Deepseek-harness增加桌面版端序列:第 5 讲 · desktop-app bundle:一个 bundle 如何改造产品

第 5 讲 · desktop-app bundle:一个 bundle 如何改造产品

系列 :我在Deepseek harness中增加了桌面版,将逐行代码解析我是怎么增加的,希望你也能创建属于自己的桌面版Agent 本讲目标 :吃透 packages/bundle/desktop-app------387 行的 cordis.patch.yml 如何定义桌面端插件名册,以及 src/index.ts(139 行)如何实现 desktopRuntime 服务(桌面端"无 HTTP 的 API 网关")。 逐行文件cordis.patch.yml(387 行,关键段)+ src/index.ts(139 行,全行) 代码仓库github.com/tslcarmack/... Desktop App 运行效果图


🎯 本讲地图

scss 复制代码
desktop-app bundle(packages/bundle/desktop-app/)
   ├─► cordis.patch.yml(387 行)------桌面端插件名册
   │      ├─► host 侧行:desktop-runtime / api-gateway / modules / connection ...
   │      └─► dsh.client 行:浏览器端 ui-* 插件(theme/layout/sidebar/conversation...)
   └─► src/index.ts(139 行)------desktopRuntime 服务实现
          ├─► mux/host 双推送通道(订阅者集合)
          ├─► graph()           → 启动图(clientModules.graph())
          ├─► loadBundleSource()→ 读客户端 bundle 源码
          └─► fetchFromPreload()→ 无 HTTP 的"fetch"(ApiProxy 适配)

📁 0. 定位:bundle = 配置行 + 代码的分发格式

回顾第 4 讲:desktop profile 的 dsh.profile.bundles = [dsh-base, dsh-desktop-app]。bundle 的价值在于------它只是"补丁包":插入/覆写配置行,随包分发代码。desktop-app 就是"把 dsh 变成桌面应用"的那个补丁包。


📁 1. cordis.patch.yml 关键段逐行

文件:packages/bundle/desktop-app/cordis.patch.yml(387 行)

1.1 开头注释(第 1-18 行)

yaml 复制代码
# The dsh-desktop-app bundle patch: the Electron surface over the dsh-base layer.
# Applied after dsh-base's insert; rows here override base rows by id, with
# the profile's own cordis.patch.yml and any --patch overlays still to come.
#
# A patch replaces the targeted row's whole `config`, so each row below
# restates every key it owns.
#
# The desktop-runtime plugin injects `cmdlineArgs` and provides `desktopRuntime`...

关键认知

  • patch 语义是按 id 替换整行 config------所以每行都要完整声明它拥有的所有 key。
  • 应用顺序:dsh-base 先插入 → desktop-app 覆写 → 用户 profile patch → --patch。

1.2 host 侧关键行(第 60-130 行附近)

yaml 复制代码
# Desktop glue: provides `desktopRuntime` for the connection node half and
    # the Electron main process. No HTTP listen.
    - id: desktop-runtime
      name: '@deepseek-ai/dsh-desktop-app'
  • desktop-runtime :这就是第 3 讲 ctx.get('desktopRuntime') 的来源。注释点破核心差异:"No HTTP listen"。
yaml 复制代码
# The API gateway: the transport-agnostic dispatch face every client shape
    # shares. The base layer's agent-default-model service owns the default model.
    - id: api-gateway
      name: '@deepseek-ai/dsh-host-apiproxy'
  • api-gateway :web/desktop 共用的传输无关 API 网关 ------web 端它挂 HTTP 路由,桌面端它挂 desktopRuntime(src/index.ts 的 setApiFetch)。
yaml 复制代码
# Browser Session export: `/export` command plus the shared download dialog.
    - id: session-log-download
      name: '@deepseek-ai/dsh-session-log-export'
