Files
digital-me-history/digital-me-backend/src/erchuang.js
T
admin 0593ff7e62
nginx serve /uploads direct (audio speed) / patch-nginx (push) Failing after 1m34s
数字我 Digital Me:全量入库(大文件走 Git LFS)
2026-10-10 16:24:43 +08:00

404 lines
34 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
import { aiWrite } from './services/aiWrite.js'; // ★ 二创 AI 创作统一走【大模型对接点】(老板可在 aiWrite.js/env 接自己的大模型)
import { ZHANZHEN } from './zhanzhen.js';
import { getNvxing, listNvxing } from './nvxing.js';
import { listTazhuan } from './tazhuan.js';
import { getMingchangmian, listMingchangmian } from './mingchangmian.js';
import { buildPack, scanEra, eraForbidden } from './sucaibao.js';
import { getDirection, specFromDirection, listDirections, deriveTuiti, toolsForOutputKind, TOOLS } from './chuangzuo.js';
import { HOOKS } from './erchuangHooks.js'; // 锚点创作钩子(勾人的写作引子·扣双证)
/**
* 二次创作·文学篇 —— 把你亲历的那一段历史(战争篇的一场战役 / 她传的一个人),就地变成一篇文学作品。
*
* ★★ 定位(天下·历史版自己的增长闭环,非大师变现):亲历历史 → 二创成【可晒可传的作品】 → 传播拉新 + 精装我的传。
* ★★★ 红线:
* ① 创作 ≠ 史料:每篇作品都【锚定一段双证史实】做地基,输出显式标「文学演绎·非史料」,并附真实正史引文;
* LLM 可想象细节、抒情言志,但【不得杜撰与史实相悖的硬事实】(胜负/生死须与所给史实一致)。
* ② 走 DashScope·Qwen(services/llm.js chat),无 key / 海外兜底关时优雅降级(抛错→路由 502,不外发不崩)。
* ③ 零游戏味:不写"解锁/成就/通关"。
*/
// 文学体裁。layer='work'=抒情整篇(诗词赋·整篇即演绎不做句级切分);'segment'=叙事分层(句级出处分层·细颗粒)。
export const STYLES = {
shi: { label: '古体诗', layer: 'work', hint: '一首古体诗(五言或七言,四到八句),要押韵、有意象、见胸襟' },
ci: { label: '词', layer: 'work', hint: '一阕词(可择《满江红》《念奴娇·赤壁怀古》《破阵子》等词牌,大致合其句读长短),豪放或沉郁随情而定' },
fu: { label: '赋', layer: 'work', hint: '一段短赋(骈散结合,铺陈其时其地其人,气势沉雄,二三百字)' },
ji: { label: '记', layer: 'segment', hint: '一篇短文·记,以亲历者视角叙这一段的所见所为所感,二三百字' },
juben: { label: '短剧剧本', layer: 'work', hint: '一段短剧分镜脚本(3-5 场,每场用【场景】【人物】【画面】【台词】【旁白】标注,可直接用于 AI 短剧或漫画分镜),紧扣史实的戏剧冲突,画面感强' },
// ── 马伯庸式细颗粒格式(segment·句级出处分层)──
weishichen: { label: '微时辰', layer: 'segment', eye: 'low', hint: '一名低阶真实在场者,在两个相邻史实节点之间的【一个时辰】(约两小时)里的所见所为——把时间窗掐死,细节从时间里挤出来,六七百字' },
duitan: { label: '史缝对谈', layer: 'segment', dialogue: true, hint: '两个同场的真实历史人物,在史书没记原话的缝隙里的一次对谈,纯对白体,各说各的身份口吻——★对白全是演绎、绝不冒充实录' },
gongzhi: { label: '一日供职', layer: 'segment', hint: '一个真实历史人物在那个朝代的一个寻常工作日,从点卯到散衙——官制程序是史实、日常器物是合理还原' },
huimu: { label: '回目片段', layer: 'segment', hint: '带回目对句的章回体小说片段——回目由史实节点生成,同样的章回皮里没有一句演义' },
gongxu: { label: '工序帖', layer: 'segment', hint: '把一道具体工序(如火船怎么裹薪草膏油、立牙旗)从头拆到可操作,每步扣住正史确有的器物' },
chuanyue: {
label: '穿越情感剧', layer: 'segment', hint: '一段穿越情感短剧:一个现代人穿越进这个真实历史场景,与一位真实历史人物结下情感牵绊',
genre: '★穿越设定与情感线全部标 [[y:穿越]]/[[y:情感]];穿越者是虚构的现代人(标 y:虚构)。情感对象须取骨架/名录里的【真实历史人物】,其真实结局锁死不许改——最动人的是「你明知他的结局,却改变不了」的宿命感:你能陪他,却救不了他。历史大势一步不改,改的只是你在缝隙里陪他走过的那些没被史书记下的时刻。',
},
quanmou: {
label: '权谋朝斗', layer: 'segment', hint: '一段权谋短剧:真实的朝堂/军帐博弈里,各方算计、进退、下套',
genre: '★所有权力大势、胜负、人物真实结局锁死史实(挂 f:cite);算计的对白、心机、暗流是演绎(标 [[y:对话]]/[[y:内心]])。谋算要扣住真实的利害关系(谁真的忌惮谁、谁真的被谁除掉),不架空、不爽文。',
},
xuanyi: {
label: '悬疑探案', layer: 'segment', hint: '一段历史悬疑:以真实事件为谜面,一个视角人物抽丝剥茧',
genre: '★谜底=真实史实(不可改·挂 f:cite);一个视角人物的推理过程、勘验、对白是演绎(标 y)。线索要落在正史确有的细节/器物/人物关系上,最后收束到真实结局——是「解开一桩真事」不是编一个假案。',
},
};
// 把一个锚点(战役 / 她传人物)解析成一张【双证史实卡】——作品的地基。
function anchorFact(anchorType, id) {
if (anchorType === 'zhanzhen') {
const b = ZHANZHEN[String(id || '')];
if (!b) return null;
return {
type: 'zhanzhen', id: b.id, title: b.name,
sub: `${b.side} · ${b.year < 0 ? '前' + -b.year : b.year}年`,
facts: [b.stake, b.real].filter(Boolean),
cites: [b.cite, ...(b.nodes || []).slice(0, 3).map((n) => n.cite)].filter(Boolean),
};
}
if (anchorType === 'nvxing') {
const w = getNvxing(String(id || ''));
if (!w) return null;
return {
type: 'nvxing', id: w.id, title: w.name,
sub: `${w.dynName} · ${w.title}`,
facts: [w.intro].filter(Boolean),
cites: (w.arc || []).slice(0, 3).map((g) => g.cite).filter(Boolean),
};
}
if (anchorType === 'mingchangmian') {
const s = getMingchangmian(String(id || ''));
if (!s) return null;
return {
type: 'mingchangmian', id: s.id, title: s.title,
sub: `${s.era || ''}${s.place ? ' · ' + s.place : ''}`,
facts: [s.intro, s.outcome?.text].filter(Boolean),
cites: [...(s.beats || []).slice(0, 2).map((b) => b.cite), s.outcome?.cite].filter(Boolean),
};
}
return null;
}
// 可创作的锚点名录(名场面 + 战役 + 她传人物)。纯读,供前端选“为哪一段作一篇”。
export function listErchuangAnchors() {
// ★ 名场面:二创的首选锚点(老板重定向)—— 现成的 cast/quotes/gaps,最好写。
const scenes = listMingchangmian().map((s) => ({ type: 'mingchangmian', id: s.id, title: s.title, sub: `${s.era || ''}${s.sceneType ? ' · ' + s.sceneType : ''}`, hook: HOOKS.mingchangmian[s.id] || null }));
const battles = Object.values(ZHANZHEN).map((b) => ({
type: 'zhanzhen', id: b.id, title: b.name, sub: `${b.side} · ${b.year < 0 ? '前' + -b.year : b.year}年`, hook: HOOKS.zhanzhen[b.id] || null,
})).sort((a, b) => (a.title > b.title ? 1 : -1));
const women = listNvxing().map((w) => ({ type: 'nvxing', id: w.id, title: w.name, sub: `${w.dynName} · ${w.title}`, hook: HOOKS.nvxing[w.id] || null }));
// ★他传接进二创(老板:二创缺他传·和她传不对称)——每位名臣一生 arc 就是现成二创材料。
const men = listTazhuan().map((w) => ({ type: 'tazhuan', id: w.id, title: w.name, sub: `${w.dynName} · ${w.title}`, hook: (HOOKS.tazhuan || {})[w.id] || null }));
// ★ 创作方向体系(26条4大类) + 国内工具推荐(哪家强推哪家·海外不接) —— 完整链路的③④步配置。
// styles 仍保留供旧前端回退;新前端走 directions。
return {
mingchangmian: scenes, zhanzhen: battles, nvxing: women, tazhuan: men,
directions: listDirections(),
tools: TOOLS,
styles: Object.entries(STYLES).map(([k, v]) => ({ key: k, label: v.label, hint: v.hint, genre: v.genre || null, layer: v.layer })),
};
}
/**
* ★★ 素材包·只读视图 —— 二创的地基「看得见」:把双证骨架摊开给用户,让他【自己顺着写】,
* 而不是只能让 AI 下笔。纯查表派生(buildPack),零 LLM、零网络。
* 结构:一句钩子/局势 → 双证结局 → 史实骨架时间线(锁死·各挂cite) → 在场真人(叙事之眼候选·真结局)
* → 正史器物 → 缝隙(史书没写、你可以创作的地方) → 红线(演义黑名单/断代禁物/铁律)。
*/
export function buildErchuangPackView(anchorType, anchorId) {
const pack = buildPack(anchorType, anchorId);
if (!pack) return null;
const sk = pack.skeleton;
return {
ok: true,
meta: {
type: pack.meta.anchorType, id: pack.meta.anchorId, title: pack.meta.title,
era: pack.meta.era || null, year: pack.meta.year ?? null,
sub: pack.meta.side || pack.meta.subtitle || '',
},
hook: sk.hook || null,
situ: sk.situ || null,
tuiti: deriveTuiti(pack), // ★ 从素材包派生的"切入题目"(第②步·让用户不必空想)
real: sk.real ? { text: sk.real.text, cite: sk.real.cite } : null,
nodes: (sk.nodes || []).map((n) => ({ event: n.event, cite: n.cite || null })),
quotes: (sk.quotes || []).map((q) => ({ who: q.who, words: q.words, cite: q.cite || null })), // 名句原话(可引用)
roster: (sk.roster || []).slice(0, 12).map((p) => ({ name: p.name, office: p.office || null, fate: p.fate || null, cite: p.cite || null, side: p.side || null })),
qiwu: (sk.qiwu || []).slice(0, 10).map((q) => ({ item: q.item, desc: q.desc || null, cite: q.cite || null })),
qiwuText: sk.qiwuText || null, // 名场面器物整段描述
cautions: sk.cautions || [], // 该场"别写歪"的提醒(整句)
forces: sk.forces?.sides ? sk.forces.sides.map((s) => ({
label: s.label, commander: s.commander?.name || null,
verified: s.strength?.verified?.value || null, claimed: s.strength?.claimed?.value || null,
})) : null,
gaps: (pack.gaps || []).map((g) => ({ between: g.between })),
eye: pack.eye ? { name: pack.eye.name, office: pack.eye.office || null } : null,
redlines: {
blacklist: sk.blacklist || [],
eraForbidden: pack.era ? eraForbidden(pack.era) : [],
rules: [
'胜负、生死、关键节点的走向——锁死史实,一处不改',
'对话是你的演绎,绝不排成正史原文冒充实录',
'演义黑名单里的桥段,一个字都别写进去',
...(pack.era ? ['器物、称谓不出现晚于本朝之物(火器/交子/点茶/缠足/棉常服/辣椒玉米等)'] : []),
],
},
};
}
// 纯函数:把史实卡 + 体裁 + 用户方向,拼成给 Qwen 的两条消息(可单测,不触网)。
export function buildErchuangPrompt(fact, styleKey, { seed, role } = {}) {
const st = (styleKey && typeof styleKey === 'object') ? styleKey : (STYLES[styleKey] || STYLES.shi);
const sys = [
'你是一位替用户做「历史二次创作」的文人。基于给定的、已双证的真实历史事件,为用户创作一篇文学作品。',
'铁律:',
'① 这是【文学演绎】而非史料——你可以想象细节、抒情言志、代入人物,但【绝不能杜撰与史实相悖的硬事实】:胜负、成败、关键人物的生死结局,必须与所给史实一致。',
'② 紧扣所给真实事件与正史引文的精神与史实,不架空、不戏说、不掺入演义里被证伪的桥段。',
'③ 不堆砌口号,不使用「解锁/成就/通关」等词;语言要有文学质地。',
'只输出作品正文本身,不要标题以外的任何解释、按语或说明。',
].join('\n');
const user = [
`真实事件:${fact.title}(${fact.sub})`,
`史实:${fact.facts.join(' ')}`,
`正史依据:${fact.cites.join(' ')}`,
role ? `我在其中的身份:${role}` : '',
seed ? `我想表达的方向:${seed}` : '',
`请以此为本,为我创作${st.hint}。`,
].filter(Boolean).join('\n');
return { messages: [{ role: 'system', content: sys }, { role: 'user', content: user }], styleLabel: st.label };
}
const DISCLAIMER = '文学演绎 · 非史料。本篇由 AI 基于真实史实创作,细节含想象成分;史实以所附正史引文为准。';
// ══ 马伯庸式细颗粒(segment):骨架注入 + 缝隙填槽 + 句级出处自标 ══════════════════
// 纯函数:把素材包 + 体裁拼成"四要素"prompt。可单测。
export function buildSegmentPrompt(pack, styleKey, { seed, role, prev, yanyi } = {}) {
const st = (styleKey && typeof styleKey === 'object') ? styleKey : (STYLES[styleKey] || STYLES.ji);
const sk = pack.skeleton;
const { claims: yyClaims, eff: yyBlack } = admitYanyi(sk.blacklist, yanyi);
// ① 史实骨架(编号挂 key·锁定禁改)
const nodeLines = (sk.nodes || []).map((n) => `[${n.key || '·'}] ${n.event}`).join('\n');
const rosterLines = (sk.roster || []).slice(0, 10).map((p) => `${p.name}(${p.office || '在场'}${p.fate ? '·' + p.fate : ''})`).join(';');
const qiwuLines = (sk.qiwu || []).slice(0, 8).map((q) => q.item).join('、') || sk.qiwuText || '';
// 名句原话(正史原文·可直接引用并挂 f:key)—— 名场面独有的最强素材。
const quoteLines = (sk.quotes || []).slice(0, 8).map((q) => `[${q.key || '·'}] ${q.who}:「${q.words}」`).join('\n');
// 该场"别写歪"的定位提醒(软·整句)—— 名场面独有,防把演义定位当史实。
const cautionLines = (sk.cautions || []).slice(0, 8).map((c) => `· ${c}`).join('\n');
const forcesLine = sk.forces?.sides ? sk.forces.sides.map((s) => `${s.label}${s.strength?.verified ? '实数约' + s.strength.verified.value : ''}${s.strength?.claimed ? '(号称' + s.strength.claimed.value + ',号称不是实数)' : ''}`).join(' vs ') : '';
const sys = [
'你是马伯庸式的历史小说作者,写「可考据的戏剧化」:锁死的史实骨架 + 缝隙里填的演绎血肉,两层分明。',
st.genre ? `【本篇类型:${st.label}】${st.genre}\n★类型感优先:这篇首先得读起来像【${st.label}】——史实是不可改的底子、不是枷锁;在锁死的史实之上,把这个类型该有的味道、情绪、笔法写足写透。` : '',
'【出处自标·铁律】你必须把每句话标成两类之一:',
'· 史实句用 [[f:key|句子]] —— key 只能取下方骨架给的编号(如 c1),且这句必须是复述该条史实、不得夹带骨架没有的名词情节;引不出骨架就别标 f。',
'· 演绎句用 [[y:子类|句子]] —— 子类∈{对话,内心,器物,感官,还原,叙事}。拿不准一律 y。',
'· ★对话永远是 y、永不挂 cite、绝不排成正史原文的样子冒充实录;人物心理是 y;正史没记的器物质感/气味/纹样是 y:器物。',
'· 胜负、生死、关键节点的走向必须与骨架一致,一处不许改。',
cautionLines ? `【这一场别写歪·史学提醒】\n${cautionLines}` : '',
yyClaims.length ? `【照妖·准用的演义桥段】以下桥段用户已「照过妖」、明知是演义、特意要写进来——【可以写】,但只作演绎、必须标 [[y:演义|…]],绝不标 f、绝不排成正史原文冒充实录,且不得借它改变骨架的胜负/生死/结局:${yyClaims.join(';')}。` : '',
`· 演义黑名单一字不得出现(以下为未照妖、禁用的):${yyBlack.join('、') || '(无)'}。`,
pack.era ? `· 本篇断代=${pack.era},器物称谓只出现该朝及以前确有之物,禁晚出之物(火器/交子/点茶/缠足/太师椅/棉常服/辣椒玉米等)。` : '',
'只输出作品正文(含 [[f]]/[[y]] 标记),不要额外解释。',
].filter(Boolean).join('\n');
const eye = st.eye === 'low' && sk.roster?.length ? sk.roster[sk.roster.length - 1] : pack.eye;
const user = [
`真实事件:${pack.meta.title}(${pack.meta.era || ''}${pack.meta.year ? '·' + (pack.meta.year < 0 ? '前' + -pack.meta.year : pack.meta.year) + '年' : ''})`,
forcesLine ? `对阵:${forcesLine}` : '',
`史实骨架(按序发生·锁定):\n${nodeLines}`,
quoteLines ? `正史名句·原话(可直接引用并挂 [[f:key|原话]],一字不改):\n${quoteLines}` : '',
rosterLines ? `在场真人(可作叙事之眼/对谈人):${rosterLines}` : '',
qiwuLines ? `正史确有的器物(可挂 f):${qiwuLines}` : '',
eye ? `叙事之眼:优先用【${eye.name}(${eye.office || '在场'})】这样的真实在场者;若需无名小卒可造虚构载体并标 y:虚构。` : '',
role ? `我的身份:${role}` : '',
seed ? `我想表达的方向:${seed}` : '',
prev
? `已有前文如下(请【接着往下写】:承接语气、不重复已写内容、把情节继续推进到新的进展,直到一个自然落点;同样逐句标 [[f]]/[[y]]):\n${prev}`
: `请以此为本,写${st.hint}。这是一篇【完整作品】:有开头、铺陈、转折、落点,从头写到尾,不要只开个头就停;篇幅要充实(约 900–1600 字)。逐句标 [[f]]/[[y]]。`,
].filter(Boolean).join('\n');
return { messages: [{ role: 'system', content: sys }, { role: 'user', content: user }], styleLabel: st.label };
}
// ── 照妖·入料:把用户【照过妖、明知是演义、特意要用作演绎】的桥段从硬黑名单里放行 ──
// 红线不变——放行的演义只能作【演绎】(标 y:演义)、绝不冒充正史、不得改胜负生死结局。
// yanyi: 数组,元素可为字符串(说法)或 {claim}。返回放行后的有效黑名单 eff + 已准用的说法 claims。
export function admitYanyi(blacklist, yanyi) {
const claims = (yanyi || []).map((y) => (typeof y === 'string' ? y : (y?.claim || ''))).map((s) => String(s).trim()).filter(Boolean);
const eff = (blacklist || []).filter((w) => !claims.some((c) => c.includes(w)));
return { claims, eff };
}
// 解析 [[f:key|句子]] / [[y:子类|句子]] + 服务端填 cite + 闸门。纯函数,可单测。
export function parseSegments(text, citeTable, { blacklist = [], era = null } = {}) {
const t = String(text || '');
const re = /\[\[([fy]):([^|\]]*)\|([^\]]*)\]\]/g;
const segs = [];
let last = 0, m;
const pushPlain = (s) => {
const v = s.trim();
if (!v) return;
// 纯标点的间隔并进上一段,别碎成孤立小段
if (/^[,。、;:!?…·—「」『』()()\s]+$/.test(v) && segs.length) { segs[segs.length - 1].text += v; return; }
segs.push({ t: 'y', kind: '叙事', text: v });
};
while ((m = re.exec(t)) !== null) {
if (m.index > last) pushPlain(t.slice(last, m.index));
const [, tag, meta, body] = m;
const sentence = (body || '').trim();
if (!sentence) { last = re.lastIndex; continue; }
if (tag === 'f') {
const key = (meta || '').trim();
// 闸门:key 必须在表内,否则降为演绎(模型无法把虚构升成史料)
if (citeTable && citeTable[key]) segs.push({ t: 'f', text: sentence, cite: citeTable[key], key });
else segs.push({ t: 'y', kind: '叙事', text: sentence });
} else {
// 演绎:对话永不带 cite(本就无);保留子类
segs.push({ t: 'y', kind: (meta || '叙事').trim() || '叙事', text: sentence });
}
last = re.lastIndex;
}
if (last < t.length) pushPlain(t.slice(last));
// 闸门:演义黑名单命中 → 删该段;断代违禁词 → 标 warn(不硬删,避免碎句)
const warnings = [];
const clean = segs.filter((s) => {
const hit = (blacklist || []).find((w) => s.text.includes(w));
if (hit) { warnings.push(`剔除含演义「${hit}」的段`); return false; }
return true;
});
if (era) for (const s of clean) { const eh = scanEra(s.text, era); if (eh.length) warnings.push(`断代存疑器物:${eh.join('、')}`); }
return { segs: clean, warnings };
}
// ══ work·整篇(诗词/剧本/美术脚本/视频脚本)—— 方向驱动·基于素材包 ══
const WORK_STRUCT = {
script: '产出【剧本】:分 3-6 场,每场标【场景·时地】【在场人物】【动作】【台词(演绎·非正史实录,不排成文言原文冒充实录)】【旁白(可引正史·附出处)】。场序=史实发生序,锁死不倒置。',
art: '产出【美术分镜】:分 6-12 格,每格标【景别】【画面内容】【出处(可引正史处附书名篇名)】【本格禁画(演义桥段/晚代器物)】。文末附一句按断代的画风参考。',
video: '产出【短视频脚本】:Hook(0-3秒反差钩子)→铺垫(局势·号称vs实数分清)→反转/高潮(决胜因)→收(点题+双证一句);随后附【镜头清单】(镜号/时长/画面/字幕并挂出处)与【旁白语气】。',
music: '产出【歌词+简谱曲谱】,以【做成一首 1 分钟内、极抓人的短歌】为目标(不是完整大歌):\n① 歌词——【1分钟内·极短的抓人短歌,不是完整大歌】:结构=一个钩子+一小段主歌+一句洗脑副歌(副歌可再钩一次),全篇 8-12 行、宁短勿长、一分钟内唱得完。★【不做长叙事·只要短时间强冲击】:不交代来龙去脉、不写事件流水账、不铺陈过程——抓住一个最有冲击力的情绪爆点/画面/金句往死里放大;情绪起伏要大(压抑↔爆发、留白↔炸裂的强落差),短时间冲击力优先于叙事完整。开篇第一句就是全曲最戳人的记忆点——当事人此刻最痛或最狂的那句第一人称大白话、一个反差、或一句画面直接砸脸——前 3 秒让竖屏观众停手,绝不铺垫客套;用具体可感的动作代替抽象狠话(「我一粒一粒数着吃」>「粮不多了」),感官颗粒度=留人力。主歌几句内推到情绪爆点;副歌要极短、能无限循环、能被单拎出来当视频标题,重复时每遍必须微变奏(改一个关键词或后半句,禁一字不差复读);结尾收在一句「反转/对比暴击」金句(弱↔强、赢↔输同句翻面那类,天然是爆款文案)。全程第一人称、对着一个具体的人或物说话(如项羽对虞姬、对乌骓;曹操对自己的赌性),情绪落点具体、人物区分度自然回来。词是演绎,但【正史是围栏不是文体】——只锁事实边界:胜负/生死/结局一律不改,器物/称谓/乐制/断代不串朝代;避演义(禁三英战吕布、草船借箭等被正史证伪的桥段,禁虚构夸张兵力,压在正史口径的真数字可大胆用,以弱胜强靠精确数据最抓人);\n★【别太工整·反 AI 作业腔·硬规则逐条锁死】(老板要求):① 长短句强制混排——全篇至少一次 2-4 字极短句(如「别哭」「烧了它」)紧挨一句十几字长句,制造听感落差,竖屏卡点/鼓点全靠这个断层;② 禁句句押韵——允许换韵、半韵、连续两三句不押,严禁一韵到底把每行尾字押死;③ 禁对仗排比与成语堆砌——不写字数结构对称的对偶、不连排三个同构短语、整首成语不超过 1 个,靠具体动作与口语撑情绪、不靠成语;④ 每行至少一处「活口」——口语词(「呢」「了吧」「怎么办」)、感叹、自问自答、破折号停顿、或半句吞掉的破句,三选一起码占一;⑤ 允许拉家常式开口(「说实话」「那天晚上」)先卸观众戒心再暴击,允许原地重复变奏(「别哭。别哭啊。」)、允许语无伦次不体面不端庄;⑥ 用「——」「……」、疑问、破句给情绪留气口,别把每行填满,空拍就是卡点位;⑦ 古意只点到即止、可有可无,宁可现代直白有情绪,也不要文绉绉的书面古语——现代词表达古代事完全允许,只要器物称谓不穿帮;真古董名句只当钩子再当场消解(拆字延音「力拔山兮,气——盖——世」或整句翻面),绝不整句端上来。目标是一个古人此刻的真实心跳,不是一段文言解说;\n② 简谱曲谱——谱出主旋律(至少副歌,能体现记得住的主题动机即可):标调号(如 1=C)/拍号/速度与最简记号约定(高音加\'、低音加,、八分加_),数字 1–7 记音高,简谱下一行对上对应歌词;旋律是新谱演绎、不假装古乐/古谱;\n★【曲也别太工整】(老板要求):旋律节奏有张弛快慢变化、乐句长短不一、不四平八稳方方正正、该起伏时起伏、有出人意料的转折与留白,自然流动不呆板机械。',
};
export function buildWorkPrompt(pack, spec, { seed, role, prev, yanyi, lyricOnly, perspective } = {}) {
const sk = pack.skeleton;
const { claims: yyClaims, eff: yyBlack } = admitYanyi(sk.blacklist, yanyi);
const nodeLines = (sk.nodes || []).map((n) => n.event).join(' → ');
const quoteLines = (sk.quotes || []).slice(0, 6).map((q) => `${q.who}:「${q.words}」`).join(' ');
const rosterLines = (sk.roster || []).slice(0, 8).map((p) => `${p.name}(${p.office || '在场'}${p.fate ? '·' + p.fate : ''})`).join(';');
const sys = [
'你在为用户做「历史二次创作」,产出一篇成品。',
spec.genre ? `【方向:${spec.label}】${spec.genre}\n★类型感优先:这篇首先得读起来像【${spec.label}】——史实是不可改的底子、不是枷锁;在锁死的史实之上,把这个类型该有的味道、情绪、笔法写足写透。` : '',
'铁律:① 这是【文学/艺术演绎】非史料,可想象细节、抒情言志,但胜负/成败/关键人物生死结局必须与所给史实一致,一处不改;',
'② 紧扣真实事件与正史,不掺演义里被证伪的桥段;',
`③ 演义黑名单一字不得出现(未照妖·禁用):${yyBlack.join('、') || '(无)'};`,
yyClaims.length ? `④ 【照妖·准用的演义桥段】以下用户已照过妖、明知是演义、特意要写——【可以写】,但只作演绎、须点明是演义(不冒充正史),且不得借它改变胜负/生死/结局:${yyClaims.join(';')};` : '',
...(sk.cautions?.length ? [`⑤ 这一场别写歪:${sk.cautions.slice(0, 4).join(';')}`] : []),
pack.era ? '⑤ 器物、称谓不出现晚于本朝之物。' : '',
(lyricOnly && spec.outputKind === 'music') ? (WORK_STRUCT.music.split('\n② 简谱曲谱')[0].replace('【歌词+简谱曲谱】', '【一首能唱的短歌歌词】') + '\n※本次只出【歌词正文】:不要简谱、不要曲谱、不要任何谱面/配器/曲式说明。' + (perspective ? `\n★【视角·第一人称·老板要求】整首以【${perspective}】的口吻与心境写——只写 ta 眼里/心里的这一场,立场、情绪、用词完全是 ta 的,与同一场其他人物的歌【内容截然不同】(别人是别人的故事)。` : '')) : (WORK_STRUCT[spec.outputKind] || ''),
'不写「解锁/成就/通关/战力」等游戏化词。只输出作品正文,不要额外解释。',
].filter(Boolean).join('\n');
const user = [
`真实事件:${pack.meta.title}(${pack.meta.era || ''})`,
`史实骨架(按序·锁死):${nodeLines}`,
sk.real ? `结局(锁死):${sk.real.text}` : '',
quoteLines ? `可引用的正史名句:${quoteLines}` : '',
rosterLines ? `在场真人:${rosterLines}` : '',
role ? `我的身份/视角:${role}` : '',
seed ? `我想表达的方向:${seed}` : '',
prev
? `已有前文如下(请【接着往下写】:承接、不重复、推进到落点):\n${prev}`
: `请据此,${spec.hint}。${spec.outputKind === 'text' ? '写成一篇【完整作品】,篇幅充实(约 900–1600 字),不要只开个头就停。' : spec.outputKind === 'music' ? (lyricOnly ? '只输出这一首歌的【歌词正文】:1 分钟内唱得完、8-12 行、宁短勿长;不做长叙事,不要简谱/曲谱/谱面说明。' : '以【做成一首 1 分钟内、极抓人的短歌】为准:短小精悍、唱完即止、不铺长、不做长叙事。') : '写成结构从头到尾完整的一篇,不要只开个头就停。'}`,
].filter(Boolean).join('\n');
return { messages: [{ role: 'system', content: sys }, { role: 'user', content: user }], styleLabel: spec.label };
}
// ══ craft·创作素材备料(不是成品,是给用户/喂AI的一套料)══
export function buildCraftPrompt(pack, dir, { seed, role } = {}) {
const sk = pack.skeleton;
const nodeLines = (sk.nodes || []).map((n) => n.event).join(' → ');
const rosterLines = (sk.roster || []).slice(0, 10).map((p) => `${p.name}(${p.office || '在场'}${p.fate ? '·' + p.fate : ''})`).join(';');
const quoteLines = (sk.quotes || []).slice(0, 6).map((q) => `${q.who}:「${q.words}」`).join(' ');
const sys = [
'你在为「历史二次创作」备【创作素材】——不是成品,是给用户拿去自己创作、或喂给 AI 的一套料。',
'按下面的清单逐项产出,每项用小标题,条理清楚、务实可用(不要写成一篇作品)。',
`方向:${dir.label}。调性:${dir.blurb}。`,
`红线:锁死史实(胜负/生死/行止不改);一切演绎须标明「演绎」;演义黑名单禁用(${(sk.blacklist || []).join('、') || '无'});号称≠实数分清;${pack.era ? '器物断代不越代;' : ''}不写游戏化词。`,
...(sk.cautions?.length ? [`这一场别写歪:${sk.cautions.slice(0, 4).join(';')}`] : []),
].filter(Boolean).join('\n');
const user = [
`要备的创作素材清单:\n${(dir.craftMaterials || []).map((m, i) => `${i + 1}. ${m}`).join('\n')}`,
'——素材包(据此备料)——',
`真实事件:${pack.meta.title}(${pack.meta.era || ''})`,
`史实骨架:${nodeLines}`,
sk.real ? `结局(锁死):${sk.real.text}` : '',
rosterLines ? `在场真人:${rosterLines}` : '',
quoteLines ? `正史名句:${quoteLines}` : '',
role ? `用户身份/视角:${role}` : '',
seed ? `用户方向:${seed}` : '',
].filter(Boolean).join('\n');
return { messages: [{ role: 'system', content: sys }, { role: 'user', content: user }], label: dir.label };
}
const CRAFT_DISCLAIMER = '这是【创作素材】不是成品 —— 供你自己创作或拿去喂 AI/别处工具生成。史实以所附正史为准,演绎处由你把关。';
/**
* 生成二创。方向(direction)驱动:
* intent='craft' → 备【创作素材】(该方向要给的一套料)。
* 否则出【作品】:叙事小说走 segment(句级出处分层);诗词/剧本/美术脚本/视频脚本走 work(整篇)。
* 兼容旧 style(STYLES) 路径。
*/
export async function createErchuang({ anchorType, anchorId, direction, style, seed, role, intent, prev, yanyi, lyricOnly, perspective } = {}) {
const dir = getDirection(direction);
const spec = dir ? specFromDirection(dir) : { key: style || 'shi', ...(STYLES[style] || STYLES.shi) };
const subOf = (pack) => `${pack.meta.era || ''}${pack.meta.side ? ' · ' + pack.meta.side : pack.meta.subtitle ? ' · ' + pack.meta.subtitle : ''}`;
const anchorOf = (pack) => ({
type: pack.meta.anchorType, id: pack.meta.anchorId, title: pack.meta.title, sub: subOf(pack),
facts: [pack.skeleton.hook, pack.skeleton.real?.text].filter(Boolean),
cites: [pack.skeleton.real?.cite, ...(pack.skeleton.nodes || []).slice(0, 3).map((n) => n.cite)].filter(Boolean),
});
// ── 创作素材·备料 ──
if (intent === 'craft') {
if (!dir) return { error: 'no-direction' };
const pack = buildPack(anchorType, anchorId);
if (!pack) return { error: 'no-anchor' };
const { messages } = buildCraftPrompt(pack, dir, { seed, role });
// 备素材是长输出(一整套料),>30s 常见 → 给足 maxTokens 使其走 120s 长超时(见 llm.js big 判定),
// 否则默认 30s×3 重试=90s 干等后 502(备素材事故)。
const { text, model } = await aiWrite(messages, { temperature: 0.7, maxTokens: 3000 });
return {
ok: true, layer: 'craft', direction: dir.label, outputKind: dir.outputKind,
materials: dir.craftMaterials, craft: (text || '').trim(),
anchor: anchorOf(pack), tools: toolsForOutputKind(dir.outputKind), model, disclaimer: CRAFT_DISCLAIMER,
};
}
// ── 作品·segment(句级出处分层)──
if (spec.layer === 'segment') {
const pack = buildPack(anchorType, anchorId);
if (!pack) return { error: 'no-anchor' };
const { messages, styleLabel } = buildSegmentPrompt(pack, spec, { seed, role, prev, yanyi });
const { text, model } = await aiWrite(messages, { temperature: 0.85, maxTokens: 4000 });
const { eff } = admitYanyi(pack.skeleton.blacklist, yanyi); // 照过妖的演义放行,不再硬删
const { segs, warnings } = parseSegments(text, pack.citeTable, { blacklist: eff, era: pack.era });
return {
ok: true, layer: 'segment', style: styleLabel,
anchor: { type: pack.meta.anchorType, id: pack.meta.anchorId, title: pack.meta.title, sub: subOf(pack) },
segs, citeTable: pack.citeTable, warnings, model, disclaimer: DISCLAIMER,
};
}
// ── 作品·work(诗词/剧本/美术脚本/视频脚本)──
const pack = buildPack(anchorType, anchorId);
if (pack && dir) {
const { messages, styleLabel } = buildWorkPrompt(pack, spec, { seed, role, prev, yanyi, lyricOnly, perspective });
const { text, model } = await aiWrite(messages, { temperature: 0.9, maxTokens: 4000 });
return {
ok: true, layer: 'work', style: styleLabel, work: (text || '').trim(), outputKind: spec.outputKind,
anchor: anchorOf(pack), tools: spec.outputKind !== 'text' ? toolsForOutputKind(spec.outputKind) : null,
model, disclaimer: DISCLAIMER,
};
}
// 回退:旧 STYLES 路径(fact-based)
const fact = anchorFact(anchorType, anchorId);
if (!fact) return { error: 'no-anchor' };
const { messages, styleLabel } = buildErchuangPrompt(fact, spec, { seed, role });
const { text, model } = await aiWrite(messages, { temperature: 0.9, maxTokens: 4000 });
return { ok: true, layer: 'work', work: (text || '').trim(), style: styleLabel, anchor: fact, model, disclaimer: DISCLAIMER };
}