mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 11:58:34 +08:00
fix(chat): persist planning reasoning, dedupe the plan summary, normalize metadata reads
This commit is contained in:
parent
e8d5e73825
commit
99c18b968a
@ -702,6 +702,7 @@ public class AgentGraphBuilder {
|
|||||||
// Thinking 键
|
// Thinking 键
|
||||||
.addStrategy(PlanStateKeys.FINAL_SUMMARY_THINKING, KeyStrategy.REPLACE)
|
.addStrategy(PlanStateKeys.FINAL_SUMMARY_THINKING, KeyStrategy.REPLACE)
|
||||||
.addStrategy(PlanStateKeys.CURRENT_STEP_THINKING, KeyStrategy.REPLACE)
|
.addStrategy(PlanStateKeys.CURRENT_STEP_THINKING, KeyStrategy.REPLACE)
|
||||||
|
.addStrategy(PlanStateKeys.PLAN_THINKING, KeyStrategy.REPLACE)
|
||||||
// 流式防重键
|
// 流式防重键
|
||||||
.addStrategy(MateClawStateKeys.CONTENT_STREAMED, KeyStrategy.REPLACE)
|
.addStrategy(MateClawStateKeys.CONTENT_STREAMED, KeyStrategy.REPLACE)
|
||||||
.addStrategy(MateClawStateKeys.THINKING_STREAMED, KeyStrategy.REPLACE)
|
.addStrategy(MateClawStateKeys.THINKING_STREAMED, KeyStrategy.REPLACE)
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import vip.mate.llm.routing.model.MultimodalRoutingDecision;
|
|||||||
import vip.mate.llm.service.ModelCapabilityService;
|
import vip.mate.llm.service.ModelCapabilityService;
|
||||||
import org.springframework.ai.chat.messages.ToolResponseMessage;
|
import org.springframework.ai.chat.messages.ToolResponseMessage;
|
||||||
import vip.mate.workspace.conversation.ConversationService;
|
import vip.mate.workspace.conversation.ConversationService;
|
||||||
|
import vip.mate.workspace.conversation.MessageMetadataJson;
|
||||||
import vip.mate.workspace.conversation.model.MessageContentPart;
|
import vip.mate.workspace.conversation.model.MessageContentPart;
|
||||||
import vip.mate.workspace.conversation.model.MessageEntity;
|
import vip.mate.workspace.conversation.model.MessageEntity;
|
||||||
|
|
||||||
@ -811,8 +812,15 @@ public abstract class BaseAgent {
|
|||||||
if (msg == null) return List.of();
|
if (msg == null) return List.of();
|
||||||
String metadata = msg.getMetadata();
|
String metadata = msg.getMetadata();
|
||||||
if (metadata == null || metadata.isEmpty()) return List.of();
|
if (metadata == null || metadata.isEmpty()) return List.of();
|
||||||
if (!metadata.contains("\"directToolNames\"")) return List.of();
|
// Guard on the bare key, not on `"directToolNames"`: the escaped form
|
||||||
java.util.regex.Matcher arrayMatcher = DIRECT_TOOL_NAMES_ARRAY.matcher(metadata);
|
// reads \"directToolNames\", where the quotes are no longer adjacent to
|
||||||
|
// the name, so a quoted guard exits early on every H2-backed row and the
|
||||||
|
// badge silently disappears. Bare-key matching holds for both forms and
|
||||||
|
// keeps the common case (no such key) allocation-free; the exact match
|
||||||
|
// then runs against normalized JSON.
|
||||||
|
if (!metadata.contains("directToolNames")) return List.of();
|
||||||
|
java.util.regex.Matcher arrayMatcher =
|
||||||
|
DIRECT_TOOL_NAMES_ARRAY.matcher(MessageMetadataJson.normalize(metadata));
|
||||||
if (!arrayMatcher.find()) return List.of();
|
if (!arrayMatcher.find()) return List.of();
|
||||||
String inner = arrayMatcher.group(1);
|
String inner = arrayMatcher.group(1);
|
||||||
java.util.regex.Matcher nameMatcher = DIRECT_TOOL_NAMES_INNER.matcher(inner);
|
java.util.regex.Matcher nameMatcher = DIRECT_TOOL_NAMES_INNER.matcher(inner);
|
||||||
|
|||||||
@ -155,6 +155,11 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
|
|||||||
// 去重:记录上一次已持久化的 step 结果和 thinking,防止 PlanSummaryNode 重复 emit 上一步内容
|
// 去重:记录上一次已持久化的 step 结果和 thinking,防止 PlanSummaryNode 重复 emit 上一步内容
|
||||||
AtomicReference<String> lastPersistedStepResult = new AtomicReference<>("");
|
AtomicReference<String> lastPersistedStepResult = new AtomicReference<>("");
|
||||||
AtomicReference<String> lastPersistedStepThinking = new AtomicReference<>("");
|
AtomicReference<String> lastPersistedStepThinking = new AtomicReference<>("");
|
||||||
|
// 最终汇总同样需要游标:FINAL_SUMMARY / FINAL_SUMMARY_THINKING 也是 REPLACE,
|
||||||
|
// 一旦写入就会出现在此后每个 NodeOutput 上。
|
||||||
|
AtomicReference<String> lastPersistedSummary = new AtomicReference<>("");
|
||||||
|
AtomicReference<String> lastPersistedSummaryThinking = new AtomicReference<>("");
|
||||||
|
AtomicReference<String> lastPersistedPlanThinking = new AtomicReference<>("");
|
||||||
|
|
||||||
return BaseAgent.routingStartupDelta(inputs).concatWith(compiledGraph.stream(inputs, config)
|
return BaseAgent.routingStartupDelta(inputs).concatWith(compiledGraph.stream(inputs, config)
|
||||||
.flatMapIterable(output -> {
|
.flatMapIterable(output -> {
|
||||||
@ -176,6 +181,17 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
|
|||||||
boolean thinkingAlreadyStreamed = output.state()
|
boolean thinkingAlreadyStreamed = output.state()
|
||||||
.value(MateClawStateKeys.THINKING_STREAMED, false);
|
.value(MateClawStateKeys.THINKING_STREAMED, false);
|
||||||
|
|
||||||
|
// 2·0 规划阶段的推理。它先于计划本身发出,且是整轮唯一必然发生的
|
||||||
|
// 一段推理 —— 步骤被派发到别处执行时,step / summary 两段
|
||||||
|
// 根本不会产生,此前这一轮就一段思考都不落库。
|
||||||
|
output.state().<String>value(PlanStateKeys.PLAN_THINKING)
|
||||||
|
.filter(s -> !s.isEmpty())
|
||||||
|
.filter(s -> !s.equals(lastPersistedPlanThinking.get()))
|
||||||
|
.ifPresent(planThinking -> {
|
||||||
|
lastPersistedPlanThinking.set(planThinking);
|
||||||
|
deltas.add(AgentService.StreamDelta.persistOnly(null, planThinking));
|
||||||
|
});
|
||||||
|
|
||||||
// 2a. 各步骤执行结果(StepExecutionNode 已通过 NodeStreamingChatHelper 直推 SSE,
|
// 2a. 各步骤执行结果(StepExecutionNode 已通过 NodeStreamingChatHelper 直推 SSE,
|
||||||
// 这里仅作为 persistOnly 送入 Accumulator,确保写入 mate_message)
|
// 这里仅作为 persistOnly 送入 Accumulator,确保写入 mate_message)
|
||||||
// 利用内容本身去重,避免 PlanSummaryNode 输出时重复 emit 上一步残留在 state 的值
|
// 利用内容本身去重,避免 PlanSummaryNode 输出时重复 emit 上一步残留在 state 的值
|
||||||
@ -199,17 +215,30 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 2b. 最终汇总(同样 thinking 先于 content)
|
// 2b. 最终汇总(同样 thinking 先于 content)
|
||||||
|
// 两个 key 都是 REPLACE:值会滞留在后续每个 NodeOutput 里。
|
||||||
|
// 没有游标时每批都会重发一次 —— 汇总正文被反复追加进
|
||||||
|
// mate_message.content,而 thinking 会在正文之后再落一段,
|
||||||
|
// 于是气泡末尾挂出一个孤立的思考框。与 2a 的 step 级
|
||||||
|
// 去重保持同一套写法。
|
||||||
output.state().<String>value(PlanStateKeys.FINAL_SUMMARY_THINKING)
|
output.state().<String>value(PlanStateKeys.FINAL_SUMMARY_THINKING)
|
||||||
.filter(s -> !s.isEmpty())
|
.filter(s -> !s.isEmpty())
|
||||||
.ifPresent(thinking -> deltas.add(thinkingAlreadyStreamed
|
.filter(s -> !s.equals(lastPersistedSummaryThinking.get()))
|
||||||
? AgentService.StreamDelta.persistOnly(null, thinking)
|
.ifPresent(thinking -> {
|
||||||
: new AgentService.StreamDelta(null, thinking)));
|
lastPersistedSummaryThinking.set(thinking);
|
||||||
|
deltas.add(thinkingAlreadyStreamed
|
||||||
|
? AgentService.StreamDelta.persistOnly(null, thinking)
|
||||||
|
: new AgentService.StreamDelta(null, thinking));
|
||||||
|
});
|
||||||
|
|
||||||
output.state().<String>value(PlanStateKeys.FINAL_SUMMARY)
|
output.state().<String>value(PlanStateKeys.FINAL_SUMMARY)
|
||||||
.filter(s -> !s.isEmpty())
|
.filter(s -> !s.isEmpty())
|
||||||
.ifPresent(summary -> deltas.add(contentAlreadyStreamed
|
.filter(s -> !s.equals(lastPersistedSummary.get()))
|
||||||
? AgentService.StreamDelta.persistOnly(summary, null)
|
.ifPresent(summary -> {
|
||||||
: new AgentService.StreamDelta(summary, null)));
|
lastPersistedSummary.set(summary);
|
||||||
|
deltas.add(contentAlreadyStreamed
|
||||||
|
? AgentService.StreamDelta.persistOnly(summary, null)
|
||||||
|
: new AgentService.StreamDelta(summary, null));
|
||||||
|
});
|
||||||
|
|
||||||
// 3. 更新最新累计 token usage
|
// 3. 更新最新累计 token usage
|
||||||
finalPromptTokens.set(output.state().value(MateClawStateKeys.PROMPT_TOKENS, 0));
|
finalPromptTokens.set(output.state().value(MateClawStateKeys.PROMPT_TOKENS, 0));
|
||||||
|
|||||||
@ -699,7 +699,8 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
.planValid(true)
|
.planValid(true)
|
||||||
.currentStepIndex(0)
|
.currentStepIndex(0)
|
||||||
.currentPhase("plan_generated")
|
.currentPhase("plan_generated")
|
||||||
.thinkingStreamed(!result.thinking().isEmpty())
|
.planThinking(result.thinking())
|
||||||
|
.thinkingStreamed(!result.thinking().isEmpty())
|
||||||
.mergeUsage(state, result)
|
.mergeUsage(state, result)
|
||||||
.events(events)
|
.events(events)
|
||||||
.build();
|
.build();
|
||||||
@ -714,6 +715,7 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
.directAnswer(directAnswer)
|
.directAnswer(directAnswer)
|
||||||
.currentPhase("direct_answer")
|
.currentPhase("direct_answer")
|
||||||
.contentStreamed(true)
|
.contentStreamed(true)
|
||||||
|
.planThinking(result.thinking())
|
||||||
.thinkingStreamed(!result.thinking().isEmpty())
|
.thinkingStreamed(!result.thinking().isEmpty())
|
||||||
.mergeUsage(state, result)
|
.mergeUsage(state, result)
|
||||||
.events(events)
|
.events(events)
|
||||||
@ -755,7 +757,8 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
.directAnswer(announcement)
|
.directAnswer(announcement)
|
||||||
.currentPhase("direct_answer")
|
.currentPhase("direct_answer")
|
||||||
.contentStreamed(true)
|
.contentStreamed(true)
|
||||||
.thinkingStreamed(!result.thinking().isEmpty())
|
.planThinking(result.thinking())
|
||||||
|
.thinkingStreamed(!result.thinking().isEmpty())
|
||||||
.mergeUsage(state, result)
|
.mergeUsage(state, result)
|
||||||
.events(events)
|
.events(events)
|
||||||
.build();
|
.build();
|
||||||
@ -799,7 +802,8 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
.currentStepIndex(0)
|
.currentStepIndex(0)
|
||||||
.currentPhase("plan_generated")
|
.currentPhase("plan_generated")
|
||||||
.contentStreamed(true)
|
.contentStreamed(true)
|
||||||
.thinkingStreamed(!result.thinking().isEmpty())
|
.planThinking(result.thinking())
|
||||||
|
.thinkingStreamed(!result.thinking().isEmpty())
|
||||||
.mergeUsage(state, result)
|
.mergeUsage(state, result)
|
||||||
.events(events);
|
.events(events);
|
||||||
if (autoGoal != null) {
|
if (autoGoal != null) {
|
||||||
|
|||||||
@ -94,6 +94,10 @@ public final class PlanStateAccessor {
|
|||||||
return state.value(FINAL_SUMMARY_THINKING, "");
|
return state.value(FINAL_SUMMARY_THINKING, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String planThinking() {
|
||||||
|
return state.value(PLAN_THINKING, "");
|
||||||
|
}
|
||||||
|
|
||||||
public String currentStepThinking() {
|
public String currentStepThinking() {
|
||||||
return state.value(CURRENT_STEP_THINKING, "");
|
return state.value(CURRENT_STEP_THINKING, "");
|
||||||
}
|
}
|
||||||
@ -225,6 +229,10 @@ public final class PlanStateAccessor {
|
|||||||
return put(FINAL_SUMMARY_THINKING, thinking);
|
return put(FINAL_SUMMARY_THINKING, thinking);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public OutputBuilder planThinking(String thinking) {
|
||||||
|
return put(PLAN_THINKING, thinking);
|
||||||
|
}
|
||||||
|
|
||||||
public OutputBuilder currentStepThinking(String thinking) {
|
public OutputBuilder currentStepThinking(String thinking) {
|
||||||
return put(CURRENT_STEP_THINKING, thinking);
|
return put(CURRENT_STEP_THINKING, thinking);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -56,6 +56,15 @@ public final class PlanStateKeys {
|
|||||||
/** 当前步骤的完整 thinking */
|
/** 当前步骤的完整 thinking */
|
||||||
public static final String CURRENT_STEP_THINKING = "current_step_thinking";
|
public static final String CURRENT_STEP_THINKING = "current_step_thinking";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 规划阶段的完整 thinking —— 决定整个计划长什么样的那次推理。
|
||||||
|
* <p>
|
||||||
|
* It is the most consequential reasoning of the turn and the only one that
|
||||||
|
* exists when the steps are dispatched elsewhere instead of executed in
|
||||||
|
* this run, which is when the step / summary spans never happen at all.
|
||||||
|
*/
|
||||||
|
public static final String PLAN_THINKING = "plan_thinking";
|
||||||
|
|
||||||
// ===== 节点名称 =====
|
// ===== 节点名称 =====
|
||||||
public static final String PLAN_GENERATION_NODE = "plan_generation";
|
public static final String PLAN_GENERATION_NODE = "plan_generation";
|
||||||
public static final String STEP_EXECUTION_NODE = "step_execution";
|
public static final String STEP_EXECUTION_NODE = "step_execution";
|
||||||
|
|||||||
@ -25,6 +25,7 @@ import vip.mate.approval.PendingApproval;
|
|||||||
import vip.mate.approval.ResolveOutcome;
|
import vip.mate.approval.ResolveOutcome;
|
||||||
import vip.mate.memory.event.ConversationCompletionPublisher;
|
import vip.mate.memory.event.ConversationCompletionPublisher;
|
||||||
import vip.mate.workspace.conversation.ConversationService;
|
import vip.mate.workspace.conversation.ConversationService;
|
||||||
|
import vip.mate.workspace.conversation.MessageMetadataJson;
|
||||||
import vip.mate.workspace.conversation.model.MessageContentPart;
|
import vip.mate.workspace.conversation.model.MessageContentPart;
|
||||||
import vip.mate.workspace.conversation.model.MessageEntity;
|
import vip.mate.workspace.conversation.model.MessageEntity;
|
||||||
|
|
||||||
@ -1707,7 +1708,12 @@ public class ChatController {
|
|||||||
String rawMetadata = savedAssistant.getMetadata();
|
String rawMetadata = savedAssistant.getMetadata();
|
||||||
if (rawMetadata != null && !rawMetadata.isBlank()) {
|
if (rawMetadata != null && !rawMetadata.isBlank()) {
|
||||||
try {
|
try {
|
||||||
Map<String, Object> parsed = objectMapper.readValue(rawMetadata,
|
// Without the unwrap this readValue throws on the H2 profile
|
||||||
|
// and the catch below swallows it, so the superseded markers
|
||||||
|
// never ride the done payload and every client waits for a
|
||||||
|
// reload instead — a degradation with no symptom in the log.
|
||||||
|
Map<String, Object> parsed = objectMapper.readValue(
|
||||||
|
MessageMetadataJson.normalize(rawMetadata),
|
||||||
new com.fasterxml.jackson.core.type.TypeReference<Map<String, Object>>() {});
|
new com.fasterxml.jackson.core.type.TypeReference<Map<String, Object>>() {});
|
||||||
Object segs = parsed.get("segments");
|
Object segs = parsed.get("segments");
|
||||||
if (segs instanceof java.util.List<?> list && !list.isEmpty()) {
|
if (segs instanceof java.util.List<?> list && !list.isEmpty()) {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
package vip.mate.memory.service;
|
package vip.mate.memory.service;
|
||||||
|
|
||||||
|
import vip.mate.workspace.conversation.MessageMetadataJson;
|
||||||
import vip.mate.workspace.conversation.model.MessageEntity;
|
import vip.mate.workspace.conversation.model.MessageEntity;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@ -105,7 +106,12 @@ final class MemorySummarizationGate {
|
|||||||
if (metadata == null || metadata.isBlank()) {
|
if (metadata == null || metadata.isBlank()) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
Matcher matcher = FINISH_REASON.matcher(metadata);
|
// The pattern matches `"finishReason":"x"`, which the escaped form
|
||||||
|
// (`\"finishReason\":\"x\"`) does not contain — the gate would then see
|
||||||
|
// no reason at all and promote incomplete / stopped / errored turns
|
||||||
|
// into long-term memory, the exact guess-from-text behaviour the
|
||||||
|
// structured field exists to avoid.
|
||||||
|
Matcher matcher = FINISH_REASON.matcher(MessageMetadataJson.normalize(metadata));
|
||||||
if (!matcher.find()) {
|
if (!matcher.find()) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,58 @@
|
|||||||
|
package vip.mate.workspace.conversation;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes the raw {@code mate_message.metadata} column value into parseable JSON.
|
||||||
|
* <p>
|
||||||
|
* The column is declared {@code JSON}. Read back through MyBatis, H2 hands it
|
||||||
|
* over as a JSON <em>string literal</em> — the whole document quoted and
|
||||||
|
* escaped — while MySQL and PostgreSQL return the object text directly. Code
|
||||||
|
* that parses the raw value therefore works in production and quietly stops
|
||||||
|
* working on the desktop/dev H2 profile.
|
||||||
|
* <p>
|
||||||
|
* The failure is always silent, never an exception the caller notices:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code readTree} yields a {@code TextNode}, so every field lookup misses
|
||||||
|
* and the metadata reads as absent rather than as unparsed;</li>
|
||||||
|
* <li>{@code readValue(.., Map.class)} throws, and these call sites all sit
|
||||||
|
* inside a best-effort {@code catch} that degrades instead of failing;</li>
|
||||||
|
* <li>a regex over the raw text stops matching, because {@code "key":"value"}
|
||||||
|
* has become {@code \"key\":\"value\"} — the key still greps, so a
|
||||||
|
* {@code contains} guard passes and only the extraction comes up empty.</li>
|
||||||
|
* </ul>
|
||||||
|
* Call {@link #normalize(String)} before parsing or matching.
|
||||||
|
*
|
||||||
|
* @author MateClaw Team
|
||||||
|
*/
|
||||||
|
public final class MessageMetadataJson {
|
||||||
|
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
|
private MessageMetadataJson() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the metadata as plain JSON text, unwrapping one layer of string
|
||||||
|
* encoding when present. Returns the input unchanged when it is already
|
||||||
|
* plain JSON, blank, or not decodable — callers keep their existing
|
||||||
|
* behaviour for values this cannot improve.
|
||||||
|
*/
|
||||||
|
public static String normalize(String raw) {
|
||||||
|
if (raw == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String json = raw.trim();
|
||||||
|
if (json.length() < 2 || json.charAt(0) != '"' || json.charAt(json.length() - 1) != '"') {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
String unwrapped = MAPPER.readValue(json, String.class);
|
||||||
|
return unwrapped != null ? unwrapped : raw;
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Not a JSON string literal after all (e.g. truncated). Hand back
|
||||||
|
// the original so the caller's own error handling decides.
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,80 @@
|
|||||||
|
package vip.mate.workspace.conversation;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pins the metadata normalization every reader of {@code mate_message.metadata}
|
||||||
|
* depends on, and the two failure shapes it exists to prevent.
|
||||||
|
*/
|
||||||
|
class MessageMetadataJsonTest {
|
||||||
|
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
private static final String PLAIN =
|
||||||
|
"{\"finishReason\":\"incomplete\",\"directToolNames\":[\"readFile\"],\"segments\":[]}";
|
||||||
|
|
||||||
|
private static String asH2ReturnsIt(String json) throws Exception {
|
||||||
|
return MAPPER.writeValueAsString(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("a JSON string literal is unwrapped to the document it holds")
|
||||||
|
void unwrapsStringLiteral() throws Exception {
|
||||||
|
assertEquals(PLAIN, MessageMetadataJson.normalize(asH2ReturnsIt(PLAIN)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("plain JSON passes through untouched")
|
||||||
|
void passesPlainJsonThrough() {
|
||||||
|
assertEquals(PLAIN, MessageMetadataJson.normalize(PLAIN));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("null, blank and undecodable values are handed back as-is")
|
||||||
|
void leavesUnusableValuesAlone() {
|
||||||
|
assertNull(MessageMetadataJson.normalize(null));
|
||||||
|
assertEquals("", MessageMetadataJson.normalize(""));
|
||||||
|
assertEquals("{not json", MessageMetadataJson.normalize("{not json"));
|
||||||
|
// Opens like a string literal but cannot be decoded — the caller's own
|
||||||
|
// error handling should see the original, not a silently mangled value.
|
||||||
|
assertEquals("\"unterminated", MessageMetadataJson.normalize("\"unterminated"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("key-matching regexes miss the escaped form — the reason normalize exists")
|
||||||
|
void escapedFormDefeatsRegexes() throws Exception {
|
||||||
|
// Same patterns the finish-reason gate and the direct-tool-name reader use.
|
||||||
|
Pattern finishReason = Pattern.compile("\"(?:finishReason|finish_reason)\"\\s*:\\s*\"([^\"]+)\"");
|
||||||
|
Pattern directToolNames = Pattern.compile(
|
||||||
|
"\"directToolNames\"\\s*:\\s*\\[(\\s*\"[^\"]*\"\\s*(?:,\\s*\"[^\"]*\"\\s*)*)\\]");
|
||||||
|
String wrapped = asH2ReturnsIt(PLAIN);
|
||||||
|
|
||||||
|
assertTrue(wrapped.contains("finishReason"),
|
||||||
|
"the bare key still greps — a guard written that way keeps working");
|
||||||
|
assertFalse(wrapped.contains("\"finishReason\""),
|
||||||
|
"a quoted guard does NOT: escaping puts a backslash between the quote and the name, "
|
||||||
|
+ "so such a guard exits early and the reader never even reaches its pattern");
|
||||||
|
assertFalse(finishReason.matcher(wrapped).find(), "escaped form must not match");
|
||||||
|
assertFalse(directToolNames.matcher(wrapped).find(), "escaped form must not match");
|
||||||
|
|
||||||
|
String normalized = MessageMetadataJson.normalize(wrapped);
|
||||||
|
assertTrue(finishReason.matcher(normalized).find());
|
||||||
|
assertTrue(directToolNames.matcher(normalized).find());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("normalized output is parseable as an object, not a text node")
|
||||||
|
void normalizedOutputParsesAsObject() throws Exception {
|
||||||
|
var node = MAPPER.readTree(MessageMetadataJson.normalize(asH2ReturnsIt(PLAIN)));
|
||||||
|
assertTrue(node.isObject(), "a text node is how this failure looks when unnoticed");
|
||||||
|
assertEquals("incomplete", node.path("finishReason").asText());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -47,6 +47,18 @@
|
|||||||
<!-- ===== 分段式渲染模式(Claude Code 风格)===== -->
|
<!-- ===== 分段式渲染模式(Claude Code 风格)===== -->
|
||||||
<template v-if="useSegmentedView">
|
<template v-if="useSegmentedView">
|
||||||
<div class="segments-view">
|
<div class="segments-view">
|
||||||
|
<!-- The "full reasoning" preference is hiding earlier spans. Say so:
|
||||||
|
silently removing them reads as reasoning that went missing. -->
|
||||||
|
<button
|
||||||
|
v-if="hiddenThinkingCount > 0 && showThinking"
|
||||||
|
class="superseded-toggle"
|
||||||
|
type="button"
|
||||||
|
@click="earlyThinkingExpanded = true"
|
||||||
|
>
|
||||||
|
<el-icon><InfoFilled /></el-icon>
|
||||||
|
<span>{{ $t('chat.earlierThinkingCollapsed', { count: hiddenThinkingCount }) }}</span>
|
||||||
|
<span class="superseded-toggle__action">{{ $t('chat.expand') }}</span>
|
||||||
|
</button>
|
||||||
<template v-for="iter in groupedIterations" :key="iter.key">
|
<template v-for="iter in groupedIterations" :key="iter.key">
|
||||||
<!-- Iteration interrupted before any output landed — surface a chip
|
<!-- Iteration interrupted before any output landed — surface a chip
|
||||||
so the user knows the agent moved on instead of silently
|
so the user knows the agent moved on instead of silently
|
||||||
@ -1098,15 +1110,23 @@ const parsedMetadata = computed(() => {
|
|||||||
return raw
|
return raw
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** Per-message override of the "full reasoning" preference (the collapse banner). */
|
||||||
|
const earlyThinkingExpanded = ref(false)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply the "full reasoning" preference. When off, only the reasoning span
|
* Apply the "full reasoning" preference. When off, only the reasoning span
|
||||||
* that produced the answer survives — the last one in the timeline. Everything
|
* that produced the answer survives — the last one in the timeline. Everything
|
||||||
* is still persisted and still exported by the trajectory endpoint; this is
|
* 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
|
* 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.
|
* a live turn still shows the model thinking as it goes.
|
||||||
|
*
|
||||||
|
* Whatever this hides is announced by the banner above the timeline and can be
|
||||||
|
* expanded in place. Dropping spans with no trace is indistinguishable from
|
||||||
|
* losing them — the reader sees reasoning that was there mid-turn simply gone,
|
||||||
|
* and a preference stuck in the off state has no symptom to follow back.
|
||||||
*/
|
*/
|
||||||
function applyThinkingDetail(segs: MessageSegment[]): MessageSegment[] {
|
function applyThinkingDetail(segs: MessageSegment[]): MessageSegment[] {
|
||||||
if (thinkingFull.value) return segs
|
if (thinkingFull.value || earlyThinkingExpanded.value) return segs
|
||||||
const keepIdx = segs.map((s, i) => (s.type === 'thinking' ? i : -1))
|
const keepIdx = segs.map((s, i) => (s.type === 'thinking' ? i : -1))
|
||||||
.filter(i => i >= 0)
|
.filter(i => i >= 0)
|
||||||
.pop()
|
.pop()
|
||||||
@ -1115,6 +1135,15 @@ function applyThinkingDetail(segs: MessageSegment[]): MessageSegment[] {
|
|||||||
s.type !== 'thinking' || i === keepIdx || s.status === 'running')
|
s.type !== 'thinking' || i === keepIdx || s.status === 'running')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How many reasoning spans the preference is currently hiding on this message. */
|
||||||
|
const hiddenThinkingCount = computed(() => {
|
||||||
|
if (thinkingFull.value || earlyThinkingExpanded.value) return 0
|
||||||
|
const all = (parsedMetadata.value?.segments as MessageSegment[] | undefined) || []
|
||||||
|
const total = all.filter(s => s.type === 'thinking').length
|
||||||
|
const shown = segments.value.filter(s => s.type === 'thinking').length
|
||||||
|
return Math.max(0, total - shown)
|
||||||
|
})
|
||||||
|
|
||||||
const segments = computed<MessageSegment[]>(() => {
|
const segments = computed<MessageSegment[]>(() => {
|
||||||
if (props.message.role !== 'assistant') return []
|
if (props.message.role !== 'assistant') return []
|
||||||
const meta = parsedMetadata.value
|
const meta = parsedMetadata.value
|
||||||
|
|||||||
@ -533,6 +533,7 @@ export default {
|
|||||||
contentRepetitionWarning: 'Repetitive content detected near the end (model artifact)',
|
contentRepetitionWarning: 'Repetitive content detected near the end (model artifact)',
|
||||||
supersededPreviewCollapsed: 'Collapsed: content drafted before tool execution (may not match actual results)',
|
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)',
|
supersededPreviewExpanded: 'Below is content drafted before tool execution (may not match actual results)',
|
||||||
|
earlierThinkingCollapsed: '{count} earlier reasoning span(s) collapsed — enable "Keep Full Reasoning" in settings to show them by default',
|
||||||
pendingReply: 'Preparing a reply…',
|
pendingReply: 'Preparing a reply…',
|
||||||
expand: 'Expand',
|
expand: 'Expand',
|
||||||
// INCOMPLETE truncation card (finishReason=incomplete)
|
// INCOMPLETE truncation card (finishReason=incomplete)
|
||||||
|
|||||||
@ -533,6 +533,7 @@ export default {
|
|||||||
contentRepetitionWarning: '检测到内容尾部重复(疑似模型输出 artifact)',
|
contentRepetitionWarning: '检测到内容尾部重复(疑似模型输出 artifact)',
|
||||||
supersededPreviewCollapsed: '已折叠:模型在工具执行前预写的内容(可能与实际结果不符)',
|
supersededPreviewCollapsed: '已折叠:模型在工具执行前预写的内容(可能与实际结果不符)',
|
||||||
supersededPreviewExpanded: '以下是模型在工具执行前预写的内容(可能与实际结果不符)',
|
supersededPreviewExpanded: '以下是模型在工具执行前预写的内容(可能与实际结果不符)',
|
||||||
|
earlierThinkingCollapsed: '更早的 {count} 段思考已折叠(设置里开启「保留完整推理」可默认展开)',
|
||||||
pendingReply: '正在准备回复…',
|
pendingReply: '正在准备回复…',
|
||||||
expand: '展开',
|
expand: '展开',
|
||||||
// INCOMPLETE 截断卡片(finishReason=incomplete)
|
// INCOMPLETE 截断卡片(finishReason=incomplete)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user