Files
dsh_ai1net_server/dsh-server-docs/04-调整方案/64-接入AnySearch搜索provider.md
T
admin 5ad755116e chore(docs): 文档库并入代码仓(R4 选 a)+ 索引/台账跟进
1) dsh-server-docs/ 从工作区(原 E:\...\aliyun-dsh-server\dsh-server-docs)**整体并入本仓**,
   保留目录名 ⇒ 仓库内 dsh-server-docs/... 的相对引用天然继续有效;旧目录(含其 .git)已归档到
   工作区 _中间产物_待清理/,未随本提交带入。
2) .gitattributes:新增 `dsh-server-docs/** -text` —— 原文档库是 `* -text` + autocrlf=false,
   必须保持纯 LF,否则会被本仓的 CRLF 规则翻掉。
3) 活引用里的绝对路径已全部改到新位置(docs 的 INDEX / README / scripts / skills + 用户级 skills
   + ~/.workbuddy/settings.json 的 hooks);历史档案(04-调整方案/、archive/)按「只增不改」未动。
   ⚠️ hooks 路径改动需「完全重启会话」才生效(配置是会话启动快照)。
4) 交接单/T08:新增 §16「生产整体切换执行记录」(形态 / 落地动作 / **4 个只有真上线才暴露的真 bug** /
   验收证据 / 回滚命令 / 残留项);台账 T08 行 → 已完成并归档;03-路线图 §二 登记 T08 收尾项。
5) 统一称谓:**「本机」只指跑 WorkBuddy 的开发机**,47 / 106 一律写「远程服务器」。
2026-09-15 18:47:13 +08:00

