package browser import ( "context" "strings" "sync" "time" browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" ) // 本文件是 design/28 §7 判据 2(M4 闭环)的**浏览器侧**:截图一落盘就把它挂进 // 「**本回合待送出的截图**」附件箱,让循环的装配钩子在同一回合的下一次模型调用里取走 // (钩子在 `cmd/cyrene/wire_browser_context.go`,取走时按主模型能力决定给真图还是 // 工具形态的文字 —— 判定与内联都在 `internal/runtime/multimodal/vision`)。 // // 为什么要一个「箱」而不是让工具自己把图塞进模型请求:工具拿到的是**一次调用**的 // context,它能改的只有自己的返回值(工具结果是**文本**,见 `internal/runtime/loop/tools.go` // 的 `toolResultMessage`),而「挂一张图到本轮视图」是**循环**的职责(消息归属、 // 角色合法性、checkpoint 一致性都在那边)。两边各让半步:工具交图,循环把它变成附件。 // // 三条口径: // // 1. **按会话隔离**:箱子按 `loop.SessionFromContext(ctx)` 的会话 id 分桶 —— 真并行 // 多路时,A 会话拍的图绝不会跑到 B 会话的上下文里(跨端串话是本项目踩过的坑); // 拿不到会话 id(子代理 / 未接编排器的调用)时**不入箱**,如实报 // `turnAttachment=no-session`,由模型自己按路径去读; // 2. **有界 + 有寿命**:每个会话最多 `maxPendingShots` 张、`shotOutboxTTL` 内有效。 // 截图是**要送出去**的东西,不是缓存:攒着不送只会把上下文撑大(一张 5 MiB 的图 // 进 base64 后约 7 M 字符)。超上限时丢**最旧**的那张并如实计数(`Dropped`); // 3. **取走即消费**:`Drain` 把该会话的待送图整体取走并清空(循环每步取一次), // 因此同一张图不会被送到两次(重复送图既费预算又会让模型以为「又拍了一张」)。 type ShotOutbox struct { mu sync.Mutex bySess map[string][]pendingShot dropped map[string]int now func() time.Time } // pendingShot 是箱里的一格:截图 + 入箱时间(TTL 判据)。 type pendingShot struct { shot browsercap.Shot at time.Time } // 箱子的具名上限。 const ( // maxPendingShots 是**每个会话**最多攒着的截张数。一次任务里连拍 4 张还没送出去 // 说明装配钩子没在跑(或模型在狂拍),此时丢最旧的一张比把 4 张 5 MiB 的图 // 一起塞进下一个请求更负责。 maxPendingShots = 4 // shotOutboxTTL 是待送截图的有效期。超过它的图不再送出(会话可能已经结束), // 文件仍在盘上,模型按路径照样能读。 shotOutboxTTL = 10 * time.Minute ) // 工具结果里 `turnAttachment` 的取值(闭集):它让**模型与面板**都能看出这张图到底 // 有没有进上下文,而不是只在日志里。 const ( // TurnAttachmentQueued 报告截图已入箱,将在本回合的下一次模型调用里作为附件送出。 TurnAttachmentQueued = "queued" // TurnAttachmentNoSession 报告本次调用拿不到会话 id(子代理 / 未接编排器),未入箱。 TurnAttachmentNoSession = "no-session" // TurnAttachmentFull 报告箱已满,最旧的一张被丢弃(如实计数,不静默)。 TurnAttachmentFull = "outbox-full" // TurnAttachmentDisabled 报告本工具没有附件箱(`New(nil)` 这类夹具),未入箱。 TurnAttachmentDisabled = "disabled" ) // NewShotOutbox 造一个空箱子。零值不可用(map 未初始化),一律经本函数构造 —— // 与 `New` 里默认建箱是同一个实现,不存在第二份。 func NewShotOutbox() *ShotOutbox { return &ShotOutbox{ bySess: make(map[string][]pendingShot), dropped: make(map[string]int), now: time.Now, } } // Put 把一张截图放进 sessionID 的格子。返回本次入箱的结论(TurnAttachment*)。 // sessionID 为空 ⇒ 谁也不放(调用方据此如实报 no-session)。 func (o *ShotOutbox) Put(sessionID string, shot browsercap.Shot) string { if o == nil { return TurnAttachmentDisabled } sessionID = strings.TrimSpace(sessionID) if sessionID == "" { return TurnAttachmentNoSession } o.mu.Lock() defer o.mu.Unlock() o.expireLocked(sessionID) list := append(o.bySess[sessionID], pendingShot{shot: shot, at: o.now()}) status := TurnAttachmentQueued if len(list) > maxPendingShots { // 丢最旧的:新的那张更接近「模型现在想问的那一页」。 list = list[len(list)-maxPendingShots:] o.dropped[sessionID]++ status = TurnAttachmentFull } o.bySess[sessionID] = list return status } // Drain 取走并清空 sessionID 的待送截图(取走即消费)。会话 id 为空或没有待送图时 // 返回 nil —— 那是常态(工具批次之间通常没有新截图),不是错误。 func (o *ShotOutbox) Drain(sessionID string) []browsercap.Shot { if o == nil { return nil } sessionID = strings.TrimSpace(sessionID) if sessionID == "" { return nil } o.mu.Lock() defer o.mu.Unlock() o.expireLocked(sessionID) list := o.bySess[sessionID] delete(o.bySess, sessionID) delete(o.dropped, sessionID) if len(list) == 0 { return nil } out := make([]browsercap.Shot, 0, len(list)) for _, p := range list { out = append(out, p.shot) } return out } // Pending 报告某会话当前攒着几张待送截图(面板/诊断用;不消费)。 func (o *ShotOutbox) Pending(sessionID string) int { if o == nil { return 0 } o.mu.Lock() defer o.mu.Unlock() o.expireLocked(sessionID) return len(o.bySess[sessionID]) } // expireLocked 丢掉该会话里过期的格子(TTL 之外)。调用方必须持有 o.mu。 func (o *ShotOutbox) expireLocked(sessionID string) { list := o.bySess[sessionID] if len(list) == 0 { return } cutoff := o.now().Add(-shotOutboxTTL) kept := list[:0] for _, p := range list { if p.at.After(cutoff) { kept = append(kept, p) } } if len(kept) == 0 { delete(o.bySess, sessionID) return } o.bySess[sessionID] = kept } // PutForContext 按 ctx 上的会话 id 入箱(工具在自己的 Execute 里用这一个入口: // 会话 id 的来源只有 `loop.SessionFromContext` 一处,绝不从参数或模型输出推断)。 func (o *ShotOutbox) PutForContext(ctx context.Context, shot browsercap.Shot) string { if o == nil { return TurnAttachmentDisabled } return o.Put(loop.SessionFromContext(ctx), shot) }