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 <hr> tag

* clean up a bit

* build

* add mb-3 for <pre>

* throttle adjustTextareaHeight to make it less laggy

* (nits) missing padding in sidebar

* rm stray console log
This commit is contained in:
Xuan-Son Nguyen 2025-05-08 15:37:29 +02:00 committed by GitHub
parent 1a844be132
commit 8c83449cb7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1142 additions and 320 deletions

View file

@ -125,7 +125,9 @@ std::vector<common_chat_msg> common_chat_msgs_parse_oaicompat(const json & messa
msgs.push_back(msg); msgs.push_back(msg);
} }
} catch (const std::exception & e) { } 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; return msgs;

Binary file not shown.

View file

@ -21,6 +21,8 @@
"postcss": "^8.4.49", "postcss": "^8.4.49",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^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-markdown": "^9.0.3",
"react-router": "^7.1.5", "react-router": "^7.1.5",
"rehype-highlight": "^7.0.2", "rehype-highlight": "^7.0.2",
@ -2058,6 +2060,15 @@
"dev": true, "dev": true,
"license": "Python-2.0" "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": { "node_modules/autoprefixer": {
"version": "10.4.20", "version": "10.4.20",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz",
@ -2804,6 +2815,18 @@
"node": ">=16.0.0" "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": { "node_modules/fill-range": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@ -2917,6 +2940,15 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/graceful-fs": {
"version": "4.2.11", "version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@ -4674,6 +4706,15 @@
"node": ">=0.10.0" "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": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@ -4872,6 +4913,17 @@
"url": "https://github.com/prettier/prettier?sponsor=1" "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": { "node_modules/property-information": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-6.5.0.tgz", "resolved": "https://registry.npmjs.org/property-information/-/property-information-6.5.0.tgz",
@ -4938,6 +4990,46 @@
"react": "^18.3.1" "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": { "node_modules/react-markdown": {
"version": "9.0.3", "version": "9.0.3",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.0.3.tgz", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.0.3.tgz",
@ -5814,7 +5906,6 @@
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"devOptional": true,
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/turbo-stream": { "node_modules/turbo-stream": {

View file

@ -24,6 +24,8 @@
"postcss": "^8.4.49", "postcss": "^8.4.49",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^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-markdown": "^9.0.3",
"react-router": "^7.1.5", "react-router": "^7.1.5",
"rehype-highlight": "^7.0.2", "rehype-highlight": "^7.0.2",

View file

@ -4,6 +4,7 @@ import Sidebar from './components/Sidebar';
import { AppContextProvider, useAppContext } from './utils/app.context'; import { AppContextProvider, useAppContext } from './utils/app.context';
import ChatScreen from './components/ChatScreen'; import ChatScreen from './components/ChatScreen';
import SettingDialog from './components/SettingDialog'; import SettingDialog from './components/SettingDialog';
import { Toaster } from 'react-hot-toast';
function App() { function App() {
return ( return (
@ -40,6 +41,7 @@ function AppLayout() {
onClose={() => setShowSettings(false)} onClose={() => setShowSettings(false)}
/> />
} }
<Toaster />
</> </>
); );
} }

View file

@ -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. // 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. // Do not use nested objects, keep it single level. Prefix the key if you need to group them.
apiKey: '', apiKey: '',
systemMessage: 'You are a helpful assistant.', systemMessage: '',
showTokensPerSecond: false, showTokensPerSecond: false,
showThoughtInProgress: false, showThoughtInProgress: false,
excludeThoughtOnReq: true, excludeThoughtOnReq: true,

View file

@ -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 (
<div className="flex flex-row gap-4 overflow-x-auto py-2 px-1 mb-1">
{items.map((item, i) => (
<div
className="indicator"
key={i}
onClick={() => clickToShow && setShow(i)}
>
{removeItem && (
<div className="indicator-item indicator-top">
<button
className="btn btn-neutral btn-sm w-4 h-4 p-0 rounded-full"
onClick={() => removeItem(i)}
>
<XMarkIcon className="h-3 w-3" />
</button>
</div>
)}
<div
className={classNames({
'flex flex-row rounded-md shadow-sm items-center m-0 p-0': true,
'cursor-pointer hover:shadow-md': !!clickToShow,
})}
>
{item.type === 'imageFile' ? (
<>
<img
src={item.base64Url}
alt={item.name}
className="w-14 h-14 object-cover rounded-md"
/>
</>
) : (
<>
<div className="w-14 h-14 flex items-center justify-center">
<DocumentTextIcon className="h-8 w-14 text-base-content/50" />
</div>
<div className="text-xs pr-4">
<b>{item.name ?? 'Extra content'}</b>
</div>
</>
)}
</div>
</div>
))}
{showingItem && (
<dialog className="modal modal-open">
<div className="modal-box">
<div className="flex justify-between items-center mb-4">
<b>{showingItem.name ?? 'Extra content'}</b>
<button className="btn btn-ghost btn-sm">
<XMarkIcon className="h-5 w-5" onClick={() => setShow(-1)} />
</button>
</div>
{showingItem.type === 'imageFile' ? (
<img src={showingItem.base64Url} alt={showingItem.name} />
) : (
<div className="overflow-x-auto">
<pre className="whitespace-pre-wrap break-words text-sm">
{showingItem.content}
</pre>
</div>
)}
</div>
<div className="modal-backdrop" onClick={() => setShow(-1)}></div>
</dialog>
)}
</div>
);
}

View file

@ -3,7 +3,14 @@ import { useAppContext } from '../utils/app.context';
import { Message, PendingMessage } from '../utils/types'; import { Message, PendingMessage } from '../utils/types';
import { classNames } from '../utils/misc'; import { classNames } from '../utils/misc';
import MarkdownDisplay, { CopyButton } from './MarkdownDisplay'; 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 { interface SplitMessage {
content: PendingMessage['content']; content: PendingMessage['content'];
@ -85,10 +92,14 @@ export default function ChatMessage({
'chat-end': msg.role === 'user', 'chat-end': msg.role === 'user',
})} })}
> >
{msg.extra && msg.extra.length > 0 && (
<ChatInputExtraContextItem items={msg.extra} clickToShow />
)}
<div <div
className={classNames({ className={classNames({
'chat-bubble markdown': true, 'chat-bubble markdown': true,
'chat-bubble-base-300': msg.role !== 'user', 'chat-bubble bg-transparent': msg.role !== 'user',
})} })}
> >
{/* textarea for editing message */} {/* textarea for editing message */}
@ -133,59 +144,11 @@ export default function ChatMessage({
{/* render message as markdown */} {/* render message as markdown */}
<div dir="auto"> <div dir="auto">
{thought && ( {thought && (
<details <ThoughtProcess
className="collapse bg-base-200 collapse-arrow mb-4" isThinking={!!isThinking && !!isPending}
open={isThinking && config.showThoughtInProgress} content={thought}
> open={config.showThoughtInProgress}
<summary className="collapse-title"> />
{isPending && isThinking ? (
<span>
<span
v-if="isGenerating"
className="loading loading-spinner loading-md mr-2"
style={{ verticalAlign: 'middle' }}
></span>
<b>Thinking</b>
</span>
) : (
<b>Thought Process</b>
)}
</summary>
<div className="collapse-content">
<MarkdownDisplay
content={thought}
isGenerating={isPending}
/>
</div>
</details>
)}
{msg.extra && msg.extra.length > 0 && (
<details
className={classNames({
'collapse collapse-arrow mb-4 bg-base-200': true,
'bg-opacity-10': msg.role !== 'assistant',
})}
>
<summary className="collapse-title">
Extra content
</summary>
<div className="collapse-content">
{msg.extra.map(
(extra, i) =>
extra.type === 'textFile' ? (
<div key={extra.name}>
<b>{extra.name}</b>
<pre>{extra.content}</pre>
</div>
) : extra.type === 'context' ? (
<div key={i}>
<pre>{extra.content}</pre>
</div>
) : null // TODO: support other extra types
)}
</div>
</details>
)} )}
<MarkdownDisplay <MarkdownDisplay
@ -259,34 +222,36 @@ export default function ChatMessage({
)} )}
{/* user message */} {/* user message */}
{msg.role === 'user' && ( {msg.role === 'user' && (
<button <BtnWithTooltips
className="badge btn-mini show-on-hover" className="btn-mini show-on-hover w-8 h-8"
onClick={() => setEditingContent(msg.content)} onClick={() => setEditingContent(msg.content)}
disabled={msg.content === null} disabled={msg.content === null}
tooltipsContent="Edit message"
> >
Edit <PencilSquareIcon className="h-4 w-4" />
</button> </BtnWithTooltips>
)} )}
{/* assistant message */} {/* assistant message */}
{msg.role === 'assistant' && ( {msg.role === 'assistant' && (
<> <>
{!isPending && ( {!isPending && (
<button <BtnWithTooltips
className="badge btn-mini show-on-hover mr-2" className="btn-mini show-on-hover w-8 h-8"
onClick={() => { onClick={() => {
if (msg.content !== null) { if (msg.content !== null) {
onRegenerateMessage(msg as Message); onRegenerateMessage(msg as Message);
} }
}} }}
disabled={msg.content === null} disabled={msg.content === null}
tooltipsContent="Regenerate response"
> >
🔄 Regenerate <ArrowPathIcon className="h-4 w-4" />
</button> </BtnWithTooltips>
)} )}
</> </>
)} )}
<CopyButton <CopyButton
className="badge btn-mini show-on-hover mr-2" className="btn-mini show-on-hover w-8 h-8"
content={msg.content} content={msg.content}
/> />
</div> </div>
@ -294,3 +259,44 @@ export default function ChatMessage({
</div> </div>
); );
} }
function ThoughtProcess({
isThinking,
content,
open,
}: {
isThinking: boolean;
content: string;
open: boolean;
}) {
return (
<div
tabIndex={0}
className={classNames({
'collapse bg-none': true,
})}
>
<input type="checkbox" defaultChecked={open} />
<div className="collapse-title px-0">
<div className="btn rounded-xl">
{isThinking ? (
<span>
<span
className="loading loading-spinner loading-md mr-2"
style={{ verticalAlign: 'middle' }}
></span>
Thinking
</span>
) : (
<>Thought Process</>
)}
</div>
</div>
<div className="collapse-content text-base-content/70 text-sm p-1">
<div className="border-l-2 border-base-content/20 pl-4 mb-4">
<MarkdownDisplay content={content} />
</div>
</div>
</div>
);
}

