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 已写盯梢用例)
This commit is contained in:
AskaEth committed 2026-10-06 21:48:50 +08:00
1 parent 4213b268fe
commit e0a6bca501
18 files changed
+3698 -72

No files matched your search

+150
View File
@@ -0,0 +1,150 @@
package browser
// 本文件是 `browser` 面板三块只读表格的**行形状**(design/09 §5.2.1 的 section.table):
// 把驱动层的事实面(`internal/capability/browser` 的 `StateSnapshot`)翻成表格行。
//
// 两条硬口径:
//
// 1. **行 key 与声明里的列 key 同源**:两边都引用 panel.go 的 `col*` 常量 —— 声明一个
// key、行里写另一个,表现就是「表头在、数据空」,而且不会有任何报错;
// 2. **不含内容、不含路径、不含 URL**:截图只报字节/尺寸/格式,页面只报「有没有会话」。
// 面板是只读投影(viewer 也能看到表格行),把主人的浏览内容投到那里是越界。
import (
"fmt"
"strconv"
"strings"
"time"
browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser"
)
// 未自检 / 未知的占位文案。**不编造数字**:没读到的读数就写「未自检」,
// 让面板与真读数一眼可分(design/28 §6 坑 5 的同一条精神:失败如实报)。
const (
notCheckedText = "未自检"
unknownText = "未知"
enabledText = "开"
disabledText = "关"
availableText = "可用"
unavailableText = "不可用"
closedText = "已关闭"
degradedYes = "是(PNG 超限降级 JPEG)"
degradedNo = "否"
)
// StateRows 把运行状态摊成一行(表格是一行事实:这个浏览器现在长什么样)。
//
// 只有一行,因为事实只有一份 —— 多行会诱导读者以为「有多个浏览器在跑」,而并发上限
// 恒为 1(design/28 §3)是这套设计的硬约束。
func StateRows(s browsercap.StateSnapshot) []map[string]any {
availability := availableText
switch {
case s.Closed:
availability = closedText
case !s.Available:
availability = unavailableText
}
source := strings.TrimSpace(s.Source)
if source == "" {
source = unknownText
}
session := "未打开"
if s.SessionLive {
session = "已打开"
if !s.SessionOpenedAt.IsZero() {
session += "(" + formatTime(s.SessionOpenedAt) + ")"
}
}
return []map[string]any{{
colSource: source + "(会话:" + session + ")",
colAvailable: availability,
colHeadless: yesNo(s.Headless, enabledText, disabledText),
colViewport: fmt.Sprintf("%d×%d", s.ViewportWidth, s.ViewportHeight),
colProfileDir: s.ProfileDir,
colConcurrent: strconv.Itoa(s.Concurrency),
colIdleReco: strconv.Itoa(s.IdleTimeoutSeconds),
colShotCap: strconv.Itoa(s.ScreenshotMaxBytes),
}}
}
// FingerprintRows 把最后一次指纹自检摊成一行;从未自检过时给一行**如实**的「未自检」。
func FingerprintRows(fp *browsercap.Fingerprint, at time.Time) []map[string]any {
if fp == nil {
return []map[string]any{{
colWebdriver: notCheckedText,
colScreen: notCheckedText,
colTimezone: notCheckedText,
colLanguage: notCheckedText,
colUserAgent: notCheckedText,
colCheckedAt: notCheckedText,
}}
}
return []map[string]any{{
// webdriver 是**布尔事实**:false 才是正常(我们不传 --enable-automation)。
colWebdriver: yesNo(fp.Webdriver, "true(自动化痕迹没关掉!)", "false(正常)"),
colScreen: fmt.Sprintf("%d×%d @%dbit dpr=%g", fp.ScreenWidth, fp.ScreenHeight, fp.ColorDepth, fp.DevicePixelRatio),
colTimezone: orUnknown(fp.Timezone),
colLanguage: languageText(fp),
colUserAgent: orUnknown(fp.UserAgent),
colCheckedAt: formatTime(at),
}}
}
// ShotRows 把最近截图统计摊成多行(**最新在前**,与驱动的环同序)。
func ShotRows(recs []browsercap.ShotRecord) []map[string]any {
out := make([]map[string]any, 0, len(recs))
for _, r := range recs {
out = append(out, map[string]any{
colShotAt: formatTime(r.At),
colShotBytes: strconv.Itoa(r.Bytes),
colShotSize: sizeText(r),
colShotFormat: orUnknown(r.Format),
colShotDegraded: yesNo(r.Degraded, degradedYes, degradedNo),
})
}
return out
}
// sizeText 渲染像素尺寸。尺寸未知时**不拿视口冒充**(那是「我们以为的」,不是「图里的」)。
func sizeText(r browsercap.ShotRecord) string {
if !r.DimensionsKnown || r.Width <= 0 || r.Height <= 0 {
return unknownText
}
return fmt.Sprintf("%d×%d", r.Width, r.Height)
}
// languageText 渲染语言列表(首项 + 总数,避免长串把单元格撑爆)。
func languageText(fp *browsercap.Fingerprint) string {
if fp.Language == "" && len(fp.Languages) == 0 {
return unknownText
}
if len(fp.Languages) <= 1 {
return orUnknown(fp.Language)
}
return fmt.Sprintf("%s 等 %d 种", orUnknown(fp.Language), len(fp.Languages))
}
// formatTime 渲染时间(秒级可读;零值给「未知」)。
func formatTime(t time.Time) string {
if t.IsZero() {
return unknownText
}
return t.Local().Format("2006-01-02 15:04:05")
}
// orUnknown 把空串渲染成「未知」。
func orUnknown(s string) string {
if strings.TrimSpace(s) == "" {
return unknownText
}
return s
}
// yesNo 按真值返回一对文案。
func yesNo(v bool, yes, no string) string {
if v {
return yes
}
return no
}