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/README.md +62 -1
- package/lib/career.js +488 -0
- package/lib/client.js +699 -33
- package/lib/game.js +373 -0
- package/lib/index.js +558 -35
- package/lib/novel.js +109 -10
- package/lib/presets.js +424 -0
- package/lib/store.js +86 -15
- package/lib/tavern.js +56 -1
- package/package.json +5 -2
package/lib/store.js
CHANGED
|
@@ -109,14 +109,38 @@ export async function openWorkspace(root, options = {}) {
|
|
|
109
109
|
return { ok: true, root: abs, exists: true }
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
/** 本地时间戳,给「覆盖前备份」这种文件名用。 */
|
|
113
|
+
function freeLocalStamp(now = new Date()) {
|
|
114
|
+
const pad = (n) => String(n).padStart(2, '0')
|
|
115
|
+
return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
|
|
116
|
+
}
|
|
117
|
+
|
|
112
118
|
/** 读工作区配置;没有就给默认值(不写盘)。 */
|
|
113
119
|
export async function readConfig(root) {
|
|
120
|
+
return (await readConfigState(root)).config
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 读配置,并且**告诉调用方「是没有,还是读不出来」**。
|
|
125
|
+
*
|
|
126
|
+
* 以前只有 `readConfig` 一个口子:任何异常都 `return defaultConfig()`。
|
|
127
|
+
* 于是 config.json 一旦读不出来(权限、损坏、同步盘没挂上),
|
|
128
|
+
* 紧跟着的一次 `writeConfig` 会把整份配置按默认值重写回去 —— 用户的
|
|
129
|
+
* 「想要什么样的人」「看过介绍了没有」全部归零,而且没人知道。
|
|
130
|
+
*/
|
|
131
|
+
export async function readConfigState(root) {
|
|
132
|
+
let text
|
|
133
|
+
try {
|
|
134
|
+
text = await fs.readFile(path.join(root, CONFIG_NAME), 'utf8')
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (error?.code === 'ENOENT') return { ok: true, exists: false, config: defaultConfig() }
|
|
137
|
+
return { ok: false, exists: false, config: defaultConfig(), error: '这个文件夹里的设置读不出来。检查一下文件权限,再试一次。' }
|
|
138
|
+
}
|
|
114
139
|
try {
|
|
115
|
-
const text = await fs.readFile(path.join(root, CONFIG_NAME), 'utf8')
|
|
116
140
|
const json = JSON.parse(text)
|
|
117
|
-
return { ...defaultConfig(), ...json }
|
|
141
|
+
return { ok: true, exists: true, config: { ...defaultConfig(), ...json } }
|
|
118
142
|
} catch {
|
|
119
|
-
return defaultConfig()
|
|
143
|
+
return { ok: false, exists: true, config: defaultConfig(), error: '这个文件夹里的设置文件坏了。这一版没敢动它,先跳过。' }
|
|
120
144
|
}
|
|
121
145
|
}
|
|
122
146
|
|
|
@@ -127,10 +151,19 @@ export async function readConfig(root) {
|
|
|
127
151
|
* 界面拿到的是一句英文报错,人看不出「你还没选文件夹」。
|
|
128
152
|
*/
|
|
129
153
|
export async function writeConfig(root, patch) {
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
154
|
+
const state = await readConfigState(root)
|
|
155
|
+
// 「读不出来」不等于「还没有」:这时候照写会把整份设置按默认值重刷一遍。
|
|
156
|
+
if (state.ok !== true) return state.config
|
|
157
|
+
const next = { ...state.config, ...patch }
|
|
158
|
+
const abs = path.join(root, CONFIG_NAME)
|
|
159
|
+
const tmp = `${abs}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`
|
|
160
|
+
try {
|
|
161
|
+
await fs.mkdir(root, { recursive: true })
|
|
162
|
+
await fs.writeFile(tmp, JSON.stringify(next, null, 2) + '\n', 'utf8')
|
|
163
|
+
await fs.rename(tmp, abs)
|
|
164
|
+
} catch {
|
|
165
|
+
await fs.rm(tmp, { force: true }).catch(() => {})
|
|
166
|
+
}
|
|
134
167
|
return next
|
|
135
168
|
}
|
|
136
169
|
|
|
@@ -275,7 +308,10 @@ export async function writeDraft(root, cardId, draft) {
|
|
|
275
308
|
avatar_touched: draft?.avatarTouched === true,
|
|
276
309
|
...(draft?.avatarTouched === true ? { avatar: draft?.avatar ?? null } : {}),
|
|
277
310
|
}
|
|
278
|
-
|
|
311
|
+
// 草稿也是「写一半掉电就废」的那种文件,跟卡片一样走 tmp+rename。
|
|
312
|
+
const tmp = `${abs}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`
|
|
313
|
+
await fs.writeFile(tmp, JSON.stringify(payload, null, 2) + '\n', 'utf8')
|
|
314
|
+
await fs.rename(tmp, abs)
|
|
279
315
|
return { ok: true, draft: payload }
|
|
280
316
|
}
|
|
281
317
|
|
|
@@ -473,8 +509,17 @@ export async function readCard(root, relPath) {
|
|
|
473
509
|
let text
|
|
474
510
|
try {
|
|
475
511
|
text = await fs.readFile(abs, 'utf8')
|
|
476
|
-
} catch {
|
|
477
|
-
|
|
512
|
+
} catch (error) {
|
|
513
|
+
// 「没有这个文件」和「有,但读不出来」是两件事。以前一律翻成「文件不存在」,
|
|
514
|
+
// 上层于是把权限问题、同步盘没挂上当「还没有这张卡」——`card.save` 的兜底
|
|
515
|
+
// 会退化成空小节,`applyProposal` 会另建一张同名的卡。
|
|
516
|
+
if (error?.code === 'ENOENT') return { ok: false, path: relPath, error: '文件不存在' }
|
|
517
|
+
return {
|
|
518
|
+
ok: false,
|
|
519
|
+
path: relPath,
|
|
520
|
+
code: String(error?.code ?? ''),
|
|
521
|
+
error: '这个文件读不出来(可能被占用,或者没有读取权限)。关掉占着它的程序,或者检查一下权限。',
|
|
522
|
+
}
|
|
478
523
|
}
|
|
479
524
|
const redactions = await readRedactions(root)
|
|
480
525
|
const hit = hitsRedaction(redactions, relPath, text)
|
|
@@ -549,7 +594,17 @@ export async function writeCard(root, relPath, data, sections, options = {}) {
|
|
|
549
594
|
}
|
|
550
595
|
}
|
|
551
596
|
const text = core.stringifyCard(data, sections)
|
|
552
|
-
|
|
597
|
+
// 落盘要走「临时文件 + 改名」。以前这里是 fs.writeFile 直接截断写:
|
|
598
|
+
// 写一半掉电(或宿主把进程杀掉),留下的就是半张卡 —— parseCard 读不出来,
|
|
599
|
+
// 下次打开是「这个文件读不出来」。卡是全库最要紧的那份数据,
|
|
600
|
+
// 别的存档层(tavern/novel/game/presets/career)早就都改了,只有它漏着。
|
|
601
|
+
try {
|
|
602
|
+
const tmp = `${abs}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`
|
|
603
|
+
await fs.writeFile(tmp, text, 'utf8')
|
|
604
|
+
await fs.rename(tmp, abs)
|
|
605
|
+
} catch {
|
|
606
|
+
return { ok: false, path: relPath, error: '这一版没能存进文件里。检查一下磁盘是不是满了,再试一次。' }
|
|
607
|
+
}
|
|
553
608
|
return { ok: true, path: relPath, text, validation }
|
|
554
609
|
}
|
|
555
610
|
|
|
@@ -761,8 +816,20 @@ export async function appendMaterial(root, name, content, options = {}) {
|
|
|
761
816
|
// 但有些材料是「重新摘一遍」而不是「再补一条」——比如从记忆里重新提取
|
|
762
817
|
// 某个人。那种情况必须覆盖,否则同一个人每点一次就多一份重复的旧摘录,
|
|
763
818
|
// 而 AI 读的时候会把两份都当真,同一个事实被引用两次。
|
|
764
|
-
if (options?.replace === true)
|
|
765
|
-
|
|
819
|
+
if (options?.replace === true) {
|
|
820
|
+
// 材料是全库唯一**没有快照**的存档层(删了就真没了)。覆盖之前先留一份
|
|
821
|
+
// `.bak-<时间>`,至少用户自己手改过的那一版还能找回来。
|
|
822
|
+
const had = await fs.stat(abs).then((s) => s.isFile()).catch(() => false)
|
|
823
|
+
if (had) {
|
|
824
|
+
const bak = `${abs}.bak-${freeLocalStamp()}`
|
|
825
|
+
await fs.copyFile(abs, bak).catch(() => {})
|
|
826
|
+
}
|
|
827
|
+
const tmp = `${abs}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`
|
|
828
|
+
await fs.writeFile(tmp, body, 'utf8')
|
|
829
|
+
await fs.rename(tmp, abs)
|
|
830
|
+
} else {
|
|
831
|
+
await fs.appendFile(abs, body, 'utf8')
|
|
832
|
+
}
|
|
766
833
|
const full = await fs.readFile(abs, 'utf8')
|
|
767
834
|
return { ok: true, path: rel, sha256: sha256(full) }
|
|
768
835
|
}
|
|
@@ -1278,6 +1345,11 @@ export async function applyProposal(root, jsonRel, options = {}) {
|
|
|
1278
1345
|
if (!read.ok) return read
|
|
1279
1346
|
const aboutId = read.record?.about?.id
|
|
1280
1347
|
if (typeof aboutId !== 'string' || aboutId === '') return { ok: false, error: '提案里没有 about.id' }
|
|
1348
|
+
// 「一条都没勾」要在建卡之前判掉。以前是先建卡再判,于是点了采纳却又
|
|
1349
|
+
// 什么都没勾时,工作区里会多出一张空卡,界面上只看到一句「还没有勾选任何一条」——
|
|
1350
|
+
// 用户根本不知道刚才那次点击留下了一张空壳。
|
|
1351
|
+
const chosenFirst = read.items.filter((i) => i.checked)
|
|
1352
|
+
if (chosenFirst.length === 0) return { ok: false, error: '还没有勾选任何一条。先点一下要留下的条目。' }
|
|
1281
1353
|
let target = await findCardByRelOrId(root, aboutId)
|
|
1282
1354
|
if (target === null) {
|
|
1283
1355
|
// 卡还不存在——这正是「说一大段话,让它整理成一张卡」的正常情况。
|
|
@@ -1298,8 +1370,7 @@ export async function applyProposal(root, jsonRel, options = {}) {
|
|
|
1298
1370
|
}
|
|
1299
1371
|
const card = await readCard(root, target)
|
|
1300
1372
|
if (!card.ok) return card
|
|
1301
|
-
const chosen =
|
|
1302
|
-
if (chosen.length === 0) return { ok: false, error: '还没有勾选任何一条。先点一下要留下的条目。' }
|
|
1373
|
+
const chosen = chosenFirst
|
|
1303
1374
|
|
|
1304
1375
|
// 写进卡之前,引文要对着**现在**的材料再验一遍。
|
|
1305
1376
|
//
|
package/lib/tavern.js
CHANGED
|
@@ -347,7 +347,7 @@ export function pickWorldEntries(entries, recentText) {
|
|
|
347
347
|
* 刻意写死几句约束:不替对方说话、不跳出角色解释、不加括号注释。
|
|
348
348
|
* 这三样是角色扮演最容易崩的地方,不写清楚模型每次都会犯。
|
|
349
349
|
*/
|
|
350
|
-
export function buildRoleplaySystem({ cardName, persona, worldNotes, memory }) {
|
|
350
|
+
export function buildRoleplaySystem({ cardName, persona, worldNotes, memory, preset }) {
|
|
351
351
|
const name = cardName === '' ? '这个人' : cardName
|
|
352
352
|
const lines = [
|
|
353
353
|
`你正在演一个角色扮演对话。你只演「${name}」这一个人,对方是「我」。`,
|
|
@@ -376,6 +376,10 @@ export function buildRoleplaySystem({ cardName, persona, worldNotes, memory }) {
|
|
|
376
376
|
'',
|
|
377
377
|
'直接写你要说的话,不要写「' + name + ':」这样的前缀。',
|
|
378
378
|
)
|
|
379
|
+
// 演法预设排在最后:它是「这一次想怎么演」,比上面那些通用约束更具体,
|
|
380
|
+
// 放前面会被一长串规则冲淡。
|
|
381
|
+
const flavour = String(preset ?? '').trim()
|
|
382
|
+
if (flavour !== '') lines.push('', '【这一次的演法】', flavour)
|
|
379
383
|
return lines.join('\n')
|
|
380
384
|
}
|
|
381
385
|
|
|
@@ -425,6 +429,56 @@ export function buildSummaryPrompt({ cardName, turns }) {
|
|
|
425
429
|
return { system, user }
|
|
426
430
|
}
|
|
427
431
|
|
|
432
|
+
/**
|
|
433
|
+
* 「把这一场里最能代表 TA 的几句挑出来」要发给模型的话。
|
|
434
|
+
*
|
|
435
|
+
* 挑出来之后会写进这张卡的「对话示例」小节。这一小节是下次演 TA 时
|
|
436
|
+
* 最有效的材料之一:比「性格:沉默寡言」有用得多,因为它是真的话。
|
|
437
|
+
*/
|
|
438
|
+
export function buildExamplePrompt({ cardName, turns }) {
|
|
439
|
+
const name = cardName === '' ? '这个人' : cardName
|
|
440
|
+
const lines = []
|
|
441
|
+
for (const turn of Array.isArray(turns) ? turns : []) {
|
|
442
|
+
const who = String(turn?.who ?? '').trim()
|
|
443
|
+
const text = String(turn?.text ?? '').trim()
|
|
444
|
+
if (who === '' || text === '') continue
|
|
445
|
+
lines.push(`${who}:${text}`)
|
|
446
|
+
}
|
|
447
|
+
const system = [
|
|
448
|
+
`你要从一段对话里挑出最能代表「${name}」怎么说话的几句。`,
|
|
449
|
+
'挑的是**TA 说的话**,不是「我」说的。',
|
|
450
|
+
'挑四到八句。短句优先。要能看出这个人说话的节奏、用词习惯、回避什么、什么时候会突然多说两句。',
|
|
451
|
+
'每句原样抄下来,一个字都不要改,不要加解释,不要加编号。',
|
|
452
|
+
'一行一句,前面写「TA:」。挑不出来就只输出一个「无」。',
|
|
453
|
+
].join('\n')
|
|
454
|
+
const user = `扮演的是「${name}」,对面是「我」。下面是这一段对话:\n\n${lines.join('\n')}\n\n请挑出最能代表 TA 说话方式的几句。`
|
|
455
|
+
return { system, user }
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* 把模型挑出来的那几行收干净。
|
|
460
|
+
*
|
|
461
|
+
* 它会不老实:加编号、加「TA:」以外的前缀、加解释、或者干脆用引号包起来。
|
|
462
|
+
* 这里只认「TA:」后面的内容,别的都丢掉;同一句重复的只留一次。
|
|
463
|
+
*/
|
|
464
|
+
export function cleanExamples(text, limit = 8) {
|
|
465
|
+
const out = []
|
|
466
|
+
const seen = new Set()
|
|
467
|
+
for (const raw of String(text ?? '').split(/\r?\n/)) {
|
|
468
|
+
let line = raw.trim()
|
|
469
|
+
if (line === '' || line === '无' || line === '沒有' || line === '没有') continue
|
|
470
|
+
line = line.replace(/^[-*•\d.、.)\s]+/, '')
|
|
471
|
+
line = line.replace(/^(TA|ta|Ta|她|他)\s*[::]\s*/, '')
|
|
472
|
+
line = line.replace(/^[「“"']|[」”"']$/g, '').trim()
|
|
473
|
+
if (line === '' || line.length < 2) continue
|
|
474
|
+
if (seen.has(line)) continue
|
|
475
|
+
seen.add(line)
|
|
476
|
+
out.push(line)
|
|
477
|
+
if (out.length >= limit) break
|
|
478
|
+
}
|
|
479
|
+
return out
|
|
480
|
+
}
|
|
481
|
+
|
|
428
482
|
export function recentContextText(turns) {
|
|
429
483
|
return (Array.isArray(turns) ? turns : [])
|
|
430
484
|
.slice(-6)
|
|
@@ -442,6 +496,7 @@ export default {
|
|
|
442
496
|
MEMORY_TITLE,
|
|
443
497
|
MIN_TURNS_TO_SUMMARIZE,
|
|
444
498
|
buildSummaryPrompt,
|
|
499
|
+
buildExamplePrompt, cleanExamples,
|
|
445
500
|
chatRelPath,
|
|
446
501
|
newChatId,
|
|
447
502
|
renderChatMarkdown,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-lifecard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Life Cards — 把你认识的人和虚构角色写成档案。在对话里问出来,或导入酒馆角色卡;数据是你自己文件夹里的一组 Markdown 文件。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"icon": "icon.svg",
|
|
@@ -33,7 +33,10 @@
|
|
|
33
33
|
"lib/jobs-store.js",
|
|
34
34
|
"lib/jobs-interview.js",
|
|
35
35
|
"lib/tavern.js",
|
|
36
|
-
"lib/novel.js"
|
|
36
|
+
"lib/novel.js",
|
|
37
|
+
"lib/career.js",
|
|
38
|
+
"lib/presets.js",
|
|
39
|
+
"lib/game.js"
|
|
37
40
|
],
|
|
38
41
|
"dsh": {
|
|
39
42
|
"bundle": {
|