fix(chat): keep every iteration reasoning and render the timeline in emission order

This commit is contained in:
matevip 2026-08-06 05:13:09 -04:00
parent 30560eb7cf
commit 22c49f3a07
11 changed files with 328 additions and 47 deletions

View File

@ -208,6 +208,7 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
AtomicBoolean finalAnswerEmitted = new AtomicBoolean(false);
AtomicBoolean finalThinkingEmitted = new AtomicBoolean(false);
AtomicReference<String> lastEmittedStreamedContent = new AtomicReference<>("");
AtomicReference<String> lastEmittedIterationThinking = new AtomicReference<>("");
// Silent-termination guard (mirrors chatStructuredStream)
AtomicInteger lastIteration = new AtomicInteger(0);
AtomicInteger lastSoftCap = new AtomicInteger(0);
@ -229,6 +230,47 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
boolean contentAlreadyStreamed = output.state().value(CONTENT_STREAMED, false);
boolean thinkingAlreadyStreamed = output.state().value(THINKING_STREAMED, false);
// Thinking is emitted BEFORE any content delta of the same
// batch. The reasoning that produced an answer precedes the
// answer, and the accumulator builds its segment timeline in
// delta arrival order emitting thinking last appended a
// thinking segment after the content segment, which readers
// then had to reorder. FINAL_THINKING and FINAL_ANSWER are
// written by the same node output, so ordering them here is
// enough to make the persisted timeline match reality.
//
// Every iteration's reasoning is persisted, not just the
// terminal one. A tool-calling iteration parks its reasoning
// in STREAMED_THINKING (REPLACE, one value per node), which
// the live channel already broadcast persistOnly carries it
// into the accumulator without a second broadcast. Without
// this, a turn that ran N tool rounds kept only the last
// round's thinking, so the persisted turn read as a bare
// conclusion and the reasoning that justified each tool call
// survived nowhere.
//
// The cursor tracks STREAMED_THINKING and nothing else. A
// shared cursor would let the stale value re-qualify: the key
// keeps its last write for the rest of the run, so once an
// unrelated emission moved a shared cursor past it, the same
// span was emitted a second time after the final answer,
// since the later nodes run after the answer was streamed.
String iterationThinking = output.state().<String>value(STREAMED_THINKING).orElse("");
if (!iterationThinking.isEmpty()
&& !iterationThinking.equals(lastEmittedIterationThinking.get())) {
lastEmittedIterationThinking.set(iterationThinking);
deltas.add(AgentService.StreamDelta.persistOnly(null, iterationThinking));
}
String thinking = extractFinalThinking(output);
if (thinking != null && !thinking.isEmpty()
&& !thinking.equals(lastEmittedIterationThinking.get())
&& finalThinkingEmitted.compareAndSet(false, true)) {
deltas.add(thinkingAlreadyStreamed
? AgentService.StreamDelta.persistOnly(null, thinking)
: new AgentService.StreamDelta(null, thinking));
}
// Route per-iteration STREAMED_CONTENT (reasoning preamble +
// SummarizingNode output) into segments only final-answer
// text arrives via the FINAL_ANSWER branch below. Pre-#120
@ -264,13 +306,6 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
addWithKindEvent(deltas, AgentService.StreamDelta.finalAnswer(answer, contentAlreadyStreamed));
}
}
String thinking = extractFinalThinking(output);
if (thinking != null && !thinking.isEmpty()
&& finalThinkingEmitted.compareAndSet(false, true)) {
deltas.add(thinkingAlreadyStreamed
? AgentService.StreamDelta.persistOnly(null, thinking)
: new AgentService.StreamDelta(null, thinking));
}
finalPromptTokens.set(output.state().value(PROMPT_TOKENS, 0));
finalCompletionTokens.set(output.state().value(COMPLETION_TOKENS, 0));
@ -368,6 +403,9 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
// compareAndSet 保证只取第一次避免 content/thinking 被重复追加
AtomicBoolean finalAnswerEmitted = new AtomicBoolean(false);
AtomicBoolean finalThinkingEmitted = new AtomicBoolean(false);
// STREAMED_CONTENTSTREAMED_THINKING 也是 REPLACE用独立游标
// 跟踪已持久化的每轮 thinking避免后续节点的 NodeOutput 重复发送
AtomicReference<String> lastEmittedIterationThinking = new AtomicReference<>("");
// STREAMED_CONTENT REPLACE 策略每轮 ReasoningNode/SummarizingNode 覆写
// lastEmitted 跟踪已发送的值避免在 ActionNode/ObservationNode NodeOutput 上重复发送同一段内容
AtomicReference<String> lastEmittedStreamedContent = new AtomicReference<>("");
@ -404,7 +442,30 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
boolean thinkingAlreadyStreamed = output.state()
.value(THINKING_STREAMED, false);
// 2a. Route per-iteration narrative into the segments timeline
// 2a. Thinking first see the ordering note in
// chatStructuredStream. The accumulator builds its
// segment timeline in delta arrival order, so the
// reasoning must be emitted ahead of the answer it
// produced. Every iteration's reasoning is persisted
// see the note in chatStructuredStream for why the
// terminal one alone is not enough.
String iterationThinking = output.state().<String>value(STREAMED_THINKING).orElse("");
if (!iterationThinking.isEmpty()
&& !iterationThinking.equals(lastEmittedIterationThinking.get())) {
lastEmittedIterationThinking.set(iterationThinking);
deltas.add(AgentService.StreamDelta.persistOnly(null, iterationThinking));
}
String thinking = extractFinalThinking(output);
if (thinking != null && !thinking.isEmpty()
&& !thinking.equals(lastEmittedIterationThinking.get())
&& finalThinkingEmitted.compareAndSet(false, true)) {
deltas.add(thinkingAlreadyStreamed
? AgentService.StreamDelta.persistOnly(null, thinking)
: new AgentService.StreamDelta(null, thinking));
}
// 2b. Route per-iteration narrative into the segments timeline
// so the segmented UI view still shows "我来…" preludes
// between tool cards, but keep the top-level content
// field (= persisted mate_message.content) reserved for
@ -437,14 +498,6 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
}
}
String thinking = extractFinalThinking(output);
if (thinking != null && !thinking.isEmpty()
&& finalThinkingEmitted.compareAndSet(false, true)) {
deltas.add(thinkingAlreadyStreamed
? AgentService.StreamDelta.persistOnly(null, thinking)
: new AgentService.StreamDelta(null, thinking));
}
// 3. 更新最新累计 token usage
finalPromptTokens.set(output.state().value(PROMPT_TOKENS, 0));
finalCompletionTokens.set(output.state().value(COMPLETION_TOKENS, 0));

