# @js-eyes/visual-bridge-kit

可复用的浏览器内"视觉反馈层" + 业务数据派发器，让 bridge 类技能（reddit-ops / x-ops / zhihu-ops … 以及外部 newidea-cli-test）共享同一套 HUD + flash overlay + 评论树关系线 + 结构化业务数据落盘 + 离线 HTML 模板回放。

## v0.5.2 设计澄清：bridge HUD 是"执行时反馈"，故意保留

> v0.5.1 一度尝试在 `--visual-record` 时让 bridge "静默"（不渲染 HUD/flash 浮层），
> 这是**误判**——bridge 端的 in-page HUD/flash 是给人类观察 agent 当下行为用的，
> 应该和不录制时一致。把它静默掉等于把"执行时反馈"也吃了。
>
> v0.5.2 已把 `silentRecord` 配置项**整体撤回**，bridge 行为回到 v0.5.0：
>
> - `showHud` / `flashElement` / `flashRelation` 始终在浏览器里画 DOM overlay
> - 录制时这些 overlay **会被 `captureScreenshot` 拍进 PNG/JPEG 像素**
> - **这是预期行为**：截图就是当时浏览器看到的样子
>
> "录屏不要叠多余视觉"指的是**合成端 (`visual-replay-hyperframes`) 不在已经带
> HUD 的截图上再叠一层**，由 hyperframes 0.5.2 的 `--effects=auto` 默认值负责
> （snapshot 模式默认 `effects.hud=false, effects.flash=false`，detail 见
> `@js-eyes/visual-replay-hyperframes` README）。
>
> bridge `VERSION` 仍 bump（0.4.0 → 0.4.2）以驱赶任何 0.4.1 缓存。

## v0.5.0 snapshot mode 主链路升级

> v0.5.0 起 **PNG/JPEG 截图链路重新升回主入口**，与"业务 payload + HTML 数据驱动模板"
> 共存。`makeFrameWriter` / `buildFrameRef` / `writeFrameSync` /
> `attachFrameRefsToEvents` 都从顶层 `require('@js-eyes/visual-bridge-kit')` 导出
> （`/dev` 子路径仍可用作 alias）。
>
> 主要变化：
>
> - `wrapCallApi` 现在默认 await `hooks.captureFrame`，截图成功后由 `onWritten`
>   回调把 `{type:'frame', ts, frameRef, viewport}` 事件 emit 进 ring buffer，
>   下游 `drainVisualEvents` 自然取回。
> - bridge 暴露 `__jse_visual.viewport()` 返回 `{cssW, cssH, dpr, scrollY}`，
>   skill 端在 `ensureBridge` 后写到 `meta.json` 的 `viewport` 字段。
> - 老主链路（events 不写 viewport / anchor.rect / relate rect）保持不变；
>   新增 `frame` 事件是可选 channel，老下游忽略不读不破坏。
>
> hyperframes v0.5.0 默认按 events 是否含 `frame` 自动选 snapshot vs template
> 模式（详见 `@js-eyes/visual-replay-hyperframes/README.md`）。

## post-2.7.0 architecture pivot（保留）

> 主链路从"PNG 截图 + DOM 测量 + 离线坐标叠层" 切换为 "业务 payload + HTML 数据驱动模板"。
> v0.5.0 引入 snapshot mode 后**两路并存**：
>
> | 维度 | A 路线（HTML 模板） | B 路线（snapshot v0.5.0+） |
> |---|---|---|
> | events 内容 | `kind / payload / anchor.spec` | 同 A + `frame` 事件 |
> | 离线产物 | reddit-style HTML 卡片 + class 切换式 flash | PNG/JPEG `#stage` 背景 + HUD + flash |
> | 视口耦合 | 响应式 vw/clamp，任意尺寸 0 错位 | 跟随浏览器实际 CSS 像素，配 `viewport` 元数据 |
> | 入口 | `require('@js-eyes/visual-bridge-kit')` | 同（顶层 export 自 v0.5.0 恢复） |

## 它解决的问题

