diff --git a/src/lib/components/chat/Messages/Markdown/ConsecutiveDetailsGroup.svelte b/src/lib/components/chat/Messages/Markdown/ConsecutiveDetailsGroup.svelte index ebfd833bb1..721bdc611e 100644 --- a/src/lib/components/chat/Messages/Markdown/ConsecutiveDetailsGroup.svelte +++ b/src/lib/components/chat/Messages/Markdown/ConsecutiveDetailsGroup.svelte @@ -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>/gi, '').trim(); + return isToolResultError(text); + }); + // Collect all embeds from tool_calls tokens $: allEmbeds = (() => { if (!allowEmbeds) return []; @@ -128,6 +141,10 @@
+ {:else if toolCallCount > 0 && hasError} +
+ +
{:else if toolCallCount > 0}
diff --git a/src/lib/components/common/ToolCallDisplay.svelte b/src/lib/components/common/ToolCallDisplay.svelte index a8e0c344b1..1229e6b36f 100644 --- a/src/lib/components/common/ToolCallDisplay.svelte +++ b/src/lib/components/common/ToolCallDisplay.svelte @@ -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);
@@ -136,6 +139,10 @@
+ {:else if isDone && isError} +
+ +
{:else if isDone}
diff --git a/src/lib/components/common/toolCallUtils.test.ts b/src/lib/components/common/toolCallUtils.test.ts new file mode 100644 index 0000000000..49fc0dfd77 --- /dev/null +++ b/src/lib/components/common/toolCallUtils.test.ts @@ -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); + }); +}); diff --git a/src/lib/components/common/toolCallUtils.ts b/src/lib/components/common/toolCallUtils.ts new file mode 100644 index 0000000000..292025c5e4 --- /dev/null +++ b/src/lib/components/common/toolCallUtils.ts @@ -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).error; + return typeof error === 'string' && error.trim().length > 0; +} diff --git a/src/lib/components/icons/ErrorCircle.svelte b/src/lib/components/icons/ErrorCircle.svelte new file mode 100644 index 0000000000..2fa8e725d8 --- /dev/null +++ b/src/lib/components/icons/ErrorCircle.svelte @@ -0,0 +1,20 @@ + + +