- 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 测试(含变异打红验证时序)
174 lines
8.2 KiB
Go
174 lines
8.2 KiB
Go
package channelbridge
|
||
|
||
// coalesce_batch.go 承载「**一条批 = 一次回合**」的落地:一个 turnID、一次 loop.Run、
|
||
// 一条助手回复,但批内 N 条仍是**各自独立的 user 行**(design/15 §2.1 明文禁止把多条
|
||
// 合并成一条:open-webui 曾把多条拼成一条,被当 bug 修过)。
|
||
//
|
||
// 形状(对 M22 的 runInboundTurn 逐字保持,只是把「一条」换成「一对多」):
|
||
//
|
||
// [来源/边界系统行(首条)] …转录… [user₁, user₂, …, userₙ] ← 模型可见
|
||
// chat_messages: user×N(同一 turnID、到达顺序)+ assistant×1(同 turnID)
|
||
//
|
||
// 三条口径:
|
||
// 1. **转录播种在落库之前**(与 M22 同序):因此本批自己的行绝不会同时出现在转录与
|
||
// 当次消息里;批内每条都只出现一次(不重)。
|
||
// 2. **回复路由取批内最后一条**(`sendReply` 只认 Platform/ChannelID/GroupID/SenderID):
|
||
// 同一车道的消息属于同一个对话,用最新那条的路由字段回话是唯一不会回错群的口径。
|
||
// 3. **附属内容各自挂各自的消息**:每条消息的附件/图片/语音只挂它自己那条 user 行,
|
||
// 绝不合并成一条附件清单(用户内容只进用户角色消息,项目红线)。
|
||
|
||
import (
|
||
"context"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||
voiceref "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice"
|
||
)
|
||
|
||
// batchMessage 是一条入站消息在本批里**已装配好、尚未落库**的产物。
|
||
type batchMessage struct {
|
||
prepared *preparedInbound
|
||
senderName string
|
||
attachments inboundContent
|
||
images inboundContent
|
||
voiceRef voiceref.Ref
|
||
document string
|
||
image string
|
||
msgs []model.Message
|
||
}
|
||
|
||
// runBatch 跑一批:**一个 turnID、一次 Run、一条助手回复**。批内 N 条消息各自落一条
|
||
// user 行(共享 turnID、顺序 = 到达顺序),助手回复与它们同 turnID。
|
||
//
|
||
// 它同时是「一条消息」的路径(batch 只有一个成员时逐字等于 M22 的 runInboundTurn):
|
||
// 主人私聊、以及 `process()` 的同步便捷路径都走这里。
|
||
func (b *Bridge) runBatch(ctx context.Context, batch []preparedInbound) {
|
||
if len(batch) == 0 {
|
||
return
|
||
}
|
||
last := &batch[len(batch)-1]
|
||
// 陌生人私聊的「同一发送者同时最多 1 个在跑回合」槽:一批 = 一个回合 = 一个槽。
|
||
if release := b.acquireStrangerTurn(last.item.msg, last.sess, last.item.a); release != nil {
|
||
defer release()
|
||
}
|
||
target := inboundTargetFor(last.sess, last.item.msg.Platform)
|
||
turnID := newTurnID()
|
||
runEnv := turnEnv(batch[0].sess, target,
|
||
b.resolvePersona(ctx, batch[0].sess, target, adapterName(batch[0].item.a)))
|
||
|
||
parts, msgs := b.buildBatchContext(ctx, batch, runEnv)
|
||
// 转录在落库**之前**读(M22 的既有保证):本批自己的行因此不会在上下文里出现两次。
|
||
msgs = b.seedTurnTranscript(ctx, target, msgs)
|
||
|
||
rows := b.persistBatchUsers(ctx, parts, target, turnID)
|
||
res, err := b.run.Run(loop.WithTurnID(loop.WithSessionEnv(ctx, runEnv), turnID), target.sessionID, msgs)
|
||
if err != nil {
|
||
b.log.Warn("agent run failed", "platform", last.item.msg.Platform,
|
||
"session", target.sessionID, "messages", len(batch), "error", err)
|
||
}
|
||
// 抽取结论(status/chars/truncated/reason)只能在回合跑起来之后才有,这里按
|
||
// 服务端存储名把结论补回刚落库的那一行(design/24 §6.1 的两段式)。
|
||
for _, row := range rows {
|
||
b.persistTurnDocumentStatus(row, res)
|
||
}
|
||
// 出错也交给 deliverReply:回合不能因为「模型那边出了问题」就对用户静默
|
||
// (空回复 / 轮次用尽 / 内部报错都落一条诚实的可见回复;被取消的回合除外)。
|
||
b.deliverReply(ctx, last.item.msg, last.item.a, last.sess, target, res, err, turnID)
|
||
}
|
||
|
||
// runBatchSegments 按 `solo` 把一批切成若干段:solo(系统通知升级成的真会话)**单独
|
||
// 一轮**,其余相邻消息合并成一轮。切分保持到达顺序,绝不为了多合并而把通知并进聊天。
|
||
//
|
||
// 罕见路径:solo 只在「传入前审查判为高价值事件并升级」时出现,通常一批里没有。
|
||
func (b *Bridge) runBatchSegments(ctx context.Context, kept []preparedInbound) {
|
||
start := 0
|
||
for i := range kept {
|
||
if !kept[i].solo {
|
||
continue
|
||
}
|
||
if i > start {
|
||
b.runBatch(ctx, kept[start:i])
|
||
}
|
||
b.runBatch(ctx, kept[i:i+1])
|
||
start = i + 1
|
||
}
|
||
if start < len(kept) {
|
||
b.runBatch(ctx, kept[start:])
|
||
}
|
||
}
|
||
|
||
// buildBatchContext 装配本批的模型可见消息:逐条摄取附件/语音/图片、构建用户消息、
|
||
// 只保留首条的系统框架行,并把每条消息的附属内容挂到**它自己**那条 user 行上。
|
||
func (b *Bridge) buildBatchContext(ctx context.Context, batch []preparedInbound, runEnv loop.SessionEnv) ([]batchMessage, []model.Message) {
|
||
parts := make([]batchMessage, 0, len(batch))
|
||
var msgs []model.Message
|
||
for i := range batch {
|
||
p := &batch[i]
|
||
msg := p.item.msg
|
||
// 入站附件摄取留在**闸门之后**(走到这里意味着这条已被放行):被黑白名单或
|
||
// 最小打扰拦下的消息绝不在盘上留下文件。
|
||
part := batchMessage{
|
||
prepared: p,
|
||
senderName: thirdPartySenderName(msg, p.item.actor),
|
||
attachments: b.ingestInboundDocuments(ctx, msg),
|
||
images: b.ingestInboundImages(msg),
|
||
voiceRef: b.ingestInboundVoiceCopy(ctx, msg, p.voiceIn),
|
||
}
|
||
part.msgs = b.buildLoopMessages(ctx, p.text, runEnv, p.item.actor, part.senderName, msg)
|
||
if i > 0 {
|
||
// 系统框架(来源行 + 第三方身份边界)每轮只出现一次:后续消息只贡献自己的
|
||
// user 行,否则上下文里会重复 N 遍同一段边界。
|
||
part.msgs = dropLeadingSystem(part.msgs)
|
||
}
|
||
attachInboundContent(part.msgs, part.attachments, part.images)
|
||
part.document = b.inboundDocumentColumn(msg, part.attachments)
|
||
part.image = inboundImageColumn(part.images)
|
||
parts = append(parts, part)
|
||
msgs = append(msgs, part.msgs...)
|
||
}
|
||
return parts, msgs
|
||
}
|
||
|
||
// dropLeadingSystem 去掉消息列表最前面的系统行(批内第 2..N 条用)。
|
||
func dropLeadingSystem(msgs []model.Message) []model.Message {
|
||
i := 0
|
||
for i < len(msgs) && msgs[i].Role == model.RoleSystem {
|
||
i++
|
||
}
|
||
return msgs[i:]
|
||
}
|
||
|
||
// persistBatchUsers 按**到达顺序**逐条落 user 行(共享 turnID),并把语音事实与用户
|
||
// 消息同源落库(design/24 §10.2 第 5 步)。返回刚落库的行,供回合结束后补抽取结论。
|
||
//
|
||
// 🔴 顺序(M44,主人 2026-10-26 指令):通知 = **先写 `voice` 列、再广播**。此前是
|
||
// 「先广播、后写列」,于是这条语音消息的**第一帧**(`message_persisted` /
|
||
// `channel_persisted`)里读不到 voice,客户端得靠重连或下一次 REST 拉取才升级 ——
|
||
// 而「实时帧一到就出播放器」正是本切片的诉求。换序后帧发出时列已写好。
|
||
//
|
||
// 之所以不能把写列真正挪到落库**之前**:`persistMessageVoice` 是按 id 的 UPDATE,
|
||
// 行还不存在时它必然 ErrMessageNotFound(`voice` 列也没有随 INSERT 写入的通道,
|
||
// 见 design/24 §10.5 的两种口径)。因此可行的最小形态就是「INSERT → UPDATE 列 →
|
||
// notify」——语句数不变,只把 notify 从 INSERT 内部挪到最后。
|
||
func (b *Bridge) persistBatchUsers(ctx context.Context, parts []batchMessage, target inboundTarget, turnID string) []history.Message {
|
||
rows := make([]history.Message, 0, len(parts))
|
||
for i := range parts {
|
||
p := &parts[i]
|
||
msg := p.prepared.item.msg
|
||
a := p.prepared.item.a
|
||
// 非语音消息(p.voiceRef 零值)时这个包装与 notifyPersisted 逐字等价。
|
||
notify := b.persistVoiceFirstNotify(p.voiceRef)
|
||
var row history.Message
|
||
if target.private {
|
||
row = b.persistMainWith(ctx, mainRow(history.RoleUser, p.prepared.text, "", p.document, p.image,
|
||
target.platform, deviceIDFor(msg), adapterName(a), turnID), notify)
|
||
} else {
|
||
row = b.persistIsolatedWith(ctx, isolatedRow(history.RoleUser, p.prepared.text, p.document, p.image,
|
||
deviceIDFor(msg), p.prepared.sess, msg.Platform, p.senderName, adapterName(a), turnID), notify)
|
||
}
|
||
rows = append(rows, row)
|
||
}
|
||
return rows
|
||
}
|