diff --git a/TODO.md b/TODO.md index 1ec7a7fb..3be999d2 100644 --- a/TODO.md +++ b/TODO.md @@ -43,13 +43,18 @@ [x] handle long requests [x] scroll lags a bit +[x] attach file (this will be different between docker and IDE's) +[x] use the event bus to handle the file upload in the browser this can be done with the file system api using `window.showOpenFilePicker()` +[ ] should we allow multiple context files? +[ ] context file display could be an accordion button + +[ ] confirm if the lsp only responds with assistant deltas ### TBD [ ] build an asset for docker (saves installing node on ubunto through nvm) [ ] automate adding the chat to docker - -[ ] in docker it seems that it maybe be posable to use the chat as a plugin or web-component +[ ] in the self hosted docker image it seems that it maybe be posable to use the chat as a plugin or web-component, of function ### EVENTS TODO FOR IDEs diff --git a/index.html b/index.html index 13bd5f99..9b5f320a 100644 --- a/index.html +++ b/index.html @@ -7,7 +7,7 @@ Refact.ai Chat -
+
diff --git a/package-lock.json b/package-lock.json index 6f7cdbe1..8deb4570 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", "@types/react-syntax-highlighter": "^15.5.11", + "@types/wicg-file-system-access": "^2023.10.4", "@typescript-eslint/eslint-plugin": "^6.14.0", "@typescript-eslint/parser": "^6.14.0", "@vitejs/plugin-react-swc": "^3.5.0", @@ -7130,6 +7131,12 @@ "integrity": "sha512-WUtIVRUZ9i5dYXefDEAI7sh9/O7jGvHg7Df/5O/gtH3Yabe5odI3UWopVR1qbPXQtvOxWu3mM4XxlYeZtMWF4g==", "dev": true }, + "node_modules/@types/wicg-file-system-access": { + "version": "2023.10.4", + "resolved": "https://registry.npmjs.org/@types/wicg-file-system-access/-/wicg-file-system-access-2023.10.4.tgz", + "integrity": "sha512-ewOj7hWhsUTS2+aY6zY+7BwlgqGBj5ZXxKuHt3TAWpIJH0bDW/6bO1N1SdUDAzV8r0Nc+/ZtpAEETYTwrehBMw==", + "dev": true + }, "node_modules/@types/yargs": { "version": "17.0.32", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", diff --git a/package.json b/package.json index 5c2e312c..c52085c4 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", "@types/react-syntax-highlighter": "^15.5.11", + "@types/wicg-file-system-access": "^2023.10.4", "@typescript-eslint/eslint-plugin": "^6.14.0", "@typescript-eslint/parser": "^6.14.0", "@vitejs/plugin-react-swc": "^3.5.0", diff --git a/src/components/ChatContent/ChatContent.tsx b/src/components/ChatContent/ChatContent.tsx index 436634ff..9acd9b76 100644 --- a/src/components/ChatContent/ChatContent.tsx +++ b/src/components/ChatContent/ChatContent.tsx @@ -1,16 +1,21 @@ import React, { useEffect } from "react"; -import { ChatMessages } from "../../services/refact"; +import { ChatMessages, isChatContextFileMessage } from "../../services/refact"; import { Markdown } from "../Markdown"; import { UserInput } from "./UserInput"; import { ScrollArea } from "../ScrollArea"; import { Spinner } from "../Spinner"; - import { Box, Flex, Text } from "@radix-ui/themes"; import styles from "./ChatContent.module.css"; -const ContextFile: React.FC<{ children: string }> = (props) => { - // TODO how should the context file look? - return {props.children}; +const ContextFile: React.FC<{ name: string; children: string }> = ({ + name, + ...props +}) => { + return ( + +
📎 {name}
+
+ ); }; const PlaceHolderText: React.FC = () => ( @@ -50,7 +55,18 @@ export const ChatContent: React.FC<{ {messages.length === 0 && } - {messages.map(([role, text], index) => { + {messages.map((message, index) => { + if (isChatContextFileMessage(message)) { + const [, file] = message; + return ( + + {file.file_content} + + ); + } + + const [role, text] = message; + if (role === "user") { const handleRetry = (question: string) => { const toSend = messages @@ -63,8 +79,6 @@ export const ChatContent: React.FC<{ {text} ); - } else if (role === "context_file") { - return {text}; // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition } else if (role === "assistant") { return {text}; diff --git a/src/components/ChatForm/ChatForm.tsx b/src/components/ChatForm/ChatForm.tsx index b025e4de..f4ff6060 100644 --- a/src/components/ChatForm/ChatForm.tsx +++ b/src/components/ChatForm/ChatForm.tsx @@ -1,6 +1,6 @@ import React from "react"; -import { Box, Flex } from "@radix-ui/themes"; +import { Box, Flex, Text } from "@radix-ui/themes"; import styles from "./ChatForm.module.css"; import { PaperPlaneButton, BackToSideBarButton } from "../Buttons/Buttons"; @@ -14,6 +14,7 @@ import { import { ErrorCallout, Callout } from "../Callout"; import { Select } from "../Select/Select"; +import { FileUpload } from "../FileUpload"; import { Button } from "@radix-ui/themes"; const CapsSelect: React.FC<{ @@ -22,12 +23,15 @@ const CapsSelect: React.FC<{ options: string[]; }> = ({ options, value, onChange }) => { return ( - + + Use model: + + ); }; @@ -43,6 +47,8 @@ export const ChatForm: React.FC<{ canChangeModel: boolean; isStreaming: boolean; onStopStreaming: () => void; + handleContextFile: () => void; + hasContextFile: boolean; }> = ({ onSubmit, onClose, @@ -55,6 +61,8 @@ export const ChatForm: React.FC<{ canChangeModel, isStreaming, onStopStreaming, + handleContextFile, + hasContextFile, }) => { const [value, setValue] = React.useState(""); const isOnline = useIsOnline(); @@ -78,6 +86,7 @@ export const ChatForm: React.FC<{ return ( + {!isOnline && Offline} {canChangeModel && ( diff --git a/src/components/FileUpload/FileUpload.tsx b/src/components/FileUpload/FileUpload.tsx new file mode 100644 index 00000000..7b914bb9 --- /dev/null +++ b/src/components/FileUpload/FileUpload.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import { Checkbox, Flex, Text } from "@radix-ui/themes"; + +export const FileUpload: React.FC<{ + onClick: () => void; + fileName?: string; + checked: boolean; +}> = ({ onClick, fileName, checked }) => { + return ( + + + { + onClick(); + }} + />{" "} + Attach {fileName ?? "a file"} + + + ); +}; diff --git a/src/components/FileUpload/index.tsx b/src/components/FileUpload/index.tsx new file mode 100644 index 00000000..987a8be5 --- /dev/null +++ b/src/components/FileUpload/index.tsx @@ -0,0 +1 @@ +export { FileUpload } from "./FileUpload"; diff --git a/src/events/index.ts b/src/events/index.ts index c70fbdc0..8bfd50f4 100644 --- a/src/events/index.ts +++ b/src/events/index.ts @@ -3,6 +3,7 @@ import { ChatResponse, CapsResponse, isCapsResponse, + ChatContextFile, } from "../services/refact"; export enum EVENT_NAMES_FROM_CHAT { @@ -10,6 +11,7 @@ export enum EVENT_NAMES_FROM_CHAT { ASK_QUESTION = "chat_question", REQUEST_CAPS = "chat_request_caps", STOP_STREAMING = "chat_stop_streaming", + REQUEST_FILE = "chat_request_for_file", } export enum EVENT_NAMES_TO_CHAT { @@ -24,6 +26,8 @@ export enum EVENT_NAMES_TO_CHAT { RECEIVE_CAPS_ERROR = "receive_caps_error", SET_CHAT_MODEL = "chat_set_chat_model", SET_DISABLE_CHAT = "set_disable_chat", + RECEIVE_FILE = "receive_context_file", + REMOVE_FILE = "remove_context_file", } export type ChatThread = { @@ -50,6 +54,18 @@ export function isActionFromChat(action: unknown): action is ActionFromChat { return Object.values(ALL_EVENT_NAMES).includes(action.type); } +export interface RequestForFileFromChat extends ActionFromChat { + type: EVENT_NAMES_FROM_CHAT.REQUEST_FILE; + payload: { id: string }; +} + +export function isRequestForFileFromChat( + action: unknown, +): action is RequestForFileFromChat { + if (!isActionFromChat(action)) return false; + return action.type === EVENT_NAMES_FROM_CHAT.REQUEST_FILE; +} + export interface QuestionFromChat extends ActionFromChat { type: EVENT_NAMES_FROM_CHAT.ASK_QUESTION; payload: ChatThread; @@ -111,6 +127,31 @@ export function isActionToChat(action: unknown): action is ActionToChat { return Object.values(EVENT_NAMES).includes(action.type); } +export interface ReceiveContextFile extends ActionToChat { + type: EVENT_NAMES_TO_CHAT.RECEIVE_FILE; + payload: { + id: string; + file: ChatContextFile; + }; +} + +export function isReceiveContextFile( + action: unknown, +): action is ReceiveContextFile { + if (!isActionToChat(action)) return false; + return action.type === EVENT_NAMES_TO_CHAT.RECEIVE_FILE; +} + +export interface RemoveContextFile extends ActionToChat { + type: EVENT_NAMES_TO_CHAT.REMOVE_FILE; + payload: { id: string }; +} + +export function isRemoveContext(action: unknown): action is RemoveContextFile { + if (!isActionToChat(action)) return false; + return action.type === EVENT_NAMES_TO_CHAT.REMOVE_FILE; +} + export interface SetChatDisable extends ActionToChat { type: EVENT_NAMES_TO_CHAT.SET_DISABLE_CHAT; payload: { id: string; disable: boolean }; diff --git a/src/features/Chat.tsx b/src/features/Chat.tsx index 427f7864..1ca4b783 100644 --- a/src/features/Chat.tsx +++ b/src/features/Chat.tsx @@ -3,6 +3,7 @@ import { ChatForm } from "../components/ChatForm"; import { useEventBusForChat } from "../hooks/useEventBusForChat"; import { ChatContent } from "../components/ChatContent"; import { Flex } from "@radix-ui/themes"; +import { isChatContextFileMessage } from "../services/refact"; export const Chat: React.FC<{ style?: React.CSSProperties }> = (props) => { const { @@ -12,6 +13,8 @@ export const Chat: React.FC<{ style?: React.CSSProperties }> = (props) => { clearError, setChatModel, stopStreaming, + handleContextFile, + hasContextFile, } = useEventBusForChat(); return ( @@ -35,7 +38,11 @@ export const Chat: React.FC<{ style?: React.CSSProperties }> = (props) => { !isChatContextFileMessage(message), + ).length === 0 && !state.streaming + } error={state.error} clearError={clearError} onSubmit={(value) => { @@ -45,6 +52,8 @@ export const Chat: React.FC<{ style?: React.CSSProperties }> = (props) => { onSetChatModel={setChatModel} caps={state.caps} onStopStreaming={stopStreaming} + handleContextFile={handleContextFile} + hasContextFile={hasContextFile} /> ); diff --git a/src/hooks/useEventBusForChat.ts b/src/hooks/useEventBusForChat.ts index 37dd9098..a0a8bcf7 100644 --- a/src/hooks/useEventBusForChat.ts +++ b/src/hooks/useEventBusForChat.ts @@ -1,5 +1,9 @@ import { useEffect, useReducer } from "react"; -import { ChatMessages, ChatResponse } from "../services/refact"; +import { + ChatMessages, + ChatResponse, + isChatContextFileMessage, +} from "../services/refact"; import { v4 as uuidv4 } from "uuid"; import { EVENT_NAMES_TO_CHAT, @@ -19,6 +23,9 @@ import { isChatReceiveCapsError, isSetChatModel, isSetDisableChat, + isReceiveContextFile, + isRequestForFileFromChat, + isRemoveContext, } from "../events"; declare global { @@ -46,6 +53,7 @@ function formatChatResponse( response: ChatResponse, ): ChatMessages { return response.choices.reduce((acc, cur) => { + // TBD: chat doesn't seem to respond with a context file if (cur.delta.role === "context_file") { return acc.concat([[cur.delta.role, cur.delta.file_content || ""]]); } @@ -55,9 +63,14 @@ function formatChatResponse( const lastMessage = acc[acc.length - 1]; if (lastMessage[0] === cur.delta.role) { - const head = acc.slice(0, -1); - return head.concat([ - [cur.delta.role, lastMessage[1] + cur.delta.content], + const last = acc.slice(0, -1); + const currentMessage = lastMessage[1]; + const currentContent = + typeof currentMessage === "string" + ? currentMessage + : currentMessage.file_content; + return last.concat([ + [cur.delta.role, currentContent + cur.delta.content], ]); } @@ -195,6 +208,40 @@ function reducer(state: ChatState, action: ActionToChat): ChatState { }; } + if (isThisChat && isRequestForFileFromChat(action)) { + return { + ...state, + waiting_for_response: true, + }; + } + + if (isThisChat && isReceiveContextFile(action)) { + return { + ...state, + waiting_for_response: false, + chat: { + ...state.chat, + messages: state.chat.messages.concat([ + ["context_file", action.payload.file], + ]), + }, + }; + } + + if (isThisChat && isRemoveContext(action)) { + const messages = state.chat.messages.filter( + (message) => !isChatContextFileMessage(message), + ); + + return { + ...state, + chat: { + ...state.chat, + messages, + }, + }; + } + return state; } @@ -329,6 +376,24 @@ export const useEventBusForChat = () => { }); } + const hasContextFile = state.chat.messages.some((message) => + isChatContextFileMessage(message), + ); + + function handleContextFile() { + if (hasContextFile) { + dispatch({ + type: EVENT_NAMES_TO_CHAT.REMOVE_FILE, + payload: { id: state.chat.id }, + }); + } else { + postMessage({ + type: EVENT_NAMES_FROM_CHAT.REQUEST_FILE, + payload: { id: state.chat.id }, + }); + } + } + return { state, askQuestion, @@ -336,5 +401,7 @@ export const useEventBusForChat = () => { clearError, setChatModel, stopStreaming, + handleContextFile, + hasContextFile, }; }; diff --git a/src/hooks/useEventBusForHost.ts b/src/hooks/useEventBusForHost.ts index c47bfec9..000694b5 100644 --- a/src/hooks/useEventBusForHost.ts +++ b/src/hooks/useEventBusForHost.ts @@ -1,5 +1,5 @@ import { useEffect, useRef } from "react"; -import { sendChat, getCaps } from "../services/refact"; +import { sendChat, getCaps, ChatContextFile } from "../services/refact"; import { useChatHistory } from "./useChatHistory"; import { EVENT_NAMES_TO_CHAT, @@ -8,6 +8,7 @@ import { isSaveChatFromChat, isRequestCapsFromChat, isStopStreamingFromChat, + isRequestForFileFromChat, } from "../events"; export function useEventBusForHost() { @@ -68,6 +69,52 @@ export function useEventBusForHost() { }); }); } + + if (isRequestForFileFromChat(event.data)) { + const { payload } = event.data; + // TBD: should we allow multiple files? + window + .showOpenFilePicker() + .then((files) => { + files.map(async (file) => { + const data = await file.getFile(); + const text = await data.text(); + const message: ChatContextFile = { + file_name: file.name, + file_content: text, + line1: 1, + line2: text.split("\n").length + 1, + }; + + window.postMessage({ + type: EVENT_NAMES_TO_CHAT.RECEIVE_FILE, + payload: { + id: payload.id, + file: message, + }, + }); + }); + }) + .catch((error: Error) => { + if (error instanceof DOMException && error.name === "AbortError") { + return; + } + // eslint-disable-next-line no-console + console.error(error); + + // TODO: add specific error type for this case + window.postMessage( + { + type: EVENT_NAMES_TO_CHAT.ERROR_STREAMING, + payload: { + id: payload.id, + message: error.message || "error attaching file", + }, + }, + "*", + ); + }); + } }; window.addEventListener("message", listener); diff --git a/src/main.tsx b/src/main.tsx index d71147b8..6ab76e90 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -5,7 +5,7 @@ import App from "./App.tsx"; // import './index.css' // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -ReactDOM.createRoot(document.getElementById("root")!).render( +ReactDOM.createRoot(document.getElementById("refact-chat")!).render( , diff --git a/src/services/refact.ts b/src/services/refact.ts index 302d5e8b..7d95fd50 100644 --- a/src/services/refact.ts +++ b/src/services/refact.ts @@ -3,16 +3,28 @@ const CHAT_URL = `${REFACT_URL}/v1/chat`; const CAPS_URL = `${REFACT_URL}/v1/caps`; export type ChatRole = "user" | "assistant" | "context_file"; -export type ChatMessage = [ChatRole, string]; +export type ChatContextFile = { + file_name: string; + file_content: string; + line1: number; + line2: number; +}; + +export type ChatContentFileMessage = ["context_file", ChatContextFile]; +export type ChatMessage = + | [Omit, string] + | ChatContentFileMessage; + export type ChatMessages = ChatMessage[]; -interface BaseDelta { - role: ChatRole; +export function isChatContextFileMessage( + message: ChatMessage, +): message is ChatContentFileMessage { + return message[0] === "context_file"; } -interface UserDelta extends BaseDelta { - role: "user"; - content: string; +interface BaseDelta { + role: ChatRole; } interface AssistantDelta extends BaseDelta { @@ -20,19 +32,26 @@ interface AssistantDelta extends BaseDelta { content: string; } -interface ChatContextFile extends BaseDelta { +// TODO: confirm UserDelta and ContextFileDelta are sent frm the lsp +interface ChatContextFileDelta extends BaseDelta { role: "context_file"; file_content: string; } -type Delta = UserDelta | AssistantDelta | ChatContextFile; +interface UserDelta extends BaseDelta { + role: "user"; + content: string; +} + +type Delta = UserDelta | AssistantDelta | ChatContextFileDelta; // interface Delta extends UserDelta, AssistantDelta , ChatContextFile {} export type ChatChoice = { - delta: Delta; + delta: Delta; // TODO: so far I've only seen AssistantDelta come from the lsp finish_reason: "stop" | "abort" | null; index: number; }; + export type ChatResponse = { choices: ChatChoice[]; created: number; @@ -45,7 +64,11 @@ export function sendChat( model: string, abortController: AbortController, ) { - const jsonMessages = messages.map(([role, content]) => { + const jsonMessages = messages.map(([role, textOrFile]) => { + const content = + typeof textOrFile === "string" + ? textOrFile + : JSON.stringify([textOrFile]); return { role, content }; }); diff --git a/vite.config.ts b/vite.config.ts index c6388a31..ff8cb9f1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -17,7 +17,6 @@ export default defineConfig(({ command }) => { proxy: { // TODO: make this an env var // https://vitejs.dev/config/#using-environment-variables-in-config - "/v1": process.env.REFACT_LSP_URL ?? "http://127.0.0.1:8001", }, },