Files
cadSet/CADDesigner-Code/frontend/src/components/ToolCallCard.tsx
T
2026-07-22 19:38:36 +08:00

248 lines
7.1 KiB
TypeScript

import { useEffect, useRef, useState } from 'react'
import { AlertTriangle, Brain, CheckCircle2, ChevronDown, LoaderCircle, Wrench } from 'lucide-react'
import { sanitizeAssistantText } from '../lib/chat'
import type { ToolNestedEvent, ToolSegment } from '../types'
import { AnimatedDisclosure } from './AnimatedDisclosure'
import { StreamingMarkdown } from './StreamingMarkdown'
interface ToolCallCardProps {
activity: ToolSegment
}
function statusLabel(status: ToolSegment['status']): string {
if (status === 'running') {
return 'Running'
}
if (status === 'completed') {
return 'Completed'
}
if (status === 'error') {
return 'Failed'
}
return 'Queued'
}
function StatusIcon({ status }: { status: ToolSegment['status'] }) {
if (status === 'running' || status === 'pending') {
return <LoaderCircle size={14} className="spin" />
}
if (status === 'completed') {
return <CheckCircle2 size={14} />
}
return <AlertTriangle size={14} />
}
function nestedStatusLabel(status: ToolNestedEvent['status']): string {
if (status === 'running') {
return 'Running'
}
if (status === 'completed') {
return 'Done'
}
if (status === 'error') {
return 'Failed'
}
return 'Info'
}
function nestedEventVariant(event: ToolNestedEvent): string {
const label = event.detailLabel.trim().toLowerCase()
if (label === 'reasoning') {
return 'reasoning'
}
if (label === 'response') {
return 'response'
}
if (label === 'error') {
return 'error'
}
if (label === 'result') {
return 'result'
}
if (label === 'progress') {
return 'progress'
}
return 'detail'
}
function isNarrativeNestedEvent(event: ToolNestedEvent): boolean {
const variant = nestedEventVariant(event)
return variant === 'response' || variant === 'reasoning'
}
function NestedToolCard({ event }: { event: ToolNestedEvent }) {
const variant = nestedEventVariant(event)
const isRunning = event.status === 'running'
const [isOpen, setIsOpen] = useState(isRunning)
const previousStatusRef = useRef(event.status)
useEffect(() => {
if (isRunning) {
setIsOpen(true)
} else if (previousStatusRef.current === 'running' && event.status !== 'running') {
setIsOpen(false)
}
previousStatusRef.current = event.status
}, [event.status, isRunning])
return (
<AnimatedDisclosure
bodyClassName="tool-nested-body"
bodyWrapClassName="tool-nested-body-wrap"
className={`tool-nested-item is-${variant}`}
onToggle={setIsOpen}
open={isOpen}
summary={
<div className="tool-nested-item-topline">
<strong>{event.title}</strong>
<div className="tool-nested-item-actions">
<span className={`tool-nested-status is-${event.status}`}>{nestedStatusLabel(event.status)}</span>
<ChevronDown className="tool-nested-chevron" size={14} />
</div>
</div>
}
summaryClassName="tool-nested-summary"
>
{event.argumentsPreview ? (
<div className="tool-nested-block is-arguments">
<span className="tool-card-label">Arguments</span>
<pre>{event.argumentsPreview}</pre>
</div>
) : null}
{event.detailPreview ? (
<div className={`tool-nested-block is-${variant}`}>
<span className="tool-card-label">{event.detailLabel}</span>
<pre>{event.detailPreview}</pre>
</div>
) : null}
</AnimatedDisclosure>
)
}
function InlineReasoningCard({ title, text, autoOpen }: { title: string; text: string; autoOpen: boolean }) {
const [isOpen, setIsOpen] = useState(autoOpen)
useEffect(() => {
if (autoOpen) {
setIsOpen(true)
}
}, [autoOpen])
return (
<AnimatedDisclosure
bodyClassName="reasoning-card-body"
bodyWrapClassName="reasoning-card-body-wrap"
className="reasoning-card tool-inline-reasoning"
onToggle={setIsOpen}
open={isOpen}
summary={
<div className="reasoning-card-title">
<Brain size={14} />
<strong>{title}</strong>
</div>
}
summaryClassName="reasoning-card-summary"
>
<StreamingMarkdown pulseOnMount={autoOpen} text={text} />
</AnimatedDisclosure>
)
}
export function ToolCallCard({ activity }: ToolCallCardProps) {
const isRunning = activity.status === 'pending' || activity.status === 'running'
const [isOpen, setIsOpen] = useState(isRunning)
useEffect(() => {
if (isRunning) {
setIsOpen(true)
}
}, [isRunning])
return (
<AnimatedDisclosure
bodyClassName="tool-card-body"
bodyWrapClassName="tool-card-body-wrap"
className="tool-card"
onToggle={setIsOpen}
open={isOpen}
summary={
<div className="tool-card-title-row">
<div className={`tool-status-pill is-${activity.status}`}>
<StatusIcon status={activity.status} />
<span>{statusLabel(activity.status)}</span>
</div>
<div className="tool-card-title">
<Wrench size={14} />
<strong>{activity.toolName}</strong>
</div>
<ChevronDown size={16} className="tool-card-chevron" />
</div>
}
summaryClassName="tool-card-summary"
>
{activity.argumentsPreview ? (
<div className="tool-card-section">
<span className="tool-card-label">Arguments</span>
<pre>{activity.argumentsPreview}</pre>
</div>
) : null}
{activity.nestedEvents.length ? (
<div className="tool-card-section">
<span className="tool-card-label">Subagent Trace</span>
<div className="tool-card-middle-sequence">
{activity.nestedEvents.map((event) => {
const variant = nestedEventVariant(event)
if (isNarrativeNestedEvent(event)) {
const rawText =
variant === 'response'
? sanitizeAssistantText(event.detailPreview)
: event.detailPreview.trim()
if (!rawText) {
return null
}
if (variant === 'reasoning') {
return (
<InlineReasoningCard
autoOpen={isRunning}
key={event.id}
text={rawText}
title={event.title}
/>
)
}
return (
<div className="bubble bubble-assistant bubble-tool-inline" key={event.id}>
<span className="bubble-role">{event.title}</span>
<StreamingMarkdown pulseOnMount={isRunning} text={rawText} />
</div>
)
}
return <NestedToolCard event={event} key={event.id} />
})}
</div>
</div>
) : null}
{activity.detailPreview ? (
<div className="tool-card-section">
<span className="tool-card-label">{activity.status === 'error' ? 'Error' : 'Result'}</span>
<pre>{activity.detailPreview}</pre>
</div>
) : null}
</AnimatedDisclosure>
)
}