mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
405 lines
18 KiB
TypeScript
405 lines
18 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
import type { ToolGroupBlock } from '@/chat/toolGroups'
|
|
import type { ToolCallBlock } from '@/chat/types'
|
|
import { getCodexCommandActions, type CodexCommandAction } from '@/chat/codexCommandPresentation'
|
|
import type { SessionMetadataSummary } from '@/types/api'
|
|
import { useHappyChatContext } from '@/components/AssistantChat/context'
|
|
import { ToolDetailDialogContent, ToolStatusIcon, toolStatusColorClass } from '@/components/ToolCard/ToolCard'
|
|
import { getToolPresentation } from '@/components/ToolCard/knownTools'
|
|
import { formatGroupedHeaderSubtitle, formatGroupedHeaderTitle, safeGroupedLabelValue } from '@/components/ToolCard/groupedPresentation'
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
|
import { cn } from '@/lib/utils'
|
|
import { useTranslation } from '@/lib/use-translation'
|
|
|
|
function DetailsIcon(props: { open: boolean }) {
|
|
return (
|
|
<svg className={cn('h-4 w-4 transition-transform duration-200', props.open ? 'rotate-90' : null)} viewBox="0 0 16 16" fill="none" data-state={props.open ? 'open' : 'closed'}>
|
|
<path d="M6 3l5 5-5 5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
function SummaryBadge(props: { className: string; text: string }) {
|
|
return (
|
|
<span className={cn('inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium', props.className)}>
|
|
{props.text}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
function RowStatusBadge(props: { block: ToolCallBlock }) {
|
|
const { t } = useTranslation()
|
|
if (props.block.tool.state === 'error') {
|
|
return <SummaryBadge className="bg-red-500/10 text-red-600" text={t('toolGroup.rowStatus.error')} />
|
|
}
|
|
if (props.block.tool.state === 'running') {
|
|
return <SummaryBadge className="bg-sky-500/10 text-sky-600" text={t('toolGroup.rowStatus.running')} />
|
|
}
|
|
if (props.block.tool.state === 'pending') {
|
|
return <SummaryBadge className="bg-amber-500/10 text-amber-700" text={t('toolGroup.rowStatus.pending')} />
|
|
}
|
|
return null
|
|
}
|
|
|
|
function formatActionSummary(block: ToolGroupBlock, t: (key: string, params?: Record<string, string | number>) => string): string | null {
|
|
const parts: string[] = []
|
|
const { countsByKind } = block.summary
|
|
|
|
if (countsByKind.mutation > 0) {
|
|
parts.push(t('toolGroup.summary.mutation', { n: countsByKind.mutation }))
|
|
}
|
|
if (countsByKind.read > 0) {
|
|
parts.push(t('toolGroup.summary.read', { n: countsByKind.read }))
|
|
}
|
|
if (countsByKind.command > 0) {
|
|
parts.push(t('toolGroup.summary.command', { n: countsByKind.command }))
|
|
}
|
|
if (countsByKind.search > 0) {
|
|
parts.push(t('toolGroup.summary.search', { n: countsByKind.search }))
|
|
}
|
|
if (countsByKind.web > 0) {
|
|
parts.push(t('toolGroup.summary.web', { n: countsByKind.web }))
|
|
}
|
|
if (countsByKind.other > 0 && parts.length > 0) {
|
|
parts.push(t('toolGroup.summary.other', { n: countsByKind.other }))
|
|
}
|
|
|
|
return parts.length > 0 ? parts.join(' · ') : null
|
|
}
|
|
|
|
function RowLabel(props: { block: ToolCallBlock; metadata: SessionMetadataSummary | null }) {
|
|
const { t } = useTranslation()
|
|
const presentation = useMemo(() => getToolPresentation({
|
|
toolName: props.block.tool.name,
|
|
input: props.block.tool.input,
|
|
result: props.block.tool.result,
|
|
childrenCount: props.block.children.length,
|
|
description: props.block.tool.nativeTitle ?? props.block.tool.description,
|
|
metadata: props.metadata
|
|
}, t), [props.block, props.metadata, t])
|
|
|
|
return (
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<div className="flex h-3.5 w-3.5 shrink-0 items-center justify-center text-[var(--app-tool-card-accent)] leading-none">
|
|
{presentation.icon}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="truncate whitespace-nowrap text-sm font-medium text-[var(--app-fg)]">
|
|
{presentation.title}
|
|
</div>
|
|
{presentation.subtitle ? (
|
|
<div className="truncate whitespace-nowrap font-mono text-xs text-[var(--app-tool-card-subtitle)]">
|
|
{presentation.subtitle}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function basename(value: string): string {
|
|
return value.replace(/\\/g, '/').split('/').filter(Boolean).at(-1) ?? value
|
|
}
|
|
|
|
function codexActionLabel(
|
|
action: CodexCommandAction,
|
|
t: (key: string, params?: Record<string, string | number>) => string
|
|
): { title: string; detail: string | null } {
|
|
if (action.type === 'read') {
|
|
const detail = safeGroupedLabelValue(action.name) ?? safeGroupedLabelValue(action.path)
|
|
return { title: t('toolGroup.codex.read'), detail: detail ? basename(detail) : null }
|
|
}
|
|
if (action.type === 'listFiles') {
|
|
return { title: t('toolGroup.codex.list'), detail: safeGroupedLabelValue(action.path) }
|
|
}
|
|
if (action.type === 'search') {
|
|
const query = safeGroupedLabelValue(action.query)
|
|
const path = safeGroupedLabelValue(action.path)
|
|
return {
|
|
title: t('toolGroup.codex.search'),
|
|
detail: query && path
|
|
? t('toolGroup.codex.searchIn', { query, path })
|
|
: query ?? path
|
|
}
|
|
}
|
|
return { title: t('toolGroup.friendly.genericCommand'), detail: null }
|
|
}
|
|
|
|
function CodexExplorationRows(props: {
|
|
tools: ToolCallBlock[]
|
|
onSelect: (toolId: string) => void
|
|
}) {
|
|
const { t } = useTranslation()
|
|
return props.tools.flatMap((tool) => (
|
|
getCodexCommandActions(tool).map((action, index) => {
|
|
const label = codexActionLabel(action, t)
|
|
return (
|
|
<button
|
|
key={`${tool.id}:${index}`}
|
|
type="button"
|
|
className="flex min-w-0 items-start gap-2 rounded-lg px-2 py-1 text-left hover:bg-[var(--app-subtle-bg)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)]"
|
|
onClick={() => props.onSelect(tool.id)}
|
|
>
|
|
<span className="mt-1 text-xs text-[var(--app-hint)]">└</span>
|
|
<span className="shrink-0 text-sm font-medium text-[var(--app-tool-card-accent)]">
|
|
{label.title}
|
|
</span>
|
|
{label.detail ? (
|
|
<span className="min-w-0 truncate text-sm text-[var(--app-fg)]">
|
|
{label.detail}
|
|
</span>
|
|
) : null}
|
|
</button>
|
|
)
|
|
})
|
|
))
|
|
}
|
|
|
|
export function ToolGroupCard(props: {
|
|
block: ToolGroupBlock
|
|
metadata: SessionMetadataSummary | null
|
|
}) {
|
|
const { t } = useTranslation()
|
|
const ctx = useHappyChatContext()
|
|
const [open, setOpen] = useState(props.block.defaultOpen)
|
|
const [selectedToolId, setSelectedToolId] = useState<string | null>(null)
|
|
const [isHydratingHistory, setIsHydratingHistory] = useState(false)
|
|
const [historyExhausted, setHistoryExhausted] = useState(false)
|
|
const [retryNonce, setRetryNonce] = useState(0)
|
|
const hydrationRunRef = useRef(0)
|
|
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
|
|
function clearRetryTimer() {
|
|
if (retryTimerRef.current === null) {
|
|
return
|
|
}
|
|
clearTimeout(retryTimerRef.current)
|
|
retryTimerRef.current = null
|
|
}
|
|
|
|
useEffect(() => {
|
|
clearRetryTimer()
|
|
hydrationRunRef.current += 1
|
|
setOpen(props.block.defaultOpen)
|
|
setSelectedToolId(null)
|
|
setIsHydratingHistory(false)
|
|
setHistoryExhausted(false)
|
|
}, [props.block.id, props.block.defaultOpen])
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
clearRetryTimer()
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (!open) {
|
|
clearRetryTimer()
|
|
hydrationRunRef.current += 1
|
|
setIsHydratingHistory(false)
|
|
setHistoryExhausted(false)
|
|
return
|
|
}
|
|
if (!props.block.needsOlderHistory) {
|
|
clearRetryTimer()
|
|
hydrationRunRef.current += 1
|
|
setIsHydratingHistory(false)
|
|
setHistoryExhausted(false)
|
|
return
|
|
}
|
|
if (isHydratingHistory || historyExhausted) {
|
|
return
|
|
}
|
|
if (ctx.isLoadingMoreMessages) {
|
|
return
|
|
}
|
|
if (!ctx.hasMoreMessages) {
|
|
hydrationRunRef.current += 1
|
|
setIsHydratingHistory(false)
|
|
setHistoryExhausted(true)
|
|
return
|
|
}
|
|
|
|
const runId = hydrationRunRef.current + 1
|
|
hydrationRunRef.current = runId
|
|
setHistoryExhausted(false)
|
|
setIsHydratingHistory(true)
|
|
void ctx.loadOlderMessagesPreservingScroll()
|
|
.then((loaded) => {
|
|
if (hydrationRunRef.current !== runId) return
|
|
setIsHydratingHistory(false)
|
|
if (!loaded) {
|
|
if (!ctx.hasMoreMessages) {
|
|
setHistoryExhausted(true)
|
|
return
|
|
}
|
|
clearRetryTimer()
|
|
retryTimerRef.current = setTimeout(() => {
|
|
retryTimerRef.current = null
|
|
if (hydrationRunRef.current !== runId) return
|
|
setRetryNonce((value) => value + 1)
|
|
}, 150)
|
|
}
|
|
})
|
|
.catch(() => {
|
|
if (hydrationRunRef.current !== runId) return
|
|
clearRetryTimer()
|
|
setIsHydratingHistory(false)
|
|
setHistoryExhausted(true)
|
|
})
|
|
}, [
|
|
open,
|
|
props.block.needsOlderHistory,
|
|
ctx.hasMoreMessages,
|
|
ctx.isLoadingMoreMessages,
|
|
ctx.loadOlderMessagesPreservingScroll,
|
|
historyExhausted,
|
|
isHydratingHistory,
|
|
retryNonce,
|
|
])
|
|
|
|
const selectedTool = useMemo(
|
|
() => props.block.tools.find((tool) => tool.id === selectedToolId) ?? null,
|
|
[props.block.tools, selectedToolId]
|
|
)
|
|
const selectedPresentation = useMemo(() => {
|
|
if (!selectedTool) return null
|
|
return getToolPresentation({
|
|
toolName: selectedTool.tool.name,
|
|
input: selectedTool.tool.input,
|
|
result: selectedTool.tool.result,
|
|
childrenCount: selectedTool.children.length,
|
|
description: selectedTool.tool.nativeTitle ?? selectedTool.tool.description,
|
|
metadata: props.metadata
|
|
}, t)
|
|
}, [selectedTool, props.metadata, t])
|
|
|
|
const primaryTitle = formatGroupedHeaderTitle(props.block, t)
|
|
const subtitle = props.block.presentationMode === 'codex-exploration'
|
|
? null
|
|
: formatGroupedHeaderSubtitle(props.block, t) ?? formatActionSummary(props.block, t)
|
|
const fileCount = props.block.summary.fileTargets.length
|
|
|
|
return (
|
|
<Card className="overflow-hidden rounded-[20px] bg-[var(--app-tool-group-bg)] shadow-none">
|
|
<CardHeader className={cn('space-y-0 p-3', subtitle ? 'pb-2' : null)}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen((value) => !value)}
|
|
className="w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)]"
|
|
aria-expanded={open}
|
|
>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="min-w-0 flex flex-1 flex-col gap-1">
|
|
<div className="min-w-0 flex items-center gap-2">
|
|
<div className="shrink-0 flex h-3.5 w-3.5 items-center justify-center text-[var(--app-tool-card-accent)] leading-none">
|
|
<DetailsIcon open={open} />
|
|
</div>
|
|
<CardTitle className="min-w-0 truncate whitespace-nowrap text-sm font-medium leading-tight text-[var(--app-fg)]">
|
|
{primaryTitle}
|
|
</CardTitle>
|
|
</div>
|
|
{subtitle ? (
|
|
<CardDescription className="truncate whitespace-nowrap font-mono text-xs text-[var(--app-tool-card-subtitle)]">
|
|
{subtitle}
|
|
</CardDescription>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="flex shrink-0 items-center gap-2 self-center text-[var(--app-hint)]">
|
|
{props.block.presentationMode !== 'codex-exploration' ? (
|
|
<SummaryBadge
|
|
className="bg-[var(--app-subtle-bg)] text-[var(--app-hint)]"
|
|
text={t('toolGroup.toolCount', { n: props.block.tools.length })}
|
|
/>
|
|
) : null}
|
|
{props.block.summary.runningCount > 0 ? (
|
|
<SummaryBadge
|
|
className="bg-sky-500/10 text-sky-600"
|
|
text={t('toolGroup.badge.running', { n: props.block.summary.runningCount })}
|
|
/>
|
|
) : null}
|
|
{props.block.summary.pendingCount > 0 ? (
|
|
<SummaryBadge
|
|
className="bg-amber-500/10 text-amber-700"
|
|
text={t('toolGroup.badge.pending', { n: props.block.summary.pendingCount })}
|
|
/>
|
|
) : null}
|
|
{props.block.summary.errorCount > 0 ? (
|
|
<SummaryBadge
|
|
className="bg-red-500/10 text-red-600"
|
|
text={t('toolGroup.badge.error', { n: props.block.summary.errorCount })}
|
|
/>
|
|
) : null}
|
|
{fileCount > 0 ? (
|
|
<SummaryBadge
|
|
className="bg-[var(--app-subtle-bg)] text-[var(--app-hint)]"
|
|
text={t('toolGroup.badge.fileTargets', { n: fileCount })}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
</CardHeader>
|
|
|
|
{open ? (
|
|
<CardContent className="px-3 pb-3 pt-1">
|
|
<div className="flex flex-col gap-2">
|
|
{props.block.presentationMode === 'codex-exploration' ? (
|
|
<CodexExplorationRows tools={props.block.tools} onSelect={setSelectedToolId} />
|
|
) : props.block.tools.map((tool) => {
|
|
return (
|
|
<button
|
|
key={tool.id}
|
|
type="button"
|
|
className="flex items-center gap-3 rounded-[16px] border border-[var(--app-border)] bg-[var(--app-bg)] px-3 py-2 text-left transition-colors hover:bg-[var(--app-subtle-bg)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)]"
|
|
onClick={() => setSelectedToolId(tool.id)}
|
|
>
|
|
<span className={cn('shrink-0', toolStatusColorClass(tool.tool.state))}>
|
|
<ToolStatusIcon state={tool.tool.state} />
|
|
</span>
|
|
<RowLabel block={tool} metadata={props.metadata} />
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
<RowStatusBadge block={tool} />
|
|
</div>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{isHydratingHistory ? (
|
|
<div className="mt-3 text-xs text-[var(--app-hint)]">
|
|
{t('toolGroup.loadingOlderHistory')}
|
|
</div>
|
|
) : null}
|
|
{!isHydratingHistory && historyExhausted && props.block.needsOlderHistory ? (
|
|
<div className="mt-3 text-xs text-[var(--app-hint)]">
|
|
{t('toolGroup.historyUnavailable')}
|
|
</div>
|
|
) : null}
|
|
</CardContent>
|
|
) : null}
|
|
|
|
<Dialog open={selectedTool !== null} onOpenChange={(nextOpen) => {
|
|
if (!nextOpen) {
|
|
setSelectedToolId(null)
|
|
}
|
|
}}>
|
|
<DialogContent className="max-w-2xl" aria-describedby={undefined}>
|
|
{selectedTool && selectedPresentation ? (
|
|
<>
|
|
<DialogHeader>
|
|
<DialogTitle>{selectedPresentation.title}</DialogTitle>
|
|
</DialogHeader>
|
|
<ToolDetailDialogContent block={selectedTool} metadata={props.metadata} />
|
|
</>
|
|
) : null}
|
|
</DialogContent>
|
|
</Dialog>
|
|
</Card>
|
|
)
|
|
}
|