@fraqjs/plugin-ai
@fraqjs/plugin-ai 提供了配置模型提供商和 API Key 的统一入口,方便基于 Vercel AI SDK 的 AI 插件开发者访问用户开发者配置的语言模型和生图模型。
安装与配置
将插件添加到 fraq.yml 的 plugins 字段下:
plugins:
fraqjs/ai:
# 在这里声明多个 AI SDK 提供商,每个提供商下配置多个模型
providers:
# 提供商的名称可以自定义,建议使用 SDK 包名中的关键词以示区分
deepseek:
# 目前支持自动配置的 SDK 包括:
# - `@ai-sdk/alibaba` (Qwen 系列)
# - `@ai-sdk/anthropic` (Claude 系列, /v1/messages 风格 API)
# - `@ai-sdk/bytedance` (Seedream 系列)
# - `@ai-sdk/deepseek` (DeepSeek 系列)
# - `@ai-sdk/google` (Gemini 系列)
# - `@ai-sdk/moonshotai` (Kimi 系列)
# - `@ai-sdk/openai` (GPT 系列, /v1/responses 风格 API)
# - `@ai-sdk/openai-compatible` (/v1/chat/completions 风格 API)
# - `@ai-sdk/xai` (Grok 系列)
sdk: '@ai-sdk/deepseek'
# 这里用于提供传入 SDK 的配置项
# 基本的配置项有:
# - `apiKey`:必需,用户的 API Key
# - `baseURL`:可选,API 的基础 URL,默认为 SDK 内置的 URL
# 例如,DeepSeek 的默认 URL 是 `https://api.deepseek.com/v1`
# 不同 SDK 能接受的配置项不同,具体请参考对应 SDK 的文档
options:
apiKey: 'sk-xxx'
# models 是一个字符串数组,用于声明用户想要使用的语言模型名称
# 具体模型名称需要参考对应提供商 / SDK 的文档
# 例如 DeepSeek 的模型名称包括 `deepseek-v4-flash`、`deepseek-v4-pro` 等
models: [deepseek-v4-flash, deepseek-v4-pro]
openai-custom:
sdk: '@ai-sdk/openai'
options:
apiKey: 'sk-xxx'
# 可以通过 `baseURL` 配置自定义的 API URL
# OpenAI 风格的 API 通常以 `/v1` 结尾
baseURL: 'https://my-openai-api.com/v1'
models: [gpt-5.5, gpt-5.5-mini]
# imageModels 是一个可选的字符串数组,用于声明生图模型名称
# 例如 OpenAI 的生图模型包括 `gpt-image-2` 等
imageModels: [gpt-image-2]
# 在这里为模型配置别名,方便在插件中使用更友好的名称访问模型
# 键 (key) 为别名,值 (value) 为 providers 中声明的模型名称,格式为 `提供商名称/模型名称`
# 别名可以同时用于语言模型和生图模型
# 例如 `fast` 对应 `deepseek` 提供商下的 `deepseek-v4-flash` 模型
# `art` 对应 `openai-custom` 提供商下的 `gpt-image-2` 生图模型
aliases:
fast: deepseek/deepseek-v4-flash
'fast-gpt': openai-custom/gpt-5.5-mini
balanced: deepseek/deepseek-v4-pro
expert: openai-custom/gpt-5.5
art: openai-custom/gpt-image-2
# 默认语言模型,插件中通过 `ctx.ai.model()` 访问时,如果没有指定模型名称,就会使用这个默认模型
# 可以使用别名或者 `提供商名称/模型名称` 的格式指定默认模型
# 如果没有配置这一项,默认使用 providers 中第一个语言模型作为默认模型
defaultModel: fast
# 默认生图模型,插件中通过 `ctx.ai.image()` 访问时,如果没有指定模型名称,就会使用这个默认模型
# 如果没有配置这一项,默认使用 providers 中第一个生图模型作为默认模型
defaultImageModel: art
# 是否禁用 AI SDK 的全局警告输出,默认为 false
disableGlobalWarnings: false如果你是插件开发者,请将本插件添加到项目的 peerDependencies 中,并在自己的插件中声明依赖:
import { definePlugin } from '@fraqjs/fraq';
import { AiService } from '@fraqjs/plugin-ai';
definePlugin({
name: 'my-plugin',
inject: {
ai: AiService,
},
apply(ctx) {
// 通过 ctx.ai.model 访问配置好的语言模型
// 通过 ctx.ai.image 访问配置好的生图模型
},
});获取模型实例
插件会将用户配置的模型实例注入到 ctx.ai 中。语言模型和生图模型分别通过 model 和 image 方法获取:
语言模型
const model = ctx.ai.model(); // 获取默认语言模型
const fastModel = ctx.ai.model('fast'); // 通过别名获取
const kimiModel = ctx.ai.model('moonshot/kimi-k2.5'); // 通过 提供商/模型名 获取
ctx.ai.hasModel('fast'); // true
ctx.ai.hasModel('nonexistent'); // false
ctx.ai.models(); // 获取所有语言模型的名称列表生图模型
const image = ctx.ai.image(); // 获取默认生图模型
const artModel = ctx.ai.image('art'); // 通过别名获取
const dalleModel = ctx.ai.image('openai-custom/gpt-image-2'); // 通过 提供商/模型名 获取
ctx.ai.hasImage('art'); // true
ctx.ai.hasImage('nonexistent'); // false
ctx.ai.images(); // 获取所有生图模型的名称列表调用 AI 功能
Vercel 的 ai 包提供了丰富的函数来调用模型,只需要传入模型实例和相应的参数即可。@fraqjs/plugin-ai 将 ai 包的所有功能导出为 ai 变量,只需要在代码中加入:
import { ai } from '@fraqjs/plugin-ai';即可使用 ai 包中的函数。下面是一些简单的例子,更多用法请参考 Vercel AI SDK 的文档。
生成文本
const { text } = await ai.generateText({
model: ctx.ai.model(),
prompt: '用一句话介绍一下 Fraq。',
});
console.log(text);流式生成文本
streamText 会返回一个流式结果,你可以通过 textStream 异步迭代器逐段读取生成的文本:
const result = ai.streamText({
model: ctx.ai.model(),
prompt: '写一首关于海的短诗。',
});
for await (const delta of result.textStream) {
process.stdout.write(delta);
}生成结构化数据
generateText 和 streamText 都支持通过 output 选项来指定生成结果的格式:
import z from 'zod';
const { output } = await ai.generateText({
model: ctx.ai.model(),
output: ai.Output.object({
schema: z.object({
recipe: z.object({
name: z.string(),
ingredients: z.array(
z.object({ name: z.string(), amount: z.string() }),
),
steps: z.array(z.string()),
}),
}),
}),
prompt: '请生成一份阳春面的食谱。',
});这里输出的 output 将会是一个符合指定 Zod schema 的对象。
同样的,如果你在插件中使用 zod,也需要将 zod 添加到 peerDependencies 中。
生成图片
generateImage 会调用生图模型并返回生成的图片,你可以通过 base64 或 uint8Array 获取图片数据:
import { seg } from '@fraqjs/fraq';
const { image } = await ai.generateImage({
model: ctx.ai.image(),
prompt: '画一只猫',
});
// 将生成的图片转换为 base64 URI 并发送
await session.reply(seg.image(`base64://${image.base64}`));工具调用
generateText 和 streamText 都支持工具调用(Tool Calling),你可以通过 tools 选项让模型在生成过程中调用你定义的函数:
import z from 'zod';
const { toolResults } = await ai.generateText({
model: ctx.ai.model(),
prompt: '东京现在的天气怎么样?',
tools: {
weather: ai.tool({
description: '查询某个城市的天气',
inputSchema: z.object({
city: z.string().describe('城市名称'),
}),
execute: async ({ city }) => `${city}今天是晴天`,
}),
},
});
console.log(toolResults);将消息转换为 XML
@fraqjs/plugin-ai 提供了 xmlify 系列函数,将 milky.IncomingMessage 转换为结构化的 XML 文本。在把聊天消息作为上下文提供给语言模型时,XML 的结构化形式比 JSON 更利于模型理解消息的各个组成部分(如图片、@提及、回复引用、合并转发等)。
转换单条消息
xmlify 接收一个 Context 和单条消息,返回一个包含 XML 文本、资源和文件的对象:
import { xmlify } from '@fraqjs/plugin-ai';
ctx.on('message_receive', async ({ data }) => {
const { xmlContent, resources, files } = await xmlify(ctx, data);
console.log(xmlContent);
});对于一条包含文本和图片的好友消息,xmlContent 的输出形如:
<message scene="friend" peer_id="10001" seq="1" sender_id="10001" time="1700000000">
<content>
<text>你好,看看这只猫</text>
<image id="image-1-1" width="800" height="600" sub_type="normal">[图片]</image>
</content>
<friend>
<user_id>10001</user_id>
<nickname>Alice</nickname>
<remark></remark>
</friend>
</message>返回对象(XmlifyContext)包含以下字段:
xmlContent:格式化后的 XML 字符串。resources:资源键到临时 URL 的映射,例如{ 'image-1-1': { url: 'https://...' } }。资源键的格式见下文资源键。files:资源键到文件数据的映射,对应file类型的消息段。forwards:资源键到转发消息段数据的映射,对应forward类型的消息段,无论该转发是否被展开。
<message> 根元素上会附带 scene、peer_id、seq、sender_id、time 等属性;根据消息场景,还会包含 <friend>、<group>、<group_member> 等子元素,描述发送者与会话的上下文信息。
资源键
资源键的格式为 <类型>-<作用域>-<序号>,类型为 image、record、video、file 或 forward,序号是该类型资源在所属作用域内的出现次序,从 1 开始:
- 消息本身的消息段属于作用域
<消息 seq>,例如 seq 为50的消息中的第一张图片为image-50-1。 - 每个合并转发都会占用一个
forward序号,无论是否展开。转发展开后,其中所有节点的消息段属于一个新的作用域,即在外层作用域后追加该转发的序号:forward-50-1内的图片为image-50.1-1,它内部嵌套的第一个转发为forward-50.1-1,再往内的图片为image-50.1.1-1。
例如 seq 为 50 的消息依次包含图片 X、转发 outer、图片 Y,其中 outer 的第一个节点包含转发 inner 和图片 B,inner 中包含图片 A:
| 资源 | 键 |
|---|---|
| 图片 X | image-50-1 |
| 转发 outer | forward-50-1 |
| 转发 inner | forward-50.1-1 |
| 图片 A | image-50.1.1-1 |
| 图片 B | image-50.1-1 |
| 图片 Y | image-50-2 |
资源键只取决于资源在消息中的位置,与对话窗口的范围以及 maxForwardDepth 无关:展开更深的转发只会增加内层的键,不会改变已有的键。因此同一条消息无论单独转换、处于哪段对话历史中,还是以不同深度展开,同一个键都指向同一个资源,可以安全地合并多次转换的结果,并且输出保持稳定,便于模型提供商复用提示词缓存。
转换多条消息
xmlifyThread 用于将多条消息合并为一个 <thread> 元素,适合将一段对话历史整体提供给模型。由于资源键包含消息 seq,同一会话中不同消息的 image、record、video、file、forward 键不会冲突;对话窗口滑动时,已有消息的输出也保持不变:
import { xmlifyThread } from '@fraqjs/plugin-ai';
const { messages } = await ctx.client.get_history_messages({
message_scene: 'group',
peer_id: 100100,
limit: 10,
});
const { xmlContent } = await xmlifyThread(ctx, messages);输出形如:
<thread scene="group" peer_id="100100">
<message seq="1" sender_id="10001" time="1700000000">
<content>
<text>有人在线吗?</text>
</content>
</message>
<message seq="2" sender_id="10002" time="1700000001">
<content>
<text>在的</text>
</content>
</message>
</thread>在 xmlifyThread 中,每个 <message> 元素只保留 seq、sender_id、time 属性,场景与对端信息统一由外层 <thread> 携带。
如果需要逐条处理对话历史(例如把每条消息放进单独的内容块),可以对每条消息调用 xmlify 并传入 inThread: true,得到的 <message> 与它在 xmlifyThread 输出中的内容一致(仅缩进不同):
const parts = await Promise.all(messages.map((message) => xmlify(ctx, message, { inThread: true })));配置项
xmlify 和 xmlifyThread 的第三个参数是一个可选的 XmlifyOptions 对象,支持以下配置项:
maxForwardDepth:合并转发消息的展开深度,默认为0。为0时,合并转发不会被展开,仅输出一个id及占位文本;大于0时,会通过ctx.client.get_forwarded_messages拉取转发内容,并递归展开到指定深度,展开后的<forward>同样带有id。超出maxForwardDepth的嵌套层级会以(Too deeply nested)占位,同样包含一个id,避免无限制地拉取深层转发内容。inThread:是否按对话历史中的消息输出,默认为false。为true时,<message>只保留seq、sender_id、time属性,与xmlifyThread中的输出一致;xmlifyThread总是启用此项。serialize:传递给dom-serializer的序列化选项,默认使用encodeEntities: 'utf8'。format:传递给xml-formatter的格式化选项,默认使用两个空格缩进。
使用内置工具
AiService 提供了一个 milkyToolset 方法,可以构造 tools 选项所需的工具集,方便 AI 直接调用 Milky 协议功能。你需要提供一个 ctx 实例和一个数组,数组中包含你想要开放给 AI 调用的 Milky API 名称:
import { milkyToolset } from '@fraqjs/plugin-ai';
const tools = milkyToolset(ctx, ['get_login_info', 'set_nickname']);
await generateText({
model: ctx.ai.model(),
prompt: '获取当前帐户的登录信息。',
tools,
});