refactor(webview): 重构工具调用显示逻辑

- 新增多个工具调用组件,分别处理不同类型的工具调用
- 优化工具调用卡片的样式和布局
- 添加加载状态和随机加载消息
- 重构 App 组件,支持新的工具调用显示逻辑
This commit is contained in:
yiliang114
2025-11-19 15:42:35 +08:00
parent 04dfad7ab5
commit 454cbfdde4
15 changed files with 1564 additions and 218 deletions

View File

@@ -2,188 +2,37 @@
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Main ToolCall component - uses factory pattern to route to specialized components
*
* This file serves as the public API for tool call rendering.
* It re-exports the router and types from the toolcalls module.
*/
import type React from 'react';
import { ToolCallRouter } from './toolcalls/index.js';
export interface ToolCallContent {
type: 'content' | 'diff';
// For content type
content?: {
type: string;
text?: string;
[key: string]: unknown;
};
// For diff type
path?: string;
oldText?: string | null;
newText?: string;
}
// Re-export types from the toolcalls module for backward compatibility
export type {
ToolCallData,
BaseToolCallProps as ToolCallProps,
} from './toolcalls/shared/types.js';
export interface ToolCallData {
toolCallId: string;
kind: string;
title: string;
status: 'pending' | 'in_progress' | 'completed' | 'failed';
rawInput?: string | object;
content?: ToolCallContent[];
locations?: Array<{
path: string;
line?: number | null;
}>;
}
// Re-export the content type for external use
export type { ToolCallContent } from './toolcalls/shared/types.js';
export interface ToolCallProps {
toolCall: ToolCallData;
}
const StatusTag: React.FC<{ status: string }> = ({ status }) => {
const getStatusInfo = () => {
switch (status) {
case 'pending':
return { className: 'status-pending', text: 'Pending', icon: '⏳' };
case 'in_progress':
return {
className: 'status-in-progress',
text: 'In Progress',
icon: '🔄',
};
case 'completed':
return { className: 'status-completed', text: 'Completed', icon: '✓' };
case 'failed':
return { className: 'status-failed', text: 'Failed', icon: '✗' };
default:
return { className: 'status-unknown', text: status, icon: '•' };
}
};
const { className, text, icon } = getStatusInfo();
return (
<span className={`tool-call-status ${className}`}>
<span className="status-icon">{icon}</span>
{text}
</span>
);
};
const ContentView: React.FC<{ content: ToolCallContent }> = ({ content }) => {
// Handle diff type
if (content.type === 'diff') {
const fileName =
content.path?.split(/[/\\]/).pop() || content.path || 'Unknown file';
const oldText = content.oldText || '';
const newText = content.newText || '';
return (
<div className="tool-call-diff">
<div className="diff-header">
<span className="diff-icon">📝</span>
<span className="diff-filename">{fileName}</span>
</div>
<div className="diff-content">
<div className="diff-side">
<div className="diff-side-label">Before</div>
<pre className="diff-code">{oldText || '(empty)'}</pre>
</div>
<div className="diff-arrow"></div>
<div className="diff-side">
<div className="diff-side-label">After</div>
<pre className="diff-code">{newText || '(empty)'}</pre>
</div>
</div>
</div>
);
}
// Handle content type with text
if (content.type === 'content' && content.content?.text) {
return (
<div className="tool-call-content">
<div className="content-text">{content.content.text}</div>
</div>
);
}
return null;
};
const getKindDisplayName = (kind: string): { name: string; icon: string } => {
const kindMap: Record<string, { name: string; icon: string }> = {
edit: { name: 'File Edit', icon: '✏️' },
read: { name: 'File Read', icon: '📖' },
execute: { name: 'Shell Command', icon: '⚡' },
fetch: { name: 'Web Fetch', icon: '🌐' },
delete: { name: 'Delete', icon: '🗑️' },
move: { name: 'Move/Rename', icon: '📦' },
search: { name: 'Search', icon: '🔍' },
think: { name: 'Thinking', icon: '💭' },
other: { name: 'Other', icon: '🔧' },
};
return kindMap[kind] || { name: kind, icon: '🔧' };
};
const formatRawInput = (rawInput: string | object | undefined): string => {
if (rawInput === undefined) {
return '';
}
if (typeof rawInput === 'string') {
return rawInput;
}
return JSON.stringify(rawInput, null, 2);
};
export const ToolCall: React.FC<ToolCallProps> = ({ toolCall }) => {
const { kind, title, status, rawInput, content, locations, toolCallId } =
toolCall;
const kindInfo: { name: string; icon: string } = getKindDisplayName(kind);
return (
<div className="tool-call-card">
<div className="tool-call-header">
<span className="tool-call-kind-icon">{kindInfo.icon}</span>
<span className="tool-call-title">{title || kindInfo.name}</span>
<StatusTag status={status} />
</div>
{/* Show raw input if available */}
{rawInput !== undefined && rawInput !== null ? (
<div className="tool-call-raw-input">
<div className="raw-input-label">Input</div>
<pre className="raw-input-content">{formatRawInput(rawInput)}</pre>
</div>
) : null}
{/* Show locations if available */}
{locations && locations.length > 0 && (
<div className="tool-call-locations">
<div className="locations-label">Files</div>
{locations.map((location, index) => (
<div key={index} className="location-item">
<span className="location-icon">📄</span>
<span className="location-path">{location.path}</span>
{location.line !== null && location.line !== undefined && (
<span className="location-line">:{location.line}</span>
)}
</div>
))}
</div>
)}
{/* Show content if available */}
{content && content.length > 0 && (
<div className="tool-call-content-list">
{content.map((item, index) => (
<ContentView key={index} content={item} />
))}
</div>
)}
<div className="tool-call-footer">
<span className="tool-call-id">
ID: {toolCallId.substring(0, 8)}...
</span>
</div>
</div>
);
};
/**
* Main ToolCall component
* Routes to specialized components based on the tool call kind
*
* Supported kinds:
* - read: File reading operations
* - write/edit: File writing and editing operations
* - execute/bash/command: Command execution
* - search/grep/glob/find: Search operations
* - think/thinking: AI reasoning
* - All others: Generic display
*/
export const ToolCall: React.FC<{
toolCall: import('./toolcalls/shared/types.js').ToolCallData;
}> = ({ toolCall }) => <ToolCallRouter toolCall={toolCall} />;

