@xia-sc/dsh-cc-studio 0.3.3 → 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 CHANGED
@@ -15,8 +15,8 @@ DSH(DeepSeek Harness)插件:输入框上方的胶囊 → 全屏融合工
15
15
  | 项目 | 值 |
16
16
  | --- | --- |
17
17
  | 插件包名 | `@xia-sc/dsh-cc-studio` |
18
- | 当前版本 | `0.3.3` |
19
- | 宿主 RPC | `/dsh-cc-studio-rpc`(自带路由,适配 dsh ≥ `0.1.5-rc.1`;已在 `0.1.6-alpha.1` 实测) |
18
+ | 当前版本 | `0.3.5` |
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/`(会话草稿) |
22
22
 
@@ -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.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,12 +245,11 @@ dsh-cc-studio/
245
245
  ├── presets/cc/ # CC 模式预设模板(共创 persona + cc-studio-agent)
246
246
  │ ├── preset.yml
247
247
  │ └── agent.cordis.yml
248
- ├── prototypes/ # A/B 融合前的对比原型(H5,浏览器直接打开)
249
- │ ├── index.html
250
- │ ├── prototype-a.html # 轻量浮层
251
- │ └── prototype-b.html # 侧边常驻
252
- ├── tests/
253
- │ └── rpc-channel.test.mjs # host 半 RPC 通道回归(假 ctx + 真实 http,23 项断言)
248
+ ├── tests/ # 5 个测试文件、216 项断言(不随包发布)
249
+ │ ├── rpc-channel.test.mjs # host 半 RPC 通道回归(假 ctx + 真实 http,23 项断言)
250
+ │ ├── preset-install.test.mjs # 预设自动安装决策 + 预设行契约,41 项断言
251
+ │ ├── cc-detection.test.mjs # CC 模式探测的源码级守卫,32 项断言
252
+ │ └── client-greetings.test.mjs # 客户端问候语与 i18n 守卫,62 项断言
254
253
  ├── CHANGELOG.md # 完整版本历史
255
254
  ├── README.md # 中文(默认)
256
255
  └── README_EN.md # English
@@ -261,11 +260,10 @@ dsh-cc-studio/
261
260
  ## 开发与测试
262
261
 
263
262
  ```bash
264
- pnpm test # = node tests/rpc-channel.test.mjs
265
- node tests/rpc-channel.test.mjs # host 半 RPC 通道回归(23 项断言)
263
+ npm test # 5 个文件、216 项断言(五个 node 脚本串联)
264
+ node tests/rpc-channel.test.mjs # 只跑 host 半 RPC 通道回归(23 项断言)
266
265
  ```
267
266
 
268
- - **原型预览**(本地直接打开,无需安装):`prototypes/index.html` 总览 → `prototype-a.html`(轻量浮层)/ `prototype-b.html`(侧边常驻)对比。
269
267
  - **改客户端**:`lib/client.js` 改动刷新页面即可;跑着 dsh 仓库的 `pnpm run dev:web` 时可热更新。
270
268
  - **改宿主**:`lib/index.js` 改动需重启 dsh web(宿主行在启动时组合)。
271
269
 
@@ -282,6 +280,8 @@ node tests/rpc-channel.test.mjs # host 半 RPC 通道回归(23 项
282
280
 
283
281
  完整历史见 [CHANGELOG.md](./CHANGELOG.md)。最近几版:
284
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`)。
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 项预设行契约守卫。
285
285
  - `0.3.3` 发布流程自动化:打 `v*` tag 即由 GitHub Actions 发布到 npm(OIDC trusted publishing,零密钥)并自动建 GitHub Release;手动触发默认只做安全自检。插件运行时行为未变。
286
286
  - `0.3.2` 包名迁移至 `@xia-sc/dsh-cc-studio`(原 `@dsh-plugins` 不是本项目持有的 npm scope),并首次发布到 npm;新增 `publishConfig.access` / `prepublishOnly` 等发布元数据。
