media 服务
media 把「媒体 → 文本」的多模态识别(图片描述、音频转写描述、视频抽帧加音轨)抽象成一个统一调度器。上层——agent preprocessor、工具、适配器——只需要向 media 提问,由它在一个 processor 池里仲裁并执行。这个池由「vision/audio LLM」与「独立的 ASR/Whisper backend」共同组成。
- 服务注册名:
getService('media')(字符串键media,ServiceTypeMap.media)。 - 契约包:
@aalis/api-media。契约除了服务本身,还导出底层的MediaProcessor抽象。你写「非 LLM 的媒体 backend」时实现MediaProcessor再registerProcessor;写「服务消费」时只用MediaService。 - 参考实现:
@aalis/plugin-media,声明provides=['media'],通过ctx.provide('media', svc)提供,实现类是MediaServiceImpl。 - 它不是沙箱:媒体的下载与落盘走
safeFetch(SSRF 守卫)加storage,但 storage 本身不是隔离边界,详见 §6。
这里有两层概念需要分清。MediaService 是一个真实可调用的服务实例,是有运行时的服务契约。MediaProcessor 则是给 backend 作者实现的「插件内子契约」——它不是独立的 DI 服务,而是注册进 media 服务内部池子的一个处理器对象。
1. 契约:核心类型与方法签名
MediaService
export interface MediaService {
// ----- processor 池管理 -----
registerProcessor(p: MediaProcessor): () => void; // 注册非 LLM backend,返回注销函数
listProcessors(cap?: MediaCapability): MediaProcessor[];
pickProcessor(cap: MediaCapability, prefer?: string | ModelRef | null): MediaProcessor | null;
// ----- 批量识别(按 attachment.kind 自动选 processor)-----
describe(attachments: MessageAttachment[], opts?: DescribeOptions): Promise<Array<string | undefined>>;
transcribe(attachment: MessageAttachment, opts?: TranscribeOptions): Promise<string | undefined>;
// ----- 一站式:处理整条入站消息(preprocessor 内部用,外部一般不直接调)-----
processMessage(msg: IncomingMessage): Promise<MediaProcessReport>; // 把每条附件描述写进 msg._attachmentDescriptions
// ----- 单图/单视频主动识别 + 描述缓存 -----
describeImage(imageUrl: string, opts?: DescribeImageOptions): Promise<string>; // 带 30 天缓存;失败返回空串
describeVideo(videoUrl: string, opts?: DescribeVideoOptions): Promise<string>; // 抽帧+可选音轨;失败返回空串
lookupDescription(imageUrl: string): string | null; // 只查缓存不触发识别
rememberDescription(imageUrl: string, description: string): void;
rememberDescriptionAlias?(source: string, landedRef: string): void; // 落盘方登记「来源 → 落盘 ref」,可选
buildContext(msg: IncomingMessage, opts?: BuildContextOptions): Promise<string>; // 为含图消息造视觉上下文 hint
}取服务:ctx.getService<MediaService>('media');plugin-media 未就绪时返回 undefined。
MediaProcessor(backend 子契约)
一个 backend 声明它能处理哪些动作,并实现对应方法。按 cap 二选一或两者都实现:
export interface MediaProcessor {
name: string; // 唯一标识,建议 `<provider>:<modelOrKind>`
capabilities: MediaCapability[];
displayName?: string; // UI 用
priority?: number; // 数值大者优先(同 cap 多 processor 仲裁),默认 0
describe?(input: DescribeInput, ctx: Context): Promise<DescribeResult>; // vision/document.image/video.passthrough
transcribe?(input: TranscribeInput, ctx: Context): Promise<TranscribeResult>; // audio
}MediaCapability
四个取值,分别对应四类处理动作:
'vision':图描述,含动图抽帧整合。'audio':音频转文本,同一个 cap 同时覆盖语音转写与音乐/环境音描述。'video.passthrough':原生视频 LLM 直通。'document.image':文档内嵌图的 OCR/理解。
注意 MediaCapability 与 LLM 的 Capability 语义不同:前者描述「处理动作」,后者描述「模型能力」。两者不在同一个 DI capability 体系里,详见 §6。
输入/输出类型
DescribeInput:attachments,加上basePrompt(完整替换默认 base)/hint(在 base+context 之后追加一条约束)/context(对话上下文,processor 可拼进 prompt)/maxTokens/mode:'single'|'combined'。basePrompt与hint的语义严格分离:要换整段 prompt 用basePrompt,只加一条要求用hint。把整段 prompt 塞进hint会与默认 base 冲突。
DescribeResult:descriptions(mode=single时与attachments等长;mode=combined时是单元素),加上meta?:{processor,model?,tokens?}。TranscribeInput:单条attachment,加上language?(ISO 639-1)/withTimestamps?/context?(仅对 LLM-as-audio 有意义,传统 Whisper 忽略)。TranscribeResult:text/segments?/language?/meta?:{processor,model?}。- 服务层 opts:
DescribeImageOptions(含detailLevel档位,详见 §6)、DescribeVideoOptions、DescribeOptions/TranscribeOptions(含prefer,强制选定 processor)、BuildContextOptions。 MediaProcessReport:{ total, successCount, items[] },items与msg.attachments等长,每条含{kind,cap?,processor?,description?,error?}。
事件(declaration merging)
'media:processed': [{ sessionId, report }]。一条入站消息的所有附件处理完毕后发出,无论成功还是失败都会发,供 webui、archive、调试等消费。
2. 谁提供 / 谁消费
提供方
| 包 | 角色 |
|---|---|
@aalis/plugin-media | 唯一的 media 服务提供者兼调度器。内置 LLM-as-Processor adapter,自动把所有在 capabilities 里声明了 vision/audio 的 LLM 包成 MediaProcessor(name 格式 llm:${contextId}#${capShort});同时把核心 asr 服务的每个 provider 桥接成 cap=audio 的 processor(name 格式 asr:${contextId})。 |
@aalis/plugin-asr-whisper-cpp / @aalis/plugin-asr-openai | 不直接 provide media,而是 provide 核心 asr 服务(subsystem='media')。media 会自动把它们纳入 audio 池。写一个音频 backend 时应优先写成 asr provider,而非 MediaProcessor,见 §4。 |
典型消费点
| 包 | 调用 | 说明 |
|---|---|---|
plugin-media(自身 preprocessor) | svc.processMessage(msg) | 经 buildPreprocessor 注册到 agent |
plugin-file-reader | media.describeImage(uri) | 识别 DOCX 内嵌图;先判 if (!media?.describeImage) return '' |
plugin-image-sender | media.describeImage(url, { detailLevel: 'casual' }) | 给候选图打描述以挑图 |
plugin-adapter-onebot | media.lookupDescription(url) | 只复用缓存、不触发识别 |
plugin-message-archive | getService('media') | 归档时取描述;inject.optional:['media'] |
plugin-webui-server | ctx.getService('media') !== undefined | 探测是否启用 |
3. 服务模型背景
有三条前提对使用 media 是必要的:
- DI 按名字解析,
media在全局只有一个赢家:偏好 > priority > 注册顺序。0.5.0 起没有 capability-based 的服务选择——capability 概念活在MediaProcessor.capabilities(实例内部池)里,不是 DI 的选择维度。见 docs/concepts/service-model.md。 - 消费方每次用都重新
getService('media'),不要缓存实例——provider bounce 或 reload 会让旧引用失效。见 docs/concepts/lazy-service-access.md。 - manifest 的双源(
package.json里的aalis.service与模块导出的provides/inject)需要保持一致。见 docs/concepts/manifest-metadata.md。
4. 写一个 provider
先决定走哪条路:
| 你的 backend 是… | 怎么接入 |
|---|---|
| vision / audio 的 LLM | 什么都不用做。只要你的 LLM provider 在 capabilities 里声明了 vision/audio,plugin-media 的内置 adapter 会懒扫描并自动把它包成 MediaProcessor。 |
| 音频转写 backend(whisper / 云 ASR) | 写成 asr provider,实现 ASRService(@aalis/api-asr,单方法 transcribe)。media 会自动把它桥接进 audio 池。可参考 plugin-asr-whisper-cpp。这是首选做法——它能同时被「直接消费 asr」与「经 media 调度」两条路用到。 |
| 非 LLM 的图/视频 backend(如自建 OCR、专用识别服务) | 实现 MediaProcessor,再 media.registerProcessor(p)。 |
4a. 不要重新 provide media 服务
media 调度器只应有一个实现(plugin-media)。你不要 ctx.provide('media', ...)——那会和官方调度器争抢同名服务的赢家位置。你要做的是往现有调度器里注册 processor,或者写一个 asr provider。
4b. 注册一个 MediaProcessor(非 LLM backend 骨架)
import type { Context } from '@aalis/core';
import type { DescribeInput, DescribeResult, MediaService } from '@aalis/api-media';
export const name = '@aalis/plugin-my-ocr';
export const inject = { required: ['media'] }; // media 是硬依赖时写 required
export function apply(ctx: Context): void {
const media = ctx.getService<MediaService>('media');
if (!media) return; // 防御:media 未就绪
const dispose = media.registerProcessor({
name: 'my-ocr:default', // 建议 <provider>:<kind>
capabilities: ['vision'], // 或 ['document.image']
displayName: '自建 OCR',
priority: 10, // > 0 优先于默认 LLM(priority=0)
async describe(input: DescribeInput, _ctx): Promise<DescribeResult> {
// input.mode 'single' → 与 attachments 等长;'combined' → 单元素
// 尊重 input.basePrompt(完整覆盖)/ input.hint(追加约束)/ input.context(仅参考)
const out = await Promise.all(input.attachments.map(a => runOcr(a)));
return { descriptions: out, meta: { processor: 'my-ocr:default' } };
},
});
ctx.onDispose(dispose); // 必须:bounce/reload 时把自己从 media 池摘掉
}4c. 写一个 asr provider(音频 backend 首选骨架)
import type { Context } from '@aalis/core';
import type { ASRService } from '@aalis/api-asr';
export const name = '@aalis/plugin-asr-xxx';
export const subsystem = 'media'; // 与 whisper-cpp/openai 一致归到 media 子系统
export const provides = ['asr'];
export function apply(ctx: Context): void {
const impl: ASRService = {
async transcribe(input, _ctx) {
// input.attachment.data 可能是 storage URI / http(s) / data-URI(见 §6)
const text = await callBackend(input.attachment, input.language);
return { text, language: input.language, meta: { model: 'whisper-xxx' } };
},
};
ctx.provide('asr', impl, { priority: 0 }); // 多 asr provider 由核心按偏好>优先级仲裁
}任一 provider 都要同步 package.json 的双源:
{
"aalis": { "service": { "provides": ["asr"], "inject": { "required": ["process", "storage"] } } },
"keywords": ["aalis", "aalis-plugin"]
}契约包本身(api-media)的 package.json 则是 "aalis": { "types": true } 加 keywords:["aalis","aalis-api"]——纯类型包不打 aalis-plugin 词。
5. 标准消费写法
export const inject = { optional: ['media'] }; // media 是可选增强时
async function handle(ctx: Context, url: string) {
const media = ctx.getService('media'); // 每次用都重新取,不要缓存
if (!media?.describeImage) { // 服务缺失 / 方法缺失双重保护
return '未启用 media 服务';
}
// describeImage 失败返回空串(不抛),按空串降级即可
const desc = await media.describeImage(url, { detailLevel: 'casual', hint: '挑出有猫的图' });
return desc || '(识别失败)';
}几个要点:
- 惰性取服务:
plugin-message-archive、plugin-image-sender、plugin-file-reader都是每次现取(getService)再加if (!media?.xxx)守卫,对照见 §2。 - 只复用缓存、不触发识别:对引用消息里的图,OneBot 适配器只调
lookupDescription(url),未命中就保持[图片]占位,不会主动消耗 vision token。 - 错误边界:
describe/transcribe/describeImage/describeVideo内部都做了 try/catch,失败返回undefined或空串而非抛错。调用方按「空 = 降级」处理即可。 - 顺序识别更稳:本地视觉模型多为单实例串行,
plugin-image-sender显式逐张识别而非并发,避免它们互相排队又同时超时。
6. 能力 / 风险 → 影响
detailLevel 四档(describeImage 专用)
detailLevel | 语义 | 实现 |
|---|---|---|
'casual' | 简洁日常(≤200 字、识梗、不列点)——聊天截图/表情包 | 直接用 cfg.vision.prompt || DEFAULT_VISION_PROMPT |
'detailed' | 详细识别(不限字数、信息密度高的图) | DEFAULT_VISION_DETAILED_PROMPT |
'professional' | 专业题目(严格 LaTeX、几何坐标、反幻觉)——数理化题 | DEFAULT_VISION_PROFESSIONAL_PROMPT |
'auto'(默认) | 单次推理自路由:模型看图自判类型并按类型给相应详略(拿不准按文字密集图完整识别) | cfg.vision.prompt || DEFAULT_VISION_AUTO_PROMPT |
四档都是一次 vision 调用。只想要短描述(例如挑图预览)传 'casual' 用简洁模板,plugin-image-sender 即如此;cfg.vision.prompt 填写后覆盖 auto 与 casual 两档,detailed/professional 不受影响。
描述缓存
缓存基于 @aalis/util-bounded-map(有界,加滑动 TTL,加 LRU),配置为 max=5000 条、ttlMs=30 天(图片内容不变、描述也就不过期,长留才吃得到跨天的表情包复用)。key 取落盘路径里的内容哈希,非内容寻址的来源(远端 URL、data-URI)原样做键;值只存裸描述,ref 标记等包装由各消费点重建。
来源 → 落盘 ref 别名:非内容寻址的来源串本身不含内容哈希,落盘后才有内容寻址路径。落盘方在落盘成功时登记一次「来源 → 落盘 ref」别名——本服务的 cacheImageRef 落 base64 data-URI 时自登记,服务外的落盘方(适配器把 QQ 媒体直链落到 data/images/…)经可选方法 rememberDescriptionAlias(source, landedRef) 登记。此后按原始来源串读写描述都落到落盘 ref 的那条内容哈希键上——OneBot 引用消息只拿得到原始 URL(适配器那条路径只查缓存、不主动触发识别)时经此命中,同一张图换来源进来也不重认,描述还进得了快照(快照只收内容哈希键)。别名按来源串相等命中;QQ 直链里的 rkey 是访问密钥,定期轮换但不改变文件,适配器登记时同时登记剥掉 rkey 的键,引用消息查询时也按剥掉 rkey 的键再查一次,轮换后仍命中。别名表纯派生、不落盘,重启后由新一轮落盘重新登记;键空间不变,别名只把来源映到已有的落盘键。登记之前已按原始来源串写入的条目不迁移(窄场景,代价只是多识别一次)。
describeImage 只在「无 hint 且未 noCache」时读写缓存——带 hint 不进缓存,因为不同意图的结果不同。缓存按详略档分键:'auto'(默认)与到达识别共用同一条,casual / detailed / professional 各自一条(否则「详细分析」会直接命中到达时写下的简述);档位后缀加在内容哈希之后,别名与跨会话共享对各档同样生效,快照也一并落盘。空串以及 [图片: / [动图: 这类占位都不会写入。lookupDescription/rememberDescription/rememberDescriptionAlias 则暴露给适配器做手动复用与别名登记。
storage URI 用法(provider 与调度器都要懂)
内部的 cacheImageRef 在三种字符串形态间分流,顺序很重要:
http(s)://→ 原样保留(下载走safeFetch,见下)。isStorageUri(data)命中(即 storage 路径,含data:/...,OneBot 已落盘)→ 转成相对路径root/rest。- 否则若
data.startsWith('data:'),当作 base64 data-URI 解码,经storage.writeFile落盘到data:/images/{session}/{hash}.{ext},并登记上节的「来源 → 落盘 ref」描述缓存别名。
先问 isStorageUri,再问 startsWith('data:')
data:/images/x.jpg(storage 根 data)与 data:image/png;base64,...(浏览器 data-URI)只靠冒号后是否紧跟 / 区分。判定顺序反了,会把 data-URI 误判成 storage 路径。完整文法见 docs/concepts/storage-uri-grammar.md(§3 data:/ 与 data-URI),它直接以本服务为示例。
临时帧与下载产物经 proc.makeTempDir 加 tmp.uri 写盘,用完 cleanup。
SSRF:所有远程下载必须走 safeFetch
由 LLM 或用户输入触发的 URL 下载是 SSRF 高危面(169.254.169.254、127.0.0.1、10.0.0.0/8)。plugin-media 一律走 safeFetch(@aalis/util-network-guard),它包含协议/host/逐跳重定向校验,加 20 MiB 体积上限,加 15s 超时,与 webui 的 image proxy 共用同一套规则。见 docs/concepts/security-model.md。
backend 自行下载也必须用 safeFetch
你写 backend 时若要自己下载远程媒体,必须用 safeFetch,不要裸调 fetch。
authority / 隔离
media 服务本身不挂 authority 的 risk/visibility 门——它是 preprocessor 链路上的内部增强,调用语境(agent / 工具)由各自的 authority 把关。
不过落盘路径按 session 做了 key 隔离(images/{safeSession}),buildContext 也只读当前会话历史。provider 实现新的存储路径时,应延续按 session 隔离,不要跨会话泄漏。
storage 不是沙箱
media 经 storage 写临时/缓存文件,但 storage 只是命名根加权限位,不是进程级隔离;ffmpeg 子进程经 process 服务运行,同样不是沙箱。详见 docs/services/storage.md §6 与 docs/services/process.md。
7. 边界与注意事项
- runtime 单例依赖:media 内部的 ffmpeg 与远程下载逻辑经模块级
setMediaRuntime({proc,storage})注入依赖,在apply()时设置。若process/storage未启用,getMediaRuntime()会抛错——因此inject.required:['process','storage']。第三方 backend 若要自己拿依赖,请走ctx,不要依赖 media 的内部 runtime。 - audio.prefer 下拉是 live mutate 的:
media监听service:registered/service:unregistered(asr/llm),动态刷新configSchema.audio.fields.prefer.options。新装 asr 或 audio-LLM 后,选项会自动出现。这意味着configSchema对象在运行时被改写,前端配置页读的是这个 live 对象。 - 空音频描述不等于「非语音」:模型的空响应可能是 maxTokens 不足、上下文超限或超时,这些都被统一标为
[音频] 识别失败(…详见日志)。不要据此判断「这段音频没人声」。 - 图片是「识别模型 + 两个正交开关」:
vision.prefer是识别模型;vision.recognizeOnArrival(默认开)决定图片到达是否立即识别落描述——描述进档案与向量库、可被召回,被吞掉的消息也留记忆;关掉则档案只留指针[图片 | ref:…],主模型需要时经analyze_image按需看,代价是图片内容不可被检索召回。vision.delivery决定主模型需要看图(当轮附件、analyze_image)时怎么给:auto(默认)按本会话生效主模型的 vision 能力——有则直通原图,无则由识别模型转文字;也可显式钉死passthrough/describe。audio.mode='passthrough'仍是音频直通:不转写,保留原始 attachment 让主模型直接吃(需主模型 audio 能力)。 - describe 交付不向主模型交图:识别是识别模型的职责,结果已作为文字拼进消息正文,出口会把末条 user 消息的
images清空。再把原图递一份给主模型等于同一张图识别两遍——实测一张 57KB 的图额外多花约 1,090 token、4.7 秒预填充。analyze_image在这条交付下返回文字描述。 - passthrough 交付的动图一律抽帧:出口(
agent:llm:before中间件)把动图抽帧为多张静图(帧数上限animatedImage.maxFrames)再交给主模型——主流视觉 API 对原始 GIF 只读首帧或拒收,抽帧是让"动"被看见的唯一通用形态;静图始终原样。变换只作用于末条 user 消息、每条消息只处理一次(成败皆不重来),dryRun 估算轮跳过。analyze_image在这条交付下把图随工具结果交出(ToolExecutionResult.images,同一出口规范化与抽帧),不经识别模型。 - 直通必须过形态规范化:适配器给
attachment.data的是内容寻址的相对路径 ref(data/images/…),而 provider 只认 data URI / http /file:/// 绝对路径。裸路径会被当作 base64 送出去,触发illegal base64 data at input byte N(整轮请求 400)。出口统一物化成 data URL,已是合法形态的逐字节原样;交不出合法形态的那一张丢弃并告警。 - 描述缓存跨会话共享是有条件的:缓存键取落盘路径里的内容哈希,同一张表情包跨群只识别一次;但当
contextHistory/senderContext开启、描述里掺进了本会话的对话语境时,键退回含会话目录的原路径,只在本会话内复用——否则 A 群的语境会随描述串到 B 群。缓存有快照落盘(data:/media/descriptions.json),进程重启不必重认。 - 视频只有「抽帧 → vision」一条路:media 不再为声明 Video 能力的 LLM 注册
video.passthroughprocessor(service 从不按该 cap 选 processor),抽帧描述用的是vision.maxTokens。MediaCapability里保留该 cap 只为第三方 processor 契约兼容。 - 缺 ffmpeg/ffprobe 时视频降级为占位:抽帧或抽音轨失败时返回
[视频] …占位串而非空串,让主 LLM 知道有视频到达但读不了,避免幻觉。
8. 交叉链接
- 概念:service-model(DI 按名仲裁、无 capability 选择)|lazy-service-access(每次 getService、别缓存)|manifest-metadata(provides/inject 双源)|storage-uri-grammar(
data:/与 data-URI 区分,直接引本服务为例)|security-model(SSRF / safeFetch)|message-llm-pipeline(preprocessor 在消息链路的位置)。 - 服务:storage(落盘后端,非沙箱)|process(ffmpeg 子进程)|llm(vision/audio LLM 自动成 processor)|message(
MessageAttachment/IncomingMessage类型源)|agent(preprocessor 注册宿主)。 - 相关契约包:
@aalis/api-asr(音频 backend 首选契约)。