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/novel.js CHANGED
@@ -38,7 +38,39 @@ export function bookIdOf(title) {
38
38
 
39
39
  /** 书的目录(工作区相对路径)。 */
40
40
  export function bookRelPath(id) {
41
- return path.posix.join(BOOK_DIR, core.safeId(id))
41
+ return path.posix.join(BOOK_DIR, bookFolderName(id))
42
+ }
43
+
44
+ /**
45
+ * 把书名/目录名收成一段安全的文件夹名。
46
+ *
47
+ * `core.safeId` 只替换 `\ / : * ? " < > | # %` 和空白,**`.` 和 `..` 会原样过去**。
48
+ * 而 `path.posix.join('books', '..')` 是 `'.'`、`join('books', '.')` 是 `'books'`,
49
+ * 于是 `deleteBook('..')` 里的 `fs.rm(root + '.', {recursive:true})` 会把**整个工作区**
50
+ * ——卡、材料、提案、`.lifecard/snapshots` ——一次删光。这不是理论问题:
51
+ * id 从书名来,而书名是用户能打出来的。
52
+ *
53
+ * 所以这里单独收一道:去掉首尾的点、把连续的 `.` 压成一个,只剩点的时候退回「未命名」。
54
+ */
55
+ export function bookFolderName(id) {
56
+ const raw = String(id ?? '').trim()
57
+ let name = core.safeId(raw)
58
+ name = name.replace(/^\.+/, '').replace(/\.+$/, '').replace(/\.{2,}/g, '.')
59
+ if (name === '' || name === '.' || /^\.+$/.test(name)) name = '未命名'
60
+ if (name === BOOK_DIR) name = `${BOOK_DIR}-book`
61
+ return name
62
+ }
63
+
64
+ /**
65
+ * 书目录的绝对路径。所有读写都从这里出去,并且**再确认一次**落在 `books/` 里面
66
+ * (`bookFolderName` 已经把危险形状挡住,这里是第二道,防以后有人绕过它拼路径)。
67
+ */
68
+ export function bookAbs(root, id) {
69
+ const rel = bookRelPath(id)
70
+ const abs = path.join(root, rel)
71
+ const base = path.join(root, BOOK_DIR)
72
+ if (abs !== base && !abs.startsWith(base + path.sep)) return null
73
+ return abs
42
74
  }
43
75
 
44
76
  /** 章文件名:`001.md` —— 补零到三位,字典序就是章序。 */
@@ -179,10 +211,11 @@ export async function writeChapter(root, id, index, body, when = new Date()) {
179
211
  } catch {
180
212
  return { ok: false, error: '这一章没能存下来。检查一下磁盘是不是满了。' }
181
213
  }
182
- // 书的 last_updated 跟着动
214
+ // 书的 last_updated 跟着动。这一步失败了也要如实说:章节确实存下来了,
215
+ // 只是书的「最后改过」没更新——不能报成功,也不能报「整章没存」。
183
216
  const nextData = { ...book.data, updated_at: when.toISOString() }
184
- await writeBookMeta(root, id, nextData, book.idea)
185
- return { ok: true, index: n, path: rel, chars: text.length }
217
+ const touched = await writeBookMeta(root, id, nextData, book.idea)
218
+ return { ok: true, index: n, path: rel, chars: text.length, warning: touched.ok === true ? undefined : touched.error }
186
219
  }
187
220
 
188
221
  /** 删一章。删完不重排章号——用户手里的「第三章」不会突然变成第二章。 */
@@ -206,8 +239,16 @@ async function writeBookMeta(root, id, data, idea) {
206
239
  await fs.mkdir(path.dirname(abs), { recursive: true })
207
240
  const text = renderBookMarkdown({ ...data, idea: String(idea ?? '').trim() })
208
241
  const tmp = `${abs}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`
209
- await fs.writeFile(tmp, text, 'utf8')
210
- await fs.rename(tmp, abs)
242
+ // 这里必须自己兜住:`writeChapter` 是先写章、再更新这本书的
243
+ // 「最后改过的时间」。更新这一步抛出去,调用方只会看到一句通用的失败,
244
+ // 而那一章其实已经在盘上了——用户以为没存,再写一次就多出一章。
245
+ try {
246
+ await fs.writeFile(tmp, text, 'utf8')
247
+ await fs.rename(tmp, abs)
248
+ } catch {
249
+ await fs.rm(tmp, { force: true }).catch(() => {})
250
+ return { ok: false, path: rel, error: '这本书的记录没能更新(章节本身可能已经存下来了)。检查一下磁盘空间。' }
251
+ }
211
252
  return { ok: true, path: rel }
212
253
  }
