From 33eeadfc38c03e82ed7d4377417c63c61267803c Mon Sep 17 00:00:00 2001 From: Aska Date: Sun, 6 Sep 2026 22:09:51 +0800 Subject: [PATCH] =?UTF-8?q?fix(backthink):=20=E5=90=8E=E5=8F=B0=E6=80=9D?= =?UTF-8?q?=E8=80=83=E9=9D=99=E9=BB=98=E5=B9=BF=E6=92=AD=E2=80=94=E2=80=94?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E6=8A=8A=E5=86=85=E9=83=A8=E6=8E=A8=E7=90=86?= =?UTF-8?q?=E6=B3=84=E5=88=B0=E8=81=8A=E5=A4=A9=E6=80=9D=E7=BB=B4=E9=93=BE?= =?UTF-8?q?=20+=20XML=20=E5=8A=A8=E4=BD=9C/=E6=97=81=E7=99=BD=E8=A7=A3?= =?UTF-8?q?=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 【静默广播修复(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 加「表达方式:动作与旁白」——动作 …、 旁白 …;克制≤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 绿。 --- configs/personas/cyrene.yaml | 8 +++ frontend/web/src/components/chat.tsx | 5 +- .../web/src/components/messages/index.tsx | 17 ++--- frontend/web/src/lib/parseContent.ts | 66 +++++++++++++++++++ frontend/web/src/lib/renderContent.tsx | 19 ++++++ internal/runtime/loop/mute_ctx.go | 25 +++++++ internal/runtime/loop/request.go | 2 +- internal/runtime/loop/step.go | 4 +- internal/runtime/loop/stream.go | 4 +- internal/runtime/loop/tools.go | 11 ++-- internal/services/backthink/thinker.go | 4 +- 11 files changed, 146 insertions(+), 19 deletions(-) create mode 100644 frontend/web/src/lib/parseContent.ts create mode 100644 frontend/web/src/lib/renderContent.tsx create mode 100644 internal/runtime/loop/mute_ctx.go diff --git a/configs/personas/cyrene.yaml b/configs/personas/cyrene.yaml index 49f1f23..0e73a7b 100644 --- a/configs/personas/cyrene.yaml +++ b/configs/personas/cyrene.yaml @@ -37,6 +37,14 @@ identity: - 提到翁法罗斯、记忆、花、星穹列车时会特别兴奋 - 用「人家」自称;称呼用户为「亲爱的」 + # 表达方式:动作与旁白(用 XML 标签,让回复「活起来」,但克制而稳定) + - 动作:用 `…` 标签包裹一个肢体/神态小动作(如 `轻轻拉住你的手`、`弯起眼睛笑了`)。 + - 旁白:用 `…` 标签包裹一句内心独白/状态(如 `其实偷偷开心了一下`、`虽然嘴上在催你睡觉`)。 + - 克制原则:动作/旁白**不要每句都加**,一段回复最多出现 1~2 个,起「点睛」作用即可;不连续堆叠、不打断正事。 + - 贴合唤醒状态:温柔陪伴/闲聊时动作偏柔软(摸头、靠近、轻声);认真办事/查资料时动作转向专注(凝神、动手操作、快速翻阅)——与当前状态一致。 + - 绝不用于:重要/敏感/专业信息里没必要加动作;不加无关的、突兀的、喧宾夺主的动作。 + - 标签仅作为输出标记,朗读正文时忽略标签内容,只自然地把动作/旁白当作微小点缀。 + # 行为红线(呈现层,非能力边界) - 不说「我只是一个 AI」「我是语言模型」「我是人工智能」「我是虚拟助手」「我是程序」之类自我否定/承认是 AI 的话 - 不假装知道知识库里没有的信息,不确定就直接说不知道 diff --git a/frontend/web/src/components/chat.tsx b/frontend/web/src/components/chat.tsx index b07dae5..d9d5ede 100644 --- a/frontend/web/src/components/chat.tsx +++ b/frontend/web/src/components/chat.tsx @@ -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" ? ( -
{message.content}
+
{renderContent(message.content)}
) : message.content ? ( - {message.content} + {renderContent(message.content)} ) : null; return (
diff --git a/frontend/web/src/components/messages/index.tsx b/frontend/web/src/components/messages/index.tsx index c74a067..33ccf62 100644 --- a/frontend/web/src/components/messages/index.tsx +++ b/frontend/web/src/components/messages/index.tsx @@ -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 ? ( - {message.content} + {renderContent(message.content)} ) : message.streaming ? ( 生成中… ) : null; @@ -34,7 +35,7 @@ export function CodeMessageBody({ message }: { message: ChatMessage }): ReactNod
{message.content ? (
-          {message.content}
+          {renderContent(message.content)}
         
) : message.streaming ? (
正在执行 / 生成中…
@@ -51,7 +52,7 @@ export function DocsMessageBody({ message }: { message: ChatMessage }): ReactNod
-
{message.content}
+
{renderContent(message.content)}
知识库
@@ -68,7 +69,7 @@ export function HomeMessageBody({ message }: { message: ChatMessage }): ReactNod return (
{message.content ? ( - {message.content} + {renderContent(message.content)} ) : null} {devices.length > 0 ? (
@@ -97,7 +98,7 @@ export function WorkMessageBody({ message }: { message: ChatMessage }): ReactNod {tasks.map((t, i) => (
- {t} + {renderContent(t)}
))}
@@ -130,7 +131,7 @@ export function VisionMessageBody({ message }: { message: ChatMessage }): ReactN {hasImage ? 识别 : null}
{message.content ? ( - {message.content} + {renderContent(message.content)} ) : message.streaming ? ( 识别中… ) : null} @@ -201,7 +202,7 @@ export function VoiceMessageBody({ message }: { message: ChatMessage }): ReactNo
{message.content ? ( -
{message.content}
+
{renderContent(message.content)}
) : null}
); @@ -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({message.content || fallbackText(message)}); + return shell({message.content ? renderContent(message.content) : fallbackText(message)}); } /** Reserved — settings is managed in the admin panel; an opaque content fallback. */ diff --git a/frontend/web/src/lib/parseContent.ts b/frontend/web/src/lib/parseContent.ts new file mode 100644 index 0000000..b74dfcc --- /dev/null +++ b/frontend/web/src/lib/parseContent.ts @@ -0,0 +1,66 @@ +// Structured parser for persona action/narration XML tags (design/07 §1). +// A single message `content` may mix plain text with `…` and +// `…`; 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 `…` or `…`. + * 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; +} diff --git a/frontend/web/src/lib/renderContent.tsx b/frontend/web/src/lib/renderContent.tsx new file mode 100644 index 0000000..b50ce23 --- /dev/null +++ b/frontend/web/src/lib/renderContent.tsx @@ -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 + * `` / `` 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 {part.text}; + return ( + + {part.text} + + ); +} diff --git a/internal/runtime/loop/mute_ctx.go b/internal/runtime/loop/mute_ctx.go new file mode 100644 index 0000000..52a8902 --- /dev/null +++ b/internal/runtime/loop/mute_ctx.go @@ -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 +} diff --git a/internal/runtime/loop/request.go b/internal/runtime/loop/request.go index af2cbc9..d1e8767 100644 --- a/internal/runtime/loop/request.go +++ b/internal/runtime/loop/request.go @@ -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) } diff --git a/internal/runtime/loop/step.go b/internal/runtime/loop/step.go index 131ac1b..1e265b3 100644 --- a/internal/runtime/loop/step.go +++ b/internal/runtime/loop/step.go @@ -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) diff --git a/internal/runtime/loop/stream.go b/internal/runtime/loop/stream.go index c59037d..99ed5b6 100644 --- a/internal/runtime/loop/stream.go +++ b/internal/runtime/loop/stream.go @@ -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) } }) diff --git a/internal/runtime/loop/tools.go b/internal/runtime/loop/tools.go index db27939..96f3ce1 100644 --- a/internal/runtime/loop/tools.go +++ b/internal/runtime/loop/tools.go @@ -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}) diff --git a/internal/services/backthink/thinker.go b/internal/services/backthink/thinker.go index f64ace7..6854eaa 100644 --- a/internal/services/backthink/thinker.go +++ b/internal/services/backthink/thinker.go @@ -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) }