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 6f6ccf8..68b9464 100644 --- a/src/common/panel/chat.ts +++ b/src/common/panel/chat.ts @@ -3,7 +3,8 @@ 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 = | { @@ -15,11 +16,43 @@ export type MessageType = | { type: "e2w-response"; id: string; - command: string; done: boolean; data: any; }; +type MessageToExtention = + | { + type: "send-message"; + data: ChatMessage[]; + } + | { + type: "abort-generate"; + id: string; + } + | { + type: "get-chat"; + chatId: string; + } + | { + type: "save-chat"; + chatId: string; + data: Chat; + } + | { + type: "get-chats"; + } + | { + type: "delete-chat"; + chatId: string; + } + | { + type: "delete-chats"; + }; + +type MessageFromWebview = MessageToExtention & { + id: string; +}; + export class ChatPanel implements vscode.WebviewViewProvider { private disposables: Disposable[] = []; private webview: Webview | undefined; @@ -50,6 +83,7 @@ export class ChatPanel implements vscode.WebviewViewProvider { "css", "main.css", ]); + const scriptUri = getUri(webview, extensionUri, [ "webviews", "build", @@ -95,21 +129,52 @@ 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": + await this.handleGetChat({ + id: message.id, + chatId: message.chatId, + }); + break; + case "save-chat": + await this.handleSaveChat({ + id: message.id, + chatId: message.chatId, + 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, + }); + break; + default: + break; } }, undefined, @@ -117,27 +182,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 +213,91 @@ export class ChatPanel implements vscode.WebviewViewProvider { sendResponse("", true); } + private async handleGetChat({ chatId, id }: { chatId: string; id: string }) { + const sendResponse = (messageToResponse: Chat | 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 handleSaveChat({ + chatId, + history, + id, + }: { + chatId: string; + history: Chat; + id: string; + }) { + await state.global.update(`chat-${chatId}`, history); + await this.postMessage({ + type: "e2w-response", + id: id, + data: "", + done: true, + }); + } + + 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({ + 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 +308,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/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 c327a4f..5d7cc14 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 { Chat } 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}`]: Chat | undefined; }; -interface StateValuesType extends Record { - inlineSuggestModeAuto: { - possibleValues: boolean; - }; - serverSpec: { - possibleValues: Spec | null; - }; -} class State { state?: vscode.Memento; constructor() {} @@ -26,18 +20,44 @@ 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(): 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 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/App.tsx b/webviews/src/App.tsx deleted file mode 100644 index d2e9d0e..0000000 --- a/webviews/src/App.tsx +++ /dev/null @@ -1,68 +0,0 @@ -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"; - -export const App = () => { - const { - handleSubmit, - isLoading, - chatMessages, - input, - setInput, - startNewChat, - stop, - } = useChat(); - - useMessageListener("startNewChat", () => { - startNewChat(); - }); - - return ( - <> -
-
- - {chatMessages.map((message) => ( - - ))} - -
-
-
-
-
- -
-
- - ); -}; 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 6e54896..96545fb 100644 --- a/webviews/src/hooks/messageListener.ts +++ b/webviews/src/hooks/messageListener.ts @@ -2,10 +2,13 @@ import { useEffect } from "react"; import { vscode } from "../utilities/vscode"; export const useMessageListener = ( - command: "startNewChat", + command: "start-new-chat", callback: (message: any) => void ) => { useEffect(() => { - vscode.addMessageListener(command, callback); - }); + const removeCallback = vscode.addMessageListener(command, callback); + return () => { + removeCallback(); + }; + }, [command]); }; diff --git a/webviews/src/hooks/useChat.ts b/webviews/src/hooks/useChat.ts index d5a3f95..41ca5aa 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,38 @@ export type ChatMessage = { chatMessageId: string; }; -export const useChat = () => { - const [chatMessages, setChatMessages] = useState([]); +export type Chat = { + messages: ChatMessage[]; + chatId: string; + date: number; + title: string; +}; + +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 chat = await vscode.getChat(chatId); + if (chat) { + setChatMessages(chat.messages); + } + } + }; + 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 +63,18 @@ export const useChat = () => { ]; }); } + setChatMessages((chatHistoryLocal) => { + (async () => { + const chat = { + chatId: chatIdLocal, + date: Date.now(), + messages: chatHistoryLocal, + title: "Chat with AI", + } satisfies Chat; + await vscode.saveChatHistory(chatIdLocal, chat); + })(); + return chatHistoryLocal; + }); setIsLoading(false); }; @@ -55,7 +90,7 @@ export const useChat = () => { } setChatMessages((value) => { - const messageId = randomMessageId(); + const messageId = randomId(); const newChatMessage = [ ...value, @@ -80,7 +115,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/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: "/", + element: , + children: [ + { + path: "chats", + element: , + loader: ChatsHistoryLoader, + }, + { + path: "chats/new-chat", + element: , + }, + { + path: "chats/:chatId", + element: , + }, + ], + }, + ], + { + initialEntries: ["/chats/new-chat"], + } +); ReactDOM.render( - + , document.getElementById("root") ); diff --git a/webviews/src/routes/chat/index.tsx b/webviews/src/routes/chat/index.tsx new file mode 100644 index 0000000..f1c2a81 --- /dev/null +++ b/webviews/src/routes/chat/index.tsx @@ -0,0 +1,76 @@ +import { useNavigate, useParams } from "react-router-dom"; +import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"; +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 }; + + const { + handleSubmit, + isLoading, + chatMessages, + input, + setInput, + startNewChat, + stop, + } = useChat(chatId === "new-chat" ? undefined : chatId); + + useMessageListener("start-new-chat", () => { + startNewChat(); + }); + + const navigate = useNavigate(); + + return ( +
+
+ navigate("/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("/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("/chats/new-chat"); + }); + + return ( + <> + + + ); +} 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..7b63b47 100644 --- a/webviews/src/utilities/vscode.ts +++ b/webviews/src/utilities/vscode.ts @@ -1,6 +1,6 @@ import type { WebviewApi } from "vscode-webview"; -import { randomMessageId } from "./messageId"; -import { ChatMessage } from "../hooks/useChat"; +import { randomId } from "./messageId"; +import { Chat, ChatMessage } from "../hooks/useChat"; import { Transform } from "./transformCallback2AsyncGenerator"; export type MessageType = @@ -12,15 +12,46 @@ 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"; + chatId: string; + } + | { + type: "save-chat"; + chatId: string; + data: Chat; + } + | { + 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") { @@ -29,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; } @@ -41,30 +83,30 @@ class VSCodeAPIWrapper { newMessage.type === "e2w" && newMessage.command in this.messageCallback ) { - this.messageCallback[newMessage.command](newMessage); + callCallbacks(newMessage.command, newMessage); return; } }); } 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 +123,7 @@ class VSCodeAPIWrapper { this.postMessageCallback( { data: chatHistory, - type: "sendMessage", + type: "send-message", }, (message) => { if (message.done) { @@ -98,17 +140,98 @@ class VSCodeAPIWrapper { return transform.stream(); } + public getChat(chatId: string) { + return new Promise((resolve) => { + this.postMessageCallback( + { + type: "get-chat", + chatId: chatId, + }, + (message) => { + resolve(message.data); + } + ); + }); + } + + public saveChatHistory(chatId: string, history: Chat) { + return new Promise((resolve) => { + this.postMessageCallback( + { + type: "save-chat", + chatId: chatId, + data: history, + }, + (message) => { + resolve(message.data); + } + ); + }); + } + + public getChats() { + return new Promise((resolve) => { + 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); + } + ); + }); + } + public addMessageListener( 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(messageId: string) { + private abortOperation() { this.vsCodeApi?.postMessage({ type: "abort-generate", - id: messageId, }); } }