mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +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 键
|
||||
.addStrategy(PlanStateKeys.FINAL_SUMMARY_THINKING, KeyStrategy.REPLACE)
|
||||
.addStrategy(PlanStateKeys.CURRENT_STEP_THINKING, KeyStrategy.REPLACE)
|
||||
.addStrategy(PlanStateKeys.PLAN_THINKING, KeyStrategy.REPLACE)
|
||||
// 流式防重键
|
||||
.addStrategy(MateClawStateKeys.CONTENT_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 org.springframework.ai.chat.messages.ToolResponseMessage;
|
||||
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.MessageEntity;
|
||||
|
||||
@ -811,8 +812,15 @@ public abstract class BaseAgent {
|
||||
if (msg == null) return List.of();
|
||||
String metadata = msg.getMetadata();
|
||||
if (metadata == null || metadata.isEmpty()) return List.of();
|
||||
if (!metadata.contains("\"directToolNames\"")) return List.of();
|
||||
java.util.regex.Matcher arrayMatcher = DIRECT_TOOL_NAMES_ARRAY.matcher(metadata);
|
||||
// Guard on the bare key, not on `"directToolNames"`: the escaped form
|
||||
// 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();
|
||||
String inner = arrayMatcher.group(1);
|
||||
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 上一步内容
|
||||
AtomicReference<String> lastPersistedStepResult = 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)
|
||||
.flatMapIterable(output -> {
|
||||
@ -176,6 +181,17 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
|
||||
boolean thinkingAlreadyStreamed = output.state()
|
||||
.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,
|
||||
// 这里仅作为 persistOnly 送入 Accumulator,确保写入 mate_message)
|
||||
// 利用内容本身去重,避免 PlanSummaryNode 输出时重复 emit 上一步残留在 state 的值
|
||||
@ -199,17 +215,30 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
|
||||
});
|
||||
|
||||
// 2b. 最终汇总(同样 thinking 先于 content)
|
||||
// 两个 key 都是 REPLACE:值会滞留在后续每个 NodeOutput 里。
|
||||
// 没有游标时每批都会重发一次 —— 汇总正文被反复追加进
|
||||
// mate_message.content,而 thinking 会在正文之后再落一段,
|
||||
// 于是气泡末尾挂出一个孤立的思考框。与 2a 的 step 级
|
||||
// 去重保持同一套写法。
|
||||
output.state().<String>value(PlanStateKeys.FINAL_SUMMARY_THINKING)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.ifPresent(thinking -> deltas.add(thinkingAlreadyStreamed
|
||||
? AgentService.StreamDelta.persistOnly(null, thinking)
|
||||
: new AgentService.StreamDelta(null, thinking)));
|
||||
.filter(s -> !s.equals(lastPersistedSummaryThinking.get()))
|
||||
.ifPresent(thinking -> {
|
||||
lastPersistedSummaryThinking.set(thinking);
|
||||
deltas.add(thinkingAlreadyStreamed
|
||||
? AgentService.StreamDelta.persistOnly(null, thinking)
|
||||
: new AgentService.StreamDelta(null, thinking));
|
||||
});
|
||||
|
||||
output.state().<String>value(PlanStateKeys.FINAL_SUMMARY)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.ifPresent(summary -> deltas.add(contentAlreadyStreamed
|
||||
? AgentService.StreamDelta.persistOnly(summary, null)
|
||||
: new AgentService.StreamDelta(summary, null)));
|
||||
.filter(s -> !s.equals(lastPersistedSummary.get()))
|
||||
.ifPresent(summary -> {
|
||||
lastPersistedSummary.set(summary);
|
||||
deltas.add(contentAlreadyStreamed
|
||||
? AgentService.StreamDelta.persistOnly(summary, null)
|
||||
: new AgentService.StreamDelta(summary, null));
|
||||
});
|
||||
|
||||
// 3. 更新最新累计 token usage
|
||||
finalPromptTokens.set(output.state().value(MateClawStateKeys.PROMPT_TOKENS, 0));
|
||||
|
||||
@ -699,7 +699,8 @@ public class PlanGenerationNode implements NodeAction {
|
||||
.planValid(true)
|
||||
.currentStepIndex(0)
|
||||
.currentPhase("plan_generated")
|
||||
.thinkingStreamed(!result.thinking().isEmpty())
|
||||
.planThinking(result.thinking())
|
||||
.thinkingStreamed(!result.thinking().isEmpty())
|
||||
.mergeUsage(state, result)
|
||||
.events(events)
|
||||
.build();
|
||||
@ -714,6 +715,7 @@ public class PlanGenerationNode implements NodeAction {
|
||||
.directAnswer(directAnswer)
|
||||
.currentPhase("direct_answer")
|
||||
.contentStreamed(true)
|
||||
.planThinking(result.thinking())
|
||||
.thinkingStreamed(!result.thinking().isEmpty())
|
||||
.mergeUsage(state, result)
|
||||
.events(events)
|
||||
@ -755,7 +757,8 @@ public class PlanGenerationNode implements NodeAction {
|
||||
.directAnswer(announcement)
|
||||
.currentPhase("direct_answer")
|
||||
.contentStreamed(true)
|
||||
.thinkingStreamed(!result.thinking().isEmpty())
|
||||
.planThinking(result.thinking())
|
||||
.thinkingStreamed(!result.thinking().isEmpty())
|
||||
.mergeUsage(state, result)
|
||||
.events(events)
|
||||
.build();
|
||||
@ -799,7 +802,8 @@ public class PlanGenerationNode implements NodeAction {
|
||||
.currentStepIndex(0)
|
||||
.currentPhase("plan_generated")
|
||||
.contentStreamed(true)
|
||||
.thinkingStreamed(!result.thinking().isEmpty())
|
||||
.planThinking(result.thinking())
|
||||
.thinkingStreamed(!result.thinking().isEmpty())
|
||||
.mergeUsage(state, result)
|
||||
.events(events);
|
||||
if (autoGoal != null) {
|
||||
|
||||
@ -94,6 +94,10 @@ public final class PlanStateAccessor {
|
||||
return state.value(FINAL_SUMMARY_THINKING, "");
|
||||
}
|
||||
|
||||
public String planThinking() {
|
||||
return state.value(PLAN_THINKING, "");
|
||||
}
|
||||
|
||||
public String currentStepThinking() {
|
||||
return state.value(CURRENT_STEP_THINKING, "");
|
||||
}
|
||||
@ -225,6 +229,10 @@ public final class PlanStateAccessor {
|
||||
return put(FINAL_SUMMARY_THINKING, thinking);
|
||||
}
|
||||
|
||||
public OutputBuilder planThinking(String thinking) {
|
||||
return put(PLAN_THINKING, thinking);
|
||||
}
|
||||
|
||||
public OutputBuilder currentStepThinking(String thinking) {
|
||||
return put(CURRENT_STEP_THINKING, thinking);
|
||||
}
|
||||
|
||||
@ -56,6 +56,15 @@ public final class PlanStateKeys {
|
||||
/** 当前步骤的完整 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 STEP_EXECUTION_NODE = "step_execution";
|
||||
|
||||
@ -25,6 +25,7 @@ import vip.mate.approval.PendingApproval;
|
||||
import vip.mate.approval.ResolveOutcome;
|
||||
import vip.mate.memory.event.ConversationCompletionPublisher;
|
||||
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.MessageEntity;
|
||||
|
||||
@ -1707,7 +1708,12 @@ public class ChatController {
|
||||
String rawMetadata = savedAssistant.getMetadata();
|
||||
if (rawMetadata != null && !rawMetadata.isBlank()) {
|
||||
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>>() {});
|
||||
Object segs = parsed.get("segments");
|
||||
if (segs instanceof java.util.List<?> list && !list.isEmpty()) {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
package vip.mate.memory.service;
|
||||
|
||||
import vip.mate.workspace.conversation.MessageMetadataJson;
|
||||
import vip.mate.workspace.conversation.model.MessageEntity;
|
||||
|
||||
import java.util.List;
|
||||
@ -105,7 +106,12 @@ final class MemorySummarizationGate {
|
||||
if (metadata == null || metadata.isBlank()) {
|
||||
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()) {
|
||||
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 风格)===== -->
|
||||
<template v-if="useSegmentedView">
|
||||
<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">
|
||||
<!-- Iteration interrupted before any output landed — surface a chip
|
||||
so the user knows the agent moved on instead of silently
|
||||
@ -1098,15 +1110,23 @@ const parsedMetadata = computed(() => {
|
||||
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
|
||||
* 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.
|
||||
*
|
||||
* 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[] {
|
||||
if (thinkingFull.value) return segs
|
||||
if (thinkingFull.value || earlyThinkingExpanded.value) return segs
|
||||
const keepIdx = segs.map((s, i) => (s.type === 'thinking' ? i : -1))
|
||||
.filter(i => i >= 0)
|
||||
.pop()
|
||||
@ -1115,6 +1135,15 @@ function applyThinkingDetail(segs: MessageSegment[]): MessageSegment[] {
|
||||
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[]>(() => {
|
||||
if (props.message.role !== 'assistant') return []
|
||||
const meta = parsedMetadata.value
|
||||
|
||||
@ -533,6 +533,7 @@ export default {
|
||||
contentRepetitionWarning: 'Repetitive content detected near the end (model artifact)',
|
||||
supersededPreviewCollapsed: 'Collapsed: content drafted before tool execution (may not match actual results)',
|
||||
supersededPreviewExpanded: 'Below is content drafted before tool execution (may not match actual results)',
|
||||
earlierThinkingCollapsed: '{count} earlier reasoning span(s) collapsed — enable "Keep Full Reasoning" in settings to show them by default',
|
||||
pendingReply: 'Preparing a reply…',
|
||||
expand: 'Expand',
|
||||
// INCOMPLETE truncation card (finishReason=incomplete)
|
||||
|
||||
@ -533,6 +533,7 @@ export default {
|
||||
contentRepetitionWarning: '检测到内容尾部重复(疑似模型输出 artifact)',
|
||||
supersededPreviewCollapsed: '已折叠:模型在工具执行前预写的内容(可能与实际结果不符)',
|
||||
supersededPreviewExpanded: '以下是模型在工具执行前预写的内容(可能与实际结果不符)',
|
||||
earlierThinkingCollapsed: '更早的 {count} 段思考已折叠(设置里开启「保留完整推理」可默认展开)',
|
||||
pendingReply: '正在准备回复…',
|
||||
expand: '展开',
|
||||
// INCOMPLETE 截断卡片(finishReason=incomplete)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user