@xia-sc/dsh-cc-studio 0.4.0 → 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
@@ -30,10 +30,8 @@ window.__ModuleLoader__.load({
30
30
  "step.world.desc":"年表/势力/地理/力量/日常",
31
31
  "step.char.desc":"六件套 + 开场",
32
32
  "step.lore.desc":"Lorebook + Decorator",
33
- "step.export.desc":"校验导出",
34
33
  "action.expand":"✦ AI 扩写",
35
34
  "action.validate":"校验",
36
- "action.export":"⬇ 导出 JSON",
37
35
  "action.exportJson":"⬇ JSON",
38
36
  "action.exportPng":"⬇ PNG",
39
37
  "action.embedPng":"⬆ 写入 PNG",
@@ -109,6 +107,8 @@ window.__ModuleLoader__.load({
109
107
  "workshop.export.failed":"✗ 校验未通过",
110
108
  "workshop.export.hint":"PNG=tEXt ccv3 · CHARX=ZIP card.json",
111
109
  "workshop.export.hint2":"⬇ PNG=1x1底图 · ⬆ 写入=选本地PNG写入ccv3 · CHARX=ZIP",
110
+ "workshop.export.currentHint":"导出的是当前工坊里的角色卡;要导出某张已存角色,请先在右侧载入它",
111
+ "workshop.export.jumpedOther":"已切到导出页 · 导出的是当前工坊里的「{name}」;「{from}」还没载入,要导它请先点它那行的「载入」",
112
112
  "workshop.export.creatorNotes":"creator_notes / 多语言",
113
113
  "workshop.export.creationDate":"creation_date",
114
114
  "workshop.export.modificationDate":"modification_date(导出自动刷新)",
@@ -138,6 +138,8 @@ window.__ModuleLoader__.load({
138
138
  "workshop.library.noMatch":"无匹配",
139
139
  "workshop.library.load":"载入",
140
140
  "workshop.library.export":"导出",
141
+ "workshop.library.exportTip":"切到导出页(导出的仍是当前工坊里的卡)",
142
+ "workshop.library.exportJumped":"已切到 ✓",
141
143
  "workshop.library.delete":"删除",
142
144
  "workshop.library.unnamed":"未命名",
143
145
  "workshop.edit.large":"大框编辑",
@@ -202,8 +204,6 @@ window.__ModuleLoader__.load({
202
204
  "store.promptRename":"输入新名称",
203
205
  "store.renamed":"已重命名为:{name}",
204
206
  "store.errRenameFailed":"重命名失败",
205
- "store.exported":"已导出:{name}",
206
- "store.errExportFailed":"导出失败",
207
207
  "store.saving":"更新中…",
208
208
  "store.savingNew":"另存中…",
209
209
  "workshop.greeting.entryLabel":"{title} · 第 {n} 条",
@@ -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":"编辑",
@@ -242,10 +243,8 @@ window.__ModuleLoader__.load({
242
243
  "step.world.desc":"Timeline/Factions/Geo/Power/Daily",
243
244
  "step.char.desc":"6-piece + Openings",
244
245
  "step.lore.desc":"Lorebook + Decorator",
245
- "step.export.desc":"Validate & Export",
246
246
  "action.expand":"AI Expand",
247
247
  "action.validate":"Validate",
248
- "action.export":"Export JSON",
249
248
  "action.exportJson":"⬇ JSON",
250
249
  "action.exportPng":"⬇ PNG",
251
250
  "action.embedPng":"⬆ Embed into PNG",
@@ -321,6 +320,8 @@ window.__ModuleLoader__.load({
321
320
  "workshop.export.failed":"✗ Failed",
322
321
  "workshop.export.hint":"PNG=tEXt ccv3 · CHARX=ZIP card.json",
323
322
  "workshop.export.hint2":"⬇ PNG=1×1 placeholder · ⬆ Embed=write into your PNG · CHARX=ZIP",
323
+ "workshop.export.currentHint":"Exports the card currently in the workshop; to export a saved one, load it from the right first",
324
+ "workshop.export.jumpedOther":"Jumped to the export page — it exports “{name}” from the workshop; “{from}” is not loaded yet, hit Load on its row first",
324
325
  "workshop.export.creatorNotes":"creator_notes / i18n",
325
326
  "workshop.export.creationDate":"creation_date",
326
327
  "workshop.export.modificationDate":"modification_date (auto on export)",
@@ -350,6 +351,8 @@ window.__ModuleLoader__.load({
350
351
  "workshop.library.noMatch":"No matches",
351
352
  "workshop.library.load":"Load",
352
353
  "workshop.library.export":"Export",
354
+ "workshop.library.exportTip":"Jump to the export page (it exports the card currently in the workshop)",
355
+ "workshop.library.exportJumped":"Opened ✓",
353
356
  "workshop.library.delete":"Delete",
354
357
  "workshop.library.unnamed":"Unnamed",
355
358
  "workshop.edit.large":"Large Editor",
@@ -414,8 +417,6 @@ window.__ModuleLoader__.load({
414
417
  "store.promptRename":"Enter a new name",
415
418
  "store.renamed":"Renamed to: {name}",
416
419
  "store.errRenameFailed":"Rename failed",
417
- "store.exported":"Exported: {name}",
418
- "store.errExportFailed":"Export failed",
419
420
  "store.saving":"Updating…",
420
421
  "store.savingNew":"Saving as new…",
421
422
  "workshop.greeting.entryLabel":"{title} · #{n}",
@@ -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){
@@ -908,21 +916,10 @@ window.__ModuleLoader__.load({
908
916
  }catch(e){ set({lastMsg: String(e && e.message || e)}); }
909
917
  set({libraryBusy:null});
910
918
  }
911
- async function exportLibraryEntry(id){
912
- if(!id) return;
913
- set({libraryBusy:"export:"+id});
914
- try{
915
- var res=await rpc("cc_getLibraryEntry", {id:id});
916
- if(res && res.ok && res.value && res.value.draft){
917
- var draft=res.value.draft;
918
- var blob=new Blob([JSON.stringify(draft,null,2)],{type:"application/json"});
919
- var a=document.createElement("a"); a.href=URL.createObjectURL(blob); a.download=(res.value.meta.name||draft.data.name||"card")+".json"; a.click(); setTimeout(function(){ URL.revokeObjectURL(a.href); },800);
920
- set({lastMsg:t("store.exported",{name:(res.value.meta.name||id)})});
921
- } else set({lastMsg: res && res.error? String(res.error.message):t("store.errExportFailed")});
922
- }catch(e){ set({lastMsg: String(e && e.message || e)}); }
923
- set({libraryBusy:null});
924
- }
925
- return { getSnapshot:getSnapshot, subscribe:subscribe, setCur:setCur, setPanel:setPanel, setSideExpanded:setSideExpanded, setIsCcMode:setIsCcMode, setDraftFromHost:setDraftFromHost, setSessionId:setSessionId, setSlotWarn:setSlotWarn, setAlternateSlots:setAlternateSlots, migrateSlot:migrateSlot, checkIsCcMode:checkIsCcMode, setPollEnabled:setPollEnabled, setPollInterval:setPollInterval, setAutoValidate:setAutoValidate, setAutoSaveOnImport:setAutoSaveOnImport, updateDraft:updateDraft, validate:validate, exportJSON:exportJSON, exportPNG:exportPNG, embedIntoPng:embedIntoPng, exportCharx:exportCharx, importJSON:importJSON, importAny:importAny, setWorldPatch:setWorldPatch, setEditingWorld:setEditingWorld, cancelEditingWorld:cancelEditingWorld, setEditingText:setEditingText, clearEditingText:clearEditingText, cancelEditingText:cancelEditingText, updateEditingText:updateEditingText, addEntry:addEntry, setLibraryQuery:setLibraryQuery, toggleLibraryCollapsed:toggleLibraryCollapsed, refreshLibrary:refreshLibrary, saveCurrentToLibrary:saveCurrentToLibrary, saveAsNewToLibrary:saveAsNewToLibrary, clearCurrentLibraryId:clearCurrentLibraryId, clearDraft:clearDraft, loadFromLibrary:loadFromLibrary, deleteFromLibrary:deleteFromLibrary, exportLibraryEntry:exportLibraryEntry, renameCurrentInLibrary:renameCurrentInLibrary };
919
+ // 0.4.1:这里原本还有一个 exportLibraryEntry —— 直接下载某张已存角色的 JSON。
920
+ // 三个导出入口归一之后,卡片上的「导出」改成切到导出页,导出动作只剩导出页那一套实现,
921
+ // 这条旁路就没有调用者了(留着它等于「同一次导出有两套行为」),故删除。
922
+ return { getSnapshot:getSnapshot, subscribe:subscribe, setCur:setCur, setPanel:setPanel, setSideExpanded:setSideExpanded, setIsCcMode:setIsCcMode, setDraftFromHost:setDraftFromHost, setSessionId:setSessionId, setSlotWarn:setSlotWarn, setAlternateSlots:setAlternateSlots, migrateSlot:migrateSlot, checkIsCcMode:checkIsCcMode, setPollEnabled:setPollEnabled, setPollInterval:setPollInterval, setAutoValidate:setAutoValidate, setAutoSaveOnImport:setAutoSaveOnImport, updateDraft:updateDraft, validate:validate, exportJSON:exportJSON, exportPNG:exportPNG, embedIntoPng:embedIntoPng, exportCharx:exportCharx, importJSON:importJSON, importAny:importAny, setWorldPatch:setWorldPatch, setEditingWorld:setEditingWorld, cancelEditingWorld:cancelEditingWorld, setEditingText:setEditingText, clearEditingText:clearEditingText, cancelEditingText:cancelEditingText, updateEditingText:updateEditingText, addEntry:addEntry, setLibraryQuery:setLibraryQuery, toggleLibraryCollapsed:toggleLibraryCollapsed, refreshLibrary:refreshLibrary, saveCurrentToLibrary:saveCurrentToLibrary, saveAsNewToLibrary:saveAsNewToLibrary, clearCurrentLibraryId:clearCurrentLibraryId, clearDraft:clearDraft, loadFromLibrary:loadFromLibrary, deleteFromLibrary:deleteFromLibrary, renameCurrentInLibrary:renameCurrentInLibrary };
926
923
  }
927
924
 
928
925
  var S={
@@ -1158,12 +1155,17 @@ window.__ModuleLoader__.load({
1158
1155
  var focusRef=React.useState(null); var focusKey=focusRef[0], setFocusKey=focusRef[1];
1159
1156
  // 主区底部三块阅读辅助(实时预览 / CBS 速查 / 融合说明)默认全收起:占地方、但不常看
1160
1157
  var foldRef=React.useState({}); var openFold=foldRef[0], setOpenFold=foldRef[1];
1158
+ // 从「已存角色」卡片跳进导出页时的来源卡(几秒后自动清掉):用来给那枚按钮一个即时反馈,
1159
+ // 并在导出页把「导出的仍是工坊里这张、你点的那张还没载入」说清楚。清空后回到常态提示。
1160
+ var jumpRef=React.useState(null); var jumpFrom=jumpRef[0], setJumpFrom=jumpRef[1];
1161
1161
  React.useEffect(function(){ return store.subscribe(function(){ setS(store.getSnapshot()); }); }, [store]);
1162
1162
  React.useEffect(function(){
1163
1163
  if(s.panelOpen) store.refreshLibrary();
1164
1164
  }, [s.panelOpen]);
1165
1165
  if(!s.panelOpen) return null;
1166
- var steps=[t("step.world"), t("step.char"), t("step.lore"), t("step.export")];
1166
+ // 顶部胶囊与左侧导航都只有写作三步:导出不再是「第 4 步」(0.4.1 归一)——
1167
+ // 「校验导出」并进左下角那枚按钮,已存角色卡片上的「导出」也指向同一页,三处入口收成两处。
1168
+ var steps=[t("step.world"), t("step.char"), t("step.lore")];
1167
1169
  // 步骤项的三个视觉状态严格互斥:选中(active) / 键盘焦点(focus-visible) / 悬停(hover)。
1168
1170
  // 鼠标点击走 onMouseDown preventDefault,不把焦点抢到按钮上 —— 否则旧项的焦点环会留在原地,
1169
1171
  // 与新的选中项同时发亮,看起来像「同时选中了两个」(这是本次修的问题)。
@@ -1183,6 +1185,13 @@ window.__ModuleLoader__.load({
1183
1185
  }
1184
1186
  // 工具按钮(⛶ / 删除…)鼠标按下就拦掉默认行为:别把焦点抢走,否则又留下一圈焦点描边
1185
1187
  function stopFocusSteal(e){ if(e && e.preventDefault) e.preventDefault(); }
1188
+ // 卡片上的「导出」= 切到导出页 + 立刻反馈(按钮文案短暂变成「已切到 ✓」)。
1189
+ // 只切页本身太安静了:用户的视线在右侧卡片上,中栏换了内容他未必看得见。
1190
+ function jumpToExport(entry){
1191
+ store.setCur(3);
1192
+ setJumpFrom({ id:String((entry&&entry.id)||""), name:String((entry&&entry.name)||"") });
1193
+ try{ setTimeout(function(){ setJumpFrom(null); }, 6000); }catch(e){}
1194
+ }
1186
1195
  // 折叠区:主区底部那几块「阅读辅助」(实时 CCv3 预览 / CBS 速查 / 融合说明)。
1187
1196
  // 默认全部收起 —— 它们不常看,收起来只占一行标题,阅读区因此能拿到整幅宽度。
1188
1197
  function foldPanel(id, title, badge, body){
@@ -1294,6 +1303,9 @@ window.__ModuleLoader__.load({
1294
1303
  field(t("workshop.field.nickname"),"nickname",{rowKey:"nickname", input:true})
1295
1304
  ),
1296
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}),
1297
1309
  field("description","description",{rowKey:"desc"}),
1298
1310
  field("personality","personality",{rowKey:"pers"}),
1299
1311
  field("scenario","scenario",{rowKey:"scen"}),
@@ -1339,7 +1351,10 @@ window.__ModuleLoader__.load({
1339
1351
  var v=s.valid;
1340
1352
  main=h("div",null,
1341
1353
  h("div",{style:S.readCard},
1342
- h("h3",{style:S.readTitle},t("workshop.export.title")),
1354
+ // 标题后面挂上「现在导的是哪张卡」:从已存角色卡片跳进来时,用户最需要确认的就是这个
1355
+ h("h3",{style:S.readTitle}, t("workshop.export.title"),
1356
+ h("span",{style:{marginLeft:8, fontSize:12, fontWeight:400, color:"var(--dsw-alias-label-secondary)"}},
1357
+ "· "+(String(s.draft.data.name||"").trim() || t("workshop.library.unnamed")))),
1343
1358
  h("div",{style:{display:"flex", flexWrap:"wrap", gap:8, marginBottom:8}},
1344
1359
  h("span",{style: Object.assign({}, S.badge, v.valid? {borderColor:"var(--dsw-alias-state-success-primary)", color:"#a6e8c8", background:"color-mix(in srgb, var(--dsw-alias-state-success-primary) 12%, var(--dsw-alias-bg-base))"}:{borderColor:"var(--dsw-alias-state-error-primary)", color:"#e8a6a6", background:"color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, var(--dsw-alias-bg-base))"})}, v.valid? t("workshop.export.passed"):t("workshop.export.failed")),
1345
1360
  h("span",{style:S.badge},t("common.groupGreetings",{n:s.draft.data.group_only_greetings.length})),
@@ -1348,13 +1363,20 @@ window.__ModuleLoader__.load({
1348
1363
  v.errors.length? h("div",{style:{background:"color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, var(--dsw-alias-bg-base))", border:"1px solid var(--dsw-alias-state-error-primary)", borderRadius:8, padding:8, marginBottom:8, color:"#e8a6a6", fontSize:12}}, v.errors.map(function(e){ return h("div",{key:e}, "• "+e); })):null,
1349
1364
  v.warnings.length? h("div",{style:{background:"color-mix(in srgb, var(--dsw-alias-border-l1) 40%, var(--dsw-alias-bg-base))", border:"1px solid var(--dsw-alias-border-l1)", borderRadius:8, padding:8, marginBottom:8, color:"var(--dsw-alias-label-secondary)", fontSize:12}}, v.warnings.map(function(e){ return h("div",{key:e}, "• "+e); })):null,
1350
1365
  h("div",{style:{display:"flex", flexWrap:"wrap", gap:8, margin:"12px 0"}},
1351
- h("button",{style:S.btn, onClick:function(){ store.exportJSON(); }}, "⬇ JSON"),
1366
+ h("button",{style:S.btn, onClick:function(){ store.exportJSON(); }}, t("action.exportJson")),
1352
1367
  h("button",{style: Object.assign({}, S.btn, {background:"#e05555"}), onClick:function(){ store.exportPNG(); }}, s.busy==="exportPng"? t("action.generating"):t("action.exportPng")),
1353
1368
  h("button",{style: Object.assign({}, S.btn, {background:"#e05555", opacity:0.92}), onClick:function(){ store.embedIntoPng(); }}, s.busy==="exportPng"? t("action.writing"):t("action.embedPng")),
1354
1369
  h("button",{style: Object.assign({}, S.btn, {background:"#2e86de"}), onClick:function(){ store.exportCharx(); }}, s.busy==="exportCharx"? t("action.generating"):t("action.exportCharx")),
1355
1370
  h("button",{style: Object.assign({}, S.btn, S.btnGhost), onClick:function(){ store.validate(); }}, s.busy==="validate"? t("action.validating"): t("action.validate")),
1356
1371
  h("span",{style:{color:"var(--dsw-alias-label-secondary)", fontSize:11, alignSelf:"center"}}, t("workshop.export.hint2"))
1357
1372
  ),
1373
+ // 导出页只认「当前工坊里的卡」:右侧卡片上的「导出」也是切到这一页,所以这里把口径写清楚
1374
+ // 从卡片跳进来、且那张卡不是工坊里这张时,把口径说透(导出的是谁、要导它该怎么做);
1375
+ // 其余情况只留常态说明。
1376
+ (jumpFrom && jumpFrom.id && jumpFrom.id!==s.currentLibraryId)
1377
+ ? h("div",{style:{fontSize:11, margin:"-4px 0 10px", lineHeight:1.7, color:"var(--dsw-alias-label-primary)", background:"rgba(255,170,60,.10)", border:"1px solid rgba(255,170,60,.45)", borderRadius:8, padding:"6px 10px"}},
1378
+ t("workshop.export.jumpedOther",{name:(String(s.draft.data.name||"").trim()||t("workshop.library.unnamed")), from:(jumpFrom.name||t("workshop.library.unnamed"))}))
1379
+ : h("div",{style:{fontSize:11, color:"var(--dsw-alias-label-secondary)", margin:"-4px 0 10px", lineHeight:1.7}}, t("workshop.export.currentHint")),
1358
1380
  field(t("workshop.export.creatorNotes"),"creator_notes",{rowKey:"notes"}),
1359
1381
  h("div",{style:{display:"grid", gridTemplateColumns:"1fr 1fr", gap:10}},
1360
1382
  field("creation_date","creation_date",{rowKey:"cdate", input:true, readOnly:true, bigBox:false, value:String(s.draft.data.creation_date)}),
@@ -1428,6 +1450,10 @@ window.__ModuleLoader__.load({
1428
1450
  filteredLibrary.map(function(entry){
1429
1451
  var isBusy = s.libraryBusy && String(s.libraryBusy).indexOf(entry.id)>=0;
1430
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);
1431
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;
1432
1458
  return h("div",{key:entry.id, style:itemStyle},
1433
1459
  h("div",{style:{display:"flex", alignItems:"center", justifyContent:"space-between", gap:6}},
@@ -1438,12 +1464,15 @@ window.__ModuleLoader__.load({
1438
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(" · ") || "—"),
1439
1465
  h("div",{style:{display:"flex", flexWrap:"wrap", gap:4, marginTop:6}},
1440
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); }),
1441
- 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
1442
1468
  ),
1443
1469
  h("div",{style:{fontSize:10, color:"var(--dsw-alias-label-secondary)", marginTop:4}}, new Date(entry.updatedAt*1000).toLocaleString()),
1444
1470
  h("div",{style:{display:"flex", gap:6, marginTop:8}},
1445
1471
  h("button",{style:Object.assign({}, S.btn, {padding:"4px 8px", fontSize:11, flex:1}), onClick:function(){ store.loadFromLibrary(entry.id); }, disabled: !!isBusy}, isBusy?"…":t("workshop.library.load")),
1446
- h("button",{style:Object.assign({}, S.btnGhost, {border:"1px solid var(--dsw-alias-border-l1)", background:"transparent", color:"var(--dsw-alias-label-secondary)", borderRadius:8, padding:"4px 8px", fontSize:11}), onClick:function(){ store.exportLibraryEntry(entry.id); }, disabled: !!isBusy}, t("workshop.library.export")),
1472
+ // 「导出」保留在原位,但改成切到导出页(0.4.1 归一):导出页导出的始终是当前工坊里的卡,
1473
+ // 想导这张已存角色先点「载入」—— 免得这里悄悄换掉用户正在编辑的草稿。
1474
+ // 点下去按钮自己先变(「已切到 ✓」),不然切页发生在中栏、用户视线却在右侧,会以为没反应。
1475
+ h("button",{style:Object.assign({}, S.btnGhost, {border:"1px solid var(--dsw-alias-border-l1)", background:"transparent", color:(jumpFrom && jumpFrom.id===entry.id)? "#a6e8c8" : "var(--dsw-alias-label-secondary)", borderRadius:8, padding:"4px 8px", fontSize:11}), title:t("workshop.library.exportTip"), onClick:function(){ jumpToExport(entry); }}, (jumpFrom && jumpFrom.id===entry.id)? t("workshop.library.exportJumped"): t("workshop.library.export")),
1447
1476
  h("button",{style:Object.assign({}, S.btnGhost, {border:"1px solid #3a2a2a", background:"transparent", color:"#e8a6a6", borderRadius:8, padding:"4px 8px", fontSize:11}), onClick:function(){ store.deleteFromLibrary(entry.id); }, disabled: !!isBusy}, t("common.delete"))
1448
1477
  )
1449
1478
  );
@@ -1464,10 +1493,20 @@ window.__ModuleLoader__.load({
1464
1493
  h("span",{style:S.badge}, t("panel.badge")),
1465
1494
  h("div",{style:{display:"flex", gap:6, alignItems:"center"}}, stepsEls)
1466
1495
  ),
1467
- h("div",{style:{display:"flex", gap:8}},
1468
- h("button",{style: Object.assign({}, S.btn, S.btnGhost, {padding:"6px 10px", fontSize:12}), onClick:function(){ store.setPanel(false); }}, t("action.close")),
1469
- h("button",{style: Object.assign({}, S.btn, {padding:"6px 10px", fontSize:12}), onClick:function(){ store.exportJSON(); }}, t("action.export"))
1470
- )
1496
+ // 工具条右侧只留关闭:这里原本还有一枚「⬇ 导出 JSON」(与导出页里的「⬇ JSON」重复,0.4.1 归一)。
1497
+ // 关闭做成 ✕ 图标按钮:不再占「收起」两个字的宽度,悬停给红底 —— 红色是「离开 / 收起」的通用暗示,
1498
+ // 平时保持低调的描边样式。hover 用局部 state(内联样式没有伪类),鼠标按下仍不抢焦点(同步骤项)。
1499
+ h("button",{type:"button", title:t("action.close"), "aria-label":t("action.close"),
1500
+ style:{ appearance:"none", boxSizing:"border-box", width:30, height:30, display:"flex", alignItems:"center", justifyContent:"center",
1501
+ padding:0, font:"inherit", fontSize:15, lineHeight:1, cursor:"pointer", borderRadius:8,
1502
+ background: hoverKey==="close"? "#e05555" : "transparent",
1503
+ border:"1px solid "+(hoverKey==="close"? "#e05555" : "var(--dsw-alias-border-l1)"),
1504
+ color: hoverKey==="close"? "#fff" : "var(--dsw-alias-label-primary)",
1505
+ transition:"background .12s ease, border-color .12s ease, color .12s ease" },
1506
+ onMouseDown:stopFocusSteal,
1507
+ onMouseEnter:function(){ setHoverKey("close"); },
1508
+ onMouseLeave:function(){ setHoverKey(null); },
1509
+ onClick:function(){ store.setPanel(false); }}, "✕")
1471
1510
  ),
1472
1511
  (function(){
1473
1512
  var st = s.draftStatus;
@@ -1503,11 +1542,15 @@ window.__ModuleLoader__.load({
1503
1542
  navItem(0, t("step.world"), t("step.world.desc")),
1504
1543
  navItem(1, t("step.char"), t("step.char.desc")),
1505
1544
  navItem(2, t("step.lore"), t("step.lore.desc")),
1506
- navItem(3, t("step.export"), t("step.export.desc")),
1507
1545
  h("div",{style:{marginTop:"auto", paddingTop:12, borderTop:"1px solid var(--dsw-alias-border-l1)", display:"flex", flexDirection:"column", gap:8}},
1546
+ // 「校验导出」入口:这一步原本是导航里的第 4 项,与这枚按钮重复,0.4.1 合并成一枚。
1547
+ // 点它切到导出页(校验按钮在那一页里)。
1548
+ // ⚠️ 它**不做选中态**:曾经给过淡紫底 + aria-current,结果从右侧卡片点「导出」时,
1549
+ // 这枚按钮和卡片那枚「已切到 ✓」会同时发亮 —— 又是「两处看起来像选中」(0.4.0 修过的同一个坑)。
1550
+ // 「现在在导出页」靠中栏标题表达(标题里写着卡名),不靠按钮。
1508
1551
  h("button",{type:"button", style: Object.assign({}, S.btn, {padding:"6px 10px", fontSize:12}),
1509
1552
  onMouseDown:function(e){ if(e && e.preventDefault) e.preventDefault(); }, // 鼠标点击不抢焦点,免得又留下一圈焦点描边(同左侧步骤项)
1510
- onClick:function(){ store.validate(); }}, s.busy==="validate"? t("action.validating"): t("action.validate")),
1553
+ onClick:function(){ store.setCur(3); }}, t("step.export")),
1511
1554
  // 清空是破坏性操作:先过二次确认才动手;工坊里没有撤销,所以按钮用危险色并与「校验」分开放
1512
1555
  h("button",{type:"button", style: Object.assign({}, S.btn, S.btnGhost, {padding:"6px 10px", fontSize:12, borderColor:"rgba(224,85,85,.55)", color:"#e05555"}),
1513
1556
  onMouseDown:function(e){ if(e && e.preventDefault) e.preventDefault(); },
@@ -1674,6 +1717,11 @@ window.__ModuleLoader__.load({
1674
1717
  try{ if(localStorage.getItem("dsh-cc-studio-legacy-notice")==="1") alts = null; }catch(e){}
1675
1718
  }
1676
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 });
1677
1725
  var draft=res.value.draft;
1678
1726
  var draftStatus=res.value.draftStatus||null;
1679
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.0",
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/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": {