Author SHA1 Message Date
AskaEth bdf1494319 docs(contract): v0.98 erratum - sixth speak error code is invalid_args not not_configured
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-27 22:01:50 +08:00
AskaEth 9c13d8a469 docs(contract): v0.98 outbound voice - speak tool budget, tts source, fallback marking
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 模型侧 speak 工具(她的自主媒介决策)+ 字数预算公式 + 六错误码登记
- voice DTO: source 增 tts、fallbackReason(仅 failed 时)、四键同生共死含降级行
- 判据变更如实标注: voice 存在 && status != failed = 可播气泡; failed = 文字+降级小标
- QQ record 段真语音条(silk 待真机)与微信降级口径
2026-10-27 21:57:55 +08:00
AskaEth 27a661436c Merge branch 'feat/m59-qqrecord-outbound-voice-record-segme' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-27 20:57:40 +08:00
AskaEth eca1f33d8c feat(qq_obv11): 出站语音发真 record 段(base64 内联优先,读不到的 file 形态提前拒绝)
M59 QQRECORD:adapter.Media 里 Kind=voice 的条目落成 {"type":"record"} 段。正文仍是独立的 text 段排在 record 前面(QQ 语音是独立气泡,正文既不塞进 record 也不被吞掉)。

record 段 file 参数三档裁决(outboundRecordFile,本片核心决策):

1. Data 非空 => base64://+标准 base64。出站语音字节本就在本进程内产生(TTS),内联不依赖跨进程共享文件系统(Napcat 可能在容器/另一台机器),也不需要插件知道框架的 $DATA_DIR(插件没有那个配置面)。

2. 原样透传:已是 base64://、http(s)://、或 filepath.IsAbs 的本地绝对路径 —— 这三种 OneBot 实现自己能解析,一个字节都不改写。

3. 明确拒绝:服务端相对路径(/api/v1/media/<name>)、裸文件名、空值,提前失败并说明要什么。前缀判定必须排在 IsAbs 之前 —— Linux 上 /api/v1/media/x.wav 会被 IsAbs 判成绝对路径,透传只会得到一条看不出根因的平台错误。注:框架语音副本(voice.SaveAudio)与文档同目录、同内容寻址、同一张 documents 表,不存在「服务端相对路径 -> 本地绝对路径」的公开映射,所以拒绝该形态是正确裁决而非偷懒。

重构:outboundSegments 从 message.go 迁到 outbound_segments.go(message.go 489 行,再加代码会破 500 行上限),并收掉原实现里不可达的内层分支(len(segs)==0 时 Content 必为 "",补 text 段那条永远走不到)—— 行为不变。

分层:生产代码不 import internal/runtime/voice(渠道插件反向依赖运行时存储层会把分层倒过来),serverMediaPathPrefix 重复一份字面量,由测试交叉断言钉住同值。

测试:7 个用例(形状/顺序/透传/拒绝/零变化/前缀同值)。零变化用硬编码期望值而非快照文件。变异实测:删 MediaVoice 分支 => 5 个 voice 用例红;base64 编码换成空切片 => record file 断言红;删前缀判定 => server-relative 用例因文案不含 "server-relative" 红。均已恢复。

⚠️ 未在本机验证(无 Napcat 环境):WAV/MP3 直发是否被 Napcat 接受、silk 转码是否成功 —— 本片只钉拼装形状,需真机验证。

验收:go build ./... + go vet ./... + go test ./... 全绿(worktree wt-59)。
2026-10-27 20:44:18 +08:00
17 changed files with 477 additions and 1474 deletions

No files matched your search

