feat(web): embed 模式接受 ?task_id=<uuid> URL 参数自动定位 task

首次签发 token / 已有缓存 token 两条进入路径都覆盖;
401 重签不重置选择,尊重用户中间 UI 切过的 task。
EMBED.md URL 参数表 + 故障兜底同步更新。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-05-22 15:27:19 +08:00
parent 758486e2cd
commit 72ae0ded0a
3 changed files with 17 additions and 1 deletions

View File

@ -24,10 +24,12 @@ embed 模式下:左上 brand 隐藏 · 退出登录隐藏 · 登录页不显示
|---|---|---| |---|---|---|
| `embed` | 是 | 固定 `1`,触发 embed 模式 | | `embed` | 是 | 固定 `1`,触发 embed 模式 |
| `parent_origin` | 是 | platform 主页 origin(scheme + host + 端口),postMessage 白名单。**没传 / 不匹配 → iframe 显错误占位、所有 message 都被丢** | | `parent_origin` | 是 | platform 主页 origin(scheme + host + 端口),postMessage 白名单。**没传 / 不匹配 → iframe 显错误占位、所有 message 都被丢** |
| `task_id` | 否 | task UUID。首次签发 token 后自动选中该 task 并加载其消息。仅在初次进入生效;后续用户在 UI 内切 task 或 401 重签都不会再回到此 task |
示例: 示例:
``` ```
https://zcbot.example.com/static/dev.html?embed=1&parent_origin=https://portal.example.com https://zcbot.example.com/static/dev.html?embed=1&parent_origin=https://portal.example.com
https://zcbot.example.com/static/dev.html?embed=1&parent_origin=https://portal.example.com&task_id=01HXXXXX-...
``` ```
--- ---
@ -225,3 +227,4 @@ curl -X POST http://127.0.0.1:8765/v1/auth/login \
| iframe 收到 `zcbot-token` 但仍不进主界面 | `event.origin !== parent_origin``token` / `user_id` 字段缺失 | iframe 是静默丢弃,看 console 是否有报错;校验字段拼写 | | iframe 收到 `zcbot-token` 但仍不进主界面 | `event.origin !== parent_origin``token` / `user_id` 字段缺失 | iframe 是静默丢弃,看 console 是否有报错;校验字段拼写 |
| 一动作就跳回等待页 | JWT 过期 / 后端校验失败,触发 `zcbot-401` | platform 重新换 token 回推;或检查 zcbot 端 JWT_SECRET 是否变过 | | 一动作就跳回等待页 | JWT 过期 / 后端校验失败,触发 `zcbot-401` | platform 重新换 token 回推;或检查 zcbot 端 JWT_SECRET 是否变过 |
| 嵌入页面 modal(新建任务 / 文件预览)被挤瘪 | iframe 高度不够 | iframe `height``100vh` 或更高,zcbot modal 是 `position:fixed; inset:0` | | 嵌入页面 modal(新建任务 / 文件预览)被挤瘪 | iframe 高度不够 | iframe `height``100vh` 或更高,zcbot modal 是 `position:fixed; inset:0` |
| 带 `task_id` 进来后没自动定位 / 报"加载失败" | task_id 不属于当前签发 token 的 user_id,或 UUID 拼写错 | 校验 platform 传的 task_id 归属对的 user;chat 区错误信息会显具体 message |

View File

