跳到主要内容
路径文档

客户端资源与模块

审计基线 0.1.5-alpha.1 @ 5dda764ed3;见 源码 / npm 渠道

一句话版:浏览器端的"活数据"统一由资源地址表达——一个 dsh-resource://<type>/… 地址对应一个资源,协议归属包用 ctx.resources.register 注册唯一 provider,任何 slot 组件用 useResource<P>(address) 读它;右侧栏则是 ui-dockkit(宿主无关引擎)+ ui-sidebar-right(产品宿主)+ 两个 tab 类型包(files / text)的组合。

这四个包都是 0.1.5-alpha.1 新增的。读完你能回答:一个地址怎么变成会自我更新的值、插件怎么把自己的协议接进来、dockkit 为什么不是插件、以及文件树点开一个文件之后到底发生了什么。

一、资源模型:一个地址,一个 provider

  • 资源地址是 dsh-resource://<type>/… 形式的 URL,host 段就是协议名RESOURCE_SCHEME = 'dsh-resource'protocolOf(address) 取 host)。
  • 需要作用域的协议把作用域编进 path,例如 dsh-resource://file/session/<sessionId>/<相对工作区根的路径>dsh-resource://file/absolute/<绝对路径>。模型本身只认识地址。
  • 其它 scheme(如 sidebar://guide)是导航地址,不命名任何资源。
  • ResourceProtocolMap(声明在 packages/client/ui-slots/src/index.ts)是"协议 → 值类型"的声明合并登记表:消费方按类型参数取值类型,不必 import owner 的运行时

二、ctx.resources:注册、按住、订阅

成员约定
register<P>(provider)为某个协议注册唯一 provider;重复注册抛错。返回幂等 disposer(调用方自己放进 ctx.effect
pin(address, signal)不订阅也把资源按住,直到 signal abort;已 abort 的 signal 什么也不 pin
source(address)hook 背后的裸 observable;getSnapshot() 读取不持有资源

provider 契约(src/client/contract.ts):

interface ResourceProvider<P> {
readonly protocol: P
open(address: string, ctx: { signal: AbortSignal }): AsyncIterable<RemoteResult<Value>>
reload?(address: string): void
}

三条硬约定:

  • open第一帧是当前内容,之后每帧一个变化;
  • 失败是帧,不是抛错——{ ok: false, error } 让资源进入 failed 并保留上一帧的值;流里抛异常是编程错误,不会被捕获;
  • 必须响应 signal:最后一个 holder 释放时流被 abort。

生命周期:每个地址一条 record,页面级存活、永不回收(只丢弃状态)。holder 计数 = 订阅者 + pin;第一个 holder 打开流,之后的 holder 共享;最后一个释放时 abort 并把快照重置为 idle。保留 record 是为了让 source() 在 React"先渲染再订阅"的窗口和 StrictMode 重挂载之间保持引用稳定。

三、useResource:四态

@deepseek-ai/dsh-client-resources 的浏览器半部在 apply 顶层建好 registry,然后:

ctx.slots.provideRoot({ keyedHooks: { resource: address => resources.source(address) } })

于是每个 slot 组件都在 props 上拿到 useResource(与作用域无关)。

status含义
none该协议的 provider 未注册,或地址不是 dsh-resource://
loadingprovider 已开、还没出第一帧
livevalue 是最近一帧 ok 的值
failed最近一帧失败;value 保留上一帧,failure 是这次的错误

reload() 向 provider 要一帧新的;协议没有 provider 或没有 reload 时是空操作。

四、client 模块系统:dsh.client → 浏览器 bundle

浏览器插件不是被宿主 import 的,而是声明 + 懒加载

声明作用
package.json#dsh.client.platform: "web"声明这是一个浏览器插件
exports["./client"]浏览器半部的入口 bundle
dsh.client.inject[]模块图依赖(bundle 加载顺序)——与 cordis 的 inject 服务依赖是两回事,别混
dsh.client.external[]平台 seed 表之外需要额外共享的模块请求

@deepseek-ai/dsh-client-modules 的宿主半部扫描启用的 Loader 条目、组成 boot graph(注入为 window.__DSH_BOOT__)、经 /plugins 提供 bundle;浏览器半部懒加载:执行一个 bundle 只注册 factory,模块体(含 CSS 注入)到 materialization 时才跑。

ui-dockkit 不是插件:它没有 dsh.client,用 tsdown 的 staticLinked 预设静态链接进消费方 bundle(packages/client/tsdown.client.ts)。它是库,不是可装载的浏览器插件——这也解释了它 README 里"内部引擎、不承诺稳定 API"的定位。

五、右侧栏的组合

ui-sidebar-rightinject = ['slots', 'layout', 'locale', 'resources'])提供两个服务与一个域:

名字角色
ctx.sidebarRight导航与布局控制器:openResource / openTab / close / active / isExpanded / toggleExpanded / focus / split / float / dock
ctx.sidebarRightTabstab 类型注册表:register(definition) / candidates(address) / claim(address, kind?)
Tab 域每个 (Session, tab id) 保留 navigation、abort signal 与绑定 actions;只有记录移除或插件卸载才 abort

它同时把 dockkit 接到产品上:每个打开的 tab 通过 ctx.resources.pin(address, signal) 把资源按住,所以切走再切回来能立刻读到最新值,而不用重开流。

座位(slot)一览,全部由 ui-sidebar-right 运行时声明:

座位种类用途
rightbar组件面板本体;它声明子座位 sidebar.right.pane.tab(keyed)、.title(keyed)、sidebar.right.tab.menu.item(list)
conversation.session.header.corner组件面板收起时的展开按钮
sidebar.right.pane.tabkeyedtab 的身体,键 = 类型定义的 id
sidebar.right.tab.guidechain替换 guide 页内容而不替换 tab
sidebar.right.tab.menu.itemlist追加内容级菜单项(布局手势由 dockkit 自己拥有)

六、tab 类型两阶段注册

  1. 类型ctx.sidebarRightTabs.register({ id, kind, patterns?, priority?, canOpen?, title, guide? })——纯静态声明,返回 disposer;id 是实现在 tab 系统里的身份,重复注册抛错。
  2. 身体ctx.slots.register({ name: 'sidebar.right.pane.tab', key: definition.id }, Body),body 用 useTabInfo(){ sidebar, panel, tab }

路由规则(编辑器解析器惯例):

维度规则
bandextension(默认,最高)> builtin > fallback
pattern: 匹配整个地址(dsh-resource://file/**);不含则匹配 URI 的 path 任意深度(*.md,忽略大小写)
同 band 排序匹配到的 pattern 更长者优先,再按注册顺序
否决canOpen(address) 返回 false 的候选直接出局

两个出厂类型:

kindprioritypatterns角色
ui-sidebar-filesfilesbuiltin无(页面,不认领地址)工作区文件树;guide 条目 order 10
ui-sidebar-textpreviewtextfallbackdsh-resource://file/**纯文本查看器;任何更具体的类型都能抢走地址

ui-sidebar-files 是"页面":它不认领地址,guide 里给一个入口,树里的文件行通过 tab.actions.openResource 打开,交给 file 的查看器去认领。

七、贯通示例:一个 dsh-resource://file/… 地址的一生

关键分工:元数据走资源流,正文走分页调用file 资源的值只有 { absolutePath, version, bytes?, changed };文本由查看器自己一页页读,所以 Host 报"文件变了"时,屏幕上的旧文本不会被悄悄替换,而是出现一条可重载的提示条。

八、源码佐证

位置符号 / 事实
packages/client/resources/src/client/contract.tsResourceProviderResourcesResourceSnapshotUseResourceResourceStatusResourceOpenContextctx.resources 声明合并
packages/client/resources/src/client/resources.tsRESOURCE_SCHEMEprotocolOfResourceRegistryResourceRecord
packages/client/resources/src/client/index.tsinject = ['slots']provideRoot({ keyedHooks: { resource } })
packages/client/resources/src/index.ts宿主半部为空(apply(): void {})——资源模型只活在浏览器
packages/client/ui-slots/src/index.tsinterface ResourceProtocolMap {}(声明合并宿主)
packages/client/modules/README.mddsh.client 声明、platform: 'web'exports["./client"]dsh.client.externalwindow.__DSH_BOOT__/plugins
packages/client/tsdown.client.tsstaticLinkedisStaticLinkedConfigclientBundle
packages/client/ui-dockkit/src/contract/types.tsLayoutStateLayoutOpPaneId/SplitId/TabIdTabRecordDockModeDockZone
packages/client/ui-dockkit/src/contract/adapter.tsDockLabelsTabRendererTabMenuExtrasDockIntents
packages/client/ui-sidebar-right/src/client/index.tsinjectctx.reflect.provide('sidebarRight' / 'sidebarRightTabs')rightbarconversation.session.header.corner 座位
packages/client/ui-sidebar-right/src/client/service.tsISidebarRight 十个方法
packages/client/ui-sidebar-right/src/client/tab-registry.tsSidebarRightTabPriorityextension/builtin/fallback)、registercandidatesclaimcoexists
packages/client/ui-sidebar-right/src/client/contract/params.tsSidebarRightResourceParamsMapSidebarRightTabParamsMapSidebarRightNavigationParams
packages/client/ui-sidebar-files/src/client/definition.tsFILES_KIND = 'files'FILES_IDpriority: 'builtin'、guide order 10
packages/client/ui-sidebar-textpreview/src/client/definition.tsTEXTPREVIEW_KIND = 'text'patterns: ['dsh-resource://file/**']priority: 'fallback'canOpen
packages/client/ui-sidebar-textpreview/src/client/rpc.tshostFileOfcreateReadPageWorkspaceFilesReadRemote
packages/bundle/web-app/cordis.patch.ymlresources / ui-sidebar-right / ui-sidebar-textpreview / ui-sidebar-files 四行

九、验证

# 1. 浏览器插件名册与装载顺序(resources 在 ui-sidebar-right 之前)
dsh web --dump-config | grep -iE "modules|connection|resources|ui-sidebar|ui-dockkit"

# 2. dockkit 没有 dsh.client,是静态链接库而非插件
grep -n '"dsh"' packages/client/ui-dockkit/package.json || echo "no dsh.client: static-linked library"
grep -n "staticLinked" packages/client/ui-dockkit/tsdown.config.ts

# 3. 协议与地址语法
grep -n "dsh-resource" packages/client/resources/src/client/resources.ts \
packages/api/workspace-files/src/client/types.ts

# 4. 浏览器里:打开会话右侧栏 → 文件标签 → 点开一个文本文件。
# DevTools 应看到 POST /api 的 workspaceFiles.stat / read,
# 以及 /api/remote.mux 上按会话共享的 workspaceFiles.changes 流。

一个可观察的行为:同一个文件在两个不同会话里打开是两个 tab(地址不同),而同一路径重复打开只会聚焦已有 tab 并投递新的 navigation.params——内容身份是整条地址。

下一步