dsh-armory 1.9.0 → 1.9.2
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.en.md +12 -3
- package/README.md +22 -7
- package/docs/installation.md +17 -7
- package/lib/client.js +133 -125
- package/lib/index.js +96 -0
- package/lib/session-persona.js +120 -0
- package/lib/typert.js +8 -0
- package/lib/whats-new.js +38 -3
- package/package.json +8 -3
package/README.en.md
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
<p align="center">
|
|
10
10
|
<a href="https://github.com/2026heshao/dsh-armory/stargazers"><img src="https://img.shields.io/github/stars/2026heshao/dsh-armory?style=social" alt="stars"></a>
|
|
11
11
|
<a href="https://github.com/2026heshao/dsh-armory/releases"><img src="https://img.shields.io/github/v/release/2026heshao/dsh-armory?label=release" alt="release"></a>
|
|
12
|
+
<a href="https://www.npmjs.com/package/dsh-armory"><img src="https://img.shields.io/npm/dt/dsh-armory?label=npm%20downloads" alt="npm downloads"></a>
|
|
12
13
|
<a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="license"></a>
|
|
13
14
|
<img src="https://img.shields.io/badge/DSH-%3E%3D0.1.5--rc.1-blue" alt="DSH">
|
|
14
15
|
<a href="https://github.com/topics/dsh-plugin"><img src="https://img.shields.io/badge/topic-dsh--plugin-8b5cf6" alt="topic: dsh-plugin"></a>
|
|
@@ -17,10 +18,14 @@
|
|
|
17
18
|
## Install in one line
|
|
18
19
|
|
|
19
20
|
```sh
|
|
21
|
+
# npm (recommended: no build-approval step, ready to use)
|
|
22
|
+
dsh plugin --profile web add dsh-armory
|
|
23
|
+
|
|
24
|
+
# GitHub
|
|
20
25
|
dsh plugin --profile web add github:2026heshao/dsh-armory
|
|
21
26
|
```
|
|
22
27
|
|
|
23
|
-
Restart DSH, then open **Skills · MCP** from the global panel list at the top of the sidebar (or the floating button bottom-right). Desktop users on the `desktop` profile: swap `--profile web` for `--profile desktop`.
|
|
28
|
+
Restart DSH, then open **Skills · MCP · Experts** from the global panel list at the top of the sidebar (or the floating button bottom-right). Desktop users on the `desktop` profile: swap `--profile web` for `--profile desktop`. You can also search `armory` and one-click install inside [dsh-market](https://github.com/dsh-market/dsh-market#readme).
|
|
24
29
|
|
|
25
30
|
## What it solves
|
|
26
31
|
|
|
@@ -87,10 +92,14 @@ A **unified board** for DeepSeek Harness (Web and desktop): five top-level tabs
|
|
|
87
92
|
## Install (details)
|
|
88
93
|
|
|
89
94
|
```sh
|
|
95
|
+
# npm (recommended)
|
|
96
|
+
dsh plugin --profile web add dsh-armory
|
|
97
|
+
|
|
98
|
+
# GitHub
|
|
90
99
|
dsh plugin --profile web add github:2026heshao/dsh-armory
|
|
91
100
|
```
|
|
92
101
|
|
|
93
|
-
Then **restart `dsh web
|
|
102
|
+
Then **restart DSH** (restart the desktop app, or `dsh web` for the browser GUI; desktop users: swap `--profile web` for `--profile desktop`). To upgrade: npm installs can click the in-panel upgrade banner; GitHub installs re-add with a new tag:
|
|
94
103
|
|
|
95
104
|
```sh
|
|
96
105
|
dsh plugin --profile web update dsh-armory
|
|
@@ -113,7 +122,7 @@ Details, local development installs, and troubleshooting: [docs/installation.md]
|
|
|
113
122
|
Two entry points, one panel:
|
|
114
123
|
|
|
115
124
|
- **Floating button (bottom-right)**: draggable, position remembered;
|
|
116
|
-
- **Sidebar entry
|
|
125
|
+
- **Sidebar entry "Skills · MCP · Experts"**: above "Settings" when expanded, icon-only in the 56px rail.
|
|
117
126
|
|
|
118
127
|
The panel floats above the session; closing it ("Close" or Esc) does not affect the conversation.
|
|
119
128
|
|
package/README.md
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
<p align="center">
|
|
10
10
|
<a href="https://github.com/2026heshao/dsh-armory/stargazers"><img src="https://img.shields.io/github/stars/2026heshao/dsh-armory?style=social" alt="stars"></a>
|
|
11
11
|
<a href="https://github.com/2026heshao/dsh-armory/releases"><img src="https://img.shields.io/github/v/release/2026heshao/dsh-armory?label=release" alt="release"></a>
|
|
12
|
+
<a href="https://www.npmjs.com/package/dsh-armory"><img src="https://img.shields.io/npm/dt/dsh-armory?label=npm%20downloads" alt="npm downloads"></a>
|
|
12
13
|
<a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="license"></a>
|
|
13
14
|
<img src="https://img.shields.io/badge/DSH-%3E%3D0.1.5--rc.1-blue" alt="DSH">
|
|
14
15
|
<a href="https://github.com/topics/dsh-plugin"><img src="https://img.shields.io/badge/topic-dsh--plugin-8b5cf6" alt="topic: dsh-plugin"></a>
|
|
@@ -17,10 +18,14 @@
|
|
|
17
18
|
## 一键安装
|
|
18
19
|
|
|
19
20
|
```sh
|
|
21
|
+
# npm(推荐:免构建授权,装完即用)
|
|
22
|
+
dsh plugin --profile web add dsh-armory
|
|
23
|
+
|
|
24
|
+
# GitHub
|
|
20
25
|
dsh plugin --profile web add github:2026heshao/dsh-armory
|
|
21
26
|
```
|
|
22
27
|
|
|
23
|
-
装完**重启 DSH**,侧栏顶部「全局面板」里点 **技能 · MCP
|
|
28
|
+
装完**重启 DSH**,侧栏顶部「全局面板」里点 **技能 · MCP · 专家**(或右下角悬浮按钮)。桌面端装在 `desktop` profile 时把 `--profile web` 换成 `--profile desktop`。也可以在装了 [dsh-market](https://github.com/dsh-market/dsh-market#readme) 的 DSH 里搜 `armory` 一键安装。
|
|
24
29
|
|
|
25
30
|
## 它解决什么
|
|
26
31
|
|
|
@@ -73,10 +78,14 @@ dsh plugin --profile web add github:2026heshao/dsh-armory
|
|
|
73
78
|
## 安装
|
|
74
79
|
|
|
75
80
|
```sh
|
|
81
|
+
# 从 npm 安装(推荐)
|
|
82
|
+
dsh plugin --profile web add dsh-armory
|
|
83
|
+
|
|
84
|
+
# 从 GitHub 安装
|
|
76
85
|
dsh plugin --profile web add github:2026heshao/dsh-armory
|
|
77
86
|
```
|
|
78
87
|
|
|
79
|
-
装完**重启 `dsh web
|
|
88
|
+
装完**重启 DSH**(桌面端重启应用,Web 端重启 `dsh web`;桌面端把 `--profile web` 换成 `--profile desktop`)。升级:npm 安装可在面板「一键升级」提示条上直接点,GitHub 安装换新版 tag 重新 `add`。卸载(不会删除 profile 里已有的 MCP 条目):
|
|
80
89
|
|
|
81
90
|
```sh
|
|
82
91
|
dsh plugin --profile web remove dsh-armory
|
|
@@ -93,7 +102,7 @@ dsh plugin --profile web remove dsh-armory
|
|
|
93
102
|
两个入口,同一个面板:
|
|
94
103
|
|
|
95
104
|
- **右下角悬浮按钮**:可拖拽移动,位置会被记住;
|
|
96
|
-
-
|
|
105
|
+
- **侧栏「技能 · MCP · 专家」**:展开态在「设置」上方,侧栏收起成 56px 轨道时只剩图标。
|
|
97
106
|
|
|
98
107
|
面板浮在会话之上,关掉它(右上角「关闭」或 Esc)不会影响会话。
|
|
99
108
|
|
|
@@ -181,12 +190,18 @@ dsh plugin --profile web remove dsh-armory
|
|
|
181
190
|
## 开发
|
|
182
191
|
|
|
183
192
|
```sh
|
|
184
|
-
npm
|
|
185
|
-
|
|
186
|
-
|
|
193
|
+
npm install --no-save yaml@2 # 测试唯一的裸依赖(node:test 内置)
|
|
194
|
+
npm test # 专家市场写链 33 断言(与 CI / 发版门槛同一条)
|
|
195
|
+
npm run test:e2e # 可选:用宿主补丁算法验证专家预设进入组合树
|
|
196
|
+
dsh --profile web --dump-config # 看 profile 的有效配置
|
|
197
|
+
dsh web # 起 Web 做真实操作验证
|
|
187
198
|
```
|
|
188
199
|
|
|
189
|
-
`npm test`
|
|
200
|
+
`npm test` 跑的是 `test/expert-manual.mjs`:真网拉 WorkBuddy 专家目录(ETag 命中后走磁盘缓存)与 senior-developer 人设,再对 `lib/expert-market.js` 的 patch 写链做 33 条断言——含 1.9.1 修复的回归用例(召唤预设必须写成顶层 `- insert:` 指令、旧版顶层裸条目要能被迁移)。本机存在 `~/.dsh/profiles/desktop/cordis.patch.yml` 时会追加一组真实 profile 往返断言,没有则记 `SKIP`。
|
|
201
|
+
|
|
202
|
+
CI(push 到 main / PR,见 `.github/workflows/ci.yml`)与发版门槛(推 tag,见 `.github/workflows/release.yml`)跑的是**逐字相同**的命令(装 `yaml@2` → `node ./test/run-expert-manual.mjs`),两处口径不许分叉;发版流程见 [安装与升级](docs/installation.md#发布流程维护者)。
|
|
203
|
+
|
|
204
|
+
> 本机若没有 `yaml`,`npm test` 会以 `ERR_MODULE_NOT_FOUND` 退出——`test/loader.mjs` 会先试标准解析,失败才回退开发机的 profile 副本(`~/.dsh/profiles/desktop/node_modules`),所以干净环境必须先装依赖。
|
|
190
205
|
|
|
191
206
|
## 文档
|
|
192
207
|
|
package/docs/installation.md
CHANGED
|
@@ -116,25 +116,35 @@ npm 与 GitHub Releases 必须**同一次发布里都更新**:用户从 npm
|
|
|
116
116
|
|
|
117
117
|
```sh
|
|
118
118
|
# 1) 改版本:package.json + README/docs 里的版本与 tag 引用 + lib/whats-new.js 的更新条目
|
|
119
|
-
# + .github/release-notes/v<新版本>.md
|
|
120
|
-
npm
|
|
119
|
+
# + .github/release-notes/v<新版本>.md(后两项缺一项,release.yml 的校验步骤就会红)
|
|
120
|
+
npm install --no-save yaml@2 # 测试唯一的裸依赖;干净 checkout 必须先装
|
|
121
|
+
npm test # 专家市场写链 33 断言(与 CI / 发版门槛同一条命令)
|
|
121
122
|
|
|
122
123
|
# 2) 真机过一遍(触发条件见下),**必须在打 tag 之前**
|
|
123
124
|
dsh web # 打开面板点一遍
|
|
124
125
|
|
|
125
|
-
# 3) 提交并推 tag(
|
|
126
|
+
# 3) 提交并推 tag(release.yml 会校验 tag 与 version 一致,不一致就拒绝发布)
|
|
126
127
|
git add -A && git commit -m "..."
|
|
127
128
|
git push origin main
|
|
128
|
-
git tag -a v1.9.
|
|
129
|
+
git tag -a v1.9.1 -m "v1.9.1 ..." && git push origin v1.9.1
|
|
129
130
|
```
|
|
130
131
|
|
|
131
|
-
推 tag 后
|
|
132
|
+
推 tag 后 `.github/workflows/release.yml` 依次做:校验 tag 与版本一致 → 校验 whats-new 有当前版本条目 → 校验 release notes 稿存在 → 装 `yaml@2` → `node ./test/run-expert-manual.mjs` → `npm publish`(trusted publishing,无需 token)→ `gh release create`。
|
|
132
133
|
|
|
133
|
-
|
|
134
|
+
CI 分工(两条工作流,门槛命令**逐字相同**,改一处必须改另一处):
|
|
135
|
+
|
|
136
|
+
| 工作流 | 触发 | 跑什么 |
|
|
137
|
+
| --- | --- | --- |
|
|
138
|
+
| `.github/workflows/ci.yml` | push 到 main、PR、手动 | 装 `yaml@2` → `node ./test/run-expert-manual.mjs`(只验证,不发布) |
|
|
139
|
+
| `.github/workflows/release.yml` | 推 `v*.*.*` tag、手动 | 同上门槛 + 版本 / whats-new / release-notes 三项校验 → `npm publish` → GitHub Release |
|
|
140
|
+
|
|
141
|
+
> **本仓库没有 `pnpm-lock.yaml`**(运行依赖全是宿主提供的 peer,不装进包),而 devDependencies 里的 `@deepseek-ai/*` 是宿主内部包、npm registry 上不存在。所以两条工作流都**不能用** `pnpm install --frozen-lockfile`——改回它会立刻红(历史上失败过一次)。测试只需 `yaml`,用 `npm install --no-save` 装。
|
|
142
|
+
|
|
143
|
+
**第 2 步的真机检查什么时候必须做**:客户端那一半是替身测试——组件在 node 里渲染不起来(见[设计与限制](design.md#版本范围怎么定以及它管不到什么)),所以 CI 全绿也证明不了面板能显示。因此**只要本次改动碰了 `lib/client.js`、或推进了 DSH 基线,就必须做**:起 `dsh web`,确认侧栏入口能打开面板、悬浮按钮在位,然后导入 / 预览 / 启停各点一次。
|
|
134
144
|
|
|
135
145
|
为什么不能交给 CI:这类失败的表现是**面板白屏**,不会让任何用例变红(注册契约是好的,渲染不出来而已)。宿主改 slot 就是这种改动的典型——`0.1.6-alpha.2` 的发布说明里有「客户端 Session 会话支持多实例共存,相关 API 及 slot 有变化」,2026-09-24 推进基线到 `0.1.7-rc.1`、09-28 推进到 `0.1.7-rc.2` 时也属于这一类,所以它固定成检查单而不是靠记得。纯 Host 侧改动(`lib/index.js`、`lib/workspace-*.js` 等)可以跳过这一步。
|
|
136
146
|
|
|
137
|
-
**Release notes 是必写的**,稿子放 `.github/release-notes/vX.Y.Z.md`(**不要**放 `docs/`,docs 会随包发布到 npm
|
|
147
|
+
**Release notes 是必写的**,稿子放 `.github/release-notes/vX.Y.Z.md`(**不要**放 `docs/`,docs 会随包发布到 npm);`release.yml` 的「校验 release notes 稿子存在」步骤会拦下漏写,`gh release create --notes-file` 用它作为 Release 页面正文。为什么必写:本仓库全部直接提交到 main、没有 PR,自动生成几乎生成不出内容(v1.3.0 及之前每个 Release 页面都只有一行 compare 链接),而面板的更新提示正是把用户指到那一页。
|
|
138
148
|
|
|
139
149
|
**应用内的「更新到 vX」提示是另一件事**:它的内容真源是 `lib/whats-new.js`(随包发布,宿主的 `whatsNew` RPC 直接转发它),发布前必须给它写上当前版本那一条——`test/whats-new.test.mjs` 会拦下「bump 了版本却没写更新内容」。只写一条就够,不必和 GitHub Release notes 逐字一致。
|
|
140
150
|
|
package/lib/client.js
CHANGED
|
@@ -375,9 +375,6 @@ const MCP_CSS = `body{--mcp-accent:var(--dsw-alias-brand-primary);--mcp-ok:var(-
|
|
|
375
375
|
.dsh-mcp-quick-note{padding:7px 11px;margin-bottom:4px;border-radius:var(--mcp-r-sm);background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary);font-size:11px;line-height:15px;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3;overflow:hidden;white-space:normal;word-break:break-word}
|
|
376
376
|
.dsh-mcp-quick-note.err{color:var(--mcp-err)}
|
|
377
377
|
.dsh-mcp-quick-note.ok{color:var(--mcp-ok)}
|
|
378
|
-
.dsh-mcp-quick-new-session{width:calc(100% - 8px);margin:0 4px 6px;border:1px solid color-mix(in srgb,var(--mcp-accent) 35%,transparent);border-radius:var(--mcp-r-sm);background:color-mix(in srgb,var(--mcp-accent) 10%,transparent);color:var(--mcp-accent);font:inherit;font-size:12px;line-height:26px;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:6px;white-space:nowrap;overflow:hidden}
|
|
379
|
-
.dsh-mcp-quick-new-session:hover{background:color-mix(in srgb,var(--mcp-accent) 18%,transparent)}
|
|
380
|
-
.dsh-mcp-quick-new-session span{overflow:hidden;text-overflow:ellipsis}
|
|
381
378
|
.dsh-mcp-quick-err{color:var(--mcp-err)}
|
|
382
379
|
/* ── 主面板页(main slot,和「插件」同级的入口页)────────────────────────
|
|
383
380
|
入口型页面要用 --dsh-frame-top-clearance 让出窗口顶带(官方 ui-layout 的约定:
|
|
@@ -1529,21 +1526,35 @@ const McpTab = (props) => {
|
|
|
1529
1526
|
// 探测一键升级的可用性:官方 pluginManager 在不在 + 本插件是不是 npm 装的。
|
|
1530
1527
|
// 与 updateHint 同一条纪律:任何失败都静默(按钮不出现即可),绝不因它让面板报错。
|
|
1531
1528
|
// 只在确实有新版时才探测——没有新版时按钮本来就不显示,省一次 remote 调用。
|
|
1529
|
+
//
|
|
1530
|
+
// 安装来源必须问宿主(call('installInfo')):宿主 listBundles() 的行里没有 source
|
|
1531
|
+
// 字段,客户端拿它判来源会恒判「npm 装的」;而 installBundle 执行 `pnpm add <spec>`,
|
|
1532
|
+
// 本地 file:/link: 安装的用户一点就把开发副本换成 npm 包。installInfo 读的是 profile
|
|
1533
|
+
// manifest 里本插件那一行的真实 spec,是唯一可信判据。
|
|
1532
1534
|
useEffect(() => {
|
|
1533
1535
|
if (!updateHint?.latest || !pluginManager || typeof pluginManager[UPGRADE_METHOD] !== 'function') return undefined;
|
|
1534
1536
|
let cancelled = false;
|
|
1535
1537
|
(async () => {
|
|
1536
1538
|
try {
|
|
1537
|
-
const
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1539
|
+
const [bundleRes, installRes] = await Promise.all([
|
|
1540
|
+
pluginManager.listBundles(),
|
|
1541
|
+
call('installInfo').catch(() => null),
|
|
1542
|
+
]);
|
|
1543
|
+
if (cancelled) return;
|
|
1544
|
+
const bundle = bundleRes && bundleRes.ok === true && Array.isArray(bundleRes.value)
|
|
1545
|
+
? bundleRes.value.find((b) => b && b.name === UPGRADE_PACKAGE) || null
|
|
1546
|
+
: null;
|
|
1547
|
+
const install = installRes && installRes.ok ? installRes : null;
|
|
1548
|
+
// 只有判定为「可升级」才记 spec;不可升级时也记下 source/reason,
|
|
1549
|
+
// 供横幅按真实来源说清「为什么不能一键升」(本地安装 vs 来源未知)。
|
|
1550
|
+
const plan = upgradePlan({ service: pluginManager, hint: updateHint, bundle, spec: install?.spec ?? null, source: install?.source ?? null });
|
|
1551
|
+
if (cancelled) return;
|
|
1552
|
+
if (plan.supported) setUpgradeState({ phase: 'idle', bundle, spec: plan.spec, source: install?.source ?? null });
|
|
1553
|
+
else setUpgradeState({ phase: 'idle', bundle, source: install?.source ?? null, reason: plan.reason });
|
|
1543
1554
|
} catch { /* 探测失败 = 没有一键升级,退回链接;不报错 */ }
|
|
1544
1555
|
})();
|
|
1545
1556
|
return () => { cancelled = true; };
|
|
1546
|
-
}, [pluginManager, updateHint]);
|
|
1557
|
+
}, [pluginManager, updateHint, call]);
|
|
1547
1558
|
|
|
1548
1559
|
// 挂载失败按 serverName 索引:既标到列表行上,也注入详情的错误区,避免「顶部报错但那一行看着正常」。
|
|
1549
1560
|
// 挂载失败的汇总横幅用的就是这个列表;行与详情都读 Host 标在行上的 mountFailed。
|
|
@@ -1710,33 +1721,31 @@ const McpTab = (props) => {
|
|
|
1710
1721
|
// 更不该报错(这一整块是纯增值信息,与 updateHint 同一条纪律)。
|
|
1711
1722
|
const [upgradeState, setUpgradeState] = useState({ phase: 'idle' });
|
|
1712
1723
|
const runUpgrade = async () => {
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
}
|
|
1720
|
-
setUpgradeState({ phase: 'running', version: plan.version });
|
|
1724
|
+
// 判定在探测 effect 里已用真实 spec(宿主 installInfo)做完;按钮只在 supported 时
|
|
1725
|
+
// 才渲染,所以这里拿到 spec 就能执行。状态过期(spec 被清掉)时只提示、不动安装。
|
|
1726
|
+
const targetSpec = upgradeState.spec;
|
|
1727
|
+
if (!targetSpec) { setUpgradeState({ phase: 'blocked', reason: upgradeState.reason || 'not-ready' }); return; }
|
|
1728
|
+
const targetVersion = targetSpec.split('@^')[1] || updateHint?.latest || '';
|
|
1729
|
+
setUpgradeState((current) => ({ ...current, phase: 'running', version: targetVersion }));
|
|
1721
1730
|
try {
|
|
1722
1731
|
// 两个参数:installBundle(spec, options)。少传第二个会被 wire 直接拒绝
|
|
1723
1732
|
// (client api: pluginManager/installBundle expected 2 argument(s), got 1)。
|
|
1724
|
-
const res = await
|
|
1733
|
+
const res = await pluginManager[UPGRADE_METHOD](targetSpec, {});
|
|
1725
1734
|
if (!res || res.ok !== true) {
|
|
1726
|
-
setUpgradeState({ phase: 'failed', text: (res && res.error && (res.error.message || res.error.code)) || '调用失败' });
|
|
1735
|
+
setUpgradeState((current) => ({ ...current, phase: 'failed', text: (res && res.error && (res.error.message || res.error.code)) || '调用失败' }));
|
|
1727
1736
|
return;
|
|
1728
1737
|
}
|
|
1729
1738
|
const r = res.value || {};
|
|
1730
1739
|
if (r.application === 'failed' || r.application === 'cancelled') {
|
|
1731
|
-
setUpgradeState({ phase: 'failed', text: upgradeFailureText(r.error, r.packageResult && r.packageResult.exitCode) });
|
|
1740
|
+
setUpgradeState((current) => ({ ...current, phase: 'failed', text: upgradeFailureText(r.error, r.packageResult && r.packageResult.exitCode) }));
|
|
1732
1741
|
return;
|
|
1733
1742
|
}
|
|
1734
1743
|
// applied / restart-required / overridden 都算装上了。宿主代码在进程内执行,升级已存在的包
|
|
1735
1744
|
// 时官方**无条件**返回 restart-required(installBundle 里 `if (Object.hasOwn(before, name))
|
|
1736
1745
|
// return 'restart-required'`,与 HMR 开不开无关),所以这里必须告诉用户去重启。
|
|
1737
|
-
setUpgradeState({ phase: 'done', version:
|
|
1746
|
+
setUpgradeState({ phase: 'done', version: targetVersion });
|
|
1738
1747
|
} catch (e) {
|
|
1739
|
-
setUpgradeState({ phase: 'failed', text: String(e?.message || e) });
|
|
1748
|
+
setUpgradeState((current) => ({ ...current, phase: 'failed', text: String(e?.message || e) }));
|
|
1740
1749
|
}
|
|
1741
1750
|
};
|
|
1742
1751
|
const filtered = useMemo(() => {
|
|
@@ -1814,8 +1823,12 @@ const McpTab = (props) => {
|
|
|
1814
1823
|
: upgradeState.phase === 'done' ? '已升级到 v' + upgradeState.version + ',重启宿主后生效'
|
|
1815
1824
|
: upgradeState.phase === 'failed' ? '升级失败:' + upgradeState.text
|
|
1816
1825
|
: upgradeState.phase === 'blocked' && upgradeState.reason === 'not-npm'
|
|
1817
|
-
? '
|
|
1818
|
-
:
|
|
1826
|
+
? '本地安装(' + (upgradeState.source || 'file') + '),一键升级会把本地副本换成 npm 包,故只提示' + (updateHint?.current ? '(当前 v' + updateHint.current + ')' : '')
|
|
1827
|
+
: upgradeState.phase === 'idle' && upgradeState.reason === 'not-npm'
|
|
1828
|
+
// 探测阶段就判定不可一键升:这是本地开发安装的常态(file:/link:),
|
|
1829
|
+
// 说清原因,免得用户以为按钮坏了。
|
|
1830
|
+
? '新版本 v' + updateHint.latest + ' 可用;本地安装(' + (upgradeState.source || 'file') + ')请手动更新后重启'
|
|
1831
|
+
: '新版本 v' + updateHint.latest + ' 可用'),
|
|
1819
1832
|
// 可升级时给按钮,否则退回链接。按钮只在探测确认过 service + npm 来源后出现——
|
|
1820
1833
|
// 探测失败时这一整条与改动前完全一致,不会出现点了没反应的按钮。
|
|
1821
1834
|
upgradeState.phase === 'idle' && upgradeState.spec
|
|
@@ -2843,7 +2856,7 @@ const ExpertMarket = ({ call }) => {
|
|
|
2843
2856
|
const QUICK_MENU_KINDS = [
|
|
2844
2857
|
{ key: 'skill', label: '技能', icon: 'skill', hint: '技能按需触发:选择后以 /技能名 插入输入框' },
|
|
2845
2858
|
{ key: 'mcp', label: 'MCP', icon: 'plug', hint: 'MCP 工具按 server 生效:选择后点名 mcp__服务__* 工具' },
|
|
2846
|
-
{ key: 'expert', label: '专家', icon: 'expert', hint: '
|
|
2859
|
+
{ key: 'expert', label: '专家', icon: 'expert', hint: '专家随时挂/换/摘:点一个即接管本会话,再点别的直接换,点 chip 的 × 取消' },
|
|
2847
2860
|
];
|
|
2848
2861
|
const QUICK_RECENT_LIMIT = 6;
|
|
2849
2862
|
// 引用文本格式(对齐宿主真实语义,避免「同名空文档」):
|
|
@@ -3026,109 +3039,92 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
|
|
|
3026
3039
|
const itemLabel = (kind, item) => (kind === 'expert' ? item.name || item.plugin : item.name);
|
|
3027
3040
|
const itemDesc = (kind, item) => (kind === 'mcp' ? (item.transport ? item.transport : '') : (item.description || '').slice(0, 48));
|
|
3028
3041
|
|
|
3029
|
-
// ── 挂专家(WorkBuddy
|
|
3030
|
-
//
|
|
3031
|
-
//
|
|
3032
|
-
//
|
|
3042
|
+
// ── 挂专家(WorkBuddy 式:随时挂 / 换 / 摘)──────────────────────────────
|
|
3043
|
+
// 主通道是宿主新的会话级人设 RPC expertAttach/expertDetach:它把专家人设作为
|
|
3044
|
+
// systemPrompt 的「本会话作用域 section」注入(与 dsh-persona / dsh-subagent 同款
|
|
3045
|
+
// deployment:persona-prefix 遮蔽机制),因此:
|
|
3046
|
+
// - 不受 agent-preset/locked 限制 —— 会话已开过口也能挂、能换、能摘;
|
|
3047
|
+
// - 只影响这一个会话,不动工具目录、不写任何持久化配置;
|
|
3048
|
+
// - 反复点不同专家 = 直接替换(Host 先摘旧的再装新的),不再是一次性。
|
|
3049
|
+
// 预设通道(agentPresets.select)作为 blank 会话的增强:它连工具目录一起接管,语义更
|
|
3050
|
+
// 彻底;只在「该专家已被召唤 + 会话还是空白」时成功,失败就自动退回人设通道——
|
|
3051
|
+
// 两条路都不通的情况不存在,所以「换专家」永远有出路。
|
|
3033
3052
|
// sessionId 来源:conversation.input.right 是 session 座位,slot 渲染时把 sessionId
|
|
3034
3053
|
// 作为 prop 显式传入(apply() 注册处 inject: (sessionId) => ({ call, sessionId }));
|
|
3035
3054
|
// 会话 summary 行本身不带 id 字段(id 只走 props),所以不能从 useSession 投影里取。
|
|
3036
3055
|
const MODULE = MODULE_CTX.ctx;
|
|
3037
|
-
const [attached, setAttached] = useState(null); // { plugin, name,
|
|
3056
|
+
const [attached, setAttached] = useState(null); // { plugin, name, mode: 'persona'|'preset' }
|
|
3038
3057
|
const [attachBusy, setAttachBusy] = useState(false);
|
|
3039
3058
|
const [attachNote, setAttachNote] = useState('');
|
|
3040
|
-
// locked 后的「带专家新建会话」待办:宿主硬规则是已开始的会话不能换预设
|
|
3041
|
-
// (agent-preset/locked),但官方 hero 预设选择器对 blank 会话可用——所以
|
|
3042
|
-
// 提供「新建会话并挂上该专家」的一键路径:sessions.create() → agentPresets.select() →
|
|
3043
|
-
// uiWorkspace.openSession(),与官方「新会话选预设」语义完全一致,只是自动化了。
|
|
3044
|
-
const [pendingExpert, setPendingExpert] = useState(null);
|
|
3045
3059
|
const sessionId = typeof sessionIdProp === 'string' && sessionIdProp !== '' ? sessionIdProp : null;
|
|
3046
3060
|
const expertPresetIdOf = (item) => 'wbexpert-' + String(item.plugin || '').toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
3047
|
-
const
|
|
3048
|
-
const ctx = MODULE;
|
|
3049
|
-
if (!ctx || typeof ctx.get !== 'function') { setAttachNote('宿主上下文不可用,无法新建会话'); return; }
|
|
3050
|
-
const sessions = ctx.get('sessions');
|
|
3051
|
-
const uiWorkspace = ctx.get('uiWorkspace');
|
|
3052
|
-
const remote = ctx.get('remote.agentPresets');
|
|
3053
|
-
if (!sessions || typeof sessions.create !== 'function') { setAttachNote('宿主未提供会话创建接口(sessions 不可用)'); return; }
|
|
3054
|
-
if (!uiWorkspace || typeof uiWorkspace.openSession !== 'function') { setAttachNote('宿主未提供会话导航接口(uiWorkspace 不可用)'); return; }
|
|
3055
|
-
if (!remote || typeof remote.select !== 'function') { setAttachNote('宿主未提供 agentPresets 接口(remote.agentPresets 不可用)'); return; }
|
|
3056
|
-
setAttachBusy(true); setAttachNote('');
|
|
3057
|
-
try {
|
|
3058
|
-
// 继承当前会话的工作目录(拿不到就让宿主用默认 workspace)。
|
|
3059
|
-
const cwd = sessionId && sessions.list && typeof sessions.list.getSnapshot === 'function'
|
|
3060
|
-
? sessions.list.getSnapshot().byId[sessionId]?.cwd : undefined;
|
|
3061
|
-
// ClientSessions.create 成功直接返回 sessionId 字符串,失败抛 SessionCreateError
|
|
3062
|
-
// (workspace-client 的 reuseOrCreateBlank 同款走法)。
|
|
3063
|
-
const newId = await sessions.create(cwd ? { cwd } : {});
|
|
3064
|
-
if (typeof newId !== 'string' || !newId) throw new Error('创建会话未返回会话 id');
|
|
3065
|
-
const presetId = expertPresetIdOf(item);
|
|
3066
|
-
const selected = await remote.select(newId, presetId);
|
|
3067
|
-
if (!selected || !selected.ok) {
|
|
3068
|
-
throw new Error('专家预设挂载失败:' + ((selected && selected.error && (selected.error.message || selected.error.code)) || '未知错误'));
|
|
3069
|
-
}
|
|
3070
|
-
uiWorkspace.openSession(newId);
|
|
3071
|
-
setAttached({ plugin: item.plugin, name: item.name || item.plugin, presetId: (selected.value && selected.value) || presetId });
|
|
3072
|
-
setPendingExpert(null);
|
|
3073
|
-
setAttachNote('✓ 已新建专家会话:' + (item.name || item.plugin) + '(人设全程接管)');
|
|
3074
|
-
call('wbExpertRecordUse', { kind: 'expert', row: { plugin: item.plugin, name: item.name || item.plugin } }).catch(() => {});
|
|
3075
|
-
} catch (e) {
|
|
3076
|
-
setAttachNote('新建专家会话失败:' + String((e && e.message) || e));
|
|
3077
|
-
} finally {
|
|
3078
|
-
setAttachBusy(false);
|
|
3079
|
-
}
|
|
3080
|
-
};
|
|
3081
|
-
const attachExpert = async (item) => {
|
|
3061
|
+
const attachExpert = async (item, { quiet = false } = {}) => {
|
|
3082
3062
|
setHoverKind(null);
|
|
3083
|
-
|
|
3084
|
-
// 结果显示回菜单内(菜单不关):成功 ✓ + 挂载名;失败显示真实报错。
|
|
3085
|
-
// 上一版把 attachNote 从界面拿掉导致失败无声,用户无从判断「为什么没显示」。
|
|
3086
|
-
if (!sessionId) { setPendingExpert(item); setAttachNote('没有活动会话:可直接带该专家新建会话'); return; }
|
|
3063
|
+
if (!sessionId) { setAttachNote('没有活动会话:请先进入一个会话再挂专家'); return; }
|
|
3087
3064
|
const presetId = expertPresetIdOf(item);
|
|
3088
|
-
const remote = MODULE && typeof MODULE.get === 'function' ? MODULE.get('remote.agentPresets') : null;
|
|
3089
|
-
if (!remote || typeof remote.select !== 'function') { setAttachNote('宿主未提供 agentPresets 接口(remote.agentPresets 不可用)'); return; }
|
|
3090
3065
|
setAttachBusy(true); setAttachNote('');
|
|
3091
3066
|
try {
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3067
|
+
// ① blank 会话 + 已召唤 → 先用预设通道(连工具目录一起接管,最彻底)。
|
|
3068
|
+
const remote = MODULE && typeof MODULE.get === 'function' ? MODULE.get('remote.agentPresets') : null;
|
|
3069
|
+
if (remote && typeof remote.select === 'function') {
|
|
3070
|
+
const result = await remote.select(sessionId, presetId).catch(() => null);
|
|
3071
|
+
if (result && result.ok) {
|
|
3072
|
+
setAttached({ plugin: item.plugin, name: item.name || item.plugin, mode: 'preset' });
|
|
3073
|
+
setAttachNote('✓ 已挂到当前会话:' + (item.name || item.plugin));
|
|
3074
|
+
call('wbExpertRecordUse', { kind: 'expert', row: { plugin: item.plugin, name: item.name || item.plugin } }).catch(() => {});
|
|
3075
|
+
return;
|
|
3076
|
+
}
|
|
3077
|
+
}
|
|
3078
|
+
// ② 其余情况(会话已开始 / 未召唤 / 无 agentPresets)→ 会话级人设通道,必定可用。
|
|
3079
|
+
const res = await call('expertAttach', { sessionId, plugin: item.plugin });
|
|
3080
|
+
if (res && res.ok) {
|
|
3081
|
+
setAttached({ plugin: item.plugin, name: res.name || item.name || item.plugin, mode: 'persona' });
|
|
3082
|
+
setAttachNote('✓ 已挂到当前会话:' + (res.name || item.name || item.plugin) + (res.replaced ? '(已替换原专家)' : ''));
|
|
3096
3083
|
call('wbExpertRecordUse', { kind: 'expert', row: { plugin: item.plugin, name: item.name || item.plugin } }).catch(() => {});
|
|
3097
3084
|
} else {
|
|
3098
|
-
|
|
3099
|
-
if (locked) {
|
|
3100
|
-
// 宿主硬规则:会话已开始(有 turn)就不允许换预设。给出一键替代路径
|
|
3101
|
-
// 而不是让用户自己去预设选择器里翻——见 createWithExpert。
|
|
3102
|
-
setPendingExpert(item);
|
|
3103
|
-
setAttachNote('该会话已开始对话,无法中途挂专家(宿主规则:预设只在空白会话可换);可一键带该专家新建会话');
|
|
3104
|
-
} else {
|
|
3105
|
-
setAttachNote('挂载失败:' + ((result && result.error && (result.error.message || result.error.code)) || JSON.stringify(result && result.error) || '未知错误'));
|
|
3106
|
-
}
|
|
3085
|
+
setAttachNote('挂载失败:' + ((res && res.error) || '未知错误'));
|
|
3107
3086
|
}
|
|
3108
3087
|
} catch (e) {
|
|
3109
|
-
setAttachNote('挂载异常:' + String((e && e.message) || e));
|
|
3088
|
+
if (!quiet) setAttachNote('挂载异常:' + String((e && e.message) || e));
|
|
3110
3089
|
} finally {
|
|
3111
3090
|
setAttachBusy(false);
|
|
3112
3091
|
}
|
|
3113
3092
|
};
|
|
3114
3093
|
const detachExpert = async () => {
|
|
3115
|
-
if (!
|
|
3116
|
-
|
|
3117
|
-
if (!remote || typeof remote.list !== 'function' || typeof remote.select !== 'function') { setAttached(null); return; }
|
|
3118
|
-
setAttachBusy(true);
|
|
3094
|
+
if (!sessionId) return;
|
|
3095
|
+
setAttachBusy(true); setAttachNote('');
|
|
3119
3096
|
try {
|
|
3120
|
-
|
|
3121
|
-
const
|
|
3122
|
-
const
|
|
3123
|
-
if (
|
|
3097
|
+
// 会话级人设一定清(不管当初走的是哪条路);预设通道只在当初走预设时回退默认。
|
|
3098
|
+
const res = await call('expertDetach', { sessionId }).catch(() => null);
|
|
3099
|
+
const remote = MODULE && typeof MODULE.get === 'function' ? MODULE.get('remote.agentPresets') : null;
|
|
3100
|
+
if (attached?.mode === 'preset' && remote && typeof remote.list === 'function' && typeof remote.select === 'function') {
|
|
3101
|
+
try {
|
|
3102
|
+
const list = await remote.list();
|
|
3103
|
+
const options = list && list.ok && Array.isArray(list.presets) ? list.presets : [];
|
|
3104
|
+
const fallback = (options.find((row) => row.isDefault) || options[0] || {}).id;
|
|
3105
|
+
if (fallback) await remote.select(sessionId, fallback);
|
|
3106
|
+
} catch { /* 预设回退失败不影响「已摘掉」这一事实 */ }
|
|
3107
|
+
}
|
|
3124
3108
|
setAttached(null);
|
|
3125
|
-
setAttachNote('');
|
|
3109
|
+
setAttachNote(res && res.ok && res.removed ? '✓ 已取消本会话的专家' : '本会话没有挂着专家');
|
|
3126
3110
|
} catch (e) {
|
|
3127
|
-
setAttachNote(String((e && e.message) || e));
|
|
3111
|
+
setAttachNote('取消失败:' + String((e && e.message) || e));
|
|
3128
3112
|
} finally {
|
|
3129
3113
|
setAttachBusy(false);
|
|
3130
3114
|
}
|
|
3131
3115
|
};
|
|
3116
|
+
// 会话切换 / 组件重挂时对齐真实状态:宿主是唯一真源(专家可能由别的入口挂上,
|
|
3117
|
+
// 或会话被重启),不能只信组件自己的 state。
|
|
3118
|
+
useEffect(() => {
|
|
3119
|
+
if (!sessionId) return undefined;
|
|
3120
|
+
let cancelled = false;
|
|
3121
|
+
call('expertActive').then((res) => {
|
|
3122
|
+
if (cancelled || !res || !res.ok || !Array.isArray(res.sessions)) return;
|
|
3123
|
+
const mine = res.sessions.find((row) => row.sessionId === sessionId);
|
|
3124
|
+
setAttached(mine ? { plugin: mine.plugin, name: mine.name, mode: 'persona' } : null);
|
|
3125
|
+
}).catch(() => { /* 对齐失败保持现状:不影响使用 */ });
|
|
3126
|
+
return () => { cancelled = true; };
|
|
3127
|
+
}, [sessionId, call]);
|
|
3132
3128
|
|
|
3133
3129
|
// ── 悬停开合(整树统一管理)───────────────────────────────────────────
|
|
3134
3130
|
// enter/leave 挂组件根,而不是每行:菜单顶部还有提示条/错误行/新建会话按钮,
|
|
@@ -3171,19 +3167,6 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
|
|
|
3171
3167
|
copied ? h('div', { className: 'dsh-mcp-quick-note' }, '已复制,粘贴到输入框:' + copied) : null,
|
|
3172
3168
|
attachBusy ? h('div', { className: 'dsh-mcp-quick-note' }, '正在挂载专家…') : null,
|
|
3173
3169
|
attachNote ? h('div', { className: 'dsh-mcp-quick-note' + (/^✓/.test(attachNote) ? ' ok' : ' err'), role: 'alert' }, attachNote) : null,
|
|
3174
|
-
// locked / 无会话时的一键替代路径:带该专家新建会话(createWithExpert)。
|
|
3175
|
-
// 按钮挂在 attachNote 下方,只在该动作可用时出现;busy 期间禁用防连点。
|
|
3176
|
-
pendingExpert && !attachBusy
|
|
3177
|
-
? h('button', {
|
|
3178
|
-
type: 'button',
|
|
3179
|
-
className: 'dsh-mcp-quick-new-session',
|
|
3180
|
-
title: '新建一个空白会话并立即挂上「' + (pendingExpert.name || pendingExpert.plugin) + '」预设,人设全程接管',
|
|
3181
|
-
onClick: (e) => { e.stopPropagation(); createWithExpert(pendingExpert); },
|
|
3182
|
-
},
|
|
3183
|
-
h(Icon, { d: ICONS.expert, size: 12 }),
|
|
3184
|
-
h('span', null, '带「' + (pendingExpert.name || pendingExpert.plugin) + '」新建会话'),
|
|
3185
|
-
)
|
|
3186
|
-
: null,
|
|
3187
3170
|
listError ? h('div', { className: 'dsh-mcp-quick-err' }, listError) : null,
|
|
3188
3171
|
!recent && !listError ? h('div', { className: 'dsh-mcp-quick-loading' }, '正在读取…') : null,
|
|
3189
3172
|
QUICK_MENU_KINDS.map((kind) => {
|
|
@@ -3221,7 +3204,7 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
|
|
|
3221
3204
|
type: 'button',
|
|
3222
3205
|
role: 'menuitem',
|
|
3223
3206
|
className: 'dsh-mcp-quick-item',
|
|
3224
|
-
title: kind.key === 'expert' ? '
|
|
3207
|
+
title: kind.key === 'expert' ? '挂到当前会话(可随时换或取消);再点别的专家 = 直接替换' : (itemDesc(kind.key, item) || itemLabel(kind.key, item)),
|
|
3225
3208
|
onClick: (e) => { e.stopPropagation(); if (kind.key === 'expert') attachExpert(item); else pick(kind.key, item, quickMentionText(kind.key, item)); },
|
|
3226
3209
|
},
|
|
3227
3210
|
kind.key === 'expert' && attached && attached.plugin === item.plugin
|
|
@@ -3933,26 +3916,43 @@ const UPGRADE_PACKAGE = 'dsh-armory';
|
|
|
3933
3916
|
/**
|
|
3934
3917
|
* 这次升级能不能做、为什么不能——纯判定,便于单测。
|
|
3935
3918
|
*
|
|
3936
|
-
*
|
|
3919
|
+
* 四种「不能升」的来源各不相同,报给用户的话也必须不同:
|
|
3937
3920
|
* - 宿主没提供 pluginManager(老宿主 / 非 web 形态)→ 退回手动命令;
|
|
3938
3921
|
* - 插件不是 npm 装的(link:/file:/github:)→ 覆盖安装会把它换成 npm 包,不能做;
|
|
3922
|
+
* - 安装来源未知(宿主没回 installInfo / 读不到 profile manifest)→ 同样不做:
|
|
3923
|
+
* 宁可少一个按钮,也不做可能毁掉用户本地副本的写操作;
|
|
3939
3924
|
* - 当前版本不低于最新 → 无需升级。
|
|
3940
3925
|
*
|
|
3941
3926
|
* @param {object} input - 判定输入。
|
|
3942
3927
|
* @param {object|null} input.service - 探测到的 pluginManager 命名空间(null = 宿主没提供)。
|
|
3943
3928
|
* @param {object|null} input.hint - updateHint 的结果(current / latest / updateAvailable)。
|
|
3944
|
-
* @param {object|null} input.bundle - listBundles 里本插件那一行(
|
|
3945
|
-
* @
|
|
3929
|
+
* @param {object|null} input.bundle - listBundles 里本插件那一行(version / installed / …)。
|
|
3930
|
+
* @param {string|null} input.spec - 宿主 installInfo 报回的 profile manifest spec。
|
|
3931
|
+
* @param {string|null} input.source - 宿主 installInfo 报回的来源分类(registry/file/link/…)。
|
|
3932
|
+
* @returns {{supported: boolean, reason: string, spec: string|null, version?: string}} 判定结果。
|
|
3946
3933
|
*/
|
|
3947
|
-
function upgradePlan({ service, hint, bundle }) {
|
|
3934
|
+
function upgradePlan({ service, hint, bundle, spec = null, source = null }) {
|
|
3948
3935
|
const hasMethod = !!service && typeof service[UPGRADE_METHOD] === 'function';
|
|
3949
3936
|
if (!hasMethod) return { supported: false, reason: 'no-service', spec: null };
|
|
3950
3937
|
if (!hint?.updateAvailable || !hint?.latest) return { supported: false, reason: 'up-to-date', spec: null };
|
|
3951
|
-
//
|
|
3952
|
-
//
|
|
3953
|
-
|
|
3954
|
-
|
|
3955
|
-
|
|
3938
|
+
// 本地安装形态不归 installBundle 管:它执行的是 `pnpm add <npm spec>`,会把用户的本地开发
|
|
3939
|
+
// 链接/文件依赖换成 npm 包——那是破坏而不是升级。只认 registry 来源。
|
|
3940
|
+
//
|
|
3941
|
+
// 判据必须来自宿主 installInfo(读 profile manifest 的真实 spec),**不能用
|
|
3942
|
+
// bundle.source**:宿主 listBundles() 的行里没有该字段(只有 name/version/description/
|
|
3943
|
+
// meta/enabled/installed/optional/removable/readOnlyReason/rows/overrides),旧实现据此
|
|
3944
|
+
// 恒判「可以升」,本地 file: 安装的用户一点就把开发副本换成了 npm 包。
|
|
3945
|
+
const sourceText = typeof source === 'string' ? source.trim() : '';
|
|
3946
|
+
const specText = typeof spec === 'string' ? spec.trim() : '';
|
|
3947
|
+
// 来源未知(新客户端配旧宿主,installInfo 还没上线 / manifest 读不到)→ 不做写操作。
|
|
3948
|
+
if (sourceText === '' || sourceText === 'unknown') return { supported: false, reason: 'unknown-source', spec: null };
|
|
3949
|
+
if (sourceText !== 'registry') return { supported: false, reason: 'not-npm', spec: null };
|
|
3950
|
+
// 双保险:spec 前缀也必须像 registry spec(宿主分类与客户端判据两处独立成立才算数)。
|
|
3951
|
+
const looksRegistry = specText === '' || /^[\^~]?\d/.test(specText) || /^(latest|\*|next|\*)$/i.test(specText);
|
|
3952
|
+
if (!looksRegistry) return { supported: false, reason: 'not-npm', spec: null };
|
|
3953
|
+
// 宿主将来若补上 bundle.source,两种信号取更保守的一侧(任一像本地就否决)。
|
|
3954
|
+
const legacySource = typeof bundle?.source === 'string' ? bundle.source.trim() : '';
|
|
3955
|
+
if (legacySource !== '' && !/^\d|^\^|^~|^latest$/.test(legacySource)) return { supported: false, reason: 'not-npm', spec: null };
|
|
3956
3956
|
return {
|
|
3957
3957
|
supported: true,
|
|
3958
3958
|
reason: 'ok',
|
|
@@ -4276,6 +4276,9 @@ const MCP_CONTRIBUTION = {
|
|
|
4276
4276
|
{ id: 'dsh-armory#mcpManager/projectConnections', service: 'mcpManager', namespace: 'mcpManager', method: 'projectConnections', invocation: { kind: 'direct' }, parameters: [], result: mcpCodec('ProjectConnectionsResult') },
|
|
4277
4277
|
{ id: 'dsh-armory#mcpManager/reconnectWorkspaceServer', service: 'mcpManager', namespace: 'mcpManager', method: 'reconnectWorkspaceServer', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
|
|
4278
4278
|
{ id: 'dsh-armory#mcpManager/updateHint', service: 'mcpManager', namespace: 'mcpManager', method: 'updateHint', invocation: { kind: 'direct' }, parameters: [], result: mcpCodec('UpdateHintResult') },
|
|
4279
|
+
// 安装形态(spec / source):一键升级的安全判据。与宿主 method、typert.js 三处必须同步,
|
|
4280
|
+
// 漏一处就是恒 404(toolTailor 踩过)。
|
|
4281
|
+
{ id: 'dsh-armory#mcpManager/installInfo', service: 'mcpManager', namespace: 'mcpManager', method: 'installInfo', invocation: { kind: 'direct' }, parameters: [], result: mcpCodec('InstallInfoResult') },
|
|
4279
4282
|
// 「这次更新了什么」:数据在 lib/whats-new.js,宿主只转发,判定“该不该弹”在客户端。
|
|
4280
4283
|
{ id: 'dsh-armory#mcpManager/whatsNew', service: 'mcpManager', namespace: 'mcpManager', method: 'whatsNew', invocation: { kind: 'direct' }, parameters: [], result: mcpCodec('WhatsNewResult') },
|
|
4281
4284
|
// ── Skill 看板:与 MCP 同一命名空间、同一信封(payload 进、{ok} 出)──
|
|
@@ -4300,7 +4303,12 @@ const MCP_CONTRIBUTION = {
|
|
|
4300
4303
|
{ id: 'dsh-armory#mcpManager/wbExpertDismiss', service: 'mcpManager', namespace: 'mcpManager', method: 'wbExpertDismiss', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
|
|
4301
4304
|
{ id: 'dsh-armory#mcpManager/wbExpertInstalled', service: 'mcpManager', namespace: 'mcpManager', method: 'wbExpertInstalled', invocation: { kind: 'direct' }, parameters: [], result: mcpCodec('WbExpertInstalledResult') },
|
|
4302
4305
|
{ id: 'dsh-armory#mcpManager/wbExpertRecent', service: 'mcpManager', namespace: 'mcpManager', method: 'wbExpertRecent', invocation: { kind: 'direct' }, parameters: [], result: mcpCodec('WbExpertRecentResult') },
|
|
4303
|
-
{ id: 'dsh-armory#mcpManager/wbExpertRecordUse', service: 'mcpManager', namespace: 'mcpManager', method: 'wbExpertRecordUse', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') }
|
|
4306
|
+
{ id: 'dsh-armory#mcpManager/wbExpertRecordUse', service: 'mcpManager', namespace: 'mcpManager', method: 'wbExpertRecordUse', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
|
|
4307
|
+
// 会话级专家人设(随时挂 / 换 / 摘):不受 agent-preset/locked 限制。
|
|
4308
|
+
// 与宿主 method、typert.js 三处必须同步,漏一处这条通道就恒 404。
|
|
4309
|
+
{ id: 'dsh-armory#mcpManager/expertAttach', service: 'mcpManager', namespace: 'mcpManager', method: 'expertAttach', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
|
|
4310
|
+
{ id: 'dsh-armory#mcpManager/expertDetach', service: 'mcpManager', namespace: 'mcpManager', method: 'expertDetach', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
|
|
4311
|
+
{ id: 'dsh-armory#mcpManager/expertActive', service: 'mcpManager', namespace: 'mcpManager', method: 'expertActive', invocation: { kind: 'direct' }, parameters: [], result: mcpCodec('ExpertActiveResult') }
|
|
4304
4312
|
].map((descriptor) => ({ ...descriptor, sourceLocation: { file: 'dsh-armory/lib/typert.js', line: 7, column: 1 } }))
|
|
4305
4313
|
};
|
|
4306
4314
|
async function apply(ctx) {
|
package/lib/index.js
CHANGED
|
@@ -15,6 +15,7 @@ import { allMcpEntries, entriesForName, projectToolSchema, revisionOf, toolInven
|
|
|
15
15
|
import { WHATS_NEW } from "./whats-new.js";
|
|
16
16
|
import { wbConnectorCatalog, wbConnectorPreview, wbSkillCatalog, wbSkillDetail, wbSkillFiles, wbSkillInstall, wbSkillInstalled, wbSkillUninstall } from "./wb-market.js";
|
|
17
17
|
import { installExpertSkills, listExpertRecent, listExpertSkills, readExpertPresets, recordExpertRecent, removeExpertPreset, uninstallExpertSkills, wbExpertCatalog, wbExpertDetail, writeExpertPreset } from "./expert-market.js";
|
|
18
|
+
import { attachPersona, detachPersona, listPersonas, reconcilePersonas } from "./session-persona.js";
|
|
18
19
|
import { scanSessionLedger } from "./tool-ledger.js";
|
|
19
20
|
import { evaluateEnvExpression, jsExpressionToTemplate } from "./workspace-config.js";
|
|
20
21
|
import { canonicalWorkspacePath, installAgentRuntime, listWorkspaceRecords, liveWorkspaceAgentCount, projectConnectionsView, readWorkspaceConfigCached, reconcileWorkspaceConnections, reconcileWorkspaceRestricts, reconnectWorkspaceServer, withWorkspaceWrite, workspaceConnectionStatus, workspaceMountErrorsView, workspaceRestrictErrorView, workspaceScopeErrorsView, workspaceToolSchemas, writeWorkspaceConfigFile } from "./workspace-runtime.js";
|
|
@@ -167,6 +168,32 @@ function patchPath(ctx) {
|
|
|
167
168
|
const filename = path.startsWith("file:") ? fileURLToPath(path) : path;
|
|
168
169
|
return join(dirname(filename), "cordis.patch.yml");
|
|
169
170
|
}
|
|
171
|
+
// profile 目录:与 patchPath 同一条受信发现路径(根 include 的 path 所在目录),
|
|
172
|
+
// 不是从 ctx.fs 或 cwd 猜出来的——installInfo 只读它下面的 package.json。
|
|
173
|
+
function profileDirOf(ctx) {
|
|
174
|
+
const path = patchPath(ctx);
|
|
175
|
+
return path ? dirname(path) : null;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* 安装来源分类:客户端据此决定「一键升级」能不能做。
|
|
179
|
+
* 本仓库历史上踩过的坑:listBundles() 的返回行里**没有 source 字段**,客户端拿它判
|
|
180
|
+
* 来源会恒判「npm 装的」,于是本地 file: 安装的用户一点升级就被 `pnpm add` 换成 npm 包。
|
|
181
|
+
* 这里只按 profile manifest 里的 spec 前缀分类,分类结果直接决定按钮出不出现。
|
|
182
|
+
* @returns {"registry"|"file"|"link"|"git"|"remote"|"path"|"unknown"}
|
|
183
|
+
*/
|
|
184
|
+
function classifyInstallSpec(spec) {
|
|
185
|
+
const text = typeof spec === "string" ? spec.trim() : "";
|
|
186
|
+
if (text === "") return "unknown";
|
|
187
|
+
if (/^(file:|portal:|workspace:)/i.test(text)) return "file";
|
|
188
|
+
if (/^link:/i.test(text)) return "link";
|
|
189
|
+
if (/^(github:|git\+|git:|https?:\/\/.*\.git)/i.test(text)) return "git";
|
|
190
|
+
if (/^https?:/i.test(text)) return "remote";
|
|
191
|
+
if (/^(\.{1,2}[\\/]|[A-Za-z]:[\\/]|\/)/.test(text)) return "path";
|
|
192
|
+
// registry spec:版本范围(^1.9.0 / ~1.9.0 / 1.9.0 / latest / * / 空=latest)
|
|
193
|
+
if (/^[\^~]?\d/.test(text) || /^(latest|\*)$/i.test(text)) return "registry";
|
|
194
|
+
if (/^[a-z@]/i.test(text)) return "registry";
|
|
195
|
+
return "unknown";
|
|
196
|
+
}
|
|
170
197
|
// Profile persistence is a Host-owned configuration capability, not a session workspace mutation.
|
|
171
198
|
// `ctx.fs` is intentionally fenced by the active workspace-write policy, so this target is
|
|
172
199
|
// derived only from the trusted root include and committed through the Host atomic writer.
|
|
@@ -312,6 +339,10 @@ const UPDATE_CHECK_URL = "https://api.github.com/repos/2026heshao/dsh-armory/rel
|
|
|
312
339
|
const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
313
340
|
const UPDATE_CHECK_TIMEOUT_MS = 5000;
|
|
314
341
|
const UPDATE_CHECK_DISABLED_ENV = "DSH_ARMORY_DISABLE_UPDATE_CHECK";
|
|
342
|
+
// 本插件的两个 profile 依赖名:1.9.0 改名后新名是 dsh-armory,旧名 dsh-skill-mcp-board
|
|
343
|
+
// 是「从旧版升级但还没删掉旧条目」的过渡态——installInfo 两行都查,命中哪行就报哪行。
|
|
344
|
+
const UPGRADE_PACKAGE_NAME = "dsh-armory";
|
|
345
|
+
const LEGACY_PACKAGE_NAME = "dsh-skill-mcp-board";
|
|
315
346
|
let updateCheckState = { checkedAt: 0, result: null };
|
|
316
347
|
|
|
317
348
|
function pluginVersion() {
|
|
@@ -1286,6 +1317,45 @@ let McpManagerGateway = (() => {
|
|
|
1286
1317
|
async updateHint() {
|
|
1287
1318
|
return checkForUpdate();
|
|
1288
1319
|
}
|
|
1320
|
+
/**
|
|
1321
|
+
* 本插件的安装形态与来源 spec —— 客户端「一键升级」的安全判据。
|
|
1322
|
+
*
|
|
1323
|
+
* 为什么必须由宿主读:客户端问得到的 listBundles() 行里**没有 source 字段**
|
|
1324
|
+
* (只有 name/version/description/meta/enabled/installed/optional/removable/
|
|
1325
|
+
* readOnlyReason/rows/overrides),客户端据此判断会恒判「可以一键升级」,而
|
|
1326
|
+
* installBundle 执行的是 `pnpm add <npm spec>`——本地 file:/link: 安装的用户一点
|
|
1327
|
+
* 就会把开发副本换成 npm 包。真源只有 profile manifest 的 dependencies。
|
|
1328
|
+
*
|
|
1329
|
+
* 只读、只回本插件自己的两行(新名 + 兼容旧名),失败一律降级为 unknown 而不是抛错:
|
|
1330
|
+
* 升级按钮不该因为读不到 manifest 就让面板报错。
|
|
1331
|
+
*/
|
|
1332
|
+
async installInfo() {
|
|
1333
|
+
const names = [UPGRADE_PACKAGE_NAME, LEGACY_PACKAGE_NAME];
|
|
1334
|
+
const info = {
|
|
1335
|
+
packageName: UPGRADE_PACKAGE_NAME,
|
|
1336
|
+
version: pluginVersion(),
|
|
1337
|
+
spec: null,
|
|
1338
|
+
source: "unknown",
|
|
1339
|
+
profileDir: null,
|
|
1340
|
+
};
|
|
1341
|
+
try {
|
|
1342
|
+
const dir = profileDirOf(this.ctx);
|
|
1343
|
+
if (dir) {
|
|
1344
|
+
info.profileDir = dir;
|
|
1345
|
+
const manifest = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
1346
|
+
const deps = manifest && typeof manifest.dependencies === "object" && manifest.dependencies ? manifest.dependencies : {};
|
|
1347
|
+
const hit = names.find((name) => typeof deps[name] === "string");
|
|
1348
|
+
if (hit) {
|
|
1349
|
+
info.spec = String(deps[hit]);
|
|
1350
|
+
info.installedAs = hit;
|
|
1351
|
+
info.source = classifyInstallSpec(info.spec);
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
} catch {
|
|
1355
|
+
// 读不到就维持 unknown:客户端据此退回「查看更新方式」链接,不做写操作。
|
|
1356
|
+
}
|
|
1357
|
+
return info;
|
|
1358
|
+
}
|
|
1289
1359
|
async whatsNew() {
|
|
1290
1360
|
// 只回数据,不在宿主判定「该不该弹」:「上次看到的版本」是每台浏览器自己的事实
|
|
1291
1361
|
// (localStorage),宿主没有这个视角;判定在客户端(whatsNewFor)。
|
|
@@ -1391,6 +1461,32 @@ let McpManagerGateway = (() => {
|
|
|
1391
1461
|
// 数据与安装逻辑都在 lib/expert-market.js;这里只做 RPC 转发与 patch 写盘收尾。
|
|
1392
1462
|
// 召唤 = 往 profile patch 插入一条独立 @deepseek-ai/dsh-agent-preset 行(persona.prefix
|
|
1393
1463
|
// = 专家人设),DSH 热加载后新会话即可在预设选择器里选它——多专家共存,卸载即摘除。
|
|
1464
|
+
// ── 会话级专家人设(随时挂 / 换 / 摘,不受 agent-preset/locked 限制)──────
|
|
1465
|
+
// 机制与边界见 lib/session-persona.js 顶部注释:走 systemPrompt 的会话作用域
|
|
1466
|
+
// section,遮蔽该会话的部署人设前缀;不动工具目录、不写任何持久化配置。
|
|
1467
|
+
async expertAttach(payload) {
|
|
1468
|
+
const sessionId = String(payload?.sessionId || "").trim();
|
|
1469
|
+
const plugin = String(payload?.plugin || "").trim();
|
|
1470
|
+
if (!sessionId) throw new Error("缺少 sessionId");
|
|
1471
|
+
if (!plugin) throw new Error("缺少 plugin");
|
|
1472
|
+
const detail = await wbExpertDetail({ plugin });
|
|
1473
|
+
const result = attachPersona(this.ctx, sessionId, {
|
|
1474
|
+
plugin,
|
|
1475
|
+
name: detail.name,
|
|
1476
|
+
personaText: detail.persona,
|
|
1477
|
+
});
|
|
1478
|
+
await recordExpertRecent("expert", { plugin, name: detail.name, profession: detail.profession, presetId: `session-${plugin}` }).catch(() => {});
|
|
1479
|
+
return { ...result, profession: detail.profession };
|
|
1480
|
+
}
|
|
1481
|
+
async expertDetach(payload) {
|
|
1482
|
+
const sessionId = String(payload?.sessionId || "").trim();
|
|
1483
|
+
if (!sessionId) throw new Error("缺少 sessionId");
|
|
1484
|
+
return detachPersona(this.ctx, sessionId);
|
|
1485
|
+
}
|
|
1486
|
+
async expertActive() {
|
|
1487
|
+
reconcilePersonas(this.ctx);
|
|
1488
|
+
return { sessions: listPersonas() };
|
|
1489
|
+
}
|
|
1394
1490
|
async wbExpertCatalog() {
|
|
1395
1491
|
return wbExpertCatalog();
|
|
1396
1492
|
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// 会话级专家人设注入 —— 让「专家」像 WorkBuddy 一样可随时挂/换/摘,不受 agent-preset/locked 限制。
|
|
2
|
+
//
|
|
3
|
+
// 为什么需要这条独立通道:宿主的 agentPresets.select() 有硬锁(会话一旦开过口就
|
|
4
|
+
// `agent-preset/locked`),因为**预设**决定工具目录与提示词结构,中途换会破坏前后一致。
|
|
5
|
+
// 但用户要的是 WorkBuddy 式体验:任何会话、任何时刻都能换专家或摘掉。可行的原因是
|
|
6
|
+
// 人设(persona)与预设是两件事——preset 的 persona 行只是一段提示词 section:
|
|
7
|
+
//
|
|
8
|
+
// dsh-persona 的注释写明该行是 **scope-only**:挂进某个 agent 预设时,它只遮蔽
|
|
9
|
+
// 「那一个会话」的部署人设。dsh-subagent 也用同一手法给子会话装人设:
|
|
10
|
+
// childCtx.systemPrompt.section({ name: "deployment:persona-prefix",
|
|
11
|
+
// order: childCtx.systemPrompt.getSectionOrder("DEPLOYMENT_PERSONA_PREFIX"),
|
|
12
|
+
// text: persona })
|
|
13
|
+
//
|
|
14
|
+
// 而 systemPrompt.section() 的归属 scope 取自定义层自身 ctx 的 kScope(见 dsh-scope
|
|
15
|
+
// 的 scopeOf / ScopedLayers.effect),cordis 的 tracker 代理会把服务方法的 this.ctx
|
|
16
|
+
// 重绑为**调用方 ctx** —— 所以用 `agent.ctx.systemPrompt.section(...)` 注册,段就落在
|
|
17
|
+
// 该会话自己的 scope 层:只影响这个会话,且 ctx.effect 返回的 disposer 可随时撤销。
|
|
18
|
+
//
|
|
19
|
+
// 与「召唤预设」的分工:召唤写 profile patch,管**新会话的预设选择器**(长期身份);
|
|
20
|
+
// 本模块管**当前会话的即时人设**(会话内挂/换/摘)。两者互不干扰:预设仍在,随时可用;
|
|
21
|
+
// 会话内挂的专家只是这段提示词 section 的替换,不动工具目录与任何持久化配置。
|
|
22
|
+
|
|
23
|
+
/** 与 dsh-persona / dsh-subagent 共用的 section 名(同名即遮蔽,最近 scope 胜出)。 */
|
|
24
|
+
const PERSONA_PREFIX_SECTION = "deployment:persona-prefix";
|
|
25
|
+
/** 单个会话最多保留的撤销记录(防泄漏;正常使用远小于此)。 */
|
|
26
|
+
const MAX_SESSIONS = 64;
|
|
27
|
+
/** sessionId -> { plugin, name, disposer } */
|
|
28
|
+
const activePersonas = new Map();
|
|
29
|
+
|
|
30
|
+
function agentCtxOf(ctx, sessionId) {
|
|
31
|
+
const agents = typeof ctx?.get === "function" ? ctx.get("agents") : undefined;
|
|
32
|
+
if (!agents || typeof agents.get !== "function") return { error: "宿主未提供 agents 服务(agentRegistry 不可用),无法定位会话" };
|
|
33
|
+
const agent = agents.get(sessionId);
|
|
34
|
+
if (agent === undefined) return { error: "会话不在运行中(可能已归档或尚未激活):" + sessionId };
|
|
35
|
+
const agentCtx = agent.ctx;
|
|
36
|
+
if (agentCtx === undefined) return { error: "该会话没有可用的作用域上下文" };
|
|
37
|
+
return { agentCtx };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 摘掉某会话当前注入的人设(若有)。
|
|
42
|
+
* @returns {boolean} 是否真的摘掉了一段。
|
|
43
|
+
*/
|
|
44
|
+
function clearPersona(sessionId) {
|
|
45
|
+
const current = activePersonas.get(sessionId);
|
|
46
|
+
if (!current) return false;
|
|
47
|
+
activePersonas.delete(sessionId);
|
|
48
|
+
try {
|
|
49
|
+
current.disposer?.();
|
|
50
|
+
} catch { /* 撤销失败不回滚状态:section 已随 scope 失效,重复撤销无害 */ }
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 当前挂在某会话上的专家(供客户端 chip 显示真实状态)。 */
|
|
55
|
+
export function personaOf(sessionId) {
|
|
56
|
+
const row = activePersonas.get(sessionId);
|
|
57
|
+
return row ? { plugin: row.plugin, name: row.name } : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 所有会话的挂载快照(会话被销毁后由 reconcile 清理)。 */
|
|
61
|
+
export function listPersonas() {
|
|
62
|
+
return [...activePersonas.entries()].map(([sessionId, row]) => ({ sessionId, plugin: row.plugin, name: row.name }));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 会话已不在运行 → 清掉它的撤销记录(disposer 早已随 scope 失效)。 */
|
|
66
|
+
export function reconcilePersonas(ctx) {
|
|
67
|
+
const agents = typeof ctx?.get === "function" ? ctx.get("agents") : undefined;
|
|
68
|
+
if (!agents || typeof agents.get !== "function") return;
|
|
69
|
+
const live = new Set(agents.list().map((agent) => agent.id));
|
|
70
|
+
for (const sessionId of [...activePersonas.keys()]) {
|
|
71
|
+
if (!live.has(sessionId)) activePersonas.delete(sessionId);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 给一个运行中的会话注入(或替换)专家人设。同一会话可反复调用:先摘旧的再装新的。
|
|
77
|
+
* @param {object} ctx - 插件宿主上下文。
|
|
78
|
+
* @param {string} sessionId - 目标会话。
|
|
79
|
+
* @param {object} spec - { plugin, name, personaText }。
|
|
80
|
+
* @returns {{ok: true, plugin: string, name: string, replaced: boolean}}
|
|
81
|
+
*/
|
|
82
|
+
export function attachPersona(ctx, sessionId, spec) {
|
|
83
|
+
const id = String(sessionId || "").trim();
|
|
84
|
+
if (!id) throw new Error("缺少会话 id");
|
|
85
|
+
const text = String(spec?.personaText || "").trim();
|
|
86
|
+
if (!text) throw new Error("人设内容为空,无法挂载");
|
|
87
|
+
const { agentCtx, error } = agentCtxOf(ctx, id);
|
|
88
|
+
if (error) throw new Error(error);
|
|
89
|
+
const prompt = agentCtx.systemPrompt;
|
|
90
|
+
if (!prompt || typeof prompt.section !== "function") {
|
|
91
|
+
throw new Error("该会话没有 systemPrompt 服务,无法注入人设");
|
|
92
|
+
}
|
|
93
|
+
const replaced = clearPersona(id);
|
|
94
|
+
// 与 dsh-persona 同款:同名 section + 同一处 order = 遮蔽该会话的部署人设前缀。
|
|
95
|
+
const disposer = prompt.section({
|
|
96
|
+
name: PERSONA_PREFIX_SECTION,
|
|
97
|
+
order: typeof prompt.getSectionOrder === "function"
|
|
98
|
+
? prompt.getSectionOrder("DEPLOYMENT_PERSONA_PREFIX")
|
|
99
|
+
: 0,
|
|
100
|
+
text,
|
|
101
|
+
});
|
|
102
|
+
activePersonas.set(id, {
|
|
103
|
+
plugin: String(spec?.plugin || ""),
|
|
104
|
+
name: String(spec?.name || spec?.plugin || "专家"),
|
|
105
|
+
disposer,
|
|
106
|
+
});
|
|
107
|
+
// 防泄漏:超出上限时丢掉最早的记录(其 disposer 一并撤销)。
|
|
108
|
+
while (activePersonas.size > MAX_SESSIONS) {
|
|
109
|
+
const oldest = activePersonas.keys().next().value;
|
|
110
|
+
clearPersona(oldest);
|
|
111
|
+
}
|
|
112
|
+
return { ok: true, plugin: String(spec?.plugin || ""), name: String(spec?.name || spec?.plugin || "专家"), replaced };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 摘掉某会话的专家人设。@returns {{ok: true, removed: boolean}} */
|
|
116
|
+
export function detachPersona(ctx, sessionId) {
|
|
117
|
+
const id = String(sessionId || "").trim();
|
|
118
|
+
if (!id) throw new Error("缺少会话 id");
|
|
119
|
+
return { ok: true, removed: clearPersona(id) };
|
|
120
|
+
}
|
package/lib/typert.js
CHANGED
|
@@ -71,6 +71,9 @@ export const TYPERT = {
|
|
|
71
71
|
mv('reconnectWorkspaceServer', [payloadParam], 'NoteResult'),
|
|
72
72
|
mv('updateHint', [], 'UpdateHintResult'),
|
|
73
73
|
mv('whatsNew', [], 'WhatsNewResult'),
|
|
74
|
+
// 安装形态(spec / source / profileDir):客户端「一键升级」的安全判据。
|
|
75
|
+
// 缺这一行 = 客户端 call('installInfo') 恒 404 → 按钮退回链接形态(同 toolTailor 的坑)。
|
|
76
|
+
mv('installInfo', [], 'InstallInfoResult'),
|
|
74
77
|
// Skill 看板(与 MCP 同一命名空间、同一信封:payload 进、{ok} 出)。
|
|
75
78
|
mv('listSkills', [payloadParam], 'SkillListResult'),
|
|
76
79
|
mv('skillDetail', [payloadParam], 'SkillDetailResult'),
|
|
@@ -98,6 +101,11 @@ export const TYPERT = {
|
|
|
98
101
|
mv('wbExpertInstalled', [], 'WbExpertInstalledResult'),
|
|
99
102
|
mv('wbExpertRecent', [], 'WbExpertRecentResult'),
|
|
100
103
|
mv('wbExpertRecordUse', [payloadParam], 'NoteResult'),
|
|
104
|
+
// 会话级专家人设(随时挂/换/摘):走 systemPrompt 的会话作用域 section,
|
|
105
|
+
// 不受 agent-preset/locked 限制。payload 交给 lib/session-persona.js 校验。
|
|
106
|
+
mv('expertAttach', [payloadParam], 'NoteResult'),
|
|
107
|
+
mv('expertDetach', [payloadParam], 'NoteResult'),
|
|
108
|
+
mv('expertActive', [], 'ExpertActiveResult'),
|
|
101
109
|
],
|
|
102
110
|
model: { services: [], events: [], objects: [] },
|
|
103
111
|
}
|
package/lib/whats-new.js
CHANGED
|
@@ -8,9 +8,10 @@
|
|
|
8
8
|
// **顺序就是语义**:客户端按数组顺序算「上次看到的版本之后还有哪些」,所以只能从旧到新
|
|
9
9
|
// 追加,不要按版本号大小或字母重排。
|
|
10
10
|
//
|
|
11
|
-
// 维护约定(由 test/
|
|
12
|
-
//
|
|
13
|
-
//
|
|
11
|
+
// 维护约定(由 test/upgrade-plan.mjs 第 8 组断言钉住):`package.json` 的版本必须是这里的
|
|
12
|
+
// 最后一条,且 `.github/release-notes/v<版本>.md` 必须存在。也就是说——bump 了版本却没写
|
|
13
|
+
// 更新内容、或没写 Release 稿,本地 `npm test` 与 CI 都会红。这正是「新功能没人发现」的
|
|
14
|
+
// 根治点:发布前不写清楚「变了什么、在哪找」,就发不出去。
|
|
14
15
|
//
|
|
15
16
|
// 条目形状:`{ version, items: [{ text, where? }] }`。`where` 用来回答「在哪儿能找到它」,
|
|
16
17
|
// 是这次加这个提示的直接原因(新开关藏在面板头部,不主动指路等于没加)。
|
|
@@ -207,4 +208,38 @@ export const WHATS_NEW = [
|
|
|
207
208
|
},
|
|
208
209
|
],
|
|
209
210
|
},
|
|
211
|
+
{
|
|
212
|
+
version: '1.9.1',
|
|
213
|
+
items: [
|
|
214
|
+
{
|
|
215
|
+
text: '修复「挂载失败:Unknown agent preset」:召唤专家写的预设行以前是写在补丁顶层的裸条目,宿主按「覆盖已有行」解释后整条跳过(预设永远不注册);现在按宿主规范写成 insert 指令,并自动把旧格式的行迁移过来——升级后首次打开面板/重新召唤即修复,已有会话的正常使用',
|
|
216
|
+
where: '会话输入框召唤菜单 → 专家',
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
text: '会话已开始也能用专家:宿主规则不允许中途换预设,改为提供「带「专家」新建会话」一键路径(自动建新会话 → 挂上专家 → 跳过去),不再是死路一条',
|
|
220
|
+
where: '召唤菜单 → 专家 → 提示条下方的按钮',
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
text: '召唤菜单两处体验修复:鼠标移过「技能」不再把菜单关掉(开合判定收归整个菜单,提示区不再误判为「离开」);提示条让菜单变高时页面不再抖动(菜单改为视口浮层,不再撑动会话滚动区)',
|
|
224
|
+
where: '会话输入框工具行右侧的看板小图标',
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
text: '一键升级更安全:以前它靠宿主返回的一个并不存在的字段判断「是不是 npm 装的」,本地 file:/link: 安装的用户一点升级就会把开发副本换成 npm 包;现在改问宿主读 profile 里的真实安装来源,本地安装与来源不明一律只提示、不写盘',
|
|
228
|
+
where: '面板顶部的更新提示条',
|
|
229
|
+
},
|
|
230
|
+
],
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
version: '1.9.2',
|
|
234
|
+
items: [
|
|
235
|
+
{
|
|
236
|
+
text: '专家升级为 WorkBuddy 式「随时挂 / 换 / 摘」:任何会话(包括已经聊了一半的)都能挂专家、直接点另一个专家就换掉、点 chip 的 × 立刻取消——不再有「会话已开始,无法中途挂专家」的死路,也不再是一个对话只能引用一次',
|
|
237
|
+
where: '会话输入框工具行右侧的看板小图标 → 专家',
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
text: '换专家会明确告诉你「已替换原专家」,取消后点别的专家又能重新挂上;组件重挂或切换会话时自动向宿主对账,chip 显示的始终是真实状态(专家也能由预设选择器等其他入口挂上)',
|
|
241
|
+
where: '专家 chip 与召唤菜单内的提示条',
|
|
242
|
+
},
|
|
243
|
+
],
|
|
244
|
+
},
|
|
210
245
|
]
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-armory",
|
|
3
|
-
"version": "1.9.
|
|
4
|
-
"description": "Unified Skill + MCP + Expert board for DeepSeek Harness Web
|
|
3
|
+
"version": "1.9.2",
|
|
4
|
+
"description": "Unified Skill + MCP + Expert board for DeepSeek Harness (desktop app and Web GUI) 鈥?global panel page, JSON import, WorkBuddy connector & SkillHub & Expert market installs, tool-usage tailor, and profile-backed Host Remote management.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/2026heshao/dsh-armory.git"
|
|
@@ -13,7 +13,12 @@
|
|
|
13
13
|
"type": "module",
|
|
14
14
|
"main": "lib/index.js",
|
|
15
15
|
"scripts": {
|
|
16
|
-
"test": "node
|
|
16
|
+
"test": "node ./test/run-all.mjs",
|
|
17
|
+
"test:expert": "node ./test/run-expert-manual.mjs",
|
|
18
|
+
"test:upgrade": "node ./test/upgrade-plan.mjs",
|
|
19
|
+
"test:install": "node ./test/verify-install-info.mjs",
|
|
20
|
+
"test:e2e": "node ./test/run-e2e.mjs",
|
|
21
|
+
"migrate": "node ./test/run.mjs migrate"
|
|
17
22
|
},
|
|
18
23
|
"exports": {
|
|
19
24
|
"./typert": "./lib/typert.js",
|