DSH 插件开发实战:从原理到桌面联动
🧑💻 写在开头
点赞 + 收藏 === 学会🤣🤣🤣
DSH 插件开发实战:从原理到桌面联动
以 dsh-desktop-bridge 为例,拆解插件加载、服务注入、本机连接、任务响应、事件归属、Electron/Vue 联动,以及安装、升级和日常管理。
本文根据项目当前源码编写,适用范围为 Windows + DSH Desktop 0.13.2 + Harness 0.2.2-alpha.2 + 桥接插件 0.2.0 + protocol 2。这是一个特定版本的工程案例,不是对所有 DSH 版本的通用 API 承诺。文中的缩减示例会明确标注;完整实现应以配套源码为准。本文未执行真实模型任务,也不将静态源码核对当作端到端验证。
一、我们到底要开发什么
假设你已经有一个桌面应用:它可以是 AI 桌宠、任务面板,也可以是设备管理客户端。现在想让它拥有 DSH 的模型、工具和 Skills 能力:
- 在应用里输入需求,由 DSH 执行;
- 在应用里看到文字结果、工具进展和审批状态;
- 让同一个角色继续沿用之前的 DSH 会话;
- 打开 DSH 交付的文件;
- 保留 DSH 原有权限和审批,不复制模型密钥到新应用。
dsh-desktop-bridge 做的就是这件事。
它不是重新实现一个 Agent,也不是把所有 DSH 插件直接暴露成 HTTP 工具,而是建立一个受控的任务入口:外部应用负责交互,桥接插件负责协议转换,DSH 负责实际执行。 某个插件是否能被模型作为工具调用,仍取决于 DSH 的实际配置。
还要区分三种概念:
| 概念 | 在本案例中的职责 |
|---|---|
| 宿主插件 | 加载到 DSH Harness,使用宿主服务和事件;本文开发的是这一类 |
| 模型工具 | 供 Agent 在执行过程中调用;本桥接自身不等于注册一个模型工具 |
| 外部客户端 | 通过本机 HTTP 与桥接交互,例如 Electron 桌宠 |
因此,学习这个案例,不只是学会写一个 HTTP 服务,而是学会把宿主生命周期、执行状态和外部交互连接起来。
二、整体架构:一条请求链,两种通知方式
用户
│ 输入任务 / 聊天
▼
Vue 页面
│ window.petApi 的有限接口
▼
Electron preload
│ IPC invoke / subscribe
▼
Electron 主进程 dsh-client
│ 读取发现文件 + Bearer 鉴权 + 本机 HTTP
▼
DSH 内的 dsh-desktop-bridge
│ sessionController.create / prompt
▼
DSH Agent → 模型 / 工具 / Skills / DSH 原生审批
│ session/event、agent/error
▼
桥接更新任务快照与持久化状态
▲
│ 客户端 GET /status 轮询读取
Electron 主进程
│ 广播状态 + 同步聊天记录
▼
Vue 页面展示结果
这里最容易误解的是“实时响应”:
- DSH 内部是事件驱动:插件订阅执行事件,更新任务状态。
- 跨应用传输是 HTTP 轮询:客户端读取最新快照,不是 WebSocket,也不是 SSE。
- Electron 内部是 IPC 广播:主进程拿到新状态后推给页面。
源码中,存在活动任务、待核对任务或不确定提交时,客户端约每 1 秒刷新一次;空闲时约每 5 秒刷新一次。使用单次定时器,在一次刷新完成后安排下一次,避免轮询重叠。
三、插件如何被 DSH 识别和加载
3.1 四个文件分别解决什么问题
插件发布目录包含四个关键文件:包清单、ESM 入口、Cordis 配置补丁和 PowerShell 安装脚本。
| 文件角色 | 作用 |
|---|---|
| package.json | 声明包名、版本、ESM 入口、发布内容和 DSH bundle 元数据 |
| index.mjs | 注入宿主服务,启动桥接,处理任务与事件 |
| cordis.patch.yml | 将插件节点插入目标 profile 的插件配置 |
| install.ps1 | 检查版本并调用 DSH 自带运行时完成安装 |
目录结构示意如下,实际发布包应包含这四个文件,而不是只提供安装脚本:
integrations/
dsh-desktop-bridge/
package.json
index.mjs
cordis.patch.yml
install.ps1
3.2 包入口和配置补丁不是同一回事
包的 main、exports 告诉模块加载器到哪里找入口;dsh.bundle.patch 告诉此版本 DSH 的插件安装流程使用哪份配置补丁。
本项目清单中包含:type: module、入口 ./index.mjs、补丁 ./cordis.patch.yml,以及对 @deepseek-ai/dsh-agent 的精确 peer 版本声明。包还设置了 private: true,因此当前流程是本地链接安装,不是可直接执行 npm publish 的公开包发布流程。
peer 被声明为 optional,不代表插件能脱离宿主运行。入口启动时还会检查真正宿主的版本。
配置补丁将 ID 为 ai-pet-bridge 的节点插入插件配置,包名为 @ai-desktop-pet/dsh-desktop-bridge。这三个名字要分清:
| 名称类型 | 当前值 |
|---|---|
| 包名 | @ai-desktop-pet/dsh-desktop-bridge |
| 插件导出名 | ai-pet-bridge |
| 补丁节点 ID | ai-pet-bridge |
开发自己的插件时,应选择自己的包名和节点 ID,避免与示例插件冲突。仅改目录名并不能完成插件身份变更。
3.3 服务注入和 apply 生命周期
实际入口导出插件名、inject 和异步 apply(ctx);其中 inject 包含 sessionController、agents。
两项服务分别承担:
sessionController:创建真实 DSH 会话、提交用户消息。agents:查找运行中的 Agent,在能够确认归属时请求取消。
ctx.on(...) 订阅宿主事件;ctx.effect(...) 注册随插件作用域结束执行的清理逻辑。本项目把注销监听、关闭服务、等待持久化、清理发现文件放进统一的 dispose 流程。
可以把插件生命周期理解成:
加载模块 → 检查依赖 → apply 初始化
→ 恢复状态 → 启动本机服务 → 发布连接信息
→ 持续响应请求和事件
→ 插件停用 / 宿主退出 → 清理资源
3.4 为什么要从宿主入口解析版本
安装过程中,包管理器可能为插件处理 peer 依赖。如果只读取插件附近的包版本,就可能“检查通过了一个副本”,却没有检查真正正在执行的宿主。
本项目通过 createRequire(process.argv[1]) 从 DSH 的宿主入口锚定解析 @deepseek-ai/dsh-agent 清单,再精确核对版本。安装脚本使用的 bootstrap 会将该入口指向内置 DSH CLI,这是本案例成立的环境前提。
这条经验可以推广:兼容性检查要检查实际执行环境,而不是碰巧能解析到的依赖副本。 不要直接删除版本判断来适配新版;先核对服务签名、事件结构、审批行为和取消语义。
四、怎么连接:随机端口、发现文件和本机鉴权
4.1 不写死端口,让操作系统分配
插件监听 127.0.0.1,端口传入 0,由系统分配可用端口。启动完成后才写入发现文件。
这样避免固定端口冲突,但客户端也就不能在设置里写死一个地址,而要先发现它。
发现文件位于 %APPDATA%\ai-desktop-pet-dsh\bridge.json,包含:
| 字段 | 含义 |
|---|---|
| protocol | 当前为 2 |
| pluginVersion | 当前为 0.2.0 |
| harnessVersion | 当前为 0.2.2-alpha.2 |
| capabilities | 聊天、工具、会话复用等协议能力声明 |
| port | 本次启动分配的端口 |
| token | 本次启动生成的 32 字节随机数的十六进制表示 |
| pid | 发布发现文件的进程 ID |
Token 每次插件启动重新生成,不写入任务状态文件。发现文件通过临时文件加 rename 发布,减少客户端读到半份 JSON 的概率。清理时先确认文件里的 token 仍属于自己,避免误删其他实例后来写入的描述文件。
这份实现使用单一发现路径和状态路径,不是多宿主实例协调协议。不要假定同时启动多个使用同一数据目录的桥接实例仍能可靠隔离。
4.2 一次连接的正确顺序
读取发现文件
→ 验证文件类型、大小、协议、版本、端口、Token 格式
→ 请求 http://127.0.0.1:<port>/status
→ 校验 capabilities 与 storageHealthy
→ 才向用户报告“已连接”
发现文件存在不等于桥接在线,pid 字段也不是可替代 HTTP 握手的身份证明。程序异常退出可能留下过期文件,必须以实际请求和响应校验为准。
真实客户端使用 lstat 拒绝符号链接,限制发现文件大小,验证协议、精确版本、Token 格式和端口范围;
4.3 为什么普通网页不能直接连
服务端要求:
- 仅监听回环地址。
- Host 必须精确为
127.0.0.1:<实际端口>。 - 仅允许一个 Authorization 请求头,并匹配
Bearer <token>。 - 任何携带 Origin 的请求都被拒绝。
- 请求体限制 64 KiB,并设置读取超时。
因此,这不是一个给任意浏览器页面开放的 CORS API。Electron 页面也不应该持有 Token,而应由主进程请求,再经有限 IPC 向页面暴露能力。
回环和 Token 主要降低误暴露与恶意网页访问风险,不构成抵抗同一账户下所有恶意进程的完整安全边界。源码使用的 POSIX mode 参数也不能等同于完整 Windows ACL 加固;发现文件仍需依赖正确的账户和目录权限保护。
4.4 先用 PowerShell 做只读连接检查
下面是教学用连接检查,不提交任务、不调用模型。它会读取敏感凭证到内存,但不会主动打印 Token。不要把完整发现文件作为故障截图发给别人。
$location = Join-Path $env:APPDATA 'ai-desktop-pet-dsh\bridge.json'
$bridge = Get-Content -LiteralPath $location -Raw | ConvertFrom-Json
if ($bridge.protocol -ne 2 -or $bridge.pluginVersion -ne '0.2.0' -or
$bridge.harnessVersion -ne '0.2.2-alpha.2') {
throw '桥接协议或版本不兼容'
}
if ($bridge.port -lt 1 -or $bridge.port -gt 65535 -or
$bridge.token -cnotmatch '^[0-9a-f]{64}$') {
throw '连接信息格式无效'
}
$headers = @{ Authorization = "Bearer $($bridge.token)" }
$status = Invoke-RestMethod -Method Get -TimeoutSec 5 `
-Uri "http://127.0.0.1:$($bridge.port)/status" -Headers $headers
$status | Select-Object protocol, pluginVersion, harnessVersion, storageHealthy
正式客户端还需执行前述文件类型、大小和能力检查,不能把这段诊断命令当作完整安全实现。
五、怎么提交和响应:把 HTTP 请求变成持久任务
5.1 先定义任务协议,而不是直接返回模型结果
桥接一共提供四个接口:
| 方法 | 路径 | 用途 |
|---|---|---|
| GET | /status | 读取版本、能力、存储健康和任务列表 |
| POST | /tasks | 创建任务,或读取相同 ID 的已有任务 |
| GET | /tasks/:id | 读取一个任务 |
| POST | /tasks/:id/cancel | 请求取消任务 |
只有创建任务的接口接受请求体,取消请求应为空体。
创建参数如下:
| 参数 | 必填 | 约束与含义 |
|---|---|---|
| id | 是 | UUID,由客户端生成并保留;作为幂等键 |
| text | 是 | 非空,最多 8000 个 JavaScript 字符单位 |
| cwd | 是 | 已存在、可访问的绝对目录 |
| clientId | 否 | 非空,最多 160;建议使用稳定的客户端标识 |
| characterId | 否 | 非空,最多 160;角色标识 |
| persona | 否 | 最多 8000;角色风格上下文 |
| purpose | 否 | chat 或 task,默认 task |
缺省 clientId 为 legacy。正式聊天客户端应始终提供稳定且独立的 clientId 和角色 ID,否则不同调用方可能共享缺省聊天绑定。
任务里同时有 admission 与 status:
admission:pending、submitting、accepted,描述向宿主提交的阶段。status:starting、running、approval、cancelling、completed、cancelled、failed、unknown,描述任务执行或确认状态。
accepted 不是 completed,HTTP 202 也不是模型已经执行成功。
5.2 服务端受理顺序为什么重要
当前实现的核心顺序是:
校验请求与目录
→ 写入 tasks 和 seen
→ 持久化
→ 返回 HTTP 202,并启动后续执行
→ 创建或取得真实 sessionId
→ 持久化 sessionId
→ 持久化 submitting 状态
→ 调用 DSH prompt
→ 记录 accepted
代码中先注册 launch,再等待 ready 返回,这样取消和卸载流程能够等待到已经登记的启动工作。文件系统校验存在 await,因此在其后再次检查并发占用,防止两个并发请求都通过早先的空闲判断。
这不是“先执行,成功后再随便记一下日志”。工具可能写文件或访问外部系统,先落盘再提交能够降低进程崩溃后丢失任务归属的概率。
5.3 幂等不等于无限重试
| 情况 | 返回 |
|---|---|
| 新任务受理 | 202 + 任务快照 |
| 相同 ID 仍在任务表 | 200 + 原任务,不重新执行、不更新原参数 |
| ID 曾经用过,但任务快照已被裁剪 | 410,禁止复用 |
| 已有活动任务或 unknown | 409 |
| 持久存储失败 | 503,拒绝新任务 |
相同 ID 的检查在全局占用判断之前,因此服务端仍可返回已有快照。但这不意味着客户端应该在任何错误后自动 POST。
本项目客户端策略更保守:提交前写入本地 attempts 账本;回执丢失后只读对账,不自动重放。当前页面的“只读核对原任务”实际刷新状态,不重新提交。尤其不能在超时后生成一个新 UUID 再次执行同一需求。
5.4 一段可运行的外部客户端教学示例
下面是独立教学示例,不是仓库现有客户端的替代实现。将其保存为自己测试目录中的 .mjs 文件,用支持相关 API 的现代 Node.js 运行。它只提交一次,之后只做 GET;运行前确保 DSH 和插件在线,传入已存在的测试目录。
它简化了生产客户端的本地加密账本、文件安全检查、响应大小限制及重启恢复,因此不应直接用于正式分发。
import http from 'node:http';
import path from 'node:path';
import { readFile, realpath, stat } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { setTimeout as delay } from 'node:timers/promises';
const appData = process.env.APPDATA;
if (!appData || !process.argv[2]) {
throw new Error('用法:node demo.mjs "已存在的绝对测试目录"');
}
if (!path.isAbsolute(process.argv[2])) throw new Error('必须传入绝对目录');
const cwd = await realpath(process.argv[2]);
if (!(await stat(cwd)).isDirectory()) throw new Error('工作目录不是目录');
const location = path.join(appData, 'ai-desktop-pet-dsh', 'bridge.json');
async function discover() {
const config = JSON.parse(await readFile(location, 'utf8'));
if (config.protocol !== 2 || config.pluginVersion !== '0.2.0' ||
config.harnessVersion !== '0.2.2-alpha.2' ||
!Number.isInteger(config.port) || config.port < 1 || config.port > 65535 ||
typeof config.token !== 'string' || !/^[0-9a-f]{64}$/.test(config.token)) {
throw new Error('不兼容的桥接配置');
}
return config;
}
async function request(route, method = 'GET', body) {
// 每次重新发现,避免一直使用重启前的端口和 Token。
const config = await discover();
const payload = body === undefined ? undefined : JSON.stringify(body);
return new Promise((resolve, reject) => {
const req = http.request({
hostname: '127.0.0.1', port: config.port, path: route, method,
headers: {
Authorization: `Bearer ${config.token}`,
...(payload === undefined ? {} : {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload),
}),
},
}, res => {
const chunks = [];
res.on('data', chunk => chunks.push(chunk));
res.on('error', reject);
res.on('aborted', () => reject(new Error('响应中断')));
res.on('end', () => {
try {
const value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
if (res.statusCode < 200 || res.statusCode >= 300) {
reject(new Error(`HTTP ${res.statusCode}: ${value.error ?? '请求失败'}`));
} else resolve(value);
} catch (error) { reject(error); }
});
});
// 绝对超时,不因分块响应持续到来而无限等待。
const timer = setTimeout(() => req.destroy(new Error('请求超时')), 5000);
req.on('close', () => clearTimeout(timer));
req.on('error', reject);
req.end(payload);
});
}
const status = await request('/status');
if (status.protocol !== 2 || status.pluginVersion !== '0.2.0' ||
status.harnessVersion !== '0.2.2-alpha.2' || !status.storageHealthy ||
status.capabilities?.correlation !== 'rpcId-turn' ||
status.capabilities?.recovery !== 'unknown-no-replay') {
throw new Error('桥接状态不兼容或存储不健康');
}
const id = randomUUID();
console.log('请保留本次任务 ID:', id);
let task;
try {
task = await request('/tasks', 'POST', {
id, cwd, purpose: 'task', clientId: 'blog-demo',
text: '在当前目录生成 hello.md,写入测试文字,并使用 present 工具交付。不要修改其他文件。',
});
} catch (error) {
console.error(`提交回执未确认,请保留 ${id},只读核对 DSH,不要自动重发。`);
throw error;
}
const stop = new Set(['completed', 'cancelled', 'failed', 'unknown']);
for (;;) {
console.log(task.status, task.progress ?? '');
if (stop.has(task.status)) break;
await delay(1000);
// 读取失败时直接结束本示例;不会重新提交任务。
task = await request(`/tasks/${id}`);
}
console.log(task.answer || task.error || '暂无文字结果');
if (task.status === 'unknown') console.log('必须到 DSH 核对真实执行状态。');
执行示例:
node ".\demo.mjs" "D:\DshWorkspace"
该目录须事先存在。示例会真实调用 DSH 的模型及工具,可能产生费用;审批仍在 DSH 内完成。正式实现还应在第一次提交前持久保存 ID 和请求参数,而不是只把 ID 打印到终端。
六、怎么驱动 DSH:会话、prompt 和角色上下文
桥接在没有可复用会话时,调用宿主的 sessionController.create({ cwd }),取得返回的真实 sessionId。随后调用 sessionController.prompt(...),传入任务 UUID 作为 requestId、真实 sessionId、mode: queue、文本 content 和时区。
关键不是记住几个字段,而是理解语义:
- 不要自己编造 sessionId。 应持久保存 create 返回的 ID。
- 复用会话就直接 prompt。 不是再次 create 并假定宿主会理解为复用。
- 排队受理与轮次执行不同。 prompt 的提交阶段结束,不代表 Agent 已完成。
- 角色说明只是用户上下文。 桥接明确提示 persona 不覆盖 DSH 权限和安全规则,但文本提示本身也不是权限隔离机制。
6.1 聊天为什么能记住前文
聊天绑定键是以下三项的 JSON 编码:
[clientId, characterId, realpath 后转小写的 cwd]
purpose: chat 会查询并保存这个键对应的真实会话;purpose: task 默认不走该聊天绑定,每次另建会话。
这意味着角色 A 与角色 B 可以隔离上下文;更换工作目录可以切换聊天上下文;同一客户端、角色和目录可以继续原会话。persona 不参与绑定键,修改角色风格不会自动清空已有 DSH 上下文。
这里的路径小写处理属于 Windows 定向实现,不应原样当作跨平台路径规范。绑定到的 DSH 会话若被外部删除,本版也没有自动验证并重建聊天绑定的恢复接口。
七、怎么接收响应:事件订阅不难,归属判断才难
7.1 会话相同不代表回复属于这次任务
同一个 DSH 会话中,可能同时出现:
- 桌宠提交的请求;
- 用户在 DSH 原生界面输入的新消息;
- 排队中的其他消息;
- 多个执行轮次。
如果仅按 sessionId 收集回复,就可能把另一条输入的结果显示到桌宠。
桥接使用三层关联:
sessionId → user/message 的 source.rpcId → turn
事件处理首先找出同会话的活动任务。遇到 turn/start 记录 currentTurn;遇到用户消息,只有 source.kind === user 且 source.rpcId === task.id 才认领当前轮次。之后只处理属于该轮次的回复、工具、审批和交付事件。
同轮若混入其他用户输入,就标记 unknown,而不是猜测哪些结果属于桌宠。
7.2 事件到任务状态的映射
| 宿主事件 | 桥接动作 |
|---|---|
| turn/start | 记录当前轮次,重置其他输入标记 |
| user/message | 用 rpcId 认领任务轮次或发现输入冲突 |
| assistant/message | 提取 text 内容,追加 answer |
| tool/call | 更新工具执行进展文字 |
| approval/asked | 记录审批 ID,进入 approval 或维持 cancelling |
| approval/decided | 移除审批 ID,按剩余审批和取消标记更新状态 |
| deliverables/presented | 验证并登记交付文件 |
| turn/end | 根据结束原因进入 completed、cancelled 或 failed |
| agent/error | 对能确认归属的轮次记录错误,等待结束 |
文字结果是消息级累积快照,不是逐 Token 推流。当前服务端答案保留上限为 16000 个 JavaScript 字符单位;交付文件最多 50 个。
7.3 为什么事件也要排队
文件验证和持久化都包含异步操作。若让每个事件并行修改任务,就可能出现结束状态先落盘、较早的交付事件随后才覆盖状态等顺序问题。
当前实现为每个任务维护 entry.events Promise 链,按顺序处理事件,并在每次处理后持久化。全局再用 writes 链串行写状态文件。
这两层队列解决的是不同问题:
- 任务事件队列保证状态转换顺序。
- 持久化队列避免并发写入相互覆盖。
八、审批、取消与 unknown:不要承诺无法证明的状态
8.1 审批只提示,不代替用户决定
桥接没有批准或拒绝审批的 HTTP 路由。外部界面看到 approval 后,提示用户去 DSH 对应 sessionId 处理。
这样保留了 DSH 原有审批界面和权限决策,不把“方便集成”变成绕过授权。
8.2 取消请求并不等于停止成功
取消流程会设置 cancelRequested、进入 cancelling、中止提交阶段的 signal,然后等待启动和已有事件处理。
只有能确认 Agent 当前运行轮次属于该任务,才调用取消,并带上保留 inbox 的选项,避免清掉会话里其他排队工作。无法安全关联时,要求用户在 DSH 内处理。
值得特别注意:实现发出停止请求后通常会标记 unknown,并不保证每次取消都会最终显示 cancelled;若在进入 prompt 前就确认取消,则可以直接结束为 cancelled。不能为了界面好看,把取消接口返回 202 翻译成“已经停止”。
8.3 unknown 是不确定性,不是成功,也不是普通失败
典型来源包括:
- 进程重启,旧任务尚未确认结束;
- prompt 已尝试提交,但回执状态无法确认;
- 同轮混入其他用户输入;
- 取消不能确认最终执行结果;
- 客户端提交回执丢失、无法完成对账。
服务端把 unknown 当作不再继续自动处理的终态,但同时把它作为全局新任务的阻塞条件。客户端的“安全完成终态”只包含 completed、cancelled、failed,因此 unknown 不能像普通历史记录一样删除。
当前策略是 unknown-no-replay:宁可要求人工核对,也不自动重复执行可能具有副作用的工具。
九、交付文件怎么联动:不要把模型文本当文件凭证
模型说“文件已经生成”,不代表文件真实存在,更不代表能安全打开。
桥接要求 DSH 使用 present 工具交付,从 deliverables/presented 事件读取文件清单,然后:
- 相对路径基于任务 cwd 解析。
- realpath 解析真实路径。
- 检查结果确实位于 cwd 内,不是目录外路径。
- 确认是普通文件,排除目录和重复项。
- 生成 artifactId,记录名称、描述和真实路径。
Electron 客户端拿到后,不把真实路径暴露给页面。页面请求打开文件时只传 taskId 和 artifactId;主进程重新解析工作目录和文件路径,再核验存在性、归属及是否变化,最后调用 shell.openPath。
这形成两次检查:登记时检查一次,打开时再检查一次。 它降低路径替换风险,但不应被描述为抵抗同账户恶意进程的完整、无竞态文件沙箱。打开 HTML、脚本或可执行类文件也有各自风险,用户仍应信任产物来源。
另一个边界同样重要:桥接对“可登记交付文件”做了目录约束,但 cwd 本身不是对 DSH 所有工具的额外沙箱。实际工具访问权限由 DSH 负责。
十、Electron 和 Vue 怎么联动
10.1 主进程才拥有连接凭证
本项目客户端集中处理:
- 读取发现文件和请求桥接;
- 校验协议与能力,限制响应最多 2 MiB;
- 保存稳定 clientId、工作目录授权、尝试账本和任务快照;
- 使用 Electron safeStorage 加密本地账本;
- 轮询、广播、核验并打开产物;
- 应用退出时结束客户端请求,但不自动取消 DSH 中的任务。
工作目录授权会加密保存,正常重启无需每次重新选择。若安全存储不可用或账本损坏,客户端拒绝继续安全提交,而不是静默丢弃历史后重建身份。
10.2 preload 暴露业务方法,不暴露任意 IPC
当前 preload 暴露的是 getDshState、chooseDshWorkspace、submitDshTask、cancelDshTask、openDshArtifact、onDshState 等有限方法,不是任意 channel、URL 或本地文件路径执行器。
主进程还检查调用窗口、主 frame、页面来源及参数数量,并限制目录授权、插件管理和记录清理只能由设置窗口调用。
不要只做一个 contextBridge 就认为 IPC 已安全;主进程仍然需要验证来源和参数。
10.3 Vue 页面只处理交互和展示
实际任务页面采用 Composition API,在 mounted 时订阅状态并读取初始快照,在 beforeUnmount 时取消订阅。用 revision 防止较早异步请求的结果覆盖已经到达的新广播,用 disposed 防止卸载后继续更新页面。
可提交条件不是一个简单的“已连接”:
已加载状态
且已连接
且工作目录已授权
且没有正在执行的页面命令
且没有活动或待核对任务
且服务端没有 unknown 阻塞
且输入有效
不确定提交保留原 UUID,按钮转为只读核对。结果使用纯文本展示,不自动打开模型返回的链接,也不使用 v-html 渲染未信任内容。
10.4 一套任务如何同时驱动聊天和任务页
开启 DSH 聊天后,聊天入口把角色 ID、角色风格和用户文本交给 dshClient.chat,返回关联任务 ID。客户端每次对账后调用 onTasks,主进程再触发聊天记录同步。
聊天同步使用 taskId 找到已有消息对:存在就更新,不存在才插入,从而避免每次轮询都增加一条“新回复”。记录中保留 sessionId、requestId、turn、admission、status,方便核对来源。
DSH 模式下,当前项目还暂停主动联网碎碎念,防止无人输入时触发工具;断线不会偷偷切回角色原 API。关闭 DSH 开关只影响后续路由,不取消已经提交的工作。
十一、持久化与管理:三个位置,三类数据
| 数据 | 位置 | 管理注意事项 |
|---|---|---|
| 插件程序副本 | %LOCALAPPDATA%\ai-desktop-pet\dsh-desktop-bridge | 被 link: 引用,安装后不要直接移动 |
| 发现文件 | %APPDATA%\ai-desktop-pet-dsh\bridge.json | 含本次 Token,不分享、不截图 |
| 服务端状态 | %APPDATA%\ai-desktop-pet-dsh\state.json | 含任务文本、角色上下文、文件路径、seen 和绑定;敏感但非 Token 存储 |
| 客户端账本 | Electron userData 下的 dsh-client.enc | safeStorage 加密保存授权、身份和本地任务记录 |
11.1 服务端状态保存的不只是任务列表
状态文件保存 protocol、tasks、seen、bindings。
- tasks:当前保留的任务快照。
- seen:已使用过的任务 UUID,防止被裁剪任务重新执行。
- bindings:聊天绑定键到真实 sessionId 的映射。
持久化使用串行写队列、独占创建临时文件、同步文件内容后 rename。它降低半写和覆盖风险,但不能把它说成跨多个文件系统和进程的完整事务系统。
任务裁剪目标约为 20 条:仅在超限时移除可裁剪的已结束快照,unknown 保留。seen 和聊天绑定没有与任务快照相同的自动裁剪机制,因此“最多约 20 条任务”不等于整个状态文件有严格容量上限。
11.2 删除记录到底删了什么
桌宠的删除记录功能只是将 completed、cancelled、failed 任务加入本地隐藏账本,并清理相应本地尝试记录。刷新和重启后继续隐藏。
它不会:
- 取消运行中的任务;
- 删除生成的文件;
- 删除 DSH 原生会话;
- 清空 DSH 聊天上下文;
- 删除服务端永久去重 ID;
- 将 unknown 强制当作已完成。
因此,“清理桌宠历史”“重置 DSH 会话”“删除交付文件”和“卸载插件”是不同管理动作,不应只用一个删除按钮混在一起。
11.3 unknown 如何恢复
本版没有确认或清除 unknown 的 HTTP 管理接口,也没有自动调用历史 API 证明任务已经停止。
建议按以下顺序处理:
- 记录 taskId、sessionId、工作目录与错误。
- 在 DSH 原生会话核对是否仍执行、有无排队和实际副作用。
- 必要时在 DSH 里完成审批或取消,确认没有相关工作继续运行。
- 完全退出 DSH,备份服务端状态文件。
- 由了解该协议的维护者,将已核实任务的 status 改成真实对应的 completed、cancelled 或 failed,并按需要同步说明字段;保留原 ID、seen 和 bindings。
- 重启后先只读刷新验证。若客户端仍残留本地 unknown,应继续核对客户端账本,不要假定改服务端一处就一定解除所有阻塞。
这属于受控的人工维护,不是推荐给普通用户随手编辑 JSON 的常规操作。不要直接删除状态文件解除阻塞,否则会丢失去重和会话绑定,可能掩盖仍在运行的工作。
十二、怎么安装:复用 DSH 自带运行时
12.1 安装前准备
- 安装匹配版本 DSH Desktop。
- 启动 DSH 完成首次初始化和模型、必要插件配置,先确保 DSH 自己能正常执行。
- 从系统托盘完全退出 DSH,不只是关闭窗口。
- 解压完整插件包到可写目录,确认包含四个关键文件。
- 阅读并确认脚本来自可信分发者。
终端用户不需要额外安装 Node.js、npm 或 pnpm。上面的教学客户端另需 Node.js,但那不是安装插件的前提。
12.2 默认安装命令
在包含桥接文件夹的 integrations 目录中打开 PowerShell:
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\dsh-desktop-bridge\install.ps1"
Bypass 仅用于本次子进程,不永久改变系统执行策略。组织策略不允许时应联系管理员,不要强行绕过。通常无需管理员权限。
自定义 DSH 安装路径:
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\dsh-desktop-bridge\install.ps1" -DshPath "E:\Apps\DSH Desktop"
同时自定义 Harness 和持久插件目录:
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\dsh-desktop-bridge\install.ps1" -DshPath "E:\Apps\DSH Desktop" -DshHome "E:\DSHData\harness" -InstallDirectory "D:\DesktopPet\bridge"
DshPath 指向包含 exe 与 resources 的目录;DshHome 指向实际已经初始化、包含 web profile 的 Harness 数据目录,不能创建一个空目录冒充。
12.3 安装脚本内部发生了什么
- 检查 DSH 是否仍运行,发现运行则停止安装,不自动杀进程。
- 查找 DSH 可执行文件、bootstrap 和 app.asar。
- 检查已初始化的 web profile。
- 临时设置 ELECTRON_RUN_AS_NODE,让自带 Electron 作为 Node 运行时执行安装逻辑。
- 设置 DSH_HOME,以及宿主内置 pnpm 入口和 runner。
- 分别验证 Desktop 0.13.2 和 Harness 0.2.2-alpha.2。
- 复制四个分发文件到持久安装目录,不复制用户凭证。
- 经内置 bootstrap 和 CLI 执行 plugin、–profile web、add、link:安装地址。
- 检查退出码,并恢复原有进程环境变量。
该版本脚本选择 link:,是为了避开当时 pnpm 10 的 file: 跨盘路径处理问题。这是本环境的工程选择,不是所有版本永远只能用 link:。
安装不会直接修改打包的 app.asar,也不需要手动解包替换 DSH 程序。安装可能触发依赖检查或联网,但不会因此提交模型任务。
12.4 怎么确认真正成功
安装输出应包含版本检测及最终成功提示:
Detected Desktop 0.13.2; Harness 0.2.2-alpha.2
Bridge added to web profile. Restart DSH Desktop. Handle approvals only in the DSH UI.
出现包管理器 dependencies 输出不等于成功,最终退出码和成功提示都要核对。
随后:重启 DSH → 启动支持桥接的桌宠 → 进入 DSH 设置 → 刷新连接 → 首次授权工作目录。
连接是同一 Windows 账户下的本地自动发现,不需要填写端口、Token 或模型密钥,也不要开放公网端口。自定义安装位置时,桌宠“打开 DSH”按钮可能找不到默认 exe,用开始菜单手动启动即可,不影响桥接连接。
十三、怎么更新、迁移和卸载
13.1 更新不是下载完就完成
桌宠当前的插件更新流程下载 ZIP、检查长度和 ZIP 基础标识,然后打开压缩包;它不会自动解压、安装或启用插件。基础格式校验也不等于发行者签名验证,仍需可信下载来源。
正确更新流程:
- 等待任务结束,处理所有待核对状态。
- 完全退出 DSH。
- 使用可信的新版本完整插件目录,重新执行安装脚本,更新持久副本。
- 自定义安装目录时继续使用相同 InstallDirectory。
- 重启并检查协议、版本、连接、任务执行和审批。
DSH 升级而桥接不兼容时,应等待或开发对应适配版,不要单独改一个版本字符串。
13.2 哪个目录可以移动
安装脚本把解压来源复制到持久目录,再链接持久副本,因此安装成功后可以移动原始解压目录。
不能直接移动正在被 link: 引用的持久副本。 要迁移它,应先在插件管理中移除桥接,完全退出宿主,再使用新 InstallDirectory 重新安装。服务端用户数据独立存在,不会随程序目录自动迁移。
13.3 停用与卸载
优先使用 DSH 插件管理界面停用或卸载该包;若当前界面无法完成,先查看对应版本 CLI 的实际帮助,再采用该版本支持的管理命令。本文只验证到安装脚本中的 add 调用,不编造通用 remove、disable、list 命令。
正确顺序是:确认任务结束 → 在宿主中停用/卸载 → 完全退出宿主 → 再按需清理不再引用的插件副本。用户状态、生成文件与 DSH 原生会话应单独决定是否保留,不能为了卸载桥接就删除整个 profile 或模型配置。
十四、从这个案例写出自己的插件
建议按以下顺序实现,不要一开始就复制整个复杂桥接:
第一步:确定插件的职责
先回答:是宿主内部扩展、模型工具,还是跨应用桥接?如果只是监听内部事件,不一定需要 HTTP;如果只是提供工具,则还需核对目标版本的工具注册接口,本案例不覆盖该接口。
第二步:建立可加载插件包
选择独立包名和节点 ID,声明 ESM 入口、对应 bundle patch 和已验证的宿主版本。先通过正式插件安装流程验证加载与停用,再增加业务逻辑。
第三步:只注入需要的服务
先完成创建会话、提交一条消息、收取对应事件的最小闭环,保存宿主返回的真实 ID。不要在未知版本上猜测服务签名。
第四步:定义外部协议
决定连接发现、认证、参数上限、任务 ID、错误码、状态机和产物格式。跨应用接口应暴露业务动作,不要暴露任意 shell、任意文件打开或任意宿主方法调用。
第五步:先处理归属和故障,再做漂亮界面
至少考虑:相同 ID 重复请求、提交回执丢失、排队尚未执行、同会话人工输入、审批挂起、取消不确定、宿主重启和存储失败。
第六步:完成客户端管理闭环
记录授权与提交账本,区分连接中断与任务停止,给出只读核对入口,说明删除记录和卸载的真实含义。最后再接入 Vue、托盘或其他业务界面。
开发自己的桥接时还应调整发现目录、状态目录及版本声明,避免与本插件相互覆盖。若修改协议而保留原版本号,会让客户端错误地把不兼容服务视为兼容;协议、能力和客户端校验必须一起管理。
十五、验证清单:证明“能运行”,也证明“不会乱执行”
建议在独立测试工作目录中验证,涉及真实模型与工具时可能收费。
| 场景 | 应核对的结果 |
|---|---|
| 不匹配宿主版本 | 安装或加载明确拒绝,不悄悄放行 |
| Token 错误 | 401 |
| 使用 localhost 而非精确回环 Host | 403 |
| 请求携带 Origin | 403 |
| 非法 UUID、相对目录或超限请求体 | 400 或 413,按具体条件返回 |
| 相同 UUID 再次请求 | 返回原快照,不二次执行、不覆盖文本 |
| 活动任务期间发新 ID | 409 |
| 提交后断开客户端 | 不自动取消,不创建新任务补发 |
| DSH 重启时存在未结任务 | unknown,不自动重放 |
| 同轮混入原生 UI 输入 | 无法独立归属时转 unknown |
| 需要工具审批 | 外部提示,DSH 内实际决定 |
| 取消正在执行的任务 | 不误停其他轮次,不误报必然 cancelled |
| 交付目录外或不存在文件 | 不登记为可打开产物 |
| 登记后文件被移动或替换 | 打开前再次核验并按情况拒绝 |
| 删除完成记录 | 本地隐藏,刷新和重启后保持;文件与原生会话仍在 |
| 首次授权后正常重启桌宠 | 加密授权和稳定 clientId 能恢复 |
| 切换页面、关闭设置窗口 | 无遗留页面监听,不取消已提交任务 |
| 下载插件 ZIP | 仅下载并打开,不误报已安装 |
不要只验证一次 hello 任务。跨应用桥接的质量通常取决于断线、取消、重启和重复请求时的行为。
十六、常见问题速查
| 问题 | 优先检查 |
|---|---|
| 提示 DSH is running | 从托盘完全退出,别强行中止执行中的任务 |
| 找不到运行时 | DshPath 是否是安装目录,文件是否完整 |
| 找不到 initialized web profile | 是否完成过首次启动;DshHome 是否正确 |
| 版本校验失败 | Desktop 与 Harness 是两项版本,分别核对 |
| 安装成功但连不上 | 是否重启、同一账户、持久副本是否还在、发现文件是否由当前实例发布 |
| 有发现文件但 HTTP 失败 | 可能是过期描述或进程离线,重新启动并只读握手 |
| 网页 fetch 返回 403 | 该桥接拒绝 Origin,应经受控的本机后端/主进程对接 |
| 一直 waiting approval | 去对应 DSH 会话审批,不是在外部页面自动通过 |
| completed 但没有产物 | 模型是否真正使用 present 交付了目录内文件 |
| 409 且界面看不到活动任务 | 可能其他客户端占用,或存在服务端 unknown;本地列表有客户端过滤 |
| 删掉记录却还记得前文 | 本地记录清理不等于重置 DSH 原生会话 |
| 超时后不知道执行了没有 | 保留原 taskId,去 DSH 核对,禁止新建 ID 自动重跑 |
反馈问题时提供版本、任务 ID、会话 ID、脱敏日志和错误现象即可。不要上传发现文件、模型密钥、完整用户状态或加密账本。
结语:插件开发的核心是边界,而不只是 API
这个案例最值得带走的,不是“如何启动一个本机 HTTP 服务”,而是六条工程原则:
- 加载有依据:通过插件清单和 profile 安装,不修改宿主程序包。
- 连接有边界:随机回环端口、发现文件、Token 和严格校验。
- 执行有身份:任务 UUID、真实 sessionId、rpcId 和 turn 各司其职。
- 状态有证据:受理不是完成,断线不是停止,取消请求不是取消成功。
- 联动有分工:DSH 管执行与审批,主进程管授权与凭证,Vue 管交互和展示。
- 管理不撒谎:隐藏记录不是删除会话,下载不是安装,unknown 不能靠重试掩盖。
当这些边界清楚之后,把桌宠换成管理系统、设备客户端或其他桌面工具,桥接思路依然成立;但如果希望扩展为远程访问、多客户端并发或自动恢复,就需要重新设计相应协议和安全机制,而不只是放宽一个监听地址。
如果对您有所帮助,欢迎您点个关注,我会定时更新技术文档,大家一起讨论学习,一起进步。
更多推荐



所有评论(0)