feat(channels): IM 车道生成并写入真实 turn_id(user/assistant 成对 + 同源 WithTurnID;群聊隔离与静默语义不变)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

This commit is contained in:
AskaEth committed 2026-10-05 12:22:44 +08:00
1 parent ba1974038a
commit 8f6a75b7d7
9 files changed
+215 -21

No files matched your search

+17 -7
View File
@@ -263,6 +263,13 @@ func (b *Bridge) runInboundTurn(ctx context.Context, msg adapter.UnifiedMessage,
// (同一条用户消息上两条都能挂)。它没有 ctx —— 这里没有任何 I/O(字节由适配器取回)。
images := b.ingestInboundImages(msg)
// 本回合的**权威 turn id**:IM 车道没有编排器,桥接器自己就是这条车道的回合
// 权威(turnid.go)。它在落库**之前**生成,因此同一次入站回合的用户消息、助手
// 回复带上的是同一个值,前端按 (sessionId, turnId) 两级分桶时才分得进同一个回合
// —— 这正是此前缺口所在:IM 的落库行没有 turn id,任何消费方都无法判定「这条
// 回复属于哪个回合」。它同时挂到 loop ctx 上,使循环发出的流帧与历史行同源。
turnID := newTurnID()
// 第三方边界只在受限(群聊/陌生人)上下文中生效:
// 记忆只读(MemWritable=false),并携带发送者名,
// 使 agent 能看到是谁在说话(design/16 §4.6 §1-2)。
@@ -295,12 +302,12 @@ func (b *Bridge) runInboundTurn(ctx context.Context, msg adapter.UnifiedMessage,
// 无论有没有文本都要落库:附件/图片就是纯媒体消息的内容(design/24 §4.4)。
var userMsg history.Message
if target.private {
userMsg = b.persistToMain(ctx, history.RoleUser, text, "", document, image, target.platform, deviceIDFor(msg), adapterName(a))
userMsg = b.persistToMain(ctx, history.RoleUser, text, "", document, image, target.platform, deviceIDFor(msg), adapterName(a), turnID)
} else {
userMsg = b.persistIsolated(ctx, history.RoleUser, text, document, image, deviceIDFor(msg), sess, msg.Platform, senderName, adapterName(a))
userMsg = b.persistIsolated(ctx, history.RoleUser, text, document, image, deviceIDFor(msg), sess, msg.Platform, senderName, adapterName(a), turnID)
}
res, err := b.run.Run(loop.WithSessionEnv(ctx, runEnv), target.sessionID, loopMsgs)
res, err := b.run.Run(loop.WithTurnID(loop.WithSessionEnv(ctx, runEnv), turnID), target.sessionID, loopMsgs)
if err != nil {
b.log.Warn("agent run failed", "platform", msg.Platform,
"session", target.sessionID, "error", err)
@@ -312,7 +319,7 @@ func (b *Bridge) runInboundTurn(ctx context.Context, msg adapter.UnifiedMessage,
// 出错也交给 deliverReply:回合不能因为「模型那边出了问题」就对用户静默
// (空回复 / 轮次用尽 / 内部报错都落一条诚实的可见回复;被取消的回合除外,
// 见 turnreply.go)。
b.deliverReply(ctx, msg, a, sess, target, res, err)
b.deliverReply(ctx, msg, a, sess, target, res, err, turnID)
}
// deliverReply 解析本回合的可见回复、持久化并经接收该消息的适配器
@@ -321,8 +328,11 @@ func (b *Bridge) runInboundTurn(ctx context.Context, msg adapter.UnifiedMessage,
// IM 回合因此绝不会「没声了」;被取消的回合刻意不交付。不支持出站文本的适配器
// (adapter.ErrUnsupported)只按 debug 级跳过,不算失败:回复
// 已在历史存储中落地。
//
// turnID 是这次入站回合的权威标识(见 runInboundTurn):助手回复与刚落的用户消息
// 传同一个值,落库后两条行同属一路,客户端据此配对「回复 ↔ 提问」。
func (b *Bridge) deliverReply(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter,
sess session.Session, target inboundTarget, res *loop.Result, runErr error) {
sess session.Session, target inboundTarget, res *loop.Result, runErr error, turnID string) {
reply, ok := turnVisibleReply(ctx, res, runErr)
if !ok {
b.log.Debug("turn reply canceled, nothing delivered",
@@ -339,9 +349,9 @@ func (b *Bridge) deliverReply(ctx context.Context, msg adapter.UnifiedMessage, a
// 广播;受限 → 隔离日志 + channel_persisted 广播),随后
// 送回渠道。
if target.private {
b.persistToMain(ctx, history.RoleAssistant, reply, mood, "", "", target.platform, deviceIDFor(msg), adapterName(a))
b.persistToMain(ctx, history.RoleAssistant, reply, mood, "", "", target.platform, deviceIDFor(msg), adapterName(a), turnID)
} else {
b.persistIsolated(ctx, history.RoleAssistant, reply, "", "", "", sess, msg.Platform, "", adapterName(a))
b.persistIsolated(ctx, history.RoleAssistant, reply, "", "", "", sess, msg.Platform, "", adapterName(a), turnID)
}
if err := b.sendReply(ctx, msg, a, reply); err != nil {
if errors.Is(err, adapter.ErrUnsupported) {