View file

@ -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 { CallbackGeneratedChunk, useAppContext } from '../utils/app.context';
import ChatMessage from './ChatMessage'; import ChatMessage from './ChatMessage';
import { CanvasType, Message, PendingMessage } from '../utils/types'; 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 CanvasPyInterpreter from './CanvasPyInterpreter';
import StorageUtils from '../utils/storage'; import StorageUtils from '../utils/storage';
import { useVSCodeContext } from '../utils/llama-vscode'; import { useVSCodeContext } from '../utils/llama-vscode';
import { useChatTextarea, ChatTextareaApi } from './useChatTextarea.ts'; 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. * A message display is a message node with additional information for rendering.
@ -72,24 +85,6 @@ function getListMessageDisplay(
return res; 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() { export default function ChatScreen() {
const { const {
viewingChat, viewingChat,
@ -102,10 +97,11 @@ export default function ChatScreen() {
} = useAppContext(); } = useAppContext();
const textarea: ChatTextareaApi = useChatTextarea(prefilledMsg.content()); const textarea: ChatTextareaApi = useChatTextarea(prefilledMsg.content());
const extraContext = useChatExtraContext();
useVSCodeContext(textarea, extraContext);
const { extraContext, clearExtraContext } = useVSCodeContext(textarea); const msgListRef = useRef<HTMLDivElement>(null);
// TODO: improve this when we have "upload file" feature useChatScroll(msgListRef);
const currExtra: Message['extra'] = extraContext ? [extraContext] : undefined;
// keep track of leaf node for rendering // keep track of leaf node for rendering
const [currNodeId, setCurrNodeId] = useState<number>(-1); const [currNodeId, setCurrNodeId] = useState<number>(-1);
@ -129,13 +125,15 @@ export default function ChatScreen() {
if (currLeafNodeId) { if (currLeafNodeId) {
setCurrNodeId(currLeafNodeId); setCurrNodeId(currLeafNodeId);
} }
scrollToBottom(true); // useChatScroll will handle the auto scroll
}; };
const sendNewMessage = async () => { const sendNewMessage = async () => {
const lastInpMsg = textarea.value(); 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; return;
}
textarea.setValue(''); textarea.setValue('');
scrollToBottom(false); scrollToBottom(false);
setCurrNodeId(-1); setCurrNodeId(-1);
@ -146,7 +144,7 @@ export default function ChatScreen() {
currConvId, currConvId,
lastMsgNodeId, lastMsgNodeId,
lastInpMsg, lastInpMsg,
currExtra, extraContext.items,
onChunk onChunk
)) ))
) { ) {
@ -154,7 +152,7 @@ export default function ChatScreen() {
textarea.setValue(lastInpMsg); textarea.setValue(lastInpMsg);
} }
// OK // OK
clearExtraContext(); extraContext.clearItems();
}; };
// for vscode context // for vscode context
@ -234,10 +232,17 @@ export default function ChatScreen() {
})} })}
> >
{/* chat messages */} {/* chat messages */}
<div id="messages-list" className="grow"> <div id="messages-list" className="grow" ref={msgListRef}>
<div className="mt-auto flex justify-center"> <div className="mt-auto flex flex-col items-center">
{/* placeholder to shift the message to the bottom */} {/* placeholder to shift the message to the bottom */}
{viewingChat ? '' : 'Send a message to start'} {viewingChat ? (
''
) : (
<>
<div className="mb-4">Send a message to start</div>
<ServerInfo />
</>
)}
</div> </div>
{[...messages, ...pendingMsgDisplay].map((msg) => ( {[...messages, ...pendingMsgDisplay].map((msg) => (
<ChatMessage <ChatMessage
@ -248,46 +253,19 @@ export default function ChatScreen() {
onRegenerateMessage={handleRegenerateMessage} onRegenerateMessage={handleRegenerateMessage}
onEditMessage={handleEditMessage} onEditMessage={handleEditMessage}
onChangeSibling={setCurrNodeId} onChangeSibling={setCurrNodeId}
isPending={msg.isPending}
/> />
))} ))}
</div> </div>
{/* chat input */} {/* chat input */}
<div className="flex flex-row items-end pt-8 pb-6 sticky bottom-0 bg-base-100"> <ChatInput
<textarea textarea={textarea}
// Default (mobile): Enable vertical resize, overflow auto for scrolling if needed extraContext={extraContext}
// Large screens (lg:): Disable manual resize, apply max-height for autosize limit onSend={sendNewMessage}
className="textarea textarea-bordered w-full resize-vertical lg:resize-none lg:max-h-48 lg:overflow-y-auto" // Adjust lg:max-h-48 as needed (e.g., lg:max-h-60) onStop={() => stopGenerating(currConvId ?? '')}
placeholder="Type a message (Shift+Enter to add a new line)" isGenerating={isGenerating(currConvId ?? '')}
ref={textarea.ref} />
onInput={textarea.onInput} // Hook's input handler (will only resize height on lg+ screens)
onKeyDown={(e) => {
if (e.nativeEvent.isComposing || e.keyCode === 229) return;
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendNewMessage();
}
}}
id="msg-input"
dir="auto"
// Set a base height of 2 rows for mobile views
// On lg+ screens, the hook will calculate and set the initial height anyway
rows={2}
></textarea>
{isGenerating(currConvId ?? '') ? (
<button
className="btn btn-neutral ml-2"
onClick={() => stopGenerating(currConvId ?? '')}
>
Stop
</button>
) : (
<button className="btn btn-primary ml-2" onClick={sendNewMessage}>
Send
</button>
)}
</div>
</div> </div>
<div className="w-full sticky top-[7em] h-[calc(100vh-9em)]"> <div className="w-full sticky top-[7em] h-[calc(100vh-9em)]">
{canvasData?.type === CanvasType.PY_INTERPRETER && ( {canvasData?.type === CanvasType.PY_INTERPRETER && (
@ -297,3 +275,129 @@ export default function ChatScreen() {
</div> </div>
); );
} }
function ServerInfo() {
const { serverProps } = useAppContext();
return (
<div className="card card-sm shadow-sm border-1 border-base-content/20 text-base-content/70 mb-6">
<div className="card-body">
<b>Server Info</b>
<p>
<b>Model</b>: {serverProps?.model_path?.split(/(\\|\/)/).pop()}
<br />
<b>Build</b>: {serverProps?.build_info}
<br />
</p>
</div>
</div>
);
}
function ChatInput({
textarea,
extraContext,
onSend,
onStop,
isGenerating,
}: {
textarea: ChatTextareaApi;
extraContext: ChatExtraContextApi;
onSend: () => void;
onStop: () => void;
isGenerating: boolean;
}) {
const [isDrag, setIsDrag] = useState(false);
return (
<div
className={classNames({
'flex items-end pt-8 pb-6 sticky bottom-0 bg-base-100': true,
'opacity-50': isDrag, // simply visual feedback to inform user that the file will be accepted
})}
>
<Dropzone
noClick
onDrop={(files: File[]) => {
setIsDrag(false);
extraContext.onFileAdded(files);
}}
onDragEnter={() => setIsDrag(true)}
onDragLeave={() => setIsDrag(false)}
multiple={true}
>
{({ getRootProps, getInputProps }) => (
<div
className="flex flex-col rounded-xl border-1 border-base-content/30 p-3 w-full"
{...getRootProps()}
>
{!isGenerating && (
<ChatInputExtraContextItem
items={extraContext.items}
removeItem={extraContext.removeItem}
/>
)}
<div className="flex flex-row w-full">
<textarea
// Default (mobile): Enable vertical resize, overflow auto for scrolling if needed
// Large screens (lg:): Disable manual resize, apply max-height for autosize limit
className="text-md outline-none border-none w-full resize-vertical lg:resize-none lg:max-h-48 lg:overflow-y-auto" // Adjust lg:max-h-48 as needed (e.g., lg:max-h-60)
placeholder="Type a message (Shift+Enter to add a new line)"
ref={textarea.ref}
onInput={textarea.onInput} // Hook's input handler (will only resize height on lg+ screens)
onKeyDown={(e) => {
if (e.nativeEvent.isComposing || e.keyCode === 229) return;
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
onSend();
}
}}
id="msg-input"
dir="auto"
// Set a base height of 2 rows for mobile views
// On lg+ screens, the hook will calculate and set the initial height anyway
rows={2}
></textarea>
{/* buttons area */}
<div className="flex flex-row gap-2 ml-2">
<label
htmlFor="file-upload"
className={classNames({
'btn w-8 h-8 p-0 rounded-full': true,
'btn-disabled': isGenerating,
})}
>
<PaperClipIcon className="h-5 w-5" />
</label>
<input
id="file-upload"
type="file"
className="hidden"
disabled={isGenerating}
{...getInputProps()}
hidden
/>
{isGenerating ? (
<button
className="btn btn-neutral w-8 h-8 p-0 rounded-full"
onClick={onStop}
>
<StopIcon className="h-5 w-5" />
</button>
) : (
<button
className="btn btn-primary w-8 h-8 p-0 rounded-full"
onClick={onSend}
>
<ArrowUpIcon className="h-5 w-5" />
</button>
)}
</div>
</div>
</div>
)}
</Dropzone>
</div>
);
}