yaml 复制代码
# Pin the native dual-face picker: auto needs webServer to sample bind host,
    # and desktop has no HTTP listen. Overlay -browse to pin the other interaction.
    - id: directory-picker
      name: '@deepseek-ai/dsh-host-directory-picker-native'
  • 桌面无 HTTP 的连锁反应directory-pickerauto 模式需要 webServer 探测绑定主机------桌面没有 webServer,所以强制 pin 到 native 双面实现。这是一个"bundle 处理环境差异"的绝佳例子。

1.3 浏览器插件名册(dsh.client 行)

yaml 复制代码
# ── browser plugin roster (dsh.client rows; node halves are layer-2 hosts) ──

    - id: modules
      name: '@deepseek-ai/dsh-client-modules'
    - id: connection
      name: '@deepseek-ai/dsh-client-connection'
    ...
    - id: ui-theme
      name: '@deepseek-ai/dsh-client-ui-theme'
    - id: ui-layout
      name: '@deepseek-ai/dsh-client-ui-layout'
    - id: ui-sidebar
      name: '@deepseek-ai/dsh-client-ui-sidebar'
    - id: ui-conversation
      name: '@deepseek-ai/dsh-client-ui-conversation'
    - id: ui-tool
      name: '@deepseek-ai/dsh-client-ui-tool'
    ...

关键认知

  • dsh.client 行 = 浏览器端插件名册 。第 4 讲提到 modules 插件把它们打包进 window.__DSH_BOOT__ 下发。
  • 桌面端前端也是插件体系 :theme/layout/sidebar/conversation/tool 等都是独立的 dsh-client-ui-* 包------这就是"一切皆插件"延伸到浏览器端的体现。
  • 注释:"node halves are layer-2 hosts"------这些插件是**双面(dual-face)**的:Node 半是加载器,浏览器半是真正的 UI。

📁 2. src/index.ts 全行逐行(139 行)

文件:packages/bundle/desktop-app/src/index.ts