213
254
 
@@ -216,11 +257,16 @@ export async function createBook(root, input = {}) {
216
257
  const title = String(input.title ?? '').trim() === '' ? '未命名' : String(input.title).trim()
217
258
  const stem = bookIdOf(title)
218
259
  let id = stem
219
- for (let n = 2; n <= 200; n += 1) {
260
+ let free = false
261
+ for (let n = 1; n <= 200; n += 1) {
262
+ id = n === 1 ? stem : `${stem}-${n}`
220
263
  const exists = await fs.stat(path.join(root, bookRelPath(id), 'book.md')).then((s) => s.isFile()).catch(() => false)
221
- if (!exists) break
222
- id = `${stem}-${n}`
264
+ if (!exists) { free = true; break }
223
265
  }
266
+ // 循环跑完还占不到位置时必须报错。以前这里出了循环就直接往下写,
267
+ // 于是同名第 201 本会**覆盖**看上去是第 200 本的那本 —— 而函数注释
268
+ // 明明写着「同名不覆盖」。
269
+ if (!free) return { ok: false, error: `同名「${title}」的书已经太多了,换个书名再建。` }
224
270
  const now = new Date()
225
271
  const data = {
226
272
  id,
@@ -249,8 +295,15 @@ export async function updateBook(root, id, patch = {}) {
249
295
  /** 删一整本书(连章节)。 */
250
296
  export async function deleteBook(root, id) {
251
297
  const rel = bookRelPath(id)
298
+ // 第二道闸:删之前亲自确认这一条真的在 books/ 里面,且不是 books 本身。
299
+ // 递归删掉的地方错一次就没有第二次机会,所以这里宁可多判一次。
300
+ if (!rel.startsWith(`${BOOK_DIR}/`) || rel === BOOK_DIR || rel.includes('..')) {
301
+ return { ok: false, error: '这个书名不对,没删。' }
302
+ }
303
+ const abs = bookAbs(root, id)
304
+ if (abs === null || abs === path.join(root, BOOK_DIR)) return { ok: false, error: '这个书名不对,没删。' }
252
305
  try {
253
- await fs.rm(path.join(root, rel), { recursive: true, force: true })
306
+ await fs.rm(abs, { recursive: true, force: true })
254
307
  } catch {
255
308
  return { ok: false, error: '这本书没能删掉。检查一下文件权限。' }
256
309
  }
@@ -434,6 +487,52 @@ export async function writeOutline(root, id, text) {
434
487
  }
435
488
 
436
489
  /** 取一章结尾的若干字(给下一章当「上一章结尾」)。 */
490
+ /**
491
+ * 把一场酒馆对话改写成小说的正文。
492
+ *
493
+ * 为什么不让它「原样搬」:屏幕上是两个人的对白,直接贴进小说里是剧本,
494
+ * 不是小说。要的是把「谁在什么时候说了什么、说的时候是什么样子」
495
+ * 变成叙述。所以要求它保留对话原意,但不许加新的情节。
496
+ */
497
+ export function buildChatToChapterPrompt({ cardName, bookTitle, turns, note }) {
498
+ const lines = []
499
+ lines.push('你在把一段已经发生过的对话,改写成小说的正文。')
500
+ lines.push('')
501
+ lines.push('## 对话里的人')
502
+ lines.push(`- ${String(cardName ?? '').trim() === '' ? '对方' : cardName}(对话里的「TA」)`)
503
+ lines.push('- 「我」(对话里的「我」,在小说里也用「我」)')
504
+ lines.push('')
505
+
506
+ lines.push('## 这一场谈了什么')
507
+ for (const turn of turns) {
508
+ const who = String(turn?.who ?? '').trim() === '我' ? '我' : (String(cardName ?? '').trim() === '' ? 'TA' : cardName)
509
+ lines.push(`${who}:${String(turn?.text ?? '').trim()}`)
510
+ }
511
+ lines.push('')
512
+
513
+ if (String(note ?? '').trim() !== '') {
514
+ lines.push('## 这一章要往哪走')
515
+ lines.push(String(note).trim())
516
+ lines.push('')
517
+ }
518
+
519
+ const system = [
520
+ '你是在改稿,不是在下判断。只说要做的事,不要评价这段对话好不好。',
521
+ '',
522
+ '硬要求:',
523
+ '1. 写成小说正文,不是剧本。不要「A:xxx」这种一行一句的形式,谁说了什么要揉进叙述里。',
524
+ '2. 对话的意思要保住。别删掉关键的那几句,也别替他们加没说过的内容。',
525
+ '3. 可以把动作、停顿、屋里的样子补出来——只补「当时本来就在那儿」的东西。',
526
+ '4. 不许加新的情节,不许替角色做决定,不许在结尾下判断或总结道理。',
527
+ '5. 写 800 到 1500 字。不要小标题,不要分段编号,不要写「本章完」。',
528
+ '6. 不要用「命运的齿轮」「不禁」「仿佛」这类词。',
529
+ '',
530
+ `这本书叫《${String(bookTitle ?? '').trim() === '' ? '没起名' : bookTitle}》。`,
531
+ ].join('\n')
532
+
533
+ return { system, user: lines.join('\n') }
534
+ }
535
+
437
536
  export function tailOf(body, chars = 600) {
438
537
  const text = String(body ?? '').trim()
439
538
  if (text.length <= chars) return text
package/lib/presets.js ADDED
@@ -0,0 +1,424 @@
1
+ /**
2
+ * presets —— 三种玩法各自的「风格预设」。
3
+ *
4
+ * 为什么要预设:同一个模型,给它不同的演法要求,出来的东西差别很大。
5
+ * 但多数人不知道该怎么写这种要求,所以内置几套能直接用的,选一个就行;
6
+ * 想改的话复制一份到自己的文件夹里改,不影响内置的。
7
+ *
8
+ * 三套对应的玩法:
9
+ * tavern —— 酒馆:和角色你一句我一句地演
10
+ * novel —— 写小说:一章一章往下写
11
+ * game —— 文字小说游戏:进到某段情节里,你来选,它来推
12
+ *
13
+ * 内置的只读;用户复制出来的存在工作区 `presets/<id>.md`,能改能删。
14
+ */
15
+
16
+ import fs from 'node:fs/promises'
17
+ import path from 'node:path'
18
+ import * as core from './core.js'
19
+
20
+ /** 自定义预设放在工作区的哪个文件夹。 */
21
+ export const PRESET_DIR = 'presets'
22
+
23
+ /** 三种玩法。界面按这个顺序摆。 */
24
+ export const MODES = [
25
+ { id: 'tavern', name: '酒馆', desc: '和 TA 你一句我一句地演' },
26
+ { id: 'novel', name: '写小说', desc: '一章一章往下写' },
27
+ { id: 'game', name: '文字小说游戏', desc: '进到情节里,你来选,它来推' },
28
+ ]
29
+
30
+ /**
31
+ * 内置预设。
32
+ *
33
+ * `prompt` 是一段会拼进系统提示词的演法要求。写的时候刻意避免
34
+ * 「你要写得生动」「要有画面感」这类说法——模型听到这种话只会加形容词。
35
+ * 有效的说法是具体到能照做:句子多长、能不能写心理、什么时候停。
36
+ */
37
+ export const BUILTIN = [
38
+ // ── 酒馆:角色扮演 ──────────────────────────────
39
+ {
40
+ id: 'tavern-slow',
41
+ mode: 'tavern',
42
+ name: '慢热',
43
+ desc: '有距离感,不主动交心,关系要一点一点磨',
44
+ prompt: [
45
+ '你演的人有距离感。',
46
+ '- 刚认识的时候话少,回答偏短,不主动问对方的事。',
47
+ '- 不会一上来就示好、也不会一上来就敌视,保持礼貌但疏远。',
48
+ '- 信任是一点一点给的:对方做了什么具体的事,你才松一点;没发生的事不认。',
49
+ '- 心里有想法也不直说,用动作或一句别的话带过去。',
50
+ '- 不解释自己为什么这样。',
51
+ ].join('\n'),
52
+ },
53
+ {
54
+ id: 'tavern-blunt',
55
+ mode: 'tavern',
56
+ name: '直球',
57
+ desc: '想什么说什么,不绕弯子',
58
+ prompt: [
59
+ '你演的人说话直接。',
60
+ '- 有想法就说,不铺垫、不试探、不用「可能吧」这种话。',
61
+ '- 不同意就说不同意,并说出理由。',
62
+ '- 不为了照顾对方情绪而含糊,但也不故意伤人。',
63
+ '- 句子短。不用「其实」「说起来」「怎么说呢」这类缓冲词。',
64
+ ].join('\n'),
65
+ },
66
+ {
67
+ id: 'tavern-sharp',
68
+ mode: 'tavern',
69
+ name: '嘴不饶人',
70
+ desc: '话里有刺,但刺底下有真东西',
71
+ prompt: [
72
+ '你演的人嘴很利。',
73
+ '- 习惯先挑对方的说法反驳一句,再往下说。',
74
+ '- 讽刺是本能,但讽刺完往往补一句说到点上的实话。',
75
+ '- 不用脏话,靠措辞让人不舒服。',
76
+ '- 被戳到痛处时会突然安静一句,然后转开。',
77
+ '- 不承认自己在意什么。',
78
+ ].join('\n'),
79
+ },
80
+ {
81
+ id: 'tavern-plot',
82
+ mode: 'tavern',
83
+ name: '主动起事',
84
+ desc: '会自己推动事情,不干等着对方开口',
85
+ prompt: [
86
+ '你演的人会主动把事往前推。',
87
+ '- 每隔一两轮就要做一件具体的事:起身、拿出东西、提议去哪、说起一个人。',
88
+ '- 会提出对方没想过的问题,并追问。',
89
+ '- 身边的事在变化:外面在下雨、有人敲门、电话响了——让环境也参与进来。',
90
+ '- 但不要替对方决定任何事。',
91
+ ].join('\n'),
92
+ },
93
+ {
94
+ id: 'tavern-daily',
95
+ mode: 'tavern',
96
+ name: '过日子',
97
+ desc: '没什么大事,就是日常,慢但耐看',
98
+ prompt: [
99
+ '你演的人和对方在过平常日子。',
100
+ '- 话题放在具体的小事上:吃了什么、东西放哪了、今天累不累。',
101
+ '- 不制造冲突,也不刻意升华。',
102
+ '- 偶尔有一句没头没尾的话,说完就过。',
103
+ '- 允许沉默和停顿,不用每句都接得很满。',
104
+ ].join('\n'),
105
+ },
106
+
107
+ // ── 写小说 ────────────────────────────────────
108
+ {
109
+ id: 'novel-plain',
110
+ mode: 'novel',
111
+ name: '冷峻白描',
112
+ desc: '短句、少形容词、只写看得见的',
113
+ prompt: [
114
+ '这一段用冷峻的白描写。',
115
+ '- 句子偏短。一句话说一件事。',
116
+ '- 少用形容词和副词。写「他把碗推过去」,不写「他温柔地把碗推过去」。',
117
+ '- 只写看得见、听得到的。不写「他心里一阵悲凉」这种,写他做了什么。',
118
+ '- 对话不加解释性的提示语,少写「他愤怒地说」。',
119
+ '- 段落之间留空,不要每段都填满。',
120
+ ].join('\n'),
121
+ },
122
+ {
123
+ id: 'novel-essay',
124
+ mode: 'novel',
125
+ name: '散文化',
126
+ desc: '松一点,写气息和物,事情不必紧',
127
+ prompt: [
128
+ '这一段用散文化的笔法写。',
129
+ '- 情节可以松,允许写与主线无关的物、气味、天气、人的习惯动作。',
130
+ '- 句子长短交错,有一两句长句把气带住。',
131
+ '- 情绪不点破,靠物件和动作带出来。',
132
+ '- 结尾不要收得太齐,留一点没说完的。',
133
+ ].join('\n'),
134
+ },
135
+ {
136
+ id: 'novel-suspense',
137
+ mode: 'novel',
138
+ name: '悬疑推进',
139
+ desc: '信息一点点给,每段留一个钩子',
140
+ prompt: [
141
+ '这一段按悬疑的节奏写。',
142
+ '- 信息控制:这一段只揭开一部分,剩下的压住。',
143
+ '- 每个段落结尾留一个未解的细节,让人想往下看。',
144
+ '- 关键信息通过具体的物和动作露出,不要让人物直接说出来。',
145
+ '- 不解释动机,让人从行为里自己判断。',
146
+ '- 不用「他不知道,这将改变一切」这类旁白预告。',
147
+ ].join('\n'),
148
+ },
149
+ {
150
+ id: 'novel-dialogue',
151
+ mode: 'novel',
152
+ name: '对话为主',
153
+ desc: '靠说话推进,描写只留必要的一两句',
154
+ prompt: [
155
+ '这一段主要靠对话推进。',
156
+ '- 对话占七成以上。描写只在必要的地方出现(谁在哪、在做什么)。',
157
+ '- 人物说话方式要能分辨出来:用词、句长、口头习惯不同。',
158
+ '- 允许答非所问、打断、说半句。真实对话不总是接得上的。',
159
+ '- 不写「他意味深长地说」这种提示语。',
160
+ ].join('\n'),
161
+ },
162
+ {
163
+ id: 'novel-first',
164
+ mode: 'novel',
165
+ name: '第一人称',
166
+ desc: '贴着「我」的眼睛写,只写我知道的',
167
+ prompt: [
168
+ '这一段用第一人称「我」写。',
169
+ '- 只写「我」看得到、听得到、想得到的东西。',
170
+ '- 「我」不知道的事就是不写,不要跳到别人脑子里。',
171
+ '- 允许「我」判断错、记错、漏掉。',
172
+ '- 语言带上这个「我」的身份痕迹:学历、地方、年纪会影响用词。',
173
+ ].join('\n'),
174
+ },
175
+
176
+ // ── 文字小说游戏 ──────────────────────────────
177
+ {
178
+ id: 'game-detect',
179
+ mode: 'game',
180
+ name: '查一件事',
181
+ desc: '找线索、拼线索,最后指认',
182
+ prompt: [
183
+ '你在主持一个查案的故事。',
184
+ '- 每一轮给出一段场景,和一个正在发生的问题。',
185
+ '- 线索要藏在具体的物和话里,不要直接给结论。',
186
+ '- 玩家选错不会立刻死,但会让后面更难。',
187
+ '- 至少三条线索指向真相,缺一条也能推出来但会很险。',
188
+ ].join('\n'),
189
+ },
190
+ {
191
+ id: 'game-survive',
192
+ mode: 'game',
193
+ name: '撑下去',
194
+ desc: '资源有限,每天都要做取舍',
195
+ prompt: [
196
+ '你在主持一个求生的故事。',
197
+ '- 给出三个能实际计数的东西:时间、体力、还有一件有限的物资。',
198
+ '- 每个选择都要消耗其中至少一样。',
199
+ '- 不能让玩家什么都保住,必须逼他放弃点什么。',
200
+ '- 状态写清楚:现在还剩多少,别含糊。',
201
+ ].join('\n'),
202
+ },
203
+ {
204
+ id: 'game-growth',
205
+ mode: 'game',
206
+ name: '慢慢长起来',
207
+ desc: '一段时间一段地过,看人怎么变',
208
+ prompt: [
209
+ '你在主持一个成长的故事。',
210
+ '- 每一轮推进一段时间(几天、几个月),不是一个场景。',
211
+ '- 变化体现在具体的技能、关系、处境上,不是抽象的「成长了」。',
212
+ '- 每次给三个方向让玩家选,每个方向都有代价。',
213
+ '- 允许失败,失败留下痕迹,不是重来一次就没了。',
214
+ ].join('\n'),
215
+ },
216
+ {
217
+ id: 'game-relation',
218
+ mode: 'game',
219
+ name: '人和人之间',
220
+ desc: '看关系怎么变,没有外敌',
221
+ prompt: [
222
+ '你在主持一个关系的故事。',
223
+ '- 没有外部敌人,冲突来自人和人之间说不出口的东西。',
224
+ '- 每轮发生一件小事,让某两个人的距离变近或变远。',
225
+ '- 玩家的选择必须是「说什么」和「不说什么」,不是打斗。',
226
+ '- 关系有具体刻度:可以明说现在到哪一步了。',
227
+ ].join('\n'),
228
+ },
229
+ {
230
+ id: 'game-explore',
231
+ mode: 'game',
232
+ name: '往没去过的地方走',
233
+ desc: '探索未知,地图一点点展开',
234
+ prompt: [
235
+ '你在主持一个探索的故事。',
236
+ '- 每轮揭开一块新的地方,写清看到、听到、闻到什么。',
237
+ '- 有危险但不滥用:不是每次进门都有怪。',
238
+ '- 未知的部分保持未知,不要提前解释世界的设定。',
239
+ '- 玩家身上带着的东西有限,带什么进去要付代价。',
240
+ ].join('\n'),
241
+ },
242
+ ]
243
+
244
+ /** 按玩法筛预设。 */
245
+ export function forMode(mode) {
246
+ const want = String(mode ?? '').trim()
247
+ return BUILTIN.filter((p) => p.mode === want).map((p) => ({ ...p, builtin: true }))
248
+ }
249
+
250
+ /** 找一个内置预设。 */
251
+ export function findBuiltin(id) {
252
+ const want = String(id ?? '').trim()
253
+ return BUILTIN.find((p) => p.id === want) ?? null
254
+ }
255
+
256
+ /** 自定义预设的文件路径。 */
257
+ export function presetRelPath(id) {
258
+ return path.posix.join(PRESET_DIR, `${core.safeId(id)}.md`)
259
+ }
260
+
261
+ /**
262
+ * 自定义预设的 Markdown。
263
+ *
264
+ * 用 Markdown 而不是 JSON:这是用户会手工改的东西,
265
+ * 在 Finder 里双击就能看、改错一个字也不会整个文件废掉。
266
+ */
267
+ export function renderPresetMarkdown(data) {
268
+ const head = { lifecard: core.DATA_VERSION, kind: 'preset', ...data }
269
+ const lines = ['---']
270
+ for (const [key, value] of Object.entries(head)) {
271
+ if (value === undefined || value === null) continue
272
+ if (typeof value === 'object') continue
273
+ lines.push(`${key}: ${JSON.stringify(String(value))}`)
274
+ }
275
+ lines.push('---', '', String(data?.prompt ?? '').trim(), '')
276
+ return lines.join('\n')
277
+ }
278
+
279
+ /** 读一份自定义预设。 */
280
+ export function parsePresetText(text) {
281
+ const parsed = core.parseCard(String(text ?? ''))
282
+ if (parsed.ok !== true) return { ok: false, error: '这份预设读不出来,格式不对' }
283
+ const intro = (parsed.sections ?? []).find((s) => s.title === '_intro')
284
+ return {
285
+ ok: true,
286
+ data: parsed.data ?? {},
287
+ prompt: String(intro?.body ?? '').trim(),
288
+ }
289
+ }
290
+
291
+ /** 列工作区里的自定义预设。 */
292
+ export async function listCustom(root) {
293
+ const dir = path.join(root, PRESET_DIR)
294
+ let names
295
+ try {
296
+ names = await fs.readdir(dir)
297
+ } catch (error) {
298
+ if (error?.code === 'ENOENT') return { ok: true, items: [] }
299
+ return { ok: false, error: '预设列表读不出来,检查一下文件夹权限' }
300
+ }
301
+ const items = []
302
+ for (const name of names) {
303
+ if (!name.endsWith('.md')) continue
304
+ const rel = path.posix.join(PRESET_DIR, name)
305
+ let text
306
+ try {
307
+ text = await fs.readFile(path.join(root, rel), 'utf8')
308
+ } catch {
309
+ continue
310
+ }
311
+ const got = parsePresetText(text)
312
+ if (got.ok !== true) continue
313
+ items.push({
314
+ id: String(got.data?.id ?? name.replace(/\.md$/, '')),
315
+ mode: String(got.data?.mode ?? 'tavern'),
316
+ name: String(got.data?.name ?? name.replace(/\.md$/, '')),
317
+ desc: String(got.data?.desc ?? ''),
318
+ prompt: got.prompt,
319
+ path: rel,
320
+ builtin: false,
321
+ })
322
+ }
323
+ return { ok: true, items }
324
+ }
325
+
326
+ /** 内置 + 自定义,按玩法分组。 */
327
+ export async function listAll(root) {
328
+ const custom = await listCustom(root)
329
+ if (custom.ok !== true) return custom
330
+ const out = []
331
+ for (const mode of MODES) {
332
+ out.push({
333
+ mode: mode.id,
334
+ name: mode.name,
335
+ desc: mode.desc,
336
+ items: [...forMode(mode.id), ...custom.items.filter((p) => p.mode === mode.id)],
337
+ })
338
+ }
339
+ return { ok: true, groups: out }
340
+ }
341
+
342
+ /** 拿一份预设(先找自定义,再找内置)。 */
343
+ export async function resolve(root, id) {
344
+ const want = String(id ?? '').trim()
345
+ if (want === '') return { ok: true, preset: null }
346
+ const custom = await listCustom(root)
347
+ if (custom.ok === true) {
348
+ const hit = custom.items.find((p) => p.id === want)
349
+ if (hit !== undefined) return { ok: true, preset: hit }
350
+ }
351
+ const builtin = findBuiltin(want)
352
+ if (builtin !== null) return { ok: true, preset: { ...builtin, builtin: true } }
353
+ // 找不到不报错:用户可能删了预设,但书或会话里还记着它。
354
+ return { ok: true, preset: null }
355
+ }
356
+
357
+ /** 把一份内置预设复制成自己的,可以改。 */
358
+ export async function copyBuiltin(root, id, newId) {
359
+ const builtin = findBuiltin(id)
360
+ if (builtin === null) return { ok: false, error: '没有这份预设' }
361
+ const target = String(newId ?? '').trim() === '' ? `${builtin.id}-my` : String(newId).trim()
362
+ const rel = presetRelPath(target)
363
+ const abs = path.join(root, rel)
364
+ await fs.mkdir(path.join(root, PRESET_DIR), { recursive: true })
365
+ try {
366
+ // 'wx' = 已存在就失败,不覆盖用户改过的东西
367
+ const handle = await fs.open(abs, 'wx')
368
+ try {
369
+ await handle.writeFile(renderPresetMarkdown({
370
+ id: target, mode: builtin.mode, name: `${builtin.name}(我的)`, desc: builtin.desc, prompt: builtin.prompt,
371
+ }), 'utf8')
372
+ } finally {
373
+ await handle.close()
374
+ }
375
+ } catch (error) {
376
+ if (error?.code === 'EEXIST') return { ok: false, error: '已经有一份同名的预设了,换个名字再复制' }
377
+ return { ok: false, error: '这份预设没能存下来,检查一下磁盘' }
378
+ }
379
+ return { ok: true, id: target, path: rel }
380
+ }
381
+
382
+ /** 存一份自定义预设(新建或改)。 */
383
+ export async function saveCustom(root, input = {}) {
384
+ const id = core.safeId(String(input.id ?? '').trim() === '' ? String(input.name ?? '').trim() : input.id)
385
+ if (String(id).trim() === '') return { ok: false, error: '这份预设得有个名字' }
386
+ if (findBuiltin(id) !== null) return { ok: false, error: '这个名字和内置的重了,换一个' }
387
+ const rel = presetRelPath(id)
388
+ const abs = path.join(root, rel)
389
+ await fs.mkdir(path.join(root, PRESET_DIR), { recursive: true })
390
+ const text = renderPresetMarkdown({
391
+ id,
392
+ mode: String(input.mode ?? 'tavern'),
393
+ name: String(input.name ?? id),
394
+ desc: String(input.desc ?? ''),
395
+ prompt: String(input.prompt ?? ''),
396
+ })
397
+ const tmp = `${abs}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`
398
+ try {
399
+ await fs.writeFile(tmp, text, 'utf8')
400
+ await fs.rename(tmp, abs)
401
+ } catch {
402
+ return { ok: false, error: '这份预设没能存下来,检查一下磁盘' }
403
+ }
404
+ return { ok: true, id, path: rel }
405
+ }
406
+
407
+ /** 删一份自定义预设。内置的删不了。 */
408
+ export async function deleteCustom(root, id) {
409
+ const want = String(id ?? '').trim()
410
+ if (findBuiltin(want) !== null) return { ok: false, error: '内置的预设删不了' }
411
+ try {
412
+ await fs.unlink(path.join(root, presetRelPath(want)))
413
+ } catch (error) {
414
+ if (error?.code === 'ENOENT') return { ok: true, gone: true }
415
+ return { ok: false, error: '这份预设没能删掉' }
416
+ }
417
+ return { ok: true }
418
+ }
419
+
420
+ export default {
421
+ PRESET_DIR, MODES, BUILTIN,
422
+ forMode, findBuiltin, presetRelPath, renderPresetMarkdown, parsePresetText,
423
+ listCustom, listAll, resolve, copyBuiltin, saveCustom, deleteCustom,
424
+ }