Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 22 commits into from
May 8, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
process selected file
  • Loading branch information
ngxson committed May 7, 2025
commit 7d59402924c468a17e425cd2478f7ea13dd4e537
4 changes: 3 additions & 1 deletion common/chat.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,9 @@ std::vector<common_chat_msg> common_chat_msgs_parse_oaicompat(const json & messa
msgs.push_back(msg);
}
} catch (const std::exception & e) {
throw std::runtime_error("Failed to parse messages: " + std::string(e.what()) + "; messages = " + messages.dump(2));
// @ngxson : disable otherwise it's bloating the API response
// printf("%s\n", std::string("; messages = ") + messages.dump(2));
throw std::runtime_error("Failed to parse messages: " + std::string(e.what()));
Copy link
Collaborator Author

@ngxson ngxson May 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

small note @ochafik , we should never reflect user input via the error message, as it usually comes with security risks. In this case, it's not a security risk, but it's a bit inconvenient to display the error in the UI

If you want to print the input for debugging, consider using LOG_DBG

}

return msgs;
Expand Down
92 changes: 92 additions & 0 deletions tools/server/webui/src/components/ChatInputExtraContextItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { DocumentTextIcon, XMarkIcon } from '@heroicons/react/24/outline';
import { MessageExtra } from '../utils/types';
import { useState } from 'react';
import { classNames } from '../utils/misc';

export default function ChatInputExtraContextItem({
items,
removeItem,
clickToShow,
}: {
items?: MessageExtra[];
removeItem?: (index: number) => void;
clickToShow?: boolean;
}) {
const [show, setShow] = useState(-1);
const showingItem = show >= 0 ? items?.[show] : undefined;

if (!items) return null;

return (
<div className="flex flex-row gap-4 overflow-x-auto py-2 px-1 mb-1">
{items.map((item, i) => (
<div
className="indicator"
key={i}
onClick={() => clickToShow && setShow(i)}
>
{removeItem && (
<div className="indicator-item indicator-top">
<button
className="btn btn-neutral btn-sm w-4 h-4 p-0 rounded-full"
onClick={() => removeItem(i)}
>
<XMarkIcon className="h-3 w-3" />
</button>
</div>
)}

<div
className={classNames({
'flex flex-row rounded-md shadow-sm items-center m-0 p-0': true,
'cursor-pointer hover:shadow-md': !!clickToShow,
})}
>
{item.type === 'imageFile' ? (
<>
<img
src={item.base64Url}
alt={item.name}
className="w-14 h-14 object-cover rounded-md"
/>
</>
) : (
<>
<div className="w-14 h-14 flex items-center justify-center">
<DocumentTextIcon className="h-8 w-14 text-base-content/50" />
</div>

<div className="text-xs pr-4">
<b>{item.name}</b>
</div>
</>
)}
</div>
</div>
))}

{showingItem && (
<dialog className="modal modal-open">
<div className="modal-box">
<div className="flex justify-between items-center mb-4">
<b>{showingItem.name}</b>
<button className="btn btn-ghost btn-sm">
<XMarkIcon className="h-5 w-5" onClick={() => setShow(-1)} />
</button>
</div>
{showingItem.type === 'imageFile' ? (
<img src={showingItem.base64Url} alt={showingItem.name} />
) : (
<div className="overflow-x-auto">
<pre className="whitespace-pre-wrap break-words text-sm">
{showingItem.content}
</pre>
</div>
)}
</div>
<div className="modal-backdrop" onClick={() => setShow(-1)}></div>
</dialog>
)}
</div>
);
}
33 changes: 5 additions & 28 deletions tools/server/webui/src/components/ChatMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Message, PendingMessage } from '../utils/types';
import { classNames } from '../utils/misc';
import MarkdownDisplay, { CopyButton } from './MarkdownDisplay';
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
import ChatInputExtraContextItem from './ChatInputExtraContextItem';

interface SplitMessage {
content: PendingMessage['content'];
Expand Down Expand Up @@ -85,6 +86,10 @@ export default function ChatMessage({
'chat-end': msg.role === 'user',
})}
>
{msg.extra && msg.extra.length > 0 && (
<ChatInputExtraContextItem items={msg.extra} clickToShow />
)}

