|
1 | 1 | /** |
2 | | - * DeepThinkingGroup — outer collapsible wrapper around a contiguous run of |
3 | | - * thinking + tool_call events. Header reads "已深度思考" once the run is closed |
4 | | - * by a following text block (or stream end), or "正在深度思考..." while still |
5 | | - * open. (cofco: the "(用时 N 秒)" duration clause is intentionally not rendered.) |
6 | | - * Collapsing the wrapper hides everything inside, including any inner |
7 | | - * ThinkingContent state. |
| 2 | + * DeepThinkingGroup — renders a contiguous run of thinking + tool_call events |
| 3 | + * as a flat timeline. There is no outer "已深度思考" wrapper anymore: the |
| 4 | + * thinking block and each tool-call card sit at the top level, laid out |
| 5 | + * sequentially, and each node owns its own (collapsed-by-default) toggle. |
| 6 | + * |
| 7 | + * The thinking node surfaces its own live status (正在深度思考… → 已深度思考) |
| 8 | + * via the timing fields computed here from this group's thinking events. |
8 | 9 | */ |
9 | | -import { Outlined } from "bisheng-icons"; |
10 | | -import { |
11 | | - memo, |
12 | | - useCallback, |
13 | | - useEffect, |
14 | | - useMemo, |
15 | | - useRef, |
16 | | - useState, |
17 | | - type FC, |
18 | | - type MouseEvent, |
19 | | -} from "react"; |
| 10 | +import { memo, useMemo, type FC } from "react"; |
20 | 11 | import type { AgentEvent } from "~/api/chatApi"; |
21 | | -import { cn, formatSeconds } from "~/utils"; |
22 | 12 | import ThinkingContent from "./ThinkingContent"; |
23 | 13 | import ToolCallDisplay from "./ToolCallDisplay"; |
24 | 14 |
|
25 | | -const BUTTON_STYLES = { |
26 | | - base: "group flex w-fit items-center gap-1 text-sm font-medium leading-[22px] text-[#212121]", |
27 | | - icon: "shrink-0 transform-gpu text-[#999999] transition-transform duration-200", |
28 | | -} as const; |
29 | | - |
30 | 15 | export interface DeepThinkingGroupProps { |
31 | 16 | /** Ordered events in this group — only thinking + tool_call entries. */ |
32 | 17 | events: AgentEvent[]; |
33 | | - /** True if this group is the currently-open trailing run. */ |
| 18 | + /** True if this group is the currently-open trailing run (message still |
| 19 | + * streaming, no final answer text yet). Drives the thinking node's |
| 20 | + * 正在/已 status. */ |
34 | 21 | isStreaming: boolean; |
35 | 22 | } |
36 | 23 |
|
37 | | -/** Pick the earliest started_at across events; fall back to undefined. */ |
38 | | -function groupStart(events: AgentEvent[]): number | undefined { |
39 | | - let earliest: number | undefined; |
40 | | - for (const ev of events) { |
41 | | - if (ev.type === "thinking" || ev.type === "tool_call") { |
42 | | - if (ev.started_at != null && (earliest == null || ev.started_at < earliest)) { |
43 | | - earliest = ev.started_at; |
44 | | - } |
| 24 | +type ThinkingEvent = Extract<AgentEvent, { type: "thinking" }>; |
| 25 | + |
| 26 | +const DeepThinkingGroup: FC<DeepThinkingGroupProps> = memo(({ events, isStreaming }) => { |
| 27 | + const thinkingEvents = useMemo( |
| 28 | + () => events.filter((e): e is ThinkingEvent => e.type === "thinking"), |
| 29 | + [events], |
| 30 | + ); |
| 31 | + |
| 32 | + // Concatenated reasoning for the inner ThinkingContent block. |
| 33 | + const reasoning = useMemo( |
| 34 | + () => thinkingEvents.map((e) => e.content).join("\n\n"), |
| 35 | + [thinkingEvents], |
| 36 | + ); |
| 37 | + |
| 38 | + const toolCalls = useMemo( |
| 39 | + () => |
| 40 | + events.filter( |
| 41 | + (e): e is Extract<AgentEvent, { type: "tool_call" }> => |
| 42 | + e.type === "tool_call", |
| 43 | + ), |
| 44 | + [events], |
| 45 | + ); |
| 46 | + |
| 47 | + // Timing for the thinking node: span the earliest start → latest end across |
| 48 | + // this group's thinking segments, with a per-segment duration sum fallback. |
| 49 | + const { startedAt, endedAt, durationMs } = useMemo(() => { |
| 50 | + let start: number | undefined; |
| 51 | + let end: number | undefined; |
| 52 | + let sum = 0; |
| 53 | + for (const e of thinkingEvents) { |
| 54 | + if (e.started_at != null && (start == null || e.started_at < start)) start = e.started_at; |
| 55 | + if (e.ended_at != null && (end == null || e.ended_at > end)) end = e.ended_at; |
| 56 | + sum += e.duration_ms ?? 0; |
45 | 57 | } |
46 | | - } |
47 | | - return earliest; |
48 | | -} |
49 | | - |
50 | | -/** Pick the latest ended_at across events. */ |
51 | | -function groupEnd(events: AgentEvent[]): number | undefined { |
52 | | - let latest: number | undefined; |
53 | | - for (const ev of events) { |
54 | | - if (ev.type === "thinking" || ev.type === "tool_call") { |
55 | | - if (ev.ended_at != null && (latest == null || ev.ended_at > latest)) { |
56 | | - latest = ev.ended_at; |
57 | | - } |
58 | | - } |
59 | | - } |
60 | | - return latest; |
61 | | -} |
62 | | - |
63 | | -/** Fallback: sum duration_ms when wall-clock fields aren't on legacy rows. */ |
64 | | -function durationFallback(events: AgentEvent[]): number { |
65 | | - let sum = 0; |
66 | | - for (const ev of events) { |
67 | | - if (ev.type === "thinking" || ev.type === "tool_call") { |
68 | | - sum += ev.duration_ms ?? 0; |
69 | | - } |
70 | | - } |
71 | | - return sum; |
72 | | -} |
73 | | - |
74 | | -const DeepThinkingGroup: FC<DeepThinkingGroupProps> = memo( |
75 | | - ({ events, isStreaming }) => { |
76 | | - // Open while the run is live so the user can watch it; closed for |
77 | | - // already-finished groups (history rows mount with isStreaming false). |
78 | | - const [isExpanded, setIsExpanded] = useState(isStreaming); |
79 | | - |
80 | | - // Auto-collapse the moment the run finishes — i.e. the main answer |
81 | | - // starts streaming and isStreaming flips false — so focus moves to the |
82 | | - // answer body without a manual click. Manual toggling stays intact |
83 | | - // afterward since we only react to the streaming → done falling edge. |
84 | | - const wasStreamingRef = useRef(isStreaming); |
85 | | - useEffect(() => { |
86 | | - if (wasStreamingRef.current && !isStreaming) { |
87 | | - setIsExpanded(false); |
88 | | - } |
89 | | - wasStreamingRef.current = isStreaming; |
90 | | - }, [isStreaming]); |
91 | | - |
92 | | - const start = groupStart(events); |
93 | | - const end = groupEnd(events); |
94 | | - |
95 | | - // Live-tick while streaming so the header counter advances every 100ms. |
96 | | - const [tick, setTick] = useState(0); |
97 | | - useEffect(() => { |
98 | | - if (!isStreaming) return; |
99 | | - const id = window.setInterval(() => setTick((t) => t + 1), 100); |
100 | | - return () => window.clearInterval(id); |
101 | | - }, [isStreaming]); |
102 | | - |
103 | | - const elapsedMs = (() => { |
104 | | - if (start == null) return durationFallback(events); |
105 | | - // For closed groups with no end_at, fall back to the per-event sum |
106 | | - // so the label doesn't creep upward against Date.now(). |
107 | | - if (!isStreaming && end == null) return durationFallback(events); |
108 | | - const stop = isStreaming ? Date.now() : end!; |
109 | | - return Math.max(0, stop - start); |
110 | | - })(); |
111 | | - // `tick` is read here so the IIFE re-runs on every interval render. |
112 | | - void tick; |
113 | | - |
114 | | - // cofco: never render the "(用时 N 秒)" clause — the label stays plain. |
115 | | - const label = isStreaming ? `正在深度思考...` : `已深度思考`; |
116 | | - |
117 | | - const handleClick = useCallback((e: MouseEvent<HTMLButtonElement>) => { |
118 | | - e.preventDefault(); |
119 | | - setIsExpanded((prev) => !prev); |
120 | | - }, []); |
121 | | - |
122 | | - // Pull thinking content (concat) for the inner ThinkingContent block. |
123 | | - const reasoning = useMemo( |
124 | | - () => |
125 | | - events |
126 | | - .filter( |
127 | | - (e): e is Extract<AgentEvent, { type: "thinking" }> => |
128 | | - e.type === "thinking", |
129 | | - ) |
130 | | - .map((e) => e.content) |
131 | | - .join("\n\n"), |
132 | | - [events], |
133 | | - ); |
134 | | - |
135 | | - const toolCalls = useMemo( |
136 | | - () => |
137 | | - events.filter( |
138 | | - (e): e is Extract<AgentEvent, { type: "tool_call" }> => |
139 | | - e.type === "tool_call", |
140 | | - ), |
141 | | - [events], |
142 | | - ); |
143 | | - |
144 | | - return ( |
145 | | - <div className="flex w-full min-w-0 flex-col gap-3"> |
146 | | - <button |
147 | | - type="button" |
148 | | - onClick={handleClick} |
149 | | - className={cn(BUTTON_STYLES.base, isStreaming && "animate-pulse")} |
150 | | - > |
151 | | - <span>{label}</span> |
152 | | - <Outlined.Down |
153 | | - size={16} |
154 | | - className={cn(BUTTON_STYLES.icon, !isExpanded && "-rotate-90")} |
155 | | - /> |
156 | | - </button> |
157 | | - <div |
158 | | - className="grid transition-all duration-300 ease-out" |
159 | | - style={{ gridTemplateRows: isExpanded ? "1fr" : "0fr" }} |
160 | | - > |
161 | | - <div className="overflow-hidden flex flex-col gap-2"> |
162 | | - <ThinkingContent |
163 | | - reasoning={reasoning} |
164 | | - showConnector={!!reasoning && toolCalls.length > 0} |
165 | | - /> |
166 | | - {toolCalls.map((tc, i) => ( |
167 | | - <ToolCallDisplay |
168 | | - key={tc.tool_call_id || `tc-${i}`} |
169 | | - toolCall={tc} |
170 | | - showConnector={i < toolCalls.length - 1} |
171 | | - /> |
172 | | - ))} |
173 | | - </div> |
174 | | - </div> |
175 | | - </div> |
176 | | - ); |
177 | | - }, |
178 | | -); |
| 58 | + return { startedAt: start, endedAt: end, durationMs: sum }; |
| 59 | + }, [thinkingEvents]); |
| 60 | + |
| 61 | + // The reasoning is still "live" only while the group streams AND the last |
| 62 | + // thinking segment hasn't closed (once a tool starts, that segment carries |
| 63 | + // an ended_at and the node settles to "已深度思考"). |
| 64 | + const lastThinking = thinkingEvents[thinkingEvents.length - 1]; |
| 65 | + const thinkingStreaming = isStreaming && !!lastThinking && lastThinking.ended_at == null; |
| 66 | + |
| 67 | + return ( |
| 68 | + <div className="flex w-full min-w-0 flex-col gap-3"> |
| 69 | + <ThinkingContent |
| 70 | + reasoning={reasoning} |
| 71 | + isStreaming={thinkingStreaming} |
| 72 | + startedAt={startedAt} |
| 73 | + endedAt={endedAt} |
| 74 | + durationMs={durationMs} |
| 75 | + /> |
| 76 | + {toolCalls.map((tc, i) => ( |
| 77 | + <ToolCallDisplay key={tc.tool_call_id || `tc-${i}`} toolCall={tc} /> |
| 78 | + ))} |
| 79 | + </div> |
| 80 | + ); |
| 81 | +}); |
179 | 82 |
|
180 | 83 | DeepThinkingGroup.displayName = "DeepThinkingGroup"; |
181 | 84 |
|
|
0 commit comments