@pi-web/media-kit
v0.1.0
Published
多 provider 媒体生成工具套件:模型目录是 JSON 数据,方言是 TS 代码。第一期能力面是图像。
Maintainers
Readme
@pi-web/media-kit
多 provider 媒体工具套件。内置图片生成、图片编辑、视频生成、图片理解与视频理解工具, 背后可接 Cloudflare、DashScope、火山方舟等厂商接口。
包名取 media 而不是 image,是因为目录里的 capabilities 和结果类型 PickedResult
从第一天就给视频、音频留了位置:将来扩媒体类型只是加方言加工具,包的骨架不动。
一句话说清它跟别的做法的区别
模型目录是数据,不是代码。 provider、模型、端点声明全部写在 JSON 文件里, 由一份从代码生成的 schema 校验。TypeScript 只写两类东西:
- 方言(dialect):一种上游线格式一个小模块,实现没法序列化的那一半 —— 构造请求体、从响应里取结果、判错、轮询语义、SSE 帧累积。
- 引擎与编排器:与目录内容完全无关的通用执行逻辑。
于是新增一个模型 = 改几行 JSON,不写代码、不重新构建、不发新版包。
三级扩展面(能用浅的就别用深的)
一、只加模型(纯数据,零代码)
在 agent source 或者本机的 agent 目录下放一个 media/catalog/,和内置目录同一套
schema。装配方把它经 catalogDirs 传进来:
makeMediaExtension(host, { catalogDirs: [".../media/catalog"] });目录可以是"目录式"(provider.json + models/*.json,一模型一文件,路由键必须等于
文件名),也可以是"单文件简式"(provider 和 routes 写在一个 JSON 里,适合临时加一个模型)。
每个工具还自动带一条同名斜杠命令(/image_generation 一只戴礼帽的柴犬),
不经过 LLM 直接跑一次;不带参数时用交互卡片问你要什么。它与工具同生共死(工具没注册
就没有这条命令),command: false 可以单独关掉。见 registerToolCommand。
目录按 内置 → agent source → 本机用户 的顺序叠加,后面的层想覆盖前面的同名路由,
要在那条路由上显式写 "replace": true。
还有第四层 project(<cwd>/.pi/media/catalog/,经 projectDir 传):默认不存在,
而且只能新增、不能覆盖——那一层的文件来自使用者 cd 进去的项目,可能是别人写的。
开它的前提是那个项目受信,细节见 catalog/README.md。
二、加方言(一小段 TS)
内置方言不覆盖你的上游线格式时才用得上:
makeMediaExtension(host, {
catalogDirs: [".../media/catalog"],
dialects: [myRelayDialect], // 满足 Dialect 契约
});和内置方言重名的注入会被整个拒绝并告警 —— 同一份目录在不同 agent 下行为不同
是排查噩梦。给自己的方言起个带前缀的名字,比如 myagent-relay。
三、加工具(复用编排器,不 fork)
要加 video_generation 这类新工具时,不要复制编排器 —— 把它的声明交给装配入口就行:
makeMediaExtension(host, {
extraTools: [
{
toolName: "video_generation",
// 从活跃路由里自己筛(也可以直接给一个数组)
routes: (pool) => pool.filter((route) => route.provider === "myvendor"),
baseDescription: "按文字描述生成一段视频。",
requiredParams: [{ name: "prompt", kind: "input", title: "想要一段什么样的视频?" }],
rememberParams: ["model"],
},
],
});偏好回填、必选项交互补全、中断处理、落盘 —— 整条流水线对新工具原样生效。
内置的三个工具自己就是同一个注册器的三次调用(见
src/tools/image-generation.ts,整个文件就是那一次调用),
不存在"内置工具专用"的私有路径。
自己接线装配(不走 makeMediaExtension)时,直接用底下那个注册器:
registerMediaTool(pi, host, {
toolName: "video_generation",
routes, // 已按用户设置过滤的活跃路由
baseDescription,
requiredParams,
dialects, // 方言表
env: process.env, // ${VAR} 到执行期才展开
});★ 活跃路由为零的工具不会被注册(不做"全禁时悄悄保留默认模型"那一套), 同时清单里会带上原因:是目录里本来就没有,还是被用户禁光了 —— 两种情况给用户的 下一步完全不同。
★★ 自己接线时,扩展工厂要从 defineExtension 过一道:
import { defineExtension } from "@pi-web/extension-kit";
export default defineExtension((pi) => {
registerMediaTool(pi, host, { /* … */ });
publishMediaCatalogState(pi.state, catalog); // 不过那一道的话,这里恒为 undefined
});自己 export default (pi) => … 也能被 pi 装载,但 pi 交给工厂的那个 api 对象上
没有 state(它是 pi 的私有对象字面量,宿主插不进去),装配期的 pi.state 是
defineExtension 在调用工厂那一刻补上的。少了这一层,清单一次都下发不出去,
而且不报任何错 —— 工具都在、日志全正常,只有设置页永远空着。
makeMediaExtension 已经在里面包好了,走它就不用管这件事。
两个入口
| 入口 | 里面有什么 | 谁引它 |
|---|---|---|
| @pi-web/media-kit | 纯类型、zod schema、目录加载器 | 设置页、测试、装配代码 |
| @pi-web/media-kit/runtime | 方言实现、执行引擎、编排器、工具注册、makeMediaExtension | 只有 agent 进程 |
运行层是声明层的超集,agent 侧写一个 import 就够。
没有宿主也能先跑起来:createDefaultHost() 给一份本机实现(附件落本地目录、
预览空实现、日志走 stderr),写例子和跑集成测试不必先实现三个接口。
attachmentDir 收两种形状:给一个字符串 = 所有产物落同一个目录;给一份
AttachmentDirs = 按媒体大类分(image/* / video/* / audio/* 各一处,
其余落必填的 fallback)。分类看的是产物的 mimeType,不是哪个工具出的它 ——
一张图无论来自 image_generation 还是将来某个 video_thumbnail,都落同一处。
createDefaultHost({
attachmentDir: { image: "./images", video: "./videos", fallback: "./media" },
});★ read(ref) 是靠扫目录把引用兑回文件的,分目录之后它会在配置里出现过的每个目录
里找(image_edit 拿历史 id 找文件那条路因此不受布局影响)。这也是这一层今天就存在、
而不是等视频工具落地再加的理由:现在改是零迁移,将来改要处理"旧产物在老目录"。
会话共享状态(下发模型清单、记住用户选过的模型和尺寸)不在宿主能力里:
它是公共面,写作面在 @pi-web/agent-kit 的 SessionState,入口是扩展装配期的 pi.state
与执行期的 ctx.state,唯一实现在 packages/runner。本包只负责载荷 ——
放在哪个键、什么形状(见 src/state.ts)。
装配期那个 pi.state 要宿主先把状态交出去(pi-web 里是 packages/runner 启动时那一步),
再由 defineExtension 补到工厂手上。换成不做这件事的裸 pi,pi.state 运行期就是缺的 ——
这是预期内的降级:工具照常注册,只是清单不下发、偏好记不住,装配会留一句告警说明。
目录结构
catalog/ 数据面:全部是 JSON
schema/ 生成物,入库;CI 校验"重新生成后零 diff"
builtin/ 内置目录,一 provider 一目录
parked/ 验证过但主动摘下来的路由(比如吃免费额度的),loader 不扫这里
tools.json 工具级 UI 数据(尺寸档位、必选项补全声明、偏好键白名单)
src/ 代码面
index.ts 声明层入口:类型 + zod schema + 目录加载器
runtime.ts 运行层入口:方言、引擎、编排器、工具注册、装配(只在 agent 进程加载)
types.ts 执行层与目录共享的纯类型(零运行时导入)
catalog/ 目录 JSON 的 zod schema
dialect/ 方言契约
host.ts 宿主能力注入面 + 会话共享状态的载荷形状
host-default.ts 宿主能力的默认本机实现(脱离宿主也能跑通)
engine/ 与目录内容无关的执行内核
dialects/ 一方言一目录:实现、旋钮、真机响应样本、测试同居
loader/ 三层收集 → 校验 → env 门控 → 合并去重 → 编译
orchestrator/ 主流水线
tools/ 通用注册器 + 两个内置工具(它们自己就是那个注册器的两次调用)
extension.ts 装配入口:设置 → 方言 → 目录 → 注册工具 → 下发清单
scripts/
gen-schema.mjs 遍历方言的 options.ts 生成 catalog/schema/几条必须守住的规矩
- JSON 里不长表达式语言。 想在目录里写"取哪个字段""条件拼接"的时候停下,
把它写成方言代码。JSON 里只允许两种模板:
${VAR}/${VAR:-默认值}(执行期展开) 和{baseUrl}(加载期展开)。 - 方言按线格式划分,不按厂商。 NewAPI、sufy、自建网关说的都是 OpenAI Images 那套话,就该共用一个方言,差异全在 JSON 里。方言数量要收在个位数。
- 加载期不取凭据的值。 env 门控只查变量存不存在,
${VAR}一路保留到执行期。 所以编译后的目录可以放心打日志、dump 给设置页,不会带出密钥。 - 坏文件跳过,不炸目录。 目录坏一角不该让整个工具用不了;但告警必须说清是哪个 文件的哪个字段、期望是什么。
- schema 生成物入库。 改了方言的旋钮忘了重新生成,
git diff catalog/schema/会在 CI 里报出来。
