@xia-sc/dsh-cc-studio 0.4.1 → 0.7.1

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/client.js CHANGED
@@ -216,6 +216,7 @@ window.__ModuleLoader__.load({
216
216
  "workshop.greeting.groupHint":"建议 ≥1 条",
217
217
  "workshop.field.nickname":"nickname({{char}} 用)",
218
218
  "workshop.field.tags":"tags(逗号分隔)",
219
+ "workshop.field.creator":"作者(creator)",
219
220
  "workshop.field.mesExample":"mes_example({{user}}/{{char}})",
220
221
  "workshop.edit.title":"{title} · 大框编辑",
221
222
  "workshop.edit.fallbackTitle":"编辑",
@@ -428,6 +429,7 @@ window.__ModuleLoader__.load({
428
429
  "workshop.greeting.groupHint":"≥1 recommended",
429
430
  "workshop.field.nickname":"nickname (used by {{char}})",
430
431
  "workshop.field.tags":"tags (comma separated)",
432
+ "workshop.field.creator":"creator / author",
431
433
  "workshop.field.mesExample":"mes_example ({{user}}/{{char}})",
432
434
  "workshop.edit.title":"{title} · Large editor",
433
435
  "workshop.edit.fallbackTitle":"Edit",
@@ -838,7 +840,11 @@ window.__ModuleLoader__.load({
838
840
  }catch(e){ set({lastMsg: String(e && e.message || e)}); }
839
841
  set({libraryBusy:null});
840
842
  }
841
- function clearCurrentLibraryId(){ set({currentLibraryId:null, lastMsg:t("store.switchedNew")}); }
843
+ function clearCurrentLibraryId(){
844
+ set({currentLibraryId:null, lastMsg:t("store.switchedNew")});
845
+ // 同步清掉宿主侧的来源 id(sidecar):否则下次保存又会按旧来源「更新」那一张
846
+ try{ rpc("cc_setOrigin", { sessionId: state.currentSessionId||null, id: null }); }catch(e){}
847
+ }
842
848
  // 清空当前角色卡:整份草稿换回空卡(makeDraft),并解除「跟已存角色的关联」——
843
849
  // 否则清空后点「保存当前」会把库里那张卡覆盖成空卡(要删卡请用右侧列表的删除)。
844
850
  // 落盘与其它写入走同一条路(pushDraftToHost):刷新页面、模型侧读到的都是空卡。
@@ -852,6 +858,8 @@ window.__ModuleLoader__.load({
852
858
  currentLibraryId:null,
853
859
  lastMsg: t("store.cleared")
854
860
  });
861
+ // 清空 = 与已存卡脱钩:宿主侧的来源 id(sidecar)也要清,否则下次保存会「更新」原来那张卡(把它覆盖成空卡)
862
+ try{ rpc("cc_setOrigin", { sessionId: state.currentSessionId||null, id: null }); }catch(e){}
855
863
  setTimeout(pushDraftToHost, 80);
856
864
  }
857
865
  async function loadFromLibrary(id){
@@ -1295,6 +1303,9 @@ window.__ModuleLoader__.load({
1295
1303
  field(t("workshop.field.nickname"),"nickname",{rowKey:"nickname", input:true})
1296
1304
  ),
1297
1305
  field(t("workshop.field.tags"),"tags",{rowKey:"tags", input:true, bigBox:false, value:s.draft.data.tags.join(", "), onChange:function(v){ store.updateDraft(function(d){ d.tags=v.split(",").map(function(x){return x.trim();}).filter(Boolean); }); }}),
1306
+ // N1(0.7.0):data.creator 此前**没有任何写入路径**(工具侧与工坊都改不了),
1307
+ // 而 cc_validate 每次都警告它为空。这里补上人工入口,工具侧对应 cc_patch_character 的 creator。
1308
+ field(t("workshop.field.creator"),"creator",{rowKey:"creator", input:true, bigBox:false}),
1298
1309
  field("description","description",{rowKey:"desc"}),
1299
1310
  field("personality","personality",{rowKey:"pers"}),
1300
1311
  field("scenario","scenario",{rowKey:"scen"}),
@@ -1439,6 +1450,10 @@ window.__ModuleLoader__.load({
1439
1450
  filteredLibrary.map(function(entry){
1440
1451
  var isBusy = s.libraryBusy && String(s.libraryBusy).indexOf(entry.id)>=0;
1441
1452
  var isCurrent = s.currentLibraryId===entry.id;
1453
+ // 列表展示前 4 个标签,但「还有几个没显示」必须用 meta.tagsTotal 算:meta.tags 本身截断到 6 个,
1454
+ // 拿它的长度当总数会偏小(卡里 7 个、列表只显示 6 个,用户会以为标签没存上 —— LLM 侧回测报的 P1-11)
1455
+ var tagTotal = (typeof entry.tagsTotal==="number") ? entry.tagsTotal : (entry.tags||[]).length;
1456
+ var tagShown = Math.min((entry.tags||[]).length, 4);
1442
1457
  var itemStyle = isCurrent ? Object.assign({}, S.libraryItem, {borderColor:"#7c5cff", background:"color-mix(in srgb, #7c5cff 8%, var(--dsw-alias-bg-layer-2))"}) : S.libraryItem;
1443
1458
  return h("div",{key:entry.id, style:itemStyle},
1444
1459
  h("div",{style:{display:"flex", alignItems:"center", justifyContent:"space-between", gap:6}},
@@ -1449,7 +1464,7 @@ window.__ModuleLoader__.load({
1449
1464
  h("div",{style:{fontSize:11, color:"var(--dsw-alias-label-secondary)", marginTop:2, whiteSpace:"nowrap", overflow:"hidden", textOverflow:"ellipsis"}}, entry.descriptionSnippet || entry.tags.join(" · ") || "—"),
1450
1465
  h("div",{style:{display:"flex", flexWrap:"wrap", gap:4, marginTop:6}},
1451
1466
  (entry.tags||[]).slice(0,4).map(function(t){ return h("span",{key:t, style:Object.assign({}, S.badge, {fontSize:10, padding:"1px 5px"})}, t); }),
1452
- entry.tags && entry.tags.length>4 ? h("span",{style:{fontSize:10, color:"var(--dsw-alias-label-secondary)"}}, "+"+(entry.tags.length-4)) : null
1467
+ tagTotal > tagShown ? h("span",{style:{fontSize:10, color:"var(--dsw-alias-label-secondary)"}}, "+"+(tagTotal-tagShown)) : null
1453
1468
  ),
1454
1469
  h("div",{style:{fontSize:10, color:"var(--dsw-alias-label-secondary)", marginTop:4}}, new Date(entry.updatedAt*1000).toLocaleString()),
1455
1470
  h("div",{style:{display:"flex", gap:6, marginTop:8}},
@@ -1702,6 +1717,11 @@ window.__ModuleLoader__.load({
1702
1717
  try{ if(localStorage.getItem("dsh-cc-studio-legacy-notice")==="1") alts = null; }catch(e){}
1703
1718
  }
1704
1719
  store.setAlternateSlots(alts);
1720
+ // 载入来源 id(0.5.0):宿主把「这份草稿是从哪张已存卡载入的」落在盘上(sidecar),
1721
+ // 刷新页面后据此恢复「已载入 ID」—— 否则内存里的 currentLibraryId 一丢,
1722
+ // 再点保存就会新建一张同名重复卡(LLM 侧回环测试抓到的 P0-4)。
1723
+ var originId = (typeof res.value.originId==="string" && res.value.originId) ? res.value.originId : null;
1724
+ if(originId !== store.getSnapshot().currentLibraryId) set({ currentLibraryId: originId });
1705
1725
  var draft=res.value.draft;
1706
1726
  var draftStatus=res.value.draftStatus||null;
1707
1727
  try{
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, readdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
7
7
  import { createHash } from "node:crypto";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { join } from "node:path";
@@ -97,6 +97,38 @@ function makeDefaultDraft(){
97
97
  }
98
98
  };
99
99
  }
100
+ /**
101
+ * 草稿形状规范化(0.7.1,B1,与 lib/agent.js 同构)。
102
+ *
103
+ * DSH 对工具/远端返回值做 lossless-JSON 校验:**任何嵌套 `undefined` 都会让整次调用报错**。
104
+ * 外部导入的卡(PNG / CHARX)或早期版本写下的草稿可能缺 `spec_version` / `creation_date` 这类字段,
105
+ * 前端直接读它就会渲染出 `undefined`、导出的卡也缺字段。所以读盘恢复时按空卡模板补齐缺失键:
106
+ * 只补 `undefined`,不覆盖已有值、不改类型(类型问题交给校验报)。
107
+ */
108
+ function normalizeCardShape(draft){
109
+ const tpl = makeDefaultDraft();
110
+ if(!draft || typeof draft !== "object") return tpl;
111
+ const out = {
112
+ spec: (draft.spec !== undefined && draft.spec !== null) ? draft.spec : tpl.spec,
113
+ spec_version: (draft.spec_version !== undefined && draft.spec_version !== null) ? draft.spec_version : tpl.spec_version,
114
+ data: {},
115
+ };
116
+ const src = (draft.data && typeof draft.data === "object") ? draft.data : {};
117
+ for(const k of Object.keys(tpl.data)){
118
+ out.data[k] = (src[k] !== undefined) ? src[k] : tpl.data[k];
119
+ }
120
+ for(const k of Object.keys(src)){ if(!(k in out.data)) out.data[k] = src[k]; }
121
+ const book = (out.data.character_book && typeof out.data.character_book === "object") ? out.data.character_book : {};
122
+ const bTpl = tpl.data.character_book;
123
+ const nb = {};
124
+ for(const k of Object.keys(bTpl)){ nb[k] = (book[k] !== undefined) ? book[k] : bTpl[k]; }
125
+ for(const k of Object.keys(book)){ if(!(k in nb)) nb[k] = book[k]; }
126
+ if(!Array.isArray(nb.entries)) nb.entries = [];
127
+ if(!nb.extensions || typeof nb.extensions !== "object") nb.extensions = {};
128
+ out.data.character_book = nb;
129
+ if(!out.data.extensions || typeof out.data.extensions !== "object") out.data.extensions = {};
130
+ return out;
131
+ }
100
132
  if(!globalThis.__CC_DRAFTS__) globalThis.__CC_DRAFTS__ = new Map();
101
133
  // —— 落盘根(0.3.6 起统一):DSH_HOME 优先,homedir()/.dsh 兜底 ——
102
134
  // 预设目录一直走这里;草稿与角色库在 0.3.5 及以前写死 homedir()/.dsh,现在统一到同一套根,
@@ -132,6 +164,27 @@ function safeDraftFile(key){
132
164
  }
133
165
  function draftPath(key){ return join(draftDir(), safeDraftFile(key)); }
134
166
  /** 读盘用:主根没有就回退旧根(0.3.6 之前草稿写死的地方)。找不到返回 null。 */
167
+ // —— 草稿的「来源 id」sidecar(0.5.0,与 lib/agent.js 同构)——
168
+ // 记「这份草稿是从哪张已存卡载入的」,让「载入 → 改 → 保存」更新原卡而不是新建一张同名重复卡。
169
+ // 浏览器侧刷新页面会丢掉内存里的 currentLibraryId,所以这份关联必须落盘;存 sidecar 而不是塞进
170
+ // draft.extensions,是因为后者会跟着导出进用户的卡里。
171
+ function originFilePath(key){ return join(draftDir(), safeDraftFile(key).replace(/\.json$/, ".origin.json")); }
172
+ function readDraftOrigin(key){
173
+ try{
174
+ const p = originFilePath(key);
175
+ if(!existsSync(p)) return null;
176
+ const obj = JSON.parse(readFileSync(p, "utf8"));
177
+ if(obj && typeof obj.libraryId === "string" && obj.libraryId) return obj;
178
+ }catch{}
179
+ return null;
180
+ }
181
+ function writeDraftOrigin(key, libraryId, name){
182
+ try{
183
+ if(!libraryId){ try{ unlinkSync(originFilePath(key)); }catch{} return; }
184
+ mkdirSync(draftDir(), { recursive: true });
185
+ writeFileSync(originFilePath(key), JSON.stringify({ key: String(key), libraryId: String(libraryId), name: String(name||""), savedAt: nowSec() }, null, 2), "utf8");
186
+ }catch(e){ try{ console.error("[dsh-cc-studio] persist draft origin failed:", (e && e.message) || e); }catch{} }
187
+ }
135
188
  function findDraftPath(key){
136
189
  const file = safeDraftFile(key);
137
190
  for(const dir of draftDirs()){
@@ -154,17 +207,36 @@ function loadPersistedDraftSync(key){
154
207
  function persistDraftSync(key, draft){
155
208
  try{
156
209
  mkdirSync(draftDir(), { recursive: true });
157
- writeFileSync(draftPath(key), JSON.stringify({ key: String(key), updatedAt: nowSec(), draft }, null, 2), "utf8");
158
- }catch(e){ try{ console.error("[dsh-cc-studio] persist draft failed:", (e && e.message) || e); }catch{} }
210
+ // revision(0.6.0,P0-6):与 lib/agent.js 同构 —— 工具半与宿主半各写各的文件、各持各的内存,
211
+ // **只有草稿文件里的 rev 是共同真相**。工坊这边每写一次盘就 +1,工具侧下次调用读到新 rev 就知道
212
+ // 「草稿被外部换过了」,从而重载而不是拿过期前提去写(否则会静默覆盖用户在工坊里的修改)。
213
+ let rev = 1;
214
+ try{
215
+ const prev = JSON.parse(readFileSync(draftPath(key), "utf8"));
216
+ if(prev && typeof prev.rev === "number" && prev.rev > 0) rev = prev.rev + 1;
217
+ }catch{}
218
+ writeFileSync(draftPath(key), JSON.stringify({ key: String(key), updatedAt: nowSec(), rev, draft }, null, 2), "utf8");
219
+ return rev;
220
+ }catch(e){ try{ console.error("[dsh-cc-studio] persist draft failed:", (e && e.message) || e); }catch{} return null; }
159
221
  }
160
222
  function draftMeta(key){
161
223
  const k = key || "default";
162
- if(!globalThis.__CC_DRAFT_META__.has(k)) globalThis.__CC_DRAFT_META__.set(k, { isNew: false, recovered: false });
224
+ if(!globalThis.__CC_DRAFT_META__.has(k)) globalThis.__CC_DRAFT_META__.set(k, { isNew: false, recovered: false, newReason: null });
163
225
  return globalThis.__CC_DRAFT_META__.get(k);
164
226
  }
227
+ /** 卡片内容指纹(忽略 modification_date):判断「这次写入到底有没有真的改变内容」(N4,与工具半同构)。 */
228
+ function cardFingerprint(draft){
229
+ try{
230
+ const d = JSON.parse(JSON.stringify((draft && draft.data) ? draft.data : {}));
231
+ delete d.modification_date;
232
+ return JSON.stringify(d);
233
+ }catch{ return ""; }
234
+ }
165
235
  function draftStatusFor(key, draft){
166
236
  const m = draftMeta(key);
167
- return { isNew: !!m.isNew, recovered: !!m.recovered, creation_date: (draft && draft.data) ? draft.data.creation_date : null, key: String(key) };
237
+ // B1(0.7.1):缺 `creation_date` 的外部导入卡不能再漏出 `undefined`(工具侧会因此整次调用失败,
238
+ // 前端也会把 undefined 渲染成字面量)。
239
+ return { isNew: !!m.isNew, recovered: !!m.recovered, creation_date: (draft && draft.data && draft.data.creation_date !== undefined) ? draft.data.creation_date : null, key: String(key) };
168
240
  }
169
241
  /**
170
242
  * 解析「这次调用读写哪把草稿槽」,并把来源一并带出来(诊断用)。
@@ -261,20 +333,25 @@ function getDraftFor(key){
261
333
  // fix #2:内存 miss 时先尝试从磁盘恢复,而非静默建空
262
334
  const restored = loadPersistedDraftSync(k);
263
335
  if(restored){
264
- globalThis.__CC_DRAFTS__.set(k, restored);
265
- const m = draftMeta(k); m.isNew = false; m.recovered = true;
336
+ globalThis.__CC_DRAFTS__.set(k, normalizeCardShape(restored)); // B1:补齐外部导入卡缺失的字段
337
+ const m = draftMeta(k); m.isNew = false; m.recovered = true; m.newReason = null;
266
338
  } else {
267
339
  globalThis.__CC_DRAFTS__.set(k, makeDefaultDraft());
268
- const m = draftMeta(k); m.isNew = true; m.recovered = false;
340
+ const m = draftMeta(k); m.isNew = true; m.recovered = false; m.newReason = "welcome";
269
341
  }
270
342
  }
271
343
  return globalThis.__CC_DRAFTS__.get(k);
272
344
  }
273
345
  function setDraftFor(key, draft){
274
- draft.data.modification_date = nowSec();
275
346
  const k = key || "default";
347
+ const prev = globalThis.__CC_DRAFTS__.get(k);
348
+ // N4(0.7.0,与 lib/agent.js 同构):内容一字未变时不写盘、不递增 rev。工坊侧每次编辑都调
349
+ // cc_setDraft,同值覆盖(失焦、重复输入、只动了别的槽)不该白耗一个版本号 —— 否则模型手上的
350
+ // rev 前提会被「其实没发生的变化」打掉一次,而它只能看到一句「草稿已被外部修改」。
351
+ if(prev && cardFingerprint(prev) === cardFingerprint(draft)) return draft;
352
+ draft.data.modification_date = nowSec();
276
353
  globalThis.__CC_DRAFTS__.set(k, draft);
277
- const m = draftMeta(k); m.isNew = false; m.recovered = false;
354
+ const m = draftMeta(k); m.isNew = false; m.recovered = false; m.newReason = null;
278
355
  persistDraftSync(k, draft);
279
356
  return draft;
280
357
  }
@@ -524,12 +601,14 @@ function makeLibraryEntryMeta(draft, existingMeta, id){
524
601
  const data = draft && draft.data ? draft.data : {};
525
602
  const now = nowSec();
526
603
  const loreCount = Array.isArray(data.character_book?.entries) ? data.character_book.entries.length : 0;
527
- const tags = Array.isArray(data.tags) ? data.tags.slice(0,6).map(String) : [];
604
+ const allTags = Array.isArray(data.tags) ? data.tags.map(String) : [];
605
+ const tags = allTags.slice(0, 6);
528
606
  return {
529
607
  id: String(id),
530
608
  name: (data.name && String(data.name).trim()) || (existingMeta && existingMeta.name) || "未命名角色",
531
609
  loreCount,
532
610
  tags,
611
+ tagsTotal: allTags.length,
533
612
  descriptionSnippet: String(data.description || "").slice(0,80),
534
613
  createdAt: existingMeta && existingMeta.createdAt ? existingMeta.createdAt : now,
535
614
  updatedAt: now,
@@ -574,13 +653,15 @@ async function saveLibraryEntry(draft, opts){
574
653
  id = String(Date.now())+"-"+Math.random().toString(36).slice(2,6);
575
654
  }
576
655
  const meta = makeLibraryEntryMeta(draft, existing ? existing.meta : null, id);
577
- // 若调用方显式传 name,覆盖 meta.name(不改 draft.data.name,保持原样)
578
- if(opts && typeof opts.name==="string" && opts.name.trim()){
579
- meta.name = String(opts.name).trim().slice(0,60);
656
+ // 改名 = 单一事实来源:meta.name 与 draft.data.name 一起改(否则「列表名 / 导出名」两套,用户改名看不出效果)
657
+ if(opts && typeof opts.name === "string" && opts.name.trim()){
658
+ const n = String(opts.name).trim().slice(0,60);
659
+ meta.name = n;
660
+ draft.data.name = n;
580
661
  }
581
662
  const payload = { id, meta, draft };
582
663
  await writeFile(join(dir, id+".json"), JSON.stringify(payload, null, 2), "utf8");
583
- return { id, meta, draft };
664
+ return { id, meta, draft, action: existing ? "updated" : "created" };
584
665
  }
585
666
  async function loadLibraryEntry(id){
586
667
  await ensureLibDir();
@@ -611,6 +692,8 @@ async function renameLibraryEntry(id, newName){
611
692
  if(!name) throw new Error("name 不能为空");
612
693
  obj.meta.name = name;
613
694
  obj.meta.updatedAt = nowSec();
695
+ // 单一事实来源(0.5.0):列表名与卡内 data.name 一起改 —— 此前只改 meta,侧边栏改了名、导出的卡里还是旧名
696
+ if(obj.draft && obj.draft.data) obj.draft.data.name = name;
614
697
  await writeFile(found.path, JSON.stringify(obj, null, 2), "utf8");
615
698
  return obj.meta;
616
699
  }
@@ -871,7 +954,8 @@ function apply(ctx, config){
871
954
  const k = parts.key;
872
955
  const d = getDraftFor(k);
873
956
  const status = draftStatusFor(k, d);
874
- const payload = { draft: d, key: k, keySource: parts.source, isCc: isCcPreset(ctx, (parts.source==="arg"||parts.source==="arg-snapshot") ? k : null), draftStatus: status };
957
+ const origin = readDraftOrigin(k);
958
+ const payload = { draft: d, key: k, keySource: parts.source, isCc: isCcPreset(ctx, (parts.source==="arg"||parts.source==="arg-snapshot") ? k : null), draftStatus: status, originId: origin ? origin.libraryId : null };
875
959
  // 只在「当前槽是个新空壳」时才去盘上翻别的槽(issue #5 的救回入口)。
876
960
  // 而且只翻**这次错位真正会牵涉到的另一把槽**,不是把所有会话的槽都端出来:
877
961
  // · 当前是会话槽(session-<id>)→ 候选只有 default —— 旧前端在没有会话 id 时
@@ -927,6 +1011,7 @@ function apply(ctx, config){
927
1011
  if(patch.mes_example!==undefined) cur.data.mes_example = String(patch.mes_example).slice(0,4000);
928
1012
  if(Array.isArray(patch.tags)) cur.data.tags = patch.tags.map(String).slice(0,12);
929
1013
  if(patch.creator_notes!==undefined) cur.data.creator_notes = String(patch.creator_notes).slice(0,2000);
1014
+ if(patch.creator!==undefined) cur.data.creator = String(patch.creator).slice(0,120); // N1:作者名此前没有写入路径
930
1015
  if(patch.world && typeof patch.world==="object"){
931
1016
  cur.data.extensions = cur.data.extensions || {};
932
1017
  cur.data.extensions.cc_world = Object.assign({}, cur.data.extensions.cc_world||{}, patch.world);
@@ -981,29 +1066,49 @@ function apply(ctx, config){
981
1066
  }
982
1067
  case "cc_saveToLibrary":{
983
1068
  // args: {draft?, id?, name?, sessionId?} draft 缺省则取当前会话草稿
1069
+ const k = draftKeyFrom(ctx, args);
984
1070
  let draft = args.draft;
985
1071
  if(!draft || typeof draft!=="object"){
986
- const k = draftKeyFrom(ctx, args);
987
1072
  draft = JSON.parse(JSON.stringify(getDraftFor(k)));
988
1073
  }
1074
+ // 不传 id 时沿用草稿的**来源 id**:工坊里「载入 → 改 → 保存」应当是更新原卡,
1075
+ // 而不是新建一张同名重复卡(刷新页面会丢掉内存里的 currentLibraryId,所以来源落在盘上)。
1076
+ const explicitId = (typeof args.id==="string" && args.id) ? args.id : "";
1077
+ const origin = explicitId ? null : readDraftOrigin(k);
1078
+ const targetId = explicitId || (origin ? origin.libraryId : "");
989
1079
  // 允许保存未完全校验的草稿,但至少需要 name?不强制,meta 会兜底“未命名角色”
990
- const res = await saveLibraryEntry(draft, { id: args.id, name: args.name });
991
- return ok(res);
1080
+ const res = await saveLibraryEntry(draft, { id: targetId || undefined, name: args.name });
1081
+ writeDraftOrigin(k, res.id, res.meta.name);
1082
+ return ok({ id: res.id, meta: res.meta, action: targetId ? "updated" : "created" });
992
1083
  }
993
1084
  case "cc_loadFromLibrary":{
994
1085
  const id = args.id;
995
1086
  if(typeof id!=="string" || !id) return fail("invalid-id","id 必填");
996
1087
  const obj = await loadLibraryEntry(id);
997
- // 载入到当前会话草稿
1088
+ // 载入到当前会话草稿,并记下来源 id(sidecar):之后不传 id 保存就是更新这一张
998
1089
  const k = draftKeyFrom(ctx, args);
999
1090
  setDraftFor(k, JSON.parse(JSON.stringify(obj.draft)));
1000
- return ok({ draft: obj.draft, meta: obj.meta, key: k, draftStatus: draftStatusFor(k, obj.draft) });
1091
+ writeDraftOrigin(k, obj.id, obj.meta ? obj.meta.name : "");
1092
+ return ok({ draft: obj.draft, meta: obj.meta, key: k, originId: obj.id, draftStatus: draftStatusFor(k, obj.draft) });
1001
1093
  }
1002
1094
  case "cc_deleteFromLibrary":{
1003
1095
  const id = args.id;
1004
1096
  if(typeof id!=="string" || !id) return fail("invalid-id","id 必填");
1097
+ const k = draftKeyFrom(ctx, args);
1098
+ const origin = readDraftOrigin(k);
1099
+ const wasOrigin = !!(origin && origin.libraryId === id);
1005
1100
  await deleteLibraryEntry(id);
1006
- return ok({ ok:true, id });
1101
+ // 删掉的正是当前草稿的来源卡 → 清掉关联,否则下次保存会「更新」一张已不存在的卡
1102
+ if(wasOrigin) writeDraftOrigin(k, null);
1103
+ const entries = await listLibraryEntries();
1104
+ return ok({ id, remaining: entries.length, originCleared: wasOrigin });
1105
+ }
1106
+ case "cc_setOrigin":{
1107
+ // 前端显式设/清「当前草稿的来源 id」:id 传 null/空 = 清(清空草稿、另存为新时用)
1108
+ const k = draftKeyFrom(ctx, args);
1109
+ const id = (typeof args.id === "string" && args.id) ? args.id : null;
1110
+ writeDraftOrigin(k, id, args.name);
1111
+ return ok({ key: k, originId: id });
1007
1112
  }
1008
1113
  case "cc_renameInLibrary":{
1009
1114
  const id = args.id;
@@ -1011,7 +1116,7 @@ function apply(ctx, config){
1011
1116
  if(typeof id!=="string" || !id) return fail("invalid-id","id 必填");
1012
1117
  if(typeof newName!=="string" || !newName.trim()) return fail("invalid-name","name 必填");
1013
1118
  const meta = await renameLibraryEntry(id, newName);
1014
- return ok({ meta });
1119
+ return ok({ meta, syncedDraftName: true });
1015
1120
  }
1016
1121
  case "cc_getLibraryEntry":{
1017
1122
  const id = args.id;
@@ -1137,7 +1242,9 @@ export { apply, inject, name, planPresetInstall, installCcPreset, supportsDeclar
1137
1242
  draftKeyPartsFrom, draftContentSummary, listDraftSlotsOnDisk, migrateDraftDecision,
1138
1243
  // 落盘根与 lib 层 CRUD(供回归测试直接断言「DSH_HOME 优先 + 读时回退」)
1139
1244
  dshHomeDir, legacyDshHomeDir, dataRoots, draftDirs, draftDir, draftPath, findDraftPath, safeDraftFile,
1140
- persistDraftSync, loadPersistedDraftSync, libDirs, libDir,
1245
+ persistDraftSync, loadPersistedDraftSync, libDirs, libDir, getDraftFor, setDraftFor, cardFingerprint, normalizeCardShape,
1246
+ // 草稿来源 id 的 sidecar(0.5.0):两半同构,测试直接交叉读写验证互通
1247
+ readDraftOrigin, writeDraftOrigin, originFilePath,
1141
1248
  listLibraryEntries, saveLibraryEntry, loadLibraryEntry, deleteLibraryEntry, renameLibraryEntry };
1142
1249
 
1143
1250
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xia-sc/dsh-cc-studio",
3
- "version": "0.4.1",
3
+ "version": "0.7.1",
4
4
  "description": "CCv3 角色卡工坊:融合工坊 + CC 模式 + JSON/PNG/CHARX + 中英双语(跟随全局)",
5
5
  "keywords": [
6
6
  "dsh",
@@ -38,7 +38,7 @@
38
38
  "presets"
39
39
  ],
40
40
  "scripts": {
41
- "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/nav-highlight.test.mjs && node tests/clear-card.test.mjs && node tests/export-entry.test.mjs && node tests/close-button.test.mjs && node tests/data-root.test.mjs",
41
+ "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/nav-highlight.test.mjs && node tests/clear-card.test.mjs && node tests/export-entry.test.mjs && node tests/close-button.test.mjs && node tests/tools-contract.test.mjs && node tests/data-root.test.mjs",
42
42
  "prepublishOnly": "npm test"
43
43
  },
44
44
  "dsh": {
@@ -32,12 +32,14 @@
32
32
  必须走完强制工作流,不可偷懒跳过 5 维世界观和世界书:
33
33
  1) cc_get_card — 先看草稿,告诉用户还缺什么
34
34
  2) cc_patch_character — 补 name/nickname/description/personality/scenario/tags(至少 description/personality/scenario 非空,tags 同步胶囊风格标签)
35
- 3) cc_patch_world — 补五维 timeline/factions/geo/power/daily,至少填 3 项,autoLorebook=true 自动生成带 @@decorator 的基础条目
36
- 4) cc_add_lorebook_entries — 再补到至少 5 条,覆盖年表/势力/地理/力量/日常各 1,至少 1 条 constant,content 头部写 @@position/@@depth/@@activate 等,支持 CBS 占位符(双花括号的 char / random / roll 形式)
35
+ 3) cc_patch_world — 补五维 timeline/factions/geo/power/daily,至少填 3 项,autoLorebook=true 自动生成带 @@decorator 的基础条目;**同时给 loreKeys**(每一维自报用户真会说的实体名,如 {geo:["旧校舍","广播室","天台"]})—— 不给的话自动条目的 keys 只有维度名,真实对话永远命中不了
36
+ 4) cc_add_lorebook_entries — 再补到至少 5 条,且**至少 1 条是你与用户共创的**(纯 autoLorebook 自动条目不算完成),覆盖年表/势力/地理/力量/日常各 1,至少 1 条 constant,content 头部写 @@position/@@depth/@@activate 等,支持 CBS 占位符(双花括号的 char / random / roll 形式);写完用 cc_simulate_trigger 拿一句用户会说的话验一下命中
37
37
  5) cc_patch_greetings — 补 first_mes、alternate_greetings(≥2)、group_only_greetings(≥1 必填)、mes_example
38
- 6) cc_validate — 必须调且 valid=true 才算完成,否则按返回的 errors/warnings 继续补;通过后提醒用户在工坊点“导出 JSON”或“★ 保存到侧边栏”(落盘 ~/.dsh/cc-library)
38
+ 6) cc_validate — 必须调且 valid=true 才算完成,否则按返回的 errors/warnings 继续补;通过后提醒用户在工坊左下角「校验导出」入口打开导出页选 JSON / PNG / CHARX,或点「★ 保存当前」落盘 ~/.dsh/cc-library
39
39
 
40
- 规则:每次对话只追问 1-2 个薄弱点,问完立刻调工具写入;用户说“随便/跳过”也要你合理推断补全,不能留空;严禁跳过 3/4 步直接做问候语;每调一次工具要简要总结“已写入 X,下一步 Y”,并让胶囊实时同步;结束前必须 cc_validate。
40
+ 辅助工具(按需,不必每步都调):cc_patch_book_meta(世界书命名 / scan_depth / token_budget)、cc_lint_lorebook(世界书静态体检)、cc_simulate_trigger(验触发词命中)、cc_preview_export(导出前干跑)、cc_new_card(重置草稿,**必须先经用户同意**再带 confirm:true)、cc_update_lorebook_entry / cc_delete_lorebook_entries(改删单条)、已存库 cc_list_library / cc_save_to_library / cc_load_from_library / cc_rename_in_library / cc_delete_from_library / cc_get_library_entry(载入后直接保存会更新原卡,不会新建重复卡)。
41
+
42
+ 规则:每次对话只追问 1-2 个薄弱点,问完立刻调工具写入;用户说“随便/跳过”也要你合理推断补全,不能留空;严禁跳过 3/4 步直接做问候语;每调一次工具要简要总结“已写入 X,下一步 Y”,并让胶囊实时同步;结束前必须 cc_validate。工具返回里只有一份 workflow.next(下一步该做什么)与 workflow.hint(进度摘要),照它走即可;用户还没回答就别抢跑。
41
43
 
42
44
  - id: agent-instructions
43
45
  name: '@deepseek-ai/dsh-agent-instructions'
@@ -67,12 +67,14 @@
67
67
  必须走完强制工作流,不可偷懒跳过 5 维世界观和世界书:
68
68
  1) cc_get_card — 先看草稿,告诉用户还缺什么
69
69
  2) cc_patch_character — 补 name/nickname/description/personality/scenario/tags(至少 description/personality/scenario 非空,tags 同步胶囊风格标签)
70
- 3) cc_patch_world — 补五维 timeline/factions/geo/power/daily,至少填 3 项,autoLorebook=true 自动生成带 @@decorator 的基础条目
71
- 4) cc_add_lorebook_entries — 再补到至少 5 条,覆盖年表/势力/地理/力量/日常各 1,至少 1 条 constant,content 头部写 @@position/@@depth/@@activate 等,支持 CBS 占位符(双花括号的 char / random / roll 形式)
70
+ 3) cc_patch_world — 补五维 timeline/factions/geo/power/daily,至少填 3 项,autoLorebook=true 自动生成带 @@decorator 的基础条目;**同时给 loreKeys**(每一维自报用户真会说的实体名,如 {geo:["旧校舍","广播室","天台"]})—— 不给的话自动条目的 keys 只有维度名,真实对话永远命中不了
71
+ 4) cc_add_lorebook_entries — 再补到至少 5 条,且**至少 1 条是你与用户共创的**(纯 autoLorebook 自动条目不算完成),覆盖年表/势力/地理/力量/日常各 1,至少 1 条 constant,content 头部写 @@position/@@depth/@@activate 等,支持 CBS 占位符(双花括号的 char / random / roll 形式);写完用 cc_simulate_trigger 拿一句用户会说的话验一下命中
72
72
  5) cc_patch_greetings — 补 first_mes、alternate_greetings(≥2)、group_only_greetings(≥1 必填)、mes_example
73
- 6) cc_validate — 必须调且 valid=true 才算完成,否则按返回的 errors/warnings 继续补;通过后提醒用户在工坊点“导出 JSON”或“★ 保存到侧边栏”(落盘 ~/.dsh/cc-library)
73
+ 6) cc_validate — 必须调且 valid=true 才算完成,否则按返回的 errors/warnings 继续补;通过后提醒用户在工坊左下角「校验导出」入口打开导出页选 JSON / PNG / CHARX,或点「★ 保存当前」落盘 ~/.dsh/cc-library
74
74
 
75
- 规则:每次对话只追问 1-2 个薄弱点,问完立刻调工具写入;用户说“随便/跳过”也要你合理推断补全,不能留空;严禁跳过 3/4 步直接做问候语;每调一次工具要简要总结“已写入 X,下一步 Y”,并让胶囊实时同步;结束前必须 cc_validate。
75
+ 辅助工具(按需,不必每步都调):cc_patch_book_meta(世界书命名 / scan_depth / token_budget)、cc_lint_lorebook(世界书静态体检)、cc_simulate_trigger(验触发词命中)、cc_preview_export(导出前干跑)、cc_new_card(重置草稿,**必须先经用户同意**再带 confirm:true)、cc_update_lorebook_entry / cc_delete_lorebook_entries(改删单条)、已存库 cc_list_library / cc_save_to_library / cc_load_from_library / cc_rename_in_library / cc_delete_from_library / cc_get_library_entry(载入后直接保存会更新原卡,不会新建重复卡)。
76
+
77
+ 规则:每次对话只追问 1-2 个薄弱点,问完立刻调工具写入;用户说“随便/跳过”也要你合理推断补全,不能留空;严禁跳过 3/4 步直接做问候语;每调一次工具要简要总结“已写入 X,下一步 Y”,并让胶囊实时同步;结束前必须 cc_validate。工具返回里只有一份 workflow.next(下一步该做什么)与 workflow.hint(进度摘要),照它走即可;用户还没回答就别抢跑。
76
78
 
77
79
  - id: agent-instructions
78
80
  name: '@deepseek-ai/dsh-agent-instructions'