@zmainer/dsh-wx-bridge 1.0.12 → 1.0.13

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
@@ -147,6 +147,14 @@ dsh plugin --profile <profile> add @zmainer/dsh-wx-bridge
147
147
 
148
148
  ## 最近变更
149
149
 
150
+ - **1.0.13**:修「`/model` 回『模型目录暂时不可用』」(用户反馈)——
151
+ 模型/推理强度目录是**纯内存**的,且只在 `session/new` 分支填充;而 peer 一旦有会话(正常使用后的必然状态)
152
+ 就走 `session/resume` 或"本进程已挂载"分支,**这两个分支把上游返回的 `configOptions` 丢掉了**,
153
+ 于是 `/model` 永远读到空目录。现在:① `resume` 的返回接住;② `set_config_option` 的返回也带完整目录,
154
+ `applyPeerConfig` 顺手吸收(覆盖"已挂载"分支);③ 目录**落盘 `state.json`** 并在启动时回读
155
+ (桥重启后立刻可用);④ 空目录文案改成可操作的(并提示 `/new` 或直接 `provider/model`);
156
+ ⑤ `/status` 显示目录条数,新增 `--selftest-catalog [sessionId]` 一次看清来源。
157
+ 上游三处返回同构(`@deepseek-ai/dsh-acp`:`session/new` / `session/resume` / `session/set_config_option`)。
150
158
  - **1.0.12**:**支持图片识别**(用户要求)——
151
159
  ① 微信里的图片会被下载并解密(协议:`image_item.media.encrypt_query_param/full_url` + `aes_key`,
152
160
  走 `https://novac2c.cdn.weixin.qq.com/c2c/download`,**AES-128-ECB** 解密),落到 `<数据目录>/media/`;
@@ -718,22 +718,50 @@ function overlayFor(key) {
718
718
  return overlayCache.get(key) || ACP_PATCH
719
719
  }
720
720
  function peerMode(peer) { return presetKey(peer) }
721
- /** ACP 会话配置目录(session/new 返回,供 /model、/effort 列选用)。 */
721
+ /**
722
+ * ACP 会话配置目录(供 /model、/effort 列选用)。
723
+ * 上游在这三处都会返回 `configOptions`:`session/new`、**`session/resume`**、`session/set_config_option`
724
+ *(@deepseek-ai/dsh-acp 的 lib/index.js 同构返回)。**三个入口都要接住** ——
725
+ * 原来只在 new 分支填充,而 peer 一旦有会话(正常使用后的必然状态)就走 resume,
726
+ * 于是 /model 永远读到空目录(2026-09-22 用户反馈)。
727
+ */
722
728
  let acpModelCatalog = []
723
729
  let acpEffortCatalog = []
724
730
  function flattenConfigOptions(opts) {
731
+ let filled = 0
725
732
  for (const o of opts || []) {
726
733
  if (o.id === 'model') {
727
734
  const list = []
728
735
  for (const g of o.options || []) for (const it of g.options || []) list.push({ group: g.name || g.group || '', label: it.name || it.value, value: it.value })
729
- if (list.length) acpModelCatalog = list
736
+ if (list.length) { acpModelCatalog = list; filled++ }
730
737
  }
731
738
  if (o.id === 'reasoning_effort') {
732
739
  const list = []
733
740
  for (const it of o.options || []) list.push({ label: it.name || it.value, value: it.value })
734
- if (list.length) acpEffortCatalog = list
741
+ if (list.length) { acpEffortCatalog = list; filled++ }
742
+ }
743
+ }
744
+ return filled
745
+ }
746
+ /** 目录落盘:桥重启后 /model 立刻可用(原来纯内存,重启即空)。 */
747
+ function persistCatalog(source) {
748
+ try {
749
+ if (!acpModelCatalog.length && !acpEffortCatalog.length) return
750
+ state.acpCatalog = {
751
+ models: acpModelCatalog.slice(0, 200), efforts: acpEffortCatalog.slice(0, 50),
752
+ source, at: new Date().toISOString(),
735
753
  }
754
+ saveState(state)
755
+ } catch (e) { log('catalog-persist-failed', { error: String(e?.message ?? e) }) }
756
+ }
757
+ /** 统一入口:拿到 configOptions 就 flatten + 落盘 + 记日志(三个来源共用)。 */
758
+ function absorbConfigOptions(opts, source) {
759
+ const n = flattenConfigOptions(opts)
760
+ if (n) {
761
+ log('catalog-filled', { source, models: acpModelCatalog.length, efforts: acpEffortCatalog.length })
762
+ persistCatalog(source)
736
763
  }
764
+ return n
737
765
  }