ts 复制代码
1  /**
  2   * @deepseek-ai/dsh-desktop-app --- the Electron-surface bundle's runtime glue.
  3   * Provides `desktopRuntime` so connection can bind ApiProxy without HTTP.
  4   * Window, preload, and ipcMain wiring live in `apps/desktop`.
  5   * @module @deepseek-ai/dsh-desktop-app
  6   */
  7
  8  import { readFile } from 'node:fs/promises'
  9  import type { Context } from '@deepseek-ai/cordis'
 10  import type { DesktopRuntime } from '@deepseek-ai/dsh-client-connection'
 11  import type { WebBootGraph } from '@deepseek-ai/dsh-client-modules'
 12
 13  /** Stable Cordis plugin name. */
 14  export const name = 'desktop-app'
 15
 16  /** No required services: the glue provides `desktopRuntime` for later rows. */
 17  export const inject: string[] = []
 18
 19  /** DesktopRuntime plus the methods Electron main uses after the window exists. */
 20  export interface DesktopRuntimeImpl extends DesktopRuntime {
 21    readonly graph: () => WebBootGraph
 22    loadBundleSource(url: string): Promise
 23    subscribeMux(listener: (json: string) => void): () => void
 24    subscribeHost(listener: (json: string) => void): () => void
 25    fetchFromPreload(request: IpcFetchRequest): Promise
 26  }
 27
 28  /** Unary IPC request the renderer sends through the preload bridge. */
 29  export interface IpcFetchRequest {
 30    url: string
 31    method: string
 32    headers: Record
 33    body: string | null
 34  }
 35
 36  /** Unary IPC response the preload bridge returns to the renderer. */
 37  export interface IpcFetchResponse {
 38    status: number
 39    headers: Record
 40    body: string
 41    bodyEncoding?: 'utf8' | 'base64'
 42  }
 43
 44  function clientIdFromBundleUrl(url: string): string {
 45    const pathname = new URL(url, 'http://dsh.internal').pathname
 46    return decodeURIComponent(pathname.replace(/^\/plugins\//u, '').replace(/\/client\.js$/u, ''))
 47  }
 48
 49  export function apply(ctx: Context): void {
 50    const muxListeners = new Set<(json: string) => void>()
 51    const hostListeners = new Set<(json: string) => void>()
 52    let apiFetch: ((request: Request) => Promise) | undefined
 53    const runtime: DesktopRuntimeImpl = {
 54      setApiFetch(fetch) {
 55        apiFetch = fetch
 56        return () => { if (apiFetch === fetch) apiFetch = undefined }
 57      },
 58      sendFrame(stream, json) {
 59        const listeners = stream === 'mux' ? muxListeners : hostListeners
 60        for (const listener of listeners) listener(json)
 61      },
 62      graph: () => {
 63        const modules = ctx.get('clientModules')
 64        if (modules === undefined) throw new Error('desktop-app: clientModules is not mounted')
 65        return modules.graph()
 66      },
 67      async loadBundleSource(url) {
 68        const id = clientIdFromBundleUrl(url)
 69        const modules = ctx.get('clientModules')
 70        const path = modules?.clientPath(id)
 71        if (path === undefined) throw new Error(`desktop-app: no client bundle for ${id}`)
 72        return readFile(path, 'utf8')
 73      },
 74      subscribeMux(listener) {
 75        muxListeners.add(listener)
 76        return () => { muxListeners.delete(listener) }
 77      },
 78      subscribeHost(listener) {
 79        hostListeners.add(listener)
 80        return () => { hostListeners.delete(listener) }
 81      },
 82      async fetchFromPreload(request) {
 83        if (apiFetch === undefined) {
 84          return { status: 503, headers: { 'content-type': 'text/plain; charset=utf-8' }, body: 'desktop-app: api fetch not installed' }
 85        }
 86        const init: RequestInit = { method: request.method, headers: request.headers }
 87        if (request.body !== null) init.body = request.body
 88        const response = await apiFetch(new Request(request.url, init))
 89        const headers: Record = {}
 90        response.headers.forEach((value, key) => { headers[key] = value })
 91        const encoded = await encodeIpcFetchBody(response)
 92        return { status: response.status, headers, ...encoded }
 93      },
 94    }
 95    ctx.provide('desktopRuntime', runtime)
 96  }
 97
 98  function isTextualIpcContentType(contentType: string): boolean {
 99    const media = contentType.split(';', 1)[0]!.trim().toLowerCase()
100    return media === '' || media.startsWith('text/') || media === 'application/json' || media.endsWith('+json')
101  }
102
103  async function encodeIpcFetchBody(response: Response): Promise<{ body: string; bodyEncoding?: 'base64' }> {
104    const contentType = response.headers.get('content-type') ?? ''
105    if (isTextualIpcContentType(contentType)) return { body: await response.text() }
106    return { body: Buffer.from(await response.arrayBuffer()).toString('base64'), bodyEncoding: 'base64' }
107  }

逐段讲解

第 13-17 行 · 插件元数据

ts 复制代码
export const name = 'desktop-app'
export const inject: string[] = []
  • name:插件名(desktop-runtime 行装载它)。
  • inject: []无必选服务------注释说"提供者给后续行用",所以它不依赖任何服务(依赖关系由 Loader 的 inject 机制管理,它自己是被依赖方)。

第 20-26 行 · DesktopRuntimeImpl

ts 复制代码
export interface DesktopRuntimeImpl extends DesktopRuntime {
  readonly graph: () => WebBootGraph
  loadBundleSource(url: string): Promise
  subscribeMux(listener): () => void
  subscribeHost(listener): () => void
  fetchFromPreload(request): Promise
}
  • 扩展自 @deepseek-ai/dsh-client-connectionDesktopRuntime 基接口(含 setApiFetch / sendFrame,这是 connection 插件需要的)。
  • 新增 5 个方法graph(启动图)、loadBundleSource(读 bundle)、两个订阅、fetchFromPreload(无 HTTP 的 fetch)。

第 44-47 行 · clientIdFromBundleUrl

ts 复制代码
function clientIdFromBundleUrl(url: string): string {
  const pathname = new URL(url, 'http://dsh.internal').pathname
  return decodeURIComponent(pathname.replace(/^\/plugins\//u, '').replace(/\/client\.js$/u, ''))
}
  • /plugins//client.js?rev=... 格式的 URL 解析出插件 id。http://dsh.internal虚拟 base ------纯粹为了用 URL 解析相对路径,没有真实网络。

第 49-96 行 · apply(ctx)(核心)

三个内部状态:

ts 复制代码
const muxListeners = new Set<(json: string) => void>()
const hostListeners = new Set<(json: string) => void>()
let apiFetch: ((request: Request) => Promise) | undefined
  • 两个订阅者集合(mux/host 通道)+ 一个可变的 apiFetch 处理器。

setApiFetch(第 54-57 行):

ts 复制代码
setApiFetch(fetch) {
  apiFetch = fetch
  return () => { if (apiFetch === fetch) apiFetch = undefined }
},
  • connection 插件注入真正的 fetch 处理函数(ApiProxy 的 dispatch)。
  • 返回幂等 disposer:只有当前值还是自己时才清掉------防止后注册的覆盖者被先注册的卸载误删。

graph(第 62-66 行):

ts 复制代码
graph: () => {
  const modules = ctx.get('clientModules')
  if (modules === undefined) throw new Error('desktop-app: clientModules is not mounted')
  return modules.graph()
},
  • 懒读取 ctx.clientModules(patch 里的 modules 行)------注释说"read lazily",因为 apply 时 modules 可能还没挂载,必须在调用时再取。
  • 取不到就 fail-fast。

loadBundleSource(第 67-73 行):

ts 复制代码
async loadBundleSource(url) {
  const id = clientIdFromBundleUrl(url)
  const modules = ctx.get('clientModules')
  const path = modules?.clientPath(id)
  if (path === undefined) throw new Error(`desktop-app: no client bundle for ${id}`)
  return readFile(path, 'utf8')
},
  • clientPath(id):clientModules 把插件 id 映射到磁盘上的 lib/client.js 路径。
  • readFile 读源码返回字符串------第 3 讲 renderer.ts 把它包装成 Blob URL 加载。

subscribeMux / subscribeHost(第 74-81 行):

ts 复制代码
subscribeMux(listener) {
  muxListeners.add(listener)
  return () => { muxListeners.delete(listener) }
},
  • Set 增删 = 可逆注册的又一体现(返回 unsubscriber)。

fetchFromPreload(第 82-93 行):

ts 复制代码
async fetchFromPreload(request) {
  if (apiFetch === undefined) {
    return { status: 503, ... }
  }
  const init: RequestInit = { method: request.method, headers: request.headers }
  if (request.body !== null) init.body = request.body
  const response = await apiFetch(new Request(request.url, init))
  ...
  return { status: response.status, headers, ...encoded }
},
  • 无 HTTP 的"fetch" :把渲染进程的 IPC 请求翻译成标准 Request → 交给 apiFetch(ApiProxy)→ 再把 Response 翻译回 IPC 响应。
  • 503 兜底:handler 未安装时明确报错(而不是静默失败)。

ctx.provide('desktopRuntime', runtime)(第 95 行):

  • 最终效果 :把 runtime 挂到 ctx 上,第 3 讲的 ctx.get('desktopRuntime') 从此可用。

第 98-107 行 · 响应体编码

ts 复制代码
function isTextualIpcContentType(contentType: string): boolean {
  const media = contentType.split(';', 1)[0]!.trim().toLowerCase()
  return media === '' || media.startsWith('text/') || media === 'application/json' || media.endsWith('+json')
}

async function encodeIpcFetchBody(response: Response): Promise<{ body: string; bodyEncoding?: 'base64' }> {
  const contentType = response.headers.get('content-type') ?? ''
  if (isTextualIpcContentType(contentType)) return { body: await response.text() }
  return { body: Buffer.from(await response.arrayBuffer()).toString('base64'), bodyEncoding: 'base64' }
}
  • 文本内容(text/*、json)按 utf8 传输;二进制(ZIP 等)转 base64------IPC 通道只能传字符串,所以需要这个编码层。

🖼️ 第 5 讲架构图

图 5-1 · desktop-app bundle 的职责:配置名册 + desktopRuntime 胶水


⚙️ 机制小结

  1. bundle = 补丁包 :desktop-app 只做两件事------插配置行(名册)+ 提供 desktopRuntime 服务(胶水)。
  2. 传输无关的 ApiProxy :web 挂 HTTP、桌面挂 setApiFetch------fetchFromPreload 把 IPC 请求翻译成标准 Request/Response,上层 API 完全不知道传输方式。
  3. 懒读取模式graph() / loadBundleSource() 都在调用时 ctx.get('clientModules'),因为 apply 时依赖可能尚未挂载。
  4. 双面插件dsh.client 行 = 浏览器端插件名册,Node 半加载、浏览器半渲染------前端也是插件体系。
  5. 环境差异处理在 bundle 层:directory-picker 强制 native、禁用 HMR------bundle 按环境声明差异,核心代码零改动。

🧪 动手验证

sh 复制代码
# 1) 在组合树里定位 desktop 专属行
cd D:\code\deepseek-harness
node apps/cli/lib/bin.js --profile desktop --dump-config | grep -E "desktop-runtime|api-gateway|directory-picker"

# 2) 看浏览器插件名册(dsh.client 行在 dump 里的形态)
node apps/cli/lib/bin.js --profile desktop --dump-config | grep "ui-"

# 3) 对比 web 与 desktop 的差异行
diff <(node apps/cli/lib/bin.js --profile web --dump-config) <(node apps/cli/lib/bin.js --profile desktop --dump-config) | head -40

📚 深入指引

文件 作用
packages/bundle/desktop-app/cordis.patch.yml 桌面端插件名册(387 行)
packages/bundle/desktop-app/src/index.ts desktopRuntime 实现(本讲全行)
packages/bundle/desktop-app/src/client/ 浏览器侧 connection/IPC 客户端(选读)
packages/api/ ApiProxy(fetchFromPreload 最终调用的实现)

下一讲预告:从桌面壳进入 harness 核心------core 六包(session / system-prompt / tools / agent / agent-loop / scope)的关键函数与依赖关系,理解"一次回合的骨架"。

相关推荐
贵慜_Derek2 小时前
DeepSeek Harness 记忆解读:场域分层、参考架构与实现思路
人工智能·agent·deepseek
樊小肆2 小时前
DeepSeeker-Code源码导读07-安全防线
人工智能·agent
dong_junshuai3 小时前
每天一个开源项目#74 OpenViking:3万星Agent上下文数据库
开源·github·agent
夫子3963 小时前
【第三部分:第一个 Agent 应用】10. 不使用框架,手写一个最小 Agent
llm·agent·ai编程
小林ixn3 小时前
Vibe Coding 爽完就返工?试试 Spec-Driven Development:AI 时代真正的工作流
agent·vibecoding
武子康3 小时前
模型分数涨了,它真的学会了吗?LittleLearner 拆开了三种可能
人工智能·llm·agent
theNamek3 小时前
我给 multi-agent 系统写了一层"社交记忆":谁靠谱、谁坑过你、凭什么这么说
agent
ltqvibe3 小时前
Agent OS:企业智能体的控制平面
人工智能·平面·agent·智能体·企业ai
柒和远方4 小时前
V073:SDD 规范驱动开发:文档即代码,两次创造与 proposal/design/task 三份规范
llm·agent