dsh-lifecard 0.1.1 → 0.1.2

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
@@ -26,6 +26,7 @@ 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
28
  import * as tavern from './tavern.js'
29
+ import * as novel from './novel.js'
29
30
  export const name = 'dsh-lifecard'
30
31
 
31
32
  /** 需要宿主提供的服务。缺 webServer 就什么都不做(明确失败胜过静默半死)。 */
@@ -2437,9 +2438,294 @@ function buildApi(ctx, workspace, config, options) {
2437
2438
  if (id === '') return { ok: false, error: '这一场找不到了' }
2438
2439
  return await tavern.deleteChat(ws, id)
2439
2440
  },
2441
+
2442
+ // ── 写小说:一本书、一章一章 ─────────────────────────────────
2443
+
2444
+ /** 书架:所有书。 */
2445
+ 'novel.books': async (body) => {
2446
+ const ws = await root(body)
2447
+ return await novel.listBooks(ws)
2448
+ },
2449
+
2450
+ /** 新开一本。 */
2451
+ 'novel.create': async (body) => {
2452
+ const ws = await root(body)
2453
+ return await novel.createBook(ws, {
2454
+ title: body?.title,
2455
+ genre: body?.genre,
2456
+ idea: body?.idea,
2457
+ })
2458
+ },
2459
+
2460
+ /** 打开一本书(含章节清单)。 */
2461
+ 'novel.get': async (body) => {
2462
+ const ws = await root(body)
2463
+ const id = String(body?.id ?? '').trim()
2464
+ if (id === '') return { ok: false, error: '没有说是哪本书' }
2465
+ return await novel.readBook(ws, id)
2466
+ },
2467
+
2468
+ /** 改书的元信息(书名 / 题材 / 一句话)。 */
2469
+ 'novel.update': async (body) => {
2470
+ const ws = await root(body)
2471
+ const id = String(body?.id ?? '').trim()
2472
+ if (id === '') return { ok: false, error: '没有说是哪本书' }
2473
+ return await novel.updateBook(ws, id, {
2474
+ title: body?.title,
2475
+ genre: body?.genre,
2476
+ idea: body?.idea,
2477
+ })
2478
+ },
2479
+
2480
+ /** 这一章不要了。 */
2481
+ 'novel.chapter.delete': async (body) => {
2482
+ const ws = await root(body)
2483
+ const id = String(body?.id ?? '').trim()
2484
+ if (id === '') return { ok: false, error: '没有说是哪本书' }
2485
+ return await novel.deleteChapter(ws, id, body?.index)
2486
+ },
2487
+
2488
+ /** 整本书不要了。 */
2489
+ 'novel.delete': async (body) => {
2490
+ const ws = await root(body)
2491
+ const id = String(body?.id ?? '').trim()
2492
+ if (id === '') return { ok: false, error: '没有说是哪本书' }
2493
+ return await novel.deleteBook(ws, id)
2494
+ },
2495
+
2496
+ // ── 写小说的三段:规划 → 写章 → 审稿 ──────────────────────
2497
+
2498
+ /** 读大纲。 */
2499
+ 'novel.outline.get': async (body) => {
2500
+ const ws = await root(body)
2501
+ const id = String(body?.id ?? '').trim()
2502
+ if (id === '') return { ok: false, error: '没有说是哪本书' }
2503
+ return await novel.readOutline(ws, id)
2504
+ },
2505
+
2506
+ /** 自己写 / 改大纲(人也在这里改)。 */
2507
+ 'novel.outline.save': async (body) => {
2508
+ const ws = await root(body)
2509
+ const id = String(body?.id ?? '').trim()
2510
+ if (id === '') return { ok: false, error: '没有说是哪本书' }
2511
+ return await novel.writeOutline(ws, id, String(body?.text ?? ''))
2512
+ },
2513
+
2514
+ /**
2515
+ * 让 AI 先把整本书的骨架搭出来(第一段:规划)。
2516
+ *
2517
+ * 只写进 outline.md,不动正文:这一步是「先想清楚」,用户看完能改,
2518
+ * 改完再一章一章让它写。不落盘的话刷新一下就没了,白等一次模型。
2519
+ */
2520
+ 'novel.outline.draft': async (body) => {
2521
+ const ws = await root(body)
2522
+ const id = String(body?.id ?? '').trim()
2523
+ if (id === '') return { ok: false, error: '没有说是哪本书' }
2524
+ const book = await novel.readBook(ws, id)
2525
+ if (book.ok !== true) return book
2526
+ const worldNotes = []
2527
+ const worldRel = typeof body?.world === 'string' ? body.world.trim() : ''
2528
+ if (worldRel !== '') {
2529
+ const world = await store.readCard(ws, worldRel)
2530
+ if (world.ok === true) {
2531
+ for (const entry of core.parseWorldEntries(world.sections)) {
2532
+ const content = String(entry?.content ?? '').trim()
2533
+ if (content !== '') worldNotes.push(content)
2534
+ }
2535
+ }
2536
+ }
2537
+ const cast = await gatherCast(ws, body?.cast)
2538
+ const users = novel.buildOutlinePrompt({
2539
+ title: String(book.data?.title ?? id),
2540
+ genre: String(book.data?.genre ?? ''),
2541
+ idea: String(book.idea ?? ''),
2542
+ cast,
2543
+ worldEntries: worldNotes,
2544
+ chapters: Number(body?.chapters) || 12,
2545
+ ask: body?.ask,
2546
+ })
2547
+ const called = await callModel(ctx, {
2548
+ system: '你是一个会写长篇小说的人。你先想清楚整本书怎么走,再动手写。',
2549
+ user: users,
2550
+ model: body?.model,
2551
+ })
2552
+ if (called.ok !== true) return { ok: false, error: `这次没想出来:${called.error ?? '过一会儿再试一次'}` }
2553
+ const text = String(called.text ?? '').trim()
2554
+ if (text === '') return { ok: false, error: 'AI 这次没给出东西,再点一次试试。' }
2555
+ const saved = await novel.writeOutline(ws, id, text)
2556
+ if (saved.ok !== true) return saved
2557
+ return { ok: true, text, ...novel.parseOutline(text), path: saved.path }
2558
+ },
2559
+
2560
+ /**
2561
+ * 审一章(第三段:审稿)。
2562
+ *
2563
+ * 只挑**具体能指出的问题**:哪里跳了、哪里人物不像、哪里在替读者总结。
2564
+ * 不要它夸——「写得很好」这种话对写东西的人毫无用处。
2565
+ */
2566
+ 'novel.chapter.review': async (body) => {
2567
+ const ws = await root(body)
2568
+ const id = String(body?.id ?? '').trim()
2569
+ if (id === '') return { ok: false, error: '没有说是哪本书' }
2570
+ const book = await novel.readBook(ws, id)
2571
+ if (book.ok !== true) return book
2572
+ const index = Number(body?.index) || 0
2573
+ const got = await novel.readChapter(ws, id, index)
2574
+ if (got.ok !== true) return got
2575
+ const bodyText = String(got.body ?? '').trim()
2576
+ if (bodyText === '') return { ok: false, error: '这一章还是空的,没什么可审的。' }
2577
+ const user = [
2578
+ `下面是一本小说《${String(book.data?.title ?? id)}》的第 ${index} 章。`,
2579
+ String(book.idea ?? '').trim() === '' ? '' : `这本书想讲的是:${String(book.idea).trim()}`,
2580
+ '',
2581
+ '【正文】',
2582
+ bodyText.slice(0, 8000),
2583
+ '',
2584
+ '【怎么审】',
2585
+ '只指出具体的问题,每条都要说清在哪一句、为什么是问题。按这个顺序:',
2586
+ '1. 哪里读不懂 / 情节跳了(读者会卡住的地方)',
2587
+ '2. 哪里人物不像(说话方式、做的事和前面的人设对不上)',
2588
+ '3. 哪里在替读者总结(「这说明他很孤独」这类,应该把事写出来)',
2589
+ '4. 哪里是空话、套话、可以整段删掉',
2590
+ '',
2591
+ '【别做的事】',
2592
+ '- 不要夸。一句夸奖都不要。',
2593
+ '- 不要整段重写、不要给「参考版本」。我要的是问题清单,不是替我写。',
2594
+ '- 不要评论「主题」「深度」这种大词,只说字面上的问题。',
2595
+ '',
2596
+ '每条一行,开头写「问题:」,然后写「怎么改:」。最多 8 条,按严重程度排。',
2597
+ '如果这一章确实没什么大问题,就只说一句「没看出硬伤,只有下面这几处小地方」。',
2598
+ ].filter((x) => x !== '').join('\n')
2599
+ const called = await callModel(ctx, {
2600
+ system: '你是一个挑毛病的编辑。你的任务不是说好话,是把问题指出来。',
2601
+ user,
2602
+ model: body?.model,
2603
+ })
2604
+ if (called.ok !== true) return { ok: false, error: `这次没审成:${called.error ?? '过一会儿再试一次'}` }
2605
+ const review = String(called.text ?? '').trim()
2606
+ if (review === '') return { ok: false, error: 'AI 这次没给出意见,再点一次试试。' }
2607
+ return { ok: true, index, review }
2608
+ },
2609
+
2610
+ /** 读一章。 */
2611
+ 'novel.chapter.get': async (body) => {
2612
+ const ws = await root(body)
2613
+ const id = String(body?.id ?? '').trim()
2614
+ if (id === '') return { ok: false, error: '没有说是哪本书' }
2615
+ return await novel.readChapter(ws, id, body?.index)
2616
+ },
2617
+
2618
+ /** 自己写(或改)一章,直接在编辑器里手写。 */
2619
+ 'novel.chapter.save': async (body) => {
2620
+ const ws = await root(body)
2621
+ const id = String(body?.id ?? '').trim()
2622
+ if (id === '') return { ok: false, error: '没有说是哪本书' }
2623
+ return await novel.writeChapter(ws, id, body?.index, String(body?.body ?? ''))
2624
+ },
2625
+
2626
+ /**
2627
+ * 让 AI 接着写。
2628
+ *
2629
+ * 三样东西一起给它:这本书的设定、上一章的结尾、这一章已经写到哪。
2630
+ * 少给一样它就会开始自己编——只给设定它不知道刚才发生了什么,
2631
+ * 只给上一章它不知道这本书要往哪走。
2632
+ */
2633
+ 'novel.chapter.draft': async (body) => {
2634
+ const ws = await root(body)
2635
+ const id = String(body?.id ?? '').trim()
2636
+ if (id === '') return { ok: false, error: '没有说是哪本书' }
2637
+ const book = await novel.readBook(ws, id)
2638
+ if (book.ok !== true) return book
2639
+ const index = Number(body?.index) || (book.chapters.length === 0 ? 1 : book.chapters[book.chapters.length - 1].index)
2640
+
2641
+ // 这一章已经写成什么样(没有就是空的)
2642
+ let currentBody = ''
2643
+ const got = await novel.readChapter(ws, id, index)
2644
+ if (got.ok === true) currentBody = String(got.body ?? '')
2645
+
2646
+ // 上一章的结尾
2647
+ let previousTail = ''
2648
+ const chapters = book.chapters.filter((c) => c.index < index)
2649
+ if (chapters.length > 0) {
2650
+ const prev = await novel.readChapter(ws, id, chapters[chapters.length - 1].index)
2651
+ if (prev.ok === true) previousTail = novel.tailOf(prev.body)
2652
+ }
2653
+
2654
+ // 世界设定(写小说时全量给,不像酒馆那样按关键词挑——写作需要知道全貌)
2655
+ const worldNotes = []
2656
+ const worldRel = typeof body?.world === 'string' && body.world.trim() !== '' ? body.world.trim() : ''
2657
+ if (worldRel !== '') {
2658
+ const world = await store.readCard(ws, worldRel)
2659
+ if (world.ok === true) {
2660
+ const entries = core.parseWorldEntries(world.sections)
2661
+ for (const entry of entries) {
2662
+ const content = String(entry?.content ?? '').trim()
2663
+ if (content !== '') worldNotes.push(content)
2664
+ }
2665
+ }
2666
+ }
2667
+
2668
+ // 出场的人(这本书挂的角色卡)
2669
+ const cast = await gatherCast(ws, body?.cast)
2670
+
2671
+ // 大纲:写第几章要对着计划写,不然一章一章会拖成流水账
2672
+ const outline = await novel.readOutline(ws, id)
2673
+ const outlineText = outline.ok === true && outline.text !== '' ? outline.text : ''
2674
+ const planItem = outline.ok === true ? (outline.chapters ?? []).find((c) => c.index === index) : undefined
2675
+ const system = '你是一个写小说的人。你的任务是把故事写下去,不是解释、不是评论、不是总结。'
2676
+ const user = novel.buildChapterPrompt({
2677
+ title: String(book.data?.title ?? id),
2678
+ idea: String(book.idea ?? ''),
2679
+ genre: String(book.data?.genre ?? ''),
2680
+ previousTail,
2681
+ currentBody: String(currentBody ?? '').slice(-2000),
2682
+ ask: body?.ask,
2683
+ worldEntries: worldNotes,
2684
+ cast,
2685
+ outline: outlineText,
2686
+ chapterPlan: planItem === undefined ? '' : planItem.plan,
2687
+ })
2688
+ const called = await callModel(ctx, { system, user, model: body?.model })
2689
+ if (called.ok !== true) {
2690
+ return { ok: false, error: `这次没写出来:${called.error ?? '过一会儿再试一次'}`, index }
2691
+ }
2692
+ const draft = String(called.text ?? '').trim()
2693
+ if (draft === '') return { ok: false, error: 'AI 这次没写出东西来,再点一次试试。', index }
2694
+ return {
2695
+ ok: true,
2696
+ index,
2697
+ draft,
2698
+ // 不直接落盘:写字的人要能先看、能改、能只取一半。落盘由 chapter.save 做。
2699
+ hasCurrent: String(currentBody).trim() !== '',
2700
+ usedWorld: worldNotes.length,
2701
+ usedCast: cast.length,
2702
+ }
2703
+ },
2440
2704
  }
2441
2705
  }
2442
2706
 
2707
+ /**
2708
+ * 把「这本书挂的几张角色卡」收成几段人设。
2709
+ *
2710
+ * 一本书可以挂好几张卡(主角、对手、配角)。写某一章的时候把它们一起给模型,
2711
+ * 它才不会把「说话很冲的那个人」写成温和的——那是长篇最容易崩的地方。
2712
+ * 每张卡只取前 500 字:卡可能写得很长,全贴会把大纲和上一章挤掉。
2713
+ */
2714
+ async function gatherCast(ws, wants) {
2715
+ const out = []
2716
+ const list = Array.isArray(wants) ? wants.slice(0, 8) : []
2717
+ for (const want of list) {
2718
+ const rel = String(want ?? '').trim()
2719
+ if (rel === '') continue
2720
+ const card = await store.readCard(ws, rel)
2721
+ 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)}`)
2725
+ }
2726
+ return out
2727
+ }
2728
+
2443
2729
  /** 酒馆的时间戳:`2026-10-24 16:11`。本地时间,字典序就是时间序。 */
2444
2730
  function tavernStamp(now = new Date()) {
2445
2731
  const pad = (n) => String(n).padStart(2, '0')