mirror of
https://github.com/open-webui/open-webui.git
synced 2026-09-11 22:52:54 +00:00
feat(ui): improve grouped tool-call summaries and status rendering
This commit is contained in:
parent
9f1b279e88
commit
f4f11d667d
6 changed files with 3071 additions and 186 deletions
132
docs/TOOL_CALL_GROUPING.md
Normal file
132
docs/TOOL_CALL_GROUPING.md
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
# Tool Call Grouping System
|
||||
|
||||
This document explains how grouped tool-call labels (for example "Reviewed Chats" and "Managed memory") are matched, ordered, and rendered.
|
||||
|
||||
## Where Group Ordering Lives
|
||||
|
||||
The group matching and ordering are defined in:
|
||||
|
||||
- `src/lib/utils/toolCallPresentation.ts`
|
||||
- Constant: `TOOL_COMBINATION_RULES`
|
||||
|
||||
Rules are evaluated in ascending `order`.
|
||||
|
||||
- Smaller `order` = higher priority (matched first)
|
||||
- First matching rule wins
|
||||
|
||||
## Match System (Easy To Edit)
|
||||
|
||||
Each rule supports these match clauses:
|
||||
|
||||
- `allOf`: every tool name listed must be present
|
||||
- `anyOf`: at least one tool name listed must be present
|
||||
- `noneOf`: none of the listed tool names may be present
|
||||
- `onlyOf`: all tools in the group must be in this allow-list (no extra tools)
|
||||
|
||||
Rule shape:
|
||||
|
||||
```ts
|
||||
{
|
||||
id: 'unique.rule.id',
|
||||
order: 100,
|
||||
iconKey: 'chat',
|
||||
pendingPrefix: 'Reviewing Chats',
|
||||
donePrefix: 'Reviewed Chats',
|
||||
match: {
|
||||
allOf: ['search_chats'],
|
||||
anyOf: ['view_chat'],
|
||||
noneOf: ['delete_memory'],
|
||||
onlyOf: ['search_chats', 'view_chat']
|
||||
},
|
||||
showDetailList: false
|
||||
}
|
||||
```
|
||||
|
||||
`onlyOf` is optional. Use it when a rule should only match a bounded set of tools.
|
||||
|
||||
## Named Group Behavior
|
||||
|
||||
When a rule matches, the grouped summary uses the rule prefix and icon.
|
||||
|
||||
By default, named groups hide the trailing raw tool list (for example "search_chats, view_chat").
|
||||
|
||||
- Default is `showDetailList: false`
|
||||
- Set `showDetailList: true` only when you explicitly want to append the raw tool names
|
||||
|
||||
## Add A New Group
|
||||
|
||||
1. Open `src/lib/utils/toolCallPresentation.ts`.
|
||||
2. Add a rule to `TOOL_COMBINATION_RULES`.
|
||||
3. Choose an `order` that places it before/after related rules.
|
||||
4. Ensure `id` is unique.
|
||||
5. Set prefixes and icon.
|
||||
6. Set `showDetailList` only if needed.
|
||||
|
||||
## Practical Ordering Strategy
|
||||
|
||||
Use these ranges to keep rules organized:
|
||||
|
||||
- `100-299`: web and knowledge research
|
||||
- `300-499`: chats and notes review
|
||||
- `500-699`: channels and media
|
||||
- `700-999`: broad fallback groups (memory/notes/code)
|
||||
|
||||
Put narrow/specific rules before broad/fallback rules.
|
||||
|
||||
## Examples
|
||||
|
||||
### 1) Web search + code execution (but not note viewing)
|
||||
|
||||
```ts
|
||||
{
|
||||
id: 'web.search_and_execute_code',
|
||||
order: 150,
|
||||
iconKey: 'terminal',
|
||||
pendingPrefix: 'Researching and Executing',
|
||||
donePrefix: 'Researched and Executed',
|
||||
match: {
|
||||
allOf: ['search_web', 'execute_code'],
|
||||
noneOf: ['view_note'],
|
||||
onlyOf: ['search_web', 'fetch_url', 'execute_code']
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2) New "Reviewed Tasks" group
|
||||
|
||||
```ts
|
||||
{
|
||||
id: 'tasks.review',
|
||||
order: 350,
|
||||
iconKey: 'note',
|
||||
pendingPrefix: 'Reviewing Tasks',
|
||||
donePrefix: 'Reviewed Tasks',
|
||||
match: {
|
||||
allOf: ['search_tasks'],
|
||||
anyOf: ['view_task']
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3) Broad fallback for any task activity
|
||||
|
||||
```ts
|
||||
{
|
||||
id: 'tasks.manage',
|
||||
order: 950,
|
||||
iconKey: 'note',
|
||||
pendingPrefix: 'Managing tasks',
|
||||
donePrefix: 'Managed tasks',
|
||||
match: {
|
||||
anyOf: ['search_tasks', 'view_task', 'create_task', 'update_task', 'delete_task']
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Rendering Location
|
||||
|
||||
The grouped summary UI reads these values in:
|
||||
|
||||
- `src/lib/components/chat/Messages/Markdown/ConsecutiveDetailsGroup.svelte`
|
||||
|
||||
It now suppresses the trailing list whenever a named group is active unless `showDetailList` is set to `true` in the matched rule.
|
||||
|
|
@ -1,20 +1,42 @@
|
|||
<script lang="ts">
|
||||
import { decode } from 'html-entities';
|
||||
import { getContext } from 'svelte';
|
||||
import type { Readable } from 'svelte/store';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
|
||||
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
|
||||
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
|
||||
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||
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 GlobeAlt from '$lib/components/icons/GlobeAlt.svelte';
|
||||
import Link from '$lib/components/icons/Link.svelte';
|
||||
import Photo from '$lib/components/icons/Photo.svelte';
|
||||
import Terminal from '$lib/components/icons/Terminal.svelte';
|
||||
import Database from '$lib/components/icons/Database.svelte';
|
||||
import Note from '$lib/components/icons/Note.svelte';
|
||||
import ChatBubble from '$lib/components/icons/ChatBubble.svelte';
|
||||
import ChatBubbles from '$lib/components/icons/ChatBubbles.svelte';
|
||||
import BookOpen from '$lib/components/icons/BookOpen.svelte';
|
||||
import ClockRotateRight from '$lib/components/icons/ClockRotateRight.svelte';
|
||||
import Document from '$lib/components/icons/Document.svelte';
|
||||
import FullHeightIframe from '$lib/components/common/FullHeightIframe.svelte';
|
||||
|
||||
import { settings } from '$lib/stores';
|
||||
import {
|
||||
INLINE_TOOL_CHEVRON_CLASS,
|
||||
INLINE_TOOL_ICON_CENTER_OFFSET_CLASS,
|
||||
INLINE_TOOL_ROW_CLASS,
|
||||
INLINE_TOOL_TITLE_CLASS,
|
||||
INLINE_TOOL_TRIGGER_CLASS
|
||||
} from '$lib/utils/toolCallInlineStyles';
|
||||
import {
|
||||
getToolPresentation,
|
||||
getToolCombinationSummary,
|
||||
type ToolIconKey
|
||||
} from '$lib/utils/toolCallPresentation';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
const i18n = getContext<Readable<{ t: (key: string, params?: Record<string, unknown>) => string }>>('i18n');
|
||||
|
||||
export let id = '';
|
||||
export let tokens: Array<{
|
||||
|
|
@ -30,24 +52,198 @@
|
|||
}> = [];
|
||||
|
||||
export let messageDone = true;
|
||||
export let groupClosed = false;
|
||||
|
||||
let open = $settings?.expandDetails ?? false;
|
||||
let open = false;
|
||||
let allEmbeds: Array<{ name: string; embed: string; args?: string }> = [];
|
||||
type ToolCallStatus = 'success' | 'failed' | 'neutral';
|
||||
type SummaryIconEntry = { iconKey: ToolIconKey; status: ToolCallStatus };
|
||||
let summaryIconEntries: SummaryIconEntry[] = [];
|
||||
const STACK_STEP_PX = 11;
|
||||
|
||||
function parseJSONString(str: string) {
|
||||
const FAILURE_TEXT_PATTERN = /error|failed|exception|traceback/i;
|
||||
const FAILURE_STATUS_PATTERN = /error|fail|failed|exception/;
|
||||
const NO_RESULTS_TEXT_PATTERN = /\b(?:no|0)\s+(?:result|results|match|matches)\b|not\s+found|none\s+found|empty/i;
|
||||
|
||||
function parseJSONString(str: string): unknown {
|
||||
try {
|
||||
return parseJSONString(JSON.parse(str));
|
||||
} catch (e) {
|
||||
} catch (_parseError) {
|
||||
void _parseError;
|
||||
return str;
|
||||
}
|
||||
}
|
||||
|
||||
$: toolCallCount = tokens.filter((t) => t?.attributes?.type === 'tool_calls').length;
|
||||
$: reasoningCount = tokens.filter((t) => t?.attributes?.type === 'reasoning').length;
|
||||
$: hasPending =
|
||||
!messageDone &&
|
||||
tokens.some((t) => t?.attributes?.done !== undefined && t?.attributes?.done !== 'true');
|
||||
function getDecodedResult(token: (typeof tokens)[number]): string {
|
||||
const raw = (token?.attributes as { result?: string } | undefined)?.result;
|
||||
return decode(raw ?? '');
|
||||
}
|
||||
|
||||
function isFailedResult(rawResult: string): boolean {
|
||||
if (!rawResult.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsed = parseJSONString(rawResult);
|
||||
|
||||
if (typeof parsed === 'string') {
|
||||
return FAILURE_TEXT_PATTERN.test(parsed);
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const record = parsed as Record<string, unknown>;
|
||||
const success = record.success;
|
||||
const ok = record.ok;
|
||||
const status = String(record.status ?? '').toLowerCase();
|
||||
const hasFailureStatus = FAILURE_STATUS_PATTERN.test(status);
|
||||
const errorField = record.error;
|
||||
const hasErrorField =
|
||||
errorField !== undefined &&
|
||||
errorField !== null &&
|
||||
String(errorField).trim().length > 0;
|
||||
const message = String(record.message ?? record.result ?? '').trim();
|
||||
|
||||
return (
|
||||
success === false ||
|
||||
ok === false ||
|
||||
hasFailureStatus ||
|
||||
hasErrorField ||
|
||||
FAILURE_TEXT_PATTERN.test(message)
|
||||
);
|
||||
}
|
||||
|
||||
function isEmptyCollection(value: unknown): boolean {
|
||||
if (Array.isArray(value)) {
|
||||
return value.length === 0;
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.keys(value as Record<string, unknown>).length === 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isNoResultsResult(rawResult: string): boolean {
|
||||
if (!rawResult.trim()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const parsed = parseJSONString(rawResult);
|
||||
|
||||
if (typeof parsed === 'string') {
|
||||
const text = parsed.trim();
|
||||
return text.length === 0 || NO_RESULTS_TEXT_PATTERN.test(text);
|
||||
}
|
||||
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.length === 0;
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const record = parsed as Record<string, unknown>;
|
||||
const countValues = [record.total, record.count, record.results_count, record.match_count];
|
||||
if (countValues.some((value) => Number(value) === 0)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const payloadCandidates = [
|
||||
record.results,
|
||||
record.items,
|
||||
record.matches,
|
||||
record.data,
|
||||
record.channels,
|
||||
record.messages,
|
||||
record.chats,
|
||||
record.notes,
|
||||
record.memories,
|
||||
record.files,
|
||||
record.result
|
||||
];
|
||||
|
||||
if (payloadCandidates.some((value) => isEmptyCollection(value))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const message = String(record.message ?? '').trim();
|
||||
if (message && NO_RESULTS_TEXT_PATTERN.test(message)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Object.keys(record).length === 0;
|
||||
}
|
||||
|
||||
function getToolCallStatus(token: (typeof tokens)[number]): ToolCallStatus {
|
||||
const rawResult = getDecodedResult(token);
|
||||
const failed = isFailedResult(rawResult);
|
||||
if (failed) {
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
const toolName = String(token?.attributes?.name ?? '').toLowerCase();
|
||||
const isDiscoveryTool = /(search|list|query|find)/.test(toolName);
|
||||
if (token?.attributes?.done === 'true' && isDiscoveryTool && isNoResultsResult(rawResult)) {
|
||||
return 'neutral';
|
||||
}
|
||||
|
||||
if (token?.attributes?.done === 'true') {
|
||||
return 'success';
|
||||
}
|
||||
|
||||
return 'neutral';
|
||||
}
|
||||
|
||||
function getStatusClass(status: ToolCallStatus): string {
|
||||
if (status === 'failed') {
|
||||
return 'text-rose-500 dark:text-rose-400';
|
||||
}
|
||||
|
||||
if (status === 'success') {
|
||||
return 'text-emerald-500 dark:text-emerald-400';
|
||||
}
|
||||
|
||||
return 'text-gray-400 dark:text-gray-500';
|
||||
}
|
||||
|
||||
$: toolCallCount = tokens.filter((t) => t?.attributes?.type === 'tool_calls').length;
|
||||
$: codeInterpreterCount = tokens.filter((t) => t?.attributes?.type === 'code_interpreter').length;
|
||||
$: lastToolCall = [...tokens]
|
||||
.reverse()
|
||||
.find((t) => t?.attributes?.type === 'tool_calls');
|
||||
$: hasPending = !groupClosed && !messageDone && (lastToolCall?.attributes?.done ?? 'false') !== 'true';
|
||||
|
||||
function getSummaryIcon(iconKey: ToolIconKey): typeof GlobeAlt {
|
||||
const iconMap: Record<ToolIconKey, typeof GlobeAlt> = {
|
||||
globe: GlobeAlt,
|
||||
link: Link,
|
||||
photo: Photo,
|
||||
terminal: Terminal,
|
||||
database: Database,
|
||||
note: Note,
|
||||
chat: ChatBubble,
|
||||
channels: ChatBubbles,
|
||||
book: BookOpen,
|
||||
clock: ClockRotateRight,
|
||||
sparkles: Sparkles,
|
||||
document: Document
|
||||
};
|
||||
|
||||
return iconMap[iconKey] ?? Sparkles;
|
||||
}
|
||||
|
||||
function getEmbedSrc(item: unknown): string {
|
||||
if (item && typeof item === 'object' && 'embed' in item) {
|
||||
return String((item as { embed: unknown }).embed);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
// Collect all embeds from tool_calls tokens
|
||||
$: allEmbeds = (() => {
|
||||
|
|
@ -66,17 +262,80 @@
|
|||
});
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
} catch (_embedParseError) {
|
||||
void _embedParseError;
|
||||
// Ignore malformed embed payloads in summary rendering.
|
||||
}
|
||||
}
|
||||
return result;
|
||||
})();
|
||||
|
||||
$: toolCallNames = tokens
|
||||
.filter((t) => t?.attributes?.type === 'tool_calls')
|
||||
.map((t) => t?.attributes?.name ?? 'tool');
|
||||
|
||||
$: combinationSummary = getToolCombinationSummary(toolCallNames, hasPending);
|
||||
$: firstToolPresentation = getToolPresentation(toolCallNames[0]);
|
||||
$: summaryIconEntries = (() => {
|
||||
const calls = tokens.filter((t) => t?.attributes?.type === 'tool_calls');
|
||||
|
||||
type IconAggregate = {
|
||||
iconKey: ToolIconKey;
|
||||
firstIndex: number;
|
||||
statusOrder: ToolCallStatus[];
|
||||
};
|
||||
|
||||
const aggregateMap = new Map<ToolIconKey, IconAggregate>();
|
||||
|
||||
for (let idx = 0; idx < calls.length; idx += 1) {
|
||||
const call = calls[idx];
|
||||
const iconKey = getToolPresentation(call?.attributes?.name ?? 'tool').iconKey;
|
||||
const status = getToolCallStatus(call);
|
||||
const current = aggregateMap.get(iconKey) ?? {
|
||||
iconKey,
|
||||
firstIndex: idx,
|
||||
statusOrder: []
|
||||
};
|
||||
|
||||
if (!current.statusOrder.includes(status)) {
|
||||
current.statusOrder.push(status);
|
||||
}
|
||||
|
||||
aggregateMap.set(iconKey, current);
|
||||
}
|
||||
|
||||
const ordered = [...aggregateMap.values()].sort((a, b) => a.firstIndex - b.firstIndex);
|
||||
const entries: SummaryIconEntry[] = [];
|
||||
|
||||
for (const item of ordered) {
|
||||
// Preserve encounter order for each icon key, e.g. failed -> success -> neutral.
|
||||
for (const status of item.statusOrder) {
|
||||
entries.push({ iconKey: item.iconKey, status });
|
||||
}
|
||||
}
|
||||
|
||||
if (entries.length > 0) {
|
||||
return entries;
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
iconKey: combinationSummary?.iconKey ?? firstToolPresentation.iconKey,
|
||||
status: 'neutral'
|
||||
}
|
||||
];
|
||||
})();
|
||||
|
||||
$: summaryText = (() => {
|
||||
const parts = [];
|
||||
|
||||
if (combinationSummary && !combinationSummary.showDetailList) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (toolCallCount > 0) {
|
||||
// Group by tool name and show counts
|
||||
const nameCounts = {};
|
||||
const nameCounts: Record<string, number> = {};
|
||||
tokens
|
||||
.filter((t) => t?.attributes?.type === 'tool_calls')
|
||||
.forEach((t) => {
|
||||
|
|
@ -98,33 +357,68 @@
|
|||
}
|
||||
}
|
||||
|
||||
const prefix = hasPending ? $i18n.t('Exploring') : $i18n.t('Explored');
|
||||
const detail = parts.join(', ');
|
||||
return detail;
|
||||
})();
|
||||
|
||||
$: prefixText = hasPending ? $i18n.t('Exploring') : $i18n.t('Explored');
|
||||
$: prefixText = combinationSummary
|
||||
? combinationSummary.prefix
|
||||
: hasPending
|
||||
? $i18n.t('Exploring')
|
||||
: $i18n.t('Explored');
|
||||
$: topSummaryIconIndex = Math.max(summaryIconEntries.length - 1, 0);
|
||||
</script>
|
||||
|
||||
<div {id} class="w-full">
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<button
|
||||
class="w-fit text-left text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition cursor-pointer"
|
||||
class={INLINE_TOOL_TRIGGER_CLASS}
|
||||
aria-label={$i18n.t('Toggle details')}
|
||||
aria-expanded={open}
|
||||
on:click={() => {
|
||||
open = !open;
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="{INLINE_TOOL_ROW_CLASS} {hasPending ? 'shimmer' : ''}">
|
||||
<!-- Status icon -->
|
||||
{#if hasPending}
|
||||
<div>
|
||||
<Spinner className="size-4" />
|
||||
</div>
|
||||
{:else if toolCallCount > 0}
|
||||
<div class="text-emerald-500 dark:text-emerald-400">
|
||||
<CheckCircle className="size-4" strokeWidth="2" />
|
||||
<div class="text-emerald-500 dark:text-emerald-400 size-4 relative overflow-visible shrink-0">
|
||||
{#if summaryIconEntries.length > 1}
|
||||
{#each summaryIconEntries as entry, idx}
|
||||
{@const isTopIcon = idx === topSummaryIconIndex}
|
||||
{@const collapseUnderTop = open && !isTopIcon}
|
||||
{@const stackOffset = (summaryIconEntries.length - 1 - idx) * STACK_STEP_PX}
|
||||
<div
|
||||
class="absolute top-0 {isTopIcon ? '' : 'transition-[transform,opacity] duration-600 ease-[cubic-bezier(0.34,1.56,0.64,1)]'} {getStatusClass(entry.status)} {collapseUnderTop ? 'opacity-0 pointer-events-none' : 'opacity-100'}"
|
||||
style="right: {stackOffset}px; z-index: {idx + 1}; transition-delay: {!isTopIcon && collapseUnderTop ? Math.abs(idx - topSummaryIconIndex) * 80 : 0}ms; transform-origin: center; transform: {isTopIcon
|
||||
? 'none'
|
||||
: `translate3d(${collapseUnderTop ? stackOffset : 0}px, 0, 0) scale(${collapseUnderTop ? 0.78 : 1})`};"
|
||||
>
|
||||
{#if idx > 0}
|
||||
<!-- Icon-shape cutout: same glyph, thicker stroke, background color -->
|
||||
<svelte:component
|
||||
this={getSummaryIcon(entry.iconKey)}
|
||||
className="absolute -inset-[2px] size-5 text-white dark:text-gray-900 fill-white dark:fill-gray-900 z-0"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
{/if}
|
||||
<svelte:component
|
||||
this={getSummaryIcon(entry.iconKey)}
|
||||
className="relative z-10 size-4"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<svelte:component
|
||||
this={getSummaryIcon(summaryIconEntries[0].iconKey)}
|
||||
className="size-4 {getStatusClass(summaryIconEntries[0].status)}"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-gray-400 dark:text-gray-500">
|
||||
|
|
@ -133,8 +427,8 @@
|
|||
{/if}
|
||||
|
||||
<!-- Summary text -->
|
||||
<div class="flex-1 line-clamp-1">
|
||||
<span class="text-gray-600 dark:text-gray-300 {hasPending ? 'shimmer' : ''}"
|
||||
<div class={INLINE_TOOL_TITLE_CLASS}>
|
||||
<span class="{hasPending ? 'shimmer' : ''}"
|
||||
>{prefixText}</span
|
||||
>
|
||||
{#if summaryText}
|
||||
|
|
@ -143,16 +437,23 @@
|
|||
</div>
|
||||
|
||||
<!-- Chevron -->
|
||||
<div class="flex shrink-0 self-center text-gray-400 dark:text-gray-500">
|
||||
<div class={INLINE_TOOL_CHEVRON_CLASS}>
|
||||
{#if open}
|
||||
<ChevronUp strokeWidth="3.5" className="size-3" />
|
||||
<ChevronUp strokeWidth="3.5" className="size-3.5" />
|
||||
{:else}
|
||||
<ChevronDown strokeWidth="3.5" className="size-3" />
|
||||
<ChevronDown strokeWidth="3.5" className="size-3.5" />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
transition:slide={{ duration: 160, easing: quintOut, axis: 'y' }}
|
||||
class="{INLINE_TOOL_ICON_CENTER_OFFSET_CLASS} mt-[-6px] h-3 -mb-1px w-px bg-gray-200 dark:bg-gray-800"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
{#if open}
|
||||
<div transition:slide={{ duration: 300, easing: quintOut, axis: 'y' }}>
|
||||
<div class="mb-0.5 space-y-0.5">
|
||||
|
|
@ -165,8 +466,8 @@
|
|||
{#each allEmbeds as embedItem, idx}
|
||||
<div id={`${id}-embed-${idx}`}>
|
||||
<FullHeightIframe
|
||||
src={embedItem.embed}
|
||||
args={embedItem.args}
|
||||
src={getEmbedSrc(embedItem)}
|
||||
args={null}
|
||||
allowScripts={true}
|
||||
allowForms={$settings?.iframeSandboxAllowForms ?? false}
|
||||
allowSameOrigin={$settings?.iframeSandboxAllowSameOrigin ?? false}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
<script lang="ts">
|
||||
import { decode } from 'html-entities';
|
||||
import { onMount, getContext } from 'svelte';
|
||||
const i18n = getContext('i18n');
|
||||
import { getContext } from 'svelte';
|
||||
import type { Readable } from 'svelte/store';
|
||||
const i18n = getContext<Readable<{ t: (key: string, params?: Record<string, unknown>) => string }>>('i18n');
|
||||
|
||||
import fileSaver from 'file-saver';
|
||||
const { saveAs } = fileSaver;
|
||||
|
|
@ -20,6 +21,7 @@
|
|||
import ToolCallDisplay from '$lib/components/common/ToolCallDisplay.svelte';
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
import Download from '$lib/components/icons/Download.svelte';
|
||||
import CheckCircle from '$lib/components/icons/CheckCircle.svelte';
|
||||
import ConsecutiveDetailsGroup from './ConsecutiveDetailsGroup.svelte';
|
||||
|
||||
import HtmlToken from './HTMLToken.svelte';
|
||||
|
|
@ -30,7 +32,7 @@
|
|||
export let tokens: Token[];
|
||||
export let top = true;
|
||||
export let attributes = {};
|
||||
export let sourceIds = [];
|
||||
export let sourceIds: string[] = [];
|
||||
|
||||
export let done = true;
|
||||
|
||||
|
|
@ -53,21 +55,24 @@
|
|||
return 'h' + depth;
|
||||
};
|
||||
|
||||
const GROUPABLE_DETAIL_TYPES = new Set(['tool_calls', 'reasoning', 'code_interpreter']);
|
||||
const GROUPABLE_DETAIL_TYPES = new Set(['tool_calls']);
|
||||
type DetailGroupItem = Token & { attributes?: { type?: string; done?: string }; text?: string };
|
||||
type DetailGroupToken = { type: 'detail_group'; items: DetailGroupItem[]; groupClosed: boolean };
|
||||
|
||||
const isGroupableDetailToken = (token: Token & { attributes?: { type?: string } }) => {
|
||||
return token?.type === 'details' && GROUPABLE_DETAIL_TYPES.has(token?.attributes?.type ?? '');
|
||||
};
|
||||
|
||||
const getDisplayTokens = (tokenList: Token[] = []) => {
|
||||
const displayTokens = [];
|
||||
let detailGroup = [];
|
||||
const displayTokens: Array<Token | DetailGroupToken> = [];
|
||||
let detailGroup: DetailGroupItem[] = [];
|
||||
|
||||
const flushDetailGroup = () => {
|
||||
const flushDetailGroup = (groupClosed: boolean) => {
|
||||
if (detailGroup.length > 1) {
|
||||
displayTokens.push({
|
||||
type: 'detail_group',
|
||||
items: [...detailGroup]
|
||||
items: [...detailGroup],
|
||||
groupClosed
|
||||
});
|
||||
} else if (detailGroup.length === 1) {
|
||||
displayTokens.push(detailGroup[0]);
|
||||
|
|
@ -80,17 +85,44 @@
|
|||
if (isGroupableDetailToken(token)) {
|
||||
detailGroup.push(token);
|
||||
} else {
|
||||
flushDetailGroup();
|
||||
flushDetailGroup(true);
|
||||
displayTokens.push(token);
|
||||
}
|
||||
}
|
||||
|
||||
flushDetailGroup();
|
||||
flushDetailGroup(false);
|
||||
|
||||
return displayTokens;
|
||||
};
|
||||
|
||||
const getDetailTextContent = (token) => {
|
||||
const hasLaterToolCall = (items: DetailGroupItem[], index: number): boolean => {
|
||||
for (let i = index + 1; i < items.length; i += 1) {
|
||||
if (items[i]?.attributes?.type === 'tool_calls') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const getNormalizedGroupedToolDone = (
|
||||
items: DetailGroupItem[],
|
||||
index: number,
|
||||
rawDone: string | undefined,
|
||||
groupClosed: boolean
|
||||
): string => {
|
||||
if (rawDone === 'true') {
|
||||
return 'true';
|
||||
}
|
||||
|
||||
if (hasLaterToolCall(items, index) || groupClosed) {
|
||||
return 'true';
|
||||
}
|
||||
|
||||
return rawDone ?? 'false';
|
||||
};
|
||||
|
||||
const getDetailTextContent = (token: DetailGroupItem): string => {
|
||||
return decode(token?.text || '')
|
||||
.replace(/<summary>.*?<\/summary>/gi, '')
|
||||
.trim();
|
||||
|
|
@ -370,20 +402,33 @@
|
|||
<ConsecutiveDetailsGroup
|
||||
id={`${id}-${tokenIdx}-detail-group`}
|
||||
tokens={token.items}
|
||||
groupClosed={token.groupClosed === true}
|
||||
messageDone={done}
|
||||
>
|
||||
<div slot="content" class="space-y-1">
|
||||
<div slot="content" class="space-y-0.5">
|
||||
{#each token.items as detailToken, detailIdx}
|
||||
{@const textContent = getDetailTextContent(detailToken)}
|
||||
|
||||
{#if detailToken?.attributes?.type === 'tool_calls'}
|
||||
{@const toolDone = getNormalizedGroupedToolDone(
|
||||
token.items,
|
||||
detailIdx,
|
||||
detailToken?.attributes?.done,
|
||||
token.groupClosed === true
|
||||
)}
|
||||
<ToolCallDisplay
|
||||
id={`${id}-${tokenIdx}-${detailIdx}-tc`}
|
||||
attributes={detailToken.attributes}
|
||||
attributes={{ ...detailToken.attributes, done: toolDone }}
|
||||
grouped={true}
|
||||
open={$settings?.expandDetails ?? false}
|
||||
className="w-full space-y-1"
|
||||
open={false}
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
{#if hasLaterToolCall(token.items, detailIdx) || (token.groupClosed === true && detailIdx === token.items.length - 1)}
|
||||
<div class="ml-[26px] -mt-0.5 -mb-px h-2 flex items-center">
|
||||
<div class="w-px h-full bg-gray-200 dark:bg-gray-800"></div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if textContent.length > 0}
|
||||
<Collapsible
|
||||
title={detailToken.summary}
|
||||
|
|
@ -418,6 +463,15 @@
|
|||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if token.groupClosed === true}
|
||||
<div class="ml-[18px] flex items-center gap-1.5 py-0.5 text-gray-600 dark:text-gray-300">
|
||||
<div class="shrink-0 text-gray-400 dark:text-gray-500">
|
||||
<CheckCircle className="size-4" strokeWidth="2" />
|
||||
</div>
|
||||
<div>{$i18n.t('Done')}</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</ConsecutiveDetailsGroup>
|
||||
{:else if token.type === 'details'}
|
||||
|
|
@ -428,7 +482,7 @@
|
|||
<ToolCallDisplay
|
||||
id={`${id}-${tokenIdx}-tc`}
|
||||
attributes={token.attributes}
|
||||
open={$settings?.expandDetails ?? false}
|
||||
open={false}
|
||||
className="w-full space-y-1"
|
||||
/>
|
||||
{:else if textContent.length > 0}
|
||||
|
|
@ -547,7 +601,7 @@
|
|||
{onSourceClick}
|
||||
/>
|
||||
{:else if token.type === 'space'}
|
||||
<div class="my-2" />
|
||||
<div class="my-2"></div>
|
||||
{:else}
|
||||
{console.log('Unknown token', token)}
|
||||
{/if}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
12
src/lib/utils/toolCallInlineStyles.ts
Normal file
12
src/lib/utils/toolCallInlineStyles.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
export const INLINE_TOOL_TRIGGER_CLASS =
|
||||
'w-full max-w-full text-left cursor-pointer py-1 pl-[10px] ml-[10px] text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition';
|
||||
|
||||
export const INLINE_TOOL_ROW_CLASS = 'flex items-center gap-1.5 min-w-0';
|
||||
|
||||
export const INLINE_TOOL_TITLE_CLASS = 'line-clamp-1 min-w-0 text-gray-600 dark:text-gray-300';
|
||||
|
||||
export const INLINE_TOOL_CHEVRON_CLASS =
|
||||
'flex shrink-0 self-center text-gray-400 dark:text-gray-500 translate-y-[1px]';
|
||||
|
||||
// Trigger has ml-[10px] + pl-[10px], icon center is +8px from content start => 28px total.
|
||||
export const INLINE_TOOL_ICON_CENTER_OFFSET_CLASS = 'ml-[27px]';
|
||||
717
src/lib/utils/toolCallPresentation.ts
Normal file
717
src/lib/utils/toolCallPresentation.ts
Normal file
|
|
@ -0,0 +1,717 @@
|
|||
export type ToolIconKey =
|
||||
| 'globe'
|
||||
| 'link'
|
||||
| 'photo'
|
||||
| 'terminal'
|
||||
| 'database'
|
||||
| 'note'
|
||||
| 'chat'
|
||||
| 'channels'
|
||||
| 'book'
|
||||
| 'clock'
|
||||
| 'sparkles'
|
||||
| 'document';
|
||||
|
||||
export type ToolSemantic =
|
||||
| 'search_web'
|
||||
| 'fetch_url'
|
||||
| 'image'
|
||||
| 'code'
|
||||
| 'memory'
|
||||
| 'notes'
|
||||
| 'chats'
|
||||
| 'channels'
|
||||
| 'knowledge'
|
||||
| 'time'
|
||||
| 'skills'
|
||||
| 'generic';
|
||||
|
||||
|
||||
export type ToolPresentation = {
|
||||
rawName: string;
|
||||
displayName: string;
|
||||
semantic: ToolSemantic;
|
||||
iconKey: ToolIconKey;
|
||||
runningLabel: string;
|
||||
doneLabel: string;
|
||||
inputLabel: string;
|
||||
outputLabel: string;
|
||||
};
|
||||
|
||||
const EXACT_TOOL_MAP: Record<string, Omit<ToolPresentation, 'rawName' | 'displayName'>> = {
|
||||
get_current_timestamp: {
|
||||
semantic: 'time',
|
||||
iconKey: 'clock',
|
||||
runningLabel: 'Checking Time',
|
||||
doneLabel: 'Checked Time',
|
||||
inputLabel: 'Time Request',
|
||||
outputLabel: 'Timestamp',
|
||||
},
|
||||
calculate_timestamp: {
|
||||
semantic: 'time',
|
||||
iconKey: 'clock',
|
||||
runningLabel: 'Calculating Time',
|
||||
doneLabel: 'Calculated Time',
|
||||
inputLabel: 'Time Offsets',
|
||||
outputLabel: 'Calculated Timestamp',
|
||||
},
|
||||
search_web: {
|
||||
semantic: 'search_web',
|
||||
iconKey: 'globe',
|
||||
runningLabel: 'Searching the web',
|
||||
doneLabel: 'Searched the web',
|
||||
inputLabel: 'Search Query',
|
||||
outputLabel: 'Search Results',
|
||||
},
|
||||
fetch_url: {
|
||||
semantic: 'fetch_url',
|
||||
iconKey: 'link',
|
||||
runningLabel: 'Fetching Web Page',
|
||||
doneLabel: 'Fetched Web Page',
|
||||
inputLabel: 'URL',
|
||||
outputLabel: 'Page Content',
|
||||
},
|
||||
generate_image: {
|
||||
semantic: 'image',
|
||||
iconKey: 'photo',
|
||||
runningLabel: 'Generating Image',
|
||||
doneLabel: 'Generated Image',
|
||||
inputLabel: 'Image Prompt',
|
||||
outputLabel: 'Generated Asset',
|
||||
},
|
||||
edit_image: {
|
||||
semantic: 'image',
|
||||
iconKey: 'photo',
|
||||
runningLabel: 'Editing Image',
|
||||
doneLabel: 'Edited Image',
|
||||
inputLabel: 'Edit Request',
|
||||
outputLabel: 'Edited Asset',
|
||||
},
|
||||
execute_code: {
|
||||
semantic: 'code',
|
||||
iconKey: 'terminal',
|
||||
runningLabel: 'Executing code',
|
||||
doneLabel: 'Executed code',
|
||||
inputLabel: 'Code',
|
||||
outputLabel: 'Execution Result',
|
||||
},
|
||||
search_memories: {
|
||||
semantic: 'memory',
|
||||
iconKey: 'database',
|
||||
runningLabel: 'Searching memories',
|
||||
doneLabel: 'Searched memories',
|
||||
inputLabel: 'Memory Query',
|
||||
outputLabel: 'Memory Matches',
|
||||
},
|
||||
add_memory: {
|
||||
semantic: 'memory',
|
||||
iconKey: 'database',
|
||||
runningLabel: 'Saving memory',
|
||||
doneLabel: 'Saved memory',
|
||||
inputLabel: 'Memory to Save',
|
||||
outputLabel: 'Stored Memory',
|
||||
},
|
||||
replace_memory_content: {
|
||||
semantic: 'memory',
|
||||
iconKey: 'database',
|
||||
runningLabel: 'Updating memory',
|
||||
doneLabel: 'Updated memory',
|
||||
inputLabel: 'Memory Update',
|
||||
outputLabel: 'Memory Update Result',
|
||||
},
|
||||
delete_memory: {
|
||||
semantic: 'memory',
|
||||
iconKey: 'database',
|
||||
runningLabel: 'Deleting memory',
|
||||
doneLabel: 'Deleted memory',
|
||||
inputLabel: 'Memory Target',
|
||||
outputLabel: 'Delete Result',
|
||||
},
|
||||
list_memories: {
|
||||
semantic: 'memory',
|
||||
iconKey: 'database',
|
||||
runningLabel: 'Listing memories',
|
||||
doneLabel: 'Listed memories',
|
||||
inputLabel: 'List Filters',
|
||||
outputLabel: 'Memory List',
|
||||
},
|
||||
search_notes: {
|
||||
semantic: 'notes',
|
||||
iconKey: 'note',
|
||||
runningLabel: 'Searching notes',
|
||||
doneLabel: 'Searched notes',
|
||||
inputLabel: 'Note Query',
|
||||
outputLabel: 'Note Matches',
|
||||
},
|
||||
view_note: {
|
||||
semantic: 'notes',
|
||||
iconKey: 'note',
|
||||
runningLabel: 'Opening note',
|
||||
doneLabel: 'Opened note',
|
||||
inputLabel: 'Note Target',
|
||||
outputLabel: 'Note Content',
|
||||
},
|
||||
write_note: {
|
||||
semantic: 'notes',
|
||||
iconKey: 'note',
|
||||
runningLabel: 'Writing note',
|
||||
doneLabel: 'Wrote note',
|
||||
inputLabel: 'Note to Save',
|
||||
outputLabel: 'Saved Note',
|
||||
},
|
||||
replace_note_content: {
|
||||
semantic: 'notes',
|
||||
iconKey: 'note',
|
||||
runningLabel: 'Updating note',
|
||||
doneLabel: 'Updated note',
|
||||
inputLabel: 'Note Update',
|
||||
outputLabel: 'Note Update Result',
|
||||
},
|
||||
search_chats: {
|
||||
semantic: 'chats',
|
||||
iconKey: 'chat',
|
||||
runningLabel: 'Searching Chats',
|
||||
doneLabel: 'Searched Chats',
|
||||
inputLabel: 'Chat Query',
|
||||
outputLabel: 'Chat Matches',
|
||||
},
|
||||
view_chat: {
|
||||
semantic: 'chats',
|
||||
iconKey: 'chat',
|
||||
runningLabel: 'Opening Chat',
|
||||
doneLabel: 'Opened Chat',
|
||||
inputLabel: 'Chat Target',
|
||||
outputLabel: 'Chat Transcript',
|
||||
},
|
||||
search_channels: {
|
||||
semantic: 'channels',
|
||||
iconKey: 'channels',
|
||||
runningLabel: 'Searching Channels',
|
||||
doneLabel: 'Searched Channels',
|
||||
inputLabel: 'Channel Query',
|
||||
outputLabel: 'Channel Matches',
|
||||
},
|
||||
search_channel_messages: {
|
||||
semantic: 'channels',
|
||||
iconKey: 'channels',
|
||||
runningLabel: 'Searching Messages',
|
||||
doneLabel: 'Searched Messages',
|
||||
inputLabel: 'Message Query',
|
||||
outputLabel: 'Message Matches',
|
||||
},
|
||||
view_channel_message: {
|
||||
semantic: 'channels',
|
||||
iconKey: 'channels',
|
||||
runningLabel: 'Opening Message',
|
||||
doneLabel: 'Opened Message',
|
||||
inputLabel: 'Message Target',
|
||||
outputLabel: 'Message Detail',
|
||||
},
|
||||
view_channel_thread: {
|
||||
semantic: 'channels',
|
||||
iconKey: 'channels',
|
||||
runningLabel: 'Opening Thread',
|
||||
doneLabel: 'Opened Thread',
|
||||
inputLabel: 'Thread Target',
|
||||
outputLabel: 'Thread Detail',
|
||||
},
|
||||
list_knowledge_bases: {
|
||||
semantic: 'knowledge',
|
||||
iconKey: 'book',
|
||||
runningLabel: 'Listing Knowledge Bases',
|
||||
doneLabel: 'Listed Knowledge Bases',
|
||||
inputLabel: 'Knowledge Filter',
|
||||
outputLabel: 'Knowledge Bases',
|
||||
},
|
||||
search_knowledge_bases: {
|
||||
semantic: 'knowledge',
|
||||
iconKey: 'book',
|
||||
runningLabel: 'Searching Knowledge Bases',
|
||||
doneLabel: 'Searched Knowledge Bases',
|
||||
inputLabel: 'Knowledge Query',
|
||||
outputLabel: 'Knowledge Matches',
|
||||
},
|
||||
search_knowledge_files: {
|
||||
semantic: 'knowledge',
|
||||
iconKey: 'book',
|
||||
runningLabel: 'Searching Knowledge Files',
|
||||
doneLabel: 'Searched Knowledge Files',
|
||||
inputLabel: 'File Query',
|
||||
outputLabel: 'File Matches',
|
||||
},
|
||||
view_file: {
|
||||
semantic: 'knowledge',
|
||||
iconKey: 'document',
|
||||
runningLabel: 'Opening File',
|
||||
doneLabel: 'Opened File',
|
||||
inputLabel: 'File Target',
|
||||
outputLabel: 'File Content',
|
||||
},
|
||||
view_knowledge_file: {
|
||||
semantic: 'knowledge',
|
||||
iconKey: 'document',
|
||||
runningLabel: 'Opening Knowledge File',
|
||||
doneLabel: 'Opened Knowledge File',
|
||||
inputLabel: 'File Target',
|
||||
outputLabel: 'File Content',
|
||||
},
|
||||
list_knowledge: {
|
||||
semantic: 'knowledge',
|
||||
iconKey: 'book',
|
||||
runningLabel: 'Listing Knowledge',
|
||||
doneLabel: 'Listed Knowledge',
|
||||
inputLabel: 'Knowledge Filter',
|
||||
outputLabel: 'Knowledge List',
|
||||
},
|
||||
query_knowledge_files: {
|
||||
semantic: 'knowledge',
|
||||
iconKey: 'book',
|
||||
runningLabel: 'Querying Knowledge Files',
|
||||
doneLabel: 'Queried Knowledge Files',
|
||||
inputLabel: 'Knowledge Query',
|
||||
outputLabel: 'Knowledge Answer',
|
||||
},
|
||||
query_knowledge_bases: {
|
||||
semantic: 'knowledge',
|
||||
iconKey: 'book',
|
||||
runningLabel: 'Querying Knowledge Bases',
|
||||
doneLabel: 'Queried Knowledge Bases',
|
||||
inputLabel: 'Knowledge Query',
|
||||
outputLabel: 'Knowledge Answer',
|
||||
},
|
||||
view_skill: {
|
||||
semantic: 'skills',
|
||||
iconKey: 'sparkles',
|
||||
runningLabel: 'Loading Skill',
|
||||
doneLabel: 'Loaded Skill',
|
||||
inputLabel: 'Skill Target',
|
||||
outputLabel: 'Skill Content',
|
||||
}
|
||||
};
|
||||
|
||||
function toTitleCaseWords(value: string): string {
|
||||
return value
|
||||
.replace(/[_.-]+/g, ' ')
|
||||
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join(' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function inferFallback(rawName: string): Omit<ToolPresentation, 'rawName' | 'displayName'> {
|
||||
const normalized = rawName.toLowerCase();
|
||||
|
||||
if (/search/.test(normalized) && /web|url|http|browse/.test(normalized)) {
|
||||
return {
|
||||
semantic: 'search_web',
|
||||
iconKey: 'globe',
|
||||
runningLabel: 'Searching the web',
|
||||
doneLabel: 'Searched the web',
|
||||
inputLabel: 'Search Query',
|
||||
outputLabel: 'Search Results',
|
||||
};
|
||||
}
|
||||
|
||||
if (/fetch|url|http|crawl/.test(normalized)) {
|
||||
return {
|
||||
semantic: 'fetch_url',
|
||||
iconKey: 'link',
|
||||
runningLabel: 'Fetching Content',
|
||||
doneLabel: 'Fetched Content',
|
||||
inputLabel: 'URL',
|
||||
outputLabel: 'Content',
|
||||
};
|
||||
}
|
||||
|
||||
if (/code|python|terminal|exec|command|script/.test(normalized)) {
|
||||
return {
|
||||
semantic: 'code',
|
||||
iconKey: 'terminal',
|
||||
runningLabel: 'Executing code',
|
||||
doneLabel: 'Executed code',
|
||||
inputLabel: 'Code',
|
||||
outputLabel: 'Execution Result',
|
||||
};
|
||||
}
|
||||
|
||||
if (/memories|memory/.test(normalized)) {
|
||||
if (/search|find|query/.test(normalized)) {
|
||||
return {
|
||||
semantic: 'memory',
|
||||
iconKey: 'database',
|
||||
runningLabel: 'Searching memories',
|
||||
doneLabel: 'Searched memories',
|
||||
inputLabel: 'Memory Query',
|
||||
outputLabel: 'Memory Matches',
|
||||
};
|
||||
}
|
||||
|
||||
if (/list/.test(normalized)) {
|
||||
return {
|
||||
semantic: 'memory',
|
||||
iconKey: 'database',
|
||||
runningLabel: 'Listing memories',
|
||||
doneLabel: 'Listed memories',
|
||||
inputLabel: 'List Filters',
|
||||
outputLabel: 'Memory List',
|
||||
};
|
||||
}
|
||||
|
||||
if (/delete|remove/.test(normalized)) {
|
||||
return {
|
||||
semantic: 'memory',
|
||||
iconKey: 'database',
|
||||
runningLabel: 'Deleting memory',
|
||||
doneLabel: 'Deleted memory',
|
||||
inputLabel: 'Memory Target',
|
||||
outputLabel: 'Delete Result',
|
||||
};
|
||||
}
|
||||
|
||||
if (/replace|update|edit/.test(normalized)) {
|
||||
return {
|
||||
semantic: 'memory',
|
||||
iconKey: 'database',
|
||||
runningLabel: 'Updating memory',
|
||||
doneLabel: 'Updated memory',
|
||||
inputLabel: 'Memory Update',
|
||||
outputLabel: 'Memory Update Result',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
semantic: 'memory',
|
||||
iconKey: 'database',
|
||||
runningLabel: 'Saving memory',
|
||||
doneLabel: 'Saved memory',
|
||||
inputLabel: 'Memory Content',
|
||||
outputLabel: 'Stored Memory',
|
||||
};
|
||||
}
|
||||
|
||||
if (/notes|note/.test(normalized)) {
|
||||
if (/search|find|query/.test(normalized)) {
|
||||
return {
|
||||
semantic: 'notes',
|
||||
iconKey: 'note',
|
||||
runningLabel: 'Searching notes',
|
||||
doneLabel: 'Searched notes',
|
||||
inputLabel: 'Note Query',
|
||||
outputLabel: 'Note Matches',
|
||||
};
|
||||
}
|
||||
|
||||
if (/view|read|open/.test(normalized)) {
|
||||
return {
|
||||
semantic: 'notes',
|
||||
iconKey: 'note',
|
||||
runningLabel: 'Opening note',
|
||||
doneLabel: 'Opened note',
|
||||
inputLabel: 'Note Target',
|
||||
outputLabel: 'Note Content',
|
||||
};
|
||||
}
|
||||
|
||||
if (/replace|update|edit/.test(normalized)) {
|
||||
return {
|
||||
semantic: 'notes',
|
||||
iconKey: 'note',
|
||||
runningLabel: 'Updating note',
|
||||
doneLabel: 'Updated note',
|
||||
inputLabel: 'Note Update',
|
||||
outputLabel: 'Note Update Result',
|
||||
};
|
||||
}
|
||||
|
||||
if (/delete|remove/.test(normalized)) {
|
||||
return {
|
||||
semantic: 'notes',
|
||||
iconKey: 'note',
|
||||
runningLabel: 'Deleting note',
|
||||
doneLabel: 'Deleted note',
|
||||
inputLabel: 'Note Target',
|
||||
outputLabel: 'Delete Result',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
semantic: 'notes',
|
||||
iconKey: 'note',
|
||||
runningLabel: 'Writing note',
|
||||
doneLabel: 'Wrote note',
|
||||
inputLabel: 'Note Draft',
|
||||
outputLabel: 'Saved Note',
|
||||
};
|
||||
}
|
||||
|
||||
if (/knowledge|note|memory|chat|channel|file|document/.test(normalized)) {
|
||||
return {
|
||||
semantic: 'generic',
|
||||
iconKey: 'document',
|
||||
runningLabel: 'Running Tool',
|
||||
doneLabel: 'Completed Tool',
|
||||
inputLabel: 'Input',
|
||||
outputLabel: 'Output',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
semantic: 'generic',
|
||||
iconKey: 'sparkles',
|
||||
runningLabel: 'Running Tool',
|
||||
doneLabel: 'Completed Tool',
|
||||
inputLabel: 'Input',
|
||||
outputLabel: 'Output',
|
||||
};
|
||||
}
|
||||
|
||||
export function getToolPresentation(name: string | undefined): ToolPresentation {
|
||||
const rawName = (name ?? 'tool').trim() || 'tool';
|
||||
const displayName = toTitleCaseWords(rawName);
|
||||
const fromMap = EXACT_TOOL_MAP[rawName];
|
||||
const inferred = fromMap ?? inferFallback(rawName);
|
||||
|
||||
return {
|
||||
rawName,
|
||||
displayName,
|
||||
...inferred
|
||||
};
|
||||
}
|
||||
|
||||
export type ToolCombination = {
|
||||
iconKey: ToolIconKey;
|
||||
prefix: string;
|
||||
showDetailList: boolean;
|
||||
matchedRuleId: string;
|
||||
};
|
||||
|
||||
export type ToolCombinationMatch = {
|
||||
allOf?: string[];
|
||||
anyOf?: string[];
|
||||
noneOf?: string[];
|
||||
onlyOf?: string[];
|
||||
};
|
||||
|
||||
export type ToolCombinationRule = {
|
||||
id: string;
|
||||
order: number;
|
||||
iconKey: ToolIconKey;
|
||||
pendingPrefix: string;
|
||||
donePrefix: string;
|
||||
match: ToolCombinationMatch;
|
||||
showDetailList?: boolean;
|
||||
};
|
||||
|
||||
// Rules are evaluated by ascending order. Lower order wins.
|
||||
export const TOOL_COMBINATION_RULES: ToolCombinationRule[] = [
|
||||
{
|
||||
id: 'web.search_and_fetch',
|
||||
order: 100,
|
||||
iconKey: 'globe',
|
||||
pendingPrefix: 'Searching the Web',
|
||||
donePrefix: 'Searched the Web',
|
||||
match: { allOf: ['search_web', 'fetch_url'] }
|
||||
},
|
||||
{
|
||||
id: 'web.search_and_execute_code',
|
||||
order: 150,
|
||||
iconKey: 'terminal',
|
||||
pendingPrefix: 'Researching and Executing',
|
||||
donePrefix: 'Researched and Executed',
|
||||
match: {
|
||||
allOf: ['search_web', 'execute_code'],
|
||||
noneOf: ['view_note'],
|
||||
onlyOf: ['search_web', 'fetch_url', 'execute_code']
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'knowledge.research',
|
||||
order: 200,
|
||||
iconKey: 'book',
|
||||
pendingPrefix: 'Researching Knowledge',
|
||||
donePrefix: 'Researched Knowledge',
|
||||
match: {
|
||||
anyOf: ['search_knowledge_bases', 'search_knowledge_files'],
|
||||
allOf: ['query_knowledge_bases']
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'knowledge.search_and_view',
|
||||
order: 210,
|
||||
iconKey: 'book',
|
||||
pendingPrefix: 'Researching Knowledge',
|
||||
donePrefix: 'Researched Knowledge',
|
||||
match: {
|
||||
anyOf: ['search_knowledge_bases', 'search_knowledge_files'],
|
||||
allOf: ['view_file']
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'knowledge.search_and_view_knowledge_file',
|
||||
order: 220,
|
||||
iconKey: 'book',
|
||||
pendingPrefix: 'Researching Knowledge',
|
||||
donePrefix: 'Researched Knowledge',
|
||||
match: {
|
||||
anyOf: ['search_knowledge_bases', 'search_knowledge_files'],
|
||||
allOf: ['view_knowledge_file']
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'notes.review',
|
||||
order: 300,
|
||||
iconKey: 'note',
|
||||
pendingPrefix: 'Reviewing Notes',
|
||||
donePrefix: 'Reviewed Notes',
|
||||
match: {
|
||||
allOf: ['search_notes'],
|
||||
anyOf: ['view_note', 'write_note', 'replace_note_content']
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'chats.review',
|
||||
order: 400,
|
||||
iconKey: 'chat',
|
||||
pendingPrefix: 'Reviewing Chats',
|
||||
donePrefix: 'Reviewed Chats',
|
||||
match: { allOf: ['search_chats', 'view_chat'] }
|
||||
},
|
||||
{
|
||||
id: 'channels.explore',
|
||||
order: 500,
|
||||
iconKey: 'channels',
|
||||
pendingPrefix: 'Exploring Channels',
|
||||
donePrefix: 'Explored Channels',
|
||||
match: {
|
||||
allOf: ['search_channels'],
|
||||
anyOf: ['search_channel_messages', 'view_channel_message', 'view_channel_thread']
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'images.create_or_edit',
|
||||
order: 600,
|
||||
iconKey: 'photo',
|
||||
pendingPrefix: 'Creating Images',
|
||||
donePrefix: 'Created Images',
|
||||
match: { anyOf: ['generate_image', 'edit_image'] }
|
||||
},
|
||||
{
|
||||
id: 'code.execute',
|
||||
order: 700,
|
||||
iconKey: 'terminal',
|
||||
pendingPrefix: 'Executing Code',
|
||||
donePrefix: 'Executed Code',
|
||||
match: { onlyOf: ['execute_code'] }
|
||||
},
|
||||
{
|
||||
id: 'memory.manage',
|
||||
order: 800,
|
||||
iconKey: 'database',
|
||||
pendingPrefix: 'Managing Memory',
|
||||
donePrefix: 'Managed Memory',
|
||||
match: {
|
||||
onlyOf: [
|
||||
'search_memories',
|
||||
'list_memories',
|
||||
'add_memory',
|
||||
'replace_memory_content',
|
||||
'delete_memory'
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'notes.manage',
|
||||
order: 900,
|
||||
iconKey: 'note',
|
||||
pendingPrefix: 'Managing Notes',
|
||||
donePrefix: 'Managed Notes',
|
||||
match: {
|
||||
onlyOf: ['search_notes', 'view_note', 'write_note', 'replace_note_content']
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'remember_and_write_note',
|
||||
order: 1000,
|
||||
iconKey: 'note',
|
||||
pendingPrefix: 'Remembering and Writing Note',
|
||||
donePrefix: 'Remembered and Wrote Note',
|
||||
match: {
|
||||
onlyOf: ['write_memory', 'write_note']
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
function normalizeToolName(name: string): string {
|
||||
return name.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function hasAll(set: Set<string>, items: string[]): boolean {
|
||||
return items.every((item) => set.has(normalizeToolName(item)));
|
||||
}
|
||||
|
||||
function hasAny(set: Set<string>, items: string[]): boolean {
|
||||
return items.some((item) => set.has(normalizeToolName(item)));
|
||||
}
|
||||
|
||||
function hasNone(set: Set<string>, items: string[]): boolean {
|
||||
return items.every((item) => !set.has(normalizeToolName(item)));
|
||||
}
|
||||
|
||||
function hasOnly(set: Set<string>, items: string[]): boolean {
|
||||
if (items.length === 0) {
|
||||
return set.size === 0;
|
||||
}
|
||||
|
||||
const allowed = new Set(items.map((item) => normalizeToolName(item)));
|
||||
for (const value of set) {
|
||||
if (!allowed.has(value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function matchesCombinationRule(rule: ToolCombinationRule, set: Set<string>): boolean {
|
||||
const { allOf, anyOf, noneOf, onlyOf } = rule.match;
|
||||
|
||||
if (allOf && allOf.length > 0 && !hasAll(set, allOf)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (anyOf && anyOf.length > 0 && !hasAny(set, anyOf)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (noneOf && noneOf.length > 0 && !hasNone(set, noneOf)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (onlyOf && !hasOnly(set, onlyOf)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getToolCombinationSummary(names: string[], pending: boolean): ToolCombination | null {
|
||||
const set = new Set(names.map((name) => normalizeToolName(name)));
|
||||
const orderedRules = [...TOOL_COMBINATION_RULES].sort((a, b) => a.order - b.order);
|
||||
|
||||
for (const rule of orderedRules) {
|
||||
if (!matchesCombinationRule(rule, set)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
iconKey: rule.iconKey,
|
||||
prefix: pending ? rule.pendingPrefix : rule.donePrefix,
|
||||
showDetailList: rule.showDetailList ?? false,
|
||||
matchedRuleId: rule.id
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue