feat(preview): add multi-file web project previews

This commit is contained in:
caoqianming 2026-08-21 11:15:53 +08:00
parent efcf06bf24
commit 503820671a
22 changed files with 1054 additions and 5 deletions

View File

@ -8,6 +8,8 @@
## Unreleased ## Unreleased
- 现在可以让助手创建 React、Vue、Vite 或普通多文件网页项目,并在完成静态构建后直接打开交互预览;新建 Web 和通用软件项目会默认优先采用 Node.js + TypeScript已有项目保持原技术栈科学计算、数据分析和材料研发仍会在更合适时使用 Python。CSS、JavaScript、图片和前端路由会作为完整项目加载不再局限于单个 HTML 文件。预览卡片在刷新或回看对话后仍可使用,也可以随时停止。
- 修复国际旗舰模型输出思考过程时偶尔无法及时停止的问题;即使模型暂时没有返回新的流式片段,点击“停止”也会迅速中断当前回答。 - 修复国际旗舰模型输出思考过程时偶尔无法及时停止的问题;即使模型暂时没有返回新的流式片段,点击“停止”也会迅速中断当前回答。
- Windows Node 主窗口改为简洁的单层自适应布局顶部只保留产品名与节点状态移除固定宽度、重复卡片边框和说明性大标题默认窗口适当增高缩放时内容和页签会随可用宽度调整专业软件的状态与操作也会在窄窗口中自然换行。可用软件按名称归并不再重复显示同一软件或能力托盘右键菜单中的长状态也会自动截断不再把菜单横向撑宽。Origin、ANSYS、Blender 均可手工选择位置Origin 自动检测也会在 COM 路径无效时继续查找注册表和标准安装目录。 - Windows Node 主窗口改为简洁的单层自适应布局顶部只保留产品名与节点状态移除固定宽度、重复卡片边框和说明性大标题默认窗口适当增高缩放时内容和页签会随可用宽度调整专业软件的状态与操作也会在窄窗口中自然换行。可用软件按名称归并不再重复显示同一软件或能力托盘右键菜单中的长状态也会自动截断不再把菜单横向撑宽。Origin、ANSYS、Blender 均可手工选择位置Origin 自动检测也会在 COM 路径无效时继续查找注册表和标准安装目录。

View File

@ -498,6 +498,14 @@ ANSYS 能力固定面向 Windows 上的 Mechanical 2024 R2revision 242
后续仍需实现 Token 轮换;不得以任意命令或脚本接口临时代替。当前 Job 中心采用轮询而非用户事件推送,单活与 offer 选择仍只覆盖单 Web 进程;生产启用多实例前必须增加 Redis/PG fencing 或固定路由到单一控制面实例。 后续仍需实现 Token 轮换;不得以任意命令或脚本接口临时代替。当前 Job 中心采用轮询而非用户事件推送,单活与 offer 选择仍只覆盖单 Web 进程;生产启用多实例前必须增加 Redis/PG fencing 或固定路由到单一控制面实例。
### 8.17 静态多文件 Web 项目预览implementation2026-08-21
单 HTML 预览继续服务自包含文件;正常软件项目不再为了预览而内联全部 CSS、JavaScript 和资源。Agent 在 task 工作目录中创建、测试并构建项目,再以 `publish_web_preview(directory, entry, spa_fallback)` 发布现有静态输出目录。`web_previews` 只保存 user/task 归属、task-relative 根目录、入口、容量与生命周期,真实文件仍是事实源;同一 task、目录和入口重复发布会刷新原会话避免每次修改都制造新记录。第一阶段明确不启动开发服务器或后端进程React/Vue/Vite 等必须先形成静态构建产物;动态/全栈预览另设专用 runtime不能把通用 bg proc 直接暴露端口。
预览控制 API 继续使用用户 Bearer JWT打开卡片时才签发绑定 `preview_id` 与到期时间的短能力 URL签名不落库、不进入模型结果。文件网关逐请求从 task 当前 `working_dir` 解析目录,因此顶层目录改名后会随 DB 身份继续工作;所有资源再次做 resolve/relative-to 校验,发布时拒绝符号链接并限制单文件、文件数和总大小。入口 HTML 和 CSS 只重写根绝对资源地址并注入预览 base普通二进制按 MIME 流式返回SPA 只对浏览器请求的无扩展路径回退入口,缺失资源仍为 404。
当前网关与 zcbot 共用 host但 HTML 响应同时用 CSP `sandbox` 和 iframe sandbox 强制 opaque origin且不授予 `allow-same-origin`;这一约束在签名 URL 被顶层打开时同样生效,项目代码不能读取主站 localStorage/JWT。`Referrer-Policy: no-referrer` 防能力 URL 随外链泄露,资源 URL 自带同一签名所以 ES module 不依赖认证 Cookie。将来需要 LocalStorage、Service Worker 或全栈服务器时,升级条件是独立 wildcard preview origin + 专用只挂载当前项目目录的容器 + HTTP/WebSocket/SSE 代理、健康检查和空闲回收,而不是放宽当前同源 sandbox。
--- ---
## 附录:DeepSeek V4 关键事实(2026-04-24) ## 附录:DeepSeek V4 关键事实(2026-04-24)

View File

@ -20,6 +20,8 @@
--- ---
## 已完成关键能力 ## 已完成关键能力
- **08-21 / Unreleased / 静态多文件 Web 项目预览**Agent 可在 task 中创建并测试正常的多文件前端项目,构建后用 `publish_web_preview` 发布 `dist/build` 等静态输出;对话直播和历史回看都会显示可打开、停止的预览卡片。新增 task-scoped `web_previews` 生命周期与签名资源网关,支持相对资源、常见根绝对资源、正确 MIME 和 SPA 路由回退;路径穿越、符号链接、文件数量/大小有机械限制HTML 在 iframe 与响应头双重 opaque sandbox 中运行,不能获得 zcbot 同源权限。coding skill 与系统提示同步采用技术栈路由:已有项目保持原栈,新建 Web/通用软件默认 Node.js + TypeScript、前端默认 Vite科学计算、数据分析、机器学习和材料研发在更合适时继续使用 Python随后按“依赖安装→测试→构建→预览”执行。skill 官方校验、技术栈与预览专项 18 项、全量 43 项 Node 及定向 66 项 Python 回归通过;全量 Python 710 项仍仅 3 个既有数据库集成模块因显式测试库缺少 `users` 表未通过(另跳过 5 项),未连接或迁移生产数据库。动态后端/开发服务器预览留待独立 runtime 阶段。
- **08-21 / Unreleased / Windows Node 任务安全退出**:托盘退出在存在活动任务时提供等待完成、取消任务或返回三种选择;默认等待路径先断开调度并复用连接循环既有的任务/上传收尾屏障,取消路径二次确认后同时取消输入下载与 adapter 进程树、持久化 `NODE_EXIT_CANCELLED` 终态,最终都等待连接任务结束后才关闭消息循环,等待期间禁用重复操作与重连,避免孤儿 Worker、并发恢复和重启误判。Windows Node、专业软件节点与合同共 68 项 unittest、.NET build 与 diff 检查通过,无 DB、schema、HTTP API、依赖或运行方式变化。 - **08-21 / Unreleased / Windows Node 任务安全退出**:托盘退出在存在活动任务时提供等待完成、取消任务或返回三种选择;默认等待路径先断开调度并复用连接循环既有的任务/上传收尾屏障,取消路径二次确认后同时取消输入下载与 adapter 进程树、持久化 `NODE_EXIT_CANCELLED` 终态,最终都等待连接任务结束后才关闭消息循环,等待期间禁用重复操作与重连,避免孤儿 Worker、并发恢复和重启误判。Windows Node、专业软件节点与合同共 68 项 unittest、.NET build 与 diff 检查通过,无 DB、schema、HTTP API、依赖或运行方式变化。
- **08-21 / Unreleased / Blender 多视图与剖切 v2**:以 `blender.scene.author@v2` 直接替换 v1目标节点为 Blender 5.2 LTS`rotary_kiln` 一次建模后可连续渲染等轴测、侧视、窑头、窑尾、纵向剖切和横截面,完整工程保持未剖切状态;固定等轴测预览外,其他选定 PNG 可动态自动回传GLB 排除仅供剖切渲染的辅助几何。专业软件提交工具改为区分缺失参数与合法空数组,允许 Blender 这类无输入、无额外导出请求的纯生成任务进入合同校验90 项 Blender/合同/Job/Node/预览专项 unittest、.NET build、独立 adapter 打包和 Blender 3.6.12 兼容烟测通过,全量 698 项仍仅 3 个既有数据库集成模块因显式测试库缺少 `users` 表未通过(另跳过 4 项),目标节点 5.2 真机验收待部署后执行,未连接或迁移生产数据库。 - **08-21 / Unreleased / Blender 多视图与剖切 v2**:以 `blender.scene.author@v2` 直接替换 v1目标节点为 Blender 5.2 LTS`rotary_kiln` 一次建模后可连续渲染等轴测、侧视、窑头、窑尾、纵向剖切和横截面,完整工程保持未剖切状态;固定等轴测预览外,其他选定 PNG 可动态自动回传GLB 排除仅供剖切渲染的辅助几何。专业软件提交工具改为区分缺失参数与合法空数组,允许 Blender 这类无输入、无额外导出请求的纯生成任务进入合同校验90 项 Blender/合同/Job/Node/预览专项 unittest、.NET build、独立 adapter 打包和 Blender 3.6.12 兼容烟测通过,全量 698 项仍仅 3 个既有数据库集成模块因显式测试库缺少 `users` 表未通过(另跳过 4 项),目标节点 5.2 真机验收待部署后执行,未连接或迁移生产数据库。

6
RUN.md
View File

