AI 能力

ToolZen 内置平台 AI 服务(多模型),插件可以直接调用它,也可以把自己的能力注册成「工具」让模型调用。两项能力都在 apiLevel 8 引入:

能力 权限 做什么
ai / allAiModels ai:chat 调用平台模型
registerTool ai:tools 把插件能力注册成 AI 可调用的工具

AI 调用消耗平台的付费额度。 插件额度与用户的内置对话额度分开计算(默认每人每天 50 次,所有插件合计),插件烧光自己那一档不影响用户的对话额度。但请勿循环调用或在后台空转,并在界面上如实显示剩余次数。

额度是分开的两档

内置 AI 对话 插件 AI 调用
计数 按模型各自配置 所有插件合计,默认 50 次/天
端点 /api/user/ai-chat/stream /api/user/ai-chat/plugin-stream

分开的理由是成本与体验:插件可以循环调用(工具调用会一轮轮发请求),共用一个额度池时一个跑飞的插件就能把用户当天的对话次数吃光,而用户只会以为客户端坏了。

额度用满时 api.ai() 会以一个说明性的错误被拒绝,文案会点明「内置 AI 对话的额度不受影响」。

读取可用模型

api.allAiModels(): Promise<PluginAiModel[]>
const models = await api.allAiModels()
// [{ id, label, remaining, unlimited, vision? }]
  • 不需要权限:它只列一下有哪些模型可用,不消耗额度、不读用户数据。
  • 未登录时返回空数组,插件据此显示登录引导。
  • remaining 是插件额度的剩余次数,与内置对话界面上显示的那个数字是两回事。
  • 判定「还能不能用」要用 unlimited,不要只看 remaining:不限量时 remaining 恒为 0,而 0 不代表用完了。只看 remaining 会把一个不限量的账号显示成「已用完」。

调用模型

api.ai(options: PluginAiOptions, streamCallback?: (chunk: PluginAiStreamChunk) => void): Promise<PluginAiResult> & { abort(): void }

一次性调用

const result = await api.ai({
  messages: [
    { role: 'system', content: '你是一个简洁的助手。' },
    { role: 'user', content: '用一句话解释什么是防抖。' }
  ]
})

console.log(result.content)
console.log(`用了 ${result.rounds} 轮,模型 ${result.model}`)

流式调用

传了 streamCallback 就是流式,逐块回调,最终仍 resolve 成完整结果:

const call = api.ai(
  { messages: [{ role: 'user', content: '写一首关于秋天的短诗' }], stream: true },
  (chunk) => {
    // chunk.delta 是本次增量文本
    output.value += chunk.delta
  }
)

// 用户点「停止」
stopButton.onclick = () => call.abort()

const result = await call

中止后那个 Promise 会以 name === 'AbortError' 的错误被拒绝:

try {
  const result = await call
} catch (error) {
  if (error.name === 'AbortError') {
    // 用户主动停止,不是失败
  } else {
    api.toast(`对话失败:${error.message}`)
  }
}

options 字段

字段 必填 说明
messages 是 对话消息,至少要有一条 user
model 否 模型 id(取自 allAiModels());不传用默认模型
stream 否 是否流式;为真时必须传 streamCallback
reasoningEffort 否 思考强度:'none' / 'low' / 'medium' / 'high'。'none' 表示关闭思考
tools 否 参与函数调用的工具名数组(由 registerTool 注册)

返回值

type PluginAiResult = {
  content: string            // 模型的完整回复文本
  reasoningContent?: string  // 部分模型单独回传的思考内容
  model: string              // 实际使用的模型 id
  rounds: number             // 一共发起了几轮上游请求(有工具调用时会大于 1)
}

注册工具让模型调用

api.registerTool(definition: PluginToolDefinition, handler: (context: PluginToolContext) => unknown): boolean
api.registerTool(
  {
    name: 'get_weather',
    description: '查询指定城市的当前天气。用户问天气时调用它。',
    parameters: {
      type: 'object',
      properties: {
        city: { type: 'string', description: '城市名,例如「北京」' }
      },
      required: ['city']
    }
  },
  async ({ arguments: args }) => {
    const weather = await fetchWeather(String(args.city))
    return { city: args.city, temperature: weather.temp, condition: weather.text }
  }
)