View file

@ -4,10 +4,13 @@ import { useAppContext } from '../utils/app.context';
import { classNames } from '../utils/misc'; import { classNames } from '../utils/misc';
import daisyuiThemes from 'daisyui/theme/object'; import daisyuiThemes from 'daisyui/theme/object';
import { THEMES } from '../Config'; import { THEMES } from '../Config';
import { useNavigate } from 'react-router'; import {
Cog8ToothIcon,
MoonIcon,
Bars3Icon,
} from '@heroicons/react/24/outline';
export default function Header() { export default function Header() {
const navigate = useNavigate();
const [selectedTheme, setSelectedTheme] = useState(StorageUtils.getTheme()); const [selectedTheme, setSelectedTheme] = useState(StorageUtils.getTheme());
const { setShowSettings } = useAppContext(); const { setShowSettings } = useAppContext();
@ -24,105 +27,21 @@ export default function Header() {
); );
}, [selectedTheme]); }, [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 ( return (
<div className="flex flex-row items-center pt-6 pb-6 sticky top-0 z-10 bg-base-100"> <div className="flex flex-row items-center pt-6 pb-6 sticky top-0 z-10 bg-base-100">
{/* open sidebar button */} {/* open sidebar button */}
<label htmlFor="toggle-drawer" className="btn btn-ghost lg:hidden"> <label htmlFor="toggle-drawer" className="btn btn-ghost lg:hidden">
<svg <Bars3Icon className="h-5 w-5" />
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="bi bi-list"
viewBox="0 0 16 16"
>
<path
fillRule="evenodd"
d="M2.5 12a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5m0-4a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5m0-4a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5"
/>
</svg>
</label> </label>
<div className="grow text-2xl font-bold ml-2">llama.cpp</div> <div className="grow text-2xl font-bold ml-2">llama.cpp</div>
{/* action buttons (top right) */} {/* action buttons (top right) */}
<div className="flex items-center"> <div className="flex items-center">
{viewingChat && (
<div className="dropdown dropdown-end">
{/* "..." button */}
<button
tabIndex={0}
role="button"
className="btn m-1"
disabled={isCurrConvGenerating}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="bi bi-three-dots-vertical"
viewBox="0 0 16 16"
>
<path d="M9.5 13a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0m0-5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0m0-5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0" />
</svg>
</button>
{/* dropdown menu */}
<ul
tabIndex={0}
className="dropdown-content menu bg-base-100 rounded-box z-[1] w-52 p-2 shadow"
>
<li onClick={downloadConversation}>
<a>Download</a>
</li>
<li className="text-error" onClick={removeConversation}>
<a>Delete</a>
</li>
</ul>
</div>
)}
<div className="tooltip tooltip-bottom" data-tip="Settings"> <div className="tooltip tooltip-bottom" data-tip="Settings">
<button className="btn" onClick={() => setShowSettings(true)}> <button className="btn" onClick={() => setShowSettings(true)}>
{/* settings button */} {/* settings button */}
<svg <Cog8ToothIcon className="w-5 h-5" />
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="bi bi-gear"
viewBox="0 0 16 16"
>
<path d="M8 4.754a3.246 3.246 0 1 0 0 6.492 3.246 3.246 0 0 0 0-6.492M5.754 8a2.246 2.246 0 1 1 4.492 0 2.246 2.246 0 0 1-4.492 0" />
<path d="M9.796 1.343c-.527-1.79-3.065-1.79-3.592 0l-.094.319a.873.873 0 0 1-1.255.52l-.292-.16c-1.64-.892-3.433.902-2.54 2.541l.159.292a.873.873 0 0 1-.52 1.255l-.319.094c-1.79.527-1.79 3.065 0 3.592l.319.094a.873.873 0 0 1 .52 1.255l-.16.292c-.892 1.64.901 3.434 2.541 2.54l.292-.159a.873.873 0 0 1 1.255.52l.094.319c.527 1.79 3.065 1.79 3.592 0l.094-.319a.873.873 0 0 1 1.255-.52l.292.16c1.64.893 3.434-.902 2.54-2.541l-.159-.292a.873.873 0 0 1 .52-1.255l.319-.094c1.79-.527 1.79-3.065 0-3.592l-.319-.094a.873.873 0 0 1-.52-1.255l.16-.292c.893-1.64-.902-3.433-2.541-2.54l-.292.159a.873.873 0 0 1-1.255-.52zm-2.633.283c.246-.835 1.428-.835 1.674 0l.094.319a1.873 1.873 0 0 0 2.693 1.115l.291-.16c.764-.415 1.6.42 1.184 1.185l-.159.292a1.873 1.873 0 0 0 1.116 2.692l.318.094c.835.246.835 1.428 0 1.674l-.319.094a1.873 1.873 0 0 0-1.115 2.693l.16.291c.415.764-.42 1.6-1.185 1.184l-.291-.159a1.873 1.873 0 0 0-2.693 1.116l-.094.318c-.246.835-1.428.835-1.674 0l-.094-.319a1.873 1.873 0 0 0-2.692-1.115l-.292.16c-.764.415-1.6-.42-1.184-1.185l.159-.291A1.873 1.873 0 0 0 1.945 8.93l-.319-.094c-.835-.246-.835-1.428 0-1.674l.319-.094A1.873 1.873 0 0 0 3.06 4.377l-.16-.292c-.415-.764.42-1.6 1.185-1.184l.292.159a1.873 1.873 0 0 0 2.692-1.115z" />
</svg>
</button> </button>
</div> </div>
@ -130,16 +49,7 @@ export default function Header() {
<div className="tooltip tooltip-bottom" data-tip="Themes"> <div className="tooltip tooltip-bottom" data-tip="Themes">
<div className="dropdown dropdown-end dropdown-bottom"> <div className="dropdown dropdown-end dropdown-bottom">
<div tabIndex={0} role="button" className="btn m-1"> <div tabIndex={0} role="button" className="btn m-1">
<svg <MoonIcon className="w-5 h-5" />
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="bi bi-palette2"
viewBox="0 0 16 16"
>
<path d="M0 .5A.5.5 0 0 1 .5 0h5a.5.5 0 0 1 .5.5v5.277l4.147-4.131a.5.5 0 0 1 .707 0l3.535 3.536a.5.5 0 0 1 0 .708L10.261 10H15.5a.5.5 0 0 1 .5.5v5a.5.5 0 0 1-.5.5H3a3 3 0 0 1-2.121-.879A3 3 0 0 1 0 13.044m6-.21 7.328-7.3-2.829-2.828L6 7.188zM4.5 13a1.5 1.5 0 1 0-3 0 1.5 1.5 0 0 0 3 0M15 15v-4H9.258l-4.015 4zM0 .5v12.495zm0 12.495V13z" />
</svg>
</div> </div>
<ul <ul
tabIndex={0} tabIndex={0}

View file

@ -11,6 +11,8 @@ import { ElementContent, Root } from 'hast';
import { visit } from 'unist-util-visit'; import { visit } from 'unist-util-visit';
import { useAppContext } from '../utils/app.context'; import { useAppContext } from '../utils/app.context';
import { CanvasType } from '../utils/types'; import { CanvasType } from '../utils/types';
import { BtnWithTooltips } from '../utils/common';
import { DocumentDuplicateIcon, PlayIcon } from '@heroicons/react/24/outline';
export default function MarkdownDisplay({ export default function MarkdownDisplay({
content, content,
@ -81,10 +83,13 @@ const CodeBlockButtons: React.ElementType<
'display-none': !node?.position, 'display-none': !node?.position,
})} })}
> >
<CopyButton className="badge btn-mini" content={copiedContent} /> <CopyButton
className="badge btn-mini btn-soft shadow-sm"
content={copiedContent}
/>
{canRunCode && ( {canRunCode && (
<RunPyCodeButton <RunPyCodeButton
className="badge btn-mini ml-2" className="badge btn-mini shadow-sm ml-2"
content={copiedContent} content={copiedContent}
/> />
)} )}
@ -101,16 +106,17 @@ export const CopyButton = ({
}) => { }) => {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
return ( return (
<button <BtnWithTooltips
className={className} className={className}
onClick={() => { onClick={() => {
copyStr(content); copyStr(content);
setCopied(true); setCopied(true);
}} }}
onMouseLeave={() => setCopied(false)} onMouseLeave={() => setCopied(false)}
tooltipsContent={copied ? 'Copied!' : 'Copy'}
> >
{copied ? 'Copied!' : '📋 Copy'} <DocumentDuplicateIcon className="h-4 w-4" />
</button> </BtnWithTooltips>
); );
}; };
@ -124,7 +130,7 @@ export const RunPyCodeButton = ({
const { setCanvasData } = useAppContext(); const { setCanvasData } = useAppContext();
return ( return (
<> <>
<button <BtnWithTooltips
className={className} className={className}
onClick={() => onClick={() =>
setCanvasData({ setCanvasData({
@ -132,9 +138,10 @@ export const RunPyCodeButton = ({
content, content,
}) })
} }
tooltipsContent="Run code"
> >
Run <PlayIcon className="h-4 w-4" />
</button> </BtnWithTooltips>
</> </>
); );
}; };

View file

@ -1,13 +1,25 @@
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { classNames } from '../utils/misc'; import { classNames } from '../utils/misc';
import { Conversation } from '../utils/types'; import { Conversation } from '../utils/types';
import StorageUtils from '../utils/storage'; import StorageUtils from '../utils/storage';
import { useNavigate, useParams } from 'react-router'; 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() { export default function Sidebar() {
const params = useParams(); const params = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { isGenerating } = useAppContext();
const [conversations, setConversations] = useState<Conversation[]>([]); const [conversations, setConversations] = useState<Conversation[]>([]);
const [currConv, setCurrConv] = useState<Conversation | null>(null); const [currConv, setCurrConv] = useState<Conversation | null>(null);
@ -26,6 +38,11 @@ export default function Sidebar() {
}; };
}, []); }, []);
const groupedConv = useMemo(
() => groupConversationsByDate(conversations),
[conversations]
);
return ( return (
<> <>
<input <input
@ -47,46 +64,96 @@ export default function Sidebar() {
{/* close sidebar button */} {/* close sidebar button */}
<label htmlFor="toggle-drawer" className="btn btn-ghost lg:hidden"> <label htmlFor="toggle-drawer" className="btn btn-ghost lg:hidden">
<svg <XMarkIcon className="w-5 h-5" />
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="bi bi-arrow-bar-left"
viewBox="0 0 16 16"
>
<path
fillRule="evenodd"
d="M12.5 15a.5.5 0 0 1-.5-.5v-13a.5.5 0 0 1 1 0v13a.5.5 0 0 1-.5.5M10 8a.5.5 0 0 1-.5.5H3.707l2.147 2.146a.5.5 0 0 1-.708.708l-3-3a.5.5 0 0 1 0-.708l3-3a.5.5 0 1 1 .708.708L3.707 7.5H9.5a.5.5 0 0 1 .5.5"
/>
</svg>
</label> </label>
</div> </div>
{/* list of conversations */} {/* new conversation button */}
<div <div
className={classNames({ className={classNames({
'btn btn-ghost justify-start': true, 'btn btn-ghost justify-start px-2': true,
'btn-active': !currConv, 'btn-soft': !currConv,
})} })}
onClick={() => navigate('/')} onClick={() => navigate('/')}
> >
+ New conversation + New conversation
</div> </div>
{conversations.map((conv) => (
<div {/* list of conversations */}
key={conv.id} {groupedConv.map((group) => (
className={classNames({ <div>
'btn btn-ghost justify-start font-normal': true, {/* group name (by date) */}
'btn-active': conv.id === currConv?.id, {group.title ? (
})} <b className="block text-xs px-2 mb-2 mt-6">{group.title}</b>
onClick={() => navigate(`/chat/${conv.id}`)} ) : (
dir="auto" <div className="h-2" />
> )}
<span className="truncate">{conv.name}</span>
{group.conversations.map((conv) => (
<ConversationItem
key={conv.id}
conv={conv}
isCurrConv={currConv?.id === conv.id}
onSelect={() => {
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);
}
}}
/>
))}
</div> </div>
))} ))}
<div className="text-center text-xs opacity-40 mt-auto mx-4"> <div className="text-center text-xs opacity-40 mt-auto mx-4 pt-8">
Conversations are saved to browser's IndexedDB Conversations are saved to browser's IndexedDB
</div> </div>
</div> </div>
@ -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 (
<div
className={classNames({
'group flex flex-row btn btn-ghost justify-start items-center font-normal px-2 h-9':
true,
'btn-soft': isCurrConv,
})}
>
<div
key={conv.id}
className="w-full overflow-hidden truncate text-start"
onClick={onSelect}
dir="auto"
>
{conv.name}
</div>
<div className="dropdown dropdown-end h-5">
<BtnWithTooltips
// on mobile, we always show the ellipsis icon
// on desktop, we only show it when the user hovers over the conversation item
// we use opacity instead of hidden to avoid layout shift
className="cursor-pointer opacity-100 md:opacity-0 group-hover:opacity-100"
onClick={() => {}}
tooltipsContent="More"
>
<EllipsisVerticalIcon className="w-5 h-5" />
</BtnWithTooltips>
{/* dropdown menu */}
<ul
tabIndex={0}
className="dropdown-content menu bg-base-100 rounded-box z-[1] p-2 shadow"
>
<li onClick={onRename}>
<a>
<PencilIcon className="w-4 h-4" />
Rename
</a>
</li>
<li onClick={onDownload}>
<a>
<ArrowDownTrayIcon className="w-4 h-4" />
Download
</a>
</li>
<li className="text-error" onClick={onDelete}>
<a>
<TrashIcon className="w-4 h-4" />
Delete
</a>
</li>
</ul>
</div>
</div>
);
}
// 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;
}