287
287
  - `0.3.1` 适配 dsh `0.1.6-alpha.1`:CC 预设里的 `workflow-worker-thread` 行换成 `workflow-ptc`,否则整份预设被判「加载失败」、CC 模式直接不可选。
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 · 从点子到可导入卡",
@@ -59,7 +60,6 @@ window.__ModuleLoader__.load({
59
60
  "settings.status.suffix":"个",
60
61
  "settings.status.path":"(落盘 ~/.dsh/cc-library)",
61
62
  "settings.status.hint":"CC 模式:切换到 CC 预设后,输入框上方出现胶囊,点开即是融合工坊(五维/角色/世界书/导出 + 已存侧栏 + 实时预览)。",
62
- "settings.status.prototype":"原型对比:",
63
63
  "settings.status.spec":"规范:",
64
64
  "settings.status.impExp":"导入/导出:JSON / PNG(tEXt ccv3) / CHARX(ZIP card.json),侧边栏 ★ 保存/载入/导出/删除。",
65
65
  "settings.status.refresh":"刷新已存列表",
@@ -113,6 +113,11 @@ window.__ModuleLoader__.load({
113
113
  "workshop.export.modificationDate":"modification_date(导出自动刷新)",
114
114
  "workshop.draft.fresh":"⚠️ 当前会话是全新空白草稿(无历史存档,可能是 host 重启后的新会话)。在工坊或对话中继续编辑前,请确认这是预期中的新建;如需继续旧卡请去右侧“已存角色”载入。",
115
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":"迁移草稿槽失败",
116
121
  "workshop.preview.title":"实时 CCv3 预览",
117
122
  "workshop.preview.cbsTitle":"CBS 速查",
118
123
  "workshop.preview.cbsHint":"大小写不敏感,逗号用 \\, 转义。",
@@ -224,6 +229,7 @@ window.__ModuleLoader__.load({
224
229
  "capsule.draft":"draft",
225
230
  "capsule.expanded":"expanded",
226
231
  "capsule.collapseHint":"Click to expand",
232
+ "capsule.slotWarn":"⚠ draft slot mismatch",
227
233
  "preset.ccLabel":"CC 模式",
228
234
  "panel.title":"Character Card Studio",
229
235
  "panel.subtitle":"CCv3 · Idea to Importable Card",
@@ -265,7 +271,6 @@ window.__ModuleLoader__.load({
265
271
  "settings.status.suffix":"",
266
272
  "settings.status.path":" (stored in ~/.dsh/cc-library)",
267
273
  "settings.status.hint":"CC Mode: switch to CC preset, capsule appears above composer; open to get fusion workshop (5D/Character/Lorebook/Export + sidebar + preview).",
268
- "settings.status.prototype":"Prototypes: ",
269
274
  "settings.status.spec":"Spec: ",
270
275
  "settings.status.impExp":"Import/Export: JSON / PNG(tEXt ccv3) / CHARX(ZIP card.json), sidebar ★ Save/Load/Export/Delete.",
271
276
  "settings.status.refresh":"Refresh saved list",
@@ -319,6 +324,11 @@ window.__ModuleLoader__.load({
319
324
  "workshop.export.modificationDate":"modification_date (auto on export)",
320
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.",
321
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",
322
332
  "workshop.preview.title":"Live CCv3 Preview",
323
333
  "workshop.preview.cbsTitle":"CBS Cheatsheet",
324
334
  "workshop.preview.cbsHint":"Case-insensitive, escape comma with \\,.",
@@ -473,7 +483,7 @@ window.__ModuleLoader__.load({
473
483
  var _saved = loadSettings();
474
484
  // 兼容旧开关:poll=false 视为 0 秒
475
485
  if(_saved.poll===false) _saved.pollInterval = 0;
476
- 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 };
477
487
  var listeners=new Set();
478
488
  function emit(){ listeners.forEach(function(fn){ try{fn();}catch(e){console.error(e);} }); }
479
489
  function getSnapshot(){ return state; }
@@ -486,7 +496,29 @@ window.__ModuleLoader__.load({
486
496
  // 写值恒等于 isCcMode,因此渲染条件里的 !s.triggered 是冗余项(删除前后行为完全一致)。
487
497
  function setIsCcMode(v){ var on=!!v; if(on!==state.isCcMode) set({isCcMode: on}); }
488
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}); } } }
489
- function setSessionId(id){ if(id) set({currentSessionId: String(id)}); }
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
+ }
490
522
  function saveSettings(){
491
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){}
492
524
  }
@@ -873,7 +905,7 @@ window.__ModuleLoader__.load({
873
905
  }catch(e){ set({lastMsg: String(e && e.message || e)}); }
874
906
  set({libraryBusy:null});
875
907
  }
876
- 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 };
877
909
  }
878
910
 
879
911
  var S={
@@ -954,27 +986,67 @@ window.__ModuleLoader__.load({
954
986
  var p = presetId ? String(presetId).toLowerCase() : "";
955
987
  if(p) return p===CC_PRESET_ID; // 会话投影是权威本地来源
956
988
  if(domVerdict) return true; // 预设芯片显示 CC
957
- if(!hasSession) return false; // 新会话页:芯片就是权威,没显示 CC 即非 CC
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;
958
995
  // 已有会话但投影尚未到达:不确定。此时关闭会造成「选 CC → 首次对话」瞬间的胶囊闪烁,
959
996
  // 所以只有在当前并非 CC 时才返回 null,让 RPC 去确认。
960
997
  return prevIsCc ? true : null;
961
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
+ }
962
1019
  /**
963
1020
  * 探测 CC 模式并写入 store,返回当前会话 id。
964
1021
  */
965
1022
  function useCcPreset(store, props, t){
966
1023
  var useSessions=props.useSessions;
967
- var currentId = useSessions ? useSessions(function(s){ return (s && s.current) || null; }) : (props.sessionId || props.session || null);
968
- if(!currentId) currentId = props.sessionId || props.session || null;
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 作下标)。
969
1029
  var presetId = useSessions ? useSessions(function(s){
970
- try{ var id=s&&s.current; return ccPresetIdOf(id && s.byId && s.byId[id]); }catch(e){ return null; }
1030
+ try{
1031
+ if(!currentId) return null;
1032
+ return ccPresetIdOf(s && s.byId && s.byId[currentId]);
1033
+ }catch(e){ return null; }
971
1034
  }) : null;
972
1035
  var ccLabel=(typeof t==="function" ? t("preset.ccLabel") : "") || "";
973
1036
  var evaluate=React.useCallback(function(){
974
1037
  var verdict=decideCcMode(presetId, ccModeFromDom(ccLabel), !!currentId, !!store.getSnapshot().isCcMode);
975
- if(verdict!==null) store.setIsCcMode(verdict);
976
- return verdict===null ? !!store.getSnapshot().isCcMode : verdict;
977
- }, [store, presetId, currentId, ccLabel]);
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]);
978
1050
  React.useEffect(function(){
979
1051
  var isCc=evaluate();
980
1052
  if(!currentId) return;
@@ -1010,7 +1082,7 @@ window.__ModuleLoader__.load({
1010
1082
  var valid=s.valid && s.valid.valid;
1011
1083
  // 计数文案走词表(沿用既有 replace("{n}") 插值模式,locale 的 t() 不做插值)
1012
1084
  var loreText=t("common.loreCount").replace("{n}", String(loreCount));
1013
- 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")): ""));
1014
1086
  return h("div",{style:S.dockWrap, "data-dsh-cc-studio":"dock-row"},
1015
1087
  h("button",{type:"button", style:S.capsule, title: t("hint.capsule"), onClick:function(){ store.setPanel(true); }},
1016
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"}}),
@@ -1296,6 +1368,24 @@ window.__ModuleLoader__.load({
1296
1368
  ),
1297
1369
  (function(){
1298
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
+ }
1299
1389
  if(!st) return null;
1300
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"));
1301
1391
  if(st.recovered){
@@ -1402,7 +1492,7 @@ window.__ModuleLoader__.load({
1402
1492
  h("h3",{style:{margin:"0 0 8px", fontSize:13}}, t("settings.status.title")),
1403
1493
  h("div",{style:{fontSize:12, color:"var(--dsw-alias-label-primary)"}}, t("settings.status.count"), libCount===null ? "—" : String(libCount)+t("settings.status.suffix"), libCount!==null ? h("span",{style:{color:"var(--dsw-alias-label-secondary)", marginLeft:8}}, t("settings.status.path")) : null),
1404
1494
  h("div",{style:{fontSize:12, color:"var(--dsw-alias-label-secondary)", marginTop:8}}, t("settings.status.hint")),
1405
- h("div",{style:{fontSize:12, color:"var(--dsw-alias-label-secondary)", marginTop:6}}, t("settings.status.prototype"), h("a",{href:"file:///E:/dsh/plugin/dsh-cc-studio/prototypes/index.html", target:"_blank", style:{color:"#7c5cff"}}, "prototypes/index.html"), " | "+t("settings.status.spec"), h("a",{href:"https://github.com/kwaroran/character-card-spec-v3/blob/main/SPEC_V3.md", target:"_blank", style:{color:"#7c5cff"}}, "CCv3 SPEC")),
1495
+ h("div",{style:{fontSize:12, color:"var(--dsw-alias-label-secondary)", marginTop:6}}, t("settings.status.spec"), h("a",{href:"https://github.com/kwaroran/character-card-spec-v3/blob/main/SPEC_V3.md", target:"_blank", style:{color:"#7c5cff"}}, "CCv3 SPEC")),
1406
1496
  h("div",{style:{fontSize:12, color:"var(--dsw-alias-label-secondary)", marginTop:6}}, t("settings.status.impExp")),
1407
1497
  store && store.refreshLibrary ? h("button",{style:Object.assign({}, S.btn, {marginTop:10, padding:"6px 10px", fontSize:12}), onClick:function(){ store.refreshLibrary(); }}, t("settings.status.refresh")) : null
1408
1498
  ),
@@ -1443,23 +1533,49 @@ window.__ModuleLoader__.load({
1443
1533
  function apply(ctx){
1444
1534
  ctx.effect(function(){ return ctx.locale.register(NS, { zh:zh, en:en }); }, "cc-studio:locale");
1445
1535
  var store=createStore(ctx);
1446
- // CC 模式检测 + 草稿同步:会话切换触发 + CC 模式下 2.5s 轻量轮询(修复 Tools 写入后胶囊/工坊不同步的 bug)
1536
+ // CC 模式检测 + 草稿同步:会话切换触发 + CC 模式下按设置间隔轻量轮询。
1537
+ // 铁律:拉草稿必须带会话 id(见 pullDraft 注释,issue #5),绝不拿 default 槽顶替。
1447
1538
  ctx.effect(function(){
1448
1539
  var lastHostJson="";
1449
1540
  var lastHostStatusJson="";
1541
+ var lastHostKey=null;
1542
+ // 拉一份草稿。**必须带 key**:没有会话 id 就什么都不拉。
1543
+ // 旧代码在 args={} 时照样发请求,主机在 HTTP 上下文里拿不到 currentInitiator(),
1544
+ // draftKeyFrom 回退成 "default" —— 于是「模型写 session-<id>、工坊画 default」两槽并存,
1545
+ // 用户看到空壳以为草稿丢了(issue #5)。宁可什么都不画,也不画错槽。
1450
1546
  function pullDraft(sessionId){
1451
- var args = sessionId ? {sessionId: sessionId} : {};
1452
- if(sessionId) store.setSessionId(sessionId);
1453
- ctx.connection.rpc.call("/dsh-cc-studio-rpc","cc_getDraft",{args:args}).then(function(res){
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){
1454
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);
1455
1569
  var draft=res.value.draft;
1456
1570
  var draftStatus=res.value.draftStatus||null;
1457
1571
  try{
1458
1572
  var j=JSON.stringify(draft);
1459
1573
  var stJ=JSON.stringify(draftStatus);
1460
- if(j!==lastHostJson || stJ!==lastHostStatusJson){
1574
+ // 换 key 时缓存必须失效,否则「内容恰好相同」的两把槽之间会互相吞掉更新
1575
+ if(j!==lastHostJson || stJ!==lastHostStatusJson || gotKey!==lastHostKey){
1461
1576
  lastHostJson=j;
1462
1577
  lastHostStatusJson=stJ;
1578
+ lastHostKey=gotKey;
1463
1579
  var curJ=JSON.stringify(store.getSnapshot().draft);
1464
1580
  var curStJ=JSON.stringify(store.getSnapshot().draftStatus||null);
1465
1581
  if(j!==curJ || stJ!==curStJ) store.setDraftFromHost(draft, draftStatus);
@@ -1468,25 +1584,33 @@ window.__ModuleLoader__.load({
1468
1584
  }).catch(function(){});
1469
1585
  }
1470
1586
  var lastPullAt = 0;
1587
+ var lastPullKey = null;
1471
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 槽。
1472
1594
  var now = Date.now();
1473
- if(now - lastPullAt < 500) return;
1595
+ if(key === lastPullKey && now - lastPullAt < 500) return;
1474
1596
  lastPullAt = now;
1475
- pullDraft(sid);
1597
+ lastPullKey = key;
1598
+ pullDraft(key);
1476
1599
  }
1477
- // 初始拉一次(default)
1478
- pullDraftThrottled(null);
1479
1600
  // 暴露给 Capsule/Workshop 按会话触发(带节流)
1480
1601
  store._pullDraft = pullDraftThrottled;
1602
+ // 注意:这里**不再**做「初始拉一次 default」。启动时还没有会话,拉了只会把 default 槽
1603
+ // 的空壳画进工坊;store 里本来就有一份本地空白草稿,够新会话页用了。
1481
1604
  // CC 模式下轻量轮询,保证 LLM 的 cc_* Tool 写入后胶囊实时同步(切换会话时会自动更新 currentSessionId)
1482
- // 仅 CC 模式下以 4s 心跳拉取,标准模式/未触发时不请求;去重避免启动时双重请求
1605
+ // 仅 CC 模式下以 4s 心跳拉取,标准模式/未触发时不请求
1483
1606
  var timer = setInterval(function(){
1484
1607
  var s = store.getSnapshot();
1485
1608
  var intervalSec = Number(s.pollInterval)||0;
1486
1609
  if(!s.isCcMode || intervalSec===0) return;
1610
+ var sid = s.currentSessionId || null;
1611
+ if(!sid) return; // 会话 id 未知:不拿 default 顶替
1487
1612
  var elapsed = Date.now() - lastPullAt;
1488
1613
  if(elapsed < intervalSec*1000) return;
1489
- var sid = s.currentSessionId || null;
1490
1614
  pullDraftThrottled(sid);
1491
1615
  }, 1000);
1492
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
- function draftKeyFrom(ctx, args){
140
- if(args && typeof args.sessionId==="string" && args.sessionId) return String(args.sessionId);
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
- return ok({ isCc: isCcPreset(ctx, args.sessionId) });
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 k = draftKeyFrom(ctx, args);
777
+ const parts = draftKeyPartsFrom(ctx, args);
778
+ const k = parts.key;
707
779
  const d = getDraftFor(k);
708
- return ok({ draft: d, key: k, isCc: isCcPreset(ctx, args.sessionId), draftStatus: draftStatusFor(k, d) });
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",
3
+ "version": "0.3.5",
4
4
  "description": "CCv3 角色卡工坊:融合工坊 + CC 模式 + JSON/PNG/CHARX + 中英双语(跟随全局)",
5
5
  "keywords": [
6
6
  "dsh",
@@ -32,11 +32,10 @@
32
32
  "files": [
33
33
  "lib",
34
34
  "cordis.patch.yml",
35
- "prototypes",
36
35
  "presets"
37
36
  ],
38
37
  "scripts": {
39
- "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",
40
39
  "prepublishOnly": "npm test"
41
40
  },
42
41
  "dsh": {
@@ -197,11 +197,16 @@
197
197
  - id: tool-subagent-list-agents
198
198
  name: '@deepseek-ai/dsh-tool-subagent-control/list-agents'
199
199
 
200
+ # 0.3.4 对齐 dsh 0.1.6-alpha.2 内置 standard:spawn 行此前漏抄 modelSelectionSettings。
201
+ # 该键在 dsh-tool-subagent 里是 `z.boolean().default(false)`,漏掉既不报错也不改 schema,
202
+ # 只是子代理的「指定模型」入口被静默关掉 —— 最难发现的那类漂移。
203
+ # fork 行故意不加:provider/model 必须等于父代理,继承的历史才能继续命中 KV cache。
200
204
  - id: tool-subagent
201
205
  name: '@deepseek-ai/dsh-tool-subagent'
202
206
  config:
203
207
  provider: spawn
204
208
  toolName: subagent
209
+ modelSelectionSettings: true
205
210
  backgroundMode: continuable
206
211
 
207
212
  - id: tool-subagent-fork
@@ -271,6 +276,21 @@
271
276
  fetch: false
272
277
  searchTimeoutMs: 60000
273
278
 
279
+ # 0.3.4 补回:`present` 是 standard 一直有的行,本预设此前整行漏抄。
280
+ # 症状很隐蔽 —— CC 模式下模型没有「把成品登记为交付物」的工具,不报任何错,只是少了能力。
281
+ # 它和下面那行都必须与 dsh 内置 standard 逐行对得上(升级清单见 AGENTS.md §4)。
282
+ - id: present
283
+ name: '@deepseek-ai/dsh-tool-present'
284
+
285
+ # dsh 0.1.6-alpha.2 新增行:standard 里就是 disabled(默认关,需要的人自己放开)。
286
+ # 抄过来是为了让「本预设 vs 内置 standard」的行差以后只剩有意为之的那几处,
287
+ # 下次升级 diff 时不必再逐个判断「这行是新加的、还是我们漏抄了」。
288
+ # 保留 disabled 也安全:健康检查 unresolvableRows 会跳过 disabled 行
289
+ # (dsh-agent-presets lib/index.js:292 `if (Boolean(row.disabled)) continue;`)。
290
+ - id: tool-plugin-manager
291
+ name: '@deepseek-ai/dsh-plugin-manager/tools'
292
+ disabled: true
293
+
274
294
  # ── CC 角色卡工坊(CC 模式专用) ───────────────────────────────────
275
295
  - id: cc-agent
276
296
  name: '@xia-sc/dsh-cc-studio/agent'
@@ -1,11 +0,0 @@
1
- <!doctype html>
2
- <html lang="zh-CN"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><title>dsh-cc-studio 原型总览</title>
3
- <style>body{margin:0;font:14px/1.6 system-ui;background:#0b0d11;color:#e6e8ee} a{color:#7c5cff} .wrap{max-width:900px;margin:40px auto;padding:0 16px} .card{background:#171a21;border:1px solid #262c3a;border-radius:14px;padding:16px;margin-bottom:16px} .btn{display:inline-block;padding:10px 14px;border-radius:10px;background:#7c5cff;color:white;text-decoration:none;font-weight:700;margin-right:8px} .btn.ghost{background:transparent;border:1px solid #262c3a;color:#e6e8ee} code{background:#0f1218;border:1px solid #262c3a;padding:2px 6px;border-radius:6px}</style></head>
4
- <body><div class="wrap">
5
- <h1>🎭 dsh-cc-studio · 原型对比</h1>
6
- <p style="color:#9aa3b8">Q1 的 A/B 两个可交互 H5 已生成,直接点开看效果(本地文件即可,或用 <code>file://</code> 打开)。Q2=B(五维深挖)、Q3=dsh-cc-studio、Q4=首版仅 JSON 已锁定。</p>
7
- <div class="card"><h3 style="margin:0 0 8px">原型 A — 轻量浮层</h3><p style="color:#9aa3b8">平时只占输入框上方一行胶囊,点开全屏工坊。适合沉浸式一次性做完。</p><a class="btn" href="./prototype-a.html" target="_blank">打开原型 A</a><span style="color:#9aa3b8">文件:<code>E:\dsh\plugin\dsh-cc-studio\prototypes\prototype-a.html</code></span></div>
8
- <div class="card"><h3 style="margin:0 0 8px">原型 B — 侧边常驻 + 浮层双形态</h3><p style="color:#9aa3b8">左侧 320px 常驻工坊(进度/5维快览/世界书快切/校验),中间照常聊天,右侧实时 JSON + 小助手。浮层仍保留用于沉浸式编辑。适合边聊边改。</p><a class="btn" href="./prototype-b.html" target="_blank">打开原型 B</a><span style="color:#9aa3b8">文件:<code>E:\dsh\plugin\dsh-cc-studio\prototypes\prototype-b.html</code></span></div>
9
- <div class="card"><h3>下一步</h3><ol><li>你在这两个页面里点一点,感受一下 A 的“干净” vs B 的“边聊边改”。</li><li>回复一句 <b>“定 A”</b> 或 <b>“定 B”</b> 或 <b>“都要 / 融合”</b>,我立刻按定版开写 <code>dsh-cc-studio</code> 真插件(<code>package.json/cordis.patch.yml/lib/index.js/lib/client.js</code>),首版先跑通 JSON 导出。</li></ol></div>
10
- <div class="card" style="background:#0f1218"><h4 style="margin:0 0 8px">已锁定的实现基线</h4><ul style="margin:0;color:#cbd5f0"><li>5维问卷:年表 / 势力 / 地理 / 力量体系 / 日常细节 → 一键生成带 <code>@@position/@@depth/@@activate</code> 的 Lorebook</li><li>CCv3 全字段表单 + Lorebook Decorator 可视化 + CBS 自动补全 + 实时校验</li><li>Host: <code>ctx.llm.stream</code> 透传 + <code>fs</code> 校验;Client: <code>shell.overlay + conversation.input.dock + settings.section</code></li><li>首版导出:<code>card.json</code>(<code>spec: chara_card_v3</code>),CHARX/PNG 二期</li></ul></div>
11
- </div></body></html>
@@ -1,246 +0,0 @@
1
- <!doctype html>
2
- <html lang="zh-CN">
3
- <head>
4
- <meta charset="utf-8"/>
5
- <meta name="viewport" content="width=device-width,initial-scale=1"/>
6
- <title>原型A · 轻量浮层工坊 — dsh-cc-studio</title>
7
- <style>
8
- :root{--bg:#0f1115;--panel:#171a21;--panel2:#1e232e;--line:#262c3a;--text:#e6e8ee;--muted:#9aa3b8;--acc:#7c5cff;--acc2:#2ee6a6;--warn:#ff6b6b;--radius:14px}
9
- *{box-sizing:border-box} html,body{height:100%} body{margin:0;background:#0b0d11;color:var(--text);font:14px/1.6 ui-sans,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial}
10
- a{color:var(--acc)}
11
- .topbar{height:44px;display:flex;align-items:center;justify-content:space-between;padding:0 14px;border-bottom:1px solid var(--line);background:#0f1218;position:sticky;top:0;z-index:5}
12
- .topbar .brand{display:flex;gap:10px;align-items:center;font-weight:700;letter-spacing:.2px}
13
- .pill{padding:4px 8px;border-radius:999px;background:#1a1f2e;border:1px solid #2a3350;color:#aeb8d4;font-size:12px}
14
- .shell{display:grid;grid-template-columns:280px 1fr;min-height:calc(100vh - 44px)}
15
- .sidebar{border-right:1px solid var(--line);background:#0f1218;padding:14px;display:flex;flex-direction:column;gap:14px}
16
- .side-card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:12px}
17
- .side-card h4{margin:0 0 8px;font-size:12px;letter-spacing:.08em;color:var(--muted);text-transform:uppercase}
18
- .conv{padding:16px;display:flex;flex-direction:column;gap:16px;background: radial-gradient(1200px 600px at 30% -10%, #1a1f3a 0, transparent 60%), linear-gradient(180deg,#0d0f14,#0b0d11)}
19
- .msg{max-width:760px;background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:14px 16px}
20
- .msg.user{margin-left:auto;background:#1a1d27;border-color:#2a3147}
21
- .msg .role{font-size:11px;color:var(--muted);margin-bottom:6px;letter-spacing:.06em}
22
- .composer{position:sticky;bottom:0;background:linear-gradient(180deg, transparent, #0b0d11 28%);padding:12px 0 16px}
23
- .dock{max-width:760px;margin:0 auto 8px;display:flex;gap:8px;align-items:center}
24
- .capsule{appearance:none;border:1px solid #2d3655;background:#171c2e;color:#c8d1ec;border-radius:999px;padding:6px 12px;font-size:12px;display:inline-flex;gap:8px;align-items:center;cursor:pointer;transition:.15s}
25
- .capsule:hover{border-color:#3a4670;background:#1c2240;transform:translateY(-1px)}
26
- .capsule .dot{width:7px;height:7px;border-radius:50%;background:var(--acc2);box-shadow:0 0 10px var(--acc2)}
27
- .inputbar{max-width:760px;margin:0 auto;background:var(--panel);border:1px solid var(--line);border-radius:16px;padding:10px;display:flex;gap:10px;align-items:flex-end}
28
- .inputbar textarea{flex:1;min-height:44px;max-height:120px;resize:none;background:#0f1218;border:1px solid #242b3d;border-radius:10px;padding:10px 12px;color:var(--text);outline:none}
29
- .btn{appearance:none;border:1px solid transparent;background:var(--acc);color:white;border-radius:10px;padding:8px 14px;font-weight:600;cursor:pointer}
30
- .btn.ghost{background:transparent;border-color:var(--line);color:var(--text)}
31
- .btn.small{padding:6px 10px;font-size:12px;border-radius:8px}
32
- /* overlay */
33
- .overlay{position:fixed;inset:0;background:rgba(3,6,12,.62);backdrop-filter: blur(8px);display:none;align-items:center;justify-content:center;padding:18px;z-index:30}
34
- .overlay.open{display:flex}
35
- .workshop{width:min(1280px, 96vw);height:min(860px, 92vh);background:linear-gradient(180deg,#171c2a,#11141d);border:1px solid #2a324b;border-radius:18px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 20px 80px rgba(0,0,0,.55)}
36
- .ws-head{height:56px;display:flex;align-items:center;justify-content:space-between;padding:0 16px;border-bottom:1px solid #242d47;background:#131827}
37
- .steps{display:flex;gap:6px;align-items:center}
38
- .step{padding:6px 10px;border-radius:999px;font-size:12px;border:1px solid #2a334e;color:var(--muted);background:#0f1422}
39
- .step.active{background:var(--acc);border-color:var(--acc);color:white;box-shadow:0 6px 16px rgba(124,92,255,.35)}
40
- .ws-body{flex:1;display:grid;grid-template-columns:220px 1fr 360px;min-height:0}
41
- .ws-nav{border-right:1px solid #242d47;background:#0f1320;padding:12px;display:flex;flex-direction:column;gap:10px}
42
- .ws-nav button{width:100%;text-align:left;padding:10px 12px;border-radius:10px;border:1px solid transparent;background:transparent;color:#cbd5f0;cursor:pointer}
43
- .ws-nav button.active{background:#1a2140;border-color:#2d3a66;color:white}
44
- .ws-nav button small{display:block;color:var(--muted);font-size:11px;margin-top:2px}
45
- .ws-main{padding:16px;overflow:auto}
46
- .ws-side{border-left:1px solid #242d47;background:#0e1220;padding:12px;overflow:auto}
47
- .card{background:#0f1422;border:1px solid #242d47;border-radius:12px;padding:12px;margin-bottom:12px}
48
- .card h3{margin:0 0 8px;font-size:13px}
49
- .field{display:flex;flex-direction:column;gap:6px;margin-bottom:10px}
50
- .field label{font-size:12px;color:var(--muted)}
51
- .field input,.field textarea,.field select{width:100%;background:#0b0f1b;border:1px solid #26314d;border-radius:8px;color:var(--text);padding:8px 10px;outline:none}
52
- .field textarea{min-height:84px;resize:vertical}
53
- .chips{display:flex;flex-wrap:wrap;gap:6px}
54
- .chip{font-size:11px;padding:4px 8px;border-radius:999px;background:#12182a;border:1px solid #28365a;color:#aebde0;cursor:pointer}
55
- .chip.on{background:var(--acc);border-color:var(--acc);color:white}
56
- .lore-row{display:grid;grid-template-columns:1fr auto;gap:8px;align-items:center;background:#0b0f1b;border:1px solid #26314d;border-radius:10px;padding:10px;margin-bottom:8px}
57
- .mono{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px}
58
- .json{white-space:pre-wrap;word-break:break-all;background:#0b0f1b;border:1px solid #26314d;border-radius:10px;padding:10px;max-height:420px;overflow:auto}
59
- .badge{font-size:11px;padding:2px 6px;border-radius:999px;border:1px solid #2a3350;background:#11182e;color:#9fb0d8}
60
- .dim-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}
61
- .dim{border:1px dashed #2a3552;border-radius:12px;padding:10px;background:#0c1020}
62
- .dim h4{margin:0 0 6px;font-size:12px;color:#cbd5f0}
63
- .kpi{display:flex;gap:8px;flex-wrap:wrap}
64
- @media (max-width:1100px){.ws-body{grid-template-columns:1fr}.ws-side{border-left:none;border-top:1px solid #242d47}.shell{grid-template-columns:1fr}.sidebar{display:none}}
65
- </style>
66
- </head>
67
- <body>
68
- <div class="topbar">
69
- <div class="brand">⬢ DSH <span style="color:var(--muted);font-weight:400">· web — 会话内角色卡工坊</span></div>
70
- <div style="display:flex;gap:8px;align-items:center"><span class="pill">CCv3 Living Standard</span><span class="pill" style="background:#1a2a1f;border-color:#2a4a3a;color:#a6e8c8">● 模型就绪</span></div>
71
- </div>
72
- <div class="shell">
73
- <div class="sidebar">
74
- <div class="side-card"><h4>工作区</h4><div class="mono" style="color:#cbd5f0">E:\demo\my-cards</div><div style="color:var(--muted);font-size:12px;margin-top:6px">会话 cwd 实时跟随(dsh-git 同款 store)</div></div>
75
- <div class="side-card"><h4>最近草稿</h4><div style="display:flex;flex-direction:column;gap:8px"><div style="padding:8px 10px;border:1px solid #26314d;border-radius:10px;background:#0b0f1b"><div style="font-weight:600">星港遗民 · 琉璃</div><div style="color:var(--muted);font-size:12px">未导出 · 5 lorebook</div></div><div style="padding:8px 10px;border:1px dashed #26314d;border-radius:10px;color:var(--muted)">+ 新建角色卡</div></div></div>
76
- <div class="side-card"><h4>原型说明</h4><div style="color:var(--muted);font-size:12px">A = 轻量浮层:平时只占输入框上方一行胶囊,点开全屏工坊。适合“写完再导”的沉浸式创作。</div></div>
77
- </div>
78
- <div class="conv">
79
- <div class="msg"><div class="role">ASSISTANT</div>试着丢一句点子给我,比如“废土上开面馆的仿生人奶奶,会修收音机”。我帮你把世界观补齐到可导出的 CCv3。</div>
80
- <div class="msg user"><div class="role">YOU</div>我想做一个雨城里的记忆典当行老板娘,能把记忆做成香薰蜡烛,客人闻到就能重温那段记忆。但我世界观很薄…</div>
81
- <div class="composer">
82
- <div class="dock">
83
- <button class="capsule" id="openA">🎭 角色卡工坊 <span class="dot"></span> 未导出 · 点此展开</button>
84
- <span class="badge">CCv3 校验:<span style="color:var(--warn)">缺 nickname / group_only_greetings</span></span>
85
- <span class="badge">草稿自动保存 ✓</span>
86
- </div>
87
- <div class="inputbar">
88
- <textarea placeholder="继续聊点子,或直接在工坊里编辑… Shift+Enter 换行"></textarea>
89
- <button class="btn">发送</button>
90
- </div>
91
- <div style="max-width:760px;margin:8px auto 0;color:var(--muted);font-size:12px">按回车发送 · 工坊里每个 “AI 扩写” 都会走 DSH 的 llm.stream,失败可重试</div>
92
- </div>
93
- </div>
94
- </div>
95
-
96
- <div class="overlay" id="overlayA">
97
- <div class="workshop" role="dialog" aria-modal="true">
98
- <div class="ws-head">
99
- <div style="display:flex;gap:12px;align-items:center"><strong>🎭 角色卡工坊</strong><span class="badge">dsh-cc-studio · A 浮层</span>
100
- <div class="steps" id="stepsA"></div>
101
- </div>
102
- <div style="display:flex;gap:8px"><button class="btn ghost small" onclick="closeA()">收起</button><button class="btn small" onclick="exportJSON()">导出 JSON</button></div>
103
- </div>
104
- <div class="ws-body">
105
- <div class="ws-nav" id="navA"></div>
106
- <div class="ws-main" id="mainA"></div>
107
- <div class="ws-side">
108
- <div class="card"><h3>实时 CCv3 预览</h3><div class="kpi" style="margin-bottom:8px"><span class="badge">spec: chara_card_v3</span><span class="badge">校验: <span id="validA" style="color:var(--acc2)">通过</span></span></div><pre class="json mono" id="jsonA"></pre></div>
109
- <div class="card"><h3>CBS 速查</h3><div class="chips"><span class="chip">{{char}}</span><span class="chip">{{user}}</span><span class="chip">{{random:A,B}}</span><span class="chip">{{roll:d6}}</span><span class="chip">{{// 注释}}</span></div><div style="color:var(--muted);font-size:12px;margin-top:8px">输入时自动补全,导出前会做大小写不敏感校验。</div></div>
110
- </div>
111
- </div>
112
- </div>
113
- </div>
114
-
115
- <script>
116
- const steps = ["点子","世界观 5维","角色细化","世界书","校验导出"];
117
- let cur = 0;
118
- const draft = {
119
- spec:"chara_card_v3", spec_version:"3.0",
120
- data:{
121
- name:"雨城烛坊 · 岚烟", nickname:"岚烟", tags:["记忆","雨城","香薰","典当"], creator:"you", character_version:"0.1",
122
- description:"雨城常年细雨,霓虹在水膜上化开。岚烟经营一家无招牌的记忆典当行,将客人的记忆抽丝封进蜡烛。",
123
- personality:"外柔内韧,嗅觉共情,话少但记性极好;对“自愿”有执念,拒绝强行抽取。",
124
- scenario:"你推开吱呀的木门,铃铛轻响,雨气混着蜡与旧纸味扑面而来。岚烟在柜台后抬头,手中烛芯正袅袅成线。",
125
- system_prompt:"You are 岚烟, keeper of memory candles. Reply in Chinese, sensory, restrained romance.",
126
- post_history_instructions:"保持蜡烛=记忆的隐喻一致性,不主动破坏客人自愿原则。",
127
- first_mes:"“雨又大了。先烤烤手吧——想典当哪一段?” 她把一盏未点的乳白蜡烛推到你面前,烛体里有细细的银线游动。",
128
- alternate_greetings:["“今晚的雨适合想起旧人。你带记忆来了吗?”","“这盏是上周一位航行员留下的,里面有海风。”"],
129
- group_only_greetings:["(群聊限定)“诸位若同时点烛,记忆会串味——我得一盏一盏来。”"],
130
- mes_example:"{{user}}: 能把我5岁那年的夏夜还给我吗?\n{{char}}: 可以,但那段有蝉鸣和西瓜味,烧起来会很吵。你确定要现在闻吗?",
131
- creator_notes:"雨城世界观薄弱用户的示例草稿", creator_notes_multilingual:{en:"Example draft for thin-worldbuilding user"}, source:[], assets:[{type:"icon",uri:"ccdefault:",name:"main",ext:"png"}], creation_date: Math.floor(Date.now()/1000), modification_date: Math.floor(Date.now()/1000),
132
- character_book:{ name:"雨城烛坊 lorebook", description:"随卡私有世界书", scan_depth:4, token_budget:1200, recursive_scanning:false, extensions:{}, entries:[
133
- {enabled:true, insertion_order:0, use_regex:false, constant:false, name:"雨城常雨", keys:["雨城","霓虹","潮气"], content:"@@position after_desc\n雨城年均降水 1800mm,排水系统以“雨梯”著称,霓虹倒影是导航。", priority:10},
134
- {enabled:true, insertion_order:1, use_regex:false, constant:true, name:"蜡烛=记忆", keys:[], content:"@@activate\n蜡烛即记忆容器,银线密度=情绪强度。闻香即重温,不可食用。", priority:9},
135
- ]}
136
- }
137
- };
138
- let idea="雨城里的记忆典当行老板娘,能把记忆做成香薰蜡烛";
139
- let world={timeline:"2127 雨城独立后,记忆被列为可交易软资产", factions:"烛坊/雨梯管理局/盗忆人", geo:"环形雨城,内环干燥外环常雨", power:"抽忆术需自愿+嗅觉天赋", daily:"雨声电台、蜡烛夜市、记忆税"};
140
-
141
- function renderSteps(){
142
- const el=document.getElementById('stepsA');
143
- el.innerHTML=steps.map((s,i)=>`<span class="step ${i===cur?'active':''}" onclick="cur=${i};renderA()">${i+1}. ${s}</span>`).join("");
144
- }
145
- function renderNav(){
146
- const nav=document.getElementById('navA');
147
- const items=[
148
- {t:"点子投喂",d:"一句话点子 + 风格"},
149
- {t:"世界观 5维",d:"年表/势力/地理/力量/日常"},
150
- {t:"角色细化",d:"六件套 + 开场"},
151
- {t:"世界书",d:"Lorebook + Decorator"},
152
- {t:"校验导出",d:"JSON 预览与下载"},
153
- ];
154
- nav.innerHTML=items.map((x,i)=>`<button class="${i===cur?'active':''}" onclick="cur=${i};renderA()">${x.t}<small>${x.d}</small></button>`).join("") +
155
- `<div style="margin-top:auto;padding-top:12px;border-top:1px solid #242d47;display:flex;gap:8px"><button class="btn ghost small" style="flex:1" onclick="autoFill()">AI 扩写</button><button class="btn small" style="flex:1" onclick="validateA()">校验</button></div>`;
156
- }
157
- function renderA(){
158
- renderSteps(); renderNav();
159
- const m=document.getElementById('mainA');
160
- if(cur===0){
161
- m.innerHTML=`
162
- <div class="card"><h3>点子投喂(薄世界观友好)</h3>
163
- <div class="field"><label>一句话点子</label><textarea id="idea">${idea}</textarea></div>
164
- <div class="field"><label>风格标签</label><div class="chips" id="tags"><span class="chip on">雨城</span><span class="chip on">感官系</span><span class="chip">赛博</span><span class="chip">治愈</span><span class="chip">微克苏鲁</span></div></div>
165
- <div style="display:flex;gap:8px"><button class="btn small" onclick="expandIdea()">✦ AI 扩写成设定</button><button class="btn ghost small" onclick="cur=1;renderA()">跳过→世界观</button></div>
166
- <div style="margin-top:10px;color:var(--muted);font-size:12px">会生成 description/personality/scenario 初稿 + 5维大纲草案,可一键采纳。</div>
167
- </div>
168
- <div class="card"><h3>AI 初稿(可直接采纳)</h3>
169
- <div class="field"><label>description</label><textarea>${draft.data.description}</textarea></div>
170
- <div class="field"><label>personality</label><textarea>${draft.data.personality}</textarea></div>
171
- <div class="field"><label>scenario</label><textarea>${draft.data.scenario}</textarea></div>
172
- </div>`;
173
- } else if(cur===1){
174
- m.innerHTML=`
175
- <div class="card"><h3>世界观 5维(Q2-B 深度问卷)</h3><div style="color:var(--muted);font-size:12px;margin-bottom:10px">每维填 1-2 句即可,AI 会据此生成 Lorebook 条目(带 @@position/@@depth)。</div>
176
- <div class="dim-grid">
177
- <div class="dim"><h4>① 年表 Timeline</h4><textarea style="width:100%;min-height:70px;background:#0b0f1b;border:1px solid #26314d;border-radius:8px;color:var(--text);padding:8px">${world.timeline}</textarea></div>
178
- <div class="dim"><h4>② 势力 Factions</h4><textarea style="width:100%;min-height:70px;background:#0b0f1b;border:1px solid #26314d;border-radius:8px;color:var(--text);padding:8px">${world.factions}</textarea></div>
179
- <div class="dim"><h4>③ 地理 Geography</h4><textarea style="width:100%;min-height:70px;background:#0b0f1b;border:1px solid #26314d;border-radius:8px;color:var(--text);padding:8px">${world.geo}</textarea></div>
180
- <div class="dim"><h4>④ 力量 Power</h4><textarea style="width:100%;min-height:70px;background:#0b0f1b;border:1px solid #26314d;border-radius:8px;color:var(--text);padding:8px">${world.power}</textarea></div>
181
- <div class="dim" style="grid-column:span 2"><h4>⑤ 日常 Daily</h4><textarea style="width:100%;min-height:70px;background:#0b0f1b;border:1px solid #26314d;border-radius:8px;color:var(--text);padding:8px">${world.daily}</textarea></div>
182
- </div>
183
- <div style="display:flex;gap:8px;margin-top:12px"><button class="btn small" onclick="genLore()">✦ 5维→生成 6 条 Lorebook</button><button class="btn ghost small" onclick="cur=2;renderA()">下一步</button></div>
184
- </div>`;
185
- } else if(cur===2){
186
- m.innerHTML=`
187
- <div class="card"><h3>角色细化 — 六件套</h3>
188
- <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
189
- <div class="field"><label>name</label><input value="${draft.data.name}"/></div>
190
- <div class="field"><label>nickname ({{char}} 用)</label><input value="${draft.data.nickname}"/></div>
191
- </div>
192
- <div class="field"><label>tags</label><input value="${draft.data.tags.join(', ')}"/></div>
193
- <div class="field"><label>description</label><textarea>${draft.data.description}</textarea></div>
194
- <div class="field"><label>personality</label><textarea>${draft.data.personality}</textarea></div>
195
- <div class="field"><label>scenario</label><textarea>${draft.data.scenario}</textarea></div>
196
- <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
197
- <div class="field"><label>system_prompt</label><textarea>${draft.data.system_prompt}</textarea></div>
198
- <div class="field"><label>post_history_instructions</label><textarea>${draft.data.post_history_instructions}</textarea></div>
199
- </div>
200
- </div>
201
- <div class="card"><h3>开场与示例</h3>
202
- <div class="field"><label>first_mes</label><textarea>${draft.data.first_mes}</textarea></div>
203
- <div class="field"><label>alternate_greetings (每行一条)</label><textarea>${draft.data.alternate_greetings.join('\n')}</textarea></div>
204
- <div class="field"><label>group_only_greetings *必填</label><textarea>${draft.data.group_only_greetings.join('\n')}</textarea></div>
205
- <div class="field"><label>mes_example ({{user}}/{{char}})</label><textarea style="min-height:110px">${draft.data.mes_example}</textarea></div>
206
- </div>`;
207
- } else if(cur===3){
208
- const entries=draft.data.character_book.entries.map((e,i)=>`
209
- <div class="lore-row">
210
- <div><div style="font-weight:600">${e.name} <span class="badge">${e.constant?'constant':e.keys.join(', ')||'—'}</span> <span class="badge">order ${e.insertion_order}</span></div>
211
- <div class="mono" style="color:#aebde0;margin-top:4px;white-space:pre-wrap">${e.content}</div>
212
- <div class="chips" style="margin-top:6px"><span class="chip">@@depth 2</span><span class="chip">@@role system</span><span class="chip">@@position after_desc</span></div></div>
213
- <div style="display:flex;flex-direction:column;gap:6px"><label style="font-size:12px"><input type="checkbox" ${e.enabled?'checked':''}/> enabled</label><button class="btn ghost small">编辑</button></div>
214
- </div>`).join("");
215
- m.innerHTML=`
216
- <div class="card"><h3>世界书 Lorebook</h3><div style="display:flex;gap:8px;margin-bottom:8px"><span class="badge">scan_depth: ${draft.data.character_book.scan_depth}</span><span class="badge">token_budget: ${draft.data.character_book.token_budget}</span><span class="badge">entries: ${draft.data.character_book.entries.length}</span><button class="btn small" style="margin-left:auto" onclick="addEntry()">+ 新增条目</button></div>${entries}
217
- <div style="margin-top:10px;display:flex;gap:8px"><button class="btn small" onclick="genLore()">✦ 用 5维再生成一批</button><button class="btn ghost small">导入 Lorebook JSON</button></div>
218
- </div>`;
219
- } else {
220
- m.innerHTML=`
221
- <div class="card"><h3>校验与导出 — 首版仅 JSON(最稳)</h3>
222
- <div class="kpi"><span class="badge" style="border-color:#2a5a3a;color:#a6e8c8">✓ spec/spec_version 正确</span><span class="badge">group_only_greetings: 1 条</span><span class="badge">assets: 1</span></div>
223
- <div style="display:flex;gap:8px;margin:12px 0"><button class="btn small" onclick="exportJSON()">⬇ 导出 card.json</button><button class="btn ghost small" onclick="validateA()">重新校验</button><span style="color:var(--muted);font-size:12px;align-self:center">CHARX/PNG 二期再上</span></div>
224
- <div class="field"><label>creator_notes / 多语言</label><textarea>${draft.data.creator_notes}</textarea></div>
225
- <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px"><div class="field"><label>creation_date</label><input value="${draft.data.creation_date}" readonly/></div><div class="field"><label>modification_date (导出自动刷新)</label><input value="${draft.data.modification_date}" readonly/></div></div>
226
- </div>`;
227
- }
228
- document.getElementById('jsonA').textContent = JSON.stringify(draft, null, 2);
229
- }
230
- function expandIdea(){ idea=document.getElementById('idea')?.value||idea; draft.data.description="雨城的雨是活的,会在玻璃上写字。岚烟的烛坊藏在雨梯夹层,蜡烛银线随记忆温度变色。"; renderA(); }
231
- function genLore(){ draft.data.character_book.entries.push({enabled:true, insertion_order: draft.data.character_book.entries.length, use_regex:false, constant:false, name:"雨梯管理局", keys:["雨梯","管理局"], content:"@@depth 4\n雨梯管理局负责分配干燥配额,烛坊因“记忆税”与其周旋。", priority:8}); renderA(); }
232
- function addEntry(){ draft.data.character_book.entries.push({enabled:true, insertion_order: draft.data.character_book.entries.length, use_regex:false, constant:false, name:"新条目", keys:["关键词"], content:"@@position personality\n新 lore 内容…", priority:5}); renderA(); }
233
- function validateA(){ document.getElementById('validA').textContent="通过"; document.getElementById('validA').style.color="var(--acc2)"; }
234
- function autoFill(){ expandIdea(); }
235
- function exportJSON(){
236
- const blob=new Blob([JSON.stringify(draft,null,2)],{type:"application/json"});
237
- const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download="card.json"; a.click(); URL.revokeObjectURL(a.href);
238
- }
239
- function openA(){ document.getElementById('overlayA').classList.add('open'); renderA(); }
240
- function closeA(){ document.getElementById('overlayA').classList.remove('open'); }
241
- document.getElementById('openA').addEventListener('click', openA);
242
- document.getElementById('overlayA').addEventListener('click', (e)=>{ if(e.target.id==='overlayA') closeA(); });
243
- renderA();
244
- </script>
245
- </body>
246
- </html>
@@ -1,175 +0,0 @@
1
- <!doctype html>
2
- <html lang="zh-CN">
3
- <head>
4
- <meta charset="utf-8"/>
5
- <meta name="viewport" content="width=device-width,initial-scale=1"/>
6
- <title>原型B · 侧边常驻 + 浮层双形态 — dsh-cc-studio</title>
7
- <style>
8
- :root{--bg:#0f1115;--panel:#171a21;--line:#262c3a;--text:#e6e8ee;--muted:#9aa3b8;--acc:#7c5cff;--acc2:#2ee6a6;--warn:#ffb020;--radius:14px}
9
- *{box-sizing:border-box} html,body{height:100%} body{margin:0;background:#0b0d11;color:var(--text);font:14px/1.6 ui-sans,system-ui,Segoe UI,Roboto,Helvetica,Arial}
10
- .topbar{height:44px;display:flex;align-items:center;justify-content:space-between;padding:0 14px;border-bottom:1px solid var(--line);background:#0f1218;position:sticky;top:0;z-index:5}
11
- .pill{padding:4px 8px;border-radius:999px;background:#1a1f2e;border:1px solid #2a3350;color:#aeb8d4;font-size:12px}
12
- .layout{display:grid;grid-template-columns:320px 1fr 340px;min-height:calc(100vh - 44px)}
13
- .left{border-right:1px solid var(--line);background:#0f1218;display:flex;flex-direction:column;min-height:0}
14
- .left-head{padding:12px 12px 10px;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between}
15
- .left-body{flex:1;overflow:auto;padding:12px;display:flex;flex-direction:column;gap:12px}
16
- .card{background:#171a21;border:1px solid var(--line);border-radius:12px;padding:12px}
17
- .card h4{margin:0 0 8px;font-size:12px;letter-spacing:.08em;color:var(--muted);text-transform:uppercase}
18
- .progress{height:6px;background:#0b0f1b;border:1px solid #26314d;border-radius:999px;overflow:hidden}
19
- .progress i{display:block;height:100%;width:62%;background:linear-gradient(90deg,var(--acc),#4ea1ff)}
20
- .field{display:flex;flex-direction:column;gap:6px;margin-bottom:10px}
21
- .field label{font-size:12px;color:var(--muted)}
22
- .field textarea,.field input{width:100%;background:#0b0f1b;border:1px solid #26314d;border-radius:8px;color:var(--text);padding:8px 10px}
23
- .chips{display:flex;flex-wrap:wrap;gap:6px}
24
- .chip{font-size:11px;padding:4px 8px;border-radius:999px;background:#12182a;border:1px solid #28365a;color:#aebde0}
25
- .chip.on{background:var(--acc);border-color:var(--acc);color:white}
26
- .center{background: radial-gradient(900px 500px at 20% -10%, #1a1f3a 0, transparent 60%), linear-gradient(180deg,#0d0f14,#0b0d11);padding:16px;display:flex;flex-direction:column;gap:14px;min-height:0;overflow:auto}
27
- .msg{max-width:760px;background:#171a21;border:1px solid var(--line);border-radius:14px;padding:14px 16px}
28
- .msg.user{margin-left:auto;background:#1a1d27;border-color:#2a3147}
29
- .composer{position:sticky;bottom:0;background:linear-gradient(180deg, transparent, #0b0d11 30%);padding:10px 0 14px}
30
- .dock{max-width:760px;margin:0 auto 8px;display:flex;gap:8px;align-items:center;flex-wrap:wrap}
31
- .capsule{appearance:none;border:1px solid #2d3655;background:#171c2e;color:#c8d1ec;border-radius:999px;padding:6px 12px;font-size:12px;display:inline-flex;gap:8px;align-items:center;cursor:pointer}
32
- .capsule.active{border-color:var(--acc);background:#1c2240}
33
- .inputbar{max-width:760px;margin:0 auto;background:#171a21;border:1px solid var(--line);border-radius:16px;padding:10px;display:flex;gap:10px;align-items:flex-end}
34
- .inputbar textarea{flex:1;min-height:44px;background:#0f1218;border:1px solid #242b3d;border-radius:10px;padding:10px 12px;color:var(--text);resize:none}
35
- .btn{appearance:none;border:1px solid transparent;background:var(--acc);color:white;border-radius:10px;padding:8px 14px;font-weight:600;cursor:pointer}
36
- .btn.ghost{background:transparent;border-color:var(--line);color:var(--text)}
37
- .btn.small{padding:6px 10px;font-size:12px;border-radius:8px}
38
- .right{border-left:1px solid var(--line);background:#0e1220;display:flex;flex-direction:column;min-height:0}
39
- .right-head{padding:12px;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between}
40
- .right-body{flex:1;overflow:auto;padding:12px;display:flex;flex-direction:column;gap:12px}
41
- .mini-chat{border:1px solid #26314d;border-radius:12px;background:#0b0f1b;padding:10px;display:flex;flex-direction:column;gap:8px}
42
- .mini-chat .bubble{background:#171a21;border:1px solid #2a3350;border-radius:10px;padding:8px 10px;font-size:12px}
43
- .overlay{position:fixed;inset:0;background:rgba(3,6,12,.62);backdrop-filter:blur(8px);display:none;align-items:center;justify-content:center;padding:18px;z-index:30}
44
- .overlay.open{display:flex}
45
- .workshop{width:min(1180px,96vw);height:min(860px,92vh);background:linear-gradient(180deg,#171c2a,#11141d);border:1px solid #2a324b;border-radius:18px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 20px 80px rgba(0,0,0,.55)}
46
- .ws-head{height:56px;display:flex;align-items:center;justify-content:space-between;padding:0 16px;border-bottom:1px solid #242d47;background:#131827}
47
- .steps{display:flex;gap:6px}
48
- .step{padding:6px 10px;border-radius:999px;font-size:12px;border:1px solid #2a334e;color:var(--muted);background:#0f1422}
49
- .step.active{background:var(--acc);border-color:var(--acc);color:white}
50
- .ws-body{display:grid;grid-template-columns:220px 1fr 360px;flex:1;min-height:0}
51
- .ws-nav{border-right:1px solid #242d47;background:#0f1320;padding:12px;display:flex;flex-direction:column;gap:10px}
52
- .ws-nav button{width:100%;text-align:left;padding:10px 12px;border-radius:10px;border:1px solid transparent;background:transparent;color:#cbd5f0;cursor:pointer}
53
- .ws-nav button.active{background:#1a2140;border-color:#2d3a66;color:white}
54
- .mono{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px}
55
- .json{white-space:pre-wrap;word-break:break-all;background:#0b0f1b;border:1px solid #26314d;border-radius:10px;padding:10px;max-height:360px;overflow:auto}
56
- .badge{font-size:11px;padding:2px 6px;border-radius:999px;border:1px solid #2a3350;background:#11182e;color:#9fb0d8}
57
- .dim-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}
58
- .dim{border:1px dashed #2a3552;border-radius:12px;padding:10px;background:#0c1020}
59
- @media (max-width:1180px){.layout{grid-template-columns:1fr}.left,.right{display:none}}
60
- </style>
61
- </head>
62
- <body>
63
- <div class="topbar">
64
- <div style="display:flex;gap:10px;align-items:center;font-weight:700">⬢ DSH <span style="color:var(--muted);font-weight:400">· 侧边常驻 + 浮层双形态</span></div>
65
- <div style="display:flex;gap:8px"><span class="pill">CCv3</span><span class="pill" style="background:#1a2a1f;border-color:#2a4a3a;color:#a6e8c8">● 模型就绪</span></div>
66
- </div>
67
-
68
- <div class="layout">
69
- <!-- LEFT persistent studio -->
70
- <div class="left">
71
- <div class="left-head"><strong style="font-size:13px">🎭 角色卡工坊</strong><span class="badge">常驻侧边</span></div>
72
- <div class="left-body">
73
- <div class="card"><h4>草稿进度</h4>
74
- <div style="display:flex;justify-content:space-between;font-size:12px;margin-bottom:6px"><span>雨城烛坊 · 岚烟</span><span style="color:var(--acc2)">62%</span></div>
75
- <div class="progress"><i></i></div>
76
- <div style="display:flex;gap:6px;margin-top:10px;flex-wrap:wrap"><span class="badge" style="background:#1a2a1f;border-color:#2a4a3a;color:#a6e8c8">✓ 点子</span><span class="badge" style="background:#1a2a1f;border-color:#2a4a3a;color:#a6e8c8">✓ 5维</span><span class="badge">角色细化</span><span class="badge">世界书 5条</span></div>
77
- <div style="display:flex;gap:8px;margin-top:10px"><button class="btn small" style="flex:1" onclick="openB()">全屏工坊</button><button class="btn ghost small" style="flex:1" onclick="exportJSON()">导出 JSON</button></div>
78
- </div>
79
- <div class="card"><h4>5维快览</h4>
80
- <div style="display:flex;flex-direction:column;gap:8px;font-size:12px">
81
- <div>① 年表:2127 记忆软资产化</div><div>② 势力:烛坊 / 雨梯局 / 盗忆人</div><div>③ 地理:环形雨城</div><div>④ 力量:自愿+嗅觉</div><div>⑤ 日常:蜡烛夜市</div>
82
- </div>
83
- <button class="btn ghost small" style="width:100%;margin-top:10px" onclick="openB();cur=1;renderB()">✎ 去 5维编辑</button>
84
- </div>
85
- <div class="card"><h4>世界书快切</h4>
86
- <div style="display:flex;flex-direction:column;gap:8px">
87
- <div style="padding:8px;border:1px solid #26314d;border-radius:10px;background:#0b0f1b;font-size:12px"><b>雨城常雨</b> <span class="badge">@@position after_desc</span><div class="mono" style="color:var(--muted)">雨梯…霓虹倒影导航</div></div>
88
- <div style="padding:8px;border:1px solid #26314d;border-radius:10px;background:#0b0f1b;font-size:12px"><b>蜡烛=记忆</b> <span class="badge">@@activate</span><div class="mono" style="color:var(--muted)">银线密度=情绪强度</div></div>
89
- </div>
90
- <button class="btn ghost small" style="width:100%;margin-top:10px" onclick="openB();cur=3;renderB()">打开世界书</button>
91
- </div>
92
- <div class="card"><h4>校验</h4><div style="font-size:12px;color:var(--muted)">group_only_greetings: <span style="color:var(--acc2)">1 条 ✓</span> · assets 主图标唯一 ✓ · 正则 0 错误</div></div>
93
- </div>
94
- </div>
95
-
96
- <!-- CENTER conversation -->
97
- <div class="center">
98
- <div class="msg"><div style="font-size:11px;color:var(--muted);letter-spacing:.06em">ASSISTANT</div>我在侧边常驻着,你边聊边改,不用来回开关。试试:“把蜡烛夜市写得更拥挤一点”</div>
99
- <div class="msg user"><div style="font-size:11px;color:var(--muted)">YOU</div>我想让盗忆人更可怜一点,不是纯反派</div>
100
- <div class="msg"><div style="font-size:11px;color:var(--muted)">ASSISTANT · 已联动工坊</div>已把 “盗忆人:被雨梯局吊销嗅觉执照的前调香师,只能靠盗忆续命” 写进 5维→势力,并生成一条 lorebook:<span class="mono" style="background:#0b0f1b;border:1px solid #26314d;padding:2px 6px;border-radius:6px">@@depth 4 盗忆人银线发黑…</span></div>
101
- <div class="composer">
102
- <div class="dock">
103
- <button class="capsule active" onclick="openB()">🎭 全屏工坊</button>
104
- <button class="capsule" onclick="document.querySelector('.left').scrollIntoView({behavior:'smooth'})">⬅ 查看侧边</button>
105
- <span class="badge">边聊边改 · 侧边实时同步</span>
106
- </div>
107
- <div class="inputbar"><textarea placeholder="直接聊,侧边会自动同步到草稿…"></textarea><button class="btn">发送</button></div>
108
- <div style="max-width:760px;margin:8px auto 0;color:var(--muted);font-size:12px">B 形态的核心差异:<b style="color:#cbd5f0">侧边常驻</b>,不需要打开浮层也能看到进度/快改;浮层仍保留用于沉浸式编辑。</div>
109
- </div>
110
- </div>
111
-
112
- <!-- RIGHT mini preview + CBS chat -->
113
- <div class="right">
114
- <div class="right-head"><strong style="font-size:13px">实时预览</strong><span class="badge">JSON</span></div>
115
- <div class="right-body">
116
- <div class="card" style="margin:0"><h4>CCv3 JSON</h4><pre class="json mono" id="jsonB"></pre><button class="btn ghost small" style="width:100%;margin-top:8px" onclick="exportJSON()">⬇ 导出 card.json</button></div>
117
- <div class="mini-chat"><h4 style="margin:0;font-size:12px;color:var(--muted)">工坊小助手(走 DSH llm)</h4>
118
- <div class="bubble">“把雨梯管理局写得官僚一点?”</div>
119
- <div class="bubble" style="background:#1a2140;border-color:#2d3a66">已改:增加“干燥配额抽签 + 记忆税滞纳金”两条 lorebook。</div>
120
- <div style="display:flex;gap:6px"><input placeholder="对工坊说句话…" style="flex:1;background:#0b0f1b;border:1px solid #26314d;border-radius:8px;padding:6px 8px;color:var(--text)"/><button class="btn small">发送</button></div>
121
- </div>
122
- <div class="card" style="margin:0"><h4>CBS 提示</h4><div class="chips"><span class="chip">{{char}}</span><span class="chip">{{user}}</span><span class="chip">{{random:A,B}}</span></div></div>
123
- </div>
124
- </div>
125
- </div>
126
-
127
- <!-- Overlay workshop (same core as A, but triggered from B) -->
128
- <div class="overlay" id="overlayB">
129
- <div class="workshop" role="dialog" aria-modal="true">
130
- <div class="ws-head"><div style="display:flex;gap:12px;align-items:center"><strong>🎭 角色卡工坊 · 全屏</strong><span class="badge">B 的浮层 = A 的工坊</span><div class="steps" id="stepsB"></div></div><div style="display:flex;gap:8px"><button class="btn ghost small" onclick="closeB()">返回侧边</button><button class="btn small" onclick="exportJSON()">导出 JSON</button></div></div>
131
- <div class="ws-body">
132
- <div class="ws-nav" id="navB"></div>
133
- <div style="padding:16px;overflow:auto" id="mainB"></div>
134
- <div style="border-left:1px solid #242d47;background:#0e1220;padding:12px;overflow:auto"><div class="card"><h4>预览</h4><pre class="json mono" id="jsonB2"></pre></div></div>
135
- </div>
136
- </div>
137
- </div>
138
-
139
- <script>
140
- const steps=["点子","5维","角色","世界书","导出"];
141
- let cur=0;
142
- const draft={spec:"chara_card_v3",spec_version:"3.0",data:{name:"雨城烛坊 · 岚烟",nickname:"岚烟",tags:["记忆","雨城","香薰"],creator:"you",character_version:"0.1",description:"雨城常雨,岚烟的烛坊以蜡封记忆。",personality:"外柔内韧,嗅觉共情",scenario:"你推开木门,雨气与蜡味扑面",system_prompt:"You are 岚烟",post_history_instructions:"保持蜡烛隐喻",first_mes:"“想典当哪一段?”",alternate_greetings:["“今晚适合想起旧人。”"],group_only_greetings:["(群聊)“一盏一盏来。”"],mes_example:"{{user}}: 还我夏夜\n{{char}}: 会很吵,你确定吗?",creator_notes:"B 原型",assets:[{type:"icon",uri:"ccdefault:",name:"main",ext:"png"}],creation_date:Math.floor(Date.now()/1000),modification_date:Math.floor(Date.now()/1000),character_book:{name:"雨城 lorebook",scan_depth:4,token_budget:1200,recursive_scanning:false,extensions:{},entries:[
143
- {enabled:true,insertion_order:0,use_regex:false,constant:false,name:"雨城常雨",keys:["雨城"],content:"@@position after_desc\n雨梯与霓虹",priority:10},
144
- {enabled:true,insertion_order:1,use_regex:false,constant:true,name:"蜡烛=记忆",keys:[],content:"@@activate\n蜡烛即记忆",priority:9},
145
- {enabled:true,insertion_order:2,use_regex:false,constant:false,name:"盗忆人",keys:["盗忆人"],content:"@@depth 4\n前调香师,被吊销执照,银线发黑",priority:8},
146
- ]}}};
147
- function renderSteps(){
148
- const el=document.getElementById('stepsB');
149
- el.innerHTML=steps.map((s,i)=>`<span class="step ${i===cur?'active':''}" onclick="cur=${i};renderB()">${i+1}. ${s}</span>`).join("");
150
- }
151
- function renderNav(){
152
- const nav=document.getElementById('navB');
153
- const items=[{t:"点子投喂",d:"一句话+风格"},{t:"世界观 5维",d:"年表/势力/地理/力量/日常"},{t:"角色细化",d:"六件套"},{t:"世界书",d:"Lorebook"},{t:"校验导出",d:"JSON"}];
154
- nav.innerHTML=items.map((x,i)=>`<button class="${i===cur?'active':''}" onclick="cur=${i};renderB()">${x.t}<div style="font-size:11px;color:var(--muted)">${x.d}</div></button>`).join("") + `<div style="margin-top:auto;padding-top:12px;border-top:1px solid #242d47;display:flex;gap:8px"><button class="btn ghost small" style="flex:1">AI 扩写</button><button class="btn small" style="flex:1">校验</button></div>`;
155
- }
156
- function renderB(){
157
- renderSteps(); renderNav();
158
- const m=document.getElementById('mainB');
159
- if(cur===0) m.innerHTML=`<div class="card"><h4>点子投喂</h4><div class="field"><label>一句话点子</label><textarea>雨城记忆典当行老板娘,蜡烛封记忆</textarea></div><div class="chips"><span class="chip on">雨城</span><span class="chip on">感官系</span><span class="chip">治愈</span></div><div style="margin-top:10px"><button class="btn small">✦ AI 扩写</button></div></div><div class="card"><h4>AI 初稿</h4><div class="field"><label>description</label><textarea>${draft.data.description}</textarea></div></div>`;
160
- else if(cur===1) m.innerHTML=`<div class="card"><h4>世界观 5维(B 的侧边会同步)</h4><div class="dim-grid"><div class="dim"><h4>① 年表</h4><textarea style="width:100%;min-height:70px;background:#0b0f1b;border:1px solid #26314d;border-radius:8px;color:var(--text);padding:8px">2127 雨城独立,记忆软资产化</textarea></div><div class="dim"><h4>② 势力</h4><textarea style="width:100%;min-height:70px;background:#0b0f1b;border:1px solid #26314d;border-radius:8px;color:var(--text);padding:8px">烛坊 / 雨梯局 / 盗忆人(前调香师)</textarea></div><div class="dim"><h4>③ 地理</h4><textarea style="width:100%;min-height:70px;background:#0b0f1b;border:1px solid #26314d;border-radius:8px;color:var(--text);padding:8px">环形雨城,内干外雨</textarea></div><div class="dim"><h4>④ 力量</h4><textarea style="width:100%;min-height:70px;background:#0b0f1b;border:1px solid #26314d;border-radius:8px;color:var(--text);padding:8px">自愿+嗅觉,银线=情绪强度</textarea></div><div class="dim" style="grid-column:span 2"><h4>⑤ 日常</h4><textarea style="width:100%;min-height:70px;background:#0b0f1b;border:1px solid #26314d;border-radius:8px;color:var(--text);padding:8px">蜡烛夜市、雨声电台、记忆税</textarea></div></div><button class="btn small" style="margin-top:12px">✦ 5维→生成 Lorebook</button></div>`;
161
- else if(cur===2) m.innerHTML=`<div class="card"><h4>角色细化</h4><div style="display:grid;grid-template-columns:1fr 1fr;gap:10px"><div class="field"><label>name</label><input value="${draft.data.name}"/></div><div class="field"><label>nickname</label><input value="${draft.data.nickname}"/></div></div><div class="field"><label>description</label><textarea>${draft.data.description}</textarea></div></div>`;
162
- else if(cur===3) m.innerHTML=`<div class="card"><h4>世界书</h4>${draft.data.character_book.entries.map(e=>`<div style="padding:10px;border:1px solid #26314d;border-radius:10px;background:#0b0f1b;margin-bottom:8px"><b>${e.name}</b> <span class="badge">${e.keys.join(',')||'constant'}</span><div class="mono" style="color:var(--muted)">${e.content}</div></div>`).join("")}<button class="btn small">+ 新增条目</button></div>`;
163
- else m.innerHTML=`<div class="card"><h4>校验与导出(仅 JSON)</h4><div style="display:flex;gap:8px;margin:10px 0"><button class="btn small" onclick="exportJSON()">⬇ 导出 card.json</button><button class="btn ghost small">校验</button></div><div style="color:var(--muted);font-size:12px">CHARX/PNG 二期</div></div>`;
164
- document.getElementById('jsonB2').textContent=JSON.stringify(draft,null,2);
165
- document.getElementById('jsonB').textContent=JSON.stringify(draft,null,2);
166
- }
167
- function openB(){document.getElementById('overlayB').classList.add('open'); renderB();}
168
- function closeB(){document.getElementById('overlayB').classList.remove('open');}
169
- function exportJSON(){const b=new Blob([JSON.stringify(draft,null,2)],{type:"application/json"});const a=document.createElement('a');a.href=URL.createObjectURL(b);a.download="card.json";a.click(); setTimeout(()=>URL.revokeObjectURL(a.href),1000);}
170
- document.getElementById('overlayB').addEventListener('click',e=>{if(e.target.id==='overlayB')closeB();});
171
- renderB();
172
- document.getElementById('jsonB').textContent=JSON.stringify(draft,null,2);
173
- </script>
174
- </body>
175
- </html>