fix auto scroll

This commit is contained in:
Xuan Son Nguyen 2025-02-05 19:06:55 +01:00
parent 0d172936be
commit cc27754437
13 changed files with 1997 additions and 313 deletions

File diff suppressed because it is too large Load diff

View file

@ -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"

View file

@ -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.

View 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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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;

View file

@ -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;

View file

@ -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);
}
};

View file

@ -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;

View file

@ -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) {

View file

@ -16,13 +16,9 @@ export const escapeAttr = (str: string) =>
str.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
// 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

View file

@ -21,5 +21,6 @@ export interface Conversation {
}
export type PendingMessage = Omit<Message, 'content'> & {
convId: string;
content: string | null;
};