- B3:截图落盘后**自动**并入本轮视图(能收图 ⇒ 真 image 部件挂 user 消息;收不了 ⇒ vision/OCR 文字;
大图先经 image-crop 裁切;取走即消费、每会话 ≤4 张、TTL 10min、跨会话隔离)
- B4:browser 面板**声明**(配置表单 + 运行状态/指纹自检/最近截图三张只读表 + 拍一张/重新自检)
+ 六个动作走 PLG-2 的通用动作端点(**不开新路由**)+ 保存事务(不可用则回滚 409)+ 热重载
- 读面**不启动浏览器**(计数型假 dialer 证明 0 次拨号);行数据不含路径/URL/内容
- 3 个变异(短路主路/行与列 key 脱钩/保存不回滚)
⚠️ **两行接线未落**(不在我文件面):wire_runtime.go 的 TransformContext + wire_frontend.go 的
wireBrowserPanel —— 落地前 B3/B4 只是「组件就绪」,跑着的后端里不存在(Y4 已写盯梢用例)
171 lines
6.5 KiB
Go
171 lines
6.5 KiB
Go
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)
|
|
}
|