package channelbridge import ( "context" "errors" "strings" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session" "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" ) // 本文件承载渠道桥接器的入站路径(design/08 §1-§2):按适配器的 // 读取循环、有界的入站回调,以及完整的 // 会话→actor→循环→回复 流水线(含结构化的身份边界)。 // readLoop 运行适配器的阻塞式读取路径,直到桥接器停止或 // 适配器自身的 context 被取消(热重载)。emit 汇聚点接到 // onMessage,因此轮询式适配器(Telegram getUpdates、weixin 长轮询) // 与推送式适配器走同一条流水线投递。消息处理 // 使用桥接器生命周期 context(而非 readCtx),因此重载不会打扰 // 在途消息。 // // 读取路径返回而 readCtx 仍存活时,说明适配器自己停止了 // 读取——目前就是 weixin-iLink 拒绝会话 // (errcode=-14,见 plugins/channels/weixin_ilink/session_state.go),过去会 // 让桥接器仍声称渠道已连接,实际却再也 // 投递不出任何东西。readCtx 被取消属于刻意停止(桥接器 Stop 或 // 该名称的热重载),由取消者处理。 func (b *Bridge) readLoop(a adapter.PlatformAdapter, readCtx context.Context) { defer b.wg.Done() a.ReadMessages(readCtx, func(msg adapter.UnifiedMessage) { b.onMessage(b.ctx, msg, a) }) if readCtx.Err() == nil { b.markReadStopped(a) } } // markReadStopped 记录适配器的读取循环自行结束、且适配器 // 报告没有存活会话,使按名称的统计(IsConnected / // ConnectedCount)不再声称一个投不出消息的渠道可用。它 // 只清除标记——绝不设置——因此不会让已连接的渠道显得 // 存活;适配器仍报告存活会话时它是空操作(已发生的 // 重连绝不会被更早的循环退出所撤销)。 func (b *Bridge) markReadStopped(a adapter.PlatformAdapter) { if a.IsConnected() { return } name := a.Name() b.mu.Lock() defer b.mu.Unlock() if !b.connected[name] { return } b.connected[name] = false b.log.Warn("channel read loop ended, adapter is no longer connected", "channel", name) } // onMessage 是面向适配器的入站回调。它在有界 goroutine 中运行 // 流水线,使适配器的读取循环不会因缓慢的 agent 轮次而阻塞。 // 适配器被显式携带,使流水线知道是哪个账号收到了 // 消息(多账号回复路由 + 按账号允许/拒绝,design/16 // §4.6 §5)。 func (b *Bridge) onMessage(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter) { b.mu.Lock() if b.closed { b.mu.Unlock() return } b.mu.Unlock() if err := msg.Validate(); err != nil { b.log.Warn("inbound message invalid, dropped", "platform", msg.Platform, "error", err) return } // design/12 §6 策略三/四:按适配器的最小打扰闸门。主人私聊 // 消息完全绕过它(主人永不被限流或裁剪);其余 // 消息先过滑动窗口限流(策略三),再过有界的 // 待处理集合——集合饱和会触发审查模型裁剪噪声(策略四)。 // 被丢弃的消息经闸门的 OnPruned 钩子(接到 // OnRejected)入审计,绝不进入流水线,因此洪泛无法压垮循环。 actor := session.ResolveActor(msg, b.owner) if !actor.IsOwner() { verdict := b.gateFor(a).Admit(ctx, msg) if !verdict.Proceed { b.log.Warn("inbound message dropped by flow gate", "platform", msg.Platform, "channel", msg.ChannelID, "reason", verdict.Reason, "dropped", len(verdict.Pruned)) return } } select { case b.sem <- struct{}{}: case <-ctx.Done(): return } // 在 Stop 设置 closed 所用的同一把锁下 Add 工作项,使 // 并发的 Stop 要么在等待前看到我们的 Add,要么我们 // 观察到 closed 后退出,不泄露 goroutine。 b.mu.Lock() if b.closed { b.mu.Unlock() <-b.sem return } b.wg.Add(1) b.mu.Unlock() go func() { defer b.wg.Done() defer func() { <-b.sem }() b.processFrom(ctx, msg, a) }() } // process 用平台与消息匹配的适配器运行入站流水线 // (单账号便捷路径;该适配器可以是仅以平台名 // 注册的裸适配器)。 func (b *Bridge) process(ctx context.Context, msg adapter.UnifiedMessage) { b.processFrom(ctx, msg, b.adapter(msg.Platform)) } // processFrom 针对指定的账号适配器运行完整入站流水线: // 解析会话/actor、应用允许/拒绝黑白名单、以聊天文本 // 驱动 agent 循环,并把循环的回复经接收该消息的同一 // 适配器送回去。身份由会话层结构化解析; // 桥接器绝不读取消息内容来判定发送者是谁 // (项目红线)。 func (b *Bridge) processFrom(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter) { // 语音 → 文本归一是入站流水线的**第一件事**(切片 IM-ASR;design/06 §1.1): // 它必须在「丢空判据」之前发生,否则 telegram 的纯语音(无 caption ⇒ Content // 为空)会在下面被整条丢掉。归一写回 msg.Content 之后,最小打扰判定、落库与 // 模型可见消息看到的都是**同一份**文本(转写结果,或一行诚实的失败提示)。 // // 它同时给出这条语音的**来源与三态结论**(切片 CODE-VOICE;design/24 §10.2 第 2 步), // 由本函数的调用方一路带到闸门之后的落副本步与落库步 —— 结论与正文同源,绝不重算。 inboundVoice := b.normalizeInboundVoice(ctx, &msg) // design/12 §6 策略一补充:系统通知在进入任何下游之前先过「传入前审查」—— // 归一化 → 剥离异常字段 → 分类 → fail-safe。被判为可忽略(或畸形/未识别)的 // 事件只登记、绝不驱动 loop;被放行的事件已就地换成**剥离后的安全文本**, // 继续走常规流水线(限定注入 / 升级真会话)。 notice := b.preEntryReview(ctx, &msg, a) // design/26 §4/§5(切片 E3):**渠道事件留存**的唯一插入点。 // // 位置说明(与 §5.1 示意图的一行之差):preEntryReview **就是**那一步归一化 // (它调用适配器的 SystemNoticeNormalizer 并净化 SafeText),把 recordEvent 放在 // 它之后一行可以**只归一化一次**,并且 §9.2 第 2 条要求把 NoticeClass 的裁决结果 // 记进日志 —— 那只有这一行之后才有结论。关键性质不变:留存**无条件**,在任何 // 早退分支(仅登记 / 静默 / 升级)之前,且**绝不**决定 loop 走不走。 b.recordEvent(ctx, msg, a, notice) if notice.Applicable && !notice.Proceed { b.registerNoticeOnly(ctx, msg, a, notice) return } text := strings.TrimSpace(msg.Content) // 无文本且无**任何可用媒体** ⇒ 没有可处理的内容。带附件的「纯文件消息」、带语音的 // 「纯语音消息」与带图片的「纯图片消息」(微信/QQ 发图常常没有配文)必须继续往下走: // 附件/语音/图片就是它的内容(design/24 §4.4 的「不许静默丢」;语音那一路的归一已 // 保证它要么有文本、要么有诚实提示,图片那一路的失败也留一行中文诚实提示)。 if text == "" && !hasUsableMedia(msg) { b.log.Debug("inbound message has no chat text, skipped", "platform", msg.Platform, "channel", msg.ChannelID) return } // design/16 §4.6 §6:黑白名单在流水线的最前端执行, // 早于会话解析与循环驱动,因此被拉黑或不在白名单的 // 群/用户消息绝不会到达 LLM。 // 主人始终放行(由解析出的 actor 结构化决定)。 actor := session.ResolveActor(msg, b.owner) if !actor.IsOwner() && b.blacklistDrop(msg, a) { return } sess, err := b.sessions.GetOrCreate(ctx, msg, b.owner) if err != nil { b.log.Warn("session resolve failed", "platform", msg.Platform, "channel", msg.ChannelID, "error", err) return } b.log.Debug("inbound → session", "platform", msg.Platform, "channel", msg.ChannelID, "session", sess.SessionID, "actor_role", sess.ActorRole, "env", sess.Env(), "mem_writable", sess.MemWritable) // design/12 §6 策略一/二 + design/18 §11「乙 = C」(主人 2026-10-13 裁决): // owner 车道不再**无条件短路**这道闸 —— 主人群消息也按结构化判据决定说不说。 // - 主人**私聊/非群**:恒回,不受影响; // - 主人**群聊**:只有指向她本人的(@她 / @全体 / 未接线渠道旧判据)才必回, // 其余**仍进上下文但不触发回复**(落进该群隔离会话,见 ownerlane.go); // - 非主人:走既有的最小打扰分级(shouldRespond)。 // 被拦下的消息只登记到其隔离会话日志并静默——绝不驱动完整 agent 循环。 // // 由「传入前审查」放行的系统通知(notice.Applicable)不在此列:它们已由策略一 // 的归一化/分类**终局判定**(高价值升级真会话、其余限定注入),若再走这道面向 // 聊天消息的闸门,actor=system 会在 isLowValueEvent 处被第二次静默掉。 if !notice.Applicable { if ok, reason := b.shouldRespond(ctx, msg, actor, sess, a); !ok { b.registerSilenced(ctx, msg, actor, sess, a) b.log.Debug("inbound message silenced (minimal disturbance)", "platform", msg.Platform, "channel", msg.ChannelID, "session", sess.SessionID, "actor_role", actor.Role, "reason", reason) return } } // 裁决①的第二个流控(主人 2026-10-05):陌生人私聊虽然一律回答,但 // **同一发送者同时最多 1 个在跑的回合**。槽位复用同一个 flowgate 的 // 会话计数(AcquireTurn/ReleaseTurn,键口径与限流一致);已有回合在跑时 // 这一条只登记、不回,并留可见告警 —— 一个陌生人无法用并发消息占满循环。 if isStrangerPrivate(sess) { if gate := b.gateFor(a); gate != nil { if !gate.AcquireTurn(msg) { b.registerThrottled(ctx, msg, actor, sess, a, "stranger private: a turn is already running for this sender") return } defer gate.ReleaseTurn(msg) } } b.runInboundTurn(ctx, msg, a, text, actor, sess, inboundVoice) } // blacklistDrop 对非主人消息应用黑白名单,返回 // 它是否必须被丢弃(design/16 §4.6 §6)。被丢弃的消息经 // OnRejected 钩子入审计,使该决定在面板中可见。 func (b *Bridge) blacklistDrop(msg adapter.UnifiedMessage, a adapter.PlatformAdapter) bool { denied, reason := b.blacklisted(msg, a) if !denied { return false } b.log.Warn("inbound message blacklisted, dropped", "platform", msg.Platform, "channel", msg.ChannelID, "reason", reason) if b.onRejected != nil { b.onRejected(msg, reason) } return true } // inboundTarget 是一条被放行的入站消息持久化与镜像的去处 // (design/16 §4.4 + design/02 §3):主人私聊被路由进唯一的 // Web 主会话并盖上渠道平台戳,供 Web 客户端镜像, // 而受限的群聊/陌生人会话保留其隔离的 sess-xxx // 会话与自有的平台日志。 type inboundTarget struct { // sessionID 是该轮运行所在的会话。 sessionID string // private 标记镜像进 Web 主会话的主人私聊。 private bool // platform 是镜像写入 chat_messages 的平台标签(仅私聊)。 platform string } // inboundTargetFor 依据结构化解析出的会话与消息的平台 // 解析路由目标。只有非群聊的主人对话才被 // 镜像;其余一律留在其隔离会话中。 func inboundTargetFor(sess session.Session, platform string) inboundTarget { if !sess.IsGroup && sess.Owner { return inboundTarget{sessionID: history.MainSessionID, private: true, platform: platform} } return inboundTarget{sessionID: sess.SessionID} } // runInboundTurn 为被放行的消息驱动 agent 轮次:解析 // 路由目标、把消息携带的文件类附件**落库并挂成文档部件** // (design/24 §3;切片 M1)、把图片媒体内联成 data: URI 部件挂上既有视觉链路 // (切片 IM-IMG;design/24 §4.2 末)、把那条被归一过的语音的**可播副本**落盘并 // 产出 `voice` 引用(design/24 §10.2 第 4 步;切片 CODE-VOICE)、构建模型可见的 // 消息列表(再前置**该会话自己**最近的历史转录:主人私聊取主会话、群聊/陌生人取 // 该隔离会话,见 transcript.go),在轮次之前持久化入站 // 用户消息**连同它们的 `document` 引用列、`image` 列与 `voice` 列**(因此即使渠道 // 回复失败它也已落地,客户端也有附件芯片、图片与 🎤 可看),并在轮次之后把抽取结论 // 补回同一 document 列,运行循环并把结果交给 deliverReply。 // // voiceTurn(切片 CODE-VOICE)是那条语音的归一结论 —— **只在放行之后**才可能落副本: // 被黑白名单或最小打扰拦下的语音,盘上不留任何文件(与附件同一纪律)。 func (b *Bridge) runInboundTurn(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter, text string, actor adapter.Actor, sess session.Session, voiceIn voiceTurn) { target := inboundTargetFor(sess, msg.Platform) // 入站附件摄取留在**闸门之后**(本函数只在消息被放行后调用):被黑白名单或 // 最小打扰拦下的消息绝不在盘上留下文件。成功者挂成文档部件,装配期 // resolveDocuments 照既有链路抽取/注入;读不到的走诚实提示。 attachments := b.ingestInboundDocuments(ctx, msg) // 语音副本落盘与附件摄取**并列**、同处闸门之后:不可播的格式先转码(复用 // internal/capability/transcode 的既有能力),转码不可用就**不落副本**并如实记分类。 // 它拿到的字节是**原始字节**(转写早已在流水线最前端用同一份原始字节做完)。 voiceRef := b.ingestInboundVoiceCopy(ctx, msg, voiceIn) // 图片内联与文档摄取**并列**、互不影响:文档走存储与抽取,图片走 data: URI 部件 // (同一条用户消息上两条都能挂)。它没有 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)。 // 谁算主人、谁算第三方由解析出的 actor 结构化决定——绝不 // 依据提示词文本。主人本人的发言**不带**发送者名(主人从不被标注): // 受限会话里「带昵称的 user 消息 = 第三方」「不带昵称的 = 主人本人」 // 就是模型侧的归属判定车道(loop/attribution.go),落库也走同一个值 —— // 否则转录会把主人的历史发言读成第三方人员说的。 // // 该轮以哪个角色说话遵循 design/22 §2/§7-3/§7-4:主人私聊 // 镜像进主会话时取主会话的绑定,否则取账号的 // 渠道级绑定,再否则取全局默认;群聊或 // 陌生人会话强制使用默认角色。 runEnv := turnEnv(sess, target, b.resolvePersona(ctx, sess, target, adapterName(a))) senderName := thirdPartySenderName(msg, actor) loopMsgs := b.buildLoopMessages(ctx, text, runEnv, actor, senderName, msg) // 附件/图片先挂到**本回合的**用户消息上(attachInboundContent 认列表里第一条 // RoleUser),再前置历史转录 —— 顺序反过来会把附件挂到转录里的旧用户消息上。 attachInboundContent(loopMsgs, attachments, images) loopMsgs = b.seedTurnTranscript(ctx, target, loopMsgs) // 只把部件挂在内存里的模型可见消息上,客户端读到的那条消息就是一片空白 —— 附件芯片 // 只认 `document` 列,图片只认 `image` 列(**图片绝不进 `document` 列**),🎤 只认 // `voice` 列。这里写的是引用与身份,文档结论要等回合跑完(下一段)。 document := b.inboundDocumentColumn(msg, attachments) image := inboundImageColumn(images) // 主人私聊消息落在 Web 主会话并广播给 Web 客户端; // 受限消息落在其隔离会话(适配器日志页), // 以 `channel_persisted` 事件广播(绝不进入 // 主/`web` 会话或 Web 聊天)。 // 无论有没有文本都要落库:附件/图片就是纯媒体消息的内容(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), turnID) } else { userMsg = b.persistIsolated(ctx, history.RoleUser, text, document, image, deviceIDFor(msg), sess, msg.Platform, senderName, adapterName(a), turnID) } // 语音事实与用户消息**同源落库**(design/24 §10.2 第 5 步):写进刚落的那一行, // 客户端因此能按结构化字段显示 🎤 与播放控件(绝不从正文里认媒体类型)。 b.persistMessageVoice(userMsg, voiceRef) 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) } // 抽取结论(status/chars/truncated/reason)只能在回合跑起来之后才有,这里按 // 服务端存储名把结论补回刚落库的那一行(design/24 §6.1 的两段式,与 Web 侧 // gateway/session_document_status.go 同一口径)。 b.persistTurnDocumentStatus(userMsg, res) // 出错也交给 deliverReply:回合不能因为「模型那边出了问题」就对用户静默 // (空回复 / 轮次用尽 / 内部报错都落一条诚实的可见回复;被取消的回合除外, // 见 turnreply.go)。 b.deliverReply(ctx, msg, a, sess, target, res, err, turnID) } // deliverReply 解析本回合的可见回复、持久化并经接收该消息的适配器 // 送回去。可见回复由 turnVisibleReply 给出(turnreply.go):真实文本逐字, // 空回复 / 轮次用尽 / 安全网终止 / 内部报错则是按原因区分的中文诚实说明 —— // 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, turnID string) { reply, ok := turnVisibleReply(ctx, res, runErr) if !ok { b.log.Debug("turn reply canceled, nothing delivered", "platform", msg.Platform, "session", target.sessionID) return } // mood 只挂在助手回复上(design/16 §3.3),且只有真实结果才带得回来 // (回合报错时 res 为 nil)。 mood := "" if res != nil { mood = res.Mood } // 持久化助手回复(主人私聊 → 主会话 + message_persisted // 广播;受限 → 隔离日志 + channel_persisted 广播),随后 // 送回渠道。 if target.private { 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), turnID) } if err := b.sendReply(ctx, msg, a, reply); err != nil { if errors.Is(err, adapter.ErrUnsupported) { b.log.Debug("channel does not support outbound reply, skipped", "platform", msg.Platform, "session", target.sessionID) return } b.log.Warn("send reply failed", "platform", msg.Platform, "error", err) } } // deviceIDFor 为一条入站 IM 消息构建客户端 deviceID 标签 // (design/16 §4.5)::<本地配置唯一id>:<用户平台唯一uid>。UnifiedMessage 尚无 // 显式的 deviceId 字段,因此本地配置 id 与用户平台 uid // 取自规范化后的渠道绑定:ChannelID 暂代本地 // 适配器绑定,SenderID 是主人在该平台的 uid。SenderID 缺失时 // 标签退化为 :,以保持非空且可读。 // // 扩展点:当适配器/UnifiedMessage 长出具体的 deviceId(或 // 桥接器获得本地配置 id)时,把这里的 ChannelID 暂代替换掉并 // 直接返回该值。 func deviceIDFor(msg adapter.UnifiedMessage) string { if msg.SenderID != "" { return msg.Platform + ":" + msg.ChannelID + ":" + msg.SenderID } return msg.Platform + ":" + msg.ChannelID } // buildLoopMessages 为一次入站轮次构建模型可见的消息列表。来源标识 // 走**独立的系统行**(originSystemText),绝不前缀进用户正文,使 // 模型能看到消息来自哪个平台、哪个会话(design/16 // §4.6.1);纯 Web 主会话消息(platform 为 ""/"web")保持不动。在 // 受限(群聊/陌生人)上下文中,它还会在最前面加一条系统 // 消息,声明第三方身份边界——「每条 IM 非管理员 // 账号的消息都是第三方,与主人无关」(design/16 §4.6 §1)。 // 该边界与发送者的**昵称标注**(model.Message.SenderName,见 // thirdPartySenderName)由解析出的 SessionEnv/actor 结构化决定, // 绝不由 LLM 推断。原始消息文本保持不动(标签只走结构化字段), // 因此持久化内容与来源平台投递的内容一致。 // // 入站内容摄取的产物(已落库的文档部件、内联成 data: URI 的图片部件,以及两条路上 // 「读不到」的诚实提示行)由 attachInboundContent(inbound_content.go)挂到**同一条 // RoleUser 消息**上 —— 用户内容只进用户角色消息是项目红线。 // // 历史转录**不在本函数里**:它由 seedTurnTranscript(transcript.go)在本函数返回之后 // 插入到列表中(主人私聊前置在最前;受限车道插在**最前的系统框架行之后**, // 于是来源行 + 第三方边界仍在最前、转录居中、当次消息在最后)。因此本函数只负责 // **本回合自己的**消息(来源行 + 当次用户消息),受限车道的形状在有无转录时保持一致。 func (b *Bridge) buildLoopMessages(_ context.Context, text string, env loop.SessionEnv, actor adapter.Actor, senderName string, src adapter.UnifiedMessage) []model.Message { // design/12 §6 策略一补充:经传入前审查放行的系统通知走「限定注入」——只把剥离后的 // 安全文本作为**一条系统行**注入她的上下文,绝不作为用户消息(否则等于把平台事件 // 伪装成有人对她说话),也绝不携带任何原始字段。 if isSystemNoticeMessage(src) { return []model.Message{model.TextMessage(model.RoleSystem, noticeSystemPrefix+text)} } msg := userTurnMessage(text) // 受限会话里说话者由结构化元数据承载,不织入用户文本 // (design/16 §4.6.1):文本标签既含糊,又构成提示词注入面。 // senderName 为空只可能是主人本人(thirdPartySenderName 对主人恒返回空), // 因为受限会话里「空昵称 = 主人」正是模型侧的判定车道 // (loop/attribution.go),第三方发送者一定带标签。 if env.Restricted() && !actor.IsOwner() && senderName != "" { msg.SenderName = senderName } // 来源系统行:会话来源 + 身份信任规则,每轮一条。 origin := originSystemText(src, actor) if !env.Restricted() { if origin != "" { return []model.Message{model.TextMessage(model.RoleSystem, origin), msg} } return []model.Message{msg} } // 受限(群聊/陌生人):来源(若是 IM)+ 第三方身份边界。 var sys string switch { case origin != "" && thirdPartyBoundaryPrompt != "": sys = origin + "\n" + thirdPartyBoundaryPrompt case origin != "": sys = origin default: sys = thirdPartyBoundaryPrompt } return []model.Message{model.TextMessage(model.RoleSystem, sys), msg} } // userTurnMessage 构建入站回合的用户消息基底:文本为空时**不塞空文本部件** // (provider 会拒空文本块),让附件部件或诚实提示行本身成为这条消息的内容 —— // 与网关 `baseUserMessage` 对纯附件回合的口径一致(design/24 §4.2)。 func userTurnMessage(text string) model.Message { if text == "" { return model.Message{Role: model.RoleUser} } return model.TextMessage(model.RoleUser, text) } // thirdPartyBoundaryPrompt、thirdPartySenderName 与 unnamedSenderName // 住在 inbound_speaker.go(第三方发送者的结构化标注车道与它的提示词)。