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 }; }