@xia-sc/dsh-cc-studio 0.3.4 → 0.3.6
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 +48 -20
- package/lib/agent.js +80 -37
- package/lib/client.js +153 -23
- package/lib/index.js +258 -64
- package/package.json +6 -3
- package/presets/cc.patch.yml +328 -0
package/lib/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* CCv3 工坊:校验 + 共享草稿(供 CC 模式的 LLM Tools 与浏览器胶囊同步)+ 已存角色侧边栏落盘。
|
|
4
4
|
*/
|
|
5
5
|
import { mkdir, readdir, readFile, writeFile, unlink } from "node:fs/promises";
|
|
6
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { createHash } from "node:crypto";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
import { join } from "node:path";
|
|
@@ -98,11 +98,30 @@ function makeDefaultDraft(){
|
|
|
98
98
|
};
|
|
99
99
|
}
|
|
100
100
|
if(!globalThis.__CC_DRAFTS__) globalThis.__CC_DRAFTS__ = new Map();
|
|
101
|
-
// ——
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
101
|
+
// —— 落盘根(0.3.6 起统一):DSH_HOME 优先,homedir()/.dsh 兜底 ——
|
|
102
|
+
// 预设目录一直走这里;草稿与角色库在 0.3.5 及以前写死 homedir()/.dsh,现在统一到同一套根,
|
|
103
|
+
// 但**读盘必须回退旧根** —— 否则自定义了 DSH_HOME 的用户升级后会看到「草稿和角色卡全没了」。
|
|
104
|
+
function legacyDshHomeDir(){
|
|
105
|
+
try{ return join(homedir(), ".dsh"); }catch{ return join(process.cwd(), ".dsh"); }
|
|
106
|
+
}
|
|
107
|
+
function dshHomeDir(){
|
|
108
|
+
const env = process.env.DSH_HOME;
|
|
109
|
+
if(typeof env === "string" && env.trim().length > 0) return env.trim();
|
|
110
|
+
return legacyDshHomeDir();
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* 某个子目录的候选根:`[主根, 旧根]`。写盘只写 candidates[0],读盘依次回退。
|
|
114
|
+
* `DSH_HOME` 未设置时两个根是同一个目录 → 去重成一条,**默认安装的行为与 0.3.5 完全一致**。
|
|
115
|
+
*/
|
|
116
|
+
function dataRoots(sub){
|
|
117
|
+
const primary = join(dshHomeDir(), sub);
|
|
118
|
+
const legacy = join(legacyDshHomeDir(), sub);
|
|
119
|
+
return primary === legacy ? [primary] : [primary, legacy];
|
|
105
120
|
}
|
|
121
|
+
// —— 草稿持久化(fix #2):<主根>/cc-drafts/<session>.json,变更即落盘,重启后按会话 id 恢复 ——
|
|
122
|
+
if(!globalThis.__CC_DRAFT_META__) globalThis.__CC_DRAFT_META__ = new Map();
|
|
123
|
+
function draftDirs(){ return dataRoots("cc-drafts"); }
|
|
124
|
+
function draftDir(){ return draftDirs()[0]; }
|
|
106
125
|
function safeDraftFile(key){
|
|
107
126
|
const raw = String(key || "default");
|
|
108
127
|
let h = 0;
|
|
@@ -112,9 +131,20 @@ function safeDraftFile(key){
|
|
|
112
131
|
return safe + "-" + hex + ".json";
|
|
113
132
|
}
|
|
114
133
|
function draftPath(key){ return join(draftDir(), safeDraftFile(key)); }
|
|
134
|
+
/** 读盘用:主根没有就回退旧根(0.3.6 之前草稿写死的地方)。找不到返回 null。 */
|
|
135
|
+
function findDraftPath(key){
|
|
136
|
+
const file = safeDraftFile(key);
|
|
137
|
+
for(const dir of draftDirs()){
|
|
138
|
+
const p = join(dir, file);
|
|
139
|
+
try{ if(existsSync(p)) return p; }catch{}
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
115
143
|
function loadPersistedDraftSync(key){
|
|
116
144
|
try{
|
|
117
|
-
const
|
|
145
|
+
const p = findDraftPath(key);
|
|
146
|
+
if(!p) return null;
|
|
147
|
+
const raw = readFileSync(p, "utf8");
|
|
118
148
|
const obj = JSON.parse(raw);
|
|
119
149
|
const d = obj && obj.draft ? obj.draft : null;
|
|
120
150
|
if(d && typeof d === "object" && d.spec === "chara_card_v3" && d.data && typeof d.data === "object") return d;
|
|
@@ -136,18 +166,94 @@ function draftStatusFor(key, draft){
|
|
|
136
166
|
const m = draftMeta(key);
|
|
137
167
|
return { isNew: !!m.isNew, recovered: !!m.recovered, creation_date: (draft && draft.data) ? draft.data.creation_date : null, key: String(key) };
|
|
138
168
|
}
|
|
139
|
-
|
|
140
|
-
|
|
169
|
+
/**
|
|
170
|
+
* 解析「这次调用读写哪把草稿槽」,并把来源一并带出来(诊断用)。
|
|
171
|
+
*
|
|
172
|
+
* 槽 key 的不变量:**同一会话里,Tools 与浏览器必须算出同一个 key**。
|
|
173
|
+
* Tools 侧(lib/agent.js 的 keyFor)拿的是 `exec.agent.session.id`;浏览器侧要么把会话 id
|
|
174
|
+
* 明确传过来,要么这里回退到 `agents.currentInitiator()` —— 但 RPC 是 HTTP 处理函数,
|
|
175
|
+
* 没有 agent 上下文,currentInitiator() 恒为空,于是旧逻辑回退到 "default"。
|
|
176
|
+
* 结果就是 issue #5:模型写 `session-<id>`、前端读 `default`,两槽并存、互相"看不见"。
|
|
177
|
+
* 所以这里做两件事:① 把前端可能误传的会话快照对象认出来;② 回传 source 让上层能把
|
|
178
|
+
* 「这次是回退到 default 了」这件事报给前端,而不是静默建一个空槽。
|
|
179
|
+
*/
|
|
180
|
+
function draftKeyPartsFrom(ctx, args){
|
|
181
|
+
const raw = args ? args.sessionId : null;
|
|
182
|
+
if(typeof raw === "string" && raw) return { key: raw, source: "arg" };
|
|
183
|
+
// 前端历史上可能把整个会话快照对象传进来(SessionSnapshot 的 id 在 .sessionId 上)。
|
|
184
|
+
// 旧逻辑 `typeof args.sessionId==="string"` 直接把它当「没传」丢弃 → 回退 default 槽;
|
|
185
|
+
// 若改成 String(object) 又会得到 "[object Object]" 这把假 key。两种都不对,认它的 .sessionId。
|
|
186
|
+
if(raw && typeof raw === "object" && typeof raw.sessionId === "string" && raw.sessionId)
|
|
187
|
+
return { key: raw.sessionId, source: "arg-snapshot" };
|
|
141
188
|
try{
|
|
142
189
|
const agents = ctx.get("agents");
|
|
143
190
|
const ag = agents && agents.currentInitiator && agents.currentInitiator();
|
|
144
191
|
if(ag){
|
|
145
|
-
if(ag.session && ag.session.id) return String(ag.session.id);
|
|
146
|
-
if(ag.id) return String(ag.id);
|
|
147
|
-
if(ag.sessionId) return String(ag.sessionId);
|
|
192
|
+
if(ag.session && ag.session.id) return { key: String(ag.session.id), source: "initiator" };
|
|
193
|
+
if(ag.id) return { key: String(ag.id), source: "initiator" };
|
|
194
|
+
if(ag.sessionId) return { key: String(ag.sessionId), source: "initiator" };
|
|
148
195
|
}
|
|
149
196
|
}catch{}
|
|
150
|
-
return "default";
|
|
197
|
+
return { key: "default", source: "fallback" };
|
|
198
|
+
}
|
|
199
|
+
function draftKeyFrom(ctx, args){ return draftKeyPartsFrom(ctx, args).key; }
|
|
200
|
+
/**
|
|
201
|
+
* 草稿「有没有内容」的判定(不追求 validateCard 那么严:迁移是自家两个槽之间搬东西,
|
|
202
|
+
* name 为空但 lore 写满了的草稿也算有内容,不能因此拒绝迁移)。
|
|
203
|
+
*/
|
|
204
|
+
function draftContentSummary(draft){
|
|
205
|
+
const d = (draft && draft.data && typeof draft.data === "object") ? draft.data : {};
|
|
206
|
+
const entries = (d.character_book && Array.isArray(d.character_book.entries)) ? d.character_book.entries.length : 0;
|
|
207
|
+
let filled = 0;
|
|
208
|
+
["name","description","personality","scenario","system_prompt","post_history_instructions","first_mes","mes_example","creator_notes"].forEach(function(k){
|
|
209
|
+
if(String(d[k]==null ? "" : d[k]).trim()) filled += 1;
|
|
210
|
+
});
|
|
211
|
+
if(Array.isArray(d.alternate_greetings) && d.alternate_greetings.length) filled += 1;
|
|
212
|
+
if(entries) filled += 1;
|
|
213
|
+
return { name: String(d.name==null ? "" : d.name), entries: entries, filled: filled };
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* 盘上还有哪些草稿槽(按写入时间倒序)。只用于「当前槽是空壳」时的救回提示 ——
|
|
217
|
+
* 这是 issue #5 里用户唯一能自救的入口:内容没丢,只是被写进了另一把 key。
|
|
218
|
+
* 两个根都扫:主根优先,同一把槽只报一次(主根那份解析失败时才让旧根补位)。
|
|
219
|
+
*/
|
|
220
|
+
function listDraftSlotsOnDisk(){
|
|
221
|
+
const out = [];
|
|
222
|
+
const seen = new Set();
|
|
223
|
+
for(const dir of draftDirs()){
|
|
224
|
+
let names = [];
|
|
225
|
+
try{ names = readdirSync(dir); }catch{ continue; }
|
|
226
|
+
for(const f of names){
|
|
227
|
+
if(!/\.json$/i.test(String(f))) continue;
|
|
228
|
+
if(seen.has(String(f))) continue;
|
|
229
|
+
let obj = null;
|
|
230
|
+
try{ obj = JSON.parse(readFileSync(join(dir, String(f)), "utf8")); }catch{ continue; }
|
|
231
|
+
const d = obj && obj.draft;
|
|
232
|
+
if(!d || typeof d !== "object" || d.spec !== "chara_card_v3" || !d.data || typeof d.data !== "object") continue;
|
|
233
|
+
const key = obj.key == null ? null : String(obj.key);
|
|
234
|
+
if(!key) continue;
|
|
235
|
+
seen.add(String(f));
|
|
236
|
+
const sum = draftContentSummary(d);
|
|
237
|
+
out.push({ key: key, updatedAt: Number(obj.updatedAt) || 0, name: sum.name, entries: sum.entries, hasContent: sum.filled > 0 });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
out.sort(function(a,b){ return b.updatedAt - a.updatedAt; });
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* 草稿槽迁移的纯决策函数(便于单测,与 planPresetInstall 同一套路子)。
|
|
245
|
+
* @returns {ok:true, draft, summary} 或 {ok:false, code, message, details}
|
|
246
|
+
*/
|
|
247
|
+
function migrateDraftDecision(source, target, overwrite){
|
|
248
|
+
if(!source || typeof source!=="object" || source.spec!=="chara_card_v3" || !source.data || typeof source.data!=="object")
|
|
249
|
+
return { ok:false, code:"empty-source", message:"源槽没有草稿" };
|
|
250
|
+
const srcSum = draftContentSummary(source);
|
|
251
|
+
if(srcSum.filled === 0) return { ok:false, code:"empty-source", message:"源槽草稿是空壳,无需迁移" };
|
|
252
|
+
const tgtSum = target ? draftContentSummary(target) : null;
|
|
253
|
+
// 绝不静默覆盖:目标槽已有内容时必须显式 overwrite
|
|
254
|
+
if(!overwrite && tgtSum && tgtSum.filled > 0)
|
|
255
|
+
return { ok:false, code:"target-not-empty", message:"目标槽已有内容,需 overwrite: true", details:{ target: tgtSum } };
|
|
256
|
+
return { ok:true, draft: JSON.parse(JSON.stringify(source)), summary: srcSum };
|
|
151
257
|
}
|
|
152
258
|
function getDraftFor(key){
|
|
153
259
|
const k = key || "default";
|
|
@@ -200,15 +306,24 @@ function isCcPreset(ctx, sessionId){
|
|
|
200
306
|
return false;
|
|
201
307
|
}
|
|
202
308
|
|
|
203
|
-
// —— 已存角色侧边栏:落盘到
|
|
204
|
-
function
|
|
205
|
-
|
|
206
|
-
}
|
|
309
|
+
// —— 已存角色侧边栏:落盘到 <主根>/cc-library/<id>.json(读盘回退 0.3.6 之前的 homedir()/.dsh)——
|
|
310
|
+
function libDirs(){ return dataRoots("cc-library"); }
|
|
311
|
+
function libDir(){ return libDirs()[0]; }
|
|
207
312
|
async function ensureLibDir(){
|
|
208
313
|
const dir = libDir();
|
|
209
314
|
await mkdir(dir, { recursive: true });
|
|
210
315
|
return dir;
|
|
211
316
|
}
|
|
317
|
+
/** 读盘用:主根没有就回退旧根。返回 `{path, raw}`;两边都没有时抛最后一个错误(与原 readFile 语义一致)。 */
|
|
318
|
+
async function readLibraryRaw(id){
|
|
319
|
+
let lastErr = null;
|
|
320
|
+
for(const dir of libDirs()){
|
|
321
|
+
const p = join(dir, String(id)+".json");
|
|
322
|
+
try{ return { path: p, raw: await readFile(p, "utf8") }; }
|
|
323
|
+
catch(e){ lastErr = e; }
|
|
324
|
+
}
|
|
325
|
+
throw lastErr || new Error("找不到角色卡: " + String(id));
|
|
326
|
+
}
|
|
212
327
|
// ========== PNG / CHARX 共享 ==========
|
|
213
328
|
function normalizeDraft(d){
|
|
214
329
|
if(!d || typeof d!=="object") throw new Error("draft 必须是对象");
|
|
@@ -421,26 +536,29 @@ function makeLibraryEntryMeta(draft, existingMeta, id){
|
|
|
421
536
|
};
|
|
422
537
|
}
|
|
423
538
|
async function listLibraryEntries(){
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
539
|
+
await ensureLibDir();
|
|
540
|
+
const seen = new Map(); // id → meta:先到先得,主根排在前 → 同名卡片主根优先
|
|
541
|
+
for(const dir of libDirs()){
|
|
542
|
+
let files=[];
|
|
543
|
+
try{ files = await readdir(dir); }catch{ continue; }
|
|
544
|
+
for(const f of files){
|
|
545
|
+
if(!f.endsWith(".json")) continue;
|
|
546
|
+
const id = f.replace(/\.json$/,"");
|
|
547
|
+
if(seen.has(id)) continue;
|
|
548
|
+
try{
|
|
549
|
+
const raw = await readFile(join(dir, f), "utf8");
|
|
550
|
+
const obj = JSON.parse(raw);
|
|
551
|
+
if(obj && obj.meta && obj.id){
|
|
552
|
+
// 兼容:meta.id 与文件名 id 一致
|
|
553
|
+
seen.set(id, obj.meta);
|
|
554
|
+
} else if(obj && obj.draft){
|
|
555
|
+
// 旧格式兜底:从 draft 重建 meta
|
|
556
|
+
seen.set(id, makeLibraryEntryMeta(obj.draft, null, id));
|
|
557
|
+
}
|
|
558
|
+
}catch{}
|
|
559
|
+
}
|
|
443
560
|
}
|
|
561
|
+
const entries = [...seen.values()];
|
|
444
562
|
entries.sort((a,b)=> (b.updatedAt||0)-(a.updatedAt||0));
|
|
445
563
|
return entries;
|
|
446
564
|
}
|
|
@@ -449,10 +567,8 @@ async function saveLibraryEntry(draft, opts){
|
|
|
449
567
|
let id = opts && opts.id ? String(opts.id) : "";
|
|
450
568
|
let existing = null;
|
|
451
569
|
if(id){
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
existing = JSON.parse(raw);
|
|
455
|
-
}catch{}
|
|
570
|
+
// 已存在的那份可能在旧根(0.3.6 之前保存的),读过来只为继承 createdAt / 旧 name
|
|
571
|
+
try{ existing = JSON.parse((await readLibraryRaw(id)).raw); }catch{}
|
|
456
572
|
}
|
|
457
573
|
if(!id){
|
|
458
574
|
id = String(Date.now())+"-"+Math.random().toString(36).slice(2,6);
|
|
@@ -467,29 +583,35 @@ async function saveLibraryEntry(draft, opts){
|
|
|
467
583
|
return { id, meta, draft };
|
|
468
584
|
}
|
|
469
585
|
async function loadLibraryEntry(id){
|
|
470
|
-
|
|
471
|
-
const
|
|
472
|
-
const raw = await readFile(p, "utf8");
|
|
473
|
-
const obj = JSON.parse(raw);
|
|
586
|
+
await ensureLibDir();
|
|
587
|
+
const obj = JSON.parse((await readLibraryRaw(id)).raw);
|
|
474
588
|
if(!obj || !obj.draft) throw new Error("文件格式错误");
|
|
475
589
|
return obj;
|
|
476
590
|
}
|
|
591
|
+
/**
|
|
592
|
+
* 删除:**两个根里的同名副本都要删**,否则读盘回退会把刚删掉的卡「复活」。
|
|
593
|
+
* 一个都没删到时仍然抛错(保持「删不存在的 id 是失败」的既有语义)。
|
|
594
|
+
*/
|
|
477
595
|
async function deleteLibraryEntry(id){
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
596
|
+
await ensureLibDir();
|
|
597
|
+
let removed = 0;
|
|
598
|
+
for(const dir of libDirs()){
|
|
599
|
+
try{ await unlink(join(dir, String(id)+".json")); removed += 1; }
|
|
600
|
+
catch(e){ if(e && e.code !== "ENOENT") throw e; }
|
|
601
|
+
}
|
|
602
|
+
if(removed === 0) throw Object.assign(new Error("找不到角色卡: " + String(id)), { code: "ENOENT" });
|
|
481
603
|
}
|
|
604
|
+
/** 改名是**就地改** meta.name:写在解析到的那一份上(主根优先),不搬家、不复制。 */
|
|
482
605
|
async function renameLibraryEntry(id, newName){
|
|
483
|
-
|
|
484
|
-
const
|
|
485
|
-
const
|
|
486
|
-
const obj = JSON.parse(raw);
|
|
606
|
+
await ensureLibDir();
|
|
607
|
+
const found = await readLibraryRaw(id);
|
|
608
|
+
const obj = JSON.parse(found.raw);
|
|
487
609
|
if(!obj || !obj.meta) throw new Error("文件格式错误");
|
|
488
610
|
const name = String(newName||"").trim().slice(0,60);
|
|
489
611
|
if(!name) throw new Error("name 不能为空");
|
|
490
612
|
obj.meta.name = name;
|
|
491
613
|
obj.meta.updatedAt = nowSec();
|
|
492
|
-
await writeFile(
|
|
614
|
+
await writeFile(found.path, JSON.stringify(obj, null, 2), "utf8");
|
|
493
615
|
return obj.meta;
|
|
494
616
|
}
|
|
495
617
|
|
|
@@ -552,11 +674,18 @@ function sendEnvelope(res, status, payload){
|
|
|
552
674
|
function rpcFull(rpcId, result){ return { type:"server-response", rpcId, result }; }
|
|
553
675
|
function rpcError(rpcId, code, message, details){ return rpcFull(rpcId, { ok:false, error:{ code, message, details } }); }
|
|
554
676
|
|
|
555
|
-
// ========== CC
|
|
556
|
-
//
|
|
677
|
+
// ========== CC 预设安装 ==========
|
|
678
|
+
// dsh >= 0.1.7-alpha.1:预设机制换代了。预设不再是「<DSH_HOME>/.agent-presets/<id>/ 目录」,
|
|
679
|
+
// 而是**组合里的一个声明行**(内置 standard/ptc/minimal/cordis 就是这么发的),目录形态在 0.1.7
|
|
680
|
+
// 已经没有任何代码读取 —— 这就是「升级到 0.1.7-alpha.1 后 CC 模式从 roster 里消失」的原因:
|
|
681
|
+
// 文件还在,只是没人再看它。本插件因此把声明写进随包的补丁层 presets/cc.patch.yml
|
|
682
|
+
// (package.json 的 dsh.bundle.patch 第二层):插件装进哪个 profile,CC 模式就出现在哪个
|
|
683
|
+
// profile 的 roster 里,不再依赖「挂载时往用户目录写文件」。
|
|
684
|
+
//
|
|
685
|
+
// 下面这套目录安装**只为更老的 dsh(<= 0.1.6-alpha.2)保留**:那条路径上预设发现只扫三个根
|
|
557
686
|
// ——shipped 根(dsh-agent-presets 包内)+ 部署 config.roots + 用户根 <DSH_HOME>/.agent-presets
|
|
558
687
|
// (见 dsh-agent-presets lib/index.js:1300-1310);插件包内的 presets/ 不在其中,
|
|
559
|
-
//
|
|
688
|
+
// 「随包分发」≠「已注册」,所以必须挂载时把模板装进用户根,「装完插件即有 CC 模式」才成立。
|
|
560
689
|
//
|
|
561
690
|
// 安全策略(绝不静默覆盖用户改动):
|
|
562
691
|
// · 目标文件不存在 → 写入模板
|
|
@@ -565,15 +694,31 @@ function rpcError(rpcId, code, message, details){ return rpcFull(rpcId, { ok:fal
|
|
|
565
694
|
// · 其余(用户改过 / 无记录的历史手工拷贝)→ 跳过并告警,交由用户决定
|
|
566
695
|
// 安装记录是目标目录里的 JSON 侧车文件,含每个受管文件写入时的 sha256,
|
|
567
696
|
// 因此不依赖版本号比较也能正确判定「这是不是我们写的、用户有没有动过」。
|
|
568
|
-
// config.presetInstall: "auto"(默认)| "off" | "force"
|
|
697
|
+
// config.presetInstall: "auto"(默认)| "off" | "force"(连用户改动一起覆盖)。
|
|
698
|
+
// 新版 dsh 上这份目录安装自动让位(status: "native"),开关也无意义 —— 想关 CC 模式就在
|
|
699
|
+
// profile 补丁里按 id 关掉 preset-cc 那一行(presets/cc.patch.yml 顶部有现成的写法)。
|
|
569
700
|
const PRESET_ID = "cc";
|
|
570
701
|
const PRESET_MANAGED_FILES = ["preset.yml", "agent.cordis.yml"];
|
|
571
702
|
const PRESET_MARKER_FILE = ".dsh-cc-studio-preset.json";
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
703
|
+
/**
|
|
704
|
+
* 当前 dsh 是否已经用「声明行」发预设(>= 0.1.7-alpha.1)。
|
|
705
|
+
* 只用来决定「要不要往那份已经没人读的目录里写字」,判错的代价并不对称:
|
|
706
|
+
* · 误判 false(在老版上)→ 照旧写一次目录,无害;
|
|
707
|
+
* · 误判 true(在真需要目录的老版上)→ 才是真的少一个预设。
|
|
708
|
+
* 所以要求两个方法**同时**存在,且**不许简化成其中任何一个**:
|
|
709
|
+
* 0.1.6 的 `dsh-agent-presets` 也有 `compositionInventory`(`async compositionInventory()`),
|
|
710
|
+
* 单看它会把老版误判成新版;`register(definition)` 才是 0.1.7 新注册表独有的(老服务里
|
|
711
|
+
* `register` 只出现在 `settings.register` / `sessionProjections.register` 上)。
|
|
712
|
+
*/
|
|
713
|
+
function supportsDeclaredPresets(ctx){
|
|
714
|
+
try{
|
|
715
|
+
const presets = ctx && typeof ctx.get === "function" ? ctx.get("agentPresets") : null;
|
|
716
|
+
return !!(presets && typeof presets.register === "function" && typeof presets.compositionInventory === "function");
|
|
717
|
+
}catch{ return false; }
|
|
576
718
|
}
|
|
719
|
+
|
|
720
|
+
// `dshHomeDir()` / `legacyDshHomeDir()` / `dataRoots()` 定义在文件顶部(草稿那一节之前)——
|
|
721
|
+
// 这里**不再重复定义**:两个同名 function 声明会「后者悄悄赢」,是漂移的温床。
|
|
577
722
|
function presetSourceRoot(){
|
|
578
723
|
try{ return fileURLToPath(new URL("../presets/"+PRESET_ID+"/", import.meta.url)); }catch{ return null; }
|
|
579
724
|
}
|
|
@@ -605,7 +750,8 @@ function planPresetInstall(targets, templates, marker, force){
|
|
|
605
750
|
return { write, skip, ok };
|
|
606
751
|
}
|
|
607
752
|
/**
|
|
608
|
-
* 把 presets/cc 安装到 <DSH_HOME>/.agent-presets/cc
|
|
753
|
+
* 把 presets/cc 安装到 <DSH_HOME>/.agent-presets/cc(**只对 < 0.1.7-alpha.1 有意义**)。
|
|
754
|
+
* 同步执行(只有两个小文件),幂等。
|
|
609
755
|
* 注意:卸载插件**不会**删除已安装的预设(与 README 的既有说明一致),因此这里不注册删除型 disposer。
|
|
610
756
|
*/
|
|
611
757
|
function installCcPreset(ctx, config){
|
|
@@ -622,6 +768,18 @@ function installCcPreset(ctx, config){
|
|
|
622
768
|
if(l && typeof l[level] === "function") l[level]("[dsh-cc-studio] " + msg);
|
|
623
769
|
else if(level === "warn") console.warn("[dsh-cc-studio] " + msg);
|
|
624
770
|
}catch{} };
|
|
771
|
+
// dsh >= 0.1.7-alpha.1:预设已随包声明(presets/cc.patch.yml),目录形态被彻底忽略。
|
|
772
|
+
// 这里一个字都不写 —— 否则只会留下一个没人读的目录,下次有人「修预设」时还要多查一层。
|
|
773
|
+
if(supportsDeclaredPresets(ctx)){
|
|
774
|
+
out.status = "native";
|
|
775
|
+
out.reason = "dsh >= 0.1.7-alpha.1:CC 预设由随包补丁层 presets/cc.patch.yml 声明,目录形态已不再被读取,跳过安装";
|
|
776
|
+
log("info", out.reason);
|
|
777
|
+
try{
|
|
778
|
+
const dst = presetTargetRoot();
|
|
779
|
+
if(existsSync(dst)) log("info", "检测到旧版安装目录 " + dst + "(0.1.6 及更早的形态):0.1.7 起不再被读取,可以安全删除。");
|
|
780
|
+
}catch{}
|
|
781
|
+
return out;
|
|
782
|
+
}
|
|
625
783
|
try{
|
|
626
784
|
const src = presetSourceRoot();
|
|
627
785
|
const templates = {};
|
|
@@ -700,12 +858,43 @@ function apply(ctx, config){
|
|
|
700
858
|
return ok({ ok:true, time: Date.now() });
|
|
701
859
|
}
|
|
702
860
|
case "cc_isCcMode":{
|
|
703
|
-
|
|
861
|
+
const parts = draftKeyPartsFrom(ctx, args);
|
|
862
|
+
return ok({ isCc: isCcPreset(ctx, (parts.source==="arg"||parts.source==="arg-snapshot") ? parts.key : null) });
|
|
704
863
|
}
|
|
705
864
|
case "cc_getDraft":{
|
|
706
|
-
const
|
|
865
|
+
const parts = draftKeyPartsFrom(ctx, args);
|
|
866
|
+
const k = parts.key;
|
|
707
867
|
const d = getDraftFor(k);
|
|
708
|
-
|
|
868
|
+
const status = draftStatusFor(k, d);
|
|
869
|
+
const payload = { draft: d, key: k, keySource: parts.source, isCc: isCcPreset(ctx, (parts.source==="arg"||parts.source==="arg-snapshot") ? k : null), draftStatus: status };
|
|
870
|
+
// 只在「当前槽是个新空壳」时才去盘上翻别的槽(issue #5 的救回入口)。
|
|
871
|
+
// 而且只翻**这次错位真正会牵涉到的另一把槽**,不是把所有会话的槽都端出来:
|
|
872
|
+
// · 当前是会话槽(session-<id>)→ 候选只有 default —— 旧前端在没有会话 id 时
|
|
873
|
+
// 一律读写 default,用户在工坊里的编辑很可能就落在那里;
|
|
874
|
+
// · 当前是 default → 候选是各个 session-* 槽 —— 模型经 Tools 写的是会话槽。
|
|
875
|
+
// 其余情况(比如别的会话的槽)与本会话无关,端出来只会变成噪音。
|
|
876
|
+
if(status.isNew || k === "default"){
|
|
877
|
+
try{
|
|
878
|
+
const all = listDraftSlotsOnDisk().filter(s=> s.key!==k && s.hasContent);
|
|
879
|
+
const alts = (k === "default") ? all.filter(s=> s.key !== "default") : all.filter(s=> s.key === "default");
|
|
880
|
+
if(alts.length) payload.alternateSlots = alts.slice(0, 3);
|
|
881
|
+
}catch{}
|
|
882
|
+
}
|
|
883
|
+
return ok(payload);
|
|
884
|
+
}
|
|
885
|
+
case "cc_migrateDraft":{
|
|
886
|
+
// 把别的草稿槽搬进本会话槽:args: { from, to?, overwrite? }
|
|
887
|
+
// to 缺省 = 当前会话(走同一套 key 解析,前端不必自己拼 key)。
|
|
888
|
+
const from = (typeof args.from==="string" && args.from) ? args.from : "default";
|
|
889
|
+
const toParts = draftKeyPartsFrom(ctx, { sessionId: (typeof args.to==="string" && args.to) ? args.to : (args.sessionId||null) });
|
|
890
|
+
const to = toParts.key;
|
|
891
|
+
if(from === to) return fail("same-slot", "源槽与目标槽相同(" + from + "),无需迁移");
|
|
892
|
+
const src = loadPersistedDraftSync(from) || (globalThis.__CC_DRAFTS__.has(from) ? globalThis.__CC_DRAFTS__.get(from) : null);
|
|
893
|
+
const tgt = loadPersistedDraftSync(to) || (globalThis.__CC_DRAFTS__.has(to) ? globalThis.__CC_DRAFTS__.get(to) : null);
|
|
894
|
+
const decision = migrateDraftDecision(src, tgt, args.overwrite===true);
|
|
895
|
+
if(!decision.ok) return fail(decision.code, decision.message, decision.details||{});
|
|
896
|
+
setDraftFor(to, decision.draft);
|
|
897
|
+
return ok({ draft: decision.draft, key: to, from: from, moved: decision.summary, draftStatus: draftStatusFor(to, decision.draft) });
|
|
709
898
|
}
|
|
710
899
|
case "cc_setDraft":{
|
|
711
900
|
const k = draftKeyFrom(ctx, args);
|
|
@@ -939,6 +1128,11 @@ function apply(ctx, config){
|
|
|
939
1128
|
}), "dsh-cc-studio: POST "+RPC_CHANNEL+"/*");
|
|
940
1129
|
}
|
|
941
1130
|
|
|
942
|
-
export { apply, inject, name, planPresetInstall, installCcPreset, presetTargetRoot, presetSourceRoot
|
|
1131
|
+
export { apply, inject, name, planPresetInstall, installCcPreset, supportsDeclaredPresets, presetTargetRoot, presetSourceRoot,
|
|
1132
|
+
draftKeyPartsFrom, draftContentSummary, listDraftSlotsOnDisk, migrateDraftDecision,
|
|
1133
|
+
// 落盘根与 lib 层 CRUD(供回归测试直接断言「DSH_HOME 优先 + 读时回退」)
|
|
1134
|
+
dshHomeDir, legacyDshHomeDir, dataRoots, draftDirs, draftDir, draftPath, findDraftPath, safeDraftFile,
|
|
1135
|
+
persistDraftSync, loadPersistedDraftSync, libDirs, libDir,
|
|
1136
|
+
listLibraryEntries, saveLibraryEntry, loadLibraryEntry, deleteLibraryEntry, renameLibraryEntry };
|
|
943
1137
|
|
|
944
1138
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xia-sc/dsh-cc-studio",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
4
4
|
"description": "CCv3 角色卡工坊:融合工坊 + CC 模式 + JSON/PNG/CHARX + 中英双语(跟随全局)",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
@@ -35,12 +35,15 @@
|
|
|
35
35
|
"presets"
|
|
36
36
|
],
|
|
37
37
|
"scripts": {
|
|
38
|
-
"test": "node tests/rpc-channel.test.mjs && node tests/preset-install.test.mjs && node tests/cc-detection.test.mjs && node tests/client-greetings.test.mjs",
|
|
38
|
+
"test": "node tests/rpc-channel.test.mjs && node tests/preset-install.test.mjs && node tests/cc-detection.test.mjs && node tests/client-greetings.test.mjs && node tests/draft-slot-sync.test.mjs && node tests/data-root.test.mjs",
|
|
39
39
|
"prepublishOnly": "npm test"
|
|
40
40
|
},
|
|
41
41
|
"dsh": {
|
|
42
42
|
"bundle": {
|
|
43
|
-
"patch":
|
|
43
|
+
"patch": [
|
|
44
|
+
"./cordis.patch.yml",
|
|
45
|
+
"./presets/cc.patch.yml"
|
|
46
|
+
]
|
|
44
47
|
},
|
|
45
48
|
"client": {
|
|
46
49
|
"platform": "web",
|