'use client'; /** * Reasoning ("thinking") panel for the editor agent. Renders the model's * reasoning (recovered from inline / reasoning_content and split out by * extractReasoningMiddleware) as a collapsible block, separate from the answer * text, with how long the model spent on THIS block. * * A multi-step agent reasons several times (read → reason → edit → reason → * answer), so a message can hold multiple reasoning blocks. Each gets its own * timer keyed `${messageId}:${ordinal}`: an earlier block freezes once a later * part follows it; the last block ticks live until something follows or the run * finalizes. The ordinal is this block's position among the message's reasoning * parts (matched by text), mirroring how the runtime keys the timers. */ import { useEffect, useState } from 'react'; import { useMessage } from '@assistant-ui/react'; import { Brain, ChevronRight } from 'lucide-react'; import { cn } from '@/lib/utils/cn'; import { useI18n } from '@/lib/hooks/use-i18n'; import { useThinkingTimers, formatThinkDuration } from '@/lib/agent/client/thinking-timers'; export function ReasoningPart({ text }: { text: string }) { const { t } = useI18n(); const id = useMessage((m) => m.id); const running = useMessage((m) => m.status?.type === 'running'); // This block's ordinal among the message's reasoning parts. Matched by text, // but assistant-ui's text smoothing can momentarily desync the `text` prop from // the message snapshot — a -1 there would flip the timer lookup on and off and // make the label flicker, so we fall back to the active (last) reasoning block. const ordinal = useMessage((m) => { const rs = (m.content as Array<{ type: string; text?: string }>).filter( (p) => p.type === 'reasoning', ); const i = rs.findIndex((p) => p.text === text); return i >= 0 ? i : Math.max(0, rs.length - 1); }); const timer = useThinkingTimers((s) => s.timers[`${id}:${ordinal}`]); const [open, setOpen] = useState(false); // This block was interrupted if the run ended `incomplete` (stopped/cancelled // or errored) while it was the active block — i.e. it's the LAST reasoning // block and the final content part (nothing followed it). Earlier blocks that // finished before the stop keep their normal "已思考" label. const interrupted = useMessage((m) => { if (m.status?.type !== 'incomplete') return false; const c = m.content as readonly { type: string }[]; if (c.length === 0 || c[c.length - 1].type !== 'reasoning') return false; return c.filter((p) => p.type === 'reasoning').length - 1 === ordinal; }); const startedAt = timer?.startedAt; const endedAt = timer?.endedAt; // Label is decided by run state + whether the block ended — NOT by timer // presence — so a transient missing timer never flips it to "思考过程" mid-run. const live = running && endedAt == null; const ticking = live && startedAt != null; // The live elapsed is driven by a 1s interval into STATE — never recomputed // from Date.now() on each render. This component re-renders on every streamed // reasoning delta (thousands of times); recomputing the elapsed per render made // the counter jump erratically. Now it advances once per second, independent of // re-renders, so the number ticks smoothly. const [tickMs, setTickMs] = useState(0); useEffect(() => { if (!ticking || startedAt == null) return; // Deliberate one-shot init so the counter shows the right elapsed value // before the first 1s tick; the interval below keeps it updated. // eslint-disable-next-line react-hooks/set-state-in-effect setTickMs(Date.now() - startedAt); const h = setInterval(() => setTickMs(Date.now() - startedAt), 1000); return () => clearInterval(h); }, [ticking, startedAt]); if (!text) return null; // While ticking: whole seconds from interval state (calm). When frozen: the // precise final duration. const dur = ticking ? `${Math.max(0, Math.floor(tickMs / 1000))}s` : startedAt != null && endedAt != null ? formatThinkDuration(endedAt - startedAt) : ''; const word = interrupted ? t('edit.agent.stopped') : endedAt != null ? t('edit.agent.thought') : live ? t('edit.agent.thinking') : t('edit.agent.reasoning'); return (
{open ? (
{text}
) : null}
); }