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 26 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
acd4767
feat(server): add basic js tool call support
samolego May 7, 2025
6236918
code abstraction for tool calling
samolego May 8, 2025
e84e819
minor changes, renames
samolego May 8, 2025
f6b1386
add tool call fields
samolego May 12, 2025
f2175cb
fix: Use structured `tool_calls` for tool handling
samolego May 13, 2025
4698b66
fix: forward tool call info back to api
samolego May 13, 2025
69e7119
provide tool call response in a dropdown
samolego May 13, 2025
75fd25e
Fix UI updates after tool call chains
samolego May 13, 2025
ae32a9a
move js evaluation to sandboxed iframe, remove debug logs
samolego May 13, 2025
00d911d
merge assistant messages on tool use
samolego May 18, 2025
d99808f
feat: populate settings tool calling section
samolego May 18, 2025
0b34d53
feat: add stream response setting
samolego May 18, 2025
0480054
fix: revert base url
samolego May 18, 2025
7fa0043
Merge remote-tracking branch 'upstream/master' into feat/tool-calling
samolego May 18, 2025
b128ca5
fix: readd missing comments
samolego May 19, 2025
c203815
fix: more cleanup
samolego May 19, 2025
cf110f9
minor changes
samolego May 19, 2025
4e7da1b
Delete deno.lock
samolego May 19, 2025
031e673
Update tools/server/webui/package.json
samolego May 20, 2025
c9ec6fa
fix: remove unused type, do not add tool calls in user messages
samolego May 20, 2025
3f76cac
feat: support streaming tool calls
samolego May 26, 2025
c98baef
bugfixes for streaming calls
samolego May 27, 2025
22a951b
fix demo conversation import
samolego May 27, 2025
798946e
fix: make chained message regeneratable
samolego May 29, 2025
92f8bb0
updates to config and available tools map
samolego May 29, 2025
5c898ec
better handling of logged variables in js repl
samolego May 29, 2025
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
30 changes: 30 additions & 0 deletions tools/server/webui/public/demo-conversation.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,36 @@
"id": 1734087548329,
"role": "assistant",
"content": "Code block:\n```js\nconsole.log('hello world')\n```\n```sh\nls -la /dev\n```"
},
{
"id": 1734087548330,
"role": "user",
"content": "What is the weather in San Francisco?"
},
{
"id": 1734087548331,
"role": "assistant",
"content": "<think>User wants to know what's te weather like in San Francisco.</think>Let me check that for you!",
"tool_calls": [
{
"id": "call_abc123",
"type": "function",
"function": {
"name": "get_current_weather",
"arguments": "{\n\"location\": \"San Francisco\"\n}"
}
}
]
},
{
"id": 1734087548332,
"role": "tool",
"content": "{\"temperature\": 20, \"condition\": \"sunny\"}"
},
{
"id": 1734087548333,
"role": "assistant",
"content": "The weather in San Francisco is currently 20°C and sunny."
}
]
}
1 change: 1 addition & 0 deletions tools/server/webui/src/Config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export const CONFIG_DEFAULT = {
custom: '', // custom json-stringified object
// experimental features
pyIntepreterEnabled: false,
toolJsReplEnabled: false,
};
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(JSON.stringify).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>
185 changes: 142 additions & 43 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 { useAppContext } from '../utils/app.context';
import { Message, PendingMessage } from '../utils/types';
import { classNames } from '../utils/misc';
import { classNames, parseThoughtContent } 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 @@ -18,8 +20,87 @@ interface SplitMessage {
isThinking?: boolean;
}

// Helper function to extract thoughts using shared parseThoughtContent
function extractThoughts(content: string | null, role: string): SplitMessage {
if (content === null || (role !== 'assistant' && role !== 'tool')) {
return { content };
}

const { filteredContent, thoughtContent, isThinking } =
parseThoughtContent(content);

return {
content: filteredContent,
thought: thoughtContent,
isThinking,
};
}

