diff --git a/app/package.json b/app/package.json index 300467e..1781e4a 100755 --- a/app/package.json +++ b/app/package.json @@ -3,6 +3,7 @@ "@parcel/compressor-brotli": "^2.8.3", "@parcel/compressor-gzip": "^2.8.3", "@parcel/config-default": "^2.8.3", + "@tailwindcss/typography": "^0.5.9", "@types/chroma-js": "^2.4.0", "@types/react": "^18.0.27", "@types/react-dom": "^18.0.10", @@ -48,12 +49,15 @@ "match-sorter": "^6.3.1", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hotkeys-hook": "^4.4.0", + "react-markdown": "^8.0.6", "react-responsive": "^9.0.2", "react-router-dom": "^6.8.1", "react-scroll": "^1.8.9", "react-select": "^5.7.0", "react-tiny-popover": "^7.2.3", "react-transition-group": "^4.4.5", + "remark-gfm": "^3.0.1", "sort-by": "^1.2.0", "sse.js": "^0.6.1", "tailwind-merge": "^1.9.0", diff --git a/app/src/app.tsx b/app/src/app.tsx index efbdf40..b828c41 100755 --- a/app/src/app.tsx +++ b/app/src/app.tsx @@ -1,5 +1,5 @@ import React, { useEffect } from "react" -import {Playground, Compare, Settings} from "./pages" +import {Playground, Compare, Chat, Settings} from "./pages" import {SSE} from "sse.js" import { EditorState, @@ -56,6 +56,12 @@ const DEFAULT_CONTEXTS = { showParametersTable: false } }, + chat:{ + history: DEFAULT_HISTORY_STATE, + editor: DEFAULT_EDITOR_STATE, + modelsState: [], + parameters: DEFAULT_PARAMETERS_STATE + }, }, MODELS: [], } @@ -155,7 +161,7 @@ const APIContextWrapper = ({children}) => { useEffect(() => { const sse_request = new SSE("/api/notifications") - + sse_request.addEventListener("notification", (event: any) => { const parsedEvent = JSON.parse(event.data); notificationSubscribers.current.forEach((callback) => { @@ -180,15 +186,15 @@ const APIContextWrapper = ({children}) => { notificationSubscribers.current = notificationSubscribers.current.filter((cb) => cb !== callback); }, }; - + const Provider = { - setAPIKey: async (provider, apiKey) => (await fetch(`/api/provider/${provider}/api-key`, {method: "PUT", headers: {"Content-Type": "application/json"}, + setAPIKey: async (provider, apiKey) => (await fetch(`/api/provider/${provider}/api-key`, {method: "PUT", headers: {"Content-Type": "application/json"}, body: JSON.stringify({apiKey: apiKey})} )).json(), getAll: async () => (await fetch("/api/providers")).json(), getAllWithModels: async () => (await fetch("/api/providers-with-key-and-models")).json(), }; - + const Inference = { subscribeTextCompletion: (callback) => { textCompletionSubscribers.current.push(callback); @@ -205,14 +211,14 @@ const APIContextWrapper = ({children}) => { }, chatCompletion: createChatCompletionRequest, }; - + const [apiContext, _] = React.useState({ Model, Notifications, Provider, Inference, }); - + function createTextCompletionRequest({prompt, models}) { const url = "/api/inference/text/stream"; const payload = { @@ -221,30 +227,30 @@ const APIContextWrapper = ({children}) => { }; return createCompletionRequest(url, payload, textCompletionSubscribers); } - + function createChatCompletionRequest(prompt, model) { const url = "/api/inference/chat/stream"; const payload = {prompt, model}; return createCompletionRequest(url, payload, chatCompletionSubscribers); } - + function createCompletionRequest(url, payload, subscribers) { pendingCompletionRequest.current = true; let sse_request = null; - + function beforeUnloadHandler() { if (sse_request) sse_request.close(); } - + window.addEventListener("beforeunload", beforeUnloadHandler); const completionsBuffer = createCompletionsBuffer(payload.models); let error_occured = false; let request_complete = false; - + sse_request = new SSE(url, {payload: JSON.stringify(payload)}); - + bindSSEEvents(sse_request, completionsBuffer, {error_occured, request_complete}, beforeUnloadHandler, subscribers); - + return () => { if (sse_request) sse_request.close(); }; @@ -257,32 +263,32 @@ const APIContextWrapper = ({children}) => { }); return buffer; } - + function bindSSEEvents(sse_request, completionsBuffer, requestState, beforeUnloadHandler, subscribers) { sse_request.onopen = async () => { bulkWrite(completionsBuffer, requestState, subscribers); }; - + sse_request.addEventListener("infer", (event) => { let resp = JSON.parse(event.data); completionsBuffer[resp.modelTag].push(resp); }); - + sse_request.addEventListener("status", (event) => { subscribers.current.forEach((callback) => callback({ event: "status", data: JSON.parse(event.data) })); }); - + sse_request.addEventListener("error", (event) => { requestState.error_occured = true; try { const message = JSON.parse(event.data); - + subscribers.current.forEach((callback) => callback({ "event": "error", - "data": message.status + "data": message.status })); } catch (e) { subscribers.current.forEach((callback) => callback({ @@ -290,19 +296,19 @@ const APIContextWrapper = ({children}) => { "data": "Unknown error" })); } - + close_sse(sse_request, requestState, beforeUnloadHandler, subscribers); }); - + sse_request.addEventListener("abort", () => { requestState.error_occured = true; close_sse(sse_request, requestState, beforeUnloadHandler, subscribers); }); - + sse_request.addEventListener("readystatechange", (event) => { if (event.readyState === 2) close_sse(sse_request, requestState, beforeUnloadHandler, subscribers); }); - + sse_request.stream(); } @@ -313,27 +319,27 @@ const APIContextWrapper = ({children}) => { "meta": {error: requestState.error_occured}, })); window.removeEventListener("beforeunload", beforeUnloadHandler); - } - + } + function bulkWrite(completionsBuffer, requestState, subscribers) { setTimeout(() => { let newTokens = false; let batchUpdate = {}; - + for (let modelTag in completionsBuffer) { if (completionsBuffer[modelTag].length > 0) { newTokens = true; batchUpdate[modelTag] = completionsBuffer[modelTag].splice(0, completionsBuffer[modelTag].length); } } - + if (newTokens) { subscribers.current.forEach((callback) => callback({ event: "completion", data: batchUpdate, })); } - + if (!requestState.request_complete) bulkWrite(completionsBuffer, requestState, subscribers); }, 20); } @@ -347,12 +353,11 @@ const APIContextWrapper = ({children}) => { const PlaygroundContextWrapper = ({page, children}) => { const apiContext = React.useContext(APIContext) - const [editorContext, _setEditorContext] = React.useState(DEFAULT_CONTEXTS.PAGES[page].editor); const [parametersContext, _setParametersContext] = React.useState(DEFAULT_CONTEXTS.PAGES[page].parameters); let [modelsStateContext, _setModelsStateContext] = React.useState(DEFAULT_CONTEXTS.PAGES[page].modelsState); const [modelsContext, _setModelsContext] = React.useState(DEFAULT_CONTEXTS.MODELS); - const [historyContext, _setHistoryContext] = React.useState(DEFAULT_CONTEXTS.PAGES[page].history); + const [historyContext, setHistoryContext] = React.useState(DEFAULT_CONTEXTS.PAGES[page].history); /* Temporary fix for models that have been purged remotely but are still cached locally */ for(const {name} of modelsStateContext) { @@ -360,7 +365,7 @@ const PlaygroundContextWrapper = ({page, children}) => { modelsStateContext = modelsStateContext.filter(({name: _name}) => _name !== name) } } - + const editorContextRef = React.useRef(editorContext); const historyContextRef = React.useRef(historyContext); @@ -399,7 +404,7 @@ const PlaygroundContextWrapper = ({page, children}) => { break; } } - + apiContext.Notifications.subscribe(notificationCallback) return () => { @@ -410,9 +415,9 @@ const PlaygroundContextWrapper = ({page, children}) => { const updateModelsData = async () => { const json_params = await apiContext.Model.getAllEnabled() const models = {}; - + const PAGE_MODELS_STATE = SETTINGS.pages[page].modelsState; - + for (const [model_key, modelDetails] of Object.entries(json_params)) { const existingModelEntry = (PAGE_MODELS_STATE.find((model) => model.name === model_key)); @@ -448,7 +453,7 @@ const PlaygroundContextWrapper = ({page, children}) => { provider: modelDetails.provider, } } - + const SERVER_SIDE_MODELS = Object.keys(json_params); for (const {name} of PAGE_MODELS_STATE) { if (!SERVER_SIDE_MODELS.includes(name)) { @@ -465,7 +470,7 @@ const PlaygroundContextWrapper = ({page, children}) => { const setEditorContext = (newEditorContext, immediate=false) => { SETTINGS.pages[page].editor = {...SETTINGS.pages[page].editor, ...newEditorContext}; - const _editor = {...SETTINGS.pages[page].editor, internalState: null }; + const _editor = {...SETTINGS.pages[page].editor, internalState: null}; _setEditorContext(_editor); if (immediate) { @@ -485,14 +490,14 @@ const PlaygroundContextWrapper = ({page, children}) => { const setModelsContext = (newModels) => { SETTINGS.models = newModels; - + debouncedSettingsSave() _setModelsContext(newModels); } const setModelsStateContext = (newModelsState) => { SETTINGS.pages[page].modelsState = newModelsState; - + debouncedSettingsSave() _setModelsStateContext(newModelsState); } @@ -503,7 +508,7 @@ const PlaygroundContextWrapper = ({page, children}) => { show: (value === undefined || value === null) ? !SETTINGS.pages[page].history.show : value } - _setHistoryContext(_newHistory); + setHistoryContext(_newHistory); SETTINGS.pages[page].history = _newHistory; debouncedSettingsSave() @@ -518,7 +523,7 @@ const PlaygroundContextWrapper = ({page, children}) => { const year = currentDate.getFullYear(); const month = String(currentDate.getMonth() + 1).padStart(2, '0'); const day = String(currentDate.getDate()).padStart(2, '0'); - + const newEntry = { timestamp: currentDate.getTime(), date: `${year}-${month}-${day}`, @@ -536,7 +541,7 @@ const PlaygroundContextWrapper = ({page, children}) => { current: newEntry } - _setHistoryContext(_newHistory); + setHistoryContext(_newHistory); //console.warn("Adding to history", _newHistory) SETTINGS.pages[page].history = _newHistory; @@ -549,7 +554,7 @@ const PlaygroundContextWrapper = ({page, children}) => { entries: SETTINGS.pages[page].history.entries.filter((historyEntry) => historyEntry !== entry) } - _setHistoryContext(_newHistory); + setHistoryContext(_newHistory); SETTINGS.pages[page].history = _newHistory; debouncedSettingsSave() @@ -562,7 +567,7 @@ const PlaygroundContextWrapper = ({page, children}) => { current: null } - _setHistoryContext(_newHistory); + setHistoryContext(_newHistory); SETTINGS.pages[page].history = _newHistory; debouncedSettingsSave() @@ -572,7 +577,7 @@ const PlaygroundContextWrapper = ({page, children}) => { SETTINGS.pages[page].history.current = entry; _setEditorContext(entry.editor); - _setHistoryContext(SETTINGS.pages[page].history); + setHistoryContext(SETTINGS.pages[page].history); setParametersContext(entry.parameters); setModelsStateContext(entry.modelsState); } @@ -583,8 +588,8 @@ const PlaygroundContextWrapper = ({page, children}) => { return ( @@ -626,6 +631,17 @@ function ProviderWithRoutes() { } /> + + + + + + } + /> + ) -} \ No newline at end of file +} diff --git a/app/src/components/chat/avatar.tsx b/app/src/components/chat/avatar.tsx new file mode 100644 index 0000000..c8985fe --- /dev/null +++ b/app/src/components/chat/avatar.tsx @@ -0,0 +1,19 @@ +import { FC } from "react" + +interface Props { + name: string +} + +const Avatar: FC = ({name}) => { + const colors = ["pink", "purple", "red", "yellow", "blue", "gray", "green", "indigo"]; + const hashCode = (s:string) => s.split('').reduce((a,b) => (((a << 5) - a) + b.charCodeAt(0))|0, 0); + const color = colors[hashCode(name) % colors.length]; + return ( +
+ ) +} + +export default Avatar diff --git a/app/src/components/chat/message-composer.tsx b/app/src/components/chat/message-composer.tsx new file mode 100644 index 0000000..d59b380 --- /dev/null +++ b/app/src/components/chat/message-composer.tsx @@ -0,0 +1,45 @@ +import { FC, useState } from "react" +import { useHotkeys } from "react-hotkeys-hook" + +interface Props { + onSubmit: (content: string) => void + onCancel: () => void + disabled: boolean +} + +const MessageComposer: FC = ({ onSubmit, onCancel, disabled }) => { + const [newMessage, setNewMessage] = useState("") + + function handleSubmit() { + if (!newMessage) return + onSubmit(newMessage) + setNewMessage("") + } + + const hotkeyOptions = { preventDefault: true, enableOnFormTags: true } + useHotkeys("enter", handleSubmit, hotkeyOptions) + useHotkeys("esc, ctrl+c", onCancel, hotkeyOptions) + + const height = 24 * (newMessage.split("\n").length + 1) + const style = { + minHeight: "50px", + maxHeight: "200px", + height: `${height}px`, + } + + return ( +
+