@ -23,6 +23,7 @@
### 2026-05-22 ### 2026-05-22
- **embed 模式接受 `task_id` URL 参数定位 task**:`web/static/dev.html` 解析 `?task_id=<uuid>` 并在首次签发 token 后调 `selectTask(task_id)` 自动加载该 task 的消息列表;两条进入路径都覆盖 —— ① `embedHandleMessage` 收到 `zcbot-token` 首次 `enterApp()` 之后(无缓存 token 走父端握手);② `embedInit` 启动时 localStorage 已有 token 直接 `enterApp()` 之后。`_embedInitialTaskHandled` once 标记保证只生效一次 —— 401 重签时不重置选择(尊重用户中间 UI 切过别的 task),后续切 task 走 UI 用户主导。task_id 错或不属于当前 user → `selectTask` 走原有 401/404 错误分支(chat 区显"加载失败:…")。`EMBED.md` URL 参数表新增 `task_id` 行 + 故障兜底表加一行"带 task_id 没自动定位"。否决:(a) postMessage 协议加 `zcbot-task` 让父端任意时刻切 task —— 当前需求只到"打开 iframe 时定位",加协议增维护面;父端要切换可重载 iframe 走 URL 参数同一路径;(b) 把 task_id 塞进 `zcbot-token` payload —— token 是身份,task 是导航状态,语义混耦;(c) 同时支持 `?msg_id=` 滚动到特定消息 —— 用户没要求,加 anchor 还要改 `loadMessages` 渲染后滚动逻辑,YAGNI。
- **媒体生成每账号每日配额(yaml 可配,默 20 图 / 5 视频)**:`config/agent.yaml` 加 `quotas` 段(`images_per_day: 20` / `videos_per_day: 5`),`core/storage/usage.py::check_daily_quota(user_id, kind, limit)` SELECT COUNT FROM usage_events WHERE user_id=? AND kind IN(image/video) AND created_at >= 本地今日 00:00,`limit<=0` 短路不查 DB。`SeedreamTool` / `SeedanceTool` ctor 新增 `daily_limit` 形参由 `agent_builder` 从 yaml 透传,`execute()` 起手 if 超额返 `[Error] 已达每日 X 生成上限(N/M),次日 00:00 重置` 不调远端不烧钱。tool 返串会进 LLM 上下文 → 模型据此对用户解释,所以**只暴露已用/上限 + 重置时间**,不写 `config/agent.yaml::quotas.X` 这种 yaml 路径(否则 LLM 倾向原文复述,SaaS 场景泄漏内部 schema 给外部用户;管理员要调改自己读代码/yaml 找,30 秒事)。**跨 task 跨 variant 全口径合计** —— 配额是账号级与具体 variant 无关(seedream + 未来的 seedream_pro 共享同一 20 张池)。失败任务不计 —— record_*_usage 只在成功+下载完才落库,失败 retry 不烧配额符合直觉。并发 race(同 user 跨 task 两次 check 同时过)可接受 —— 软上限非计费 hard cap,日级偶尔多 1 张不破坏保护意图,不加事务锁。否决:(a) env 变量(`ZCBOT_IMAGES_PER_DAY` 等)—— 配额是业务策略不是部署秘密 / 环境差异,跟现有 yaml 类参数(默认 size / 价格 / 超时)分工一致,且 yaml 带注释 + 多值组合扩展自然(未来加 audio_per_day);(b) AgentLoop 集中 pre-flight —— 给 loop 加配额映射反而散,tool 层自检每次只多一行 SQL 亚毫秒,符合"工具按原子操作切分";(c) 滑动 24h 窗口 —— 用户直觉是"今天用完了明天再来"的日历日,服务器本地 00:00 重置语义更顺;(d) tool 返串里贴 yaml 路径给管理员看 —— LLM 会向用户复述,泄漏内部 schema。 - **媒体生成每账号每日配额(yaml 可配,默 20 图 / 5 视频)**:`config/agent.yaml` 加 `quotas` 段(`images_per_day: 20` / `videos_per_day: 5`),`core/storage/usage.py::check_daily_quota(user_id, kind, limit)` SELECT COUNT FROM usage_events WHERE user_id=? AND kind IN(image/video) AND created_at >= 本地今日 00:00,`limit<=0` 短路不查 DB。`SeedreamTool` / `SeedanceTool` ctor 新增 `daily_limit` 形参由 `agent_builder` 从 yaml 透传,`execute()` 起手 if 超额返 `[Error] 已达每日 X 生成上限(N/M),次日 00:00 重置` 不调远端不烧钱。tool 返串会进 LLM 上下文 → 模型据此对用户解释,所以**只暴露已用/上限 + 重置时间**,不写 `config/agent.yaml::quotas.X` 这种 yaml 路径(否则 LLM 倾向原文复述,SaaS 场景泄漏内部 schema 给外部用户;管理员要调改自己读代码/yaml 找,30 秒事)。**跨 task 跨 variant 全口径合计** —— 配额是账号级与具体 variant 无关(seedream + 未来的 seedream_pro 共享同一 20 张池)。失败任务不计 —— record_*_usage 只在成功+下载完才落库,失败 retry 不烧配额符合直觉。并发 race(同 user 跨 task 两次 check 同时过)可接受 —— 软上限非计费 hard cap,日级偶尔多 1 张不破坏保护意图,不加事务锁。否决:(a) env 变量(`ZCBOT_IMAGES_PER_DAY` 等)—— 配额是业务策略不是部署秘密 / 环境差异,跟现有 yaml 类参数(默认 size / 价格 / 超时)分工一致,且 yaml 带注释 + 多值组合扩展自然(未来加 audio_per_day);(b) AgentLoop 集中 pre-flight —— 给 loop 加配额映射反而散,tool 层自检每次只多一行 SQL 亚毫秒,符合"工具按原子操作切分";(c) 滑动 24h 窗口 —— 用户直觉是"今天用完了明天再来"的日历日,服务器本地 00:00 重置语义更顺;(d) tool 返串里贴 yaml 路径给管理员看 —— LLM 会向用户复述,泄漏内部 schema。
- **"+ 新建任务"按钮从 header 挪到任务面板 + 改通栏单独一行**:`web/static/dev.html` `#hd-new` 节点直接在 HTML 里挪到 `#pane-left`,放在第一行 `.pane-head`(任务标题 + 计数 + filter + 刷新 + 折叠)之下、搜索行之上的独立 `.pane-head` 行,`flex:1` 撑满整行(primary 红底通栏 CTA)。原本塞在第一行 spacer 之后,但 pane 320px 宽度下"+ 新建任务"中文五字会被挤断行,通栏解决根因。语义更贴(新建任务 = 任务面板的动作);顶栏减负只剩身份区(brand / 用户名 / 退出登录);两种模式 DOM 一致,顺手删了 `embedInit` 里动态 `insertBefore` 那段 + `@media phone``#hd-new` 紧凑覆盖(通栏环境不需要缩字号)。桌面 / 平板折叠态被 `#pane-left > * { display: none }` 自动藏掉,无需额外覆盖。 - **"+ 新建任务"按钮从 header 挪到任务面板 + 改通栏单独一行**:`web/static/dev.html` `#hd-new` 节点直接在 HTML 里挪到 `#pane-left`,放在第一行 `.pane-head`(任务标题 + 计数 + filter + 刷新 + 折叠)之下、搜索行之上的独立 `.pane-head` 行,`flex:1` 撑满整行(primary 红底通栏 CTA)。原本塞在第一行 spacer 之后,但 pane 320px 宽度下"+ 新建任务"中文五字会被挤断行,通栏解决根因。语义更贴(新建任务 = 任务面板的动作);顶栏减负只剩身份区(brand / 用户名 / 退出登录);两种模式 DOM 一致,顺手删了 `embedInit` 里动态 `insertBefore` 那段 + `@media phone``#hd-new` 紧凑覆盖(通栏环境不需要缩字号)。桌面 / 平板折叠态被 `#pane-left > * { display: none }` 自动藏掉,无需额外覆盖。
- **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 加 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 —— 工作量爆炸。

