模型常漂移成 *动作:弯起眼睛*(星号),前端/后端只认 <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组)
184 lines
7.4 KiB
TypeScript
184 lines
7.4 KiB
TypeScript
// 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 };
|
||
}
|