dsh-lifecard 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -25,8 +25,34 @@ import * as memory from './memory.js'
25
25
  import * as jobs from './jobs.js'
26
26
  import * as jobsStore from './jobs-store.js'
27
27
  import * as interview from './jobs-interview.js'
28
- import * as tavern from './tavern.js'
29
- import * as novel from './novel.js'
28
+ // 这四个是「可以整个删掉」的功能模块:酒馆、写小说、预设、文字游戏。
29
+ // 用「用到才加载」而不是文件顶上的静态 import —— 静态 import 一个不存在的
30
+ // 文件会让整个插件起不来,那就不是「能删」而是「删了就全废」。
31
+ // 删掉对应文件之后,只有那个功能报「这个功能没装」,别的照常。
32
+ let tavern = null
33
+ let novel = null
34
+ let presets = null
35
+ let game = null
36
+ let career = null
37
+ /** 哪个功能没装(给用户看的名字)。 */
38
+ const FEATURE_NAMES = { tavern: '酒馆', novel: '写小说', presets: '预设', game: '文字小说游戏', career: '简历与规划' }
39
+ /**
40
+ * 按需把四个模块加载进来。已经加载过的不会重复加载。
41
+ * @returns {Promise<Record<string, boolean>>} 每个模块现在在不在
42
+ */
43
+ async function ensureModules() {
44
+ if (tavern === null) tavern = await import('./tavern.js').catch(() => null)
45
+ if (novel === null) novel = await import('./novel.js').catch(() => null)
46
+ if (presets === null) presets = await import('./presets.js').catch(() => null)
47
+ if (game === null) game = await import('./game.js').catch(() => null)
48
+ if (career === null) career = await import('./career.js').catch(() => null)
49
+ return { tavern: tavern !== null, novel: novel !== null, presets: presets !== null, game: game !== null, career: career !== null }
50
+ }
51
+ /** 这个功能没装的时候,统一回一句人话。 */
52
+ function missingFeature(name) {
53
+ const label = FEATURE_NAMES[name] ?? name
54
+ return { ok: false, error: `「${label}」这个功能没装(对应的文件被删掉了)。别的功能不受影响。`, missingFeature: name }
55
+ }
30
56
  export const name = 'dsh-lifecard'
31
57
 
32
58
  /** 需要宿主提供的服务。缺 webServer 就什么都不做(明确失败胜过静默半死)。 */