// Helper component to render a single message part
function MessagePart({
message,
isPending,
showThoughts = true,
className = '',
baseClassName = '',
isMainMessage = false,
}: {
message: Message | PendingMessage;
isPending?: boolean;
showThoughts?: boolean;
className?: string;
baseClassName?: string;
isMainMessage?: boolean;
}) {
const { config } = useAppContext();
const { content, thought, isThinking } = extractThoughts(
message.content,
message.role
);

if (message.role === 'tool' && baseClassName) {
return (
<ToolCallResultDisplay
content={content || ''}
baseClassName={baseClassName}
/>
);
}

return (
<div className={className}>
{showThoughts && thought && (
<ThoughtProcess
isThinking={!!isThinking && !!isPending}
content={thought}
open={config.showThoughtInProgress}
/>
)}

{message.role === 'tool' && content ? (
<ToolCallResultDisplay content={content} />
) : (
content &&
content.trim() !== '' && (
<MarkdownDisplay content={content} isGenerating={isPending} />
)
)}

{message.tool_calls &&
message.tool_calls.map((toolCall) => (
<ToolCallArgsDisplay
key={toolCall.id}
toolCall={toolCall}
{...(!isMainMessage && baseClassName ? { baseClassName } : {})}
/>
))}
</div>
);
}

export default function ChatMessage({
msg,
chainedParts,
siblingLeafNodeIds,
siblingCurrIdx,
id,
Expand All @@ -29,6 +110,7 @@ export default function ChatMessage({
isPending,
}: {
msg: Message | PendingMessage;
chainedParts?: (Message | PendingMessage)[];
siblingLeafNodeIds: Message['id'][];
siblingCurrIdx: number;
id?: string;
Expand All @@ -55,34 +137,23 @@ export default function ChatMessage({
const nextSibling = siblingLeafNodeIds[siblingCurrIdx + 1];
const prevSibling = siblingLeafNodeIds[siblingCurrIdx - 1];

// 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') {
return { content: msg.content };
}
let actualContent = '';
let thought = '';
let isThinking = false;
let thinkSplit = msg.content.split('<think>', 2);
actualContent += thinkSplit[0];
while (thinkSplit[1] !== undefined) {
// <think> tag found
thinkSplit = thinkSplit[1].split('</think>', 2);
thought += thinkSplit[0];
isThinking = true;
if (thinkSplit[1] !== undefined) {
// </think> closing tag found
isThinking = false;
thinkSplit = thinkSplit[1].split('<think>', 2);
actualContent += thinkSplit[0];
}
}
return { content: actualContent, thought, isThinking };
}, [msg]);
const mainSplitMessage = useMemo(
() => extractThoughts(msg.content, msg.role),
[msg.content, msg.role]
);

if (!viewingChat) return null;

const toolCalls = msg.tool_calls ?? null;

const hasContentInMainMsg =
mainSplitMessage.content && mainSplitMessage.content.trim() !== '';
const hasContentInChainedParts = chainedParts?.some((part) => {
const splitPart = extractThoughts(part.content, part.role);
return splitPart.content && splitPart.content.trim() !== '';
});
const entireTurnHasSomeDisplayableContent =
hasContentInMainMsg || hasContentInChainedParts;
const isUser = msg.role === 'user';

return (
Expand Down Expand Up @@ -141,28 +212,40 @@ export default function ChatMessage({
{/* not editing content, render message */}
{editingContent === null && (
<>
{content === null ? (
{mainSplitMessage.content === null &&
!toolCalls &&
!chainedParts?.length ? (
<>
{/* show loading dots for pending message */}
<span className="loading loading-dots loading-md"></span>
</>
) : (
<>
{/* render message as markdown */}
{/* render main message */}
<div dir="auto" tabIndex={0}>
{thought && (
<ThoughtProcess
isThinking={!!isThinking && !!isPending}
content={thought}
open={config.showThoughtInProgress}
/>
)}

<MarkdownDisplay
content={content}
isGenerating={isPending}
<MessagePart
message={msg}
isPending={isPending}
showThoughts={true}
isMainMessage={true}
/>
</div>

{/* render chained parts */}
{chainedParts?.map((part) => (
<MessagePart
key={part.id}
message={part}
isPending={isPending}
showThoughts={true}
className={part.role === 'assistant' ? 'mt-2' : ''}
baseClassName={
part.role === 'tool'
? 'collapse bg-base-200 collapse-arrow mb-4 mt-2'
: ''
}
/>
))}
</>
)}
{/* render timings if enabled */}
Expand Down Expand Up @@ -195,7 +278,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 +334,35 @@ 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}
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, ...(chainedParts || [])]
.filter((p) => p.content)
.map((p) => {
if (p.role === 'user') {
return p.content;
} else {
return extractThoughts(p.content, p.role).content;
}
})
.join('\n\n') || ''
}
/>
)}
</div>
)}
</div>
Expand Down
Loading
Loading