Bin29 commited on
Commit
872fa61
·
1 Parent(s): 9fdb075

增加自定义提示词管理系统 超时默认为10分钟 优化UI

Browse files
.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: 请求超时时间(毫秒)(默认值:60000
58
- # OPENAI_TIMEOUT=60000
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&section=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&center=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
- ∫ &nbsp; ∑ &nbsp; ∂ &nbsp; ∞
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;">◆ &nbsp; ◆ &nbsp; ◆</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&section=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_URL` | `redis://localhost:6379` | Redis 连接地址 |
154
- | `OPENAI_API_KEY` | - | OpenAI API Key(必需) |
155
- | `OPENAI_MODEL` | `glm-4-flash` | 使用的 AI 模型 |
156
- | `OPENAI_TIMEOUT` | `600000` | OpenAI 请求超时时间(毫秒) |
157
- | `AI_TEMPERATURE` | `0.7` | AI 温度参数0-1) |
158
- | `AI_MAX_CODE_TOKENS` | `1200` | 代码生成最大 Token |
159
- | `DESIGNER_TEMPERATURE` | `0.8` | 概念设计师温度参数 |
160
- | `DESIGNER_MAX_TOKENS` | `800` | 概念设计师最大 Token |
161
- | `ENABLE_AI_CODE_FIX` | `true` | 是否启用 AI 代码修复 |
162
- | `CODE_RETRY_MAX_RETRIES` | `4` | 代码重试最大次数 |
163
- | `CUSTOM_API_URL` | - | 自定义 API 地址 |
164
- | `ENABLE_JOB_CACHE` | `true` | 是否启用任务缓存 |
165
- | `CACHE_TTL_SECONDS` | `3600` | 缓存过期时间(秒) |
166
-
167
- **示例 `.env` 文件:**
168
-
169
- ```bash
170
- PORT=3000
171
- REDIS_URL=redis://localhost:6379
172
- OPENAI_API_KEY=your-api-key-here
173
- OPENAI_MODEL=glm-4-flash
174
- AI_TEMPERATURE=0.7
175
- CODE_RETRY_MAX_RETRIES=4
176
- ```
177
-
178
- ## 部署
179
-
180
- 请查看[部署文档](DEPLOYMENT.md)。
181
-
182
- ## 贡献
183
-
184
- 我对原作品进行了一些修改和重构,使其更符合我的设计想法:
185
-
186
- 1. 框架架构重构
187
-
188
- - 后端使用 Express.js + Bull 任务队列架构
189
-
190
- 2. 前后端分离
191
-
192
- - 前后端分离,React + TypeScript + Vite 独立前端
193
-
194
- 3. 存储方案升级
195
-
196
- - Redis 存储(任务结果、状态、缓存,支持持久化)
197
-
198
- 4. 任务队列系统
199
-
200
- - Bull + Redis 任务队列,支持重试、超时、指数退避
201
-
202
- 5. 前端技术栈
203
-
204
- - React 19 + TailwindCSS + react-syntax-highlighter
205
-
206
- 6. 项目结构
207
-
208
- - src/{config,middlewares,routes,services,queues,prompts,types,utils}/
209
- frontend/src/{components,hooks,lib,types}/
210
-
211
- 7. 新增功能
212
-
213
-
214
- - CORS 配置中间件
215
-
216
- - 前端主题切换、设置模态框等组件
217
-
218
- - 增加对第三方oai格式的请求支持
219
-
220
- - 支持第三方自定义api
221
-
222
- - 增加重试机制,增加查询
223
-
224
- - 重构UI,重构提示词,采取强注入manim api规范的方式
225
-
226
- - 增加前端自定义视频参数
227
-
228
- - 支持内存查询端点
229
-
230
- - 优化提示词管理系统
231
-
232
- - 对AI的输出结合提示词进行高度优化的正则清理,适配思考模型
233
-
234
- ## 思路
235
-
236
- 1. 在原作者使用AI一键生成manim视频并且后端渲染的基础上,增加了fallback机制,升弱模型的生成完成度
237
-
238
- 2. 考虑到多数AI的manim语料训练并不多,为了降低AI幻觉率,采用提示词工程的方法,强注入manimv0.19.2的api索引表知识(自爬取洗制作)
239
-
240
- ## 现状
241
-
242
- 目前仍在完善项目,这只是第一个预览版本。我将致力于设计出更好的提示词与fallback流程。目标是可以对一道中国高考数学题进行完整的可视化。以下是建设的计划:
243
-
244
- - 优化提示词,生成更长篇幅的Manim代码和更精准的效果
245
- - 增加调度和重试功能
246
- - 增加一定的验证页面,以防止滥 (已经完
247
- - 增加自定义模式功能,使用不同提示词生成不同视频
248
- - 增加迭代功能,延长生成代码和视频长度
249
- - 提供可能的打包版本,让非开发者可以本地实现项目
250
-
251
- ## 开源与版权声明 (License & Copyright)
252
-
253
- ### 1. 软件协议 (Software License)
254
- 本项目后端架构及前端部分实现参考/使用了 [manim-video-generator](https://github.com/rohitg00/manim-video-generator)核心思想。
255
- * 继承部分代码遵循 **MIT License**
256
- * 本项目新增的重构代码、任务队列逻辑及前端组件,同样以 **MIT License** 向开源社区开放。
257
-
258
- ### 2. 核心资产版权声明 (Core Assets - **PROHIBITED FOR COMMERCIAL USE**)
259
- **以下内容为本人(ManimCat 作者)原创,严禁任何形式的商用行为:**
260
-
261
- * **Prompt Engineering(提示词工程)**本项目中 `src/prompts/` 目录下所有高度优化的 Manim 代码生成提示词及逻辑,均为本人原创。
262
- * **API Index Data**:本人自行爬取、清洗并制作的 Manim v0.18.2 API 索引表及相关强约束规则。
263
- * **特定算法逻辑**:针对思考模型的正则清理算法及 fallback 容错机制。
264
-
265
- **未经本人书许可,任何人不得将核心资产用于:**
266
- 1. 直接打包作为付费产品销售。
267
- 2. 集成在付费订阅制的商业 AI 服务中。
268
- 3. 在未注明出处的情况下进行二次分发并获利。
269
-
270
- > 事实上,作者已经关注到市面上存在一些闭源商业项目,正利用类似的 AI + Manim 思路向数学教育工作者收取高额费用进行盈利。然而,开源社区目前仍缺乏针对教育场景深度优化的成熟项目。
271
-
272
- > ManimCat 的诞生正是为了对标并挑战这些闭源商业软件。 我希望通过开源的方式,让每一位老师都能廉价地享受到 AI 带来的教学可视化便利————你只需要支付api的费用,幸运的是,对于优秀的中国LLM大模型来说,这些花费很廉价。为了保护这一愿景不被商业机构剽窃并反向收割用户,我坚决禁止任何对本项目核心提示词及索引数据的商业授权。
273
-
274
-
275
- ## 维护说明
276
-
277
- 由于作者精力有限(个人业余兴趣开发者,非专业背景),目前完全无法对外部代码进行有效的审查和长期维护。因此,本项目暂不支持团队协同开发,不接受 PR。感谢理解。
278
-
279
- 如果你有好的建议或发现了 Bug,欢迎提交 Issue 进行讨论,我会根据自己节奏进行改进。如果你希望在本项目基础上进行大规模修改欢迎 Fork 出属于你自己的版本。
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <div align="center">
2
+
3
+ <!-- 顶部装饰线 - 统一为深灰色调 -->
4
+ <img width="100%" src="https://capsule-render.vercel.app/api?type=waving&color=455A64&height=120&section=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&center=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
+ ∫ &nbsp; ∑ &nbsp; ∂ &nbsp; ∞
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;">◆ &nbsp; ◆ &nbsp; ◆</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&section=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: 3s
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: 10s
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 type { GenerateRequest, JobResult, ProcessingStage, VideoConfig } from '../types/api';
7
-
8
- interface UseGenerationReturn {
9
- status: 'idle' | 'processing' | 'completed' | 'error';
10
- result: JobResult | null;
11
- error: string | null;
12
- jobId: string | null;
13
- stage: ProcessingStage;
14
- generate: (request: GenerateRequest) => Promise<void>;
15
- reset: () => void;
16
- cancel: () => void;
17
- }
18
-
19
- /** 轮询间隔 */
20
- const POLL_INTERVAL = 1000;
21
-
22
- /** 从 localStorage 加载超时配置 */
23
- function getTimeoutConfig(): number {
24
- try {
25
- const saved = localStorage.getItem('manimcat_settings');
26
- if (saved) {
27
- const parsed = JSON.parse(saved);
28
- if (parsed.video?.timeout) {
29
- return parsed.video.timeout;
30
- }
31
- }
32
- } catch {
33
- // 忽略错误,使用默认值
34
- }
35
- return 120; // 默认 120 秒
36
- }
37
-
38
- export function useGeneration(): UseGenerationReturn {
39
- const [status, setStatus] = useState<'idle' | 'processing' | 'completed' | 'error'>('idle');
40
- const [result, setResult] = useState<JobResult | null>(null);
41
- const [error, setError] = useState<string | null>(null);
42
- const [jobId, setJobId] = useState<string | null>(null);
43
- const [stage, setStage] = useState<ProcessingStage>('analyzing');
44
-
45
- const pollCountRef = useRef(0);
46
- const pollIntervalRef = useRef<number | null>(null);
47
- const abortControllerRef = useRef<AbortController | null>(null);
48
-
49
- // 清理轮询和请求
50
- useEffect(() => {
51
- return () => {
52
- if (pollIntervalRef.current) {
53
- clearInterval(pollIntervalRef.current);
54
- }
55
- abortControllerRef.current?.abort();
56
- };
57
- }, []);
58
-
59
- // 更新处理阶段
60
- const updateStage = useCallback((count: number) => {
61
- if (count < 5) {
62
- setStage('analyzing');
63
- } else if (count < 15) {
64
- setStage('generating');
65
- } else if (count < 25) {
66
- setStage('refining');
67
- } else if (count < 60) {
68
- setStage('rendering');
69
- } else {
70
- setStage('still-rendering');
71
- }
72
- }, []);
73
-
74
- // 开始轮询
75
- const startPolling = useCallback((id: string) => {
76
- pollCountRef.current = 0;
77
- setJobId(id);
78
-
79
- // 获取用户配置的超时时间
80
- const maxPollCount = getTimeoutConfig();
81
-
82
- pollIntervalRef.current = window.setInterval(async () => {
83
- pollCountRef.current++;
84
-
85
- try {
86
- const data = await getJobStatus(id, abortControllerRef.current?.signal);
87
-
88
- if (data.status === 'completed') {
89
- if (pollIntervalRef.current) {
90
- clearInterval(pollIntervalRef.current);
91
- }
92
- setStatus('completed');
93
- setResult(data);
94
- } else if (data.status === 'failed') {
95
- if (pollIntervalRef.current) {
96
- clearInterval(pollIntervalRef.current);
97
- }
98
- setStatus('error');
99
- setError(data.error || '生成失败');
100
- } else {
101
- // 使用后端返回的 stage,如果没有则使用前端估算的 fallback
102
- if (data.stage) {
103
- setStage(data.stage);
104
- } else {
105
- updateStage(pollCountRef.current);
106
- }
107
- }
108
-
109
- // 超时检查(使用用户配置的超时时间)
110
- if (pollCountRef.current >= maxPollCount) {
111
- if (pollIntervalRef.current) {
112
- clearInterval(pollIntervalRef.current);
113
- }
114
- setStatus('error');
115
- setError(`生成超时(${maxPollCount}秒),请尝试更简单的概念或增加超时时间`);
116
- }
117
- } catch (err) {
118
- if (err instanceof Error && err.name === 'AbortError') {
119
- return;
120
- }
121
-
122
- // 如果是连接错误(后端断开),停止轮询
123
- if (err instanceof Error && (err.message.includes('ECONNREFUSED') || err.message.includes('Failed to fetch'))) {
124
- console.error('后端连接断开,停止轮询');
125
- if (pollIntervalRef.current) {
126
- clearInterval(pollIntervalRef.current);
127
- }
128
- setStatus('error');
129
- setError('后端服务已断开,请刷新页面重试');
130
- return;
131
- }
132
-
133
- console.error('轮询错误:', err);
134
-
135
- // 如果是任务未找到 (404) 或明确的失效提示
136
- if (err instanceof Error && (err.message.includes('未找到任务') || err.message.includes('失效'))) {
137
- if (pollIntervalRef.current) {
138
- clearInterval(pollIntervalRef.current);
139
- pollIntervalRef.current = null;
140
- }
141
- setStatus('error');
142
- setError('任务已失效(可能因服务重启)请重新生成');
143
- return;
144
- }
145
- }
146
- }, POLL_INTERVAL);
147
- }, [updateStage]);
148
-
149
- // 生成动画
150
- const generate = useCallback(async (request: GenerateRequest) => {
151
- setStatus('processing');
152
- setError(null);
153
- setResult(null);
154
- setStage('analyzing');
155
- pollCountRef.current = 0;
156
- abortControllerRef.current = new AbortController();
157
-
158
- try {
159
- // 检查是否有自定义 AI 配置
160
- const customConfig = loadCustomConfig();
161
-
162
- if (customConfig) {
163
- // 使用自定义 AI 生成代码
164
- setStage('generating');
165
- const code = await generateWithCustomApi(
166
- request.concept,
167
- customConfig,
168
- abortControllerRef.current.signal
169
- );
170
-
171
- // 发送代码到后端渲染
172
- setStage('rendering');
173
- const response = await generateAnimation(
174
- { ...request, code },
175
- abortControllerRef.current.signal
176
- );
177
- startPolling(response.jobId);
178
- } else {
179
- // 使用后端 AI
180
- const response = await generateAnimation(request, abortControllerRef.current.signal);
181
- startPolling(response.jobId);
182
- }
183
- } catch (err) {
184
- if (err instanceof Error && err.name === 'AbortError') {
185
- return;
186
- }
187
- setStatus('error');
188
- setError(err instanceof Error ? err.message : '生成请求失败');
189
- }
190
- }, [startPolling]);
191
-
192
- // 重置状态
193
- const reset = useCallback(() => {
194
- setStatus('idle');
195
- setError(null);
196
- setResult(null);
197
- setJobId(null);
198
- setStage('analyzing');
199
- if (pollIntervalRef.current) {
200
- clearInterval(pollIntervalRef.current);
201
- }
202
- abortControllerRef.current?.abort();
203
- }, []);
204
-
205
- // 取消生成
206
- const cancel = useCallback(() => {
207
- if (pollIntervalRef.current) {
208
- clearInterval(pollIntervalRef.current);
209
- }
210
- abortControllerRef.current?.abort();
211
- setStatus('idle');
212
- setError(null);
213
- setJobId(null);
214
- setStage('analyzing');
215
- }, []);
216
-
217
- return {
218
- status,
219
- result,
220
- error,
221
- jobId,
222
- stage,
223
- generate,
224
- reset,
225
- cancel,
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
- export async function getJobStatus(jobId: string, signal?: AbortSignal): Promise<JobResult> {
67
- const response = await fetch(`${API_BASE}/jobs/${jobId}`, {
68
- headers: getAuthHeaders(),
69
- signal,
70
- });
71
-
72
- if (!response.ok) {
73
- const error: ApiError = await response.json();
74
- throw new Error(error.error || '查询任务状态失败');
75
- }
76
-
77
- return response.json();
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-0yzmNnTY.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-DkT5mxpT.css">
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
- // 检查是否 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
- ): Promise<GenerationResult> {
86
- const { analysisType, manimCode, needsAI } = analyzeResult
87
- logger.info('Generating code', { jobId, needsAI, analysisType })
88
-
89
- // 基本可视化代码(fallback)
90
- const basicVisualizationCode = `from manim import *
91
-
92
- class MainScene(Scene):
93
- def construct(self):
94
- text = Text("Animation for: ${concept}")
95
- self.play(Write(text))
96
- self.wait(1)
97
- `.replace('${concept}', concept)
98
-
99
- if (needsAI) {
100
- // 使用两阶段 AI 生成:概念设计者 + 代码生成者
101
- try {
102
- logger.info('使用两阶段 AI 生成', { jobId })
103
- const result = await generateTwoStageAIManimCode(concept, customApiConfig)
104
- if (result.code && result.code.length > 0) {
105
- logger.info('两阶段 AI 代码生成成功', { jobId, length: result.code.length, hasSceneDesign: !!result.sceneDesign })
106
- // 保存 sceneDesign 用于重试
107
- return {
108
- manimCode: result.code,
109
- usedAI: true,
110
- generationType: 'two-stage-ai',
111
- sceneDesign: result.sceneDesign
112
- }
113
- }
114
- } catch (error) {
115
- logger.warn('AI generation failed, using fallback', { jobId, error: String(error) })
116
- }
117
- return { manimCode: basicVisualizationCode, usedAI: false, generationType: 'fallback' }
118
- }
119
-
120
- if (manimCode) {
121
- logger.info('Using pre-generated code', { jobId, length: manimCode.length })
122
- return { manimCode, usedAI: false, generationType: analysisType }
123
- }
124
-
125
- return { manimCode: basicVisualizationCode, usedAI: false, generationType: 'fallback' }
126
- }
127
-
128
- /**
129
- * 分析并生成(合并分析+生成)
130
- */
131
- export async function analyzeAndGenerate(
132
- jobId: string,
133
- concept: string,
134
- quality: string,
135
- _timings: Record<string, number>,
136
- customApiConfig?: CustomApiConfig
137
- ): Promise<GenerationResult> {
138
- const analysisResult = await analyzeConcept(jobId, concept, quality)
139
- return generateCode(jobId, concept, quality, analysisResult, customApiConfig)
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 { createLogger } from '../../utils/logger'
11
- import type { VideoJobData } from '../../types'
12
-
13
- // 导入步骤模块
14
- import { checkCache, handleCacheHit } from './steps/cache-step'
15
- import { analyzeAndGenerate } from './steps/analysis-step'
16
- import { renderVideo, handlePreGeneratedCode } from './steps/render-step'
17
- import { storeResult } from './steps/storage-step'
18
-
19
- const logger = createLogger('VideoProcessor')
20
-
21
- /**
22
- * 任务处理器主函数
23
- */
24
- videoQueue.process(async (job) => {
25
- const data = job.data as VideoJobData
26
- const { jobId, concept, quality, forceRefresh = false, preGeneratedCode } = data
27
-
28
- logger.info('Processing video job', { jobId, concept, quality, hasPreGeneratedCode: !!preGeneratedCode })
29
-
30
- // 阶段时长追踪
31
- const timings: Record<string, number> = {}
32
-
33
- try {
34
- // 如果有预生成代码,跳过缓存和 AI 生成阶段,直接渲染
35
- if (preGeneratedCode) {
36
- return await handlePreGeneratedCode(jobId, concept, quality, preGeneratedCode, timings, data)
37
- }
38
-
39
- // Step 1: 检查缓存
40
- await storeJobStage(jobId, 'analyzing')
41
- const cacheStart = Date.now()
42
- const cacheResult = await checkCache(jobId, concept, quality, forceRefresh, timings)
43
- timings.cache = Date.now() - cacheStart
44
-
45
- if (cacheResult.hit) {
46
- // 缓存命中 - 直接处理缓存结果
47
- await handleCacheHit(jobId, concept, quality, cacheResult.data!, timings)
48
- logger.info('Job completed (cache hit)', { jobId, timings })
49
- return { success: true, source: 'cache', timings }
50
- }
51
-
52
- // Step 2 & 3: 分析概念并生成代码
53
- await storeJobStage(jobId, 'generating')
54
- const analyzeStart = Date.now()
55
- const codeResult = await analyzeAndGenerate(jobId, concept, quality, timings, data.customApiConfig)
56
- timings.analyze = Date.now() - analyzeStart
57
-
58
- // Step 4: 渲染视频
59
- const renderStart = Date.now()
60
- const renderResult = await renderVideo(
61
- jobId,
62
- concept,
63
- quality,
64
- codeResult,
65
- timings,
66
- data.customApiConfig,
67
- data.videoConfig,
68
- () => storeJobStage(jobId, 'rendering')
69
- )
70
- timings.render = Date.now() - renderStart
71
-
72
- // Step 5: 存储结果
73
- const storeStart = Date.now()
74
- await storeResult(renderResult, timings)
75
- timings.store = Date.now() - storeStart
76
-
77
- // 总时长
78
- timings.total = timings.cache + timings.analyze + timings.render + timings.store
79
-
80
- logger.info('Job completed', { jobId, source: 'generation', timings })
81
-
82
- return { success: true, source: 'generation', timings }
83
- } catch (error) {
84
- const errorMessage = error instanceof Error ? error.message : String(error)
85
- logger.error('Job failed', { jobId, error: errorMessage, timings })
86
-
87
- // 存储失败结果
88
- await storeJobResult(jobId, {
89
- status: 'failed',
90
- data: { error: errorMessage }
91
- })
92
-
93
- throw error
94
- }
95
- })
96
-
97
- /**
98
- * 存储任务阶段(辅助函数)
99
- */
100
- async function storeJobStage(jobId: string, stage: string): Promise<void> {
101
- const { storeJobStage: storeStage } = await import('../../services/job-store')
102
- await storeStage(jobId, stage as any)
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
- // 请求体 schema(与原有保持一致)
28
- const bodySchema = z.object({
29
- concept: z.string().min(1, '概念必填'),
30
- quality: z.enum(['low', 'medium', 'high']).optional().default('low'),
31
- forceRefresh: z.boolean().optional().default(false),
32
- /** 预生成的代码(使用自定义 AI 时) */
33
- code: z.string().optional(),
34
- /** 自定义 API 配置(用于代码修复) */
35
- customApiConfig: z.object({
36
- apiUrl: z.string(),
37
- apiKey: z.string(),
38
- model: z.string()
39
- }).optional(),
40
- /** 视频配置 */
41
- videoConfig: z.object({
42
- quality: z.enum(['low', 'medium', 'high']).optional(),
43
- frameRate: z.number().int().min(1).max(120).optional(),
44
- timeout: z.number().optional()
45
- }).optional()
46
- })
47
-
48
- /**
49
- * 处理视频生成请求的核心逻辑
50
- */
51
- async function handleGenerateRequest(req: express.Request, res: express.Response) {
52
- let parsed;
53
- try {
54
- parsed = bodySchema.parse(req.body);
55
- } catch (error: any) {
56
- throw error;
57
- }
58
-
59
- const { concept, quality, forceRefresh, code, customApiConfig, videoConfig } = parsed;
60
-
61
- // 清理输入
62
- const sanitizedConcept = concept.trim().replace(/\s+/g, ' ')
63
-
64
- if (sanitizedConcept.length === 0) {
65
- throw new ValidationError('提供的概念为空', { concept })
66
- }
67
-
68
- // 生成唯一的任务 ID
69
- const jobId = uuidv4()
70
-
71
- logger.info('收到动画生成请求', {
72
- jobId,
73
- concept: sanitizedConcept,
74
- quality,
75
- forceRefresh,
76
- hasPreGeneratedCode: !!code,
77
- videoConfig
78
- })
79
-
80
- // 设置初始阶段
81
- await storeJobStage(jobId, code ? 'rendering' : 'analyzing')
82
-
83
- // 添加任务到 Bull 队列
84
- await videoQueue.add(
85
- {
86
- jobId,
87
- concept: sanitizedConcept,
88
- quality,
89
- forceRefresh,
90
- preGeneratedCode: code,
91
- customApiConfig,
92
- videoConfig,
93
- timestamp: new Date().toISOString()
94
- },
95
- {
96
- jobId
97
- }
98
- )
99
-
100
- logger.info('动画请求已加入队列', { jobId })
101
-
102
- const response: GenerateResponse = {
103
- success: true,
104
- jobId,
105
- message: code ? '视频渲染已开始' : '动画生成已开始',
106
- status: 'processing'
107
- }
108
-
109
- res.status(202).json(response)
110
- }
111
-
112
- /**
113
- * 条件认证中间件
114
- * 如果请求包含预生成代码,跳过认证
115
- */
116
- function optionalAuthMiddleware(
117
- req: express.Request,
118
- res: express.Response,
119
- next: express.NextFunction
120
- ) {
121
- // 如果有预生成代码,跳过认证(因为 AI 调用已在前端完成)
122
- if (req.body?.code) {
123
- return next()
124
- }
125
- // 否则使用完整认证
126
- return authMiddleware(req, res, next)
127
- }
128
-
129
- /**
130
- * POST /api/generate
131
- * 提交视频生成任务
132
- */
133
- router.post('/generate', optionalAuthMiddleware, asyncHandler(handleGenerateRequest))
134
-
135
- export default router
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 healthRouter from './health.route'
12
- import metricsRouter from './metrics.route'
13
-
14
- const router = express.Router()
15
-
16
- // 挂载健康检查路由(不使用 /api 前缀)
17
- router.use(healthRouter)
18
-
19
- // 挂载 API 路由(使用 /api 前缀)
20
- router.use('/api', generateRouter)
21
- router.use('/api', jobStatusRouter)
22
- router.use('/api/metrics', metricsRouter)
23
-
24
- export default router
 
 
 
 
 
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
- }, 30000) // 30 秒超时
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 { buildInitialCodePrompt, CODE_RETRY_SYSTEM_PROMPT } from './prompts'
17
- import { getClient } from './client'
18
- import { extractCodeFromResponse, extractErrorMessage, getErrorType } from './utils'
19
-
20
- const logger = createLogger('CodeRetryManager')
21
-
22
- // 配置
23
- const MAX_RETRIES = parseInt(process.env.CODE_RETRY_MAX_RETRIES || '4', 10)
24
- const OPENAI_MODEL = process.env.OPENAI_MODEL || 'glm-4-flash'
25
- const AI_TEMPERATURE = parseFloat(process.env.AI_TEMPERATURE || '0.7')
26
- const MAX_TOKENS = parseInt(process.env.AI_MAX_TOKENS || '1200', 10)
27
-
28
- /**
29
- * 生成唯一种子
30
- */
31
- function generateSeed(concept: string): string {
32
- const timestamp = Date.now()
33
- const randomPart = crypto.randomBytes(4).toString('hex')
34
- return crypto.createHash('md5').update(`${concept}-${timestamp}-${randomPart}`).digest('hex').slice(0, 8)
35
- }
36
-
37
- /**
38
- * 创建重试上下文
39
- */
40
- export function createRetryContext(
41
- concept: string,
42
- sceneDesign: string
43
- ): CodeRetryContext {
44
- const seed = generateSeed(concept)
45
-
46
- return {
47
- concept,
48
- sceneDesign,
49
- originalPrompt: buildInitialCodePrompt(concept, seed, sceneDesign),
50
- messages: []
51
- }
52
- }
53
-
54
- /**
55
- * 首次代码生成
56
- */
57
- async function generateInitialCode(
58
- context: CodeRetryContext,
59
- customApiConfig?: any
60
- ): Promise<string> {
61
- const client = getClient(customApiConfig)
62
- if (!client) {
63
- throw new Error('OpenAI 客户端不可用')
64
- }
65
-
66
- try {
67
- const response = await client.chat.completions.create({
68
- model: OPENAI_MODEL,
69
- messages: [
70
- { role: 'system', content: CODE_RETRY_SYSTEM_PROMPT },
71
- { role: 'user', content: context.originalPrompt }
72
- ],
73
- temperature: AI_TEMPERATURE,
74
- max_tokens: MAX_TOKENS
75
- })
76
-
77
- const content = response.choices[0]?.message?.content || ''
78
- if (!content) {
79
- throw new Error('AI 返回空内容')
80
- }
81
-
82
- // 清洗代码
83
- const code = extractCodeFromResponse(content)
84
- const cleaned = cleanManimCode(code)
85
-
86
- // 保存对话历史
87
- context.messages.push(
88
- { role: 'user', content: context.originalPrompt },
89
- { role: 'assistant', content: code }
90
- )
91
-
92
- logger.info('首次代码生成成功', {
93
- concept: context.concept,
94
- codeLength: cleaned.code.length
95
- })
96
-
97
- return cleaned.code
98
- } catch (error) {
99
- if (error instanceof OpenAI.APIError) {
100
- logger.error('OpenAI API 错误', {
101
- status: error.status,
102
- message: error.message
103
- })
104
- }
105
- throw error
106
- }
107
- }
108
-
109
- /**
110
- * 重试代码生成
111
- */
112
- async function retryCodeGeneration(
113
- context: CodeRetryContext,
114
- errorMessage: string,
115
- attempt: number,
116
- customApiConfig?: any
117
- ): Promise<string> {
118
- const client = getClient(customApiConfig)
119
- if (!client) {
120
- throw new Error('OpenAI 客户端不可用')
121
- }
122
-
123
- // 构建重试提示词(包含完整对话历史和错误信息)
124
- const retryPrompt = buildRetryPrompt(context, errorMessage, attempt)
125
-
126
- try {
127
- // 构建消息数组:system + 历史消息 + 当前重试提示词
128
- const messages: ChatMessage[] = [
129
- { role: 'system', content: CODE_RETRY_SYSTEM_PROMPT },
130
- ...context.messages,
131
- { role: 'user', content: retryPrompt }
132
- ]
133
-
134
- const response = await client.chat.completions.create({
135
- model: OPENAI_MODEL,
136
- messages,
137
- temperature: AI_TEMPERATURE,
138
- max_tokens: MAX_TOKENS
139
- })
140
-
141
- const content = response.choices[0]?.message?.content || ''
142
- if (!content) {
143
- throw new Error('AI 返回空内容')
144
- }
145
-
146
- // 清洗代码
147
- const code = extractCodeFromResponse(content)
148
- const cleaned = cleanManimCode(code)
149
-
150
- // 保存对话历史
151
- context.messages.push(
152
- { role: 'user', content: retryPrompt },
153
- { role: 'assistant', content: code }
154
- )
155
-
156
- logger.info('代码重试生成成功', {
157
- concept: context.concept,
158
- attempt,
159
- codeLength: cleaned.code.length
160
- })
161
-
162
- return cleaned.code
163
- } catch (error) {
164
- if (error instanceof OpenAI.APIError) {
165
- logger.error('OpenAI API 错误(重试)', {
166
- attempt,
167
- status: error.status,
168
- message: error.message
169
- })
170
- }
171
- throw error
172
- }
173
- }
174
-
175
- /**
176
- * 构建重试提示词
177
- */
178
- function buildRetryPrompt(
179
- context: CodeRetryContext,
180
- errorMessage: string,
181
- attempt: number
182
- ): string {
183
- return `## 目标层
184
-
185
- ### 输入预期
186
-
187
- - **概念**:${context.concept}
188
- - **错误信息**(第 ${attempt} 次重试):${errorMessage}
189
-
190
- ### 产出要求
191
-
192
- - **修复代码**:根据错误信息修复之前的代码。
193
- - **完整代码**:必须输出完整的、可运行的 Manim 代码,不是修复片段!
194
- - **锚点协议**:代码必须包裹在 ### START ### 和 ### END ### 之间
195
- - **纯代码输出**:严禁包含任何解释性文字。
196
- - **结构规范**:核心类名固定为 \`MainScene\`(若为 3D 场景则继承自 \`ThreeDScene\`)。
197
- - **导入规范**:必须使用全部导入 \`from manim import *\`
198
-
199
- ## 行为层
200
-
201
- ### 修复原则
202
-
203
- 1. **分析错误**:根据错误信息找出代码中的问题
204
- 2. **完整修复**:修复后必须输出完整的 Manim 代码(包含 import、class 定义等所有部分)
205
- 3. **确保可运行**:修复后的代码必须是完整的、可直接运行的 Python 代码
206
-
207
- ### 重要提示
208
-
209
- 不要只输出修复的代码片段!必须输出完整的 Manim 代码。
210
-
211
- 示例格式:
212
-
213
- \`\`\`
214
- ### START ###
215
- from manim import *
216
-
217
- class MainScene(Scene):
218
- def construct(self):
219
- # ... 你的完整代码 ...
220
- ### END ###
221
- \`\`\`
222
-
223
- 请修复上述代码,输出完整的 Python 代码。`
224
- }
225
-
226
- /**
227
- * 重试管理器 - 核心函数
228
- *
229
- * 流程:
230
- * 1. 首次生成代码 → 渲染
231
- * 2. 如果失败,检查错误是否可修复
232
- * 3. 重试(最多4次),每次都发送完整对话历史
233
- * 4. 如果4次后仍失败,返回失败结果
234
- */
235
- export async function executeCodeRetry(
236
- context: CodeRetryContext,
237
- renderer: (code: string) => Promise<RenderResult>,
238
- customApiConfig?: any
239
- ): Promise<RetryManagerResult> {
240
- logger.info('开始代码重试管理', {
241
- concept: context.concept,
242
- maxRetries: MAX_RETRIES
243
- })
244
-
245
- // 步骤1:首次代码生成和渲染
246
- let currentCode = await generateInitialCode(context, customApiConfig)
247
- let renderResult = await renderer(currentCode)
248
-
249
- if (renderResult.success) {
250
- logger.info('首次渲染成功')
251
- return { code: currentCode, success: true, attempts: 1 }
252
- }
253
-
254
- // 步骤2:提取错误信息并开始重试
255
- let errorMessage = extractErrorMessage(renderResult.stderr)
256
- let errorType = getErrorType(renderResult.stderr)
257
- logger.warn('首次渲染失败', { errorType, error: errorMessage })
258
- // 所有错误都尝试重试,AI 有能力修复语法、导入等问题
259
-
260
- // 步骤3:重试循环
261
- for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
262
- logger.info(`开始第 ${attempt} 次重试`, {
263
- totalAttempts: attempt + 1,
264
- errorType,
265
- error: errorMessage
266
- })
267
-
268
- try {
269
- currentCode = await retryCodeGeneration(context, errorMessage, attempt, customApiConfig)
270
- renderResult = await renderer(currentCode)
271
-
272
- if (renderResult.success) {
273
- logger.info('重试渲染成功', { attempt: attempt + 1 })
274
- return { code: currentCode, success: true, attempts: attempt + 1 }
275
- }
276
-
277
- // 更新错误信息
278
- errorMessage = extractErrorMessage(renderResult.stderr)
279
- errorType = getErrorType(renderResult.stderr)
280
- logger.warn('重试渲染失败', { attempt: attempt + 1, errorType, error: errorMessage })
281
- } catch (error) {
282
- logger.error('重试过程出错', { attempt: attempt + 1, error: String(error) })
283
- }
284
- }
285
-
286
- // 步骤4:所有重试失败
287
- logger.error('所有重试均失败', {
288
- totalAttempts: MAX_RETRIES + 1,
289
- finalError: extractErrorMessage(renderResult.stderr)
290
- })
291
-
292
- return {
293
- code: currentCode,
294
- success: false,
295
- attempts: MAX_RETRIES + 1,
296
- lastError: extractErrorMessage(renderResult.stderr)
297
- }
298
- }
299
-
300
- /**
301
- * 导出类型
302
- */
303
- export type {
304
- CodeRetryOptions,
305
- CodeRetryResult,
306
- RenderResult,
307
- RetryManagerResult,
308
- ChatMessage,
309
- CodeRetryContext
310
- } from './types'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 = `你是一位 Manim 动画专家,专注于通过动态动画深度解读数学概念。
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 extractDesignFromResponse(text: string): string {
71
- if (!text) return ''
72
- const sanitized = text.replace(/<think>[\s\S]*?<\/think>/gi, '')
73
- const match = sanitized.match(/<design>([\s\S]*?)<\/design>/i)
74
- if (match) {
75
- return match[1].trim()
76
- }
77
- return sanitized.trim()
78
- }
79
-
80
- function extractCodeFromResponse(text: string): string {
81
- if (!text) return ''
82
- const sanitized = text.replace(/<think>[\s\S]*?<\/think>/gi, '')
83
- const anchorMatch = sanitized.match(/### START ###([\s\S]*?)### END ###/)
84
- if (anchorMatch) {
85
- return anchorMatch[1].trim()
86
- }
87
- const codeMatch = sanitized.match(/```(?:python)?([\s\S]*?)```/i)
88
- if (codeMatch) {
89
- return codeMatch[1].trim()
90
- }
91
- return sanitized.trim()
92
- }
93
-
94
- /**
95
- * 清洗设计方案文本
96
- */
97
- interface CleanDesignResult {
98
- text: string
99
- changes: string[]
100
- }
101
-
102
- function cleanDesignText(text: string): CleanDesignResult {
103
- const changes: string[] = []
104
- let cleaned = text
105
-
106
- // 移除多余的空白行
107
- const beforeLength = cleaned.length
108
- cleaned = cleaned.replace(/\n{3,}/g, '\n\n')
109
- if (cleaned.length !== beforeLength) {
110
- changes.push('remove-extra-newlines')
111
- }
112
-
113
- // 移除首尾空白
114
- cleaned = cleaned.trim()
115
-
116
- return { text: cleaned, changes }
117
- }
118
-
119
- /**
120
- * 阶段1:设计者/思考者
121
- * 接收用户的抽象概念,输出详细的场景设计方案
122
- */
123
- async function generateSceneDesign(
124
- concept: string,
125
- customApiConfig?: CustomApiConfig
126
- ): Promise<string> {
127
- const client = customApiConfig ? createCustomClient(customApiConfig) : openaiClient
128
-
129
- if (!client) {
130
- logger.warn('OpenAI 客户端不可用')
131
- return ''
132
- }
133
-
134
- try {
135
- const seed = generateUniqueSeed(concept)
136
-
137
- const systemPrompt = SYSTEM_PROMPTS.conceptDesigner
138
- const userPrompt = generateConceptDesignerPrompt(concept, seed)
139
-
140
- logger.info('开始阶段1:生成场景设计方案', { concept, seed })
141
-
142
- const response = await client.chat.completions.create({
143
- model: OPENAI_MODEL,
144
- messages: [
145
- { role: 'system', content: systemPrompt },
146
- { role: 'user', content: userPrompt }
147
- ],
148
- temperature: DESIGNER_TEMPERATURE,
149
- max_tokens: DESIGNER_MAX_TOKENS
150
- })
151
-
152
- const content = response.choices[0]?.message?.content || ''
153
- if (!content) {
154
- logger.warn('设计者返回空内容')
155
- return ''
156
- }
157
-
158
- const extractedDesign = extractDesignFromResponse(content)
159
- const cleanedDesign = cleanDesignText(extractedDesign)
160
- if (cleanedDesign.changes.length > 0) {
161
- logger.info('设计方案已清洗', {
162
- concept,
163
- seed,
164
- changes: cleanedDesign.changes,
165
- originalLength: content.length,
166
- cleanedLength: cleanedDesign.text.length
167
- })
168
- }
169
-
170
- if (!cleanedDesign.text) {
171
- logger.warn('设计者返回空方案')
172
- return ''
173
- }
174
-
175
- logger.info('阶段1:场景设计方案生成成功', {
176
- concept,
177
- seed,
178
- designLength: cleanedDesign.text.length,
179
- design: cleanedDesign.text
180
- })
181
-
182
- return cleanedDesign.text
183
- } catch (error) {
184
- if (error instanceof OpenAI.APIError) {
185
- logger.error('设计者 API 错误', {
186
- concept,
187
- status: error.status,
188
- code: error.code,
189
- type: error.type,
190
- message: error.message
191
- })
192
- } else if (error instanceof Error) {
193
- logger.error('设计者生成失败', {
194
- concept,
195
- errorName: error.name,
196
- errorMessage: error.message
197
- })
198
- } else {
199
- logger.error('设计者生成失败(未知错误)', { concept, error: String(error) })
200
- }
201
- return ''
202
- }
203
- }
204
-
205
- /**
206
- * 阶段2:代码生成者
207
- * 接收场景设计方案,输出 Manim 代码
208
- */
209
- async function generateCodeFromDesign(
210
- concept: string,
211
- sceneDesign: string,
212
- customApiConfig?: CustomApiConfig
213
- ): Promise<string> {
214
- const client = customApiConfig ? createCustomClient(customApiConfig) : openaiClient
215
-
216
- if (!client) {
217
- logger.warn('OpenAI 客户端不可用')
218
- return ''
219
- }
220
-
221
- try {
222
- const seed = generateUniqueSeed(`${concept}-${sceneDesign.slice(0, 20)}`)
223
-
224
- const systemPrompt = SYSTEM_PROMPTS.codeGeneration
225
- const userPrompt = generateCodeGenerationPrompt(concept, seed, sceneDesign)
226
-
227
- logger.info('开始阶段2:根据设计方案生成代码', { concept, seed })
228
-
229
- const response = await client.chat.completions.create({
230
- model: OPENAI_MODEL,
231
- messages: [
232
- { role: 'system', content: systemPrompt },
233
- { role: 'user', content: userPrompt }
234
- ],
235
- temperature: CODER_TEMPERATURE,
236
- max_tokens: MAX_TOKENS
237
- })
238
-
239
- const content = response.choices[0]?.message?.content || ''
240
- if (!content) {
241
- logger.warn('代码生成者返回空内容')
242
- return ''
243
- }
244
-
245
- logger.info('阶段2:代码生成成功', {
246
- concept,
247
- seed,
248
- codeLength: content.length,
249
- code: content
250
- })
251
-
252
- return content
253
- } catch (error) {
254
- // ✅ 正确:将完整的判断表达式放在括号内
255
- if (error instanceof OpenAI.APIError) {
256
- logger.error('代码生成者 API 错误', {
257
- concept,
258
- status: error.status,
259
- code: error.code,
260
- type: error.type,
261
- message: error.message
262
- })
263
- } else if (error instanceof Error) {
264
- logger.error('代码生成者失败', {
265
- concept,
266
- errorName: error.name,
267
- errorMessage: error.message
268
- })
269
- } else {
270
- logger.error('代码生成者失败(未知错误)', { concept, error: String(error) })
271
- }
272
- return ''
273
- }
274
-
275
- }
276
-
277
- /**
278
- * 两阶段 AI 生成
279
- * 1. 设计者生成场景设计方案
280
- * 2. 代码生成者根据设计方案生成代码
281
- */
282
- export async function generateTwoStageAIManimCode(
283
- concept: string,
284
- customApiConfig?: CustomApiConfig
285
- ): Promise<{ code: string; sceneDesign: string }>
286
- {
287
- logger.info('开始两阶段 AI 生成流程', { concept })
288
-
289
- // 阶段1:生成场景设计方案
290
- const sceneDesign = await generateSceneDesign(concept, customApiConfig)
291
-
292
- if (!sceneDesign) {
293
- logger.warn('场景设计方案生成失败,中止流程')
294
- return { code: '', sceneDesign: '' }
295
- }
296
-
297
- // 阶段2:根据设计方案生成代码
298
- const code = await generateCodeFromDesign(concept, sceneDesign, customApiConfig)
299
-
300
- logger.info('两阶段 AI 生成流程完成', {
301
- concept,
302
- hasSceneDesign: !!sceneDesign,
303
- hasCode: !!code
304
- })
305
-
306
- return { code, sceneDesign }
307
- }
308
-
309
- /**
310
- * 检查 OpenAI 客户端是否可用
311
- */
312
- export function isOpenAIAvailable(): boolean {
313
- return openaiClient !== null
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
- * - 除 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
  }
 
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
- * Manim 执行器
3
- * 执行 Manim 命令,管理子进程
4
- */
5
-
6
- import { spawn } from 'child_process'
7
- import { createLogger } from './logger'
8
-
9
- const logger = createLogger('ManimExecutor')
10
-
11
- /**
12
- * Manim 执行结果
13
- */
14
- export interface ManimExecutionResult {
15
- success: boolean
16
- stdout: string
17
- stderr: string
18
- peakMemoryMB: number
19
- }
20
-
21
- /**
22
- * Manim 执行选项
23
- */
24
- export interface ManimExecuteOptions {
25
- jobId: string
26
- quality: string
27
- frameRate: number
28
- tempDir: string
29
- mediaDir: string
30
- }
31
-
32
- /**
33
- * 获取进程的内存使用情况(MB)
34
- */
35
- export async function getProcessMemory(pid: number): Promise<number | null> {
36
- return new Promise((resolve) => {
37
- const platform = process.platform
38
-
39
- if (platform === 'win32') {
40
- // Windows: 使用 wmic 获取进程内存
41
- spawn('wmic', ['process', 'where', `ProcessId=${pid}`, 'get', 'WorkingSetSize', '/value'])
42
- .stdout.on('data', (data) => {
43
- const output = data.toString()
44
- const match = output.match(/WorkingSetSize=(\d+)/)
45
- if (match) {
46
- // 转换为 MB
47
- const bytes = parseInt(match[1], 10)
48
- resolve(Math.round(bytes / 1024 / 1024))
49
- } else {
50
- resolve(null)
51
- }
52
- })
53
- .on('error', () => resolve(null))
54
- .on('close', () => resolve(null))
55
- } else {
56
- // Linux/Mac: 使用 ps 获取进程内存
57
- spawn('ps', ['-o', 'rss=', '-p', pid.toString()])
58
- .stdout.on('data', (data) => {
59
- const output = data.toString().trim()
60
- if (output) {
61
- // ps 返回的是 KB,转换为 MB
62
- const kb = parseInt(output, 10)
63
- resolve(Math.round(kb / 1024))
64
- } else {
65
- resolve(null)
66
- }
67
- })
68
- .on('error', () => resolve(null))
69
- .on('close', () => resolve(null))
70
- }
71
- })
72
- }
73
-
74
- /**
75
- * 执行 manim 命令
76
- */
77
- export function executeManimCommand(
78
- codeFile: string,
79
- options: ManimExecuteOptions
80
- ): Promise<ManimExecutionResult> {
81
- const { jobId, quality, frameRate, tempDir, mediaDir } = options
82
-
83
- return new Promise((resolve) => {
84
- const startTime = Date.now()
85
-
86
- // 质量对应的分辨率
87
- const resolutionMap: Record<string, { width: number; height: number }> = {
88
- low: { width: 854, height: 480 },
89
- medium: { width: 1280, height: 720 },
90
- high: { width: 1920, height: 1080 }
91
- }
92
-
93
- const resolution = resolutionMap[quality] || resolutionMap.medium
94
-
95
- const args = [
96
- 'render',
97
- '--format', 'mp4',
98
- '-r', frameRate.toString(),
99
- '--resolution', `${resolution.width},${resolution.height}`,
100
- '--media_dir', mediaDir,
101
- codeFile,
102
- 'MainScene'
103
- ]
104
-
105
- logger.info(`Job ${jobId}: 启动 manim 进程`, {
106
- command: `manim ${args.join(' ')}`,
107
- cwd: tempDir
108
- })
109
-
110
- const proc = spawn('manim', args, { cwd: tempDir })
111
-
112
- let stdout = ''
113
- let stderr = ''
114
- let lastProgressTime = Date.now()
115
- let lastLogTime = Date.now()
116
- let peakMemory = 0 // 峰值内存(MB)
117
-
118
- // 内存监控定时器(每2秒检查一次)
119
- const memoryMonitor = setInterval(async () => {
120
- if (proc.pid) {
121
- const memory = await getProcessMemory(proc.pid)
122
- if (memory) {
123
- if (memory > peakMemory) {
124
- peakMemory = memory
125
- }
126
- logger.info(`Job ${jobId}: Manim 内存使用`, {
127
- memoryMB: memory,
128
- peakMemoryMB: peakMemory
129
- })
130
- }
131
- }
132
- }, 2000)
133
-
134
- proc.stdout.on('data', (data) => {
135
- const text = data.toString()
136
- stdout += text
137
-
138
- // 实时输出所有 stdout(每5秒批量输出一次,避免过于频繁)
139
- const elapsed = Date.now() - lastLogTime
140
- if (elapsed > 5000) {
141
- logger.info(`Job ${jobId}: Manim 进度输出`, {
142
- output: text.trim(),
143
- totalOutputLength: stdout.length
144
- })
145
- lastLogTime = Date.now()
146
- }
147
-
148
- // 检测进度条更新(单独处理,更频繁)
149
- if (text.includes('%') || text.includes('it/s')) {
150
- const progressElapsed = Date.now() - lastProgressTime
151
- if (progressElapsed > 3000) {
152
- logger.info(`Job ${jobId}: 渲染进度`, { progress: text.trim() })
153
- lastProgressTime = Date.now()
154
- }
155
- }
156
- })
157
-
158
- proc.stderr.on('data', (data) => {
159
- const text = data.toString()
160
- stderr += text
161
-
162
- // 实时记录所有 stderr 输出(不论是否包含错误)
163
- logger.info(`Job ${jobId}: Manim stderr 实时输出`, {
164
- output: text.trim(),
165
- totalStderrLength: stderr.length
166
- })
167
- })
168
-
169
- // 设置超时(5分钟)
170
- const timeout = setTimeout(() => {
171
- const elapsed = ((Date.now() - startTime) / 1000).toFixed(1)
172
- logger.warn(`Job ${jobId}: Manim render timeout (${elapsed}s), killing process`, {
173
- peakMemoryMB: peakMemory
174
- })
175
- clearInterval(memoryMonitor)
176
- proc.kill('SIGKILL')
177
- resolve({
178
- success: false,
179
- stdout,
180
- stderr: stderr || 'Manim render timeout (5 minutes)',
181
- peakMemoryMB: peakMemory
182
- })
183
- }, 10 * 60 * 1000)
184
-
185
- proc.on('close', (code) => {
186
- clearTimeout(timeout)
187
- clearInterval(memoryMonitor)
188
- const elapsed = ((Date.now() - startTime) / 1000).toFixed(1)
189
- if (code === 0) {
190
- logger.info(`Job ${jobId}: Manim 成功完成`, {
191
- elapsed: `${elapsed}s`,
192
- exitCode: code,
193
- stdoutLength: stdout.length,
194
- stderrLength: stderr.length,
195
- peakMemoryMB: peakMemory
196
- })
197
- resolve({ success: true, stdout, stderr, peakMemoryMB: peakMemory })
198
- } else {
199
- logger.error(`Job ${jobId}: Manim 退出异常`, {
200
- elapsed: `${elapsed}s`,
201
- exitCode: code,
202
- stdoutLength: stdout.length,
203
- stderrLength: stderr.length,
204
- stderrPreview: stderr.slice(-500),
205
- peakMemoryMB: peakMemory
206
- })
207
- resolve({ success: false, stdout, stderr, peakMemoryMB: peakMemory })
208
- }
209
- })
210
-
211
- proc.on('error', (error) => {
212
- clearTimeout(timeout)
213
- clearInterval(memoryMonitor)
214
- const elapsed = ((Date.now() - startTime) / 1000).toFixed(1)
215
- logger.error(`Job ${jobId}: Manim 进程启动失败`, {
216
- elapsed: `${elapsed}s`,
217
- errorMessage: error.message,
218
- errorStack: error.stack
219
- })
220
- resolve({ success: false, stdout, stderr: error.message, peakMemoryMB: peakMemory })
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": 10,
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
+