View File

@@ -0,0 +1,70 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Execute tool call component - specialized for command execution operations
*/
import type React from 'react';
import type { BaseToolCallProps } from './shared/types.js';
import {
ToolCallCard,
ToolCallRow,
StatusIndicator,
CodeBlock,
} from './shared/LayoutComponents.js';
import { formatValue, safeTitle, groupContent } from './shared/utils.js';
/**
* Specialized component for Execute tool calls
* Optimized for displaying command execution with stdout/stderr
*/
export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
const { title, status, rawInput, content } = toolCall;
const titleText = safeTitle(title);
// Group content by type
const { textOutputs, errors, otherData } = groupContent(content);
return (
<ToolCallCard icon="⚡">
{/* Title row */}
<ToolCallRow label="Execute">
<StatusIndicator status={status} text={titleText} />
</ToolCallRow>
{/* Command */}
{rawInput && (
<ToolCallRow label="Command">
<CodeBlock>{formatValue(rawInput)}</CodeBlock>
</ToolCallRow>
)}
{/* Standard output */}
{textOutputs.length > 0 && (
<ToolCallRow label="Output">
<CodeBlock>{textOutputs.join('\n')}</CodeBlock>
</ToolCallRow>
)}
{/* Standard error / Errors */}
{errors.length > 0 && (
<ToolCallRow label="Error">
<div style={{ color: '#c74e39' }}>
<CodeBlock>{errors.join('\n')}</CodeBlock>
</div>
</ToolCallRow>
)}
{/* Exit code or other execution details */}
{otherData.length > 0 && (
<ToolCallRow label="Details">
<CodeBlock>
{otherData.map((data: unknown) => formatValue(data)).join('\n\n')}
</CodeBlock>
</ToolCallRow>
)}
</ToolCallCard>
);
};

View File