@ -95,6 +95,9 @@
# 后台进程(bg proc,DESIGN §8.12;shell/run_python background=true):每用户并发上限,默 3。 # 后台进程(bg proc,DESIGN §8.12;shell/run_python background=true):每用户并发上限,默 3。
# 状态锚 <user_root>/.zcbot_procs/,后台默认限时 7200s(工具 timeout 参数可调,cap 86400)。 # 状态锚 <user_root>/.zcbot_procs/,后台默认限时 7200s(工具 timeout 参数可调,cap 86400)。
# ZCBOT_MAX_BG_PROCS=3 # ZCBOT_MAX_BG_PROCS=3
# 静态多文件 Web 项目预览会话 TTL(秒),默认 7 天,平台限制 5 分钟30 天。
# 改后重启 web 生效;只影响新建/再次发布的预览。
# ZCBOT_WEB_PREVIEW_TTL_SECONDS=604800
# 定时任务守护循环(DESIGN §8.5,随 web 进程起,plain-asyncio 仿 _disk_scanner): # 定时任务守护循环(DESIGN §8.5,随 web 进程起,plain-asyncio 仿 _disk_scanner):
# ZCBOT_DISABLE_SCHEDULER=1 # 可选,整体关掉调度(对照 Claude Code CLAUDE_CODE_DISABLE_CRON) # ZCBOT_DISABLE_SCHEDULER=1 # 可选,整体关掉调度(对照 Claude Code CLAUDE_CODE_DISABLE_CRON)
# ZCBOT_SCHEDULER_TICK_SECONDS=10 # 可选,扫描间隔,默 10s(只决定最坏延迟≤1tick,不影响会否漏) # ZCBOT_SCHEDULER_TICK_SECONDS=10 # 可选,扫描间隔,默 10s(只决定最坏延迟≤1tick,不影响会否漏)
@ -374,6 +377,9 @@ $env:ZCBOT_EVAL_TOKEN = "<dedicated-eval-user-jwt>"
| `POST /v1/asr/transcribe` | body 为裸 PCM(16kHz/16bit/单声道/小端,`application/octet-stream`)→ 讯飞 IAT 整段转写,返 `{text}`;>60s → 413;`XFYUN_*` env 未配 → 501;讯飞侧错误 → 502(带错误码提示)。流式 WS 连不上时前端的兜底通道 | 必填 | | `POST /v1/asr/transcribe` | body 为裸 PCM(16kHz/16bit/单声道/小端,`application/octet-stream`)→ 讯飞 IAT 整段转写,返 `{text}`;>60s → 413;`XFYUN_*` env 未配 → 501;讯飞侧错误 → 502(带错误码提示)。流式 WS 连不上时前端的兜底通道 | 必填 |
| `GET /v1/files?path=&include_hidden=&task_id=` | 列 user_root 下条目 + 面包屑dotfile/dotdir 默认隐藏。`include_hidden=true` 仅在同时提供当前用户的 `task_id``path` 位于该 task working_dir 子树时展示点目录user_root 平台目录与点文件始终不展示 | 必填 | | `GET /v1/files?path=&include_hidden=&task_id=` | 列 user_root 下条目 + 面包屑dotfile/dotdir 默认隐藏。`include_hidden=true` 仅在同时提供当前用户的 `task_id``path` 位于该 task working_dir 子树时展示点目录user_root 平台目录与点文件始终不展示 | 必填 |
| `GET /v1/files/download?path=` | 下单文件 | 必填 | | `GET /v1/files/download?path=` | 下单文件 | 必填 |
| `GET /v1/web-previews/{id}` | 查询本人静态 Web 项目预览状态 | 必填 |
| `POST /v1/web-previews/{id}/launch` | 为本人活动预览签发不落库的到期访问 URL | 必填 |
| `DELETE /v1/web-previews/{id}` | 幂等停止本人静态 Web 项目预览 | 必填 |
| `GET /v1/tasks/{id}/files/download?path=` | 下载结构化产物;`path` 以该 task 当前 working_dir 为根,顶层目录改名后无需改历史消息;跨用户/越界/不存在均拒绝。`legacy=true` 仅供前端读取迁移前卡片,按旧 user-root→两种 task-relative 含义顺序兼容 | 必填 | | `GET /v1/tasks/{id}/files/download?path=` | 下载结构化产物;`path` 以该 task 当前 working_dir 为根,顶层目录改名后无需改历史消息;跨用户/越界/不存在均拒绝。`legacy=true` 仅供前端读取迁移前卡片,按旧 user-root→两种 task-relative 含义顺序兼容 | 必填 |
| `GET /v1/tasks/{id}/files/preview_pdf?path=` | PPT/PPTX 结构化产物按 task-relative 路径转 PDF 预览;状态码与旧 `/v1/files/preview_pdf` 一致;旧卡片可同样传 `legacy=true` | 必填 | | `GET /v1/tasks/{id}/files/preview_pdf?path=` | PPT/PPTX 结构化产物按 task-relative 路径转 PDF 预览;状态码与旧 `/v1/files/preview_pdf` 一致;旧卡片可同样传 `legacy=true` | 必填 |
| `POST /v1/files/upload` | multipart 上传到 `<user_root>/<path>/`;路径不存在自动 mkdir`conflict` 默认 `rename`,重名原子保存为 `name (n).ext`;仅显式 `conflict=overwrite` 才覆盖,单文件可用 `target_name` 指定要替换的目标文件名 | 必填 | | `POST /v1/files/upload` | multipart 上传到 `<user_root>/<path>/`;路径不存在自动 mkdir`conflict` 默认 `rename`,重名原子保存为 `name (n).ext`;仅显式 `conflict=overwrite` 才覆盖,单文件可用 `target_name` 指定要替换的目标文件名 | 必填 |

View File

