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

259 lines
12 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 的 `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 }