@@ -0,0 +1,96 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Generic tool call component - handles all tool call types as fallback
*/
import type React from 'react';
import type { BaseToolCallProps } from './shared/types.js';
import {
ToolCallCard,
ToolCallRow,
StatusIndicator,
CodeBlock,
LocationsList,
DiffDisplay,
} from './shared/LayoutComponents.js';
import {
formatValue,
safeTitle,
getKindIcon,
groupContent,
} from './shared/utils.js';
/**
* Generic tool call component that can display any tool call type
* Used as fallback for unknown tool call kinds
*/
export const GenericToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
const { kind, title, status, rawInput, content, locations } = toolCall;
const kindIcon = getKindIcon(kind);
const titleText = safeTitle(title);
// Group content by type
const { textOutputs, errors, diffs, otherData } = groupContent(content);
return (
<ToolCallCard icon={kindIcon}>
{/* Title row */}
<ToolCallRow label="Tool">
<StatusIndicator status={status} text={titleText} />
</ToolCallRow>
{/* Input row */}
{rawInput && (
<ToolCallRow label="Input">
<CodeBlock>{formatValue(rawInput)}</CodeBlock>
</ToolCallRow>
)}
{/* Locations row */}
{locations && locations.length > 0 && (
<ToolCallRow label="Files">
<LocationsList locations={locations} />
</ToolCallRow>
)}
{/* Output row - combined text outputs */}
{textOutputs.length > 0 && (
<ToolCallRow label="Output">
<CodeBlock>{textOutputs.join('\n')}</CodeBlock>
</ToolCallRow>
)}
{/* Error row - combined errors */}
{errors.length > 0 && (
<ToolCallRow label="Error">
<div style={{ color: '#c74e39' }}>{errors.join('\n')}</div>
</ToolCallRow>
)}
{/* Diff rows */}
{diffs.map(
(item: import('./shared/types.js').ToolCallContent, idx: number) => (
<ToolCallRow key={`diff-${idx}`} label="Diff">
<DiffDisplay
path={item.path}
oldText={item.oldText}
newText={item.newText}
/>
</ToolCallRow>
),
)}
{/* Other data rows */}
{otherData.length > 0 && (
<ToolCallRow label="Data">
<CodeBlock>
{otherData.map((data: unknown) => formatValue(data)).join('\n\n')}
</CodeBlock>
</ToolCallRow>
)}
</ToolCallCard>
);
};

View File

@@ -0,0 +1,76 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Read tool call component - specialized for file reading operations
*/
import type React from 'react';
import type { BaseToolCallProps } from './shared/types.js';
import {
ToolCallCard,
ToolCallRow,
StatusIndicator,
CodeBlock,
LocationsList,
} from './shared/LayoutComponents.js';
import { formatValue, safeTitle, groupContent } from './shared/utils.js';
/**
* Specialized component for Read tool calls
* Optimized for displaying file reading operations
*/
export const ReadToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
const { title, status, rawInput, content, locations } = toolCall;
const titleText = safeTitle(title);
// Group content by type
const { textOutputs, errors, otherData } = groupContent(content);
return (
<ToolCallCard icon="📖">
{/* Title row */}
<ToolCallRow label="Read">
<StatusIndicator status={status} text={titleText} />
</ToolCallRow>
{/* File path(s) */}
{locations && locations.length > 0 && (
<ToolCallRow label="File">
<LocationsList locations={locations} />
</ToolCallRow>
)}
{/* Input parameters (e.g., line range, offset) */}
{rawInput && (
<ToolCallRow label="Options">
<CodeBlock>{formatValue(rawInput)}</CodeBlock>
</ToolCallRow>
)}
{/* File content output */}
{textOutputs.length > 0 && (
<ToolCallRow label="Content">
<CodeBlock>{textOutputs.join('\n')}</CodeBlock>
</ToolCallRow>
)}
{/* Error handling */}
{errors.length > 0 && (
<ToolCallRow label="Error">
<div style={{ color: '#c74e39' }}>{errors.join('\n')}</div>
</ToolCallRow>
)}
{/* Other data */}
{otherData.length > 0 && (
<ToolCallRow label="Details">
<CodeBlock>
{otherData.map((data: unknown) => formatValue(data)).join('\n\n')}
</CodeBlock>
</ToolCallRow>
)}
</ToolCallCard>
);
};

View File

@@ -0,0 +1,76 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Search tool call component - specialized for search operations
*/
import type React from 'react';
import type { BaseToolCallProps } from './shared/types.js';
import {
ToolCallCard,
ToolCallRow,
StatusIndicator,
CodeBlock,
LocationsList,
} from './shared/LayoutComponents.js';
import { formatValue, safeTitle, groupContent } from './shared/utils.js';
/**
* Specialized component for Search tool calls
* Optimized for displaying search operations and results
*/
export const SearchToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
const { title, status, rawInput, content, locations } = toolCall;
const titleText = safeTitle(title);
// Group content by type
const { textOutputs, errors, otherData } = groupContent(content);
return (
<ToolCallCard icon="🔍">
{/* Title row */}
<ToolCallRow label="Search">
<StatusIndicator status={status} text={titleText} />
</ToolCallRow>
{/* Search query/pattern */}
{rawInput && (
<ToolCallRow label="Query">
<CodeBlock>{formatValue(rawInput)}</CodeBlock>
</ToolCallRow>
)}
{/* Search results - files found */}
{locations && locations.length > 0 && (
<ToolCallRow label="Results">
<LocationsList locations={locations} />
</ToolCallRow>
)}
{/* Search output details */}
{textOutputs.length > 0 && (
<ToolCallRow label="Matches">
<CodeBlock>{textOutputs.join('\n')}</CodeBlock>
</ToolCallRow>
)}
{/* Error handling */}
{errors.length > 0 && (
<ToolCallRow label="Error">
<div style={{ color: '#c74e39' }}>{errors.join('\n')}</div>
</ToolCallRow>
)}
{/* Other search metadata */}
{otherData.length > 0 && (
<ToolCallRow label="Details">
<CodeBlock>
{otherData.map((data: unknown) => formatValue(data)).join('\n\n')}
</CodeBlock>
</ToolCallRow>
)}
</ToolCallCard>
);
};

