diff --git a/EMBED.md b/EMBED.md new file mode 100644 index 0000000..708d3ea --- /dev/null +++ b/EMBED.md @@ -0,0 +1,227 @@ +# zcbot 控制台 iframe 嵌入对接 + +> 给 **platform 工程**:把 zcbot 控制台(`/static/dev.html`)嵌入 platform 主页,需要做的事。 +> +> 假设:platform 和 zcbot **不同源**(eg. `https://portal.example.com` 嵌 `https://zcbot.example.com`)。同源直接共享 localStorage 不需要这套握手。 + +--- + +## 一句话总结 + +```html + +``` + +iframe 加载完会发 `postMessage({type:"zcbot-ready"})` 给 platform。platform 后端拿 `PLATFORM_KEY` 调 `POST /v1/auth/login` 换出 JWT,通过 `postMessage({type:"zcbot-token", token, user_id})` 推回 iframe。完事。 + +embed 模式下:左上 brand 隐藏 · 退出登录隐藏 · 登录页不显示 · "+ 新建任务"挪到任务面板。 + +--- + +## 1. URL 参数 + +| 参数 | 必填 | 说明 | +|---|---|---| +| `embed` | 是 | 固定 `1`,触发 embed 模式 | +| `parent_origin` | 是 | platform 主页 origin(scheme + host + 端口),postMessage 白名单。**没传 / 不匹配 → iframe 显错误占位、所有 message 都被丢** | + +示例: +``` +https://zcbot.example.com/static/dev.html?embed=1&parent_origin=https://portal.example.com +``` + +--- + +## 2. postMessage 协议 + +所有消息 `data` 都是 plain object,`type` 字段标识种类。两端都必须校验 `event.origin`。 + +### iframe → platform(子发父) + +| `type` | 时机 | 字段 | platform 应做 | +|---|---|---|---| +| `zcbot-ready` | iframe 加载完成 / 已有缓存 token 但仍发一次 | (无) | 检查当前用户 → 调后端换 JWT → 回推 `zcbot-token` | +| `zcbot-401` | 用户操作触发 401(token 过期 / 被吊销) | (无) | 重新换 JWT(可能要求用户重新登录 platform)→ 回推 `zcbot-token`;或决定关闭 iframe | + +### platform → iframe(父发子) + +| `type` | 字段 | iframe 行为 | +|---|---|---| +| `zcbot-token` | `token` (string, JWT)
`user_id` (string, UUID)
`user_name` (string, 可选) | 写 localStorage + 进入主界面;若已在主界面(401 重签场景)只重载任务列表 | + +--- + +## 3. platform 后端:换 JWT + +zcbot 已经有 SSO 入口,**platform 后端用共享的 `PLATFORM_KEY` 代任意用户换 JWT**: + +``` +POST https://zcbot.example.com/v1/auth/login +Content-Type: application/json + +{ + "user_id": "", + "platform_key": "<跟 zcbot env PLATFORM_KEY 同串>" +} +``` + +返回: +```json +{ + "token": "eyJhbGciOiJIUzI1NiIs...", + "user_id": "...", + "exp": 1740000000 +} +``` + +- `PLATFORM_KEY`:platform 和 zcbot **后端共享的密钥**,**绝不能下放到浏览器**。换 token 必须 platform 后端代理。 +- `user_id`:任意 UUID;首次出现 zcbot 会自动建 `users` 行(占位,无 email/密码),后续 task / message 都用这个 user_id 作为分区键。**platform 用户 ↔ zcbot user_id 映射由 platform 维护**(建议 1:1)。 +- JWT 默认 7 天 TTL(可改 `ZCBOT_JWT_TTL_SECONDS` env)。token 过期前 iframe 收到 401 → 发 `zcbot-401` 给父端 → 父端再走这个换 token 流程。 + +### Node.js 后端示例 + +```js +// POST /api/zcbot-token (platform 自己的 API,由前端登录态保护) +app.post("/api/zcbot-token", async (req, res) => { + const userId = req.session.zcbotUserId; // platform user → zcbot user_id 映射 + const r = await fetch("https://zcbot.example.com/v1/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + user_id: userId, + platform_key: process.env.ZCBOT_PLATFORM_KEY, + }), + }); + if (!r.ok) return res.status(502).json({ error: "zcbot login failed" }); + const { token } = await r.json(); + res.json({ token, user_id: userId, user_name: req.session.userName }); +}); +``` + +### Python (FastAPI) 后端示例 + +```python +import os, httpx +from fastapi import HTTPException + +@app.post("/api/zcbot-token") +async def issue_zcbot_token(user_id: str = Depends(current_user_id)): + async with httpx.AsyncClient(timeout=5) as c: + r = await c.post( + "https://zcbot.example.com/v1/auth/login", + json={"user_id": user_id, "platform_key": os.environ["ZCBOT_PLATFORM_KEY"]}, + ) + if r.status_code != 200: + raise HTTPException(502, "zcbot login failed") + data = r.json() + return {"token": data["token"], "user_id": user_id} +``` + +--- + +## 4. platform 前端:iframe + postMessage + +```html + + + +``` + +iframe **高度必须足够**(推荐 `100vh` 或固定大尺寸,zcbot 内部有多个全屏 `position:fixed` modal,iframe 太小会挤瘪)。 + +--- + +## 5. 安全要点 + +1. **`parent_origin` URL 参数必填** —— 缺失 / 非法 iframe 不工作。这是 postMessage 的 origin 白名单。 +2. **`PLATFORM_KEY` 只在 platform 后端** —— 等同 JWT 签名权限,泄漏 = 任意伪造任意用户。前端代码 / Git 仓库 / 客户端日志都不能出现。 +3. **iframe 收 message 强制校验 `event.origin === parent_origin`** —— 已在 dev.html 实现,父端也应对称校验 `event.origin === ZCBOT_ORIGIN`,防恶意页面伪造消息。 +4. **CSP `frame-ancestors`** —— 真发布时 zcbot 这边建议加响应头限制只允许 platform 域嵌入,目前 zcbot 未默认设置(本地宽松,见 §6)。 +5. **HTTPS 必须** —— `parent_origin` 是 `https://`,生产部署不要走 http(postMessage 在 http 不发警告,但中间人可改 iframe src 注入恶意 token)。 +6. **TTL**:JWT 默认 7 天。若 platform 希望更短(强制频繁刷新),改 zcbot env `ZCBOT_JWT_TTL_SECONDS` 即可。 + +--- + +## 6. zcbot 部署侧需要做的事 + +> 这一节是 **zcbot 运维 / 我自己** 要做的,platform 工程不需要操心,列出来供参考。 + +1. **CORS `allow_origins`** 收紧到 platform 域名(目前 `["*"]`,见 `web/app.py:516`) + ```python + allow_origins=["https://portal.example.com"], + allow_credentials=False, + ``` +2. **CSP `frame-ancestors`** 加响应头中间件,只放行 platform 域: + ```python + @app.middleware("http") + async def csp_headers(req, call_next): + resp = await call_next(req) + resp.headers["Content-Security-Policy"] = ( + "frame-ancestors https://portal.example.com" + ) + return resp + ``` + (FastAPI 默认不发 `X-Frame-Options`,iframe 加载本身不会被浏览器拦;`frame-ancestors` 是 CSP 替代品,更细粒度。) +3. **env**:`PLATFORM_KEY` / `JWT_SECRET` 必填(已是启动 fail-fast 校验,见 `web/auth.py:67`)。 + +--- + +## 7. 调试 + +**本地试 iframe(同机两端口)**: +- zcbot 跑 `8765` → 浏览器开 `http://127.0.0.1:8765/static/dev.html?embed=1&parent_origin=http://127.0.0.1:3000` +- 单独不带 platform 时 iframe 会显示"等待登录…",DevTools console 看 `postMessage` 收发。 +- 手动模拟 platform 推 token(在 iframe DevTools 里): + ```js + window.postMessage({type:"zcbot-token", token:"<手工 curl 换的 JWT>", user_id:""}, location.origin) + ``` + 注意 origin 必须 match `parent_origin` 参数,本机调试时把 `parent_origin` 设成 iframe 自己的 origin 即可(自发自收)。 + +**curl 试后端换 token**: +```bash +curl -X POST http://127.0.0.1:8765/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"user_id":"00000000-0000-0000-0000-000000000001","platform_key":""}' +``` + +--- + +## 8. 故障兜底 + +| 现象 | 原因 | 处置 | +|---|---|---| +| iframe 显示"embed 模式缺少 parent_origin 参数" | URL 里没传 `parent_origin` | 加上 `&parent_origin=https://...` | +| iframe 永远显"等待登录…" | platform 没收 / 没回 `zcbot-ready` | DevTools console 看 message;校验 `event.origin` 是否匹配 | +| iframe 收到 `zcbot-token` 但仍不进主界面 | `event.origin !== parent_origin` 或 `token` / `user_id` 字段缺失 | iframe 是静默丢弃,看 console 是否有报错;校验字段拼写 | +| 一动作就跳回等待页 | JWT 过期 / 后端校验失败,触发 `zcbot-401` | platform 重新换 token 回推;或检查 zcbot 端 JWT_SECRET 是否变过 | +| 嵌入页面 modal(新建任务 / 文件预览)被挤瘪 | iframe 高度不够 | iframe `height` 改 `100vh` 或更高,zcbot modal 是 `position:fixed; inset:0` | diff --git a/PROGRESS.md b/PROGRESS.md index dbc5212..4e93bf4 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -23,6 +23,7 @@ ### 2026-05-22 +- **dev SPA 加 iframe embed 模式(`?embed=1&parent_origin=...`)**:`web/static/dev.html` 加 embed 模式 — 父页面 iframe 嵌入时藏左上 brand / 顶栏 `#hd-who` / 退出登录按钮(桌面段整层 header `display:none`,移动段保留 header 给 `.mobile-tabs` 切换用),JS `embedInit` 把 `#hd-new`("+ 新建任务")从 header 节点移到任务面板 pane-head(spacer 之后、`#filter-status` 之前,加 `small` class 跟周边按钮对齐高度)。postMessage 协议:iframe 启动发 `{type:"zcbot-ready"}` 给父端,父端调自家后端用 `PLATFORM_KEY` 走 zcbot 已有的 `POST /v1/auth/login` 拿 JWT,通过 `{type:"zcbot-token", token, user_id, user_name?}` 推回 iframe;iframe 写 localStorage + `enterApp()`。401 时改写 `logout()` 不再 `location.reload()`,而是发 `{type:"zcbot-401"}` 通知父端重换 token,期间显灰底等待层(`#embed-waiting` spinner + 文案);新加 css class `body.embed-mode` / `body.embed-mode.embed-waiting` 控制可见性。**安全要点**:`event.origin` 双向校验(白名单 = URL 参数 `parent_origin`),缺参数直接显错误占位拒收;`PLATFORM_KEY` 留在 platform 后端绝不下发浏览器。`web/EMBED.md` 写给 platform 工程的对接手册(URL / 协议 / Node/Python 后端示例 / 父端前端示例 / CORS / CSP frame-ancestors 收紧建议 / 调试 + 故障兜底表)。否决:(a) URL 参数直接传 token —— Referer / 浏览器历史泄漏面;(b) 同源 + 共享 localStorage —— 用户明确说不同源;(c) 拆 dev.html 进 platform SPA route —— 工作量爆炸。 - **dev SPA chat-input 支持 Ctrl+V 粘贴文件上传 + chat-hint 反馈**:`web/static/dev.html` 给 `#chat-input` 加 `paste` 监听 —— `e.clipboardData.files` 非空时 `preventDefault` + 复用现有 `uploadFiles(files)` 走 `/v1/files/upload` 落到 `state.filesPath`(与拖拽到右 pane 同通路);纯文本粘贴走默认不拦。`uploadFiles` 改返回 bool(成功 true / 失败 false,原 alert 行为不变);粘贴 handler 通过 `chat-hint` 广播 "上传中:…" → "已粘贴:"(4s 后回前一个 hint,同 `optimizePrompt` 救回范式,不破坏 streaming/optimizing 期间的状态)。失败仍走 alert,hint 立即恢复。placeholder 提示加 `Ctrl+V 可粘贴文件`。常见场景:截图后直接 Ctrl+V 入对话区当作素材上传,免去切窗口走右 pane 拖拽。 - **dev SPA 文件预览弹框让出 chat-form 高度(打开期间输入区仍可点可打字)**:`web/static/dev.html` 给 `#file-preview-modal` 加 `bottom: var(--preview-bottom-inset, 0)` —— 默认 0 行为不变,`openFilePreview` 时 JS 量 `#chat-form.offsetHeight`(隐藏走 `offsetParent` 判空 → 0,无活动任务恢复全屏)写到弹框元素 inline style 上;`.card` 加 `max-height: calc(100vh - var(...) - 32px)` 让卡片随容器收缩不溢出,手机段同理用 `100dvh`。`closeFilePreview` `removeProperty` 清掉避免下次冗余。弹框遮罩本身物理上不覆盖底部输入区 → chat-form 自然可点击/打字,Esc 与点遮罩关闭逻辑不动。否决:(a) 整窗 `pointer-events: none` + card 收回 —— 遮罩物理还在覆盖,视觉仍遮挡;(b) 弹框抽进 `#pane-mid` 内 absolute —— 弹框来源含 `#pane-right` 文件列表和聊天 chip,挂 mid 内会限制弹框只能在 mid 列,且 `#pane-mid` 多层 flex 嵌套要重排;(c) 硬编一个常量 `bottom: 140px` —— chat-form 高度依据 textarea 用户拖拽变化(min 60 但可拉高),JS 量一次足够准。 - **对外路径协议刚性化(system 强约束 + SKILL 简化 + UI 一次性兼容)**:`prompts/system/general_v1.md`「路径」段加规则 —— 助手对外 echo 产物路径必须用 user_root 相对全形式 `/`(`` = task_dir 末段,如 `生图测试/videos/xxx.mp4` / `基金申报/sections/01-绪论.md` / `公司汇报/slides/deck.pptx`),不简写为 `videos/xxx.mp4` 这种 task 内裸形式(Web UI 按 `/` 前缀挂 chip,简写 → chip 失效用户点不开)。媒体 tool(`seedream` / `seedance`)的 `saved:` 行已是规范全形式可直接照抄,其他场景(ppt / proposal / coding 等 run_python/write 写文件)自己拼。**跨所有产物 skill 统一生效**(不止 imagegen/videogen)。`skills/imagegen/SKILL.md` + `skills/videogen/SKILL.md` 把原有"把 `saved: xxx` 告诉用户"重复教学改成"照抄 saved 行,详见 system「路径」段"(消除 skill 内具体写法 → 协议归一到 system,新产物 skill 不用重复教育)。ppt/proposal/coding 等 SKILL **不动** —— 它们只泛说"告诉用户文件路径"没教错,system 协议升级后助手自然按全形式 echo,加 skill 提醒反而是协议漂移源。`web/static/dev.html::extractArtifactRels` 加一次性兼容兜底:产物目录裸路径 `videos/xxx.` / `figures/xxx.`(协议刚性前历史简写)prepend `/` 拼成 user_root rel —— 白名单显式枚举两项不扩展、长期老消息归档后整段可删。**术语校准**:前缀叫 ``(working_dir 最后一段)而非 `` —— 用户允许 `wd_name ≠ task_name`(`build_agent` wd_raw 走 working_dir 字段独立可指定),`_display` 锚 user_root 出来的是 ``,SKILL/system 早期写 `task_name` 在分叉场景下会误导助手拼错前缀。否决:(a) 后端 `_display` 改 task-relative 让 tool 输出本身就裸 —— `Tool` 基类 + fs/skill_tool/seedream/seedance/agent_builder/smoke 改 8 个文件,且 fs 跨 task 时要分层 fallback(working_dir → user_root → 绝对),复杂度超过收益;(b) 后端补 HEAD 探针让前端验文件存在再挂 chip —— 工程量与开发期需求不匹配;(c) 白名单常驻服务所有简写形式 —— 维护负担+清单可能膨胀,改成"一次性兼容历史消息"角色后边界清晰;(d) 每个写产物的 SKILL 各加一句"按 system 协议" —— 协议漂移源,违反"system 谈通用、SKILL 谈领域"边界。 diff --git a/RUN.md b/RUN.md index 79089dc..1bc14d9 100644 --- a/RUN.md +++ b/RUN.md @@ -2,7 +2,7 @@ > 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md`。 -最后更新:2026-05-22(seedance 视频生成接入 — 同 ARK_API_KEY,新增 videogen skill) +最后更新:2026-05-22(dev SPA 加 iframe embed 模式 — `?embed=1&parent_origin=...`,对接见 `EMBED.md`) --- @@ -117,6 +117,8 @@ curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/ta **dev SPA**:打开 `http://127.0.0.1:8765/`(自动 302 → `/static/dev.html`),登录页两 tab(默认"邮箱密码",备用"UUID + PLATFORM_KEY",last-used 持久化 LS)进入 3 栏(task / chat / files)。给同事试用:`main.py user add` 发用户,**不用重启** web(每次 login 都查 DB),把 URL + 邮箱密码分别发给同事。 +**iframe 嵌入**(platform 主页内嵌):URL 加 `?embed=1&parent_origin=<父页面 origin>`,触发 embed 模式 —— 藏左上 brand / 退出按钮,登录页不显示,新建任务挪到任务面板;父页面通过 `postMessage` 协议推 JWT(`zcbot-ready` / `zcbot-token` / `zcbot-401`)。完整对接手册见 `EMBED.md`(URL 参数 / 协议 / 后端 SSO 示例 / 父端前端示例 / 安全 / 故障兜底)。 + ### 路由表 全 JSON,CORS `allow_origins=["*"]`;详细 schema 见 `/docs`。 diff --git a/web/static/dev.html b/web/static/dev.html index e92b1cf..7980b12 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -620,6 +620,33 @@ border-radius: 0; } } + + /* ───── embed mode (?embed=1&parent_origin=...) —— 父页面 iframe 嵌入 ───── + 藏左上 brand / 用户名 / 退出登录;桌面整层 header 去掉(没 mobile-tabs 切换需求); + "+ 新建任务" 由 JS 移到任务面板 pane-head。 */ + body.embed-mode #login { display: none !important; } + body.embed-mode header .brand, + body.embed-mode header #hd-who, + body.embed-mode header #hd-logout { display: none; } + @media (min-width: 641px) { + body.embed-mode header { display: none; } + } + #embed-waiting { + position: fixed; inset: 0; z-index: 90; + display: none; align-items: center; justify-content: center; + background: var(--bg); color: var(--muted); font-size: 13px; + flex-direction: column; gap: 12px; padding: 24px; + } + body.embed-mode.embed-waiting #embed-waiting { display: flex; } + body.embed-mode.embed-waiting #app { visibility: hidden; } + #embed-waiting .text { text-align: center; max-width: 80%; } + #embed-waiting .err { color: var(--accent); font-size: 12px; max-width: 80%; text-align: center; min-height: 1em; } + @keyframes embed-spin { to { transform: rotate(360deg); } } + #embed-waiting .spinner { + width: 24px; height: 24px; border-radius: 50%; + border: 2px solid var(--border); border-top-color: var(--accent); + animation: embed-spin .8s linear infinite; + } @@ -687,6 +714,13 @@ + +
+
+
等待登录…
+
+
+
@@ -854,6 +888,11 @@ const LS_UID = "zcbot.user_id"; const LS_NAME = "zcbot.name"; const LS_LEFT_COLLAPSED = "zcbot.left-collapsed"; +// ?embed=1&parent_origin=https://... → iframe 模式;父页面用 postMessage 推 token +const _embedQS = new URLSearchParams(location.search); +const EMBED = _embedQS.get("embed") === "1"; +const EMBED_PARENT_ORIGIN = (_embedQS.get("parent_origin") || "").trim(); + const state = { token: localStorage.getItem(LS_TOKEN) || "", userId: localStorage.getItem(LS_UID) || "", @@ -1121,6 +1160,12 @@ function logout() { localStorage.removeItem(LS_UID); localStorage.removeItem(LS_NAME); if (state.evtSrc) state.evtSrc.close(); + if (EMBED) { + embedPostToParent({ type: "zcbot-401" }); + embedShowWaiting("登录已失效,等待父页面重新签发…", false); + document.body.classList.add("embed-waiting"); + return; + } location.reload(); } $("hd-logout").onclick = logout; @@ -3145,8 +3190,73 @@ $("nt-wd-new").addEventListener("input", () => { updateWdHint(); }); +// ───── embed mode ───── +function embedPostToParent(msg) { + if (!EMBED_PARENT_ORIGIN || window.parent === window) return; + try { window.parent.postMessage(msg, EMBED_PARENT_ORIGIN); } catch (e) {} +} +function embedShowWaiting(text, isErr) { + const w = $("embed-waiting"); + if (!w) return; + if (isErr) { + w.querySelector(".text").textContent = ""; + w.querySelector(".err").textContent = text || ""; + w.querySelector(".spinner").style.display = "none"; + } else { + w.querySelector(".text").textContent = text || "等待登录…"; + w.querySelector(".err").textContent = ""; + w.querySelector(".spinner").style.display = ""; + } +} +function embedHandleMessage(e) { + if (e.origin !== EMBED_PARENT_ORIGIN) return; + const d = e.data || {}; + if (d.type === "zcbot-token" && d.token && d.user_id) { + state.token = d.token; + state.userId = d.user_id; + state.userName = d.user_name || ""; + localStorage.setItem(LS_TOKEN, state.token); + localStorage.setItem(LS_UID, state.userId); + if (state.userName) localStorage.setItem(LS_NAME, state.userName); + else localStorage.removeItem(LS_NAME); + document.body.classList.remove("embed-waiting"); + if ($("app").classList.contains("ready")) { + // 401 后重签:重载列表,不重复 enterApp + loadTaskList(); + } else { + enterApp(); + } + } +} +function embedInit() { + if (!EMBED_PARENT_ORIGIN) { + document.body.classList.add("embed-mode", "embed-waiting"); + embedShowWaiting("embed 模式缺少 parent_origin 参数 (URL 必须形如 ?embed=1&parent_origin=https://your-portal.com)", true); + return; + } + document.body.classList.add("embed-mode"); + // 把 #hd-new 从 header 移到任务面板 pane-head(spacer 之后、filter-status 之前) + const newBtn = $("hd-new"); + const head = document.querySelector("#pane-left .pane-head"); + const ref = $("filter-status"); + if (newBtn && head && ref) { + newBtn.classList.add("small"); + head.insertBefore(newBtn, ref); + } + window.addEventListener("message", embedHandleMessage); + if (state.token) { + enterApp(); + } else { + document.body.classList.add("embed-waiting"); + embedShowWaiting("等待登录…", false); + } + embedPostToParent({ type: "zcbot-ready" }); +} + // ───── boot ───── -if (state.token) { +if (EMBED) { + embedInit(); +} else if (state.token) { // 已有 token:试探一下,失败回登录页 enterApp(); } else {