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

Draft
wants to merge 9 commits into
base: master
Choose a base branch
from
Draft
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 @@ -33,6 +33,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
5 changes: 2 additions & 3 deletions tools/server/webui/src/Config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,7 @@ import { isNumeric } from './utils/misc';
export const isDev = import.meta.env.MODE === 'development';

// constants
export const BASE_URL = new URL('.', document.baseURI).href
.toString()
.replace(/\/$/, '');
export const BASE_URL = 'http://127.0.0.1:8080';
Copy link

Choose a reason for hiding this comment

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

Are you sure you meant to commit this change?

Copy link
Author

Choose a reason for hiding this comment

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

Not intended. Thanks


export const CONFIG_DEFAULT = {
// Note: in order not to introduce breaking changes, please keep the same data type (number, string, etc) if you want to change the default value. Do not use null or undefined for default value.
Expand Down Expand Up @@ -39,6 +37,7 @@ export const CONFIG_DEFAULT = {
custom: '', // custom json-stringified object
// experimental features
pyIntepreterEnabled: false,
jsInterpreterToolUse: 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
78 changes: 78 additions & 0 deletions tools/server/webui/src/assets/iframe_sandbox.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<!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
const originalConsoleLog = console.log; // Keep a reference if needed
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>
71 changes: 61 additions & 10 deletions tools/server/webui/src/components/ChatMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,17 +48,22 @@ 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
// for reasoning model, we split the message into content, thought, and tool output
const { content, thought, isThinking }: SplitMessage = useMemo(() => {
if (msg.content === null || msg.role !== 'assistant') {
if (
msg.content === null ||
(msg.role !== 'assistant' && msg.role !== 'tool')
) {
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);
Expand All @@ -71,11 +76,13 @@ export default function ChatMessage({
actualContent += thinkSplit[0];
}
}

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

if (!viewingChat) return null;

const toolCalls = msg.tool_calls ?? null;

return (
<div className="group" id={id}>
<div
Expand Down Expand Up @@ -125,8 +132,12 @@ export default function ChatMessage({
<>
{content === null ? (
<>
{/* show loading dots for pending message */}
<span className="loading loading-dots loading-md"></span>
{toolCalls ? null : (
<>
{/* show loading dots for pending message */}
<span className="loading loading-dots loading-md"></span>
</>
)}
</>
) : (
<>
Expand Down Expand Up @@ -188,13 +199,53 @@ export default function ChatMessage({
</details>
)}

<MarkdownDisplay
content={content}
isGenerating={isPending}
/>
{msg.role === 'tool' ? (
<details
className="collapse bg-base-200 collapse-arrow mb-4"
open={true}
>
<summary className="collapse-title">
<b>Tool call result</b>
</summary>
<div className="collapse-content">
<MarkdownDisplay
content={content}
isGenerating={false} // Tool results are not "generating"
/>
</div>
</details>
) : (
<MarkdownDisplay
content={content}
isGenerating={isPending}
/>
)}
</div>
</>
)}
{toolCalls &&
toolCalls.map((toolCall, i) => (
<details
key={i}
className="collapse bg-base-200 collapse-arrow mb-4"
open={false} // todo: make this configurable like showThoughtInProgress
>
<summary className="collapse-title">
<b>Tool call:</b> {toolCall.function.name}
</summary>

<div className="collapse-content">
<div className="font-bold mb-1">Arguments:</div>
<pre className="whitespace-pre-wrap bg-base-300 p-2 rounded">
{JSON.stringify(
JSON.parse(toolCall.function.arguments),
null,
2
)}
</pre>
</div>
</details>
))}
{/* render timings if enabled */}
{timings && config.showTokensPerSecond && (
<div className="dropdown dropdown-hover dropdown-top mt-2">
Expand Down
15 changes: 15 additions & 0 deletions tools/server/webui/src/components/SettingDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,21 @@ const SETTING_SECTIONS: SettingSection[] = [
),
key: 'pyIntepreterEnabled',
},
{
type: SettingInputType.CHECKBOX,
label: (
<>
<b>Enable JavaScript tool use</b>
<br />
<small className="text-xs">
This alows LLM to use browser your browser console as tool. If
model supports function calling, it can use the console to do e.g.
data analysis etc. by itself.
</small>
</>
),
key: 'jsInterpreterToolUse',
},
],
},
];
Expand Down
Loading