View File

@@ -0,0 +1,70 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Think tool call component - specialized for thinking/reasoning operations
*/
import type React from 'react';
import type { BaseToolCallProps } from './shared/types.js';
import {
ToolCallCard,
ToolCallRow,
StatusIndicator,
CodeBlock,
} from './shared/LayoutComponents.js';
import { formatValue, safeTitle, groupContent } from './shared/utils.js';
/**
* Specialized component for Think tool calls
* Optimized for displaying AI reasoning and thought processes
*/
export const ThinkToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
const { title, status, rawInput, content } = toolCall;
const titleText = safeTitle(title);
// Group content by type
const { textOutputs, errors, otherData } = groupContent(content);
return (
<ToolCallCard icon="💭">
{/* Title row */}
<ToolCallRow label="Thinking">
<StatusIndicator status={status} text={titleText} />
</ToolCallRow>
{/* Thinking context/prompt */}
{rawInput && (
<ToolCallRow label="Context">
<CodeBlock>{formatValue(rawInput)}</CodeBlock>
</ToolCallRow>
)}
{/* Thought content */}
{textOutputs.length > 0 && (
<ToolCallRow label="Thoughts">
<div style={{ fontStyle: 'italic', opacity: 0.95 }}>
{textOutputs.join('\n\n')}
</div>
</ToolCallRow>
)}
{/* Error handling */}
{errors.length > 0 && (
<ToolCallRow label="Error">
<div style={{ color: '#c74e39' }}>{errors.join('\n')}</div>
</ToolCallRow>
)}
{/* Other reasoning data */}
{otherData.length > 0 && (
<ToolCallRow label="Details">
<CodeBlock>
{otherData.map((data: unknown) => formatValue(data)).join('\n\n')}
</CodeBlock>
</ToolCallRow>
)}
</ToolCallCard>
);
};

View File

@@ -0,0 +1,91 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Write/Edit tool call component - specialized for file writing and editing operations
*/
import type React from 'react';
import type { BaseToolCallProps } from './shared/types.js';
import {
ToolCallCard,
ToolCallRow,
StatusIndicator,
CodeBlock,
LocationsList,
DiffDisplay,
} from './shared/LayoutComponents.js';
import { formatValue, safeTitle, groupContent } from './shared/utils.js';
/**
* Specialized component for Write/Edit tool calls
* Optimized for displaying file writing and editing operations with diffs
*/
export const WriteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
const { kind, title, status, rawInput, content, locations } = toolCall;
const titleText = safeTitle(title);
const isEdit = kind.toLowerCase() === 'edit';
// Group content by type
const { textOutputs, errors, diffs, otherData } = groupContent(content);
return (
<ToolCallCard icon="✏️">
{/* Title row */}
<ToolCallRow label={isEdit ? 'Edit' : 'Write'}>
<StatusIndicator status={status} text={titleText} />
</ToolCallRow>
{/* File path(s) */}
{locations && locations.length > 0 && (
<ToolCallRow label="File">
<LocationsList locations={locations} />
</ToolCallRow>
)}
{/* Input parameters (e.g., old_string, new_string for edits) */}
{rawInput && (
<ToolCallRow label="Changes">
<CodeBlock>{formatValue(rawInput)}</CodeBlock>
</ToolCallRow>
)}
{/* Diff display - most important for write/edit operations */}
{diffs.map(
(item: import('./shared/types.js').ToolCallContent, idx: number) => (
<ToolCallRow key={`diff-${idx}`} label="Diff">
<DiffDisplay
path={item.path}
oldText={item.oldText}
newText={item.newText}
/>
</ToolCallRow>
),
)}
{/* Success message or output */}
{textOutputs.length > 0 && (
<ToolCallRow label="Result">
<CodeBlock>{textOutputs.join('\n')}</CodeBlock>
</ToolCallRow>
)}
{/* Error handling */}
{errors.length > 0 && (
<ToolCallRow label="Error">
<div style={{ color: '#c74e39' }}>{errors.join('\n')}</div>
</ToolCallRow>
)}
{/* Other data */}
{otherData.length > 0 && (
<ToolCallRow label="Details">
<CodeBlock>
{otherData.map((data: unknown) => formatValue(data)).join('\n\n')}
</CodeBlock>
</ToolCallRow>
)}
</ToolCallCard>
);
};

