mirror of https://github.com/open-webui/open-webui
Merge 408c3e7cc8 into 8dbbc206c5
This commit is contained in:
commit
909e287f3f
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
Loading…
Reference in New Issue