${esc(c.name)}
${esc(t.importance[c.importance] ?? c.importance)} ${c.aliases.length ? `${esc(t.aka)} ${esc(c.aliases.join(' · '))}` : ''} ${marked(c.oneLiner)}${esc(t.persona.evidence)}
${esc(q)}`).join('')}
#!/usr/bin/env node
// novel-characters — deterministic helpers for the novel-characters skill.
// Zero dependencies on purpose: the skill must work in any directory
// without an npm install. Node 18+ (stdlib only).
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
import { basename, join, relative, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
/* ------------------------------------------------------------------ */
/* chunk */
/* ------------------------------------------------------------------ */
// 块大小的权衡:块越大,单块阅读越重;块越小,块数越多,跨块归并
// (全管线语义最难的一步)的接缝就越多。现在的模型读 4 万字符毫无压力,
// 所以往大取——接缝少三倍,漏归并的机会也少三倍。
// 上限 24 块,扣掉 23 段重叠净覆盖约 93 万字符。
export const CHUNK_SIZE = 40_000;
export const CHUNK_OVERLAP = 1_200;
export const MAX_CHUNKS = 24;
/**
* Split source text on paragraph boundaries into overlapping chunks.
* Overlap keeps a character introduced at a chunk seam visible to both sides.
*/
export function chunkText(text) {
const clean = text.replace(/\r\n/g, '\n').trim();
if (!clean) return [];
if (clean.length <= CHUNK_SIZE) return [clean];
const chunks = [];
let cursor = 0;
while (cursor < clean.length && chunks.length < MAX_CHUNKS) {
let end = Math.min(cursor + CHUNK_SIZE, clean.length);
if (end < clean.length) {
// Prefer a paragraph break, then a sentence end, inside the last 20%.
const windowStart = cursor + Math.floor(CHUNK_SIZE * 0.8);
const window = clean.slice(windowStart, end);
const para = window.lastIndexOf('\n\n');
const sentence = Math.max(
window.lastIndexOf('。'),
window.lastIndexOf('!'),
window.lastIndexOf('?'),
window.lastIndexOf('. '),
);
const offset = para >= 0 ? para : sentence;
if (offset >= 0) end = windowStart + offset + 1;
}
chunks.push(clean.slice(cursor, end).trim());
if (end >= clean.length) break;
cursor = Math.max(end - CHUNK_OVERLAP, cursor + 1);
}
return chunks;
}
/* ------------------------------------------------------------------ */
/* merge */
/* ------------------------------------------------------------------ */
/**
* Merge per-chunk rosters into one cast, keyed by name AND alias so that
* 陆行远 / 陆 / 姑娘 collapse onto the same person regardless of which
* chunk saw which form first.
*/
export function mergeRoster(batches) {
const byKey = new Map();
const keyOf = (s) => String(s).trim().toLowerCase();
for (const batch of batches) {
for (const entry of batch ?? []) {
if (!entry?.name) continue;
const aliases = Array.isArray(entry.aliases) ? entry.aliases : [];
const candidates = [entry.name, ...aliases].map(keyOf).filter(Boolean);
const existingKey = candidates.find((c) => byKey.has(c));
const target = existingKey
? byKey.get(existingKey)
: { name: String(entry.name).trim(), aliases: [], notes: [], quotes: [] };
for (const alias of [entry.name, ...aliases]) {
const trimmed = String(alias).trim();
if (trimmed && trimmed !== target.name && !target.aliases.includes(trimmed)) {
target.aliases.push(trimmed);
}
}
if (entry.note && String(entry.note).trim()) target.notes.push(String(entry.note).trim());
for (const quote of entry.quotes ?? []) {
const trimmed = String(quote).trim();
if (trimmed && !target.quotes.includes(trimmed)) target.quotes.push(trimmed);
}
for (const c of candidates) byKey.set(c, target);
}
}
// Collapse the alias-keyed index back to one entry per character.
const unique = new Map();
for (const value of byKey.values()) unique.set(keyOf(value.name), value);
// More chunks mentioning a character == more screen time.
return [...unique.values()].sort((a, b) => b.notes.length - a.notes.length);
}
/**
* 疑似同人候选。跨块身份消解是语义问题,确定性代码只能做廉价的那一半:
* 名字包含(「陆」⊂「陆行远」)是很强的合并信号,精确匹配却收敛不了它——
* 除非某块恰好把「陆」列成了「陆行远」的别名。「陆先生」和「行远」是不是
* 同一个人则连包含都测不出,所以候选只是嫌疑名单,判断留给模型,
* 复核结果写成 merges.json 再用 --apply 确定性落地。
*/
export function mergeCandidates(cast) {
const keysOf = (c) => [c.name, ...(c.aliases ?? [])].map((s) => String(s).trim()).filter(Boolean);
const candidates = [];
for (let i = 0; i < cast.length; i++) {
for (let j = i + 1; j < cast.length; j++) {
let reason = null;
for (const a of keysOf(cast[i])) {
for (const b of keysOf(cast[j])) {
const [short, long] = a.length <= b.length ? [a, b] : [b, a];
if (short.toLowerCase() === long.toLowerCase()) continue; // 相等的键 mergeRoster 已经收敛了
// 拉丁文字短侧至少 3 个字符,否则「Al」⊂「Alex」这类噪音太多;CJK 单字就有信息量
const min = CJK.test(short) ? 1 : 3;
if (short.length >= min && long.toLowerCase().includes(short.toLowerCase())) {
reason = `「${short}」⊂「${long}」`;
break;
}
}
if (reason) break;
}
if (reason) candidates.push({ a: cast[i].name, b: cast[j].name, reason });
}
}
return candidates;
}
/**
* 把模型复核后的合并决定落地。merges: [{ keep, absorb: [...] }],
* keep / absorb 用 name 或任一 alias 定位都行。找不到就整体报错——
* 静默跳过会让调用方以为合并成功了。
* 不改动传入的 cast:全程在副本上操作,抛错后入参照常可用。
*/
export function applyMerges(cast, merges) {
cast = cast.map((c) => ({ ...c, aliases: [...c.aliases], notes: [...c.notes], quotes: [...c.quotes] }));
const keyOf = (s) => String(s).trim().toLowerCase();
const index = new Map();
for (const c of cast) for (const k of [c.name, ...c.aliases]) index.set(keyOf(k), c);
const removed = new Set();
const problems = [];
for (const m of merges ?? []) {
const target = index.get(keyOf(m?.keep ?? ''));
if (!target) {
problems.push(`keep「${m?.keep}」在归并结果里找不到`);
continue;
}
for (const name of m?.absorb ?? []) {
const victim = index.get(keyOf(name));
if (!victim) {
problems.push(`absorb「${name}」在归并结果里找不到`);
continue;
}
if (victim === target) continue; // 本来就是同一个人,不算错
for (const alias of [victim.name, ...victim.aliases]) {
if (alias !== target.name && !target.aliases.includes(alias)) target.aliases.push(alias);
}
target.notes.push(...victim.notes);
for (const q of victim.quotes) if (!target.quotes.includes(q)) target.quotes.push(q);
for (const k of [victim.name, ...victim.aliases]) index.set(keyOf(k), target);
removed.add(victim);
}
}
if (problems.length) throw new Error(problems.join('\n'));
return cast.filter((c) => !removed.has(c)).sort((a, b) => b.notes.length - a.notes.length);
}
/* ------------------------------------------------------------------ */
/* slug */
/* ------------------------------------------------------------------ */
/** Filesystem-safe stem for a character name, CJK preserved. */
export function slug(name) {
const cleaned = String(name)
.trim()
.replace(/[\s/\\:*?"<>|]+/g, '-')
.replace(/^-+|-+$/g, '');
return cleaned || 'character';
}
/* ------------------------------------------------------------------ */
/* i18n */
/* ------------------------------------------------------------------ */
/*
* 报告语言。默认 zh。
* 内置 zh / en 两套界面文案;给了其他语言码就用 en 的界面骨架,
* 但角色内容仍按那个语言生成——界面词没翻译总比整篇乱掉强。
*/
export const DEFAULT_LANG = 'zh';
const STRINGS = {
zh: {
kicker: '角色设定集',
titleTail: ' · 角色',
docTitle: (s) => `${s} · 角色设定集`,
counts: (n, shots) => `${n} 位角色${shots ? ` · ${shots} 张设定图` : ''} · 按戏份排序`,
synopsis: '故事摘要',
indexLabel: '角色索引',
aka: '又称',
groups: { persona: '画像', image: '形象', voice: '声音' },
persona: {
gender: '性别', ageRange: '年龄', identity: '身份',
appearance: '外貌', temperament: '性情', motivation: '动机',
arc: '人物弧光', relationships: '关系', evidence: '原文依据',
},
image: {
prompt: '出图提示词 · 喂出图模型用这条', promptLocal: '出图提示词(中文对照)',
negative: '反向提示词', sheet: '角色设定图提示词 EN',
},
voice: {
timbre: '音色', pitch: '音高', pace: '语速', accent: '口音',
emotion: '情绪', referenceHint: '类比',
prompt: '音色提示词 · 喂 TTS 引擎用这条',
},
importance: { protagonist: '主角', major: '主要角色', supporting: '配角', minor: '龙套' },
graphTitle: '关系图谱',
graphHint: '悬停看关系,点击进角色',
graphCounts: (n, e) => `${n} 位角色 · ${e} 组关系`,
exportJson: '导出 JSON',
graphLabels: '关系文字',
graphEmpty: '这批角色之间没有互相指认的关系',
graphDangling: (n) => `另有 ${n} 条关系指向没做画像的角色,图里不画`,
relationsAll: '全部关系',
copy: '复制', copied: '已复制', copyFailed: '复制失败', copyJson: '复制整份角色 JSON',
sheetCaption: '左:半身像 右:全身三视图',
noImage: '尚未出图',
noImageHint: '用下方提示词生成',
colophonA: '画像与提示词由模型依据原文生成,',
colophonB: '标记处为原文未明说、为可用性补全的内容。',
mdTitle: (s) => `# ${s} — 角色表`,
mdCast: (n, names) => `共 ${n} 位角色:${names}`,
mdSynopsis: '## 故事摘要',
searchPlaceholder: '搜索角色、特质、身份',
rosterTitle: '角色 · 按戏份排序',
footnote: '标注(推断)的条目为原文未明写、依据文本推演。',
noMatch: '没有匹配的角色',
voiceTag: 'VOICE',
expandAll: '全部展开',
zoomImage: '放大查看',
copyImage: '复制图片',
closeImage: '关闭',
},
en: {
kicker: 'CHARACTER BIBLE',
titleTail: ' · Cast',
docTitle: (s) => `${s} · Character Bible`,
counts: (n, shots) =>
`${n} character${n === 1 ? '' : 's'}${shots ? ` · ${shots} sheet${shots === 1 ? '' : 's'}` : ''} · ordered by prominence`,
synopsis: 'Synopsis',
indexLabel: 'Cast index',
aka: 'a.k.a.',
groups: { persona: 'Profile', image: 'Design', voice: 'Voice' },
persona: {
gender: 'Gender', ageRange: 'Age', identity: 'Standing',
appearance: 'Appearance', temperament: 'Temperament', motivation: 'Motivation',
arc: 'Arc', relationships: 'Relationships', evidence: 'From the text',
},
image: {
prompt: 'Image prompt · feed this to the image model', promptLocal: 'Image prompt (local translation)',
negative: 'Negative prompt', sheet: 'Model sheet prompt',
},
voice: {
timbre: 'Timbre', pitch: 'Pitch', pace: 'Pace', accent: 'Accent',
emotion: 'Emotion', referenceHint: 'Sounds like',
prompt: 'Voice prompt · feed this to the TTS engine',
},
importance: { protagonist: 'Lead', major: 'Major', supporting: 'Supporting', minor: 'Minor' },
graphTitle: 'Relationship map',
graphHint: 'Hover to trace, click to open',
graphCounts: (n, e) => `${n} character${n === 1 ? '' : 's'} · ${e} link${e === 1 ? '' : 's'}`,
exportJson: 'Export JSON',
graphLabels: 'Link labels',
graphEmpty: 'No one in this cast names anyone else',
graphDangling: (n) => `${n} more link${n === 1 ? '' : 's'} point to characters without a profile and are not drawn`,
relationsAll: 'All links',
copy: 'Copy', copied: 'Copied', copyFailed: 'Failed', copyJson: 'Copy full JSON',
sheetCaption: 'Left: bust Right: full-body turnaround',
noImage: 'Not generated yet',
noImageHint: 'use the prompts below',
colophonA: 'Profiles and prompts are model-generated from the source text; ',
colophonB: 'marks what the text does not state and was filled in for usability.',
mdTitle: (s) => `# ${s} — Cast`,
mdCast: (n, names) => `${n} characters: ${names}`,
mdSynopsis: '## Synopsis',
searchPlaceholder: 'Search characters, traits, roles',
rosterTitle: 'Cast · by prominence',
footnote: 'Anything marked (inferred) is not stated in the text and was reasoned from it.',
noMatch: 'No matching character',
voiceTag: 'VOICE',
expandAll: 'Expand all',
zoomImage: 'View larger',
copyImage: 'Copy image',
closeImage: 'Close',
},
ja: {
kicker: 'キャラクター設定集',
titleTail: ' · 登場人物',
docTitle: (s) => `${s} · キャラクター設定集`,
counts: (n, shots) => `${n}人${shots ? ` · 設定画 ${shots}枚` : ''} · 出番順`,
synopsis: 'あらすじ',
indexLabel: '登場人物一覧',
aka: '別名',
groups: { persona: '人物像', image: 'ビジュアル', voice: '声' },
persona: {
gender: '性別', ageRange: '年齢', identity: '立場',
appearance: '外見', temperament: '性格', motivation: '動機',
arc: '人物の変化', relationships: '関係', evidence: '原文の根拠',
},
image: {
prompt: '画像プロンプト · 画像モデルにはこれを', promptLocal: '画像プロンプト(対訳)',
negative: 'ネガティブプロンプト', sheet: 'キャラ設定画プロンプト EN',
},
voice: {
timbre: '声質', pitch: '音域', pace: '話速', accent: '訛り',
emotion: '感情', referenceHint: 'たとえるなら',
prompt: '音声プロンプト · TTS にはこれを',
},
importance: { protagonist: '主役', major: '主要人物', supporting: '脇役', minor: '端役' },
graphTitle: '相関図',
graphHint: 'ホバーで関係、クリックで詳細',
graphCounts: (n, e) => `${n}人 · ${e}組の関係`,
exportJson: 'JSON を書き出す',
graphLabels: '関係ラベル',
graphEmpty: 'この登場人物どうしを結ぶ関係はありません',
graphDangling: (n) => `他に${n}件、設定を作っていない人物への関係があります(図には出ません)`,
relationsAll: '関係一覧',
copy: 'コピー', copied: 'コピー済み', copyFailed: '失敗', copyJson: 'JSON をコピー',
sheetCaption: '左:バストアップ 右:三面図',
noImage: '未生成',
noImageHint: '下のプロンプトで生成',
colophonA: '人物像とプロンプトは原文をもとにモデルが生成したものです。',
colophonB: 'の箇所は原文に明記がなく、実用のために補ったものです。',
mdTitle: (s) => `# ${s} — 登場人物`,
mdCast: (n, names) => `全${n}人:${names}`,
mdSynopsis: '## あらすじ',
searchPlaceholder: 'キャラクター・特徴・立場を検索',
rosterTitle: '登場人物 · 出番順',
footnote: '(推断)の箇所は原文に明記がなく、本文から推し量ったものです。',
noMatch: '該当するキャラクターがいません',
voiceTag: 'VOICE',
expandAll: 'すべて展開',
zoomImage: '拡大表示',
copyImage: '画像をコピー',
closeImage: '閉じる',
},
};
/**
* 取界面文案。
*
* 两层:内置表覆盖常用语言;其他语言由 skill 在生成时翻译一份塞进
* cast.json 的 `ui`,这里合并进来。这样支持的语言不受内置表限制。
*
* @param lang 语言码
* @param overrides cast.json 的 `ui`,可以只覆盖一部分键
*/
export function strings(lang = DEFAULT_LANG, overrides = null) {
const base = STRINGS[lang] ?? STRINGS.en;
if (!overrides || typeof overrides !== 'object') return base;
// 只合两层——STRINGS 的嵌套就两层深,够用且不会被脏数据带偏。
const merged = { ...base };
for (const [k, v] of Object.entries(overrides)) {
if (typeof base[k] === 'function') continue; // 函数模板不接受覆盖
if (v && typeof v === 'object' && !Array.isArray(v) && base[k] && typeof base[k] === 'object') {
merged[k] = { ...base[k], ...v };
} else if (typeof v === 'string') {
merged[k] = v;
}
}
return merged;
}
export const SUPPORTED_UI_LANGS = Object.keys(STRINGS);
/** 需要 skill 补一份 `ui` 翻译的语言(内置表里没有的)。 */
export const needsUiTranslation = (lang) => !SUPPORTED_UI_LANGS.includes(lang);
/** `ui` 里可覆盖的键——供 ui-template 子命令生成骨架。 */
export function uiTemplate() {
const en = STRINGS.en;
const out = {};
for (const [k, v] of Object.entries(en)) {
if (typeof v === 'function') continue;
out[k] = v && typeof v === 'object' ? { ...v } : v;
}
return out;
}
/* ------------------------------------------------------------------ */
/* seed — 从大纲预填角色表骨架 */
/* ------------------------------------------------------------------ */
/**
* outline 的 tier 映射成 cast 的 importance。
*
* 注意两边粒度不一样:outline 的 `lead` 是「主角组」,男女主与主反派都在里面,
* 对应 cast 的 protagonist 与 major 两档。这里一律给 protagonist,由模型照
* seedNote 里的 role(女主 / 男主 / 反派)把主角之外的改成 major——**分档本身
* 不推翻,只在主角组内部细分**。
*/
export const TIER_TO_IMPORTANCE = { lead: 'protagonist', support: 'supporting', functional: 'minor' };
/**
* 从 outline.json 预填 cast.json 骨架。
*
* 大纲是角色设定的上游:谁进谁不进、谁是主角组,在改编阶段就拍板了,这一层
* 不再判断一遍。搬过来的是**大纲已经定死的事实**(角色码、名字、分档、人物线、
* 由原著的谁合并而来),留空的是**这一层才该做的设计**(别名、画像、形象提示词、
* 音色提示词)——别名要读原文才知道,大纲里没有。
*
* 与 novel-art / novel-script 的 seedFromOutline 同一个形状:搬事实、留设计、
* 附一条 seedNote 带上下文。产出是骨架不是成品,直接跑 validate 会报一堆
* 字段缺失,那是预期的。
*/
export function seedFromOutline(outline) {
const characters = (outline?.characters ?? []).map((c) => {
const note = [
c?.id ? `角色码 ${c.id}` : null,
c?.role ? `大纲定位:${c.role}` : null,
c?.tier ? `大纲分档:${c.tier}` : null,
(c?.from ?? []).length ? `合并自:${(c.from ?? []).join('、')}` : null,
].filter(Boolean).join(' ');
return {
// 从大纲搬来的事实,不用再想
...(c?.id ? { id: c.id } : {}),
name: c?.name ?? '',
importance: TIER_TO_IMPORTANCE[c?.tier] ?? 'supporting',
// 这一层才该做的设计,先占位
aliases: [],
oneLiner: '',
persona: {
gender: '', ageRange: '', identity: '', appearance: '',
personality: [], temperament: '', motivation: '',
// 人物弧光大纲已经写了,直接用;觉得不对回去改大纲,别在这里改一个不一样的
arc: c?.arc ?? '',
relationships: [], evidence: [],
},
image: { prompt: '', promptLocal: '', negativePrompt: '', tags: [], sheet: '' },
voice: { timbre: '', pitch: '', pace: '', accent: '', emotion: '', prompt: '', referenceHint: '' },
...(note ? { seedNote: note } : {}),
};
});
return {
source: outline?.source ?? '',
lang: outline?.lang ?? DEFAULT_LANG,
summary: '',
characters,
};
}
/* ------------------------------------------------------------------ */
/* validate */
/* ------------------------------------------------------------------ */
const IMPORTANCE = ['protagonist', 'major', 'supporting', 'minor'];
/** 中日韩表意文字与假名、谚文——图像/TTS 提示词里出现就说明串语言了。 */
const CJK = /[㐀-鿿-ヿ가-]/;
/** 假名单独一条:用来把日文和中文区分开。 */
const KANA = /[-ヿ]/;
const PERSONA_STRINGS = ['gender', 'ageRange', 'identity', 'appearance', 'temperament', 'motivation', 'arc'];
/** 机器输入,永远英文——图像和 TTS 引擎都吃英文最稳,跟报告语言无关。 */
const MACHINE_FIELDS = { image: ['prompt', 'negativePrompt', 'sheet'], voice: ['prompt'] };
/** 给人读的,跟随报告语言。 */
const HUMAN_VOICE_FIELDS = ['timbre', 'pitch', 'pace', 'accent', 'emotion', 'referenceHint'];
const normalise = (s) => String(s).replace(/\s+/g, '');
/**
* @param characters 角色卡数组
* @param sourceText 原文;null 则跳过逐字引文校验
* @param lang 报告语言,决定人类可读字段该是什么语言
*/
export function validateCast(characters, sourceText, lang = DEFAULT_LANG) {
const problems = [];
const flatSource = sourceText === null ? null : normalise(sourceText);
const at = (name, msg) => problems.push(`[${name}] ${msg}`);
if (!Array.isArray(characters) || characters.length === 0) {
return ['cast 为空或不是数组'];
}
// --- 同批角色的提示词不许雷同 ---
/*
* profile-pass.md 早就写着「同一批角色之间要能区分开,别把长相和声线做成一个样」,
* 但没有门。模型第二趟出卡时容易套同一个模板:个体描述写得短,剩下全是真实感样板
* 与固定的构图光照尾巴——两个年龄性别接近的角色出来就是同一个人(issue #9)。
*
* 判定:按词集合算 Jaccard 相似度,超阈值就点名那一对。
* 阈值取 0.75,是量出来的不是拍的——自带样例四个角色两两最高 39%,
* 而「只改年龄与衣服颜色」的雷同用例是 98%,中间余量极大。
*
* image.sheet 刻意不查:三分区排版规范是大段固定文本,真实角色之间本来就有 63%
* 重合,设门必然误拦——误拦的门比没有门更糟。
*/
const promptSim = (a, b) => {
const words = (s) => new Set(String(s ?? '').toLowerCase().split(/[^a-z]+/).filter((w) => w.length > 2));
const A = words(a); const B = words(b);
if (A.size < 8 || B.size < 8) return 0;
const inter = [...A].filter((w) => B.has(w)).length;
return inter / new Set([...A, ...B]).size;
};
const SIM_MAX = 0.75;
for (const [field, label] of [['prompt', '出图提示词'], ['voice', '音色提示词']]) {
const get = (c) => (field === 'prompt' ? c?.image?.prompt : c?.voice?.prompt);
for (let i = 0; i < characters.length; i += 1) {
for (let j = i + 1; j < characters.length; j += 1) {
const sim = promptSim(get(characters[i]), get(characters[j]));
if (sim >= SIM_MAX) {
const pct = Math.round(sim * 100);
problems.push(
`${characters[i]?.name ?? '(无名)'} 与 ${characters[j]?.name ?? '(无名)'} 的${label}雷同 ${pct}%`
+ `(上限 ${Math.round(SIM_MAX * 100)}%)——同一批角色要能区分开,别套同一个模板`,
);
}
}
}
}
for (const c of characters) {
const name = c?.name ?? '(无名)';
// --- 结构 ---
if (typeof c?.name !== 'string' || !c.name.trim()) at(name, '缺少 name');
if (!Array.isArray(c?.aliases)) at(name, 'aliases 必须是数组');
if (!IMPORTANCE.includes(c?.importance)) {
at(name, `importance 必须是 ${IMPORTANCE.join('/')},实际是 ${JSON.stringify(c?.importance)}`);
}
if (typeof c?.oneLiner !== 'string' || !c.oneLiner.trim()) at(name, '缺少 oneLiner');
const persona = c?.persona;
if (!persona || typeof persona !== 'object') {
at(name, '缺少 persona');
} else {
for (const f of PERSONA_STRINGS) {
if (typeof persona[f] !== 'string' || !persona[f].trim()) at(name, `persona.${f} 缺失或为空`);
}
if (!Array.isArray(persona.personality)) at(name, 'persona.personality 必须是数组');
if (!Array.isArray(persona.relationships)) at(name, 'persona.relationships 必须是数组');
if (!Array.isArray(persona.evidence)) at(name, 'persona.evidence 必须是数组');
}
const image = c?.image;
if (!image || typeof image !== 'object') {
at(name, '缺少 image');
} else {
for (const f of ['prompt', 'negativePrompt']) {
if (typeof image[f] !== 'string' || !image[f].trim()) at(name, `image.${f} 缺失或为空`);
}
if (typeof image.sheet !== 'string' || !image.sheet.trim()) {
at(name, 'image.sheet 缺失或为空(角色设定图提示词)');
}
if (!Array.isArray(image.tags)) at(name, 'image.tags 必须是数组');
}
const voice = c?.voice;
if (!voice || typeof voice !== 'object') {
at(name, '缺少 voice');
} else {
for (const f of [...HUMAN_VOICE_FIELDS, 'prompt']) {
if (typeof voice[f] !== 'string' || !voice[f].trim()) at(name, `voice.${f} 缺失或为空`);
}
/*
* 音色提示词里不许出现引号包起来的台词。
* profile-pass.md 第 7 条写着「描述乐器本身,不是某一句台词的演绎」,
* 但模型会写出「杀意藏在『规矩就是规矩』这类客套话里」这种句子——
* 台词一进去,有些 TTS 引擎会把它当成要朗读的内容(生产里踩过)。
* 只查成对的引号,判定干净:正常的音色描述不需要引任何一句话。
*/
/*
* 音色提示词要紧凑,不要散文。
* voice design 引擎(Qwen3-TTS Voice Design / ElevenLabs / MiniMax)吃的是
* 参数密度:年龄性别、音色、音区、共鸣、动态、音量、语速、语调、口音、默认情绪。
* 写成流畅的英文散文会把这些参数稀释掉——生产里实测对比过,紧凑版明显更好。
*
* 上限 400 字符是量出来的:自带样例四个角色 218–245,而被实测判为过冗余的
* 散文版是 490–514,中间余量很大。它拦的是「写成小作文」,不是「写得细」。
*/
if (typeof voice.prompt === 'string' && voice.prompt.length > 400) {
at(name, `voice.prompt ${voice.prompt.length} 字符,超过 400——音色提示词要紧凑的参数串,不是散文;参数写全但别铺陈`);
}
if (typeof voice.prompt === 'string') {
const quoted = voice.prompt.match(/[「『"“][^」』"”]{2,}[」』"”]/);
if (quoted) {
at(name, `voice.prompt 里出现了引号台词「${quoted[0].slice(0, 24)}」——音色提示词描述的是嗓子本身,不是某一句台词的演绎`);
}
}
}
// --- 引文必须逐字 ---
if (flatSource && Array.isArray(persona?.evidence)) {
for (const quote of persona.evidence) {
if (typeof quote !== 'string') {
at(name, 'persona.evidence 里有非字符串');
} else if (!flatSource.includes(normalise(quote))) {
at(name, `引文不是原文逐字片段:${quote}`);
}
}
}
// --- 出图提示词不许出现人名 ---
if (image) {
const names = [c?.name, ...(Array.isArray(c?.aliases) ? c.aliases : [])].filter(
(n) => typeof n === 'string' && n.trim(),
);
for (const field of ['prompt', 'promptLocal', 'sheet']) {
const value = image[field];
if (typeof value !== 'string') continue;
for (const n of names) {
if (value.includes(n)) at(name, `image.${field} 里出现了人名「${n}」`);
}
}
}
// --- 语言分工 ---
// 机器字段永远英文;人类字段跟随报告语言。
// 只有 zh / en 能可靠自动判别,其他语言不猜、跳过。
for (const [group, fields] of Object.entries(MACHINE_FIELDS)) {
const obj = c?.[group];
if (!obj) continue;
for (const f of fields) {
if (typeof obj[f] === 'string' && CJK.test(obj[f])) {
at(name, `${group}.${f} 是喂给模型的,必须英文,但含中日韩字符`);
}
}
}
if (Array.isArray(image?.tags)) {
for (const t of image.tags) {
if (typeof t === 'string' && CJK.test(t)) at(name, `image.tags 必须英文,但「${t}」含中日韩字符`);
}
}
// 只有这三种能可靠自动判别,其他语言不猜、跳过——误报比漏报更烦人。
if (voice) {
for (const f of HUMAN_VOICE_FIELDS) {
const v = voice[f];
if (typeof v !== 'string' || !v.trim()) continue;
if (lang === 'en' && CJK.test(v)) at(name, `voice.${f} 应为英文,但含中日韩字符`);
if (lang === 'zh' && !CJK.test(v)) at(name, `voice.${f} 应为中文,实际是「${v}」`);
if (lang === 'zh' && KANA.test(v)) at(name, `voice.${f} 应为中文,但含日文假名`);
if (lang === 'ja' && !KANA.test(v) && !CJK.test(v)) {
at(name, `voice.${f} 应为日文,实际是「${v}」`);
}
}
}
}
return problems;
}
/* ------------------------------------------------------------------ */
/* assemble */
/* ------------------------------------------------------------------ */
/**
* 把第二趟的角色卡合成 cast.json 的顶层结构。纯机械活——之前留给模型
* 手拼,手拼会丢字段、写错顶层键。
*
* 排序:importance 分档,同档按 order(merge 输出的名字顺序,即戏份
* 顺序)。不给 order 就保持传入顺序——CLI 按文件名读卡,那是 slug
* 字典序不是戏份序,所以报告要「按戏份排序」就必须给 order。
*/
export function assembleCast(cards, { source, lang = DEFAULT_LANG, summary = '', ui = null, order = null } = {}) {
const rank = (c) => {
const i = IMPORTANCE.indexOf(c?.importance);
return i < 0 ? IMPORTANCE.length : i; // 越界的排最后,让 validate 去报,这里不崩
};
const orderIndex = new Map((order ?? []).map((name, i) => [String(name).trim().toLowerCase(), i]));
// 不在 order 里的排到同档末尾(保持传入顺序),不报错——卡是从 merge 结果生成的,正常对得上
const byOrder = (c) => orderIndex.get(String(c?.name ?? '').trim().toLowerCase()) ?? orderIndex.size;
const characters = cards
.map((card, i) => [card, i])
.sort((a, b) => rank(a[0]) - rank(b[0]) || byOrder(a[0]) - byOrder(b[0]) || a[1] - b[1])
.map(([card]) => card);
const cast = { source, lang };
if (ui) cast.ui = ui;
cast.summary = summary;
cast.characters = characters;
return cast;
}
/* ------------------------------------------------------------------ */
/* render — markdown */
/* ------------------------------------------------------------------ */
export function renderMarkdown(characters, source, summary = '', lang = DEFAULT_LANG, ui = null) {
const t = strings(lang, ui);
const out = [t.mdTitle(source), '', t.mdCast(characters.length, characters.map((c) => c.name).join('、')), ''];
if (summary) out.push(t.mdSynopsis, '', summary, '');
for (const c of characters) {
const { persona, image, voice } = c;
out.push('---', '');
out.push(`## ${c.name}${c.aliases.length ? `(${c.aliases.join('、')})` : ''}`, '');
out.push(`> ${t.importance[c.importance] ?? c.importance} · ${c.oneLiner}`, '');
if (c.sheetImage) out.push(``, '');
out.push(`### ${t.groups.persona}`, '');
out.push(`- **${t.persona.gender}**:${persona.gender}`);
out.push(`- **${t.persona.ageRange}**:${persona.ageRange}`);
out.push(`- **${t.persona.identity}**:${persona.identity}`);
if (persona.personality.length) out.push(`- ${persona.personality.join(' / ')}`);
out.push('');
out.push(`**${t.persona.appearance}** ${persona.appearance}`, '');
out.push(`**${t.persona.temperament}** ${persona.temperament}`, '');
out.push(`**${t.persona.motivation}** ${persona.motivation}`, '');
out.push(`**${t.persona.arc}** ${persona.arc}`, '');
if (persona.relationships.length) {
out.push(`**${t.persona.relationships}**`, '');
for (const r of persona.relationships) out.push(`- ${r.name} — ${r.relation}`);
out.push('');
}
if (persona.evidence.length) {
out.push(`**${t.persona.evidence}**`, '');
for (const q of persona.evidence) out.push(`> ${q}`, '');
}
out.push(`### ${t.groups.image}`, '');
if (image.tags.length) out.push(`\`${image.tags.join('`, `')}\``, '');
out.push(`**${t.image.prompt}**`, '', '```text', image.prompt, '```', '');
if (image.promptLocal) out.push(`${image.promptLocal}`, '');
out.push(`**${t.image.negative}**`, '', '```text', image.negativePrompt, '```', '');
out.push(`**${t.image.sheet}**`, '', '```text', image.sheet, '```', '');
out.push(`### ${t.groups.voice}`, '');
for (const f of HUMAN_VOICE_FIELDS) out.push(`- **${t.voice[f]}**:${voice[f]}`);
out.push('');
out.push(`**${t.voice.prompt}**`, '', '```text', voice.prompt, '```', '');
}
return out.join('\n');
}
/* ------------------------------------------------------------------ */
/* render — html */
/* ------------------------------------------------------------------ */
/*
* 三栏工作台。设计约定见 references/report-style.md。不能破的:
* 1. 双字域:衬线=叙事与原文,无衬线=分析,等宽=喂给机器的提示词
* 2. 「(推断)」自动高亮,让读者一眼分清有据和补全
* 3. 一次只看一个角色,靠左栏切换 + 顶栏搜索找人
* 4. 打印时全部展开——屏幕上一次一个,纸上要是完整的一份
*/
const esc = (s) =>
String(s ?? '')
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"');
/** 推断标记:半角/全角 × 中英四种写法都要认,模型不挑食地乱产。 */
const INFERRED = /(\s*(?:推断|inferred)[^)]*)|\(\s*(?:推断|inferred)[^)]*\)/gi;
const marked = (s) => esc(s).replace(INFERRED, (m) => `${m}`);
const IMPORTANCE_ORDER = ['protagonist', 'major', 'supporting', 'minor'];
/** 左栏的一条角色。缩略图取设定图的左半边——那里正好是半身像。 */
function renderRosterItem(c, index, t) {
const meta = [c.persona?.gender, c.persona?.ageRange].filter(Boolean).join(' · ');
const hay = [
c.name,
...(c.aliases ?? []),
c.persona?.identity,
c.persona?.gender,
c.persona?.ageRange,
...(c.persona?.personality ?? []),
c.oneLiner,
]
.filter(Boolean)
.join(' ');
return ``;
}
function renderCharacter(c, index, t) {
const { persona, image, voice } = c;
const promptRow = (label, value) =>
!value
? ''
: ` ${esc(value)}${esc(label)}
${marked(body)}
${esc(t.sheetCaption)}
` : `${esc(q)}`).join('')}
${esc(t.graphEmpty)}
`} 报告语言,默认取 cast.json 的 lang,再默认 ${DEFAULT_LANG}
内置界面文案:${SUPPORTED_UI_LANGS.join(' / ')};其他语言码用英文界面骨架
render 选项:
--source 报告标题用的书名(默认取 cast.json 的 source 或文件名)
--images 设定图所在目录,任意路径(相对当前目录解析);默认 cast.json 同级的 images/
会去找 /-sheet.png;报告里的图片路径按「报告写在 cast.json 旁边」计算`;
function readJson(path) {
return JSON.parse(readFileSync(resolve(path), 'utf8'));
}
/** 取 --flag 的值,没有就返回 fallback。 */
function flag(rest, name, fallback = null) {
const i = rest.indexOf(name);
return i >= 0 && rest[i + 1] ? rest[i + 1] : fallback;
}
/** cast.json 可以是 {source, lang, summary, characters},也可以是裸数组(旧格式)。 */
function loadCast(path) {
const raw = readJson(path);
const characters = Array.isArray(raw) ? raw : raw.characters;
if (!Array.isArray(characters)) throw new Error(`${path} 里没有 characters 数组`);
return {
characters,
source: Array.isArray(raw) ? null : raw.source,
summary: Array.isArray(raw) ? '' : (raw.summary ?? ''),
lang: Array.isArray(raw) ? DEFAULT_LANG : (raw.lang ?? DEFAULT_LANG),
ui: Array.isArray(raw) ? null : (raw.ui ?? null),
};
}
function main(argv) {
const [cmd, ...rest] = argv;
if (!cmd || cmd === '-h' || cmd === '--help') {
console.log(USAGE);
process.exit(cmd ? 0 : 1);
}
if (cmd === 'seed') {
const [path] = rest;
if (!path) throw new Error('用法:seed ');
console.log(JSON.stringify(seedFromOutline(readJson(path)), null, 2));
return;
}
if (cmd === 'chunk') {
const [book, workdir] = rest;
if (!book || !workdir) throw new Error('用法:chunk ');
const text = readFileSync(resolve(book), 'utf8');
const chunks = chunkText(text);
mkdirSync(resolve(workdir), { recursive: true });
chunks.forEach((c, i) => {
writeFileSync(join(resolve(workdir), `chunk-${String(i).padStart(2, '0')}.txt`), c, 'utf8');
});
const truncated = chunks.length >= MAX_CHUNKS && text.length > CHUNK_SIZE * MAX_CHUNKS;
console.log(
JSON.stringify(
{ chunks: chunks.length, chars: text.length, workdir: resolve(workdir), truncated },
null,
2,
),
);
if (truncated) console.error(`⚠️ 文本超过 ${MAX_CHUNKS} 块上限,尾部未扫描`);
return;
}
if (cmd === 'merge') {
const [workdir] = rest;
if (!workdir || workdir.startsWith('--')) throw new Error('用法:merge [--apply merges.json]');
const dir = resolve(workdir);
const files = readdirSync(dir).filter((f) => /^roster-.*\.json$/.test(f)).sort();
if (!files.length) throw new Error(`${dir} 里没有 roster-*.json`);
const batches = files.map((f) => {
const raw = readJson(join(dir, f));
return Array.isArray(raw) ? raw : (raw.characters ?? []);
});
let cast = mergeRoster(batches);
const applyPath = flag(rest, '--apply');
if (applyPath) {
const raw = readJson(applyPath);
cast = applyMerges(cast, Array.isArray(raw) ? raw : (raw.merges ?? []));
}
// mergeCandidates 是嫌疑名单不是判决:由调用方复核后写 merges.json 再 --apply
console.log(JSON.stringify({ characters: cast, mergeCandidates: mergeCandidates(cast) }, null, 2));
return;
}
if (cmd === 'assemble') {
const [workdir] = rest;
if (!workdir || workdir.startsWith('--')) {
throw new Error('用法:assemble --source <书名> [--lang zh] [--out cast.json]');
}
const dir = resolve(workdir);
const sourceName = flag(rest, '--source');
if (!sourceName) throw new Error('assemble 需要 --source <书名>');
const lang = flag(rest, '--lang', DEFAULT_LANG);
const files = readdirSync(dir).filter((f) => /^card-.*\.json$/.test(f)).sort();
if (!files.length) throw new Error(`${dir} 里没有 card-*.json`);
// 宽容解析:一份坏卡不废整批——逐个点名,重跑坏的那个角色就行
const problems = [];
const cards = [];
const seen = new Map();
for (const f of files) {
let card;
try {
card = readJson(join(dir, f));
} catch (error) {
problems.push(`${f} 不是合法 JSON(${error.message})——重跑这个角色即可`);
continue;
}
if (!card || typeof card !== 'object' || Array.isArray(card) || !card.name) {
problems.push(`${f} 不是单张角色卡(缺 name)——重跑这个角色即可`);
continue;
}
if (seen.has(card.name)) {
problems.push(`「${card.name}」有两份卡(${seen.get(card.name)} 和 ${f}),删掉多余的那份`);
continue;
}
seen.set(card.name, f);
cards.push(card);
}
// 三份附带文件都能单独指定路径;不给才去工作目录里找 skill 自己写下的那份
const summaryPath = resolve(flag(rest, '--summary', join(dir, 'summary.txt')));
const summary = existsSync(summaryPath) ? readFileSync(summaryPath, 'utf8').trim() : '';
if (!summary) problems.push(`缺 ${summaryPath}(故事摘要)——用报告语言写 3–5 句进去,或用 --summary 指定`);
const uiPath = resolve(flag(rest, '--ui', join(dir, 'ui.json')));
const ui = existsSync(uiPath) ? readJson(uiPath) : null;
if (needsUiTranslation(lang) && !ui) {
problems.push(`lang=${lang} 不在内置界面语言里,缺 ${uiPath}——用 ui-template 生成骨架翻译后放进去,或用 --ui 指定`);
}
// 同档角色的戏份顺序来自 merge 的输出——卡按文件名读入是 slug 字典序,
// 不给顺序的话「按戏份排序」就名存实亡
const orderPath = flag(rest, '--order', existsSync(join(dir, 'merged.json')) ? join(dir, 'merged.json') : null);
let order = null;
if (orderPath) {
const raw = readJson(orderPath);
const list = Array.isArray(raw) ? raw : (raw.characters ?? []);
order = list.map((e) => (typeof e === 'string' ? e : e?.name)).filter(Boolean);
} else {
console.error(`⚠️ 没有 --order 也没有 ${join(dir, 'merged.json')},同档角色将按文件名序而不是戏份序`);
}
if (problems.length) {
console.error(`✗ ${problems.length} 处问题:\n`);
for (const p of problems) console.error(' ' + p);
process.exit(1);
}
const cast = assembleCast(cards, { source: sourceName, lang, summary, ui, order });
const json = JSON.stringify(cast, null, 2) + '\n';
const out = flag(rest, '--out');
if (out) {
writeFileSync(resolve(out), json, 'utf8');
console.log(`✓ ${cast.characters.length} 个角色 → ${resolve(out)}`);
} else {
process.stdout.write(json);
}
return;
}
if (cmd === 'validate') {
const [castPath, bookPath] = rest;
if (!castPath) throw new Error('用法:validate ');
const { characters, summary, lang: castLang, ui } = loadCast(castPath);
const lang = flag(rest, '--lang', castLang);
const source = bookPath ? readFileSync(resolve(bookPath), 'utf8') : null;
if (!bookPath) console.error('⚠️ 没给原文,跳过逐字引文校验');
const problems = validateCast(characters, source, lang);
// 顶层的故事摘要——报告要用,缺了就没法在顶部交代背景
if (typeof summary !== 'string' || !summary.trim()) {
problems.unshift('顶层缺少 summary(故事摘要),报告顶部会空着');
}
// 内置表没有这个语言,又没给 ui 翻译 —— 报告界面会露出英文
if (needsUiTranslation(lang) && !ui) {
problems.unshift(
`lang=${lang} 不在内置界面语言(${SUPPORTED_UI_LANGS.join('/')})里,` +
'顶层需要一份 ui 翻译,否则界面文案会是英文。' +
'用 `ui-template` 生成骨架后翻译填进去。',
);
}
if (problems.length) {
console.error(`✗ ${problems.length} 处违规:\n`);
for (const p of problems) console.error(' ' + p);
process.exit(1);
}
console.log(`✓ ${characters.length} 个角色全部通过校验(lang=${lang})`);
return;
}
if (cmd === 'render') {
const [castPath] = rest;
if (!castPath) throw new Error('用法:render [--html|--md]');
const html = rest.includes('--html');
const imagesFlag = flag(rest, '--images');
const sourceFlag = flag(rest, '--source');
const { characters, source, summary, lang: castLang, ui } = loadCast(castPath);
const lang = flag(rest, '--lang', castLang);
const title = sourceFlag ?? source ?? basename(castPath).replace(/\.[^.]+$/, '');
// 图是用户在下游出好的素材,放哪由用户定:--images 按普通命令行路径解析,
// 不给才退回 cast.json 同级的 images/。报告默认写在 cast.json 旁边,
// src 写成相对那里的路径,整个目录一起挪也不断。图不存在就渲染成占位。
const outDir = resolve(castPath, '..');
const imagesDir = imagesFlag ? resolve(imagesFlag) : join(outDir, 'images');
for (const c of characters) {
const abs = join(imagesDir, `${slug(c.name)}-sheet.png`);
if (existsSync(abs)) c.sheetImage = relative(outDir, abs).split(sep).join('/');
}
process.stdout.write(
(html
? renderHtml(characters, title, summary, lang, ui)
: renderMarkdown(characters, title, summary, lang, ui)) + '\n',
);
return;
}
if (cmd === 'ui-template') {
const lang = rest[0] ?? '';
console.log(
JSON.stringify(
{ note: `把下面每个值翻译成 ${lang},整块放进 cast.json 的顶层 "ui"`, ui: uiTemplate() },
null,
2,
),
);
return;
}
if (cmd === 'slug') {
if (!rest[0]) throw new Error('用法:slug ');
console.log(slug(rest[0]));
return;
}
throw new Error(`未知命令 ${cmd}\n\n${USAGE}`);
}
// 只有直接运行才跑 CLI —— selftest.mjs 需要 import 这些函数。
// 两边都取 realpath:软链安装时 argv[1] 是链接路径,而 import.meta.url
// 已被 Node 解析成真实路径,不归一化就永远不相等。
function isMainModule() {
if (!process.argv[1]) return false;
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
}
if (isMainModule()) {
// `render ... | head` 这类管道提前关闭时安静退出,别甩 EPIPE 堆栈
process.stdout.on('error', (e) => {
if (e.code === 'EPIPE') process.exit(0);
throw e;
});
try {
main(process.argv.slice(2));
} catch (error) {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
}
}