View File

@@ -0,0 +1,80 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Tool call component factory - routes to specialized components by kind
*/
import type React from 'react';
import type { BaseToolCallProps } from './shared/types.js';
import { shouldShowToolCall } from './shared/utils.js';
import { GenericToolCall } from './GenericToolCall.js';
import { ReadToolCall } from './ReadToolCall.js';
import { WriteToolCall } from './WriteToolCall.js';
import { ExecuteToolCall } from './ExecuteToolCall.js';
import { SearchToolCall } from './SearchToolCall.js';
import { ThinkToolCall } from './ThinkToolCall.js';
/**
* Factory function that returns the appropriate tool call component based on kind
*/
export const getToolCallComponent = (
kind: string,
): React.FC<BaseToolCallProps> => {
const normalizedKind = kind.toLowerCase();
// Route to specialized components
switch (normalizedKind) {
case 'read':
return ReadToolCall;
case 'write':
case 'edit':
return WriteToolCall;
case 'execute':
case 'bash':
case 'command':
return ExecuteToolCall;
case 'search':
case 'grep':
case 'glob':
case 'find':
return SearchToolCall;
case 'think':
case 'thinking':
return ThinkToolCall;
// Add more specialized components as needed
// case 'fetch':
// return FetchToolCall;
// case 'delete':
// return DeleteToolCall;
default:
// Fallback to generic component
return GenericToolCall;
}
};
/**
* Main tool call component that routes to specialized implementations
*/
export const ToolCallRouter: React.FC<BaseToolCallProps> = ({ toolCall }) => {
// Check if we should show this tool call (hide internal ones)
if (!shouldShowToolCall(toolCall.kind)) {
return null;
}
// Get the appropriate component for this kind
const Component = getToolCallComponent(toolCall.kind);
// Render the specialized component
return <Component toolCall={toolCall} />;
};
// Re-export types for convenience
export type { BaseToolCallProps, ToolCallData } from './shared/types.js';

View File

@@ -0,0 +1,161 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Shared layout components for tool call UI
*/
import type React from 'react';
/**
* Props for ToolCallCard wrapper
*/
interface ToolCallCardProps {
icon: string;
children: React.ReactNode;
}
/**
* Main card wrapper with icon
*/
export const ToolCallCard: React.FC<ToolCallCardProps> = ({
icon,
children,
}) => (
<div className="tool-call-card">
<div className="tool-call-icon">{icon}</div>
<div className="tool-call-grid">{children}</div>
</div>
);
/**
* Props for ToolCallRow
*/
interface ToolCallRowProps {
label: string;
children: React.ReactNode;
}
/**
* A single row in the tool call grid
*/
export const ToolCallRow: React.FC<ToolCallRowProps> = ({
label,
children,
}) => (
<div className="tool-call-row">
<div className="tool-call-label">{label}</div>
<div className="tool-call-value">{children}</div>
</div>
);
/**
* Props for StatusIndicator
*/
interface StatusIndicatorProps {
status: 'pending' | 'in_progress' | 'completed' | 'failed';
text: string;
}
/**
* Status indicator with colored dot
*/
export const StatusIndicator: React.FC<StatusIndicatorProps> = ({
status,
text,
}) => (
<div className={`tool-call-status-indicator ${status}`} title={status}>
{text}
</div>
);
/**
* Props for CodeBlock
*/
interface CodeBlockProps {
children: string;
}
/**
* Code block for displaying formatted code or output
*/
export const CodeBlock: React.FC<CodeBlockProps> = ({ children }) => (
<pre className="code-block">{children}</pre>
);
/**
* Props for LocationsList
*/
interface LocationsListProps {
locations: Array<{
path: string;
line?: number | null;
}>;
}
/**
* List of file locations
*/
export const LocationsList: React.FC<LocationsListProps> = ({ locations }) => (
<>
{locations.map((loc, idx) => (
<div key={idx}>
{loc.path}
{loc.line !== null && loc.line !== undefined && `:${loc.line}`}
</div>
))}
</>
);
/**
* Props for DiffDisplay
*/
interface DiffDisplayProps {
path?: string;
oldText?: string | null;
newText?: string;
}
/**
* Display diff with before/after sections
*/
export const DiffDisplay: React.FC<DiffDisplayProps> = ({
path,
oldText,
newText,
}) => (
<div>
<div>
<strong>{path || 'Unknown file'}</strong>
</div>
{oldText !== undefined && (
<div>
<div
style={{
opacity: 0.5,
fontSize: '0.85em',
marginTop: '4px',
}}
>
Before:
</div>
<pre className="code-block">{oldText || '(empty)'}</pre>
</div>
)}
{newText !== undefined && (
<div>
<div
style={{
opacity: 0.5,
fontSize: '0.85em',
marginTop: '4px',
}}
>
After:
</div>
<pre className="code-block">{newText}</pre>
</div>
)}
</div>
);

