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;
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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": {
"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",
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 (
onClose={() => setShowSettings(false)}
/>
}
+ <Toaster />
</>
);
}
// 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,
--- /dev/null
+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>
+ );
+}
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'];
'chat-end': msg.role === 'user',
})}
>
+ {msg.extra && msg.extra.length > 0 && (
+ <ChatInputExtraContextItem items={msg.extra} clickToShow />
+ )}
+
<div
className={classNames({
'chat-bubble markdown': true,
- 'chat-bubble-base-300': msg.role !== 'user',
+ 'chat-bubble bg-transparent': msg.role !== 'user',
})}
>
{/* textarea for editing message */}
{/* render message as markdown */}
<div dir="auto">
{thought && (
- <details
- className="collapse bg-base-200 collapse-arrow mb-4"
- open={isThinking && 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>
+ <ThoughtProcess
+ isThinking={!!isThinking && !!isPending}
+ content={thought}
+ open={config.showThoughtInProgress}
+ />
)}
<MarkdownDisplay
)}
{/* user message */}
{msg.role === 'user' && (
- <button
- className="badge btn-mini show-on-hover"
+ <BtnWithTooltips
+ className="btn-mini show-on-hover w-8 h-8"
onClick={() => setEditingContent(msg.content)}
disabled={msg.content === null}
+ tooltipsContent="Edit message"
>
- ✍️ Edit
- </button>
+ <PencilSquareIcon className="h-4 w-4" />
+ </BtnWithTooltips>
)}
{/* assistant message */}
{msg.role === 'assistant' && (
<>
{!isPending && (
- <button
- className="badge btn-mini show-on-hover mr-2"
+ <BtnWithTooltips
+ className="btn-mini show-on-hover w-8 h-8"
onClick={() => {
if (msg.content !== null) {
onRegenerateMessage(msg as Message);
}
}}
disabled={msg.content === null}
+ tooltipsContent="Regenerate response"
>
- 🔄 Regenerate
- </button>
+ <ArrowPathIcon className="h-4 w-4" />
+ </BtnWithTooltips>
)}
</>
)}
<CopyButton
- className="badge btn-mini show-on-hover mr-2"
+ className="btn-mini show-on-hover w-8 h-8"
content={msg.content}
/>
</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>
+ );
+}
-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.
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,
} = 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<HTMLDivElement>(null);
+ useChatScroll(msgListRef);
// keep track of leaf node for rendering
const [currNodeId, setCurrNodeId] = useState<number>(-1);
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);
currConvId,
lastMsgNodeId,
lastInpMsg,
- currExtra,
+ extraContext.items,
onChunk
))
) {
textarea.setValue(lastInpMsg);
}
// OK
- clearExtraContext();
+ extraContext.clearItems();
};
// for vscode context
})}
>
{/* chat messages */}
- <div id="messages-list" className="grow">
- <div className="mt-auto flex justify-center">
+ <div id="messages-list" className="grow" ref={msgListRef}>
+ <div className="mt-auto flex flex-col items-center">
{/* 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>
{[...messages, ...pendingMsgDisplay].map((msg) => (
<ChatMessage
onRegenerateMessage={handleRegenerateMessage}
onEditMessage={handleEditMessage}
onChangeSibling={setCurrNodeId}
+ isPending={msg.isPending}
/>
))}
</div>
{/* chat input */}
- <div className="flex flex-row items-end pt-8 pb-6 sticky bottom-0 bg-base-100">
- <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="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)
- 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();
- 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>
+ <ChatInput
+ textarea={textarea}
+ extraContext={extraContext}
+ onSend={sendNewMessage}
+ onStop={() => stopGenerating(currConvId ?? '')}
+ isGenerating={isGenerating(currConvId ?? '')}
+ />
</div>
<div className="w-full sticky top-[7em] h-[calc(100vh-9em)]">
{canvasData?.type === CanvasType.PY_INTERPRETER && (
</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>
+ );
+}
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();
);
}, [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 (
<div className="flex flex-row items-center pt-6 pb-6 sticky top-0 z-10 bg-base-100">
{/* open sidebar button */}
<label htmlFor="toggle-drawer" className="btn btn-ghost lg:hidden">
- <svg
- 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>
+ <Bars3Icon className="h-5 w-5" />
</label>
<div className="grow text-2xl font-bold ml-2">llama.cpp</div>
{/* action buttons (top right) */}
<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">
<button className="btn" onClick={() => setShowSettings(true)}>
{/* settings button */}
- <svg
- 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>
+ <Cog8ToothIcon className="w-5 h-5" />
</button>
</div>
<div className="tooltip tooltip-bottom" data-tip="Themes">
<div className="dropdown dropdown-end dropdown-bottom">
<div tabIndex={0} role="button" className="btn m-1">
- <svg
- 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>
+ <MoonIcon className="w-5 h-5" />
</div>
<ul
tabIndex={0}
import { visit } from 'unist-util-visit';
import { useAppContext } from '../utils/app.context';
import { CanvasType } from '../utils/types';
+import { BtnWithTooltips } from '../utils/common';
+import { DocumentDuplicateIcon, PlayIcon } from '@heroicons/react/24/outline';
export default function MarkdownDisplay({
content,
'display-none': !node?.position,
})}
>
- <CopyButton className="badge btn-mini" content={copiedContent} />
+ <CopyButton
+ className="badge btn-mini btn-soft shadow-sm"
+ content={copiedContent}
+ />
{canRunCode && (
<RunPyCodeButton
- className="badge btn-mini ml-2"
+ className="badge btn-mini shadow-sm ml-2"
content={copiedContent}
/>
)}
}) => {
const [copied, setCopied] = useState(false);
return (
- <button
+ <BtnWithTooltips
className={className}
onClick={() => {
copyStr(content);
setCopied(true);
}}
onMouseLeave={() => setCopied(false)}
+ tooltipsContent={copied ? 'Copied!' : 'Copy'}
>
- {copied ? 'Copied!' : '📋 Copy'}
- </button>
+ <DocumentDuplicateIcon className="h-4 w-4" />
+ </BtnWithTooltips>
);
};
const { setCanvasData } = useAppContext();
return (
<>
- <button
+ <BtnWithTooltips
className={className}
onClick={() =>
setCanvasData({
content,
})
}
+ tooltipsContent="Run code"
>
- ▶️ Run
- </button>
+ <PlayIcon className="h-4 w-4" />
+ </BtnWithTooltips>
</>
);
};
-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<Conversation[]>([]);
const [currConv, setCurrConv] = useState<Conversation | null>(null);
};
}, []);
+ const groupedConv = useMemo(
+ () => groupConversationsByDate(conversations),
+ [conversations]
+ );
+
return (
<>
<input
{/* close sidebar button */}
<label htmlFor="toggle-drawer" className="btn btn-ghost lg:hidden">
- <svg
- 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>
+ <XMarkIcon className="w-5 h-5" />
</label>
</div>
- {/* list of conversations */}
+ {/* new conversation button */}
<div
className={classNames({
- 'btn btn-ghost justify-start': true,
- 'btn-active': !currConv,
+ 'btn btn-ghost justify-start px-2': true,
+ 'btn-soft': !currConv,
})}
onClick={() => navigate('/')}
>
+ New conversation
</div>
- {conversations.map((conv) => (
- <div
- key={conv.id}
- className={classNames({
- 'btn btn-ghost justify-start font-normal': true,
- 'btn-active': conv.id === currConv?.id,
- })}
- onClick={() => navigate(`/chat/${conv.id}`)}
- dir="auto"
- >
- <span className="truncate">{conv.name}</span>
+
+ {/* list of conversations */}
+ {groupedConv.map((group) => (
+ <div>
+ {/* group name (by date) */}
+ {group.title ? (
+ <b className="block text-xs px-2 mb-2 mt-6">{group.title}</b>
+ ) : (
+ <div className="h-2" />
+ )}
+
+ {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 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
</div>
</div>
</>
);
}
+
+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;
+}
--- /dev/null
+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;
+}
--- /dev/null
+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]);
+}
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 {
}
}, [textareaRef, savedInitValue]); // Depend on ref and savedInitValue
+ // On input change, we adjust the height of the textarea
const handleInput = useCallback(
(event: React.FormEvent<HTMLTextAreaElement>) => {
// Call adjustTextareaHeight on every input - it will decide whether to act
},
ref: textareaRef,
refOnSubmit: onSubmitRef,
- onInput: handleInput,
+ onInput: handleInput, // for adjusting height on input
};
}
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 */
}
@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;
APIMessage,
CanvasData,
Conversation,
+ LlamaCppServerProps,
Message,
PendingMessage,
ViewingChat,
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
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
const params = matchPath('/chat/:convId', pathname);
const convId = params?.params?.convId;
+ const [serverProps, setServerProps] = useState<LlamaCppServerProps | null>(
+ null
+ );
const [viewingChat, setViewingChat] = useState<ViewingChat | null>(null);
const [pendingMessages, setPendingMessages] = useState<
Record<Conversation['id'], PendingMessage>
const [canvasData, setCanvasData] = useState<CanvasData | null>(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
} 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
}
}
saveConfig,
showSettings,
setShowSettings,
+ serverProps,
}}
>
{children}
{children}
</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>
+ );
+}
-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
* window.postMessage({ command: 'setText', text: 'Spot the syntax error', context: 'def test()\n return 123' }, '*');
*/
-export const useVSCodeContext = (textarea: ChatTextareaApi) => {
- const [extraContext, setExtraContext] = useState<MessageExtraContext | null>(
- 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) => {
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(() => {
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(() => {
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
- return {
- extraContext,
- // call once the user message is sent, to clear the extra context
- clearExtraContext: () => setExtraContext(null),
- };
+ return {};
};
// @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';
*/
export function normalizeMsgsForAPI(messages: Readonly<Message[]>) {
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[];
}
* recommended for DeepsSeek-R1, filter out content between <think> and </think> 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('</think>').at(-1)!.trim()
- : msg.content,
+ ? contentStr.split('</think>').at(-1)!.trim()
+ : contentStr,
} as APIMessage;
});
}
});
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;
+ }
+};
});
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
*/
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';
content: string;
}
+export interface MessageExtraImageFile {
+ type: 'imageFile';
+ name: string;
+ base64Url: string;
+}
+
export interface MessageExtraContext {
type: 'context';
+ name: 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 {
id: string; // format: `conv-{timestamp}`
}
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
+}
server: {
proxy: {
'/v1': 'http://localhost:8080',
+ '/props': 'http://localhost:8080',
},
headers: {
'Cross-Origin-Embedder-Policy': 'require-corp',