server : (webui) revamp the input area, plus many small UI improvements (#13365)

* rework the input area

* process selected file

* change all icons to heroicons

* fix thought process collapse

* move conversation more menu to sidebar

* sun icon --> moon icon

* rm default system message

* stricter upload file check, only allow image if server has mtmd

* build it

* add renaming

* better autoscroll

* build

* add conversation group

* fix scroll

* extra context first, then user input in the end

* fix <hr> tag

* clean up a bit

* build

* add mb-3 for <pre>

* throttle adjustTextareaHeight to make it less laggy

* (nits) missing padding in sidebar

* rm stray console log
This commit is contained in:
Xuan-Son Nguyen
2025-05-08 15:37:29 +02:00
committed by GitHub
parent 1a844be132
commit 8c83449cb7
23 changed files with 1142 additions and 320 deletions

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,11 @@
// @ts-expect-error this package does not have typing
import TextLineStream from 'textlinestream';
import { APIMessage, Message } from './types';
import {
APIMessage,
APIMessageContentPart,
LlamaCppServerProps,
Message,
} from './types';
// ponyfill for missing ReadableStream asyncIterator on Safari
import { asyncIterator } from '@sec-ant/readable-stream/ponyfill/asyncIterator';
@@ -57,19 +62,47 @@ export const copyStr = (textToCopy: string) => {
*/
export function normalizeMsgsForAPI(messages: Readonly<Message[]>) {
return messages.map((msg) => {
let newContent = '';
if (msg.role !== 'user' || !msg.extra) {
return {
role: msg.role,
content: msg.content,
} as APIMessage;
}
// extra content first, then user text message in the end
// this allow re-using the same cache prefix for long context
const contentArr: APIMessageContentPart[] = [];
for (const extra of msg.extra ?? []) {
if (extra.type === 'context') {
newContent += `${extra.content}\n\n`;
contentArr.push({
type: 'text',
text: extra.content,
});
} else if (extra.type === 'textFile') {
contentArr.push({
type: 'text',
text: `File: ${extra.name}\nContent:\n\n${extra.content}`,
});
} else if (extra.type === 'imageFile') {
contentArr.push({
type: 'image_url',
image_url: { url: extra.base64Url },
});
} else {
throw new Error('Unknown extra type');
}
}
newContent += msg.content;
// add user message to the end
contentArr.push({
type: 'text',
text: msg.content,
});
return {
role: msg.role,
content: newContent,
content: contentArr,
};
}) as APIMessage[];
}
@@ -78,13 +111,19 @@ export function normalizeMsgsForAPI(messages: Readonly<Message[]>) {
* recommended for DeepsSeek-R1, filter out content between <think> and </think> tags
*/
export function filterThoughtFromMsgs(messages: APIMessage[]) {
console.debug({ messages });
return messages.map((msg) => {
if (msg.role !== 'assistant') {
return msg;
}
// assistant message is always a string
const contentStr = msg.content as string;
return {
role: msg.role,
content:
msg.role === 'assistant'
? msg.content.split('</think>').at(-1)!.trim()
: msg.content,
? contentStr.split('</think>').at(-1)!.trim()
: contentStr,
} as APIMessage;
});
}
@@ -126,3 +165,25 @@ export const cleanCurrentUrl = (removeQueryParams: string[]) => {
});
window.history.replaceState({}, '', url.toString());
};
export const getServerProps = async (
baseUrl: string,
apiKey?: string
): Promise<LlamaCppServerProps> => {
try {
const response = await fetch(`${baseUrl}/props`, {
headers: {
'Content-Type': 'application/json',
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
},
});
if (!response.ok) {
throw new Error('Failed to fetch server props');
}
const data = await response.json();
return data as LlamaCppServerProps;
} catch (error) {
console.error('Error fetching server props:', error);
throw error;
}
};

View File

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

View File

@@ -48,7 +48,10 @@ export interface Message {
children: Message['id'][];
}
type MessageExtra = MessageExtraTextFile | MessageExtraContext; // TODO: will add more in the future
export type MessageExtra =
| MessageExtraTextFile
| MessageExtraImageFile
| MessageExtraContext;
export interface MessageExtraTextFile {
type: 'textFile';
@@ -56,12 +59,32 @@ export interface MessageExtraTextFile {
content: string;
}
export interface MessageExtraImageFile {
type: 'imageFile';
name: string;
base64Url: string;
}
export interface MessageExtraContext {
type: 'context';
name: string;
content: string;
}
export type APIMessage = Pick<Message, 'role' | 'content'>;
export type APIMessageContentPart =
| {
type: 'text';
text: string;
}
| {
type: 'image_url';
image_url: { url: string };
};
export type APIMessage = {
role: Message['role'];
content: string | APIMessageContentPart[];
};
export interface Conversation {
id: string; // format: `conv-{timestamp}`
@@ -89,3 +112,12 @@ export interface CanvasPyInterpreter {
}
export type CanvasData = CanvasPyInterpreter;
// a non-complete list of props, only contains the ones we need
export interface LlamaCppServerProps {
build_info: string;
model_path: string;
n_ctx: number;
has_multimodal: boolean;
// TODO: support params
}