From: Xuan-Son Nguyen Date: Thu, 8 May 2025 13:37:29 +0000 (+0200) Subject: server : (webui) revamp the input area, plus many small UI improvements (#13365) X-Git-Tag: upstream/0.0.5318~3 X-Git-Url: https://git.djapps.eu/?a=commitdiff_plain;h=8c83449cb780c201839653812681c3a4cf17feed;p=pkg%2Fggml%2Fsources%2Fllama.cpp server : (webui) revamp the input area, plus many small UI improvements (#13365) * rework the input area * process selected file * change all icons to heroicons * fix thought process collapse * move conversation more menu to sidebar * sun icon --> moon icon * rm default system message * stricter upload file check, only allow image if server has mtmd * build it * add renaming * better autoscroll * build * add conversation group * fix scroll * extra context first, then user input in the end * fix
tag * clean up a bit * build * add mb-3 for

* throttle adjustTextareaHeight to make it less laggy

* (nits) missing padding in sidebar

* rm stray console log
---

diff --git a/common/chat.cpp b/common/chat.cpp
index bbc5f087..ad3d4aa9 100644
--- a/common/chat.cpp
+++ b/common/chat.cpp
@@ -125,7 +125,9 @@ std::vector common_chat_msgs_parse_oaicompat(const json & messa
             msgs.push_back(msg);
         }
     } catch (const std::exception & e) {
-        throw std::runtime_error("Failed to parse messages: " + std::string(e.what()) + "; messages = " + messages.dump(2));
+        // @ngxson : disable otherwise it's bloating the API response
+        // printf("%s\n", std::string("; messages = ") + messages.dump(2));
+        throw std::runtime_error("Failed to parse messages: " + std::string(e.what()));
     }
 
     return msgs;
diff --git a/tools/server/public/index.html.gz b/tools/server/public/index.html.gz
index 82d2e3b9..3153b615 100644
Binary files a/tools/server/public/index.html.gz and b/tools/server/public/index.html.gz differ
diff --git a/tools/server/webui/package-lock.json b/tools/server/webui/package-lock.json
index b2e3cf94..2c23a758 100644
--- a/tools/server/webui/package-lock.json
+++ b/tools/server/webui/package-lock.json
@@ -21,6 +21,8 @@
         "postcss": "^8.4.49",
         "react": "^18.3.1",
         "react-dom": "^18.3.1",
+        "react-dropzone": "^14.3.8",
+        "react-hot-toast": "^2.5.2",
         "react-markdown": "^9.0.3",
         "react-router": "^7.1.5",
         "rehype-highlight": "^7.0.2",
@@ -2058,6 +2060,15 @@
       "dev": true,
       "license": "Python-2.0"
     },
+    "node_modules/attr-accept": {
+      "version": "2.2.5",
+      "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz",
+      "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=4"
+      }
+    },
     "node_modules/autoprefixer": {
       "version": "10.4.20",
       "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz",
@@ -2804,6 +2815,18 @@
         "node": ">=16.0.0"
       }
     },
+    "node_modules/file-selector": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz",
+      "integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==",
+      "license": "MIT",
+      "dependencies": {
+        "tslib": "^2.7.0"
+      },
+      "engines": {
+        "node": ">= 12"
+      }
+    },
     "node_modules/fill-range": {
       "version": "7.1.1",
       "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -2917,6 +2940,15 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/goober": {
+      "version": "2.1.16",
+      "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.16.tgz",
+      "integrity": "sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==",
+      "license": "MIT",
+      "peerDependencies": {
+        "csstype": "^3.0.10"
+      }
+    },
     "node_modules/graceful-fs": {
       "version": "4.2.11",
       "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -4674,6 +4706,15 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/object-assign": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+      "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
     "node_modules/optionator": {
       "version": "0.9.4",
       "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -4872,6 +4913,17 @@
         "url": "https://github.com/prettier/prettier?sponsor=1"
       }
     },
+    "node_modules/prop-types": {
+      "version": "15.8.1",
+      "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+      "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+      "license": "MIT",
+      "dependencies": {
+        "loose-envify": "^1.4.0",
+        "object-assign": "^4.1.1",
+        "react-is": "^16.13.1"
+      }
+    },
     "node_modules/property-information": {
       "version": "6.5.0",
       "resolved": "https://registry.npmjs.org/property-information/-/property-information-6.5.0.tgz",
@@ -4938,6 +4990,46 @@
         "react": "^18.3.1"
       }
     },