- **运行时反馈**：bridge 仍在浏览器实时画 HUD + flash，让 agent 操作可见
- **业务数据派发**：`extractPayload` 钩子把工具响应的业务字段（reddit 帖子标题/作者/分数等）落到 `events.jsonl`
- **离线视频**：[`@js-eyes/visual-replay-hyperframes`](../visual-replay-hyperframes) 按 `hint.kind` 路由 HTML 模板，渲染成可在任意视口播放的 reddit-style 卡片
- **零业务侵入**：bridge 函数体不动，视觉与数据抽取都在 dispatch-edge hook
- **DOM 派 / API 派通吃**：默认 `flash + hud` 全开，找得到 DOM 锚点就 in-page flash，找不到自动只剩 HUD（v0.6.0+ 用 `--no-visual-flash` / `--no-visual-hud` 单独关）

## 三档反馈

| 强度 | 触发条件 | 表现 |
|---|---|---|
| **DOM-anchored** | hint 带 `anchor`，`resolveAnchor()` 命中可见元素 | flash 元素 + HUD |
| **HUD-only** | 解析不到锚点，或 `--no-visual-flash` | 屏幕角 HUD |
| **Trace-only** | `--no-visual`，但仍写 jsonl | 仅落盘 |

## 架构（A 主路：HTML 数据驱动）

```
┌─ Node ────────────────────────────────────────────────────────────────┐
│  parseVisualFlags(opts, siteDefaults?) ─► visualConfig                │
│      └ deprecatedFlags 检测 → CLI 层一次性告警                         │
│  makeBridgeExpander({baseDir}) ─► 处理 // @@include 行                 │
│  applyVisualConfig(session, cfg) ─► 下发到 page                       │
│  wrapCallApi(session, hint, fn, hooks)                                │
│      ├ buildSummary(resp, hint, err)                                  │
│      └ extractPayload(resp, hint, err) ★ 主链路                       │
│  drainVisualEvents(session) ─► 取 ring buffer（含 payload）            │
│  appendVisualSession(dir, entry, opts) ─► 会话包：events.jsonl + meta  │
└────────────────────────────────────────────────────────────────────────┘
                          │ summary.payload 透传
                          ▼  callRaw(...)
┌─ Page (bridge IIFE) ──────────────────────────────────────────────────┐
│  // @@include @js-eyes/visual-bridge-kit/bridge/visual.common.js       │
│  // @@include ./_visual-<site>.js                                      │
│                                                                        │
│  window.__jse_visual = {                                               │
│    config / getConfig                                                  │
│    flashElement / flashRelation / showHud / announceStage              │
│    cleanup / drainEvents / emit                                        │
│    before(hint) / after(hint, summary) ─► emit({type, kind, payload})  │
│    resolveAnchor / staggerFlashItems   ← _visual-<site>.js 覆盖        │
│  }                                                                     │
└────────────────────────────────────────────────────────────────────────┘
                          │ events.jsonl
                          ▼
┌─ Offline replay ──────────────────────────────────────────────────────┐
│  @js-eyes/visual-replay-hyperframes (lib/translator.js)                │
│      └ readVisualSession(dir) → buildTimeline(entries)                 │
│        → for each (before/after) pair: getTemplate(skillId, kind)      │
│        → 渲 HTML reddit-style 卡片 → composition.html                  │
└────────────────────────────────────────────────────────────────────────┘
```

### B 备用路（dev / debug）：PNG 截图

```js
// 仅作 dev 调试 / 旧 fixture 回归
const { makeFrameWriter, attachFrameRefsToEvents } = require('@js-eyes/visual-bridge-kit/dev');

// 显式把 captureFrame 挂回 wrapCallApi（主链路不会自动调）：
const writer = makeFrameWriter({ recordDir, getTabId, captureScreenshot });
await wrapCallApi(session, hint, fn, {
  buildSummary,
  extractPayload,
  captureFrame: writer, // dev only
});
```

PNG 仍可生成，但 A 路线 translator 不消费它。

## 三步接入指南

> 假设你的 skill 目录是 `skills/js-<site>-ops-skill/`，bridge 文件在 `bridges/`。

### 1. 加 dep

```jsonc
// skills/js-<site>-ops-skill/package.json
{
  "dependencies": {
    "@js-eyes/visual-bridge-kit": "^0.1.0"
  }
}
```

### 2. 在 session.js 用通用 expandBridgeSource

