fix auto scroll
This commit is contained in:
parent
0d172936be
commit
cc27754437
13 changed files with 1997 additions and 313 deletions
1540
examples/server/webui/package-lock.json
generated
1540
examples/server/webui/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -21,7 +21,10 @@
|
|||
"postcss": "^8.4.49",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-markdown": "^9.0.3",
|
||||
"react-router": "^7.1.5",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"textlinestream": "^1.1.1",
|
||||
"vite-plugin-singlefile": "^2.0.3"
|
||||
|
|
|
@ -4,7 +4,9 @@ import { isNumeric } from './utils/misc';
|
|||
export const isDev = import.meta.env.MODE === 'development';
|
||||
|
||||
// constants
|
||||
export const BASE_URL = new URL('.', document.baseURI).href.toString().replace(/\/$/, '');
|
||||
export const BASE_URL = new URL('.', document.baseURI).href
|
||||
.toString()
|
||||
.replace(/\/$/, '');
|
||||
|
||||
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.
|
||||
|
|
181
examples/server/webui/src/components/ChatMessage.tsx
Normal file
181
examples/server/webui/src/components/ChatMessage.tsx
Normal file
|
@ -0,0 +1,181 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import { useAppContext } from '../utils/app.context';
|
||||
import { Message, PendingMessage } from '../utils/types';
|
||||
import { classNames } from '../utils/misc';
|
||||
import Markdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeHightlight from 'rehype-highlight';
|
||||
|
||||
export default function ChatMessage({
|
||||
msg,
|
||||
id,
|
||||
scrollToBottom,
|
||||
}: {
|
||||
msg: Message | PendingMessage;
|
||||
id?: string;
|
||||
scrollToBottom: (requiresNearBottom: boolean) => void;
|
||||
}) {
|
||||
const { viewingConversation, replaceMessageAndGenerate, config } =
|
||||
useAppContext();
|
||||
const [editingContent, setEditingContent] = useState<string | null>(null);
|
||||
const timings = useMemo(
|
||||
() =>
|
||||
msg.timings
|
||||
? {
|
||||
...msg.timings,
|
||||
prompt_per_second:
|
||||
(msg.timings.prompt_n / msg.timings.prompt_ms) * 1000,
|
||||
predicted_per_second:
|
||||
(msg.timings.predicted_n / msg.timings.predicted_ms) * 1000,
|
||||
}
|
||||
: null,
|
||||
[msg.timings]
|
||||
);
|
||||
|
||||
if (!viewingConversation) return null;
|
||||
|
||||
const regenerate = async () => {
|
||||
replaceMessageAndGenerate(viewingConversation.id, msg.id, undefined, () =>
|
||||
scrollToBottom(true)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group" id={id}>
|
||||
<div
|
||||
className={classNames({
|
||||
chat: true,
|
||||
'chat-start': msg.role !== 'user',
|
||||
'chat-end': msg.role === 'user',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={classNames({
|
||||
'chat-bubble markdown': true,
|
||||
'chat-bubble-base-300': msg.role !== 'user',
|
||||
})}
|
||||
>
|
||||
{/* textarea for editing message */}
|
||||
{editingContent !== null && (
|
||||
<>
|
||||
<textarea
|
||||
dir="auto"
|
||||
className="textarea textarea-bordered bg-base-100 text-base-content w-[calc(90vw-8em)] lg:w-96"
|
||||
value={editingContent}
|
||||
onChange={(e) => setEditingContent(e.target.value)}
|
||||
></textarea>
|
||||
<br />
|
||||
<button
|
||||
className="btn btn-ghost mt-2 mr-2"
|
||||
onClick={() => setEditingContent(null)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn mt-2"
|
||||
onClick={() =>
|
||||
replaceMessageAndGenerate(
|
||||
viewingConversation.id,
|
||||
msg.id,
|
||||
editingContent
|
||||
)
|
||||
}
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{editingContent === null && (
|
||||
<>
|
||||
{msg.content === null ? (
|
||||
<>
|
||||
{/* show loading dots for pending message */}
|
||||
<span className="loading loading-dots loading-md"></span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* render message as markdown */}
|
||||
<div dir="auto">
|
||||
<MarkdownDisplay content={msg.content} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{/* render timings if enabled */}
|
||||
{timings && config.showTokensPerSecond && (
|
||||
<div className="dropdown dropdown-hover dropdown-top mt-2">
|
||||
<div
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
className="cursor-pointer font-semibold text-sm opacity-60"
|
||||
>
|
||||
Speed: {timings.predicted_per_second.toFixed(1)} t/s
|
||||
</div>
|
||||
<div className="dropdown-content bg-base-100 z-10 w-64 p-2 shadow mt-4">
|
||||
<b>Prompt</b>
|
||||
<br />- Tokens: {timings.prompt_n}
|
||||
<br />- Time: {timings.prompt_ms} ms
|
||||
<br />- Speed: {timings.prompt_per_second.toFixed(1)} t/s
|
||||
<br />
|
||||
<b>Generation</b>
|
||||
<br />- Tokens: {timings.predicted_n}
|
||||
<br />- Time: {timings.predicted_ms} ms
|
||||
<br />- Speed: {timings.predicted_per_second.toFixed(1)} t/s
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* actions for each message */}
|
||||
{msg.content !== null && (
|
||||
<div
|
||||
className={classNames({
|
||||
'mx-4 mt-2 mb-2': true,
|
||||
'text-right': msg.role === 'user',
|
||||
})}
|
||||
>
|
||||
{/* user message */}
|
||||
{msg.role === 'user' && (
|
||||
<button
|
||||
className="badge btn-mini show-on-hover"
|
||||
onClick={() => setEditingContent(msg.content)}
|
||||
disabled={msg.content === null}
|
||||
>
|
||||
✍️ Edit
|
||||
</button>
|
||||
)}
|
||||
{/* assistant message */}
|
||||
{msg.role === 'assistant' && (
|
||||
<>
|
||||
<button
|
||||
className="badge btn-mini show-on-hover mr-2"
|
||||
onClick={regenerate}
|
||||
disabled={msg.content === null}
|
||||
>
|
||||
🔄 Regenerate
|
||||
</button>
|
||||
<button
|
||||
className="badge btn-mini show-on-hover mr-2"
|
||||
onClick={() => navigator.clipboard.writeText(msg.content || '')}
|
||||
disabled={msg.content === null}
|
||||
>
|
||||
📋 Copy
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MarkdownDisplay({ content }: { content: string }) {
|
||||
return (
|
||||
<Markdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHightlight]}>
|
||||
{content}
|
||||
</Markdown>
|
||||
);
|
||||
}
|
|
@ -1,8 +1,8 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useAppContext } from '../utils/app.context';
|
||||
import StorageUtils from '../utils/storage';
|
||||
import { Message, PendingMessage } from '../utils/types';
|
||||
import { classNames } from '../utils/misc';
|
||||
import { useNavigate } from 'react-router';
|
||||
import ChatMessage from './ChatMessage';
|
||||
|
||||
export default function ChatScreen() {
|
||||
const {
|
||||
|
@ -13,24 +13,69 @@ export default function ChatScreen() {
|
|||
pendingMessage,
|
||||
} = useAppContext();
|
||||
const [inputMsg, setInputMsg] = useState('');
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const convId = viewingConversation?.id ?? StorageUtils.getNewConvId();
|
||||
const scrollToBottom = (requiresNearBottom: boolean) => {
|
||||
if (!containerRef.current) return;
|
||||
const msgListElem = containerRef.current;
|
||||
const spaceToBottom =
|
||||
msgListElem.scrollHeight -
|
||||
msgListElem.scrollTop -
|
||||
msgListElem.clientHeight;
|
||||
if (!requiresNearBottom || spaceToBottom < 100) {
|
||||
setTimeout(
|
||||
() => msgListElem.scrollTo({ top: msgListElem.scrollHeight }),
|
||||
1
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// scroll to bottom when conversation changes
|
||||
useEffect(() => {
|
||||
scrollToBottom(false);
|
||||
}, [viewingConversation?.id]);
|
||||
|
||||
const sendNewMessage = async () => {
|
||||
if (inputMsg.trim().length === 0) return;
|
||||
const convId = viewingConversation?.id ?? StorageUtils.getNewConvId();
|
||||
const lastInpMsg = inputMsg;
|
||||
setInputMsg('');
|
||||
if (!viewingConversation) {
|
||||
// if user is creating a new conversation, redirect to the new conversation
|
||||
navigate(`/chat/${convId}`);
|
||||
}
|
||||
const onChunk = () => scrollToBottom(true);
|
||||
if (!(await sendMessage(convId, inputMsg, onChunk))) {
|
||||
// restore the input message if failed
|
||||
setInputMsg(lastInpMsg);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* chat messages */}
|
||||
<div id="messages-list" className="flex flex-col grow overflow-y-auto">
|
||||
<div
|
||||
id="messages-list"
|
||||
className="flex flex-col grow overflow-y-auto"
|
||||
ref={containerRef}
|
||||
>
|
||||
<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) => (
|
||||
<MessageBubble key={msg.id} msg={msg} />
|
||||
))}
|
||||
{viewingConversation?.messages.map((msg) => (
|
||||
<ChatMessage key={msg.id} msg={msg} scrollToBottom={scrollToBottom} />
|
||||
))}
|
||||
|
||||
{pendingMessage !== null && (
|
||||
<MessageBubble msg={pendingMessage} id="pending-msg" />
|
||||
)}
|
||||
{pendingMessage !== null &&
|
||||
pendingMessage.convId === viewingConversation?.id && (
|
||||
<ChatMessage
|
||||
msg={pendingMessage}
|
||||
scrollToBottom={scrollToBottom}
|
||||
id="pending-msg"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* chat input */}
|
||||
|
@ -40,6 +85,13 @@ export default function ChatScreen() {
|
|||
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>
|
||||
|
@ -50,7 +102,7 @@ export default function ChatScreen() {
|
|||
) : (
|
||||
<button
|
||||
className="btn btn-primary ml-2"
|
||||
onClick={() => sendMessage(convId, inputMsg)}
|
||||
onClick={sendNewMessage}
|
||||
disabled={inputMsg.trim().length === 0}
|
||||
>
|
||||
Send
|
||||
|
@ -60,163 +112,3 @@ export default function ChatScreen() {
|
|||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageBubble({ msg, id }: { msg: Message | PendingMessage, id?: string }) {
|
||||
const { viewingConversation, replaceMessageAndGenerate, config } =
|
||||
useAppContext();
|
||||
const [editingContent, setEditingContent] = useState<string | null>(null);
|
||||
const timings = useMemo(
|
||||
() =>
|
||||
msg.timings
|
||||
? {
|
||||
...msg.timings,
|
||||
prompt_per_second:
|
||||
(msg.timings.prompt_n / msg.timings.prompt_ms) * 1000,
|
||||
predicted_per_second:
|
||||
(msg.timings.predicted_n / msg.timings.predicted_ms) * 1000,
|
||||
}
|
||||
: null,
|
||||
[msg.timings]
|
||||
);
|
||||
|
||||
if (!viewingConversation) return null;
|
||||
|
||||
return (
|
||||
<div className="group" id={id}>
|
||||
<div
|
||||
className={classNames({
|
||||
chat: true,
|
||||
'chat-start': msg.role !== 'user',
|
||||
'chat-end': msg.role === 'user',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={classNames({
|
||||
'chat-bubble markdown': true,
|
||||
'chat-bubble-base-300': msg.role !== 'user',
|
||||
})}
|
||||
>
|
||||
{/* textarea for editing message */}
|
||||
{editingContent !== null && (
|
||||
<>
|
||||
<textarea
|
||||
dir="auto"
|
||||
className="textarea textarea-bordered bg-base-100 text-base-content w-[calc(90vw-8em)] lg:w-96"
|
||||
value={editingContent}
|
||||
onChange={(e) => setEditingContent(e.target.value)}
|
||||
></textarea>
|
||||
<br />
|
||||
<button
|
||||
className="btn btn-ghost mt-2 mr-2"
|
||||
onClick={() => setEditingContent(null)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn mt-2"
|
||||
onClick={() =>
|
||||
replaceMessageAndGenerate(
|
||||
viewingConversation.id,
|
||||
msg.id,
|
||||
editingContent
|
||||
)
|
||||
}
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{editingContent === null && (
|
||||
<>
|
||||
{msg.content === null ? (
|
||||
<>
|
||||
{/* show loading dots for pending message */}
|
||||
<span
|
||||
className="loading loading-dots loading-md"
|
||||
></span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* render message as markdown */}
|
||||
<div dir="auto">
|
||||
{msg.content}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{/* render timings if enabled */}
|
||||
{timings && config.showTokensPerSecond && (
|
||||
<div className="dropdown dropdown-hover dropdown-top mt-2">
|
||||
<div
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
className="cursor-pointer font-semibold text-sm opacity-60"
|
||||
>
|
||||
Speed: {timings.predicted_per_second.toFixed(1)} t/s
|
||||
</div>
|
||||
<div className="dropdown-content bg-base-100 z-10 w-64 p-2 shadow mt-4">
|
||||
<b>Prompt</b>
|
||||
<br />- Tokens: {timings.prompt_n}
|
||||
<br />- Time: {timings.prompt_ms} ms
|
||||
<br />- Speed: {timings.prompt_per_second.toFixed(1)} t/s
|
||||
<br />
|
||||
<b>Generation</b>
|
||||
<br />- Tokens: {timings.predicted_n}
|
||||
<br />- Time: {timings.predicted_ms} ms
|
||||
<br />- Speed: {timings.predicted_per_second.toFixed(1)} t/s
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* actions for each message */}
|
||||
{msg.content !== null && (
|
||||
<div
|
||||
className={classNames({
|
||||
'mx-4 mt-2 mb-2': true,
|
||||
'text-right': msg.role === 'user',
|
||||
})}
|
||||
>
|
||||
{/* user message */}
|
||||
{msg.role === 'user' && (
|
||||
<button
|
||||
className="badge btn-mini show-on-hover"
|
||||
onClick={() => setEditingContent(msg.content)}
|
||||
disabled={msg.content === null}
|
||||
>
|
||||
✍️ Edit
|
||||
</button>
|
||||
)}
|
||||
{/* assistant message */}
|
||||
{msg.role === 'assistant' && (
|
||||
<>
|
||||
<button
|
||||
className="badge btn-mini show-on-hover mr-2"
|
||||
onClick={() =>
|
||||
replaceMessageAndGenerate(
|
||||
viewingConversation.id,
|
||||
msg.id,
|
||||
undefined
|
||||
)
|
||||
}
|
||||
disabled={msg.content === null}
|
||||
>
|
||||
🔄 Regenerate
|
||||
</button>
|
||||
<button
|
||||
className="badge btn-mini show-on-hover mr-2"
|
||||
onClick={() => navigator.clipboard.writeText(msg.content || '')}
|
||||
disabled={msg.content === null}
|
||||
>
|
||||
📋 Copy
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import StorageUtils from '../utils/storage';
|
||||
import { useAppContext } from '../utils/app.context';
|
||||
import { classNames } from '../utils/misc';
|
||||
|
@ -13,26 +13,29 @@ export default function Header() {
|
|||
const [showSettingDialog, setShowSettingDialog] = useState(false);
|
||||
|
||||
const setTheme = (theme: string) => {
|
||||
document.body.setAttribute('data-theme', theme);
|
||||
document.body.setAttribute(
|
||||
'data-color-scheme',
|
||||
// @ts-expect-error daisyuiThemes complains about index type, but it should work
|
||||
daisyuiThemes[theme]?.['color-scheme'] ?? 'auto'
|
||||
);
|
||||
StorageUtils.setTheme(theme);
|
||||
setSelectedTheme(theme);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.body.setAttribute('data-theme', selectedTheme);
|
||||
document.body.setAttribute(
|
||||
'data-color-scheme',
|
||||
// @ts-expect-error daisyuiThemes complains about index type, but it should work
|
||||
daisyuiThemes[selectedTheme]?.['color-scheme'] ?? 'auto'
|
||||
);
|
||||
}, [selectedTheme]);
|
||||
|
||||
const { isGenerating, viewingConversation } = useAppContext();
|
||||
|
||||
const removeConversation = () => {
|
||||
if (isGenerating || !viewingConversation) return;
|
||||
const convId = viewingConversation.id;
|
||||
if (window.confirm('Are you sure to delete this conversation?')) {
|
||||
StorageUtils.remove(convId);
|
||||
navigate('/');
|
||||
}
|
||||
};
|
||||
if (window.confirm('Are you sure to delete this conversation?')) {
|
||||
StorageUtils.remove(convId);
|
||||
navigate('/');
|
||||
}
|
||||
};
|
||||
|
||||
const downloadConversation = () => {
|
||||
if (isGenerating || !viewingConversation) return;
|
||||
|
@ -47,7 +50,7 @@ export default function Header() {
|
|||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-row items-center mt-6 mb-6">
|
||||
|
@ -105,7 +108,11 @@ export default function Header() {
|
|||
</ul>
|
||||
</div>
|
||||
<div className="tooltip tooltip-bottom" data-tip="Settings">
|
||||
<button className="btn" disabled={isGenerating} onClick={() => setShowSettingDialog(true)}>
|
||||
<button
|
||||
className="btn"
|
||||
disabled={isGenerating}
|
||||
onClick={() => setShowSettingDialog(true)}
|
||||
>
|
||||
{/* settings button */}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
@ -169,7 +176,10 @@ export default function Header() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<SettingDialog show={showSettingDialog} onClose={() => setShowSettingDialog(false)} />
|
||||
<SettingDialog
|
||||
show={showSettingDialog}
|
||||
onClose={() => setShowSettingDialog(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -6,19 +6,44 @@ import StorageUtils from '../utils/storage';
|
|||
|
||||
type SettKey = keyof typeof CONFIG_DEFAULT;
|
||||
|
||||
const COMMON_SAMPLER_KEYS: SettKey[] = ['temperature', 'top_k', 'top_p', 'min_p', 'max_tokens'];
|
||||
const OTHER_SAMPLER_KEYS: SettKey[] = ['dynatemp_range', 'dynatemp_exponent', 'typical_p', 'xtc_probability', 'xtc_threshold'];
|
||||
const PENALTY_KEYS: SettKey[] = ['repeat_last_n', 'repeat_penalty', 'presence_penalty', 'frequency_penalty',
|
||||
'dry_multiplier', 'dry_base', 'dry_allowed_length', 'dry_penalty_last_n'];
|
||||
const COMMON_SAMPLER_KEYS: SettKey[] = [
|
||||
'temperature',
|
||||
'top_k',
|
||||
'top_p',
|
||||
'min_p',
|
||||
'max_tokens',
|
||||
];
|
||||
const OTHER_SAMPLER_KEYS: SettKey[] = [
|
||||
'dynatemp_range',
|
||||
'dynatemp_exponent',
|
||||
'typical_p',
|
||||
'xtc_probability',
|
||||
'xtc_threshold',
|
||||
];
|
||||
const PENALTY_KEYS: SettKey[] = [
|
||||
'repeat_last_n',
|
||||
'repeat_penalty',
|
||||
'presence_penalty',
|
||||
'frequency_penalty',
|
||||
'dry_multiplier',
|
||||
'dry_base',
|
||||
'dry_allowed_length',
|
||||
'dry_penalty_last_n',
|
||||
];
|
||||
|
||||
export default function SettingDialog({ show, onClose }: {
|
||||
export default function SettingDialog({
|
||||
show,
|
||||
onClose,
|
||||
}: {
|
||||
show: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { config, saveConfig } = useAppContext();
|
||||
|
||||
// clone the config object to prevent direct mutation
|
||||
const [localConfig, setLocalConfig] = useState<typeof CONFIG_DEFAULT>(JSON.parse(JSON.stringify(config)));
|
||||
const [localConfig, setLocalConfig] = useState<typeof CONFIG_DEFAULT>(
|
||||
JSON.parse(JSON.stringify(config))
|
||||
);
|
||||
|
||||
const resetConfig = () => {
|
||||
if (window.confirm('Are you sure to reset all settings?')) {
|
||||
|
@ -39,20 +64,24 @@ export default function SettingDialog({ show, onClose }: {
|
|||
StorageUtils.appendMsg(demoConv.id, msg);
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<dialog className={`modal ${show ? 'modal-open' : ''}`}>
|
||||
<div className="modal-box">
|
||||
<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>
|
||||
|
||||
<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={(value) => setLocalConfig({ ...localConfig, apiKey: value })}
|
||||
onChange={(value) =>
|
||||
setLocalConfig({ ...localConfig, apiKey: value })
|
||||
}
|
||||
/>
|
||||
|
||||
<label className="form-control mb-2">
|
||||
|
@ -61,7 +90,12 @@ export default function SettingDialog({ show, onClose }: {
|
|||
className="textarea textarea-bordered h-24"
|
||||
placeholder={`Default: ${CONFIG_DEFAULT.systemMessage}`}
|
||||
value={localConfig.systemMessage}
|
||||
onChange={(e) => setLocalConfig({ ...localConfig, systemMessage: e.target.value })}
|
||||
onChange={(e) =>
|
||||
setLocalConfig({
|
||||
...localConfig,
|
||||
systemMessage: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
|
@ -71,19 +105,25 @@ export default function SettingDialog({ show, onClose }: {
|
|||
configKey={key}
|
||||
configDefault={CONFIG_DEFAULT}
|
||||
value={localConfig[key]}
|
||||
onChange={(value) => setLocalConfig({ ...localConfig, [key]: value })}
|
||||
onChange={(value) =>
|
||||
setLocalConfig({ ...localConfig, [key]: value })
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
<details className="collapse collapse-arrow bg-base-200 mb-2 overflow-visible">
|
||||
<summary className="collapse-title font-bold">Other sampler settings</summary>
|
||||
<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={(value) => setLocalConfig({ ...localConfig, samplers: value })}
|
||||
onChange={(value) =>
|
||||
setLocalConfig({ ...localConfig, samplers: value })
|
||||
}
|
||||
/>
|
||||
{OTHER_SAMPLER_KEYS.map((key) => (
|
||||
<SettingsModalShortInput
|
||||
|
@ -91,14 +131,18 @@ export default function SettingDialog({ show, onClose }: {
|
|||
configKey={key}
|
||||
configDefault={CONFIG_DEFAULT}
|
||||
value={localConfig[key]}
|
||||
onChange={(value) => setLocalConfig({ ...localConfig, [key]: value })}
|
||||
onChange={(value) =>
|
||||
setLocalConfig({ ...localConfig, [key]: value })
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details className="collapse collapse-arrow bg-base-200 mb-2 overflow-visible">
|
||||
<summary className="collapse-title font-bold">Penalties settings</summary>
|
||||
<summary className="collapse-title font-bold">
|
||||
Penalties settings
|
||||
</summary>
|
||||
<div className="collapse-content">
|
||||
{PENALTY_KEYS.map((key) => (
|
||||
<SettingsModalShortInput
|
||||
|
@ -106,51 +150,79 @@ export default function SettingDialog({ show, onClose }: {
|
|||
configKey={key}
|
||||
configDefault={CONFIG_DEFAULT}
|
||||
value={localConfig[key]}
|
||||
onChange={(value) => setLocalConfig({ ...localConfig, [key]: value })}
|
||||
onChange={(value) =>
|
||||
setLocalConfig({ ...localConfig, [key]: value })
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details className="collapse collapse-arrow bg-base-200 mb-2 overflow-visible">
|
||||
<summary className="collapse-title font-bold">Reasoning models</summary>
|
||||
<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) => setLocalConfig({ ...localConfig, showThoughtInProgress: e.target.checked })}
|
||||
onChange={(e) =>
|
||||
setLocalConfig({
|
||||
...localConfig,
|
||||
showThoughtInProgress: e.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="ml-4">Expand though process by default for generating message</span>
|
||||
<span className="ml-4">
|
||||
Expand though process by default for generating message
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-row items-center mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox"
|
||||
checked={localConfig.excludeThoughtOnReq}
|
||||
onChange={(e) => setLocalConfig({ ...localConfig, excludeThoughtOnReq: e.target.checked })}
|
||||
onChange={(e) =>
|
||||
setLocalConfig({
|
||||
...localConfig,
|
||||
excludeThoughtOnReq: e.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="ml-4">Exclude thought process when sending request to API (Recommended for DeepSeek-R1)</span>
|
||||
<span className="ml-4">
|
||||
Exclude thought process when sending request to API
|
||||
(Recommended for DeepSeek-R1)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details className="collapse collapse-arrow bg-base-200 mb-2 overflow-visible">
|
||||
<summary className="collapse-title font-bold">Advanced config</summary>
|
||||
<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 && (
|
||||
{/* 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>
|
||||
<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) => setLocalConfig({ ...localConfig, showTokensPerSecond: e.target.checked })}
|
||||
onChange={(e) =>
|
||||
setLocalConfig({
|
||||
...localConfig,
|
||||
showTokensPerSecond: e.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="ml-4">Show tokens per second</span>
|
||||
</div>
|
||||
|
@ -171,7 +243,9 @@ export default function SettingDialog({ show, onClose }: {
|
|||
className="textarea textarea-bordered h-24"
|
||||
placeholder='Example: { "mirostat": 1, "min_p": 0.1 }'
|
||||
value={localConfig.custom}
|
||||
onChange={(e) => setLocalConfig({ ...localConfig, custom: e.target.value })}
|
||||
onChange={(e) =>
|
||||
setLocalConfig({ ...localConfig, custom: e.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
@ -194,9 +268,16 @@ export default function SettingDialog({ show, onClose }: {
|
|||
);
|
||||
}
|
||||
|
||||
function SettingsModalShortInput({ configKey, configDefault, value, onChange, label }: {
|
||||
configKey: string;
|
||||
configDefault: any;
|
||||
function SettingsModalShortInput({
|
||||
configKey,
|
||||
configDefault,
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
configKey: SettKey;
|
||||
configDefault: typeof CONFIG_DEFAULT;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
value: any;
|
||||
onChange: (value: string) => void;
|
||||
label?: string;
|
||||
|
|
|
@ -1,11 +1,22 @@
|
|||
@use "sass:meta";
|
||||
@use 'sass:meta';
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
.markdown {
|
||||
h1, h2, h3, h4, h5, h6, ul, ol, li { all: revert; }
|
||||
@apply whitespace-pre-wrap;
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
ul,
|
||||
ol,
|
||||
li {
|
||||
all: revert;
|
||||
}
|
||||
pre {
|
||||
@apply whitespace-pre-wrap rounded-lg p-2;
|
||||
border: 1px solid currentColor;
|
||||
|
@ -19,7 +30,9 @@
|
|||
.btn-mini {
|
||||
@apply cursor-pointer hover:shadow-md;
|
||||
}
|
||||
.chat-screen { max-width: 900px; }
|
||||
.chat-screen {
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
.chat-bubble-base-300 {
|
||||
--tw-bg-opacity: 1;
|
||||
|
|
|
@ -4,27 +4,35 @@ import StorageUtils from './storage';
|
|||
import {
|
||||
filterThoughtFromMsgs,
|
||||
normalizeMsgsForAPI,
|
||||
sendSSEPostRequest,
|
||||
getSSEStreamAsync,
|
||||
} from './misc';
|
||||
import { BASE_URL, CONFIG_DEFAULT, isDev } from '../Config';
|
||||
import { matchPath, useLocation, useParams } from 'react-router';
|
||||
import { matchPath, useLocation } from 'react-router';
|
||||
|
||||
interface AppContextValue {
|
||||
isGenerating: boolean;
|
||||
viewingConversation: Conversation | null;
|
||||
pendingMessage: PendingMessage | null;
|
||||
sendMessage: (convId: string, content: string) => Promise<void>;
|
||||
sendMessage: (
|
||||
convId: string,
|
||||
content: string,
|
||||
onChunk?: CallbackGeneratedChunk
|
||||
) => Promise<boolean>;
|
||||
stopGenerating: () => void;
|
||||
replaceMessageAndGenerate: (
|
||||
convId: string,
|
||||
origMsgId: Message['id'],
|
||||
content?: string
|
||||
content?: string,
|
||||
onChunk?: CallbackGeneratedChunk
|
||||
) => Promise<void>;
|
||||
|
||||
config: typeof CONFIG_DEFAULT;
|
||||
saveConfig: (config: typeof CONFIG_DEFAULT) => void;
|
||||
}
|
||||
|
||||
// for now, this callback is only used for scrolling to the bottom of the chat
|
||||
type CallbackGeneratedChunk = () => void;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const AppContext = createContext<AppContextValue>({} as any);
|
||||
|
||||
|
@ -58,7 +66,10 @@ export const AppContextProvider = ({
|
|||
};
|
||||
}, [convId]);
|
||||
|
||||
const generateMessage = async (convId: string) => {
|
||||
const generateMessage = async (
|
||||
convId: string,
|
||||
onChunk?: CallbackGeneratedChunk
|
||||
) => {
|
||||
if (isGenerating) return;
|
||||
|
||||
const config = StorageUtils.getConfig();
|
||||
|
@ -67,10 +78,12 @@ export const AppContextProvider = ({
|
|||
throw new Error('Current conversation is not found');
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
setIsGenerating(true);
|
||||
setAbortController(new AbortController());
|
||||
setAbortController(abortController);
|
||||
|
||||
let pendingMsg: PendingMessage = {
|
||||
convId,
|
||||
id: Date.now() + 1,
|
||||
role: 'assistant',
|
||||
content: null,
|
||||
|
@ -117,7 +130,7 @@ export const AppContextProvider = ({
|
|||
};
|
||||
|
||||
// send request
|
||||
const chunks = sendSSEPostRequest(`${BASE_URL}/v1/chat/completions`, {
|
||||
const fetchResponse = await fetch(`${BASE_URL}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
@ -128,12 +141,21 @@ export const AppContextProvider = ({
|
|||
body: JSON.stringify(params),
|
||||
signal: abortController.signal,
|
||||
});
|
||||
if (fetchResponse.status !== 200) {
|
||||
const body = await fetchResponse.json();
|
||||
throw new Error(body?.error?.message || 'Unknown error');
|
||||
}
|
||||
const chunks = getSSEStreamAsync(fetchResponse);
|
||||
for await (const chunk of chunks) {
|
||||
// const stop = chunk.stop;
|
||||
if (chunk.error) {
|
||||
throw new Error(chunk.error?.message || 'Unknown error');
|
||||
}
|
||||
const addedContent = chunk.choices[0].delta.content;
|
||||
const lastContent = pendingMsg.content || '';
|
||||
if (addedContent) {
|
||||
pendingMsg = {
|
||||
convId,
|
||||
id: pendingMsg.id,
|
||||
role: 'assistant',
|
||||
content: lastContent + addedContent,
|
||||
|
@ -150,8 +172,10 @@ export const AppContextProvider = ({
|
|||
};
|
||||
}
|
||||
setPendingMessage(pendingMsg);
|
||||
onChunk?.();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setPendingMessage(null);
|
||||
setIsGenerating(false);
|
||||
if ((err as Error).name === 'AbortError') {
|
||||
|
@ -168,29 +192,39 @@ export const AppContextProvider = ({
|
|||
|
||||
if (pendingMsg.content) {
|
||||
StorageUtils.appendMsg(currConversation.id, {
|
||||
...pendingMsg,
|
||||
id: pendingMsg.id,
|
||||
content: pendingMsg.content,
|
||||
role: pendingMsg.role,
|
||||
timings: pendingMsg.timings,
|
||||
});
|
||||
}
|
||||
setPendingMessage(null);
|
||||
setIsGenerating(false);
|
||||
onChunk?.(); // trigger scroll to bottom
|
||||
};
|
||||
|
||||
const sendMessage = async (convId: string, content: string) => {
|
||||
if (isGenerating || content.trim().length === 0) return;
|
||||
const sendMessage = async (
|
||||
convId: string,
|
||||
content: string,
|
||||
onChunk?: CallbackGeneratedChunk
|
||||
): Promise<boolean> => {
|
||||
if (isGenerating || content.trim().length === 0) return false;
|
||||
|
||||
StorageUtils.appendMsg(convId, {
|
||||
id: Date.now(),
|
||||
role: 'user',
|
||||
content,
|
||||
});
|
||||
|
||||
try {
|
||||
await generateMessage(convId);
|
||||
await generateMessage(convId, onChunk);
|
||||
return true;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (_) {
|
||||
// rollback
|
||||
StorageUtils.popMsg(convId);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const stopGenerating = () => {
|
||||
|
@ -203,16 +237,18 @@ export const AppContextProvider = ({
|
|||
const replaceMessageAndGenerate = async (
|
||||
convId: string,
|
||||
origMsgId: Message['id'],
|
||||
content?: string
|
||||
content?: string,
|
||||
onChunk?: CallbackGeneratedChunk
|
||||
) => {
|
||||
if (isGenerating) return;
|
||||
|
||||
StorageUtils.filterAndKeepMsgs(convId, (msg) => msg.id < origMsgId);
|
||||
if (content) {
|
||||
StorageUtils.filterAndKeepMsgs(convId, (msg) => msg.id < origMsgId);
|
||||
await sendMessage(convId, content);
|
||||
// case: replace user message then generate assistant message
|
||||
await sendMessage(convId, content, onChunk);
|
||||
} else {
|
||||
StorageUtils.filterAndKeepMsgs(convId, (msg) => msg.id < origMsgId);
|
||||
await generateMessage(convId);
|
||||
// case: generate last assistant message
|
||||
await generateMessage(convId, onChunk);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -1,60 +0,0 @@
|
|||
import hljs from 'highlight.js/lib/core';
|
||||
|
||||
// only import commonly used languages to reduce bundle size
|
||||
|
||||
import python from 'highlight.js/lib/languages/python';
|
||||
import javascript from 'highlight.js/lib/languages/javascript';
|
||||
import json from 'highlight.js/lib/languages/json';
|
||||
import bash from 'highlight.js/lib/languages/bash';
|
||||
import yaml from 'highlight.js/lib/languages/yaml';
|
||||
import markdown from 'highlight.js/lib/languages/markdown';
|
||||
import scss from 'highlight.js/lib/languages/scss';
|
||||
import xml from 'highlight.js/lib/languages/xml';
|
||||
import ruby from 'highlight.js/lib/languages/ruby';
|
||||
import go from 'highlight.js/lib/languages/go';
|
||||
import java from 'highlight.js/lib/languages/java';
|
||||
import rust from 'highlight.js/lib/languages/rust';
|
||||
import scala from 'highlight.js/lib/languages/scala';
|
||||
import cpp from 'highlight.js/lib/languages/cpp';
|
||||
import csharp from 'highlight.js/lib/languages/csharp';
|
||||
import swift from 'highlight.js/lib/languages/swift';
|
||||
import dart from 'highlight.js/lib/languages/dart';
|
||||
import elixir from 'highlight.js/lib/languages/elixir';
|
||||
import kotlin from 'highlight.js/lib/languages/kotlin';
|
||||
import lua from 'highlight.js/lib/languages/lua';
|
||||
import php from 'highlight.js/lib/languages/php';
|
||||
import latex from 'highlight.js/lib/languages/latex';
|
||||
|
||||
hljs.registerLanguage('python', python);
|
||||
hljs.registerLanguage('javascript', javascript);
|
||||
hljs.registerLanguage('json', json);
|
||||
hljs.registerLanguage('yaml', yaml);
|
||||
hljs.registerLanguage('markdown', markdown);
|
||||
hljs.registerLanguage('xml', xml);
|
||||
hljs.registerLanguage('ruby', ruby);
|
||||
hljs.registerLanguage('go', go);
|
||||
hljs.registerLanguage('java', java);
|
||||
hljs.registerLanguage('rust', rust);
|
||||
hljs.registerLanguage('scala', scala);
|
||||
hljs.registerLanguage('csharp', csharp);
|
||||
hljs.registerLanguage('swift', swift);
|
||||
hljs.registerLanguage('dart', dart);
|
||||
hljs.registerLanguage('elixir', elixir);
|
||||
hljs.registerLanguage('kotlin', kotlin);
|
||||
hljs.registerLanguage('lua', lua);
|
||||
hljs.registerLanguage('php', php);
|
||||
hljs.registerLanguage('latex', latex);
|
||||
|
||||
// reuse some languages to further reduce bundle size
|
||||
|
||||
hljs.registerLanguage('shell', bash);
|
||||
hljs.registerLanguage('bash', bash);
|
||||
hljs.registerLanguage('sh', bash);
|
||||
|
||||
hljs.registerLanguage('css', scss);
|
||||
hljs.registerLanguage('scss', scss);
|
||||
|
||||
hljs.registerLanguage('c', cpp);
|
||||
hljs.registerLanguage('cpp', cpp);
|
||||
|
||||
export default hljs;
|
|
@ -28,7 +28,6 @@ function escapedBracketRule(options: typeof defaultOptions): RuleInline {
|
|||
const start = state.pos;
|
||||
|
||||
for (const { left, right, display } of options.delimiters) {
|
||||
|
||||
// Check if it starts with the left delimiter
|
||||
if (!state.src.slice(start).startsWith(left)) continue;
|
||||
|
||||
|
@ -62,7 +61,7 @@ function escapedBracketRule(options: typeof defaultOptions): RuleInline {
|
|||
state.pos = pos + right.length;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default function (md: MarkdownIt, options = defaultOptions) {
|
||||
|
|
|
@ -16,13 +16,9 @@ export const escapeAttr = (str: string) =>
|
|||
str.replace(/>/g, '>').replace(/"/g, '"');
|
||||
|
||||
// wrapper for SSE
|
||||
export async function* sendSSEPostRequest(
|
||||
url: string,
|
||||
fetchOptions: RequestInit
|
||||
) {
|
||||
const res = await fetch(url, fetchOptions);
|
||||
if (!res.body) throw new Error('Response body is empty');
|
||||
const lines: ReadableStream<string> = res.body
|
||||
export async function* getSSEStreamAsync(fetchResponse: Response) {
|
||||
if (!fetchResponse.body) throw new Error('Response body is empty');
|
||||
const lines: ReadableStream<string> = fetchResponse.body
|
||||
.pipeThrough(new TextDecoderStream())
|
||||
.pipeThrough(new TextLineStream());
|
||||
// @ts-expect-error asyncIterator complains about type, but it should work
|
||||
|
|
|
@ -21,5 +21,6 @@ export interface Conversation {
|
|||
}
|
||||
|
||||
export type PendingMessage = Omit<Message, 'content'> & {
|
||||
convId: string;
|
||||
content: string | null;
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue