Skip to content

webui: prettify styling #15201

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

Open
wants to merge 18 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
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
6 changes: 5 additions & 1 deletion tools/server/webui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@
content="width=device-width, initial-scale=1, maximum-scale=1"
/>
<meta name="color-scheme" content="light dark" />
<title>🦙 llama.cpp - chat</title>
<link
rel="icon"
href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🦙</text></svg>"
/>
<title>llama.cpp</title>
</head>
<body>
<div id="root"></div>
Expand Down
64 changes: 38 additions & 26 deletions tools/server/webui/src/components/ChatMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
ArrowPathIcon,
ChevronLeftIcon,
ChevronRightIcon,
ExclamationCircleIcon,
PencilSquareIcon,
} from '@heroicons/react/24/outline';
import ChatInputExtraContextItem from './ChatInputExtraContextItem';
Expand Down Expand Up @@ -109,7 +110,7 @@ export default function ChatMessage({
<div
className={classNames({
'chat-bubble markdown': true,
'chat-bubble bg-transparent': !isUser,
'bg-transparent': !isUser,
})}
>
{/* textarea for editing message */}
Expand Down Expand Up @@ -168,30 +169,6 @@ export default function ChatMessage({
</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>
Expand All @@ -201,7 +178,7 @@ export default function ChatMessage({
{msg.content !== null && (
<div
className={classNames({
'flex items-center gap-2 mx-4 mt-2 mb-2': true,
'flex items-center gap-2 mx-4 mb-2': true,
'flex-row-reverse': msg.role === 'user',
})}
>
Expand Down Expand Up @@ -264,6 +241,41 @@ export default function ChatMessage({
<ArrowPathIcon className="h-4 w-4" />
</BtnWithTooltips>
)}

{/* render timings if enabled */}
{timings && config.showTokensPerSecond && (
<BtnWithTooltips
className="btn-mini w-8 h-8"
tooltipsContent="Performance"
>
<div className="dropdown dropdown-hover dropdown-top">
<ExclamationCircleIcon className="h-4 w-4" />

<div
tabIndex={0}
className="dropdown-content rounded-box bg-base-100 z-10 w-48 px-4 py-2 shadow mt-4 text-sm text-left"
>
<b>Prompt Processing</b>
<ul className="list-inside list-disc">
<li>Tokens: {timings.prompt_n}</li>
<li>Time: {timings.prompt_ms} ms</li>
<li>
Speed: {timings.prompt_per_second.toFixed(1)} t/s
</li>
</ul>
<br />
<b>Generation</b>
<ul className="list-inside list-disc">
<li>Tokens: {timings.predicted_n}</li>
<li>Time: {timings.predicted_ms} ms</li>
<li>
Speed: {timings.predicted_per_second.toFixed(1)} t/s
</li>
</ul>
</div>
</div>
</BtnWithTooltips>
)}
</>
)}
<CopyButton className="btn-mini w-8 h-8" content={msg.content} />
Expand Down
199 changes: 101 additions & 98 deletions tools/server/webui/src/components/ChatScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -231,32 +231,31 @@ export default function ChatScreen() {
flex: !hasCanvas,
})}
>
{/* placeholder to shift the message to the bottom */}
{!viewingChat && (
<div className="grow flex flex-col items-center justify-center ">
<b className="text-4xl">Nice to see you.</b>
<small>how can I help you today?</small>
</div>
)}

{/* chat messages */}
<div id="messages-list" className="grow" ref={msgListRef}>
<div className="mt-auto flex flex-col items-center">
{/* placeholder to shift the message to the bottom */}
{viewingChat ? (
''
) : (
<>
<div className="mb-4">Send a message to start</div>
<ServerInfo />
</>
)}
{viewingChat && (
<div id="messages-list" className="grow" ref={msgListRef}>
{[...messages, ...pendingMsgDisplay].map((msg) => (
<ChatMessage
key={msg.msg.id}
msg={msg.msg}
siblingLeafNodeIds={msg.siblingLeafNodeIds}
siblingCurrIdx={msg.siblingCurrIdx}
onRegenerateMessage={handleRegenerateMessage}
onEditMessage={handleEditMessage}
onChangeSibling={setCurrNodeId}
isPending={msg.isPending}
/>
))}
</div>
{[...messages, ...pendingMsgDisplay].map((msg) => (
<ChatMessage
key={msg.msg.id}
msg={msg.msg}
siblingLeafNodeIds={msg.siblingLeafNodeIds}
siblingCurrIdx={msg.siblingCurrIdx}
onRegenerateMessage={handleRegenerateMessage}
onEditMessage={handleEditMessage}
onChangeSibling={setCurrNodeId}
isPending={msg.isPending}
/>
))}
</div>
)}

{/* chat input */}
<ChatInput
Expand All @@ -276,41 +275,6 @@ export default function ChatScreen() {
);
}

function ServerInfo() {
const { serverProps } = useAppContext();
const modalities = [];
if (serverProps?.modalities?.audio) {
modalities.push('audio');
}
if (serverProps?.modalities?.vision) {
modalities.push('vision');
}
return (
<div
className="card card-sm shadow-sm border-1 border-base-content/20 text-base-content/70 mb-6"
tabIndex={0}
aria-description="Server information"
>
<div className="card-body">
<b>Server Info</b>
<p>
<b>Model</b>: {serverProps?.model_path?.split(/(\\|\/)/).pop()}
<br />
<b>Build</b>: {serverProps?.build_info}
<br />
{modalities.length > 0 ? (
<>
<b>Supported modalities:</b> {modalities.join(', ')}
</>
) : (
''
)}
</p>
</div>
</div>
);
}

function ChatInput({
textarea,
extraContext,
Expand All @@ -332,7 +296,7 @@ function ChatInput({
role="group"
aria-label="Chat input"
className={classNames({
'flex items-end pt-8 pb-6 sticky bottom-0 bg-base-100': true,
'flex flex-col items-end pt-8 sticky bottom-0 bg-base-100': true,
'opacity-50': isDrag, // simply visual feedback to inform user that the file will be accepted
})}
>
Expand All @@ -348,7 +312,7 @@ function ChatInput({
>
{({ getRootProps, getInputProps }) => (
<div
className="flex flex-col rounded-xl border-1 border-base-content/30 p-3 w-full"
className="flex flex-col rounded-xl w-full"
// when a file is pasted to the input, we handle it here
// if a text is pasted, and if it is long text, we will convert it to a file
onPasteCapture={(e: ClipboardEvent<HTMLInputElement>) => {
Expand Down Expand Up @@ -390,11 +354,11 @@ function ChatInput({
/>
)}

<div className="flex flex-row w-full">
<div className="bg-base-200 border-1 border-base-content/30 rounded-lg p-2 flex flex-col">
<textarea
// Default (mobile): Enable vertical resize, overflow auto for scrolling if needed
// Large screens (lg:): Disable manual resize, apply max-height for autosize limit
className="text-md outline-none border-none w-full resize-vertical lg:resize-none lg:max-h-48 lg:overflow-y-auto" // Adjust lg:max-h-48 as needed (e.g., lg:max-h-60)
className="w-full focus:outline-none px-2 border-none focus:ring-0 resize-none"
placeholder="Type a message (Shift+Enter to add a new line)"
ref={textarea.ref}
onInput={textarea.onInput} // Hook's input handler (will only resize height on lg+ screens)
Expand All @@ -413,47 +377,86 @@ function ChatInput({
></textarea>

{/* buttons area */}
<div className="flex flex-row gap-2 ml-2">
<label
htmlFor="file-upload"
className={classNames({
'btn w-8 h-8 p-0 rounded-full': true,
'btn-disabled': isGenerating,
})}
aria-label="Upload file"
tabIndex={0}
role="button"
>
<PaperClipIcon className="h-5 w-5" />
</label>
<input
id="file-upload"
type="file"
disabled={isGenerating}
{...getInputProps()}
hidden
/>
{isGenerating ? (
<button
className="btn btn-neutral w-8 h-8 p-0 rounded-full"
onClick={onStop}
>
<StopIcon className="h-5 w-5" />
</button>
) : (
<button
className="btn btn-primary w-8 h-8 p-0 rounded-full"
onClick={onSend}
aria-label="Send message"
<div className="flex items-center justify-between mt-2">
<div className="flex items-center">
<label
htmlFor="file-upload"
className={classNames({
'btn w-8 h-8 p-0 rounded-full': true,
'btn-disabled': isGenerating,
})}
aria-label="Upload file"
tabIndex={0}
role="button"
>
<ArrowUpIcon className="h-5 w-5" />
</button>
)}
<PaperClipIcon className="h-5 w-5" />
</label>
<input
id="file-upload"
type="file"
disabled={isGenerating}
{...getInputProps()}
hidden
/>
</div>

<div className="flex items-center">
{isGenerating ? (
<button
className="btn btn-neutral w-8 h-8 p-0 rounded-full"
onClick={onStop}
>
<StopIcon className="h-5 w-5" />
</button>
) : (
<button
className="btn btn-primary w-8 h-8 p-0 rounded-full"
onClick={onSend}
aria-label="Send message"
>
<ArrowUpIcon className="h-5 w-5" />
</button>
)}
</div>
</div>
</div>
</div>
)}
</Dropzone>
<ServerInfo />
</div>
);
}

function ServerInfo() {
const { serverProps } = useAppContext();
const modalities = [];
if (serverProps?.modalities?.audio) {
modalities.push('audio');
}
if (serverProps?.modalities?.vision) {
modalities.push('vision');
}
return (
<div
className="sticky bottom-0 w-full pt-1 pb-1 text-base-content/70 text-xs text-center"
tabIndex={0}
aria-description="Server information"
>
<span>
<b>Llama.cpp</b> {serverProps?.build_info}
</span>

<span className="sm:ml-2">
{modalities.length > 0 ? (
<>
<br className="sm:hidden" />
<b>Supported modalities:</b> {modalities.join(', ')}
</>
) : (
''
)}
</span>
</div>
);
}
Loading