```js
// skills/js-<site>-ops-skill/lib/session.js
const path = require('path');
const { makeBridgeExpander } = require('@js-eyes/visual-bridge-kit');

const expandBridgeSource = makeBridgeExpander({
  baseDir: path.join(__dirname, '..', 'bridges'),
});
```

### 3. 在 bridges/common.js 顶部加 include

```js
// skills/js-<site>-ops-skill/bridges/common.js
// @@include @js-eyes/visual-bridge-kit/bridge/visual.common.js
// @@include ./_visual-<site>.js
```

> 这一行已经被 `// @@include ./common.js` 嵌入到每个 bridge 文件，所以只需写一次。

### 4. （站点专属）实现 anchor resolver

复制 `node_modules/@js-eyes/visual-bridge-kit/bridge/anchorResolver.template.js` 到 `bridges/_visual-<site>.js`，按站点 DOM 反查规则改写。reddit-ops 的实现参见 [skills/js-reddit-ops-skill/bridges/_visual-reddit.js](../../skills/js-reddit-ops-skill/bridges/_visual-reddit.js)。

### 5. 给每个工具写 visualHint

```js
// lib/visualHint.js
function getVisualHint(toolName, args, result){
  switch (toolName) {
    case 'site_get_post':
      return { kind: 'item', toolName, label: '读取帖子', anchor: args.id || null };
    case 'site_list_feed':
      return { kind: 'list', toolName, label: '抓取列表', anchor: null };
    default:
      return { kind: 'global', toolName, label: toolName };
  }
}
module.exports = { getVisualHint };
```

### 6. 在调度层包 wrapCallApi

```js
// lib/runTool.js
const { wrapCallApi, drainVisualEvents, appendVisualSession } = require('@js-eyes/visual-bridge-kit');
const { getVisualHint, buildSummary, extractPayload } = require('./visualHint');

const hint = getVisualHint(toolName, args);
const resp = await wrapCallApi(session, hint, async () => {
  return await session.callApi(method, [args]);
}, {
  buildSummary: (r) => buildSummary(r, hint),
  // ★ 主链路：把响应里的业务字段抽成 payload，下游 translator 按 hint.kind 路由 HTML 模板
  extractPayload: (r, h, e) => extractPayload(r, Object.assign({}, hint, { args }), e),
});

const events = await drainVisualEvents(session);
appendVisualSession(recordDir, {
  runId, skillId, toolName, args, hint, ok: resp.ok !== false, durationMs, events,
}, { skillId, skillVersion });
```

### 7. CLI 旋钮

```js
// lib/commands.js
function parseArgv(argv){
  // ...
  if (a === '--visual') opts.visual = true;
  else if (a === '--no-visual') opts.visual = false;
  else if (a === '--visual-detail') opts.visualDetail = argv[++i];
  else if (a === '--visual-ms') opts.visualMs = argv[++i];
  else if (a === '--visual-hud') opts.visualHud = true;
  else if (a === '--no-visual-hud') opts.visualHud = false;
  else if (a === '--visual-flash') opts.visualFlash = true;
  else if (a === '--no-visual-flash') opts.visualFlash = false;
  else if (a === '--visual-trace') opts.visualTrace = argv[++i];
  else if (a === '--visual-list-stride') opts.visualListStride = argv[++i];
  else if (a === '--visual-prefix') opts.visualPrefix = argv[++i];
}

// 之后给到 parseVisualFlags 时可以传 siteDefaults，覆盖默认值（例如 prefix）：
// parseVisualFlags(opts, { prefix: '__jse_<site>_visual_' })
```

## CLI flag 速查

| flag | 默认 | 说明 |
|---|---|---|
| `--visual` / `--no-visual` | 开 | 总开关 |
| `--visual-detail compact\|staged` | `staged` | `compact` 只 HUD，`staged` 全套（含 relation） |
| `--visual-ms <n>` | `420` | flash 持续时长（ms） |
| `--visual-hud` / `--no-visual-hud` | 开 | 右上角 HUD 卡片（v0.6.0 取代 `--visual-mode hud/dom`） |
| `--visual-flash` / `--no-visual-flash` | 开 | 元素 flash overlay + relation（v0.6.0 取代 `--visual-mode hud/dom`） |
| `--visual-trace <file>` | — | 把事件落到 jsonl |
| `--visual-list-stride <ms>` | `90` | 列表呼吸感的步进 |
| `--visual-prefix <p>` | `__jse_visual_` | DOM id 前缀（站点可用 `siteDefaults` 改默认值） |