注册之后,在 api.ai() 的 options.tools 里点名它,模型就可能调用:

const result = await api.ai({
  messages: [{ role: 'user', content: '北京今天天气怎么样?' }],
  tools: ['get_weather']
})

要点

  • description 是模型判断「什么时候该用这个工具」的唯一依据,写清楚:做什么、什么时候用、有什么副作用。没有描述的工具基本没用。
  • parameters 是 JSON Schema 的子集:type / description / properties / required / items / enum。没有参数的工具可以省。
  • handler 的返回值会被转成字符串回给模型:字符串原样、对象与数组 JSON.stringify、undefined / null 回空串。结果超过 32000 字符会被截断(模型的上下文有限)。
  • handler 抛错不会中断对话:宿主会把错误消息作为工具结果回给模型,模型下一轮能自己纠正。
  • 模型给的参数不可信:它可能拼错 JSON、传错类型。宿主已经兜住了(解析失败会回一段描述性文本给模型),但你的 handler 仍应当校验自己关心的字段。
  • 工具循环上限 8 轮(防止两个工具互相触发无限烧额度),到顶会用已有的正文收尾。
  • 插件退出时工具自动注销,不必手动清理。

参数不可信的实际表现

模型调用工具时传的是一个 JSON 字符串。下面这些情况宿主都会兜住,不会让对话中断:

模型给的 宿主的行为
一段不是 JSON 的文本 回「参数不是合法的 JSON」给模型,让它重试
[1,2](数组)或 42(数字) 回「参数必须是 JSON 对象」
一个不存在的工具名 回「工具不存在」
空参数 按「无参数」处理,给 handler 传 {}

完整例子:带工具的多轮对话

// 1. 注册工具(放在 setup 里,或 onPluginReady 之后)
api.registerTool(
  {
    name: 'search_notes',
    description: '在用户的本地笔记里搜索关键词,返回匹配的笔记标题与摘要。',
    parameters: {
      type: 'object',
      properties: { keyword: { type: 'string', description: '搜索关键词' } },
      required: ['keyword']
    }
  },
  async ({ arguments: args }) => {
    const hits = searchLocalNotes(String(args.keyword))
    return hits.slice(0, 5).map((note) => ({ title: note.title, excerpt: note.excerpt }))
  }
)

// 2. 带工具发起对话
const result = await api.ai({
  messages: [{ role: 'user', content: '我之前记过关于「防抖」的笔记吗?' }],
  tools: ['search_notes']
})

// result.content 已经是模型看过工具结果之后的最终回复
console.log(result.content)

宿主负责整个循环:把工具定义发给模型 → 模型回工具调用 → 执行工具 → 把结果发回去 → 直到模型不再要工具。

使用边界

  • 工具名在单个插件内唯一,同名后注册的覆盖先注册的。
  • api.ai() 需要用户已登录;未登录时以错误拒绝,插件应当先调 allAiModels() 判断并显示登录引导。
  • 插件额度用满后要等到次日才恢复,插件应当如实提示而不是让用户反复重试。
  • 不要用 AI 能力处理用户未明确交出的隐私数据。

老客户端上的行为

ai:chat / ai:tools 是 apiLevel 8 引入的。老客户端上 api.ai() 不存在,调用会以错误被拒绝(而不是静默返回空值——调用方需要知道失败原因)。

function supports(api, name) {
  try {
    return typeof api.hasCapability === 'function' && api.hasCapability(name) === true
  } catch {
    return false
  }
}

if (!supports(api, 'ai:chat')) {
  api.toast('AI 能力需要升级 ToolZen 客户端')
}

也可以直接在 manifest 里声明 requires: ["ai:chat"],让宿主在加载前拦下并提示升级——详见版本与兼容性。