View file

@ -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<MessageExtra[]>([]);
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;
}

View file

@ -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<HTMLDivElement>) {
useEffect(() => {
if (!msgListRef.current) return;
const resizeObserver = new ResizeObserver((_) => {
scrollToBottomThrottled(true, 10);
});
resizeObserver.observe(msgListRef.current);
return () => {
resizeObserver.disconnect();
};
}, [msgListRef]);
}

View file

@ -1,35 +1,39 @@
import { useEffect, useRef, useState, useCallback } from 'react'; import { useEffect, useRef, useState, useCallback } from 'react';
import { throttle } from '../utils/misc';
// Media Query for detecting "large" screens (matching Tailwind's lg: breakpoint) // Media Query for detecting "large" screens (matching Tailwind's lg: breakpoint)
const LARGE_SCREEN_MQ = '(min-width: 1024px)'; const LARGE_SCREEN_MQ = '(min-width: 1024px)';
// Calculates and sets the textarea height based on its scrollHeight // Calculates and sets the textarea height based on its scrollHeight
const adjustTextareaHeight = (textarea: HTMLTextAreaElement | null) => { const adjustTextareaHeight = throttle(
if (!textarea) return; (textarea: HTMLTextAreaElement | null) => {
if (!textarea) return;
// Only perform auto-sizing on large screens // Only perform auto-sizing on large screens
if (!window.matchMedia(LARGE_SCREEN_MQ).matches) { if (!window.matchMedia(LARGE_SCREEN_MQ).matches) {
// On small screens, reset inline height and max-height styles. // On small screens, reset inline height and max-height styles.
// This allows CSS (e.g., `rows` attribute or classes) to control the height, // This allows CSS (e.g., `rows` attribute or classes) to control the height,
// and enables manual resizing if `resize-vertical` is set. // and enables manual resizing if `resize-vertical` is set.
textarea.style.height = ''; // Use 'auto' or '' to reset textarea.style.height = ''; // Use 'auto' or '' to reset
textarea.style.maxHeight = ''; textarea.style.maxHeight = '';
return; // Do not adjust height programmatically on small screens return; // Do not adjust height programmatically on small screens
} }
const computedStyle = window.getComputedStyle(textarea); const computedStyle = window.getComputedStyle(textarea);
// Get the max-height specified by CSS (e.g., from `lg:max-h-48`) // Get the max-height specified by CSS (e.g., from `lg:max-h-48`)
const currentMaxHeight = computedStyle.maxHeight; const currentMaxHeight = computedStyle.maxHeight;
// Temporarily remove max-height to allow scrollHeight to be calculated correctly // Temporarily remove max-height to allow scrollHeight to be calculated correctly
textarea.style.maxHeight = 'none'; textarea.style.maxHeight = 'none';
// Reset height to 'auto' to measure the actual scrollHeight needed // Reset height to 'auto' to measure the actual scrollHeight needed
textarea.style.height = 'auto'; textarea.style.height = 'auto';
// Set the height to the calculated scrollHeight // Set the height to the calculated scrollHeight
textarea.style.height = `${textarea.scrollHeight}px`; textarea.style.height = `${textarea.scrollHeight}px`;
// Re-apply the original max-height from CSS to enforce the limit // Re-apply the original max-height from CSS to enforce the limit
textarea.style.maxHeight = currentMaxHeight; textarea.style.maxHeight = currentMaxHeight;
}; },
100
); // Throttle to prevent excessive calls
// Interface describing the API returned by the hook // Interface describing the API returned by the hook
export interface ChatTextareaApi { export interface ChatTextareaApi {
@ -65,6 +69,7 @@ export function useChatTextarea(initValue: string): ChatTextareaApi {
} }
}, [textareaRef, savedInitValue]); // Depend on ref and savedInitValue }, [textareaRef, savedInitValue]); // Depend on ref and savedInitValue
// On input change, we adjust the height of the textarea
const handleInput = useCallback( const handleInput = useCallback(
(event: React.FormEvent<HTMLTextAreaElement>) => { (event: React.FormEvent<HTMLTextAreaElement>) => {
// Call adjustTextareaHeight on every input - it will decide whether to act // Call adjustTextareaHeight on every input - it will decide whether to act
@ -94,6 +99,6 @@ export function useChatTextarea(initValue: string): ChatTextareaApi {
}, },
ref: textareaRef, ref: textareaRef,
refOnSubmit: onSubmitRef, refOnSubmit: onSubmitRef,
onInput: handleInput, onInput: handleInput, // for adjusting height on input
}; };
} }

