package channelbridge import ( "context" "errors" "strings" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/turnreply" ) // 本文件承载 IM 回合的「可见回复」判定(切片 KB1 的渠道侧一半)。 // // 缺口形状:网关侧已保证「归本代次、且不是被用户打断的回合,历史里一定有一条 // 可见的 assistant 消息」(gateway/session_turn_reply.go),但 IM 走的是**另一条 // 路** —— 渠道桥自己驱动 loop 并自行落库,过去的 deliverReply 遇到空文本直接 // return,于是模型给空回复(只有思维链、没有正文)时,IM 侧就是「她没声了」: // 既没有出站消息,也没有任何说明。轮次用尽、安全网终止、回合内部报错这三条出口 // 同样静默。 // // 文案与判定口径与网关**逐字一致**(同一份文案来自共享包 // internal/runtime/turnreply),但「该不该交付」的边界由渠道桥自己的编排状态决定: // IM 没有网关那套会话代次(interrupt / 续跑队列是 Web 会话的概念),这里的取消 // 判据是**处理上下文被取消** —— 桥接器 Stop 会取消 b.ctx,在途回合随之取消, // 用户意图就是「停下」,此时补一句「我出问题了」反而违背那条指令。 // turnVisibleReply 返回本回合应当交付给渠道的可见回复:reply 为要交付(并落库) // 的文本,ok=false 表示这个回合**刻意什么都不交付**(被取消)。 // // 与网关 persistTurnReply 同构:有真实文本就交付真实文本(连被取消的回合也照旧 // 交付 —— 内容是真的,与「不静默」无关);没有可见文本时,只有「未被取消」才补 // 一条按结束原因区分的诚实说明。 // // 真实文本按渠道既有行为去掉首尾空白后交付(内容一字不改),与加入本保证之前 // 的行为完全一致 —— **唯一例外**是与网关同一次(同一份)的段落包裹规整 // (turnreply.NormalizeParagraphs):它给未被 `

` 包裹的段落补上包裹, // 于是**落库文本**(主人私聊会镜像进 Web 主会话)与**出站气泡切分** // (splitBubbles 按 `

` 切)吃的是同一份文本,客户端那侧「整条塌成一段」 // 的同一条判据在 IM 侧不会再出现第二个答案。 func turnVisibleReply(ctx context.Context, res *loop.Result, runErr error) (string, bool) { text := turnreply.Text(res) if res != nil && res.Text == text { return strings.TrimSpace(turnreply.NormalizeParagraphs(res.Text)), true } if errors.Is(runErr, context.Canceled) || ctx.Err() != nil { return "", false } return text, true }