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 18 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
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)
])
),
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think this can be kept simple for now, by simply listing the list of tools here (we don't have many, right?)

Copy link
Author

Choose a reason for hiding this comment

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

Well, sure can, but I think we will need to readd it in the future?
#13501 (comment)

Copy link
Collaborator

Choose a reason for hiding this comment

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

If you're saying that we will have user-provided tool, then I think this is not the correct approach.

This Array.from(AVAILABLE_TOOLS.values()) will be called when the app is loaded. It will become out of sync with AVAILABLE_TOOLS if something decide to add a new tool after that

Copy link
Author

Choose a reason for hiding this comment

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

Ok!

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