feat(channelbridge): 出站语音结论落 voice 列并挂出站 Media(M58)

- voice.Ref 增 SourceTTS 与 FallbackReason,Validate/IsZero/validSource 同步
- 出站成功:INSERT -> 补写 voice 列 -> notify(首帧即带 voice)
- 出站降级:source=tts + status=failed + fallbackReason,正文原样
- 首条气泡挂出站 Media(新开条目,不复制 target.Media)
- gateway historyVoice 投影 fallbackReason
- 新增 outbound_voice.go 与整链 e2e 测试(含变异打红验证时序)
This commit is contained in:
AskaEth committed 2026-10-27 22:08:38 +08:00
1 parent 9f6243a729
commit 28afa11aeb
13 files changed
+755 -44

No files matched your search

+19 -3
View File
@@ -193,15 +193,31 @@ func (b *Bridge) deliverReply(ctx context.Context, msg adapter.UnifiedMessage, a
if res != nil {
mood = res.Mood
}
// 出站语音(design/25 §7.1):先把结算结论折成 voice 列事实,再让落库那条路走
// 「INSERT → 写 voice 列 → notify」(M44 首帧纪律)—— 与入站语音共用同一份
// persistVoiceFirstNotify,因此首帧上就带着 `voice`,客户端不必等重连。
ref, hasVoice := b.outboundVoiceRef(resultVoice(res))
notify := b.notifyPersisted
if hasVoice {
notify = b.persistVoiceFirstNotify(ref)
}
// 持久化助手回复(主人私聊 → 主会话 + message_persisted
// 广播;受限 → 隔离日志 + channel_persisted 广播),随后
// 送回渠道。
if target.private {
b.persistToMain(ctx, history.RoleAssistant, reply, mood, "", "", target.platform, deviceIDFor(msg), adapterName(a), turnID)
b.persistMainWith(ctx, mainRow(history.RoleAssistant, reply, mood, "", "", target.platform, deviceIDFor(msg), adapterName(a), turnID), notify)
} else {
b.persistIsolated(ctx, history.RoleAssistant, reply, "", "", "", sess, msg.Platform, "", adapterName(a), turnID)
b.persistIsolatedWith(ctx, isolatedRow(history.RoleAssistant, reply, "", "", "", sess, msg.Platform, "", adapterName(a), turnID), notify)
}
if err := b.sendReply(ctx, msg, a, reply); err != nil {
// 音频只在合成成功、且盘上那份副本读得回来时才挂上;读不回来就诚实退回纯文本
// (正文照发,WARN 已由 outboundVoiceMedia 记下)。
var voiceMedia *adapter.Media
if hasVoice {
if md, ok := b.outboundVoiceMedia(msg, ref); ok {
voiceMedia = &md
}
}
if err := b.sendReplyVoice(ctx, msg, a, reply, voiceMedia); err != nil {
if errors.Is(err, adapter.ErrUnsupported) {
b.log.Debug("channel does not support outbound reply, skipped",
"platform", msg.Platform, "session", target.sessionID)