View file

@ -22,12 +22,15 @@ html {
all: revert; all: revert;
} }
pre { pre {
@apply whitespace-pre-wrap rounded-lg p-2; @apply whitespace-pre-wrap rounded-lg p-2 mb-3;
border: 1px solid currentColor; border: 1px solid currentColor;
} }
p { p {
@apply mb-2; @apply mb-2;
} }
hr {
@apply my-4 border-base-content/20 border-1;
}
/* TODO: fix markdown table */ /* TODO: fix markdown table */
} }
@ -35,7 +38,7 @@ html {
@apply md:opacity-0 md:group-hover:opacity-100; @apply md:opacity-0 md:group-hover:opacity-100;
} }
.btn-mini { .btn-mini {
@apply cursor-pointer hover:shadow-md; @apply cursor-pointer;
} }
.chat-screen { .chat-screen {
max-width: 900px; max-width: 900px;

View file

@ -3,6 +3,7 @@ import {
APIMessage, APIMessage,
CanvasData, CanvasData,
Conversation, Conversation,
LlamaCppServerProps,
Message, Message,
PendingMessage, PendingMessage,
ViewingChat, ViewingChat,
@ -12,9 +13,11 @@ import {
filterThoughtFromMsgs, filterThoughtFromMsgs,
normalizeMsgsForAPI, normalizeMsgsForAPI,
getSSEStreamAsync, getSSEStreamAsync,
getServerProps,
} from './misc'; } from './misc';
import { BASE_URL, CONFIG_DEFAULT, isDev } from '../Config'; import { BASE_URL, CONFIG_DEFAULT, isDev } from '../Config';
import { matchPath, useLocation, useNavigate } from 'react-router'; import { matchPath, useLocation, useNavigate } from 'react-router';
import toast from 'react-hot-toast';
interface AppContextValue { interface AppContextValue {
// conversations and messages // conversations and messages
@ -46,6 +49,9 @@ interface AppContextValue {
saveConfig: (config: typeof CONFIG_DEFAULT) => void; saveConfig: (config: typeof CONFIG_DEFAULT) => void;
showSettings: boolean; showSettings: boolean;
setShowSettings: (show: boolean) => void; 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 // 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 params = matchPath('/chat/:convId', pathname);
const convId = params?.params?.convId; const convId = params?.params?.convId;
const [serverProps, setServerProps] = useState<LlamaCppServerProps | null>(
null
);
const [viewingChat, setViewingChat] = useState<ViewingChat | null>(null); const [viewingChat, setViewingChat] = useState<ViewingChat | null>(null);
const [pendingMessages, setPendingMessages] = useState< const [pendingMessages, setPendingMessages] = useState<
Record<Conversation['id'], PendingMessage> Record<Conversation['id'], PendingMessage>
@ -85,6 +94,20 @@ export const AppContextProvider = ({
const [canvasData, setCanvasData] = useState<CanvasData | null>(null); const [canvasData, setCanvasData] = useState<CanvasData | null>(null);
const [showSettings, setShowSettings] = useState(false); 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 // handle change when the convId from URL is changed
useEffect(() => { useEffect(() => {
// also reset the canvas data // also reset the canvas data
@ -260,7 +283,7 @@ export const AppContextProvider = ({
} else { } else {
console.error(err); console.error(err);
// eslint-disable-next-line @typescript-eslint/no-explicit-any // 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 throw err; // rethrow
} }
} }
@ -377,6 +400,7 @@ export const AppContextProvider = ({
saveConfig, saveConfig,
showSettings, showSettings,
setShowSettings, setShowSettings,
serverProps,
}} }}
> >
{children} {children}

View file

@ -36,3 +36,32 @@ export const OpenInNewTab = ({
{children} {children}
</a> </a>
); );
export function BtnWithTooltips({
className,
onClick,
onMouseLeave,
children,
tooltipsContent,
disabled,
}: {
className?: string;
onClick: () => void;
onMouseLeave?: () => void;
children: React.ReactNode;
tooltipsContent: string;
disabled?: boolean;
}) {
return (
<div className="tooltip tooltip-bottom" data-tip={tooltipsContent}>
<button
className={`${className ?? ''} flex items-center justify-center`}
onClick={onClick}
disabled={disabled}
onMouseLeave={onMouseLeave}
>
{children}
</button>
</div>
);
}

View file

@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect } from 'react';
import { MessageExtraContext } from './types';
import { ChatTextareaApi } from '../components/useChatTextarea.ts'; 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 // Extra context when using llama.cpp WebUI from llama-vscode, inside an iframe
// Ref: https://github.com/ggml-org/llama.cpp/pull/11940 // 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' }, '*'); * window.postMessage({ command: 'setText', text: 'Spot the syntax error', context: 'def test()\n return 123' }, '*');
*/ */
export const useVSCodeContext = (textarea: ChatTextareaApi) => { export const useVSCodeContext = (
const [extraContext, setExtraContext] = useState<MessageExtraContext | null>( textarea: ChatTextareaApi,
null extraContext: ChatExtraContextApi
); ) => {
// Accept setText message from a parent window and set inputMsg and extraContext // Accept setText message from a parent window and set inputMsg and extraContext
useEffect(() => { useEffect(() => {
const handleMessage = (event: MessageEvent) => { const handleMessage = (event: MessageEvent) => {
@ -27,10 +26,14 @@ export const useVSCodeContext = (textarea: ChatTextareaApi) => {
const data: SetTextEvData = event.data; const data: SetTextEvData = event.data;
textarea.setValue(data?.text); textarea.setValue(data?.text);
if (data?.context && data.context.length > 0) { if (data?.context && data.context.length > 0) {
setExtraContext({ extraContext.clearItems();
type: 'context', extraContext.addItems([
content: data.context, {
}); type: 'context',
name: 'Extra context',
content: data.context,
},
]);
} }
textarea.focus(); textarea.focus();
setTimeout(() => { setTimeout(() => {
@ -41,7 +44,7 @@ export const useVSCodeContext = (textarea: ChatTextareaApi) => {
window.addEventListener('message', handleMessage); window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage); return () => window.removeEventListener('message', handleMessage);
}, [textarea]); }, [textarea, extraContext]);
// Add a keydown listener that sends the "escapePressed" message to the parent window // Add a keydown listener that sends the "escapePressed" message to the parent window
useEffect(() => { useEffect(() => {
@ -55,9 +58,5 @@ export const useVSCodeContext = (textarea: ChatTextareaApi) => {
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, []); }, []);
return { return {};
extraContext,
// call once the user message is sent, to clear the extra context
clearExtraContext: () => setExtraContext(null),
};
}; };

View file

@ -1,6 +1,11 @@
// @ts-expect-error this package does not have typing // @ts-expect-error this package does not have typing
import TextLineStream from 'textlinestream'; import TextLineStream from 'textlinestream';
import { APIMessage, Message } from './types'; import {
APIMessage,
APIMessageContentPart,
LlamaCppServerProps,
Message,
} from './types';
// ponyfill for missing ReadableStream asyncIterator on Safari // ponyfill for missing ReadableStream asyncIterator on Safari
import { asyncIterator } from '@sec-ant/readable-stream/ponyfill/asyncIterator'; import { asyncIterator } from '@sec-ant/readable-stream/ponyfill/asyncIterator';
@ -57,19 +62,47 @@ export const copyStr = (textToCopy: string) => {
*/ */
export function normalizeMsgsForAPI(messages: Readonly<Message[]>) { export function normalizeMsgsForAPI(messages: Readonly<Message[]>) {
return messages.map((msg) => { 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 ?? []) { for (const extra of msg.extra ?? []) {
if (extra.type === 'context') { 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 { return {
role: msg.role, role: msg.role,
content: newContent, content: contentArr,
}; };
}) as APIMessage[]; }) as APIMessage[];
} }
@ -78,13 +111,19 @@ export function normalizeMsgsForAPI(messages: Readonly<Message[]>) {
* recommended for DeepsSeek-R1, filter out content between <think> and </think> tags * recommended for DeepsSeek-R1, filter out content between <think> and </think> tags
*/ */
export function filterThoughtFromMsgs(messages: APIMessage[]) { export function filterThoughtFromMsgs(messages: APIMessage[]) {
console.debug({ messages });
return messages.map((msg) => { return messages.map((msg) => {
if (msg.role !== 'assistant') {
return msg;
}
// assistant message is always a string
const contentStr = msg.content as string;
return { return {
role: msg.role, role: msg.role,
content: content:
msg.role === 'assistant' msg.role === 'assistant'
? msg.content.split('</think>').at(-1)!.trim() ? contentStr.split('</think>').at(-1)!.trim()
: msg.content, : contentStr,
} as APIMessage; } as APIMessage;
}); });
} }
@ -126,3 +165,25 @@ export const cleanCurrentUrl = (removeQueryParams: string[]) => {
}); });
window.history.replaceState({}, '', url.toString()); window.history.replaceState({}, '', url.toString());
}; };
export const getServerProps = async (
baseUrl: string,
apiKey?: string
): Promise<LlamaCppServerProps> => {
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;
}
};

View file

@ -116,6 +116,16 @@ const StorageUtils = {
}); });
return conv; return conv;
}, },
/**
* update the name of a conversation
*/
async updateConversationName(convId: string, name: string): Promise<void> {
await db.conversations.update(convId, {
name,
lastModified: Date.now(),
});
dispatchConversationChange(convId);
},
/** /**
* if convId does not exist, throw an error * if convId does not exist, throw an error
*/ */

View file

@ -48,7 +48,10 @@ export interface Message {
children: Message['id'][]; children: Message['id'][];
} }
type MessageExtra = MessageExtraTextFile | MessageExtraContext; // TODO: will add more in the future export type MessageExtra =
| MessageExtraTextFile
| MessageExtraImageFile
| MessageExtraContext;
export interface MessageExtraTextFile { export interface MessageExtraTextFile {
type: 'textFile'; type: 'textFile';
@ -56,12 +59,32 @@ export interface MessageExtraTextFile {
content: string; content: string;
} }
export interface MessageExtraImageFile {
type: 'imageFile';
name: string;
base64Url: string;
}
export interface MessageExtraContext { export interface MessageExtraContext {
type: 'context'; type: 'context';
name: string;
content: string; content: string;
} }
export type APIMessage = Pick<Message, 'role' | 'content'>; 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 { export interface Conversation {
id: string; // format: `conv-{timestamp}` id: string; // format: `conv-{timestamp}`
@ -89,3 +112,12 @@ export interface CanvasPyInterpreter {
} }
export type CanvasData = 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
}

View file

@ -71,6 +71,7 @@ export default defineConfig({
server: { server: {
proxy: { proxy: {
'/v1': 'http://localhost:8080', '/v1': 'http://localhost:8080',
'/props': 'http://localhost:8080',
}, },
headers: { headers: {
'Cross-Origin-Embedder-Policy': 'require-corp', 'Cross-Origin-Embedder-Policy': 'require-corp',