@@ -485,6 +511,13 @@ async function collectStream(handle) {
485
511
  const eat = (chunk) => {
486
512
  if (chunk === null || chunk === undefined) return
487
513
  if (typeof chunk === 'string') { text += chunk; return }
514
+ // 模型「想」的那一段走的是另一条事件(实测:{ type: 'reasoning-delta', text })。
515
+ // 它不是回答,也**不能**当成回答拼进来:拼进去之后 JSON 前面多出几千字
516
+ // 「Thinking Process: …」,解析必失败——「整理进卡里」于是等五十秒、
517
+ // 试两次、报「AI 这次没给出能用的结果」,用户看到的是一直转圈。
518
+ // 下面那些「只要是 .text 就收」的兜底分支会让它漏进来,所以必须先在
519
+ // 这里按类型挡掉。
520
+ if (typeof chunk.type === 'string' && /reasoning|thinking|analysis/i.test(chunk.type)) return
488
521
  // 真实的流事件形状(实测):{ type: 'text-delta', index, text }
489
522
  // block-start / block-end / text-start / text-end / finish 都不带正文
490
523
  if (chunk.type === 'text-delta' && typeof chunk.text === 'string') { text += chunk.text; return }
@@ -845,8 +878,13 @@ function buildApi(ctx, workspace, config, options) {
845
878
  const ws = await root(body)
846
879
  const data = body?.data
847
880
  if (data === null || typeof data !== 'object') return { ok: false, error: '没收到卡片内容,重新试一次。' }
881
+ // 判空必须在 safeId **之前**:`core.safeId('')` 返回 `'unnamed'`,
882
+ // 于是下面那句 `if (id === '')` 永远不成立——没起名字的新卡会悄悄
883
+ // 变成一张叫「unnamed」的卡,之后所有空名字的卡还会互相覆盖。
884
+ if (String(data.id ?? data.name ?? '').trim() === '') {
885
+ return { ok: false, error: '这张卡还没有名字,先给它起一个。' }
886
+ }
848
887
  const id = core.safeId(data.id ?? data.name ?? '')
849
- if (id === '') return { ok: false, error: '这张卡还没有名字,先给它起一个。' }
850
888
  data.id = id
851
889
  data.lifecard = core.DATA_VERSION
852
890
  let rel
@@ -1198,7 +1236,9 @@ function buildApi(ctx, workspace, config, options) {
1198
1236
  const name = String(body?.name ?? about ?? 'material')
1199
1237
  const block = body?.meta === undefined ? content : `${content}\n\n\`\`\`\n${body.meta}\n\`\`\``
1200
1238
  const saved = await store.appendMaterial(ws, name, block)
1201
- return { ok: true, ...saved }
1239
+ // 以前这里无条件 `ok:true` 再展开 saved,把 `ok:false` 盖掉了:
1240
+ // 存失败时界面照样说「已保存」。别的路由都是先判 saved.ok 再往下走。
1241
+ if (saved?.ok !== true) return { ok: false, error: saved?.error ?? '这段材料没存进去。', ...saved }
1202
1242
  },
1203
1243
 
1204
1244
  /** 材料列表 + 全文(分析用)。 */
@@ -1879,7 +1919,7 @@ function buildApi(ctx, workspace, config, options) {
1879
1919
  ? all.filter((p) => body.people.includes(keyOf(p)))
1880
1920
  : all
1881
1921
  if (wanted.length === 0) return { ok: false, error: '勾选的人一个都没对上。' }
1882
- const at = new Date().toISOString().slice(0, 10)
1922
+ const at = store.localDate()
1883
1923
  const files = []
1884
1924
  for (const p of wanted) {
1885
1925
  const label = p.name !== '' ? p.name : p.kin
@@ -1923,7 +1963,7 @@ function buildApi(ctx, workspace, config, options) {
1923
1963
  ? all.filter((p) => body.people.includes(p.name !== '' ? `${p.kin}:${p.name}` : p.kin))
1924
1964
  : all
1925
1965
  if (wanted.length === 0) return { ok: false, error: '勾选的人一个都没对上。' }
1926
- const md = memory.renderMemoryMaterial(wanted, { read_at: new Date().toISOString().slice(0, 10) })
1966
+ const md = memory.renderMemoryMaterial(wanted, { read_at: store.localDate() })
1927
1967
  const saved = await store.appendMaterial(ws, `记忆里认出来的人`, md)
1928
1968
  if (saved.ok !== true) return saved
1929
1969
  // 记下「这些人还缺什么」。等他们变成卡之后,卡上那个红色感叹号
@@ -2252,6 +2292,7 @@ function buildApi(ctx, workspace, config, options) {
2252
2292
 
2253
2293
  /** 酒馆:说过的这几场。 */
2254
2294
  'tavern.list': async (body) => {
2295
+ if ((await ensureModules()).tavern !== true) return missingFeature('tavern')
2255
2296
  const ws = await root(body)
2256
2297
  return await tavern.listChats(ws)
2257
2298
  },
@@ -2263,6 +2304,7 @@ function buildApi(ctx, workspace, config, options) {
2263
2304
  * 而不是对着一个空框想「我该说什么」。
2264
2305
  */
2265
2306
  'tavern.start': async (body) => {
2307
+ if ((await ensureModules()).tavern !== true) return missingFeature('tavern')
2266
2308
  const ws = await root(body)
2267
2309
  const want = String(body?.card ?? '').trim()
2268
2310
  if (want === '') return { ok: false, error: '得先挑一个人,才能开始。' }
@@ -2304,6 +2346,7 @@ function buildApi(ctx, workspace, config, options) {
2304
2346
 
2305
2347
  /** 酒馆:读这一场。 */
2306
2348
  'tavern.get': async (body) => {
2349
+ if ((await ensureModules()).tavern !== true) return missingFeature('tavern')
2307
2350
  const ws = await root(body)
2308
2351
  const id = String(body?.id ?? '').trim()
2309
2352
  if (id === '') return { ok: false, error: '这一场找不到了' }
@@ -2331,6 +2374,8 @@ function buildApi(ctx, workspace, config, options) {
2331
2374
  * 断网一次就全没了。
2332
2375
  */
2333
2376
  'tavern.say': async (body) => {
2377
+ if ((await ensureModules()).tavern !== true) return missingFeature('tavern')
2378
+ if ((await ensureModules()).presets !== true) return missingFeature('presets')
2334
2379
  const id = String(body?.id ?? '').trim()
2335
2380
  if (id === '') return { ok: false, error: '这一场找不到了' }
2336
2381
  // 同一场会话一次只处理一句:读-改-写之间夹着一次模型调用,
@@ -2374,7 +2419,11 @@ function buildApi(ctx, workspace, config, options) {
2374
2419
  const memoryNote = (Array.isArray(got.notes) ? got.notes : [])
2375
2420
  .find((n) => String(n?.title ?? '').trim() === tavern.MEMORY_TITLE)
2376
2421
  const memory = memoryNote === undefined ? '' : String(memoryNote.body ?? '')
2377
- const system = tavern.buildRoleplaySystem({ cardName, persona: persona.text, worldNotes, memory })
2422
+ // 演法预设:选了就把它那套要求拼在最后。找不到预设(用户删了)就不拼,
2423
+ // 不能因为这一条让整场对话报错。
2424
+ const roleplayPreset = await presets.resolve(ws, body?.preset)
2425
+ const presetNote = roleplayPreset.ok === true && roleplayPreset.preset !== null ? String(roleplayPreset.preset.prompt ?? '') : ''
2426
+ const system = tavern.buildRoleplaySystem({ cardName, persona: persona.text, worldNotes, memory, preset: presetNote })
2378
2427
  const user = tavern.buildRoleplayUser({ cardName, turns })
2379
2428
  const called = await callModel(ctx, { system, user, model: body?.model })
2380
2429
  const now = tavernStamp()
@@ -2407,9 +2456,13 @@ function buildApi(ctx, workspace, config, options) {
2407
2456
  * 让它别把之前发生过的事记错。
2408
2457
  */
2409
2458
  'tavern.summarize': async (body) => {
2459
+ if ((await ensureModules()).tavern !== true) return missingFeature('tavern')
2410
2460
  const ws = await root(body)
2411
2461
  const id = String(body?.id ?? '').trim()
2412
2462
  if (id === '') return { ok: false, error: '这一场找不到了' }
2463
+ // 收尾也是一次「读 → 调模型(十几二十秒)→ 整文件写回」,跟 tavern.say 同一个形状。
2464
+ // 不收进来排队的话,这段时间里说的任何一句都会被回写成收尾前那一版 —— 而且不留痕迹。
2465
+ return oneAtATime(id, async () => {
2413
2466
  const got = await tavern.readChat(ws, id)
2414
2467
  if (got.ok !== true) return got
2415
2468
  const turns = Array.isArray(got.turns) ? got.turns.slice() : []
@@ -2429,10 +2482,112 @@ function buildApi(ctx, workspace, config, options) {
2429
2482
  const saved = await tavern.writeChat(ws, id, { ...got.data, notes, turns: turns.length, updated_at: tavernStamp() }, turns)
2430
2483
  if (saved.ok !== true) return { ok: false, error: `收好了,但没存下来:${saved.error}`, summary, turns }
2431
2484
  return { ok: true, id, summary, notes, turns }
2485
+ })
2486
+ },
2487
+
2488
+ /**
2489
+ * 把这一场里最能代表 TA 的几句,写进那张卡的「对话示例」。
2490
+ *
2491
+ * 「对话示例」是下次演 TA 时最管用的材料:比「性格:沉默寡言」具体得多,
2492
+ * 因为它就是那个人真的说过的话。演着演着发现 TA 说话的样子比卡上写的准,
2493
+ * 这里就能把它固化下来。
2494
+ */
2495
+ 'tavern.toExample': async (body) => {
2496
+ if ((await ensureModules()).tavern !== true) return missingFeature('tavern')
2497
+ const ws = await root(body)
2498
+ const id = String(body?.id ?? '').trim()
2499
+ if (id === '') return { ok: false, error: '这一场找不到了' }
2500
+ return oneAtATime(id, async () => {
2501
+ const got = await tavern.readChat(ws, id)
2502
+ if (got.ok !== true) return got
2503
+ const turns = Array.isArray(got.turns) ? got.turns : []
2504
+ if (turns.filter((t) => String(t?.who ?? '').trim() !== '我').length === 0) {
2505
+ return { ok: false, error: 'TA 还没说过话,挑不出例句。' }
2506
+ }
2507
+ const cardName = String(got.data?.card_name ?? '').trim()
2508
+ const prompt = tavern.buildExamplePrompt({ cardName, turns })
2509
+ const called = await callModel(ctx, { system: prompt.system, user: prompt.user, model: body?.model })
2510
+ if (called.ok !== true) return { ok: false, error: `没挑出来:${called.error ?? '过一会儿再试一次'}` }
2511
+ const picked = tavern.cleanExamples(called.text)
2512
+ if (picked.length === 0) return { ok: false, error: '这一次没挑出能用的句子,过一会儿再试一次。' }
2513
+
2514
+ const want = String(got.data?.card ?? '').trim()
2515
+ if (want === '') return { ok: false, error: '这一场不知道是跟哪张卡演的,存不回去。' }
2516
+ const card = await store.findCardByRelOrId(ws, want)
2517
+ if (card === null) return { ok: false, error: '那张卡找不到了,它可能被删掉了。' }
2518
+ const read = await store.readCard(ws, card)
2519
+ if (read.ok !== true) return read
2520
+ const sections = Array.isArray(read.sections) ? read.sections.map((x) => ({ ...x })) : []
2521
+ const title = '对话示例'
2522
+ const at = sections.findIndex((x) => String(x.title ?? '').trim() === title)
2523
+ const block = picked.map((line) => `- ${line}`).join('\n')
2524
+ if (at === -1) sections.push({ title, body: block })
2525
+ else {
2526
+ const oldBody = String(sections[at].body ?? '').trim()
2527
+ const merged = oldBody === '' || oldBody === '_(还没写)_' ? block : `${oldBody}\n${block}`
2528
+ // 例句越攒越多会把这个小节撑爆,之后每演一句都把这些全塞进提示词。
2529
+ // 只留最后 24 行——它们是最靠近「现在这个人」的样子。
2530
+ const lines = merged.split(/\r?\n/).filter((x) => x.trim() !== '')
2531
+ sections[at] = { ...sections[at], body: lines.slice(-24).join('\n') }
2532
+ }
2533
+ const written = await store.writeCard(ws, card, read.data, sections)
2534
+ if (written.ok !== true) return written
2535
+ return { ok: true, path: written.path, picked }
2536
+ })
2432
2537
  },
2433
2538
 
2434
2539
  /** 酒馆:这一场不要了。 */
2435
- 'tavern.delete': async (body) => {
2540
+ /**
2541
+ * 把酒馆里聊出来的一场,变成某本小说的一章。
2542
+ *
2543
+ * 这是「三扇门其实是一件事」的那一步:先在酒馆里把人立起来、把话演一遍,
2544
+ * 演得满意的那一场直接变成小说的正文,而不是再从头写一遍。
2545
+ */
2546
+ 'tavern.toChapter': async (body) => {
2547
+ const ws = await root(body)
2548
+ const mods = await ensureModules()
2549
+ if (mods.tavern !== true) return missingFeature('tavern')
2550
+ if (mods.novel !== true) return missingFeature('novel')
2551
+
2552
+ const id = String(body?.id ?? '').trim()
2553
+ if (id === '') return { ok: false, error: '得先挑一场聊过的。' }
2554
+ const book = String(body?.book ?? '').trim()
2555
+ if (book === '') return { ok: false, error: '得先挑一本书。没有书的话,先回「写小说」那扇门建一本。' }
2556
+
2557
+ const chat = await tavern.readChat(ws, id)
2558
+ if (chat.ok !== true) return chat
2559
+ const turns = chat.turns ?? []
2560
+ if (turns.length === 0) return { ok: false, error: '这一场还什么都没说。先在酒馆里聊几句,再变。' }
2561
+
2562
+ const target = await novel.readBook(ws, book)
2563
+ if (target.ok !== true) return target
2564
+ if (target.book === null) return { ok: false, error: '找不到这本书。它可能被删了。' }
2565
+
2566
+ const built = novel.buildChatToChapterPrompt({
2567
+ cardName: chat.data?.card_name ?? '',
2568
+ bookTitle: target.book?.title ?? '',
2569
+ turns,
2570
+ note: String(body?.note ?? ''),
2571
+ })
2572
+ const called = await callModel(ctx, { system: built.system, user: built.user, model: body?.model })
2573
+ if (called.ok !== true) return { ok: false, error: `这次没改出来:${called.error ?? '过一会儿再试一次'}` }
2574
+
2575
+ const text = String(called.text ?? '').trim()
2576
+ if (text === '') return { ok: false, error: '它这次什么都没写出来,再试一次。' }
2577
+
2578
+ // 章号接着最后一章往后排。用「最后一章的号 + 1」而不是「章数 + 1」:
2579
+ // 中间删过章的话(第 2 章被删),章数是 2、最后一章是第 3 章,
2580
+ // 用章数算出来的 3 会覆盖掉现成的第 3 章。
2581
+ const list = Array.isArray(target.chapters) ? target.chapters : []
2582
+ const last = list.length === 0 ? 0 : Number(list[list.length - 1]?.index ?? 0)
2583
+ const next = (Number.isFinite(last) ? last : 0) + 1
2584
+ const written = await novel.writeChapter(ws, book, next, text)
2585
+ if (written.ok !== true) return written
2586
+ return { ok: true, book, chapter: written.index ?? next, text, turns: turns.length }
2587
+ },
2588
+
2589
+ 'tavern.delete': async (body) => {
2590
+ if ((await ensureModules()).tavern !== true) return missingFeature('tavern')
2436
2591
  const ws = await root(body)
2437
2592
  const id = String(body?.id ?? '').trim()
2438
2593
  if (id === '') return { ok: false, error: '这一场找不到了' }
@@ -2443,12 +2598,14 @@ function buildApi(ctx, workspace, config, options) {
2443
2598
 
2444
2599
  /** 书架:所有书。 */
2445
2600
  'novel.books': async (body) => {
2601
+ if ((await ensureModules()).novel !== true) return missingFeature('novel')
2446
2602
  const ws = await root(body)
2447
2603
  return await novel.listBooks(ws)
2448
2604
  },
2449
2605
 
2450
2606
  /** 新开一本。 */
2451
2607
  'novel.create': async (body) => {
2608
+ if ((await ensureModules()).novel !== true) return missingFeature('novel')
2452
2609
  const ws = await root(body)
2453
2610
  return await novel.createBook(ws, {
2454
2611
  title: body?.title,
@@ -2459,6 +2616,7 @@ function buildApi(ctx, workspace, config, options) {
2459
2616
 
2460
2617
  /** 打开一本书(含章节清单)。 */
2461
2618
  'novel.get': async (body) => {
2619
+ if ((await ensureModules()).novel !== true) return missingFeature('novel')
2462
2620
  const ws = await root(body)
2463
2621
  const id = String(body?.id ?? '').trim()
2464
2622
  if (id === '') return { ok: false, error: '没有说是哪本书' }
@@ -2467,6 +2625,7 @@ function buildApi(ctx, workspace, config, options) {
2467
2625
 
2468
2626
  /** 改书的元信息(书名 / 题材 / 一句话)。 */
2469
2627
  'novel.update': async (body) => {
2628
+ if ((await ensureModules()).novel !== true) return missingFeature('novel')
2470
2629
  const ws = await root(body)
2471
2630
  const id = String(body?.id ?? '').trim()
2472
2631
  if (id === '') return { ok: false, error: '没有说是哪本书' }
@@ -2479,6 +2638,7 @@ function buildApi(ctx, workspace, config, options) {
2479
2638
 
2480
2639
  /** 这一章不要了。 */
2481
2640
  'novel.chapter.delete': async (body) => {
2641
+ if ((await ensureModules()).novel !== true) return missingFeature('novel')
2482
2642
  const ws = await root(body)
2483
2643
  const id = String(body?.id ?? '').trim()
2484
2644
  if (id === '') return { ok: false, error: '没有说是哪本书' }
@@ -2487,6 +2647,7 @@ function buildApi(ctx, workspace, config, options) {
2487
2647
 
2488
2648
  /** 整本书不要了。 */
2489
2649
  'novel.delete': async (body) => {
2650
+ if ((await ensureModules()).novel !== true) return missingFeature('novel')
2490
2651
  const ws = await root(body)
2491
2652
  const id = String(body?.id ?? '').trim()
2492
2653
  if (id === '') return { ok: false, error: '没有说是哪本书' }
@@ -2497,6 +2658,7 @@ function buildApi(ctx, workspace, config, options) {
2497
2658
 
2498
2659
  /** 读大纲。 */
2499
2660
  'novel.outline.get': async (body) => {
2661
+ if ((await ensureModules()).novel !== true) return missingFeature('novel')
2500
2662
  const ws = await root(body)
2501
2663
  const id = String(body?.id ?? '').trim()
2502
2664
  if (id === '') return { ok: false, error: '没有说是哪本书' }
@@ -2505,6 +2667,7 @@ function buildApi(ctx, workspace, config, options) {
2505
2667
 
2506
2668
  /** 自己写 / 改大纲(人也在这里改)。 */
2507
2669
  'novel.outline.save': async (body) => {
2670
+ if ((await ensureModules()).novel !== true) return missingFeature('novel')
2508
2671
  const ws = await root(body)
2509
2672
  const id = String(body?.id ?? '').trim()
2510
2673
  if (id === '') return { ok: false, error: '没有说是哪本书' }
@@ -2518,6 +2681,8 @@ function buildApi(ctx, workspace, config, options) {
2518
2681
  * 改完再一章一章让它写。不落盘的话刷新一下就没了,白等一次模型。
2519
2682
  */
2520
2683
  'novel.outline.draft': async (body) => {
2684
+ if ((await ensureModules()).novel !== true) return missingFeature('novel')
2685
+ if ((await ensureModules()).presets !== true) return missingFeature('presets')
2521
2686
  const ws = await root(body)
2522
2687
  const id = String(body?.id ?? '').trim()
2523
2688
  if (id === '') return { ok: false, error: '没有说是哪本书' }
@@ -2544,8 +2709,13 @@ function buildApi(ctx, workspace, config, options) {
2544
2709
  chapters: Number(body?.chapters) || 12,
2545
2710
  ask: body?.ask,
2546
2711
  })
2712
+ const outlinePreset = await presets.resolve(ws, body?.preset)
2713
+ const outlineNote = outlinePreset.ok === true && outlinePreset.preset !== null ? String(outlinePreset.preset.prompt ?? '') : ''
2547
2714
  const called = await callModel(ctx, {
2548
- system: '你是一个会写长篇小说的人。你先想清楚整本书怎么走,再动手写。',
2715
+ system: [
2716
+ '你是一个会写长篇小说的人。你先想清楚整本书怎么走,再动手写。',
2717
+ outlineNote === '' ? '' : `\n【这本书怎么写】\n${outlineNote}`,
2718
+ ].filter((x) => x !== '').join('\n'),
2549
2719
  user: users,
2550
2720
  model: body?.model,
2551
2721
  })
@@ -2554,7 +2724,55 @@ function buildApi(ctx, workspace, config, options) {
2554
2724
  if (text === '') return { ok: false, error: 'AI 这次没给出东西,再点一次试试。' }
2555
2725
  const saved = await novel.writeOutline(ws, id, text)
2556
2726
  if (saved.ok !== true) return saved
2557
- return { ok: true, text, ...novel.parseOutline(text), path: saved.path }
2727
+ // 以前这里把 parseOutline 的结果展开在 ok:true 后面,而它自带一个
2728
+ // ok(=「扒到几章」是否大于 0)。AI 写得散、一行「第 N 章:」都没有时,
2729
+ // 文件其实已经写进去了,却返回 ok:false,界面报「这次没想出一个字」。
2730
+ const parsed = novel.parseOutline(text)
2731
+ return {
2732
+ ok: true,
2733
+ text,
2734
+ path: saved.path,
2735
+ theme: parsed.theme,
2736
+ ending: parsed.ending,
2737
+ chapters: parsed.chapters,
2738
+ ...(parsed.chapters.length > 0 ? {} : { warning: '这一版大纲里没认出「第 N 章:」这样的行,文件已经存下了,你可以自己补上章号。' }),
2739
+ }
2740
+ },
2741
+
2742
+ /**
2743
+ * 整本导出成一整篇文字。
2744
+ *
2745
+ * 用户电脑上只有这个插件,不会去翻 books/ 底下那一堆 001.md。
2746
+ * 写完一本书要能一口气复制走,而不是一章一章点开来复制。
2747
+ */
2748
+ 'novel.export': async (body) => {
2749
+ if ((await ensureModules()).novel !== true) return missingFeature('novel')
2750
+ const ws = await root(body)
2751
+ const id = String(body?.id ?? '').trim()
2752
+ if (id === '') return { ok: false, error: '没有说是哪本书' }
2753
+ const got = await novel.readBook(ws, id)
2754
+ if (got.ok !== true) return got
2755
+
2756
+ const lines = []
2757
+ const title = String(got.data?.title ?? id).trim()
2758
+ lines.push(`# ${title === '' ? '未命名' : title}`)
2759
+ lines.push('')
2760
+ if (String(got.idea ?? '').trim() !== '') {
2761
+ lines.push(`> ${String(got.idea).trim()}`)
2762
+ lines.push('')
2763
+ }
2764
+ let total = 0
2765
+ for (const chapter of got.chapters) {
2766
+ const one = await novel.readChapter(ws, id, chapter.index)
2767
+ if (one.ok !== true) continue
2768
+ lines.push(`## 第 ${chapter.index} 章`)
2769
+ lines.push('')
2770
+ lines.push(String(one.body ?? '').trim())
2771
+ lines.push('')
2772
+ total += 1
2773
+ }
2774
+ if (total === 0) return { ok: false, error: '这本书还一章都没有。先写一章,再导出。' }
2775
+ return { ok: true, title, chapters: total, text: lines.join('\n').trim() + '\n' }
2558
2776
  },
2559
2777
 
2560
2778
  /**
@@ -2564,51 +2782,336 @@ function buildApi(ctx, workspace, config, options) {
2564
2782
  * 不要它夸——「写得很好」这种话对写东西的人毫无用处。
2565
2783
  */
2566
2784
  'novel.chapter.review': async (body) => {
2785
+ if ((await ensureModules()).novel !== true) return missingFeature('novel')
2567
2786
  const ws = await root(body)
2568
2787
  const id = String(body?.id ?? '').trim()
2569
2788
  if (id === '') return { ok: false, error: '没有说是哪本书' }
2570
- const book = await novel.readBook(ws, id)
2571
- if (book.ok !== true) return book
2572
2789
  const index = Number(body?.index) || 0
2573
2790
  const got = await novel.readChapter(ws, id, index)
2574
2791
  if (got.ok !== true) return got
2575
2792
  const bodyText = String(got.body ?? '').trim()
2576
2793
  if (bodyText === '') return { ok: false, error: '这一章还是空的,没什么可审的。' }
2794
+ if (bodyText.length > 20000) {
2795
+ return { ok: false, error: '这一章太长(超过两万字),一次读不完。先分成两章再挑毛病。' }
2796
+ }
2797
+
2798
+ // 盲审:只给正文,不给书名、不给大纲、不给「这一章本来想写什么」。
2799
+ //
2800
+ // 为什么不给:写的人心里有一套意图,看的时候会不自觉地按意图去读,
2801
+ // 于是「我本来想写这个」会替「我实际写出来的是什么」作证。
2802
+ // 换成一个什么都不知道的读者,才是这本书真正的处境。
2577
2803
  const user = [
2578
- `下面是一本小说《${String(book.data?.title ?? id)}》的第 ${index} 章。`,
2579
- String(book.idea ?? '').trim() === '' ? '' : `这本书想讲的是:${String(book.idea).trim()}`,
2804
+ '下面是一段小说正文。你之前没看过这本书,也不知道作者想写什么。',
2805
+ '你就是一个第一次读到这里的读者。',
2580
2806
  '',
2581
2807
  '【正文】',
2582
- bodyText.slice(0, 8000),
2808
+ bodyText,
2583
2809
  '',
2584
- '【怎么审】',
2585
- '只指出具体的问题,每条都要说清在哪一句、为什么是问题。按这个顺序:',
2586
- '1. 哪里读不懂 / 情节跳了(读者会卡住的地方)',
2587
- '2. 哪里人物不像(说话方式、做的事和前面的人设对不上)',
2588
- '3. 哪里在替读者总结(「这说明他很孤独」这类,应该把事写出来)',
2589
- '4. 哪里是空话、套话、可以整段删掉',
2810
+ '【读完告诉我这些】',
2811
+ '1. 你读到哪儿卡住了(那句话说不上来、那段跳了、回头看了两遍才懂)',
2812
+ '2. 你读到哪儿不信(这个人不会这么做、这件事不会这么发生)',
2813
+ '3. 你读到哪儿想跳过(哪几段你在心里划过去了)',
2814
+ '4. 哪句话是空话,删掉不影响(直接引原句)',
2590
2815
  '',
2591
- '【别做的事】',
2592
- '- 不要夸。一句夸奖都不要。',
2593
- '- 不要整段重写、不要给「参考版本」。我要的是问题清单,不是替我写。',
2594
- '- 不要评论「主题」「深度」这种大词,只说字面上的问题。',
2595
- '',
2596
- '每条一行,开头写「问题:」,然后写「怎么改:」。最多 8 条,按严重程度排。',
2597
- '如果这一章确实没什么大问题,就只说一句「没看出硬伤,只有下面这几处小地方」。',
2598
- ].filter((x) => x !== '').join('\n')
2816
+ '【怎么答】',
2817
+ '- 每条一行,先写「哪里:」再写「为什么:」。要引原句。',
2818
+ '- 最多 8 条,按你读的时候的不舒服程度排。',
2819
+ '- 不夸。一句夸奖都不要。',
2820
+ '- 不给改写版本——你只是读者,不是代笔。',
2821
+ '- 没看出问题就直接说「没卡住的地方」,不要凑条数。',
2822
+ ].join('\n')
2599
2823
  const called = await callModel(ctx, {
2600
- system: '你是一个挑毛病的编辑。你的任务不是说好话,是把问题指出来。',
2824
+ // 换了一张纸:这段系统提示只要求它当读者,不要求它当编辑,
2825
+ // 免得它从「怎么写更好」的角度出发,跑回写作者的位置上。
2826
+ system: [
2827
+ '你是一个普通读者,刚读完一段小说。',
2828
+ '你没有写过这段,也不知道作者是谁、想写什么。',
2829
+ '你只会说读的时候哪里不顺、哪里不信、哪里想跳过。',
2830
+ '你不会改稿,也不会替作者想句子。',
2831
+ body?.model === undefined || String(body.model).trim() === '' ? '' : '',
2832
+ ].filter((x) => x !== '').join('\n'),
2601
2833
  user,
2602
2834
  model: body?.model,
2603
2835
  })
2604
2836
  if (called.ok !== true) return { ok: false, error: `这次没审成:${called.error ?? '过一会儿再试一次'}` }
2605
2837
  const review = String(called.text ?? '').trim()
2606
2838
  if (review === '') return { ok: false, error: 'AI 这次没给出意见,再点一次试试。' }
2607
- return { ok: true, index, review }
2839
+ return { ok: true, index, review, chars: bodyText.length, blind: true, model: String(body?.model ?? '') }
2608
2840
  },
2609
2841
 
2610
2842
  /** 读一章。 */
2843
+ // ── 简历与规划:写简历、看差距、规划怎么走 ─────────────────
2844
+
2845
+ /** 读简历和规划(还没写过也正常,返回 null)。 */
2846
+ 'career.get': async (body) => {
2847
+ const ws = await root(body); const mods = await ensureModules(); if (mods.career !== true) return missingFeature('career')
2848
+ const profile = await career.readProfile(ws)
2849
+ if (profile.ok !== true) return profile
2850
+ const plan = await career.readPlan(ws)
2851
+ if (plan.ok !== true) return plan
2852
+ return {
2853
+ ok: true,
2854
+ profile: profile.data,
2855
+ profileSections: profile.sections,
2856
+ plan: plan.data,
2857
+ planSections: plan.sections,
2858
+ }
2859
+ },
2860
+
2861
+ /** 存简历。 */
2862
+ 'career.profile.save': async (body) => {
2863
+ const ws = await root(body); const mods = await ensureModules(); if (mods.career !== true) return missingFeature('career')
2864
+ const saved = await career.writeProfile(ws, body?.profile ?? {})
2865
+ if (saved.ok !== true) return saved
2866
+ return { ok: true, path: career.PROFILE_REL }
2867
+ },
2868
+
2869
+ /** 存规划。 */
2870
+ 'career.plan.save': async (body) => {
2871
+ const ws = await root(body); const mods = await ensureModules(); if (mods.career !== true) return missingFeature('career')
2872
+ const saved = await career.writePlan(ws, body?.plan ?? {})
2873
+ if (saved.ok !== true) return saved
2874
+ return { ok: true, path: career.PURPOSE_REL }
2875
+ },
2876
+
2877
+ /** 让 AI 照简历写一份投这个岗位/学校的简历。 */
2878
+ 'career.resume': async (body) => {
2879
+ const ws = await root(body); const mods = await ensureModules(); if (mods.career !== true) return missingFeature('career')
2880
+ const profile = await career.readProfile(ws)
2881
+ if (profile.ok !== true) return profile
2882
+ if (profile.data === null) return { ok: false, error: '还没有简历。先填一下基本情况、学过什么、做过什么。' }
2883
+ const target = String(body?.target ?? '').trim()
2884
+ if (target === '') return { ok: false, error: '先写清楚要投哪儿——公司加岗位,或者学校加专业。' }
2885
+ const built = career.buildResumePrompt({ profile: profile.sections, target })
2886
+ const called = await callModel(ctx, { system: built.system, user: built.user, model: body?.model })
2887
+ if (called.ok !== true) return { ok: false, error: `这次没写出来:${called.error ?? '过一会儿再试一次'}` }
2888
+ return { ok: true, target, text: String(called.text ?? '').trim() }
2889
+ },
2890
+
2891
+ /** 看差距:目标要求的 vs 你现在有的。 */
2892
+ 'career.gap': async (body) => {
2893
+ const ws = await root(body); const mods = await ensureModules(); if (mods.career !== true) return missingFeature('career')
2894
+ const profile = await career.readProfile(ws)
2895
+ if (profile.ok !== true) return profile
2896
+ if (profile.data === null) return { ok: false, error: '还没有简历。先填一下基本情况、学过什么、做过什么。' }
2897
+ const target = String(body?.target ?? '').trim()
2898
+ if (target === '') return { ok: false, error: '先写清楚要够到哪儿——公司加岗位,或者学校加专业。' }
2899
+ const built = career.buildGapPrompt({ profile: profile.sections, target })
2900
+ const called = await callModel(ctx, { system: built.system, user: built.user, model: body?.model })
2901
+ if (called.ok !== true) return { ok: false, error: `这次没算出来:${called.error ?? '过一会儿再试一次'}` }
2902
+ return { ok: true, target, text: String(called.text ?? '').trim() }
2903
+ },
2904
+
2905
+ /** 规划怎么走。 */
2906
+ 'career.path': async (body) => {
2907
+ const ws = await root(body); const mods = await ensureModules(); if (mods.career !== true) return missingFeature('career')
2908
+ const profile = await career.readProfile(ws)
2909
+ if (profile.ok !== true) return profile
2910
+ if (profile.data === null) return { ok: false, error: '还没有简历。先填一下基本情况、学过什么、做过什么。' }
2911
+ const goal = String(body?.goal ?? '').trim()
2912
+ if (goal === '') return { ok: false, error: '先写一句「想到哪儿去」——具体的岗位、学校或状态。' }
2913
+ const horizon = String(body?.horizon ?? '1年').trim()
2914
+ const built = career.buildPathPrompt({ profile: profile.sections, goal, horizon })
2915
+ const called = await callModel(ctx, { system: built.system, user: built.user, model: body?.model })
2916
+ if (called.ok !== true) return { ok: false, error: `这次没规划出来:${called.error ?? '过一会儿再试一次'}` }
2917
+ const text = String(called.text ?? '').trim()
2918
+ return { ok: true, goal, horizon, text, steps: career.parseSteps(text) }
2919
+ },
2920
+
2921
+ // ── 文字小说游戏:进到情节里,你来选,它来推 ────────────────
2922
+
2923
+ /** 一本书能开哪几局。 */
2924
+ 'game.list': async (body) => {
2925
+ const ws = await root(body); const mods = await ensureModules(); if (mods.game !== true) return missingFeature('game')
2926
+ const id = String(body?.book ?? '').trim()
2927
+ if (id === '') return { ok: false, error: '没有说是哪本书' }
2928
+ return await game.listPlays(ws, id)
2929
+ },
2930
+
2931
+ /** 读一局(回到上次那个地方)。 */
2932
+ 'game.get': async (body) => {
2933
+ const ws = await root(body); const mods = await ensureModules(); if (mods.game !== true) return missingFeature('game')
2934
+ const book = String(body?.book ?? '').trim()
2935
+ const id = String(body?.id ?? '').trim()
2936
+ if (book === '' || id === '') return { ok: false, error: '这一局找不到了' }
2937
+ const got = await game.readPlay(ws, book, id)
2938
+ if (got.ok !== true) return got
2939
+ const last = [...got.turns].reverse().find((t) => t.kind === 'narrate')
2940
+ return {
2941
+ ok: true, id, book, path: got.path,
2942
+ chapter: Number(got.data?.chapter) || 0,
2943
+ preset: String(got.data?.preset ?? ''),
2944
+ scene: got.scene,
2945
+ holding: got.holding,
2946
+ turns: got.turns,
2947
+ choices: game.parseChoices(String(last?.text ?? '')).choices,
2948
+ }
2949
+ },
2950
+
2951
+ /** 开一局:从某一章接着演。 */
2952
+ 'game.start': async (body) => {
2953
+ const ws = await root(body); const mods = await ensureModules(); if (mods.game !== true) return missingFeature('game')
2954
+ if (mods.novel !== true) return missingFeature('novel')
2955
+ const bookId = String(body?.book ?? '').trim()
2956
+ if (bookId === '') return { ok: false, error: '没有说是哪本书' }
2957
+ const book = await novel.readBook(ws, bookId)
2958
+ if (book.ok !== true) return book
2959
+ const chapter = Number(body?.chapter) || (book.chapters.length === 0 ? 0 : book.chapters[0].index)
2960
+ // 开局那一段:优先用这一章的正文;没有就让 AI 自己起头。
2961
+ let fromChapter = ''
2962
+ if (chapter > 0) {
2963
+ const got = await novel.readChapter(ws, bookId, chapter)
2964
+ if (got.ok === true) fromChapter = novel.tailOf(got.body, 1600)
2965
+ }
2966
+ const picked = mods.presets === true ? await presets.resolve(ws, body?.preset) : { ok: true, preset: null }
2967
+ const presetNote = picked.ok === true && picked.preset !== null ? String(picked.preset.prompt ?? '') : ''
2968
+ const system = game.buildGmSystem({ bookTitle: String(book.data?.title ?? bookId), scene: '', holding: [], preset: presetNote })
2969
+ const user = game.buildGmUser({ turns: [], move: '', fromChapter })
2970
+ const called = await callModel(ctx, { system, user, model: body?.model })
2971
+ if (called.ok !== true) return { ok: false, error: `这一局开不起来:${called.error ?? '过一会儿再试一次'}` }
2972
+ const parsed = game.parseChoices(called.text)
2973
+ const id = game.newPlayId()
2974
+ const now = new Date()
2975
+ const turns = [{ kind: 'narrate', text: parsed.body }]
2976
+ const data = {
2977
+ id, book: bookId, chapter, preset: String(body?.preset ?? ''),
2978
+ created_at: now.toISOString(), updated_at: now.toISOString(), turn: 0, scene: '', holding: [],
2979
+ }
2980
+ const saved = await game.writePlay(ws, bookId, id, data, turns)
2981
+ if (saved.ok !== true) return saved
2982
+ return { ok: true, id, book: bookId, chapter, path: saved.path, text: parsed.body, choices: parsed.choices, turns }
2983
+ },
2984
+
2985
+ /** 走一步。 */
2986
+ 'game.say': async (body) => {
2987
+ const ws = await root(body); const mods = await ensureModules(); if (mods.game !== true) return missingFeature('game')
2988
+ const bookId = String(body?.book ?? '').trim()
2989
+ const id = String(body?.id ?? '').trim()
2990
+ if (bookId === '' || id === '') return { ok: false, error: '这一局找不到了' }
2991
+ // 同一局的两步必须排队:这里也是「读 → 调模型(几十秒)→ 整文件写回」。
2992
+ // 键要带 bookId —— 两本书里各自有一局 `play-1` 是很正常的。
2993
+ return oneAtATime(`game:${bookId}/${id}`, async () => {
2994
+ const got = await game.readPlay(ws, bookId, id)
2995
+ if (got.ok !== true) return got
2996
+ const turns = Array.isArray(got.turns) ? got.turns : []
2997
+ const moves = turns.filter((t) => t.kind === 'choose' || t.kind === 'say').length
2998
+ if (moves >= game.MAX_TURNS) {
2999
+ return { ok: false, error: `这一局已经走了 ${moves} 步,太长了记不住前面。在这里收个尾,或者另开一局。`, turns }
3000
+ }
3001
+ const move = String(body?.move ?? '').trim()
3002
+ if (move === '') return { ok: false, error: '先选一个方向,或者自己写一句' }
3003
+ const book = mods.novel === true ? await novel.readBook(ws, bookId) : { ok: true, data: { title: bookId } }
3004
+ if (book.ok !== true) return book
3005
+ const picked = mods.presets === true ? await presets.resolve(ws, String(got.data?.preset ?? '')) : { ok: true, preset: null }
3006
+ const presetNote = picked.ok === true && picked.preset !== null ? String(picked.preset.prompt ?? '') : ''
3007
+ const system = game.buildGmSystem({ bookTitle: String(book.data?.title ?? bookId), scene: got.scene, holding: got.holding, preset: presetNote })
3008
+ const user = game.buildGmUser({ turns, move })
3009
+ const called = await callModel(ctx, { system, user, model: body?.model })
3010
+ // 模型没接上也要把你这一步记下来:不然你选过什么,下一轮它完全不知道。
3011
+ const parsed = game.parseChoices(called.ok === true ? called.text : '')
3012
+ const nextTurns = [...turns, { kind: 'choose', text: move, result: called.ok === true ? parsed.body : '' }]
3013
+ const now = new Date()
3014
+ const data = { ...got.data, updated_at: now.toISOString(), turn: moves + 1 }
3015
+ const saved = await game.writePlay(ws, bookId, id, data, nextTurns)
3016
+ if (saved.ok !== true) return saved
3017
+ if (called.ok !== true) {
3018
+ return { ok: false, error: `这一步记下了,但它没接上话:${called.error ?? '过一会儿再试一次'}`, turns: nextTurns, kept: true }
3019
+ }
3020
+ return { ok: true, id, text: parsed.body, choices: parsed.choices, turns: nextTurns }
3021
+ })
3022
+ },
3023
+
3024
+ /**
3025
+ * 把这一局演出来的东西,写成这本书的一章。
3026
+ *
3027
+ * 和酒馆那条是一个意思:演着演着发现这一段真好,就把它定下来变成正文,
3028
+ * 而不是让人自己回去照着复述一遍。
3029
+ */
3030
+ 'game.toChapter': async (body) => {
3031
+ const ws = await root(body); const mods = await ensureModules(); if (mods.game !== true) return missingFeature('game')
3032
+ if (mods.novel !== true) return missingFeature('novel')
3033
+ const bookId = String(body?.book ?? '').trim()
3034
+ const id = String(body?.id ?? '').trim()
3035
+ if (bookId === '' || id === '') return { ok: false, error: '这一局找不到了' }
3036
+ const got = await game.readPlay(ws, bookId, id)
3037
+ if (got.ok !== true) return got
3038
+ const turns = Array.isArray(got.turns) ? got.turns : []
3039
+ if (turns.length === 0) return { ok: false, error: '这一局还什么都没发生,先走一步再变成一章。' }
3040
+ const book = await novel.readBook(ws, bookId)
3041
+ if (book.ok !== true) return book
3042
+ const prompt = game.buildPlayToChapterPrompt({
3043
+ bookTitle: String(book.data?.title ?? bookId),
3044
+ turns,
3045
+ note: body?.note,
3046
+ })
3047
+ const called = await callModel(ctx, { system: prompt.system, user: prompt.user, model: body?.model })
3048
+ if (called.ok !== true) return { ok: false, error: `没写成:${called.error ?? '过一会儿再试一次'}`, kept: true }
3049
+ const text = String(called.text ?? '').trim()
3050
+ if (text === '') return { ok: false, error: '这次什么都没写出来,过一会儿再试一次。' }
3051
+ const list = Array.isArray(book.chapters) ? book.chapters : []
3052
+ const last = list.length === 0 ? 0 : Number(list[list.length - 1]?.index ?? 0)
3053
+ const next = (Number.isFinite(last) ? last : 0) + 1
3054
+ const saved = await novel.writeChapter(ws, bookId, next, text)
3055
+ if (saved.ok !== true) return saved
3056
+ return { ok: true, chapter: next, text, warning: saved.warning }
3057
+ },
3058
+
3059
+ /** 这一局不要了。 */
3060
+ 'game.delete': async (body) => {
3061
+ const ws = await root(body); const mods = await ensureModules(); if (mods.game !== true) return missingFeature('game')
3062
+ const book = String(body?.book ?? '').trim()
3063
+ const id = String(body?.id ?? '').trim()
3064
+ if (book === '' || id === '') return { ok: false, error: '这一局找不到了' }
3065
+ return await game.deletePlay(ws, book, id)
3066
+ },
3067
+
3068
+ // ── 预设:三种玩法各自的演法/写法 ─────────────────────────
3069
+
3070
+ /** 哪些功能装了、哪些没装。界面按这个决定显示什么。 */
3071
+ 'features.list': async (body) => {
3072
+ const mods = await ensureModules()
3073
+ return {
3074
+ ok: true,
3075
+ features: {
3076
+ real: true,
3077
+ jobs: true,
3078
+ tavern: mods.tavern === true,
3079
+ novel: mods.novel === true,
3080
+ presets: mods.presets === true,
3081
+ game: mods.game === true && mods.novel === true,
3082
+ career: mods.career === true,
3083
+ },
3084
+ }
3085
+ },
3086
+
3087
+ /** 列预设(内置 + 自己的)。 */
3088
+ 'preset.list': async (body) => {
3089
+ const ws = await root(body); const mods = await ensureModules(); if (mods.presets !== true) return missingFeature('presets')
3090
+ return await presets.listAll(ws)
3091
+ },
3092
+
3093
+ /** 把一份内置的复制成自己的,之后能随便改。 */
3094
+ 'preset.copy': async (body) => {
3095
+ const ws = await root(body); const mods = await ensureModules(); if (mods.presets !== true) return missingFeature('presets')
3096
+ return await presets.copyBuiltin(ws, body?.id, body?.as)
3097
+ },
3098
+
3099
+ /** 存一份自己的预设(新建或改)。 */
3100
+ 'preset.save': async (body) => {
3101
+ const ws = await root(body); const mods = await ensureModules(); if (mods.presets !== true) return missingFeature('presets')
3102
+ return await presets.saveCustom(ws, { id: body?.id, mode: body?.mode, name: body?.name, desc: body?.desc, prompt: body?.prompt })
3103
+ },
3104
+
3105
+ /** 删一份自己的预设。 */
3106
+ 'preset.delete': async (body) => {
3107
+ const ws = await root(body); const mods = await ensureModules(); if (mods.presets !== true) return missingFeature('presets')
3108
+ const id = String(body?.id ?? '').trim()
3109
+ if (id === '') return { ok: false, error: '没有说是哪一份' }
3110
+ return await presets.deleteCustom(ws, id)
3111
+ },
3112
+
2611
3113
  'novel.chapter.get': async (body) => {
3114
+ if ((await ensureModules()).novel !== true) return missingFeature('novel')
2612
3115
  const ws = await root(body)
2613
3116
  const id = String(body?.id ?? '').trim()
2614
3117
  if (id === '') return { ok: false, error: '没有说是哪本书' }
@@ -2617,6 +3120,7 @@ function buildApi(ctx, workspace, config, options) {
2617
3120
 
2618
3121
  /** 自己写(或改)一章,直接在编辑器里手写。 */
2619
3122
  'novel.chapter.save': async (body) => {
3123
+ if ((await ensureModules()).novel !== true) return missingFeature('novel')
2620
3124
  const ws = await root(body)
2621
3125
  const id = String(body?.id ?? '').trim()
2622
3126
  if (id === '') return { ok: false, error: '没有说是哪本书' }
@@ -2631,6 +3135,7 @@ function buildApi(ctx, workspace, config, options) {
2631
3135
  * 只给上一章它不知道这本书要往哪走。
2632
3136
  */
2633
3137
  'novel.chapter.draft': async (body) => {
3138
+ if ((await ensureModules()).novel !== true) return missingFeature('novel')
2634
3139
  const ws = await root(body)
2635
3140
  const id = String(body?.id ?? '').trim()
2636
3141
  if (id === '') return { ok: false, error: '没有说是哪本书' }
@@ -2672,7 +3177,12 @@ function buildApi(ctx, workspace, config, options) {
2672
3177
  const outline = await novel.readOutline(ws, id)
2673
3178
  const outlineText = outline.ok === true && outline.text !== '' ? outline.text : ''
2674
3179
  const planItem = outline.ok === true ? (outline.chapters ?? []).find((c) => c.index === index) : undefined
2675
- const system = '你是一个写小说的人。你的任务是把故事写下去,不是解释、不是评论、不是总结。'
3180
+ const writePreset = await presets.resolve(ws, body?.preset)
3181
+ const presetNote = writePreset.ok === true && writePreset.preset !== null ? String(writePreset.preset.prompt ?? '') : ''
3182
+ const system = [
3183
+ '你是一个写小说的人。你的任务是把故事写下去,不是解释、不是评论、不是总结。',
3184
+ presetNote === '' ? '' : `\n【这一段怎么写】\n${presetNote}`,
3185
+ ].filter((x) => x !== '').join('\n')
2676
3186
  const user = novel.buildChapterPrompt({
2677
3187
  title: String(book.data?.title ?? id),
2678
3188
  idea: String(book.idea ?? ''),
@@ -2719,9 +3229,22 @@ async function gatherCast(ws, wants) {
2719
3229
  if (rel === '') continue
2720
3230
  const card = await store.readCard(ws, rel)
2721
3231
  if (card.ok !== true) continue
2722
- const persona = tavern.personaFromCard(card.data, card.sections)
2723
- if (persona.text === '') continue
2724
- out.push(`【${persona.name}】${persona.text.slice(0, 500)}`)
3232
+ // 酒馆模块被删掉时,这里自己拼一份最小的人设——
3233
+ // 「写小说」不该因为「酒馆」没了就用不了。
3234
+ let name = String(card.data?.name ?? '').trim()
3235
+ let text = ''
3236
+ if (tavern !== null) {
3237
+ const persona = tavern.personaFromCard(card.data, card.sections)
3238
+ name = persona.name
3239
+ text = persona.text
3240
+ } else {
3241
+ const parts = ['自述', '性格', '口癖', '外貌']
3242
+ .map((title) => core.sectionBody(card.sections, title).trim())
3243
+ .filter((x) => x !== '')
3244
+ text = parts.join('\n')
3245
+ }
3246
+ if (text === '') continue
3247
+ out.push(`【${name === '' ? '这个人' : name}】${text.slice(0, 500)}`)
2725
3248
  }
2726
3249
  return out
2727
3250
  }