From 4f3da40683337a11c513e49d043e74b1cb89948c Mon Sep 17 00:00:00 2001 From: Ivan Pegashev Date: Tue, 30 Apr 2024 01:40:50 +0300 Subject: [PATCH 1/3] feat(chat): init steps for supporting chat history and small refactoring --- src/common/panel/chat.ts | 149 +++++++++++++++++++++----- src/common/utils/state.ts | 42 ++++---- webviews/src/App.tsx | 90 +++++++++------- webviews/src/hooks/messageListener.ts | 2 +- webviews/src/hooks/useChat.ts | 36 +++++-- webviews/src/hooks/useChatMessages.ts | 11 ++ webviews/src/utilities/messageId.ts | 2 +- webviews/src/utilities/vscode.ts | 77 +++++++++++-- 8 files changed, 306 insertions(+), 103 deletions(-) create mode 100644 webviews/src/hooks/useChatMessages.ts diff --git a/src/common/panel/chat.ts b/src/common/panel/chat.ts index 6f6ccf8..2026814 100644 --- a/src/common/panel/chat.ts +++ b/src/common/panel/chat.ts @@ -4,6 +4,7 @@ import { getUri } from "../utils/getUri"; import { getNonce } from "../utils/getNonce"; import { chat } from "../chat"; import { ChatMessage } from "../prompt/promptChat"; +import { state } from "../utils/state"; export type MessageType = | { @@ -15,11 +16,36 @@ export type MessageType = | { type: "e2w-response"; id: string; - command: string; done: boolean; data: any; }; +type MessageFromWebview = + | { + type: "send-message"; + id: string; + data: ChatMessage[]; + } + | { + type: "abort-generate"; + id: string; + } + | { + type: "get-chat-history"; + id: string; + chatId: string; + } + | { + type: "save-chat-history"; + id: string; + chatId: string; + data: ChatMessage[]; + } + | { + type: "get-chats"; + id: string; + }; + export class ChatPanel implements vscode.WebviewViewProvider { private disposables: Disposable[] = []; private webview: Webview | undefined; @@ -50,6 +76,7 @@ export class ChatPanel implements vscode.WebviewViewProvider { "css", "main.css", ]); + const scriptUri = getUri(webview, extensionUri, [ "webviews", "build", @@ -95,21 +122,41 @@ export class ChatPanel implements vscode.WebviewViewProvider { private setWebviewMessageListener(webview: Webview) { webview.onDidReceiveMessage( - async (message: any) => { + async (message: MessageFromWebview) => { if (message.type in this.messageCallback) { this.messageCallback[message.type](); return; } + const type = message.type; switch (type) { - case "sendMessage": + case "send-message": await this.handleStartGeneration({ + id: message.id, chatMessage: message.data, - messageId: message.messageId, - messageType: message.type, }); - return; + break; + case "get-chat-history": + await this.handleGetChatHistory({ + id: message.id, + chatId: message.chatId, + }); + break; + case "save-chat-history": + await this.handleSaveChatHistory({ + id: message.id, + chatId: message.chatId, + history: message.data, + }); + break; + case "get-chats": + await this.handleGetChats({ + id: message.id, + }); + break; + default: + break; } }, undefined, @@ -117,27 +164,17 @@ export class ChatPanel implements vscode.WebviewViewProvider { ); } - private addMessageListener( - commandOrMessageId: string, - callback: (message: any) => void - ) { - this.messageCallback[commandOrMessageId] = callback; - } - private async handleStartGeneration({ - messageId, - messageType, + id, chatMessage, }: { - messageId: string; - messageType: string; + id: string; chatMessage: ChatMessage[]; }) { - const sendResponse = (messageToResponse: any, done: boolean) => { + const sendResponse = (messageToResponse: string, done: boolean) => { this.postMessage({ type: "e2w-response", - id: messageId, - command: messageType, + id: id, data: messageToResponse, done: done, }); @@ -158,8 +195,74 @@ export class ChatPanel implements vscode.WebviewViewProvider { sendResponse("", true); } + private async handleGetChatHistory({ + chatId, + id, + }: { + chatId: string; + id: string; + }) { + const sendResponse = ( + messageToResponse: ChatMessage[] | null, + done: boolean + ) => { + this.postMessage({ + type: "e2w-response", + id: id, + data: messageToResponse, + done: done, + }); + }; + + const history = state.global.get(`chat-${chatId}`); + if (history) { + sendResponse(history, true); + } else { + sendResponse(null, true); + } + } + + private async handleSaveChatHistory({ + chatId, + history, + id, + }: { + chatId: string; + history: ChatMessage[]; + id: string; + }) { + await state.global.update(`chat-${chatId}`, history); + await this.postMessage({ + type: "e2w-response", + id: id, + data: "", + done: true, + }); + } + + private async handleGetChats({ id }: { id: string }) { + const chats = state.global.getChats(); + await this.postMessage({ + type: "e2w-response", + id: id, + data: chats, + done: true, + }); + } + + private addMessageListener( + commandOrMessageId: string, + callback: (message: any) => void + ) { + this.messageCallback[commandOrMessageId] = callback; + } + + private async postMessage(message: MessageType) { + await this.webview?.postMessage(message); + } + public async sendMessageToWebview( - command: MessageType["command"], + command: string, data: MessageType["data"] ) { const message: MessageType = { @@ -170,8 +273,4 @@ export class ChatPanel implements vscode.WebviewViewProvider { }; await this.postMessage(message); } - - private async postMessage(message: MessageType) { - await this.webview?.postMessage(message); - } } diff --git a/src/common/utils/state.ts b/src/common/utils/state.ts index c327a4f..08d6170 100644 --- a/src/common/utils/state.ts +++ b/src/common/utils/state.ts @@ -1,23 +1,17 @@ import * as vscode from "vscode"; import type { Spec } from "../download"; +import { ChatMessage } from "../prompt/promptChat"; const StateValues = { - inlineSuggestModeAuto: { - default: true, - }, - serverSpec: { - default: null, - }, + inlineSuggestModeAuto: true, + serverSpec: null, +}; +type StateValuesType = { + inlineSuggestModeAuto: boolean; + serverSpec: Spec | null; + [key: `chat-${string}`]: ChatMessage[] | undefined; }; -interface StateValuesType extends Record { - inlineSuggestModeAuto: { - possibleValues: boolean; - }; - serverSpec: { - possibleValues: Spec | null; - }; -} class State { state?: vscode.Memento; constructor() {} @@ -26,18 +20,30 @@ class State { this.state = state; } - public get( + public get( key: T - ): StateValuesType[T]["possibleValues"] { - return this.state?.get(key) ?? StateValues[key]["default"]; + ): StateValuesType[T] { + // @ts-ignore + return this.state?.get(key) ?? StateValues[key]; } public async update( key: T, - value: StateValuesType[T]["possibleValues"] + value: StateValuesType[T] ) { await this.state?.update(key, value); } + + public getChats(): ChatMessage[][] { + const allKeys = (this.state?.keys() || + []) as unknown as (keyof StateValuesType)[]; + + return allKeys + .filter((key) => key.startsWith("chat-")) + .map((key) => { + return this.get(key as `chat-${string}`) as ChatMessage[]; + }); + } } export const state = { diff --git a/webviews/src/App.tsx b/webviews/src/App.tsx index d2e9d0e..b649521 100644 --- a/webviews/src/App.tsx +++ b/webviews/src/App.tsx @@ -6,6 +6,8 @@ import { AutoScrollDown } from "./components/AutoScrollDown"; import { useMessageListener } from "./hooks/messageListener"; import TextArea from "./components/TextArea"; import { useChat } from "./hooks/useChat"; +import { useEffect } from "react"; +import { vscode } from "./utilities/vscode"; export const App = () => { const { @@ -18,51 +20,57 @@ export const App = () => { stop, } = useChat(); - useMessageListener("startNewChat", () => { + useMessageListener("start-new-chat", () => { startNewChat(); }); + useEffect(() => { + const getChats = async () => { + const chats = await vscode.getChats(); + console.log(chats); + }; + getChats(); + }, []); + return ( - <> -
-
- - {chatMessages.map((message) => ( - - ))} - -
-
-
-
-
- +
+
+ + {chatMessages.map((message) => ( + + ))} + +
+
+
+
-
- + +
+
); }; diff --git a/webviews/src/hooks/messageListener.ts b/webviews/src/hooks/messageListener.ts index 6e54896..d8bef4d 100644 --- a/webviews/src/hooks/messageListener.ts +++ b/webviews/src/hooks/messageListener.ts @@ -2,7 +2,7 @@ import { useEffect } from "react"; import { vscode } from "../utilities/vscode"; export const useMessageListener = ( - command: "startNewChat", + command: "start-new-chat", callback: (message: any) => void ) => { useEffect(() => { diff --git a/webviews/src/hooks/useChat.ts b/webviews/src/hooks/useChat.ts index d5a3f95..e56caac 100644 --- a/webviews/src/hooks/useChat.ts +++ b/webviews/src/hooks/useChat.ts @@ -1,6 +1,7 @@ -import { useCallback, useRef, useState } from "react"; -import { randomMessageId } from "../utilities/messageId"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { randomId } from "../utilities/messageId"; import { vscode } from "../utilities/vscode"; +import { useChatMessages } from "./useChatMessages"; export type ChatMessage = { role: string; @@ -8,16 +9,31 @@ export type ChatMessage = { chatMessageId: string; }; -export const useChat = () => { - const [chatMessages, setChatMessages] = useState([]); +export const useChat = (chatId?: string) => { + const { chatMessages, setChatMessages } = useChatMessages(); const [input, setInput] = useState(""); const [isLoading, setIsLoading] = useState(false); + const [chatIdLocal, setChatIdLocal] = useState(chatId || randomId()); const abortController = useRef(new AbortController()); + useEffect(() => { + const getChatHistory = async () => { + if (chatId) { + const history = await vscode.getChatHistory(chatId); + if (history) { + setChatMessages(history); + } + } + }; + if (chatId) { + getChatHistory(); + } + }, [chatId, setChatMessages]); + const sendMessage = async (chatHistoryLocal: ChatMessage[]) => { - const messageId = randomMessageId(); + const messageId = randomId(); for await (const newMessage of vscode.startGeneration(chatHistoryLocal, { signal: abortController.current.signal, })) { @@ -40,6 +56,12 @@ export const useChat = () => { ]; }); } + setChatMessages((chatHistoryLocal) => { + (async () => { + await vscode.saveChatHistory(chatIdLocal, chatHistoryLocal); + })(); + return chatHistoryLocal; + }); setIsLoading(false); }; @@ -55,7 +77,7 @@ export const useChat = () => { } setChatMessages((value) => { - const messageId = randomMessageId(); + const messageId = randomId(); const newChatMessage = [ ...value, @@ -80,7 +102,7 @@ export const useChat = () => { const startNewChat = useCallback(() => { setChatMessages([]); - }, []); + }, [setChatMessages]); return { chatMessages, diff --git a/webviews/src/hooks/useChatMessages.ts b/webviews/src/hooks/useChatMessages.ts new file mode 100644 index 0000000..e672f43 --- /dev/null +++ b/webviews/src/hooks/useChatMessages.ts @@ -0,0 +1,11 @@ +import { useState } from "react"; +import { ChatMessage } from "./useChat"; + +export const useChatMessages = () => { + const [chatMessages, setChatMessages] = useState([]); + + return { + chatMessages, + setChatMessages, + }; +}; diff --git a/webviews/src/utilities/messageId.ts b/webviews/src/utilities/messageId.ts index ba87346..a56bf6c 100644 --- a/webviews/src/utilities/messageId.ts +++ b/webviews/src/utilities/messageId.ts @@ -1 +1 @@ -export const randomMessageId: () => string = () => global.crypto.randomUUID(); +export const randomId: () => string = () => global.crypto.randomUUID(); diff --git a/webviews/src/utilities/vscode.ts b/webviews/src/utilities/vscode.ts index 83d35e5..475b16a 100644 --- a/webviews/src/utilities/vscode.ts +++ b/webviews/src/utilities/vscode.ts @@ -1,5 +1,5 @@ import type { WebviewApi } from "vscode-webview"; -import { randomMessageId } from "./messageId"; +import { randomId } from "./messageId"; import { ChatMessage } from "../hooks/useChat"; import { Transform } from "./transformCallback2AsyncGenerator"; @@ -12,12 +12,33 @@ export type MessageType = } | { type: "e2w-response"; - command: string; id: string; done: boolean; data: any; }; +type MessageToExtention = + | { + type: "send-message"; + data: ChatMessage[]; + } + | { + type: "abort-generate"; + id: string; + } + | { + type: "get-chat-history"; + chatId: string; + } + | { + type: "save-chat-history"; + chatId: string; + data: ChatMessage[]; + } + | { + type: "get-chats"; + }; + class VSCodeAPIWrapper { private readonly vsCodeApi: WebviewApi | undefined; private messageCallback: Record = {}; @@ -48,23 +69,23 @@ class VSCodeAPIWrapper { } public postMessageCallback( - message: { type: string; data: any }, + message: MessageToExtention, messageCallback?: (message: any) => void, config?: { signal?: AbortSignal } ) { if (this.vsCodeApi) { - const messageId = randomMessageId(); + const id = randomId(); if (messageCallback) { - this.addMessageListener(messageId, messageCallback); + this.addMessageListener(id, messageCallback); } config?.signal?.addEventListener("abort", () => { - this.abortOperation(messageId); + this.abortOperation(); }); this.vsCodeApi.postMessage({ ...message, - messageId, + id, }); } else { console.log(message); @@ -81,7 +102,7 @@ class VSCodeAPIWrapper { this.postMessageCallback( { data: chatHistory, - type: "sendMessage", + type: "send-message", }, (message) => { if (message.done) { @@ -98,6 +119,43 @@ class VSCodeAPIWrapper { return transform.stream(); } + public getChatHistory(chatId: string) { + return new Promise((resolve) => { + this.postMessageCallback( + { + type: "get-chat-history", + chatId: chatId, + }, + (message) => { + resolve(message.data); + } + ); + }); + } + + public saveChatHistory(chatId: string, history: ChatMessage[]) { + return new Promise((resolve) => { + this.postMessageCallback( + { + type: "save-chat-history", + chatId: chatId, + data: history, + }, + (message) => { + resolve(message.data); + } + ); + }); + } + + public getChats() { + return new Promise((resolve) => { + this.postMessageCallback({ + type: "get-chats", + }); + }); + } + public addMessageListener( commandOrMessageId: string, callback: (message: any) => void @@ -105,10 +163,9 @@ class VSCodeAPIWrapper { this.messageCallback[commandOrMessageId] = callback; } - private abortOperation(messageId: string) { + private abortOperation() { this.vsCodeApi?.postMessage({ type: "abort-generate", - id: messageId, }); } } From 005c3e0d514ec8ba02a6418cf7b708d06ffbcf5c Mon Sep 17 00:00:00 2001 From: Ivan Pegashev Date: Tue, 30 Apr 2024 18:13:31 +0300 Subject: [PATCH 2/3] feat(chat): next step --- src/common/panel/chat.ts | 43 +++++++++++++------------------- src/common/prompt/promptChat.ts | 8 ++++++ src/common/utils/state.ts | 8 +++--- webviews/src/hooks/useChat.ts | 21 +++++++++++++--- webviews/src/utilities/vscode.ts | 20 +++++++-------- 5 files changed, 56 insertions(+), 44 deletions(-) diff --git a/src/common/panel/chat.ts b/src/common/panel/chat.ts index 2026814..36def95 100644 --- a/src/common/panel/chat.ts +++ b/src/common/panel/chat.ts @@ -3,7 +3,7 @@ import * as vscode from "vscode"; import { getUri } from "../utils/getUri"; import { getNonce } from "../utils/getNonce"; import { chat } from "../chat"; -import { ChatMessage } from "../prompt/promptChat"; +import { Chat, ChatMessage } from "../prompt/promptChat"; import { state } from "../utils/state"; export type MessageType = @@ -20,10 +20,9 @@ export type MessageType = data: any; }; -type MessageFromWebview = +type MessageToExtention = | { type: "send-message"; - id: string; data: ChatMessage[]; } | { @@ -31,21 +30,22 @@ type MessageFromWebview = id: string; } | { - type: "get-chat-history"; - id: string; + type: "get-chat"; chatId: string; } | { - type: "save-chat-history"; - id: string; + type: "save-chat"; chatId: string; - data: ChatMessage[]; + data: Chat; } | { type: "get-chats"; - id: string; }; +type MessageFromWebview = MessageToExtention & { + id: string; +}; + export class ChatPanel implements vscode.WebviewViewProvider { private disposables: Disposable[] = []; private webview: Webview | undefined; @@ -137,14 +137,14 @@ export class ChatPanel implements vscode.WebviewViewProvider { chatMessage: message.data, }); break; - case "get-chat-history": - await this.handleGetChatHistory({ + case "get-chat": + await this.handleGetChat({ id: message.id, chatId: message.chatId, }); break; - case "save-chat-history": - await this.handleSaveChatHistory({ + case "save-chat": + await this.handleSaveChat({ id: message.id, chatId: message.chatId, history: message.data, @@ -195,17 +195,8 @@ export class ChatPanel implements vscode.WebviewViewProvider { sendResponse("", true); } - private async handleGetChatHistory({ - chatId, - id, - }: { - chatId: string; - id: string; - }) { - const sendResponse = ( - messageToResponse: ChatMessage[] | null, - done: boolean - ) => { + private async handleGetChat({ chatId, id }: { chatId: string; id: string }) { + const sendResponse = (messageToResponse: Chat | null, done: boolean) => { this.postMessage({ type: "e2w-response", id: id, @@ -222,13 +213,13 @@ export class ChatPanel implements vscode.WebviewViewProvider { } } - private async handleSaveChatHistory({ + private async handleSaveChat({ chatId, history, id, }: { chatId: string; - history: ChatMessage[]; + history: Chat; id: string; }) { await state.global.update(`chat-${chatId}`, history); diff --git a/src/common/prompt/promptChat.ts b/src/common/prompt/promptChat.ts index e3e5452..263d548 100644 --- a/src/common/prompt/promptChat.ts +++ b/src/common/prompt/promptChat.ts @@ -1,6 +1,14 @@ export type ChatMessage = { role: string; content: string; + // chatMessageId: string; +}; + +export type Chat = { + messages: ChatMessage[]; + chatId: string; + date: number; + title: string; }; const promptBaseDefault = `You are an AI programming assistant, utilizing the DeepSeek Coder model, developed by DeepSeek Company, and you only answer questions related to computer science. For politically sensitive questions, security and privacy issues, and other non-computer science questions, you will refuse to answer. diff --git a/src/common/utils/state.ts b/src/common/utils/state.ts index 08d6170..33b4ab6 100644 --- a/src/common/utils/state.ts +++ b/src/common/utils/state.ts @@ -1,6 +1,6 @@ import * as vscode from "vscode"; import type { Spec } from "../download"; -import { ChatMessage } from "../prompt/promptChat"; +import { Chat } from "../prompt/promptChat"; const StateValues = { inlineSuggestModeAuto: true, @@ -9,7 +9,7 @@ const StateValues = { type StateValuesType = { inlineSuggestModeAuto: boolean; serverSpec: Spec | null; - [key: `chat-${string}`]: ChatMessage[] | undefined; + [key: `chat-${string}`]: Chat | undefined; }; class State { @@ -34,14 +34,14 @@ class State { await this.state?.update(key, value); } - public getChats(): ChatMessage[][] { + public getChats(): Chat[] { const allKeys = (this.state?.keys() || []) as unknown as (keyof StateValuesType)[]; return allKeys .filter((key) => key.startsWith("chat-")) .map((key) => { - return this.get(key as `chat-${string}`) as ChatMessage[]; + return this.get(key as `chat-${string}`) as Chat; }); } } diff --git a/webviews/src/hooks/useChat.ts b/webviews/src/hooks/useChat.ts index e56caac..41ca5aa 100644 --- a/webviews/src/hooks/useChat.ts +++ b/webviews/src/hooks/useChat.ts @@ -9,6 +9,13 @@ export type ChatMessage = { chatMessageId: string; }; +export type Chat = { + messages: ChatMessage[]; + chatId: string; + date: number; + title: string; +}; + export const useChat = (chatId?: string) => { const { chatMessages, setChatMessages } = useChatMessages(); @@ -21,9 +28,9 @@ export const useChat = (chatId?: string) => { useEffect(() => { const getChatHistory = async () => { if (chatId) { - const history = await vscode.getChatHistory(chatId); - if (history) { - setChatMessages(history); + const chat = await vscode.getChat(chatId); + if (chat) { + setChatMessages(chat.messages); } } }; @@ -58,7 +65,13 @@ export const useChat = (chatId?: string) => { } setChatMessages((chatHistoryLocal) => { (async () => { - await vscode.saveChatHistory(chatIdLocal, chatHistoryLocal); + const chat = { + chatId: chatIdLocal, + date: Date.now(), + messages: chatHistoryLocal, + title: "Chat with AI", + } satisfies Chat; + await vscode.saveChatHistory(chatIdLocal, chat); })(); return chatHistoryLocal; }); diff --git a/webviews/src/utilities/vscode.ts b/webviews/src/utilities/vscode.ts index 475b16a..d8901f7 100644 --- a/webviews/src/utilities/vscode.ts +++ b/webviews/src/utilities/vscode.ts @@ -1,6 +1,6 @@ import type { WebviewApi } from "vscode-webview"; import { randomId } from "./messageId"; -import { ChatMessage } from "../hooks/useChat"; +import { Chat, ChatMessage } from "../hooks/useChat"; import { Transform } from "./transformCallback2AsyncGenerator"; export type MessageType = @@ -27,13 +27,13 @@ type MessageToExtention = id: string; } | { - type: "get-chat-history"; + type: "get-chat"; chatId: string; } | { - type: "save-chat-history"; + type: "save-chat"; chatId: string; - data: ChatMessage[]; + data: Chat; } | { type: "get-chats"; @@ -119,11 +119,11 @@ class VSCodeAPIWrapper { return transform.stream(); } - public getChatHistory(chatId: string) { - return new Promise((resolve) => { + public getChat(chatId: string) { + return new Promise((resolve) => { this.postMessageCallback( { - type: "get-chat-history", + type: "get-chat", chatId: chatId, }, (message) => { @@ -133,11 +133,11 @@ class VSCodeAPIWrapper { }); } - public saveChatHistory(chatId: string, history: ChatMessage[]) { + public saveChatHistory(chatId: string, history: Chat) { return new Promise((resolve) => { this.postMessageCallback( { - type: "save-chat-history", + type: "save-chat", chatId: chatId, data: history, }, @@ -149,7 +149,7 @@ class VSCodeAPIWrapper { } public getChats() { - return new Promise((resolve) => { + return new Promise((resolve) => { this.postMessageCallback({ type: "get-chats", }); From 3978c3127165fbab06ee79b48648b7ebd797d257 Mon Sep 17 00:00:00 2001 From: Ivan Pegashev Date: Sun, 5 May 2024 02:15:37 +0300 Subject: [PATCH 3/3] feat(chat): update --- package.json | 3 +- src/common/panel/chat.ts | 44 +++++++++ src/common/utils/state.ts | 14 +++ src/extension.ts | 2 +- webviews/package-lock.json | 95 ++++++++++++++++++- webviews/package.json | 6 +- .../index.tsx | 0 .../index.tsx | 2 +- .../{ChatMessage => chat-message}/index.tsx | 0 .../styles.module.css | 0 .../{TextArea => text-area}/index.tsx | 0 .../{TextArea => text-area}/styles.module.css | 0 webviews/src/hooks/messageListener.ts | 7 +- webviews/src/index.css | 12 +++ webviews/src/index.tsx | 37 +++++++- .../src/{App.tsx => routes/chat/index.tsx} | 48 +++++----- .../{App.css => routes/chat/style.module.css} | 29 +++--- webviews/src/routes/chatsHistory/index.tsx | 47 +++++++++ .../src/routes/chatsHistory/style.module.css | 19 ++++ webviews/src/routes/root/root.tsx | 24 +++++ webviews/src/utilities/vscode.ts | 80 ++++++++++++++-- 21 files changed, 416 insertions(+), 53 deletions(-) rename webviews/src/components/{AutoScrollDown => auto-scroll-down}/index.tsx (100%) rename webviews/src/components/{ChatHelloMessage => chat-hello-message}/index.tsx (85%) rename webviews/src/components/{ChatMessage => chat-message}/index.tsx (100%) rename webviews/src/components/{ChatMessage => chat-message}/styles.module.css (100%) rename webviews/src/components/{TextArea => text-area}/index.tsx (100%) rename webviews/src/components/{TextArea => text-area}/styles.module.css (100%) create mode 100644 webviews/src/index.css rename webviews/src/{App.tsx => routes/chat/index.tsx} (52%) rename webviews/src/{App.css => routes/chat/style.module.css} (71%) create mode 100644 webviews/src/routes/chatsHistory/index.tsx create mode 100644 webviews/src/routes/chatsHistory/style.module.css create mode 100644 webviews/src/routes/root/root.tsx diff --git a/package.json b/package.json index a44d67c..b79416b 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,8 @@ "view/title": [ { "command": "firecoder.startNewChat", - "group": "navigation@1" + "group": "navigation", + "when": "view === firecoder.chat-gui" } ] }, diff --git a/src/common/panel/chat.ts b/src/common/panel/chat.ts index 36def95..68b9464 100644 --- a/src/common/panel/chat.ts +++ b/src/common/panel/chat.ts @@ -40,6 +40,13 @@ type MessageToExtention = } | { type: "get-chats"; + } + | { + type: "delete-chat"; + chatId: string; + } + | { + type: "delete-chats"; }; type MessageFromWebview = MessageToExtention & { @@ -150,6 +157,17 @@ export class ChatPanel implements vscode.WebviewViewProvider { history: message.data, }); break; + case "delete-chat": + await this.handleDeleteChat({ + id: message.id, + chatId: message.chatId, + }); + break; + case "delete-chats": + await this.handleDeleteChats({ + id: message.id, + }); + break; case "get-chats": await this.handleGetChats({ id: message.id, @@ -231,6 +249,32 @@ export class ChatPanel implements vscode.WebviewViewProvider { }); } + private async handleDeleteChat({ + chatId, + id, + }: { + chatId: string; + id: string; + }) { + await state.global.delete(`chat-${chatId}`); + await this.postMessage({ + type: "e2w-response", + id: id, + data: "", + done: true, + }); + } + + private async handleDeleteChats({ id }: { id: string }) { + await state.global.deleteChats(); + await this.postMessage({ + type: "e2w-response", + id: id, + data: "", + done: true, + }); + } + private async handleGetChats({ id }: { id: string }) { const chats = state.global.getChats(); await this.postMessage({ diff --git a/src/common/utils/state.ts b/src/common/utils/state.ts index 33b4ab6..5d7cc14 100644 --- a/src/common/utils/state.ts +++ b/src/common/utils/state.ts @@ -44,6 +44,20 @@ class State { return this.get(key as `chat-${string}`) as Chat; }); } + public async delete(key: T) { + await this.state?.update(key, undefined); + } + + public async deleteChats() { + const allKeys = (this.state?.keys() || + []) as unknown as (keyof StateValuesType)[]; + + await Promise.all( + allKeys + .filter((key) => key.startsWith("chat-")) + .map((key) => this.delete(key as `chat-${string}`)) + ); + } } export const state = { diff --git a/src/extension.ts b/src/extension.ts index b132532..a683f94 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -29,7 +29,7 @@ export async function activate(context: vscode.ExtensionContext) { ); context.subscriptions.push( vscode.commands.registerCommand("firecoder.startNewChat", async () => { - await provider.sendMessageToWebview("startNewChat", {}); + await provider.sendMessageToWebview("start-new-chat", {}); }) ); diff --git a/webviews/package-lock.json b/webviews/package-lock.json index 2189ebd..a134925 100644 --- a/webviews/package-lock.json +++ b/webviews/package-lock.json @@ -10,10 +10,14 @@ "dependencies": { "@vscode/webview-ui-toolkit": "^1.2.2", "classnames": "^2.5.1", + "localforage": "^1.10.0", + "match-sorter": "^6.3.4", "react": "^18.2.0", "react-dom": "^18.2.0", "react-markdown": "^9.0.1", - "react-syntax-highlighter": "^15.5.0" + "react-router-dom": "^6.23.0", + "react-syntax-highlighter": "^15.5.0", + "sort-by": "^1.2.0" }, "devDependencies": { "@types/node": "^12.20.37", @@ -3619,6 +3623,14 @@ } } }, + "node_modules/@remix-run/router": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.16.0.tgz", + "integrity": "sha512-Quz1KOffeEf/zwkCBM3kBtH4ZoZ+pT3xIXBG4PPW/XFtDP7EGhtTiC2+gpL9GnR7+Qdet5Oa6cYSvwKYg6kN9Q==", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@rollup/plugin-babel": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", @@ -10151,6 +10163,11 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" + }, "node_modules/immer": { "version": "9.0.21", "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz", @@ -13395,6 +13412,14 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz", + "integrity": "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lilconfig": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", @@ -13433,6 +13458,14 @@ "node": ">=8.9.0" } }, + "node_modules/localforage": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz", + "integrity": "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==", + "dependencies": { + "lie": "3.1.1" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -13577,6 +13610,15 @@ "tmpl": "1.0.5" } }, + "node_modules/match-sorter": { + "version": "6.3.4", + "resolved": "https://registry.npmjs.org/match-sorter/-/match-sorter-6.3.4.tgz", + "integrity": "sha512-jfZW7cWS5y/1xswZo8VBOdudUiSd9nifYRWphc9M5D/ee4w4AoXLgBEdRbgVaxbMuagBPeUC5y2Hi8DO6o9aDg==", + "dependencies": { + "@babel/runtime": "^7.23.8", + "remove-accents": "0.5.0" + } + }, "node_modules/mdast-util-from-markdown": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.0.tgz", @@ -14589,6 +14631,14 @@ "node": ">= 0.4" } }, + "node_modules/object-path": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/object-path/-/object-path-0.6.0.tgz", + "integrity": "sha512-fxrwsCFi3/p+LeLOAwo/wyRMODZxdGBtUlWRzsEpsUVrisZbEfZ21arxLGfaWfcnqb8oHPNihIb4XPE8CQPN5A==", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/object.assign": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.5.tgz", @@ -16955,6 +17005,36 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.23.0.tgz", + "integrity": "sha512-wPMZ8S2TuPadH0sF5irFGjkNLIcRvOSaEe7v+JER8508dyJumm6XZB1u5kztlX0RVq6AzRVndzqcUh6sFIauzA==", + "dependencies": { + "@remix-run/router": "1.16.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.23.0.tgz", + "integrity": "sha512-Q9YaSYvubwgbal2c9DJKfx6hTNoBp3iJDsl+Duva/DwxoJH+OTXkxGpql4iUK2sla/8z4RpjAm6EWx1qUDuopQ==", + "dependencies": { + "@remix-run/router": "1.16.0", + "react-router": "6.23.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, "node_modules/react-scripts": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-5.0.1.tgz", @@ -17359,6 +17439,11 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remove-accents": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz", + "integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==" + }, "node_modules/renderkid": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz", @@ -18526,6 +18611,14 @@ "websocket-driver": "^0.7.4" } }, + "node_modules/sort-by": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/sort-by/-/sort-by-1.2.0.tgz", + "integrity": "sha512-aRyW65r3xMnf4nxJRluCg0H/woJpksU1dQxRtXYzau30sNBOmf5HACpDd9MZDhKh7ALQ5FgSOfMPwZEtUmMqcg==", + "dependencies": { + "object-path": "0.6.0" + } + }, "node_modules/source-list-map": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz", diff --git a/webviews/package.json b/webviews/package.json index dc9e674..e4d90e1 100644 --- a/webviews/package.json +++ b/webviews/package.json @@ -11,10 +11,14 @@ "dependencies": { "@vscode/webview-ui-toolkit": "^1.2.2", "classnames": "^2.5.1", + "localforage": "^1.10.0", + "match-sorter": "^6.3.4", "react": "^18.2.0", "react-dom": "^18.2.0", "react-markdown": "^9.0.1", - "react-syntax-highlighter": "^15.5.0" + "react-router-dom": "^6.23.0", + "react-syntax-highlighter": "^15.5.0", + "sort-by": "^1.2.0" }, "devDependencies": { "@types/node": "^12.20.37", diff --git a/webviews/src/components/AutoScrollDown/index.tsx b/webviews/src/components/auto-scroll-down/index.tsx similarity index 100% rename from webviews/src/components/AutoScrollDown/index.tsx rename to webviews/src/components/auto-scroll-down/index.tsx diff --git a/webviews/src/components/ChatHelloMessage/index.tsx b/webviews/src/components/chat-hello-message/index.tsx similarity index 85% rename from webviews/src/components/ChatHelloMessage/index.tsx rename to webviews/src/components/chat-hello-message/index.tsx index 641aa0d..009e4f2 100644 --- a/webviews/src/components/ChatHelloMessage/index.tsx +++ b/webviews/src/components/chat-hello-message/index.tsx @@ -1,4 +1,4 @@ -import { ChatMessage } from "../ChatMessage"; +import { ChatMessage } from "../chat-message"; const ChatHelloMessageContent = ` Hello! I'm FireCoder, your friendly AI assistant.\n diff --git a/webviews/src/components/ChatMessage/index.tsx b/webviews/src/components/chat-message/index.tsx similarity index 100% rename from webviews/src/components/ChatMessage/index.tsx rename to webviews/src/components/chat-message/index.tsx diff --git a/webviews/src/components/ChatMessage/styles.module.css b/webviews/src/components/chat-message/styles.module.css similarity index 100% rename from webviews/src/components/ChatMessage/styles.module.css rename to webviews/src/components/chat-message/styles.module.css diff --git a/webviews/src/components/TextArea/index.tsx b/webviews/src/components/text-area/index.tsx similarity index 100% rename from webviews/src/components/TextArea/index.tsx rename to webviews/src/components/text-area/index.tsx diff --git a/webviews/src/components/TextArea/styles.module.css b/webviews/src/components/text-area/styles.module.css similarity index 100% rename from webviews/src/components/TextArea/styles.module.css rename to webviews/src/components/text-area/styles.module.css diff --git a/webviews/src/hooks/messageListener.ts b/webviews/src/hooks/messageListener.ts index d8bef4d..96545fb 100644 --- a/webviews/src/hooks/messageListener.ts +++ b/webviews/src/hooks/messageListener.ts @@ -6,6 +6,9 @@ export const useMessageListener = ( callback: (message: any) => void ) => { useEffect(() => { - vscode.addMessageListener(command, callback); - }); + const removeCallback = vscode.addMessageListener(command, callback); + return () => { + removeCallback(); + }; + }, [command]); }; diff --git a/webviews/src/index.css b/webviews/src/index.css new file mode 100644 index 0000000..9381f1a --- /dev/null +++ b/webviews/src/index.css @@ -0,0 +1,12 @@ +html { + height: 100%; +} + +body { + height: 100%; + padding: 0px; +} + +#root { + height: 100%; +} diff --git a/webviews/src/index.tsx b/webviews/src/index.tsx index 16e0258..f637539 100644 --- a/webviews/src/index.tsx +++ b/webviews/src/index.tsx @@ -1,10 +1,43 @@ import React from "react"; import ReactDOM from "react-dom"; -import { App } from "./App"; +import { createMemoryRouter, RouterProvider } from "react-router-dom"; +import "./index.css"; +import Root from "./routes/root/root"; +import { ChatInstance } from "./routes/chat"; +import ChatsHistory, { + loader as ChatsHistoryLoader, +} from "./routes/chatsHistory"; + +const router = createMemoryRouter( + [ + { + path: "https://github.com/", + element: , + children: [ + { + path: "chats", + element: , + loader: ChatsHistoryLoader, + }, + { + path: "chats/new-chat", + element: , + }, + { + path: "chats/:chatId", + element: , + }, + ], + }, + ], + { + initialEntries: ["https://github.com/chats/new-chat"], + } +); ReactDOM.render( - + , document.getElementById("root") ); diff --git a/webviews/src/App.tsx b/webviews/src/routes/chat/index.tsx similarity index 52% rename from webviews/src/App.tsx rename to webviews/src/routes/chat/index.tsx index b649521..f1c2a81 100644 --- a/webviews/src/App.tsx +++ b/webviews/src/routes/chat/index.tsx @@ -1,15 +1,16 @@ +import { useNavigate, useParams } from "react-router-dom"; import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"; -import "./App.css"; -import { ChatMessage } from "./components/ChatMessage"; -import { ChatHelloMessage } from "./components/ChatHelloMessage"; -import { AutoScrollDown } from "./components/AutoScrollDown"; -import { useMessageListener } from "./hooks/messageListener"; -import TextArea from "./components/TextArea"; -import { useChat } from "./hooks/useChat"; -import { useEffect } from "react"; -import { vscode } from "./utilities/vscode"; +import { ChatMessage } from "../../components/chat-message"; +import { ChatHelloMessage } from "../../components/chat-hello-message"; +import { AutoScrollDown } from "../../components/auto-scroll-down"; +import { useMessageListener } from "../../hooks/messageListener"; +import TextArea from "../../components/text-area"; +import { useChat } from "../../hooks/useChat"; +import styles from "./style.module.css"; + +export const ChatInstance = () => { + let { chatId } = useParams() as { chatId?: string }; -export const App = () => { const { handleSubmit, isLoading, @@ -18,23 +19,22 @@ export const App = () => { setInput, startNewChat, stop, - } = useChat(); + } = useChat(chatId === "new-chat" ? undefined : chatId); useMessageListener("start-new-chat", () => { startNewChat(); }); - useEffect(() => { - const getChats = async () => { - const chats = await vscode.getChats(); - console.log(chats); - }; - getChats(); - }, []); + const navigate = useNavigate(); return ( -
-
+
+
+ navigate("https://github.com/chats")}> + + +
+
{chatMessages.map((message) => ( { ))}
-
+
-
+
-
+ ); }; diff --git a/webviews/src/App.css b/webviews/src/routes/chat/style.module.css similarity index 71% rename from webviews/src/App.css rename to webviews/src/routes/chat/style.module.css index 31d420f..ff031a6 100644 --- a/webviews/src/App.css +++ b/webviews/src/routes/chat/style.module.css @@ -1,23 +1,26 @@ -main { - display: flex; - flex-direction: column; - justify-content: space-between; - align-items: flex-start; - height: 100vh; +.chatRoot { + display: grid; + grid-template-rows: min-content 1fr min-content; + height: 100%; + grid-template-columns: 100%; } -body { - padding: 0px; +.chatBlockNavigation { + display: flex; + justify-content: flex-start; + align-items: center; + padding-left: 16px; + padding-top: 6px; } -.chat-history { +.chatHistory { width: 100%; overflow: auto; display: flex; flex-direction: column; } -.chat-input-block { +.chatInputBlock { display: flex; flex-direction: column; width: calc(100% - 40px); @@ -26,17 +29,17 @@ body { padding: 20px 20px 20px 20px; } -.chat-input { +.chatInput { flex-grow: 2; padding-right: 20px; } -.progress-container { +.progressContainer { height: 1px; width: calc(100% - 20px); } -.progress-container .progress-bit { +.progressBit { height: 1px; top: 1px; position: relative; diff --git a/webviews/src/routes/chatsHistory/index.tsx b/webviews/src/routes/chatsHistory/index.tsx new file mode 100644 index 0000000..103e97e --- /dev/null +++ b/webviews/src/routes/chatsHistory/index.tsx @@ -0,0 +1,47 @@ +import { useLoaderData, useNavigate } from "react-router-dom"; +import { Chat } from "../../hooks/useChat"; +import { vscode } from "../../utilities/vscode"; +import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"; +import styles from "./style.module.css"; + +export async function loader() { + const chats = await vscode.getChats(); + return chats; +} + +const ChatsHistory = () => { + const chats = useLoaderData() as Chat[]; + + const navigate = useNavigate(); + + return ( +
+
+ navigate("https://github.com/chats/new-chat")}> + Open New Chat + + vscode.deleteChats()} + > + Remove All Chats + +
+
+ {chats.reverse().map((chat) => ( +
+

{chat.title}

+ navigate(`/chats/${chat.chatId}`)} + > + Open Chat + +
+ ))} +
+
+ ); +}; + +export default ChatsHistory; diff --git a/webviews/src/routes/chatsHistory/style.module.css b/webviews/src/routes/chatsHistory/style.module.css new file mode 100644 index 0000000..12dee54 --- /dev/null +++ b/webviews/src/routes/chatsHistory/style.module.css @@ -0,0 +1,19 @@ +.chatHistoryBlockNavigation { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px; +} + +.chatHistoryBlock { + display: flex; + flex-direction: column; +} + +.chatHistoryChat { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + padding: 16px; +} diff --git a/webviews/src/routes/root/root.tsx b/webviews/src/routes/root/root.tsx new file mode 100644 index 0000000..2fe169c --- /dev/null +++ b/webviews/src/routes/root/root.tsx @@ -0,0 +1,24 @@ +import { useEffect } from "react"; +import { Outlet, useLocation, useNavigate } from "react-router-dom"; +import { useMessageListener } from "../../hooks/messageListener"; + +export default function Root() { + let location = useLocation(); + + useEffect(() => { + console.log(location); + }, [location]); + + const navigate = useNavigate(); + + useMessageListener("start-new-chat", () => { + console.log("callback start-new-chat"); + navigate("https://github.com/chats/new-chat"); + }); + + return ( + <> + + + ); +} diff --git a/webviews/src/utilities/vscode.ts b/webviews/src/utilities/vscode.ts index d8901f7..7b63b47 100644 --- a/webviews/src/utilities/vscode.ts +++ b/webviews/src/utilities/vscode.ts @@ -37,11 +37,21 @@ type MessageToExtention = } | { type: "get-chats"; + } + | { + type: "delete-chat"; + chatId: string; + } + | { + type: "delete-chats"; }; class VSCodeAPIWrapper { private readonly vsCodeApi: WebviewApi | undefined; - private messageCallback: Record = {}; + private messageCallback: Record< + string, + Record any> + > = {}; constructor() { if (typeof acquireVsCodeApi === "function") { @@ -50,11 +60,22 @@ class VSCodeAPIWrapper { window.addEventListener("message", (message) => { const newMessage = (message as MessageEvent).data; + const callCallbacks = (commandOrMessageId: string, message: any) => { + if (commandOrMessageId in this.messageCallback) { + const callbacks = Object.values( + this.messageCallback[commandOrMessageId] + ); + callbacks.forEach((callback) => { + callback(newMessage); + }); + } + }; + if ( newMessage.type === "e2w-response" && newMessage.id in this.messageCallback ) { - this.messageCallback[newMessage.id](newMessage); + callCallbacks(newMessage.id, newMessage); return; } @@ -62,7 +83,7 @@ class VSCodeAPIWrapper { newMessage.type === "e2w" && newMessage.command in this.messageCallback ) { - this.messageCallback[newMessage.command](newMessage); + callCallbacks(newMessage.command, newMessage); return; } }); @@ -150,9 +171,41 @@ class VSCodeAPIWrapper { public getChats() { return new Promise((resolve) => { - this.postMessageCallback({ - type: "get-chats", - }); + this.postMessageCallback( + { + type: "get-chats", + }, + (message) => { + resolve(message.data); + } + ); + }); + } + + public deleteChat(chatId: string) { + return new Promise((resolve) => { + this.postMessageCallback( + { + type: "delete-chat", + chatId: chatId, + }, + (message) => { + resolve(message.data); + } + ); + }); + } + + public deleteChats() { + return new Promise((resolve) => { + this.postMessageCallback( + { + type: "delete-chats", + }, + (message) => { + resolve(message.data); + } + ); }); } @@ -160,7 +213,20 @@ class VSCodeAPIWrapper { commandOrMessageId: string, callback: (message: any) => void ) { - this.messageCallback[commandOrMessageId] = callback; + const callbackId = randomId(); + if (commandOrMessageId in this.messageCallback) { + this.messageCallback[commandOrMessageId][callbackId] = callback; + } else { + this.messageCallback[commandOrMessageId] = { + [callbackId]: callback, + }; + } + // remove callback on dispose + return () => { + if (commandOrMessageId in this.messageCallback) { + delete this.messageCallback[commandOrMessageId][callbackId]; + } + }; } private abortOperation() {