245 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 64 · 接入 AnySearch 搜索 provider(B 方案)
- 日期:2026-09-12
- 触发:用户问「平台是否配了联网搜索 / 用的什么搜索 / 没有地方管理这类功能 / 能否装 anysearch」,随后选定 **B 方案**并给出 API Key
- 结论一句话:**平台现用 DeepSeek 官方搜索;接 AnySearch 可行,且官方插件自带 provider 选择配置(不用自己写 patch 解决歧义)——但落地前有 3 个必答点,其中「这把 key 给谁用」是 P0,因为它决定外泄面。**
- 状态:**admin 侧已实施**(见 §八);⚠️ 未闭环 = **§8.3**(端到端确认 → 铺普通用户)。§9.4 的「保持平台级直铺」建议**已由档案 65 推翻**(改走候选池 + 平台托管段)
> **TL;DR**|**结论**:接 AnySearch 技术上零障碍(官方插件 `@anysearch/[email protected]`,MIT,自带 `searchProvider: anysearch` 配置);**卡点不在技术,在「key 归属」**——全租户共用一把 key 意味着任何租户可从自己实例读出它。
> **关键**:前置验证 4 项全绿(key 有效 / 服务器可达 / 联合可用 / 包安全);插件会**一并替换 fetch provider**,需决定是否保留本地带 SSRF 防护的 `http`。
> **状态**:admin 侧已实施(§八);剩余待办 = §8.3
---
## 一、现状(只读取证,2026-09-12)
| 项 | 实测值 |
|---|---|
| dsh 版本 | `0.1.2-rc.1`(`/usr/local/lib/node_modules/@deepseek-ai/dsh`) |
| 搜索 provider | **只有 `dsh-web-search-deepseek`**(id `deepseek-official`)——DeepSeek 官方联网搜索 |
| 抓取 provider | `dsh-web-fetch-http`(id `http`,本地实现,带 SSRF 防护) |
| 机制 | POST `https://api.deepseek.com/anthropic/v1/messages`,Anthropic 兼容 + 原生 `web_search_20250305`(`max_uses` 默认 5);复用 `DEEPSEEK_API_KEY`(**不**复用 `DEEPSEEK_BASE_URL`) |
| 成本特征 | **一次搜索 = 一次模型 turn**(比专用搜索 API 贵) |
| 未安装 | 官方另有 `dsh-web-search-exa`、`dsh-web-search-perplexity`,本部署未装 |
| 管理面 | dsh 自带「设置 → 插件 → 插件配置 → Web search」,但被 `ensure-role-profile-patch.cjs` 对**非 admin** `disabled: true`(连同 models / plugin-inventory / cordis)→ **admin 能管、普通用户看不到**;门户侧无搜索管理面 |
## 二、前置验证(已完成,全绿)
| # | 项 | 结果 | 命令要点 |
|---|---|---|---|
| 1 | Key 有效性(本机) | ✅ HTTP 200 · `code:0` · 1.4s | `curl -X POST https://api.anysearch.com/v1/search -H 'Authorization: Bearer <key>' -d '{"query":..,"max_results":1}'` |
| 2 | 服务器网络可达 | ✅ 拿到 `request_id` | 服务器 DNS 走 CloudFront;IPv6 解析正常 |
| 3 | 服务器 + key 联合 | ✅ HTTP 200 · `code:0` · 2.6s | 同上,在服务器执行 |
| 4 | 插件包安全审查 | ✅ P2 安全 | 无 `child_process` / `eval` / `new Function` / `require()` / `process.env` / 文件写 / `chmod`;唯一外联 `https://api.anysearch.com` |
> 注:本机 `Invoke-RestMethod` 对该端点**必超时**(两次实测),用 `curl` 正常 —— 排查时勿被误导。
## 三、插件包分析(`@anysearch/[email protected]`)
168 KB / 33 文件,`lib/*.js` 为预构建产物(消费方无需构建)。
**自带的 `cordis.patch.yml`(关键,直接解决了 provider 歧义):**
```yaml
- id: web
config:
searchProvider: anysearch
fetchProvider: anysearch
- insert:
- id: web-search-anysearch
name: '@anysearch/anysearch-dsh'
config:
apiKeyEnv: ANYSEARCH_API_KEY
```
| 事实 | 值 | 影响 |
|---|---|---|
| provider id | `anysearch`(search + fetch 各注册一个) | patch 已显式指定 → **不会触发 `WEB_PROVIDER_AMBIGUOUS`** |
| `available()` | `endpoint(baseURL,'/v1/search') !== undefined` → **不校验 key,恒为 true** | 装上后 search registry = {deepseek-official, anysearch},靠 patch 消歧 |
| key 解析 | `ctx.credentials.resolve(credentialRef('ANYSEARCH_API_KEY'))` | 走 dsh credentials 服务(读 `$DSH_HOME/.credentials.yaml` / 环境) |
| 额外工具 | `anysearch_search` / `anysearch_batch_search` / `anysearch_capabilities` | 白得 3 个工具 |
| `web_fetch` 工具 | 已存在则不重复注册 | 无副作用 |
| 默认 baseURL | `https://api.anysearch.com` | 与验证一致 |
**它的 patch 同时把 `fetchProvider` 换成 `anysearch`** —— 即抓取从本地实现改为第三方 AnySearch Extract,**本地那套 SSRF 防护(拒非公网地址、逐点解析、逐跳重定向校验)随之失效**。
平台若要保留本地 fetch,需在 `profiles/web/cordis.patch.yml` 覆写:
```yaml
- id: web
config:
fetchProvider: http
```
> ⚠️ **合并顺序未实测**:平台的 profile patch 与 bundle patch 都经 `applyEntryPatches` 合入,谁后应用决定谁生效。**必须用 `dsh --profile web --dump-config` 实测合成结果**,不能假设。
## 四、三个必答点
| # | 级别 | 问题 | 选项 |
|---|---|---|---|
| **1** | **P0** | **这把 key 给谁用?** | (a) 全平台统一注入 —— 所有实例共用;**任何租户都能从自己实例读出它**(`.credentials.yaml` 属主即该用户,或 env 可读),等同档案 23 已记的 `DEEPSEEK_API_KEY` 外泄面,且这把是个人账号 key、配额与账单都绑在你身上<br>(b) 只给 admin → 普通用户继续 DeepSeek<br>(c) 全平台用 + 接受外泄面(写进已知风险) |
| **2** | P0 | fetch 是否一并换成 AnySearch Extract? | (a) **保留本地 `http`**(推荐:SSRF 防护不丢,只换搜索)<br>(b) 接受插件的默认(search + fetch 都走 AnySearch,抓取质量可能更好,但防护逻辑改变) |
| **3** | P1 | 投放方式 | (a) **平台级统一铺**(同 `portal-entry` / `business-plugins` 姿势,用户无感、不可关)<br>(b) 进功能插件候选池 → 用户在实例「功能管理」自助启停(搜索是基础能力,用户关掉会退回 DeepSeek,不建议) |
## 五、执行步骤(确认后)
| 步 | 动作 | 中断? |
|---|---|---|
| S0 | 把 tgz 放 `/opt/dsh/artifacts/anysearch-dsh-0.1.4.tgz` | 否 |
| S1 | **先写 key**:合并 `<home>/.credentials.yaml` → `ANYSEARCH_API_KEY`(**必须合并**,该文件已有 `client-connection/browser-session` 记录;保持 600 + 用户属主) | 否 |
| S2 | 仿 `ensure-biz-plugins.cjs` 写 `ensure-anysearch.cjs`:tgz 暂存 `<home>/.dsh-stage/`(444、chown uid)→ `setpriv --reuid <uid> env HOME=<home> pnpm add --store-dir <inherit> file:<staged>` → `reconcileBundles` | 否 |
| S3 | 写 profile patch 覆写段(若第 2 点选保留本地 fetch) | 否 |
| S4 | **停实例 scope**(`systemctl stop dsh-<uid>-*.scope`,下次访问自动拉起) | **是** |
| S5 | `--dump-config` + 实例内实跑 `web_search` 验证 | — |
> **顺序不可颠倒**:profile 里 `patchReload: live`,bundle patch 有**热加载**可能 → 先写好 key 再装插件,避免出现「provider 已切换、key 还没配」的窗口。
## 六、影响与回滚
**影响(R8)**:S4 会停掉 2 个用户实例(guest `4092b965…`、admin `cce6d1cd…`),**当前会话断开**,下次访问自动拉起(冷启动约 4-5s,含编译缓存)。
**回滚**(任一步均可独立回滚):
1. 从 profile `package.json` 的 `dependencies` 摘掉 `@anysearch/anysearch-dsh` + `bundles` 移除该名目 → 重启实例即回到 DeepSeek 搜索;
2. 删除 `.credentials.yaml` 里的 `ANYSEARCH_API_KEY` 记录;
3. 移除 S3 的覆写段;
4. tgz 与 `.dsh-stage/` 暂存可留(只读 444,不生效)。
## 七、红线遵守
- **R1** 不触发 dsh 升级(插件走 profile bundle,不动 dsh 主程序/缓存)。
- **R2** 不改官方 dsh 主程序与缓存。
- **R3** 不依赖 patch 官方包。
- **R7** 只动「本次明确清单」内的文件:每用户 `profile/package.json` 1 个 + `.credentials.yaml` 1 个(合并,非覆盖)+ `cordis.patch.yml` 1 个;不批量改换行符 / 不 `git add -A`。
- **R8** 停实例前已在本档列明影响并取得确认。
---
## 八、实施记录(2026-09-12 10:30-11:20)
**用户裁决**:① 分两步走(**先只装 admin 验证**)② **保留本地抓取** ③ 现在就执行。
**范围**:仅 admin(`cce6d1cd-b376-4304-80f0-0e1c58c9ffde`,uid **114801**)。
**载体**:新脚本 **`dsh_shenxian/scripts/ensure-anysearch-admin.cjs`**(仿 `ensure-biz-plugins.cjs` 的 setpriv + 沿用旧 store 姿势;支持 `--apply` / `--restart` / 干跑 / 指定用户)。
| 步 | 结果 |
|---|---|
| 传包 | `/opt/dsh/artifacts/anysearch-dsh-0.1.4.tgz`(168851 B) |
| 凭据 | `refs.ANYSEARCH_API_KEY` 写入,**600 + uid 114801** |
| patch | `platform: anysearch-search` 覆写段写入 |
| 依赖 | `@anysearch/anysearch-dsh` 安装成功(store 沿用旧,ws 保持干净) |
| bundles | 6 项,含该包 ✅ |
| 停实例 | **0 个** —— 执行时 admin 实例未运行 → 下次访问自动拉起新配置(无需额外重启) |
### 8.1 关键实测发现(推翻本档 §三 的假设)
> **profile 层 `- id: web / config:` 对 `dsh-web` 条目是「整体替换 config」,不是字段级合并。**
首次只写 `fetchProvider: http` 时,**插件自带 patch 的 `searchProvider: anysearch` 被一并冲掉** —— dump-config 里该条目只剩 `fetchProvider: http`。若就此上线,search 侧两个 provider 都 `available()=true` 又无显式选择 → **命中 `WEB_PROVIDER_AMBIGUOUS`(报错,不会自动选一个)**。
**修正**:覆写块**必须两个字段都写全**;脚本 `ensurePatch` 已改为幂等「整块替换」(新增 `updated` 动作)。
### 8.2 验证记录
```yaml
# dsh --profile web --dump-config 实测(admin)
- id: web
name: '@deepseek-ai/dsh-web'
config:
searchProvider: anysearch
fetchProvider: http
- id: web-search-anysearch
name: '@anysearch/anysearch-dsh'
config:
apiKeyEnv: ANYSEARCH_API_KEY
```
| # | 验证项 | 结果 |
|---|---|---|
| 1 | provider 选择 | ✅ search=`anysearch` / fetch=`http` |
| 2 | 凭据文档格式 | ✅ dsh 严格解析通过(dump-config 无 warning / 无报错) |
| 3 | 凭据权限 | ✅ `600` + `dsh-cce6d1cdb376430480f0` |
| 4 | 网络 + key(服务器侧 curl) | ✅ HTTP 200 · `code:0` · 2.6s |
| 5 | **端到端(实例内实跑 `web_search`)** | ⏳ **未做** —— 留待 admin 首次访问实例时确认 |
> **附注**:dump 中 `tool-web` 条目带 `disabled: true`,注释显示来自 `@deepseek-ai/dsh-web-app` —— 这是**既有状态**(档案 37a 已证 `web_search` 当时可用),非本次引入。
### 8.3 待办
1. **admin 端到端确认**(访问自己的实例 → 让它搜一次 → 确认走 AnySearch);
2. 通过后铺普通用户:`ANYSEARCH_API_KEY=… node scripts/ensure-anysearch-admin.cjs --apply --restart guest`;
3. 观察配额消耗(`anysearch.com/console`),评估是否需把 key 换成团队账号。
> **⚠️ 修正(2026-09-12 用户拍板)**:**上文第 2 条的「每用户直铺」路线作废** —— 三方插件**一律**走「**admin 在门户导入候选池 → 用户在实例『功能管理』自助启用/禁用**」,**不再向用户 profile 铺任何东西**。
> 执行口径改以 **档案 65 §7.3** 为准:部署 65 → 投候选池 → **退役本脚本的手工覆写职责** → 启/禁两态端到端。
> 原因:直铺段与档案 65 的平台托管段同属「整体替换 config」语义,两段并存会互相覆盖,产生难以察觉的配置漂移(档案 66 §待办 3)。
### 8.4 回滚
| 步 | 动作 |
|---|---|
| 1 | profile `package.json`:从 `dependencies` 与 `dsh.profile.bundles` 摘掉 `@anysearch/anysearch-dsh` → 停实例 |
| 2 | `.credentials.yaml`:删掉 `refs.ANYSEARCH_API_KEY`(删 key = 该段为空则连 `refs:` 段一起删) |
| 3 | `cordis.patch.yml`:删掉 `# >>> platform: anysearch-search` ~ `# <<< platform: anysearch-search` 整块(或直接还原 `.bak-anysearch`) |
> ⚠️ **`.dsh-stage/anysearch-dsh-0.1.4.tgz` 不可删** —— profile 的 `file:` 依赖指向它,删了会重现「清理残留 tgz 导致依赖断裂」那个事故(2026-09-12 上午,目前**只记在当日工作日志的「事故 63」里、无正式档案**):此后任何 `pnpm add` 都会在解析阶段 ENOENT 失败。
> 含密钥的 `.credentials.yaml.bak-anysearch` 已在验证后**删除**(回滚只需删 3 行,无需备份)。
---
## 九、与「候选池 + 用户自助启停」方式的差异(2026-09-12 追问后核实)
用户问:能否改成「admin 在门户导入候选池 → 用户自己在实例里启用/禁用」。**机制确实存在**(档案 16 三层模型,已实施),但**本场景不建议走**。
### 9.1 两条路的事实差异
| | 平台级直铺(**本次采用**) | 候选池 + 用户启停 |
|---|---|---|
| 入口 | 脚本直接 `pnpm add` 进 profile bundle | 门户 `portal.html#/plugins` 上传 → `business_plugins` 表 + 磁盘 tgz |
| 用户可见 | 实例「功能管理」里**看不到** | 列表可见、可勾选 |
| 用户可关 | 不能 | 能 |
| 配置一致性 | 恒定(覆写段与插件同时在场) | **可能失衡**(见 9.2) |
### 9.2 关键坑:走候选池会「一禁用就坏」
实测实现(`src/web/routes/business-plugins.ts` L399-402、L410-413):
- **启用** = `pnpm add file:<tgz>` + `reconcileBundles`
- **禁用** = **`pnpm remove <id>`** + `reconcileBundles` —— **真卸载**,不是「留包 + `disabled: true`」
> ⚠️ **档案 16 §7.1-5 写的「禁用机制 = 保留包 + cordis patch `disabled: true`(不删 node_modules)」与现实现不符**(实现是移除依赖)。该条需修订 —— 本次不擅自改档案 16,仅在此登记。
于是用户禁用 AnySearch 时:
```
插件 bundle 被移除 → 插件自带 patch(searchProvider: anysearch)不再加载
→ 但 §八 的 profile 覆写段仍在,仍写死 searchProvider: anysearch
→ 指向一个未注册的 provider
→ WEB_PROVIDER_CONFIGURED_MISSING(报错,不回落 DeepSeek)
```
**且用户自己修不了**(他看不到 `<profile>/cordis.patch.yml`)。
### 9.3 为什么不能靠「不写死」绕开
dsh 的选择语义决定了「两个 provider 共存 + 用户可任意启停」**必然存在坏状态**:
| 配置策略 | anysearch 启用时 | anysearch 被禁用时 |
|---|---|---|
| 写死 `searchProvider: anysearch` | ✅ 正常 | ❌ `CONFIGURED_MISSING` |
| 什么都不写 | ❌ `AMBIGUOUS`(2 个可用) | ✅ 自动选 deepseek |
| 禁掉 deepseek 那条 provider | ✅ 自动选 anysearch | ❌ `UNAVAILABLE`(0 个可用) |
三种组合都有坏格子 —— 要优雅解决,需要**平台在 apply 时同步维护 web 配置**,属平台改造,不是配置能解决的。
### 9.4 结论与建议
- **保持平台级直铺(现状)**:搜索是基础能力,用户不该关;配置恒定正确,无坏状态。
- 若确要让用户自助启停,**前置条件 = 先补一个平台能力**:在 `/api/plugins/mine/apply` 的启用/禁用路径里,对「搜索/抓取 provider 类插件」同步 upsert profile patch 的 `searchProvider` / `fetchProvider`。
- 另一条折中:候选池支持**定向投放**(目前档案 16 定为「全员可见,先不做分组」)—— 即便做了定向,9.2 的坑依然存在。
> **⚠️ 状态更新(2026-09-12,档案 65 落地后)**:本节的「保持平台级直铺」建议**已被后续决策推翻** —— 用户选定**走候选池统一机制**("有问题解决问题"),档案 65 已在平台侧实现 9.4 第二条所述的前置能力(`install`/`uninstall` 后按当前 bundles **重算托管段**),**9.2 的坏状态已消除**。**以档案 65 为准**,本节保留为决策过程记录。
> 另:本档头部「⏸ 待用户确认 2 个决策点」已过时 —— 见 §八 实施记录(admin 侧已实施);当前未闭环项见 §8.3。
> **再更新(2026-09-12 15:40,实证)**:§9.1 的「走候选池」这条路又暴露一个**更硬的问题** —— 该插件与平台 dsh `0.1.2-rc.1` **本体不兼容**(`@deepseek-ai/dsh-llm` 未导出 `assertNever`),从候选池启用会让实例**崩溃循环**(已实证并止损)→ **见档案 70**。即:§9.2/§9.4 讨论的「provider 坏状态」档案 65 可解,但**兼容性问题档案 65 解不了** —— 先解决「插件能不能装」,再谈「启停怎么联动」。
> **终局更新(2026-09-12 18:56):该能力已被用户放弃(选 **B · 下架**)** —— 候选池条目已下架(HTTP 200 / `audit_log` id 175),凭据中 `ANYSEARCH_API_KEY` 实测**已不存在**,全部用户 profile 无残留;**§8.3 的「铺普通用户」与 §8.4 的回滚步骤(删 3 行)随之一起作废**。后续若重启此能力,请从**换兼容版本**开始(先过档案 71 的兼容性预检),**不要再导入 `0.1.4`**。完整处置见 **档案 70 §九**。