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

151 lines
5.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}