主人 2026-10-23/24:「要让群聊聊天很自然,很流畅的做法」。群里「一鼓作气打三句」 此前会让她回三次;本片在**每会话车道头部**加一段静默等待,把这几句合成**一轮**回答 (批内每条仍是独立的一条消息,design/15 §2.1 不许拼成一条)。 四条不可走样: - 🔴 主人私聊**结构性**无窗口:车道分 `newDirectLane`(无 batcher 字段、拿不到定时器 工厂)与 `newBatchLane`,类型由 actor 元数据(IsOwner() && !IsGroupChat())决定, 配置无权翻转; - 🔴 窗口内零丢失:唯一容器是车道队列(takeAll 单消费者取出),合并阶段无新增丢弃点 (CoalescedDroppedTotal 恒 0);超单批上限的**尾部回插队首**保序、锁内、发车前; - 🔴 一条批 = 一个 turnID = 一次 loop.Run = 一条助手回复; - 🔴 不改「回什么」:闸门仍逐条跑(prepareInbound 逐字搬运 M22 的 processFrom), 被静默的消息进不了批。 实现落点: - lane.go:direct/batch 两种车道 + front(回插队首)+ takeAll + 停机诚实登记; - coalesce.go:生效参数解析、滑动窗口 + 绝对上限等待、发车原因与计数; - coalesce_lane.go:攒批 → 拆分 → 陈旧度/闸门 → 发车; - coalesce_prepare.go / coalesce_batch.go:processFrom 拆成 prepareInbound + runBatch; - runtimeconfig/channels_coalesce.go:$DATA_DIR/config/coalesce.json(零新 .env 键); - gateway/admin_coalesce.go:GET/PUT /api/v1/admin/channels/coalesce(三态补丁 + writeAuth + 审计 channels.coalesce + 热生效),routes.go 两行注册(塔已批); - 前端:渠道总览页一张卡(六个旋钮;主人私聊只给只读说明,不给开关)。 M22 既有用例仍在(lane_test.go 夹具显式关窗以钉住车道本身);stranger_test.go 的那条 按塔裁定 #4 改写:「已有回合在跑 ⇒ 只登记不回」变成排队回答(留 WARN,绝不静默)。 回归:go build/vet/test ./... 全绿;三包 -race 全绿;前端 tsc --noEmit + npm run build + 渲染测试 36 项通过。不 push、不重启后端、未改 .env 与 Docs/。
163 lines
7.2 KiB
Go
163 lines
7.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 步)。返回刚落库的行,供回合结束后补抽取结论。
|
||
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
|
||
var row history.Message
|
||
if target.private {
|
||
row = b.persistToMain(ctx, history.RoleUser, p.prepared.text, "", p.document, p.image,
|
||
target.platform, deviceIDFor(msg), adapterName(a), turnID)
|
||
} else {
|
||
row = b.persistIsolated(ctx, history.RoleUser, p.prepared.text, p.document, p.image,
|
||
deviceIDFor(msg), p.prepared.sess, msg.Platform, p.senderName, adapterName(a), turnID)
|
||
}
|
||
b.persistMessageVoice(row, p.voiceRef)
|
||
rows = append(rows, row)
|
||
}
|
||
return rows
|
||
}
|