From 0489646e554415c145c7418abc41900a41fa2e3b Mon Sep 17 00:00:00 2001 From: Ajax Davis Date: Fri, 23 Jan 2026 22:35:17 +1000 Subject: [PATCH] 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 --- .gitignore | 6 + .../src/app/omega/[conversationId]/page.tsx | 189 +++++---------- packages/ui/package.json | 28 +++ .../src/ToolRenderer/DefaultJsonRenderer.tsx | 165 +++++++++++++ packages/ui/src/ToolRenderer/ToolRenderer.tsx | 117 ++++++++++ .../ToolRenderer/registerBuiltInRenderers.ts | 54 +++++ packages/ui/src/ToolRenderer/registry.ts | 51 ++++ .../renderers/RegistryExecuteRenderer.tsx | 219 ++++++++++++++++++ .../renderers/RegistrySearchRenderer.tsx | 204 ++++++++++++++++ .../ui/src/ToolRenderer/renderers/index.ts | 8 + packages/ui/src/ToolRenderer/types.ts | 55 +++++ packages/ui/tsup.config.ts | 7 + 12 files changed, 967 insertions(+), 136 deletions(-) create mode 100644 packages/ui/src/ToolRenderer/DefaultJsonRenderer.tsx create mode 100644 packages/ui/src/ToolRenderer/ToolRenderer.tsx create mode 100644 packages/ui/src/ToolRenderer/registerBuiltInRenderers.ts create mode 100644 packages/ui/src/ToolRenderer/registry.ts create mode 100644 packages/ui/src/ToolRenderer/renderers/RegistryExecuteRenderer.tsx create mode 100644 packages/ui/src/ToolRenderer/renderers/RegistrySearchRenderer.tsx create mode 100644 packages/ui/src/ToolRenderer/renderers/index.ts create mode 100644 packages/ui/src/ToolRenderer/types.ts diff --git a/.gitignore b/.gitignore index 9353235..64877b4 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,12 @@ dist # misc .DS_Store +# video files +*.mp4 +*.webm +*.mov +*.avi + # debug npm-debug.log* yarn-debug.log* diff --git a/apps/web/src/app/omega/[conversationId]/page.tsx b/apps/web/src/app/omega/[conversationId]/page.tsx index 18b60ef..af7feca 100644 --- a/apps/web/src/app/omega/[conversationId]/page.tsx +++ b/apps/web/src/app/omega/[conversationId]/page.tsx @@ -4,6 +4,9 @@ import { Badge } from '@tpmjs/ui/Badge/Badge'; import { Button } from '@tpmjs/ui/Button/Button'; import { Icon } from '@tpmjs/ui/Icon/Icon'; 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 { useParams, useRouter } from 'next/navigation'; import { useCallback, useEffect, useRef, useState } from 'react'; @@ -11,6 +14,9 @@ import { Streamdown } from 'streamdown'; import { AppHeader } from '~/components/AppHeader'; import { EnvVarWarningBanner } from '~/components/omega/EnvVarWarningBanner'; +// Initialize built-in tool renderers (idempotent) +registerBuiltInRenderers(); + interface ToolDiscoveryInfo { staticTools?: string[]; dynamicToolsLoaded?: string[]; @@ -29,7 +35,8 @@ interface Message { toolCalls?: Array<{ toolCallId: string; toolName: string; - args: unknown; + args?: unknown; + output?: unknown; }>; inputTokens?: number; outputTokens?: number; @@ -69,115 +76,32 @@ interface Conversation { } /** - * Tool call debug card component + * Convert ToolCall status to ToolState */ -function ToolCallCard({ - toolCall, - isExpanded, - onToggle, -}: { - toolCall: ToolCall; - isExpanded: boolean; - onToggle: () => void; -}) { - 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', +function statusToToolState(status: ToolCall['status']): ToolState { + switch (status) { + case 'pending': + return 'partial-call'; + case 'running': + return 'call'; + case 'success': + case 'error': + return 'result'; + } +} + +/** + * 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 = { - 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 ( -
- {/* Header */} - - - {/* Expanded Content */} - {isExpanded && ( -
- {/* Input Section */} - {toolCall.input !== undefined && toolCall.input !== null ? ( -
-
- - Input - -
-
-
-                {formatJson(toolCall.input)}
-              
-
- ) : null} - - {/* Output Section */} - {toolCall.output !== undefined && toolCall.output !== null ? ( -
-
- - Output - -
-
-
-                {formatJson(toolCall.output)}
-              
-
- ) : null} - - {/* Status indicator for running */} - {toolCall.status === 'running' && !toolCall.output && ( -
- - Executing... -
- )} -
- )} -
- ); } /** @@ -197,7 +121,6 @@ export default function OmegaChatPage(): React.ReactElement { const [streamingContent, setStreamingContent] = useState(''); const [error, setError] = useState(null); const [toolCalls, setToolCalls] = useState([]); - const [expandedToolCalls, setExpandedToolCalls] = useState>(new Set()); const [viewMode, setViewMode] = useState<'chat' | 'debug'>('chat'); // Map of message IDs to their tool discovery info (persisted in frontend state) const [messageToolDiscovery, setMessageToolDiscovery] = useState>( @@ -209,18 +132,6 @@ export default function OmegaChatPage(): React.ReactElement { const messagesContainerRef = useRef(null); const inputRef = useRef(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 const fetchConversation = useCallback(async () => { try { @@ -353,8 +264,6 @@ export default function OmegaChatPage(): React.ReactElement { status: 'running', }, ]); - // Auto-expand new tool calls - setExpandedToolCalls((prev) => new Set([...prev, data.toolCallId])); break; case 'run.step.tool.completed': // Update tool call with result @@ -580,6 +489,7 @@ export default function OmegaChatPage(): React.ReactElement {
{/* Per-Message View */} + {/* biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Debug view with multiple conditional sections */} {messages.map((message, index) => { const discovery = messageToolDiscovery.get(message.id); return ( @@ -789,16 +699,24 @@ export default function OmegaChatPage(): React.ReactElement {
)} - {/* TOOL message - show as collapsed tool result */} - {message.role === 'TOOL' && ( + {/* TOOL message - show tool results from toolCalls field */} + {message.role === 'TOOL' && message.toolCalls && (
-
-
- Tool Results -
-
-                                {message.content}
-                              
+
+ {message.toolCalls.map((tc) => ( + + ))}
)} @@ -811,10 +729,9 @@ export default function OmegaChatPage(): React.ReactElement { {toolCalls.map((tc) => (
- toggleToolCall(tc.toolCallId)} +
diff --git a/packages/ui/package.json b/packages/ui/package.json index 60383f1..2fbab61 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -234,6 +234,34 @@ "./PageHeader/PageHeader": { "types": "./dist/PageHeader/PageHeader.d.ts", "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": [ diff --git a/packages/ui/src/ToolRenderer/DefaultJsonRenderer.tsx b/packages/ui/src/ToolRenderer/DefaultJsonRenderer.tsx new file mode 100644 index 0000000..be19b34 --- /dev/null +++ b/packages/ui/src/ToolRenderer/DefaultJsonRenderer.tsx @@ -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 ( +
+ {/* Header */} + + + {/* Expanded Content */} + {isExpanded && ( +
+ {/* Input Section */} + {input !== undefined && input !== null && ( +
+
+ + Input + +
+
+
+                {formatJson(input)}
+              
+
+ )} + + {/* Output Section */} + {output !== undefined && output !== null && ( +
+
+ + Output + +
+
+
+                {formatJson(output)}
+              
+
+ )} + + {/* Error Section */} + {error && !output && ( +
+
+ Error +
+
+
+                {error}
+              
+
+ )} + + {/* Loading state */} + {status === 'running' && !output && !error && ( +
+ + Executing... +
+ )} +
+ )} +
+ ); +} diff --git a/packages/ui/src/ToolRenderer/ToolRenderer.tsx b/packages/ui/src/ToolRenderer/ToolRenderer.tsx new file mode 100644 index 0000000..71a107d --- /dev/null +++ b/packages/ui/src/ToolRenderer/ToolRenderer.tsx @@ -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 ( + * + * ); + * } + * 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 ; +} diff --git a/packages/ui/src/ToolRenderer/registerBuiltInRenderers.ts b/packages/ui/src/ToolRenderer/registerBuiltInRenderers.ts new file mode 100644 index 0000000..a30ab0d --- /dev/null +++ b/packages/ui/src/ToolRenderer/registerBuiltInRenderers.ts @@ -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; +} diff --git a/packages/ui/src/ToolRenderer/registry.ts b/packages/ui/src/ToolRenderer/registry.ts new file mode 100644 index 0000000..7b64f89 --- /dev/null +++ b/packages/ui/src/ToolRenderer/registry.ts @@ -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(config: ToolRendererConfig): 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 | 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(); diff --git a/packages/ui/src/ToolRenderer/renderers/RegistryExecuteRenderer.tsx b/packages/ui/src/ToolRenderer/renderers/RegistryExecuteRenderer.tsx new file mode 100644 index 0000000..1d88745 --- /dev/null +++ b/packages/ui/src/ToolRenderer/renderers/RegistryExecuteRenderer.tsx @@ -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; +} + +/** + * 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): 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 ( +
+ {/* Header */} + + + {/* Expanded Content */} + {isExpanded && ( +
+ {/* Input toggle */} + {execInput?.params && Object.keys(execInput.params).length > 0 && ( +
+ + {showInput && ( +
+                  {formatJson(execInput.params)}
+                
+ )} +
+ )} + + {/* Loading state */} + {status === 'running' && ( +
+ + Executing {toolName}... +
+ )} + + {/* Error state */} + {hasError && ( +
+
+ + Error +
+
+                {error || execOutput?.error}
+              
+
+ )} + + {/* Success output */} + {state === 'result' && !hasError && execOutput?.result !== undefined && ( +
+
+ + Output + +
+
+
+                {formatJson(execOutput.result)}
+              
+
+ )} + + {/* No output state */} + {state === 'result' && !hasError && execOutput?.result === undefined && ( +
+ No output returned +
+ )} +
+ )} +
+ ); +} diff --git a/packages/ui/src/ToolRenderer/renderers/RegistrySearchRenderer.tsx b/packages/ui/src/ToolRenderer/renderers/RegistrySearchRenderer.tsx new file mode 100644 index 0000000..0072773 --- /dev/null +++ b/packages/ui/src/ToolRenderer/renderers/RegistrySearchRenderer.tsx @@ -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): 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 ( +
+ {/* Header */} + + + {/* Expanded Content */} + {isExpanded && ( +
+ {/* Loading state */} + {status === 'running' && ( +
+ + Searching registry... +
+ )} + + {/* Error state */} + {hasError &&
{error || searchOutput?.error}
} + + {/* Results grid */} + {state === 'result' && !hasError && tools.length > 0 && ( +
+ {tools.map((tool) => ( +
+
+
+
+ {tool.package} + :: + {tool.name} +
+

+ {tool.description} +

+
+ +
+
+ + {tool.toolId} + +
+
+ ))} +
+ )} + + {/* Empty results */} + {state === 'result' && !hasError && tools.length === 0 && ( +
+ No tools found for this search +
+ )} + + {/* Raw JSON toggle */} + {state === 'result' && ( +
+ + {showRawJson && ( +
+                  {JSON.stringify(output, null, 2)}
+                
+ )} +
+ )} +
+ )} +
+ ); +} diff --git a/packages/ui/src/ToolRenderer/renderers/index.ts b/packages/ui/src/ToolRenderer/renderers/index.ts new file mode 100644 index 0000000..d3ecb6a --- /dev/null +++ b/packages/ui/src/ToolRenderer/renderers/index.ts @@ -0,0 +1,8 @@ +/** + * Built-in Tool Renderers + * + * Export all built-in renderers for registration. + */ + +export { RegistryExecuteRenderer } from './RegistryExecuteRenderer'; +export { RegistrySearchRenderer } from './RegistrySearchRenderer'; diff --git a/packages/ui/src/ToolRenderer/types.ts b/packages/ui/src/ToolRenderer/types.ts new file mode 100644 index 0000000..2422ecf --- /dev/null +++ b/packages/ui/src/ToolRenderer/types.ts @@ -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 { + /** 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 { + /** 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>; +} diff --git a/packages/ui/tsup.config.ts b/packages/ui/tsup.config.ts index b5533a1..1876858 100644 --- a/packages/ui/tsup.config.ts +++ b/packages/ui/tsup.config.ts @@ -44,6 +44,13 @@ entries.push('src/system/hooks/useParallax.ts'); entries.push('src/system/hooks/useReducedMotion.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({ entry: entries, format: ['esm'],