From 817f1e635515c778112a3feb9ab5f629531a9c0a Mon Sep 17 00:00:00 2001 From: Aska Date: Mon, 7 Sep 2026 20:29:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(chat):=20=E5=8A=A8=E4=BD=9C/=E6=97=81?= =?UTF-8?q?=E7=99=BD=E6=A0=BC=E5=BC=8F=E7=BB=9F=E4=B8=80=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=85=BC=E5=AE=B9=E6=98=9F=E5=8F=B7(=EF=BC=8A=E2=80=A6?= =?UTF-8?q?=EF=BC=8A/=EF=BC=8A=E2=80=A6)=20+=20=E4=BA=BA=E8=AE=BE=E5=BC=BA?= =?UTF-8?q?=E5=88=B6=20XML?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 模型常漂移成 *动作:弯起眼睛*(星号),前端/后端只认 /,导致星号版 没被剥成灰斜体注记/没从 IM 剥除→裸出在气泡、上下文不连贯。 - parseContent.ts(新): 把 XML /、全角*…*(带'动作/旁白/内心/神态/轻声' 关键词或整段≤40字)、半角*词…*(仅关键词前缀)统一解析成 ContentPart,剥出正文进 BubbleNotes 灰斜体注记(*动作:…*); 超长无关键词*…*保留为文本;