View File

@ -139,7 +139,12 @@ public class FinalAnswerNode implements NodeAction {
} else if (!existingAnswer.isEmpty()) {
// 来自 reasoning 直接回答 stopped partial
finalAnswer = existingAnswer;
finalThinking = !currentThinking.isEmpty() ? currentThinking : existingThinking;
// FINAL_THINKING wins here: every writer of FINAL_ANSWER on this path
// sets it in the same node output, so it is the reasoning that produced
// this very answer. CURRENT_THINKING is REPLACE and was last written by
// a tool-calling iteration, so preferring it swapped in an earlier
// round's reasoning on any turn that used tools.
finalThinking = !existingThinking.isEmpty() ? existingThinking : currentThinking;
// 尊重上游已设的 finishReason STOPPED只有未设时才默认 NORMAL
finishReason = !existingReason.isEmpty() ? parseFinishReason(existingReason) : FinishReason.NORMAL;
log.info("[FinalAnswerNode] Using existing finalAnswer ({} chars), reason={}",

View File

@ -179,14 +179,9 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
// 2a. 各步骤执行结果StepExecutionNode 已通过 NodeStreamingChatHelper 直推 SSE
// 这里仅作为 persistOnly 送入 Accumulator确保写入 mate_message
// 利用内容本身去重避免 PlanSummaryNode 输出时重复 emit 上一步残留在 state 的值
output.state().<String>value(PlanStateKeys.CURRENT_STEP_RESULT)
.filter(s -> !s.isEmpty())
.filter(s -> !s.equals(lastPersistedStepResult.get()))
.ifPresent(stepContent -> {
deltas.add(AgentService.StreamDelta.persistOnly(stepContent, null));
lastPersistedStepResult.set(stepContent);
});
// Within each pair the thinking is emitted first: the
// accumulator orders its segment timeline by delta arrival,
// and the reasoning behind a step precedes the step's result.
output.state().<String>value(PlanStateKeys.CURRENT_STEP_THINKING)
.filter(s -> !s.isEmpty())
.filter(s -> !s.equals(lastPersistedStepThinking.get()))
@ -195,19 +190,27 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
lastPersistedStepThinking.set(stepThinking);
});
// 2b. 最终汇总
output.state().<String>value(PlanStateKeys.FINAL_SUMMARY)
output.state().<String>value(PlanStateKeys.CURRENT_STEP_RESULT)
.filter(s -> !s.isEmpty())
.ifPresent(summary -> deltas.add(contentAlreadyStreamed
? AgentService.StreamDelta.persistOnly(summary, null)
: new AgentService.StreamDelta(summary, null)));
.filter(s -> !s.equals(lastPersistedStepResult.get()))
.ifPresent(stepContent -> {
deltas.add(AgentService.StreamDelta.persistOnly(stepContent, null));
lastPersistedStepResult.set(stepContent);
});
// 2b. 最终汇总同样 thinking 先于 content
output.state().<String>value(PlanStateKeys.FINAL_SUMMARY_THINKING)
.filter(s -> !s.isEmpty())
.ifPresent(thinking -> deltas.add(thinkingAlreadyStreamed
? AgentService.StreamDelta.persistOnly(null, thinking)
: new AgentService.StreamDelta(null, thinking)));
output.state().<String>value(PlanStateKeys.FINAL_SUMMARY)
.filter(s -> !s.isEmpty())
.ifPresent(summary -> deltas.add(contentAlreadyStreamed
? AgentService.StreamDelta.persistOnly(summary, null)
: new AgentService.StreamDelta(summary, null)));
// 3. 更新最新累计 token usage
finalPromptTokens.set(output.state().value(MateClawStateKeys.PROMPT_TOKENS, 0));
finalCompletionTokens.set(output.state().value(MateClawStateKeys.COMPLETION_TOKENS, 0));

