mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(chat): keep every iteration reasoning and render the timeline in emission order
This commit is contained in:
parent
30560eb7cf
commit
22c49f3a07
@ -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_CONTENT:STREAMED_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));
|
||||
|
||||
@ -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={}",
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 || []
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -532,6 +532,7 @@ export default {
|
||||
iterationEmpty: '第 {index} 轮被中断(无输出)',
|
||||
contentRepetitionWarning: '检测到内容尾部重复(疑似模型输出 artifact)',
|
||||
supersededPreviewCollapsed: '已折叠:模型在工具执行前预写的内容(可能与实际结果不符)',
|
||||
supersededPreviewExpanded: '以下是模型在工具执行前预写的内容(可能与实际结果不符)',
|
||||
pendingReply: '正在准备回复…',
|
||||
expand: '展开',
|
||||
// INCOMPLETE 截断卡片(finishReason=incomplete)
|
||||
|
||||
@ -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 */
|
||||
|
||||
Loading…
Reference in New Issue
Block a user