From 22c49f3a0757f2acbc7f59d4b9ee628733971f12 Mon Sep 17 00:00:00 2001 From: matevip Date: Thu, 6 Aug 2026 05:13:09 -0400 Subject: [PATCH] fix(chat): keep every iteration reasoning and render the timeline in emission order --- .../agent/graph/StateGraphReActAgent.java | 85 ++++++++-- .../agent/graph/node/FinalAnswerNode.java | 7 +- .../plan/StateGraphPlanExecuteAgent.java | 29 ++-- .../channel/web/AgentStreamAccumulator.java | 20 ++- ...entStreamAccumulatorThinkingOrderTest.java | 156 ++++++++++++++++++ mateclaw-ui/src/assets/main.css | 7 + mateclaw-ui/src/components/chat/ChatInput.vue | 24 ++- .../src/components/chat/MessageBubble.vue | 39 +++-- mateclaw-ui/src/i18n/locales/en-US.ts | 1 + mateclaw-ui/src/i18n/locales/zh-CN.ts | 1 + mateclaw-ui/src/types/index.ts | 6 + 11 files changed, 328 insertions(+), 47 deletions(-) create mode 100644 mateclaw-server/src/test/java/vip/mate/channel/web/AgentStreamAccumulatorThinkingOrderTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java index 905df5f3..4a0b14b8 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java @@ -208,6 +208,7 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC AtomicBoolean finalAnswerEmitted = new AtomicBoolean(false); AtomicBoolean finalThinkingEmitted = new AtomicBoolean(false); AtomicReference lastEmittedStreamedContent = new AtomicReference<>(""); + AtomicReference 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().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 lastEmittedIterationThinking = new AtomicReference<>(""); // STREAMED_CONTENT 是 REPLACE 策略(每轮 ReasoningNode/SummarizingNode 覆写), // 用 lastEmitted 跟踪已发送的值,避免在 ActionNode/ObservationNode 的 NodeOutput 上重复发送同一段内容。 AtomicReference 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().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)); diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/FinalAnswerNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/FinalAnswerNode.java index 3870c022..6896fe95 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/FinalAnswerNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/FinalAnswerNode.java @@ -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={}", diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/StateGraphPlanExecuteAgent.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/StateGraphPlanExecuteAgent.java index 06af1b05..3b75972d 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/StateGraphPlanExecuteAgent.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/StateGraphPlanExecuteAgent.java @@ -179,14 +179,9 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS // 2a. 各步骤执行结果(StepExecutionNode 已通过 NodeStreamingChatHelper 直推 SSE, // 这里仅作为 persistOnly 送入 Accumulator,确保写入 mate_message) // 利用内容本身去重,避免 PlanSummaryNode 输出时重复 emit 上一步残留在 state 的值 - output.state().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().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().value(PlanStateKeys.FINAL_SUMMARY) + output.state().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().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().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)); diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/AgentStreamAccumulator.java b/mateclaw-server/src/main/java/vip/mate/channel/web/AgentStreamAccumulator.java index 8e0ca63c..117b6055 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/web/AgentStreamAccumulator.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/AgentStreamAccumulator.java @@ -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 newSegment(String type) { Map 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. diff --git a/mateclaw-server/src/test/java/vip/mate/channel/web/AgentStreamAccumulatorThinkingOrderTest.java b/mateclaw-server/src/test/java/vip/mate/channel/web/AgentStreamAccumulatorThinkingOrderTest.java new file mode 100644 index 00000000..e2ab611a --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/web/AgentStreamAccumulatorThinkingOrderTest.java @@ -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. + * + *

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 + * after the content segment — a timeline that contradicts what + * actually happened and that readers had to reorder before rendering. + * + *

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 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"); + } + } +} diff --git a/mateclaw-ui/src/assets/main.css b/mateclaw-ui/src/assets/main.css index 29b22af7..e77d74f4 100644 --- a/mateclaw-ui/src/assets/main.css +++ b/mateclaw-ui/src/assets/main.css @@ -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); diff --git a/mateclaw-ui/src/components/chat/ChatInput.vue b/mateclaw-ui/src/components/chat/ChatInput.vue index afc847ed..da7c35fa 100644 --- a/mateclaw-ui/src/components/chat/ChatInput.vue +++ b/mateclaw-ui/src/components/chat/ChatInput.vue @@ -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; } diff --git a/mateclaw-ui/src/components/chat/MessageBubble.vue b/mateclaw-ui/src/components/chat/MessageBubble.vue index 6a525c2a..a0c95de1 100644 --- a/mateclaw-ui/src/components/chat/MessageBubble.vue +++ b/mateclaw-ui/src/components/chat/MessageBubble.vue @@ -70,7 +70,12 @@ @click="toggleSupersededSegment(seg.id)" > - {{ $t('chat.supersededPreviewCollapsed') }} + + {{ isSupersededExpanded(seg.id) + ? $t('chat.supersededPreviewExpanded') + : $t('chat.supersededPreviewCollapsed') }} {{ isSupersededExpanded(seg.id) ? $t('chat.collapse') : $t('chat.expand') }} @@ -1111,9 +1116,15 @@ const segments = computed(() => { // 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(() => { 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 || [] diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 781c1e57..4590fe40 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -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) diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 3680e492..5f4203d2 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -532,6 +532,7 @@ export default { iterationEmpty: '第 {index} 轮被中断(无输出)', contentRepetitionWarning: '检测到内容尾部重复(疑似模型输出 artifact)', supersededPreviewCollapsed: '已折叠:模型在工具执行前预写的内容(可能与实际结果不符)', + supersededPreviewExpanded: '以下是模型在工具执行前预写的内容(可能与实际结果不符)', pendingReply: '正在准备回复…', expand: '展开', // INCOMPLETE 截断卡片(finishReason=incomplete) diff --git a/mateclaw-ui/src/types/index.ts b/mateclaw-ui/src/types/index.ts index 86f20b18..a1eea53c 100644 --- a/mateclaw-ui/src/types/index.ts +++ b/mateclaw-ui/src/types/index.ts @@ -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 */