@xia-sc/dsh-cc-studio 0.3.4 → 0.3.5
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 +6 -5
- package/lib/client.js +148 -22
- package/lib/index.js +113 -11
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ DSH(DeepSeek Harness)插件:输入框上方的胶囊 → 全屏融合工
|
|
|
15
15
|
| 项目 | 值 |
|
|
16
16
|
| --- | --- |
|
|
17
17
|
| 插件包名 | `@xia-sc/dsh-cc-studio` |
|
|
18
|
-
| 当前版本 | `0.3.
|
|
18
|
+
| 当前版本 | `0.3.5` |
|
|
19
19
|
| 宿主 RPC | `/dsh-cc-studio-rpc`(自带路由,适配 dsh ≥ `0.1.5-rc.1`;已在 `0.1.6-alpha.2` 实测) |
|
|
20
20
|
| 客户端挂载点 | `conversation.input.dock`(胶囊)+ `shell.overlay`(工坊)+ `settings.section` |
|
|
21
21
|
| 落盘位置 | `~/.dsh/cc-library/`(角色卡)、`~/.dsh/cc-drafts/`(会话草稿) |
|
|
@@ -28,7 +28,7 @@ DSH(DeepSeek Harness)插件:输入框上方的胶囊 → 全屏融合工
|
|
|
28
28
|
dsh plugin --profile web add @xia-sc/dsh-cc-studio
|
|
29
29
|
|
|
30
30
|
# 锁定版本
|
|
31
|
-
dsh plugin --profile web add @xia-sc/dsh-cc-studio@0.3.
|
|
31
|
+
dsh plugin --profile web add @xia-sc/dsh-cc-studio@0.3.5
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
**或从 GitHub 源安装**:
|
|
@@ -237,7 +237,7 @@ dsh-cc-studio/
|
|
|
237
237
|
├── cordis.patch.yml # 宿主行插入:id dsh-cc-studio
|
|
238
238
|
├── lib/
|
|
239
239
|
│ ├── index.js # host: /dsh-cc-studio-rpc(validate, cc_getDraft/cc_setDraft/cc_patchDraft, cc_isCcMode,
|
|
240
|
-
│ │ # cc_validateDraft, 已存库 cc_*Library, 容器 cc_importFromPng/cc_exportPng(+imageB64)/
|
|
240
|
+
│ │ # cc_validateDraft, 草稿槽 cc_migrateDraft, 已存库 cc_*Library, 容器 cc_importFromPng/cc_exportPng(+imageB64)/
|
|
241
241
|
│ │ # cc_importFromCharx/cc_exportCharx, CRC32/ZIP/STORE&DEFLATE)
|
|
242
242
|
│ ├── agent.js # CC 模式 Tools:6 步共创 + 2 Lorebook 管理 + 6 已存库 CRUD = 14 个,含「先与用户讨论」提示
|
|
243
243
|
│ └── client.js # client: dock 胶囊 + overlay 工坊 + settings.section(DSW Token 深浅色、品牌紫、
|
|
@@ -245,7 +245,7 @@ dsh-cc-studio/
|
|
|
245
245
|
├── presets/cc/ # CC 模式预设模板(共创 persona + cc-studio-agent)
|
|
246
246
|
│ ├── preset.yml
|
|
247
247
|
│ └── agent.cordis.yml
|
|
248
|
-
├── tests/ #
|
|
248
|
+
├── tests/ # 5 个测试文件、216 项断言(不随包发布)
|
|
249
249
|
│ ├── rpc-channel.test.mjs # host 半 RPC 通道回归(假 ctx + 真实 http,23 项断言)
|
|
250
250
|
│ ├── preset-install.test.mjs # 预设自动安装决策 + 预设行契约,41 项断言
|
|
251
251
|
│ ├── cc-detection.test.mjs # CC 模式探测的源码级守卫,32 项断言
|
|
@@ -260,7 +260,7 @@ dsh-cc-studio/
|
|
|
260
260
|
## 开发与测试
|
|
261
261
|
|
|
262
262
|
```bash
|
|
263
|
-
npm test #
|
|
263
|
+
npm test # 5 个文件、216 项断言(五个 node 脚本串联)
|
|
264
264
|
node tests/rpc-channel.test.mjs # 只跑 host 半 RPC 通道回归(23 项断言)
|
|
265
265
|
```
|
|
266
266
|
|
|
@@ -280,6 +280,7 @@ node tests/rpc-channel.test.mjs # 只跑 host 半 RPC 通道回归(
|
|
|
280
280
|
|
|
281
281
|
完整历史见 [CHANGELOG.md](./CHANGELOG.md)。最近几版:
|
|
282
282
|
|
|
283
|
+
- `0.3.5` 修 #5「前端与 Tools 读到不同草稿槽」:`useCcPreset` 读的 `useSessions().current` 字段在 dsh `0.1.6-alpha.2` 的 `SessionListState` 里并不存在(恒为 `null`),加上启动时那次无 key 的拉取占掉了全局节流,前端一直用 `default` 槽 —— 而模型经 Tools 写的是 `session-<会话id>` 槽,于是「模型说写好了 / 工坊说没数据」。现在会话 id 只从 slot props 取并发布到 store、拉草稿没有会话 id 就一次都不发、节流按 key、主机回传 key 不一致时不渲染而是告警;另修胶囊闪退(根域实例每秒钟把会话域实例判定的 CC 状态清掉)并新增 `cc_migrateDraft` 与「迁入本会话槽」一键救回。测试 158 → 216 项(新增行为级 `tests/draft-slot-sync.test.mjs`)。
|
|
283
284
|
- `0.3.4` 适配 dsh `0.1.6-alpha.2` 并补回两处预设漏抄:CC 预设缺 `present` 行(CC 模式下模型没有「登记交付物」的工具,且不报错)、`tool-subagent` 行缺 `modelSelectionSettings: true`(子代理的指定模型入口被静默关掉);顺带补上 alpha.2 新增的 `tool-plugin-manager` 行(`disabled`,与内置 `standard` 对齐到只剩有意的行差)。新增 7 项预设行契约守卫。
|
|
284
285
|
- `0.3.3` 发布流程自动化:打 `v*` tag 即由 GitHub Actions 发布到 npm(OIDC trusted publishing,零密钥)并自动建 GitHub Release;手动触发默认只做安全自检。插件运行时行为未变。
|
|
285
286
|
- `0.3.2` 包名迁移至 `@xia-sc/dsh-cc-studio`(原 `@dsh-plugins` 不是本项目持有的 npm scope),并首次发布到 npm;新增 `publishConfig.access` / `prepublishOnly` 等发布元数据。
|
package/lib/client.js
CHANGED
|
@@ -18,6 +18,7 @@ window.__ModuleLoader__.load({
|
|
|
18
18
|
"capsule.draft":"草稿",
|
|
19
19
|
"capsule.expanded":"已展开",
|
|
20
20
|
"capsule.collapseHint":"点击展开",
|
|
21
|
+
"capsule.slotWarn":"⚠ 草稿槽不一致",
|
|
21
22
|
"preset.ccLabel":"CC 模式",
|
|
22
23
|
"panel.title":"角色卡工坊",
|
|
23
24
|
"panel.subtitle":"CCv3 · 从点子到可导入卡",
|
|
@@ -112,6 +113,11 @@ window.__ModuleLoader__.load({
|
|
|
112
113
|
"workshop.export.modificationDate":"modification_date(导出自动刷新)",
|
|
113
114
|
"workshop.draft.fresh":"⚠️ 当前会话是全新空白草稿(无历史存档,可能是 host 重启后的新会话)。在工坊或对话中继续编辑前,请确认这是预期中的新建;如需继续旧卡请去右侧“已存角色”载入。",
|
|
114
115
|
"workshop.draft.recovered":"已从磁盘自动恢复草稿(host 重启后恢复),creation_date={date}。可继续编辑,下次写入后此提示消失。",
|
|
116
|
+
"workshop.slot.mismatch":"⚠ 草稿槽不一致:本次读到的是 `{got}` 槽,而当前会话是 `{want}`。为防止把别的槽画成你的草稿,本次内容未渲染;下一次轮询会按当前会话重新读取。",
|
|
117
|
+
"workshop.slot.recover":"发现另一份已落盘草稿:{name}({n} 条 lore,槽 {key})。如果这就是本会话此前写入的内容,可一键迁入当前会话槽 —— 内容没有丢,只是被写进了另一把 key。",
|
|
118
|
+
"workshop.slot.migrateOne":"迁入 {key}({n} 条 lore)",
|
|
119
|
+
"workshop.slot.migrated":"已把槽 {key} 的内容迁入当前会话({n} 条 lore)。",
|
|
120
|
+
"workshop.slot.migrateFailed":"迁移草稿槽失败",
|
|
115
121
|
"workshop.preview.title":"实时 CCv3 预览",
|
|
116
122
|
"workshop.preview.cbsTitle":"CBS 速查",
|
|
117
123
|
"workshop.preview.cbsHint":"大小写不敏感,逗号用 \\, 转义。",
|
|
@@ -223,6 +229,7 @@ window.__ModuleLoader__.load({
|
|
|
223
229
|
"capsule.draft":"draft",
|
|
224
230
|
"capsule.expanded":"expanded",
|
|
225
231
|
"capsule.collapseHint":"Click to expand",
|
|
232
|
+
"capsule.slotWarn":"⚠ draft slot mismatch",
|
|
226
233
|
"preset.ccLabel":"CC 模式",
|
|
227
234
|
"panel.title":"Character Card Studio",
|
|
228
235
|
"panel.subtitle":"CCv3 · Idea to Importable Card",
|
|
@@ -317,6 +324,11 @@ window.__ModuleLoader__.load({
|
|
|
317
324
|
"workshop.export.modificationDate":"modification_date (auto on export)",
|
|
318
325
|
"workshop.draft.fresh":"⚠️ This session has a fresh blank draft (no saved history, possibly a new session after host restart). Please confirm this is an intended new card before editing; to continue an old card, load it from Saved Characters on the right.",
|
|
319
326
|
"workshop.draft.recovered":"Draft auto-restored from disk (after host restart), creation_date={date}. Keep editing; this notice clears after the next write.",
|
|
327
|
+
"workshop.slot.mismatch":"⚠ Draft slot mismatch: this read came from slot `{got}` while the current session is `{want}`. To avoid painting another slot's content as your draft, nothing was rendered; the next poll re-reads for the current session.",
|
|
328
|
+
"workshop.slot.recover":"Another persisted draft was found: {name} ({n} lore entries, slot {key}). If it belongs to this session, migrate it in one click — nothing was lost, it was just written under a different key.",
|
|
329
|
+
"workshop.slot.migrateOne":"Migrate {key} ({n} lore)",
|
|
330
|
+
"workshop.slot.migrated":"Migrated slot {key} into this session ({n} lore entries).",
|
|
331
|
+
"workshop.slot.migrateFailed":"Failed to migrate the draft slot",
|
|
320
332
|
"workshop.preview.title":"Live CCv3 Preview",
|
|
321
333
|
"workshop.preview.cbsTitle":"CBS Cheatsheet",
|
|
322
334
|
"workshop.preview.cbsHint":"Case-insensitive, escape comma with \\,.",
|
|
@@ -471,7 +483,7 @@ window.__ModuleLoader__.load({
|
|
|
471
483
|
var _saved = loadSettings();
|
|
472
484
|
// 兼容旧开关:poll=false 视为 0 秒
|
|
473
485
|
if(_saved.poll===false) _saved.pollInterval = 0;
|
|
474
|
-
var state={ draft: draft, draftStatus: null, cur:0, panelOpen:false, sideExpanded:false, isCcMode:false, currentSessionId:null, world:{ timeline:"", factions:"", geo:"", power:"", daily:"" }, busy:null, valid:{valid:true,errors:[],warnings:[]}, lastMsg:null, library:[], libraryQuery:"", libraryCollapsed:false, libraryBusy:null, currentLibraryId:null, editingWorld:null, editingWorldRevert:null, editingText:null, pollEnabled:_saved.poll, pollInterval:_saved.pollInterval, autoValidate:_saved.autoValidate, autoSaveOnImport:_saved.autoSaveOnImport };
|
|
486
|
+
var state={ draft: draft, draftStatus: null, cur:0, panelOpen:false, sideExpanded:false, isCcMode:false, currentSessionId:null, slotWarn:null, alternateSlots:null, world:{ timeline:"", factions:"", geo:"", power:"", daily:"" }, busy:null, valid:{valid:true,errors:[],warnings:[]}, lastMsg:null, library:[], libraryQuery:"", libraryCollapsed:false, libraryBusy:null, currentLibraryId:null, editingWorld:null, editingWorldRevert:null, editingText:null, pollEnabled:_saved.poll, pollInterval:_saved.pollInterval, autoValidate:_saved.autoValidate, autoSaveOnImport:_saved.autoSaveOnImport };
|
|
475
487
|
var listeners=new Set();
|
|
476
488
|
function emit(){ listeners.forEach(function(fn){ try{fn();}catch(e){console.error(e);} }); }
|
|
477
489
|
function getSnapshot(){ return state; }
|
|
@@ -484,7 +496,29 @@ window.__ModuleLoader__.load({
|
|
|
484
496
|
// 写值恒等于 isCcMode,因此渲染条件里的 !s.triggered 是冗余项(删除前后行为完全一致)。
|
|
485
497
|
function setIsCcMode(v){ var on=!!v; if(on!==state.isCcMode) set({isCcMode: on}); }
|
|
486
498
|
function setDraftFromHost(draft, status){ try{ var j=JSON.stringify(draft); var curJ=JSON.stringify(state.draft); var stJ=JSON.stringify(status||null); var curStJ=JSON.stringify(state.draftStatus||null); if(stJ!==curStJ) set({draftStatus: status||null}); if(j!==curJ){ var w=draft&&draft.data&&draft.data.extensions&&draft.data.extensions.cc_world; if(w && typeof w==="object"){ var nw={ timeline:String(w.timeline||""), factions:String(w.factions||""), geo:String(w.geo||""), power:String(w.power||""), daily:String(w.daily||"") }; var curW=state.world; if(JSON.stringify(nw)!==JSON.stringify(curW)) set({draft: draft, world: nw}); else set({draft: draft}); } else set({draft: draft}); } }catch(e){ try{ var ww=draft&&draft.data&&draft.data.extensions&&draft.data.extensions.cc_world; if(ww){ var nn={ timeline:String(ww.timeline||""), factions:String(ww.factions||""), geo:String(ww.geo||""), power:String(ww.power||""), daily:String(ww.daily||"") }; set({draft: draft, world: nn}); } else set({draft: draft}); }catch(e2){ set({draft: draft}); } } }
|
|
487
|
-
function setSessionId(id){ if(id) set({currentSessionId:
|
|
499
|
+
function setSessionId(id){ if(!id) return; var v=String(id); if(v!==state.currentSessionId) set({currentSessionId: v}); }
|
|
500
|
+
// 草稿槽不一致告警(issue #5):主机回传的 key 与我们要的不是同一把时,绝不把它当当前草稿画出来。
|
|
501
|
+
function setSlotWarn(w){ set({slotWarn: w||null}); }
|
|
502
|
+
function setAlternateSlots(list){ set({alternateSlots: (list && list.length)? list : null}); }
|
|
503
|
+
// 把另一把槽的内容迁进当前会话槽(用户点「迁入当前会话槽」时调用)
|
|
504
|
+
async function migrateSlot(from){
|
|
505
|
+
var to = state.currentSessionId || null;
|
|
506
|
+
if(!from || !to) return;
|
|
507
|
+
set({busy:"migrate"});
|
|
508
|
+
try{
|
|
509
|
+
var res=await rpc("cc_migrateDraft", {from: from, to: to, sessionId: to});
|
|
510
|
+
if(res && res.ok && res.value && res.value.draft){
|
|
511
|
+
setDraftFromHost(res.value.draft, res.value.draftStatus||null);
|
|
512
|
+
// 迁过一次就不再重复提示「盘上还有一份 default 草稿」——源槽内容不删(不做破坏性操作),
|
|
513
|
+
// 但每开一个新会话都弹一次同样的横幅只会变成噪音。
|
|
514
|
+
if(from==="default"){ try{ localStorage.setItem("dsh-cc-studio-legacy-notice","1"); }catch(e){} }
|
|
515
|
+
set({busy:null, slotWarn:null, alternateSlots:null, currentLibraryId:null,
|
|
516
|
+
lastMsg: t("workshop.slot.migrated",{key:from, n:String((res.value.moved && res.value.moved.entries)||0)})});
|
|
517
|
+
} else {
|
|
518
|
+
set({busy:null, lastMsg: t("workshop.slot.migrateFailed") + (res && res.error ? (" — "+String(res.error.message)) : "")});
|
|
519
|
+
}
|
|
520
|
+
}catch(e){ set({busy:null, lastMsg: t("workshop.slot.migrateFailed")+" — "+String(e.message)}); }
|
|
521
|
+
}
|
|
488
522
|
function saveSettings(){
|
|
489
523
|
try{ localStorage.setItem("dsh-cc-studio-settings", JSON.stringify({ poll: !!state.pollEnabled, pollInterval: Number(state.pollInterval)||0, autoValidate: !!state.autoValidate, autoSaveOnImport: !!state.autoSaveOnImport })); }catch(e){}
|
|
490
524
|
}
|
|
@@ -871,7 +905,7 @@ window.__ModuleLoader__.load({
|
|
|
871
905
|
}catch(e){ set({lastMsg: String(e && e.message || e)}); }
|
|
872
906
|
set({libraryBusy:null});
|
|
873
907
|
}
|
|
874
|
-
return { getSnapshot:getSnapshot, subscribe:subscribe, setCur:setCur, setPanel:setPanel, setSideExpanded:setSideExpanded, setIsCcMode:setIsCcMode, setDraftFromHost:setDraftFromHost, setSessionId:setSessionId, 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, loadFromLibrary:loadFromLibrary, deleteFromLibrary:deleteFromLibrary, exportLibraryEntry:exportLibraryEntry, renameCurrentInLibrary:renameCurrentInLibrary };
|
|
908
|
+
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, loadFromLibrary:loadFromLibrary, deleteFromLibrary:deleteFromLibrary, exportLibraryEntry:exportLibraryEntry, renameCurrentInLibrary:renameCurrentInLibrary };
|
|
875
909
|
}
|
|
876
910
|
|
|
877
911
|
var S={
|
|
@@ -952,27 +986,67 @@ window.__ModuleLoader__.load({
|
|
|
952
986
|
var p = presetId ? String(presetId).toLowerCase() : "";
|
|
953
987
|
if(p) return p===CC_PRESET_ID; // 会话投影是权威本地来源
|
|
954
988
|
if(domVerdict) return true; // 预设芯片显示 CC
|
|
955
|
-
|
|
989
|
+
// 连会话上下文都没有(既没有会话 id、芯片也没显示 CC):**无权下否的结论**。
|
|
990
|
+
// issue #5 的「胶囊闪一下又消失」就出在这里:shell.overlay 是根域挂载点,拿不到会话 id,
|
|
991
|
+
// 它对同一个 store 每秒 evaluate() 一次,旧代码在这里返回 false,把会话域实例刚判定为
|
|
992
|
+
// true 的 isCcMode 清掉 → 胶囊被卸载,下一次会话域实例又装回来 = 一闪一闪。
|
|
993
|
+
// 判定不出「是」时只保持现状,让真正有会话上下文的实例(或 RPC)去改。
|
|
994
|
+
if(!hasSession) return prevIsCc ? true : false;
|
|
956
995
|
// 已有会话但投影尚未到达:不确定。此时关闭会造成「选 CC → 首次对话」瞬间的胶囊闪烁,
|
|
957
996
|
// 所以只有在当前并非 CC 时才返回 null,让 RPC 去确认。
|
|
958
997
|
return prevIsCc ? true : null;
|
|
959
998
|
}
|
|
999
|
+
/**
|
|
1000
|
+
* 从 slot 的 props 里取「本组件所属的会话 id」。权威顺序:
|
|
1001
|
+
* ① session 域 slot 的标准属性 `sessionId`(SessionId);
|
|
1002
|
+
* ② `session`(会话域 ownerProps 里的 SessionSnapshot,id 在 .sessionId 上);
|
|
1003
|
+
* ③ 异形兜底:props.sessionId 是对象时读它的 .sessionId。
|
|
1004
|
+
* 注意:**不要**再读 useSessions() 的 `current` 字段 —— 0.1.6-alpha.2 的 SessionListState
|
|
1005
|
+
* 只有 ids / byId / phase(dsh-api-session-controller 的 sessions/service.d.ts),没有 current,
|
|
1006
|
+
* 旧代码读它恒为 null,于是「会话投影 = 权威」这条路径从来没生效过(issue #5)。
|
|
1007
|
+
*/
|
|
1008
|
+
function ccSessionIdOfProps(props){
|
|
1009
|
+
try{
|
|
1010
|
+
if(!props) return null;
|
|
1011
|
+
var direct=props.sessionId;
|
|
1012
|
+
if(typeof direct==="string" && direct) return direct;
|
|
1013
|
+
var snap=props.session;
|
|
1014
|
+
if(snap && typeof snap.sessionId==="string" && snap.sessionId) return snap.sessionId;
|
|
1015
|
+
if(direct && typeof direct==="object" && typeof direct.sessionId==="string" && direct.sessionId) return direct.sessionId;
|
|
1016
|
+
}catch(e){}
|
|
1017
|
+
return null;
|
|
1018
|
+
}
|
|
960
1019
|
/**
|
|
961
1020
|
* 探测 CC 模式并写入 store,返回当前会话 id。
|
|
962
1021
|
*/
|
|
963
1022
|
function useCcPreset(store, props, t){
|
|
964
1023
|
var useSessions=props.useSessions;
|
|
965
|
-
|
|
966
|
-
|
|
1024
|
+
// ownId:本组件自己绑定的会话 id(跨渲染稳定)。shell.overlay 是根域,没有会话绑定 → null,
|
|
1025
|
+
// 这时退到 store.currentSessionId(由带会话绑定的那个实例发布,两端共享同一份映射)。
|
|
1026
|
+
var ownId = ccSessionIdOfProps(props);
|
|
1027
|
+
var currentId = ownId || store.getSnapshot().currentSessionId || null;
|
|
1028
|
+
// 会话投影:必须用上面解析出来的会话 id 去 byId 里查(旧代码用不存在的 s.current 作下标)。
|
|
967
1029
|
var presetId = useSessions ? useSessions(function(s){
|
|
968
|
-
try{
|
|
1030
|
+
try{
|
|
1031
|
+
if(!currentId) return null;
|
|
1032
|
+
return ccPresetIdOf(s && s.byId && s.byId[currentId]);
|
|
1033
|
+
}catch(e){ return null; }
|
|
969
1034
|
}) : null;
|
|
970
1035
|
var ccLabel=(typeof t==="function" ? t("preset.ccLabel") : "") || "";
|
|
971
1036
|
var evaluate=React.useCallback(function(){
|
|
972
1037
|
var verdict=decideCcMode(presetId, ccModeFromDom(ccLabel), !!currentId, !!store.getSnapshot().isCcMode);
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
1038
|
+
// 没有会话上下文的实例(根域的 shell.overlay)只能把结论改成「是」,不能改成「否」:
|
|
1039
|
+
// 它无从知道当前会话是不是 CC 模式,写 false 就是 issue #5 的胶囊闪退。
|
|
1040
|
+
var canConclude = !!ownId || !!currentId;
|
|
1041
|
+
if(verdict===null) return !!store.getSnapshot().isCcMode;
|
|
1042
|
+
if(verdict || canConclude) store.setIsCcMode(verdict);
|
|
1043
|
+
return verdict || !!store.getSnapshot().isCcMode;
|
|
1044
|
+
}, [store, presetId, currentId, ownId, ccLabel]);
|
|
1045
|
+
// 把「本组件所属会话」发布到 store:这是全局唯一一份「当前会话 → 草稿槽」的映射,
|
|
1046
|
+
// 草稿轮询、根域实例、以及所有 cc_* 调用都读它(issue #5 的两槽并存就是它一直是 null 造成的)。
|
|
1047
|
+
React.useEffect(function(){
|
|
1048
|
+
if(ownId) store.setSessionId(ownId);
|
|
1049
|
+
}, [store, ownId]);
|
|
976
1050
|
React.useEffect(function(){
|
|
977
1051
|
var isCc=evaluate();
|
|
978
1052
|
if(!currentId) return;
|
|
@@ -1008,7 +1082,7 @@ window.__ModuleLoader__.load({
|
|
|
1008
1082
|
var valid=s.valid && s.valid.valid;
|
|
1009
1083
|
// 计数文案走词表(沿用既有 replace("{n}") 插值模式,locale 的 t() 不做插值)
|
|
1010
1084
|
var loreText=t("common.loreCount").replace("{n}", String(loreCount));
|
|
1011
|
-
var text=(t("capsule.open")+" · "+loreText+" · "+ (valid? t("capsule.exported"): t("capsule.draft")));
|
|
1085
|
+
var text=(t("capsule.open")+" · "+loreText+" · "+ (valid? t("capsule.exported"): t("capsule.draft")) + (s.slotWarn? (" · "+t("capsule.slotWarn")): ""));
|
|
1012
1086
|
return h("div",{style:S.dockWrap, "data-dsh-cc-studio":"dock-row"},
|
|
1013
1087
|
h("button",{type:"button", style:S.capsule, title: t("hint.capsule"), onClick:function(){ store.setPanel(true); }},
|
|
1014
1088
|
h("span",{style:{width:7,height:7,borderRadius:50, background: valid? "#2ee6a6":"#7c5cff", boxShadow:"0 0 8px "+(valid?"#2ee6a6":"#7c5cff"), display:"inline-block", flex:"none"}}),
|
|
@@ -1294,6 +1368,24 @@ window.__ModuleLoader__.load({
|
|
|
1294
1368
|
),
|
|
1295
1369
|
(function(){
|
|
1296
1370
|
var st = s.draftStatus;
|
|
1371
|
+
// 草稿槽不一致 / 发现别的槽有内容时的提示(issue #5):把「不一致」变成看得见的东西,
|
|
1372
|
+
// 并给一个真的能把内容捞回来的按钮 —— 用户看到空壳时的第一反应是「草稿丢了」。
|
|
1373
|
+
if(s.slotWarn){
|
|
1374
|
+
return h("div",{style:{margin:"10px 16px 0", padding:"8px 12px", borderRadius:10, fontSize:12, lineHeight:1.5, background:"rgba(232,120,120,.12)", border:"1px solid rgba(232,120,120,.5)", color:"var(--dsw-alias-label-primary)", flex:"none"}},
|
|
1375
|
+
t("workshop.slot.mismatch",{got:String(s.slotWarn.got||""), want:String(s.slotWarn.requested||"")}));
|
|
1376
|
+
}
|
|
1377
|
+
if(s.alternateSlots && s.alternateSlots.length){
|
|
1378
|
+
return h("div",{style:{margin:"10px 16px 0", padding:"8px 12px", borderRadius:10, fontSize:12, lineHeight:1.5, background:"rgba(255,170,60,.10)", border:"1px solid rgba(255,170,60,.45)", color:"var(--dsw-alias-label-primary)", flex:"none"}},
|
|
1379
|
+
h("div",null, t("workshop.slot.recover",{name:String(s.alternateSlots[0].name||"—"), n:String(s.alternateSlots[0].entries||0), key:String(s.alternateSlots[0].key||"")})),
|
|
1380
|
+
h("div",{style:{marginTop:8, display:"flex", gap:8, flexWrap:"wrap"}},
|
|
1381
|
+
s.alternateSlots.map(function(slot){
|
|
1382
|
+
return h("button",{key:String(slot.key), style:Object.assign({}, S.btn, {padding:"4px 10px", fontSize:11}),
|
|
1383
|
+
disabled: s.busy==="migrate",
|
|
1384
|
+
onClick:function(){ store.migrateSlot(String(slot.key)); }},
|
|
1385
|
+
t("workshop.slot.migrateOne",{key:String(slot.key), n:String(slot.entries||0)}));
|
|
1386
|
+
})
|
|
1387
|
+
));
|
|
1388
|
+
}
|
|
1297
1389
|
if(!st) return null;
|
|
1298
1390
|
if(st.isNew) return h("div",{style:{margin:"10px 16px 0", padding:"8px 12px", borderRadius:10, fontSize:12, lineHeight:1.5, background:"rgba(255,170,60,.10)", border:"1px solid rgba(255,170,60,.45)", color:"var(--dsw-alias-label-primary)", flex:"none"}}, t("workshop.draft.fresh"));
|
|
1299
1391
|
if(st.recovered){
|
|
@@ -1441,23 +1533,49 @@ window.__ModuleLoader__.load({
|
|
|
1441
1533
|
function apply(ctx){
|
|
1442
1534
|
ctx.effect(function(){ return ctx.locale.register(NS, { zh:zh, en:en }); }, "cc-studio:locale");
|
|
1443
1535
|
var store=createStore(ctx);
|
|
1444
|
-
// CC 模式检测 + 草稿同步:会话切换触发 + CC
|
|
1536
|
+
// CC 模式检测 + 草稿同步:会话切换触发 + CC 模式下按设置间隔轻量轮询。
|
|
1537
|
+
// 铁律:拉草稿必须带会话 id(见 pullDraft 注释,issue #5),绝不拿 default 槽顶替。
|
|
1445
1538
|
ctx.effect(function(){
|
|
1446
1539
|
var lastHostJson="";
|
|
1447
1540
|
var lastHostStatusJson="";
|
|
1541
|
+
var lastHostKey=null;
|
|
1542
|
+
// 拉一份草稿。**必须带 key**:没有会话 id 就什么都不拉。
|
|
1543
|
+
// 旧代码在 args={} 时照样发请求,主机在 HTTP 上下文里拿不到 currentInitiator(),
|
|
1544
|
+
// draftKeyFrom 回退成 "default" —— 于是「模型写 session-<id>、工坊画 default」两槽并存,
|
|
1545
|
+
// 用户看到空壳以为草稿丢了(issue #5)。宁可什么都不画,也不画错槽。
|
|
1448
1546
|
function pullDraft(sessionId){
|
|
1449
|
-
var
|
|
1450
|
-
if(
|
|
1451
|
-
|
|
1547
|
+
var key = (typeof sessionId==="string" && sessionId) ? sessionId : null;
|
|
1548
|
+
if(!key) return Promise.resolve();
|
|
1549
|
+
store.setSessionId(key);
|
|
1550
|
+
return ctx.connection.rpc.call("/dsh-cc-studio-rpc","cc_getDraft",{args:{sessionId:key}}).then(function(res){
|
|
1452
1551
|
if(!res || !res.ok || !res.value || !res.value.draft) return;
|
|
1552
|
+
var gotKey = (typeof res.value.key==="string" && res.value.key) ? res.value.key : null;
|
|
1553
|
+
// 期间用户切了会话:这次响应已经过期,丢掉(否则会把上一个会话的草稿画进新会话)
|
|
1554
|
+
var cur = store.getSnapshot().currentSessionId;
|
|
1555
|
+
if(cur && cur !== key) return;
|
|
1556
|
+
// 主机回的槽和我们点名的槽不是同一把:**不渲染**,只把不一致摆到台面上。
|
|
1557
|
+
// 正常情况下不该发生(我们总是显式传 sessionId);留着是为了让这类错位几秒内可见。
|
|
1558
|
+
if(gotKey && gotKey !== key){
|
|
1559
|
+
store.setSlotWarn({ requested:key, got:gotKey, source: res.value.keySource||null });
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1562
|
+
store.setSlotWarn(null);
|
|
1563
|
+
var alts = res.value.alternateSlots || null;
|
|
1564
|
+
// 只对「老版本遗留的 default 槽」做一次性提示:迁过一次就别再每开一个新会话弹一遍
|
|
1565
|
+
if(alts && alts.length===1 && alts[0].key==="default"){
|
|
1566
|
+
try{ if(localStorage.getItem("dsh-cc-studio-legacy-notice")==="1") alts = null; }catch(e){}
|
|
1567
|
+
}
|
|
1568
|
+
store.setAlternateSlots(alts);
|
|
1453
1569
|
var draft=res.value.draft;
|
|
1454
1570
|
var draftStatus=res.value.draftStatus||null;
|
|
1455
1571
|
try{
|
|
1456
1572
|
var j=JSON.stringify(draft);
|
|
1457
1573
|
var stJ=JSON.stringify(draftStatus);
|
|
1458
|
-
|
|
1574
|
+
// 换 key 时缓存必须失效,否则「内容恰好相同」的两把槽之间会互相吞掉更新
|
|
1575
|
+
if(j!==lastHostJson || stJ!==lastHostStatusJson || gotKey!==lastHostKey){
|
|
1459
1576
|
lastHostJson=j;
|
|
1460
1577
|
lastHostStatusJson=stJ;
|
|
1578
|
+
lastHostKey=gotKey;
|
|
1461
1579
|
var curJ=JSON.stringify(store.getSnapshot().draft);
|
|
1462
1580
|
var curStJ=JSON.stringify(store.getSnapshot().draftStatus||null);
|
|
1463
1581
|
if(j!==curJ || stJ!==curStJ) store.setDraftFromHost(draft, draftStatus);
|
|
@@ -1466,25 +1584,33 @@ window.__ModuleLoader__.load({
|
|
|
1466
1584
|
}).catch(function(){});
|
|
1467
1585
|
}
|
|
1468
1586
|
var lastPullAt = 0;
|
|
1587
|
+
var lastPullKey = null;
|
|
1469
1588
|
function pullDraftThrottled(sid){
|
|
1589
|
+
var key = (typeof sid==="string" && sid) ? sid : null;
|
|
1590
|
+
if(!key) return; // 没有会话 id 就不拉(见 pullDraft 注释)
|
|
1591
|
+
// 只节流「同一把 key」的重复请求:旧代码一把全局时间戳会在启动时把
|
|
1592
|
+
// 「会话就绪后的第一次正式拉取」直接吞掉(apply 里的初始 pullDraft(null) 先占了坑),
|
|
1593
|
+
// 于是 store.currentSessionId 永远没人写 → 轮询永远读 default 槽。
|
|
1470
1594
|
var now = Date.now();
|
|
1471
|
-
if(now - lastPullAt < 500) return;
|
|
1595
|
+
if(key === lastPullKey && now - lastPullAt < 500) return;
|
|
1472
1596
|
lastPullAt = now;
|
|
1473
|
-
|
|
1597
|
+
lastPullKey = key;
|
|
1598
|
+
pullDraft(key);
|
|
1474
1599
|
}
|
|
1475
|
-
// 初始拉一次(default)
|
|
1476
|
-
pullDraftThrottled(null);
|
|
1477
1600
|
// 暴露给 Capsule/Workshop 按会话触发(带节流)
|
|
1478
1601
|
store._pullDraft = pullDraftThrottled;
|
|
1602
|
+
// 注意:这里**不再**做「初始拉一次 default」。启动时还没有会话,拉了只会把 default 槽
|
|
1603
|
+
// 的空壳画进工坊;store 里本来就有一份本地空白草稿,够新会话页用了。
|
|
1479
1604
|
// CC 模式下轻量轮询,保证 LLM 的 cc_* Tool 写入后胶囊实时同步(切换会话时会自动更新 currentSessionId)
|
|
1480
|
-
// 仅 CC 模式下以 4s
|
|
1605
|
+
// 仅 CC 模式下以 4s 心跳拉取,标准模式/未触发时不请求
|
|
1481
1606
|
var timer = setInterval(function(){
|
|
1482
1607
|
var s = store.getSnapshot();
|
|
1483
1608
|
var intervalSec = Number(s.pollInterval)||0;
|
|
1484
1609
|
if(!s.isCcMode || intervalSec===0) return;
|
|
1610
|
+
var sid = s.currentSessionId || null;
|
|
1611
|
+
if(!sid) return; // 会话 id 未知:不拿 default 顶替
|
|
1485
1612
|
var elapsed = Date.now() - lastPullAt;
|
|
1486
1613
|
if(elapsed < intervalSec*1000) return;
|
|
1487
|
-
var sid = s.currentSessionId || null;
|
|
1488
1614
|
pullDraftThrottled(sid);
|
|
1489
1615
|
}, 1000);
|
|
1490
1616
|
return function(){ clearInterval(timer); };
|
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";
|
|
@@ -136,18 +136,88 @@ function draftStatusFor(key, draft){
|
|
|
136
136
|
const m = draftMeta(key);
|
|
137
137
|
return { isNew: !!m.isNew, recovered: !!m.recovered, creation_date: (draft && draft.data) ? draft.data.creation_date : null, key: String(key) };
|
|
138
138
|
}
|
|
139
|
-
|
|
140
|
-
|
|
139
|
+
/**
|
|
140
|
+
* 解析「这次调用读写哪把草稿槽」,并把来源一并带出来(诊断用)。
|
|
141
|
+
*
|
|
142
|
+
* 槽 key 的不变量:**同一会话里,Tools 与浏览器必须算出同一个 key**。
|
|
143
|
+
* Tools 侧(lib/agent.js 的 keyFor)拿的是 `exec.agent.session.id`;浏览器侧要么把会话 id
|
|
144
|
+
* 明确传过来,要么这里回退到 `agents.currentInitiator()` —— 但 RPC 是 HTTP 处理函数,
|
|
145
|
+
* 没有 agent 上下文,currentInitiator() 恒为空,于是旧逻辑回退到 "default"。
|
|
146
|
+
* 结果就是 issue #5:模型写 `session-<id>`、前端读 `default`,两槽并存、互相"看不见"。
|
|
147
|
+
* 所以这里做两件事:① 把前端可能误传的会话快照对象认出来;② 回传 source 让上层能把
|
|
148
|
+
* 「这次是回退到 default 了」这件事报给前端,而不是静默建一个空槽。
|
|
149
|
+
*/
|
|
150
|
+
function draftKeyPartsFrom(ctx, args){
|
|
151
|
+
const raw = args ? args.sessionId : null;
|
|
152
|
+
if(typeof raw === "string" && raw) return { key: raw, source: "arg" };
|
|
153
|
+
// 前端历史上可能把整个会话快照对象传进来(SessionSnapshot 的 id 在 .sessionId 上)。
|
|
154
|
+
// 旧逻辑 `typeof args.sessionId==="string"` 直接把它当「没传」丢弃 → 回退 default 槽;
|
|
155
|
+
// 若改成 String(object) 又会得到 "[object Object]" 这把假 key。两种都不对,认它的 .sessionId。
|
|
156
|
+
if(raw && typeof raw === "object" && typeof raw.sessionId === "string" && raw.sessionId)
|
|
157
|
+
return { key: raw.sessionId, source: "arg-snapshot" };
|
|
141
158
|
try{
|
|
142
159
|
const agents = ctx.get("agents");
|
|
143
160
|
const ag = agents && agents.currentInitiator && agents.currentInitiator();
|
|
144
161
|
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);
|
|
162
|
+
if(ag.session && ag.session.id) return { key: String(ag.session.id), source: "initiator" };
|
|
163
|
+
if(ag.id) return { key: String(ag.id), source: "initiator" };
|
|
164
|
+
if(ag.sessionId) return { key: String(ag.sessionId), source: "initiator" };
|
|
148
165
|
}
|
|
149
166
|
}catch{}
|
|
150
|
-
return "default";
|
|
167
|
+
return { key: "default", source: "fallback" };
|
|
168
|
+
}
|
|
169
|
+
function draftKeyFrom(ctx, args){ return draftKeyPartsFrom(ctx, args).key; }
|
|
170
|
+
/**
|
|
171
|
+
* 草稿「有没有内容」的判定(不追求 validateCard 那么严:迁移是自家两个槽之间搬东西,
|
|
172
|
+
* name 为空但 lore 写满了的草稿也算有内容,不能因此拒绝迁移)。
|
|
173
|
+
*/
|
|
174
|
+
function draftContentSummary(draft){
|
|
175
|
+
const d = (draft && draft.data && typeof draft.data === "object") ? draft.data : {};
|
|
176
|
+
const entries = (d.character_book && Array.isArray(d.character_book.entries)) ? d.character_book.entries.length : 0;
|
|
177
|
+
let filled = 0;
|
|
178
|
+
["name","description","personality","scenario","system_prompt","post_history_instructions","first_mes","mes_example","creator_notes"].forEach(function(k){
|
|
179
|
+
if(String(d[k]==null ? "" : d[k]).trim()) filled += 1;
|
|
180
|
+
});
|
|
181
|
+
if(Array.isArray(d.alternate_greetings) && d.alternate_greetings.length) filled += 1;
|
|
182
|
+
if(entries) filled += 1;
|
|
183
|
+
return { name: String(d.name==null ? "" : d.name), entries: entries, filled: filled };
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* 盘上还有哪些草稿槽(按写入时间倒序)。只用于「当前槽是空壳」时的救回提示 ——
|
|
187
|
+
* 这是 issue #5 里用户唯一能自救的入口:内容没丢,只是被写进了另一把 key。
|
|
188
|
+
*/
|
|
189
|
+
function listDraftSlotsOnDisk(){
|
|
190
|
+
const out = [];
|
|
191
|
+
let names = [];
|
|
192
|
+
try{ names = readdirSync(draftDir()); }catch{ return out; }
|
|
193
|
+
for(const f of names){
|
|
194
|
+
if(!/\.json$/i.test(String(f))) continue;
|
|
195
|
+
let obj = null;
|
|
196
|
+
try{ obj = JSON.parse(readFileSync(join(draftDir(), String(f)), "utf8")); }catch{ continue; }
|
|
197
|
+
const d = obj && obj.draft;
|
|
198
|
+
if(!d || typeof d !== "object" || d.spec !== "chara_card_v3" || !d.data || typeof d.data !== "object") continue;
|
|
199
|
+
const key = obj.key == null ? null : String(obj.key);
|
|
200
|
+
if(!key) continue;
|
|
201
|
+
const sum = draftContentSummary(d);
|
|
202
|
+
out.push({ key: key, updatedAt: Number(obj.updatedAt) || 0, name: sum.name, entries: sum.entries, hasContent: sum.filled > 0 });
|
|
203
|
+
}
|
|
204
|
+
out.sort(function(a,b){ return b.updatedAt - a.updatedAt; });
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* 草稿槽迁移的纯决策函数(便于单测,与 planPresetInstall 同一套路子)。
|
|
209
|
+
* @returns {ok:true, draft, summary} 或 {ok:false, code, message, details}
|
|
210
|
+
*/
|
|
211
|
+
function migrateDraftDecision(source, target, overwrite){
|
|
212
|
+
if(!source || typeof source!=="object" || source.spec!=="chara_card_v3" || !source.data || typeof source.data!=="object")
|
|
213
|
+
return { ok:false, code:"empty-source", message:"源槽没有草稿" };
|
|
214
|
+
const srcSum = draftContentSummary(source);
|
|
215
|
+
if(srcSum.filled === 0) return { ok:false, code:"empty-source", message:"源槽草稿是空壳,无需迁移" };
|
|
216
|
+
const tgtSum = target ? draftContentSummary(target) : null;
|
|
217
|
+
// 绝不静默覆盖:目标槽已有内容时必须显式 overwrite
|
|
218
|
+
if(!overwrite && tgtSum && tgtSum.filled > 0)
|
|
219
|
+
return { ok:false, code:"target-not-empty", message:"目标槽已有内容,需 overwrite: true", details:{ target: tgtSum } };
|
|
220
|
+
return { ok:true, draft: JSON.parse(JSON.stringify(source)), summary: srcSum };
|
|
151
221
|
}
|
|
152
222
|
function getDraftFor(key){
|
|
153
223
|
const k = key || "default";
|
|
@@ -700,12 +770,43 @@ function apply(ctx, config){
|
|
|
700
770
|
return ok({ ok:true, time: Date.now() });
|
|
701
771
|
}
|
|
702
772
|
case "cc_isCcMode":{
|
|
703
|
-
|
|
773
|
+
const parts = draftKeyPartsFrom(ctx, args);
|
|
774
|
+
return ok({ isCc: isCcPreset(ctx, (parts.source==="arg"||parts.source==="arg-snapshot") ? parts.key : null) });
|
|
704
775
|
}
|
|
705
776
|
case "cc_getDraft":{
|
|
706
|
-
const
|
|
777
|
+
const parts = draftKeyPartsFrom(ctx, args);
|
|
778
|
+
const k = parts.key;
|
|
707
779
|
const d = getDraftFor(k);
|
|
708
|
-
|
|
780
|
+
const status = draftStatusFor(k, d);
|
|
781
|
+
const payload = { draft: d, key: k, keySource: parts.source, isCc: isCcPreset(ctx, (parts.source==="arg"||parts.source==="arg-snapshot") ? k : null), draftStatus: status };
|
|
782
|
+
// 只在「当前槽是个新空壳」时才去盘上翻别的槽(issue #5 的救回入口)。
|
|
783
|
+
// 而且只翻**这次错位真正会牵涉到的另一把槽**,不是把所有会话的槽都端出来:
|
|
784
|
+
// · 当前是会话槽(session-<id>)→ 候选只有 default —— 旧前端在没有会话 id 时
|
|
785
|
+
// 一律读写 default,用户在工坊里的编辑很可能就落在那里;
|
|
786
|
+
// · 当前是 default → 候选是各个 session-* 槽 —— 模型经 Tools 写的是会话槽。
|
|
787
|
+
// 其余情况(比如别的会话的槽)与本会话无关,端出来只会变成噪音。
|
|
788
|
+
if(status.isNew || k === "default"){
|
|
789
|
+
try{
|
|
790
|
+
const all = listDraftSlotsOnDisk().filter(s=> s.key!==k && s.hasContent);
|
|
791
|
+
const alts = (k === "default") ? all.filter(s=> s.key !== "default") : all.filter(s=> s.key === "default");
|
|
792
|
+
if(alts.length) payload.alternateSlots = alts.slice(0, 3);
|
|
793
|
+
}catch{}
|
|
794
|
+
}
|
|
795
|
+
return ok(payload);
|
|
796
|
+
}
|
|
797
|
+
case "cc_migrateDraft":{
|
|
798
|
+
// 把别的草稿槽搬进本会话槽:args: { from, to?, overwrite? }
|
|
799
|
+
// to 缺省 = 当前会话(走同一套 key 解析,前端不必自己拼 key)。
|
|
800
|
+
const from = (typeof args.from==="string" && args.from) ? args.from : "default";
|
|
801
|
+
const toParts = draftKeyPartsFrom(ctx, { sessionId: (typeof args.to==="string" && args.to) ? args.to : (args.sessionId||null) });
|
|
802
|
+
const to = toParts.key;
|
|
803
|
+
if(from === to) return fail("same-slot", "源槽与目标槽相同(" + from + "),无需迁移");
|
|
804
|
+
const src = loadPersistedDraftSync(from) || (globalThis.__CC_DRAFTS__.has(from) ? globalThis.__CC_DRAFTS__.get(from) : null);
|
|
805
|
+
const tgt = loadPersistedDraftSync(to) || (globalThis.__CC_DRAFTS__.has(to) ? globalThis.__CC_DRAFTS__.get(to) : null);
|
|
806
|
+
const decision = migrateDraftDecision(src, tgt, args.overwrite===true);
|
|
807
|
+
if(!decision.ok) return fail(decision.code, decision.message, decision.details||{});
|
|
808
|
+
setDraftFor(to, decision.draft);
|
|
809
|
+
return ok({ draft: decision.draft, key: to, from: from, moved: decision.summary, draftStatus: draftStatusFor(to, decision.draft) });
|
|
709
810
|
}
|
|
710
811
|
case "cc_setDraft":{
|
|
711
812
|
const k = draftKeyFrom(ctx, args);
|
|
@@ -939,6 +1040,7 @@ function apply(ctx, config){
|
|
|
939
1040
|
}), "dsh-cc-studio: POST "+RPC_CHANNEL+"/*");
|
|
940
1041
|
}
|
|
941
1042
|
|
|
942
|
-
export { apply, inject, name, planPresetInstall, installCcPreset, presetTargetRoot, presetSourceRoot
|
|
1043
|
+
export { apply, inject, name, planPresetInstall, installCcPreset, presetTargetRoot, presetSourceRoot,
|
|
1044
|
+
draftKeyPartsFrom, draftContentSummary, listDraftSlotsOnDisk, migrateDraftDecision };
|
|
943
1045
|
|
|
944
1046
|
|
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.5",
|
|
4
4
|
"description": "CCv3 角色卡工坊:融合工坊 + CC 模式 + JSON/PNG/CHARX + 中英双语(跟随全局)",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
@@ -35,7 +35,7 @@
|
|
|
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",
|
|
39
39
|
"prepublishOnly": "npm test"
|
|
40
40
|
},
|
|
41
41
|
"dsh": {
|