## visualHint schema

```ts
type Hint = {
  kind: 'item' | 'list' | 'tree' | 'global' | 'navigation' | 'write';
  toolName: string;
  label: string;
  anchor?: string | object | null;     // 任何 resolveAnchor 能识别的 spec
  target?: string;                      // HUD 副标题
  detail?: string;                      // HUD 第三行
  tone?: 'info' | 'pending' | 'success' | 'danger';
};

type Summary = {
  ok: boolean;
  items?: Array<anchorSpec>;            // kind:'list' 时 in-page stagger flash
  relate?: Array<{from, to, label}>;    // kind:'tree' 时画 in-page relation 线
  errorCode?: string;
  detail?: string;
  payload?: object | null;              // ★ post-2.7.0：业务数据载荷
};
```

## events.jsonl payload schema (post-2.7.0)

`meta.json` 标 `payloadSchemaVersion: 1`。每条 entry 的 `events[*]` 中，`type === 'after'` 的事件多带一个 `payload` 字段，由 `hint.kind` 决定 shape：

| hint.kind | payload shape |
|---|---|
| `list` | `{ items: [{id,title,author,subreddit,score,num_comments,...}], totalCount, sub, sort }` |
| `item` | 单条 item 的字段（id/title/author/score/...）；或 `{ summary, fields:[{k,v}] }` 兜底 |
| `tree` | `{ items: [...], relations: [{from,to,depth?}] }` |
| `global` | `{ summary: string, fields: [{k,v}] }` |
| `navigation` | `{ from, to, hint: 'page_will_reload', label }` |
| `write` | 走 `global` 兜底（首版未做精细抽取） |

## 安全护栏

- `pointer-events: none` 永远不拦 reddit/x/zhihu 自家点击；
- 默认不 `scrollIntoView`（虚拟滚动列表不友好），元素不在视口自动降级 HUD；
- z-index 取 `2147483000`，低于 newidea outline 的 `2147483646`，不挡 site 自家 dialog；
- ring buffer 上限 200 条，防止 page 长时间运行后内存堆积；
- SPA 路由变化（`pushState/replaceState/popstate`）自动 `cleanup()`。

## 版本

- `0.1.0` 起步：HUD + flash + relation + jsonl trace + auto/dom/hud/both/off mode + SPA 防御。
- `0.4.0` 与 server-core 2.7.0 同步：`appendVisualSession` 会话包目录形态、`makeFrameWriter` 顶层 export。
- `0.4.x` (post-2.7.0 architecture pivot)：emit 主链路下线 `viewport / anchor.rect / frameRef`；新增 `hooks.extractPayload` 钩子；`makeFrameWriter` 等 PNG helpers 迁到 `@js-eyes/visual-bridge-kit/dev` 子路径；`meta.json` 加 `payloadSchemaVersion: 1`，去掉 `redact / frameCount`。版本号不动。
- `0.5.0` snapshot mode 主链路升级：`makeFrameWriter` 回到顶层 export；`wrapCallApi` 在每个命令边界截一帧 JPEG 写入会话包；新增 `viewport()` 给 frame event 带视口元数据。
- `0.5.3` 引入 `bumpCaptureSettleRelative` / `awaitCaptureSettle`：让 `wrapCallApi` 等到 in-page stagger flash 真的画完再触发 `chrome.tabs.captureVisibleTab`。
- `0.6.0` **BREAKING**：删除 `visualMode` 五值枚举（`auto|dom|hud|both|off`），拆为两个正交布尔位 `hud` / `flash` + 顶层 `enabled` 总开关。CLI 旧 `--visual-mode` 命中即列入 `deprecatedFlags` 并被忽略；caller 需自行展开为 `--visual-hud` / `--visual-flash` 组合（auto/both → 都 on；dom → flash on；hud → hud on；off → `--no-visual`）。

## 许可

MIT