View File

@@ -0,0 +1,68 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Shared types for tool call components
*/
/**
* Tool call content types
*/
export interface ToolCallContent {
type: 'content' | 'diff';
// For content type
content?: {
type: string;
text?: string;
error?: unknown;
[key: string]: unknown;
};
// For diff type
path?: string;
oldText?: string | null;
newText?: string;
}
/**
* Tool call location type
*/
export interface ToolCallLocation {
path: string;
line?: number | null;
}
/**
* Tool call status type
*/
export type ToolCallStatus = 'pending' | 'in_progress' | 'completed' | 'failed';
/**
* Base tool call data interface
*/
export interface ToolCallData {
toolCallId: string;
kind: string;
title: string | object;
status: ToolCallStatus;
rawInput?: string | object;
content?: ToolCallContent[];
locations?: ToolCallLocation[];
}
/**
* Base props for all tool call components
*/
export interface BaseToolCallProps {
toolCall: ToolCallData;
}
/**
* Grouped content structure for rendering
*/
export interface GroupedContent {
textOutputs: string[];
errors: string[];
diffs: ToolCallContent[];
otherData: unknown[];
}

View File

@@ -0,0 +1,105 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Shared utility functions for tool call components
*/
import type { ToolCallContent, GroupedContent } from './types.js';
/**
* Format any value to a string for display
*/
export const formatValue = (value: unknown): string => {
if (value === null || value === undefined) {
return '';
}
if (typeof value === 'string') {
return value;
}
if (typeof value === 'object') {
try {
return JSON.stringify(value, null, 2);
} catch (_e) {
return String(value);
}
}
return String(value);
};
/**
* Safely convert title to string, handling object types
*/
export const safeTitle = (title: unknown): string => {
if (typeof title === 'string') {
return title;
}
if (title && typeof title === 'object') {
return JSON.stringify(title);
}
return 'Tool Call';
};
/**
* Get icon emoji for a given tool kind
*/
export const getKindIcon = (kind: string): string => {
const kindMap: Record<string, string> = {
edit: '✏️',
write: '✏️',
read: '📖',
execute: '⚡',
fetch: '🌐',
delete: '🗑️',
move: '📦',
search: '🔍',
think: '💭',
diff: '📝',
};
return kindMap[kind.toLowerCase()] || '🔧';
};
/**
* Check if a tool call should be displayed
* Hides internal tool calls
*/
export const shouldShowToolCall = (kind: string): boolean =>
!kind.includes('internal');
/**
* Group tool call content by type to avoid duplicate labels
*/
export const groupContent = (content?: ToolCallContent[]): GroupedContent => {
const textOutputs: string[] = [];
const errors: string[] = [];
const diffs: ToolCallContent[] = [];
const otherData: unknown[] = [];
content?.forEach((item) => {
if (item.type === 'diff') {
diffs.push(item);
} else if (item.content) {
const contentObj = item.content;
// Handle error content
if (contentObj.type === 'error' || 'error' in contentObj) {
const errorMsg =
formatValue(contentObj.error) ||
formatValue(contentObj.text) ||
'An error occurred';
errors.push(errorMsg);
}
// Handle text content
else if (contentObj.text) {
textOutputs.push(formatValue(contentObj.text));
}
// Handle other content
else {
otherData.push(contentObj);
}
}
});
return { textOutputs, errors, diffs, otherData };
};