From 05e606049088b1187e5ed735136ccdf1a9739a58 Mon Sep 17 00:00:00 2001 From: Wenxin Duan Date: Wed, 16 Sep 2026 16:32:16 +0800 Subject: [PATCH] feat: publish Agent Studio integrations and setup guide --- .env.example | 10 + .gitignore | 7 + README.md | 275 +++--- apps/server/src/app.ts | 16 + apps/server/src/errors/error-handler.ts | 2 + apps/server/src/index.ts | 102 +- apps/server/src/routes/cms.ts | 58 ++ apps/server/src/routes/editor.ts | 48 + apps/server/src/routes/image-video.ts | 13 + apps/server/src/routes/reports.ts | 20 + apps/server/tests/cms-api.test.ts | 21 + apps/server/tests/editor-api.test.ts | 37 + apps/server/tests/image-video-api.test.ts | 35 +- apps/server/tests/report-api.test.ts | 20 + apps/server/tests/test-app.ts | 8 +- apps/web/src/App.tsx | 18 +- .../components/approvals/ApprovalDialog.tsx | 24 +- apps/web/src/components/cms/CmsPanel.tsx | 66 ++ apps/web/src/components/layout/AppShell.tsx | 5 +- .../web/src/components/skills/SkillsPanel.tsx | 3 +- .../components/tools/EditorWorkflowPanel.tsx | 72 ++ .../src/components/tools/ImageVideoPanel.tsx | 38 +- .../web/src/components/tools/ToolFallback.tsx | 46 +- apps/web/src/store/reducer.ts | 6 + apps/web/src/styles/components.css | 40 + apps/web/src/styles/layout.css | 1 + apps/web/tests/app-shell.test.tsx | 38 + apps/web/tests/cms-panel.test.tsx | 22 + apps/web/tests/editor-workflow.test.tsx | 21 + apps/web/tests/image-video.test.tsx | 36 + apps/web/tests/reducer.test.ts | 11 + apps/web/tests/tools-approval.test.tsx | 44 + apps/web/vite.config.ts | 5 +- docs/drama-minis-web.md | 29 + docs/editor-acceptance-title.md | 54 ++ docs/editor-cms-web.md | 56 ++ docs/event-mapping.md | 6 +- docs/image-to-video-2s-web-test.md | 43 + docs/image-to-video-acceptance.md | 33 + docs/image-to-video-cat-recovery.md | 41 + docs/image-to-video-live-artwork-test.md | 78 ++ docs/live-model-verification.md | 40 + docs/security-boundaries.md | 13 +- .../2026-09-09-drama-minis-integration.md | 11 + .../plans/2026-09-14-editor-cms-connection.md | 52 + .../plans/2026-09-14-editor-media-pipeline.md | 54 ++ .../plans/2026-09-15-image-to-video-web.md | 71 ++ .../2026-09-10-short-drama-web-design.md | 8 +- fixtures/vision-shapes.svg | 6 + packages/harness/package.json | 7 +- .../src/agent/pi-agent-session-adapter.ts | 8 +- .../harness/src/agent/pi-event-adapter.ts | 20 +- packages/harness/src/cms/cms-connection.ts | 205 ++++ packages/harness/src/cms/editor-tools.ts | 32 + packages/harness/src/image-video/contracts.ts | 7 +- packages/harness/src/image-video/mcp.ts | 79 +- packages/harness/src/image-video/media.ts | 27 +- packages/harness/src/image-video/service.ts | 42 +- packages/harness/src/index.ts | 12 + .../harness/src/media/analysis-binding.ts | 30 + packages/harness/src/media/editor-service.ts | 125 +++ .../harness/src/media/editor-subtitles.ts | 21 + packages/harness/src/media/editor-tools.ts | 21 + packages/harness/src/media/editor-workflow.ts | 135 +++ packages/harness/src/media/hls.ts | 48 + packages/harness/src/media/media-worker.ts | 82 ++ packages/harness/src/media/recipe-binding.ts | 19 + packages/harness/src/media/review-ledger.ts | 9 + packages/harness/src/media/skill-runner.ts | 48 + packages/harness/src/skills/skill-catalog.ts | 38 +- packages/harness/src/tools/minis-report.ts | 108 +++ packages/harness/src/tools/swads-mcp.ts | 78 ++ packages/harness/src/tools/tool-policy.ts | 6 +- .../harness/tests/analysis-binding.test.ts | 27 + packages/harness/tests/cms-connection.test.ts | 102 ++ packages/harness/tests/editor-service.test.ts | 18 + .../harness/tests/editor-subtitles.test.ts | 9 + packages/harness/tests/editor-tools.test.ts | 19 + .../harness/tests/editor-workflow.test.ts | 56 ++ .../harness/tests/fixtures/editor-analysis.ts | 9 + .../harness/tests/fixtures/editor-plan.ts | 15 + packages/harness/tests/hls.test.ts | 25 + .../tests/image-video-duration.test.ts | 37 + .../harness/tests/image-video-mcp.test.ts | 107 +++ packages/harness/tests/image-video.test.ts | 66 ++ packages/harness/tests/media-worker.test.ts | 28 + packages/harness/tests/minis-report.test.ts | 34 + .../harness/tests/pi-event-adapter.test.ts | 39 + packages/harness/tests/recipe-binding.test.ts | 22 + packages/harness/tests/review-ledger.test.ts | 12 + packages/harness/tests/skill-runner.test.ts | 43 + packages/harness/tests/skills.test.ts | 57 +- packages/harness/tests/swads-mcp.test.ts | 38 + packages/harness/tests/tools.test.ts | 8 + pnpm-lock.yaml | 893 +++++++++++++++++- pnpm-workspace.yaml | 1 + scripts/start-private-server.mjs | 37 + scripts/verify-editor-api.mjs | 100 ++ scripts/verify-editor-media.mjs | 21 + scripts/verify-editor-source.mjs | 83 ++ scripts/verify-image-video-readiness.mjs | 52 + scripts/verify-live-model.mjs | 89 ++ scripts/verify-minis-render.mjs | 23 + scripts/verify-model-vision.mjs | 50 + scripts/verify-private-cms.mjs | 18 + scripts/verify-tool-image-transport.mjs | 32 + 106 files changed, 4942 insertions(+), 198 deletions(-) create mode 100644 apps/server/src/routes/cms.ts create mode 100644 apps/server/src/routes/editor.ts create mode 100644 apps/server/src/routes/reports.ts create mode 100644 apps/server/tests/cms-api.test.ts create mode 100644 apps/server/tests/editor-api.test.ts create mode 100644 apps/server/tests/report-api.test.ts create mode 100644 apps/web/src/components/cms/CmsPanel.tsx create mode 100644 apps/web/src/components/tools/EditorWorkflowPanel.tsx create mode 100644 apps/web/tests/cms-panel.test.tsx create mode 100644 apps/web/tests/editor-workflow.test.tsx create mode 100644 docs/drama-minis-web.md create mode 100644 docs/editor-acceptance-title.md create mode 100644 docs/editor-cms-web.md create mode 100644 docs/image-to-video-2s-web-test.md create mode 100644 docs/image-to-video-acceptance.md create mode 100644 docs/image-to-video-cat-recovery.md create mode 100644 docs/image-to-video-live-artwork-test.md create mode 100644 docs/live-model-verification.md create mode 100644 docs/superpowers/plans/2026-09-09-drama-minis-integration.md create mode 100644 docs/superpowers/plans/2026-09-14-editor-cms-connection.md create mode 100644 docs/superpowers/plans/2026-09-14-editor-media-pipeline.md create mode 100644 docs/superpowers/plans/2026-09-15-image-to-video-web.md create mode 100644 fixtures/vision-shapes.svg create mode 100644 packages/harness/src/cms/cms-connection.ts create mode 100644 packages/harness/src/cms/editor-tools.ts create mode 100644 packages/harness/src/media/analysis-binding.ts create mode 100644 packages/harness/src/media/editor-service.ts create mode 100644 packages/harness/src/media/editor-subtitles.ts create mode 100644 packages/harness/src/media/editor-tools.ts create mode 100644 packages/harness/src/media/editor-workflow.ts create mode 100644 packages/harness/src/media/hls.ts create mode 100644 packages/harness/src/media/media-worker.ts create mode 100644 packages/harness/src/media/recipe-binding.ts create mode 100644 packages/harness/src/media/review-ledger.ts create mode 100644 packages/harness/src/media/skill-runner.ts create mode 100644 packages/harness/src/tools/minis-report.ts create mode 100644 packages/harness/src/tools/swads-mcp.ts create mode 100644 packages/harness/tests/analysis-binding.test.ts create mode 100644 packages/harness/tests/cms-connection.test.ts create mode 100644 packages/harness/tests/editor-service.test.ts create mode 100644 packages/harness/tests/editor-subtitles.test.ts create mode 100644 packages/harness/tests/editor-tools.test.ts create mode 100644 packages/harness/tests/editor-workflow.test.ts create mode 100644 packages/harness/tests/fixtures/editor-analysis.ts create mode 100644 packages/harness/tests/fixtures/editor-plan.ts create mode 100644 packages/harness/tests/hls.test.ts create mode 100644 packages/harness/tests/image-video-duration.test.ts create mode 100644 packages/harness/tests/image-video-mcp.test.ts create mode 100644 packages/harness/tests/media-worker.test.ts create mode 100644 packages/harness/tests/minis-report.test.ts create mode 100644 packages/harness/tests/recipe-binding.test.ts create mode 100644 packages/harness/tests/review-ledger.test.ts create mode 100644 packages/harness/tests/skill-runner.test.ts create mode 100644 packages/harness/tests/swads-mcp.test.ts create mode 100644 scripts/start-private-server.mjs create mode 100644 scripts/verify-editor-api.mjs create mode 100644 scripts/verify-editor-media.mjs create mode 100644 scripts/verify-editor-source.mjs create mode 100644 scripts/verify-image-video-readiness.mjs create mode 100644 scripts/verify-live-model.mjs create mode 100644 scripts/verify-minis-render.mjs create mode 100644 scripts/verify-model-vision.mjs create mode 100644 scripts/verify-private-cms.mjs create mode 100644 scripts/verify-tool-image-transport.mjs diff --git a/.env.example b/.env.example index 0f5ed18..f766408 100644 --- a/.env.example +++ b/.env.example @@ -15,3 +15,13 @@ SESSION_RUN_TIMEOUT_MS=120000 APPROVAL_TIMEOUT_MS=60000 MAX_IMAGE_BYTES=5242880 MAX_IMAGES_PER_MESSAGE=4 + +# Optional real Skill integrations. Never commit filled credentials. +SWADS_MCP_TOKEN= +# Absolute directory paths containing SKILL.md; leave overrides commented unless set. +# IMAGE_VIDEO_SKILL_DIR=/absolute/path/to/swads-image-to-video +# DRAMA_MINIS_SKILL_DIR=/absolute/path/to/drama-minis-diagnosis +# DRAMA_EDITOR_SKILL_DIR=/absolute/path/to/swads-short-drama-editor +# PYTHON_EXECUTABLE=/absolute/path/to/python3 +# Optional CMS public key, not required for image-to-video. +CMS_SUPABASE_PUBLISHABLE_KEY= diff --git a/.gitignore b/.gitignore index b2a60fd..db5c6f1 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,10 @@ workspace/sessions/* !workspace/sessions/.gitkeep *.log .DS_Store +.agent-studio-sessions/ +.agent-studio-runtime/ + +# One-shot operator repairs contain machine/task-specific recovery assumptions. +/scripts/recover-*.mjs +/scripts/diagnose-authorized-agent.mjs +.superpowers/ diff --git a/README.md b/README.md index 4a20d0d..ba76c7b 100644 --- a/README.md +++ b/README.md @@ -1,135 +1,163 @@ # Agent Studio Mini -一个面向课堂演示的可观察 Agent Harness。它把 Pi Coding Agent SDK 放在 Fastify 服务端,通过 HTTP + SSE 将 Session、流式回复、Skills、工具调用、审批与图片附件映射到 assistant-ui 三栏工作台。 +基于 React、assistant-ui、Fastify 和 Pi Coding Agent SDK 的本地 Agent 工作台。通过网页调用模型和真实 Skill,观察工具执行过程,并在图片外传、视频生成等操作前逐项审批。 -> 截图占位说明:仓库不提交与本机配置绑定的截图。启动后打开 `http://127.0.0.1:5173`,即可看到左侧 Skills、中央 Streaming Chat、右侧 Event Timeline 的完整界面。 +> 当前定位是单用户、本地运行的开发与验收版本,不是可直接暴露到公网的多租户产品。图生视频仍依赖 SW Ads / H3;历史成功样片不代表所有任务都已实现无人干预稳定出片。 -## 架构 +## 功能与状态 -```mermaid -flowchart TD - UI[assistant-ui Composer / Thread / Tool UI] - API[Fastify API] - REG[SessionRegistry] - PI[Pi AgentSession] - EVT[EventStore / ApprovalBroker] - RES[ResourceLoader / Tools] - VIEW[Timeline / Approval] - CORE[Skills / Workspace / Model] +| 功能 | 当前状态 | +| --- | --- | +| 模型聊天 | 流式回复、HTTP + SSE 事件、取消运行;支持 OpenAI、Anthropic 和兼容网关配置 | +| 图片输入 | JPEG / PNG / WebP 上传、规范化和图片能力校验;视觉能力需按实际模型验证 | +| Skill 调用 | 展示目录元信息,执行时读取真实 SKILL.md 和允许的参考资源 | +| 工具审批 | 服务端管理审批、超时与拒绝;浏览器不能伪造执行成功 | +| 图生视频 | 本机草稿 → 上传审批 → SW Ads 入库 → H3 生成审批 → 查询、校验、播放、下载 MP4 | +| 旧任务接续 | 重启后,经明确确认将草稿或待生成任务接续到新会话;不重复上传、不自动生成 | +| Drama Minis 报告 | 真实指标查询与 HTML / JSON 报告;PDF 需要 Chrome / Chromium | +| CMS 剪辑 | 已有连接、素材准备和受控预览工具;完整剪辑验收仍有限制 | - UI -->|HTTP + SSE| API - API --> REG - REG --> PI - API --> EVT - PI --> RES - EVT --> VIEW - RES --> CORE -``` +## 快速开始 -```mermaid -sequenceDiagram - participant U as 用户 - participant W as assistant-ui - participant H as Harness - participant P as Pi AgentSession - U->>W: 发送文字和可选图片 - W->>H: POST message - H-->>W: 202 + runId - H->>P: prompt(text, images) - P-->>H: 文本、Skill read、Tool 事件 - H-->>W: SSE HarnessEvent - P->>H: save_report_draft - H-->>W: approval.required - U->>H: Approve 或 Deny - H-->>P: 审批结果 - P-->>H: tool/result + message end - H-->>W: run.completed -``` +### 环境要求 -职责边界:assistant-ui 只负责交互和渲染;Harness 是 Session、事件、附件、模型与审批的权威;Pi 负责 Agent loop、模型调用、ResourceLoader 与工具执行。浏览器不直接持有 Pi Session,也不能自行把写工具标为已批准。 - -更详细的组件和数据流见 [docs/architecture.md](docs/architecture.md),事件对应关系见 [docs/event-mapping.md](docs/event-mapping.md)。 - -## 目录 - -```text -apps/server Fastify API、SSE、启动装配 -apps/web React + assistant-ui 三栏工作台 -packages/shared 浏览器与服务端共享的 Zod 协议 -packages/harness Pi adapter、Session、事件、审批、附件和安全边界 -.agents/skills 示例 ads-analysis Skill -fixtures 确定性的模拟广告账户数据 -docs 架构、安全、事件、课堂演示和生产路线 -``` - -## 环境与安装 - -- Node.js 22.19 或更高版本。Pi 0.85.1 的实际要求高于原始需求中的 Node.js 20。 -- pnpm 11.7.0。 +- Node.js **22.19+**;建议使用受支持的 LTS 版本。 +- pnpm **11.7.0**,版本见 package.json。 +- 可调用的 LLM API Key;仅启动界面和运行默认测试不需要真实 Key。 +- 图生视频额外需要 SW Ads MCP Token、完整的 swads-image-to-video Skill 和 Python 3。 +- FFmpeg / FFprobe 使用项目依赖中的二进制;安装时需要允许相关依赖构建和下载。 ```bash -pnpm install -pnpm dev +git clone https://git.mincode.cn/wenxinduan50/agent.git +cd agent +pnpm install --frozen-lockfile +pnpm build +cp .env.example .env ``` -Web 默认地址是 `http://127.0.0.1:5173`,Fastify 默认监听 `http://127.0.0.1:3001`。根命令会同时启动两者,Vite 将 `/api` 和 `/health` 代理到 Fastify。 +编辑 .env,填写自己的配置。**不要提交 .env、API Key、Token 或 CMS 密码。** + +在项目根目录启动后端: + +```bash +node --env-file=.env apps/server/dist/index.js +``` + +另开一个终端,在同一目录启动网页: + +```bash +pnpm --filter @agent-studio/web dev +``` + +- 网页: +- 后端: +- Vite 将 /api 和 /health 代理到后端。 + +首次构建不能省略,工作区包通过 dist 导出。后端源码变化后重新构建并重启;网页开发服务支持热更新。 + +如果已经通过 shell / IDE 注入环境变量,也可使用 `pnpm dev` 同时启动前后端。**pnpm dev 不会自动加载 .env**;上面的 `node --env-file` 才负责读取文件。 ## 配置模型 -模型解析优先级为:当前 Session 的 Settings 配置、服务器环境变量、明确的 `MODEL_NOT_CONFIGURED` 错误。没有“第一个可用模型”的隐式回退。 +可以使用服务端环境变量,也可以在网页右上角“模型设置”中配置当前会话。优先级:当前会话配置 → 环境变量 → 明确提示未配置;不会自动切换模型。 -`.env.example` 是变量清单;服务端只读取启动进程的环境,不会自动加载 `.env`。可以由 shell、IDE 或进程管理器注入,例如: +| 变量 | 用途 | +| --- | --- | +| AGENT_PROVIDER | openai、anthropic 或 compatible | +| AGENT_MODEL | 提供商支持的准确模型 ID | +| LLM_API | openai-responses、openai-completions 或 anthropic-messages | +| LLM_BASE_URL | 自定义兼容接口地址;默认限制为安全的 HTTPS 目标 | +| OPENAI_API_KEY / ANTHROPIC_API_KEY | 对应模型凭证 | +| AGENT_MODEL_SUPPORTS_IMAGES | 显式声明图片能力,不是检测结果 | +| SESSION_RUN_TIMEOUT_MS | 单次 Agent 运行上限,默认 120000 毫秒 | +| APPROVAL_TIMEOUT_MS | 审批有效时间,默认 60000 毫秒 | -```bash -export AGENT_PROVIDER=openai -export AGENT_MODEL=gpt-5-mini -export LLM_API=openai-responses -export OPENAI_API_KEY='your-key' -export AGENT_MODEL_SUPPORTS_IMAGES=true -pnpm dev +OpenAI 兼容服务通常使用 `AGENT_PROVIDER=compatible`、`LLM_API=openai-completions`,具体以所用服务契约为准。聊天模型与 H3 视频模型是独立链路,聊天 API Key 不能代替视频凭证。 + +网页填写的凭证只保存在服务端内存,接口返回脱敏提示;重启或清除会话后失效。网页“连接测试”可能仅返回“首次运行验证”,不能据此声称真实调用成功。 + +## 安装真实 Skills + +仓库自带 ads-analysis、report-writer 演示 Skill。三个业务 Skill 的原始文件**不随本仓库分发**,需另行取得完整且获授权的目录,包括 references、scripts、assets 等资源。 + +| Skill | 默认位置 | 覆盖变量 | +| --- | --- | --- | +| swads-image-to-video | ~/.codex/skills/swads-image-to-video | IMAGE_VIDEO_SKILL_DIR | +| drama-minis-diagnosis | ~/.codex/skills/drama-minis-diagnosis | DRAMA_MINIS_SKILL_DIR | +| swads-short-drama-editor | ~/.codex/skills/swads-short-drama-editor | DRAMA_EDITOR_SKILL_DIR | + +覆盖变量填写 **Skill 目录的绝对路径**,不是 SKILL.md 文件路径。其他 Skill 可使用 AGENT_SKILLS_DIRS 配置,多个目录用操作系统路径分隔符连接(macOS / Linux 为冒号)。 + +图生视频和剪辑还需将 PYTHON_EXECUTABLE 设置为 Python 3 可执行文件的绝对路径,可用 `command -v python3` 查询。代码中的默认路径面向本机 Codex 运行时,其他电脑不一定存在。 + +仅复制 Skill 文件,不等于自动接通它引用的所有工具。工具仍须经过 Harness 白名单、参数校验和审批策略适配;缺少资源或后端能力时应明确报告不可用。 + +## 在网页生成视频 + +1. 配置 SWADS_MCP_TOKEN、图生视频 Skill 和 Python,并启动服务。当前 MCP 地址由代码固定为 https://ads.mincode.cn/mcp。 +2. 在 Skills 区域的“图片 → 有声视频”表单选择参考图,填写标题、动作与声音、目标秒数。 +3. 点击“准备本地任务”。这一步只保存在本机并填写聊天框,**不会自动发送**。 +4. 点击“发送”。Agent 读取 Skill,并申请参考图上传审批。 +5. 核对图片、提示词和时长,勾选确认后批准上传。该操作会产生 SW Ads 中待审核、未分配账户的参考素材,不是完全无入库副作用。 +6. 参考图就绪后,另行确认 H3 生成审批。费用以服务和账户为准,不假定免费。 +7. 网页按原任务轮询。成片通过技术检查后,任务卡显示播放器和“下载 MP4”。 + +当前使用 H3 ref2va、9:16、768p。成片检查时长、尺寸、帧率、完整解码和音轨;符合约束的少量超长尾帧会按原速裁齐,不通过降速、循环或定格伪造时长。**技术检查通过不等于画面、声音和提示词效果已经人工验收。** + +### 重启后继续任务 + +在原任务卡点击“继续此任务”,核对信息并确认接续到当前会话。已入库任务复用原图和回执,聊天框只准备生成审批请求,不重新上传、不自动生成。 + +- 只允许本地草稿和“参考图已入库 · 待生成审批”的任务接续。 +- 原会话仍有效时,请回原页面继续,不能由其他会话接管。 +- 排队或生成中的任务按原 job ID 查询,不重新提交。 +- unknown_upload / unknown_submission 必须核实回执,不能盲目重试。 +- 接续恢复任务操作权,不恢复旧聊天历史或 API Key。 + +当前表单要求提供图片;未传图时自动 AI 生图尚未作为完整链路交付。 + +## 其他工作流 + +- **课堂 Demo**:分析内置 demo-account 的模拟广告数据,经审批保存报告。fixtures 不是真实账户。 +- **Drama Minis**:需要业务 Skill 和 SW Ads Token;真实指标查询、报告生成均受审批控制。PDF 依赖 Chrome / Chromium,失败时保留 HTML / JSON。 +- **CMS 剪辑**:需要独立 CMS 配置与授权素材;图生视频不需要 CMS。详见 [CMS 接入说明](docs/editor-cms-web.md)。 + +## 项目结构 + +```text +apps/web/ React + assistant-ui,聊天、任务卡、审批、事件界面 +apps/server/ Fastify API、SSE、本地任务与媒体路由 +packages/harness/ Pi 适配、Skills、模型、工具策略、审批和任务状态机 +packages/shared/ 前后端共享协议与 Zod 校验 +.agents/skills/ ads-analysis 演示 Skill +.pi/skills/ report-writer 演示 Skill +fixtures/ 测试与演示数据 +docs/ 架构、安全、接入和验收说明 +scripts/ 可选诊断和真实服务验证工具 ``` -Anthropic 使用 `AGENT_PROVIDER=anthropic`、`LLM_API=anthropic-messages` 和 `ANTHROPIC_API_KEY`。自定义兼容服务可设置 `LLM_BASE_URL`;默认仅接受安全的 HTTPS 地址,开发环境可接受 localhost,内网地址仅在明确设置 `ALLOW_PRIVATE_LLM_BASE_URLS=true` 后允许。 +```mermaid +flowchart LR + Web[网页工作台] -->|HTTP / SSE| API[Fastify + Harness] + API --> Agent[Pi Agent + 聊天模型] + Agent --> Skills[Skill 与受控工具] + Skills --> Approval[服务端审批] + Approval --> SW[SW Ads MCP / H3] + SW --> Tasks[本机任务记录与 MP4 校验] + Tasks --> Web +``` -也可以在页面右上角打开 Settings,填写 Provider、API 类型、Base URL、API Key、Model 和图片能力。保存后配置只属于当前 Session。连接测试在当前实现中安全地返回“首次运行验证”,不会编造一次真实调用成功。 +## 数据与安全 -API Key 从同源请求提交后只驻留服务器进程内存,并在 Pi `InMemoryCredentialStore` 中注入;不会写入 localStorage、sessionStorage、IndexedDB、SSE、事件、响应或用户目录。GET 只返回脱敏的 `keyHint`。使用页面“重置训练会话”、清除凭证或重启进程会清除对应内存配置。 +- .agent-studio-runtime/image-video/ 保存持久任务、冻结参考图、私有回执和已校验 MP4。备份时视为私有业务数据;回执可能含签名 URL。 +- .agent-studio-sessions/ 是临时会话工作区;模型凭证、审批和事件主要驻留内存。不要依赖刷新页面恢复聊天。 +- 图片外传、视频生成分开审批;服务端保留幂等键、回执和原 job ID,结果未知时停止重投。 +- 媒体访问有 HTTPS、DNS / 地址校验、大小边界和文件哈希检查。本地任务接口限制 loopback 和同源访问。 +- .env 文件、运行目录、依赖、构建产物、日志和一次性本机恢复脚本不进入 Git。 +- **不要把本地端口直接暴露到公网。** 目前没有生产级身份认证、租户隔离或分布式锁。 +- Pi 和扩展继承 Node 进程权限;工具白名单和路径校验不是操作系统沙箱。 -## 图片链路 - -Composer 支持选择、拖拽和粘贴 JPEG、PNG、WebP,默认单图最大 5 MiB、每条消息最多 4 张。上传采用 multipart 流,服务端同时校验文件名扩展、MIME、magic bytes、尺寸与像素数,再用 Sharp 自动旋转和重新编码。规范化文件仅保存在当前 Session 临时目录。 - -浏览器状态和 SSE 只保存 `attachmentId` 与安全元数据。真正调用 Pi 时,服务端才读取规范化图片并构造 `ImageContent[]`。如果显式模型是 text-only,发送图片会返回 `MODEL_DOES_NOT_SUPPORT_IMAGES`,不会静默丢图或切换模型。 - -## 课堂 Demo - -1. 打开页面并配置模型。 -2. 点击“填入演示任务”,得到:`请分析 demo-account 最近 7 天广告数据,并生成一份简短报告草稿。` -3. 可选:附加一张广告素材截图,并补充“请结合图片解释创意表现,但不要从图片猜测账户指标”。 -4. 点击发送。POST 会立即返回 202;中央区域流式显示回复,左右两栏显示 Skill 和事件。 -5. 观察 `mock_ads_metrics` 只读工具卡。数据来自 `fixtures/ads-account.json`,不是实时广告平台。 -6. `save_report_draft` 出现时选择 Approve 或 Deny。Approve 只会在当前 Session 工作区原子写入固定的 `report.md`;Deny 或超时不会创建文件。 -7. 删除或重置 Session 后,附件、报告和内存凭证一起清理。 - -完整 10–15 分钟讲解脚本见 [docs/classroom-demo.md](docs/classroom-demo.md)。 - -## Skill 渐进披露 - -启动时 `SkillCatalog` 使用 Pi `DefaultResourceLoader` 发现 Skills,并向页面只展示名称、描述、来源、安全显示路径和 diagnostics。Skill 正文不会塞入初始前端状态。 - -当 Pi 的 `read` 工具成功读取一个已索引的规范化 `SKILL.md` 路径时,Harness 推导 `skill.requested` 和 `skill.loaded`。它们不是 Pi 原生事件:requested 表示已识别目标 Skill,loaded 只在 read 成功后发出。示例 Skill 要求在分析任何账户指标前调用 `mock_ads_metrics`。 - -## Approval 与安全边界 - -- 工具策略:`read`、`grep`、`find`、`ls`、`mock_ads_metrics` 允许;`save_report_draft` 必须审批;其余工具拒绝。 -- 审批状态和超时由服务器 `ApprovalBroker` 决定。重复或冲突决策被拒绝。 -- Session 工作区经过 realpath、路径包含关系、符号链接和保护路径检查;报告只能写固定文件。 -- Base URL 做协议、userinfo、fragment、端口、DNS、私网、loopback、link-local 和云 metadata 检查。 -- 上传图片不作为静态资源公开,也不把 base64 放入事件。 - -Pi 没有内置 Sandbox,Project Trust 也不是 Sandbox。Pi 和 Extension 继承当前 Node 进程的操作系统权限。本项目的路径校验、工具 allowlist 和审批是应用层约束,不能替代容器、VM、系统用户或网络隔离。完整说明见 [docs/security-boundaries.md](docs/security-boundaries.md)。 - -## 验证 +## 开发与验证 ```bash pnpm lint @@ -138,20 +166,23 @@ pnpm test pnpm build ``` -最近一次完整测试包含 123 条测试:shared 13、harness 68、server 17、web 25。默认测试使用可编程 Fake AgentSession,不需要真实 API Key、真实模型或外网。最终交付前还会重新运行以上四个命令并以实际结果为准。 +2026-09-16 的验收基线为 **236 项测试**:shared 13、harness 152、server 25、web 46。默认测试使用隔离的临时数据与模拟远端,不提交真实视频任务;部分媒体检查会调用本机 FFmpeg。 -## 当前依赖与 API 核对 +scripts/verify-*.mjs 是可选的真实环境验证工具,部分会访问模型、上传素材或生成视频,可能产生费用。先阅读脚本并核对目标和副作用,不要作为默认安装步骤。start-private-server.mjs 是当前本机联调启动器,含特定网关和模型配置;通用部署请用环境变量和上面的启动方式。 -- TypeScript 5.9.3,typescript-eslint 8.69。 -- Pi Coding Agent SDK / Pi AI 0.85.1,当前公开 API 使用 `ModelRuntime.create`、`registerProvider`、`InMemoryCredentialStore`、`setRuntimeApiKey`、`createAgentSession`、`DefaultResourceLoader` 与 `defineTool`。 -- Pi 0.85.1 的 schema 包是 `typebox` 1.3.7,不是 `@sinclair/typebox`。 -- assistant-ui React 0.15.18,外部 Harness 状态通过 `useExternalStoreRuntime` 映射,附件通过自定义 `AttachmentAdapter` 上传。 -- React 19.2.8、Fastify 5.12.3、Vite 8.2.2、Zod 4.5.4、Sharp 0.35.4。 +## 已知限制与验收口径 -实现只使用安装包导出的公开类型;没有修改用户目录下的 Pi 配置,也没有依赖已废弃的 Runtime 示例。 +- SW Ads 上传曾返回 HTTP 500;原猫图经受控恢复后入库成功,但历史 500 的服务端根因尚未确认。 +- 既往 2 秒、10 秒样片测试有过人工恢复,不应描述成全程无人干预的稳定性验证。 +- 猫图已验证网页接续成功,生成仍需单独审批;接续通过不等于已经生成视频。 +- 模型、网络、共享 GPU 排队、存储和第三方接口都会影响完成时间。重启后能查询任务,不代表旧 Agent 运行能自动重放。 +- 当前不是完整 CMS 自动剪辑产品;画面质量、连续性和声音仍需检查。 +- 尚无多用户鉴权、跨设备同步、分布式任务调度和完整生产监控。 -## 已知限制与生产化 +## 文档 -课堂版是单进程、单实例、内存 EventStore / ApprovalBroker / ModelConnectionStore;进程退出不会恢复运行,报告和附件只是本机临时文件。浏览器直接刷新会丢失旧 Session 的 UI 引用,旧 Session 要到 Server 重启时统一清理,因此正常操作应使用页面“重置训练会话”。它没有用户身份、跨设备同步、分布式事件总线或操作系统级隔离。 - -生产化需要 Redis/Postgres 持久化、对象存储、KMS/Secret Manager、身份鉴权、授权与租户隔离、CSRF 和速率限制、容器或 microVM Sandbox、出站网络策略、不可篡改审计日志、分布式锁与可重放 SSE。路线见 [docs/production-roadmap.md](docs/production-roadmap.md)。 +- [架构](docs/architecture.md) · [事件映射](docs/event-mapping.md) · [安全边界](docs/security-boundaries.md) +- [课堂演示](docs/classroom-demo.md) · [生产化路线](docs/production-roadmap.md) +- [图生视频说明](docs/image-to-video-web.md) · [验收清单](docs/image-to-video-acceptance.md) +- [任务接续与猫图恢复记录](docs/image-to-video-cat-recovery.md) +- [Drama Minis 报告](docs/drama-minis-web.md) · [CMS 接入](docs/editor-cms-web.md) diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index db01930..304b38e 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -1,4 +1,6 @@ import type { + ImageVideoService, + CmsConnection, ApprovalBroker, AttachmentStore, EventStore, @@ -18,6 +20,10 @@ import { registerEventRoute } from "./routes/events.js"; import { registerModelRoutes } from "./routes/models.js"; import { registerSessionRoutes } from "./routes/sessions.js"; import { registerSkillsRoute } from "./routes/skills.js"; +import { registerReportRoutes } from "./routes/reports.js"; +import { registerCmsRoutes } from "./routes/cms.js"; +import { registerEditorRoutes } from "./routes/editor.js"; +import { registerImageVideoRoutes } from "./routes/image-video.js"; import { SseRegistry, type SseRegistryPort } from "./sse/sse-registry.js"; export type ApiSession = SessionRecord & { @@ -27,6 +33,8 @@ export type ApiSession = SessionRecord & { }; export type AppDependencies = { + imageVideo?: ImageVideoService; + imageVideoPorts?: number[]; sessionRegistry: { create(): Promise; get(sessionId: string): ApiSession | undefined; @@ -38,6 +46,10 @@ export type AppDependencies = { attachmentStore: Pick; approvalBroker: Pick; eventStore?: EventStore; + readReport?: (sessionId: string, reportId: string, format: string) => Promise<{ bytes: Buffer; contentType: string }>; + readEditorFrame?: (sessionId: string, jobId: string, frameId: string) => Promise; + editorForSession?: (sessionId:string)=>{workflowView:(jobId:string)=>unknown;review:(jobId:string,stage:"selection"|"plan",value:unknown)=>Promise;video:(jobId:string,kind:"sources"|"plans",id:string)=>Promise<{path:string;size:number}>}; + cmsForSession?: (sessionId: string) => CmsConnection; sseRegistry?: SseRegistryPort; testModelConnection?: ( resolved: ResolvedModelConnection, @@ -57,6 +69,10 @@ export function buildApp(dependencies: AppDependencies) { installErrorHandler(app); registerHealthRoute(app); registerSkillsRoute(app, effectiveDependencies); + registerReportRoutes(app, effectiveDependencies); + registerCmsRoutes(app, effectiveDependencies); + registerEditorRoutes(app, effectiveDependencies); + registerImageVideoRoutes(app, effectiveDependencies); registerSessionRoutes(app, effectiveDependencies); registerModelRoutes(app, effectiveDependencies); registerAttachmentRoutes(app, effectiveDependencies); diff --git a/apps/server/src/errors/error-handler.ts b/apps/server/src/errors/error-handler.ts index 46623f5..9f5b70b 100644 --- a/apps/server/src/errors/error-handler.ts +++ b/apps/server/src/errors/error-handler.ts @@ -47,6 +47,8 @@ function mapError(error: FastifyError | Error): { } function statusForHarnessCode(code: string): number { + if (code === "CMS_ORIGIN_DENIED" || code === "CMS_ADMIN_REQUIRED") return 403; + if (code === "CMS_RATE_LIMITED") return 429; if (code.includes("NOT_FOUND") || code === "SESSION_NOT_FOUND") return 404; if (code === "SESSION_BUSY" || code.includes("ALREADY_RESOLVED") || code.includes("DUPLICATE")) return 409; if (code === "IMAGE_TOO_LARGE") return 413; diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index bea94e0..5726295 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -1,9 +1,26 @@ import { mkdir } from "node:fs/promises"; -import { dirname, join, resolve } from "node:path"; +import { delimiter, dirname, join, resolve } from "node:path"; +import { homedir } from "node:os"; import { fileURLToPath, pathToFileURL } from "node:url"; import { + ImageVideoService, + ImageVideoMcpRemote, + createImageVideoCollector, + createImageVideoTools, + IMAGE_VIDEO_RESOURCES, FileAttachmentStore, + CmsConnection, + EditorService, + SkillRunner, + createMediaTools, + createEditorTools, + EDITOR_RESOURCES, HarnessSession, + HarnessError, + MinisReportService, + SwadsMcpClient, + createSwadsTools, + createMinisReportTool, InMemoryApprovalBroker, InMemoryEventStore, InMemoryModelConnectionStore, @@ -29,12 +46,21 @@ export async function buildDefaultApp(environment: NodeJS.ProcessEnv = process.e await mkdir(agentDir, { recursive: true, mode: 0o700 }); const eventStore = new InMemoryEventStore(); + const dramaSkillRoot = resolve(environment.DRAMA_MINIS_SKILL_DIR ?? join(homedir(), ".codex", "skills", "drama-minis-diagnosis")); + const editorSkillRoot = resolve(environment.DRAMA_EDITOR_SKILL_DIR ?? join(homedir(), ".codex", "skills", "swads-short-drama-editor")); + const imageVideoSkillRoot = resolve(environment.IMAGE_VIDEO_SKILL_DIR ?? join(homedir(), ".codex", "skills", "swads-image-to-video")); + const imageVideoRemote = new ImageVideoMcpRemote({ token: environment.SWADS_MCP_TOKEN ?? "", receiptRoot: join(agentDir, "image-video") }); + const cmsConnections = new Map(); + const editors = new Map(); + const swads = new SwadsMcpClient({ token: environment.SWADS_MCP_TOKEN ?? "" }); + const reports = new WeakMap(); const skillCatalog = new SkillCatalog({ cwd: projectRoot, agentDir, - ...(environment.AGENT_SKILLS_DIRS === undefined - ? {} - : { extraDirsValue: environment.AGENT_SKILLS_DIRS }), + extraDirsValue: [dramaSkillRoot, editorSkillRoot, imageVideoSkillRoot, environment.AGENT_SKILLS_DIRS].filter(Boolean).join(delimiter), + resources: { [join(dramaSkillRoot, "SKILL.md")]: [ + "references/metrics.md", "references/reporting-playbook.md", "assets/report-theme.css", "scripts/generate_report.py", "examples/sample-report.json", "examples/weekly-report-example.json", + ], [join(editorSkillRoot, "SKILL.md")]: EDITOR_RESOURCES, [join(imageVideoSkillRoot, "SKILL.md")]: IMAGE_VIDEO_RESOURCES }, }); await skillCatalog.reload(); const environmentConnection = await readEnvironmentConnection(environment, config.allowPrivateLlmBaseUrls); @@ -55,11 +81,17 @@ export async function buildDefaultApp(environment: NodeJS.ProcessEnv = process.e maxBytes: config.maxImageBytes, maxAttachmentsPerMessage: config.maxImagesPerMessage, }); + const imageVideo = new ImageVideoService({ root: join(agentDir, "image-video"), attachments: attachmentStore, remote: imageVideoRemote, collect: createImageVideoCollector({ skillRoot: imageVideoSkillRoot, python: environment.PYTHON_EXECUTABLE ?? join(homedir(), ".cache/codex-runtimes/codex-primary-runtime/dependencies/python/bin/python3") }) }); const registry = new InMemorySessionRegistry({ sessionsRoot, createSession: ({ sessionId, workspaceRoot }) => { + const cms = new CmsConnection({ publishableKey: environment.CMS_SUPABASE_PUBLISHABLE_KEY ?? "" }); + cmsConnections.set(sessionId, cms); + const editor = new EditorService({ cms, sessionId, runtimeRoot: join(projectRoot, ".agent-studio-runtime", "videos"),runner:new SkillRunner({skillRoot:editorSkillRoot,pythonExecutable:environment.PYTHON_EXECUTABLE??join(homedir(),".cache/codex-runtimes/codex-primary-runtime/dependencies/python/bin/python3")}) }); + editors.set(sessionId, editor); + let sessionSkillIndex = skillCatalog.index(); eventStore.append(sessionId, { type: "session.started", payload: { cwd: "." } }); - return new HarnessSession({ + const session = new HarnessSession({ sessionId, workspaceRoot, eventStore, @@ -71,31 +103,86 @@ export async function buildDefaultApp(environment: NodeJS.ProcessEnv = process.e sessionId, runId, eventStore, - skillIndex: skillCatalog.index(), + skillIndex: sessionSkillIndex, }), createAgentPort: async (input) => { const workspace = await SafeWorkspace.create(workspaceRoot); + const snapshot = await skillCatalog.createSessionSnapshot(workspace.root); + sessionSkillIndex = snapshot.index; + const drama = snapshot.skills.find((skill) => skill.name === "drama-minis-diagnosis"); + let reportService = reports.get(session); + if (drama !== undefined && reportService === undefined) { + reportService = new MinisReportService({ workspace, sessionId, skillRoot: drama.baseDir, + ...(environment.PYTHON_EXECUTABLE === undefined ? {} : { pythonExecutable: environment.PYTHON_EXECUTABLE }), + ...(environment.CHROME_EXECUTABLE === undefined ? {} : { chromeExecutable: environment.CHROME_EXECUTABLE }), + }); + reports.set(session, reportService); + } + const service = reportService; return createPiAgentSessionPortFactory({ agentDir, + skills: snapshot.skills, + appendSystemPrompt: ["用户选择 Skill 时必须先用 read 读取对应 SKILL.md。Drama Minis 任务必须按该 Skill 执行,并按相对路径读取 references。不要把 Skill 全文重复给用户。swads_whoami 和 metrics_catalog 是真实只读 MCP 工具;metrics_semantic_query 必须经用户确认周期、对比周期和范围,并在页面批准后才执行。不得用 mock_ads_metrics 分析真实 Minis。生成日报/周报必须用 generate_minis_report,使用本次查询返回的 sourceQueryId 和新的 JSON,不能使用示例数字。此受控工具代替 shell 运行 Python,提供 HTML、A4 PDF、JSON 下载。只在工具成功后声称文件已生成。缺少配置或数据时明确报错,不编造数据。"], approvalBroker, customTools: [ + ...(snapshot.skills.some(skill => skill.name === "swads-image-to-video") ? createImageVideoTools(imageVideo, sessionId) : []), + ...(snapshot.skills.some((skill) => skill.name === "swads-short-drama-editor") ? [ + ...createEditorTools(cms,()=>({rendering:true,vision:modelStore.resolveConnection(sessionId).connection.supportsImages,humanReview:true})), + ...createMediaTools(editor, () => modelStore.resolveConnection(sessionId).connection.supportsImages), + ] : []), createMockAdsMetricsTool(join(projectRoot, "fixtures", "ads-account.json")), createSaveReportDraftTool(workspace), + ...(service === undefined ? [] : [ + ...createSwadsTools(swads, (args, result) => service.recordQuery(args, result)), + createMinisReportTool(service), + ]), ], })(input); }, }); + return session; }, }); - return buildApp({ + const app = buildApp({ + imageVideo, + imageVideoPorts: [config.port, 5173, 4173], + editorForSession:(sessionId)=>{const editor=editors.get(sessionId);if(!editor)throw new HarnessError("SESSION_NOT_FOUND","Session not found");return editor;}, + readEditorFrame: (sessionId, jobId, frameId) => { + const editor = editors.get(sessionId); + if (!editor) throw new HarnessError("FRAME_NOT_FOUND", "Frame not found in this Session"); + return editor.readFrame(jobId, frameId); + }, + cmsForSession: (sessionId) => { + const cms = cmsConnections.get(sessionId); + if (cms === undefined) throw new HarnessError("SESSION_NOT_FOUND", "Session not found"); + return cms; + }, sessionRegistry: registry, skillCatalog, modelStore, attachmentStore, approvalBroker, eventStore, + readReport: async (sessionId, reportId, format) => { + const session = registry.get(sessionId); + const service = session === undefined ? undefined : reports.get(session); + if (service === undefined) throw new HarnessError("REPORT_NOT_FOUND", "Report not found in this Session"); + return service.read(reportId, format); + }, }); + app.addHook("onClose", () => swads.close()); + app.addHook("onClose", () => imageVideoRemote.close()); + app.addHook("onClose", () => { editors.clear(); }); + app.addHook("onClose", () => { for (const cms of cmsConnections.values()) cms.clear(); cmsConnections.clear(); }); + app.addHook("onResponse", (request, _reply, done) => { + if (request.method === "DELETE" && /^\/api\/sessions\/ses_[a-zA-Z0-9_-]+$/.test(request.url)) { + const id = request.url.split("/").at(-1) ?? ""; + if (registry.get(id) === undefined) { cmsConnections.get(id)?.clear(); cmsConnections.delete(id); editors.delete(id); } + } + done(); + }); + return app; } async function readEnvironmentConnection( @@ -122,6 +209,7 @@ async function readEnvironmentConnection( modelId, api, supportsImages: environment.AGENT_MODEL_SUPPORTS_IMAGES !== "false", + ...(environment.AGENT_MODEL_MAX_TOKENS === undefined ? {} : { maxTokens: Number(environment.AGENT_MODEL_MAX_TOKENS) }), ...(baseUrl === undefined ? {} : { baseUrl }), ...(apiKey === undefined || apiKey === "" ? {} : { apiKey }), }; diff --git a/apps/server/src/routes/cms.ts b/apps/server/src/routes/cms.ts new file mode 100644 index 0000000..37e63fb --- /dev/null +++ b/apps/server/src/routes/cms.ts @@ -0,0 +1,58 @@ +import { HarnessError, type CmsConnection } from "@agent-studio/harness"; +import type { FastifyInstance, FastifyRequest } from "fastify"; +import { z } from "zod"; +import type { AppDependencies } from "../app.js"; +import { requireSession } from "./sessions.js"; + +const Params = z.object({ sessionId: z.string().regex(/^ses_[a-zA-Z0-9_-]+$/) }); +const Login = z.object({ email: z.string().email().max(254), password: z.string().min(1).max(1024) }).strict(); + +export function registerCmsRoutes(app: FastifyInstance, dependencies: AppDependencies): void { + const attempts = new Map(); + const connection = (request: FastifyRequest, mutate = false): CmsConnection => { + const { sessionId } = Params.parse(request.params); + const session = requireSession(dependencies, sessionId); + if (mutate && session.isBusy === true) throw new HarnessError("SESSION_BUSY", "运行期间不能修改 CMS 连接"); + if (dependencies.cmsForSession === undefined) throw new HarnessError("CMS_NOT_CONFIGURED", "CMS 连接不可用"); + return dependencies.cmsForSession(sessionId); + }; + const guard = (request: FastifyRequest): void => { + const origin = request.headers.origin; + if (request.headers["sec-fetch-site"] === "cross-site" || (origin !== undefined && origin !== `http://${request.headers.host ?? ""}`)) { + throw new HarnessError("CMS_ORIGIN_DENIED", "CMS 请求必须来自当前本机页面"); + } + }; + app.get("/api/sessions/:sessionId/cms/connection", (request, reply) => { + guard(request); + void reply.header("Cache-Control", "no-store"); + return { connection: connection(request).status() }; + }); + app.post("/api/sessions/:sessionId/cms/connection", async (request, reply) => { + guard(request); + void reply.header("Cache-Control", "no-store"); + if (!request.headers["content-type"]?.startsWith("application/json")) throw new HarnessError("CMS_INVALID_CONTENT_TYPE", "CMS login requires JSON"); + const cms = connection(request, true); + const input = Login.parse(request.body); + const key = request.ip; + const now = Date.now(); + for (const [id, value] of attempts) if (value.until < now) attempts.delete(id); + const record = attempts.get(key) ?? { count: 0, until: now + 60_000 }; + if (record.count >= 5) throw new HarnessError("CMS_RATE_LIMITED", "登录尝试过多,请一分钟后再试"); + record.count++; attempts.set(key, record); + try { return { connection: await cms.login(input.email, input.password) }; } + finally { input.password = ""; } + }); + app.delete("/api/sessions/:sessionId/cms/connection", (request, reply) => { + guard(request); + void reply.header("Cache-Control", "no-store"); + const cms = connection(request, true); + cms.clear(); + return { connection: cms.status() }; + }); + app.get("/api/sessions/:sessionId/cms/dramas", async (request, reply) => { + guard(request); + void reply.header("Cache-Control", "no-store"); + const query = z.object({ q: z.string().max(120).default(""), offset: z.coerce.number().int().min(0).max(10_000).default(0) }).parse(request.query); + return connection(request).listDramas(query.q, query.offset); + }); +} diff --git a/apps/server/src/routes/editor.ts b/apps/server/src/routes/editor.ts new file mode 100644 index 0000000..796c46d --- /dev/null +++ b/apps/server/src/routes/editor.ts @@ -0,0 +1,48 @@ +import { HarnessError } from "@agent-studio/harness"; +import type { FastifyInstance,FastifyRequest } from "fastify"; +import {createReadStream} from "node:fs"; +import { z } from "zod"; +import type { AppDependencies } from "../app.js"; +import { requireSession } from "./sessions.js"; + +const Params = z.object({ sessionId: z.string().regex(/^ses_[\w-]+$/), jobId: z.string().regex(/^job_[\w-]+$/), frameId: z.string().regex(/^frame_[\w-]+$/) }).strict(); +export function registerEditorRoutes(app: FastifyInstance, dependencies: AppDependencies): void { + const context=(request:FastifyRequest)=>{ + const params=z.object({sessionId:z.string().regex(/^ses_[\w-]+$/),jobId:z.string().regex(/^job_[\w-]+$/)}).parse(request.params); + requireSession(dependencies,params.sessionId); + if(request.headers["sec-fetch-site"]==="cross-site"||(request.headers.origin!==undefined&&request.headers.origin!==`http://${request.headers.host??""}`))throw new HarnessError("CMS_ORIGIN_DENIED","媒体和审核仅限当前本机页面"); + if(!dependencies.editorForSession)throw new HarnessError("EDITOR_JOB_NOT_FOUND","Editor unavailable"); + return {...params,editor:dependencies.editorForSession(params.sessionId)}; + }; + app.get("/api/sessions/:sessionId/editor/:jobId/workflow",(request,reply)=>{const ctx=context(request);void reply.header("Cache-Control","no-store");return ctx.editor.workflowView(ctx.jobId);}); + app.post("/api/sessions/:sessionId/editor/:jobId/review/:stage",async(request,reply)=>{ + const ctx=context(request);const {stage}=z.object({stage:z.enum(["selection","plan"])}).parse(request.params); + if(!request.headers["content-type"]?.startsWith("application/json"))throw new HarnessError("CMS_INVALID_CONTENT_TYPE","Review requires JSON"); + void reply.header("Cache-Control","no-store");return ctx.editor.review(ctx.jobId,stage,request.body); + }); + for(const kind of ["sources","plans"] as const){ + app.get(`/api/sessions/:sessionId/editor/:jobId/${kind}/:mediaId${kind==="plans"?"/preview":""}`,async(request,reply)=>{ + const ctx=context(request);const {mediaId}=z.object({mediaId:z.string().regex(/^[\w-]{1,100}$/)}).parse(request.params); + const media=await ctx.editor.video(ctx.jobId,kind,mediaId); + void reply.header("Cache-Control","no-store").header("X-Content-Type-Options","nosniff").header("Accept-Ranges","bytes").type("video/mp4"); + const query=z.object({download:z.enum(["0","1"]).optional()}).parse(request.query); + if(query.download==="1")void reply.header("Content-Disposition",`attachment; filename="${kind==="plans"?"REVIEW-preview":"licensed-source"}.mp4"`); + const range=request.headers.range; + if(range!==undefined){ + const match=/^bytes=(\d*)-(\d*)$/.exec(range);let start=0;let end=media.size-1; + if(match?.[1]){start=Number(match[1]);if(match[2])end=Math.min(Number(match[2]),end);} + else if(match?.[2]){start=Math.max(0,media.size-Number(match[2]));} + if(!match||(!match[1]&&!match[2])||!Number.isSafeInteger(start)||!Number.isSafeInteger(end)||start>end||start>=media.size||end<0){return reply.code(416).header("Content-Range",`bytes */${String(media.size)}`).send();} + return reply.code(206).header("Content-Range",`bytes ${String(start)}-${String(end)}/${String(media.size)}`).header("Content-Length",end-start+1).send(createReadStream(media.path,{start,end})); + } + return reply.header("Content-Length",media.size).send(createReadStream(media.path)); + }); + } + app.get("/api/sessions/:sessionId/editor/:jobId/frames/:frameId", async (request, reply) => { + const { sessionId, jobId, frameId } = Params.parse(request.params); + requireSession(dependencies, sessionId); + if (!dependencies.readEditorFrame) throw new HarnessError("FRAME_NOT_FOUND", "Frame not found"); + const bytes = await dependencies.readEditorFrame(sessionId, jobId, frameId); + return reply.header("Cache-Control", "no-store").header("X-Content-Type-Options", "nosniff").type("image/jpeg").send(bytes); + }); +} diff --git a/apps/server/src/routes/image-video.ts b/apps/server/src/routes/image-video.ts index 04ad844..6927c12 100644 --- a/apps/server/src/routes/image-video.ts +++ b/apps/server/src/routes/image-video.ts @@ -30,6 +30,19 @@ export function registerImageVideoRoutes(app: FastifyInstance, dependencies: App if (!request.headers["content-type"]?.startsWith("application/json")) throw new HarnessError("I2V_JSON_REQUIRED", "Draft creation requires JSON"); void reply.header("Cache-Control", "no-store"); return { task: await service.create(sessionId, ImageVideoDraftSchema.parse(request.body)) }; }); + app.post("/api/sessions/:sessionId/image-video/tasks/:taskId/continue", async (request, reply) => { + const service = context(request); + const { sessionId, taskId } = z.object({ sessionId: z.string().regex(/^ses_[\w-]+$/), taskId: taskIdSchema }).parse(request.params); + const session = requireSession(dependencies, sessionId); + if (session.isBusy) throw new HarnessError("SESSION_BUSY", "请等待当前会话结束再接续任务。"); + if (!request.headers["content-type"]?.startsWith("application/json")) throw new HarnessError("I2V_JSON_REQUIRED", "Continuation requires JSON"); + const { expectedRevision } = z.object({ confirmed: z.literal(true), expectedRevision: z.number().int().nonnegative() }).strict().parse(request.body); + void reply.header("Cache-Control", "no-store"); + return { task: await service.continueTask(sessionId, taskId, expectedRevision, owner => dependencies.sessionRegistry.get(owner) !== undefined, () => { + const current = requireSession(dependencies, sessionId); + if (current !== session || current.isBusy) throw new HarnessError("SESSION_BUSY", "会话状态已改变,请重新确认接续。"); + }) }; + }); for (const kind of ["reference", "video"] as const) app.get(`/api/image-video/tasks/:taskId/${kind}`, async (request, reply) => { const service = context(request); const { taskId } = z.object({ taskId: taskIdSchema }).parse(request.params); const query = z.object({ download: z.literal("1").optional() }).strict().parse(request.query); diff --git a/apps/server/src/routes/reports.ts b/apps/server/src/routes/reports.ts new file mode 100644 index 0000000..f39b3d6 --- /dev/null +++ b/apps/server/src/routes/reports.ts @@ -0,0 +1,20 @@ +import { HarnessError } from "@agent-studio/harness"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import type { AppDependencies } from "../app.js"; +import { requireSession } from "./sessions.js"; + +const Params = z.object({ sessionId: z.string().regex(/^ses_[\w-]+$/), reportId: z.string().regex(/^rpt_[\w-]+$/), format: z.enum(["html", "pdf", "json"]) }).strict(); + +export function registerReportRoutes(app: FastifyInstance, dependencies: AppDependencies): void { + app.get("/api/sessions/:sessionId/reports/:reportId/:format", async (request, reply) => { + const { sessionId, reportId, format } = Params.parse(request.params); + requireSession(dependencies, sessionId); + if (dependencies.readReport === undefined) throw new HarnessError("REPORT_NOT_FOUND", "Report not found"); + const file = await dependencies.readReport(sessionId, reportId, format); + return reply.header("Cache-Control", "no-store").header("X-Content-Type-Options", "nosniff") + .header("Content-Security-Policy", "sandbox allow-scripts; default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src data:") + .header("Content-Disposition", `attachment; filename="${reportId}.${format}"`) + .type(file.contentType).send(file.bytes); + }); +} diff --git a/apps/server/tests/cms-api.test.ts b/apps/server/tests/cms-api.test.ts new file mode 100644 index 0000000..c1a782d --- /dev/null +++ b/apps/server/tests/cms-api.test.ts @@ -0,0 +1,21 @@ +import { CmsConnection } from "@agent-studio/harness"; +import { describe, expect, it } from "vitest"; +import { createTestApp } from "./test-app.js"; + +describe("CMS API safety", () => { + it("exposes only redacted connection status and rejects cross-origin login before contacting CMS", async () => { + const cms = new CmsConnection({ publishableKey: "sb_publishable_test", fetch: () => Promise.reject(new Error("Must not reach CMS")) }); + const { app } = createTestApp({ cmsForSession: () => cms }); + try { + const status = await app.inject({ method: "GET", url: "/api/sessions/ses_1/cms/connection" }); + expect(status.statusCode).toBe(200); + expect(status.json()).toMatchObject({ connection: { connected: false, configured: true } }); + expect(status.headers["cache-control"]).toBe("no-store"); + const denied = await app.inject({ method: "POST", url: "/api/sessions/ses_1/cms/connection", headers: { origin: "https://attacker.example", "sec-fetch-site": "cross-site" }, payload: { email: "fixture@example.com", password: "never-expose" } }); + expect(denied.statusCode).toBe(403); + expect(denied.body).not.toContain("never-expose"); + expect((await app.inject({ method: "GET", url: "/api/sessions/ses_missing/cms/connection" })).statusCode).toBe(404); + expect((await app.inject({ method: "GET", url: "/api/sessions/ses_1/cms/dramas" })).json()).toMatchObject({ error: { code: "CMS_LOGIN_REQUIRED" } }); + } finally { await app.close(); } + }); +}); diff --git a/apps/server/tests/editor-api.test.ts b/apps/server/tests/editor-api.test.ts new file mode 100644 index 0000000..13d8254 --- /dev/null +++ b/apps/server/tests/editor-api.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { HarnessError } from "@agent-studio/harness"; +import { createTestApp } from "./test-app.js"; +import {mkdtemp,writeFile,rm} from "node:fs/promises"; +import {tmpdir} from "node:os"; +import {join} from "node:path"; + +describe("private editor frames", () => { + it("only serves a registered Session frame and forbids caching", async () => { + const { app } = createTestApp({ readEditorFrame: (sessionId, jobId, frameId) => { + if (sessionId !== "ses_1" || jobId !== "job_known" || frameId !== "frame_known") throw new HarnessError("FRAME_NOT_FOUND", "Missing frame"); + return Promise.resolve(Buffer.from([255, 216, 255, 217])); + } }); + try { + const response = await app.inject({ method: "GET", url: "/api/sessions/ses_1/editor/job_known/frames/frame_known" }); + expect(response.statusCode).toBe(200); + expect(response.headers["content-type"]).toBe("image/jpeg"); + expect(response.headers["cache-control"]).toBe("no-store"); + expect(response.rawPayload).toEqual(Buffer.from([255, 216, 255, 217])); + expect((await app.inject({ method: "GET", url: "/api/sessions/ses_missing/editor/job_known/frames/frame_known" })).statusCode).toBe(404); + expect((await app.inject({ method: "GET", url: "/api/sessions/ses_1/editor/job_known/frames/secret.env" })).statusCode).toBe(400); + } finally { await app.close(); } + }); +}); + +it("streams only registered media ranges and keeps review mutations same-origin",async()=>{ + const root=await mkdtemp(join(tmpdir(),"editor-api-"));const path=join(root,"preview.mp4");await writeFile(path,Buffer.from("0123456789")); + const {app}=createTestApp({editorForSession:()=>({workflowView:()=>({jobId:"job_test"}),review:()=>Promise.resolve({saved:true}),video:()=>Promise.resolve({path,size:10})})}); + try{ + const range=await app.inject({method:"GET",url:"/api/sessions/ses_1/editor/job_test/plans/plan_test/preview",headers:{range:"bytes=2-5"}}); + expect(range.statusCode).toBe(206);expect(range.body).toBe("2345");expect(range.headers["content-range"]).toBe("bytes 2-5/10");expect(range.headers["content-type"]).toBe("video/mp4"); + expect((await app.inject({method:"GET",url:"/api/sessions/ses_1/editor/job_test/plans/plan_test/preview",headers:{range:"bytes=20-30"}})).statusCode).toBe(416); + expect((await app.inject({method:"GET",url:"/api/sessions/ses_missing/editor/job_test/plans/plan_test/preview"})).statusCode).toBe(404); + expect((await app.inject({method:"POST",url:"/api/sessions/ses_1/editor/job_test/review/selection",headers:{origin:"https://evil.example","content-type":"application/json"},payload:{}})).statusCode).toBeGreaterThanOrEqual(400); + expect((await app.inject({method:"POST",url:"/api/sessions/ses_1/editor/job_test/review/selection",payload:{}})).json()).toEqual({saved:true}); + }finally{await app.close();await rm(root,{recursive:true,force:true});} +}); diff --git a/apps/server/tests/image-video-api.test.ts b/apps/server/tests/image-video-api.test.ts index 77faeb8..6468e3f 100644 --- a/apps/server/tests/image-video-api.test.ts +++ b/apps/server/tests/image-video-api.test.ts @@ -13,10 +13,43 @@ async function fixture() { const image = await attachments.add("ses_1", { name: "pixel.png", mediaType: "image/png", stream: Readable.from(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAE0lEQVQImWP4z8DwnwGM/zMwAAAf7gP9qS/A4gAAAABJRU5ErkJggg==", "base64")) }); const service = new ImageVideoService({ root: join(root, "tasks"), attachments, remote: {} as never, collect: () => Promise.reject(new Error("No external operations permitted")) }); const dependencies = { imageVideo: service, sessionRegistry: { get: (id: string) => id === "ses_1" ? { sessionId: id } : undefined, disposeAll: async () => {} }, skillCatalog: { list: () => [] }, modelStore: {}, attachmentStore: attachments, approvalBroker: {} } as unknown as AppDependencies; - return { app: buildApp(dependencies), service, draft: { attachmentId: image.attachmentId, title: "Reference", prompt: "Natural motion and ambient sound", durationSeconds: 10 } }; + return { app: buildApp(dependencies), service, dependencies, draft: { attachmentId: image.attachmentId, title: "Reference", prompt: "Natural motion and ambient sound", durationSeconds: 10 } }; } describe("image video local API", () => { + it("does not persist a target session that disappeared during continuation", async () => { + const { app, service, dependencies, draft } = await fixture(); + const task = await service.create("ses_1", draft); + let lookups = 0; + dependencies.sessionRegistry.get = id => id === "ses_new" && lookups++ === 0 ? { sessionId: id } as never : undefined; + try { + const result = await app.inject({ method: "POST", url: `/api/sessions/ses_new/image-video/tasks/${task.id}/continue`, payload: { confirmed: true, expectedRevision: 0 } }); + expect(result.statusCode).toBe(404); + expect((await service.owned("ses_1", task.id)).revision).toBe(0); + } finally { await app.close(); } + }); + it("requires an explicit same-origin confirmation to continue an orphaned task in a live session", async () => { + const { app, service, dependencies, draft } = await fixture(); + const task = await service.create("ses_1", draft); + const liveSession = { sessionId: "ses_new" } as never; + dependencies.sessionRegistry.get = id => id === "ses_new" ? liveSession : undefined; + const url = `/api/sessions/ses_new/image-video/tasks/${task.id}/continue`; + const payload = { confirmed: true, expectedRevision: 0 }; + try { + expect((await app.inject({ method: "POST", url, payload: { expectedRevision: 0 } })).statusCode).toBe(400); + expect((await app.inject({ method: "POST", url, payload: { ...payload, confirmed: false } })).statusCode).toBe(400); + expect((await app.inject({ method: "POST", url, payload, headers: { origin: "https://evil.example" } })).statusCode).toBeGreaterThanOrEqual(400); + expect((await app.inject({ method: "POST", url, payload, headers: { "sec-fetch-site": "cross-site" } })).statusCode).toBeGreaterThanOrEqual(400); + expect((await app.inject({ method: "POST", url: url.replace("ses_new", "ses_unknown"), payload })).statusCode).toBe(404); + expect((await app.inject({ method: "POST", url, payload: { ...payload, prompt: "changed" } })).statusCode).toBe(400); + await expect(service.owned("ses_new", task.id)).rejects.toMatchObject({ code: "I2V_TASK_NOT_FOUND" }); + const result = await app.inject({ method: "POST", url, payload }); + expect(result.statusCode).toBe(200); + expect(result.json<{ task: ImageVideoPublicTask }>().task).toMatchObject({ id: task.id, state: "draft", revision: 1 }); + expect((await service.owned("ses_new", task.id)).prompt).toBe(draft.prompt); + expect((await service.list())).toHaveLength(1); + } finally { await app.close(); } + }); it("creates locally, persists history independently from Sessions and serves registered reference ranges", async () => { const { app, service, draft } = await fixture(); try { diff --git a/apps/server/tests/report-api.test.ts b/apps/server/tests/report-api.test.ts new file mode 100644 index 0000000..536d23b --- /dev/null +++ b/apps/server/tests/report-api.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { createTestApp } from "./test-app.js"; + +describe("report download API", () => { + it("requires a known Session and serves only a fixed report format with sandbox and no-store headers", async () => { + const { app } = createTestApp({ + readReport: (_session, _report, format) => Promise.resolve({ bytes: Buffer.from("