<div
className={classNames({
'chat-bubble markdown': true,
Expand Down Expand Up @@ -160,34 +165,6 @@ export default function ChatMessage({
</details>
)}

{msg.extra && msg.extra.length > 0 && (
<details
className={classNames({
'collapse collapse-arrow mb-4 bg-base-200': true,
'bg-opacity-10': msg.role !== 'assistant',
})}
>
<summary className="collapse-title">
Extra content
</summary>
<div className="collapse-content">
{msg.extra.map(
(extra, i) =>
extra.type === 'textFile' ? (
<div key={extra.name}>
<b>{extra.name}</b>
<pre>{extra.content}</pre>
</div>
) : extra.type === 'context' ? (
<div key={i}>
<pre>{extra.content}</pre>
</div>
) : null // TODO: support other extra types
)}
</div>
</details>
)}

<MarkdownDisplay
content={content}
isGenerating={isPending}
Expand Down
28 changes: 18 additions & 10 deletions tools/server/webui/src/components/ChatScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { CallbackGeneratedChunk, useAppContext } from '../utils/app.context';
import ChatMessage from './ChatMessage';
import { CanvasType, Message, MessageExtraContext, PendingMessage } from '../utils/types';
import { CanvasType, Message, PendingMessage } from '../utils/types';
import { classNames, cleanCurrentUrl, throttle } from '../utils/misc';
import CanvasPyInterpreter from './CanvasPyInterpreter';
import StorageUtils from '../utils/storage';
Expand All @@ -12,12 +12,15 @@ import {
StopIcon,
PaperClipIcon,
DocumentTextIcon,
XMarkIcon,
} from '@heroicons/react/24/solid';
import {
ChatExtraContextApi,
useChatExtraContext,
} from './useChatExtraContext.tsx';
import Dropzone from 'react-dropzone';
import toast from 'react-hot-toast';
import ChatInputExtraContextItem from './ChatInputExtraContextItem.tsx';

/**
* A message display is a message node with additional information for rendering.
Expand Down Expand Up @@ -143,8 +146,10 @@ export default function ChatScreen() {

const sendNewMessage = async () => {
const lastInpMsg = textarea.value();
if (lastInpMsg.trim().length === 0 || isGenerating(currConvId ?? ''))
if (lastInpMsg.trim().length === 0 || isGenerating(currConvId ?? '')) {
toast.error('Please enter a message');
return;
}
textarea.setValue('');
scrollToBottom(false);
setCurrNodeId(-1);
Expand Down Expand Up @@ -312,7 +317,16 @@ function ChatInput({
multiple={true}
>
{({ getRootProps, getInputProps }) => (
<div className="flex rounded-xl border-1 border-base-content/30 p-3 w-full" {...getRootProps()}>
<div
className="flex flex-col rounded-xl border-1 border-base-content/30 p-3 w-full"
{...getRootProps()}
>
<ChatInputExtraContextItem
items={extraContext.items}
removeItem={extraContext.removeItem}
/>

<div className="flex flex-row w-full">
<textarea
// Default (mobile): Enable vertical resize, overflow auto for scrolling if needed
// Large screens (lg:): Disable manual resize, apply max-height for autosize limit
Expand Down Expand Up @@ -367,15 +381,9 @@ function ChatInput({
)}
</div>
</div>
</div>
)}
</Dropzone>
</div>
);
}

function ChatInputExtraContextItem({}: {
idx: number,
item: MessageExtraContext,
}) {

}
4 changes: 2 additions & 2 deletions tools/server/webui/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export default function Sidebar() {
<div
className={classNames({
'btn btn-ghost justify-start': true,
'btn-active': !currConv,
'btn-soft': !currConv,
})}
onClick={() => navigate('/')}
>
Expand All @@ -67,7 +67,7 @@ export default function Sidebar() {
key={conv.id}
className={classNames({
'btn btn-ghost justify-start font-normal': true,
'btn-active': conv.id === currConv?.id,
'btn-soft': conv.id === currConv?.id,
})}
onClick={() => navigate(`/chat/${conv.id}`)}
dir="auto"
Expand Down
3 changes: 2 additions & 1 deletion tools/server/webui/src/utils/app.context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} 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
Expand Down Expand Up @@ -260,7 +261,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
}
}
Expand Down
1 change: 1 addition & 0 deletions tools/server/webui/src/utils/llama-vscode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export const useVSCodeContext = (
extraContext.addItems([
{
type: 'context',
name: 'Extra context',
content: data.context,
},
]);
Expand Down
47 changes: 39 additions & 8 deletions tools/server/webui/src/utils/misc.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// @ts-expect-error this package does not have typing
import TextLineStream from 'textlinestream';
import { APIMessage, Message } from './types';
import { APIMessage, APIMessageContentPart, Message } from './types';

// ponyfill for missing ReadableStream asyncIterator on Safari
import { asyncIterator } from '@sec-ant/readable-stream/ponyfill/asyncIterator';
Expand Down Expand Up @@ -57,19 +57,44 @@ 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;
}

const contentArr: APIMessageContentPart[] = [
{
type: 'text',
text: msg.content,
},
];

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}`,
});
Comment on lines +82 to +86
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is now the input file will be formatted upon sending to server. The file name will be preserved

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

return {
role: msg.role,
content: newContent,
content: contentArr,
};
}) as APIMessage[];
}
Expand All @@ -78,13 +103,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;
});
}
Expand Down
21 changes: 19 additions & 2 deletions tools/server/webui/src/utils/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@ export interface Message {
children: Message['id'][];
}

export type MessageExtra = MessageExtraTextFile | MessageExtraImageFile | MessageExtraContext;
export type MessageExtra =
| MessageExtraTextFile
| MessageExtraImageFile
| MessageExtraContext;

export interface MessageExtraTextFile {
type: 'textFile';
Expand All @@ -64,10 +67,24 @@ export interface MessageExtraImageFile {

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}`
Expand Down