404 lines
34 KiB
JavaScript
404 lines
34 KiB
JavaScript
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 };
|
||
}
|