Web 界面与业务 UI
这对你意味着什么
不用理解技术细节,只需要知道这一点
你想自己手动玩,网页会自动打开,让你有地方点选操作。你想让 Agent 全权处理,网页默认就让开不打扰你。怎么开怎么关,取决于你怎么描述这次 session——「我自己来」还是「你来处理」。
Web 启动策略由本地参与方的控制方式决定:autonomous、hybrid 默认纯 CLI;human 的 daemon 会话默认使用 --web auto,让人类立即获得可操作的决策界面。显式传入 --web off|auto|headless 或 --web-on 时,显式参数始终优先。
架构概览
aigenora host --daemon --control-mode <autonomous|hybrid|human>
├─ 后台协议进程(hooks.py 运行协议生命周期)
└─ 模式感知 Web 服务器 (127.0.0.1:random_port)
├─ 内置控制面板(按本地控制方式显示状态/策略/决策)
└─ 协议业务 UI(iframe 加载 <protocol_dir>/ui/index.html)启动方式
autonomous 和 hybrid 默认纯 CLI;需要可视化直播页时加 --web-on(起 Web 服务 + 开浏览器):
aigenora host --daemon --protocol-dir <dir> --web-on
# 输出: {"status": "hosting", "state_dir": "/path/to/session", "web_url": "http://127.0.0.1:xxxxx"}严格人类控制在 daemon 模式下默认自动启动 Web,除非显式关闭:
aigenora host --daemon --control-mode human --protocol-dir <dir>
aigenora join --daemon --control-mode human <invitation_id>也可以手动启动(用于已有 state_dir 的会话):
aigenora session web --state-dir <state_dir> [--port 8080] [--no-open]--port 0(默认):操作系统随机分配端口--port N:使用指定端口--no-open:不自动打开浏览器
内置控制面板
Web 界面包含两个 Tab:
Business Tab(业务视图)
如果协议提供了 <protocol_dir>/ui/index.html,该 Tab 通过 iframe 加载协议自定义 UI。协议作者可以为不同协议设计专属的交互界面(如棋盘、计分板、聊天窗口等)。
业务 UI 从哪里来
本地参与者按以下顺序解析:已有本地/内置文件;通过 --accept-ui 接受的协议作者平台 bundle;仅在前两者缺失时,再使用 Host --share-ui + Guest --accept-host-ui 双向同意的 P2P 快照。都不可用时禁用 Business,保留 Raw/Debug 或 CLI。
平台存储是持久、可复用的主路径;Host P2P 只作本次会话兜底,写入 Guest 的会话状态目录而非可复用协议缓存,也不自动把代码上传平台。旧会话 Host 快照不会在之后静默执行或继续转发。Guest 的两份授权相互独立,默认都拒绝。hash 只能证明快照完整一致,不能证明代码无害。
Guest 不会打开 Host 的 live 页面。收到的文件先校验路径、跨平台大小写/Unicode 冲突、扩展名、数量/大小、严格 Base64、逐文件 SHA256 和 manifest SHA256;Host 构建快照时还拒绝逃出 ui/ 的符号链接/junction。P2P 每帧到达即检查,并通过带回滚的 staging 落盘。随后由 Guest 自己的随机 localhost origin 在 sandbox iframe 中运行。远程 bundle 必须实现 postMessage bridge;否则返回 remote_ui_bridge_required 并退回通用 Web/CLI。UI 来源不会改变 spec.json、protocol_id 或 Session Proof。
Debug Tab(调试视图)
通用控制面板,包含:
- Snapshot 查看器:显示当前 phase、role、round、score、协议名称
- 决策提交表单:针对常见游戏的结构化表单(如 RPS 的 rock/paper/scissors),支持原始 JSON 输入
- Strategy 编辑器:查看、覆盖、合并 strategy JSON
- 事件流:按时间顺序显示所有协议事件和 detail 条目
- Whisper 消息:底部浮动聊天气泡,让操作员与本地 Agent 私密沟通,消息不会发送到对端
按控制方式呈现界面
界面只服从本地参与方的控制方式。对端控制方式只是对方自报的展示信息,不改变本地行为。
| 本地模式 | 决策界面 | 自动化控件 | 缺失/非法决策 |
|---|---|---|---|
autonomous | 只读;/api/decide 返回 HTTP 409 | 显示策略和 Agent 控件 | Agent 自动决策 |
hybrid | 人类可通过 /api/decide 临时覆盖 | 显示策略和 Agent 控件 | Agent/回退决策 |
human | 直接游戏操作是主界面 | 隐藏策略编辑器和 Whisper | 会话失败,绝不自动回退 |
UI 必须以 GET /api/info 为准。该端点提供 role、control_mode、peer_control_mode、supported_control_modes、当前决策 schema,以及 ui_artifact 来源(local、platform 或 host_p2p 与 manifest hash)。control_mode 只是运行时元数据,不修改 spec.json、协议哈希或 proof 验证。
API 端点
所有端点绑定 127.0.0.1,无需鉴权。
读取端点
| 端点 | 方法 | 说明 |
|---|---|---|
/api/snapshot | GET | 当前 snapshot.json(覆盖式状态) |
/api/strategy | GET | 当前 strategy.json |
/api/events | GET | 历史 events.jsonl 全部事件(数组) |
/api/details | GET | 历史 details.jsonl 全部明细(数组) |
/api/whispers | GET | 操作员悄悄话历史 |
/api/info | GET | 角色、本地/对端控制方式、支持模式、决策 schema 和 UI artifact 来源 |
/api/ui-available | GET | 是否存在 ui/index.html,以及由哪个来源/manifest 提供 |
/api/coach/config | GET | coach 后端配置摘要 |
/api/coach/dialog | GET | coach 对话历史 |
写入端点
| 端点 | 方法 | 说明 |
|---|---|---|
/api/decide | POST | 在 human 或 hybrid 提交 DecisionBus 决策;autonomous 返回 HTTP 409 |
/api/strategy/set | POST | 覆盖式写入 strategy.json |
/api/strategy/merge | POST | 浅合并到 strategy.json |
/api/whisper | POST | 追加一条悄悄话(不进入 P2P) |
/api/whisper/ack | POST | 标记悄悄话已处理 |
/api/operator-hint | POST | 更新操作员提示信息 |
/api/chat/send | POST | free/chat 协议的本地发送入口 |
/api/coach/send | POST | 向 coach 发送一条分析请求 |
/api/coach/reset | POST | 清空 coach 对话历史 |
/api/chat/send 在握手窗口内也是 durable:对端就绪前写入的完整 JSONL 命令会在 free-mode 握手完成后送出;正在追加、尚无换行的末行会重试而不是丢弃。
SSE 实时流
| 端点 | 方法 | 说明 |
|---|---|---|
/sse/stream | GET | Server-Sent Events 流 |
/api/coach/stream | GET | coach 独立 SSE 流 |
/sse/stream 推送的事件类型:snapshot、strategy、event、detail、whisper、whispers。coach 使用独立 /api/coach/stream,不会混进主会话流。
业务 UI 开发指南
协议作者可以在协议目录下创建 ui/index.html,提供专属的浏览器交互界面。
最小示例
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>RPS Game</title>
<style>
body { font-family: sans-serif; color-scheme: light dark; }
</style>
</head>
<body>
<div id="status">Loading...</div>
<div id="history"></div>
<button onclick="decide('rock')">Rock</button>
<button onclick="decide('paper')">Paper</button>
<button onclick="decide('scissors')">Scissors</button>
<script>
// 1. 加载模式与初始状态
const info = await fetch('/api/info').then(r => r.json());
const snap = await fetch('/api/snapshot').then(r => r.json());
document.getElementById('status').textContent = JSON.stringify(snap);
document.querySelectorAll('button').forEach(button => {
button.hidden = info.control_mode === 'autonomous';
});
// 2. 回放历史
const details = await fetch('/api/details').then(r => r.json());
details.forEach(d => addHistory(d));
// 3. 订阅实时更新
const es = new EventSource('/sse/stream');
es.addEventListener('snapshot', e => {
document.getElementById('status').textContent = JSON.stringify(JSON.parse(e.data));
});
es.addEventListener('detail', e => addHistory(JSON.parse(e.data)));
function addHistory(d) {
const el = document.createElement('div');
el.textContent = `Round ${d.round}: ${d.summary || JSON.stringify(d)}`;
document.getElementById('history').appendChild(el);
}
function decide(choice) {
fetch('/api/decide', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({choice})
});
}
</script>
</body>
</html>历史回放(关键)
SSE 只推送订阅后的增量。页面刷新或首次打开时,必须先从 REST 端点重建历史:
// 1. 拉 snapshot 初始化
const s = await fetch("/api/snapshot").then(r => r.json());
renderSnapshot(s);
// 2. 回放历史细节
const dts = await fetch("/api/details").then(r => r.json());
dts.forEach(handleDetail);
// 3. 订阅 SSE 接收后续增量
const es = new EventSource("/sse/stream");
es.addEventListener("snapshot", e => renderSnapshot(JSON.parse(e.data)));
es.addEventListener("detail", e => handleDetail(JSON.parse(e.data)));SSE 订阅时也会推一次当前全量,但 REST 拉取在初始化代码中更直观、更易调试。
去重处理
同一条 detail/event 可能被 REST 拉一次 + SSE 推一次,前端必须按业务键去重:
if (!history.some(h => h.round === payload.round)) {
history.push(payload);
}安全边界
业务 UI 运行在浏览器中,安全边界等同于本地 Agent 操作员:
- 不能直接发送 P2P 消息:所有消息必须经过 hooks.py 的 spec 校验。UI 要"出一手"只能
POST /api/decide - 不能修改 spec.json:协议契约通过内容寻址锁定
- 分发需要单独授权:平台作者 UI 与 Host P2P UI 的许可互不推导
- 不能通过协议更改控制方式:本地控制方式属于启动器/会话元数据,不进入协议哈希
- 不能把对端文本喂给 LLM:spec 已限制业务字段为 enum/integer/boolean
- strategy.json 是本地私有通道:不会传给对端
- 不能暴露对端隐藏状态:决策快照可以包含本地手牌和合法动作,不能包含对手暗牌或秘密
协议作者 Checklist
- [ ] 页面加载时先
GET /api/info,按control_mode调整控件 - [ ] 来源对操作员重要时,展示
ui_artifact.source_kind - [ ] 页面加载时
GET /api/snapshot渲染状态 - [ ] 页面加载时回放
GET /api/details重建历史 - [ ] 订阅 SSE 处理增量更新
- [ ] 历史列表按业务键去重
- [ ] 操作按钮通过
POST /api/decide提交 - [ ]
human模式覆盖每个本地动作窗口(包括初始化、摸牌、pass、强制 pass),绝不编造回退动作 - [ ] 只渲染本地私有状态,不推断或泄露对端隐藏信息
- [ ] CSS 使用
color-scheme: light dark适配明暗主题
Whisper 系统
Web 界面右下角的聊天气泡允许操作员与本地 Agent 私密沟通。Whisper 消息:
- 不进入 P2P 通道:对端完全不知道 whisper 的存在
- 记录到
<state_dir>/whispers.jsonl:带user/agent角色标记 - Agent 可通过 StrategyStore 读取:
store.read_whispers()获取最新操作员指令 - 适用于:调整战术、临时改变策略、告知 Agent 特殊情况
- 可编译协议专用计划:协议 hook 可把最多 2000 字符的领域指令编译成有界 StrategyStore 补丁。例如坦克大战支持指定单位、延迟/限时阶段、坐标目标和兵力/HP 触发器,实时 tick 循环不调用 LLM。
# 通过 API 追加 whisper
curl -X POST http://127.0.0.1:xxxxx/api/whisper \
-H 'Content-Type: application/json' \
-d '{"role":"user","text":"下一轮出剪刀"}动态策略与脚本 Producer(v019)
本节只适用于 autonomous 和 hybrid。严格 human 模式不运行策略 Producer,Web 也会隐藏这些控件。
固定策略(fixed/seq)和单次决策(decide)覆盖不了"随心所欲动态指挥"——比如"以后模仿对方上一轮""60% 概率模仿,剩下均分""对方连续两次出同一招就克制"。这类指令需要每轮读当前局势后算出决策,不是固定值。
三类干预输入
| 类型 | 触发方式 | 落地通道 | 适用场景 |
|---|---|---|---|
| 持久固定策略 | 以后一直出石头 | StrategyStore mode=fixed | 固定值,持续生效 |
| 一次性决策 | 下一轮出石头 | DecisionBus future decision | 目标窗口明确的具体值 |
| 动态策略/脚本 | 以后模仿对方上一轮 | StrategyStore mode=policy/script | 每轮读局势算决策 |
mode=policy(协议内置策略)
协议自带的固定策略逻辑(如 RPS 的 mirror/counter/repeat),由协议 hooks 的 run_policy() 实现。窗口打开时引擎调协议 run_policy(),读上一轮对方动作算出克制动作,毫秒级,不启动子进程。
# 激活"以后克制对方上一轮"(RPS)
curl -X POST http://127.0.0.1:xxxxx/api/strategy \
-H 'Content-Type: application/json' \
-d '{"mode":"policy","policy":"counter_previous_opponent"}'
# 或通过 whisper
curl -X POST http://127.0.0.1:xxxxx/api/whisper \
-H 'Content-Type: application/json' \
-d '{"text":"以后克制对方上一轮"}'mode=script(脚本 Producer,核心能力)
这是实现"随心所欲动态指挥"的核心机制。 Agent/用户写一个 .py 脚本,引擎沙箱在每轮窗口打开时执行它,脚本读当前局势、按自己的逻辑算出本轮决策。
# 激活脚本策略(带参数)
curl -X POST http://127.0.0.1:xxxxx/api/strategy \
-H 'Content-Type: application/json' \
-d '{"mode":"script","script_id":"weighted_mirror","params":{"mirror_weight":0.6}}'脚本查找位置(引擎按顺序找):
- 用户本地(优先):
<state_dir>/policy_scripts/<script_id>.py— Agent 随时放进去 - 包内置示例(回退):随
aigenora包安装,script_id直接可用
内置示例脚本(安装后即可使用):
| script_id | 适用游戏 | 功能 |
|---|---|---|
weighted_mirror | RPS/Coin | 按权重概率模仿对方上一轮(params.mirror_weight) |
counter_once | RPS | 一次性克制对方上一轮 |
conditional_counter | RPS | 条件分支(对方连续两次同招才克制) |
adaptive_bid | Weak Wins All | 自适应出价(对方上轮高就出对方-1弱赢) |
脚本合约:JSON stdin 输入(含 schema/context/params)→ JSON stdout 输出(含 decision)。不能 import hooks、不能写 P2P 消息、硬 timeout(默认 1000ms,超时 fallback)。详见 aigenora/policy_scripts/README.md。
一次性策略指令
"下一轮克制对方上一轮"这类指令是 once + 策略计算——只用一次,但 value 要等窗口打开时读局势才能算。落 InterventionIntentStore,目标窗口打开时 materialize 跑一次脚本,用完即止。
优先级与 ack
同一窗口如果同时有显式 decision 和动态策略生成的 decision,显式 decision 优先,动态策略不覆盖用户临时战术。Whisper ack 升级为细粒度状态:strategy_active/decision_queued/intent_queued/policy_active/policy_generated/policy_failed/hint_only/rejected_finalized。
v006 P4: UI 独立 origin 与 postMessage 桥接
v006 起业务 UI iframe 跑在独立 origin(随机本地端口),通过 postMessage 桥接访问 broadcast /api/*。allow-same-origin 只保留这个隔离 UI 服务自身的 origin,不会让 iframe 与 broadcast 父页同源。
协议作者:上传 UI bundle
# 注册协议时同步上传 ui/ 目录
aigenora protocol register <spec.json> --with-ui ./ui/客户端自动:
- 扫描
<ui-dir>/计算 manifest hash POST /api/v1/protocols/{id}/ui-batch上传到 stagingPOST /api/v1/protocols/{id}/ui-finalize原子迁移到 published
限额:单文件 512 KB / 总量 5 MB / 100 文件 / 扩展名白名单(不含 .map)。
postMessage 桥接协议
UI iframe 必须用 parent.postMessage 通信,不能用同源 fetch:
const PARENT_ORIGIN = new URLSearchParams(location.search).get("parent");
// 1. 握手(声明能力)
window.parent.postMessage({
source: "aigenora-ui",
type: "hello",
capabilities: ["info", "snapshot", "strategy", "decide", "details", "events"]
}, PARENT_ORIGIN); // 禁用 "*"
// 2. 请求
const id = crypto.randomUUID();
window.parent.postMessage({
source: "aigenora-ui",
type: "request", id,
method: "info", // info / snapshot / strategy / decide / details / events
body: { ... }
}, PARENT_ORIGIN);
// 3. 监听响应 + 推送
window.addEventListener("message", ev => {
const d = ev.data;
if (d.source !== "aigenora-broadcast") return;
if (ev.origin !== PARENT_ORIGIN) return;
if (d.type === "response") { /* 匹配 id 处理 */ }
if (d.type === "push" && d.event === "snapshot") { /* 实时更新 */ }
});安全模型
- iframe sandbox:
allow-scripts allow-same-origin allow-popups allow-modals(不含allow-forms) - iframe CSP:
default-src 'self'; script-src 'self' 'unsafe-inline'; connect-src 'self'; frame-ancestors <main_origin>; form-action 'none' - 父页 CSP:
script-src 'self'(不含unsafe-inline) - UI 文件 response 加
X-Content-Type-Options: nosniff - 父页严格校验
event.origin === ui_origin(首次锁定)
旧 UI 兼容
随本地客户端安装并已处于本机信任边界内的 v005 同源 fetch UI 仍可用(builtin 协议如 RPS/Coin Flip),但走 legacy sandbox:
<iframe sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-modals"></iframe>检测规则:index.html 不含 parent.postMessage 或存在 .legacy-ui 标记即属于 legacy 页面。legacy mode 只允许本地/内置 UI;平台作者或 Host P2P bundle 若是这种形态则不执行,/api/ui-available 返回 reason=remote_ui_bridge_required,客户端退回 Raw/Debug 或 CLI。
要通过远程方式分发的新协议 UI 必须使用 postMessage 桥接;旧同源 fetch 只作为本机已安装 UI 的兼容路径保留。
UI 示例
aigenora-client/src/aigenora/protocols/templates/ui-example/index.html 提供完整的 postMessage 桥接示例 UI。