View File

@ -212,13 +212,22 @@ public final class AgentStreamAccumulator {
// thinking_delta
if (delta.thinking() != null && !delta.thinking().isBlank()) {
var seg = findLastRunning("thinking");
// No running thinking segment means this delta opens a new reasoning
// span (a fresh iteration, after a tool call closed the previous one).
// The flat `thinking` field concatenates every span of the turn, so
// without a break the spans glue into one run-on paragraph spans
// are separate thoughts and read as such only when kept apart.
boolean opensNewSpan = seg == null;
if (!delta.segmentOnly()) {
if (opensNewSpan && thinking.length() > 0) {
thinking.append("\n\n");
}
thinking.append(delta.thinking());
}
if (!delta.persistenceOnly()) {
sink.broadcast(conversationId, "thinking_delta", Map.of("delta", delta.thinking()));
}
var seg = findLastRunning("thinking");
if (seg != null) {
seg.put("thinkingText", seg.getOrDefault("thinkingText", "") + delta.thinking());
} else {
@ -394,8 +403,15 @@ public final class AgentStreamAccumulator {
private Map<String, Object> newSegment(String type) {
Map<String, Object> seg = new LinkedHashMap<>();
seg.put("id", type.substring(0, 2) + "-" + segCounter++);
int seq = segCounter++;
seg.put("id", type.substring(0, 2) + "-" + seq);
seg.put("type", type);
// Monotonic emission index. Renderers order the timeline by this
// rather than inferring a position from the segment's type: array
// order can be perturbed on the way to the UI (dedup, fallback
// injection, live/persisted merges), and type-based relocation
// moves a span away from the point it was actually produced at.
seg.put("seq", seq);
seg.put("status", "running");
// Wall-clock bounds let history replays show the real per-segment
// duration (e.g. "thought for 12s") instead of estimating from length.

View File

@ -0,0 +1,156 @@
package vip.mate.channel.web;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import vip.mate.agent.AgentService.StreamDelta;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Pins the delta emission order that the graph agents use for a turn's
* thinking, and the segment timeline it produces.
*
* <p>Reasoning precedes the answer it produced, so the thinking delta is
* emitted ahead of the final-answer content delta of the same batch. The
* accumulator builds {@code metadata.segments} strictly in delta arrival
* order, so emitting thinking last used to append a thinking segment
* <em>after</em> the content segment a timeline that contradicts what
* actually happened and that readers had to reorder before rendering.
*
* <p>These cases lock the producer-side contract: a consumer may render
* {@code segments} in array order without any type-based reordering.
*/
class AgentStreamAccumulatorThinkingOrderTest {
private static final AgentStreamAccumulator.Sink NOOP_SINK = new AgentStreamAccumulator.Sink() {
@Override public void broadcast(String conversationId, String eventName, Object payload) { }
@Override public void updatePhase(String conversationId, String phase) { }
};
private static JsonNode segmentsOf(AgentStreamAccumulator acc, ObjectMapper mapper) throws Exception {
return mapper.readTree(acc.toMetadataJson()).path("segments");
}
private static List<String> typesOf(JsonNode segments) {
return segments.findValuesAsText("type");
}
@Test
@DisplayName("direct answer — thinking segment precedes the answer's content segment")
void thinkingPrecedesFinalAnswer() throws Exception {
ObjectMapper mapper = new ObjectMapper();
AgentStreamAccumulator acc = new AgentStreamAccumulator(mapper, NOOP_SINK);
String cid = "conv-order-1";
// Emission order used by the graph agents for a no-tool turn.
acc.accept(StreamDelta.persistOnly(null, "用户问的是时区换算,直接算即可。"), cid);
acc.accept(StreamDelta.finalAnswer("北京时间 21:00 对应 UTC 13:00。", true), cid);
JsonNode segments = segmentsOf(acc, mapper);
assertEquals(List.of("thinking", "content"), typesOf(segments),
"thinking must land before the content it produced — no consumer-side reordering");
assertEquals("用户问的是时区换算,直接算即可。", segments.get(0).path("thinkingText").asText());
assertEquals("北京时间 21:00 对应 UTC 13:00。", segments.get(1).path("text").asText());
}
@Test
@DisplayName("tool turn — the final call's thinking sits between the tool card and the answer")
void thinkingKeepsItsPlaceInAToolTurn() throws Exception {
ObjectMapper mapper = new ObjectMapper();
AgentStreamAccumulator acc = new AgentStreamAccumulator(mapper, NOOP_SINK);
String cid = "conv-order-2";
acc.accept(StreamDelta.segmentOnly("先查一下会议室占用。", null), cid);
acc.accept(StreamDelta.event("tool_call_started",
Map.of("toolCallId", "t1", "toolName", "roomQuery", "arguments", "{}")), cid);
acc.accept(StreamDelta.event("tool_call_completed",
Map.of("toolCallId", "t1", "toolName", "roomQuery", "result", "[]", "success", true)), cid);
acc.accept(StreamDelta.persistOnly(null, "返回为空,说明当前没有占用记录。"), cid);
acc.accept(StreamDelta.finalAnswer("目前没有会议室被占用。", true), cid);
JsonNode segments = segmentsOf(acc, mapper);
assertEquals(List.of("content", "tool_call", "thinking", "content"), typesOf(segments),
"the final call's reasoning belongs after the observation it read, not at the top of the turn");
assertEquals("目前没有会议室被占用。", acc.getContent(),
"segmentOnly narration stays out of the persisted top-level content");
}
@Test
@DisplayName("multi-iteration turn keeps one thinking span per iteration, in place")
void everyIterationsThinkingSurvives() throws Exception {
ObjectMapper mapper = new ObjectMapper();
AgentStreamAccumulator acc = new AgentStreamAccumulator(mapper, NOOP_SINK);
String cid = "conv-order-4";
// Two tool rounds, each preceded by its own reasoning, then the answer.
acc.accept(StreamDelta.persistOnly(null, "先确认今天的日期。"), cid);
acc.accept(StreamDelta.event("tool_call_started",
Map.of("toolCallId", "t1", "toolName", "clock", "arguments", "{}")), cid);
acc.accept(StreamDelta.event("tool_call_completed",
Map.of("toolCallId", "t1", "toolName", "clock", "result", "2026-08-06", "success", true)), cid);
acc.accept(StreamDelta.persistOnly(null, "拿到日期了,再算天数差。"), cid);
acc.accept(StreamDelta.event("tool_call_started",
Map.of("toolCallId", "t2", "toolName", "calc", "arguments", "{}")), cid);
acc.accept(StreamDelta.event("tool_call_completed",
Map.of("toolCallId", "t2", "toolName", "calc", "result", "147", "success", true)), cid);
acc.accept(StreamDelta.persistOnly(null, "147 天,可以作答。"), cid);
acc.accept(StreamDelta.finalAnswer("还有 147 天。", true), cid);
JsonNode segments = segmentsOf(acc, mapper);
assertEquals(
List.of("thinking", "tool_call", "thinking", "tool_call", "thinking", "content"),
typesOf(segments),
"each iteration's reasoning stays at the point it was produced");
assertEquals("先确认今天的日期。", segments.get(0).path("thinkingText").asText());
assertEquals("拿到日期了,再算天数差。", segments.get(2).path("thinkingText").asText());
assertEquals("147 天,可以作答。", segments.get(4).path("thinkingText").asText());
assertEquals("先确认今天的日期。\n\n拿到日期了再算天数差。\n\n147 天,可以作答。", acc.getThinking(),
"the flat thinking field carries every span, separated so they stay readable");
}
@Test
@DisplayName("deltas within one span append without inserting a separator")
void withinSpanDeltasAreNotSeparated() throws Exception {
ObjectMapper mapper = new ObjectMapper();
AgentStreamAccumulator acc = new AgentStreamAccumulator(mapper, NOOP_SINK);
String cid = "conv-order-5";
// A streaming channel feeds one span as many small deltas.
acc.accept(StreamDelta.persistOnly(null, "先看"), cid);
acc.accept(StreamDelta.persistOnly(null, "一下"), cid);
acc.accept(StreamDelta.persistOnly(null, "输入。"), cid);
JsonNode segments = segmentsOf(acc, mapper);
assertEquals(List.of("thinking"), typesOf(segments), "one running span, not three");
assertEquals("先看一下输入。", acc.getThinking());
}
@Test
@DisplayName("every segment carries a monotonic seq matching its emission position")
void segmentsCarryMonotonicSeq() throws Exception {
ObjectMapper mapper = new ObjectMapper();
AgentStreamAccumulator acc = new AgentStreamAccumulator(mapper, NOOP_SINK);
String cid = "conv-order-3";
acc.accept(StreamDelta.segmentOnly("先查一下会议室占用。", null), cid);
acc.accept(StreamDelta.event("tool_call_started",
Map.of("toolCallId", "t1", "toolName", "roomQuery", "arguments", "{}")), cid);
acc.accept(StreamDelta.event("tool_call_completed",
Map.of("toolCallId", "t1", "toolName", "roomQuery", "result", "[]", "success", true)), cid);
acc.accept(StreamDelta.persistOnly(null, "返回为空,说明当前没有占用记录。"), cid);
acc.accept(StreamDelta.finalAnswer("目前没有会议室被占用。", true), cid);
JsonNode segments = segmentsOf(acc, mapper);
for (int i = 0; i < segments.size(); i++) {
assertEquals(i, segments.get(i).path("seq").asInt(-1),
"seq is the emission index — renderers sort by it instead of relocating by type");
}
}
}

View File

@ -39,6 +39,12 @@
--mc-bg-elevated: #ffffff;
--mc-bg-sunken: #ebe3db;
--mc-bg-muted: #f1e8df;
/* Hover wash for icon buttons and list rows. Consumers write
`background: var(--mc-bg-hover)` with no literal fallback, and an
undefined custom property makes the whole declaration compute to the
property's initial value transparent so a missing definition here
silently erases the element's background instead of leaving it alone. */
--mc-bg-hover: #ebe3db;
--mc-surface-strong: #fdfaf6;
--mc-surface-overlay: rgba(255, 255, 255, 0.72);
--mc-panel-top: rgba(255, 255, 255, 0.94);
@ -166,6 +172,7 @@ html.dark {
--mc-bg-elevated: #221a16;
--mc-bg-sunken: #2a211c;
--mc-bg-muted: #201813;
--mc-bg-hover: #2a211c;
--mc-surface-strong: #2a201a;
--mc-surface-overlay: rgba(34, 26, 22, 0.78);
--mc-panel-top: rgba(36, 28, 24, 0.96);

View File

@ -786,6 +786,24 @@ defineExpose({
cursor: not-allowed;
}
/* Focus ring. Left unstyled, these fall back to the UA default, whose
`outline: auto` draws a thick halo in a colour the browser picks for
contrast a gold ring on the red stop button, which reads as an error
state rather than focus. Restyled, not removed: keyboard users need the
affordance, so this stays a visible ring in the app's own palette. */
.action-btn:focus-visible {
outline: 2px solid var(--mc-primary, #D97757);
outline-offset: 2px;
}
.send-btn.is-loading:focus-visible {
outline-color: var(--mc-danger, #ef4444);
}
.send-btn.is-interrupt:focus-visible {
outline-color: var(--mc-warning, #f59e0b);
}
.thinking-btn {
position: relative;
}
@ -818,7 +836,11 @@ defineExpose({
background: var(--mc-primary-light, rgba(217, 119, 87, 0.08));
}
.send-btn {
/* Qualified with .action-btn so the send button outranks the generic
`.action-btn:hover` that leaks in from a non-scoped stylesheet elsewhere
in the app. Bare `.send-btn` ties with it on specificity and loses on
source order, which erased the button's fill on hover. */
.action-btn.send-btn {
background: var(--mc-primary, #D97757);
color: white;
}

View File

@ -70,7 +70,12 @@
@click="toggleSupersededSegment(seg.id)"
>
<el-icon><InfoFilled /></el-icon>
<span>{{ $t('chat.supersededPreviewCollapsed') }}</span>
<!-- Label tracks the actual state it read "已折叠" even while
the body below it was expanded, so the banner contradicted
what the reader could plainly see. -->
<span>{{ isSupersededExpanded(seg.id)
? $t('chat.supersededPreviewExpanded')
: $t('chat.supersededPreviewCollapsed') }}</span>
<span class="superseded-toggle__action">
{{ isSupersededExpanded(seg.id) ? $t('chat.collapse') : $t('chat.expand') }}
</span>
@ -1111,9 +1116,15 @@ const segments = computed<MessageSegment[]>(() => {
// iteration's thinking bucket instead of the default-zero bucket
// colliding with later iteration content. Without this, the fallback
// thinking renders below the answer for any conversation that has
// multi-iteration RFC-22 segments tagged elsewhere.
// multi-iteration segments tagged elsewhere.
//
// seq=-1 keeps it ahead of every producer-numbered segment so the
// sort below still applies to the array it was injected into. This
// reconstruction has no real emission position the thinking came
// from contentParts, not from the timeline and leading the turn is
// the only defensible placement for it.
const firstIter = segs.find(s => typeof s.iterationIndex === 'number')?.iterationIndex ?? 0
segs.unshift({ id: 'th-fb', type: 'thinking', status: 'completed', thinkingText: thinkingPart.text, iterationIndex: firstIter })
segs.unshift({ id: 'th-fb', type: 'thinking', status: 'completed', thinkingText: thinkingPart.text, iterationIndex: firstIter, seq: -1 })
}
}
@ -1137,15 +1148,15 @@ const segments = computed<MessageSegment[]>(() => {
segs.length = 0
segs.push(...deduped)
// thinking content content
// thinking 线
const thinkingIndices = segs
.map((seg, index) => seg.type === 'thinking' ? index : -1)
.filter(index => index >= 0)
const firstNonThinkingIdx = segs.findIndex((seg: MessageSegment) => seg.type !== 'thinking')
if (thinkingIndices.length === 1 && firstNonThinkingIdx >= 0 && thinkingIndices[0] > firstNonThinkingIdx) {
const [thinkingSeg] = segs.splice(thinkingIndices[0], 1)
segs.splice(0, 0, thinkingSeg)
// segments `seq`
//
// thinking "
// thinking " content
//
// `seq` live
//
if (segs.every(seg => typeof seg.seq === 'number')) {
segs.sort((a, b) => (a.seq as number) - (b.seq as number))
}
return segs
@ -1251,8 +1262,8 @@ function fmtTokens(n: number): string {
* Group segments by iterationIndex so each ReAct iteration renders as its own
* thinking/tool-calls/content cluster. Falls back to a single ungrouped bucket
* for legacy messages (no iterationIndex tagged) so historical conversations
* keep rendering as before including the existing "single-thinking reorder"
* normalization done in the `segments` computed above.
* keep rendering as before. Ordering within a bucket is whatever the
* `segments` computed above settled on emission order, by `seq`.
*/
const groupedIterations = computed(() => {
const segs = segments.value || []

View File

@ -532,6 +532,7 @@ export default {
iterationEmpty: 'Iteration {index} interrupted (no output)',
contentRepetitionWarning: 'Repetitive content detected near the end (model artifact)',
supersededPreviewCollapsed: 'Collapsed: content drafted before tool execution (may not match actual results)',
supersededPreviewExpanded: 'Below is content drafted before tool execution (may not match actual results)',
pendingReply: 'Preparing a reply…',
expand: 'Expand',
// INCOMPLETE truncation card (finishReason=incomplete)

View File

@ -532,6 +532,7 @@ export default {
iterationEmpty: '第 {index} 轮被中断(无输出)',
contentRepetitionWarning: '检测到内容尾部重复(疑似模型输出 artifact',
supersededPreviewCollapsed: '已折叠:模型在工具执行前预写的内容(可能与实际结果不符)',
supersededPreviewExpanded: '以下是模型在工具执行前预写的内容(可能与实际结果不符)',
pendingReply: '正在准备回复…',
expand: '展开',
// INCOMPLETE 截断卡片finishReason=incomplete

View File

@ -208,6 +208,12 @@ export interface MessageSegment {
id: string
type: 'thinking' | 'tool_call' | 'content' | 'phase' | 'approval' | 'plan'
status: 'running' | 'completed' | 'error'
/**
* Producer-assigned emission index, monotonic within a turn. Present on
* persisted segments; absent on live ones, which are already appended in
* event order. Renderers sort by it instead of relocating segments by type.
*/
seq?: number
/** type=thinking */
thinkingText?: string
/** type=tool_call */