feat(chat): add reasoning retention controls and a linear trajectory export

This commit is contained in:
matevip 2026-08-06 05:48:28 -04:00
parent 22c49f3a07
commit ed7f6f5c19
17 changed files with 480 additions and 9 deletions

View File

@ -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) {

View File

@ -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().<String>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().<String>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));

View File

@ -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.
* <p>
* <b>Tune in {@code application.yml} under {@code mate.agent.reasoning}, not in
* the Java field defaults.</b> The yml is the source of truth; the field default
* below is a conservative fallback for tests / unit constructors.
* <p>
* 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
}
}

View File

@ -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;

View File

@ -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;
/**

View File

@ -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");
}

View File

@ -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<MessageEntity> messages = listMessages(conversationId);
List<String> 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

View File

@ -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.
* <p>
* 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.
* <p>
* Output is plain text and intentionally boring, so it can be pasted into an
* issue, diffed between two runs, or grepped:
* <pre>
* ## [3] assistant
* &lt;think&gt;
* ...
* &lt;/think&gt;
* &lt;tool_call name="execute_code"&gt;
* {"code": "..."}
* &lt;/tool_call&gt;
* &lt;tool_response success="true"&gt;
* ...
* &lt;/tool_response&gt;
* </pre>
*
* @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<MessageEntity> messages, List<String> 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<JsonNode> 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<JsonNode> orderedSegments(MessageEntity message) {
List<JsonNode> 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("<think>\n");
appendBlock(out, seg.path("thinkingText").asText(""));
out.append("</think>\n");
}
case "tool_call" -> {
out.append("<tool_call name=\"").append(seg.path("toolName").asText("")).append("\">\n");
appendBlock(out, seg.path("toolArgs").asText(""));
out.append("</tool_call>\n");
out.append("<tool_response success=\"")
.append(seg.path("toolSuccess").asBoolean(true)).append("\">\n");
appendBlock(out, seg.path("toolResult").asText(""));
out.append("</tool_response>\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("<content superseded=\"true\">\n");
} else {
out.append("<content>\n");
}
appendBlock(out, seg.path("text").asText(""));
out.append("</content>\n");
}
default -> {
out.append("<segment type=\"").append(type).append("\"/>\n");
}
}
}
private static String textAt(List<String> 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');
}
}

View File

@ -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));
}
/**
* 导出会话轨迹 调试/验收用的线性纯文本转录
* <p>
* 与聊天界面读同一份 {@code metadata.segments} 时间线但按发射顺序原样打印
* 每轮推理工具调用工具返回答案各自成块包括界面会折叠掉的
* superseded 预写内容可直接 diff 两次运行或贴进 issue
*/
@Operation(summary = "导出会话轨迹(纯文本)")
@GetMapping(value = "/{conversationId}/trajectory", produces = "text/plain;charset=UTF-8")
public ResponseEntity<String> 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));
}
/**
* 获取指定会话的消息历史支持分页
* <p>

View File

@ -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 设计

View File

@ -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("<think>");
int call = out.indexOf("<tool_call name=\"clock\">");
int response = out.indexOf("<tool_response success=\"true\">");
int content = out.indexOf("<content>");
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("<content superseded=\"true\">"), 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("<think>"), 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("<think>"), 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);
}
}

View File

@ -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<MessageSegment[]>(() => {
if (props.message.role !== 'assistant') return []
const meta = parsedMetadata.value
@ -1159,7 +1176,7 @@ const segments = computed<MessageSegment[]>(() => {
segs.sort((a, b) => (a.seq as number) - (b.seq as number))
}
return segs
return applyThinkingDetail(segs)
}
// Fallback toolCalls + contentParts best-effort

View File

@ -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.',

View File

@ -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 无法联网搜索。',

View File

@ -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<boolean>(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<boolean>(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) {

View File

@ -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
// 搜索服务配置

View File

@ -58,6 +58,19 @@
</div>
</div>
<div v-if="settings.showThinking" class="setting-item">
<div class="setting-info">
<div class="setting-label">{{ t('settings.fields.thinkingFull') }}</div>
<div class="setting-hint">{{ t('settings.hints.thinkingFull') }}</div>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input v-model="settings.thinkingFull" type="checkbox" />
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="setting-item setting-item-vertical">
<div class="setting-info">
<div class="setting-label">{{ t('settings.fields.workspaceStorageRoot') }}</div>
@ -359,6 +372,7 @@ const settings = reactive<SystemSettings>({
streamEnabled: true,
debugMode: false,
showThinking: true,
thinkingFull: true,
workspaceStorageRoot: '',
searchEnabled: true,
searchProvider: 'serper',