Spaces:
Sleeping
Sleeping
增加自定义提示词管理系统 超时默认为10分钟 优化UI
Browse files- .env.example +3 -2
- README.md +365 -279
- docker-compose.yml +3 -2
- frontend/src/App.tsx +18 -1
- frontend/src/components/PromptInput.tsx +88 -0
- frontend/src/components/PromptSidebar.tsx +189 -0
- frontend/src/components/PromptsManager.tsx +214 -0
- frontend/src/hooks/useGeneration.ts +253 -227
- frontend/src/hooks/usePrompts.ts +173 -0
- frontend/src/lib/api.ts +107 -78
- frontend/src/types/api.ts +19 -1
- public/assets/index-Cyf_XhIu.css +1 -0
- public/assets/index-DkT5mxpT.css +0 -1
- public/assets/{index-0yzmNnTY.js → index-FIHmTYvd.js} +0 -0
- public/index.html +2 -4
- src/config/redis.ts +1 -0
- src/prompts/index.ts +14 -0
- src/queues/processors/steps/analysis-step.ts +142 -140
- src/queues/processors/steps/render-step.ts +4 -3
- src/queues/processors/steps/storage-step.ts +2 -0
- src/queues/processors/video.processor.ts +115 -103
- src/routes/generate.route.ts +184 -134
- src/routes/index.ts +28 -24
- src/routes/job-cancel.route.ts +36 -0
- src/routes/job-status.route.ts +2 -1
- src/routes/prompts.route.ts +38 -0
- src/server.ts +238 -235
- src/services/code-retry/manager.ts +332 -310
- src/services/code-retry/prompts.ts +3 -3
- src/services/code-retry/types.ts +62 -62
- src/services/concept-designer.ts +331 -314
- src/services/job-cancel-store.ts +47 -0
- src/services/job-cancel.ts +68 -0
- src/services/job-store.ts +187 -187
- src/types/index.ts +20 -0
- src/utils/errors.ts +10 -0
- src/utils/manim-executor.ts +535 -223
- src/utils/manim-process-registry.ts +37 -0
- zeabur.json +129 -119
.env.example
CHANGED
|
@@ -54,8 +54,8 @@ CUSTOM_API_URL=
|
|
| 54 |
# AI_MAX_TOKENS: AI 响应的最大 token 数(默认值:1200)
|
| 55 |
# AI_MAX_TOKENS=1200
|
| 56 |
|
| 57 |
-
# OPENAI_TIMEOUT: 请求超时时间(毫秒)(默认值:
|
| 58 |
-
# OPENAI_TIMEOUT=
|
| 59 |
|
| 60 |
# -----------------------------------------------------------------------------
|
| 61 |
# 两阶段 AI 生成配置
|
|
@@ -74,3 +74,4 @@ CUSTOM_API_URL=
|
|
| 74 |
# - development(开发环境)
|
| 75 |
# - production(生产环境)
|
| 76 |
NODE_ENV=development
|
|
|
|
|
|
| 54 |
# AI_MAX_TOKENS: AI 响应的最大 token 数(默认值:1200)
|
| 55 |
# AI_MAX_TOKENS=1200
|
| 56 |
|
| 57 |
+
# OPENAI_TIMEOUT: 请求超时时间(毫秒)(默认值:600000)
|
| 58 |
+
# OPENAI_TIMEOUT=600000
|
| 59 |
|
| 60 |
# -----------------------------------------------------------------------------
|
| 61 |
# 两阶段 AI 生成配置
|
|
|
|
| 74 |
# - development(开发环境)
|
| 75 |
# - production(生产环境)
|
| 76 |
NODE_ENV=development
|
| 77 |
+
|
README.md
CHANGED
|
@@ -1,279 +1,365 @@
|
|
| 1 |
-
<div align="center">
|
| 2 |
-
|
| 3 |
-
<!-- 顶部装饰线 - 统一为深灰色调 -->
|
| 4 |
-
<img width="100%" src="https://capsule-render.vercel.app/api?type=waving&color=455A64&height=120§ion=header" />
|
| 5 |
-
|
| 6 |
-
<br>
|
| 7 |
-
|
| 8 |
-
<img src="public/logo.svg" width="200" alt="ManimCat Logo" />
|
| 9 |
-
|
| 10 |
-
<!-- 装饰:猫咪足迹 -->
|
| 11 |
-
<div style="opacity: 0.3; margin: 20px 0;">
|
| 12 |
-
<img src="https://raw.githubusercontent.com/Tarikul-Islam-Anik/Animated-Fluent-Emojis/master/Emojis/Animals/Paw%20Prints.png" width="40" alt="paws" />
|
| 13 |
-
</div>
|
| 14 |
-
|
| 15 |
-
<h1>
|
| 16 |
-
<picture>
|
| 17 |
-
<img src="https://readme-typing-svg.herokuapp.com?font=Fira+Code&size=40&duration=3000&pause=1000&color=455A64¢er=true&vCenter=true&width=435&lines=ManimCat+%F0%9F%90%BE" alt="ManimCat" />
|
| 18 |
-
</picture>
|
| 19 |
-
</h1>
|
| 20 |
-
|
| 21 |
-
<!-- 装饰:数学符号分隔 -->
|
| 22 |
-
<p align="center">
|
| 23 |
-
<span style="font-family: monospace; font-size: 24px; color: #90A4AE;">
|
| 24 |
-
∫ ∑ ∂ ∞
|
| 25 |
-
</span>
|
| 26 |
-
</p>
|
| 27 |
-
|
| 28 |
-
<p align="center">
|
| 29 |
-
<strong>🎬 AI-Powered Mathematical Animation Generator</strong>
|
| 30 |
-
</p>
|
| 31 |
-
|
| 32 |
-
<p align="center">
|
| 33 |
-
让数学动画创作变得简单优雅 · 基于 Manim 与大语言模型
|
| 34 |
-
</p>
|
| 35 |
-
|
| 36 |
-
<!-- 装饰:几何点阵分隔 -->
|
| 37 |
-
<div style="margin: 30px 0;">
|
| 38 |
-
<span style="color: #CFD8DC; font-size: 20px;">◆ ◆ ◆</span>
|
| 39 |
-
</div>
|
| 40 |
-
|
| 41 |
-
<p align="center">
|
| 42 |
-
<img src="https://img.shields.io/badge/ManimCE-0.19.2-455A64?style=for-the-badge&logo=python&logoColor=white" alt="ManimCE" />
|
| 43 |
-
<img src="https://img.shields.io/badge/React-19.2.0-455A64?style=for-the-badge&logo=react&logoColor=white" alt="React" />
|
| 44 |
-
<img src="https://img.shields.io/badge/Node.js-18+-455A64?style=for-the-badge&logo=node.js&logoColor=white" alt="Node.js" />
|
| 45 |
-
<img src="https://img.shields.io/badge/License-MIT-607D8B?style=for-the-badge" alt="License" />
|
| 46 |
-
</p>
|
| 47 |
-
|
| 48 |
-
<p align="center" style="font-size: 18px;">
|
| 49 |
-
<a href="#前言"><strong>前言</strong></a> •
|
| 50 |
-
<a href="#样例"><strong>样例</strong></a> •
|
| 51 |
-
<a href="#技术"><strong>技术</strong></a> •
|
| 52 |
-
<a href="#部署"><strong>部署</strong></a> •
|
| 53 |
-
<a href="#贡献"><strong>贡献</strong></a> •
|
| 54 |
-
<a href="#思路"><strong>思路</strong></a> •
|
| 55 |
-
<a href="#现状"><strong>现状</strong></a>
|
| 56 |
-
</p>
|
| 57 |
-
|
| 58 |
-
<br>
|
| 59 |
-
|
| 60 |
-
<!-- 底部装饰线 - 统一为深灰色调 -->
|
| 61 |
-
<img width="100%" src="https://capsule-render.vercel.app/api?type=waving&color=455A64&height=100§ion=footer" />
|
| 62 |
-
|
| 63 |
-
</div>
|
| 64 |
-
|
| 65 |
-
<br>
|
| 66 |
-
|
| 67 |
-
## 前言
|
| 68 |
-
|
| 69 |
-
很荣幸在这里介绍我的新项目ManimCat,它是~一只猫~
|
| 70 |
-
|
| 71 |
-
本项目基于[manim-video-generator](https://github.com/rohitg00/manim-video-generator)架构级重构与二次开发而来,在此感谢原作者 Rohit Ghumare。我重写了整个前后端架构,解决了原版在并发和渲染稳定性上的痛点,并加以个人审美设计与应用的理想化改进。
|
| 72 |
-
|
| 73 |
-
ManimCat 是一个基于 AI 的数学动画生成平台,致力于让数学教师使用manim代码生成视频应用到课堂与教学之中。
|
| 74 |
-
|
| 75 |
-
用户只需输入自然语言描述,系统便会通过 AI 自动生成 Manim 代码并渲染出精美的数学可视化视频,支持 LaTeX 公式、模板化生成以及代码错误自动修复,让复杂概念的动态展示变得触手可及。
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
## 样例
|
| 79 |
-
|
| 80 |
-
期待ing!
|
| 81 |
-
|
| 82 |
-
## 技术
|
| 83 |
-
|
| 84 |
-
### 技术栈
|
| 85 |
-
|
| 86 |
-
**后端**
|
| 87 |
-
- Express.js 4.18.0 + TypeScript 5.9.3
|
| 88 |
-
- Bull 4.16.5 + ioredis 5.9.2(Redis 任务队列)
|
| 89 |
-
- OpenAI SDK 4.50.0
|
| 90 |
-
- Zod 3.23.0(数据验证)
|
| 91 |
-
|
| 92 |
-
**前端**
|
| 93 |
-
- React 19.2.0 + TypeScript 5.9.3
|
| 94 |
-
- Vite 7.2.4
|
| 95 |
-
- TailwindCSS 3.4.19
|
| 96 |
-
- react-syntax-highlighter 16.1.0
|
| 97 |
-
|
| 98 |
-
**系统依赖**
|
| 99 |
-
- Python 3.11
|
| 100 |
-
- Manim Community Edition 0.19.2
|
| 101 |
-
- LaTeX(texlive)
|
| 102 |
-
- ffmpeg + Xvfb
|
| 103 |
-
|
| 104 |
-
**部署**
|
| 105 |
-
- Docker + Docker Compose
|
| 106 |
-
- Redis 7
|
| 107 |
-
|
| 108 |
-
### 技术路线
|
| 109 |
-
|
| 110 |
-
```
|
| 111 |
-
用户请求 → POST /api/generate
|
| 112 |
-
↓
|
| 113 |
-
[认证中间件]
|
| 114 |
-
↓
|
| 115 |
-
[Bull 任务队列]
|
| 116 |
-
↓
|
| 117 |
-
┌───────────────────────────────────┐
|
| 118 |
-
│ 视频生成处理器 │
|
| 119 |
-
├───────────────────────────────────┤
|
| 120 |
-
│ 1. 检查概念缓存 │
|
| 121 |
-
│ 2. 概念分析 │
|
| 122 |
-
│ - LaTeX 检测 │
|
| 123 |
-
│ - 模板匹配 │
|
| 124 |
-
│ - AI 生成(两阶段) │
|
| 125 |
-
│ ├─ 阶段1: 概念设计师 │
|
| 126 |
-
│ └─ 阶段2: 代码生成者 │
|
| 127 |
-
│ 3. 代码重试管理器 │
|
| 128 |
-
│ ├─ 首次生成代码 → 渲染 │
|
| 129 |
-
│ ├─ 失败 → 检查错误可修复性 │
|
| 130 |
-
│ ├─ 重试循环(最多4次) │
|
| 131 |
-
│ │ ├─ 发送完整对话历史 │
|
| 132 |
-
│ │ ├─ AI 修复代码 │
|
| 133 |
-
│ │ └─ 重新渲染 │
|
| 134 |
-
│ └─ 成功/失败 → 存储结果 │
|
| 135 |
-
│ 4. 存储结果到 Redis │
|
| 136 |
-
└──────────────────
|
| 137 |
-
↓
|
| 138 |
-
前端轮询状态
|
| 139 |
-
↓
|
| 140 |
-
GET /api/jobs/:jobId
|
| 141 |
-
```
|
| 142 |
-
|
| 143 |
-
**重试机制说明:**
|
| 144 |
-
- 概念设计师结果会保存,不需要重复设计
|
| 145 |
-
- 每次重试都发送完整的对话历史(原始提示词 + 历史代码 + 错误信息)
|
| 146 |
-
- 最多重试 4 次,失败后任务标记为失败
|
| 147 |
-
|
| 148 |
-
### 环境变量配置
|
| 149 |
-
|
| 150 |
-
| 环境变量 | 默认值 | 说明 |
|
| 151 |
-
|---------|--------|------|
|
| 152 |
-
| `PORT` | `3000` |
|
| 153 |
-
| `
|
| 154 |
-
| `
|
| 155 |
-
| `
|
| 156 |
-
| `
|
| 157 |
-
| `
|
| 158 |
-
| `
|
| 159 |
-
| `
|
| 160 |
-
| `
|
| 161 |
-
| `
|
| 162 |
-
| `
|
| 163 |
-
| `
|
| 164 |
-
| `
|
| 165 |
-
| `
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
```
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
-
|
| 221 |
-
|
| 222 |
-
-
|
| 223 |
-
|
| 224 |
-
-
|
| 225 |
-
|
| 226 |
-
-
|
| 227 |
-
|
| 228 |
-
-
|
| 229 |
-
|
| 230 |
-
-
|
| 231 |
-
|
| 232 |
-
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
**
|
| 260 |
-
|
| 261 |
-
*
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
**
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<div align="center">
|
| 2 |
+
|
| 3 |
+
<!-- 顶部装饰线 - 统一为深灰色调 -->
|
| 4 |
+
<img width="100%" src="https://capsule-render.vercel.app/api?type=waving&color=455A64&height=120§ion=header" />
|
| 5 |
+
|
| 6 |
+
<br>
|
| 7 |
+
|
| 8 |
+
<img src="public/logo.svg" width="200" alt="ManimCat Logo" />
|
| 9 |
+
|
| 10 |
+
<!-- 装饰:猫咪足迹 -->
|
| 11 |
+
<div style="opacity: 0.3; margin: 20px 0;">
|
| 12 |
+
<img src="https://raw.githubusercontent.com/Tarikul-Islam-Anik/Animated-Fluent-Emojis/master/Emojis/Animals/Paw%20Prints.png" width="40" alt="paws" />
|
| 13 |
+
</div>
|
| 14 |
+
|
| 15 |
+
<h1>
|
| 16 |
+
<picture>
|
| 17 |
+
<img src="https://readme-typing-svg.herokuapp.com?font=Fira+Code&size=40&duration=3000&pause=1000&color=455A64¢er=true&vCenter=true&width=435&lines=ManimCat+%F0%9F%90%BE" alt="ManimCat" />
|
| 18 |
+
</picture>
|
| 19 |
+
</h1>
|
| 20 |
+
|
| 21 |
+
<!-- 装饰:数学符号分隔 -->
|
| 22 |
+
<p align="center">
|
| 23 |
+
<span style="font-family: monospace; font-size: 24px; color: #90A4AE;">
|
| 24 |
+
∫ ∑ ∂ ∞
|
| 25 |
+
</span>
|
| 26 |
+
</p>
|
| 27 |
+
|
| 28 |
+
<p align="center">
|
| 29 |
+
<strong>🎬 AI-Powered Mathematical Animation Generator</strong>
|
| 30 |
+
</p>
|
| 31 |
+
|
| 32 |
+
<p align="center">
|
| 33 |
+
让数学动画创作变得简单优雅 · 基于 Manim 与大语言模型
|
| 34 |
+
</p>
|
| 35 |
+
|
| 36 |
+
<!-- 装饰:几何点阵分隔 -->
|
| 37 |
+
<div style="margin: 30px 0;">
|
| 38 |
+
<span style="color: #CFD8DC; font-size: 20px;">◆ ◆ ◆</span>
|
| 39 |
+
</div>
|
| 40 |
+
|
| 41 |
+
<p align="center">
|
| 42 |
+
<img src="https://img.shields.io/badge/ManimCE-0.19.2-455A64?style=for-the-badge&logo=python&logoColor=white" alt="ManimCE" />
|
| 43 |
+
<img src="https://img.shields.io/badge/React-19.2.0-455A64?style=for-the-badge&logo=react&logoColor=white" alt="React" />
|
| 44 |
+
<img src="https://img.shields.io/badge/Node.js-18+-455A64?style=for-the-badge&logo=node.js&logoColor=white" alt="Node.js" />
|
| 45 |
+
<img src="https://img.shields.io/badge/License-MIT-607D8B?style=for-the-badge" alt="License" />
|
| 46 |
+
</p>
|
| 47 |
+
|
| 48 |
+
<p align="center" style="font-size: 18px;">
|
| 49 |
+
<a href="#前言"><strong>前言</strong></a> •
|
| 50 |
+
<a href="#样例"><strong>样例</strong></a> •
|
| 51 |
+
<a href="#技术"><strong>技术</strong></a> •
|
| 52 |
+
<a href="#部署"><strong>部署</strong></a> •
|
| 53 |
+
<a href="#贡献"><strong>贡献</strong></a> •
|
| 54 |
+
<a href="#思路"><strong>思路</strong></a> •
|
| 55 |
+
<a href="#现状"><strong>现状</strong></a>
|
| 56 |
+
</p>
|
| 57 |
+
|
| 58 |
+
<br>
|
| 59 |
+
|
| 60 |
+
<!-- 底部装饰线 - 统一为深灰色调 -->
|
| 61 |
+
<img width="100%" src="https://capsule-render.vercel.app/api?type=waving&color=455A64&height=100§ion=footer" />
|
| 62 |
+
|
| 63 |
+
</div>
|
| 64 |
+
|
| 65 |
+
<br>
|
| 66 |
+
|
| 67 |
+
## 前言
|
| 68 |
+
|
| 69 |
+
很荣幸在这里介绍我的新项目ManimCat,它是~一只猫~
|
| 70 |
+
|
| 71 |
+
本项目基于[manim-video-generator](https://github.com/rohitg00/manim-video-generator)架构级重构与二次开发而来,在此感谢原作者 Rohit Ghumare。我重写了整个前后端架构,解决了原版在并发和渲染稳定性上的痛点,并加以个人审美设计与应用的理想化改进。
|
| 72 |
+
|
| 73 |
+
ManimCat 是一个基于 AI 的数学动画生成平台,致力于让数学教师使用manim代码生成视频应用到课堂与教学之中。
|
| 74 |
+
|
| 75 |
+
用户只需输入自然语言描述,系统便会通过 AI 自动生成 Manim 代码并渲染出精美的数学可视化视频,支持 LaTeX 公式、模板化生成以及代码错误自动修复,让复杂概念的动态展示变得触手可及。
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
## 样例
|
| 79 |
+
|
| 80 |
+
期待ing!
|
| 81 |
+
|
| 82 |
+
## 技术
|
| 83 |
+
|
| 84 |
+
### 技术栈
|
| 85 |
+
|
| 86 |
+
**后端**
|
| 87 |
+
- Express.js 4.18.0 + TypeScript 5.9.3
|
| 88 |
+
- Bull 4.16.5 + ioredis 5.9.2(Redis 任务队列)
|
| 89 |
+
- OpenAI SDK 4.50.0
|
| 90 |
+
- Zod 3.23.0(数据验证)
|
| 91 |
+
|
| 92 |
+
**前端**
|
| 93 |
+
- React 19.2.0 + TypeScript 5.9.3
|
| 94 |
+
- Vite 7.2.4
|
| 95 |
+
- TailwindCSS 3.4.19
|
| 96 |
+
- react-syntax-highlighter 16.1.0
|
| 97 |
+
|
| 98 |
+
**系统依赖**
|
| 99 |
+
- Python 3.11
|
| 100 |
+
- Manim Community Edition 0.19.2
|
| 101 |
+
- LaTeX(texlive)
|
| 102 |
+
- ffmpeg + Xvfb
|
| 103 |
+
|
| 104 |
+
**部署**
|
| 105 |
+
- Docker + Docker Compose
|
| 106 |
+
- Redis 7
|
| 107 |
+
|
| 108 |
+
### 技术路线
|
| 109 |
+
|
| 110 |
+
```
|
| 111 |
+
用户请求 → POST /api/generate
|
| 112 |
+
↓
|
| 113 |
+
[认证中间件]
|
| 114 |
+
↓
|
| 115 |
+
[Bull 任务队列]
|
| 116 |
+
↓
|
| 117 |
+
┌───────────────────────────────────┐
|
| 118 |
+
│ 视频生成处理器 │
|
| 119 |
+
├───────────────────────────────────┤
|
| 120 |
+
│ 1. 检查概念缓存 │
|
| 121 |
+
│ 2. 概念分析 │
|
| 122 |
+
│ - LaTeX 检测 │
|
| 123 |
+
│ - 模板匹配 │
|
| 124 |
+
│ - AI 生成(两阶段) │
|
| 125 |
+
│ ├─ 阶段1: 概念设计师 │
|
| 126 |
+
│ └─ 阶段2: 代码生成者 │
|
| 127 |
+
│ 3. 代码重试管理器 │
|
| 128 |
+
│ ├─ 首次生成代码 → 渲染 │
|
| 129 |
+
│ ├─ 失败 → 检查错误可修复性 │
|
| 130 |
+
│ ├─ 重试循环(最多4次) │
|
| 131 |
+
│ │ ├─ 发送完整对话历史 │
|
| 132 |
+
│ │ ├─ AI 修复代码 │
|
| 133 |
+
│ │ └─ 重新渲染 │
|
| 134 |
+
│ └─ 成功/失败 → 存储结果 │
|
| 135 |
+
│ 4. 存储结果到 Redis │
|
| 136 |
+
└───────────────────────────────────┘
|
| 137 |
+
↓
|
| 138 |
+
前端轮询状态
|
| 139 |
+
↓
|
| 140 |
+
GET /api/jobs/:jobId
|
| 141 |
+
```
|
| 142 |
+
|
| 143 |
+
**重试机制说明:**
|
| 144 |
+
- 概念设计师结果会保存,不需要重复设计
|
| 145 |
+
- 每次重试都发送完整的对话历史(原始提示词 + 历史代码 + 错误信息)
|
| 146 |
+
- 最多重试 4 次,失败后任务标记为失败
|
| 147 |
+
|
| 148 |
+
### 环境变量配置
|
| 149 |
+
|
| 150 |
+
| 环境变量 | 默认值 | 说明 |
|
| 151 |
+
|---------|--------|------|
|
| 152 |
+
| `PORT` | `3000` | 服务端口 |
|
| 153 |
+
| `REDIS_HOST` | `localhost` | Redis 地址 |
|
| 154 |
+
| `REDIS_PORT` | `6379` | Redis 端口 |
|
| 155 |
+
| `REDIS_PASSWORD` | - | Redis 密码(如需) |
|
| 156 |
+
| `REDIS_DB` | `0` | Redis 数据库 |
|
| 157 |
+
| `OPENAI_API_KEY` | - | OpenAI API Key(必填) |
|
| 158 |
+
| `OPENAI_MODEL` | `glm-4-flash` | OpenAI 模型 |
|
| 159 |
+
| `OPENAI_TIMEOUT` | `600000` | OpenAI 请求超时(毫秒) |
|
| 160 |
+
| `CUSTOM_API_URL` | - | 自定义 OpenAI 兼容 API |
|
| 161 |
+
| `MANIMCAT_API_KEY` | - | API 访问密钥(可选) |
|
| 162 |
+
| `AI_TEMPERATURE` | `0.7` | 生成温度 |
|
| 163 |
+
| `AI_MAX_TOKENS` | `1200` | 生成最大 tokens |
|
| 164 |
+
| `DESIGNER_TEMPERATURE` | `0.8` | 设计师温度 |
|
| 165 |
+
| `DESIGNER_MAX_TOKENS` | `800` | 设计师最大 tokens |
|
| 166 |
+
| `REQUEST_TIMEOUT` | `600000` | 请求超时(毫秒) |
|
| 167 |
+
| `JOB_TIMEOUT` | `600000` | 任务超时(毫秒) |
|
| 168 |
+
| `MANIM_TIMEOUT` | `600000` | Manim 渲染超时(毫秒) |
|
| 169 |
+
| `CODE_RETRY_MAX_RETRIES` | `4` | 代码修复重试次数 |
|
| 170 |
+
|
| 171 |
+
**示例 `.env` 文件:**
|
| 172 |
+
|
| 173 |
+
```bash
|
| 174 |
+
PORT=3000
|
| 175 |
+
REDIS_HOST=localhost
|
| 176 |
+
REDIS_PORT=6379
|
| 177 |
+
OPENAI_API_KEY=your-api-key-here
|
| 178 |
+
OPENAI_MODEL=glm-4-flash
|
| 179 |
+
OPENAI_TIMEOUT=600000
|
| 180 |
+
AI_TEMPERATURE=0.7
|
| 181 |
+
CODE_RETRY_MAX_RETRIES=4
|
| 182 |
+
```
|
| 183 |
+
|
| 184 |
+
## 部署
|
| 185 |
+
|
| 186 |
+
请查看[部署文档](DEPLOYMENT.md)。
|
| 187 |
+
|
| 188 |
+
## 贡献
|
| 189 |
+
|
| 190 |
+
我对原作品进行了一些修改和重构,使其更符合我的设计想法:
|
| 191 |
+
|
| 192 |
+
1. 框架架构重构
|
| 193 |
+
|
| 194 |
+
- 后端使用 Express.js + Bull 任务队列架构
|
| 195 |
+
|
| 196 |
+
2. 前后端分离
|
| 197 |
+
|
| 198 |
+
- 前后端分离,React + TypeScript + Vite 独立前端
|
| 199 |
+
|
| 200 |
+
3. 存储方案升级
|
| 201 |
+
|
| 202 |
+
- Redis 存储(任务结果、状态、缓存,支持持久化)
|
| 203 |
+
|
| 204 |
+
4. 任务队列系统
|
| 205 |
+
|
| 206 |
+
- Bull + Redis 任务队列,支持重试、超时、指数退避
|
| 207 |
+
|
| 208 |
+
5. 前端技术栈
|
| 209 |
+
|
| 210 |
+
- React 19 + TailwindCSS + react-syntax-highlighter
|
| 211 |
+
|
| 212 |
+
6. 项目结构
|
| 213 |
+
|
| 214 |
+
- src/{config,middlewares,routes,services,queues,prompts,types,utils}/
|
| 215 |
+
frontend/src/{components,hooks,lib,types}/
|
| 216 |
+
|
| 217 |
+
7. 新增功能
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
- CORS 配置中间件
|
| 221 |
+
|
| 222 |
+
- 前端主题切换、设置模态框等组件
|
| 223 |
+
|
| 224 |
+
- 增加对第三方oai格式的请求支持
|
| 225 |
+
|
| 226 |
+
- 支持第三方自定义api
|
| 227 |
+
|
| 228 |
+
- 增加重试机制,增加前后端状态查询
|
| 229 |
+
|
| 230 |
+
- 重构UI,重构提示词,采取强注入manim api规范的方式
|
| 231 |
+
|
| 232 |
+
- 增加前端自定义视频参数
|
| 233 |
+
|
| 234 |
+
- 支持内存查询端点
|
| 235 |
+
|
| 236 |
+
- 优化提示词管理系统
|
| 237 |
+
|
| 238 |
+
- 对AI的输出结合提示词进行高度优化的正则清理,适配思考模型
|
| 239 |
+
|
| 240 |
+
- **自定义提示词管理**:新增专门的提示词管理页面,支持配置8种不同类型的提示词
|
| 241 |
+
|
| 242 |
+
## 自定义提示词管理
|
| 243 |
+
|
| 244 |
+
### 功能概述
|
| 245 |
+
|
| 246 |
+
提示词管理页面提供了对 AI 生成行为的精细控制,用户可以配置不同阶段的提示词,影响从概念设计到代码生成和修复的各个环节。
|
| 247 |
+
|
| 248 |
+
### 提示词类型
|
| 249 |
+
|
| 250 |
+
系统支持 **8 种提示词类型**,分为两个主要类别:
|
| 251 |
+
|
| 252 |
+
#### 系统级提示词(System)
|
| 253 |
+
- **conceptDesigner**:概念设计系统提示词 - 用于指导 AI 理解数学概念并设计动画场景
|
| 254 |
+
- **codeGeneration**:代码生成系统提示词 - 用于指导 AI 生成符合规范的 Manim 代码
|
| 255 |
+
- **codeRetry**:系统重试提示词 - 仅用于代码渲染失败后的修复阶段,系统本身不会 重���,只是进入修复流程时使用该系统提示词
|
| 256 |
+
|
| 257 |
+
#### 用户级提示词(User)
|
| 258 |
+
- **conceptDesigner**:概念设计用户提示词 - 补充说明概念设计的具体需求和风格
|
| 259 |
+
- **codeGeneration**:代码生成用户提示词 - 补充说明代码生成的具体要求和规范
|
| 260 |
+
- **codeRetryInitial**:代码修复初始重试提示词 - 代码第一次失败时的修复指导
|
| 261 |
+
- **codeRetryFix**:代码修复提示词 - 代码第二次失败时的详细修复指导
|
| 262 |
+
|
| 263 |
+
### 使用流程
|
| 264 |
+
|
| 265 |
+
1. **访问页面**:点击主界面右上角的“提示词管理”按钮(文档图标)
|
| 266 |
+
2. **选择类型**:在侧边栏选择要编辑的提示词类型
|
| 267 |
+
3. **编辑提示词**:在主编辑区输入或修改提示词内容
|
| 268 |
+
4. **保存配置**:点击保存按钮或自动保存
|
| 269 |
+
5. **应用效果**:配置会自动应用到下一次生成任务
|
| 270 |
+
|
| 271 |
+
### 与主页面概念输入的关系
|
| 272 |
+
|
| 273 |
+
- **主页面输入**:每次生成动画时需要重新输入的**具体任务描述**
|
| 274 |
+
- **提示词管理**:一次配置,多次使用的**全局行为规则**
|
| 275 |
+
- **结合使用**:系统会将用户输入的概念与配置的提示词结合使用,生成符合要求的动画
|
| 276 |
+
|
| 277 |
+
### 特点
|
| 278 |
+
|
| 279 |
+
- **侧边栏导航**:清晰的分类展示,支持快速切换
|
| 280 |
+
- **恢复默认**:一键恢复到系统默认提示词
|
| 281 |
+
- **字符限制**:每个提示词最多支持 20000 字符
|
| 282 |
+
- **持久化存储**:配置会保存到浏览器 localStorage 中
|
| 283 |
+
|
| 284 |
+
### 提示词生效逻辑
|
| 285 |
+
|
| 286 |
+
- **默认优先级**:用户未修改时,使用后端默认提示词模板
|
| 287 |
+
- **覆盖优先级**:用户修改后,仅覆盖对应字段,其余继续使用默认值
|
| 288 |
+
- **重试阶段**:初次生成失败后进入修复流程,系统提示词使用 `codeRetry`,用户提示词使用 `codeRetryInitial`/`codeRetryFix`
|
| 289 |
+
|
| 290 |
+
### 与原项目对比
|
| 291 |
+
|
| 292 |
+
#### 原项目(manim-video-generator)
|
| 293 |
+
- **硬编码提示词**:提示词直接写死在代码中,无法修改
|
| 294 |
+
- **单一提示词**:整个项目只有一个固定的提示词模板
|
| 295 |
+
- **缺乏灵活性**:无法根据不同任务调整 AI 的行为
|
| 296 |
+
- **难以维护**:修改提示词需要重新部署应用
|
| 297 |
+
|
| 298 |
+
#### 本项目(ManimCat)
|
| 299 |
+
- **动态提示词**:支持 8 种不同类型的提示词配置
|
| 300 |
+
- **分类管理**:系统级和用户级提示词分开管理,逻辑清晰
|
| 301 |
+
- **实时生效**:配置后立即生效,无需重新部署
|
| 302 |
+
- **版本控制**:支持恢复默认值和持久化存储
|
| 303 |
+
|
| 304 |
+
### 提示词生效逻辑
|
| 305 |
+
|
| 306 |
+
- **默认优先级**:用户未修改时,使用后端默认提示词模板
|
| 307 |
+
- **覆盖优先级**:用户修改后,仅覆盖对应字段,其余继续使用默认值
|
| 308 |
+
- **重试阶段**:初次生成失败后进入修复流程,系统提示词使用 `codeRetry`,用户提示词使用 `codeRetryInitial`/`codeRetryFix`
|
| 309 |
+
- **精细控制**:每个阶段的提示词都可以独立配置
|
| 310 |
+
|
| 311 |
+
### 架构优势
|
| 312 |
+
|
| 313 |
+
这种设计使得 ManimCat 在处理不同类型的数学动画时更加灵活:
|
| 314 |
+
- 对于简单任务,可以使用默认提示词快速生成
|
| 315 |
+
- 对于复杂任务,可以通过提示词管理页面进行精细调整
|
| 316 |
+
- 支持不同风格的数学可视化(严谨数学证明、通俗教学演示等)
|
| 317 |
+
- 便于维护和扩展,新的提示词类型可以轻松添加
|
| 318 |
+
|
| 319 |
+
## 思路
|
| 320 |
+
|
| 321 |
+
1. 在原作者使用AI一键生成manim视频并且后端渲染的基础上,增加了fallback机制,提升弱模型的生成完成度
|
| 322 |
+
|
| 323 |
+
2. 考虑到多数AI的manim语料训练并不多,为了降低AI幻觉率,采用提示词工程的方法,强注入manimv0.19.2的api索引表知识(自行爬取清洗制作)
|
| 324 |
+
|
| 325 |
+
## 现状
|
| 326 |
+
|
| 327 |
+
目前仍在完善项目,这只是第一个预览版本。我将致力于设计出更好的提示词与fallback流程。目标是可以对一道中国高考数学题进行完整的可视化。以下是建设的计划:
|
| 328 |
+
|
| 329 |
+
- 优化提示词,生成更长篇幅的Manim代码和更精准的效果
|
| 330 |
+
- 增加调度和重试功能
|
| 331 |
+
- 增加一定的验证页面,以防止滥用 (已经完成)
|
| 332 |
+
- 增加自定义模式功能,使用不同提示词生成不同视频
|
| 333 |
+
- 增加迭代功能,延长生成代码和视频长度
|
| 334 |
+
- 提供可能的打包版本,让非开发者可以本地实现项目
|
| 335 |
+
|
| 336 |
+
## 开源与版权声明 (License & Copyright)
|
| 337 |
+
|
| 338 |
+
### 1. 软件协议 (Software License)
|
| 339 |
+
本项目后端架构及前端部分实现参考/使用了 [manim-video-generator](https://github.com/rohitg00/manim-video-generator) 的核心思想。
|
| 340 |
+
* 继承部分代码遵循 **MIT License**。
|
| 341 |
+
* 本项目新增的重构代码、任务队列逻辑及前端组件,同样以 **MIT License** 向开源社区开放。
|
| 342 |
+
|
| 343 |
+
### 2. 核心资产版权声明 (Core Assets - **PROHIBITED FOR COMMERCIAL USE**)
|
| 344 |
+
**以下内容为本人(ManimCat 作者)原创,严禁任何形式的商用行为:**
|
| 345 |
+
|
| 346 |
+
* **Prompt Engineering(提示词工程)**:本项目中 `src/prompts/` 目录下所有高度优化的 Manim 代���生成提示词及逻辑,均为本人原创。
|
| 347 |
+
* **API Index Data**:本人自行爬取、清洗并制作的 Manim v0.18.2 API 索引表及相关强约束规则。
|
| 348 |
+
* **特定算法逻辑**:针对思考模型的正则清理算法及 fallback 容错机制。
|
| 349 |
+
|
| 350 |
+
**未经本人书面许可,任何人不得将上述“核心资产”用于:**
|
| 351 |
+
1. 直接打包作为付费产品销售。
|
| 352 |
+
2. 集成在付费订阅制的商业 AI 服务中。
|
| 353 |
+
3. 在未注明出处的情况下进行二次分发并获利。
|
| 354 |
+
|
| 355 |
+
> 事实上,作者已经关注到市面上存在一些闭源商业项目,正利用类似的 AI + Manim 思路向数学教育工作者收取高额费用进行盈利。然而,开源社区目前仍缺乏针对教育场景深度优化的成熟项目。
|
| 356 |
+
|
| 357 |
+
> ManimCat 的诞生正是为了对标并挑战这些闭源商业软件。 我希望通过开源的方式,让每一位老师都能廉价地享受到 AI 带来的教学可视化便利————你只需要支付api的费用,幸运的是,对于优秀的中国LLM大模型来说,这些花费很廉价。为了保护这一愿景不被商业机构剽窃并反向收割用户,我坚决禁止任何对本项目核心提示词及索引数据的商业授权。
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
## 维护说明
|
| 361 |
+
|
| 362 |
+
由于作者精力有限(个人业余兴趣开发者,非专业背景),目前完全无法对外部代码进行有效的审查和长期维护。因此,本项目暂不支持团队协同开发,不接受 PR。感谢理解。
|
| 363 |
+
|
| 364 |
+
如果你有好的建议或发现了 Bug,欢迎提交 Issue 进行讨论,我会根据自己的节奏进行改进。如果你希望在本项目基础上进行大规模修改,欢迎 Fork 出属于你自己的版本。
|
| 365 |
+
|
docker-compose.yml
CHANGED
|
@@ -15,7 +15,7 @@ services:
|
|
| 15 |
healthcheck:
|
| 16 |
test: ["CMD", "redis-cli", "ping"]
|
| 17 |
interval: 5s
|
| 18 |
-
timeout:
|
| 19 |
retries: 10
|
| 20 |
start_period: 5s
|
| 21 |
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
|
@@ -55,7 +55,7 @@ services:
|
|
| 55 |
healthcheck:
|
| 56 |
test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))\""]
|
| 57 |
interval: 30s
|
| 58 |
-
timeout:
|
| 59 |
retries: 3
|
| 60 |
start_period: 40s
|
| 61 |
networks:
|
|
@@ -81,3 +81,4 @@ volumes:
|
|
| 81 |
driver: local
|
| 82 |
video-storage:
|
| 83 |
driver: local
|
|
|
|
|
|
| 15 |
healthcheck:
|
| 16 |
test: ["CMD", "redis-cli", "ping"]
|
| 17 |
interval: 5s
|
| 18 |
+
timeout: 600s
|
| 19 |
retries: 10
|
| 20 |
start_period: 5s
|
| 21 |
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
|
|
|
| 55 |
healthcheck:
|
| 56 |
test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))\""]
|
| 57 |
interval: 30s
|
| 58 |
+
timeout: 600s
|
| 59 |
retries: 3
|
| 60 |
start_period: 40s
|
| 61 |
networks:
|
|
|
|
| 81 |
driver: local
|
| 82 |
video-storage:
|
| 83 |
driver: local
|
| 84 |
+
|
frontend/src/App.tsx
CHANGED
|
@@ -7,12 +7,14 @@ import { LoadingSpinner } from './components/LoadingSpinner';
|
|
| 7 |
import { ResultSection } from './components/ResultSection';
|
| 8 |
import { ThemeToggle } from './components/ThemeToggle';
|
| 9 |
import { SettingsModal } from './components/SettingsModal';
|
|
|
|
| 10 |
import ManimCatLogo from './components/ManimCatLogo';
|
| 11 |
import type { Quality } from './types/api';
|
| 12 |
|
| 13 |
function App() {
|
| 14 |
const { status, result, error, jobId, stage, generate, reset, cancel } = useGeneration();
|
| 15 |
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
|
|
| 16 |
|
| 17 |
const handleSubmit = (data: { concept: string; quality: Quality; forceRefresh: boolean }) => {
|
| 18 |
generate(data);
|
|
@@ -22,9 +24,18 @@ function App() {
|
|
| 22 |
<div className="min-h-screen bg-bg-primary transition-colors duration-300">
|
| 23 |
{/* 主题切换按钮 */}
|
| 24 |
<div className="fixed top-4 right-4 z-50 flex items-center gap-2">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
<button
|
| 26 |
onClick={() => setSettingsOpen(true)}
|
| 27 |
-
className="p-2.5 text-text-secondary/70 hover:text-text-secondary hover:bg-bg-secondary/50 rounded-full transition-all"
|
| 28 |
title="API 设置"
|
| 29 |
>
|
| 30 |
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
@@ -141,6 +152,12 @@ function App() {
|
|
| 141 |
console.log('保存配置:', config);
|
| 142 |
}}
|
| 143 |
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
</div>
|
| 145 |
);
|
| 146 |
}
|
|
|
|
| 7 |
import { ResultSection } from './components/ResultSection';
|
| 8 |
import { ThemeToggle } from './components/ThemeToggle';
|
| 9 |
import { SettingsModal } from './components/SettingsModal';
|
| 10 |
+
import { PromptsManager } from './components/PromptsManager';
|
| 11 |
import ManimCatLogo from './components/ManimCatLogo';
|
| 12 |
import type { Quality } from './types/api';
|
| 13 |
|
| 14 |
function App() {
|
| 15 |
const { status, result, error, jobId, stage, generate, reset, cancel } = useGeneration();
|
| 16 |
const [settingsOpen, setSettingsOpen] = useState(false);
|
| 17 |
+
const [promptsOpen, setPromptsOpen] = useState(false);
|
| 18 |
|
| 19 |
const handleSubmit = (data: { concept: string; quality: Quality; forceRefresh: boolean }) => {
|
| 20 |
generate(data);
|
|
|
|
| 24 |
<div className="min-h-screen bg-bg-primary transition-colors duration-300">
|
| 25 |
{/* 主题切换按钮 */}
|
| 26 |
<div className="fixed top-4 right-4 z-50 flex items-center gap-2">
|
| 27 |
+
<button
|
| 28 |
+
onClick={() => setPromptsOpen(true)}
|
| 29 |
+
className="p-2.5 text-text-secondary/70 hover:text-text-secondary hover:bg-bg-secondary/50 rounded-full transition-all active:scale-90 active:duration-75"
|
| 30 |
+
title="提示词管理"
|
| 31 |
+
>
|
| 32 |
+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 33 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
|
| 34 |
+
</svg>
|
| 35 |
+
</button>
|
| 36 |
<button
|
| 37 |
onClick={() => setSettingsOpen(true)}
|
| 38 |
+
className="p-2.5 text-text-secondary/70 hover:text-text-secondary hover:bg-bg-secondary/50 rounded-full transition-all active:scale-90 active:duration-75"
|
| 39 |
title="API 设置"
|
| 40 |
>
|
| 41 |
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
| 152 |
console.log('保存配置:', config);
|
| 153 |
}}
|
| 154 |
/>
|
| 155 |
+
|
| 156 |
+
{/* 提示词管理 */}
|
| 157 |
+
<PromptsManager
|
| 158 |
+
isOpen={promptsOpen}
|
| 159 |
+
onClose={() => setPromptsOpen(false)}
|
| 160 |
+
/>
|
| 161 |
</div>
|
| 162 |
);
|
| 163 |
}
|
frontend/src/components/PromptInput.tsx
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// 提示词输入组件
|
| 2 |
+
// 提供统一的提示词编辑界面
|
| 3 |
+
|
| 4 |
+
interface PromptInputProps {
|
| 5 |
+
value: string;
|
| 6 |
+
onChange: (value: string) => void;
|
| 7 |
+
label: string;
|
| 8 |
+
placeholder?: string;
|
| 9 |
+
maxLength?: number;
|
| 10 |
+
disabled?: boolean;
|
| 11 |
+
showWordCount?: boolean;
|
| 12 |
+
onSave?: () => void;
|
| 13 |
+
onRestoreDefault?: () => void;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export function PromptInput({
|
| 17 |
+
value,
|
| 18 |
+
onChange,
|
| 19 |
+
label,
|
| 20 |
+
placeholder,
|
| 21 |
+
maxLength = 20000,
|
| 22 |
+
disabled = false,
|
| 23 |
+
showWordCount = true,
|
| 24 |
+
onSave,
|
| 25 |
+
onRestoreDefault
|
| 26 |
+
}: PromptInputProps) {
|
| 27 |
+
const wordCount = value.length;
|
| 28 |
+
const isMaxLength = wordCount >= maxLength;
|
| 29 |
+
|
| 30 |
+
return (
|
| 31 |
+
<div className="w-full space-y-4">
|
| 32 |
+
{/* 标签和操作按钮 */}
|
| 33 |
+
<div className="flex items-center justify-between">
|
| 34 |
+
<label className="text-sm font-medium text-text-primary">
|
| 35 |
+
{label}
|
| 36 |
+
</label>
|
| 37 |
+
<div className="flex gap-2">
|
| 38 |
+
{onRestoreDefault && (
|
| 39 |
+
<button
|
| 40 |
+
onClick={onRestoreDefault}
|
| 41 |
+
disabled={disabled}
|
| 42 |
+
className="px-3 py-1.5 text-xs text-text-secondary hover:text-text-primary hover:bg-bg-secondary/50 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
| 43 |
+
>
|
| 44 |
+
恢复默认
|
| 45 |
+
</button>
|
| 46 |
+
)}
|
| 47 |
+
{onSave && (
|
| 48 |
+
<button
|
| 49 |
+
onClick={onSave}
|
| 50 |
+
disabled={disabled}
|
| 51 |
+
className="px-3 py-1.5 text-xs bg-accent text-white hover:bg-accent-hover rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
| 52 |
+
>
|
| 53 |
+
保存
|
| 54 |
+
</button>
|
| 55 |
+
)}
|
| 56 |
+
</div>
|
| 57 |
+
</div>
|
| 58 |
+
|
| 59 |
+
{/* 输入区域 */}
|
| 60 |
+
<textarea
|
| 61 |
+
value={value}
|
| 62 |
+
onChange={(e) => {
|
| 63 |
+
if (e.target.value.length <= maxLength) {
|
| 64 |
+
onChange(e.target.value);
|
| 65 |
+
}
|
| 66 |
+
}}
|
| 67 |
+
placeholder={placeholder}
|
| 68 |
+
disabled={disabled}
|
| 69 |
+
rows={20}
|
| 70 |
+
className={`w-full px-4 py-3 bg-bg-secondary/50 border border-bg-secondary/50 rounded-xl text-sm text-text-primary placeholder-text-secondary/40 focus:outline-none focus:ring-2 focus:ring-accent/20 focus:bg-bg-secondary/70 focus:border-accent/30 transition-all resize-y min-h-[480px] ${
|
| 71 |
+
isMaxLength
|
| 72 |
+
? 'border-red-500/30 focus:ring-red-500/20 focus:border-red-500/50'
|
| 73 |
+
: ''
|
| 74 |
+
}`}
|
| 75 |
+
/>
|
| 76 |
+
|
| 77 |
+
{/* 字符计数 */}
|
| 78 |
+
{showWordCount && (
|
| 79 |
+
<div className="flex items-center justify-between text-xs text-text-secondary/60">
|
| 80 |
+
<span>{wordCount} / {maxLength} 字符</span>
|
| 81 |
+
{isMaxLength && (
|
| 82 |
+
<span className="text-red-500">已达到字符限制</span>
|
| 83 |
+
)}
|
| 84 |
+
</div>
|
| 85 |
+
)}
|
| 86 |
+
</div>
|
| 87 |
+
);
|
| 88 |
+
}
|
frontend/src/components/PromptSidebar.tsx
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// 提示词管理侧边栏组件
|
| 2 |
+
|
| 3 |
+
import type { ReactNode } from 'react';
|
| 4 |
+
|
| 5 |
+
interface SidebarItemProps {
|
| 6 |
+
icon: ReactNode;
|
| 7 |
+
label: string;
|
| 8 |
+
active?: boolean;
|
| 9 |
+
onClick?: () => void;
|
| 10 |
+
children?: ReactNode;
|
| 11 |
+
expanded?: boolean;
|
| 12 |
+
onToggle?: () => void;
|
| 13 |
+
indent?: boolean;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
function SidebarItem({
|
| 17 |
+
icon,
|
| 18 |
+
label,
|
| 19 |
+
active = false,
|
| 20 |
+
onClick,
|
| 21 |
+
children,
|
| 22 |
+
expanded = true,
|
| 23 |
+
onToggle,
|
| 24 |
+
indent = false
|
| 25 |
+
}: SidebarItemProps) {
|
| 26 |
+
const hasChildren = !!children;
|
| 27 |
+
|
| 28 |
+
return (
|
| 29 |
+
<div className={`${indent ? 'ml-4' : ''}`}>
|
| 30 |
+
<button
|
| 31 |
+
onClick={() => hasChildren ? onToggle?.() : onClick?.()}
|
| 32 |
+
className={`w-full flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
|
| 33 |
+
active
|
| 34 |
+
? 'text-accent bg-bg-secondary/50 rounded-lg'
|
| 35 |
+
: 'text-text-secondary hover:text-text-primary hover:bg-bg-secondary/30 rounded-lg'
|
| 36 |
+
}`}
|
| 37 |
+
>
|
| 38 |
+
<span className="w-5 h-5 flex items-center justify-center">{icon}</span>
|
| 39 |
+
<span className="flex-1 text-left">{label}</span>
|
| 40 |
+
{hasChildren && (
|
| 41 |
+
<svg
|
| 42 |
+
className={`w-4 h-4 transition-transform ${expanded ? 'rotate-180' : ''}`}
|
| 43 |
+
fill="none"
|
| 44 |
+
stroke="currentColor"
|
| 45 |
+
viewBox="0 0 24 24"
|
| 46 |
+
>
|
| 47 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
| 48 |
+
</svg>
|
| 49 |
+
)}
|
| 50 |
+
</button>
|
| 51 |
+
{hasChildren && expanded && (
|
| 52 |
+
<div className="mt-1 space-y-1">
|
| 53 |
+
{children}
|
| 54 |
+
</div>
|
| 55 |
+
)}
|
| 56 |
+
</div>
|
| 57 |
+
);
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
interface PromptSidebarProps {
|
| 61 |
+
activeSection: string;
|
| 62 |
+
activePrompt: string;
|
| 63 |
+
onSectionChange: (section: string, prompt: string) => void;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
export function PromptSidebar({
|
| 67 |
+
activeSection,
|
| 68 |
+
activePrompt,
|
| 69 |
+
onSectionChange
|
| 70 |
+
}: PromptSidebarProps) {
|
| 71 |
+
return (
|
| 72 |
+
<div className="w-64 bg-bg-secondary/30 border-r border-bg-secondary/50 overflow-y-auto">
|
| 73 |
+
<div className="p-4 space-y-6">
|
| 74 |
+
{/* 概念设计提示词 */}
|
| 75 |
+
<div>
|
| 76 |
+
<h3 className="px-4 text-xs font-medium text-text-secondary/60 uppercase tracking-wider mb-2">
|
| 77 |
+
概念设计
|
| 78 |
+
</h3>
|
| 79 |
+
<div className="space-y-1">
|
| 80 |
+
<SidebarItem
|
| 81 |
+
icon={
|
| 82 |
+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 83 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
|
| 84 |
+
</svg>
|
| 85 |
+
}
|
| 86 |
+
label="系统提示词"
|
| 87 |
+
active={activeSection === 'system' && activePrompt === 'conceptDesigner'}
|
| 88 |
+
onClick={() => onSectionChange('system', 'conceptDesigner')}
|
| 89 |
+
indent
|
| 90 |
+
/>
|
| 91 |
+
<SidebarItem
|
| 92 |
+
icon={
|
| 93 |
+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 94 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
| 95 |
+
</svg>
|
| 96 |
+
}
|
| 97 |
+
label="用户提示词"
|
| 98 |
+
active={activeSection === 'user' && activePrompt === 'conceptDesigner'}
|
| 99 |
+
onClick={() => onSectionChange('user', 'conceptDesigner')}
|
| 100 |
+
indent
|
| 101 |
+
/>
|
| 102 |
+
</div>
|
| 103 |
+
</div>
|
| 104 |
+
|
| 105 |
+
{/* 代码生成提示词 */}
|
| 106 |
+
<div>
|
| 107 |
+
<h3 className="px-4 text-xs font-medium text-text-secondary/60 uppercase tracking-wider mb-2">
|
| 108 |
+
代码生成
|
| 109 |
+
</h3>
|
| 110 |
+
<div className="space-y-1">
|
| 111 |
+
<SidebarItem
|
| 112 |
+
icon={
|
| 113 |
+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 114 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l4-4 4 4m0 6l-4 4-4-4" />
|
| 115 |
+
</svg>
|
| 116 |
+
}
|
| 117 |
+
label="系统提示词"
|
| 118 |
+
active={activeSection === 'system' && activePrompt === 'codeGeneration'}
|
| 119 |
+
onClick={() => onSectionChange('system', 'codeGeneration')}
|
| 120 |
+
indent
|
| 121 |
+
/>
|
| 122 |
+
<SidebarItem
|
| 123 |
+
icon={
|
| 124 |
+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 125 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
| 126 |
+
</svg>
|
| 127 |
+
}
|
| 128 |
+
label="用户提示词"
|
| 129 |
+
active={activeSection === 'user' && activePrompt === 'codeGeneration'}
|
| 130 |
+
onClick={() => onSectionChange('user', 'codeGeneration')}
|
| 131 |
+
indent
|
| 132 |
+
/>
|
| 133 |
+
</div>
|
| 134 |
+
</div>
|
| 135 |
+
|
| 136 |
+
{/* 代码修复提示词 */}
|
| 137 |
+
<div>
|
| 138 |
+
<h3 className="px-4 text-xs font-medium text-text-secondary/60 uppercase tracking-wider mb-2">
|
| 139 |
+
代码修复
|
| 140 |
+
</h3>
|
| 141 |
+
<div className="space-y-1">
|
| 142 |
+
<SidebarItem
|
| 143 |
+
icon={
|
| 144 |
+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 145 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
| 146 |
+
</svg>
|
| 147 |
+
}
|
| 148 |
+
label="初始重试提示词"
|
| 149 |
+
active={activeSection === 'user' && activePrompt === 'codeRetryInitial'}
|
| 150 |
+
onClick={() => onSectionChange('user', 'codeRetryInitial')}
|
| 151 |
+
indent
|
| 152 |
+
/>
|
| 153 |
+
<SidebarItem
|
| 154 |
+
icon={
|
| 155 |
+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 156 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
| 157 |
+
</svg>
|
| 158 |
+
}
|
| 159 |
+
label="修复提示词"
|
| 160 |
+
active={activeSection === 'user' && activePrompt === 'codeRetryFix'}
|
| 161 |
+
onClick={() => onSectionChange('user', 'codeRetryFix')}
|
| 162 |
+
indent
|
| 163 |
+
/>
|
| 164 |
+
</div>
|
| 165 |
+
</div>
|
| 166 |
+
|
| 167 |
+
{/* 系统重试提示词 */}
|
| 168 |
+
<div>
|
| 169 |
+
<h3 className="px-4 text-xs font-medium text-text-secondary/60 uppercase tracking-wider mb-2">
|
| 170 |
+
系统重试
|
| 171 |
+
</h3>
|
| 172 |
+
<div className="space-y-1">
|
| 173 |
+
<SidebarItem
|
| 174 |
+
icon={
|
| 175 |
+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 176 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
| 177 |
+
</svg>
|
| 178 |
+
}
|
| 179 |
+
label="重试提示词"
|
| 180 |
+
active={activeSection === 'system' && activePrompt === 'codeRetry'}
|
| 181 |
+
onClick={() => onSectionChange('system', 'codeRetry')}
|
| 182 |
+
indent
|
| 183 |
+
/>
|
| 184 |
+
</div>
|
| 185 |
+
</div>
|
| 186 |
+
</div>
|
| 187 |
+
</div>
|
| 188 |
+
);
|
| 189 |
+
}
|
frontend/src/components/PromptsManager.tsx
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// 提示词管理主页面组件
|
| 2 |
+
// 包含侧边栏导航和主编辑区的完整布局
|
| 3 |
+
|
| 4 |
+
import { useEffect, useState } from 'react';
|
| 5 |
+
import { PromptSidebar } from './PromptSidebar';
|
| 6 |
+
import { PromptInput } from './PromptInput';
|
| 7 |
+
import { usePrompts } from '../hooks/usePrompts';
|
| 8 |
+
|
| 9 |
+
interface PromptsManagerProps {
|
| 10 |
+
isOpen: boolean;
|
| 11 |
+
onClose: () => void;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
// 提示词类型配置
|
| 15 |
+
const PROMPT_CONFIG = {
|
| 16 |
+
system: {
|
| 17 |
+
conceptDesigner: {
|
| 18 |
+
label: '概念设计系统提示词',
|
| 19 |
+
placeholder: '输入概念设计阶段的系统提示词...',
|
| 20 |
+
description: '用于指导 AI 理解数学概念并设计动画场景'
|
| 21 |
+
},
|
| 22 |
+
codeGeneration: {
|
| 23 |
+
label: '代码生成系统提示词',
|
| 24 |
+
placeholder: '输入代码生成阶段的系统提示词...',
|
| 25 |
+
description: '用于指导 AI 生成符合规范的 Manim 代码'
|
| 26 |
+
},
|
| 27 |
+
codeRetry: {
|
| 28 |
+
label: '系统重试提示词',
|
| 29 |
+
placeholder: '输入系统重试阶段的提示词...',
|
| 30 |
+
description: '用于指导 AI 在代码失败时进行重试和优化'
|
| 31 |
+
}
|
| 32 |
+
},
|
| 33 |
+
user: {
|
| 34 |
+
conceptDesigner: {
|
| 35 |
+
label: '概念设计用户提示词',
|
| 36 |
+
placeholder: '输入概念设计阶段的用户提示词...',
|
| 37 |
+
description: '补充说明概念设计的具体需求和风格'
|
| 38 |
+
},
|
| 39 |
+
codeGeneration: {
|
| 40 |
+
label: '代码生成用户提示词',
|
| 41 |
+
placeholder: '输入代码生成阶段的用户提示词...',
|
| 42 |
+
description: '补充说明代码生成的具体要求和规范'
|
| 43 |
+
},
|
| 44 |
+
codeRetryInitial: {
|
| 45 |
+
label: '代码修复初始重试提示词',
|
| 46 |
+
placeholder: '输入代码修复初始重试阶段的提示词...',
|
| 47 |
+
description: '代码第一次失败时的修复指导'
|
| 48 |
+
},
|
| 49 |
+
codeRetryFix: {
|
| 50 |
+
label: '代码修复提示词',
|
| 51 |
+
placeholder: '输入代码修复阶段的提示词...',
|
| 52 |
+
description: '代码第二次失败时的详细修复指导'
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
};
|
| 56 |
+
|
| 57 |
+
export function PromptsManager({ isOpen, onClose }: PromptsManagerProps) {
|
| 58 |
+
const {
|
| 59 |
+
isLoading,
|
| 60 |
+
activeSection,
|
| 61 |
+
activePrompt,
|
| 62 |
+
setActiveSection,
|
| 63 |
+
setActivePrompt,
|
| 64 |
+
getCurrentPrompt,
|
| 65 |
+
setCurrentPrompt,
|
| 66 |
+
restoreDefault
|
| 67 |
+
} = usePrompts();
|
| 68 |
+
|
| 69 |
+
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'success' | 'error'>('idle');
|
| 70 |
+
|
| 71 |
+
const TRANSITION_MS = 400;
|
| 72 |
+
|
| 73 |
+
const [shouldRender, setShouldRender] = useState(isOpen);
|
| 74 |
+
|
| 75 |
+
const [isVisible, setIsVisible] = useState(isOpen);
|
| 76 |
+
|
| 77 |
+
useEffect(() => {
|
| 78 |
+
if (isOpen) {
|
| 79 |
+
setShouldRender(true);
|
| 80 |
+
// 延迟一帧再显示,确保初始 opacity-0 生效
|
| 81 |
+
setTimeout(() => setIsVisible(true), 50);
|
| 82 |
+
} else {
|
| 83 |
+
setIsVisible(false);
|
| 84 |
+
const timeout = window.setTimeout(() => setShouldRender(false), 400);
|
| 85 |
+
return () => window.clearTimeout(timeout);
|
| 86 |
+
}
|
| 87 |
+
}, [isOpen]);
|
| 88 |
+
|
| 89 |
+
// 处理侧边栏导航
|
| 90 |
+
const handleSectionChange = (section: string, prompt: string) => {
|
| 91 |
+
setActiveSection(section);
|
| 92 |
+
setActivePrompt(prompt);
|
| 93 |
+
setSaveStatus('idle');
|
| 94 |
+
};
|
| 95 |
+
|
| 96 |
+
// 处理保存
|
| 97 |
+
const handleSave = () => {
|
| 98 |
+
setSaveStatus('saving');
|
| 99 |
+
setTimeout(() => {
|
| 100 |
+
setSaveStatus('success');
|
| 101 |
+
setTimeout(() => setSaveStatus('idle'), 2000);
|
| 102 |
+
}, 500);
|
| 103 |
+
};
|
| 104 |
+
|
| 105 |
+
// 处理恢复默认
|
| 106 |
+
const handleRestoreDefault = () => {
|
| 107 |
+
restoreDefault();
|
| 108 |
+
setSaveStatus('success');
|
| 109 |
+
setTimeout(() => setSaveStatus('idle'), 2000);
|
| 110 |
+
};
|
| 111 |
+
|
| 112 |
+
// 获取当前配置
|
| 113 |
+
const currentConfig = activeSection === 'system'
|
| 114 |
+
? PROMPT_CONFIG.system[activePrompt as keyof typeof PROMPT_CONFIG.system]
|
| 115 |
+
: PROMPT_CONFIG.user[activePrompt as keyof typeof PROMPT_CONFIG.user];
|
| 116 |
+
|
| 117 |
+
if (!shouldRender) return null;
|
| 118 |
+
|
| 119 |
+
return (
|
| 120 |
+
<div
|
| 121 |
+
className={`fixed inset-0 z-50 flex flex-col bg-bg-primary transition-all duration-[400ms] ease-out ${isVisible ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 translate-y-8 scale-95 pointer-events-none'}`}
|
| 122 |
+
>
|
| 123 |
+
{/* 顶部导航栏 */}
|
| 124 |
+
<div className="h-16 bg-bg-secondary border-b border-bg-secondary/50 flex items-center justify-between px-6">
|
| 125 |
+
<div className="flex items-center gap-4">
|
| 126 |
+
<button
|
| 127 |
+
onClick={onClose}
|
| 128 |
+
className="p-2 text-text-secondary hover:text-text-primary hover:bg-bg-secondary/50 rounded-lg transition-colors"
|
| 129 |
+
title="返回主界面"
|
| 130 |
+
>
|
| 131 |
+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 132 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
| 133 |
+
</svg>
|
| 134 |
+
</button>
|
| 135 |
+
<h1 className="text-lg font-medium text-text-primary">提示词管理</h1>
|
| 136 |
+
</div>
|
| 137 |
+
<div className="flex items-center gap-3">
|
| 138 |
+
{saveStatus === 'success' && (
|
| 139 |
+
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
|
| 140 |
+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 141 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
| 142 |
+
</svg>
|
| 143 |
+
<span>保存成功</span>
|
| 144 |
+
</div>
|
| 145 |
+
)}
|
| 146 |
+
<button
|
| 147 |
+
onClick={handleSave}
|
| 148 |
+
disabled={saveStatus === 'saving'}
|
| 149 |
+
className="px-4 py-2 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
| 150 |
+
>
|
| 151 |
+
{saveStatus === 'saving' ? (
|
| 152 |
+
<>
|
| 153 |
+
<svg className="animate-spin w-4 h-4 inline mr-2" fill="none" viewBox="0 0 24 24">
|
| 154 |
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
| 155 |
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
| 156 |
+
</svg>
|
| 157 |
+
保存中...
|
| 158 |
+
</>
|
| 159 |
+
) : (
|
| 160 |
+
'保存'
|
| 161 |
+
)}
|
| 162 |
+
</button>
|
| 163 |
+
</div>
|
| 164 |
+
</div>
|
| 165 |
+
|
| 166 |
+
{/* 主内容区 */}
|
| 167 |
+
<div className="flex-1 flex overflow-hidden">
|
| 168 |
+
{/* 侧边栏 */}
|
| 169 |
+
<PromptSidebar
|
| 170 |
+
activeSection={activeSection}
|
| 171 |
+
activePrompt={activePrompt}
|
| 172 |
+
onSectionChange={handleSectionChange}
|
| 173 |
+
/>
|
| 174 |
+
|
| 175 |
+
{/* 主编辑区 */}
|
| 176 |
+
<div className="flex-1 overflow-y-auto p-8">
|
| 177 |
+
<div className="max-w-4xl mx-auto space-y-8">
|
| 178 |
+
{/* 当前提示词信息 */}
|
| 179 |
+
<div className="bg-bg-secondary/30 rounded-xl p-6">
|
| 180 |
+
<h2 className="text-xl font-medium text-text-primary mb-2">
|
| 181 |
+
{currentConfig?.label || '提示词编辑'}
|
| 182 |
+
</h2>
|
| 183 |
+
<p className="text-sm text-text-secondary/70">
|
| 184 |
+
{currentConfig?.description || '在此处编辑提示词,它将用于指导 AI 生成和优化动画'}
|
| 185 |
+
</p>
|
| 186 |
+
</div>
|
| 187 |
+
|
| 188 |
+
{/* 输入组件 */}
|
| 189 |
+
<div className="space-y-6">
|
| 190 |
+
<PromptInput
|
| 191 |
+
value={getCurrentPrompt()}
|
| 192 |
+
onChange={setCurrentPrompt}
|
| 193 |
+
label={currentConfig?.label || '提示词'}
|
| 194 |
+
placeholder={currentConfig?.placeholder}
|
| 195 |
+
showWordCount
|
| 196 |
+
disabled={isLoading}
|
| 197 |
+
onSave={handleSave}
|
| 198 |
+
onRestoreDefault={handleRestoreDefault}
|
| 199 |
+
/>
|
| 200 |
+
|
| 201 |
+
</div>
|
| 202 |
+
</div>
|
| 203 |
+
</div>
|
| 204 |
+
</div>
|
| 205 |
+
|
| 206 |
+
{/* 底部状态栏 */}
|
| 207 |
+
<div className="h-10 bg-bg-secondary border-t border-bg-secondary/50 flex items-center justify-between px-6 text-xs text-text-secondary">
|
| 208 |
+
<span>提示词管理</span>
|
| 209 |
+
<span>字符限制:20000</span>
|
| 210 |
+
</div>
|
| 211 |
+
</div>
|
| 212 |
+
);
|
| 213 |
+
}
|
| 214 |
+
|
frontend/src/hooks/useGeneration.ts
CHANGED
|
@@ -1,227 +1,253 @@
|
|
| 1 |
-
// 生成请求 Hook
|
| 2 |
-
|
| 3 |
-
import { useState, useCallback, useRef, useEffect } from 'react';
|
| 4 |
-
import { generateAnimation, getJobStatus } from '../lib/api';
|
| 5 |
-
import { loadCustomConfig, generateWithCustomApi } from '../lib/custom-ai';
|
| 6 |
-
import
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
const [
|
| 41 |
-
const [
|
| 42 |
-
const [
|
| 43 |
-
const [
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
const
|
| 47 |
-
const
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
}
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
if (
|
| 103 |
-
|
| 104 |
-
}
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
}
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
if (err instanceof Error &&
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
if (
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
}, []);
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// 生成请求 Hook
|
| 2 |
+
|
| 3 |
+
import { useState, useCallback, useRef, useEffect } from 'react';
|
| 4 |
+
import { generateAnimation, getJobStatus, cancelJob } from '../lib/api';
|
| 5 |
+
import { loadCustomConfig, generateWithCustomApi } from '../lib/custom-ai';
|
| 6 |
+
import { loadPrompts } from './usePrompts';
|
| 7 |
+
import type { GenerateRequest, JobResult, ProcessingStage, VideoConfig } from '../types/api';
|
| 8 |
+
|
| 9 |
+
interface UseGenerationReturn {
|
| 10 |
+
status: 'idle' | 'processing' | 'completed' | 'error';
|
| 11 |
+
result: JobResult | null;
|
| 12 |
+
error: string | null;
|
| 13 |
+
jobId: string | null;
|
| 14 |
+
stage: ProcessingStage;
|
| 15 |
+
generate: (request: GenerateRequest) => Promise<void>;
|
| 16 |
+
reset: () => void;
|
| 17 |
+
cancel: () => void;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
/** 轮询间隔 */
|
| 21 |
+
const POLL_INTERVAL = 1000;
|
| 22 |
+
|
| 23 |
+
/** 从 localStorage 加载超时配置 */
|
| 24 |
+
function getTimeoutConfig(): number {
|
| 25 |
+
try {
|
| 26 |
+
const saved = localStorage.getItem('manimcat_settings');
|
| 27 |
+
if (saved) {
|
| 28 |
+
const parsed = JSON.parse(saved);
|
| 29 |
+
if (parsed.video?.timeout) {
|
| 30 |
+
return parsed.video.timeout;
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
+
} catch {
|
| 34 |
+
// 忽略错误,使用默认值
|
| 35 |
+
}
|
| 36 |
+
return 120; // 默认 120 秒
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
export function useGeneration(): UseGenerationReturn {
|
| 40 |
+
const [status, setStatus] = useState<'idle' | 'processing' | 'completed' | 'error'>('idle');
|
| 41 |
+
const [result, setResult] = useState<JobResult | null>(null);
|
| 42 |
+
const [error, setError] = useState<string | null>(null);
|
| 43 |
+
const [jobId, setJobId] = useState<string | null>(null);
|
| 44 |
+
const [stage, setStage] = useState<ProcessingStage>('analyzing');
|
| 45 |
+
|
| 46 |
+
const pollCountRef = useRef(0);
|
| 47 |
+
const pollIntervalRef = useRef<number | null>(null);
|
| 48 |
+
const abortControllerRef = useRef<AbortController | null>(null);
|
| 49 |
+
|
| 50 |
+
const requestCancel = useCallback(async (id: string | null) => {
|
| 51 |
+
if (!id) {
|
| 52 |
+
return;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
try {
|
| 56 |
+
await cancelJob(id);
|
| 57 |
+
} catch (err) {
|
| 58 |
+
console.warn('取消任务失败', err);
|
| 59 |
+
}
|
| 60 |
+
}, []);
|
| 61 |
+
|
| 62 |
+
// 清理轮询和请求
|
| 63 |
+
useEffect(() => {
|
| 64 |
+
return () => {
|
| 65 |
+
if (pollIntervalRef.current) {
|
| 66 |
+
clearInterval(pollIntervalRef.current);
|
| 67 |
+
}
|
| 68 |
+
abortControllerRef.current?.abort();
|
| 69 |
+
};
|
| 70 |
+
}, []);
|
| 71 |
+
|
| 72 |
+
// 更新处理阶段
|
| 73 |
+
const updateStage = useCallback((count: number) => {
|
| 74 |
+
if (count < 5) {
|
| 75 |
+
setStage('analyzing');
|
| 76 |
+
} else if (count < 15) {
|
| 77 |
+
setStage('generating');
|
| 78 |
+
} else if (count < 25) {
|
| 79 |
+
setStage('refining');
|
| 80 |
+
} else if (count < 60) {
|
| 81 |
+
setStage('rendering');
|
| 82 |
+
} else {
|
| 83 |
+
setStage('still-rendering');
|
| 84 |
+
}
|
| 85 |
+
}, []);
|
| 86 |
+
|
| 87 |
+
// 开始轮询
|
| 88 |
+
const startPolling = useCallback((id: string) => {
|
| 89 |
+
pollCountRef.current = 0;
|
| 90 |
+
setJobId(id);
|
| 91 |
+
|
| 92 |
+
// 获取用户配置的超时时间
|
| 93 |
+
const maxPollCount = getTimeoutConfig();
|
| 94 |
+
|
| 95 |
+
pollIntervalRef.current = window.setInterval(async () => {
|
| 96 |
+
pollCountRef.current++;
|
| 97 |
+
|
| 98 |
+
try {
|
| 99 |
+
const data = await getJobStatus(id, abortControllerRef.current?.signal);
|
| 100 |
+
|
| 101 |
+
if (data.status === 'completed') {
|
| 102 |
+
if (pollIntervalRef.current) {
|
| 103 |
+
clearInterval(pollIntervalRef.current);
|
| 104 |
+
}
|
| 105 |
+
setStatus('completed');
|
| 106 |
+
setResult(data);
|
| 107 |
+
} else if (data.status === 'failed') {
|
| 108 |
+
if (pollIntervalRef.current) {
|
| 109 |
+
clearInterval(pollIntervalRef.current);
|
| 110 |
+
}
|
| 111 |
+
setStatus('error');
|
| 112 |
+
if (data.cancel_reason) {
|
| 113 |
+
setError(`任务已取消:${data.cancel_reason}`);
|
| 114 |
+
} else {
|
| 115 |
+
setError(data.error || '生成失败');
|
| 116 |
+
}
|
| 117 |
+
} else {
|
| 118 |
+
// 使用后端返回的 stage,如果没有则使用前端估算的 fallback
|
| 119 |
+
if (data.stage) {
|
| 120 |
+
setStage(data.stage);
|
| 121 |
+
} else {
|
| 122 |
+
updateStage(pollCountRef.current);
|
| 123 |
+
}
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
// 超时检查(使用用户配置的超时时间)
|
| 127 |
+
if (pollCountRef.current >= maxPollCount) {
|
| 128 |
+
if (pollIntervalRef.current) {
|
| 129 |
+
clearInterval(pollIntervalRef.current);
|
| 130 |
+
}
|
| 131 |
+
await requestCancel(id);
|
| 132 |
+
setStatus('error');
|
| 133 |
+
setError(`生成超时(${maxPollCount}秒),请尝试更简单的概念或增加超时时间`);
|
| 134 |
+
}
|
| 135 |
+
} catch (err) {
|
| 136 |
+
if (err instanceof Error && err.name === 'AbortError') {
|
| 137 |
+
return;
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
// 如果是连接错误(后端断开),停止轮询
|
| 141 |
+
if (err instanceof Error && (err.message.includes('ECONNREFUSED') || err.message.includes('Failed to fetch'))) {
|
| 142 |
+
console.error('后端连接断开,停止轮询');
|
| 143 |
+
if (pollIntervalRef.current) {
|
| 144 |
+
clearInterval(pollIntervalRef.current);
|
| 145 |
+
}
|
| 146 |
+
setStatus('error');
|
| 147 |
+
setError('后端服务已断开,请刷新页面重试');
|
| 148 |
+
return;
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
console.error('轮询错误:', err);
|
| 152 |
+
await requestCancel(id);
|
| 153 |
+
|
| 154 |
+
// 如果是任务未找到 (404) 或明确的失效提示
|
| 155 |
+
if (err instanceof Error && (err.message.includes('未找到任务') || err.message.includes('失效'))) {
|
| 156 |
+
if (pollIntervalRef.current) {
|
| 157 |
+
clearInterval(pollIntervalRef.current);
|
| 158 |
+
pollIntervalRef.current = null;
|
| 159 |
+
}
|
| 160 |
+
setStatus('error');
|
| 161 |
+
setError('任务已失效(可能因服务重启),请重新生成');
|
| 162 |
+
return;
|
| 163 |
+
}
|
| 164 |
+
}
|
| 165 |
+
}, POLL_INTERVAL);
|
| 166 |
+
}, [requestCancel, updateStage]);
|
| 167 |
+
|
| 168 |
+
// 生成动画
|
| 169 |
+
const generate = useCallback(async (request: GenerateRequest) => {
|
| 170 |
+
setStatus('processing');
|
| 171 |
+
setError(null);
|
| 172 |
+
setResult(null);
|
| 173 |
+
setStage('analyzing');
|
| 174 |
+
pollCountRef.current = 0;
|
| 175 |
+
abortControllerRef.current = new AbortController();
|
| 176 |
+
|
| 177 |
+
try {
|
| 178 |
+
// 加载提示词配置
|
| 179 |
+
const promptOverrides = loadPrompts();
|
| 180 |
+
|
| 181 |
+
// 检查是否有自定义 AI 配置
|
| 182 |
+
const customConfig = loadCustomConfig();
|
| 183 |
+
|
| 184 |
+
if (customConfig) {
|
| 185 |
+
// 使用自定义 AI 生成代码
|
| 186 |
+
setStage('generating');
|
| 187 |
+
const code = await generateWithCustomApi(
|
| 188 |
+
request.concept,
|
| 189 |
+
customConfig,
|
| 190 |
+
abortControllerRef.current.signal
|
| 191 |
+
);
|
| 192 |
+
|
| 193 |
+
// 发送代码到后端渲染
|
| 194 |
+
setStage('rendering');
|
| 195 |
+
const response = await generateAnimation(
|
| 196 |
+
{ ...request, code, promptOverrides },
|
| 197 |
+
abortControllerRef.current.signal
|
| 198 |
+
);
|
| 199 |
+
startPolling(response.jobId);
|
| 200 |
+
} else {
|
| 201 |
+
// 使用后端 AI
|
| 202 |
+
const response = await generateAnimation(
|
| 203 |
+
{ ...request, promptOverrides },
|
| 204 |
+
abortControllerRef.current.signal
|
| 205 |
+
);
|
| 206 |
+
startPolling(response.jobId);
|
| 207 |
+
}
|
| 208 |
+
} catch (err) {
|
| 209 |
+
if (err instanceof Error && err.name === 'AbortError') {
|
| 210 |
+
return;
|
| 211 |
+
}
|
| 212 |
+
setStatus('error');
|
| 213 |
+
setError(err instanceof Error ? err.message : '生成请求失败');
|
| 214 |
+
}
|
| 215 |
+
}, [startPolling]);
|
| 216 |
+
|
| 217 |
+
// 重置状态
|
| 218 |
+
const reset = useCallback(() => {
|
| 219 |
+
setStatus('idle');
|
| 220 |
+
setError(null);
|
| 221 |
+
setResult(null);
|
| 222 |
+
setJobId(null);
|
| 223 |
+
setStage('analyzing');
|
| 224 |
+
if (pollIntervalRef.current) {
|
| 225 |
+
clearInterval(pollIntervalRef.current);
|
| 226 |
+
}
|
| 227 |
+
abortControllerRef.current?.abort();
|
| 228 |
+
}, []);
|
| 229 |
+
|
| 230 |
+
// 取消生成
|
| 231 |
+
const cancel = useCallback(() => {
|
| 232 |
+
if (pollIntervalRef.current) {
|
| 233 |
+
clearInterval(pollIntervalRef.current);
|
| 234 |
+
}
|
| 235 |
+
void requestCancel(jobId);
|
| 236 |
+
abortControllerRef.current?.abort();
|
| 237 |
+
setStatus('idle');
|
| 238 |
+
setError(null);
|
| 239 |
+
setJobId(null);
|
| 240 |
+
setStage('analyzing');
|
| 241 |
+
}, [jobId, requestCancel]);
|
| 242 |
+
|
| 243 |
+
return {
|
| 244 |
+
status,
|
| 245 |
+
result,
|
| 246 |
+
error,
|
| 247 |
+
jobId,
|
| 248 |
+
stage,
|
| 249 |
+
generate,
|
| 250 |
+
reset,
|
| 251 |
+
cancel,
|
| 252 |
+
};
|
| 253 |
+
}
|
frontend/src/hooks/usePrompts.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// 提示词管理 Hook
|
| 2 |
+
// 负责提示词的状态管理、存储和加载
|
| 3 |
+
|
| 4 |
+
import { useState, useEffect, useCallback } from 'react';
|
| 5 |
+
import { getPromptDefaults } from '../lib/api';
|
| 6 |
+
import type { PromptOverrides } from '../types/api';
|
| 7 |
+
|
| 8 |
+
/** 存储在 localStorage 中的键名 */
|
| 9 |
+
const PROMPTS_STORAGE_KEY = 'manimcat_prompt_overrides';
|
| 10 |
+
|
| 11 |
+
/** 默认提示词配置 */
|
| 12 |
+
const DEFAULT_PROMPTS: PromptOverrides = {
|
| 13 |
+
system: {
|
| 14 |
+
conceptDesigner: '',
|
| 15 |
+
codeGeneration: '',
|
| 16 |
+
codeRetry: '',
|
| 17 |
+
},
|
| 18 |
+
user: {
|
| 19 |
+
conceptDesigner: '',
|
| 20 |
+
codeGeneration: '',
|
| 21 |
+
codeRetryInitial: '',
|
| 22 |
+
codeRetryFix: '',
|
| 23 |
+
},
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
/** 从 localStorage 加载提示词配置 */
|
| 27 |
+
/** Load prompt overrides from localStorage if available. */
|
| 28 |
+
export function loadStoredPrompts(): PromptOverrides | null {
|
| 29 |
+
try {
|
| 30 |
+
const saved = localStorage.getItem(PROMPTS_STORAGE_KEY);
|
| 31 |
+
if (saved) {
|
| 32 |
+
const parsed = JSON.parse(saved);
|
| 33 |
+
return {
|
| 34 |
+
system: { ...DEFAULT_PROMPTS.system, ...parsed.system },
|
| 35 |
+
user: { ...DEFAULT_PROMPTS.user, ...parsed.user },
|
| 36 |
+
};
|
| 37 |
+
}
|
| 38 |
+
} catch (error) {
|
| 39 |
+
console.error('Failed to load prompts:', error);
|
| 40 |
+
}
|
| 41 |
+
return null;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
export function loadPrompts(): PromptOverrides {
|
| 45 |
+
return loadStoredPrompts() || DEFAULT_PROMPTS;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
/** 保存提示词配置到 localStorage */
|
| 49 |
+
export function savePrompts(prompts: PromptOverrides): void {
|
| 50 |
+
try {
|
| 51 |
+
localStorage.setItem(PROMPTS_STORAGE_KEY, JSON.stringify(prompts));
|
| 52 |
+
} catch (error) {
|
| 53 |
+
console.error('Failed to save prompts:', error);
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
/** 提示词管理 Hook */
|
| 58 |
+
export function usePrompts() {
|
| 59 |
+
const [prompts, setPrompts] = useState<PromptOverrides>(DEFAULT_PROMPTS);
|
| 60 |
+
const [defaultPrompts, setDefaultPrompts] = useState<PromptOverrides>(DEFAULT_PROMPTS);
|
| 61 |
+
const [isLoading, setIsLoading] = useState<boolean>(true);
|
| 62 |
+
const [activeSection, setActiveSection] = useState<string>('system');
|
| 63 |
+
const [activePrompt, setActivePrompt] = useState<string>('conceptDesigner');
|
| 64 |
+
|
| 65 |
+
// 从 localStorage 加载配置
|
| 66 |
+
useEffect(() => {
|
| 67 |
+
const savedPrompts = loadStoredPrompts();
|
| 68 |
+
if (savedPrompts) {
|
| 69 |
+
setPrompts(savedPrompts);
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
let isActive = true;
|
| 73 |
+
const loadDefaults = async () => {
|
| 74 |
+
setIsLoading(true);
|
| 75 |
+
try {
|
| 76 |
+
const defaults = await getPromptDefaults();
|
| 77 |
+
if (isActive) {
|
| 78 |
+
setDefaultPrompts(defaults);
|
| 79 |
+
}
|
| 80 |
+
} catch (error) {
|
| 81 |
+
console.error('Failed to load prompt defaults:', error);
|
| 82 |
+
} finally {
|
| 83 |
+
if (isActive) {
|
| 84 |
+
setIsLoading(false);
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
};
|
| 88 |
+
|
| 89 |
+
void loadDefaults();
|
| 90 |
+
return () => {
|
| 91 |
+
isActive = false;
|
| 92 |
+
};
|
| 93 |
+
}, []);
|
| 94 |
+
|
| 95 |
+
// 保存到 localStorage
|
| 96 |
+
useEffect(() => {
|
| 97 |
+
savePrompts(prompts);
|
| 98 |
+
}, [prompts]);
|
| 99 |
+
|
| 100 |
+
// 更新提示词
|
| 101 |
+
const updatePrompt = useCallback((section: string, type: string, value: string) => {
|
| 102 |
+
setPrompts(prev => {
|
| 103 |
+
const newPrompts = { ...prev };
|
| 104 |
+
|
| 105 |
+
if (section === 'system') {
|
| 106 |
+
if (!newPrompts.system) {
|
| 107 |
+
newPrompts.system = {};
|
| 108 |
+
}
|
| 109 |
+
newPrompts.system[type as keyof PromptOverrides['system']] = value;
|
| 110 |
+
} else if (section === 'user') {
|
| 111 |
+
if (!newPrompts.user) {
|
| 112 |
+
newPrompts.user = {};
|
| 113 |
+
}
|
| 114 |
+
newPrompts.user[type as keyof PromptOverrides['user']] = value;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
return newPrompts;
|
| 118 |
+
});
|
| 119 |
+
}, []);
|
| 120 |
+
|
| 121 |
+
// 恢复默认值
|
| 122 |
+
const restoreDefault = useCallback(() => {
|
| 123 |
+
setPrompts(DEFAULT_PROMPTS);
|
| 124 |
+
}, []);
|
| 125 |
+
|
| 126 |
+
// 获取当前编辑的提示词
|
| 127 |
+
const getDefaultPrompt = useCallback((section: string, type: string) => {
|
| 128 |
+
if (section === 'system') {
|
| 129 |
+
return defaultPrompts.system?.[type as keyof PromptOverrides['system']] || '';
|
| 130 |
+
}
|
| 131 |
+
if (section === 'user') {
|
| 132 |
+
return defaultPrompts.user?.[type as keyof PromptOverrides['user']] || '';
|
| 133 |
+
}
|
| 134 |
+
return '';
|
| 135 |
+
}, [defaultPrompts]);
|
| 136 |
+
|
| 137 |
+
//
|
| 138 |
+
const getCurrentPrompt = useCallback(() => {
|
| 139 |
+
let current = '';
|
| 140 |
+
if (activeSection === 'system') {
|
| 141 |
+
current = prompts.system?.[activePrompt as keyof PromptOverrides['system']] || '';
|
| 142 |
+
} else if (activeSection === 'user') {
|
| 143 |
+
current = prompts.user?.[activePrompt as keyof PromptOverrides['user']] || '';
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
if (current && current.trim().length > 0) {
|
| 147 |
+
return current;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
return getDefaultPrompt(activeSection, activePrompt);
|
| 151 |
+
}, [prompts, activeSection, activePrompt, getDefaultPrompt]);
|
| 152 |
+
|
| 153 |
+
//
|
| 154 |
+
const setCurrentPrompt = useCallback((value: string) => {
|
| 155 |
+
const defaultValue = getDefaultPrompt(activeSection, activePrompt);
|
| 156 |
+
const nextValue = value === defaultValue ? '' : value;
|
| 157 |
+
updatePrompt(activeSection, activePrompt, nextValue);
|
| 158 |
+
}, [activeSection, activePrompt, getDefaultPrompt, updatePrompt]);
|
| 159 |
+
|
| 160 |
+
return {
|
| 161 |
+
prompts,
|
| 162 |
+
defaultPrompts,
|
| 163 |
+
isLoading,
|
| 164 |
+
activeSection,
|
| 165 |
+
activePrompt,
|
| 166 |
+
setActiveSection,
|
| 167 |
+
setActivePrompt,
|
| 168 |
+
updatePrompt,
|
| 169 |
+
restoreDefault,
|
| 170 |
+
getCurrentPrompt,
|
| 171 |
+
setCurrentPrompt,
|
| 172 |
+
};
|
| 173 |
+
}
|
frontend/src/lib/api.ts
CHANGED
|
@@ -1,78 +1,107 @@
|
|
| 1 |
-
// API 请求函数
|
| 2 |
-
|
| 3 |
-
import type { GenerateRequest, GenerateResponse, JobResult, ApiError, VideoConfig } from '../types/api';
|
| 4 |
-
|
| 5 |
-
const API_BASE = '/api';
|
| 6 |
-
|
| 7 |
-
/** 从 localStorage 加载视频配置 */
|
| 8 |
-
function loadVideoConfig(): VideoConfig {
|
| 9 |
-
try {
|
| 10 |
-
const saved = localStorage.getItem('manimcat_settings');
|
| 11 |
-
if (saved) {
|
| 12 |
-
const parsed = JSON.parse(saved);
|
| 13 |
-
if (parsed.video) {
|
| 14 |
-
return parsed.video;
|
| 15 |
-
}
|
| 16 |
-
}
|
| 17 |
-
} catch {
|
| 18 |
-
// 忽略错误,使用默认值
|
| 19 |
-
}
|
| 20 |
-
return { quality: 'medium', frameRate: 30, timeout: 120 };
|
| 21 |
-
}
|
| 22 |
-
|
| 23 |
-
/**
|
| 24 |
-
* 获取 API 请求头(包含认证信息)
|
| 25 |
-
*/
|
| 26 |
-
function getAuthHeaders(): HeadersInit {
|
| 27 |
-
const headers: HeadersInit = {
|
| 28 |
-
'Content-Type': 'application/json',
|
| 29 |
-
};
|
| 30 |
-
|
| 31 |
-
// 从 localStorage 获取用户配置的 API Key
|
| 32 |
-
const apiKey = localStorage.getItem('manimcat_api_key');
|
| 33 |
-
if (apiKey) {
|
| 34 |
-
headers['Authorization'] = `Bearer ${apiKey}`;
|
| 35 |
-
}
|
| 36 |
-
|
| 37 |
-
return headers;
|
| 38 |
-
}
|
| 39 |
-
|
| 40 |
-
/**
|
| 41 |
-
* 提交动画生成请求
|
| 42 |
-
*/
|
| 43 |
-
export async function generateAnimation(request: GenerateRequest, signal?: AbortSignal): Promise<GenerateResponse> {
|
| 44 |
-
// 如果请求中没有 videoConfig,则从设置中加载默认值
|
| 45 |
-
const videoConfig = request.videoConfig || loadVideoConfig();
|
| 46 |
-
|
| 47 |
-
const payload = { ...request, videoConfig };
|
| 48 |
-
const response = await fetch(`${API_BASE}/generate`, {
|
| 49 |
-
method: 'POST',
|
| 50 |
-
headers: getAuthHeaders(),
|
| 51 |
-
body: JSON.stringify(payload),
|
| 52 |
-
signal,
|
| 53 |
-
});
|
| 54 |
-
|
| 55 |
-
if (!response.ok) {
|
| 56 |
-
const error: ApiError = await response.json();
|
| 57 |
-
throw new Error(error.error || '生成请求失败');
|
| 58 |
-
}
|
| 59 |
-
|
| 60 |
-
return response.json();
|
| 61 |
-
}
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// API 请求函数
|
| 2 |
+
|
| 3 |
+
import type { GenerateRequest, GenerateResponse, JobResult, ApiError, VideoConfig, PromptOverrides } from '../types/api';
|
| 4 |
+
|
| 5 |
+
const API_BASE = '/api';
|
| 6 |
+
|
| 7 |
+
/** 从 localStorage 加载视频配置 */
|
| 8 |
+
function loadVideoConfig(): VideoConfig {
|
| 9 |
+
try {
|
| 10 |
+
const saved = localStorage.getItem('manimcat_settings');
|
| 11 |
+
if (saved) {
|
| 12 |
+
const parsed = JSON.parse(saved);
|
| 13 |
+
if (parsed.video) {
|
| 14 |
+
return parsed.video;
|
| 15 |
+
}
|
| 16 |
+
}
|
| 17 |
+
} catch {
|
| 18 |
+
// 忽略错误,使用默认值
|
| 19 |
+
}
|
| 20 |
+
return { quality: 'medium', frameRate: 30, timeout: 120 };
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
/**
|
| 24 |
+
* 获取 API 请求头(包含认证信息)
|
| 25 |
+
*/
|
| 26 |
+
function getAuthHeaders(): HeadersInit {
|
| 27 |
+
const headers: HeadersInit = {
|
| 28 |
+
'Content-Type': 'application/json',
|
| 29 |
+
};
|
| 30 |
+
|
| 31 |
+
// 从 localStorage 获取用户配置的 API Key
|
| 32 |
+
const apiKey = localStorage.getItem('manimcat_api_key');
|
| 33 |
+
if (apiKey) {
|
| 34 |
+
headers['Authorization'] = `Bearer ${apiKey}`;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
return headers;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
/**
|
| 41 |
+
* 提交动画生成请求
|
| 42 |
+
*/
|
| 43 |
+
export async function generateAnimation(request: GenerateRequest, signal?: AbortSignal): Promise<GenerateResponse> {
|
| 44 |
+
// 如果请求中没有 videoConfig,则从设置中加载默认值
|
| 45 |
+
const videoConfig = request.videoConfig || loadVideoConfig();
|
| 46 |
+
|
| 47 |
+
const payload = { ...request, videoConfig };
|
| 48 |
+
const response = await fetch(`${API_BASE}/generate`, {
|
| 49 |
+
method: 'POST',
|
| 50 |
+
headers: getAuthHeaders(),
|
| 51 |
+
body: JSON.stringify(payload),
|
| 52 |
+
signal,
|
| 53 |
+
});
|
| 54 |
+
|
| 55 |
+
if (!response.ok) {
|
| 56 |
+
const error: ApiError = await response.json();
|
| 57 |
+
throw new Error(error.error || '生成请求失败');
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
return response.json();
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
export async function getPromptDefaults(signal?: AbortSignal): Promise<PromptOverrides> {
|
| 64 |
+
const response = await fetch(`${API_BASE}/prompts/defaults`, {
|
| 65 |
+
headers: getAuthHeaders(),
|
| 66 |
+
signal,
|
| 67 |
+
});
|
| 68 |
+
|
| 69 |
+
if (!response.ok) {
|
| 70 |
+
const error: ApiError = await response.json();
|
| 71 |
+
throw new Error(error.error || 'Failed to load prompt defaults');
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
return response.json();
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
/**
|
| 78 |
+
* 查询任务状态
|
| 79 |
+
*/
|
| 80 |
+
export async function getJobStatus(jobId: string, signal?: AbortSignal): Promise<JobResult> {
|
| 81 |
+
const response = await fetch(`${API_BASE}/jobs/${jobId}`, {
|
| 82 |
+
headers: getAuthHeaders(),
|
| 83 |
+
signal,
|
| 84 |
+
});
|
| 85 |
+
|
| 86 |
+
if (!response.ok) {
|
| 87 |
+
const error: ApiError = await response.json();
|
| 88 |
+
throw new Error(error.error || '查询任务状态失败');
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
return response.json();
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
/**
|
| 95 |
+
* 取消任务
|
| 96 |
+
*/
|
| 97 |
+
export async function cancelJob(jobId: string): Promise<void> {
|
| 98 |
+
const response = await fetch(`${API_BASE}/jobs/${jobId}/cancel`, {
|
| 99 |
+
method: 'POST',
|
| 100 |
+
headers: getAuthHeaders(),
|
| 101 |
+
});
|
| 102 |
+
|
| 103 |
+
if (!response.ok) {
|
| 104 |
+
const error: ApiError = await response.json();
|
| 105 |
+
throw new Error(error.error || '取消任务失败');
|
| 106 |
+
}
|
| 107 |
+
}
|
frontend/src/types/api.ts
CHANGED
|
@@ -11,6 +11,20 @@ export interface ApiConfig {
|
|
| 11 |
manimcatApiKey: string;
|
| 12 |
}
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
/** 视频配置 */
|
| 15 |
export interface VideoConfig {
|
| 16 |
/** 默认质量 */
|
|
@@ -42,6 +56,8 @@ export interface GenerateRequest {
|
|
| 42 |
code?: string;
|
| 43 |
/** 视频配置 */
|
| 44 |
videoConfig?: VideoConfig;
|
|
|
|
|
|
|
| 45 |
}
|
| 46 |
|
| 47 |
/** 生成响应 */
|
|
@@ -64,9 +80,11 @@ export interface JobResult {
|
|
| 64 |
used_ai?: boolean;
|
| 65 |
render_quality?: string;
|
| 66 |
generation_type?: string;
|
| 67 |
-
render_peak_memory_mb?: number;
|
|
|
|
| 68 |
error?: string;
|
| 69 |
details?: string;
|
|
|
|
| 70 |
}
|
| 71 |
|
| 72 |
/** API 错误 */
|
|
|
|
| 11 |
manimcatApiKey: string;
|
| 12 |
}
|
| 13 |
|
| 14 |
+
export interface PromptOverrides {
|
| 15 |
+
system?: {
|
| 16 |
+
conceptDesigner?: string;
|
| 17 |
+
codeGeneration?: string;
|
| 18 |
+
codeRetry?: string;
|
| 19 |
+
};
|
| 20 |
+
user?: {
|
| 21 |
+
conceptDesigner?: string;
|
| 22 |
+
codeGeneration?: string;
|
| 23 |
+
codeRetryInitial?: string;
|
| 24 |
+
codeRetryFix?: string;
|
| 25 |
+
};
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
/** 视频配置 */
|
| 29 |
export interface VideoConfig {
|
| 30 |
/** 默认质量 */
|
|
|
|
| 56 |
code?: string;
|
| 57 |
/** 视频配置 */
|
| 58 |
videoConfig?: VideoConfig;
|
| 59 |
+
/** Prompt overrides */
|
| 60 |
+
promptOverrides?: PromptOverrides;
|
| 61 |
}
|
| 62 |
|
| 63 |
/** 生成响应 */
|
|
|
|
| 80 |
used_ai?: boolean;
|
| 81 |
render_quality?: string;
|
| 82 |
generation_type?: string;
|
| 83 |
+
render_peak_memory_mb?: number;
|
| 84 |
+
|
| 85 |
error?: string;
|
| 86 |
details?: string;
|
| 87 |
+
cancel_reason?: string;
|
| 88 |
}
|
| 89 |
|
| 90 |
/** API 错误 */
|
public/assets/index-Cyf_XhIu.css
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
@import"https://cdn.jsdelivr.net/npm/lxgw-wenkai-screen-webfont@1.1.0/style.css";*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:Inter,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:SF Mono,Monaco,Cascadia Code,Roboto Mono,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.-top-2{top:-.5rem}.-top-2\.5{top:-.625rem}.left-0{left:0}.left-1{left:.25rem}.left-3{left:.75rem}.left-4{left:1rem}.right-0{right:0}.right-3{right:.75rem}.right-4{right:1rem}.top-1{top:.25rem}.top-1\/2{top:50%}.top-4{top:1rem}.top-full{top:100%}.z-10{z-index:10}.z-50{z-index:50}.mx-auto{margin-left:auto;margin-right:auto}.mb-1{margin-bottom:.25rem}.mb-12{margin-bottom:3rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.ml-1{margin-left:.25rem}.ml-4{margin-left:1rem}.mr-2{margin-right:.5rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-8{margin-top:2rem}.inline{display:inline}.flex{display:flex}.grid{display:grid}.hidden{display:none}.h-10{height:2.5rem}.h-16{height:4rem}.h-3\.5{height:.875rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-7{height:1.75rem}.h-8{height:2rem}.h-\[360px\]{height:360px}.h-full{height:100%}.min-h-\[80px\]{min-height:80px}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-12{width:3rem}.w-16{width:4rem}.w-3\.5{width:.875rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-64{width:16rem}.w-8{width:2rem}.w-full{width:100%}.max-w-2xl{max-width:42rem}.max-w-4xl{max-width:56rem}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.flex-1{flex:1 1 0%}.-translate-x-full{--tw-translate-x: -100%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-1\/2{--tw-translate-y: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-x-0{--tw-translate-x: 0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-x-5{--tw-translate-x: 1.25rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.rotate-180{--tw-rotate: 180deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes spin{to{transform:rotate(360deg)}}.animate-spin{animation:spin 1s linear infinite}.cursor-pointer{cursor:pointer}.resize-none{resize:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-col{flex-direction:column}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem * var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(2rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem * var(--tw-space-y-reverse))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-y-auto{overflow-y:auto}.whitespace-pre-wrap{white-space:pre-wrap}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-xl{border-radius:.75rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-bg-secondary\/50{border-color:rgba(var(--bg-secondary-rgb),.5)}.border-red-500\/30{border-color:#ef44444d}.bg-accent{background-color:rgba(var(--accent-rgb),1)}.bg-bg-primary{background-color:rgba(var(--bg-primary-rgb),1)}.bg-bg-secondary{background-color:rgba(var(--bg-secondary-rgb),1)}.bg-bg-secondary\/20{background-color:rgba(var(--bg-secondary-rgb),.2)}.bg-bg-secondary\/30{background-color:rgba(var(--bg-secondary-rgb),.3)}.bg-bg-secondary\/40{background-color:rgba(var(--bg-secondary-rgb),.4)}.bg-bg-secondary\/50{background-color:rgba(var(--bg-secondary-rgb),.5)}.bg-bg-secondary\/70{background-color:rgba(var(--bg-secondary-rgb),.7)}.bg-bg-tertiary{background-color:rgba(var(--bg-tertiary-rgb),1)}.bg-black{--tw-bg-opacity: 1;background-color:rgb(0 0 0 / var(--tw-bg-opacity, 1))}.bg-black\/50{background-color:#00000080}.bg-blue-50{--tw-bg-opacity: 1;background-color:rgb(239 246 255 / var(--tw-bg-opacity, 1))}.bg-green-50{--tw-bg-opacity: 1;background-color:rgb(240 253 244 / var(--tw-bg-opacity, 1))}.bg-red-50{--tw-bg-opacity: 1;background-color:rgb(254 242 242 / var(--tw-bg-opacity, 1))}.bg-red-50\/50{background-color:#fef2f280}.bg-red-50\/80{background-color:#fef2f2cc}.bg-white{--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.from-transparent{--tw-gradient-from: transparent var(--tw-gradient-from-position);--tw-gradient-to: rgb(0 0 0 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.via-white\/10{--tw-gradient-to: rgb(255 255 255 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), rgb(255 255 255 / .1) var(--tw-gradient-via-position), var(--tw-gradient-to)}.to-transparent{--tw-gradient-to: transparent var(--tw-gradient-to-position)}.object-contain{-o-object-fit:contain;object-fit:contain}.p-1{padding:.25rem}.p-2{padding:.5rem}.p-2\.5{padding:.625rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-12{padding-left:3rem;padding-right:3rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-16{padding-top:4rem;padding-bottom:4rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-3\.5{padding-top:.875rem;padding-bottom:.875rem}.py-4{padding-top:1rem;padding-bottom:1rem}.pr-10{padding-right:2.5rem}.pt-4{padding-top:1rem}.text-left{text-align:left}.text-center{text-align:center}.font-mono{font-family:SF Mono,Monaco,Cascadia Code,Roboto Mono,monospace}.text-5xl{font-size:3rem;line-height:1}.text-\[10px\]{font-size:10px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-light{font-weight:300}.font-medium{font-weight:500}.uppercase{text-transform:uppercase}.tracking-tight{letter-spacing:-.025em}.tracking-wide{letter-spacing:.025em}.tracking-wider{letter-spacing:.05em}.text-accent{color:rgba(var(--accent-rgb),1)}.text-blue-600{--tw-text-opacity: 1;color:rgb(37 99 235 / var(--tw-text-opacity, 1))}.text-green-600{--tw-text-opacity: 1;color:rgb(22 163 74 / var(--tw-text-opacity, 1))}.text-red-500{--tw-text-opacity: 1;color:rgb(239 68 68 / var(--tw-text-opacity, 1))}.text-red-600{--tw-text-opacity: 1;color:rgb(220 38 38 / var(--tw-text-opacity, 1))}.text-text-primary{color:rgba(var(--text-primary-rgb),1)}.text-text-primary\/90{color:rgba(var(--text-primary-rgb),.9)}.text-text-secondary{color:rgba(var(--text-secondary-rgb),1)}.text-text-secondary\/40{color:rgba(var(--text-secondary-rgb),.4)}.text-text-secondary\/50{color:rgba(var(--text-secondary-rgb),.5)}.text-text-secondary\/60{color:rgba(var(--text-secondary-rgb),.6)}.text-text-secondary\/70{color:rgba(var(--text-secondary-rgb),.7)}.text-text-secondary\/80{color:rgba(var(--text-secondary-rgb),.8)}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.placeholder-text-secondary\/40::-moz-placeholder{color:rgba(var(--text-secondary-rgb),.4)}.placeholder-text-secondary\/40::placeholder{color:rgba(var(--text-secondary-rgb),.4)}.opacity-25{opacity:.25}.opacity-75{opacity:.75}.shadow-lg{--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1);--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow: 0 4px 6px -1px rgb(0 0 0 / .1), 0 2px 4px -2px rgb(0 0 0 / .1);--tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow: 0 1px 2px 0 rgb(0 0 0 / .05);--tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow: 0 20px 25px -5px rgb(0 0 0 / .1), 0 8px 10px -6px rgb(0 0 0 / .1);--tw-shadow-colored: 0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-accent\/25{--tw-shadow-color: rgba(var(--accent-rgb), .25);--tw-shadow: var(--tw-shadow-colored)}.shadow-black\/10{--tw-shadow-color: rgb(0 0 0 / .1);--tw-shadow: var(--tw-shadow-colored)}.drop-shadow-lg{--tw-drop-shadow: drop-shadow(0 10px 8px rgb(0 0 0 / .04)) drop-shadow(0 4px 3px rgb(0 0 0 / .1));filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}.duration-500{transition-duration:.5s}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}:root{--bg-primary-rgb: 245, 245, 245;--bg-secondary-rgb: 238, 238, 238;--bg-tertiary-rgb: 224, 224, 224;--text-primary-rgb: 33, 33, 33;--text-secondary-rgb: 117, 117, 117;--text-tertiary-rgb: 158, 158, 158;--accent-rgb: 66, 66, 66;--accent-hover-rgb: 48, 48, 48;--border-rgb: 0, 0, 0}.dark{--bg-primary-rgb: 18, 18, 18;--bg-secondary-rgb: 28, 28, 28;--bg-tertiary-rgb: 38, 38, 38;--text-primary-rgb: 245, 245, 245;--text-secondary-rgb: 189, 189, 189;--text-tertiary-rgb: 117, 117, 117;--accent-rgb: 138, 138, 138;--accent-hover-rgb: 158, 158, 158;--border-rgb: 255, 255, 255}body{font-family:LXGW WenKai Screen,LXGW WenKai TC,LXGW WenKai Lite,LXGW WenKai,sans-serif;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;background-color:rgba(var(--bg-primary-rgb),1);color:rgba(var(--text-primary-rgb),1);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.3s}select option{padding:10px 16px;background-color:#fff;color:#212121}select option:hover,select option:focus{background-color:#f5f5f5}select option:checked{background-color:#424242;color:#fff;font-weight:500}::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:#0000001a;border-radius:9999px}.dark ::-webkit-scrollbar-thumb{background:#ffffff26}::-webkit-scrollbar-thumb:hover{background:#0003}.dark ::-webkit-scrollbar-thumb:hover{background:#ffffff40}.dark .react-syntax-highlighter{background:transparent!important}@keyframes fade-in{0%{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}.animate-fade-in{animation:fade-in .3s ease-out}.hover\:bg-accent-hover:hover{background-color:rgba(var(--accent-hover-rgb),1)}.hover\:bg-bg-secondary:hover{background-color:rgba(var(--bg-secondary-rgb),1)}.hover\:bg-bg-secondary\/30:hover{background-color:rgba(var(--bg-secondary-rgb),.3)}.hover\:bg-bg-secondary\/50:hover{background-color:rgba(var(--bg-secondary-rgb),.5)}.hover\:bg-bg-secondary\/60:hover{background-color:rgba(var(--bg-secondary-rgb),.6)}.hover\:bg-bg-secondary\/70:hover{background-color:rgba(var(--bg-secondary-rgb),.7)}.hover\:bg-bg-secondary\/80:hover{background-color:rgba(var(--bg-secondary-rgb),.8)}.hover\:text-accent:hover{color:rgba(var(--accent-rgb),1)}.hover\:text-accent-hover:hover{color:rgba(var(--accent-hover-rgb),1)}.hover\:text-red-500:hover{--tw-text-opacity: 1;color:rgb(239 68 68 / var(--tw-text-opacity, 1))}.hover\:text-text-primary:hover{color:rgba(var(--text-primary-rgb),1)}.hover\:text-text-secondary:hover{color:rgba(var(--text-secondary-rgb),1)}.hover\:shadow-xl:hover{--tw-shadow: 0 20px 25px -5px rgb(0 0 0 / .1), 0 8px 10px -6px rgb(0 0 0 / .1);--tw-shadow-colored: 0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.hover\:shadow-accent\/35:hover{--tw-shadow-color: rgba(var(--accent-rgb), .35);--tw-shadow: var(--tw-shadow-colored)}.focus\:border-accent\/30:focus{border-color:rgba(var(--accent-rgb),.3)}.focus\:border-red-500\/50:focus{border-color:#ef444480}.focus\:bg-bg-secondary\/70:focus{background-color:rgba(var(--bg-secondary-rgb),.7)}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-2:focus{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus\:ring-accent\/20:focus{--tw-ring-color: rgba(var(--accent-rgb), .2)}.focus\:ring-red-500\/20:focus{--tw-ring-color: rgb(239 68 68 / .2)}.active\:scale-\[0\.97\]:active{--tw-scale-x: .97;--tw-scale-y: .97;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.active\:scale-\[0\.98\]:active{--tw-scale-x: .98;--tw-scale-y: .98;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-30:disabled{opacity:.3}.disabled\:opacity-50:disabled{opacity:.5}.group:hover .group-hover\:translate-x-1{--tw-translate-x: .25rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes shimmer{to{transform:translate(100%)}}.group:hover .group-hover\:animate-shimmer{animation:shimmer 1.5s infinite}.dark\:bg-blue-900\/20:is(.dark *){background-color:#1e3a8a33}.dark\:bg-green-900\/20:is(.dark *){background-color:#14532d33}.dark\:bg-red-900\/10:is(.dark *){background-color:#7f1d1d1a}.dark\:bg-red-900\/20:is(.dark *){background-color:#7f1d1d33}.dark\:text-blue-400:is(.dark *){--tw-text-opacity: 1;color:rgb(96 165 250 / var(--tw-text-opacity, 1))}.dark\:text-green-400:is(.dark *){--tw-text-opacity: 1;color:rgb(74 222 128 / var(--tw-text-opacity, 1))}.dark\:text-red-400:is(.dark *){--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity, 1))}@media(min-width:640px){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:p-6{padding:1.5rem}.sm\:py-20{padding-top:5rem;padding-bottom:5rem}.sm\:text-6xl{font-size:3.75rem;line-height:1}.sm\:text-base{font-size:1rem;line-height:1.5rem}}@media(min-width:1024px){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
public/assets/index-DkT5mxpT.css
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
@import"https://cdn.jsdelivr.net/npm/lxgw-wenkai-webfont@1.1.0/style.css";@import"https://cdn.jsdelivr.net/npm/lxgw-wenkai-lite-webfont@1.1.0/style.css";@import"https://cdn.jsdelivr.net/npm/lxgw-wenkai-tc-webfont@1.0.0/style.css";@import"https://cdn.jsdelivr.net/npm/lxgw-wenkai-screen-webfont@1.1.0/style.css";*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:Inter,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:SF Mono,Monaco,Cascadia Code,Roboto Mono,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.-top-2{top:-.5rem}.-top-2\.5{top:-.625rem}.left-0{left:0}.left-1{left:.25rem}.left-3{left:.75rem}.left-4{left:1rem}.right-0{right:0}.right-3{right:.75rem}.right-4{right:1rem}.top-1{top:.25rem}.top-1\/2{top:50%}.top-4{top:1rem}.top-full{top:100%}.z-10{z-index:10}.z-50{z-index:50}.mx-auto{margin-left:auto;margin-right:auto}.mb-1{margin-bottom:.25rem}.mb-12{margin-bottom:3rem}.mb-3{margin-bottom:.75rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.ml-1{margin-left:.25rem}.mt-0\.5{margin-top:.125rem}.mt-2{margin-top:.5rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-8{margin-top:2rem}.flex{display:flex}.grid{display:grid}.hidden{display:none}.h-10{height:2.5rem}.h-16{height:4rem}.h-3\.5{height:.875rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-7{height:1.75rem}.h-8{height:2rem}.h-\[360px\]{height:360px}.h-full{height:100%}.min-h-\[80px\]{min-height:80px}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-12{width:3rem}.w-16{width:4rem}.w-3\.5{width:.875rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-8{width:2rem}.w-full{width:100%}.max-w-2xl{max-width:42rem}.max-w-4xl{max-width:56rem}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.flex-1{flex:1 1 0%}.-translate-x-full{--tw-translate-x: -100%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-1\/2{--tw-translate-y: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-x-0{--tw-translate-x: 0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-x-5{--tw-translate-x: 1.25rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.rotate-180{--tw-rotate: 180deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes spin{to{transform:rotate(360deg)}}.animate-spin{animation:spin 1s linear infinite}.cursor-pointer{cursor:pointer}.resize-none{resize:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-col{flex-direction:column}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem * var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-xl{border-radius:.75rem}.bg-accent{background-color:rgba(var(--accent-rgb),1)}.bg-bg-primary{background-color:rgba(var(--bg-primary-rgb),1)}.bg-bg-secondary{background-color:rgba(var(--bg-secondary-rgb),1)}.bg-bg-secondary\/20{background-color:rgba(var(--bg-secondary-rgb),.2)}.bg-bg-secondary\/30{background-color:rgba(var(--bg-secondary-rgb),.3)}.bg-bg-secondary\/40{background-color:rgba(var(--bg-secondary-rgb),.4)}.bg-bg-secondary\/50{background-color:rgba(var(--bg-secondary-rgb),.5)}.bg-bg-secondary\/70{background-color:rgba(var(--bg-secondary-rgb),.7)}.bg-bg-tertiary{background-color:rgba(var(--bg-tertiary-rgb),1)}.bg-black{--tw-bg-opacity: 1;background-color:rgb(0 0 0 / var(--tw-bg-opacity, 1))}.bg-black\/50{background-color:#00000080}.bg-blue-50{--tw-bg-opacity: 1;background-color:rgb(239 246 255 / var(--tw-bg-opacity, 1))}.bg-green-50{--tw-bg-opacity: 1;background-color:rgb(240 253 244 / var(--tw-bg-opacity, 1))}.bg-red-50{--tw-bg-opacity: 1;background-color:rgb(254 242 242 / var(--tw-bg-opacity, 1))}.bg-red-50\/50{background-color:#fef2f280}.bg-red-50\/80{background-color:#fef2f2cc}.bg-white{--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.from-transparent{--tw-gradient-from: transparent var(--tw-gradient-from-position);--tw-gradient-to: rgb(0 0 0 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.via-white\/10{--tw-gradient-to: rgb(255 255 255 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), rgb(255 255 255 / .1) var(--tw-gradient-via-position), var(--tw-gradient-to)}.to-transparent{--tw-gradient-to: transparent var(--tw-gradient-to-position)}.object-contain{-o-object-fit:contain;object-fit:contain}.p-1{padding:.25rem}.p-2{padding:.5rem}.p-2\.5{padding:.625rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-12{padding-left:3rem;padding-right:3rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-16{padding-top:4rem;padding-bottom:4rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-3\.5{padding-top:.875rem;padding-bottom:.875rem}.py-4{padding-top:1rem;padding-bottom:1rem}.pr-10{padding-right:2.5rem}.pt-4{padding-top:1rem}.text-left{text-align:left}.text-center{text-align:center}.font-mono{font-family:SF Mono,Monaco,Cascadia Code,Roboto Mono,monospace}.text-5xl{font-size:3rem;line-height:1}.text-\[10px\]{font-size:10px}.text-base{font-size:1rem;line-height:1.5rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-light{font-weight:300}.font-medium{font-weight:500}.uppercase{text-transform:uppercase}.tracking-tight{letter-spacing:-.025em}.tracking-wide{letter-spacing:.025em}.text-accent{color:rgba(var(--accent-rgb),1)}.text-blue-600{--tw-text-opacity: 1;color:rgb(37 99 235 / var(--tw-text-opacity, 1))}.text-green-600{--tw-text-opacity: 1;color:rgb(22 163 74 / var(--tw-text-opacity, 1))}.text-red-500{--tw-text-opacity: 1;color:rgb(239 68 68 / var(--tw-text-opacity, 1))}.text-red-600{--tw-text-opacity: 1;color:rgb(220 38 38 / var(--tw-text-opacity, 1))}.text-text-primary{color:rgba(var(--text-primary-rgb),1)}.text-text-primary\/90{color:rgba(var(--text-primary-rgb),.9)}.text-text-secondary{color:rgba(var(--text-secondary-rgb),1)}.text-text-secondary\/50{color:rgba(var(--text-secondary-rgb),.5)}.text-text-secondary\/60{color:rgba(var(--text-secondary-rgb),.6)}.text-text-secondary\/70{color:rgba(var(--text-secondary-rgb),.7)}.text-text-secondary\/80{color:rgba(var(--text-secondary-rgb),.8)}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.placeholder-text-secondary\/40::-moz-placeholder{color:rgba(var(--text-secondary-rgb),.4)}.placeholder-text-secondary\/40::placeholder{color:rgba(var(--text-secondary-rgb),.4)}.opacity-25{opacity:.25}.opacity-75{opacity:.75}.shadow-lg{--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1);--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow: 0 4px 6px -1px rgb(0 0 0 / .1), 0 2px 4px -2px rgb(0 0 0 / .1);--tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow: 0 1px 2px 0 rgb(0 0 0 / .05);--tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow: 0 20px 25px -5px rgb(0 0 0 / .1), 0 8px 10px -6px rgb(0 0 0 / .1);--tw-shadow-colored: 0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-accent\/25{--tw-shadow-color: rgba(var(--accent-rgb), .25);--tw-shadow: var(--tw-shadow-colored)}.shadow-black\/10{--tw-shadow-color: rgb(0 0 0 / .1);--tw-shadow: var(--tw-shadow-colored)}.drop-shadow-lg{--tw-drop-shadow: drop-shadow(0 10px 8px rgb(0 0 0 / .04)) drop-shadow(0 4px 3px rgb(0 0 0 / .1));filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}.duration-500{transition-duration:.5s}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}:root{--bg-primary-rgb: 245, 245, 245;--bg-secondary-rgb: 238, 238, 238;--bg-tertiary-rgb: 224, 224, 224;--text-primary-rgb: 33, 33, 33;--text-secondary-rgb: 117, 117, 117;--text-tertiary-rgb: 158, 158, 158;--accent-rgb: 66, 66, 66;--accent-hover-rgb: 48, 48, 48;--border-rgb: 0, 0, 0}.dark{--bg-primary-rgb: 18, 18, 18;--bg-secondary-rgb: 28, 28, 28;--bg-tertiary-rgb: 38, 38, 38;--text-primary-rgb: 245, 245, 245;--text-secondary-rgb: 189, 189, 189;--text-tertiary-rgb: 117, 117, 117;--accent-rgb: 138, 138, 138;--accent-hover-rgb: 158, 158, 158;--border-rgb: 255, 255, 255}body{font-family:LXGW WenKai Screen,LXGW WenKai TC,LXGW WenKai Lite,LXGW WenKai,sans-serif;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;background-color:rgba(var(--bg-primary-rgb),1);color:rgba(var(--text-primary-rgb),1);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.3s}select option{padding:10px 16px;background-color:#fff;color:#212121}select option:hover,select option:focus{background-color:#f5f5f5}select option:checked{background-color:#424242;color:#fff;font-weight:500}::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:#0000001a;border-radius:9999px}.dark ::-webkit-scrollbar-thumb{background:#ffffff26}::-webkit-scrollbar-thumb:hover{background:#0003}.dark ::-webkit-scrollbar-thumb:hover{background:#ffffff40}.dark .react-syntax-highlighter{background:transparent!important}@keyframes fade-in{0%{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}.animate-fade-in{animation:fade-in .3s ease-out}.hover\:bg-accent-hover:hover{background-color:rgba(var(--accent-hover-rgb),1)}.hover\:bg-bg-secondary:hover{background-color:rgba(var(--bg-secondary-rgb),1)}.hover\:bg-bg-secondary\/30:hover{background-color:rgba(var(--bg-secondary-rgb),.3)}.hover\:bg-bg-secondary\/50:hover{background-color:rgba(var(--bg-secondary-rgb),.5)}.hover\:bg-bg-secondary\/60:hover{background-color:rgba(var(--bg-secondary-rgb),.6)}.hover\:bg-bg-secondary\/70:hover{background-color:rgba(var(--bg-secondary-rgb),.7)}.hover\:bg-bg-secondary\/80:hover{background-color:rgba(var(--bg-secondary-rgb),.8)}.hover\:text-accent:hover{color:rgba(var(--accent-rgb),1)}.hover\:text-accent-hover:hover{color:rgba(var(--accent-hover-rgb),1)}.hover\:text-red-500:hover{--tw-text-opacity: 1;color:rgb(239 68 68 / var(--tw-text-opacity, 1))}.hover\:text-text-primary:hover{color:rgba(var(--text-primary-rgb),1)}.hover\:text-text-secondary:hover{color:rgba(var(--text-secondary-rgb),1)}.hover\:shadow-xl:hover{--tw-shadow: 0 20px 25px -5px rgb(0 0 0 / .1), 0 8px 10px -6px rgb(0 0 0 / .1);--tw-shadow-colored: 0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.hover\:shadow-accent\/35:hover{--tw-shadow-color: rgba(var(--accent-rgb), .35);--tw-shadow: var(--tw-shadow-colored)}.focus\:bg-bg-secondary\/70:focus{background-color:rgba(var(--bg-secondary-rgb),.7)}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-2:focus{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus\:ring-accent\/20:focus{--tw-ring-color: rgba(var(--accent-rgb), .2)}.focus\:ring-red-500\/20:focus{--tw-ring-color: rgb(239 68 68 / .2)}.active\:scale-\[0\.97\]:active{--tw-scale-x: .97;--tw-scale-y: .97;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.active\:scale-\[0\.98\]:active{--tw-scale-x: .98;--tw-scale-y: .98;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-30:disabled{opacity:.3}.disabled\:opacity-50:disabled{opacity:.5}.group:hover .group-hover\:translate-x-1{--tw-translate-x: .25rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes shimmer{to{transform:translate(100%)}}.group:hover .group-hover\:animate-shimmer{animation:shimmer 1.5s infinite}.dark\:bg-blue-900\/20:is(.dark *){background-color:#1e3a8a33}.dark\:bg-green-900\/20:is(.dark *){background-color:#14532d33}.dark\:bg-red-900\/10:is(.dark *){background-color:#7f1d1d1a}.dark\:bg-red-900\/20:is(.dark *){background-color:#7f1d1d33}.dark\:text-blue-400:is(.dark *){--tw-text-opacity: 1;color:rgb(96 165 250 / var(--tw-text-opacity, 1))}.dark\:text-green-400:is(.dark *){--tw-text-opacity: 1;color:rgb(74 222 128 / var(--tw-text-opacity, 1))}.dark\:text-red-400:is(.dark *){--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity, 1))}@media(min-width:640px){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:p-6{padding:1.5rem}.sm\:py-20{padding-top:5rem;padding-bottom:5rem}.sm\:text-6xl{font-size:3.75rem;line-height:1}.sm\:text-base{font-size:1rem;line-height:1.5rem}}@media(min-width:1024px){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
|
|
|
|
|
public/assets/{index-0yzmNnTY.js → index-FIHmTYvd.js}
RENAMED
|
The diff for this file is too large to render.
See raw diff
|
|
|
public/index.html
CHANGED
|
@@ -2,14 +2,12 @@
|
|
| 2 |
<html lang="zh-CN">
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
-
<!-- 预连接 CDN,加速字体加载 -->
|
| 6 |
-
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
|
| 7 |
<!-- 直接内嵌 SVG 作为 favicon -->
|
| 8 |
<link rel="icon" href="data:image/svg+xml,<svg viewBox='0 0 512 512' xmlns='http://www.w3.org/2000/svg'><rect width='512' height='512' fill='%23faf9f5'/><path d='M 100 400 V 140 L 230 300 L 360 140 V 260' fill='none' stroke='%23455a64' stroke-width='55' stroke-linecap='round' stroke-linejoin='round'/><g transform='translate(360, 340)'><path d='M -70 40 C -80 0, -80 -30, -50 -60 L -20 -30 L 20 -30 L 50 -60 C 80 -30, 80 0, 70 40 C 60 70, -60 70, -70 40 Z' fill='%23455a64'/><circle cx='-35' cy='-5' r='18' fill='%23ffffff'/><circle cx='35' cy='-5' r='18' fill='%23ffffff'/><circle cx='-38' cy='-5' r='6' fill='%23455a64'/><circle cx='32' cy='-5' r='6' fill='%23455a64'/></g></svg>" />
|
| 9 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 10 |
<title>ManimCat - 数学动画生成器</title>
|
| 11 |
-
<script type="module" crossorigin src="/assets/index-
|
| 12 |
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
| 13 |
</head>
|
| 14 |
<body>
|
| 15 |
<div id="root"></div>
|
|
|
|
| 2 |
<html lang="zh-CN">
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8" />
|
|
|
|
|
|
|
| 5 |
<!-- 直接内嵌 SVG 作为 favicon -->
|
| 6 |
<link rel="icon" href="data:image/svg+xml,<svg viewBox='0 0 512 512' xmlns='http://www.w3.org/2000/svg'><rect width='512' height='512' fill='%23faf9f5'/><path d='M 100 400 V 140 L 230 300 L 360 140 V 260' fill='none' stroke='%23455a64' stroke-width='55' stroke-linecap='round' stroke-linejoin='round'/><g transform='translate(360, 340)'><path d='M -70 40 C -80 0, -80 -30, -50 -60 L -20 -30 L 20 -30 L 50 -60 C 80 -30, 80 0, 70 40 C 60 70, -60 70, -70 40 Z' fill='%23455a64'/><circle cx='-35' cy='-5' r='18' fill='%23ffffff'/><circle cx='35' cy='-5' r='18' fill='%23ffffff'/><circle cx='-38' cy='-5' r='6' fill='%23455a64'/><circle cx='32' cy='-5' r='6' fill='%23455a64'/></g></svg>" />
|
| 7 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 8 |
<title>ManimCat - 数学动画生成器</title>
|
| 9 |
+
<script type="module" crossorigin src="/assets/index-FIHmTYvd.js"></script>
|
| 10 |
+
<link rel="stylesheet" crossorigin href="/assets/index-Cyf_XhIu.css">
|
| 11 |
</head>
|
| 12 |
<body>
|
| 13 |
<div id="root"></div>
|
src/config/redis.ts
CHANGED
|
@@ -60,6 +60,7 @@ export function createRedisClient(): Redis {
|
|
| 60 |
*/
|
| 61 |
export const REDIS_KEYS = {
|
| 62 |
JOB_RESULT: 'job:result:',
|
|
|
|
| 63 |
CONCEPT_CACHE: 'concept:cache:',
|
| 64 |
QUEUE_PREFIX: 'bull:'
|
| 65 |
} as const
|
|
|
|
| 60 |
*/
|
| 61 |
export const REDIS_KEYS = {
|
| 62 |
JOB_RESULT: 'job:result:',
|
| 63 |
+
JOB_CANCEL: 'job:cancel:',
|
| 64 |
CONCEPT_CACHE: 'concept:cache:',
|
| 65 |
QUEUE_PREFIX: 'bull:'
|
| 66 |
} as const
|
src/prompts/index.ts
CHANGED
|
@@ -64,6 +64,20 @@ export const SYSTEM_PROMPTS = {
|
|
| 64 |
- **坐标系一致性**:所有图形必须通过 \`axes.c2p\` 映射到坐标轴上,严禁脱离坐标系的自由定位。`
|
| 65 |
}
|
| 66 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
// =====================
|
| 68 |
// API Index
|
| 69 |
// =====================
|
|
|
|
| 64 |
- **坐标系一致性**:所有图形必须通过 \`axes.c2p\` 映射到坐标轴上,严禁脱离坐标系的自由定位。`
|
| 65 |
}
|
| 66 |
|
| 67 |
+
export const SYSTEM_PROMPT_BASE = SYSTEM_PROMPTS.codeGeneration
|
| 68 |
+
|
| 69 |
+
export const SYSTEM_PROMPT_STAGES = {
|
| 70 |
+
conceptDesigner: SYSTEM_PROMPTS.conceptDesigner,
|
| 71 |
+
codeGeneration: '',
|
| 72 |
+
codeFix: SYSTEM_PROMPTS.codeFix.replace(SYSTEM_PROMPT_BASE, '')
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
export const SYSTEM_PROMPT_COMBINED = {
|
| 76 |
+
conceptDesigner: SYSTEM_PROMPT_STAGES.conceptDesigner,
|
| 77 |
+
codeGeneration: `${SYSTEM_PROMPT_BASE}${SYSTEM_PROMPT_STAGES.codeGeneration}`,
|
| 78 |
+
codeFix: `${SYSTEM_PROMPT_BASE}${SYSTEM_PROMPT_STAGES.codeFix}`
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
// =====================
|
| 82 |
// API Index
|
| 83 |
// =====================
|
src/queues/processors/steps/analysis-step.ts
CHANGED
|
@@ -1,140 +1,142 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* 概念分析步骤
|
| 3 |
-
* 分析用户输入,决定生成策略
|
| 4 |
-
*/
|
| 5 |
-
|
| 6 |
-
import {
|
| 7 |
-
isLikelyLatex,
|
| 8 |
-
selectTemplate,
|
| 9 |
-
generateLatexSceneCode
|
| 10 |
-
} from '../../../services/manim-templates'
|
| 11 |
-
import { generateTwoStageAIManimCode } from '../../../services/concept-designer'
|
| 12 |
-
import { createLogger } from '../../../utils/logger'
|
| 13 |
-
import type { CustomApiConfig } from '../../../types'
|
| 14 |
-
|
| 15 |
-
const logger = createLogger('AnalysisStep')
|
| 16 |
-
|
| 17 |
-
/**
|
| 18 |
-
* 概念分析结果
|
| 19 |
-
*/
|
| 20 |
-
export interface AnalysisResult {
|
| 21 |
-
analysisType: 'latex' | 'template' | 'ai' | 'fallback'
|
| 22 |
-
manimCode: string | null
|
| 23 |
-
needsAI: boolean
|
| 24 |
-
}
|
| 25 |
-
|
| 26 |
-
/**
|
| 27 |
-
* 代码生成结果
|
| 28 |
-
*/
|
| 29 |
-
export interface GenerationResult {
|
| 30 |
-
manimCode: string
|
| 31 |
-
usedAI: boolean
|
| 32 |
-
generationType: string
|
| 33 |
-
sceneDesign?: string // 新增:保存场景设计方案,用于重试
|
| 34 |
-
}
|
| 35 |
-
|
| 36 |
-
/**
|
| 37 |
-
* 分析概念
|
| 38 |
-
*/
|
| 39 |
-
export async function analyzeConcept(
|
| 40 |
-
jobId: string,
|
| 41 |
-
concept: string,
|
| 42 |
-
_quality: string
|
| 43 |
-
): Promise<AnalysisResult> {
|
| 44 |
-
logger.info('Analyzing concept', { jobId, concept })
|
| 45 |
-
|
| 46 |
-
// 检查是否
|
| 47 |
-
if (isLikelyLatex(concept)) {
|
| 48 |
-
logger.info('Detected LaTeX', { jobId })
|
| 49 |
-
return {
|
| 50 |
-
analysisType: 'latex',
|
| 51 |
-
manimCode: generateLatexSceneCode(concept),
|
| 52 |
-
needsAI: false
|
| 53 |
-
}
|
| 54 |
-
}
|
| 55 |
-
|
| 56 |
-
// 尝试匹配模板
|
| 57 |
-
const templateResult = selectTemplate(concept)
|
| 58 |
-
if (templateResult) {
|
| 59 |
-
logger.info('Matched template', { jobId, template: templateResult.templateName })
|
| 60 |
-
return {
|
| 61 |
-
analysisType: 'template',
|
| 62 |
-
manimCode: templateResult.code,
|
| 63 |
-
needsAI: false
|
| 64 |
-
}
|
| 65 |
-
}
|
| 66 |
-
|
| 67 |
-
// 需要 AI 生成
|
| 68 |
-
logger.info('Using AI for unique output', { jobId })
|
| 69 |
-
return {
|
| 70 |
-
analysisType: 'ai',
|
| 71 |
-
manimCode: null,
|
| 72 |
-
needsAI: true
|
| 73 |
-
}
|
| 74 |
-
}
|
| 75 |
-
|
| 76 |
-
/**
|
| 77 |
-
* 生成代码
|
| 78 |
-
*/
|
| 79 |
-
export async function generateCode(
|
| 80 |
-
jobId: string,
|
| 81 |
-
concept: string,
|
| 82 |
-
_quality: string,
|
| 83 |
-
analyzeResult: AnalysisResult,
|
| 84 |
-
customApiConfig?: CustomApiConfig
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
self.
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
}
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
*
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* 概念分析步骤
|
| 3 |
+
* 分析用户输入,决定生成策略
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
import {
|
| 7 |
+
isLikelyLatex,
|
| 8 |
+
selectTemplate,
|
| 9 |
+
generateLatexSceneCode
|
| 10 |
+
} from '../../../services/manim-templates'
|
| 11 |
+
import { generateTwoStageAIManimCode } from '../../../services/concept-designer'
|
| 12 |
+
import { createLogger } from '../../../utils/logger'
|
| 13 |
+
import type { CustomApiConfig, PromptOverrides } from '../../../types'
|
| 14 |
+
|
| 15 |
+
const logger = createLogger('AnalysisStep')
|
| 16 |
+
|
| 17 |
+
/**
|
| 18 |
+
* 概念分析结果
|
| 19 |
+
*/
|
| 20 |
+
export interface AnalysisResult {
|
| 21 |
+
analysisType: 'latex' | 'template' | 'ai' | 'fallback'
|
| 22 |
+
manimCode: string | null
|
| 23 |
+
needsAI: boolean
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
/**
|
| 27 |
+
* 代码生成结果
|
| 28 |
+
*/
|
| 29 |
+
export interface GenerationResult {
|
| 30 |
+
manimCode: string
|
| 31 |
+
usedAI: boolean
|
| 32 |
+
generationType: string
|
| 33 |
+
sceneDesign?: string // 新增:保存场景设计方案,用于重试
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
/**
|
| 37 |
+
* 分析概念
|
| 38 |
+
*/
|
| 39 |
+
export async function analyzeConcept(
|
| 40 |
+
jobId: string,
|
| 41 |
+
concept: string,
|
| 42 |
+
_quality: string
|
| 43 |
+
): Promise<AnalysisResult> {
|
| 44 |
+
logger.info('Analyzing concept', { jobId, concept })
|
| 45 |
+
|
| 46 |
+
// 检查是否��� LaTeX
|
| 47 |
+
if (isLikelyLatex(concept)) {
|
| 48 |
+
logger.info('Detected LaTeX', { jobId })
|
| 49 |
+
return {
|
| 50 |
+
analysisType: 'latex',
|
| 51 |
+
manimCode: generateLatexSceneCode(concept),
|
| 52 |
+
needsAI: false
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
// 尝试匹配模板
|
| 57 |
+
const templateResult = selectTemplate(concept)
|
| 58 |
+
if (templateResult) {
|
| 59 |
+
logger.info('Matched template', { jobId, template: templateResult.templateName })
|
| 60 |
+
return {
|
| 61 |
+
analysisType: 'template',
|
| 62 |
+
manimCode: templateResult.code,
|
| 63 |
+
needsAI: false
|
| 64 |
+
}
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
// 需要 AI 生成
|
| 68 |
+
logger.info('Using AI for unique output', { jobId })
|
| 69 |
+
return {
|
| 70 |
+
analysisType: 'ai',
|
| 71 |
+
manimCode: null,
|
| 72 |
+
needsAI: true
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
/**
|
| 77 |
+
* 生成代码
|
| 78 |
+
*/
|
| 79 |
+
export async function generateCode(
|
| 80 |
+
jobId: string,
|
| 81 |
+
concept: string,
|
| 82 |
+
_quality: string,
|
| 83 |
+
analyzeResult: AnalysisResult,
|
| 84 |
+
customApiConfig?: CustomApiConfig,
|
| 85 |
+
promptOverrides?: PromptOverrides
|
| 86 |
+
): Promise<GenerationResult> {
|
| 87 |
+
const { analysisType, manimCode, needsAI } = analyzeResult
|
| 88 |
+
logger.info('Generating code', { jobId, needsAI, analysisType })
|
| 89 |
+
|
| 90 |
+
// 基本可视化代码(fallback)
|
| 91 |
+
const basicVisualizationCode = `from manim import *
|
| 92 |
+
|
| 93 |
+
class MainScene(Scene):
|
| 94 |
+
def construct(self):
|
| 95 |
+
text = Text("Animation for: ${concept}")
|
| 96 |
+
self.play(Write(text))
|
| 97 |
+
self.wait(1)
|
| 98 |
+
`.replace('${concept}', concept)
|
| 99 |
+
|
| 100 |
+
if (needsAI) {
|
| 101 |
+
// 使用两阶段 AI 生成:概念设计者 + 代码生成者
|
| 102 |
+
try {
|
| 103 |
+
logger.info('使用两阶段 AI 生成', { jobId })
|
| 104 |
+
const result = await generateTwoStageAIManimCode(concept, customApiConfig, promptOverrides)
|
| 105 |
+
if (result.code && result.code.length > 0) {
|
| 106 |
+
logger.info('两阶段 AI 代码生成成功', { jobId, length: result.code.length, hasSceneDesign: !!result.sceneDesign })
|
| 107 |
+
// 保存 sceneDesign 用于重试
|
| 108 |
+
return {
|
| 109 |
+
manimCode: result.code,
|
| 110 |
+
usedAI: true,
|
| 111 |
+
generationType: 'two-stage-ai',
|
| 112 |
+
sceneDesign: result.sceneDesign
|
| 113 |
+
}
|
| 114 |
+
}
|
| 115 |
+
} catch (error) {
|
| 116 |
+
logger.warn('AI generation failed, using fallback', { jobId, error: String(error) })
|
| 117 |
+
}
|
| 118 |
+
return { manimCode: basicVisualizationCode, usedAI: false, generationType: 'fallback' }
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
if (manimCode) {
|
| 122 |
+
logger.info('Using pre-generated code', { jobId, length: manimCode.length })
|
| 123 |
+
return { manimCode, usedAI: false, generationType: analysisType }
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
return { manimCode: basicVisualizationCode, usedAI: false, generationType: 'fallback' }
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
/**
|
| 130 |
+
* 分析并生成(合并分析+生成)
|
| 131 |
+
*/
|
| 132 |
+
export async function analyzeAndGenerate(
|
| 133 |
+
jobId: string,
|
| 134 |
+
concept: string,
|
| 135 |
+
quality: string,
|
| 136 |
+
_timings: Record<string, number>,
|
| 137 |
+
customApiConfig?: CustomApiConfig,
|
| 138 |
+
promptOverrides?: PromptOverrides
|
| 139 |
+
): Promise<GenerationResult> {
|
| 140 |
+
const analysisResult = await analyzeConcept(jobId, concept, quality)
|
| 141 |
+
return generateCode(jobId, concept, quality, analysisResult, customApiConfig, promptOverrides)
|
| 142 |
+
}
|
src/queues/processors/steps/render-step.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { storeJobStage } from '../../../services/job-store'
|
|
| 12 |
import { createLogger } from '../../../utils/logger'
|
| 13 |
import { cleanManimCode } from '../../../utils/manim-code-cleaner'
|
| 14 |
import { createRetryContext, executeCodeRetry } from '../../../services/code-retry'
|
| 15 |
-
import type { VideoJobData, VideoConfig } from '../../../types'
|
| 16 |
|
| 17 |
const logger = createLogger('RenderStep')
|
| 18 |
|
|
@@ -67,6 +67,7 @@ export async function renderVideo(
|
|
| 67 |
timings: Record<string, number>,
|
| 68 |
customApiConfig?: any,
|
| 69 |
videoConfig?: VideoConfig,
|
|
|
|
| 70 |
onStageUpdate?: () => Promise<void>
|
| 71 |
): Promise<RenderResult> {
|
| 72 |
const { manimCode, usedAI, generationType, sceneDesign } = codeResult
|
|
@@ -170,7 +171,7 @@ export async function renderVideo(
|
|
| 170 |
const retryStart = Date.now()
|
| 171 |
|
| 172 |
// 创建重试上下文
|
| 173 |
-
const retryContext = createRetryContext(concept, sceneDesign)
|
| 174 |
|
| 175 |
// 执行重试管理器
|
| 176 |
const retryManagerResult = await executeCodeRetry(
|
|
@@ -281,7 +282,7 @@ export async function handlePreGeneratedCode(
|
|
| 281 |
manimCode: preGeneratedCode,
|
| 282 |
usedAI: false,
|
| 283 |
generationType: 'custom-api'
|
| 284 |
-
}, timings, jobData.customApiConfig, jobData.videoConfig)
|
| 285 |
timings.render = Date.now() - renderStart
|
| 286 |
|
| 287 |
// 存储结果
|
|
|
|
| 12 |
import { createLogger } from '../../../utils/logger'
|
| 13 |
import { cleanManimCode } from '../../../utils/manim-code-cleaner'
|
| 14 |
import { createRetryContext, executeCodeRetry } from '../../../services/code-retry'
|
| 15 |
+
import type { PromptOverrides, VideoJobData, VideoConfig } from '../../../types'
|
| 16 |
|
| 17 |
const logger = createLogger('RenderStep')
|
| 18 |
|
|
|
|
| 67 |
timings: Record<string, number>,
|
| 68 |
customApiConfig?: any,
|
| 69 |
videoConfig?: VideoConfig,
|
| 70 |
+
promptOverrides?: PromptOverrides,
|
| 71 |
onStageUpdate?: () => Promise<void>
|
| 72 |
): Promise<RenderResult> {
|
| 73 |
const { manimCode, usedAI, generationType, sceneDesign } = codeResult
|
|
|
|
| 171 |
const retryStart = Date.now()
|
| 172 |
|
| 173 |
// 创建重试上下文
|
| 174 |
+
const retryContext = createRetryContext(concept, sceneDesign, promptOverrides)
|
| 175 |
|
| 176 |
// 执行重试管理器
|
| 177 |
const retryManagerResult = await executeCodeRetry(
|
|
|
|
| 282 |
manimCode: preGeneratedCode,
|
| 283 |
usedAI: false,
|
| 284 |
generationType: 'custom-api'
|
| 285 |
+
}, timings, jobData.customApiConfig, jobData.videoConfig, jobData.promptOverrides)
|
| 286 |
timings.render = Date.now() - renderStart
|
| 287 |
|
| 288 |
// 存储结果
|
src/queues/processors/steps/storage-step.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
| 4 |
*/
|
| 5 |
|
| 6 |
import { storeJobResult } from '../../../services/job-store'
|
|
|
|
| 7 |
import { cacheResult } from './cache-step'
|
| 8 |
import type { RenderResult } from './render-step'
|
| 9 |
import { createLogger } from '../../../utils/logger'
|
|
@@ -31,6 +32,7 @@ export async function storeResult(
|
|
| 31 |
renderPeakMemoryMB
|
| 32 |
}
|
| 33 |
})
|
|
|
|
| 34 |
logger.info('Result stored', { jobId, videoUrl })
|
| 35 |
|
| 36 |
// 缓存结果(如果启用)
|
|
|
|
| 4 |
*/
|
| 5 |
|
| 6 |
import { storeJobResult } from '../../../services/job-store'
|
| 7 |
+
import { clearJobCancelled } from '../../../services/job-cancel-store'
|
| 8 |
import { cacheResult } from './cache-step'
|
| 9 |
import type { RenderResult } from './render-step'
|
| 10 |
import { createLogger } from '../../../utils/logger'
|
|
|
|
| 32 |
renderPeakMemoryMB
|
| 33 |
}
|
| 34 |
})
|
| 35 |
+
await clearJobCancelled(jobId)
|
| 36 |
logger.info('Result stored', { jobId, videoUrl })
|
| 37 |
|
| 38 |
// 缓存结果(如果启用)
|
src/queues/processors/video.processor.ts
CHANGED
|
@@ -1,103 +1,115 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* Video Processor
|
| 3 |
-
* 任务处理器 - 主编排器
|
| 4 |
-
*
|
| 5 |
-
* 职责:任务流程编排,异常处理,计时统计
|
| 6 |
-
*/
|
| 7 |
-
|
| 8 |
-
import { videoQueue } from '../../config/bull'
|
| 9 |
-
import { storeJobResult } from '../../services/job-store'
|
| 10 |
-
import {
|
| 11 |
-
import
|
| 12 |
-
|
| 13 |
-
//
|
| 14 |
-
import {
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
import {
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
// Step
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
//
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Video Processor
|
| 3 |
+
* 任务处理器 - 主编排器
|
| 4 |
+
*
|
| 5 |
+
* 职责:任务流程编排,异常处理,计时统计
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
import { videoQueue } from '../../config/bull'
|
| 9 |
+
import { storeJobResult } from '../../services/job-store'
|
| 10 |
+
import { ensureJobNotCancelled } from '../../services/job-cancel'
|
| 11 |
+
import { clearJobCancelled } from '../../services/job-cancel-store'
|
| 12 |
+
import { JobCancelledError } from '../../utils/errors'
|
| 13 |
+
import { createLogger } from '../../utils/logger'
|
| 14 |
+
import type { VideoJobData } from '../../types'
|
| 15 |
+
|
| 16 |
+
// 导入步骤模块
|
| 17 |
+
import { checkCache, handleCacheHit } from './steps/cache-step'
|
| 18 |
+
import { analyzeAndGenerate } from './steps/analysis-step'
|
| 19 |
+
import { renderVideo, handlePreGeneratedCode } from './steps/render-step'
|
| 20 |
+
import { storeResult } from './steps/storage-step'
|
| 21 |
+
|
| 22 |
+
const logger = createLogger('VideoProcessor')
|
| 23 |
+
|
| 24 |
+
/**
|
| 25 |
+
* 任务处理器主函数
|
| 26 |
+
*/
|
| 27 |
+
videoQueue.process(async (job) => {
|
| 28 |
+
const data = job.data as VideoJobData
|
| 29 |
+
const { jobId, concept, quality, forceRefresh = false, preGeneratedCode, promptOverrides } = data
|
| 30 |
+
|
| 31 |
+
logger.info('Processing video job', { jobId, concept, quality, hasPreGeneratedCode: !!preGeneratedCode })
|
| 32 |
+
|
| 33 |
+
// 阶段时长追踪
|
| 34 |
+
const timings: Record<string, number> = {}
|
| 35 |
+
|
| 36 |
+
try {
|
| 37 |
+
await ensureJobNotCancelled(jobId, job)
|
| 38 |
+
// 如果有预生成代码,跳过缓存和 AI 生成阶段,直接渲染
|
| 39 |
+
if (preGeneratedCode) {
|
| 40 |
+
await ensureJobNotCancelled(jobId, job)
|
| 41 |
+
return await handlePreGeneratedCode(jobId, concept, quality, preGeneratedCode, timings, data)
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
// Step 1: 检查缓存
|
| 45 |
+
await ensureJobNotCancelled(jobId, job)
|
| 46 |
+
await storeJobStage(jobId, 'analyzing')
|
| 47 |
+
const cacheStart = Date.now()
|
| 48 |
+
const cacheResult = await checkCache(jobId, concept, quality, forceRefresh, timings)
|
| 49 |
+
timings.cache = Date.now() - cacheStart
|
| 50 |
+
|
| 51 |
+
if (cacheResult.hit) {
|
| 52 |
+
// 缓存命中 - 直接处理缓存结果
|
| 53 |
+
await handleCacheHit(jobId, concept, quality, cacheResult.data!, timings)
|
| 54 |
+
logger.info('Job completed (cache hit)', { jobId, timings })
|
| 55 |
+
return { success: true, source: 'cache', timings }
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
// Step 2 & 3: 分析概念并生成代码
|
| 59 |
+
await ensureJobNotCancelled(jobId, job)
|
| 60 |
+
await storeJobStage(jobId, 'generating')
|
| 61 |
+
const analyzeStart = Date.now()
|
| 62 |
+
const codeResult = await analyzeAndGenerate(jobId, concept, quality, timings, data.customApiConfig, promptOverrides)
|
| 63 |
+
timings.analyze = Date.now() - analyzeStart
|
| 64 |
+
|
| 65 |
+
// Step 4: 渲染视频
|
| 66 |
+
await ensureJobNotCancelled(jobId, job)
|
| 67 |
+
const renderStart = Date.now()
|
| 68 |
+
const renderResult = await renderVideo(
|
| 69 |
+
jobId,
|
| 70 |
+
concept,
|
| 71 |
+
quality,
|
| 72 |
+
codeResult,
|
| 73 |
+
timings,
|
| 74 |
+
data.customApiConfig,
|
| 75 |
+
data.videoConfig,
|
| 76 |
+
promptOverrides,
|
| 77 |
+
() => storeJobStage(jobId, 'rendering')
|
| 78 |
+
)
|
| 79 |
+
timings.render = Date.now() - renderStart
|
| 80 |
+
|
| 81 |
+
// Step 5: 存储结果
|
| 82 |
+
await ensureJobNotCancelled(jobId, job)
|
| 83 |
+
const storeStart = Date.now()
|
| 84 |
+
await storeResult(renderResult, timings)
|
| 85 |
+
timings.store = Date.now() - storeStart
|
| 86 |
+
|
| 87 |
+
// 总时长
|
| 88 |
+
timings.total = timings.cache + timings.analyze + timings.render + timings.store
|
| 89 |
+
|
| 90 |
+
logger.info('Job completed', { jobId, source: 'generation', timings })
|
| 91 |
+
|
| 92 |
+
return { success: true, source: 'generation', timings }
|
| 93 |
+
} catch (error) {
|
| 94 |
+
const errorMessage = error instanceof Error ? error.message : String(error)
|
| 95 |
+
const cancelReason = error instanceof JobCancelledError ? error.details : undefined
|
| 96 |
+
logger.error('Job failed', { jobId, error: errorMessage, timings })
|
| 97 |
+
|
| 98 |
+
// 存储失败结果
|
| 99 |
+
await storeJobResult(jobId, {
|
| 100 |
+
status: 'failed',
|
| 101 |
+
data: { error: errorMessage, cancelReason }
|
| 102 |
+
})
|
| 103 |
+
await clearJobCancelled(jobId)
|
| 104 |
+
|
| 105 |
+
throw error
|
| 106 |
+
}
|
| 107 |
+
})
|
| 108 |
+
|
| 109 |
+
/**
|
| 110 |
+
* 存储任务阶段(辅助函数)
|
| 111 |
+
*/
|
| 112 |
+
async function storeJobStage(jobId: string, stage: string): Promise<void> {
|
| 113 |
+
const { storeJobStage: storeStage } = await import('../../services/job-store')
|
| 114 |
+
await storeStage(jobId, stage as any)
|
| 115 |
+
}
|
src/routes/generate.route.ts
CHANGED
|
@@ -1,135 +1,185 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* 生成路由
|
| 3 |
-
* POST /api/generate - 创建视频生成任务
|
| 4 |
-
*
|
| 5 |
-
* 迁移自 src/api/generate.step.ts
|
| 6 |
-
* 改动点:
|
| 7 |
* - 使用 Express Router
|
| 8 |
-
* - emit() 改为 videoQueue.add()
|
| 9 |
-
* - Zod 验证保持不变
|
| 10 |
-
* - 有预生成代码时不使用认证(前端已通过自定义 API 认证)
|
| 11 |
-
*/
|
| 12 |
-
|
| 13 |
-
import express from 'express'
|
| 14 |
-
import { z } from 'zod'
|
| 15 |
-
import { v4 as uuidv4 } from 'uuid'
|
| 16 |
-
import { videoQueue } from '../config/bull'
|
| 17 |
-
import { storeJobStage } from '../services/job-store'
|
| 18 |
-
import { createLogger } from '../utils/logger'
|
| 19 |
-
import { ValidationError } from '../utils/errors'
|
| 20 |
-
import { asyncHandler } from '../middlewares/error-handler'
|
| 21 |
-
import { authMiddleware } from '../middlewares/auth.middleware'
|
| 22 |
-
import type { GenerateRequest, GenerateResponse, VideoConfig } from '../types'
|
| 23 |
-
|
| 24 |
-
const router = express.Router()
|
| 25 |
-
const logger = createLogger('GenerateRoute')
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
}
|
| 128 |
-
|
| 129 |
-
/
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* 生成路由
|
| 3 |
+
* POST /api/generate - 创建视频生成任务
|
| 4 |
+
*
|
| 5 |
+
* 迁移自 src/api/generate.step.ts
|
| 6 |
+
* 改动点:
|
| 7 |
* - 使用 Express Router
|
| 8 |
+
* - emit() 改为 videoQueue.add()
|
| 9 |
+
* - Zod 验证保持不变
|
| 10 |
+
* - 有预生成代码时不使用认证(前端已通过自定义 API 认证)
|
| 11 |
+
*/
|
| 12 |
+
|
| 13 |
+
import express from 'express'
|
| 14 |
+
import { z } from 'zod'
|
| 15 |
+
import { v4 as uuidv4 } from 'uuid'
|
| 16 |
+
import { videoQueue } from '../config/bull'
|
| 17 |
+
import { storeJobStage } from '../services/job-store'
|
| 18 |
+
import { createLogger } from '../utils/logger'
|
| 19 |
+
import { AuthenticationError, ValidationError } from '../utils/errors'
|
| 20 |
+
import { asyncHandler } from '../middlewares/error-handler'
|
| 21 |
+
import { authMiddleware } from '../middlewares/auth.middleware'
|
| 22 |
+
import type { GenerateRequest, GenerateResponse, VideoConfig } from '../types'
|
| 23 |
+
|
| 24 |
+
const router = express.Router()
|
| 25 |
+
const logger = createLogger('GenerateRoute')
|
| 26 |
+
|
| 27 |
+
function extractToken(authHeader: string | string[] | undefined): string {
|
| 28 |
+
if (!authHeader) return ''
|
| 29 |
+
|
| 30 |
+
if (typeof authHeader === 'string') {
|
| 31 |
+
return authHeader.replace(/^Bearer\s+/i, '')
|
| 32 |
+
}
|
| 33 |
+
if (Array.isArray(authHeader)) {
|
| 34 |
+
return authHeader[0]?.replace(/^Bearer\s+/i, '') || ''
|
| 35 |
+
}
|
| 36 |
+
return ''
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
function hasPromptOverrides(promptOverrides: any): boolean {
|
| 40 |
+
if (!promptOverrides) return false
|
| 41 |
+
const system = promptOverrides.system || {}
|
| 42 |
+
const user = promptOverrides.user || {}
|
| 43 |
+
return Object.values(system).some((value) => typeof value === 'string' && value.trim().length > 0) ||
|
| 44 |
+
Object.values(user).some((value) => typeof value === 'string' && value.trim().length > 0)
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
function requirePromptOverrideAuth(req: express.Request): void {
|
| 48 |
+
const manimcatApiKey = process.env.MANIMCAT_API_KEY
|
| 49 |
+
if (!manimcatApiKey) {
|
| 50 |
+
throw new AuthenticationError('Prompt overrides require MANIMCAT_API_KEY to be set.')
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
const token = extractToken(req.headers?.authorization)
|
| 54 |
+
if (!token || token !== manimcatApiKey) {
|
| 55 |
+
throw new AuthenticationError('Prompt overrides require a valid MANIMCAT_API_KEY token.')
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
// 请求体 schema(与原有保持一致)
|
| 60 |
+
const bodySchema = z.object({
|
| 61 |
+
concept: z.string().min(1, '概念必填'),
|
| 62 |
+
quality: z.enum(['low', 'medium', 'high']).optional().default('low'),
|
| 63 |
+
forceRefresh: z.boolean().optional().default(false),
|
| 64 |
+
/** 预生成的代码(使用自定义 AI 时) */
|
| 65 |
+
code: z.string().optional(),
|
| 66 |
+
/** 自定义 API 配置(用于代码修复) */
|
| 67 |
+
customApiConfig: z.object({
|
| 68 |
+
apiUrl: z.string(),
|
| 69 |
+
apiKey: z.string(),
|
| 70 |
+
model: z.string()
|
| 71 |
+
}).optional(),
|
| 72 |
+
promptOverrides: z.object({
|
| 73 |
+
system: z.object({
|
| 74 |
+
conceptDesigner: z.string().max(20000).optional(),
|
| 75 |
+
codeGeneration: z.string().max(20000).optional(),
|
| 76 |
+
codeRetry: z.string().max(20000).optional()
|
| 77 |
+
}).optional(),
|
| 78 |
+
user: z.object({
|
| 79 |
+
conceptDesigner: z.string().max(20000).optional(),
|
| 80 |
+
codeGeneration: z.string().max(20000).optional(),
|
| 81 |
+
codeRetryInitial: z.string().max(20000).optional(),
|
| 82 |
+
codeRetryFix: z.string().max(20000).optional()
|
| 83 |
+
}).optional()
|
| 84 |
+
}).optional(),
|
| 85 |
+
/** 视频配置 */
|
| 86 |
+
videoConfig: z.object({
|
| 87 |
+
quality: z.enum(['low', 'medium', 'high']).optional(),
|
| 88 |
+
frameRate: z.number().int().min(1).max(120).optional(),
|
| 89 |
+
timeout: z.number().optional()
|
| 90 |
+
}).optional()
|
| 91 |
+
})
|
| 92 |
+
|
| 93 |
+
/**
|
| 94 |
+
* 处理视频生成请求的核心逻辑
|
| 95 |
+
*/
|
| 96 |
+
async function handleGenerateRequest(req: express.Request, res: express.Response) {
|
| 97 |
+
let parsed;
|
| 98 |
+
try {
|
| 99 |
+
parsed = bodySchema.parse(req.body);
|
| 100 |
+
} catch (error: any) {
|
| 101 |
+
throw error;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
const { concept, quality, forceRefresh, code, customApiConfig, promptOverrides, videoConfig } = parsed;
|
| 105 |
+
|
| 106 |
+
// 清理输入
|
| 107 |
+
if (hasPromptOverrides(promptOverrides)) {
|
| 108 |
+
requirePromptOverrideAuth(req)
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
const sanitizedConcept = concept.trim().replace(/\s+/g, ' ')
|
| 112 |
+
|
| 113 |
+
if (sanitizedConcept.length === 0) {
|
| 114 |
+
throw new ValidationError('提供的概念为空', { concept })
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
// 生成唯一的任务 ID
|
| 118 |
+
const jobId = uuidv4()
|
| 119 |
+
|
| 120 |
+
logger.info('收到动画生成请求', {
|
| 121 |
+
jobId,
|
| 122 |
+
concept: sanitizedConcept,
|
| 123 |
+
quality,
|
| 124 |
+
forceRefresh,
|
| 125 |
+
hasPreGeneratedCode: !!code,
|
| 126 |
+
videoConfig
|
| 127 |
+
})
|
| 128 |
+
|
| 129 |
+
// 设置初始阶段
|
| 130 |
+
await storeJobStage(jobId, code ? 'rendering' : 'analyzing')
|
| 131 |
+
|
| 132 |
+
// 添加任务到 Bull 队列
|
| 133 |
+
await videoQueue.add(
|
| 134 |
+
{
|
| 135 |
+
jobId,
|
| 136 |
+
concept: sanitizedConcept,
|
| 137 |
+
quality,
|
| 138 |
+
forceRefresh,
|
| 139 |
+
preGeneratedCode: code,
|
| 140 |
+
customApiConfig,
|
| 141 |
+
promptOverrides,
|
| 142 |
+
videoConfig,
|
| 143 |
+
timestamp: new Date().toISOString()
|
| 144 |
+
},
|
| 145 |
+
{
|
| 146 |
+
jobId
|
| 147 |
+
}
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
logger.info('动画请求已加入队列', { jobId })
|
| 151 |
+
|
| 152 |
+
const response: GenerateResponse = {
|
| 153 |
+
success: true,
|
| 154 |
+
jobId,
|
| 155 |
+
message: code ? '视频渲染已开始' : '动画生成已开始',
|
| 156 |
+
status: 'processing'
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
res.status(202).json(response)
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
/**
|
| 163 |
+
* 条件认证中间件
|
| 164 |
+
* 如果请求包含预生成代码,跳过认证
|
| 165 |
+
*/
|
| 166 |
+
function optionalAuthMiddleware(
|
| 167 |
+
req: express.Request,
|
| 168 |
+
res: express.Response,
|
| 169 |
+
next: express.NextFunction
|
| 170 |
+
) {
|
| 171 |
+
// 如果有预生成代码,跳过认证(因为 AI 调用已在前端完成)
|
| 172 |
+
if (req.body?.code) {
|
| 173 |
+
return next()
|
| 174 |
+
}
|
| 175 |
+
// 否则使用完整认证
|
| 176 |
+
return authMiddleware(req, res, next)
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
/**
|
| 180 |
+
* POST /api/generate
|
| 181 |
+
* 提交视频生成任务
|
| 182 |
+
*/
|
| 183 |
+
router.post('/generate', optionalAuthMiddleware, asyncHandler(handleGenerateRequest))
|
| 184 |
+
|
| 185 |
+
export default router
|
src/routes/index.ts
CHANGED
|
@@ -1,24 +1,28 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* Routes Index
|
| 3 |
-
* 路由总入口
|
| 4 |
-
* - 统一挂载所有路由
|
| 5 |
-
* - API 版本控制
|
| 6 |
-
*/
|
| 7 |
-
|
| 8 |
-
import express from 'express'
|
| 9 |
-
import generateRouter from './generate.route'
|
| 10 |
-
import jobStatusRouter from './job-status.route'
|
| 11 |
-
import
|
| 12 |
-
import
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
router.use('/api
|
| 23 |
-
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Routes Index
|
| 3 |
+
* 路由总入口
|
| 4 |
+
* - 统一挂载所有路由
|
| 5 |
+
* - API 版本控制
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
import express from 'express'
|
| 9 |
+
import generateRouter from './generate.route'
|
| 10 |
+
import jobStatusRouter from './job-status.route'
|
| 11 |
+
import jobCancelRouter from './job-cancel.route'
|
| 12 |
+
import promptsRouter from './prompts.route'
|
| 13 |
+
import healthRouter from './health.route'
|
| 14 |
+
import metricsRouter from './metrics.route'
|
| 15 |
+
|
| 16 |
+
const router = express.Router()
|
| 17 |
+
|
| 18 |
+
// 挂载健康检查路由(不使用 /api 前缀)
|
| 19 |
+
router.use(healthRouter)
|
| 20 |
+
|
| 21 |
+
// 挂载 API 路由(使用 /api 前缀)
|
| 22 |
+
router.use('/api', generateRouter)
|
| 23 |
+
router.use('/api', jobStatusRouter)
|
| 24 |
+
router.use('/api', jobCancelRouter)
|
| 25 |
+
router.use('/api', promptsRouter)
|
| 26 |
+
router.use('/api/metrics', metricsRouter)
|
| 27 |
+
|
| 28 |
+
export default router
|
src/routes/job-cancel.route.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Job Cancel Route
|
| 3 |
+
* POST /api/jobs/:jobId/cancel
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
import express, { type Request, type Response } from 'express'
|
| 7 |
+
import { asyncHandler } from '../middlewares/error-handler'
|
| 8 |
+
import { cancelJob } from '../services/job-cancel'
|
| 9 |
+
import { ValidationError } from '../utils/errors'
|
| 10 |
+
|
| 11 |
+
const router = express.Router()
|
| 12 |
+
|
| 13 |
+
router.post(
|
| 14 |
+
'/jobs/:jobId/cancel',
|
| 15 |
+
asyncHandler(async (req: Request, res: Response) => {
|
| 16 |
+
const { jobId } = req.params
|
| 17 |
+
|
| 18 |
+
if (!jobId) {
|
| 19 |
+
throw new ValidationError('Missing jobId')
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
const result = await cancelJob(jobId)
|
| 23 |
+
const status = result.jobState == 'completed' ? 'completed' : 'cancelled'
|
| 24 |
+
const message = status == 'completed' ? 'Job already completed' : 'Job cancelled'
|
| 25 |
+
|
| 26 |
+
res.status(200).json({
|
| 27 |
+
success: true,
|
| 28 |
+
jobId,
|
| 29 |
+
status,
|
| 30 |
+
jobState: result.jobState,
|
| 31 |
+
message
|
| 32 |
+
})
|
| 33 |
+
})
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
export default router
|
src/routes/job-status.route.ts
CHANGED
|
@@ -103,7 +103,8 @@ router.get(
|
|
| 103 |
status: 'failed' as const,
|
| 104 |
success: false as const,
|
| 105 |
error: result.data.error,
|
| 106 |
-
details: result.data.details
|
|
|
|
| 107 |
})
|
| 108 |
})
|
| 109 |
)
|
|
|
|
| 103 |
status: 'failed' as const,
|
| 104 |
success: false as const,
|
| 105 |
error: result.data.error,
|
| 106 |
+
details: result.data.details,
|
| 107 |
+
cancel_reason: result.data.cancelReason
|
| 108 |
})
|
| 109 |
})
|
| 110 |
)
|
src/routes/prompts.route.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import express from 'express'
|
| 2 |
+
|
| 3 |
+
import { SYSTEM_PROMPTS, generateConceptDesignerPrompt, generateCodeGenerationPrompt } from '../prompts'
|
| 4 |
+
import { CODE_RETRY_SYSTEM_PROMPT, buildInitialCodePrompt } from '../services/code-retry/prompts'
|
| 5 |
+
import { buildRetryFixPrompt } from '../services/code-retry/manager'
|
| 6 |
+
import type { PromptOverrides } from '../types'
|
| 7 |
+
|
| 8 |
+
const router = express.Router()
|
| 9 |
+
|
| 10 |
+
const PLACEHOLDERS = {
|
| 11 |
+
concept: '{{concept}}',
|
| 12 |
+
seed: '{{seed}}',
|
| 13 |
+
sceneDesign: '{{sceneDesign}}',
|
| 14 |
+
errorMessage: '{{errorMessage}}',
|
| 15 |
+
attempt: '{{attempt}}'
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
function buildDefaultPromptTemplates(): PromptOverrides {
|
| 19 |
+
return {
|
| 20 |
+
system: {
|
| 21 |
+
conceptDesigner: SYSTEM_PROMPTS.conceptDesigner,
|
| 22 |
+
codeGeneration: SYSTEM_PROMPTS.codeGeneration,
|
| 23 |
+
codeRetry: CODE_RETRY_SYSTEM_PROMPT
|
| 24 |
+
},
|
| 25 |
+
user: {
|
| 26 |
+
conceptDesigner: generateConceptDesignerPrompt(PLACEHOLDERS.concept, PLACEHOLDERS.seed),
|
| 27 |
+
codeGeneration: generateCodeGenerationPrompt(PLACEHOLDERS.concept, PLACEHOLDERS.seed, PLACEHOLDERS.sceneDesign),
|
| 28 |
+
codeRetryInitial: buildInitialCodePrompt(PLACEHOLDERS.concept, PLACEHOLDERS.seed, PLACEHOLDERS.sceneDesign),
|
| 29 |
+
codeRetryFix: buildRetryFixPrompt(PLACEHOLDERS.concept, PLACEHOLDERS.errorMessage, PLACEHOLDERS.attempt)
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
router.get('/prompts/defaults', (_req, res) => {
|
| 35 |
+
res.json(buildDefaultPromptTemplates())
|
| 36 |
+
})
|
| 37 |
+
|
| 38 |
+
export default router
|
src/server.ts
CHANGED
|
@@ -1,235 +1,238 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* Express Application Entry Point
|
| 3 |
-
* Express 应用主入口
|
| 4 |
-
*/
|
| 5 |
-
|
| 6 |
-
import 'dotenv/config'
|
| 7 |
-
import express, { type Request, Response, type NextFunction } from 'express'
|
| 8 |
-
import { appConfig, validateConfig, printConfig, isDevelopment } from './config/app'
|
| 9 |
-
import { redisClient } from './config/redis'
|
| 10 |
-
import { closeQueue } from './config/bull'
|
| 11 |
-
import { corsMiddleware } from './middlewares/cors'
|
| 12 |
-
import { errorHandler, notFoundHandler } from './middlewares/error-handler'
|
| 13 |
-
import { logger, createLogger } from './utils/logger'
|
| 14 |
-
import routes from './routes'
|
| 15 |
-
import type { Server } from 'http'
|
| 16 |
-
import path from 'path'
|
| 17 |
-
|
| 18 |
-
// 导入队列处理器以启动 worker
|
| 19 |
-
import './queues/processors/video.processor'
|
| 20 |
-
|
| 21 |
-
const app = express()
|
| 22 |
-
const appLogger = createLogger('Server')
|
| 23 |
-
|
| 24 |
-
let server: Server | null = null
|
| 25 |
-
|
| 26 |
-
/**
|
| 27 |
-
* 请求日志中间件
|
| 28 |
-
*/
|
| 29 |
-
function requestLogger(req: Request, res: Response, next: NextFunction): void {
|
| 30 |
-
const start = Date.now()
|
| 31 |
-
|
| 32 |
-
res.on('finish', () => {
|
| 33 |
-
const duration = Date.now() - start
|
| 34 |
-
// 只记录非查询状态的请求
|
| 35 |
-
if (!req.path.includes('/jobs/')) {
|
| 36 |
-
appLogger.info('Request completed', {
|
| 37 |
-
method: req.method,
|
| 38 |
-
path: req.path,
|
| 39 |
-
status: res.statusCode,
|
| 40 |
-
duration: `${duration}ms`
|
| 41 |
-
})
|
| 42 |
-
}
|
| 43 |
-
})
|
| 44 |
-
|
| 45 |
-
next()
|
| 46 |
-
}
|
| 47 |
-
|
| 48 |
-
/**
|
| 49 |
-
* 初始化应用
|
| 50 |
-
*/
|
| 51 |
-
async function initializeApp(): Promise<void> {
|
| 52 |
-
try {
|
| 53 |
-
// 验证配置
|
| 54 |
-
validateConfig()
|
| 55 |
-
|
| 56 |
-
// 基础中间件
|
| 57 |
-
app.use(express.json({ limit: '10mb' }))
|
| 58 |
-
app.use(express.urlencoded({ extended: true, limit: '10mb' }))
|
| 59 |
-
app.use(corsMiddleware)
|
| 60 |
-
|
| 61 |
-
// JSON 解析错误处理
|
| 62 |
-
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
| 63 |
-
if (err instanceof SyntaxError && 'body' in err) {
|
| 64 |
-
appLogger.error('JSON 解析错误', {
|
| 65 |
-
method: req.method,
|
| 66 |
-
path: req.path,
|
| 67 |
-
error: err.message,
|
| 68 |
-
body: req.body
|
| 69 |
-
})
|
| 70 |
-
return res.status(400).json({
|
| 71 |
-
error: 'Invalid JSON',
|
| 72 |
-
message: err.message
|
| 73 |
-
})
|
| 74 |
-
}
|
| 75 |
-
next(err)
|
| 76 |
-
})
|
| 77 |
-
|
| 78 |
-
// 请求日志(开发环境)
|
| 79 |
-
if (isDevelopment()) {
|
| 80 |
-
app.use(requestLogger)
|
| 81 |
-
}
|
| 82 |
-
|
| 83 |
-
// 静态文件服务
|
| 84 |
-
app.use(express.static('public'))
|
| 85 |
-
|
| 86 |
-
// 挂载所有路由(包括健康检查和 API 路由)
|
| 87 |
-
app.use(routes)
|
| 88 |
-
|
| 89 |
-
// SPA fallback:任何非 API 请求都返回 React 的 index.html
|
| 90 |
-
app.get('*', (req, res) => {
|
| 91 |
-
// 跳过健康检查和 API 路由
|
| 92 |
-
if (req.path.startsWith('/health') || req.path.startsWith('/api')) {
|
| 93 |
-
return notFoundHandler(req, res, () => {})
|
| 94 |
-
}
|
| 95 |
-
// 返回 React 前端的 index.html
|
| 96 |
-
const indexPath = path.join(__dirname, '..', 'public', 'index.html')
|
| 97 |
-
res.sendFile(indexPath, (err) => {
|
| 98 |
-
if (err) {
|
| 99 |
-
return notFoundHandler(req, res, () => {})
|
| 100 |
-
}
|
| 101 |
-
})
|
| 102 |
-
})
|
| 103 |
-
|
| 104 |
-
// 全局错误处理
|
| 105 |
-
app.use(errorHandler)
|
| 106 |
-
|
| 107 |
-
// 打印配置信息
|
| 108 |
-
printConfig()
|
| 109 |
-
|
| 110 |
-
appLogger.info('Express application initialized successfully')
|
| 111 |
-
} catch (error) {
|
| 112 |
-
appLogger.error('Failed to initialize application', { error })
|
| 113 |
-
throw error
|
| 114 |
-
}
|
| 115 |
-
}
|
| 116 |
-
|
| 117 |
-
/**
|
| 118 |
-
* 尝试在指定端口启动服务器
|
| 119 |
-
*/
|
| 120 |
-
function tryListen(port: number, host: string, retries = 3): Promise<void> {
|
| 121 |
-
return new Promise((resolve, reject) => {
|
| 122 |
-
const attemptListen = (attemptNumber: number) => {
|
| 123 |
-
server = app.listen(port, host)
|
| 124 |
-
.on('listening', () => {
|
| 125 |
-
appLogger.info(`🚀 Server listening on http://${host}:${port}`)
|
| 126 |
-
appLogger.info(`📝 Environment: ${appConfig.nodeEnv}`)
|
| 127 |
-
appLogger.info(`🔍 Health check: http://${host}:${port}/health`)
|
| 128 |
-
resolve()
|
| 129 |
-
})
|
| 130 |
-
.on('error', (error: NodeJS.ErrnoException) => {
|
| 131 |
-
if (error.code === 'EADDRINUSE') {
|
| 132 |
-
appLogger.warn(`Port ${port} is in use, attempt ${attemptNumber}/${retries}`)
|
| 133 |
-
|
| 134 |
-
if (attemptNumber < retries) {
|
| 135 |
-
// 等待一段时间后重试
|
| 136 |
-
setTimeout(() => {
|
| 137 |
-
attemptListen(attemptNumber + 1)
|
| 138 |
-
}, 1000 * attemptNumber) // 递增等待时间
|
| 139 |
-
} else {
|
| 140 |
-
appLogger.error(`Failed to bind to port ${port} after ${retries} attempts`)
|
| 141 |
-
reject(new Error(`Port ${port} is already in use. Please stop the existing process or use a different port.`))
|
| 142 |
-
}
|
| 143 |
-
} else {
|
| 144 |
-
appLogger.error('Server error', { error })
|
| 145 |
-
reject(error)
|
| 146 |
-
}
|
| 147 |
-
})
|
| 148 |
-
}
|
| 149 |
-
|
| 150 |
-
attemptListen(1)
|
| 151 |
-
})
|
| 152 |
-
}
|
| 153 |
-
|
| 154 |
-
/**
|
| 155 |
-
* 启动服务器
|
| 156 |
-
*/
|
| 157 |
-
async function startServer(): Promise<void> {
|
| 158 |
-
await initializeApp()
|
| 159 |
-
await tryListen(appConfig.port, appConfig.host)
|
| 160 |
-
setupShutdownHandlers()
|
| 161 |
-
}
|
| 162 |
-
|
| 163 |
-
/**
|
| 164 |
-
* 设置优雅关闭处理器
|
| 165 |
-
*/
|
| 166 |
-
function setupShutdownHandlers(): void {
|
| 167 |
-
// 优雅关闭处理
|
| 168 |
-
const shutdown = async (signal: string): Promise<void> => {
|
| 169 |
-
appLogger.info(`Received ${signal}, starting graceful shutdown...`)
|
| 170 |
-
|
| 171 |
-
if (!server) {
|
| 172 |
-
appLogger.warn('Server instance not found, skipping server close')
|
| 173 |
-
await cleanupResources()
|
| 174 |
-
process.exit(0)
|
| 175 |
-
return
|
| 176 |
-
}
|
| 177 |
-
|
| 178 |
-
// 停止接收新连接
|
| 179 |
-
server.close(async (err) => {
|
| 180 |
-
if (err) {
|
| 181 |
-
appLogger.error('Error closing server', { error: err })
|
| 182 |
-
process.exit(1)
|
| 183 |
-
}
|
| 184 |
-
|
| 185 |
-
await cleanupResources()
|
| 186 |
-
})
|
| 187 |
-
|
| 188 |
-
// 强制退出超时
|
| 189 |
-
setTimeout(() => {
|
| 190 |
-
appLogger.warn('Forced shutdown after timeout')
|
| 191 |
-
process.exit(1)
|
| 192 |
-
},
|
| 193 |
-
}
|
| 194 |
-
|
| 195 |
-
// 清理资源
|
| 196 |
-
const cleanupResources = async (): Promise<void> => {
|
| 197 |
-
try {
|
| 198 |
-
// 关闭队列
|
| 199 |
-
await closeQueue()
|
| 200 |
-
|
| 201 |
-
// 关闭 Redis 连接
|
| 202 |
-
await redisClient.quit()
|
| 203 |
-
|
| 204 |
-
appLogger.info('Graceful shutdown completed')
|
| 205 |
-
process.exit(0)
|
| 206 |
-
} catch (error) {
|
| 207 |
-
appLogger.error('Error during shutdown', { error })
|
| 208 |
-
process.exit(1)
|
| 209 |
-
}
|
| 210 |
-
}
|
| 211 |
-
|
| 212 |
-
// 监听退出信号
|
| 213 |
-
process.on('SIGTERM', () => shutdown('SIGTERM'))
|
| 214 |
-
process.on('SIGINT', () => shutdown('SIGINT'))
|
| 215 |
-
|
| 216 |
-
// 未捕获异常处理
|
| 217 |
-
process.on('uncaughtException', (error) => {
|
| 218 |
-
appLogger.error('Uncaught exception', { error })
|
| 219 |
-
shutdown('UNCAUGHT_EXCEPTION')
|
| 220 |
-
})
|
| 221 |
-
|
| 222 |
-
process.on('unhandledRejection', (reason, promise) => {
|
| 223 |
-
appLogger.error('Unhandled rejection', { reason, promise })
|
| 224 |
-
shutdown('UNHANDLED_REJECTION')
|
| 225 |
-
})
|
| 226 |
-
}
|
| 227 |
-
|
| 228 |
-
// 启动应用
|
| 229 |
-
startServer().catch((error) => {
|
| 230 |
-
appLogger.error('Failed to start server', { error })
|
| 231 |
-
process.exit(1)
|
| 232 |
-
})
|
| 233 |
-
|
| 234 |
-
// 导出 app 用于测试
|
| 235 |
-
export default app
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Express Application Entry Point
|
| 3 |
+
* Express 应用主入口
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
import 'dotenv/config'
|
| 7 |
+
import express, { type Request, Response, type NextFunction } from 'express'
|
| 8 |
+
import { appConfig, validateConfig, printConfig, isDevelopment } from './config/app'
|
| 9 |
+
import { redisClient } from './config/redis'
|
| 10 |
+
import { closeQueue } from './config/bull'
|
| 11 |
+
import { corsMiddleware } from './middlewares/cors'
|
| 12 |
+
import { errorHandler, notFoundHandler } from './middlewares/error-handler'
|
| 13 |
+
import { logger, createLogger } from './utils/logger'
|
| 14 |
+
import routes from './routes'
|
| 15 |
+
import type { Server } from 'http'
|
| 16 |
+
import path from 'path'
|
| 17 |
+
|
| 18 |
+
// 导入队列处理器以启动 worker
|
| 19 |
+
import './queues/processors/video.processor'
|
| 20 |
+
|
| 21 |
+
const app = express()
|
| 22 |
+
const appLogger = createLogger('Server')
|
| 23 |
+
|
| 24 |
+
let server: Server | null = null
|
| 25 |
+
|
| 26 |
+
/**
|
| 27 |
+
* 请求日志中间件
|
| 28 |
+
*/
|
| 29 |
+
function requestLogger(req: Request, res: Response, next: NextFunction): void {
|
| 30 |
+
const start = Date.now()
|
| 31 |
+
|
| 32 |
+
res.on('finish', () => {
|
| 33 |
+
const duration = Date.now() - start
|
| 34 |
+
// 只记录非查询状态的请求
|
| 35 |
+
if (!req.path.includes('/jobs/')) {
|
| 36 |
+
appLogger.info('Request completed', {
|
| 37 |
+
method: req.method,
|
| 38 |
+
path: req.path,
|
| 39 |
+
status: res.statusCode,
|
| 40 |
+
duration: `${duration}ms`
|
| 41 |
+
})
|
| 42 |
+
}
|
| 43 |
+
})
|
| 44 |
+
|
| 45 |
+
next()
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
/**
|
| 49 |
+
* 初始化应用
|
| 50 |
+
*/
|
| 51 |
+
async function initializeApp(): Promise<void> {
|
| 52 |
+
try {
|
| 53 |
+
// 验证配置
|
| 54 |
+
validateConfig()
|
| 55 |
+
|
| 56 |
+
// 基础中间件
|
| 57 |
+
app.use(express.json({ limit: '10mb' }))
|
| 58 |
+
app.use(express.urlencoded({ extended: true, limit: '10mb' }))
|
| 59 |
+
app.use(corsMiddleware)
|
| 60 |
+
|
| 61 |
+
// JSON 解析错误处理
|
| 62 |
+
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
| 63 |
+
if (err instanceof SyntaxError && 'body' in err) {
|
| 64 |
+
appLogger.error('JSON 解析错误', {
|
| 65 |
+
method: req.method,
|
| 66 |
+
path: req.path,
|
| 67 |
+
error: err.message,
|
| 68 |
+
body: req.body
|
| 69 |
+
})
|
| 70 |
+
return res.status(400).json({
|
| 71 |
+
error: 'Invalid JSON',
|
| 72 |
+
message: err.message
|
| 73 |
+
})
|
| 74 |
+
}
|
| 75 |
+
next(err)
|
| 76 |
+
})
|
| 77 |
+
|
| 78 |
+
// 请求日志(开发环境)
|
| 79 |
+
if (isDevelopment()) {
|
| 80 |
+
app.use(requestLogger)
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
// 静态文件服务
|
| 84 |
+
app.use(express.static('public'))
|
| 85 |
+
|
| 86 |
+
// 挂载所有路由(包括健康检查和 API 路由)
|
| 87 |
+
app.use(routes)
|
| 88 |
+
|
| 89 |
+
// SPA fallback:任何非 API 请求都返回 React 的 index.html
|
| 90 |
+
app.get('*', (req, res) => {
|
| 91 |
+
// 跳过健康检查和 API 路由
|
| 92 |
+
if (req.path.startsWith('/health') || req.path.startsWith('/api')) {
|
| 93 |
+
return notFoundHandler(req, res, () => {})
|
| 94 |
+
}
|
| 95 |
+
// 返回 React 前端的 index.html
|
| 96 |
+
const indexPath = path.join(__dirname, '..', 'public', 'index.html')
|
| 97 |
+
res.sendFile(indexPath, (err) => {
|
| 98 |
+
if (err) {
|
| 99 |
+
return notFoundHandler(req, res, () => {})
|
| 100 |
+
}
|
| 101 |
+
})
|
| 102 |
+
})
|
| 103 |
+
|
| 104 |
+
// 全局错误处理
|
| 105 |
+
app.use(errorHandler)
|
| 106 |
+
|
| 107 |
+
// 打印配置信息
|
| 108 |
+
printConfig()
|
| 109 |
+
|
| 110 |
+
appLogger.info('Express application initialized successfully')
|
| 111 |
+
} catch (error) {
|
| 112 |
+
appLogger.error('Failed to initialize application', { error })
|
| 113 |
+
throw error
|
| 114 |
+
}
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
/**
|
| 118 |
+
* 尝试在指定端口启动服务器
|
| 119 |
+
*/
|
| 120 |
+
function tryListen(port: number, host: string, retries = 3): Promise<void> {
|
| 121 |
+
return new Promise((resolve, reject) => {
|
| 122 |
+
const attemptListen = (attemptNumber: number) => {
|
| 123 |
+
server = app.listen(port, host)
|
| 124 |
+
.on('listening', () => {
|
| 125 |
+
appLogger.info(`🚀 Server listening on http://${host}:${port}`)
|
| 126 |
+
appLogger.info(`📝 Environment: ${appConfig.nodeEnv}`)
|
| 127 |
+
appLogger.info(`🔍 Health check: http://${host}:${port}/health`)
|
| 128 |
+
resolve()
|
| 129 |
+
})
|
| 130 |
+
.on('error', (error: NodeJS.ErrnoException) => {
|
| 131 |
+
if (error.code === 'EADDRINUSE') {
|
| 132 |
+
appLogger.warn(`Port ${port} is in use, attempt ${attemptNumber}/${retries}`)
|
| 133 |
+
|
| 134 |
+
if (attemptNumber < retries) {
|
| 135 |
+
// 等待一段时间后重试
|
| 136 |
+
setTimeout(() => {
|
| 137 |
+
attemptListen(attemptNumber + 1)
|
| 138 |
+
}, 1000 * attemptNumber) // 递增等待时间
|
| 139 |
+
} else {
|
| 140 |
+
appLogger.error(`Failed to bind to port ${port} after ${retries} attempts`)
|
| 141 |
+
reject(new Error(`Port ${port} is already in use. Please stop the existing process or use a different port.`))
|
| 142 |
+
}
|
| 143 |
+
} else {
|
| 144 |
+
appLogger.error('Server error', { error })
|
| 145 |
+
reject(error)
|
| 146 |
+
}
|
| 147 |
+
})
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
attemptListen(1)
|
| 151 |
+
})
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
/**
|
| 155 |
+
* 启动服务器
|
| 156 |
+
*/
|
| 157 |
+
async function startServer(): Promise<void> {
|
| 158 |
+
await initializeApp()
|
| 159 |
+
await tryListen(appConfig.port, appConfig.host)
|
| 160 |
+
setupShutdownHandlers()
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
/**
|
| 164 |
+
* 设置优雅关闭处理器
|
| 165 |
+
*/
|
| 166 |
+
function setupShutdownHandlers(): void {
|
| 167 |
+
// 优雅关闭处理
|
| 168 |
+
const shutdown = async (signal: string): Promise<void> => {
|
| 169 |
+
appLogger.info(`Received ${signal}, starting graceful shutdown...`)
|
| 170 |
+
|
| 171 |
+
if (!server) {
|
| 172 |
+
appLogger.warn('Server instance not found, skipping server close')
|
| 173 |
+
await cleanupResources()
|
| 174 |
+
process.exit(0)
|
| 175 |
+
return
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
// 停止接收新连接
|
| 179 |
+
server.close(async (err) => {
|
| 180 |
+
if (err) {
|
| 181 |
+
appLogger.error('Error closing server', { error: err })
|
| 182 |
+
process.exit(1)
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
await cleanupResources()
|
| 186 |
+
})
|
| 187 |
+
|
| 188 |
+
// 强制退出超时
|
| 189 |
+
setTimeout(() => {
|
| 190 |
+
appLogger.warn('Forced shutdown after timeout')
|
| 191 |
+
process.exit(1)
|
| 192 |
+
}, 10 * 60 * 1000) // 10 minutes timeout
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
// 清理资源
|
| 196 |
+
const cleanupResources = async (): Promise<void> => {
|
| 197 |
+
try {
|
| 198 |
+
// 关闭队列
|
| 199 |
+
await closeQueue()
|
| 200 |
+
|
| 201 |
+
// 关闭 Redis 连接
|
| 202 |
+
await redisClient.quit()
|
| 203 |
+
|
| 204 |
+
appLogger.info('Graceful shutdown completed')
|
| 205 |
+
process.exit(0)
|
| 206 |
+
} catch (error) {
|
| 207 |
+
appLogger.error('Error during shutdown', { error })
|
| 208 |
+
process.exit(1)
|
| 209 |
+
}
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
// 监听退出信号
|
| 213 |
+
process.on('SIGTERM', () => shutdown('SIGTERM'))
|
| 214 |
+
process.on('SIGINT', () => shutdown('SIGINT'))
|
| 215 |
+
|
| 216 |
+
// 未捕获异常处理
|
| 217 |
+
process.on('uncaughtException', (error) => {
|
| 218 |
+
appLogger.error('Uncaught exception', { error })
|
| 219 |
+
shutdown('UNCAUGHT_EXCEPTION')
|
| 220 |
+
})
|
| 221 |
+
|
| 222 |
+
process.on('unhandledRejection', (reason, promise) => {
|
| 223 |
+
appLogger.error('Unhandled rejection', { reason, promise })
|
| 224 |
+
shutdown('UNHANDLED_REJECTION')
|
| 225 |
+
})
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
// 启动应用
|
| 229 |
+
startServer().catch((error) => {
|
| 230 |
+
appLogger.error('Failed to start server', { error })
|
| 231 |
+
process.exit(1)
|
| 232 |
+
})
|
| 233 |
+
|
| 234 |
+
// 导出 app 用于测试
|
| 235 |
+
export default app
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
|
src/services/code-retry/manager.ts
CHANGED
|
@@ -1,310 +1,332 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* Code Retry Service - 重试管理器
|
| 3 |
-
*
|
| 4 |
-
* 核心逻辑:
|
| 5 |
-
* 1. 维护完整的对话历史(原始提示词 + 每次生成的代码 + 每次的错误)
|
| 6 |
-
* 2. 每次重试都发送完整的对话历史
|
| 7 |
-
* 3. 最多重试 4 次
|
| 8 |
-
*/
|
| 9 |
-
|
| 10 |
-
import crypto from 'crypto'
|
| 11 |
-
import OpenAI from 'openai'
|
| 12 |
-
import { createLogger } from '../../utils/logger'
|
| 13 |
-
import { cleanManimCode } from '../../utils/manim-code-cleaner'
|
| 14 |
-
|
| 15 |
-
import type { CodeRetryOptions, CodeRetryResult, RenderResult, RetryManagerResult, ChatMessage, CodeRetryContext } from './types'
|
| 16 |
-
import {
|
| 17 |
-
import {
|
| 18 |
-
import {
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
const
|
| 25 |
-
const
|
| 26 |
-
const
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
*
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
}
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
}
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
}
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
*
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Code Retry Service - 重试管理器
|
| 3 |
+
*
|
| 4 |
+
* 核心逻辑:
|
| 5 |
+
* 1. 维护完整的对话历史(原始提示词 + 每次生成的代码 + 每次的错误)
|
| 6 |
+
* 2. 每次重试都发送完整的对话历史
|
| 7 |
+
* 3. 最多重试 4 次
|
| 8 |
+
*/
|
| 9 |
+
|
| 10 |
+
import crypto from 'crypto'
|
| 11 |
+
import OpenAI from 'openai'
|
| 12 |
+
import { createLogger } from '../../utils/logger'
|
| 13 |
+
import { cleanManimCode } from '../../utils/manim-code-cleaner'
|
| 14 |
+
|
| 15 |
+
import type { CodeRetryOptions, CodeRetryResult, RenderResult, RetryManagerResult, ChatMessage, CodeRetryContext } from './types'
|
| 16 |
+
import type { PromptOverrides } from '../../types'
|
| 17 |
+
import { buildInitialCodePrompt, CODE_RETRY_SYSTEM_PROMPT } from './prompts'
|
| 18 |
+
import { getClient } from './client'
|
| 19 |
+
import { extractCodeFromResponse, extractErrorMessage, getErrorType } from './utils'
|
| 20 |
+
|
| 21 |
+
const logger = createLogger('CodeRetryManager')
|
| 22 |
+
|
| 23 |
+
// 配置
|
| 24 |
+
const MAX_RETRIES = parseInt(process.env.CODE_RETRY_MAX_RETRIES || '4', 10)
|
| 25 |
+
const OPENAI_MODEL = process.env.OPENAI_MODEL || 'glm-4-flash'
|
| 26 |
+
const AI_TEMPERATURE = parseFloat(process.env.AI_TEMPERATURE || '0.7')
|
| 27 |
+
const MAX_TOKENS = parseInt(process.env.AI_MAX_TOKENS || '1200', 10)
|
| 28 |
+
|
| 29 |
+
/**
|
| 30 |
+
* 生成唯一种子
|
| 31 |
+
*/
|
| 32 |
+
function applyPromptTemplate(template: string, values: Record<string, string>): string {
|
| 33 |
+
let output = template
|
| 34 |
+
for (const [key, value] of Object.entries(values)) {
|
| 35 |
+
output = output.replace(new RegExp(`{{\s*${key}\s*}}`, 'g'), value)
|
| 36 |
+
}
|
| 37 |
+
return output
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
function getCodeRetrySystemPrompt(promptOverrides?: PromptOverrides): string {
|
| 41 |
+
return promptOverrides?.system?.codeRetry || CODE_RETRY_SYSTEM_PROMPT
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
function buildInitialPrompt(
|
| 45 |
+
concept: string,
|
| 46 |
+
seed: string,
|
| 47 |
+
sceneDesign: string,
|
| 48 |
+
promptOverrides?: PromptOverrides
|
| 49 |
+
): string {
|
| 50 |
+
const override = promptOverrides?.user?.codeRetryInitial
|
| 51 |
+
if (override) {
|
| 52 |
+
return applyPromptTemplate(override, {
|
| 53 |
+
concept,
|
| 54 |
+
seed,
|
| 55 |
+
sceneDesign
|
| 56 |
+
})
|
| 57 |
+
}
|
| 58 |
+
return buildInitialCodePrompt(concept, seed, sceneDesign)
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
function generateSeed(concept: string): string {
|
| 62 |
+
const timestamp = Date.now()
|
| 63 |
+
const randomPart = crypto.randomBytes(4).toString('hex')
|
| 64 |
+
return crypto.createHash('md5').update(`${concept}-${timestamp}-${randomPart}`).digest('hex').slice(0, 8)
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
/**
|
| 68 |
+
* 创建重试上下文
|
| 69 |
+
*/
|
| 70 |
+
export function createRetryContext(
|
| 71 |
+
concept: string,
|
| 72 |
+
sceneDesign: string,
|
| 73 |
+
promptOverrides?: PromptOverrides
|
| 74 |
+
): CodeRetryContext {
|
| 75 |
+
const seed = generateSeed(concept)
|
| 76 |
+
|
| 77 |
+
return {
|
| 78 |
+
concept,
|
| 79 |
+
sceneDesign,
|
| 80 |
+
originalPrompt: buildInitialPrompt(concept, seed, sceneDesign, promptOverrides),
|
| 81 |
+
messages: [],
|
| 82 |
+
promptOverrides
|
| 83 |
+
}
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
/**
|
| 87 |
+
* 首次代码生成
|
| 88 |
+
*/
|
| 89 |
+
async function generateInitialCode(
|
| 90 |
+
context: CodeRetryContext,
|
| 91 |
+
customApiConfig?: any
|
| 92 |
+
): Promise<string> {
|
| 93 |
+
const client = getClient(customApiConfig)
|
| 94 |
+
if (!client) {
|
| 95 |
+
throw new Error('OpenAI 客户端不可用')
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
try {
|
| 99 |
+
const response = await client.chat.completions.create({
|
| 100 |
+
model: OPENAI_MODEL,
|
| 101 |
+
messages: [
|
| 102 |
+
{ role: 'system', content: getCodeRetrySystemPrompt(context.promptOverrides) },
|
| 103 |
+
{ role: 'user', content: context.originalPrompt }
|
| 104 |
+
],
|
| 105 |
+
temperature: AI_TEMPERATURE,
|
| 106 |
+
max_tokens: MAX_TOKENS
|
| 107 |
+
})
|
| 108 |
+
|
| 109 |
+
const content = response.choices[0]?.message?.content || ''
|
| 110 |
+
if (!content) {
|
| 111 |
+
throw new Error('AI 返回空内容')
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
// 清洗代码
|
| 115 |
+
const code = extractCodeFromResponse(content)
|
| 116 |
+
const cleaned = cleanManimCode(code)
|
| 117 |
+
|
| 118 |
+
// 保存对话历史
|
| 119 |
+
context.messages.push(
|
| 120 |
+
{ role: 'user', content: context.originalPrompt },
|
| 121 |
+
{ role: 'assistant', content: code }
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
logger.info('首次代码生成成功', {
|
| 125 |
+
concept: context.concept,
|
| 126 |
+
codeLength: cleaned.code.length
|
| 127 |
+
})
|
| 128 |
+
|
| 129 |
+
return cleaned.code
|
| 130 |
+
} catch (error) {
|
| 131 |
+
if (error instanceof OpenAI.APIError) {
|
| 132 |
+
logger.error('OpenAI API 错误', {
|
| 133 |
+
status: error.status,
|
| 134 |
+
message: error.message
|
| 135 |
+
})
|
| 136 |
+
}
|
| 137 |
+
throw error
|
| 138 |
+
}
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
/**
|
| 142 |
+
* 重试代码生成
|
| 143 |
+
*/
|
| 144 |
+
async function retryCodeGeneration(
|
| 145 |
+
context: CodeRetryContext,
|
| 146 |
+
errorMessage: string,
|
| 147 |
+
attempt: number,
|
| 148 |
+
customApiConfig?: any
|
| 149 |
+
): Promise<string> {
|
| 150 |
+
const client = getClient(customApiConfig)
|
| 151 |
+
if (!client) {
|
| 152 |
+
throw new Error('OpenAI 客户端不可用')
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
// 构建重试提示词(包含完整对话历史和错误信息)
|
| 156 |
+
const retryPrompt = buildRetryPrompt(context, errorMessage, attempt)
|
| 157 |
+
|
| 158 |
+
try {
|
| 159 |
+
// 构建消息数组:system + 历史消息 + 当前重试提示词
|
| 160 |
+
const messages: ChatMessage[] = [
|
| 161 |
+
{ role: 'system', content: getCodeRetrySystemPrompt(context.promptOverrides) },
|
| 162 |
+
...context.messages,
|
| 163 |
+
{ role: 'user', content: retryPrompt }
|
| 164 |
+
]
|
| 165 |
+
|
| 166 |
+
const response = await client.chat.completions.create({
|
| 167 |
+
model: OPENAI_MODEL,
|
| 168 |
+
messages,
|
| 169 |
+
temperature: AI_TEMPERATURE,
|
| 170 |
+
max_tokens: MAX_TOKENS
|
| 171 |
+
})
|
| 172 |
+
|
| 173 |
+
const content = response.choices[0]?.message?.content || ''
|
| 174 |
+
if (!content) {
|
| 175 |
+
throw new Error('AI 返回空内容')
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
// 清洗代码
|
| 179 |
+
const code = extractCodeFromResponse(content)
|
| 180 |
+
const cleaned = cleanManimCode(code)
|
| 181 |
+
|
| 182 |
+
// 保存对话历史
|
| 183 |
+
context.messages.push(
|
| 184 |
+
{ role: 'user', content: retryPrompt },
|
| 185 |
+
{ role: 'assistant', content: code }
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
logger.info('代码重试生成成功', {
|
| 189 |
+
concept: context.concept,
|
| 190 |
+
attempt,
|
| 191 |
+
codeLength: cleaned.code.length
|
| 192 |
+
})
|
| 193 |
+
|
| 194 |
+
return cleaned.code
|
| 195 |
+
} catch (error) {
|
| 196 |
+
if (error instanceof OpenAI.APIError) {
|
| 197 |
+
logger.error('OpenAI API 错误(重试)', {
|
| 198 |
+
attempt,
|
| 199 |
+
status: error.status,
|
| 200 |
+
message: error.message
|
| 201 |
+
})
|
| 202 |
+
}
|
| 203 |
+
throw error
|
| 204 |
+
}
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
/**
|
| 208 |
+
* 构建重试提示词
|
| 209 |
+
*/
|
| 210 |
+
export function buildRetryFixPrompt(
|
| 211 |
+
concept: string,
|
| 212 |
+
errorMessage: string,
|
| 213 |
+
attempt: number | string
|
| 214 |
+
): string {
|
| 215 |
+
return `## 目标层
|
| 216 |
+
|
| 217 |
+
### 输入预期
|
| 218 |
+
|
| 219 |
+
- **概念**:${concept}
|
| 220 |
+
- **错误信息**(第 ${attempt} 次重试):${errorMessage}
|
| 221 |
+
|
| 222 |
+
### 产出要求
|
| 223 |
+
|
| 224 |
+
- **修复代码**:根据错误信息修复之前的代码。
|
| 225 |
+
- **完整代码**:必须输出完整的、可运行的 Manim 代码,不是修复片段!
|
| 226 |
+
- **锚点协议**:代码必须包裹在 ### START ### 和 ### END ### 之间
|
| 227 |
+
- **纯代码输出**:严禁包含任何解释性文字。
|
| 228 |
+
- **结构规范**:核心类名固定为 \`AnimationScene\`
|
| 229 |
+
`
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
function buildRetryPrompt(
|
| 233 |
+
context: CodeRetryContext,
|
| 234 |
+
errorMessage: string,
|
| 235 |
+
attempt: number
|
| 236 |
+
): string {
|
| 237 |
+
const override = context.promptOverrides?.user?.codeRetryFix
|
| 238 |
+
if (override) {
|
| 239 |
+
return applyPromptTemplate(override, {
|
| 240 |
+
concept: context.concept,
|
| 241 |
+
errorMessage,
|
| 242 |
+
attempt: String(attempt)
|
| 243 |
+
})
|
| 244 |
+
}
|
| 245 |
+
return buildRetryFixPrompt(context.concept, errorMessage, attempt)
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
/**
|
| 249 |
+
* 重试管理器 - 核心函数
|
| 250 |
+
*
|
| 251 |
+
* 流程:
|
| 252 |
+
* 1. 首次生成代码 → 渲染
|
| 253 |
+
* 2. 如果失败,检查错误是否可修复
|
| 254 |
+
* 3. 重试(最多4次),每次都发送完整对话历史
|
| 255 |
+
* 4. 如果4次后仍失败,返回失败结果
|
| 256 |
+
*/
|
| 257 |
+
export async function executeCodeRetry(
|
| 258 |
+
context: CodeRetryContext,
|
| 259 |
+
renderer: (code: string) => Promise<RenderResult>,
|
| 260 |
+
customApiConfig?: any
|
| 261 |
+
): Promise<RetryManagerResult> {
|
| 262 |
+
logger.info('开始代码重试管理', {
|
| 263 |
+
concept: context.concept,
|
| 264 |
+
maxRetries: MAX_RETRIES
|
| 265 |
+
})
|
| 266 |
+
|
| 267 |
+
// 步骤1:首次代码生成和渲染
|
| 268 |
+
let currentCode = await generateInitialCode(context, customApiConfig)
|
| 269 |
+
let renderResult = await renderer(currentCode)
|
| 270 |
+
|
| 271 |
+
if (renderResult.success) {
|
| 272 |
+
logger.info('首次渲染成功')
|
| 273 |
+
return { code: currentCode, success: true, attempts: 1 }
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
// 步骤2:提取错误信息并开始重试
|
| 277 |
+
let errorMessage = extractErrorMessage(renderResult.stderr)
|
| 278 |
+
let errorType = getErrorType(renderResult.stderr)
|
| 279 |
+
logger.warn('首次渲染失败', { errorType, error: errorMessage })
|
| 280 |
+
// 所有错误都尝试重试,AI 有能力修复语法、导入等问题
|
| 281 |
+
|
| 282 |
+
// 步骤3:重试循环
|
| 283 |
+
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
| 284 |
+
logger.info(`开始第 ${attempt} 次重试`, {
|
| 285 |
+
totalAttempts: attempt + 1,
|
| 286 |
+
errorType,
|
| 287 |
+
error: errorMessage
|
| 288 |
+
})
|
| 289 |
+
|
| 290 |
+
try {
|
| 291 |
+
currentCode = await retryCodeGeneration(context, errorMessage, attempt, customApiConfig)
|
| 292 |
+
renderResult = await renderer(currentCode)
|
| 293 |
+
|
| 294 |
+
if (renderResult.success) {
|
| 295 |
+
logger.info('重试渲染成功', { attempt: attempt + 1 })
|
| 296 |
+
return { code: currentCode, success: true, attempts: attempt + 1 }
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
// 更新错误信息
|
| 300 |
+
errorMessage = extractErrorMessage(renderResult.stderr)
|
| 301 |
+
errorType = getErrorType(renderResult.stderr)
|
| 302 |
+
logger.warn('重试渲染失败', { attempt: attempt + 1, errorType, error: errorMessage })
|
| 303 |
+
} catch (error) {
|
| 304 |
+
logger.error('重试过程出错', { attempt: attempt + 1, error: String(error) })
|
| 305 |
+
}
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
// 步骤4:所有重试失败
|
| 309 |
+
logger.error('所有重试均失败', {
|
| 310 |
+
totalAttempts: MAX_RETRIES + 1,
|
| 311 |
+
finalError: extractErrorMessage(renderResult.stderr)
|
| 312 |
+
})
|
| 313 |
+
|
| 314 |
+
return {
|
| 315 |
+
code: currentCode,
|
| 316 |
+
success: false,
|
| 317 |
+
attempts: MAX_RETRIES + 1,
|
| 318 |
+
lastError: extractErrorMessage(renderResult.stderr)
|
| 319 |
+
}
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
/**
|
| 323 |
+
* 导出类型
|
| 324 |
+
*/
|
| 325 |
+
export type {
|
| 326 |
+
CodeRetryOptions,
|
| 327 |
+
CodeRetryResult,
|
| 328 |
+
RenderResult,
|
| 329 |
+
RetryManagerResult,
|
| 330 |
+
ChatMessage,
|
| 331 |
+
CodeRetryContext
|
| 332 |
+
} from './types'
|
src/services/code-retry/prompts.ts
CHANGED
|
@@ -3,10 +3,10 @@
|
|
| 3 |
*/
|
| 4 |
|
| 5 |
import { API_INDEX } from '../../prompts/api-index'
|
|
|
|
| 6 |
|
| 7 |
// System prompt - 与代码生成者一致
|
| 8 |
-
export const CODE_RETRY_SYSTEM_PROMPT =
|
| 9 |
-
严格按照提示词词规范输出,确保代码符合 Manim Community Edition (v0.19.2) 的最佳实践。`
|
| 10 |
|
| 11 |
/**
|
| 12 |
* 构建首次代码生成的用户提示词
|
|
@@ -88,4 +88,4 @@ ${API_INDEX}
|
|
| 88 |
${sceneDesign}
|
| 89 |
\`\`\`
|
| 90 |
|
| 91 |
-
请根据上述设计方案生成 Manim 代码。`}
|
|
|
|
| 3 |
*/
|
| 4 |
|
| 5 |
import { API_INDEX } from '../../prompts/api-index'
|
| 6 |
+
import { SYSTEM_PROMPT_BASE } from '../../prompts'
|
| 7 |
|
| 8 |
// System prompt - 与代码生成者一致
|
| 9 |
+
export const CODE_RETRY_SYSTEM_PROMPT = SYSTEM_PROMPT_BASE
|
|
|
|
| 10 |
|
| 11 |
/**
|
| 12 |
* 构建首次代码生成的用户提示词
|
|
|
|
| 88 |
${sceneDesign}
|
| 89 |
\`\`\`
|
| 90 |
|
| 91 |
+
请根据上述设计方案生成 Manim 代码。`}
|
src/services/code-retry/types.ts
CHANGED
|
@@ -1,62 +1,62 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* Code Retry Service - 类型定义
|
| 3 |
-
*/
|
| 4 |
-
|
| 5 |
-
import type { CustomApiConfig } from '../../types'
|
| 6 |
-
|
| 7 |
-
/**
|
| 8 |
-
* 对话消息类型
|
| 9 |
-
*/
|
| 10 |
-
export interface ChatMessage {
|
| 11 |
-
role: 'system' | 'user' | 'assistant'
|
| 12 |
-
content: string
|
| 13 |
-
}
|
| 14 |
-
|
| 15 |
-
/**
|
| 16 |
-
* 代码重试上下文
|
| 17 |
-
* 维护完整的对话历史
|
| 18 |
-
*/
|
| 19 |
-
export interface CodeRetryContext {
|
| 20 |
-
concept: string
|
| 21 |
-
sceneDesign: string
|
| 22 |
-
originalPrompt: string // 原始写代码的提示词
|
| 23 |
-
messages: ChatMessage[] // 完整对话历史
|
| 24 |
-
}
|
| 25 |
-
|
| 26 |
-
/**
|
| 27 |
-
* 代码重试选项
|
| 28 |
-
*/
|
| 29 |
-
export interface CodeRetryOptions {
|
| 30 |
-
context: CodeRetryContext
|
| 31 |
-
customApiConfig?: CustomApiConfig
|
| 32 |
-
}
|
| 33 |
-
|
| 34 |
-
/**
|
| 35 |
-
* 代码重试结果
|
| 36 |
-
*/
|
| 37 |
-
export interface CodeRetryResult {
|
| 38 |
-
success: boolean
|
| 39 |
-
code: string
|
| 40 |
-
attempt: number
|
| 41 |
-
reason?: string
|
| 42 |
-
}
|
| 43 |
-
|
| 44 |
-
/**
|
| 45 |
-
* 渲染结果
|
| 46 |
-
*/
|
| 47 |
-
export interface RenderResult {
|
| 48 |
-
success: boolean
|
| 49 |
-
stderr: string
|
| 50 |
-
stdout: string
|
| 51 |
-
peakMemoryMB: number
|
| 52 |
-
}
|
| 53 |
-
|
| 54 |
-
/**
|
| 55 |
-
* 重试管理器结果
|
| 56 |
-
*/
|
| 57 |
-
export interface RetryManagerResult {
|
| 58 |
-
code: string
|
| 59 |
-
success: boolean
|
| 60 |
-
attempts: number
|
| 61 |
-
lastError?: string
|
| 62 |
-
}
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Code Retry Service - 类型定义
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import type { CustomApiConfig, PromptOverrides } from '../../types'
|
| 6 |
+
|
| 7 |
+
/**
|
| 8 |
+
* 对话消息类型
|
| 9 |
+
*/
|
| 10 |
+
export interface ChatMessage {
|
| 11 |
+
role: 'system' | 'user' | 'assistant'
|
| 12 |
+
content: string
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
/**
|
| 16 |
+
* 代码重试上下文
|
| 17 |
+
* 维护完整的对话历史
|
| 18 |
+
*/
|
| 19 |
+
export interface CodeRetryContext {
|
| 20 |
+
concept: string
|
| 21 |
+
sceneDesign: string
|
| 22 |
+
originalPrompt: string // 原始写代码的提示词
|
| 23 |
+
messages: ChatMessage[] // 完整对话历史
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
/**
|
| 27 |
+
* 代码重试选项
|
| 28 |
+
*/
|
| 29 |
+
export interface CodeRetryOptions {
|
| 30 |
+
context: CodeRetryContext
|
| 31 |
+
customApiConfig?: CustomApiConfig
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
/**
|
| 35 |
+
* 代码重试结果
|
| 36 |
+
*/
|
| 37 |
+
export interface CodeRetryResult {
|
| 38 |
+
success: boolean
|
| 39 |
+
code: string
|
| 40 |
+
attempt: number
|
| 41 |
+
reason?: string
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
/**
|
| 45 |
+
* 渲染结果
|
| 46 |
+
*/
|
| 47 |
+
export interface RenderResult {
|
| 48 |
+
success: boolean
|
| 49 |
+
stderr: string
|
| 50 |
+
stdout: string
|
| 51 |
+
peakMemoryMB: number
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
/**
|
| 55 |
+
* 重试管理器结果
|
| 56 |
+
*/
|
| 57 |
+
export interface RetryManagerResult {
|
| 58 |
+
code: string
|
| 59 |
+
success: boolean
|
| 60 |
+
attempts: number
|
| 61 |
+
lastError?: string
|
| 62 |
+
}
|
src/services/concept-designer.ts
CHANGED
|
@@ -1,314 +1,331 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* 概念设计者服务 - 两阶段 AI 生成架构
|
| 3 |
-
* 第一阶段:设计者/思考者 - 将抽象概念转化为详细的场景设计方案
|
| 4 |
-
* 第二阶段:代码生成者 - 将设计方案转化为 Manim 代码
|
| 5 |
-
*/
|
| 6 |
-
|
| 7 |
-
import OpenAI from 'openai'
|
| 8 |
-
import crypto from 'crypto'
|
| 9 |
-
import { createLogger } from '../utils/logger'
|
| 10 |
-
import { SYSTEM_PROMPTS, generateConceptDesignerPrompt, generateCodeGenerationPrompt } from '../prompts'
|
| 11 |
-
import type { CustomApiConfig } from '../types'
|
| 12 |
-
|
| 13 |
-
const logger = createLogger('ConceptDesigner')
|
| 14 |
-
|
| 15 |
-
const OPENAI_MODEL = process.env.OPENAI_MODEL || 'glm-4-flash'
|
| 16 |
-
const DESIGNER_TEMPERATURE = parseFloat(process.env.DESIGNER_TEMPERATURE || '0.8')
|
| 17 |
-
const CODER_TEMPERATURE = parseFloat(process.env.AI_TEMPERATURE || '0.7')
|
| 18 |
-
const MAX_TOKENS = parseInt(process.env.AI_MAX_TOKENS || '1200', 10)
|
| 19 |
-
const DESIGNER_MAX_TOKENS = parseInt(process.env.DESIGNER_MAX_TOKENS || '800', 10)
|
| 20 |
-
const OPENAI_TIMEOUT = parseInt(process.env.OPENAI_TIMEOUT || '600000', 10)
|
| 21 |
-
|
| 22 |
-
const CUSTOM_API_URL = process.env.CUSTOM_API_URL?.trim()
|
| 23 |
-
|
| 24 |
-
let openaiClient: OpenAI | null = null
|
| 25 |
-
|
| 26 |
-
try {
|
| 27 |
-
const baseConfig = {
|
| 28 |
-
timeout: OPENAI_TIMEOUT,
|
| 29 |
-
defaultHeaders: {
|
| 30 |
-
'User-Agent': 'ManimCat/1.0'
|
| 31 |
-
}
|
| 32 |
-
}
|
| 33 |
-
|
| 34 |
-
if (CUSTOM_API_URL) {
|
| 35 |
-
openaiClient = new OpenAI({
|
| 36 |
-
...baseConfig,
|
| 37 |
-
baseURL: CUSTOM_API_URL,
|
| 38 |
-
apiKey: process.env.OPENAI_API_KEY
|
| 39 |
-
})
|
| 40 |
-
} else {
|
| 41 |
-
openaiClient = new OpenAI(baseConfig)
|
| 42 |
-
}
|
| 43 |
-
} catch (error) {
|
| 44 |
-
logger.warn('OpenAI 客户端初始化失败', { error })
|
| 45 |
-
}
|
| 46 |
-
|
| 47 |
-
/**
|
| 48 |
-
* 创建自定义 OpenAI 客户端
|
| 49 |
-
*/
|
| 50 |
-
function createCustomClient(config: CustomApiConfig): OpenAI {
|
| 51 |
-
return new OpenAI({
|
| 52 |
-
baseURL: config.apiUrl.trim().replace(/\/+$/, ''),
|
| 53 |
-
apiKey: config.apiKey,
|
| 54 |
-
timeout: OPENAI_TIMEOUT,
|
| 55 |
-
defaultHeaders: {
|
| 56 |
-
'User-Agent': 'ManimCat/1.0'
|
| 57 |
-
}
|
| 58 |
-
})
|
| 59 |
-
}
|
| 60 |
-
|
| 61 |
-
/**
|
| 62 |
-
* 基于概念和时间戳生成唯一种子
|
| 63 |
-
*/
|
| 64 |
-
function generateUniqueSeed(concept: string): string {
|
| 65 |
-
const timestamp = Date.now()
|
| 66 |
-
const randomPart = crypto.randomBytes(4).toString('hex')
|
| 67 |
-
return crypto.createHash('md5').update(`${concept}-${timestamp}-${randomPart}`).digest('hex').slice(0, 8)
|
| 68 |
-
}
|
| 69 |
-
|
| 70 |
-
function
|
| 71 |
-
|
| 72 |
-
const
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
}
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
}
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
}
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
}
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
}
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
const
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
}
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
{
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
})
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* 概念设计者服务 - 两阶段 AI 生成架构
|
| 3 |
+
* 第一阶段:设计者/思考者 - 将抽象概念转化为详细的场景设计方案
|
| 4 |
+
* 第二阶段:代码生成者 - 将设计方案转化为 Manim 代码
|
| 5 |
+
*/
|
| 6 |
+
|
| 7 |
+
import OpenAI from 'openai'
|
| 8 |
+
import crypto from 'crypto'
|
| 9 |
+
import { createLogger } from '../utils/logger'
|
| 10 |
+
import { SYSTEM_PROMPTS, generateConceptDesignerPrompt, generateCodeGenerationPrompt } from '../prompts'
|
| 11 |
+
import type { CustomApiConfig, PromptOverrides } from '../types'
|
| 12 |
+
|
| 13 |
+
const logger = createLogger('ConceptDesigner')
|
| 14 |
+
|
| 15 |
+
const OPENAI_MODEL = process.env.OPENAI_MODEL || 'glm-4-flash'
|
| 16 |
+
const DESIGNER_TEMPERATURE = parseFloat(process.env.DESIGNER_TEMPERATURE || '0.8')
|
| 17 |
+
const CODER_TEMPERATURE = parseFloat(process.env.AI_TEMPERATURE || '0.7')
|
| 18 |
+
const MAX_TOKENS = parseInt(process.env.AI_MAX_TOKENS || '1200', 10)
|
| 19 |
+
const DESIGNER_MAX_TOKENS = parseInt(process.env.DESIGNER_MAX_TOKENS || '800', 10)
|
| 20 |
+
const OPENAI_TIMEOUT = parseInt(process.env.OPENAI_TIMEOUT || '600000', 10)
|
| 21 |
+
|
| 22 |
+
const CUSTOM_API_URL = process.env.CUSTOM_API_URL?.trim()
|
| 23 |
+
|
| 24 |
+
let openaiClient: OpenAI | null = null
|
| 25 |
+
|
| 26 |
+
try {
|
| 27 |
+
const baseConfig = {
|
| 28 |
+
timeout: OPENAI_TIMEOUT,
|
| 29 |
+
defaultHeaders: {
|
| 30 |
+
'User-Agent': 'ManimCat/1.0'
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
if (CUSTOM_API_URL) {
|
| 35 |
+
openaiClient = new OpenAI({
|
| 36 |
+
...baseConfig,
|
| 37 |
+
baseURL: CUSTOM_API_URL,
|
| 38 |
+
apiKey: process.env.OPENAI_API_KEY
|
| 39 |
+
})
|
| 40 |
+
} else {
|
| 41 |
+
openaiClient = new OpenAI(baseConfig)
|
| 42 |
+
}
|
| 43 |
+
} catch (error) {
|
| 44 |
+
logger.warn('OpenAI 客户端初始化失败', { error })
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
/**
|
| 48 |
+
* 创建自定义 OpenAI 客户端
|
| 49 |
+
*/
|
| 50 |
+
function createCustomClient(config: CustomApiConfig): OpenAI {
|
| 51 |
+
return new OpenAI({
|
| 52 |
+
baseURL: config.apiUrl.trim().replace(/\/+$/, ''),
|
| 53 |
+
apiKey: config.apiKey,
|
| 54 |
+
timeout: OPENAI_TIMEOUT,
|
| 55 |
+
defaultHeaders: {
|
| 56 |
+
'User-Agent': 'ManimCat/1.0'
|
| 57 |
+
}
|
| 58 |
+
})
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
/**
|
| 62 |
+
* 基于概念和时间戳生成唯一种子
|
| 63 |
+
*/
|
| 64 |
+
function generateUniqueSeed(concept: string): string {
|
| 65 |
+
const timestamp = Date.now()
|
| 66 |
+
const randomPart = crypto.randomBytes(4).toString('hex')
|
| 67 |
+
return crypto.createHash('md5').update(`${concept}-${timestamp}-${randomPart}`).digest('hex').slice(0, 8)
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
function applyPromptTemplate(template: string, values: Record<string, string>): string {
|
| 71 |
+
let output = template
|
| 72 |
+
for (const [key, value] of Object.entries(values)) {
|
| 73 |
+
output = output.replace(new RegExp(`{{\s*${key}\s*}}`, 'g'), value)
|
| 74 |
+
}
|
| 75 |
+
return output
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
function extractDesignFromResponse(text: string): string {
|
| 79 |
+
if (!text) return ''
|
| 80 |
+
const sanitized = text.replace(/<think>[\s\S]*?<\/think>/gi, '')
|
| 81 |
+
const match = sanitized.match(/<design>([\s\S]*?)<\/design>/i)
|
| 82 |
+
if (match) {
|
| 83 |
+
return match[1].trim()
|
| 84 |
+
}
|
| 85 |
+
return sanitized.trim()
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
function extractCodeFromResponse(text: string): string {
|
| 89 |
+
if (!text) return ''
|
| 90 |
+
const sanitized = text.replace(/<think>[\s\S]*?<\/think>/gi, '')
|
| 91 |
+
const anchorMatch = sanitized.match(/### START ###([\s\S]*?)### END ###/)
|
| 92 |
+
if (anchorMatch) {
|
| 93 |
+
return anchorMatch[1].trim()
|
| 94 |
+
}
|
| 95 |
+
const codeMatch = sanitized.match(/```(?:python)?([\s\S]*?)```/i)
|
| 96 |
+
if (codeMatch) {
|
| 97 |
+
return codeMatch[1].trim()
|
| 98 |
+
}
|
| 99 |
+
return sanitized.trim()
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
/**
|
| 103 |
+
* 清洗设计方案文本
|
| 104 |
+
*/
|
| 105 |
+
interface CleanDesignResult {
|
| 106 |
+
text: string
|
| 107 |
+
changes: string[]
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
function cleanDesignText(text: string): CleanDesignResult {
|
| 111 |
+
const changes: string[] = []
|
| 112 |
+
let cleaned = text
|
| 113 |
+
|
| 114 |
+
// 移除多余的空白行
|
| 115 |
+
const beforeLength = cleaned.length
|
| 116 |
+
cleaned = cleaned.replace(/\n{3,}/g, '\n\n')
|
| 117 |
+
if (cleaned.length !== beforeLength) {
|
| 118 |
+
changes.push('remove-extra-newlines')
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
// 移除首尾空白
|
| 122 |
+
cleaned = cleaned.trim()
|
| 123 |
+
|
| 124 |
+
return { text: cleaned, changes }
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
/**
|
| 128 |
+
* 阶段1:设计者/思考者
|
| 129 |
+
* 接收用户的抽象概念,输出详细的场景设计方案
|
| 130 |
+
*/
|
| 131 |
+
async function generateSceneDesign(
|
| 132 |
+
concept: string,
|
| 133 |
+
customApiConfig?: CustomApiConfig,
|
| 134 |
+
promptOverrides?: PromptOverrides
|
| 135 |
+
): Promise<string> {
|
| 136 |
+
const client = customApiConfig ? createCustomClient(customApiConfig) : openaiClient
|
| 137 |
+
|
| 138 |
+
if (!client) {
|
| 139 |
+
logger.warn('OpenAI 客户端不可用')
|
| 140 |
+
return ''
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
try {
|
| 144 |
+
const seed = generateUniqueSeed(concept)
|
| 145 |
+
|
| 146 |
+
const systemPrompt = promptOverrides?.system?.conceptDesigner || SYSTEM_PROMPTS.conceptDesigner
|
| 147 |
+
const userPromptOverride = promptOverrides?.user?.conceptDesigner
|
| 148 |
+
const userPrompt = userPromptOverride
|
| 149 |
+
? applyPromptTemplate(userPromptOverride, { concept, seed })
|
| 150 |
+
: generateConceptDesignerPrompt(concept, seed)
|
| 151 |
+
|
| 152 |
+
logger.info('开始阶段1:生成场景设计方案', { concept, seed })
|
| 153 |
+
|
| 154 |
+
const response = await client.chat.completions.create({
|
| 155 |
+
model: OPENAI_MODEL,
|
| 156 |
+
messages: [
|
| 157 |
+
{ role: 'system', content: systemPrompt },
|
| 158 |
+
{ role: 'user', content: userPrompt }
|
| 159 |
+
],
|
| 160 |
+
temperature: DESIGNER_TEMPERATURE,
|
| 161 |
+
max_tokens: DESIGNER_MAX_TOKENS
|
| 162 |
+
})
|
| 163 |
+
|
| 164 |
+
const content = response.choices[0]?.message?.content || ''
|
| 165 |
+
if (!content) {
|
| 166 |
+
logger.warn('设计者返回空内容')
|
| 167 |
+
return ''
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
const extractedDesign = extractDesignFromResponse(content)
|
| 171 |
+
const cleanedDesign = cleanDesignText(extractedDesign)
|
| 172 |
+
if (cleanedDesign.changes.length > 0) {
|
| 173 |
+
logger.info('设计方案已清洗', {
|
| 174 |
+
concept,
|
| 175 |
+
seed,
|
| 176 |
+
changes: cleanedDesign.changes,
|
| 177 |
+
originalLength: content.length,
|
| 178 |
+
cleanedLength: cleanedDesign.text.length
|
| 179 |
+
})
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
if (!cleanedDesign.text) {
|
| 183 |
+
logger.warn('设计者返回空方案')
|
| 184 |
+
return ''
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
logger.info('阶段1:场景设计方案生成成功', {
|
| 188 |
+
concept,
|
| 189 |
+
seed,
|
| 190 |
+
designLength: cleanedDesign.text.length,
|
| 191 |
+
design: cleanedDesign.text
|
| 192 |
+
})
|
| 193 |
+
|
| 194 |
+
return cleanedDesign.text
|
| 195 |
+
} catch (error) {
|
| 196 |
+
if (error instanceof OpenAI.APIError) {
|
| 197 |
+
logger.error('设计者 API 错误', {
|
| 198 |
+
concept,
|
| 199 |
+
status: error.status,
|
| 200 |
+
code: error.code,
|
| 201 |
+
type: error.type,
|
| 202 |
+
message: error.message
|
| 203 |
+
})
|
| 204 |
+
} else if (error instanceof Error) {
|
| 205 |
+
logger.error('设计者生成失败', {
|
| 206 |
+
concept,
|
| 207 |
+
errorName: error.name,
|
| 208 |
+
errorMessage: error.message
|
| 209 |
+
})
|
| 210 |
+
} else {
|
| 211 |
+
logger.error('设计者生成失败(未知错误)', { concept, error: String(error) })
|
| 212 |
+
}
|
| 213 |
+
return ''
|
| 214 |
+
}
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
/**
|
| 218 |
+
* 阶段2:代码生成者
|
| 219 |
+
* 接收场景设计方案,输出 Manim 代码
|
| 220 |
+
*/
|
| 221 |
+
async function generateCodeFromDesign(
|
| 222 |
+
concept: string,
|
| 223 |
+
sceneDesign: string,
|
| 224 |
+
customApiConfig?: CustomApiConfig,
|
| 225 |
+
promptOverrides?: PromptOverrides
|
| 226 |
+
): Promise<string> {
|
| 227 |
+
const client = customApiConfig ? createCustomClient(customApiConfig) : openaiClient
|
| 228 |
+
|
| 229 |
+
if (!client) {
|
| 230 |
+
logger.warn('OpenAI 客户端不可用')
|
| 231 |
+
return ''
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
try {
|
| 235 |
+
const seed = generateUniqueSeed(`${concept}-${sceneDesign.slice(0, 20)}`)
|
| 236 |
+
|
| 237 |
+
const systemPrompt = promptOverrides?.system?.codeGeneration || SYSTEM_PROMPTS.codeGeneration
|
| 238 |
+
const userPromptOverride = promptOverrides?.user?.codeGeneration
|
| 239 |
+
const userPrompt = userPromptOverride
|
| 240 |
+
? applyPromptTemplate(userPromptOverride, { concept, seed, sceneDesign })
|
| 241 |
+
: generateCodeGenerationPrompt(concept, seed, sceneDesign)
|
| 242 |
+
|
| 243 |
+
logger.info('开始阶段2:根据设计方案生成代码', { concept, seed })
|
| 244 |
+
|
| 245 |
+
const response = await client.chat.completions.create({
|
| 246 |
+
model: OPENAI_MODEL,
|
| 247 |
+
messages: [
|
| 248 |
+
{ role: 'system', content: systemPrompt },
|
| 249 |
+
{ role: 'user', content: userPrompt }
|
| 250 |
+
],
|
| 251 |
+
temperature: CODER_TEMPERATURE,
|
| 252 |
+
max_tokens: MAX_TOKENS
|
| 253 |
+
})
|
| 254 |
+
|
| 255 |
+
const content = response.choices[0]?.message?.content || ''
|
| 256 |
+
if (!content) {
|
| 257 |
+
logger.warn('代码生成者返回空内容')
|
| 258 |
+
return ''
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
logger.info('阶段2:代码生成成功', {
|
| 262 |
+
concept,
|
| 263 |
+
seed,
|
| 264 |
+
codeLength: content.length,
|
| 265 |
+
code: content
|
| 266 |
+
})
|
| 267 |
+
|
| 268 |
+
return content
|
| 269 |
+
} catch (error) {
|
| 270 |
+
// ✅ 正确:将完整的判断表达式放在括号内
|
| 271 |
+
if (error instanceof OpenAI.APIError) {
|
| 272 |
+
logger.error('代码生成者 API 错误', {
|
| 273 |
+
concept,
|
| 274 |
+
status: error.status,
|
| 275 |
+
code: error.code,
|
| 276 |
+
type: error.type,
|
| 277 |
+
message: error.message
|
| 278 |
+
})
|
| 279 |
+
} else if (error instanceof Error) {
|
| 280 |
+
logger.error('代码生成者失败', {
|
| 281 |
+
concept,
|
| 282 |
+
errorName: error.name,
|
| 283 |
+
errorMessage: error.message
|
| 284 |
+
})
|
| 285 |
+
} else {
|
| 286 |
+
logger.error('代码生成者失败(未知错误)', { concept, error: String(error) })
|
| 287 |
+
}
|
| 288 |
+
return ''
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
/**
|
| 294 |
+
* 两阶段 AI 生成
|
| 295 |
+
* 1. 设计者生成场景设计方案
|
| 296 |
+
* 2. 代码生成者根据设计方案生成代码
|
| 297 |
+
*/
|
| 298 |
+
export async function generateTwoStageAIManimCode(
|
| 299 |
+
concept: string,
|
| 300 |
+
customApiConfig?: CustomApiConfig,
|
| 301 |
+
promptOverrides?: PromptOverrides
|
| 302 |
+
): Promise<{ code: string; sceneDesign: string }>
|
| 303 |
+
{
|
| 304 |
+
logger.info('开始两阶段 AI 生成流程', { concept })
|
| 305 |
+
|
| 306 |
+
// 阶段1:生成场景设计方案
|
| 307 |
+
const sceneDesign = await generateSceneDesign(concept, customApiConfig, promptOverrides)
|
| 308 |
+
|
| 309 |
+
if (!sceneDesign) {
|
| 310 |
+
logger.warn('场景设计方案生成失败,中止流程')
|
| 311 |
+
return { code: '', sceneDesign: '' }
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
// 阶段2:根据设计方案生成代码
|
| 315 |
+
const code = await generateCodeFromDesign(concept, sceneDesign, customApiConfig, promptOverrides)
|
| 316 |
+
|
| 317 |
+
logger.info('两阶段 AI 生成流程完成', {
|
| 318 |
+
concept,
|
| 319 |
+
hasSceneDesign: !!sceneDesign,
|
| 320 |
+
hasCode: !!code
|
| 321 |
+
})
|
| 322 |
+
|
| 323 |
+
return { code, sceneDesign }
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
/**
|
| 327 |
+
* 检查 OpenAI 客户端是否可用
|
| 328 |
+
*/
|
| 329 |
+
export function isOpenAIAvailable(): boolean {
|
| 330 |
+
return openaiClient !== null
|
| 331 |
+
}
|
src/services/job-cancel-store.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Job Cancel Store
|
| 3 |
+
* 取消状态存取
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
import { redisClient, REDIS_KEYS, generateRedisKey } from '../config/redis'
|
| 7 |
+
|
| 8 |
+
const JOB_CANCEL_KEY_PREFIX = `${REDIS_KEYS.JOB_CANCEL}`
|
| 9 |
+
const CANCEL_TTL_SECONDS = 7 * 24 * 60 * 60
|
| 10 |
+
|
| 11 |
+
export async function markJobCancelled(jobId: string, reason: string = 'Job cancelled'): Promise<void> {
|
| 12 |
+
const key = generateRedisKey(JOB_CANCEL_KEY_PREFIX, jobId)
|
| 13 |
+
const payload = {
|
| 14 |
+
jobId,
|
| 15 |
+
reason,
|
| 16 |
+
timestamp: Date.now()
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
await redisClient.set(key, JSON.stringify(payload))
|
| 20 |
+
await redisClient.expire(key, CANCEL_TTL_SECONDS)
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
export async function isJobCancelled(jobId: string): Promise<boolean> {
|
| 24 |
+
const key = generateRedisKey(JOB_CANCEL_KEY_PREFIX, jobId)
|
| 25 |
+
return (await redisClient.get(key)) !== null
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
export async function clearJobCancelled(jobId: string): Promise<void> {
|
| 29 |
+
const key = generateRedisKey(JOB_CANCEL_KEY_PREFIX, jobId)
|
| 30 |
+
await redisClient.del(key)
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
export async function getCancelReason(jobId: string): Promise<string | null> {
|
| 34 |
+
const key = generateRedisKey(JOB_CANCEL_KEY_PREFIX, jobId)
|
| 35 |
+
const data = await redisClient.get(key)
|
| 36 |
+
|
| 37 |
+
if (!data) {
|
| 38 |
+
return null
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
try {
|
| 42 |
+
const parsed = JSON.parse(data) as { reason?: string }
|
| 43 |
+
return parsed.reason || null
|
| 44 |
+
} catch {
|
| 45 |
+
return null
|
| 46 |
+
}
|
| 47 |
+
}
|
src/services/job-cancel.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Job Cancel Service
|
| 3 |
+
* 任务取消逻辑
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
import { videoQueue } from '../config/bull'
|
| 7 |
+
import { createLogger } from '../utils/logger'
|
| 8 |
+
import { JobCancelledError } from '../utils/errors'
|
| 9 |
+
import { clearJobCancelled, getCancelReason, isJobCancelled, markJobCancelled } from './job-cancel-store'
|
| 10 |
+
import { cancelManimProcess } from '../utils/manim-process-registry'
|
| 11 |
+
import { deleteJobStage, getJobResult, storeJobResult } from './job-store'
|
| 12 |
+
|
| 13 |
+
const logger = createLogger('JobCancel')
|
| 14 |
+
export async function ensureJobNotCancelled(jobId: string, job?: { discard: () => void }): Promise<void> {
|
| 15 |
+
if (!(await isJobCancelled(jobId))) {
|
| 16 |
+
return
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
try {
|
| 20 |
+
job?.discard()
|
| 21 |
+
} catch (error) {
|
| 22 |
+
logger.warn('Failed to discard cancelled job', { jobId, error })
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
const reason = await getCancelReason(jobId)
|
| 26 |
+
throw new JobCancelledError('Job cancelled', reason || undefined)
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
export async function cancelJob(jobId: string): Promise<{ jobState: string | null }> {
|
| 30 |
+
const existing = await getJobResult(jobId)
|
| 31 |
+
if (existing?.status === 'completed') {
|
| 32 |
+
return { jobState: 'completed' }
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
const cancelReason = 'Cancelled by client'
|
| 36 |
+
await markJobCancelled(jobId, cancelReason)
|
| 37 |
+
|
| 38 |
+
let jobState: string | null = null
|
| 39 |
+
const job = await videoQueue.getJob(jobId)
|
| 40 |
+
|
| 41 |
+
if (job) {
|
| 42 |
+
jobState = await job.getState()
|
| 43 |
+
|
| 44 |
+
if (jobState === 'waiting' || jobState === 'delayed') {
|
| 45 |
+
await job.remove()
|
| 46 |
+
await clearJobCancelled(jobId)
|
| 47 |
+
logger.info('Removed pending job', { jobId, jobState })
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
if (jobState === 'active') {
|
| 51 |
+
const killed = cancelManimProcess(jobId)
|
| 52 |
+
logger.info('Signaled active job cancellation', { jobId, killed })
|
| 53 |
+
}
|
| 54 |
+
} else {
|
| 55 |
+
await clearJobCancelled(jobId)
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
if (!existing || existing.status != 'failed') {
|
| 59 |
+
await storeJobResult(jobId, {
|
| 60 |
+
status: 'failed',
|
| 61 |
+
data: { error: 'Job cancelled', cancelReason }
|
| 62 |
+
})
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
await deleteJobStage(jobId)
|
| 66 |
+
|
| 67 |
+
return { jobState }
|
| 68 |
+
}
|
src/services/job-store.ts
CHANGED
|
@@ -1,188 +1,188 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* 任务存储服务
|
| 3 |
-
* 改造点:
|
| 4 |
-
* -
|
| 5 |
-
* - 使用 ioredis 直接操作 Redis
|
| 6 |
-
* - 接口保持兼容,方便业务代码无感知迁移
|
| 7 |
-
* - 支持 stage 存储,用于前端显示精确的处理阶段
|
| 8 |
-
*/
|
| 9 |
-
|
| 10 |
-
import { redisClient, REDIS_KEYS, generateRedisKey } from '../config/redis'
|
| 11 |
-
import { videoQueue } from '../config/bull'
|
| 12 |
-
import { createLogger } from '../utils/logger'
|
| 13 |
-
import type { JobResult, ProcessingStage } from '../types'
|
| 14 |
-
|
| 15 |
-
const logger = createLogger('JobStore')
|
| 16 |
-
|
| 17 |
-
const JOB_RESULTS_GROUP = 'job-results'
|
| 18 |
-
const JOB_RESULT_KEY_PREFIX = `${REDIS_KEYS.JOB_RESULT}`
|
| 19 |
-
const JOB_STAGE_KEY_PREFIX = `${REDIS_KEYS.JOB_RESULT}:stage`
|
| 20 |
-
|
| 21 |
-
/**
|
| 22 |
-
* 使用 Redis 存储任务结果
|
| 23 |
-
*/
|
| 24 |
-
export async function storeJobResult(
|
| 25 |
-
jobId: string,
|
| 26 |
-
result: Omit<JobResult, 'timestamp'>
|
| 27 |
-
): Promise<void> {
|
| 28 |
-
const key = generateRedisKey(JOB_RESULT_KEY_PREFIX, jobId)
|
| 29 |
-
const data = {
|
| 30 |
-
...result,
|
| 31 |
-
timestamp: Date.now()
|
| 32 |
-
}
|
| 33 |
-
|
| 34 |
-
try {
|
| 35 |
-
await redisClient.set(key, JSON.stringify(data))
|
| 36 |
-
// 设置过期时间:7 天后自动清理
|
| 37 |
-
await redisClient.expire(key, 7 * 24 * 60 * 60)
|
| 38 |
-
logger.info('任务结果已存储', { jobId, status: result.status })
|
| 39 |
-
} catch (error) {
|
| 40 |
-
logger.error('存储任务结果失败', { jobId, error })
|
| 41 |
-
throw error
|
| 42 |
-
}
|
| 43 |
-
}
|
| 44 |
-
|
| 45 |
-
/**
|
| 46 |
-
* 从 Redis 获取任务结果
|
| 47 |
-
*/
|
| 48 |
-
export async function getJobResult(
|
| 49 |
-
jobId: string
|
| 50 |
-
): Promise<JobResult | null> {
|
| 51 |
-
const key = generateRedisKey(JOB_RESULT_KEY_PREFIX, jobId)
|
| 52 |
-
|
| 53 |
-
try {
|
| 54 |
-
const data = await redisClient.get(key)
|
| 55 |
-
if (!data) {
|
| 56 |
-
return null
|
| 57 |
-
}
|
| 58 |
-
return JSON.parse(data) as JobResult
|
| 59 |
-
} catch (error) {
|
| 60 |
-
logger.error('获取任务结果失败', { jobId, error })
|
| 61 |
-
return null
|
| 62 |
-
}
|
| 63 |
-
}
|
| 64 |
-
|
| 65 |
-
/**
|
| 66 |
-
* 获取 Bull 任务状态
|
| 67 |
-
* 返回任务在队列中的状态
|
| 68 |
-
*/
|
| 69 |
-
export async function getBullJobStatus(
|
| 70 |
-
jobId: string
|
| 71 |
-
): Promise<'waiting' | 'active' | 'completed' | 'failed' | 'delayed' | null> {
|
| 72 |
-
try {
|
| 73 |
-
const job = await videoQueue.getJob(jobId)
|
| 74 |
-
if (!job) {
|
| 75 |
-
return null
|
| 76 |
-
}
|
| 77 |
-
const state = await job.getState()
|
| 78 |
-
// Bull 可能返回 'paused' 等状态,过滤掉
|
| 79 |
-
if (state === 'paused') {
|
| 80 |
-
return 'waiting'
|
| 81 |
-
}
|
| 82 |
-
// 只返回我们关心的状态
|
| 83 |
-
if (state === 'waiting' || state === 'active' || state === 'completed' || state === 'failed' || state === 'delayed') {
|
| 84 |
-
return state
|
| 85 |
-
}
|
| 86 |
-
return null
|
| 87 |
-
} catch (error) {
|
| 88 |
-
logger.error('获取 Bull 任务状态失败', { jobId, error })
|
| 89 |
-
return null
|
| 90 |
-
}
|
| 91 |
-
}
|
| 92 |
-
|
| 93 |
-
/**
|
| 94 |
-
* 从 Redis 删除任务结果
|
| 95 |
-
*/
|
| 96 |
-
export async function deleteJobResult(
|
| 97 |
-
jobId: string
|
| 98 |
-
): Promise<void> {
|
| 99 |
-
const key = generateRedisKey(JOB_RESULT_KEY_PREFIX, jobId)
|
| 100 |
-
|
| 101 |
-
try {
|
| 102 |
-
await redisClient.del(key)
|
| 103 |
-
logger.info('任务结果已删除', { jobId })
|
| 104 |
-
} catch (error) {
|
| 105 |
-
logger.error('删除任务结果失败', { jobId, error })
|
| 106 |
-
throw error
|
| 107 |
-
}
|
| 108 |
-
}
|
| 109 |
-
|
| 110 |
-
/**
|
| 111 |
-
* 获取所有任务结果(用于调试/管理)
|
| 112 |
-
*/
|
| 113 |
-
export async function getAllJobResults(): Promise<Array<{ jobId: string; result: JobResult }>> {
|
| 114 |
-
try {
|
| 115 |
-
const keys = await redisClient.keys(`${JOB_RESULT_KEY_PREFIX}*`)
|
| 116 |
-
const results: Array<{ jobId: string; result: JobResult }> = []
|
| 117 |
-
|
| 118 |
-
for (const key of keys) {
|
| 119 |
-
const data = await redisClient.get(key)
|
| 120 |
-
if (data) {
|
| 121 |
-
const jobId = key.substring(JOB_RESULT_KEY_PREFIX.length)
|
| 122 |
-
results.push({ jobId, result: JSON.parse(data) as JobResult })
|
| 123 |
-
}
|
| 124 |
-
}
|
| 125 |
-
|
| 126 |
-
return results
|
| 127 |
-
} catch (error) {
|
| 128 |
-
logger.error('获取所有任务结果失败', { error })
|
| 129 |
-
return []
|
| 130 |
-
}
|
| 131 |
-
}
|
| 132 |
-
|
| 133 |
-
/**
|
| 134 |
-
* 存储任务处理阶段
|
| 135 |
-
*/
|
| 136 |
-
export async function storeJobStage(
|
| 137 |
-
jobId: string,
|
| 138 |
-
stage: ProcessingStage
|
| 139 |
-
): Promise<void> {
|
| 140 |
-
const key = generateRedisKey(JOB_STAGE_KEY_PREFIX, jobId)
|
| 141 |
-
|
| 142 |
-
try {
|
| 143 |
-
await redisClient.set(key, stage)
|
| 144 |
-
// 设置过期时间:与 job result 相同,7 天后自动清理
|
| 145 |
-
await redisClient.expire(key, 7 * 24 * 60 * 60)
|
| 146 |
-
logger.debug('任务阶段已存储', { jobId, stage })
|
| 147 |
-
} catch (error) {
|
| 148 |
-
logger.error('存储任务阶段失败', { jobId, error })
|
| 149 |
-
throw error
|
| 150 |
-
}
|
| 151 |
-
}
|
| 152 |
-
|
| 153 |
-
/**
|
| 154 |
-
* 获取任务处理阶段
|
| 155 |
-
*/
|
| 156 |
-
export async function getJobStage(
|
| 157 |
-
jobId: string
|
| 158 |
-
): Promise<ProcessingStage | null> {
|
| 159 |
-
const key = generateRedisKey(JOB_STAGE_KEY_PREFIX, jobId)
|
| 160 |
-
|
| 161 |
-
try {
|
| 162 |
-
const stage = await redisClient.get(key)
|
| 163 |
-
if (!stage) {
|
| 164 |
-
return null
|
| 165 |
-
}
|
| 166 |
-
return stage as ProcessingStage
|
| 167 |
-
} catch (error) {
|
| 168 |
-
logger.error('获取任务阶段失败', { jobId, error })
|
| 169 |
-
return null
|
| 170 |
-
}
|
| 171 |
-
}
|
| 172 |
-
|
| 173 |
-
/**
|
| 174 |
-
* 删除任务阶段
|
| 175 |
-
*/
|
| 176 |
-
export async function deleteJobStage(
|
| 177 |
-
jobId: string
|
| 178 |
-
): Promise<void> {
|
| 179 |
-
const key = generateRedisKey(JOB_STAGE_KEY_PREFIX, jobId)
|
| 180 |
-
|
| 181 |
-
try {
|
| 182 |
-
await redisClient.del(key)
|
| 183 |
-
logger.debug('任务阶段已删除', { jobId })
|
| 184 |
-
} catch (error) {
|
| 185 |
-
logger.error('删除任务
|
| 186 |
-
throw error
|
| 187 |
-
}
|
| 188 |
}
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* 任务存储服务
|
| 3 |
+
* 改造点:
|
| 4 |
+
* - ���除 InternalStateManager 依赖
|
| 5 |
+
* - 使用 ioredis 直接操作 Redis
|
| 6 |
+
* - 接口保持兼容,方便业务代码无感知迁移
|
| 7 |
+
* - 支持 stage 存储,用于前端显示精确的处理阶段
|
| 8 |
+
*/
|
| 9 |
+
|
| 10 |
+
import { redisClient, REDIS_KEYS, generateRedisKey } from '../config/redis'
|
| 11 |
+
import { videoQueue } from '../config/bull'
|
| 12 |
+
import { createLogger } from '../utils/logger'
|
| 13 |
+
import type { JobResult, ProcessingStage } from '../types'
|
| 14 |
+
|
| 15 |
+
const logger = createLogger('JobStore')
|
| 16 |
+
|
| 17 |
+
const JOB_RESULTS_GROUP = 'job-results'
|
| 18 |
+
const JOB_RESULT_KEY_PREFIX = `${REDIS_KEYS.JOB_RESULT}`
|
| 19 |
+
const JOB_STAGE_KEY_PREFIX = `${REDIS_KEYS.JOB_RESULT}:stage`
|
| 20 |
+
|
| 21 |
+
/**
|
| 22 |
+
* 使用 Redis 存储任务结果
|
| 23 |
+
*/
|
| 24 |
+
export async function storeJobResult(
|
| 25 |
+
jobId: string,
|
| 26 |
+
result: Omit<JobResult, 'timestamp'>
|
| 27 |
+
): Promise<void> {
|
| 28 |
+
const key = generateRedisKey(JOB_RESULT_KEY_PREFIX, jobId)
|
| 29 |
+
const data = {
|
| 30 |
+
...result,
|
| 31 |
+
timestamp: Date.now()
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
try {
|
| 35 |
+
await redisClient.set(key, JSON.stringify(data))
|
| 36 |
+
// 设置过期时间:7 天后自动清理
|
| 37 |
+
await redisClient.expire(key, 7 * 24 * 60 * 60)
|
| 38 |
+
logger.info('任务结果已存储', { jobId, status: result.status })
|
| 39 |
+
} catch (error) {
|
| 40 |
+
logger.error('存储任务结果失败', { jobId, error })
|
| 41 |
+
throw error
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
/**
|
| 46 |
+
* 从 Redis 获取任务结果
|
| 47 |
+
*/
|
| 48 |
+
export async function getJobResult(
|
| 49 |
+
jobId: string
|
| 50 |
+
): Promise<JobResult | null> {
|
| 51 |
+
const key = generateRedisKey(JOB_RESULT_KEY_PREFIX, jobId)
|
| 52 |
+
|
| 53 |
+
try {
|
| 54 |
+
const data = await redisClient.get(key)
|
| 55 |
+
if (!data) {
|
| 56 |
+
return null
|
| 57 |
+
}
|
| 58 |
+
return JSON.parse(data) as JobResult
|
| 59 |
+
} catch (error) {
|
| 60 |
+
logger.error('获取任务结果失败', { jobId, error })
|
| 61 |
+
return null
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
/**
|
| 66 |
+
* 获取 Bull 任务状态
|
| 67 |
+
* 返回任务在队列中的状态
|
| 68 |
+
*/
|
| 69 |
+
export async function getBullJobStatus(
|
| 70 |
+
jobId: string
|
| 71 |
+
): Promise<'waiting' | 'active' | 'completed' | 'failed' | 'delayed' | null> {
|
| 72 |
+
try {
|
| 73 |
+
const job = await videoQueue.getJob(jobId)
|
| 74 |
+
if (!job) {
|
| 75 |
+
return null
|
| 76 |
+
}
|
| 77 |
+
const state = await job.getState()
|
| 78 |
+
// Bull 可能返回 'paused' 等状态,过滤掉
|
| 79 |
+
if (state === 'paused') {
|
| 80 |
+
return 'waiting'
|
| 81 |
+
}
|
| 82 |
+
// 只返回我们关心的状态
|
| 83 |
+
if (state === 'waiting' || state === 'active' || state === 'completed' || state === 'failed' || state === 'delayed') {
|
| 84 |
+
return state
|
| 85 |
+
}
|
| 86 |
+
return null
|
| 87 |
+
} catch (error) {
|
| 88 |
+
logger.error('获取 Bull 任务状态失败', { jobId, error })
|
| 89 |
+
return null
|
| 90 |
+
}
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
/**
|
| 94 |
+
* 从 Redis 删除任务结果
|
| 95 |
+
*/
|
| 96 |
+
export async function deleteJobResult(
|
| 97 |
+
jobId: string
|
| 98 |
+
): Promise<void> {
|
| 99 |
+
const key = generateRedisKey(JOB_RESULT_KEY_PREFIX, jobId)
|
| 100 |
+
|
| 101 |
+
try {
|
| 102 |
+
await redisClient.del(key)
|
| 103 |
+
logger.info('任务结果已删除', { jobId })
|
| 104 |
+
} catch (error) {
|
| 105 |
+
logger.error('删除任务结果失败', { jobId, error })
|
| 106 |
+
throw error
|
| 107 |
+
}
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
/**
|
| 111 |
+
* 获取所有任务结果(用于调试/管理)
|
| 112 |
+
*/
|
| 113 |
+
export async function getAllJobResults(): Promise<Array<{ jobId: string; result: JobResult }>> {
|
| 114 |
+
try {
|
| 115 |
+
const keys = await redisClient.keys(`${JOB_RESULT_KEY_PREFIX}*`)
|
| 116 |
+
const results: Array<{ jobId: string; result: JobResult }> = []
|
| 117 |
+
|
| 118 |
+
for (const key of keys) {
|
| 119 |
+
const data = await redisClient.get(key)
|
| 120 |
+
if (data) {
|
| 121 |
+
const jobId = key.substring(JOB_RESULT_KEY_PREFIX.length)
|
| 122 |
+
results.push({ jobId, result: JSON.parse(data) as JobResult })
|
| 123 |
+
}
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
return results
|
| 127 |
+
} catch (error) {
|
| 128 |
+
logger.error('获取所有任务结果失败', { error })
|
| 129 |
+
return []
|
| 130 |
+
}
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
/**
|
| 134 |
+
* 存储任务处理阶段
|
| 135 |
+
*/
|
| 136 |
+
export async function storeJobStage(
|
| 137 |
+
jobId: string,
|
| 138 |
+
stage: ProcessingStage
|
| 139 |
+
): Promise<void> {
|
| 140 |
+
const key = generateRedisKey(JOB_STAGE_KEY_PREFIX, jobId)
|
| 141 |
+
|
| 142 |
+
try {
|
| 143 |
+
await redisClient.set(key, stage)
|
| 144 |
+
// 设置过期时间:与 job result 相同,7 天后自动清理
|
| 145 |
+
await redisClient.expire(key, 7 * 24 * 60 * 60)
|
| 146 |
+
logger.debug('任务阶段已存储', { jobId, stage })
|
| 147 |
+
} catch (error) {
|
| 148 |
+
logger.error('存储任务阶段失败', { jobId, error })
|
| 149 |
+
throw error
|
| 150 |
+
}
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
/**
|
| 154 |
+
* 获取任务处理阶段
|
| 155 |
+
*/
|
| 156 |
+
export async function getJobStage(
|
| 157 |
+
jobId: string
|
| 158 |
+
): Promise<ProcessingStage | null> {
|
| 159 |
+
const key = generateRedisKey(JOB_STAGE_KEY_PREFIX, jobId)
|
| 160 |
+
|
| 161 |
+
try {
|
| 162 |
+
const stage = await redisClient.get(key)
|
| 163 |
+
if (!stage) {
|
| 164 |
+
return null
|
| 165 |
+
}
|
| 166 |
+
return stage as ProcessingStage
|
| 167 |
+
} catch (error) {
|
| 168 |
+
logger.error('获取任务阶段失败', { jobId, error })
|
| 169 |
+
return null
|
| 170 |
+
}
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
/**
|
| 174 |
+
* 删除任务阶段
|
| 175 |
+
*/
|
| 176 |
+
export async function deleteJobStage(
|
| 177 |
+
jobId: string
|
| 178 |
+
): Promise<void> {
|
| 179 |
+
const key = generateRedisKey(JOB_STAGE_KEY_PREFIX, jobId)
|
| 180 |
+
|
| 181 |
+
try {
|
| 182 |
+
await redisClient.del(key)
|
| 183 |
+
logger.debug('任务阶段已删除', { jobId })
|
| 184 |
+
} catch (error) {
|
| 185 |
+
logger.error('删除任务���段失败', { jobId, error })
|
| 186 |
+
throw error
|
| 187 |
+
}
|
| 188 |
}
|
src/types/index.ts
CHANGED
|
@@ -42,6 +42,23 @@ export interface CustomApiConfig {
|
|
| 42 |
model: string
|
| 43 |
}
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
/**
|
| 46 |
* 视频生成任务数据
|
| 47 |
*/
|
|
@@ -83,6 +100,7 @@ export interface FailedJobResult {
|
|
| 83 |
data: {
|
| 84 |
error: string
|
| 85 |
details?: string
|
|
|
|
| 86 |
}
|
| 87 |
timestamp: number
|
| 88 |
}
|
|
@@ -114,6 +132,7 @@ export interface GenerateRequest {
|
|
| 114 |
concept: string
|
| 115 |
quality?: VideoQuality
|
| 116 |
forceRefresh?: boolean
|
|
|
|
| 117 |
}
|
| 118 |
|
| 119 |
/**
|
|
@@ -162,6 +181,7 @@ export interface JobStatusFailedResponse {
|
|
| 162 |
success: false
|
| 163 |
error: string
|
| 164 |
details?: string
|
|
|
|
| 165 |
}
|
| 166 |
|
| 167 |
/**
|
|
|
|
| 42 |
model: string
|
| 43 |
}
|
| 44 |
|
| 45 |
+
/**
|
| 46 |
+
* Prompt overrides for generation stages
|
| 47 |
+
*/
|
| 48 |
+
export interface PromptOverrides {
|
| 49 |
+
system?: {
|
| 50 |
+
conceptDesigner?: string
|
| 51 |
+
codeGeneration?: string
|
| 52 |
+
codeRetry?: string
|
| 53 |
+
}
|
| 54 |
+
user?: {
|
| 55 |
+
conceptDesigner?: string
|
| 56 |
+
codeGeneration?: string
|
| 57 |
+
codeRetryInitial?: string
|
| 58 |
+
codeRetryFix?: string
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
/**
|
| 63 |
* 视频生成任务数据
|
| 64 |
*/
|
|
|
|
| 100 |
data: {
|
| 101 |
error: string
|
| 102 |
details?: string
|
| 103 |
+
cancelReason?: string
|
| 104 |
}
|
| 105 |
timestamp: number
|
| 106 |
}
|
|
|
|
| 132 |
concept: string
|
| 133 |
quality?: VideoQuality
|
| 134 |
forceRefresh?: boolean
|
| 135 |
+
promptOverrides?: PromptOverrides
|
| 136 |
}
|
| 137 |
|
| 138 |
/**
|
|
|
|
| 181 |
success: false
|
| 182 |
error: string
|
| 183 |
details?: string
|
| 184 |
+
cancel_reason?: string
|
| 185 |
}
|
| 186 |
|
| 187 |
/**
|
src/utils/errors.ts
CHANGED
|
@@ -121,6 +121,16 @@ export class TimeoutError extends AppError {
|
|
| 121 |
}
|
| 122 |
}
|
| 123 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
/**
|
| 125 |
* 判断是否为应用错误
|
| 126 |
*/
|
|
|
|
| 121 |
}
|
| 122 |
}
|
| 123 |
|
| 124 |
+
/**
|
| 125 |
+
* 任务取消错误 499
|
| 126 |
+
*/
|
| 127 |
+
export class JobCancelledError extends AppError {
|
| 128 |
+
constructor(message: string = 'Job cancelled', details?: any) {
|
| 129 |
+
super(message, 499, true, details)
|
| 130 |
+
this.name = 'JobCancelledError'
|
| 131 |
+
}
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
/**
|
| 135 |
* 判断是否为应用错误
|
| 136 |
*/
|
src/utils/manim-executor.ts
CHANGED
|
@@ -1,223 +1,535 @@
|
|
| 1 |
-
/**
|
| 2 |
-
|
| 3 |
-
*
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
return
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
}
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
|
| 3 |
+
* Manim 执行器
|
| 4 |
+
|
| 5 |
+
* 执行 Manim 命令,管理子进程
|
| 6 |
+
|
| 7 |
+
*/
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
import { spawn } from 'child_process'
|
| 12 |
+
import { promises as fs } from 'fs'
|
| 13 |
+
|
| 14 |
+
import { createLogger } from './logger'
|
| 15 |
+
|
| 16 |
+
import { registerManimProcess, unregisterManimProcess, wasManimProcessCancelled } from './manim-process-registry'
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
const logger = createLogger('ManimExecutor')
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
/**
|
| 25 |
+
|
| 26 |
+
* Manim 执行结果
|
| 27 |
+
|
| 28 |
+
*/
|
| 29 |
+
|
| 30 |
+
export interface ManimExecutionResult {
|
| 31 |
+
|
| 32 |
+
success: boolean
|
| 33 |
+
|
| 34 |
+
stdout: string
|
| 35 |
+
|
| 36 |
+
stderr: string
|
| 37 |
+
|
| 38 |
+
peakMemoryMB: number
|
| 39 |
+
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
/**
|
| 45 |
+
|
| 46 |
+
* Manim 执行选项
|
| 47 |
+
|
| 48 |
+
*/
|
| 49 |
+
|
| 50 |
+
export interface ManimExecuteOptions {
|
| 51 |
+
|
| 52 |
+
jobId: string
|
| 53 |
+
|
| 54 |
+
quality: string
|
| 55 |
+
|
| 56 |
+
frameRate: number
|
| 57 |
+
|
| 58 |
+
tempDir: string
|
| 59 |
+
|
| 60 |
+
mediaDir: string
|
| 61 |
+
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
/**
|
| 67 |
+
|
| 68 |
+
* 获取进程的内存使用情况(MB)
|
| 69 |
+
|
| 70 |
+
*/
|
| 71 |
+
|
| 72 |
+
export async function getProcessMemory(pid: number): Promise<number | null> {
|
| 73 |
+
const platform = process.platform
|
| 74 |
+
|
| 75 |
+
if (platform === 'linux') {
|
| 76 |
+
return getLinuxProcessTreeMemory(pid)
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
if (platform === 'win32') {
|
| 80 |
+
return getWindowsProcessMemory(pid)
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
return getUnixProcessMemory(pid)
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
async function getLinuxProcessTreeMemory(pid: number): Promise<number | null> {
|
| 87 |
+
const visited = new Set<number>()
|
| 88 |
+
const queue: number[] = [pid]
|
| 89 |
+
let totalKb = 0
|
| 90 |
+
|
| 91 |
+
while (queue.length > 0) {
|
| 92 |
+
const current = queue.shift()
|
| 93 |
+
if (!current || visited.has(current)) {
|
| 94 |
+
continue
|
| 95 |
+
}
|
| 96 |
+
visited.add(current)
|
| 97 |
+
|
| 98 |
+
const rssKb = await readLinuxVmRssKb(current)
|
| 99 |
+
if (rssKb) {
|
| 100 |
+
totalKb += rssKb
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
const children = await readLinuxChildPids(current)
|
| 104 |
+
for (const child of children) {
|
| 105 |
+
if (!visited.has(child)) {
|
| 106 |
+
queue.push(child)
|
| 107 |
+
}
|
| 108 |
+
}
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
if (!totalKb) {
|
| 112 |
+
return null
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
return Math.round(totalKb / 1024)
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
async function readLinuxVmRssKb(pid: number): Promise<number | null> {
|
| 119 |
+
try {
|
| 120 |
+
const status = await fs.readFile(`/proc/${pid}/status`, 'utf-8')
|
| 121 |
+
const line = status.split(/\r?\n/).find((entry) => entry.startsWith('VmRSS:'))
|
| 122 |
+
if (!line) {
|
| 123 |
+
return null
|
| 124 |
+
}
|
| 125 |
+
const match = line.match(/VmRSS:\s+(\d+)/)
|
| 126 |
+
if (!match) {
|
| 127 |
+
return null
|
| 128 |
+
}
|
| 129 |
+
return parseInt(match[1], 10)
|
| 130 |
+
} catch {
|
| 131 |
+
return null
|
| 132 |
+
}
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
async function readLinuxChildPids(pid: number): Promise<number[]> {
|
| 136 |
+
try {
|
| 137 |
+
const children = await fs.readFile(`/proc/${pid}/task/${pid}/children`, 'utf-8')
|
| 138 |
+
if (!children.trim()) {
|
| 139 |
+
return []
|
| 140 |
+
}
|
| 141 |
+
return children
|
| 142 |
+
.trim()
|
| 143 |
+
.split(/\s+/)
|
| 144 |
+
.map((value) => parseInt(value, 10))
|
| 145 |
+
.filter((value) => !Number.isNaN(value))
|
| 146 |
+
} catch {
|
| 147 |
+
return []
|
| 148 |
+
}
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
async function getWindowsProcessMemory(pid: number): Promise<number | null> {
|
| 152 |
+
return new Promise((resolve) => {
|
| 153 |
+
spawn('wmic', ['process', 'where', `ProcessId=${pid}`, 'get', 'WorkingSetSize', '/value'])
|
| 154 |
+
.stdout.on('data', (data) => {
|
| 155 |
+
const output = data.toString()
|
| 156 |
+
const match = output.match(/WorkingSetSize=(\d+)/)
|
| 157 |
+
if (match) {
|
| 158 |
+
const bytes = parseInt(match[1], 10)
|
| 159 |
+
resolve(Math.round(bytes / 1024 / 1024))
|
| 160 |
+
} else {
|
| 161 |
+
resolve(null)
|
| 162 |
+
}
|
| 163 |
+
})
|
| 164 |
+
.on('error', () => resolve(null))
|
| 165 |
+
.on('close', () => resolve(null))
|
| 166 |
+
})
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
async function getUnixProcessMemory(pid: number): Promise<number | null> {
|
| 170 |
+
return new Promise((resolve) => {
|
| 171 |
+
spawn('ps', ['-o', 'rss=', '-p', pid.toString()])
|
| 172 |
+
.stdout.on('data', (data) => {
|
| 173 |
+
const output = data.toString().trim()
|
| 174 |
+
if (output) {
|
| 175 |
+
const kb = parseInt(output, 10)
|
| 176 |
+
resolve(Math.round(kb / 1024))
|
| 177 |
+
} else {
|
| 178 |
+
resolve(null)
|
| 179 |
+
}
|
| 180 |
+
})
|
| 181 |
+
.on('error', () => resolve(null))
|
| 182 |
+
.on('close', () => resolve(null))
|
| 183 |
+
})
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
/**
|
| 189 |
+
|
| 190 |
+
* 执行 manim 命令
|
| 191 |
+
|
| 192 |
+
*/
|
| 193 |
+
|
| 194 |
+
export function executeManimCommand(
|
| 195 |
+
|
| 196 |
+
codeFile: string,
|
| 197 |
+
|
| 198 |
+
options: ManimExecuteOptions
|
| 199 |
+
|
| 200 |
+
): Promise<ManimExecutionResult> {
|
| 201 |
+
|
| 202 |
+
const { jobId, quality, frameRate, tempDir, mediaDir } = options
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
return new Promise((resolve) => {
|
| 207 |
+
|
| 208 |
+
const startTime = Date.now()
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
// 质量对应的分辨率
|
| 213 |
+
|
| 214 |
+
const resolutionMap: Record<string, { width: number; height: number }> = {
|
| 215 |
+
|
| 216 |
+
low: { width: 854, height: 480 },
|
| 217 |
+
|
| 218 |
+
medium: { width: 1280, height: 720 },
|
| 219 |
+
|
| 220 |
+
high: { width: 1920, height: 1080 }
|
| 221 |
+
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
const resolution = resolutionMap[quality] || resolutionMap.medium
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
const args = [
|
| 231 |
+
|
| 232 |
+
'render',
|
| 233 |
+
|
| 234 |
+
'--format', 'mp4',
|
| 235 |
+
|
| 236 |
+
'-r', frameRate.toString(),
|
| 237 |
+
|
| 238 |
+
'--resolution', `${resolution.width},${resolution.height}`,
|
| 239 |
+
|
| 240 |
+
'--media_dir', mediaDir,
|
| 241 |
+
|
| 242 |
+
codeFile,
|
| 243 |
+
|
| 244 |
+
'MainScene'
|
| 245 |
+
|
| 246 |
+
]
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
logger.info(`Job ${jobId}: 启动 manim 进程`, {
|
| 251 |
+
|
| 252 |
+
command: `manim ${args.join(' ')}`,
|
| 253 |
+
|
| 254 |
+
cwd: tempDir
|
| 255 |
+
|
| 256 |
+
})
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
const proc = spawn('manim', args, { cwd: tempDir })
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
registerManimProcess(jobId, proc)
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
let stdout = ''
|
| 269 |
+
|
| 270 |
+
let stderr = ''
|
| 271 |
+
|
| 272 |
+
let lastProgressTime = Date.now()
|
| 273 |
+
|
| 274 |
+
let lastLogTime = Date.now()
|
| 275 |
+
|
| 276 |
+
let peakMemory = 0 // 峰值内存(MB)
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
// 内存监控定时器(每2秒检查一次)
|
| 281 |
+
|
| 282 |
+
const memoryMonitor = setInterval(async () => {
|
| 283 |
+
|
| 284 |
+
if (proc.pid) {
|
| 285 |
+
|
| 286 |
+
const memory = await getProcessMemory(proc.pid)
|
| 287 |
+
|
| 288 |
+
if (memory) {
|
| 289 |
+
|
| 290 |
+
if (memory > peakMemory) {
|
| 291 |
+
|
| 292 |
+
peakMemory = memory
|
| 293 |
+
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
logger.info(`Job ${jobId}: Manim 内存使用`, {
|
| 297 |
+
|
| 298 |
+
memoryMB: memory,
|
| 299 |
+
|
| 300 |
+
peakMemoryMB: peakMemory
|
| 301 |
+
|
| 302 |
+
})
|
| 303 |
+
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
}, 2000)
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
proc.stdout.on('data', (data) => {
|
| 313 |
+
|
| 314 |
+
const text = data.toString()
|
| 315 |
+
|
| 316 |
+
stdout += text
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
// 10 minutes timeout
|
| 321 |
+
|
| 322 |
+
const elapsed = Date.now() - lastLogTime
|
| 323 |
+
|
| 324 |
+
if (elapsed > 5000) {
|
| 325 |
+
|
| 326 |
+
logger.info(`Job ${jobId}: Manim 进度输出`, {
|
| 327 |
+
|
| 328 |
+
output: text.trim(),
|
| 329 |
+
|
| 330 |
+
totalOutputLength: stdout.length
|
| 331 |
+
|
| 332 |
+
})
|
| 333 |
+
|
| 334 |
+
lastLogTime = Date.now()
|
| 335 |
+
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
// 检测进度条更新(单独处理,更频繁)
|
| 341 |
+
|
| 342 |
+
if (text.includes('%') || text.includes('it/s')) {
|
| 343 |
+
|
| 344 |
+
const progressElapsed = Date.now() - lastProgressTime
|
| 345 |
+
|
| 346 |
+
if (progressElapsed > 3000) {
|
| 347 |
+
|
| 348 |
+
logger.info(`Job ${jobId}: 渲染进度`, { progress: text.trim() })
|
| 349 |
+
|
| 350 |
+
lastProgressTime = Date.now()
|
| 351 |
+
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
})
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
proc.stderr.on('data', (data) => {
|
| 361 |
+
|
| 362 |
+
const text = data.toString()
|
| 363 |
+
|
| 364 |
+
stderr += text
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
// 实时记录所有 stderr 输出(不论是否包含错误)
|
| 369 |
+
|
| 370 |
+
logger.info(`Job ${jobId}: Manim stderr 实时输出`, {
|
| 371 |
+
|
| 372 |
+
output: text.trim(),
|
| 373 |
+
|
| 374 |
+
totalStderrLength: stderr.length
|
| 375 |
+
|
| 376 |
+
})
|
| 377 |
+
|
| 378 |
+
})
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
|
| 382 |
+
// 10 minutes timeout
|
| 383 |
+
|
| 384 |
+
const timeout = setTimeout(() => {
|
| 385 |
+
|
| 386 |
+
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1)
|
| 387 |
+
|
| 388 |
+
logger.warn(`Job ${jobId}: Manim render timeout (${elapsed}s), killing process`, {
|
| 389 |
+
|
| 390 |
+
peakMemoryMB: peakMemory
|
| 391 |
+
|
| 392 |
+
})
|
| 393 |
+
|
| 394 |
+
clearInterval(memoryMonitor)
|
| 395 |
+
|
| 396 |
+
proc.kill('SIGKILL')
|
| 397 |
+
|
| 398 |
+
resolve({
|
| 399 |
+
|
| 400 |
+
success: false,
|
| 401 |
+
|
| 402 |
+
stdout,
|
| 403 |
+
|
| 404 |
+
stderr: stderr || 'Manim render timeout (10 minutes)',
|
| 405 |
+
|
| 406 |
+
peakMemoryMB: peakMemory
|
| 407 |
+
|
| 408 |
+
})
|
| 409 |
+
|
| 410 |
+
}, 10 * 60 * 1000)
|
| 411 |
+
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
proc.on('close', (code) => {
|
| 415 |
+
|
| 416 |
+
clearTimeout(timeout)
|
| 417 |
+
|
| 418 |
+
clearInterval(memoryMonitor)
|
| 419 |
+
|
| 420 |
+
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1)
|
| 421 |
+
|
| 422 |
+
const cancelled = wasManimProcessCancelled(jobId)
|
| 423 |
+
|
| 424 |
+
unregisterManimProcess(jobId)
|
| 425 |
+
|
| 426 |
+
|
| 427 |
+
|
| 428 |
+
if (cancelled) {
|
| 429 |
+
|
| 430 |
+
logger.warn(`Job ${jobId}: Manim cancelled`, { elapsed: `${elapsed}s` })
|
| 431 |
+
|
| 432 |
+
resolve({ success: false, stdout, stderr: 'Job cancelled', peakMemoryMB: peakMemory })
|
| 433 |
+
|
| 434 |
+
return
|
| 435 |
+
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
+
|
| 439 |
+
|
| 440 |
+
if (code == 0) {
|
| 441 |
+
|
| 442 |
+
logger.info(`Job ${jobId}: Manim 成功完成`, {
|
| 443 |
+
|
| 444 |
+
elapsed: `${elapsed}s`,
|
| 445 |
+
|
| 446 |
+
exitCode: code,
|
| 447 |
+
|
| 448 |
+
stdoutLength: stdout.length,
|
| 449 |
+
|
| 450 |
+
stderrLength: stderr.length,
|
| 451 |
+
|
| 452 |
+
peakMemoryMB: peakMemory
|
| 453 |
+
|
| 454 |
+
})
|
| 455 |
+
|
| 456 |
+
resolve({ success: true, stdout, stderr, peakMemoryMB: peakMemory })
|
| 457 |
+
|
| 458 |
+
} else {
|
| 459 |
+
|
| 460 |
+
logger.error(`Job ${jobId}: Manim 退出异常`, {
|
| 461 |
+
|
| 462 |
+
elapsed: `${elapsed}s`,
|
| 463 |
+
|
| 464 |
+
exitCode: code,
|
| 465 |
+
|
| 466 |
+
stdoutLength: stdout.length,
|
| 467 |
+
|
| 468 |
+
stderrLength: stderr.length,
|
| 469 |
+
|
| 470 |
+
stderrPreview: stderr.slice(-500),
|
| 471 |
+
|
| 472 |
+
peakMemoryMB: peakMemory
|
| 473 |
+
|
| 474 |
+
})
|
| 475 |
+
|
| 476 |
+
resolve({ success: false, stdout, stderr, peakMemoryMB: peakMemory })
|
| 477 |
+
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
})
|
| 481 |
+
|
| 482 |
+
|
| 483 |
+
|
| 484 |
+
proc.on('error', (error) => {
|
| 485 |
+
|
| 486 |
+
clearTimeout(timeout)
|
| 487 |
+
|
| 488 |
+
clearInterval(memoryMonitor)
|
| 489 |
+
|
| 490 |
+
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1)
|
| 491 |
+
|
| 492 |
+
const cancelled = wasManimProcessCancelled(jobId)
|
| 493 |
+
|
| 494 |
+
unregisterManimProcess(jobId)
|
| 495 |
+
|
| 496 |
+
|
| 497 |
+
|
| 498 |
+
if (cancelled) {
|
| 499 |
+
|
| 500 |
+
logger.warn(`Job ${jobId}: Manim cancelled`, { elapsed: `${elapsed}s` })
|
| 501 |
+
|
| 502 |
+
resolve({ success: false, stdout, stderr: 'Job cancelled', peakMemoryMB: peakMemory })
|
| 503 |
+
|
| 504 |
+
return
|
| 505 |
+
|
| 506 |
+
}
|
| 507 |
+
|
| 508 |
+
|
| 509 |
+
|
| 510 |
+
logger.error(`Job ${jobId}: Manim 进程启动失败`, {
|
| 511 |
+
|
| 512 |
+
elapsed: `${elapsed}s`,
|
| 513 |
+
|
| 514 |
+
errorMessage: error.message,
|
| 515 |
+
|
| 516 |
+
errorStack: error.stack
|
| 517 |
+
|
| 518 |
+
})
|
| 519 |
+
|
| 520 |
+
resolve({ success: false, stdout, stderr: error.message, peakMemoryMB: peakMemory })
|
| 521 |
+
|
| 522 |
+
})
|
| 523 |
+
|
| 524 |
+
})
|
| 525 |
+
|
| 526 |
+
}
|
| 527 |
+
|
| 528 |
+
|
| 529 |
+
|
| 530 |
+
|
| 531 |
+
|
| 532 |
+
|
| 533 |
+
|
| 534 |
+
|
| 535 |
+
|
src/utils/manim-process-registry.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Manim Process Registry
|
| 3 |
+
* Manim 子进程管理
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
import type { ChildProcess } from 'child_process'
|
| 7 |
+
|
| 8 |
+
const activeProcesses = new Map<string, { proc: ChildProcess; cancelled: boolean }>()
|
| 9 |
+
|
| 10 |
+
export function registerManimProcess(jobId: string, proc: ChildProcess): void {
|
| 11 |
+
activeProcesses.set(jobId, { proc, cancelled: false })
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export function unregisterManimProcess(jobId: string): void {
|
| 15 |
+
activeProcesses.delete(jobId)
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export function cancelManimProcess(jobId: string): boolean {
|
| 19 |
+
const entry = activeProcesses.get(jobId)
|
| 20 |
+
if (!entry) {
|
| 21 |
+
return false
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
entry.cancelled = true
|
| 25 |
+
|
| 26 |
+
try {
|
| 27 |
+
entry.proc.kill('SIGKILL')
|
| 28 |
+
} catch {
|
| 29 |
+
return false
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
return true
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
export function wasManimProcessCancelled(jobId: string): boolean {
|
| 36 |
+
return activeProcesses.get(jobId)?.cancelled ?? false
|
| 37 |
+
}
|
zeabur.json
CHANGED
|
@@ -1,119 +1,129 @@
|
|
| 1 |
-
{
|
| 2 |
-
"name": "manimcat",
|
| 3 |
-
"description": "ManimCat - AI-powered mathematical animation generator with Express.js, Bull Queue and Redis",
|
| 4 |
-
"version": "2.0.0",
|
| 5 |
-
"icon": "https://raw.githubusercontent.com/yourusername/ManimCat/main/public/logo.svg",
|
| 6 |
-
"services": {
|
| 7 |
-
"app": {
|
| 8 |
-
"type": "service",
|
| 9 |
-
"dockerfile": "Dockerfile",
|
| 10 |
-
"buildCommand": "npm run build",
|
| 11 |
-
"startCommand": "node dist/server.js",
|
| 12 |
-
"port": 3000,
|
| 13 |
-
"healthCheck": {
|
| 14 |
-
"path": "/health",
|
| 15 |
-
"interval": 30,
|
| 16 |
-
"timeout":
|
| 17 |
-
"retries": 3,
|
| 18 |
-
"startPeriod": 40
|
| 19 |
-
},
|
| 20 |
-
"env": [
|
| 21 |
-
{
|
| 22 |
-
"key": "NODE_ENV",
|
| 23 |
-
"value": "production",
|
| 24 |
-
"description": "Node environment"
|
| 25 |
-
},
|
| 26 |
-
{
|
| 27 |
-
"key": "PORT",
|
| 28 |
-
"value": "3000",
|
| 29 |
-
"description": "Server port"
|
| 30 |
-
},
|
| 31 |
-
{
|
| 32 |
-
"key": "REDIS_HOST",
|
| 33 |
-
"value": "${REDIS_HOST}",
|
| 34 |
-
"description": "Redis host (auto-injected by Zeabur)"
|
| 35 |
-
},
|
| 36 |
-
{
|
| 37 |
-
"key": "REDIS_PORT",
|
| 38 |
-
"value": "${REDIS_PORT}",
|
| 39 |
-
"description": "Redis port (auto-injected by Zeabur)"
|
| 40 |
-
},
|
| 41 |
-
{
|
| 42 |
-
"key": "REDIS_PASSWORD",
|
| 43 |
-
"value": "${REDIS_PASSWORD}",
|
| 44 |
-
"description": "Redis password (if required)"
|
| 45 |
-
},
|
| 46 |
-
{
|
| 47 |
-
"key": "REDIS_DB",
|
| 48 |
-
"value": "0",
|
| 49 |
-
"description": "Redis database number"
|
| 50 |
-
},
|
| 51 |
-
{
|
| 52 |
-
"key": "OPENAI_API_KEY",
|
| 53 |
-
"description": "OpenAI API key for AI code generation (required)",
|
| 54 |
-
"required": true
|
| 55 |
-
},
|
| 56 |
-
{
|
| 57 |
-
"key": "OPENAI_MODEL",
|
| 58 |
-
"value": "glm-4-flash",
|
| 59 |
-
"description": "OpenAI-compatible model (glm-4-flash/gpt-4o-mini/gpt-4-turbo, etc.)"
|
| 60 |
-
},
|
| 61 |
-
{
|
| 62 |
-
"key": "CUSTOM_API_URL",
|
| 63 |
-
"value": "",
|
| 64 |
-
"description": "Custom OpenAI-compatible API endpoint (optional)"
|
| 65 |
-
},
|
| 66 |
-
{
|
| 67 |
-
"key": "MANIMCAT_API_KEY",
|
| 68 |
-
"value": "",
|
| 69 |
-
"description": "API key for authentication (optional, leave empty to disable auth)"
|
| 70 |
-
},
|
| 71 |
-
{
|
| 72 |
-
"key": "DISPLAY",
|
| 73 |
-
"value": ":99",
|
| 74 |
-
"description": "X display for Xvfb"
|
| 75 |
-
}
|
| 76 |
-
],
|
| 77 |
-
"volumes": [
|
| 78 |
-
{
|
| 79 |
-
"name": "videos",
|
| 80 |
-
"mountPath": "/app/public/videos"
|
| 81 |
-
},
|
| 82 |
-
{
|
| 83 |
-
"name": "tmp",
|
| 84 |
-
"mountPath": "/app/tmp"
|
| 85 |
-
}
|
| 86 |
-
],
|
| 87 |
-
"resources": {
|
| 88 |
-
"cpu": "2",
|
| 89 |
-
"memory": "4Gi",
|
| 90 |
-
"disk": "20Gi"
|
| 91 |
-
}
|
| 92 |
-
},
|
| 93 |
-
"redis": {
|
| 94 |
-
"type": "redis",
|
| 95 |
-
"version": "7",
|
| 96 |
-
"persistence": true,
|
| 97 |
-
"config": {
|
| 98 |
-
"maxmemory": "512mb",
|
| 99 |
-
"maxmemory-policy": "allkeys-lru",
|
| 100 |
-
"appendonly": "yes"
|
| 101 |
-
},
|
| 102 |
-
"resources": {
|
| 103 |
-
"cpu": "0.5",
|
| 104 |
-
"memory": "512Mi",
|
| 105 |
-
"disk": "5Gi"
|
| 106 |
-
}
|
| 107 |
-
}
|
| 108 |
-
},
|
| 109 |
-
"dependencies": {
|
| 110 |
-
"app": ["redis"]
|
| 111 |
-
},
|
| 112 |
-
"regions": ["auto"],
|
| 113 |
-
"networking": {
|
| 114 |
-
"app": {
|
| 115 |
-
"public": true,
|
| 116 |
-
"domains": []
|
| 117 |
-
}
|
| 118 |
-
}
|
| 119 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "manimcat",
|
| 3 |
+
"description": "ManimCat - AI-powered mathematical animation generator with Express.js, Bull Queue and Redis",
|
| 4 |
+
"version": "2.0.0",
|
| 5 |
+
"icon": "https://raw.githubusercontent.com/yourusername/ManimCat/main/public/logo.svg",
|
| 6 |
+
"services": {
|
| 7 |
+
"app": {
|
| 8 |
+
"type": "service",
|
| 9 |
+
"dockerfile": "Dockerfile",
|
| 10 |
+
"buildCommand": "npm run build",
|
| 11 |
+
"startCommand": "node dist/server.js",
|
| 12 |
+
"port": 3000,
|
| 13 |
+
"healthCheck": {
|
| 14 |
+
"path": "/health",
|
| 15 |
+
"interval": 30,
|
| 16 |
+
"timeout": 600,
|
| 17 |
+
"retries": 3,
|
| 18 |
+
"startPeriod": 40
|
| 19 |
+
},
|
| 20 |
+
"env": [
|
| 21 |
+
{
|
| 22 |
+
"key": "NODE_ENV",
|
| 23 |
+
"value": "production",
|
| 24 |
+
"description": "Node environment"
|
| 25 |
+
},
|
| 26 |
+
{
|
| 27 |
+
"key": "PORT",
|
| 28 |
+
"value": "3000",
|
| 29 |
+
"description": "Server port"
|
| 30 |
+
},
|
| 31 |
+
{
|
| 32 |
+
"key": "REDIS_HOST",
|
| 33 |
+
"value": "${REDIS_HOST}",
|
| 34 |
+
"description": "Redis host (auto-injected by Zeabur)"
|
| 35 |
+
},
|
| 36 |
+
{
|
| 37 |
+
"key": "REDIS_PORT",
|
| 38 |
+
"value": "${REDIS_PORT}",
|
| 39 |
+
"description": "Redis port (auto-injected by Zeabur)"
|
| 40 |
+
},
|
| 41 |
+
{
|
| 42 |
+
"key": "REDIS_PASSWORD",
|
| 43 |
+
"value": "${REDIS_PASSWORD}",
|
| 44 |
+
"description": "Redis password (if required)"
|
| 45 |
+
},
|
| 46 |
+
{
|
| 47 |
+
"key": "REDIS_DB",
|
| 48 |
+
"value": "0",
|
| 49 |
+
"description": "Redis database number"
|
| 50 |
+
},
|
| 51 |
+
{
|
| 52 |
+
"key": "OPENAI_API_KEY",
|
| 53 |
+
"description": "OpenAI API key for AI code generation (required)",
|
| 54 |
+
"required": true
|
| 55 |
+
},
|
| 56 |
+
{
|
| 57 |
+
"key": "OPENAI_MODEL",
|
| 58 |
+
"value": "glm-4-flash",
|
| 59 |
+
"description": "OpenAI-compatible model (glm-4-flash/gpt-4o-mini/gpt-4-turbo, etc.)"
|
| 60 |
+
},
|
| 61 |
+
{
|
| 62 |
+
"key": "CUSTOM_API_URL",
|
| 63 |
+
"value": "",
|
| 64 |
+
"description": "Custom OpenAI-compatible API endpoint (optional)"
|
| 65 |
+
},
|
| 66 |
+
{
|
| 67 |
+
"key": "MANIMCAT_API_KEY",
|
| 68 |
+
"value": "",
|
| 69 |
+
"description": "API key for authentication (optional, leave empty to disable auth)"
|
| 70 |
+
},
|
| 71 |
+
{
|
| 72 |
+
"key": "DISPLAY",
|
| 73 |
+
"value": ":99",
|
| 74 |
+
"description": "X display for Xvfb"
|
| 75 |
+
}
|
| 76 |
+
],
|
| 77 |
+
"volumes": [
|
| 78 |
+
{
|
| 79 |
+
"name": "videos",
|
| 80 |
+
"mountPath": "/app/public/videos"
|
| 81 |
+
},
|
| 82 |
+
{
|
| 83 |
+
"name": "tmp",
|
| 84 |
+
"mountPath": "/app/tmp"
|
| 85 |
+
}
|
| 86 |
+
],
|
| 87 |
+
"resources": {
|
| 88 |
+
"cpu": "2",
|
| 89 |
+
"memory": "4Gi",
|
| 90 |
+
"disk": "20Gi"
|
| 91 |
+
}
|
| 92 |
+
},
|
| 93 |
+
"redis": {
|
| 94 |
+
"type": "redis",
|
| 95 |
+
"version": "7",
|
| 96 |
+
"persistence": true,
|
| 97 |
+
"config": {
|
| 98 |
+
"maxmemory": "512mb",
|
| 99 |
+
"maxmemory-policy": "allkeys-lru",
|
| 100 |
+
"appendonly": "yes"
|
| 101 |
+
},
|
| 102 |
+
"resources": {
|
| 103 |
+
"cpu": "0.5",
|
| 104 |
+
"memory": "512Mi",
|
| 105 |
+
"disk": "5Gi"
|
| 106 |
+
}
|
| 107 |
+
}
|
| 108 |
+
},
|
| 109 |
+
"dependencies": {
|
| 110 |
+
"app": ["redis"]
|
| 111 |
+
},
|
| 112 |
+
"regions": ["auto"],
|
| 113 |
+
"networking": {
|
| 114 |
+
"app": {
|
| 115 |
+
"public": true,
|
| 116 |
+
"domains": []
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
|