/** * Split a chat answer into the spoken layer (before the first H2) and the * report skeleton (from that H2 onward). The cut is any ATX H2, so a drifted * first heading still folds instead of dumping the whole body. * * Fenced `##` lines are not cuts. The fence rule matches `chat-markdown-split`. */ export type SpokenAndReportSplit = Readonly<{ spoken: string; report: string; headings: readonly string[]; }>; const FENCE = /^\s{0,3}(`{3,}|~{3,})/; const H2 = /^##\s+\S/; function extractHeadings(report: string): string[] { const lines = report.split("\n"); let insideFence = false; const headings: string[] = []; for (const line of lines) { if (FENCE.test(line)) insideFence = !insideFence; if (!insideFence && H2.test(line)) { headings.push(line.replace(/^##\s+/, "").trim()); } } return headings; } export function splitSpokenAndReport(text: string): SpokenAndReportSplit { const lines = text.split("\n"); let insideFence = false; let cut = -1; for (let index = 0; index < lines.length; index += 1) { const line = lines[index] ?? ""; if (FENCE.test(line)) insideFence = !insideFence; if (!insideFence && H2.test(line)) { cut = index; break; } } if (cut < 0) return { spoken: text, report: "", headings: [] }; const spoken = lines.slice(0, cut).join("\n").replace(/\s+$/u, ""); const report = lines.slice(cut).join("\n"); return { spoken, report, headings: extractHeadings(report) }; } export function shortenAnswerHeading(heading: string): string { if (heading === "统一参数与原始结构") return "统一参数"; if (heading === "技法审计表") return "技法审计"; return heading; } export function answerDetailCaption(headings: readonly string[]): string { if (headings.length === 0) return ""; const labels = headings.slice(0, 3).map(shortenAnswerHeading); if (headings.length <= 3) return labels.join("、"); return `${labels.join("、")} 等 ${headings.length} 节`; }