插件宿主能力缺口:audio / video parts 支持
本页记录插件无法靠自己补齐的宿主能力缺口。个别场景有受限的替代路径 (音频可借前端播放器,见下文),但那替代不了原生通道,视频则连替代都没有。 结论均在本页引用的源码中 核实:宿主行为看
app/main_server/character_runtime.py与plugin/server/messaging/proactive_bridge.py,respond推送的图片如何投递看main_logic/core/proactive.py,wire 负载与其上限看plugin/core/context.py+plugin/settings.py+plugin/message_plane/ingest_server.py,临时媒体存储看plugin/server/routes/media.py+plugin/sdk/shared/core/images.py,part schema 看plugin/sdk/shared/core/push_message_schema.py,前端播放器看static/jukebox/music_ui.js。为避免行号漂移,下文只引用函数名与常量名。状态(2026-08-28 复核):本页最初提出的「问题一:图片 parts 没有用户 可见通道」已由 #2835(
1d654e302,2026-08-28 合并)落地,改写为下方 「已落地」一节;仍然缺失的是 audio / video parts 的宿主消费端。插件自身可解决的适配问题(双重回复、会话打断、消息监听、后台发图门控、
target_lanlan缺失等)已在社区插件内自行修复,不在本页范围。
已落地:图片 parts 的用户可见通道(#2835)
visibility 含 "chat" 的 push,其 text / image parts 会按原顺序渲染到 用户聊天窗,渲染形态是带来源标签的 role="system" 气泡(system chip)—— 既不是助手气泡也不是用户气泡,插件即使用角色口吻写文案,来源标签也会说明它 从哪来。
触发条件只看 visibility,与 ai_behavior 无关:respond / read / blind 三种都会渲染。ai_behavior 只决定这批 parts 是否同时进入模型上下文:
# 用户看得见图,模型完全不知道这张图
push_message(
visibility=["chat"],
ai_behavior="blind",
parts=[{"type": "image", "data": <png bytes>, "mime": "image/png"}],
)
# 用户看得见图,模型也看得见,并且立刻回一句
push_message(
visibility=["chat"],
ai_behavior="respond",
parts=[{"type": "image", "data": <png bytes>, "mime": "image/png"}],
)read 有一处例外:它进模型是尽力而为。当前会话没有 stream_image (或根本没有会话)时宿主会清空待注入图片;realtime provider 没有原生视觉时 同样清空——read 没有票据绑定的通道去投递 VISION_MODEL 描述,宿主宁可提前 退出并在日志里说明。这两种情况下聊天气泡照常渲染。要保证图片进模型,用 ai_behavior="respond":它的图片随 callback 走,不依赖当前会话。
两种图片来源都支持:
- inline:
parts=[{"type": "image", "data": <bytes>, "mime": "image/png"}], 在 wire 上编码为binary_base64。请保持小图——见下方 wire 预算; - 本地临时 URL:
await ctx.images.upload(data)返回可直接放进parts的 image part,其 URL 形如http://127.0.0.1:<port>/media/<id>,由宿主的临时 媒体存储(plugin/server/routes/media.py)提供。只要图片不是极小,都该走 这条路:URL 在 wire 上只占几十字节。
实现路径:_ordered_plugin_chat_blocks / _build_plugin_chat_blocks 生成 blocks → LLMSessionManager.render_chat_blocks 发出 chat_blocks WS 帧 → 前端 appendReactChatBlocks 渲染成 system chip。
模型侧 read 与 respond 都先由 _resolve_plugin_model_image / _fetch_plugin_image_base64 解出 base64,但投递方式不同——去日志里找图时值得 知道:read 直接交给会话的 stream_image;respond 则把图挂在 callback 上 (media_images),随主动搭话一起投递——语音模式经 _stream_cb_media → stream_image,文本模式作为 prompt_ephemeral 的 images= 参数。
仍存在的限制
- 任意外部 URL 依然被拒。
_is_local_plugin_media_url要求http+ 回环地址 +/media/<id>路径且无 query/fragment/凭据;不满足时聊天渲染直接 跳过该 part,模型注入侧记plugin image resolve failed; dropped。想发外部 图片,先ctx.images.upload()转成本地临时 URL。 - 传输层上限比宿主配额先卡住你。整个
MESSAGE_PUSH信封打包后必须小于MESSAGE_PLANE_PAYLOAD_MAX_BYTES(默认 256 KiB,可用NEKO_MESSAGE_PLANE_PAYLOAD_MAX_BYTES调整),而_build_wire_payload至今 在parts[].binary_base64之外还带一份 legacybinary_data拷贝——一张 inline 图等于在 wire 上走了两遍,源图明显超过 ~110 KiB 就会在plugin/message_plane/ingest_server.py被拒,根本轮不到宿主配额。更大的图 必须走ctx.images.upload()。 - 聊天与模型两条路径各有独立配额。聊天路径:最多
_PLUGIN_CHAT_IMAGE_MAX_COUNT = 8张,inline 图合计_PLUGIN_CHAT_INLINE_TOTAL_MAX_BYTES = 8 MiB;模型路径:单图_PLUGIN_IMAGE_MAX_BYTES = 8 MiB,每条 push 复用每回合 callback 的_PLUGIN_IMAGE_MAX_COUNT/_PLUGIN_IMAGE_TOTAL_MAX_BYTES预算。超出的图 被丢弃,不影响同一条 push 的其余 parts。这些 8 MiB 是 push 过了传输层之后 宿主愿意收多少——对 inline part 来说,你实际撞到的是上一条的 wire 上限。 ctx.images.upload()不能在 lifecycle handler 里调用——插件命令循环在 lifecycle 期间不处理上传响应,会直接抛RuntimeError;请在 entry、timer、 message 或自定义事件 handler 中调用。- 上传会归一化为 JPEG:长边上限
MAX_IMAGE_EDGE = 2048,源图上限MAX_SOURCE_IMAGE_BYTES = 32 MiB/MAX_SOURCE_IMAGE_PIXELS = 16M 像素, 产物上限MAX_UPLOADED_IMAGE_BYTES = 8 MiB。
仍缺口:audio / video parts 被宿主丢弃
现象:推送 audio / video part 没有任何输出。模型注入侧只有 ai_behavior in ("respond", "read") 才进入媒体循环,循环内对非 image part 打一行 warning 后丢弃;ai_behavior="blind" 根本不进循环,连诊断日志都没有。 聊天渲染侧同样不认这两类 part(_build_plugin_chat_blocks 只处理 text 与 image),所以即使写了 visibility=["chat"] 也不会渲染、也不会有日志。
# respond / read:宿主进入媒体循环,对 audio/video 打 warning 后丢弃
push_message(
visibility=["chat"],
ai_behavior="respond",
parts=[{"type": "audio", "data": <wav bytes>, "mime": "audio/wav"}],
)根因(源码核实):
app/main_server/character_runtime.py的媒体循环整体由ai_behavior_v2 in ("respond", "read")守卫;循环内part_type != "image"的分支记logger.warning("[EventBus] media_part type=%s not yet supported (mime=%s); dropped")后continue。注释说明stream_audio是实时麦克风 PCM 管线(特定采样率 + RNNoise 门控),不是通用文件注入器,并且没有 video API。- 所以
{"type": "audio"}/{"type": "video"}在 v2 schema 中虽有定义 (plugin/sdk/shared/core/push_message_schema.py与官方指引),宿主消费端 未实现。
影响:插件无法向会话推送语音 / 视频内容(TTS 音频文件、音乐片段、 游戏过场视频等)。已核实的替代路径:
- 音频:
ui_action=media_play_url可以让前端音频播放器播一个可直接播放、 非 HLS 的音频 URL(bridge 把该 action 转成music_play_url事件,前端走dispatchMusicPlay)。两个前提:- URL 已在播放允许列表上。前端
sendMusicMessageDetailed只给 500ms 等music-allowlist-updated事件,超时仍不在名单上就以unsafe_url拒绝并弹 toast。稳妥做法是先用ui_action=media_allowlist_add(domains或精确http_urls)登记,再推播放; - URL 不能是 HLS 流。
isUnsupportedMusicStream在起播前就跑,.m3u8一律以unsupported_stream拒绝并弹错误 toast,加没加白名单都一样。
- URL 已在播放允许列表上。前端
- 视频:没有替代。
media_play_url不能顶替——bridge 转出的music_play_url事件不携带media_type,前端固定把它交给音乐 / 音频播放器, 前端也没有视频事件通道。
期望:为 audio / video parts 提供宿主侧消费端(音频可注入会话或转交前端 播放器;视频至少支持 url 形式),或者在 schema 层显式标注这两类 part 未实现, 让 neko-plugin check 能在插件发布前拦下。
验证方法
- 推送
push_message(visibility=["chat"], ai_behavior="blind", parts=[{"type": "image", "data": <png bytes>, "mime": "image/png"}]): 用户聊天窗出现带插件名标签的 system chip 图片气泡;模型上下文里没有这张图。 - 同一 payload 换
ai_behavior="respond":聊天窗同样出现图片气泡,且图片进入 模型上下文(随 callback 投递,文本模式走prompt_ephemeral、语音模式走stream_image),她会就这张图回一句。 - 把图片换成
{"type": "image", "url": "https://example.com/cat.png"},并保持ai_behavior="respond"(打这条日志的是模型路径,blind根本不进媒体循环): 外部 URL 被拒——聊天窗无图,宿主日志出现plugin image resolve failed; dropped。改用await ctx.images.upload(<bytes>)返回的 part 则两侧都正常。 - 推送
parts=[{"type": "audio", "data": <wav bytes>, "mime": "audio/wav"}]+ai_behavior="respond":宿主日志出现media_part type=audio not yet supported, 无任何输出;换ai_behavior="blind"则连日志都没有(不进媒体循环,聊天渲染 也不认 audio part)。
