feat(ui): improve grouped tool-call summaries and status rendering

This commit is contained in:
wannes depuydt 2026-04-08 16:25:25 +02:00
parent 9f1b279e88
commit f4f11d667d
6 changed files with 3071 additions and 186 deletions

132
docs/TOOL_CALL_GROUPING.md Normal file
View 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.

View file

@ -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}

View file

@ -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

View 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]';

View 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;
}