initial open-source release

This commit is contained in:
minorQAQ committed 2026-08-01 11:15:19 +08:00
commit 01c4605498
224 files changed
+55124

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
src/agent/history/*.db
src/agent/history/sessions/
src/agent/history/cron/
src/agent/memory/chroma_rag_db/*
node_modules/
__pycache__/
package-lock.json
dist/
electron-builder-cache/
src/agent/config/env_config.json
src/agent/config/agent_config.json
llm_server_bac/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 minorQAQ
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+587
View File
@@ -0,0 +1,587 @@
<div align="center">
<img src="./git_image/标题.png" alt="minor Agent" width="1000">
### 一个可本地运行的多模态 Agent 桌面应用 —— 真正能"动手"操作你电脑的 AI 助手
基于 **LangGraph** 构建,具备 GUI 自动化、浏览器控制、终端执行、RAG 知识库、邮件收发、图像生成、语音对话、数据库操作、定时任务等 19 项工具,并提供 Electron 桌面端一键安装体验。
<!-- 主界面截图占位 -->
<img src="./git_image/banner.png" alt="minor Agent 主界面" width="920">
[功能亮点](#-功能亮点) · [快速开始](#-快速开始) · [项目架构](#-项目架构) · [开发手册](#-开发手册) · [LLM 推理服务](#-llm-推理服务)
</div>
---
## 📖 项目简介
**minor Agent** 最初按 Web 在线应用开发,后转为本地桌面应用开源。它不是又一个"对话框 + 联网搜索"的套壳 Agent,而是一个**以"工具执行 + 视觉闭环"为核心**的通用智能体:
> 🎓 **项目初衷**:这是一个 LangChain + LangGraph 的学习项目。基于两者构建 Agent 工作流,结构清晰、上手容易、扩展方便,适合作为 Agent 开发的参考实现。
- 🖥️ **真能操作桌面**:截图 → 视觉定位 → 键鼠模拟,闭环完成 ERP 录入、表单填写、软件操控
- 🧩 **19 项内置工具**:从终端命令到邮件收发,从 PPT 制作到图像生成,覆盖真实办公场景
- 🏠 **完全本地运行**:Electron 打包,数据不出本机;后端 FastAPI + 前端零延迟直读磁盘
- 🎨 **可深度定制**:主题 / 字体 / 字号 / 背景 / 工作空间 / 提示词分层架构,全部可调
- 🔁 **工程级稳定性**:循环检测、上下文压缩、人工确认、轨迹回放,对抗 LLM 的"幻觉操作"
> 💡 项目演进:`Web 在线应用` → `Electron 桌面应用`。源码同一套 `src/`,既可由 `uvicorn` 作 Web 服务启动,也可由 Electron 主进程拉起,**双模式共用**。
<!-- 架构总览图占位 -->
<p align="center">
<img src="./git_image/architecture.png" alt="系统架构总览" width="860">
</p>
---
## ✨ 功能亮点
### 🛠️ 19 项工具能力
| 工具 | 文件 | 能力 |
|------|------|------|
| 🖱️ **gui** | `tools/gui.py` | 桌面 GUI 自动化:截图、视觉批量定位、点击/输入/快捷键/滚动 |
| 🌐 **browser_control** | `tools/browser_control.py` | 浏览器打开/关闭、地址导航、标签页交互 |
| 💻 **terminal_execute** | `tools/terminal_execute.py` | 执行任意终端命令,支持工作目录与超时 |
| 🪟 **software_control** | `tools/software_control.py` | Win32 API 启停软件、注册表、`os.startfile` |
| 📧 **email** | `tools/email.py` | IMAP 收邮件 + SMTP 发邮件(含附件) |
| 📄 **doc** | `tools/doc.py` | 创建/读取/编辑 docx / xlsx / pptx / pdf / txt |
| 📂 **directory_listing** | `tools/directory_listing.py` | 递归列目录、文件元信息 |
| 📨 **send_file** | `tools/send_file.py` | 以邮件附件形式发送本地文件 |
| 🔍 **web_search** | `tools/web_search.py` | 联网搜索(Tavily / Firecrawl) |
| 🗄️ **text2sql** | `tools/text2sql.py` | 自然语言/SQL 操作数据库:查表结构、查询、增删改 |
| 📚 **rag** | `tools/rag.py` | ChromaDB 向量检索 + Reranker 重排序 |
| 🎨 **image_gen** | `tools/image_gen.py` | 调用 Z-Image-Turbo 生成图片 |
| ✅ **todo_list** | `tools/todo_list.py` | 多步任务规划与进度跟踪 |
| 🤝 **human_interaction** | `tools/human_interaction.py` | 主动询问用户、收集信息 |
| 🤖 **agent_call** | `tools/agent_call.py` | 委派子 Agent 处理子任务 |
| 🧭 **skill_router** | `tools/skill_router.py` | 匹配并路由到预定义 Skill 流程 |
| 📅 **date_query** | `tools/date_query.py` | 当前日期时间查询 |
| ⏰ **cron_manager** | `tools/cron_manager.py` | 定时任务管理:查看/新增/修改/删除定时任务,支持 cron/间隔/一次性触发 |
| 🗣️ **streaming_client** | `tts/streaming_client.py` | 流式 TTS 语音合成客户端 |
### 🎯 区别于其他 Agent 项目的核心设计
| 设计点 | 多数 Agent 项目 | minor Agent |
|--------|----------------|-------------|
| **GUI 操作粒度** | 一步一调用,N 个动作 = 2N+1 次 LLM 调用 | **动作序列合并**:同页多操作打包为一次 `gui` 调用,N 个动作仅 2 次 LLM 调用 |
| **视觉反馈** | 仅返回坐标文本 | **截图作为视觉闭环**注入上下文,多模态 LLM 看到真实屏幕状态 |
| **坐标定位** | 逐元素请求 | **批量定位** `_batch_ground_elements`:N 个元素 1 次子模型调用 |
| **循环失控** | 无防护,token 烧穿 | **两级循环检测**:连续 3 次同工具警告反思 → 连续 5 次强制终止 |
| **上下文膨胀** | 简单截断 | **图内分级压缩**:50% 窗口轻量压缩 / 75% 窗口全量压缩,每步 LLM 调用后检查 |
| **部署形态** | 仅 Web / 仅 API | **同一套源码双模式**:`uvicorn` Web 服务 ↔ Electron 桌面应用 |
| **文件操作** | HTTP 上传下载 | 桌面端走 **IPC 直读磁盘**,零延迟;Web 端走 `/api/fs/*` |
| **提示词** | 单层 system prompt | **四层提示词架构**:系统级 / 工具描述 / 运行时注入 / 内部子模型 |
### 🖼️ 运行界面
Agent 运行过程中的关键交互界面,支持文件从任意位置**拖拽到聊天区或编辑区**,灵活组织工作流:
<!-- 运行界面截图占位 -->
<p align="center">
<img src="./git_image/运行-人机交互.png" alt="人机交互确认" width="360">
<img src="./git_image/运行-todolist.png" alt="任务规划 TodoList" width="360">
<br>
<sub><b>人机交互确认</b> — 敏感操作(终端执行、文件写入等)弹出确认浮窗,用户可同意、拒绝或修改指令&emsp;|&emsp;<b>任务规划 TodoList</b> — 多步任务自动拆解与进度跟踪,支持主 / 子 Agent 独立任务列表</sub>
</p>
<p align="center">
<img src="./git_image/运行-文件修改.png" alt="文件编辑模式" width="360">
<img src="./git_image/运行-工具调用1.png" alt="工具调用记录" width="360">
<br>
<sub><b>文件编辑模式</b> — 内置 Monaco 编辑器,支持代码高亮、差异对比、文件树拖拽组织&emsp;|&emsp;<b>工具调用记录</b> — 实时展示每轮 ReAct 的工具调用链、参数及返回值</sub>
</p>
<p align="center">
<img src="./git_image/运行-工具调用2.png" alt="子 Agent 嵌套调用" width="560">
<br>
<sub><b>子 Agent 嵌套调用</b> — 主 Agent 委派子 Agent 执行子任务,工具调用以嵌套结构展示,思考过程穿插显示,支持展开/折叠查看详情</sub>
</p>
<p align="center">
<img src="./git_image/运行-定时任务.png" alt="定时任务创建" width="360">
<img src="./git_image/运行-edit模式.png" alt="Edit 编辑模式" width="360">
<br>
<sub><b>定时任务管理</b> — 支持 cron / 间隔 / 一次性三种触发方式,状态实时可见&emsp;|&emsp;<b>Edit 编辑模式</b> — 独立编辑区,Monaco 编辑器 + 文档树侧栏,支持拖拽文件/文件夹到附件区发送给 Agent</sub>
</p>
<p align="center">
<img src="./git_image/修改图片.png" alt="图片修改画板" width="560">
<br>
<sub><b>图片修改画板</b> — 附件图片缩略图 hover 显示删除按钮,点击图片打开预览浮窗,支持下载/修改/关闭;点击「修改」进入画板编辑器,在底图上使用画笔/矩形/圆形/直线/文字/橡皮擦进行标注编辑,Ctrl+滚轮缩放,中键拖动平移,支持撤销/重做,确定后替换原图</sub>
</p>
<p align="center">
<img src="./git_image/画板.png" alt="空白画板" width="560">
<br>
<sub><b>空白画板</b> — 输入区画板按钮一键打开 1536×768 空白画布,支持画笔/矩形/圆形/直线/文字/橡皮擦绘图,颜色与粗细可调,文字对象可拖拽/缩放/旋转,Ctrl+滚轮缩放画布,中键平移,完成导出为 PNG 自动添至附件区</sub>
</p>
---
## 🏗️ 项目架构
### 整体分层
```
┌──────────────────────────────────────────────────────────────┐
│ Electron 主进程 (main.js) │
│ 窗口管理 · 首次配置向导 · Python 生命周期 · 端口清理 · IPC │
└───────────────────────────────┬──────────────────────────────┘
│ spawn uvicorn (127.0.0.1:8765)
▼
┌──────────────────────────────────────────────────────────────┐
│ FastAPI 后端 (web/server.py) │
│ 会话管理 · 多模态收发 · SSE 流式 · 文件服务 · 工具调用推送 │
└───────────────────────────────┬──────────────────────────────┘
│ execute_agent()
▼
┌──────────────────────────────────────────────────────────────┐
│ Agent 编排层 (agents/agent_runtime.py) │
│ 消息拼装 · 历史持久化 · 跨轮工具上下文 · 实时推送 │
└───────────────────────────────┬──────────────────────────────┘
│ graph.invoke()
▼
┌──────────────────────────────────────────────────────────────┐
│ LangGraph ReAct 图 (core/graph.py) │
│ agent 节点 → tools 节点 → process_tool_artifact 节点 │
│ 内含: 反思注入 · 压缩 · 循环检测 · 路由 · 人工确认 │
└───────────────────────────────┬──────────────────────────────┘
│ 调用
▼
┌──────────────────────────────────────────────────────────────┐
│ 19 项工具 (tools/*.py) · 5 项技能 (skills/) │
└──────────────────────────────────────────────────────────────┘
```
### ReAct 循环(图内)
```
START → [agent: call_model] ──有 tool_calls──▶ [tools: ToolNode] ──▶ [process_tool_artifact]
▲ ◀────────────────────────┘
│ 无 tool_calls
▼
END
```
- **`call_model`**(`core/nodes.py`):注入反思提示 → LLM 调用 → 每步检查压缩 → 提取 thinking
- **`should_continue`**(`core/routing.py`):含 tool_calls → 检查确认策略 / 循环检测 → 路由到 `tools` 或 `END`
- **`process_tool_artifact`**:gui 截图作为 synthetic HumanMessage 注入,实现视觉闭环
### 多 Agent 模式
```
START → [supervisor] ──main完成──▶ [parallel_sub_agents] ──并行完成──▶ [supervisor]
│ │
└─ 子Agent结果 tag_message 注入主Agent ◄┘
有 agent_call → 循环;无 → END
```
---
## 📂 项目结构
```
Agent/
├── electron/ # 🖥️ Electron 桌面端
│ ├── main.js # 主进程:窗口/IPC/Python生命周期/端口清理
│ ├── preload.js # 安全桥接层:fs/dialog/window/Python setup API
│ └── setup.html # 首次配置向导(3 步:Python环境 → LLM → 高级参数)
│
├── src/
│ ├── agent/ # 🧠 Agent 核心
│ │ ├── agents/
│ │ │ └── agent_runtime.py # 编排层入口:execute_agent / continue_after_human_action
│ │ ├── core/
│ │ │ ├── graph.py # LangGraph 构建(单 Agent + 多 Agent)
│ │ │ ├── nodes.py # ReAct 节点:call_model / process_tool_artifact / 压缩
│ │ │ ├── runtime.py # 图执行器:消息拼装、历史加载、轨迹落盘
│ │ │ ├── routing.py # should_continue 路由 + 强制终止
│ │ │ ├── state.py # Graph 状态定义(messages / agent_mode)
│ │ │ ├── loop_detector.py # 循环检测:重复工具 / TodoList 停滞,两级响应
│ │ │ ├── llm.py # ChatQwen 多模态封装(图像/附件/非标准 tool_call)
│ │ │ ├── tool_policy.py # 工具执行策略(直执 / 需确认)
│ │ │ ├── approvals.py # 待确认操作注册与 UI 元数据
│ │ │ └── config_manager.py# 配置读写:agent/tool/env/gui/model/theme
│ │ ├── memory/
│ │ │ └── system_prompt.py # ★ 四层提示词:MAIN / PLAN / REFLECTION / VISION...
│ │ ├── tools/ # 19 项工具(见上表)
│ │ │ ├── ...(gui / browser_control / terminal_execute / email / doc / web_search / text2sql / rag / cron_manager 等)
│ │ ├── cron/ # ⏰ 定时任务引擎
│ │ │ ├── models.py # 数据模型(CronTask / Trigger)
│ │ │ ├── storage.py # 持久化层(CronTaskRepository,JSON / MySQL)
│ │ │ ├── scheduler.py # 调度计算与触发扫描
│ │ │ ├── runner.py # 任务执行器(子进程)
│ │ │ └── live.py # 常驻调度循环
│ │ ├── skills/ # 5 项技能(skill.json + skill.md)
│ │ │ ├── netease_mail_read/
│ │ │ ├── netease_mail_send/
│ │ │ ├── ppt_maker/ # HTML 模板 → PPT(含 9 个附件文档 + 模板)
│ │ │ ├── taste/ # 代码生成界面美化
│ │ │ └── wechat_send_message/
│ │ ├── tts/
│ │ │ └── streaming_client.py # 流式 TTS 客户端(SSE 收音频块)
│ │ ├── history/
│ │ │ ├── session_storage.py # 会话与消息持久化(JSON / MySQL 双后端)
│ │ │ └── tool_call_recorder.py # 每轮工具调用轨迹记录 (turn_{id}.json)
│ │ ├── utils/ # env_utils / agent_utils / image_utils / ppt_utils / tool_call_utils
│ │ └── config/ # ★ 配置文件目录
│ │ ├── env_config.json # 运行时配置(首次向导生成,勿手动改)
│ │ ├── env_config_dist.json # 配置模板(默认值)
│ │ ├── agent_config.json # Agent 行为配置 + 兜底 system_prompt
│ │ ├── tool_config.json # 各工具启用/参数
│ │ ├── gui_config.json # GUI 工具参数
│ │ ├── theme_config.json # 主题
│ │ └── workspace_config.json # 工作空间列表与当前选择
│ │
│ └── web/ # 🎨 前端 UI
│ ├── server.py # FastAPI 后端(50+ 路由)
│ ├── ui_session.py # UI 会话状态管理
│ ├── index.html # 主页面(侧边栏/聊天/编辑/Agent面板/操作栏)
│ ├── app.js / app.css # 入口
│ ├── js/ # 28 个前端模块(见开发手册)
│ ├── css/ # 16 个样式模块 + variables.css 主题变量
│ ├── packages/ # Monaco Editor 本地 npm 包
│ └── image/ # SVG 图标资源
│
├── llm_server/ # 🧠 LLM 推理服务(独立部署,详见该目录 README)
├── git_image/ # 📸 README 展示图片
├── package.json # Electron + electron-builder 配置
├── requirements.txt # Python 依赖
└── README.md # 本文件
```
---
## 🚀 快速开始
minor Agent 提供两种运行方式:**桌面应用(推荐)** 与 **Web 开发模式**。两者共用同一套 `src/` 源码。
### 方式一:桌面应用(推荐体验)
#### 1. 安装依赖
```powershell
cd C:\Users\86166\Desktop\Agent_Learning_minor\Agent
# 设置国内镜像(可选,加速 Electron 下载)
$env:ELECTRON_MIRROR="https://npmmirror.com/mirrors/electron/"
npm install
```
#### 2. 启动应用
```powershell
npm start
```
首次运行会弹出**配置向导**(3 步),写入 `src/agent/config/env_config.json` 后自动启动:
| 步骤 | 配置内容 | 说明 |
|------|----------|------|
| **Step 1** Python 环境 | 选择 / 自动检测系统 Python | 主进程会调用系统 Python 启动 uvicorn;可选自动 `pip install` 依赖 |
| **Step 2** LLM 设置 | `base_url` / `api_key` / `model` | 必填;可填任何 OpenAI 兼容端点 |
| **Step 3** 高级参数 | TTS / ASR / RAG / 搜索 / 压缩 / 循环阈值 | 可选;不填则对应能力禁用 |
> 💡 之后启动直接进入主界面,不再弹向导。如需重配,删除 `src/agent/config/env_config.json` 即可重触首次流程。
#### 3. 打包为安装程序
```powershell
# 设置镜像 + 自定义缓存目录后构建
$env:ELECTRON_MIRROR="https://npmmirror.com/mirrors/electron/"
$env:ELECTRON_BUILDER_CACHE="$PWD\electron-builder-cache"
npm run build
```
输出 `dist/minor Agent Setup 1.0.0.exe`,双击安装即可。安装包使用**系统 Python + 运行时安装依赖**方案,体积远小于打包完整 venv 的方案。
<details>
<summary>📦 打包原理(点击展开)</summary>
- `package.json` 的 `build.files` 仅打包 `electron/`、`src/`、`requirements.txt`,**排除** `env_config.json`(每个用户独立生成)
- `asar: false`:源码不加密,便于审计与二次开发
- NSIS 安装器:允许改安装目录、创建桌面/开始菜单快捷方式、卸载时清理 AppData
- 首次启动由 `setup.html` 向导引导:检测 Python → 配置 LLM → 写 `env_config.json` → 启动 FastAPI → 加载 UI
- 主进程 `findSystemPython()` 依次检查:用户配置路径 → 常见 Python 安装位置 → PATH 中的 `python`
</details>
---
### 方式二:Web 开发模式(调试用)
适合前端开发与快速迭代,无需 Electron。
```powershell
cd C:\Users\86166\Desktop\Agent_Learning_minor\Agent
# 1. 创建并激活虚拟环境
python -m venv myenv
.\myenv\Scripts\Activate.ps1 # Windows
# source myenv/bin/activate # macOS / Linux
# 2. 安装依赖
pip install --upgrade pip
pip install -r requirements.txt
# 3. 配置环境(首次)
# 复制模板并填写 LLM 凭证
Copy-Item src\agent\config\env_config_dist.json src\agent\config\env_config.json
# 编辑 env_config.json,至少填写 models[].base_url / api_key / model
# 4. 启动 Web 服务
$env:PYTHONPATH="src;$env:PYTHONPATH"
uvicorn web.server:app --host 127.0.0.1 --port 8765 --reload
# 5. 浏览器打开
# http://127.0.0.1:8765
```
<details>
<summary>🌐 (可选)公网暴露</summary>
```powershell
# 通过 Cloudflare Tunnel 临时暴露
cloudflared tunnel --url http://localhost:8765
```
</details>
---
## ⚙️ 配置说明
所有运行时配置集中在 `src/agent/config/env_config.json`,首次向导自动生成。关键字段:
| 字段 | 默认值 | 说明 |
|------|--------|------|
| `models[]` | `SETUP_REQUIRED` | LLM 列表:`{id, name, model, api_key, base_url, timeout, max_retries}` |
| `gui_model_id` | `""` | GUI 视觉定位专用模型(留空则回退主模型) |
| `WORKING_DIR` | `.` | 工作根目录(桌面端自动设为安装目录) |
| `WORKSPACE_DIR` | `workspace` | 默认工作空间子目录 |
| `USER_PYTHON_PATH` | `""` | 指定 Python 解释器路径 |
| `LLM_CONTEXT_WINDOW` | `262144` | 上下文窗口大小(token) |
| `MINI_COMPRESS_RATE` | `0.4` | 轻量压缩阈值(占窗口 40% 触发) |
| `HARD_COMPRESS_RATE` | `0.7` | 全量压缩阈值(占窗口 70% 触发) |
| `CURRENT_TURNS` | `4` | 短期记忆保留的最近工具轮数 |
| `IMG_SIZE` | `768` | 截图短边尺寸 |
| `GROUNDING_WIDTH/HEIGHT` | `1000` | 视觉定位输入分辨率 |
| `LOOP_DETECT_TODOLIST_STALE_ROUNDS` | `20` | TodoList 停滞多少轮触发反思 |
| `LOOP_DETECT_REPEATED_TOOL_WARN` | `3` | 连续同工具同参多少次警告 |
| `LOOP_DETECT_REPEATED_TOOL_END` | `5` | 连续同工具同参多少次强制终止 |
| `RAG_CHUNK_SIZE / OVERLAP` | `500 / 50` | RAG 分块大小与重叠 |
| `SEND_FILE_SIZE_LIMIT` | `30` | 发送文件大小上限(MB) |
| `WEB_SEARCH_API_KEY` | `""` | 联网搜索 API Key |
| `WEB_SEARCH_ENGINE` | `"tavily"` | 搜索引擎选择(tavily / firecrawl) |
| `STORAGE_BACKEND` | `"json"` | 存储后端(json / mysql) |
| `MYSQL_HOST` / `MYSQL_PORT` / `MYSQL_USER` / `MYSQL_PASSWORD` / `MYSQL_DATABASE` | `""` | MySQL 连接参数(STORAGE_BACKEND=mysql 时生效) |
| `TOOL_TIMEOUT` | `300` | 工具执行超时时间(秒) |
| `CRON_TIME_PERIOD_MINUTES` | `30` | 定时任务时段长度(分钟),同一时段全局并发=1 |
| `ASR_BASE_URL` | `""` | 语音识别服务地址 |
| `STREAMING_TTS_URL` | `""` | 流式 TTS 服务地址 |
| `RAG_BASE_URL` | `""` | RAG 嵌入/重排序服务地址 |
| `EMAIL_ADDRESS / EMAIL_AUTH_CODE` | `""` | 邮箱凭证(IMAP/SMTP) |
> ⚙️ 以上参数均可在主界面「设置」面板热修改,或通过 `/api/config/*` 接口程序化调整。
设置面板包含五个标签页,覆盖 Agent 运行的全部可调参数:
<!-- 设置界面截图占位 -->
<p align="center">
<img src="./git_image/5.png" alt="Agent 配置" width="360">
<img src="./git_image/1.png" alt="工具配置" width="360">
<br>
<sub><b>Agent 配置</b> — 管理多个 AI 角色,各自绑定独立模型与工具集&emsp;|&emsp;<b>工具配置</b> — 启用/禁用工具、调整参数及执行策略</sub>
</p>
<p align="center">
<img src="./git_image/2.png" alt="环境变量" width="360">
<img src="./git_image/3.png" alt="GUI 设置" width="360">
<br>
<sub><b>环境变量</b> — LLM 密钥、路径、压缩阈值等全局参数&emsp;|&emsp;<b>GUI 设置</b> — 选择 GUI 自动化操作的目标显示器</sub>
</p>
<p align="center">
<img src="./git_image/4.png" alt="主题配置" width="360">
<br>
<sub><b>主题配置</b> — 12 套预设主题 + 自定义背景 / 模糊度 / 暗角 / 字体</sub>
</p>
---
## 🧠 实现细节
### 1. 四层提示词架构
| 层级 | 位置 | 注入时机 | 作用 |
|------|------|----------|------|
| **L1 系统级** | `memory/system_prompt.py` | 会话开始 | `MAIN_SYSTEM_PROMPT` 定义角色;`PLAN_MODE_PROMPT` 强制先规划 |
| **L2 工具描述** | 各 `tools/*.py` 的 `description` | 工具绑定时 | 指导 LLM 正确调用(含 one-shot 示例) |
| **L3 运行时注入** | `nodes.py` / `loop_detector.py` | 每轮 ReAct | `REFLECTION_PROMPT` 反思 + 反循环警告,作为 synthetic HumanMessage |
| **L4 内部子模型** | `gui.py` `_build_batch_prompt` | 工具内部 | 批量坐标定位,one-shot 强制纯坐标输出 |
### 2. 上下文分级压缩(图内)
<!-- 多级压缩机制图占位 -->
<p align="center">
<img src="./git_image/compress.png" alt="多级压缩机制" width="780">
</p>
```
每次 LLM 调用后读取 response.usage_metadata.total_tokens
├─ > 50% 窗口 → 轻量压缩:保留本轮首个 agent 动作
├─ > 75% 窗口 → 全量压缩:仅保留当前 user 消息
└─ 就地替换 state["messages"] = [SystemMessage(摘要) + 保留部分]
```
压缩发生在 `call_model` 内部(图内每步),而非图外一次性处理,**子 Agent 自动受益**。
### 3. 循环检测两级响应
| 级别 | 触发条件 | 响应 |
|------|----------|------|
| ⚠️ 警告 | 连续 3 次同工具同参 / TodoList 停滞 20 轮 | 注入反思提示引导模型自我纠偏 |
| 🛑 终止 | 连续 5 次同工具同参 | `should_force_end()` 返回 END,向前端输出错误报告 |
### 4. GUI 动作序列化(性能关键)
| 模式 | N 个动作的 LLM 调用次数 |
|------|------------------------|
| 单步执行(多数项目) | 2N + 1 |
| **序列合并(本项目)** | **2**(1 次批量定位 + 1 次决策) |
通过 `MAIN_SYSTEM_PROMPT` + `REFLECTION_PROMPT` 双重强调"同页操作必须合并为一次 `gui` 调用"实现。
### 5. 双模式源码共用
`src/web/` 同一套代码两种部署:
| | Web 模式 | 桌面模式 |
|---|---------|---------|
| 启动 | `uvicorn web.server:app` | Electron `spawn` uvicorn |
| 文件操作 | `/api/fs/*` HTTP | `electron-api.js` → IPC 直读磁盘 |
| 对话框 | 浏览器原生 | Electron `dialog` 模块 |
| 加载协议 | `http://` | `file://` + IPC |
前端通过 `window.electronAPI?.isElectron` 自动适配。
---
## 🔌 LLM 推理服务
项目自带一套 6 服务推理后端(`llm_server/`),可独立部署在 GPU 服务器上,为 Agent 提供 LLM / ASR / TTS / RAG / 图像生成能力。
| 服务 | 端口 | 模型 | 脚本 |
|------|------|------|------|
| LLM | 8900 | Qwen3.6-35B-A3B-FP8 | `start_llm.sh` |
| ASR | 8901 | Qwen3-ASR-1.7B | `start_asr.sh` |
| TTS | 8902 | VoxCPM | `start_streaming_tts.sh` |
| RAG | 8903 | Qwen3-Embedding-0.6B + Reranker-4B | `start_rag_server.sh` |
| Image Gen | 8904 | Z-Image-Turbo | `start_image_gen.sh` |
> 📄 完整部署、模型下载、测试方法见 **[llm_server/README.md](./llm_server/README.md)**。
> 💡 也可不自建推理服务,直接在配置向导填入任何 OpenAI 兼容 API(如云端 Qwen / DeepSeek / OpenAI)。
---
## 🛠️ 开发手册
### 前端模块职责(`src/web/js/`)
| 模块 | 职责 |
|------|------|
| `app.js` | 应用入口,初始化与事件绑定 |
| `api.js` | 封装所有后端 API 请求 |
| `state.js` | 全局状态管理 |
| `send.js` | 消息发送逻辑 |
| `chat-render.js` | 聊天消息渲染 |
| `cron.js` | 定时任务管理面板 |
| `sessions.js` | 会话列表管理 |
| `toolcalls.js` | 工具调用实时面板 |
| `todo.js` | TodoList 面板 |
| `edit-mode.js` | 编辑模式(Monaco) |
| `doc-tree.js` / `doc-mod-panel.js` | 文档树与修改面板 |
| `action-bar.js` | 底部操作栏 |
| `skills.js` | 技能管理 |
| `settings.js` | 设置面板 |
| `themes.js` | 主题切换 |
| `layout.js` | 布局与分隔条拖拽 |
| `electron-api.js` | ★ Electron IPC 适配层 |
| `file-preview.js` | 文件预览(图片/HTML/PDF) |
| `canvas-editor.js` | 画板编辑器(画笔/形状/文字/橡皮擦,支持缩放与图片修改) |
| `audio.js` | 语音录制与播放 |
| `i18n.js` | 国际化 |
| `token-bar.js` | Token 用量条 |
| `pending.js` / `pending-overlay.js` | 待确认操作 |
| `rollback.js` | 消息回滚 |
| `workspace.js` | 工作空间切换 |
| `dialog.js` | 对话框 |
| `utils.js` | 通用工具 |
### CSS 架构(`src/web/css/`)
`variables.css` 定义主题变量,其余按模块拆分:`base / layout / chat / composer / cron / dialog / settings / skills / todo / toolcalls / edit-mode / audio / animations / pending / pending-overlay / responsive`。
### 添加自定义工具
1. 在 `src/agent/tools/` 新建 `my_tool.py`,继承 LangChain `BaseTool`,实现 `_run`,填写 `name` / `description` / `args_schema`
2. 在 `tool_config.json` 注册并配置启用状态与参数
3. (可选)在 `tool_policy.py` 设置是否需要人工确认
4. 重启后端,工具自动绑定到 LLM
### 添加自定义技能
1. 在 `src/agent/skills/` 新建 `my_skill/` 目录
2. 编写 `skill.json`(名称、描述、标签)与 `skill.md`(操作流程,支持 `{SKILL_DIR}` 占位)
3. 可放 `attachments/` 附件(模板、脚本)
4. `skill_router` 会自动匹配并路由
### 同步 Web ↔ 桌面端
桌面端与 Web 端共用 `src/web/`。UI 改动无需同步;仅当分叉修改时手动合并。常用同步命令:
```powershell
# 桌面端 → Web 端(按需)
Copy-Item "src\web\index.html" "src\web\index.html" -Force
Copy-Item "src\web\css\*.css" "src\web\css\" -Force
Copy-Item "src\web\js\themes.js" "src\web\js\themes.js" -Force
```
### 调试技巧
- **后端日志**:桌面端 `npm start` 会在终端打印 `[FastAPI]` 日志
- **端口占用**:`main.js` 的 `killPortProcess(8765)` 启动前自动清理
- **重置配置**:删除 `src/agent/config/env_config.json` 重触首次向导
- **工具调用轨迹**:`history/tool_calling/{session_id}/turn_{id}.json`
---
## ⚠️ 注意事项
| 事项 | 说明 |
|------|------|
| 🖥️ **平台** | 桌面自动化工具(`gui` / `software_control`)依赖 Win32 / pywin32 / pyautogui,**目前仅支持 Windows**;Web 模式下这些工具不可用 |
| 🐍 **Python 版本** | 建议 Python 3.11 / 3.12 / 3.13;首次向导会自动检测 |
| 🔐 **未签名安装包** | `.exe` 可能被 Windows Defender 拦截,点「更多信息 → 仍要运行」 |
| 🌐 **首次启动联网** | 加载 Google Fonts 需联网;离线环境字体选择器有 HTML 硬编码兜底 |
| 🔑 **API Key 安全** | `env_config.json` 含明文凭证,**勿提交到公开仓库**(已在 `.gitignore` 排除 `env_config.json`,仅保留 `env_config_dist.json` 模板) |
| 🎨 **图标** | 当前为 SVG,NSIS 打包后若显示默认图标,需准备 `.ico` 文件替换 `src/web/image/icon.ico` |
| 💾 **历史存储** | 会话历史与工具轨迹落盘在 `history/`,长期使用注意清理 |
| ⚡ **GUI 性能** | 同页多操作务必合并为一次 `gui` 调用,否则延迟显著(见「实现细节」) |
---
## 📄 License
MIT License — 详见 [LICENSE](./LICENSE)。
<div align="center">
<sub>如果这个项目对你有帮助,欢迎 ⭐ Star 支持一下!</sub>
</div>
View File
Whitespace-only changes.
+906
View File
@@ -0,0 +1,906 @@
// main.js — Electron 主进程(桌面版最小安装包)
//
// 两种模式:
// - 开发模式 (npm start): 使用项目本地的 src/ 和 myenv/
// - 打包模式 (minor Agent.exe): 使用 resources/ 下的 src/ 和 myenv/
//
// 首次运行会弹出 setup.html 让用户配置 LLM API Key,
// 写入 env_config.json 后启动 FastAPI 并加载 Web UI。
const { app, BrowserWindow, dialog, ipcMain, Menu } = require('electron');
const { spawn, execSync } = require('child_process');
const path = require('path');
const http = require('http');
const fs = require('fs');
// ==================== 路径解析 ====================
const isPackaged = app.isPackaged;
// 开发模式下所有文件在项目目录中
// 打包模式下: 主进程代码 -> resources/app.asar,源码+venv -> resources/
const AGENT_DIR = isPackaged
? process.resourcesPath // resources/
: path.join(__dirname, '..'); // minors_app/
// 打包模式下 Python 源码在 resources/app/src/(非 resources/src/)
const SRC_DIR = isPackaged
? path.join(process.resourcesPath, 'app', 'src')
: path.join(AGENT_DIR, 'src');
// 配置文件路径 (src/agent/config/env_config.json)
const CONFIG_PATH = path.join(SRC_DIR, 'agent', 'config', 'env_config.json');
const CONFIG_DIST_PATH = path.join(SRC_DIR, 'agent', 'config', 'env_config_dist.json');
const HOST = '127.0.0.1';
const PORT = 8765;
const BASE_URL = `http://${HOST}:${PORT}`;
// ==================== 全局状态 ====================
let mainWindow = null;
let pythonProcess = null;
let isQuitting = false;
let isLaunching = false; // 防止 setup 窗口关闭时触发 app.quit
// ==================== 端口清理 ====================
function killPortProcess(port) {
try {
// 查找占用端口的所有 PID 并强制终止(含子进程树)
const result = execSync(
`powershell -Command "Get-NetTCPConnection -LocalPort ${port} -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess | ForEach-Object { Stop-Process -Id $_ -Force -ErrorAction SilentlyContinue }"`,
{ timeout: 5000, stdio: 'pipe' }
);
return true;
} catch {
return false;
}
}
// ==================== 首次运行检测 ====================
function isFirstRun() {
if (!fs.existsSync(CONFIG_PATH)) {
return true;
}
try {
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
const models = config.models || [];
if (models.length === 0) return true;
if (models[0].id === 'SETUP_REQUIRED') return true;
if (!models[0].api_key || !models[0].base_url || !models[0].model) return true;
return false;
} catch {
return true;
}
}
// ==================== 配置管理 ====================
function writeEnvConfig(userInput) {
let config;
const templatePath = CONFIG_DIST_PATH;
if (fs.existsSync(templatePath)) {
config = JSON.parse(fs.readFileSync(templatePath, 'utf-8'));
} else if (fs.existsSync(CONFIG_PATH)) {
config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
} else {
config = {};
}
config.WORKING_DIR = isPackaged ? process.resourcesPath : AGENT_DIR;
config.USER_NICKNAME = userInput.nickname || (config.USER_NICKNAME || '');
config.USER_PYTHON_PATH = userInput.pythonPath || (config.USER_PYTHON_PATH || '');
config.EMAIL_ADDRESS = userInput.emailAddr || (config.EMAIL_ADDRESS || '');
config.EMAIL_AUTH_CODE = userInput.emailCode || (config.EMAIL_AUTH_CODE || '');
config.WEB_SEARCH_API_KEY = userInput.webSearchKey || (config.WEB_SEARCH_API_KEY || '');
config.ASR_BASE_URL = userInput.asrUrl || (config.ASR_BASE_URL || '');
config.STREAMING_TTS_URL = userInput.ttsUrl || (config.STREAMING_TTS_URL || '');
config.RAG_BASE_URL = userInput.ragUrl || (config.RAG_BASE_URL || '');
config.LLM_CONTEXT_WINDOW = String(userInput.llmContextWindow || config.LLM_CONTEXT_WINDOW || 262144);
config.MINI_COMPRESS_RATE = String(userInput.miniCompressRate || config.MINI_COMPRESS_RATE || 0.4);
config.HARD_COMPRESS_RATE = String(userInput.hardCompressRate || config.HARD_COMPRESS_RATE || 0.7);
config.CURRENT_TURNS = String(userInput.currentTurns || config.CURRENT_TURNS || 4);
config.IMG_SIZE = String(userInput.imgSize || config.IMG_SIZE || 768);
config.RAG_CHUNK_SIZE = String(userInput.ragChunkSize || config.RAG_CHUNK_SIZE || 500);
config.RAG_CHUNK_OVERLAP = String(userInput.ragChunkOverlap || config.RAG_CHUNK_OVERLAP || 50);
config.GROUNDING_WIDTH = String(userInput.groundingWidth || config.GROUNDING_WIDTH || 1000);
config.GROUNDING_HEIGHT = String(userInput.groundingHeight || config.GROUNDING_HEIGHT || 1000);
config.LOOP_DETECT_TODOLIST_STALE_ROUNDS = String(userInput.loopStaleRounds || config.LOOP_DETECT_TODOLIST_STALE_ROUNDS || 20);
config.LOOP_DETECT_REPEATED_TOOL_WARN = String(userInput.loopRepeatedWarn || config.LOOP_DETECT_REPEATED_TOOL_WARN || 3);
config.LOOP_DETECT_REPEATED_TOOL_END = String(userInput.loopRepeatedEnd || config.LOOP_DETECT_REPEATED_TOOL_END || 5);
config.SEND_FILE_SIZE_LIMIT = String(userInput.sendFileSizeLimit || config.SEND_FILE_SIZE_LIMIT || 30);
// 存储后端:json(本地文件) / mysql(数据库,存定时任务+会话历史)
// 向后兼容:优先读 STORAGE_BACKEND,回退旧的 CRON_STORAGE_BACKEND
config.STORAGE_BACKEND = userInput.cronStorageBackend || config.STORAGE_BACKEND || config.CRON_STORAGE_BACKEND || 'json';
config.models = [{
id: 'user_configured',
name: userInput.model,
model: userInput.model,
api_key: userInput.apiKey,
base_url: userInput.baseUrl,
timeout: userInput.timeout || 60,
max_retries: 0,
}];
config.gui_model_id = userInput.guiModelId || userInput.model || (config.gui_model_id || '');
const configDir = path.dirname(CONFIG_PATH);
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), 'utf-8');
console.log('[Electron] 配置已写入:', CONFIG_PATH);
}
// ==================== Python 检测 ====================
function findSystemPython() {
// 1. 优先使用用户配置的 Python 路径
try {
if (fs.existsSync(CONFIG_PATH)) {
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
if (config.USER_PYTHON_PATH && fs.existsSync(config.USER_PYTHON_PATH)) {
return config.USER_PYTHON_PATH;
}
}
} catch {}
// 2. 常见 Python 安装路径
const candidates = [
'python',
'python3',
path.join(process.env.LOCALAPPDATA || '', 'Programs', 'Python', 'Python312', 'python.exe'),
path.join(process.env.LOCALAPPDATA || '', 'Programs', 'Python', 'Python311', 'python.exe'),
path.join(process.env.LOCALAPPDATA || '', 'Programs', 'Python', 'Python313', 'python.exe'),
'C:\\Python312\\python.exe',
'C:\\Python311\\python.exe',
'C:\\Python313\\python.exe',
];
for (const p of candidates) {
if (p === 'python' || p === 'python3') {
// PATH 中的 python 直接用,无法预检
return p;
}
if (fs.existsSync(p)) return p;
}
return 'python'; // 最后 fallback 到 PATH
}
// ==================== FastAPI 生命周期 ====================
let _fastapiStderr = ''; // 累积 stderr 用于超时诊断
function startFastAPI() {
return new Promise((resolve, reject) => {
killPortProcess(PORT);
const pythonExe = findSystemPython();
// 检查 Python 是否存在
if (!fs.existsSync(pythonExe)) {
reject(new Error(`Python 路径不存在:\n${pythonExe}\n\n请运行安装程序或手动配置 Python 路径。`));
return;
}
console.log(`[Electron] Python: ${pythonExe}`);
console.log(`[Electron] SRC_DIR: ${SRC_DIR}`);
_fastapiStderr = '';
// 启动 uvicorn:用 shell 模式确保 PYTHONPATH 含空格路径能正确传递
const uvicornArgs = ['-m', 'uvicorn', 'web.server:app', '--host', HOST, '--port', String(PORT), '--log-level', 'warning'];
pythonProcess = spawn(`"${pythonExe}"`, uvicornArgs, {
cwd: SRC_DIR,
env: {
...process.env,
PYTHONPATH: SRC_DIR,
PYTHONUNBUFFERED: '1',
WORKING_DIR: path.resolve(AGENT_DIR, '.'),
},
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
shell: true,
});
console.log(`[Electron] FastAPI PID: ${pythonProcess.pid}`);
pythonProcess.stdout.on('data', (d) => {
const line = d.toString().trim();
if (line) console.log(`[FastAPI] ${line}`);
});
pythonProcess.stderr.on('data', (d) => {
const text = d.toString().trim();
if (text) {
console.log(`[FastAPI] ${text}`);
_fastapiStderr += text + '\n';
if (_fastapiStderr.length > 5000) _fastapiStderr = _fastapiStderr.slice(-3000);
}
});
pythonProcess.on('error', (err) => {
console.error('[Electron] FastAPI 启动失败:', err.message);
reject(err);
});
pythonProcess.on('exit', (code) => {
console.log(`[Electron] FastAPI 退出,码: ${code}`);
pythonProcess = null;
if (!isQuitting && code !== 0 && code !== null && mainWindow) {
dialog.showErrorBox('后端服务异常', `FastAPI 意外退出(退出码: ${code})。`);
}
});
waitForReady(resolve, reject, 30);
});
}
function waitForReady(resolve, reject, retriesLeft) {
if (retriesLeft <= 0) {
reject(new Error(
`FastAPI 启动超时\n\nPython: ${findSystemPython()}\n工作目录: ${SRC_DIR}\n\n最后 stderr 输出:\n${_fastapiStderr || '(无输出)'}`
));
return;
}
const req = http.get(`${BASE_URL}/api/health`, (res) => {
if (res.statusCode === 200) {
console.log('[Electron] FastAPI 就绪');
resolve();
} else {
retry();
}
});
req.on('error', () => retry());
req.setTimeout(2000, () => { req.destroy(); retry(); });
function retry() {
setTimeout(() => waitForReady(resolve, reject, retriesLeft - 1), 1000);
}
}
function stopFastAPI() {
// 先通过端口杀一次(清理所有关联进程)
killPortProcess(PORT);
// 再通过 PID 杀一次(确保主进程被杀)
if (pythonProcess) {
console.log('[Electron] 关闭 FastAPI...');
try {
if (process.platform === 'win32') {
execSync(`taskkill /pid ${pythonProcess.pid} /f /t 2>nul`, { stdio: 'ignore' });
} else {
pythonProcess.kill('SIGTERM');
}
} catch {}
pythonProcess = null;
}
}
// ==================== 窗口管理 ====================
async function createMainWindow() {
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 800,
minHeight: 600,
title: 'minor Agent',
backgroundColor: '#1a1a2e',
frame: false,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
},
show: false,
});
// 清除缓存后再加载,确保获取最新 JS/CSS
await mainWindow.webContents.session.clearCache();
await mainWindow.webContents.session.clearStorageData({ storages: ['caches', 'serviceworkers'] });
mainWindow.loadURL(BASE_URL);
mainWindow.once('ready-to-show', () => mainWindow.show());
mainWindow.on('closed', () => { mainWindow = null; });
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
require('electron').shell.openExternal(url).catch(() => {});
return { action: 'deny' };
});
}
// ==================== 应用生命周期 ====================
let setupWindow = null;
function createSetupWindow() {
setupWindow = new BrowserWindow({
width: 560,
height: 720,
minWidth: 500,
minHeight: 600,
title: 'minor Agent - 首次配置',
backgroundColor: '#f8fafc',
frame: false,
resizable: true,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
},
show: false,
});
setupWindow.loadFile(path.join(__dirname, 'setup.html'));
setupWindow.once('ready-to-show', () => setupWindow.show());
setupWindow.on('closed', () => { setupWindow = null; });
}
app.whenReady().then(async () => {
// 隐藏菜单栏
Menu.setApplicationMenu(null);
if (isFirstRun()) {
// 首次运行:打开配置窗口(Python 环境安装 + LLM 配置)
createSetupWindow();
} else {
// 非首次:直接启动 FastAPI 和主窗口
try {
await startFastAPI();
await createMainWindow();
} catch (err) {
dialog.showErrorBox('启动失败', `无法启动 minor Agent:\n${err.message}`);
app.quit();
}
}
});
ipcMain.handle('save-config', async (_event, data) => {
try {
writeEnvConfig(data);
const testOk = await testLLMConnection(data);
if (!testOk) {
return { success: false, error: '无法连接到 LLM 服务,请检查 API Key 和 Base URL' };
}
return { success: true };
} catch (err) {
return { success: false, error: err.message };
}
});
ipcMain.on('launch-app', async () => {
// 标记正在启动,防止关闭 setup 窗口时触发 app.quit
isLaunching = true;
// 关闭配置窗口
if (setupWindow) {
setupWindow.close();
setupWindow = null;
}
// 启动 FastAPI 并创建主窗口
try {
await startFastAPI();
await createMainWindow();
} catch (err) {
dialog.showErrorBox('启动失败', `无法启动 minor Agent:\n${err.message}`);
app.quit();
} finally {
isLaunching = false;
}
});
// ===== 首次运行检测(供前端查询) =====
ipcMain.handle('is-first-run', () => isFirstRun());
async function testLLMConnection(data) {
const https = require('https');
return new Promise((resolve) => {
// 检查 LLM 服务健康状态:GET /models 返回 200 即可
const url = new URL(data.baseUrl + (data.baseUrl.endsWith('/') ? '' : '/') + 'models');
const isHttps = url.protocol === 'https:';
const mod = isHttps ? https : http;
const req = mod.request({
hostname: url.hostname,
port: url.port || (isHttps ? 443 : 80),
path: url.pathname,
method: 'GET',
headers: {
'Authorization': `Bearer ${data.apiKey}`,
},
timeout: 15000,
rejectUnauthorized: false,
}, (res) => {
resolve(res.statusCode === 200);
});
req.on('error', () => resolve(false));
req.on('timeout', () => { req.destroy(); resolve(false); });
req.end();
});
}
// ==================== IPC:文件系统操作 ====================
const SUPPORTED_FILE_EXTS = new Set([
'.txt','.json','.md','.cpp','.c','.py','.m','.java','.html','.htm',
'.svg','.css','.js','.ts','.xml','.yaml','.yml','.toml',
'.csv','.log','.env','.ini','.cfg','.sh','.bat','.ps1','.sql',
'.r','.go','.rs','.rb','.php','.swift','.kt','.scala',
'.docx','.pptx','.pdf','.xlsx','.doc','.xls','.ppt',
'.png','.jpg','.jpeg','.gif','.bmp','.webp','.ico',
]);
function isSupportedFile(name) {
const ext = '.' + (name.split('.').pop() || '').toLowerCase();
return SUPPORTED_FILE_EXTS.has(ext);
}
function _getMime(filePath) {
const ext = '.' + (filePath.split('.').pop() || '').toLowerCase();
const map = {
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
'.gif': 'image/gif', '.bmp': 'image/bmp', '.webp': 'image/webp',
'.ico': 'image/x-icon',
'.svg': 'image/svg+xml', '.pdf': 'application/pdf',
'.html': 'text/html', '.htm': 'text/html',
};
return map[ext] || 'application/octet-stream';
}
function readDirRecursive(dirPath, depth, currentDepth, rootDir) {
if (currentDepth > depth) return [];
const baseRoot = rootDir || dirPath;
let results = [];
try {
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === '__pycache__') continue;
if (entry.isDirectory()) {
if (currentDepth < depth) {
results = results.concat(readDirRecursive(fullPath, depth, currentDepth + 1, baseRoot));
}
} else if (entry.isFile()) {
if (isSupportedFile(entry.name)) {
try {
const stat = fs.statSync(fullPath);
results.push({
path: path.relative(baseRoot, fullPath).replace(/\\/g, '/'),
name: entry.name,
is_dir: false,
size: stat.size,
});
} catch {}
}
}
}
} catch {}
return results;
}
ipcMain.handle('fs:readFile', async (_event, filePath) => {
try {
const content = fs.readFileSync(filePath, 'utf-8');
return { content, status: 'ok' };
} catch (err) {
return { content: '', status: 'error', error: err.message };
}
});
ipcMain.handle('fs:readBinaryFile', async (_event, filePath) => {
try {
const buf = fs.readFileSync(filePath);
return { data: buf.toString('base64'), mime: _getMime(filePath), status: 'ok' };
} catch (err) {
return { data: '', mime: '', status: 'error', error: err.message };
}
});
ipcMain.handle('fs:writeFile', async (_event, filePath, content) => {
try {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content, 'utf-8');
return { ok: true };
} catch (err) {
return { ok: false, error: err.message };
}
});
ipcMain.handle('fs:readDir', async (_event, dirPath, depth = 10) => {
try {
const files = readDirRecursive(dirPath, depth, 0);
return { files, status: 'ok' };
} catch (err) {
return { files: [], status: 'error', error: err.message };
}
});
ipcMain.handle('fs:stat', async (_event, filePath) => {
try {
const s = fs.statSync(filePath);
return { exists: true, is_dir: s.isDirectory(), size: s.size, mtime: s.mtimeMs };
} catch {
return { exists: false };
}
});
ipcMain.handle('fs:rename', async (_event, oldPath, newPath) => {
try {
fs.mkdirSync(path.dirname(newPath), { recursive: true });
fs.renameSync(oldPath, newPath);
return { ok: true };
} catch (err) {
return { ok: false, error: err.message };
}
});
ipcMain.handle('fs:delete', async (_event, filePath) => {
try {
// 使用 shell.trashItem 移入回收站,而非永久删除
const { shell } = require('electron');
await shell.trashItem(filePath);
return { ok: true };
} catch (err) {
// 回收站不可用时回退到永久删除(如网络驱动器、U盘等)
try {
const s = fs.statSync(filePath);
if (s.isDirectory()) {
fs.rmSync(filePath, { recursive: true, force: true });
} else {
fs.unlinkSync(filePath);
}
return { ok: true };
} catch (err2) {
return { ok: false, error: err2.message };
}
}
});
ipcMain.handle('fs:mkdir', async (_event, dirPath) => {
try {
fs.mkdirSync(dirPath, { recursive: true });
return { ok: true };
} catch (err) {
return { ok: false, error: err.message };
}
});
ipcMain.handle('fs:copyFile', async (_event, srcPath, destDir) => {
try {
const srcName = path.basename(srcPath);
let destPath = path.join(destDir, srcName);
// 避免重名:追加 _1, _2 ...
let counter = 1;
const ext = path.extname(srcName);
const base = path.basename(srcName, ext);
while (fs.existsSync(destPath)) {
destPath = path.join(destDir, `${base}_${counter}${ext}`);
counter++;
}
fs.mkdirSync(destDir, { recursive: true });
const stat = fs.statSync(srcPath);
if (stat.isDirectory()) {
// 目录递归复制(Node.js 16.7+)
fs.cpSync(srcPath, destPath, { recursive: true });
} else {
fs.copyFileSync(srcPath, destPath);
}
return { ok: true, destPath, destName: path.basename(destPath) };
} catch (err) {
return { ok: false, error: err.message };
}
});
ipcMain.handle('fs:writeFromBuffer', async (_event, destDir, fileName, base64Data) => {
try {
let destPath = path.join(destDir, fileName);
// 避免重名
let counter = 1;
const ext = path.extname(fileName);
const base = path.basename(fileName, ext);
while (fs.existsSync(destPath)) {
destPath = path.join(destDir, `${base}_${counter}${ext}`);
counter++;
}
fs.mkdirSync(destDir, { recursive: true });
const buf = Buffer.from(base64Data, 'base64');
fs.writeFileSync(destPath, buf);
return { ok: true, destPath, destName: path.basename(destPath) };
} catch (err) {
return { ok: false, error: err.message };
}
});
ipcMain.handle('fs:exists', async (_event, filePath) => {
return fs.existsSync(filePath);
});
// ==================== IPC:对话框 ====================
ipcMain.handle('dialog:openFolder', async () => {
const win = BrowserWindow.getFocusedWindow();
const result = await dialog.showOpenDialog(win, {
properties: ['openDirectory'],
title: '选择工作文件夹',
});
if (result.canceled || result.filePaths.length === 0) {
return { canceled: true };
}
return { canceled: false, path: result.filePaths[0] };
});
ipcMain.handle('dialog:openFile', async () => {
const win = BrowserWindow.getFocusedWindow();
const result = await dialog.showOpenDialog(win, {
properties: ['openFile'],
title: '打开文件',
filters: [
{ name: '支持的文件', extensions: [
'txt','json','md','cpp','c','py','m','java','html','htm',
'svg','css','js','ts','xml','yaml','yml','toml',
'csv','log','env','ini','cfg','sh','bat','ps1','sql',
'r','go','rs','rb','php','swift','kt','scala',
'docx','pptx','pdf','xlsx',
'png','jpg','jpeg','gif','bmp','webp',
]},
{ name: '所有文件', extensions: ['*'] },
],
});
if (result.canceled || result.filePaths.length === 0) {
return { canceled: true };
}
const filePath = result.filePaths[0];
const name = path.basename(filePath);
try {
const content = fs.readFileSync(filePath, 'utf-8');
return { canceled: false, path: filePath, name, content };
} catch {
return { canceled: false, path: filePath, name, content: '' };
}
});
ipcMain.handle('dialog:saveFile', async (_event, defaultPath) => {
const win = BrowserWindow.getFocusedWindow();
const result = await dialog.showSaveDialog(win, {
defaultPath: defaultPath,
title: '保存文件',
});
if (result.canceled) return { canceled: true };
return { canceled: false, path: result.filePath };
});
// ==================== IPC:应用信息 ====================
ipcMain.handle('app:getPath', () => AGENT_DIR);
// ===== Python 检测 =====
ipcMain.handle('python:detect', async () => {
const pythonPath = findSystemPython();
let version = '';
let versionOk = false;
try {
const result = execSync(`"${pythonPath}" --version`, { timeout: 5000, stdio: 'pipe' });
version = result.toString().trim();
// 检查版本 >= 3.12
const m = version.match(/Python\s+(\d+)\.(\d+)/i);
if (m) {
const major = parseInt(m[1]), minor = parseInt(m[2]);
versionOk = major > 3 || (major === 3 && minor >= 12);
}
} catch {}
return { pythonPath, version, versionOk };
});
// ===== 选择 Python 可执行文件 =====
ipcMain.handle('dialog:pickPython', async () => {
const win = BrowserWindow.getFocusedWindow();
const result = await dialog.showOpenDialog(win, {
properties: ['openFile'],
title: '选择 Python 可执行文件',
filters: [
{ name: 'Python 可执行文件', extensions: ['exe'] },
],
});
if (result.canceled || result.filePaths.length === 0) {
return { canceled: true };
}
return { canceled: false, path: result.filePaths[0] };
});
// ===== Python 环境安装 =====
ipcMain.handle('python:setup', async (event, pythonPath) => {
const win = BrowserWindow.fromWebContents(event.sender);
const send = (type, data) => {
try { win?.webContents?.send('python:setup-progress', { type, ...data }); } catch {}
};
try {
send('status', { text: '检测 Python...' });
// 验证 Python 可用
try {
execSync(`"${pythonPath}" --version`, { timeout: 5000, stdio: 'pipe' });
} catch {
return { ok: false, error: 'Python 无法启动,请检查路径' };
}
// 确定 venv 路径(在安装目录下,卸载时可一并清理)
const installDir = isPackaged ? path.dirname(app.getPath('exe')) : AGENT_DIR;
const venvDir = path.join(installDir, 'agent_venv');
const venvPython = path.join(venvDir, 'Scripts', 'python.exe');
if (!fs.existsSync(venvPython)) {
send('status', { text: '创建虚拟环境...' });
try {
execSync(`"${pythonPath}" -m venv "${venvDir}"`, { timeout: 60000, stdio: 'pipe' });
} catch (e) {
return { ok: false, error: '创建虚拟环境失败: ' + e.message };
}
} else {
send('status', { text: '使用已有虚拟环境' });
}
// pip install -r requirements.txt
const reqPath = path.join(__dirname, '..', 'requirements.txt');
if (!fs.existsSync(reqPath)) {
return { ok: false, error: '未找到 requirements.txt' };
}
send('status', { text: '安装依赖包...' });
const pipArgs = ['-m', 'pip', 'install', '-r', reqPath];
const proc = spawn(venvPython, pipArgs, {
cwd: path.dirname(venvDir),
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
// 实时流式输出安装日志
let logLines = [];
let stderrBuf = '';
let pendingLine = '';
const flushLine = (line) => {
const trimmed = line.trim();
if (!trimmed) return;
// 过滤掉无意义的进度条和空白行
if (/^\s*\d+%/.test(trimmed)) return;
if (/^\s*[-|/\\]+\s*$/.test(trimmed)) return;
logLines.push(trimmed);
if (logLines.length > 200) logLines = logLines.slice(-150);
send('log', { lines: [trimmed], all: logLines.join('\n') });
};
proc.stdout.on('data', (chunk) => {
const text = chunk.toString();
const parts = (pendingLine + text).split('\n');
pendingLine = parts.pop() || '';
for (const line of parts) flushLine(line);
});
proc.stderr.on('data', (chunk) => {
const text = chunk.toString();
stderrBuf += text;
if (stderrBuf.length > 10000) stderrBuf = stderrBuf.slice(-8000);
const parts = text.split('\n');
// stderr 最后一行可能不完整,但 pip 进度条通常每行独立
for (const line of parts) {
const trimmed = line.trim();
if (trimmed && !trimmed.includes('%')) flushLine(trimmed);
}
});
return new Promise((resolve) => {
proc.on('close', (code) => {
if (code === 0) {
send('done', { venvDir, venvPython });
resolve({ ok: true, venvDir, venvPython });
} else {
const errDetail = stderrBuf.split('\n').filter(l => l.trim()).slice(-10).join('\n');
resolve({ ok: false, error: `pip install 退出码 ${code}${errDetail ? '\n' + errDetail : ''}` });
}
});
proc.on('error', (err) => {
resolve({ ok: false, error: err.message });
});
});
} catch (e) {
send('status', { text: '安装失败' });
return { ok: false, error: e.message };
}
});
// ===== 文件系统 IPC =====
let _fsWatcher = null;
let _watchedPath = null;
ipcMain.handle('fs:watch', async (_event, dirPath) => {
if (_fsWatcher) { _fsWatcher.close(); _fsWatcher = null; }
_watchedPath = dirPath;
try {
_fsWatcher = fs.watch(dirPath, { recursive: true }, (eventType, filename) => {
if (!filename || !mainWindow) return;
const rel = filename.replace(/\\/g, '/');
if (filename.startsWith('.') || filename.includes('node_modules') || filename.includes('__pycache__')) return;
const absPath = path.join(dirPath, filename);
try {
const stat = fs.statSync(absPath);
const file = {
path: rel, name: path.basename(filename),
is_dir: stat.isDirectory(), size: stat.size,
};
mainWindow.webContents.send('fs:changed', { event: eventType, file });
} catch {
// 文件可能已被删除,仍然发送事件让前端感知变化
mainWindow.webContents.send('fs:changed', {
event: eventType,
file: { path: rel, name: path.basename(filename), is_dir: false, size: 0 },
});
}
});
return { ok: true };
} catch (e) {
return { ok: false, error: e.message };
}
});
ipcMain.handle('fs:unwatch', async () => {
if (_fsWatcher) { _fsWatcher.close(); _fsWatcher = null; _watchedPath = null; }
return { ok: true };
});
// ===== 窗口控制(自定义标题栏) =====
ipcMain.on('window:minimize', () => {
if (mainWindow) mainWindow.minimize();
});
// ===== 配置窗口关闭 =====
ipcMain.on('setup:close', () => {
if (setupWindow) setupWindow.close();
else app.quit();
});
let _maximizeLock = false;
ipcMain.on('window:maximize', () => {
if (_maximizeLock || !mainWindow) return;
_maximizeLock = true;
mainWindow.isMaximized() ? mainWindow.unmaximize() : mainWindow.maximize();
setTimeout(() => { _maximizeLock = false; }, 300);
});
ipcMain.on('window:close', () => {
if (mainWindow) mainWindow.close();
});
// 最大化/还原状态变化时通知渲染进程
app.on('browser-window-created', (_, win) => {
win.on('maximize', () => win.webContents.send('window:maximized', true));
win.on('unmaximize', () => win.webContents.send('window:maximized', false));
});
app.on('window-all-closed', () => {
if (isLaunching) return; // setup → main 过渡中,不退出
isQuitting = true;
stopFastAPI();
app.quit();
});
app.on('before-quit', () => {
isQuitting = true;
stopFastAPI();
});
app.on('activate', () => {
if (mainWindow === null) {
createMainWindow();
}
});
// 防止多实例
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
app.quit();
} else {
app.on('second-instance', () => {
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
});
}
+66
View File
@@ -0,0 +1,66 @@
// preload.js — 安全桥接层(桌面版)
// 暴露最小化 API 供 setup.html 和主应用使用
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
platform: process.platform,
isElectron: true,
// ===== 文件系统 =====
readFile: (filePath) => ipcRenderer.invoke('fs:readFile', filePath),
readBinaryFile: (filePath) => ipcRenderer.invoke('fs:readBinaryFile', filePath),
writeFile: (filePath, content) => ipcRenderer.invoke('fs:writeFile', filePath, content),
readDir: (dirPath, depth) => ipcRenderer.invoke('fs:readDir', dirPath, depth ?? 10),
stat: (filePath) => ipcRenderer.invoke('fs:stat', filePath),
rename: (oldPath, newPath) => ipcRenderer.invoke('fs:rename', oldPath, newPath),
deleteFile: (filePath) => ipcRenderer.invoke('fs:delete', filePath),
mkdir: (dirPath) => ipcRenderer.invoke('fs:mkdir', dirPath),
exists: (filePath) => ipcRenderer.invoke('fs:exists', filePath),
copyFile: (srcPath, destDir) => ipcRenderer.invoke('fs:copyFile', srcPath, destDir),
writeFromBuffer: (destDir, fileName, base64Data) => ipcRenderer.invoke('fs:writeFromBuffer', destDir, fileName, base64Data),
// ===== 对话框 =====
pickFolder: () => ipcRenderer.invoke('dialog:openFolder'),
openFile: () => ipcRenderer.invoke('dialog:openFile'),
saveFile: (defaultPath) => ipcRenderer.invoke('dialog:saveFile', defaultPath),
// ===== 应用信息 =====
getAppPath: () => ipcRenderer.invoke('app:getPath'),
// ===== 首次配置 =====
saveConfig: (data) => ipcRenderer.invoke('save-config', data),
launchApp: () => ipcRenderer.send('launch-app'),
isFirstRun: () => ipcRenderer.invoke('is-first-run'),
onSetupComplete: (cb) => {
ipcRenderer.on('setup:complete', () => cb());
},
// ===== 文件系统监听 =====
watchDir: (dirPath) => ipcRenderer.invoke('fs:watch', dirPath),
unwatchDir: () => ipcRenderer.invoke('fs:unwatch'),
onFileChanged: (cb) => {
ipcRenderer.on('fs:changed', (_e, data) => cb(data));
},
// ===== 窗口控制(自定义标题栏) =====
minimizeWindow: () => ipcRenderer.send('window:minimize'),
maximizeWindow: () => ipcRenderer.send('window:maximize'),
closeWindow: () => ipcRenderer.send('window:close'),
onMaximizeChanged: (cb) => {
ipcRenderer.on('window:maximized', (_e, maximized) => cb(maximized));
},
// ===== 配置窗口控制 =====
setupClose: () => ipcRenderer.send('setup:close'),
// ===== Python 环境安装 =====
pythonDetect: () => ipcRenderer.invoke('python:detect'),
pickPython: () => ipcRenderer.invoke('dialog:pickPython'),
pythonSetup: (pythonPath) => ipcRenderer.invoke('python:setup', pythonPath),
onPythonSetupProgress: (cb) => {
const handler = (_e, data) => cb(data);
ipcRenderer.on('python:setup-progress', handler);
return () => ipcRenderer.removeListener('python:setup-progress', handler);
},
});
+622
View File
@@ -0,0 +1,622 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>minor Agent - 首次配置</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
height: 100%; overflow: hidden;
font-family: "Microsoft YaHei", "Segoe UI", system-ui, -apple-system, sans-serif;
background: #f8fafc;
color: #0f172a;
}
/* ===== 自定义标题栏 ===== */
.title-bar {
display: flex; align-items: center;
height: 36px; padding: 0 8px 0 14px;
background: #e2e8f0;
-webkit-app-region: drag;
user-select: none; flex-shrink: 0;
}
.title-bar-name {
font-size: 0.78rem; font-weight: 600;
color: #475569; letter-spacing: 0.02em;
}
.title-bar-actions {
display: flex; gap: 2px; margin-left: auto;
-webkit-app-region: no-drag;
}
.title-bar-btn {
width: 36px; height: 28px; border: none;
background: transparent; color: #64748b;
font-family: "Segoe MDL2 Assets", "Segoe UI", sans-serif;
font-size: 0.65rem; cursor: pointer; border-radius: 4px;
display: flex; align-items: center; justify-content: center;
transition: background 0.15s, color 0.15s;
}
.title-bar-btn:hover { background: rgba(0,0,0,0.08); color: #334155; }
.title-bar-btn--close:hover { background: #e81123; color: #fff; }
/* ===== 内容区域 ===== */
.setup-body {
height: calc(100% - 36px);
overflow-y: auto;
display: flex; align-items: center; justify-content: center;
}
.setup-card {
background: linear-gradient(165deg, #ffffff, #f8fafc);
border: 1px solid rgba(14, 116, 144, 0.12);
border-radius: 16px;
padding: 28px 32px;
width: 540px;
max-width: 96%;
max-height: 94%;
overflow-y: auto;
box-shadow: 0 12px 48px rgba(14, 116, 144, 0.12), 0 4px 12px rgba(15, 23, 42, 0.06);
animation: cardIn 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
margin: auto;
}
@keyframes cardIn {
from { opacity: 0; transform: translateY(20px) scale(0.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
h1 { font-size: 20px; font-weight: 700; margin: 0 0 2px; color: #0f172a; }
.subtitle { font-size: 13px; color: #64748b; margin-bottom: 6px; }
.step-indicator {
display: flex; gap: 8px; margin-bottom: 18px; align-items: center;
font-size: 13px; color: #94a3b8;
}
.step-dot {
width: 10px; height: 10px; border-radius: 50%;
background: #e2e8f0; transition: background 0.3s;
}
.step-dot.active { background: #0e7490; }
.step-dot.done { background: #16a34a; }
.page { display: none; }
.page.active { display: block; }
.section-title {
font-size: 14px; font-weight: 600; color: #334155;
margin-top: 14px; margin-bottom: 4px;
}
.section-title:first-of-type { margin-top: 0; }
.hint { font-size: 11px; color: #94a3b8; margin: 3px 0 6px; }
.hint a { color: #0e7490; }
.optional-badge {
display: inline-block; background: #f1f5f9;
color: #94a3b8; font-size: 10px; padding: 1px 6px;
border-radius: 4px; margin-left: 6px; vertical-align: middle;
}
.required-mark { color: #ef4444; margin-left: 2px; }
label {
display: block; font-size: 13px; color: #475569;
margin-bottom: 4px; margin-top: 14px;
}
label:first-of-type { margin-top: 0; }
input, select {
width: 100%; padding: 9px 12px; border-radius: 8px;
border: 1px solid #cbd5e1; background: #f8fafc;
color: #0f172a; font-size: 14px; outline: none;
transition: border-color 0.2s, background 0.2s;
}
input:focus, select:focus { border-color: #0e7490; background: #fff; }
input::placeholder { color: #94a3b8; }
.btn-row { display: flex; gap: 10px; margin-top: 22px; }
button {
flex: 1; padding: 10px 14px; border: none; border-radius: 8px;
font-size: 14px; font-weight: 600; cursor: pointer;
transition: opacity 0.2s, transform 0.1s, background 0.2s;
}
button:hover { opacity: 0.9; }
button:active { transform: scale(0.98); }
button:disabled { opacity: 0.45; cursor: not-allowed; transform: none; }
.btn-primary {
background: linear-gradient(135deg, #0e7490, #0891b2); color: #fff;
}
.btn-secondary { background: #f1f5f9; color: #475569; }
.btn-secondary:hover { background: #e2e8f0; color: #334155; }
.divider {
border: none; border-top: 1px solid #e2e8f0; margin: 16px 0;
}
.error {
color: #ef4444; font-size: 12px; margin-top: 10px; display: none;
white-space: pre-wrap; max-height: 120px; overflow-y: auto;
}
.error.show { display: block; }
.two-col { display: flex; gap: 12px; }
.two-col > div { flex: 1; }
.two-col label { margin-top: 0; }
.path-row { display: flex; gap: 8px; align-items: center; }
.path-row input { flex: 1; }
.btn-browse {
flex: 0 0 auto; padding: 9px 14px;
background: #f1f5f9; color: #475569; border-radius: 8px;
border: 1px solid #cbd5e1; cursor: pointer; font-size: 13px;
font-weight: 500; -webkit-app-region: no-drag;
}
.btn-browse:hover { background: #e2e8f0; color: #334155; }
/* ===== 安装日志 ===== */
.setup-progress { margin-top: 14px; }
.progress-status { font-size: 13px; color: #475569; margin-bottom: 6px; }
.install-log {
font-family: "Cascadia Code", "Consolas", monospace;
font-size: 11px; color: #64748b;
background: #f1f5f9; border: 1px solid #e2e8f0;
border-radius: 6px; padding: 8px 10px;
max-height: 180px; overflow-y: auto;
white-space: pre-wrap; word-break: break-all;
line-height: 1.5;
}
</style>
</head>
<body>
<!-- 自定义标题栏 -->
<header class="title-bar">
<span class="title-bar-name">minor Agent</span>
<div class="title-bar-actions">
<button class="title-bar-btn title-bar-btn--close" id="btnClose" title="关闭">&#xE8BB;</button>
</div>
</header>
<!-- 内容区域 -->
<div class="setup-body">
<div class="setup-card">
<h1>minor Agent</h1>
<p class="subtitle">首次使用 · 请完成以下配置</p>
<div class="step-indicator">
<span class="step-dot active" id="dot1"></span>
<span>环境准备</span>
<span style="margin:0 4px;color:#94a3b8;">→</span>
<span class="step-dot" id="dot2"></span>
<span>模型</span>
<span style="margin:0 4px;color:#94a3b8;">→</span>
<span class="step-dot" id="dot3"></span>
<span>高级</span>
</div>
<!-- 第1页 -->
<form class="page active" id="page1">
<div class="section-title">Python 环境准备<span class="required-mark">*</span></div>
<p class="hint">minor Agent 需要 Python 3.12+。如果系统已安装 Python,将自动检测。</p>
<label for="pythonPath">Python 可执行文件路径</label>
<div class="path-row">
<input id="pythonPath" type="text" placeholder="自动检测中..." />
<button type="button" class="btn-browse" id="browsePython">浏览...</button>
</div>
<div class="setup-progress" id="setupProgress" hidden>
<div class="progress-status" id="progressStatus">准备中...</div>
<div class="install-log" id="installLog"></div>
</div>
<div class="btn-row">
<button type="button" class="btn-primary" id="btnSetupPython" disabled>安装依赖环境</button>
</div>
<p class="error" id="pythonError"></p>
<hr class="divider" />
<div class="section-title">用户信息</div>
<label for="nickname">您的昵称</label>
<input id="nickname" type="text" placeholder="输入昵称,方便识别" />
<hr class="divider" />
<div class="section-title">邮件服务<span class="optional-badge">可选</span></div>
<p class="hint">不填则无法使用邮件收发功能。可跳过。<br/>目前支持 QQ邮箱、网易163邮箱。</p>
<label for="emailAddr">邮箱地址</label>
<input id="emailAddr" type="email" placeholder="example@qq.com" />
<label for="emailCode">邮箱授权码</label>
<input id="emailCode" type="password" placeholder="SMTP 授权码(非登录密码)" />
<hr class="divider" />
<div class="section-title">联网搜索引擎<span class="optional-badge">可选</span></div>
<p class="hint">不填则无法使用 web_search 联网搜索功能。可跳过。<br/>支持 Tavily(免费额度)/ Firecrawl / 火山引擎。<br/>请在设置中配置 WEB_SEARCH_ENGINE 选择引擎,默认 Tavily。</p>
<label for="tavilyKey">搜索 API Key (WEB_SEARCH_API_KEY)</label>
<input id="webSearchKey" type="password" placeholder="tvly-xxx... 或 fc-xxx... 或 AIza... 等" />
<hr class="divider" />
<div class="section-title">ASR / TTS / RAG 服务器<span class="optional-badge">可选</span></div>
<p class="hint">语音识别(ASR)、语音合成(TTS)、知识库(RAG)需独立部署的服务端。<br/>不填则对应功能不可用。</p>
<label for="asrUrl">ASR 服务地址(语音转文字)</label>
<input id="asrUrl" type="text" placeholder="http://localhost:8901" />
<label for="ttsUrl">TTS 服务地址(文字转语音)</label>
<input id="ttsUrl" type="text" placeholder="http://localhost:8902" />
<label for="ragUrl">RAG 服务地址(知识库检索)</label>
<input id="ragUrl" type="text" placeholder="http://localhost:8903" />
<div class="btn-row">
<button type="button" class="btn-primary" id="nextPage1" disabled>下一页 →</button>
</div>
</form>
<!-- 第2页 -->
<form class="page" id="page2">
<div class="section-title">LLM 对话模型<span class="required-mark">*</span></div>
<p class="hint">Agent 核心推理模型,必须填写。</p>
<label for="model">Model Name / ID<span class="required-mark">*</span></label>
<input id="model" type="text" placeholder="例如:gpt-4o / Qwen3.5-122B-A10B" required />
<label for="apiKey">API Key<span class="required-mark">*</span></label>
<input id="apiKey" type="password" placeholder="sk-xxx..." required />
<label for="baseUrl">API Base URL<span class="required-mark">*</span></label>
<input id="baseUrl" type="text" placeholder="https://api.openai.com/v1" required />
<label for="timeout">超时时间(秒)</label>
<input id="timeout" type="number" value="60" min="10" max="600" />
<hr class="divider" />
<div class="btn-row">
<button type="button" class="btn-secondary" id="prevPage2">← 返回上一页</button>
<button type="button" class="btn-primary" id="nextPage2">下一页 →</button>
</div>
</form>
<!-- 第3页 -->
<form class="page" id="page3">
<div class="section-title">Agent 运行参数</div>
<p class="hint">以下均有默认值,一般无需修改。</p>
<label for="llmContextWindow">LLM 上下文窗口大小</label>
<input id="llmContextWindow" type="number" value="262144" />
<div class="two-col">
<div>
<label for="miniCompressRate">轻度压缩率</label>
<input id="miniCompressRate" type="number" value="0.4" step="0.1" min="0.1" max="0.9" />
</div>
<div>
<label for="hardCompressRate">深度压缩率</label>
<input id="hardCompressRate" type="number" value="0.7" step="0.1" min="0.1" max="0.9" />
</div>
</div>
<label for="currentTurns">保留最近 N 轮对话</label>
<input id="currentTurns" type="number" value="4" min="1" max="20" />
<label for="imgSize">图片尺寸(像素)</label>
<input id="imgSize" type="number" value="768" min="256" max="2048" />
<hr class="divider" />
<div class="section-title">RAG 知识库参数</div>
<div class="two-col">
<div>
<label for="ragChunkSize">文档分块大小</label>
<input id="ragChunkSize" type="number" value="500" min="100" max="5000" />
</div>
<div>
<label for="ragChunkOverlap">分块重叠量</label>
<input id="ragChunkOverlap" type="number" value="50" min="0" max="500" />
</div>
</div>
<hr class="divider" />
<div class="section-title">GUI 截图参数</div>
<div class="two-col">
<div>
<label for="groundingWidth">截图宽度</label>
<input id="groundingWidth" type="number" value="1000" min="400" max="4000" />
</div>
<div>
<label for="groundingHeight">截图高度</label>
<input id="groundingHeight" type="number" value="1000" min="400" max="4000" />
</div>
</div>
<hr class="divider" />
<div class="section-title">循环检测参数</div>
<div class="two-col">
<div>
<label for="loopStaleRounds">TodoList 停滞轮数</label>
<input id="loopStaleRounds" type="number" value="20" min="5" max="100" />
</div>
<div>
<label for="loopRepeatedWarn">重复工具警告轮数</label>
<input id="loopRepeatedWarn" type="number" value="3" min="1" max="20" />
</div>
</div>
<label for="loopRepeatedEnd">重复工具终止轮数</label>
<input id="loopRepeatedEnd" type="number" value="5" min="2" max="50" />
<label for="sendFileSizeLimit">发送文件大小限制(MB)</label>
<input id="sendFileSizeLimit" type="number" value="30" min="1" max="500" />
<hr class="divider" />
<div class="section-title">定时任务存储方式</div>
<p class="hint">定时任务数据的存储后端。当前仅支持 JSON 本地文件;MySQL 数据库为预留选项(开发中,暂不可用)。</p>
<label for="cronStorageBackend">存储后端</label>
<select id="cronStorageBackend">
<option value="json" selected>JSON(本地文件,推荐)</option>
<option value="mysql" disabled>MySQL 数据库(开发中,暂不可用)</option>
</select>
<div class="btn-row">
<button type="button" class="btn-secondary" id="prevPage3">← 返回上一页</button>
<button type="submit" class="btn-primary" id="submitBtn">保存并启动</button>
</div>
<p class="error" id="errorMsg"></p>
</form>
</div>
</div>
<script>
// ===== 标题栏关闭按钮 =====
document.getElementById('btnClose').addEventListener('click', () => {
if (window.electronAPI?.setupClose) {
window.electronAPI.setupClose();
}
});
// ===== 配置页逻辑 =====
const page1 = document.getElementById('page1');
const page2 = document.getElementById('page2');
const page3 = document.getElementById('page3');
const dot1 = document.getElementById('dot1');
const dot2 = document.getElementById('dot2');
const dot3 = document.getElementById('dot3');
const errorMsg = document.getElementById('errorMsg');
const submitBtn = document.getElementById('submitBtn');
const pythonPathInput = document.getElementById('pythonPath');
const btnSetupPython = document.getElementById('btnSetupPython');
const nextPage1Btn = document.getElementById('nextPage1');
const setupProgress = document.getElementById('setupProgress');
const progressStatus = document.getElementById('progressStatus');
const pythonError = document.getElementById('pythonError');
const installLog = document.getElementById('installLog');
let pythonReady = false;
let setupPythonPath = '';
// ===== 自动检测 Python =====
(async function autoDetectPython() {
try {
const api = window.electronAPI;
if (!api || !api.pythonDetect) {
pythonPathInput.placeholder = '请手动输入 Python 路径';
btnSetupPython.disabled = false;
return;
}
const result = await api.pythonDetect();
if (result.pythonPath && result.pythonPath !== 'python') {
pythonPathInput.value = result.pythonPath;
if (result.version) pythonPathInput.title = result.version;
}
if (result.version) {
if (result.versionOk) {
pythonPathInput.placeholder = result.version + ' - 已检测到';
} else {
pythonPathInput.placeholder = result.version + ' - 需要 Python 3.12+';
showPythonError('检测到的 Python 版本过低,需要 Python 3.12+,请手动选择');
}
}
} catch (e) {
pythonPathInput.placeholder = '自动检测失败,请手动选择';
}
if (pythonPathInput.value.trim()) btnSetupPython.disabled = false;
})();
// ===== 浏览按钮 =====
document.getElementById('browsePython').addEventListener('click', async () => {
try {
const api = window.electronAPI;
if (!api || !api.pickPython) return;
const result = await api.pickPython();
if (result.canceled || !result.path) return;
pythonPathInput.value = result.path;
pythonPathInput.placeholder = '';
hidePythonError();
btnSetupPython.disabled = false;
} catch (e) {
showPythonError('选择文件失败: ' + e.message);
}
});
pythonPathInput.addEventListener('input', () => {
btnSetupPython.disabled = !pythonPathInput.value.trim();
if (pythonPathInput.value.trim()) hidePythonError();
});
// ===== 安装依赖环境 =====
btnSetupPython.addEventListener('click', async () => {
const pyPath = pythonPathInput.value.trim();
if (!pyPath) return;
btnSetupPython.disabled = true;
pythonReady = false;
nextPage1Btn.disabled = true;
setupProgress.hidden = false;
progressStatus.textContent = '准备中...';
installLog.textContent = '';
hidePythonError();
const unsub = window.electronAPI.onPythonSetupProgress((data) => {
switch (data.type) {
case 'status':
progressStatus.textContent = data.text;
break;
case 'log':
if (data.all != null) {
installLog.textContent = data.all;
installLog.scrollTop = installLog.scrollHeight;
}
break;
}
});
try {
const result = await window.electronAPI.pythonSetup(pyPath);
unsub();
if (result.ok) {
progressStatus.textContent = '环境安装完成!';
installLog.textContent += '\n\n' + '虚拟环境: ' + result.venvDir;
installLog.scrollTop = installLog.scrollHeight;
pythonReady = true;
setupPythonPath = result.venvPython;
pythonPathInput.value = result.venvPython;
nextPage1Btn.disabled = false;
btnSetupPython.textContent = '重新安装';
btnSetupPython.disabled = false;
} else {
showPythonError(result.error || '安装失败');
progressStatus.textContent = '安装失败';
btnSetupPython.disabled = false;
}
} catch (e) {
unsub();
showPythonError('安装异常: ' + e.message);
progressStatus.textContent = '安装异常';
btnSetupPython.disabled = false;
}
});
// ===== 页面导航 =====
nextPage1Btn.addEventListener('click', () => {
if (!pythonReady && pythonPathInput.value.trim()) {
showPythonError('请先点击"安装依赖环境"完成 Python 环境准备');
return;
}
page1.classList.remove('active'); page2.classList.add('active');
dot1.classList.remove('active'); dot1.classList.add('done');
dot2.classList.add('active');
});
document.getElementById('prevPage2').addEventListener('click', () => {
page2.classList.remove('active'); page1.classList.add('active');
dot2.classList.remove('active');
dot1.classList.remove('done'); dot1.classList.add('active');
});
document.getElementById('nextPage2').addEventListener('click', () => {
const model = document.getElementById('model').value.trim();
const apiKey = document.getElementById('apiKey').value.trim();
const baseUrl = document.getElementById('baseUrl').value.trim();
if (!model || !apiKey || !baseUrl) {
alert('请填写 Model Name / ID、API Key 和 API Base URL(标记 * 的必填项)');
return;
}
page2.classList.remove('active'); page3.classList.add('active');
dot2.classList.remove('active'); dot2.classList.add('done');
dot3.classList.add('active');
});
document.getElementById('prevPage3').addEventListener('click', () => {
page3.classList.remove('active'); page2.classList.add('active');
dot3.classList.remove('active');
dot2.classList.remove('done'); dot2.classList.add('active');
});
nextPage1Btn.disabled = true;
// ===== 表单提交 =====
document.getElementById('page3').addEventListener('submit', async (e) => {
e.preventDefault();
submitBtn.disabled = true;
submitBtn.textContent = '正在验证...';
errorMsg.classList.remove('show');
const getVal = id => document.getElementById(id).value.trim();
const getNum = id => parseInt(document.getElementById(id).value) || 0;
const getFloat = id => parseFloat(document.getElementById(id).value) || 0;
const data = {
nickname: getVal('nickname'),
pythonPath: setupPythonPath || getVal('pythonPath'),
model: getVal('model'),
apiKey: getVal('apiKey'),
baseUrl: getVal('baseUrl').replace(/\/+$/, ''),
timeout: getNum('timeout') || 60,
emailAddr: getVal('emailAddr'),
emailCode: getVal('emailCode'),
webSearchKey: getVal('webSearchKey'),
asrUrl: getVal('asrUrl'),
ttsUrl: getVal('ttsUrl'),
ragUrl: getVal('ragUrl'),
llmContextWindow: getNum('llmContextWindow') || 262144,
miniCompressRate: getFloat('miniCompressRate') || 0.4,
hardCompressRate: getFloat('hardCompressRate') || 0.7,
currentTurns: getNum('currentTurns') || 4,
imgSize: getNum('imgSize') || 768,
ragChunkSize: getNum('ragChunkSize') || 500,
ragChunkOverlap: getNum('ragChunkOverlap') || 50,
groundingWidth: getNum('groundingWidth') || 1000,
groundingHeight: getNum('groundingHeight') || 1000,
loopStaleRounds: getNum('loopStaleRounds') || 20,
loopRepeatedWarn: getNum('loopRepeatedWarn') || 3,
loopRepeatedEnd: getNum('loopRepeatedEnd') || 5,
sendFileSizeLimit: getNum('sendFileSizeLimit') || 30,
cronStorageBackend: getVal('cronStorageBackend') || 'json',
};
if (!data.apiKey || !data.baseUrl || !data.model) {
showError('请返回第2页填写模型 ID、API Key 和 Base URL(标记 * 的必填项)');
return;
}
try {
const result = await window.electronAPI.saveConfig(data);
if (result.success) {
submitBtn.textContent = '配置成功,启动中...';
window.electronAPI.launchApp();
} else {
showError(result.error || '保存配置失败');
}
} catch (err) {
showError('通信失败: ' + err.message);
}
});
function showError(msg) {
errorMsg.textContent = msg;
errorMsg.classList.add('show');
submitBtn.disabled = false;
submitBtn.textContent = '保存并启动';
}
function showPythonError(msg) {
pythonError.textContent = msg;
pythonError.classList.add('show');
}
function hidePythonError() {
pythonError.classList.remove('show');
}
</script>
</body>
</html>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

+22
View File
@@ -0,0 +1,22 @@
# README 展示图片说明
本目录存放 GitHub 项目页 README 中引用的展示图片。下方表格列出每张图的**文件名、引用位置、建议内容与尺寸**,按需制作后放入本目录即可(README 已用相对路径 `./git_image/xxx.png` 引用)。
| 文件名 | 在 README 中的位置 | 建议内容 | 建议尺寸 |
|--------|-------------------|----------|----------|
| `banner.png` | 顶部主图 | Agent 主界面截图(侧边栏 + 聊天区 + 工具调用面板 + Agent 面板同时可见,体现多模态对话与工具执行) | 宽 920px,比例 16:9 |
| `architecture.png` | 「项目架构」整体分层 | 系统分层图:Electron 主进程 → FastAPI → Agent 编排层 → LangGraph ReAct 图 → 工具/技能(可用 Mermaid 或 draw.io 导出 PNG) | 宽 860px |
| `react-loop.png` | 「ReAct 循环」节 | ReAct 循环流程图:`agent → tools → process_tool_artifact`,标注压缩 / 循环检测 / 视觉闭环注入 | 宽 780px |
| `wizard.png` | 「快速开始 → 桌面应用」 | 首次配置向导截图(3 步:Python 环境 / LLM 设置 / 高级参数) | 宽 720px |
| `multi-agent.png` | (可选补充)「多 Agent 模式」 | supervisor ↔ parallel_sub_agents 调度示意图 | 宽 720px |
| `desktop-demo.gif` | (可选补充)顶部或功能亮点 | GUI 自动化操作录屏(如自动填表单 / 操作 ERP),10–15s 循环 | 宽 720px |
## 制作建议
- **截图**:先 `npm start` 启动桌面端,进入主界面后用系统截图工具,裁掉无关边框
- **流程图**:推荐用 [Mermaid](https://mermaid.live/) 画好后导出 PNG,或 draw.io / Excalidraw
- **格式**:截图优先 PNG(无损);录屏用 GIF 或 MP4(GitHub 支持 MP4 内联播放)
- **体积**:单张控制在 500KB 以内,可用 [TinyPNG](https://tinypng.com/) 压缩
- **命名**:保持与上表一致,README 引用路径无需改动
> 放入图片前,README 会显示破损图标占位;放入后自动渲染。
Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 403 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 KiB

+184
View File
@@ -0,0 +1,184 @@
# LLM 推理服务
为 [minor Agent](../README.md) 提供本地化推理后端,包含 **6 个独立服务**,均跑在 GPU 服务器上,通过 OpenAI 兼容 / 自定义 HTTP API 暴露。可整体部署,也可按需启用。
## 服务总览
| 服务 | 端口 | 模型 | 启动脚本 | Python 服务端 | 关键端点 |
|------|:----:|------|----------|---------------|----------|
| **LLM** | 8900 | Qwen3.6-35B-A3B-FP8 | `start_llm.sh` | vLLM 内置 | `/v1/chat/completions` |
| **ASR** | 8901 | Qwen3-ASR-1.7B | `start_asr.sh` | vLLM 内置 | `/v1/chat/completions` |
| **TTS** | 8902 | VoxCPM 1.5 / 2 | `start_streaming_tts.sh` | `streaming_tts_server.py` | `/stream_tts` |
| **RAG** | 8903 | Embedding-0.6B + Reranker-4B | `start_rag_server.sh` | `rag_server.py` | `/embed` `/rerank` |
| **Image Gen** | 8904 | Z-Image-Turbo | `start_image_gen.sh` | `image_gen_server.py` | `/generate` |
> 所有脚本 / Python 文件均通过 `os.path.dirname(__file__)` 与 `${SCRIPT_DIR}` 动态定位模型路径,**目录整体迁移无需改代码**。
---
## 一、部署流程
### 1. 上传到服务器
```bash
scp -P <SSH_PORT> -r Agent/llm_server/* <user>@<your-gpu-server>:~/llm_server/
ssh -o ServerAliveInterval=60 -o ServerAliveCountMax=3 -p <SSH_PORT> <user>@<your-gpu-server>
```
### 2. 服务器环境
```bash
cd ~/llm_server
chmod +x *.sh
# HuggingFace 镜像加速(国内必需)
export HF_ENDPOINT=https://hf-mirror.com
export HF_HUB_ENABLE_XET=0
echo 'export HF_ENDPOINT=https://hf-mirror.com' >> ~/.bashrc
echo 'export HF_HUB_ENABLE_XET=0' >> ~/.bashrc
python3 -m venv myenv && source myenv/bin/activate
# 一次性安装全部依赖
pip install vllm modelscope transformers fastapi uvicorn torch numpy torchaudio soundfile \
bitsandbytes huggingface_hub voxcpm diffusers
sudo apt update && sudo apt install -y ffmpeg libavcodec-dev libavformat-dev libavutil-dev \
libavdevice-dev libavfilter-dev libswscale-dev libswresample-dev
```
### 3. 下载模型
所有模型统一存放 `~/llm_server/models/`:
| 模型 | 命令 |
|------|------|
| Qwen3.6-35B-A3B-FP8 (LLM) | `modelscope download --model Qwen/Qwen3.6-35B-A3B-FP8 --local_dir ./models/Qwen/Qwen3___6-35B-A3B-FP8` |
| Qwen3-ASR-1.7B | `modelscope download --model Qwen/Qwen3-ASR-1.7B --local_dir ./models/Qwen/Qwen3-ASR-1.7B` |
| Qwen3-Embedding-0.6B | `modelscope download --model Qwen/Qwen3-Embedding-0.6B --local_dir ./models/Qwen/Qwen3-Embedding-0___6B` |
| Qwen3-Reranker-4B | `modelscope download --model Qwen/Qwen3-Reranker-4B --local_dir ./models/Qwen/Qwen3-Reranker-4B` |
| VoxCPM 1.5 (推荐) | `modelscope download --model OpenBMB/VoxCPM1.5 --local_dir ./models/VoxCPM1__5` |
| VoxCPM 2 (音质更佳) | `modelscope download --model OpenBMB/VoxCPM2 --local_dir ./models/VoxCPM2` |
| Z-Image-Turbo | `modelscope download --model Tongyi-MAI/Z-Image-Turbo --local_dir ./models/Tongyi-MAI/Z-Image-Turbo` |
完成后目录结构:
```
~/llm_server/models/
├── Qwen/
│ ├── Qwen3___6-35B-A3B-FP8/ # LLM
│ ├── Qwen3-ASR-1.7B/ # ASR
│ ├── Qwen3-Embedding-0___6B/ # Embedding
│ └── Qwen3-Reranker-4B/ # Reranker
├── VoxCPM1__5/ # TTS
└── Tongyi-MAI/Z-Image-Turbo/ # Image Gen
```
---
## 二、启动与测试
每个服务独立启动,互不依赖。所有 `start_*.sh` 支持环境变量覆盖:
| 环境变量 | 作用 | 适用脚本 |
|----------|------|----------|
| `HOST_PORT` | 服务端口 | 全部 |
| `LOCAL_MODEL_DIR` / `MODEL_PATH` / `VOXCPM_MODEL_PATH` | 模型目录 | 全部 |
| `CUDA_VISIBLE_DEVICES` | GPU 设备号 | 全部 |
| `SPECIFIC_GPU` | 单 GPU 指定(写 `CUDA_VISIBLE_DEVICES`) | image_gen / tts |
### LLM · 8900
```bash
bash start_llm.sh
# 测试
python3 test_llm.py --url http://127.0.0.1:8900 # 非流式
python3 test_llm.py --url http://127.0.0.1:8900 --stream # 流式
```
> vLLM 加载 Qwen3.6-35B-A3B-FP8,OpenAI 兼容 API,使用 `chat_template.jinja` 格式化对话。
### ASR · 8901
```bash
bash start_asr.sh
# 测试
python3 test_asr.py --audio /path/to/audio.wav --url http://127.0.0.1:8901/v1/chat/completions
```
### TTS · 8902(流式)
```bash
bash start_streaming_tts.sh
# 测试(流式返回 wav 分块)
curl -X POST http://127.0.0.1:8902/stream_tts \
-H "Content-Type: application/json" \
-d '{"text": "你好,这是一个流式语音合成测试。", "request_id": "test-1"}'
```
> VoxCPM 流式合成,边生成边返回 wav chunk,配合 `reference_audio.wav` 控制音色。
### RAG · 8903(Embedding + Reranker)
```bash
bash start_rag_server.sh
python3 test_rag.py
```
### Image Gen · 8904
```bash
LOCAL_MODEL_DIR=./models/Tongyi-MAI/Z-Image-Turbo SPECIFIC_GPU=3 bash start_image_gen.sh
# 测试
curl -X POST http://127.0.0.1:8904/generate \
-H "Content-Type: application/json" \
-d '{"prompt": "一只橘猫坐在窗台上,阳光洒落", "width": 1024, "height": 1024, "return_format": "base64"}' \
| python3 -c "import sys,json; d=json.load(sys.stdin); print(f'成功:{d.get(\"success\")} 尺寸:{d.get(\"width\")}x{d.get(\"height\")} 耗时:{d.get(\"elapsed_seconds\",\"?\")}s')"
```
> Z-Image-Turbo 基于 Diffusers,**9 步推理**生成 1024×1024 图片,`return_format=base64` 直接返回内联数据。
---
## 三、本地端口映射(开发调试)
将远程端口映射到本地,用 `http://localhost:8900` 直连远程服务:
```bash
# 一次性映射全部端口
ssh -fN -o ServerAliveInterval=60 -o ServerAliveCountMax=3 \
-L 8900:0.0.0.0:8900 -L 8901:0.0.0.0:8901 -L 8902:0.0.0.0:8902 \
-L 8903:0.0.0.0:8903 -L 8904:0.0.0.0:8904 \
<user>@<your-gpu-server> -p <SSH_PORT>
```
验证映射(HTTP 200 即通):
```bash
for p in 8900 8901 8902 8903 8904; do
curl -o /dev/null -s -w "$p: %{http_code}\n" http://localhost:$p/health
done
```
---
## 四、停止服务
```bash
for p in 8900 8901 8902 8903 8904; do kill $(lsof -t -i:$p); done
```
---
## 五、接入 Agent
在 minor Agent 配置向导(或 `env_config.json`)中填入对应地址即可启用各能力:
| Agent 能力 | 配置字段 | 示例值 |
|-----------|----------|--------|
| LLM 对话 | `models[].base_url` | `http://localhost:8900/v1` |
| 语音识别 | `ASR_BASE_URL` | `http://localhost:8901/v1` |
| 语音合成 | `STREAMING_TTS_URL` | `http://localhost:8902` |
| 知识库检索 | `RAG_BASE_URL` | `http://localhost:8903` |
| 图像生成 | (由 `image_gen` 工具读取) | `http://localhost:8904` |
> 💡 不必自建本服务,Agent 也支持任何 **OpenAI 兼容 API**(云端 Qwen / DeepSeek / OpenAI 等),填入 `base_url` + `api_key` 即可。
+123
View File
@@ -0,0 +1,123 @@
{%- set enable_thinking = enable_thinking | default(false) %}
{%- if tools %}
{{- '<|im_start|>system\n' }}
{%- if messages[0].role == 'system' %}
{%- if messages[0].content is string %}
{{- messages[0].content }}
{%- else %}
{%- for content in messages[0].content %}
{%- if content.type == 'image' or 'image' in content or 'image_url' in content %}
{{- "<|vision_start|><|image_pad|><|vision_end|>" }}
{%- elif content.type == 'audio' or 'audio' in content or 'audio_url' in content %}
{{- "<|audio_start|><|audio_pad|><|audio_end|>" }}
{%- elif content.type == 'video' or 'video' in content %}
{{- "<|vision_start|><|video_pad|><|vision_end|>" }}
{%- elif content.type == 'text' %}
{{- content.text }}
{%- endif %}
{%- endfor %}
{%- endif %}
{%- endif %}
{{- '\n\n' }}
{{- "# Tools\n\nYou may call one or more functions to assist with the user query.\n\nYou are provided with function signatures within <tools></tools> XML tags:\n<tools>" }}
{%- for tool in tools %}
{{- "\n" }}
{{- tool | tojson }}
{%- endfor %}
{{- "\n</tools>\n\nFor each function call, return a json object with function name and arguments within <tool_call></tool_call> XML tags:\n<tool_call>\n{\"name\": <function-name>, \"arguments\": <args-json-object>}\n</tool_call><|im_end|>\n" }}
{%- else %}
{%- if messages[0].role == 'system' %}
{%- if messages[0].content is string %}
{{- '<|im_start|>system\n' + messages[0].content + '<|im_end|>\n' }}
{%- else %}
{%- for content in messages[0].content %}
{%- if content.type == 'image' or 'image' in content or 'image_url' in content %}
{{- '<|im_start|>system\n' +"<|vision_start|><|image_pad|><|vision_end|>"+ '<|im_end|>\n' }}
{%- elif content.type == 'audio' or 'audio' in content or 'audio_url' in content %}
{{- '<|im_start|>system\n' +"<|audio_start|><|audio_pad|><|audio_end|>"+ '<|im_end|>\n' }}
{%- elif content.type == 'video' or 'video' in content %}
{{- '<|im_start|>system\n' +"<|vision_start|><|video_pad|><|vision_end|>"+ '<|im_end|>\n' }}
{%- elif content.type == 'text' %}
{{- '<|im_start|>system\n' +content.text+ '<|im_end|>\n' }}
{%- endif %}
{%- endfor %}
{%- endif %}
{%- endif %}
{%- endif %}
{%- set ns = namespace(multi_step_tool=true, last_query_index=messages|length - 1) %}
{%- for message in messages[::-1] %}
{%- set index = (messages|length - 1) - loop.index0 %}
{%- if ns.multi_step_tool and message.role == "user" and message.content is string and not(message.content.startswith('<tool_response>') and message.content.endswith('</tool_response>')) %}
{%- set ns.multi_step_tool = false %}
{%- set ns.last_query_index = index %}
{%- endif %}
{%- endfor %}
{%- for message in messages %}
{%- if message.content is string %}
{%- set content = message.content %}
{%- else %}
{%- set content = namespace(text="") %}
{%- for mcontent in message.content %}
{%- if mcontent.type == 'image' or 'image' in mcontent or 'image_url' in mcontent %}
{%- set content.text = content.text~"<|vision_start|><|image_pad|><|vision_end|>" %}
{%- elif mcontent.type == 'audio' or 'audio' in mcontent or 'audio_url' in mcontent %}
{%- set content.text = content.text~"<|audio_start|><|audio_pad|><|audio_end|>" %}
{%- elif mcontent.type == 'video' or 'video' in mcontent %}
{%- set content.text = content.text~"<|vision_start|><|video_pad|><|vision_end|>" %}
{%- elif mcontent.type == 'text' %}
{%- set content.text = content.text~mcontent.text %}
{%- endif %}
{%- endfor %}
{%- set content = content.text %}
{%- endif %}
{%- if (message.role == "user") or (message.role == "system" and not loop.first) %}
{{- '<|im_start|>' + message.role + '\n' + content + '<|im_end|>' + '\n' }}
{%- elif message.role == "assistant" %}
{%- set reasoning_content = "" %}
{%- if message.reasoning_content is string %}
{%- set reasoning_content = message.reasoning_content %}
{%- else %}
{%- if '</think>' in content %}
{%- set reasoning_content = content.split('</think>')[0].rstrip('\n').split('<think>')[-1].lstrip('\n') %}
{%- set content = content.split('</think>')[-1].lstrip('\n') %}
{%- endif %}
{%- endif %}
{%- if loop.index0 > ns.last_query_index %}
{%- if loop.last or (not loop.last and reasoning_content) %}
{{- '<|im_start|>' + message.role + '\n<think>\n' + reasoning_content.strip("\n") + '\n</think>\n\n' + content.lstrip('\n') }}
{%- else %}
{{- '<|im_start|>' + message.role + '\n' + content }}
{%- endif %}
{%- else %}
{{- '<|im_start|>' + message.role + '\n' + content }}
{%- endif %}
{%- if message.tool_calls %}
{%- for tool_call in message.tool_calls %}
{%- if (loop.first and content) or (not loop.first) %}{{- '\n' }}{%- endif %}
{%- if tool_call.function %}
{%- set tool_call = tool_call.function %}
{%- endif %}
{{- '<tool_call>\n{"name": "' }}
{{- tool_call.name }}
{{- '", "arguments": ' }}
{%- if tool_call.arguments is string %}
{{- tool_call.arguments }}
{%- else %}
{{- tool_call.arguments | tojson }}
{%- endif %}
{{- '}\n</tool_call>' }}
{%- endfor %}
{%- endif %}
{{- '<|im_end|>\n' }}
{%- elif message.role == "tool" %}
{%- if loop.first or (messages[loop.index0 - 1].role != "tool") %}{{- '<|im_start|>user' }}{%- endif %}
{{- '\n<tool_response>\n' }}
{{- content }}
{{- '\n</tool_response>' }}
{%- if loop.last or (messages[loop.index0 + 1].role != "tool") %}{{- '<|im_end|>\n' }}{%- endif %}
{%- endif %}
{%- endfor %}
{%- if add_generation_prompt %}
{{- '<|im_start|>assistant\n' }}
{%- if enable_thinking is defined and enable_thinking is false %}{{- '<think>\n\n</think>\n\n' }}{%- endif %}
{%- endif %}
+207
View File
@@ -0,0 +1,207 @@
#!/usr/bin/env python3
"""
Z-Image-Turbo 图像生成服务 —— FastAPI 封装。
使用 Tongyi-MAI/Z-Image-Turbo 模型(基于 Diffusers),默认 1024×1024 输出。
启动方式:
python image_gen_server.py --port 8904
"""
import os
import sys
import json
import base64
import time
import argparse
import warnings
import threading
from contextlib import asynccontextmanager
from pathlib import Path
from io import BytesIO
import torch
# ======================== 配置区域 ========================
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DEFAULT_PORT = 8904
DEFAULT_MODEL_ID = "Tongyi-MAI/Z-Image-Turbo"
# 本地模型路径:优先读环境变量 LOCAL_MODEL_DIR,未设置则回退到远程 ID
LOCAL_MODEL_DIR = os.environ.get("LOCAL_MODEL_DIR", "")
MODEL_PATH = LOCAL_MODEL_DIR if LOCAL_MODEL_DIR and os.path.isdir(LOCAL_MODEL_DIR) else DEFAULT_MODEL_ID
# GPU 选择:CUDA_VISIBLE_DEVICES 或环境变量 SPECIFIC_GPU
SPECIFIC_GPU = os.environ.get("SPECIFIC_GPU", "")
if SPECIFIC_GPU:
os.environ["CUDA_VISIBLE_DEVICES"] = SPECIFIC_GPU
print(f"[Z-Image] 已设置 CUDA_VISIBLE_DEVICES={SPECIFIC_GPU}")
# 兼容性:将前导 warnings 移到 torch 导入后
warnings.filterwarnings("ignore", category=FutureWarning)
# ======================== FastAPI ========================
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse, Response
# Lazy-loaded pipeline singleton
_pipe = None
_pipe_lock = threading.Lock()
_device: str | None = None
def _get_device() -> str:
if torch.cuda.is_available():
return "cuda"
return "cpu"
def _get_pipe():
"""延迟加载 Z-Image pipeline(线程安全)。
使用 Diffusers DiffusionPipeline + trust_remote_code 加载模型仓库中的自定义 ZImagePipeline。
"""
global _pipe, _device
if _pipe is not None:
return _pipe, _device
with _pipe_lock:
if _pipe is not None:
return _pipe, _device
from diffusers import DiffusionPipeline
_device = _get_device()
load_kwargs = {
"torch_dtype": torch.bfloat16 if _device == "cuda" else torch.float32,
"low_cpu_mem_usage": False,
"trust_remote_code": True,
}
# 本地路径时跳过在线检查
if LOCAL_MODEL_DIR and os.path.isdir(LOCAL_MODEL_DIR):
load_kwargs["local_files_only"] = True
print(f"[Z-Image] 加载模型: {MODEL_PATH},设备: {_device}")
_pipe = DiffusionPipeline.from_pretrained(MODEL_PATH, **load_kwargs)
_pipe.to(_device)
print(f"[Z-Image] 模型加载成功")
return _pipe, _device
@asynccontextmanager
async def lifespan(app: FastAPI):
print(f"[Z-Image] 服务启动中,端口: {_model_args.get('port', DEFAULT_PORT)}")
# 启动时预加载模型
try:
_get_pipe()
except Exception as e:
print(f"[Z-Image] 模型预加载失败(将在首次请求时重试): {e}")
yield
print("[Z-Image] 服务关闭")
app = FastAPI(title="Z-Image-Turbo Gen", lifespan=lifespan)
@app.post("/generate")
async def generate_image(request: Request):
"""生成图像。
输入 JSON:
prompt: str — 必填,正向提示词
negative_prompt: str — 可选,负向提示词
height: int — 可选,输出高度(默认 1024,须为 64 的倍数)
width: int — 可选,输出宽度(默认 1024,须为 64 的倍数)
num_inference_steps: int — 可选,推理步数(Turbo 模型建议 9,即 8 次 DiT forward)
guidance_scale: float — 可选,CFG(Turbo 模型建议 0.0)
seed: int — 可选,随机种子(-1 表示随机)
return_format: str — 可选,"base64"(默认)或 "url"
输出 JSON:
success: bool
image: str — base64 编码的 PNG 或文件路径
format: str — "png"
width: int
height: int
"""
try:
body = await request.json()
except Exception:
raise HTTPException(status_code=400, detail="请求体必须为 JSON")
prompt = (body.get("prompt") or "").strip()
if not prompt:
raise HTTPException(status_code=400, detail="prompt 不能为空")
height = int(body.get("height", 1024))
width = int(body.get("width", 1024))
steps = int(body.get("num_inference_steps", 11))
guidance = float(body.get("guidance_scale", 0.0))
seed = int(body.get("seed", -1))
negative_prompt = (body.get("negative_prompt") or "").strip() or None
return_format = (body.get("return_format") or "base64").strip().lower()
# 参数校验
if height % 64 != 0 or width % 64 != 0:
raise HTTPException(status_code=400, detail="height 和 width 须为 64 的整数倍")
if height < 256 or width < 256 or height > 2048 or width > 2048:
raise HTTPException(status_code=400, detail="尺寸须在 [256, 2048] 之间")
pipe, device = _get_pipe()
generator = None
if seed >= 0:
generator = torch.Generator(device).manual_seed(seed)
# 生成
print(f"[Z-Image] 生成中: {width}x{height}, steps={steps}, prompt={prompt[:80]}...")
t0 = time.time()
result = pipe(
prompt=prompt,
negative_prompt=negative_prompt,
height=height,
width=width,
num_inference_steps=steps,
guidance_scale=guidance,
generator=generator,
)
image = result.images[0]
elapsed = time.time() - t0
print(f"[Z-Image] 生成完成,耗时 {elapsed:.1f}s")
# 编码输出
buf = BytesIO()
image.save(buf, format="PNG")
buf.seek(0)
if return_format == "base64":
img_b64 = base64.b64encode(buf.read()).decode("ascii")
return JSONResponse({
"success": True,
"image": img_b64,
"format": "png",
"width": width,
"height": height,
"elapsed_seconds": round(elapsed, 2),
})
else:
return Response(content=buf.getvalue(), media_type="image/png")
@app.get("/health")
async def health():
return {"status": "ok"}
# ======================== 启动入口 ========================
_model_args: dict = {}
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Z-Image-Turbo Image Generation Server")
parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"服务端口(默认 {DEFAULT_PORT})")
args = parser.parse_args()
_model_args["port"] = args.port
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=args.port)
+168
View File
@@ -0,0 +1,168 @@
import os
import torch
import torch.nn.functional as F
from fastapi import FastAPI
from pydantic import BaseModel
from typing import List
from modelscope import AutoTokenizer, AutoModel, AutoModelForCausalLM
from transformers import BitsAndBytesConfig
# 自动获取脚本所在目录作为基础路径
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
# 在 load_models() 里添加量化配置
quantization_config = BitsAndBytesConfig(load_in_8bit=True)
# ===================== 模型路径 =====================
EMBED_MODEL_PATH = os.path.join(BASE_DIR, "models", "Qwen", "Qwen3-Embedding-0___6B")
RERANK_MODEL_PATH = os.path.join(BASE_DIR, "models", "Qwen", "Qwen3-Reranker-4B")
# ===================== 初始化 FastAPI =====================
app = FastAPI(title="RAG Backend with 8-bit Qwen3 Models")
# ===================== 全局变量(模型、分词器) =====================
embed_tokenizer = None
embed_model = None
rerank_tokenizer = None
rerank_model = None
# ===================== 工具函数 =====================
def last_token_pool(last_hidden_states, attention_mask):
left_padding = (attention_mask[:, -1].sum() == attention_mask.shape[0])
if left_padding:
return last_hidden_states[:, -1]
else:
sequence_lengths = attention_mask.sum(dim=1) - 1
batch_size = last_hidden_states.shape[0]
return last_hidden_states[torch.arange(batch_size, device=last_hidden_states.device), sequence_lengths]
def get_detailed_instruct(task_description: str, query: str) -> str:
return f'Instruct: {task_description}\nQuery:{query}'
def format_rerank_instruction(instruction, query, doc):
if instruction is None:
instruction = 'Given a web search query, retrieve relevant passages that answer the query'
return f"<Instruct>: {instruction}\n<Query>: {query}\n<Document>: {doc}"
# ===================== 启动时加载模型(8‑bit) =====================
@app.on_event("startup")
def load_models():
global embed_tokenizer, embed_model, rerank_tokenizer, rerank_model
# ---------- 加载 Embedding 模型 ----------
print("Loading Embedding model with 16-bit quantization...")
embed_tokenizer = AutoTokenizer.from_pretrained(EMBED_MODEL_PATH, padding_side='left')
embed_model = AutoModel.from_pretrained(
EMBED_MODEL_PATH,
#load_in_8bit=True, # 启用 8-bit 量化
device_map="auto", # 自动分配到 GPU
dtype=torch.float16 # 计算精度保持 fp16
)
embed_model.eval()
print("Embedding model loaded.")
# ---------- 加载 Reranker 模型 ----------
print("Loading Reranker model with 8-bit quantization...")
rerank_tokenizer = AutoTokenizer.from_pretrained(RERANK_MODEL_PATH, padding_side='left')
rerank_model = AutoModelForCausalLM.from_pretrained(
RERANK_MODEL_PATH,
quantization_config=quantization_config,
device_map="auto",
dtype=torch.float16
)
rerank_model.eval()
# 预计算 Reranker 所需的固定 tokens
global token_true_id, token_false_id, prefix_tokens, suffix_tokens, max_rerank_length
token_false_id = rerank_tokenizer.convert_tokens_to_ids("no")
token_true_id = rerank_tokenizer.convert_tokens_to_ids("yes")
max_rerank_length = 32768
prefix = "<|im_start|>system\nJudge whether the Document meets the requirements based on the Query and the Instruct provided. Note that the answer can only be \"yes\" or \"no\".<|im_end|>\n<|im_start|>user\n"
suffix = "<|im_end|>\n<|im_start|>assistant\n<think>\n\n</think>\n\n"
prefix_tokens = rerank_tokenizer.encode(prefix, add_special_tokens=False)
suffix_tokens = rerank_tokenizer.encode(suffix, add_special_tokens=False)
print("Reranker model loaded.")
# ===================== API 请求体定义 =====================
class EmbedRequest(BaseModel):
task_description: str = "Given a web search query, retrieve relevant passages that answer the query"
queries: List[str]
documents: List[str] # 可选,若只查询文本可只传 queries
class EmbedResponse(BaseModel):
query_embeddings: List[List[float]]
doc_embeddings: List[List[float]] # 当传入 documents 时返回
class RerankRequest(BaseModel):
task_description: str = "Given a web search query, retrieve relevant passages that answer the query"
query: str
documents: List[str]
class RerankResponse(BaseModel):
scores: List[float]
# ===================== Embedding 端点 =====================
@app.post("/embed", response_model=EmbedResponse)
async def get_embeddings(req: EmbedRequest):
# 为 query 加上指令,document 不加指令
instructed_queries = [get_detailed_instruct(req.task_description, q) for q in req.queries]
# 所有文本拼成一个 batch:先是 queries,后是 documents(如果有)
input_texts = instructed_queries + req.documents
# 分词
batch_dict = embed_tokenizer(
input_texts,
padding=True,
truncation=True,
return_tensors="pt",
).to(embed_model.device)
with torch.no_grad():
outputs = embed_model(**batch_dict)
embeddings = last_token_pool(outputs.last_hidden_state, batch_dict['attention_mask'])
embeddings = F.normalize(embeddings, p=2, dim=1)
# 拆分 query 和 document 的嵌入
nq = len(instructed_queries)
query_embs = embeddings[:nq].cpu().tolist()
doc_embs = embeddings[nq:].cpu().tolist() if req.documents else []
return EmbedResponse(query_embeddings=query_embs, doc_embeddings=doc_embs)
# ===================== Reranker 端点 =====================
@app.post("/rerank", response_model=RerankResponse)
async def rerank_documents(req: RerankRequest):
# 构造带格式的输入对
pairs = [
format_rerank_instruction(req.task_description, req.query, doc)
for doc in req.documents
]
# 处理输入,添加 prefix/suffix tokens 并 pad
inputs = rerank_tokenizer(
pairs,
padding=False,
truncation='longest_first',
return_attention_mask=False,
max_length=max_rerank_length - len(prefix_tokens) - len(suffix_tokens)
)
for i, ele in enumerate(inputs['input_ids']):
inputs['input_ids'][i] = prefix_tokens + ele + suffix_tokens
inputs = rerank_tokenizer.pad(inputs, padding=True, return_tensors="pt", max_length=max_rerank_length)
inputs = {k: v.to(rerank_model.device) for k, v in inputs.items()}
with torch.no_grad():
batch_scores = rerank_model(**inputs).logits[:, -1, :]
true_vector = batch_scores[:, token_true_id]
false_vector = batch_scores[:, token_false_id]
scores = torch.stack([false_vector, true_vector], dim=1)
scores = torch.nn.functional.log_softmax(scores, dim=1)
scores = scores[:, 1].exp().cpu().tolist()
return RerankResponse(scores=scores)
# ===================== 健康检查 =====================
@app.get("/health")
def health():
return {"status": "ok"}
Binary file not shown.
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
set -euo pipefail
# 自动获取脚本所在目录
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VENV_DIR="${SCRIPT_DIR}/myenv"
MODEL_DIR="${SCRIPT_DIR}/models/Qwen/Qwen3-ASR-1.7B"
HOST_PORT="${HOST_PORT:-8901}"
# 激活虚拟环境
source "${VENV_DIR}/bin/activate"
# 检查端口占用
if lsof -i "tcp:${HOST_PORT}" -t >/dev/null 2>&1; then
echo "Port ${HOST_PORT} already in use" >&2
exit 1
fi
echo "Starting vLLM server on port ${HOST_PORT} ..."
echo "Model path: ${MODEL_DIR}"
# 进入脚本目录
cd "${SCRIPT_DIR}"
# 设置环境变量
export VLLM_USE_MODELSCOPE=true
export MODELSCOPE_DOWNLOAD_MODE=local
export MODELSCOPE_HUB_ENABLE_FILE_CHECK=false
export MODELSCOPE_CACHE="${SCRIPT_DIR}/models"
export HF_HUB_OFFLINE=1
export TRANSFORMERS_OFFLINE=1
# 固定使用 GPU 0(多 GPU 服务器上所有模型共用一张卡)
export CUDA_VISIBLE_DEVICES=3
# 启动 vLLM 服务
vllm serve "${MODEL_DIR}" \
--port "${HOST_PORT}" \
--host 0.0.0.0 \
--quantization fp8 \
--dtype auto \
--kv-cache-dtype fp8 \
--max-model-len 16384 \
--allowed-local-media-path / \
--tensor-parallel-size 1 \
--gpu-memory-utilization 0.15 \
--max-num-batched-tokens 4096
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -euo pipefail
# ---------- 配置 ----------
HOST_PORT="${HOST_PORT:-8904}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# ---------- Python 虚拟环境 ----------
if [ -f "${SCRIPT_DIR}/../myenv/bin/activate" ]; then
source "${SCRIPT_DIR}/../myenv/bin/activate"
elif [ -f "${SCRIPT_DIR}/../../myenv/bin/activate" ]; then
source "${SCRIPT_DIR}/../../myenv/bin/activate"
fi
# ---------- 可选 CUDA 设备隔离 ----------
CUDA_DEV="${CUDA_VISIBLE_DEVICES:-}"
if [ -n "${CUDA_DEV}" ]; then
export CUDA_VISIBLE_DEVICES="${CUDA_DEV}"
echo "[Z-Image] CUDA_VISIBLE_DEVICES=${CUDA_DEV}"
fi
echo "[Z-Image] 启动图像生成服务 (端口=${HOST_PORT})"
cd "${SCRIPT_DIR}"
exec python image_gen_server.py --port "${HOST_PORT}"
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VENV_DIR="${SCRIPT_DIR}/myenv"
HOST_PORT="${HOST_PORT:-8900}"
MODEL_DIR="${SCRIPT_DIR}/models/Qwen/Qwen3___6-35B-A3B-FP8"
# 激活虚拟环境
source "${VENV_DIR}/bin/activate"
# 检查端口占用
if lsof -i "tcp:${HOST_PORT}" -t >/dev/null 2>&1; then
echo "Port ${HOST_PORT} already in use" >&2
exit 1
fi
echo "Starting vLLM server on port ${HOST_PORT} ..."
echo "Model path: ${MODEL_DIR}"
# 设置环境变量
export VLLM_USE_MODELSCOPE=true
export MODELSCOPE_DOWNLOAD_MODE=local
export MODELSCOPE_HUB_ENABLE_FILE_CHECK=false
export MODELSCOPE_CACHE="${SCRIPT_DIR}/models"
export HF_HUB_OFFLINE=1
export TRANSFORMERS_OFFLINE=1
export CUDA_VISIBLE_DEVICES=3
# 启动 vLLM 服务
vllm serve "${MODEL_DIR}" \
--port "${HOST_PORT}" \
--host 0.0.0.0 \
--tensor-parallel-size 1 \
--max-model-len 262144 \
--reasoning-parser qwen3 \
--enable-auto-tool-choice \
--tool-call-parser qwen3_coder \
--speculative-config '{"method":"qwen3_next_mtp","num_speculative_tokens":2}' \
--enable-prefix-caching \
--max_num_batched_tokens 4096 \
--max_num_seqs 8 \
--kv-cache-dtype fp8_e4m3 \
--chat-template "${SCRIPT_DIR}/chat_template.jinja" \
--safetensors-load-strategy eager \
--served-model-name Qwen/Qwen3.6-35B-A3B-FP8
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
set -euo pipefail
# 自动获取脚本所在目录
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VENV_DIR="${VENV_DIR:-${SCRIPT_DIR}/myenv}"
HOST_PORT="${HOST_PORT:-8903}"
# 激活虚拟环境
source "${VENV_DIR}/bin/activate"
# 检查端口占用
if lsof -i "tcp:${HOST_PORT}" -t >/dev/null 2>&1; then
echo "Port ${HOST_PORT} already in use" >&2
exit 1
fi
echo "Starting Qwen3 RAG server on port ${HOST_PORT} ..."
# 进入脚本目录
cd "${SCRIPT_DIR}"
# 离线模式设置(确保已下载模型,不从远程拉取)
export MODELSCOPE_DOWNLOAD_MODE=local
export MODELSCOPE_HUB_ENABLE_FILE_CHECK=false
export HF_HUB_OFFLINE=1
export TRANSFORMERS_OFFLINE=1
# 指定 modelscope 的缓存目录(如果你的模型保存在此路径下)
# 根据你的下载路径调整,或将其设置为环境变量覆盖
export MODELSCOPE_CACHE="${MODELSCOPE_CACHE:-${SCRIPT_DIR}/models}"
# 固定使用 GPU 0(多 GPU 服务器上所有模型共用一张卡)
export CUDA_VISIBLE_DEVICES=3
# 启动 FastAPI 服务
uvicorn rag_server:app \
--host 0.0.0.0 \
--port "${HOST_PORT}" \
--workers 1
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -euo pipefail
# ---------- 配置 ----------
HOST_PORT="${HOST_PORT:-8902}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# VoxCPM 模型路径(可覆盖)
VOXCPM_MODEL_PATH="${VOXCPM_MODEL_PATH:-${SCRIPT_DIR}/models/VoxCPM1__5}"
# ---------- Python 虚拟环境 ----------
if [ -f "${SCRIPT_DIR}/../myenv/bin/activate" ]; then
source "${SCRIPT_DIR}/../myenv/bin/activate"
elif [ -f "${SCRIPT_DIR}/../../myenv/bin/activate" ]; then
source "${SCRIPT_DIR}/../../myenv/bin/activate"
fi
# ---------- 可选 CUDA 设备隔离 ----------
CUDA_DEV="${CUDA_VISIBLE_DEVICES:-3}"
export CUDA_VISIBLE_DEVICES="${CUDA_DEV}"
echo "[VoxCPM TTS] 启动流式 TTS 服务 (端口=${HOST_PORT}, CUDA=${CUDA_DEV})"
echo "[VoxCPM TTS] 模型路径: ${VOXCPM_MODEL_PATH}"
cd "${SCRIPT_DIR}"
exec python streaming_tts_server.py \
--port "${HOST_PORT}" \
--model-path "${VOXCPM_MODEL_PATH}"
+325
View File
@@ -0,0 +1,325 @@
#!/usr/bin/env python3
"""
VoxCPM 流式 TTS 服务 —— 按句分块合成,SSE 流式推送 PCM 音频。
使用 OpenBMB VoxCPM 模型(Whispera 同款),无需 Genie TTS。
配合前端 Web Audio API 实现边合成边播放,无需保存音频文件。
启动方式:
python streaming_tts_server.py --port 8902 --model-path ./models/VoxCPM
"""
import os
import sys
import json
import base64
import time
import threading
import argparse
import warnings
from contextlib import asynccontextmanager
from pathlib import Path
# 抑制 PyTorch weight_norm 弃用警告(来自 voxcpm 内部)
warnings.filterwarnings("ignore", message=".*weight_norm.*deprecated.*")
# ======================== 配置区域 ========================
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
# 启用 TF32 加速(Ampere+ GPU)
import torch
torch.set_float32_matmul_precision("high")
# 确保 torchaudio 已安装(VoxCPM 参考音频需要)
try:
import torchaudio # noqa: F401
except ImportError:
pass # 无参考音频时可选
# 默认参数(可通过命令行覆盖)
DEFAULT_MODEL_PATH = os.path.join(BASE_DIR, "models", "VoxCPM1__5")
DEFAULT_PORT = 8902
# 参考音频(用于统一音色,同一人物说的一段话 + 对应文本)
# 如果配置了这对参数,所有合成都会克隆该音色,各句音色保持一致
DEFAULT_PROMPT_WAV = os.path.join(BASE_DIR, "reference_audio.wav")
DEFAULT_PROMPT_TEXT = "日子缓缓前行,不必急于追赶,照顾好自己的心情,认真过好当下的每一刻就足够了。"
# 音频质量参数
DEFAULT_CFG_VALUE = 1.5 # 音色贴合度 (1.0~3.0),越高越贴近参考音频但可能不自然
DEFAULT_INFERENCE_STEPS = 15 # 推理步数 (4~30),越高音质越好但越慢
# =========================================================
# 请求级取消标志
_cancel_flags: dict[str, threading.Event] = {}
_cancel_lock = threading.Lock()
# 当前活跃请求 ID(新请求到达时自动取消旧请求)
_current_request_id: str | None = None
_current_req_lock = threading.Lock()
# 模型锁(VoxCPM 只允许单线程使用,新请求取消旧请求后等待锁释放)
_model_lock = threading.Lock()
def set_cancel(request_id: str) -> None:
with _cancel_lock:
evt = _cancel_flags.get(request_id)
if evt is None:
evt = threading.Event()
_cancel_flags[request_id] = evt
evt.set()
def is_cancelled(request_id: str) -> bool:
with _cancel_lock:
evt = _cancel_flags.get(request_id)
return evt is not None and evt.is_set()
def cancel_current_and_set(new_request_id: str) -> str | None:
"""取消当前活跃请求并注册新请求 ID,返回被取消的旧请求 ID。"""
global _current_request_id
with _current_req_lock:
old = _current_request_id
_current_request_id = new_request_id
if old and old != new_request_id:
set_cancel(old)
# 若旧请求持有模型锁,强制释放以让新请求立即获取
try:
if _model_lock.locked():
_model_lock.release()
except RuntimeError:
pass
print(f"[VoxCPM TTS] 打断旧请求 {old},开始新请求 {new_request_id}")
return old
# Lazy-loaded model singleton
_model = None
_singleton_lock = threading.Lock()
_model_sample_rate = 24000
def _get_model(model_path: str):
"""延迟加载 VoxCPM 模型(线程安全)。"""
global _model, _model_sample_rate
if _model is not None:
return _model, _model_sample_rate
with _singleton_lock:
if _model is not None:
return _model, _model_sample_rate
if "voxcpm" not in sys.modules:
try:
from voxcpm import VoxCPM # type: ignore
except ImportError:
raise ImportError(
"无法导入 voxcpm 模块。请确保 Whispera-main 项目的 "
"voxcpm-tts-streaming-module/src 在 sys.path 中,"
"或 pip install 了 voxcpm 包。"
)
from voxcpm import VoxCPM # type: ignore
mp = _resolve_model_path(model_path)
print(f"[VoxCPM TTS] 加载模型: {mp}")
_model = VoxCPM(
voxcpm_model_path=mp,
enable_denoiser=False,
optimize=True,
)
_model_sample_rate = int(_model.tts_model.sample_rate)
print(f"[VoxCPM TTS] 模型加载成功,采样率: {_model_sample_rate} Hz")
return _model, _model_sample_rate
def _resolve_model_path(model_path: str) -> str:
"""解析模型路径:支持多种命名格式。"""
base = Path(model_path).expanduser().resolve()
if not base.exists():
raise FileNotFoundError(f"VoxCPM 模型路径不存在: {base}")
# 1. 直接就是模型目录(有 config.json)
if (base / "config.json").is_file():
return str(base)
# 2. 检查常见子目录名
for sub in ["openbmb__VoxCPM2", "openbmb__VoxCPM1.5",
"VoxCPM2", "VoxCPM1.5", "VoxCPM1__5"]:
candidate = base / sub
if candidate.is_dir() and (candidate / "config.json").is_file():
return str(candidate)
raise FileNotFoundError(
f"在 {base} 下未找到 VoxCPM 模型(需要包含 config.json 的子目录,"
f"如 openbmb__VoxCPM1.5/、VoxCPM1__5/ 等)"
)
def _build_sse(data: dict) -> str:
return f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
def _stream_audio_events(text: str, request_id: str, model_path: str):
"""生成 SSE 音频流事件(同步,在独立线程中运行)。"""
import numpy as np
try:
model, sample_rate = _get_model(model_path)
except Exception as e:
yield _build_sse({"type": "audio.error", "request_id": request_id, "message": str(e)})
return
# 开始
yield _build_sse({
"type": "audio.start",
"request_id": request_id,
"sample_rate": sample_rate,
"audio_format": "pcm_f32le",
})
chunk_index = 0
total_samples = 0
# 获取模型锁(前一请求已被 cancel_current_and_set 取消,锁应已释放)
# 短超时 + 重试,避免用户等待
for attempt in range(3):
if _model_lock.acquire(timeout=3):
break
if attempt < 2:
print(f"[VoxCPM TTS] 等待模型锁释放... (尝试 {attempt + 1}/3)")
else:
yield _build_sse({"type": "audio.error", "request_id": request_id,
"message": "等待模型超时,请重试"})
return
try:
gen = model.generate_streaming(
text=text,
prompt_wav_path=DEFAULT_PROMPT_WAV or None,
prompt_text=DEFAULT_PROMPT_TEXT or None,
cfg_value=DEFAULT_CFG_VALUE,
inference_timesteps=DEFAULT_INFERENCE_STEPS,
retry_badcase=False,
)
for audio_chunk in gen:
if is_cancelled(request_id):
yield _build_sse({"type": "audio.cancelled", "request_id": request_id})
return
arr = np.asarray(audio_chunk, dtype=np.float32).ravel()
pcm_bytes = np.ascontiguousarray(arr, dtype=np.float32).tobytes()
num_samples = arr.size
total_samples += num_samples
b64_data = base64.b64encode(pcm_bytes).decode("ascii")
yield _build_sse({
"type": "audio.chunk",
"request_id": request_id,
"index": chunk_index,
"num_samples": num_samples,
"sample_rate": sample_rate,
"data": b64_data,
})
chunk_index += 1
finally:
_model_lock.release()
yield _build_sse({
"type": "audio.done",
"request_id": request_id,
"total_samples": total_samples,
"interrupted": False,
})
# ======================== FastAPI ========================
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
@asynccontextmanager
async def lifespan(app: FastAPI):
mp = _model_args.get("model_path", DEFAULT_MODEL_PATH)
print(f"[VoxCPM TTS] 服务启动中,模型路径: {mp}")
print(f"[VoxCPM TTS] 调用方式: POST /stream_tts body: {{'text': '...', 'request_id': '...'}}")
# 启动时预加载模型(避免首次请求等待)
try:
_get_model(mp)
except Exception as e:
print(f"[VoxCPM TTS] 模型预加载失败: {e}")
yield
print("[VoxCPM TTS] 服务关闭")
app = FastAPI(title="VoxCPM Streaming TTS", lifespan=lifespan)
@app.post("/stream_tts")
async def stream_tts(request: Request):
"""流式 TTS 端点:接收文本,SSE 流式返回 PCM f32le 音频块。"""
try:
body = await request.json()
except Exception:
form = await request.form()
body = dict(form)
text = (body.get("text") or "").strip()
request_id = body.get("request_id") or f"tts-{int(time.time() * 1000)}"
if not text:
return StreamingResponse(
iter([_build_sse({"type": "audio.error", "request_id": request_id, "message": "文本为空"})]),
media_type="text/event-stream",
)
# 新请求到达:自动取消当前活跃的旧请求
cancel_current_and_set(request_id)
return StreamingResponse(
_stream_audio_events(text, request_id, _model_args.get("model_path", DEFAULT_MODEL_PATH)),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@app.post("/cancel_tts")
async def cancel_tts(request: Request):
"""取消指定 request_id 的 TTS 合成。"""
try:
body = await request.json()
except Exception:
form = await request.form()
body = dict(form)
request_id = body.get("request_id", "")
if request_id:
set_cancel(request_id)
return {"status": "cancelled", "request_id": request_id}
return {"status": "error", "message": "缺少 request_id"}
@app.get("/health")
async def health():
return {"status": "ok"}
# ======================== 启动入口 ========================
_model_args: dict = {}
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="VoxCPM Streaming TTS Server")
parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"服务端口(默认 {DEFAULT_PORT})")
parser.add_argument("--model-path", type=str, default=DEFAULT_MODEL_PATH, help="VoxCPM 模型目录")
args = parser.parse_args()
_model_args["model_path"] = args.model_path
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=args.port)
+132
View File
@@ -0,0 +1,132 @@
"""
Test script for Qwen3-Omni-30B-A3B-Captioner via vLLM Chat Completions API.
Note:
This model is an audio captioner, not a pure ASR. It generates
detailed descriptions of audio content (speech, environment, music, etc.)
without any text prompt. Only the audio file is sent.
It is recommended to keep audio length <= 30 seconds for best detail.
"""
import requests
import argparse
import os
import sys
import base64
import mimetypes
DEFAULT_URL = "http://127.0.0.1:8901/v1/chat/completions"
# ----------------------------------------------------------------------
# Optional: check audio duration (requires soundfile)
# ----------------------------------------------------------------------
def get_audio_duration_sec(file_path: str) -> float:
"""Return audio duration in seconds, or -1.0 if unable to read."""
try:
import soundfile as sf
info = sf.info(file_path)
return info.duration
except Exception:
try:
# Fallback using librosa (if available)
import librosa
return librosa.get_duration(path=file_path)
except Exception:
return -1.0
# ----------------------------------------------------------------------
def file_to_url(file_path: str, use_base64: bool = False) -> str:
"""Convert local file path to a URL accepted by vLLM.
When the server is remote, use base64 data URL instead of file://
since the remote server cannot access local filesystem paths.
"""
if use_base64:
mime_type, _ = mimetypes.guess_type(file_path)
if mime_type is None:
mime_type = "audio/wav"
with open(file_path, "rb") as f:
b64_data = base64.b64encode(f.read()).decode("utf-8")
return f"data:{mime_type};base64,{b64_data}"
else:
abs_path = os.path.abspath(file_path)
return "file://" + abs_path.replace("\\", "/")
def caption_audio(audio_path: str, server_url: str = DEFAULT_URL, use_base64: bool = False) -> str:
"""
Send audio to the Qwen3-Omni-30B-A3B-Captioner and return its description.
"""
if not os.path.exists(audio_path):
raise FileNotFoundError(f"Audio file not found: {audio_path}")
# Check duration (warning only, not blocking)
duration = get_audio_duration_sec(audio_path)
if duration > 30:
print(
f"⚠️ Audio length is {duration:.1f}s (>30s). "
"The model may lose fine detail. Consider trimming to ≤30s.",
file=sys.stderr
)
audio_url = file_to_url(audio_path, use_base64=use_base64)
# The model expects NO text prompt. Only the audio input is required.
payload = {
"messages": [
{
"role": "user",
"content": [
{"type": "audio_url", "audio_url": {"url": audio_url}}
]
}
]
}
headers = {"Content-Type": "application/json"}
try:
resp = requests.post(server_url, headers=headers, json=payload, timeout=120)
resp.raise_for_status()
result = resp.json()
# Extract assistant reply
text = result["choices"][0]["message"]["content"]
return text.strip()
except requests.exceptions.RequestException as e:
print(f"Request failed: {e}", file=sys.stderr)
if hasattr(e, 'response') and e.response is not None:
print(f"Server response: {e.response.text}", file=sys.stderr)
raise
def main():
parser = argparse.ArgumentParser(
description="Test Qwen3-Omni-Captioner (audio captioning, NOT pure ASR)"
)
parser.add_argument("--audio", "-a", required=True,
help="Path to input audio file (mp3, wav, etc.)")
parser.add_argument("--url", "-u", default=DEFAULT_URL,
help="vLLM chat completions endpoint")
parser.add_argument("--output", "-o", default=None,
help="Output text file (if not provided, print to stdout)")
parser.add_argument("--verbose", "-v", action="store_true",
help="Print detailed request/response info")
parser.add_argument("--base64", "-b", action="store_true",
help="Send audio as base64 data URL (use when server is remote)")
args = parser.parse_args()
if args.verbose:
print(f"Audio file: {args.audio}")
print(f"Server URL: {args.url}")
print(f"Base64 mode: {args.base64}")
caption_text = caption_audio(args.audio, args.url, use_base64=args.base64)
if args.output:
with open(args.output, "w", encoding="utf-8") as f:
f.write(caption_text)
print(f"Caption saved to: {args.output}")
else:
print(caption_text)
if __name__ == "__main__":
main()
Binary file not shown.
+145
View File
@@ -0,0 +1,145 @@
"""
测试 Qwen3.6-35B-A3B-FP8 LLM 服务(OpenAI 兼容 API)
使用方法:
python test_llm.py # 默认地址 http://127.0.0.1:8900
python test_llm.py --url http://xxx:8900
python test_llm.py --stream # 流式输出
"""
import argparse
import json
import requests
def test_non_stream(base_url: str, model: str):
"""非流式请求:发送消息,打印完整返回结构"""
payload = {
"model": model,
"messages": [
{"role": "user", "content": "你好,请用一句话介绍你自己。"}
],
"stream": False,
}
print("\n" + "=" * 80)
print(" 请求体 (Request)")
print("=" * 80)
print(json.dumps(payload, indent=2, ensure_ascii=False))
resp = requests.post(
f"{base_url}/v1/chat/completions",
json=payload,
headers={"Authorization": "Bearer <YOUR_API_KEY>"},
)
print("\n" + "=" * 80)
print(f" 响应 (Response) HTTP {resp.status_code}")
print("=" * 80)
if resp.status_code == 200:
data = resp.json()
print(json.dumps(data, indent=2, ensure_ascii=False))
# 提取关键信息
print("\n" + "-" * 80)
print(" 关键信息提取")
print("-" * 80)
choice = data.get("choices", [{}])[0]
message = choice.get("message", {})
print(f" model : {data.get('model')}")
print(f" finish_reason : {choice.get('finish_reason')}")
print(f" role : {message.get('role')}")
print(f" content : {message.get('content', '')[:200]}...")
if message.get("tool_calls"):
print(f" tool_calls : {json.dumps(message['tool_calls'], ensure_ascii=False)}")
usage = data.get("usage", {})
if usage:
print(f" prompt_tokens : {usage.get('prompt_tokens')}")
print(f" completion_tokens: {usage.get('completion_tokens')}")
print(f" total_tokens : {usage.get('total_tokens')}")
else:
print(f"Error: {resp.text}")
def test_stream(base_url: str, model: str):
"""流式请求:逐块打印输出"""
payload = {
"model": model,
"messages": [
{"role": "user", "content": "你好,请用一句话介绍你自己。"}
],
"stream": True,
}
print("\n" + "=" * 80)
print(" 流式请求 (Stream)")
print("=" * 80)
print(json.dumps(payload, indent=2, ensure_ascii=False))
resp = requests.post(
f"{base_url}/v1/chat/completions",
json=payload,
headers={"Authorization": "Bearer <YOUR_API_KEY>"},
stream=True,
)
print("\n" + "=" * 80)
print(f" 流式响应 HTTP {resp.status_code}")
print("=" * 80)
if resp.status_code != 200:
print(f"Error: {resp.text}")
return
full_content = ""
for line in resp.iter_lines(decode_unicode=True):
if not line:
continue
if line.startswith("data: "):
data_str = line[6:]
if data_str == "[DONE]":
print("\n\n[DONE]")
break
try:
chunk = json.loads(data_str)
delta = chunk.get("choices", [{}])[0].get("delta", {})
content = delta.get("content", "")
if content:
print(content, end="", flush=True)
full_content += content
# 打印每个 chunk 的完整结构(首次和最后一次)
if not full_content.strip() or content:
pass # 避免刷屏,取消注释下面这行可查看每个 chunk
# print(f"\n[chunk] {json.dumps(chunk, ensure_ascii=False)}")
except json.JSONDecodeError:
print(f"\n[无法解析] {data_str}")
print("\n\n" + "-" * 80)
print(f" 完整内容: {full_content}")
def main():
parser = argparse.ArgumentParser(description="测试 Qwen3.6 LLM 服务")
parser.add_argument("--url", default="http://127.0.0.1:8900",
help="LLM 服务地址 (默认: http://127.0.0.1:8900)")
parser.add_argument("--model", default="Qwen/Qwen3.6-35B-A3B-FP8",
help="模型名称")
parser.add_argument("--stream", action="store_true",
help="使用流式输出")
args = parser.parse_args()
print("=" * 80)
print(" Qwen3.6-35B-A3B-FP8 LLM 测试")
print("=" * 80)
print(f" 服务地址: {args.url}")
print(f" 模型 : {args.model}")
if args.stream:
test_stream(args.url, args.model)
else:
test_non_stream(args.url, args.model)
if __name__ == "__main__":
main()
Binary file not shown.
+82
View File
@@ -0,0 +1,82 @@
import requests
import json
# 服务地址
BASE_URL = "http://0.0.0.0:8903"
def print_separator(title):
print("\n" + "=" * 80)
print(f" {title}")
print("=" * 80)
def test_health():
print_separator("Health Check")
resp = requests.get(f"{BASE_URL}/health")
print(f"Status: {resp.status_code}")
print(f"Response: {resp.json()}")
def test_embed():
print_separator("Embedding Test")
payload = {
"task_description": "Given a web search query, retrieve relevant passages that answer the query",
"queries": [
"What is the capital of France?",
"How to train a cat?"
],
"documents": [
"Paris is the capital of France.",
"Cats can be trained using positive reinforcement."
]
}
print(">>> Request Body:")
print(json.dumps(payload, indent=2, ensure_ascii=False))
resp = requests.post(f"{BASE_URL}/embed", json=payload)
print(f"<<< Status: {resp.status_code}")
if resp.status_code == 200:
data = resp.json()
# 只打印向量长度和前面几个值,避免刷屏
print(f"<<< Response Keys: {list(data.keys())}")
print(f"Number of query embeddings: {len(data['query_embeddings'])}")
print(f"Dimension of each query embedding: {len(data['query_embeddings'][0])}")
print(f"First query embedding (first 5 values): {data['query_embeddings'][0][:5]}")
print(f"Number of doc embeddings: {len(data['doc_embeddings'])}")
if data['doc_embeddings']:
print(f"Dimension of each doc embedding: {len(data['doc_embeddings'][0])}")
print(f"First doc embedding (first 5 values): {data['doc_embeddings'][0][:5]}")
else:
print("No document embeddings returned.")
# 如果需要完整输出,取消下面注释(会打印大量浮点数)
# print(f"<<< Full Response:\n{json.dumps(data, indent=2)}")
else:
print(f"Error: {resp.text}")
def test_rerank():
print_separator("Reranker Test")
payload = {
"task_description": "Given a web search query, retrieve relevant passages that answer the query",
"query": "What is the capital of France?",
"documents": [
"Paris is the capital of France.",
"Cats are often difficult to train.",
"Lyon is a city in France."
]
}
print(">>> Request Body:")
print(json.dumps(payload, indent=2, ensure_ascii=False))
resp = requests.post(f"{BASE_URL}/rerank", json=payload)
print(f"<<< Status: {resp.status_code}")
if resp.status_code == 200:
data = resp.json()
print(f"<<< Response Keys: {list(data.keys())}")
print(f"Scores: {data['scores']}")
# 打印完整响应
# print(f"<<< Full Response:\n{json.dumps(data, indent=2)}")
else:
print(f"Error: {resp.text}")
if __name__ == "__main__":
test_health()
test_embed()
test_rerank()
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env python3
"""
VoxCPM 流式 TTS 测试脚本。
用法:
python test_tts.py # 默认测试
python test_tts.py --text "你好世界" # 自定义文本
python test_tts.py --url http://x.x.x.x:8902 # 远程服务器
python test_tts.py --save test_output.wav # 保存为 WAV 文件
"""
import argparse
import base64
import json
import struct
import sys
import time
import requests
def main():
parser = argparse.ArgumentParser(description="VoxCPM 流式 TTS 测试")
parser.add_argument("--url", default="http://0.0.0.0:8902", help="TTS 服务地址")
parser.add_argument("--text", default="你好,这是一个流式语音合成测试。今天天气不错,适合出去散步。",
help="测试文本")
parser.add_argument("--save", default="", help="保存 WAV 文件路径(不指定则不保存)")
parser.add_argument("--play", action="store_true", help="本地播放(需要 pyaudio)")
args = parser.parse_args()
base_url = args.url.rstrip("/")
request_id = f"test-{int(time.time() * 1000)}"
# 1. 健康检查
print(f"→ 健康检查 {base_url}/health ...")
try:
r = requests.get(f"{base_url}/health", timeout=5)
r.raise_for_status()
print(f" 状态: {r.json()}")
except Exception as e:
print(f" ❌ 无法连接 {base_url}: {e}")
sys.exit(1)
# 2. 流式 TTS 请求
print(f"→ 流式 TTS 请求: \"{args.text}\"")
t_start = time.perf_counter()
sample_rate = 0
all_pcm = bytearray()
total_samples = 0
total_chunks = 0
try:
resp = requests.post(
f"{base_url}/stream_tts",
json={"text": args.text, "request_id": request_id},
stream=True,
timeout=300,
)
resp.raise_for_status()
for line in resp.iter_lines(decode_unicode=True):
if not line or not line.startswith("data: "):
continue
payload = json.loads(line[len("data: "):])
event_type = payload.get("type", "")
if event_type == "audio.start":
sample_rate = payload.get("sample_rate", 0)
num_segments = payload.get("num_segments", 0)
print(f" audio.start | 采样率={sample_rate}Hz 句数={num_segments}")
elif event_type == "audio.chunk":
b64_data = payload.get("data", "")
idx = payload.get("index", 0)
num_s = payload.get("num_samples", 0)
if b64_data:
pcm = base64.b64decode(b64_data)
all_pcm.extend(pcm)
total_samples += num_s
total_chunks += 1
# 进度提示
if idx % 5 == 0 and b64_data:
dur = total_samples / sample_rate if sample_rate else 0
print(f" audio.chunk | #{idx} 累计={dur:.1f}s")
elif event_type == "audio.done":
total = payload.get("total_segments", 0)
print(f" audio.done | 共{total}句 {total_samples}样本 {total_chunks}块")
elif event_type == "audio.error":
print(f" ❌ audio.error: {payload.get('message', '')}")
sys.exit(1)
elif event_type == "audio.cancelled":
print(f" ⚠ audio.cancelled")
break
elif event_type == "done":
pass # 流结束
except requests.exceptions.ConnectionError as e:
print(f" ❌ 连接失败: {e}")
sys.exit(1)
except Exception as e:
print(f" ❌ 错误: {e}")
sys.exit(1)
t_elapsed = time.perf_counter() - t_start
duration = total_samples / sample_rate if sample_rate else 0
rtf = t_elapsed / duration if duration > 0 else 0
print(f"\n{'='*50}")
print(f" 总耗时: {t_elapsed:.2f}s")
print(f" 音频时长: {duration:.2f}s")
print(f" RTF: {rtf:.2f} (越小越好,<1 表示比实时快)")
print(f" 采样率: {sample_rate} Hz")
print(f" 总样本数: {total_samples}")
print(f" PCM 大小: {len(all_pcm)} bytes")
print(f"{'='*50}")
if not all_pcm:
print(" ⚠ 未收到任何音频数据")
sys.exit(1)
# 3. 保存 WAV
if args.save:
_save_wav(args.save, all_pcm, sample_rate)
print(f"→ 已保存: {args.save}")
# 4. 本地播放
if args.play:
_play_pcm(all_pcm, sample_rate)
def _save_wav(filepath: str, pcm_f32: bytearray, sample_rate: int):
"""将 PCM f32le 转为 16-bit WAV 保存。"""
import io
import wave
import numpy as np
arr = np.frombuffer(pcm_f32, dtype=np.float32)
# 转为 int16
arr_i16 = (arr * 32767).clip(-32768, 32767).astype(np.int16)
with wave.open(filepath, "wb") as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(sample_rate)
wf.writeframes(arr_i16.tobytes())
def _play_pcm(pcm_f32: bytearray, sample_rate: int):
"""通过 pyaudio 播放 PCM 音频。"""
try:
import pyaudio
import numpy as np
except ImportError:
print(" ⚠ 需要安装 pyaudio: pip install pyaudio")
return
arr = np.frombuffer(pcm_f32, dtype=np.float32)
arr_i16 = (arr * 32767).clip(-32768, 32767).astype(np.int16)
p = pyaudio.PyAudio()
stream = p.open(
format=pyaudio.paInt16,
channels=1,
rate=sample_rate,
output=True,
)
print("→ 播放中...")
stream.write(arr_i16.tobytes())
stream.stop_stream()
stream.close()
p.terminate()
print("→ 播放完毕")
if __name__ == "__main__":
main()
+50
View File
@@ -0,0 +1,50 @@
{
"name": "minor-agent-desktop",
"version": "1.0.0",
"description": "minor 多模态 Agent 桌面应用",
"main": "electron/main.js",
"scripts": {
"start": "electron .",
"build": "electron-builder",
"build:dir": "electron-builder --dir"
},
"author": "",
"license": "MIT",
"devDependencies": {
"electron": "^33.4.11",
"electron-builder": "^26.15.3"
},
"build": {
"appId": "com.minor.agent",
"productName": "minor",
"directories": {
"output": "dist"
},
"asar": false,
"files": [
"electron/**/*",
"src/**/*",
"!src/agent/config/env_config.json",
"requirements.txt"
],
"extraResources": [],
"win": {
"target": "nsis",
"icon": "src/web/image/icon.ico"
},
"nsis": {
"oneClick": false,
"perMachine": false,
"allowToChangeInstallationDirectory": true,
"installerHeaderIcon": "src/web/image/icon.ico",
"createDesktopShortcut": true,
"createStartMenuShortcut": true,
"shortcutName": "minor Agent",
"deleteAppDataOnUninstall": true,
"runAfterFinish": true
}
},
"dependencies": {
"bluebird": "^3.7.2"
}
}
+53
View File
@@ -0,0 +1,53 @@
# ===== Web Framework =====
fastapi>=0.115.0
uvicorn[standard]>=0.32.0
python-multipart>=0.0.9
# ===== LangChain =====
langchain>=1.2.0
langchain-core>=1.2.0
langchain-community>=0.4.1
langchain-openai>=1.1.12
langchain-text-splitters>=1.1.0
langgraph>=1.1.6
# ===== Vector Store =====
chromadb>=0.4.0
pymysql>=1.1.0
# ===== Desktop Automation =====
pyautogui>=0.9.54
pywin32>=312
pyperclip>=1.8.0
screeninfo>=0.8.0
# ===== Audio =====
pyaudio>=0.2.11
pydub>=0.25.1
# ===== Image =====
Pillow>=10.0.0
# ===== Documents =====
python-docx>=1.0.0
openpyxl>=3.0.0
xlrd>=2.0.0
pypdf>=6.0.0
python-pptx>=1.0.0
# ===== Database =====
cryptography>=41.0.0
# ===== Data Analysis (hard_excel_read / excel2sql / terminal_execute画图) =====
pandas>=2.0.0
matplotlib>=3.7.0
python-dateutil>=2.8.0
# ===== Network =====
requests>=2.31.0
# ===== Core =====
pydantic>=2.0.0
# ===== Websearch =====
firecrawl-py
View File
Whitespace-only changes.
+41
View File
@@ -0,0 +1,41 @@
"""Agent 实例注册包。
系统定位:
对外导出各 Agent 的 ``AgentRuntime`` 单例。
所有 Agent 实例均从 ``agent_config.json`` 动态构建,不再 hardcoded。
主 Agent 通过 ``CallSubAgentTool`` 以工具委派方式调用子 Agent,
子 Agent 各自运行独立的单 Agent ReAct 图。
Agent 列表:
- main_agent_runtime: 主 Agent 运行时(从配置中 role="main" 的 agent 动态创建)。
- reload_all_agent_runtimes: 重新加载配置并重建所有 Agent 运行时。
可扩展性:
新增 Agent 只需在 agent_config.json 中添加配置项,无需编写 .py 文件。
"""
from agent.agents.agent_runtime import (
build_all_agent_runtimes,
get_main_agent_runtime,
get_all_agent_runtimes,
get_agent_runtime,
reload_all_agent_runtimes,
execute_agent,
continue_after_human_action,
main_agent_runtime,
AgentMode,
)
reload_main_agent_runtime = reload_all_agent_runtimes # 向后兼容别名
__all__ = [
"main_agent_runtime",
"reload_main_agent_runtime",
"reload_all_agent_runtimes",
"build_all_agent_runtimes",
"get_main_agent_runtime",
"get_all_agent_runtimes",
"get_agent_runtime",
"execute_agent",
"continue_after_human_action",
"AgentMode",
]
+272
View File
@@ -0,0 +1,272 @@
"""动态 Agent 工厂:根据 agent_config.json 全动态创建 AgentRuntime。
系统定位:
实现从配置文件全动态加载 Agent 实例。所有 Agent 的运行参数(图类型、
模型、工具集、系统提示词、最大迭代次数)均由 agent_config.json 驱动。
同时提供 Web/API 层的入口函数 execute_agent 和 continue_after_human_action。
使用方式:
from agent.agents.agent_runtime import get_main_agent_runtime, reload_all_agent_runtimes, execute_agent
runtime = get_main_agent_runtime()
"""
from __future__ import annotations
from typing import Any, Literal
from agent.core.config_manager import load_agent_configs, load_models, AgentConfig
from agent.core.graph import build_agent_graph
from agent.core.runtime import continue_after_human_action as continue_runtime_after_human_action
from agent.core.runtime import execute_agent as execute_runtime_agent
from agent.core.runtime import AgentRuntime
from agent.tools import get_tools
AgentMode = Literal["agent", "plan", "cron"]
# ---------- 缓存 ----------
_all_agent_runtimes: dict[str, AgentRuntime] = {}
_main_agent_name: str = "main"
def _build_llm_for_agent(cfg: AgentConfig):
"""根据 Agent 配置中指定的 llm_model_id 构建 LLM 实例。
从 env_config.json 的 models 列表中查找匹配的模型配置,
用其参数构造 ChatQwen 实例。若未找到匹配模型则回退到环境变量默认值。
"""
from agent.core.llm import ChatQwen
models = load_models()
model_config = None
for m in models:
if m.get("id") == cfg.llm_model_id:
model_config = m
break
if model_config:
# enable_thinking: true 时传入空 extra_body 恢复模型原生行为(启用思考)
# 默认不传 extra_body,由 ChatQwen 使用禁用思考的默认值
if model_config.get("enable_thinking"):
extra_body = {}
else:
extra_body = None
return ChatQwen(
model=model_config.get("model", ""),
api_key=model_config.get("api_key", ""),
base_url=model_config.get("base_url", ""),
timeout=float(model_config.get("timeout", 60)),
max_retries=int(model_config.get("max_retries", 0)),
extra_body=extra_body,
)
# 回退:使用环境变量中已加载的默认 LLM 配置
from agent.core.llm import llm as _default_llm
return ChatQwen(
model=_default_llm.model_name,
api_key=_default_llm.openai_api_key or "",
base_url=_default_llm.openai_api_base or "",
)
def _build_agent_runtime(cfg: AgentConfig) -> AgentRuntime:
"""根据单个 AgentConfig 构建 AgentRuntime 实例。
输入:
cfg: Agent 配置(name/graph/tools/llm_model_id/system_prompt/max_iterations)。
输出:
完整的 AgentRuntime 实例,可被 execute_agent 直接使用。
"""
# 1. 构建 LLM
agent_llm = _build_llm_for_agent(cfg)
# 2. 获取所有可用工具并按配置过滤
all_tools = get_tools()
if cfg.tools:
agent_tools = [t for t in all_tools if t.name in cfg.tools]
else:
agent_tools = all_tools
# 3. bind_tools
model_with_tools = agent_llm.bind_tools(agent_tools, tool_choice="auto")
# 4. 构建图
graph = build_agent_graph(
tools=agent_tools,
model_with_tools=model_with_tools,
trajectory_rounds=cfg.trajectory_rounds,
)
# 5. 组装 AgentRuntime
return AgentRuntime(
name=cfg.name,
description=cfg.description,
graph=graph,
llm=agent_llm,
tools=agent_tools,
tools_by_name={tool.name: tool for tool in agent_tools},
system_prompt=cfg.system_prompt,
max_iterations=cfg.max_iterations,
trajectory_rounds=cfg.trajectory_rounds,
)
def build_all_agent_runtimes() -> dict[str, AgentRuntime]:
"""从 agent_config.json 读取所有启用 Agent 并构建运行时字典。
输出:
{agent_name: AgentRuntime} 字典,其中第一个 role="main" 的为 main_agent。
系统定位:
启动时调用一次,构建全局运行时缓存。
"""
global _all_agent_runtimes, _main_agent_name
configs = load_agent_configs()
_all_agent_runtimes = {}
_main_agent_name = "main"
for cfg in configs:
if not cfg.enabled:
continue
try:
runtime = _build_agent_runtime(cfg)
_all_agent_runtimes[cfg.name] = runtime
if cfg.role == "main":
_main_agent_name = cfg.name
except Exception as e:
print(f"[AgentFactory] 构建 Agent '{cfg.name}' 失败: {e}")
# 确保至少有一个 main agent
if _main_agent_name not in _all_agent_runtimes and _all_agent_runtimes:
first_name = next(iter(_all_agent_runtimes))
_main_agent_name = first_name
print(f"[AgentFactory] 未找到 role='main' 的 Agent,自动将 '{first_name}' 设为主 Agent。")
return _all_agent_runtimes
def get_main_agent_runtime() -> AgentRuntime | None:
"""获取当前配置中 role="main" 的 AgentRuntime。
若运行时缓存为空则自动构建。
"""
global _all_agent_runtimes
if not _all_agent_runtimes:
build_all_agent_runtimes()
return _all_agent_runtimes.get(_main_agent_name)
def get_all_agent_runtimes() -> dict[str, AgentRuntime]:
"""获取所有已构建的 Agent 运行时字典。"""
if not _all_agent_runtimes:
build_all_agent_runtimes()
return dict(_all_agent_runtimes)
def get_agent_runtime(name: str) -> AgentRuntime | None:
"""按名称获取指定 Agent 运行时。"""
if not _all_agent_runtimes:
build_all_agent_runtimes()
return _all_agent_runtimes.get(name)
def reload_all_agent_runtimes() -> dict:
"""重新加载配置并重建所有 Agent 运行时,返回当前状态。
输出:
{"agents": [{"name": str, "tools_count": int, "max_iterations": int}, ...],
"main_agent": str}
"""
global _all_agent_runtimes, _main_agent_name
_all_agent_runtimes.clear()
build_all_agent_runtimes()
# 同步更新模块级缓存引用
global main_agent_runtime, agent_graph, tools, TOOLS_BY_NAME, llm
main_rt = _all_agent_runtimes.get(_main_agent_name)
if main_rt:
main_agent_runtime = main_rt
agent_graph = main_rt.graph
tools = main_rt.tools
TOOLS_BY_NAME = main_rt.tools_by_name
llm = main_rt.llm
# 刷新 CallSubAgentTool 描述
_refresh_subagent_tool_description()
agents_info = []
for name, rt in _all_agent_runtimes.items():
agents_info.append({
"name": name,
"description": rt.description,
"tools_count": len(rt.tools),
"max_iterations": rt.max_iterations,
})
return {
"agents": agents_info,
"main_agent": _main_agent_name,
}
# ---------- Web/API 层入口 ----------
def execute_agent(
chat_history: list[dict[str, Any]],
session_id: str = "test",
agent_mode: AgentMode = "agent",
) -> list[dict[str, Any]]:
"""Web/API 层入口:使用 main_agent 运行时处理用户新一轮输入。"""
return execute_runtime_agent(
runtime=main_agent_runtime,
chat_history=chat_history,
session_id=session_id,
agent_mode=agent_mode,
)
def continue_after_human_action(
chat_history: list[dict[str, Any]],
session_id: str,
approval_id: str,
decision: Literal["approve", "reject", "edit"],
instruction: str | None = None,
) -> list[dict[str, Any]]:
"""Web/API 层入口:用户对待确认项决策后继续执行 main_agent。"""
return continue_runtime_after_human_action(
runtime=main_agent_runtime,
chat_history=chat_history,
session_id=session_id,
approval_id=approval_id,
decision=decision,
instruction=instruction,
)
# ---------- 首次导入时从配置构建主 Agent 运行时并缓存引用 ----------
_built = build_all_agent_runtimes()
main_agent_runtime = get_main_agent_runtime()
agent_graph = main_agent_runtime.graph
tools = main_agent_runtime.tools
TOOLS_BY_NAME = main_agent_runtime.tools_by_name
llm = main_agent_runtime.llm
def _refresh_subagent_tool_description() -> None:
"""在运行时构建完成后刷新 CallSubAgentTool 的描述,使主 Agent 知晓可委派的子 Agent。
通过传入 sub_names 和 sub_descriptions 参数避免从 agent_runtime 自身重新导入(循环导入)。
在 build_all_agent_runtimes() 和 reload_all_agent_runtimes() 之后调用。
"""
sub_names = []
sub_descriptions = {}
for name, rt in _all_agent_runtimes.items():
if name != _main_agent_name:
sub_names.append(name)
if rt.description:
sub_descriptions[name] = rt.description
from agent.tools.agent_call import CallSubAgentTool
for tool in tools:
if isinstance(tool, CallSubAgentTool):
tool._refresh_description(sub_names, sub_descriptions)
break
_refresh_subagent_tool_description()
+44
View File
@@ -0,0 +1,44 @@
[
{
"name": "main_agent",
"description": "",
"system_prompt": "你是一个具备多模态理解能力的桌面操作助手,能够直接理解用户上传的图片、语音和文件,并通过调用工具完成桌面自动化任务。\n【核心行为准则】\n- 你的首要职责是理解用户的意图,并高效地完成用户的任务。\n- 对于任何用户上传的图片、音频或文件,直接利用你的多模态能力分析并回答,不要为此调用截图等无关工具。\n- 只有在信息确实无法从当前对话或上传内容中获取,且必须通过外部工具才能获得时,才调用工具。例如:需要查看当前屏幕状态、需要执行 GUI 操作、需要搜索网络信息等。\n- **【强制规则】面对涉及 PPT 制作、代码项目等复杂产出类或系统操作类任务时,你必须先调用 skill_router 工具查询是否有对应的 Skill 操作指南。\n如果 skill_router 返回了匹配的 Skill,你必须将其视为最高优先级的执行规范,严格遵循 Skill 中定义的完整工作流,严禁跳过任何步骤,\n严禁用你自身的编程经验(如直接写 python-pptx 代码、用终端脚本批量生成等)替代 Skill 规范。Skill 规范的地位高于任何工具描述中的建议,当工具描述与 Skill 规范冲突时,以 Skill 规范为准。**\n- 当你调用工具收到截图或返回结果后,必须仔细观察返回内容,基于实际结果继续推进任务,严禁脱离实际结果凭空编造。\n- 进行浏览器操作时,慎用刷新工具,避免刷新导致页面状态丢失。\n- 对于同一个页面下的GUI操作,必须将连续操作合并为一次 gui_tool 调用(actions 列表传入多个动作),严禁逐个单步调用。仅当页面发生跳转/加载后需要重新观察时,才允许分步调用,注意单次调用最多包含5个动作。\n- 如果不确定是否需要工具,优先选择不调用,用已有信息给出最佳答案。\n- 对于需要多次操作才能完成的任务,应主动使用 todo_list 工具规划步骤并逐步推进,并在任务完成后将todo_list中的所有项标记为已完成。\n- 当任务完成时,向用户总结执行过程和结果。\n\n【工作空间配置】\n- 工作空间 (WORKSPACE_DIR): /app/src/workspace\n- 你产生的代码、脚本、图片、文档等文件,默认保存到工作空间目录下。\n- 使用 terminal_execute 工具时,如果不指定 working_dir,默认在工作空间执行命令。\n- 使用 doc_tool 创建/修改文件时,文件路径请使用工作空间的绝对路径。\n",
"max_iterations": 200,
"llm_model_id": "affiaa7r",
"tools": [
"date_query"
],
"enabled": true,
"role": "main",
"trajectory_rounds": 3
},
{
"name": "websearch_agent",
"description": "网络搜索子 Agent,擅长搜索引擎查询与信息整理。",
"system_prompt": "你是一个专业的网络内容搜索助手,由主 Agent 委派执行特定子任务。\n\n【身份定位】\n- 你是多 Agent 体系中的一个**子 Agent**,接收主 Agent 委派的任务并独立完成。\n- 你的职责是**专注完成被委派的任务**,不越权处理超出任务范围的事务。\n\n【工作流程】\n1. 接收主 Agent 下发的任务描述。\n2. 调用可用工具逐步完成任务。\n3. 完成后返回清晰的结果摘要。\n\n【关键行为准则】\n- **聚焦任务**:只执行被委派的具体任务,不要做额外操作或发散思考。\n- **高效执行**:尽量在一次推理链路中完成,减少不必要的确认或反复。\n- **如实汇报**:基于实际执行结果输出,不编造信息。遇到错误时说明原因。\n- **工具使用**:优先利用内置工具完成任务,但仅在必要时才调用。\n- **结果导向**:返回的结果应直接回应主 Agent 的问题,避免冗长铺垫。\n\n【输出格式】\n- 直接输出任务执行结果,用简洁、清晰的语言概括关键信息。\n- 如果有多个结果,用分点或编号列出。\n- 以「[完成]」标记任务执行完毕。",
"max_iterations": 200,
"llm_model_id": "affiaa7r",
"tools": [
"web_search",
"date_query"
],
"enabled": false,
"role": "sub",
"trajectory_rounds": 3
},
{
"name": "SQL_agent",
"description": "数据库操作子 Agent,擅长数据库操作。",
"system_prompt": "你是一个专业的数据库操作助手,由主 Agent 委派执行特定子任务。\n\n【身份定位】\n- 你是多 Agent 体系中的一个**子 Agent**,接收主 Agent 委派的任务并独立完成。\n- 你的职责是**专注完成被委派的任务**,不越权处理超出任务范围的事务。\n\n【工作流程】\n1. 接收主 Agent 下发的任务描述。\n2. 调用可用工具逐步完成任务。\n3. 完成后返回清晰的结果摘要。\n\n【关键行为准则】\n- **聚焦任务**:只执行被委派的具体任务,不要做额外操作或发散思考。\n- **高效执行**:尽量在一次推理链路中完成,减少不必要的确认或反复。\n- **如实汇报**:基于实际执行结果输出,不编造信息。遇到错误时说明原因。\n- **工具使用**:优先利用内置工具完成任务,但仅在必要时才调用。\n- **结果导向**:返回的结果应直接回应主 Agent 的问题,避免冗长铺垫。\n\n【输出格式】\n- 直接输出任务执行结果,用简洁、清晰的语言概括关键信息。\n- 如果有多个结果,用分点或编号列出。\n- 以「[完成]」标记任务执行完毕。",
"max_iterations": 200,
"llm_model_id": "affiaa7r",
"tools": [
"text2sql",
"excel2sql",
"hard_excel_read"
],
"enabled": false,
"role": "sub",
"trajectory_rounds": 3
}
]
+37
View File
@@ -0,0 +1,37 @@
{
"WORKING_DIR": ".",
"WORKSPACE_DIR": "workspace",
"USER_PYTHON_PATH": "",
"STREAMING_TTS_URL": "",
"ASR_BASE_URL": "",
"RAG_BASE_URL": "",
"WEB_SEARCH_API_KEY": "",
"WEB_SEARCH_ENGINE": "tavily",
"LLM_CONTEXT_WINDOW": "262144",
"MINI_COMPRESS_RATE": "0.4",
"HARD_COMPRESS_RATE": "0.7",
"CURRENT_TURNS": "4",
"IMG_SIZE": "768",
"RAG_CHUNK_SIZE": "500",
"RAG_CHUNK_OVERLAP": "50",
"GROUNDING_WIDTH": "1000",
"GROUNDING_HEIGHT": "1000",
"LOOP_DETECT_TODOLIST_STALE_ROUNDS": "20",
"LOOP_DETECT_REPEATED_TOOL_WARN": "3",
"LOOP_DETECT_REPEATED_TOOL_END": "5",
"SEND_FILE_SIZE_LIMIT": "30",
"EMAIL_ADDRESS": "",
"EMAIL_AUTH_CODE": "",
"gui_model_id": "",
"models": [
{
"id": "SETUP_REQUIRED",
"name": "请先完成首次配置",
"model": "",
"api_key": "",
"base_url": "",
"timeout": 60,
"max_retries": 0
}
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"gui_monitor_name": "\\\\.\\DISPLAY5",
"gui_monitor_index": 0,
"gui_model_id": "affiaa7r",
"monitors_snapshot": [
{
"name": "\\\\.\\DISPLAY5",
"x": 0,
"y": 0,
"width": 2560,
"height": 1440,
"is_primary": true
},
{
"name": "\\\\.\\DISPLAY1",
"x": -1350,
"y": 149,
"width": 1350,
"height": 2400,
"is_primary": false
}
]
}
+3
View File
@@ -0,0 +1,3 @@
{
"bg_image": ""
}
+244
View File
@@ -0,0 +1,244 @@
[
{
"name": "web_search",
"display_name": "WebSearch",
"description": "一个网络搜索工具,可以根据用户的查询返回相关的搜索结果。仅当需要查询近期知识或已有知识无法解答需要联网查询时调用,其余任何情况严禁调用。",
"permission": "direct",
"auto_execute_rules": [],
"enabled": true,
"timeout": 300
},
{
"name": "date_query",
"display_name": "DateQuery",
"description": "一个日期查询工具,可以返回当前的日期信息。仅当需要查询当前时间时调用,其余任何情况严禁调用。",
"permission": "direct",
"auto_execute_rules": [],
"enabled": true,
"timeout": 300
},
{
"name": "browser_control",
"display_name": "BrowserControl",
"description": "统一控制浏览器:打开/关闭浏览器、刷新/关闭标签页、打开指定 URL。",
"permission": "confirm",
"auto_execute_rules": [
{
"parameter": "action",
"operator": "equals",
"value": "open"
}
],
"enabled": true,
"timeout": 300
},
{
"name": "human_interaction",
"display_name": "HumanInteraction",
"description": "当任务缺少关键信息、需要人类选择路径、或需要人类审批后才能继续时调用。三种类型分别是:information=请求人类补充缺失信息;selection=让人类在多个选项中选择且可编辑;approval=请求人类审批高影响决策或继续执行。工具返回必须被视为用户原始意图的纯字符串反馈。其余情况严禁调用。",
"permission": "confirm",
"auto_execute_rules": [],
"enabled": true,
"timeout": 300
},
{
"name": "software_control",
"display_name": "SoftwareControl",
"description": "操作本机已安装的软件。工具会自动扫描 Windows 开始菜单快捷方式、桌面快捷方式和 App Paths,建立软件索引后根据用户输入做模糊匹配。action=open 时打开软件;action=close 时仅做正常关闭;action=force_close 时才允许在正常关闭失败后强制结束进程。仅当用户明确要求打开、关闭或强制关闭某个本机软件时调用。",
"permission": "confirm",
"auto_execute_rules": [
{
"parameter": "action",
"operator": "equals",
"value": "open"
}
],
"enabled": true,
"timeout": 300
},
{
"name": "rag_tool",
"display_name": "RAGTool",
"description": "个人知识库管理工具,支持增删查及列出所有来源操作。",
"permission": "confirm",
"auto_execute_rules": [
{
"parameter": "action_type",
"operator": "equals",
"value": "query"
}
],
"enabled": true,
"timeout": 300
},
{
"name": "directory_listing",
"display_name": "DirectoryListing",
"description": "列出指定目录下的所有文件和子目录。可用于探索文件系统、查找文件位置。仅当需要获取目录内容或确认文件结构时调用,其余情况严禁调用。",
"permission": "direct",
"auto_execute_rules": [],
"enabled": true,
"timeout": 300
},
{
"name": "todo_list",
"display_name": "TodoList",
"description": "任务管理工具。用于将复杂任务拆分为多个步骤并跟踪执行进度。",
"permission": "direct",
"auto_execute_rules": [],
"enabled": true,
"timeout": 300
},
{
"name": "gui_tool",
"display_name": "GUITool",
"description": "GUI 自动化工具。通过截图视觉定位桌面元素,将坐标映射到真实屏幕,并执行 pyautogui 动作。接收一个有序的动作列表,按顺序依次执行。每个动作项支持的操作:click(点击)、type(输入文本,可通过 element_description 指定目标输入框自动聚焦)、drag_and_drop(拖拽)、scroll(滚动)、hotkey(快捷键)、hold_and_press(按住组合键",
"permission": "direct",
"auto_execute_rules": [],
"enabled": true,
"timeout": 300
},
{
"name": "call_subagent",
"display_name": "CallSubAgentTool",
"description": "调用一个已启用的子 Agent 执行指定任务。子 Agent 会独立运行并返回最终结果。适用于需要专业能力(如代码分析、文档处理等)进行辅助的场景。可用的子 Agent 名称取决于 agent_config.json 中已启用且 role='sub' 的配置。",
"permission": "direct",
"auto_execute_rules": [],
"enabled": true,
"timeout": 300
},
{
"name": "doc_tool",
"display_name": "DocTool",
"description": "文档操作工具。支持纯文本(plain_text)、表格(tabular: CSV/XLSX)、Word(docx)、PowerPoint(pptx)、PDF 文档的增删改查,以及图片(image)、音频(audio)的读取。",
"permission": "direct",
"auto_execute_rules": [],
"enabled": true,
"timeout": 300
},
{
"name": "skill_router",
"display_name": "SkillRouter",
"description": "Skill 路由工具。根据用户的 query(问题或任务描述),从已安装的 skill 中智能匹配最相关的操作指南,并返回指南全文。",
"permission": "direct",
"auto_execute_rules": [
{
"parameter": "query",
"operator": "exists",
"value": ""
}
],
"enabled": true,
"timeout": 300
},
{
"name": "send_file",
"display_name": "SendFileTool",
"description": "向用户发送文件的工具。根据文件类型自动处理:",
"permission": "direct",
"auto_execute_rules": [],
"enabled": true,
"timeout": 300
},
{
"name": "terminal_execute",
"display_name": "TerminalExecute",
"description": "终端命令执行工具。在本地终端(PowerShell)中执行 shell 命令并返回执行结果。",
"permission": "direct",
"auto_execute_rules": [],
"enabled": true,
"timeout": 300
},
{
"name": "mail_tool",
"display_name": "MailTool",
"description": "QQ email tool. Supports sending and reading email without GUI.",
"permission": "direct",
"auto_execute_rules": [
{
"parameter": "action",
"operator": "equals",
"value": "read"
}
],
"enabled": true,
"timeout": 300
},
{
"name": "image_gen",
"display_name": "ImageGen",
"description": "AI 图像生成工具,将文字描述转换为高质量图片。支持详细的中英文提示词,可指定尺寸和排除元素。生成的图片自动保存到 workspace 目录。适用场景:用户要求画图、生成图片、制作海报、设计插图等。",
"permission": "direct",
"auto_execute_rules": [],
"enabled": true,
"timeout": 300
},
{
"name": "cron_manager",
"display_name": "CronManager",
"description": "定时任务管理工具。用于查看(含状态)、新增、修改、删除定时任务。支持三种触发方式:cron 表达式、固定间隔、一次性延时。适用场景:用户希望设置/查看/修改定时任务,或 Agent 在定时任务模式下需管理其他定时任务。",
"permission": "direct",
"auto_execute_rules": [],
"enabled": true,
"timeout": 300
},
{
"name": "text2sql",
"display_name": "Text2SQL",
"description": "数据库操作工具(text2sql)。支持查看数据库结构(列出表、查看表结构)、查询表内容(SELECT),对数据库内容做增删改(INSERT/UPDATE/DELETE/DDL),以及建库建表(create_database/create_table)。查类与建库建表操作(list_tables/describe/query/create_database/create_table)直接执行;增删改(execute)需用户确认。连接配置复用 env_config.json 的 MYSQL_*(host/port/user/password),database 参数必填(用户需告知要操作的数据库名)。",
"permission": "confirm",
"auto_execute_rules": [
{
"parameter": "action",
"operator": "equals",
"value": "list_tables"
},
{
"parameter": "action",
"operator": "equals",
"value": "describe"
},
{
"parameter": "action",
"operator": "equals",
"value": "query"
},
{
"parameter": "action",
"operator": "equals",
"value": "create_database"
},
{
"parameter": "action",
"operator": "equals",
"value": "create_table"
}
],
"enabled": true,
"timeout": 300
},
{
"name": "hard_excel_read",
"display_name": "HardExcelRead",
"description": "复杂Excel表结构读取工具。读取指定sheet(或所有sheet)前N行原始数据+辅助摘要(维度/合并区/列类型与样本/数据区范围建议)返回给大模型解析复杂表结构。不含数据区全量行。配合excel2sql完成解析→入库。",
"permission": "direct",
"auto_execute_rules": [],
"enabled": true,
"timeout": 300
},
{
"name": "excel2sql",
"display_name": "Excel2SQL",
"description": "按大模型给定的数据行列范围,自动建库建表(表COMMENT存表格描述)并把Excel数据区流式批量入库MySQL。不做重清洗DSL,按范围直接入库;值级清洗入库后用text2sql(execute)完成。dry_run=true时直接执行复核,false需确认。",
"permission": "direct",
"auto_execute_rules": [
{
"parameter": "dry_run",
"operator": "equals",
"value": "true"
}
],
"enabled": true,
"timeout": 300
}
]
+7
View File
@@ -0,0 +1,7 @@
{
"current": "C:\\Users\\86166\\Desktop\\新建文件夹",
"list": [
"C:\\Users\\86166\\Desktop\\Agent_Learning_minor\\Agent\\src\\workspace",
"C:\\Users\\86166\\Desktop\\新建文件夹"
]
}
+16
View File
@@ -0,0 +1,16 @@
"""Agent 核心运行时包。
包含:
- state / graph: LangGraph 状态与图拓扑
- nodes / routing: 图节点与条件路由
- runtime: 对外执行入口 execute_agent / continue_after_human_action
- llm: ChatQwen 多模态网关
- approvals: HITL 待确认管理(含 human_interaction 参数规范化)
- tool_policy: 工具执行安全策略
系统定位:
Agent 业务逻辑中枢,``agents`` 包在此之上组装具体 Agent 实例。
可扩展性:
子 Agent 可复用本包组件,仅替换 tools、system_prompt 与 graph 变体。
"""
+257
View File
@@ -0,0 +1,257 @@
"""待确认操作(HITL):内存注册、UI 元数据构建、参数规范化与结果生成。
系统定位:
维护进程内 ``PENDING_TOOL_APPROVALS`` 字典;在图因需确认工具而暂停时
保存上下文并构建前端 UI 元数据;在用户操作后生成 ToolMessage 供图继续执行。
内容分区:
- PENDING 存储与元数据构建(build_approval_meta / build_human_interaction_meta)
- UI 消息辅助(remove_pending_ui_message)
- human_interaction 工具的参数规范化与结果文案(normalize / result)
注意:
当前为内存存储,进程重启后待确认项丢失;生产环境可改为 Redis/DB。
可扩展性:
- 增加 TTL、session 级清理、持久化后端。
- ``build_*_meta`` 可与前端组件协议版本化。
- 新增 interaction_type 时同步扩展 normalize / result 分支。
"""
from __future__ import annotations
import json
import uuid
from typing import Any, Sequence
from langchain_core.messages import BaseMessage
from langchain_core.messages import messages_to_dict
from agent.tools.human_interaction import HumanInteraction
from agent.utils.tool_call_utils import json_safe
# ============================================================
# PENDING 存储
# ============================================================
PENDING_TOOL_APPROVALS: dict[str, dict[str, Any]] = {}
# ============================================================
# UI 元数据构建
# ============================================================
def build_approval_meta(session_id: str, tool_call: dict[str, Any], messages: Sequence[BaseMessage], agent_name: str = "主Agent") -> dict[str, Any]:
"""为需确认的普通工具调用构建 UI 元数据并注册 pending。
输入:
session_id: 会话 ID。
tool_call: normalize 后的工具调用 dict。
messages: 当前图内消息序列(序列化后存入 pending)。
agent_name: 发起该待确认项的 agent 名称(主 Agent 默认 "主Agent")。
输出:
``{"pending_action": {...}}``,含 id、type=tool_call、args、options 等。
"""
approval_id = uuid.uuid4().hex
tool_name = tool_call.get("name") or "unknown_tool"
args = json_safe(tool_call.get("args") or {})
display_call = {"name": tool_name, "args": args}
instruction = json.dumps(display_call, ensure_ascii=False, indent=2)
PENDING_TOOL_APPROVALS[approval_id] = {
"session_id": session_id,
"type": "tool_call",
"tool_call": {"id": tool_call.get("id"), "name": tool_name, "args": args},
"messages": messages_to_dict(list(messages)),
"agent_name": agent_name,
"is_sub_agent": agent_name not in ("", "主Agent", "__main__"),
}
return {
"pending_action": {
"id": approval_id,
"type": "tool_call",
"title": f"待确认执行工具:{tool_name}",
"tool_name": tool_name,
"args": display_call,
"instruction": instruction,
"options": ["approve", "reject"],
"agent_name": agent_name,
"is_sub_agent": agent_name not in ("", "主Agent", "__main__"),
"resolved": False,
}
}
def build_human_interaction_meta(session_id: str, tool_call: dict[str, Any], messages: Sequence[BaseMessage], agent_name: str = "主Agent") -> dict[str, Any]:
"""为 human_interaction 工具构建 UI 元数据并注册 pending。
输入:
session_id: 会话 ID。
tool_call: 含 name=args 的工具调用。
messages: 当前消息序列。
agent_name: 发起该待确认项的 agent 名称(主 Agent 默认 "主Agent")。
输出:
``{"pending_action": {...}}``,type=human_interaction,含 interaction_type 等。
"""
approval_id = uuid.uuid4().hex
args = normalize_human_interaction_args(tool_call.get("args") or {})
interaction_type = args.get("interaction_type") or "information"
default_titles = {
"information": "请补充信息",
"selection": "请选择一个选项",
}
PENDING_TOOL_APPROVALS[approval_id] = {
"session_id": session_id,
"type": "human_interaction",
"tool_call": {"id": tool_call.get("id"), "name": "human_interaction", "args": args},
"messages": messages_to_dict(list(messages)),
"agent_name": agent_name,
"is_sub_agent": agent_name not in ("", "主Agent", "__main__"),
}
return {
"pending_action": {
"id": approval_id,
"type": "human_interaction",
"interaction_type": interaction_type,
"title": args.get("title") or default_titles.get(str(interaction_type), "需要人工处理"),
"prompt": args.get("prompt") or "请处理后继续。",
"tool_name": "human_interaction",
"args": {
"交互类型": interaction_type,
"说明": args.get("prompt") or "请处理后继续。",
"可选项": args.get("options") or [],
},
"instruction": args.get("suggested_response") or "",
"options": args.get("options") or [],
"questions": args.get("questions") or None,
"agent_name": agent_name,
"is_sub_agent": agent_name not in ("", "主Agent", "__main__"),
"resolved": False,
}
}
def remove_pending_ui_message(chat_history: list[dict[str, Any]], approval_id: str) -> None:
"""在 chat_history 中将指定 approval_id 的 pending_action 标记为已解决(原地修改)。"""
for msg in reversed(chat_history):
pending = ((msg.get("meta") or {}).get("pending_action") or {}) if isinstance(msg, dict) else {}
if pending.get("id") == approval_id:
pending["resolved"] = True
msg.setdefault("meta", {})["pending_action"] = pending
return
def get_pending_actions_for_session(session_id: str) -> list[dict[str, Any]]:
"""获取指定会话所有未解决的 pending_action 列表(human_interaction + tool_call + sub_agent)。
从 PENDING_TOOL_APPROVALS 中扫描,返回可供前端浮窗渲染的 pending_action 字典列表。
每项携带 agent_name / is_sub_agent,供前端按 agent 分组弹窗。
"""
actions = []
for approval_id, entry in PENDING_TOOL_APPROVALS.items():
if entry.get("session_id") != session_id:
continue
is_sub = bool(entry.get("is_sub_agent"))
agent_name = entry.get("agent_name") or ("主Agent" if not is_sub else "子Agent")
entry_type = entry.get("type")
if entry_type == "human_interaction":
args = normalize_human_interaction_args((entry.get("tool_call") or {}).get("args") or {})
interaction_type = args.get("interaction_type") or "information"
default_titles = {
"information": "请补充信息",
"selection": "请选择一个选项",
}
actions.append({
"id": approval_id,
"type": "human_interaction",
"interaction_type": interaction_type,
"title": args.get("title") or default_titles.get(str(interaction_type), "需要人工处理"),
"prompt": args.get("prompt") or "请处理后继续。",
"instruction": args.get("suggested_response") or "",
"options": args.get("options") or [],
"questions": args.get("questions") or None,
"tool_name": "human_interaction",
"agent_name": agent_name,
"is_sub_agent": is_sub,
"resolved": False,
})
elif entry_type == "tool_call":
tool_call = entry.get("tool_call") or {}
tool_name = tool_call.get("name") or "unknown_tool"
args = tool_call.get("args") or {}
display_call = {"name": tool_name, "args": args}
actions.append({
"id": approval_id,
"type": "tool_call",
"title": f"待确认执行工具:{tool_name}",
"tool_name": tool_name,
"args": display_call,
"instruction": json.dumps(display_call, ensure_ascii=False, indent=2),
"options": ["approve", "skip"] if is_sub else ["approve", "reject", "skip"],
"agent_name": agent_name,
"is_sub_agent": is_sub,
"resolved": False,
})
return actions
# ============================================================
# human_interaction 工具的参数规范化与结果文案
# ============================================================
def normalize_human_interaction_args(args: dict[str, Any]) -> dict[str, Any]:
"""规范化 human_interaction 工具参数为固定 schema。
输入:
args: 模型传入的原始参数字典。
输出:
含 interaction_type、title、prompt、options、suggested_response、questions 的字典;
interaction_type 限定为 information | selection。
"""
normalized = dict(args or {})
normalized["interaction_type"] = str(normalized.get("interaction_type") or "information")
if normalized["interaction_type"] not in {"information", "selection"}:
normalized["interaction_type"] = "information"
normalized["title"] = str(normalized.get("title") or "需要人工处理")
normalized["prompt"] = str(normalized.get("prompt") or "请处理后继续。")
normalized["options"] = HumanInteraction.normalize_options(normalized.get("options"))
normalized["suggested_response"] = str(normalized.get("suggested_response") or "")
# 规范化 questions 字段
questions = normalized.get("questions")
if isinstance(questions, str):
try:
questions = json.loads(questions)
except (json.JSONDecodeError, TypeError):
questions = None
if isinstance(questions, list):
normalized_questions = []
for q in questions:
if isinstance(q, dict):
nq = {
"question": str(q.get("question", "")),
"options": HumanInteraction.normalize_options(q.get("options")),
}
normalized_questions.append(nq)
normalized["questions"] = normalized_questions if normalized_questions else None
else:
normalized["questions"] = None
return normalized
def human_interaction_result(pending: dict[str, Any], decision: str, instruction: str | None) -> str:
"""根据用户决策生成写入 ToolMessage 的文本结果。
输入:
pending: PENDING_TOOL_APPROVALS 中 type=human_interaction 的条目。
decision: approve | reject | edit。
instruction: 用户输入文本(补充信息、选项、修改说明等)。
输出:
供 LangGraph 继续推理的 ToolMessage 字符串内容。
"""
tool_call = pending.get("tool_call") or {}
args = normalize_human_interaction_args(tool_call.get("args") or {})
interaction_type = args.get("interaction_type") or "information"
options = args.get("options") or []
return HumanInteraction.format_result(interaction_type, options, instruction or "")
+550
View File
@@ -0,0 +1,550 @@
"""Agent 配置管理:从 JSON 文件加载/保存 agent、tool、env、model 配置。
系统定位:
提供统一接口读写 ``agent/config/`` 下的 JSON 配置文件,
使前端手动配置的 JSON 可以替代 core 底下的相应 .py 硬编码。
配置文件:
agent_config.json - Agent 运行时配置(名称、模型引用、工具列表、system_prompt)
tool_config.json - 工具权限与参数配置
env_config.json - 环境变量与 LLM 模型列表
可扩展性:
可增加版本号、校验 schema、多环境配置切换。
"""
from __future__ import annotations
import json
import threading
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
# ---------- 配置文件路径 ----------
_CONFIG_DIR = Path(__file__).resolve().parent.parent / "config"
AGENT_CONFIG_PATH = _CONFIG_DIR / "agent_config.json"
TOOL_CONFIG_PATH = _CONFIG_DIR / "tool_config.json"
ENV_CONFIG_PATH = _CONFIG_DIR / "env_config.json"
# ---------- 模型配置 ----------
@dataclass
class ModelConfig:
id: str = "" # 唯一标识
name: str = "" # 显示名称
api_key: str = ""
base_url: str = ""
model: str = "" # API 模型名
timeout: float = 60.0
max_retries: int = 0
@dataclass
class AgentConfig:
name: str = ""
description: str = "" # 简短能力描述,暴露给主Agent用于委派决策
system_prompt: str = ""
max_iterations: int = 200
llm_model_id: str = "" # 引用 env_config.json models 列表中的模型 ID
tools: list[str] = field(default_factory=list)
enabled: bool = True
role: str = "sub" # "main" | "sub"
trajectory_rounds: int = 3 # 短期记忆:执行过程中保留最近 N 轮工具返回结果
@dataclass
class ToolConfig:
name: str = ""
display_name: str = ""
description: str = ""
permission: str = "confirm" # "direct" | "confirm"
auto_execute_rules: list[dict] = field(default_factory=list) # [{"parameter":"action","operator":"equals","value":"open"}, ...]
enabled: bool = True
timeout: float = 300.0 # 工具执行超时(秒),超时后返回"工具执行超时"
# ---------- 通用 JSON 读写 ----------
def _read_json(path: Path) -> dict | list | None:
"""读取 JSON 文件,不存在或格式错误返回 None。"""
if not path.is_file():
return None
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
return None
def _write_json(path: Path, data: Any) -> bool:
"""写入 JSON 文件,成功返回 True。"""
try:
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
return True
except OSError:
return False
# ---------- Agent 配置 ----------
def _default_agent_config() -> list[dict]:
"""生成默认 agent 配置(单 agent: main)。"""
from agent.memory.system_prompt import get_main_system_prompt
from agent.tools import get_all_available_tool_names
models = load_models()
default_model_id = models[0]["id"] if models else ""
return [
{
"name": "main",
"system_prompt": get_main_system_prompt(),
"max_iterations": 200,
"llm_model_id": default_model_id,
"tools": get_all_available_tool_names(),
"enabled": True,
"role": "main",
"trajectory_rounds": 3,
}
]
def load_agent_configs() -> list[AgentConfig]:
"""从 agent_config.json 加载 agent 配置列表,无文件时返回默认。
对于 role='main' 的 Agent,system_prompt 始终从 get_main_system_prompt() 动态获取
(忽略 JSON 中的静态值),确保提示词可热更新且包含正确的动态路径。
对于 role='sub' 的 Agent,若未指定 system_prompt 则自动使用默认子 Agent 提示词。
"""
from agent.memory.system_prompt import DEFAULT_SUB_AGENT_PROMPT, get_main_system_prompt
raw = _read_json(AGENT_CONFIG_PATH)
if not raw or not isinstance(raw, list):
raw = _default_agent_config()
_write_json(AGENT_CONFIG_PATH, raw)
configs = []
for item in raw:
role = item.get("role", "sub")
if role == "main":
# main agent 始终从代码动态加载 system_prompt,保证热更新和动态路径正确
system_prompt = get_main_system_prompt()
else:
system_prompt = item.get("system_prompt", "")
# 子 Agent 未指定提示词时使用默认子 Agent 提示词
if not system_prompt:
system_prompt = DEFAULT_SUB_AGENT_PROMPT
configs.append(AgentConfig(
name=item.get("name", ""),
description=item.get("description", ""),
system_prompt=system_prompt,
max_iterations=item.get("max_iterations", 200),
llm_model_id=item.get("llm_model_id", ""),
tools=item.get("tools", []),
enabled=item.get("enabled", True),
role=role,
trajectory_rounds=item.get("trajectory_rounds", 3),
))
return configs
def save_agent_configs(configs: list[dict]) -> bool:
"""将 agent 配置列表写入 agent_config.json。"""
return _write_json(AGENT_CONFIG_PATH, configs)
# ---------- Tool 配置 ----------
# 各工具的默认 auto_execute_rules —— 与 tool_policy.py 的硬编码规则对应
_TOOL_DEFAULT_AUTO_RULES: dict[str, list[dict]] = {
"browser_control": [
{"parameter": "action", "operator": "equals", "value": "open"},
],
"rag_tool": [
{"parameter": "action_type", "operator": "equals", "value": "query"},
],
"skill_router": [
{"parameter": "query", "operator": "exists", "value": ""},
],
"mail_tool": [
{"parameter": "action", "operator": "equals", "value": "read"},
],
}
def _get_tool_parameters() -> dict[str, list[dict]]:
"""获取每个工具的参数名和类型信息,供前端生成 auto_execute_rules 提示。
返回:
{tool_name: [{"name": "action", "type": "Literal[open,close]"}, ...], ...}
"""
from agent.tools import _ALL_AVAILABLE_TOOL_CLASSES
import typing
result: dict[str, list[dict]] = {}
for name, cls in _ALL_AVAILABLE_TOOL_CLASSES.items():
try:
inst = cls()
schema = getattr(inst, "args_schema", None)
if schema is None:
result[name] = []
continue
fields_info = getattr(schema, "model_fields", {})
params = []
for fname, finfo in fields_info.items():
annotation = finfo.annotation
type_str = str(annotation) if annotation is not None else "Any"
# 简化 Literal 显示
import re
m = re.search(r"Literal\[(.+?)\]", type_str)
if m:
type_str = "Literal[" + m.group(1) + "]"
params.append({"name": fname, "type": type_str})
result[name] = params
except Exception:
result[name] = []
return result
def _default_tool_config() -> list[dict]:
"""根据所有可用工具生成默认 tool 配置。"""
from agent.tools import get_all_available_tool_names, get_all_available_tool_descriptions
from agent.core.tool_policy import classify_tool_execution
all_names = get_all_available_tool_names()
all_descriptions = get_all_available_tool_descriptions()
result = []
for tname in all_names:
display_name, desc = all_descriptions.get(tname, (tname, ""))
perm = classify_tool_execution(tname, {})
default_rules = _TOOL_DEFAULT_AUTO_RULES.get(tname, [])
result.append({
"name": tname,
"display_name": display_name,
"description": desc,
"permission": perm,
"auto_execute_rules": default_rules,
"enabled": True,
"timeout": 300.0,
})
return result
def load_tool_configs() -> list[ToolConfig]:
"""从 tool_config.json 加载工具配置,无文件时返回默认;有新工具时自动补入。"""
raw = _read_json(TOOL_CONFIG_PATH)
if not raw or not isinstance(raw, list):
raw = _default_tool_config()
_write_json(TOOL_CONFIG_PATH, raw)
# 补上代码中已注册但 JSON 中缺失的新工具
registered = get_registered_tool_names()
existing_names = {item.get("name", "") for item in raw}
missing = [n for n in registered if n not in existing_names]
if missing:
defaults = {t["name"]: t for t in _default_tool_config()}
for name in missing:
if name in defaults:
raw.append(defaults[name])
_write_json(TOOL_CONFIG_PATH, raw)
configs = []
for item in raw:
configs.append(ToolConfig(
name=item.get("name", ""),
display_name=item.get("display_name", ""),
description=item.get("description", ""),
permission=item.get("permission", "confirm"),
auto_execute_rules=item.get("auto_execute_rules", []),
enabled=item.get("enabled", True),
timeout=float(item.get("timeout", 300.0)),
))
return configs
def save_tool_configs(configs: list[dict]) -> bool:
"""将 tool 配置列表写入 tool_config.json。"""
ok = _write_json(TOOL_CONFIG_PATH, configs)
invalidate_tool_timeout_cache() # 配置变更后清除超时缓存,确保新执行读到新超时
return ok
# ---------- 工具超时缓存 ----------
DEFAULT_TOOL_TIMEOUT = 300.0
_tool_timeout_cache: dict[str, float] = {}
_tool_timeout_cache_lock = threading.Lock()
def get_tool_timeout(tool_name: str) -> float:
"""获取工具执行超时(秒),缓存 {tool_name: timeout}。
未配置或查询失败返回 DEFAULT_TOOL_TIMEOUT(300)。
供 invoke_tool_and_build_message 在执行工具前查询。
"""
if not tool_name:
return DEFAULT_TOOL_TIMEOUT
with _tool_timeout_cache_lock:
if tool_name in _tool_timeout_cache:
return _tool_timeout_cache[tool_name]
timeout = DEFAULT_TOOL_TIMEOUT
try:
for cfg in load_tool_configs():
if cfg.name == tool_name:
timeout = cfg.timeout if cfg.timeout and cfg.timeout > 0 else DEFAULT_TOOL_TIMEOUT
break
except Exception:
timeout = DEFAULT_TOOL_TIMEOUT
with _tool_timeout_cache_lock:
_tool_timeout_cache[tool_name] = timeout
return timeout
def invalidate_tool_timeout_cache() -> None:
"""清除工具超时缓存(save_tool_configs 后调用,确保配置变更立即生效)。"""
with _tool_timeout_cache_lock:
_tool_timeout_cache.clear()
# ---------- 强制权限工具(前端锁死,不可修改) ----------
# 仅必须 confirm 的工具锁死;safe_tools 默认 direct 但用户可改为 confirm
_FORCED_CONFIRM_TOOLS = {"human_interaction"}
def get_tool_forced_permissions() -> dict[str, str]:
"""返回强制权限的工具映射 {tool_name: "confirm"},
前端应对这些工具禁用权限下拉框和 auto_execute_rules 编辑。"""
return {name: "confirm" for name in _FORCED_CONFIRM_TOOLS}
# ---------- 模型管理 ----------
def _default_models() -> list[dict]:
"""生成默认模型列表,首次启动时从 .env 继承已有 LLM 配置。"""
import os
api_key = os.environ.get("LLM_API_KEY", "")
base_url = os.environ.get("LLM_BASE_URL", "")
model = os.environ.get("LLM_MODEL", "")
timeout = os.environ.get("LLM_TIMEOUT", "60")
if api_key and base_url and model:
return [
{
"id": str(uuid.uuid4())[:8],
"name": "默认模型",
"api_key": api_key,
"base_url": base_url,
"model": model,
"timeout": float(timeout) if timeout else 60.0,
"max_retries": 0,
}
]
return []
def load_models() -> list[dict]:
"""从 env_config.json 的 models 字段加载模型列表。"""
raw = _read_json(ENV_CONFIG_PATH)
if isinstance(raw, dict) and "models" in raw and isinstance(raw["models"], list) and raw["models"]:
return raw["models"]
return _default_models()
def save_models(models: list[dict]) -> bool:
"""将模型列表写入 env_config.json(合并其他字段)。"""
current = load_env_config()
current["models"] = models
return _write_json(ENV_CONFIG_PATH, current)
# ---------- Env 配置 ----------
def _default_env_config() -> dict:
"""生成默认 env 配置(不含 models,models 由 load_models 独立处理)。"""
import os as _os
_default_working = _os.path.dirname(_os.path.dirname(_os.path.dirname(_os.path.dirname(_os.path.abspath(__file__)))))
return {
"WORKING_DIR": _default_working,
"WORKSPACE_DIR": "",
"STREAMING_TTS_URL": "",
"ASR_BASE_URL": "",
"RAG_BASE_URL": "",
"IMAGE_GEN_URL": "http://localhost:8904",
"LLM_CONTEXT_WINDOW": "262144",
"MINI_COMPRESS_RATE": "0.4",
"HARD_COMPRESS_RATE": "0.7",
"CURRENT_TURNS": "4",
"IMG_SIZE": "768",
"RAG_CHUNK_SIZE": "500",
"RAG_CHUNK_OVERLAP": "50",
"GROUNDING_WIDTH": "1000",
"GROUNDING_HEIGHT": "1000",
"LOOP_DETECT_TODOLIST_STALE_ROUNDS": "10",
"LOOP_DETECT_REPEATED_TOOL_WARN": "3",
"LOOP_DETECT_REPEATED_TOOL_END": "5",
"SEND_FILE_SIZE_LIMIT": "30",
"STORAGE_BACKEND": "json",
"MYSQL_HOST": "localhost",
"MYSQL_PORT": "3306",
"MYSQL_USER": "root",
"MYSQL_PASSWORD": "",
"MYSQL_DATABASE": "agent_history",
"EMAIL_ADDRESS": "",
"EMAIL_AUTH_CODE": "",
}
def load_env_config() -> dict:
"""从 env_config.json 加载环境变量配置,无文件时返回默认。"""
raw = _read_json(ENV_CONFIG_PATH)
if not raw or not isinstance(raw, dict):
raw = _default_env_config()
raw["models"] = _default_models()
_write_json(ENV_CONFIG_PATH, raw)
return raw
# 确保有 models 字段
if "models" not in raw or not isinstance(raw.get("models"), list):
raw["models"] = _default_models()
return raw
def save_env_config(config: dict) -> bool:
"""将 env 配置写入 env_config.json(保留已有 models)。"""
# 保留已有的 models 列表(由 models API 单独管理)
existing = _read_json(ENV_CONFIG_PATH)
if isinstance(existing, dict) and "models" in existing:
config["models"] = existing["models"]
return _write_json(ENV_CONFIG_PATH, config)
# ---------- 获取所有已注册工具的基础信息 ----------
def get_registered_tool_names() -> list[str]:
"""返回所有可用工具名称(含数据库工具)。"""
from agent.tools import get_all_available_tool_names
return get_all_available_tool_names()
# ---------- GUI 显示器配置 ----------
GUI_CONFIG_PATH = _CONFIG_DIR / "gui_config.json"
def _default_gui_config() -> dict:
"""生成默认 GUI 显示器配置(主显示器)。"""
try:
from screeninfo import get_monitors
monitors = get_monitors()
primary = next((m for m in monitors if m.is_primary), monitors[0] if monitors else None)
if primary:
return {
"gui_monitor_name": primary.name or "",
"gui_monitor_index": 0,
"gui_model_id": "",
"monitors_snapshot": _serialize_monitors(monitors),
}
except Exception:
pass
return {
"gui_monitor_name": "",
"gui_monitor_index": 0,
"gui_model_id": "",
"monitors_snapshot": [],
}
def _serialize_monitors(monitors) -> list[dict]:
"""将 screeninfo Monitor 对象序列化为可 JSON 化的字典列表。"""
result = []
for m in monitors:
result.append({
"name": m.name or "",
"x": m.x,
"y": m.y,
"width": m.width,
"height": m.height,
"is_primary": m.is_primary,
})
return result
def _get_live_monitors() -> list[dict]:
"""实时获取当前连接的显示器列表(通过 screeninfo)。"""
from screeninfo import get_monitors
return _serialize_monitors(get_monitors())
def load_gui_config() -> dict:
"""从 gui_config.json 加载 GUI 显示器配置,无文件时返回默认。"""
raw = _read_json(GUI_CONFIG_PATH)
if not raw or not isinstance(raw, dict):
raw = _default_gui_config()
_write_json(GUI_CONFIG_PATH, raw)
return raw
def save_gui_config(config: dict) -> bool:
"""将 GUI 显示器配置写入 gui_config.json。"""
return _write_json(GUI_CONFIG_PATH, config)
def get_gui_model_config() -> dict | None:
"""获取 GUI 工具专用的模型配置。
从 gui_config.json 读取 gui_model_id,在 env_config.json 的 models 列表中查找对应模型。
若 gui_model_id 为空或找不到对应模型,返回 None(表示使用 agent 主模型)。
返回:
模型配置 dict 或 None
"""
gui_config = load_gui_config()
model_id = gui_config.get("gui_model_id", "").strip()
if not model_id:
return None
models = load_models()
for m in models:
if m.get("id", "") == model_id:
return dict(m)
return None
def get_gui_llm():
"""获取 GUI 工具专用的 LLM 实例。
若 gui_config.json 中 gui_model_id 已配置且在模型列表中存在,返回对应的 ChatQwen 实例;
否则返回 None(调用方应回退到全局 llm 单例)。
返回:
ChatQwen 实例或 None
"""
model_cfg = get_gui_model_config()
if model_cfg is None:
return None
from agent.core.llm import ChatQwen
return ChatQwen(
model=model_cfg["model"],
api_key=model_cfg["api_key"],
base_url=model_cfg["base_url"],
timeout=model_cfg.get("timeout", 60),
max_retries=model_cfg.get("max_retries", 0),
extra_body={} if model_cfg.get("enable_thinking") else None,
)
def get_active_monitor_region() -> dict | None:
"""获取当前选中的 GUI 操作显示器的区域信息。
返回:
{"x": int, "y": int, "width": int, "height": int, "name": str} 或 None
系统定位:
供 gui_tool.py 截取指定显示器区域并做坐标变换。
"""
config = load_gui_config()
monitor_name = config.get("gui_monitor_name", "")
monitors = _get_live_monitors()
if not monitors:
return None
# 按名称模糊匹配
if monitor_name:
for m in monitors:
if monitor_name.lower() in (m.get("name", "") or "").lower():
return {"x": m["x"], "y": m["y"], "width": m["width"], "height": m["height"], "name": m["name"]}
# 回退:主显示器
primary = next((m for m in monitors if m.get("is_primary")), monitors[0] if monitors else None)
if primary:
return {"x": primary["x"], "y": primary["y"], "width": primary["width"], "height": primary["height"], "name": primary["name"]}
return None
+159
View File
@@ -0,0 +1,159 @@
"""共享数据库连接与建表工具。
系统定位:
为 cron 任务存储与普通会话历史存储提供统一的 MySQL 连接、配置读取、
幂等建表与后端选择入口,避免两处存储后端配置漂移。
可扩展性:
- 新增存储后端时复用 mysql_conn / ensure_tables / get_storage_backend。
- ensure_tables 按表名细粒度幂等,cron 表与 session 表互不影响。
- 连接配置统一读 MYSQL_*(cron 与 session 共用同一数据库)。
- 向后兼容:旧配置中的 CRON_MYSQL_* 仍可读取(逐步迁移)。
"""
from __future__ import annotations
import re
import threading
from typing import Any
def load_mysql_config() -> dict[str, Any]:
"""从 env_config.json 读取 MySQL 连接配置(MYSQL_*,cron 与 session 共用)。
向后兼容:优先读 MYSQL_*,回退到旧的 CRON_MYSQL_*。
"""
try:
from agent.core.config_manager import load_env_config
cfg = load_env_config() or {}
except Exception:
cfg = {}
return {
"host": str(cfg.get("MYSQL_HOST") or cfg.get("CRON_MYSQL_HOST") or "localhost"),
"port": int(cfg.get("MYSQL_PORT") or cfg.get("CRON_MYSQL_PORT") or 3306),
"user": str(cfg.get("MYSQL_USER") or cfg.get("CRON_MYSQL_USER") or "root"),
"password": str(cfg.get("MYSQL_PASSWORD") or cfg.get("CRON_MYSQL_PASSWORD") or ""),
"database": str(cfg.get("MYSQL_DATABASE") or cfg.get("CRON_MYSQL_DATABASE") or "agent_history"),
}
def mysql_conn():
"""创建一个 MySQL 连接(DictCursor,手动提交)。调用方负责 close。
系统定位:
所有 MySQL 仓库(cron / session)获取连接的统一入口。
"""
import pymysql
from pymysql.cursors import DictCursor
cfg = load_mysql_config()
return pymysql.connect(
host=cfg["host"],
port=cfg["port"],
user=cfg["user"],
password=cfg["password"],
database=cfg["database"],
charset="utf8mb4",
cursorclass=DictCursor,
autocommit=False,
)
_ensured_tables: set[str] = set()
_database_ensured: bool = False
_ensure_lock = threading.Lock()
_TABLE_NAME_RE = re.compile(
r"CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+`?(\w+)`?", re.IGNORECASE
)
def ensure_database() -> None:
"""幂等创建 MySQL 数据库(CREATE DATABASE IF NOT EXISTS)。
系统定位:
在 ensure_tables 之前调用,确保目标数据库存在。
如果数据库不存在,mysql_conn() 会连接失败,导致建表无法执行。
此函数连接 MySQL 服务器(不指定 database)并创建数据库。
"""
global _database_ensured
if _database_ensured:
return
with _ensure_lock:
if _database_ensured:
return
import pymysql
from pymysql.cursors import DictCursor
cfg = load_mysql_config()
try:
conn = pymysql.connect(
host=cfg["host"],
port=cfg["port"],
user=cfg["user"],
password=cfg["password"],
charset="utf8mb4",
cursorclass=DictCursor,
autocommit=True,
)
try:
with conn.cursor() as cur:
db_name = cfg["database"]
cur.execute(
f"CREATE DATABASE IF NOT EXISTS `{db_name}` CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci"
)
_database_ensured = True
finally:
conn.close()
except Exception as e:
import sys
print(f"[db] ensure_database 失败: {e}", file=sys.stderr)
def ensure_tables(ddls: list[str]) -> None:
"""幂等建表:对未建过的表执行 CREATE TABLE IF NOT EXISTS。
输入:
ddls: CREATE TABLE IF NOT EXISTS 语句列表。
输出: 无。
系统定位:
cron 与 session 仓库在初始化时调用,传入各自的建表 DDL。
可扩展性:
按表名细粒度缓存(_ensured_tables),已建过的表跳过;新表首次执行。
多线程安全(_ensure_lock)。
"""
# 先确保数据库存在,否则建表连接会失败
ensure_database()
with _ensure_lock:
pending: list[tuple[str, str]] = []
for ddl in ddls:
m = _TABLE_NAME_RE.search(ddl)
name = m.group(1) if m else ddl
if name not in _ensured_tables:
pending.append((name, ddl))
if not pending:
return
conn = mysql_conn()
try:
with conn.cursor() as cur:
for name, ddl in pending:
cur.execute(ddl)
_ensured_tables.add(name)
conn.commit()
finally:
conn.close()
def get_storage_backend() -> str:
"""读取 env_config.json 的 STORAGE_BACKEND,默认 json。
cron 任务与普通会话历史共用此后端开关:mysql 时两者都入库。
向后兼容:优先读 STORAGE_BACKEND,回退到旧的 CRON_STORAGE_BACKEND。
"""
try:
from agent.core.config_manager import load_env_config
cfg = load_env_config() or {}
return str(cfg.get("STORAGE_BACKEND") or cfg.get("CRON_STORAGE_BACKEND") or "json").lower()
except Exception:
return "json"
+60
View File
@@ -0,0 +1,60 @@
"""LangGraph 工作流图构建。
系统定位:
将 LLM(已 bind_tools)、工具列表组装为可编译的 StateGraph,
供 ``agents/agent`` 及各子 Agent 的 ``AgentRuntime`` 使用。
图结构:
react: START -> agent -> (tools | END)
tools -> process_tool_artifact -> agent
可扩展性:
- 可增加 checkpoint、interrupt 节点、子图路由。
- 可为不同 Agent 注入不同的 conditional_edges 或额外节点(如 RAG 检索)。
"""
from __future__ import annotations
from typing import Any
from langgraph.graph import END, START, StateGraph
from agent.core.nodes import make_call_model_node, process_tool_artifact
from agent.core.routing import should_continue
from agent.core.state import AgentState
def build_agent_graph(tools: list[Any], model_with_tools: Any, trajectory_rounds: int = 3):
"""构建并编译标准 ReAct Agent 图。
输入:
tools: LangChain BaseTool 列表,供工具节点执行。
model_with_tools: 已 ``bind_tools`` 的聊天模型,供 agent 节点调用。
trajectory_rounds: 短期记忆保留最近 N 轮工具返回结果,默认 3。
输出:
编译后的 LangGraph CompiledGraph,支持 ``invoke({"messages": ...})``。
系统定位:
默认图类型 "react",供各 Agent 使用。
工具执行节点统一使用 make_parallel_tool_node:线程池并行执行同一轮的
多个 tool_call(含子 Agent 并行),单个 tool_call 时跳过线程池直接执行。
可扩展性:
返回前可 ``workflow.compile(checkpointer=...)`` 启用持久化;
可参数化节点名以支持多 Agent 图合并。
"""
from agent.tools.agent_call import make_parallel_tool_node
workflow = StateGraph(AgentState)
workflow.add_node("agent", make_call_model_node(model_with_tools, trajectory_rounds=trajectory_rounds))
# 工具执行节点:线程池并行执行(含子 Agent 并行)
workflow.add_node("tools", make_parallel_tool_node(tools))
workflow.add_node("process_tool_artifact", process_tool_artifact)
workflow.add_edge(START, "agent")
workflow.add_conditional_edges("agent", should_continue, {"tools": "tools", END: END})
workflow.add_edge("tools", "process_tool_artifact")
workflow.add_edge("process_tool_artifact", "agent")
return workflow.compile()
+413
View File
@@ -0,0 +1,413 @@
"""ChatQwen:基于 OpenAI 兼容 API 的多模态 LangChain 聊天模型。
系统定位:
项目默认 LLM 单例 ``llm``,被 agents 绑定工具,并承担:
- 用户消息预处理(附件、ASR、图片 data URL)
- 非标准 tool_call XML/JSON 解析
- Vision 描述接口
依赖:
env_utils 中的 LLM_* / ASR_BASE_URL
可扩展性:
- 可子类化覆盖 _preprocess_messages、_create_chat_result
- 流式、重试、限流可在 _generate 层扩展
"""
import os
from typing import Any, List, Dict, Optional
from pydantic import Field
import re, json
from langchain_core.messages import HumanMessage, AIMessage, BaseMessage, SystemMessage, ToolCall
from langchain_core.outputs import ChatResult
from langchain_openai import ChatOpenAI
from agent.utils.env_utils import LLM_API_KEY, LLM_BASE_URL, LLM_MODEL, LLM_TIMEOUT
from agent.utils.agent_utils import assistant_text, Documents_process, audio_file_to_text, make_attachment_text
from agent.utils.image_utils import image_path_to_openai_image_url_part, image_bytes_to_openai_image_url_part, IMAGE_FILE_EXTENSIONS
from agent.memory.system_prompt import VISION_SYSTEM_PROMPT
class ChatQwen(ChatOpenAI):
"""Qwen 系列模型的 LangChain 封装,扩展多模态能力。
系统定位:
agents/main_agent 的 llm 与 bind_tools 基底。
可扩展性:
新增厂商响应字段时在 _create_chat_result 中适配。
"""
def __init__(self, **kwargs: Any) -> None:
"""从环境变量或 kwargs 初始化 OpenAI 兼容客户端。
输入:
kwargs: 可覆盖 model、api_key、base_url、timeout、max_retries。
输出:
无。
系统定位:
模块级 ``llm = ChatQwen()`` 单例构造。
可扩展性:
可增加 default_headers、proxy 等 ChatOpenAI 参数。
"""
# 允许调用方覆盖 extra_body:
# - None(默认):使用禁用思考的默认值
# - 空 dict {}:清空 extra_body,恢复模型原生行为(含思考模式)
# - 非空 dict:直接使用传入值
custom_extra_body = kwargs.pop("extra_body", None)
if custom_extra_body is None:
extra_body = {
"chat_template_kwargs": {"enable_thinking": False},
"thinking": {"type": "disabled"},
}
else:
extra_body = custom_extra_body
super().__init__(
model=kwargs.pop("model", os.environ.get("LLM_MODEL", "")),
api_key=kwargs.pop("api_key", os.environ.get("LLM_API_KEY", "")),
base_url=kwargs.pop("base_url", os.environ.get("LLM_BASE_URL", "")),
timeout=kwargs.pop("timeout", float(os.environ.get("LLM_TIMEOUT", 60))),
max_retries=kwargs.pop("max_retries", 0),
extra_body=extra_body,
**kwargs
)
@staticmethod
def _process_image_content(part: Dict[str, Any]) -> Dict[str, Any]:
"""将 image_url 块中的本地路径或字节转为 data URL。
输入: OpenAI 风格 content part dict。
输出: 处理后的 part 或失败时 text 占位。
系统定位: _preprocess_messages 多模态分支。
可扩展性: 委托 image_utils,保持单一图片处理逻辑。
"""
image_type = part.get("type")
if image_type != "image_url":
return part
image_url = part.get("image_url", {})
url = image_url.get("url", "")
if url.startswith(("http://", "https://", "data:")):
return part
if isinstance(url, str) and os.path.exists(url):
ext = os.path.splitext(url)[1].lower()
if ext in IMAGE_FILE_EXTENSIONS:
result = image_path_to_openai_image_url_part(url)
return result if result else {"type": "text", "text": "[图片处理失败]"}
if isinstance(url, bytes):
result = image_bytes_to_openai_image_url_part(url)
return result if result else {"type": "text", "text": "[图片处理失败]"}
return part
def _audio_to_text(self, audio_path: str) -> str:
"""调用 ASR 服务将音频转为文本。委托至 agent_utils.audio_file_to_text。
输入: audio_path 本地音频文件。
输出: 识别文本字符串。
"""
return audio_file_to_text(audio_path)
def _preprocess_messages(self, messages: List[BaseMessage]) -> List[BaseMessage]:
"""入模前统一处理:JSON 列表、附件、ASR、图片、文档解析。
输入: LangChain BaseMessage 列表。
输出: 规范化后的新消息列表(不修改原列表)。
系统定位: _generate 调用 super()._generate 之前的适配层。
可扩展性: 新增 part.type 时在此增加分支。
"""
new_messages = []
for msg in messages:
if isinstance(msg, HumanMessage) and isinstance(msg.content, str):
text = msg.content.strip()
if text.startswith("[") and text.endswith("]"):
try:
possible_list = json.loads(text)
if isinstance(possible_list, list):
msg = HumanMessage(content=possible_list, additional_kwargs=msg.additional_kwargs)
except (json.JSONDecodeError, ValueError):
pass
if isinstance(msg, HumanMessage) and isinstance(msg.content, dict):
# 尝试从 dict 中提取文本,否则序列化为字符串
text = msg.content.get("text") or msg.content.get("content") or ""
if text:
msg = HumanMessage(content=str(text), additional_kwargs=msg.additional_kwargs)
else:
msg = HumanMessage(content=json.dumps(msg.content, ensure_ascii=False), additional_kwargs=msg.additional_kwargs)
elif isinstance(msg, HumanMessage) and isinstance(msg.content, list):
new_content: List[Dict[str, Any] | str] = []
for part in msg.content:
if isinstance(part, dict):
if part.get("type") == "audio":
audio_path = part.get("audio_path")
if audio_path:
normalized = audio_path.replace("\\", "/")
try:
text = self._audio_to_text(audio_path)
new_content.append({"type": "text", "text": make_attachment_text("音频", normalized, text)})
except Exception as e:
new_content.append({"type": "text", "text": make_attachment_text("音频", normalized, f"[语音识别失败: {e}]")})
elif part.get("type") == "image_url":
image_url = part.get("image_url", {})
url = image_url.get("url", "")
# 若为本地文件路径,注入路径 + 尺寸文本供 Agent 工具引用(如 PPT Skill 使用用户图片)
if isinstance(url, str) and os.path.exists(url) and not url.startswith(("http://", "https://", "data:")):
ext = os.path.splitext(url)[1].lower()
if ext in IMAGE_FILE_EXTENSIONS:
normalized_path = url.replace("\\", "/")
try:
from PIL import Image as PILImage
with PILImage.open(url) as img:
w, h = img.size
new_content.append({"type": "text", "text": make_attachment_text("图片", normalized_path, f"{w}×{h}px")})
except Exception:
new_content.append({"type": "text", "text": make_attachment_text("图片", normalized_path)})
new_content.append(self._process_image_content(part))
elif part.get("type") == "text":
text = (part.get("text") or "").strip()
if text:
new_content.append({"type": "text", "text": text})
elif part.get("path") or (part.get("file") or {}).get("path"):
file_path = part.get("path") or (part.get("file") or {}).get("path")
if isinstance(file_path, str) and os.path.isfile(file_path):
ext = os.path.splitext(file_path)[1].lower()
normalized_path = file_path.replace("\\", "/")
if ext in IMAGE_FILE_EXTENSIONS:
new_content.append(self._process_image_content({"type": "image_url", "image_url": {"url": file_path}}))
else:
try:
file_text, file_images = Documents_process.process(file_path)
new_content.append({"type": "text", "text": make_attachment_text("文件", normalized_path, f"以下是文件 {normalized_path} 的内容:\n{file_text.strip()}" if file_text.strip() else "")})
for image_bytes in file_images or []:
image_part = image_bytes_to_openai_image_url_part(image_bytes)
if image_part is not None:
new_content.append(image_part)
if not file_text.strip() and not file_images:
new_content.append({"type": "text", "text": make_attachment_text("附件文件", normalized_path)})
except Exception as e:
new_content.append({"type": "text", "text": f"[附件处理失败: {normalized_path}: {e}]"})
else:
text = assistant_text(part)
if text:
new_content.append({"type": "text", "text": text})
elif isinstance(part, str):
text = part.strip()
if text:
new_content.append(text)
if len(new_content) == 1 and isinstance(new_content[0], str):
msg = HumanMessage(content=new_content[0], additional_kwargs=msg.additional_kwargs)
else:
msg = HumanMessage(content=new_content, additional_kwargs=msg.additional_kwargs)
new_messages.append(msg)
return new_messages
def _generate(
self,
messages: List[BaseMessage],
stop: Optional[List[str]] = None,
run_manager: Optional[Any] = None,
**kwargs: Any,
) -> ChatResult:
"""LangChain 生成入口:预处理 -> API 调用。
输入:
messages: 对话消息。
stop: 停止词。
run_manager: LangChain callback manager。
输出:
ChatResult。
系统定位:
所有 invoke/stream 最终经过此方法。
可扩展性:
可在此注入 token 统计回调、链路追踪 span。
"""
messages = self._preprocess_messages(messages)
return super()._generate(messages, stop=stop, run_manager=run_manager, **kwargs)
def _create_chat_result(self, response, generation_info=None) -> ChatResult:
"""解析 API 响应:reasoning 合并、XML/JSON tool_call 提取。
输入:
response: OpenAI 兼容 API 原始响应。
generation_info: LangChain 生成元信息。
输出:
ChatResult,AIMessage 可能含 tool_calls。
系统定位:
兼容非 OpenAI 标准 tool_calls 格式的关键适配点。
可扩展性:
新增模型格式时在 regex/JSON 分支扩展。
"""
result = super()._create_chat_result(response, generation_info)
try:
dumped = response if isinstance(response, dict) else response.model_dump(exclude={"choices": {"__all__": {"message": {"parsed"}}}})
for i, choice in enumerate(dumped.get("choices") or []):
if i >= len(result.generations):
break
lc_msg = result.generations[i].message
msg = choice.get("message") or {}
ak = lc_msg.additional_kwargs
# 优先处理 reasoning 内容
merged = ""
for key in ("reasoning_content", "reasoning_details", "reasoning"):
merged = assistant_text(msg.get(key))
if merged:
break
if merged:
ak.setdefault("reasoning_content", merged)
raw_content = assistant_text(msg.get("content"))
if not raw_content and merged:
lc_msg.content = merged
elif raw_content:
lc_msg.content = raw_content
content_text = lc_msg.content if isinstance(lc_msg.content, str) else ""
# 匹配 <tool_call>...</tool_call> 块
pattern = re.compile(r'<tool_call>\s*(.*?)\s*</tool_call>', re.DOTALL)
matches = pattern.findall(content_text)
if matches:
tool_calls = []
for idx, match in enumerate(matches):
# 先尝试 JSON 解析(兼容旧格式)
try:
call_data = json.loads(match)
tool_calls.append({
"id": f"call_{idx}",
"type": "function",
"function": {
"name": call_data.get("name", ""),
"arguments": json.dumps(call_data.get("arguments", {}))
}
})
continue # 解析成功,跳过 XML 尝试
except json.JSONDecodeError:
pass
# 再尝试 XML 解析
func_match = re.search(r'<function=(\w+)>', match)
if not func_match:
continue
func_name = func_match.group(1)
params = {}
for param_match in re.finditer(r'<parameter=(\w+)>\s*(.*?)\s*</parameter>', match, re.DOTALL):
key = param_match.group(1)
value = param_match.group(2).strip()
# 可选:智能类型转换,先保持简单
params[key] = value
tool_calls.append({
"id": f"call_{idx}",
"type": "function",
"function": {
"name": func_name,
"arguments": json.dumps(params)
}
})
if tool_calls:
ak["tool_calls"] = tool_calls
# 构造 LangChain 的 ToolCall 对象列表
lc_tool_calls = []
for idx, tc in enumerate(tool_calls):
lc_tool_calls.append(
ToolCall(
id=tc["id"],
name=tc["function"]["name"],
args=json.loads(tc["function"]["arguments"])
)
)
lc_msg.tool_calls = lc_tool_calls
# 清理内容中的 XML/JSON 工具调用痕迹
cleaned = pattern.sub("", content_text).strip()
lc_msg.content = cleaned if cleaned else ""
except Exception:
pass
return result
def describe_image(self, image_path_or_url: str, prompt: str = "请详细描述这张图片的内容。") -> str:
"""Vision 便捷接口:单张图片 + 提示词 -> 文本描述。
输入:
image_path_or_url: 路径或 http(s)/data URL。
prompt: 用户侧提示。
输出:
模型描述文本。
系统定位:
工具或测试脚本直接调用,不经 LangGraph。
可扩展性:
可返回结构化 JSON(物体列表等)。
"""
messages = [
SystemMessage(content=[{"type": "text", "text": VISION_SYSTEM_PROMPT}]),
HumanMessage(content=[
{"type": "image_url", "image_url": {"url": image_path_or_url}},
{"type": "text", "text": prompt}
])
]
result = self.invoke(messages)
return result.content
def describe_image_bytes(self, image_bytes: bytes, prompt: str = "请详细描述这张图片的内容。") -> str:
"""Vision 便捷接口:内存图片字节 -> 文本描述。
输入:
image_bytes: 图片二进制。
prompt: 提示词。
输出:
描述文本;处理失败返回固定错误文案。
系统定位:
截屏、工具 artifact 等无路径场景。
可扩展性:
与 describe_image 共用 VISION_SYSTEM_PROMPT。
"""
img_part = image_bytes_to_openai_image_url_part(image_bytes)
if img_part is None:
return "图片处理失败"
messages = [
SystemMessage(content=[{"type": "text", "text": VISION_SYSTEM_PROMPT}]),
HumanMessage(content=[img_part, {"type": "text", "text": prompt}])
]
result = self.invoke(messages)
return result.content
llm = ChatQwen()
def get_default_llm():
"""获取默认 LLM 实例,优先使用主 Agent 配置的模型。
系统定位:
skill_router、GUI tool 等内部组件需要一个"兜底 LLM"来做推理。
该函数会先尝试获取主 Agent Runtime 的 LLM(由 agent_config.json 驱动),
若获取失败(模块未加载/主 Runtime 不存在)则回退到 models[0] 的默认 ChatQwen。
输出:
ChatQwen 实例。
"""
try:
from agent.agents.agent_runtime import get_main_agent_runtime
main_rt = get_main_agent_runtime()
if main_rt and main_rt.llm:
return main_rt.llm
except Exception:
pass
return llm
def reload_llm() -> None:
"""热重载:重新从环境变量创建 LLM 实例。"""
global llm
llm = ChatQwen()
+361
View File
@@ -0,0 +1,361 @@
"""Agent 死循环检测模块。
系统定位:
在 ReAct 循环的 agent 节点和路由节点之间,检测工具调用死循环、
todolist 进度停滞等异常模式,提供分级响应:
- Level 1: 注入反思提示引导模型自我纠正
- Level 2: 强制终止图执行并向用户报告
配置来源:
env_config.json 中的 LOOP_DETECT_* 参数,可由前端动态修改。
可扩展性:
可增加更多循环模式检测器、自定义检测规则。
"""
from __future__ import annotations
import hashlib
import json
import threading
from typing import Any, Optional
from langchain_core.messages import AIMessage, ToolMessage
from agent.utils.tool_call_utils import normalize_tool_call, format_args_summary
# ============================================================
# 配置加载(每次检测时实时读取,支持前端热更新)
# ============================================================
def get_loop_config() -> dict:
"""从 env_config.json 读取循环检测参数(每次调用实时读取)。"""
try:
from agent.core.config_manager import load_env_config
env = load_env_config()
except Exception:
env = {}
return {
"todolist_stale_rounds": int(env.get("LOOP_DETECT_TODOLIST_STALE_ROUNDS", 10)),
"repeated_tool_warn": int(env.get("LOOP_DETECT_REPEATED_TOOL_WARN", 3)),
"repeated_tool_end": int(env.get("LOOP_DETECT_REPEATED_TOOL_END", 5)),
}
# ============================================================
# 状态存储(按 session_id,线程安全)
# ============================================================
_lock = threading.Lock()
_session_state: dict[str, dict] = {}
def _get_session_state(session_id: str) -> dict:
"""获取或创建 session 级循环检测状态。"""
if not session_id:
session_id = "_default"
with _lock:
if session_id not in _session_state:
_session_state[session_id] = {
"round_count": 0,
"last_todolist_done_count": -1,
"todolist_stale_rounds": 0,
}
return _session_state[session_id]
def reset_session(session_id: str) -> None:
"""重置指定 session 的循环检测状态(每轮用户输入时调用)。"""
if not session_id:
session_id = "_default"
with _lock:
_session_state[session_id] = {
"round_count": 0,
"last_todolist_done_count": -1,
"todolist_stale_rounds": 0,
}
def clear_session(session_id: str) -> None:
"""清除指定 session 的循环检测状态(会话结束时调用)。"""
if not session_id:
session_id = "_default"
with _lock:
_session_state.pop(session_id, None)
# ============================================================
# 工具调用历史分析
# ============================================================
def _tool_call_fingerprint(tc: dict) -> str:
"""计算单个工具调用的指纹 (tool_name, args_hash)。
输入:
tc: 已 normalize 的工具调用 dict。
输出:
"tool_name:md5hash" 格式的指纹字符串。
"""
tc_name = tc.get("name", "") if isinstance(tc, dict) else getattr(tc, "name", "")
tc_args = tc.get("args", {}) if isinstance(tc, dict) else getattr(tc, "args", {})
args_str = json.dumps(tc_args, sort_keys=True, default=str)
args_hash = hashlib.md5(args_str.encode()).hexdigest()[:12]
return f"{tc_name}:{args_hash}"
def get_recent_tool_calls_from_messages(
messages: list,
count: int = 10,
) -> list[dict]:
"""从消息列表中提取最近 N 个工具调用(含指纹)。
输入:
messages: 消息列表。
count: 需要提取的最近工具调用数量。
输出:
[{"name": str, "args": dict, "fingerprint": str}, ...],按时间正序排列。
"""
result = []
for msg in messages:
if isinstance(msg, AIMessage) and getattr(msg, "tool_calls", None):
for tc in msg.tool_calls:
tc_dict = normalize_tool_call(tc)
fingerprint = _tool_call_fingerprint(tc_dict)
result.append({
"name": tc_dict.get("name", "unknown"),
"args": tc_dict.get("args", {}),
"fingerprint": fingerprint,
})
# 按时间正序,取最近 count 个
return result[-count:] if len(result) > count else result
def _compute_consecutive_same(recent_calls: list[dict]) -> int:
"""计算从末尾向前连续相同的工具调用次数(基于指纹)。
输入:
recent_calls: 工具调用列表(时间正序)。
输出:
末尾连续相同指纹的次数。
"""
if not recent_calls:
return 0
last_fp = recent_calls[-1]["fingerprint"]
count = 0
for call in reversed(recent_calls):
if call["fingerprint"] == last_fp:
count += 1
else:
break
return count
def detect_repeated_tool_calls(
messages: list,
session_id: str = "",
) -> tuple[int, str, str | None]:
"""检测连续相同工具调用。
输入:
messages: 当前消息列表。
session_id: 会话 ID。
输出:
(连续次数, 工具名, 参数摘要 | None)
连续次数为 0 表示无重复。
"""
cfg = get_loop_config()
warn_threshold = max(cfg["repeated_tool_warn"], 2)
end_threshold = max(cfg["repeated_tool_end"], warn_threshold + 1)
lookback = end_threshold + 2
recent = get_recent_tool_calls_from_messages(messages, count=lookback)
consecutive = _compute_consecutive_same(recent)
if consecutive < warn_threshold:
return 0, "", None
last_call = recent[-1] if recent else {}
args_summary = format_args_summary(last_call.get("args", {}))
return consecutive, last_call.get("name", "unknown"), args_summary
# ============================================================
# Todolist 进度停滞检测
# ============================================================
def _get_current_todolist_done_count(session_id: str) -> int:
"""获取当前 todolist 的已完成步骤数。"""
try:
from agent.tools.todo_list import get_todo_store_data
todo = get_todo_store_data(session_id)
if todo and todo.get("steps"):
return len(todo.get("done_steps", []))
except Exception:
pass
return -1
def check_and_record_todolist_progress(
messages: list,
session_id: str = "",
) -> tuple[bool, int, str]:
"""检查 todolist 进度是否停滞,并更新内部状态。
每轮 call_model 调用时调用一次。随着 round_count 递增,
对比当前 done_count 与上次记录的 done_count,统计停滞轮数。
输入:
messages: 当前消息列表。
session_id: 会话 ID。
输出:
(是否停滞超过阈值, 停滞轮数, todolist 状态摘要)
需要注入反思提示时返回 True。
"""
cfg = get_loop_config()
stale_threshold = max(cfg["todolist_stale_rounds"], 1)
state = _get_session_state(session_id)
state["round_count"] = state.get("round_count", 0) + 1
current_done = _get_current_todolist_done_count(session_id)
# 如果没有 todolist,不检查
if current_done < 0:
state["last_todolist_done_count"] = -1
state["todolist_stale_rounds"] = 0
return False, 0, ""
last_done = state.get("last_todolist_done_count", -1)
if current_done != last_done:
# 有进展,重置停滞计数
state["last_todolist_done_count"] = current_done
state["todolist_stale_rounds"] = 0
return False, 0, ""
else:
# 无进展,递增停滞计数
state["todolist_stale_rounds"] = state.get("todolist_stale_rounds", 0) + 1
stale = state["todolist_stale_rounds"]
# 构建 todolist 摘要
try:
from agent.tools.todo_list import get_todo_store_data
todo = get_todo_store_data(session_id)
steps = todo.get("steps", []) if todo else []
done_set = set(todo.get("done_steps", [])) if todo else set()
next_undone = ""
for i, s in enumerate(steps):
if i not in done_set:
next_undone = s[:50]
break
total = len(steps)
summary = f"总共 {total} 步,当前卡在步骤 {len(done_set)+1}/{total}:{next_undone}"
except Exception:
summary = f"已停滞 {stale} 轮"
if stale >= stale_threshold:
return True, stale, summary
return False, stale, summary
def should_force_end(
messages: list,
session_id: str = "",
) -> tuple[bool, str, str | None]:
"""检查是否应强制终止(Level 2 响应)。
当前仅检测连续相同工具调用超过 end 阈值的情况。
输入:
messages: 当前消息列表。
session_id: 会话 ID。
输出:
(是否强制终止, 工具名, 参数摘要)
"""
count, tool_name, args_summary = detect_repeated_tool_calls(messages, session_id)
cfg = get_loop_config()
end_threshold = max(cfg["repeated_tool_end"], cfg.get("repeated_tool_warn", 3) + 1)
if count >= end_threshold:
return True, tool_name, args_summary
return False, "", None
# ============================================================
# 反循环反思提示生成
# ============================================================
def build_loop_reflection_prompt(
messages: list,
session_id: str = "",
) -> str | None:
"""根据当前状态构建反循环反思提示。
输入:
messages: 当前消息列表。
session_id: 会话 ID。
输出:
反思提示文本;无需注入时返回 None。
"""
parts = []
# 检查 1: 连续相同工具调用 (Level 1 - 警告)
tool_count, tool_name, args_summary = detect_repeated_tool_calls(messages, session_id)
cfg = get_loop_config()
warn_threshold = max(cfg["repeated_tool_warn"], 2)
if tool_count >= warn_threshold:
parts.append(
f"【循环警告】你已经连续 {tool_count} 次调用工具 `{tool_name}` "
f"且参数基本相同({args_summary}),但似乎没有取得实质进展。\n"
f"请反思当前策略是否有效,考虑:\n"
f"1. 更换工具或策略\n"
f"2. 检查之前的工具返回结果是否有误\n"
f"3. 如果确实无法推进,向用户说明当前困难并请求帮助"
)
# 检查 2: Todolist 进度停滞
is_stale, stale_rounds, stale_summary = check_and_record_todolist_progress(
messages, session_id
)
stale_threshold = max(cfg["todolist_stale_rounds"], 1)
if is_stale:
parts.append(
f"【进度停滞警告】TodoList 已有 {stale_rounds} 轮没有进度"
+ (f"({stale_summary})。" if stale_summary else "。")
+ f"\n请反思当前卡点,考虑是否需要调整计划或换一种方式继续。"
)
if not parts:
return None
return "\n\n".join(parts)
def build_force_end_message(
tool_name: str,
consecutive_count: int,
args_summary: str | None = None,
) -> str:
"""构建强制终止时向前端输出的异常循环消息。
输入:
tool_name: 循环调用的工具名称。
consecutive_count: 连续调用次数。
args_summary: 参数摘要。
输出:
用户可读的循环报告文本。
"""
args_info = f"\n参数:{args_summary}" if args_summary else ""
return (
f"**检测到异常循环**\n\n"
f"工具 `{tool_name}` 已连续调用 {consecutive_count} 次且参数相同,判定为死循环,已强制终止本次任务。{args_info}\n\n"
f"建议:请尝试换一种方式描述任务,或简化任务后重试。"
)
+492
View File
@@ -0,0 +1,492 @@
"""LangGraph 图节点:模型调用、反思机制与工具产物后处理。
系统定位:
实现 ReAct 循环中的 ``agent`` 节点工厂、``process_tool_artifact`` 节点。
在 ``graph`` 注册到 StateGraph。
反思机制:
``make_call_model_node`` 在每次调用 LLM 前注入反思引导提示,
模型收到后会在输出 tool_calls 的同时产出文本思考内容(思维链)。
该文本思考内容被提取并记录到 think store 供前端实时展示。
可扩展性:
- ``make_call_model_node`` 可注入 streaming、重试、用量统计回调。
- ``process_tool_artifact`` 可扩展 audio、文件、结构化 JSON 等 artifact 类型。
"""
from __future__ import annotations
from typing import Any, Callable
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from agent.core.loop_detector import build_loop_reflection_prompt
from agent.core.state import AgentState
from agent.memory.system_prompt import PLAN_FIRST_CALL_PROMPT, REFLECTION_PROMPT
from agent.utils.image_utils import image_bytes_to_openai_image_url_part
from agent.utils.tool_call_utils import normalize_tool_call, extract_reasoning_text
from langchain_core.messages import SystemMessage
def _has_image_content(msg: HumanMessage) -> bool:
"""判断 HumanMessage 是否包含图片内容(截图等 data URL)。
输入:
msg: 待检查的 HumanMessage。
输出:
True 表示 content 中包含 image_url 类型的内容块。
"""
content = msg.content
if isinstance(content, list):
for part in content:
if isinstance(part, dict) and part.get("type") == "image_url":
return True
return False
def _filter_short_term_memory(messages: list, trajectory_rounds: int) -> list:
"""过滤短期记忆:保留所有工具调用记录和工具文字返回值,图片截图仅保留最近 trajectory_rounds 轮。
输入:
messages: 完整消息列表。
trajectory_rounds: 保留最近 N 轮的图片截图(合成 HumanMessage);<=0 表示不过滤任何消息。
输出:
过滤后的消息列表。
系统定位:
call_model 节点在调用 LLM 前进行消息裁剪,避免 GUI 等多轮工具调用
场景下上下文过长。
规则:
- 保留所有 SystemMessage、HumanMessage(非合成图片)、AIMessage、ToolMessage
- 仅 process_tool_artifact 生成的合成图片 HumanMessage 按 trajectory_rounds 裁剪
- 每轮由一个 AIMessage(含 tool_calls)定义
"""
if trajectory_rounds <= 0:
return list(messages)
# 从后向前遍历,统计已遇到的含 tool_calls 的 AIMessage 轮数
rounds_seen = 0
kept_indices = set()
for i in range(len(messages) - 1, -1, -1):
msg = messages[i]
if isinstance(msg, AIMessage) and getattr(msg, "tool_calls", None):
rounds_seen += 1
# 合成图片消息:仅保留最近 trajectory_rounds 轮
if isinstance(msg, HumanMessage) and msg.additional_kwargs.get("synthetic") and _has_image_content(msg):
if rounds_seen < trajectory_rounds:
kept_indices.add(i)
else:
kept_indices.add(i)
return [messages[i] for i in sorted(kept_indices)]
def _get_todolist_status() -> str:
"""获取当前会话的 todolist 状态摘要。
输出:
todolist 状态文本;无 todolist 时返回空字符串。
"""
try:
from agent.history.tool_call_recorder import get_current_session
from agent.tools.todo_list import get_todo_store_data
sid = get_current_session()
if not sid:
return ""
todo = get_todo_store_data(sid)
if not todo or not todo.get("steps"):
return ""
steps = todo["steps"]
done = set(todo.get("done_steps", []))
lines = []
for i, s in enumerate(steps):
status = "[已完成]" if i in done else "[待完成]"
lines.append(f" {i+1}. {status} {s}")
total = len(steps)
done_count = len(done)
header = f"当前TodoList进度:{done_count}/{total} 已完成"
return header + "\n" + "\n".join(lines)
except Exception:
return ""
def _build_reflection_prompt(agent_mode: str, is_first_call: bool, messages: list, session_id: str = "") -> str | None:
"""构建反思提示词,引导 Agent 在下一步执行前进行思考。
输入:
agent_mode: 运行模式("agent" | "plan")。
is_first_call: 是否为当前任务的首次 agent 调用。
messages: 当前消息列表(已过滤短期记忆),供循环检测使用。
session_id: 会话 ID,供循环检测使用。
输出:
反思提示文本,不需要反思时返回 None。
"""
todo_status = _get_todolist_status()
# Plan 模式首次调用:强制要求生成 todolist
if agent_mode == "plan" and is_first_call:
return PLAN_FIRST_CALL_PROMPT
# 非 plan 模式首次调用,不需要反思
if is_first_call:
return None
# 后续调用:引导模型先思考再行动
parts = [REFLECTION_PROMPT]
if todo_status:
parts.append(todo_status)
else:
parts.append("当前无 TodoList。")
# 循环检测:注入反循环反思提示
loop_warning = build_loop_reflection_prompt(messages, session_id)
if loop_warning:
parts.append(loop_warning)
return "\n".join(parts)
def _is_first_agent_call(messages: list) -> bool:
"""判断是否为当前用户任务的首轮 agent 调用。
规则:找到最后一条非 synthetic 的 HumanMessage(即用户最新输入)。
检查其后是否存在 AIMessage。若不存在,说明 agent 尚未对该输入做出响应。
输入:
messages: 已过滤短期记忆的消息列表。
输出:
True 表示首次调用。
"""
# 从后向前找到最后一条非 synthetic 的 HumanMessage
last_user_idx = -1
for i in range(len(messages) - 1, -1, -1):
msg = messages[i]
if isinstance(msg, HumanMessage) and not msg.additional_kwargs.get("synthetic"):
last_user_idx = i
break
if last_user_idx < 0:
return True
# 检查该用户消息之后是否有 AIMessage
for i in range(last_user_idx + 1, len(messages)):
if isinstance(messages[i], AIMessage):
return False
return True
# ---------- ReAct 循环内上下文压缩 ----------
def _extract_total_tokens(response: Any) -> int:
"""从模型 response 中提取 total_tokens。"""
usage = None
if hasattr(response, "response_metadata"):
meta = response.response_metadata
if isinstance(meta, dict):
usage = meta.get("token_usage", {})
if not usage and hasattr(response, "usage_metadata"):
meta = response.usage_metadata
if isinstance(meta, dict):
usage = meta
return usage.get("total_tokens", 0) if usage else 0
def _compress_messages_in_loop(state: dict, response: Any) -> None:
"""基于 LLM 返回的 token 用量,就地压缩 state["messages"]。
读 response metadata 中的 ``total_tokens``,无需任何持久化字段。
旧消息被原地替换为 SystemMessage 摘要,新消息保留不变。
同时将 total_tokens 存入 tool_call_recorder 供前端实时展示。
"""
total = _extract_total_tokens(response)
if total <= 0:
return
# 存储最新 token 用量供前端轮询
_sid = ""
try:
from agent.history.tool_call_recorder import get_current_session, set_session_tokens
_sid = get_current_session() or ""
if _sid:
set_session_tokens(_sid, total)
except Exception:
pass
from agent.utils.env_utils import LLM_CONTEXT_WINDOW, HARD_COMPRESS_RATE, MINI_COMPRESS_RATE
light_thresh = int(LLM_CONTEXT_WINDOW * MINI_COMPRESS_RATE)
hard_thresh = int(LLM_CONTEXT_WINDOW * HARD_COMPRESS_RATE)
if total <= light_thresh:
return
messages: list = list(state["messages"])
# 找到最新一条非 synthetic 的 HumanMessage(当前用户输入位置)
last_user_idx = -1
for i in range(len(messages) - 1, -1, -1):
if isinstance(messages[i], HumanMessage):
kw = getattr(messages[i], "additional_kwargs", {}) or {}
if not kw.get("synthetic"):
last_user_idx = i
break
if last_user_idx <= 0:
return
# 确定保留起点:hard 只保留 user 之后,light 保留本轮首个 agent 动作
if total > hard_thresh:
keep_from = last_user_idx
else:
keep_from = last_user_idx
for i in range(last_user_idx - 1, 0, -1):
if isinstance(messages[i], AIMessage) and getattr(messages[i], "tool_calls", None):
keep_from = i
break
if keep_from <= 0:
return
# 构建待压缩文本
to_compress = messages[:keep_from]
parts = []
for m in to_compress:
if isinstance(m, SystemMessage):
parts.append(f"[系统] {getattr(m, 'content', '')}")
elif isinstance(m, HumanMessage):
kw = getattr(m, "additional_kwargs", {}) or {}
if not kw.get("synthetic"):
parts.append(f"用户: {getattr(m, 'content', '')}")
elif isinstance(m, AIMessage):
if hasattr(m, "tool_calls") and m.tool_calls:
names = [tc.get("name", "?") if isinstance(tc, dict) else getattr(tc, "name", "?") for tc in m.tool_calls]
parts.append(f"AI调用工具: {', '.join(names)}")
else:
parts.append(f"AI回复: {getattr(m, 'content', '')[:200]}")
elif isinstance(m, ToolMessage):
parts.append(f"工具({getattr(m, 'name', '?')})返回: {getattr(m, 'content', '')[:300]}")
compress_text = "\n".join(parts[-50:])
if not compress_text.strip():
return
label = "全量" if total > hard_thresh else "轻量"
before_len = len(messages)
# SSE 通知前端:压缩开始
try:
from agent.history.tool_call_recorder import record_tool_call_live
if _sid:
record_tool_call_live(_sid, "summarizer", {
"action": f"{label}压缩中",
"before_tokens": total,
"before_messages": before_len,
})
except Exception:
pass
from agent.core.llm import llm as compress_llm
from agent.utils.agent_utils import summarize_chat_text
try:
summary_text = summarize_chat_text(compress_llm, compress_text)
compressed_msg = SystemMessage(content=f"【运行中上下文摘要】\n{summary_text}")
state["messages"] = [compressed_msg] + messages[keep_from:]
after_len = len(state["messages"])
# SSE 通知前端:压缩完成
try:
from agent.history.tool_call_recorder import record_tool_result_live
if _sid:
record_tool_result_live(_sid, "summarizer",
f"压缩完成: {before_len}→{after_len}条消息")
except Exception:
pass
except Exception as e:
# SSE 通知前端:压缩失败
try:
from agent.history.tool_call_recorder import record_tool_result_live
if _sid:
record_tool_result_live(_sid, "summarizer",
f"压缩失败: {str(e)}")
except Exception:
pass
def make_call_model_node(
model_with_tools: Any,
trajectory_rounds: int = 3,
) -> Callable[[AgentState], dict]:
"""创建带反思机制 + 上下文中压缩的调用已绑定工具的 LLM 的图节点闭包。
每次调用前注入反思引导提示;调用后提取模型同时产出的文本思考内容(content),
记录到 think store,供前端实时展示思维链。Plan 模式下首次调用强制要求生成 todolist。
在每次 LLM 调用后,根据 response 的 ``usage_metadata.total_tokens`` 判断
是否触发消息列表就地压缩——确保 ReAct 循环中的上下文永不溢出。
"""
def call_model(state: AgentState) -> dict:
all_messages = list(state["messages"])
agent_mode = state.get("agent_mode", "agent")
# 检查 abort 标志
try:
from agent.core.runtime import check_abort
from agent.history.tool_call_recorder import get_current_session
sid = get_current_session()
if sid and check_abort(sid):
raise RuntimeError("ABORTED_BY_USER")
except RuntimeError:
raise
except Exception:
pass
filtered_messages = _filter_short_term_memory(all_messages, trajectory_rounds)
# 判断是否为首轮 agent 调用(当前轮尚无 AIMessage)
is_first_call = _is_first_agent_call(filtered_messages)
# 获取 session_id 供循环检测使用
try:
from agent.history.tool_call_recorder import get_current_session
_sid = get_current_session()
except Exception:
_sid = ""
# 构建反思提示并注入到消息列表(仅作引导,不记录到前端)
reflection = _build_reflection_prompt(agent_mode, is_first_call, filtered_messages, _sid)
messages_for_llm = list(filtered_messages)
if reflection:
reflection_msg = HumanMessage(content=reflection, additional_kwargs={"synthetic": True, "reflection": True})
messages_for_llm.append(reflection_msg)
response = model_with_tools.invoke(messages_for_llm)
# 基于模型返回 token 用量的上下文中压缩(读 response metadata,不依赖 SQLite)
# 同时将 total_tokens 存入 session_tokens 供前端轮询
_compress_messages_in_loop(state, response)
# 提取模型真实输出的思考内容(content),记录到前端 think 面板
# 模型同时输出 content + tool_calls 时,content 就是其思维链
# 部分模型(如 Qwen enable_thinking)将思考放在 additional_kwargs.reasoning_content
response_content = extract_reasoning_text(response)
if response_content and isinstance(response_content, str) and response_content.strip():
has_tool_calls = getattr(response, "tool_calls", None)
if has_tool_calls:
# 有 tool_calls 时 content 是思考过程,记录到 think 面板
try:
from agent.history.tool_call_recorder import record_reflection_live, get_current_session
sid = get_current_session()
if sid:
record_reflection_live(sid, response_content.strip())
except Exception:
pass
if hasattr(response, "tool_calls") and response.tool_calls:
for tc in response.tool_calls:
tc_normalized = normalize_tool_call(tc)
tool_name = tc_normalized.get("name", "unknown")
tool_args = tc_normalized.get("args", {})
try:
from agent.history.tool_call_recorder import record_tool_call_live, get_current_session
sid = get_current_session()
if sid:
record_tool_call_live(sid, tool_name, tool_args)
if tool_name == "todo_list":
try:
from agent.tools.todo_list import update_todo_store_from_tool_args
update_todo_store_from_tool_args(sid, tool_args)
except Exception:
pass
except Exception:
pass
return {"messages": [response]}
return call_model
def process_tool_artifact(state: AgentState) -> dict:
last_msg = state["messages"][-1]
# ---- 实时记录工具结果到前端 ----
# 处理尾部所有连续的 ToolMessage(并行工具调用时会有多条),
# record_tool_result_live 对已 done 的记录是幂等的,不会重复标记
try:
from agent.history.tool_call_recorder import record_tool_result_live, get_current_session
sid = get_current_session()
if sid:
# 从末尾向前收集连续的 ToolMessage
trailing = []
for msg in reversed(state["messages"]):
if not isinstance(msg, ToolMessage):
break
trailing.append(msg)
# 按原始顺序处理(保证 record_tool_result_live 的 reverse 查找正确)
for msg in reversed(trailing):
record_tool_result_live(
sid,
msg.name or "unknown",
str(msg.content) if msg.content else "",
msg.artifact,
)
except Exception:
pass
if not isinstance(last_msg, ToolMessage) or not last_msg.artifact:
return {}
artifact = last_msg.artifact
img_bytes_list: list[bytes] = []
source_path = ""
if isinstance(artifact, dict):
if "image_bytes" in artifact:
img_bytes_list.append(artifact["image_bytes"])
if "images" in artifact and isinstance(artifact["images"], list):
for item in artifact["images"]:
if isinstance(item, bytes):
img_bytes_list.append(item)
elif isinstance(item, dict) and "image_bytes" in item:
img_bytes_list.append(item["image_bytes"])
source_path = artifact.get("file_path", "") or artifact.get("source_path", "")
elif isinstance(artifact, list):
for item in artifact:
if isinstance(item, dict) and "image_bytes" in item:
img_bytes_list.append(item["image_bytes"])
elif isinstance(item, bytes):
img_bytes_list.append(item)
elif isinstance(artifact, bytes):
img_bytes_list.append(artifact)
if not img_bytes_list:
return {}
content_parts = []
failed_count = 0
for img_bytes in img_bytes_list:
data_url = image_bytes_to_openai_image_url_part(img_bytes)
if data_url is None:
failed_count += 1
continue
content_parts.append(data_url)
if not content_parts:
human_msg = HumanMessage(
content="工具返回了截图,但图片处理失败,请向用户说明。",
additional_kwargs={"synthetic": True},
)
return {"messages": [human_msg]}
content_parts.append({"type": "text", "text": "上面是工具返回的截图。" + (f"(来源:{source_path})" if source_path else "")})
if failed_count > 0:
content_parts[-1]["text"] += f"(其中 {failed_count} 张图片处理失败,请忽略)"
human_msg = HumanMessage(content=content_parts, additional_kwargs={"synthetic": True})
return {"messages": [human_msg]}
+99
View File
@@ -0,0 +1,99 @@
"""LangGraph 条件路由:工具循环 vs 结束 vs 待确认暂停。
系统定位:
连接 ``graph`` 的条件边与 ``runtime.execute_agent`` 的
后处理逻辑,在模型输出 tool_calls 时决定图走向或提取 UI 待确认元数据。
可扩展性:
- ``should_continue`` 可扩展为多目标路由(如专用 RAG 节点)。
- ``first_pending_tool_meta`` 可支持多个并行待确认项的优先级队列。
"""
from __future__ import annotations
from typing import Any, Literal, Sequence
from langchain_core.messages import BaseMessage
from langgraph.graph import END
from agent.core.approvals import build_approval_meta, build_human_interaction_meta
from agent.core.loop_detector import should_force_end as _loop_should_force_end
from agent.core.state import AgentState
from agent.core.tool_policy import classify_tool_execution
from agent.utils.tool_call_utils import normalize_tool_call
def should_continue(state: AgentState) -> Literal["tools", "__end__"]:
"""根据最后一条 AI 消息中的 tool_calls 决定下一节点。
输入:
state: 含 messages 的 AgentState。
输出:
``"tools"`` 进入 ToolNode;``"__end__"`` 结束图(含需确认工具场景、
循环检测强制终止)。
系统定位:
``graph`` 中 agent 节点的 conditional_edges 路由函数。
若任一 tool_call 策略为 confirm,则直接 END,由 runtime 展示确认 UI。
若检测到死循环(连续相同工具调用超阈值),则强制 END。
可扩展性:
可先执行 direct 类工具、仅对 confirm 类暂停(需拆分 tool_calls 处理)。
"""
last_msg = state["messages"][-1]
if hasattr(last_msg, "tool_calls") and last_msg.tool_calls:
# 循环检测 Level 2:连续相同工具调用超过 END 阈值 → 强制终止
messages = state["messages"]
try:
from agent.history.tool_call_recorder import get_current_session
_sid = get_current_session()
except Exception:
_sid = ""
force_end, loop_tool, loop_args = _loop_should_force_end(messages, _sid)
if force_end:
return END
for raw_tool_call in last_msg.tool_calls:
tool_call = normalize_tool_call(raw_tool_call)
if classify_tool_execution(tool_call.get("name"), tool_call.get("args") or {}) == "confirm":
return END
return "tools"
return END
def first_pending_tool_meta(messages: Sequence[BaseMessage], session_id: str) -> dict[str, Any] | None:
"""从图执行结果中提取第一个需 UI 处理的待确认项元数据。
输入:
messages: 图 invoke 返回的完整消息列表。
session_id: 当前会话 ID,写入 PENDING_TOOL_APPROVALS。
输出:
含 ``pending_action`` 的 meta 字典;无待确认项时返回 None。
系统定位:
``runtime.execute_agent`` 在图结束后调用,决定是否追加确认型 assistant 消息。
可扩展性:
可返回列表以支持批量确认;可按工具类型定制 meta 结构。
"""
if not messages:
return None
last_msg = messages[-1]
if not hasattr(last_msg, "tool_calls") or not last_msg.tool_calls:
return None
# 当前 agent 名称(主 Agent 显示为 "主Agent",子 Agent 为其名称)
try:
from agent.tools.todo_list import get_current_agent_name
_ag = get_current_agent_name()
agent_name = "主Agent" if _ag in ("", "__main__") else _ag
except Exception:
agent_name = "主Agent"
for raw_tool_call in last_msg.tool_calls:
tool_call = normalize_tool_call(raw_tool_call)
if tool_call.get("name") == "human_interaction":
return build_human_interaction_meta(session_id, tool_call, messages, agent_name=agent_name)
if classify_tool_execution(tool_call.get("name"), tool_call.get("args") or {}) == "confirm":
return build_approval_meta(session_id, tool_call, messages, agent_name=agent_name)
return None
+940
View File
@@ -0,0 +1,940 @@
"""Agent 运行时:对话执行、历史持久化与人工确认续跑。
系统定位:
Web 层(即 ``web.ui_session``)与 LangGraph 之间的编排层。
``agents/*`` 组装 ``AgentRuntime`` 实例,本模块提供统一执行 API。
主要流程:
execute_agent: 用户消息 -> summarizer 压缩 -> graph.invoke -> 响应/待确认
continue_after_human_action: 用户确认/拒绝 -> 工具执行或图续跑
可扩展性:
- ``AgentRuntime`` 可增加 checkpointer、tracing、多模态配置。
"""
from __future__ import annotations
import json
import threading
from dataclasses import dataclass
from typing import Any, Literal
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
from langgraph.errors import GraphRecursionError
from langchain_core.messages import messages_from_dict
from agent.core.approvals import PENDING_TOOL_APPROVALS, remove_pending_ui_message, human_interaction_result
from agent.history.tool_call_recorder import save_turn, start_live_turn, end_live_turn, save_aborted_turn, set_current_turn, set_current_session
from agent.memory.system_prompt import PLAN_MODE_PROMPT, get_cron_system_prompt
from agent.core.loop_detector import reset_session as reset_loop_session, should_force_end as loop_should_force_end, build_force_end_message, clear_session as clear_loop_session
from agent.core.routing import first_pending_tool_meta
from agent.core.tool_policy import classify_tool_execution
from agent.tools.agent_call import SubAgentPendingError
from agent.utils.agent_utils import assistant_text, get_history
from agent.utils.tool_call_utils import normalize_tool_call, invoke_tool_and_build_message
from web.ui_session import rewrite_user_message_files_to_session_dir, session_dir, sync_turn_from_chat_history
AgentMode = Literal["agent", "plan", "cron"]
@dataclass
class AgentRuntime:
"""单个 Agent 的运行时配置容器。
字段:
name: Agent 标识,用于 session_meta 与日志。
graph: 编译后的 LangGraph。
llm: 原始 LLM 实例(压缩、TTS 等)。
tools: 工具列表。
tools_by_name: 工具名 → 工具实例,供确认后手动 invoke。
system_prompt: 系统提示词,传入 summarizer。
max_iterations: 最大迭代次数,超过则输出 OOM(默认 200)。
系统定位:
``agents/main_agent`` 等模块的装配结果,传入 execute_agent。
可扩展性:
可增加 default_mode、专用 checkpointer 等字段。
"""
name: str
graph: Any
llm: Any
tools: list[Any]
tools_by_name: dict[str, Any]
system_prompt: str
max_iterations: int = 200
trajectory_rounds: int = 3
description: str = ""
_OOM_MESSAGE = "OOM: 已达到最大迭代次数限制,任务未能完成。请简化任务或增加 max_iterations 配置后重试。"
def _inject_previous_turn_context(session_id: str, messages: list) -> None:
"""从上一轮 tool_calling 记录中读取工具调用上下文,注入为合成消息。"""
try:
from agent.history.tool_call_recorder import get_latest_turn
latest = get_latest_turn(session_id)
if not latest:
return
tool_calls = latest.get("tool_calls", [])
if not tool_calls:
return
for tc in tool_calls:
tn = tc.get("name", "unknown")
ta = json.dumps(tc.get("args", {}), ensure_ascii=False)[:300]
tr = str(tc.get("result_text", ""))[:500]
if tn and tr:
messages.append(HumanMessage(
content=f"[上轮工具调用] {tn}({ta}) → {tr}",
additional_kwargs={"synthetic": True},
))
except Exception:
pass
def _rollback_chat_history_to_last_user(chat_history: list[dict[str, Any]], session_id: str) -> list[dict[str, Any]]:
"""将 chat_history 回滚到上一条用户消息之前,并同步 turn 文件。"""
rolled_back = list(chat_history)
while rolled_back and rolled_back[-1].get("role") != "user":
rolled_back.pop()
if rolled_back:
rolled_back.pop()
if session_id:
sync_turn_from_chat_history(session_id, rolled_back, "")
return rolled_back
def _ensure_todo_cleared(session_id: str) -> None:
"""安全清理指定会话的 todo_list 缓存。"""
if not session_id:
return
try:
from agent.tools.todo_list import clear_todo_store_session
clear_todo_store_session(session_id)
except Exception:
pass
# ---------- Abort 机制 ----------
_abort_flags: dict[str, threading.Event] = {}
_abort_lock = threading.Lock()
def set_abort_flag(session_id: str) -> None:
"""设置指定会话的 abort 标志,通知正在执行的 Agent 终止。"""
if not session_id:
return
with _abort_lock:
event = _abort_flags.get(session_id)
if event is None:
event = threading.Event()
_abort_flags[session_id] = event
event.set()
def check_abort(session_id: str) -> bool:
"""检查指定会话是否已被标记 abort。"""
if not session_id:
return False
with _abort_lock:
event = _abort_flags.get(session_id)
return event is not None and event.is_set()
def clear_abort_flag(session_id: str) -> None:
"""清除指定会话的 abort 标志。"""
if not session_id:
return
with _abort_lock:
_abort_flags.pop(session_id, None)
def _invoke_with_recursion_limit(
runtime: AgentRuntime,
graph: Any,
input_data: dict,
config: dict | None = None,
) -> dict:
"""包装 graph.invoke,注入 recursion_limit 并捕获超限错误返回 OOM 消息。"""
cfg = dict(config or {})
cfg["recursion_limit"] = runtime.max_iterations
try:
return graph.invoke(input_data, config=cfg)
except GraphRecursionError:
print(f"[OOM] Agent '{runtime.name}' 达到最大迭代次数 {runtime.max_iterations},输出 OOM。)")
return {"messages": [AIMessage(content=_OOM_MESSAGE)], "_oom": True}
except SubAgentPendingError as e:
# 子 Agent 触发 HITL:冒泡到主图,返回主图消息快照 + sub_pending 元数据
return {
"messages": e.main_messages or [],
"_sub_pending": e.pending_meta,
"_main_tool_call_id": e.main_tool_call_id,
}
def save_messages_to_history(session_id: str, user_msg: HumanMessage, assistant_msg: AIMessage) -> None:
"""将本轮用户与助手消息写入 JSON 聊天历史(turn 文件)。
输入:
session_id: 会话 ID;空则跳过。
user_msg: 用户 HumanMessage;含 synthetic 标记时不写入。
assistant_msg: 助手 AIMessage。
输出:
无。
系统定位:
execute_agent 正常回复结束后持久化对话。
可扩展性:
可改为异步写入、双写向量库、按 agent_name 分表。
"""
if not session_id:
return
history = get_history(session_id)
if not user_msg.additional_kwargs.get("synthetic"):
history.add_message(user_msg)
history.add_message(assistant_msg)
def _message_content_equals(left: Any, right: Any) -> bool:
"""宽松比较消息 content,兼容纯文本与多模态列表。"""
if left == right:
return True
return assistant_text(left) == assistant_text(right)
def _pending_action_id(meta: dict[str, Any] | None) -> str | None:
"""从 assistant meta 中提取 pending_action.id。"""
pending_action = (meta or {}).get("pending_action") or {}
return pending_action.get("id")
def _remember_pending_user_content(pending_meta: dict[str, Any], user_content: Any) -> None:
"""将本轮用户输入缓存到 PENDING_TOOL_APPROVALS,供确认续跑时对齐历史。"""
approval_id = _pending_action_id(pending_meta)
if approval_id and approval_id in PENDING_TOOL_APPROVALS:
PENDING_TOOL_APPROVALS[approval_id]["user_content"] = user_content
def _persist_messages_from_pending_turn(
session_id: str,
messages: list[Any],
pending: dict[str, Any],
) -> None:
if not session_id or not messages:
return
# 找到本轮用户消息
user_content = pending.get("user_content")
start_index = 0
if user_content is not None:
for idx in range(len(messages) - 1, -1, -1):
msg = messages[idx]
if isinstance(msg, HumanMessage) and not msg.additional_kwargs.get("synthetic"):
if _message_content_equals(msg.content, user_content):
start_index = idx
break
# 只提取:
# - 用户消息(跳过 synthetic。)
# - 最终一条不包含 tool_calls 的助手消息
user_msg = None
final_assistant = None
for msg in messages[start_index:]:
if isinstance(msg, HumanMessage) and not msg.additional_kwargs.get("synthetic"):
user_msg = msg
if isinstance(msg, AIMessage) and not msg.tool_calls:
final_assistant = msg
history = get_history(session_id)
if user_msg:
history.add_message(user_msg)
if final_assistant:
history.add_message(final_assistant)
def _persist_pending_rejection(session_id: str, pending: dict[str, Any], content: str) -> None:
"""将用户拒绝待确认操作这一轮写入历史,避免下一轮上下文断层。"""
prior_messages = messages_from_dict(pending.get("messages") or [])
prior_messages.append(AIMessage(content=content))
_persist_messages_from_pending_turn(session_id, prior_messages, pending)
def _invoke_after_manual_tool_result(
runtime: AgentRuntime,
messages: list[Any],
session_id: str,
*,
max_steps: int = 8,
agent_mode: str = "agent",
) -> tuple[list[Any], dict[str, Any] | None]:
"""人工确认后继续推进图,并在遇到新的待确认项时暂停。"""
current_messages = messages
for _ in range(max_steps):
result = _invoke_with_recursion_limit(runtime, runtime.graph, {"messages": current_messages, "agent_mode": agent_mode}, {"configurable": {"session_id": session_id}})
all_messages = result["messages"]
# 循环检测 Level 2:检查是否因死循环被强制终止
force_end, loop_tool, loop_args = loop_should_force_end(all_messages, session_id)
if force_end:
# 返回特殊标记:pending_meta 中携带 _loop_force_end 信息
return all_messages, {"_loop_force_end": True, "tool_name": loop_tool, "args_summary": loop_args or ""}
pending_meta = first_pending_tool_meta(all_messages, session_id)
if pending_meta:
return all_messages, pending_meta
last_msg = all_messages[-1] if all_messages else None
if not isinstance(last_msg, AIMessage) or not last_msg.tool_calls:
return all_messages, None
next_messages = [*all_messages]
progressed = False
for raw_tool_call in last_msg.tool_calls:
tool_call = normalize_tool_call(raw_tool_call)
tool_name = tool_call.get("name")
tool_args = tool_call.get("args") or {}
tool_call_id = tool_call.get("id") or f"manual_{session_id}_{len(next_messages)}"
if classify_tool_execution(tool_name, tool_args) != "direct":
continue
tool = runtime.tools_by_name.get(tool_name or "")
if not tool:
continue
tool_content, tool_artifact = invoke_tool_and_build_message(tool, tool_name, tool_args, tool_call_id)
msg_kwargs = {"content": tool_content, "tool_call_id": tool_call_id, "name": tool_name or "unknown_tool"}
if tool_artifact:
msg_kwargs["artifact"] = tool_artifact
next_messages.append(ToolMessage(**msg_kwargs))
progressed = True
if not progressed:
return all_messages, None
current_messages = next_messages
return current_messages, None
def _finalize_turn(
runtime: AgentRuntime,
all_messages: list[Any],
chat_history: list[dict[str, Any]],
session_id: str,
user_content: Any,
turn_id: str,
agent_mode: str = "agent",
) -> list[dict[str, Any]]:
"""处理图执行完成后的公共逻辑:检查 pending、保存 turn、构建 assistant 消息。"""
# 循环检测 Level 2
loop_force_end, loop_tool_name, loop_args_summary = loop_should_force_end(all_messages, session_id)
if loop_force_end:
end_live_turn(session_id)
from agent.core.loop_detector import detect_repeated_tool_calls as _detect_repeated
actual_count, _, _ = _detect_repeated(all_messages, session_id)
loop_msg = build_force_end_message(loop_tool_name, actual_count, loop_args_summary)
chat_history.append({"role": "assistant", "content": loop_msg, "meta": {"turn_id": turn_id}})
if session_id:
clear_loop_session(session_id)
_ensure_todo_cleared(session_id)
sync_turn_from_chat_history(session_id, chat_history, "")
return chat_history
# 检查待确认项
pending_meta = first_pending_tool_meta(all_messages, session_id)
if pending_meta:
# 不调用 end_live_turn:human_interaction 和其他工具一样走 live 记录通道,
# 前端持续轮询展示 running 状态,浮窗只是它的"前端 UI"
_remember_pending_user_content(pending_meta, user_content)
approval_id = _pending_action_id(pending_meta)
if approval_id and approval_id in PENDING_TOOL_APPROVALS:
PENDING_TOOL_APPROVALS[approval_id]["agent_mode"] = agent_mode
PENDING_TOOL_APPROVALS[approval_id]["turn_id"] = turn_id
if session_id:
sync_turn_from_chat_history(session_id, chat_history, "")
return chat_history
# 无待确认项:正常结束
end_live_turn(session_id)
assistant_msg = all_messages[-1] if all_messages else AIMessage(content="")
content = assistant_text(assistant_msg.content) or "(空回复)"
assistant_meta = {
"turn_id": turn_id,
}
chat_history.append({"role": "assistant", "content": content, "meta": assistant_meta})
# 持久化
if session_id and turn_id:
save_turn(session_id, turn_id, {"role": "user", "content": user_content}, all_messages)
_persist_messages_from_pending_turn(session_id, all_messages, {"user_content": user_content})
_ensure_todo_cleared(session_id)
if session_id:
sync_turn_from_chat_history(session_id, chat_history, "")
return chat_history
def _classify_error(e: Exception) -> str:
"""将异常归类为用户可读的错误提示。"""
msg = str(e)
# LLM API 认证/鉴权错误
if any(k in msg.lower() for k in ("401", "403", "unauthorized", "forbidden", "invalid api key", "authentication", "incorrect api key")):
return "API Key 无效或无权访问,请检查模型配置中的 API Key 和 Base URL。"
# 速率限制
if any(k in msg.lower() for k in ("429", "rate limit", "too many requests", "quota")):
return "API 调用频率过高或额度不足,请稍后重试或检查 API 配额。"
# 网络/连接错误
if any(k in msg.lower() for k in ("connection", "timeout", "connect", "network", "refused", "unreachable", "econnrefused", "etimedout", "socket", "dns")):
return f"无法连接到模型服务,请检查网络连接和 Base URL 是否正确。"
# 上下文超长
if any(k in msg.lower() for k in ("context length", "too long", "maximum context", "token", "reduce", "truncat")):
return "对话内容超过了模型上下文窗口限制,请尝试压缩对话或开启新会话。"
# 模型不可用
if any(k in msg.lower() for k in ("model not found", "does not exist", "not available", "deployment", "404")):
return "指定的模型不可用,请检查 Model Name 是否正确。"
# 服务器内部错误
if any(k in msg.lower() for k in ("500", "502", "503", "internal", "server error", "overloaded")):
return "模型服务端暂时故障,请稍后重试。"
# RecursionError (LangGraph loop detection)
if isinstance(e, RecursionError) or "recursion" in msg.lower():
return "Agent 执行轮数超过限制,任务可能过于复杂,请尝试拆分或简化任务后重试。"
# 通用兜底
return f"执行过程中出现错误,请稍后重试。如果问题持续,请联系开发者。\n错误详情: {msg[:200]}"
def execute_agent(
runtime: AgentRuntime,
chat_history: list[dict[str, Any]],
session_id: str = "test",
agent_mode: AgentMode = "agent",
) -> list[dict[str, Any]]:
"""处理用户新一轮输入,驱动 Agent 图执行并更新 chat_history。
输入:
runtime: Agent 运行时配置。
chat_history: Web UI 聊天历史(dict 列表);末条须为 user。
session_id: 会话 ID,用于历史、文件目录、turn 同步。
agent_mode: agent(默认)| plan(先规划再执行)。
输出:
更新后的 chat_history;可能追加 assistant 消息、pending_action。
系统定位:
对外主入口,由 Web/API 层在每轮用户发送后调用。
可扩展性:
可返回 generator 支持流式;可注入 middleware 做鉴权、限流。
"""
chat_history = chat_history or []
if not chat_history or chat_history[-1].get("role") != "user":
return chat_history
if session_id:
rewrite_user_message_files_to_session_dir(chat_history[-1], session_id)
user_content = chat_history[-1]["content"]
# 按运行模式选择系统提示词:plan/cron 使用专用提示词,其余用 runtime 默认
if agent_mode == "plan":
system_prompt = PLAN_MODE_PROMPT
elif agent_mode == "cron":
system_prompt = get_cron_system_prompt()
else:
system_prompt = runtime.system_prompt
# 从 turn 文件加载历史消息 + 注入上轮工具上下文(图内 _compress_messages_in_loop 负责运行时压缩)
messages = [SystemMessage(content=system_prompt)]
if session_id:
history = get_history(session_id)
messages.extend(history.messages)
# 注入上一轮工具调用上下文(从 tool_calling 中读取)
_inject_previous_turn_context(session_id, messages)
messages.append(HumanMessage(content=user_content))
graph_config = {
"configurable": {"session_id": session_id},
}
try:
# 开始实时记录(按 turn 隔离旧数据)
turn_id = ""
if session_id:
# 优先使用 _chat_start_core 设置的 pending turn_id
from agent.utils.agent_utils import take_pending_turn_id
from agent.history.tool_call_recorder import start_live_turn, init_live_turn_with_id, set_current_session as _set_cur_sess
pending_tid = take_pending_turn_id(session_id)
if pending_tid:
turn_id = init_live_turn_with_id(session_id, pending_tid)
else:
turn_id = start_live_turn(session_id)
_set_cur_sess(session_id)
clear_abort_flag(session_id)
# 重置循环检测状态(每轮用户输入后重新开始计数)
reset_loop_session(session_id)
# 主 Agent 上下文:TodoList 按 agent 隔离时,主 Agent 用保留键 "__main__"
from agent.tools.todo_list import set_current_agent_name
set_current_agent_name("__main__")
result = _invoke_with_recursion_limit(runtime, runtime.graph, {"messages": messages, "agent_mode": agent_mode}, graph_config)
set_current_turn("")
set_current_session("")
# 检查是否被 abort:保留已完成的工具调用记录,追加暂停消息结束本轮
if session_id and check_abort(session_id):
clear_abort_flag(session_id)
save_aborted_turn(session_id, turn_id)
end_live_turn(session_id)
_ensure_todo_cleared(session_id)
chat_history.append({"role": "assistant", "content": "已被用户手动暂停", "meta": {"paused": True}})
if session_id:
sync_turn_from_chat_history(session_id, chat_history, "")
return chat_history
# ===== 子 Agent HITL 冒泡:主图在 call_subagent 处暂停 =====
sub_pending_meta = result.get("_sub_pending")
if sub_pending_meta:
sub_approval_id = _pending_action_id(sub_pending_meta)
if sub_approval_id and sub_approval_id in PENDING_TOOL_APPROVALS:
entry = PENDING_TOOL_APPROVALS[sub_approval_id]
entry["main_messages"] = list(result.get("messages") or [])
entry["main_tool_call_id"] = result.get("_main_tool_call_id") or f"manual_{sub_approval_id}"
entry["agent_mode"] = agent_mode
entry["turn_id"] = turn_id
entry["user_content"] = user_content
# 不调用 end_live_turn:turn 仍 live,前端轮询展示 running + 浮窗
if session_id:
sync_turn_from_chat_history(session_id, chat_history, "")
return chat_history
all_messages = result["messages"]
chat_history = _finalize_turn(runtime, all_messages, chat_history, session_id, user_content, turn_id, agent_mode)
return chat_history
except RuntimeError as e:
if str(e) == "ABORTED_BY_USER":
set_current_turn("")
set_current_session("")
clear_abort_flag(session_id)
save_aborted_turn(session_id, turn_id)
end_live_turn(session_id)
_ensure_todo_cleared(session_id)
chat_history.append({"role": "assistant", "content": "已被用户手动暂停", "meta": {"paused": True}})
if session_id:
sync_turn_from_chat_history(session_id, chat_history, "")
return chat_history
import traceback
set_current_turn("")
set_current_session("")
end_live_turn(session_id)
err_msg = _classify_error(e)
print(f"[ERROR] 调用模型时出错: {err_msg}")
traceback.print_exc()
chat_history.append({"role": "assistant", "content": err_msg})
except Exception as e:
import traceback
set_current_turn("")
set_current_session("")
end_live_turn(session_id)
err_msg = _classify_error(e)
print(f"[ERROR] 调用模型时出错: {err_msg}")
traceback.print_exc()
chat_history.append({"role": "assistant", "content": err_msg})
if session_id:
sync_turn_from_chat_history(session_id, chat_history, "")
return chat_history
def _handle_loop_force_end_in_pending(pending_meta: dict[str, Any], chat_history: list[dict[str, Any]], session_id: str) -> list[dict[str, Any]] | None:
"""若 pending_meta 包含 _loop_force_end,写入终止消息并返回 chat_history;否则返回 None。"""
if not pending_meta.get("_loop_force_end"):
return None
loop_msg = build_force_end_message(
pending_meta.get("tool_name", ""), 0,
pending_meta.get("args_summary", ""),
)
chat_history.append({"role": "assistant", "content": loop_msg, "meta": {}})
if session_id:
clear_loop_session(session_id)
sync_turn_from_chat_history(session_id, chat_history, "")
return chat_history
def _finalize_new_pending_after_human_action(
pending_meta: dict[str, Any],
pending: dict[str, Any],
chat_history: list[dict[str, Any]],
session_id: str,
agent_mode: str,
) -> list[dict[str, Any]]:
"""记录新的待确认项并返回 chat_history。"""
_remember_pending_user_content(pending_meta, pending.get("user_content"))
approval_id2 = _pending_action_id(pending_meta)
if approval_id2 and approval_id2 in PENDING_TOOL_APPROVALS:
PENDING_TOOL_APPROVALS[approval_id2]["agent_mode"] = agent_mode
# 传递原始 turn_id 给嵌套的人机交互,确保所有记录写入同一个文件
original_turn_id = pending.get("turn_id", "")
if original_turn_id:
PENDING_TOOL_APPROVALS[approval_id2]["turn_id"] = original_turn_id
# 不追加空消息 — pending_action 由 API 层单独返回
if session_id:
sync_turn_from_chat_history(session_id, chat_history, "")
return chat_history
def _resume_graph_after_tool_message(
runtime: AgentRuntime,
prior_messages: list[Any],
session_id: str,
agent_mode: str,
pending: dict[str, Any],
chat_history: list[dict[str, Any]],
turn_id: str,
) -> list[dict[str, Any]]:
"""用已追加好 ToolMessage 的 prior_messages 续跑图,统一处理 pending/结束/持久化。
用于主 Agent skip 续跑、子 Agent 完成后恢复主图。pending 提供 user_content/turn_id。
"""
try:
all_messages, pending_meta = _invoke_after_manual_tool_result(runtime, prior_messages, session_id, agent_mode=agent_mode)
if pending_meta:
end_live_turn(session_id)
result = _handle_loop_force_end_in_pending(pending_meta, chat_history, session_id)
if result is not None:
return result
return _finalize_new_pending_after_human_action(pending_meta, pending, chat_history, session_id, agent_mode)
end_live_turn(session_id)
assistant_msg = all_messages[-1] if all_messages else AIMessage(content="")
content = assistant_text(assistant_msg.content) or "(空回复)"
chat_history.append({"role": "assistant", "content": content, "meta": {"turn_id": turn_id}})
_persist_messages_from_pending_turn(session_id, all_messages, pending)
if session_id and turn_id:
save_turn(session_id, turn_id, {"role": "user", "content": pending.get("user_content") or ""}, all_messages)
_ensure_todo_cleared(session_id)
sync_turn_from_chat_history(session_id, chat_history, "")
return chat_history
except Exception:
import traceback
traceback.print_exc()
end_live_turn(session_id)
content = "续跑图执行时遇到问题。"
chat_history.append({"role": "assistant", "content": content, "meta": {}})
if session_id:
sync_turn_from_chat_history(session_id, chat_history, "")
return chat_history
def _extract_sub_final_text(all_messages: list[Any]) -> str:
"""从子 Agent 图消息中提取最后一条非工具调用的助手消息文本。"""
for msg in reversed(all_messages or []):
if isinstance(msg, AIMessage) and not getattr(msg, "tool_calls", None):
content = msg.content
if isinstance(content, str):
return content
if isinstance(content, list):
return "\n".join(
p.get("text", "") for p in content
if isinstance(p, dict) and p.get("type") == "text"
)
return "子 Agent 已完成任务但未返回文字结果。"
def _invoke_sub_graph(
sub_runtime: AgentRuntime,
sub_messages: list[Any],
session_id: str,
agent_name: str,
main_messages: list[Any],
main_tool_call_id: str,
agent_mode: str,
user_content: Any,
turn_id: str,
) -> tuple[list[Any], dict[str, Any] | None]:
"""恢复子 Agent 图执行(复用 _invoke_after_manual_tool_result)。
若子图又产生 pending,已在 PENDING_TOOL_APPROVALS 中标记 is_sub_agent 并附加
sub_runtime/sub_messages/main_messages/main_tool_call_id,供下一轮确认恢复。
"""
from agent.tools.todo_list import set_current_agent_name
set_current_agent_name(agent_name)
# 子 Agent 续跑同样需抑制 record_*_live,避免其思考/工具调用
# 混入主 Agent 的实时列表(与 agent_call.CallSubAgentTool._run 保持一致)。
from agent.history.tool_call_recorder import sub_agent_context
try:
with sub_agent_context():
sub_all_messages, sub_pending_meta = _invoke_after_manual_tool_result(
sub_runtime, sub_messages, session_id, agent_mode=agent_mode
)
finally:
set_current_agent_name("__main__")
if sub_pending_meta and not sub_pending_meta.get("_loop_force_end"):
approval_id = _pending_action_id(sub_pending_meta)
if approval_id and approval_id in PENDING_TOOL_APPROVALS:
entry = PENDING_TOOL_APPROVALS[approval_id]
entry["is_sub_agent"] = True
entry["agent_name"] = agent_name
entry["sub_runtime"] = sub_runtime
entry["sub_messages"] = list(sub_all_messages) # 更新为最新子图消息
entry["main_messages"] = main_messages
entry["main_tool_call_id"] = main_tool_call_id
entry["agent_mode"] = agent_mode
entry["turn_id"] = turn_id
entry["user_content"] = user_content
return sub_all_messages, sub_pending_meta
def _resume_sub_agent(
runtime: AgentRuntime,
pending: dict[str, Any],
chat_history: list[dict[str, Any]],
session_id: str,
approval_id: str,
decision: str,
instruction: str | None,
agent_mode: str,
) -> list[dict[str, Any]]:
"""子 Agent HITL 确认/跳过后恢复:先续子图,子图完成后再恢复主图。"""
sub_runtime = pending.get("sub_runtime")
sub_messages = list(pending.get("sub_messages") or [])
main_messages = list(pending.get("main_messages") or [])
main_tool_call_id = pending.get("main_tool_call_id") or f"manual_{approval_id}"
agent_name = pending.get("agent_name") or "子Agent"
original_turn_id = pending.get("turn_id", "")
sub_type = pending.get("type")
sub_tool_call = dict(pending.get("tool_call") or {})
sub_tool_name = sub_tool_call.get("name") or "unknown_tool"
sub_call_id = sub_tool_call.get("id") or f"sub_{approval_id}"
# 1) 构造子图续跑的 ToolMessage
if decision == "skip":
sub_messages.append(ToolMessage(
content=f"用户已跳过此工具调用({sub_tool_name}),未执行。",
tool_call_id=sub_call_id, name=sub_tool_name,
))
elif sub_type == "human_interaction":
sub_messages.append(ToolMessage(
content=human_interaction_result(pending, "approve", instruction),
tool_call_id=sub_call_id, name="human_interaction",
))
else:
sub_tool = sub_runtime.tools_by_name.get(sub_tool_name) if sub_runtime else None
if sub_tool:
tc, ta = invoke_tool_and_build_message(sub_tool, sub_tool_name, sub_tool_call.get("args") or {}, sub_call_id)
msg_kwargs = {"content": tc, "tool_call_id": sub_call_id, "name": sub_tool_name}
if ta:
msg_kwargs["artifact"] = ta
sub_messages.append(ToolMessage(**msg_kwargs))
else:
sub_messages.append(ToolMessage(
content=f"工具 {sub_tool_name} 未找到", tool_call_id=sub_call_id, name=sub_tool_name,
))
# 2) 恢复子图
if session_id:
set_current_session(session_id)
if original_turn_id:
from agent.history.tool_call_recorder import set_current_turn as _set_tc_turn
_set_tc_turn(original_turn_id)
sub_all_messages, sub_pending_meta = _invoke_sub_graph(
sub_runtime, sub_messages, session_id, agent_name,
main_messages, main_tool_call_id, agent_mode,
pending.get("user_content"), original_turn_id,
)
if sub_pending_meta:
# 子图又产生 pending(嵌套/连续 HITL)— _invoke_sub_graph 已注册新 pending(含 main 上下文)
result = _handle_loop_force_end_in_pending(sub_pending_meta, chat_history, session_id)
if result is not None:
return result
if session_id:
sync_turn_from_chat_history(session_id, chat_history, "")
return chat_history
# 3) 子图完成:提取最终文本,恢复主图
sub_final_text = _extract_sub_final_text(sub_all_messages)
main_messages.append(ToolMessage(content=sub_final_text, tool_call_id=main_tool_call_id, name="call_subagent"))
from agent.tools.todo_list import set_current_agent_name
set_current_agent_name("__main__")
if session_id:
turn_id = start_live_turn(session_id)
else:
turn_id = original_turn_id or ""
main_pending = {"user_content": pending.get("user_content"), "turn_id": original_turn_id}
return _resume_graph_after_tool_message(runtime, main_messages, session_id, agent_mode, main_pending, chat_history, original_turn_id or turn_id)
def continue_after_human_action(
runtime: AgentRuntime,
chat_history: list[dict[str, Any]],
session_id: str,
approval_id: str,
decision: Literal["approve", "reject", "edit", "skip"],
instruction: str | None = None,
) -> list[dict[str, Any]]:
"""用户对待确认项做出决策后,继续执行或结束该轮交互。
输入:
runtime: Agent 运行时。
chat_history: 当前聊天历史。
session_id: 会话 ID。
approval_id: pending_action.id。
decision: approve | reject | edit | skip。
- approve: 执行工具/确认续跑;skip: 不执行工具但继续图(ToolMessage 标记跳过)。
- edit: human_interaction selection 提交文本。
instruction: selection 时为用户输入文本。
输出:
更新后的 chat_history。
系统定位:
UI 确认框回调入口;按 pending.type 与 is_sub_agent 分支:
human_interaction / tool_call / sub_agent。
可扩展性:
可增加 timeout 自动 reject;支持批量 approve 多个 tool_call。
"""
chat_history = chat_history or []
pending = PENDING_TOOL_APPROVALS.pop(approval_id, None)
if not pending:
chat_history.append({"role": "assistant", "content": "该待确认项已处理或已过期。", "meta": {}})
return chat_history
remove_pending_ui_message(chat_history, approval_id)
agent_mode = pending.get("agent_mode", "agent")
# ===== 子 Agent HITL 恢复(approve / skip,无 reject)=====
if pending.get("is_sub_agent"):
return _resume_sub_agent(runtime, pending, chat_history, session_id, approval_id, decision, instruction, agent_mode)
if decision == "reject" and pending.get("type") != "human_interaction":
content = "已拒绝,本次待确认操作不会执行。"
chat_history.append({"role": "assistant", "content": content, "meta": {}})
_persist_pending_rejection(session_id, pending, content)
if session_id:
sync_turn_from_chat_history(session_id, chat_history, "")
return chat_history
# ===== 跳过:不执行工具,写跳过 ToolMessage 后续跑图(tool_call / human_interaction 通用)=====
if decision == "skip":
tool_call = dict(pending.get("tool_call") or {})
tool_name = tool_call.get("name") or "unknown_tool"
call_id = tool_call.get("id") or f"manual_{approval_id}"
skip_content = f"用户已跳过此工具调用({tool_name}),未执行。"
prior_messages = messages_from_dict(pending.get("messages") or [])
prior_messages.append(ToolMessage(content=skip_content, tool_call_id=call_id, name=tool_name))
original_turn_id = pending.get("turn_id", "")
if session_id:
set_current_session(session_id)
if original_turn_id:
from agent.history.tool_call_recorder import set_current_turn as _set_tc_turn
_set_tc_turn(original_turn_id)
turn_id = start_live_turn(session_id)
else:
turn_id = original_turn_id or ""
from agent.tools.todo_list import set_current_agent_name
set_current_agent_name("__main__")
return _resume_graph_after_tool_message(runtime, prior_messages, session_id, agent_mode, pending, chat_history, turn_id)
if pending.get("type") == "human_interaction":
original_turn_id = pending.get("turn_id", "")
if session_id and original_turn_id:
set_current_session(session_id)
# 恢复原始 turn_id 到上下文,确保后续 live 记录写入正确的文件
from agent.history.tool_call_recorder import record_tool_result_live, set_current_turn as _set_tc_turn
_set_tc_turn(original_turn_id)
tool_content = human_interaction_result(pending, decision, instruction)
record_tool_result_live(session_id, "human_interaction", tool_content)
else:
tool_content = human_interaction_result(pending, decision, instruction) if session_id else ""
try:
tool_call = dict(pending.get("tool_call") or {})
prior_messages = messages_from_dict(pending.get("messages") or [])
call_id = tool_call.get("id") or f"human_{approval_id}"
prior_messages.append(ToolMessage(content=tool_content, tool_call_id=call_id, name="human_interaction"))
all_messages, pending_meta = _invoke_after_manual_tool_result(runtime, prior_messages, session_id, agent_mode=agent_mode)
if pending_meta:
# 新一轮 human_interaction:不清理 live 记录,前端继续轮询展示
result = _handle_loop_force_end_in_pending(pending_meta, chat_history, session_id)
if result is not None:
return result
return _finalize_new_pending_after_human_action(pending_meta, pending, chat_history, session_id, agent_mode)
# 正常结束
end_live_turn(session_id)
assistant_msg = all_messages[-1] if all_messages else AIMessage(content="")
content = assistant_text(assistant_msg.content) or "(空回复)"
chat_history.append({"role": "assistant", "content": content, "meta": {"turn_id": original_turn_id or f"human_{approval_id}"}})
_persist_messages_from_pending_turn(session_id, all_messages, pending)
if session_id and original_turn_id:
save_turn(session_id, original_turn_id, {"role": "user", "content": pending.get("user_content") or ""}, all_messages)
_ensure_todo_cleared(session_id)
sync_turn_from_chat_history(session_id, chat_history, "")
return chat_history
except Exception:
import traceback
traceback.print_exc()
end_live_turn(session_id)
content = "处理人工交互结果时遇到问题。"
chat_history.append({"role": "assistant", "content": content, "meta": {}})
if session_id:
sync_turn_from_chat_history(session_id, chat_history, "")
return chat_history
tool_call = dict(pending.get("tool_call") or {})
tool_name = tool_call.get("name")
tool_args = tool_call.get("args") or {}
tool = runtime.tools_by_name.get(tool_name)
if not tool:
chat_history.append({"role": "assistant", "content": f"找不到工具:{tool_name}", "meta": {}})
if session_id:
sync_turn_from_chat_history(session_id, chat_history, "")
return chat_history
if session_id:
set_current_session(session_id)
turn_id = start_live_turn(session_id)
else:
turn_id = ""
try:
tool_content, tool_artifact = invoke_tool_and_build_message(tool, tool_name, tool_args, tool_call.get("id", ""))
prior_messages = messages_from_dict(pending.get("messages") or [])
call_id = tool_call.get("id") or f"manual_{approval_id}"
msg_kwargs = {"content": tool_content, "tool_call_id": call_id, "name": tool_name}
if tool_artifact:
msg_kwargs["artifact"] = tool_artifact
prior_messages.append(ToolMessage(**msg_kwargs))
all_messages, pending_meta = _invoke_after_manual_tool_result(runtime, prior_messages, session_id, agent_mode=agent_mode)
if pending_meta:
end_live_turn(session_id)
result = _handle_loop_force_end_in_pending(pending_meta, chat_history, session_id)
if result is not None:
return result
return _finalize_new_pending_after_human_action(pending_meta, pending, chat_history, session_id, agent_mode)
# 正常结束
end_live_turn(session_id)
assistant_msg = all_messages[-1] if all_messages else AIMessage(content="")
content = assistant_text(assistant_msg.content) or "(空回复)"
chat_history.append({"role": "assistant", "content": content, "meta": {"turn_id": turn_id}})
_persist_messages_from_pending_turn(session_id, all_messages, pending)
if session_id and turn_id:
save_turn(session_id, turn_id, {"role": "user", "content": pending.get("user_content") or ""}, all_messages)
_ensure_todo_cleared(session_id)
sync_turn_from_chat_history(session_id, chat_history, "")
return chat_history
except Exception:
import traceback
traceback.print_exc()
end_live_turn(session_id)
content = "执行确认后的工具调用时遇到问题。"
prior_messages = messages_from_dict(pending.get("messages") or [])
call_id = tool_call.get("id") or f"manual_{approval_id}"
prior_messages.extend([
ToolMessage(content=content, tool_call_id=call_id, name=tool_name or "unknown_tool"),
AIMessage(content=content),
])
chat_history.append({"role": "assistant", "content": content, "meta": {}})
_persist_messages_from_pending_turn(session_id, prior_messages, pending)
if session_id:
sync_turn_from_chat_history(session_id, chat_history, "")
return chat_history
+34
View File
@@ -0,0 +1,34 @@
"""LangGraph 状态类型定义。
系统定位:
定义图运行时共享状态 ``AgentState``,被 ``graph``、``nodes``、
``routing`` 作为 StateGraph 的 schema 使用。
可扩展性:
- 可在此增加 ``session_id``、``pending_approval`` 等字段,
需配合 reducer(如 add_messages)声明合并策略。
"""
from __future__ import annotations
from typing import Annotated, Literal, Sequence, TypedDict
from langchain_core.messages import BaseMessage
from langgraph.graph.message import add_messages
class AgentState(TypedDict):
"""LangGraph 图状态。
字段:
messages: 对话消息序列,通过 ``add_messages`` 追加合并。
agent_mode: 运行模式 — "agent"(标准)| "plan"(先规划再执行)| "cron"(定时任务无人值守)。
系统定位:
ReAct 循环(agent -> tools -> process_tool_artifact -> agent)的载体。
可扩展性:
扩展字段时保持 TypedDict 与 reducer 注解一致,避免并发更新冲突。
"""
messages: Annotated[Sequence[BaseMessage], add_messages]
agent_mode: str # "agent" | "plan"
+133
View File
@@ -0,0 +1,133 @@
"""工具执行安全策略:直接执行 vs 需人工确认。
系统定位:
在 ``core/routing.should_continue`` 中决定图是否进入 ToolNode,
或在遇到需确认工具时提前结束图执行,交由 UI 与 ``runtime`` 处理。
自动执行规则 (auto_execute_rules):
对于权限为 ``confirm`` 的工具,可通过 tool_config.json 中的
``auto_execute_rules`` 定义参数条件,匹配时自动升级为 ``direct``。
每条规则格式: ``{"parameter": "action", "operator": "equals", "value": "query"}``
含义: 当工具调用的 ``action`` 参数等于 ``"query"`` 时,跳过确认直接执行。
可扩展性:
- 可增加 operator 类型(contains、in、regex)。
- 可返回 ``Literal["direct", "confirm", "deny"]`` 支持完全禁止类工具。
"""
from __future__ import annotations
from typing import Literal
# 从 tool_config.json 加载的自动执行规则缓存
_AUTO_EXECUTE_RULES_CACHE: dict[str, list[dict]] | None = None
# 从 tool_config.json 加载的工具权限缓存
_TOOL_PERMISSIONS_CACHE: dict[str, str] | None = None
def _get_tool_permissions() -> dict[str, str]:
"""懒加载 tool_config.json 中各工具的 permission 字段。
返回:
{tool_name: "direct"|"confirm"} 映射;加载失败返回空字典。
"""
global _TOOL_PERMISSIONS_CACHE
if _TOOL_PERMISSIONS_CACHE is not None:
return _TOOL_PERMISSIONS_CACHE
try:
from agent.core.config_manager import load_tool_configs
configs = load_tool_configs()
_TOOL_PERMISSIONS_CACHE = {}
for c in configs:
if c.enabled:
_TOOL_PERMISSIONS_CACHE[c.name] = c.permission
except Exception:
_TOOL_PERMISSIONS_CACHE = {}
return _TOOL_PERMISSIONS_CACHE
def _get_auto_execute_rules() -> dict[str, list[dict]]:
"""懒加载 tool_config.json 中的 auto_execute_rules。
返回:
{tool_name: [rule, ...]} 映射;加载失败返回空字典。
"""
global _AUTO_EXECUTE_RULES_CACHE
if _AUTO_EXECUTE_RULES_CACHE is not None:
return _AUTO_EXECUTE_RULES_CACHE
try:
from agent.core.config_manager import load_tool_configs
configs = load_tool_configs()
_AUTO_EXECUTE_RULES_CACHE = {}
for c in configs:
if c.enabled and c.auto_execute_rules:
_AUTO_EXECUTE_RULES_CACHE[c.name] = c.auto_execute_rules
except Exception:
_AUTO_EXECUTE_RULES_CACHE = {}
return _AUTO_EXECUTE_RULES_CACHE
def reload_auto_execute_rules() -> None:
"""清除缓存,下次 classify 时重新从 JSON 加载。"""
global _AUTO_EXECUTE_RULES_CACHE, _TOOL_PERMISSIONS_CACHE
_AUTO_EXECUTE_RULES_CACHE = None
_TOOL_PERMISSIONS_CACHE = None
def _match_auto_execute_rule(rule: dict, args: dict) -> bool:
"""检查工具调用的 args 是否匹配单条自动执行规则。
输入:
rule: {"parameter": str, "operator": str, "value": str}
args: 工具调用的参数 dict。
输出:
匹配则返回 True。
支持的 operator:
- "equals": 参数值(转为字符串后)完全等于 rule value。
- "exists": 参数存在(不为 None/空字符串)即匹配,忽略 value。
"""
param = rule.get("parameter", "")
op = rule.get("operator", "equals")
if op == "exists":
actual = args.get(param)
return actual is not None and actual != ""
expected = str(rule.get("value", ""))
actual = str(args.get(param, ""))
if op == "equals":
return actual == expected
return False
def classify_tool_execution(tool_name: str | None, args: dict) -> Literal["direct", "confirm"]:
"""判定工具调用是否可在图内自动执行。
判定优先级:
1. 空名称 / human_interaction → 强制 confirm
2. tool_config.json 中 permission == "direct" → direct
3. tool_config.json 中 auto_execute_rules 匹配 → direct
4. 其余 → confirm
注意:
参数提取时使用安全默认值,避免 KeyError 或 None 导致的误判。
"""
if not tool_name:
return "confirm"
# human_interaction 必须人工确认,不可绕过
if tool_name == "human_interaction":
return "confirm"
# 读取 tool_config.json 中的权限(含 safe_tools 默认 direct,用户可改)
permissions = _get_tool_permissions()
if permissions.get(tool_name) == "direct":
return "direct"
# 检查 JSON 配置的 auto_execute_rules
rules = _get_auto_execute_rules().get(tool_name)
if rules:
for rule in rules:
if _match_auto_execute_rule(rule, args):
return "direct"
# 未匹配默认需确认
return "confirm"
+13
View File
@@ -0,0 +1,13 @@
"""Agent 定时任务(Cron)模式。
系统定位:
为 Agent 应用提供无人值守的定时执行能力。与 Edit/Chat 模式并列,
通过子进程隔离执行,不干扰主进程 Chat 流程。
子模块:
- models: 数据模型(CronTask / Trigger / ExecutionLog)
- storage: 存储抽象层(JSON 实现 + MySQL stub + 工厂)
- scheduler: 调度器(croniter + 自写线程,spawn 子进程)
- runner: 子进程入口(重定向 SESSIONS_ROOT,转发 live,回传 finished)
- live: 主进程侧 live 快照中继(SSE pub/sub)
"""
+72
View File
@@ -0,0 +1,72 @@
"""定时任务实时流中继(主进程侧)。
系统定位:
子进程(agent.cron.runner)无法直接与前端建立 SSE,故将 live 快照经
HTTP POST 回传主进程,由本模块按 task_id 分发给前端 SSE 订阅者。
快照格式与 tool_call_recorder._build_live_snapshot 一致:
{tool_calls, reflections, started_at},前端复用 toolcalls.js 渲染。
"""
from __future__ import annotations
import queue as _queue_module
import threading
from typing import Any
# per-task 订阅者队列列表
_cron_live_subs: dict[str, list[_queue_module.Queue]] = {}
_subscribers_lock = threading.Lock()
def subscribe(task_id: str) -> _queue_module.Queue:
"""订阅指定任务的实时更新,返回一个 Queue。"""
q: _queue_module.Queue = _queue_module.Queue(maxsize=64)
with _subscribers_lock:
if task_id not in _cron_live_subs:
_cron_live_subs[task_id] = []
_cron_live_subs[task_id].append(q)
return q
def unsubscribe(task_id: str, q: _queue_module.Queue) -> None:
"""取消订阅。"""
with _subscribers_lock:
subs = _cron_live_subs.get(task_id, [])
if q in subs:
subs.remove(q)
if not subs:
_cron_live_subs.pop(task_id, None)
def push_snapshot(task_id: str, snapshot: dict[str, Any]) -> None:
"""将子进程回传的 live 快照推送给该任务的所有订阅者。"""
with _subscribers_lock:
subs = list(_cron_live_subs.get(task_id, []))
if not subs:
return
for q in subs:
try:
q.put_nowait({"type": "update", "data": snapshot})
except _queue_module.Full:
try:
q.get_nowait()
q.put_nowait({"type": "update", "data": snapshot})
except Exception:
pass
def push_finished(task_id: str, payload: dict[str, Any]) -> None:
"""推送任务结束信号,前端据此重载消息并刷新状态。"""
with _subscribers_lock:
subs = list(_cron_live_subs.get(task_id, []))
if not subs:
return
for q in subs:
try:
q.put_nowait({"type": "finished", "data": payload})
except _queue_module.Full:
try:
q.get_nowait()
q.put_nowait({"type": "finished", "data": payload})
except Exception:
pass
+177
View File
@@ -0,0 +1,177 @@
"""定时任务数据模型。
系统定位:
定义 CronTask / Trigger / ExecutionLog 三个 dataclass,
被 storage / scheduler / runner / API 共享。
可扩展性:
- to_dict / from_dict 保证 JSON 持久化与未来 MySQL 序列化同构。
- 新增触发类型时扩展 Trigger.type 的 Literal 并补 _compute_next_run 分支。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Literal
TriggerType = Literal["cron", "interval", "once"]
TaskStatus = Literal["pending", "running", "completed", "failed", "expired", "disabled"]
TriggerSource = Literal["schedule", "manual"]
@dataclass
class Trigger:
"""触发配置。
字段:
type: "cron"(cron 表达式)| "interval"(固定间隔秒)| "once"(一次性延时)。
cron: type=cron 时的标准 5 段 cron 表达式,如 "0 9 * * *"。
interval_seconds: type=interval 时的间隔秒数。
run_at: type=once 时的目标本地时间(ISO 字符串,如 "2026-07-29T18:00:00")。
"""
type: TriggerType = "cron"
cron: str = ""
interval_seconds: int = 0
run_at: str = ""
def to_dict(self) -> dict[str, Any]:
return {
"type": self.type,
"cron": self.cron,
"interval_seconds": self.interval_seconds,
"run_at": self.run_at,
}
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> "Trigger":
data = data or {}
return cls(
type=data.get("type", "cron"),
cron=data.get("cron", ""),
interval_seconds=int(data.get("interval_seconds", 0) or 0),
run_at=data.get("run_at", ""),
)
@dataclass
class ExecutionLog:
"""单次执行记录。
字段:
started_at: 开始时间(ISO 字符串)。
ended_at: 结束时间(ISO 字符串)。
status: 本次执行结果状态。
error: 失败时的错误描述。
trigger: 触发来源——schedule(调度)| manual(手动/追问)。
turn_id: 对应的 turn_id,用于关联工具调用记录。
"""
started_at: str = ""
ended_at: str = ""
status: TaskStatus = "completed"
error: str = ""
trigger: TriggerSource = "schedule"
turn_id: str = ""
def to_dict(self) -> dict[str, Any]:
return {
"started_at": self.started_at,
"ended_at": self.ended_at,
"status": self.status,
"error": self.error,
"trigger": self.trigger,
"turn_id": self.turn_id,
}
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> "ExecutionLog":
data = data or {}
return cls(
started_at=data.get("started_at", ""),
ended_at=data.get("ended_at", ""),
status=data.get("status", "completed"),
error=data.get("error", ""),
trigger=data.get("trigger", "schedule"),
turn_id=data.get("turn_id", ""),
)
@dataclass
class CronTask:
"""定时任务完整定义。
字段:
task_id: 任务唯一标识(时间戳风格,与 session_id 同构)。
name: 任务名称(前端展示)。
prompt: 触发时投递给 Agent 的用户提示词。
trigger: 触发配置。
enabled: 是否启用。
timeout_seconds: 单次执行超时秒数。
created_at: 创建时间(ISO 字符串)。
next_run_at: 下次预计运行时间(ISO 字符串,空表示不再运行)。
last_run_at: 上次运行时间(ISO 字符串)。
last_status: 最近一次执行状态。
last_error: 最近一次失败错误描述。
executions: 历史执行记录列表。
"""
task_id: str = ""
name: str = ""
prompt: str = ""
trigger: Trigger = field(default_factory=Trigger)
enabled: bool = True
timeout_seconds: int = 300
created_at: str = ""
next_run_at: str = ""
last_run_at: str = ""
last_status: TaskStatus = "pending"
last_error: str = ""
executions: list[ExecutionLog] = field(default_factory=list)
def to_dict(self) -> dict[str, Any]:
return {
"task_id": self.task_id,
"name": self.name,
"prompt": self.prompt,
"trigger": self.trigger.to_dict(),
"enabled": self.enabled,
"timeout_seconds": self.timeout_seconds,
"created_at": self.created_at,
"next_run_at": self.next_run_at,
"last_run_at": self.last_run_at,
"last_status": self.last_status,
"last_error": self.last_error,
"executions": [e.to_dict() for e in self.executions],
}
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> "CronTask":
data = data or {}
return cls(
task_id=data.get("task_id", ""),
name=data.get("name", ""),
prompt=data.get("prompt", ""),
trigger=Trigger.from_dict(data.get("trigger")),
enabled=bool(data.get("enabled", True)),
timeout_seconds=int(data.get("timeout_seconds", 300) or 300),
created_at=data.get("created_at", ""),
next_run_at=data.get("next_run_at", ""),
last_run_at=data.get("last_run_at", ""),
last_status=data.get("last_status", "pending"),
last_error=data.get("last_error", ""),
executions=[ExecutionLog.from_dict(e) for e in (data.get("executions") or [])],
)
def summary(self) -> dict[str, Any]:
"""返回前端列表展示用的精简字典(不含 executions 全量)。"""
d = self.to_dict()
d["executions_count"] = len(self.executions)
d.pop("executions", None)
return d
def now_iso() -> str:
"""当前本地时间的 ISO 字符串(无时区后缀,与前端一致)。"""
return datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
+282
View File
@@ -0,0 +1,282 @@
"""定时任务子进程入口。
系统定位:
被 CronScheduler 通过 ``python -m agent.cron.runner`` 拉起的独立进程,
无人值守地执行单个定时任务。与主进程 Chat 完全隔离,可多任务并行。
执行流程:
1. 重定向 SESSIONS_ROOT / TOOL_CALLING_ROOT / ui_session.SESSIONS_ROOT 到
history/cron/{task_id}/,使所有 turn/tool/token 文件写入 cron 目录。
2. 构建主 AgentRuntime(复用 build_all_agent_runtimes)。
3. 起后台线程订阅本进程 tool_call_recorder.subscribe_live(task_id),
将快照经 HTTP POST 转发到主进程 /api/cron/internal/live。
4. 以 agent_mode="cron" 调用 execute_agent,工作线程超时则强制结束。
5. POST /api/cron/internal/finished 回传最终状态后退出。
可扩展性:
- 超时控制当前采用工作线程 + 进程退出;未来可改 asyncio + 信号。
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import threading
import time
import traceback
from pathlib import Path
from typing import Any
# 主进程回传地址(uvicorn 单 worker,固定端口)
_MAIN_HOST = "127.0.0.1"
_MAIN_PORT = 8765
_LIVE_URL = f"http://{_MAIN_HOST}:{_MAIN_PORT}/api/cron/internal/live"
_FINISHED_URL = f"http://{_MAIN_HOST}:{_MAIN_PORT}/api/cron/internal/finished"
def _redirect_storage_roots(task_id: str) -> str:
"""将本进程的会话存储根目录重定向到 history/cron/{task_id}/。
必须在构建 AgentRuntime 与调用 execute_agent 之前执行。三个模块全局均
被显式覆盖(函数在调用时读取模块全局,故覆盖后即生效):
- agent.utils.agent_utils.SESSIONS_ROOT → turn 文件、session_dir、get_history
- agent.history.tool_call_recorder.TOOL_CALLING_ROOT → tool_*.json、token
- web.ui_session.SESSIONS_ROOT → turn relpath 计算
返回重定向后的任务目录绝对路径。
"""
from agent.cron.storage import CRON_ROOT
import agent.utils.agent_utils as au
au.SESSIONS_ROOT = CRON_ROOT
from agent.history import tool_call_recorder as tcr
tcr.TOOL_CALLING_ROOT = Path(CRON_ROOT)
import web.ui_session as ui
ui.SESSIONS_ROOT = CRON_ROOT
task_dir = os.path.join(CRON_ROOT, task_id)
os.makedirs(task_dir, exist_ok=True)
return task_dir
def _start_live_forwarder(task_id: str) -> threading.Event:
"""启动后台线程:订阅本进程 live 更新并转发到主进程。
返回一个 stop Event,主线程在执行结束后 set 它以终止转发线程。
"""
stop = threading.Event()
def _forward():
try:
import requests
from agent.history.tool_call_recorder import subscribe_live, unsubscribe_live
except Exception:
return
q = subscribe_live(task_id)
try:
while not stop.is_set():
try:
snapshot = q.get(timeout=0.5)
except Exception:
continue
if snapshot is None:
break
try:
requests.post(
_LIVE_URL,
json={"task_id": task_id, "snapshot": snapshot},
timeout=5,
)
except Exception:
# 主进程不可达时不阻塞子进程执行
pass
finally:
try:
unsubscribe_live(task_id, q)
except Exception:
pass
t = threading.Thread(target=_forward, name=f"cron-live-{task_id}", daemon=True)
t.start()
return stop
def _post_finished(task_id: str, status: str, error: str, turn_id: str) -> None:
"""回传最终状态到主进程。失败时仅打印日志,不影响子进程退出。"""
try:
import requests
requests.post(
_FINISHED_URL,
json={
"task_id": task_id,
"status": status,
"error": error,
"turn_id": turn_id,
},
timeout=10,
)
except Exception as e:
print(f"[cron.runner] 回传 finished 失败: {e}", file=sys.stderr)
def _run_with_timeout(func, timeout: float) -> tuple[Any, BaseException | None, bool]:
"""在工作线程中执行 func。
返回 (result, exception, timed_out):
- 正常完成 → (value, None, False)
- 抛异常 → (None, exc, False)
- 超时 → (None, None, True)
超时后无法真正杀死工作线程(Python 限制),但子进程随后 sys.exit 会使
整个进程退出,工作线程随之销毁。
"""
box: dict[str, Any] = {}
done = threading.Event()
def _worker():
try:
box["value"] = func()
except BaseException as e: # noqa: BLE001 — 子进程需捕获全部异常以回传状态
box["error"] = e
finally:
done.set()
t = threading.Thread(target=_worker, name="cron-exec", daemon=True)
t.start()
finished = done.wait(timeout=timeout if timeout > 0 else None)
if not finished:
return None, None, True
if "error" in box:
return None, box["error"], False
return box.get("value"), None, False
def _classify_runner_error(e: Exception) -> str:
"""将执行异常归类为可读错误描述(复用 runtime 的分类逻辑)。"""
try:
from agent.core.runtime import _classify_error
return _classify_error(e)
except Exception:
return f"执行过程中出现错误: {str(e)[:200]}"
def main() -> int:
parser = argparse.ArgumentParser(description="Agent 定时任务子进程执行器")
parser.add_argument("--task-id", required=True, help="任务 ID")
parser.add_argument("--prompt", required=True, help="触发时投递给 Agent 的提示词")
parser.add_argument("--trigger", default="schedule", choices=["schedule", "manual"],
help="触发来源:schedule(调度)| manual(手动/追问)")
parser.add_argument("--timeout", type=int, default=300, help="单次执行超时秒数")
args = parser.parse_args()
task_id = args.task_id
prompt = args.prompt
timeout = max(int(args.timeout or 300), 10)
started_iso = ""
try:
from agent.cron.models import now_iso
started_iso = now_iso()
except Exception:
pass
# 1. 重定向存储根目录(必须在构建 runtime 之前)
try:
_redirect_storage_roots(task_id)
except Exception as e:
print(f"[cron.runner] 重定向存储根目录失败: {e}", file=sys.stderr)
traceback.print_exc()
_post_finished(task_id, "failed", f"存储初始化失败: {e}", "")
return 1
# 2. 构建 AgentRuntime
try:
from agent.agents.agent_runtime import build_all_agent_runtimes, get_main_agent_runtime
build_all_agent_runtimes()
runtime = get_main_agent_runtime()
if runtime is None:
_post_finished(task_id, "failed", "未找到可用的主 Agent 运行时", "")
return 1
except Exception as e:
print(f"[cron.runner] 构建 AgentRuntime 失败: {e}", file=sys.stderr)
traceback.print_exc()
_post_finished(task_id, "failed", f"Agent 初始化失败: {e}", "")
return 1
# 3. 启动 live 转发线程
stop_forwarder = _start_live_forwarder(task_id)
# 4. 执行任务(agent_mode="cron",直接走 core.runtime 绕开 {"agent","plan"} 归一化)
from agent.core.runtime import execute_agent as execute_runtime_agent
from agent.utils.agent_utils import get_last_turn_id
chat_history = [{"role": "user", "content": prompt}]
status = "completed"
error_msg = ""
def _exec():
return execute_runtime_agent(
runtime=runtime,
chat_history=chat_history,
session_id=task_id,
agent_mode="cron",
)
try:
result_chat_history, exc, timed_out = _run_with_timeout(_exec, float(timeout))
if timed_out:
status = "failed"
error_msg = f"任务超时(超过 {timeout} 秒)"
elif exc is not None:
status = "failed"
error_msg = _classify_runner_error(exc)
traceback.print_exception(type(exc), exc, exc.__traceback__)
else:
# 正常完成;检查最后一条消息是否含错误文案(如 OOM、API 错误)
if result_chat_history:
last = result_chat_history[-1] if result_chat_history else {}
if isinstance(last, dict) and last.get("role") == "assistant":
content = str(last.get("content") or "")
_ERROR_PREFIXES = (
"OOM:", "执行过程中出现错误", "API Key", "无法连接",
"指定的模型", "模型服务端", "对话内容超过", "API 调用频率",
"Agent 执行轮数超过限制",
)
if content.startswith(_ERROR_PREFIXES):
status = "failed"
error_msg = content
except Exception as e:
status = "failed"
error_msg = _classify_runner_error(e)
traceback.print_exc()
# 5. 停止转发线程
stop_forwarder.set()
# 6. 获取本次 turn_id(用于关联执行记录)
turn_id = ""
try:
turn_id = get_last_turn_id(task_id) or ""
except Exception:
pass
# 6.5 MySQL 模式下把执行记录导入数据库(json 模式无操作,文件即存储)
try:
from agent.cron.storage import persist_turn_records
persist_turn_records(task_id, turn_id)
except Exception as e:
print(f"[cron.runner] 导入执行记录失败: {e}", file=sys.stderr)
# 7. 回传最终状态
_post_finished(task_id, status, error_msg, turn_id)
# 给转发线程一点时间发送最后的快照
time.sleep(0.3)
return 0 if status == "completed" else 1
if __name__ == "__main__":
sys.exit(main())
+540
View File
@@ -0,0 +1,540 @@
"""定时任务调度器。
系统定位:
主进程后台 daemon 线程,每 5s 扫描所有启用的定时任务,到点则拉起
子进程(agent.cron.runner)执行。负责 next_run_at 计算、错失/过期处理、
子进程生命周期回收。
设计要点:
- croniter 计算 cron 表达式的下一次触发时刻。
- once 类型若错过且从未成功 → 标记 expired 并禁用。
- cron 类型启动扫描时若发现错过一轮 → 记 expired 执行记录并推进到下一时刻。
- _running_procs 防止同任务并发执行。
- spawn_now 供手动立即运行 / 追问使用(trigger=manual)。
"""
from __future__ import annotations
import os
import subprocess
import sys
import threading
import time
from datetime import datetime
from pathlib import Path
from typing import Any
from agent.cron.models import CronTask, now_iso
from agent.cron.storage import get_task_repository
# 扫描间隔(秒)
_SCAN_INTERVAL = 5.0
# 时间段长度(分钟):同一时间段内只允许一个定时任务运行(全局并发=1)。
# 由 env_config.json 的 CRON_TIME_PERIOD_MINUTES 配置,默认 30。
_CRON_TIME_PERIOD_MINUTES = 30.0
def _load_time_period_minutes() -> float:
"""从 env_config.json 读取 CRON_TIME_PERIOD_MINUTES,失败回退默认 30。"""
global _CRON_TIME_PERIOD_MINUTES
try:
from agent.core.config_manager import load_env_config
cfg = load_env_config() or {}
val = float(cfg.get("CRON_TIME_PERIOD_MINUTES", 30) or 30)
if val > 0:
_CRON_TIME_PERIOD_MINUTES = val
except Exception:
pass
return _CRON_TIME_PERIOD_MINUTES
def get_cron_time_period() -> float:
"""获取当前时间段长度(分钟),供 API 暴露给前端与冲突检测使用。"""
return _CRON_TIME_PERIOD_MINUTES
def set_cron_time_period(minutes: float) -> float:
"""热更新时间段长度(分钟),并持久化到 env_config.json。
输入:
minutes: 新的时间段长度(必须为正数)。
输出:
生效后的时间段长度(分钟)。
"""
global _CRON_TIME_PERIOD_MINUTES
val = float(minutes)
if val <= 0:
raise ValueError("时间段长度必须为正数")
_CRON_TIME_PERIOD_MINUTES = val
try:
from agent.core.config_manager import load_env_config, save_env_config
cfg = load_env_config() or {}
cfg["CRON_TIME_PERIOD_MINUTES"] = int(val) if float(val).is_integer() else val
save_env_config(cfg)
except Exception as e:
print(f"[CronScheduler] 保存 CRON_TIME_PERIOD_MINUTES 失败: {e}")
return val
def _parse_iso(dt_str: str) -> datetime | None:
"""解析 ISO 字符串(无时区)为 datetime,失败返回 None。"""
if not dt_str:
return None
try:
return datetime.strptime(dt_str, "%Y-%m-%dT%H:%M:%S")
except ValueError:
return None
def _compute_next_run(trigger, now: datetime) -> str:
"""根据触发配置计算下一次运行时刻(ISO 字符串)。
输入:
trigger: Trigger 实例。
now: 当前 datetime。
输出:
下次运行 ISO 字符串;不再运行(once 已过)时返回 ""。
"""
t_type = trigger.type
if t_type == "cron":
if not trigger.cron:
return ""
try:
from croniter import croniter
itr = croniter(trigger.cron, now)
return itr.get_next(datetime).strftime("%Y-%m-%dT%H:%M:%S")
except Exception:
return ""
if t_type == "interval":
if trigger.interval_seconds <= 0:
return ""
nxt = now + timedelta_safe(trigger.interval_seconds)
return nxt.strftime("%Y-%m-%dT%H:%M:%S")
if t_type == "once":
target = _parse_iso(trigger.run_at)
if target is None or target <= now:
# 已过时,不再调度
return ""
return trigger.run_at
return ""
def timedelta_safe(seconds: int):
from datetime import timedelta
return timedelta(seconds=seconds)
def compute_next_fire(trigger, now: datetime | None = None) -> datetime | None:
"""计算下一次触发时刻(datetime),供新建/修改任务的时段冲突检测使用。
输入:
trigger: Trigger 实例(type/cron/interval_seconds/run_at)。
now: 基准时刻,默认 datetime.now()。
输出:
下一次触发的 datetime;不再触发(once 已过等)返回 None。
"""
now = now or datetime.now()
iso = _compute_next_run(trigger, now)
if not iso:
return None
return _parse_iso(iso)
def detect_time_period_conflict(trigger, exclude_task_id: str | None = None) -> dict[str, Any]:
"""检测候选任务下次触发时刻是否落入某现有任务时段窗口内。
供前端 check-conflict API 与 cron_manager 工具复用,保证两条入口判定一致。
输入:
trigger: 候选任务的 Trigger 实例。
exclude_task_id: 修改任务时排除自身 task_id,避免与自身比较。
输出:
{"conflict": bool, "conflict_with": [name,...], "period_minutes": float, "next_fire": "ISO"|""}
若候选下次触发时刻与某已启用任务的下次触发时刻绝对差 < 时段长度则 conflict=True。
"""
period_min = get_cron_time_period()
period_sec = period_min * 60
now = datetime.now()
cand_fire = compute_next_fire(trigger, now)
result: dict[str, Any] = {
"conflict": False,
"conflict_with": [],
"period_minutes": period_min,
"next_fire": cand_fire.strftime("%Y-%m-%dT%H:%M:%S") if cand_fire else "",
}
if cand_fire is None:
# 候选不再触发(once 已过等),无冲突
return result
exclude_id = str(exclude_task_id or "")
repo = get_task_repository()
for t in repo.list_tasks():
if not t.enabled:
continue
if t.task_id == exclude_id:
continue
ex_fire = compute_next_fire(t.trigger, now)
if ex_fire is None:
continue
if abs((cand_fire - ex_fire).total_seconds()) < period_sec:
result["conflict"] = True
if t.name and t.name not in result["conflict_with"]:
result["conflict_with"].append(t.name)
return result
class CronScheduler:
"""单例调度器。
生命周期:
start() → 启动 daemon 线程;shutdown() → 终止线程并 kill 所有子进程。
由 FastAPI lifespan 调用。
"""
def __init__(self) -> None:
self._thread: threading.Thread | None = None
self._stop_event = threading.Event()
self._running_procs: dict[str, subprocess.Popen] = {}
self._procs_lock = threading.Lock()
self._app_start_time = datetime.now()
self._started = False
# 当前占用时段起点(time.time() 秒),None 表示空闲。
# 任务启动时置为 now,时段自然到期(超过 CRON_TIME_PERIOD_MINUTES)后释放;
# _monitor_proc 回收时不清除,确保"一个时间段一个任务"。
self._period_start_ts: float | None = None
# ---------- 单例 ----------
_instance: "CronScheduler | None" = None
_instance_lock = threading.Lock()
@classmethod
def get_instance(cls) -> "CronScheduler":
with cls._instance_lock:
if cls._instance is None:
cls._instance = cls()
return cls._instance
# ---------- 生命周期 ----------
def start(self) -> None:
if self._started:
return
self._started = True
self._stop_event.clear()
self._app_start_time = datetime.now()
# 启动时加载时间段长度配置(CRON_TIME_PERIOD_MINUTES)
_load_time_period_minutes()
self._thread = threading.Thread(target=self._run_loop, name="cron-scheduler", daemon=True)
self._thread.start()
print("[CronScheduler] 调度线程已启动")
def shutdown(self) -> None:
if not self._started:
return
self._stop_event.set()
# 终止所有运行中的子进程
with self._procs_lock:
procs = list(self._running_procs.values())
for p in procs:
try:
p.terminate()
except Exception:
pass
if self._thread:
self._thread.join(timeout=3.0)
self._started = False
print("[CronScheduler] 调度线程已停止")
# ---------- 主循环 ----------
def _run_loop(self) -> None:
# 启动时重算缺失的 next_run_at,并处理错失/过期
try:
self._on_startup()
except Exception as e:
print(f"[CronScheduler] 启动扫描异常: {e}")
while not self._stop_event.is_set():
try:
self._scan_once()
except Exception as e:
print(f"[CronScheduler] 扫描异常: {e}")
self._stop_event.wait(_SCAN_INTERVAL)
def _on_startup(self) -> None:
"""启动扫描:重算 next_run_at,处理应用未运行期间错失的触发。"""
repo = get_task_repository()
now = datetime.now()
for task in repo.list_tasks():
if not task.enabled:
continue
if task.task_id in self._running_procs:
continue
self._handle_startup_task(task, now, repo)
def _handle_startup_task(self, task: CronTask, now: datetime, repo) -> None:
"""处理单个任务在启动时的 next_run_at 重算与错失判定。"""
trigger = task.trigger
t_type = trigger.type
# once 类型:若目标时间已过且从未成功完成 → 标记 expired 并禁用
if t_type == "once":
target = _parse_iso(trigger.run_at)
if target is not None and target < now:
ever_succeeded = any(e.status == "completed" for e in task.executions)
if not ever_succeeded:
repo.append_execution(task.task_id, {
"started_at": trigger.run_at,
"ended_at": now_iso(),
"status": "expired",
"error": "应用未运行,已错过执行时间",
"trigger": "schedule",
"turn_id": "",
})
repo.update_status(task.task_id, "expired",
error="应用未运行,已错过执行时间",
next_run_at="")
task.enabled = False
task.last_status = "expired"
repo.save_task(task)
return
# once 未过或已成功:重算 next_run_at
nxt = _compute_next_run(trigger, now)
if nxt != task.next_run_at:
repo.update_status(task.task_id, task.last_status, next_run_at=nxt)
return
# cron 类型:若 next_run_at 早于 app 启动时间 → 说明错过了一轮
if t_type == "cron":
if not task.next_run_at:
nxt = _compute_next_run(trigger, now)
repo.update_status(task.task_id, task.last_status, next_run_at=nxt)
return
nxt_dt = _parse_iso(task.next_run_at)
if nxt_dt is not None and nxt_dt < self._app_start_time:
# 错过该次触发:记 expired 执行记录,推进到下一个未来时刻
repo.append_execution(task.task_id, {
"started_at": task.next_run_at,
"ended_at": now_iso(),
"status": "expired",
"error": "应用未运行错过该次触发",
"trigger": "schedule",
"turn_id": "",
})
new_nxt = _compute_next_run(trigger, now)
repo.update_status(task.task_id, task.last_status, next_run_at=new_nxt)
return
# interval 类型:直接推进到 now + interval(不记过期)
if t_type == "interval":
nxt_dt = _parse_iso(task.next_run_at)
if not task.next_run_at or (nxt_dt is not None and nxt_dt < now):
new_nxt = _compute_next_run(trigger, now)
repo.update_status(task.task_id, task.last_status, next_run_at=new_nxt)
return
def _scan_once(self) -> None:
"""单次扫描:到点且未在跑的任务 → spawn 子进程。"""
repo = get_task_repository()
now = datetime.now()
now_iso_str = now_iso()
for task in repo.list_tasks():
if not task.enabled:
continue
if task.task_id in self._running_procs:
continue
if not task.next_run_at:
continue
nxt_dt = _parse_iso(task.next_run_at)
if nxt_dt is None or nxt_dt > now:
continue
# 到点触发
self._spawn(task, trigger_source="schedule", started_iso=now_iso_str)
# ---------- 子进程管理 ----------
def _get_python_and_cwd(self) -> tuple[str, str]:
"""获取子进程使用的 Python 解释器路径与工作目录(src)。"""
try:
from agent.core.config_manager import load_env_config
cfg = load_env_config() or {}
py = cfg.get("USER_PYTHON_PATH", "")
if py and os.path.isfile(py):
pass
else:
py = sys.executable
except Exception:
py = sys.executable
# cwd = src 目录(agent/web 包所在目录)
from agent.cron import storage as _storage
cwd = str(Path(_storage.__file__).resolve().parents[2])
return py, cwd
def _spawn(self, task: CronTask, trigger_source: str, started_iso: str, prompt: str | None = None) -> bool:
"""拉起子进程执行任务。
输入:
task: 任务对象。
trigger_source: "schedule" | "manual"。
started_iso: 本次开始时间 ISO。
prompt: 可选覆盖提示词(手动追问时使用);None 则用 task.prompt。
输出:
True 表示已成功 spawn。
"""
with self._procs_lock:
if task.task_id in self._running_procs:
return False
# 时段 gate:同一时间段内只允许一个任务运行。
# 1) 已有任务在跑 → 跳过;
# 2) 上一个任务启动后仍在 CRON_TIME_PERIOD_MINUTES 时段窗口内 → 跳过。
now_ts = time.time()
if self._running_procs:
print(f"[CronScheduler] 时段内已有任务运行,跳过 {task.task_id}")
return False
if self._period_start_ts is not None and (now_ts - self._period_start_ts) < _CRON_TIME_PERIOD_MINUTES * 60:
print(f"[CronScheduler] 时段内已有任务,跳过 {task.task_id}")
return False
# 占用新时段
self._period_start_ts = now_ts
py, cwd = self._get_python_and_cwd()
use_prompt = prompt if prompt is not None else task.prompt
cmd = [
py, "-m", "agent.cron.runner",
"--task-id", task.task_id,
"--prompt", use_prompt,
"--trigger", trigger_source,
"--timeout", str(task.timeout_seconds),
]
env = dict(os.environ)
# 确保 src 在 PYTHONPATH(与 cwd 配合,-m 已能定位 agent 包)
try:
if cwd not in env.get("PYTHONPATH", ""):
env["PYTHONPATH"] = cwd + os.pathsep + env.get("PYTHONPATH", "")
except Exception:
pass
try:
proc = subprocess.Popen(
cmd,
cwd=cwd,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
# 避免子进程随父进程退出被信号干扰
creationflags=getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) if os.name == "nt" else 0,
)
except Exception as e:
print(f"[CronScheduler] 拉起子进程失败 task={task.task_id}: {e}")
get_task_repository().update_status(task.task_id, "failed", error=f"拉起子进程失败: {e}")
# 子进程未启动成功,释放占用的时段(避免空占时段阻塞后续任务)
with self._procs_lock:
if not self._running_procs:
self._period_start_ts = None
return False
with self._procs_lock:
self._running_procs[task.task_id] = proc
repo = get_task_repository()
repo.update_status(task.task_id, "running", error="", last_run_at=started_iso)
# 后台线程监控子进程退出
monitor = threading.Thread(
target=self._monitor_proc,
args=(task.task_id, proc, trigger_source, started_iso),
name=f"cron-monitor-{task.task_id}",
daemon=True,
)
monitor.start()
return True
def _monitor_proc(self, task_id: str, proc: subprocess.Popen, trigger_source: str, started_iso: str) -> None:
"""监控子进程退出,回收并更新状态。
注意:子进程自身会 POST /api/cron/internal/finished 回传最终状态(含超时/异常)。
本监控线程作为兜底:若子进程异常退出且未回传,则据 exit code 推断状态;
若已回传(status 已被 finished handler 更新为 completed/failed),则不覆盖。
"""
exit_code = proc.wait()
ended_iso = now_iso()
with self._procs_lock:
self._running_procs.pop(task_id, None)
repo = get_task_repository()
task = repo.get_task(task_id)
if task is None:
return
# 若 finished handler 已更新状态为终态(completed/failed/expired),则不覆盖
if task.last_status in ("completed", "failed", "expired"):
return
# 兜底:子进程未回传 finished(如被 kill、崩溃),据 exit code 推断
if exit_code == 0:
status = "completed"
error = ""
else:
status = "failed"
error = f"子进程异常退出(exit code {exit_code})"
repo.update_status(task_id, status, error=error, last_run_at=started_iso)
repo.append_execution(task_id, {
"started_at": started_iso,
"ended_at": ended_iso,
"status": status,
"error": error,
"trigger": trigger_source,
"turn_id": "",
})
# ---------- 手动触发 / 立即运行 ----------
def spawn_now(self, task_id: str, prompt: str | None = None) -> bool:
"""立即运行指定任务(手动触发或追问)。
输入:
task_id: 任务 ID。
prompt: 追问时传入的新提示词;None 则使用任务原始 prompt。
输出:
True 表示已成功 spawn。
"""
task = get_task_repository().get_task(task_id)
if task is None:
return False
with self._procs_lock:
if task_id in self._running_procs:
return False
return self._spawn(task, trigger_source="manual", started_iso=now_iso(), prompt=prompt)
def is_running(self, task_id: str) -> bool:
with self._procs_lock:
return task_id in self._running_procs
def kill_task(self, task_id: str) -> bool:
"""终止运行中的子进程。"""
with self._procs_lock:
proc = self._running_procs.get(task_id)
if proc is None:
return False
try:
proc.terminate()
return True
except Exception:
return False
def reload_task(self, task_id: str) -> None:
"""任务配置变更后重算 next_run_at(供 cron_manager 工具调用)。"""
task = get_task_repository().get_task(task_id)
if task is None:
return
if not task.enabled:
return
nxt = _compute_next_run(task.trigger, datetime.now())
get_task_repository().update_status(task_id, task.last_status, next_run_at=nxt)
def get_scheduler() -> CronScheduler:
"""获取全局调度器单例。"""
return CronScheduler.get_instance()
+760
View File
@@ -0,0 +1,760 @@
"""定时任务存储抽象层。
系统定位:
为 scheduler / API / cron_manager 工具提供统一的任务与记录读写接口。
支持两种后端:JSON 文件(history/cron/{task_id}/cron_task.json)与
MySQL(pymysql,表 cron_tasks / cron_messages / cron_tool_calls)。
可扩展性:
- get_task_repository() / get_record_repository() 依据 env_config.json 的
STORAGE_BACKEND(默认 "json")选择实现,两种后端均完整支持增删改查。
- 多模态附件地址在读取时解析为绝对地址(相对 CRON_ROOT),
保证数据库迁移到不同机器后仍可定位文件。
"""
from __future__ import annotations
import json
import os
import shutil
from pathlib import Path
from typing import Any, Protocol
from agent.cron.models import CronTask, ExecutionLog, now_iso
from agent.utils.agent_utils import HISTORY_ROOT
from agent.core.db import (
mysql_conn,
ensure_tables,
get_storage_backend,
)
# 定时任务根目录:history/cron
CRON_ROOT = os.path.join(HISTORY_ROOT, "cron")
os.makedirs(CRON_ROOT, exist_ok=True)
# ---------- Protocol 定义 ----------
class CronTaskRepository(Protocol):
"""任务配置与状态的读写接口。"""
def list_tasks(self) -> list[CronTask]: ...
def get_task(self, task_id: str) -> CronTask | None: ...
def save_task(self, task: CronTask) -> None: ...
def delete_task(self, task_id: str) -> None: ...
def update_status(
self,
task_id: str,
status: str,
*,
error: str = "",
last_run_at: str = "",
next_run_at: str = "",
) -> None: ...
def append_execution(self, task_id: str, exec_log: dict[str, Any]) -> None: ...
class CronRecordRepository(Protocol):
"""任务执行记录(消息与工具调用)的读取接口。"""
def load_messages(self, task_id: str) -> list[dict]: ...
def list_turns(self, task_id: str) -> list[dict]: ...
def get_tool_calls(self, task_id: str, turn_id: str) -> list[dict]: ...
# ---------- JSON 实现 ----------
class JsonCronTaskRepository:
"""基于 JSON 文件的任务仓库。
存储路径: CRON_ROOT/{task_id}/cron_task.json
"""
def _task_dir(self, task_id: str) -> str:
return os.path.join(CRON_ROOT, task_id)
def _task_file(self, task_id: str) -> str:
return os.path.join(self._task_dir(task_id), "cron_task.json")
def list_tasks(self) -> list[CronTask]:
if not os.path.isdir(CRON_ROOT):
return []
tasks: list[CronTask] = []
for name in os.listdir(CRON_ROOT):
tf = self._task_file(name)
if not os.path.isfile(tf):
continue
task = self._load(tf)
if task:
tasks.append(task)
tasks.sort(key=lambda t: t.created_at, reverse=True)
return tasks
def get_task(self, task_id: str) -> CronTask | None:
return self._load(self._task_file(task_id))
def save_task(self, task: CronTask) -> None:
if not task.task_id:
return
d = self._task_dir(task.task_id)
os.makedirs(d, exist_ok=True)
with open(self._task_file(task.task_id), "w", encoding="utf-8") as f:
json.dump(task.to_dict(), f, ensure_ascii=False, indent=2)
def delete_task(self, task_id: str) -> None:
if not task_id:
return
d = self._task_dir(task_id)
if os.path.isdir(d):
shutil.rmtree(d, ignore_errors=True)
def update_status(
self,
task_id: str,
status: str,
*,
error: str = "",
last_run_at: str = "",
next_run_at: str = "",
) -> None:
task = self.get_task(task_id)
if not task:
return
task.last_status = status # type: ignore[assignment]
if error:
task.last_error = error
elif status != "failed":
# 成功类状态清空错误描述
task.last_error = ""
if last_run_at:
task.last_run_at = last_run_at
if next_run_at is not None:
task.next_run_at = next_run_at
self.save_task(task)
def append_execution(self, task_id: str, exec_log: dict[str, Any]) -> None:
task = self.get_task(task_id)
if not task:
return
task.executions.append(ExecutionLog.from_dict(exec_log))
self.save_task(task)
@staticmethod
def _load(path: str) -> CronTask | None:
if not os.path.isfile(path):
return None
try:
with open(path, "r", encoding="utf-8") as f:
return CronTask.from_dict(json.load(f))
except (json.JSONDecodeError, OSError):
return None
class JsonCronRecordRepository:
"""基于 JSON 文件的执行记录仓库。
复用 session 同构的 turn_*.json / tool_*.json(由 runner 写入)。
读取时将附件 relpath 解析为绝对地址(相对 CRON_ROOT),保证可移植性。
"""
def _task_dir(self, task_id: str) -> str:
return os.path.join(CRON_ROOT, task_id)
def load_messages(self, task_id: str) -> list[dict]:
"""加载该任务所有 turn 的扁平消息列表,附件路径解析为绝对地址。"""
from agent.utils.agent_utils import TURN_JSON_PREFIX
sess_dir = self._task_dir(task_id)
if not os.path.isdir(sess_dir):
return []
all_msgs: list[dict] = []
turn_files = sorted(
f for f in os.listdir(sess_dir)
if f.startswith(TURN_JSON_PREFIX) and f.endswith(".json")
)
for tf in turn_files:
tp = os.path.join(sess_dir, tf)
if not os.path.isfile(tp):
continue
try:
with open(tp, "r", encoding="utf-8") as f:
data = json.load(f)
for msg in data.get("messages", []):
all_msgs.append(self._resolve_abs_paths(msg, task_id))
except (json.JSONDecodeError, OSError):
pass
return all_msgs
def list_turns(self, task_id: str) -> list[dict]:
"""列出该任务所有 turn 的元信息(turn_id + messages 概要)。"""
from agent.utils.agent_utils import TURN_JSON_PREFIX
sess_dir = self._task_dir(task_id)
if not os.path.isdir(sess_dir):
return []
turns: list[dict] = []
turn_files = sorted(
f for f in os.listdir(sess_dir)
if f.startswith(TURN_JSON_PREFIX) and f.endswith(".json")
)
for tf in turn_files:
tp = os.path.join(sess_dir, tf)
if not os.path.isfile(tp):
continue
try:
with open(tp, "r", encoding="utf-8") as f:
data = json.load(f)
turns.append({
"turn_id": data.get("turn_id", ""),
"messages_count": len(data.get("messages", [])),
})
except (json.JSONDecodeError, OSError):
pass
return turns
def get_tool_calls(self, task_id: str, turn_id: str) -> list[dict]:
"""读取指定 turn 的工具调用记录。"""
tool_path = os.path.join(self._task_dir(task_id), f"tool_{turn_id}.json")
if not os.path.isfile(tool_path):
return []
try:
with open(tool_path, "r", encoding="utf-8") as f:
data = json.load(f)
return data.get("tool_calls", []) or []
except (json.JSONDecodeError, OSError):
return []
def _resolve_abs_paths(self, msg: dict, task_id: str) -> dict:
"""附件路径解析委托给模块级 _resolve_msg_paths(JSON/MySQL 共用)。"""
return _resolve_msg_paths(msg, task_id)
# ---------- MySQL 实现(pymysql) ----------
# 连接 / 配置 / 后端选择已抽取至 agent.core.db,此处保留别名供模块内部调用
_mysql_conn = mysql_conn
_get_backend = get_storage_backend
# cron 三表 DDL(由 ensure_tables 幂等建表)
_CRON_TABLE_DDLS = [
"""
CREATE TABLE IF NOT EXISTS cron_tasks (
task_id VARCHAR(64) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
prompt TEXT NOT NULL,
trigger_type ENUM('cron','interval','once') NOT NULL,
cron_expr VARCHAR(128) DEFAULT '',
interval_seconds INT DEFAULT 0,
run_at VARCHAR(32) DEFAULT '',
enabled TINYINT(1) DEFAULT 1,
timeout_seconds INT DEFAULT 300,
created_at VARCHAR(32) NOT NULL,
next_run_at VARCHAR(32) DEFAULT '',
last_run_at VARCHAR(32) DEFAULT '',
last_status VARCHAR(16) DEFAULT 'pending',
last_error TEXT,
executions JSON DEFAULT NULL
) CHARACTER SET utf8mb4
""",
"""
CREATE TABLE IF NOT EXISTS cron_messages (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
task_id VARCHAR(64) NOT NULL,
turn_id VARCHAR(64) NOT NULL,
role VARCHAR(16) NOT NULL,
content JSON NOT NULL,
meta JSON DEFAULT NULL,
INDEX idx_task_turn (task_id, turn_id)
) CHARACTER SET utf8mb4
""",
"""
CREATE TABLE IF NOT EXISTS cron_tool_calls (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
task_id VARCHAR(64) NOT NULL,
turn_id VARCHAR(64) NOT NULL,
tool_calls JSON NOT NULL,
INDEX idx_task_turn (task_id, turn_id)
) CHARACTER SET utf8mb4
""",
]
def _ensure_mysql_tables() -> None:
"""幂等建 cron 三表(委托共享 ensure_tables)。"""
ensure_tables(_CRON_TABLE_DDLS)
def _row_to_task(row: dict[str, Any]) -> CronTask:
"""MySQL 行 → CronTask(构造与 to_dict 同构的 dict 再 from_dict)。"""
executions = row.get("executions")
if isinstance(executions, str):
try:
executions = json.loads(executions)
except (json.JSONDecodeError, TypeError):
executions = []
elif isinstance(executions, (bytes, bytearray)):
try:
executions = json.loads(executions.decode("utf-8"))
except Exception:
executions = []
elif not isinstance(executions, list):
executions = []
d = {
"task_id": row.get("task_id", ""),
"name": row.get("name", ""),
"prompt": row.get("prompt", ""),
"trigger": {
"type": row.get("trigger_type", "cron"),
"cron": row.get("cron_expr", "") or "",
"interval_seconds": int(row.get("interval_seconds", 0) or 0),
"run_at": row.get("run_at", "") or "",
},
"enabled": bool(row.get("enabled", 1)),
"timeout_seconds": int(row.get("timeout_seconds", 300) or 300),
"created_at": row.get("created_at", "") or "",
"next_run_at": row.get("next_run_at", "") or "",
"last_run_at": row.get("last_run_at", "") or "",
"last_status": row.get("last_status", "pending") or "pending",
"last_error": row.get("last_error", "") or "",
"executions": executions,
}
return CronTask.from_dict(d)
class MysqlCronTaskRepository:
"""基于 MySQL(pymysql)的任务仓库。
表: cron_tasks。trigger 拆为 trigger_type/cron_expr/interval_seconds/run_at 列,
executions 存为 JSON 列。
"""
def __init__(self) -> None:
_ensure_mysql_tables()
def list_tasks(self) -> list[CronTask]:
conn = _mysql_conn()
try:
with conn.cursor() as cur:
cur.execute("SELECT * FROM cron_tasks ORDER BY created_at DESC")
rows = cur.fetchall() or []
return [_row_to_task(r) for r in rows]
finally:
conn.close()
def get_task(self, task_id: str) -> CronTask | None:
conn = _mysql_conn()
try:
with conn.cursor() as cur:
cur.execute("SELECT * FROM cron_tasks WHERE task_id=%s", (task_id,))
row = cur.fetchone()
return _row_to_task(row) if row else None
finally:
conn.close()
def save_task(self, task: CronTask) -> None:
if not task.task_id:
return
trig = task.trigger
execs_json = json.dumps([e.to_dict() for e in task.executions], ensure_ascii=False)
conn = _mysql_conn()
try:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO cron_tasks
(task_id, name, prompt, trigger_type, cron_expr, interval_seconds, run_at,
enabled, timeout_seconds, created_at, next_run_at, last_run_at,
last_status, last_error, executions)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
ON DUPLICATE KEY UPDATE
name=VALUES(name), prompt=VALUES(prompt), trigger_type=VALUES(trigger_type),
cron_expr=VALUES(cron_expr), interval_seconds=VALUES(interval_seconds),
run_at=VALUES(run_at), enabled=VALUES(enabled),
timeout_seconds=VALUES(timeout_seconds), next_run_at=VALUES(next_run_at),
last_run_at=VALUES(last_run_at), last_status=VALUES(last_status),
last_error=VALUES(last_error), executions=VALUES(executions)
""",
(
task.task_id, task.name, task.prompt, trig.type, trig.cron,
trig.interval_seconds, trig.run_at, int(task.enabled),
task.timeout_seconds, task.created_at, task.next_run_at,
task.last_run_at, task.last_status, task.last_error, execs_json,
),
)
conn.commit()
finally:
conn.close()
def delete_task(self, task_id: str) -> None:
if not task_id:
return
conn = _mysql_conn()
try:
with conn.cursor() as cur:
cur.execute("DELETE FROM cron_tasks WHERE task_id=%s", (task_id,))
cur.execute("DELETE FROM cron_messages WHERE task_id=%s", (task_id,))
cur.execute("DELETE FROM cron_tool_calls WHERE task_id=%s", (task_id,))
conn.commit()
finally:
conn.close()
def update_status(
self,
task_id: str,
status: str,
*,
error: str = "",
last_run_at: str = "",
next_run_at: str = "",
) -> None:
sets: list[str] = ["last_status=%s"]
vals: list[Any] = [status]
if error:
sets.append("last_error=%s")
vals.append(error)
elif status != "failed":
sets.append("last_error=%s")
vals.append("")
if last_run_at:
sets.append("last_run_at=%s")
vals.append(last_run_at)
# next_run_at 显式更新(空串也写,表示不再运行)
sets.append("next_run_at=%s")
vals.append(next_run_at)
vals.append(task_id)
conn = _mysql_conn()
try:
with conn.cursor() as cur:
cur.execute(
f"UPDATE cron_tasks SET {', '.join(sets)} WHERE task_id=%s",
vals,
)
conn.commit()
finally:
conn.close()
def append_execution(self, task_id: str, exec_log: dict[str, Any]) -> None:
conn = _mysql_conn()
try:
with conn.cursor() as cur:
cur.execute("SELECT executions FROM cron_tasks WHERE task_id=%s", (task_id,))
row = cur.fetchone()
execs: list = []
if row and row.get("executions"):
raw = row["executions"]
if isinstance(raw, str):
try:
execs = json.loads(raw)
except (json.JSONDecodeError, TypeError):
execs = []
elif isinstance(raw, list):
execs = raw
execs.append(exec_log)
cur.execute(
"UPDATE cron_tasks SET executions=%s WHERE task_id=%s",
(json.dumps(execs, ensure_ascii=False), task_id),
)
conn.commit()
finally:
conn.close()
class MysqlCronRecordRepository:
"""基于 MySQL(pymysql)的执行记录仓库。
表: cron_messages / cron_tool_calls。
附件路径统一存相对 CRON_ROOT 的 relpath,读取时按本机 CRON_ROOT 解析为绝对地址。
写入由 runner 执行后调用 persist_turn_records 触发(见模块底部)。
"""
def __init__(self) -> None:
_ensure_mysql_tables()
@staticmethod
def _parse_json_col(val: Any) -> Any:
if isinstance(val, str):
try:
return json.loads(val)
except (json.JSONDecodeError, TypeError):
return None
if isinstance(val, (bytes, bytearray)):
try:
return json.loads(val.decode("utf-8"))
except Exception:
return None
return val
def load_messages(self, task_id: str) -> list[dict]:
conn = _mysql_conn()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT turn_id, role, content, meta FROM cron_messages "
"WHERE task_id=%s ORDER BY id",
(task_id,),
)
rows = cur.fetchall() or []
msgs: list[dict] = []
for r in rows:
content = self._parse_json_col(r.get("content")) or ""
meta = self._parse_json_col(r.get("meta"))
msg: dict[str, Any] = {"role": r.get("role", "user"), "content": content}
if meta:
msg["meta"] = meta
msgs.append(_resolve_msg_paths(msg, task_id))
return msgs
finally:
conn.close()
def list_turns(self, task_id: str) -> list[dict]:
conn = _mysql_conn()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT turn_id, COUNT(*) AS cnt FROM cron_messages "
"WHERE task_id=%s GROUP BY turn_id ORDER BY MIN(id)",
(task_id,),
)
rows = cur.fetchall() or []
return [{"turn_id": r.get("turn_id", ""), "messages_count": int(r.get("cnt", 0))} for r in rows]
finally:
conn.close()
def get_tool_calls(self, task_id: str, turn_id: str) -> list[dict]:
conn = _mysql_conn()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT tool_calls FROM cron_tool_calls "
"WHERE task_id=%s AND turn_id=%s ORDER BY id",
(task_id, turn_id),
)
rows = cur.fetchall() or []
out: list[dict] = []
for r in rows:
tc = self._parse_json_col(r.get("tool_calls"))
if isinstance(tc, list):
out.extend(tc)
return out
finally:
conn.close()
def save_turn(self, task_id: str, turn_id: str, messages: list[dict]) -> None:
"""写入一个 turn 的消息(幂等:先删同 turn 旧记录)。"""
conn = _mysql_conn()
try:
with conn.cursor() as cur:
cur.execute(
"DELETE FROM cron_messages WHERE task_id=%s AND turn_id=%s",
(task_id, turn_id),
)
for msg in messages:
content = msg.get("content")
meta = msg.get("meta")
cur.execute(
"INSERT INTO cron_messages (task_id, turn_id, role, content, meta) "
"VALUES (%s,%s,%s,%s,%s)",
(
task_id, turn_id, msg.get("role", "user"),
json.dumps(content, ensure_ascii=False),
json.dumps(meta, ensure_ascii=False) if meta is not None else None,
),
)
conn.commit()
finally:
conn.close()
def save_tool_calls(self, task_id: str, turn_id: str, tool_calls: list[dict]) -> None:
"""写入一个 turn 的工具调用记录(幂等)。"""
conn = _mysql_conn()
try:
with conn.cursor() as cur:
cur.execute(
"DELETE FROM cron_tool_calls WHERE task_id=%s AND turn_id=%s",
(task_id, turn_id),
)
cur.execute(
"INSERT INTO cron_tool_calls (task_id, turn_id, tool_calls) VALUES (%s,%s,%s)",
(task_id, turn_id, json.dumps(tool_calls, ensure_ascii=False)),
)
conn.commit()
finally:
conn.close()
# ---------- 附件路径解析(JSON / MySQL 共用) ----------
def _resolve_msg_paths(msg: dict, task_id: str) -> dict:
"""将消息 content 中的相对附件路径解析为绝对地址(相对 CRON_ROOT)。
runner 写入 / 数据库存储的附件路径均为相对 CRON_ROOT 的 relpath,
此处返回前端/序列化前还原为绝对路径,供文件读取。
"""
content = msg.get("content")
if not isinstance(content, list):
return msg
for piece in content:
if not isinstance(piece, dict):
continue
for key in ("path", "audio_path"):
p = piece.get(key)
if isinstance(p, str) and p and not os.path.isabs(p):
piece[key] = os.path.normpath(os.path.join(CRON_ROOT, p))
iu = piece.get("image_url")
if isinstance(iu, dict):
u = iu.get("url")
if isinstance(u, str) and u and not u.startswith("data:") and not os.path.isabs(u):
iu["url"] = os.path.normpath(os.path.join(CRON_ROOT, u))
return msg
def _to_rel_paths(msg: dict, task_id: str) -> dict:
"""_resolve_msg_paths 的逆操作:绝对路径 → 相对 CRON_ROOT 的 relpath(入库前调用)。"""
import copy
msg = copy.deepcopy(msg)
content = msg.get("content")
if not isinstance(content, list):
return msg
cron_root_abs = os.path.abspath(CRON_ROOT)
for piece in content:
if not isinstance(piece, dict):
continue
for key in ("path", "audio_path"):
p = piece.get(key)
if isinstance(p, str) and p and os.path.isabs(p):
try:
rel = os.path.relpath(p, cron_root_abs)
piece[key] = rel.replace("\\", "/")
except ValueError:
pass
iu = piece.get("image_url")
if isinstance(iu, dict):
u = iu.get("url")
if isinstance(u, str) and u and not u.startswith("data:") and os.path.isabs(u):
try:
rel = os.path.relpath(u, cron_root_abs)
iu["url"] = rel.replace("\\", "/")
except ValueError:
pass
return msg
# ---------- 工厂 ----------
_backend_cache: dict[str, Any] = {}
def get_task_repository() -> CronTaskRepository:
"""获取任务仓库实例(按配置选择 json / mysql)。"""
backend = _get_backend()
if backend == "mysql":
if "task_mysql" not in _backend_cache:
_backend_cache["task_mysql"] = MysqlCronTaskRepository()
return _backend_cache["task_mysql"] # type: ignore[return-value]
if "task_json" not in _backend_cache:
_backend_cache["task_json"] = JsonCronTaskRepository()
return _backend_cache["task_json"] # type: ignore[return-value]
def get_record_repository() -> CronRecordRepository:
"""获取执行记录仓库实例(按配置选择 json / mysql)。"""
backend = _get_backend()
if backend == "mysql":
if "record_mysql" not in _backend_cache:
_backend_cache["record_mysql"] = MysqlCronRecordRepository()
return _backend_cache["record_mysql"] # type: ignore[return-value]
if "record_json" not in _backend_cache:
_backend_cache["record_json"] = JsonCronRecordRepository()
return _backend_cache["record_json"] # type: ignore[return-value]
def new_task_id() -> str:
"""生成基于时间戳的任务 ID(与 session_id 同构风格)。"""
from datetime import datetime
base = datetime.now().strftime("%Y%m%d_%H%M%S")
existing = {t.task_id for t in get_task_repository().list_tasks()}
if base not in existing:
return base
n = 1
while True:
cand = f"{base}_{n}"
if cand not in existing:
return cand
n += 1
def persist_turn_records(task_id: str, turn_id: str) -> None:
"""将 runner 写入的 turn/tool 文件记录导入存储后端。
- JSON 后端:文件即存储,无需操作。
- MySQL 后端:读取 history/cron/{task_id}/turn_*.json 与 tool_*.json,
将附件路径转为相对 CRON_ROOT 的 relpath 后写入 cron_messages / cron_tool_calls。
由 runner.main() 在执行完成后调用。失败仅打印日志,不影响任务状态回传。
"""
if _get_backend() != "mysql" or not task_id or not turn_id:
return
try:
from agent.utils.agent_utils import TURN_JSON_PREFIX
sess_dir = os.path.join(CRON_ROOT, task_id)
# 读取 turn 消息
turn_path = os.path.join(sess_dir, f"{TURN_JSON_PREFIX}{turn_id}.json")
messages: list[dict] = []
if os.path.isfile(turn_path):
with open(turn_path, "r", encoding="utf-8") as f:
data = json.load(f)
messages = data.get("messages", []) or []
# 读取工具调用
tool_path = os.path.join(sess_dir, f"tool_{turn_id}.json")
tool_calls: list[dict] = []
if os.path.isfile(tool_path):
with open(tool_path, "r", encoding="utf-8") as f:
data = json.load(f)
tool_calls = data.get("tool_calls", []) or []
# 附件路径转相对(数据库存 relpath,保证可移植性)
messages = [_to_rel_paths(m, task_id) for m in messages]
repo = get_record_repository()
if hasattr(repo, "save_turn"):
repo.save_turn(task_id, turn_id, messages) # type: ignore[attr-defined]
if hasattr(repo, "save_tool_calls"):
repo.save_tool_calls(task_id, turn_id, tool_calls) # type: ignore[attr-defined]
except Exception as e:
import sys
print(f"[cron.storage] persist_turn_records 失败: {e}", file=sys.stderr)
# ---------- MySQL 表结构参考(已由 _ensure_mysql_tables 幂等建表实现) ----------
"""
CREATE TABLE cron_tasks (
task_id VARCHAR(64) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
prompt TEXT NOT NULL,
trigger_type ENUM('cron','interval','once') NOT NULL,
cron_expr VARCHAR(128) DEFAULT '',
interval_seconds INT DEFAULT 0,
run_at VARCHAR(32) DEFAULT '',
enabled TINYINT(1) DEFAULT 1,
timeout_seconds INT DEFAULT 300,
created_at VARCHAR(32) NOT NULL,
next_run_at VARCHAR(32) DEFAULT '',
last_run_at VARCHAR(32) DEFAULT '',
last_status VARCHAR(16) DEFAULT 'pending',
last_error TEXT,
executions JSON DEFAULT NULL
);
CREATE TABLE cron_messages (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
task_id VARCHAR(64) NOT NULL,
turn_id VARCHAR(64) NOT NULL,
role VARCHAR(16) NOT NULL,
content JSON NOT NULL,
meta JSON DEFAULT NULL,
INDEX idx_task_turn (task_id, turn_id)
);
CREATE TABLE cron_tool_calls (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
task_id VARCHAR(64) NOT NULL,
turn_id VARCHAR(64) NOT NULL,
tool_calls JSON NOT NULL,
INDEX idx_task_turn (task_id, turn_id)
);
-- 附件路径统一存储相对 CRON_ROOT 的 relpath,读取时按本机 CRON_ROOT 拼绝对路径。
"""
+412
View File
@@ -0,0 +1,412 @@
"""普通会话历史的存储后端抽象(MySQL)。
系统定位:
为 web/ui_session、agent_utils、tool_call_recorder 提供会话消息与工具调用记录的
数据库读写入口。与 cron 共用 STORAGE_BACKEND 开关与 MYSQL_* 连接配置
(见 agent.core.db)。
设计要点(不双写:sql 就是 sql,json 就是 json):
- 选 mysql 时:对话消息与工具调用记录**只入库**(不写 turn_*.json / tool_*.json),
读取只走数据库;二进制产物文件(图片/附件等)仍按 json 方式落盘到 session 文件夹。
- 选 json 时:本模块所有操作退化为 no-op,读写仍走文件(现状不变)。
- 消息与工具调用记录**原样**入库(不做路径转换):DB 行为与文件读取完全一致,
下游 _serialize_content / _tool_calls_from_turn_record 无需感知数据来源。
- 写入幂等:save_turn / save_tool_calls 对同一 (session_id, turn_id) 先 DELETE 再 INSERT。
- sessions 表记录会话的 created_at/updated_at/last_turn_id,供列表排序与 get_last_turn_id。
- DB 失败仅打印日志,不阻断主流程(与 cron persist_turn_records 一致)。
可扩展性:
- 未来如需跨机迁移,可在此处增加 relpath 转换(参考 cron/storage._to_rel_paths)。
"""
from __future__ import annotations
import json
import sys
from datetime import datetime
from typing import Any, Protocol
from agent.core.db import ensure_tables, get_storage_backend, mysql_conn
def _now_iso() -> str:
return datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
# ---------- 会话三表 DDL(由 ensure_tables 幂等建表) ----------
_SESSION_TABLE_DDLS = [
"""
CREATE TABLE IF NOT EXISTS sessions (
session_id VARCHAR(64) PRIMARY KEY,
created_at VARCHAR(32) NOT NULL,
updated_at VARCHAR(32) NOT NULL,
last_turn_id VARCHAR(64) DEFAULT ''
) CHARACTER SET utf8mb4
""",
"""
CREATE TABLE IF NOT EXISTS session_messages (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
session_id VARCHAR(64) NOT NULL,
turn_id VARCHAR(64) NOT NULL,
seq INT NOT NULL DEFAULT 0,
message JSON NOT NULL,
INDEX idx_sess_turn (session_id, turn_id)
) CHARACTER SET utf8mb4
""",
"""
CREATE TABLE IF NOT EXISTS session_tool_calls (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
session_id VARCHAR(64) NOT NULL,
turn_id VARCHAR(64) NOT NULL,
tool_calls JSON NOT NULL,
meta JSON DEFAULT NULL,
INDEX idx_sess_turn (session_id, turn_id)
) CHARACTER SET utf8mb4
""",
]
def is_mysql() -> bool:
"""当前会话存储后端是否为 mysql。"""
return get_storage_backend() == "mysql"
# ---------- Protocol ----------
class SessionRecordRepository(Protocol):
"""会话消息与工具调用记录的读写接口(mysql 后端实现)。"""
def save_turn(self, session_id: str, turn_id: str, messages: list[dict]) -> None: ...
def save_tool_calls(
self, session_id: str, turn_id: str, tool_calls: list[dict], meta: dict | None = None
) -> None: ...
def load_messages(self, session_id: str) -> list[dict]: ...
def list_session_ids(self) -> list[str]: ...
def get_turn_record(self, session_id: str, turn_id: str) -> dict[str, Any] | None: ...
def get_latest_turn_record(self, session_id: str) -> dict[str, Any] | None: ...
def get_last_turn_id(self, session_id: str) -> str: ...
def delete_turns_after(self, session_id: str, keep_until_turn_id: str) -> None: ...
def delete_session(self, session_id: str) -> None: ...
# ---------- MySQL 实现 ----------
class MysqlSessionRecordRepository:
"""基于 MySQL(pymysql)的会话记录仓库。
表: sessions / session_messages / session_tool_calls。
消息与工具调用记录原样以 JSON 列存储;meta 列存 started_at/ended_at/duration 等。
"""
def __init__(self) -> None:
ensure_tables(_SESSION_TABLE_DDLS)
def save_turn(self, session_id: str, turn_id: str, messages: list[dict]) -> None:
if not session_id or not turn_id:
return
conn = mysql_conn()
try:
with conn.cursor() as cur:
cur.execute(
"DELETE FROM session_messages WHERE session_id=%s AND turn_id=%s",
(session_id, turn_id),
)
for seq, msg in enumerate(messages or []):
cur.execute(
"INSERT INTO session_messages (session_id, turn_id, seq, message) "
"VALUES (%s,%s,%s,%s)",
(session_id, turn_id, seq, json.dumps(msg, ensure_ascii=False)),
)
now = _now_iso()
cur.execute(
"""
INSERT INTO sessions (session_id, created_at, updated_at, last_turn_id)
VALUES (%s,%s,%s,%s)
ON DUPLICATE KEY UPDATE updated_at=VALUES(updated_at), last_turn_id=VALUES(last_turn_id)
""",
(session_id, now, now, turn_id),
)
conn.commit()
finally:
conn.close()
def save_tool_calls(
self, session_id: str, turn_id: str, tool_calls: list[dict], meta: dict | None = None
) -> None:
if not session_id or not turn_id:
return
meta = meta or {}
conn = mysql_conn()
try:
with conn.cursor() as cur:
cur.execute(
"DELETE FROM session_tool_calls WHERE session_id=%s AND turn_id=%s",
(session_id, turn_id),
)
cur.execute(
"INSERT INTO session_tool_calls (session_id, turn_id, tool_calls, meta) "
"VALUES (%s,%s,%s,%s)",
(
session_id,
turn_id,
json.dumps(tool_calls or [], ensure_ascii=False),
json.dumps(meta, ensure_ascii=False) if meta else None,
),
)
conn.commit()
finally:
conn.close()
def load_messages(self, session_id: str) -> list[dict]:
conn = mysql_conn()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT message FROM session_messages WHERE session_id=%s "
"ORDER BY turn_id ASC, seq ASC",
(session_id,),
)
rows = cur.fetchall() or []
return [self._loads(r["message"]) for r in rows]
finally:
conn.close()
def list_session_ids(self) -> list[str]:
conn = mysql_conn()
try:
with conn.cursor() as cur:
cur.execute("SELECT session_id FROM sessions ORDER BY updated_at DESC")
rows = cur.fetchall() or []
return [r["session_id"] for r in rows]
finally:
conn.close()
def get_turn_record(self, session_id: str, turn_id: str) -> dict[str, Any] | None:
conn = mysql_conn()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT tool_calls, meta FROM session_tool_calls "
"WHERE session_id=%s AND turn_id=%s",
(session_id, turn_id),
)
row = cur.fetchone()
if not row:
return None
return self._row_to_record(row, session_id, turn_id)
finally:
conn.close()
def get_latest_turn_record(self, session_id: str) -> dict[str, Any] | None:
conn = mysql_conn()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT turn_id, tool_calls, meta FROM session_tool_calls "
"WHERE session_id=%s ORDER BY turn_id DESC LIMIT 1",
(session_id,),
)
row = cur.fetchone()
if not row:
return None
return self._row_to_record(row, session_id, row["turn_id"])
finally:
conn.close()
def get_last_turn_id(self, session_id: str) -> str:
conn = mysql_conn()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT last_turn_id FROM sessions WHERE session_id=%s",
(session_id,),
)
row = cur.fetchone()
if row and row.get("last_turn_id"):
return row["last_turn_id"]
# 回退:从消息表取最大 turn_id
cur.execute(
"SELECT MAX(turn_id) AS m FROM session_messages WHERE session_id=%s",
(session_id,),
)
r2 = cur.fetchone()
return (r2 or {}).get("m") or ""
finally:
conn.close()
def delete_turns_after(self, session_id: str, keep_until_turn_id: str) -> None:
if not session_id:
return
conn = mysql_conn()
try:
with conn.cursor() as cur:
cur.execute(
"DELETE FROM session_messages WHERE session_id=%s AND turn_id > %s",
(session_id, keep_until_turn_id),
)
cur.execute(
"DELETE FROM session_tool_calls WHERE session_id=%s AND turn_id > %s",
(session_id, keep_until_turn_id),
)
# 更新 last_turn_id 为剩余最大 turn_id
cur.execute(
"SELECT MAX(turn_id) AS m FROM session_messages WHERE session_id=%s",
(session_id,),
)
r = cur.fetchone()
last = (r or {}).get("m") or ""
cur.execute(
"UPDATE sessions SET last_turn_id=%s, updated_at=%s WHERE session_id=%s",
(last, _now_iso(), session_id),
)
conn.commit()
finally:
conn.close()
def delete_session(self, session_id: str) -> None:
if not session_id:
return
conn = mysql_conn()
try:
with conn.cursor() as cur:
cur.execute("DELETE FROM session_messages WHERE session_id=%s", (session_id,))
cur.execute("DELETE FROM session_tool_calls WHERE session_id=%s", (session_id,))
cur.execute("DELETE FROM sessions WHERE session_id=%s", (session_id,))
conn.commit()
finally:
conn.close()
@staticmethod
def _loads(raw: Any) -> Any:
"""pymysql 对 JSON 列可能返回 dict 或 str,统一反序列化。"""
if isinstance(raw, str):
try:
return json.loads(raw)
except (json.JSONDecodeError, TypeError):
return {}
return raw
@classmethod
def _row_to_record(cls, row: dict[str, Any], session_id: str, turn_id: str) -> dict[str, Any]:
"""把 session_tool_calls 行还原为与 tool_*.json 同构的 record dict。"""
tool_calls = cls._loads(row.get("tool_calls")) or []
meta = cls._loads(row.get("meta")) if row.get("meta") is not None else {}
if not isinstance(meta, dict):
meta = {}
record: dict[str, Any] = {
"session_id": session_id,
"turn_id": turn_id,
"tool_calls": tool_calls,
"timestamp": turn_id,
}
# 合并 meta 中的 started_at/ended_at/duration/aborted 等
for k in ("started_at", "ended_at", "duration", "aborted"):
if k in meta:
record[k] = meta[k]
return record
# ---------- 工厂 ----------
_session_repo_cache: dict[str, Any] = {}
def get_session_record_repository() -> MysqlSessionRecordRepository:
"""获取会话记录仓库实例(仅在 mysql 后端调用,带缓存)。"""
if "session_mysql" not in _session_repo_cache:
_session_repo_cache["session_mysql"] = MysqlSessionRecordRepository()
return _session_repo_cache["session_mysql"] # type: ignore[return-value]
# ---------- 模块级便捷封装(内部自检 backend,json 后端 no-op / 返回 None) ----------
def persist_messages(session_id: str, turn_id: str, messages: list[dict]) -> None:
"""mysql 后端下把一轮消息写入 DB;json 后端 no-op。"""
if not is_mysql() or not session_id or not turn_id:
return
try:
get_session_record_repository().save_turn(session_id, turn_id, messages)
except Exception as e:
print(f"[session_storage] persist_messages 失败: {e}", file=sys.stderr)
def persist_tool_calls(
session_id: str, turn_id: str, tool_calls: list[dict], meta: dict | None = None
) -> None:
"""mysql 后端下把一轮工具调用记录写入 DB;json 后端 no-op。"""
if not is_mysql() or not session_id or not turn_id:
return
try:
get_session_record_repository().save_tool_calls(session_id, turn_id, tool_calls, meta)
except Exception as e:
print(f"[session_storage] persist_tool_calls 失败: {e}", file=sys.stderr)
def load_messages(session_id: str) -> list[dict] | None:
"""mysql 后端返回消息列表;json 后端返回 None(调用方回退文件逻辑)。"""
if not is_mysql():
return None
try:
return get_session_record_repository().load_messages(session_id)
except Exception as e:
print(f"[session_storage] load_messages 失败: {e}", file=sys.stderr)
return None
def list_session_ids() -> list[str] | None:
"""mysql 后端返回会话 ID 列表(updated_at 倒序);json 后端返回 None。"""
if not is_mysql():
return None
try:
return get_session_record_repository().list_session_ids()
except Exception as e:
print(f"[session_storage] list_session_ids 失败: {e}", file=sys.stderr)
return None
def get_turn_record(session_id: str, turn_id: str) -> dict[str, Any] | None:
"""mysql 后端返回 turn 记录(与 tool_*.json 同构);json 后端返回 None。"""
if not is_mysql():
return None
try:
return get_session_record_repository().get_turn_record(session_id, turn_id)
except Exception as e:
print(f"[session_storage] get_turn_record 失败: {e}", file=sys.stderr)
return None
def get_latest_turn_record(session_id: str) -> dict[str, Any] | None:
"""mysql 后端返回最近 turn 记录;json 后端返回 None。"""
if not is_mysql():
return None
try:
return get_session_record_repository().get_latest_turn_record(session_id)
except Exception as e:
print(f"[session_storage] get_latest_turn_record 失败: {e}", file=sys.stderr)
return None
def get_last_turn_id(session_id: str) -> str | None:
"""mysql 后端返回最后 turn_id;json 后端返回 None。"""
if not is_mysql():
return None
try:
return get_session_record_repository().get_last_turn_id(session_id)
except Exception as e:
print(f"[session_storage] get_last_turn_id 失败: {e}", file=sys.stderr)
return None
def delete_turns_after(session_id: str, keep_until_turn_id: str) -> None:
"""mysql 后端下删除 keep_until_turn_id 之后的 turn 记录;json 后端 no-op。"""
if not is_mysql():
return
try:
get_session_record_repository().delete_turns_after(session_id, keep_until_turn_id)
except Exception as e:
print(f"[session_storage] delete_turns_after 失败: {e}", file=sys.stderr)
def delete_session(session_id: str) -> None:
"""mysql 后端下删除会话全部记录;json 后端 no-op。"""
if not is_mysql():
return
try:
get_session_record_repository().delete_session(session_id)
except Exception as e:
print(f"[session_storage] delete_session 失败: {e}", file=sys.stderr)
File diff suppressed because it is too large. Load diff
View File
Whitespace-only changes.
+282
View File
@@ -0,0 +1,282 @@
_BASE_PROMPT = """你是一个具备多模态理解能力的桌面操作助手,能够直接理解用户上传的图片、语音和文件,并通过调用工具完成桌面自动化任务。
【核心行为准则】
- 你的首要职责是理解用户的意图,并高效地完成用户的任务。
- 对于任何用户上传的图片、音频或文件,直接利用你的多模态能力分析并回答,不要为此调用截图等无关工具。
- 只有在信息确实无法从当前对话或上传内容中获取,且必须通过外部工具才能获得时,才调用工具。例如:需要查看当前屏幕状态、需要执行 GUI 操作、需要搜索网络信息等。
- **【强制规则】面对涉及 PPT 制作、代码项目等复杂产出类或系统操作类任务时,你必须先调用 skill_router 工具查询是否有对应的 Skill 操作指南。
如果 skill_router 返回了匹配的 Skill,你必须将其视为最高优先级的执行规范,严格遵循 Skill 中定义的完整工作流,严禁跳过任何步骤,
严禁用你自身的编程经验(如直接写 python-pptx 代码、用终端脚本批量生成等)替代 Skill 规范。Skill 规范的地位高于任何工具描述中的建议,当工具描述与 Skill 规范冲突时,以 Skill 规范为准。**
- 当你调用工具收到截图或返回结果后,必须仔细观察返回内容,基于实际结果继续推进任务,严禁脱离实际结果凭空编造。
- 进行浏览器操作时,慎用刷新工具,避免刷新导致页面状态丢失。
- 对于同一个页面下的GUI操作,必须将连续操作合并为一次 gui_tool 调用(actions 列表传入多个动作),严禁逐个单步调用。仅当页面发生跳转/加载后需要重新观察时,才允许分步调用,注意单次调用最多包含5个动作。
- 如果不确定是否需要工具,优先选择不调用,用已有信息给出最佳答案。
- 对于需要多次操作才能完成的任务,应主动使用 todo_list 工具规划步骤并逐步推进,并在任务完成后将todo_list中的所有项标记为已完成。
- 当任务完成时,向用户总结执行过程和结果。
""".strip()
_WS_TAIL = """\n\n【工作空间配置】
- 工作空间 (WORKSPACE_DIR): {workspace_dir}
- 你产生的代码、脚本、图片、文档等文件,默认保存到工作空间目录下。
- 使用 terminal_execute 工具时,如果不指定 working_dir,默认在工作空间执行命令。
- 使用 doc_tool 创建/修改文件时,文件路径请使用工作空间的绝对路径。
"""
# 当前运行时的工作空间路径(可通过 API 动态修改)
_current_workspace_dir: str | None = None
def get_main_system_prompt(workspace_dir: str | None = None) -> str:
"""动态构建主系统提示词(含工作空间路径)。
从 env_config.json 读取 WORKSPACE_DIR,拼装完整的系统提示词。
可通过 workspace_dir 参数覆盖默认工作空间。加载失败时仅返回基础部分。
输入:
workspace_dir: 可选的工作空间绝对路径,传入时覆盖默认配置。
输出:
完整系统提示词字符串。
"""
try:
from agent.utils.env_utils import get_workspace_dir
ws_dir = workspace_dir or _current_workspace_dir or get_workspace_dir()
ws_info = _WS_TAIL.format(workspace_dir=ws_dir)
except Exception:
ws_info = ""
return _BASE_PROMPT + ws_info
def set_current_workspace(workspace_dir: str | None) -> None:
"""设置当前运行时的工作空间路径。
输入:
workspace_dir: 工作空间绝对路径;为 None 时恢复使用 env_config 默认值。
"""
global _current_workspace_dir
_current_workspace_dir = workspace_dir if workspace_dir else None
def get_cron_system_prompt(workspace_dir: str | None = None) -> str:
"""动态构建 Cron 模式系统提示词(含工作空间路径)。
与 get_main_system_prompt 同构,但使用 CRON_MODE_PROMPT 作为基底。
子进程 runner 与主进程 runtime 均通过此函数获取 cron 提示词,
确保工作空间路径热更新且正确填入 {workspace_dir} 占位符。
"""
try:
from agent.utils.env_utils import get_workspace_dir
ws_dir = workspace_dir or _current_workspace_dir or get_workspace_dir()
return CRON_MODE_PROMPT.format(workspace_dir=ws_dir)
except Exception:
# 占位符未填时退回不含工作空间信息的版本,避免 .format 异常中断执行
return CRON_MODE_PROMPT.replace("{workspace_dir}", "")
SUMMARY_SYSTEM_PROMPT ="""
你是一个名为“Summary Agent”的专业摘要助手,专门负责对用户提供的文本内容进行高度凝练、准确的总结。
## 核心原则
- 客观准确:严格基于原文内容,不添加个人观点、评论或外部知识。
- 抓住要点:提取核心论点、关键数据、重要事件或结论,忽略次要细节、重复和示例。
- 语言简洁:用清晰、简练的语句表达,避免冗长。
- 保持原意:不歪曲原文事实和观点,即使原文存在错误或争议,仅如实概括。
- 结构清晰:根据内容类型选择最合适的结构,如段落式或分点式,确保逻辑流畅。
## 输出格式
- 默认以一段连贯的文字呈现摘要,必要时可使用编号或项目符号。
- 摘要长度:如果用户未指定,则将原文字数压缩至 20%~30% 左右,或直接给出约3~5句话的精简摘要。对于长文,可适当增加长度。
- 开头不需要“摘要:”等标记,直接输出摘要内容。(如用户要求标记,可加上)
- 处理对话记录时,请总结:讨论主题、各方核心立场、达成的共识、存在的分歧及后续行动项。
## 特别行为
- 若内容不足以形成有意义的摘要(如太短或无实际信息),请回复“内容过短,无法生成有效摘要”。
- 若用户要求总结特定方面(如仅总结“原因”或“结论”),请针对性提取,并在摘要中注明聚焦点。
- 当需要保留原文关键术语或专有名词时,可原样引用,并补充简要解释。
- 多语言支持:用与原文相同的语言输出摘要,除非用户指定目标语言。
## 示例行为(模仿语气)
用户:“请总结以下新闻:……(长新闻文本)……”
你输出:“某公司于2026年5月发布了新一代量子芯片,性能较前代提升10倍,预计2027年量产。此举被业界视为量子计算商业化的关键突破,同时引发了对加密安全的讨论。”
现在,请以 Summary Agent 的身份,完成用户的摘要请求。
""".strip()
VISION_SYSTEM_PROMPT = """
你是专业的图像理解助手。你的任务是对用户提供的图片进行准确、全面的描述。
【描述要求】
1. 用客观、准确的中文描述图片内容,包括主要物体、场景、人物动作、界面元素、图表数据和清晰可读的文字。
2. 涉及具体品牌、时间、地点、人物、影视剧名称、知名物品等特定内容时,请直接命名说明。
3. 如果用户提供了多张图片,请分别编号描述(如「图1」「图2」等),便于后续问答引用。
4. 看不清或无法确定的内容请如实说明,不要臆测或编造。
【回答要求】
在完成图片描述后,请认真回答用户的问题,结合图片内容进行分析推理,给出具体、简短、清晰、实用的回复。
""".strip()
DB_SYSTEM_PROMT = """
你是一位敏捷的数据库分析助手,能够高效利用内置功能帮助用户从PostgreSQL数据库中获取答案。
给定一个数据库相关问题,你需要按照以下步骤操作:
1.创建一个语法正确的PostgreSQL查询语句,优先使用SELECT或WITH语句,确保查询只读且安全。
2.使用内置的SQL查询工具执行该查询,并获取结果。
3.将查询结果总结为一个简洁、清晰的回答,突出关键数字和关系,帮助用户理解数据背后的含义。
【你的行动指南】
1. **永远优先使用内置功能获取真实数据**。对于任何与数据库内容相关的问题,严禁编造答案。
2. **智能判断调用顺序**:
- 如果用户问题模糊(例如“帮我看看用户相关的数据”),先展示所有可用表,并询问用户具体需求。
- 如果用户指明了具体表名,可以直接查看表结构,以便构思查询。
- 如果用户直接提供了一条看似合理的SELECT或WITH语句,可以先验证其安全性,若通过则立即执行。
3. **灵活处理查询结果**:当执行查询返回数据后,请将JSON结果归纳为人类友好的描述,突出关键数字和关系。若无数据返回,应如实告知并建议用户调整查询条件。
4. **安全底线不可突破**:无论何时,只允许执行以SELECT或WITH开头的只读查询。若功能返回安全拦截错误,必须向用户解释原因并建议修改。
5. **非数据库问题快速响应**:遇到问候、常识性问题或超出数据库范围的内容时,可以直接友好回应,不必强制调用功能。
请记住,你是一个协作伙伴,目标是让用户以最小的学习成本从数据库中获得有价值的信息。开始工作吧!
""".strip()
PLAN_MODE_PROMPT = """
你是一个具备多模态理解能力的桌面操作助手,运行在 **Plan 模式**下。Plan 模式要求你在执行任务前先进行详细规划。
【Plan 模式核心规则】
- **首要任务**:收到用户请求后,你必须**第一步就调用 todo_list 工具**,将用户的任务拆解为非常细致、可执行的步骤列表。每个步骤应该具体到单个操作(如"打开浏览器"、"搜索XXX"、"截取屏幕"、"分析结果"等)。
- **规划要求**:步骤拆解必须详尽,覆盖从开始到完成的完整流程。不要跳过任何中间步骤。步骤描述要清晰具体,便于后续逐一执行和跟踪。
- **规划完成后**:生成 todolist 后,向用户简要汇报规划结果,然后**立即开始按步骤执行**,不要等待用户确认。
- **执行过程中**:每完成一个步骤,调用 todo_list 标记该步骤为已完成(done_step),让用户实时看到进度。
- **任务完成后**:确认所有步骤已完成,向用户总结执行过程和结果。
【通用行为准则】
- 你的首要职责是理解用户的意图,并高效地完成用户的任务。
- 对于任何用户上传的图片、音频或文件,直接利用你的多模态能力分析并回答,不要为此调用截图、OCR 等无关工具。
- **【强制规则】当你被要求生成 PPT、制作演示文稿、写幻灯片、生成 PDF 报告、撰写 Markdown 文档等涉及文档产出的任务时,你的 todo_list 规划的第一步必须是调用 skill_router 查询 Skill。如果 skill_router 返回了匹配的 Skill,你必须将其视为最高优先级的执行规范,严格遵循 Skill 中定义的完整工作流,严禁跳过任何步骤,严禁用你自身的编程经验(如直接写 python-pptx 代码、用终端脚本批量生成等)替代 Skill 规范。**
- 只有在信息确实无法从当前对话或上传内容中获取,且必须通过外部工具才能获得时,才调用工具。
- 当你调用工具收到截图或返回结果后,必须仔细观察返回内容,基于实际结果继续推进任务,严禁脱离实际结果凭空编造。
- 每次工具调用后,向用户简要汇报当前进度和下一步计划,确保用户始终了解任务状态。
- 如果不确定是否需要工具,优先选择不调用,用已有信息给出最佳答案。
""".strip()
CRON_MODE_PROMPT = """
你是一个具备多模态理解能力的桌面操作助手,运行在 **定时任务(Cron)模式**下。该模式由定时器或用户在定时任务会话中触发,你将无人值守地自主完成预设任务。
【Cron 模式核心规则】
- **自主完成**:这是无人值守场景,你必须独立完成整个任务流程,不依赖用户在线交互。遇到信息缺失时,使用合理默认值继续推进,而非卡住等待。
- **慎用人工干预**:除非绝对必要(如涉及高风险不可逆操作),否则不要调用 human_interaction 工具——定时触发时用户大概率不在场,无法及时响应。
- **任务规划**:复杂任务应先用 todo_list 工具拆解步骤并逐步推进,每完成一步标记完成。
- **时效性判断**:若任务有明确时效(如"每天 9 点发送晨报"),而当前触发时间已严重滞后,应在执行汇报中说明滞后情况;若任务本身已过时效无意义(如"提醒参加 10 点的会议"但已过 10 点),则简要说明已过时效并跳过本次执行。
- **执行汇报**:任务完成后,输出简洁的执行汇报,包含:做了什么、关键结果、是否有异常。汇报会展示在定时任务的运行记录中。
【通用行为准则】
- 你的首要职责是高效完成预设任务。
- 对于图片、音频、文件等,直接利用多模态能力分析,不要为此调用截图等无关工具。
- **【强制规则】面对涉及 PPT 制作、代码项目等复杂产出类或系统操作类任务时,你必须先调用 skill_router 工具查询是否有对应的 Skill 操作指南。如果 skill_router 返回了匹配的 Skill,必须严格遵循其完整工作流,严禁跳过任何步骤,严禁用自身编程经验替代 Skill 规范。**
- 只有在信息确实无法从当前对话或上传内容中获取,且必须通过外部工具才能获得时,才调用工具。
- 调用工具收到返回结果后,必须仔细观察返回内容,基于实际结果继续推进,严禁脱离实际结果凭空编造。
- 对于同一个页面下的 GUI 操作,必须将连续操作合并为一次 gui_tool 调用(actions 列表传入多个动作),严禁逐个单步调用。仅当页面发生跳转/加载后需要重新观察时,才允许分步调用。
- 你可以使用 cron_manager 工具查看、新增、修改、删除定时任务(包括查看任务状态),这样无需用户手动操作即可管理定时任务。
【工作空间配置】
- 工作空间 (WORKSPACE_DIR): {workspace_dir}
- 你产生的代码、脚本、图片、文档等文件,默认保存到工作空间目录下。
- 使用 terminal_execute 工具时,如果不指定 working_dir,默认在工作空间执行命令。
- 使用 doc_tool 创建/修改文件时,文件路径请使用工作空间的绝对路径。
""".strip()
# ---------- 反思 / Plan 首轮注入提示(作为 synthetic HumanMessage 注入到对话上下文)----------
# 这些不是系统提示词,而是在 call_model 节点中动态拼入消息列表的引导文本。
PLAN_FIRST_CALL_PROMPT = """
【系统指令 - Plan 模式】
你现在处于 Plan 模式。请**立即调用 todo_list 工具**,按照用户任务拆解为步骤列表。
规划要求:
- 如果用户任务包含多个子任务,每个子任务都需要被拆解为一个步骤。
- 每一步骤必须具体到GUI操作(如"打开XXX浏览器"、"搜索XXX"、"单击XXX"等),但注意必须是确定的GUI操作,不能是模糊的操作(如"找到XXX按钮"等)。
- **重要:合并同类操作**。同一个页面/界面上的多个连续操作应合并为一条步骤,用"并"连接,而不是拆成多条。例如:
* "点击顶部搜索框,输入'燕三嘤嘤嘤'并点击搜索"(合并了点击、输入、搜索三个操作)
* "在搜索结果中找到燕三嘤嘤嘤并点击头像进入他的主页"(合并了查找和点击)
* "在他的主页点开视频列表里的第一个视频并等待播放"(合并了点击和验证)
* "打开哔哩哔哩网站"(单个操作不需要合并)
* 反面示例(过于细碎,禁止):"1.点击搜索框 2.输入文字 3.点击搜索按钮 4.找到用户 5.点击头像"
- 步骤覆盖从开始到完成的完整流程,不要遗漏任何中间步骤,然后立即开始执行第一步,无需等待用户确认。
- 请注意:如果用户明确分条步骤,每个步骤都需要被拆解为一个步骤(但仍需合并同一页面的连续操作)。
""".strip()
REFLECTION_PROMPT = """
【重要:如果你判断不需要调用任何工具,请直接回复用户,不要输出任何反思内容!】
只有在需要调用工具时,才先进行简短反思(一句话即可),然后调用工具。格式如下:
1. 如果不需要调用工具(任务已完成或无需工具即可回答),直接给出最终回复,不要输出反思。
2. 如果需要调用工具:先用一句话思考上一步结果和下一步计划,然后调用工具。
3. 如果上一轮是 GUI 操作(点击、输入等)但未命中目标或未达预期效果,请根据最新的截图重新估算目标位置,调整坐标或参数后重试。
4. 如果上一轮操作成功,根据分析结果进行下一步操作。
【gui_tool 批量调用规则 - 务必遵守】
如果你计划在同一页面/界面执行多个连续的 GUI 操作(例如:依次点击多个按钮、填写多个表单字段、连续输入多项内容),必须将所有这些操作合并为一次 gui_tool 调用,在 actions 列表中一次性传入所有动作。严禁将同一页面的连续操作拆分多次 gui_tool 调用——这样会导致严重的延迟。仅当需要等页面跳转/加载后才能确定后续操作时,才分步调用。
""".strip()
# ---------- Skill Router Prompt ----------
SKILL_ROUTER_PROMPT = """你是一个 Skill 路由助手。根据用户 query,从下方 Skill 列表中选出最相关的 1~3 个 Skill。
只输出匹配的 Skill name,每个一行;没有匹配的则输出 "NONE"。
不要输出任何解释、说明或额外文字。
输出格式(严格遵守):
skill_name_1
skill_name_2
=== 示例 ===
用户 query: "帮我打开浏览器搜索Python教程"
Skill 列表:
- name: hello_world
description: 一个示例 Skill,用于演示 skill 系统的基本用法。当用户询问如何打招呼或进行简单问候时匹配。
tags: 示例, 入门
- name: web_browser
description: 浏览器自动化操作指南,涵盖打开浏览器、搜索、导航、关闭标签页等操作。
tags: 浏览器, 自动化, 搜索
- name: file_cleanup
description: 系统文件清理指南,帮助用户清理临时文件、回收站、缓存等。
tags: 清理, 系统, 磁盘
输出:
web_browser
=== 当前请求 ===
用户 query: {query}
Skill 列表:
{skills_block}
输出:"""
SKILL_ROUTER_NO_MATCH_MSG = "skill_router: 大模型未匹配到与当前查询相关的 skill。"
SKILL_ROUTER_EMPTY_MSG = "skill_router: 匹配的 skill 内容为空。"
SKILL_ROUTER_NO_QUERY_MSG = "skill_router: 请提供有效的查询内容。"
SKILL_ROUTER_NO_SKILLS_MSG = (
"skill_router: 当前没有安装任何 skill。\n"
"你可以在系统配置 -> Skills 面板中创建新的 skill,"
"或者在 skills 目录下手动创建 skill 文件夹(含 skill.json 和 skill.md)。\n"
"创建后无需重启,热加载即可使用。"
)
# ---------- 子 Agent 默认系统提示词 ----------
DEFAULT_SUB_AGENT_PROMPT = """你是一个专业的任务执行助手,由主 Agent 委派执行特定子任务。
【身份定位】
- 你是多 Agent 体系中的一个**子 Agent**,接收主 Agent 委派的任务并独立完成。
- 你的职责是**专注完成被委派的任务**,不越权处理超出任务范围的事务。
【工作流程】
1. 接收主 Agent 下发的任务描述。
2. 调用可用工具逐步完成任务。
3. 完成后返回清晰的结果摘要。
4. 如果任务复杂,先使用 todo_list 规划步骤再执行。
【关键行为准则】
- **聚焦任务**:只执行被委派的具体任务,不要做额外操作或发散思考。
- **高效执行**:尽量在一次推理链路中完成,减少不必要的确认或反复。
- **如实汇报**:基于实际执行结果输出,不编造信息。遇到错误时说明原因。
- **工具使用**:优先利用内置工具完成任务,但仅在必要时才调用。
- **结果导向**:返回的结果应直接回应主 Agent 的问题,避免冗长铺垫。
【输出格式】
- 直接输出任务执行结果,用简洁、清晰的语言概括关键信息。
- 如果有多个结果,用分点或编号列出。
- 以「[完成]」标记任务执行完毕。
""".strip()
+375
View File
@@ -0,0 +1,375 @@
"""Skill 管理器:负责 skill 的增删改查及附件管理。
每个 skill 是一个文件夹,位于 Skills 目录下,结构如下::
skills/
{skill_name}/
skill.json - 基本信息与描述
skill.md - 主体内容(指南)
attachments/ - 可选附件(.py/.docx/.csv/.xlsx 等)
系统定位:
为 skill_router 工具和前端 API 提供统一的数据读写接口。
可扩展性:
可增加版本校验、导入/导出、标签过滤等功能。
"""
from __future__ import annotations
import json
import os
import shutil
from pathlib import Path
from typing import Any
SKILLS_DIR = Path(__file__).resolve().parent
def _ensure_skills_dir() -> None:
"""确保 skills 目录存在。"""
os.makedirs(SKILLS_DIR, exist_ok=True)
# ---------- Skill 信息数据类 ----------
class SkillInfo:
"""单个 Skill 的基本信息。"""
name: str
description: str
version: str
author: str
tags: list[str]
enable: bool
def __init__(self, name: str = "", description: str = "", version: str = "1.0.0",
author: str = "", tags: list[str] | None = None, enable: bool = True):
self.name = name
self.description = description
self.version = version
self.author = author
self.tags = tags or []
self.enable = enable
def to_dict(self) -> dict:
return {
"name": self.name,
"description": self.description,
"version": self.version,
"author": self.author,
"tags": self.tags,
"enable": self.enable,
}
@classmethod
def from_dict(cls, d: dict, name: str = "") -> "SkillInfo":
return cls(
name=d.get("name", name),
description=d.get("description", ""),
version=d.get("version", "1.0.0"),
author=d.get("author", ""),
tags=d.get("tags", []),
enable=d.get("enable", True),
)
# ---------- CRUD ----------
def list_skills() -> list[dict]:
"""列出所有 skill 的基本信息(不含主体内容)。
输出:
list[dict]: 每项含 name, description, version, author, tags, has_attachments
"""
_ensure_skills_dir()
result = []
for entry in sorted(SKILLS_DIR.iterdir()):
if not entry.is_dir():
continue
json_path = entry / "skill.json"
if not json_path.is_file():
continue
try:
with open(json_path, "r", encoding="utf-8") as f:
meta = json.load(f)
except (json.JSONDecodeError, OSError):
continue
meta["name"] = entry.name
meta.setdefault("description", "")
meta.setdefault("version", "1.0.0")
meta.setdefault("author", "")
meta.setdefault("tags", [])
meta.setdefault("enable", True)
# 检查是否有附件
attach_dir = entry / "attachments"
meta["has_attachments"] = attach_dir.is_dir() and bool(list(attach_dir.iterdir()))
meta["attachment_files"] = _list_attachments(entry.name)
result.append(meta)
return result
def get_skill(name: str) -> dict | None:
"""获取单个 skill 的完整信息(含主体内容和附件列表)。
输出:
dict 或 None,字段: name, description, version, author, tags,
content (skill.md 内容), attachment_files
"""
skill_dir = SKILLS_DIR / name
if not skill_dir.is_dir():
return None
json_path = skill_dir / "skill.json"
md_path = skill_dir / "skill.md"
meta = {}
if json_path.is_file():
try:
with open(json_path, "r", encoding="utf-8") as f:
meta = json.load(f)
except (json.JSONDecodeError, OSError):
pass
content = ""
if md_path.is_file():
try:
with open(md_path, "r", encoding="utf-8") as f:
content = f.read()
except OSError:
pass
return {
"name": name,
"description": meta.get("description", ""),
"version": meta.get("version", "1.0.0"),
"author": meta.get("author", ""),
"tags": meta.get("tags", []),
"enable": meta.get("enable", True),
"content": content,
"attachment_files": _list_attachments(name),
}
def create_or_update_skill(name: str, description: str = "", content: str = "",
version: str = "0.0", author: str = "minor",
tags: list[str] | None = None, enable: bool = True) -> bool:
"""创建或更新一个 skill。
输入:
name: skill 名称(即文件夹名)。
description: 简述。
content: skill.md 的内容。
version: 版本号。
author: 作者。
tags: 标签列表。
enable: 是否启用此 skill,默认 True。
输出:
成功返回 True。
"""
_ensure_skills_dir()
if not name or not name.strip():
return False
name = name.strip().replace(" ", "_")
skill_dir = SKILLS_DIR / name
os.makedirs(skill_dir, exist_ok=True)
# 写 skill.json
meta = {
"name": name,
"description": description,
"version": version,
"author": author,
"tags": tags or [],
"enable": enable,
}
json_path = skill_dir / "skill.json"
try:
with open(json_path, "w", encoding="utf-8") as f:
json.dump(meta, f, ensure_ascii=False, indent=2)
except OSError:
return False
# 写 skill.md
md_path = skill_dir / "skill.md"
try:
with open(md_path, "w", encoding="utf-8") as f:
f.write(content)
except OSError:
return False
return True
def delete_skill(name: str) -> bool:
"""删除一个 skill 及其所有附件。
输出:
存在且删除成功返回 True,不存在返回 False。
"""
skill_dir = SKILLS_DIR / name
if not skill_dir.is_dir():
return False
shutil.rmtree(skill_dir, ignore_errors=True)
return True
# ---------- 附件管理 ----------
def _attachments_dir(skill_name: str) -> Path:
return SKILLS_DIR / skill_name / "attachments"
def _list_attachments(skill_name: str) -> list[str]:
"""列出某 skill 的所有附件文件名。"""
ad = _attachments_dir(skill_name)
if not ad.is_dir():
return []
result = []
for root, dirs, files in os.walk(ad):
for fname in files:
rel = os.path.relpath(os.path.join(root, fname), ad)
result.append(rel.replace("\\", "/"))
result.sort()
return result
def add_attachment(skill_name: str, filename: str, content_bytes: bytes) -> bool:
"""上传/更新一个附件文件到 skill。
输入:
skill_name: skill 名称。
filename: 附件文件名(可包含相对子路径,如 "scripts/run.py")。
content_bytes: 文件二进制内容。
输出:
成功返回 True。
"""
skill_dir = SKILLS_DIR / skill_name
if not skill_dir.is_dir():
return False
ad = _attachments_dir(skill_name)
os.makedirs(ad, exist_ok=True)
# 安全检查:防止路径穿越
safe_name = filename.replace("\\", "/").strip("/")
if ".." in safe_name:
return False
target = ad / safe_name
target.parent.mkdir(parents=True, exist_ok=True)
try:
with open(target, "wb") as f:
f.write(content_bytes)
return True
except OSError:
return False
def delete_attachment(skill_name: str, filename: str) -> bool:
"""删除某 skill 下的一个附件。
输出:
存在且删除成功返回 True。
"""
ad = _attachments_dir(skill_name)
safe_name = filename.replace("\\", "/").strip("/")
if ".." in safe_name:
return False
target = ad / safe_name
if not target.is_file():
return False
try:
os.remove(target)
# 移除空父目录
parent = target.parent
while parent != ad and parent != SKILLS_DIR:
try:
parent.rmdir()
except OSError:
break
parent = parent.parent
return True
except OSError:
return False
def get_attachment_bytes(skill_name: str, filename: str) -> bytes | None:
"""获取附件文件的二进制内容。"""
ad = _attachments_dir(skill_name)
safe_name = filename.replace("\\", "/").strip("/")
if ".." in safe_name:
return None
target = ad / safe_name
if not target.is_file():
return None
try:
with open(target, "rb") as f:
return f.read()
except OSError:
return None
# ---------- 供 skill_router 使用的批量加载 ----------
def load_all_skill_contents() -> dict[str, str]:
"""加载所有 **启用** skill 的 skill.md 内容,键为 skill 名称。
skill.json 中 enable 为 false 的 skill 会被跳过。
输出:
{skill_name: markdown_content, ...}
"""
_ensure_skills_dir()
result = {}
for entry in sorted(SKILLS_DIR.iterdir()):
if not entry.is_dir():
continue
# 检查 skill.json 中的 enable 字段(默认视为启用,兼容旧数据)
json_path = entry / "skill.json"
if json_path.is_file():
try:
with open(json_path, "r", encoding="utf-8") as f:
meta = json.load(f)
if meta.get("enable") is False:
continue
except (json.JSONDecodeError, OSError):
pass
md_path = entry / "skill.md"
if not md_path.is_file():
continue
try:
with open(md_path, "r", encoding="utf-8") as f:
result[entry.name] = f.read()
except OSError:
pass
return result
def load_all_skill_metas() -> dict[str, dict]:
"""加载所有 **启用** skill 的 skill.json 元信息,键为 skill 名称。
skill.json 中 enable 为 false 的 skill 会被跳过。
输出:
{skill_name: {name, description, version, author, tags, enable}, ...}
"""
_ensure_skills_dir()
result = {}
for entry in sorted(SKILLS_DIR.iterdir()):
if not entry.is_dir():
continue
json_path = entry / "skill.json"
if not json_path.is_file():
continue
try:
with open(json_path, "r", encoding="utf-8") as f:
meta = json.load(f)
except (json.JSONDecodeError, OSError):
continue
# 跳过禁用的 skill
if meta.get("enable") is False:
continue
meta["name"] = entry.name
meta.setdefault("description", "")
meta.setdefault("version", "0.0")
meta.setdefault("author", "minor")
meta.setdefault("tags", [])
meta.setdefault("enable", True)
result[entry.name] = meta
return result
@@ -0,0 +1,15 @@
{
"name": "netease_mail_read",
"description": "网易邮箱邮件读取操作指南。当用户任务涉及查看邮件、读取邮件、收邮件、查阅163邮箱等操作时匹配此 Skill。",
"version": "1.0.0",
"author": "minor",
"tags": [
"网易邮箱",
"邮件",
"读取",
"163",
"收信",
"查看"
],
"enable": false
}
@@ -0,0 +1,43 @@
# 网易邮箱邮件读取 Skill
## 适用场景
当用户需要查看/读取邮件(通过网易163邮箱)时,Agent 应调用 skill_router 匹配此 skill,并严格遵循以下操作指南读取邮件。
## 前置准备
- 邮箱账号:19553226590,密码:@Qag73953
## 操作步骤
### 1. 打开网易邮箱并登录
1. 使用浏览器打开 `https://mail.163.com/`
2. 在登录页面输入账号 `19553226590` 和密码 `@Qag73953`
3. 点击登录按钮完成登录
4. 等待页面加载进入邮箱主页
### 2. 点击收信
1. 在邮箱主页左侧或顶部找到"收信"或"收件箱"按钮
2. 点击进入收件箱页面
3. 等待收件箱邮件列表加载完成
### 3. 查看邮件
**查看单封邮件**:
1. 在收件箱邮件列表中,点击目标邮件的标题
2. 邮件详情页面将展开显示完整邮件内容
3. 如果单封邮件内容过长,超出当前屏幕显示范围,按**空格键翻页**继续阅读
**查看多封邮件**:
1. 查看完当前邮件后,点击页面上的"收件箱"或返回按钮,回到收件箱列表页面
2. 在收件箱列表中点击下一封邮件的标题继续查看
3. 重复以上步骤,依次查看所有需要阅读的邮件
## 注意事项
- 登录后请等待页面完全加载再进行操作
- 查看多封邮件时,每看完一封必须先返回收件箱页面,再点击下一封邮件的标题
- 邮件内容过长时使用空格键翻页,不要使用鼠标滚轮(可能定位不准)
- 如果邮件列表有多页,同样按空格键翻页查看更多邮件条目
@@ -0,0 +1,14 @@
{
"name": "netease_mail_send",
"description": "网易邮箱邮件发送操作指南。当用户任务涉及发送邮件、写邮件、通过163邮箱发信等操作时匹配此 Skill。",
"version": "1.0.0",
"author": "minor",
"tags": [
"网易邮箱",
"邮件",
"发送",
"163",
"写信"
],
"enable": false
}
@@ -0,0 +1,50 @@
# 网易邮箱邮件发送 Skill
## 适用场景
当用户需要发送邮件(通过网易163邮箱)时,Agent 应调用 skill_router 匹配此 skill,并严格遵循以下操作指南发送邮件。
## 前置准备
- 浏览器已安装并可正常使用
- 邮箱账号:19553226590,密码:@Qag73953
## 操作步骤
### 1. 打开网易邮箱并登录
1. 使用浏览器打开 `https://mail.163.com/`
2. 在登录页面输入账号 `19553226590` 和密码 `@Qag73953`
3. 点击登录按钮完成登录
4. 等待页面加载进入邮箱主页
### 2. 点击写信
1. 在邮箱主页左侧或顶部找到"写信"按钮
2. 点击"写信"按钮,进入写信页面
### 3. 填写邮件内容
1. **收件人**:在"收件人"输入框中输入收件人邮箱地址
2. **主题**:在"主题"输入框中输入邮件主题
3. **正文内容**:在添加附件下方的大面积空白输入框中输入邮件正文内容
### 4. 添加附件(如有需要)
1. 点击"添加附件"按钮
2. 在弹出的文件选择窗口中,在文件名输入框中粘贴或输入文件的完整路径地址
3. 点击"打开"确认选择文件
4. **等待附件上传完成**:需要确认页面上出现"上传完成"字样后,再进行下一步操作
### 5. 发送邮件
1. 点击页面上的"发送"按钮发送邮件
2. **如果页面上看不到"发送"按钮**:按空格键翻页,在下一页面中查找"发送"按钮,找到后点击发送
3. 确认邮件发送成功(页面提示发送成功)
## 注意事项
- 登录后请等待页面完全加载再进行操作
- 附件上传需等待"上传完成"提示出现后再点击发送,否则附件可能未成功添加
- 如果写信页面的发送按钮不在可见区域,按空格键翻页查找
- 正文输入框位于"添加附件"按钮下方的空白区域,注意区分与主题输入框的位置
@@ -0,0 +1,214 @@
# 01 — 总调度:HTML 幻灯片生成工作流总览
> **角色**:主调度器(Master Orchestrator)
> **读取时机**:任何幻灯片生成任务启动时,**最先读取此文件**
> **职责**:定义完整的 3 步串行流水线、全局执行纪律、角色切换协议。本 Skill 仅输出 HTML,无 SVG、无 PPTX。
---
## ⛔ 最重要规则:用户对齐
**Step 2(Strategist)是唯一的用户交互阻断点。此步骤中的所有决策必须通过 `human_interaction` 工具与用户对齐,Agent 不得自行决策。具体包括 3 个阶段,每个阶段必须独立调用 `human_interaction` 并等待用户确认。**
违反此规则的后果:生成的 PPT 风格、内容、配色可能与用户预期严重偏离,导致返工。
---
## 核心流水线
```
用户请求
│
├── Step 1: 源内容收集 ──────────── 读取/研究用户素材 → 结构化内容大纲
│
├── Step 2: Strategist 策略师阶段 ⛔ BLOCKING(三阶段用户确认)
│ ├── 阶段 a — 方向锚定(受众/场合/主题/页数/视觉风格)
│ │ └── human_interaction(selection) → 展示 3-5 匹配风格
│ ├── 阶段 b — 设计锁定(配色/字体/页面节奏分配)
│ │ └── human_interaction(approval) → 确认色板 + 字体 + 节奏
│ └── 阶段 c — 内容审批(逐页标题 + 要点 + 配图策略)
│ └── human_interaction(approval) → 确认内容大纲
│ └── 产出:design_spec.md + spec_lock.md
│
├── Step 3: Executor 执行师阶段 ──── 生成图表 → 逐页编写独立 HTML
│ ├── 每页生成前重读 spec_lock.md
│ ├── 参考 layouts/ 与 templates/ 模板
│ ├── 图表用 matplotlib 生成 PNG → 内联 base64
│ ├── 每页为独立 HTML 文件(1.html、2.html、...)
│ └── {N}.html
│
└── Step 4: QA & 交付 ──────────── 质量检查 → 图片内联固化 → 交付清单
```
3 个生成步骤**严格串行**,每步的输出是下一步的输入。Step 2 是唯一阻断点,用户确认后 Step 3-4 自动化推进。
---
## 全局执行纪律(12 条铁律)
| # | 规则 | 详细说明 |
|---|------|----------|
| 1 | **串行执行** | 步骤必须按 1→2→3→4 顺序执行,不可跳跃、不可并行。同一步骤内的子任务可并行。 |
| 2 | **BLOCKING = 硬停止** | 标记 ⛔ 的阶段必须等待用户通过 `human_interaction` 显式确认。确认前不可执行任何后续代码或输出。**这是硬性规则,违反即废弃当前生成结果。** |
| 3 | **禁止跨阶段捆绑** | Strategist 的三个阶段(a/b/c)各自独立等待确认,不可在一个 `human_interaction` 调用中打包多个阶段。 |
| 4 | **入口前验证(GATE)** | 每步进入前必须检查前置条件是否满足。条件不满足时向用户说明缺失项并等待。 |
| 5 | **禁止推测执行** | 不要在等待用户确认期间"提前准备"后续步骤的内容、颜色、布局或代码。所有生成动作必须在对应的确认返回后开始。 |
| 6 | **禁止子代理生成页面** | HTML 页面必须由当前主 Agent 端到端完成。不可委托子代理、不可脚本批量生成 HTML 代码。 |
| 7 | **逐页顺序生成** | 页面必须一页一页生成,禁止批量、禁止并行。生成每页前必须先重读 `spec_lock.md`。 |
| 8 | **每页重读 spec_lock** | 每生成一个 HTML 页面之前,必须重新读取 `spec_lock.md` 以获取最新的锁定设计规范。这是防止 Agent 上下文窗口偏移造成风格漂移的关键措施。 |
| 9 | **颜色唯一来源** | HTML 中使用的任何颜色必须来自 `spec_lock.md` 中 `palette` 字段定义的色板。禁止自行引入新颜色、禁止使用浏览器默认色。 |
| 10 | **HTML Only — 无 SVG / 无 PPTX** | 本 Skill 仅输出 HTML 文件。不生成 SVG、不转换 PPTX。如果用户需要 PPTX 格式,向用户说明当前 Skill 版本仅支持 HTML 输出。 |
| 11 | **⛔ 必须使用 human_interaction 工具** | 任何需要用户选择、确认、补充信息的位置,**必须**调用 `human_interaction` 工具(`selection` / `approval` / `information` 三种类型)。**禁止以纯文本回复代替工具调用。禁止使用 `tool_call(confirm)` 或任何其他机制替代。** |
| 12 | **生成模式恒为 html,多文件输出** | `generation_mode` 始终为 `html`。每页输出为独立 HTML 文件(1.html、2.html、...),每个文件自包含可独立打开。画布尺寸由用户在阶段 a 选择(16:9 / 4:3 / A4纵向)。 |
---
## 三步详解
### Step 1 — 源内容收集
**目标**:将用户提供的所有素材整理为结构化的 Markdown 内容大纲。
**处理逻辑**:
- 用户上传了文件(文档/大纲/笔记)→ 阅读理解,提取关键信息,整理为层级化大纲
- 用户仅提供主题、无详细材料 → 基于主题进行知识检索,整理为覆盖核心要点的 Markdown 大纲
- 用户提供了零散想法/要点 → 归类、补充逻辑链条,组织为连贯的大纲
**输出规范**:
- 使用 Markdown 标题层级(`#` / `##` / `###`)表示信息层次
- 每个一级标题对应一个候选页面
- 在要点旁标注推断的页面节奏类型(anchor / dense / breathing)(初步判断,后续策略师阶段会精调)
**GATE**:必须产出至少 3 个以上候选页面的结构化大纲,方可进入 Step 2。
---
### Step 2 — Strategist 策略师阶段 ⛔ BLOCKING — 必须通过 human_interaction 与用户对齐
> **进入前必须先读取 `02-strategist.md` + `04-styles.md`**
这是整个流水线中**唯一需要用户交互的阻断步骤**。Agent **不得跳过、不得自行决策、不得用任何其他方式替代 `human_interaction` 调用**。策略师需要完成三阶段确认,每阶段必须独立调用 `human_interaction` 并等待用户响应,最终产出两份文件:
- `design_spec.md`:人类可读的设计说明书
- `spec_lock.md`:机器可读的 YAML 执行合约
三个阶段各自独立阻塞:
| 阶段 | 目标 | human_interaction 类型 | 关键输入文件 |
|------|------|------------------------|-------------|
| a — 方向锚定 | 确定受众、场合、主题方向、页数范围、视觉风格 | `selection` | `04-styles.md`(从中匹配 3-5 个风格可选) |
| b — 设计锁定 | 确定配色色板(含 hex)、字体配对、页面节奏分配 | `approval` | `04-styles.md` 中选中风格的精确色板 |
| c — 内容审批 | 逐页确认标题、关键要点、配图/图表策略 | `approval`(⛔ 禁止用 selection) | Step 1 产出的内容大纲 |
**每阶段约束**:
- 阶段 a:必须从 `04-styles.md` 风格目录中根据用户需求匹配 **3-5 个候选风格**,每个风格附带精确色板预览(hex 色块),通过 `human_interaction(selection)` 展示。
- 阶段 b:基于阶段 a 用户选定的风格,提取其精确配色、字体规范。输出页面节奏分配表(anchor / dense / breathing),通过 `human_interaction(approval)` 锁定。
- 阶段 c:**⛔ 必须使用 `approval` 类型**。将完整内容大纲(逐页标题 + 要点 + 配图策略)写入 `prompt` 字段。**禁止使用 `selection` 类型、禁止把每页标题放入 `questions` 数组。**
**⛔ 三阶段全部确认完毕后,产出 `design_spec.md` 和 `spec_lock.md`,然后自动进入 Step 3。**
---
### Step 3 — Executor 执行师阶段
> **进入前必须先读取 `07-executor.md` + `06-html-standards.md` + `05-layout-patterns.md`**
> **如有动画需求,还需读取 `08-animation-system.md`**
> **如有图表需求,还需读取图表生成参考(matplotlib)**
**核心流程**:
1. **⛔ 创建项目子目录**:`terminal_execute mkdir workspace/ppt_{topic}`
2. **生成图表**(如有):用 Python + matplotlib 生成图表 PNG,保存至项目子目录
3. **逐页生成 HTML section**:
- 每生成一页前,**必须先重读 `spec_lock.md`**
- 根据 `spec_lock.md` 中该页的 `rhythm` 标签,参考 `05-layout-patterns.md` 选择对应布局
- 将图表 PNG 转为 base64 内联进 HTML
- 根据 `08-animation-system.md` 为页面元素添加 `data-anim` 声明式动效属性
- 每页生成为独立 HTML 文件
4. **生成演讲备注**(可选):每页的备注信息统一写入 `speaker_notes.md`
**硬约束**:
- 禁止子代理生成页面
- 必须逐页顺序生成
- 每页前重读 spec_lock.md
- 所有颜色来自 spec_lock.md palette
- HTML 中禁止 `<script>` 标签(除动效引擎 + 键盘导航)
- ⛔ 禁止 CSS `@keyframes`,动效必须用 data-anim 系统
- 禁止外部 CDN 引用
- 画布尺寸锁定(16:9=1280×720 / 4:3=1024×768),禁止 1920×1080
- 图片引用必须使用绝对路径,禁止相对路径 `./xxx.png`
- 根容器内所有元素 `position:absolute` 显式定位
- 每页输出为独立文件 `{N}.html`(**纯数字,禁止 `1_cover.html` 等**)
---
### Step 4 — QA & 交付
> **进入前必须先读取 `09-quality-checklist.md`**
**质检清单**:
- 所有页面生成完毕(页数与 spec_lock.md 一致)
- ⛔ 画布尺寸校验:每页根 div 宽高是否与 spec_lock.md canvas 完全一致(16:9 检查 1280×720)
- 每页颜色使用来自 palette,无越权颜色
- 无 `<script>` 标签(除动效引擎 + 键盘导航)
- ⛔ 无 CSS `@keyframes` 定义
- 图片引用全部使用绝对路径,无 `./xxx.png` 相对路径
- 无外部 CDN 引用(字体、图标、样式均内联或使用系统安全字体)
- 每页 HTML 文件可在浏览器中独立打开
- 动画属性 data-anim 正确声明,动效不重叠冲突
- 图片已内联为 base64
**交付**:
- 使用 `doc_tool create` 逐页写出 `{N}.html`
- 运行 `python {SKILL_DIR}/attachments/complete_htmls_to_base64.py {workspace_dir}` 将外部图片引用转为 base64,产出 `{N}_complete.html`
- 向用户汇报最终交付:`{N}_complete.html`(所有图片已内联,每页可在浏览器中独立打开预览)
- 提示用户打开 `{N}_complete.html` 即可逐页浏览全部幻灯片
---
## 角色切换协议
每次角色切换时,**必须先完整读取对应角色文档**,不可凭记忆执行。
| 切换到 | 必须先读取 |
|--------|-----------|
| Strategist | `02-strategist.md` |
| 风格匹配(Strategist 阶段 a) | `04-styles.md` |
| 设计系统参考(Strategist 阶段 b) | `03-design-system.md` |
| Executor | `07-executor.md` + `06-html-standards.md` + `05-layout-patterns.md` |
| 动效引擎(Executor) | `08-animation-system.md` |
| QA & 交付 | `09-quality-checklist.md` |
| 图片内联固化 | `attachments/complete_htmls_to_base64.py`(运行即可) |
---
## 入口路由
根据用户意图选择执行路径:
| 用户意图 | 执行路径 |
|----------|----------|
| 从零生成幻灯片(有主题/素材) | 完整 1→2→3→4 流水线 |
| 只有主题无材料 | Step 1 知识检索 → 完整后续 |
| 仅生成设计规范(不产出 HTML) | Step 1-2,止于 design_spec.md |
| 从已有 spec_lock 继续生成 | 跳过 Step 1-2,直接 Step 3-4 |
| 修改已有 HTML 某页 | 用户指定页面编号 → 重读 spec_lock.md → 重新生成该页 |
| 调整配色/风格 | 回到 Step 2 阶段 b 重新锁定设计 |
---
## 附件清单
| 文件 | 角色/职责 | 何时读取 |
|------|-----------|----------|
| `01-workflow.md` | **总调度**(当前文件) | 任务启动时(最先读) |
| `02-strategist.md` | **策略师** — 三阶段确认流程 | Step 2 进入前 |
| `03-design-system.md` | 设计系统 — 配色规则、字体层级、排版规范、卡片系统 | Strategist 阶段 b + Executor |
| `04-styles.md` | **风格目录** — 15+ 预设视觉风格及精确色板 | Strategist 阶段 a |
| `05-layout-patterns.md` | 布局模式 — 各页面类型的 HTML 布局模板与节奏定义 | Executor 开始前 |
| `06-html-standards.md` | HTML 技术约束 — 画布尺寸、CSS 能力边界、禁止项 | Executor 每页生成时 |
| `07-executor.md` | **执行师** — 逐页 HTML 生成指南、动效策略、图表处理 | Executor 进入时 |
| `08-animation-system.md` | 动效系统 — data-anim 声明式动画属性与内置动效引擎 | Executor 生成需要动效时 |
| `09-quality-checklist.md` | 质检清单 — HTML 质量检查项与交付规范 | QA 阶段 |
| `templates/` | 模板库 — 预置 HTML 模板(封面、过渡页、内容页、结束页) | Executor 参考复用 |
| `attachments/complete_htmls_to_base64.py` | 固化脚本 — 将外部图片引用转为 base64 内联 | 交付前最后一步 |
@@ -0,0 +1,841 @@
# 02 — Strategist(策略师):设计规划与三阶段阻断确认
> **角色**:策略师 — 内容分析、风格匹配、设计规划、用户确认
> **读取时机**:Step 2 进入策略阶段前(紧随 `01-workflow.md` 之后)
> **必须同步读取**:`04-styles.md`(风格目录,阶段 a 匹配风格用)、`03-design-system.md`(设计系统,阶段 b 锁定设计用)
> **产出**:`design_spec.md`(人工可读设计说明书) + `spec_lock.md`(机器可读 YAML 执行合约)
---
## ⛔ 核心规则:三阶段必须全部通过 human_interaction 与用户对齐
本文件的全部职责是定义 Agent 如何通过 `human_interaction` 工具与用户对齐。**三个阶段各自独立阻断,每个阶段必须调用 1 次 `human_interaction`(共 3 次),不允许合并、不允许跳过、不允许用纯文本替代。**
| 阶段 | human_interaction 类型 | 对齐内容 | 不可跳过 |
|------|----------------------|----------|----------|
| a — 方向锚定 | `selection` | 受众、场合、画布、页数、叙事风格、视觉风格 | ⛔ |
| b — 设计锁定 | `approval` | 精确色板(hex)、字体配对、页面节奏分配 | ⛔ |
| c — 内容审批 | `approval` | 逐页标题、核心要点、配图策略、图表需求 | ⛔ |
> **违反即废弃**:如果 Agent 跳过任一阶段或未调用 `human_interaction`,生成结果无效,必须从阶段 a 重新开始。
---
## 核心使命
接收 Step 1 产出的结构化内容大纲 → 分析主题特征与受众需求 → 匹配视觉风格 → 三阶段交互确认 → 输出两份锁定文件。
**关键原则**:策略师不生成任何 HTML 代码。它的全部职责是分析、规划、确认、锁定。一旦输出 `spec_lock.md`,策略师使命完成,后续由 Executor 接手。
---
## 三阶段确认流程总览
```
策略师启动
│
├── ⛔ 阶段 a — 方向锚定
│ ├── 读取 04-styles.md,根据主题+受众匹配 3-5 个候选风格
│ ├── human_interaction(selection) — 用户选择风格
│ ├── 确认:画布 16:9(1280×720)、generation_mode=html
│ ├── 确认:受众、场合、核心信息、页数范围、交付目的
│ └── 等待用户确认 → 进入阶段 b
│
├── ⛔ 阶段 b — 设计锁定
│ ├── 基于用户选定的风格,提取精确色板(hex 色值)
│ ├── 确定字体配对(标题字体 + 正文字体 + 字号层级)
│ ├── 为每一页分配节奏标签(anchor / dense / breathing)
│ ├── 应用 60-30-10 配色法则
│ ├── human_interaction(approval) — 用户审批设计系统
│ └── 等待用户审批 → 进入阶段 c
│
└── ⛔ 阶段 c — 内容审批
├── 输出每页:标题 + 核心要点(3-5 条)+ 配图策略
├── 标注图表需求(哪些页需要 matplotlib 生成图表)
├── human_interaction(approval) — 用户审批内容大纲
└── 用户批准后 → 产出 design_spec.md + spec_lock.md → 策略师结束
```
---
## 阶段 a — 方向锚定
### 目标
在进入任何设计细节之前,先锁死 PPT 的"大方向":谁说给谁听、在什么场景下用、想传达什么核心信息、需要多少页、走什么视觉风格。
### 执行步骤
#### 步骤 1:读取并分析
1. 读取 `04-styles.md`(风格目录),了解全部可用风格的:名称、适用场景、核心美学描述、精确色板(含 hex 值)、排版规则、DO/DON'T 约束
2. 回顾 Step 1 产出的内容大纲,提取以下特征:
- **领域**:科技 / 金融 / 教育 / 医疗 / 创意 / 政务 / 通用
- **调性**:严肃权威 / 创新活力 / 温暖亲近 / 数据驱动 / 视觉冲击
- **信息密度**:数据密集型 / 观点密集型 / 叙事驱动型 / 混合型
#### 步骤 2:风格匹配
根据分析结果,从 `04-styles.md` 中筛选 **3-5 个最匹配的候选风格**。匹配逻辑:
| 内容特征 | 推荐风格倾向 |
|----------|-------------|
| 科技 / AI / 数字化 | 暗黑科技、毛玻璃、极简商务、蓝图风格 |
| 金融 / 咨询 / 战略 | 极简商务、编辑杂志风、数据新闻风 |
| 教育 / 培训 / 教程 | 柔和圆润、手绘笔记、黑板风格 |
| 创意 / 品牌 / 路演 | 孟菲斯、杂志拼贴、复古海报、水墨中国风 |
| 政务 / 正式汇报 | 极简商务、编辑杂志风、柔和圆润 |
| 医疗 / 健康 | 柔和圆润、极简商务、水墨中国风 |
**关键要求**:
- 每个候选风格必须附带 **该风格的精确主色 hex 值**,让用户能直观感知色彩
- 每个候选风格必须附带 **一句话适用场景描述**,说明为什么推荐
- 候选风格之间必须 **差异化明显**(如:方案 A 冷峻科技 vs 方案 B 温暖近人 vs 方案 C 创意活泼),不可给出 3 个相似的蓝色商务风
- 对每个候选标注 `(Recommended)` 来标记首推方案
#### 步骤 3:调用 human_interaction
使用 `selection` 类型,将全部确认项打包在一个 `human_interaction` 调用中。
**阶段 a 调用示例**:
```
human_interaction(
interaction_type="selection",
title="PPT 方向锚定 — 阶段 a 确认",
prompt="以下是基于您的内容主题匹配的方向性决策。画布固定为 16:9(1280×720),输出格式为 HTML(支持动效,浏览器预览)。请逐项确认或选择:",
questions=[
{
"question": "目标受众",
"options": [
"高管决策层",
"技术/研发团队",
"客户/合作伙伴",
"学生/培训学员",
"公众/媒体",
"内部团队"
]
},
{
"question": "使用场合",
"options": [
"正式汇报/提案",
"路演/发布会",
"培训/教学/讲座",
"内部会议/周会",
"品牌展示/宣传",
"学术/行业会议"
]
},
{
"question": "核心信息(一句话概括本 PPT 要传达的核心观点)",
"options": []
},
{
"question": "页数范围",
"options": [
"3-5 页(极简速报)",
"6-10 页(标准演示,Recommended)",
"11-16 页(深度报告)",
"17+ 页(完整方案)"
]
},
{
"question": "画布尺寸与比例",
"options": [
"16:9 宽屏(1280×720,Recommended)— 适配主流显示器/投影仪",
"4:3 传统(1024×768)— 适配旧投影仪/打印场景",
"A4 纵向(720×1280)— 适配手机阅读/竖屏展示"
]
},
{
"question": "叙事风格",
"options": [
"金字塔原理 — 结论先行、层层论证(适合商务汇报,Recommended)",
"故事叙述 — 起承转合、情感驱动(适合品牌故事、TED式演讲)",
"SCQA框架 — 情境→冲突→问题→答案(适合咨询方案、问题解决)",
"数据驱动 — 数据讲述、图表主导(适合分析报告、趋势演示)",
"黄金圈法则 — Why→How→What(适合愿景宣讲、创业路演)"
]
},
{
"question": "视觉风格(当前主题:{主题关键词},已从风格目录匹配以下差异化方案)",
"options": [
"风格 A:极简商务 swiss-minimal — 白底+#1A73E8 科技蓝,洁净理性,适合正式汇报/战略提案 (Recommended)",
"风格 B:暗黑科技 dark-tech — 深灰底+#00D4FF 电光青,未来感强,适合科技产品/AI 主题",
"风格 C:柔和圆润 soft-rounded — 浅米底+#FF6B6B 珊瑚粉,亲和温暖,适合培训/内部沟通",
"风格 D:编辑杂志风 editorial — 奶白底+#1A1A1A 墨黑,大留白强排版,适合品牌/创意提案",
"风格 E:水墨中国风 ink-wash — 宣纸底+#2C3E50 墨蓝,东方美学,适合文化/教育/传统行业"
]
}
]
)
```
#### 步骤 4:处理返回结果
- 获取用户对每个 question 的选择/输入
- 将最终确认的方向锚定值记录下来,**不可在后续阶段更改**
- 选定的风格名称即为后续阶段 b 的设计依据
- 选定的画布尺寸将锁定,不可在后续阶段更改
- **固定值(自动锁定,无需用户选择)**:
- 生成模式:`html`
- 输出格式:多文件 `{N}.html`(每页独立 HTML,N 从 1 开始)
---
## 阶段 b — 设计锁定
### 目标
将阶段 a 选定的视觉风格转化为精确的、可执行的设计参数。用户确认后即成为 Executor 的"宪法"——后续所有 HTML 页面的颜色、字体、节奏均不得偏离。
### 执行步骤
#### 步骤 1:提取风格精确色板
从 `04-styles.md` 中读取用户选定风格的完整色板定义,按 **60-30-10 法则** 分配角色:
| 色彩角色 | 占比 | 用途 | palette 字段 |
|----------|------|------|-------------|
| 主色(Primary) | ~60% | 背景面积、大面积色块、页面底色 | `bg` |
| 辅色(Secondary) | ~30% | 卡片背景、区块底色、次要区域 | `secondary` |
| 强调色(Accent) | ~10% | 关键数据、CTA、高亮、标题装饰线 | `accent` |
| 文字主色 | — | 正文、标题 | `text` |
| 弱化文字色 | — | 注释、页码、辅助信息 | `muted` |
**60-30-10 原则解释**:
- 60% 是主导色,定义页面的整体情绪——通常用于背景(`bg`)
- 30% 是辅助色,创造视觉层次——通常用于卡片、区块、次要区域(`secondary`)
- 10% 是强调色,引导视线到最重要的信息——用于关键数字、CTA 按钮、重要标记(`accent`)
- 文字色(`text`、`muted`)不计入 60-30-10 比例,但必须与色板协调
**色板输出格式**(6 色,均为 hex 值):
```
palette:
bg: '#FAFBFC' # 页面背景色(占视觉面积 ~60%)
primary: '#1A73E8' # 主品牌色(30% 面积的基调,卡片/区块/标题底色)
secondary: '#E8F0FE' # 辅色(浅色变体,用于微妙的区块区分)
accent: '#FF6D00' # 强调色(10%,关键元素高亮)
text: '#1F2937' # 正文文字色
muted: '#9CA3AF' # 弱化文字色(注释、页码、次要标签)
```
#### 步骤 2:确定字体配对
基于风格确定标题字体与正文字体的配对。字体必须为 **系统安全字体**(无需外部加载)。
**可选字体清单**:
| 类别 | 中文字体 | 英文字体 | 安全回退 |
|------|---------|---------|---------|
| 无衬线商务 | 微软雅黑 | Segoe UI, Arial, Helvetica | sans-serif |
| 无衬线现代 | 思源黑体 / PingFang SC | Inter, SF Pro Display | sans-serif |
| 衬线正式 | 宋体 / SimSun | Georgia, Times New Roman | serif |
| 等宽技术 | — | Consolas, Monaco, Courier New | monospace |
**字体配对示例**:
| 场景 | 标题字体 | 正文字体 | 适用风格 |
|------|---------|---------|----------|
| 中文商务 | `'Microsoft YaHei', '微软雅黑', sans-serif` / Bold / 42px | `'Microsoft YaHei', '微软雅黑', sans-serif` / Regular / 18px | swiss-minimal, dark-tech |
| 中文现代 | `'PingFang SC', 'Microsoft YaHei', sans-serif` / Bold / 44px | `'PingFang SC', 'Microsoft YaHei', sans-serif` / Regular / 17px | soft-rounded, glassmorphism |
| 中文正式 | `'SimSun', '宋体', 'STSong', serif` / Bold / 40px | `'SimSun', '宋体', 'STSong', serif` / Regular / 16px | editorial, ink-wash |
| 英文为主 | `'Segoe UI', 'Arial', sans-serif` / Bold / 40px | `'Segoe UI', 'Arial', sans-serif` / Regular / 17px | swiss-minimal, dark-tech |
| 中英混排 | `'Microsoft YaHei', 'PingFang SC', sans-serif` / Bold / 42px | `'Segoe UI', 'Arial', 'Microsoft YaHei', sans-serif` / Regular / 17px | 通用 |
**字号层级(基于 1280×720 画布)**:
| 角色 | 字号范围 | 字重 | 用途 |
|------|---------|------|------|
| H1(封面标题) | 48-64px | Bold 700 | 封面主标题 |
| H2(页面标题) | 36-48px | Bold 700 | 各内容页标题 |
| H3(小节标题) | 24-32px | SemiBold 600 | 页面内的分区标题 |
| Body(正文) | 16-20px | Regular 400 | 正文、列表、要点 |
| Caption(注释) | 12-14px | Regular 400 | 数据来源、页码、脚注 |
**字体规范输出格式**:
```
font:
title: "'Microsoft YaHei', 'PingFang SC', sans-serif"
title_weight: 700
title_size: 44
body: "'Microsoft YaHei', 'PingFang SC', sans-serif"
body_weight: 400
body_size: 17
caption_size: 13
```
#### 步骤 3:页面节奏分配
为每一页分配节奏标签。节奏标签决定 Executor 生成该页时的信息密度和留白策略。
**三种节奏类型**:
| 标签 | 含义 | 信息密度 | 适用页面类型 | 留白策略 |
|------|------|----------|-------------|----------|
| `anchor` | 锚点页 | 极低 | 封面、结束页、重大过渡页 | 大面积留白,仅有标题+副标题,视觉冲击力优先 |
| `dense` | 密集页 | 高 | 数据分析、方案详情、多要点罗列 | 紧凑但不拥挤,利用色块/卡片分层承载信息 |
| `breathing` | 呼吸页 | 中等 | 概念阐述、故事叙述、单图+要点 | 适度留白,给信息以呼吸空间,节奏舒缓 |
**节奏分配原则**:
1. 封面(第 1 页)和结束页(最后 1 页)必须为 `anchor`
2. 重要过渡页(如目录/章节分隔)建议为 `anchor`
3. 不超过 2 页连续 `dense`(避免信息疲劳)
4. `breathing` 作为缓冲页,穿插在 `dense` 页之间
5. `dense` 页面适合放图表、数据、多要点
6. 整体节奏应如呼吸起伏:anchor → breathing → dense → breathing → anchor
**节奏分布表示例**(8 页 PPT):
| 页码 | 标题 | 节奏 | 说明 |
|------|------|------|------|
| 1 | 封面 | `anchor` | 强制 anchor |
| 2 | 背景与挑战 | `breathing` | 故事引入,舒缓开局 |
| 3 | 核心方案 | `dense` | 方案要点密集展示 |
| 4 | 数据验证 | `dense` | 数据图表,信息量大 |
| 5 | 案例展示 | `breathing` | 案例+配图,呼吸缓冲 |
| 6 | 实施路线 | `dense` | 时间线/路线图 |
| 7 | 总结 | `breathing` | 提炼要点 |
| 8 | 谢谢 | `anchor` | 强制 anchor |
#### 步骤 4:调用 human_interaction
使用 `approval` 类型,展示完整设计系统供用户审批。
**阶段 b 调用示例**:
```
human_interaction(
interaction_type="approval",
title="设计系统锁定 — 阶段 b 审批",
prompt="基于您选择的「{用户选定的风格名称}」风格,以下是锁定的设计系统。请审批后进入内容大纲确认阶段:\n\n## 配色方案(60-30-10 法则)\n- 背景色(60%):{bg} — 页面主底色\n- 主色(30%):{primary} — 卡片/区块底色\n- 强调色(10%):{accent} — 关键数据/高亮元素\n- 正文文字色:{text}\n- 弱化文字色:{muted}\n\n## 字体方案\n- 标题:{title_font} Bold {title_size}px\n- 正文:{body_font} Regular {body_size}px\n- 注释:{body_font} Regular {caption_size}px\n\n## 页面节奏分配\n{节奏分布表}\n\n## 画布与模式\n- 画布:16:9 / 1280×720\n- 生成模式:HTML(支持 data-anim 动效)\n- 风格:{用户选定的风格名称}",
suggested_response="设计系统没问题,进入内容大纲审批"
)
```
**注意**:`prompt` 中必须包含色板的实际 hex 色块预览(用 markdown 色块或明确标注 hex 值),让用户能想象最终视觉效果。不要只有文字描述。
---
## 阶段 c — 内容审批
### 目标
展示最终的逐页内容方案,让用户在生成 HTML 之前做最后一轮内容层面的审查和调整。
### 执行步骤
#### 步骤 1:组织内容大纲
将 Step 1 产出的大纲与阶段 a/b 确认的设计参数结合,整理为逐页内容方案。每页包含:
1. **页号**:从 1 开始的序号
2. **页面类型**:封面 / 目录 / 内容 / 过渡 / 数据 / 案例 / 总结 / 结束
3. **节奏标签**:`anchor` / `dense` / `breathing`(来自阶段 b 的分配)
4. **页面标题**:精炼、有冲击力的标题(10-20 字为宜)
5. **核心要点**:3-5 条关键信息,每条 15-30 字
6. **配图策略**:该页是否需要图片、图片用途(背景/插图/配图)、推荐尺寸
7. **图表需求**(可选):如需数据可视化,标注图表类型(柱状图/折线图/饼图/流程图/雷达图)
#### 步骤 2:配图策略规范 — 【强制使用用户图片】
**核心规则:PPT 中的所有配图必须来自用户提供的图片文件,Agent 不自行生成、不搜索网络图片、不使用占位图。**
在此阶段需要明确:
- **检查用户是否已上传图片**:回顾对话中用户消息是否包含 `[用户上传了图片,文件路径: xxx,尺寸: W×Hpx]` 格式的提示
- **如果用户已上传图片**:根据提示中的**尺寸信息**自动分析每张图片,将其分配到合适的页面。分类规则:
- 宽度 ≥ 1280 且高度 ≥ 720 → 适合作为 `bg` 全幅背景图
- 宽度 ≥ 640 → 适合作为 `hero` 主视觉或 `card` 配图
- 宽度 < 640 → 适合作为 `icon` 或 `portrait`
- **如果用户未上传图片**:在内容大纲的配图策略中标注 `image_strategy`,并在阶段 c 审批时提醒用户补充图片
- **每张图片的用途**:
- `bg`:全幅背景图(尺寸与画布一致)
- `hero`:封面/过渡页主视觉图(推荐画布宽度的 50%)
- `card`:内容页卡片配图(推荐 400×280)
- `icon`:小图标/Logo(推荐 128×128)
- `portrait`:人物头像(推荐 200×200)
- **图片引用方式**:在 HTML 中使用**绝对路径**引用用户上传的图片(如 `C:/Users/.../xxx.png`),**禁止相对路径**(`./xxx.png`)。交付前运行 `attachments/complete_htmls_to_base64.py` 将外部引用转为 base64 内联,输出 `_complete.html`
- **图片缩放规则**:用户上传的图片尺寸已在消息中提供(如 `[用户上传了图片,文件路径: xxx.png,尺寸: W×Hpx]`)。在 HTML 中使用时:
- 计算缩放比例:`max_w = 画布宽度 - 内边距`,`max_h = 画布高度 - 内边距`
- 保持宽高比:`scale = min(max_w / img_w, max_h / img_h)`,最终 `width = img_w * scale`,`height = img_h * scale`
- 大图(> 画布尺寸)→ 等比缩小适配;小图 → 保持原尺寸,不强行拉伸
#### 步骤 2a:图片确认(阶段 c 审批时)
在阶段 c 审批的内容大纲中,必须明确标注:
1. **已匹配的用户图片**:列出 "第 X 页 → 用户图片 `xxx.jpg`(用途:bg/hero/card)"
2. **尚未提供的图片**:列出 "第 Y 页需要配图(用途:card,推荐 400×280),请补充上传"
3. 在 `spec_lock.md` 的每页定义中记录 `user_image` 字段,Executor 据此引用
#### 步骤 3:图表需求规范
扫描内容大纲,主动识别需要数据可视化的页面。**不等待用户指定**,策略师应主动推荐:
| 数据场景 | 推荐图表类型 |
|----------|-------------|
| 数值对比(多组) | 柱状图 / 分组柱状图 |
| 趋势变化 | 折线图 / 面积图 |
| 占比分布 | 饼图 / 环形图 / 堆叠柱状图 |
| 多维评估 | 雷达图 / 热力图 |
| 流程步骤 | 横向流程图(CSS 实现更佳) |
| 时间线 | 纵向时间轴(CSS 实现更佳) |
| 层级关系 | 树状图 / 组织结构图 |
**图表生成说明**:图表将在 Executor 阶段由 Agent 通过 Python + matplotlib 自动生成 PNG,然后内联 base64 到 HTML 中。图表配色必须与 `spec_lock.md` 的 palette 一致。
#### 步骤 4:调用 human_interaction
> **⛔ 阶段 c 必须使用 `approval` 类型。禁止使用 `selection` 类型、禁止把每页标题放入 `questions` 数组、禁止把大纲条目序列化为 JSON 字符串。**
使用 `approval` 类型,**将完整内容大纲写入 `prompt` 字段**。
**阶段 c 调用示例**:
```
human_interaction(
interaction_type="approval",
title="内容大纲审批 — 阶段 c 最终确认",
prompt="以下是完整的逐页内容方案,请审批。批准后将立即生成 design_spec.md 和 spec_lock.md,并自动进入 HTML 生成阶段。\n\n## 风格:{风格名称}\n## 画布:16:9(1280×720)\n## 总页数:{N} 页\n\n### 第 1 页 · 封面「{封面标题}」(anchor)\n- 主标题:{封面大标题}\n- 副标题:{副标题/日期/作者}\n- 配图:背景图 1280×720(需用户提供)\n\n### 第 2 页 · 背景「{页面标题}」(breathing)\n- 要点 1:{核心信息}\n- 要点 2:{核心信息}\n- 要点 3:{核心信息}\n- 配图:无,纯文字+装饰元素\n\n### 第 3 页 · 数据概览「{页面标题}」(dense)\n- 要点 1:{核心信息}\n- 要点 2:{核心信息}\n- 📊 图表:分组柱状图(matplotlib 生成)— 展示 Q1-Q4 营收对比\n- 配图:无\n\n...(逐页列出)\n\n### 第 {N} 页 · 谢谢 (anchor)\n- 主文案:感谢聆听\n- 配图:无,纯文字+风格装饰\n\n---\n## 📋 用户需提供的图片清单\n- 第 1 页背景图:1280×720(封面全幅背景)\n- 第 5 页配图:400×280(案例卡片配图)\n\n## 📊 需自动生成的图表\n- 第 3 页:分组柱状图(matplotlib)\n- 第 6 页:折线图(matplotlib)",
suggested_response="内容无误,开始生成 HTML"
)
```
### ⛔ 阶段 c 生成模板(直接填充即可)
> **Agent 在生成阶段 c 的 `human_interaction` 调用时,必须严格使用以下模板。直接复制模板、替换 `{...}` 占位符为实际内容,禁止改动结构。**
```
human_interaction(
interaction_type="approval",
title="内容大纲审批 — 阶段 c 最终确认",
prompt="以下是完整的逐页内容方案,请审批。批准后将立即进入 HTML 生成阶段。
## 风格:{用户选定的风格名称}
## 画布:{宽}×{高}({画布比例})
## 配色:{bg} / {primary} / {accent}
## 总页数:{N} 页
---
### 第 1 页 · 封面「{封面主标题}」
**类型**:cover | **节奏**:anchor
- 主标题:{封面大标题,12-20字}
- 副标题:{副标题/日期/作者信息}
- 配图策略:{bg 全幅背景 / hero 主视觉 / none}
{若用户已上传图片,标注}→ 使用用户图片:{文件名}({W}×{H}px,用途:{bg/hero})
### 第 2 页 · 「{页面标题}」
**类型**:{content / data / case / transition} | **节奏**:{breathing / dense}
- {核心要点 1}(15-30字)
- {核心要点 2}
- {核心要点 3}
- 配图策略:{card / hero / none}
{若用户已上传图片,标注}→ 使用用户图片:{文件名}({W}×{H}px,用途:{card/hero})
### 第 3 页 · 「{页面标题}」
**类型**:{类型} | **节奏**:{节奏}
- {核心要点 1}
- {核心要点 2}
- {核心要点 3}
{若有图表}→ 📊 图表:{图表类型}(matplotlib 生成) — {数据描述}
- 配图策略:{策略}
... {逐页列出所有页面,格式同上}
### 第 {N} 页 · 结束「谢谢观看」
**类型**:end | **节奏**:anchor
- 主文案:{谢谢观看 / 感谢聆听 / Q&A}
- 配图策略:none(纯文字 + 风格装饰元素)
---
## 📋 用户需提供的图片清单
{若用户已上传图片则写"用户已提供以下图片:"并逐张列出用途分配;若无则写需求列表}
- 第 X 页:{推荐尺寸}({用途说明})
- ...
## 📊 需自动生成的图表
{若内容需要图表则列出;无需图表则写"本次 PPT 无需图表"}
- 第 X 页:{图表类型} — {数据内容简述}
- ...",
suggested_response="内容无误,开始生成 HTML"
)
```
### ⛔ 模板使用铁律
> **以下规则违反任何一条,生成结果即无效。**
| # | 规则 | 错误示例 | 正确做法 |
|---|------|---------|---------|
| 1 | **必须使用 `approval` 类型** | `interaction_type="selection"` | `interaction_type="approval"` |
| 2 | **禁止使用 `questions` 参数** | `questions=[{"question": "第1页标题是?", ...}]` | 不传 `questions`,所有内容写入 `prompt` |
| 3 | **全量大纲在一个 prompt 中** | 分 3 次 human_interaction 逐段确认 | 一次调用包含所有页面 |
| 4 | **prompt 必须是纯 Markdown 文本** | 把大纲序列化为 JSON 字符串嵌入 | 用 Markdown 标题、列表组织内容 |
| 5 | **每页至少列出 3 条要点** | 只写标题不写要点 | 标题 + 3-5 条要点 + 配图策略 |
| 6 | **必须标注已匹配的用户图片** | 有用户图片但不标注用途 | "使用用户图片:xxx.png(1920×1080,用途:bg)" |
| 7 | **图表需求必须写在对应页** | 图表需求只在末尾总结列出 | 在对应页下写 "📊 图表:..." |
| 8 | **禁止阶段 c 使用 `selection`** | 用 selection 让用户逐页勾选 | 用 `approval`,prompt 中一次性展示全部 |
| 9 | **`suggested_response` 必须填写** | 不填或留空 | 填 "内容无误,开始生成 HTML" |
| 10 | **图片按尺寸自动分配** | 不读用户图片尺寸信息 | 根据实际 W×H 判断用途(bg / hero / card / icon) |
---
## 输出规范
用户通过阶段 c 审批后,立即产出以下两份文件。两份文件必须同时生成,内容一致、互为映照。
---
### 文件 1:`design_spec.md`(人工可读设计说明书)
**存放路径**:`workspace/ppt_{topic}/design_spec.md`
**必须包含以下 10 个章节**:
```markdown
# 设计说明书 — {PPT 主题}
## 1. 项目信息
- 项目名称:{PPT 主题}
- 创建日期:{YYYY-MM-DD}
- Skill 版本:v3.0(HTML-Only)
- 生成模式:html
## 2. 画布与交付
- 画布尺寸:{width}×{height}({用户选择的比例})
- 输出格式:多文件 `{N}.html`(每页独立 HTML,可单独在浏览器中打开)
- 动效支持:data-anim 声明式动效系统
## 3. 方向锚定(阶段 a 确认结果)
- 目标受众:{用户选择}
- 使用场合:{用户选择}
- 核心信息:{用户输入的一句话核心观点}
- 页数范围:{用户选择}
- 画布比例:{用户选择}
- 叙事风格:{用户选择}
- 视觉风格:{风格名称}(来自 04-styles.md)
- 风格简述:{该风格的核心美学描述,1-2 句话}
## 4. 配色方案(60-30-10 法则)
| 角色 | 色值 | 占比/用途 |
|------|------|-----------|
| 背景色(bg) | `{hex}` | 页面主底色,~60% 视觉面积 |
| 主色(primary) | `{hex}` | 卡片/区块底色,~30% |
| 辅色(secondary) | `{hex}` | 微妙区块区分 |
| 强调色(accent) | `{hex}` | 关键元素高亮,~10% |
| 文字色(text) | `{hex}` | 正文文字 |
| 弱化色(muted) | `{hex}` | 注释/页码/次要标签 |
## 5. 字体方案
| 角色 | 字体 | 字重 | 字号 |
|------|------|------|------|
| 封面标题 | `{font}` | Bold 700 | {size}px |
| 页面标题(H2) | `{font}` | Bold 700 | {size}px |
| 小节标题(H3) | `{font}` | SemiBold 600 | {size}px |
| 正文(Body) | `{font}` | Regular 400 | {size}px |
| 注释(Caption) | `{font}` | Regular 400 | {size}px |
## 6. 页面节奏分配
| 页码 | 标题 | 页面类型 | 节奏 | 说明 |
|------|------|----------|------|------|
| 1 | {标题} | 封面 | anchor | 大面积留白,主标题居中 |
| 2 | {标题} | 内容 | breathing | ... |
| ... | ... | ... | ... | ... |
## 7. 内容大纲
(逐页详细:标题、核心要点 3-5 条、配图策略、图表需求)
## 8. 图片需求清单(用户提供)
| 序号 | 所在页 | 用途 | 推荐尺寸 | 说明 |
|------|--------|------|----------|------|
| 1 | 第 1 页 | bg(封面背景) | 1280×720 | 尽量高清、与主题相关 |
| ... | ... | ... | ... | ... |
## 9. 图表需求清单(Agent 自动生成)
| 序号 | 所在页 | 图表类型 | 数据内容 | 配色约束 |
|------|--------|----------|----------|----------|
| 1 | 第 3 页 | 分组柱状图 | Q1-Q4 营收对比 | 使用 palette 中的 primary + accent |
| ... | ... | ... | ... | ... |
## 10. 技术约束
- 禁止 `<script>` 标签(除动效引擎 + 键盘导航)
- ⛔ 禁止 CSS `@keyframes`,动效必须用 data-anim 系统
- 禁止外部 CDN 引用
- 所有颜色来自本设计说明书 palette
- 画布尺寸锁定(16:9=1280×720 / 4:3=1024×768),禁止 1920×1080
- 图片引用必须使用绝对路径,禁止相对路径 `./xxx.png`
- 根容器内所有元素 `position:absolute` 显式定位
- 动效通过 data-anim 声明式属性驱动
- 图片内联 base64(交付前运行固化脚本)
- 遵循 06-html-standards.md 全部约束
```
---
### 文件 2:`spec_lock.md`(机器可读 YAML 执行合约)
**存放路径**:`workspace/ppt_{topic}/spec_lock.md`
**这是 Executor 生成 HTML 时的唯一数据源。Executor 每页生成前必须重读此文件。**
```yaml
# spec_lock.md — 机器可读执行合约
# 此文件由 Strategist 在阶段 c 审批通过后生成。
# Executor 必须在每页生成前重新读取此文件。
# 禁止手动编辑此文件——任何设计变更应回到 Strategist 阶段重新确认。
meta:
project_name: "{PPT 主题}"
created_at: "{YYYY-MM-DD}"
skill_version: "3.0.0"
spec_version: "1.0"
canvas:
width: {由用户阶段a选择} # 1280 或 1024 或 720
height: {由用户阶段a选择} # 720 或 768 或 1280
aspect_ratio: "{16:9 | 4:3 | 9:16}" # 用户选择的画布比例
generation_mode: "html"
palette:
bg: "{hex}" # 页面背景色,60% 视觉面积
primary: "{hex}" # 主色,卡片/区块
secondary: "{hex}" # 辅色,微妙区分
accent: "{hex}" # 强调色,10% 关键高亮
text: "{hex}" # 正文文字
muted: "{hex}" # 弱化文字
font:
title: "{font_stack}" # 标题字体 CSS font-family 值
title_weight: 700
title_size_px: {number}
body: "{font_stack}" # 正文字体 CSS font-family 值
body_weight: 400
body_size_px: {number}
caption_size_px: {number}
style:
name: "{风格名称}" # 来自 04-styles.md 的风格 ID
category: "{风格类目}" # 企业/产品 | 编辑/出版 | 表现力/印刷 | 手绘/画笔 | 特殊
narrative_style: "{用户阶段a选择的叙事风格}" # 金字塔原理 | 故事叙述 | SCQA框架 | 数据驱动 | 黄金圈法则
pages:
- num: 1
type: "cover"
rhythm: "anchor"
title: "{页面标题}"
subtitle: "{副标题(可选)}"
key_points:
- "{核心要点 1(封面通常无要点,可为副标题或日期)}"
image_strategy: "{bg | hero | card | icon | none}"
user_image: "" # 用户提供图片的文件名,如 "cover_bg.jpg";无图片则为空
chart: false
- num: 2
type: "content"
rhythm: "breathing"
title: "{页面标题}"
key_points:
- "{核心要点 1}"
- "{核心要点 2}"
- "{核心要点 3}"
image_strategy: "none"
chart: false
- num: 3
type: "data"
rhythm: "dense"
title: "{页面标题}"
key_points:
- "{核心要点 1}"
- "{核心要点 2}"
image_strategy: "none"
chart:
type: "bar" # bar | line | pie | radar | scatter
title: "{图表标题}"
data_description: "{数据描述——Executor 据此构造 matplotlib 数据}"
color_palette: ["primary", "accent", "secondary"] # 引用 palette 中的角色名
# ... 更多页面 ...
- num: {N}
type: "end"
rhythm: "anchor"
title: "感谢聆听"
key_points: []
image_strategy: "none"
chart: false
```
### spec_lock.md 字段规范说明
#### pages 数组每项字段
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `num` | int | ✅ | 页码,从 1 开始递增 |
| `type` | string | ✅ | 页面类型:`cover` / `toc` / `content` / `data` / `case` / `transition` / `summary` / `end` |
| `rhythm` | string | ✅ | 节奏标签:`anchor` / `dense` / `breathing` |
| `title` | string | ✅ | 页面标题(精炼,10-20 字) |
| `subtitle` | string | ❌ | 副标题(仅 cover/transition 类型可能使用) |
| `key_points` | string[] | ✅ | 核心要点数组,3-5 条;cover/end 可空 |
| `image_strategy` | string | ✅ | 配图策略:`bg` / `hero` / `card` / `icon` / `portrait` / `none`。若为 `none` 以外的值,需用户在内容审批阶段确认是否提供图片 |
| `user_image` | string | ❌ | 用户实际提供的图片文件名(如 `cover_bg.jpg`),存放在 `workspace/ppt_{topic}/images/` 目录。无图片时为空字符串 |
| `chart` | object / false | ✅ | 图表定义对象或无图表时填 `false` |
#### chart 对象字段(chart 不为 false 时)
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `type` | string | ✅ | 图表类型:`bar` / `line` / `pie` / `radar` / `scatter` |
| `title` | string | ✅ | 图表标题 |
| `data_description` | string | ✅ | 自然语言数据描述,Executor 据此构造 matplotlib 数据(如:"2023-2026 年各产品线营收:产品A 从 120 增长到 280,产品B 从 80 增长到 190,产品C 从 60 增长到 140") |
| `color_palette` | string[] | ✅ | 引用 palette 中的角色名(如 `["primary", "accent", "secondary"]`),Executor 据此取色 |
---
## human_interaction 工具通用调用规范
> **⛔ 这是 Strategist 阶段最重要的参考章节。Agent 必须严格遵循以下规范调用 `human_interaction` 工具。**
### 三种类型与适用场景
| 类型 | 核心参数 | 适用场景 | 何时用 |
|------|----------|----------|--------|
| `selection` | `title`, `prompt`, `questions`(含 `options` 数组) | 用户在多个候选方案中做选择 | 阶段 a(方向锚定——风格、受众、场合、页数等有多选性质的问题) |
| `approval` | `title`, `prompt`, `suggested_response` | 用户审批/确认后继续推进 | 阶段 b(设计锁定审批)、阶段 c(内容大纲审批) |
| `information` | `title`, `prompt`, `suggested_response` | 需要用户补充自由文本信息 | 用户未提供足够信息时请求补充(如核心信息、主题描述等) |
### 通用约束
1. **每阶段只调用 1 次 human_interaction**:将该阶段所有待确认项打包在一个调用中,禁止拆分多次调用。
2. **必须提供候选值**:`selection` 类型的每个 question 必须提供 `options` 数组(至少 2 个选项),`information` 类型可以留空 options。
3. **必须标注推荐值**:在有推荐值时标注 `(Recommended)`,帮助用户快速决策。
4. **必须等待返回**:调用 `human_interaction` 后,**必须阻塞等待**工具返回结果,不可在返回前执行任何后续代码、读取文件或生成内容。
5. **用户选择即真理**:后续阶段必须基于用户的实际选择(而非 Agent 的原始推荐)进行推导。如阶段 b 必须基于阶段 a 用户选定的风格提取色板,而非 Agent 首推风格。
6. **options 值必须具体**:不要写"待定"、"用户自定义"、"其他"等模糊选项。所有选项必须是具体、可执行的选择。
7. **prompt 必须包含足够上下文**:让用户在不读任何其他文件的情况下理解每个选项的含义。关键信息(如色板 hex 值、风格简述)需要在 prompt 中直接展示。
8. **⛔ 禁止用其他方式替代 human_interaction**:不得使用 `tool_call(confirm)`、纯文本"请确认"、`ask_user` 或任何其他非 `human_interaction` 的机制来获取用户反馈。
---
## 附录:三阶段 human_interaction 快速参考
> **Agent 在执行 PPT 策略师阶段时,直接复制对应阶段的模板,替换 `{...}` 为实际内容即可。**
---
### 阶段 a — 方向锚定(selection)
```
human_interaction(
interaction_type="selection",
title="PPT 方向确认",
prompt="请确认以下方向性决策(推荐项已标注 Recommended):",
questions=[
{"question": "目标受众", "options": ["高管决策层", "技术/研发团队", "客户/合作伙伴", "内部团队"]},
{"question": "使用场合", "options": ["正式汇报/提案", "路演/发布会", "培训/教学", "内部会议"]},
{"question": "页数范围", "options": ["3-5页 极简", "6-10页 标准 (Recommended)", "11-16页 深度"]},
{"question": "画布尺寸", "options": ["16:9 宽屏 1280×720 (Recommended)", "4:3 传统 1024×768", "A4纵向 720×1280"]},
{"question": "视觉风格(主题:{主题关键词})", "options": [
"{风格A} — {hex主色} {一句话描述} (Recommended)",
"{风格B} — {hex主色} {一句话描述}",
"{风格C} — {hex主色} {一句话描述}"
]}
]
)
```
**规则**:每个 question 必有 options 数组。风格选项从 04-styles.md 匹配 3-5 个,附带主色 hex 值。
---
### 阶段 b — 设计锁定(approval)
```
human_interaction(
interaction_type="approval",
title="设计系统确认",
prompt="基于您选择的「{风格名称}」风格,设计系统如下:
## 配色(60-30-10)
- 背景(60%): {bg_hex} ■
- 主色(30%): {primary_hex} ■
- 强调(10%): {accent_hex} ■
- 正文: {text_hex} / 弱化: {muted_hex}
## 字体
- 标题: {title_font} Bold {title_size}px
- 正文: {body_font} Regular {body_size}px
## 页面节奏
| 页码 | 标题 | 节奏 |
|------|------|------|
| 1 | {封面标题} | anchor |
| 2 | {标题} | {breathing/dense} |
| ... | ... | ... |
| {N} | 谢谢观看 | anchor |
确认后立即锁定,后续生成不可更改。",
suggested_response="确认,开始内容大纲"
)
```
**规则**:色板 hex 值必须从 04-styles.md 提取,用 ■ 色块或 hex 值直接展示。不可仅文字描述。
---
### 阶段 c — 内容审批(approval)⛔
```
human_interaction(
interaction_type="approval",
title="内容大纲确认",
prompt="以下是逐页内容方案,确认后立即生成 HTML:
## 风格:{风格名称} | 画布:{W}×{H} | 共 {N} 页
### 第 1 页 · 封面「{主标题}」
**cover | anchor**
- 主标题:{12-20字}
- 副标题:{信息}
- 配图:{bg / hero / none} {若用户已传图}→ 用户图片 {文件名}({W}×{H},用途:{bg})
### 第 2 页 · 「{页面标题}」
**{content/data} | {breathing/dense}**
- {要点1}
- {要点2}
- {要点3}
- 配图:{策略}
{有图表}→ 📊 {图表类型} — {数据描述}
... {逐页列出,格式同上}
### 第 {N} 页 · 结束
**end | anchor**
- 主文案:谢谢观看 / Q&A
---
## 📋 图片清单
{列出已匹配的用户图片或需要的图片}
## 📊 图表清单
{列出需自动生成的图表或"无"}",
suggested_response="确认,开始生成 HTML"
)
```
**⛔ 铁律**:必须用 `approval`,禁止 `selection`、禁止 `questions` 参数。全部大纲写入 `prompt`。
---
@@ -0,0 +1,481 @@
# 03 — HTML演示设计系统
> **适用路线**:HTML 路线(`generation_mode = "html"`)
> **角色**:HTML 演示的完整视觉设计规范 — 定义所有视觉参数的取值空间、层级关系与设计决策依据
> **读取时机**:Strategist 阶段锁定设计方案时;Executor 阶段逐页生成 HTML 前
> **设计灵感**:oh-my-ppt 项目 — 纯 HTML/CSS 驱动的演示生成系统
---
## 一、色彩系统
### 1.1 60-30-10 色彩法则
经典室内设计色彩比例,确保视觉平衡与层次感:
| 角色 | 占比 | 用途 | 示例 |
|------|------|------|------|
| **主色 (Primary)** | 60% | 页面背景、大面积色块、卡片底色 | 幻灯片底色、大面积区域填充 |
| **辅色 (Secondary)** | 30% | 卡片、侧边栏、区块背景、次要装饰 | 信息卡片、图标底色、分割区域 |
| **强调色 (Accent)** | 10% | CTA 按钮、关键数据、高亮文本、图标 | 核心数字、行动号召、重点标记 |
**硬性规则**:每页 Accent 色使用不超过页面面积的 10%,避免强调色滥用导致视觉焦点分散。
### 1.2 6-角色色板结构
每套配色方案必须完整定义以下 6 个色值,以 CSS 自定义属性形式组织:
| 色值角色 | CSS 变量名 | 职责说明 |
|----------|-----------|----------|
| `bg` | `--color-bg` | 页面根背景色,决定整体明暗基调 |
| `primary` | `--color-primary` | 主色调,用于标题栏、主要区块、主按钮 |
| `secondary` | `--color-secondary` | 辅助色,用于次级区域、图表辅助系列、侧边栏 |
| `accent` | `--color-accent` | 强调色,用于关键数据高亮、CTA、活跃状态 |
| `text` | `--color-text` | 正文文字色,确保与背景对比度 ≥ 4.5:1 (WCAG AA) |
| `muted` | `--color-muted` | 弱化文字色,用于副文本、脚注、辅助线、占位符 |
**CSS 实现准则**:
```css
:root {
--color-bg: #FFFFFF;
--color-primary: #1A73E8;
--color-secondary: #E8F0FE;
--color-accent: #FF6D00;
--color-text: #1F2937;
--color-muted: #9CA3AF;
}
```
**衍生色值**(按需自动推导,不纳入核心色板):
- `--color-primary-hover`:primary 加深 10%
- `--color-primary-light`:primary 透明度降至 15%
- `--color-text-inverse`:深色背景上的反白文字,通常取 `--color-bg`
### 1.3 行业色彩参考
以下为各行业场景的色彩倾向指南,用于 Strategist 阶段匹配用户场景:
| 行业 / 场景 | 主色调倾向 | 辅色搭配 | 强调色点缀 |
|-------------|-----------|----------|-----------|
| **科技 / AI** | 蓝 `#1A73E8`、深蓝 `#0D47A1` | 浅蓝灰 `#E3F2FD` | 青 `#00BCD4`、电光紫 `#7C4DFF` |
| **金融 / 商务** | 藏蓝 `#1B3A5C`、深灰 `#37474F` | 米白 `#F5F0E8` | 金 `#C8A951`、铜 `#B8860B` |
| **医疗 / 健康** | 绿 `#2E7D32`、蓝绿 `#009688` | 薄荷绿 `#E8F5E9` | 白 `#FFFFFF`、珊瑚 `#FF6B6B` |
| **教育 / 培训** | 橙 `#F57C00`、蓝 `#1976D2` | 奶油 `#FFF8E1` | 黄 `#FFC107`、绿 `#4CAF50` |
| **环保 / 能源** | 绿 `#4CAF50`、森林绿 `#2E7D32` | 大地色 `#F1F8E9` | 天蓝 `#29B6F6` |
| **政府 / 政务** | 红 `#C62828`、深蓝 `#1A237E` | 浅灰 `#ECEFF1` | 金 `#FFD700` |
| **零售 / 消费** | 暖橙 `#FF7043`、粉 `#E91E63` | 浅粉 `#FCE4EC` | 黄 `#FFEB3B` |
| **制造 / 工业** | 灰蓝 `#455A64`、铁灰 `#607D8B` | 浅灰 `#ECEFF1` | 橙 `#FF6D00` |
### 1.4 中性色阶 (Neutral Scale)
7 级中性色阶,从纯白到近黑,覆盖所有灰阶需求:
| 级数 | 色值 | CSS 变量名 | 典型用途 |
|------|------|-----------|----------|
| N0 | `#FFFFFF` | `--neutral-0` | 纯白背景、卡片表面 |
| N1 | `#F9FAFB` | `--neutral-1` | 极浅灰背景(日间模式默认) |
| N2 | `#F3F4F6` | `--neutral-2` | 卡片悬浮背景、分割区 |
| N3 | `#E5E7EB` | `--neutral-3` | 边框、分割线 |
| N4 | `#9CA3AF` | `--neutral-4` | 弱化文字、占位符 |
| N5 | `#6B7280` | `--neutral-5` | 副标题、辅助说明 |
| N6 | `#1F2937` | `--neutral-6` | 正文主文字(浅色背景) |
| N7 | `#111827` | `--neutral-7` | 最深文字、深色强调 |
**使用规则**:
- 浅色主题:文字层级 N7 → N6 → N5 → N4,背景层级 N0 → N1 → N2
- 深色主题:背景层级 N7 → N6 → N5,文字层级 N0 → N1 → N2 → N3
---
## 二、字体系统
### 2.1 字体层级 (Typography Hierarchy)
基于 **1280×720px** 标准画布定义,共 7 个层级:
| 层级 | 字号范围 | 字重 | 行高 | 用途场景 |
|------|---------|------|------|----------|
| **Hero Title** (英雄标题) | 56-64px | Bold (700) | 1.1 | 封面页主标题、年度数字、核心主张 |
| **Page Title** (页面标题) | 42-48px | Bold (700) | 1.2 | 内容页顶部标题、章节标题 |
| **Section Title** (段落标题) | 32-36px | SemiBold (600) | 1.3 | 页面内分区标题、卡片标题 |
| **Subtitle** (副标题) | 24-28px | Medium (500) | 1.4 | 封面副标题、章节导语 |
| **Body** (正文) | 16-18px | Regular (400) | 1.6 | 段落正文、列表项、表格内容 |
| **Caption** (说明文字) | 14px | Regular (400) | 1.5 | 图表标签、图片来源标注 |
| **Annotation** (注释) | 12px | Regular (400) | 1.4 | 脚注、数据来源、页脚 |
**字号缩放规则**:
- 内容密集页(dense):整体字号下调 1 级(Hero→Page Title,Page Title→Section Title,以此类推)
- 呼吸感页(breathing):整体字号上调 0.5 级,扩大留白
### 2.2 字体系列推荐 (Font Pairings)
字体系列按语言和使用场景分类:
| 分类 | 首选字体 | 备选字体 | CSS font-family |
|------|---------|---------|-----------------|
| **中文无衬线** | 微软雅黑 | Noto Sans SC, PingFang SC | `"Microsoft YaHei", "Noto Sans SC", "PingFang SC", sans-serif` |
| **中文衬线** | Noto Serif SC | 宋体, SimSun | `"Noto Serif SC", "SimSun", "STSong", serif` |
| **英文无衬线** | Inter | Calibri, Roboto, Helvetica Neue | `"Inter", "Calibri", "Roboto", "Helvetica Neue", sans-serif` |
| **英文衬线** | Merriweather | Georgia, Times New Roman | `"Merriweather", "Georgia", "Times New Roman", serif` |
| **等宽 (Monospace)** | Consolas | Fira Code, Source Code Pro, Courier New | `"Consolas", "Fira Code", "Source Code Pro", "Courier New", monospace` |
**经典配对组合**:
| 配对方案 | 标题字体 | 正文字体 | 适用风格 |
|----------|---------|---------|----------|
| **现代企业** | Inter Bold + 微软雅黑 | Inter Regular + 微软雅黑 | corporate-clean, tech-startup |
| **编辑风雅** | Merriweather Bold + Noto Serif SC | Georgia Regular + 微软雅黑 | editorial-elegance, luxury-gold |
| **极简几何** | Inter ExtraLight → Bold 切换 | Inter Regular | neo-minimal, nord-cool |
| **活力创意** | 微软雅黑 Bold (加大字距) | Inter Regular | vibrant-creative, bold-impact |
| **东方典雅** | Noto Serif SC Bold | 微软雅黑 Light | chinese-ink |
### 2.3 字体加载策略
HTML 演示中的字体加载优先级:
1. **第一优先级(Web Safe)**:`"Microsoft YaHei"`, `"SimHei"`, `"Arial"` — 零延迟,系统内置
2. **第二优先级(Google Fonts CDN)**:`Inter`, `Noto Sans SC`, `Noto Serif SC`, `Merriweather`, `Fira Code` — 通过 `<link>` 标签异步加载
3. **第三优先级(CSS Fallback)**:泛类回退 (`sans-serif`, `serif`, `monospace`)
**HTML 引用示例**:
```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Noto+Sans+SC:wght@300;400;500;600;700&family=Noto+Serif+SC:wght@400;600;700&family=Merriweather:wght@400;700&family=Fira+Code:wght@400;500&display=swap" rel="stylesheet">
```
---
## 三、间距系统 (Spacing System)
### 3.1 基础单位
以 **8px** 为最小单位(8px grid),所有间距值均为 8 的倍数。
### 3.2 间距尺度
| Token 名称 | 值 | CSS 变量 | 用途 |
|------------|----|----------|------|
| `space-xs` | 8px | `--space-xs` | 紧密元素间距(图标与文字、标签内边距) |
| `space-sm` | 16px | `--space-sm` | 元素间距(列表项间距、按钮组间隔) |
| `space-md` | 24px | `--space-md` | 标准组件间距(卡片间距、段落间距) |
| `space-lg` | 32px | `--space-lg` | 大间距(区块间距、卡片内边距常规值) |
| `space-xl` | 48px | `--space-xl` | 超大间距(页面标题与内容区、主要分区) |
| `space-2xl` | 64px | `--space-2xl` | 页面级间距(封面标题与副标题之间) |
| `space-3xl` | 96px | `--space-3xl` | 极端留白(封面底部留白、过渡页中央留白) |
### 3.3 间距应用规范
| 应用场景 | 推荐间距 | 说明 |
|----------|---------|------|
| 页面整体内边距 | `--space-lg` (32px) ~ `--space-xl` (48px) | 1280×720 画布的安全边距 |
| 卡片内边距 (padding) | `--space-lg` (32px) ~ 40px | 确保卡片内容呼吸感 |
| 卡片之间间距 | `--space-md` (24px) | 网格布局中相邻卡片间距 |
| 标题与正文间距 | `--space-sm` (16px) | 标题后紧接着的正文段落 |
| 段落之间间距 | `--space-md` (24px) | 正文段落之间的垂直间距 |
| 列表项间距 | `--space-sm` (16px) | 无序/有序列表项之间 |
| 图标与文字间距 | `--space-xs` (8px) | 内联图标与相邻文字 |
---
## 四、卡片设计规范 (Card Design)
### 4.1 圆角规范 (Border Radius)
| 圆角级别 | 值 | CSS 变量 | 适用场景 |
|----------|----|----------|----------|
| 无圆角 | 0px | `--radius-none` | 表格、数据面板、严谨风格 |
| 微圆角 | 4px | `--radius-sm` | 按钮、输入框、标签 |
| 标准圆角 | 8px | `--radius-md` | 标准卡片、信息展示 |
| 柔和圆角 | 12px | `--radius-lg` | 卡片(默认推荐)、大容器 |
| 圆润卡片 | 16px | `--radius-xl` | 柔和风格卡片、特色区块 |
| 全圆角 | 9999px | `--radius-full` | 药丸按钮、头像、标签徽章 |
### 4.2 阴影系统 (Shadow System)
| 阴影级别 | CSS Box-Shadow | 使用场景 |
|----------|---------------|----------|
| **无阴影 (none)** | `none` | 扁平设计、极简风格 |
| **柔和阴影 (soft)** | `0 1px 3px 0 rgba(0,0,0,0.08), 0 1px 2px 0 rgba(0,0,0,0.06)` | 微弱层级提示、标准卡片 |
| **中等阴影 (medium)** | `0 4px 6px -1px rgba(0,0,0,0.08), 0 2px 4px -2px rgba(0,0,0,0.05)` | 悬浮卡片、重点强调 |
| **深度阴影 (raised)** | `0 10px 15px -3px rgba(0,0,0,0.08), 0 4px 6px -4px rgba(0,0,0,0.04)` | 模态层、最高层级元素 |
**暗色模式阴影**:暗色背景下阴影不可见,改用边框 (`border: 1px solid var(--neutral-5)`) 替代阴影来区分层次。
### 4.3 边框选项
| 选项 | 值 | 用途 |
|------|----|------|
| 无边框 | `none` | 默认卡片 |
| 细边框 | `1px solid var(--neutral-3)` | 浅色背景卡片轮廓 |
| 强调左边框 | `4px solid var(--color-accent)` | 引用卡片、提示卡片 |
| 渐变边框 | 通过 `border-image` 或伪元素实现 | 特殊视觉风格(赛博朋克、科技风) |
---
## 五、页面节奏标签 (Page Rhythm Tags)
每页幻灯片根据内容密度和沟通目标,标记为以下三种节奏之一:
### 5.1 标签定义
| 标签 | 名称 | 最大字符数 | 留白策略 | 典型页面类型 |
|------|------|-----------|----------|-------------|
| `anchor` | 锚点页 | ≤ 30 字符 | 大量留白(上下各 ≥ 96px) | 封面、结束页、重大过渡 |
| `dense` | 密集页 | ≤ 80 字符 | 紧凑布局,减少间距 | 数据图表页、对比分析、要点罗列 |
| `breathing` | 呼吸页 | ≤ 20 字符 | 宽松布局,优先留白 | 引言页、过渡页、金句页、大图展示 |
### 5.2 节奏策略
| 标签 | 字号策略 | 间距策略 | 装饰策略 |
|------|---------|----------|----------|
| **anchor** | 使用 Hero Title (56-64px) | 上下均使用 `--space-3xl` (96px) | 可加装饰线、大图标点缀 |
| **dense** | 整体下调 1 级 | 卡片间距 `--space-md`,内边距 `--space-sm` | 去装饰化,信息效率优先 |
| **breathing** | 整体上调 0.5 级 | 区块间距 `--space-2xl`起 | 装饰性留白本身就是设计元素 |
### 5.3 节奏序列建议
一份优秀的演示应形成「锚 → 呼吸 → 密集 (×N) → 呼吸 → 锚」的节奏曲线:
```
封面(anchor) → 目录(breathing) → 内容页(dense) × 3~8 → 过渡(breathing) → 内容页(dense) × 3~8 → 总结(breathing) → 结束(anchor)
```
---
## 六、图标策略 (Icon Strategy)
### 6.1 优先级排序
| 优先级 | 方案 | 说明 | 示例 |
|--------|------|------|------|
| **🥇 Emoji** | 系统原生 Emoji | 零依赖、跨平台一致、天然彩色 | 🚀 📊 💡 🎯 ⚡ 🔥 💎 🌟 |
| **🥈 Unicode 符号** | 几何/箭头/数学符号 | 单色、简洁、无版权 | ● ○ ◆ ◇ ▶ ▸ → ← ↑ ↓ ✔ ✖ ★ ☆ |
| **🥉 内联 SVG** | 手写 `<svg>` 标签 | 可控颜色/大小、不失真 | 自定义图标、品牌 Logo |
### 6.2 严格禁止
- ❌ 不使用任何外部图标库(Font Awesome、Material Icons、Feather 等)
- ❌ 不通过 CDN 引用图标字体
- ❌ 不使用 `<img>` 标签加载外部图标图片
### 6.3 内联 SVG 图标模板
```html
<!-- 标准图标 (24x24) -->
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<path d="M12 6v6l4 2"/>
</svg>
```
### 6.4 常用业务 Emoji 速查
| 类别 | Emoji |
|------|-------|
| **增长/向上** | 📈 📊 🚀 ⬆️ 💹 🏆 |
| **目标/战略** | 🎯 🧭 🗺️ 🏁 🔭 |
| **技术/创新** | 💻 🤖 ⚙️ 🔬 💡 🧬 |
| **安全/稳定** | 🛡️ 🔒 ✅ 🏗️ ⚓ |
| **团队/合作** | 👥 🤝 🧩 🌐 🔗 |
| **时间/效率** | ⏱️ ⚡ 🕐 📅 🔄 |
| **金钱/商业** | 💰 💎 📋 💼 🏦 |
| **提醒/警告** | ⚠️ ❗ 🔔 💬 📌 |
---
## 七、网格系统 (Grid System)
### 7.1 12列网格
基于 1280px 画布宽度,定义 12 列等分网格:
| 列数 | 宽度 | 典型用途 |
|------|------|----------|
| 12/12 | 1280px | 全宽横幅、封面背景 |
| 8/12 | ~853px | 主要内容区(居中) |
| 6/12 | 640px | 半宽布局(并排) |
| 4/12 | ~427px | 三列卡片、特性展示 |
| 3/12 | 320px | 四列图标/数据指标 |
| 2/12 | ~213px | 六列标签/徽章 |
### 7.2 CSS Grid 实现
```css
/* 12列网格容器 */
.slide-grid {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: var(--space-md); /* 24px 列间距 */
width: 1280px;
padding: 0 var(--space-xl); /* 48px 外边距 */
}
/* 常用跨度 */
.span-12 { grid-column: span 12; }
.span-8 { grid-column: span 8; }
.span-6 { grid-column: span 6; }
.span-4 { grid-column: span 4; }
.span-3 { grid-column: span 3; }
```
### 7.3 响应式注意事项
HTML 演示虽以 1280×720 为目标,但在不同屏幕预览时:
- 使用 `max-width: 1280px; max-height: 720px` 限制幻灯片容器
- 使用 `aspect-ratio: 16 / 9` 保持画布比例
- 小屏幕上通过 `transform: scale()` 或 `object-fit` 缩放适应视口
---
## 八、视觉层级原则 (Visual Hierarchy Principles)
### 8.1 四大原则
| 原则 | 解释 | 实现手段 |
|------|------|----------|
| **对比 (Contrast)** | 重要元素与背景形成明显差异 | 字号差 ≥ 1.5×、色差 ≥ 3:1 对比度、字重差异 |
| **尺度 (Scale)** | 通过尺寸建立信息重要性排序 | Hero 56-64px → Body 16-18px,比例约 3:1~4:1 |
| **邻近 (Proximity)** | 相关信息物理邻近,无关信息物理分离 | 同组间距 8-16px,异组间距 24-48px |
| **对齐 (Alignment)** | 建立清晰视觉流线,减少杂乱 | 左对齐为主(中文场景)、网格对齐、基线对齐 |
### 8.2 Z型阅读路径
对于 1280×720 画布,观众的视线遵循 Z 型路径:
```
左上(标题/Logo) ──────────────→ 右上(关键数据/日期)
↓ ↓
左下(辅助内容/图表) ←────────── 右下(CTA/结论)
```
**设计指导**:
- **左上角**:放置页面标题或核心主张(第一眼落点)
- **右上角**:放置关键数字、日期、Logo
- **底部区域**:放置图表、详细内容、CTA 按钮
- **右下角**:行动号召或核心结论(视线终点)
### 8.3 留白策略
| 留白类型 | 定义 | 配比指导 |
|----------|------|----------|
| **主动留白** | 刻意为之的空区域,引导视线、凸显孤立元素 | anchor/breathing 页 ≥ 40% |
| **被动留白** | 元素间的自然间距 | 所有页面通用,≥ 16px |
---
## 九、动效体系 (Animation System)
HTML 路线支持通过 CSS `data-anim` 属性声明交互动效。
### 9.1 动效类型
| 类型 | `data-anim` 值 | 描述 | 推荐节奏 |
|------|---------------|------|----------|
| 淡入 | `fade-in` | 元素从不透明到出现 | anchor, breathing |
| 上浮淡入 | `fade-up` | 从下方 20px 淡入滑入 | 所有页面通用 |
| 缩放淡入 | `scale-in` | 从 0.8× 缩放至 1× 并淡入 | 封面 Hero 标题 |
| 左滑入 | `slide-left` | 从左侧滑入 | 列表项次第出现 |
| 右滑入 | `slide-right` | 从右侧滑入 | 图片/图表呈现 |
| 弹出 | `pop` | 弹性缩放出现 | 关键数字强调 |
### 9.2 动效时序
通过 `data-anim-delay` 控制延迟(单位:ms),实现逐元素次第出现:
```html
<h1 data-anim="fade-up" data-anim-delay="0">标题</h1>
<p data-anim="fade-up" data-anim-delay="200">副标题</p>
<div data-anim="scale-in" data-anim-delay="500">关键数字</div>
```
### 9.3 动效纪律
- 每页动效总数 ≤ 6 个元素
- 动效总时长 ≤ 1.2 秒
- 密集页(dense):禁用动效或仅保留 1 个元素动效
- 呼吸页(breathing):可用 3-5 个元素动效
- 锚点页(anchor):可充分使用动效营造仪式感
---
## 十、CSS 自定义属性总览
所有设计系统中定义的 CSS 变量,应在 HTML 的 `:root` 中统一声明:
```css
:root {
/* === 色彩 === */
--color-bg: #FFFFFF;
--color-primary: #1A73E8;
--color-secondary: #E8F0FE;
--color-accent: #FF6D00;
--color-text: #1F2937;
--color-muted: #9CA3AF;
/* === 中性色阶 === */
--neutral-0: #FFFFFF;
--neutral-1: #F9FAFB;
--neutral-2: #F3F4F6;
--neutral-3: #E5E7EB;
--neutral-4: #9CA3AF;
--neutral-5: #6B7280;
--neutral-6: #1F2937;
--neutral-7: #111827;
/* === 间距 === */
--space-xs: 8px;
--space-sm: 16px;
--space-md: 24px;
--space-lg: 32px;
--space-xl: 48px;
--space-2xl: 64px;
--space-3xl: 96px;
/* === 圆角 === */
--radius-none: 0px;
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-full: 9999px;
/* === 字号 === */
--font-hero: 56px;
--font-page-title: 42px;
--font-section-title: 32px;
--font-subtitle: 24px;
--font-body: 16px;
--font-caption: 14px;
--font-annotation: 12px;
/* === 字体 === */
--font-sans-cn: "Microsoft YaHei", "Noto Sans SC", "PingFang SC", sans-serif;
--font-sans-en: "Inter", "Calibri", "Roboto", sans-serif;
--font-serif: "Merriweather", "Noto Serif SC", Georgia, serif;
--font-mono: "Consolas", "Fira Code", "Courier New", monospace;
/* === 过渡 === */
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
--duration-fast: 0.2s;
--duration-normal: 0.4s;
--duration-slow: 0.6s;
}
```
---
## 十一、画布规范 (Canvas Spec)
| 属性 | 值 | 说明 |
|------|----|------|
| 画布尺寸 | 1280 × 720 px | 标准 16:9 比例 |
| 安全区域 | 内边距 48px (四边) | 确保关键内容不被裁切 |
| 有效内容区 | 1184 × 624 px | 安全区内可放置内容的区域 |
| 最小字号 | 12px | 任何文字不得小于此值 |
| 最大内容密度 | ≤ 80 中文字符/页 | dense 页上限;anchor 页 ≤ 30 字符 |
---
@@ -0,0 +1,862 @@
# 04 — HTML演示视觉风格目录
> **适用路线**:HTML 路线(`generation_mode = "html"`)
> **角色**:18 种预设视觉风格完整索引 — 提供即用配色、字体配对与设计原则
> **读取时机**:Strategist 阶段需要匹配用户场景锁定 `visual_style` 时
> **使用方式**:从下方目录中选择最匹配用户需求的风格,将其色板、字体、布局原则应用于 HTML 生成
---
## 风格速查矩阵
| # | Style ID | 中文名 | 明暗 | 适合场景 | 一句话描述 |
|---|----------|--------|------|----------|-----------|
| 1 | `corporate-clean` | 企业洁净 | 浅 | 商业汇报、年度总结、咨询方案 | 专业可信赖的蓝色商务风 |
| 2 | `dark-tech` | 暗夜科技 | 深 | 技术发布会、AI主题、开发者大会 | 深邃科技感,发光元素点亮暗背景 |
| 3 | `warm-professional` | 温暖专业 | 浅 | 内部培训、团队分享、人力资源 | 暖色调拉近距离,不失专业 |
| 4 | `glassmorphism` | 毛玻璃 | 浅/彩 | 产品发布、设计展示、品牌页面 | 多层半透明卡片,梦幻层次感 |
| 5 | `editorial-elegance` | 编辑风雅 | 浅 | 品牌手册、文化刊物、高端提案 | 衬线字体 + 大量留白,杂志质感 |
| 6 | `neo-minimal` | 新极简 | 浅 | 设计提案、创意方案、极简品牌 | 极度克制,单一强调色点睛 |
| 7 | `vibrant-creative` | 活力创意 | 浅/彩 | 营销提案、创意脑暴、活动策划 | 高饱和撞色,能量四射 |
| 8 | `cyberpunk-neon` | 赛博霓虹 | 深 | 游戏发布、Web3、前瞻科技 | 霓虹灯 + 暗黑背景,街头科幻 |
| 9 | `chinese-ink` | 水墨中国 | 浅 | 文化讲座、中式品牌、国学课程 | 墨色为骨,留白为韵,东方极致 |
| 10 | `nord-cool` | 北欧清冷 | 浅 | 数据报告、学术研究、架构设计 | 冷色调理性克制,信息密度友好 |
| 11 | `natural-organic` | 自然有机 | 浅 | ESG报告、环保品牌、健康生活 | 大地色系 + 圆角,亲和自然 |
| 12 | `bold-impact` | 大胆冲击 | 浅/深 | 融资路演、战略发布、品牌宣言 | 极高对比度,超大字号,一句话震撼 |
| 13 | `soft-pastel` | 柔和粉彩 | 浅 | 亲子教育、女性社区、生活方式 | 马卡龙色系,圆润可爱,零攻击性 |
| 14 | `luxury-gold` | 奢华金 | 深 | 高端品牌、VIP邀请、奢侈品 | 暗底金线,衬线字体,顶级质感 |
| 15 | `tech-startup` | 科技创业 | 浅 | 创业路演、产品Demo Day、SaaS | 白底渐变光,年轻乐观,未来感 |
| 16 | `cream-white` | 米白雅致 | 浅 | 生活美学、日系品牌、婚礼策划 | 暖米白+奶茶棕,手工纸质感 |
| 17 | `cream-green` | 奶油抹茶 | 浅 | 健康餐食、茶文化、瑜伽冥想 | 抹茶绿+奶油底,清新治愈 |
| 18 | `cinematic-teal-orange` | 电影感青橙 | 深 | 影视提案、创意叙事、摄影作品 | 暗青+琥珀橙,好莱坞调色 |
---
## 一、corporate-clean(企业洁净)
### Style: corporate-clean
- **Display Name**:企业洁净
- **Keywords**:专业、信赖、秩序、高效、克制的蓝
- **Best For**:企业年度汇报、咨询方案交付、B2B销售演示、董事会报告、财务分析
- **Aesthetic**:
经典商务美学 — 以沉稳的藏蓝为骨架,浅灰蓝为辅,营造可信赖、有条理的专业氛围。大面积白色背景确保信息传达清晰无误,蓝色强调色用于引导视线至关键数据与结论。整体气质如同高级管理咨询顾问的着装:精致合身,不张扬,但每一处细节都经得起审视。
视觉节奏偏向理性均衡 — 图表与数据是主角,装饰退居幕后。卡片以微阴影托起,边框极细乃至不可见,营造"漂浮的信息岛"感。
- **Palette**:
- `bg`: `#FFFFFF`
- `primary`: `#1B3A5C`
- `secondary`: `#E8EEF4`
- `accent`: `#2563EB`
- `text`: `#1F2937`
- `muted`: `#6B7280`
- **Typography**:`Inter Bold + 微软雅黑` | `Inter Regular + 微软雅黑`
- **Layout Principles**:
1. 严格 12 列网格对齐,内容绝不游离网格
2. 每页标题使用断言式结论(金字塔模式天然契合)
3. 数据图表占据视觉重心,文字充当辅助注释
4. 卡片统一使用 `--radius-lg` (12px) + `soft` 阴影
5. 左右结构优先:左侧标题/要点,右侧图表/数据
- **Animation Style**:`fade-up` 为主,`slide-left` 用于列表项次第出现,延迟 150ms 递增。封面可用 `scale-in` 营造开场仪式感。
- **DO**:
- ✅ 大量使用图表和数据可视化
- ✅ 每页标题即核心结论
- ✅ 保持充足的数据标注和来源说明
- ✅ 蓝色作为唯一强调色,忌多色分散注意力
- **DON'T**:
- ❌ 不要使用渐变背景
- ❌ 不要超过 2 种强调色
- ❌ 不要使用装饰性插图和无关图标
- ❌ 不要使用衬线字体(影响数据可读性)
---
## 二、dark-tech(暗夜科技)
### Style: dark-tech
- **Display Name**:暗夜科技
- **Keywords**:科技感、深邃、发光、未来、极客
- **Best For**:技术发布会、AI/ML主题分享、开发者大会演讲、网络安全报告、前沿趋势
- **Aesthetic**:
深不见底的暗蓝黑色背景如同一块无限延伸的科技画布,上面的内容仿佛浮在夜空中。青色和电光蓝作为发光强调色,在暗背景上产生类似 HUD(抬头显示器)的视觉联想。卡片与元素边界以半透明细线勾勒,配合微妙的发光阴影 (`box-shadow` 扩散),营造"屏幕内"的沉浸科技体验。
整体气质冷静而自信 — 像深夜的数据中心,只有必要的信号灯在闪烁,每一束光都有明确目的。
- **Palette**:
- `bg`: `#0B1120`
- `primary`: `#1E3A5F`
- `secondary`: `#17263B`
- `accent`: `#00D4FF`
- `text`: `#E2E8F0`
- `muted`: `#64748B`
- **Typography**:`Inter SemiBold + 微软雅黑` | `Inter Regular + 微软雅黑`
- **Layout Principles**:
1. 暗背景 + 发光元素为核心视觉特征
2. 使用 `box-shadow: 0 0 20px rgba(0,212,255,0.3)` 模拟发光效果
3. 卡片背景使用半透明深色 (`rgba(23,38,59,0.7)`)
4. 边框使用 `1px solid rgba(0,212,255,0.15)` 的细线
5. 代码块、数据指标使用等宽字体 (`Consolas`) 强化技术感
- **Animation Style**:`fade-up` + 微弱的 `scale-in` 组合,延迟 200ms 递增。关键数据可用 `pop` 弹性动画。可添加 `typewriter` 逐字效果用于代码或关键声明。
- **DO**:
- ✅ 大量留黑,让发光元素成为焦点
- ✅ 使用细线网格或点阵装饰背景(科技网格线)
- ✅ 数据指标做大字号发光处理
- ✅ 代码片段用等宽字体 + 语法高亮风格的色彩
- **DON'T**:
- ❌ 不要使用大面积白色或亮色背景块
- ❌ 不要使用暖色系(破坏冷静科技氛围)
- ❌ 不要过度阴影 — 用发光 (glow) 代替传统阴影
- ❌ 不要使用衬线字体
---
## 三、warm-professional(温暖专业)
### Style: warm-professional
- **Display Name**:温暖专业
- **Keywords**:亲切、人性化、暖色调、团队、信赖感
- **Best For**:内部培训、团队建设汇报、人力资源展示、企业文化宣导、客户成功案例
- **Aesthetic**:
以奶油色和暖灰为基底,用陶土色和焦橙色作为强调 — 仿佛冬日里的一杯热咖啡或一盏暖灯。相比 corporate-clean 的冷峻理性,warm-professional 通过色调的温度感拉近与观众的心理距离,适合"人对人"而非"数据对数据"的沟通场景。
卡片带有柔和圆角和大面积内边距,文字排版宽松,整体节奏舒缓 — 像一场舒适的炉边对话而非高压会议室报告。
- **Palette**:
- `bg`: `#FFFBF5`
- `primary`: `#D4A574`
- `secondary`: `#FEF0E3`
- `accent`: `#E07B3C`
- `text`: `#3D3226`
- `muted`: `#A09180`
- **Typography**:`Inter SemiBold + 微软雅黑` | `Inter Regular + 微软雅黑`
- **Layout Principles**:
1. 暖色背景避免纯白 (`#FFFBF5` 带微暖底)
2. 卡片使用 `--radius-xl` (16px) + 柔和阴影
3. 图文搭配:人物照片/插画与要点文字交替
4. 列表项使用圆形或柔和的图标标记
5. 页面内边距慷慨(≥ 48px),营造呼吸感
- **Animation Style**:`fade-up` 为主,延迟 250ms — 节奏从容不迫。避免快节奏动效。
- **DO**:
- ✅ 使用暖色渐变微调背景(从 `#FFFBF5` 到 `#FEF0E3`)
- ✅ 大面积的留白和宽松排版
- ✅ 使用人物相关的 Emoji(👥 🤝 💬)作为点缀
- ✅ 数据用较大字号呈现,避免密密麻麻的表格
- **DON'T**:
- ❌ 不要使用冷蓝/冷绿色系
- ❌ 不要使用锐角边框或直角卡片
- ❌ 不要过度使用动画(保持安静平和)
- ❌ 不要让文字密度过高
---
## 四、glassmorphism(毛玻璃)
### Style: glassmorphism
- **Display Name**:毛玻璃
- **Keywords**:通透、层次、梦幻、现代、半透明
- **Best For**:产品发布演示、设计成果展示、品牌形象页、创意提案、App功能展示
- **Aesthetic**:
以柔和的紫蓝渐变或多彩渐变铺满背景,前景卡片使用半透明磨砂玻璃效果 (`backdrop-filter: blur()` + 半透明白色/深色背景)。多层卡片相互交叠时,透过上层的毛玻璃隐约看到下层内容与背景色彩,形成丰富而梦幻的视觉深度。
这种风格天生具有"现代设计感"和"精致感" — 仿佛在透过高级玻璃橱窗观看展品,每一层都增加了维度和趣味。
- **Palette**:
- `bg`: `#F0F0FF`(渐变底层:`linear-gradient(135deg, #E8E0F0, #E0E8F8, #E0F0F0)`)
- `primary`: `#7C5CBF`
- `secondary`: `rgba(255,255,255,0.60)`
- `accent`: `#FF6B9D`
- `text`: `#2D1B69`
- `muted`: `#8B7BAF`
- **Typography**:`Inter SemiBold + 微软雅黑` | `Inter Regular + 微软雅黑`
- **Layout Principles**:
1. 背景必须使用多色渐变(≥ 2 色,通常 3 色)或彩色形状装饰
2. 卡片核心 CSS:`background: rgba(255,255,255,0.60); backdrop-filter: blur(16px); border: 1px solid rgba(255,255,255,0.5); border-radius: 16px;`
3. 卡片可适度交叠排列,使用 `translate` 制造偏移
4. 强调元素(按钮、关键数字)使用纯色块,与毛玻璃形成对比
5. 装饰性彩色光晕圆形散布在背景中 (`border-radius: 50%; filter: blur(60px)`)
- **Animation Style**:`fade-up` + 微弱的 `scale-in` (0.95→1),延迟 200ms。卡片悬停时可轻微上浮并增强阴影。
- **DO**:
- ✅ 多层毛玻璃卡片制造深度感
- ✅ 背景使用有机形状的光晕装饰
- ✅ 适度使用圆角 (16px+)
- ✅ 关键数据用不透明的强烈色彩打破毛玻璃的柔和感
- **DON'T**:
- ❌ 不要在毛玻璃卡片上放密集文字(降低可读性)
- ❌ 不要使用高饱和纯色做卡片背景
- ❌ 不要过度叠层(≤ 3 层交叠)
- ❌ 不要忘记 `backdrop-filter` 的浏览器兼容性降级方案
---
## 五、editorial-elegance(编辑风雅)
### Style: editorial-elegance
- **Display Name**:编辑风雅
- **Keywords**:典雅、杂志感、衬线、留白、克制
- **Best For**:品牌手册、文化刊物、高端提案书、设计哲学阐述、年度文化总结
- **Aesthetic**:
如同翻开一本精心排版的独立杂志 — 衬线字体是绝对主角,无衬线仅用于辅助标注。色彩极度克制,以黑、白和暖灰构成基调,仅用一抹低调的强调色(如深蓝灰或暗红)在关键处点睛。大量留白并非浪费,而是给予文字呼吸的空间,让每一句话都显得重要。
整体气质安静而自信 — 不靠色彩吸引注意,而是靠内容本身的力度和排版的美感。图片以黑白或低饱和呈现,文字排版本身就是装饰。
- **Palette**:
- `bg`: `#FAFAF8`
- `primary`: `#2C2C2C`
- `secondary`: `#F0EDE8`
- `accent`: `#8B4513`
- `text`: `#1A1A1A`
- `muted`: `#8C8C8C`
- **Typography**:`Merriweather Bold + Noto Serif SC Bold` | `Merriweather Regular + 微软雅黑 Light`
- **Layout Principles**:
1. 页标题使用衬线字体 (Merriweather / Noto Serif SC),正文字号偏大 (18-20px)
2. 非对称布局:文字块偏离中心,留出大面积空白
3. 装饰元素极简 — 一根细线、一个页码,足矣
4. 图片黑白处理或低饱和滤镜
5. 拉大字距 (`letter-spacing: 0.04em`),增强呼吸感
- **Animation Style**:`fade-in` 仅淡入,无位移 — 保持静谧气质。延迟 300ms 以上,徐徐呈现。
- **DO**:
- ✅ 衬线字体做标题,字重对比强烈 (Bold vs Light)
- ✅ 留白面积 ≥ 50%
- ✅ 使用细线 (`1px solid`) 做视觉分割
- ✅ 文字排版本身就是设计
- **DON'T**:
- ❌ 不要使用高饱和色彩
- ❌ 不要使用阴影和圆角卡片
- ❌ 不要使用 Emoji 或花哨图标
- ❌ 不要让任何元素"拥挤"
---
## 六、neo-minimal(新极简)
### Style: neo-minimal
- **Display Name**:新极简
- **Keywords**:极致简洁、几何、单一强调色、留白、建筑感
- **Best For**:设计提案、创意方案、极简品牌展示、架构概览、概念阐述
- **Aesthetic**:
回归设计的本质 — 去掉一切可去掉的,留下的每一个像素都有其存在的必要性。以纯白为画布,黑色文字为唯一信息载体,仅用一种高饱和强调色(通常是鲜明的珊瑚橙或电光蓝)在关键处画龙点睛。几何形状(圆、方、线条)是唯一的视觉装饰。
整体气质冷静果敢 — 像现代主义建筑的室内,白墙、清水混凝土,一把亮色椅子就是全场的焦点。这种风格的"大胆"恰恰来自它的"克制"。
- **Palette**:
- `bg`: `#FFFFFF`
- `primary`: `#000000`
- `secondary`: `#F5F5F5`
- `accent`: `#FF5A45`
- `text`: `#1A1A1A`
- `muted`: `#A0A0A0`
- **Typography**:`Inter ExtraLight → Bold 切换` | `Inter Regular`
- **Layout Principles**:
1. 单一强调色原则:全篇只用 1 个 accent 色(如珊瑚橙 `#FF5A45`)
2. 大色块几何装饰:圆圈、矩形色块、粗线条
3. 文字字号极端对比:Hero 64px ↔ Body 14px
4. 网格对齐严苛,元素绝不对偏
5. 大面积留白 (≥ 60%)
- **Animation Style**:`fade-up` 简洁利落,延迟 180ms。几何色块可用 `scale-in` 从 0 弹入。
- **DO**:
- ✅ 用大面积纯色块做视觉冲击
- ✅ 极端字号对比 (64px vs 14px)
- ✅ 几何形状做装饰(圆形、矩形、线条)
- ✅ 每个页面只强调 1 个核心信息
- **DON'T**:
- ❌ 不要使用渐变
- ❌ 不要使用阴影(扁平至极)
- ❌ 不要使用超过 1 个强调色
- ❌ 不要让内容超过 30 字(这是极简,不是密集)
---
## 七、vibrant-creative(活力创意)
### Style: vibrant-creative
- **Display Name**:活力创意
- **Keywords**:高饱和、渐变、能量、大胆、年轻
- **Best For**:营销提案、创意脑暴会、活动策划方案、品牌Campaign、社交媒体报告
- **Aesthetic**:
不惧色彩 — 高饱和度的橙、紫、粉、青在页面上碰撞、融合,渐变是传递能量的主要手段。背景可能是从紫到橙的暖色渐变,卡片可能是半透明的彩色玻璃效果,文字可能是白色反白在彩色块上。
整体气质热烈奔放 — 像创意团队的头脑风暴墙,每一张便签都是荧光色,每一笔涂鸦都充满能量。这不是给保守派准备的设计,这是给敢于说"让我们试试这个大胆的想法"的人准备的。
- **Palette**:
- `bg`: `#FFF5F0`
- `primary`: `#FF6B35`
- `secondary`: `#FFE0D3`
- `accent`: `#7C3AED`
- `text`: `#2D1B15`
- `muted`: `#C49585`
- **Typography**:`Inter Bold + 微软雅黑 Bold` | `Inter Medium + 微软雅黑`
- **Layout Principles**:
1. 大胆使用双色或三色渐变 (`linear-gradient(135deg, #FF6B35, #FF3D8E)`)
2. 卡片使用大圆角 (16px) + 彩色边框
3. 文字可以在彩色色块上使用反白
4. 装饰元素可以使用有机波浪形状
5. 不对称布局,打破网格限制
- **Animation Style**:`pop` 弹性动画为首选,`scale-in` + `fade-up` 组合,延迟 120ms — 快节奏、有活力。
- **DO**:
- ✅ 使用高饱和渐变做大标题背景
- ✅ 大胆撞色(橙×紫、粉×青、黄×蓝)
- ✅ 不规则布局打破沉闷
- ✅ 使用表情丰富的 Emoji
- **DON'T**:
- ❌ 不要使用灰色或低饱和色
- ❌ 不要让页面出现大面积白色空白
- ❌ 不要使用正式/严肃的语气(风格不配)
- ❌ 不要所有元素对齐网格 — 刻意破格是风格的一部分
---
## 八、cyberpunk-neon(赛博霓虹)
### Style: cyberpunk-neon
- **Display Name**:赛博霓虹
- **Keywords**:霓虹灯、暗黑、赛博朋克、未来都市、故障艺术
- **Best For**:游戏发布会、Web3/区块链项目、科幻主题活动、电音派对、次世代产品
- **Aesthetic**:
黑夜 + 霓虹灯 — 这是赛博朋克视觉的核心公式。深黑或极暗紫的底色上,洋红 (`#FF2D95`)、电青 (`#00F0FF`)、硫黄 (`#FFE600`) 作为霓虹管道的三种颜色,以细线、发光文字和几何边框的形式出现。网格线和斜线装饰让人联想到雨夜霓虹都市的街道。
整体气质叛逆而前卫 — 像《银翼杀手》的视觉语言被搬进了演示文稿。发光文字似乎悬浮在潮湿的暗夜空气中,每一个像素都在宣告"我们是未来"。
- **Palette**:
- `bg`: `#0A0A0A`
- `primary`: `#1A0030`
- `secondary`: `#110022`
- `accent`: `#FF2D95`
- `text`: `#F0E6FF`
- `muted`: `#6B5080`
- **Typography**:`Inter Bold + 微软雅黑` | `Consolas + 微软雅黑`
- **Layout Principles**:
1. 暗黑背景 + 霓虹色 (洋红 `#FF2D95` / 电青 `#00F0FF` / 硫黄 `#FFE600`)
2. 文字发光效果:`text-shadow: 0 0 10px currentColor, 0 0 30px currentColor`
3. 边框使用霓虹发光:`border: 2px solid #FF2D95; box-shadow: 0 0 10px #FF2D95, inset 0 0 10px rgba(255,45,149,0.2)`
4. 背景叠加网格线或斜线图案 (CSS `repeating-linear-gradient`)
5. 可添加故障艺术效果 (clip-path 动画、text-shadow 重影)
- **Animation Style**:`fade-up` + 霓虹闪烁 (`@keyframes neon-flicker`),延迟 100ms。可添加故障抖动 (`@keyframes glitch-skew`) 用于标题。
- **DO**:
- ✅ 霓虹文字发光效果是标志性特征,务必使用
- ✅ 暗色背景叠加网格线
- ✅ 使用等宽字体做代码/数据展示
- ✅ 三种霓虹色交替使用,保持视觉丰富
- **DON'T**:
- ❌ 不要使用白色或浅色背景
- ❌ 不要使用传统阴影(发光代替阴影)
- ❌ 不要使用衬线字体
- ❌ 不要过度使用故障效果(1-2 处足够)
---
## 九、chinese-ink(水墨中国)
### Style: chinese-ink
- **Display Name**:水墨中国
- **Keywords**:东方、水墨、留白、禅意、中国红
- **Best For**:文化讲座、中式品牌展示、国学课程、茶道/书法活动、传统节庆
- **Aesthetic**:
以宣纸般的米白为底色,浓淡墨色为文字与骨架,中国红 (`#C41E3A`) 仅用于印章、标题点缀或关键标记 — 如同一幅水墨画上的朱砂印,克制而贵重。装饰元素以晕染墨点、枯笔线条、竹节纹样为主,通过 CSS 的 `radial-gradient` 和 `opacity` 模拟水墨的浓淡干湿。
整体气质沉静内敛 — 不争不抢,以"少即是多"、"空即是满"的东方哲学驾驭视觉。留白不是空白,而是想象的空间。
- **Palette**:
- `bg`: `#F8F4ED`
- `primary`: `#2C2C2C`
- `secondary`: `#EBE5D9`
- `accent`: `#C41E3A`
- `text`: `#3A3A3A`
- `muted`: `#A09888`
- **Typography**:`Noto Serif SC Bold` | `Noto Serif SC Regular + 微软雅黑 Light`
- **Layout Principles**:
1. 宣纸底色 (`#F8F4ED`) + 墨色文字
2. 竖排文字可选(CSS `writing-mode: vertical-rl`),还原中式排版
3. 中国红 (`#C41E3A`) 仅用于印章大小的小面积点缀
4. 使用 `radial-gradient` 制作墨点晕染效果
5. 大面积留白,文字集中在一侧(如左上或右中)
- **Animation Style**:`fade-in` 缓慢淡入,延迟 400ms — 如水墨在宣纸上缓缓晕开。可配合 `@keyframes ink-spread` 模拟墨迹扩散。
- **DO**:
- ✅ 衬线字体是灵魂,务必使用 Noto Serif SC
- ✅ 留白占总面积 50% 以上
- ✅ 使用墨色渐变模拟浓淡
- ✅ 中国红克制使用(≤ 3% 面积)
- **DON'T**:
- ❌ 不要使用无衬线字体做标题
- ❌ 不要使用渐变背景(破坏宣纸质感)
- ❌ 不要使用 Emoji(破坏东方意境)
- ❌ 不要使用阴影和发光效果
---
## 十、nord-cool(北欧清冷)
### Style: nord-cool
- **Display Name**:北欧清冷
- **Keywords**:理性、清冷、Nord调色板、学术、克制
- **Best For**:数据报告、学术研究展示、架构设计文档、技术方案、科学演讲
- **Aesthetic**:
基于著名的 Nord 调色板,以冰霜蓝灰 (`#ECEFF4`) 为背景,极夜蓝 (`#2E3440`) 为文字,霜蓝 (`#5E81AC`、`#88C0D0`) 和冷绿 (`#A3BE8C`) 为辅助色系。整体色调像北极圈的晨曦 — 清冷、干净、没有一丝多余的温度。
这是一种高度理性、数据友好型的风格 — 低饱和冷色调对长时间阅读最为友好,不会视觉疲劳,同时通过色彩的温度感暗示客观和中立。
- **Palette**:
- `bg`: `#ECEFF4`
- `primary`: `#2E3440`
- `secondary`: `#D8DEE9`
- `accent`: `#5E81AC`
- `text`: `#2E3440`
- `muted`: `#81A1C1`
- **Typography**:`Inter Regular + 微软雅黑` | `Inter Regular + 微软雅黑`
- **Layout Principles**:
1. 严格网格对齐,所有元素边界清晰
2. 图表使用 Nord 调色板衍生的 6 色数据系列
3. 卡片无阴影或仅微弱阴影,依赖边框区分层次
4. 代码块使用 `#2E3440` 深色背景 + Nord 语法高亮
5. 信息密集但留白充足,确保可读性
- **Animation Style**:`fade-up` 简洁,延迟 150ms。无弹性、无晃动 — 保持理性。
- **DO**:
- ✅ 严格遵循 Nord 调色板,不引入额外颜色
- ✅ 数据可视化优先
- ✅ 使用等宽字体展示代码
- ✅ 图表色系使用 Nord 衍生色
- **DON'T**:
- ❌ 不要使用暖色系
- ❌ 不要使用高饱和色彩
- ❌ 不要使用弹性动画或炫酷特效
- ❌ 不要让装饰元素占据空间
---
## 十一、natural-organic(自然有机)
### Style: natural-organic
- **Display Name**:自然有机
- **Keywords**:大地色、有机形状、亲和、可持续、温暖
- **Best For**:ESG报告、环保品牌展示、健康生活方案、户外活动、有机食品
- **Aesthetic**:
从森林、土壤、苔藓和秋叶中提取色彩 — 墨绿 (`#386641`)、苔绿 (`#6A994E`)、暖棕 (`#BC6C25`)、奶油 (`#FEFAE0`)。这些色彩本身就传达了可持续、健康和温暖的品牌联想。配合大圆角 (20px+)、有机波浪形状和植物相关 Emoji (🌿🌱🍃),每个页面都像一片自然栖息地。
整体气质亲和接地 — 没有凌厉的直线,没有冷冰冰的数据面板,一切元素都像被自然之手打磨过,柔软、圆润、有温度。
- **Palette**:
- `bg`: `#FEFAE0`
- `primary`: `#386641`
- `secondary`: `#E9F5DB`
- `accent`: `#BC6C25`
- `text`: `#2D3A1E`
- `muted`: `#8B9A6E`
- **Typography**:`Inter SemiBold + 微软雅黑` | `Inter Regular + 微软雅黑`
- **Layout Principles**:
1. 大圆角 (`--radius-xl` 16px+),柔和卡片
2. 使用有机波浪形状 (`border-radius` 非对称) 作为装饰
3. 自然意象 Emoji (🌿🌱🍃🌍💚) 作为图标
4. 卡片使用暖色背景而非白色
5. 图片使用圆角裁剪或圆形遮罩
- **Animation Style**:`fade-up` 为主,延迟 250ms — 柔和舒缓的节奏。波浪装饰可用 `@keyframes wave` 轻微浮动。
- **DO**:
- ✅ 使用大地色系,远离冷蓝/冷灰
- ✅ 大圆角和有机形状
- ✅ 自然类 Emoji 点缀
- ✅ 图文结合,自然摄影优先
- **DON'T**:
- ❌ 不要使用纯黑文字(用深棕/深绿替代)
- ❌ 不要使用直角和锐利边框
- ❌ 不要使用霓虹色或荧光色
- ❌ 不要让数据图表过于冰冷(暖色系列)
---
## 十二、bold-impact(大胆冲击)
### Style: bold-impact
- **Display Name**:大胆冲击
- **Keywords**:超大字体、极简文字、高对比、宣言式、震撼
- **Best For**:融资路演核心页、品牌宣言、战略发布会、创业Pitch关键帧、广告Campaign
- **Aesthetic**:
"少到极致,大到惊人" — 每个页面只承载 1 个核心信息,用 72-96px 的超大字号占据画面 60% 以上面积。配色是极端的二元对立:黑底白字或白底黑字,加一个高饱和的强调色(如正红 `#FF003C` 或亮黄 `#FFEA00`)在唯一的关键词上爆发出全部能量。
这不是信息传递,这是视觉宣言。每一页都像一张独立的海报,合在一起就是一场视觉上的"重低音轰炸"。
- **Palette**:
- `bg`: `#000000`
- `primary`: `#000000`
- `secondary`: `#1A1A1A`
- `accent`: `#FF003C`
- `text`: `#FFFFFF`
- `muted`: `#888888`
- **Typography**:`Inter Black (900) + 微软雅黑 Bold` | `Inter Bold + 微软雅黑 Bold`
- **Layout Principles**:
1. 每页只放 1 个核心信息,字号 72-120px
2. 文字占据画面 60-80% 面积
3. 强调色仅用于 1-2 个关键词
4. 其他元素(Logo、页码)缩小至 12-14px 放在角落
5. 可以跨页拆分一个长句,形成叙事节奏
- **Animation Style**:`scale-in` 从 0 弹入 (0→1.05→1),延迟 100ms — 冲击力最大化。配合 `pop` 弹性动画于关键词。
- **DO**:
- ✅ 超大字号 (72px+) 是核心特征
- ✅ 每页 ≤ 10 个字
- ✅ 高对比度黑白 + 一个高饱和强调色
- ✅ 跨页叙事:一句话拆成多页
- **DON'T**:
- ❌ 不要在页面上放超过 15 个字
- ❌ 不要使用渐变背景
- ❌ 不要使用卡片、图表等复合元素
- ❌ 不要添加装饰性元素(字本身即是装饰)
---
## 十三、soft-pastel(柔和粉彩)
### Style: soft-pastel
- **Display Name**:柔和粉彩
- **Keywords**:马卡龙色、圆润、可爱、亲和、零攻击性
- **Best For**:亲子教育、女性社区、生活方式品牌、甜品/烘焙、幼儿园/早教
- **Aesthetic**:
马卡龙色系 — 樱花粉 (`#FFD1DC`)、薄荷绿 (`#B5EAD7`)、宝宝蓝 (`#C7CEEA`)、奶油黄 (`#FFF1B0`)、薰衣草紫 (`#E2D1F9`) — 柔和得仿佛加了柔光滤镜。所有元素都使用大圆角 (16-24px),卡片像棉花糖一样蓬松柔软,阴影轻柔如羽毛。
整体气质甜美可亲 — 没有任何尖锐的边角,没有任何刺眼的色彩,像走进一家精致的法式甜品店,一切都让人心情愉悦。
- **Palette**:
- `bg`: `#FFF9FB`
- `primary`: `#FFD1DC`
- `secondary`: `#FFEBF0`
- `accent`: `#FF8FAB`
- `text`: `#5D4E53`
- `muted`: `#C4A8B0`
- **Typography**:`Inter SemiBold + 微软雅黑` | `Inter Regular + 微软雅黑`
- **Layout Principles**:
1. 全圆角 (16-24px),无直角
2. 柔和渐变背景 (从粉到蓝到紫的极淡渐变)
3. 卡片使用蓬松的大 padding (40px+)
4. 装饰使用圆形、云朵形状
5. 柔和阴影:`box-shadow: 0 4px 20px rgba(255,143,171,0.15)`
- **Animation Style**:`fade-up` + 轻微 `scale-in` (0.95→1),延迟 250ms — 缓缓的、柔软的呈现。可配合 `@keyframes float` 轻微浮动。
- **DO**:
- ✅ 马卡龙色系,避免任何高饱和色
- ✅ 大圆角 + 柔和阴影
- ✅ 可爱风格 Emoji (🌸💕✨🎀🍰)
- ✅ 宽松排版,大量 padding
- **DON'T**:
- ❌ 不要使用纯黑文字(用柔和的深棕/深粉代替)
- ❌ 不要使用直角和锐利边框
- ❌ 不要让信息密度高
- ❌ 不要使用冷色调(保持暖粉基调)
---
## 十四、luxury-gold(奢华金)
### Style: luxury-gold
- **Display Name**:奢华金
- **Keywords**:奢华、金色、暗底、高级、专属
- **Best For**:高端品牌展示、VIP邀请函、奢侈品推介、私人会所、高端地产
- **Aesthetic**:
极暗的底色(近乎纯黑或深棕黑)之上,金色 (`#D4AF37` / `#C5A55A`) 是唯一的"光源"。衬线字体以金色呈现,搭配精细的金色分割线 (`1px solid gold`),如同一张烫金的高级信纸被放置在黑丝绒之上。
整体气质尊贵克制 — 不需要解释自己有多贵,金色本身就说明了一切。字距拉大 (`letter-spacing: 0.08em`),留白慷慨,每个字都值得被注视。
- **Palette**:
- `bg`: `#0D0D0D`
- `primary`: `#1A1A1A`
- `secondary`: `#222222`
- `accent`: `#D4AF37`
- `text`: `#E8E0D0`
- `muted`: `#8A8068`
- **Typography**:`Merriweather Bold + Noto Serif SC Bold` | `Merriweather Regular + 微软雅黑 Light`
- **Layout Principles**:
1. 极暗背景 (`#0D0D0D`) + 金色文字与装饰线
2. 衬线字体是必需条件
3. 金色分割线 (`1px solid #D4AF37`) 作为唯一点缀
4. 文字居中或非对称偏移,大面积留黑
5. 字距拉大 (`letter-spacing: 0.06em~0.12em`)
- **Animation Style**:`fade-in` 缓慢淡入,延迟 350ms — 如天鹅绒幕布缓缓拉开。金色元素可用 `@keyframes gold-shimmer` 模拟金属光泽流动。
- **DO**:
- ✅ 金色 (`#D4AF37`) 作为唯一强调色
- ✅ 衬线字体,字距拉大
- ✅ 极暗背景,大面积留黑
- ✅ 金色细线作为视觉装饰
- **DON'T**:
- ❌ 不要使用白色或浅色背景
- ❌ 不要使用无衬线字体
- ❌ 不要引入金色以外的彩色
- ❌ 不要让信息密度过高
---
## 十五、tech-startup(科技创业)
### Style: tech-startup
- **Display Name**:科技创业
- **Keywords**:现代、乐观、紫蓝渐变、年轻、SaaS
- **Best For**:创业路演、SaaS产品Demo、创新提案、增长报告、孵化器Demo Day
- **Aesthetic**:
明亮的白色背景上,一条从紫 (`#7C3AED`) 到蓝 (`#3B82F6`) 到青 (`#06B6D4`) 的渐变色带作为贯穿全篇的视觉线索 — 可以是标题下的强调条、卡片的顶部边框、关键数据的颜色或背景的装饰光晕。这种渐变配色自 2020 年代以来已成为"科技创业"的视觉代名词:乐观、成长、连接、未来。
整体气质年轻自信 — 像一家刚获得 A 轮融资的初创公司,PPT 里散发着"我们在改变世界"的能量。
- **Palette**:
- `bg`: `#FFFFFF`
- `primary`: `#7C3AED`
- `secondary`: `#F5F3FF`
- `accent`: `#06B6D4`
- `text`: `#1E1B4B`
- `muted`: `#94A3B8`
- **Typography**:`Inter Bold + 微软雅黑` | `Inter Regular + 微软雅黑`
- **Layout Principles**:
1. 白底 + 紫→蓝→青渐变作为贯穿主题的视觉线索
2. 卡片顶部/左侧使用渐变边框 (`border-top: 3px solid transparent; border-image: linear-gradient(...)`)
3. 关键数据使用渐变文字 (`background-clip: text`)
4. 装饰性渐变光晕圆形散布在角落
5. 右上角放置 Logo,页面底部渐变条收尾
- **Animation Style**:`fade-up` + `scale-in` 组合,延迟 150ms — 有活力但不急躁。封面可使用渐变流动动画 (`@keyframes gradient-shift`)。
- **DO**:
- ✅ 紫蓝青渐变色带作为视觉主线
- ✅ 白色背景保持干净
- ✅ 数据指标做大字号 + 渐变文字
- ✅ 使用 🚀⚡💡 等科技创业 Emoji
- **DON'T**:
- ❌ 不要使用暗色背景(区别于 dark-tech)
- ❌ 不要在渐变之外引入其他强色
- ❌ 不要让渐变过度使用(每页 ≤ 3 处渐变元素)
- ❌ 不要使用衬线字体
---
## 十六、cream-white(米白雅致)
### Style: cream-white
- **Display Name**:米白雅致
- **Keywords**:米白、温柔、雅致、纯净、日系
- **Best For**:生活美学提案、日系品牌展示、婚礼策划、高端下午茶、SPA/护肤品牌
- **Aesthetic**:
以温暖的米白色为基底,搭配奶茶棕和燕麦色,营造如同手工纸或亚麻布般的自然肌理感。整体色调如同冬日暖阳透过白纱窗帘洒在原木桌面 — 柔和、安静、有温度但不甜腻。没有刺眼的白,也没有沉闷的灰,每一个色块都经过"加一点暖"的处理。
排版宽松有致,卡片边缘如同手工撕纸般微微毛边,大量留白让文字呼吸。适合传递"品质感"和"匠心"而非"效率"和"数据"的场景。
- **Palette**:
- `bg`: `#FDF8F0`
- `primary`: `#C9A87C`
- `secondary`: `#F5EDE0`
- `accent`: `#B8860B`
- `text`: `#4A3728`
- `muted`: `#B0A090`
- **Typography**:`Georgia + 宋体` | `Inter Regular + 微软雅黑`
- **Layout Principles**:
1. 米白底色 (`#FDF8F0`) 带微暖底,避免冷白
2. 卡片背景使用比底色略深的暖灰 (`#F5EDE0`)
3. 细线装饰使用金色/棕色 (`#C9A87C`),如烫金线条
4. 大面积留白(≥ 50%),信息集中在中央或左侧
5. 图片使用暖色调滤镜或黑白+暖棕调色
- **Animation Style**:`fade-in` 缓慢淡入,延迟 300ms — 如轻纱缓落。静且慢,不急不躁。
- **DO**:
- ✅ 大面积米白+暖棕配色
- ✅ 衬线字体做标题(优雅感)
- ✅ 细金线/棕线做装饰分割
- ✅ 使用 🕯️🌾✨ 等温暖意象
- ✅ 图片加暖色叠加层 (`background-color: rgba(201,168,124,0.15)`)
- **DON'T**:
- ❌ 不要使用纯白 (`#FFFFFF`) 底色
- ❌ 不要使用冷色调(蓝/绿/紫)
- ❌ 不要使用霓虹色或高饱和色
- ❌ 不要让文字密度过高
---
## 十七、cream-green(奶油绿)
### Style: cream-green
- **Display Name**:奶油抹茶
- **Keywords**:抹茶绿、奶油、清新、自然、治愈
- **Best For**:健康餐食品牌、茶文化展示、瑜伽/冥想、有机农场、环保手作
- **Aesthetic**:
如同抹茶拿铁般的温柔配色 — 以奶油黄绿为底,抹茶绿为主色,搭配淡薄荷和燕麦白。整体色调清新但不刺眼,有绿色植物的活力但加了一层奶油滤镜变得柔和可亲。仿佛坐在阳光明媚的庭院里喝着抹茶,微风拂过绿植。
圆角是标配 (16-20px),卡片微微浮起,装饰元素以小叶片、圆点、细波纹为主。整个页面散发出"天然、健康、慢生活"的信号。
- **Palette**:
- `bg`: `#FAFAF0`
- `primary`: `#8B9E6B`
- `secondary`: `#EDF2E8`
- `accent`: `#D4A853`
- `text`: `#3D4A35`
- `muted`: `#9AAA8A`
- **Typography**:`Inter SemiBold + 微软雅黑` | `Inter Regular + 微软雅黑`
- **Layout Principles**:
1. 奶油黄绿底色 (`#FAFAF0`),带微妙绿意
2. 抹茶绿主色 (`#8B9E6B`) 用于标题、卡片标题栏
3. 淡奶油辅助色 (`#EDF2E8`) 用于卡片背景
4. 大圆角 (16-20px),柔和阴影
5. 装饰用小叶片 🍃 或圆点阵列
- **Animation Style**:`fade-up`,延迟 220ms — 轻柔上浮,如叶片轻落。
- **DO**:
- ✅ 抹茶绿+奶油底配色
- ✅ 大圆角+柔和阴影
- ✅ 自然意象装饰(🍃🌿🍵)
- ✅ 宽松排版,充足留白
- **DON'T**:
- ❌ 不要使用纯黑文字(用深绿代替)
- ❌ 不要使用高饱和绿(荧光绿/霓虹绿)
- ❌ 不要使用锐利边角和直角卡片
- ❌ 不要引入暖红/暖橙(破坏绿色基调)
---
## 十八、cinematic-teal-orange(电影感青橙)
### Style: cinematic-teal-orange
- **Display Name**:电影感青橙
- **Keywords**:青橙调色、电影感、对比、叙事、戏剧性
- **Best For**:品牌微电影提案、影视项目路演、创意故事叙述、摄影作品集、年度回顾
- **Aesthetic**:
灵感来自好莱坞 Teal & Orange 经典调色 — 暗青(深蓝绿)为背景底色,琥珀橙为强调高光,形成经典的互补色对撞。这种配色自《变形金刚》《疯狂的麦克斯》以来已成为"电影感"的视觉代名词:暗部偏青蓝,高光偏暖橙,画面自带戏剧张力和叙事感。
文字以暖白 (`#F5EDE0`) 反白在暗青色背景上,强调数据/标题用琥珀橙 (`#F7931E`) 点亮。装饰线条模仿电影画幅的宽银幕比例,分割线使用青到橙的渐变。
- **Palette**:
- `bg`: `#0D2628`
- `primary`: `#1A3C40`
- `secondary`: `#1E3335`
- `accent`: `#F7931E`
- `text`: `#F5EDE0`
- `muted`: `#7A8A8B`
- **Typography**:`Inter Bold + 微软雅黑` | `Inter Regular + 微软雅黑`
- **Layout Principles**:
1. 暗青底色 (`#0D2628`) + 暖白文字 (`#F5EDE0`)
2. 琥珀橙 (`#F7931E`) 用于关键强调 — 不超过 10% 面积
3. 宽银幕比例装饰条(顶部/底部 60px 高的暗色条)
4. 文字排版有电影字幕般的精致感 — 字距略大 (`letter-spacing: 0.03em`)
5. 卡片使用半透明暗色背景 (`rgba(26,60,64,0.6)`) + 细边框
- **Animation Style**:`fade-up` 为主,延迟 180ms — 序幕般徐徐展开。可使用 `wipe-right` 模拟电影画幅展开效果。
- **DO**:
- ✅ 暗青+琥珀橙互补色对撞
- ✅ 宽银幕装饰条(上/下黑边)
- ✅ 暖白文字反白在暗底上
- ✅ 标题字距略大 (`letter-spacing: 0.04em`)
- ✅ 使用 🎬🎥✨ 等电影意象
- **DON'T**:
- ❌ 不要使用白色或浅色背景
- ❌ 不要在橙色之外引入其他暖色(红/粉/紫)
- ❌ 不要使用衬线字体(不适合电影风格)
- ❌ 不要让信息密度过高(保持电影海报般的大气)
---
## 附录:风格选择决策树
```
用户场景是面向...
├── 企业/商务/正式汇报
│ ├── 数据重型 → corporate-clean
│ ├── 品牌/文化 → editorial-elegance
│ └── 高端/奢侈 → luxury-gold
├── 科技/互联网
│ ├── 深色/未来感 → dark-tech
│ ├── 创业/年轻 → tech-startup
│ ├── 游戏/Web3 → cyberpunk-neon
│ └── 理性/学术 → nord-cool
├── 温暖/人文/团队
│ ├── 专业但亲和 → warm-professional
│ └── 环保/自然 → natural-organic
├── 创意/设计/营销
│ ├── 大胆/冲击 → bold-impact
│ ├── 活力/多彩 → vibrant-creative
│ ├── 梦幻/层次 → glassmorphism
│ └── 极简/克制 → neo-minimal
├── 柔和/女性/生活
│ ├── 甜美/亲和 → soft-pastel
│ └── 优雅/品质 → cream-white
├── 中式/传统/文化
│ └── 东方/禅意 → chinese-ink
├── 自然/健康/环保
│ ├── 大地色/可持续 → natural-organic
│ └── 清新/治愈 → cream-green
└── 影视/叙事/创意
└── 戏剧性/电影感 → cinematic-teal-orange
```
---
@@ -0,0 +1,913 @@
# 05 — HTML 布局模式指南
> **角色**:HTML 路线专属布局参考手册 — 每种模式提供完整 HTML 模板(1280×720 画布)
> **读取时机**:HTML 路线 Executor 阶段规划每页布局时
> **参考来源**:oh-my-ppt 项目布局体系 + CSS 原生能力
---
## 通用 CSS 变量声明
所有模板共享以下 CSS 自定义属性(定义在 `<style>` 块中,按主题替换色值):
```css
:root {
--bg: #FAFBFC;
--primary: #1A73E8;
--secondary: #4285F4;
--accent: #FF6D00;
--text: #1F2937;
--muted: #6B7280;
--card-bg: #FFFFFF;
--card-shadow: 0 2px 12px rgba(0,0,0,0.08);
--border-color: #E5E7EB;
}
```
> 实际使用中根据 `spec_lock.md` 的 palette 替换色值。
---
## 一、anchor 页面(封面 / 过渡页,≤30 字)
### 1. Cover — Hero Centered(封面 — 大标题居中 + 底部色条)
```html
<div style="width:1280px;height:720px;position:relative;overflow:hidden;
background:var(--bg);font-family:'Microsoft YaHei','PingFang SC','Noto Sans SC',sans-serif;">
<style>
:root {
--bg: #FAFBFC; --primary: #1A73E8; --secondary: #4285F4;
--accent: #FF6D00; --text: #1F2937; --muted: #6B7280;
--card-bg: #FFFFFF; --card-shadow: 0 2px 12px rgba(0,0,0,0.08);
--border-color: #E5E7EB;
}
</style>
<!-- 主标题 & 副标题 -->
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;
height:580px;text-align:center;">
<h1 data-anim="fade-up" data-anim-duration="0.8s"
style="font-size:48px;font-weight:bold;color:var(--text);margin:0;letter-spacing:0.02em;">
主标题在此
</h1>
<p data-anim="fade-up" data-anim-delay="0.2s"
style="font-size:24px;color:var(--muted);margin:16px 0 0 0;">
副标题或一句话描述
</p>
</div>
<!-- 底部装饰色条 -->
<div data-anim="wipe-right" data-anim-duration="0.8s"
style="position:absolute;bottom:0;left:0;width:1280px;height:140px;
background:linear-gradient(135deg,var(--primary),var(--secondary));">
<p style="position:absolute;bottom:24px;left:80px;font-size:14px;color:rgba(255,255,255,0.85);margin:0;"
data-anim="fade-up" data-anim-delay="0.6s">
作者 / 日期
</p>
</div>
</div>
```
**设计要点**:
- 标题 48px Bold,副标题 24px muted
- 底部色条 140px 高,用 `linear-gradient` 主色→辅色
- data-anim 顺序:标题 → 副标题(+0.2s) → 作者(+0.6s),色条用 `wipe-right`
---
### 2. Cover — Split(封面 — 左文右图)
```html
<div style="width:1280px;height:720px;position:relative;overflow:hidden;
background:var(--bg);font-family:'Microsoft YaHei','PingFang SC','Noto Sans SC',sans-serif;">
<style>
:root {
--bg: #FAFBFC; --primary: #1A73E8; --secondary: #4285F4;
--accent: #FF6D00; --text: #1F2937; --muted: #6B7280;
--card-bg: #FFFFFF; --card-shadow: 0 2px 12px rgba(0,0,0,0.08);
--border-color: #E5E7EB;
}
</style>
<!-- 左侧文字区 580px -->
<div style="position:absolute;top:0;left:0;width:580px;height:720px;
display:flex;flex-direction:column;justify-content:center;padding-left:80px;">
<h1 data-anim="slide-right" data-anim-duration="0.7s"
style="font-size:48px;font-weight:bold;color:var(--text);margin:0 0 16px 0;">
主标题
</h1>
<p data-anim="slide-right" data-anim-delay="0.15s"
style="font-size:24px;color:var(--muted);margin:0 0 32px 0;">
副标题内容
</p>
<div data-anim="fade-up" data-anim-delay="0.4s">
<p style="font-size:14px;color:var(--muted);margin:0;">作者姓名</p>
<p style="font-size:14px;color:var(--muted);margin:4px 0 0 0;">2026-07</p>
</div>
</div>
<!-- 右侧图片/装饰区 700px -->
<div data-anim="fade-up" data-anim-delay="0.3s"
style="position:absolute;top:0;right:0;width:700px;height:720px;
background:linear-gradient(135deg,var(--primary) 0%,var(--secondary) 100%);">
<div style="display:flex;align-items:center;justify-content:center;height:100%;">
<span style="font-size:120px;opacity:0.3;color:#FFFFFF;">◆</span>
</div>
</div>
</div>
```
**设计要点**:
- 左侧 580px 文字区,右侧 700px 图片/装饰区
- 文字区用 flex 垂直居中,`slide-right` 动效
- 右侧可用纯色渐变 + 装饰图标,或替换为 `<img>` 背景图
---
### 3. Section Divider(章节过渡页)
```html
<div style="width:1280px;height:720px;position:relative;overflow:hidden;
background:var(--bg);font-family:'Microsoft YaHei','PingFang SC','Noto Sans SC',sans-serif;">
<style>
:root {
--bg: #FAFBFC; --primary: #1A73E8; --secondary: #4285F4;
--accent: #FF6D00; --text: #1F2937; --muted: #6B7280;
--card-bg: #FFFFFF; --card-shadow: 0 2px 12px rgba(0,0,0,0.08);
--border-color: #E5E7EB;
}
</style>
<div style="display:flex;flex-direction:column;justify-content:center;
height:720px;padding-left:160px;">
<!-- 章节编号 — 大字 muted -->
<span data-anim="fade-up"
style="font-size:64px;font-weight:bold;color:var(--muted);opacity:0.15;margin-bottom:8px;">
02
</span>
<!-- 章节标题 -->
<h1 data-anim="fade-up" data-anim-delay="0.15s"
style="font-size:42px;font-weight:bold;color:var(--text);margin:0 0 12px 0;">
章节标题
</h1>
<!-- 装饰线 -->
<div data-anim="wipe-right" data-anim-delay="0.35s" data-anim-duration="0.6s"
style="width:200px;height:4px;background:var(--primary);border-radius:2px;margin-bottom:16px;"></div>
<!-- 副标题 -->
<p data-anim="fade-up" data-anim-delay="0.5s"
style="font-size:24px;color:var(--muted);margin:0;">
章节副标题或简要说明
</p>
</div>
</div>
```
**设计要点**:
- 章节编号 64px、opacity:0.15 做背景大字
- 装饰线 200×4px,用 `wipe-right` 揭示动效
- 左侧 160px padding 留白,整体靠左对齐
---
## 二、dense 页面(内容页,≤80 字)
### 4. 2×2 Cards(四宫格卡片)
```html
<div style="width:1280px;height:720px;position:relative;overflow:hidden;
background:var(--bg);font-family:'Microsoft YaHei','PingFang SC','Noto Sans SC',sans-serif;">
<style>
:root {
--bg: #FAFBFC; --primary: #1A73E8; --secondary: #4285F4;
--accent: #FF6D00; --text: #1F2937; --muted: #6B7280;
--card-bg: #FFFFFF; --card-shadow: 0 2px 12px rgba(0,0,0,0.08);
--border-color: #E5E7EB;
}
</style>
<!-- 页面标题 -->
<h2 data-anim="fade-up"
style="position:absolute;top:40px;left:80px;font-size:32px;font-weight:bold;
color:var(--text);margin:0;">
页面标题
</h2>
<!-- 2×2 卡片网格 -->
<div data-anim-stagger
style="position:absolute;top:110px;left:80px;width:1120px;
display:grid;grid-template-columns:repeat(2,548px);
grid-template-rows:repeat(2,260px);gap:24px;">
<!-- 卡片 1 -->
<div data-anim="fade-up"
style="background:var(--card-bg);border-radius:16px;box-shadow:var(--card-shadow);
padding:32px;display:flex;flex-direction:column;">
<div style="width:48px;height:48px;border-radius:12px;background:var(--primary);
display:flex;align-items:center;justify-content:center;margin-bottom:20px;">
<span style="font-size:24px;color:#FFFFFF;">📊</span>
</div>
<h3 style="font-size:20px;font-weight:bold;color:var(--text);margin:0 0 12px 0;">卡片标题 1</h3>
<p style="font-size:16px;color:var(--muted);margin:0;line-height:1.6;">
卡片内容描述文字,简要说明此板块的核心要点与关键信息。
</p>
</div>
<!-- 卡片 2 -->
<div data-anim="fade-up"
style="background:var(--card-bg);border-radius:16px;box-shadow:var(--card-shadow);
padding:32px;display:flex;flex-direction:column;">
<div style="width:48px;height:48px;border-radius:12px;background:var(--secondary);
display:flex;align-items:center;justify-content:center;margin-bottom:20px;">
<span style="font-size:24px;color:#FFFFFF;">🚀</span>
</div>
<h3 style="font-size:20px;font-weight:bold;color:var(--text);margin:0 0 12px 0;">卡片标题 2</h3>
<p style="font-size:16px;color:var(--muted);margin:0;line-height:1.6;">
卡片内容描述文字,简要说明此板块的核心要点与关键信息。
</p>
</div>
<!-- 卡片 3 -->
<div data-anim="fade-up"
style="background:var(--card-bg);border-radius:16px;box-shadow:var(--card-shadow);
padding:32px;display:flex;flex-direction:column;">
<div style="width:48px;height:48px;border-radius:12px;background:var(--accent);
display:flex;align-items:center;justify-content:center;margin-bottom:20px;">
<span style="font-size:24px;color:#FFFFFF;">⚡</span>
</div>
<h3 style="font-size:20px;font-weight:bold;color:var(--text);margin:0 0 12px 0;">卡片标题 3</h3>
<p style="font-size:16px;color:var(--muted);margin:0;line-height:1.6;">
卡片内容描述文字,简要说明此板块的核心要点与关键信息。
</p>
</div>
<!-- 卡片 4 -->
<div data-anim="fade-up"
style="background:var(--card-bg);border-radius:16px;box-shadow:var(--card-shadow);
padding:32px;display:flex;flex-direction:column;">
<div style="width:48px;height:48px;border-radius:12px;background:var(--primary);
display:flex;align-items:center;justify-content:center;margin-bottom:20px;">
<span style="font-size:24px;color:#FFFFFF;">🔒</span>
</div>
<h3 style="font-size:20px;font-weight:bold;color:var(--text);margin:0 0 12px 0;">卡片标题 4</h3>
<p style="font-size:16px;color:var(--muted);margin:0;line-height:1.6;">
卡片内容描述文字,简要说明此板块的核心要点与关键信息。
</p>
</div>
</div>
</div>
```
**设计要点**:
- 卡片 548×260,24px 间隙,border-radius:16px
- 图标区 48×48,border-radius:12px
- `data-anim-stagger` 让 4 张卡片依次淡入(50ms 间隔)
- 卡片内边距 32px,标题 20px Bold,正文 16px
---
### 5. KPI Dashboard(3×1 KPI 指标 + 底部图表)
```html
<div style="width:1280px;height:720px;position:relative;overflow:hidden;
background:var(--bg);font-family:'Microsoft YaHei','PingFang SC','Noto Sans SC',sans-serif;">
<style>
:root {
--bg: #FAFBFC; --primary: #1A73E8; --secondary: #4285F4;
--accent: #FF6D00; --text: #1F2937; --muted: #6B7280;
--card-bg: #FFFFFF; --card-shadow: 0 2px 12px rgba(0,0,0,0.08);
--border-color: #E5E7EB;
}
</style>
<!-- 页面标题 -->
<h2 data-anim="fade-up"
style="position:absolute;top:40px;left:80px;font-size:32px;font-weight:bold;
color:var(--text);margin:0;">
核心指标概览
</h2>
<!-- 3 个 KPI 卡片 -->
<div data-anim-stagger
style="position:absolute;top:110px;left:80px;width:1120px;
display:flex;gap:40px;">
<!-- KPI 1 -->
<div data-anim="scale-in"
style="flex:1;height:120px;background:var(--card-bg);border-radius:16px;
box-shadow:var(--card-shadow);padding:24px 32px;
display:flex;flex-direction:column;justify-content:center;
border-left:4px solid var(--primary);">
<span style="font-size:36px;font-weight:bold;color:var(--primary);">+23.5%</span>
<span style="font-size:14px;color:var(--muted);margin-top:8px;">同比增长率</span>
</div>
<!-- KPI 2 -->
<div data-anim="scale-in"
style="flex:1;height:120px;background:var(--card-bg);border-radius:16px;
box-shadow:var(--card-shadow);padding:24px 32px;
display:flex;flex-direction:column;justify-content:center;
border-left:4px solid var(--secondary);">
<span style="font-size:36px;font-weight:bold;color:var(--secondary);">1,856</span>
<span style="font-size:14px;color:var(--muted);margin-top:8px;">活跃项目数</span>
</div>
<!-- KPI 3 -->
<div data-anim="scale-in"
style="flex:1;height:120px;background:var(--card-bg);border-radius:16px;
box-shadow:var(--card-shadow);padding:24px 32px;
display:flex;flex-direction:column;justify-content:center;
border-left:4px solid var(--accent);">
<span style="font-size:36px;font-weight:bold;color:var(--accent);">8.5/10</span>
<span style="font-size:14px;color:var(--muted);margin-top:8px;">客户满意度</span>
</div>
</div>
<!-- 底部图表区域 -->
<div data-anim="fade-up" data-anim-delay="0.4s"
style="position:absolute;top:270px;left:80px;width:1120px;height:400px;
background:var(--card-bg);border-radius:16px;box-shadow:var(--card-shadow);
display:flex;align-items:center;justify-content:center;">
<img src="./chart_1.png"
style="width:1080px;height:360px;object-fit:contain;" alt="趋势图表">
</div>
</div>
</div>
```
**设计要点**:
- KPI 卡片 380×120,40px 间距,左侧 4px 彩色边框做视觉锚点
- 数值 36px Bold 彩色,标签 14px muted
- 底部图表区 1120×400,内嵌图表图片
- 卡片用 `scale-in` 动效,`data-anim-stagger` 依次出现
---
### 6. Left Text / Right Image(左文右图)
```html
<div style="width:1280px;height:720px;position:relative;overflow:hidden;
background:var(--bg);font-family:'Microsoft YaHei','PingFang SC','Noto Sans SC',sans-serif;">
<style>
:root {
--bg: #FAFBFC; --primary: #1A73E8; --secondary: #4285F4;
--accent: #FF6D00; --text: #1F2937; --muted: #6B7280;
--card-bg: #FFFFFF; --card-shadow: 0 2px 12px rgba(0,0,0,0.08);
--border-color: #E5E7EB;
}
</style>
<!-- 页面标题 -->
<h2 data-anim="fade-up"
style="position:absolute;top:40px;left:80px;font-size:32px;font-weight:bold;
color:var(--text);margin:0;">
页面标题
</h2>
<!-- 左侧文字区 -->
<div data-anim-stagger
style="position:absolute;top:120px;left:80px;width:540px;">
<div data-anim="slide-right"
style="display:flex;align-items:flex-start;margin-bottom:28px;">
<span style="flex-shrink:0;width:8px;height:8px;border-radius:50%;background:var(--primary);
margin-top:6px;margin-right:16px;"></span>
<div>
<h3 style="font-size:18px;font-weight:bold;color:var(--text);margin:0 0 6px 0;">要点标题 1</h3>
<p style="font-size:16px;color:var(--muted);margin:0;line-height:1.6;">
要点描述文字,展开说明此要点的具体内容与细节。
</p>
</div>
</div>
<div data-anim="slide-right"
style="display:flex;align-items:flex-start;margin-bottom:28px;">
<span style="flex-shrink:0;width:8px;height:8px;border-radius:50%;background:var(--secondary);
margin-top:6px;margin-right:16px;"></span>
<div>
<h3 style="font-size:18px;font-weight:bold;color:var(--text);margin:0 0 6px 0;">要点标题 2</h3>
<p style="font-size:16px;color:var(--muted);margin:0;line-height:1.6;">
要点描述文字,展开说明此要点的具体内容与细节。
</p>
</div>
</div>
<div data-anim="slide-right"
style="display:flex;align-items:flex-start;margin-bottom:28px;">
<span style="flex-shrink:0;width:8px;height:8px;border-radius:50%;background:var(--accent);
margin-top:6px;margin-right:16px;"></span>
<div>
<h3 style="font-size:18px;font-weight:bold;color:var(--text);margin:0 0 6px 0;">要点标题 3</h3>
<p style="font-size:16px;color:var(--muted);margin:0;line-height:1.6;">
要点描述文字,展开说明此要点的具体内容与细节。
</p>
</div>
</div>
</div>
<!-- 右侧图片区 -->
<div data-anim="fade-up" data-anim-delay="0.3s"
style="position:absolute;top:120px;right:60px;width:560px;height:520px;
border-radius:16px;overflow:hidden;box-shadow:var(--card-shadow);">
<img src="./image_1.png"
style="width:100%;height:100%;object-fit:cover;" alt="配图">
</div>
</div>
</div>
```
**设计要点**:
- 左侧文字区 540px,右侧图片 560×520(含 60px 右边距)
- 要点用 8px 圆点 + 标题(18px) + 描述(16px)的组合
- 图片容器用 `border-radius:16px;overflow:hidden;` 做圆角裁剪
- 文字用 `data-anim-stagger` + `slide-right` 依次滑入
---
### 7. Top Image / Bottom Text(上图下文)
```html
<div style="width:1280px;height:720px;position:relative;overflow:hidden;
background:var(--bg);font-family:'Microsoft YaHei','PingFang SC','Noto Sans SC',sans-serif;">
<style>
:root {
--bg: #FAFBFC; --primary: #1A73E8; --secondary: #4285F4;
--accent: #FF6D00; --text: #1F2937; --muted: #6B7280;
--card-bg: #FFFFFF; --card-shadow: 0 2px 12px rgba(0,0,0,0.08);
--border-color: #E5E7EB;
}
</style>
<!-- 顶部图片区 -->
<div data-anim="fade-up"
style="position:absolute;top:0;left:0;width:1280px;height:360px;
overflow:hidden;background:var(--border-color);">
<img src="./image_1.png"
style="width:100%;height:100%;object-fit:cover;" alt="配图">
</div>
<!-- 底部文字区 -->
<div style="position:absolute;top:400px;left:0;width:1280px;padding:0 80px;">
<h2 data-anim="slide-right"
style="font-size:32px;font-weight:bold;color:var(--text);margin:0 0 24px 0;">
页面标题
</h2>
<!-- 三列要点 -->
<div data-anim-stagger
style="display:flex;gap:32px;">
<div data-anim="fade-up"
style="flex:1;background:var(--card-bg);border-radius:12px;
box-shadow:var(--card-shadow);padding:24px;">
<h3 style="font-size:18px;font-weight:bold;color:var(--primary);margin:0 0 8px 0;">
要点 1
</h3>
<p style="font-size:15px;color:var(--muted);margin:0;line-height:1.5;">
相关描述内容,简洁明了。
</p>
</div>
<div data-anim="fade-up"
style="flex:1;background:var(--card-bg);border-radius:12px;
box-shadow:var(--card-shadow);padding:24px;">
<h3 style="font-size:18px;font-weight:bold;color:var(--secondary);margin:0 0 8px 0;">
要点 2
</h3>
<p style="font-size:15px;color:var(--muted);margin:0;line-height:1.5;">
相关描述内容,简洁明了。
</p>
</div>
<div data-anim="fade-up"
style="flex:1;background:var(--card-bg);border-radius:12px;
box-shadow:var(--card-shadow);padding:24px;">
<h3 style="font-size:18px;font-weight:bold;color:var(--accent);margin:0 0 8px 0;">
要点 3
</h3>
<p style="font-size:15px;color:var(--muted);margin:0;line-height:1.5;">
相关描述内容,简洁明了。
</p>
</div>
</div>
</div>
</div>
</div>
```
**设计要点**:
- 图片区 1280×360(占画布 50%高度),`object-fit:cover`
- 下方文字区 320px 高度,标题 32px + 三列卡片
- 卡片 border-radius:12px,padding:24px,15px 正文
---
### 8. 3-Column Feature(三列特性展示)
```html
<div style="width:1280px;height:720px;position:relative;overflow:hidden;
background:var(--bg);font-family:'Microsoft YaHei','PingFang SC','Noto Sans SC',sans-serif;">
<style>
:root {
--bg: #FAFBFC; --primary: #1A73E8; --secondary: #4285F4;
--accent: #FF6D00; --text: #1F2937; --muted: #6B7280;
--card-bg: #FFFFFF; --card-shadow: 0 2px 12px rgba(0,0,0,0.08);
--border-color: #E5E7EB;
}
</style>
<!-- 页面标题 -->
<h2 data-anim="fade-up"
style="position:absolute;top:52px;left:50%;transform:translateX(-50%);
font-size:32px;font-weight:bold;color:var(--text);margin:0;text-align:center;">
核心特性
</h2>
<!-- 三列 -->
<div data-anim-stagger
style="position:absolute;top:160px;left:0;width:1280px;
display:flex;justify-content:center;gap:40px;padding:0 80px;">
<!-- 列 1 -->
<div data-anim="fade-up"
style="flex:0 0 340px;display:flex;flex-direction:column;align-items:center;text-align:center;">
<div style="width:80px;height:80px;border-radius:20px;background:var(--primary);
display:flex;align-items:center;justify-content:center;margin-bottom:24px;">
<span style="font-size:36px;">🏆</span>
</div>
<h3 style="font-size:22px;font-weight:bold;color:var(--text);margin:0 0 12px 0;">特性标题</h3>
<p style="font-size:16px;color:var(--muted);margin:0;line-height:1.7;">
关于此特性的详细描述,说明其价值与使用场景,让观众快速理解核心优势。
</p>
</div>
<!-- 列 2 -->
<div data-anim="fade-up"
style="flex:0 0 340px;display:flex;flex-direction:column;align-items:center;text-align:center;">
<div style="width:80px;height:80px;border-radius:20px;background:var(--secondary);
display:flex;align-items:center;justify-content:center;margin-bottom:24px;">
<span style="font-size:36px;">⚙️</span>
</div>
<h3 style="font-size:22px;font-weight:bold;color:var(--text);margin:0 0 12px 0;">特性标题</h3>
<p style="font-size:16px;color:var(--muted);margin:0;line-height:1.7;">
关于此特性的详细描述,说明其价值与使用场景,让观众快速理解核心优势。
</p>
</div>
<!-- 列 3 -->
<div data-anim="fade-up"
style="flex:0 0 340px;display:flex;flex-direction:column;align-items:center;text-align:center;">
<div style="width:80px;height:80px;border-radius:20px;background:var(--accent);
display:flex;align-items:center;justify-content:center;margin-bottom:24px;">
<span style="font-size:36px;">🔐</span>
</div>
<h3 style="font-size:22px;font-weight:bold;color:var(--text);margin:0 0 12px 0;">特性标题</h3>
<p style="font-size:16px;color:var(--muted);margin:0;line-height:1.7;">
关于此特性的详细描述,说明其价值与使用场景,让观众快速理解核心优势。
</p>
</div>
</div>
</div>
</div>
```
**设计要点**:
- 每列 340px 宽,40px 间距,`flex:0 0 340px` 防止收缩
- 图标区 80×80,border-radius:20px,彩色背景 + emoji 图标
- 标题居中、描述居中,22px 标题 + 16px 正文
- `data-anim-stagger` 控制三列依次出现
---
### 9. Timeline(时间线)
```html
<div style="width:1280px;height:720px;position:relative;overflow:hidden;
background:var(--bg);font-family:'Microsoft YaHei','PingFang SC','Noto Sans SC',sans-serif;">
<style>
:root {
--bg: #FAFBFC; --primary: #1A73E8; --secondary: #4285F4;
--accent: #FF6D00; --text: #1F2937; --muted: #6B7280;
--card-bg: #FFFFFF; --card-shadow: 0 2px 12px rgba(0,0,0,0.08);
--border-color: #E5E7EB;
}
</style>
<!-- 页面标题 -->
<h2 data-anim="fade-up"
style="position:absolute;top:40px;left:80px;font-size:32px;font-weight:bold;
color:var(--text);margin:0;">
发展历程
</h2>
<!-- 时间线容器 -->
<div data-anim-stagger
style="position:absolute;top:140px;left:140px;width:1060px;">
<!-- 节点 1 -->
<div data-anim="slide-left" style="display:flex;align-items:flex-start;margin-bottom:32px;">
<!-- 时间标识 -->
<div style="flex-shrink:0;width:100px;text-align:right;padding-right:24px;">
<span style="font-size:16px;font-weight:bold;color:var(--primary);">2022 Q1</span>
</div>
<!-- 时间线枢纽 -->
<div style="flex-shrink:0;display:flex;flex-direction:column;align-items:center;margin-right:24px;">
<div style="width:16px;height:16px;border-radius:50%;background:var(--primary);
border:3px solid var(--bg);box-shadow:0 0 0 2px var(--primary);"></div>
<div style="width:2px;flex:1;min-height:60px;background:var(--border-color);"></div>
</div>
<!-- 内容卡片 -->
<div style="flex:1;background:var(--card-bg);border-radius:12px;
box-shadow:var(--card-shadow);padding:20px 24px;">
<h3 style="font-size:18px;font-weight:bold;color:var(--text);margin:0 0 6px 0;">里程碑标题</h3>
<p style="font-size:15px;color:var(--muted);margin:0;">关键事件描述,发生了什么以及带来的影响。</p>
</div>
</div>
<!-- 节点 2 -->
<div data-anim="slide-left" style="display:flex;align-items:flex-start;margin-bottom:32px;">
<div style="flex-shrink:0;width:100px;text-align:right;padding-right:24px;">
<span style="font-size:16px;font-weight:bold;color:var(--secondary);">2023 Q3</span>
</div>
<div style="flex-shrink:0;display:flex;flex-direction:column;align-items:center;margin-right:24px;">
<div style="width:16px;height:16px;border-radius:50%;background:var(--secondary);
border:3px solid var(--bg);box-shadow:0 0 0 2px var(--secondary);"></div>
<div style="width:2px;flex:1;min-height:60px;background:var(--border-color);"></div>
</div>
<div style="flex:1;background:var(--card-bg);border-radius:12px;
box-shadow:var(--card-shadow);padding:20px 24px;">
<h3 style="font-size:18px;font-weight:bold;color:var(--text);margin:0 0 6px 0;">里程碑标题</h3>
<p style="font-size:15px;color:var(--muted);margin:0;">关键事件描述,发生了什么以及带来的影响。</p>
</div>
</div>
<!-- 节点 3 -->
<div data-anim="slide-left" style="display:flex;align-items:flex-start;margin-bottom:32px;">
<div style="flex-shrink:0;width:100px;text-align:right;padding-right:24px;">
<span style="font-size:16px;font-weight:bold;color:var(--accent);">2025 Q1</span>
</div>
<div style="flex-shrink:0;display:flex;flex-direction:column;align-items:center;margin-right:24px;">
<div style="width:16px;height:16px;border-radius:50%;background:var(--accent);
border:3px solid var(--bg);box-shadow:0 0 0 2px var(--accent);"></div>
<div style="width:2px;flex:1;min-height:60px;background:var(--border-color);"></div>
</div>
<div style="flex:1;background:var(--card-bg);border-radius:12px;
box-shadow:var(--card-shadow);padding:20px 24px;">
<h3 style="font-size:18px;font-weight:bold;color:var(--text);margin:0 0 6px 0;">里程碑标题</h3>
<p style="font-size:15px;color:var(--muted);margin:0;">关键事件描述,发生了什么以及带来的影响。</p>
</div>
</div>
</div>
</div>
</div>
```
**设计要点**:
- 垂直时间线,时间标签(100px) → 圆点枢纽(16px) → 连接线(2px) → 内容卡片
- 每个节点一个彩色圆点,border + box-shadow 实现双环效果
- 卡片 border-radius:12px,padding:20px 24px
- `data-anim-stagger` + `slide-left` 动效
---
### 10. Comparison(2-Column 对比)
```html
<div style="width:1280px;height:720px;position:relative;overflow:hidden;
background:var(--bg);font-family:'Microsoft YaHei','PingFang SC','Noto Sans SC',sans-serif;">
<style>
:root {
--bg: #FAFBFC; --primary: #1A73E8; --secondary: #4285F4;
--accent: #FF6D00; --text: #1F2937; --muted: #6B7280;
--card-bg: #FFFFFF; --card-shadow: 0 2px 12px rgba(0,0,0,0.08);
--border-color: #E5E7EB;
}
</style>
<!-- 页面标题 -->
<h2 data-anim="fade-up"
style="position:absolute;top:40px;left:50%;transform:translateX(-50%);
font-size:32px;font-weight:bold;color:var(--text);margin:0;">
方案对比
</h2>
<!-- 两列对比 -->
<div style="position:absolute;top:120px;left:80px;width:1120px;display:flex;gap:40px;">
<!-- 方案 A -->
<div data-anim="slide-right" data-anim-duration="0.7s"
style="flex:1;background:var(--card-bg);border-radius:16px;
box-shadow:var(--card-shadow);overflow:hidden;">
<!-- 方案头部 -->
<div style="background:linear-gradient(135deg,var(--primary),var(--secondary));
padding:28px 32px;text-align:center;">
<h3 style="font-size:22px;font-weight:bold;color:#FFFFFF;margin:0;">方案 A</h3>
<p style="font-size:14px;color:rgba(255,255,255,0.8);margin:6px 0 0 0;">传统方案</p>
</div>
<!-- 方案内容 -->
<div style="padding:32px;">
<div style="display:flex;align-items:center;margin-bottom:20px;">
<span style="color:var(--primary);font-size:20px;margin-right:12px;">✓</span>
<span style="font-size:16px;color:var(--text);">优势点描述 1</span>
</div>
<div style="display:flex;align-items:center;margin-bottom:20px;">
<span style="color:var(--primary);font-size:20px;margin-right:12px;">✓</span>
<span style="font-size:16px;color:var(--text);">优势点描述 2</span>
</div>
<div style="display:flex;align-items:center;margin-bottom:20px;">
<span style="color:#DC2626;font-size:20px;margin-right:12px;">✗</span>
<span style="font-size:16px;color:var(--muted);">劣势点描述 1</span>
</div>
<div style="display:flex;align-items:center;margin-bottom:20px;">
<span style="color:#DC2626;font-size:20px;margin-right:12px;">✗</span>
<span style="font-size:16px;color:var(--muted);">劣势点描述 2</span>
</div>
</div>
</div>
<!-- 方案 B -->
<div data-anim="slide-left" data-anim-duration="0.7s" data-anim-delay="0.2s"
style="flex:1;background:var(--card-bg);border-radius:16px;
box-shadow:var(--card-shadow);overflow:hidden;">
<!-- 方案头部 -->
<div style="background:linear-gradient(135deg,var(--accent),#F57C00);
padding:28px 32px;text-align:center;">
<h3 style="font-size:22px;font-weight:bold;color:#FFFFFF;margin:0;">方案 B</h3>
<p style="font-size:14px;color:rgba(255,255,255,0.8);margin:6px 0 0 0;">推荐方案</p>
</div>
<!-- 方案内容 -->
<div style="padding:32px;">
<div style="display:flex;align-items:center;margin-bottom:20px;">
<span style="color:var(--accent);font-size:20px;margin-right:12px;">✓</span>
<span style="font-size:16px;color:var(--text);">优势点描述 1</span>
</div>
<div style="display:flex;align-items:center;margin-bottom:20px;">
<span style="color:var(--accent);font-size:20px;margin-right:12px;">✓</span>
<span style="font-size:16px;color:var(--text);">优势点描述 2</span>
</div>
<div style="display:flex;align-items:center;margin-bottom:20px;">
<span style="color:var(--accent);font-size:20px;margin-right:12px;">✓</span>
<span style="font-size:16px;color:var(--text);">优势点描述 3</span>
</div>
<div style="display:flex;align-items:center;margin-bottom:20px;">
<span style="color:var(--accent);font-size:20px;margin-right:12px;">✓</span>
<span style="font-size:16px;color:var(--text);">优势点描述 4</span>
</div>
</div>
</div>
</div>
<!-- VS 分隔标识 -->
<div data-anim="scale-in" data-anim-delay="0.5s"
style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);
width:56px;height:56px;border-radius:50%;background:var(--text);
display:flex;align-items:center;justify-content:center;
box-shadow:0 4px 16px rgba(0,0,0,0.2);">
<span style="font-size:18px;font-weight:bold;color:#FFFFFF;">VS</span>
</div>
</div>
</div>
```
**设计要点**:
- 两列均分 1120px 宽度,40px 间隙
- 每列头部用渐变背景(28px padding),下方 32px padding 内容区
- ✓/✗ 标记用彩色,✗ 用 #DC2626 红色
- 中间圆形 VS 标识,`scale-in` 动效增强对比感
- 两列分别用 `slide-right` / `slide-left` 动画从两侧滑入
---
## 三、breathing 页面(过渡 / 金句,≤20 字)
### 11. Quote / Centered(居中金句)
```html
<div style="width:1280px;height:720px;position:relative;overflow:hidden;
background:var(--bg);font-family:'Noto Serif SC','Georgia','Times New Roman',serif;">
<style>
:root {
--bg: #FAFBFC; --primary: #1A73E8; --secondary: #4285F4;
--accent: #FF6D00; --text: #1F2937; --muted: #6B7280;
--card-bg: #FFFFFF; --card-shadow: 0 2px 12px rgba(0,0,0,0.08);
--border-color: #E5E7EB;
}
</style>
<!-- 左侧装饰线 -->
<div data-anim="wipe-right" data-anim-duration="0.6s"
style="position:absolute;top:50%;left:160px;width:4px;height:0;
transform:translateY(-50%);"></div>
<!-- 引号装饰 -->
<div data-anim="fade-in" data-anim-delay="0.2s"
style="position:absolute;top:160px;left:50%;transform:translateX(-50%);
font-size:120px;color:var(--primary);opacity:0.08;line-height:1;">
&ldquo;
</div>
<!-- 核心金句 -->
<div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);
text-align:center;max-width:960px;">
<p data-anim="fade-up" data-anim-duration="0.8s"
style="font-size:36px;font-weight:bold;color:var(--text);margin:0;line-height:1.6;
font-family:'Microsoft YaHei','PingFang SC','Noto Sans SC',sans-serif;">
核心金句或关键结论,简短而有力。
</p>
<p data-anim="fade-up" data-anim-delay="0.4s"
style="font-size:18px;color:var(--muted);margin:24px 0 0 0;
font-family:'Microsoft YaHei',sans-serif;">
&mdash; 来源或作者
</p>
</div>
</div>
</div>
```
**设计要点**:
- 衬线字体 `Noto Serif SC` 增强文艺感
- 超大引号 120px opacity:0.08 做背景装饰
- 金句 36px Bold,来源 18px muted
- 极简装饰,核心靠字重与间距传递力量感
---
### 12. Big Number + Label(大字 + 标签)
```html
<div style="width:1280px;height:720px;position:relative;overflow:hidden;
background:var(--bg);font-family:'Microsoft YaHei','PingFang SC','Noto Sans SC',sans-serif;">
<style>
:root {
--bg: #FAFBFC; --primary: #1A73E8; --secondary: #4285F4;
--accent: #FF6D00; --text: #1F2937; --muted: #6B7280;
--card-bg: #FFFFFF; --card-shadow: 0 2px 12px rgba(0,0,0,0.08);
--border-color: #E5E7EB;
}
</style>
<!-- 背景装饰圆 -->
<div data-anim="scale-in" data-anim-duration="1s"
style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);
width:360px;height:360px;border-radius:50%;
background:radial-gradient(circle,var(--primary) 0%,transparent 70%);
opacity:0.08;"></div>
<!-- 大字 + 描述 -->
<div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);
text-align:center;">
<p data-anim="scale-in" data-anim-delay="0.2s"
style="font-size:96px;font-weight:bold;color:var(--primary);margin:0;line-height:1;">
86%
</p>
<p data-anim="fade-up" data-anim-delay="0.5s"
style="font-size:24px;color:var(--muted);margin:16px 0 0 0;letter-spacing:0.05em;">
关键指标标签描述
</p>
</div>
</div>
</div>
```
**设计要点**:
- 大字 80-96px Bold,颜色用 primary 或 accent
- 背景可选 radial-gradient 大圆增加层次感
- 标签 24px muted,字间距略宽松
- 数字用 `scale-in`,标签用 `fade-up` 延迟出现
---
## 四、卡片设计规范速查
| 属性 | 值 |
|------|-----|
| 圆角 (border-radius) | 12px(小型)/ 16px(标准卡片) |
| 阴影 (box-shadow) | `0 2px 12px rgba(0,0,0,0.08)` |
| 卡片内边距 | 24px ~ 32px |
| 图标区尺寸 | 48×48(小型)/ 80×80(大型) |
| 图标圆角 | 12px(小型)/ 20px(大型) |
| 卡片间距 | 24px(紧凑)/ 40px(宽松) |
| 边框 | 1px solid var(--border-color) 或省略 |
---
## 五、动效使用原则(HTML 路线)
1. **每页 ≤6 个独立 data-anim 元素**
2. **列表/卡片优先用 `data-anim-stagger`**,不逐个设 delay
3. **封面/章节页:1 个 hero 动效 → 次级动效**
4. **breathing 页面:≤2 个动效元素**
5. **dense 页面动效顺序:页面标题 → 卡片 stagger → 底部补充**
6. **锚定到 spec_lock.md 的 rhythm 标签**选择对应布局
---
## 六、字号层级速查
| 角色 | 字号 | 字重 | 用途 |
|------|------|------|------|
| Hero 标题 | 56-64px | Bold | 封面超大标题 |
| 主标题 | 48px | Bold | 封面/过渡页标题 |
| 章节标题 | 42px | Bold | 章节过渡页 |
| 页面标题 | 32px | Bold | 内容页标题 |
| 副标题 | 24px | Regular | 副标题 |
| 卡片标题 | 18-22px | Bold | 卡片/面板标题 |
| 正文 | 15-16px | Regular | 正文描述 |
| KPI 数值 | 36px | Bold | KPI 指标数字 |
| 大字 | 80-96px | Bold | breathing 大字 |
| 脚注 | 12-14px | Regular | 来源/日期 |
@@ -0,0 +1,405 @@
# 06 — HTML 技术约束与规范
> **角色**:HTML 路线硬约束手册 — 画布结构、CSS 能力边界、字体栈、动效引擎
> **读取时机**:HTML 路线 Executor 阶段编写 HTML 前
> **参考来源**:oh-my-ppt 的 data-anim 动效体系 + CSS 规范
---
## 一、画布与容器规范
### ⛔ 画布尺寸强制校验
> **这是本文件最重要的规则。画布尺寸错误是最高优先级禁止项。**
| 用户选择 | 根 div 强制宽高 | 禁止值 |
|----------|---------------|--------|
| 16:9 | `width:1280px;height:720px` | 1920×1080、1366×768、2560×1440 等一切非 1280×720 的值 |
| 4:3 | `width:1024px;height:768px` | 800×600、1600×1200 等一切非 1024×768 的值 |
| A4纵向 | `width:720px;height:1280px` | 595×842 等一切非 720×1280 的值 |
**画布硬约束**:
- 所有页面必须使用完全相同的根 div 尺寸,禁止页间尺寸不一致
- Agent 不得自行决定画布尺寸,必须严格使用上表中的值
- 生成每页 HTML 前必须确认根 div 尺寸与上表一致
### 根容器模板
每页 HTML 必须是一个**自包含的 HTML 文档**(含 `<html>` `<head>` `<body>`),根 div 尺寸锁死 1280×720:
```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="ppt-page" content="{N}">
<meta name="ppt-total" content="{T}">
</head>
<body style="margin:0;padding:0;display:flex;align-items:center;justify-content:center;
min-height:100vh;background:#E5E7EB;">
<div style="width:1280px;height:720px;position:relative;overflow:hidden;
background:var(--bg);font-family:'Microsoft YaHei','PingFang SC','Noto Sans SC',sans-serif;">
<!-- 页面内容 -->
</div>
</body>
</html>
```
### 格式对照
| 格式 | 宽 × 高 | 根 div style |
|------|---------|-------------|
| `ppt169` | 1280 × 720 | `width:1280px;height:720px` |
| `ppt43` | 1024 × 768 | `width:1024px;height:768px` |
### 硬约束汇总
| # | 规则 | 说明 |
|---|------|------|
| 1 | 根 div 尺寸固定 | `width:1280px;height:720px;position:relative;overflow:hidden` |
| 2 | 所有内容必须在 1280×720 内 | 超出部分被 `overflow:hidden` 裁剪 |
| 3 | 禁止 `position:fixed` / `position:sticky` | 页面在固定尺寸容器内渲染 |
| 4 | 优先 `position:absolute` 或 `flex`/`grid` | 精确控制元素位置 |
| 5 | 文本中的 `&` 必须转义为 `&amp;` | XML/HTML 解析器强制要求 |
| 6 | 所有样式在单个 `<style>` 块或内联 `style` 中 | 禁止外部 CSS / `<link>` / `@import` |
---
## 二、CSS 规则
### CSS 组织方式
样式写在根 div 内的 `<style>` 标签中,**只能在 `<style>` 中定义 CSS 自定义属性(`:root`)和通用类**。具体元素的样式使用内联 `style="..."`。
```html
<div style="width:1280px;height:720px;position:relative;overflow:hidden;
background:var(--bg);font-family:'Microsoft YaHei','PingFang SC','Noto Sans SC',sans-serif;">
<style>
:root {
--bg: #FAFBFC;
--primary: #1A73E8;
--secondary: #4285F4;
--accent: #FF6D00;
--text: #1F2937;
--muted: #6B7280;
--card-bg: #FFFFFF;
--card-shadow: 0 2px 12px rgba(0,0,0,0.08);
--border-color: #E5E7EB;
}
</style>
<h1 style="font-size:48px;color:var(--text);">标题</h1>
</div>
```
### CSS 自定义属性清单
| 变量名 | 用途 | 示例值 |
|--------|------|--------|
| `--bg` | 页面背景色 | `#FAFBFC` |
| `--primary` | 主色(标题栏、主按钮、强调区) | `#1A73E8` |
| `--secondary` | 辅色(次要区域、图表副系列) | `#4285F4` |
| `--accent` | 强调色(高亮、关键数字、CTA) | `#FF6D00` |
| `--text` | 正文颜色 | `#1F2937` |
| `--muted` | 弱化色(副文本、脚注) | `#6B7280` |
| `--card-bg` | 卡片背景色 | `#FFFFFF` |
| `--card-shadow` | 卡片阴影 | `0 2px 12px rgba(0,0,0,0.08)` |
| `--border-color` | 边框/分割线颜色 | `#E5E7EB` |
### CSS 能力边界
| ✅ 支持 | ❌ 禁止 |
|---------|--------|
| `flexbox` / `display:grid` | **`@keyframes` / CSS animation — ⛔ 最高优先级禁止** |
| `linear-gradient` / `radial-gradient` | `@font-face` / 自定义字体 |
| `box-shadow` / `border-radius` | `position:fixed` / `position:sticky` |
| `transform` (2D: `translate`, `rotate`, `scale`, `skew`) | EXIF 图片引用(CDN、Google Fonts) |
| `opacity` / `rgba` / `hsla` | `<link>` / `@import` 外部样式表 |
| `filter`(仅 `blur`) | 非标准 CSS(`-webkit-` 前缀仅限 `backdrop-filter`) |
| `clip-path`(基础形状) | CSS `calc()` 的复杂嵌套 |
| `backdrop-filter: blur()` | `position:absolute` 嵌套超过 4 层 |
| `text-shadow` | 非图片 `filter`(如 `contrast`、`hue-rotate`) |
---
## 三、HTML 结构规范
### 结构层级
- 推荐 **≤ 4 层嵌套**(根 div → 区块 → 子元素 → 文本)
- 扁平优先,避免深层 div 嵌套
- 每个逻辑区块用独立 `<div>` 包裹
### 语义标签
| 标签 | 用途 |
|------|------|
| `<section>` | 页面大区块(不常用) |
| `<div>` | 通用容器 |
| `<h1>` ~ `<h6>` | 标题层级 |
| `<p>` | 段落文本 |
| `<span>` | 内联文本 / 小型装饰 |
| `<img>` | 图片 |
### 禁止元素
| 标签 | 原因 |
|------|------|
| `<script>` | 除动效引擎 + 键盘导航外的任何 JS 禁止 |
| `<iframe>` | 安全风险 + 不可控渲染 |
| `<video>` / `<audio>` | 静态 PPT 不需要多媒体 |
| `<canvas>` | 不需要动态绘图 |
| `<form>` / `<input>` | 非交互式页面 |
| `<link>` | 禁止外部资源引用 |
### 闭合规则
- 每个标签必须正确闭合
- 自闭合标签写为 `<img ... />` 或 `<img ...>`
- 属性值用双引号 `"..."` 包裹
---
## 四、图片规范
### ⛔ 图片路径强制规则
> **所有图片引用必须使用绝对路径,绝对禁止相对路径。**
```html
<!-- ✅ 正确:绝对路径 -->
<img src="C:/Users/86166/Desktop/Agent_Learning_minor/Agent/src/agent/history/sessions/20260724_134021/tranformer_20260724_134021_1.png"
style="width:540px;height:360px;object-fit:contain;" alt="图表">
<!-- ✅ 正确:背景图绝对路径 -->
<div style="background-image:url('C:/Users/.../bg.png');background-size:cover;background-position:center;"></div>
<!-- ❌ 错误:相对路径 — 绝对禁止 -->
<img src="./tranformer.png" ...>
<img src="../images/xxx.png" ...>
<div style="background-image:url('./bg.png');">
```
**为什么要绝对路径**:
- PPT 的 HTML 文件在 `workspace/ppt_{topic}/`,用户图片在 `sessions/{session_id}/`,二者目录不同,相对路径无法解析
- `attachments/complete_htmls_to_base64.py` 脚本需要绝对路径才能正确读取图片文件并转为 base64
### 引用方式
```html
<!-- 图片引用使用绝对路径 -->
<img src="C:/Users/.../chart_1.png" style="width:540px;height:360px;object-fit:contain;" alt="图表">
<!-- 背景图使用绝对路径 -->
<div style="background-image:url('C:/Users/.../bg.png');background-size:cover;background-position:center;"></div>
```
### 图片容器规范
```html
<!-- 填充容器 -->
<div style="width:560px;height:400px;border-radius:16px;overflow:hidden;">
<img src="./image.jpg" style="width:100%;height:100%;object-fit:cover;" alt="描述">
</div>
```
| 属性 | 推荐值 | 说明 |
|------|--------|------|
| `object-fit` | `cover`(填充)/ `contain`(图表) | 控制缩放方式 |
| 容器 | 必须有显式 `width` × `height` | 避免布局抖动 |
| `border-radius` | 16px | 图片容器圆角 |
### 图片路径
- **生成时**:使用**绝对路径**引用图片(如 `C:/Users/.../xxx.png`),禁止相对路径
- **交付前**:用 `attachments/complete_htmls_to_base64.py` 将外部引用转为内联 base64 Data URI
- **禁止**:手写 `data:image/...;base64,...`、禁止相对路径 `./xxx.png`
---
## 五、字体规范
### 系统安全字体栈
| 类别 | 字体栈 |
|------|--------|
| 中文无衬线 | `"Microsoft YaHei", "PingFang SC", "Noto Sans SC", sans-serif` |
| 英文无衬线 | `"Inter", "Calibri", "Segoe UI", sans-serif` |
| 衬线 | `"Noto Serif SC", "Georgia", "Times New Roman", serif` |
| 等宽 | `"Consolas", "Fira Code", "Courier New", monospace` |
### 字号层级
| 角色 | 字号 | 字重 | 用途 |
|------|------|------|------|
| Hero 标题 | 56-64px | Bold (700) | 封面超大标题 |
| 主标题 | 48px | Bold (700) | 封面/过渡页标题 |
| 章节标题 | 42px | Bold (700) | 章节过渡页 |
| 页面标题 | 32px | Bold (700) | 内容页标题 |
| 副标题 | 24-28px | Regular (400) | 副标题 |
| 卡片标题 | 18-22px | Bold (700) | 卡片 / 面板标题 |
| 正文 | 15-16px | Regular (400) | 正文描述 |
| KPI 数值 | 36px | Bold (700) | KPI 指标数字 |
| 大字 | 80-96px | Bold (700) | breathing 大字 |
| 脚注 | 12-14px | Regular (400) | 来源 / 日期 |
### 行高规范
| 场景 | line-height |
|------|-------------|
| 标题 | `1.2` ~ `1.3` |
| 正文 | `1.5` ~ `1.7` |
| 脚注 | `1.4` |
---
## 六、动效引擎 + 键盘翻页导航(data-anim 系统)
> 每页 HTML 中**必须**在根 div 内包含以下脚本。这是**整个文档中唯一允许的** `<script>` 标签,包含动效引擎和键盘翻页导航。
### 动效引擎 + 导航脚本
```html
<script>
(function(){
// ===== 动效引擎 =====
const els=document.querySelectorAll('[data-anim]');
const staggerEls=document.querySelectorAll('[data-anim-stagger]');
const seqEls=document.querySelectorAll('[data-anim-sequence]');
let clickGroup=0;
const groups={};
function initEl(el){
const anim=el.getAttribute('data-anim')||'fade-in';
const dur=el.getAttribute('data-anim-duration')||'0.6s';
el.style.transition=`opacity ${dur} ease, transform ${dur} ease, clip-path ${dur} ease`;
el.style.opacity='0';
if(anim.includes('up')) el.style.transform='translateY(30px)';
else if(anim.includes('left')) el.style.transform='translateX(-60px)';
else if(anim.includes('right')) el.style.transform='translateX(60px)';
else if(anim==='scale-in'||anim==='zoom-in') el.style.transform='scale(0.8)';
else if(anim==='wipe-right') el.style.clipPath='inset(0 100% 0 0)';
else el.style.transform='translateY(0)';
}
function showEl(el){
el.style.opacity='1';
el.style.transform='translate(0,0) scale(1)';
el.style.clipPath='inset(0 0 0 0)';
}
els.forEach(el=>{
initEl(el);
const grp=el.getAttribute('data-anim-click-group');
if(grp){ if(!groups[grp]) groups[grp]=[]; groups[grp].push(el); return; }
const delay=parseFloat(el.getAttribute('data-anim-delay')||'0')*1000;
setTimeout(()=>showEl(el),delay+50);
});
staggerEls.forEach(container=>{
const children=container.querySelectorAll('[data-anim]');
children.forEach((ch,i)=>{ initEl(ch); setTimeout(()=>showEl(ch),i*50+100); });
});
seqEls.forEach(container=>{
const children=container.querySelectorAll('[data-anim]');
let total=0;
children.forEach(ch=>{
const d=parseFloat(ch.getAttribute('data-anim-duration')||'0.6')*1000;
const delay=parseFloat(ch.getAttribute('data-anim-delay')||'0')*1000;
initEl(ch); total+=delay; setTimeout(()=>showEl(ch),total+100); total+=d;
});
});
document.addEventListener('click',()=>{
clickGroup++;
(groups[clickGroup]||[]).forEach(el=>showEl(el));
});
// ===== 键盘翻页导航 =====
var metaPage=document.querySelector('meta[name="ppt-page"]');
var metaTotal=document.querySelector('meta[name="ppt-total"]');
if(metaPage&&metaTotal){
var cur=parseInt(metaPage.getAttribute('content'));
var tot=parseInt(metaTotal.getAttribute('content'));
if(!isNaN(cur)&&!isNaN(tot)&&tot>1){
var ov=null;
function showOv(dir){
if(!ov){
ov=document.createElement('div');
ov.style.cssText='position:fixed;top:0;left:0;width:100%;height:100%;display:flex;align-items:center;justify-content:center;pointer-events:none;z-index:99999;background:rgba(0,0,0,0.25);opacity:0;transition:opacity 0.2s;font-size:36px;color:#fff;font-family:sans-serif;letter-spacing:2px;';
document.body.appendChild(ov);
}
ov.textContent=dir==='prev'?'\u25C0 '+cur+' / '+tot:(cur+1)+' / '+tot+' \u25B6';
ov.style.opacity='1';
setTimeout(function(){ov.style.opacity='0';},500);
}
document.addEventListener('keydown',function(e){
if(e.key==='ArrowRight'||e.key==='Right'){
e.preventDefault();
if(cur<tot){showOv('next');setTimeout(function(){window.location.href=(cur+1)+'.html';},150);}
}else if(e.key==='ArrowLeft'||e.key==='Left'){
e.preventDefault();
if(cur>1){showOv('prev');setTimeout(function(){window.location.href=(cur-1)+'.html';},150);}
}
});
}
}
})();
</script>
```
### 支持属性一览
| 属性 | 值 | 说明 |
|------|-----|------|
| `data-anim` | `"fade-up"` | 动效类型(见下表) |
| `data-anim-delay` | `"0.3s"` | 延迟(秒),默认 `0` |
| `data-anim-duration` | `"0.8s"` | 时长(秒),默认 `0.6s` |
| `data-anim-stagger` | 无需值,仅存在即生效 | 容器上:子元素依次出现(50ms 间隔) |
| `data-anim-sequence` | 无需值,仅存在即生效 | 容器上:子元素逐个出现(完成后再下一个) |
| `data-anim-click-group` | `"1"` | 点击分组:页面点击后该组元素同时出现 |
### 动效类型表
| 值 | 初始状态 | 最终状态 | 视觉效果 |
|----|----------|----------|----------|
| `fade-up` | opacity:0, translateY(30px) | opacity:1, translateY(0) | 从下方淡入 30px |
| `fade-in` | opacity:0 | opacity:1 | 简单淡入 |
| `scale-in` | opacity:0, scale(0.8) | opacity:1, scale(1) | 缩放 + 淡入 |
| `slide-left` | opacity:0, translateX(-60px) | opacity:1, translateX(0) | 从左侧滑入 60px |
| `slide-right` | opacity:0, translateX(60px) | opacity:1, translateX(0) | 从右侧滑入 60px |
| `wipe-right` | clip-path: inset(0 100% 0 0) | clip-path: inset(0 0 0 0) | 从左到右揭示 |
| `zoom-in` | opacity:0, scale(0.5) | opacity:1, scale(1) | 从小放大进入 |
| `pulse-soft` | opacity:0, scale(0.9) | opacity:1, scale(1) | 轻柔脉冲(CTA 用) |
### 动效使用原则
1. **每页 ≤ 6 个独立 data-anim 元素**
2. **列表/卡片优先用 `data-anim-stagger`**,不逐个设 delay
3. **封面/章节页 1 个 hero 动效 + 次级动效**
4. **breathing 页面 ≤ 2 个动效元素**
5. **dense 页面:标题 → stagger 卡片 → 补充**
6. **`wipe-right` 仅用于线/条元素**(如下划线、装饰条、底部色条)
7. **`pulse-soft` 仅用于 CTA 或关键数字**,不滥用
---
## 七、禁止事项清单
| 类别 | 禁止项 | 替代方案 |
|------|--------|----------|
| **脚本** | `<script>`(除动效引擎 + 键盘导航外) | 无 |
| **样式** | 外部 CSS `<link>` / `@import` | `<style>` 块 + 内联 `style` |
| **字体** | `@font-face` / Google Fonts / CDN 字体 | 系统安全字体栈 |
| **定位** | `position:fixed` / `position:sticky` | `position:absolute` |
| **媒体** | `<iframe>` / `<video>` / `<audio>` | 静态 `<img>` |
| **动画** | CSS `animation` / `@keyframes` | `data-anim` 动效引擎 |
| **特殊字符** | 裸 `&` | 转义为 `&amp;` |
| **图片** | 相对路径(`./xxx.png`)、手写 base64 Data URI | 绝对路径 + 交付前固化脚本 |
| **嵌套** | > 4 层 div 嵌套 | 扁平化结构 |
| **filter** | `contrast` / `hue-rotate` / `saturate` 等 | 仅用 `blur` |
| **画布** | 1920×1080、1366×768 等非标准尺寸 | 仅 1280×720(16:9)/ 1024×768(4:3)/ 720×1280(A4纵向) |
| **布局** | 依赖 flex/grid/文档流自动排列 | 全部 `position:absolute` 显式定位 |
@@ -0,0 +1,532 @@
# 07 — Executor(执行师):HTML 页面生成指南
> **角色**:HTML 执行师 — 将 spec_lock.md 的设计合约转化为逐页 HTML 页面
> **读取时机**:Step 3(HTML 路线)进入执行阶段前
> **前置依赖**:`05-layout-patterns.md`(布局模式参考)、`08-animation-system.md`(动画引擎脚本与动效目录)
---
## ⛔ Executor 阶段原则
Executor 阶段原则上自动化执行,不主动打断用户。但若在生成过程中遇到以下情况,**必须暂停并使用 `human_interaction` 与用户对齐**:
| 情况 | human_interaction 类型 | 示例 |
|------|----------------------|------|
| spec_lock.md 中某页信息不足以生成 | `information` | 配图策略标了 `hero` 但用户未提供图片,询问用户是否补充或改为纯文字 |
| 图表数据描述模糊,matplotlib 无法确定具体数值 | `information` | "Q1-Q4 营收对比"但未给出具体数字,询问用户数据 |
| 生成过程中发现 spec_lock 内容有矛盾 | `approval` | 某页被标记为 `anchor` 但分配了 80+ 字内容,请求用户调整 |
| 用户中途要求修改已生成的页面 | `approval` | 展示修改方案,用户审批后重新生成该页 |
> **禁止 Executor 自行猜测或填写缺失数据。遇到不确定的情况必须回到用户侧对齐。**
---
## 核心使命
读取 `spec_lock.md` → 生成图表(如有) → 逐页手写 HTML → 每页内嵌 CSS 变量与动画引擎 → 质量检查
---
## 一、生成前检查清单
在生成第一页 HTML 之前,必须完成以下检查:
### 1.1 读取 spec_lock.md
确认以下信息已锁定:
| 检查项 | 来源字段 | 说明 |
|--------|----------|------|
| 画布格式 | `canvas` | 确认 `{width}×{height}`,不可偏离(16:9=1280×720 / 4:3=1024×768 / A4纵向=720×1280) |
| 色板十六进制值 | `palette` | 确认 `bg`、`primary`、`secondary`、`accent`、`text`、`muted` 的全部 6 个 hex 值 |
| 字体栈 | `fonts` | 确认 `title_font` 和 `body_font` 的具体 font-family 字符串,必须以系统安全字体结尾 |
| 页面节奏 | `pages[].rhythm` | 识别每页的 rhythm 标签(`anchor` / `dense` / `breathing`),据此匹配布局 |
| 每页内容 | `pages[].title`、`pages[].key_points` | 确认标题文案与要点的具体内容 |
| 图表需求 | `pages[].chart` | 确认哪些页面需要图表、图表数据来源 |
### 1.1.1 ⛔ 画布尺寸自检(每页生成前必须执行)
在生成每页 HTML 的根 div 之前,**必须执行以下自检**:
```
确认 spec_lock.md canvas 值:
□ 16:9 → 根 div 必须是 width:1280px;height:720px
□ 4:3 → 根 div 必须是 width:1024px;height:768px
□ A4纵向 → 根 div 必须是 width:720px;height:1280px
□ 禁止使用 1920×1080 或任何非标准尺寸
□ 所有页面根 div 尺寸必须完全一致
```
**自检未通过 → 禁止生成 HTML。**
### 1.2 确认色板与 CSS 变量映射
将 `spec_lock.md` 的 palette 字段映射为 `:root` 中的 CSS 自定义属性:
```
palette.bg → --bg
palette.primary → --primary
palette.secondary→ --secondary
palette.accent → --accent
palette.text → --text
palette.muted → --muted
```
衍生变量(按需推导,不纳入 spec_lock):
- `--card-bg`:浅色主题为 `#FFFFFF`,若 bg 为白色则取 `#FFFFFF`
- `--card-shadow`:`0 2px 12px rgba(0,0,0,0.08)`
- `--border-color`:`#E5E7EB`(浅色)或从 bg 推深 2 级
### 1.3 确认字体栈
```css
--title-font: 'Microsoft YaHei', 'PingFang SC', 'Noto Sans SC', sans-serif;
--body-font: 'Microsoft YaHei', 'PingFang SC', 'Noto Sans SC', sans-serif;
```
字体栈必须以系统安全字体结尾(`sans-serif` / `serif` / `monospace`),**不使用任何 CDN 加载的外部字体**。
### 1.4 识别页面节奏与布局匹配
| rhythm | 含义 | 信息量 | 匹配布局(来自 05-layout-patterns.md) |
|--------|------|--------|---------------------------------------|
| `anchor` | 封面/章节过渡页 | ≤30 字 | Cover Hero Centered / Cover Split / Section Divider |
| `dense` | 内容/数据页 | ≤80 字 | 2×2 Cards / KPI Dashboard / Left Text Right Image / Top Image Bottom Text / 3-Column Feature / Timeline / Comparison |
| `breathing` | 金句/大字页 | ≤20 字 | Quote Centered / Big Number + Label |
---
## 二、图表生成(当页面需要图表时)
### 2.1 何时需要图表
当 `spec_lock.md` 中该页标注了 `chart` 字段,或 key_points 中包含"趋势""对比""占比""分布"等数据描述时。
### 2.2 生成流程
1. **编写 Python 脚本** → 使用 matplotlib,配色严格取自 spec_lock.md palette:
- 主系列用 `primary`
- 副系列用 `secondary`
- 强调数据用 `accent`
- 网格线/坐标轴用 `muted`
- 背景用 `bg`
2. **脚本模板**(`workspace/ppt_{topic}/gen_chart_{N}.py`):
```python
import matplotlib.pyplot as plt
import matplotlib
matplotlib.rcParams['font.sans-serif'] = ['Microsoft YaHei', 'SimHei']
matplotlib.rcParams['axes.unicode_minus'] = False
# === 色值取自 spec_lock.md palette ===
PRIMARY = "#1A73E8"
SECONDARY = "#4285F4"
ACCENT = "#FF6D00"
MUTED = "#6B7280"
BG = "#FAFBFC"
fig, ax = plt.subplots(figsize=(10.8, 3.6), dpi=100)
fig.patch.set_facecolor(BG)
ax.set_facecolor(BG)
# ... 绘图逻辑 ...
ax.spines['top'].set_visible(False)
ax.spines['right'].set_visible(False)
ax.spines['left'].set_color(MUTED)
ax.spines['bottom'].set_color(MUTED)
ax.tick_params(colors=MUTED)
plt.tight_layout()
plt.savefig('chart_{N}.png', dpi=120, bbox_inches='tight', facecolor=BG)
```
3. **运行脚本**:
```bash
cd workspace/ppt_{topic} && python gen_chart_{N}.py
```
4. **在 HTML 中引用**:
```html
<img src="./chart_{N}.png"
style="width:1080px;height:360px;object-fit:contain;" alt="图表">
```
### 2.3 图表尺寸约束
| 布局类型 | 图表宽 × 高 | 说明 |
|----------|------------|------|
| KPI Dashboard 底部 | 1080×360 | 占画布下半部分 |
| Left Text Right Image 右侧 | 560×520 | 与左侧文字等高 |
| Top Image Bottom Text 顶部 | 1280×360 | 全宽半高 |
---
## 三、逐页生成流程
### 3.1 每页生成前(必须)
**重新读取 `spec_lock.md`**,获取该页的 rhythm、title、key_points。这是防止上下文漂移导致风格不一致的关键措施。
### 3.2 生成步骤
```
┌─ Step A: 读取 spec_lock.md 中该页信息
│
├─ Step B: 根据 rhythm 标签,从 05-layout-patterns.md 选择匹配布局
│ anchor → 封面/过渡布局(第 1-3 模板)
│ dense → 内容布局(第 4-10 模板)
│ breathing → 金句/大字布局(第 11-12 模板)
│
├─ Step C: 编写独立 HTML 文件
│ 1. 替换所有占位文案为 spec_lock 中的真实内容
│ 2. 在 <head> 中设置 meta ppt-page="{N}" 和 meta ppt-total="{T}"
│ 3. 在 <style> 块的 :root 中写入 palette 的 hex 色值
│ 4. 根据 spec_lock 的字号规范调整 font-size
│ 5. 为需要动效的元素添加 data-anim 属性
│ 6. 在 </div> 之前嵌入动效引擎 + 键盘导航 <script>
│
├─ Step D: 如该页需要图表 → 先生成图表 PNG,再在 HTML 中引用
│
└─ Step E: 使用 doc_tool create 输出独立文件 `{N}.html`
### 3.3 ⛔ 文件命名铁律
> **文件名只能是纯数字。这是硬性规则,违反即无效。**
| ✅ 正确 | ❌ 绝对禁止 |
|--------|------------|
| `1.html` | `1_cover.html`、`1_封面.html`、`01.html`、`page1.html` |
| `2.html` | `2_TOC.html`、`2_目录.html` |
| `3.html` | `3_transformer_architecture.html` |
| ... | 任何包含数字以外的字符 |
**为什么必须纯数字**:键盘导航脚本通过 `meta[name="ppt-page"]` 获取当前页码,用 `(cur+1)+'.html'` 构造下一页 URL。如果文件名不是纯数字(如 `1_cover.html`),导航将失效。
每页是一个**自包含的完整 HTML 文档**(含 `<html>` `<head>` `<body>`),文件命名为 `{N}.html`(N 从 1 开始,纯数字无后缀),输出到 `workspace/ppt_{topic}/` 目录。
```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="ppt-page" content="{N}">
<meta name="ppt-total" content="{T}">
<title>第 {N} 页</title>
<style>
:root {
--bg: {hex}; --primary: {hex}; --secondary: {hex};
--accent: {hex}; --text: {hex}; --muted: {hex};
--card-bg: #FFFFFF; --card-shadow: 0 2px 12px rgba(0,0,0,0.08);
--border-color: #E5E7EB;
--title-font: '{font}'; --body-font: '{font}';
}
* { margin: 0; padding: 0; box-sizing: border-box; }
.ppt-root {
width: 1280px; height: 720px; position: relative;
overflow: hidden; background: var(--bg);
font-family: var(--body-font);
}
</style>
</head>
<body style="display:flex;justify-content:center;align-items:center;
min-height:100vh;background:#e0e0e0;">
<div class="ppt-root">
<!-- 页面内容 -->
</div>
<script>
/* 动效引擎 + 键盘导航 */
</script>
</body>
</html>
```
每个文件可在浏览器中直接打开预览。通过键盘左右方向键可在页面间切换。无需单文件组装、无需 CSS 页间切换、无需可见导航按钮。
---
## 四、按节奏的动画策略
### 4.1 anchor(封面/章节页)— ≤2 个动效元素
| 页面类型 | 动效方案 | data-anim 示例 |
|----------|---------|---------------|
| 封面 Hero | 标题 `fade-up` + 副标题 `fade-up`(延迟 0.2s) | `data-anim="fade-up"` |
| 封面 Split | 左文字 `slide-right` + 右装饰区 `fade-up`(延迟 0.3s) | `data-anim="slide-right"` |
| 章节过渡 | 编号 `fade-up` + 标题 `fade-up`(延迟 0.15s) + 装饰线 `wipe-right`(延迟 0.35s) | `data-anim="wipe-right"` |
**原则**:封面动效营造仪式感,延迟错开形成层次;副标题始终比主标题晚 0.15-0.2s。
### 4.2 dense(内容页)— ≤6 个动效元素
| 页面类型 | 动效方案 | data-anim 示例 |
|----------|---------|---------------|
| 2×2 Cards | 父容器 `data-anim-stagger` + 子卡片 `fade-up` | 父: `data-anim-stagger`,子: `data-anim="fade-up"` |
| KPI Dashboard | 父容器 `data-anim-stagger` + KPI 卡片 `scale-in` + 图表 `fade-up` | 子: `data-anim="scale-in"`,图表: `data-anim="fade-up" data-anim-delay="0.4s"` |
| 左文右图 | 文字要点 `slide-right`(stagger),图片 `fade-up`(延迟 0.3s) | 子: `data-anim="slide-right"` |
| 时间线 | 父容器 `data-anim-stagger` + 节点 `slide-left` | 父: `data-anim-stagger`,子: `data-anim="slide-left"` |
| 对比 | 左侧 `slide-right` + 右侧 `slide-left`(延迟 0.2s) + VS 标识 `scale-in`(延迟 0.5s) | 左: `data-anim="slide-right"`,右: `data-anim="slide-left" data-anim-delay="0.2s"` |
**原则**:
- 页面标题最先出现(`fade-up`,无延迟)
- 卡片/列表用 `data-anim-stagger` 实现级联出场(80ms 间隔)
- 图表/图片用 `fade-up` 延迟 0.3-0.4s 出场
- stagger 容器不计入独立动效数量,子元素各自计数
### 4.3 breathing(金句/大字页)— ≤1 个动效元素
| 页面类型 | 动效方案 | data-anim 示例 |
|----------|---------|---------------|
| 居中金句 | 金句 `fade-up`(慢速 0.8s) | `data-anim="fade-up" data-anim-duration="0.8s"` |
| 大字 + 标签 | 数字 `scale-in`(延迟 0.2s,慢速 1s) | `data-anim="scale-in" data-anim-delay="0.2s" data-anim-duration="1s"` |
**原则**:breathing 页面讲究留白与沉浸感,动效极少但持续时长可适当拉长。
---
## 五、每页动效上限
| rhythm | 最大动效数 | 说明 |
|--------|-----------|------|
| `anchor` | ≤2 | 封面标题 + 副标题;或标题 + 装饰线 |
| `dense` | ≤6 | 标题(1) + stagger 卡片(≤4) + 图表/图片(1) |
| `breathing` | ≤1 | 金句或大字,单一焦点 |
> **注意**:`data-anim-stagger` 容器自身不计入动效数,但其中通过 `data-anim` 标记的子元素各自计数。例如:stagger 容器内含 4 个 `data-anim="fade-up"` 卡片,计为 4 个动效。
---
## 六、CSS 变量替换规范
### 6.1 每页 HTML 的 `<style>` 块必须以 `:root` 声明开头
```html
<style>
:root {
--bg: #FAFBFC;
--primary: #1A73E8;
--secondary: #4285F4;
--accent: #FF6D00;
--text: #1F2937;
--muted: #6B7280;
--card-bg: #FFFFFF;
--card-shadow: 0 2px 12px rgba(0,0,0,0.08);
--border-color: #E5E7EB;
--title-font: 'Microsoft YaHei', 'PingFang SC', 'Noto Sans SC', sans-serif;
--body-font: 'Microsoft YaHei', 'PingFang SC', 'Noto Sans SC', sans-serif;
}
</style>
```
### 6.2 色值必须与 spec_lock.md 完全一致
直接从 `spec_lock.md` 的 `palette` 字段复制 hex 值,不允许自行调整、加深、变亮、或使用其他色值。
### 6.3 独立 HTML 文档模板
每页输出为一个**自包含的完整 HTML 文档**:
```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>第 {N} 页</title>
<style>
:root {
--bg: {hex}; --primary: {hex}; --secondary: {hex};
--accent: {hex}; --text: {hex}; --muted: {hex};
--card-bg: #FFFFFF; --card-shadow: 0 2px 12px rgba(0,0,0,0.08);
--border-color: #E5E7EB;
--title-font: '{font}'; --body-font: '{font}';
}
* { margin: 0; padding: 0; box-sizing: border-box; }
.ppt-root {
width: 1280px; height: 720px; position: relative;
overflow: hidden; background: var(--bg);
font-family: var(--body-font);
}
</style>
</head>
<body style="display:flex;justify-content:center;align-items:center;
min-height:100vh;background:#e0e0e0;">
<div class="ppt-root">
<!-- 页面内容 — 使用 var(--xxx) 引用色值 -->
</div>
<script>
/* 动画引擎 */
</script>
</body>
</html>
```
---
## 七、输出
### 7.1 最终输出
每页输出为独立 HTML 文件:`workspace/ppt_{topic}/{N}.html`(N 从 1 到总页数)。每个文件为自包含的完整 HTML 文档。
### 7.2 输出流程
1. 逐页使用 doc_tool create 写出 {N}.html
2. 运行 `python {SKILL_DIR}/attachments/complete_htmls_to_base64.py {workspace_dir}` 产出 `{N}_complete.html`
3. 交付所有 {N}_complete.html 文件
### 7.3 图片缩放规则
用户上传的图片尺寸已在消息中提供。在 HTML 中使用时:
```css
/* 根据 spec_lock.md canvas 和图片原始尺寸计算缩放 */
--canvas-w: {spec_lock canvas width};
--canvas-h: {spec_lock canvas height};
--padding: 48px;
--max-img-w: calc(var(--canvas-w) - 2 * var(--padding));
--max-img-h: calc(var(--canvas-h) - 2 * var(--padding));
```
**缩放公式**:`scale = min(max_img_w / original_w, max_img_h / original_h)`
- 若 scale < 1:等比缩小,`width = original_w * scale`,`height = original_h * scale`
- 若 scale >= 1:保持原尺寸,不拉伸
```html
<!-- 示例:原图 1920×1080,画布 1280×720 -->
<!-- scale = min(1184/1920, 624/1080) = min(0.617, 0.578) = 0.578 -->
<!-- width = 1920 * 0.578 = 1109, height = 1080 * 0.578 = 624 -->
<img src="./user_image.png" style="width:1109px;height:624px;object-fit:contain;" alt="配图">
```
---
## 八、禁止事项
| # | 禁止项 | 原因 |
|---|--------|------|
| 1 | 生成 SVG | 当前 Skill 仅输出 HTML,不涉及 SVG |
| 2 | 使用系统安全字体之外的任何字体 | 无 CDN、无 Google Fonts、无 @font-face,仅限系统预装字体 |
| 3 | 添加任何 CDN 链接(字体、图标、CSS 框架) | 页面必须完全自包含,零外部依赖 |
| 4 | 超出动效上限 | anchor≤2 / dense≤6 / breathing≤1 |
| 5 | 偏离 spec_lock.md 色板 | 所有 hex 值必须是 palette 中定义的值,禁止自行引入新色 |
| 6 | 使用外部模板文件 | 仅能参考 05-layout-patterns.md 中的布局模式,不可引用外部 HTML 文件 |
| 7 | 使用动画引擎 + 键盘导航之外的 JavaScript | 每页只能有一个 `<script>` 标签,内容为动效引擎 + 键盘导航脚本(来自 06-html-standards.md) |
| 8 | **⛔ 使用 CSS @keyframes** | **最高优先级禁止**。所有动效通过 `data-anim` + CSS `transition` 实现,禁止定义任何 `@keyframes` 关键帧动画 |
| 9 | 子代理生成页面 | HTML 页面必须由当前主 Agent 端到端逐个完成 |
| 10 | 批量 / 并行生成页面 | 必须逐页顺序生成,每页生成前重读 spec_lock.md |
| 11 | **⛔ 使用错误画布尺寸** | 16:9 只能是 1280×720,禁止 1920×1080;4:3 只能是 1024×768;A4纵向只能是 720×1280 |
| 12 | **⛔ 使用相对路径引用图片** | 所有 `<img src>` 和 `background-image:url()` 必须使用绝对路径,禁止 `./xxx.png` |
| 13 | **依赖 flex/grid/文档流布局** | 根容器内所有元素必须 `position:absolute;top:{N}px;left:{N}px;` 显式定位 |
| 14 | **⛔ 文件名使用非纯数字** | 文件名只能是 `1.html`、`2.html`、...。绝对禁止 `1_cover.html`、`01.html`、`page1.html` 等 |
---
## 九、完整生成示例(dense → 2×2 Cards)
以下为一页完整的 `dense` 类型页面的生成示例,展示所有规范如何落地:
```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="ppt-page" content="2">
<meta name="ppt-total" content="6">
<title>第 2 页</title>
</head>
<body style="margin:0;padding:0;display:flex;align-items:center;justify-content:center;
min-height:100vh;background:#E5E7EB;">
<div style="width:1280px;height:720px;position:relative;overflow:hidden;
background:var(--bg);font-family:var(--body-font);">
<style>
:root {
--bg: #FAFBFC;
--primary: #1A73E8;
--secondary: #4285F4;
--accent: #FF6D00;
--text: #1F2937;
--muted: #6B7280;
--card-bg: #FFFFFF;
--card-shadow: 0 2px 12px rgba(0,0,0,0.08);
--border-color: #E5E7EB;
--title-font: 'Microsoft YaHei', 'PingFang SC', 'Noto Sans SC', sans-serif;
--body-font: 'Microsoft YaHei', 'PingFang SC', 'Noto Sans SC', sans-serif;
}
</style>
<!-- 页面标题 -->
<h2 data-anim="fade-up"
style="position:absolute;top:40px;left:80px;font-size:32px;font-weight:bold;
color:var(--text);margin:0;font-family:var(--title-font);">
核心能力矩阵
</h2>
<!-- 2×2 卡片网格 — stagger 级联动效 -->
<div data-anim-stagger
style="position:absolute;top:110px;left:80px;width:1120px;
display:grid;grid-template-columns:repeat(2,548px);
grid-template-rows:repeat(2,260px);gap:24px;">
<div data-anim="fade-up"
style="background:var(--card-bg);border-radius:16px;box-shadow:var(--card-shadow);
padding:32px;display:flex;flex-direction:column;">
<div style="width:48px;height:48px;border-radius:12px;background:var(--primary);
display:flex;align-items:center;justify-content:center;margin-bottom:20px;">
<span style="font-size:24px;color:#FFFFFF;">🚀</span>
</div>
<h3 style="font-size:20px;font-weight:bold;color:var(--text);margin:0 0 12px 0;
font-family:var(--title-font);">快速部署</h3>
<p style="font-size:16px;color:var(--muted);margin:0;line-height:1.6;">
一键自动化部署流水线,分钟级完成全链路发布,支持灰度与回滚。
</p>
</div>
<div data-anim="fade-up"
style="background:var(--card-bg);border-radius:16px;box-shadow:var(--card-shadow);
padding:32px;display:flex;flex-direction:column;">
<div style="width:48px;height:48px;border-radius:12px;background:var(--secondary);
display:flex;align-items:center;justify-content:center;margin-bottom:20px;">
<span style="font-size:24px;color:#FFFFFF;">📊</span>
</div>
<h3 style="font-size:20px;font-weight:bold;color:var(--text);margin:0 0 12px 0;
font-family:var(--title-font);">实时监控</h3>
<p style="font-size:16px;color:var(--muted);margin:0;line-height:1.6;">
多维指标大盘,异常秒级告警,覆盖基础设施到业务层全链路。
</p>
</div>
<div data-anim="fade-up"
style="background:var(--card-bg);border-radius:16px;box-shadow:var(--card-shadow);
padding:32px;display:flex;flex-direction:column;">
<div style="width:48px;height:48px;border-radius:12px;background:var(--accent);
display:flex;align-items:center;justify-content:center;margin-bottom:20px;">
<span style="font-size:24px;color:#FFFFFF;">🔒</span>
</div>
<h3 style="font-size:20px;font-weight:bold;color:var(--text);margin:0 0 12px 0;
font-family:var(--title-font);">安全合规</h3>
<p style="font-size:16px;color:var(--muted);margin:0;line-height:1.6;">
内置权限管控与审计追踪,满足等保三级与 SOC2 合规要求。
</p>
</div>
<div data-anim="fade-up"
style="background:var(--card-bg);border-radius:16px;box-shadow:var(--card-shadow);
padding:32px;display:flex;flex-direction:column;">
<div style="width:48px;height:48px;border-radius:12px;background:var(--primary);
display:flex;align-items:center;justify-content:center;margin-bottom:20px;">
<span style="font-size:24px;color:#FFFFFF;">⚡</span>
</div>
<h3 style="font-size:20px;font-weight:bold;color:var(--text);margin:0 0 12px 0;
font-family:var(--title-font);">弹性伸缩</h3>
<p style="font-size:16px;color:var(--muted);margin:0;line-height:1.6;">
基于流量预测的自动扩缩容,资源利用率提升 40% 以上。
</p>
</div>
</div>
<script>
/* 动效引擎 + 键盘导航脚本(完整内容见 06-html-standards.md 第六节) */
</script>
</div>
</body>
</html>
```
> 以上示例中画布为 1280×720,`:root` 中色值需替换为 spec_lock.md 的 palette,标题与卡片内容替换为 spec_lock.md 的真实文案。`meta[name="ppt-page"]` 和 `meta[name="ppt-total"]` 需填入该页的实际页码和总页数。
@@ -0,0 +1,687 @@
# 08 — 动画系统(Animation System):声明式动效引擎与动效目录
> **角色**:HTML 路线动画系统完整参考 — 定义 `data-anim` 声明式动画的全部类型、属性、引擎脚本与使用规范
> **读取时机**:Executor 阶段为 HTML 页面添加动效时
> **前置依赖**:本文件包含每页 HTML 中唯一允许的 `<script>` 标签(动画引擎),直接嵌入使用
---
## 一、动画引擎脚本(完整版)
以下是每页 HTML 末尾必须嵌入的**唯一** `<script>` 标签。引擎约 45 行(压缩后约 35 行),负责解析 `data-anim` 属性并驱动所有 CSS 过渡动画。
### 1.1 引擎源码(可读版)
```javascript
(function() {
const ANIM_ATTR = 'data-anim';
const VISIBLE_CLS = 'anim-visible';
const STAGGER = 'data-anim-stagger';
const SEQUENCE = 'data-anim-sequence';
const CLICK_GROUP = 'data-anim-click-group';
// 初始状态映射:每种动效的起始 CSS
const initMap = {
'fade-up': 'transform:translateY(30px);opacity:0;',
'fade-in': 'opacity:0;',
'scale-in': 'transform:scale(0.85);opacity:0;',
'slide-left': 'transform:translateX(-60px);opacity:0;',
'slide-right': 'transform:translateX(60px);opacity:0;',
'wipe-right': 'clip-path:inset(0 100% 0 0);opacity:0;',
'zoom-in': 'transform:scale(0.5);opacity:0;',
'pulse-soft': ''
};
const clickGroups = {};
const DEFAULT_DURATION = 0.6;
const EASE = 'cubic-bezier(0.16, 1, 0.3, 1)';
// 为元素设置初始状态 + transition
document.querySelectorAll('[' + ANIM_ATTR + ']').forEach(function(el) {
var anim = el.getAttribute(ANIM_ATTR);
if (!anim || !initMap[anim]) return;
var delay = parseFloat(el.getAttribute('data-anim-delay') || '0');
var duration = parseFloat(el.getAttribute('data-anim-duration') || String(DEFAULT_DURATION));
var trigger = el.getAttribute('data-anim-trigger') || 'load';
el.style.cssText +=
'transition: all ' + duration + 's ' + EASE + ';' +
initMap[anim];
// pulse-soft 用 CSS animation 实现(唯一例外)
if (anim === 'pulse-soft') {
el.style.cssText += 'animation: pulse-soft-kf 1.5s ease-in-out infinite;';
}
if (trigger === 'click') {
var group = el.getAttribute(CLICK_GROUP);
if (group) {
clickGroups[group] = clickGroups[group] || [];
clickGroups[group].push({ el: el, delay: delay });
}
} else {
// load 触发:按延迟激活
setTimeout(function() {
el.classList.add(VISIBLE_CLS);
}, delay * 1000);
}
});
// --- Stagger:父容器子元素依次激活 ---
document.querySelectorAll('[' + STAGGER + ']').forEach(function(parent) {
var gap = parseFloat(parent.getAttribute(STAGGER) || '0.08');
Array.from(parent.children).filter(function(c) {
return c.hasAttribute(ANIM_ATTR);
}).forEach(function(child, i) {
var existing = parseFloat(child.getAttribute('data-anim-delay') || '0');
child.setAttribute('data-anim-delay', String(existing + i * gap));
});
});
// --- Sequence:子元素逐个触发(前一个 transitionend 触发下一个) ---
document.querySelectorAll('[' + SEQUENCE + ']').forEach(function(parent) {
Array.from(parent.children).filter(function(c) {
return c.hasAttribute(ANIM_ATTR);
}).forEach(function(child, i) {
if (i > 0) child.style.visibility = 'hidden';
child.addEventListener('transitionend', function handler() {
if (i + 1 < parent.children.length) {
var next = parent.children[i + 1];
next.style.visibility = 'visible';
next.classList.add(VISIBLE_CLS);
}
}, { once: true });
});
});
// --- Click Group:页面点击逐组触发 ---
document.addEventListener('click', function() {
Object.values(clickGroups).forEach(function(group) {
if (group.length > 0) {
var item = group.shift();
setTimeout(function() {
item.el.classList.add(VISIBLE_CLS);
}, item.delay * 1000);
}
});
});
})();
```
### 1.2 引擎源码(压缩版,可直接嵌入 HTML)
```html
<script>
(function(){const S="data-anim";const V="anim-visible";const G="data-anim-stagger";const Q="data-anim-sequence";const C="data-anim-click-group";const D={};const M={"fade-up":"transform:translateY(30px);opacity:0;","fade-in":"opacity:0;","scale-in":"transform:scale(0.85);opacity:0;","slide-left":"transform:translateX(-60px);opacity:0;","slide-right":"transform:translateX(60px);opacity:0;","wipe-right":"clip-path:inset(0 100% 0 0);opacity:0;","zoom-in":"transform:scale(0.5);opacity:0;","pulse-soft":""};document.querySelectorAll("["+S+"]").forEach(e=>{const a=e.getAttribute(S);if(!a||!M[a])return;const d=parseFloat(e.getAttribute("data-anim-delay")||"0");const r=parseFloat(e.getAttribute("data-anim-duration")||"0.6");const t=e.getAttribute("data-anim-trigger")||"load";e.style.cssText+="transition:all "+r+"s cubic-bezier(0.16,1,0.3,1);"+M[a];if(a==="pulse-soft"){e.style.cssText+="animation:pulse-soft-kf 1.5s ease-in-out infinite"}if(t==="click"){const g=e.getAttribute(C);if(g){D[g]=D[g]||[];D[g].push({el:e,delay:d})}}else{setTimeout(()=>{e.classList.add(V)},d*1000)}});document.querySelectorAll("["+G+"]").forEach(p=>{const g=parseFloat(p.getAttribute(G)||"0.08");Array.from(p.children).filter(c=>c.hasAttribute(S)).forEach((c,i)=>{const ce=parseFloat(c.getAttribute("data-anim-delay")||"0");c.setAttribute("data-anim-delay",String(ce+i*g))})});document.querySelectorAll("["+Q+"]").forEach(p=>{Array.from(p.children).filter(c=>c.hasAttribute(S)).forEach((c,i)=>{if(i>0)c.style.visibility="hidden";c.addEventListener("transitionend",function h(){if(i+1<p.children.length){const n=p.children[i+1];n.style.visibility="visible";n.classList.add(V)}},{once:true})})});document.addEventListener("click",()=>{Object.values(D).forEach(g=>{if(g.length>0){const n=g.shift();setTimeout(()=>n.el.classList.add(V),n.delay*1000)}})})})();
</script>
```
### 1.3 引擎工作原理
```
页面加载
│
├─ 扫描所有带 data-anim 属性的元素
│ ├─ 设置初始状态(opacity:0 + 起始 transform/clip-path)
│ ├─ 设置 CSS transition(duration + easing)
│ └─ 根据 trigger 决定激活时机
│
├─ 扫描 data-anim-stagger 容器
│ └─ 为子元素追加递增 delay(默认 80ms 间隔)
│
├─ 扫描 data-anim-sequence 容器
│ └─ 监听 transitionend,触发下一个子元素
│
├─ 扫描 data-anim-click-group
│ └─ 注册到 clickGroups,按页面点击逐组弹出
│
└─ 激活方式:为元素添加 .anim-visible 类 → CSS transition 接管过渡
```
---
## 二、完整动效目录
每种动效都有一个 `data-anim` 标识值、对应的 CSS 初始状态和 `.anim-visible` 目标状态。
### 2.1 fade-up — 上浮淡入
最通用的入场动效,适用于标题、卡片、正文段落。
**初始状态 CSS:**
```css
[data-anim="fade-up"] {
opacity: 0;
transform: translateY(30px);
transition: all 0.6s cubic-bezier(0.16, 1, 0.3, 1);
}
```
**.anim-visible 目标状态:**
```css
[data-anim="fade-up"].anim-visible {
opacity: 1;
transform: translateY(0);
}
```
**使用示例:**
```html
<h1 data-anim="fade-up" data-anim-duration="0.8s">主标题从下方浮入</h1>
<p data-anim="fade-up" data-anim-delay="0.2s">副标题延迟 0.2 秒</p>
```
---
### 2.2 fade-in — 纯淡入
仅透明度变化,无位移。适用于已有定位的元素、图片、图表。
**初始状态 CSS:**
```css
[data-anim="fade-in"] {
opacity: 0;
transition: all 0.6s cubic-bezier(0.16, 1, 0.3, 1);
}
```
**.anim-visible 目标状态:**
```css
[data-anim="fade-in"].anim-visible {
opacity: 1;
}
```
**使用示例:**
```html
<img data-anim="fade-in" data-anim-delay="0.4s"
src="./chart_1.png" style="width:1080px;height:360px;" alt="图表">
```
---
### 2.3 scale-in — 缩放淡入
从 0.85 倍放大至 1 倍同时淡入。适用于封面标题、KPI 数字、核心数据。
**初始状态 CSS:**
```css
[data-anim="scale-in"] {
opacity: 0;
transform: scale(0.85);
transition: all 0.6s cubic-bezier(0.16, 1, 0.3, 1);
}
```
**.anim-visible 目标状态:**
```css
[data-anim="scale-in"].anim-visible {
opacity: 1;
transform: scale(1);
}
```
**使用示例:**
```html
<!-- 封面 Hero 标题 -->
<h1 data-anim="scale-in" data-anim-duration="0.8s"
style="font-size:56px;font-weight:bold;">年度战略报告</h1>
<!-- KPI 数字 -->
<span data-anim="scale-in" data-anim-delay="0.2s"
style="font-size:96px;font-weight:bold;color:var(--primary);">86%</span>
```
---
### 2.4 slide-left — 左侧滑入
从左侧 60px 外滑入同时淡入。适用于时间线节点、列表项。
**初始状态 CSS:**
```css
[data-anim="slide-left"] {
opacity: 0;
transform: translateX(-60px);
transition: all 0.6s cubic-bezier(0.16, 1, 0.3, 1);
}
```
**.anim-visible 目标状态:**
```css
[data-anim="slide-left"].anim-visible {
opacity: 1;
transform: translateX(0);
}
```
**使用示例:**
```html
<!-- 时间线节点 -->
<div data-anim="slide-left" style="display:flex;align-items:flex-start;">
<span style="font-size:16px;color:var(--primary);">2024 Q3</span>
<div style="flex:1;">里程碑内容描述</div>
</div>
```
---
### 2.5 slide-right — 右侧滑入
从右侧 60px 外滑入同时淡入。适用于封面 Split 布局左侧文字、列表项。
**初始状态 CSS:**
```css
[data-anim="slide-right"] {
opacity: 0;
transform: translateX(60px);
transition: all 0.6s cubic-bezier(0.16, 1, 0.3, 1);
}
```
**.anim-visible 目标状态:**
```css
[data-anim="slide-right"].anim-visible {
opacity: 1;
transform: translateX(0);
}
```
**使用示例:**
```html
<!-- 对比布局左侧方案 -->
<div data-anim="slide-right" data-anim-duration="0.7s"
style="flex:1;background:var(--card-bg);border-radius:16px;">
<h3>方案 A</h3>
<p>方案 A 的优势描述</p>
</div>
```
---
### 2.6 wipe-right — 从左到右擦除揭示
使用 `clip-path` 实现从左到右的擦除效果。适用于装饰线、底部色条、分隔线。
**初始状态 CSS:**
```css
[data-anim="wipe-right"] {
opacity: 0;
clip-path: inset(0 100% 0 0);
transition: all 0.6s cubic-bezier(0.16, 1, 0.3, 1);
}
```
**.anim-visible 目标状态:**
```css
[data-anim="wipe-right"].anim-visible {
opacity: 1;
clip-path: inset(0 0 0 0);
}
```
**使用示例:**
```html
<!-- 章节过渡页装饰线 -->
<div data-anim="wipe-right" data-anim-delay="0.35s" data-anim-duration="0.6s"
style="width:200px;height:4px;background:var(--primary);border-radius:2px;"></div>
<!-- 封面底部色条 -->
<div data-anim="wipe-right" data-anim-duration="0.8s"
style="position:absolute;bottom:0;left:0;width:1280px;height:140px;
background:linear-gradient(135deg,var(--primary),var(--secondary));"></div>
```
---
### 2.7 zoom-in — 放大淡入
从 0.5 倍大幅缩放至 1 倍,冲击力强。适用于 breathing 页面大字、核心结论。
**初始状态 CSS:**
```css
[data-anim="zoom-in"] {
opacity: 0;
transform: scale(0.5);
transition: all 0.6s cubic-bezier(0.16, 1, 0.3, 1);
}
```
**.anim-visible 目标状态:**
```css
[data-anim="zoom-in"].anim-visible {
opacity: 1;
transform: scale(1);
}
```
**使用示例:**
```html
<!-- breathing 大字页 -->
<p data-anim="zoom-in" data-anim-duration="1s"
style="font-size:96px;font-weight:bold;color:var(--primary);">
86%
</p>
```
---
### 2.8 pulse-soft — 柔和脉冲(强调动效)
唯一使用 CSS `@keyframes` 的动效(引擎自动注入)。适用于 CTA 按钮、关键行动点。
**CSS @keyframes(引擎自动生成):**
```css
@keyframes pulse-soft-kf {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.03); }
}
```
**初始状态:** 无特殊初始状态
**使用示例:**
```html
<!-- CTA 按钮 -->
<div data-anim="pulse-soft"
style="padding:14px 40px;background:var(--accent);color:#FFFFFF;
border-radius:999px;font-size:18px;font-weight:bold;
display:inline-block;cursor:pointer;">
立即体验
</div>
```
> **注意**:`pulse-soft` 是持续动效,不等待 `trigger`,页面加载即开始无限循环。不计入页面动效总数上限。
---
## 三、时序属性
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `data-anim-delay` | 数字(秒) | `0` | 动效延迟触发的时间,如 `"0.3"` 表示 0.3 秒后开始 |
| `data-anim-duration` | 数字(秒) | `0.6` | 动效过渡的持续时间,如 `"0.8"` 表示持续 0.8 秒 |
| `data-anim-trigger` | 字符串 | `"load"` | 触发方式:`"load"` 自动触发 / `"click"` 手动触发(配合 click-group) |
### 3.1 delay 堆叠示例
```html
<!-- 标题先出,副标题 0.2s 后出,作者 0.6s 后出 -->
<h1 data-anim="fade-up" data-anim-duration="0.8s">主标题</h1>
<p data-anim="fade-up" data-anim-delay="0.2s">副标题</p>
<p data-anim="fade-up" data-anim-delay="0.6s"
style="font-size:14px;color:var(--muted);">作者 / 日期</p>
```
### 3.2 duration 建议值
| 场景 | 推荐 duration | 说明 |
|------|--------------|------|
| 卡片/列表项 | `0.5s` | 快速入场,不拖沓 |
| 页面标题 | `0.6s` | 标准节奏 |
| 封面标题 | `0.8s` | 营造仪式感 |
| breathing 大字 | `1s` | 沉浸式出场 |
| wipe-right 色条 | `0.6s` ~ `0.8s` | 擦除动画略慢更有质感 |
---
## 四、组合属性
### 4.1 data-anim-stagger — 级联错峰
在**父容器**上设置,子元素按固定间隔依次激活。适用于卡片网格、KPI 行、列表。
| 属性 | 值 | 说明 |
|------|-----|------|
| `data-anim-stagger` | 数字(秒) | 每个子元素之间增加的延迟,推荐 `0.08`(80ms) |
**使用示例:**
```html
<!-- 4 张卡片以 80ms 间隔依次淡入 -->
<div data-anim-stagger>
<div data-anim="fade-up">卡片 1 — 0ms 激活</div>
<div data-anim="fade-up">卡片 2 — 80ms 激活</div>
<div data-anim="fade-up">卡片 3 — 160ms 激活</div>
<div data-anim="fade-up">卡片 4 — 240ms 激活</div>
</div>
```
**引擎行为:** 引擎将子元素已有的 `data-anim-delay` 值与 `i × gap` 相加,重写 delay 属性。
**注意:** 仅有 `data-anim` 属性的直接子元素参与 stagger,不含 `data-anim` 的子元素被跳过。
---
### 4.2 data-anim-sequence — 逐个触发
在**父容器**上设置,子元素严格按顺序逐个出现:前一个动画的 `transitionend` 事件触发下一个。适用于逐步展示的教学页、步骤页。
| 属性 | 值 | 说明 |
|------|-----|------|
| `data-anim-sequence` | 无值 | 标记即可,子元素逐个触发 |
**使用示例:**
```html
<!-- 3 个步骤逐个出现,前一个完成后触发下一个 -->
<div data-anim-sequence>
<div data-anim="fade-up" data-anim-duration="0.5s">步骤 1:需求分析</div>
<div data-anim="fade-up" data-anim-duration="0.5s">步骤 2:方案设计</div>
<div data-anim="fade-up" data-anim-duration="0.5s">步骤 3:落地实施</div>
</div>
```
**引擎行为:** 除第一个子元素外,其余子元素初始 `visibility: hidden`。监听每个子元素的 `transitionend` 事件,依次设置下一个子元素 `visibility: visible` 并添加 `.anim-visible`。
**注意:** `data-anim-sequence` 与 `data-anim-stagger` 不可同时使用于同一容器。sequence 更适用于内容需要观众逐条阅读的场景。
---
### 4.3 data-anim-click-group — 点击分组
元素按组编号,页面每次点击触发下一组元素出现。适用于演讲者手动控制节奏的演示。
| 属性 | 值 | 说明 |
|------|-----|------|
| `data-anim-click-group` | 数字字符串 | 组编号,如 `"1"`、`"2"`。同组元素首次点击同时出现 |
| `data-anim-trigger` | `"click"` | 必须配合设置为 `"click"` |
**使用示例:**
```html
<!-- 第 1 次点击:出现要点 A -->
<div data-anim="fade-up" data-anim-trigger="click"
data-anim-click-group="1">要点 A:市场趋势分析</div>
<!-- 第 2 次点击:出现要点 B 和图表 -->
<div data-anim="fade-up" data-anim-trigger="click"
data-anim-click-group="2">要点 B:竞品对比</div>
<img data-anim="fade-in" data-anim-trigger="click"
data-anim-click-group="2" src="./chart_1.png" alt="对比图表">
<!-- 第 3 次点击:出现结论 -->
<div data-anim="scale-in" data-anim-trigger="click"
data-anim-click-group="3">结论:推荐方案 B</div>
```
**引擎行为:** 页面加载时所有 click 触发元素保持初始隐藏状态。每次页面点击,从 `clickGroups` 取出编号最小的未处理组,为该组所有元素添加 `.anim-visible`。
---
## 五、CSS 过渡定义速查
| 动效类型 | transition 属性 | easing | .anim-visible 目标 |
|----------|----------------|--------|-------------------|
| `fade-up` | `all 0.6s` | `cubic-bezier(0.16, 1, 0.3, 1)` | `opacity:1; transform:translateY(0)` |
| `fade-in` | `all 0.6s` | `cubic-bezier(0.16, 1, 0.3, 1)` | `opacity:1` |
| `scale-in` | `all 0.6s` | `cubic-bezier(0.16, 1, 0.3, 1)` | `opacity:1; transform:scale(1)` |
| `slide-left` | `all 0.6s` | `cubic-bezier(0.16, 1, 0.3, 1)` | `opacity:1; transform:translateX(0)` |
| `slide-right` | `all 0.6s` | `cubic-bezier(0.16, 1, 0.3, 1)` | `opacity:1; transform:translateX(0)` |
| `wipe-right` | `all 0.6s` | `cubic-bezier(0.16, 1, 0.3, 1)` | `opacity:1; clip-path:inset(0 0 0 0)` |
| `zoom-in` | `all 0.6s` | `cubic-bezier(0.16, 1, 0.3, 1)` | `opacity:1; transform:scale(1)` |
| `pulse-soft` | — | `ease-in-out` (keyframes) | 循环缩放 1→1.03→1 |
**缓动函数说明:**
```
cubic-bezier(0.16, 1, 0.3, 1)
↑ ↑
快速启动 柔和收尾
```
该缓动曲线是"ease-out"的增强版——元素快速进入视野,在接近终点时柔和减速。适合演示场景的入场动效,避免线性过渡的生硬感。
---
## 六、完整使用示例
### 6.1 封面页(anchor,≤2 动效)
```html
<div style="width:1280px;height:720px;position:relative;overflow:hidden;
background:var(--bg);font-family:var(--body-font);">
<style>
:root {
--bg: #FAFBFC; --primary: #1A73E8; --secondary: #4285F4;
--accent: #FF6D00; --text: #1F2937; --muted: #6B7280;
--card-bg: #FFFFFF; --card-shadow: 0 2px 12px rgba(0,0,0,0.08);
--border-color: #E5E7EB;
--title-font: 'Microsoft YaHei', 'PingFang SC', 'Noto Sans SC', sans-serif;
--body-font: 'Microsoft YaHei', 'PingFang SC', 'Noto Sans SC', sans-serif;
}
</style>
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;
height:580px;text-align:center;">
<h1 data-anim="fade-up" data-anim-duration="0.8s"
style="font-size:48px;font-weight:bold;color:var(--text);margin:0;
font-family:var(--title-font);">
数字化转型战略报告
</h1>
<p data-anim="fade-up" data-anim-delay="0.2s"
style="font-size:24px;color:var(--muted);margin:16px 0 0 0;">
2025 年度规划与路线图
</p>
</div>
<div data-anim="wipe-right" data-anim-duration="0.8s"
style="position:absolute;bottom:0;left:0;width:1280px;height:140px;
background:linear-gradient(135deg,var(--primary),var(--secondary));">
<p data-anim="fade-up" data-anim-delay="0.6s"
style="position:absolute;bottom:24px;left:80px;font-size:14px;
color:rgba(255,255,255,0.85);margin:0;">
战略规划部 / 2025.07
</p>
</div>
<script>/* 动画引擎脚本 */</script>
</div>
```
### 6.2 内容页 — 2×2 Cards(dense,5 动效)
```html
<div style="width:1280px;height:720px;position:relative;overflow:hidden;
background:var(--bg);font-family:var(--body-font);">
<style>
:root { /* 色值省略,替换为 spec_lock palette */ }
</style>
<!-- 标题:1 个动效 -->
<h2 data-anim="fade-up"
style="position:absolute;top:40px;left:80px;font-size:32px;font-weight:bold;
color:var(--text);margin:0;">
核心能力矩阵
</h2>
<!-- 4 张卡片:stagger 级联,4 个动效 -->
<div data-anim-stagger
style="position:absolute;top:110px;left:80px;width:1120px;
display:grid;grid-template-columns:repeat(2,548px);
grid-template-rows:repeat(2,260px);gap:24px;">
<div data-anim="fade-up" style="background:var(--card-bg);...">卡片 1</div>
<div data-anim="fade-up" style="background:var(--card-bg);...">卡片 2</div>
<div data-anim="fade-up" style="background:var(--card-bg);...">卡片 3</div>
<div data-anim="fade-up" style="background:var(--card-bg);...">卡片 4</div>
</div>
<!-- 共 5 个动效元素,未超过 dense 上限 6 -->
<script>/* 动画引擎脚本 */</script>
</div>
```
### 6.3 breathing — Big Number + Label(1 动效)
```html
<div style="width:1280px;height:720px;position:relative;overflow:hidden;
background:var(--bg);font-family:var(--body-font);">
<style>
:root { /* 色值省略 */ }
</style>
<div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);
text-align:center;">
<p data-anim="scale-in" data-anim-duration="1s"
style="font-size:96px;font-weight:bold;color:var(--primary);margin:0;">
86%
</p>
<p style="font-size:24px;color:var(--muted);margin:16px 0 0 0;">
客户满意度达标率
</p>
</div>
<script>/* 动画引擎脚本 */</script>
</div>
```
---
## 七、设计约束
| # | 约束 | 说明 |
|---|------|------|
| 1 | **每页 ≤6 个独立 data-anim 元素** | stagger 父容器不计入,子元素各自计数;pulse-soft 不计入 |
| 2 | **动画引擎脚本是页面中唯一的 `<script>` 标签** | 禁止任何其他 JavaScript(事件绑定、库引用、内联脚本等) |
| 3 | **禁止 CSS @keyframes** | 除 `pulse-soft-kf`(引擎自动注入)外,不定义任何关键帧动画 |
| 4 | **禁止 CSS animation 属性** | 除 `pulse-soft-kf` 外,不使用 `animation` 属性;所有动效走 `transition` |
| 5 | **动画初始状态由引擎 JS 设置** | 不需在 CSS 中手写初始 `opacity:0` 或初始 `transform`,引擎注入 |
| 6 | **.anim-visible 类由引擎自动添加** | 不要在 HTML 或 CSS 中手动添加此 class |
| 7 | **stagger 与 sequence 互斥** | 同一父容器不可同时使用 `data-anim-stagger` 和 `data-anim-sequence` |
| 8 | **锚定 spec_lock.md 的 rhythm 选择动效策略** | anchor→fade-up/wipe-right、dense→stagger+fade-up/scale-in、breathing→scale-in/zoom-in |
---
## 八、动效选择决策树
```
页面 rhythm 是什么?
│
├─ anchor(封面/章节过渡)
│ ├─ 封面标题 → fade-up 或 scale-in(duration: 0.8s)
│ ├─ 副标题 → fade-up(delay: +0.2s)
│ ├─ 装饰色条 → wipe-right(duration: 0.8s)
│ └─ 作者/日期 → fade-up(delay: +0.6s)
│
├─ dense(内容页)
│ ├─ 页面标题 → fade-up(无 delay)
│ ├─ 卡片网格 → data-anim-stagger + 子卡片 fade-up
│ ├─ KPI 指标 → data-anim-stagger + 子项 scale-in
│ ├─ 列表要点 → data-anim-stagger + 子项 slide-left/slide-right
│ └─ 图表/图片 → fade-in 或 fade-up(delay: 0.3-0.4s)
│
└─ breathing(金句/大字)
├─ 金句文字 → fade-up(duration: 0.8s)
├─ 大字数字 → scale-in 或 zoom-in(duration: 1s)
└─ 标签说明 → 不动效(静态出现)
```
---
## 九、常见问题排查
| 问题 | 原因 | 解决 |
|------|------|------|
| 动效不触发 | 未嵌入动画引擎 `<script>` | 确认每页末尾有引擎脚本 |
| 所有元素同时出现 | 未使用 `data-anim-stagger` | 在父容器添加 `data-anim-stagger` 属性 |
| stagger 间隔过大/过小 | gap 值不匹配 | 调整 `data-anim-stagger` 值(默认 0.08s,可设为 0.12 或 0.05) |
| 点击触发不工作 | `data-anim-trigger` 未设为 `"click"` | 同时设置 `data-anim-trigger="click"` 和 `data-anim-click-group="N"` |
| wipe-right 无效 | 元素使用了 `overflow:visible` 之外的 clip-path 不兼容布局 | 确保 wipe-right 元素为独立块级元素,不在 flex/grid 深层嵌套 |
| 移动端卡顿 | transition 同时涉及 `transform` 和 `opacity` 以外的属性 | 动效仅使用 `transform` + `opacity` + `clip-path`,均属 GPU 加速属性 |
@@ -0,0 +1,91 @@
# HTML 输出质检清单(QA Checklist)
本文档是 HTML 演示文稿输出的全面质量检查清单。Agent 在交付前必须逐项核验。
---
## 零、流程合规检查(4 项)— ⛔ 先于所有技术检查
| # | 检查项 | 标准 | 通过 |
|---|--------|------|------|
| 1 | 阶段 a human_interaction | 已调用 `human_interaction(selection)` 完成方向锚定(风格/受众/场合/画布/页数/叙事风格),用户已选择 | ☐ |
| 2 | 阶段 b human_interaction | 已调用 `human_interaction(approval)` 完成设计锁定(色板 hex + 字体 + 页面节奏),用户已审批 | ☐ |
| 3 | 阶段 c human_interaction | 已调用 `human_interaction(approval)` 完成内容大纲审批(逐页标题+要点+配图策略+图表),用户已审批 | ☐ |
| 4 | 无跳过/合并 | 三个阶段各自独立完成,未合并、未跳过、未用纯文本或 `tool_call(confirm)` 替代 `human_interaction` | ☐ |
> **以上 4 项中任何一项未通过,不得进入技术检查阶段,必须回到对应阶段重新与用户对齐。**
---
## 一、结构检查(8 项)
| # | 检查项 | 标准 | 通过 |
|---|--------|------|------|
| 1 | 根容器尺寸 | 根 `div` 或 `body` 内主容器精确为 `width: 1280px; height: 720px` | ☐ |
| 2 | 溢出控制 | 根容器设置 `overflow: hidden`,无内容溢出可视区域 | ☐ |
| 3 | 定位方式 | 禁止使用 `position: fixed` 和 `position: sticky`,仅允许 `relative` / `absolute` / `static` | ☐ |
| 4 | 标签闭合 | 所有 HTML 标签正确闭合,无未闭合标签 | ☐ |
| 5 | 脚本限制 | 除动画引擎外,无其他 `<script>` 标签,无事件绑定 | ☐ |
| 6 | 嵌入媒体限制 | 不使用 `<iframe>`、`<video>`、`<audio>` 标签 | ☐ |
| 7 | 特殊字符转义 | 内容中的 `&` 已转义为 `&amp;` | ☐ |
| 8 | 嵌套层级 | HTML 元素最大嵌套深度不超过 4 层 | ☐ |
---
## 二、样式检查(8 项)
| # | 检查项 | 标准 | 通过 |
|---|--------|------|------|
| 1 | 颜色调色板 | 所有颜色值来源于 `spec_lock.md` 中定义的调色板,无额外/内联颜色(透明除外) | ☐ |
| 2 | CSS 变量定义 | 所有颜色变量在 `:root` 中统一定义,变量名与 spec_lock 一致 | ☐ |
| 3 | 字体引用 | 不引用任何外部字体(无 `@import url()` 字体、无 Google Fonts 等) | ☐ |
| 4 | 字体栈 | 使用系统安全字体栈(如 `'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif`) | ☐ |
| 5 | 禁止 CSS 动画 | 不使用 `@keyframes`、`animation` 属性(动画仅由 JS 引擎驱动) | ☐ |
| 6 | 样式位置 | 所有样式写在 `<style>` 块中或使用内联 `style` 属性,无外部样式表 | ☐ |
| 7 | 卡片一致性 | 卡片统一使用相同的 `border-radius`、`box-shadow`、`padding` 值 | ☐ |
| 8 | 排版层级 | 标题/副标题/正文/注释的字体大小、字重、颜色层级与 spec_lock 一致 | ☐ |
---
## 三、动画检查(5 项)
| # | 检查项 | 标准 | 通过 |
|---|--------|------|------|
| 1 | 动画引擎完整性 | 动画引擎 `<script>` 存在且为页面内**唯一**脚本 | ☐ |
| 2 | 动画数量限制 | 锚点页(封面/封底/分隔页)≤2 个动画;信息密集页 ≤6 个动画;呼吸动画 ≤1 个 | ☐ |
| 3 | data-anim 属性 | 所有 `data-anim` 属性拼写正确(`fade-up` / `scale-in` / `slide-left` / `slide-right` / `zoom-in`),无自定义动画名 | ☐ |
| 4 | 无 CSS 动画冲突 | CSS 中无 `transition` 属性对动画元素干扰,无 `animation-delay` 等可产生冲突的属性 | ☐ |
| 5 | 交错延迟合理 | `data-anim-delay` 取值在 0~2 秒范围内,`data-anim-stagger` 容器内子元素间隔合理 | ☐ |
---
## 四、内容检查(5 项)
| # | 检查项 | 标准 | 通过 |
|---|--------|------|------|
| 1 | 标题一致性 | 页面标题/主标题与 `spec_lock.md` 中定义的内容大纲一致 | ☐ |
| 2 | 要点完整性 | `spec_lock.md` 中列出的关键要点均已呈现在页面中 | ☐ |
| 3 | 数据真实性 | 无伪造数据、图表数值、统计数据、引用来源——如 spec_lock 未提供具体数据,使用占位标记而非虚构 | ☐ |
| 4 | 图片路径 | `<img>` 标签的 `src` 路径指向同目录下的实际图片文件,路径使用相对路径 | ☐ |
| 5 | 内容不溢出 | 在 1280×720 画布内所有内容完全可见,无截断或越过边界的内容 | ☐ |
---
## 五、文件命名与交付
| # | 检查项 | 标准 | 通过 |
|---|--------|------|------|
| 1 | 目录结构 | 所有 HTML 文件位于 `workspace/ppt_{topic}/` 目录下 | ☐ |
| 2 | 文件命名 | 页面文件按顺序命名为 `1.html`、`2.html`、...,每个为自包含的完整 HTML 文档 | ☐ |
| 3 | 可独立打开 | 每个 `{N}.html` 可在浏览器中直接打开,画布尺寸正确,动效正常 | ☐ |
| 4 | 图片本地化 | 所有引用的图片文件存放在与 HTML 同目录下 | ☐ |
| 5 | Base64 转换 | 最终交付前必须运行 `python {SKILL_DIR}/attachments/complete_htmls_to_base64.py {workspace_dir}` 将所有图片转为内嵌 base64 | ☐ |
---
## 质检流程
1. **Agent 自检**:生成每页 HTML 后,逐项核对上述清单
2. **独立打开验证**:在浏览器中打开每个 `{N}.html` 验证视觉效果
3. **自动化检查**:运行 `python {SKILL_DIR}/attachments/complete_htmls_to_base64.py {workspace_dir}` 确保图片路径全部可解析
4. **通过标准**:所有检查项均为 ☑ 方可交付
@@ -0,0 +1,264 @@
#!/usr/bin/env python3
"""
complete_htmls_to_base64.py
将 HTML 文件中的外部图片引用转换为 base64 内嵌 Data URI。
适用于 ppt_maker 技能生成的 HTML 演示文稿。
用法:
python complete_htmls_to_base64.py workspace/ppt_my_topic/
python complete_htmls_to_base64.py workspace/ppt_my_topic/ --force
功能:
- 查找目录中所有 .html 文件
- 将 <img src="..."> 中的外部图片路径替换为 data URI
- 将 style 属性中 url('...') 的外部图片路径替换为 data URI
- 跳过以 _complete.html 结尾的文件
- 输出文件命名为 {原文件名}_complete.html
"""
import os
import sys
import re
import base64
import argparse
from pathlib import Path
# 支持的图片格式及对应 MIME 类型
MIME_TYPES = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.bmp': 'image/bmp',
'.ico': 'image/x-icon',
}
# 匹配 <img> 标签的 src 属性(非 data URI)
IMG_SRC_PATTERN = re.compile(
r'(<img\b[^>]*?\bsrc\s*=\s*["\'])(?!data:)([^"\']+?)(["\'])',
re.IGNORECASE
)
# 匹配 url('...') 引用(非 data URI),在 style 属性内
URL_PATTERN = re.compile(
r'(url\s*\(\s*["\']?)(?!data:)([^"\')\s]+?)(["\']?\s*\))',
re.IGNORECASE
)
def is_data_uri(path: str) -> bool:
"""检查路径是否已经是 data URI"""
return path.startswith('data:')
def is_external_url(path: str) -> bool:
"""检查路径是否为外部 URL"""
return path.startswith('http://') or path.startswith('https://')
def get_mime_type(file_path: str) -> str:
"""根据文件扩展名获取 MIME 类型"""
ext = os.path.splitext(file_path)[1].lower()
return MIME_TYPES.get(ext, 'application/octet-stream')
def image_to_base64(image_path: str) -> str:
"""将图片文件转换为 base64 字符串"""
try:
with open(image_path, 'rb') as f:
image_data = f.read()
return base64.b64encode(image_data).decode('ascii')
except FileNotFoundError:
print(f" ⚠ 警告: 文件不存在 - {image_path}")
return None
except PermissionError:
print(f" ⚠ 警告: 无权限读取 - {image_path}")
return None
except Exception as e:
print(f" ⚠ 警告: 读取失败 - {image_path} ({e})")
return None
def make_data_uri(image_path: str, base64_data: str) -> str:
"""构造 data URI"""
mime = get_mime_type(image_path)
return f'data:{mime};base64,{base64_data}'
def replace_img_src(match: re.Match, base_dir: str) -> str:
"""替换 img 标签中的 src 为 data URI"""
prefix = match.group(1)
src_path = match.group(2)
suffix = match.group(3)
# 跳过已处理的
if is_data_uri(src_path):
return match.group(0)
# 跳过外部 URL
if is_external_url(src_path):
return match.group(0)
# 解析相对路径
full_image_path = os.path.normpath(os.path.join(base_dir, src_path))
base64_data = image_to_base64(full_image_path)
if base64_data is None:
return match.group(0) # 保留原样
data_uri = make_data_uri(full_image_path, base64_data)
return f'{prefix}{data_uri}{suffix}'
def replace_url_in_style(match: re.Match, base_dir: str) -> str:
"""替换 style 属性中 url() 引用为 data URI"""
prefix = match.group(1)
url_path = match.group(2)
suffix = match.group(3)
# 跳过已处理的
if is_data_uri(url_path):
return match.group(0)
# 跳过外部 URL
if is_external_url(url_path):
return match.group(0)
# 解析相对路径
full_image_path = os.path.normpath(os.path.join(base_dir, url_path))
base64_data = image_to_base64(full_image_path)
if base64_data is None:
return match.group(0)
data_uri = make_data_uri(full_image_path, base64_data)
return f'{prefix}{data_uri}{suffix}'
def process_html_file(html_path: str, force: bool = False) -> bool:
"""
处理单个 HTML 文件,将外部图片转换为 base64。
返回 True 表示成功处理,False 表示跳过或失败。
"""
base_name = os.path.basename(html_path)
dir_name = os.path.dirname(html_path)
# 跳过已完成的文件
if base_name.endswith('_complete.html'):
print(f" ⏭ 跳过(已是 _complete 文件): {base_name}")
return False
output_path = os.path.join(
dir_name,
f'{os.path.splitext(base_name)[0]}_complete.html'
)
# 检查是否已存在
if os.path.exists(output_path) and not force:
print(f" ⏭ 跳过(输出已存在,使用 --force 覆盖): {os.path.basename(output_path)}")
return False
# 读取 HTML 内容
with open(html_path, 'r', encoding='utf-8') as f:
content = f.read()
base_dir = dir_name
# 替换 <img src="...">
new_content, img_count = IMG_SRC_PATTERN.subn(
lambda m: replace_img_src(m, base_dir), content
)
img_replaced = img_count
# 替换 url('...')
new_content, url_count = URL_PATTERN.subn(
lambda m: replace_url_in_style(m, base_dir), new_content
)
url_replaced = url_count
total_replaced = img_replaced + url_replaced
# 写入输出文件
with open(output_path, 'w', encoding='utf-8') as f:
f.write(new_content)
print(f" ✓ 已处理: {base_name} -> {os.path.basename(output_path)}")
print(f" - <img> 替换: {img_replaced} 处")
print(f" - url() 替换: {url_replaced} 处")
print(f" - 总计: {total_replaced} 处")
return True
def main():
parser = argparse.ArgumentParser(
description='将 HTML 文件中的外部图片引用转换为 base64 Data URI'
)
parser.add_argument(
'directory',
nargs='?',
default='.',
help='包含 HTML 文件的目录路径(默认: 当前目录)'
)
parser.add_argument(
'--force',
action='store_true',
help='强制覆盖已存在的 _complete.html 文件'
)
args = parser.parse_args()
target_dir = os.path.abspath(args.directory)
if not os.path.isdir(target_dir):
print(f"✗ 错误: 目录不存在 - {target_dir}")
sys.exit(1)
print(f"📁 目标目录: {target_dir}")
print(f"🔍 扫描 HTML 文件...\n")
# 查找所有 .html 文件
html_files = sorted(
str(p) for p in Path(target_dir).glob('*.html')
if not str(p).endswith('_complete.html')
)
if not html_files:
print("⚠ 未找到需要处理的 HTML 文件")
return
print(f"找到 {len(html_files)} 个 HTML 文件\n")
# 处理每个文件
success_count = 0
skip_count = 0
for html_file in html_files:
result = process_html_file(html_file, force=args.force)
if result:
success_count += 1
else:
skip_count += 1
# 汇总
print(f"\n{'='*50}")
print(f"📊 处理完成:")
print(f" - 成功: {success_count} 个文件")
print(f" - 跳过: {skip_count} 个文件")
print(f" - 输出目录: {target_dir}")
# 列出所有生成的 _complete.html 文件
complete_files = sorted(
str(p) for p in Path(target_dir).glob('*_complete.html')
)
if complete_files:
print(f"\n📦 生成的 _complete.html 文件:")
for cf in complete_files:
print(f" - {os.path.basename(cf)}")
if __name__ == '__main__':
main()
@@ -0,0 +1,219 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- CUSTOMIZE: 修改页面标题 -->
<title>内容 - 2×2 卡片网格</title>
<style>
:root {
/* CUSTOMIZE: 替换为 spec_lock.md 中定义的调色板 */
--bg: #FAFBFC;
--primary: #1E293B;
--secondary: #F1F5F9;
--accent: #D97706;
--text: #0F172A;
--muted: #64748B;
--card-bg: #FFFFFF;
--card-shadow: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04);
--border-color: #E2E8F0;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 1280px;
height: 720px;
overflow: hidden;
font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
background: var(--bg);
color: var(--text);
}
.page {
width: 1280px;
height: 720px;
position: relative;
overflow: hidden;
padding: 60px 80px;
}
/* 页面标题 */
.page-title {
font-size: 32px;
font-weight: 700;
color: var(--text);
margin-bottom: 40px;
letter-spacing: 0.02em;
}
/* 2×2 卡片网格 */
.card-grid {
display: grid;
grid-template-columns: 580px 580px;
grid-template-rows: 260px 260px;
gap: 24px;
}
/* 卡片 */
.card {
width: 580px;
height: 260px;
background: var(--secondary);
border-radius: 16px;
padding: 32px;
display: flex;
flex-direction: column;
justify-content: flex-start;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.25);
border: 1px solid rgba(255, 255, 255, 0.06);
transition: border-color 0.3s;
}
.card:hover {
border-color: var(--accent);
}
/* 卡片图标 */
.card-icon {
width: 44px;
height: 44px;
border-radius: 10px;
background: linear-gradient(135deg, var(--accent), var(--primary));
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
margin-bottom: 18px;
color: var(--text);
}
/* 卡片标题 */
.card-title {
font-size: 20px;
font-weight: 700;
color: var(--text);
margin-bottom: 10px;
}
/* 卡片描述 */
.card-desc {
font-size: 15px;
font-weight: 400;
color: var(--muted);
line-height: 1.6;
}
/* 左上角装饰 */
.page::before {
content: '';
position: absolute;
top: 0;
right: 0;
width: 300px;
height: 300px;
background: radial-gradient(circle, rgba(59, 130, 246, 0.06) 0%, transparent 70%);
pointer-events: none;
}
</style>
</head>
<body>
<!-- CUSTOMIZE: 修改此模板以匹配你的 spec_lock.md 内容 -->
<div class="page">
<!-- CUSTOMIZE: 替换为实际页面标题 -->
<h2 class="page-title" data-anim="fade-up" data-anim-duration="0.6s">
页面标题
</h2>
<div class="card-grid" data-anim-stagger>
<!-- 卡片 1 -->
<!-- CUSTOMIZE: 替换图标、标题和描述 -->
<div class="card" data-anim="fade-up">
<div class="card-icon">01</div>
<h3 class="card-title">卡片标题一</h3>
<p class="card-desc">
卡片描述内容,简要说明此卡片的核心信息。保持简洁,不超过两行。
</p>
</div>
<!-- 卡片 2 -->
<!-- CUSTOMIZE: 替换图标、标题和描述 -->
<div class="card" data-anim="fade-up">
<div class="card-icon">02</div>
<h3 class="card-title">卡片标题二</h3>
<p class="card-desc">
卡片描述内容,简要说明此卡片的核心信息。保持简洁,不超过两行。
</p>
</div>
<!-- 卡片 3 -->
<!-- CUSTOMIZE: 替换图标、标题和描述 -->
<div class="card" data-anim="fade-up">
<div class="card-icon">03</div>
<h3 class="card-title">卡片标题三</h3>
<p class="card-desc">
卡片描述内容,简要说明此卡片的核心信息。保持简洁,不超过两行。
</p>
</div>
<!-- 卡片 4 -->
<!-- CUSTOMIZE: 替换图标、标题和描述 -->
<div class="card" data-anim="fade-up">
<div class="card-icon">04</div>
<h3 class="card-title">卡片标题四</h3>
<p class="card-desc">
卡片描述内容,简要说明此卡片的核心信息。保持简洁,不超过两行。
</p>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function(){
var els = document.querySelectorAll('[data-anim]');
els.forEach(function(el, i){
el.style.opacity = '0';
var anim = el.getAttribute('data-anim');
if (anim === 'fade-up') el.style.transform = 'translateY(30px)';
else if (anim === 'scale-in') el.style.transform = 'scale(0.85)';
else if (anim === 'slide-left') el.style.transform = 'translateX(-60px)';
else if (anim === 'slide-right') el.style.transform = 'translateX(60px)';
else if (anim === 'zoom-in') el.style.transform = 'scale(0.5)';
el.style.transition = 'opacity '+(el.getAttribute('data-anim-duration')||'0.6s')+' '+(el.getAttribute('data-anim-easing')||'cubic-bezier(0.34,1.56,0.64,1)')+', transform '+(el.getAttribute('data-anim-duration')||'0.6s')+' '+(el.getAttribute('data-anim-easing')||'cubic-bezier(0.34,1.56,0.64,1)');
});
function animStagger(parent, gap){
var children = Array.from(parent.children).filter(function(c){ return c.hasAttribute('data-anim'); });
children.forEach(function(c, i){
var delay = parseFloat(c.getAttribute('data-anim-delay')||'0');
setTimeout(function(){ c.classList.add('anim-visible'); c.style.opacity = '1'; c.style.transform = 'none'; }, (i * (gap||80)) + delay*1000);
});
}
function animSequence(parent, gap){
var children = Array.from(parent.children).filter(function(c){ return c.hasAttribute('data-anim'); });
var totalDelay = 0;
children.forEach(function(c){
setTimeout(function(){ c.classList.add('anim-visible'); c.style.opacity = '1'; c.style.transform = 'none'; }, totalDelay);
totalDelay += (gap||80);
});
}
function triggerClickGroup(group){
var groupEls = document.querySelectorAll('[data-anim-click-group="'+group+'"]');
groupEls.forEach(function(el, i){
setTimeout(function(){ el.classList.add('anim-visible'); el.style.opacity = '1'; el.style.transform = 'none'; }, i*60);
});
}
setTimeout(function(){
els.forEach(function(el){
if (!el.closest('[data-anim-stagger]') && !el.closest('[data-anim-sequence]') && !el.getAttribute('data-anim-click-group')){
var delay = parseFloat(el.getAttribute('data-anim-delay')||'0') * 1000;
setTimeout(function(){ el.classList.add('anim-visible'); el.style.opacity = '1'; el.style.transform = 'none'; }, delay);
}
});
document.querySelectorAll('[data-anim-stagger]').forEach(function(p){ animStagger(p, 80); });
document.querySelectorAll('[data-anim-sequence]').forEach(function(p){ animSequence(p, 300); });
}, 100);
});
</script>
</body>
</html>
@@ -0,0 +1,262 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- CUSTOMIZE: 修改页面标题 -->
<title>内容 - 3 KPI 指标</title>
<style>
:root {
/* CUSTOMIZE: 替换为 spec_lock.md 中定义的调色板 */
--bg: #FAFBFC;
--primary: #1E293B;
--secondary: #F1F5F9;
--accent: #D97706;
--text: #0F172A;
--muted: #64748B;
--card-bg: #FFFFFF;
--card-shadow: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04);
--border-color: #E2E8F0;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 1280px;
height: 720px;
overflow: hidden;
font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
background: var(--bg);
color: var(--text);
}
.page {
width: 1280px;
height: 720px;
position: relative;
overflow: hidden;
padding: 60px 80px;
}
/* 页面标题 */
.page-title {
font-size: 32px;
font-weight: 700;
color: var(--text);
margin-bottom: 36px;
letter-spacing: 0.02em;
}
/* KPI 卡片行 */
.kpi-row {
display: flex;
gap: 30px;
margin-bottom: 40px;
}
/* KPI 卡片 */
.kpi-card {
width: 380px;
height: 120px;
background: var(--secondary);
border-radius: 14px;
padding: 24px 30px;
display: flex;
flex-direction: column;
justify-content: center;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.25);
border: 1px solid rgba(255, 255, 255, 0.06);
position: relative;
overflow: hidden;
}
/* 卡片顶部彩色条 */
.kpi-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 3px;
}
.kpi-card.card-1::before { background: var(--accent); }
.kpi-card.card-2::before { background: #22c55e; }
.kpi-card.card-3::before { background: #f59e0b; }
.kpi-card .kpi-label {
font-size: 14px;
font-weight: 500;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 8px;
}
.kpi-card .kpi-value {
font-size: 36px;
font-weight: 800;
color: var(--text);
line-height: 1;
letter-spacing: -0.02em;
}
.kpi-card .kpi-unit {
font-size: 16px;
font-weight: 400;
color: var(--muted);
margin-left: 4px;
}
.kpi-card .kpi-change {
font-size: 13px;
font-weight: 600;
color: #22c55e;
}
.kpi-card .kpi-change.negative { color: #ef4444; }
/* 底部图表区域 */
.chart-area {
width: 100%;
height: 340px;
background: var(--secondary);
border-radius: 14px;
padding: 24px 30px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.25);
border: 1px solid rgba(255, 255, 255, 0.06);
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.chart-area .chart-title {
position: absolute;
top: 20px;
left: 30px;
font-size: 16px;
font-weight: 600;
color: var(--muted);
}
.chart-area .placeholder-text {
color: var(--muted);
font-size: 15px;
opacity: 0.5;
}
/* 背景装饰 */
.page::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 600px;
height: 2px;
background: linear-gradient(90deg, transparent, rgba(59, 130, 246, 0.15), transparent);
}
</style>
</head>
<body>
<!-- CUSTOMIZE: 修改此模板以匹配你的 spec_lock.md 内容 -->
<div class="page">
<!-- CUSTOMIZE: 替换为实际页面标题 -->
<h2 class="page-title" data-anim="fade-up" data-anim-duration="0.6s">
核心指标概览
</h2>
<div class="kpi-row" data-anim-stagger>
<!-- KPI 1 -->
<!-- CUSTOMIZE: 替换标签、数值和单位 -->
<div class="kpi-card card-1" data-anim="scale-in">
<span class="kpi-label">总收入</span>
<div>
<span class="kpi-value">128</span>
<span class="kpi-unit">亿元</span>
<span class="kpi-change">↑ 12.5%</span>
</div>
</div>
<!-- KPI 2 -->
<!-- CUSTOMIZE: 替换标签、数值和单位 -->
<div class="kpi-card card-2" data-anim="scale-in">
<span class="kpi-label">活跃用户</span>
<div>
<span class="kpi-value">3.6</span>
<span class="kpi-unit">M</span>
<span class="kpi-change">↑ 8.3%</span>
</div>
</div>
<!-- KPI 3 -->
<!-- CUSTOMIZE: 替换标签、数值和单位 -->
<div class="kpi-card card-3" data-anim="scale-in">
<span class="kpi-label">转化率</span>
<div>
<span class="kpi-value">24</span>
<span class="kpi-unit">%</span>
<span class="kpi-change negative">↓ 2.1%</span>
</div>
</div>
</div>
<!-- 底部图表区域 -->
<!-- CUSTOMIZE: 替换为实际图表/数据可视化内容 -->
<div class="chart-area" data-anim="fade-up" data-anim-delay="0.5">
<span class="chart-title">月度趋势</span>
<!-- CUSTOMIZE: 在此处添加图表或数据可视化内容 -->
<span class="placeholder-text">图表占位区域 — 请替换为实际图表内容</span>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function(){
var els = document.querySelectorAll('[data-anim]');
els.forEach(function(el, i){
el.style.opacity = '0';
var anim = el.getAttribute('data-anim');
if (anim === 'fade-up') el.style.transform = 'translateY(30px)';
else if (anim === 'scale-in') el.style.transform = 'scale(0.85)';
else if (anim === 'slide-left') el.style.transform = 'translateX(-60px)';
else if (anim === 'slide-right') el.style.transform = 'translateX(60px)';
else if (anim === 'zoom-in') el.style.transform = 'scale(0.5)';
el.style.transition = 'opacity '+(el.getAttribute('data-anim-duration')||'0.6s')+' '+(el.getAttribute('data-anim-easing')||'cubic-bezier(0.34,1.56,0.64,1)')+', transform '+(el.getAttribute('data-anim-duration')||'0.6s')+' '+(el.getAttribute('data-anim-easing')||'cubic-bezier(0.34,1.56,0.64,1)');
});
function animStagger(parent, gap){
var children = Array.from(parent.children).filter(function(c){ return c.hasAttribute('data-anim'); });
children.forEach(function(c, i){
var delay = parseFloat(c.getAttribute('data-anim-delay')||'0');
setTimeout(function(){ c.classList.add('anim-visible'); c.style.opacity = '1'; c.style.transform = 'none'; }, (i * (gap||80)) + delay*1000);
});
}
function animSequence(parent, gap){
var children = Array.from(parent.children).filter(function(c){ return c.hasAttribute('data-anim'); });
var totalDelay = 0;
children.forEach(function(c){
setTimeout(function(){ c.classList.add('anim-visible'); c.style.opacity = '1'; c.style.transform = 'none'; }, totalDelay);
totalDelay += (gap||80);
});
}
function triggerClickGroup(group){
var groupEls = document.querySelectorAll('[data-anim-click-group="'+group+'"]');
groupEls.forEach(function(el, i){
setTimeout(function(){ el.classList.add('anim-visible'); el.style.opacity = '1'; el.style.transform = 'none'; }, i*60);
});
}
setTimeout(function(){
els.forEach(function(el){
if (!el.closest('[data-anim-stagger]') && !el.closest('[data-anim-sequence]') && !el.getAttribute('data-anim-click-group')){
var delay = parseFloat(el.getAttribute('data-anim-delay')||'0') * 1000;
setTimeout(function(){ el.classList.add('anim-visible'); el.style.opacity = '1'; el.style.transform = 'none'; }, delay);
}
});
document.querySelectorAll('[data-anim-stagger]').forEach(function(p){ animStagger(p, 80); });
document.querySelectorAll('[data-anim-sequence]').forEach(function(p){ animSequence(p, 300); });
}, 100);
});
</script>
</body>
</html>
@@ -0,0 +1,212 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- CUSTOMIZE: 修改页面标题 -->
<title>内容 - 左文右图</title>
<style>
:root {
/* CUSTOMIZE: 替换为 spec_lock.md 中定义的调色板 */
--bg: #FAFBFC;
--primary: #1E293B;
--secondary: #F1F5F9;
--accent: #D97706;
--text: #0F172A;
--muted: #64748B;
--card-bg: #FFFFFF;
--card-shadow: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04);
--border-color: #E2E8F0;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 1280px;
height: 720px;
overflow: hidden;
font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
background: var(--bg);
color: var(--text);
}
.page {
width: 1280px;
height: 720px;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
padding: 60px 80px;
}
/* 左侧文本区域 */
.text-block {
flex: 0 0 540px;
padding-right: 50px;
}
.text-block h2 {
font-size: 32px;
font-weight: 700;
color: var(--text);
margin-bottom: 24px;
line-height: 1.3;
}
.text-block .text-body {
font-size: 16px;
font-weight: 400;
color: var(--muted);
line-height: 1.8;
margin-bottom: 28px;
}
/* 要点列表 */
.text-block .point-list {
list-style: none;
}
.text-block .point-list li {
font-size: 16px;
font-weight: 400;
color: var(--text);
line-height: 1.6;
margin-bottom: 14px;
padding-left: 24px;
position: relative;
}
.text-block .point-list li::before {
content: '';
position: absolute;
left: 0;
top: 8px;
width: 8px;
height: 8px;
background: var(--accent);
border-radius: 2px;
transform: rotate(45deg);
}
/* 右侧图片区域 */
.image-block {
flex: 0 0 660px;
height: 560px;
position: relative;
}
/* CUSTOMIZE: 替换图片路径 */
.image-block .placeholder {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 16px;
background: linear-gradient(135deg, var(--primary), var(--secondary));
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.3);
}
.image-block .placeholder-content {
color: var(--muted);
font-size: 16px;
text-align: center;
opacity: 0.5;
}
/* 图片标签 */
.image-block .img-caption {
position: absolute;
bottom: -28px;
right: 0;
font-size: 13px;
color: var(--muted);
opacity: 0.6;
}
</style>
</head>
<body>
<!-- CUSTOMIZE: 修改此模板以匹配你的 spec_lock.md 内容 -->
<div class="page">
<div class="text-block">
<!-- CUSTOMIZE: 替换为实际标题 -->
<h2 data-anim="fade-up" data-anim-duration="0.6s">
内容标题:核心观点阐述
</h2>
<!-- CUSTOMIZE: 替换为实际正文 -->
<p class="text-body" data-anim="fade-up" data-anim-duration="0.6s" data-anim-delay="0.15">
此处为正文段落内容,用于详细阐述本页的核心观点和论据。
支持多段落组织和要点列表呈现,确保信息层次分明、可读性强。
</p>
<!-- CUSTOMIZE: 替换为实际要点 -->
<ul class="point-list" data-anim="fade-up" data-anim-duration="0.6s" data-anim-delay="0.3">
<li>关键要点一:简洁表达核心发现或结论</li>
<li>关键要点二:数据支撑或论据补充</li>
<li>关键要点三:行动建议或后续方向</li>
</ul>
</div>
<div class="image-block">
<!-- CUSTOMIZE: 替换为实际图片 -->
<div class="placeholder">
<!-- CUSTOMIZE: 将此 div 替换为 <img src="your-image.png" alt="配图" class="placeholder"> -->
<span class="placeholder-content">图片占位区域<br>660 × 560</span>
</div>
<!-- CUSTOMIZE: 替换图片说明文字 -->
<span class="img-caption">图片来源说明</span>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function(){
var els = document.querySelectorAll('[data-anim]');
els.forEach(function(el, i){
el.style.opacity = '0';
var anim = el.getAttribute('data-anim');
if (anim === 'fade-up') el.style.transform = 'translateY(30px)';
else if (anim === 'scale-in') el.style.transform = 'scale(0.85)';
else if (anim === 'slide-left') el.style.transform = 'translateX(-60px)';
else if (anim === 'slide-right') el.style.transform = 'translateX(60px)';
else if (anim === 'zoom-in') el.style.transform = 'scale(0.5)';
el.style.transition = 'opacity '+(el.getAttribute('data-anim-duration')||'0.6s')+' '+(el.getAttribute('data-anim-easing')||'cubic-bezier(0.34,1.56,0.64,1)')+', transform '+(el.getAttribute('data-anim-duration')||'0.6s')+' '+(el.getAttribute('data-anim-easing')||'cubic-bezier(0.34,1.56,0.64,1)');
});
function animStagger(parent, gap){
var children = Array.from(parent.children).filter(function(c){ return c.hasAttribute('data-anim'); });
children.forEach(function(c, i){
var delay = parseFloat(c.getAttribute('data-anim-delay')||'0');
setTimeout(function(){ c.classList.add('anim-visible'); c.style.opacity = '1'; c.style.transform = 'none'; }, (i * (gap||80)) + delay*1000);
});
}
function animSequence(parent, gap){
var children = Array.from(parent.children).filter(function(c){ return c.hasAttribute('data-anim'); });
var totalDelay = 0;
children.forEach(function(c){
setTimeout(function(){ c.classList.add('anim-visible'); c.style.opacity = '1'; c.style.transform = 'none'; }, totalDelay);
totalDelay += (gap||80);
});
}
function triggerClickGroup(group){
var groupEls = document.querySelectorAll('[data-anim-click-group="'+group+'"]');
groupEls.forEach(function(el, i){
setTimeout(function(){ el.classList.add('anim-visible'); el.style.opacity = '1'; el.style.transform = 'none'; }, i*60);
});
}
setTimeout(function(){
els.forEach(function(el){
if (!el.closest('[data-anim-stagger]') && !el.closest('[data-anim-sequence]') && !el.getAttribute('data-anim-click-group')){
var delay = parseFloat(el.getAttribute('data-anim-delay')||'0') * 1000;
setTimeout(function(){ el.classList.add('anim-visible'); el.style.opacity = '1'; el.style.transform = 'none'; }, delay);
}
});
document.querySelectorAll('[data-anim-stagger]').forEach(function(p){ animStagger(p, 80); });
document.querySelectorAll('[data-anim-sequence]').forEach(function(p){ animSequence(p, 300); });
}, 100);
});
</script>
</body>
</html>
@@ -0,0 +1,255 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- CUSTOMIZE: 修改页面标题 -->
<title>内容 - 时间线</title>
<style>
:root {
/* CUSTOMIZE: 替换为 spec_lock.md 中定义的调色板 */
--bg: #FAFBFC;
--primary: #1E293B;
--secondary: #F1F5F9;
--accent: #D97706;
--text: #0F172A;
--muted: #64748B;
--card-bg: #FFFFFF;
--card-shadow: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04);
--border-color: #E2E8F0;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 1280px;
height: 720px;
overflow: hidden;
font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
background: var(--bg);
color: var(--text);
}
.page {
width: 1280px;
height: 720px;
position: relative;
overflow: hidden;
padding: 70px 80px;
}
/* 页面标题 */
.page-title {
font-size: 32px;
font-weight: 700;
color: var(--text);
margin-bottom: 60px;
letter-spacing: 0.02em;
}
/* 时间线容器 */
.timeline {
position: relative;
padding: 0 20px;
}
/* 水平连接线 */
.timeline::before {
content: '';
position: absolute;
top: 36px;
left: 20px;
right: 20px;
height: 2px;
background: linear-gradient(90deg, var(--accent), rgba(59, 130, 246, 0.3), var(--accent), rgba(59, 130, 246, 0.3));
}
/* 节点行 */
.timeline-nodes {
display: flex;
justify-content: space-between;
position: relative;
z-index: 1;
}
/* 单个节点 */
.timeline-node {
display: flex;
flex-direction: column;
align-items: center;
width: 260px;
}
/* 节点圆点 */
.node-dot {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--accent);
border: 3px solid var(--bg);
box-shadow: 0 0 0 2px var(--accent);
margin-bottom: 28px;
flex-shrink: 0;
}
/* 交替:偶数节点向下 */
.timeline-node:nth-child(even) {
flex-direction: column-reverse;
}
.timeline-node:nth-child(even) .node-dot {
margin-bottom: 0;
margin-top: 28px;
}
/* 节点卡片 */
.node-card {
width: 260px;
background: var(--secondary);
border-radius: 12px;
padding: 20px;
text-align: center;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.05);
}
.node-card .node-year {
font-size: 14px;
font-weight: 700;
color: var(--accent);
letter-spacing: 0.06em;
margin-bottom: 6px;
}
.node-card .node-title {
font-size: 18px;
font-weight: 600;
color: var(--text);
margin-bottom: 6px;
}
.node-card .node-desc {
font-size: 13px;
font-weight: 400;
color: var(--muted);
line-height: 1.5;
}
/* 底部进度线 */
.page::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 2px;
background: linear-gradient(90deg, var(--accent) 0%, var(--accent) 25%, var(--secondary) 25%, var(--secondary) 100%);
}
</style>
</head>
<body>
<!-- CUSTOMIZE: 修改此模板以匹配你的 spec_lock.md 内容 -->
<div class="page">
<!-- CUSTOMIZE: 替换为实际页面标题 -->
<h2 class="page-title" data-anim="fade-up" data-anim-duration="0.6s">
发展历程
</h2>
<div class="timeline">
<div class="timeline-nodes" data-anim-stagger>
<!-- 节点 1 -->
<!-- CUSTOMIZE: 替换年份、标题和描述 -->
<div class="timeline-node" data-anim="fade-up">
<div class="node-dot"></div>
<div class="node-card">
<div class="node-year">2020 Q1</div>
<div class="node-title">项目启动</div>
<div class="node-desc">完成可行性研究与技术选型,组建核心团队</div>
</div>
</div>
<!-- 节点 2 -->
<!-- CUSTOMIZE: 替换年份、标题和描述 -->
<div class="timeline-node" data-anim="fade-up">
<div class="node-dot"></div>
<div class="node-card">
<div class="node-year">2021 Q2</div>
<div class="node-title">产品上线</div>
<div class="node-desc">v1.0 版本发布,首批用户入驻平台</div>
</div>
</div>
<!-- 节点 3 -->
<!-- CUSTOMIZE: 替换年份、标题和描述 -->
<div class="timeline-node" data-anim="fade-up">
<div class="node-dot"></div>
<div class="node-card">
<div class="node-year">2023 Q3</div>
<div class="node-title">规模扩展</div>
<div class="node-desc">突破百万用户里程碑,完成 A 轮融资</div>
</div>
</div>
<!-- 节点 4 -->
<!-- CUSTOMIZE: 替换年份、标题和描述 -->
<div class="timeline-node" data-anim="fade-up">
<div class="node-dot"></div>
<div class="node-card">
<div class="node-year">2025 Q4</div>
<div class="node-title">行业领先</div>
<div class="node-desc">市场份额第一,开启国际化布局</div>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function(){
var els = document.querySelectorAll('[data-anim]');
els.forEach(function(el, i){
el.style.opacity = '0';
var anim = el.getAttribute('data-anim');
if (anim === 'fade-up') el.style.transform = 'translateY(30px)';
else if (anim === 'scale-in') el.style.transform = 'scale(0.85)';
else if (anim === 'slide-left') el.style.transform = 'translateX(-60px)';
else if (anim === 'slide-right') el.style.transform = 'translateX(60px)';
else if (anim === 'zoom-in') el.style.transform = 'scale(0.5)';
el.style.transition = 'opacity '+(el.getAttribute('data-anim-duration')||'0.6s')+' '+(el.getAttribute('data-anim-easing')||'cubic-bezier(0.34,1.56,0.64,1)')+', transform '+(el.getAttribute('data-anim-duration')||'0.6s')+' '+(el.getAttribute('data-anim-easing')||'cubic-bezier(0.34,1.56,0.64,1)');
});
function animStagger(parent, gap){
var children = Array.from(parent.children).filter(function(c){ return c.hasAttribute('data-anim'); });
children.forEach(function(c, i){
var delay = parseFloat(c.getAttribute('data-anim-delay')||'0');
setTimeout(function(){ c.classList.add('anim-visible'); c.style.opacity = '1'; c.style.transform = 'none'; }, (i * (gap||80)) + delay*1000);
});
}
function animSequence(parent, gap){
var children = Array.from(parent.children).filter(function(c){ return c.hasAttribute('data-anim'); });
var totalDelay = 0;
children.forEach(function(c){
setTimeout(function(){ c.classList.add('anim-visible'); c.style.opacity = '1'; c.style.transform = 'none'; }, totalDelay);
totalDelay += (gap||80);
});
}
function triggerClickGroup(group){
var groupEls = document.querySelectorAll('[data-anim-click-group="'+group+'"]');
groupEls.forEach(function(el, i){
setTimeout(function(){ el.classList.add('anim-visible'); el.style.opacity = '1'; el.style.transform = 'none'; }, i*60);
});
}
setTimeout(function(){
els.forEach(function(el){
if (!el.closest('[data-anim-stagger]') && !el.closest('[data-anim-sequence]') && !el.getAttribute('data-anim-click-group')){
var delay = parseFloat(el.getAttribute('data-anim-delay')||'0') * 1000;
setTimeout(function(){ el.classList.add('anim-visible'); el.style.opacity = '1'; el.style.transform = 'none'; }, delay);
}
});
document.querySelectorAll('[data-anim-stagger]').forEach(function(p){ animStagger(p, 80); });
document.querySelectorAll('[data-anim-sequence]').forEach(function(p){ animSequence(p, 300); });
}, 100);
});
</script>
</body>
</html>
@@ -0,0 +1,174 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- CUSTOMIZE: 修改页面标题 -->
<title>封面 - Hero</title>
<style>
:root {
/* CUSTOMIZE: 替换为 spec_lock.md 中定义的调色板 */
--bg: #FAFBFC;
--primary: #1E293B;
--secondary: #F1F5F9;
--accent: #D97706;
--text: #0F172A;
--muted: #64748B;
--card-bg: #FFFFFF;
--card-shadow: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04);
--border-color: #E2E8F0;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 1280px;
height: 720px;
overflow: hidden;
font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
background: var(--bg);
color: var(--text);
}
.page {
width: 1280px;
height: 720px;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
/* 底部色条 */
.accent-bar {
position: absolute;
bottom: 0;
left: 0;
width: 1280px;
height: 6px;
background: linear-gradient(90deg, var(--accent), var(--primary));
}
/* 主标题 */
.hero-title {
font-size: 52px;
font-weight: 700;
color: var(--text);
text-align: center;
max-width: 900px;
line-height: 1.3;
letter-spacing: 0.02em;
margin-bottom: 24px;
}
/* 副标题 */
.hero-subtitle {
font-size: 22px;
font-weight: 400;
color: var(--muted);
text-align: center;
max-width: 700px;
line-height: 1.5;
margin-bottom: 48px;
}
/* 作者 & 日期 */
.hero-meta {
font-size: 16px;
font-weight: 400;
color: var(--muted);
text-align: center;
letter-spacing: 0.05em;
}
.hero-meta span { margin: 0 12px; }
/* 装饰性顶部线条 */
.top-line {
position: absolute;
top: 60px;
left: 50%;
transform: translateX(-50%);
width: 80px;
height: 3px;
background: var(--accent);
border-radius: 2px;
}
</style>
</head>
<body>
<!-- CUSTOMIZE: 修改此模板以匹配你的 spec_lock.md 内容 -->
<div class="page">
<div class="top-line" data-anim="fade-up" data-anim-duration="0.5s"></div>
<!-- CUSTOMIZE: 替换为实际标题 -->
<h1 class="hero-title" data-anim="fade-up" data-anim-duration="0.8s">
演示文稿主标题
</h1>
<!-- CUSTOMIZE: 替换为实际副标题 -->
<p class="hero-subtitle" data-anim="fade-up" data-anim-duration="0.8s" data-anim-delay="0.3">
副标题或一句话简介,说明本次演示的核心主题与价值主张
</p>
<!-- CUSTOMIZE: 替换为实际作者和日期 -->
<div class="hero-meta">
<span>作者姓名</span>
<span>|</span>
<span>2025 年 1 月</span>
</div>
<div class="accent-bar"></div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function(){
var els = document.querySelectorAll('[data-anim]');
els.forEach(function(el, i){
el.style.opacity = '0';
var anim = el.getAttribute('data-anim');
if (anim === 'fade-up') el.style.transform = 'translateY(30px)';
else if (anim === 'scale-in') el.style.transform = 'scale(0.85)';
else if (anim === 'slide-left') el.style.transform = 'translateX(-60px)';
else if (anim === 'slide-right') el.style.transform = 'translateX(60px)';
else if (anim === 'zoom-in') el.style.transform = 'scale(0.5)';
el.style.transition = 'opacity '+(el.getAttribute('data-anim-duration')||'0.6s')+' '+(el.getAttribute('data-anim-easing')||'cubic-bezier(0.34,1.56,0.64,1)')+', transform '+(el.getAttribute('data-anim-duration')||'0.6s')+' '+(el.getAttribute('data-anim-easing')||'cubic-bezier(0.34,1.56,0.64,1)');
});
function animStagger(parent, gap){
var children = Array.from(parent.children).filter(function(c){ return c.hasAttribute('data-anim'); });
children.forEach(function(c, i){
var delay = parseFloat(c.getAttribute('data-anim-delay')||'0');
setTimeout(function(){ c.classList.add('anim-visible'); c.style.opacity = '1'; c.style.transform = 'none'; }, (i * (gap||80)) + delay*1000);
});
}
function animSequence(parent, gap){
var children = Array.from(parent.children).filter(function(c){ return c.hasAttribute('data-anim'); });
var totalDelay = 0;
children.forEach(function(c){
setTimeout(function(){ c.classList.add('anim-visible'); c.style.opacity = '1'; c.style.transform = 'none'; }, totalDelay);
totalDelay += (gap||80);
});
}
function triggerClickGroup(group){
var groupEls = document.querySelectorAll('[data-anim-click-group="'+group+'"]');
groupEls.forEach(function(el, i){
setTimeout(function(){ el.classList.add('anim-visible'); el.style.opacity = '1'; el.style.transform = 'none'; }, i*60);
});
}
setTimeout(function(){
els.forEach(function(el){
if (!el.closest('[data-anim-stagger]') && !el.closest('[data-anim-sequence]') && !el.getAttribute('data-anim-click-group')){
var delay = parseFloat(el.getAttribute('data-anim-delay')||'0') * 1000;
setTimeout(function(){ el.classList.add('anim-visible'); el.style.opacity = '1'; el.style.transform = 'none'; }, delay);
}
});
document.querySelectorAll('[data-anim-stagger]').forEach(function(p){ animStagger(p, 80); });
document.querySelectorAll('[data-anim-sequence]').forEach(function(p){ animSequence(p, 300); });
}, 100);
});
</script>
</body>
</html>
@@ -0,0 +1,202 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- CUSTOMIZE: 修改页面标题 -->
<title>封面 - Split</title>
<style>
:root {
/* CUSTOMIZE: 替换为 spec_lock.md 中定义的调色板 */
--bg: #FAFBFC;
--primary: #1E293B;
--secondary: #F1F5F9;
--accent: #D97706;
--text: #0F172A;
--muted: #64748B;
--card-bg: #FFFFFF;
--card-shadow: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04);
--border-color: #E2E8F0;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 1280px;
height: 720px;
overflow: hidden;
font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
background: var(--bg);
color: var(--text);
}
.page {
width: 1280px;
height: 720px;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
padding: 0 80px;
}
/* 左侧文本区域 */
.text-block {
flex: 0 0 540px;
padding-right: 60px;
}
.text-block .tag {
display: inline-block;
font-size: 13px;
font-weight: 600;
color: var(--accent);
letter-spacing: 0.12em;
text-transform: uppercase;
margin-bottom: 20px;
padding: 4px 12px;
border: 1px solid var(--accent);
border-radius: 4px;
}
.text-block h1 {
font-size: 44px;
font-weight: 700;
color: var(--text);
line-height: 1.25;
margin-bottom: 20px;
}
.text-block .subtitle {
font-size: 18px;
font-weight: 400;
color: var(--muted);
line-height: 1.6;
margin-bottom: 36px;
}
.text-block .meta {
font-size: 15px;
color: var(--muted);
letter-spacing: 0.04em;
}
/* 右侧图片区域 */
.image-block {
flex: 0 0 660px;
height: 540px;
position: relative;
}
/* CUSTOMIZE: 替换图片路径 */
.image-block .placeholder {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 16px;
background: linear-gradient(135deg, var(--primary), var(--secondary));
display: flex;
align-items: center;
justify-content: center;
}
.image-block .placeholder-content {
color: var(--muted);
font-size: 16px;
text-align: center;
opacity: 0.5;
}
/* 右侧图片区域的装饰边框 */
.image-block::before {
content: '';
position: absolute;
top: -12px;
right: -12px;
width: 120px;
height: 120px;
border-top: 3px solid var(--accent);
border-right: 3px solid var(--accent);
border-radius: 0 16px 0 0;
}
</style>
</head>
<body>
<!-- CUSTOMIZE: 修改此模板以匹配你的 spec_lock.md 内容 -->
<div class="page">
<div class="text-block">
<!-- CUSTOMIZE: 替换标签文本 -->
<span class="tag" data-anim="fade-up" data-anim-duration="0.6s">项目标签</span>
<!-- CUSTOMIZE: 替换为实际标题 -->
<h1 data-anim="fade-up" data-anim-duration="0.8s" data-anim-delay="0.2">
演示文稿<br>主标题
</h1>
<!-- CUSTOMIZE: 替换为实际副标题 -->
<p class="subtitle">
副标题或一句话简介说明核心主题
</p>
<!-- CUSTOMIZE: 替换为实际作者信息 -->
<p class="meta">作者姓名 | 2025 年 1 月</p>
</div>
<div class="image-block" data-anim="fade-up" data-anim-duration="0.8s" data-anim-delay="0.4">
<!-- CUSTOMIZE: 替换为实际图片 <img> 标签 -->
<div class="placeholder">
<!-- CUSTOMIZE: 将此 div 替换为 <img src="your-image.png" alt="封面图" class="placeholder"> -->
<span class="placeholder-content">图片占位区域<br>660 × 540</span>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function(){
var els = document.querySelectorAll('[data-anim]');
els.forEach(function(el, i){
el.style.opacity = '0';
var anim = el.getAttribute('data-anim');
if (anim === 'fade-up') el.style.transform = 'translateY(30px)';
else if (anim === 'scale-in') el.style.transform = 'scale(0.85)';
else if (anim === 'slide-left') el.style.transform = 'translateX(-60px)';
else if (anim === 'slide-right') el.style.transform = 'translateX(60px)';
else if (anim === 'zoom-in') el.style.transform = 'scale(0.5)';
el.style.transition = 'opacity '+(el.getAttribute('data-anim-duration')||'0.6s')+' '+(el.getAttribute('data-anim-easing')||'cubic-bezier(0.34,1.56,0.64,1)')+', transform '+(el.getAttribute('data-anim-duration')||'0.6s')+' '+(el.getAttribute('data-anim-easing')||'cubic-bezier(0.34,1.56,0.64,1)');
});
function animStagger(parent, gap){
var children = Array.from(parent.children).filter(function(c){ return c.hasAttribute('data-anim'); });
children.forEach(function(c, i){
var delay = parseFloat(c.getAttribute('data-anim-delay')||'0');
setTimeout(function(){ c.classList.add('anim-visible'); c.style.opacity = '1'; c.style.transform = 'none'; }, (i * (gap||80)) + delay*1000);
});
}
function animSequence(parent, gap){
var children = Array.from(parent.children).filter(function(c){ return c.hasAttribute('data-anim'); });
var totalDelay = 0;
children.forEach(function(c){
setTimeout(function(){ c.classList.add('anim-visible'); c.style.opacity = '1'; c.style.transform = 'none'; }, totalDelay);
totalDelay += (gap||80);
});
}
function triggerClickGroup(group){
var groupEls = document.querySelectorAll('[data-anim-click-group="'+group+'"]');
groupEls.forEach(function(el, i){
setTimeout(function(){ el.classList.add('anim-visible'); el.style.opacity = '1'; el.style.transform = 'none'; }, i*60);
});
}
setTimeout(function(){
els.forEach(function(el){
if (!el.closest('[data-anim-stagger]') && !el.closest('[data-anim-sequence]') && !el.getAttribute('data-anim-click-group')){
var delay = parseFloat(el.getAttribute('data-anim-delay')||'0') * 1000;
setTimeout(function(){ el.classList.add('anim-visible'); el.style.opacity = '1'; el.style.transform = 'none'; }, delay);
}
});
document.querySelectorAll('[data-anim-stagger]').forEach(function(p){ animStagger(p, 80); });
document.querySelectorAll('[data-anim-sequence]').forEach(function(p){ animSequence(p, 300); });
}, 100);
});
</script>
</body>
</html>
@@ -0,0 +1,181 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- CUSTOMIZE: 修改页面标题 -->
<title>结束 - 引用</title>
<style>
:root {
/* CUSTOMIZE: 替换为 spec_lock.md 中定义的调色板 */
--bg: #FAFBFC;
--primary: #1E293B;
--secondary: #F1F5F9;
--accent: #D97706;
--text: #0F172A;
--muted: #64748B;
--card-bg: #FFFFFF;
--card-shadow: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04);
--border-color: #E2E8F0;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 1280px;
height: 720px;
overflow: hidden;
font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
background: var(--bg);
color: var(--text);
}
.page {
width: 1280px;
height: 720px;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 160px;
}
/* 引号装饰 */
.quote-mark {
font-size: 120px;
font-weight: 700;
color: var(--accent);
opacity: 0.2;
line-height: 0.8;
font-family: Georgia, 'Times New Roman', serif;
}
.quote-mark.open {
align-self: flex-start;
margin-bottom: -40px;
}
.quote-mark.close {
align-self: flex-end;
margin-top: -40px;
}
/* 引用正文 */
.quote-text {
font-size: 36px;
font-weight: 500;
color: var(--text);
text-align: center;
line-height: 1.5;
max-width: 900px;
font-style: italic;
letter-spacing: 0.02em;
}
/* 出处 */
.quote-attribution {
font-size: 18px;
font-weight: 400;
color: var(--muted);
text-align: center;
margin-top: 36px;
letter-spacing: 0.04em;
}
.quote-attribution .name {
color: var(--accent);
font-weight: 600;
}
/* 分隔线 */
.attribution-line {
width: 60px;
height: 1px;
background: var(--muted);
opacity: 0.4;
margin: 0 auto 16px;
}
/* 背景纹理 */
.page::before {
content: '';
position: absolute;
inset: 0;
background:
radial-gradient(circle at 20% 80%, rgba(59, 130, 246, 0.04) 0%, transparent 50%),
radial-gradient(circle at 80% 20%, rgba(30, 58, 138, 0.04) 0%, transparent 50%);
pointer-events: none;
}
</style>
</head>
<body>
<!-- CUSTOMIZE: 修改此模板——替换引用内容和出处 -->
<div class="page">
<!-- CUSTOMIZE: 替换引用文字 -->
<span class="quote-mark open">&ldquo;</span>
<p class="quote-text" data-anim="fade-up" data-anim-duration="1s">
重要的不是被给予了什么,<br>而是如何去利用被给予的东西。
</p>
<span class="quote-mark close">&rdquo;</span>
<!-- CUSTOMIZE: 替换引用来源 -->
<div class="quote-attribution">
<div class="attribution-line"></div>
<span class="name">阿尔弗雷德·阿德勒</span>
<span> / 个体心理学创始人</span>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function(){
var els = document.querySelectorAll('[data-anim]');
els.forEach(function(el, i){
el.style.opacity = '0';
var anim = el.getAttribute('data-anim');
if (anim === 'fade-up') el.style.transform = 'translateY(30px)';
else if (anim === 'scale-in') el.style.transform = 'scale(0.85)';
else if (anim === 'slide-left') el.style.transform = 'translateX(-60px)';
else if (anim === 'slide-right') el.style.transform = 'translateX(60px)';
else if (anim === 'zoom-in') el.style.transform = 'scale(0.5)';
el.style.transition = 'opacity '+(el.getAttribute('data-anim-duration')||'0.6s')+' '+(el.getAttribute('data-anim-easing')||'cubic-bezier(0.34,1.56,0.64,1)')+', transform '+(el.getAttribute('data-anim-duration')||'0.6s')+' '+(el.getAttribute('data-anim-easing')||'cubic-bezier(0.34,1.56,0.64,1)');
});
function animStagger(parent, gap){
var children = Array.from(parent.children).filter(function(c){ return c.hasAttribute('data-anim'); });
children.forEach(function(c, i){
var delay = parseFloat(c.getAttribute('data-anim-delay')||'0');
setTimeout(function(){ c.classList.add('anim-visible'); c.style.opacity = '1'; c.style.transform = 'none'; }, (i * (gap||80)) + delay*1000);
});
}
function animSequence(parent, gap){
var children = Array.from(parent.children).filter(function(c){ return c.hasAttribute('data-anim'); });
var totalDelay = 0;
children.forEach(function(c){
setTimeout(function(){ c.classList.add('anim-visible'); c.style.opacity = '1'; c.style.transform = 'none'; }, totalDelay);
totalDelay += (gap||80);
});
}
function triggerClickGroup(group){
var groupEls = document.querySelectorAll('[data-anim-click-group="'+group+'"]');
groupEls.forEach(function(el, i){
setTimeout(function(){ el.classList.add('anim-visible'); el.style.opacity = '1'; el.style.transform = 'none'; }, i*60);
});
}
setTimeout(function(){
els.forEach(function(el){
if (!el.closest('[data-anim-stagger]') && !el.closest('[data-anim-sequence]') && !el.getAttribute('data-anim-click-group')){
var delay = parseFloat(el.getAttribute('data-anim-delay')||'0') * 1000;
setTimeout(function(){ el.classList.add('anim-visible'); el.style.opacity = '1'; el.style.transform = 'none'; }, delay);
}
});
document.querySelectorAll('[data-anim-stagger]').forEach(function(p){ animStagger(p, 80); });
document.querySelectorAll('[data-anim-sequence]').forEach(function(p){ animSequence(p, 300); });
}, 100);
});
</script>
</body>
</html>
@@ -0,0 +1,198 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- CUSTOMIZE: 修改页面标题 -->
<title>结束 - 感谢</title>
<style>
:root {
/* CUSTOMIZE: 替换为 spec_lock.md 中定义的调色板 */
--bg: #FAFBFC;
--primary: #1E293B;
--secondary: #F1F5F9;
--accent: #D97706;
--text: #0F172A;
--muted: #64748B;
--card-bg: #FFFFFF;
--card-shadow: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04);
--border-color: #E2E8F0;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 1280px;
height: 720px;
overflow: hidden;
font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
background: linear-gradient(135deg, var(--primary), var(--bg));
color: var(--text);
}
.page {
width: 1280px;
height: 720px;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
/* 主文字 */
.thankyou-text {
font-size: 80px;
font-weight: 800;
color: var(--text);
text-align: center;
letter-spacing: 0.06em;
line-height: 1.1;
margin-bottom: 20px;
}
/* 副标题 */
.thankyou-sub {
font-size: 20px;
font-weight: 400;
color: var(--muted);
text-align: center;
letter-spacing: 0.04em;
margin-bottom: 48px;
}
/* 联系方式 */
.contact-info {
font-size: 16px;
font-weight: 400;
color: var(--muted);
text-align: center;
line-height: 1.8;
opacity: 0.7;
}
/* 装饰元素 */
.decoration {
position: absolute;
pointer-events: none;
}
.decoration.circle-1 {
width: 300px;
height: 300px;
border-radius: 50%;
border: 1px solid rgba(59, 130, 246, 0.08);
top: -80px;
right: -80px;
}
.decoration.circle-2 {
width: 200px;
height: 200px;
border-radius: 50%;
border: 1px solid rgba(59, 130, 246, 0.06);
bottom: -60px;
left: -60px;
}
.decoration.circle-3 {
width: 450px;
height: 450px;
border-radius: 50%;
background: radial-gradient(circle, rgba(59, 130, 246, 0.03) 0%, transparent 70%);
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
/* 底部分隔线 */
.bottom-line {
position: absolute;
bottom: 60px;
left: 50%;
transform: translateX(-50%);
width: 80px;
height: 2px;
background: var(--accent);
border-radius: 1px;
opacity: 0.5;
}
</style>
</head>
<body>
<!-- CUSTOMIZE: 修改此模板——替换感谢语、联系方式等 -->
<div class="page">
<!-- 装饰 -->
<div class="decoration circle-1"></div>
<div class="decoration circle-2"></div>
<div class="decoration circle-3"></div>
<!-- CUSTOMIZE: 替换为实际结束语 -->
<h1 class="thankyou-text" data-anim="scale-in" data-anim-duration="0.9s">
谢谢观看
</h1>
<!-- CUSTOMIZE: 替换副标题 -->
<p class="thankyou-sub">
Thank You for Your Attention
</p>
<!-- CUSTOMIZE: 替换为实际联系方式 -->
<div class="contact-info">
<p>联系人:姓名 | 邮箱@example.com</p>
<p>电话:+86 123-4567-8900</p>
</div>
<div class="bottom-line"></div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function(){
var els = document.querySelectorAll('[data-anim]');
els.forEach(function(el, i){
el.style.opacity = '0';
var anim = el.getAttribute('data-anim');
if (anim === 'fade-up') el.style.transform = 'translateY(30px)';
else if (anim === 'scale-in') el.style.transform = 'scale(0.85)';
else if (anim === 'slide-left') el.style.transform = 'translateX(-60px)';
else if (anim === 'slide-right') el.style.transform = 'translateX(60px)';
else if (anim === 'zoom-in') el.style.transform = 'scale(0.5)';
el.style.transition = 'opacity '+(el.getAttribute('data-anim-duration')||'0.6s')+' '+(el.getAttribute('data-anim-easing')||'cubic-bezier(0.34,1.56,0.64,1)')+', transform '+(el.getAttribute('data-anim-duration')||'0.6s')+' '+(el.getAttribute('data-anim-easing')||'cubic-bezier(0.34,1.56,0.64,1)');
});
function animStagger(parent, gap){
var children = Array.from(parent.children).filter(function(c){ return c.hasAttribute('data-anim'); });
children.forEach(function(c, i){
var delay = parseFloat(c.getAttribute('data-anim-delay')||'0');
setTimeout(function(){ c.classList.add('anim-visible'); c.style.opacity = '1'; c.style.transform = 'none'; }, (i * (gap||80)) + delay*1000);
});
}
function animSequence(parent, gap){
var children = Array.from(parent.children).filter(function(c){ return c.hasAttribute('data-anim'); });
var totalDelay = 0;
children.forEach(function(c){
setTimeout(function(){ c.classList.add('anim-visible'); c.style.opacity = '1'; c.style.transform = 'none'; }, totalDelay);
totalDelay += (gap||80);
});
}
function triggerClickGroup(group){
var groupEls = document.querySelectorAll('[data-anim-click-group="'+group+'"]');
groupEls.forEach(function(el, i){
setTimeout(function(){ el.classList.add('anim-visible'); el.style.opacity = '1'; el.style.transform = 'none'; }, i*60);
});
}
setTimeout(function(){
els.forEach(function(el){
if (!el.closest('[data-anim-stagger]') && !el.closest('[data-anim-sequence]') && !el.getAttribute('data-anim-click-group')){
var delay = parseFloat(el.getAttribute('data-anim-delay')||'0') * 1000;
setTimeout(function(){ el.classList.add('anim-visible'); el.style.opacity = '1'; el.style.transform = 'none'; }, delay);
}
});
document.querySelectorAll('[data-anim-stagger]').forEach(function(p){ animStagger(p, 80); });
document.querySelectorAll('[data-anim-sequence]').forEach(function(p){ animSequence(p, 300); });
}, 100);
});
</script>
</body>
</html>
@@ -0,0 +1,182 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- CUSTOMIZE: 修改页面标题 -->
<title>分隔页</title>
<style>
:root {
/* CUSTOMIZE: 替换为 spec_lock.md 中定义的调色板 */
--bg: #FAFBFC;
--primary: #1E293B;
--secondary: #F1F5F9;
--accent: #D97706;
--text: #0F172A;
--muted: #64748B;
--card-bg: #FFFFFF;
--card-shadow: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04);
--border-color: #E2E8F0;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 1280px;
height: 720px;
overflow: hidden;
font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
background: linear-gradient(135deg, var(--primary), var(--bg));
color: var(--text);
}
.page {
width: 1280px;
height: 720px;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
/* 章节编号 */
.section-number {
font-size: 120px;
font-weight: 800;
color: var(--accent);
opacity: 0.25;
line-height: 1;
margin-bottom: -20px;
letter-spacing: -0.03em;
}
/* 章节标题 */
.section-title {
font-size: 48px;
font-weight: 700;
color: var(--text);
text-align: center;
letter-spacing: 0.03em;
margin-bottom: 24px;
}
/* 装饰下划线 */
.decorative-line {
width: 100px;
height: 4px;
background: var(--accent);
border-radius: 2px;
margin-bottom: 32px;
}
/* 章节描述 */
.section-desc {
font-size: 18px;
font-weight: 400;
color: var(--muted);
text-align: center;
max-width: 600px;
line-height: 1.6;
}
/* 装饰背景圆 */
.bg-circle {
position: absolute;
border-radius: 50%;
opacity: 0.04;
background: var(--accent);
}
.bg-circle.c1 {
width: 400px;
height: 400px;
top: -100px;
right: -100px;
}
.bg-circle.c2 {
width: 250px;
height: 250px;
bottom: -60px;
left: -60px;
}
</style>
</head>
<body>
<!-- CUSTOMIZE: 修改此模板以匹配你的 spec_lock.md 内容 -->
<div class="page">
<!-- 装饰性背景圆 -->
<div class="bg-circle c1"></div>
<div class="bg-circle c2"></div>
<!-- CUSTOMIZE: 替换章节编号 -->
<div class="section-number" data-anim="scale-in" data-anim-duration="0.8s">
01
</div>
<!-- CUSTOMIZE: 替换章节标题 -->
<h2 class="section-title" data-anim="scale-in" data-anim-duration="0.8s">
章节标题
</h2>
<!-- 仅编号有动画,标题无动画(锚点页动画≤2) -->
<div class="decorative-line"></div>
<!-- CUSTOMIZE: 替换章节描述 -->
<p class="section-desc">
章节简要描述,一句话概括本章核心内容
</p>
</div>
<script>
document.addEventListener('DOMContentLoaded', function(){
var els = document.querySelectorAll('[data-anim]');
els.forEach(function(el, i){
el.style.opacity = '0';
var anim = el.getAttribute('data-anim');
if (anim === 'fade-up') el.style.transform = 'translateY(30px)';
else if (anim === 'scale-in') el.style.transform = 'scale(0.85)';
else if (anim === 'slide-left') el.style.transform = 'translateX(-60px)';
else if (anim === 'slide-right') el.style.transform = 'translateX(60px)';
else if (anim === 'zoom-in') el.style.transform = 'scale(0.5)';
el.style.transition = 'opacity '+(el.getAttribute('data-anim-duration')||'0.6s')+' '+(el.getAttribute('data-anim-easing')||'cubic-bezier(0.34,1.56,0.64,1)')+', transform '+(el.getAttribute('data-anim-duration')||'0.6s')+' '+(el.getAttribute('data-anim-easing')||'cubic-bezier(0.34,1.56,0.64,1)');
});
function animStagger(parent, gap){
var children = Array.from(parent.children).filter(function(c){ return c.hasAttribute('data-anim'); });
children.forEach(function(c, i){
var delay = parseFloat(c.getAttribute('data-anim-delay')||'0');
setTimeout(function(){ c.classList.add('anim-visible'); c.style.opacity = '1'; c.style.transform = 'none'; }, (i * (gap||80)) + delay*1000);
});
}
function animSequence(parent, gap){
var children = Array.from(parent.children).filter(function(c){ return c.hasAttribute('data-anim'); });
var totalDelay = 0;
children.forEach(function(c){
setTimeout(function(){ c.classList.add('anim-visible'); c.style.opacity = '1'; c.style.transform = 'none'; }, totalDelay);
totalDelay += (gap||80);
});
}
function triggerClickGroup(group){
var groupEls = document.querySelectorAll('[data-anim-click-group="'+group+'"]');
groupEls.forEach(function(el, i){
setTimeout(function(){ el.classList.add('anim-visible'); el.style.opacity = '1'; el.style.transform = 'none'; }, i*60);
});
}
setTimeout(function(){
els.forEach(function(el){
if (!el.closest('[data-anim-stagger]') && !el.closest('[data-anim-sequence]') && !el.getAttribute('data-anim-click-group')){
var delay = parseFloat(el.getAttribute('data-anim-delay')||'0') * 1000;
setTimeout(function(){ el.classList.add('anim-visible'); el.style.opacity = '1'; el.style.transform = 'none'; }, delay);
}
});
document.querySelectorAll('[data-anim-stagger]').forEach(function(p){ animStagger(p, 80); });
document.querySelectorAll('[data-anim-sequence]').forEach(function(p){ animSequence(p, 300); });
}, 100);
});
</script>
</body>
</html>
Loaded 100 of 224 files, more files were not shown because too many files have changed in this diff. Show more