From ed7f6f5c1996df988295b5c2b3ca63d627ce8904 Mon Sep 17 00:00:00 2001 From: matevip Date: Thu, 6 Aug 2026 05:48:28 -0400 Subject: [PATCH] feat(chat): add reasoning retention controls and a linear trajectory export --- .../vip/mate/agent/AgentGraphBuilder.java | 23 ++- .../agent/graph/StateGraphReActAgent.java | 20 ++- .../config/ReasoningRetentionProperties.java | 38 ++++ .../java/vip/mate/config/WebMvcConfig.java | 3 +- .../mate/system/model/SystemSettingsDTO.java | 8 + .../system/service/SystemSettingService.java | 6 + .../conversation/ConversationService.java | 15 ++ .../conversation/TrajectoryRenderer.java | 166 ++++++++++++++++++ .../controller/ConversationController.java | 20 +++ .../src/main/resources/application.yml | 7 + .../conversation/TrajectoryRendererTest.java | 128 ++++++++++++++ .../src/components/chat/MessageBubble.vue | 21 ++- mateclaw-ui/src/i18n/locales/en-US.ts | 2 + mateclaw-ui/src/i18n/locales/zh-CN.ts | 2 + .../src/stores/useSystemSettingsStore.ts | 13 +- mateclaw-ui/src/types/index.ts | 3 + .../src/views/Settings/System/index.vue | 14 ++ 17 files changed, 480 insertions(+), 9 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/config/ReasoningRetentionProperties.java create mode 100644 mateclaw-server/src/main/java/vip/mate/workspace/conversation/TrajectoryRenderer.java create mode 100644 mateclaw-server/src/test/java/vip/mate/workspace/conversation/TrajectoryRendererTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java index aacdc66a..a03d79e9 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java @@ -31,7 +31,9 @@ import vip.mate.agent.graph.plan.state.PlanStateKeys; import vip.mate.agent.graph.state.MateClawStateKeys; import vip.mate.agent.binding.service.AgentBindingService; import vip.mate.agent.model.AgentEntity; +import org.springframework.beans.factory.annotation.Autowired; import vip.mate.config.GraphObservationProperties; +import vip.mate.config.ReasoningRetentionProperties; import vip.mate.exception.MateClawException; import vip.mate.llm.chatmodel.OpenAiCompatibleChatModelBuilder; import vip.mate.llm.chatmodel.ReasoningEffortResolver; @@ -169,11 +171,24 @@ public class AgentGraphBuilder { */ private vip.mate.audit.service.AuditEventService auditEventService; - @org.springframework.beans.factory.annotation.Autowired(required = false) + @Autowired(required = false) public void setAuditEventService(vip.mate.audit.service.AuditEventService s) { this.auditEventService = s; } + /** + * Reasoning retention policy for ReAct turns. Setter injection so the + * {@code @RequiredArgsConstructor} signature stays stable for the unit + * constructions across the test suite; null in those, where the agent's own + * default (keep every iteration) applies. + */ + private ReasoningRetentionProperties reasoningRetentionProperties; + + @Autowired(required = false) + public void setReasoningRetentionProperties(ReasoningRetentionProperties p) { + this.reasoningRetentionProperties = p; + } + /** * Optional per-step delegation dependencies for the Plan-Execute graph. * Setter injection (like {@link #auditEventService}) breaks the @@ -571,8 +586,12 @@ public class AgentGraphBuilder { String reasoningEffort = resolveReasoningEffortForModel(runtimeModel); CompiledGraph compiledGraph = buildReActGraph(toolSet, chatModel, maxIter, reasoningEffort, runtimeModel, agentId, skillCatalogRenderer, prefixBudgetPlan, autoDemotedTools); - return new StateGraphReActAgent(chatClient, conversationService, compiledGraph, + StateGraphReActAgent agent = new StateGraphReActAgent(chatClient, conversationService, compiledGraph, chatModel, conversationWindowManager, toolSet); + if (reasoningRetentionProperties != null) { + agent.setPersistEveryIterationReasoning(reasoningRetentionProperties.persistsEveryIteration()); + } + return agent; } StateGraphPlanExecuteAgent buildPlanExecuteAgent(AgentToolSet toolSet, ModelConfigEntity runtimeModel, int maxIter) { 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 4a0b14b8..7e3289b3 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 @@ -63,6 +63,20 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC */ private final vip.mate.agent.AgentToolSet toolSet; + /** + * Whether every iteration's reasoning is persisted, or only the terminal + * one. Set from {@code mate.agent.reasoning.retention}; defaults to keeping + * everything so a turn stays replayable without operator opt-in. A setter + * rather than a constructor argument — the agent is built per request in a + * builder that already threads a dozen collaborators, and this is a single + * boolean with a safe default. + */ + private boolean persistEveryIterationReasoning = true; + + public void setPersistEveryIterationReasoning(boolean persistEveryIterationReasoning) { + this.persistEveryIterationReasoning = persistEveryIterationReasoning; + } + public StateGraphReActAgent(ChatClient chatClient, ConversationService conversationService, CompiledGraph compiledGraph, org.springframework.ai.chat.model.ChatModel chatModel, @@ -256,7 +270,8 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC // 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() + if (persistEveryIterationReasoning + && !iterationThinking.isEmpty() && !iterationThinking.equals(lastEmittedIterationThinking.get())) { lastEmittedIterationThinking.set(iterationThinking); deltas.add(AgentService.StreamDelta.persistOnly(null, iterationThinking)); @@ -450,7 +465,8 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC // 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() + if (persistEveryIterationReasoning + && !iterationThinking.isEmpty() && !iterationThinking.equals(lastEmittedIterationThinking.get())) { lastEmittedIterationThinking.set(iterationThinking); deltas.add(AgentService.StreamDelta.persistOnly(null, iterationThinking)); diff --git a/mateclaw-server/src/main/java/vip/mate/config/ReasoningRetentionProperties.java b/mateclaw-server/src/main/java/vip/mate/config/ReasoningRetentionProperties.java new file mode 100644 index 00000000..bd8aafb5 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/config/ReasoningRetentionProperties.java @@ -0,0 +1,38 @@ +package vip.mate.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * How much of a turn's reasoning is written to the message record. + *

+ * Tune in {@code application.yml} under {@code mate.agent.reasoning}, not in + * the Java field defaults. The yml is the source of truth; the field default + * below is a conservative fallback for tests / unit constructors. + *

+ * A ReAct turn reasons once per iteration. Only the terminal iteration's + * reasoning is needed to explain the answer, but the earlier ones are what + * explain each tool call — which is exactly what a replay of a misbehaving turn + * needs. Persisting all of them costs message-row size, so operators running + * long tool loops on a small database can trade the detail away. + * + * @author MateClaw Team + */ +@Data +@ConfigurationProperties(prefix = "mate.agent.reasoning") +public class ReasoningRetentionProperties { + + /** {@link Retention#ALL} keeps every iteration; {@link Retention#TERMINAL} keeps only the last. */ + private Retention retention = Retention.ALL; + + public boolean persistsEveryIteration() { + return retention != Retention.TERMINAL; + } + + public enum Retention { + /** Persist the reasoning of every iteration, positioned where it happened. */ + ALL, + /** Persist only the reasoning of the iteration that produced the final answer. */ + TERMINAL + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/config/WebMvcConfig.java b/mateclaw-server/src/main/java/vip/mate/config/WebMvcConfig.java index 84cc3024..b37e5b9d 100644 --- a/mateclaw-server/src/main/java/vip/mate/config/WebMvcConfig.java +++ b/mateclaw-server/src/main/java/vip/mate/config/WebMvcConfig.java @@ -17,7 +17,8 @@ import vip.mate.kbopen.auth.KbScopeInterceptor; */ @Configuration @RequiredArgsConstructor -@EnableConfigurationProperties({GraphObservationProperties.class, ConversationWindowProperties.class, ToolTimeoutProperties.class}) +@EnableConfigurationProperties({GraphObservationProperties.class, ConversationWindowProperties.class, ToolTimeoutProperties.class, + ReasoningRetentionProperties.class}) public class WebMvcConfig implements WebMvcConfigurer { private final WorkspaceAccessInterceptor workspaceAccessInterceptor; diff --git a/mateclaw-server/src/main/java/vip/mate/system/model/SystemSettingsDTO.java b/mateclaw-server/src/main/java/vip/mate/system/model/SystemSettingsDTO.java index 50c02d33..c716f496 100644 --- a/mateclaw-server/src/main/java/vip/mate/system/model/SystemSettingsDTO.java +++ b/mateclaw-server/src/main/java/vip/mate/system/model/SystemSettingsDTO.java @@ -15,6 +15,14 @@ public class SystemSettingsDTO { * level (which controls whether the model thinks at all). */ private Boolean showThinking; + /** + * Whether the chat UI renders every iteration's reasoning, or only the span + * that produced the answer. Default true. A tool-heavy turn persists one + * reasoning span per iteration — all of them is what makes a run + * reviewable, one of them is what keeps the bubble readable. Only takes + * effect while {@link #showThinking} is on. + */ + private Boolean thinkingFull; private Boolean stateGraphEnabled; /** diff --git a/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java b/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java index 465ba419..4d87f387 100644 --- a/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java +++ b/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java @@ -33,6 +33,7 @@ public class SystemSettingService { private static final String STREAM_ENABLED_KEY = "streamEnabled"; private static final String DEBUG_MODE_KEY = "debugMode"; private static final String SHOW_THINKING_KEY = "showThinking"; + private static final String THINKING_FULL_KEY = "thinkingFull"; private static final String STATEGRAPH_ENABLED_KEY = "stateGraphEnabled"; // 搜索服务配置 keys @@ -166,6 +167,7 @@ public class SystemSettingService { dto.setStreamEnabled(Boolean.parseBoolean(getValue(STREAM_ENABLED_KEY, "true"))); dto.setDebugMode(Boolean.parseBoolean(getValue(DEBUG_MODE_KEY, "false"))); dto.setShowThinking(Boolean.parseBoolean(getValue(SHOW_THINKING_KEY, "true"))); + dto.setThinkingFull(Boolean.parseBoolean(getValue(THINKING_FULL_KEY, "true"))); dto.setStateGraphEnabled(Boolean.parseBoolean(getValue(STATEGRAPH_ENABLED_KEY, "false"))); // 搜索服务配置 @@ -334,6 +336,10 @@ public class SystemSettingService { if (dto.getShowThinking() != null) { saveValue(SHOW_THINKING_KEY, String.valueOf(dto.getShowThinking()), "聊天界面是否展示模型思考过程"); } + if (dto.getThinkingFull() != null) { + saveValue(THINKING_FULL_KEY, String.valueOf(dto.getThinkingFull()), + "聊天界面是否展示每一轮的思考,而非只展示得出答案的那一轮"); + } if (dto.getStateGraphEnabled() != null) { saveValue(STATEGRAPH_ENABLED_KEY, String.valueOf(dto.getStateGraphEnabled()), "启用 StateGraph 架构的 ReAct Agent"); } diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java index 360f19bd..7937b00e 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java @@ -1146,6 +1146,21 @@ public class ConversationService { .toList(); } + /** + * Render the whole conversation as one linear transcript for debugging and + * acceptance: every reasoning span, tool call, tool result and answer in + * emission order. Server paths (never the file system) are exposed, same as + * {@link #renderMessageContent(MessageEntity, boolean)} with + * {@code includePath=false}. + */ + public String renderTrajectory(String conversationId) { + List messages = listMessages(conversationId); + List rendered = messages.stream() + .map(message -> renderMessageContent(message, false)) + .toList(); + return new TrajectoryRenderer(objectMapper).render(conversationId, messages, rendered); + } + /** * External-facing message views for untrusted callers (webchat visitors). * Strips the server-side absolute file path from both the structured parts diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/TrajectoryRenderer.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/TrajectoryRenderer.java new file mode 100644 index 00000000..9305f324 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/TrajectoryRenderer.java @@ -0,0 +1,166 @@ +package vip.mate.workspace.conversation; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import vip.mate.workspace.conversation.model.MessageEntity; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * Renders a conversation into one linear, diffable transcript. + *

+ * The chat UI is the wrong tool for verifying what a turn actually did: it + * collapses reasoning, hides superseded spans, and its ordering has its own + * bugs. This renderer reads the same {@code metadata.segments} timeline the UI + * does and prints it verbatim, in emission order, with each span tagged by + * kind — so "what did the model reason before that tool call" is answered by + * reading, not by clicking through collapsed panels. + *

+ * Output is plain text and intentionally boring, so it can be pasted into an + * issue, diffed between two runs, or grepped: + *

+ * ## [3] assistant
+ * <think>
+ * ...
+ * </think>
+ * <tool_call name="execute_code">
+ * {"code": "..."}
+ * </tool_call>
+ * <tool_response success="true">
+ * ...
+ * </tool_response>
+ * 
+ * + * @author MateClaw Team + */ +@Slf4j +public class TrajectoryRenderer { + + private final ObjectMapper objectMapper; + + public TrajectoryRenderer(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + /** + * Render an ordered list of messages. Assistant turns are expanded from + * their segment timeline; every other role prints its rendered content. + * + * @param renderedContent per-message user-visible content, index-aligned with {@code messages} + */ + public String render(String conversationId, List messages, List renderedContent) { + StringBuilder out = new StringBuilder(); + out.append("# trajectory ").append(conversationId).append('\n'); + out.append("# messages=").append(messages.size()).append('\n'); + + for (int i = 0; i < messages.size(); i++) { + MessageEntity m = messages.get(i); + String role = m.getRole() != null ? m.getRole() : "unknown"; + out.append("\n## [").append(i).append("] ").append(role).append('\n'); + + if (!"assistant".equals(role)) { + appendBlock(out, textAt(renderedContent, i)); + continue; + } + List segments = orderedSegments(m); + if (segments.isEmpty()) { + // No timeline (legacy row, or a turn that never streamed) — the + // rendered content is all there is. Say so rather than emitting + // an empty turn that reads like the model produced nothing. + out.append("# (no segment timeline — rendered content only)\n"); + appendBlock(out, textAt(renderedContent, i)); + continue; + } + for (JsonNode seg : segments) { + appendSegment(out, seg); + } + } + return out.toString(); + } + + /** + * Segments in emission order. Sorts by the producer-assigned {@code seq}; + * rows written before that field existed keep their stored array order, + * which is the same order for those rows. + */ + private List orderedSegments(MessageEntity message) { + List segments = new ArrayList<>(); + String metadata = message.getMetadata(); + if (metadata == null || metadata.isBlank()) { + return segments; + } + try { + JsonNode root = objectMapper.readTree(metadata); + // An H2 JSON column hands the document back wrapped as a JSON string + // literal, so a plain parse yields a TextNode and the timeline reads + // as absent rather than as a parse failure. MessageVO unwraps the + // same way for the chat UI; without it here the transcript quietly + // degrades to "no segment timeline" on exactly the rows that have one. + if (root.isTextual()) { + root = objectMapper.readTree(root.textValue()); + } + JsonNode node = root.path("segments"); + if (!node.isArray()) { + return segments; + } + node.forEach(segments::add); + } catch (Exception e) { + log.warn("Failed to parse segments for message {}: {}", message.getId(), e.getMessage()); + return segments; + } + if (segments.stream().allMatch(s -> s.path("seq").isNumber())) { + segments.sort(Comparator.comparingInt(s -> s.path("seq").asInt())); + } + return segments; + } + + private void appendSegment(StringBuilder out, JsonNode seg) { + String type = seg.path("type").asText(""); + switch (type) { + case "thinking" -> { + out.append("\n"); + appendBlock(out, seg.path("thinkingText").asText("")); + out.append("\n"); + } + case "tool_call" -> { + out.append("\n"); + appendBlock(out, seg.path("toolArgs").asText("")); + out.append("\n"); + out.append("\n"); + appendBlock(out, seg.path("toolResult").asText("")); + out.append("\n"); + } + case "content" -> { + // A superseded span is content the model drafted before its + // tools ran. It is dropped from the UI but kept here — a wrong + // answer that got corrected is exactly what a replay is after. + if (seg.path("superseded").asBoolean(false)) { + out.append("\n"); + } else { + out.append("\n"); + } + appendBlock(out, seg.path("text").asText("")); + out.append("\n"); + } + default -> { + out.append("\n"); + } + } + } + + private static String textAt(List rendered, int index) { + return rendered != null && index < rendered.size() ? rendered.get(index) : ""; + } + + /** Append a body, guaranteeing exactly one trailing newline and no blank body. */ + private static void appendBlock(StringBuilder out, String body) { + if (body == null || body.isBlank()) { + return; + } + out.append(body.stripTrailing()).append('\n'); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/controller/ConversationController.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/controller/ConversationController.java index 291b543f..1511096c 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/controller/ConversationController.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/controller/ConversationController.java @@ -3,6 +3,8 @@ package vip.mate.workspace.conversation.controller; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; import vip.mate.common.result.R; @@ -59,6 +61,24 @@ public class ConversationController { return R.ok(conversationService.pageConversations(username, workspaceId, page, size, keyword)); } + /** + * 导出会话轨迹 —— 调试/验收用的线性纯文本转录。 + *

+ * 与聊天界面读同一份 {@code metadata.segments} 时间线,但按发射顺序原样打印: + * 每轮推理、工具调用、工具返回、答案各自成块,包括界面会折叠掉的 + * superseded 预写内容。可直接 diff 两次运行,或贴进 issue。 + */ + @Operation(summary = "导出会话轨迹(纯文本)") + @GetMapping(value = "/{conversationId}/trajectory", produces = "text/plain;charset=UTF-8") + public ResponseEntity exportTrajectory(@PathVariable String conversationId, + Authentication auth) { + String username = auth != null ? auth.getName() : "anonymous"; + if (!conversationService.isConversationOwner(conversationId, username)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body("无权访问该会话\n"); + } + return ResponseEntity.ok(conversationService.renderTrajectory(conversationId)); + } + /** * 获取指定会话的消息历史(支持分页)。 *

diff --git a/mateclaw-server/src/main/resources/application.yml b/mateclaw-server/src/main/resources/application.yml index 10be017a..c502c9a8 100644 --- a/mateclaw-server/src/main/resources/application.yml +++ b/mateclaw-server/src/main/resources/application.yml @@ -380,6 +380,13 @@ mate: # ---, table pipe alignment) before persistence / channel delivery. Set to # false to pass model output through verbatim. markdown-normalize-enabled: true + reasoning: + # How much of a turn's reasoning reaches the message record. + # all — every iteration's reasoning, kept where it happened. The + # reasoning behind each tool call is what a replay needs. + # terminal — only the iteration that produced the final answer. Smaller + # rows, but a long tool loop persists as a bare conclusion. + retention: all graph: observation: # 与 GraphObservationProperties.java 默认值对齐,参考 openclaw token-budget 设计 diff --git a/mateclaw-server/src/test/java/vip/mate/workspace/conversation/TrajectoryRendererTest.java b/mateclaw-server/src/test/java/vip/mate/workspace/conversation/TrajectoryRendererTest.java new file mode 100644 index 00000000..00cfe972 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/workspace/conversation/TrajectoryRendererTest.java @@ -0,0 +1,128 @@ +package vip.mate.workspace.conversation; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.workspace.conversation.model.MessageEntity; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pins the linear transcript used for debugging and acceptance: every span in + * emission order, tagged by kind, including what the chat UI hides. + */ +class TrajectoryRendererTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final TrajectoryRenderer RENDERER = new TrajectoryRenderer(MAPPER); + + private static MessageEntity message(String role, String metadata) { + MessageEntity m = new MessageEntity(); + m.setRole(role); + m.setMetadata(metadata); + return m; + } + + @Test + @DisplayName("segments render in seq order, each tagged by kind") + void rendersTimelineInSeqOrder() { + String metadata = """ + {"segments":[ + {"seq":1,"type":"tool_call","toolName":"clock","toolArgs":"{}", + "toolResult":"2026-08-06","toolSuccess":true}, + {"seq":0,"type":"thinking","thinkingText":"先确认日期。"}, + {"seq":2,"type":"content","text":"今天是 2026-08-06。"} + ]}"""; + String out = RENDERER.render("conv-1", + List.of(message("user", null), message("assistant", metadata)), + List.of("今天几号?", "今天是 2026-08-06。")); + + int think = out.indexOf(""); + int call = out.indexOf(""); + int response = out.indexOf(""); + int content = out.indexOf(""); + assertTrue(think >= 0 && call > think && response > call && content > response, + "stored order is 1,0,2 — the transcript must follow seq, not array position:\n" + out); + assertTrue(out.contains("先确认日期。"), out); + assertTrue(out.contains("2026-08-06"), out); + assertTrue(out.contains("## [0] user"), out); + assertTrue(out.contains("## [1] assistant"), out); + } + + @Test + @DisplayName("superseded drafts are kept — the UI hides them, a replay needs them") + void keepsSupersededDraft() { + String metadata = """ + {"segments":[ + {"seq":0,"type":"content","text":"我猜是周三。","superseded":true}, + {"seq":1,"type":"content","text":"查过了,是周四。"} + ]}"""; + String out = RENDERER.render("conv-2", List.of(message("assistant", metadata)), List.of("")); + + assertTrue(out.contains(""), out); + assertTrue(out.contains("我猜是周三。"), out); + assertTrue(out.contains("查过了,是周四。"), out); + } + + @Test + @DisplayName("a row without a timeline falls back to rendered content, and says so") + void fallsBackForLegacyRows() { + String out = RENDERER.render("conv-3", + List.of(message("assistant", null)), List.of("旧消息正文")); + + assertTrue(out.contains("no segment timeline"), out); + assertTrue(out.contains("旧消息正文"), out); + } + + @Test + @DisplayName("segments without seq keep their stored order rather than being dropped") + void toleratesMissingSeq() { + String metadata = """ + {"segments":[ + {"type":"thinking","thinkingText":"甲"}, + {"type":"content","text":"乙"} + ]}"""; + String out = RENDERER.render("conv-4", List.of(message("assistant", metadata)), List.of("乙")); + + assertTrue(out.indexOf("甲") < out.indexOf("乙"), out); + } + + @Test + @DisplayName("metadata wrapped as a JSON string literal still yields its timeline") + void unwrapsDoubleEncodedMetadata() throws Exception { + // How an H2 JSON column hands the document back. Read naively this + // parses to a TextNode and the turn looks like it has no timeline. + String inner = """ + {"segments":[{"seq":0,"type":"thinking","thinkingText":"内层推理"}]}"""; + String doubleEncoded = MAPPER.writeValueAsString(inner); + + String out = RENDERER.render("conv-7", + List.of(message("assistant", doubleEncoded)), List.of("答案")); + + assertTrue(out.contains(""), out); + assertTrue(out.contains("内层推理"), out); + assertFalse(out.contains("no segment timeline"), out); + } + + @Test + @DisplayName("unparseable metadata degrades to the rendered content instead of throwing") + void survivesBrokenMetadata() { + String out = RENDERER.render("conv-5", + List.of(message("assistant", "{not json")), List.of("兜底正文")); + + assertTrue(out.contains("兜底正文"), out); + assertFalse(out.contains(""), out); + } + + @Test + @DisplayName("header states the conversation and message count") + void writesHeader() { + String out = RENDERER.render("conv-6", List.of(message("user", null)), List.of("嗨")); + assertEquals("# trajectory conv-6", out.lines().findFirst().orElse("")); + assertTrue(out.contains("# messages=1"), out); + } +} diff --git a/mateclaw-ui/src/components/chat/MessageBubble.vue b/mateclaw-ui/src/components/chat/MessageBubble.vue index a0c95de1..afa1d693 100644 --- a/mateclaw-ui/src/components/chat/MessageBubble.vue +++ b/mateclaw-ui/src/components/chat/MessageBubble.vue @@ -733,7 +733,7 @@ const hasContent = computed(() => { // The segments auto-collapse when a thinking phase completes, so the final // answer stays the focal point even with reasoning visible. debugMode remains // a separate switch for tool-call internals and other diagnostics. -const { showThinking } = storeToRefs(useSystemSettingsStore()) +const { showThinking, thinkingFull } = storeToRefs(useSystemSettingsStore()) const showThinkingPanel = computed(() => showThinking.value && !!thinkingContent.value) @@ -1098,6 +1098,23 @@ const parsedMetadata = computed(() => { return raw }) +/** + * Apply the "full reasoning" preference. When off, only the reasoning span + * that produced the answer survives — the last one in the timeline. Everything + * is still persisted and still exported by the trajectory endpoint; this is + * purely how much of it the bubble shows. A running span is never dropped, so + * a live turn still shows the model thinking as it goes. + */ +function applyThinkingDetail(segs: MessageSegment[]): MessageSegment[] { + if (thinkingFull.value) return segs + const keepIdx = segs.map((s, i) => (s.type === 'thinking' ? i : -1)) + .filter(i => i >= 0) + .pop() + if (keepIdx === undefined) return segs + return segs.filter((s, i) => + s.type !== 'thinking' || i === keepIdx || s.status === 'running') +} + const segments = computed(() => { if (props.message.role !== 'assistant') return [] const meta = parsedMetadata.value @@ -1159,7 +1176,7 @@ const segments = computed(() => { segs.sort((a, b) => (a.seq as number) - (b.seq as number)) } - return segs + return applyThinkingDetail(segs) } // Fallback:从 toolCalls + contentParts 做 best-effort 重建(旧消息兼容) diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 4590fe40..cbbd9ef8 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -1162,6 +1162,7 @@ export default { streamEnabled: 'Stream Response', debugMode: 'Debug Mode', showThinking: 'Show Thinking Process', + thinkingFull: 'Keep Full Reasoning', workspaceStorageRoot: 'Default Workspace Storage Path', searchEnabled: 'Enable Search', searchProvider: 'Search Provider', @@ -1215,6 +1216,7 @@ export default { streamEnabled: 'Controls whether chat prefers streaming output in UI settings.', debugMode: 'Reserved for showing more execution details later.', showThinking: 'Render the model\'s reasoning in chat (collapsible). The "Deep Thinking" toggle in the chat input controls whether the model thinks; this only controls whether it is displayed.', + thinkingFull: 'Show every iteration\'s reasoning instead of only the one that produced the answer. A tool-heavy turn has a dozen spans; all of them is what lets you review why each tool was called, so turn this off when you only want the conclusion. Either way the full reasoning is persisted and available from the trajectory export.', workspaceStorageRoot: 'Files of new conversations and workspaces are stored under this path (the global fallback directory). Takes effect immediately and never migrates existing data; leave blank to use the server default. Must be an absolute path.', workspaceStorageRootPlaceholder: 'Leave blank for the server default, e.g. /data/mateclaw/workspace', searchEnabled: 'When disabled, the search tool will be unavailable to agents.', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 5f4203d2..f89a8072 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -1024,6 +1024,7 @@ export default { streamEnabled: '流式响应', debugMode: '调试模式', showThinking: '显示思考过程', + thinkingFull: '保留完整推理', workspaceStorageRoot: '默认工作空间存储路径', searchEnabled: '启用搜索', searchProvider: '搜索提供商', @@ -1083,6 +1084,7 @@ export default { streamEnabled: '用于控制前端默认流式响应偏好。', debugMode: '预留给后续执行明细展示。', showThinking: '在聊天中展示模型的思考过程(可随时折叠)。聊天输入框的"深度思考"开关决定模型是否思考,本开关只决定界面是否展示。', + thinkingFull: '展示每一轮的思考,而不是只展示得出答案的那一轮。用了很多工具的回合会有十几段推理,全部展示才能复盘每次工具调用的依据,只看结论时可以关掉。无论开关如何,完整推理都会落库,也都能通过轨迹导出拿到。', workspaceStorageRoot: '新建会话、工作空间的文件将存放在该路径下(作为全局兜底目录)。修改后立即生效,不影响已有数据;留空则使用服务端默认位置。必须为绝对路径。', workspaceStorageRootPlaceholder: '留空使用服务端默认位置,例如 /data/mateclaw/workspace', searchEnabled: '关闭后搜索工具将不可用,Agent 无法联网搜索。', diff --git a/mateclaw-ui/src/stores/useSystemSettingsStore.ts b/mateclaw-ui/src/stores/useSystemSettingsStore.ts index 5f1a1a2a..d1839a6d 100644 --- a/mateclaw-ui/src/stores/useSystemSettingsStore.ts +++ b/mateclaw-ui/src/stores/useSystemSettingsStore.ts @@ -19,6 +19,7 @@ interface CachedSettings { streamEnabled: boolean debugMode: boolean showThinking: boolean + thinkingFull: boolean } function readCache(): CachedSettings { @@ -30,10 +31,11 @@ function readCache(): CachedSettings { streamEnabled: parsed.streamEnabled !== false, // default true debugMode: parsed.debugMode === true, // default false showThinking: parsed.showThinking !== false, // default true + thinkingFull: parsed.thinkingFull !== false, // default true } } } catch { /* ignore */ } - return { streamEnabled: true, debugMode: false, showThinking: true } + return { streamEnabled: true, debugMode: false, showThinking: true, thinkingFull: true } } export const useSystemSettingsStore = defineStore('systemSettings', () => { @@ -46,6 +48,11 @@ export const useSystemSettingsStore = defineStore('systemSettings', () => { // Whether the model's reasoning ("thinking") blocks are rendered in chat. // Independent from debugMode: this is a user preference, not a debug aid. const showThinking = ref(cached.showThinking) + // Whether every iteration's reasoning is rendered, or only the span that + // produced the answer. A tool-heavy turn persists a dozen spans; showing all + // of them is what makes a run reviewable, but it is a wall of text when the + // reader only wants the conclusion. + const thinkingFull = ref(cached.thinkingFull) function persist() { try { @@ -53,6 +60,7 @@ export const useSystemSettingsStore = defineStore('systemSettings', () => { streamEnabled: streamEnabled.value, debugMode: debugMode.value, showThinking: showThinking.value, + thinkingFull: thinkingFull.value, })) } catch { /* ignore */ } } @@ -63,6 +71,7 @@ export const useSystemSettingsStore = defineStore('systemSettings', () => { if (typeof settings.streamEnabled === 'boolean') streamEnabled.value = settings.streamEnabled if (typeof settings.debugMode === 'boolean') debugMode.value = settings.debugMode if (typeof settings.showThinking === 'boolean') showThinking.value = settings.showThinking + if (typeof settings.thinkingFull === 'boolean') thinkingFull.value = settings.thinkingFull persist() } @@ -74,7 +83,7 @@ export const useSystemSettingsStore = defineStore('systemSettings', () => { } catch { /* keep cached defaults */ } } - return { streamEnabled, debugMode, showThinking, apply, load } + return { streamEnabled, debugMode, showThinking, thinkingFull, apply, load } }) if (import.meta.hot) { diff --git a/mateclaw-ui/src/types/index.ts b/mateclaw-ui/src/types/index.ts index a1eea53c..be5d18ed 100644 --- a/mateclaw-ui/src/types/index.ts +++ b/mateclaw-ui/src/types/index.ts @@ -835,6 +835,9 @@ export interface SystemSettings { debugMode: boolean // Whether chat renders the model's reasoning ("thinking") blocks; default true showThinking: boolean + // Whether chat renders every iteration's reasoning or only the span that + // produced the answer; default true. Only meaningful while showThinking is on. + thinkingFull: boolean // Default workspace storage root; '' = use the server-side default workspaceStorageRoot?: string // 搜索服务配置 diff --git a/mateclaw-ui/src/views/Settings/System/index.vue b/mateclaw-ui/src/views/Settings/System/index.vue index e86f46ce..2703918b 100644 --- a/mateclaw-ui/src/views/Settings/System/index.vue +++ b/mateclaw-ui/src/views/Settings/System/index.vue @@ -58,6 +58,19 @@ +

+
+
{{ t('settings.fields.thinkingFull') }}
+
{{ t('settings.hints.thinkingFull') }}
+
+
+ +
+
+
{{ t('settings.fields.workspaceStorageRoot') }}
@@ -359,6 +372,7 @@ const settings = reactive({ streamEnabled: true, debugMode: false, showThinking: true, + thinkingFull: true, workspaceStorageRoot: '', searchEnabled: true, searchProvider: 'serper',