Files
Cyrene_v2/frontend/web/src/lib/parseContent.ts
T
Aska 817f1e6355
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(chat): 动作/旁白格式统一——兼容星号(*…*/*…) + 人设强制 XML
模型常漂移成 *动作:弯起眼睛*(星号),前端/后端只认 <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组)
2026-09-07 20:29:47 +08:00

184 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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. <action>…</action> / <narration>…</narration> — 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";
export interface ContentPart {
type: ContentPartType;
text: string;
}
/** Matches one action/narration block in any of its three accepted forms:
* `<action>…</action>` / `<narration>…</narration>`, 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 block position.
* Pure text with no block yields a single `{type:"text"}` part.
*/
export function parseContent(content: string): ContentPart[] {
const parts: ContentPart[] = [];
let last = 0;
ROLE_RE.lastIndex = 0;
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(blockToPart(m));
last = m.index! + m[0].length;
}
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 <action>/<narration>: 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;
}
/** 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;
}
/** Result of `splitBubble`: a renderable plain-text `body` plus the annotation
* blocks that belong above (`head`) or below (`tail`) the bubble. `head`/`tail`
* hold the leading / trailing action & narration parts in order; `body` is the
* concatenated text with any annotation blocks removed. */
export interface BubbleSplit {
body: string;
head: ContentPart[];
tail: ContentPart[];
}
/**
* Peel the `<action>` / `<narration>` blocks out of `content` so the bubble
* body is pure text while the annotations attach as notes outside it.
*
* - `head` = the leading run of action/narration before the FIRST text part
* (rendered above the bubble).
* - `tail` = the trailing run of action/narration after the LAST text part
* (rendered below the bubble).
* - `body` = every text part concatenated and outer-trimmed.
* - Annotation blocks interleaved BETWEEN text paragraphs have no stable text
* anchor once the body collapses, so they lift to `head` — the only hard
* requirement is that they never render inside the bubble.
* - A content with no text part at all yields an empty `body` with everything
* in `head`, so the notes alone carry the message.
*/
export function splitBubble(content: string): BubbleSplit {
const parts = parseContent(content);
let firstTextIdx = -1;
let lastTextIdx = -1;
parts.forEach((part, i) => {
if (part.type === "text") {
if (firstTextIdx < 0) firstTextIdx = i;
lastTextIdx = i;
}
});
const head: ContentPart[] = [];
const tail: ContentPart[] = [];
let bodyText = "";
parts.forEach((part, i) => {
if (part.type === "text") {
bodyText += part.text;
return;
}
if (firstTextIdx < 0 || i < firstTextIdx || (i > firstTextIdx && i < lastTextIdx)) {
head.push(part);
} else {
tail.push(part);
}
});
return { body: bodyText.trim(), head, tail };
}