feat(ui): add ToolRenderer system for Omega chat
Add abstract tool rendering infrastructure to @tpmjs/ui: - ToolRenderer component with registry-based renderer lookup - DefaultJsonRenderer as fallback with collapsible JSON display - RegistrySearchRenderer for registrySearchTool results - RegistryExecuteRenderer for registryExecuteTool results - registerBuiltInRenderers() for idempotent initialization Update Omega chat page to use new ToolRenderer: - Replace inline ToolCallCard with ToolRenderer component - Add helper functions to convert between ToolCall and ToolPart - Remove unused expandedToolCalls state (managed internally) Also add video file extensions to .gitignore
This commit is contained in:
parent
ca6f908ebd
commit
0489646e55
12 changed files with 967 additions and 136 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -16,6 +16,12 @@ dist
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# video files
|
||||||
|
*.mp4
|
||||||
|
*.webm
|
||||||
|
*.mov
|
||||||
|
*.avi
|
||||||
|
|
||||||
# debug
|
# debug
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@ import { Badge } from '@tpmjs/ui/Badge/Badge';
|
||||||
import { Button } from '@tpmjs/ui/Button/Button';
|
import { Button } from '@tpmjs/ui/Button/Button';
|
||||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||||
import { Textarea } from '@tpmjs/ui/Textarea/Textarea';
|
import { Textarea } from '@tpmjs/ui/Textarea/Textarea';
|
||||||
|
import { registerBuiltInRenderers } from '@tpmjs/ui/ToolRenderer/registerBuiltInRenderers';
|
||||||
|
import { ToolRenderer } from '@tpmjs/ui/ToolRenderer/ToolRenderer';
|
||||||
|
import type { ToolPart, ToolState } from '@tpmjs/ui/ToolRenderer/types';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
@ -11,6 +14,9 @@ import { Streamdown } from 'streamdown';
|
||||||
import { AppHeader } from '~/components/AppHeader';
|
import { AppHeader } from '~/components/AppHeader';
|
||||||
import { EnvVarWarningBanner } from '~/components/omega/EnvVarWarningBanner';
|
import { EnvVarWarningBanner } from '~/components/omega/EnvVarWarningBanner';
|
||||||
|
|
||||||
|
// Initialize built-in tool renderers (idempotent)
|
||||||
|
registerBuiltInRenderers();
|
||||||
|
|
||||||
interface ToolDiscoveryInfo {
|
interface ToolDiscoveryInfo {
|
||||||
staticTools?: string[];
|
staticTools?: string[];
|
||||||
dynamicToolsLoaded?: string[];
|
dynamicToolsLoaded?: string[];
|
||||||
|
|
@ -29,7 +35,8 @@ interface Message {
|
||||||
toolCalls?: Array<{
|
toolCalls?: Array<{
|
||||||
toolCallId: string;
|
toolCallId: string;
|
||||||
toolName: string;
|
toolName: string;
|
||||||
args: unknown;
|
args?: unknown;
|
||||||
|
output?: unknown;
|
||||||
}>;
|
}>;
|
||||||
inputTokens?: number;
|
inputTokens?: number;
|
||||||
outputTokens?: number;
|
outputTokens?: number;
|
||||||
|
|
@ -69,115 +76,32 @@ interface Conversation {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tool call debug card component
|
* Convert ToolCall status to ToolState
|
||||||
*/
|
*/
|
||||||
function ToolCallCard({
|
function statusToToolState(status: ToolCall['status']): ToolState {
|
||||||
toolCall,
|
switch (status) {
|
||||||
isExpanded,
|
case 'pending':
|
||||||
onToggle,
|
return 'partial-call';
|
||||||
}: {
|
case 'running':
|
||||||
toolCall: ToolCall;
|
return 'call';
|
||||||
isExpanded: boolean;
|
case 'success':
|
||||||
onToggle: () => void;
|
case 'error':
|
||||||
}) {
|
return 'result';
|
||||||
const statusColors = {
|
}
|
||||||
pending: 'bg-warning/10 text-warning border-warning/30',
|
}
|
||||||
running: 'bg-info/10 text-info border-info/30',
|
|
||||||
success: 'bg-success/10 text-success border-success/30',
|
/**
|
||||||
error: 'bg-error/10 text-error border-error/30',
|
* Convert ToolCall to ToolPart for rendering
|
||||||
|
*/
|
||||||
|
function toolCallToToolPart(tc: ToolCall): ToolPart {
|
||||||
|
return {
|
||||||
|
type: tc.status === 'success' || tc.status === 'error' ? 'tool-result' : 'tool-call',
|
||||||
|
toolCallId: tc.toolCallId,
|
||||||
|
toolName: tc.toolName,
|
||||||
|
args: tc.input,
|
||||||
|
result: tc.isError ? { error: tc.output } : tc.output,
|
||||||
|
state: statusToToolState(tc.status),
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusIcons: Record<ToolCall['status'], 'loader' | 'check' | 'alertCircle' | 'info'> = {
|
|
||||||
pending: 'info',
|
|
||||||
running: 'loader',
|
|
||||||
success: 'check',
|
|
||||||
error: 'alertCircle',
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatJson = (data: unknown): React.ReactNode => {
|
|
||||||
try {
|
|
||||||
return JSON.stringify(data, null, 2);
|
|
||||||
} catch {
|
|
||||||
return String(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-border bg-surface-secondary/50 overflow-hidden font-mono text-xs">
|
|
||||||
{/* Header */}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={onToggle}
|
|
||||||
className="w-full flex items-center gap-3 p-3 h-auto justify-start rounded-none hover:bg-surface-secondary/80"
|
|
||||||
>
|
|
||||||
<div className={`p-1.5 rounded ${statusColors[toolCall.status]}`}>
|
|
||||||
<Icon
|
|
||||||
icon={statusIcons[toolCall.status]}
|
|
||||||
size="xs"
|
|
||||||
className={toolCall.status === 'running' ? 'animate-spin' : ''}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 text-left">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-foreground font-semibold">{toolCall.toolName}</span>
|
|
||||||
<span className="text-foreground-tertiary text-[10px]">
|
|
||||||
{toolCall.toolCallId.slice(0, 8)}...
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Icon
|
|
||||||
icon="chevronRight"
|
|
||||||
size="xs"
|
|
||||||
className={`text-foreground-tertiary transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* Expanded Content */}
|
|
||||||
{isExpanded && (
|
|
||||||
<div className="border-t border-border">
|
|
||||||
{/* Input Section */}
|
|
||||||
{toolCall.input !== undefined && toolCall.input !== null ? (
|
|
||||||
<div className="p-3 border-b border-border/50">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span className="text-[10px] uppercase tracking-wider text-foreground-tertiary">
|
|
||||||
Input
|
|
||||||
</span>
|
|
||||||
<div className="flex-1 h-px bg-border/50" />
|
|
||||||
</div>
|
|
||||||
<pre className="text-[11px] text-foreground-secondary overflow-x-auto whitespace-pre-wrap break-all max-h-48 overflow-y-auto">
|
|
||||||
{formatJson(toolCall.input)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* Output Section */}
|
|
||||||
{toolCall.output !== undefined && toolCall.output !== null ? (
|
|
||||||
<div className="p-3">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span className="text-[10px] uppercase tracking-wider text-foreground-tertiary">
|
|
||||||
Output
|
|
||||||
</span>
|
|
||||||
<div className="flex-1 h-px bg-border/50" />
|
|
||||||
</div>
|
|
||||||
<pre
|
|
||||||
className={`text-[11px] overflow-x-auto whitespace-pre-wrap break-all max-h-48 overflow-y-auto ${toolCall.isError ? 'text-error' : 'text-success'}`}
|
|
||||||
>
|
|
||||||
{formatJson(toolCall.output)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* Status indicator for running */}
|
|
||||||
{toolCall.status === 'running' && !toolCall.output && (
|
|
||||||
<div className="p-3 flex items-center gap-2 text-foreground-tertiary">
|
|
||||||
<Icon icon="loader" size="xs" className="animate-spin" />
|
|
||||||
<span>Executing...</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -197,7 +121,6 @@ export default function OmegaChatPage(): React.ReactElement {
|
||||||
const [streamingContent, setStreamingContent] = useState('');
|
const [streamingContent, setStreamingContent] = useState('');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [toolCalls, setToolCalls] = useState<ToolCall[]>([]);
|
const [toolCalls, setToolCalls] = useState<ToolCall[]>([]);
|
||||||
const [expandedToolCalls, setExpandedToolCalls] = useState<Set<string>>(new Set());
|
|
||||||
const [viewMode, setViewMode] = useState<'chat' | 'debug'>('chat');
|
const [viewMode, setViewMode] = useState<'chat' | 'debug'>('chat');
|
||||||
// Map of message IDs to their tool discovery info (persisted in frontend state)
|
// Map of message IDs to their tool discovery info (persisted in frontend state)
|
||||||
const [messageToolDiscovery, setMessageToolDiscovery] = useState<Map<string, ToolDiscoveryInfo>>(
|
const [messageToolDiscovery, setMessageToolDiscovery] = useState<Map<string, ToolDiscoveryInfo>>(
|
||||||
|
|
@ -209,18 +132,6 @@ export default function OmegaChatPage(): React.ReactElement {
|
||||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
const toggleToolCall = (toolCallId: string) => {
|
|
||||||
setExpandedToolCalls((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(toolCallId)) {
|
|
||||||
next.delete(toolCallId);
|
|
||||||
} else {
|
|
||||||
next.add(toolCallId);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fetch conversation details
|
// Fetch conversation details
|
||||||
const fetchConversation = useCallback(async () => {
|
const fetchConversation = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -353,8 +264,6 @@ export default function OmegaChatPage(): React.ReactElement {
|
||||||
status: 'running',
|
status: 'running',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
// Auto-expand new tool calls
|
|
||||||
setExpandedToolCalls((prev) => new Set([...prev, data.toolCallId]));
|
|
||||||
break;
|
break;
|
||||||
case 'run.step.tool.completed':
|
case 'run.step.tool.completed':
|
||||||
// Update tool call with result
|
// Update tool call with result
|
||||||
|
|
@ -580,6 +489,7 @@ export default function OmegaChatPage(): React.ReactElement {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Per-Message View */}
|
{/* Per-Message View */}
|
||||||
|
{/* biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Debug view with multiple conditional sections */}
|
||||||
{messages.map((message, index) => {
|
{messages.map((message, index) => {
|
||||||
const discovery = messageToolDiscovery.get(message.id);
|
const discovery = messageToolDiscovery.get(message.id);
|
||||||
return (
|
return (
|
||||||
|
|
@ -789,16 +699,24 @@ export default function OmegaChatPage(): React.ReactElement {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* TOOL message - show as collapsed tool result */}
|
{/* TOOL message - show tool results from toolCalls field */}
|
||||||
{message.role === 'TOOL' && (
|
{message.role === 'TOOL' && message.toolCalls && (
|
||||||
<div className="flex justify-start">
|
<div className="flex justify-start">
|
||||||
<div className="max-w-[80%]">
|
<div className="max-w-[80%] space-y-2">
|
||||||
<div className="text-xs text-foreground-tertiary mb-1">
|
{message.toolCalls.map((tc) => (
|
||||||
Tool Results
|
<ToolRenderer
|
||||||
</div>
|
key={tc.toolCallId}
|
||||||
<pre className="text-xs font-mono bg-surface-secondary border border-border rounded p-2 overflow-x-auto max-h-32 overflow-y-auto">
|
part={{
|
||||||
{message.content}
|
type: 'tool-result',
|
||||||
</pre>
|
toolCallId: tc.toolCallId,
|
||||||
|
toolName: tc.toolName,
|
||||||
|
args: tc.args,
|
||||||
|
result: tc.output,
|
||||||
|
state: 'result',
|
||||||
|
}}
|
||||||
|
isStreaming={false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -811,10 +729,9 @@ export default function OmegaChatPage(): React.ReactElement {
|
||||||
{toolCalls.map((tc) => (
|
{toolCalls.map((tc) => (
|
||||||
<div key={tc.toolCallId} className="flex justify-start">
|
<div key={tc.toolCallId} className="flex justify-start">
|
||||||
<div className="max-w-[80%]">
|
<div className="max-w-[80%]">
|
||||||
<ToolCallCard
|
<ToolRenderer
|
||||||
toolCall={tc}
|
part={toolCallToToolPart(tc)}
|
||||||
isExpanded={expandedToolCalls.has(tc.toolCallId)}
|
isStreaming={tc.status === 'running'}
|
||||||
onToggle={() => toggleToolCall(tc.toolCallId)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -234,6 +234,34 @@
|
||||||
"./PageHeader/PageHeader": {
|
"./PageHeader/PageHeader": {
|
||||||
"types": "./dist/PageHeader/PageHeader.d.ts",
|
"types": "./dist/PageHeader/PageHeader.d.ts",
|
||||||
"default": "./dist/PageHeader/PageHeader.js"
|
"default": "./dist/PageHeader/PageHeader.js"
|
||||||
|
},
|
||||||
|
"./ToolRenderer/ToolRenderer": {
|
||||||
|
"types": "./dist/ToolRenderer/ToolRenderer.d.ts",
|
||||||
|
"default": "./dist/ToolRenderer/ToolRenderer.js"
|
||||||
|
},
|
||||||
|
"./ToolRenderer/types": {
|
||||||
|
"types": "./dist/ToolRenderer/types.d.ts",
|
||||||
|
"default": "./dist/ToolRenderer/types.js"
|
||||||
|
},
|
||||||
|
"./ToolRenderer/registry": {
|
||||||
|
"types": "./dist/ToolRenderer/registry.d.ts",
|
||||||
|
"default": "./dist/ToolRenderer/registry.js"
|
||||||
|
},
|
||||||
|
"./ToolRenderer/DefaultJsonRenderer": {
|
||||||
|
"types": "./dist/ToolRenderer/DefaultJsonRenderer.d.ts",
|
||||||
|
"default": "./dist/ToolRenderer/DefaultJsonRenderer.js"
|
||||||
|
},
|
||||||
|
"./ToolRenderer/registerBuiltInRenderers": {
|
||||||
|
"types": "./dist/ToolRenderer/registerBuiltInRenderers.d.ts",
|
||||||
|
"default": "./dist/ToolRenderer/registerBuiltInRenderers.js"
|
||||||
|
},
|
||||||
|
"./ToolRenderer/renderers/RegistrySearchRenderer": {
|
||||||
|
"types": "./dist/ToolRenderer/renderers/RegistrySearchRenderer.d.ts",
|
||||||
|
"default": "./dist/ToolRenderer/renderers/RegistrySearchRenderer.js"
|
||||||
|
},
|
||||||
|
"./ToolRenderer/renderers/RegistryExecuteRenderer": {
|
||||||
|
"types": "./dist/ToolRenderer/renderers/RegistryExecuteRenderer.d.ts",
|
||||||
|
"default": "./dist/ToolRenderer/renderers/RegistryExecuteRenderer.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
|
|
|
||||||
165
packages/ui/src/ToolRenderer/DefaultJsonRenderer.tsx
Normal file
165
packages/ui/src/ToolRenderer/DefaultJsonRenderer.tsx
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
/**
|
||||||
|
* Default JSON Renderer
|
||||||
|
*
|
||||||
|
* Fallback renderer that displays tool input/output as formatted JSON.
|
||||||
|
* Used when no specific renderer is registered for a tool.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { cn } from '@tpmjs/utils/cn';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button } from '../Button/Button';
|
||||||
|
import { Icon } from '../Icon/Icon';
|
||||||
|
import type { ToolRendererProps } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format data as JSON string for display
|
||||||
|
*/
|
||||||
|
function formatJson(data: unknown): string {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(data, null, 2);
|
||||||
|
} catch {
|
||||||
|
return String(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DefaultJsonRenderer - Fallback renderer for any tool
|
||||||
|
*
|
||||||
|
* Displays:
|
||||||
|
* - Collapsible header with tool name and status
|
||||||
|
* - Input parameters as formatted JSON
|
||||||
|
* - Output results as formatted JSON
|
||||||
|
* - Loading state while executing
|
||||||
|
* - Error state styling
|
||||||
|
*/
|
||||||
|
export function DefaultJsonRenderer({
|
||||||
|
toolName,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
state,
|
||||||
|
isStreaming,
|
||||||
|
error,
|
||||||
|
}: ToolRendererProps): React.ReactElement {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(true);
|
||||||
|
|
||||||
|
// Determine status based on state
|
||||||
|
const status = error
|
||||||
|
? 'error'
|
||||||
|
: state === 'result'
|
||||||
|
? 'success'
|
||||||
|
: isStreaming
|
||||||
|
? 'running'
|
||||||
|
: 'pending';
|
||||||
|
|
||||||
|
const statusConfig = {
|
||||||
|
pending: {
|
||||||
|
icon: 'info' as const,
|
||||||
|
colorClass: 'bg-warning/10 text-warning border-warning/30',
|
||||||
|
label: 'Pending',
|
||||||
|
},
|
||||||
|
running: {
|
||||||
|
icon: 'loader' as const,
|
||||||
|
colorClass: 'bg-info/10 text-info border-info/30',
|
||||||
|
label: 'Running',
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
icon: 'check' as const,
|
||||||
|
colorClass: 'bg-success/10 text-success border-success/30',
|
||||||
|
label: 'Success',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
icon: 'alertCircle' as const,
|
||||||
|
colorClass: 'bg-error/10 text-error border-error/30',
|
||||||
|
label: 'Error',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const { icon, colorClass, label } = statusConfig[status];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border bg-surface-secondary/50 overflow-hidden font-mono text-xs">
|
||||||
|
{/* Header */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
className="w-full flex items-center gap-3 p-3 h-auto justify-start rounded-none hover:bg-surface-secondary/80"
|
||||||
|
>
|
||||||
|
<div className={cn('p-1.5 rounded border', colorClass)}>
|
||||||
|
<Icon icon={icon} size="xs" className={status === 'running' ? 'animate-spin' : ''} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 text-left">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-foreground font-semibold">{toolName}</span>
|
||||||
|
<span className="text-foreground-tertiary text-[10px]">{label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Icon
|
||||||
|
icon="chevronRight"
|
||||||
|
size="xs"
|
||||||
|
className={cn('text-foreground-tertiary transition-transform', isExpanded && 'rotate-90')}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Expanded Content */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="border-t border-border">
|
||||||
|
{/* Input Section */}
|
||||||
|
{input !== undefined && input !== null && (
|
||||||
|
<div className="p-3 border-b border-border/50">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-[10px] uppercase tracking-wider text-foreground-tertiary">
|
||||||
|
Input
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 h-px bg-border/50" />
|
||||||
|
</div>
|
||||||
|
<pre className="text-[11px] text-foreground-secondary overflow-x-auto whitespace-pre-wrap break-all max-h-48 overflow-y-auto">
|
||||||
|
{formatJson(input)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Output Section */}
|
||||||
|
{output !== undefined && output !== null && (
|
||||||
|
<div className="p-3">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-[10px] uppercase tracking-wider text-foreground-tertiary">
|
||||||
|
Output
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 h-px bg-border/50" />
|
||||||
|
</div>
|
||||||
|
<pre
|
||||||
|
className={cn(
|
||||||
|
'text-[11px] overflow-x-auto whitespace-pre-wrap break-all max-h-48 overflow-y-auto',
|
||||||
|
error ? 'text-error' : 'text-success'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatJson(output)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error Section */}
|
||||||
|
{error && !output && (
|
||||||
|
<div className="p-3">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-[10px] uppercase tracking-wider text-error">Error</span>
|
||||||
|
<div className="flex-1 h-px bg-error/30" />
|
||||||
|
</div>
|
||||||
|
<pre className="text-[11px] text-error overflow-x-auto whitespace-pre-wrap break-all">
|
||||||
|
{error}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading state */}
|
||||||
|
{status === 'running' && !output && !error && (
|
||||||
|
<div className="p-3 flex items-center gap-2 text-foreground-tertiary">
|
||||||
|
<Icon icon="loader" size="xs" className="animate-spin" />
|
||||||
|
<span>Executing...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
packages/ui/src/ToolRenderer/ToolRenderer.tsx
Normal file
117
packages/ui/src/ToolRenderer/ToolRenderer.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
/**
|
||||||
|
* ToolRenderer Component
|
||||||
|
*
|
||||||
|
* Main component for rendering tool calls in chat interfaces.
|
||||||
|
* Uses the registry to find the appropriate renderer for each tool.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { DefaultJsonRenderer } from './DefaultJsonRenderer';
|
||||||
|
import { toolRendererRegistry } from './registry';
|
||||||
|
import type { ToolPart, ToolRendererProps, ToolState } from './types';
|
||||||
|
|
||||||
|
export interface ToolRendererComponentProps {
|
||||||
|
/** The tool part from AI SDK message.parts */
|
||||||
|
part: ToolPart;
|
||||||
|
/** Whether this tool is currently streaming/executing */
|
||||||
|
isStreaming?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract input from various tool part formats
|
||||||
|
*/
|
||||||
|
function extractInput(part: ToolPart): unknown {
|
||||||
|
// AI SDK uses 'args' for tool-call and tool-invocation
|
||||||
|
if ('args' in part) {
|
||||||
|
return part.args;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract output from various tool part formats
|
||||||
|
*/
|
||||||
|
function extractOutput(part: ToolPart): unknown {
|
||||||
|
// AI SDK uses 'result' for tool-result
|
||||||
|
if ('result' in part) {
|
||||||
|
return part.result;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine the tool state from the part type
|
||||||
|
*/
|
||||||
|
function determineState(part: ToolPart): ToolState {
|
||||||
|
// If state is explicitly set, use it
|
||||||
|
if (part.state) {
|
||||||
|
return part.state;
|
||||||
|
}
|
||||||
|
// Otherwise infer from type
|
||||||
|
if (part.type === 'tool-result') {
|
||||||
|
return 'result';
|
||||||
|
}
|
||||||
|
if (part.type === 'tool-call') {
|
||||||
|
return 'call';
|
||||||
|
}
|
||||||
|
return 'partial-call';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if output indicates an error
|
||||||
|
*/
|
||||||
|
function extractError(output: unknown): string | undefined {
|
||||||
|
if (output && typeof output === 'object' && 'error' in output) {
|
||||||
|
const err = (output as { error: unknown }).error;
|
||||||
|
return typeof err === 'string' ? err : JSON.stringify(err);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ToolRenderer - Renders a tool call using the appropriate registered renderer
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* import { ToolRenderer } from '@tpmjs/ui/ToolRenderer/ToolRenderer';
|
||||||
|
*
|
||||||
|
* // In a chat message component
|
||||||
|
* {message.parts?.map((part, idx) => {
|
||||||
|
* if (part.type.startsWith('tool-')) {
|
||||||
|
* return (
|
||||||
|
* <ToolRenderer
|
||||||
|
* key={part.toolCallId || idx}
|
||||||
|
* part={part}
|
||||||
|
* isStreaming={isStreaming && part.state !== 'result'}
|
||||||
|
* />
|
||||||
|
* );
|
||||||
|
* }
|
||||||
|
* return null;
|
||||||
|
* })}
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function ToolRenderer({
|
||||||
|
part,
|
||||||
|
isStreaming = false,
|
||||||
|
}: ToolRendererComponentProps): React.ReactElement {
|
||||||
|
const toolName = part.toolName;
|
||||||
|
const input = extractInput(part);
|
||||||
|
const output = extractOutput(part);
|
||||||
|
const state = determineState(part);
|
||||||
|
const error = extractError(output);
|
||||||
|
|
||||||
|
// Find a registered renderer or use default
|
||||||
|
const RegisteredRenderer = toolRendererRegistry.getRenderer(toolName);
|
||||||
|
const Renderer = RegisteredRenderer ?? DefaultJsonRenderer;
|
||||||
|
|
||||||
|
const props: ToolRendererProps = {
|
||||||
|
part,
|
||||||
|
toolName,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
state,
|
||||||
|
isStreaming,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <Renderer {...props} />;
|
||||||
|
}
|
||||||
54
packages/ui/src/ToolRenderer/registerBuiltInRenderers.ts
Normal file
54
packages/ui/src/ToolRenderer/registerBuiltInRenderers.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
/**
|
||||||
|
* Register Built-in Tool Renderers
|
||||||
|
*
|
||||||
|
* Call this function once at app initialization to register
|
||||||
|
* all built-in tool renderers with the registry.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { DefaultJsonRenderer } from './DefaultJsonRenderer';
|
||||||
|
import { toolRendererRegistry } from './registry';
|
||||||
|
import { RegistryExecuteRenderer } from './renderers/RegistryExecuteRenderer';
|
||||||
|
import { RegistrySearchRenderer } from './renderers/RegistrySearchRenderer';
|
||||||
|
|
||||||
|
let isRegistered = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register all built-in tool renderers
|
||||||
|
*
|
||||||
|
* This function is idempotent - calling it multiple times has no effect.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* // In your app's root or layout
|
||||||
|
* import { registerBuiltInRenderers } from '@tpmjs/ui/ToolRenderer/registerBuiltInRenderers';
|
||||||
|
*
|
||||||
|
* registerBuiltInRenderers();
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function registerBuiltInRenderers(): void {
|
||||||
|
if (isRegistered) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register specific renderers first (higher priority)
|
||||||
|
toolRendererRegistry.register({
|
||||||
|
match: (name) => name === 'registrySearchTool',
|
||||||
|
priority: 100,
|
||||||
|
component: RegistrySearchRenderer,
|
||||||
|
});
|
||||||
|
|
||||||
|
toolRendererRegistry.register({
|
||||||
|
match: (name) => name === 'registryExecuteTool',
|
||||||
|
priority: 100,
|
||||||
|
component: RegistryExecuteRenderer,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register default fallback renderer (lowest priority)
|
||||||
|
toolRendererRegistry.register({
|
||||||
|
match: () => true,
|
||||||
|
priority: -1000,
|
||||||
|
component: DefaultJsonRenderer,
|
||||||
|
});
|
||||||
|
|
||||||
|
isRegistered = true;
|
||||||
|
}
|
||||||
51
packages/ui/src/ToolRenderer/registry.ts
Normal file
51
packages/ui/src/ToolRenderer/registry.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
/**
|
||||||
|
* Tool Renderer Registry
|
||||||
|
*
|
||||||
|
* Singleton registry for registering and looking up tool renderers.
|
||||||
|
* Renderers are matched by priority (higher = more specific).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ToolRendererConfig, ToolRendererProps } from './types';
|
||||||
|
|
||||||
|
class ToolRendererRegistry {
|
||||||
|
private renderers: ToolRendererConfig[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a tool renderer
|
||||||
|
* @param config - The renderer configuration
|
||||||
|
*/
|
||||||
|
register<TInput = unknown, TOutput = unknown>(config: ToolRendererConfig<TInput, TOutput>): void {
|
||||||
|
// Add renderer and sort by priority (descending)
|
||||||
|
this.renderers.push(config as ToolRendererConfig);
|
||||||
|
this.renderers.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the appropriate renderer for a tool
|
||||||
|
* @param toolName - The name of the tool
|
||||||
|
* @returns The matching renderer component, or null if none found
|
||||||
|
*/
|
||||||
|
getRenderer(toolName: string): React.ComponentType<ToolRendererProps> | null {
|
||||||
|
const config = this.renderers.find((r) => r.match(toolName));
|
||||||
|
return config?.component ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all registered renderers (for debugging)
|
||||||
|
*/
|
||||||
|
getRegisteredRenderers(): ToolRendererConfig[] {
|
||||||
|
return [...this.renderers];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all renderers (useful for testing)
|
||||||
|
*/
|
||||||
|
clear(): void {
|
||||||
|
this.renderers = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global tool renderer registry
|
||||||
|
*/
|
||||||
|
export const toolRendererRegistry = new ToolRendererRegistry();
|
||||||
|
|
@ -0,0 +1,219 @@
|
||||||
|
/**
|
||||||
|
* Registry Execute Tool Renderer
|
||||||
|
*
|
||||||
|
* Specialized renderer for the registryExecuteTool that displays
|
||||||
|
* the tool being executed with its input and output.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { cn } from '@tpmjs/utils/cn';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Badge } from '../../Badge/Badge';
|
||||||
|
import { Button } from '../../Button/Button';
|
||||||
|
import { Icon } from '../../Icon/Icon';
|
||||||
|
import type { ToolRendererProps } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input for registryExecuteTool
|
||||||
|
*/
|
||||||
|
interface ExecuteInput {
|
||||||
|
toolId?: string;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Output from registryExecuteTool
|
||||||
|
*/
|
||||||
|
interface ExecuteOutput {
|
||||||
|
result?: unknown;
|
||||||
|
error?: string;
|
||||||
|
executionTimeMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse toolId into package and tool name
|
||||||
|
*/
|
||||||
|
function parseToolId(toolId: string): { packageName: string; toolName: string } {
|
||||||
|
const parts = toolId.split('::');
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
return {
|
||||||
|
packageName: parts[0] || 'unknown',
|
||||||
|
toolName: parts.slice(1).join('::') || 'unknown',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
packageName: toolId,
|
||||||
|
toolName: 'unknown',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format data as JSON string for display
|
||||||
|
*/
|
||||||
|
function formatJson(data: unknown): string {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(data, null, 2);
|
||||||
|
} catch {
|
||||||
|
return String(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RegistryExecuteRenderer - Displays tool execution with input/output
|
||||||
|
*/
|
||||||
|
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Complex renderer with multiple UI states
|
||||||
|
export function RegistryExecuteRenderer({
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
state,
|
||||||
|
isStreaming,
|
||||||
|
error,
|
||||||
|
}: ToolRendererProps<ExecuteInput, ExecuteOutput>): React.ReactElement {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(true);
|
||||||
|
const [showInput, setShowInput] = useState(false);
|
||||||
|
|
||||||
|
const execInput = input as ExecuteInput | undefined;
|
||||||
|
const execOutput = output as ExecuteOutput | undefined;
|
||||||
|
const hasError = error || execOutput?.error;
|
||||||
|
|
||||||
|
const { packageName, toolName } = parseToolId(execInput?.toolId ?? '');
|
||||||
|
|
||||||
|
// Determine status
|
||||||
|
const status = hasError
|
||||||
|
? 'error'
|
||||||
|
: state === 'result'
|
||||||
|
? 'success'
|
||||||
|
: isStreaming
|
||||||
|
? 'running'
|
||||||
|
: 'pending';
|
||||||
|
|
||||||
|
const statusConfig = {
|
||||||
|
pending: {
|
||||||
|
icon: 'info' as const,
|
||||||
|
colorClass: 'bg-warning/10 text-warning border-warning/30',
|
||||||
|
label: 'Pending',
|
||||||
|
},
|
||||||
|
running: {
|
||||||
|
icon: 'loader' as const,
|
||||||
|
colorClass: 'bg-info/10 text-info border-info/30',
|
||||||
|
label: 'Executing',
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
icon: 'check' as const,
|
||||||
|
colorClass: 'bg-success/10 text-success border-success/30',
|
||||||
|
label: 'Success',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
icon: 'alertCircle' as const,
|
||||||
|
colorClass: 'bg-error/10 text-error border-error/30',
|
||||||
|
label: 'Error',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const { icon, colorClass, label } = statusConfig[status];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border bg-surface-secondary/50 overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
className="w-full flex items-center gap-3 p-3 h-auto justify-start rounded-none hover:bg-surface-secondary/80"
|
||||||
|
>
|
||||||
|
<div className={cn('p-1.5 rounded border', colorClass)}>
|
||||||
|
<Icon icon={icon} size="xs" className={status === 'running' ? 'animate-spin' : ''} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 text-left">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Badge variant="outline" size="sm" className="font-mono">
|
||||||
|
{packageName}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-sm font-medium text-foreground">{toolName}</span>
|
||||||
|
<span className="text-xs text-foreground-tertiary">{label}</span>
|
||||||
|
</div>
|
||||||
|
{execOutput?.executionTimeMs && (
|
||||||
|
<span className="text-[10px] text-foreground-tertiary">
|
||||||
|
{execOutput.executionTimeMs}ms
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Icon
|
||||||
|
icon="chevronRight"
|
||||||
|
size="xs"
|
||||||
|
className={cn('text-foreground-tertiary transition-transform', isExpanded && 'rotate-90')}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Expanded Content */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="border-t border-border">
|
||||||
|
{/* Input toggle */}
|
||||||
|
{execInput?.params && Object.keys(execInput.params).length > 0 && (
|
||||||
|
<div className="border-b border-border/50">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowInput(!showInput)}
|
||||||
|
className="w-full rounded-none text-xs text-foreground-tertiary flex items-center gap-2 justify-start"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon="chevronRight"
|
||||||
|
size="xs"
|
||||||
|
className={cn('transition-transform', showInput && 'rotate-90')}
|
||||||
|
/>
|
||||||
|
<span>Input Parameters ({Object.keys(execInput.params).length})</span>
|
||||||
|
</Button>
|
||||||
|
{showInput && (
|
||||||
|
<pre className="p-3 text-[11px] font-mono text-foreground-secondary bg-background/50 overflow-x-auto whitespace-pre-wrap break-all max-h-40 overflow-y-auto border-t border-border/30">
|
||||||
|
{formatJson(execInput.params)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading state */}
|
||||||
|
{status === 'running' && (
|
||||||
|
<div className="p-4 flex items-center gap-2 text-foreground-tertiary">
|
||||||
|
<Icon icon="loader" size="sm" className="animate-spin" />
|
||||||
|
<span className="text-sm">Executing {toolName}...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error state */}
|
||||||
|
{hasError && (
|
||||||
|
<div className="p-3">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Icon icon="alertCircle" size="xs" className="text-error" />
|
||||||
|
<span className="text-[10px] uppercase tracking-wider text-error">Error</span>
|
||||||
|
</div>
|
||||||
|
<pre className="text-[11px] font-mono text-error overflow-x-auto whitespace-pre-wrap break-all">
|
||||||
|
{error || execOutput?.error}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Success output */}
|
||||||
|
{state === 'result' && !hasError && execOutput?.result !== undefined && (
|
||||||
|
<div className="p-3">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-[10px] uppercase tracking-wider text-foreground-tertiary">
|
||||||
|
Output
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 h-px bg-border/50" />
|
||||||
|
</div>
|
||||||
|
<pre className="text-[11px] font-mono text-success overflow-x-auto whitespace-pre-wrap break-all max-h-60 overflow-y-auto">
|
||||||
|
{formatJson(execOutput.result)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* No output state */}
|
||||||
|
{state === 'result' && !hasError && execOutput?.result === undefined && (
|
||||||
|
<div className="p-3 text-sm text-foreground-tertiary text-center">
|
||||||
|
No output returned
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,204 @@
|
||||||
|
/**
|
||||||
|
* Registry Search Tool Renderer
|
||||||
|
*
|
||||||
|
* Specialized renderer for the registrySearchTool that displays
|
||||||
|
* search results as a grid of tool cards.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { cn } from '@tpmjs/utils/cn';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Badge } from '../../Badge/Badge';
|
||||||
|
import { Button } from '../../Button/Button';
|
||||||
|
import { Icon } from '../../Icon/Icon';
|
||||||
|
import type { ToolRendererProps } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input for registrySearchTool
|
||||||
|
*/
|
||||||
|
interface SearchInput {
|
||||||
|
query?: string;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tool result from the search
|
||||||
|
*/
|
||||||
|
interface SearchResultTool {
|
||||||
|
toolId: string;
|
||||||
|
package: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Output from registrySearchTool
|
||||||
|
*/
|
||||||
|
interface SearchOutput {
|
||||||
|
tools?: SearchResultTool[];
|
||||||
|
total?: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RegistrySearchRenderer - Displays search results as tool cards
|
||||||
|
*/
|
||||||
|
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Complex renderer with multiple UI states
|
||||||
|
export function RegistrySearchRenderer({
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
state,
|
||||||
|
isStreaming,
|
||||||
|
error,
|
||||||
|
}: ToolRendererProps<SearchInput, SearchOutput>): React.ReactElement {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(true);
|
||||||
|
const [showRawJson, setShowRawJson] = useState(false);
|
||||||
|
|
||||||
|
const searchInput = input as SearchInput | undefined;
|
||||||
|
const searchOutput = output as SearchOutput | undefined;
|
||||||
|
const tools = searchOutput?.tools ?? [];
|
||||||
|
const hasError = error || searchOutput?.error;
|
||||||
|
|
||||||
|
// Determine status
|
||||||
|
const status = hasError
|
||||||
|
? 'error'
|
||||||
|
: state === 'result'
|
||||||
|
? 'success'
|
||||||
|
: isStreaming
|
||||||
|
? 'running'
|
||||||
|
: 'pending';
|
||||||
|
|
||||||
|
const statusConfig = {
|
||||||
|
pending: { icon: 'info' as const, colorClass: 'text-warning' },
|
||||||
|
running: { icon: 'loader' as const, colorClass: 'text-info' },
|
||||||
|
success: { icon: 'check' as const, colorClass: 'text-success' },
|
||||||
|
error: { icon: 'alertCircle' as const, colorClass: 'text-error' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const { icon, colorClass } = statusConfig[status];
|
||||||
|
|
||||||
|
const handleCopyToolId = (toolId: string) => {
|
||||||
|
navigator.clipboard.writeText(toolId);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border bg-surface-secondary/50 overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
className="w-full flex items-center gap-3 p-3 h-auto justify-start rounded-none hover:bg-surface-secondary/80"
|
||||||
|
>
|
||||||
|
<Icon icon="search" size="sm" className="text-primary" />
|
||||||
|
<div className="flex-1 text-left">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-foreground">Registry Search</span>
|
||||||
|
{searchInput?.query && (
|
||||||
|
<Badge variant="secondary" size="sm">
|
||||||
|
"{searchInput.query}"
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{state === 'result' && !hasError && (
|
||||||
|
<span className="text-xs text-foreground-tertiary">
|
||||||
|
Found {tools.length} tool{tools.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Icon
|
||||||
|
icon={icon}
|
||||||
|
size="xs"
|
||||||
|
className={cn(colorClass, status === 'running' && 'animate-spin')}
|
||||||
|
/>
|
||||||
|
<Icon
|
||||||
|
icon="chevronRight"
|
||||||
|
size="xs"
|
||||||
|
className={cn('text-foreground-tertiary transition-transform', isExpanded && 'rotate-90')}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Expanded Content */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="border-t border-border">
|
||||||
|
{/* Loading state */}
|
||||||
|
{status === 'running' && (
|
||||||
|
<div className="p-4 flex items-center justify-center gap-2 text-foreground-tertiary">
|
||||||
|
<Icon icon="loader" size="sm" className="animate-spin" />
|
||||||
|
<span className="text-sm">Searching registry...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error state */}
|
||||||
|
{hasError && <div className="p-4 text-sm text-error">{error || searchOutput?.error}</div>}
|
||||||
|
|
||||||
|
{/* Results grid */}
|
||||||
|
{state === 'result' && !hasError && tools.length > 0 && (
|
||||||
|
<div className="p-3 space-y-2">
|
||||||
|
{tools.map((tool) => (
|
||||||
|
<div
|
||||||
|
key={tool.toolId}
|
||||||
|
className="p-3 rounded-md border border-border/50 bg-background/50 hover:bg-background transition-colors group"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-xs text-primary font-mono">{tool.package}</span>
|
||||||
|
<span className="text-foreground-tertiary">::</span>
|
||||||
|
<span className="text-sm font-medium text-foreground">{tool.name}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-foreground-secondary line-clamp-2">
|
||||||
|
{tool.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleCopyToolId(tool.toolId);
|
||||||
|
}}
|
||||||
|
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
title="Copy toolId"
|
||||||
|
>
|
||||||
|
<Icon icon="copy" size="xs" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 pt-2 border-t border-border/30">
|
||||||
|
<code className="text-[10px] text-foreground-tertiary font-mono">
|
||||||
|
{tool.toolId}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty results */}
|
||||||
|
{state === 'result' && !hasError && tools.length === 0 && (
|
||||||
|
<div className="p-4 text-center text-sm text-foreground-tertiary">
|
||||||
|
No tools found for this search
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Raw JSON toggle */}
|
||||||
|
{state === 'result' && (
|
||||||
|
<div className="border-t border-border/50">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowRawJson(!showRawJson)}
|
||||||
|
className="w-full rounded-none text-xs text-foreground-tertiary"
|
||||||
|
>
|
||||||
|
{showRawJson ? 'Hide' : 'Show'} Raw JSON
|
||||||
|
</Button>
|
||||||
|
{showRawJson && (
|
||||||
|
<pre className="p-3 text-[10px] font-mono text-foreground-tertiary bg-background/50 overflow-x-auto max-h-40 overflow-y-auto">
|
||||||
|
{JSON.stringify(output, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
packages/ui/src/ToolRenderer/renderers/index.ts
Normal file
8
packages/ui/src/ToolRenderer/renderers/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
/**
|
||||||
|
* Built-in Tool Renderers
|
||||||
|
*
|
||||||
|
* Export all built-in renderers for registration.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { RegistryExecuteRenderer } from './RegistryExecuteRenderer';
|
||||||
|
export { RegistrySearchRenderer } from './RegistrySearchRenderer';
|
||||||
55
packages/ui/src/ToolRenderer/types.ts
Normal file
55
packages/ui/src/ToolRenderer/types.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
/**
|
||||||
|
* Tool Renderer Types
|
||||||
|
*
|
||||||
|
* Interfaces for the abstract tool rendering system.
|
||||||
|
* Used to render tool calls in AI chat interfaces.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool state during streaming
|
||||||
|
*/
|
||||||
|
export type ToolState = 'partial-call' | 'call' | 'result';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A part representing a tool call or result from AI SDK
|
||||||
|
*/
|
||||||
|
export interface ToolPart {
|
||||||
|
type: 'tool-invocation' | 'tool-call' | 'tool-result';
|
||||||
|
toolCallId: string;
|
||||||
|
toolName: string;
|
||||||
|
args?: unknown;
|
||||||
|
result?: unknown;
|
||||||
|
state?: ToolState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props passed to every tool renderer
|
||||||
|
*/
|
||||||
|
export interface ToolRendererProps<TInput = unknown, TOutput = unknown> {
|
||||||
|
/** The full tool part from AI SDK */
|
||||||
|
part: ToolPart;
|
||||||
|
/** Name of the tool being rendered */
|
||||||
|
toolName: string;
|
||||||
|
/** Input parameters for the tool */
|
||||||
|
input: TInput;
|
||||||
|
/** Output from the tool (undefined while running) */
|
||||||
|
output: TOutput | undefined;
|
||||||
|
/** Current state of the tool call */
|
||||||
|
state: ToolState;
|
||||||
|
/** Whether the tool is currently streaming/executing */
|
||||||
|
isStreaming: boolean;
|
||||||
|
/** Error message if the tool failed */
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for registering a tool renderer
|
||||||
|
*/
|
||||||
|
export interface ToolRendererConfig<TInput = unknown, TOutput = unknown> {
|
||||||
|
/** Function to determine if this renderer handles a given tool */
|
||||||
|
match: (toolName: string) => boolean;
|
||||||
|
/** Priority for matching (higher = more specific, checked first) */
|
||||||
|
priority?: number;
|
||||||
|
/** The React component to render the tool */
|
||||||
|
component: React.ComponentType<ToolRendererProps<TInput, TOutput>>;
|
||||||
|
}
|
||||||
|
|
@ -44,6 +44,13 @@ entries.push('src/system/hooks/useParallax.ts');
|
||||||
entries.push('src/system/hooks/useReducedMotion.ts');
|
entries.push('src/system/hooks/useReducedMotion.ts');
|
||||||
entries.push('src/system/hooks/useDitherAnimation.ts');
|
entries.push('src/system/hooks/useDitherAnimation.ts');
|
||||||
|
|
||||||
|
// Manually add ToolRenderer files that don't match naming conventions
|
||||||
|
entries.push('src/ToolRenderer/types.ts');
|
||||||
|
entries.push('src/ToolRenderer/registry.ts');
|
||||||
|
entries.push('src/ToolRenderer/registerBuiltInRenderers.ts');
|
||||||
|
entries.push('src/ToolRenderer/renderers/RegistrySearchRenderer.tsx');
|
||||||
|
entries.push('src/ToolRenderer/renderers/RegistryExecuteRenderer.tsx');
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
entry: entries,
|
entry: entries,
|
||||||
format: ['esm'],
|
format: ['esm'],
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue