// 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"; export interface ContentPart { type: ContentPartType; text: string; } /** 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 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 /: 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 `` / `` 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 }; }