initial open-source release
No files matched your search
@@ -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/
|
||||||
@@ -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.
|
||||||
@@ -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> — 敏感操作(终端执行、文件写入等)弹出确认浮窗,用户可同意、拒绝或修改指令 | <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 编辑器,支持代码高亮、差异对比、文件树拖拽组织 | <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 / 间隔 / 一次性三种触发方式,状态实时可见 | <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 角色,各自绑定独立模型与工具集 | <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 密钥、路径、压缩阈值等全局参数 | <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>
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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="关闭"></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>
|
||||||
|
After Width: | Height: | Size: 275 KiB |
|
After Width: | Height: | Size: 292 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 212 KiB |
@@ -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 会显示破损图标占位;放入后自动渲染。
|
||||||
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 288 KiB |
|
After Width: | Height: | Size: 436 KiB |
|
After Width: | Height: | Size: 400 KiB |
|
After Width: | Height: | Size: 257 KiB |
|
After Width: | Height: | Size: 278 KiB |
|
After Width: | Height: | Size: 403 KiB |
|
After Width: | Height: | Size: 391 KiB |
@@ -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` 即可。
|
||||||
@@ -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 %}
|
||||||
@@ -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)
|
||||||
@@ -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"}
|
||||||
@@ -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
|
||||||
@@ -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}"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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}"
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"bg_image": ""
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"current": "C:\\Users\\86166\\Desktop\\新建文件夹",
|
||||||
|
"list": [
|
||||||
|
"C:\\Users\\86166\\Desktop\\Agent_Learning_minor\\Agent\\src\\workspace",
|
||||||
|
"C:\\Users\\86166\\Desktop\\新建文件夹"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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 变体。
|
||||||
|
"""
|
||||||
@@ -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 "")
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
|
|
||||||
@@ -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"建议:请尝试换一种方式描述任务,或简化任务后重试。"
|
||||||
|
)
|
||||||
@@ -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]}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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)
|
||||||
|
"""
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
@@ -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())
|
||||||
@@ -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()
|
||||||
@@ -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 拼绝对路径。
|
||||||
|
"""
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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;">
|
||||||
|
“
|
||||||
|
</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;">
|
||||||
|
— 来源或作者
|
||||||
|
</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 | 文本中的 `&` 必须转义为 `&` | 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` 动效引擎 |
|
||||||
|
| **特殊字符** | 裸 `&` | 转义为 `&` |
|
||||||
|
| **图片** | 相对路径(`./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 | 特殊字符转义 | 内容中的 `&` 已转义为 `&` | ☐ |
|
||||||
|
| 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">“</span>
|
||||||
|
|
||||||
|
<p class="quote-text" data-anim="fade-up" data-anim-duration="1s">
|
||||||
|
重要的不是被给予了什么,<br>而是如何去利用被给予的东西。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<span class="quote-mark close">”</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>
|
||||||