View File

@ -890,9 +890,12 @@ const LS_NAME = "zcbot.name";
const LS_LEFT_COLLAPSED = "zcbot.left-collapsed"; const LS_LEFT_COLLAPSED = "zcbot.left-collapsed";
// ?embed=1&parent_origin=https://... → iframe 模式;父页面用 postMessage 推 token // ?embed=1&parent_origin=https://... → iframe 模式;父页面用 postMessage 推 token
// 可选 task_id=<uuid>:首次签发 token 后自动定位到该 task 并加载消息
const _embedQS = new URLSearchParams(location.search); const _embedQS = new URLSearchParams(location.search);
const EMBED = _embedQS.get("embed") === "1"; const EMBED = _embedQS.get("embed") === "1";
const EMBED_PARENT_ORIGIN = (_embedQS.get("parent_origin") || "").trim(); const EMBED_PARENT_ORIGIN = (_embedQS.get("parent_origin") || "").trim();
const EMBED_INITIAL_TASK_ID = (_embedQS.get("task_id") || "").trim();
let _embedInitialTaskHandled = false;
const state = { const state = {
token: localStorage.getItem(LS_TOKEN) || "", token: localStorage.getItem(LS_TOKEN) || "",
@ -3222,10 +3225,15 @@ function embedHandleMessage(e) {
else localStorage.removeItem(LS_NAME); else localStorage.removeItem(LS_NAME);
document.body.classList.remove("embed-waiting"); document.body.classList.remove("embed-waiting");
if ($("app").classList.contains("ready")) { if ($("app").classList.contains("ready")) {
// 401 后重签:重载列表,不重复 enterApp // 401 后重签:重载列表,不重复 enterApp / 不重复定位 task(尊重用户中间切过的选择)
loadTaskList(); loadTaskList();
} else { } else {
enterApp(); enterApp();
// 首次签发:若 URL 带 task_id,定位到该 task(loadMessages 由 selectTask 触发)
if (EMBED_INITIAL_TASK_ID && !_embedInitialTaskHandled) {
_embedInitialTaskHandled = true;
selectTask(EMBED_INITIAL_TASK_ID);
}
} }
} }
} }
@ -3239,6 +3247,10 @@ function embedInit() {
window.addEventListener("message", embedHandleMessage); window.addEventListener("message", embedHandleMessage);
if (state.token) { if (state.token) {
enterApp(); enterApp();
if (EMBED_INITIAL_TASK_ID && !_embedInitialTaskHandled) {
_embedInitialTaskHandled = true;
selectTask(EMBED_INITIAL_TASK_ID);
}
} else { } else {
document.body.classList.add("embed-waiting"); document.body.classList.add("embed-waiting");
embedShowWaiting("等待登录…", false); embedShowWaiting("等待登录…", false);