@zhitu/sdk
v0.5.0
Published
zhituSDK —— 平台沙箱工具官方前端 SDK:iframe 内经 postMessage 存取私有 KV、设备本地文件、受控导出,并可把条目提交到用户复习计划(零依赖、零凭据)
Maintainers
Readme
@zhitu/sdk
当前版本:0.5.0。
智图平台第三方工具的官方浏览器 SDK。工具运行在平台沙箱 iframe 中,通过宿主 Bridge 安全地访问:
- 服务端私有 KV 数据;
- 当前设备上的 Blob 文件;
- 经顶层宿主确认的文件分享或下载;
- 当前工具名下的复习计划;
- 平台统一 AI 能力(可选)。
SDK 不持有用户 token,不直连平台 API,运行时零依赖,并自带 TypeScript 类型。
安装
pnpm add @zhitu/sdk
# 或 npm install @zhitu/sdk包同时提供 ESM、CommonJS 和类型声明。
运行前提
@zhitu/sdk 供智图工具沙箱内的浏览器代码使用,不是服务端 SDK。调用能力前,工具的 manifest.json 必须声明相应 capability:
{
"name": "示例工具",
"version": "1.0.0",
"entry": "index.html",
"capabilities": [
"ui",
"tool-data-read",
"tool-data-write",
"tool-file-read",
"tool-file-write",
"tool-file-export"
],
"outboundDomains": [],
"dataSchema": {
"collections": {
"notes": {
"fields": {
"title": "string",
"text": "string",
"starred": "boolean"
}
}
}
}
}只有服务端 KV 写入需要在 dataSchema 中声明 collection 和字段;设备本地文件由 tool-file-read/write 控制,导出由独立的 tool-file-export 控制。
快速开始
import { createToolClient } from '@zhitu/sdk'
const client = createToolClient()
await client.data.put('notes', 'note-1', {
title: '一次函数',
text: '斜率求错',
starred: false,
})
const note = await client.data.get('notes', 'note-1')
console.log(note)
client.dispose()data.get() 返回 unknown,工具应按自己的 manifest schema 做类型收窄。
服务端数据
await client.data.put('notes', 'a', { title: 'A', starred: true })
await client.data.put('notes', 'b', { title: 'B', starred: false })
const keys = await client.data.listKeys('notes')
const notes = await Promise.all(keys.map((key) => client.data.get('notes', key)))
await client.data.putMany([
{ collection: 'notes', key: 'c', value: { title: 'C', starred: false } },
{ collection: 'notes', key: 'd', value: { title: 'D', starred: true } },
])
await client.data.delete('notes', 'a')数据值必须是 manifest 声明的扁平 string | number | boolean 对象。数组、Date、Map、函数和二进制不允许;二进制使用 client.files.*。
putMany() 最多写入 100 条,并保持原子性:批内重复或任一条校验失败时,整批都不会写入。
设备本地文件
文件保存在宿主站点的 IndexedDB 中,不上传服务端,也不会跨设备同步。
async function saveImage(file: File) {
return client.files.put('images', 'question-1', file, {
name: file.name,
})
}
const stored = await client.files.get('images', 'question-1')
const bytes = await stored.blob.arrayBuffer()
console.log(stored.ref, bytes.byteLength)
const image = document.querySelector<HTMLImageElement>('#preview')
if (image) {
const url = URL.createObjectURL(stored.blob)
image.src = url
try {
await image.decode()
} finally {
URL.revokeObjectURL(url)
}
}
await client.files.delete('images', 'question-1')临时缓存可以显式设置 ISO 格式的过期时间:
async function cachePreview(file: File) {
return client.files.put('cache', 'preview', file, {
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
})
}不传 expiresAt 时文件永久保留。到期后 files.get() 按不存在处理,宿主会在后续文件访问时机会式清理;页面关闭期间没有常驻定时器。
本地文件限额
| 范围 | 限额 | |---|---:| | 单文件 | 2 MiB | | 每账号 × 每工具 | 40 MiB / 20 个 | | 每账号全部工具 | 200 MiB / 5000 个 |
账号本地擦除流程会同步清除该账号的工具文件。浏览器清理站点数据时,本地文件也会被删除。
受控文件导出
files.export() 将 Blob 发给顶层宿主,但不会让 sandbox iframe 直接下载。宿主会显示文件名、MIME 和大小,用户点击确认后才执行:移动端支持 Web Share 时优先分享,其他情况由宿主下载。
const png = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob((blob) => blob ? resolve(blob) : reject(new Error('PNG 生成失败')), 'image/png')
})
const { method } = await client.files.export(png, { name: '一次函数.png' })
console.log(method) // "share" | "download"导出文件不会自动写入 IndexedDB。当前限制为单文件 10 MiB,文件名 1~128 字符,不得含路径或系统保留字符,扩展名必须与 MIME 匹配。允许的组合为:
| MIME | 扩展名 |
|---|---|
| image/png | .png |
| image/jpeg | .jpg / .jpeg |
| image/webp | .webp |
| image/svg+xml | .svg |
| application/pdf | .pdf |
| application/json | .json |
| text/csv | .csv |
| text/plain | .txt |
宿主同一时间只处理一个导出确认,防止工具静默或批量触发。稳定错误码:
| code | 含义 |
|---|---|
| CAPABILITY_NOT_DECLARED | manifest 未声明 tool-file-export |
| FILE_EXPORT_INVALID_ARGUMENT | Blob、文件名或 MIME 不合法 |
| FILE_EXPORT_TOO_LARGE | Blob 超过 10 MiB |
| FILE_EXPORT_BUSY | 已有导出正在等待用户处理 |
| USER_CANCELLED | 用户在宿主确认层取消 |
| FILE_EXPORT_FAILED | 分享和下载均无法完成 |
能力探测
ready() 是可选探测,不会阻塞其他 API:
const bridge = await client.ready()
if (bridge.capabilities.includes('tool-file-write')) {
console.log('单文件上限:', bridge.limits.maxFileBytes)
}**0.4.0 BREAKING:**宿主成功响应
ready()时,必须返回非数组对象launch: { reviewKey: string | null },其中字符串必须是合法 review key;同时limits.maxReviewItemsPerTool、limits.maxReviewKeyLength、limits.maxReviewTitleLength均为必填的非负有限数值。任一字段缺失或非法都会抛出BRIDGE_INVALID_RESPONSE。 既有 file limits 仍为可选字段。
宿主不响应导致短超时,或明确返回 UNSUPPORTED_MESSAGE 时,ready() 仍返回协议 v1 fallback:
capabilities 为空、launch.reviewKey 为 null,三个 review limits 分别为 200、128、100。
此降级不会阻塞其他 API。
帮我记(review-add)
工具需在 manifest.json 中声明 review-add capability:
{
"name": "示例工具",
"version": "1.0.0",
"entry": "index.html",
"capabilities": ["ui", "review-add"],
"outboundDomains": []
}调用时只提交工具内稳定 key 和展示标题;宿主会向用户确认:
import { createToolClient } from '@zhitu/sdk'
const client = createToolClient()
await client.ready()
const key = client.launch.reviewKey ?? 'algebra.linear-1'
const { added } = await client.review.add({
key,
title: ' 一次函数:斜率 ',
})
console.log(added) // 1:新加入;0:已存在
client.dispose()key 仅允许 A-Z、a-z、0-9、.、_、-,长度为 1~128;title 会先 trim(),之后长度必须为 1~100。输入不合法时 SDK 会在本地拒绝,不发送 Bridge 消息。
帮我记限额
| ready().limits 字段 | 当前限额 | 说明 |
|---|---:|---|
| maxReviewItemsPerTool | 200 | 每账号、每工具最多保存的复习项 |
| maxReviewKeyLength | 128 | key 最大长度 |
| maxReviewTitleLength | 100 | title trim() 后最大长度 |
这三个字段在 0.4.0 的成功 ready() 响应和 fallback 中均为必填字段。
帮我记错误码
| code | 含义 |
|---|---|
| REVIEW_ITEM_LIMIT_EXCEEDED | 该工具的复习项已达上限 |
| CAPABILITY_NOT_DECLARED | manifest 未声明 review-add |
| REVIEW_REQUEST_BUSY | 已有加入复习请求正在等待处理 |
| USER_CANCELLED | 用户取消加入 |
| BRIDGE_REQUEST_FAILED | 宿主返回通用失败且未提供可公开错误码 |
client.launch.reviewKey 遵循 read-after-ready:ready() 完成前始终为 null;成功响应中的
launch.reviewKey 必须是合法 key 或 null,fallback 时为 null。
reviewKey 只是定位提示,绝不是授权凭据。 是否允许加入复习由宿主依据当前工具会话、manifest capability 和用户确认独立决定。
查询复习计划(review-read)
review-read 允许工具读取当前用户、当前工具名下的复习数据;它与 review-add 是分离的权限和职责,
互不蕴含。只需要读取时,manifest 仅声明 review-read:
{
"name": "示例工具",
"version": "1.0.0",
"entry": "index.html",
"capabilities": ["ui", "review-read"],
"outboundDomains": []
}import { createToolClient } from '@zhitu/sdk'
const client = createToolClient()
const { items, total } = await client.review.list()
for (const item of items) {
console.log(item.key, item.title, item.dueAt)
}
console.log('总数:', total)
client.dispose()review.list() 返回全部条目,包括尚未到期的未来排期;SDK 不按 dueAt 做本地时间过滤。
返回值严格为 { items: { key, title, dueAt }[], total },最多 200 项,且 total === items.length。
可能抛出的错误码:
| code | 含义 |
|---|---|
| CAPABILITY_NOT_DECLARED | manifest 未声明 review-read |
| TOOL_NOT_AVAILABLE | 当前工具不可用 |
| UNSUPPORTED_MESSAGE | 宿主不支持 tool-review-list |
| BRIDGE_INVALID_RESPONSE | 宿主返回的 list 数据不符合协议 |
| BRIDGE_REQUEST_FAILED | 宿主返回通用失败且未提供可公开错误码 |
| TIMEOUT | 请求超时 |
| BRIDGE_SEND_FAILED | Bridge 消息发送失败 |
| CLIENT_DISPOSED | client 已释放 |
错误处理
import { ToolBridgeError } from '@zhitu/sdk'
try {
await client.files.get('images', 'missing')
} catch (error) {
if (error instanceof ToolBridgeError) {
switch (error.code) {
case 'LOCAL_FILE_NOT_FOUND':
// 展示空态
break
case 'LOCAL_FILE_QUOTA_EXCEEDED':
// 提示用户删除不再需要的文件
break
case 'TIMEOUT':
// 可重试或提示宿主未就绪
break
default:
console.error(error.code, error.message)
}
}
}SDK 自身使用的稳定错误码包括:
| code | 含义 |
|---|---|
| TIMEOUT | 请求超过配置的等待时间 |
| CLIENT_DISPOSED | 客户端已释放 |
| BRIDGE_SEND_FAILED | postMessage 发送失败 |
| BRIDGE_INVALID_RESPONSE | 宿主响应格式不符合协议 |
| BRIDGE_REQUEST_FAILED | 宿主返回失败但未提供错误码 |
| INVALID_INPUT | SDK 本地输入校验失败 |
宿主还会返回经过白名单处理的权限、配额、数据校验和 AI 错误码。
生命周期
页面或组件卸载时必须调用 dispose()。它会移除消息监听,并以 CLIENT_DISPOSED 拒绝未完成及后续请求。
import { createToolClient } from '@zhitu/sdk'
import { useEffect, useMemo } from 'react'
function ToolApp() {
const client = useMemo(() => createToolClient(), [])
useEffect(() => () => client.dispose(), [client])
return null
}API
| API | 说明 |
|---|---|
| createToolClient(config?) | 创建客户端 |
| ready() | 探测协议、能力与限制;timeout/UNSUPPORTED_MESSAGE 时返回 fallback |
| data.get(collection, key) | 读取一条服务端私有数据 |
| data.put(collection, key, value) | 写入或覆盖一条数据 |
| data.delete(collection, key) | 幂等删除一条数据 |
| data.listKeys(collection) | 列出 collection 的全部 key,最近更新在前 |
| data.putMany(entries) | 最多 100 条原子写入 |
| files.put(collection, key, blob, options?) | 保存 Blob,支持 name、expiresAt |
| files.get(collection, key) | 返回 { ref, blob } |
| files.delete(collection, key) | 幂等删除本地文件 |
| files.export(blob, { name }) | 经宿主确认后分享或下载,返回 { method } |
| review.add({ key, title }) | 经用户确认加入复习,返回 { added: 0 \| 1 } |
| review.list() | 读取当前工具的全部复习条目(含未来排期) |
| launch.reviewKey | ready() 后可读的复习定位 key;无合法提示时为 null |
| invokeAi({ input, instruction? }) | 调用平台统一 AI 入口;需 ai-invoke |
| dispose() | 释放客户端 |
配置
| 字段 | 默认值 | 说明 |
|---|---:|---|
| timeoutMs | 10000 | 普通 Bridge 请求超时 |
| aiTimeoutMs | 190000 | AI 请求超时 |
| hostOrigin | "*" | postMessage 的 targetOrigin |
| win | 当前 window | 测试或特殊运行环境注入 |
沙箱 iframe 没有 allow-same-origin,因此是浏览器的不透明 origin。宿主通过 event.source === iframe.contentWindow 钉定工具窗口,SDK 只接受 event.source === window.parent 的回复;安全性不依赖可伪造的消息字段。
兼容性
旧版平铺方法仍然可用:
getData()→data.get()putData()→data.put()deleteData()→data.delete()listKeys()→data.listKeys()putMany()→data.putMany()
新代码建议使用命名空间 API。SDK 模块顶层不访问 window,可以被 SSR 构建安全导入,但客户端只能在智图宿主 iframe 中真正发起请求。
维护者发布
只能使用 pnpm publish。开发态 exports 指向 TypeScript 源码,pnpm 发布时会应用 publishConfig,将入口切换到 dist/;prepublishOnly 会阻止裸 npm publish 并自动执行构建和测试。
