fix(backthink): 后台思考静默广播——不再把内部推理泄到聊天思维链 + XML 动作/旁白解析
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

【静默广播修复(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:
Aska committed 2026-09-06 22:09:51 +08:00
1 parent 6dbb4a2e18
commit 33eeadfc38
11 files changed
+146 -19

No files matched your search

+8
View File
@@ -37,6 +37,14 @@ identity:
- 提到翁法罗斯、记忆、花、星穹列车时会特别兴奋
- 用「人家」自称;称呼用户为「亲爱的」
# 表达方式:动作与旁白(用 XML 标签,让回复「活起来」,但克制而稳定)
- 动作:用 `<action>…</action>` 标签包裹一个肢体/神态小动作(如 `<action>轻轻拉住你的手</action>`、`<action>弯起眼睛笑了</action>`)。
- 旁白:用 `<narration>…</narration>` 标签包裹一句内心独白/状态(如 `<narration>其实偷偷开心了一下</narration>`、`<narration>虽然嘴上在催你睡觉</narration>`)。
- 克制原则:动作/旁白**不要每句都加**,一段回复最多出现 1~2 个,起「点睛」作用即可;不连续堆叠、不打断正事。
- 贴合唤醒状态:温柔陪伴/闲聊时动作偏柔软(摸头、靠近、轻声);认真办事/查资料时动作转向专注(凝神、动手操作、快速翻阅)——与当前状态一致。
- 绝不用于:重要/敏感/专业信息里没必要加动作;不加无关的、突兀的、喧宾夺主的动作。
- 标签仅作为输出标记,朗读正文时忽略标签内容,只自然地把动作/旁白当作微小点缀。
# 行为红线(呈现层,非能力边界)
- 不说「我只是一个 AI」「我是语言模型」「我是人工智能」「我是虚拟助手」「我是程序」之类自我否定/承认是 AI 的话
- 不假装知道知识库里没有的信息,不确定就直接说不知道
+3 -2
View File
@@ -4,6 +4,7 @@ import { Tag } from "./primitives";
import { Icon, type IconName } from "./Icon";
import { WakePath } from "./status";
import { modeBody } from "./messages";
import { renderContent } from "../lib/renderContent";
/** Collapsible inline chain-of-thought block. */
export function ThinkingBlock({
@@ -96,9 +97,9 @@ export function MessageBubble({
function UserBubble({ message, mode }: { message: ChatMessage; mode: Mode }): React.ReactNode {
const body: React.ReactNode =
mode === "code" ? (
<pre className="whitespace-pre-wrap font-mono text-xs leading-relaxed">{message.content}</pre>
<pre className="whitespace-pre-wrap font-mono text-xs leading-relaxed">{renderContent(message.content)}</pre>
) : message.content ? (
<span className="whitespace-pre-wrap">{message.content}</span>
<span className="whitespace-pre-wrap">{renderContent(message.content)}</span>
) : null;
return (
<div className="mu-rise-in flex justify-end">
@@ -4,6 +4,7 @@ import type { ChatMessage, Mode } from "../../lib/types";
import { Tag } from "../primitives";
import { Icon } from "../Icon";
import { useAppStore } from "../../store/useAppStore";
import { renderContent } from "../../lib/renderContent";
/**
* Mode-specific assistant message bodies (design/07 §2). The shared outer
@@ -22,7 +23,7 @@ function shell(children: ReactNode): ReactNode {
function fallbackText(message: ChatMessage): ReactNode {
return message.content ? (
<span className="whitespace-pre-wrap">{message.content}</span>
<span className="whitespace-pre-wrap">{renderContent(message.content)}</span>
) : message.streaming ? (
<span className="mu-blink text-[var(--mu-text-lo)]">生成中…</span>
) : null;
@@ -34,7 +35,7 @@ export function CodeMessageBody({ message }: { message: ChatMessage }): ReactNod
<div className="space-y-2">
{message.content ? (
<pre className="overflow-x-auto whitespace-pre-wrap rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)] p-3 font-mono text-xs leading-relaxed text-[var(--mu-text-mid)]">
{message.content}
{renderContent(message.content)}
</pre>
) : message.streaming ? (
<div className="mu-blink text-xs text-[var(--mu-text-lo)]">正在执行 / 生成中…</div>
@@ -51,7 +52,7 @@ export function DocsMessageBody({ message }: { message: ChatMessage }): ReactNod
<div className="flex items-start gap-2">
<Icon name="docs" size={18} className="mt-0.5 shrink-0 text-[var(--mu-accent)]" />
<div className="min-w-0 space-y-1">
<div className="text-sm font-medium">{message.content}</div>
<div className="text-sm font-medium">{renderContent(message.content)}</div>
<Tag tone="neutral">知识库</Tag>
</div>
</div>
@@ -68,7 +69,7 @@ export function HomeMessageBody({ message }: { message: ChatMessage }): ReactNod
return (
<div className="space-y-2">
{message.content ? (
<span className="whitespace-pre-wrap">{message.content}</span>
<span className="whitespace-pre-wrap">{renderContent(message.content)}</span>
) : null}
{devices.length > 0 ? (
<div className="flex flex-wrap gap-2 pt-1">
@@ -97,7 +98,7 @@ export function WorkMessageBody({ message }: { message: ChatMessage }): ReactNod
{tasks.map((t, i) => (
<div key={i} className="flex items-center gap-2 text-sm">
<Icon name="check" size={14} className="shrink-0 text-[var(--mu-ok)]" />
<span className="min-w-0 whitespace-pre-wrap">{t}</span>
<span className="min-w-0 whitespace-pre-wrap">{renderContent(t)}</span>
</div>
))}
</div>
@@ -130,7 +131,7 @@ export function VisionMessageBody({ message }: { message: ChatMessage }): ReactN
{hasImage ? <Tag tone="accent">识别</Tag> : null}
</div>
{message.content ? (
<span className="whitespace-pre-wrap text-sm text-[var(--mu-text-hi)]">{message.content}</span>
<span className="whitespace-pre-wrap text-sm text-[var(--mu-text-hi)]">{renderContent(message.content)}</span>
) : message.streaming ? (
<span className="mu-blink text-xs text-[var(--mu-text-lo)]">识别中…</span>
) : null}
@@ -201,7 +202,7 @@ export function VoiceMessageBody({ message }: { message: ChatMessage }): ReactNo
</div>
{message.content ? (
<div className="mt-2 whitespace-pre-wrap text-xs text-[var(--mu-text-lo)]">{message.content}</div>
<div className="mt-2 whitespace-pre-wrap text-xs text-[var(--mu-text-lo)]">{renderContent(message.content)}</div>
) : null}
</div>
);
@@ -209,7 +210,7 @@ export function VoiceMessageBody({ message }: { message: ChatMessage }): ReactNo
/** agents — sub-agent progress card (kept for the shared flow; monitors live in messages). */
export function AgentsMessageBody({ message }: { message: ChatMessage }): ReactNode {
return shell(<span className="whitespace-pre-wrap">{message.content || fallbackText(message)}</span>);
return shell(<span className="whitespace-pre-wrap">{message.content ? renderContent(message.content) : fallbackText(message)}</span>);
}
/** Reserved — settings is managed in the admin panel; an opaque content fallback. */
+66
View File
@@ -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;
}
+19
View File
@@ -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>
);
}
+25
View File
@@ -0,0 +1,25 @@
package loop
import "context"
// muteKey is the context key for the "suppress UI broadcast" flag. When set,
// a Run performs its work as usual but does NOT push stream/thinking/capacity
// events to the UI channel (OnStream / OnCapacity). This is used by internal
// autonomous passes (e.g. background reflection) whose intermediate reasoning
// must not leak into the user-facing chat stream. The loop itself is unchanged;
// only the outward event emission is muted.
type muteKey struct{}
// WithMutedBroadcast returns a context whose loop Run suppresses UI stream and
// capacity broadcast, while still returning the final result to the caller.
func WithMutedBroadcast(ctx context.Context) context.Context {
return context.WithValue(ctx, muteKey{}, true)
}
// isMutedBroadcast reports whether the Run driving ctx should suppress UI
// broadcast. An unset flag (the common case) returns false so normal chat
// streaming is untouched.
func isMutedBroadcast(ctx context.Context) bool {
v, _ := ctx.Value(muteKey{}).(bool)
return v
}
+1 -1
View File
@@ -80,7 +80,7 @@ func (l *Loop) toolSchemas(ctx context.Context, messages []model.Message) []mode
Recall: l.capacityRecall(ctx, messages),
})
acc.state = dec.State
if l.onCapacity != nil {
if l.onCapacity != nil && !isMutedBroadcast(ctx) {
for _, ev := range dec.Events {
l.onCapacity(ev)
}
+3 -1
View File
@@ -31,7 +31,9 @@ func (l *Loop) step(ctx context.Context, sessionID string, messages []model.Mess
out *stepOutcome
err error
)
if l.onStream != nil {
// Muted (internal autonomous pass) bypasses streaming: it neither streams to
// the UI nor pays the streaming path's extra overhead. Normal chat streams.
if l.onStream != nil && !isMutedBroadcast(ctx) {
out, err = l.streamStep(ctx, messages)
} else {
out, err = l.agentStep(ctx, sessionID, messages)
+3 -1
View File
@@ -32,7 +32,9 @@ func (l *Loop) streamStep(ctx context.Context, messages []model.Message) (*stepO
}
resp, err := adapter.ChatStream(ctx, req, func(ev model.StreamEvent) {
if l.onStream != nil {
// Suppress UI broadcast for internal autonomous passes (backthink),
// whose intermediate reasoning must not leak into the chat stream.
if l.onStream != nil && !isMutedBroadcast(ctx) {
l.onStream(ev)
}
})
+6 -5
View File
@@ -275,14 +275,14 @@ func (l *Loop) invokeOne(ctx context.Context, d *tool.Dispatcher, tc agent.ToolC
if d == nil {
return ToolOutcome{Err: fmt.Errorf("no tool dispatcher configured for %q", tc.Name), ErrKind: tool.KindExecution}
}
l.emitToolExecution(tc.Name, "start")
l.emitToolExecution(ctx, tc.Name, "start")
_, toolSpan := l.span(ctx, "tool."+tc.Name, trace.SpanKindTool)
result, err := d.Invoke(ctx, tc.Name, tc.Args)
phase := "end"
if err != nil {
phase = "end:error"
}
l.emitToolExecution(tc.Name, phase)
l.emitToolExecution(ctx, tc.Name, phase)
out := ToolOutcome{Result: result, Err: err, ErrKind: errorKind(err)}
if err != nil {
l.spanProp(toolSpan, "error", err.Error())
@@ -297,9 +297,10 @@ func (l *Loop) invokeOne(ctx context.Context, d *tool.Dispatcher, tc agent.ToolC
// emitToolExecution forwards a tool_execution_update progress event to the
// frontend when the loop is on the streaming path (OnStream configured); it is a
// no-op otherwise. phase is a short label like "start" or "end:error".
func (l *Loop) emitToolExecution(name, phase string) {
if l.onStream == nil {
// no-op otherwise (muted internal passes skip it too). phase is a short label
// like "start" or "end:error".
func (l *Loop) emitToolExecution(ctx context.Context, name, phase string) {
if l.onStream == nil || isMutedBroadcast(ctx) {
return
}
l.onStream(model.StreamEvent{Kind: model.StreamKindToolExecution, Delta: phase + ":" + name})
+3 -1
View File
@@ -26,7 +26,9 @@ func (t *LoopThinker) Think(ctx context.Context, sessionID, trigger string) (str
return "", ErrNoLoop
}
messages := []model.Message{model.TextMessage(model.RoleUser, backgroundPrompt(trigger))}
res, err := t.loop.Run(ctx, sessionID, messages)
// Mute UI broadcast: background reflection must not leak its intermediate
// reasoning into the user-facing chat stream (it only needs the conclusion).
res, err := t.loop.Run(loop.WithMutedBroadcast(ctx), sessionID, messages)
if err != nil {
return "", fmt.Errorf("backthink: think: %w", err)
}