feat(preview): add multi-file web project previews
This commit is contained in:
parent
efcf06bf24
commit
503820671a
|
|
@ -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 路径无效时继续查找注册表和标准安装目录。
|
||||||
|
|
|
||||||
|
|
@ -498,6 +498,14 @@ ANSYS 能力固定面向 Windows 上的 Mechanical 2024 R2(revision 242),
|
||||||
|
|
||||||
后续仍需实现 Token 轮换;不得以任意命令或脚本接口临时代替。当前 Job 中心采用轮询而非用户事件推送,单活与 offer 选择仍只覆盖单 Web 进程;生产启用多实例前必须增加 Redis/PG fencing 或固定路由到单一控制面实例。
|
后续仍需实现 Token 轮换;不得以任意命令或脚本接口临时代替。当前 Job 中心采用轮询而非用户事件推送,单活与 offer 选择仍只覆盖单 Web 进程;生产启用多实例前必须增加 Redis/PG fencing 或固定路由到单一控制面实例。
|
||||||
|
|
||||||
|
### 8.17 静态多文件 Web 项目预览(implementation,2026-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)
|
||||||
|
|
|
||||||
|
|
@ -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
6
RUN.md
|
|
@ -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` 指定要替换的目标文件名 | 必填 |
|
||||||
|
|
|
||||||
|
|
@ -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** —— 用户会自己看。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 里手撸文档转换脚本**"
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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 匹配不上)
|
||||||
- 跑测试失败就立刻改测试 (先看是测试错还是代码错)
|
- 跑测试失败就立刻改测试 (先看是测试错还是代码错)
|
||||||
|
- 用后台进程临时启动服务并把它描述成可访问的网页预览
|
||||||
|
|
|
||||||
|
|
@ -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"/);
|
||||||
|
});
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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=(",", ":"))
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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/跳秒/停止)
|
||||||
// + 立刻拉一次状态,秒数即时起跳
|
// + 立刻拉一次状态,秒数即时起跳
|
||||||
|
|
|
||||||
|
|
@ -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)}`;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue