Files
deepseek-harness/docs/subsystems/client-resources.zh.md
T

7.4 KiB
Raw Blame History

客户端资源

English | 中文

客户端资源模型把一个地址变成任何 Web Client 组件都能读的活数据。dsh-client-resources 提供 ctx.resources 服务与 useResource 全局标准 hook;拥有某类内容的包为它的协议注册一个提供方,组件按地址读取该内容的当前状态,而无需引用拥有者的运行时。右侧 Sidebar 的 tab 是这个模型的第一个消费方(右侧 Sidebar);决策记录见 客户端资源模型 Agent Note

本页是面向开发者的参考:地址怎么写、提供方怎么注册、资源怎么读、状态与失败各是什么意思、模型怎样持有与释放一份资源。

地址

资源地址是 dsh-resource://<type>/… 形式的 URL。host 命名协议,必须是 ResourceProtocolMap 的键;路径归协议自己,由其拥有者逐段做百分号编码。需要作用域的协议把作用域放进路径:file 协议的地址形如 dsh-resource://file/session/<sessionId>/<相对该会话工作区根的路径>dsh-resource://file/absolute/<去掉前导 / 的绝对路径>,用 dsh-util-workspace-pathfileAddressFor(sessionId, cwd, path) 构造、parseFileAddress(address) 读回。模型本身只读 scheme 与 hostprotocolOf(address)dsh-resource:// URL 返回小写 host,对其它任何字串返回 undefined。其它 scheme 下的地址——Sidebar 的 sidebar://guide——不指向资源,读作 none

地址 协议键 读作
dsh-resource://file/session/s1/notes/a.md file 会话 s1 工作区根下 notes/a.md 的元数据(file 提供方已注册时)
dsh-resource://file/absolute/home/me/notes.md file 该绝对路径的元数据,经当前会话读取、受其工作区限制
DSH-RESOURCE://File/session/s1/a file 另一份记录:地址按字符串比较,openResource 只接受 fileAddressFor 生成的规范小写拼写
sidebar://guide none:导航地址
/home/me/notes.md none:不是 URL

注册提供方

协议拥有者在 ResourceProtocolMap 上声明其值类型,并在自己的 ctx.effect 里注册一个提供方,使协议与插件同寿(提供协议)。open(address, { signal }) 返回一条 RemoteResult 帧流——首帧是当前状态,之后每次变化一帧——并且必须在 signal 中止时停下。失败是携带 RemoteFailureok: false 帧;流里抛出是编程错误,不会被捕获。reload(address) 可选,请已打开的流给一个新帧。

import type { Context } from '@deepseek-ai/cordis'
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
import type {} from '@deepseek-ai/dsh-client-resources/client'

interface NoteView { readonly title: string; readonly updatedAt: string }

declare module '@deepseek-ai/dsh-client-ui-slots' {
  interface ResourceProtocolMap { note: NoteView }
}

export const inject = ['resources', 'remote']

export function apply(ctx: Context): void {
  ctx.effect(() => ctx.resources.register<'note'>({
    protocol: 'note',
    async *open(address, { signal }): AsyncIterable<RemoteResult<NoteView>> {
      const id = new URL(address).pathname.slice(1)
      yield await ctx.remote.notes.read(id, signal)
      for await (const change of ctx.remote.notes.follow(id, signal)) yield change
    },
    reload(address) { ctx.remote.notes.requestReread(new URL(address).pathname.slice(1)) },
  }), 'my-notes: note resource provider')
}

一个协议恰有一个提供方;第二次注册抛错。注册时若该协议的地址已被持有,则立刻打开它们的流;提供方 dispose 时结束这些流,地址读作 none 直到提供方回来。

读取资源

每个 slot 组件不论作用域都在 props 上收到 useResourceSlots)。useResource<P>(address) 以类型参数命名协议,返回该地址的当前快照;订阅就是持有资源的方式,另一个持有者让资源存活时,新挂载的组件立刻读到最新值而不重开流(读取资源)。

status 含义 value failure
none 地址的协议没有注册提供方,或地址不是资源地址 undefined undefined
loading 提供方的流已打开、尚未产出 undefined undefined
live 最新一帧成功 最新的 ok undefined
failed 最新一帧报告了失败 保留的上一个 ok 该帧的 RemoteFailure

reload() 请提供方给一个新帧,协议没有提供方或提供方没有 reload 时是空操作;该函数按地址引用稳定,正文可以长期持有。

import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type {} from '@deepseek-ai/dsh-api-workspace-files/client'

type Props = PropsRuntime<'sidebar.right.pane.tab'>

export function FileHeader({ tab, useResource, t }: Props) {
  const meta = useResource<'file'>(tab.contentId)
  if (meta.status === 'failed') return <p role="alert">{t('failed', { code: meta.failure.code })}</p>
  return (
    <header>
      {tab.title}
      {meta.value?.changed && <button type="button" onClick={meta.reload}>{t('reload')}</button>}
    </header>
  )
}

failed 由消费方自己呈现:模型把最后一个值留在失败旁,正文可以带提示显示旧内容而不是一片空白,下一个 ok 帧会清除失败。模型本身不产生任何用户可见文案。

持有与释放

资源有持有者就存活:一个订阅中的 useResource,或一次钉住。ctx.resources.pin(address, signal) 在不订阅的情况下让资源保持打开直到 signal 中止,已中止的信号什么也不钉;右侧 Sidebar 在每条打开的 tab 记录存续期内钉住其地址,因此切 tab 卸载正文不关流。第一个持有者打开提供方的流;最后一个释放时中止它、丢弃值,并把快照回到 loading(有提供方)或 none(没有)。提供方在这次释放之后产出的帧被丢弃,迭代器被归还。ctx.resources.source(address) 是 hook 背后的裸 observable,按地址引用稳定,供 React 之外的调用方使用;只读它的快照不算持有(生命周期)。

流只推元数据不推内容。file 提供方的值是 { absolutePath, version, bytes?, changed }absolutePathversionbytes 来自 Host 的 statchanged 在 Host 报告 agent 写入时置起、由 reload 清除。消费方自己经 Workspace Files Remote 命名空间按页读文件文本(dsh-api-workspace-files)。

限制

记录在页面存续期内保留:地址的记录在最后一个持有者离开后仍留着,不持有流也不持有值,因此内存随读过的不同地址数增长。忽略 signal 的提供方会一直跑到它的下一帧。失败类型是 Remote 面的 RemoteFailure,来源不是 Remote 调用的提供方得自己铸一个。拼错的协议或畸形的地址读作 none,没有别的诊断。