+    "node_modules/react-dropzone": {
+      "version": "14.3.8",
+      "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.8.tgz",
+      "integrity": "sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==",
+      "license": "MIT",
+      "dependencies": {
+        "attr-accept": "^2.2.4",
+        "file-selector": "^2.1.0",
+        "prop-types": "^15.8.1"
+      },
+      "engines": {
+        "node": ">= 10.13"
+      },
+      "peerDependencies": {
+        "react": ">= 16.8 || 18.0.0"
+      }
+    },
+    "node_modules/react-hot-toast": {
+      "version": "2.5.2",
+      "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.5.2.tgz",
+      "integrity": "sha512-Tun3BbCxzmXXM7C+NI4qiv6lT0uwGh4oAfeJyNOjYUejTsm35mK9iCaYLGv8cBz9L5YxZLx/2ii7zsIwPtPUdw==",
+      "license": "MIT",
+      "dependencies": {
+        "csstype": "^3.1.3",
+        "goober": "^2.1.16"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "peerDependencies": {
+        "react": ">=16",
+        "react-dom": ">=16"
+      }
+    },
+    "node_modules/react-is": {
+      "version": "16.13.1",
+      "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+      "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+      "license": "MIT"
+    },
     "node_modules/react-markdown": {
       "version": "9.0.3",
       "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.0.3.tgz",
@@ -5814,7 +5906,6 @@
       "version": "2.8.1",
       "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
       "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
-      "devOptional": true,
       "license": "0BSD"
     },
     "node_modules/turbo-stream": {
diff --git a/tools/server/webui/package.json b/tools/server/webui/package.json
index 6ac06b1a..ab1b920b 100644
--- a/tools/server/webui/package.json
+++ b/tools/server/webui/package.json
@@ -24,6 +24,8 @@
     "postcss": "^8.4.49",
     "react": "^18.3.1",
     "react-dom": "^18.3.1",
+    "react-dropzone": "^14.3.8",
+    "react-hot-toast": "^2.5.2",
     "react-markdown": "^9.0.3",
     "react-router": "^7.1.5",
     "rehype-highlight": "^7.0.2",
diff --git a/tools/server/webui/src/App.tsx b/tools/server/webui/src/App.tsx
index cc4659e1..3b00a8f9 100644
--- a/tools/server/webui/src/App.tsx
+++ b/tools/server/webui/src/App.tsx
@@ -4,6 +4,7 @@ import Sidebar from './components/Sidebar';
 import { AppContextProvider, useAppContext } from './utils/app.context';
 import ChatScreen from './components/ChatScreen';
 import SettingDialog from './components/SettingDialog';
+import { Toaster } from 'react-hot-toast';
 
 function App() {
   return (
@@ -40,6 +41,7 @@ function AppLayout() {
           onClose={() => setShowSettings(false)}
         />
       }
+      
     
   );
 }
diff --git a/tools/server/webui/src/Config.ts b/tools/server/webui/src/Config.ts
index dd1cc0e1..5eef608c 100644
--- a/tools/server/webui/src/Config.ts
+++ b/tools/server/webui/src/Config.ts
@@ -12,7 +12,7 @@ export const CONFIG_DEFAULT = {
   // Note: in order not to introduce breaking changes, please keep the same data type (number, string, etc) if you want to change the default value. Do not use null or undefined for default value.
   // Do not use nested objects, keep it single level. Prefix the key if you need to group them.
   apiKey: '',
-  systemMessage: 'You are a helpful assistant.',
+  systemMessage: '',
   showTokensPerSecond: false,
   showThoughtInProgress: false,
   excludeThoughtOnReq: true,
diff --git a/tools/server/webui/src/components/ChatInputExtraContextItem.tsx b/tools/server/webui/src/components/ChatInputExtraContextItem.tsx
new file mode 100644
index 00000000..ac416fa9
--- /dev/null
+++ b/tools/server/webui/src/components/ChatInputExtraContextItem.tsx
@@ -0,0 +1,92 @@
+import { DocumentTextIcon, XMarkIcon } from '@heroicons/react/24/outline';
+import { MessageExtra } from '../utils/types';
+import { useState } from 'react';
+import { classNames } from '../utils/misc';
+
+export default function ChatInputExtraContextItem({
+  items,
+  removeItem,
+  clickToShow,
+}: {
+  items?: MessageExtra[];
+  removeItem?: (index: number) => void;
+  clickToShow?: boolean;
+}) {
+  const [show, setShow] = useState(-1);
+  const showingItem = show >= 0 ? items?.[show] : undefined;
+
+  if (!items) return null;
+
+  return (
+    
+ {items.map((item, i) => ( +
clickToShow && setShow(i)} + > + {removeItem && ( +
+ +
+ )} + +
+ {item.type === 'imageFile' ? ( + <> + {item.name} + + ) : ( + <> +
+ +
+ +
+ {item.name ?? 'Extra content'} +
+ + )} +
+
+ ))} + + {showingItem && ( + +
+
+ {showingItem.name ?? 'Extra content'} + +
+ {showingItem.type === 'imageFile' ? ( + {showingItem.name} + ) : ( +
+
+                  {showingItem.content}
+                
+
+ )} +
+
setShow(-1)}>
+
+ )} +
+ ); +} diff --git a/tools/server/webui/src/components/ChatMessage.tsx b/tools/server/webui/src/components/ChatMessage.tsx index 40ea7471..08eb4235 100644 --- a/tools/server/webui/src/components/ChatMessage.tsx +++ b/tools/server/webui/src/components/ChatMessage.tsx @@ -3,7 +3,14 @@ import { useAppContext } from '../utils/app.context'; import { Message, PendingMessage } from '../utils/types'; import { classNames } from '../utils/misc'; import MarkdownDisplay, { CopyButton } from './MarkdownDisplay'; -import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline'; +import { + ArrowPathIcon, + ChevronLeftIcon, + ChevronRightIcon, + PencilSquareIcon, +} from '@heroicons/react/24/outline'; +import ChatInputExtraContextItem from './ChatInputExtraContextItem'; +import { BtnWithTooltips } from '../utils/common'; interface SplitMessage { content: PendingMessage['content']; @@ -85,10 +92,14 @@ export default function ChatMessage({ 'chat-end': msg.role === 'user', })} > + {msg.extra && msg.extra.length > 0 && ( + + )} +
{/* textarea for editing message */} @@ -133,59 +144,11 @@ export default function ChatMessage({ {/* render message as markdown */}
{thought && ( -
- - {isPending && isThinking ? ( - - - Thinking - - ) : ( - Thought Process - )} - -
- -
-
- )} - - {msg.extra && msg.extra.length > 0 && ( -
- - Extra content - -
- {msg.extra.map( - (extra, i) => - extra.type === 'textFile' ? ( -
- {extra.name} -
{extra.content}
-
- ) : extra.type === 'context' ? ( -
-
{extra.content}
-
- ) : null // TODO: support other extra types - )} -
-
+ )} setEditingContent(msg.content)} disabled={msg.content === null} + tooltipsContent="Edit message" > - ✍️ Edit - + + )} {/* assistant message */} {msg.role === 'assistant' && ( <> {!isPending && ( - + + )} )}
@@ -294,3 +259,44 @@ export default function ChatMessage({
); } + +function ThoughtProcess({ + isThinking, + content, + open, +}: { + isThinking: boolean; + content: string; + open: boolean; +}) { + return ( +
+ +
+
+ {isThinking ? ( + + + Thinking + + ) : ( + <>Thought Process + )} +
+
+
+
+ +
+
+
+ ); +} diff --git a/tools/server/webui/src/components/ChatScreen.tsx b/tools/server/webui/src/components/ChatScreen.tsx index a2e3ee99..b645a494 100644 --- a/tools/server/webui/src/components/ChatScreen.tsx +++ b/tools/server/webui/src/components/ChatScreen.tsx @@ -1,12 +1,25 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { CallbackGeneratedChunk, useAppContext } from '../utils/app.context'; import ChatMessage from './ChatMessage'; import { CanvasType, Message, PendingMessage } from '../utils/types'; -import { classNames, cleanCurrentUrl, throttle } from '../utils/misc'; +import { classNames, cleanCurrentUrl } from '../utils/misc'; import CanvasPyInterpreter from './CanvasPyInterpreter'; import StorageUtils from '../utils/storage'; import { useVSCodeContext } from '../utils/llama-vscode'; import { useChatTextarea, ChatTextareaApi } from './useChatTextarea.ts'; +import { + ArrowUpIcon, + StopIcon, + PaperClipIcon, +} from '@heroicons/react/24/solid'; +import { + ChatExtraContextApi, + useChatExtraContext, +} from './useChatExtraContext.tsx'; +import Dropzone from 'react-dropzone'; +import toast from 'react-hot-toast'; +import ChatInputExtraContextItem from './ChatInputExtraContextItem.tsx'; +import { scrollToBottom, useChatScroll } from './useChatScroll.tsx'; /** * A message display is a message node with additional information for rendering. @@ -72,24 +85,6 @@ function getListMessageDisplay( return res; } -const scrollToBottom = throttle( - (requiresNearBottom: boolean, delay: number = 80) => { - const mainScrollElem = document.getElementById('main-scroll'); - if (!mainScrollElem) return; - const spaceToBottom = - mainScrollElem.scrollHeight - - mainScrollElem.scrollTop - - mainScrollElem.clientHeight; - if (!requiresNearBottom || spaceToBottom < 50) { - setTimeout( - () => mainScrollElem.scrollTo({ top: mainScrollElem.scrollHeight }), - delay - ); - } - }, - 80 -); - export default function ChatScreen() { const { viewingChat, @@ -102,10 +97,11 @@ export default function ChatScreen() { } = useAppContext(); const textarea: ChatTextareaApi = useChatTextarea(prefilledMsg.content()); + const extraContext = useChatExtraContext(); + useVSCodeContext(textarea, extraContext); - const { extraContext, clearExtraContext } = useVSCodeContext(textarea); - // TODO: improve this when we have "upload file" feature - const currExtra: Message['extra'] = extraContext ? [extraContext] : undefined; + const msgListRef = useRef(null); + useChatScroll(msgListRef); // keep track of leaf node for rendering const [currNodeId, setCurrNodeId] = useState(-1); @@ -129,13 +125,15 @@ export default function ChatScreen() { if (currLeafNodeId) { setCurrNodeId(currLeafNodeId); } - scrollToBottom(true); + // useChatScroll will handle the auto scroll }; const sendNewMessage = async () => { const lastInpMsg = textarea.value(); - if (lastInpMsg.trim().length === 0 || isGenerating(currConvId ?? '')) + if (lastInpMsg.trim().length === 0 || isGenerating(currConvId ?? '')) { + toast.error('Please enter a message'); return; + } textarea.setValue(''); scrollToBottom(false); setCurrNodeId(-1); @@ -146,7 +144,7 @@ export default function ChatScreen() { currConvId, lastMsgNodeId, lastInpMsg, - currExtra, + extraContext.items, onChunk )) ) { @@ -154,7 +152,7 @@ export default function ChatScreen() { textarea.setValue(lastInpMsg); } // OK - clearExtraContext(); + extraContext.clearItems(); }; // for vscode context @@ -234,10 +232,17 @@ export default function ChatScreen() { })} > {/* chat messages */} -
-
+
+
{/* placeholder to shift the message to the bottom */} - {viewingChat ? '' : 'Send a message to start'} + {viewingChat ? ( + '' + ) : ( + <> +
Send a message to start
+ + + )}
{[...messages, ...pendingMsgDisplay].map((msg) => ( ))}
{/* chat input */} -
- - - {isGenerating(currConvId ?? '') ? ( - - ) : ( - - )} -
+ stopGenerating(currConvId ?? '')} + isGenerating={isGenerating(currConvId ?? '')} + />
{canvasData?.type === CanvasType.PY_INTERPRETER && ( @@ -297,3 +275,129 @@ export default function ChatScreen() {
); } + +function ServerInfo() { + const { serverProps } = useAppContext(); + return ( +
+
+ Server Info +

+ Model: {serverProps?.model_path?.split(/(\\|\/)/).pop()} +
+ Build: {serverProps?.build_info} +
+

+
+
+ ); +} + +function ChatInput({ + textarea, + extraContext, + onSend, + onStop, + isGenerating, +}: { + textarea: ChatTextareaApi; + extraContext: ChatExtraContextApi; + onSend: () => void; + onStop: () => void; + isGenerating: boolean; +}) { + const [isDrag, setIsDrag] = useState(false); + + return ( +
+ { + setIsDrag(false); + extraContext.onFileAdded(files); + }} + onDragEnter={() => setIsDrag(true)} + onDragLeave={() => setIsDrag(false)} + multiple={true} + > + {({ getRootProps, getInputProps }) => ( +
+ {!isGenerating && ( + + )} + +
+ + + {/* buttons area */} +
+ + + {isGenerating ? ( + + ) : ( + + )} +
+
+
+ )} +
+
+ ); +} diff --git a/tools/server/webui/src/components/Header.tsx b/tools/server/webui/src/components/Header.tsx index 4c6b291e..45775ff7 100644 --- a/tools/server/webui/src/components/Header.tsx +++ b/tools/server/webui/src/components/Header.tsx @@ -4,10 +4,13 @@ import { useAppContext } from '../utils/app.context'; import { classNames } from '../utils/misc'; import daisyuiThemes from 'daisyui/theme/object'; import { THEMES } from '../Config'; -import { useNavigate } from 'react-router'; +import { + Cog8ToothIcon, + MoonIcon, + Bars3Icon, +} from '@heroicons/react/24/outline'; export default function Header() { - const navigate = useNavigate(); const [selectedTheme, setSelectedTheme] = useState(StorageUtils.getTheme()); const { setShowSettings } = useAppContext(); @@ -24,105 +27,21 @@ export default function Header() { ); }, [selectedTheme]); - const { isGenerating, viewingChat } = useAppContext(); - const isCurrConvGenerating = isGenerating(viewingChat?.conv.id ?? ''); - - const removeConversation = () => { - if (isCurrConvGenerating || !viewingChat) return; - const convId = viewingChat?.conv.id; - if (window.confirm('Are you sure to delete this conversation?')) { - StorageUtils.remove(convId); - navigate('/'); - } - }; - - const downloadConversation = () => { - if (isCurrConvGenerating || !viewingChat) return; - const convId = viewingChat?.conv.id; - const conversationJson = JSON.stringify(viewingChat, null, 2); - const blob = new Blob([conversationJson], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `conversation_${convId}.json`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - }; - return (
{/* open sidebar button */}
llama.cpp
{/* action buttons (top right) */}
- {viewingChat && ( -
- {/* "..." button */} - - {/* dropdown menu */} - -
- )} -
@@ -130,16 +49,7 @@ export default function Header() {
- - - +
    - + {canRunCode && ( )} @@ -101,16 +106,17 @@ export const CopyButton = ({ }) => { const [copied, setCopied] = useState(false); return ( - + + ); }; @@ -124,7 +130,7 @@ export const RunPyCodeButton = ({ const { setCanvasData } = useAppContext(); return ( <> - + + ); }; diff --git a/tools/server/webui/src/components/Sidebar.tsx b/tools/server/webui/src/components/Sidebar.tsx index 34727c62..1a6c8a32 100644 --- a/tools/server/webui/src/components/Sidebar.tsx +++ b/tools/server/webui/src/components/Sidebar.tsx @@ -1,13 +1,25 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { classNames } from '../utils/misc'; import { Conversation } from '../utils/types'; import StorageUtils from '../utils/storage'; import { useNavigate, useParams } from 'react-router'; +import { + ArrowDownTrayIcon, + EllipsisVerticalIcon, + PencilIcon, + TrashIcon, + XMarkIcon, +} from '@heroicons/react/24/outline'; +import { BtnWithTooltips } from '../utils/common'; +import { useAppContext } from '../utils/app.context'; +import toast from 'react-hot-toast'; export default function Sidebar() { const params = useParams(); const navigate = useNavigate(); + const { isGenerating } = useAppContext(); + const [conversations, setConversations] = useState([]); const [currConv, setCurrConv] = useState(null); @@ -26,6 +38,11 @@ export default function Sidebar() { }; }, []); + const groupedConv = useMemo( + () => groupConversationsByDate(conversations), + [conversations] + ); + return ( <> - - - +
- {/* list of conversations */} + {/* new conversation button */}
navigate('/')} > + New conversation
- {conversations.map((conv) => ( -
navigate(`/chat/${conv.id}`)} - dir="auto" - > - {conv.name} + + {/* list of conversations */} + {groupedConv.map((group) => ( +
+ {/* group name (by date) */} + {group.title ? ( + {group.title} + ) : ( +
+ )} + + {group.conversations.map((conv) => ( + { + navigate(`/chat/${conv.id}`); + }} + onDelete={() => { + if (isGenerating(conv.id)) { + toast.error( + 'Cannot delete conversation while generating' + ); + return; + } + if ( + window.confirm( + 'Are you sure to delete this conversation?' + ) + ) { + toast.success('Conversation deleted'); + StorageUtils.remove(conv.id); + navigate('/'); + } + }} + onDownload={() => { + if (isGenerating(conv.id)) { + toast.error( + 'Cannot download conversation while generating' + ); + return; + } + const conversationJson = JSON.stringify(conv, null, 2); + const blob = new Blob([conversationJson], { + type: 'application/json', + }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `conversation_${conv.id}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }} + onRename={() => { + if (isGenerating(conv.id)) { + toast.error( + 'Cannot rename conversation while generating' + ); + return; + } + const newName = window.prompt( + 'Enter new name for the conversation', + conv.name + ); + if (newName && newName.trim().length > 0) { + StorageUtils.updateConversationName(conv.id, newName); + } + }} + /> + ))}
))} -
+
Conversations are saved to browser's IndexedDB
@@ -94,3 +161,170 @@ export default function Sidebar() { ); } + +function ConversationItem({ + conv, + isCurrConv, + onSelect, + onDelete, + onDownload, + onRename, +}: { + conv: Conversation; + isCurrConv: boolean; + onSelect: () => void; + onDelete: () => void; + onDownload: () => void; + onRename: () => void; +}) { + return ( +
+
+ {conv.name} +
+
+ + {/* dropdown menu */} + +
+
+ ); +} + +// WARN: vibe code below + +export interface GroupedConversations { + title?: string; + conversations: Conversation[]; +} + +// TODO @ngxson : add test for this function +// Group conversations by date +// - "Previous 7 Days" +// - "Previous 30 Days" +// - "Month Year" (e.g., "April 2023") +export function groupConversationsByDate( + conversations: Conversation[] +): GroupedConversations[] { + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); // Start of today + + const sevenDaysAgo = new Date(today); + sevenDaysAgo.setDate(today.getDate() - 7); + + const thirtyDaysAgo = new Date(today); + thirtyDaysAgo.setDate(today.getDate() - 30); + + const groups: { [key: string]: Conversation[] } = { + Today: [], + 'Previous 7 Days': [], + 'Previous 30 Days': [], + }; + const monthlyGroups: { [key: string]: Conversation[] } = {}; // Key format: "Month Year" e.g., "April 2023" + + // Sort conversations by lastModified date in descending order (newest first) + // This helps when adding to groups, but the final output order of groups is fixed. + const sortedConversations = [...conversations].sort( + (a, b) => b.lastModified - a.lastModified + ); + + for (const conv of sortedConversations) { + const convDate = new Date(conv.lastModified); + + if (convDate >= today) { + groups['Today'].push(conv); + } else if (convDate >= sevenDaysAgo) { + groups['Previous 7 Days'].push(conv); + } else if (convDate >= thirtyDaysAgo) { + groups['Previous 30 Days'].push(conv); + } else { + const monthName = convDate.toLocaleString('default', { month: 'long' }); + const year = convDate.getFullYear(); + const monthYearKey = `${monthName} ${year}`; + if (!monthlyGroups[monthYearKey]) { + monthlyGroups[monthYearKey] = []; + } + monthlyGroups[monthYearKey].push(conv); + } + } + + const result: GroupedConversations[] = []; + + if (groups['Today'].length > 0) { + result.push({ + title: undefined, // no title for Today + conversations: groups['Today'], + }); + } + + if (groups['Previous 7 Days'].length > 0) { + result.push({ + title: 'Previous 7 Days', + conversations: groups['Previous 7 Days'], + }); + } + + if (groups['Previous 30 Days'].length > 0) { + result.push({ + title: 'Previous 30 Days', + conversations: groups['Previous 30 Days'], + }); + } + + // Sort monthly groups by date (most recent month first) + const sortedMonthKeys = Object.keys(monthlyGroups).sort((a, b) => { + const dateA = new Date(a); // "Month Year" can be parsed by Date constructor + const dateB = new Date(b); + return dateB.getTime() - dateA.getTime(); + }); + + for (const monthKey of sortedMonthKeys) { + if (monthlyGroups[monthKey].length > 0) { + result.push({ title: monthKey, conversations: monthlyGroups[monthKey] }); + } + } + + return result; +} diff --git a/tools/server/webui/src/components/useChatExtraContext.tsx b/tools/server/webui/src/components/useChatExtraContext.tsx new file mode 100644 index 00000000..866401db --- /dev/null +++ b/tools/server/webui/src/components/useChatExtraContext.tsx @@ -0,0 +1,174 @@ +import { useState } from 'react'; +import { MessageExtra } from '../utils/types'; +import toast from 'react-hot-toast'; +import { useAppContext } from '../utils/app.context'; + +// Interface describing the API returned by the hook +export interface ChatExtraContextApi { + items?: MessageExtra[]; // undefined if empty, similar to Message['extra'] + addItems: (items: MessageExtra[]) => void; + removeItem: (idx: number) => void; + clearItems: () => void; + onFileAdded: (files: File[]) => void; // used by "upload" button +} + +export function useChatExtraContext(): ChatExtraContextApi { + const { serverProps } = useAppContext(); + const [items, setItems] = useState([]); + + const addItems = (newItems: MessageExtra[]) => { + setItems((prev) => [...prev, ...newItems]); + }; + + const removeItem = (idx: number) => { + setItems((prev) => prev.filter((_, i) => i !== idx)); + }; + + const clearItems = () => { + setItems([]); + }; + + const onFileAdded = (files: File[]) => { + for (const file of files) { + const mimeType = file.type; + console.debug({ mimeType, file }); + if (file.size > 10 * 1024 * 1024) { + toast.error('File is too large. Maximum size is 10MB.'); + break; + } + + if (mimeType.startsWith('image/') && mimeType !== 'image/svg+xml') { + if (!serverProps?.has_multimodal) { + toast.error('Multimodal is not supported by this server or model.'); + break; + } + const reader = new FileReader(); + reader.onload = (event) => { + if (event.target?.result) { + addItems([ + { + type: 'imageFile', + name: file.name, + base64Url: event.target.result as string, + }, + ]); + } + }; + reader.readAsDataURL(file); + } else if ( + mimeType.startsWith('video/') || + mimeType.startsWith('audio/') + ) { + toast.error('Video and audio files are not supported yet.'); + break; + } else if (mimeType.startsWith('application/pdf')) { + toast.error('PDF files are not supported yet.'); + break; + } else { + // Because there can be many text file types (like code file), we will not check the mime type + // and will just check if the file is not binary. + const reader = new FileReader(); + reader.onload = (event) => { + if (event.target?.result) { + const content = event.target.result as string; + if (!isLikelyNotBinary(content)) { + toast.error('File is binary. Please upload a text file.'); + return; + } + addItems([ + { + type: 'textFile', + name: file.name, + content, + }, + ]); + } + }; + reader.readAsText(file); + } + } + }; + + return { + items: items.length > 0 ? items : undefined, + addItems, + removeItem, + clearItems, + onFileAdded, + }; +} + +// WARN: vibe code below +// This code is a heuristic to determine if a string is likely not binary. +// It is necessary because input file can have various mime types which we don't have time to investigate. +// For example, a python file can be text/plain, application/x-python, etc. +export function isLikelyNotBinary(str: string): boolean { + const options = { + prefixLength: 1024 * 10, // Check the first 10KB of the string + suspiciousCharThresholdRatio: 0.15, // Allow up to 15% suspicious chars + maxAbsoluteNullBytes: 2, + }; + + if (!str) { + return true; // Empty string is considered "not binary" or trivially text. + } + + const sampleLength = Math.min(str.length, options.prefixLength); + if (sampleLength === 0) { + return true; // Effectively an empty string after considering prefixLength. + } + + let suspiciousCharCount = 0; + let nullByteCount = 0; + + for (let i = 0; i < sampleLength; i++) { + const charCode = str.charCodeAt(i); + + // 1. Check for Unicode Replacement Character (U+FFFD) + // This is a strong indicator if the string was created from decoding bytes as UTF-8. + if (charCode === 0xfffd) { + suspiciousCharCount++; + continue; + } + + // 2. Check for Null Bytes (U+0000) + if (charCode === 0x0000) { + nullByteCount++; + // We also count nulls towards the general suspicious character count, + // as they are less common in typical text files. + suspiciousCharCount++; + continue; + } + + // 3. Check for C0 Control Characters (U+0001 to U+001F) + // Exclude common text control characters: TAB (9), LF (10), CR (13). + // We can also be a bit lenient with BEL (7) and BS (8) which sometimes appear in logs. + if (charCode < 32) { + if ( + charCode !== 9 && // TAB + charCode !== 10 && // LF + charCode !== 13 && // CR + charCode !== 7 && // BEL (Bell) - sometimes in logs + charCode !== 8 // BS (Backspace) - less common, but possible + ) { + suspiciousCharCount++; + } + } + // Characters from 32 (space) up to 126 (~) are printable ASCII. + // Characters 127 (DEL) is a control character. + // Characters >= 128 are extended ASCII / multi-byte Unicode. + // If they resulted in U+FFFD, we caught it. Otherwise, they are valid + // (though perhaps unusual) Unicode characters from JS's perspective. + // The main concern is if those higher characters came from misinterpreting + // a single-byte encoding as UTF-8, which again, U+FFFD would usually flag. + } + + // Check absolute null byte count + if (nullByteCount > options.maxAbsoluteNullBytes) { + return false; // Too many null bytes is a strong binary indicator + } + + // Check ratio of suspicious characters + const ratio = suspiciousCharCount / sampleLength; + return ratio <= options.suspiciousCharThresholdRatio; +} diff --git a/tools/server/webui/src/components/useChatScroll.tsx b/tools/server/webui/src/components/useChatScroll.tsx new file mode 100644 index 00000000..25ea0223 --- /dev/null +++ b/tools/server/webui/src/components/useChatScroll.tsx @@ -0,0 +1,34 @@ +import React, { useEffect } from 'react'; +import { throttle } from '../utils/misc'; + +export const scrollToBottom = (requiresNearBottom: boolean, delay?: number) => { + const mainScrollElem = document.getElementById('main-scroll'); + if (!mainScrollElem) return; + const spaceToBottom = + mainScrollElem.scrollHeight - + mainScrollElem.scrollTop - + mainScrollElem.clientHeight; + if (!requiresNearBottom || spaceToBottom < 100) { + setTimeout( + () => mainScrollElem.scrollTo({ top: mainScrollElem.scrollHeight }), + delay ?? 80 + ); + } +}; + +const scrollToBottomThrottled = throttle(scrollToBottom, 80); + +export function useChatScroll(msgListRef: React.RefObject) { + useEffect(() => { + if (!msgListRef.current) return; + + const resizeObserver = new ResizeObserver((_) => { + scrollToBottomThrottled(true, 10); + }); + + resizeObserver.observe(msgListRef.current); + return () => { + resizeObserver.disconnect(); + }; + }, [msgListRef]); +} diff --git a/tools/server/webui/src/components/useChatTextarea.ts b/tools/server/webui/src/components/useChatTextarea.ts index a3223f4f..c2f86520 100644 --- a/tools/server/webui/src/components/useChatTextarea.ts +++ b/tools/server/webui/src/components/useChatTextarea.ts @@ -1,35 +1,39 @@ import { useEffect, useRef, useState, useCallback } from 'react'; +import { throttle } from '../utils/misc'; // Media Query for detecting "large" screens (matching Tailwind's lg: breakpoint) const LARGE_SCREEN_MQ = '(min-width: 1024px)'; // Calculates and sets the textarea height based on its scrollHeight -const adjustTextareaHeight = (textarea: HTMLTextAreaElement | null) => { - if (!textarea) return; +const adjustTextareaHeight = throttle( + (textarea: HTMLTextAreaElement | null) => { + if (!textarea) return; - // Only perform auto-sizing on large screens - if (!window.matchMedia(LARGE_SCREEN_MQ).matches) { - // On small screens, reset inline height and max-height styles. - // This allows CSS (e.g., `rows` attribute or classes) to control the height, - // and enables manual resizing if `resize-vertical` is set. - textarea.style.height = ''; // Use 'auto' or '' to reset - textarea.style.maxHeight = ''; - return; // Do not adjust height programmatically on small screens - } + // Only perform auto-sizing on large screens + if (!window.matchMedia(LARGE_SCREEN_MQ).matches) { + // On small screens, reset inline height and max-height styles. + // This allows CSS (e.g., `rows` attribute or classes) to control the height, + // and enables manual resizing if `resize-vertical` is set. + textarea.style.height = ''; // Use 'auto' or '' to reset + textarea.style.maxHeight = ''; + return; // Do not adjust height programmatically on small screens + } - const computedStyle = window.getComputedStyle(textarea); - // Get the max-height specified by CSS (e.g., from `lg:max-h-48`) - const currentMaxHeight = computedStyle.maxHeight; + const computedStyle = window.getComputedStyle(textarea); + // Get the max-height specified by CSS (e.g., from `lg:max-h-48`) + const currentMaxHeight = computedStyle.maxHeight; - // Temporarily remove max-height to allow scrollHeight to be calculated correctly - textarea.style.maxHeight = 'none'; - // Reset height to 'auto' to measure the actual scrollHeight needed - textarea.style.height = 'auto'; - // Set the height to the calculated scrollHeight - textarea.style.height = `${textarea.scrollHeight}px`; - // Re-apply the original max-height from CSS to enforce the limit - textarea.style.maxHeight = currentMaxHeight; -}; + // Temporarily remove max-height to allow scrollHeight to be calculated correctly + textarea.style.maxHeight = 'none'; + // Reset height to 'auto' to measure the actual scrollHeight needed + textarea.style.height = 'auto'; + // Set the height to the calculated scrollHeight + textarea.style.height = `${textarea.scrollHeight}px`; + // Re-apply the original max-height from CSS to enforce the limit + textarea.style.maxHeight = currentMaxHeight; + }, + 100 +); // Throttle to prevent excessive calls // Interface describing the API returned by the hook export interface ChatTextareaApi { @@ -65,6 +69,7 @@ export function useChatTextarea(initValue: string): ChatTextareaApi { } }, [textareaRef, savedInitValue]); // Depend on ref and savedInitValue + // On input change, we adjust the height of the textarea const handleInput = useCallback( (event: React.FormEvent) => { // Call adjustTextareaHeight on every input - it will decide whether to act @@ -94,6 +99,6 @@ export function useChatTextarea(initValue: string): ChatTextareaApi { }, ref: textareaRef, refOnSubmit: onSubmitRef, - onInput: handleInput, + onInput: handleInput, // for adjusting height on input }; } diff --git a/tools/server/webui/src/index.scss b/tools/server/webui/src/index.scss index a18f0945..563e7a46 100644 --- a/tools/server/webui/src/index.scss +++ b/tools/server/webui/src/index.scss @@ -22,12 +22,15 @@ html { all: revert; } pre { - @apply whitespace-pre-wrap rounded-lg p-2; + @apply whitespace-pre-wrap rounded-lg p-2 mb-3; border: 1px solid currentColor; } p { @apply mb-2; } + hr { + @apply my-4 border-base-content/20 border-1; + } /* TODO: fix markdown table */ } @@ -35,7 +38,7 @@ html { @apply md:opacity-0 md:group-hover:opacity-100; } .btn-mini { - @apply cursor-pointer hover:shadow-md; + @apply cursor-pointer; } .chat-screen { max-width: 900px; diff --git a/tools/server/webui/src/utils/app.context.tsx b/tools/server/webui/src/utils/app.context.tsx index 54bb65b6..96cffd95 100644 --- a/tools/server/webui/src/utils/app.context.tsx +++ b/tools/server/webui/src/utils/app.context.tsx @@ -3,6 +3,7 @@ import { APIMessage, CanvasData, Conversation, + LlamaCppServerProps, Message, PendingMessage, ViewingChat, @@ -12,9 +13,11 @@ import { filterThoughtFromMsgs, normalizeMsgsForAPI, getSSEStreamAsync, + getServerProps, } from './misc'; import { BASE_URL, CONFIG_DEFAULT, isDev } from '../Config'; import { matchPath, useLocation, useNavigate } from 'react-router'; +import toast from 'react-hot-toast'; interface AppContextValue { // conversations and messages @@ -46,6 +49,9 @@ interface AppContextValue { saveConfig: (config: typeof CONFIG_DEFAULT) => void; showSettings: boolean; setShowSettings: (show: boolean) => void; + + // props + serverProps: LlamaCppServerProps | null; } // this callback is used for scrolling to the bottom of the chat and switching to the last node @@ -74,6 +80,9 @@ export const AppContextProvider = ({ const params = matchPath('/chat/:convId', pathname); const convId = params?.params?.convId; + const [serverProps, setServerProps] = useState( + null + ); const [viewingChat, setViewingChat] = useState(null); const [pendingMessages, setPendingMessages] = useState< Record @@ -85,6 +94,20 @@ export const AppContextProvider = ({ const [canvasData, setCanvasData] = useState(null); const [showSettings, setShowSettings] = useState(false); + // get server props + useEffect(() => { + getServerProps(BASE_URL, config.apiKey) + .then((props) => { + console.debug('Server props:', props); + setServerProps(props); + }) + .catch((err) => { + console.error(err); + toast.error('Failed to fetch server props'); + }); + // eslint-disable-next-line + }, []); + // handle change when the convId from URL is changed useEffect(() => { // also reset the canvas data @@ -260,7 +283,7 @@ export const AppContextProvider = ({ } else { console.error(err); // eslint-disable-next-line @typescript-eslint/no-explicit-any - alert((err as any)?.message ?? 'Unknown error'); + toast.error((err as any)?.message ?? 'Unknown error'); throw err; // rethrow } } @@ -377,6 +400,7 @@ export const AppContextProvider = ({ saveConfig, showSettings, setShowSettings, + serverProps, }} > {children} diff --git a/tools/server/webui/src/utils/common.tsx b/tools/server/webui/src/utils/common.tsx index 09b08b5c..372f464a 100644 --- a/tools/server/webui/src/utils/common.tsx +++ b/tools/server/webui/src/utils/common.tsx @@ -36,3 +36,32 @@ export const OpenInNewTab = ({ {children} ); + +export function BtnWithTooltips({ + className, + onClick, + onMouseLeave, + children, + tooltipsContent, + disabled, +}: { + className?: string; + onClick: () => void; + onMouseLeave?: () => void; + children: React.ReactNode; + tooltipsContent: string; + disabled?: boolean; +}) { + return ( +
+ +
+ ); +} diff --git a/tools/server/webui/src/utils/llama-vscode.ts b/tools/server/webui/src/utils/llama-vscode.ts index 55ebdcff..0ad8f804 100644 --- a/tools/server/webui/src/utils/llama-vscode.ts +++ b/tools/server/webui/src/utils/llama-vscode.ts @@ -1,6 +1,6 @@ -import { useEffect, useState } from 'react'; -import { MessageExtraContext } from './types'; +import { useEffect } from 'react'; import { ChatTextareaApi } from '../components/useChatTextarea.ts'; +import { ChatExtraContextApi } from '../components/useChatExtraContext.tsx'; // Extra context when using llama.cpp WebUI from llama-vscode, inside an iframe // Ref: https://github.com/ggml-org/llama.cpp/pull/11940 @@ -15,11 +15,10 @@ interface SetTextEvData { * window.postMessage({ command: 'setText', text: 'Spot the syntax error', context: 'def test()\n return 123' }, '*'); */ -export const useVSCodeContext = (textarea: ChatTextareaApi) => { - const [extraContext, setExtraContext] = useState( - null - ); - +export const useVSCodeContext = ( + textarea: ChatTextareaApi, + extraContext: ChatExtraContextApi +) => { // Accept setText message from a parent window and set inputMsg and extraContext useEffect(() => { const handleMessage = (event: MessageEvent) => { @@ -27,10 +26,14 @@ export const useVSCodeContext = (textarea: ChatTextareaApi) => { const data: SetTextEvData = event.data; textarea.setValue(data?.text); if (data?.context && data.context.length > 0) { - setExtraContext({ - type: 'context', - content: data.context, - }); + extraContext.clearItems(); + extraContext.addItems([ + { + type: 'context', + name: 'Extra context', + content: data.context, + }, + ]); } textarea.focus(); setTimeout(() => { @@ -41,7 +44,7 @@ export const useVSCodeContext = (textarea: ChatTextareaApi) => { window.addEventListener('message', handleMessage); return () => window.removeEventListener('message', handleMessage); - }, [textarea]); + }, [textarea, extraContext]); // Add a keydown listener that sends the "escapePressed" message to the parent window useEffect(() => { @@ -55,9 +58,5 @@ export const useVSCodeContext = (textarea: ChatTextareaApi) => { return () => window.removeEventListener('keydown', handleKeyDown); }, []); - return { - extraContext, - // call once the user message is sent, to clear the extra context - clearExtraContext: () => setExtraContext(null), - }; + return {}; }; diff --git a/tools/server/webui/src/utils/misc.ts b/tools/server/webui/src/utils/misc.ts index 87f55b2a..ba760e83 100644 --- a/tools/server/webui/src/utils/misc.ts +++ b/tools/server/webui/src/utils/misc.ts @@ -1,6 +1,11 @@ // @ts-expect-error this package does not have typing import TextLineStream from 'textlinestream'; -import { APIMessage, Message } from './types'; +import { + APIMessage, + APIMessageContentPart, + LlamaCppServerProps, + Message, +} from './types'; // ponyfill for missing ReadableStream asyncIterator on Safari import { asyncIterator } from '@sec-ant/readable-stream/ponyfill/asyncIterator'; @@ -57,19 +62,47 @@ export const copyStr = (textToCopy: string) => { */ export function normalizeMsgsForAPI(messages: Readonly) { return messages.map((msg) => { - let newContent = ''; + if (msg.role !== 'user' || !msg.extra) { + return { + role: msg.role, + content: msg.content, + } as APIMessage; + } + + // extra content first, then user text message in the end + // this allow re-using the same cache prefix for long context + const contentArr: APIMessageContentPart[] = []; for (const extra of msg.extra ?? []) { if (extra.type === 'context') { - newContent += `${extra.content}\n\n`; + contentArr.push({ + type: 'text', + text: extra.content, + }); + } else if (extra.type === 'textFile') { + contentArr.push({ + type: 'text', + text: `File: ${extra.name}\nContent:\n\n${extra.content}`, + }); + } else if (extra.type === 'imageFile') { + contentArr.push({ + type: 'image_url', + image_url: { url: extra.base64Url }, + }); + } else { + throw new Error('Unknown extra type'); } } - newContent += msg.content; + // add user message to the end + contentArr.push({ + type: 'text', + text: msg.content, + }); return { role: msg.role, - content: newContent, + content: contentArr, }; }) as APIMessage[]; } @@ -78,13 +111,19 @@ export function normalizeMsgsForAPI(messages: Readonly) { * recommended for DeepsSeek-R1, filter out content between and tags */ export function filterThoughtFromMsgs(messages: APIMessage[]) { + console.debug({ messages }); return messages.map((msg) => { + if (msg.role !== 'assistant') { + return msg; + } + // assistant message is always a string + const contentStr = msg.content as string; return { role: msg.role, content: msg.role === 'assistant' - ? msg.content.split('').at(-1)!.trim() - : msg.content, + ? contentStr.split('').at(-1)!.trim() + : contentStr, } as APIMessage; }); } @@ -126,3 +165,25 @@ export const cleanCurrentUrl = (removeQueryParams: string[]) => { }); window.history.replaceState({}, '', url.toString()); }; + +export const getServerProps = async ( + baseUrl: string, + apiKey?: string +): Promise => { + try { + const response = await fetch(`${baseUrl}/props`, { + headers: { + 'Content-Type': 'application/json', + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), + }, + }); + if (!response.ok) { + throw new Error('Failed to fetch server props'); + } + const data = await response.json(); + return data as LlamaCppServerProps; + } catch (error) { + console.error('Error fetching server props:', error); + throw error; + } +}; diff --git a/tools/server/webui/src/utils/storage.ts b/tools/server/webui/src/utils/storage.ts index 1dfc9d97..505693e9 100644 --- a/tools/server/webui/src/utils/storage.ts +++ b/tools/server/webui/src/utils/storage.ts @@ -116,6 +116,16 @@ const StorageUtils = { }); return conv; }, + /** + * update the name of a conversation + */ + async updateConversationName(convId: string, name: string): Promise { + await db.conversations.update(convId, { + name, + lastModified: Date.now(), + }); + dispatchConversationChange(convId); + }, /** * if convId does not exist, throw an error */ diff --git a/tools/server/webui/src/utils/types.ts b/tools/server/webui/src/utils/types.ts index 0eb77400..add48be4 100644 --- a/tools/server/webui/src/utils/types.ts +++ b/tools/server/webui/src/utils/types.ts @@ -48,7 +48,10 @@ export interface Message { children: Message['id'][]; } -type MessageExtra = MessageExtraTextFile | MessageExtraContext; // TODO: will add more in the future +export type MessageExtra = + | MessageExtraTextFile + | MessageExtraImageFile + | MessageExtraContext; export interface MessageExtraTextFile { type: 'textFile'; @@ -56,12 +59,32 @@ export interface MessageExtraTextFile { content: string; } +export interface MessageExtraImageFile { + type: 'imageFile'; + name: string; + base64Url: string; +} + export interface MessageExtraContext { type: 'context'; + name: string; content: string; } -export type APIMessage = Pick; +export type APIMessageContentPart = + | { + type: 'text'; + text: string; + } + | { + type: 'image_url'; + image_url: { url: string }; + }; + +export type APIMessage = { + role: Message['role']; + content: string | APIMessageContentPart[]; +}; export interface Conversation { id: string; // format: `conv-{timestamp}` @@ -89,3 +112,12 @@ export interface CanvasPyInterpreter { } export type CanvasData = CanvasPyInterpreter; + +// a non-complete list of props, only contains the ones we need +export interface LlamaCppServerProps { + build_info: string; + model_path: string; + n_ctx: number; + has_multimodal: boolean; + // TODO: support params +} diff --git a/tools/server/webui/vite.config.ts b/tools/server/webui/vite.config.ts index b8a0f03d..366df3b7 100644 --- a/tools/server/webui/vite.config.ts +++ b/tools/server/webui/vite.config.ts @@ -71,6 +71,7 @@ export default defineConfig({ server: { proxy: { '/v1': 'http://localhost:8080', + '/props': 'http://localhost:8080', }, headers: { 'Cross-Origin-Embedder-Policy': 'require-corp',