diff --git a/internal/runtime/loop/loop.go b/internal/runtime/loop/loop.go index 574dc6c..9afd957 100644 --- a/internal/runtime/loop/loop.go +++ b/internal/runtime/loop/loop.go @@ -85,6 +85,11 @@ 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 diff --git a/internal/runtime/loop/voice.go b/internal/runtime/loop/voice.go new file mode 100644 index 0000000..3d333e8 --- /dev/null +++ b/internal/runtime/loop/voice.go @@ -0,0 +1,40 @@ +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 +}