report

"), contentType: format === "html" ? "text/html" : "application/json" }), + }); + try { + const response = await app.inject({ method: "GET", url: "/api/sessions/ses_1/reports/rpt_test/html" }); + expect(response.statusCode).toBe(200); + expect(response.body).toContain("

report

"); + expect(response.headers["cache-control"]).toBe("no-store"); + expect(response.headers["content-security-policy"]).toContain("sandbox"); + expect(response.headers["content-disposition"]).toContain("attachment"); + expect((await app.inject({ method: "GET", url: "/api/sessions/ses_missing/reports/rpt_test/html" })).statusCode).toBe(404); + expect((await app.inject({ method: "GET", url: "/api/sessions/ses_1/reports/rpt_test/env" })).statusCode).toBe(400); + } finally { await app.close(); } + }); +}); diff --git a/apps/server/tests/test-app.ts b/apps/server/tests/test-app.ts index 0b7e09c..b86f5ad 100644 --- a/apps/server/tests/test-app.ts +++ b/apps/server/tests/test-app.ts @@ -1,9 +1,9 @@ import { HarnessError, InMemoryEventStore } from "@agent-studio/harness"; import type { FastifyInstance } from "fastify"; import { vi } from "vitest"; -import { buildApp } from "../src/app.js"; +import { buildApp, type AppDependencies } from "../src/app.js"; -export function createTestApp(): { +export function createTestApp(options: { editorForSession?:AppDependencies["editorForSession"]; readReport?: AppDependencies["readReport"]; readEditorFrame?: AppDependencies["readEditorFrame"]; cmsForSession?: AppDependencies["cmsForSession"] } = {}): { app: FastifyInstance; session: { startRun: ReturnType; cancelRun: ReturnType }; modelStore: { @@ -60,6 +60,10 @@ export function createTestApp(): { }; const eventStore = new InMemoryEventStore(); const app = buildApp({ + ...(options.editorForSession===undefined?{}:{editorForSession:options.editorForSession}), + ...(options.readEditorFrame === undefined ? {} : { readEditorFrame: options.readEditorFrame }), + ...(options.readReport === undefined ? {} : { readReport: options.readReport }), + ...(options.cmsForSession === undefined ? {} : { cmsForSession: options.cmsForSession }), sessionRegistry: registry, skillCatalog: { list: () => [{ name: "ads-analysis", description: "Analyze", source: "project", filePath: ".agents/skills/ads-analysis/SKILL.md", diagnostics: [] }] }, modelStore, diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 82beb5d..8401fb6 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -4,13 +4,15 @@ import type { ImageAttachment, ModelConnectionInput } from "@agent-studio/shared import { HarnessAttachmentAdapter } from "./assistant/attachment-adapter.js"; import { useHarnessRuntime, type HarnessRuntimeStore } from "./assistant/use-harness-runtime.js"; import { Thread } from "./components/chat/Thread.js"; -import { AppShell } from "./components/layout/AppShell.js"; +import { AppShell, type WorkspaceTab } from "./components/layout/AppShell.js"; import { SkillsPanel } from "./components/skills/SkillsPanel.js"; +import { CmsPanel } from "./components/cms/CmsPanel.js"; import { EventTimeline } from "./components/timeline/EventTimeline.js"; import { ApprovalDialog } from "./components/approvals/ApprovalDialog.js"; import { ModelSettings, type ModelView } from "./components/settings/ModelSettings.js"; import { CompletionAnnouncer } from "./components/common/CompletionAnnouncer.js"; import { HarnessClientStore } from "./store/harness-client-store.js"; +import { ImageVideoPanel } from "./components/tools/ImageVideoPanel.js"; type AppStore = HarnessRuntimeStore & { start(): Promise; @@ -35,6 +37,7 @@ export function App({ store: suppliedStore, autoStart = true, modelConfigured: s const [ownedStore] = useState(() => new HarnessClientStore()); const store = suppliedStore ?? ownedStore; const [settingsOpen, setSettingsOpen] = useState(false); + const [activeTab, setActiveTab] = useState("chat"); const snapshot = useSyncExternalStore( (subscriber) => store.subscribe(subscriber), () => store.getSnapshot(), @@ -82,6 +85,8 @@ export function App({ store: suppliedStore, autoStart = true, modelConfigured: s return ( undefined); }, }} - skills={} + skills={<> skill.name === "swads-image-to-video") ? { sessionId: snapshot.sessionId } : {})} onDraft={task => { runtime.thread.composer.setText(`请使用 swads-image-to-video,先读取完整 Skill 和 references。本地冻结任务已创建 taskId=${task.id},标题=${task.title},精确提示词=${task.prompt},目标=${String(task.durationSeconds)} 秒。不要重复创建任务,不使用 CMS。${task.state === "awaiting_submission" ? "参考图已上传入库,本任务已接续到当前会话;不得重新上传。先用 i2v_status 核对状态,再申请 i2v_submit 独立生成审批。" : "先申请 i2v_upload_reference 独立图片外传审批;参考图入库就绪后再申请 i2v_submit 独立生成审批。"}不得改变提示词或时长,结果未知禁止重投。`); setActiveTab("chat"); }} /> { runtime.thread.composer.setText( + name === "drama-minis-diagnosis" + ? "请使用 drama-minis-diagnosis Skill 生成 Drama Minis 日报。先读取 Skill,向我确认分析周期、对比周期和账户范围,确认前不要查询经营数据。" + : name === "swads-short-drama-editor" + ? "请使用 swads-short-drama-editor。先读取完整 Skill 和 CMS source contract,再检查 cms_connection_status,说明实际可用能力,并询问我要剪哪部剧。不要在聊天索要密码,不要在能力未就绪时声称视频已生成。" + : `请使用 ${name} Skill,先读取指令并告诉我需要补充哪些任务信息。`, + ); setActiveTab("chat"); }} />{snapshot.sessionId === undefined ? null : { + runtime.thread.composer.setText(`请使用 swads-short-drama-editor 分析这部授权剧:${drama.title},精确 drama_id=${drama.id}。先读取 Skill 和参考文件,获取完整目录前申请审批。不要在缺少画面/参考证据时生成诊断 PASS 或渲染视频。`); setActiveTab("chat"); + }} />}} chat={ {pendingApproval !== undefined ? ( store.approve?.(approvalId) ?? Promise.reject(new Error("Approval API unavailable"))} onDeny={(approvalId) => store.deny?.(approvalId) ?? Promise.reject(new Error("Approval API unavailable"))} diff --git a/apps/web/src/components/approvals/ApprovalDialog.tsx b/apps/web/src/components/approvals/ApprovalDialog.tsx index 1df6812..499423e 100644 --- a/apps/web/src/components/approvals/ApprovalDialog.tsx +++ b/apps/web/src/components/approvals/ApprovalDialog.tsx @@ -1,5 +1,6 @@ import { ShieldAlert } from "lucide-react"; import { useEffect, useRef, useState } from "react"; +import { ImageVideoApprovalDetails } from "../tools/ImageVideoPanel.js"; import type { ClientApproval } from "../../store/reducer.js"; export function ApprovalDialog({ request, onApprove, onDeny }: { @@ -10,6 +11,17 @@ export function ApprovalDialog({ request, onApprove, onDeny }: { const [submitting, setSubmitting] = useState(false); const approveRef = useRef(null); const pending = request.status === "pending"; + const query = request.toolName === "metrics_semantic_query"; + const minis = request.toolName === "generate_minis_report"; + const cms = ["cms_list_dramas", "cms_drama_inventory", "cms_read_subtitle"].includes(request.toolName); + const prepare = request.toolName === "prepare_drama_sources"; + const frames = request.toolName === "read_drama_frames"; + const render = request.toolName === "render_editor_preview"; + const i2vUpload = request.toolName === "i2v_upload_reference"; + const i2vSubmit = request.toolName === "i2v_submit"; + const i2v = i2vUpload || i2vSubmit; + const [taskReady, setTaskReady] = useState(false); + const [confirmed, setConfirmed] = useState(false); const [remaining, setRemaining] = useState(() => secondsRemaining(request.expiresAt)); useEffect(() => { @@ -21,7 +33,7 @@ export function ApprovalDialog({ request, onApprove, onDeny }: { }, [request.expiresAt]); const decide = async (decision: "approve" | "deny") => { - if (submitting || !pending) return; + if (submitting || !pending || (decision === "approve" && i2v && (!taskReady || !confirmed))) return; setSubmitting(true); try { await (decision === "approve" ? onApprove(request.approvalId) : onDeny(request.approvalId)); @@ -41,17 +53,19 @@ export function ApprovalDialog({ request, onApprove, onDeny }: { > SERVER APPROVAL -

允许写入报告草稿吗?

-

Agent 请求执行 {request.toolName}。批准后只会写入当前训练会话的固定报告文件。

+

{i2vUpload ? "允许将这张参考图上传到 SW Ads 吗?" : i2vSubmit ? "允许提交本次 H3 生成吗?" : render ? "允许生成 10 秒 REVIEW 预览吗?" : prepare ? "允许下载所选分集并在本机抽帧吗?" : frames ? "允许将字幕和代表帧发送给当前模型吗?" : cms ? "允许读取 CMS 内容并交给模型吗?" : query ? "允许查询真实经营数据吗?" : minis ? "允许生成 HTML / PDF 报告吗?" : "允许写入报告草稿吗?"}

+

Agent 请求执行 {request.toolName}。{i2vUpload ? "将本次冻结参考图发送到 SW Ads 存储,并创建 review_pending、未分配账户的参考素材记录。" : i2vSubmit ? "将下方冻结图片和提示词提交到共享 H3 队列。费用未知,目标时长支持尚待实际验证;不自动换模型或重投。" : render ? "仅在本机运行原 Skill 渲染和技术 QA,使用已审核版本的授权片源及原声,生成可播放下载的 REVIEW MP4。不会发布、上传或替代最终带声音审片。" : prepare ? "仅将所选分集下载到本机并抽帧,每次最多 5 集 / 1 GiB。不会修改 CMS;此步仅返回元数据,不向模型发送字幕或图片。" : frames ? "将此任务的英文字幕和每集 3 张代表帧发送给当前配置的模型网关。代表帧分析不能替代完整带声音审片。请核对任务范围。" : cms ? "本次剧集简介、目录或字幕可能包含未公开内容,将发送给当前配置的模型网关。请核对剧集和读取范围;不会修改 CMS。" : query ? "请核对日期、账户范围和指标。查询结果将发送给当前配置的模型网关进行分析,不会更改广告投放。" : "批准后只会写入当前训练会话的报告文件。"}

+ {i2v ? <> : null} + {i2v || query || minis || cms || prepare || frames || render ?
{minis ? "报告数据与来源" : "本次执行参数"}
{JSON.stringify(request.arguments, null, 2)}
: null}
风险
{riskLabel(request.riskLevel)}
-
目标
report.md
+
目标
{i2vUpload ? "本机参考图 → SW Ads 存储(待审核、未归属)" : i2vSubmit ? "本次参考图与提示词 → H3 ref2va 生成" : render ? "已审核片源 → 本机 REVIEW MP4" : prepare ? "CMS CDN → 本机媒体目录" : frames ? "本机授权素材 → 当前模型网关" : cms ? "SWAVE CMS → 当前模型网关" : query ? "swads MCP → 当前模型网关" : minis ? "report.html / report.pdf / report.json" : "report.md"}
审批 ID
{request.approvalId}
剩余时间
{remaining > 0 ? `${String(remaining)} 秒` : "等待服务器确认"}
- +

提交后等待服务器事件确认,重复点击不会重复执行。

diff --git a/apps/web/src/components/cms/CmsPanel.tsx b/apps/web/src/components/cms/CmsPanel.tsx new file mode 100644 index 0000000..8b685a6 --- /dev/null +++ b/apps/web/src/components/cms/CmsPanel.tsx @@ -0,0 +1,66 @@ +import { useState } from "react"; + +type Connection = { configured: boolean; connected: boolean; expiresAt?: string }; +type Drama = { id: string; title: string; language: string; total_episodes: number }; +type ApiResult = { connection?: Connection; dramas?: Drama[] }; + +export function CmsPanel({ sessionId, busy, onSelect }: { sessionId: string; busy: boolean; onSelect: (drama: Drama) => void }) { + const [open, setOpen] = useState(false); + const [connection, setConnection] = useState(); + const [pending, setPending] = useState(false); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [query, setQuery] = useState(""); + const [offset, setOffset] = useState(0); + const [dramas, setDramas] = useState([]); + const base = `/api/sessions/${encodeURIComponent(sessionId)}/cms`; + const request = async (path: string, method = "GET", body?: unknown): Promise => { + const response = await fetch(`${base}${path}`, { method, cache: "no-store", headers: { "Content-Type": "application/json" }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }); + if (!response.ok) { + if (path === "/connection" && method === "POST") throw new Error("CMS 登录失败:请检查账户、密码和管理员权限;若启用了验证码需先完成相应认证。"); + throw new Error("CMS 请求失败,请检查连接是否过期并重新登录。"); + } + return await response.json() as ApiResult; + }; + const act = async (operation: () => Promise) => { + if (pending) return; + setPending(true); setError(""); + try { await operation(); } catch (failure) { setError(failure instanceof Error ? failure.message : "CMS 请求失败"); } + finally { setPending(false); } + }; + const loadDramas = async (page: number) => { + const result = await request(`/dramas?q=${encodeURIComponent(query)}&offset=${String(page)}`); + setDramas(result.dramas ?? []); setOffset(page); + }; + return
+ + {open ?
+

使用 SWAVE 的 Supabase 管理员账户。密码通过本机后端发送至 shorts-sg 登录服务,不进入聊天或浏览器存储。

+

已提供选段审核和 10 秒 REVIEW 预览工具;需先授权素材分析、通过原始校验并核实参考。技术预览不等于最终审片通过。

+ {connection?.connected ? <> +

CMS 已连接(本次会话)

+ +
{ event.preventDefault(); void act(() => loadDramas(0)); }}> + + +
+
    {dramas.map((drama) =>
  • {drama.title}{drama.language} · {drama.total_episodes} 集
  • )}
+ {dramas.length || offset ?
: null} + :
{ + event.preventDefault(); if (busy || pending) return; + const enteredPassword = password; setPassword(""); + void act(async () => { setConnection((await request("/connection", "POST", { email, password: enteredPassword })).connection); }); + }}> + + + + {connection?.configured === false ?

后端尚未配置 CMS publishable key。

: null} +
} + {error ?

{error}

: null} +
: null} +
; +} diff --git a/apps/web/src/components/layout/AppShell.tsx b/apps/web/src/components/layout/AppShell.tsx index d3c9f9d..39dfda9 100644 --- a/apps/web/src/components/layout/AppShell.tsx +++ b/apps/web/src/components/layout/AppShell.tsx @@ -2,10 +2,9 @@ import { Activity, MessageSquare, PanelsTopLeft } from "lucide-react"; import { useEffect, useState, type KeyboardEvent, type ReactNode } from "react"; import { TopBar, type TopBarProps } from "../topbar/TopBar.js"; -type WorkspaceTab = "chat" | "skills" | "events"; +export type WorkspaceTab = "chat" | "skills" | "events"; -export function AppShell({ topBar, skills, chat, timeline }: { topBar: TopBarProps; skills: ReactNode; chat: ReactNode; timeline: ReactNode }) { - const [activeTab, setActiveTab] = useState("chat"); +export function AppShell({ topBar, skills, chat, timeline, activeTab, onTabChange: setActiveTab }: { topBar: TopBarProps; skills: ReactNode; chat: ReactNode; timeline: ReactNode; activeTab: WorkspaceTab; onTabChange: (tab: WorkspaceTab) => void }) { const [mobile, setMobile] = useState(false); useEffect(() => { diff --git a/apps/web/src/components/skills/SkillsPanel.tsx b/apps/web/src/components/skills/SkillsPanel.tsx index 8fc3723..5f56153 100644 --- a/apps/web/src/components/skills/SkillsPanel.tsx +++ b/apps/web/src/components/skills/SkillsPanel.tsx @@ -1,7 +1,7 @@ import { AlertCircle, BookOpen, Check, LoaderCircle } from "lucide-react"; import type { ClientSkill } from "../../store/reducer.js"; -export function SkillsPanel({ skills }: { skills: ClientSkill[] }) { +export function SkillsPanel({ skills, onUse, disabled = false }: { skills: ClientSkill[]; onUse?: (name: string) => void; disabled?: boolean }) { return (
@@ -24,6 +24,7 @@ export function SkillsPanel({ skills }: { skills: ClientSkill[] }) { {skill.activity === "loaded" ? : null}

{skill.description}

+ {onUse === undefined ? null : }
{skill.source}{skill.filePath}
{skill.diagnostics.map((diagnostic, index) => (
diff --git a/apps/web/src/components/tools/EditorWorkflowPanel.tsx b/apps/web/src/components/tools/EditorWorkflowPanel.tsx new file mode 100644 index 0000000..38c3c5d --- /dev/null +++ b/apps/web/src/components/tools/EditorWorkflowPanel.tsx @@ -0,0 +1,72 @@ +import {useState,type FormEvent} from "react"; + +type Workflow={jobId:string;selection:null|{hash:string;reviewed:boolean;segments:{id:string;episodeNo:number;start:number;end:number;reason:string;videoUrl:string;subtitles:string}[]};plan:null|{hash:string;reviewed:boolean;reference:Record;artifacts:Record;subtitles:string;segmentIds:string[]};preview:null|{url:string;duration:number;sha256:string;machinePass:boolean;checks:Record}}; +const urlPattern=/^\/api\/sessions\/ses_[\w-]+\/editor\/job_[\w-]+\/workflow$/; +export function EditorWorkflowPanel({url}:{url:string}){ + const [data,setData]=useState(null);const [busy,setBusy]=useState(false);const [message,setMessage]=useState(""); + if(!urlPattern.test(url))return null; + const base=url.slice(0,-"/workflow".length); + const allowedVideo=(value:string,kind:"sources"|"plans")=>typeof value==="string"&&value.startsWith(`${base}/${kind}/`)&&new RegExp(`^${kind}/[\\w-]+${kind==="plans"?"/preview":""}$`).test(value.slice(base.length+1)); + const request=async(target:string,body?:unknown)=>{ + setBusy(true);setMessage(""); + try{ + const response=await fetch(target,body===undefined?{cache:"no-store"}:{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)}); + if(!response.ok){const failure=await response.json() as {error?:{message?:unknown}};throw new Error(typeof failure.error?.message==="string"?failure.error.message:`请求未通过(${String(response.status)}),请刷新状态并核对审核记录。`);} + const next=await response.json() as Workflow; + if(typeof next.jobId!=="string"||!base.endsWith(`/editor/${next.jobId}`))throw new Error("媒体任务不匹配"); + setData(next);if(body!==undefined)setMessage("审核记录已保存。请在聊天中输入“继续”,让 Agent 执行下一步。"); + }catch(error){setMessage(error instanceof Error?error.message:"请求失败");}finally{setBusy(false);} + }; + const selected=data?.selection;const plan=data?.plan; + const stage=selected&&!selected.reviewed?"selection":plan&&!plan.reviewed?"plan":undefined; + const submit=(event:FormEvent)=>{ + event.preventDefault();if(!stage||!selected)return; + const fields=new FormData(event.currentTarget);const text=(name:string)=>{const value=fields.get(name);return typeof value==="string"?value:"";}; + const checked=(name:string)=>fields.get(name)==="on"; + const common={planHash:stage==="selection"?selected.hash:plan?.hash,reviewer:text("reviewer"),policyCheckedAt:new Date(text("policyCheckedAt")).toISOString(),pictureAudioReviewed:checked("pictureAudioReviewed"),noUnresolvedFindings:checked("noUnresolvedFindings")}; + const payload=stage==="selection"?{...common,policyPageUpdated:text("policyPageUpdated"),segmentEvidence:selected.segments.map(s=>({segmentId:s.id,pictureEvidence:text(`${s.id}-picture`),audioEvidence:text(`${s.id}-audio`),subtitleEvidence:text(`${s.id}-subtitle`)}))}:{...common,referenceReviewed:checked("referenceReviewed"),storyReviewed:checked("storyReviewed"),packagingReviewed:checked("packagingReviewed"),segmentEvidence:(plan?.segmentIds??[]).map(id=>({segmentId:id,observation:text(`${id}-observation`)}))}; + void request(`${base}/review/${stage}`,payload); + }; + return
+ + {message?

{message}

:null} + {data?<> +

分析已通过不等于成片。选段审核、方案审核和最终审片分别记录;不会上传或发布广告。

+ {selected?
候选源片段 · {selected.reviewed?"已有人工审核记录":"待带声音审核"} + {selected.segments.map(span=>
+

选段 {span.id} · 第 {span.episodeNo} 集 · {span.start}–{span.end} 秒

{span.reason}

+ {allowedVideo(span.videoUrl,"sources")?
)} +
:

等待 Agent 提交候选选段。

} + {plan?
剪辑方案 · {plan.reviewed?"已有人工审核记录":"待核对参考、故事与字幕"} +
本次参考证据与完整方案
{JSON.stringify({reference:plan.reference,artifacts:plan.artifacts,finalSubtitles:plan.subtitles},null,2)}
+
:null} + {stage&&selected?
+

请亲自观看完整选段并开启声音。下列记录由审核人填写,模型不能代勾。若内容不合适,不要确认,请在聊天中要求替换完整场景。

+ 核对 TikTok Adult Content 官方政策 + + + {stage==="selection"?:null} + + {stage==="plan"?<> + + + + :null} + + {(stage==="selection"?selected.segments.map(s=>s.id):plan?.segmentIds??[]).map(id=>
选段 {id} 审核证据(每项至少 20 字符) + {stage==="selection"?<>