Files
wehub-resource-sync c48612c494
CI / E2E Tests (push) Has been cancelled
CI / Lint, Typecheck & Unit Tests (push) Has been cancelled
Docs Build / Build docs site (push) Has been cancelled
Publish @openmaic packages / Build, validate & publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:23 +08:00

124 lines
5.6 KiB
TypeScript

'use client';
/**
* Reasoning ("thinking") panel for the editor agent. Renders the model's
* reasoning (recovered from inline <think> / 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 (
<div
className={cn(
'rounded-lg border',
interrupted
? 'border-neutral-200 bg-neutral-50/60 dark:border-neutral-700/60 dark:bg-neutral-800/30'
: 'border-violet-100/70 bg-violet-50/30 dark:border-violet-500/15 dark:bg-violet-500/[0.04]',
)}
>
<button
type="button"
onClick={() => setOpen((o) => !o)}
className={cn(
'flex w-full items-center gap-1.5 px-2.5 py-1.5 text-left text-[12px] font-medium transition-colors',
interrupted
? 'text-muted-foreground/70 hover:text-muted-foreground'
: 'text-[#5b1fa8]/80 hover:text-[#5b1fa8] dark:text-violet-300/80 dark:hover:text-violet-200',
)}
>
<ChevronRight className={cn('size-3 shrink-0 transition-transform', open && 'rotate-90')} />
<Brain className="size-3 shrink-0" />
<span className={cn('shrink-0', ticking && 'ai-thinking-shimmer')}>{word}</span>
{dur ? <span className="shrink-0 tabular-nums opacity-70">{dur}</span> : null}
</button>
{open ? (
<div className="whitespace-pre-wrap break-words border-t border-violet-100/60 px-2.5 py-2 text-[12px] leading-relaxed text-muted-foreground dark:border-violet-500/10">
{text}
</div>
) : null}
</div>
);
}