dsh-erpbot 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -29
- package/bot-identity.js +246 -0
- package/client.js +2438 -627
- package/cordis.patch.yml +128 -0
- package/docs/README.md +39 -0
- package/docs/acceptance.md +136 -0
- package/docs/architecture.md +161 -0
- package/docs/bot-agents-spec.md +205 -0
- package/docs/bot-collaboration-spec.md +161 -0
- package/docs/data-contract.md +262 -0
- package/docs/mcp-inventory-analysis.md +58 -0
- package/docs/requirements.md +234 -0
- package/index.js +270 -15
- package/package.json +6 -2
- package/scripts/check-bot-identity.mjs +71 -0
- package/scripts/lint-factories.mjs +315 -0
- package/scripts/lint-scopes.mjs +179 -0
- package/scripts/scaffold-bots.mjs +76 -0
- package/scripts/test-manifest.mjs +72 -0
- package/scripts/verify.mjs +159 -0
- package/spec/README.md +299 -0
- package/spec/base-content-spec.md +129 -0
- package/spec/cai-content-spec.md +257 -0
- package/spec/shui-content-spec.md +252 -0
- package/spec/spec-CORE.md +155 -0
- package/spec/tui-content-spec.md +259 -0
- package/spec/zi-content-spec.md +256 -0
- package/src/assistant/index.js +389 -4
- package/src/bot-data.js +25 -13
- package/src/bundle.json +1 -0
- package/src/cards/index.js +18 -4
- package/src/dashboard/index.js +65 -25
- package/src/entry.js +7 -7
- package/src/i18n/index.js +80 -60
- package/src/mount.js +19 -2
- package/src/nav.js +7 -5
- package/src/pages-data.js +29 -262
- package/src/shell/index.js +57 -33
- package/src/styles.js +396 -78
- package/src/workbench/index.js +1297 -85
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# MCP 清单方向分析(基座 · MCP 与数据)
|
|
2
|
+
|
|
3
|
+
> 状态:v1(2026-11-13)— 面向「基座要看到连接了哪些 MCP、每个 MCP 有哪些接口和内容」这一方向的事实核验与方案选型。
|
|
4
|
+
> 依据:DSH 安装版 `@deepseek-ai/dsh-mcp-client` / `dsh-mcp-manager` / `dsh-mcp-resources` / `dsh-api-remotes` 源码与 README,以及 `dsh-web-app/cordis.patch.yml` 的 mcp-manager 挂载事实。
|
|
5
|
+
|
|
6
|
+
## 0. 一句话结论
|
|
7
|
+
|
|
8
|
+
**连接了哪些 MCP + 连接状态 + 每个 MCP 有哪些 tools** 三件事 DSH 都已提供权威 API(`ctx.mcpManager`),基座只需做「Host 路由桥接 → 客户端 fetch 渲染」,不写任何 MCP 协议代码。**「每个 MCP 有哪些内容(resources)」是真实缺口**:DSH 把 resources 设计成模型按需读(`list_mcp_resources` 工具),manager/probe 都不列资源目录,且 probe 属于 DSH 内部包不可改;这部分要么接受「只列 tools、资源走工具按需读」,要么 dsh-erpbot 自起 MCP client(引入 `@modelcontextprotocol/client`,风险高)。
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## 1. 三个子需求逐一核验
|
|
13
|
+
|
|
14
|
+
| 基座要什么 | DSH 权威 API | 现状 |
|
|
15
|
+
|---|---|---|
|
|
16
|
+
| 连接了哪些 MCP + 连接状态 | `ctx.mcpManager.list()` → `[{name, configured, status}]` | ✅ 现成;`connected` 态含 `toolCount` / `connectedAt` |
|
|
17
|
+
| 每个 MCP 有哪些接口(tools) | `ctx.mcpManager.probe(name, opts)` → `toolNames[]`(连接+列工具后立即关闭,不注册工具) | ✅ 现成;但要主动 probe 拿快照 |
|
|
18
|
+
| 每个 MCP 有哪些内容(resources) | 无 Host 侧 API | ❌ 缺口;probe 只做 `tools/list`,不做 `resources/list` |
|
|
19
|
+
|
|
20
|
+
## 2. 关键 API 事实(读源码核实)
|
|
21
|
+
|
|
22
|
+
- `dsh-mcp-manager` 已随 `dsh-web-app` 挂载为 `mcp-manager` 行(web 与 desktop profile 都有),注入 `credentials`。基座**不需要**自己装 manager,直接注入 `mcpManager` 即可。
|
|
23
|
+
- `ctx.mcpManager.list()` 返回 `McpManagedServerView[]`:`{ name, configured, status }`。`status` 是 `McpConnectionStatus` 扩展,含 `connected{state,toolCount,connectedAt}`、`reconnecting{attempt,maxAttempts,nextRetryAtMs,lastError}`、`failed{reason}`、`disabled`、`waiting-credentials` 等。
|
|
24
|
+
- `ctx.mcpManager.probe(name, {maxPages, maxToolNames})` 是**一次性探测**:连接→读 `serverInfo{name,version}`→列 `toolNames[]`→关闭,不注册工具、不占 serverName 命名空间。默认 16 页 / 512 个工具名上限。这是拿「接口清单」的权威入口。
|
|
25
|
+
- `restart(name)`、`watch(signal)` 也在 manager 上:`watch` 首次全量 + 每次状态/条目变更推送,浏览器侧经 `ctx.remote.mcpManager` 可达。
|
|
26
|
+
- `dsh-mcp-resources` 提供三个共享工具 `list_mcp_resources` / `list_mcp_resource_templates` / `read_mcp_resource`,**给模型按需读**,不是给管理面列目录。resources 不在 manager/probe 的返回里。
|
|
27
|
+
- 浏览器侧拿清单有两条路:`ctx.remote.mcpManager`(`dsh-api-remotes` 已挂载,含 `mcpManager` remote),或像现有 erpbot 一样走自建 Host 路由 `hostJson()`。
|
|
28
|
+
|
|
29
|
+
## 3. 三个备选方案(含取舍)
|
|
30
|
+
|
|
31
|
+
| 方案 | 数据通道 | 改动量 | 实时性 | 风险 |
|
|
32
|
+
|---|---|---|---|---|
|
|
33
|
+
| **A(推荐)** Host 路由桥接 `/erp/mcp/*`,客户端用现有 `hostJson()` fetch | 小:index.js 加路由(注入 `mcpManager`),workbench 加渲染 | 中 | 中等(手动刷新/轮询) | 低:沿用现有 fenced 路由 + hostJson 模式 |
|
|
34
|
+
| B 浏览器直连 `ctx.remote.mcpManager`(`watch` 实时推送) | 大:改 client 的 `mount`/inject 接线 + 订阅 stream | 高 | 高(实时) | 高:remote 面没在 erpbot 现有接线里,改动面大、类型依赖 `dsh-api-remotes` |
|
|
35
|
+
| C 只读 `list()` 显示 toolCount,不展开接口名 | 最小:只加一个路由返回 list | 最低 | — | 最低:但不满足「有哪些接口」 |
|
|
36
|
+
|
|
37
|
+
**推荐 A + C 组合落地**:先用 C 把「连接了哪些 MCP + 状态 + 工具数」做实,再用 A 的 probe 在用户点开某个通道时按需拉接口清单(`toolNames[]`),避免每次进底座都 probe 所有 server(probe 有 10s 预算 + 每 server 一次连接开销)。
|
|
38
|
+
|
|
39
|
+
## 4. resources 缺口的两个出路
|
|
40
|
+
|
|
41
|
+
1. **接受「只列 tools,资源按需读」**(推荐先做):底座清单列「server → 状态 → toolCount → 工具名列表」,资源能力用一行说明「有 resources 能力,用 list_mcp_resources 读」,不预列资源目录。理由:资源目录可能很大、且 DSH 的设计意图就是按需读,预列会引入大量一次性 token 与连接开销。
|
|
42
|
+
2. **自起 MCP client 读 `resources/list`**(后做,需评估):dsh-erpbot 在 Host 半注入 `mcpManager`,但读 resources 要直接 import `@modelcontextprotocol/client`(probe.ts 就是这么干的,但那是 DSH 内部包,erpbot 独立包依赖树里没有)。要额外声明依赖,且要自己处理分页、凭证模板、关闭清理——风险高、收益只在「预列资源目录」这一件事上。
|
|
43
|
+
|
|
44
|
+
## 5. 结论与建议落地顺序
|
|
45
|
+
|
|
46
|
+
1. 先做 C:`GET /erp/mcp/list` → 复用 `ctx.mcpManager.list()`,底座「MCP 通道与服务」项从静态 payload 换成实时列表(name / configured / status / toolCount)。
|
|
47
|
+
2. 再做 A:`GET /erp/mcp/probe?name=<id>` → `ctx.mcpManager.probe(name)`,用户点开某通道时拉接口清单 `toolNames[]`,渲染成通道展开视图。
|
|
48
|
+
3. resources 先不预列,保持 DSH 的「按需读」语义;若用户明确要「预列资源目录」,再评估自起 client 的成本。
|
|
49
|
+
|
|
50
|
+
## 6. 与现有 `/erp/mcp/status` 路由的关系
|
|
51
|
+
|
|
52
|
+
上一轮已加的 `GET /erp/mcp/status` 目前返回**静态** `MCP_CHANNELS` 数组。本方向落地后应把它替换为「`ctx.mcpManager.list()` 的真实快照」,删除静态数组,让底座 MCP 状态点真正反映 DSH 已连接的 MCP。
|
|
53
|
+
|
|
54
|
+
## 7. 变更记录
|
|
55
|
+
|
|
56
|
+
| 日期 | 变更 |
|
|
57
|
+
|---|---|
|
|
58
|
+
| 2026-11-13 | 初版,核验 DSH MCP 三件套 API 并给出方案选型 |
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# dsh-erpbot 需求规格(PRD)
|
|
2
|
+
|
|
3
|
+
> 状态:v1(2026-11-12)— 基于当前已实现功能架构逆向整理的权威需求。
|
|
4
|
+
> 适用范围:`dsh-erpbot` 插件(Aurora 风格财务 / ERP 面板)。
|
|
5
|
+
> 用途:评估现有实现是否达标,以及未来开发的唯一需求基线。
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 1. 目标与定位
|
|
10
|
+
|
|
11
|
+
### 1.1 一句话
|
|
12
|
+
|
|
13
|
+
**Erp = Harness + ERP,做的是 AI + ERP 的财务智能化自动化**,不是一套 ERP,也不是一张财务看板。它以 DSH 为入口,让 AI 去连数据(MCP 通道)、算口径(数据蒸馏 + 指标)、跑技能(自动对账 / 关账 / 勾选 / 申报 / 预测)、留审计(回执 + 审计链 + 幂等),人只做确认与例外。
|
|
14
|
+
|
|
15
|
+
### 1.2 目标(Goals)
|
|
16
|
+
|
|
17
|
+
- **G1 AI 自动化执行**:财务核心作业(凭证识别、银行对账、进项勾选、纳税申报、关账、现金流预测、催收、资金日报)由 AI 技能 / 场景自动完成,人只复核与处理例外。
|
|
18
|
+
- **G2 结果可信呈现**:AI 干完活的产物以「KPI 卡 + 工作台」两级结构呈现,第一屏看大数、点开看明细;每个数可回口径、回时点、回数据集、回审计回执。
|
|
19
|
+
- **G3 内嵌助手(人机协作)**:在不跳转工作区的前提下,人问「为什么 / 怎么办」,AI 去查数、执行、回执;弹窗 / 停靠 / 折叠。
|
|
20
|
+
- **G4 双语**:所有用户可见文案支持中文 / English,切换即时生效且不丢失页面状态。
|
|
21
|
+
- **G5 自适应**:面板尺寸由 DSH 分配,布局与图表必须跟随槽位盒子(非窗口)分级重排、降级而不溢出。
|
|
22
|
+
- **G6 可维护**:数据与渲染分离,加页面 / 加渲染器是「改数据 + 改少量渲染」而非「重写」。
|
|
23
|
+
|
|
24
|
+
### 1.3 非目标(Non-goals,明确不做)
|
|
25
|
+
|
|
26
|
+
- 不做一套 ERP:不手工记账、不替代 ERP 原始记账;对 ERP 的写入走 MCP 受控写通道(`direction=controlled_write`),不直连、不裸写。
|
|
27
|
+
- 不做静态报表:各页数字是「AI 执行结果的呈现」,不是给人逐格填的账表;不做纯展示层的看板。
|
|
28
|
+
- 不做第三种语言:只做 `中文 / EN` 两种。
|
|
29
|
+
- 不做多套视觉主题:只保留 Aurora 一种风格(跟随 DSH 明暗,不做本地主题切换器)。
|
|
30
|
+
- 不做全局快捷键、不做左栏新增 AI 图标(AI 入口只来自总览页卡片与右上角)。
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## 2. 范围(Scope)
|
|
35
|
+
|
|
36
|
+
### 2.1 页面体系
|
|
37
|
+
|
|
38
|
+
| 页面 id | 名称 | 定位 |
|
|
39
|
+
|---|---|---|
|
|
40
|
+
| `overview` | 总览 | 全局 KPI + 核心块轮播 + 收支趋势 + 支出构成 + 账龄 + 最近动态 |
|
|
41
|
+
| `cai` | 财 · 过去 | 凭证、账簿、期末关账、报表、核对底稿 |
|
|
42
|
+
| `zi` | 资 · 现在 | 账户余额、收付待办、对账未达、资金计划、票据授信 |
|
|
43
|
+
| `tui` | 推 · 未来 | 预算编制、滚动预测、情景模拟、假设版本、成本分摊、管报 |
|
|
44
|
+
| `shui` | 税 · 税务 | 销项 / 进项发票、纳税申报、报送缴款、税负风险 |
|
|
45
|
+
| `base` | 底座 · MCP 与数据 | MCP 通道与服务、技能与场景、数据源、蒸馏批次、主数据映射、指标与口径、质量与对账、回执与审计(单页,页内无 tab) |
|
|
46
|
+
|
|
47
|
+
页面状态写进 URL hash(`#/erp/<id>`),刷新后停在原页;`#/erp/shu`、`#/erp/guan` 是落到 `base` 的深链别名。
|
|
48
|
+
|
|
49
|
+
### 2.2 功能模块
|
|
50
|
+
|
|
51
|
+
1. 导航(左栏 rail + 页面路由)
|
|
52
|
+
2. 概览页(卡片矩阵 + 图表)
|
|
53
|
+
3. 工作台页(四 Bot + 底座:KPI 卡 + 段工作台)
|
|
54
|
+
4. 双语(i18n)
|
|
55
|
+
5. 主题(明暗跟随 DSH)
|
|
56
|
+
6. 内嵌 AI 助手
|
|
57
|
+
7. 响应式布局(三层容器查询)
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## 3. 功能需求(FR)
|
|
62
|
+
|
|
63
|
+
> 每条 FR 形如 `FR-<模块>-<序号>`,用于验收与回归追踪。标注 `[已实现]` / `[待实现]` 表示当前状态。
|
|
64
|
+
|
|
65
|
+
### 3.1 导航(FR-NAV)
|
|
66
|
+
|
|
67
|
+
| 编号 | 需求 | 状态 |
|
|
68
|
+
|---|---|---|
|
|
69
|
+
| FR-NAV-1 | 左侧导航图标列表顶部(`plugins` 之上)显示 Erp 图标 | 已实现 |
|
|
70
|
+
| FR-NAV-2 | 点击图标切换到 Erp 主面板,再次点击/切走可回到会话 | 已实现 |
|
|
71
|
+
| FR-NAV-3 | 左栏包含 6 个页面条目(总览 + 四 Bot + 底座),当前页高亮 | 已实现 |
|
|
72
|
+
| FR-NAV-4 | 页面 id 写进 `#/erp/<id>`,刷新保持原页 | 已实现 |
|
|
73
|
+
| FR-NAV-5 | 页面 id 必须全小写字母(`readHashPage` 用 `([a-z]+)` 解析) | 已实现 |
|
|
74
|
+
| FR-NAV-6 | 左栏底部固定「设置 + 语言」组,矮面板不随图标滚动挤走 | 已实现 |
|
|
75
|
+
|
|
76
|
+
### 3.2 概览页(FR-OV)
|
|
77
|
+
|
|
78
|
+
| 编号 | 需求 | 状态 |
|
|
79
|
+
|---|---|---|
|
|
80
|
+
| FR-OV-1 | 顶部 4 张 KPI 统计卡(含迷你折线 + 环比) | 已实现 |
|
|
81
|
+
| FR-OV-2 | 核心块轮播(账 / 资 / 推 / 税),左右箭头 + 圆点 + 自动轮播 + 悬停暂停 | 已实现 |
|
|
82
|
+
| FR-OV-3 | 收支趋势图(SVG,含 tooltip、峰值高亮) | 已实现 |
|
|
83
|
+
| FR-OV-4 | 支出构成环形图(含图例 + 增长徽标) | 已实现 |
|
|
84
|
+
| FR-OV-5 | 应收账款账龄卡(分段占比条) | 已实现 |
|
|
85
|
+
| FR-OV-6 | 最近动态时间线 | 已实现 |
|
|
86
|
+
| FR-OV-7 | AI 助手卡(入口 + 状态点 + 建议 chip) | 已实现 |
|
|
87
|
+
|
|
88
|
+
### 3.3 KPI 卡(FR-KPI)
|
|
89
|
+
|
|
90
|
+
| 编号 | 需求 | 状态 |
|
|
91
|
+
|---|---|---|
|
|
92
|
+
| FR-KPI-1 | 每张卡只保留三件事:主数字(带单位)、一行口径(cardSub)、一个微型图 | 已实现 |
|
|
93
|
+
| FR-KPI-2 | 支持 7 种变体:`value` `split` `stack` `gauge` `countdown` `status` `delta` | 已实现 |
|
|
94
|
+
| FR-KPI-3 | 点开卡片进入 `MetricDetailModal`,展示精确金额 + 完整口径 + 构成明细 + 统计口径 + 更新时点 | 已实现 |
|
|
95
|
+
| FR-KPI-4 | 金额单位在渲染层换算(精确元 → 万/亿/K/M/B),payload 永远存精确元 | 已实现 |
|
|
96
|
+
| FR-KPI-5 | 构成明细占比可显式给(`row.pct`)或按金额自动推算 | 已实现 |
|
|
97
|
+
|
|
98
|
+
### 3.4 工作台渲染器(FR-WB)
|
|
99
|
+
|
|
100
|
+
| 编号 | 需求 | 状态 |
|
|
101
|
+
|---|---|---|
|
|
102
|
+
| FR-WB-1 | 支持 12 种渲染器:`list` `table` `flow` `cards` `steps` `checks` `bars` `chart` `donut` `timeline` `scenarios` `empty` | 已实现 |
|
|
103
|
+
| FR-WB-2 | 左栏条目可带 `pill`(数量徽标),右栏顶部可带 1–3 个 `summary` 摘要块 | 已实现 |
|
|
104
|
+
| FR-WB-3 | `chart` / `donut` / `bars` 支持「图表 / 清单」双视图(`alt` 字段) | 已实现 |
|
|
105
|
+
| FR-WB-4 | 切段时自动回到图表视图 | 已实现 |
|
|
106
|
+
| FR-WB-5 | 右栏底部可带 `foot` 口径落款 | 已实现 |
|
|
107
|
+
|
|
108
|
+
### 3.5 双语(FR-I18N)
|
|
109
|
+
|
|
110
|
+
| 编号 | 需求 | 状态 |
|
|
111
|
+
|---|---|---|
|
|
112
|
+
| FR-I18N-1 | 支持 `中文` / `EN`,默认中文 | 已实现 |
|
|
113
|
+
| FR-I18N-2 | 切换控件是左栏底部一颗圆按钮,只显示当前语言(`中` / `EN`),点击即切 | 已实现 |
|
|
114
|
+
| FR-I18N-3 | 语言是插件内自持状态,不跟随、不改写宿主界面语言 | 已实现 |
|
|
115
|
+
| FR-I18N-4 | 所有用户可见文案走 `t(key)` 或 `L({zh,en})`,禁止组件内裸写 | 已实现 |
|
|
116
|
+
| FR-I18N-5 | `zh` / `en` 字典 key 集合必须成对一致 | 已实现 |
|
|
117
|
+
|
|
118
|
+
### 3.6 主题(FR-THEME)
|
|
119
|
+
|
|
120
|
+
| 编号 | 需求 | 状态 |
|
|
121
|
+
|---|---|---|
|
|
122
|
+
| FR-THEME-1 | 跟随 DSH 明暗外观(`theme/change`),不做本地主题切换器 | 已实现 |
|
|
123
|
+
| FR-THEME-2 | 明暗两套主题 token 内置于 CSS,切换后满足 WCAG AA 对比度 | 已实现 |
|
|
124
|
+
| FR-THEME-3 | 样式全部 `.erp-app` 前缀作用域化,不触碰宿主 UI | 已实现 |
|
|
125
|
+
|
|
126
|
+
### 3.7 内嵌 AI 助手(FR-AI)
|
|
127
|
+
|
|
128
|
+
> 详细交互规格(状态机、弹窗 / 停靠 / 窄条规格、会话管理、降级)见本文 [附录 A](#附录-a-ai-助手交互规格)。
|
|
129
|
+
|
|
130
|
+
| 编号 | 需求 | 状态 |
|
|
131
|
+
|---|---|---|
|
|
132
|
+
| FR-AI-1 | 入口:右上角 spark 图标 + 总览页 AI 卡 | 已实现 |
|
|
133
|
+
| FR-AI-2 | 弹窗内嵌官方会话(`conversation.content`,`variant:'embedded'`,固定 Chat),不跳转 | 已实现 |
|
|
134
|
+
| FR-AI-3 | 专属工作区 `Documents/deepseek-harness/erpbot-assistant`,会话自动创建并持久化 | 已实现 |
|
|
135
|
+
| FR-AI-4 | 四态机:`closed` / `modal` / `dock` / `rail`,`modal` 记住上一非弹窗状态 | 已实现 |
|
|
136
|
+
| FR-AI-5 | 停靠 / 折叠状态持久化,面板重开自动恢复 | 已实现 |
|
|
137
|
+
| FR-AI-6 | 会话列表抽屉 + 新建 + 切换 + 相对时间 + 状态点 | 已实现 |
|
|
138
|
+
| FR-AI-7 | 内嵌契约缺失时降级为「打开对话页」,不白屏 | 已实现 |
|
|
139
|
+
|
|
140
|
+
### 3.8 响应式(FR-RESP)
|
|
141
|
+
|
|
142
|
+
| 编号 | 需求 | 状态 |
|
|
143
|
+
|---|---|---|
|
|
144
|
+
| FR-RESP-1 | 面板尺寸跟随 DSH 分配的槽位盒子 | 已实现 |
|
|
145
|
+
| FR-RESP-2 | 宽度用容器查询 `@container erp` 分级(1180/900/720/560),不用 `@media` | 已实现 |
|
|
146
|
+
| FR-RESP-3 | 高度用容器查询 `@container board` 分级(820/680/560/460) | 已实现 |
|
|
147
|
+
| FR-RESP-4 | 支出卡 / 环形图 / 卡片自身是二级容器(`exp` / `donut` / `visa-wrap`) | 已实现 |
|
|
148
|
+
| FR-RESP-5 | 放不下的信息靠卡片内部降级消化,绝不把布局顶出去 | 已实现 |
|
|
149
|
+
| FR-RESP-6 | 图表有 `tight` 模式,矮盒仍绘图不空白 | 已实现 |
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## 4. 非功能需求(NFR)
|
|
154
|
+
|
|
155
|
+
| 编号 | 需求 | 度量 |
|
|
156
|
+
|---|---|---|
|
|
157
|
+
| NFR-1 | 无障碍:弹窗 `role=dialog`、焦点圈、Esc 关闭、卡片 `role=button` + Enter/Space | 键盘可全程操作 |
|
|
158
|
+
| NFR-2 | 对比度:明暗两套满足 WCAG AA | 主题切换后文字可读 |
|
|
159
|
+
| NFR-3 | 性能:图表动画在弹窗打开期间暂停,SVG 由 ResizeObserver 重绘 | 无布局抖动 |
|
|
160
|
+
| NFR-4 | 可靠性:任意内部服务缺失时降级不白屏(catch + 空态) | 无 uncaught 崩溃 |
|
|
161
|
+
| NFR-5 | 可维护性:加一页只需改 `NAV` + `ZH/EN` 各一条 + `PAGES` 一条 | 单人可完成 |
|
|
162
|
+
| NFR-6 | 构建确定性:`client.js` 由 `build.mjs` 纯拼接生成,可 sha256 自证 | 产物可复现 |
|
|
163
|
+
| NFR-7 | 语言完整性:英文无中文泄漏,key 成对 | 切换后无乱码/漏译 |
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## 5. 术语表
|
|
168
|
+
|
|
169
|
+
| 术语 | 含义 |
|
|
170
|
+
|---|---|
|
|
171
|
+
| KPI 卡 | 工作台顶排的单指标卡片,7 种变体按 `kpi.kind` 渲染 |
|
|
172
|
+
| 段工作台 | 工作台页下方「左栏清单 + 右侧主内容」的大组件;底座合并后为单页 8 项 |
|
|
173
|
+
| 渲染器 | 段工作台右侧按 `section.kind` 分发的 12 种内容视图 |
|
|
174
|
+
| 口径(cardSub / sub) | 指标的计算定义;cardSub 是卡上一行短口径,sub 是弹窗完整口径 |
|
|
175
|
+
| 构成明细(breakdown) | 弹窗内按行展示的拆分(含占比条) |
|
|
176
|
+
| 主题桥 | 把 DSH 明暗外观同步到 Erp 面板的机制 |
|
|
177
|
+
| 专属工作区 | AI 助手会话落地的独立工作区目录 |
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
## 6. 演进路线(Roadmap)
|
|
182
|
+
|
|
183
|
+
> 现状基线为 v1;以下为未来评估/开发的方向,非本轮承诺。
|
|
184
|
+
|
|
185
|
+
1. **数据真实性**:当前 PAGES 是静态演示数据,未来接真实 ERP 数据源。
|
|
186
|
+
2. **AI 助手增强**:会话搜索、30+ 分页、归档恢复、多窗口草稿提示(原方案 §16/§17 未决项)。
|
|
187
|
+
3. **工作台交互**:段的增删改(当前只读)、表格排序 / 筛选。
|
|
188
|
+
4. **可配置化**:KPI 卡与渲染器的用户自定义(当前硬编码)。
|
|
189
|
+
5. **测试覆盖**:`check.cjs` / `validate-page.js` 工具链的落地(README 引用但未入库)。
|
|
190
|
+
|
|
191
|
+
---
|
|
192
|
+
|
|
193
|
+
## 7. 需求变更记录
|
|
194
|
+
|
|
195
|
+
| 日期 | 变更 | 来源 |
|
|
196
|
+
|---|---|---|
|
|
197
|
+
| 2026-11-12 | 初版,逆向整理当前实现为权威需求基线 | 用户请求 |
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## 附录 A:AI 助手交互规格
|
|
202
|
+
|
|
203
|
+
> 来源:原根仓库 `docs/erpbot-assistant-interaction.md`(标「待实现」,实际代码已实现)。
|
|
204
|
+
> 本附录为 AI 助手的权威交互需求,`FR-AI-*` 据此验收。
|
|
205
|
+
|
|
206
|
+
### A.1 状态模型
|
|
207
|
+
|
|
208
|
+
| 状态 | 说明 | 进入 | 退出 |
|
|
209
|
+
|---|---|---|---|
|
|
210
|
+
| `closed` | 只有总览 AI 卡作入口 | 关闭停靠 / 弹窗 | 点卡 / chip / 窄条 |
|
|
211
|
+
| `modal` | 居中弹窗,遮罩,覆盖面板 | 点 AI 卡 / chip / 窄条「打开弹窗」 | Esc / 点遮罩 / 关闭 / 固定到侧边 |
|
|
212
|
+
| `dock` | 右侧停靠列 | 弹窗内「固定到侧边」/ 窄条展开 | 折叠(→rail)/ 关闭(→closed) |
|
|
213
|
+
| `rail` | 停靠折叠后的右侧窄条 | dock 点折叠 | 点窄条展开回 dock |
|
|
214
|
+
|
|
215
|
+
规则:`modal` 打开时记住上一非弹窗状态,关闭后回到该状态;`dock`/`rail` 与「是否启用过助手」绑定;`presentation ∈ {closed,dock,rail}` 持久化,`modal` 不持久化。
|
|
216
|
+
|
|
217
|
+
### A.2 会话管理
|
|
218
|
+
|
|
219
|
+
- 专属工作区 `Documents/deepseek-harness/erpbot-assistant`,标题「Erp 助手」,首次使用自动创建并注册(Host 半路由 `POST /erp/workspace`)。
|
|
220
|
+
- 会话自动创建,id 持久化 `localStorage dsh-erpbot.assistant.session.v1`;停靠状态持久化 `dsh-erpbot.assistant.present.v1`。
|
|
221
|
+
- 弹窗头部:`[列表] Erp 助手 ·状态点 … [新建] [固定] [关闭]`。
|
|
222
|
+
- 会话列表抽屉:切换 / 新建 / 相对时间 / 状态点;空白会话只列当前一条;30+ 分页(未来)。
|
|
223
|
+
|
|
224
|
+
### A.3 降级
|
|
225
|
+
|
|
226
|
+
内嵌契约(`SessionProvider` / `renderFactorySlot`)缺失时,降级为「打开对话页」,不白屏。
|
|
227
|
+
|
|
228
|
+
### A.4 边界
|
|
229
|
+
|
|
230
|
+
| 场景 | 处理 |
|
|
231
|
+
|---|---|
|
|
232
|
+
| 专属工作区被删 | 下次打开自动重建并提示一次 |
|
|
233
|
+
| 当前会话被归档 | 下次打开新建一条 |
|
|
234
|
+
| 多窗口 | 各自维护当前会话 id,共享历史与草稿 |
|
package/index.js
CHANGED
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
* things the browser cannot do:
|
|
6
6
|
*
|
|
7
7
|
* 1. Per-bot workspace directories + registry entries
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* finance → D:\erpBot\finance (fallback: <Documents>/deepseek-harness/erpbot-finance)
|
|
9
|
+
* funds → D:\erpBot\funds
|
|
10
|
+
* forecast → D:\erpBot\forecast
|
|
11
|
+
* tax → D:\erpBot\tax
|
|
12
12
|
* 2. The bot config file and the bot identity memory
|
|
13
13
|
* <workspace>\bot.json added skills + timestamps
|
|
14
14
|
* <workspace>\AGENTS.md identity, scene prompt and the skill list that
|
|
@@ -29,21 +29,42 @@ import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
|
|
|
29
29
|
import { existsSync } from 'node:fs'
|
|
30
30
|
import { join } from 'node:path'
|
|
31
31
|
import { homedir } from 'node:os'
|
|
32
|
+
import {
|
|
33
|
+
AGENTS_SCHEMA,
|
|
34
|
+
BOT_IDENTITY,
|
|
35
|
+
BOT_TITLES,
|
|
36
|
+
buildAgentsDoc,
|
|
37
|
+
} from './bot-identity.js'
|
|
32
38
|
|
|
33
|
-
/** Host services: the web server, request fencing, and the
|
|
34
|
-
export const inject = ['webServer', 'connection', 'workspaceRegistry']
|
|
39
|
+
/** Host services: the web server, request fencing, the workspace registry, the MCP server manager, and the subagent seam (for pre-building the four bot teammates). */
|
|
40
|
+
export const inject = ['webServer', 'connection', 'workspaceRegistry', 'mcpManager', 'subagents']
|
|
35
41
|
|
|
36
42
|
/** Bot id → workspace display title. This whitelist is the whole API surface. */
|
|
37
|
-
const BOTS =
|
|
38
|
-
cai: 'erpBot · 财 Bot',
|
|
39
|
-
zi: 'erpBot · 资 Bot',
|
|
40
|
-
tui: 'erpBot · 推 Bot',
|
|
41
|
-
shui: 'erpBot · 税 Bot',
|
|
42
|
-
}
|
|
43
|
+
const BOTS = BOT_TITLES
|
|
43
44
|
|
|
44
45
|
/** Preferred root on the D: drive (one subfolder per bot). */
|
|
45
46
|
const D_ROOT = 'D:\\erpBot'
|
|
46
47
|
|
|
48
|
+
/** The team workspace: a shared checkout the four bots collaborate in, separate
|
|
49
|
+
* from the per-bot directories so team work never mixes with a single bot's
|
|
50
|
+
* session files. */
|
|
51
|
+
const TEAM_DIR = 'team'
|
|
52
|
+
|
|
53
|
+
/** Bot id → agent preset id + teammate name + initial prompt. The preset ids
|
|
54
|
+
* mirror the four `preset-erp-*` rows in cordis.patch.yml; the teammate names
|
|
55
|
+
* are what `send_message` / `list_agents` show.
|
|
56
|
+
*
|
|
57
|
+
* The prompt is ONLY a formation-time role announcement — it names who this
|
|
58
|
+
* teammate is, nothing about how to collaborate. How the lead and teammates
|
|
59
|
+
* divide, delegate, and relay work belongs to DSH's own team/subagent
|
|
60
|
+
* runtime, so a future DSH upgrade keeps working without touching this. */
|
|
61
|
+
const TEAM_BOTS = [
|
|
62
|
+
{ id: 'finance', preset: 'erp-finance', name: 'finance', prompt: '你是财务团队的一员,角色是「账房」,负责账务与报表。' },
|
|
63
|
+
{ id: 'funds', preset: 'erp-funds', name: 'funds', prompt: '你是财务团队的一员,角色是「司库」,负责资金与收付。' },
|
|
64
|
+
{ id: 'forecast', preset: 'erp-forecast', name: 'forecast', prompt: '你是财务团队的一员,角色是「军师」,负责预算与预测。' },
|
|
65
|
+
{ id: 'tax', preset: 'erp-tax', name: 'tax', prompt: '你是财务团队的一员,角色是「税官」,负责税务与申报。' },
|
|
66
|
+
]
|
|
67
|
+
|
|
47
68
|
/** Fallback root when D: is unavailable, beside DSH's default workspace. */
|
|
48
69
|
const FALLBACK_ROOT = join(homedir(), 'Documents', 'deepseek-harness')
|
|
49
70
|
|
|
@@ -268,6 +289,89 @@ async function ensureWorkspace(ctx, bot) {
|
|
|
268
289
|
}
|
|
269
290
|
}
|
|
270
291
|
|
|
292
|
+
/**
|
|
293
|
+
* Scaffold one bot's standard files when they are absent.
|
|
294
|
+
*
|
|
295
|
+
* A freshly mounted plugin has four empty bot directories: without this step
|
|
296
|
+
* the identity files only appear after someone opens that bot's page in the
|
|
297
|
+
* browser, so `D:\erpBot\<bot>` looks unconfigured until then. Missing files are
|
|
298
|
+
* created; an AGENTS.md that exists but carries no current schema marker is
|
|
299
|
+
* rewritten (the pre-v2 format had five sections instead of eight); a file that
|
|
300
|
+
* is present and current is left untouched so user edits survive.
|
|
301
|
+
*
|
|
302
|
+
* @param bot - whitelisted bot id.
|
|
303
|
+
* @returns `{ bot, directory, created: string[], migrated: boolean }`.
|
|
304
|
+
*/
|
|
305
|
+
async function scaffoldBotFiles(bot) {
|
|
306
|
+
const { directory } = await ensureDirectory(bot)
|
|
307
|
+
const configPath = join(directory, 'bot.json')
|
|
308
|
+
const agentsPath = join(directory, 'AGENTS.md')
|
|
309
|
+
const created = []
|
|
310
|
+
|
|
311
|
+
/* bot.json: skills list + timestamp. Read first so a rewritten AGENTS.md
|
|
312
|
+
reflects the skills the user already bound. */
|
|
313
|
+
let skills = []
|
|
314
|
+
let configExists = false
|
|
315
|
+
if (existsSync(configPath)) {
|
|
316
|
+
configExists = true
|
|
317
|
+
try {
|
|
318
|
+
const parsed = JSON.parse(await readFile(configPath, 'utf8'))
|
|
319
|
+
if (parsed !== null && typeof parsed === 'object' && Array.isArray(parsed.skills)) {
|
|
320
|
+
skills = parsed.skills.filter((name) => typeof name === 'string')
|
|
321
|
+
}
|
|
322
|
+
} catch (error) {
|
|
323
|
+
/* a corrupt config is rewritten from the empty default */
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (!configExists) {
|
|
327
|
+
const config = { bot, skills, updatedAt: new Date().toISOString() }
|
|
328
|
+
await writeFile(configPath, JSON.stringify(config, null, 2) + '\n', 'utf8')
|
|
329
|
+
created.push('bot.json')
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/* AGENTS.md: create when missing, rewrite when the schema is stale. */
|
|
333
|
+
let agents = null
|
|
334
|
+
if (existsSync(agentsPath)) {
|
|
335
|
+
try {
|
|
336
|
+
agents = await readFile(agentsPath, 'utf8')
|
|
337
|
+
} catch (error) {
|
|
338
|
+
agents = null
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
let migrated = false
|
|
342
|
+
if (agents === null) {
|
|
343
|
+
await writeFile(agentsPath, buildAgentsDoc(bot, skills, {}) + '\n', 'utf8')
|
|
344
|
+
created.push('AGENTS.md')
|
|
345
|
+
} else if (agents.indexOf(AGENTS_SCHEMA) < 0) {
|
|
346
|
+
await writeFile(agentsPath, buildAgentsDoc(bot, skills, {}) + '\n', 'utf8')
|
|
347
|
+
migrated = true
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return { bot, directory, created, migrated }
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Scaffold every bot's standard files. One bot's failure must not stop the
|
|
355
|
+
* others, so each is settled independently and reported.
|
|
356
|
+
* @param ctx - Host context, for logging.
|
|
357
|
+
* @returns `[{ bot, directory, created, migrated, error? }]`.
|
|
358
|
+
*/
|
|
359
|
+
async function scaffoldAllBots(ctx) {
|
|
360
|
+
const results = []
|
|
361
|
+
for (const bot of Object.keys(BOT_IDENTITY)) {
|
|
362
|
+
try {
|
|
363
|
+
results.push(await scaffoldBotFiles(bot))
|
|
364
|
+
} catch (error) {
|
|
365
|
+
const message = String((error && error.message) || error)
|
|
366
|
+
results.push({ bot, directory: '', created: [], migrated: false, error: message })
|
|
367
|
+
if (ctx !== undefined && ctx.logger !== undefined) {
|
|
368
|
+
ctx.logger.warn('erpBot: could not scaffold ' + bot + ' — ' + message)
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return results
|
|
373
|
+
}
|
|
374
|
+
|
|
271
375
|
/**
|
|
272
376
|
* Mount the Host half.
|
|
273
377
|
* @param ctx - Host context.
|
|
@@ -275,6 +379,66 @@ async function ensureWorkspace(ctx, bot) {
|
|
|
275
379
|
export function apply(ctx) {
|
|
276
380
|
const fenced = (req) => ctx.connection.requestRejection(req) === undefined
|
|
277
381
|
|
|
382
|
+
/* 0) Scaffold the four bot workspaces at mount: bot.json + AGENTS.md appear
|
|
383
|
+
as soon as the plugin loads, instead of only after the user opens that bot's
|
|
384
|
+
page. Idempotent — existing current files are kept. */
|
|
385
|
+
void scaffoldAllBots(ctx).then((results) => {
|
|
386
|
+
for (const result of results) {
|
|
387
|
+
if (result.created.length > 0) {
|
|
388
|
+
ctx.logger.info('erpBot: scaffolded ' + result.bot + ' → ' + result.created.join(', '))
|
|
389
|
+
}
|
|
390
|
+
if (result.migrated) {
|
|
391
|
+
ctx.logger.info('erpBot: migrated AGENTS.md to the current schema for ' + result.bot)
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}).catch((error) => {
|
|
395
|
+
ctx.logger.error('erpBot: workspace scaffolding failed')
|
|
396
|
+
ctx.logger.error(error)
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
/* 0b) Team formation: when the team root session goes live, pre-build the four
|
|
400
|
+
bot teammates through the standard subagent seam. erpbot's job ends once the
|
|
401
|
+
team is formed — `delivery: 'parent'` returns every child's result to the
|
|
402
|
+
lead (root), so division, delegation, and result relay all run inside DSH's
|
|
403
|
+
own team/subagent runtime, never through erpbot. */
|
|
404
|
+
const teamPath = join(D_ROOT, TEAM_DIR)
|
|
405
|
+
const formedRoots = new Set()
|
|
406
|
+
|
|
407
|
+
ctx.on('agent/created', async ({ agent, source }) => {
|
|
408
|
+
/* Only fresh (startup) ordinary roots in the team directory. */
|
|
409
|
+
const header = agent.session && agent.session.header ? agent.session.header : null
|
|
410
|
+
const cwd = header ? header.cwd : undefined
|
|
411
|
+
if (source !== 'startup') return
|
|
412
|
+
if (header !== null && header.origin === 'subagent') return
|
|
413
|
+
if (cwd !== teamPath) return
|
|
414
|
+
const sessionId = agent.id
|
|
415
|
+
if (formedRoots.has(sessionId)) return
|
|
416
|
+
formedRoots.add(sessionId)
|
|
417
|
+
|
|
418
|
+
ctx.logger.info('erpBot: team root live (' + String(sessionId) + '), pre-building ' + TEAM_BOTS.length + ' teammates')
|
|
419
|
+
for (const bot of TEAM_BOTS) {
|
|
420
|
+
try {
|
|
421
|
+
const activation = await ctx.subagents.startActivation({
|
|
422
|
+
provider: 'spawn',
|
|
423
|
+
parent: agent,
|
|
424
|
+
agentPreset: bot.preset,
|
|
425
|
+
name: bot.name,
|
|
426
|
+
label: bot.name,
|
|
427
|
+
/* Each bot works from its own directory so dsh-agent-instructions
|
|
428
|
+
loads ITS AGENTS.md — identity (roles/boundaries), bound skills,
|
|
429
|
+
and discipline all come from that file, not from a hardcoded tool
|
|
430
|
+
list in the preset. */
|
|
431
|
+
cwd: join(D_ROOT, bot.id),
|
|
432
|
+
delivery: 'parent',
|
|
433
|
+
prompt: [{ type: 'text', text: bot.prompt }],
|
|
434
|
+
})
|
|
435
|
+
ctx.logger.info('erpBot: teammate ' + bot.name + ' ready (' + String(activation.childId) + ')')
|
|
436
|
+
} catch (error) {
|
|
437
|
+
ctx.logger.warn('erpBot: failed to build teammate ' + bot.name + ' — ' + String((error && error.message) || error))
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
})
|
|
441
|
+
|
|
278
442
|
// 1) Workspace directory + registry entry.
|
|
279
443
|
ctx.effect(() => ctx.webServer.register({
|
|
280
444
|
kind: 'prefix',
|
|
@@ -297,7 +461,7 @@ export function apply(ctx) {
|
|
|
297
461
|
}
|
|
298
462
|
const bot = typeof body.bot === 'string' ? body.bot : ''
|
|
299
463
|
if (!Object.prototype.hasOwnProperty.call(BOTS, bot)) {
|
|
300
|
-
respond(res, 400, { ok: false, error: { code: 'invalid-bot', message: 'bot must be one of
|
|
464
|
+
respond(res, 400, { ok: false, error: { code: 'invalid-bot', message: 'bot must be one of finance, funds, forecast, tax' } })
|
|
301
465
|
return
|
|
302
466
|
}
|
|
303
467
|
try {
|
|
@@ -311,6 +475,40 @@ export function apply(ctx) {
|
|
|
311
475
|
},
|
|
312
476
|
}), 'erpBot: /erp/workspace route')
|
|
313
477
|
|
|
478
|
+
// 1b) Team workspace: a single shared checkout under D:\erpBot\team, separate
|
|
479
|
+
// from the four per-bot directories. The team's root session and every
|
|
480
|
+
// delegated bot child read/write this one directory.
|
|
481
|
+
ctx.effect(() => ctx.webServer.register({
|
|
482
|
+
kind: 'prefix',
|
|
483
|
+
path: '/erp/team/workspace',
|
|
484
|
+
handler: async (req, res) => {
|
|
485
|
+
if (!fenced(req)) {
|
|
486
|
+
respond(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } })
|
|
487
|
+
return
|
|
488
|
+
}
|
|
489
|
+
if (req.method !== 'POST') {
|
|
490
|
+
respond(res, 405, { ok: false, error: { code: 'method-error', message: 'method not allowed' } })
|
|
491
|
+
return
|
|
492
|
+
}
|
|
493
|
+
try {
|
|
494
|
+
const directory = join(D_ROOT, TEAM_DIR)
|
|
495
|
+
await mkdir(directory, { recursive: true })
|
|
496
|
+
const workspace = await ctx.workspaceRegistry.create(directory, 'erpBot · 团队协作')
|
|
497
|
+
respond(res, 200, {
|
|
498
|
+
ok: true,
|
|
499
|
+
workspaceId: workspace.id,
|
|
500
|
+
path: workspace.path ?? directory,
|
|
501
|
+
title: workspace.title ?? 'erpBot · 团队协作',
|
|
502
|
+
})
|
|
503
|
+
} catch (error) {
|
|
504
|
+
respond(res, 500, {
|
|
505
|
+
ok: false,
|
|
506
|
+
error: { code: 'team-workspace-failed', message: String((error && error.message) || error) },
|
|
507
|
+
})
|
|
508
|
+
}
|
|
509
|
+
},
|
|
510
|
+
}), 'erpBot: /erp/team/workspace route')
|
|
511
|
+
|
|
314
512
|
// 2) DSH skills available to add.
|
|
315
513
|
ctx.effect(() => ctx.webServer.register({
|
|
316
514
|
kind: 'prefix',
|
|
@@ -344,10 +542,14 @@ export function apply(ctx) {
|
|
|
344
542
|
if (req.method === 'GET') {
|
|
345
543
|
const bot = String((req.url || '').split('bot=')[1] || '').split('&')[0]
|
|
346
544
|
if (!Object.prototype.hasOwnProperty.call(BOTS, bot)) {
|
|
347
|
-
respond(res, 400, { ok: false, error: { code: 'invalid-bot', message: 'bot must be one of
|
|
545
|
+
respond(res, 400, { ok: false, error: { code: 'invalid-bot', message: 'bot must be one of finance, funds, forecast, tax' } })
|
|
348
546
|
return
|
|
349
547
|
}
|
|
350
548
|
try {
|
|
549
|
+
/* Read touches a workspace that may have been emptied or never
|
|
550
|
+
scaffolded (the mount-time pass is fire-and-forget); fill in any
|
|
551
|
+
missing standard file before serving the state. */
|
|
552
|
+
await scaffoldBotFiles(bot)
|
|
351
553
|
const state = await readBotConfig(bot)
|
|
352
554
|
respond(res, 200, { ok: true, ...state })
|
|
353
555
|
} catch (error) {
|
|
@@ -368,7 +570,7 @@ export function apply(ctx) {
|
|
|
368
570
|
}
|
|
369
571
|
const bot = typeof body.bot === 'string' ? body.bot : ''
|
|
370
572
|
if (!Object.prototype.hasOwnProperty.call(BOTS, bot)) {
|
|
371
|
-
respond(res, 400, { ok: false, error: { code: 'invalid-bot', message: 'bot must be one of
|
|
573
|
+
respond(res, 400, { ok: false, error: { code: 'invalid-bot', message: 'bot must be one of finance, funds, forecast, tax' } })
|
|
372
574
|
return
|
|
373
575
|
}
|
|
374
576
|
try {
|
|
@@ -383,4 +585,57 @@ export function apply(ctx) {
|
|
|
383
585
|
}
|
|
384
586
|
},
|
|
385
587
|
}), 'erpBot: /erp/bot route')
|
|
588
|
+
|
|
589
|
+
// 4) MCP server fleet: live list + per-server tool inventory via ctx.mcpManager.
|
|
590
|
+
// The manager is mounted by dsh-web-app; this host half only bridges it to
|
|
591
|
+
// the browser, so the base page shows the DSH-configured MCP servers and
|
|
592
|
+
// their discovered tools without any MCP protocol code here.
|
|
593
|
+
ctx.effect(() => ctx.webServer.register({
|
|
594
|
+
kind: 'prefix',
|
|
595
|
+
path: '/erp/mcp/list',
|
|
596
|
+
handler: async (req, res) => {
|
|
597
|
+
if (!fenced(req)) {
|
|
598
|
+
respond(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } })
|
|
599
|
+
return
|
|
600
|
+
}
|
|
601
|
+
if (req.method !== 'GET') {
|
|
602
|
+
respond(res, 405, { ok: false, error: { code: 'method-error', message: 'method not allowed' } })
|
|
603
|
+
return
|
|
604
|
+
}
|
|
605
|
+
try {
|
|
606
|
+
const views = (ctx.mcpManager ? ctx.mcpManager.list() : [])
|
|
607
|
+
respond(res, 200, { ok: true, servers: views })
|
|
608
|
+
} catch (error) {
|
|
609
|
+
respond(res, 500, { ok: false, error: { code: 'mcp-list-failed', message: String((error && error.message) || error) } })
|
|
610
|
+
}
|
|
611
|
+
},
|
|
612
|
+
}), 'erpBot: /erp/mcp/list route')
|
|
613
|
+
|
|
614
|
+
// 5) Probe one server: connect, read identity, list tools, close. No tools
|
|
615
|
+
// are registered and the live instance is untouched.
|
|
616
|
+
ctx.effect(() => ctx.webServer.register({
|
|
617
|
+
kind: 'prefix',
|
|
618
|
+
path: '/erp/mcp/probe',
|
|
619
|
+
handler: async (req, res) => {
|
|
620
|
+
if (!fenced(req)) {
|
|
621
|
+
respond(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } })
|
|
622
|
+
return
|
|
623
|
+
}
|
|
624
|
+
if (req.method !== 'GET') {
|
|
625
|
+
respond(res, 405, { ok: false, error: { code: 'method-error', message: 'method not allowed' } })
|
|
626
|
+
return
|
|
627
|
+
}
|
|
628
|
+
const name = String((req.url || '').split('name=')[1] || '').split('&')[0]
|
|
629
|
+
if (!name || !ctx.mcpManager) {
|
|
630
|
+
respond(res, 400, { ok: false, error: { code: 'invalid-server', message: 'name is required' } })
|
|
631
|
+
return
|
|
632
|
+
}
|
|
633
|
+
try {
|
|
634
|
+
const result = await ctx.mcpManager.probe(name, { timeoutMs: 10000 })
|
|
635
|
+
respond(res, 200, { ok: true, name, probe: result })
|
|
636
|
+
} catch (error) {
|
|
637
|
+
respond(res, 500, { ok: false, error: { code: 'mcp-probe-failed', message: String((error && error.message) || error) } })
|
|
638
|
+
}
|
|
639
|
+
},
|
|
640
|
+
}), 'erpBot: /erp/mcp/probe route')
|
|
386
641
|
}
|