dsh-lifecard 0.1.1 → 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/README.md +62 -1
- package/lib/career.js +488 -0
- package/lib/client.js +1121 -67
- package/lib/game.js +373 -0
- package/lib/index.js +816 -7
- package/lib/novel.js +567 -0
- package/lib/presets.js +424 -0
- package/lib/store.js +86 -15
- package/lib/tavern.js +56 -1
- package/package.json +6 -2
package/lib/index.js
CHANGED
|
@@ -25,7 +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
|
-
|
|
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
|
+
}
|
|
29
56
|
export const name = 'dsh-lifecard'
|
|
30
57
|
|
|
31
58
|
/** 需要宿主提供的服务。缺 webServer 就什么都不做(明确失败胜过静默半死)。 */
|
|
@@ -484,6 +511,13 @@ async function collectStream(handle) {
|
|
|
484
511
|
const eat = (chunk) => {
|
|
485
512
|
if (chunk === null || chunk === undefined) return
|
|
486
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
|
|
487
521
|
// 真实的流事件形状(实测):{ type: 'text-delta', index, text }
|
|
488
522
|
// block-start / block-end / text-start / text-end / finish 都不带正文
|
|
489
523
|
if (chunk.type === 'text-delta' && typeof chunk.text === 'string') { text += chunk.text; return }
|
|
@@ -844,8 +878,13 @@ function buildApi(ctx, workspace, config, options) {
|
|
|
844
878
|
const ws = await root(body)
|
|
845
879
|
const data = body?.data
|
|
846
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
|
+
}
|
|
847
887
|
const id = core.safeId(data.id ?? data.name ?? '')
|
|
848
|
-
if (id === '') return { ok: false, error: '这张卡还没有名字,先给它起一个。' }
|
|
849
888
|
data.id = id
|
|
850
889
|
data.lifecard = core.DATA_VERSION
|
|
851
890
|
let rel
|
|
@@ -1197,7 +1236,9 @@ function buildApi(ctx, workspace, config, options) {
|
|
|
1197
1236
|
const name = String(body?.name ?? about ?? 'material')
|
|
1198
1237
|
const block = body?.meta === undefined ? content : `${content}\n\n\`\`\`\n${body.meta}\n\`\`\``
|
|
1199
1238
|
const saved = await store.appendMaterial(ws, name, block)
|
|
1200
|
-
|
|
1239
|
+
// 以前这里无条件 `ok:true` 再展开 saved,把 `ok:false` 盖掉了:
|
|
1240
|
+
// 存失败时界面照样说「已保存」。别的路由都是先判 saved.ok 再往下走。
|
|
1241
|
+
if (saved?.ok !== true) return { ok: false, error: saved?.error ?? '这段材料没存进去。', ...saved }
|
|
1201
1242
|
},
|
|
1202
1243
|
|
|
1203
1244
|
/** 材料列表 + 全文(分析用)。 */
|
|
@@ -1878,7 +1919,7 @@ function buildApi(ctx, workspace, config, options) {
|
|
|
1878
1919
|
? all.filter((p) => body.people.includes(keyOf(p)))
|
|
1879
1920
|
: all
|
|
1880
1921
|
if (wanted.length === 0) return { ok: false, error: '勾选的人一个都没对上。' }
|
|
1881
|
-
const at =
|
|
1922
|
+
const at = store.localDate()
|
|
1882
1923
|
const files = []
|
|
1883
1924
|
for (const p of wanted) {
|
|
1884
1925
|
const label = p.name !== '' ? p.name : p.kin
|
|
@@ -1922,7 +1963,7 @@ function buildApi(ctx, workspace, config, options) {
|
|
|
1922
1963
|
? all.filter((p) => body.people.includes(p.name !== '' ? `${p.kin}:${p.name}` : p.kin))
|
|
1923
1964
|
: all
|
|
1924
1965
|
if (wanted.length === 0) return { ok: false, error: '勾选的人一个都没对上。' }
|
|
1925
|
-
const md = memory.renderMemoryMaterial(wanted, { read_at:
|
|
1966
|
+
const md = memory.renderMemoryMaterial(wanted, { read_at: store.localDate() })
|
|
1926
1967
|
const saved = await store.appendMaterial(ws, `记忆里认出来的人`, md)
|
|
1927
1968
|
if (saved.ok !== true) return saved
|
|
1928
1969
|
// 记下「这些人还缺什么」。等他们变成卡之后,卡上那个红色感叹号
|
|
@@ -2251,6 +2292,7 @@ function buildApi(ctx, workspace, config, options) {
|
|
|
2251
2292
|
|
|
2252
2293
|
/** 酒馆:说过的这几场。 */
|
|
2253
2294
|
'tavern.list': async (body) => {
|
|
2295
|
+
if ((await ensureModules()).tavern !== true) return missingFeature('tavern')
|
|
2254
2296
|
const ws = await root(body)
|
|
2255
2297
|
return await tavern.listChats(ws)
|
|
2256
2298
|
},
|
|
@@ -2262,6 +2304,7 @@ function buildApi(ctx, workspace, config, options) {
|
|
|
2262
2304
|
* 而不是对着一个空框想「我该说什么」。
|
|
2263
2305
|
*/
|
|
2264
2306
|
'tavern.start': async (body) => {
|
|
2307
|
+
if ((await ensureModules()).tavern !== true) return missingFeature('tavern')
|
|
2265
2308
|
const ws = await root(body)
|
|
2266
2309
|
const want = String(body?.card ?? '').trim()
|
|
2267
2310
|
if (want === '') return { ok: false, error: '得先挑一个人,才能开始。' }
|
|
@@ -2303,6 +2346,7 @@ function buildApi(ctx, workspace, config, options) {
|
|
|
2303
2346
|
|
|
2304
2347
|
/** 酒馆:读这一场。 */
|
|
2305
2348
|
'tavern.get': async (body) => {
|
|
2349
|
+
if ((await ensureModules()).tavern !== true) return missingFeature('tavern')
|
|
2306
2350
|
const ws = await root(body)
|
|
2307
2351
|
const id = String(body?.id ?? '').trim()
|
|
2308
2352
|
if (id === '') return { ok: false, error: '这一场找不到了' }
|
|
@@ -2330,6 +2374,8 @@ function buildApi(ctx, workspace, config, options) {
|
|
|
2330
2374
|
* 断网一次就全没了。
|
|
2331
2375
|
*/
|
|
2332
2376
|
'tavern.say': async (body) => {
|
|
2377
|
+
if ((await ensureModules()).tavern !== true) return missingFeature('tavern')
|
|
2378
|
+
if ((await ensureModules()).presets !== true) return missingFeature('presets')
|
|
2333
2379
|
const id = String(body?.id ?? '').trim()
|
|
2334
2380
|
if (id === '') return { ok: false, error: '这一场找不到了' }
|
|
2335
2381
|
// 同一场会话一次只处理一句:读-改-写之间夹着一次模型调用,
|
|
@@ -2373,7 +2419,11 @@ function buildApi(ctx, workspace, config, options) {
|
|
|
2373
2419
|
const memoryNote = (Array.isArray(got.notes) ? got.notes : [])
|
|
2374
2420
|
.find((n) => String(n?.title ?? '').trim() === tavern.MEMORY_TITLE)
|
|
2375
2421
|
const memory = memoryNote === undefined ? '' : String(memoryNote.body ?? '')
|
|
2376
|
-
|
|
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 })
|
|
2377
2427
|
const user = tavern.buildRoleplayUser({ cardName, turns })
|
|
2378
2428
|
const called = await callModel(ctx, { system, user, model: body?.model })
|
|
2379
2429
|
const now = tavernStamp()
|
|
@@ -2406,9 +2456,13 @@ function buildApi(ctx, workspace, config, options) {
|
|
|
2406
2456
|
* 让它别把之前发生过的事记错。
|
|
2407
2457
|
*/
|
|
2408
2458
|
'tavern.summarize': async (body) => {
|
|
2459
|
+
if ((await ensureModules()).tavern !== true) return missingFeature('tavern')
|
|
2409
2460
|
const ws = await root(body)
|
|
2410
2461
|
const id = String(body?.id ?? '').trim()
|
|
2411
2462
|
if (id === '') return { ok: false, error: '这一场找不到了' }
|
|
2463
|
+
// 收尾也是一次「读 → 调模型(十几二十秒)→ 整文件写回」,跟 tavern.say 同一个形状。
|
|
2464
|
+
// 不收进来排队的话,这段时间里说的任何一句都会被回写成收尾前那一版 —— 而且不留痕迹。
|
|
2465
|
+
return oneAtATime(id, async () => {
|
|
2412
2466
|
const got = await tavern.readChat(ws, id)
|
|
2413
2467
|
if (got.ok !== true) return got
|
|
2414
2468
|
const turns = Array.isArray(got.turns) ? got.turns.slice() : []
|
|
@@ -2428,16 +2482,771 @@ function buildApi(ctx, workspace, config, options) {
|
|
|
2428
2482
|
const saved = await tavern.writeChat(ws, id, { ...got.data, notes, turns: turns.length, updated_at: tavernStamp() }, turns)
|
|
2429
2483
|
if (saved.ok !== true) return { ok: false, error: `收好了,但没存下来:${saved.error}`, summary, turns }
|
|
2430
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
|
+
})
|
|
2431
2537
|
},
|
|
2432
2538
|
|
|
2433
2539
|
/** 酒馆:这一场不要了。 */
|
|
2434
|
-
|
|
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')
|
|
2435
2591
|
const ws = await root(body)
|
|
2436
2592
|
const id = String(body?.id ?? '').trim()
|
|
2437
2593
|
if (id === '') return { ok: false, error: '这一场找不到了' }
|
|
2438
2594
|
return await tavern.deleteChat(ws, id)
|
|
2439
2595
|
},
|
|
2596
|
+
|
|
2597
|
+
// ── 写小说:一本书、一章一章 ─────────────────────────────────
|
|
2598
|
+
|
|
2599
|
+
/** 书架:所有书。 */
|
|
2600
|
+
'novel.books': async (body) => {
|
|
2601
|
+
if ((await ensureModules()).novel !== true) return missingFeature('novel')
|
|
2602
|
+
const ws = await root(body)
|
|
2603
|
+
return await novel.listBooks(ws)
|
|
2604
|
+
},
|
|
2605
|
+
|
|
2606
|
+
/** 新开一本。 */
|
|
2607
|
+
'novel.create': async (body) => {
|
|
2608
|
+
if ((await ensureModules()).novel !== true) return missingFeature('novel')
|
|
2609
|
+
const ws = await root(body)
|
|
2610
|
+
return await novel.createBook(ws, {
|
|
2611
|
+
title: body?.title,
|
|
2612
|
+
genre: body?.genre,
|
|
2613
|
+
idea: body?.idea,
|
|
2614
|
+
})
|
|
2615
|
+
},
|
|
2616
|
+
|
|
2617
|
+
/** 打开一本书(含章节清单)。 */
|
|
2618
|
+
'novel.get': async (body) => {
|
|
2619
|
+
if ((await ensureModules()).novel !== true) return missingFeature('novel')
|
|
2620
|
+
const ws = await root(body)
|
|
2621
|
+
const id = String(body?.id ?? '').trim()
|
|
2622
|
+
if (id === '') return { ok: false, error: '没有说是哪本书' }
|
|
2623
|
+
return await novel.readBook(ws, id)
|
|
2624
|
+
},
|
|
2625
|
+
|
|
2626
|
+
/** 改书的元信息(书名 / 题材 / 一句话)。 */
|
|
2627
|
+
'novel.update': async (body) => {
|
|
2628
|
+
if ((await ensureModules()).novel !== true) return missingFeature('novel')
|
|
2629
|
+
const ws = await root(body)
|
|
2630
|
+
const id = String(body?.id ?? '').trim()
|
|
2631
|
+
if (id === '') return { ok: false, error: '没有说是哪本书' }
|
|
2632
|
+
return await novel.updateBook(ws, id, {
|
|
2633
|
+
title: body?.title,
|
|
2634
|
+
genre: body?.genre,
|
|
2635
|
+
idea: body?.idea,
|
|
2636
|
+
})
|
|
2637
|
+
},
|
|
2638
|
+
|
|
2639
|
+
/** 这一章不要了。 */
|
|
2640
|
+
'novel.chapter.delete': async (body) => {
|
|
2641
|
+
if ((await ensureModules()).novel !== true) return missingFeature('novel')
|
|
2642
|
+
const ws = await root(body)
|
|
2643
|
+
const id = String(body?.id ?? '').trim()
|
|
2644
|
+
if (id === '') return { ok: false, error: '没有说是哪本书' }
|
|
2645
|
+
return await novel.deleteChapter(ws, id, body?.index)
|
|
2646
|
+
},
|
|
2647
|
+
|
|
2648
|
+
/** 整本书不要了。 */
|
|
2649
|
+
'novel.delete': async (body) => {
|
|
2650
|
+
if ((await ensureModules()).novel !== true) return missingFeature('novel')
|
|
2651
|
+
const ws = await root(body)
|
|
2652
|
+
const id = String(body?.id ?? '').trim()
|
|
2653
|
+
if (id === '') return { ok: false, error: '没有说是哪本书' }
|
|
2654
|
+
return await novel.deleteBook(ws, id)
|
|
2655
|
+
},
|
|
2656
|
+
|
|
2657
|
+
// ── 写小说的三段:规划 → 写章 → 审稿 ──────────────────────
|
|
2658
|
+
|
|
2659
|
+
/** 读大纲。 */
|
|
2660
|
+
'novel.outline.get': async (body) => {
|
|
2661
|
+
if ((await ensureModules()).novel !== true) return missingFeature('novel')
|
|
2662
|
+
const ws = await root(body)
|
|
2663
|
+
const id = String(body?.id ?? '').trim()
|
|
2664
|
+
if (id === '') return { ok: false, error: '没有说是哪本书' }
|
|
2665
|
+
return await novel.readOutline(ws, id)
|
|
2666
|
+
},
|
|
2667
|
+
|
|
2668
|
+
/** 自己写 / 改大纲(人也在这里改)。 */
|
|
2669
|
+
'novel.outline.save': async (body) => {
|
|
2670
|
+
if ((await ensureModules()).novel !== true) return missingFeature('novel')
|
|
2671
|
+
const ws = await root(body)
|
|
2672
|
+
const id = String(body?.id ?? '').trim()
|
|
2673
|
+
if (id === '') return { ok: false, error: '没有说是哪本书' }
|
|
2674
|
+
return await novel.writeOutline(ws, id, String(body?.text ?? ''))
|
|
2675
|
+
},
|
|
2676
|
+
|
|
2677
|
+
/**
|
|
2678
|
+
* 让 AI 先把整本书的骨架搭出来(第一段:规划)。
|
|
2679
|
+
*
|
|
2680
|
+
* 只写进 outline.md,不动正文:这一步是「先想清楚」,用户看完能改,
|
|
2681
|
+
* 改完再一章一章让它写。不落盘的话刷新一下就没了,白等一次模型。
|
|
2682
|
+
*/
|
|
2683
|
+
'novel.outline.draft': async (body) => {
|
|
2684
|
+
if ((await ensureModules()).novel !== true) return missingFeature('novel')
|
|
2685
|
+
if ((await ensureModules()).presets !== true) return missingFeature('presets')
|
|
2686
|
+
const ws = await root(body)
|
|
2687
|
+
const id = String(body?.id ?? '').trim()
|
|
2688
|
+
if (id === '') return { ok: false, error: '没有说是哪本书' }
|
|
2689
|
+
const book = await novel.readBook(ws, id)
|
|
2690
|
+
if (book.ok !== true) return book
|
|
2691
|
+
const worldNotes = []
|
|
2692
|
+
const worldRel = typeof body?.world === 'string' ? body.world.trim() : ''
|
|
2693
|
+
if (worldRel !== '') {
|
|
2694
|
+
const world = await store.readCard(ws, worldRel)
|
|
2695
|
+
if (world.ok === true) {
|
|
2696
|
+
for (const entry of core.parseWorldEntries(world.sections)) {
|
|
2697
|
+
const content = String(entry?.content ?? '').trim()
|
|
2698
|
+
if (content !== '') worldNotes.push(content)
|
|
2699
|
+
}
|
|
2700
|
+
}
|
|
2701
|
+
}
|
|
2702
|
+
const cast = await gatherCast(ws, body?.cast)
|
|
2703
|
+
const users = novel.buildOutlinePrompt({
|
|
2704
|
+
title: String(book.data?.title ?? id),
|
|
2705
|
+
genre: String(book.data?.genre ?? ''),
|
|
2706
|
+
idea: String(book.idea ?? ''),
|
|
2707
|
+
cast,
|
|
2708
|
+
worldEntries: worldNotes,
|
|
2709
|
+
chapters: Number(body?.chapters) || 12,
|
|
2710
|
+
ask: body?.ask,
|
|
2711
|
+
})
|
|
2712
|
+
const outlinePreset = await presets.resolve(ws, body?.preset)
|
|
2713
|
+
const outlineNote = outlinePreset.ok === true && outlinePreset.preset !== null ? String(outlinePreset.preset.prompt ?? '') : ''
|
|
2714
|
+
const called = await callModel(ctx, {
|
|
2715
|
+
system: [
|
|
2716
|
+
'你是一个会写长篇小说的人。你先想清楚整本书怎么走,再动手写。',
|
|
2717
|
+
outlineNote === '' ? '' : `\n【这本书怎么写】\n${outlineNote}`,
|
|
2718
|
+
].filter((x) => x !== '').join('\n'),
|
|
2719
|
+
user: users,
|
|
2720
|
+
model: body?.model,
|
|
2721
|
+
})
|
|
2722
|
+
if (called.ok !== true) return { ok: false, error: `这次没想出来:${called.error ?? '过一会儿再试一次'}` }
|
|
2723
|
+
const text = String(called.text ?? '').trim()
|
|
2724
|
+
if (text === '') return { ok: false, error: 'AI 这次没给出东西,再点一次试试。' }
|
|
2725
|
+
const saved = await novel.writeOutline(ws, id, text)
|
|
2726
|
+
if (saved.ok !== true) return saved
|
|
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' }
|
|
2776
|
+
},
|
|
2777
|
+
|
|
2778
|
+
/**
|
|
2779
|
+
* 审一章(第三段:审稿)。
|
|
2780
|
+
*
|
|
2781
|
+
* 只挑**具体能指出的问题**:哪里跳了、哪里人物不像、哪里在替读者总结。
|
|
2782
|
+
* 不要它夸——「写得很好」这种话对写东西的人毫无用处。
|
|
2783
|
+
*/
|
|
2784
|
+
'novel.chapter.review': async (body) => {
|
|
2785
|
+
if ((await ensureModules()).novel !== true) return missingFeature('novel')
|
|
2786
|
+
const ws = await root(body)
|
|
2787
|
+
const id = String(body?.id ?? '').trim()
|
|
2788
|
+
if (id === '') return { ok: false, error: '没有说是哪本书' }
|
|
2789
|
+
const index = Number(body?.index) || 0
|
|
2790
|
+
const got = await novel.readChapter(ws, id, index)
|
|
2791
|
+
if (got.ok !== true) return got
|
|
2792
|
+
const bodyText = String(got.body ?? '').trim()
|
|
2793
|
+
if (bodyText === '') return { ok: false, error: '这一章还是空的,没什么可审的。' }
|
|
2794
|
+
if (bodyText.length > 20000) {
|
|
2795
|
+
return { ok: false, error: '这一章太长(超过两万字),一次读不完。先分成两章再挑毛病。' }
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2798
|
+
// 盲审:只给正文,不给书名、不给大纲、不给「这一章本来想写什么」。
|
|
2799
|
+
//
|
|
2800
|
+
// 为什么不给:写的人心里有一套意图,看的时候会不自觉地按意图去读,
|
|
2801
|
+
// 于是「我本来想写这个」会替「我实际写出来的是什么」作证。
|
|
2802
|
+
// 换成一个什么都不知道的读者,才是这本书真正的处境。
|
|
2803
|
+
const user = [
|
|
2804
|
+
'下面是一段小说正文。你之前没看过这本书,也不知道作者想写什么。',
|
|
2805
|
+
'你就是一个第一次读到这里的读者。',
|
|
2806
|
+
'',
|
|
2807
|
+
'【正文】',
|
|
2808
|
+
bodyText,
|
|
2809
|
+
'',
|
|
2810
|
+
'【读完告诉我这些】',
|
|
2811
|
+
'1. 你读到哪儿卡住了(那句话说不上来、那段跳了、回头看了两遍才懂)',
|
|
2812
|
+
'2. 你读到哪儿不信(这个人不会这么做、这件事不会这么发生)',
|
|
2813
|
+
'3. 你读到哪儿想跳过(哪几段你在心里划过去了)',
|
|
2814
|
+
'4. 哪句话是空话,删掉不影响(直接引原句)',
|
|
2815
|
+
'',
|
|
2816
|
+
'【怎么答】',
|
|
2817
|
+
'- 每条一行,先写「哪里:」再写「为什么:」。要引原句。',
|
|
2818
|
+
'- 最多 8 条,按你读的时候的不舒服程度排。',
|
|
2819
|
+
'- 不夸。一句夸奖都不要。',
|
|
2820
|
+
'- 不给改写版本——你只是读者,不是代笔。',
|
|
2821
|
+
'- 没看出问题就直接说「没卡住的地方」,不要凑条数。',
|
|
2822
|
+
].join('\n')
|
|
2823
|
+
const called = await callModel(ctx, {
|
|
2824
|
+
// 换了一张纸:这段系统提示只要求它当读者,不要求它当编辑,
|
|
2825
|
+
// 免得它从「怎么写更好」的角度出发,跑回写作者的位置上。
|
|
2826
|
+
system: [
|
|
2827
|
+
'你是一个普通读者,刚读完一段小说。',
|
|
2828
|
+
'你没有写过这段,也不知道作者是谁、想写什么。',
|
|
2829
|
+
'你只会说读的时候哪里不顺、哪里不信、哪里想跳过。',
|
|
2830
|
+
'你不会改稿,也不会替作者想句子。',
|
|
2831
|
+
body?.model === undefined || String(body.model).trim() === '' ? '' : '',
|
|
2832
|
+
].filter((x) => x !== '').join('\n'),
|
|
2833
|
+
user,
|
|
2834
|
+
model: body?.model,
|
|
2835
|
+
})
|
|
2836
|
+
if (called.ok !== true) return { ok: false, error: `这次没审成:${called.error ?? '过一会儿再试一次'}` }
|
|
2837
|
+
const review = String(called.text ?? '').trim()
|
|
2838
|
+
if (review === '') return { ok: false, error: 'AI 这次没给出意见,再点一次试试。' }
|
|
2839
|
+
return { ok: true, index, review, chars: bodyText.length, blind: true, model: String(body?.model ?? '') }
|
|
2840
|
+
},
|
|
2841
|
+
|
|
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
|
+
|
|
3113
|
+
'novel.chapter.get': async (body) => {
|
|
3114
|
+
if ((await ensureModules()).novel !== true) return missingFeature('novel')
|
|
3115
|
+
const ws = await root(body)
|
|
3116
|
+
const id = String(body?.id ?? '').trim()
|
|
3117
|
+
if (id === '') return { ok: false, error: '没有说是哪本书' }
|
|
3118
|
+
return await novel.readChapter(ws, id, body?.index)
|
|
3119
|
+
},
|
|
3120
|
+
|
|
3121
|
+
/** 自己写(或改)一章,直接在编辑器里手写。 */
|
|
3122
|
+
'novel.chapter.save': async (body) => {
|
|
3123
|
+
if ((await ensureModules()).novel !== true) return missingFeature('novel')
|
|
3124
|
+
const ws = await root(body)
|
|
3125
|
+
const id = String(body?.id ?? '').trim()
|
|
3126
|
+
if (id === '') return { ok: false, error: '没有说是哪本书' }
|
|
3127
|
+
return await novel.writeChapter(ws, id, body?.index, String(body?.body ?? ''))
|
|
3128
|
+
},
|
|
3129
|
+
|
|
3130
|
+
/**
|
|
3131
|
+
* 让 AI 接着写。
|
|
3132
|
+
*
|
|
3133
|
+
* 三样东西一起给它:这本书的设定、上一章的结尾、这一章已经写到哪。
|
|
3134
|
+
* 少给一样它就会开始自己编——只给设定它不知道刚才发生了什么,
|
|
3135
|
+
* 只给上一章它不知道这本书要往哪走。
|
|
3136
|
+
*/
|
|
3137
|
+
'novel.chapter.draft': async (body) => {
|
|
3138
|
+
if ((await ensureModules()).novel !== true) return missingFeature('novel')
|
|
3139
|
+
const ws = await root(body)
|
|
3140
|
+
const id = String(body?.id ?? '').trim()
|
|
3141
|
+
if (id === '') return { ok: false, error: '没有说是哪本书' }
|
|
3142
|
+
const book = await novel.readBook(ws, id)
|
|
3143
|
+
if (book.ok !== true) return book
|
|
3144
|
+
const index = Number(body?.index) || (book.chapters.length === 0 ? 1 : book.chapters[book.chapters.length - 1].index)
|
|
3145
|
+
|
|
3146
|
+
// 这一章已经写成什么样(没有就是空的)
|
|
3147
|
+
let currentBody = ''
|
|
3148
|
+
const got = await novel.readChapter(ws, id, index)
|
|
3149
|
+
if (got.ok === true) currentBody = String(got.body ?? '')
|
|
3150
|
+
|
|
3151
|
+
// 上一章的结尾
|
|
3152
|
+
let previousTail = ''
|
|
3153
|
+
const chapters = book.chapters.filter((c) => c.index < index)
|
|
3154
|
+
if (chapters.length > 0) {
|
|
3155
|
+
const prev = await novel.readChapter(ws, id, chapters[chapters.length - 1].index)
|
|
3156
|
+
if (prev.ok === true) previousTail = novel.tailOf(prev.body)
|
|
3157
|
+
}
|
|
3158
|
+
|
|
3159
|
+
// 世界设定(写小说时全量给,不像酒馆那样按关键词挑——写作需要知道全貌)
|
|
3160
|
+
const worldNotes = []
|
|
3161
|
+
const worldRel = typeof body?.world === 'string' && body.world.trim() !== '' ? body.world.trim() : ''
|
|
3162
|
+
if (worldRel !== '') {
|
|
3163
|
+
const world = await store.readCard(ws, worldRel)
|
|
3164
|
+
if (world.ok === true) {
|
|
3165
|
+
const entries = core.parseWorldEntries(world.sections)
|
|
3166
|
+
for (const entry of entries) {
|
|
3167
|
+
const content = String(entry?.content ?? '').trim()
|
|
3168
|
+
if (content !== '') worldNotes.push(content)
|
|
3169
|
+
}
|
|
3170
|
+
}
|
|
3171
|
+
}
|
|
3172
|
+
|
|
3173
|
+
// 出场的人(这本书挂的角色卡)
|
|
3174
|
+
const cast = await gatherCast(ws, body?.cast)
|
|
3175
|
+
|
|
3176
|
+
// 大纲:写第几章要对着计划写,不然一章一章会拖成流水账
|
|
3177
|
+
const outline = await novel.readOutline(ws, id)
|
|
3178
|
+
const outlineText = outline.ok === true && outline.text !== '' ? outline.text : ''
|
|
3179
|
+
const planItem = outline.ok === true ? (outline.chapters ?? []).find((c) => c.index === index) : undefined
|
|
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')
|
|
3186
|
+
const user = novel.buildChapterPrompt({
|
|
3187
|
+
title: String(book.data?.title ?? id),
|
|
3188
|
+
idea: String(book.idea ?? ''),
|
|
3189
|
+
genre: String(book.data?.genre ?? ''),
|
|
3190
|
+
previousTail,
|
|
3191
|
+
currentBody: String(currentBody ?? '').slice(-2000),
|
|
3192
|
+
ask: body?.ask,
|
|
3193
|
+
worldEntries: worldNotes,
|
|
3194
|
+
cast,
|
|
3195
|
+
outline: outlineText,
|
|
3196
|
+
chapterPlan: planItem === undefined ? '' : planItem.plan,
|
|
3197
|
+
})
|
|
3198
|
+
const called = await callModel(ctx, { system, user, model: body?.model })
|
|
3199
|
+
if (called.ok !== true) {
|
|
3200
|
+
return { ok: false, error: `这次没写出来:${called.error ?? '过一会儿再试一次'}`, index }
|
|
3201
|
+
}
|
|
3202
|
+
const draft = String(called.text ?? '').trim()
|
|
3203
|
+
if (draft === '') return { ok: false, error: 'AI 这次没写出东西来,再点一次试试。', index }
|
|
3204
|
+
return {
|
|
3205
|
+
ok: true,
|
|
3206
|
+
index,
|
|
3207
|
+
draft,
|
|
3208
|
+
// 不直接落盘:写字的人要能先看、能改、能只取一半。落盘由 chapter.save 做。
|
|
3209
|
+
hasCurrent: String(currentBody).trim() !== '',
|
|
3210
|
+
usedWorld: worldNotes.length,
|
|
3211
|
+
usedCast: cast.length,
|
|
3212
|
+
}
|
|
3213
|
+
},
|
|
3214
|
+
}
|
|
3215
|
+
}
|
|
3216
|
+
|
|
3217
|
+
/**
|
|
3218
|
+
* 把「这本书挂的几张角色卡」收成几段人设。
|
|
3219
|
+
*
|
|
3220
|
+
* 一本书可以挂好几张卡(主角、对手、配角)。写某一章的时候把它们一起给模型,
|
|
3221
|
+
* 它才不会把「说话很冲的那个人」写成温和的——那是长篇最容易崩的地方。
|
|
3222
|
+
* 每张卡只取前 500 字:卡可能写得很长,全贴会把大纲和上一章挤掉。
|
|
3223
|
+
*/
|
|
3224
|
+
async function gatherCast(ws, wants) {
|
|
3225
|
+
const out = []
|
|
3226
|
+
const list = Array.isArray(wants) ? wants.slice(0, 8) : []
|
|
3227
|
+
for (const want of list) {
|
|
3228
|
+
const rel = String(want ?? '').trim()
|
|
3229
|
+
if (rel === '') continue
|
|
3230
|
+
const card = await store.readCard(ws, rel)
|
|
3231
|
+
if (card.ok !== true) continue
|
|
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)}`)
|
|
2440
3248
|
}
|
|
3249
|
+
return out
|
|
2441
3250
|
}
|
|
2442
3251
|
|
|
2443
3252
|
/** 酒馆的时间戳:`2026-10-24 16:11`。本地时间,字典序就是时间序。 */
|