Files
AskaEth e0a6bca501 feat(browser): B3 截图→上下文闭环 + B4 面板声明页(Y4)
- 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 已写盯梢用例)
2026-10-06 21:48:50 +08:00

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)
}