feat(chat): 动作/旁白格式统一——兼容星号(*…*/*…) + 人设强制 XML
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

模型常漂移成 *动作:弯起眼睛*(星号),前端/后端只认 <action>/<narration>,导致星号版
没被剥成灰斜体注记/没从 IM 剥除→裸出在气泡、上下文不连贯。
- parseContent.ts(新): 把 XML <action>/<narration>、全角*…*(带'动作/旁白/内心/神态/轻声'
  关键词或整段≤40字)、半角*词…*(仅关键词前缀)统一解析成 ContentPart,剥出正文进 BubbleNotes
  灰斜体注记(*动作:…*); 超长无关键词*…*保留为文本; <p>拆气泡不变
- sanitize.go: stripNarration 同样剥星号动作(与 XML 一致); splitBubbles 不变
- cyrene.yaml: 新增格式铁律——动作/旁白必须用 <action>/<narration>,禁用*/Markdown等
验收: 后端 build/vet/test 全绿 + 前端 build 绿 + Node parseContent/splitBubble 实测(5组)
This commit is contained in:
Aska committed 2026-09-07 20:29:47 +08:00
1 parent 06fd2f4aaf
commit 817f1e6355
4 files changed
+197 -20

No files matched your search

@@ -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)</?p\b[^>]*?>`)
// 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 <action>/<narration> 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 <p>...</p> block, capturing its inner text
// (design: each <p> is one chat bubble; the Web client renders each as its own
// bubble and the IM adapters deliver each as its own message).