fix(backthink): 后台思考静默广播——不再把内部推理泄到聊天思维链 + XML 动作/旁白解析
【静默广播修复(bug:思维链露出 backthink 推理)】 - 根因:LoopThinker.Think 复用同一 loop.Loop 且 loop 全局带 OnStream,backthink 后台思考时 把 [background autonomous reflection] 的 thinking/流式事件广播到前端,被当聊天思维链显示。 - loop/mute_ctx.go:WithMutedBroadcast(ctx) + isMutedBroadcast(ctx)——context 上静默标志。 - loop 四处广播抑制(stream.go 流式 / step.go 静默走非流式 agentStep / request.go capacity 事件 / tools.go tool_execution),静默时跳过 UI 广播,推理照常返回结论。 - thinker.go LoopThinker.Think 用 loop.WithMutedBroadcast(ctx),backthink 不再泄 thinking。 【XML 动作/旁白结构化解析】 - cyrene.yaml systemPrompt 加「表达方式:动作与旁白」——动作 <action>…</action>、 旁白 <narration>…</narration>;克制≤1-2个/段、贴合唤醒态、重要信息不加、朗读正文忽略标签。 - 前端 parseContent.ts:ContentPart[](text/action/narration),非贪婪+反向引用、跨行、非法/ 未闭合兜底纯文本不崩;summarizeContent 供思维导图/状态。 - 前端 renderContent.tsx:action/narration → 灰斜体(mu-text-lo italic)句中原位渲染。 - 应用到 chat.tsx UserBubble/AssistantBubble + messages 各 mode body。 go build/vet/test(loop/backthink)绿 + 前端 tsc/vite 绿。
This commit is contained in:
1 parent
6dbb4a2e18
commit
33eeadfc38
11 files changed
+146
-19
No files matched your search
@@ -0,0 +1,66 @@
|
||||
// Structured parser for persona action/narration XML tags (design/07 §1).
|
||||
// A single message `content` may mix plain text with `<action>…</action>` and
|
||||
// `<narration>…</narration>`; this splits it into an ordered parts array so the
|
||||
// UI can render inline (grey italic) while the structured data stays available
|
||||
// for downstream feeds (mind-map / state) via `summarizeContent`.
|
||||
|
||||
export type ContentPartType = "text" | "action" | "narration";
|
||||
|
||||
export interface ContentPart {
|
||||
type: ContentPartType;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** Matches a well-formed `<action>…</action>` or `<narration>…</narration>`.
|
||||
* Non-greedy body (`[\s\S]*?`) stops at the first matching close tag, so the
|
||||
* tags can span multiple lines. Unclosed or malformed tags simply do not match
|
||||
* and fall through as plain text — a literal `<` in the body never breaks. */
|
||||
const TAG_RE = /<\s*(action|narration)\s*>([\s\S]*?)<\/\s*\1\s*>/g;
|
||||
|
||||
/**
|
||||
* Split `content` into an ordered parts array preserving tag position.
|
||||
* Pure text with no tags yields a single `{type:"text"}` part.
|
||||
*/
|
||||
export function parseContent(content: string): ContentPart[] {
|
||||
const parts: ContentPart[] = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
for (const m of content.matchAll(TAG_RE)) {
|
||||
const index = m.index ?? content.length;
|
||||
const before = content.slice(lastIndex, index);
|
||||
if (before) parts.push({ type: "text", text: before });
|
||||
parts.push({ type: m[1] === "action" ? "action" : "narration", text: m[2] });
|
||||
lastIndex = index + m[0].length;
|
||||
}
|
||||
|
||||
const tail = content.slice(lastIndex);
|
||||
if (tail) parts.push({ type: "text", text: tail });
|
||||
if (parts.length === 0) parts.push({ type: "text", text: content });
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** One extracted tag value. */
|
||||
export interface ContentTagItem {
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** Flat summary derived from the parts: the plain body string plus the collected
|
||||
* action / narration values. Useful for mind-map / state feeds that want the
|
||||
* structured payload rather than the inline render. */
|
||||
export interface ContentSummary {
|
||||
text: string;
|
||||
actions: ContentTagItem[];
|
||||
narrations: ContentTagItem[];
|
||||
}
|
||||
|
||||
/** Collapse a message into `{text, actions, narrations}`. */
|
||||
export function summarizeContent(content: string): ContentSummary {
|
||||
const summary: ContentSummary = { text: "", actions: [], narrations: [] };
|
||||
for (const part of parseContent(content)) {
|
||||
if (part.type === "text") summary.text += part.text;
|
||||
else if (part.type === "action") summary.actions.push({ text: part.text });
|
||||
else summary.narrations.push({ text: part.text });
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { parseContent, type ContentPart } from "../lib/parseContent";
|
||||
|
||||
/** Render a message body into inline parts: plain text as-is, while
|
||||
* `<action>` / `<narration>` become muted italic spans (Miuix `--mu-text-lo`).
|
||||
* Whitespace is preserved by the caller's `whitespace-pre-wrap` container — the
|
||||
* returned nodes are just the segments dropped into it in order. */
|
||||
export function renderContent(content: string): ReactNode[] {
|
||||
return parseContent(content).map((part, i) => renderPart(part, i));
|
||||
}
|
||||
|
||||
function renderPart(part: ContentPart, key: number): ReactNode {
|
||||
if (part.type === "text") return <span key={key}>{part.text}</span>;
|
||||
return (
|
||||
<span key={key} className="italic text-[var(--mu-text-lo)]">
|
||||
{part.text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user