+21 -9
View File
@@ -610,9 +610,9 @@
{ "name": "web_search", "status": "ok", "durationMs": 1200,
"argsSummary": "{\"query\":\"明天天气\"}", "resultSummary": "晴,18~26℃" }
],
"voice": { // v0.67 登记,v0.79 已实现(切片 L45);非语音消息键缺席;🎤 判据 = 本键存在
"source": "asr", // "asr"(服务端识别,含云端)| "platform"(渠道平台自带转写)
"status": "transcribed", // "transcribed" | "empty" | "failed"(闭集三态)
"voice": { // v0.67 登记,v0.79 已实现(切片 L45);v0.98 起也承载她的出站语音;非语音消息键缺席;🎤 判据 = 本键存在 && status != "failed"(v0.98)
"source": "asr", // "asr"(服务端识别,含云端)| "platform"(渠道平台自带转写)| "tts"(她的出站合成语音,v0.98)
"status": "transcribed", // "transcribed" | "empty" | "failed"(闭集三态;failed 时 fallbackReason 可选在场,v0.98)
"name": "a1b2c3….ogg", // 音频副本存储名;无副本 ⇒ 键省略
"mime": "audio/ogg",
"size": 12345,
@@ -649,9 +649,9 @@
**`voice` 语义(v0.67 登记;✅ **v0.79 已实现**,切片 L45 —— 覆盖率见本节末)**:
- 形状:`{source,status,name?,mime?,size?,durationMs?,url?}`,`omitempty`(**非语音消息键缺席**,与引入语音前逐字相同)。
- **🎤 判据 = `voice` 键存在**(这条消息来自一条语音消息)。客户端**绝不**从 `content` 正文里认媒体类型:QQ 语音正文里那个 `[语音]` 占位标记自 v0.67 起**不再下发**(见 §6 的 v0.67 行为变更),正文里也可能出现用户自己打出的 `[语音]` 字样。**只认 `voice` 键**。
- `source` ∈ `"asr"`(服务端语音识别所得,含云端)/ `"platform"`(渠道平台自带转写,微信 iLink 的 `voice_item.text`)。
- 形状:`{source,status,name?,mime?,size?,durationMs?,url?,fallbackReason?}`,`omitempty`(**非语音消息键缺席**,与引入语音前逐字相同;`fallbackReason` 仅 `status=="failed"` 时在场,v0.98)。
- **🎤 判据 = `voice` 键存在 && `status != "failed"`(v0.98 起)**(这条消息是一条可播放的语音)。🔴 **v0.98 变更**:出站降级行(她请求语音但 TTS 超时/失败转文字)与入站转写失败行都是 `status:"failed"` 且**不可播**——按旧判据(仅键存在)会被误渲染成语音气泡;`status=="failed"` 的行按**文字消息 + 降级小标**显示(`fallbackReason` 如 `"tts_timeout"` 可做特殊提示)。客户端**绝不**从 `content` 正文里认媒体类型:QQ 语音正文里那个 `[语音]` 占位标记自 v0.67 起**不再下发**(见 §6 的 v0.67 行为变更),正文里也可能出现用户自己打出的 `[语音]` 字样。
- `source` ∈ `"asr"`(服务端语音识别所得,含云端)/ `"platform"`(渠道平台自带转写,微信 iLink 的 `voice_item.text`)/ **`"tts"`(v0.98:她的出站合成语音——她调了模型侧 `speak` 工具且合成成功;播放与入站语音走同一 `GET /api/v1/media/{name}`)**。
- `status` ∈ `"transcribed"`(正文即转写文本)/ `"empty"`(转写成功但没识别出文字)/ `"failed"`(没拿到转写文本 —— 正文是那行中文诚实提示)。**客户端按 `status` 分支,绝不按正文文案分支**(§0.3 的同一纪律)。设计还**刻意不下发** `failed` 的机器可读细分(未配置 / 鉴权 / 欠费 / 限流 / 超时 / 格式不支持 / 无字节 ⇒ 分类只进服务端日志,正文给的是人读的原因方向)。
- `name` / `mime` / `size` / `url` **四者同生共死**:有音频副本时全在,**无副本时全缺席**(**不会**出现 `url` 指着一个不存在的 `name` 这种形状)。
- `durationMs` **只有渠道如实给出时才有**;读不出来就**省略键,绝不估算**(当前未确证各渠道是否都给得出,见 design/24 §10.12-8)。
@@ -1583,7 +1583,19 @@ curl -s -X POST -H "X-API-Key: $KEY" \
> 只保留最近若干版(**v0.60 起**);更早的全部历史见归档 `Docs/archive/api-client.v0.65.md` §5(v0.28 → v0.59)。
> 每版一句话 + 三档标注。**三档定义见 [§0.4](#04-三档标注惯例)。**
### v0.97(当前契约版本)
### v0.98(当前契约版本)
**「她的出站语音」版(M57 / M58 / M59;一处判据变更如实标注,其余纯增量)**
- **模型侧新工具 `speak`**(不是客户端端点,客户端零新调用):她自主决定某条回复用**语音**发。参数 `{text, emotion?}`;执行 = 按字数算超时预算(`clamp(3000 + 25×rune, 3000, 20000)` ms,`utf8.RuneCountInString` 一字一算)→ emotionTTS(发音人按生效人格,v0.96 链不动)→ 成功落音频副本(内容寻址 0600);**超时/失败返回结构化错误**(`tts_timeout` / `tts_failed` / `tts_no_audio` / `tts_store_failed` / `text_too_long` / `invalid_args`,均带预算与实际耗时)——她能在后续输出里读到成败并自然改用文字。
- 🔴 **speak 豁免能力收缩**(quiet/awake 等状态也广告)但**不豁免安全门**:群聊/陌生人在工具策略面按「未知工具」deny(fail-closed,design/11 §3)、owner 恒放行;「群聊是否放行 speak」留待后续裁决。
- ⚠️ `OutboundVoice.DurationMs` 恒 `0`(本版不解析音频时长);客户端**不得**以 `durationMs==0` 判定失败。
- **出站 `voice` 落列与降级标记(M58)**:她请求语音且合成成功 ⇒ assistant 消息的 `voice` 列写 `{source:"tts", status:"transcribed", name, mime, size, durationMs}`(**与入站 DTO 形状一致**,`source` 新值 `"tts"` = 她说的合成语音;`url` 由网关派生,规则同入站);**她请求了但降级**(超时/失败)⇒ 写 `{source:"tts", status:"failed", fallbackReason}`(如 `"tts_timeout"`)——**`name`/`mime`/`size`/`url` 四键整体缺席**,正文照发文字。降级事实**随消息落库**(不只实时帧)——历史回看可见。
- 🔴 **判据变更(唯一 breaking,如实标注)**:🎤 判据从「`voice` 键存在」改为 **「`voice` 键存在 && `status != "failed"` = 可播语音气泡;`status == "failed"` = 文字消息 + 降级小标**(`fallbackReason` 供特殊显示,如「语音超时,已转文字」)。**入站同样适用**:入站转写失败的行本就是 `status:"failed"` 且不可播——判据统一后语义更干净。旧判据客户端会把降级行误渲染成语音气泡。
- **渠道侧(M59)**:QQ OneBot v11 以 `record` 段发**真语音条**(`base64://` 内联首选,Napcat 自行转 silk;真机 silk 效果**待验证**);微信 iLink 无语音发送能力 ⇒ 该渠道降级纯文本(但全端语音副本仍可见——IM 文本与 Web/安卓语音气泡并存)。**出站 Media 由语音事实单独构造**,绝不透传入站 Media(2026-10-04 事故纪律)。
- **全部读面自动生效**:`source` 闭集在 `voice` 包单点(`asr`/`platform`/`tts`);REST 读面 / 实时帧 / 握手回放 / `message_edited` 经同一投影点 `attachVoiceTo`,首帧即带(M44 顺序纪律沿用)。
### v0.97
**「语音帧补全 + 上游错误出口收敛」版(M44 / M47 / M49 / M50;以纯增量为主,一处行为变更如实标注)**
@@ -2017,7 +2029,7 @@ curl -s -X POST -H "X-API-Key: $KEY" \
- **纯增量**:
① 新端点 `GET /api/v1/media/{name}`(用户侧,**owner/admin 必须 Bearer**):`200` 字节流 / `206 Partial Content` / `304 Not Modified`;`Content-Type: audio/*`;`Content-Disposition: inline; filename="<name>"`;`nosniff`;`Accept-Ranges: bytes`;`Cache-Control: private, max-age=86400, immutable`;支持 `Range` 与 `HEAD`;`404 media_not_found`;`401`/`403` 由中间件给出。⚠️ **`416` 是标准库直写的非 JSON 响应**(见 §0.3、§4.1、§4.5)。
② 消息 DTO 增**可选** `voice` 对象(`omitempty`;**非语音消息键缺席**):`{source,status,name?,mime?,size?,durationMs?,url?}`;`source` ∈ `asr`/`platform`,`status` ∈ `transcribed`/`empty`/`failed`,`name`/`mime`/`size`/`url` **四者同生共死**(有副本时才在);`url` 是服务端相对路径 `/api/v1/media/<name>`。**🎤 判据 = `voice` 键存在**。⚠️ **v0.79 校正(实测覆盖)**:真正会带 `voice` 的读取面**只有** `GET /sessions/{id}/messages`、`GET /admin/channels[/{platform}]/messages`、`PATCH /messages/{id}` 响应与 `message_edited`;**`message_persisted` / `channel_persisted` 与握手 `history` 回放不带**(✅ **已由 v0.97(切片 M44)补齐**,见 §3.2、§3.4.4、§4.4 覆盖率)。
② 消息 DTO 增**可选** `voice` 对象(`omitempty`;**非语音消息键缺席**):`{source,status,name?,mime?,size?,durationMs?,url?}`;`source` ∈ `asr`/`platform`(🔴 **v0.98 增 `tts`** = 她的出站合成语音,含 `fallbackReason` 降级标记与判据变更,见 §4.4 / §6 v0.98),`status` ∈ `transcribed`/`empty`/`failed`,`name`/`mime`/`size`/`url` **四者同生共死**(有副本时才在;**降级行四键整体缺席**);`url` 是服务端相对路径 `/api/v1/media/<name>`。**🎤 判据 = `voice` 键存在 && `status != "failed"`(v0.98 起)**。⚠️ **v0.79 校正(实测覆盖)**:真正会带 `voice` 的读取面**只有** `GET /sessions/{id}/messages`、`GET /admin/channels[/{platform}]/messages`、`PATCH /messages/{id}` 响应与 `message_edited`;**`message_persisted` / `channel_persisted` 与握手 `history` 回放不带**(✅ **已由 v0.97(切片 M44)补齐**,见 §3.2、§3.4.4、§4.4 覆盖率)。
③ 新错误码 **`media_not_found`(404)** → 加进 §4.1 的 404 行(与 `document_not_found` 并列;**刻意不复用**它,否则客户端分不清缺的是文档还是语音)。
④ 新错误形态 **`416`**(**非信封**,标准库直写)→ §0.3 / §4.1 / §4.5。
⑤ 新 admin 端点 **`GET /api/v1/admin/channels/events`**(非消息事件列表,倒序;`?platform=&tier=&category=&channelId=&since=&until=&limit=&offset=`;响应 `{data:{events:[…],total}}`;`since`/`until` 按 UTC ISO-8601 定宽字符串**字典序**比较、**跨度上限 90 天**;不含正文、不含媒体 URL、不含任何凭据 / `flag`)—— §2.4(a)。**已落地**(`owner|admin` 档;元素键名 camelCase,逐字见 §2.4(a);未给窗口 ⇒ 不注入窗口)。
@@ -2243,4 +2255,4 @@ curl -s -X POST -H "X-API-Key: $KEY" \
---
*本文件由「以代码为真源」维护(当前 `v0.97`;`v0.96` ~ `v0.68` 见本文件 §6,`v0.67` 及更早见本文件 §6 与 `Docs/archive/`)。范围:`v0.97`(三面 —— M44 `voice` 进 WS 实时帧/握手回放 / M47 emotionTTS 自启(默认关)/ M49+M50+M48+M52 上游错误出口「构造即安全」)同步了 `internal/services/gateway/**`(`broadcast.go` / `history_voice.go` / `ws_history.go` 及 `broadcast_voice_test.go`)、`internal/services/channelbridge/**`(`persist.go` / `inbound_voice_copy.go` / `coalesce_batch.go` 及 `inbound_voice_gateway_e2e_test.go`)、`cmd/cyrene/**`(`voice_tts_launch.go` 及测试 / `main.go`)、`internal/config/runtimeconfig/voice_tts_launch.go`、`internal/runtime/voice/ttslaunch/**`、`internal/runtime/trace/**`(`upstream.go` 等)与本文件(§3.2 / §4.4 / §4.5 / §6 / §7.2 / §7.4);`v0.96` ~ `v0.82` 各版的文件级范围见各自 §6 条目;更早版本(`v0.81` ~ `v0.68`)的文件级范围按原记录保留如下:`v0.81` 同步了 backthink/flowgraph/gateway 的自排轮可见面;`v0.80` 同步了 flowgraph timeline、websearch 热重载、wire_channels 的 VoiceTranscoder 注入;`v0.79` 同步了 plugin actions、memory review、history_voice、media、CODE-TC-2;`v0.78` 同步了迁移 0020/0023、voice 包、channelbridge inbound_voice、panels、iLink 三态;`v0.77` 同步了 trace graph 分桶、loop/flow、memory tidy、admin_flow;`v0.76` 的未读面同步了 broadcast/session_read;`v0.75` ~ `v0.71` 同步了 memory tidy、proactive、flowgraph、loop 各面(详见归档)。*
*本文件由「以代码为真源」维护(当前 `v0.98`;`v0.97` ~ `v0.68` 见本文件 §6,`v0.67` 及更早见本文件 §6 与 `Docs/archive/`)。范围:`v0.98`(她的出站语音 —— M57 `speak` 工具 + 字数预算 / M58 出站 voice 落列 + 降级标记 + 判据变更 / M59 QQ record 段)同步了 `internal/runtime/loop/**`(`speak_tool.go` / `voice.go` / `request.go` / `tools*.go` 及测试)、`internal/runtime/voice/voice.go`(`SourceTTS` / `Ref.FallbackReason`)、`internal/services/channelbridge/**`(`outbound_voice.go` 及 e2e / `bubbles.go` / `inbound*.go` / `persist*.go` / `proactive*.go`)、`internal/services/gateway/history_voice.go`、`plugins/channels/qq_obv11/outbound_segments.go` 及测试与本文件(§4.4 / §6 / 旧横幅加注);`v0.97` ~ `v0.82` 各版的文件级范围见各自 §6 条目。*
-14
View File
@@ -85,11 +85,6 @@ type Result struct {
// 用户消息的 document 列或随响应回传,使客户端能看到「这份附件读到了 /
// 读不到」—— 没有它,抽取失败对客户端就是不可见的。
Documents []history.DocumentRef
// Voice 是本回合的**出站语音结论**:nil 表示本回合没有请求语音(纯文本);
// Name 非空表示合成成功且音频副本已落盘;Requested 为真但 Name 为空、
// FallbackReason 非空表示她请求了语音但降级为文本(如 tts_timeout)。
// 字段语义详见 OutboundVoice。
Voice *OutboundVoice
// docSeen 是 Documents 的去重索引(存储名 → 下标),作用域限于本次
// Result,绝不跨 Run 共享。
docSeen map[string]int
@@ -143,12 +138,6 @@ type Loop struct {
// 逐字节一致。它由装配期一次性挂上,之后只读;每会话的状态在 SkillFace 内部
// 按会话分桶并自带锁,因此循环的并发 Run 契约不变。
skillFace *SkillFace
// voiceSynth / voiceStore 是 speak 的注入依赖(M57)。两者都非 nil 时
// 循环向模型广告 speak 并自行承载它的执行(见 voice_deps.go);
// speakBudget 是超时预算的替换公式,nil 用内置公式。
voiceSynth VoiceSynth
voiceStore VoiceStore
speakBudget func(int) int
log *slog.Logger
}
@@ -222,9 +211,6 @@ func New(opts Options) *Loop {
imageResolver: opts.Vision,
documentResolver: opts.DocumentResolver,
documentInjectChars: docInjectChars,
voiceSynth: opts.VoiceSynth,
voiceStore: opts.VoiceStore,
speakBudget: opts.SpeakBudget,
log: sourcedLog(opts.Log),
}
}
-9
View File
@@ -214,15 +214,6 @@ type Options struct {
// 或重排模型所见的消息。它是循环中与
// OpenClaw 的 transformContext 等价之物(design §1.3)。为 nil = 不做投影。
TransformContext func(ctx context.Context, messages []model.Message, round int) []model.Message
// VoiceSynth 与 VoiceStore 一起接线时(**两个都非 nil**),循环向模型
// 广告 speak 工具(M57):她可以自己决定这一条回复用语音还是文本。
// 任一为 nil = 不广告、speak 不可调用,模型面与接线前逐字节一致。
VoiceSynth VoiceSynth
VoiceStore VoiceStore
// SpeakBudget 可替换 speak 的超时预算公式(文本 rune 数 → 毫秒)。
// 为 nil 或返回值非正时回落内置公式 speakBudgetMs。它是给测试与
// 部署调优用的注入点,不是能力开关(开关是上面两个依赖)。
SpeakBudget func(runes int) int
// Log 是基础 logger。为 nil 时替换为标记来源
// "runtime/loop" 的丢弃 logger。
Log *slog.Logger
+2 -2
View File
@@ -87,7 +87,7 @@ func (l *Loop) toolSchemas(ctx context.Context, messages []model.Message) []mode
}
schemas := d.ToolSchemas()
if l.capacity == nil || !l.capacity.Enabled() {
return l.withSpeakSpec(l.narrowToolSchemas(ctx, l.restrictSchemas(ctx, d, schemas)))
return l.narrowToolSchemas(ctx, l.restrictSchemas(ctx, d, schemas))
}
names := make([]string, 0, len(schemas))
@@ -124,7 +124,7 @@ func (l *Loop) toolSchemas(ctx context.Context, messages []model.Message) []mode
out = append(out, s)
}
}
return l.withSpeakSpec(l.narrowToolSchemas(ctx, l.restrictSchemas(ctx, d, out)))
return l.narrowToolSchemas(ctx, l.restrictSchemas(ctx, d, out))
}
// capacityTexts 为 capacity 判定器提取近期对话文本:
-1
View File
@@ -187,7 +187,6 @@ func (l *Loop) driveTurn(ctx context.Context, sessionID string, msgs []model.Mes
}
msgs = batch.messages
res.ToolCallsExecuted += batch.executed
applyBatchVoice(res, &batch)
l.saveCheckpoint(ctx, sessionID, step, msgs, agent.StopReasonToolCalls)
// 关键工具循环安全网(或终止钩子)不等真正的最终答案
-336
View File
@@ -1,336 +0,0 @@
package loop
// 本文件实现模型侧的 speak 工具(M57)。主人 2026-10-27 的口径是「他可以自己
// 选择某次消息是拿文字发 还是去请求 TTS 接口发音频」——**决策是她的**,因此这里
// 只提供一个可调用的工具,绝不替她决定什么时候用语音(那会变成幕后置规则)。
// 调了 speak = 这条想用语音;不调 = 发文本。
//
// 三条执行口径:
// - 超时预算按**文本 rune 数**推导(speakBudgetMs):短句拿最低预算,长文拿
// 上限,中间线性 —— 越长合成越慢,但绝不无限等;
// - 成功 = 音频副本**已落盘**(经 VoiceStore 接缝,接线方走 documents.SaveAudio
// 的内容寻址 0600 口径)+ 结构化结果(name/mime/size/durationMs/budgetMs/costMs);
// - 超时/失败 = 结构化错误(code + 预算 + 实际耗时),**回合绝不因此失败**,
// 她能在后续输出里看到「这次语音没成」,客户端也能据此特殊显示。
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"unicode/utf8"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
voicetts "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/tts"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
const (
// speakToolName 是模型侧工具名。它同时是 Result.Voice 回填时的匹配键。
speakToolName = "speak"
// speakBudgetBaseMs 是任何一次合成的最低预算:即使一个字的请求也要给
// 上游留出建连 + 首包的时间,低于它的预算只会把"网络慢"误判成"内容长"。
speakBudgetBaseMs = 3000
// speakBudgetPerRuneMs 是每个 rune 追加的预算(毫秒)。25ms/rune 约等于
// 每秒 40 个字符的合成速度,比实测慢一截,留出余量。
speakBudgetPerRuneMs = 25
// speakBudgetMaxMs 是预算上限:再长的文本也等这么久,超了就退回文本,
// 绝不让她的一条消息把整个回合挂住。
speakBudgetMaxMs = 20000
// speakMaxRunes 是文本长度硬上限。按上面的公式,约 680 rune 就顶到预算
// 上限,再长的文本必然超时 —— 早拒绝能省一次上游调用与一次落盘。
speakMaxRunes = 1200
// speakAudioOrigName 是落盘时给 documents.SaveAudio 的原始名,扩展名
// 由上游实际格式拼接(白名单见 voiceref.MediaExtAllowed)。
speakAudioOrigName = "speak."
// 降级/失败原因码(Result.Voice.FallbackReason 与错误正文共用同一套取值,
// 客户端据此决定怎么显示「这条本来想用语音」)。
speakReasonTimeout = "tts_timeout"
speakReasonFailed = "tts_failed"
speakReasonNoAudio = "tts_no_audio"
speakReasonStoreFailed = "tts_store_failed"
speakReasonTooLong = "text_too_long"
speakReasonInvalidArgs = "invalid_args"
)
// speakTool 实现 sdk.Tool。依赖全部由循环注入(不自己 new),因此叶子测试
// 可以塞桩:合成桩决定成功/超时,落盘桩决定存储名,预算桩决定超时长短。
type speakTool struct {
synth VoiceSynth
store VoiceStore
// budget 把 rune 数映射为预算(毫秒);nil 用内置公式。
budget func(runes int) int
// persona 解析本回合的生效人格 id,供 TTS 的发音人解析链使用
// (发音人绑人格,链本身不动)。
persona func(context.Context) string
log *slog.Logger
}
// 编译期断言:speak 就是一个普通的模型侧工具,接线方式与其他工具一致。
var _ sdk.Tool = speakTool{}
// Name 返回工具标识。
func (speakTool) Name() string { return speakToolName }
// Description 说明**何时**用语音。它是能力描述,不是身份/权限判定 ——
// 谁能用语音仍由渠道层 actor 元数据 + 工具可达性标记决定。
func (speakTool) Description() string {
return "用语音(合成音频)把这段话念出来,让这一条回复变成声音。" +
"适合用语音:轻声细语、撒娇、唱歌、长一点的心里话或独白、需要语气温度的表达。" +
"适合用文本:代码、列表、链接、报错原文,以及需要被复制或搜索的内容。" +
"语音只改变这一条回复的呈现方式,不改变你的能力或身份。" +
"合成失败或超时会自动退回文本,调用一次就够,不要为同一句话反复重试。"
}
// Parameters 返回工具输入的 JSON Schema。
func (speakTool) Parameters() (map[string]any, error) {
return map[string]any{
"type": "object",
"properties": map[string]any{
"text": map[string]any{
"type": "string",
"description": "要念出来的话。写成可直接朗读的口语,不要带 Markdown 标记、代码块或链接。",
},
"emotion": map[string]any{
"type": "string",
"description": "可选语气微调(如「温柔」「轻声」「欢快」)。默认语气由你的人格决定," +
"这个字段只做额外微调,不会换掉发音人。",
},
},
"required": []string{"text"},
}, nil
}
// speakArgs 是解析后的调用参数。runes 只算一次,预算与日志共用。
type speakArgs struct {
text string
emotion string
runes int
}
// Execute 合成一段语音并落盘副本。成功返回结构化结果;失败返回 *speakError,
// 它同时是模型能读懂的错误正文与 Result.Voice 的降级依据。
func (t speakTool) Execute(ctx context.Context, args any) (any, error) {
in, err := parseSpeakArgs(args)
if err != nil {
return nil, err
}
if t.synth == nil || t.store == nil {
return nil, &speakError{Code: speakReasonFailed,
Err: errors.New("speak: voice synthesis is not configured")}
}
budget := t.budgetMs(in.runes)
synthCtx, cancel := context.WithTimeout(ctx, time.Duration(budget)*time.Millisecond)
defer cancel()
synthCtx = t.stampVoiceContext(synthCtx, ctx, in.emotion)
started := time.Now()
audio, provider, err := t.synth.TTSWithProvider(synthCtx, in.text)
cost := int(time.Since(started) / time.Millisecond)
if err != nil {
return nil, t.synthesisFailed(synthCtx, err, budget, cost)
}
ext, extOK := speakExt(audio.Format)
if len(audio.Data) == 0 || !extOK {
return nil, &speakError{Code: speakReasonNoAudio, BudgetMs: budget, CostMs: cost,
Err: fmt.Errorf("speak: unusable audio (format %q, %d bytes)", audio.Format, len(audio.Data))}
}
// 落盘用**父 ctx**:预算只约束上游合成,不该让已经合成好的音频因为预算
// 到点而丢掉(写盘是本地动作,毫秒级,且副本本身就是交付物)。
ref, err := t.store.SaveAudio(ctx, speakAudioOrigName+ext, audio.Data)
if err != nil {
return nil, &speakError{Code: speakReasonStoreFailed, BudgetMs: budget, CostMs: cost, Err: err}
}
t.logSynthesized(provider, in.runes, budget, cost, len(audio.Data))
// durationMs 恒为 0:本项目**绝不估算时长**(与 voice.Copy.DurationMs 同一
// 口径),而解析 WAV 头不在本切片范围内。0 表示「未解析」,不是零长度。
return map[string]any{
"name": ref.Name,
"mime": ref.MIME,
"size": ref.Size,
"durationMs": int64(0),
"budgetMs": budget,
"costMs": cost,
}, nil
}
// stampVoiceContext 把本回合的生效人格与可选语气挂到 ctx 上:TTS 侧按人格
// 解析发音人(链不动),emotion 只作为逐请求的语气指令透传到底层链。
func (t speakTool) stampVoiceContext(synthCtx, turnCtx context.Context, emotion string) context.Context {
if t.persona != nil {
if pid := t.persona(turnCtx); pid != "" {
synthCtx = voicetts.WithPersonaID(synthCtx, pid)
}
}
if emotion != "" {
synthCtx = mvoice.WithTTSInstruction(synthCtx, emotion)
}
return synthCtx
}
// budgetMs 返回本次合成的超时预算;注入值非正时回落内置公式。
func (t speakTool) budgetMs(runes int) int {
if t.budget != nil {
if ms := t.budget(runes); ms > 0 {
return ms
}
}
return speakBudgetMs(runes)
}
// synthesisFailed 把上游错误归一化成带原因码的结构化失败。超时判定看三处:
// 上游自己报的 deadline、我们给的预算 ctx 已过期、或实际耗时已经吃满预算
// (上游把超时包成自己的错误类型时只有最后一处能认出来)。
func (t speakTool) synthesisFailed(synthCtx context.Context, err error, budget, cost int) error {
code := speakReasonFailed
if errors.Is(err, context.DeadlineExceeded) ||
errors.Is(synthCtx.Err(), context.DeadlineExceeded) || cost >= budget {
code = speakReasonTimeout
}
if t.log != nil {
// 只记长度与耗时,**绝不记正文**(与网关出站 TTS 同一口径)。
t.log.Warn("speak degraded to text",
"reason", code, "budget_ms", budget, "cost_ms", cost, "err", err)
}
return &speakError{Code: code, BudgetMs: budget, CostMs: cost, Err: err}
}
// logSynthesized 记一条成功合成。只带来源、级别名、长度与耗时,绝不带正文。
func (t speakTool) logSynthesized(provider string, runes, budget, cost, size int) {
if t.log == nil {
return
}
t.log.Info("speak synthesized",
"provider", provider, "runes", runes, "budget_ms", budget, "cost_ms", cost, "bytes", size)
}
// speakError 是 speak 的结构化失败。Code 进 Result.Voice.FallbackReason,让
// 「想用语音但没成」与「本来就用文本」在调用方与客户端都能区分开;预算与实际
// 耗时让模型下一轮能判断是"该说短点"还是"上游坏了"。
type speakError struct {
Code string
BudgetMs int
CostMs int
Err error
}
// Error 渲染成一句可读正文,随 RoleTool 消息回灌给模型。
func (e *speakError) Error() string {
if e.Err == nil {
return fmt.Sprintf("speak failed: %s (budget_ms=%d cost_ms=%d)", e.Code, e.BudgetMs, e.CostMs)
}
return fmt.Sprintf("speak failed: %s (budget_ms=%d cost_ms=%d): %v", e.Code, e.BudgetMs, e.CostMs, e.Err)
}
// Unwrap 让 errors.Is 能穿透到原因(含 tool.ErrInvalidArgs 的参数校验分类)。
func (e *speakError) Unwrap() error { return e.Err }
// parseSpeakArgs 解析并校验参数。参数错误包 tool.ErrInvalidArgs,使循环把它
// 归到 argument-validation 而不是执行失败。
func parseSpeakArgs(args any) (speakArgs, error) {
m, err := speakArgMap(args)
if err != nil {
return speakArgs{}, err
}
text, _ := m["text"].(string)
text = strings.TrimSpace(text)
if text == "" {
return speakArgs{}, &speakError{Code: speakReasonInvalidArgs,
Err: fmt.Errorf("speak: text is required: %w", tool.ErrInvalidArgs)}
}
runes := utf8.RuneCountInString(text)
if runes > speakMaxRunes {
return speakArgs{}, &speakError{Code: speakReasonTooLong,
Err: fmt.Errorf("speak: text has %d runes, limit is %d: %w", runes, speakMaxRunes, tool.ErrInvalidArgs)}
}
emotion, _ := m["emotion"].(string)
return speakArgs{text: text, emotion: strings.TrimSpace(emotion), runes: runes}, nil
}
// speakArgMap 把调用参数归一化成 map;nil 参数视为空对象(缺 text 由调用方报)。
func speakArgMap(args any) (map[string]any, error) {
if args == nil {
return map[string]any{}, nil
}
m, ok := args.(map[string]any)
if !ok {
return nil, &speakError{Code: speakReasonInvalidArgs,
Err: fmt.Errorf("speak: arguments must be an object: %w", tool.ErrInvalidArgs)}
}
return m, nil
}
// speakBudgetMs 把文本长度(rune 数)映射为合成超时预算:
// clamp(speakBudgetBaseMs + speakBudgetPerRuneMs×runes, base, max)。
func speakBudgetMs(runes int) int {
if runes < 0 {
runes = 0
}
ms := speakBudgetBaseMs + speakBudgetPerRuneMs*runes
if ms > speakBudgetMaxMs {
return speakBudgetMaxMs
}
if ms < speakBudgetBaseMs {
return speakBudgetBaseMs
}
return ms
}
// speakExt 把上游实际格式映射成落盘扩展名。空格式按 wav 命名 —— TTS 上游
// 产物恒为 WAV(见 voice/tts/audio.go 的口径);白名单外的格式一律判不可用,
// 绝不拿一个读不出 Content-Type 的名字落盘。
func speakExt(format string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(format)) {
case "":
return "wav", true
case "wav", "mp3", "ogg", "flac", "m4a", "webm", "amr":
return strings.ToLower(strings.TrimSpace(format)), true
default:
return "", false
}
}
// speakSpec 返回 speak 的模型面声明(工具未接线时为 nil)。
func (l *Loop) speakSpec() *model.ToolSpec {
if !l.voiceEnabled() {
return nil
}
t := l.speakTool()
params, err := t.Parameters()
if err != nil {
return nil
}
return &model.ToolSpec{Name: t.Name(), Description: t.Description(), Parameters: params}
}
// speakTool 用循环注入的依赖装配一个 speak 工具值(每次装配都是廉价的)。
func (l *Loop) speakTool() speakTool {
return speakTool{
synth: l.voiceSynth,
store: l.voiceStore,
budget: l.speakBudget,
persona: l.personaFor,
log: l.log,
}
}
// invokeTool 执行单个工具调用。speak 由循环自己承载(它需要本回合的人格与
// 预算口径,且未注册进能力登记表),其余一律交给 dispatcher —— 它的超时、
// panic 兜底与允许集软判对普通工具仍然生效。
func (l *Loop) invokeTool(ctx context.Context, d *tool.Dispatcher, tc agent.ToolCall) (any, error) {
if tc.Name == speakToolName && l.voiceEnabled() {
return l.speakTool().Execute(ctx, tc.Args)
}
if d == nil {
return nil, fmt.Errorf("no tool dispatcher configured for %q", tc.Name)
}
return d.Invoke(tool.WithLegID(ctx, ToolLegIDFrom(ctx)), tc.Name, tc.Args)
}
-334
View File
@@ -1,334 +0,0 @@
package loop
import (
"context"
"errors"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
)
// 本文件是 speak 工具的叶子测试(M57):预算公式、扩展名白名单、
// 参数校验、成功/超时/无音频/落盘失败四条执行路径,以及模型面的
// 广告与追加。依赖全部用桩,绝不真连 TTS、绝不真写盘。
// stubSynth 是可配置的合成桩:delay 制造超时,err 制造失败,
// audio 决定成功时的产物,provider 供日志断言。
type stubSynth struct {
audio mvoice.Audio
provider string
err error
delay time.Duration
gotText string
}
func (s *stubSynth) TTSWithProvider(ctx context.Context, text string) (mvoice.Audio, string, error) {
s.gotText = text
if s.delay > 0 {
select {
case <-time.After(s.delay):
case <-ctx.Done():
return mvoice.Audio{}, "", ctx.Err()
}
}
if s.err != nil {
return mvoice.Audio{}, "", s.err
}
return s.audio, s.provider, nil
}
// stubStore 是可配置的落盘桩,同时记录收到的原始名与字节。
type stubStore struct {
ref VoiceRef
err error
gotName string
gotData []byte
}
func (s *stubStore) SaveAudio(_ context.Context, origName string, data []byte) (VoiceRef, error) {
s.gotName = origName
s.gotData = data
if s.err != nil {
return VoiceRef{}, s.err
}
return s.ref, nil
}
// newSpeakTool 装配一个注入了桩的 speak 工具。
func newSpeakTool(synth VoiceSynth, store VoiceStore) speakTool {
return speakTool{synth: synth, store: store}
}
// speakCodeOf 取出 speak 失败的结构化原因码。
func speakCodeOf(t *testing.T, err error) *speakError {
t.Helper()
var se *speakError
if !errors.As(err, &se) {
t.Fatalf("error %v is not *speakError", err)
}
return se
}
// TestSpeakBudgetMs 锁住预算公式的边界:短句拿最低预算、线性增长、
// 长文封顶,负数按 0 处理(绝不返回负预算)。
func TestSpeakBudgetMs(t *testing.T) {
t.Parallel()
cases := []struct {
runes int
want int
}{
{-5, speakBudgetBaseMs},
{0, speakBudgetBaseMs},
{1, speakBudgetBaseMs + speakBudgetPerRuneMs},
{100, speakBudgetBaseMs + 100*speakBudgetPerRuneMs},
{680, speakBudgetMaxMs},
{speakMaxRunes, speakBudgetMaxMs},
}
for _, c := range cases {
if got := speakBudgetMs(c.runes); got != c.want {
t.Errorf("speakBudgetMs(%d) = %d, want %d", c.runes, got, c.want)
}
}
}
// TestSpeakExt 锁住落盘扩展名:空格式按 wav(TTS 产物恒为 WAV)、
// 白名单内小写化、白名单外判不可用(绝不拿读不出 Content-Type 的
// 名字落盘)。
func TestSpeakExt(t *testing.T) {
t.Parallel()
cases := []struct {
format string
want string
ok bool
}{
{"", "wav", true},
{" ", "wav", true},
{"WAV", "wav", true},
{"mp3", "mp3", true},
{" Opus ", "", false},
{"exe", "", false},
}
for _, c := range cases {
got, ok := speakExt(c.format)
if got != c.want || ok != c.ok {
t.Errorf("speakExt(%q) = (%q,%v), want (%q,%v)", c.format, got, ok, c.want, c.ok)
}
}
}
// TestParseSpeakArgs_Validation 锁住参数校验:缺 text、非对象、超长
// 都归 argument-validation(errors.Is(tool.ErrInvalidArgs)),并且
// 长度按 **rune** 算而不是字节(多字节文本不会因字节数被误判超长)。
func TestParseSpeakArgs_Validation(t *testing.T) {
t.Parallel()
if _, err := parseSpeakArgs(nil); !errors.Is(err, tool.ErrInvalidArgs) {
t.Fatalf("nil args err = %v, want ErrInvalidArgs", err)
}
if _, err := parseSpeakArgs(map[string]any{"text": " "}); !errors.Is(err, tool.ErrInvalidArgs) {
t.Fatalf("blank text err = %v, want ErrInvalidArgs", err)
}
if _, err := parseSpeakArgs([]string{"a"}); !errors.Is(err, tool.ErrInvalidArgs) {
t.Fatalf("non-object args err = %v, want ErrInvalidArgs", err)
}
if _, err := parseSpeakArgs(map[string]any{"text": strings.Repeat("あ", speakMaxRunes+1)}); !errors.Is(err, tool.ErrInvalidArgs) {
t.Fatalf("too-long text err = %v, want ErrInvalidArgs", err)
} else if code := speakCodeOf(t, err).Code; code != speakReasonTooLong {
t.Fatalf("too-long code = %q, want %q", code, speakReasonTooLong)
}
// 恰好上限、且是多字节:按 rune 计数,应当放行。
in, err := parseSpeakArgs(map[string]any{
"text": strings.Repeat("あ", speakMaxRunes),
"emotion": " 温柔 ",
})
if err != nil {
t.Fatalf("at-limit runes rejected: %v", err)
}
if in.runes != speakMaxRunes {
t.Fatalf("runes = %d, want %d(必须按 rune 而非字节计数)", in.runes, speakMaxRunes)
}
if in.emotion != "温柔" {
t.Fatalf("emotion = %q, want trimmed 温柔", in.emotion)
}
}
// TestSpeakTool_ExecuteSuccess 锁住成功路径的交付物:音频落盘用
// 上游实际格式的扩展名,返回值带齐 name/mime/size/budgetMs/costMs,
// 且 durationMs 恒为 0(未解析,不是零长度)。
func TestSpeakTool_ExecuteSuccess(t *testing.T) {
t.Parallel()
synth := &stubSynth{
audio: mvoice.Audio{Format: "wav", Data: []byte{1, 2, 3}},
provider: "emotiontts",
}
store := &stubStore{ref: VoiceRef{Name: "abc123.wav", MIME: "audio/wav", Size: 3}}
tk := newSpeakTool(synth, store)
tk.budget = func(int) int { return 777 }
out, err := tk.Execute(context.Background(), map[string]any{"text": "晚安"})
if err != nil {
t.Fatalf("Execute: %v", err)
}
if synth.gotText != "晚安" {
t.Fatalf("synth text = %q, want 晚安", synth.gotText)
}
if store.gotName != "speak.wav" {
t.Fatalf("origName = %q, want speak.wav", store.gotName)
}
if len(store.gotData) != 3 {
t.Fatalf("stored bytes = %d, want 3", len(store.gotData))
}
m, ok := out.(map[string]any)
if !ok {
t.Fatalf("result type = %T, want map[string]any", out)
}
if m["name"] != "abc123.wav" || m["mime"] != "audio/wav" || m["size"] != int64(3) {
t.Fatalf("result = %v, want the stored ref's name/mime/size", m)
}
if m["durationMs"] != int64(0) {
t.Fatalf("durationMs = %v, want int64(0)(未解析)", m["durationMs"])
}
if m["budgetMs"] != 777 {
t.Fatalf("budgetMs = %v, want the injected 777", m["budgetMs"])
}
if _, ok := m["costMs"].(int); !ok {
t.Fatalf("costMs = %v, want int", m["costMs"])
}
}
// TestSpeakTool_ExecuteTimeout 锁住超时路径:预算到点即放弃合成,
// 原因码是 tts_timeout,且预算/实际耗时都回传给调用方(她下一轮
// 据此判断是「说短点」还是「上游坏了」)。这里用 1ms 预算 +
// 5ms 延迟制造真超时,不靠断言硬编码的时间差。
func TestSpeakTool_ExecuteTimeout(t *testing.T) {
t.Parallel()
tk := newSpeakTool(
&stubSynth{audio: mvoice.Audio{Format: "wav", Data: []byte{1}}, delay: 5 * time.Millisecond},
&stubStore{},
)
tk.budget = func(int) int { return 1 }
_, err := tk.Execute(context.Background(), map[string]any{"text": "晚安"})
se := speakCodeOf(t, err)
if se.Code != speakReasonTimeout {
t.Fatalf("code = %q, want %q", se.Code, speakReasonTimeout)
}
if se.BudgetMs != 1 {
t.Fatalf("BudgetMs = %d, want 1", se.BudgetMs)
}
if se.CostMs < 0 {
t.Fatalf("CostMs = %d, want >= 0", se.CostMs)
}
}
// TestSpeakTool_ExecuteNoAudio 锁住「上游说成功但没给可用音频」:
// 空字节与白名单外格式都归 tts_no_audio,绝不落一个空文件。
func TestSpeakTool_ExecuteNoAudio(t *testing.T) {
t.Parallel()
cases := []struct {
name string
audio mvoice.Audio
}{
{"empty bytes", mvoice.Audio{Format: "wav"}},
{"unknown format", mvoice.Audio{Format: "exe", Data: []byte{1}}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
store := &stubStore{ref: VoiceRef{Name: "x.wav"}}
tk := newSpeakTool(&stubSynth{audio: c.audio, provider: "p"}, store)
_, err := tk.Execute(context.Background(), map[string]any{"text": "晚安"})
if code := speakCodeOf(t, err).Code; code != speakReasonNoAudio {
t.Fatalf("code = %q, want %q", code, speakReasonNoAudio)
}
if store.gotName != "" {
t.Fatalf("store was called with %q, want no write at all", store.gotName)
}
})
}
}
// TestSpeakTool_ExecuteStoreFailed 锁住落盘失败:合成成功但副本存不下
// 时不谎报成功 —— 原因是 tts_store_failed,音频字节不会被当作交付物。
func TestSpeakTool_ExecuteStoreFailed(t *testing.T) {
t.Parallel()
tk := newSpeakTool(
&stubSynth{audio: mvoice.Audio{Format: "wav", Data: []byte{1, 2}}, provider: "p"},
&stubStore{err: errors.New("disk full")},
)
_, err := tk.Execute(context.Background(), map[string]any{"text": "晚安"})
se := speakCodeOf(t, err)
if se.Code != speakReasonStoreFailed {
t.Fatalf("code = %q, want %q", se.Code, speakReasonStoreFailed)
}
if !errors.Is(err, se.Err) {
t.Fatalf("error chain lost the store error: %v", err)
}
}
// TestSpeakTool_NotConfigured 锁住「合成或落盘缺一个」:不 panic,
// 以结构化失败返回,使回合仍能继续。
func TestSpeakTool_NotConfigured(t *testing.T) {
t.Parallel()
for _, tk := range []speakTool{
newSpeakTool(nil, &stubStore{}),
newSpeakTool(&stubSynth{}, nil),
} {
_, err := tk.Execute(context.Background(), map[string]any{"text": "晚安"})
if code := speakCodeOf(t, err).Code; code != speakReasonFailed {
t.Fatalf("code = %q, want %q", code, speakReasonFailed)
}
}
}
// TestSpeakSpec 锁住模型面的接线判定:两个依赖缺任一都不广告
// (模型面与接线前逐字节一致),都在时声明带参数 schema 的 speak。
func TestSpeakSpec(t *testing.T) {
t.Parallel()
if spec := New(Options{}).speakSpec(); spec != nil {
t.Fatalf("unwired speakSpec = %+v, want nil", spec)
}
if spec := New(Options{VoiceSynth: &stubSynth{}}).speakSpec(); spec != nil {
t.Fatalf("half-wired speakSpec = %+v, want nil", spec)
}
spec := New(Options{VoiceSynth: &stubSynth{}, VoiceStore: &stubStore{}}).speakSpec()
if spec == nil {
t.Fatal("wired speakSpec = nil, want a spec")
}
if spec.Name != speakToolName {
t.Fatalf("spec name = %q, want %q", spec.Name, speakToolName)
}
if spec.Parameters == nil {
t.Fatal("spec parameters = nil, want the JSON schema")
}
}
// TestWithSpeakSpec 锁住追加语义:未接线原样返回、接线后追加到末尾
// (因此不受前面任何收缩影响)、已有同名不重复追加。
func TestWithSpeakSpec(t *testing.T) {
t.Parallel()
base := []model.ToolSpec{{Name: "terminal"}, {Name: "web-search"}}
unwired := New(Options{})
if got := unwired.withSpeakSpec(base); len(got) != len(base) {
t.Fatalf("unwired appended: %d tools, want %d", len(got), len(base))
}
wired := New(Options{VoiceSynth: &stubSynth{}, VoiceStore: &stubStore{}})
got := wired.withSpeakSpec(base)
if len(got) != len(base)+1 {
t.Fatalf("wired tools = %d, want %d", len(got), len(base)+1)
}
if last := got[len(got)-1].Name; last != speakToolName {
t.Fatalf("last tool = %q, want %q(speak 必须在末尾)", last, speakToolName)
}
again := wired.withSpeakSpec(got)
if len(again) != len(got) {
t.Fatalf("duplicate speak appended: %d tools, want %d", len(again), len(got))
}
}
-32
View File
@@ -3,7 +3,6 @@ package loop
import (
"context"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
@@ -129,37 +128,6 @@ func notAllowedReason(name string) string {
return "not_allowed: tool " + name + " is not allowed in a group/stranger conversation"
}
// applyDispatcherlessVoiceGate 是 dispatcher 缺席时对 speak 的兜底安全门
// (design/11 §3)。正常接线恒有 dispatcher,安全门由 applyToolSecurityGate
// 走策略引擎;但 speak 由循环自己承载,无 dispatcher 也能执行(invokeOne
// 的短路),若此处不拦,退化配置就会让群聊/陌生人(甚至模型幻觉出的)
// speak 调用真的去合成音频并落盘 —— 绕过身份判定,也白烧一次上游调用。
//
// 口径与策略引擎对未登记工具的处理一致:**默认关闭**,只有主人环境放行。
// 拒绝走与安全门相同的软结果与审计通道(not_allowed + auditToolRejectNotAllowed),
// 使模型看到的原因、前端小字与审计轨迹在不同配置下逐字同源。
func (l *Loop) applyDispatcherlessVoiceGate(ctx context.Context, calls []agent.ToolCall, actions []toolCallAction) {
if !l.voiceEnabled() {
return
}
env := sessionEnvFrom(ctx)
if env.ActorEnv == restrict.ActorOwner {
return
}
for i := range calls {
if !actions[i].run || calls[i].Name != speakToolName {
continue
}
reason := notAllowedReason(calls[i].Name)
actions[i] = toolCallAction{run: false, reason: reason}
l.emitAudit(AuditEvent{
Who: string(env.ActorEnv),
What: auditToolRejectNotAllowed,
Result: reason,
})
}
}
// emitAudit 把 AuditEvent 转发给配置的 OnAudit sink。未接线
// sink 时为空操作,因此在未挂审计 sink 的叶子测试中
// 安全门仍能工作(软 not_allowed 结果)。
@@ -8,9 +8,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
)
@@ -237,75 +235,6 @@ func TestRun_Stranger_NotAllowed_Blocked(t *testing.T) {
}
}
// TestApplyToolAdmissions_DispatcherlessSpeakGate 锁住退化配置下的同一道
// 安全门:speak 由循环自己承载,没有 dispatcher 也能执行(invokeOne 的
// 短路),所以「没配 dispatcher」绝不能变成绕过 design/11 §3 的旁路 ——
// 群聊/陌生人的 speak 必须在这里被拦成 not_allowed 软结果并留下审计,
// 主人环境照常放行。
func TestApplyToolAdmissions_DispatcherlessSpeakGate(t *testing.T) {
t.Parallel()
var mu sync.Mutex
var audited []AuditEvent
l := New(Options{
VoiceSynth: &stubSynth{audio: mvoice.Audio{Format: "wav", Data: []byte{1}}, provider: "p"},
VoiceStore: &stubStore{ref: VoiceRef{Name: "a.wav", MIME: "audio/wav", Size: 1}},
OnAudit: func(ev AuditEvent) {
mu.Lock()
audited = append(audited, ev)
mu.Unlock()
},
})
cases := []struct {
env restrict.ActorEnv
allow bool
}{
{restrict.ActorGroupMember, false},
{restrict.ActorStranger, false},
{restrict.ActorOwner, true},
}
for _, tc := range cases {
mu.Lock()
audited = nil
mu.Unlock()
calls := []agent.ToolCall{{Name: speakToolName, Args: map[string]any{"text": "晚安"}}}
actions := []toolCallAction{{run: true}}
l.applyToolAdmissions(WithSessionEnv(context.Background(), SessionEnv{ActorEnv: tc.env}), nil, calls, actions)
if actions[0].run != tc.allow {
t.Fatalf("env %s run = %v, want %v (reason %q)", tc.env, actions[0].run, tc.allow, actions[0].reason)
}
mu.Lock()
got := append([]AuditEvent(nil), audited...)
mu.Unlock()
if tc.allow {
if len(got) != 0 {
t.Fatalf("env %s audited %+v, want none", tc.env, got)
}
continue
}
if !strings.Contains(actions[0].reason, "not_allowed") {
t.Fatalf("env %s reason = %q, want a not_allowed soft result", tc.env, actions[0].reason)
}
if len(got) != 1 || got[0].What != auditToolRejectNotAllowed || got[0].Who != string(tc.env) {
t.Fatalf("env %s audited %+v, want one %s by %s", tc.env, got, auditToolRejectNotAllowed, tc.env)
}
}
// speak 未接线时没有可执行的旁路,门不该动任何调用。
actions := []toolCallAction{{run: true}}
New(Options{}).applyToolAdmissions(
WithSessionEnv(context.Background(), SessionEnv{ActorEnv: restrict.ActorGroupMember}),
nil,
[]agent.ToolCall{{Name: speakToolName}},
actions,
)
if !actions[0].run {
t.Fatalf("unwired speak blocked (reason %q), want untouched", actions[0].reason)
}
}
// mustSetPolicy 把 ToolPolicy 应用到具名工具,出错即让测试失败。
func mustSetPolicy(t *testing.T, reg *registry.Registry, name string, pol registry.ToolPolicy) {
t.Helper()
+93 -17
View File
@@ -5,10 +5,12 @@ import (
"encoding/json"
"errors"
"fmt"
"sync"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// Options.ToolExecution 接受的 ToolExecutionMode 取值。为空时选用
@@ -75,9 +77,6 @@ type toolRunResult struct {
messages []model.Message
executed int
terminate bool
// voice 是本批 speak 调用的结论(M57):nil = 这一批没有要求语音。
// 由 runToolCalls 在结局钩子之后提取,供 run.go 回填 Result.Voice。
voice *OutboundVoice
}
// toolLoopState 携带关键工具循环安全网的每次 run 状态。
@@ -157,10 +156,6 @@ func (l *Loop) runToolCalls(ctx context.Context, messages []model.Message, calls
// 结果落定后的两类观察/改写钩子见 applyToolOutcomeHooks。
l.applyToolOutcomeHooks(ctx, calls, outcomes)
// 语音结论必须在钩子之后提取:AfterOutcome 可以改写 speak 的结果,
// 出站结论要看**模型最终看到的那份**。
res.voice = l.captureVoice(calls, outcomes)
// 钩子可以把每个调用都标记为 terminate 来要求终止本轮
// (OpenClaw 的 shouldTerminateToolBatch)。
if !res.terminate && shouldTerminateBatch(outcomes) {
@@ -236,19 +231,11 @@ func classifyToolBatch(st *toolLoopState, calls []agent.ToolCall) ([]toolCallAct
// 不被允许的调用绝不执行,以 not_allowed 软结果回灌并记录审计;
// 它是该批的最终权威,后续钩子可以收紧但绝不重新放行。②
// BatchAdmit 批次钩子(审计 / 政治审查)拦截整批。③ CallAdmit
// 逐调用否决。
//
// dispatcher 缺席时安全门走不了(它需要 dispatcher 的策略引擎),
// 其余工具由 runToolCalls 保证以「没有 dispatcher」错误回灌;但
// speak 由循环自己承载、无 dispatcher 也能执行(见 invokeOne 的
// 短路),因此这条退化配置下必须由 applyDispatcherlessVoiceGate
// 补上同一道硬门 —— 否则「没配 dispatcher」就成了绕过 design/11 §3
// 的旁路。
// 逐调用否决。dispatcher 缺席时安全门跳过(runToolCalls 已保证
// 此时整批以错误回灌)。
func (l *Loop) applyToolAdmissions(ctx context.Context, d *tool.Dispatcher, calls []agent.ToolCall, actions []toolCallAction) {
if d != nil {
l.applyToolSecurityGate(ctx, d, calls, actions)
} else {
l.applyDispatcherlessVoiceGate(ctx, calls, actions)
}
if l.hooks.BatchAdmit != nil {
if ok, reason := l.hooks.BatchAdmit(ctx, calls); !ok {
@@ -300,6 +287,95 @@ func (l *Loop) applyToolOutcomeHooks(ctx context.Context, calls []agent.ToolCall
}
}
// invokeOne 在其每工具截止时间下执行单个工具调用,发出一个
// tool span、以 tool_execution.start/end 事件流式输出执行进度
// 并归一化结果。执行全程由单一 phase 决策驱动:start 帧带
// running,Invoke 返回后按成败取 done/error,同一取值既决定
// end 帧的 phase,也决定 span 终态(design/19 的 tool 节点
// running/done/error 状态即由此驱动)。
func (l *Loop) invokeOne(ctx context.Context, d *tool.Dispatcher, tc agent.ToolCall) ToolOutcome {
if d == nil {
return ToolOutcome{Err: fmt.Errorf("no tool dispatcher configured for %q", tc.Name), ErrKind: tool.KindExecution}
}
_, toolSpan := l.span(ctx, "tool."+tc.Name, trace.SpanKindTool)
l.emitToolExecution(ctx, tc, model.StreamKindToolExecutionStart, model.ToolPhaseRunning, "")
// 路标识桥(design/15 §2.5.7、主人裁决 3):网关在**每一条在途路**上把互斥
// 身份经 loop.WithToolLegID 挂在 ctx 上(普通回合 / 并行路 / 队首续跑路都挂,
// 取值 = 该路的 turnID),这里把它桥接成工具层的 tool.WithLegID —— 工具侧
// 凭 tool.LegIDFrom 读到「我是哪一路」做文件级互斥;未挂载 ⇒ 空串 ⇒ 完全
// 不做互斥,行为逐字不变(兼容硬要求)。
//
// 刻意**不**读 ParallelLegFrom:那条通道只有真并行路才有、且只决定 checkpoint
// 归属(checkpoint.go 的 checkpointAgentID),拿它当互斥身份会让普通回合在
// 「1 普通回合 + 1 并行路」时完全不受保护。
result, err := d.Invoke(tool.WithLegID(ctx, ToolLegIDFrom(ctx)), tc.Name, tc.Args)
phase := model.ToolPhaseDone
if err != nil {
phase = model.ToolPhaseError
}
l.emitToolExecution(ctx, tc, model.StreamKindToolExecutionEnd, phase, toolEndFrameSummary(result, err))
out := ToolOutcome{Result: result, Err: err, ErrKind: errorKind(err)}
l.spanProp(toolSpan, "phase", phase)
l.recordToolDone(toolSpan, tc, out)
if err != nil {
l.spanProp(toolSpan, "error", err.Error())
l.spanProp(toolSpan, "error_kind", out.ErrKind)
} else {
recordToolResult(toolSpan, result)
}
l.finishSpan(toolSpan, spanStatusForToolPhase(phase))
return out
}
// emitToolExecution 在循环处于流式路径(配置了 OnStream)时把
// tool_execution 三事件(start / update / end)转发给前端;否则为
// 空操作(被静音的内部流程也会跳过它)。phase 是结构化字段
// (running/done/error),绝不塞进 Delta;工具名走 Tool 字段,
// ID 复用 provider 的 tool call id,使执行帧能与其 tool_call.* 帧关联;
// resultSummary 只在 end 帧、且只在结果真正拿到时非空(口径见 tool_frame_summary.go)。
func (l *Loop) emitToolExecution(ctx context.Context, tc agent.ToolCall, kind model.StreamKind, phase, resultSummary string) {
if l.onStream == nil || isMutedBroadcast(ctx) {
return
}
ev := model.StreamEvent{Kind: kind, Phase: phase, Tool: tc.Name, ID: tc.ID, ResultSummary: resultSummary}
ev.SessionID = loopSession(ctx)
ev.TurnID = loopTurn(ctx)
l.onStream(ev)
}
// spanStatusForToolPhase 把工具执行的终态 phase 映射为 span 终态,
// 使 trace/流程图与 WS 帧从同一个 phase 取值得出一致结论。
// 非终态 phase(running)不会走到这里 —— span 只在执行结束时收尾。
func spanStatusForToolPhase(phase string) trace.SpanStatus {
if phase == model.ToolPhaseError {
return trace.SpanStatusError
}
return trace.SpanStatusOK
}
// invokeParallel 并发运行 idxs 处的调用,上限为循环的
// 并行度,并把每个结果写回 out 的对应调用索引处,使调用方
// 能按原始顺序渲染结果。信号量限制并发,在途
// goroutine 完成时释放其槽位,因此被取消的 context
// (它使每个 Invoke 迅速返回)不会泄露 goroutine。
func (l *Loop) invokeParallel(ctx context.Context, d *tool.Dispatcher, calls []agent.ToolCall, idxs []int, out []ToolOutcome) {
if len(idxs) == 0 {
return
}
sem := make(chan struct{}, l.toolParallelism)
var wg sync.WaitGroup
for _, i := range idxs {
wg.Add(1)
sem <- struct{}{}
go func(i int) {
defer wg.Done()
defer func() { <-sem }()
out[i] = l.invokeOne(ctx, d, calls[i])
}(i)
}
wg.Wait()
}
// 由 classifyToolBatch 为两种跳过原因返回。
func duplicateCallReason() string {
return "duplicate call with identical arguments already issued in this batch"
-107
View File
@@ -1,107 +0,0 @@
package loop
// 本文件承载**单个/一批工具调用的执行**(span、流式帧、结果归一化、
// 有界并行),与 tools.go 的分类、准入与回渲职责分开 —— 从 tools.go
// 抽出,使两边都留在 500 行以内(AGENTS.md §10)。
import (
"context"
"fmt"
"sync"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// invokeOne 在其每工具截止时间下执行单个工具调用,发出一个
// tool span、以 tool_execution.start/end 事件流式输出执行进度
// 并归一化结果。执行全程由单一 phase 决策驱动:start 帧带
// running,Invoke 返回后按成败取 done/error,同一取值既决定
// end 帧的 phase,也决定 span 终态(design/19 的 tool 节点
// running/done/error 状态即由此驱动)。
func (l *Loop) invokeOne(ctx context.Context, d *tool.Dispatcher, tc agent.ToolCall) ToolOutcome {
// speak 由循环自己承载(见 invokeTool),没有 dispatcher 也能执行;其余
// 工具缺 dispatcher 时仍按原样短路,不为此多开 span 或流式帧。
if d == nil && !(l.voiceEnabled() && tc.Name == speakToolName) {
return ToolOutcome{Err: fmt.Errorf("no tool dispatcher configured for %q", tc.Name), ErrKind: tool.KindExecution}
}
_, toolSpan := l.span(ctx, "tool."+tc.Name, trace.SpanKindTool)
l.emitToolExecution(ctx, tc, model.StreamKindToolExecutionStart, model.ToolPhaseRunning, "")
// 分派见 invokeTool:路标识桥(tool.WithLegID)与 speak 的循环内承载都在
// 那里。路标识(design/15 §2.5.7、主人裁决 3):网关在**每一条在途路**上把
// 互斥身份经 loop.WithToolLegID 挂在 ctx 上(普通回合 / 并行路 / 队首续跑路
// 都挂,取值 = 该路的 turnID),工具层凭 tool.LegIDFrom 读到「我是哪一路」
// 做文件级互斥;未挂载 ⇒ 空串 ⇒ 完全不做互斥,行为逐字不变(兼容硬要求)。
//
// 刻意**不**读 ParallelLegFrom:那条通道只有真并行路才有、且只决定 checkpoint
// 归属(checkpoint.go 的 checkpointAgentID),拿它当互斥身份会让普通回合在
// 「1 普通回合 + 1 并行路」时完全不受保护。
result, err := l.invokeTool(ctx, d, tc)
phase := model.ToolPhaseDone
if err != nil {
phase = model.ToolPhaseError
}
l.emitToolExecution(ctx, tc, model.StreamKindToolExecutionEnd, phase, toolEndFrameSummary(result, err))
out := ToolOutcome{Result: result, Err: err, ErrKind: errorKind(err)}
l.spanProp(toolSpan, "phase", phase)
l.recordToolDone(toolSpan, tc, out)
if err != nil {
l.spanProp(toolSpan, "error", err.Error())
l.spanProp(toolSpan, "error_kind", out.ErrKind)
} else {
recordToolResult(toolSpan, result)
}
l.finishSpan(toolSpan, spanStatusForToolPhase(phase))
return out
}
// emitToolExecution 在循环处于流式路径(配置了 OnStream)时把
// tool_execution 三事件(start / update / end)转发给前端;否则为
// 空操作(被静音的内部流程也会跳过它)。phase 是结构化字段
// (running/done/error),绝不塞进 Delta;工具名走 Tool 字段,
// ID 复用 provider 的 tool call id,使执行帧能与其 tool_call.* 帧关联;
// resultSummary 只在 end 帧、且只在结果真正拿到时非空(口径见 tool_frame_summary.go)。
func (l *Loop) emitToolExecution(ctx context.Context, tc agent.ToolCall, kind model.StreamKind, phase, resultSummary string) {
if l.onStream == nil || isMutedBroadcast(ctx) {
return
}
ev := model.StreamEvent{Kind: kind, Phase: phase, Tool: tc.Name, ID: tc.ID, ResultSummary: resultSummary}
ev.SessionID = loopSession(ctx)
ev.TurnID = loopTurn(ctx)
l.onStream(ev)
}
// spanStatusForToolPhase 把工具执行的终态 phase 映射为 span 终态,
// 使 trace/流程图与 WS 帧从同一个 phase 取值得出一致结论。
// 非终态 phase(running)不会走到这里 —— span 只在执行结束时收尾。
func spanStatusForToolPhase(phase string) trace.SpanStatus {
if phase == model.ToolPhaseError {
return trace.SpanStatusError
}
return trace.SpanStatusOK
}
// invokeParallel 并发运行 idxs 处的调用,上限为循环的
// 并行度,并把每个结果写回 out 的对应调用索引处,使调用方
// 能按原始顺序渲染结果。信号量限制并发,在途
// goroutine 完成时释放其槽位,因此被取消的 context
// (它使每个 Invoke 迅速返回)不会泄露 goroutine。
func (l *Loop) invokeParallel(ctx context.Context, d *tool.Dispatcher, calls []agent.ToolCall, idxs []int, out []ToolOutcome) {
if len(idxs) == 0 {
return
}
sem := make(chan struct{}, l.toolParallelism)
var wg sync.WaitGroup
for _, i := range idxs {
wg.Add(1)
sem <- struct{}{}
go func(i int) {
defer wg.Done()
defer func() { <-sem }()
out[i] = l.invokeOne(ctx, d, calls[i])
}(i)
}
wg.Wait()
}
-40
View File
@@ -1,40 +0,0 @@
package loop
// OutboundVoice 是一次 Run 的**出站语音结论**:模型在回合内调用 speak 工具
// 请求合成语音时,循环把这次请求的结局归一化到本结构,随 Result 交还调用方
// (网关据此决定这条回复是发音频还是发文本,持久化层据此记下「这条是语音」
// 与降级原因)。它是进程内类型,不直接序列化给客户端。
//
// 三种状态互斥且由字段组合判定:
//
// - Result.Voice == nil —— 本回合**没有**请求语音,回复就是纯文本;
// - Name != "" —— 合成成功,且音频副本**已经落盘**(Name 即服务端存储名,
// Mime/Size 描述该副本);
// - Requested && Name == "" && FallbackReason != "" —— 她**请求了**语音,
// 但合成降级为文本(如 tts_timeout);FallbackReason 是机器可读的降级码,
// 客户端可以据此特殊显示「这条本来想用语音」。
//
// 语音是她的表达选择,不是身份或权限判定 —— 谁能用语音仍由渠道层 actor
// 元数据 + 工具可达性标记决定,本结构只记录结局。
type OutboundVoice struct {
// Requested 报告本回合是否发起过语音合成请求(成功与降级都为 true)。
Requested bool
// Name 是落盘音频副本的服务端存储名;空表示没有可用音频(降级或未请求)。
Name string
// Mime 是音频副本的媒体类型(如 audio/wav),随 Name 一起有意义。
Mime string
// Size 是音频副本的字节数。
Size int64
// DurationMs 是音频时长(毫秒)。**不知道就是 0** —— 本项目绝不用比特率
// 或字数估算时长(与 voice.Copy.DurationMs 同一口径);0 表示「未解析」,
// 不表示零长度音频。
DurationMs int64
// CostMs 是这次合成实际耗时(毫秒),用于对照预算看是否接近超时。
CostMs int
// BudgetMs 是本次合成被允许的超时预算(毫秒),由文本 rune 数推导
// (见 speakBudgetMs);未请求时为 0。
BudgetMs int
// FallbackReason 是降级原因码(如 tts_timeout),仅在「请求了但没有音频」
// 时非空;它让调用方与客户端能把「想用语音但没成」与「本来就用文本」区分开。
FallbackReason string
}
-172
View File
@@ -1,172 +0,0 @@
package loop
// 本文件是 speak 的接线面(M57):两个注入依赖的接口、接线判定、
// 以及「本回合的出站语音结论」从工具结局到 Result.Voice 的提取。
// 它们刻意留在循环里而不是工具包里 —— speak 需要本回合的人格与
// 预算口径,而这两样只有循环持有。
import (
"context"
"errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
)
// VoiceSynth 是 speak 需要的合成能力(*tts.Service 天然满足)。
// 接口收窄到**一个方法**:循环只需要「给定文本拿回音频与提供者」,
// 发音人解析、降级链与配额全在实现内部,循环不重复实现它们。
type VoiceSynth interface {
// TTSWithProvider 合成文本并返回音频与**实际**提供者标识
// (降级后是备用的那个),供日志与可观测性使用。
TTSWithProvider(ctx context.Context, text string) (mvoice.Audio, string, error)
}
// VoiceRef 是音频副本落盘后的交付描述:循环只需要「叫什么、什么类型、
// 多大」,与存储层的哈希/去重标记/磁盘路径无关。单独定义而不复用
// documents.Ref 是为了让循环不 import documents 包 —— 该包的测试会
// import 循环,反向依赖构成测试期的 import cycle。
type VoiceRef struct {
// Name 是服务端存储名(客户端凭它取回音频)。
Name string
// MIME 是存储层字节嗅探所得,不是合成侧的声明。
MIME string
// Size 是该副本的字节数。
Size int64
}
// VoiceStore 是 speak 需要的落盘能力。只要一个方法:音频副本必须走文档
// 存储的内容寻址 + 权限口径,而不是循环自己写文件。*documents.Store 经
// VoiceStoreFunc 适配即可接上(见该类型的用法说明)。
type VoiceStore interface {
// SaveAudio 落盘一份音频副本并返回其交付描述。
SaveAudio(ctx context.Context, origName string, data []byte) (VoiceRef, error)
}
// VoiceStoreFunc 把函数适配为 VoiceStore(与 ImageResolveFunc 同一惯例)。
// 接线方用它把存储层接到本包的接缝上,避免循环反向依赖 documents:
//
// loop.VoiceStoreFunc(func(ctx context.Context, name string, data []byte) (loop.VoiceRef, error) {
// ref, err := docs.SaveAudio(ctx, name, data)
// return loop.VoiceRef{Name: ref.Name, MIME: ref.MIME, Size: ref.Size}, err
// })
type VoiceStoreFunc func(ctx context.Context, origName string, data []byte) (VoiceRef, error)
// SaveAudio 实现 VoiceStore。
func (f VoiceStoreFunc) SaveAudio(ctx context.Context, origName string, data []byte) (VoiceRef, error) {
return f(ctx, origName, data)
}
// voiceEnabled 报告 speak 是否已接线。两个依赖**都**非 nil 才算 ——
// 只接了合成却没有存储时,调用必然在落盘一步失败,不如干脆不广告,
// 让模型面与接线前逐字节一致。
func (l *Loop) voiceEnabled() bool {
return l != nil && l.voiceSynth != nil && l.voiceStore != nil
}
// withSpeakSpec 把 speak 追加到模型面的工具声明末尾。它由 toolSchemas
// 在**所有收缩之后**调用,因此 speak 不受能力允许集(quiet/awake)与
// 技能轮次的 allowed-tools 裁剪 —— speak 不在任何内置 toolset 里,
// 走那些过滤就等于永远不广告。代价(群聊/陌生人也能看到 speak)由
// 执行侧的工具安全门兜住:speak 未注册进能力登记表,非主人环境下
// Policy.Allow 以「未知工具」拒绝,fail-closed。
func (l *Loop) withSpeakSpec(schemas []model.ToolSpec) []model.ToolSpec {
spec := l.speakSpec()
if spec == nil {
return schemas
}
for i := range schemas {
if schemas[i].Name == spec.Name {
return schemas
}
}
out := make([]model.ToolSpec, 0, len(schemas)+1)
out = append(out, schemas...)
return append(out, *spec)
}
// applyBatchVoice 把一批工具调用的语音结论回填进本回合的 Result。
// 后面批次里没有 speak 时保留前面那批的结论 —— 「这条回复想用语音」
// 发生在哪一批都算数。
//
// 跨批也守 captureVoice 的同一条优先级:**已落盘的音频绝不被后续批次的
// 失败抹掉**。同一批里由 captureVoice 自己保证(成功优先于失败),但批次
// 之间是两次独立结论,若这里无条件覆盖,则「先成功合成 abc.wav、下一轮
// 换个参数再试却失败」会把交付物退化成 tts_failed —— 音频已经存在,退回
// 文本既没有收益也违背她那次表达的意愿。反过来(先降级后成功)仍然覆盖:
// 后到的成功就是这条消息最终的语音。
func applyBatchVoice(res *Result, batch *toolRunResult) {
if batch.voice == nil {
return
}
if res.Voice != nil && res.Voice.Name != "" && batch.voice.Name == "" {
return
}
res.Voice = batch.voice
}
// captureVoice 从一批调用的结局里提取本回合的出站语音结论。返回 nil
// 表示「这一批没有要求语音」,Result.Voice 因此保持 nil,客户端按纯
// 文本呈现 —— 与没有 speak 之前逐字节一致。
//
// 多个 speak 调用时取**最后一个成功的**:成功意味着音频真的落了盘,
// 后续的重试即使失败也不该让已经交付的音频消失。全都失败时取最后
// 一个失败的原因,使客户端仍能区分「想用语音但没成」与「本来就用
// 文本」。
func (l *Loop) captureVoice(calls []agent.ToolCall, outcomes []ToolOutcome) *OutboundVoice {
var lastOK, lastFail *OutboundVoice
n := len(calls)
if len(outcomes) < n {
n = len(outcomes)
}
for i := 0; i < n; i++ {
if calls[i].Name != speakToolName || outcomes[i].Skipped {
continue
}
if outcomes[i].Err == nil {
lastOK = synthesizedVoice(outcomes[i].Result)
continue
}
lastFail = degradedVoice(outcomes[i].Err)
}
if lastOK != nil {
return lastOK
}
return lastFail
}
// synthesizedVoice 把 speak 成功时的结构化结果翻成 OutboundVoice。
// 结果被钩子改写成非对象时返回 nil:拿不到存储名就播放不了,此时
// 按文本呈现是唯一诚实的选择(DurationMs 保持 0 = 未解析)。
func synthesizedVoice(result any) *OutboundVoice {
m, ok := result.(map[string]any)
if !ok {
return nil
}
v := &OutboundVoice{Requested: true}
v.Name, _ = m["name"].(string)
v.Mime, _ = m["mime"].(string)
v.Size, _ = m["size"].(int64)
v.DurationMs, _ = m["durationMs"].(int64)
v.CostMs, _ = m["costMs"].(int)
v.BudgetMs, _ = m["budgetMs"].(int)
return v
}
// degradedVoice 把 speak 的失败翻成「想用语音但退回了文本」。原因码
// 优先取结构化错误的 Code(超时/无音频/落盘失败/文本过长),非结构化
// 失败一律归 tts_failed,绝不把空原因码交给客户端。
func degradedVoice(err error) *OutboundVoice {
v := &OutboundVoice{Requested: true, FallbackReason: speakReasonFailed}
var se *speakError
if !errors.As(err, &se) {
return v
}
if se.Code != "" {
v.FallbackReason = se.Code
}
v.BudgetMs = se.BudgetMs
v.CostMs = se.CostMs
return v
}
-296
View File
@@ -1,296 +0,0 @@
package loop
import (
"context"
"errors"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
)
// 本文件锁住「speak 调用 → Result.Voice」这条出站链(M57):
// 成功、降级、没调三态在叶子层,加上一次端到端回合,证明模型面真的
// 被广告了 speak、调用真的被执行、结论真的落在 Result 上。
// TestCaptureVoice_States 锁住三态:成功带交付物、失败带原因码与
// 预算/耗时、没调(或只调了别的工具)返回 nil。
func TestCaptureVoice_States(t *testing.T) {
t.Parallel()
l := New(Options{})
if v := l.captureVoice(
[]agent.ToolCall{{Name: "terminal"}},
[]ToolOutcome{{Result: "ok"}},
); v != nil {
t.Fatalf("non-speak batch voice = %+v, want nil", v)
}
ok := l.captureVoice(
[]agent.ToolCall{{Name: speakToolName}},
[]ToolOutcome{{Result: map[string]any{
"name": "abc.wav", "mime": "audio/wav", "size": int64(9),
"durationMs": int64(0), "budgetMs": 3025, "costMs": 120,
}}},
)
if ok == nil || !ok.Requested {
t.Fatalf("success voice = %+v, want Requested", ok)
}
if ok.Name != "abc.wav" || ok.Mime != "audio/wav" || ok.Size != 9 {
t.Fatalf("success voice = %+v, want the stored ref", ok)
}
if ok.BudgetMs != 3025 || ok.CostMs != 120 || ok.DurationMs != 0 {
t.Fatalf("success voice = %+v, want budget/cost and unparsed duration", ok)
}
if ok.FallbackReason != "" {
t.Fatalf("success FallbackReason = %q, want empty", ok.FallbackReason)
}
bad := l.captureVoice(
[]agent.ToolCall{{Name: speakToolName}},
[]ToolOutcome{{Err: &speakError{Code: speakReasonTimeout, BudgetMs: 3000, CostMs: 3100}}},
)
if bad == nil || !bad.Requested {
t.Fatalf("failure voice = %+v, want Requested", bad)
}
if bad.FallbackReason != speakReasonTimeout || bad.BudgetMs != 3000 || bad.CostMs != 3100 {
t.Fatalf("failure voice = %+v, want the timeout code and budget/cost", bad)
}
if bad.Name != "" {
t.Fatalf("failure name = %q, want empty(没有交付物)", bad.Name)
}
// 非结构化失败也要有原因码,绝不把空串交给客户端。
raw := l.captureVoice(
[]agent.ToolCall{{Name: speakToolName}},
[]ToolOutcome{{Err: errors.New("boom")}},
)
if raw == nil || raw.FallbackReason != speakReasonFailed {
t.Fatalf("raw failure voice = %+v, want %q", raw, speakReasonFailed)
}
}
// TestCaptureVoice_LastSuccessWins 锁住多次 speak 的取用规则:成功优先
// 于失败(音频已经落盘就交付),同类取最后一个(这条消息最后的意愿)。
func TestCaptureVoice_LastSuccessWins(t *testing.T) {
t.Parallel()
l := New(Options{})
calls := []agent.ToolCall{{Name: speakToolName}, {Name: speakToolName}, {Name: speakToolName}}
// 先成功、后失败:交付已落盘的那份。
got := l.captureVoice(calls[:2], []ToolOutcome{
{Result: map[string]any{"name": "first.wav", "mime": "audio/wav", "size": int64(1)}},
{Err: &speakError{Code: speakReasonFailed}},
})
if got == nil || got.Name != "first.wav" {
t.Fatalf("voice = %+v, want first.wav(成功优先于后续失败)", got)
}
// 两次都成功:取后者。
got = l.captureVoice(calls[:2], []ToolOutcome{
{Result: map[string]any{"name": "first.wav"}},
{Result: map[string]any{"name": "second.wav"}},
})
if got == nil || got.Name != "second.wav" {
t.Fatalf("voice = %+v, want second.wav(后者覆盖前者)", got)
}
// 被跳过的调用不算数。
if v := l.captureVoice(calls[:1], []ToolOutcome{{Skipped: true, Reason: "duplicate"}}); v != nil {
t.Fatalf("skipped voice = %+v, want nil", v)
}
}
// TestRun_VoiceResult 是一次端到端回合:speak 出现在模型面里、被调用
// 后真的走了注入的合成与落盘,结论落在 Result.Voice 上。
func TestRun_VoiceResult(t *testing.T) {
t.Parallel()
call := model.Response{Model: "m", ToolCalls: []model.ToolCall{{
ID: "call_speak_1",
Name: speakToolName,
Args: map[string]any{"text": "晚安", "emotion": "温柔"},
}}}
ad := &mockAdapter{stream: []mgResp{{resp: call}, {resp: model.Response{Model: "m", Content: "晚安"}}}}
td := dispatcherWith(t, &mockTool{name: "terminal", result: "ok"})
synth := &stubSynth{audio: mvoice.Audio{Format: "wav", Data: []byte{1, 2, 3}}, provider: "emotiontts"}
store := &stubStore{ref: VoiceRef{Name: "abc.wav", MIME: "audio/wav", Size: 3}}
l := New(Options{
Agent: newTestAgent(t, ad, td),
MaxRounds: 8,
OnStream: func(model.StreamEvent) {},
VoiceSynth: synth,
VoiceStore: store,
})
res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "说晚安")})
if err != nil {
t.Fatalf("Run: %v", err)
}
if res.ToolCallsExecuted != 1 {
t.Fatalf("ToolCallsExecuted = %d, want 1", res.ToolCallsExecuted)
}
// ① 模型面确实被广告了 speak(否则模型不可能调它)。
if len(ad.streamReqs) == 0 {
t.Fatal("no streaming request captured")
}
if set := toolSet(ad.streamReqs[0].Tools); !set[speakToolName] {
t.Fatalf("advertised tools = %v, want %q", toolNames(ad.streamReqs[0].Tools), speakToolName)
}
// ② 调用真的走了注入的合成与落盘。
if synth.gotText != "晚安" {
t.Fatalf("synth text = %q, want 晚安", synth.gotText)
}
if store.gotName != "speak.wav" {
t.Fatalf("origName = %q, want speak.wav", store.gotName)
}
// ③ 结论落在 Result.Voice 上。
if res.Voice == nil {
t.Fatal("Result.Voice = nil, want the synthesized audio")
}
if !res.Voice.Requested || res.Voice.Name != "abc.wav" || res.Voice.Mime != "audio/wav" || res.Voice.Size != 3 {
t.Fatalf("Result.Voice = %+v, want the stored ref", res.Voice)
}
if res.Voice.FallbackReason != "" {
t.Fatalf("FallbackReason = %q, want empty", res.Voice.FallbackReason)
}
}
// TestRun_VoiceDegraded 锁住端到端的降级:合成失败时回合照常结束
// (绝不因语音失败而失败),Result.Voice 带上原因码让客户端特殊显示。
func TestRun_VoiceDegraded(t *testing.T) {
t.Parallel()
call := model.Response{Model: "m", ToolCalls: []model.ToolCall{{
ID: "call_speak_2",
Name: speakToolName,
Args: map[string]any{"text": "晚安"},
}}}
ad := &mockAdapter{stream: []mgResp{{resp: call}, {resp: model.Response{Model: "m", Content: "晚安"}}}}
l := New(Options{
Agent: newTestAgent(t, ad, dispatcherWith(t)),
MaxRounds: 8,
OnStream: func(model.StreamEvent) {},
VoiceSynth: &stubSynth{err: errors.New("upstream down")},
VoiceStore: &stubStore{},
})
res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "说晚安")})
if err != nil {
t.Fatalf("Run: %v", err)
}
if res.Text != "晚安" {
t.Fatalf("Text = %q, want the text answer(语音失败不得拖垮回合)", res.Text)
}
if res.Voice == nil || res.Voice.FallbackReason != speakReasonFailed {
t.Fatalf("Result.Voice = %+v, want %q", res.Voice, speakReasonFailed)
}
if res.Voice.Requested != true {
t.Fatalf("Result.Voice.Requested = %v, want true(确实想用语音)", res.Voice.Requested)
}
}
// TestRun_NoSpeakNoVoice 锁住「不接线就没有语音面」:未注入依赖时
// 模型面里没有 speak,Result.Voice 保持 nil —— 与接线前逐字节一致。
func TestRun_NoSpeakNoVoice(t *testing.T) {
t.Parallel()
ad := &mockAdapter{stream: []mgResp{{resp: model.Response{Model: "m", Content: "晚安"}}}}
l := New(Options{
Agent: newTestAgent(t, ad, dispatcherWith(t, &mockTool{name: "terminal", result: "ok"})),
MaxRounds: 8,
OnStream: func(model.StreamEvent) {},
})
res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "说晚安")})
if err != nil {
t.Fatalf("Run: %v", err)
}
if res.Voice != nil {
t.Fatalf("Result.Voice = %+v, want nil", res.Voice)
}
if len(ad.streamReqs) == 0 {
t.Fatal("no streaming request captured")
}
if set := toolSet(ad.streamReqs[0].Tools); set[speakToolName] {
t.Fatalf("advertised tools = %v, must not contain %q", toolNames(ad.streamReqs[0].Tools), speakToolName)
}
}
// TestInvokeTool_SpeakWithoutDispatcher 锁住 speak 不依赖 dispatcher:
// 未接线时普通工具仍按原样报「没有 dispatcher」,接线后 speak 照常执行。
func TestInvokeTool_SpeakWithoutDispatcher(t *testing.T) {
t.Parallel()
wired := New(Options{
VoiceSynth: &stubSynth{audio: mvoice.Audio{Format: "wav", Data: []byte{1}}, provider: "p"},
VoiceStore: &stubStore{ref: VoiceRef{Name: "a.wav", MIME: "audio/wav", Size: 1}},
})
out := wired.invokeOne(context.Background(), nil, agent.ToolCall{
Name: speakToolName, Args: map[string]any{"text": "晚安"},
})
if out.Err != nil {
t.Fatalf("speak without dispatcher err = %v, want nil", out.Err)
}
plain := New(Options{})
out = plain.invokeOne(context.Background(), nil, agent.ToolCall{Name: "terminal"})
if out.Err == nil || out.ErrKind != tool.KindExecution {
t.Fatalf("nil dispatcher outcome = %+v, want an execution error", out)
}
}
// TestApplyBatchVoice_KeepsCrossBatchSuccess 锁住跨批的语音优先级:批次之间
// 是两次独立结论,后一批换个参数失败时**绝不能**把前一批已落盘的音频抹成
// tts_failed —— 交付物已经存在,退回文本既没有收益也违背她那次表达的意愿。
// 反过来(先降级后成功)后到的成功仍然覆盖;后一批没有 speak 时保留结论。
func TestApplyBatchVoice_KeepsCrossBatchSuccess(t *testing.T) {
t.Parallel()
delivered := &OutboundVoice{Requested: true, Name: "abc.wav", Mime: "audio/wav", Size: 3}
degraded := &OutboundVoice{Requested: true, FallbackReason: speakReasonFailed}
res := &Result{}
applyBatchVoice(res, &toolRunResult{voice: delivered})
applyBatchVoice(res, &toolRunResult{voice: degraded})
if res.Voice == nil || res.Voice.Name != "abc.wav" || res.Voice.FallbackReason != "" {
t.Fatalf("success then cross-batch failure voice = %+v, want the delivered abc.wav", res.Voice)
}
// 先降级后成功:后到的成功就是这条消息最终的语音。
res = &Result{}
applyBatchVoice(res, &toolRunResult{voice: degraded})
applyBatchVoice(res, &toolRunResult{voice: delivered})
if res.Voice == nil || res.Voice.Name != "abc.wav" || res.Voice.FallbackReason != "" {
t.Fatalf("degraded then success voice = %+v, want abc.wav", res.Voice)
}
// 后一批没有 speak:前面那批的结论照旧算数。
res = &Result{}
applyBatchVoice(res, &toolRunResult{voice: delivered})
applyBatchVoice(res, &toolRunResult{})
if res.Voice == nil || res.Voice.Name != "abc.wav" {
t.Fatalf("batch without voice = %+v, want the earlier abc.wav", res.Voice)
}
}
// TestVoiceStoreFunc 锁住接线方用的函数适配器:它满足 VoiceStore,且
// 原样转发原始名与字节(接线方正是靠它把 documents.Store 接到接缝上,
// 而不让循环反向 import documents)。
func TestVoiceStoreFunc(t *testing.T) {
t.Parallel()
var gotName string
var gotData []byte
var store VoiceStore = VoiceStoreFunc(func(_ context.Context, origName string, data []byte) (VoiceRef, error) {
gotName, gotData = origName, data
return VoiceRef{Name: "saved.wav", MIME: "audio/wav", Size: int64(len(data))}, nil
})
ref, err := store.SaveAudio(context.Background(), "speak.wav", []byte{7, 8})
if err != nil {
t.Fatalf("SaveAudio: %v", err)
}
if gotName != "speak.wav" || len(gotData) != 2 {
t.Fatalf("forwarded (%q, %d bytes), want (speak.wav, 2 bytes)", gotName, len(gotData))
}
if ref.Name != "saved.wav" || ref.Size != 2 {
t.Fatalf("ref = %+v, want the adapter's own result", ref)
}
}
+7 -34
View File
@@ -1,11 +1,12 @@
package qq_obv11
// This file carries OneBot v11 protocol types and the conversions between the
// native event/segment model and the normalized adapter.UnifiedMessage /
// UnifiedResponse model. It understands both array-format messages
// ([]{"type","data"}) and the legacy CQ-code string format
// ([CQ:image,file=...]text), and maps QQ media (image/record/video/file) into
// adapter.Media and QQ mentions into adapter.Mention.
// This file carries OneBot v11 protocol types and the conversions from the
// native event/segment model into the normalized adapter.UnifiedMessage model.
// It understands both array-format messages ([]{"type","data"}) and the legacy
// CQ-code string format ([CQ:image,file=...]text), and maps QQ media
// (image/record/video/file) into adapter.Media and QQ mentions into
// adapter.Mention. 反方向的「统一消息 → 出站消息段」在 `outbound_segments.go`
// (入站与出站各有各的知识面,不混在一个文件里)。
import (
"encoding/json"
@@ -371,34 +372,6 @@ func mediaFromParams(p map[string]string, kind adapter.MediaKind) adapter.Media
return adapter.Media{Kind: kind, URL: url, Name: file}
}
// outboundSegments converts a unified message into OneBot message segments.
func outboundSegments(msg adapter.UnifiedMessage) ([]obSegment, error) {
var segs []obSegment
if msg.Content != "" {
segs = append(segs, obSegment{Type: "text", Data: map[string]any{"text": msg.Content}})
}
for _, m := range msg.Media {
file := firstNonEmpty(m.URL, m.Name)
switch m.Kind {
case adapter.MediaImage:
segs = append(segs, obSegment{Type: "image", Data: map[string]any{"file": file}})
case adapter.MediaVoice:
segs = append(segs, obSegment{Type: "record", Data: map[string]any{"file": file}})
case adapter.MediaVideo:
segs = append(segs, obSegment{Type: "video", Data: map[string]any{"file": file}})
case adapter.MediaFile:
segs = append(segs, obSegment{Type: "file", Data: map[string]any{"file": file}})
}
}
if len(segs) == 0 {
if msg.Content == "" {
return nil, fmt.Errorf("qq-obv11: message has no content or media: %w", adapter.ErrUnsupported)
}
segs = append(segs, obSegment{Type: "text", Data: map[string]any{"text": msg.Content}})
}
return segs, nil
}
// resolveTarget determines where a reply should be delivered.
func resolveTarget(msg adapter.UnifiedMessage) (messageType string, userID, groupID int64, err error) {
if msg.ChannelType == adapter.ChannelGroup {
@@ -0,0 +1,147 @@
package qq_obv11
// 本文件承载**出站消息段的拼装**(切片 M59 QQRECORD)。它从 `message.go` 里分出来,
// 因为两件事各有各的知识面:`message.go` 讲「OneBot 的事件/段怎么读进来」,这里讲
// 「统一消息怎么发出去」。
//
// 本片唯一的新能力是**语音发成真语音条**:QQ 的语音是一条独立气泡,OneBot v11 用
// `record` 段表达它。在此之前出站只有 text/image/video/file 四类,`MediaVoice` 条目
// 虽然已经落进 `record` 段(`message.go` 的旧代码就有这一档),但 `file` 一律取
// `URL`/`Name` 原样透传 —— 对**入站**媒体这是对的(平台给的是可下载地址),对**出站**
// 语音则是错的:出站语音的字节是本进程里产生的,`URL` 很可能是框架自己的服务端相对
// 路径(`/api/v1/media/<name>`),OneBot 实现在它自己的进程里解析不了这个值。
// 因此本片把「record 段的 file 取哪种形态」变成一处**显式裁决**(`outboundRecordFile`),
// 并在不可用时提前失败,而不是把一条解析不了的值丢给平台去报一个看不出根因的错。
import (
"encoding/base64"
"fmt"
"path/filepath"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
const (
// obRecordSegment 是语音段在 OneBot v11 数组格式里的段类型(`{"type":"record"}`)。
obRecordSegment = "record"
// obBase64FilePrefix 是 OneBot v11 通用的内联 file 形态前缀(`base64://<base64>`):
// 值本身就是文件内容,实现方自己解码,不需要任何共享文件系统。
obBase64FilePrefix = "base64://"
// serverMediaPathPrefix 是框架语音副本对外的**服务端相对路径**前缀(契约 v0.67 §4.4,
// 唯一派生点 `internal/runtime/voice.MediaPath`)。
//
// 它**不是**文件路径:谁拿到它都得走 HTTP 才取得到字节。这里重复一份字面量而不是
// import `internal/runtime/voice` —— 渠道插件反向依赖运行时存储层会把分层倒过来
// (插件只该依赖 `internal/channel/*`)。两处必须同值,由 `outbound_voice_test.go`
// 的交叉断言钉住,改了一边测试就红。
serverMediaPathPrefix = "/api/v1/media/"
)
// outboundSegments converts a unified message into OneBot message segments.
//
// 文本正文与语音段的关系(平台惯例):`record` 段**自己就是一条语音气泡**,它既不
// 承载正文、也不会把正文吞进去 ⇒ 正文(若有)作为**独立 text 段**排在前面,媒体段按
// `Media` 顺序紧随其后。于是一条「说了一句话 + 一条语音」的消息落成
// `[{text},{record}]` 两段;只发语音时就是 `[{record}]` 一段。这条顺序也是 QQ 客户端
// 的渲染顺序,入站的 `[CQ:record]` 在客户端里同样是独立气泡(`message.go` 把它读成
// `[语音]` 占位 + 一条 `MediaVoice`)。
//
// 正文**不做占位符过滤**:`Content` 是调用方给的正文,本函数不猜它是不是
// `[语音]` 这类占位标记(那是 `mediafetch.ChatText` 的职责,出站链路上游不该把占位
// 标记当正文交下来)。保持「非空就发」让本片对无语音的消息做到零行为变化。
func outboundSegments(msg adapter.UnifiedMessage) ([]obSegment, error) {
var segs []obSegment
if msg.Content != "" {
segs = append(segs, obSegment{Type: "text", Data: map[string]any{"text": msg.Content}})
}
for _, m := range msg.Media {
seg, ok, err := outboundMediaSegment(m)
if err != nil {
return nil, err
}
if ok {
segs = append(segs, seg)
}
}
if len(segs) == 0 {
// 走到这里意味着 `Content` 为空、且 `Media` 里没有一条能拼成段(空/未知 Kind)。
// 迁出前的旧实现在这里还有一层 `if msg.Content == ""` + 补一条 text 段,那两条
// 分支不可达:`Content` 非空时上面就已经追加过 text 段了。行为不变,故收掉。
return nil, fmt.Errorf("qq-obv11: message has no content or media: %w", adapter.ErrUnsupported)
}
return segs, nil
}
// outboundMediaSegment 把一条 Media 拼成一个消息段。第二个返回值 false 表示该 Kind 在
// OneBot 侧没有对应段(未知 Kind),沿用既有行为:**静默跳过**,不因一条不认识的附件
// 把整条消息打掉。
func outboundMediaSegment(m adapter.Media) (obSegment, bool, error) {
switch m.Kind {
case adapter.MediaImage:
return fileSegment("image", firstNonEmpty(m.URL, m.Name)), true, nil
case adapter.MediaVoice:
file, err := outboundRecordFile(m)
if err != nil {
return obSegment{}, false, err
}
return fileSegment(obRecordSegment, file), true, nil
case adapter.MediaVideo:
return fileSegment("video", firstNonEmpty(m.URL, m.Name)), true, nil
case adapter.MediaFile:
return fileSegment("file", firstNonEmpty(m.URL, m.Name)), true, nil
default:
return obSegment{}, false, nil
}
}
// fileSegment 是「只有一个 file 参数」的段的唯一构造点(image/video/file/record 同形)。
func fileSegment(typ, file string) obSegment {
return obSegment{Type: typ, Data: map[string]any{"file": file}}
}
// outboundRecordFile 为 record 段裁决 `file` 参数取哪种形态。**这是本片的核心决策点。**
//
// 三种形态,按优先级:
//
// 1. **base64 内联**(`base64://<标准 base64>`)—— `Media.Data` 非空时用它,这是
// 出站语音的**首选形态**。理由三条:
// a. OneBot v11 通用,Napcat 收到后自己解码,并按需把源格式转成 QQ 的 silk;
// b. 不依赖跨进程共享文件系统 —— 「插件与 OneBot 实现同机」只是当前部署事实,
// 不该被写进协议形状(Napcat 完全可能跑在容器/另一台机器上);
// c. 出站语音的字节本来就是本进程里产生的(TTS),走内联不必先落盘再寻址,也不必
// 让插件知道框架的 `$DATA_DIR`(插件没有、也不该有那个配置面)。
// 2. **原样透传** —— 上游已经给了 `base64://` 编码值、`http(s)://` 地址(Napcat 自己
// 下载),或**本地绝对路径**(`filepath.IsAbs`;同机部署时 Napcat 能直接读)。这三种
// 都是 OneBot 实现能自己解析的形态,一个字节都不改写。
// 3. **明确拒绝** —— 服务端相对路径(`/api/v1/media/<name>`)、裸文件名、空值。这些值在
// OneBot 进程里没有意义:透传只会得到一条「文件不存在」的平台错误;更糟的是在
// Linux 上 `/api/v1/media/x.wav` 会被 `filepath.IsAbs` 判成绝对路径,于是一个不存在的
// 位置被当成本地文件读。**所以前缀判定必须排在 `IsAbs` 之前**,提前失败并说清要什么。
//
// `Data` 与 `URL` 同时存在时 `Data` 优先:内联形态不依赖任何外部可达性,是最不容易
// 在部署上翻车的那一种(与 `adapter.Media` 的「URL/Data 互斥」口径一致)。
//
// ⚠️ **未在本机验证**:QQ 上游格式是 silk,WAV 直发是否被 Napcat 接受、转码是否成功,
// 需要真机(有 Napcat 环境)验证。本函数只负责把形态选对,转码是 Napcat 的事。
func outboundRecordFile(m adapter.Media) (string, error) {
if len(m.Data) > 0 {
return obBase64FilePrefix + base64.StdEncoding.EncodeToString(m.Data), nil
}
ref := firstNonEmpty(m.URL, m.Name)
if ref == "" {
return "", fmt.Errorf("qq-obv11: voice media carries neither data nor url: %w", adapter.ErrUnsupported)
}
if strings.HasPrefix(ref, serverMediaPathPrefix) {
return "", fmt.Errorf("qq-obv11: voice media %q is a server-relative media path, not a file path: %w", ref, adapter.ErrUnsupported)
}
if strings.HasPrefix(ref, obBase64FilePrefix) || isHTTPURL(ref) || filepath.IsAbs(ref) {
return ref, nil
}
return "", fmt.Errorf("qq-obv11: voice media %q is not readable by the OneBot implementation (want base64://, http(s)://, or a local absolute path): %w", ref, adapter.ErrUnsupported)
}
// isHTTPURL 报告 s 是否是 http(s) 绝对地址(Napcat 会自己下载这种形态)。
func isHTTPURL(s string) bool {
return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://")
}
@@ -0,0 +1,207 @@
package qq_obv11
// 本文件钉住**出站语音段**的拼装形状(切片 M59 QQRECORD)。三条断言口径:
//
// 1. **形状**:`Media` 里的 `MediaVoice` 条目必须落成 `{"type":"record"}` 段,且
// `file` 取 `outboundRecordFile` 裁决出来的那一种形态(内联 base64 / 原样透传);
// 2. **顺序**:正文是**独立的 text 段**、排在 record 前面(QQ 的语音是独立气泡,
// 正文既不能塞进 record,也不能被 record 吞掉);
// 3. **零变化**:不带 voice 的消息,段序列与改动前**逐字相等**(硬编码期望值,不写
// 快照文件 —— 快照会把「顺手改坏」也一起接受下来)。
//
// ⚠️ 这里钉的是**拼装形状**,不是「QQ 真的响了」:WAV/MP3 直发是否被 Napcat 接受、
// silk 转码是否成功,需要真机(有 Napcat 环境)验证,本机无法证明。
import (
"encoding/base64"
"errors"
"path/filepath"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
voiceref "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice"
)
// voiceBytes 是一份「像那么回事」的语音字节(只用于断言编码结果,不是可播音源)。
func voiceBytes() []byte { return []byte("RIFF....WAVEfmt fake-audio-bytes") }
func TestOutboundVoiceRecordSegmentFromInlineData(t *testing.T) {
t.Parallel()
data := voiceBytes()
segs, err := outboundSegments(adapter.UnifiedMessage{
Content: "听一下",
Media: []adapter.Media{{Kind: adapter.MediaVoice, MIMEType: "audio/wav", Data: data}},
})
if err != nil {
t.Fatalf("outboundSegments error: %v", err)
}
if len(segs) != 2 {
t.Fatalf("segs = %+v, want 2 (text + record)", segs)
}
if segs[0].Type != "text" || segs[0].Data["text"] != "听一下" {
t.Errorf("segs[0] = %+v, want text 听一下", segs[0])
}
if segs[1].Type != obRecordSegment {
t.Fatalf("segs[1].Type = %q, want %q", segs[1].Type, obRecordSegment)
}
want := obBase64FilePrefix + base64.StdEncoding.EncodeToString(data)
if got := segs[1].Data["file"]; got != want {
t.Errorf("record file = %q, want %q(内联 base64 形态)", got, want)
}
if len(segs[1].Data) != 1 {
t.Errorf("record data = %+v, want only the file key", segs[1].Data)
}
}
func TestOutboundVoiceOnlyHasNoTextSegment(t *testing.T) {
t.Parallel()
segs, err := outboundSegments(adapter.UnifiedMessage{
Media: []adapter.Media{{Kind: adapter.MediaVoice, Data: voiceBytes()}},
})
if err != nil {
t.Fatalf("outboundSegments error: %v", err)
}
if len(segs) != 1 || segs[0].Type != obRecordSegment {
t.Fatalf("segs = %+v, want a single record segment", segs)
}
}
// TestOutboundVoiceFilePassthrough 钉住「OneBot 实现自己能解析的形态一个字节都不改写」:
// http(s) 地址、上游已编码的 base64://、以及本地绝对路径。
func TestOutboundVoiceFilePassthrough(t *testing.T) {
t.Parallel()
localPath := filepath.Join(t.TempDir(), "copy.wav")
tests := []struct {
name string
url string
}{
{name: "http url", url: "http://cdn.example/x.mp3"},
{name: "https url", url: "https://cdn.example/x.mp3"},
{name: "pre-encoded base64", url: "base64://QUJD"},
{name: "local absolute path", url: localPath},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
segs, err := outboundSegments(adapter.UnifiedMessage{
Media: []adapter.Media{{Kind: adapter.MediaVoice, URL: tc.url}},
})
if err != nil {
t.Fatalf("outboundSegments error: %v", err)
}
if len(segs) != 1 || segs[0].Type != obRecordSegment {
t.Fatalf("segs = %+v, want a single record segment", segs)
}
if got := segs[0].Data["file"]; got != tc.url {
t.Errorf("record file = %q, want %q(原样透传)", got, tc.url)
}
})
}
}
// TestOutboundVoiceRejectsUnreadableFile 钉住「读不到的值提前失败」:服务端相对路径
// (框架自己的 `/api/v1/media/<name>`)、裸文件名、空值都不该被当成 file 参数丢给
// OneBot 实现 —— 那只会换来一条看不出根因的平台错误。
//
// 每个用例还断言**错误文案的区分点**:三种拒绝走三条不同的分支,文案各不相同。
// 这不是为了好看 —— 服务端相对路径那条分支在 Windows 上即使删掉也会因为
// `filepath.IsAbs("/api/v1/media/…") == false` 落到兜底分支(照样报错),只有文案断言
// 才能在**所有平台**上钉住那条前缀判定(它在 Linux 上是唯一挡住「把服务端路径当本地
// 绝对路径」的闸)。
func TestOutboundVoiceRejectsUnreadableFile(t *testing.T) {
t.Parallel()
tests := []struct {
name string
med adapter.Media
wantMsg string
}{
{
name: "server relative media path",
med: adapter.Media{Kind: adapter.MediaVoice, URL: serverMediaPathPrefix + "0123456789abcdef0123456789abcdef.wav"},
wantMsg: "server-relative",
},
{
name: "bare file name",
med: adapter.Media{Kind: adapter.MediaVoice, Name: "copy.wav"},
wantMsg: "not readable",
},
{
name: "nothing at all",
med: adapter.Media{Kind: adapter.MediaVoice},
wantMsg: "neither data nor url",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, err := outboundSegments(adapter.UnifiedMessage{Content: "hi", Media: []adapter.Media{tc.med}})
if err == nil {
t.Fatalf("expected error, got nil (med=%+v)", tc.med)
}
if !errors.Is(err, adapter.ErrUnsupported) {
t.Errorf("err = %v, want ErrUnsupported", err)
}
if !strings.Contains(err.Error(), tc.wantMsg) {
t.Errorf("err = %v, want it to mention %q", err, tc.wantMsg)
}
})
}
}
// TestOutboundWithoutVoiceUnchanged 是「零变化」的等价断言:不带 voice 的消息,
// 段序列与改动前逐字相等(期望值硬编码,见 `message.go` 里改动前的四档)。
func TestOutboundWithoutVoiceUnchanged(t *testing.T) {
t.Parallel()
segs, err := outboundSegments(adapter.UnifiedMessage{
Content: "pong",
Media: []adapter.Media{
{Kind: adapter.MediaImage, URL: "http://cdn/x.jpg"},
{Kind: adapter.MediaVideo, URL: "http://cdn/x.mp4"},
{Kind: adapter.MediaFile, Name: "x.pdf"},
{Kind: adapter.MediaKind("hologram"), URL: "http://cdn/x.holo"},
},
})
if err != nil {
t.Fatalf("outboundSegments error: %v", err)
}
want := []obSegment{
{Type: "text", Data: map[string]any{"text": "pong"}},
{Type: "image", Data: map[string]any{"file": "http://cdn/x.jpg"}},
{Type: "video", Data: map[string]any{"file": "http://cdn/x.mp4"}},
{Type: "file", Data: map[string]any{"file": "x.pdf"}},
}
if len(segs) != len(want) {
t.Fatalf("segs = %+v, want %+v(未知 Kind 必须静默跳过)", segs, want)
}
for i := range want {
if segs[i].Type != want[i].Type {
t.Errorf("segs[%d].Type = %q, want %q", i, segs[i].Type, want[i].Type)
}
if len(segs[i].Data) != len(want[i].Data) {
t.Errorf("segs[%d].Data = %+v, want %+v", i, segs[i].Data, want[i].Data)
}
for k, v := range want[i].Data {
if segs[i].Data[k] != v {
t.Errorf("segs[%d].Data[%q] = %v, want %v", i, k, segs[i].Data[k], v)
}
}
}
}
// TestServerMediaPathPrefixMatchesVoicePackage 是两处字面量的交叉断言:插件里那份
// `serverMediaPathPrefix` 必须与框架唯一的派生点(`voice.MediaPathPrefix`)同值,
// 否则「拒绝服务端相对路径」这条裁决会在框架换前缀后静默失效。
func TestServerMediaPathPrefixMatchesVoicePackage(t *testing.T) {
t.Parallel()
if serverMediaPathPrefix != voiceref.MediaPathPrefix {
t.Fatalf("serverMediaPathPrefix = %q, want %q(与 voice.MediaPathPrefix 必须同值)",
serverMediaPathPrefix, voiceref.MediaPathPrefix)
}
}