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

+258
View File
@@ -0,0 +1,258 @@
package browser
// 本文件是 `browser` 插件向**面板**声明的管理页面(design/09 §5.2 的 `adminPanels`,
// design/28 §8 B4 的落点):形状即 `pkg/plugins/sdk` 的 `Panel`。
//
// 声明只说三件事:**要什么字段、怎么渲染、动作叫什么**。它
// - 不注入任何 JS/CSS/HTML、
// - 不给 URL(只有动作名,走宿主的通用动作端点分发)、
// - 不开任何路由。
// 这三条是 design/09 §5.2.1 的红线:声明而非编码、声明里塞不进请求面(防 SSRF)、
// 面板数据一律经插件管理器的端点。渲染与流程由宿主负责,本文件只负责「说得清楚」。
//
// 页面构成(一处读全):
//
// ① form 浏览器配置 —— 有头/无头、视口、profile 目录、并发上限…(保存即热重载)
// ② table 运行状态 —— 只读事实面 + 行内动作「拍一张(当前页面)」
// ③ table 指纹自检 —— 只读事实面 + 行内动作「重新自检」(唯一会起浏览器的读面动作)
// ④ table 最近截图 —— 只统计(字节/尺寸/格式),不含路径与内容
//
// 为什么状态/自检/截图都做成 **table** 而不是 `status`:`status` 区块按 §5.2.1 的接入点表
// **不消费 `source`**(`paneldecl.shapeOf` 明写 notAllowed),没有来源就没有活数据 —— 硬要
// 用 status 只能给一份声明期的静态文本,那才是真正的「看起来有、其实没有」。表格是
// 声明面里唯一既有来源又能带按钮的形状,因此三块活数据都用它(同为一处口径)。
import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// PanelPluginID 是本插件在面板体系里的 id。它与可变源的文件名**同源**:
// `$DATA_DIR/config/plugins/browser.json`(design/13 §2.1:文件名 = 插件 id),
// 两边由 panel_test.go 钉住。
const PanelPluginID = "browser"
// PanelIDSettings 是「浏览器与截图」页的面板 id。
// 前端路由为 `/admin/plugin/browser/settings`(design/09 §5.7 C)。
const PanelIDSettings = "settings"
// 本插件声明的**全部**动作名(design/09 §5.2.1 的动作面)。
//
// 表里的动作名一律取自这里,通用动作端点也只接受这几个名字(未声明 ⇒ 404/403)。
// 把「声明」与「动作名」放同一个文件,是为了让「表里写了一个没实现的动作」这种半渲染
// 在 review 时一眼可见 —— 它们就挨着。
const (
// ActionState 是运行状态只读来源(表格数据源)。**不启动浏览器**。
ActionState = "plugin.getBrowserState"
// ActionShots 是最近截图只读来源(表格数据源)。**不读文件、不含内容**。
ActionShots = "plugin.listShots"
// ActionFingerprint 是最后一次指纹自检的只读来源(表格数据源),未自检过时如实说「未自检」。
ActionFingerprint = "plugin.getFingerprint"
// ActionSaveConfig 保存浏览器配置(表单提交)。落 `$DATA_DIR/config/plugins/browser.json`
// 并**热重载**工具(design/09 §5.5 的口径:面板的意义就是不改 `.env`、不重启)。
ActionSaveConfig = "plugin.saveBrowserConfig"
// ActionShoot 拍一张(行内动作)。owner-only + 落审计(design/28 §1 M3)。
ActionShoot = "plugin.shootPage"
// ActionCheck 重新做一次指纹自检(行内动作)。它会**启动浏览器** —— 这是本页唯一有
// 进程外副作用的动作,因此是显式按钮而绝非「打开页面就顺手跑」。
ActionCheck = "plugin.checkFingerprint"
)
// ActionNames 返回本插件声明的动作名(顺序与 AdminPanels 里出现的顺序一致:写表单 →
// 三个读来源 → 两个行内动作)。调用方(插件管理器的动作端点)据此建白名单:
// 白名单外的动作名一律拒,绝不「先执行再看结果」。
func ActionNames() []string {
return []string{
ActionSaveConfig,
ActionState, ActionFingerprint, ActionShots,
ActionShoot, ActionCheck,
}
}
// 表里三块只读数据的列 key(与 paneldata.go 的行字段同源:声明一个 key、行里写另一个,
// 表现就是「表头在、数据空」—— 所以两边只允许引用常量)。
const (
// 运行状态列
colSource = "source"
colAvailable = "available"
colHeadless = "headless"
colViewport = "viewport"
colProfileDir = "profileDir"
colConcurrent = "concurrency"
colIdleReco = "idleTimeoutSeconds"
colShotCap = "shotMaxBytes"
// 指纹自检列
colWebdriver = "webdriver"
colScreen = "screen"
colTimezone = "timezone"
colLanguage = "language"
colUserAgent = "userAgent"
colCheckedAt = "checkedAt"
// 最近截图列
colShotAt = "at"
colShotBytes = "bytes"
colShotSize = "size"
colShotFormat = "format"
colShotDegraded = "degraded"
)
// 表单字段 key(与 `browsercfg.Config` 的 JSON tag 逐字一致 —— 字段名与文件字段名对上,
// 面板写的键就是运行期读的键)。
const (
FieldHeadless = "headless"
FieldExecutable = "executable"
FieldCDPEndpoint = "cdpEndpoint"
FieldProfileDir = "profileDir"
FieldViewportWidth = "viewportWidth"
FieldViewportHeight = "viewportHeight"
FieldIdleTimeout = "idleTimeoutSeconds"
FieldConcurrency = "concurrency"
)
// PanelInput 是声明生成需要的**当前事实**:配置(给表单默认值)与可用性(决定描述文案
// 是否如实说明「这台机器现在没有浏览器」)。
//
// 为什么默认值要由调用方传入而不是读文件:声明是**装配期**生成的(组合根一处),
// 读文件是它唯一的事实来源;把「读哪份配置」的判断塞进声明函数会让「面板显示的」与
// 「工具真的用的」两处各自算一次。
type PanelInput struct {
// Config 是**当前生效**的配置(已 Normalize)。
Config browsercfg.Config
// Available 报告当前有没有可用来源。
Available bool
// Source 是来源描述(本机 / 容器端点),只进描述文案。
Source string
}
// AdminPanels 返回本插件声明的管理页面。
func AdminPanels(in PanelInput) []sdk.Panel {
return []sdk.Panel{{
ID: PanelIDSettings,
Title: "浏览器与截图",
Description: panelDescription(in),
Group: sdk.GroupPlugins,
Order: 30,
Icon: "browser",
// 未启用的插件 ⇒ 入口不出现(design/09 §5.2.1 nav.item)。browser 是编进二进制的
// 内置能力,没有卸载路径 ⇒ 恒为启用;写条件是为了让「启用事实」由插件管理器给出。
When: &sdk.When{Enabled: boolFlag(true)},
Sections: []sdk.Section{
configSection(in.Config),
stateSection(),
fingerprintSection(),
shotsSection(),
},
}}
}
// panelDescription 拼页面描述。不可用时**如实说明**(照 web-search `off` 的诚实口径:
// 面板可以配,配好保存后热重载生效;没有来源时工具不会注册)。
func panelDescription(in PanelInput) string {
base := "配置这个浏览器(有头/无头、视口、profile 目录、并发上限),查看运行状态与指纹自检," +
"并拍一张当前页面。改动落 $DATA_DIR/config/plugins/browser.json,**保存后立即生效**" +
"(工具会按新配置重建,无需重启)。"
if in.Available {
return base + "当前来源:" + in.Source + "。"
}
return base + "⚠️ 当前**没有**可用来源(未探测到本机 Chrome/Chromium,也没有在跑的 CDP 端点):" +
"`browser` 工具此刻**不在**模型的能力面里;在这里配好路径/端点并保存,重建后即生效。"
}
// configSection 是配置表单:字段与 `browsercfg.Config` 同源,默认值取当前生效值
// (面板因此「打开即显示现状」)。
func configSection(cfg browsercfg.Config) sdk.Section {
one := 1.0
return sdk.Section{
Type: sdk.SectionForm,
ID: "config",
Title: "浏览器配置",
Fields: []sdk.Field{
{Key: FieldHeadless, Label: "无头模式", Type: sdk.FieldSwitch, Default: cfg.Headless,
Placeholder: "关 = 有头(默认;过风控的主路径)"},
{Key: FieldViewportWidth, Label: "视口宽", Type: sdk.FieldNumber, Default: cfg.ViewportWidth,
Min: floatPtr(320), Max: floatPtr(float64(browsercfg.MaxViewportWidth))},
{Key: FieldViewportHeight, Label: "视口高", Type: sdk.FieldNumber, Default: cfg.ViewportHeight,
Min: floatPtr(240), Max: floatPtr(float64(browsercfg.MaxViewportHeight))},
{Key: FieldProfileDir, Label: "profile 目录", Type: sdk.FieldText, Default: cfg.ProfileDir,
Placeholder: "含 cookie/登录态;默认 $DATA_DIR/browser/profile"},
{Key: FieldConcurrency, Label: "并发上限", Type: sdk.FieldNumber, Default: cfg.Concurrency,
Min: &one, Max: &one,
Placeholder: "固定 1:同一 profile 起两个实例会互锁"},
{Key: FieldIdleTimeout, Label: "空闲回收(秒)", Type: sdk.FieldNumber, Default: cfg.IdleTimeoutSeconds,
Min: floatPtr(1)},
{Key: FieldExecutable, Label: "浏览器可执行文件", Type: sdk.FieldText, Default: cfg.Executable,
Placeholder: "留空 = 自动探测本机 Chrome/Chromium"},
{Key: FieldCDPEndpoint, Label: "CDP 端点", Type: sdk.FieldText, Default: cfg.CDPEndpoint,
Placeholder: "如 http://127.0.0.1:9222(显式给出时取代自动探测)"},
},
Submit: &sdk.Submit{Action: ActionSaveConfig},
}
}
// stateSection 是「运行状态」:只读事实面 + 行内动作「拍一张」。
func stateSection() sdk.Section {
return sdk.Section{
Type: sdk.SectionTable,
ID: "state",
Title: "运行状态",
Source: &sdk.Source{Action: ActionState},
Columns: []sdk.Column{
{Key: colSource, Label: "来源"},
{Key: colAvailable, Label: "可用"},
{Key: colHeadless, Label: "无头"},
{Key: colViewport, Label: "视口"},
{Key: colProfileDir, Label: "profile"},
{Key: colConcurrent, Label: "并发"},
{Key: colIdleReco, Label: "空闲回收(秒)"},
{Key: colShotCap, Label: "截图上限(字节)"},
},
RowActions: []sdk.RowAction{
{ID: "shoot", Label: "拍一张(当前页面)", Action: ActionShoot},
},
}
}
// fingerprintSection 是「指纹自检」:默认只显示**上一次**读数(不启动浏览器),
// 要刷新得点行内动作(那是显式的、有进程外副作用的动作)。
func fingerprintSection() sdk.Section {
return sdk.Section{
Type: sdk.SectionTable,
ID: "fingerprint",
Title: "指纹自检",
Source: &sdk.Source{Action: ActionFingerprint},
Columns: []sdk.Column{
{Key: colWebdriver, Label: "webdriver"},
{Key: colScreen, Label: "屏幕"},
{Key: colTimezone, Label: "时区"},
{Key: colLanguage, Label: "语言"},
{Key: colUserAgent, Label: "User-Agent"},
{Key: colCheckedAt, Label: "自检时间"},
},
RowActions: []sdk.RowAction{
{ID: "check", Label: "重新自检(会启动浏览器)", Action: ActionCheck},
},
}
}
// shotsSection 是「最近截图」:只有统计(时间/字节/尺寸/格式),**不含路径与内容**。
func shotsSection() sdk.Section {
return sdk.Section{
Type: sdk.SectionTable,
ID: "shots",
Title: "最近截图",
Source: &sdk.Source{Action: ActionShots},
Columns: []sdk.Column{
{Key: colShotAt, Label: "时间"},
{Key: colShotBytes, Label: "字节"},
{Key: colShotSize, Label: "尺寸"},
{Key: colShotFormat, Label: "格式"},
{Key: colShotDegraded, Label: "降级"},
},
}
}
// boolFlag 是 `&true` / `&false` 的简写(sdk.When.Enabled 是三态指针)。
func boolFlag(v bool) *bool { return &v }
// floatPtr 是 `&1.0` 的简写(sdk.Field.Min/Max 是指针,缺省与「显式 0」不同义)。
func floatPtr(v float64) *float64 { return &v }