@ -1,7 +1,7 @@
# zcbot Skill 清单 # zcbot Skill 清单
服务对象:中国建筑材料科学研究总院 —— 无机非金属材料 R&D(水泥 / 混凝土 / 玻璃 / 陶瓷 / 耐火 / 新型建材) 服务对象:中国建筑材料科学研究总院 —— 无机非金属材料 R&D(水泥 / 混凝土 / 玻璃 / 陶瓷 / 耐火 / 新型建材)
最后更新:2026-08-06(PPT 取消定向模型,跟随当前对话模型) 最后更新:2026-08-21(coding 增加 Node.js/TypeScript 优先的技术栈路由与静态多文件 Web 预览)
Skill 总数:18 Skill 总数:18
zcbot 的"skill"是一份可加载的工作流脚本(`skills/<name>/SKILL.md` + 配套 templates / scripts / Python helper),模型在识别用户意图后挂载对应 skill,按其内置的阶段化流程产出可交付物。本文档面向**使用方 / 协作方**,按"做什么、什么时候用、什么时候别用、典型产物"组织。 zcbot 的"skill"是一份可加载的工作流脚本(`skills/<name>/SKILL.md` + 配套 templates / scripts / Python helper),模型在识别用户意图后挂载对应 skill,按其内置的阶段化流程产出可交付物。本文档面向**使用方 / 协作方**,按"做什么、什么时候用、什么时候别用、典型产物"组织。
@ -30,7 +30,7 @@ zcbot 的"skill"是一份可加载的工作流脚本(`skills/<name>/SKILL.md` +
| 内容生成 | [imagegen](#imagegen) | 豆包 Seedream 5.0 文生图 + 改图 i2i(¥0.22 / 张) | | 内容生成 | [imagegen](#imagegen) | 豆包 Seedream 5.0 文生图 + 改图 i2i(¥0.22 / 张) |
| 内容生成 | [videogen](#videogen) | 豆包 Seedance 2.0 文生视频(¥1.86 起 / 段) | | 内容生成 | [videogen](#videogen) | 豆包 Seedance 2.0 文生视频(¥1.86 起 / 段) |
| 通用 | [analyze](#analyze) | 科学问题拆解 / 引导(模糊命题 → 子问题 + 路线图) | | 通用 | [analyze](#analyze) | 科学问题拆解 / 引导(模糊命题 → 子问题 + 路线图) |
| 通用 | [coding](#coding) | 修代码 / 调试 / 重构 | | 通用 | [coding](#coding) | 修代码 / 调试 / 重构 / 创建可预览前端项目 |
| 元能力 | [skill-creator](#skill-creator) | 引导用户创建 / 改造自己的私有 skill(从零写或 fork 内置) | | 元能力 | [skill-creator](#skill-creator) | 引导用户创建 / 改造自己的私有 skill(从零写或 fork 内置) |
--- ---
@ -499,7 +499,7 @@ paper_server 是内部 Django 文献库:元数据来自 OpenAlex,PDF / XML 由 S
--- ---
### coding ### coding
**修代码 / 调试 / 实现。** **修代码 / 调试 / 实现,也可从零创建可预览的前端软件项目。**
通用工具足够,没有专属 scripts 或 templates —— 代码任务的多样性来自代码本身。 通用工具足够,没有专属 scripts 或 templates —— 代码任务的多样性来自代码本身。
@ -509,11 +509,16 @@ paper_server 是内部 Django 文献库:元数据来自 OpenAlex,PDF / XML 由 S
- **edit 唯一匹配** —— old_str 必须在文件里出现且仅出现一次,不够唯一就多带上下文 - **edit 唯一匹配** —— old_str 必须在文件里出现且仅出现一次,不够唯一就多带上下文
- **验证优先** —— 项目有测试就 `pytest` / `npm test`;没有就写最小复现脚本 - **验证优先** —— 项目有测试就 `pytest` / `npm test`;没有就写最小复现脚本
- **不臆造 API** —— 没用过的库先 read 它的源码或文档,不要凭直觉拼方法名 - **不臆造 API** —— 没用过的库先 read 它的源码或文档,不要凭直觉拼方法名
- **技术栈路由** —— 已有项目沿用原技术栈;新建 Web/通用软件默认优先 Node.js + TypeScript前端默认 Vite科学计算、数据分析、机器学习和材料研发在 Python 生态更合适时优先 Python
- **Node 项目流程** —— 新项目默认 npm保留正常 `package.json`、源码、资源、配置和测试,按项目脚本完成依赖安装、测试与构建
- **项目预览** —— 保持正常多文件结构,完成测试和正式构建后用 `publish_web_preview` 发布 `dist/build` 等静态输出;修改后重新构建并发布同一目录即可刷新
- **能力边界** —— 当前在线预览只运行静态前端;需要后端、数据库、开发服务器或服务端渲染时交付源码与运行说明,不拿临时后台进程冒充可访问预览
**反模式**(明确禁止): **反模式**(明确禁止):
- 一次性 write 整个大文件(改 3 行就别 write 200 行) - 一次性 write 整个大文件(改 3 行就别 write 200 行)
- 没读过文件直接 edit(大概率 old_str 匹配不上) - 没读过文件直接 edit(大概率 old_str 匹配不上)
- 跑测试失败就立刻改测试(先看是测试错还是代码错) - 跑测试失败就立刻改测试(先看是测试错还是代码错)
- 把 CSS/JS/资源强行内联成单 HTML或启动临时服务后声称用户可以访问
**输出**:改完后一两句话说清「改了什么、为什么、怎么验证」,**不复述 diff** —— 用户会自己看。 **输出**:改完后一两句话说清「改了什么、为什么、怎么验证」,**不复述 diff** —— 用户会自己看。

View File

@ -421,6 +421,15 @@ def _build_system_prompt(
"office_to_pdf、图像和视频工具会自动发布其成品无需重复调用。\n" "office_to_pdf、图像和视频工具会自动发布其成品无需重复调用。\n"
if allow_working_dir_rename else "" if allow_working_dir_rename else ""
) )
web_preview_line = (
"新建 Web 或通用软件项目默认优先 Node.js + TypeScript前端默认 Vite已有项目"
"沿用原技术栈;科学计算、数据分析、机器学习和材料研发任务在 Python 生态更合适时"
"优先 Python。用户要求编写可交互网页或前端软件并预览时创建正常的多文件项目"
"完成依赖安装、测试和静态构建后调用 `publish_web_preview` 发布构建目录;不要为了"
"迁就单文件预览把 CSS、JavaScript 和资源强行内联。该入口只服务静态构建产物,不"
"把需要后端或开发服务器的项目伪装成可运行预览。\n"
if allow_working_dir_rename else ""
)
office_pdf_hint = ( office_pdf_hint = (
"已有 Office 文件需要转 PDF 时,仅当 host-side `office_to_pdf` 的工具说明列出该格式" "已有 Office 文件需要转 PDF 时,仅当 host-side `office_to_pdf` 的工具说明列出该格式"
"才调用LibreOffice 在 backend host不在 Docker shell 内探测 `soffice`。\n" "才调用LibreOffice 在 backend host不在 Docker shell 内探测 `soffice`。\n"
@ -440,6 +449,7 @@ def _build_system_prompt(
f"⛔ 不要把产物写到 cwd / `skills/` / repo 根 —— 只写到 task_dir。\n" f"⛔ 不要把产物写到 cwd / `skills/` / repo 根 —— 只写到 task_dir。\n"
f"{rename_line}" f"{rename_line}"
f"{publish_line}" f"{publish_line}"
f"{web_preview_line}"
f"\n## 生成 Word / PDF 报告(验收 / 技术 / 评审报告等自由长文)\n" f"\n## 生成 Word / PDF 报告(验收 / 技术 / 评审报告等自由长文)\n"
f"**优先**把正文写成 Markdown(`<task_dir>/sections/*.md`,纯文本、零转义 / 零语法风险)," f"**优先**把正文写成 Markdown(`<task_dir>/sections/*.md`,纯文本、零转义 / 零语法风险),"
f"再调平台渲染器直接出 docx 或 pdf —— **别在 run_python 里手撸文档转换脚本**" f"再调平台渲染器直接出 docx 或 pdf —— **别在 run_python 里手撸文档转换脚本**"

View File

@ -215,6 +215,49 @@ class Message(Base):
return sanitize_jsonb_nul(value) return sanitize_jsonb_nul(value)
class WebPreview(Base):
"""Task-scoped static web project preview.
The database stores only a task-relative directory and lifecycle metadata. Files stay
in the task workspace as the source of truth; launch URLs are signed on demand and are
never persisted.
"""
__tablename__ = "web_previews"
__table_args__ = (
Index("ix_web_previews_user_status", "user_id", "status"),
Index("ix_web_previews_task_created", "task_id", "created_at"),
)
preview_id: Mapped[UUID] = mapped_column(
PG_UUID(as_uuid=True), primary_key=True, default=uuid4
)
user_id: Mapped[UUID] = mapped_column(
PG_UUID(as_uuid=True), ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False
)
task_id: Mapped[UUID] = mapped_column(
PG_UUID(as_uuid=True), ForeignKey("tasks.task_id", ondelete="CASCADE"), nullable=False
)
root_path: Mapped[str] = mapped_column(Text, nullable=False)
entry_path: Mapped[str] = mapped_column(Text, nullable=False, default="index.html")
name: Mapped[str] = mapped_column(Text, nullable=False, default="网页项目预览")
spa_fallback: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default=text("true")
)
status: Mapped[str] = mapped_column(
Text, nullable=False, default="active", server_default="active"
)
file_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0, server_default="0")
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
)
class Artifact(Base): class Artifact(Base):
"""Stable identity and lifecycle metadata for a published workspace file.""" """Stable identity and lifecycle metadata for a published workspace file."""

View File

@ -138,6 +138,7 @@ def build_tools(ctx: ToolContext) -> dict[str, Any]:
def _task_actions() -> list: def _task_actions() -> list:
from tools.publish_artifacts import PublishArtifactsTool from tools.publish_artifacts import PublishArtifactsTool
from tools.publish_web_preview import PublishWebPreviewTool
return [ return [
RenameWorkingDirTool( RenameWorkingDirTool(
ctx.deferred_actions, ctx.deferred_actions,
@ -154,6 +155,12 @@ def build_tools(ctx: ToolContext) -> dict[str, Any]:
working_dir=ctx.working_dir_path, working_dir=ctx.working_dir_path,
**wd_base, **wd_base,
), ),
PublishWebPreviewTool(
ctx.uid,
ctx.task_id,
working_dir=ctx.working_dir_path,
**wd_base,
),
] ]
def _document_search() -> list: def _document_search() -> list:

229
core/web_previews.py Normal file
View File

@ -0,0 +1,229 @@
"""Static multi-file web preview lifecycle and workspace path validation."""
from __future__ import annotations
import os
from datetime import datetime, timedelta, timezone
from pathlib import Path, PurePosixPath
from uuid import UUID
from sqlalchemy import select, update
from core.storage import session_scope
from core.storage.models import Task, WebPreview
DEFAULT_TTL_SECONDS = 7 * 24 * 3600
MIN_TTL_SECONDS = 300
MAX_TTL_SECONDS = 30 * 24 * 3600
MAX_ACTIVE_PER_USER = 10
MAX_FILES = 5_000
MAX_TOTAL_BYTES = 200 * 1024 * 1024
MAX_FILE_BYTES = 50 * 1024 * 1024
class WebPreviewError(ValueError):
pass
def _now() -> datetime:
return datetime.now(timezone.utc)
def _ttl_seconds() -> int:
raw = os.getenv("ZCBOT_WEB_PREVIEW_TTL_SECONDS", "").strip()
try:
value = int(raw) if raw else DEFAULT_TTL_SECONDS
except ValueError:
value = DEFAULT_TTL_SECONDS
return max(MIN_TTL_SECONDS, min(value, MAX_TTL_SECONDS))
def _safe_relative(raw: str, *, label: str, allow_dot: bool = False) -> str:
value = str(raw or "").strip().replace("\\", "/")
if allow_dot and value in {"", ".", "./"}:
return "."
if not value or "\x00" in value or value.startswith("/"):
raise WebPreviewError(f"{label} must be a non-empty relative path")
path = PurePosixPath(value)
if path.is_absolute() or any(part in {"", ".", ".."} for part in path.parts):
raise WebPreviewError(f"{label} contains an invalid path segment")
return path.as_posix()
def resolve_preview_root(working_dir: Path, root_path: str) -> tuple[Path, str]:
wd = Path(working_dir).resolve()
rel = _safe_relative(root_path, label="directory", allow_dot=True)
candidate = wd if rel == "." else wd.joinpath(*PurePosixPath(rel).parts)
resolved = candidate.resolve()
try:
resolved.relative_to(wd)
except ValueError as exc:
raise WebPreviewError("preview directory escapes the task working directory") from exc
if not resolved.is_dir():
raise WebPreviewError(f"preview directory not found: {rel}")
return resolved, rel
def inspect_preview_tree(root: Path, entry_path: str) -> tuple[str, int, int]:
entry = _safe_relative(entry_path or "index.html", label="entry")
entry_abs = root.joinpath(*PurePosixPath(entry).parts).resolve()
try:
entry_abs.relative_to(root.resolve())
except ValueError as exc:
raise WebPreviewError("entry escapes the preview directory") from exc
if not entry_abs.is_file() or entry_abs.suffix.lower() not in {".html", ".htm"}:
raise WebPreviewError(f"preview entry must be an existing HTML file: {entry}")
count = 0
total = 0
for current, dirs, files in os.walk(root, followlinks=False):
current_path = Path(current)
for name in tuple(dirs):
if (current_path / name).is_symlink():
raise WebPreviewError("preview directory cannot contain symbolic links")
for name in files:
path = current_path / name
if path.is_symlink():
raise WebPreviewError("preview directory cannot contain symbolic links")
try:
size = path.stat().st_size
except OSError as exc:
raise WebPreviewError(f"cannot inspect preview file: {name}") from exc
if size > MAX_FILE_BYTES:
raise WebPreviewError(f"preview file exceeds 50 MiB: {name}")
count += 1
total += size
if count > MAX_FILES:
raise WebPreviewError(f"preview contains more than {MAX_FILES} files")
if total > MAX_TOTAL_BYTES:
raise WebPreviewError("preview directory exceeds 200 MiB")
return entry, count, total
def preview_dict(row: WebPreview) -> dict:
return {
"type": "web_preview",
"preview_id": str(row.preview_id),
"task_id": str(row.task_id),
"name": row.name,
"root": row.root_path,
"entry": row.entry_path,
"spa_fallback": bool(row.spa_fallback),
"status": row.status,
"file_count": row.file_count,
"size_bytes": row.size_bytes,
"expires_at": row.expires_at.isoformat(),
}
def create_web_preview(
*,
user_id: UUID,
task_id: UUID,
working_dir: Path,
directory: str,
entry: str = "index.html",
name: str = "网页项目预览",
spa_fallback: bool = True,
) -> dict:
root, root_rel = resolve_preview_root(working_dir, directory)
entry_rel, file_count, size_bytes = inspect_preview_tree(root, entry)
display_name = str(name or "").strip()[:120] or "网页项目预览"
now = _now()
expires_at = now + timedelta(seconds=_ttl_seconds())
with session_scope() as session:
task_exists = session.execute(
select(Task.task_id).where(Task.task_id == task_id, Task.user_id == user_id)
).scalar_one_or_none()
if task_exists is None:
raise WebPreviewError("task not found")
session.execute(
update(WebPreview)
.where(
WebPreview.user_id == user_id,
WebPreview.status == "active",
WebPreview.expires_at <= now,
)
.values(status="expired")
)
existing = session.execute(
select(WebPreview)
.where(
WebPreview.user_id == user_id,
WebPreview.task_id == task_id,
WebPreview.root_path == root_rel,
WebPreview.entry_path == entry_rel,
WebPreview.status == "active",
)
.order_by(WebPreview.created_at.desc())
.limit(1)
).scalar_one_or_none()
if existing is not None:
existing.name = display_name
existing.spa_fallback = bool(spa_fallback)
existing.file_count = file_count
existing.size_bytes = size_bytes
existing.expires_at = expires_at
session.flush()
return preview_dict(existing)
active = session.execute(
select(WebPreview.preview_id).where(
WebPreview.user_id == user_id,
WebPreview.status == "active",
)
).scalars().all()
if len(active) >= MAX_ACTIVE_PER_USER:
raise WebPreviewError(
f"active web preview limit reached ({MAX_ACTIVE_PER_USER}); stop an old preview first"
)
row = WebPreview(
user_id=user_id,
task_id=task_id,
root_path=root_rel,
entry_path=entry_rel,
name=display_name,
spa_fallback=bool(spa_fallback),
file_count=file_count,
size_bytes=size_bytes,
expires_at=expires_at,
)
session.add(row)
session.flush()
return preview_dict(row)
def get_web_preview(preview_id: UUID, user_id: UUID | None = None) -> tuple[WebPreview, str]:
with session_scope() as session:
statement = (
select(WebPreview, Task.working_dir)
.join(Task, Task.task_id == WebPreview.task_id)
.where(WebPreview.preview_id == preview_id)
)
if user_id is not None:
statement = statement.where(WebPreview.user_id == user_id)
result = session.execute(statement).first()
if result is None:
raise WebPreviewError("preview not found")
row, working_dir = result
if row.status == "active" and row.expires_at <= _now():
row.status = "expired"
session.flush()
return row, working_dir
def stop_web_preview(preview_id: UUID, user_id: UUID) -> dict:
with session_scope() as session:
row = session.execute(
select(WebPreview).where(
WebPreview.preview_id == preview_id,
WebPreview.user_id == user_id,
)
).scalar_one_or_none()
if row is None:
raise WebPreviewError("preview not found")
if row.status == "active":
row.status = "stopped"
session.flush()
return preview_dict(row)

View File

@ -0,0 +1,53 @@
"""Add task-scoped static web project previews.
Revision ID: 0036
Revises: 0035
Create Date: 2026-08-21
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0036"
down_revision: str | None = "0035"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"web_previews",
sa.Column("preview_id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"user_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.user_id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"task_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("tasks.task_id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("root_path", sa.Text(), nullable=False),
sa.Column("entry_path", sa.Text(), nullable=False, server_default="index.html"),
sa.Column("name", sa.Text(), nullable=False, server_default="网页项目预览"),
sa.Column("spa_fallback", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("status", sa.Text(), nullable=False, server_default="active"),
sa.Column("file_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("size_bytes", sa.BigInteger(), nullable=False, server_default="0"),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
op.create_index("ix_web_previews_user_status", "web_previews", ["user_id", "status"])
op.create_index("ix_web_previews_task_created", "web_previews", ["task_id", "created_at"])
def downgrade() -> None:
op.drop_index("ix_web_previews_task_created", table_name="web_previews")
op.drop_index("ix_web_previews_user_status", table_name="web_previews")
op.drop_table("web_previews")

View File

@ -1,6 +1,6 @@
--- ---
name: coding name: coding
description: 修改、调试、实现代码相关任务。当用户要求修 bug、写函数、重构、读懂代码库、跑测试时使用。 description: 修改、调试、实现代码与软件项目。当用户要求修 bug、写函数、重构、读懂代码库、跑测试,或从零创建 Node.js、TypeScript、Python 及可在网页中预览的前端应用时使用。
--- ---
# Coding # Coding
@ -17,6 +17,15 @@ description: 修改、调试、实现代码相关任务。当用户要求修 bug
- **验证优先**: 项目有测试就 `shell pytest` / `npm test`;没有就写最小复现脚本验证 - **验证优先**: 项目有测试就 `shell pytest` / `npm test`;没有就写最小复现脚本验证
- **不臆造 API**: 看到没用过的库,先 read 它的源码或文档,不要凭直觉拼方法名 - **不臆造 API**: 看到没用过的库,先 read 它的源码或文档,不要凭直觉拼方法名
## 网页软件项目
- 先判断技术栈:已有项目沿用其语言、框架和包管理器;新建 Web 或通用软件项目默认优先 Node.js + TypeScript新建前端默认使用 Vite按交互复杂度选择 React、Vue 或原生 TypeScript。
- 科学计算、数据分析、机器学习、材料研发及 Python 生态占优的任务优先 Python用户指定语言或框架时服从用户不为追求 Node.js 改写既有项目。
- 新建 Node.js 项目默认使用 npm保留 `package.json`、源码、资源、配置和测试,按项目脚本依次安装依赖、测试并构建。
- 按正常项目结构创建源码、样式、资源、配置和测试;不要为了迁就预览把所有内容塞进单个 HTML。
- 先运行项目测试与正式构建,再调用 `publish_web_preview` 发布静态输出目录(常见为 `dist/``build/`)。
- 发布入口必须是构建目录中的 HTML资源留在同一目录树。再次修改后重新构建并发布同一目录即可刷新原预览。
- 当前预览只运行静态前端。需要后端、开发服务器、数据库或服务端渲染时,交付源码与运行说明,并明确说明尚不能作为在线预览运行。
## 输出 ## 输出
- 改完后一两句话说清: 改了什么、为什么、怎么验证 - 改完后一两句话说清: 改了什么、为什么、怎么验证
- 不复述 diff —— 用户会自己看 - 不复述 diff —— 用户会自己看
@ -25,3 +34,4 @@ description: 修改、调试、实现代码相关任务。当用户要求修 bug
- 一次性 write 整个大文件 (改 3 行就别 write 200 行) - 一次性 write 整个大文件 (改 3 行就别 write 200 行)
- 没读过文件直接 edit (大概率 old_str 匹配不上) - 没读过文件直接 edit (大概率 old_str 匹配不上)
- 跑测试失败就立刻改测试 (先看是测试错还是代码错) - 跑测试失败就立刻改测试 (先看是测试错还是代码错)
- 用后台进程临时启动服务并把它描述成可访问的网页预览

View File

@ -0,0 +1,24 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import test from "node:test";
const preview = fs.readFileSync(new URL("../web/static/js/preview.js", import.meta.url), "utf8");
const chat = fs.readFileSync(new URL("../web/static/js/chat.js", import.meta.url), "utf8");
const html = fs.readFileSync(new URL("../web/static/dev.html", import.meta.url), "utf8");
test("web preview tool results render persistent interactive cards", () => {
assert.match(preview, /export function parseWebPreviewResult/);
assert.match(preview, /export function renderWebPreviewCardHtml/);
assert.match(chat, /toolName === "publish_web_preview"/);
assert.match(chat, /parseWebPreviewResult\(txtStr\)/);
assert.match(chat, /openWebPreview\(previewId\)/);
assert.match(chat, /stopWebPreview\(previewId\)/);
assert.match(html, /\.web-preview-card/);
});
test("project iframe remains opaque and launch URL is fetched with auth", () => {
assert.match(preview, /api\("POST", `\/v1\/web-previews\/\$\{encodeURIComponent\(previewId\)\}\/launch`\)/);
assert.match(preview, /allow-scripts allow-forms allow-modals allow-downloads/);
assert.doesNotMatch(preview, /allow-same-origin/);
assert.match(preview, /frame\.referrerPolicy = "no-referrer"/);
});

View File

@ -43,6 +43,13 @@ def _build(backend: str, *, allow_rename: bool = True) -> tuple[str, Path, Path]
class TestSystemPromptPaths(unittest.TestCase): class TestSystemPromptPaths(unittest.TestCase):
def test_coding_skill_routes_new_projects_without_forcing_python_work(self):
skill = (ROOT / "skills" / "coding" / "SKILL.md").read_text(encoding="utf-8")
self.assertIn("新建 Web 或通用软件项目默认优先 Node.js + TypeScript", skill)
self.assertIn("新建前端默认使用 Vite", skill)
self.assertIn("已有项目沿用其语言、框架和包管理器", skill)
self.assertIn("材料研发及 Python 生态占优的任务优先 Python", skill)
def test_current_time_rules_are_at_prompt_start(self): def test_current_time_rules_are_at_prompt_start(self):
prompt, _, _ = _build("docker") prompt, _, _ = _build("docker")
self.assertTrue(prompt.startswith("## 当前时间与时效\n")) self.assertTrue(prompt.startswith("## 当前时间与时效\n"))
@ -73,6 +80,15 @@ class TestSystemPromptPaths(unittest.TestCase):
scheduled, _, _ = _build("docker", allow_rename=False) scheduled, _, _ = _build("docker", allow_rename=False)
self.assertIn("必须调用 `rename_working_dir`", interactive) self.assertIn("必须调用 `rename_working_dir`", interactive)
self.assertNotIn("必须调用 `rename_working_dir`", scheduled) self.assertNotIn("必须调用 `rename_working_dir`", scheduled)
self.assertIn("`publish_web_preview`", interactive)
self.assertNotIn("`publish_web_preview`", scheduled)
def test_interactive_coding_stack_routing(self):
interactive, _, _ = _build("docker", allow_rename=True)
self.assertIn("Node.js + TypeScript", interactive)
self.assertIn("前端默认 Vite", interactive)
self.assertIn("已有项目沿用原技术栈", interactive)
self.assertIn("材料研发任务在 Python 生态更合适时", interactive)
def test_report_pdf_uses_platform_renderer_directly(self): def test_report_pdf_uses_platform_renderer_directly(self):
prompt, _, _ = _build("docker") prompt, _, _ = _build("docker")

206
tests/test_web_previews.py Normal file
View File

@ -0,0 +1,206 @@
from __future__ import annotations
import importlib
import json
import os
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from uuid import uuid4
from fastapi import FastAPI
from fastapi.testclient import TestClient
from core.web_previews import (
MAX_FILE_BYTES,
WebPreviewError,
inspect_preview_tree,
resolve_preview_root,
)
from tools.publish_web_preview import PublishWebPreviewTool
from web.routers.web_previews import (
_mint_preview_token,
_rewrite_css,
_rewrite_html,
register_web_preview_routes,
)
class WebPreviewPathTests(unittest.TestCase):
def test_static_tree_accepts_entry_and_counts_files(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
working = Path(tmp)
dist = working / "dist"
(dist / "assets").mkdir(parents=True)
(dist / "index.html").write_text("<script src='/assets/app.js'></script>", encoding="utf-8")
(dist / "assets" / "app.js").write_text("console.log('ok')", encoding="utf-8")
root, rel = resolve_preview_root(working, "dist")
entry, count, size = inspect_preview_tree(root, "index.html")
self.assertEqual(rel, "dist")
self.assertEqual(entry, "index.html")
self.assertEqual(count, 2)
self.assertGreater(size, 0)
def test_directory_escape_and_symlink_are_rejected(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
working = Path(tmp) / "task"
working.mkdir()
with self.assertRaises(WebPreviewError):
resolve_preview_root(working, "../outside")
dist = working / "dist"
dist.mkdir()
(dist / "index.html").write_text("ok", encoding="utf-8")
try:
(dist / "outside-link").symlink_to(Path(tmp))
except OSError:
self.skipTest("symlinks are unavailable on this Windows test host")
with self.assertRaisesRegex(WebPreviewError, "symbolic links"):
inspect_preview_tree(dist, "index.html")
def test_large_file_is_rejected_without_reading_it(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "index.html").write_text("ok", encoding="utf-8")
large = root / "large.bin"
with large.open("wb") as handle:
handle.truncate(MAX_FILE_BYTES + 1)
with self.assertRaisesRegex(WebPreviewError, "50 MiB"):
inspect_preview_tree(root, "index.html")
def test_root_urls_are_scoped_to_signed_preview_prefix(self) -> None:
prefix = "/web-preview/pid/token"
html = '<html><head></head><body><script src="/assets/app.js"></script></body></html>'
rewritten = _rewrite_html(html, prefix)
self.assertIn(f'<base href="{prefix}/">', rewritten)
self.assertIn(f'src="{prefix}/assets/app.js"', rewritten)
self.assertIn("https://", _rewrite_html('<a href="https://example.com">x</a>', prefix))
self.assertIn(f"url({prefix}/img/a.png)", _rewrite_css("a{background:url(/img/a.png)}", prefix))
class PublishWebPreviewToolTests(unittest.TestCase):
def test_tool_returns_persistable_preview_marker(self) -> None:
preview_id = uuid4()
preview = {"type": "web_preview", "preview_id": str(preview_id), "status": "active"}
with tempfile.TemporaryDirectory() as tmp, patch(
"tools.publish_web_preview.create_web_preview", return_value=preview
) as create:
tool = PublishWebPreviewTool(
uuid4(), uuid4(), working_dir=Path(tmp), base_dir=Path(tmp), user_root=Path(tmp)
)
result = tool.execute("dist", name="演示项目")
self.assertTrue(result.startswith("[WebPreview] "))
self.assertEqual(json.loads(result.removeprefix("[WebPreview] "))["preview_id"], str(preview_id))
self.assertEqual(create.call_args.kwargs["directory"], "dist")
def test_registry_exposes_preview_only_to_interactive_runs(self) -> None:
from core.tool_registry import ToolContext, build_tools
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
common = {
"tool_base": root,
"ur_path": root,
"working_dir_path": root,
"task_id": uuid4(),
"uid": uuid4(),
"cfg": {},
"caps": SimpleNamespace(enable_run_python=False),
"skills": SimpleNamespace(skills={}),
"cancel_check": None,
"deferred_actions": SimpleNamespace(),
"ark_cfg": None,
"img_provider": "",
"img_key": "",
"img_cfg": None,
"img_provider_cfg": None,
"video_variant": "",
"office_to_pdf_available": False,
}
with (
patch.dict(os.environ, {"DOCUMENT_SEARCH_API_KEY": "", "MP_API_KEY": ""}),
patch("core.tool_registry._external_system_status_available", return_value=False),
patch("core.tool_registry._external_systems_available", return_value=False),
patch("core.tool_registry.smtp_configured", return_value=False),
patch("core.tool_registry.wechat_push_available", return_value=False),
patch("core.tool_registry.lfasr_configured", return_value=False),
patch("core.tool_registry.BochaConfig.load", return_value=None),
):
interactive = build_tools(ToolContext(scheduled_run=False, **common))
scheduled = build_tools(ToolContext(scheduled_run=True, **common))
self.assertIn("publish_web_preview", interactive)
self.assertNotIn("publish_web_preview", scheduled)
class WebPreviewRouteTests(unittest.TestCase):
def test_signed_gateway_serves_rewritten_html_and_assets(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
working = Path(tmp)
dist = working / "dist"
(dist / "assets").mkdir(parents=True)
(dist / "index.html").write_text(
'<!doctype html><script type="module" src="/assets/app.js"></script>',
encoding="utf-8",
)
(dist / "assets" / "app.js").write_text("export default 1", encoding="utf-8")
preview_id = uuid4()
row = SimpleNamespace(
preview_id=preview_id,
user_id=uuid4(),
task_id=uuid4(),
root_path="dist",
entry_path="index.html",
name="测试项目",
spa_fallback=True,
status="active",
file_count=2,
size_bytes=100,
expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
)
cfg = SimpleNamespace(jwt_secret="preview-test-secret-at-least-32-bytes")
token = _mint_preview_token(cfg, preview_id, row.expires_at)
app = FastAPI()
register_web_preview_routes(app, require_user=lambda: row.user_id, auth_cfg=cfg)
client = TestClient(app)
with (
patch("web.routers.web_previews.get_web_preview", return_value=(row, "ignored")),
patch("web.routers.web_previews.from_db_path", return_value=working),
):
response = client.get(f"/web-preview/{preview_id}/{token}/")
asset = client.get(f"/web-preview/{preview_id}/{token}/assets/app.js")
self.assertEqual(response.status_code, 200)
self.assertIn(f"/web-preview/{preview_id}/{token}/assets/app.js", response.text)
self.assertIn("sandbox", response.headers["content-security-policy"])
self.assertNotIn("allow-same-origin", response.headers["content-security-policy"])
self.assertEqual(response.headers["referrer-policy"], "no-referrer")
self.assertEqual(asset.status_code, 200)
self.assertIn("javascript", asset.headers["content-type"])
def test_invalid_preview_token_is_not_distinguishable_from_missing(self) -> None:
app = FastAPI()
cfg = SimpleNamespace(jwt_secret="preview-test-secret-at-least-32-bytes")
register_web_preview_routes(app, require_user=lambda: uuid4(), auth_cfg=cfg)
response = TestClient(app).get(f"/web-preview/{uuid4()}/not-a-token/")
self.assertEqual(response.status_code, 404)
class WebPreviewMigrationTests(unittest.TestCase):
def test_0036_adds_preview_table_and_indexes(self) -> None:
module = importlib.import_module(
"db.migrations.versions.20260821_0900_0036_web_previews"
)
self.assertEqual(module.revision, "0036")
self.assertEqual(module.down_revision, "0035")
source = Path(module.__file__).read_text(encoding="utf-8")
self.assertIn('"web_previews"', source)
self.assertIn("ix_web_previews_user_status", source)
self.assertIn("ondelete=\"CASCADE\"", source)
if __name__ == "__main__":
unittest.main()

View File

@ -100,6 +100,7 @@ class AuthGateTests(unittest.TestCase):
("GET", "/v1/kb"), ("GET", "/v1/kb"),
("GET", "/v1/schedules"), ("GET", "/v1/schedules"),
("GET", "/v1/files"), ("GET", "/v1/files"),
("GET", "/v1/web-previews/00000000-0000-0000-0000-000000000000"),
("GET", "/v1/user/storage"), ("GET", "/v1/user/storage"),
("GET", "/v1/procs"), ("GET", "/v1/procs"),
("GET", "/v1/wechat/bind"), ("GET", "/v1/wechat/bind"),

View File

@ -0,0 +1,89 @@
"""Publish a built static web project as a live multi-file preview."""
from __future__ import annotations
import json
from pathlib import Path
from typing import ClassVar
from uuid import UUID
from core.web_previews import WebPreviewError, create_web_preview
from .base import Tool
class PublishWebPreviewTool(Tool):
name = "publish_web_preview"
description = (
"Publish an existing static multi-file web build from the current task as an interactive "
"preview. Use it after creating and testing a project and producing its final static output "
"directory (for example dist or build). The directory must contain the HTML entry and all "
"local CSS/JS/assets. This tool does not run a development server or backend. Re-publishing "
"the same directory refreshes the existing preview."
)
parameters: ClassVar[dict] = {
"type": "object",
"properties": {
"directory": {
"type": "string",
"minLength": 1,
"maxLength": 1000,
"description": "Static output directory relative to the current task working directory.",
},
"entry": {
"type": "string",
"minLength": 1,
"maxLength": 500,
"default": "index.html",
"description": "HTML entry path relative to directory.",
},
"name": {
"type": "string",
"maxLength": 120,
"default": "网页项目预览",
"description": "Short user-facing preview name.",
},
"spa_fallback": {
"type": "boolean",
"default": True,
"description": "Serve the HTML entry for extensionless missing routes used by client-side routers.",
},
},
"required": ["directory"],
"additionalProperties": False,
}
def __init__(
self,
user_id: UUID,
task_id: UUID,
*,
working_dir: Path,
**kwargs,
) -> None:
super().__init__(**kwargs)
self.user_id = user_id
self.task_id = task_id
self.working_dir = Path(working_dir)
def execute(
self,
directory: str,
entry: str = "index.html",
name: str = "网页项目预览",
spa_fallback: bool = True,
) -> str:
if self.user_root is None:
return "[Error] web preview publishing requires a user workspace"
try:
preview = create_web_preview(
user_id=self.user_id,
task_id=self.task_id,
working_dir=self.working_dir,
directory=directory,
entry=entry,
name=name,
spa_fallback=spa_fallback,
)
except WebPreviewError as exc:
return f"[Error] cannot publish web preview: {exc}"
return "[WebPreview] " + json.dumps(preview, ensure_ascii=False, separators=(",", ":"))

View File

@ -60,6 +60,7 @@ from .routers.skills_memory import register_skill_memory_routes
from .routers.software_nodes import register_software_node_routes from .routers.software_nodes import register_software_node_routes
from .routers.tasks import register_task_routes from .routers.tasks import register_task_routes
from .routers.wechat import register_wechat_routes from .routers.wechat import register_wechat_routes
from .routers.web_previews import register_web_preview_routes
from .scheduler_runner import start_scheduler from .scheduler_runner import start_scheduler
from .software_followups import ( from .software_followups import (
requeue_stale_analysis_followups, requeue_stale_analysis_followups,
@ -211,6 +212,7 @@ def create_app() -> FastAPI:
register_kb_routes(app, require_user=require_user) register_kb_routes(app, require_user=require_user)
register_schedule_routes(app, require_user=require_user) register_schedule_routes(app, require_user=require_user)
register_file_routes(app, require_user=require_user) register_file_routes(app, require_user=require_user)
register_web_preview_routes(app, require_user=require_user, auth_cfg=auth_cfg)
register_external_system_routes(app, require_user=require_user) register_external_system_routes(app, require_user=require_user)
register_asr_routes(app, require_user=require_user, auth_cfg=auth_cfg) register_asr_routes(app, require_user=require_user, auth_cfg=auth_cfg)
register_task_routes(app, require_user=require_user) register_task_routes(app, require_user=require_user)

190
web/routers/web_previews.py Normal file
View File

@ -0,0 +1,190 @@
"""Authenticated preview controls and signed static project file gateway."""
from __future__ import annotations
import mimetypes
import re
import time
from pathlib import Path, PurePosixPath
from uuid import UUID
import jwt
from fastapi import Depends, HTTPException, Request
from fastapi.responses import FileResponse, Response
from core.paths import from_db_path
from core.web_previews import (
WebPreviewError,
get_web_preview,
preview_dict,
stop_web_preview,
)
_HTML_MAX_REWRITE = 4 * 1024 * 1024
_CSS_MAX_REWRITE = 4 * 1024 * 1024
_ATTR_ROOT_URL = re.compile(r"(?i)(\b(?:src|href|poster|action)\s*=\s*['\"])/(?!/)")
_CSS_ROOT_URL = re.compile(r"(?i)(url\(\s*['\"]?)/(?!/)")
_CSS_ROOT_IMPORT = re.compile(r"(?i)(@import\s+['\"])/(?!/)")
def _preview_headers(*, html: bool = False) -> dict[str, str]:
headers = {
"Cache-Control": "no-cache",
"Referrer-Policy": "no-referrer",
"X-Content-Type-Options": "nosniff",
"Access-Control-Allow-Origin": "*",
}
if html:
# The gateway currently shares the zcbot host. Header-level sandboxing also applies
# when somebody opens the signed URL as a top-level page, so project code never gains
# the host page's origin or localStorage even outside the SPA iframe.
headers["Content-Security-Policy"] = (
"sandbox allow-downloads allow-forms allow-modals allow-scripts; "
"frame-ancestors 'self'"
)
return headers
def _rewrite_html(text: str, prefix: str) -> str:
rewritten = _ATTR_ROOT_URL.sub(lambda m: m.group(1) + prefix + "/", text)
base = f'<base href="{prefix}/">'
if re.search(r"(?i)<head(?:\s[^>]*)?>", rewritten):
return re.sub(
r"(?i)<head(?:\s[^>]*)?>",
lambda m: m.group(0) + base,
rewritten,
count=1,
)
return base + rewritten
def _rewrite_css(text: str, prefix: str) -> str:
text = _CSS_ROOT_URL.sub(lambda m: m.group(1) + prefix + "/", text)
return _CSS_ROOT_IMPORT.sub(lambda m: m.group(1) + prefix + "/", text)
def _mint_preview_token(auth_cfg, preview_id: UUID, expires_at) -> str:
now = int(time.time())
exp = int(expires_at.timestamp())
return jwt.encode(
{"typ": "web_preview", "pid": str(preview_id), "iat": now, "exp": exp},
auth_cfg.jwt_secret,
algorithm="HS256",
)
def _verify_preview_token(auth_cfg, preview_id: UUID, token: str) -> None:
try:
payload = jwt.decode(token, auth_cfg.jwt_secret, algorithms=["HS256"])
except jwt.InvalidTokenError as exc:
raise HTTPException(404, "preview not found") from exc
if payload.get("typ") != "web_preview" or payload.get("pid") != str(preview_id):
raise HTTPException(404, "preview not found")
def _preview_asset(root: Path, raw_path: str) -> Path:
raw = str(raw_path or "").replace("\\", "/").lstrip("/")
path = PurePosixPath(raw)
if any(part in {"", ".", ".."} for part in path.parts):
raise HTTPException(404, "preview file not found")
target = root.joinpath(*path.parts).resolve()
try:
target.relative_to(root.resolve())
except ValueError as exc:
raise HTTPException(404, "preview file not found") from exc
return target
def register_web_preview_routes(app, *, require_user, auth_cfg) -> None:
@app.get("/v1/web-previews/{preview_id}", tags=["web-previews"])
def web_preview_status(
preview_id: UUID,
user_id: UUID = Depends(require_user), # noqa: B008
):
try:
row, _ = get_web_preview(preview_id, user_id)
except WebPreviewError as exc:
raise HTTPException(404, str(exc)) from exc
return preview_dict(row)
@app.post("/v1/web-previews/{preview_id}/launch", tags=["web-previews"])
def launch_web_preview(
preview_id: UUID,
user_id: UUID = Depends(require_user), # noqa: B008
):
try:
row, _ = get_web_preview(preview_id, user_id)
except WebPreviewError as exc:
raise HTTPException(404, str(exc)) from exc
if row.status != "active":
raise HTTPException(410, f"preview is {row.status}")
token = _mint_preview_token(auth_cfg, preview_id, row.expires_at)
return {
"preview": preview_dict(row),
"url": f"/web-preview/{preview_id}/{token}/",
}
@app.delete("/v1/web-previews/{preview_id}", tags=["web-previews"])
def stop_preview(
preview_id: UUID,
user_id: UUID = Depends(require_user), # noqa: B008
):
try:
return stop_web_preview(preview_id, user_id)
except WebPreviewError as exc:
raise HTTPException(404, str(exc)) from exc
def serve(preview_id: UUID, token: str, asset_path: str, request: Request):
_verify_preview_token(auth_cfg, preview_id, token)
try:
row, db_working_dir = get_web_preview(preview_id)
except WebPreviewError as exc:
raise HTTPException(404, "preview not found") from exc
if row.status != "active":
raise HTTPException(410, f"preview is {row.status}")
working_dir = from_db_path(db_working_dir).resolve()
root = _preview_asset(working_dir, row.root_path) if row.root_path != "." else working_dir
if not root.is_dir():
raise HTTPException(404, "preview directory not found")
requested = asset_path or row.entry_path
target = _preview_asset(root, requested)
if not target.is_file():
accepts_html = "text/html" in (request.headers.get("accept") or "")
extensionless = not PurePosixPath(requested).suffix
if row.spa_fallback and accepts_html and extensionless:
target = _preview_asset(root, row.entry_path)
if not target.is_file():
raise HTTPException(404, "preview file not found")
prefix = f"/web-preview/{preview_id}/{token}"
suffix = target.suffix.lower()
size = target.stat().st_size
if suffix in {".html", ".htm"} and size <= _HTML_MAX_REWRITE:
text = target.read_text(encoding="utf-8", errors="replace")
return Response(
_rewrite_html(text, prefix),
media_type="text/html; charset=utf-8",
headers=_preview_headers(html=True),
)
if suffix == ".css" and size <= _CSS_MAX_REWRITE:
text = target.read_text(encoding="utf-8", errors="replace")
return Response(
_rewrite_css(text, prefix),
media_type="text/css; charset=utf-8",
headers=_preview_headers(),
)
media_type = mimetypes.guess_type(target.name)[0] or "application/octet-stream"
return FileResponse(
str(target),
media_type=media_type,
headers=_preview_headers(),
)
@app.get("/web-preview/{preview_id}/{token}/", include_in_schema=False)
def web_preview_entry(preview_id: UUID, token: str, request: Request):
return serve(preview_id, token, "", request)
@app.get("/web-preview/{preview_id}/{token}/{asset_path:path}", include_in_schema=False)
def web_preview_asset(preview_id: UUID, token: str, asset_path: str, request: Request):
return serve(preview_id, token, asset_path, request)

View File

@ -1204,6 +1204,34 @@
} }
.software-preview-chip .art-chip-icon { flex-basis: 26px; height: 26px; opacity: .8; } .software-preview-chip .art-chip-icon { flex-basis: 26px; height: 26px; opacity: .8; }
.software-preview-chip .art-chip-open { padding-left: 2px; } .software-preview-chip .art-chip-open { padding-left: 2px; }
.web-preview-card {
margin-top: 8px; max-width: 620px; padding: 10px;
display: grid; grid-template-columns: 42px minmax(0, 1fr) auto; align-items: center; gap: 10px;
border: 1px solid #b9d7c1; border-radius: var(--r-md);
background: linear-gradient(135deg, #f5fbf6, #fff); box-shadow: 0 2px 8px rgba(30,100,55,.06);
}
.web-preview-icon {
width: 42px; height: 36px; display: grid; place-items: center; border-radius: 5px;
background: #e3f3e7; color: #24723c; font: 700 10px var(--mono); letter-spacing: .4px;
}
.web-preview-info { min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.web-preview-info strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; }
.web-preview-info span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); font-size: 11px; }
.web-preview-actions { display: flex; gap: 6px; }
.web-preview-actions button {
min-height: 30px; padding: 4px 9px; border: 1px solid #a9cdb3; border-radius: var(--r-sm);
background: #fff; color: #24723c; cursor: pointer; font: inherit; font-size: 11px;
}
.web-preview-actions .web-preview-open { background: #287a41; border-color: #287a41; color: #fff; }
.web-preview-actions button:hover:not(:disabled) { filter: brightness(.96); transform: translateY(-1px); }
.web-preview-card.stopped { filter: grayscale(.7); opacity: .7; }
.web-preview-actions button:disabled { cursor: default; opacity: .65; }
@media (max-width: 640px) {
.web-preview-card { grid-template-columns: 36px minmax(0, 1fr); }
.web-preview-icon { width: 36px; }
.web-preview-actions { grid-column: 1 / -1; }
.web-preview-actions button { flex: 1; }
}
#chat-hint .art-chip { margin: 0 2px; vertical-align: middle; } #chat-hint .art-chip { margin: 0 2px; vertical-align: middle; }
.paste-chip-wrap { .paste-chip-wrap {
display: inline-flex; align-items: center; max-width: 280px; margin: 0 2px; display: inline-flex; align-items: center; max-width: 280px; margin: 0 2px;

View File

@ -41,6 +41,10 @@ import {
openPasteFilePreview, openPasteFilePreview,
closeAllPreviews, closeAllPreviews,
closePreviewIfShowing, closePreviewIfShowing,
openWebPreview,
parseWebPreviewResult,
renderWebPreviewCardHtml,
stopWebPreview,
} from "./preview.js"; } from "./preview.js";
import { loadFiles, scheduleFilesRefresh, uploadFiles, formatUploadProgress } from "./files.js"; import { loadFiles, scheduleFilesRefresh, uploadFiles, formatUploadProgress } from "./files.js";
import { toolActivityLabel, _workingDirName, extractMediaBanner, extractArtifactRels, renderArtifactBarHtml, renderArtifactChipContent, upgradeMediaArtifacts, ARTIFACT_PRODUCING_TOOLS, _flushMediaArtifactCache } from "./media.js"; import { toolActivityLabel, _workingDirName, extractMediaBanner, extractArtifactRels, renderArtifactBarHtml, renderArtifactChipContent, upgradeMediaArtifacts, ARTIFACT_PRODUCING_TOOLS, _flushMediaArtifactCache } from "./media.js";
@ -1752,6 +1756,8 @@ function renderMessages(msgs, { stickBottom = true } = {}) {
const callMeta = toolCallsById.get(p.tool_call_id); const callMeta = toolCallsById.get(p.tool_call_id);
const activityLabel = callMeta ? callMeta.label : `已执行 ${p.name || "工具"}`; const activityLabel = callMeta ? callMeta.label : `已执行 ${p.name || "工具"}`;
const failed = isToolResultFailure(txt); const failed = isToolResultFailure(txt);
const webPreview = (p.name || "") === "publish_web_preview"
? parseWebPreviewResult(txt) : null;
// 工具结果只有产物工具(seedream/seedance)挂 chip + inline 大图;通用工具 // 工具结果只有产物工具(seedream/seedance)挂 chip + inline 大图;通用工具
// (grep/read/glob/shell)echo 的路径是"引用"不是"产物",不挂以免噪声。 // (grep/read/glob/shell)echo 的路径是"引用"不是"产物",不挂以免噪声。
const isProducer = ARTIFACT_PRODUCING_TOOLS.has(p.name || "") const isProducer = ARTIFACT_PRODUCING_TOOLS.has(p.name || "")
@ -1762,6 +1768,7 @@ function renderMessages(msgs, { stickBottom = true } = {}) {
<summary><span class="tool-state" aria-hidden="true">${failed ? "!" : "✓"}</span><span class="tool-label">${escapeHtml(activityLabel)}</span><span class="tool-result-meta">${(txt || "").length} </span>${banner}</summary> <summary><span class="tool-state" aria-hidden="true">${failed ? "!" : "✓"}</span><span class="tool-label">${escapeHtml(activityLabel)}</span><span class="tool-result-meta">${(txt || "").length} </span>${banner}</summary>
<pre>${escapeHtml(txt || "")}</pre> <pre>${escapeHtml(txt || "")}</pre>
</details> </details>
${renderWebPreviewCardHtml(webPreview)}
${renderArtifactBarHtml(rels, isProducer, state.taskId || "", true)} ${renderArtifactBarHtml(rels, isProducer, state.taskId || "", true)}
`; `;
// bg proc 启动结果 → 卡片活化(spinner/跳秒/停止,与直播态同构;真实状态 // bg proc 启动结果 → 卡片活化(spinner/跳秒/停止,与直播态同构;真实状态
@ -2721,7 +2728,7 @@ document.addEventListener("keydown", (e) => {
// 对话流里 artifact chip / 内联 img / HTML 放大按钮点击委托 — 复用文件预览 modal。 // 对话流里 artifact chip / 内联 img / HTML 放大按钮点击委托 — 复用文件预览 modal。
// 视频走原生 <video controls>:点击=播放/暂停,全屏走浏览器自带按钮,不进 modal — // 视频走原生 <video controls>:点击=播放/暂停,全屏走浏览器自带按钮,不进 modal —
// 弹个 modal 反而打断播放,不如交给浏览器。 // 弹个 modal 反而打断播放,不如交给浏览器。
$("chat-stream").addEventListener("click", (e) => { $("chat-stream").addEventListener("click", async (e) => {
const queuedRemove = e.target.closest && e.target.closest(".queued-remove"); const queuedRemove = e.target.closest && e.target.closest(".queued-remove");
if (queuedRemove) { if (queuedRemove) {
const card = queuedRemove.closest(".msg.queued[data-queue-id]"); const card = queuedRemove.closest(".msg.queued[data-queue-id]");
@ -2763,6 +2770,40 @@ $("chat-stream").addEventListener("click", (e) => {
if (label) sendMessage(label); if (label) sendMessage(label);
return; return;
} }
const previewOpen = e.target.closest && e.target.closest(".web-preview-open");
if (previewOpen) {
const card = previewOpen.closest(".web-preview-card");
const previewId = card && card.dataset.previewId;
if (!previewId || previewOpen.disabled) return;
try {
await openWebPreview(previewId);
} catch (err) {
message(err.message || "网页项目预览打开失败", "error");
if (err.status === 410 && card) {
card.classList.add("stopped");
card.querySelectorAll("button").forEach((button) => { button.disabled = true; });
previewOpen.textContent = "已失效";
}
}
return;
}
const previewStop = e.target.closest && e.target.closest(".web-preview-stop");
if (previewStop) {
const card = previewStop.closest(".web-preview-card");
const previewId = card && card.dataset.previewId;
if (!previewId || previewStop.disabled) return;
try {
await stopWebPreview(previewId);
card.classList.add("stopped");
card.querySelectorAll("button").forEach((button) => { button.disabled = true; });
const open = card.querySelector(".web-preview-open");
if (open) open.textContent = "已停止";
message("网页项目预览已停止", "success");
} catch (err) {
message(err.message || "停止网页项目预览失败", "error");
}
return;
}
const chip = e.target.closest && e.target.closest(".art-chip"); const chip = e.target.closest && e.target.closest(".art-chip");
if (chip) { if (chip) {
const rel = chip.dataset.rel; const rel = chip.dataset.rel;
@ -3454,6 +3495,8 @@ function handleSseEvent(ev, asstCard, ctx) {
const wd = _workingDirName(ctx.workingDir); const wd = _workingDirName(ctx.workingDir);
const isProducer = ARTIFACT_PRODUCING_TOOLS.has(toolName); const isProducer = ARTIFACT_PRODUCING_TOOLS.has(toolName);
const structured = Array.isArray(ev.data && ev.data.artifacts) ? ev.data.artifacts : []; const structured = Array.isArray(ev.data && ev.data.artifacts) ? ev.data.artifacts : [];
const webPreview = toolName === "publish_web_preview"
? parseWebPreviewResult(txtStr) : null;
const fresh = structured.length ? structured : (isProducer const fresh = structured.length ? structured : (isProducer
? extractArtifactRels(txtStr, wd).filter(r => !ctx.seenRels.has(r)) ? extractArtifactRels(txtStr, wd).filter(r => !ctx.seenRels.has(r))
: []); : []);
@ -3468,6 +3511,8 @@ function handleSseEvent(ev, asstCard, ctx) {
asstCard.insertAdjacentHTML("beforeend", barHtml); asstCard.insertAdjacentHTML("beforeend", barHtml);
if (isProducer) upgradeMediaArtifacts(asstCard); if (isProducer) upgradeMediaArtifacts(asstCard);
} }
const previewHtml = renderWebPreviewCardHtml(webPreview);
if (previewHtml) asstCard.insertAdjacentHTML("beforeend", previewHtml);
if (visible) scheduleFilesRefresh(); // 工具调用结果回来,FS 可能被改了,debounce 刷新右侧 if (visible) scheduleFilesRefresh(); // 工具调用结果回来,FS 可能被改了,debounce 刷新右侧
// bg proc 刚启动([Background] 开头的结果)→ 结果卡活化(spinner/跳秒/停止) // bg proc 刚启动([Background] 开头的结果)→ 结果卡活化(spinner/跳秒/停止)
// + 立刻拉一次状态,秒数即时起跳 // + 立刻拉一次状态,秒数即时起跳

View File

@ -36,6 +36,7 @@ export function toolActivityLabel(name, args) {
case "load_skill": return `加载技能: ${clip(a.name, 40)}`; case "load_skill": return `加载技能: ${clip(a.name, 40)}`;
case "rename_working_dir": return `重命名工作目录: ${clip(a.new_name, 60)}`; case "rename_working_dir": return `重命名工作目录: ${clip(a.new_name, 60)}`;
case "publish_artifacts": return `发布最终产物: ${clip(JSON.stringify(a.artifacts || []), 80)}`; case "publish_artifacts": return `发布最终产物: ${clip(JSON.stringify(a.artifacts || []), 80)}`;
case "publish_web_preview": return `发布网页项目预览: ${clip(a.directory, 80)}`;
case "office_to_pdf": return `转换 PDF: ${clip(a.source, 80)}`; case "office_to_pdf": return `转换 PDF: ${clip(a.source, 80)}`;
case "seedream": return `生成图像: ${clip(a.prompt, 60)}`; case "seedream": return `生成图像: ${clip(a.prompt, 60)}`;
case "gpt_image": return `生成图像: ${clip(a.prompt, 60)}`; case "gpt_image": return `生成图像: ${clip(a.prompt, 60)}`;

View File

@ -4,6 +4,7 @@
// 导出 open*/close* 供 files / 媒体 chip / 粘贴文件 / main 的 Esc 关栈调用; // 导出 open*/close* 供 files / 媒体 chip / 粘贴文件 / main 的 Esc 关栈调用;
// _categorize 也供 media 段判图/视频。反向依赖 downloadFile(media)、logout(auth)。 // _categorize 也供 media 段判图/视频。反向依赖 downloadFile(media)、logout(auth)。
import { state } from "./state.js"; import { state } from "./state.js";
import { api } from "./api.js";
import { $ } from "./dom.js"; import { $ } from "./dom.js";
import { humanSize, escapeHtml } from "./format.js"; import { humanSize, escapeHtml } from "./format.js";
import { renderMd, highlightIn } from "./markdown.js"; import { renderMd, highlightIn } from "./markdown.js";
@ -17,6 +18,74 @@ const PREVIEW_BIN_MAX = 50 * 1024 * 1024;
const PDFJS_SRC = "/static/vendor/pdfjs/pdf.min.js"; const PDFJS_SRC = "/static/vendor/pdfjs/pdf.min.js";
const PDFJS_WORKER_SRC = "/static/vendor/pdfjs/pdf.worker.min.js"; const PDFJS_WORKER_SRC = "/static/vendor/pdfjs/pdf.worker.min.js";
const WEB_PREVIEW_PREFIX = "[WebPreview] ";
export function parseWebPreviewResult(text) {
const source = String(text || "");
if (!source.startsWith(WEB_PREVIEW_PREFIX)) return null;
try {
const value = JSON.parse(source.slice(WEB_PREVIEW_PREFIX.length).split("\n", 1)[0]);
if (!value || value.type !== "web_preview" || !value.preview_id) return null;
return value;
} catch (_) {
return null;
}
}
export function renderWebPreviewCardHtml(preview) {
if (!preview || !preview.preview_id) return "";
const stopped = preview.status && preview.status !== "active";
const meta = `${Number(preview.file_count || 0)} 个文件 · ${humanSize(Number(preview.size_bytes || 0))}`;
return `<section class="web-preview-card${stopped ? " stopped" : ""}" data-preview-id="${escapeHtml(preview.preview_id)}">
<div class="web-preview-icon" aria-hidden="true">WEB</div>
<div class="web-preview-info">
<strong>${escapeHtml(preview.name || "网页项目预览")}</strong>
<span>${escapeHtml(preview.root || ".")} · ${escapeHtml(meta)}</span>
</div>
<div class="web-preview-actions">
<button type="button" class="web-preview-open"${stopped ? " disabled" : ""}>${stopped ? "已停止" : "打开预览"}</button>
<button type="button" class="web-preview-stop"${stopped ? " disabled" : ""} title="停止此预览">停止</button>
</div>
</section>`;
}
export async function openWebPreview(preview) {
const previewId = typeof preview === "string" ? preview : preview && preview.preview_id;
if (!previewId) return;
const data = await api("POST", `/v1/web-previews/${encodeURIComponent(previewId)}/launch`);
const current = data.preview || preview || {};
_fpCurrentRel = null;
_fpCurrentTaskId = current.task_id || "";
_fpCurrentLegacy = false;
_fpCurrentArtifactId = "";
_fpCurrentWebPreviewId = previewId;
$("fp-name").textContent = current.name || "网页项目预览";
$("fp-meta").textContent = `${Number(current.file_count || 0)} 个文件 · ${humanSize(Number(current.size_bytes || 0))}`;
_resetTextModes("fp");
const body = $("fp-body");
_clearZoom(body);
body.className = "body html-preview";
body.innerHTML = "";
const frame = document.createElement("iframe");
frame.className = "preview-frame preview-html-frame web-project-frame";
frame.title = current.name || "网页项目预览";
frame.referrerPolicy = "no-referrer";
frame.setAttribute("sandbox", "allow-scripts allow-forms allow-modals allow-downloads");
frame.src = data.url;
body.appendChild(frame);
const cf = $("chat-form");
const inset = (cf && cf.offsetParent) ? cf.offsetHeight : 0;
$("file-preview-modal").style.setProperty("--preview-bottom-inset", inset + "px");
$("file-preview-modal").classList.add("show");
document.body.classList.add("fp-open");
}
export async function stopWebPreview(previewId) {
const result = await api("DELETE", `/v1/web-previews/${encodeURIComponent(previewId)}`);
if (_fpCurrentWebPreviewId === previewId) closeFilePreview();
return result;
}
const _scriptCache = new Map(); const _scriptCache = new Map();
function loadScript(src) { function loadScript(src) {
if (_scriptCache.has(src)) return _scriptCache.get(src); if (_scriptCache.has(src)) return _scriptCache.get(src);
@ -69,6 +138,7 @@ let _fpCurrentRel = null;
let _fpCurrentTaskId = ""; let _fpCurrentTaskId = "";
let _fpCurrentLegacy = false; let _fpCurrentLegacy = false;
let _fpCurrentArtifactId = ""; let _fpCurrentArtifactId = "";
let _fpCurrentWebPreviewId = "";
// Markdown / HTML 共用“预览 / 源文件”切换。HTML 在 opaque-origin sandbox iframe // Markdown / HTML 共用“预览 / 源文件”切换。HTML 在 opaque-origin sandbox iframe
// 中运行:可执行脚本、加载 HTTPS 资源,但不能读取 zcbot 页面或发起表单/顶层跳转。 // 中运行:可执行脚本、加载 HTTPS 资源,但不能读取 zcbot 页面或发起表单/顶层跳转。
@ -270,6 +340,7 @@ function _pptPreviewUrl(rel, taskId = "", legacy = false, artifactId = "") {
} }
export async function openFilePreview(rel, taskId = "", legacy = false, artifactId = "") { export async function openFilePreview(rel, taskId = "", legacy = false, artifactId = "") {
_fpCurrentWebPreviewId = "";
_fpCurrentRel = rel; _fpCurrentRel = rel;
_fpCurrentTaskId = taskId; _fpCurrentTaskId = taskId;
_fpCurrentLegacy = legacy; _fpCurrentLegacy = legacy;
@ -718,6 +789,7 @@ export function closeFilePreview() {
_fpCurrentTaskId = ""; _fpCurrentTaskId = "";
_fpCurrentLegacy = false; _fpCurrentLegacy = false;
_fpCurrentArtifactId = ""; _fpCurrentArtifactId = "";
_fpCurrentWebPreviewId = "";
} }
let _mpCurrentRel = null; let _mpCurrentRel = null;