Skip to content

feat(server): Add tool call support to WebUI (LLama Server) #13501

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
1 change: 1 addition & 0 deletions tools/server/webui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"remark-math": "^6.0.0",
"tailwindcss": "^4.1.1",
"textlinestream": "^1.1.1",
"unist-util-visit": "^5.0.0",
"vite-plugin-singlefile": "^2.0.3"
},
"devDependencies": {
Expand Down
10 changes: 10 additions & 0 deletions tools/server/webui/src/Config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import daisyuiThemes from 'daisyui/theme/object';
import { isNumeric } from './utils/misc';
import { AVAILABLE_TOOLS } from './utils/tool_calling/register_tools';
import { AgentTool } from './utils/tool_calling/agent_tool';

export const isDev = import.meta.env.MODE === 'development';

Expand Down Expand Up @@ -41,6 +43,14 @@ export const CONFIG_DEFAULT = {
custom: '', // custom json-stringified object
// experimental features
pyIntepreterEnabled: false,
// Fields for tool calling
streamResponse: true,
...Object.fromEntries(
Array.from(AVAILABLE_TOOLS.values()).map((tool: AgentTool) => [
`tool_${tool.id}_enabled`,
false, // Default value for tool enabled state (e.g., false for opt-in)
])
),
};
export const CONFIG_INFO: Record<string, string> = {
apiKey: 'Set the API Key if you are using --api-key option for the server.',
Expand Down
77 changes: 77 additions & 0 deletions tools/server/webui/src/assets/iframe_sandbox.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<!doctype html>
<html>
<head>
<title>JS Sandbox</title>
<script>
// Capture console.log output within the iframe
const iframeConsole = {
_buffer: [],
log: function (...args) {
this._buffer.push(args.map(String).join(' '));
},
getOutput: function () {
const output = this._buffer.join('\n');
this._buffer = [];
return output;
},
clear: function () {
this._buffer = [];
},
};
// Redirect the iframe's console.log
console.log = iframeConsole.log.bind(iframeConsole);

window.addEventListener('message', (event) => {
if (!event.data || !event.source || !event.source.postMessage) {
return;
}

if (event.data.command === 'executeCode') {
const { code, call_id } = event.data;
let result = '';
let error = null;
iframeConsole.clear();

try {
result = eval(code);
if (result !== undefined && result !== null) {
try {
result = JSON.stringify(result, null, 2);
} catch (e) {
result = String(result);
}
} else {
result = '';
}
} catch (e) {
error = e.message || String(e);
}

const consoleOutput = iframeConsole.getOutput();
const finalOutput = consoleOutput
? consoleOutput + (result && consoleOutput ? '\n' : '') + result
: result;

event.source.postMessage(
{
call_id: call_id,
output: finalOutput,
error: error,
},
event.origin === 'null' ? '*' : event.origin
);
}
});

if (window.parent && window.parent !== window) {
window.parent.postMessage(
{ command: 'iframeReady', call_id: 'initial_ready' },
'*'
);
}
</script>
</head>
<body>
<p>JavaScript Execution Sandbox</p>
</body>
</html>
99 changes: 87 additions & 12 deletions tools/server/webui/src/components/ChatMessage.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { useMemo, useState } from 'react';
import { useMemo, useState, Fragment } from 'react';
import { useAppContext } from '../utils/app.context';
import { Message, PendingMessage } from '../utils/types';
import { classNames } from '../utils/misc';
import MarkdownDisplay, { CopyButton } from './MarkdownDisplay';
import { ToolCallArgsDisplay } from './tool_calling/ToolCallArgsDisplay';
import { ToolCallResultDisplay } from './tool_calling/ToolCallResultDisplay';
import {
ArrowPathIcon,
ChevronLeftIcon,
Expand All @@ -20,6 +22,7 @@ interface SplitMessage {

export default function ChatMessage({
msg,
chainedParts,
siblingLeafNodeIds,
siblingCurrIdx,
id,
Expand All @@ -29,6 +32,7 @@ export default function ChatMessage({
isPending,
}: {
msg: Message | PendingMessage;
chainedParts?: (Message | PendingMessage)[];
siblingLeafNodeIds: Message['id'][];
siblingCurrIdx: number;
id?: string;
Expand Down Expand Up @@ -57,8 +61,15 @@ export default function ChatMessage({

// for reasoning model, we split the message into content and thought
// TODO: implement this as remark/rehype plugin in the future
const { content, thought, isThinking }: SplitMessage = useMemo(() => {
if (msg.content === null || msg.role !== 'assistant') {
const {
content: mainDisplayableContent,
thought,
isThinking,
}: SplitMessage = useMemo(() => {
if (
msg.content === null ||
(msg.role !== 'assistant' && msg.role !== 'tool')
) {
return { content: msg.content };
}
let actualContent = '';
Expand All @@ -78,11 +89,21 @@ export default function ChatMessage({
actualContent += thinkSplit[0];
}
}

return { content: actualContent, thought, isThinking };
}, [msg]);

if (!viewingChat) return null;

const toolCalls = msg.tool_calls ?? null;

const hasContentInMainMsg =
mainDisplayableContent && mainDisplayableContent.trim() !== '';
const hasContentInChainedParts = chainedParts?.some(
(part) => part.content && part.content.trim() !== ''
);
const entireTurnHasSomeDisplayableContent =
hasContentInMainMsg || hasContentInChainedParts;
const isUser = msg.role === 'user';

return (
Expand Down Expand Up @@ -141,7 +162,9 @@ export default function ChatMessage({
{/* not editing content, render message */}
{editingContent === null && (
<>
{content === null ? (
{mainDisplayableContent === null &&
!toolCalls &&
!chainedParts?.length ? (
<>
{/* show loading dots for pending message */}
<span className="loading loading-dots loading-md"></span>
Expand All @@ -158,13 +181,53 @@ export default function ChatMessage({
/>
)}

<MarkdownDisplay
content={content}
isGenerating={isPending}
/>
{msg.role === 'tool' && mainDisplayableContent ? (
<ToolCallResultDisplay content={mainDisplayableContent} />
) : (
mainDisplayableContent &&
mainDisplayableContent.trim() !== '' && (
<MarkdownDisplay
content={mainDisplayableContent}
isGenerating={isPending}
/>
)
)}
</div>
</>
)}
{toolCalls &&
toolCalls.map((toolCall) => (
<ToolCallArgsDisplay key={toolCall.id} toolCall={toolCall} />
))}

{chainedParts?.map((part) => (
<Fragment key={part.id}>
{part.role === 'tool' && part.content && (
<ToolCallResultDisplay
content={part.content}
baseClassName="collapse bg-base-200 collapse-arrow mb-4 mt-2"
/>
)}

{part.role === 'assistant' && part.content && (
<div dir="auto" className="mt-2">
<MarkdownDisplay
content={part.content}
isGenerating={!!isPending}
/>
</div>
)}

{part.tool_calls &&
part.tool_calls.map((toolCall) => (
<ToolCallArgsDisplay
key={toolCall.id}
toolCall={toolCall}
baseClassName="collapse bg-base-200 collapse-arrow mb-4 mt-2"
/>
))}
</Fragment>
))}
{/* render timings if enabled */}
{timings && config.showTokensPerSecond && (
<div className="dropdown dropdown-hover dropdown-top mt-2">
Expand Down Expand Up @@ -195,7 +258,7 @@ export default function ChatMessage({
</div>

{/* actions for each message */}
{msg.content !== null && (
{(entireTurnHasSomeDisplayableContent || msg.role === 'user') && (
<div
className={classNames({
'flex items-center gap-2 mx-4 mt-2 mb-2': true,
Expand Down Expand Up @@ -251,19 +314,31 @@ export default function ChatMessage({
<BtnWithTooltips
className="btn-mini w-8 h-8"
onClick={() => {
if (msg.content !== null) {
if (entireTurnHasSomeDisplayableContent) {
onRegenerateMessage(msg as Message);
}
}}
disabled={msg.content === null}
disabled={
!entireTurnHasSomeDisplayableContent || msg.content === null
}
tooltipsContent="Regenerate response"
>
<ArrowPathIcon className="h-4 w-4" />
</BtnWithTooltips>
)}
</>
)}
<CopyButton className="btn-mini w-8 h-8" content={msg.content} />
{entireTurnHasSomeDisplayableContent && (
<CopyButton
className="badge btn-mini show-on-hover mr-2"
content={
msg.content ??
chainedParts?.find((p) => p.role === 'assistant' && p.content)
?.content ??
''
}
/>
)}
</div>
)}
</div>
Expand Down
Loading