231 lines
7.7 KiB
TypeScript
231 lines
7.7 KiB
TypeScript
'use client'
|
|
|
|
import { type ToolCallMessagePartProps } from '@assistant-ui/react'
|
|
import { useStore } from '@nanostores/react'
|
|
import { ChevronRight } from 'lucide-react'
|
|
import { useEffect, useState } from 'react'
|
|
|
|
import { useElapsedSeconds } from '@/components/assistant-ui/activity-timer'
|
|
import { ActivityTimerText } from '@/components/assistant-ui/activity-timer-text'
|
|
import { cn } from '@/lib/utils'
|
|
import { $toolInlineDiffs } from '@/store/tool-diffs'
|
|
|
|
const TOOL_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
|
|
|
const TOOL_SPINNER_INTERVAL_MS = 80
|
|
|
|
function titleForTool(name: string): string {
|
|
return (
|
|
name
|
|
.split('_')
|
|
.filter(Boolean)
|
|
.map(part => `${part[0]?.toUpperCase() ?? ''}${part.slice(1)}`)
|
|
.join(' ') || name
|
|
)
|
|
}
|
|
|
|
function toolLabel(name: string, isPending: boolean): string {
|
|
const labels: Record<string, { done: string; pending: string }> = {
|
|
edit_file: { done: 'Edited file', pending: 'Editing file' },
|
|
execute_code: { done: 'Ran code', pending: 'Running code' },
|
|
image_generate: { done: 'Generated image', pending: 'Generating image' },
|
|
list_files: { done: 'Listed files', pending: 'Listing files' },
|
|
read_file: { done: 'Read file', pending: 'Reading file' },
|
|
search_files: { done: 'Searched files', pending: 'Searching files' },
|
|
session_search_recall: { done: 'Searched session history', pending: 'Searching session history' },
|
|
terminal: { done: 'Ran command', pending: 'Running command' },
|
|
todo: { done: 'Updated todos', pending: 'Updating todos' },
|
|
web_extract: { done: 'Read webpage', pending: 'Reading webpage' },
|
|
web_search: { done: 'Searched the web', pending: 'Searching the web' },
|
|
write_file: { done: 'Edited file', pending: 'Editing file' }
|
|
}
|
|
|
|
if (labels[name]) {
|
|
return isPending ? labels[name].pending : labels[name].done
|
|
}
|
|
|
|
return `${isPending ? 'Using' : 'Used'} ${titleForTool(name)}`
|
|
}
|
|
|
|
function compactPreview(value: unknown, max = 72): string {
|
|
const text =
|
|
typeof value === 'string'
|
|
? value
|
|
: value && typeof value === 'object' && 'context' in value
|
|
? String((value as { context?: unknown }).context ?? '')
|
|
: ''
|
|
|
|
const oneLine = text.replace(/\s+/g, ' ').trim()
|
|
|
|
return oneLine.length > max ? `${oneLine.slice(0, max - 1)}…` : oneLine
|
|
}
|
|
|
|
function shouldShowInlinePreview(toolName: string): boolean {
|
|
return !['image_generate', 'terminal', 'execute_code'].includes(toolName)
|
|
}
|
|
|
|
function contextValue(value: unknown): string {
|
|
if (typeof value === 'string') {
|
|
return value
|
|
}
|
|
|
|
if (value && typeof value === 'object' && 'context' in value) {
|
|
return String((value as { context?: unknown }).context ?? '')
|
|
}
|
|
|
|
return ''
|
|
}
|
|
|
|
function prettyJson(value: unknown): string {
|
|
return typeof value === 'string' ? value : JSON.stringify(value, null, 2)
|
|
}
|
|
|
|
function recordValue(value: unknown): Record<string, unknown> {
|
|
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {}
|
|
}
|
|
|
|
function stripAnsi(value: string): string {
|
|
return value.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'), '')
|
|
}
|
|
|
|
function stripInlineDiffChrome(value: string): string {
|
|
return value ? stripAnsi(value).replace(/^\s*┊\s*review diff\s*\n/i, '').trim() : ''
|
|
}
|
|
|
|
function inlineDiffFromResult(result: unknown): string {
|
|
const value = recordValue(result).inline_diff
|
|
|
|
return typeof value === 'string' ? stripInlineDiffChrome(value) : ''
|
|
}
|
|
|
|
function detailLabel(toolName: string): string {
|
|
if (toolName === 'image_generate') {
|
|
return 'Prompt'
|
|
}
|
|
|
|
if (toolName === 'web_search') {
|
|
return 'Query'
|
|
}
|
|
|
|
if (toolName === 'web_extract') {
|
|
return 'URL'
|
|
}
|
|
|
|
if (toolName === 'terminal') {
|
|
return 'Command'
|
|
}
|
|
|
|
if (toolName === 'execute_code') {
|
|
return 'Code'
|
|
}
|
|
|
|
return 'Input'
|
|
}
|
|
|
|
function detailText(args: unknown, result: unknown): string {
|
|
const argContext = contextValue(args)
|
|
const resultContext = contextValue(result)
|
|
|
|
if (resultContext && resultContext !== argContext) {
|
|
return resultContext
|
|
}
|
|
|
|
if (argContext) {
|
|
return argContext
|
|
}
|
|
|
|
if (result !== undefined) {
|
|
return prettyJson(result)
|
|
}
|
|
|
|
return prettyJson(args)
|
|
}
|
|
|
|
export const ToolFallback = ({ toolCallId, toolName, args, result }: ToolCallMessagePartProps) => {
|
|
const [open, setOpen] = useState(false)
|
|
const isPending = result === undefined
|
|
const [tick, setTick] = useState(0)
|
|
const elapsed = useElapsedSeconds(isPending)
|
|
const preview = compactPreview(args) || compactPreview(result)
|
|
const label = toolLabel(toolName, isPending)
|
|
const detail = detailText(args, result)
|
|
const liveDiffs = useStore($toolInlineDiffs)
|
|
const sideDiff = toolCallId ? liveDiffs[toolCallId] || '' : ''
|
|
const inlineDiff = stripInlineDiffChrome(sideDiff) || inlineDiffFromResult(result)
|
|
const spinnerFrame = TOOL_SPINNER_FRAMES[tick % TOOL_SPINNER_FRAMES.length]
|
|
|
|
useEffect(() => {
|
|
if (!isPending) {
|
|
return
|
|
}
|
|
|
|
const id = window.setInterval(() => setTick(value => value + 1), TOOL_SPINNER_INTERVAL_MS)
|
|
|
|
return () => window.clearInterval(id)
|
|
}, [isPending])
|
|
|
|
return (
|
|
<div className="mb-3 mt-1 text-sm text-muted-foreground">
|
|
<button
|
|
className="inline-grid max-w-full grid-cols-[0.75rem_minmax(0,auto)_minmax(0,1fr)_auto_auto] items-center gap-1 rounded-md py-0.5 pr-1 text-left text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
onClick={() => setOpen(v => !v)}
|
|
type="button"
|
|
>
|
|
<ChevronRight
|
|
className={cn('shrink-0 text-muted-foreground/80 transition-transform', open && 'rotate-90')}
|
|
size={12}
|
|
/>
|
|
<span
|
|
className={cn('shrink-0 text-xs font-medium text-foreground/70', isPending && 'shimmer text-foreground/55')}
|
|
>
|
|
{label}
|
|
</span>
|
|
{preview && shouldShowInlinePreview(toolName) && (
|
|
<span className="min-w-0 truncate text-xs text-muted-foreground/80">{preview}</span>
|
|
)}
|
|
{isPending ? (
|
|
<span aria-label="Running" className="ml-1 w-3 shrink-0 text-center text-xs text-muted-foreground/80">
|
|
{spinnerFrame}
|
|
</span>
|
|
) : null}
|
|
{isPending && <ActivityTimerText seconds={elapsed} />}
|
|
</button>
|
|
{open && (
|
|
<div className="ml-4 mt-1 max-w-full whitespace-pre-wrap wrap-anywhere border-l border-border pl-3 text-xs leading-relaxed text-muted-foreground/85">
|
|
<span className="mr-1 font-medium text-muted-foreground/70">{detailLabel(toolName)}:</span>
|
|
{detail}
|
|
</div>
|
|
)}
|
|
{inlineDiff && <InlineDiff text={inlineDiff} />}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function InlineDiff({ text }: { text: string }) {
|
|
return (
|
|
<pre className="ml-4 mt-2 max-h-96 max-w-full overflow-auto rounded-lg border border-border/60 bg-background/70 px-3 py-2 font-mono text-[0.6875rem] leading-relaxed">
|
|
{text.split('\n').map((line, index) => {
|
|
const added = line.startsWith('+') && !line.startsWith('+++')
|
|
const removed = line.startsWith('-') && !line.startsWith('---')
|
|
const hunk = line.startsWith('@@')
|
|
const fileHeader = line.startsWith('---') || line.startsWith('+++') || / → /.test(line.slice(0, 60))
|
|
|
|
return (
|
|
<span
|
|
className={cn(
|
|
'block min-w-max whitespace-pre',
|
|
added && 'text-emerald-700 dark:text-emerald-300',
|
|
removed && 'text-rose-700 dark:text-rose-300',
|
|
hunk && 'text-sky-700 dark:text-sky-300',
|
|
!added && !removed && !hunk && fileHeader && 'text-muted-foreground/80'
|
|
)}
|
|
key={`${index}-${line}`}
|
|
>
|
|
{line || ' '}
|
|
</span>
|
|
)
|
|
})}
|
|
</pre>
|
|
)
|
|
}
|