- G5:pending_full 增 clientMsgId/messageId(仅在可给出时出现、绝不新铸 id —— 队列满的拒绝发生在幂等键认领之前,代铸会让客户端重试被判重复而永远进不来);另定向回发送者 {type:error,data:{code:chat_busy,...}}(与 REST 409 chat_busy 同码),广播帧照旧带同样的键供其它端对齐。
- C1:语音回合改走与文本同一条路径(编排器 Submit → 落库 → 流式 → 跨端同步),deviceId 走既有 withDeviceID、platform 走存储默认 web;未接编排器时保留旧直连路径。音频在回合结算处(persistAssistantReply → persistDocumentStatus → dispatchVoiceReply)合成并定向回原连接,回调各自 goroutine(TTS 延迟不拖住 pending_drain/session_idle);续跑 FIFO 与转向注入两条路径都带走回调 ⇒ 忙时排队/被注入的语音也不丢音频。
- C3:voice_output 增 sessionId/deviceId;三处主动加码(按最优方向):① voice_input 增可选 clientMsgId(语音也有幂等)② 不再拿 stopReason 顶替空正文当语音内容(空正文走 provider 降级信号)③ TTS 失败照旧回帧 + provider:silent(不静默)。
- 拆 chat_documents_test.go(510 行越界)→ 373 + 160,只搬不改(拆前后同 -run 过滤 PASS 集合逐条一致)。
- 主会话独立验收:隔离 worktree(HEAD + 本批次 11 文件)build/vet 绿、gateway -race 绿、全树 59 包(唯一红是既有负载 flake orchestration/TestSpawnTimeoutFromToolDefault)、linux+darwin 交叉编译绿;变异独立复现(pending_full 摘掉 clientMsgId ⇒ 3 条红)。
- 待办(已登记):Docs/api-client.md 与 api-reference.md 的帧/字段登记随 v0.59 一并落笔(本批次刻意未动 Docs,避免与契约批次打架)。
112 lines
4.7 KiB
Go
112 lines
4.7 KiB
Go
package gateway
|
||
|
||
import (
|
||
"context"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||
)
|
||
|
||
// 本文件归属协同项 C1 的「语音回合收尾」:把一次语音回合的结果交回语音层,
|
||
// 由它合成音频并**定向**回发起这条语音的连接。
|
||
//
|
||
// 语音回合现在与文本回合走同一条路(进编排器 → 落库 → 流式 → 跨端同步),
|
||
// 因此音频回复不能还在提交那一刻同步等待 —— 回合是异步完成的(可能排队、
|
||
// 可能被中断)。编排器唯一知道「这个回合真正产出了什么」的地方是回合结算处
|
||
// (completeTurn,与 S3.5 回写文档结论同一收尾点),所以收尾回调在那里触发。
|
||
//
|
||
// 与 session_reject.go 同一纪律:编排器对传输层一无所知,「怎么把帧送回那根
|
||
// 连接」由 WS 边界在 ctx 上给出。
|
||
|
||
// VoiceReplyFunc 是语音回合的收尾回调:回合结算后,编排器把该回合的结果交给
|
||
// 它(res 可能为 nil —— 被取消或出错的回合没有结果)。
|
||
type VoiceReplyFunc func(res *loop.Result)
|
||
|
||
// voiceReplyKey 是携带语音收尾回调的 context 键。它从 WS 帧边界
|
||
// (voice.go 的 runVoiceTurn)引入编排器,与 deviceID / 幂等键同一种机制。
|
||
type voiceReplyKey struct{}
|
||
|
||
// withVoiceReplySink 返回携带语音收尾回调的 context。nil 回调是空操作:文本
|
||
// 回合(以及未接线传输层的提交)在 ctx 上不留任何痕迹。
|
||
func withVoiceReplySink(ctx context.Context, sink VoiceReplyFunc) context.Context {
|
||
if sink == nil {
|
||
return ctx
|
||
}
|
||
return context.WithValue(ctx, voiceReplyKey{}, sink)
|
||
}
|
||
|
||
// voiceReplySinkFrom 返回 ctx 携带的语音收尾回调,未设置时为 nil。
|
||
func voiceReplySinkFrom(ctx context.Context) VoiceReplyFunc {
|
||
sink, _ := ctx.Value(voiceReplyKey{}).(VoiceReplyFunc)
|
||
return sink
|
||
}
|
||
|
||
// voiceTurn 配对一次语音回合与它的收尾回调(按代次配对,与 turnDocuments
|
||
// 同理):被打断或被新回合取代的陈旧回合在结算时不该替别人回音频。
|
||
//
|
||
// sinks 是切片,因为一个回合可能同时有**多条**语音请求在等音频:忙时入 pending
|
||
// 的语音消息可能被转向注入(design/15 §2.4,见 session_steer.go 的 consumePending)
|
||
// 而由同一个回合作答 —— 它的音频必须由这个回合结算时一并送回,否则那条请求会
|
||
// 静默地收不到声音(音频回复不能丢)。
|
||
type voiceTurn struct {
|
||
gen uint64
|
||
sinks []VoiceReplyFunc
|
||
}
|
||
|
||
// voiceSinksOf 把单个回调收成切片(nil 回调 → nil 切片)。
|
||
func voiceSinksOf(sink VoiceReplyFunc) []VoiceReplyFunc {
|
||
if sink == nil {
|
||
return nil
|
||
}
|
||
return []VoiceReplyFunc{sink}
|
||
}
|
||
|
||
// rememberVoiceTurn 记下本回合的语音收尾回调。**每个新回合都必须调用它一次**
|
||
// (纯文本回合记 nil):否则一个陈旧的语音回调会被沿用到后面的文本回合上,
|
||
// 让一次文本问答无端合成一段音频。
|
||
func (o *Orchestrator) rememberVoiceTurn(sessionID string, gen uint64, sink VoiceReplyFunc) {
|
||
o.mu.Lock()
|
||
defer o.mu.Unlock()
|
||
if st := o.states[sessionID]; st != nil {
|
||
st.voiceTurn = voiceTurn{gen: gen, sinks: voiceSinksOf(sink)}
|
||
}
|
||
}
|
||
|
||
// addVoiceSinkLocked 把一条**被转向注入**的语音请求的收尾回调追加到当前回合上。
|
||
// 必须在 o.mu 下调用;代次不符(那条请求不属于本回合)时什么都不做。
|
||
func (o *Orchestrator) addVoiceSinkLocked(st *sessionState, gen uint64, sink VoiceReplyFunc) {
|
||
if st == nil || sink == nil || st.voiceTurn.gen != gen {
|
||
return
|
||
}
|
||
st.voiceTurn.sinks = append(st.voiceTurn.sinks, sink)
|
||
}
|
||
|
||
// takeVoiceTurn 取走与 gen 配对的所有语音收尾回调并清空记录。第二个返回值为
|
||
// false 表示代次已陈旧(中断/取代):迟到的结算不该回音频。
|
||
func (o *Orchestrator) takeVoiceTurn(sessionID string, gen uint64) ([]VoiceReplyFunc, bool) {
|
||
o.mu.Lock()
|
||
defer o.mu.Unlock()
|
||
st := o.states[sessionID]
|
||
if st == nil || st.voiceTurn.gen != gen {
|
||
return nil, false
|
||
}
|
||
sinks := st.voiceTurn.sinks
|
||
st.voiceTurn = voiceTurn{}
|
||
return sinks, true
|
||
}
|
||
|
||
// dispatchVoiceReply 在回合结算时把结果交回语音层(协同 C1)。不是语音回合时
|
||
// 是空操作;被取代的陈旧回合同样什么都不做。
|
||
//
|
||
// 每个回调在自己的 goroutine 上跑:TTS 是网络调用,绝不许它拖住编排器的状态迁移
|
||
// (助手回复此刻已经落库,音频是它之后的增强;pending 续跑与 session_idle
|
||
// 不该等一段语音合成)。
|
||
func (o *Orchestrator) dispatchVoiceReply(sessionID string, gen uint64, res *loop.Result) {
|
||
sinks, ok := o.takeVoiceTurn(sessionID, gen)
|
||
if !ok {
|
||
return
|
||
}
|
||
for _, sink := range sinks {
|
||
go sink(res)
|
||
}
|
||
}
|