DeepSeek Harness 开发实战教程
基于 Cordis 插件框架,从零构建可扩展的 AI Agent 能力。前半部分整合官方 17 篇文档讲透核心概念;后半部分完整拆解三个已发布的真实插件,还原从脚手架、双端通信、工具注册、LLM 调用到发布上线的全过程。
概览与核心思想
DeepSeek Harness 是一个基于 Cordis 插件框架的 AI Agent 开发平台。它的核心思想是:一切皆插件,能力可组合。
什么是 Cordis?
Cordis 是一个轻量级的插件框架。在 Harness 中,每一个功能——工具注册、LLM 调用、事件监听——都是一个插件。插件之间通过 服务(Service)共享能力,通过 事件(Event)松耦合通信。
定义接口与类型
具体实现
工具 / 插件
Provider 和 Consumer 互不依赖,都只依赖 Definition —— 这就是可替换能力的秘密
四个核心概念
| 概念 | 一句话理解 | 类比 |
|---|---|---|
| 插件 (Plugin) | 导出 apply(ctx) 函数的模块,通过 ctx 注册能力 | 应用的一个功能模块 |
| 服务 (Service) | 挂载在 ctx 上的命名能力,其他插件通过 inject 消费 | 全局可注入的单例 |
| 事件 (Event) | 插件间松耦合通信机制,支持广播、串行、瀑布等模式 | 发布/订阅系统 |
| Effect | 插件生命周期内的资源管理,卸载时自动清理 | React 的 useEffect |
插件只描述自己贡献什么,cordis.yml 负责组合应用。你不需要写启动代码——配置即应用。
本教程的实战样本
后半部分的三章到十二章全部围绕三个已发布、可安装、有真实用户的插件展开,它们覆盖了 DSH 插件开发的三种主要形态:
| 项目 | 形态 | 你会学到 |
|---|---|---|
| AVdsh-artifact-viewer | 双端 bundle:Host 提供 RPC + 持久化,Browser 提供面板 | RPC 通道、文件预览、原子写入、Slot 注册 |
| GTdsh-github-trending | 双端 bundle:Tool + Host HTTP 路由 + LLM 生成 | defineTool 全套字段、systemPrompt、缓存、长轮询、调用 LLM |
| SPspritely | 以客户端为主,Host 仅可选地注册设置命名空间 | 可选依赖降级、设置同步、状态源、划词工具条 |
第一个插件
插件就是一个导出 apply 函数的 TypeScript 模块。框架加载时调用它,传入上下文对象 ctx。
最小插件
import type { Context } from '@deepseek-ai/cordis'
export const name = 'hello-plugin'
export function apply(ctx: Context) {
console.log('[hello-plugin] plugin loaded!')
}
三种插件形态
函数形式最常用,但 Cordis 还支持对象形式和类形式:
① 函数形式(推荐)
export function apply(ctx: Context) { /* ... */ }
② 对象形式
export const objectPlugin = {
name: 'object-plugin',
apply(ctx: Context) { /* ... */ }
}
③ 类形式(用于提供服务)
export class MyService extends Service {
constructor(ctx: Context) {
super(ctx, 'myService')
}
}
大多数情况用函数形式。当插件需要向其他插件提供服务时,用类形式(继承 Service)。对象形式较少用。
组合应用:cordis.yml
插件写好后,通过 cordis.yml 组合成应用:
# cordis.yml — 这就是你的应用
- name: './hello.ts'
列表中的各项并发启动,顺序由服务依赖(inject)决定,而非文件位置。
如果模块路径拼写错误,Cordis 会通过 logger 报告错误但不会崩溃。如果新增插件似乎没效果,先检查路径拼写。
生命周期与 Effect
插件可能因配置修改、热重载、资源释放或依赖消失而卸载。Cordis 通过 Effect 机制确保资源被正确清理。
Fiber 状态机
每个已加载的插件实例都有一个 Fiber(运行时句柄),在以下状态间转换:
| 状态 | 含义 |
|---|---|
| PENDING | 已声明,但所需服务尚未就绪 |
| LOADING | 依赖就绪,正在执行 apply |
| ACTIVE | 插件运行中 |
| FAILED | apply 或配置校验抛出异常 |
| UNLOADING | 正在卸载并释放资源 |
| DISPOSED | 已完全卸载 |
Effect:管理自定义资源
对于 Cordis 不管理的资源(定时器、连接、watcher),用 ctx.effect() 包装,返回一个 disposer:
export function apply(ctx: Context) {
ctx.effect(() => {
// 加载时:创建资源
const timer = setInterval(() => console.log('tick'), 200)
// 卸载时:返回清理函数
return () => {
clearInterval(timer)
console.log('cleaned up')
}
})
}
三个实战项目都给 ctx.effect 传了第二个参数(标签)。它不影响行为,但会在诊断日志和 registry dump 里显示,排查"为什么资源没释放"时非常有用:
ctx.effect(() => () => cache.dispose(), 'github-trending: cache disposal')
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-sprite: dictionaries')
已经是 Effect 的操作
大多数内置 API 本身就是 effect,不需要手动清理:
| 操作 | 自动清理行为 |
|---|---|
ctx.on(event, listener) | 卸载时自动移除监听器 |
ctx.plugin(child) | 子插件随父插件一同卸载 |
ctx.tools.register(...) | 卸载时自动注销工具 |
| 服务注册 | 提供方卸载时移除服务 |
ctx.webServer.register(...) | 返回 disposer,需放进 effect |
Disposer 按注册顺序的逆序启动,但多个异步 disposer 会并发运行。如果拆除步骤必须按顺序,请把它们放在同一个 disposer 中串行等待。
手动卸载子插件
// 挂载子插件,获得 fiber 句柄
const fiber = ctx.plugin(childPlugin)
// 稍后手动卸载(等待所有异步清理完成)
await fiber.dispose()
服务与依赖注入
服务是插件向其他插件公开的命名能力。Harness 中的 ctx.tools、ctx.llm、ctx.agents 都是服务。
提供服务
继承 Service 基类,在构造函数中用 super(ctx, '服务名') 注册:
import { Service, type Context } from '@deepseek-ai/cordis'
// 编译时:TypeScript 声明合并,让 ctx.greeter 有类型
declare module '@deepseek-ai/cordis' {
interface Context {
greeter: GreeterService
}
}
export class GreeterService extends Service {
constructor(ctx: Context) {
super(ctx, 'greeter') // 运行时:以 'greeter' 名称注册
}
greet(who: string) {
return `Hello, ${who}!`
}
}
export const name = 'greeter'
export function apply(ctx: Context) {
ctx.plugin(GreeterService) // Service 子类本身就是插件
}
消费服务:inject
用 inject 声明依赖,框架保证服务就绪后才加载你的插件:
export const name = 'consumer'
export const inject = ['greeter'] // 声明依赖
export function apply(ctx: Context) {
// 这里 ctx.greeter 一定已经就绪
console.log(ctx.greeter.greet('world'))
}
提供 greeter 服务
等待服务就绪
依赖的动态行为
inject 不是一次性检查。运行期间如果服务消失:
- 依赖它的插件会自动卸载
- 服务恢复后,插件自动重新加载
这就是为什么可以在运行时替换服务提供方——所有依赖方会自动重启并使用新实现。
可选依赖的两种写法
写法 A:探测式(ctx.get)
如果服务缺失时插件仍可运行,跳过 inject,用 ctx.get() 探测:
export function apply(ctx: Context) {
const greeter = ctx.get('greeter') // 无提供方时为 undefined
console.log(greeter?.greet('maybe') ?? 'no greeter available')
}
写法 B:回调式(ctx.inject)—— spritely 用的就是这种
把"需要服务才能做的那部分"隔离到一个回调里。服务缺失时插件照常加载,只是那段注册不发生;服务后来出现时,回调会被重新执行。这是最优雅的降级方式。
spritely/src/index.ts/** 注册持久化设置命名空间;没有 settings 服务时插件仍然可用(回退 localStorage)。 */
export function apply(ctx: Context): void {
ctx.inject(['settings'], (settingsCtx) => {
settingsCtx.settings.register(
settingsNamespace(SPRITELY_SETTINGS_NAMESPACE),
SpritelySettingsSchema,
)
})
}
服务名称共用扁平命名空间。为自有服务添加辨识度前缀(如 myApp_metrics),因为 harness 已占用 tools、llm 等普通名称。
事件系统
事件让插件无需知道谁在监听,就能发出通知。Harness 用事件处理工具结果、模型请求、审批决定等交互。
基本用法
// 监听事件
ctx.on('event-name', (payload) => {
// 处理事件
})
// 触发事件
ctx.emit('event-name', payload)
五种分发模式
| 模式 | 调用方式 | 语义 |
|---|---|---|
| emit | ctx.emit(name, ...args) |
同步广播,不等待返回值 |
| parallel | await ctx.parallel(name, ...args) |
所有监听器并发运行,一同等待 |
| serial | await ctx.serial(name, ...args) |
按顺序运行,第一个非空返回值胜出并停止 |
| bail | ctx.bail(name, ...args) |
serial 的同步版本 |
| waterfall | ctx.waterfall(name, ...args, next) |
环绕中间件,可转换或短路 |
waterfall:拦截与转换
waterfall 是最强大的模式。每个监听器收到参数和 next(),可以:
- 调用
next()获取下游结果,然后转换它 - 不调用
next()直接返回,短路整条链
// 监听器 1:包装下游结果
ctx.on('demo/transform', async (input, next) => {
const downstream = await next()
return downstream.toUpperCase()
})
// 监听器 2:条件短路
ctx.on('demo/transform', async (input, next) => {
if (input.includes('blocked')) return '** blocked **'
return next()
})
// 触发
await ctx.waterfall('demo/transform', 'hello', async () => 'hello')
// → "HELLO"
await ctx.waterfall('demo/transform', 'blocked words', async () => 'blocked words')
// → "** BLOCKED **"
只负责观察或标注的监听器必须调用 next()。不调用就直接返回代表有意短路。如果日志监听器忘记调用 next(),会悄无声息地吞掉所有下游行为。
类型安全的事件
用 TypeScript 声明合并为事件提供类型:
declare module '@deepseek-ai/cordis' {
interface Events {
'stats/report'(name: string, count: number): void
'demo/transform'(input: string, next: () => Promise<string>): Promise<string>
}
}
事件采用 namespace/action 命名(如 tools/result、agent/request),保持扁平命名空间易读。
配置与 Schema
插件可以接受来自 cordis.yml 的配置。通过导出 Schemastery schema,框架在加载前验证配置,错误配置会导致明确的加载失败。
定义可配置插件
import type { Context } from '@deepseek-ai/cordis'
import Schema from '@deepseek-ai/schemastery'
export const name = 'config-demo'
// TypeScript 接口
export interface Config {
greeting: string
targets: string[]
}
// 同名的运行时 schema(默认值写在这里)
export const Config: Schema<Config> = Schema.object({
greeting: Schema.string().default('Hello'),
targets: Schema.array(String).default(['world']),
})
export function apply(ctx: Context, config: Config) {
for (const target of config.targets) {
console.log(`${config.greeting}, ${target}!`)
}
}
在 cordis.yml 中配置
- name: './config-demo.ts'
config:
targets: ['alpha', 'beta']
# greeting 未提供,使用 schema 默认值 'Hello'
严格校验示例
export const Config = Schema.object({
apiKey: Schema.string().required(),
timeout: Schema.number().default(30000),
mode: Schema.union(['fast', 'accurate']).default('fast'),
})
配置不合法时,插件进入 FAILED 状态并给出明确错误:
ValidationError: invalid config:
- $.targets expected array but got not-an-array (at targets)
Schemastery 能表达类型、默认值、联合、必填,但表达不了"两个字段必须成对出现""必须是正整数"这类交叉约束。github-trending 的做法是 schema 先兜一层默认值,apply 里再断言一次:
function assertPositiveInteger(name: string, value: number): void {
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`github-trending: ${name} must be a positive integer`)
}
}
// provider 与 model 必须成对配置:只给一个是不可能工作的
const hasProvider = resolved.overviewsProvider !== undefined && resolved.overviewsProvider !== ''
const hasModel = resolved.overviewsModel !== undefined && resolved.overviewsModel !== ''
if (hasProvider !== hasModel) {
throw new Error('github-trending: overviewsProvider and overviewsModel must be configured together')
}
凡是不同部署可能需要不同值的参数,都必须定义为配置字段。检验标准:能否在 cordis.yml 中改变这个值,而不需要修改代码?
计算配置值:!!js
Loader 支持 !!js 标签,用于加载时计算配置值:
- name: './config-demo.ts'
config:
greeting: !!js process.env.DEMO_GREETING ?? 'Hello'
!!js 仅在 config 和 disabled 字段内有效。
组合与热重载
cordis.yml 不只是插件列表——它是应用的定义。本章讲解配置项元数据、热模块替换(HMR)和如何诊断"插件不加载"的问题。
配置项的完整字段
- id: greeter # 稳定标识,用于 HMR 区分修改 vs 删除重建
name: './greeter.ts' # 模块路径或 npm 包名
disabled: true # 跳过挂载,但保留配置项
config: # 插件配置
key: value
不带 id 的配置项每次读取都会获得新生成的 id。编辑 cordis.yml 时,即使自身文本未变,也会被视为"先删除再添加"并重新挂载。显式 id 让 loader 只更新真正变化的部分。
热模块替换 (HMR)
加载 @deepseek-ai/cordis-plugin-hmr 后,保存文件会自动:
# cordis.yml
- id: logger
name: '@deepseek-ai/cordis-plugin-logger-console'
- id: timer
name: '@deepseek-ai/cordis-plugin-timer'
- id: hmr
name: '@deepseek-ai/cordis-plugin-hmr'
config:
root: ['.']
- id: hello
name: './hello.ts'
HMR 插件需要 logger(输出日志)和 timer(去抖)服务。缺少 timer 时,HMR 会永远停在 PENDING 且不发出任何提示——这是最常见的"静默失败"场景。
诊断:为什么我的插件没输出?
如果插件的 inject 指定了无人提供的服务,它会一直 PENDING,不输出任何内容。用以下代码诊断:
import { FiberState, type Context } from '@deepseek-ai/cordis'
export function apply(ctx: Context) {
setTimeout(() => {
for (const runtime of ctx.registry.values()) {
for (const fiber of runtime.fibers) {
if (fiber.state === FiberState.PENDING) {
console.log(`${fiber.name} is PENDING — 缺少必需服务`)
}
}
}
}, 500)
}
开发一个 Tool
Tool 是模型可调用的工具。Harness 提供 defineTool DSL,让你声明参数、输出和执行逻辑,框架自动处理 JSON Schema 生成、参数校验和结果渲染。
完整示例
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'greet-tool'
export const inject = ['tools']
export function apply(ctx: Context) {
ctx.tools.register(defineTool({
name: 'greet',
description: 'Greet someone by name.',
// 参数定义 → 自动生成 JSON Schema,自动校验模型输入
parameters: {
name: {
type: 'string',
required: true,
description: 'The name to greet',
},
},
// 输出:规范值 + 渲染器
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
// 执行逻辑
async execute(args) {
return `Hello, ${args.name}!`
},
}))
}
defineTool 各字段解析
| 字段 | 作用 |
|---|---|
name | 工具名称,模型通过它调用 |
description | 工具描述,告诉模型何时使用 |
parameters | 参数定义,自动转为 JSON Schema 并校验模型输入 |
output.schema | 声明 execute 返回值的规范类型 |
output.render | 将规范值转为可持久化的内容块(text/image 等) |
execute | 实际执行逻辑,接收校验后的参数 |
上面只是最小可用集。第 16 章会用 GT 的完整工具定义展示生产级的全部字段,包括结果卡片、超时预算、并发安全声明。
通过流水线执行工具
import { CallId } from '@deepseek-ai/dsh-llm'
const result = await ctx.tools.execute({
callId: CallId('demo-1'),
name: 'greet',
arguments: { name: 'Cordis' },
signal: new AbortController().signal,
})
console.log('tool replied:', JSON.stringify(result.content))
观察工具调用:事件监听
import type {} from '@deepseek-ai/dsh-tools' // 引入类型声明合并
export function apply(ctx: Context) {
ctx.on('tools/result', (exec, result) => {
const text = result.content
.map(block => block.type === 'text' ? block.text : '')
.join('')
console.log(`[tool-logger] ${exec.name} -> ${text}`)
})
}
工具插件需要 tools 服务,而 dsh-tools 又需要 systemPrompt 服务。完整组合:dsh-system-prompt → dsh-tools → 你的工具插件。
能力的三层拆分
当一项能力足够通用、需要支持可替换的提供方时,Harness 将其拆分为三种角色:Service Definition、Service Provider 和 Consumer。
三种角色
定义接口 + Request/Result 类型
具体实现 1
具体实现 2
将能力暴露给模型
| 角色 | 职责 | 依赖 |
|---|---|---|
| Service Definition | 定义 Cordis 服务接口、Request/Result 类型 | 无(最稳定) |
| Service Provider | 实现具体能力(如本地执行、远程执行) | Definition |
| Consumer | 将能力包装为工具或其他消费形式 | Definition |
Provider 和 Consumer 互不依赖,都只依赖 Definition。这意味着更换提供方时,工具代码完全不需要改动。
以 Bash 能力为例
| 包 | 角色 | 作用 |
|---|---|---|
dsh-shell | Definition | 定义 shell 服务及 Bash 请求/结果类型 |
dsh-bash-local | Provider | 在本地计算机执行命令 |
dsh-tool-bash | Consumer | 将 shell 能力公开为模型可调用的工具 |
实战:构建三层能力
第一步:Service Definition
// my-cap/src/index.ts
import { Service, type Context } from '@deepseek-ai/cordis'
declare module '@deepseek-ai/cordis' {
interface Context { myCap: MyCapService }
}
export abstract class MyCapService extends Service {
constructor(ctx: Context) { super(ctx, 'myCap') }
abstract execute(request: MyCapRequest): Promise<MyCapResult>
}
export interface MyCapRequest { input: string }
export interface MyCapResult { output: string }
第二步:Service Provider
// my-cap-local/src/index.ts
import type { Context } from '@deepseek-ai/cordis'
import { MyCapService, type MyCapRequest, type MyCapResult } from '@deepseek-ai/dsh-my-cap'
class MyCapLocal extends MyCapService {
async execute(request: MyCapRequest): Promise<MyCapResult> {
return { output: request.input.toUpperCase() }
}
}
export const name = 'my-cap-local'
export function apply(ctx: Context) {
ctx.plugin(MyCapLocal)
}
第三步:Consumer(工具)
// tool-my-cap/src/index.ts
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'tool-my-cap'
export const inject = ['tools', 'myCap']
export function apply(ctx: Context) {
ctx.tools.register(defineTool({
name: 'my_cap',
description: 'Execute my capability.',
parameters: { input: { type: 'string', required: true } },
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args) {
const result = await ctx.myCap.execute({ input: args.input })
return result.output
},
}))
}
组合
- name: '@deepseek-ai/dsh-my-cap-local' # Provider
- name: '@deepseek-ai/dsh-tool-my-cap' # Consumer
只有当角色需要独立演进或替换时,才使用不同包。简单的工具插件无需拆分——一个文件就够了。
LLM 适配器开发
LLM 适配器将 Harness 的提供方无关请求转换为具体模型 API 的调用,并将响应转换回 Harness 的流式分片协议。
最小实现
import type { Context } from '@deepseek-ai/cordis'
import Schema from '@deepseek-ai/schemastery'
import {
LlmAdapter, type GenerateOptions, type StreamChunk
} from '@deepseek-ai/dsh-llm'
class MyAdapter extends LlmAdapter {
constructor(private apiKey: string) { super() }
async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
// 1. 将 options.messages 转换为提供方格式
// 2. 调用流式 API
// 3. 将响应转换为 StreamChunk
}
}
export interface Config {
apiKey: string
providers: string[]
}
export const Config: Schema<Config> = Schema.object({
apiKey: Schema.string().required(),
providers: Schema.array(Schema.string()).required(),
})
export const name = 'my-llm-adapter'
export const inject = ['llm']
export function apply(ctx: Context, config: Config) {
const adapter = new MyAdapter(config.apiKey)
ctx.llm.registerAdapter(config.providers, adapter)
}
StreamChunk 协议
stream() 必须按以下协议生成分片:
async function *exampleChunks(): AsyncIterable<StreamChunk> {
// 文本块
yield { type: 'block-start', index: 0, blockType: 'text' }
yield { type: 'text-delta', index: 0, text: 'Hello' }
yield { type: 'text-delta', index: 0, text: ' world' }
yield {
type: 'block-end', index: 0,
block: { type: 'text', text: 'Hello world' },
}
// 工具调用块
yield { type: 'block-start', index: 1, blockType: 'tool-call' }
yield {
type: 'tool-call-delta', index: 1,
id: CallId('call-123'), name: 'bash',
argumentsDelta: '{"command":"ls"}',
}
yield {
type: 'block-end', index: 1,
block: { type: 'tool-call', id: CallId('call-123'), name: 'bash', arguments: '{"command":"ls"}' },
}
// 使用量 + 结束
yield { type: 'usage', usage: { inputTokens: 100, outputTokens: 50 } }
yield { type: 'finish', reason: { kind: 'stop' } }
}
- 每个
block-start必须有对应的block-end index从 0 开始递增finish必须是最后一个分片usage必须在finish之前
本章是注册新模型提供方(写适配器)。如果你只是想在插件里用现有模型做一次生成(写调用方),请直接看第 18 章——那里用的是 ctx.llm.stream(),代码量小一个数量级。
错误处理
适配器应通过带稳定 code 的 LlmError 抛出错误,agent loop 会保留该错误用于诊断和策略处理:
import { LlmAdapter, LlmError, attributionHeaders } from '@deepseek-ai/dsh-llm'
class HttpAdapter extends LlmAdapter {
async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
const response = await fetch(this.endpoint, {
method: 'POST',
headers: { 'content-type': 'application/json', ...attributionHeaders() },
body: JSON.stringify({ model: options.model, messages: options.messages }),
...options.signal ? { signal: options.signal } : {},
})
if (!response.ok) {
throw new LlmError(`Provider API error: ${response.status}`, 'PROVIDER_HTTP_ERROR')
}
// ... 解析响应并输出分片
}
}
在 cordis.yml 中使用
- id: my-llm
name: './src/my-llm-adapter.ts'
config:
apiKey: !!js process.env.MY_API_KEY
providers:
- my-provider
- id: agent-loop
name: '@deepseek-ai/dsh-agent-loop'
config:
agents:
- id: main
provider: my-provider
model: my-model-v1
打包与发布插件
将插件打包为可安装的组合包(bundle),用 dsh plugin add 安装进 profile。
两个核心概念
| 概念 | 是什么 | manifest | 回答的问题 |
|---|---|---|---|
| 组合包 (Bundle) | 附带配置层的 npm 包 | dsh.bundle |
这个包贡献什么? |
| Profile | 描述一份可启动组合的目录 | dsh.profile |
这套配置由哪些组合包按什么顺序组成? |
组合包结构
package.json
{
"name": "dsh-hello-plugin",
"version": "0.1.0",
"type": "module",
"main": "index.js",
"files": ["index.js", "cordis.patch.yml"],
"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }
}
cordis.patch.yml
- insert:
- id: hello
name: dsh-hello-plugin # 按包名引用,Node 模块解析能找到已安装代码
安装进 Profile
# 安装本地包
dsh plugin --profile demo add ./hello-plugin
# 从 GitHub 安装
dsh plugin --profile demo add github:you/hello-plugin
# 查看生效配置
dsh --profile demo --dump-config
# 启动
dsh --profile demo
配置层加载顺序
生效配置在空根之上按以下顺序逐层组合(后应用的层胜出):
- 组合包 patch(按
dsh.profile.bundles列表顺序) - profile 自己的 cordis.patch.yml
- home 级 cordis.patch.yml(各 profile 共享)
- --patch overlay(按命令行参数顺序)
后应用的层按行胜出,且 patch 会替换目标行的整个 config 值,而不是深度合并各键。覆盖时必须重述该行需要的每一个键。
从 GitHub 安装的注意事项
Git 安装拉取的是源码,不是构建产物。需要两边各做一件事:
- 作者:提供
prepare脚本,pnpm 在 git 安装后运行它来构建 - 用户:在 profile 的
pnpm-workspace.yaml中授权构建:allowBuilds: dsh-hello-plugin: true
允许 prepare 意味着允许该包的代码在安装时于你的机器上执行,且不在 agent 沙箱之内。只对源码可信的包授权,并锁定 commit(github:you/hello-plugin#sha)。
如果不想让用户做构建授权,可以:
- 发布到 npm:
pnpm publish时构建好,用户安装的是预构建代码 - 交付 tarball:
pnpm pack打包,用户dsh plugin add ./hello-plugin-0.1.0.tgz
速查手册(基础)
基础 API 和模式的快速参考。含 Host 端 RPC、Client 端 Slot、Tool、LLM 调用等实战 API 的完整速查见第 23 章。
插件骨架
import type { Context } from '@deepseek-ai/cordis'
export const name = 'my-plugin'
export const inject = ['tools'] // 可选:声明依赖
export function apply(ctx: Context) {
// 在这里注册能力
}
常用 API 一览
| API | 用途 |
|---|---|
ctx.on(event, handler) | 监听事件(自动清理) |
ctx.emit(event, ...args) | 同步广播事件 |
ctx.serial(event, ...args) | 串行执行,第一个非空返回值胜出 |
ctx.waterfall(event, ...args, next) | 瀑布式中间件 |
ctx.plugin(child) | 挂载子插件,返回 fiber |
ctx.inject(['svc'], cb) | 可选依赖回调(缺失也能加载) |
ctx.effect(() => cleanup, 'label') | 管理自定义资源 |
ctx.get('serviceName') | 可选获取服务(无则 undefined) |
ctx.tools.register(defineTool({...})) | 注册工具 |
ctx.llm.registerAdapter(names, adapter) | 注册 LLM 适配器 |
事件模式选择
| 需求 | 模式 |
|---|---|
| 通知所有监听器,不关心返回值 | emit |
| 所有监听器并发执行,等待全部完成 | parallel |
| 按顺序执行,第一个"认领"的胜出 | serial / bail |
| 拦截/转换/短路流水线 | waterfall |
常见问题排查
| 症状 | 可能原因 | 解决 |
|---|---|---|
| 插件没输出,不报错 | inject 的服务无提供方,处于 PENDING | 检查依赖服务是否已挂载 |
| HMR 不工作 | 缺少 timer 服务,HMR 插件 PENDING | 在 cordis.yml 中添加 cordis-plugin-timer |
| 配置修改没生效 | 配置项没有 id,被视为删除重建 | 为每个配置项添加稳定 id |
| waterfall 下游行为消失 | 某个监听器忘记调用 next() | 观察型监听器必须调用 next() |
| TypeScript 类型错误 | 缺少 declare module 声明合并 | 添加 interface Context / Events 声明 |
学习路径建议
项目实战篇 · 第 12 — 23 章
从这里开始,全部内容来自三个已发布、可 dsh plugin add 安装、有真实用户的插件源码。它们分别代表 DSH 插件的三种主要形态。本章先给出全景地图与选型指南,随后逐层拆解每一个技术点。
全景:三种插件形态
动手写代码之前,先看清"插件"这个词在 DSH 里到底有几种可能。同一个 npm 包里可以同时存在 Host 半部分(Node 端)和 Client 半部分(浏览器端),两端的职责边界决定了你会用到哪些 API。
双端模型
一个 DSH bundle 插件有两半,各自有独立的入口、独立的依赖、独立的生命周期:
src/index.ts → lib/index.js(Node ESM)
src/client/index.ts → lib/client.js(CJS 工厂)
只能 Host 访问
只能 Client 注册
唯一通道:Client → Host 只能走 RPC(rpc.call)或 HTTP(宿主 webServer 路由)。Client 永远不能直接 require('node:fs')。
三个样本对照
| 维度 | AVartifact-viewer | GTgithub-trending | SPspritely |
|---|---|---|---|
| 形态 | 双端,Host 重 | 双端,两端都重 | Client 为主,Host 极轻 |
Host 依赖inject |
['connection'] |
['tools','systemPrompt','webServer','llm','agentDefaultModel'] |
无(ctx.inject(['settings'], cb) 可选) |
Client 依赖inject |
['slots','locale','sessions','connection','workspaces'] |
['slots','locale'] |
['slots','sessions','workspaces','locale','conversation','connection','remote','settingsScope'] |
| Host 职责 | RPC 通道 + 文件预览 + 磁盘持久化 | 注册 Tool + HTTP 路由 + 缓存 + 调 LLM | 注册设置命名空间(可有可无) |
| Client 职责 | 侧栏按钮、右侧面板、拦截消息图片加星标 | 右侧趋势面板(纯展示 + 轮询) | 精灵吉祥物 + 划词工具条 |
| 数据持久化 | Host 磁盘(~/.dsh/storages/) |
Host 内存缓存(重启即冷) | Host 设置文档(~/.dsh/settings.yaml) |
| 对应章节 | 14、15 | 16、17、18 | 19、20 |
加载链路(四个阶段)
dsh plugin --profile <name> add <pkg> 把 pnpm add 转发到 profile 目录,然后对账 dsh.profile.bundles:装了且声明了 dsh.bundle 的包追加进列表,卸载的移出。cordis.patch.yml,往配置树插入一行。lib/index.js,调用 apply(ctx, config)。dsh.client 元数据,serve /plugins/<pkg>/client.js,通过 window.__ModuleLoader__.load({ id, factory }) 注入浏览器模块表。可以,但 Host 入口通常还要保留——spritely 的 src/index.ts 只有八行,作用仅仅是"如果宿主有 settings 服务,就登记一个命名空间"。一个空 apply 的 Host 入口也是有用的:它是插件在 loader 树里的锚点,也让 cordis.patch.yml 有一行可插入。
选型决策树
工程脚手架与双端构建
DSH 共享的 clientBundle 预设没有对外发布,第三方插件必须在自己的 tsdown.config.ts 里复刻同样的产物格式。这是外部插件开发最大的一块隐形门槛,本章把它彻底讲透。
目录结构
package.json 解剖
三个项目都遵循同一套约定,以下以 AV 为例(已省略 repository / keywords 等无关字段):
package.json{
"name": "@wangjunjian/dsh-artifact-viewer",
"version": "0.4.0",
"type": "module",
"packageManager": "pnpm@10.33.2",
// ── 双入口 ─────────────────────────────────
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" },
"./client": { "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" },
"./cordis.patch.yml": "./cordis.patch.yml", // loader 需要读它
"./package.json": "./package.json"
},
// ── DSH manifest ───────────────────────────
"dsh": {
"bundle": { "patch": "./cordis.patch.yml" },
"client": {
"platform": "web",
"inject": ["@deepseek-ai/dsh-client-runtime"],
"external": ["@deepseek-ai/dsh-client-ui-primitives"]
}
},
"files": ["lib/index.js", "lib/client.js", "lib/types/**/*.d.ts",
"cordis.patch.yml", "README.md", "LICENSE"],
// ── 依赖分层 ───────────────────────────────
"peerDependencies": { // 宿主/web shell 提供,不随包安装
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.2",
"react": "^18.2.0"
// …其余 @deepseek-ai/* 全部列出
},
"dependencies": { // 真正要随包安装的(会被内联进 client bundle)
"@deepseek-ai/schemastery": "^3.18.1"
},
"scripts": {
"build": "tsc -b tsconfig.json && tsdown",
"prepare": "tsc -b tsconfig.json && tsdown", // git 安装 / publish 时自动构建
"test": "vitest run",
"typecheck": "tsc --noEmit",
"lint": "biome check ."
}
}
| 字段 | 为什么必须有 |
|---|---|
exports["./client"] | web shell 按这个子路径找浏览器包。缺了客户端压根不会被 serve。 |
exports["./cordis.patch.yml"] | loader 要能按包名解析到 patch 文件,否则 dsh plugin add 后配置层不生效。 |
dsh.bundle.patch | 告诉 CLI 这个包是 bundle,并指明配置层文件。 |
dsh.client.platform | 标记 web,宿主才知道要把它 serve 给浏览器。 |
dsh.client.inject / external | 声明模块表里共享的依赖,配合构建期的 purity gate 使用。 |
scripts.prepare | git 安装拿到的是源码,靠它构建。npm 发布时也会跑一遍,保证产物最新。 |
publishConfig.access | scoped 包默认 private,必须显式写 "public"。 |
packageManager | 固定 pnpm 版本,CI 的 pnpm/action-setup 自动读取。 |
react 和所有 @deepseek-ai/* 都必须声明为 peer。原因是浏览器端模块表由 web shell 冻结提供,如果你的包把 react 装了一份副本进去,运行时会拿到两个 React 实例,hooks 直接崩。只有真正会被内联的第三方库(如 schemastery、node-html-parser)才进 dependencies。
tsdown 双产物配置
一次构建要产出两类东西:
| 产物 | 格式 | 平台 | 谁加载 |
|---|---|---|---|
lib/index.js(+ 其他 lib/*.js) | ESM | node | DSH Host 的 Cordis Loader |
lib/client.js | CJS 闭包工厂 | browser | 浏览器的 window.__ModuleLoader__ |
① Node 半部分:标准库构建
function clientLibraryConfig(id, libEntry) {
return {
name: id,
entry: [...libEntry], // ['lib/types/index.js', 'lib/types/invariant.js']
outDir: 'lib',
format: ['esm'],
platform: 'node',
target: 'es2024',
dts: false, // 类型由 tsc -b 单独产出到 lib/types
clean: false, // 两个 config 写同一个 outDir,不能互相清空
}
}
② 浏览器部分:闭包工厂 + banner/footer 包裹
web shell 加载的是一个调用 __ModuleLoader__.load 的脚本文件,模块表通过 factory(require) 注入。这个包装完全靠 outputOptions 的 banner / footer / intro 拼出来:
outputOptions: {
entryFileNames: 'client.js',
// 关键:把整个 bundle 包成一个 factory 函数体
banner: `window.__ModuleLoader__.load({ id: ${JSON.stringify(id)}, factory: (require) => {`,
footer: 'return module.exports; } });',
intro: 'var module = { exports: {} }; var exports = module.exports;',
// 关键:必须内联动态 import。mermaid 这类库内部用动态 import,
// 不内联会被拆成几十个 chunk,浏览器 module loader 根本加载不了。
inlineDynamicImports: true,
}
artifact-viewer 引入了 mermaid(渲染 Markdown 里的图表)。mermaid 内部大量使用动态 import(),默认配置下 tsdown 会把它拆成几十个 chunk 文件;而 web shell 只加载一个 factory 文件,那些 chunk 永远不会被请求,运行时报模块找不到。inlineDynamicImports: true 是外部 UI 插件的必选项。
③ 模块表 external
web shell 维护一张冻结的模块表,表内的包由宿主提供,你的 bundle 不能内联它们:
const MODULE_TABLE_EXTERNALS = new Set([
'react', 'react/jsx-runtime', 'react-dom', 'react-dom/client',
'@deepseek-ai/cordis',
'@deepseek-ai/dsh-client-ui-slots',
'@deepseek-ai/dsh-client-ui-primitives',
'@deepseek-ai/dsh-client-runtime/client',
])
deps: {
neverBundle: (s) => MODULE_TABLE_EXTERNALS.has(s),
alwaysBundle: (s) => !MODULE_TABLE_EXTERNALS.has(s),
}
④ Purity gate:把"不该内联"变成构建期错误
光靠 external 不够——万一有人手滑 import { X } from '@deepseek-ai/dsh-xxx' 引了个不在表里的包,它会被静默内联,运行时出现微妙的 scope 不匹配。两个项目都在 tsdown 插件里加了硬闸门:
plugins: [{
name: 'dsh-artifact-viewer-bundle-purity',
resolveId(source) {
if (!source.startsWith('@deepseek-ai/')) return null
if (isModuleTableExternal(source)) return null
throw new Error(
`client bundle purity: "${source}" is not in the module table — `
+ 'declare it in dsh.client.external or collaborate through cordis services',
)
},
}]
import type { Context } from '@deepseek-ai/cordis' 在 tsc 阶段就被擦除了,resolveId 根本看不到。所以三个项目的 client 入口顶部都堆着一排 import type {} from '…/client'——那是拉声明合并用的,不是值依赖。跨插件协作一律走 cordis 服务,不走 import。
⑤ CSS Modules:虚拟模块 + 自动注入 style 标签
tsc 只编译 TS,不拷贝样式表。所以 CSS Modules 必须在 tsdown 侧处理:拦截 *.module.css,用 lightningcss 编译,再生成一个虚拟模块把 CSS 注入成插件自有的 <style> 标签。
const CSS_VIRTUAL_PREFIX = '\0dsh-css:'
const CSS_VIRTUAL_SUFFIX = '.mjs'
plugins: [{
name: 'dsh-artifact-viewer-css-modules',
resolveId(source, importer) {
if (!source.endsWith('.module.css') || importer === undefined) return null
const resolved = new URL(source, `file://${importer}`).pathname
// tsc 把 JS 吐在 lib/ 但没拷 CSS,所以要把 lib 路径映射回 src 再读
const sourcePath = resolved.replace(/\/lib\//, '/src/')
return CSS_VIRTUAL_PREFIX + sourcePath + CSS_VIRTUAL_SUFFIX
},
async load(virtualId) {
if (!virtualId.startsWith(CSS_VIRTUAL_PREFIX)) return null
const fileId = virtualId.slice(CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length)
this.addWatchFile(fileId) // watch 模式能热更
const source = await readFile(fileId)
const { code, exports: cssExports } = transform({
filename: fileId, code: source,
cssModules: { pattern: '[hash]_[local]' }, // 哈希类名,避免和宿主撞名
minify: true,
})
// 收集 classMap 作为默认导出:import css from './X.module.css'
const classMap = {}
for (const [local, exp] of Object.entries(cssExports ?? {})) classMap[local] = exp.name
return styleInjectionModule(fileId, code.toString(), classMap)
},
}]
生成的虚拟模块长这样(去重靠 data-plugin-css 属性查询):
const css = "._hash_panel{...}";
const tagId = "@wangjunjian/dsh-artifact-viewer/ArtifactPanel.module.css";
if (typeof document !== 'undefined'
&& document.querySelector('style[data-plugin-css=' + JSON.stringify(tagId) + ']') === null) {
const tag = document.createElement('style');
tag.dataset.plugin = "@wangjunjian/dsh-artifact-viewer";
tag.dataset.pluginCss = tagId;
tag.textContent = css;
document.head.appendChild(tag);
}
export default { "panel": "_hash_panel", /* … */ };
⑥ define 注入:别让浏览器读到未定义变量
define: {
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV ?? 'production'),
'import.meta.env.MODE': JSON.stringify(process.env.NODE_ENV ?? 'production'),
'import.meta.env': JSON.stringify({ MODE: process.env.NODE_ENV ?? 'production' }),
}
TypeScript 配置要点
module/moduleResolution用 NodeNext,源码里相对导入必须写.js/.ts后缀。declarationDir单独输出到lib/types,与 JS 产物分离,避免互相覆盖。lib/不提交 git;npm 包内容完全由files字段白名单控制。- src 里需要一个
css-modules.d.ts,让 TS 认识*.module.css的默认导出。
AV / GT 直接 export default { … } satisfies UserConfig,client 入口指向 lib/client/index.js。
SP 则把配置抽成了 clientBundle(id, libEntry) 工厂函数,导出一个函数式 config,并用环境变量 DSH_BUILD_FACE 控制只构建 node 半还是双端都构建——CI 里只想跑类型检查时可以跳过浏览器包,节省一半时间。中大型插件建议照抄这个做法。
Host 端:RPC 通道与持久化
AV 要解决的是"浏览器想读写用户磁盘上的文件、想保存跨会话的收藏"。浏览器做不到,Host 能——于是用一条 loopback RPC 通道把能力暴露出去。
核心 API
| API | 位置 | 作用 |
|---|---|---|
ctx.get('connection') | Host | 拿到宿主连接句柄(inject: ['connection']) |
connection.rpc.handle(channel, handler, opts) | Host | 注册一条 RPC 通道的处理器,返回 disposer |
ctx.get('connection').rpc | Client | 浏览器侧调用端 |
rpc.call(channel, endpoint, payload) | Client | 发起一次调用,返回 { ok, value?, error? } |
通道命名:第一个坑
最初用 /plugin/artifact-viewer 作为 channel,DSH 启动直接报错:
connection: invalid or reserved RPC channel "/plugin/artifact-viewer"
正确做法:用自定义的短路径,并在 Host 与 Client 两边共用同一个常量。
// 两边都写死同一个字符串;生产代码建议抽到共享模块导出
const CHANNEL = '/artifact-viewer'
Handler 的形状
一个 channel 下挂多个 endpoint,用第一个参数分发。返回值必须是统一的 RpcResult 信封——不要抛异常给调用方,把错误变成 ok: false 的数据。
export const name = 'artifact-viewer'
export const inject = ['connection']
export function apply(ctx: Context, config: Config): void {
if (!config.enabled) return
const connection = ctx.get('connection') as HostConnectionHandle
connection.rpc.handle(
CHANNEL,
async (endpoint, payload): Promise<RpcResult> => {
// 1) 公共入参校验(每个 endpoint 都要 projectPath)
const rawProjectPath = (payload as { projectPath?: unknown }).projectPath
if (typeof rawProjectPath !== 'string') return badRequest('projectPath is required')
const projectPath = await resolveProjectPath(rawProjectPath)
// 2) 按 endpoint 分发
if (endpoint === 'bookmarks/read') return readBookmarks(projectPath)
if (endpoint === 'bookmarks/write') return writeBookmarks(projectPath, payload)
if (endpoint === 'file/preview') return previewFile(projectPath, payload)
return badRequest(`unknown endpoint ${endpoint}`)
},
{ authority: 'loopback' }, // ← 关键:只接受本机回环调用
)
}
// 统一的失败信封
function badRequest(message: string): RpcResult {
return { ok: false, error: { code: 'bad-request', message, details: { issues: [] } } }
}
| 约定 | 说明 |
|---|---|
authority: 'loopback' | 只接受来自本机的调用。局域网浏览器访问时这条通道不可用,插件必须自己降级(spritely 在第 19 章就是这么处理的)。 |
| 统一错误信封 | { ok: false, error: { code, message, details } }。code 用稳定字符串(bad-request / internal),前端可据此决定是重试还是提示。 |
| endpoint 白名单 | 未知 endpoint 返回错误而不是静默忽略,便于排查前后端拼写不一致。 |
持久化:三个必须做对的细节
① 路径用 dshHomePath,不要自己拼
import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
function storagesDir(): string {
return dshHomePath('storages', 'artifact-viewer') // ~/.dsh/storages/artifact-viewer
}
function bookmarksStoreFile(): string {
return join(storagesDir(), 'bookmarks.json')
}
dshHomePath 会尊重 $DSH_HOME 覆盖,用户改了宿主目录也不会写到错误的地方。
② 写入必须原子:tmp + rename
直接 writeFile(file) 时进程被杀会留下半截 JSON,下次启动直接解析失败、用户收藏全丢。
async function saveBookmarkStore(store: BookmarkStore): Promise<RpcResult> {
const file = bookmarksStoreFile()
try {
await mkdir(storagesDir(), { recursive: true })
const tmp = `${file}.tmp`
await writeFile(tmp, `${JSON.stringify(store, null, 2)}\n`)
await rename(tmp, file) // 同分区内 rename 是原子的
return { ok: true, value: null }
} catch (error) {
return { ok: false, error: { code: 'internal', message: errorMessage(error), details: {} } }
}
}
③ key 要 realpath 规范化
收藏按项目路径分组存储。如果不做规范化,同一个目录可能因为一次走 /var/folders/…、另一次走 /private/var/folders/…(符号链接)而写出两份互不可见的数据——用户会看到收藏时有时无。
async function resolveProjectPath(raw: string): Promise<string> {
try { return await realpath(raw) }
catch (error) { if (isENOENT(error)) return raw; throw error }
}
文件预览:二进制与编码
文本走 utf8、二进制走 base64,并做三重保护:必须是文件、不超过 512 KB、文本必须是合法 UTF-8。
const PREVIEW_MAX_BYTES = 512 * 1024
// Buffer.toString('utf8') 会静默替换非法字节,必须用 fatal 模式显式检测
function isValidUtf8(buffer: Buffer): boolean {
try {
const decoder = new TextDecoder('utf-8', { fatal: true })
decoder.decode(buffer)
return true
} catch { return false }
}
// 客户端拿到 base64 后按扩展名推断 mediaType 再展示
function inferMediaType(path: string): string {
const lower = path.toLowerCase()
if (lower.endsWith('.md') || lower.endsWith('.markdown')) return 'text/markdown'
if (lower.endsWith('.png')) return 'image/png'
// …
return 'application/octet-stream'
}
Client 侧:把 RPC 包成控制器 + 快照仓库
组件不该直接碰 rpc.call。AV 用一个 BookmarkController 收敛全部读写,并把状态放进 SnapshotStore,组件订阅它即可。
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
const CHANNEL = '/artifact-viewer'
export class BookmarkController {
readonly store: SnapshotStore<BookmarkState>
constructor(private readonly rpc: ClientConnectionRpc) {
this.store = createSnapshotStore<BookmarkState>({
status: 'idle', bookmarks: [], error: null,
})
}
async load(projectPath: string): Promise<void> {
this.store.update((state) => { state.status = 'loading'; state.error = null })
const result = await this.rpc.call(CHANNEL, 'bookmarks/read', { projectPath })
if (!result.ok) { this.fail(result.error.message); return }
if (!Array.isArray(result.value)) { this.fail('bookmarks file is not an array'); return }
this.store.update((state) => {
state.status = 'ready'
state.bookmarks = result.value as BookmarkRecord[]
})
}
async toggle(projectPath: string, record: BookmarkRecord): Promise<void> {
const exists = this.store.getSnapshot().bookmarks.some((e) => e.id === record.id)
await (exists ? this.remove(projectPath, record.id) : this.add(projectPath, record))
}
private fail(message: string): void {
this.store.update((state) => { state.status = 'error'; state.error = message })
}
}
- 乐观更新:
add/remove/toggle先在本地快照上改,再发起写请求,UI 无延迟。 - 错误不丢:任何失败都落到
status: 'error' + error,组件只管渲染,不用 try/catch。 - 可测:把
rpc换成假对象就能单测整个控制器,不需要浏览器。
端到端数据流
SnapshotStore
/artifact-viewer
fs 读写
Client 端:Slots 插槽与 Face 注入
Client 插件不"渲染页面",而是往宿主声明的插槽里贡献组件。三个项目加起来用了五个不同的插槽,本章把它们和配套的三件套(locale、store、face)一次讲完。
两个 API 的分工
| API | 语义 | 何时调用 |
|---|---|---|
ctx.slots.inject(name, cb) |
声明:"等 name 这个插槽被创建出来时,调用 cb 注册我的组件"。cb 返回 cleanup。 |
宿主模块可能晚于你加载(shell.overlay 由 ui-layout 声明)。这是唯一可靠的注册方式。 |
ctx.slots.register(desc, Component) |
登记:真正把组件放进插槽,返回一个 disposer。 | 在 inject 的回调里调用。 |
插槽是宿主模块声明的,而模块加载顺序由服务依赖决定、不由文件顺序决定。直接 ctx.slots.register('shell.overlay', …) 时该插槽很可能还不存在。永远套一层 inject。
最小可运行骨架
GT 的 client 入口只有 48 行,是最干净的模板:
src/client/index.tsximport type {} from '@deepseek-ai/dsh-client-locale/client'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client' // 拉 slot 契约
import { GithubTrendingPanel } from './GithubTrendingPanel.js'
import { en, type GithubTrendingKey, zh } from './locales.js'
const NS = 'github-trending'
// 声明合并:让 t('panel.title') 有 key 自动补全和类型检查
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap { 'github-trending': GithubTrendingKey }
}
export const inject = ['slots', 'locale']
export function apply(ctx: ClientContext): void {
// ① 注册字典(effect 包裹 → 卸载自动注销)
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'github-trending: dictionaries')
// ② 等插槽出现 → 登记组件
ctx.slots.inject('shell.overlay', () =>
ctx.slots.register(
{
name: 'shell.overlay',
id: 'github-trending-panel', // 全局唯一,重名会冲突
order: 20, // additive 插槽的排序权重
locale: NS, // 组件自动拿到 t 函数
},
GithubTrendingPanel,
),
)
}
常用插槽清单
| 插槽名 | 类型 | 用途 | 谁在用 |
|---|---|---|---|
shell.overlay | additive | 浮在整个应用之上的层。右侧面板、精灵吉祥物、划词工具条都在这。 | AVGTSP |
sidebar.footer.action | additive | 左侧栏底部的操作按钮(如设置按钮那一排)。 | AV |
conversation.message.images | single | 消息里的图片区块渲染器。要替换默认实现。 | AV |
conversation.chat.turnTail | single | 每轮对话尾部的附加区(文件链接行等)。 | — |
tool.call.toolview | single | 工具调用的卡片视图。 | — |
给 conversation.message.images 注册时如果用默认优先级 0,会和宿主默认实现撞车:
single slot "conversation.message.images" already has a registration at priority 0
正解是设置 priority: -1 去 shadow 默认实现:
ctx.slots.inject('conversation.message.images', () =>
ctx.slots.register(
{ name: 'conversation.message.images', locale: NS, priority: -1,
inject: (): MessageImagesFace => ({ hooks: { bookmarks: bookmarks.store }, bookmarks }) },
ArtifactMessageImages,
),
)
Face 注入:组件与业务的解耦层
插槽组件是纯展示的,它拿不到 ctx,也不该拿。宿主通过 inject 回调把一族能力(叫 face)塞进 props。三个项目都定义了显式的 face 接口:
/** 注入给浮动产物面板的业务面 */
export interface ArtifactPanelFace {
hooks: {
currentSession: CurrentSessionSource // 可订阅的当前会话
bookmarks: BookmarkController['store']
}
bookmarks: BookmarkController
rpc: ConnectionHandle['rpc']
onOpenPath: (path: string) => Promise<void> // 用宿主默认应用打开
onOpenSession: (sessionId: string) => void // 跳回源会话
}
ctx.slots.inject('shell.overlay', () =>
ctx.slots.register(
{ name: 'shell.overlay', id: 'artifact-viewer-panel', order: 50, locale: NS, store: viewerStore,
inject: (): ArtifactPanelFace => ({
hooks: { currentSession, bookmarks: bookmarks.store },
bookmarks,
rpc,
onOpenPath: (path) => ctx.workspaces.openPath(path),
onOpenSession: (id) => ctx.sessions.open(id as SessionId),
}),
},
ArtifactPanel,
),
)
hooks.* 放可订阅的源(组件用 useSyncExternalStore 消费),其余放一次性动作(函数调用)。这样组件层完全没有 React 状态管理逻辑,纯 props 进、纯渲染出——也最容易测试(见第 21 章)。
共享状态:defineStore
面板的开关、宽度、当前标签页这类 UI 状态,用 runtime 提供的 defineStore。actions 是 Immer draft mutator,直接改就行:
import { defineStore } from '@deepseek-ai/dsh-client-runtime/client'
export const createArtifactViewerStore = () =>
defineStore({
init: (): ArtifactViewerState => ({
panelOpen: false, activeTab: 'current', expanded: false, width: 420,
}),
actions: {
togglePanel: (draft) => { draft.panelOpen = !draft.panelOpen },
setTab: (draft, tab: 'current' | 'bookmarks') => { draft.activeTab = tab },
openArtifactByPath: (draft, path: string) => {
draft.panelOpen = true
draft.activeTab = 'current'
draft.pendingOpenPath = path
},
},
})
把 store 挂到 slot 描述里(store: viewerStore),组件就能拿到同一份状态;注册时也可以传 createArtifactViewerStore().create() 在测试里拿到真实实例,不需要 mock 任何 hook。
三个项目的 Slot 用法对照
| 项目 | 插槽 | id | order / priority | 注入内容 |
|---|---|---|---|---|
| AV | sidebar.footer.action | artifact-viewer-toggle | order 50 | 共享 store(面板开关) |
| AV | shell.overlay | artifact-viewer-panel | order 50 | currentSession、bookmarks、rpc、onOpenPath/onOpenSession |
| AV | conversation.message.images | — | priority -1 | bookmarks(给图片加收藏星标) |
| GT | shell.overlay | github-trending-panel | order 20 | 无(组件自己 fetch 宿主路由) |
| SP | shell.overlay | sprite / selection-toolbar | — | 四个 hooks 源 + startSession + 三个 setter |
清理契约
slots.inject 的回调必须返回 cleanup,把所有在回调里创建的东西关掉。SP 是最完整的示例——它在一个回调里创建了六个资源,就返回六个 dispose:
ctx.slots.inject('shell.overlay', () => {
const source = createSpriteStateSource(ctx.sessions) // 订阅会话
const background = createBackgroundSource() // localStorage
const spriteKind = createSpriteKindSource()
const position = createSpritePositionSource()
const selection = createSelectionSource() // 划词监听
const sync = new SpriteSettingsSync(settings, { spriteKind, position, background })
const presenter = new BackgroundPresenter()
presenter.apply(background.getSnapshot())
const unsubscribeBackground = background.subscribe(() => presenter.apply(background.getSnapshot()))
const dispose = ctx.slots.register({ /* … */ }, SpriteMascot)
const disposeToolbar = ctx.slots.register({ /* … */ }, SelectionToolbar)
// 回调返回 cleanup:插槽消失 / 插件卸载时全部回收
return () => {
dispose(); disposeToolbar()
source.dispose(); selection.dispose(); sync.dispose()
unsubscribeBackground(); presenter.dispose()
}
})
注意 settings(settingsScope.bind 的结果)是在 apply 顶层创建、被回调捕获的——它属于插件生命周期,不属于插槽生命周期,所以没有出现在这个 cleanup 里。区分"谁创建谁负责"是写 cleanup 的关键。
Tool 实战:让模型会用你的能力
第 7 章的 defineTool 只是最小可用集。本章用 GT 的 github_trending 展示生产级工具的全部字段,以及一个容易被忽略的核心设计:给模型看的和给 UI 看的是两条独立的输出通道。
完整字段表
| 字段 | 作用 | 是否必填 |
|---|---|---|
name | 工具名,模型用它调用 | ✅ |
description | 告诉模型何时用这个工具(写清楚场景,别只写功能) | ✅ |
parameters | 参数定义 → 自动转 JSON Schema + 自动校验模型输入 | ✅ |
output.schema | 声明 execute 返回的规范值类型 | ✅ |
output.render | 规范值 → 内容块数组(给模型 / 持久化用) | ✅ |
output.presentationMeta | 规范值 → 可重放的紧凑 JSON(给 UI 卡片用) | 推荐 |
timeoutMs | 单次执行的协作式超时预算 | 推荐 |
isConcurrencySafe | 声明能否与其他调用并发执行 | 推荐 |
execute(args, exec) | 执行逻辑;exec.signal 是取消信号 | ✅ |
presentCall(args) | 调用中的卡片视图(pending 态) | 推荐 |
presentResult(args, result) | 完成后的卡片视图(读 presentationMeta 重放) | 推荐 |
三层输出模型
{ repositories, truncated }
→ Markdown 文本(给模型)
→ 紧凑 JSON(给 UI 卡片)
同一个规范值,两种消费方式。不要给模型塞 JSON——塞精简后的 Markdown 列表,省 token 又易读。
规范值 → 给模型的 Markdown
src/tool.tsexport interface GithubTrendingResult {
repositories: TrendingRepository[] // 规范值:结构化,UI 和未来的逻辑都靠它
truncated: boolean
}
/** 把规范值格式化成给模型看的精简 Markdown 列表 */
export function formatTrendingOutput(result: GithubTrendingResult): string {
if (result.repositories.length === 0) return 'No trending repositories found.'
const header = result.truncated
? `Top ${result.repositories.length} trending repositories (truncated):`
: `Top ${result.repositories.length} trending repositories:`
const lines = result.repositories.map((repo) => {
const today = repo.starsToday > 0 ? ` | +${repo.starsToday.toLocaleString()} today` : ''
const lang = repo.language !== undefined ? ` | ${repo.language}` : ''
const stars = repo.stars > 0 ? ` | ⭐ ${repo.stars.toLocaleString()}` : ''
const forks = repo.forks > 0 ? ` | 🍴 ${repo.forks.toLocaleString()}` : ''
const description = repo.description !== undefined ? `\n ${repo.description}` : ''
return `${repo.rank}. [${repo.fullName}](${repo.url})${lang}${stars}${forks}${today}${description}`
})
return [header, ...lines].join('\n')
}
要点:字段为空就不输出。模型看到的是"信息密度最高"的文本,而不是一堆 null。
规范值 → 给 UI 的可重放元数据
presentationMeta 会被持久化到会话记录里。未来的某次重放(重新打开旧会话)不会再执行 execute,而是拿这段元数据重新渲染卡片。所以:
- 只放渲染必需的最小字段(GT 只留
fullName / url / starsToday,丢掉了 description 和 README); - 读取时必须做防御性校验——它可能被旧版本写入、也可能被手改。
// 写:只保留卡片需要的字段
export function trendingMetaFromValue(result: GithubTrendingResult): JsonValue {
return {
count: result.repositories.length,
truncated: result.truncated,
repositories: result.repositories.map((r) => ({ fullName: r.fullName, url: r.url, starsToday: r.starsToday })),
}
}
// 读:逐个字段校验,任何一项不对就返回 undefined 让调用方降级
export function trendingMetaFromResult(meta: unknown): TrendingMeta | undefined {
if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return undefined
const { count, truncated, repositories } = meta as Record<string, unknown>
if (typeof count !== 'number' || typeof truncated !== 'boolean' || !Array.isArray(repositories)) return undefined
const repos = repositories.filter((repo): repo is TrendingRepoMeta => {
if (typeof repo !== 'object' || repo === null) return false
const r = repo as Record<string, unknown>
return typeof r.fullName === 'string' && typeof r.url === 'string' && typeof r.starsToday === 'number'
})
return { count, truncated, repositories: repos }
}
两种卡片视图
/** 调用中:一张通用卡,告诉用户"在搜什么" */
export function presentTrendingCall(args: GithubTrendingArgs): GenericCallView {
const language = args.language?.trim() || 'all languages'
return {
card: 'generic',
title: `GitHub Trending: ${language} (${args.since})`,
kind: 'search',
rawInput: `${language} / ${args.since}`,
}
}
/** 完成后:搜索结果卡,列出命中的链接。出错或元数据损坏时返回 undefined → 回退默认渲染 */
export function presentTrendingResult(args: GithubTrendingArgs, result: ToolResult): SearchResultView | undefined {
if (result.isError) return undefined
const meta = trendingMetaFromResult(result.meta)
if (meta === undefined) return undefined
return {
card: 'search',
shape: 'paths',
title: `GitHub Trending: ${args.language ?? 'all languages'} (${args.since})`,
paths: meta.repositories.map((repo) => repo.url),
total: meta.count,
truncated: meta.truncated,
}
}
注册:连同系统提示词一起
注册工具不等于模型会用它。还需要在系统提示词里告诉模型有这么个能力、什么时候用:
src/tool.tsexport function applyGithubTrendingTool(ctx: Context, config: ToolConfig): void {
ctx.systemPrompt.section({
name: 'tool:github_trending',
order: 112,
text: 'Use the github_trending tool to discover currently popular repositories on GitHub. '
+ 'It returns repository names, descriptions, languages, star counts, and stars gained today.',
})
ctx.tools.register(defineTool({
name: 'github_trending',
description: 'Fetch currently trending GitHub repositories for a language and time window.',
parameters: {
language: {
type: 'string',
description: 'Optional programming language filter (e.g. "python", "typescript", "go"). Omit to list trending repositories across all languages.',
},
since: {
type: 'string',
description: 'Time window: "daily", "weekly", or "monthly". Defaults to "daily".',
},
maxResults: {
type: 'integer',
description: `Maximum number of repositories to return (1-${Math.min(config.maxResults, MAX_RESULTS_LIMIT)}).`,
},
},
output: {
schema: { /* 完整的对象 schema,见仓库源码 */ },
render: (_args, value) => [{ type: 'text', text: formatTrendingOutput(value) }],
presentationMeta: (_args, value) => trendingMetaFromValue(value),
},
timeoutMs: config.timeoutMs,
isConcurrencySafe: () => true, // 只读的抓取,天然可并发
async execute(args, exec: ToolRunContext): Promise<GithubTrendingResult> {
const language = typeof args.language === 'string' ? args.language : undefined
const maxResults = resolveMaxResults(args.maxResults, config.maxResults)
const url = buildTrendingUrl({ language, since: args.since, maxResults })
const repositories = await fetchTrendingRepositories(url, exec.signal) // 转发取消信号
return { repositories: repositories.slice(0, maxResults), truncated: repositories.length > maxResults }
},
presentCall: presentTrendingCall,
presentResult: (args, result) => presentTrendingResult(args, result),
}))
}
防御性细节
模型输入永远不可信
export function resolveMaxResults(requested, configCap) {
const cap = Math.min(configCap, MAX_RESULTS_LIMIT)
if (requested === undefined || !Number.isFinite(requested))
return Math.min(DEFAULT_MAX_RESULTS, cap)
return Math.max(1, Math.min(Math.round(requested), cap))
}
模型可能传 99999、-3、3.7。先钳制到硬上限,再钳制到配置上限,双保险。
URL 参数要转义 + 白名单
export function buildTrendingUrl(args) {
const base = 'https://github.com/trending'
const path = args.language?.trim()
? `${base}/${encodeURIComponent(args.language.trim())}`
: base
// since 做白名单,绝不拼原始输入
const since = args.since === 'weekly' || args.since === 'monthly'
? args.since : 'daily'
return `${path}?since=${since}`
}
枚举型参数永远做白名单,不把模型输出直接拼进 URL。
组合与依赖
// src/index.ts
export const name = 'github-trending'
export const inject = ['tools', 'systemPrompt', 'webServer', 'llm', 'agentDefaultModel']
export function apply(ctx: Context, config: Config): void {
// 1) 先做交叉校验(第 5 章):正整数、成对覆盖、语言取值
// 2) 注册工具
applyGithubTrendingTool(ctx, {
timeoutMs: resolved.timeoutMs,
maxResults: Math.min(resolved.maxResults, MAX_RESULTS_LIMIT),
})
// 3) 可选:LLM 概览生成器(第 18 章)
// 4) 缓存 + web 路由(第 17 章)
}
dsh-system-prompt → dsh-tools → 你的工具插件。前两者由宿主默认装配;你的插件只需要声明 inject: ['tools', 'systemPrompt'],Cordis 会保证顺序。
Host HTTP 路由、缓存与长轮询
RPC 适合"点对点调用",但浏览器要轮询一份共享数据时,一条 HTTP 路由更自然。GT 在宿主上开了 /github-trending,把抓取放在 Host 侧——顺带解决了浏览器 CORS 问题。
注册路由
webServer.register 返回 disposer,用 ctx.effect 包起来,插件卸载时路由自动注销:
ctx.effect(
() => ctx.webServer.register({
kind: 'prefix',
path: '/github-trending',
handler: createTrendingHandler(cache, generator, introGenerator, resolved.overviewsMaxRepos),
}),
'github-trending: web route',
)
Handler 的三条规则
return async (req, res) => {
if (req.method !== 'GET' && req.method !== 'HEAD') {
res.writeHead(405, { 'content-type': 'application/json' })
res.end(JSON.stringify({ error: 'Method not allowed' }))
return
}
const url = new URL(req.url ?? '/', 'http://x')
// 按需生成项目介绍:?intro=owner/name —— 先做正则白名单
const introFullName = url.searchParams.get('intro')
if (introFullName !== null) {
if (!/^[\w.-]+\/[\w.-]+$/.test(introFullName)) {
res.writeHead(400, { 'content-type': 'application/json' })
res.end(JSON.stringify({ error: 'intro must be an owner/name repository path' }))
return
}
// …生成并返回
}
const language = url.searchParams.get('language') ?? undefined
const sinceRaw = url.searchParams.get('since') ?? 'daily'
const since = sinceRaw === 'weekly' || sinceRaw === 'monthly' ? sinceRaw : 'daily'
const forceRefresh = url.searchParams.get('refresh') === '1'
try {
// 命中缓存就直接返回;未命中才 refresh
const entry = forceRefresh
? (await cache.refreshAll(fetchers, language))[since]
: cache.get(language, since) === undefined
? await cache.refresh(fetchers[since], language, since)
: cache.get(language, since)!
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-cache' })
res.end(present(entry))
} catch (error) {
// ③ 关键:刷新失败时服务旧数据,别让整个面板挂掉
const cached = cache.get(language, since)
if (cached !== undefined) {
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
res.end(present(cached))
return
}
res.writeHead(502, { 'content-type': 'application/json; charset=utf-8' })
res.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }))
}
}
缓存设计
TrendingCache 的四个要点,任何"Host 侧抓外部数据"的插件都能直接复用:
| 要点 | 实现 |
|---|---|
| 复合 key | `${language ?? ''}:${since}`。没有语言时用空串占位,避免 undefined 被字符串化成 "undefined"。 |
| 幂等定时刷新 | ensureScheduled() 用 timers.has(key) 去重,可以放心在每次请求里调用。 |
| 带重试的超时 | 每次尝试新建 AbortController,setTimeout 触发 abort;失败等 500ms 重试,最多 3 次。 |
| 钩子不可靠 | onRefreshed 的同步异常必须吞掉——概览生成出错绝不能破坏缓存本身。 |
private async fetchWithRetry(fetcher) {
const MAX_RETRIES = 3, RETRY_DELAY_MS = 500
let lastError: unknown
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), this.options.timeoutMs)
try {
return await fetcher(controller.signal)
} catch (error) {
lastError = error
if (attempt < MAX_RETRIES) await new Promise((r) => setTimeout(r, RETRY_DELAY_MS))
} finally {
clearTimeout(timer) // 成功也要清,否则 Node 不退出
}
}
throw lastError
}
private notifyRefreshed(entry, language, since): void {
if (this.options.onRefreshed === undefined) return
try { this.options.onRefreshed(entry, language, since) }
catch { /* 钩子失败绝不能破坏缓存 */ }
}
缓存类自己持有 setInterval,Cordis 不知道它们存在。一定要实现 dispose() 并在 apply 里挂到 effect 上,否则热重载一次泄漏一组定时器:
ctx.effect(() => () => { cache.dispose() }, 'github-trending: cache disposal')
长轮询:让"生成中"真的动起来
概览是后台逐个生成的。朴素做法是前端固定间隔轮询——但轮询间隔内新生成的概览看不到,间隔太短又浪费请求。GT 的做法是 ?wait=1 长轮询:
// Host:有未完成的概览时,把请求挂起到"下一个仓库开始或结束"
const waitForProgress = req.method === 'GET' && url.searchParams.get('wait') === '1'
if (waitForProgress && generator !== undefined && hasPending(entry)) {
await generator.waitForChange(OVERVIEW_WAIT_TIMEOUT_MS)
entry = cache.get(language, since) ?? entry // 等待期间可能已被刷新替换,重新读
}
// 生成器侧:状态一变就唤醒所有等待者
waitForChange(timeoutMs: number): Promise<void> {
return new Promise((resolve) => {
const onChange = (): void => { clearTimeout(timer); resolve() }
const timer = setTimeout(() => {
this.listeners.delete(onChange)
resolve()
}, timeoutMs)
this.listeners.add(onChange)
})
}
第一版把循环写成"用 setTimeout 递归调度",依赖数组里放 hasPendingOverviews 这个布尔值。问题是:布尔值变化只会重新触发一次一次性定时器,循环跑完就停了,概览生成到一半 UI 就卡住不动。
正解:把循环体放进 effect 内部,让它自己持续跑,只在依赖变化或卸载时用 AbortController 掐断。
const hasPendingOverviews = currentEntry?.repositories.some((r) => r.overviewPending) ?? false
useEffect(() => {
if (!state.open || state.collapsed || !hasPendingOverviews) return
const controller = new AbortController()
const since = state.since
void (async () => {
for (;;) { // ← 循环在 effect 体内,不依赖定时器续命
try {
const data = await fetchEntry(since, false, controller.signal, true)
if (controller.signal.aborted) return
setEntries((prev) => ({ ...prev, [since]: data }))
if (!data.repositories.some((r) => r.overviewPending)) return
} catch {
if (controller.signal.aborted) return
await new Promise((r) => setTimeout(r, PENDING_RETRY_INTERVAL_MS))
}
}
})()
return () => { controller.abort() }
}, [state.open, state.collapsed, hasPendingOverviews, state.since, fetchEntry])
附带好处:这是 fetch 驱动而非定时器驱动,后台标签页里 setTimeout 被浏览器节流也不影响。
前端布局推挤技巧
DSH 目前没有原生右侧栏插槽,面板只能浮在 shell.overlay 上,会遮住对话。GT 用一个运行时 CSS 变量把中间列挤开:
const PUSH_CLASS = 'dsh-github-trending-pushed'
// 一次性注入样式(用 !important 覆盖宿主布局)
style.textContent = `
.${PUSH_CLASS} > div:nth-child(2),
.${PUSH_CLASS} > div:nth-child(3) {
margin-right: var(--dsh-github-trending-width, 0px) !important;
transition: margin-right var(--ds-transition-duration-slow, 0.2s) var(--ds-ease-in-out, ease);
}
`
// 向上找到 [data-shell-overlay] 的父元素(AppFrame 根),加类 + 设变量
if (enabled) {
frame.classList.add(PUSH_CLASS)
frame.style.setProperty('--dsh-github-trending-width', `${width}px`)
} else {
frame.classList.remove(PUSH_CLASS)
frame.style.removeProperty('--dsh-github-trending-width')
}
选择器全部带插件前缀 + 用宿主自己的 CSS 变量兜底(var(--ds-transition-duration-slow, 0.2s)),不侵入主项目代码,宿主改了布局也不至于崩。
在插件里调用 LLM
Tool 是"让模型用你的能力",这一章反过来:插件自己调模型。GT 用宿主 LLM 服务把每个仓库的 README 压成一段 80 词的中文简介——这是目前三个项目里唯一一处 LLM 消费,也是最容易踩坑的地方。
18.1 三步走:注入 → 解析路由 → 流式取回
调用链只有三步,但每一步都有隐含约定:
inject = ['llm', 'agentDefaultModel']。llm 提供 stream(),agentDefaultModel 提供"用户当前选了哪个模型"。
const route = resolve(),要 const resolveRoute = () => resolve()。每次调用前重新求值。
BlockAssembler 吞 chunk,finish() 后取 text 块拼成字符串。
agentDefaultModel.currentSelection() 是实时读取的。如果你在 apply() 里就把 provider/model 固化成常量,用户之后在界面上换了模型,你的插件会继续用旧模型——而且 settings 服务可能比你的插件晚挂载,早解析时它甚至还不存在。GT 的注释写得很直白:"resolve the model route per pass so a default-model change (or a settings service that mounted after this plugin loaded) takes effect without a host restart."
GT 的 src/index.ts 里是这样构造生成器的:
export const inject = ['tools', 'systemPrompt', 'webServer', 'llm', 'agentDefaultModel']
// ① 懒解析:包成箭头函数,每次 pass 开头才求值
const resolveRoute = (): OverviewRoute => {
const selection = ctx.agentDefaultModel.currentSelection()
return { provider: selection.provider, model: selection.model }
}
// ② 生成器拿到的是函数,不是值
const generator = new OverviewGenerator(ctx, resolveRoute, resolved.overviews)
18.2 一次完整的流式调用
下面这个函数是可以直接抄的模板。它做了四件容易被忽略的事:超时用 AbortSignal 而不是 race、区分 finish 原因、判空、finally 里取消定时器。
src/summaries.ts — streamGeneratedText/** Abort signal that fires after timeoutMs, with its timer handle. */
function deadline(timeoutMs: number): { signal: AbortSignal; cancel: () => void } {
const controller = new AbortController()
const timer = setTimeout(() => {
controller.abort()
}, timeoutMs)
return { signal: controller.signal, cancel: () => clearTimeout(timer) }
}
export async function streamGeneratedText(
ctx: Context,
route: OverviewRoute,
request: {
messages: GenerateOptions['messages']
system: string
maxTokens: number
timeoutMs: number
label: string
},
): Promise<string> {
const call = deadline(request.timeoutMs)
try {
const assembler = new BlockAssembler()
for await (const chunk of ctx.llm.stream({
provider: route.provider,
model: route.model,
messages: request.messages,
system: request.system,
maxTokens: request.maxTokens,
signal: call.signal,
})) {
assembler.push(chunk)
}
const finish = assembler.finish
// ① max-tokens 视为"部分成功":截断的简介好过没有简介
if (finish.kind !== 'stop' && finish.kind !== 'max-tokens') {
const detail = 'failure' in finish ? finish.failure.message : finish.kind
throw new Error(
`${request.label} via ${route.provider}/${route.model} finished with ${finish.kind}: ${detail}`,
)
}
const text = assembler
.blocks()
.filter((block) => block.type === 'text')
.map((block) => block.text)
.join('')
.trim()
// ② 空文本必须报错:否则你会在 UI 上看到一堆空白卡片
if (text === '') {
throw new Error(
`${request.label} via ${route.provider}/${route.model} returned empty text (finish: ${finish.kind})`,
)
}
return text
} finally {
// ③ 成功失败都要清定时器,否则 Node 进程被悬挂的 timer 拖住
call.cancel()
}
}
DeepSeek-R1 这类模型先输出几百到几千 token 的思维链,再输出正文。如果你给 maxTokens: 2048,推理过程就把预算吃光了,finish.kind 会给你一个 'max-tokens',正文一个字都没有——于是 text === '',抛错。所以插件里凡是要调模型的配置项,token 预算必须暴露成可配置项(GT 的 overviewsMaxTokens 默认 2048,introsMaxTokens 默认 4096),让用户在推理模型上自己调大,而不是写死在代码里。
18.3 构造消息:createUserMessage 与 source 标记
插件发起的调用不是用户在说话,但也要以 user 消息的形式送进去。宿主要知道"这条消息来自哪个插件",所以 source 字段不能省:
const system =
`You write concise overviews of GitHub repositories for a trending list. ` +
`Write in ${LANGUAGE_NAMES[options.language]}. Reply with a single short ` +
`paragraph (at most 80 words) explaining what the project is, what problem ` +
`it solves, and why it might be gaining attention. No markdown, no preamble, no headings.`
const messages = [
createUserMessage({
content: [
{
type: 'text',
text:
`Repository: ${repo.fullName}\n` +
`Description: ${repo.description ?? '(none)'}\n\n` +
`README (truncated):\n${readme}`,
},
],
// 让宿主知道来源插件 —— 日志、配额、审计都靠它
source: { kind: 'plugin', plugin: 'github-trending' },
}),
]
return streamGeneratedText(ctx, route, {
messages,
system,
maxTokens: options.maxTokens,
timeoutMs: options.timeoutMs,
label: `overview generation for ${repo.fullName}`,
})
label 是个小设计但很值:所有错误信息都带上"哪个仓库的什么调用走的哪条路由",日志里一眼能定位是 provider 配错了还是某个仓库的 README 太长。
18.4 串行调度器:绝不让 N 个 LLM 请求并发
一次刷新有最多 10 个仓库要生成简介。如果直接 Promise.all,你会:瞬间打爆 provider 限流、把用户的正常对话请求挤到排队、以及拿到一堆 429。GT 用一个串行队列解决,而且它比"简单队列"多考虑了四件事:
| 机制 | 实现 | 解决什么问题 |
|---|---|---|
| 当前窗口优先 | pickNext() 里 keys.find(k => k.endsWith(':' + activeSince)) | 用户正在看 daily,就先生成 daily,而不是按队列顺序慢慢轮到 |
| 刷新覆盖刷新 | 每个 key 带 versions 计数器,isStale: () => versions.get(key) !== version | 用户连点刷新时,放弃正在跑的旧 entry,不去改一个已被缓存替换掉的对象 |
| 失败记忆 | failures: WeakMap<entry, Map<fullName, reason>> | 失败的仓库不再显示"生成中",而是显示原因;下次刷新换了新 entry 对象,自动重试 |
| 变更广播 | waitForChange(timeoutMs) + notifyChange() | 支撑第 17 章的长轮询:每生成一个就推给面板 |
private async drain(): Promise<void> {
if (this.draining) return // 单飞:已在 drain 就直接返回
this.draining = true
try {
for (;;) {
const next = this.pickNext()
if (next === undefined) return
const { key, entry, version } = next
try {
// 每轮 pass 都重新解析路由 —— 用户换模型立即生效
const route = this.resolveRoute()
await enrichEntryWithOverviews(this.ctx, route, entry, this.options, {
isStale: () => this.versions.get(key) !== version,
onRepoStart: (repo) => {
this.current = repo.fullName
this.notifyChange()
},
onRepoDone: (repo, outcome, skipReason) => {
this.current = undefined
if (outcome === 'skipped')
this.recordFailure(entry, repo.fullName, skipReason ?? 'unknown')
this.notifyChange()
},
})
} catch {
// pass 级失败(比如插件被卸载)绝不能 reject 这个 fire-and-forget
}
}
} finally {
this.draining = false
}
}
入队函数是同步、不返回 Promise、不抛错的——它只负责把 entry 塞进队列然后 void this.drain():
request(entry, language, since): void {
const key = pendingKey(language, since)
this.versions.set(key, (this.versions.get(key) ?? 0) + 1)
this.pending.set(key, entry)
void this.drain() // 故意不 await
}
调用方(缓存刷新的 hook)因此永远不会被 LLM 生成阻塞——GitHub 抓取和渲染照常,简介是渐进式长出来的。这是"缓存刷新成功"和"简介生成完成"两件事解耦的关键。
18.5 单层失败绝不上抛
enrichEntryWithOverviews 的循环体里,每个仓库一个 try/catch,失败只记 reason 继续下一个:
for (const repo of entry.repositories.slice(0, options.maxRepos)) {
if (hooks?.isStale?.() === true) return
hooks?.onRepoStart?.(repo)
let outcome: EnrichOutcome = 'skipped'
let skipReason: string | undefined
try {
const call = deadline(options.timeoutMs)
let readme: string | undefined
try {
readme = await fetchReadmeText(repo.fullName, call.signal)
} finally {
call.cancel()
}
if (readme === undefined) {
skipReason = 'no README found on the default branch'
} else {
repo.overview = await summarizeRepository(ctx, route, repo, readme, options)
outcome = 'generated'
}
} catch (error) {
skipReason = error instanceof Error ? error.message : String(error)
ctx.logger.warn('github-trending: overview generation skipped for %s: %s', repo.fullName, skipReason)
} finally {
hooks?.onRepoDone?.(repo, outcome, skipReason)
}
}
把这三层的错误处理连起来看,是一条完整的降级链:单个仓库失败 → 跳过,记 reason,继续;整轮 pass 失败(比如插件被卸载)→ catch 掉,队列等下次刷新;整个简介功能失败 → overviewsEnabled: false 时压根不启用,列表照常显示。用户任何时刻看到的都是"能用"的界面,最坏情况只是少了几段文字。
设置持久化与优雅降级
Config 是管理员在 patch 里写的启动参数,Settings 是用户在界面上改的偏好。混用两者是新手最常见的架构错误。SP 是三个项目里唯一用了 Settings 服务的,而且它把"三层降级"做到了极致。
19.1 先分清:Config vs Settings
| 维度 | Config | Settings |
|---|---|---|
| 谁改 | 管理员 / 插件作者 | 终端用户 |
| 在哪 | cordis.patch.yml | 宿主 settings.yaml(Host)/ localStorage(Client 兜底) |
| 何时生效 | 插件加载时,改动需要重载 | 运行时热更新 |
| Schema 位置 | Host 侧 export const Config | 两端共享的独立模块 |
| 典型内容 | 超时、并发数、开关 | 外观、位置、语言、个人偏好 |
| 例子 | GT timeoutMs、maxResults | SP character、position、background |
19.2 Schema 两端共享:一个模块,两种用途
关键洞察:Schema 文件必须同时被 Host 和 Client 引用,所以它不能 import 任何 Node-only 的东西。SP 的 src/settings.ts 只依赖 schemastery:
export const SPRITELY_SETTINGS_NAMESPACE = 'ui-sprite'
export const SPRITELY_CHARACTER_FIELD = 'character'
export const SPRITELY_POSITION_FIELD = 'position'
export const SPRITELY_BACKGROUND_FIELD = 'background'
export interface SpritelySettings {
character: SpriteKind
position: SpritePosition | null
background: BackgroundState | null
veil: number
}
export const SpritelySettingsSchema = z.object({
character: z.union([...CHARACTER_KINDS]).default('blob'),
position: z.object({ ... }).nullable().default(z.const(null)),
background: z.object({ ... }).nullable().default(z.const(null)),
veil: z.number().default(0.5),
})
z.number().default(0.5) 写法说明schemastery 的 API 与 zod 略有差异:.default() 直接收字面量值,而 zod 习惯收 thunk。上例中 z.const(null) 作为 default 是 schemastery 里"默认值为 null"的写法(z.object(...).nullable().default(null) 在部分版本下不会正确落到 snapshot 里)。照抄前先在你的 schemastery 版本上验一遍。
19.3 Host 端:用可选依赖注册命名空间
这是 SP 整个 Host 半边的全部代码(18 行)。注意它用的是 ctx.inject(['settings'], cb) 而不是 export const inject——因为 settings 服务可能不存在:
import type { Context } from '@deepseek-ai/cordis'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import { SPRITELY_SETTINGS_NAMESPACE, SpritelySettingsSchema } from './settings.js'
export function apply(ctx: Context): void {
// settings 是可选依赖:宿主没装 settings 插件时,回调根本不会执行,
// 插件其余部分照常工作(localStorage 模式)
ctx.inject(['settings'], (settingsCtx) => {
settingsCtx.settings.register(
settingsNamespace(SPRITELY_SETTINGS_NAMESPACE),
SpritelySettingsSchema,
)
})
}
export const inject = ['llm'] 是硬依赖:服务不存在,插件加载失败并报错。ctx.inject(['settings'], cb) 是软依赖:服务不存在,回调永不执行,插件照常加载。判断标准很简单——"没了它插件还有意义吗?"没了 llm 生成不了简介,硬依赖;没了 settings 只是不能跨浏览器同步,软依赖。
19.4 Client 端:绑定命名空间与快照字段
Client 侧只需一行绑定:
// 绑定后拿到一个 SettingsScope<SpritelySettings>
const settings = ctx.settingsScope.bind<SpritelySettings>({
namespace: SPRITELY_SETTINGS_NAMESPACE,
})
scope.getSnapshot() 返回的字段是理解整套机制的关键:
| 字段 | 类型 | 含义 |
|---|---|---|
status | 'pending' | 'ready' | 'error' | 文档是否已从 Host 读到。只有 ready 时 value 才有意义 |
mode | 'host' | 'memory' | Host 文档可用就是 host;局域网浏览器等 RPC 不可达时降级为 memory |
writable | boolean | 能否写入。memory 模式或只读文档下为 false |
value | T | undefined | 默认值 + 各层覆盖合并后的最终值 |
user | Partial<T> | … | 用户层:用户显式改过的字段。只含被改过的 key,用于判断"用户到底设过什么" |
user 层这是设计中最精妙的一点。value.character 永远是"当前该显示什么",但你无法从中判断"这是用户选的,还是默认值"。而迁移逻辑需要知道:命名空间是全新的吗?——只有 user 层不存在(typeof user !== 'object')时,才说明 Host 上还没有用户的偏好,此时才值得把 localStorage 里的老数据推上去。没有 user 层,你就只能瞎猜。
19.5 双向桥:SpriteSettingsSync
localStorage 快(首帧就能读),Host 文档准(跨浏览器共享)。SP 用一座桥把两者接起来,遵循三条规则:
| 方向 | 触发条件 | 行为 |
|---|---|---|
| Host → 本地(adopt) | 快照 ready 且 user 层存在 | 逐字段比较,只推送 user 层持有的字段(用 FIELD in user 判断),忽略未设置的字段 |
| 本地 → Host(migrate) | 快照 ready、writable、无 user 层,且未迁移过 | 一次性把三个字段推上去,migrated = true 后不再执行 |
| 写入(write) | 用户改设置 | 先写本地(立即生效),再尝试写 Host;mode !== 'host' 或不可写时静默跳过 |
export class SpriteSettingsSync {
private readonly unsubscribe: () => void
private migrated = false
constructor(
private readonly scope: SettingsScope<SpritelySettings>,
private readonly sources: SpriteSettingsSources,
) {
this.unsubscribe = scope.subscribe(() => {
this.adopt()
})
this.adopt() // 构造时立即采纳一次
}
// 用户写入:本地 + Host 双写,本地先行
setCharacter(kind: SpriteKind): void {
this.sources.spriteKind.set(kind)
this.write(SPRITELY_CHARACTER_FIELD, kind)
}
private adopt(): void {
const snapshot = this.scope.getSnapshot()
if (snapshot.status !== 'ready' || snapshot.value === undefined) return
if (!this.hasUserLayer(snapshot)) {
this.migrate(snapshot) // 全新命名空间 → 本地值上行
return
}
const { value } = snapshot
const user = snapshot.user as Partial<SpritelySettings>
// 只采纳用户层持有的字段,且值不同才写(避免回环)
if (SPRITELY_CHARACTER_FIELD in user &&
!sameValue(this.sources.spriteKind.getSnapshot(), value.character)) {
this.sources.spriteKind.set(value.character)
}
// position / background 同理
}
private write(field: string, value: unknown): void {
const snapshot = this.scope.getSnapshot()
if (snapshot.mode !== 'host' || !snapshot.writable) return
void this.scope.set(field, value)
}
dispose(): void { this.unsubscribe() }
}
adopt() 里那个 !sameValue(...) 判断不是优化,是必需品。没有它:Host 更新 → 写本地 → 本地 source 变化 → 触发 UI → 若 UI 再写 Host → Host 更新……无限循环。sameValue 用 JSON.stringify 比较即可,settings 值都是小 JSON,代价可忽略。
19.6 三层降级全景
把上面的机制串起来,SP 在任何环境下都不会"白屏"或"报错":
ctx.inject(['settings']) 检测到。
.default() 兜底,插件依然能跑,只是重启后重置。
"快速本地存储 + 权威远程文档 + 单向迁移 + 值比较防回环",是任何"用户偏好同步"场景的通用解。把它抽象成 createSettingsBridge(scope, sources, fields) 就能复用到任何插件——三个项目里只有 SP 用到了,但它值得被提取成公共库。
客户端状态源模式
Client 端有两种状态容器,用错是新手最容易犯的设计错误。这一章讲清楚 defineStore 和 Source(Observable) 的分工,以及 SP 里那个教科书级的 Source 实现。
20.1 两种容器,一个判据
| 维度 | defineStore | Source / Observable |
|---|---|---|
| 判据 | 状态的所有者是你 | 状态的所有者是外部系统,你只是投影 |
| 写入方式 | Immer draft 变更函数 | 只读;由上游 subscribe 驱动 |
| 生命周期 | 插件加载期常驻 | 通常由 slot 声明期决定,随 declaration 创建/销毁 |
| 典型用途 | 面板开关、当前 Tab、宽度、待打开路径 | 会话运行状态、选中文本、文件变更、设置文档 |
| 例子 | AV createArtifactViewerStore | SP createSpriteStateSource / createSelectionSource |
把"会话是否在运行"塞进 defineStore,你就得手动监听 sessions 服务、手动同步、手动在会话切换时清理——而且这份状态会和真正的会话状态短暂不一致。用 Source 则天然只有一个真相来源。
20.2 Source 契约:四个方法,三条铁律
Source 就是 React useSyncExternalStore 要求的那个形状,只是多了一个 dispose:
export interface SpriteStateSource extends HostObservable<SpriteState> {
/** Unsubscribe from the sessions list and the current session snapshot. */
dispose(): void
}
// HostObservable<T> 的形状:
// getSnapshot(): T —— 返回当前值
// subscribe(fn): () => void —— 订阅,返回退订函数
getSnapshot()必须返回稳定引用。两次调用之间没有变化时,必须返回同一个对象。返回新对象会让 React 认为每次都变了,陷入无限重渲染。- 只有值真的变了才通知。用值比较(不是引用比较)过滤,否则高频上游会淹没订阅者。
- 通知时先拷贝监听器集合。
for (const fn of [...listeners]) fn()——监听器可能在回调里退订自己,直接遍历原集合会漏通知或抛错。
20.3 第一步:把派生逻辑抽成纯函数
这是整个模式的核心价值:投影逻辑是纯函数,可以单测,无需 mock 任何服务。SP 把"会话当前在干嘛"压缩成 6 种姿态,优先级写在文档注释里:
src/client/sprite-state.ts — 纯函数派生export type SpriteActivity =
| 'idle' | 'thinking' | 'writing' | 'working' | 'waiting' | 'error'
export interface SpriteState {
readonly activity: SpriteActivity
/** Name of the in-flight tool call while activity === 'working', else undefined. */
readonly toolName: string | undefined
}
const IDLE: SpriteState = Object.freeze({ activity: 'idle', toolName: undefined })
/**
* Precedence: a pending interaction wins (the user must answer), then a
* failed turn, then the running phase, then idle. While running, in-flight
* tool calls rank first (working), then a streamed text block (writing),
* then reasoning or the pre-first-token latency (thinking).
*/
export function deriveSpriteState(
list: SessionListState,
snapshot: ConversationSnapshot | undefined,
): SpriteState {
const row = list.current === undefined ? undefined : list.byId[list.current]
// ① 待用户交互最高优先级:权限确认/追问不回答,一切都不会继续
if (row?.pendingInteraction !== undefined) {
return { activity: 'waiting', toolName: undefined }
}
if (snapshot === undefined) return { activity: 'idle', toolName: undefined }
if (snapshot.lastAgentError !== null) return { activity: 'error', toolName: undefined }
if (!snapshot.running) return { activity: 'idle', toolName: undefined }
// ② 运行中的细分:工具调用 > 文本流 > 推理/等待首 token
const calls = snapshot.runningCalls
if (calls.length > 0) {
return { activity: 'working', toolName: calls[calls.length - 1]?.name }
}
const blocks = snapshot.partial?.blocks ?? []
if (blocks.some((b) => b.kind === 'tool-call')) return { activity: 'working', toolName: undefined }
if (blocks.some((b) => b.kind === 'text')) return { activity: 'writing', toolName: undefined }
// Reasoning-only partial, or running with no visible chunk yet.
return { activity: 'thinking', toolName: undefined }
}
IDLE 要 Object.freeze初始值被冻结后,getSnapshot() 在没有任何会话时返回的永远是同一个对象引用,天然满足"稳定引用"铁律,且能防止下游误改。这类"常量状态"都值得冻结。
20.4 第二步:包装成 Source,跟随当前会话
纯函数只处理"给定输入得出输出"。Source 负责两件事:订阅上游、在选中会话变化时重新订阅。注意 refresh(重算)和 follow(重挂)是分开的:
export function createSpriteStateSource(sessions: ISessions): SpriteStateSource {
const listeners = new Set<() => void>()
let current: SpriteState = IDLE
let unsubscribeSession: (() => void) | undefined
// 重算:读列表快照 + 当前会话快照 → 纯派生 → 变了才通知
const refresh = (): void => {
const list = sessions.list.getSnapshot()
const id = list.current
const binding = id === undefined ? undefined : sessions.binding(id)
const snapshot = binding?.session.getSnapshot()
const next = deriveSpriteState(list, snapshot)
if (!sameState(current, next)) { // 值比较,不是引用比较
current = next
for (const fn of [...listeners]) fn() // 拷贝后再遍历
}
}
// 重挂:选中会话变了,退订旧的、订阅新的
const follow = (): void => {
unsubscribeSession?.()
unsubscribeSession = undefined
const id = sessions.list.getSnapshot().current
const binding = id === undefined ? undefined : sessions.binding(id)
if (binding !== undefined) {
unsubscribeSession = binding.session.subscribe(refresh)
}
refresh()
}
const unsubscribeList = sessions.list.subscribe(follow)
follow() // 构造时立即挂载一次
return {
getSnapshot: () => current,
subscribe(fn) {
listeners.add(fn)
return () => { listeners.delete(fn) }
},
dispose() {
unsubscribeList()
unsubscribeSession?.()
},
}
}
这里有两个上游:会话列表(sessions.list)和当前会话(binding.session)。列表订阅是常驻的,会话订阅是随选中变化而重建的。dispose() 必须两个都退订——漏掉会话订阅会导致会话对象被插件长期持有,用户删了会话都释放不了内存。
20.5 在组件里消费
Source 通过 Face 注入传给组件(见第 15 章),组件用标准 hook 消费,全程不知道 sessions 服务存在:
// Face 声明:hooks 里塞的是 Source 本身,不是值
inject(): SpriteMascotInjected => ({
hooks: { sprite: source, background, spriteKind, position },
startSession: () => { ctx.workspaces.startSession() },
setSpriteKind: (kind) => { sync.setCharacter(kind) },
// …
})
// 组件内部:标准 useSyncExternalStore
const state = useSyncExternalStore(
hooks.sprite.subscribe,
hooks.sprite.getSnapshot,
)
// state.activity === 'working' → 播放敲键盘动画
- 可测:
deriveSpriteState是纯函数,测试只需伪造两个快照对象,不用 mock sessions 服务。SP 的sprite-state.client.spec.ts就是这么写的。 - 作用域无关:Source 在根作用域创建(
shell.overlay是根级 slot),却能读到会话作用域的数据——因为它自己管理订阅,不依赖 React context 层级。 - 零重复渲染:值比较 + 稳定引用,让上游每秒几十次的流式更新只在姿态真的切换时才触发一次动画切换。
测试策略
双端插件的测试难点在于:Host 代码跑在 Node,Client 代码跑在浏览器,同一个 tests/ 目录里要同时支持两种环境。三个项目给出了两套可行配置,以及一套"能不 mock 就不 mock"的分层原则。
21.1 三层测试金字塔
| 层 | 测什么 | 依赖 | 占比建议 |
|---|---|---|---|
| 纯函数 | 派生逻辑、解析器、格式化、URL 构造 | 零 mock | 最多 |
| Host 集成 | 插件注册、RPC handler、缓存、路由 handler | 真 Context + 假服务 | 中 |
| Client 组件 | 渲染、交互、slot 注册、桥接逻辑 | React Testing Library + 假 Source | 中 |
第 20 章把派生逻辑抽成 deriveSpriteState,正是为了这一刻:测它只需要两个普通对象,不需要 mock sessions 服务、不需要 jsdom、不需要 React。把业务逻辑从服务依赖里剥出来,是对可测性最大的一笔投资。
21.2 双环境配置(两种写法)
AV 和 GT 用 Vitest 3 的 environmentMatchGlobs;SP 用 Vitest 4,该选项已被移除,改为逐文件 pragma。两种都要会:
写法 A · environmentMatchGlobs(Vitest 3)
vitest.config.ts — artifact-viewerimport { fileURLToPath } from 'node:url'
import { defineConfig } from 'vitest/config'
const root = fileURLToPath(new URL('.', import.meta.url))
export default defineConfig({
test: {
globals: false,
environment: 'node',
environmentMatchGlobs: [
['tests/**/*.client.spec.tsx', 'jsdom'],
['tests/**/*.client.spec.ts', 'jsdom'],
],
server: {
deps: { inline: [/@deepseek-ai/] }, // 别让 CJS 产物走 extern
},
},
resolve: {
alias: [
// 源码为 NodeNext 写的 .js 后缀,测回 .ts
{ find: /^(\.\.?\/.*)\.js$/, replacement: '$1' },
{ find: /^react$/, replacement: `${root}node_modules/react` },
{ find: /^react-dom$/, replacement: `${root}node_modules/react-dom` },
{ find: /^react\/jsx-runtime$/, replacement: `${root}node_modules/react/jsx-runtime` },
],
},
})
写法 B · 逐文件 pragma(Vitest 4)
vitest.config.ts — spritelyimport { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: false,
include: ['tests/**/*.spec.{ts,tsx}'],
setupFiles: ['tests/setup.ts'],
// Individual client specs opt into jsdom via the per-file
// `@vitest-environment jsdom` pragma; the default stays Node.
// (`environmentMatchGlobs` no longer exists in Vitest 4.)
},
})
tests/settings-sync.client.spec.ts — 首行
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
写成 replacement: './node_modules/react' 或 path.join(__dirname, …) 都可能解析到宿主 web shell 的另一份 React,导致经典的 "Invalid hook call"——两份 React 副本各自持有 hook 状态。必须用 fileURLToPath(new URL('./node_modules/react', import.meta.url)),把路径锁定在本项目的 node_modules 上。这是三个项目踩过的坑里最隐蔽的一个。
21.3 测试 Host 端:真 Context + 假服务
Host 插件测试的最佳姿势是用真的 Context 加载真的插件模块,只把宿主服务换成假的。关键是让假服务把 handler 收集起来,供测试直接调用:
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { Context } from '@deepseek-ai/cordis'
import * as ArtifactViewerPlugin from '../src/index.js'
let ctx: Context
let fiber: Awaited<ReturnType<Context['plugin']>>
let rpcHandlers: Map<string, Function>
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'dsh-artifact-viewer-'))
rpcHandlers = new Map()
ctx = new Context()
// ① provide 假服务:把注册进来的 handler 存起来,返回 disposer
ctx.provide('connection', {
rpc: {
handle: vi.fn((channel, handler) => {
rpcHandlers.set(channel, handler)
return () => { rpcHandlers.delete(channel) }
}),
},
} as never)
// ② 用真插件模块加载真 fiber
fiber = await ctx.plugin(ArtifactViewerPlugin, {})
})
afterEach(async () => {
await fiber.dispose() // 验证清理逻辑
await rm(tmpDir, { recursive: true, force: true })
})
it('reads and writes bookmarks', async () => {
// ③ 直接调 handler,不用真的 RPC 传输层
const handler = rpcHandlers.get('/artifact-viewer')!
await handler('bookmarks/write', { entries: [...] })
const read = await handler('bookmarks/read', {})
expect(read).toMatchObject({ ok: true })
})
① ctx.plugin(RealPlugin) 验证了 inject 声明和真实的加载路径;② 假服务返回的 disposer 让你能断言"卸载后 handler 确实被删了"(rpcHandlers.size === 0);③ 直接调 handler 绕开了序列化,测试跑得飞快。一份测试同时覆盖注册、业务、清理。
21.4 测试 Client 端:假 Source + 纯 props 组件
组件保持"纯 props"的回报在这里兑现——测组件时你只需要给它一组假的 hooks:
// ① 迷你 Source 工厂:localStorage 可选持久化(正好复刻真实 source 的行为)
function createSnapshotStore<T>(
initial: T,
options?: { persist?: { name: string } },
) {
const key = options?.persist?.name
const stored = key ? localStorage.getItem(key) : null
let current: T = stored !== null ? JSON.parse(stored) : initial
const listeners = new Set<() => void>()
return {
getSnapshot: () => current,
subscribe: (fn: () => void) => { listeners.add(fn); return () => { listeners.delete(fn) } },
set: (value: T) => {
current = value
if (key) localStorage.setItem(key, JSON.stringify(value))
for (const fn of [...listeners]) fn()
},
}
}
// ② 组件测的是"给 props → 出什么 DOM / 调什么回调"
render(<SpriteMascot hooks={{ sprite: fakeSource, … }} setSpriteKind={spy} />)
await user.click(screen.getByRole('button', { name: '切换形象' }))
expect(spy).toHaveBeenCalledWith('cat')
@deepseek-ai/dsh-client-* 的客户端产物是 CJS 自注册包——它们通过 window.__ModuleLoader__.load({ id, factory }) 把导出塞进加载器注册表,文件自己的 CJS 导出是空的。浏览器里由宿主注入加载器,测试环境必须自己垫一个。SP 的 tests/setup.ts 就实现了这个最小垫片,测试里通过 window.__ModuleLoader__.require(specifier) 取导出。不垫这个,你会遇到"导入成功但导出全是 undefined"的诡异现象。
21.5 三个项目的测试清单
| 项目 | Host 测试(node) | Client 测试(jsdom) |
|---|---|---|
| AV | plugin.spec.ts(RPC + 持久化集成)、artifacts.spec.ts(产物收集)、mermaid.spec.ts |
ArtifactPanel.client.spec.tsx、ArtifactList.client.spec.tsx、StarIcon.client.spec.tsx |
| GT | cache.spec.ts、parser.spec.ts、tool.spec.ts、summaries.spec.ts、intros.spec.ts、plugin.spec.ts(+ fixtures/) |
—(面板以类型与构建验证为主) |
| SP | invariant.client.spec.ts(Invariant 是"空实现"契约,仍要测) |
sprite-state、settings-sync、background-source、sprite-position-source、selection-toolbar、sprite-mascot、apply.client.spec.tsx |
SP 只消费能力、不提供能力,所以 InvariantInstaller 是个空实现(第 12 章)。但"空"本身是一种对外承诺——别的插件可以据此推理 SP 不会篡改会话状态。给它写测试不是为了覆盖率,是为了把契约固化下来:将来有人往里加了东西,测试会提醒他重新审视这个承诺。
发布、安装与排错
这一章全部是踩过的坑。前面 21 章教你写代码,这一章教你的代码真的跑起来。
22.1 发布前验证
pnpm run lint — Biome(lineWidth: 120、单引号、trailing commas)pnpm run typecheck — tsc -b,双端类型都要过pnpm test — Host 与 Client 用例全绿pnpm run build — 确认 lib/index.js 与 lib/client.js 都产出构建成功不代表宿主能加载。三类问题只在运行时暴露:RPC channel 用了保留前缀、single slot 优先级冲突、module table 外部包被内联。所以发布前必须真的 dsh plugin add 一次并启动宿主验证。
22.2 package.json 检查清单
{
"name": "@wangjunjian/dsh-artifact-viewer",
"version": "0.4.0",
"type": "module",
"main": "./lib/index.js", // ← Host 半边(ESM)
"types": "./lib/index.d.ts",
"exports": {
".": "./lib/index.js",
"./client": "./lib/client.js", // ← Client 半边(CJS 自注册)
"./cordis.patch.yml": "./cordis.patch.yml", // ← Bundle 清单,必须导出
"./package.json": "./package.json"
},
"dsh": {
"bundle": { "patch": "./cordis.patch.yml" },
"client": {
"platform": "web",
"inject": [...], // Client 需要的服务
"external": [...] // 走模块表、不内联
}
},
"files": ["lib", "cordis.patch.yml", "README.md"],
"peerDependencies": { // 宿主提供,运行时由模块表解析
"@deepseek-ai/cordis": "*",
"react": "*"
},
"scripts": {
"build": "tsc -b tsconfig.json && tsdown",
"prepare": "tsc -b tsconfig.json && tsdown"
}
}
"./cordis.patch.yml"— 不导出,宿主找不到 patch 文件,bundle 注册静默失败。dsh.client.platform— 不写,客户端 bundle 不会被投放到 web shell。files含cordis.patch.yml—exports里写了但没进files,发布后文件不存在。
22.3 CI 与 Trusted Publishing
三个项目都用 GitHub Actions + npm OIDC 免 token 发布。两个工作流:
| 工作流 | 触发 | 步骤 |
|---|---|---|
ci.yml | push 到 main + 所有 PR | setup-node(cache: pnpm) → pnpm install --frozen-lockfile → lint → typecheck → test → build |
release.yml | release: types: [published] | 先跑全套验证 → 校验版本一致性 → 升级 npm → npm publish --provenance --access public |
release.yml 里有三个非显然的必需步骤:
# 1. 权限:OIDC 必须
permissions:
contents: read
id-token: write
# 2. 版本一致性校验:release tag 去 v 后必须等于 package.json 的 version
- name: Verify version consistency
run: |
tag_version="${GITHUB_REF_NAME#v}"
pkg_version="$(node -p "require('./package.json').version")"
[ "$tag_version" = "$pkg_version" ] || { echo "version mismatch"; exit 1; }
# 3. 升级 npm CLI:trusted publishing 要求 npm ≥ 11.5.1,Node 22 自带的是 10
- run: npm install -g npm@latest
- run: npm publish --provenance --access public
不配这个,最后一步会报 ENEEDAUTH。步骤:包必须已存在于 npm(首版先本地手发)→ 打开 https://www.npmjs.com/package/<pkg>/access → Trusted Publishers → GitHub Actions → 填 organization/user、repository、workflow filename(release.yml)、environment 留空。
22.4 发版命令序列
# 1. bump package.json 的 version(如 0.2.0 → 0.2.1)后:
version="$(node -p "require('./package.json').version")"
git commit -am "release: v${version}"
git tag "v${version}"
git push origin main --tags
# 2. 创建 GitHub Release —— 这才是触发发布工作流的关键一步
gh release create "v${version}" --title "v${version}" --notes "本次变更说明"
# 3. 观察结果
gh run watch --workflow=Publish
npm view <pkg> version # 新包有几分钟元数据传播延迟
git push --tags 不会触发发布tag 只是 Git 引用,GitHub Release 是独立的对象。只 push tag 时,Actions 里只会跑 CI 工作流——而它的显示名是 commit message(比如 release: v0.2.1),极易被误认为发布已成功。判断标准:Releases 页面有没有对应条目、Publish 工作流有没有运行。发布失败后不必重建 Release,修好原因在 Actions 页面点 Re-run failed jobs 即可。
22.5 安装与升级
# 从 npm 安装
dsh plugin --profile web add @wangjunjian/dsh-artifact-viewer
# 从本地路径安装(开发时最常用,改动后需重新 add 或 link)
dsh plugin --profile web add /Users/you/GitHub/wang-junjian/dsh-artifact-viewer
# 移除
dsh plugin --profile web remove @wangjunjian/dsh-artifact-viewer
dsh plugin add 的实际效果可以直接检查 ~/.dsh/profiles/web/package.json 里的 dependencies 和 dsh.profile.bundles——这是排错时最快的事实来源。
22.6 坑位总表
| 症状 | 根因 | 解决 |
|---|---|---|
duplicate loader entry id: X | 包改名/换 scope 后新旧两个包都在 bundles 里,patch 都 insert 同一个 id | 先 remove 旧包名再 add 新包名 |
| RPC 注册失败 / channel 被拒绝 | 用了保留前缀 /plugin/* | 换成插件自有前缀,如 /artifact-viewer |
| single slot 注册冲突 | 多个插件用默认优先级 0 争同一个 single slot | 要覆盖宿主默认实现就显式给 priority: -1 |
| "Invalid hook call" | 两份 React 副本(alias 指向了宿主 node_modules) | alias 用 fileURLToPath(new URL('./node_modules/react', import.meta.url)) |
| Client 包导入成功但导出全 undefined | dsh-client-* 是 CJS 自注册包,导出在加载器注册表里 | 测试环境垫 window.__ModuleLoader__,用 .require() 取 |
| mermaid 等大依赖导致多 chunk 加载失败 | 客户端 bundle 必须单文件 | tsdown 设 inlineDynamicImports: true |
| 插件白屏但无报错 | module table 外部包被内联了 | 检查 tsdown externals,确保 @deepseek-ai/* 走外部 |
| 插件用了旧模型,换模型不生效 | 路由在 apply() 里就固化了 | 改成懒解析:() => resolveRoute() |
| 推理模型返回空简介 | 思维链吃光了 maxTokens | 把 token 预算暴露成配置项,让用户调大 |
| 面板永远显示"生成中" | 失败的仓库没有被记录 | 用 WeakMap<entry, Map<name, reason>> 记失败原因 |
| 连点刷新后简介乱套 | 生成器还在改已被替换的旧 entry 对象 | 版本号 + isStale() 中途放弃 |
| 设置改动后无限循环刷新 | Host ↔ 本地双向同步没有值比较 | adopt 前先 sameValue() 判断 |
| 书签路径重复 / 找不到 | 符号链接 / .. 导致路径字符串不唯一 | key 用 realpath() 归一化 |
| 书签文件偶尔损坏 | 写一半进程挂了 | 写 .tmp 再 rename() 原子替换 |
| 插件卸载后定时器还在跑 | 缓存定时器没注册进 ctx.effect | ctx.effect(() => () => cache.dispose(), 'label') |
| 长轮询越刷越快 / 内存涨 | 用 setTimeout 递归,且没有 AbortController | for(;;) 循环写在 useEffect 体内,配 AbortController |
dsh plugin add 报一堆 peer 依赖警告 | 正常现象:profile 目录不装这些包,运行时由模块表提供 | 只要不是 error,忽略即可 |
发布成功但 npm view 404 | registry 元数据传播延迟 | 等几分钟,或用带 token 的请求确认 |
npm publish 报 ENEEDAUTH | trusted publisher 未在 npm 侧登记 | 到包的 access 页配置 GitHub Actions 信任关系 |
| 2FA 账号本地发布失败 | 需要 OTP 或 bypass token | --otp=<code>,长期方案用 trusted publishing |
22.7 调试技巧
- 先看 DSH 启动日志。绝大多数加载失败(RPC 保留前缀、slot 优先级冲突、module table 内联、loader entry 重复)都在这里有明确报错。
- 浏览器控制台查
window.__ModuleLoader__,确认你的 client bundle 是否真的注册进去了、id 对不对。 - 样式问题先查 CSS 变量。DSH 主题变量有没有被正确赋值;你的 fallback 值(
var(--ds-xxx, 兜底值))是否写对。 - 直接检查 profile 目录
~/.dsh/profiles/web/package.json,看 dependencies 与 bundles 的实际内容,比任何日志都准。
22.8 推荐开发流程(八步)
src/index.ts:RPC / Tool / HTTP 路由 / LLM 调用src/client/index.ts:注册 slot,注入所需 faceLocaleNamespaceMap 声明合并dsh plugin --profile web add <path>,启动宿主看效果gh release create速查手册(实战版)
把三个项目里反复出现的写法压成可复制的模板。这一章就是拿来抄的。
23.1 Host 端骨架
// src/index.ts
import type { Context } from '@deepseek-ai/cordis'
import { z } from '@deepseek-ai/schemastery'
export const name = 'my-plugin' // 与 cordis.patch.yml 的 id 一致
// ① 硬依赖:缺了插件就加载失败
export const inject = ['connection', 'webServer', 'llm']
// ② Config schema(管理员在 patch 里写)
export interface Config { timeoutMs: number }
export const Config = z.object({ timeoutMs: z.number().default(30_000) })
export function apply(ctx: Context, config: Config): void {
// ③ 跨字段校验:schema 表达不了的放这里
if (config.timeoutMs <= 0) throw new Error('timeoutMs must be positive')
// ④ 可选依赖:服务不存在则回调不执行,插件照常加载
ctx.inject(['settings'], (sctx) => {
sctx.settings.register(settingsNamespace('my-plugin'), MySchema)
})
// ⑤ dispose 型资源一律用 effect 包起来(带 label,便于调试)
ctx.effect(() => ctx.webServer.register({
kind: 'prefix', path: '/my-plugin', handler,
}), 'my-plugin: http route')
ctx.effect(() => () => cache.dispose(), 'my-plugin: cache disposal')
// ⑥ RPC:channel 绝不能用 /plugin/* 前缀
ctx.connection.rpc.handle('/my-plugin', async (endpoint, payload) => {
try {
switch (endpoint) {
case 'items/read': return { ok: true, value: await read() }
case 'items/write': return { ok: true, value: await write(payload) }
default: return { ok: false, error: `unknown endpoint: ${endpoint}` }
}
} catch (error) {
// ⑦ 异常不外抛,转成 error 信封
return { ok: false, error: error instanceof Error ? error.message : String(error) }
}
}, { authority: 'loopback' })
}
23.2 Client 端骨架
// src/client/index.ts
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
// 类型专用导入:只取 Context 合并,不产生运行时依赖(bundle 纯净门禁)
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
export const inject = ['slots', 'locale', 'connection', 'settingsScope']
// 声明合并:把本插件的文案 key 挂进宿主的命名空间表
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap { 'my-plugin': MyKey }
}
export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.locale.register('my-plugin', { zh, en }), 'my-plugin: dictionaries')
const settings = ctx.settingsScope.bind<MySettings>({ namespace: 'my-plugin' })
// ⚠️ 用 inject 声明 slot,不要直接 register —— slot 可能还没被声明
ctx.slots.inject('shell.overlay', () => {
const source = createMySource(ctx.sessions) // 外部状态 → Source
const store = createMyStore() // 自有状态 → defineStore
const sync = new MySettingsSync(settings, source)
const dispose = ctx.slots.register(
{
name: 'shell.overlay',
id: 'my-plugin-panel', // 全局唯一
locale: 'my-plugin',
order: 50, // 叠加型 slot 用 order
// priority: -1 // 单一型 slot 才用 priority
inject: (): MyPanelInjected => ({ // Face 注入:组件保持纯 props
hooks: { source },
onSubmit: (v) => { sync.set(v) },
}),
},
MyPanel,
)
// 谁创建,谁销毁 —— 严格逆序
return () => {
dispose()
source.dispose()
sync.dispose()
}
})
}
23.3 服务目录
| 服务 | 端 | 用途 | 用法要点 |
|---|---|---|---|
connection | 双端 | 与对端通信 | Host rpc.handle;Client rpc.call |
tools | Host | 注册模型可调用的工具 | defineTool(...) |
systemPrompt | Host | 向系统提示词追加段落 | section({ name, order, text }) |
webServer | Host | 挂载 HTTP 路由 | register({ kind: 'prefix', path, handler }) |
llm | Host | 调用模型 | stream() + BlockAssembler |
agentDefaultModel | Host | 用户当前选定的模型 | 必须懒解析 |
settings | Host | 注册设置命名空间 | 可选依赖,用 ctx.inject |
invariants | Host | 声明对外承诺 | 纯消费者插件注册空实现 |
slots | Client | 槽位声明 / 注册 | inject 声明,register 注册 |
locale | Client | 文案注册 | register(ns, { zh, en }) |
sessions | Client | 会话列表与会话作用域 | list.getSnapshot()、binding(id) |
workspaces | Client | 工作区动作 | startSession() 等 |
conversation | Client | 会话作用域内的会话服务 | ctx.get('conversation') |
settingsScope | Client | 绑定设置命名空间 | bind<T>({ namespace }) |
remote | Client | 转发宿主事件 | settings 失效通知走它 |
23.4 Host API 速查
| API | 说明 |
|---|---|
ctx.effect(fn, label) | 注册带标签的清理逻辑;fn 返回 disposer |
ctx.inject(['svc'], cb) | 可选依赖;服务就绪后回调,缺失则不执行 |
ctx.plugin(Plugin, config) | 加载子插件,返回 fiber |
ctx.get('service') | 在作用域内取服务 |
ctx.connection.rpc.handle(channel, fn, opts) | 注册 RPC;返回 disposer;channel 禁用 /plugin/* |
ctx.webServer.register({ kind, path, handler }) | 注册 HTTP 路由;返回 disposer |
ctx.llm.stream({ provider, model, messages, system, maxTokens, signal }) | 流式生成,返回异步迭代器 |
ctx.tools.register(tool) | 注册工具 |
ctx.systemPrompt.section({ name, order, text }) | 追加提示词段落 |
ctx.settings.register(ns, schema) | 注册设置命名空间 |
ctx.invariants.register(id, install) | 声明 Invariant |
ctx.logger.warn(fmt, ...args) | 结构化日志(%s 占位符) |
23.5 Client API 速查
| API | 说明 |
|---|---|
ctx.slots.inject(name, cb) | 声明槽位;cb 返回清理函数;槽位未声明则不执行 |
ctx.slots.register(desc, Comp) | 注册组件;返回 disposer。desc 含 name/id/locale/order|priority/inject |
ctx.locale.register(ns, dict) | 注册文案;返回 disposer |
ctx.settingsScope.bind<T>({ namespace }) | 绑定设置命名空间,返回 SettingsScope<T> |
scope.getSnapshot() | 返回 { status, mode, writable, value, user } |
scope.set(field, value) | 写单个字段;返回 Promise |
scope.subscribe(fn) | 订阅快照变化;返回退订函数 |
ctx.connection.rpc.call(channel, endpoint, payload) | 调 Host 端;返回 { ok, value?, error? } |
ctx.sessions.list.getSnapshot() | 会话列表快照(current、byId) |
ctx.sessions.binding(id) | 取会话绑定(.session 是 Observable) |
ctx.sessions.scope(id) | 取会话作用域,用于 ctx.get('conversation') |
defineStore(init, actions) | 自有 UI 状态;Immer draft 变更 |
HostObservable<T> | 外部状态源契约:getSnapshot + subscribe |
23.6 三条贯穿全书的铁律
- 谁创建,谁销毁。每一个
register、subscribe、setInterval都要有对应的 disposer,并挂在正确的生命周期上(ctx.effect用于插件级,slots.inject的返回值用于槽位级)。 - 降级优于报错。辅助功能失败就该安静地消失——单个仓库失败跳过、整轮失败等下次、功能不可用就关掉。用户永远应该看到一个能用的界面。
- 纯函数承载逻辑,副作用集中在边界。派生逻辑写成纯函数(可测、可推理),服务订阅与写入集中在 Source / Store / Bridge 里。三个项目里所有写得好的部分,都符合这一条。
到这里,三个项目的源码已经被拆成了可复用的模式。下一步是打开 dsh-artifact-viewer 的 docs/best-practices.md——那份文档是这三个项目在真实迭代中积累的踩坑记录,本教程的第 22 章大量取材于它。写代码时遇到诡异行为,先去那里搜一遍。