- 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 已写盯梢用例)
259 lines
12 KiB
Go
259 lines
12 KiB
Go
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 }
|