This commit is contained in:
Diwakar Ray Yadav 2026-08-04 16:51:52 +08:00 committed by GitHub
commit 909e287f3f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 128 additions and 0 deletions

View File

@ -10,7 +10,9 @@
import WrenchSolid from '$lib/components/icons/WrenchSolid.svelte';
import Sparkles from '$lib/components/icons/Sparkles.svelte';
import CheckCircle from '$lib/components/icons/CheckCircle.svelte';
import ErrorCircle from '$lib/components/icons/ErrorCircle.svelte';
import FullHeightIframe from '$lib/components/common/FullHeightIframe.svelte';
import { isToolResultError } from '$lib/components/common/toolCallUtils';
import { settings } from '$lib/stores';
@ -19,6 +21,7 @@
export let id = '';
export let tokens: Array<{
summary?: string;
text?: string;
attributes?: {
type?: string;
name?: string;
@ -51,6 +54,16 @@
$: codeInterpreterCount = tokens.filter((t) => t?.attributes?.type === 'code_interpreter').length;
// True when any completed tool call in the group returned an error payload
// (e.g. {"error": "403 Client Error: ..."}), so the group summary can show a
// warning icon instead of a success check.
$: hasError = tokens.some((t) => {
if (t?.attributes?.type !== 'tool_calls') return false;
if (t?.attributes?.done !== 'true') return false;
const text = decode(t?.text ?? '').replace(/<summary>.*?<\/summary>/gi, '').trim();
return isToolResultError(text);
});
// Collect all embeds from tool_calls tokens
$: allEmbeds = (() => {
if (!allowEmbeds) return [];
@ -128,6 +141,10 @@
<div>
<Spinner className="size-4" />
</div>
{:else if toolCallCount > 0 && hasError}
<div class="text-red-500 dark:text-red-400">
<ErrorCircle className="size-4" strokeWidth="2" />
</div>
{:else if toolCallCount > 0}
<div class="text-emerald-500 dark:text-emerald-400">
<CheckCircle className="size-4" strokeWidth="2" />

View File

@ -13,9 +13,11 @@
import Spinner from './Spinner.svelte';
import WrenchSolid from '../icons/WrenchSolid.svelte';
import CheckCircle from '../icons/CheckCircle.svelte';
import ErrorCircle from '../icons/ErrorCircle.svelte';
import Image from './Image.svelte';
import FullHeightIframe from './FullHeightIframe.svelte';
import { settings } from '$lib/stores';
import { isToolResultError } from './toolCallUtils';
export let id: string = '';
export let attributes: {
@ -95,6 +97,7 @@
$: parsedArgs = parseArguments(args);
$: parsedResult = parseJSONString(result);
$: isError = isDone && isToolResultError(result);
</script>
<div {id} class={className}>
@ -136,6 +139,10 @@
<div>
<Spinner className="size-4" />
</div>
{:else if isDone && isError}
<div class="text-red-500 dark:text-red-400">
<ErrorCircle className="size-4" strokeWidth="2" />
</div>
{:else if isDone}
<div class="text-emerald-500 dark:text-emerald-400">
<CheckCircle className="size-4" strokeWidth="2" />

View File

@ -0,0 +1,47 @@
import { describe, it, expect } from 'vitest';
import { isToolResultError, parseToolResult } from './toolCallUtils';
describe('parseToolResult', () => {
it('unwraps nested JSON-encoded strings', () => {
expect(parseToolResult('{"error":"x"}')).toEqual({ error: 'x' });
// Double-encoded payload: a JSON string containing a JSON string.
expect(parseToolResult('"{\\"error\\":\\"x\\"}"')).toEqual({ error: 'x' });
});
it('returns scalars without looping forever', () => {
expect(parseToolResult('5')).toBe(5);
expect(parseToolResult('null')).toBe(null);
expect(parseToolResult('true')).toBe(true);
expect(parseToolResult(5)).toBe(5);
});
it('returns non-JSON strings as-is', () => {
expect(parseToolResult('not json')).toBe('not json');
expect(parseToolResult('')).toBe('');
});
});
describe('isToolResultError', () => {
it('detects a JSON-encoded object with a non-empty error string', () => {
expect(isToolResultError('{"error":"403 Client Error: Forbidden for url: ..."}')).toBe(true);
expect(isToolResultError({ error: 'boom' })).toBe(true);
});
it('ignores empty / null / non-string error fields', () => {
expect(isToolResultError('{"error":""}')).toBe(false);
expect(isToolResultError('{"error":null}')).toBe(false);
expect(isToolResultError('{"error":0}')).toBe(false);
expect(isToolResultError('{"error":false}')).toBe(false);
expect(isToolResultError({ error: null })).toBe(false);
});
it('ignores non-error payloads', () => {
expect(isToolResultError('{"results":[1,2,3]}')).toBe(false);
expect(isToolResultError('{"query":"cats"}')).toBe(false);
expect(isToolResultError('just text')).toBe(false);
expect(isToolResultError('')).toBe(false);
expect(isToolResultError(null)).toBe(false);
expect(isToolResultError(undefined)).toBe(false);
expect(isToolResultError([])).toBe(false);
});
});

View File

@ -0,0 +1,37 @@
/**
* Iteratively unwrap nested JSON-encoded strings.
*
* The naive recursive form (`return parse(JSON.parse(str))`) recurses forever
* on scalar values such as `JSON.parse('5') -> 5`, because `JSON.parse(5)` is
* `JSON.parse('5')` again. Unwrapping in a `while` loop avoids that
* stack-overflow-and-recover path.
*/
export function parseToolResult(value: string | unknown): unknown {
let current: unknown = value;
while (typeof current === 'string') {
try {
current = JSON.parse(current);
} catch {
break;
}
}
return current;
}
/**
* A tool call result is considered an error when the (possibly JSON-encoded)
* payload is an object that carries a non-empty `error` string the shape the
* Open WebUI backend uses for tool failures such as
* `{"error": "403 Client Error: Forbidden for url: ..."}`.
*
* Empty/null/non-string `error` fields are ignored so that normal results that
* happen to include an `error: null` or `error: 0` field are not misreported.
*/
export function isToolResultError(value: string | unknown): boolean {
const parsed = parseToolResult(value);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
return false;
}
const error = (parsed as Record<string, unknown>).error;
return typeof error === 'string' && error.trim().length > 0;
}

View File

@ -0,0 +1,20 @@
<script lang="ts">
export let className = 'size-4';
export let strokeWidth = '1.5';
</script>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width={strokeWidth}
stroke="currentColor"
class={className}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v4M12 16.992v.008M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>