1. 下篇要加什么
1.1 产品形态
上篇做完后,DevMind 已经是一个完整的对话应用:会话列表、按轮显示问答、审批、编辑重跑、停止后继续。但 AI 能用的工具都在 Server 上(加法、查时间、读一段内置文本),它看不到用户电脑上的任何东西。
下篇让 AI 能读用户电脑上的项目:
- 用户问“列出我工作区根目录的文件,再读一下 README.md”,AI 先调用
workspace_list_files,再调用workspace_read_file,拿到内容后回答。 - 列文件只返回文件名,直接执行;读文件会把文件内容交给模型,所以 Desktop 会弹一个确认框,用户点“允许一次”才读。
- 只能访问一个事先指定的目录(工作区)。绝对路径、
../跳出去、指向外面的符号链接、.env、.git、私钥这类敏感文件,在弹确认框之前就直接拒绝,不打扰用户。 - 页面顶部显示“本机工具:已连接 · 工作区 /Users/…”,用户知道 AI 这次能不能用本机工具。
关键的一点:Agent 跑在 Server 上,文件在用户电脑上。 Server 要调用 Desktop 上的工具,得有一条 Server 能主动发消息给 Desktop 的通道。这条通道就是本篇的 WSS。它不是一个“远程 Shell”:Server 只能调用 Desktop 白名单里的结构化工具,参数要过 Schema 校验,路径要过工作区检查,内容进模型之前要用户点头,同一个调用无论收到几次只执行一次,断线后还能对上账。
本篇只做两个只读工具:workspace.list_files(列目录)和 workspace.read_file(读文本文件)。写文件、执行命令这类有副作用的工具放到 v0.2 以后,到时候还要再叠加上篇的审批中断。
1.2 用户能看到、能做的操作
| 操作 | 会发生什么 | 规则 |
|---|---|---|
| 打开 Desktop | 页面顶部显示“本机工具:连接中 → 已连接” | Desktop 主动连 Server,不开放任何端口;断了会自动重连 |
| 让 AI 列出工作区的文件 | 直接执行,工具卡片显示结果 | list_files 是 LOW 风险,不需要确认;.env、.git 这类名字不会出现在结果里 |
| 让 AI 读一个文件 | 弹出确认框,显示工具名和完整参数 | read_file 是 MEDIUM 风险,必须确认;只能点“允许一次”或“拒绝”,不能改参数 |
| 在确认框里点拒绝,或者 60 秒不理 | 不读文件;AI 收到“用户拒绝”,自己决定怎么跟用户说 | 超时按拒绝处理 |
| AI 想读工作区外面的文件或敏感文件 | 不弹确认框,直接拒绝 | 越界检查在确认之前,不用让用户判断一个本来就不允许的请求 |
| 确认框还在时点“停止” | 确认框立刻关闭,这一轮标为“已停止” | 和上篇的停止一样,后台的模型和工具都停下 |
| 确认后、结果传回之前断网或 Server 重启 | 结果先存在 Desktop 本地,重新连上后补发,Server 对上账 | Server 不会自动重新执行这个调用 |
| 没开 Desktop,或者在浏览器里单独调试页面 | 照常问答;AI 调用本机工具会收到“没有绑定设备”或“设备离线” | 没有本机工具也能用 |
| 同一台电脑开了两个 Desktop | 后连上的那个生效,先连的那个显示“已在另一个窗口连接”,不再重连 | 避免两个实例互相抢连接 |
几个词,文档里统一这样用:
- 设备:一台电脑上装的一份 Desktop。第一次启动时生成
device_id,存在本机,之后不变。 - 工作区:本机工具唯一能访问的目录,开发期写在
.env里。 - Ticket:Desktop 建立 WSS 连接用的一次性门票,有效期 60 秒,用一次就作废。
- 工具调用:模型的一次“我要调用某个工具”的意图,对应 Server 上的一行
tool_call。 - 下发:Server 把一个工具调用发给 Desktop 一次,对应一行
tool_execution。同一个工具调用可能因为超时、断线被下发不止一次,但 Desktop 只执行一次。 - 确认:Desktop 本地弹框问用户“允许读这个文件吗”。注意和上篇的审批区分:审批是 Agent 图停下来、在对话里显示卡片,属于 Server;确认是工具执行前在本机弹框,属于 Desktop。本篇的两个只读工具只需要确认,不需要审批。
2. 页面上多了什么
2.1 界面
下图是 Desktop 的界面,红框是本篇新加的部分:
- 顶部状态:设备是否连上了 Server,工作区是哪个目录。鼠标移上去能看到设备 ID。
- 工具卡片:上篇已经有了,但当时工具结果其实传不到前端(原因见 7.3 节),卡片一直显示“执行中”。本篇修好后,执行完会显示“已完成”和结果。
- 确认框:Main 进程收到需要确认的调用时弹出。它不属于某个会话,叠在整个页面上面。
2.2 Desktop 代码的结构
上篇只写了 Renderer。本篇的主要代码在 Electron 的 Main 进程里:
文件:apps/devmind-desktop/src/
main/
├── index.ts Electron 入口:创建窗口,启动和停止 Desktop Runtime
├── env.d.ts Main 进程能读到的 MAIN_VITE_ 环境变量的类型
└── desktop/ 本篇新增:本机工具的全部逻辑
├── runtime.ts 装配入口:读配置 → 工具注册表 → 执行记录 → 确认管理器 → WSS 客户端
├── config.ts 读 MAIN_VITE_ 配置;首次启动生成设备 ID
├── wss-client.ts 申请 Ticket、建立 WSS、处理工具调用、补发结果、心跳和重连
├── protocol.ts WSS 消息的 Zod 校验和类型;本机工具的类型 LocalTool
├── tools.ts 工具注册表(白名单):list_files、read_file
├── path-policy.ts 路径策略:把相对路径解析成工作区内的真实路径
├── validation.ts 用 Ajv 按 JSON Schema 校验参数;稳定序列化
├── execution-store.ts 本地执行记录:按 tool_call_id 保证只执行一次,结果保留到 Server 确认
├── confirmation-manager.ts 确认框:把请求发给 Renderer,等用户回答
└── __tests__/ vitest 单元测试
preload/
└── index.ts 只把几个受控动作暴露给页面:读设备信息、订阅确认请求、回答确认
renderer/src/
├── desktop/
│ ├── ToolConfirmationDialog.tsx 确认框
│ └── desktop-runtime.ts 读设备 ID、订阅连接状态;浏览器里调试时没有本机工具
├── hooks/useTurnRunner.ts 提问时带上 device_id(上篇改)
└── agent/devmind-api.ts createTurn() 多一个 deviceId 参数(上篇改)
为什么放在 Main 而不是 Renderer:Renderer 是加载网页内容的地方,安全边界和浏览器差不多,不应该拿到文件系统权限;Main 是 Node.js 进程,可以读文件、持有设备身份、写执行记录。Renderer 只能通过 preload 暴露的几个函数回答“允许”或“拒绝”,碰不到 fs,也碰不到 WSS 连接。
3. 新加的东西,在后端是什么
3.1 对照表
| 看到的 / 用到的 | 存在哪 | ID | 说明 |
|---|---|---|---|
| 这台电脑上的 Desktop | Desktop 本地 userData/device.json | device_id | 第一次启动生成,提问时随 POST /api/turns 发给 Server,写进 agent_run.device_id |
| 一张连接门票 | Server 进程内存(已用过的 jti) | JWT 的 jti | 60 秒有效,只能用一次 |
| 一条 WSS 连接 | Server 进程内存 ConnectionManager | connection_id | 重连一次就是一条新连接;Server 重启后全部消失 |
| 模型的一次工具调用 | tool_call 表 | tool_call_id | 新表。保存参数、状态、结果,是 Server 侧的审计事实 |
| 一次下发 | tool_execution 表 | id,加 attempt_no | 新表。超时或断线后再下发一次,就多一行 |
| Desktop 上的执行记录 | Desktop 本地 desktop-tool-executions.json | tool_call_id | 保证同一个调用只执行一次;结果留到 Server 确认收到为止 |
| 确认框 | Main 进程内存 | confirmationId | 只标识这一次弹框,不代替 tool_call_id |
和上篇的表接在一起:
执行 run(上篇) agent_run.device_id 决定这次执行能调用哪台电脑
└── 工具调用 tool_call 模型的一个意图(0:N)
└── 下发 tool_execution 发给 Desktop 一次(1:N),通常只有 1 次
3.2 为什么“工具调用”和“下发”要分开
和上篇“轮”与“执行”分开是同一个道理:用户关心的是“AI 读了 README.md,结果是什么”,而后台可能发了不止一次。
| 这次调用经历了什么 | tool_call | tool_execution |
|---|---|---|
| 正常执行 | 1 行,succeeded | 1 行,succeeded |
| 用户点了拒绝 | 1 行,rejected | 1 行,rejected |
| 等结果时 Desktop 断线,重连后补发了结果 | 1 行,先 unknown,补发后 succeeded | 1 行,先 unknown,补发后 succeeded |
| 审批恢复时图把这个节点重放了一遍,又走到这个调用 | 1 行,已经 succeeded,直接用保存的结果 | 不新增 |
| 等结果超时,后来又被重新下发 | 1 行 | 2 行,attempt_no 是 1、2;Desktop 第二次直接返回第一次的结果 |
只有一张表的话,要么一个调用出现好几次结果,要么“发过几次、每次怎么结束的”被覆盖掉,没法排查。
3.3 tool_call_id:为什么是 graph_thread_id 加模型给的 ID
幂等的前提是:同一个调用,无论走到这里几次,ID 都一样;不同的调用,ID 一定不同。 本篇的 tool_call_id 是:
tool_call_id = graph_thread_id + ":" + 模型生成的 tool call id
例如 019a…c3:call_00_Xy7k
- 模型生成的 tool call id 保存在存档(Checkpoint)里的那条 AIMessage 上。LangGraph 从存档恢复、重放一个节点时,读到的还是同一条 AIMessage,ID 不变。
- 不能在工具节点里
uuid4()随机生成:上篇讲过,LangGraph 从interrupt()恢复时会把整个节点重新执行一遍,随机 ID 会变。 - 前面拼上
graph_thread_id:模型生成的 ID 只在一次对话里不重复,不同存档链上可能撞上。
不同情况下 ID 怎么变:
| 场景 | 执行 | 存档链 | tool_call_id | 会不会再执行一次 |
|---|---|---|---|---|
| WSS 消息重发、Desktop 断线重连 | 不变 | 不变 | 不变 | 不会,Desktop 按本地记录返回 |
Server 重新下发 unknown、timed_out 的调用 | 不变 | 不变 | 不变 | 不会,Desktop 按本地记录返回 |
| Server 已经保存了结果,又遇到同一个调用 | 任意 | 不变 | 不变 | 不会,Server 直接用保存的结果 |
| 审批恢复(上篇场景 3) | 新建 | 复用 | 未完成的调用不变 | 不会 |
| 编辑或重试(上篇场景 4) | 新建 | 新建 | 新的 | 会,这是用户要求的新一次回答 |
run_id 只作为审计信息发给 Desktop,不参与 ID:审批恢复会换一个新的执行,但存档链不变,调用也不该重来。
3.4 状态
Server 上 tool_call.status 的流转:
| 状态 | 意思 | 是不是最终结论 |
|---|---|---|
created、dispatched | 已落库、已发出,还没有结果 | 否 |
succeeded、failed、rejected、cancelled | Desktop 回报的结果 | 是,之后的结果不再覆盖 |
timed_out | Server 等太久,放弃等待并通知 Desktop 取消 | 否,迟到的结果会覆盖它 |
unknown | 发出去之后断了,不知道 Desktop 有没有执行 | 否,Desktop 补发的结果会覆盖它 |
Desktop 本地记录的状态是 running 加上面那些结果。running 表示已经拿到执行权、正在确认或执行。Desktop 在执行途中被关掉,下次启动时把 running 改成 unknown 补发给 Server,不会自动重新执行。
4. Server 和 Desktop 怎么通信:WSS
4.1 为什么是 WSS,为什么 Desktop 连 Server
用户的电脑通常在家里的路由器、公司内网或者 NAT 后面,Server 没法主动连过来,也不应该要求用户把本机端口暴露出去。所以连接只能由 Desktop 主动发起。
一次本地工具调用也不是简单的“一问一答”:Server 发请求,Desktop 可能要等用户确认,执行完回传结果,Server 还可能中途取消,双方都需要心跳、重连后对账。这需要一条长期保持、双向都能发消息的连接。WebSocket 正好是这样的连接,加上 TLS 就是 WSS。轮询也能做,但要不停地空请求,而且“Server 要取消”这种消息只能等下一次轮询才送到。
WSS 只解决“安全传输、双向通信”,不解决“谁能调、能调什么、会不会重复执行”。这些靠本篇的 Ticket、设备绑定、白名单、参数校验、路径策略、确认、幂等记录和对账一层层保证,任何一层都不能因为“已经用了 WSS”而省掉。
注意,WSS 只用来让 Server 调用 Desktop 的工具。对话的执行过程仍然走上篇的 POST /api/agent SSE,两条连接互不相干。
4.2 和同类桌面 Agent 的区别
上篇对比过 Codex 和 Claude Code:它们的 Agent 进程就跑在用户电脑上,读文件就是 Agent 进程自己调一个函数,确认也只是进程内的一个提示,不需要网络通道。
DevMind 的 Agent 跑在 Server 上,所以要多出一条“Server → Desktop”的通道,并且要额外处理两件本机 Agent 不用操心的事:
- 身份:连上来的 Desktop 是谁、属于哪个用户。本机 Agent 天然就是当前用户。
- 网络不可靠:请求发出去之后连接断了,Server 不知道 Desktop 有没有执行。本机函数调用不存在这个问题。
本篇大部分篇幅都在处理这两件事。
4.3 接口和消息一览
HTTP 接口:
| 接口 | 谁调 | 做什么 |
|---|---|---|
POST /api/desktop/connection-ticket | Desktop Main | 用当前用户身份换一张一次性 Ticket,body 是 {"device_id": …} |
WS /api/desktop/ws?ticket=… | Desktop Main | 建立 WSS 连接 |
POST /api/turns(上篇改) | Renderer | 多一个可选的 device_id,写进这一轮的执行 |
POST /api/desktop/dev/tool-calls | 开发者 | 只在开发模式开放:不经过模型,直接对一个执行调用 Desktop 工具,用来单独测通道 |
WSS 上的消息。所有消息都带 event_id(这条消息的 ID,排查用)和 sequence(发送方在这条连接上递增的序号,接收方拒绝倒退的序号):
| 方向 | type | 作用 |
|---|---|---|
| Desktop → Server | device.register | 连上后的第一条消息:设备 ID、进程实例 ID、版本 |
| Server → Desktop | device.registered | 注册成功,返回 connection_id |
| Desktop → Server | tool.registry.sync | 全量同步工具白名单:名字、版本、风险、Schema、超时 |
| Server → Desktop | tool.call.request | 下发一次工具调用 |
| Desktop → Server | tool.call.result | 结果:succeeded、failed、rejected、cancelled、timed_out、unknown |
| Server → Desktop | tool.call.ack | Server 已经把结果存进数据库,Desktop 不用再补发 |
| Server → Desktop | tool.call.cancel | 取消一个还没完成的调用 |
| 双向 | heartbeat | 保活:任何一方长时间收不到对方的消息,就断开重连 |
| 双向 | protocol.error | 对方发来一条看不懂的消息 |
tool.call.result 只上报最终结果,没有“执行中”这种中间状态:Server 收到第一条结果就会当成最终结果。
4.4 一次连接从建立到断开
Desktop 启动后,下面这些事情按顺序发生(细节见 6.3 节场景 0):
为什么 Ticket 要单独申请,而不是建 WSS 时直接带用户身份:浏览器和大多数 WebSocket 客户端在握手时没法带自定义请求头,身份只能放在 URL 里;URL 会被写进各种日志。所以用一张很快过期、用一次就作废的 Ticket 放在 URL 里,即使被日志记下来也没法再用。本篇还给 uvicorn 的日志加了过滤器,把 ticket= 后面的值换成 ***(见 7.9 节)。
4.5 超时要一层套一层
一次调用里有好几个超时,必须从里到外一层比一层长,否则会出现“Server 已经放弃了,Desktop 才开始执行”:
| 超时 | 默认值 | 在哪 | 到时间会怎样 |
|---|---|---|---|
| 确认超时 | 60 秒 | Desktop confirmation-manager.ts | 按拒绝处理 |
| 执行超时 | 每个工具自己声明,本篇都是 10 秒 | Desktop tools.ts | 用 AbortSignal 中止执行 |
expires_at | 现在 + Server 等待时间 − 执行超时 − 5 秒 | 请求里的字段 | Desktop 在确认前、确认后各查一次,过期就拒绝 |
| Server 等待时间 | 120 秒 | .env 的 DESKTOP_TOOL_TIMEOUT_SECONDS | 记为 timed_out,通知 Desktop 取消 |
| ToolRegistry 外层超时 | Server 等待时间 + 10 秒 | main.py 传给 create_langgraph_runtime() | M1 写的 ToolRegistry 默认 10 秒就杀掉工具,用户还没来得及点确认,所以 Desktop 工具单独放宽 |
expires_at 的意思是“过了这个时间就别开始执行了”。用户在确认框前发了一会儿呆,点“允许”的时候已经过期,Desktop 也不会执行,因为 Server 那边可能已经不等了。
5. 新加的模块
5.1 架构图
蓝线是对话(上篇的两步走),橙线是 Server 下发工具调用,绿线是 Desktop 回传结果,紫色虚线是 Main 和页面之间的 IPC:
- 左边是上篇的对话链路:Renderer 两步走,Server 的适配器运行 LangGraph 图。本篇只在图的工具节点里多了两个 Desktop 工具。
- 中间是 Server 新加的
desktop/包:Desktop 工具被调用时,DesktopToolService先把这次调用写进tool_call,再通过ConnectionManager找到这台设备的连接、发出去、等结果。 - 右边是 Desktop 的 Main 进程:
DesktopWssClient收到请求后,按固定顺序层层检查,需要确认就通过 IPC 让 Renderer 弹框,执行后把结果发回去。
5.2 模块和它们存在的理由
Server 新加的 desktop/ 包,分层方式和上篇的 runs/ 一样:
| 层 | 文件 | 做什么 | 不做什么 |
|---|---|---|---|
| 协议模型 | models.py | WSS 上每种消息的 Pydantic 模型,收到的消息先过校验 | 不含逻辑 |
| Ticket | tickets.py | 签发和消费一次性 JWT | 不认识连接、不写数据库 |
| 连接表 | connections.py | 哪个设备在线、哪个调用在等结果;只存内存 | 不写数据库 |
| Repository | repository.py | tool_call、tool_execution 的 SQL | 不判断能不能做 |
| Service | service.py | 一次调用的完整流程:落库 → 下发 → 等结果 → 断线、超时、取消时对账 | 不写 SQL,不认识 WebSocket |
| 接口 | api/desktop.py | Ticket 接口、WSS 消息循环、开发调试接口 | 不含业务规则 |
| 模型可见的工具 | agent/desktop_tools.py | 两个 @tool 函数,只是把调用转给 Service | 不做校验 |
为什么连接表单独一层、而且只存内存:在线连接、正在等结果的协程,本来就只在当前进程里有意义,Server 重启后它们全部失效。重启后靠什么接上?靠数据库里的 tool_call 和 Desktop 本地的执行记录,这就是“对账”。
Desktop Main 的模块:
| 模块 | 做什么 | 为什么单独一块 |
|---|---|---|
wss-client.ts | 连接、收发消息、按顺序处理一次调用 | 唯一碰网络的地方;不依赖 electron 模块,可以直接在 Node 里测试 |
tools.ts | 工具白名单 | 加一个工具只改这里 |
path-policy.ts | 路径安全 | 本地文件访问的最后一道边界,单独测试 |
validation.ts | 参数校验 | 网络输入一律不可信,Server 校验过也要再校验 |
execution-store.ts | 本地执行记录 | 幂等和补发都靠它,必须先落盘再执行 |
confirmation-manager.ts | 确认框 | 唯一碰 Electron IPC 的地方 |
runtime.ts | 装配 | 和上篇 Server 的 main.py 一样,手动把对象装起来 |
5.3 文件地图
代码仓库地址: gitee.com/panghuzhuai…
和上篇一样,精读 是要逐行读懂、能自己改的;通读 是知道每个函数做什么;略读 是知道它存在。第二列:下篇 是本篇新写的,下篇改 是之前写的、本篇改过。没列出来的文件本篇没动。
apps/devmind-server/
├── migrations/0003_add_desktop_tool_tables.sql 下篇 通读 tool_call、tool_execution 两张表
├── src/devmind_server/
│ ├── main.py 下篇改 通读 启动时装配 Desktop 通道
│ ├── core/
│ │ ├── config.py 下篇改 略读 Ticket 密钥、有效期、等待时间、允许的 Origin
│ │ └── logging.py 下篇 略读 日志里的 ticket 参数换成 ***
│ ├── api/
│ │ ├── desktop.py 下篇 精读 Ticket 接口、WSS 消息循环、开发调试接口
│ │ ├── dependencies.py 下篇改 略读 多两个依赖:Ticket 服务、Desktop 工具服务
│ │ └── router.py 下篇改 略读 挂上 desktop 路由
│ ├── desktop/ 下篇
│ │ ├── service.py 精读 一次调用:落库 → 下发 → 等结果 → 对账
│ │ ├── connections.py 精读 在线连接、等结果的协程
│ │ ├── repository.py 通读 两张表的 SQL
│ │ ├── tickets.py 通读 一次性 JWT
│ │ └── models.py 略读 协议消息模型
│ ├── agent/
│ │ ├── desktop_tools.py 下篇 通读 模型可见的 workspace_list_files、workspace_read_file
│ │ ├── tool_registry.py 下篇改 通读 透传 config、单独放宽超时、按 ToolCall 格式调用
│ │ └── langgraph_runtime/
│ │ ├── graph.py 下篇改 略读 工具节点接收 config
│ │ └── factory.py 下篇改 略读 注册 Desktop 工具
│ └── runs/service.py 下篇改 略读 多一个 get_run_for_user(),开发调试接口用
└── tests/
├── test_desktop_wss.py 下篇 通读 用 TestClient 扮演 Desktop 的集成测试
└── test_desktop_tickets.py 下篇 略读 Ticket 单元测试
apps/devmind-desktop/
├── src/main/
│ ├── index.ts 下篇改 略读 启动和停止 Desktop Runtime
│ ├── env.d.ts 下篇 略读 MAIN_VITE_ 变量的类型
│ └── desktop/ 下篇
│ ├── wss-client.ts 精读 连接、处理调用、补发、心跳、重连
│ ├── execution-store.ts 精读 本地执行记录
│ ├── path-policy.ts 精读 路径策略
│ ├── tools.ts 通读 工具白名单
│ ├── confirmation-manager.ts 通读 确认框的 Main 一侧
│ ├── runtime.ts 通读 装配
│ ├── protocol.ts、validation.ts、config.ts 略读 消息校验、参数校验、配置
│ └── __tests__/ 通读 路径策略、执行记录、WSS 客户端的测试
├── src/preload/index.ts、index.d.ts 下篇改 通读 暴露给页面的受控动作
├── src/renderer/src/
│ ├── desktop/ToolConfirmationDialog.tsx 下篇 通读 确认框
│ ├── desktop/desktop-runtime.ts 下篇 略读 读设备 ID 和连接状态
│ ├── App.tsx、App.css 下篇改 略读 顶部状态、挂上确认框
│ ├── hooks/useTurnRunner.ts 下篇改 略读 提问带 device_id
│ └── agent/devmind-api.ts 下篇改 略读 createTurn() 多一个 deviceId
└── .env.example、package.json 下篇改 略读 MAIN_VITE_ 配置;ws、ajv、zod、vitest
5.4 用到的库
| 库 | 在哪 | 用来做什么 |
|---|---|---|
| PyJWT | Server | 签发和校验 Ticket(HS256) |
FastAPI 的 WebSocket | Server | WSS 路由:accept()、receive_text()、send_json()、close(code) |
ws | Desktop Main | Node 里的 WebSocket 客户端。Electron Main 没有浏览器的 WebSocket 对象 |
zod | Desktop Main | 收到的每条 Server 消息先过运行时校验;TypeScript 的类型只在编译期存在 |
ajv | Desktop Main | 按工具的 JSON Schema 校验参数 |
vitest | Desktop | 单元测试 |
6. 场景:一次本地工具调用,代码从哪个文件走到哪个文件
这一章按用户实际的操作顺序把代码串起来。例子接着上篇的会话:Desktop 启动并连上 Server;用户问“列出我工作区根目录的文件,然后读一下 README.md”;AI 先列文件(直接执行),再读文件(弹确认框);然后看用户拒绝、越界、确认时点停止、断线这几种情况。
每个场景的写法和上篇一样:
- 调用链路图:从上往下是时间顺序。四条泳道是 Renderer、Desktop Main、Server 的接口层、Server 的业务层(
desktop/包、图和适配器)。备注框写的是这个方法里面依次调用了什么。 - 调用明细表:按调用顺序,一行一个方法。
- 调用树:Service 在一个事务里依次调用了哪些 Repository 方法。
- 数据库变化和要注意的地方。
路径的写法:Server 文件省略前缀 apps/devmind-server/src/devmind_server/;Desktop Main 文件省略前缀 apps/devmind-desktop/src/main/;Renderer 文件省略前缀 apps/devmind-desktop/src/renderer/src/。例子里的 ID:会话 c1、轮次 t3、执行 r3、存档链 g3、设备 d1、模型给的调用 ID call_1,所以 tool_call_id 是 g3:call_1。
6.1 开始之前:两边启动时装好了哪些对象
Server 和上篇一样,在 main.py 的 lifespan() 里按顺序创建对象;本篇在“组装 LangGraph 的图”之前,多建了 Desktop 通道的三个对象,因为 Desktop 工具要拿着 DesktopToolService 才能工作。Desktop 这边,Electron 启动后由 desktop/runtime.ts 装配 Main 进程里的对象。
| 文件 · 对象 | 做什么 | 谁用它 |
|---|---|---|
core/logging.py · install_ticket_redaction() | 给 uvicorn 的日志挂过滤器,把 ticket= 后面的值换成 *** | 启动时调用一次 |
desktop/connections.py · ConnectionManager | 进程内存里的连接表:按“用户 + 设备”找在线连接,按 tool_call_id 把结果交给正在等的协程 | WSS 路由、DesktopToolService |
desktop/repository.py · ToolCallRepository | tool_call、tool_execution 的 SQL,和上篇共用一个连接池 | DesktopToolService |
desktop/service.py · DesktopToolService | 一次调用的完整流程。启动时还调一次 recover_after_restart():上一个进程发出去、没等到结果的调用,统一标为 unknown | Desktop 工具、WSS 路由、开发调试接口 |
agent/desktop_tools.py · create_desktop_tools(service) | 创建模型能看到的两个工具,函数体里只是调 service.call_from_config() | create_langgraph_runtime() |
desktop/tickets.py · TicketService | 签发和消费 Ticket,密钥来自 .env | Ticket 接口、WSS 路由 |
Desktop desktop/runtime.ts · startDesktopRuntime() | 读配置 → 工具注册表 → 执行记录 → 确认管理器 → DesktopWssClient,然后 start() 开始连接 | main/index.ts 在窗口创建后调用 |
6.2 全景图:一次本地工具调用经过的全部代码
6.1 是前置条件。下面这张图把一次调用的全过程画在一起:上面一行是 Server,下面一行是 Desktop,中间是 WSS。格子的颜色表示在哪一边、哪一层;标题带 ↓ 的格子展开成下面一排小步骤。
读的时候抓住这几点:
- 身份全部来自 Server:用户、会话、执行、设备都是适配器从数据库读出来、放进 LangGraph 的
configurable的。模型只决定“调哪个工具、参数是什么”,改不了这些。 - 先落库,再下发:任何一次下发在数据库里都有记录,Server 崩了也知道发过什么。
- Desktop 先检查,再占位,再确认,再执行:越界的请求不占位、不弹框;占位之后同一个调用不会再执行第二次。
- 结果先落库,再回 ack:Desktop 收到 ack 才删掉本地的结果,中间任何一步断了都还能补发。
后面的场景就是在这张图里挑一段走一遍:场景 0 是连接建立;场景 1 是 LOW 工具走完整条链;场景 2 多了确认;场景 3 是各种在 Desktop 被拒绝的情况;场景 4 是取消;场景 5 是断线和重启后的对账。
6.3 场景 0:Desktop 启动,连上 Server
用户打开 Desktop,什么都还没问。
调用链路:Desktop 启动并注册。
| 顺序 | 文件 · 方法 | 做什么 |
|---|---|---|
| 1 | main/index.ts | 窗口创建之后调 startDesktopRuntime(() => mainWindow)。传的是“取窗口的函数”而不是窗口本身:macOS 上关掉窗口再打开,确认框还能弹到新窗口里 |
| 2 | desktop/runtime.ts · startDesktopRuntime() | 先注册 desktop-runtime:get-device-id 这个 IPC,再等配置加载完。顺序不能反:页面可能比配置更早挂载,先注册的 IPC 返回一个会等配置就绪的 Promise |
| 3 | desktop/config.ts · loadDesktopConfig() | 读 MAIN_VITE_ 配置,工作区目录不存在就启动失败;设备 ID 从 userData/device.json 读,第一次启动时生成 |
| 4 | desktop/wss-client.ts · start() | 先加载本地执行记录(上次没执行完的标为 unknown),再连网 |
| 5 | desktop/wss-client.ts · connect() → requestTicket() | POST /api/desktop/connection-ticket,带开发期身份头和 device_id |
| 6 | api/desktop.py · create_connection_ticket() | 用户来自 get_current_user(),不信请求体;TicketService.issue() 签一张 60 秒的 JWT |
| 7 | desktop/wss-client.ts · connect() | new WebSocket(wsUrl?ticket=…),连上后 onOpen() 发 device.register |
| 8 | api/desktop.py · desktop_ws() | accept() → 检查 Origin → tickets.consume() → 10 秒内收到 device.register,设备 ID 必须和 Ticket 里的一致 |
| 9 | desktop/connections.py · ConnectionManager.register() | 登记连接;同一设备已有旧连接时,旧连接上等结果的调用立即失败,旧连接以 4409 关闭 |
| 10 | api/desktop.py · desktop_ws() | 回 device.registered,进入消息循环 |
| 11 | desktop/wss-client.ts · onMessage() | 收到 device.registered:状态改为“已连接”,同步工具白名单,补发本地还没被确认的结果,开始每 15 秒一次心跳 |
| 12 | api/desktop.py · desktop_ws() | 收到 tool.registry.sync:整体替换这条连接的白名单 connection.tools |
| 13 | desktop/runtime.ts → Renderer desktop/desktop-runtime.ts | onStatus 回调把状态推给页面,顶部显示“本机工具:已连接” |
Server 收到连接后的处理:
WS /api/desktop/ws?ticket=eyJ…
└─ api/desktop.py 的 desktop_ws()
├─ websocket.accept() 先接受:之后才能用 4401、4403 这类关闭码告诉 Desktop 为什么被拒
├─ 检查 Origin 带了 Origin 说明是浏览器页面发起的,必须在白名单里,否则 4403
├─ TicketService.consume(ticket) 验签、验过期、验用途,jti 用过就 4401
├─ 10 秒内读第一条消息 必须是 device.register,否则 4400
├─ device_id 和 Ticket 里的不一致 4403 DEVICE_MISMATCH
├─ ConnectionManager.register() 同一设备的旧连接被替换
├─ 发 device.registered
└─ 消息循环:每条消息先过 Pydantic 校验,再检查 sequence 只增不减
├─ tool.registry.sync 替换白名单
├─ tool.call.result 先落库,再回 ack(场景 1)
├─ heartbeat 回一个 heartbeat
└─ 45 秒没收到任何消息 4408 关闭,认为是半开连接
finally:ConnectionManager.unregister() 只有它还是当前连接时才移除;它上面等结果的调用立即失败
要注意:
accept()要写在最前面。在accept()之前关闭,Starlette 直接回 HTTP 403,Desktop 分不清是 Ticket 过期还是别的原因。- Server 不保存“哪个用户有哪些设备”。设备是不是这个用户的,靠 Ticket:Ticket 是用这个用户的身份申请的,里面写着设备 ID。
- 每次重连都重新申请 Ticket,因为旧的已经用过了。
6.4 场景 1:AI 列出工作区的文件(不需要确认)
用户在会话 c1 里问:“列出我工作区根目录的文件,然后读一下 README.md。”这一节只看第一个工具 workspace_list_files。
调用链路:从提问到 Desktop 执行,再回到模型。
| 顺序 | 文件 · 方法 | 做什么 |
|---|---|---|
| 1 | Renderer hooks/useTurnRunner.ts · ask() | 和上篇一样先准备再执行,只是 createTurn() 多带一个 deviceId: await getDeviceId() |
| 2 | Server runs/service.py · prepare_new_turn() | 上篇已有的代码:device_id 写进新建的执行 agent_run.device_id |
| 3 | Server agent/ag_ui_adapter.py · stream() | 上篇已有:build_run_configurable() 把用户、会话、轮次、执行、存档链、设备放进 configurable,模型改不了这些值 |
| 4 | Server agent/langgraph_runtime/graph.py · route_after_model() | 模型返回一个 workspace_list_files 调用;它不在需要审批的工具里,直接走 tools 节点 |
| 5 | Server graph.py · execute_tools(state, config) | 本篇改:节点多声明一个 config 参数,LangGraph 会把这次运行的配置传进来,再交给 registry.execute(tool_call, config) |
| 6 | Server agent/tool_registry.py · ToolRegistry.execute() | 本篇改:把模型给的调用 ID 写进 configurable["devmind_tool_call_id"];Desktop 工具的超时放宽到 130 秒;按标准 ToolCall 格式调用工具 |
| 7 | Server agent/desktop_tools.py · workspace_list_files() | 名字里不能有点,所以模型看到的是下划线;转成 service.call_from_config(config, tool_name="workspace.list_files", …) |
| 8 | Server desktop/service.py · DesktopCallContext.from_config() | 从 configurable 里读出可信上下文,拼出 tool_call_id = g3:call_1;这次执行没绑定设备就返回 DESKTOP_DEVICE_NOT_SELECTED |
| 9 | Server desktop/service.py · call_tool() | 设备在线、工具在白名单里 → 在一个事务里写 tool_call 和第一次下发 → 组装请求 → manager.dispatch() 发出去并等结果。见下面的调用树 |
| 10 | Desktop desktop/wss-client.ts · onMessage() → handleToolCall() | 收到请求,按顺序检查:设备 → 白名单和版本 → 参数 → 路径预检 → 有没有过期 → 本地占位(见 8.1 节) |
| 11 | Desktop desktop/execution-store.ts · reserve() | 在锁里检查这个 tool_call_id 有没有记录,没有就写一条 running 并落盘,然后才执行 |
| 12 | Desktop desktop/wss-client.ts · execute() → tools.ts · list_files.execute() | 10 秒执行超时;读目录,过滤掉敏感名字,最多返回 500 项 |
| 13 | Desktop execution-store.ts · finish() → wss-client.ts · sendResult() | 结果落盘(含完整输出),发 tool.call.result |
| 14 | Server api/desktop.py · desktop_ws() → service.py · handle_result() | 先 record_result() 写库,再 manager.resolve() 唤醒第 9 步在等的协程,最后回 tool.call.ack |
| 15 | Desktop execution-store.ts · markAcked() | 删掉本地保存的输出,只留状态;之后重连不会再补发 |
| 16 | Server call_tool() → ToolRegistry.execute() | 返回输出,包成 {"ok": true, "data": …} 的 ToolMessage 交给模型;同时 ag_ui_langgraph 给前端发 TOOL_CALL_RESULT,工具卡片显示“已完成” |
Server 的调用树(第 9 步和第 14 步):
DesktopToolService.call_tool(context, tool_name="workspace.list_files", arguments={"path": "."})
├─ manager.get(user_id, device_id) 设备不在线 → DEVICE_OFFLINE,什么都不写
├─ connection.tools["workspace.list_files"] 不在白名单 → TOOL_NOT_AVAILABLE
├─ async with 连接, 事务:
│ ├─ repository.lock_tool_call("g3:call_1") 锁住这次调用(FOR NO KEY UPDATE)
│ ├─ 不存在 → repository.insert_tool_call() 参数和参数哈希一起存下来
│ │ 已存在 → 参数哈希不同:IDEMPOTENCY_CONFLICT
│ │ 已经 succeeded:直接返回保存的结果,不再下发
│ │ 已经 failed / rejected / cancelled:直接返回这个错误
│ │ dispatched / timed_out / unknown:用同一个 ID 再下发一次
│ └─ repository.start_attempt() 新增一行 tool_execution(attempt_no + 1),tool_call 改为 dispatched
├─ 组装 ToolCallRequest 带上 expires_at、是否需要确认、执行超时
└─ manager.dispatch(connection, request, timeout=120)
├─ 登记一个 Future,等这个 tool_call_id 的结果
├─ connection.send(request) 在发送锁里分配 sequence
└─ await Future ← 由 handle_result() 里的 resolve() 唤醒
WSS 收到 tool.call.result
└─ DesktopToolService.handle_result()
├─ async with 连接, 事务:
│ └─ repository.record_result() 条件 UPDATE:同一用户、同一设备、还不是最终状态才写
│ 顺带把最近一次下发的状态也改掉
├─ manager.resolve() 有协程在等就唤醒它;没有(迟到的结果)也没关系
└─ (回到路由)发 tool.call.ack
数据库变化:
| 表 | 变化 |
|---|---|
tool_call | 新增 g3:call_1:workspace.list_files,参数 {"path": "."},dispatched → succeeded,result 是文件列表 |
tool_execution | 新增 1 行:第 1 次下发,连接 ID、dispatched → succeeded,耗时 |
要注意:
- 第 14 步的顺序是先写库,再唤醒,最后 ack。先 ack 再写库的话,ack 之后 Server 崩了,Desktop 已经删了结果,这次调用的结果就永远丢了。
- Desktop 工具只在
tools节点里运行,所以 M3 的那些限制照样生效:一次只能调用一个工具,一次执行最多 16 个工具调用。
6.5 场景 2:AI 读文件,用户点“允许一次”
接着场景 1,模型拿到文件列表后,调用 workspace_read_file,参数 {"path": "README.md"}。
Server 一侧和场景 1 完全一样,区别都在 Desktop:read_file 声明了 requiresConfirmation: true,占位之后、执行之前要弹确认框。
调用链路:读文件,用户点“允许一次”。
| 顺序 | 文件 · 方法 | 和场景 1 的区别 |
|---|---|---|
| 1 | Desktop wss-client.ts · handleToolCall() | 占位成功后,因为需要确认,调 this.options.confirm(request, controller.signal) |
| 2 | Desktop confirmation-manager.ts · request() | 生成 confirmationId,记下 resolve,开 60 秒计时,通过 webContents.send('desktop-tool:confirmation-requested') 发给页面 |
| 3 | Preload index.ts · onConfirmationRequested | 把 Main 的消息转给页面注册的回调 |
| 4 | Renderer desktop/ToolConfirmationDialog.tsx | 加进队列,显示工具名、完整参数、过期时间 |
| 5 | Renderer 用户点“允许一次” → answerConfirmation(id, true) | 通过 ipcRenderer.invoke('desktop-tool:confirmation-response') 回到 Main |
| 6 | Desktop confirmation-manager.ts 的 ipcMain.handle | 找到这个 confirmationId,resolve(approved === true):只有严格的 true 才算允许 |
| 7 | Desktop wss-client.ts · handleToolCall() | 确认返回后再查两件事:有没有被取消、有没有过期(用户可能在过期之后才点)。都没有才执行 |
| 8 | Desktop tools.ts · read_file.execute() | 再走一遍路径策略;文件超过 max_bytes 返回 FILE_TOO_LARGE;读完再按实际字节数检查一次,防止读的时候文件变大 |
要注意:
- 确认框只能回答允许或拒绝,不能改参数。用户想读别的文件,应该在对话里告诉 AI,让 AI 发起一个新的调用。
- 确认发生在 Desktop,不是上篇那种图上的审批中断。Server 这边只是在
call_tool()里多等了一会儿,所以 ToolRegistry 的外层超时要放宽(4.5 节)。
6.6 场景 3:在 Desktop 被拒绝的几种情况
Desktop 收到请求后的检查顺序是固定的,前面的检查不过,后面的都不做:
| 顺序 | 检查 | 不通过时的错误码 | 弹不弹确认框 | 写不写本地记录 |
|---|---|---|---|---|
| 1 | 请求里的设备 ID 是不是本机 | DEVICE_MISMATCH | 否 | 否 |
| 2 | 工具在不在白名单、版本对不对 | TOOL_NOT_AVAILABLE | 否 | 否 |
| 3 | 参数是否符合 JSON Schema(多一个字段也不行) | INVALID_ARGUMENTS | 否 | 否 |
| 4 | 路径预检:绝对路径、跳出工作区、符号链接指向外面、敏感文件、不存在 | ABSOLUTE_PATH_DENIED、PATH_OUT_OF_SCOPE、SENSITIVE_PATH_DENIED、PATH_NOT_FOUND | 否 | 否 |
| 5 | 有没有过期 | REQUEST_EXPIRED | 否 | 否 |
| 6 | 本地占位:这个 ID 已经有记录 | 参数不同返回 IDEMPOTENCY_CONFLICT;正在执行就不回复;已经结束就补发保存的结果 | 否 | 已有 |
| 7 | 用户确认 | 拒绝或 60 秒没理:CONFIRMATION_REJECTED | 是 | 是 |
| 8 | 确认后再查:有没有被取消、有没有过期 | CANCELLED_BY_SERVER、REQUEST_EXPIRED | 已弹过 | 是 |
第 1~5 步被拒绝时不写本地记录:这些拒绝没有任何副作用,Server 再发一次也只是再拒绝一次,不需要幂等保护。从第 6 步开始,这个调用“可能被执行”,所以必须先占位。
Server 收到 rejected 后,call_tool() 抛出 DesktopToolError(code);ToolRegistry 把错误码原样放进 ToolMessage:
{"ok": false, "tool": "workspace_read_file", "data": null,
"error": {"code": "PATH_OUT_OF_SCOPE", "message": "PATH_OUT_OF_SCOPE: path escapes the workspace"}}
模型看到稳定的错误码,自己决定怎么跟用户解释,比如“这个文件在工作区外面,我没有权限读”。实际测试中,用户拒绝后模型的回答是“README.md 的读取被确认环节拒绝,所以没有拿到内容”,没有编造文件内容。
路径策略的两个细节(代码见 8.2 节):
- 先按字面判断越界,再访问文件系统。如果先
realpath再判断,../secret存在时返回PATH_OUT_OF_SCOPE、不存在时返回PATH_NOT_FOUND,模型就能用错误码探测工作区外面有哪些文件。 - 越界判断不能只写
relative.startsWith('..'):工作区里一个叫..foo的普通文件也会被误伤。要判断relative === '..'或者以../开头。
6.7 场景 4:确认框还在,用户点了“停止”
AI 要读 LICENSE,确认框弹出来了,用户没点,而是在对话里点了“停止”。
上篇讲过停止的前半段:前端断开 SSE,适配器在 stream() 里捕获取消,对生产者任务调用 task.cancel(),LangGraph 正在执行的节点会收到 CancelledError。本篇接着往下:这个节点正卡在 call_tool() 等 Desktop 的结果。
调用链路:确认框还在时点停止。
| 顺序 | 文件 · 方法 | 做什么 |
|---|---|---|
| 1 | Renderer hooks/useTurnRunner.ts · stop() | 上篇:agent.abortRun() 断开 SSE |
| 2 | Server agent/ag_ui_adapter.py · stream() | 上篇:捕获取消 → _stop_producer() 对图的任务调用 cancel() |
| 3 | Server desktop/connections.py · dispatch() | 正在 await Future,收到 CancelledError;finally 里把这个调用从等待表里删掉 |
| 4 | Server desktop/service.py · call_tool() 的 except asyncio.CancelledError | 在屏蔽取消的作用域里调 _cancel_inflight():tool_call 记为 unknown(错误码 RUN_CANCELLED),发 tool.call.cancel;然后继续抛出取消 |
| 5 | Desktop wss-client.ts · onMessage() | 收到 tool.call.cancel,对这个调用的 AbortController 调 abort('CANCELLED_BY_SERVER') |
| 6 | Desktop confirmation-manager.ts | 监听到 signal 的 abort:结束等待(按未批准),并发 desktop-tool:confirmation-cancelled 让页面关掉确认框 |
| 7 | Desktop wss-client.ts · handleToolCall() | 确认返回后先查是否已取消:是,记为 cancelled(而不是“用户拒绝”),发结果 |
| 8 | Server handle_result() | unknown 不是最终状态,被 cancelled 覆盖,回 ack |
数据库变化:
| 时刻 | tool_call | agent_run |
|---|---|---|
| 点停止之后 | unknown,RUN_CANCELLED | cancelled,CLIENT_DISCONNECTED(上篇) |
| Desktop 回报之后 | cancelled,CANCELLED_BY_SERVER | 不变 |
要注意:
- 第 4 步为什么要“屏蔽取消”:这时协程正处在取消中,如果收尾的写库和发消息也被取消,
tool_call就会一直停在dispatched。anyio.CancelScope(shield=True)保证收尾做完,再把取消继续抛出去。 - 为什么先记
unknown而不是直接记cancelled:Server 发出取消的那一刻,Desktop 可能已经执行完了,结果正在路上。Server 不知道,只能先记“不确定”,以 Desktop 之后的回报为准。 - 取消也可能比请求先到(请求还在做参数校验和路径预检时就收到了取消)。Desktop 用一个
cancelledEarly集合记下来,占位之后立刻按取消处理。
6.8 场景 5:断线、Server 重启之后的对账
AI 要读 LICENSE,用户还在看确认框时 Server 重启了。用户随后点了“允许一次”,Desktop 在本地读完了文件,但连接是断的。
调用链路:断线、Server 重启之后补发结果。
| 顺序 | 发生了什么 | 哪段代码 |
|---|---|---|
| 1 | Server 关闭,WSS 断开。call_tool() 正在等的 Future 收到 DeviceDisconnectedError,tool_call 记为 unknown(RESULT_UNKNOWN) | connections.py · unregister() → _fail_pending();service.py · call_tool() |
| 2 | Desktop 状态变为“未连接,正在重连”,按 1、2、4…秒退避重连 | wss-client.ts · onClose() → scheduleReconnect() |
| 3 | 用户点了允许,Desktop 读文件,结果写进本地记录。发送时连接没打开,消息被丢弃,但记录还在,acked 是 false | wss-client.ts · execute() → finish();send() 连接没打开时直接返回 |
| 4 | Server 启动。recover_after_restart() 把上个进程留下的 created、dispatched 调用全标为 unknown(SERVER_RESTARTED) | service.py · recover_after_restart() → repository.mark_inflight_unknown() |
| 5 | Desktop 重新申请 Ticket、连接、注册 | 同场景 0 |
| 6 | 收到 device.registered 后,补发所有还没被确认的结果 | wss-client.ts · onMessage() → store.listUnacked() → sendResult() |
| 7 | Server 写库:unknown 被 succeeded 覆盖,最近一次下发也改为 succeeded;回 ack | service.py · handle_result() → repository.record_result() |
| 8 | Desktop 删掉本地的输出 | execution-store.ts · markAcked() |
数据库变化:
| 时刻 | tool_call | tool_execution(只有 1 行) | Desktop 本地记录 |
|---|---|---|---|
| 断线后 | unknown | unknown | running(还在等确认) |
| 用户允许后 | 不变 | 不变 | succeeded,有输出,未确认 |
| 重连补发后 | succeeded,有 result | succeeded | succeeded,输出已删,已确认 |
要注意:
- 这次对话本身已经结束了:断线那一刻,模型拿到的是
RESULT_UNKNOWN错误,这一轮按上篇的规则收尾。对账补上的是审计记录,让tool_call反映真实发生的事,不会让已经结束的这一轮“活过来”。 - 为什么不自动重试:Server 不知道 Desktop 有没有执行。只读工具重复执行没有坏处,但以后的写文件、执行命令重复一次就可能出事。所以规则统一为“结果不确定就记
unknown,等对账”。 - Desktop 在执行途中被关掉:下次启动
load()时把running改为unknown(DESKTOP_RESTARTED)补发,也不会自动重新执行。 - 本地记录在 ack 之后保留 7 天再清理,这期间同一个
tool_call_id再来,Desktop 仍然能认出来。
6.9 场景对比
| 场景 | 弹确认框 | tool_call 最终状态 | 下发次数 | 模型拿到什么 |
|---|---|---|---|---|
| 1 列文件 | 否 | succeeded | 1 | 文件列表 |
| 2 读文件,允许 | 是 | succeeded | 1 | 文件内容 |
| 3 越界、敏感文件、参数不对 | 否 | rejected | 1 | 错误码 |
| 3 用户拒绝或不理 | 是 | rejected,CONFIRMATION_REJECTED | 1 | 错误码 |
| 4 确认时点停止 | 是,随后自动关闭 | 先 unknown,对账后 cancelled | 1 | 拿不到,这一轮已停止 |
| 5 断线后补发 | 是 | 先 unknown,对账后 succeeded | 1 | RESULT_UNKNOWN |
| Desktop 没开 | 否 | 不写 | 0 | DEVICE_OFFLINE |
| 提问时没有设备(浏览器里调试) | 否 | 不写 | 0 | DESKTOP_DEVICE_NOT_SELECTED |