738
766
  function getAcpHost(key) {
739
767
  const k = String(key || ACP_DEFAULT_PRESET).trim() || ACP_DEFAULT_PRESET
@@ -881,7 +909,9 @@ async function ensurePeerSession(peer) {
881
909
  return slot.id
882
910
  }
883
911
  try {
884
- await host.resumeSession(slot.id, cwd)
912
+ const r = await host.resumeSession(slot.id, cwd)
913
+ // resume 也会带回配置目录——原来这里把它丢了,导致 /model 在"有会话"时永远空目录
914
+ absorbConfigOptions(r?.configOptions, 'resume')
885
915
  await applyPeerConfig(peer, slot.id)
886
916
  return slot.id
887
917
  } catch (e) {
@@ -895,7 +925,7 @@ async function ensurePeerSession(peer) {
895
925
  slot.cwd = cwd
896
926
  // 卖点落地:桥写出的会话要立刻出现在桌面 GUI 的工作区列表里(不靠人手动 /attach)。
897
927
  void reportSessionToHost(slot.id, cwd)
898
- flattenConfigOptions(r?.configOptions)
928
+ absorbConfigOptions(r?.configOptions, 'new')
899
929
  if (Array.isArray(r?.configOptions)) peer.acpConfig = r.configOptions.map((o) => o.id).join(',')
900
930
  await applyPeerConfig(peer, slot.id)
901
931
  await saveState(state)
@@ -998,8 +1028,10 @@ async function applyPeerConfig(peer, sessionId) {
998
1028
  }
999
1029
  const host = getAcpHost(peerMode(peer))
1000
1030
  try {
1001
- if (peer.acpModel) await host.setConfigOption(sid, 'model', peer.acpModel)
1002
- if (peer.acpEffort) await host.setConfigOption(sid, 'reasoning_effort', peer.acpEffort)
1031
+ // set_config_option 的返回里同样带完整 configOptions 顺手补目录
1032
+ //("本进程已挂载"这条分支就是靠它覆盖的:那里没有 new/resume 的返回可接)
1033
+ if (peer.acpModel) absorbConfigOptions((await host.setConfigOption(sid, 'model', peer.acpModel))?.configOptions, 'set-model')
1034
+ if (peer.acpEffort) absorbConfigOptions((await host.setConfigOption(sid, 'reasoning_effort', peer.acpEffort))?.configOptions, 'set-effort')
1003
1035
  } catch (e) { log('acp-config-apply-failed', { error: String(e?.message ?? e) }) }
1004
1036
  }
1005
1037
 
@@ -1149,6 +1181,12 @@ function enqueueTask(token, peerKey, from, text, contextToken) {
1149
1181
 
1150
1182
  let state = loadState()
1151
1183
  let lastSelfSave = 0
1184
+ // 目录落盘的回读:桥刚起来、还没跟 ACP 交换过配置时,/model、/effort 也该有清单。
1185
+ if (Array.isArray(state.acpCatalog?.models) && state.acpCatalog.models.length) {
1186
+ acpModelCatalog = state.acpCatalog.models
1187
+ acpEffortCatalog = Array.isArray(state.acpCatalog.efforts) ? state.acpCatalog.efforts : []
1188
+ log('catalog-from-state', { models: acpModelCatalog.length, efforts: acpEffortCatalog.length, at: state.acpCatalog.at || '' })
1189
+ }
1152
1190
  /**
1153
1191
  * 外部(宿主半的扫码登记、面板动作)会改同一份 state.json;桥只在启动时读一次的话,
1154
1192
  * 「扫码登记了新主人」要等到下次重启才生效 —— 这里在每轮轮询时比较 mtime,
@@ -1293,7 +1331,8 @@ async function handleMessage(token, msg) {
1293
1331
  '预设档=' + presetKey(peer) + '|默认档=' + ACP_DEFAULT_PRESET,
1294
1332
  '原生会话=' + (sessionSlot(peer, peerMode(peer)).id ? String(sessionSlot(peer, peerMode(peer)).id).slice(0, 12) + '…' : '(未建立)')
1295
1333
  + '|acp pid=' + (getAcpHost(peerMode(peer)).pid || 0),
1296
- '模型=' + (peer.acpModel || '(profile 默认)') + '|推理强度=' + (peer.acpEffort || '(默认)')].join(NL), ctx)
1334
+ '模型=' + (peer.acpModel || '(profile 默认)') + '|推理强度=' + (peer.acpEffort || '(默认)'),
1335
+ '模型目录=' + (acpModelCatalog.length || '(未拿到)') + ' 项|推理强度目录=' + (acpEffortCatalog.length || '(未拿到)') + ' 项'].join(NL), ctx)
1297
1336
  if (cmd === '/task') {
1298
1337
  const list = Object.values(state.tasks).slice(-8)
1299
1338
  if (!list.length) return reply(token, from, '当前无任务。发任意文本即可派活。', ctx)
@@ -1388,7 +1427,8 @@ async function handleMessage(token, msg) {
1388
1427
  try { await ensurePeerSession(peer) } catch (e) { return reply(token, from, 'ACP 会话不可用:' + String(e?.message ?? e), ctx) }
1389
1428
  const pickArg = body.trim().slice(6).trim()
1390
1429
  if (!pickArg) {
1391
- if (!acpModelCatalog.length) return reply(token, from, '模型目录暂时不可用(先发一条任务建会话)。', ctx)
1430
+ if (!acpModelCatalog.length) return reply(token, from, '模型目录还没拿到(本进程还没跟 ACP 会话交换过配置)。'
1431
+ + NL + '试:发任意一条消息、或 /new 后再发 /model;也可以直接 /model provider/model 指定。', ctx)
1392
1432
  const cur = peer.acpModel || ''
1393
1433
  const label = (m) => { try { const [pv, md] = JSON.parse(m.value); return pv + '/' + md } catch { return m.value } }
1394
1434
  const rows = acpModelCatalog.map((m, i) => (i + 1) + '. ' + m.label + '(' + m.group + '|' + label(m) + ')' + (m.value === cur ? ' ← 当前' : ''))
@@ -1485,6 +1525,24 @@ async function main() {
1485
1525
  }, null, 1))
1486
1526
  process.exit(0)
1487
1527
  }
1528
+ if (args.includes('--selftest-catalog')) {
1529
+ const sid = argOf('--selftest-catalog', '')
1530
+ const host = getAcpHost()
1531
+ let src = 'none', err = null
1532
+ try {
1533
+ if (sid) { const r = await host.resumeSession(sid, CWD); src = 'resume:' + absorbConfigOptions(r?.configOptions, 'selftest-resume') }
1534
+ else { const r = await host.newSession(CWD); src = 'new:' + absorbConfigOptions(r?.configOptions, 'selftest-new') }
1535
+ } catch (e) { err = String(e?.message ?? e) }
1536
+ host.stop()
1537
+ await saveState(state) // 等落盘完成再退出(saveState 是异步链,不然文件还没写就 process.exit)
1538
+ console.log('[selftest-catalog] ' + JSON.stringify({
1539
+ session: sid || '(new)', source: src, error: err,
1540
+ models: acpModelCatalog.length, sample: acpModelCatalog.slice(0, 3).map((m) => m.label),
1541
+ efforts: acpEffortCatalog.map((e) => e.value),
1542
+ persisted: !!(state.acpCatalog && state.acpCatalog.models && state.acpCatalog.models.length),
1543
+ }, null, 1))
1544
+ process.exit(acpModelCatalog.length ? 0 : 1)
1545
+ }
1488
1546
  if (args.includes('--selftest-media')) {
1489
1547
  const f = argOf('--selftest-media', '')
1490
1548
  let parsed = {}
@@ -1558,7 +1616,8 @@ async function main() {
1558
1616
  for (const [, h] of acpHosts) h.stop()
1559
1617
  console.log('[selftest-acp] ' + JSON.stringify({
1560
1618
  bin: resolveAcpBin(), binOk: fileOk(resolveAcpBin()), home: ACP_HOME, preset: presetKey(peer), patch: overlayFor(presetKey(peer)),
1561
- session: sessionSlot(peer, peer.acpMode).id,
1619
+ session: sessionSlot(peer, presetKey(peer)).id,
1620
+ catalog: acpModelCatalog.length,
1562
1621
  turn1: r1?.text, turn2: r2?.text, turn3: r3?.text, notice: r2?.notice || null,
1563
1622
  }))
1564
1623
  process.exit(0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zmainer/dsh-wx-bridge",
3
- "version": "1.0.12",
3
+ "version": "1.0.13",
4
4
  "type": "module",
5
5
  "main": "./lib/index.js",
6
6
  "dsh": {