diff --git a/CHANGELOG.md b/CHANGELOG.md index 12dac06..fc3e4eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ## Unreleased +- 现在可以让助手创建 React、Vue、Vite 或普通多文件网页项目,并在完成静态构建后直接打开交互预览;新建 Web 和通用软件项目会默认优先采用 Node.js + TypeScript,已有项目保持原技术栈,科学计算、数据分析和材料研发仍会在更合适时使用 Python。CSS、JavaScript、图片和前端路由会作为完整项目加载,不再局限于单个 HTML 文件。预览卡片在刷新或回看对话后仍可使用,也可以随时停止。 + - 修复国际旗舰模型输出思考过程时偶尔无法及时停止的问题;即使模型暂时没有返回新的流式片段,点击“停止”也会迅速中断当前回答。 - Windows Node 主窗口改为简洁的单层自适应布局:顶部只保留产品名与节点状态,移除固定宽度、重复卡片边框和说明性大标题;默认窗口适当增高,缩放时内容和页签会随可用宽度调整,专业软件的状态与操作也会在窄窗口中自然换行。可用软件按名称归并,不再重复显示同一软件或能力;托盘右键菜单中的长状态也会自动截断,不再把菜单横向撑宽。Origin、ANSYS、Blender 均可手工选择位置,Origin 自动检测也会在 COM 路径无效时继续查找注册表和标准安装目录。 diff --git a/DESIGN.md b/DESIGN.md index 5d6660c..7c290ed 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -498,6 +498,14 @@ ANSYS 能力固定面向 Windows 上的 Mechanical 2024 R2(revision 242), 后续仍需实现 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) diff --git a/PROGRESS.md b/PROGRESS.md index 8429cf2..8003e99 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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 / 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 真机验收待部署后执行,未连接或迁移生产数据库。 diff --git a/RUN.md b/RUN.md index 6ffadf1..433f979 100644 --- a/RUN.md +++ b/RUN.md @@ -95,6 +95,9 @@ # 后台进程(bg proc,DESIGN §8.12;shell/run_python background=true):每用户并发上限,默 3。 # 状态锚 /.zcbot_procs/,后台默认限时 7200s(工具 timeout 参数可调,cap 86400)。 # 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): # ZCBOT_DISABLE_SCHEDULER=1 # 可选,整体关掉调度(对照 Claude Code CLAUDE_CODE_DISABLE_CRON) # ZCBOT_SCHEDULER_TICK_SECONDS=10 # 可选,扫描间隔,默 10s(只决定最坏延迟≤1tick,不影响会否漏) @@ -374,6 +377,9 @@ $env:ZCBOT_EVAL_TOKEN = "" | `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/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/preview_pdf?path=` | PPT/PPTX 结构化产物按 task-relative 路径转 PDF 预览;状态码与旧 `/v1/files/preview_pdf` 一致;旧卡片可同样传 `legacy=true` | 必填 | | `POST /v1/files/upload` | multipart 上传到 `//`;路径不存在自动 mkdir;`conflict` 默认 `rename`,重名原子保存为 `name (n).ext`;仅显式 `conflict=overwrite` 才覆盖,单文件可用 `target_name` 指定要替换的目标文件名 | 必填 | diff --git a/SKILL_LIST.md b/SKILL_LIST.md index 31a1412..cfed89c 100644 --- a/SKILL_LIST.md +++ b/SKILL_LIST.md @@ -1,7 +1,7 @@ # zcbot Skill 清单 服务对象:中国建筑材料科学研究总院 —— 无机非金属材料 R&D(水泥 / 混凝土 / 玻璃 / 陶瓷 / 耐火 / 新型建材) -最后更新:2026-08-06(PPT 取消定向模型,跟随当前对话模型) +最后更新:2026-08-21(coding 增加 Node.js/TypeScript 优先的技术栈路由与静态多文件 Web 预览) Skill 总数:18 zcbot 的"skill"是一份可加载的工作流脚本(`skills//SKILL.md` + 配套 templates / scripts / Python helper),模型在识别用户意图后挂载对应 skill,按其内置的阶段化流程产出可交付物。本文档面向**使用方 / 协作方**,按"做什么、什么时候用、什么时候别用、典型产物"组织。 @@ -30,7 +30,7 @@ zcbot 的"skill"是一份可加载的工作流脚本(`skills//SKILL.md` + | 内容生成 | [imagegen](#imagegen) | 豆包 Seedream 5.0 文生图 + 改图 i2i(¥0.22 / 张) | | 内容生成 | [videogen](#videogen) | 豆包 Seedance 2.0 文生视频(¥1.86 起 / 段) | | 通用 | [analyze](#analyze) | 科学问题拆解 / 引导(模糊命题 → 子问题 + 路线图) | -| 通用 | [coding](#coding) | 修代码 / 调试 / 重构 | +| 通用 | [coding](#coding) | 修代码 / 调试 / 重构 / 创建可预览前端项目 | | 元能力 | [skill-creator](#skill-creator) | 引导用户创建 / 改造自己的私有 skill(从零写或 fork 内置) | --- @@ -499,7 +499,7 @@ paper_server 是内部 Django 文献库:元数据来自 OpenAlex,PDF / XML 由 S --- ### coding -**修代码 / 调试 / 实现。** +**修代码 / 调试 / 实现,也可从零创建可预览的前端软件项目。** 通用工具足够,没有专属 scripts 或 templates —— 代码任务的多样性来自代码本身。 @@ -509,11 +509,16 @@ paper_server 是内部 Django 文献库:元数据来自 OpenAlex,PDF / XML 由 S - **edit 唯一匹配** —— old_str 必须在文件里出现且仅出现一次,不够唯一就多带上下文 - **验证优先** —— 项目有测试就 `pytest` / `npm test`;没有就写最小复现脚本 - **不臆造 API** —— 没用过的库先 read 它的源码或文档,不要凭直觉拼方法名 +- **技术栈路由** —— 已有项目沿用原技术栈;新建 Web/通用软件默认优先 Node.js + TypeScript,前端默认 Vite;科学计算、数据分析、机器学习和材料研发在 Python 生态更合适时优先 Python +- **Node 项目流程** —— 新项目默认 npm,保留正常 `package.json`、源码、资源、配置和测试,按项目脚本完成依赖安装、测试与构建 +- **项目预览** —— 保持正常多文件结构,完成测试和正式构建后用 `publish_web_preview` 发布 `dist/build` 等静态输出;修改后重新构建并发布同一目录即可刷新 +- **能力边界** —— 当前在线预览只运行静态前端;需要后端、数据库、开发服务器或服务端渲染时交付源码与运行说明,不拿临时后台进程冒充可访问预览 **反模式**(明确禁止): - 一次性 write 整个大文件(改 3 行就别 write 200 行) - 没读过文件直接 edit(大概率 old_str 匹配不上) - 跑测试失败就立刻改测试(先看是测试错还是代码错) +- 把 CSS/JS/资源强行内联成单 HTML,或启动临时服务后声称用户可以访问 **输出**:改完后一两句话说清「改了什么、为什么、怎么验证」,**不复述 diff** —— 用户会自己看。 diff --git a/core/agent_builder.py b/core/agent_builder.py index b390446..8347303 100644 --- a/core/agent_builder.py +++ b/core/agent_builder.py @@ -421,6 +421,15 @@ def _build_system_prompt( "office_to_pdf、图像和视频工具会自动发布其成品,无需重复调用。\n" 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 时,仅当 host-side `office_to_pdf` 的工具说明列出该格式" "才调用;LibreOffice 在 backend host,不在 Docker shell 内探测 `soffice`。\n" @@ -440,6 +449,7 @@ def _build_system_prompt( f"⛔ 不要把产物写到 cwd / `skills/` / repo 根 —— 只写到 task_dir。\n" f"{rename_line}" f"{publish_line}" + f"{web_preview_line}" f"\n## 生成 Word / PDF 报告(验收 / 技术 / 评审报告等自由长文)\n" f"**优先**把正文写成 Markdown(`/sections/*.md`,纯文本、零转义 / 零语法风险)," f"再调平台渲染器直接出 docx 或 pdf —— **别在 run_python 里手撸文档转换脚本**" diff --git a/core/storage/models.py b/core/storage/models.py index 46895f1..a3ffd3d 100644 --- a/core/storage/models.py +++ b/core/storage/models.py @@ -215,6 +215,49 @@ class Message(Base): 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): """Stable identity and lifecycle metadata for a published workspace file.""" diff --git a/core/tool_registry.py b/core/tool_registry.py index 6dedf96..be06f12 100644 --- a/core/tool_registry.py +++ b/core/tool_registry.py @@ -138,6 +138,7 @@ def build_tools(ctx: ToolContext) -> dict[str, Any]: def _task_actions() -> list: from tools.publish_artifacts import PublishArtifactsTool + from tools.publish_web_preview import PublishWebPreviewTool return [ RenameWorkingDirTool( ctx.deferred_actions, @@ -154,6 +155,12 @@ def build_tools(ctx: ToolContext) -> dict[str, Any]: working_dir=ctx.working_dir_path, **wd_base, ), + PublishWebPreviewTool( + ctx.uid, + ctx.task_id, + working_dir=ctx.working_dir_path, + **wd_base, + ), ] def _document_search() -> list: diff --git a/core/web_previews.py b/core/web_previews.py new file mode 100644 index 0000000..3d4b4da --- /dev/null +++ b/core/web_previews.py @@ -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) diff --git a/db/migrations/versions/20260821_0900_0036_web_previews.py b/db/migrations/versions/20260821_0900_0036_web_previews.py new file mode 100644 index 0000000..946559b --- /dev/null +++ b/db/migrations/versions/20260821_0900_0036_web_previews.py @@ -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") diff --git a/skills/coding/SKILL.md b/skills/coding/SKILL.md index 6711f76..b158710 100644 --- a/skills/coding/SKILL.md +++ b/skills/coding/SKILL.md @@ -1,6 +1,6 @@ --- name: coding -description: 修改、调试、实现代码相关任务。当用户要求修 bug、写函数、重构、读懂代码库、跑测试时使用。 +description: 修改、调试、实现代码与软件项目。当用户要求修 bug、写函数、重构、读懂代码库、跑测试,或从零创建 Node.js、TypeScript、Python 及可在网页中预览的前端应用时使用。 --- # Coding @@ -17,6 +17,15 @@ description: 修改、调试、实现代码相关任务。当用户要求修 bug - **验证优先**: 项目有测试就 `shell pytest` / `npm test`;没有就写最小复现脚本验证 - **不臆造 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 —— 用户会自己看 @@ -25,3 +34,4 @@ description: 修改、调试、实现代码相关任务。当用户要求修 bug - 一次性 write 整个大文件 (改 3 行就别 write 200 行) - 没读过文件直接 edit (大概率 old_str 匹配不上) - 跑测试失败就立刻改测试 (先看是测试错还是代码错) +- 用后台进程临时启动服务并把它描述成可访问的网页预览 diff --git a/tests/frontend_web_preview.test.mjs b/tests/frontend_web_preview.test.mjs new file mode 100644 index 0000000..5017640 --- /dev/null +++ b/tests/frontend_web_preview.test.mjs @@ -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"/); +}); diff --git a/tests/test_system_prompt_paths.py b/tests/test_system_prompt_paths.py index b6fe379..8ce8b81 100644 --- a/tests/test_system_prompt_paths.py +++ b/tests/test_system_prompt_paths.py @@ -43,6 +43,13 @@ def _build(backend: str, *, allow_rename: bool = True) -> tuple[str, Path, Path] 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): prompt, _, _ = _build("docker") self.assertTrue(prompt.startswith("## 当前时间与时效\n")) @@ -73,6 +80,15 @@ class TestSystemPromptPaths(unittest.TestCase): scheduled, _, _ = _build("docker", allow_rename=False) self.assertIn("必须调用 `rename_working_dir`", interactive) 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): prompt, _, _ = _build("docker") diff --git a/tests/test_web_previews.py b/tests/test_web_previews.py new file mode 100644 index 0000000..29c3f39 --- /dev/null +++ b/tests/test_web_previews.py @@ -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("", 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 = '' + rewritten = _rewrite_html(html, prefix) + self.assertIn(f'', rewritten) + self.assertIn(f'src="{prefix}/assets/app.js"', rewritten) + self.assertIn("https://", _rewrite_html('x', 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( + '', + 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() diff --git a/tests/test_web_routes_nodb.py b/tests/test_web_routes_nodb.py index b9ee36a..da34aa9 100644 --- a/tests/test_web_routes_nodb.py +++ b/tests/test_web_routes_nodb.py @@ -100,6 +100,7 @@ class AuthGateTests(unittest.TestCase): ("GET", "/v1/kb"), ("GET", "/v1/schedules"), ("GET", "/v1/files"), + ("GET", "/v1/web-previews/00000000-0000-0000-0000-000000000000"), ("GET", "/v1/user/storage"), ("GET", "/v1/procs"), ("GET", "/v1/wechat/bind"), diff --git a/tools/publish_web_preview.py b/tools/publish_web_preview.py new file mode 100644 index 0000000..4f14744 --- /dev/null +++ b/tools/publish_web_preview.py @@ -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=(",", ":")) diff --git a/web/app.py b/web/app.py index e39e7ff..1c0270e 100644 --- a/web/app.py +++ b/web/app.py @@ -60,6 +60,7 @@ from .routers.skills_memory import register_skill_memory_routes from .routers.software_nodes import register_software_node_routes from .routers.tasks import register_task_routes from .routers.wechat import register_wechat_routes +from .routers.web_previews import register_web_preview_routes from .scheduler_runner import start_scheduler from .software_followups import ( requeue_stale_analysis_followups, @@ -211,6 +212,7 @@ def create_app() -> FastAPI: register_kb_routes(app, require_user=require_user) register_schedule_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_asr_routes(app, require_user=require_user, auth_cfg=auth_cfg) register_task_routes(app, require_user=require_user) diff --git a/web/routers/web_previews.py b/web/routers/web_previews.py new file mode 100644 index 0000000..5fa0881 --- /dev/null +++ b/web/routers/web_previews.py @@ -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'' + if re.search(r"(?i)]*)?>", rewritten): + return re.sub( + r"(?i)]*)?>", + 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) diff --git a/web/static/dev.html b/web/static/dev.html index 09f6800..f333a1e 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -1204,6 +1204,34 @@ } .software-preview-chip .art-chip-icon { flex-basis: 26px; height: 26px; opacity: .8; } .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; } .paste-chip-wrap { display: inline-flex; align-items: center; max-width: 280px; margin: 0 2px; diff --git a/web/static/js/chat.js b/web/static/js/chat.js index a058e9f..85b3907 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -41,6 +41,10 @@ import { openPasteFilePreview, closeAllPreviews, closePreviewIfShowing, + openWebPreview, + parseWebPreviewResult, + renderWebPreviewCardHtml, + stopWebPreview, } from "./preview.js"; import { loadFiles, scheduleFilesRefresh, uploadFiles, formatUploadProgress } from "./files.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 activityLabel = callMeta ? callMeta.label : `已执行 ${p.name || "工具"}`; const failed = isToolResultFailure(txt); + const webPreview = (p.name || "") === "publish_web_preview" + ? parseWebPreviewResult(txt) : null; // 工具结果只有产物工具(seedream/seedance)挂 chip + inline 大图;通用工具 // (grep/read/glob/shell)echo 的路径是"引用"不是"产物",不挂以免噪声。 const isProducer = ARTIFACT_PRODUCING_TOOLS.has(p.name || "") @@ -1762,6 +1768,7 @@ function renderMessages(msgs, { stickBottom = true } = {}) { ${escapeHtml(activityLabel)}${(txt || "").length} 字符${banner}
${escapeHtml(txt || "")}
+ ${renderWebPreviewCardHtml(webPreview)} ${renderArtifactBarHtml(rels, isProducer, state.taskId || "", true)} `; // bg proc 启动结果 → 卡片活化(spinner/跳秒/停止,与直播态同构;真实状态 @@ -2721,7 +2728,7 @@ document.addEventListener("keydown", (e) => { // 对话流里 artifact chip / 内联 img / HTML 放大按钮点击委托 — 复用文件预览 modal。 // 视频走原生