拆气泡不变 - sanitize.go: stripNarration 同样剥星号动作(与 XML 一致); splitBubbles 不变 - cyrene.yaml: 新增格式铁律——动作/旁白必须用 /,禁用*/Markdown等 验收: 后端 build/vet/test 全绿 + 前端 build 绿 + Node parseContent/splitBubble 实测(5组) --- configs/personas/cyrene.yaml | 1 + frontend/web/src/lib/parseContent.ts | 101 ++++++++++++++---- internal/services/channelbridge/sanitize.go | 46 ++++++++ .../services/channelbridge/sanitize_test.go | 69 ++++++++++++ 4 files changed, 197 insertions(+), 20 deletions(-) diff --git a/configs/personas/cyrene.yaml b/configs/personas/cyrene.yaml index 09e1a01..d21eb3c 100644 --- a/configs/personas/cyrene.yaml +++ b/configs/personas/cyrene.yaml @@ -51,6 +51,7 @@ identity: - 但每一条 `

…

` 尽量短(一两句、半句也行);重要/专业/完整说明时才允许较长段落。 # 表达方式:动作与旁白(用 XML 标签,让回复「活起来」,但克制而稳定) + - **格式铁律**:动作/旁白**必须**用 `…` / `…` 标签包裹;**禁用 `*` 星号、禁用 Markdown 强调(`*…*`、`**…**`)、禁用其它符号包裹**——否则无法被识别为动作/旁白。 - 动作:用 `…` 标签包裹一个肢体/神态小动作(如 `轻轻拉住你的手`、`弯起眼睛笑了`)。 - 旁白:用 `…` 标签包裹一句内心独白/状态(如 `其实偷偷开心了一下`、`虽然嘴上在催你睡觉`)。 - 克制原则:动作/旁白**不要每句都加**,一段回复最多出现 1~2 个,起「点睛」作用即可;不连续堆叠、不打断正事。 diff --git a/frontend/web/src/lib/parseContent.ts b/frontend/web/src/lib/parseContent.ts index 87b9e11..96da425 100644 --- a/frontend/web/src/lib/parseContent.ts +++ b/frontend/web/src/lib/parseContent.ts @@ -1,9 +1,15 @@ -// 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 position each block (inline, or as an attachment note via `splitBubble`) -// while the structured data stays available for downstream feeds (mind-map / -// state) via `summarizeContent`. +// Structured parser for persona action/narration markup (design/07 §1). +// A single message `content` may mix plain text with an action/narration block in +// any of three equivalent forms, so a model that drifts from the canonical XML +// still peels into the same muted note instead of leaking markup into a bubble: +// 1. … / … — the canonical form; +// 2. *…* full-width star (*动作:弯起眼睛*); +// 3. *…* half-width star, only when it clearly carries a role-play keyword +// (*动作:弯起眼睛*) — plain *emphasis* stays as text. +// This splits `content` into an ordered parts array so the UI can position each +// block (inline, or as an attachment note via `splitBubble`) while the structured +// data stays available for downstream feeds (mind-map / state) via +// `summarizeContent`. export type ContentPartType = "text" | "action" | "narration"; @@ -12,35 +18,90 @@ export interface ContentPart { 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; +/** Matches one action/narration block in any of its three accepted forms: + * `…` / `…`, a `*…*` full-width star + * block, or a `*…*` half-width star block that begins with a role-play keyword. + * The alternation is ordered left-to-right (the leftmost token wins), and each + * body is non-greedy (`[\s\S]*?`) so a block may span newlines while a malformed + * or unclosed tag/star simply falls through as plain text. Capture groups: + * 1 = XML tag name, 2 = XML body, 3 = full-star body, 4 = half-star body. */ +const ROLE_RE = + /<\s*(action|narration)\s*>([\s\S]*?)<\/\s*\1\s*>|*([\s\S]*?)*|\*((?:动作|旁白|内心|神态|轻声)\s*[::]?[\s\S]*?)\*/g; + +/** Leading role-play keyword of a star block, stripped before it becomes a note. */ +const ROLE_KEYWORD_RE = /^(动作|旁白|内心|神态|轻声)\s*[::]?\s*/; + +/** Caps how long a keyword-less full-width star block may be and still count as a + * role-play remark (the drift shape *弯起眼睛*). A long keyword-less *…* + * paragraph stays as plain text rather than being mislabelled as an action note. */ +const STAR_SHORT_RUNES = 40; +const NEWLINE_RE = /[\n\r]/; /** - * Split `content` into an ordered parts array preserving tag position. - * Pure text with no tags yields a single `{type:"text"}` part. + * Split `content` into an ordered parts array preserving block position. + * Pure text with no block yields a single `{type:"text"}` part. */ export function parseContent(content: string): ContentPart[] { const parts: ContentPart[] = []; - let lastIndex = 0; + let last = 0; + ROLE_RE.lastIndex = 0; - for (const m of content.matchAll(TAG_RE)) { - const index = m.index ?? content.length; - const before = content.slice(lastIndex, index); + let m: RegExpExecArray | null; + while ((m = ROLE_RE.exec(content)) !== null) { + const before = content.slice(last, m.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; + parts.push(blockToPart(m)); + last = m.index! + m[0].length; } - const tail = content.slice(lastIndex); + const tail = content.slice(last); if (tail) parts.push({ type: "text", text: tail }); if (parts.length === 0) parts.push({ type: "text", text: content }); return parts; } +/** Turn a single matching block into its content part, per which form it took. */ +function blockToPart(m: RegExpExecArray): ContentPart { + if (m[1]) { + // Canonical /: body is used as-is (it has no keyword prefix). + return { type: m[1] === "action" ? "action" : "narration", text: m[2] }; + } + if (m[3]) return classifyFullStar(m[3]) ?? { type: "text", text: m[0] }; + return classifyHalfStar(m[4]); +} + +/** A `*…*` block is role-play (returned as a part) whenever it carries a role-play + * keyword or is a short stand-alone fragment; a long keyword-less paragraph wrapped + * in stars returns `null`, so the caller keeps it as plain text star-for-star. */ +function classifyFullStar(body: string): ContentPart | null { + const kw = matchRoleKeyword(body); + if (kw) return { type: kw.kind, text: kw.rest }; + const t = body.trim(); + if (t && t.length <= STAR_SHORT_RUNES && !NEWLINE_RE.test(t)) { + return { type: "action", text: t }; + } + return null; +} + +/** A `*…*` block is role-play only because the regex already required a keyword, + * so it is peeled; a keyword-less `*emphasis*` never makes it here and stays text. */ +function classifyHalfStar(body: string): ContentPart { + const kw = matchRoleKeyword(body); + if (kw) return { type: kw.kind, text: kw.rest }; + return { type: "action", text: body.trim() }; +} + +/** Matches the leading `动作/旁白/内心/神态/轻声` keyword of a star block, mapping + * it to an action/narration kind and returning the bare text after it. */ +function matchRoleKeyword(body: string): { kind: "action" | "narration"; rest: string } | null { + const m = ROLE_KEYWORD_RE.exec(body); + if (!m) return null; + const kind: "action" | "narration" = + m[1] === "旁白" || m[1] === "内心" ? "narration" : "action"; + return { kind, rest: body.slice(m[0].length).trim() }; +} + /** One extracted tag value. */ export interface ContentTagItem { text: string; diff --git a/internal/services/channelbridge/sanitize.go b/internal/services/channelbridge/sanitize.go index 351fae8..0fcb2ee 100644 --- a/internal/services/channelbridge/sanitize.go +++ b/internal/services/channelbridge/sanitize.go @@ -5,6 +5,7 @@ import ( "regexp" "strings" "time" + "unicode/utf8" ) // This file holds the chat-reply sanitizer used before a reply is delivered to an @@ -24,8 +25,21 @@ var ( paragraphRe = regexp.MustCompile(`(?is)]*?>`) // blankLinesRe collapses runs of blank lines left behind after stripping. blankLinesRe = regexp.MustCompile(`\n\s*\n+`) + // starRoleplayRe matches a full-width star-wrapped block *...* — the drift + // form when the model forgets the / XML tags. The body is + // captured so a non-role-play full-width emphasis can be left alone. + starRoleplayRe = regexp.MustCompile(`(?s)*([\s\S]*?)*`) + // halfStarRoleplayRe matches a half-width star block that clearly carries a + // role-play keyword (动作/旁白/内心/神态/轻声), so *动作:弯起眼睛* is stripped + // while plain markdown *emphasis* is left untouched. + halfStarRoleplayRe = regexp.MustCompile(`(?s)\*((?:动作|旁白|内心|神态|轻声)\s*[::]?[\s\S]*?)\*`) ) +// shortRoleplayRunes caps how long a keyword-less full-width star block may be +// and still be treated as a role-play remark (design §9.5: conservative — a long +// keyword-less *...* paragraph is left as text rather than dropped wholesale). +const shortRoleplayRunes = 40 + // stripNarration returns the plain chat text of a reply with role-play/action/ // narration markup removed, paragraph markers flattened and blank-line runs // collapsed. It never raises or panics on malformed input; a reply that was only an @@ -36,11 +50,43 @@ func stripNarration(text string) string { } s := pairedRoleplayRe.ReplaceAllString(text, "") s = strayRoleplayRe.ReplaceAllString(s, "") + s = starRoleplayRe.ReplaceAllStringFunc(s, func(m string) string { + if isStarRoleplay(strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(m, "*"), "*"))) { + return "" + } + return m + }) + s = halfStarRoleplayRe.ReplaceAllString(s, "") s = paragraphRe.ReplaceAllString(s, "") s = blankLinesRe.ReplaceAllString(s, "\n") return strings.TrimSpace(s) } +// isStarRoleplay reports whether a full-width star block *...* holds a role-play +// remark: it carries a role-play keyword, or it is a short stand-alone fragment +// (no newline, no long body) — the drift shape *弯起眼睛*. A long keyword-less +// paragraph wrapped in stars is kept so a real message is not dropped. +func isStarRoleplay(inner string) bool { + t := strings.TrimSpace(inner) + if t == "" { + return false + } + if hasRoleplayPrefix(t) { + return true + } + return !strings.Contains(t, "\n") && utf8.RuneCountInString(t) <= shortRoleplayRunes +} + +// hasRoleplayPrefix reports whether the block starts with a role-play keyword. +func hasRoleplayPrefix(t string) bool { + for _, p := range []string{"动作", "旁白", "内心", "神态", "轻声"} { + if strings.HasPrefix(t, p) { + return true + } + } + return false +} + // paragraphBlockRe matches an entire

...

block, capturing its inner text // (design: each

is one chat bubble; the Web client renders each as its own // bubble and the IM adapters deliver each as its own message). diff --git a/internal/services/channelbridge/sanitize_test.go b/internal/services/channelbridge/sanitize_test.go index 3742f28..73ef992 100644 --- a/internal/services/channelbridge/sanitize_test.go +++ b/internal/services/channelbridge/sanitize_test.go @@ -89,3 +89,72 @@ func TestSplitBubbles_OnlyActionIsEmpty(t *testing.T) { t.Errorf("splitBubbles = %#v, want empty", got) } } + +func TestStripNarration_StripsFullStarRoleplay(t *testing.T) { + t.Parallel() + in := "*动作:弯起眼睛笑了*\n当然看得到呀,亲爱的。" + got := stripNarration(in) + want := "当然看得到呀,亲爱的。" + if got != want { + t.Errorf("stripNarration = %q, want %q", got, want) + } +} + +func TestStripNarration_StripsKeywordHalfStarRoleplay(t *testing.T) { + t.Parallel() + in := "*旁白:心里有点甜*\n这是正文。" + got := stripNarration(in) + want := "这是正文。" + if got != want { + t.Errorf("stripNarration = %q, want %q", got, want) + } +} + +func TestStripNarration_KeepsPlainEmphasis(t *testing.T) { + t.Parallel() + in := "这是一段 *强调* 文字,不是动作。" + if got := stripNarration(in); got != in { + t.Errorf("stripNarration stripped emphasis: %q -> %q", in, got) + } +} + +func TestStripNarration_MixedStarAndXml(t *testing.T) { + t.Parallel() + in := "拍拍肩膀 *旁白:其实有点开心* 你来了呀。对了 *强调*。" + got := stripNarration(in) + for _, leaked := range []string{"action", "旁白", "拍拍", "其实有点开心"} { + if strings.Contains(got, leaked) { + t.Errorf("stripNarration left role-play %q: %q", leaked, got) + } + } + if !strings.Contains(got, "你来了呀") || !strings.Contains(got, "*强调*") { + t.Errorf("stripNarration dropped chat/emphasis: %q", got) + } +} + +func TestStripNarration_OnlyStarActionBecomesEmpty(t *testing.T) { + t.Parallel() + if got := stripNarration("*弯起眼睛*"); got != "" { + t.Errorf("stripNarration = %q, want empty", got) + } +} + +func TestStripNarration_KeepsLongKeywordlessFullStar(t *testing.T) { + t.Parallel() + in := "*这是一段很长的全角星号包裹的普通段落,足足有几十个字,超过阈值所以应当被保留而不是被剥掉。*" + if got := stripNarration(in); got != in { + t.Errorf("stripNarration dropped long full-width star text: %q -> %q", in, got) + } +} + +func TestSplitBubbles_StripsStarRoleplayInParagraphs(t *testing.T) { + t.Parallel() + in := "

*动作:弯起眼睛*当然看得到呀。

*旁白:心里甜* 好。

" + got := splitBubbles(in) + if len(got) != 2 || got[0] != "当然看得到呀。" { + t.Errorf("splitBubbles = %#v", got) + } + if got[1] != "好。" { + t.Errorf("splitBubbles[1] = %q, want 好。", got[1]) + } +}