# dsh-plugin-manager 架构规范文档(防失忆手册) > **本文档是唯一权威规范。** 任何修改(修 bug、加功能、重构)前必须先读本文档; > 改完必须按「§8 发布检查清单」核对。未来会话若忘记项目细节,读完本文档即可恢复全部上下文。 > 最后更新:2026-08(AI 搜索双轨 / 头像懒加载 / 安全面加固 / 粘贴安装 mkdir 修复 之后) --- ## 0. 一句话记忆锚点 - **包名** `@dsh-external/dsh-plugin-manager`,仓库 `github:yunniees/DSH-Plugin-Manager` - **两端**:host(`src/index.ts`,Node,bundle 层)+ client(`src/client/index.ts`,浏览器面板) - **通信**:client 只经 `api()` → host HTTP `~/api/<路由>`(前缀常量两端同名 `API`) - **外部世界**:host 只经 `llmText` / `githubFetch` / `spawnDsh` 三个统一出口 - **铁律**:UI 文本必走 `t()`;改 client 必 `npm run build`;密钥永不出现;输入必白名单 - **开发中装配**:本地开发用 `dsh plugin add <本地源码目录>`(link 方式,源码目录**不可移动/删除**);发布后从 GitHub 安装的是独立副本 --- ## 1. 定位与能力全景 单 npm 包完整提供(装一个插件 = 全部功能,无外部服务依赖): | 能力 | 实现 | |---|---| | 插件清单/启停/删除 | 读 profile bundles;开关写目录名;删除走 `dsh plugin remove` | | AI 分类/增量简介/修复 | `llmText` + 状态复用(已分类/已同步不重复消耗 token) | | 界面翻译 | zh/en 字典 + Other 自定义语言(AI 翻译 + localStorage 缓存) | | 插件市场 | GitHub 检索:浏览/搜索/AI 搜索/AI 介绍/排序/筛选/无限滚动/一键安装 | | 预设管理 | 启停/检查更新/版本选择/更新/分享/粘贴安装 | | 分享/粘贴 | 插件+预设打包 `dshpm://v1/` 链接 | | 网络韧性 | GitHub 镜像链(官方代理优先→镜像直连兜底);安装失败 git 镜像重试;allowBuilds 自动放行 | **运行时分层**: ``` ┌──────────────────── DSH 进程 (Node) ────────────────────┐ │ src/index.ts (host) │ │ ├─ webServer.register(prefix API) → 21 个 JSON 路由 │ │ ├─ llmText() → ctx.llm(AI,必传 purpose) │ │ ├─ githubFetch() → 官方(代理优先)+4 镜像(直连优先) │ │ ├─ spawnDsh() → dsh CLI(安装/更新/卸载,真实落盘) │ │ └─ 本地文件: profile manifest / settings.yaml / presets │ └──────────────────────────────────────────────────────────┘ ▲ HTTP JSON(仅本机回环,90s 前端超时) ┌──────────────────── 浏览器 (client) ─────────────────────┐ │ src/client/index.ts │ │ ├─ slots 注册侧栏入口(React 壳,内部 vanilla DOM) │ │ ├─ 面板:概览 / 市场(浏览·安装) / 分享 / 版本 / 预设 │ │ └─ localStorage: dpm-lang / dpm-customdict / 两个缓存 │ └──────────────────────────────────────────────────────────┘ ``` --- ## 2. 目录与构建 ``` ├── src/index.ts # host(tsc → lib/index.js) ├── src/client/index.ts # client(tsdown → lib/client.js,浏览器 bundle) ├── scripts/build.mjs # typecheck(host+client) → tsc → tsdown ├── cordis.patch.yml # bundle 装配补丁 ├── lib/ # 产物(提交进 git,GitHub 安装开箱即用) └── package.json # files: [lib, cordis.patch.yml, README.md, ARCHITECTURE.md] ``` ```sh npm run typecheck # 必须 0 错误 npm run build # typecheck + tsc + tsdown,全绿才可发布 ``` **铁律:改 client 源码后必须 `npm run build`**,否则装配的是旧产物(最常见事故)。 --- ## 3. 统一调用层(核心设计) ### 3.1 host 端三个唯一出口 | 封装 | 用途 | 必守规则 | |---|---|---| | `llmText(ctx, opts)` | 一切 LLM 调用 | 必传 `purpose`;按需 `timeoutMs`;返回纯文本 | | `githubFetch(url, headers, timeoutMs, totalMs)` | 一切 GitHub HTTP | 官方源**代理优先/直连兜底**;镜像源(ghfast.top/gh-proxy.com/ghproxy.net/moeyy)**直连优先**(国内加速站绕代理,节点挂了不受影响);404 短路返回;总时长上限截断 | | `spawnDsh(args, useMirror)` | dsh CLI 子进程 | 优先 `node `(resolveDshBin 向上找,免 PATH);`installWithMirror` 包一层:官方失败→GIT 镜像重试一次 | 派生封装:`rawFetch`(小文件抓取,4.5s/12s)、`githubSearch`(搜索 API,10s/22s)、`directGet`(node:https 直连兜底,绕过全局代理)。 ### 3.2 client 端唯一出口 所有后端通信走 `api(path, init)`(默认 90s 超时,统一 `{...json, _status}`)。**禁止 client 直接 fetch 第三方地址**——唯一例外:市场卡片的 owner 头像由浏览器 `` 加载(见 §6.3)。 ### 3.3 响应契约 - 成功/业务失败都回 JSON:`{ ok: boolean, message?: string, ...字段 }` - 参数不合法 → 400;未捕获异常 → 外层 try/catch 统一 500,**message 绝不含堆栈/密钥/绝对路径** - AI 文案必须过 `sanitizeTranslation()`(超长/格式异常/过短丢弃) --- ## 4. 翻译架构(R1:所有 UI 文本必须可翻译) **设计**: 1. 字典 `const L = { zh: {…}, en: {…} }`,**zh/en 键必须一一对应**; 2. 键名 `<域>.<动作>` 点分小写(`market.install`、`uninstall.confirm`),变量用 `{var}` 占位; 3. 调用 `t('键', vars)`;**动态键只允许有限枚举拼接**(`t('market.sort.' + v)`,v∈best/stars/updated); 4. 语言状态 `S.lang ∈ 'zh' | 'en' | 'other:<名>'`,持久化 `localStorage['dpm-lang']`;Other 的译文存 `dpm-customdict`(经 `/translate` 的 key=译文 行协议生成,`applyDict()` 合并); 5. 缓存按语言隔离:`dpm-intro-cache` 键前缀 `intro::`,`dpm-desc-cache` 键 `包名|语言`;**切语言必清简介缓存**(`clearIntroCache`); 6. AI 生成的简介/介绍必须过 `sanitizeTranslation` 才可入库。 **新增一段 UI 文本的标准流程**: 1. `L.zh` 与 `L.en` **同时**加键 → 2. 代码 `t('键')` → 3. `npm run build` → 4. 跑键差集核对(§7.4)。 **历史教训**:曾把 `"预设 " + pr.name` 硬编码进发给 AI 的文本(违反 R1),已改 `t("preset.cat")`。审代码时专门 grep 非字典区的中文串。 --- ## 5. 安全边界(R2) 1. **密钥**:全代码无任何硬编码 key/token。`githubSearch` 的 token 参数仅调用方注入(当前无人传);即便未来支持也仅限本机 HTTP 传入,不落盘不打日志。 2. **输入白名单(host 逐路由校验,新增路由必须照做)**: | 输入 | 正则 | |---|---| | `owner/repo`(install-github / market-intro) | `/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/` | | 预设名(toggle/check/versions/update) | `/^[a-z0-9][a-z0-9-]*$/` | | 预设名(files / install) | `/^[A-Za-z0-9._-]+$/` | | 版本 tag(preset-update) | `/^[A-Za-z0-9._-]+$/` | | 安装目标 spec(ai-install) | 长度 ≤200 且无 `\r\n`(防 YAML/URL 注入) | | 写入文件名 | basename 后 `/^[A-Za-z0-9._-]+$/` + 扩展白名单(`.mjs`/`preset.yml`/`agent.cordis.yml`) | 3. **文件写入**:路径一律 `join(受控目录, basename(白名单名))`;**写前确保目录存在**(`mkdir(base, {recursive:true})`——/install-preset 曾因缺 mkdir 导致粘贴安装必失败);分享读取单文件 ≤200KB。 4. **敏感数据隔离**:状态文件只存分类/简介/语言/模型名;`settings.yaml` 只解析 provider+模型名;`process.env` 仅透传子进程。 5. **信息面**:`/installed` 返回 `dshHome`/`profileDir` 供本机面板展示,HTTP 仅本机回环,不写日志。 --- ## 6. 异步与 UI 模式(R3:最容易踩的坑) ### 6.1 状态变更 → 立即重绘 **铁律:凡设置 loading/busy 状态,必须在发起 await 之前重绘一次 UI**,否则"转圈"永远不显示。 正确示例(`loadMarket`):`st.loading = true → renderMarketGrid()(转圈出现)→ await api → st.loading = false → renderMarketGrid()` 历史 bug:曾设 loading 后直接 await,加载中转圈从未渲染。 ### 6.2 多请求并发 + 总时长预算 - 多个独立请求必须 `Promise.allSettled` 并发(AI 搜索 6 次查询曾串行,弱网超前端 90s); - 每类请求有总超时预算:搜索 22s / raw 验证 12s / LLM 20-90s;host 全链路必须 < client 90s; - 失败容忍:单查询失败跳过,全失败才报错;报错后 UI 提供**可点击重试**(`market.failRetry`)。 ### 6.3 慢资源懒加载(内容先行) 市场卡片头像模式:首字母占位先渲染 → `` 慢慢加载 → 成功替换 / 失败换 `ghfast.top/<原URL>` 镜像再试 / 仍失败保留首字母。**任何图片/富媒体一律照此模式,不得阻塞文字内容渲染。** ### 6.4 AI 搜索双轨(当前实现,勿回退) ``` LLM 生成 3 组英文关键词(空则 desc 兜底) → 每组两种查询并发:『topic:dsh-plugin <词>』+『<词> deepseek harness』(覆盖未打标签插件) → 合并去重 → 按 star 降序(满足"收藏最多"类需求) → 候选 >20 时 LLM 筛选(prompt 明确"优先 star 高")→ byPick/rest 各自 star 降序 → 返回 top20 + rest(前端滚动从 rest 补批,不再请求) ``` 实测:该策略对"插件管理"类需求产生 92 个候选(topic 池共 5375 仓库)。 ### 6.5 返回语义 市场"返回"= **退出搜索态回到默认浏览**(清 q/aiSearch/aiRest,重置 sort=best/typeFilter,items 清空后 renderMarketView 自动加载),**不是关闭面板**。关闭面板用遮罩/✕。 ### 6.6 无限滚动 滚动容器是 `.dpm-main`(不是 body/mbody);监听只绑一次(`scrollBound` 标记);触发条件 `接近底部400px && !end && !loading`;首屏不满时自动补页最多 2 次(`autoFilled`)。 --- ## 7. 状态与缓存(R4) | 存储 | 位置 | 内容 | 规则 | |---|---|---|---| | host 状态 | `/.dsh-plugin-manager.json` | 分类/简介/语言/模型 | 增量写入,无密钥 | | 预设来源 | `~/.dsh/.agent-presets//dpm.source.json` | repo/commit/version | 更新后写回 | | AI 简介缓存 | `dpm-intro-cache` | `intro::` | >600 条保留最近 500;切语言清空 | | 摘要缓存 | `dpm-desc-cache` | `包名\|语言` | 过 sanitize 才写 | | 语言/字典 | `dpm-lang` / `dpm-customdict` | — | — | --- ## 8. 新功能开发流程(R5:标准动作序列) **每次新增功能(例:未来的"AI 制作插件")按此序列,顺序不可乱**: 1. **host 路由**:在 `apply()` 的 POST 分支加 `if (path === '/xxx')`,第一行做**输入白名单校验**(§5.2 表);所有外部调用走 §3.1 封装;响应用 `send()` 契约; 2. **client 调用**:新增 `api('/xxx', …)`,确认 base 常量一致; 3. **UI 文本**:每个可见文案先加 `L.zh`+`L.en` 键再引用(§4 流程); 4. **异步模式**:loading 先重绘(§6.1);多请求并发(§6.2);慢资源懒加载(§6.3); 5. **AI 调用**:`llmText` 必传新 `purpose`(如 `'plugin-forge'`);输出必须 `sanitizeTranslation` 或结构化校验; 6. **写盘**:目录先 mkdir,文件名白名单,扩展名白名单; 7. **验证**:`npm run typecheck` 0 错 → `npm run build` 全绿 → 翻译键差集(§9.3)→ 手动冒烟主流程; 8. **文档**:新路由补进 §10 路由表;新模式/教训补进对应章节。 --- ## 9. 工程规范 ### 9.1 类型 - 双端 `strict: true`,禁止 implicit any;`el()` 返回 HTMLElement,`.disabled`/`.dataset` 需 `as HTMLButtonElement`/`(n as HTMLElement)`; - client 数据模型用显式 interface(`MarketItem`/`ShareItem`/`PluginView`/`PresetView`),host ctx 用轻量局部类型。 ### 9.2 命名与反馈 - 状态单例 `S`(client);缓存键前缀 `dpm-`;LLM purpose 用 `-` 连字符小写; - 异步必反馈:轻 `toast(msg, isErr)`、结果 `openResult`、危险操作 `openConfirm`(Promise 化);按钮忙碌态 `dpm-spin`+文案,完成还原。 ### 9.3 翻译键差集核对(防回归脚本) ```sh # 调用集 vs 字典集,双向 diff;动态枚举(market.sort.*)人工确认 grep -oE "t\([\"'][A-Za-z0-9_.]+" src/client/index.ts | sed "s/t([\"']//" | sort -u grep -oE "^\s*'[A-Za-z0-9_.]+':" src/client/index.ts | tr -d " :'" | sort -u ``` ### 9.4 防回归清单(血泪教训) 1. 禁删"看似没用"的函数——client bundle 内部交叉引用复杂,删除前必 grep 全部引用(曾误删 15+ 函数); 2. 从产物回迁源码:补全类型直到 tsc 0 错,再 build; 3. 路由两端同步:client 调的每个 `/xxx` 必在 host 有实现; 4. 写文件前想目录是否存在; 5. 多请求预算:host 全链路 < 90s; 6. **移动 link 插件目录必须四同步**:package.json 的 link 路径 + node_modules junction + `pnpm-lock.yaml` + `node_modules/.package-map.json` + `node_modules/.pnpm/lock.yaml`。 只改前两项,任何后续 `pnpm install`(如一键更新)会**按旧 lock 把 junction 重建回旧路径(死链)**,DSH CLI 再把 bundles 列表按安装状态裁剪掉这些插件,浏览器刷新后 client 加载失败、面板无法打开——2026-08 一键更新事故的根因,三处元数据缺一不可; 7. **预设更新必须枚举目录拉全量文件**:`fetchPresetFiles` 已从硬编码 `['router-bootstrap.mjs','router-core.mjs']` 改为 GitHub API 枚举目录全部 `.mjs/.yml`(排除 preset.yml/agent.cordis.yml 后全拉)。 上游可能多版本并存(`router-bootstrap-v1/v5/v8.mjs`),`agent.cordis.yml` 引用的版本缺失会导致预设挂载失败,报 `Cannot find module '…/router-bootstrap-v1.mjs'`、模型操作全线失败——2026-08 实测事故。硬编码文件名列表一律视为反模式。 --- ## 10. 模块清单 ### 10.1 host 路由表(21 个) GET:`/installed` `/models` `/market` POST:`/classify` `/summarize` `/fix` `/translate` `/check-update` `/versions` `/update` `/install-github`(repo 白名单) `/uninstall` `/ai-market-search` `/market-intro`(repo 白名单) `/ai-install`(spec 长度/控制字符) `/preset-toggle` `/preset-check-update` `/preset-versions` `/preset-update`(tag 白名单) `/preset-files` `/install-preset`(mkdir+文件名白名单) ### 10.2 client 视图 概览(switchView)/ 市场·浏览(renderMarketBrowse:防抖 400ms 搜索、AI 搜索、排序、筛选、无限滚动)/ 市场·安装(URL 安装)/ 分享(openSharePanel:生成+粘贴)/ 版本选择(openVersionPicker 插件、openPresetVersions 预设)/ openConfirm / openResult ### 10.3 AI 能力(7 类,均经 llmText + purpose) classify / summarize(含 diff-judge)/ fix / translate / market-search + market-filter / market-intro / ai-install(+reason)。模型来自 `/models`(DSH 已配置 provider),**插件不持有任何 key**。 --- ## 11. 发布检查清单(GitHub/发版前逐条打勾) - [ ] `npm run typecheck` 0 错误;`npm run build` 全绿 - [ ] `grep -rniE "sk-|api[_-]?key|Bearer " src/` 只允许命中动态参数 - [ ] 无本机绝对路径硬编码(路径全部 join/resolveDshHome 推导) - [ ] 翻译键双向 diff 干净;新键 zh/en 齐全 - [ ] client 调用的每个路由 host 已实现(含输入校验) - [ ] `lib/` 已重新构建并提交(GitHub 安装靠它开箱即用) - [ ] package.json repository 指向 `yunniees/DSH-Plugin-Manager`;版本号已 bump - [ ] 本地装配冒烟:市场加载/安装/翻译/预设/分享主流程走通 --- ## 12. 已知信息面与限制(如实告知) - HTTP 仅本机回环;`/installed` 含 `dshHome`/`profileDir` 供面板展示,不写日志; - GitHub 匿名搜索限流 ~10 次/min(AI 搜索一次并发 6 次,连续操作可能触发限流,错误会提示+可重试); - 镜像仅加速 raw/API 直连;搜索 API 官方挂时仅 gh-proxy.com 可用; - 安装/卸载真实执行 `dsh plugin`(pnpm 落盘);allowBuilds 自动放行会写 `pnpm-workspace.yaml`; - 开发装配是 link 指向本地源码目录(该目录不可移动/删除);发布后用户从 GitHub 装的是独立副本,不受开发目录影响。 --- ## 13. 计划中的扩展(占位) - **AI 制作插件**(用户已排期):集成在本包内。将遵循 §8 流程——新路由 `/plugin-forge` 系列、新 purpose `plugin-forge-*`、生成物落盘走 §5.3 白名单、UI 进面板新视图、全部文案走 `t()`。设计时先补本章。