server : (webui) revamp Settings dialog, add Pyodide interpreter (#11759)
* redo Settings modal UI * add python code interpreter * fix auto scroll * build * fix overflow for long output lines * bring back sticky copy button * adapt layout on mobile view * fix multiple lines output and color scheme * handle python exception * better state management * add webworker * add headers * format code * speed up by loading pyodide on page load * (small tweak) add small animation to make it feels like claude
This commit is contained in:
parent
e6e6583199
commit
55ac8c7791
18 changed files with 873 additions and 272 deletions
Binary file not shown.
|
@ -4378,6 +4378,9 @@ int main(int argc, char ** argv) {
|
|||
res.set_content("Error: gzip is not supported by this browser", "text/plain");
|
||||
} else {
|
||||
res.set_header("Content-Encoding", "gzip");
|
||||
// COEP and COOP headers, required by pyodide (python interpreter)
|
||||
res.set_header("Cross-Origin-Embedder-Policy", "require-corp");
|
||||
res.set_header("Cross-Origin-Opener-Policy", "same-origin");
|
||||
res.set_content(reinterpret_cast<const char*>(index_html_gz), index_html_gz_len, "text/html; charset=utf-8");
|
||||
}
|
||||
return false;
|
||||
|
|
10
examples/server/webui/package-lock.json
generated
10
examples/server/webui/package-lock.json
generated
|
@ -8,6 +8,7 @@
|
|||
"name": "webui",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@sec-ant/readable-stream": "^0.6.0",
|
||||
"@vscode/markdown-it-katex": "^1.1.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
|
@ -902,6 +903,15 @@
|
|||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroicons/react": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz",
|
||||
"integrity": "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">= 16 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
|
|
|
@ -11,6 +11,7 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@sec-ant/readable-stream": "^0.6.0",
|
||||
"@vscode/markdown-it-katex": "^1.1.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import { HashRouter, Outlet, Route, Routes } from 'react-router';
|
||||
import Header from './components/Header';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import { AppContextProvider } from './utils/app.context';
|
||||
import { AppContextProvider, useAppContext } from './utils/app.context';
|
||||
import ChatScreen from './components/ChatScreen';
|
||||
import SettingDialog from './components/SettingDialog';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
|
@ -22,13 +23,23 @@ function App() {
|
|||
}
|
||||
|
||||
function AppLayout() {
|
||||
const { showSettings, setShowSettings } = useAppContext();
|
||||
return (
|
||||
<>
|
||||
<Sidebar />
|
||||
<div className="chat-screen drawer-content grow flex flex-col h-screen w-screen mx-auto px-4">
|
||||
<div
|
||||
className="drawer-content grow flex flex-col h-screen w-screen mx-auto px-4 overflow-auto"
|
||||
id="main-scroll"
|
||||
>
|
||||
<Header />
|
||||
<Outlet />
|
||||
</div>
|
||||
{
|
||||
<SettingDialog
|
||||
show={showSettings}
|
||||
onClose={() => setShowSettings(false)}
|
||||
/>
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ export const BASE_URL = new URL('.', document.baseURI).href
|
|||
|
||||
export const CONFIG_DEFAULT = {
|
||||
// Note: in order not to introduce breaking changes, please keep the same data type (number, string, etc) if you want to change the default value. Do not use null or undefined for default value.
|
||||
// Do not use nested objects, keep it single level. Prefix the key if you need to group them.
|
||||
apiKey: '',
|
||||
systemMessage: 'You are a helpful assistant.',
|
||||
showTokensPerSecond: false,
|
||||
|
@ -36,6 +37,8 @@ export const CONFIG_DEFAULT = {
|
|||
dry_penalty_last_n: -1,
|
||||
max_tokens: -1,
|
||||
custom: '', // custom json-stringified object
|
||||
// experimental features
|
||||
pyIntepreterEnabled: false,
|
||||
};
|
||||
export const CONFIG_INFO: Record<string, string> = {
|
||||
apiKey: 'Set the API Key if you are using --api-key option for the server.',
|
||||
|
|
195
examples/server/webui/src/components/CanvasPyInterpreter.tsx
Normal file
195
examples/server/webui/src/components/CanvasPyInterpreter.tsx
Normal file
|
@ -0,0 +1,195 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useAppContext } from '../utils/app.context';
|
||||
import { OpenInNewTab, XCloseButton } from '../utils/common';
|
||||
import { CanvasType } from '../utils/types';
|
||||
import { PlayIcon, StopIcon } from '@heroicons/react/24/outline';
|
||||
import StorageUtils from '../utils/storage';
|
||||
|
||||
const canInterrupt = typeof SharedArrayBuffer === 'function';
|
||||
|
||||
// adapted from https://pyodide.org/en/stable/usage/webworker.html
|
||||
const WORKER_CODE = `
|
||||
importScripts("https://cdn.jsdelivr.net/pyodide/v0.27.2/full/pyodide.js");
|
||||
|
||||
let stdOutAndErr = [];
|
||||
|
||||
let pyodideReadyPromise = loadPyodide({
|
||||
stdout: (data) => stdOutAndErr.push(data),
|
||||
stderr: (data) => stdOutAndErr.push(data),
|
||||
});
|
||||
|
||||
let alreadySetBuff = false;
|
||||
|
||||
self.onmessage = async (event) => {
|
||||
stdOutAndErr = [];
|
||||
|
||||
// make sure loading is done
|
||||
const pyodide = await pyodideReadyPromise;
|
||||
const { id, python, context, interruptBuffer } = event.data;
|
||||
|
||||
if (interruptBuffer && !alreadySetBuff) {
|
||||
pyodide.setInterruptBuffer(interruptBuffer);
|
||||
alreadySetBuff = true;
|
||||
}
|
||||
|
||||
// Now load any packages we need, run the code, and send the result back.
|
||||
await pyodide.loadPackagesFromImports(python);
|
||||
|
||||
// make a Python dictionary with the data from content
|
||||
const dict = pyodide.globals.get("dict");
|
||||
const globals = dict(Object.entries(context));
|
||||
try {
|
||||
self.postMessage({ id, running: true });
|
||||
// Execute the python code in this context
|
||||
const result = pyodide.runPython(python, { globals });
|
||||
self.postMessage({ result, id, stdOutAndErr });
|
||||
} catch (error) {
|
||||
self.postMessage({ error: error.message, id });
|
||||
}
|
||||
interruptBuffer[0] = 0;
|
||||
};
|
||||
`;
|
||||
|
||||
let worker: Worker;
|
||||
const interruptBuffer = canInterrupt
|
||||
? new Uint8Array(new SharedArrayBuffer(1))
|
||||
: null;
|
||||
|
||||
const startWorker = () => {
|
||||
if (!worker) {
|
||||
worker = new Worker(
|
||||
URL.createObjectURL(new Blob([WORKER_CODE], { type: 'text/javascript' }))
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (StorageUtils.getConfig().pyIntepreterEnabled) {
|
||||
startWorker();
|
||||
}
|
||||
|
||||
const runCodeInWorker = (
|
||||
pyCode: string,
|
||||
callbackRunning: () => void
|
||||
): {
|
||||
donePromise: Promise<string>;
|
||||
interrupt: () => void;
|
||||
} => {
|
||||
startWorker();
|
||||
const id = Math.random() * 1e8;
|
||||
const context = {};
|
||||
if (interruptBuffer) {
|
||||
interruptBuffer[0] = 0;
|
||||
}
|
||||
|
||||
const donePromise = new Promise<string>((resolve) => {
|
||||
worker.onmessage = (event) => {
|
||||
const { error, stdOutAndErr, running } = event.data;
|
||||
if (id !== event.data.id) return;
|
||||
if (running) {
|
||||
callbackRunning();
|
||||
return;
|
||||
} else if (error) {
|
||||
resolve(error.toString());
|
||||
} else {
|
||||
resolve(stdOutAndErr.join('\n'));
|
||||
}
|
||||
};
|
||||
worker.postMessage({ id, python: pyCode, context, interruptBuffer });
|
||||
});
|
||||
|
||||
const interrupt = () => {
|
||||
console.log('Interrupting...');
|
||||
console.trace();
|
||||
if (interruptBuffer) {
|
||||
interruptBuffer[0] = 2;
|
||||
}
|
||||
};
|
||||
|
||||
return { donePromise, interrupt };
|
||||
};
|
||||
|
||||
export default function CanvasPyInterpreter() {
|
||||
const { canvasData, setCanvasData } = useAppContext();
|
||||
|
||||
const [code, setCode] = useState(canvasData?.content ?? ''); // copy to avoid direct mutation
|
||||
const [running, setRunning] = useState(false);
|
||||
const [output, setOutput] = useState('');
|
||||
const [interruptFn, setInterruptFn] = useState<() => void>();
|
||||
const [showStopBtn, setShowStopBtn] = useState(false);
|
||||
|
||||
const runCode = async (pycode: string) => {
|
||||
interruptFn?.();
|
||||
setRunning(true);
|
||||
setOutput('Loading Pyodide...');
|
||||
const { donePromise, interrupt } = runCodeInWorker(pycode, () => {
|
||||
setOutput('Running...');
|
||||
setShowStopBtn(canInterrupt);
|
||||
});
|
||||
setInterruptFn(() => interrupt);
|
||||
const out = await donePromise;
|
||||
setOutput(out);
|
||||
setRunning(false);
|
||||
setShowStopBtn(false);
|
||||
};
|
||||
|
||||
// run code on mount
|
||||
useEffect(() => {
|
||||
setCode(canvasData?.content ?? '');
|
||||
runCode(canvasData?.content ?? '');
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [canvasData?.content]);
|
||||
|
||||
if (canvasData?.type !== CanvasType.PY_INTERPRETER) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card bg-base-200 w-full h-full shadow-xl">
|
||||
<div className="card-body">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<span className="text-lg font-bold">Python Interpreter</span>
|
||||
<XCloseButton
|
||||
className="bg-base-100"
|
||||
onClick={() => setCanvasData(null)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-rows-3 gap-4 h-full">
|
||||
<textarea
|
||||
className="textarea textarea-bordered w-full h-full font-mono"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
></textarea>
|
||||
<div className="font-mono flex flex-col row-span-2">
|
||||
<div className="flex items-center mb-2">
|
||||
<button
|
||||
className="btn btn-sm bg-base-100"
|
||||
onClick={() => runCode(code)}
|
||||
disabled={running}
|
||||
>
|
||||
<PlayIcon className="h-6 w-6" /> Run
|
||||
</button>
|
||||
{showStopBtn && (
|
||||
<button
|
||||
className="btn btn-sm bg-base-100 ml-2"
|
||||
onClick={() => interruptFn?.()}
|
||||
>
|
||||
<StopIcon className="h-6 w-6" /> Stop
|
||||
</button>
|
||||
)}
|
||||
<span className="grow text-right text-xs">
|
||||
<OpenInNewTab href="https://github.com/ggerganov/llama.cpp/issues/11762">
|
||||
Report a bug
|
||||
</OpenInNewTab>
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
className="textarea textarea-bordered h-full dark-color"
|
||||
value={output}
|
||||
readOnly
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -149,11 +149,17 @@ export default function ChatMessage({
|
|||
)}
|
||||
</summary>
|
||||
<div className="collapse-content">
|
||||
<MarkdownDisplay content={thought} />
|
||||
<MarkdownDisplay
|
||||
content={thought}
|
||||
isGenerating={isPending}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
<MarkdownDisplay content={content} />
|
||||
<MarkdownDisplay
|
||||
content={content}
|
||||
isGenerating={isPending}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAppContext } from '../utils/app.context';
|
||||
import StorageUtils from '../utils/storage';
|
||||
import { useNavigate } from 'react-router';
|
||||
import ChatMessage from './ChatMessage';
|
||||
import { PendingMessage } from '../utils/types';
|
||||
import { CanvasType, PendingMessage } from '../utils/types';
|
||||
import { classNames } from '../utils/misc';
|
||||
import CanvasPyInterpreter from './CanvasPyInterpreter';
|
||||
|
||||
export default function ChatScreen() {
|
||||
const {
|
||||
|
@ -12,24 +14,24 @@ export default function ChatScreen() {
|
|||
isGenerating,
|
||||
stopGenerating,
|
||||
pendingMessages,
|
||||
canvasData,
|
||||
} = useAppContext();
|
||||
const [inputMsg, setInputMsg] = useState('');
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const currConvId = viewingConversation?.id ?? '';
|
||||
const pendingMsg: PendingMessage | undefined = pendingMessages[currConvId];
|
||||
|
||||
const scrollToBottom = (requiresNearBottom: boolean) => {
|
||||
if (!containerRef.current) return;
|
||||
const msgListElem = containerRef.current;
|
||||
const mainScrollElem = document.getElementById('main-scroll');
|
||||
if (!mainScrollElem) return;
|
||||
const spaceToBottom =
|
||||
msgListElem.scrollHeight -
|
||||
msgListElem.scrollTop -
|
||||
msgListElem.clientHeight;
|
||||
mainScrollElem.scrollHeight -
|
||||
mainScrollElem.scrollTop -
|
||||
mainScrollElem.clientHeight;
|
||||
if (!requiresNearBottom || spaceToBottom < 50) {
|
||||
setTimeout(
|
||||
() => msgListElem.scrollTo({ top: msgListElem.scrollHeight }),
|
||||
() => mainScrollElem.scrollTo({ top: mainScrollElem.scrollHeight }),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
@ -58,66 +60,87 @@ export default function ChatScreen() {
|
|||
}
|
||||
};
|
||||
|
||||
const hasCanvas = !!canvasData;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* chat messages */}
|
||||
<div
|
||||
className={classNames({
|
||||
'grid lg:gap-8 grow transition-[300ms]': true,
|
||||
'grid-cols-[1fr_0fr] lg:grid-cols-[1fr_1fr]': hasCanvas, // adapted for mobile
|
||||
'grid-cols-[1fr_0fr]': !hasCanvas,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
id="messages-list"
|
||||
className="flex flex-col grow overflow-y-auto"
|
||||
ref={containerRef}
|
||||
className={classNames({
|
||||
'flex flex-col w-full max-w-[900px] mx-auto': true,
|
||||
'hidden lg:flex': hasCanvas, // adapted for mobile
|
||||
flex: !hasCanvas,
|
||||
})}
|
||||
>
|
||||
<div className="mt-auto flex justify-center">
|
||||
{/* placeholder to shift the message to the bottom */}
|
||||
{viewingConversation ? '' : 'Send a message to start'}
|
||||
{/* chat messages */}
|
||||
<div id="messages-list" className="grow">
|
||||
<div className="mt-auto flex justify-center">
|
||||
{/* placeholder to shift the message to the bottom */}
|
||||
{viewingConversation ? '' : 'Send a message to start'}
|
||||
</div>
|
||||
{viewingConversation?.messages.map((msg) => (
|
||||
<ChatMessage
|
||||
key={msg.id}
|
||||
msg={msg}
|
||||
scrollToBottom={scrollToBottom}
|
||||
/>
|
||||
))}
|
||||
|
||||
{pendingMsg && (
|
||||
<ChatMessage
|
||||
msg={pendingMsg}
|
||||
scrollToBottom={scrollToBottom}
|
||||
isPending
|
||||
id="pending-msg"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{viewingConversation?.messages.map((msg) => (
|
||||
<ChatMessage key={msg.id} msg={msg} scrollToBottom={scrollToBottom} />
|
||||
))}
|
||||
|
||||
{pendingMsg && (
|
||||
<ChatMessage
|
||||
msg={pendingMsg}
|
||||
scrollToBottom={scrollToBottom}
|
||||
isPending
|
||||
id="pending-msg"
|
||||
/>
|
||||
{/* chat input */}
|
||||
<div className="flex flex-row items-center pt-8 pb-6 sticky bottom-0 bg-base-100">
|
||||
<textarea
|
||||
className="textarea textarea-bordered w-full"
|
||||
placeholder="Type a message (Shift+Enter to add a new line)"
|
||||
value={inputMsg}
|
||||
onChange={(e) => setInputMsg(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && e.shiftKey) return;
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendNewMessage();
|
||||
}
|
||||
}}
|
||||
id="msg-input"
|
||||
dir="auto"
|
||||
></textarea>
|
||||
{isGenerating(currConvId) ? (
|
||||
<button
|
||||
className="btn btn-neutral ml-2"
|
||||
onClick={() => stopGenerating(currConvId)}
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-primary ml-2"
|
||||
onClick={sendNewMessage}
|
||||
disabled={inputMsg.trim().length === 0}
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full sticky top-[7em] h-[calc(100vh-9em)]">
|
||||
{canvasData?.type === CanvasType.PY_INTERPRETER && (
|
||||
<CanvasPyInterpreter />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* chat input */}
|
||||
<div className="flex flex-row items-center mt-8 mb-6">
|
||||
<textarea
|
||||
className="textarea textarea-bordered w-full"
|
||||
placeholder="Type a message (Shift+Enter to add a new line)"
|
||||
value={inputMsg}
|
||||
onChange={(e) => setInputMsg(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && e.shiftKey) return;
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendNewMessage();
|
||||
}
|
||||
}}
|
||||
id="msg-input"
|
||||
dir="auto"
|
||||
></textarea>
|
||||
{isGenerating(currConvId) ? (
|
||||
<button
|
||||
className="btn btn-neutral ml-2"
|
||||
onClick={() => stopGenerating(currConvId)}
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-primary ml-2"
|
||||
onClick={sendNewMessage}
|
||||
disabled={inputMsg.trim().length === 0}
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -5,12 +5,11 @@ import { classNames } from '../utils/misc';
|
|||
import daisyuiThemes from 'daisyui/src/theming/themes';
|
||||
import { THEMES } from '../Config';
|
||||
import { useNavigate } from 'react-router';
|
||||
import SettingDialog from './SettingDialog';
|
||||
|
||||
export default function Header() {
|
||||
const navigate = useNavigate();
|
||||
const [selectedTheme, setSelectedTheme] = useState(StorageUtils.getTheme());
|
||||
const [showSettingDialog, setShowSettingDialog] = useState(false);
|
||||
const { setShowSettings } = useAppContext();
|
||||
|
||||
const setTheme = (theme: string) => {
|
||||
StorageUtils.setTheme(theme);
|
||||
|
@ -54,7 +53,7 @@ export default function Header() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-row items-center mt-6 mb-6">
|
||||
<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
|
||||
|
@ -109,7 +108,7 @@ export default function Header() {
|
|||
</ul>
|
||||
</div>
|
||||
<div className="tooltip tooltip-bottom" data-tip="Settings">
|
||||
<button className="btn" onClick={() => setShowSettingDialog(true)}>
|
||||
<button className="btn" onClick={() => setShowSettings(true)}>
|
||||
{/* settings button */}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
@ -172,11 +171,6 @@ export default function Header() {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingDialog
|
||||
show={showSettingDialog}
|
||||
onClose={() => setShowSettingDialog(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -9,8 +9,16 @@ import 'katex/dist/katex.min.css';
|
|||
import { classNames, copyStr } from '../utils/misc';
|
||||
import { ElementContent, Root } from 'hast';
|
||||
import { visit } from 'unist-util-visit';
|
||||
import { useAppContext } from '../utils/app.context';
|
||||
import { CanvasType } from '../utils/types';
|
||||
|
||||
export default function MarkdownDisplay({ content }: { content: string }) {
|
||||
export default function MarkdownDisplay({
|
||||
content,
|
||||
isGenerating,
|
||||
}: {
|
||||
content: string;
|
||||
isGenerating?: boolean;
|
||||
}) {
|
||||
const preprocessedContent = useMemo(
|
||||
() => preprocessLaTeX(content),
|
||||
[content]
|
||||
|
@ -21,7 +29,11 @@ export default function MarkdownDisplay({ content }: { content: string }) {
|
|||
rehypePlugins={[rehypeHightlight, rehypeKatex, rehypeCustomCopyButton]}
|
||||
components={{
|
||||
button: (props) => (
|
||||
<CopyCodeButton {...props} origContent={preprocessedContent} />
|
||||
<CodeBlockButtons
|
||||
{...props}
|
||||
isGenerating={isGenerating}
|
||||
origContent={preprocessedContent}
|
||||
/>
|
||||
),
|
||||
// note: do not use "pre", "p" or other basic html elements here, it will cause the node to re-render when the message is being generated (this should be a bug with react-markdown, not sure how to fix it)
|
||||
}}
|
||||
|
@ -31,11 +43,12 @@ export default function MarkdownDisplay({ content }: { content: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
const CopyCodeButton: React.ElementType<
|
||||
const CodeBlockButtons: React.ElementType<
|
||||
React.ClassAttributes<HTMLButtonElement> &
|
||||
React.HTMLAttributes<HTMLButtonElement> &
|
||||
ExtraProps & { origContent: string }
|
||||
> = ({ node, origContent }) => {
|
||||
ExtraProps & { origContent: string; isGenerating?: boolean }
|
||||
> = ({ node, origContent, isGenerating }) => {
|
||||
const { config } = useAppContext();
|
||||
const startOffset = node?.position?.start.offset ?? 0;
|
||||
const endOffset = node?.position?.end.offset ?? 0;
|
||||
|
||||
|
@ -48,14 +61,33 @@ const CopyCodeButton: React.ElementType<
|
|||
[origContent, startOffset, endOffset]
|
||||
);
|
||||
|
||||
const codeLanguage = useMemo(
|
||||
() =>
|
||||
origContent
|
||||
.substring(startOffset, startOffset + 10)
|
||||
.match(/^```([^\n]+)\n/)?.[1] ?? '',
|
||||
[origContent, startOffset]
|
||||
);
|
||||
|
||||
const canRunCode =
|
||||
!isGenerating &&
|
||||
config.pyIntepreterEnabled &&
|
||||
codeLanguage.startsWith('py');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames({
|
||||
'text-right sticky top-4 mb-2 mr-2 h-0': true,
|
||||
'text-right sticky top-[7em] mb-2 mr-2 h-0': true,
|
||||
'display-none': !node?.position,
|
||||
})}
|
||||
>
|
||||
<CopyButton className="badge btn-mini" content={copiedContent} />
|
||||
{canRunCode && (
|
||||
<RunPyCodeButton
|
||||
className="badge btn-mini ml-2"
|
||||
content={copiedContent}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -82,6 +114,31 @@ export const CopyButton = ({
|
|||
);
|
||||
};
|
||||
|
||||
export const RunPyCodeButton = ({
|
||||
content,
|
||||
className,
|
||||
}: {
|
||||
content: string;
|
||||
className?: string;
|
||||
}) => {
|
||||
const { setCanvasData } = useAppContext();
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className={className}
|
||||
onClick={() =>
|
||||
setCanvasData({
|
||||
type: CanvasType.PY_INTERPRETER,
|
||||
content,
|
||||
})
|
||||
}
|
||||
>
|
||||
▶️ Run
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* This injects the "button" element before each "pre" element.
|
||||
* The actual button will be replaced with a react component in the MarkdownDisplay.
|
||||
|
@ -95,9 +152,7 @@ function rehypeCustomCopyButton() {
|
|||
// replace current node
|
||||
preNode.properties.visited = 'true';
|
||||
node.tagName = 'div';
|
||||
node.properties = {
|
||||
className: 'relative my-4',
|
||||
};
|
||||
node.properties = {};
|
||||
// add node for button
|
||||
const btnNode: ElementContent = {
|
||||
type: 'element',
|
||||
|
|
|
@ -3,18 +3,27 @@ import { useAppContext } from '../utils/app.context';
|
|||
import { CONFIG_DEFAULT, CONFIG_INFO } from '../Config';
|
||||
import { isDev } from '../Config';
|
||||
import StorageUtils from '../utils/storage';
|
||||
import { isBoolean, isNumeric, isString } from '../utils/misc';
|
||||
import { classNames, isBoolean, isNumeric, isString } from '../utils/misc';
|
||||
import {
|
||||
BeakerIcon,
|
||||
ChatBubbleOvalLeftEllipsisIcon,
|
||||
Cog6ToothIcon,
|
||||
FunnelIcon,
|
||||
HandRaisedIcon,
|
||||
SquaresPlusIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { OpenInNewTab } from '../utils/common';
|
||||
|
||||
type SettKey = keyof typeof CONFIG_DEFAULT;
|
||||
|
||||
const COMMON_SAMPLER_KEYS: SettKey[] = [
|
||||
const BASIC_KEYS: SettKey[] = [
|
||||
'temperature',
|
||||
'top_k',
|
||||
'top_p',
|
||||
'min_p',
|
||||
'max_tokens',
|
||||
];
|
||||
const OTHER_SAMPLER_KEYS: SettKey[] = [
|
||||
const SAMPLER_KEYS: SettKey[] = [
|
||||
'dynatemp_range',
|
||||
'dynatemp_exponent',
|
||||
'typical_p',
|
||||
|
@ -32,6 +41,223 @@ const PENALTY_KEYS: SettKey[] = [
|
|||
'dry_penalty_last_n',
|
||||
];
|
||||
|
||||
enum SettingInputType {
|
||||
SHORT_INPUT,
|
||||
LONG_INPUT,
|
||||
CHECKBOX,
|
||||
CUSTOM,
|
||||
}
|
||||
|
||||
interface SettingFieldInput {
|
||||
type: Exclude<SettingInputType, SettingInputType.CUSTOM>;
|
||||
label: string | React.ReactElement;
|
||||
help?: string | React.ReactElement;
|
||||
key: SettKey;
|
||||
}
|
||||
|
||||
interface SettingFieldCustom {
|
||||
type: SettingInputType.CUSTOM;
|
||||
key: SettKey;
|
||||
component:
|
||||
| string
|
||||
| React.FC<{
|
||||
value: string | boolean | number;
|
||||
onChange: (value: string) => void;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface SettingSection {
|
||||
title: React.ReactElement;
|
||||
fields: (SettingFieldInput | SettingFieldCustom)[];
|
||||
}
|
||||
|
||||
const ICON_CLASSNAME = 'w-4 h-4 mr-1 inline';
|
||||
|
||||
const SETTING_SECTIONS: SettingSection[] = [
|
||||
{
|
||||
title: (
|
||||
<>
|
||||
<Cog6ToothIcon className={ICON_CLASSNAME} />
|
||||
General
|
||||
</>
|
||||
),
|
||||
fields: [
|
||||
{
|
||||
type: SettingInputType.SHORT_INPUT,
|
||||
label: 'API Key',
|
||||
key: 'apiKey',
|
||||
},
|
||||
{
|
||||
type: SettingInputType.LONG_INPUT,
|
||||
label: 'System Message (will be disabled if left empty)',
|
||||
key: 'systemMessage',
|
||||
},
|
||||
...BASIC_KEYS.map(
|
||||
(key) =>
|
||||
({
|
||||
type: SettingInputType.SHORT_INPUT,
|
||||
label: key,
|
||||
key,
|
||||
}) as SettingFieldInput
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
title: (
|
||||
<>
|
||||
<FunnelIcon className={ICON_CLASSNAME} />
|
||||
Samplers
|
||||
</>
|
||||
),
|
||||
fields: [
|
||||
{
|
||||
type: SettingInputType.SHORT_INPUT,
|
||||
label: 'Samplers queue',
|
||||
key: 'samplers',
|
||||
},
|
||||
...SAMPLER_KEYS.map(
|
||||
(key) =>
|
||||
({
|
||||
type: SettingInputType.SHORT_INPUT,
|
||||
label: key,
|
||||
key,
|
||||
}) as SettingFieldInput
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
title: (
|
||||
<>
|
||||
<HandRaisedIcon className={ICON_CLASSNAME} />
|
||||
Penalties
|
||||
</>
|
||||
),
|
||||
fields: PENALTY_KEYS.map((key) => ({
|
||||
type: SettingInputType.SHORT_INPUT,
|
||||
label: key,
|
||||
key,
|
||||
})),
|
||||
},
|
||||
{
|
||||
title: (
|
||||
<>
|
||||
<ChatBubbleOvalLeftEllipsisIcon className={ICON_CLASSNAME} />
|
||||
Reasoning
|
||||
</>
|
||||
),
|
||||
fields: [
|
||||
{
|
||||
type: SettingInputType.CHECKBOX,
|
||||
label: 'Expand though process by default for generating message',
|
||||
key: 'showThoughtInProgress',
|
||||
},
|
||||
{
|
||||
type: SettingInputType.CHECKBOX,
|
||||
label:
|
||||
'Exclude thought process when sending request to API (Recommended for DeepSeek-R1)',
|
||||
key: 'excludeThoughtOnReq',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: (
|
||||
<>
|
||||
<SquaresPlusIcon className={ICON_CLASSNAME} />
|
||||
Advanced
|
||||
</>
|
||||
),
|
||||
fields: [
|
||||
{
|
||||
type: SettingInputType.CUSTOM,
|
||||
key: 'custom', // dummy key, won't be used
|
||||
component: () => {
|
||||
const debugImportDemoConv = async () => {
|
||||
const res = await fetch('/demo-conversation.json');
|
||||
const demoConv = await res.json();
|
||||
StorageUtils.remove(demoConv.id);
|
||||
for (const msg of demoConv.messages) {
|
||||
StorageUtils.appendMsg(demoConv.id, msg);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<button className="btn" onClick={debugImportDemoConv}>
|
||||
(debug) Import demo conversation
|
||||
</button>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
type: SettingInputType.CHECKBOX,
|
||||
label: 'Show tokens per second',
|
||||
key: 'showTokensPerSecond',
|
||||
},
|
||||
{
|
||||
type: SettingInputType.LONG_INPUT,
|
||||
label: (
|
||||
<>
|
||||
Custom JSON config (For more info, refer to{' '}
|
||||
<OpenInNewTab href="https://github.com/ggerganov/llama.cpp/blob/master/examples/server/README.md">
|
||||
server documentation
|
||||
</OpenInNewTab>
|
||||
)
|
||||
</>
|
||||
),
|
||||
key: 'custom',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: (
|
||||
<>
|
||||
<BeakerIcon className={ICON_CLASSNAME} />
|
||||
Experimental
|
||||
</>
|
||||
),
|
||||
fields: [
|
||||
{
|
||||
type: SettingInputType.CUSTOM,
|
||||
key: 'custom', // dummy key, won't be used
|
||||
component: () => (
|
||||
<>
|
||||
<p className="mb-8">
|
||||
Experimental features are not guaranteed to work correctly.
|
||||
<br />
|
||||
<br />
|
||||
If you encounter any problems, create a{' '}
|
||||
<OpenInNewTab href="https://github.com/ggerganov/llama.cpp/issues/new?template=019-bug-misc.yml">
|
||||
Bug (misc.)
|
||||
</OpenInNewTab>{' '}
|
||||
report on Github. Please also specify <b>webui/experimental</b> on
|
||||
the report title and include screenshots.
|
||||
<br />
|
||||
<br />
|
||||
Some features may require packages downloaded from CDN, so they
|
||||
need internet connection.
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: SettingInputType.CHECKBOX,
|
||||
label: (
|
||||
<>
|
||||
<b>Enable Python interpreter</b>
|
||||
<br />
|
||||
<small className="text-xs">
|
||||
This feature uses{' '}
|
||||
<OpenInNewTab href="https://pyodide.org">pyodide</OpenInNewTab>,
|
||||
downloaded from CDN. To use this feature, ask the LLM to generate
|
||||
python code inside a markdown code block. You will see a "Run"
|
||||
button on the code block, near the "Copy" button.
|
||||
</small>
|
||||
</>
|
||||
),
|
||||
key: 'pyIntepreterEnabled',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function SettingDialog({
|
||||
show,
|
||||
onClose,
|
||||
|
@ -40,6 +266,7 @@ export default function SettingDialog({
|
|||
onClose: () => void;
|
||||
}) {
|
||||
const { config, saveConfig } = useAppContext();
|
||||
const [sectionIdx, setSectionIdx] = useState(0);
|
||||
|
||||
// clone the config object to prevent direct mutation
|
||||
const [localConfig, setLocalConfig] = useState<typeof CONFIG_DEFAULT>(
|
||||
|
@ -92,181 +319,109 @@ export default function SettingDialog({
|
|||
onClose();
|
||||
};
|
||||
|
||||
const debugImportDemoConv = async () => {
|
||||
const res = await fetch('/demo-conversation.json');
|
||||
const demoConv = await res.json();
|
||||
StorageUtils.remove(demoConv.id);
|
||||
for (const msg of demoConv.messages) {
|
||||
StorageUtils.appendMsg(demoConv.id, msg);
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const onChange = (key: SettKey) => (value: string | boolean) => {
|
||||
// note: we do not perform validation here, because we may get incomplete value as user is still typing it
|
||||
setLocalConfig({ ...localConfig, [key]: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<dialog className={`modal ${show ? 'modal-open' : ''}`}>
|
||||
<div className="modal-box">
|
||||
<dialog className={classNames({ modal: true, 'modal-open': show })}>
|
||||
<div className="modal-box w-11/12 max-w-3xl">
|
||||
<h3 className="text-lg font-bold mb-6">Settings</h3>
|
||||
<div className="h-[calc(90vh-12rem)] overflow-y-auto">
|
||||
<p className="opacity-40 mb-6">
|
||||
Settings below are saved in browser's localStorage
|
||||
</p>
|
||||
|
||||
<SettingsModalShortInput
|
||||
configKey="apiKey"
|
||||
configDefault={CONFIG_DEFAULT}
|
||||
value={localConfig.apiKey}
|
||||
onChange={onChange('apiKey')}
|
||||
/>
|
||||
|
||||
<label className="form-control mb-2">
|
||||
<div className="label">
|
||||
System Message (will be disabled if left empty)
|
||||
</div>
|
||||
<textarea
|
||||
className="textarea textarea-bordered h-24"
|
||||
placeholder={`Default: ${CONFIG_DEFAULT.systemMessage}`}
|
||||
value={localConfig.systemMessage}
|
||||
onChange={(e) => onChange('systemMessage')(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{COMMON_SAMPLER_KEYS.map((key) => (
|
||||
<SettingsModalShortInput
|
||||
key={key}
|
||||
configKey={key}
|
||||
configDefault={CONFIG_DEFAULT}
|
||||
value={localConfig[key]}
|
||||
onChange={onChange(key)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<details className="collapse collapse-arrow bg-base-200 mb-2 overflow-visible">
|
||||
<summary className="collapse-title font-bold">
|
||||
Other sampler settings
|
||||
</summary>
|
||||
<div className="collapse-content">
|
||||
<SettingsModalShortInput
|
||||
label="Samplers queue"
|
||||
configKey="samplers"
|
||||
configDefault={CONFIG_DEFAULT}
|
||||
value={localConfig.samplers}
|
||||
onChange={onChange('samplers')}
|
||||
/>
|
||||
{OTHER_SAMPLER_KEYS.map((key) => (
|
||||
<SettingsModalShortInput
|
||||
key={key}
|
||||
configKey={key}
|
||||
configDefault={CONFIG_DEFAULT}
|
||||
value={localConfig[key]}
|
||||
onChange={onChange(key)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details className="collapse collapse-arrow bg-base-200 mb-2 overflow-visible">
|
||||
<summary className="collapse-title font-bold">
|
||||
Penalties settings
|
||||
</summary>
|
||||
<div className="collapse-content">
|
||||
{PENALTY_KEYS.map((key) => (
|
||||
<SettingsModalShortInput
|
||||
key={key}
|
||||
configKey={key}
|
||||
configDefault={CONFIG_DEFAULT}
|
||||
value={localConfig[key]}
|
||||
onChange={onChange(key)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details className="collapse collapse-arrow bg-base-200 mb-2 overflow-visible">
|
||||
<summary className="collapse-title font-bold">
|
||||
Reasoning models
|
||||
</summary>
|
||||
<div className="collapse-content">
|
||||
<div className="flex flex-row items-center mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox"
|
||||
checked={localConfig.showThoughtInProgress}
|
||||
onChange={(e) =>
|
||||
onChange('showThoughtInProgress')(e.target.checked)
|
||||
}
|
||||
/>
|
||||
<span className="ml-4">
|
||||
Expand though process by default for generating message
|
||||
</span>
|
||||
<div className="flex flex-col md:flex-row h-[calc(90vh-12rem)]">
|
||||
{/* Left panel, showing sections - Desktop version */}
|
||||
<div className="hidden md:flex flex-col items-stretch pr-4 mr-4 border-r-2 border-base-200">
|
||||
{SETTING_SECTIONS.map((section, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={classNames({
|
||||
'btn btn-ghost justify-start font-normal w-44 mb-1': true,
|
||||
'btn-active': sectionIdx === idx,
|
||||
})}
|
||||
onClick={() => setSectionIdx(idx)}
|
||||
dir="auto"
|
||||
>
|
||||
{section.title}
|
||||
</div>
|
||||
<div className="flex flex-row items-center mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox"
|
||||
checked={localConfig.excludeThoughtOnReq}
|
||||
onChange={(e) =>
|
||||
onChange('excludeThoughtOnReq')(e.target.checked)
|
||||
}
|
||||
/>
|
||||
<span className="ml-4">
|
||||
Exclude thought process when sending request to API
|
||||
(Recommended for DeepSeek-R1)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<details className="collapse collapse-arrow bg-base-200 mb-2 overflow-visible">
|
||||
<summary className="collapse-title font-bold">
|
||||
Advanced config
|
||||
</summary>
|
||||
<div className="collapse-content">
|
||||
{/* this button only shows in dev mode, used to import a demo conversation to test message rendering */}
|
||||
{isDev && (
|
||||
<div className="flex flex-row items-center mb-2">
|
||||
<button className="btn" onClick={debugImportDemoConv}>
|
||||
(debug) Import demo conversation
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-row items-center mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox"
|
||||
checked={localConfig.showTokensPerSecond}
|
||||
onChange={(e) =>
|
||||
onChange('showTokensPerSecond')(e.target.checked)
|
||||
}
|
||||
/>
|
||||
<span className="ml-4">Show tokens per second</span>
|
||||
</div>
|
||||
<label className="form-control mb-2">
|
||||
<div className="label inline">
|
||||
Custom JSON config (For more info, refer to{' '}
|
||||
<a
|
||||
className="underline"
|
||||
href="https://github.com/ggerganov/llama.cpp/blob/master/examples/server/README.md"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{/* Left panel, showing sections - Mobile version */}
|
||||
<div className="md:hidden flex flex-row gap-2 mb-4">
|
||||
<details className="dropdown">
|
||||
<summary className="btn bt-sm w-full m-1">
|
||||
{SETTING_SECTIONS[sectionIdx].title}
|
||||
</summary>
|
||||
<ul className="menu dropdown-content bg-base-100 rounded-box z-[1] w-52 p-2 shadow">
|
||||
{SETTING_SECTIONS.map((section, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={classNames({
|
||||
'btn btn-ghost justify-start font-normal': true,
|
||||
'btn-active': sectionIdx === idx,
|
||||
})}
|
||||
onClick={() => setSectionIdx(idx)}
|
||||
dir="auto"
|
||||
>
|
||||
server documentation
|
||||
</a>
|
||||
)
|
||||
</div>
|
||||
<textarea
|
||||
className="textarea textarea-bordered h-24"
|
||||
placeholder='Example: { "mirostat": 1, "min_p": 0.1 }'
|
||||
value={localConfig.custom}
|
||||
onChange={(e) => onChange('custom')(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
{section.title}
|
||||
</div>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
{/* Right panel, showing setting fields */}
|
||||
<div className="grow overflow-y-auto px-4">
|
||||
{SETTING_SECTIONS[sectionIdx].fields.map((field, idx) => {
|
||||
const key = `${sectionIdx}-${idx}`;
|
||||
if (field.type === SettingInputType.SHORT_INPUT) {
|
||||
return (
|
||||
<SettingsModalShortInput
|
||||
key={key}
|
||||
configKey={field.key}
|
||||
value={localConfig[field.key]}
|
||||
onChange={onChange(field.key)}
|
||||
label={field.label as string}
|
||||
/>
|
||||
);
|
||||
} else if (field.type === SettingInputType.LONG_INPUT) {
|
||||
return (
|
||||
<SettingsModalLongInput
|
||||
key={key}
|
||||
configKey={field.key}
|
||||
value={localConfig[field.key].toString()}
|
||||
onChange={onChange(field.key)}
|
||||
label={field.label as string}
|
||||
/>
|
||||
);
|
||||
} else if (field.type === SettingInputType.CHECKBOX) {
|
||||
return (
|
||||
<SettingsModalCheckbox
|
||||
key={key}
|
||||
configKey={field.key}
|
||||
value={!!localConfig[field.key]}
|
||||
onChange={onChange(field.key)}
|
||||
label={field.label as string}
|
||||
/>
|
||||
);
|
||||
} else if (field.type === SettingInputType.CUSTOM) {
|
||||
return (
|
||||
<div key={key} className="mb-2">
|
||||
{typeof field.component === 'string'
|
||||
? field.component
|
||||
: field.component({
|
||||
value: localConfig[field.key],
|
||||
onChange: onChange(field.key),
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})}
|
||||
|
||||
<p className="opacity-40 mb-6 text-sm mt-8">
|
||||
Settings are saved in browser's localStorage
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-action">
|
||||
|
@ -285,37 +440,97 @@ export default function SettingDialog({
|
|||
);
|
||||
}
|
||||
|
||||
function SettingsModalShortInput({
|
||||
function SettingsModalLongInput({
|
||||
configKey,
|
||||
configDefault,
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
configKey: SettKey;
|
||||
configDefault: typeof CONFIG_DEFAULT;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
value: any;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
label?: string;
|
||||
}) {
|
||||
return (
|
||||
<label className="input input-bordered join-item grow flex items-center gap-2 mb-2">
|
||||
<div className="dropdown dropdown-hover">
|
||||
<div tabIndex={0} role="button" className="font-bold">
|
||||
{label || configKey}
|
||||
</div>
|
||||
<div className="dropdown-content menu bg-base-100 rounded-box z-10 w-64 p-2 shadow mt-4">
|
||||
{CONFIG_INFO[configKey] ?? '(no help message available)'}
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="grow"
|
||||
placeholder={`Default: ${configDefault[configKey] || 'none'}`}
|
||||
<label className="form-control mb-2">
|
||||
<div className="label inline">{label || configKey}</div>
|
||||
<textarea
|
||||
className="textarea textarea-bordered h-24"
|
||||
placeholder={`Default: ${CONFIG_DEFAULT[configKey] || 'none'}`}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsModalShortInput({
|
||||
configKey,
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
configKey: SettKey;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
value: any;
|
||||
onChange: (value: string) => void;
|
||||
label?: string;
|
||||
}) {
|
||||
const helpMsg = CONFIG_INFO[configKey];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* on mobile, we simply show the help message here */}
|
||||
{helpMsg && (
|
||||
<div className="block md:hidden mb-1">
|
||||
<b>{label || configKey}</b>
|
||||
<br />
|
||||
<p className="text-xs">{helpMsg}</p>
|
||||
</div>
|
||||
)}
|
||||
<label className="input input-bordered join-item grow flex items-center gap-2 mb-2">
|
||||
<div className="dropdown dropdown-hover">
|
||||
<div tabIndex={0} role="button" className="font-bold hidden md:block">
|
||||
{label || configKey}
|
||||
</div>
|
||||
{helpMsg && (
|
||||
<div className="dropdown-content menu bg-base-100 rounded-box z-10 w-64 p-2 shadow mt-4">
|
||||
{helpMsg}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="grow"
|
||||
placeholder={`Default: ${CONFIG_DEFAULT[configKey] || 'none'}`}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsModalCheckbox({
|
||||
configKey,
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
configKey: SettKey;
|
||||
value: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-row items-center mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="toggle"
|
||||
checked={value}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
/>
|
||||
<span className="ml-4">{label || configKey}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -45,6 +45,9 @@
|
|||
/* Highlight.js */
|
||||
[data-color-scheme='light'] {
|
||||
@include meta.load-css('highlight.js/styles/stackoverflow-light');
|
||||
.dark-color {
|
||||
@apply bg-base-content text-base-100;
|
||||
}
|
||||
}
|
||||
[data-color-scheme='dark'] {
|
||||
@include meta.load-css('highlight.js/styles/stackoverflow-dark');
|
||||
|
@ -52,6 +55,9 @@
|
|||
[data-color-scheme='auto'] {
|
||||
@media (prefers-color-scheme: light) {
|
||||
@include meta.load-css('highlight.js/styles/stackoverflow-light');
|
||||
.dark-color {
|
||||
@apply bg-base-content text-base-100;
|
||||
}
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@include meta.load-css('highlight.js/styles/stackoverflow-dark');
|
||||
|
|
|
@ -1,5 +1,11 @@
|
|||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { APIMessage, Conversation, Message, PendingMessage } from './types';
|
||||
import {
|
||||
APIMessage,
|
||||
CanvasData,
|
||||
Conversation,
|
||||
Message,
|
||||
PendingMessage,
|
||||
} from './types';
|
||||
import StorageUtils from './storage';
|
||||
import {
|
||||
filterThoughtFromMsgs,
|
||||
|
@ -10,6 +16,7 @@ import { BASE_URL, CONFIG_DEFAULT, isDev } from '../Config';
|
|||
import { matchPath, useLocation } from 'react-router';
|
||||
|
||||
interface AppContextValue {
|
||||
// conversations and messages
|
||||
viewingConversation: Conversation | null;
|
||||
pendingMessages: Record<Conversation['id'], PendingMessage>;
|
||||
isGenerating: (convId: string) => boolean;
|
||||
|
@ -26,8 +33,15 @@ interface AppContextValue {
|
|||
onChunk?: CallbackGeneratedChunk
|
||||
) => Promise<void>;
|
||||
|
||||
// canvas
|
||||
canvasData: CanvasData | null;
|
||||
setCanvasData: (data: CanvasData | null) => void;
|
||||
|
||||
// config
|
||||
config: typeof CONFIG_DEFAULT;
|
||||
saveConfig: (config: typeof CONFIG_DEFAULT) => void;
|
||||
showSettings: boolean;
|
||||
setShowSettings: (show: boolean) => void;
|
||||
}
|
||||
|
||||
// for now, this callback is only used for scrolling to the bottom of the chat
|
||||
|
@ -54,8 +68,13 @@ export const AppContextProvider = ({
|
|||
Record<Conversation['id'], AbortController>
|
||||
>({});
|
||||
const [config, setConfig] = useState(StorageUtils.getConfig());
|
||||
const [canvasData, setCanvasData] = useState<CanvasData | null>(null);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
|
||||
// handle change when the convId from URL is changed
|
||||
useEffect(() => {
|
||||
// also reset the canvas data
|
||||
setCanvasData(null);
|
||||
const handleConversationChange = (changedConvId: string) => {
|
||||
if (changedConvId !== convId) return;
|
||||
setViewingConversation(StorageUtils.getOneConversation(convId));
|
||||
|
@ -292,8 +311,12 @@ export const AppContextProvider = ({
|
|||
sendMessage,
|
||||
stopGenerating,
|
||||
replaceMessageAndGenerate,
|
||||
canvasData,
|
||||
setCanvasData,
|
||||
config,
|
||||
saveConfig,
|
||||
showSettings,
|
||||
setShowSettings,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
|
38
examples/server/webui/src/utils/common.tsx
Normal file
38
examples/server/webui/src/utils/common.tsx
Normal file
|
@ -0,0 +1,38 @@
|
|||
export const XCloseButton: React.ElementType<
|
||||
React.ClassAttributes<HTMLButtonElement> &
|
||||
React.HTMLAttributes<HTMLButtonElement>
|
||||
> = ({ className, ...props }) => (
|
||||
<button className={`btn btn-square btn-sm ${className ?? ''}`} {...props}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
|
||||
export const OpenInNewTab = ({
|
||||
href,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
children: string;
|
||||
}) => (
|
||||
<a
|
||||
className="underline"
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
|
@ -85,3 +85,6 @@ export function classNames(classes: Record<string, boolean>): string {
|
|||
.map(([key, _]) => key)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
export const delay = (ms: number) =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
|
|
@ -23,3 +23,14 @@ export interface Conversation {
|
|||
export type PendingMessage = Omit<Message, 'content'> & {
|
||||
content: string | null;
|
||||
};
|
||||
|
||||
export enum CanvasType {
|
||||
PY_INTERPRETER,
|
||||
}
|
||||
|
||||
export interface CanvasPyInterpreter {
|
||||
type: CanvasType.PY_INTERPRETER;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export type CanvasData = CanvasPyInterpreter;
|
||||
|
|
|
@ -72,5 +72,9 @@ export default defineConfig({
|
|||
proxy: {
|
||||
'/v1': 'http://localhost:8080',
|
||||
},
|
||||
headers: {
|
||||
'Cross-Origin-Embedder-Policy': 'require-corp',
|
||||
'Cross-Origin-Opener-Policy': 'same-origin',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue