Spaces:
Sleeping
Sleeping
新增二次编辑功能
Browse files- README.md +13 -8
- frontend/src/App.tsx +262 -197
- frontend/src/components/AiModifyModal.tsx +94 -0
- frontend/src/components/CodeView.tsx +39 -18
- frontend/src/components/PromptSidebar.tsx +220 -189
- frontend/src/components/PromptsManager.tsx +224 -214
- frontend/src/components/ResultSection.tsx +45 -6
- frontend/src/hooks/useGeneration.ts +56 -2
- frontend/src/hooks/usePrompts.ts +175 -173
- frontend/src/lib/api.ts +20 -1
- frontend/src/types/api.ts +105 -93
- src/config/bull.ts +33 -74
- src/prompts/index.ts +44 -1
- src/queues/processors/steps/render-step.ts +40 -78
- src/queues/processors/steps/storage-step.ts +3 -1
- src/queues/processors/video.processor.ts +52 -3
- src/routes/generate.route.ts +4 -2
- src/routes/index.ts +2 -0
- src/routes/modify.route.ts +142 -0
- src/routes/prompts.route.ts +14 -5
- src/services/code-edit.ts +134 -0
- src/services/code-retry/types.ts +12 -11
- src/types/index.ts +53 -31
README.md
CHANGED
|
@@ -75,7 +75,7 @@ pinned: false
|
|
| 75 |
|
| 76 |
<br>
|
| 77 |
|
| 78 |
-
#
|
| 79 |
|
| 80 |
很荣幸在这里介绍我的新项目ManimCat,它是~一只猫~
|
| 81 |
|
|
@@ -85,7 +85,7 @@ pinned: false
|
|
| 85 |
|
| 86 |
用户只需输入自然语言描述,系统便会通过 AI 自动生成 Manim 代码并渲染出精美的数学可视化视频,支持 LaTeX 公式、模板化生成以及代码错误自动修复,让复杂概念的动态展示变得触手可及。
|
| 87 |
|
| 88 |
-
#
|
| 89 |
|
| 90 |
### 01
|
| 91 |
|
|
@@ -98,7 +98,7 @@ pinned: false
|
|
| 98 |
</div>
|
| 99 |
|
| 100 |
|
| 101 |
-
#
|
| 102 |
|
| 103 |
### 技术栈
|
| 104 |
|
|
@@ -200,11 +200,11 @@ AI_TEMPERATURE=0.7
|
|
| 200 |
CODE_RETRY_MAX_RETRIES=4
|
| 201 |
```
|
| 202 |
|
| 203 |
-
#
|
| 204 |
|
| 205 |
请查看[部署文档](DEPLOYMENT.md)。
|
| 206 |
|
| 207 |
-
#
|
| 208 |
|
| 209 |
我对原作品进行了比较大量的修改和重构,使其更符合我的设计想法:
|
| 210 |
|
|
@@ -295,7 +295,12 @@ CODE_RETRY_MAX_RETRIES=4
|
|
| 295 |
|
| 296 |
</details>
|
| 297 |
|
| 298 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 299 |
|
| 300 |
### 1. 软件协议
|
| 301 |
本项目后端架构及前端部分实现参考/使用了 [manim-video-generator](https://github.com/rohitg00/manim-video-generator) 的核心思想。
|
|
@@ -319,9 +324,9 @@ CODE_RETRY_MAX_RETRIES=4
|
|
| 319 |
|
| 320 |
> ManimCat 的诞生正是为了对标并挑战这些闭源商业软件。 我希望通过开源的方式,让每一位老师都能廉价地享受到 AI 带来的教学可视化便利————你只需要支付api的费用,幸运的是,对于优秀的中国LLM大模型来说,这些花费很廉价。为了保护这一愿景不被商业机构剽窃并反向收割用户,我坚决禁止任何对本项目核心提示词及索引数据的商业授权。
|
| 321 |
|
| 322 |
-
#
|
| 323 |
|
| 324 |
-
由于作者精力有限(个人业余兴趣开发者,非专业背景),
|
| 325 |
|
| 326 |
如果你有好的建议或发现了 Bug,欢迎提交 Issue 进行讨论,我会根据自己的节奏进行改进。如果你希望在本项目基础上进行大规模修改,欢迎 Fork 出属于你自己的版本。
|
| 327 |
|
|
|
|
| 75 |
|
| 76 |
<br>
|
| 77 |
|
| 78 |
+
# 前言
|
| 79 |
|
| 80 |
很荣幸在这里介绍我的新项目ManimCat,它是~一只猫~
|
| 81 |
|
|
|
|
| 85 |
|
| 86 |
用户只需输入自然语言描述,系统便会通过 AI 自动生成 Manim 代码并渲染出精美的数学可视化视频,支持 LaTeX 公式、模板化生成以及代码错误自动修复,让复杂概念的动态展示变得触手可及。
|
| 87 |
|
| 88 |
+
# 样例
|
| 89 |
|
| 90 |
### 01
|
| 91 |
|
|
|
|
| 98 |
</div>
|
| 99 |
|
| 100 |
|
| 101 |
+
# 技术
|
| 102 |
|
| 103 |
### 技术栈
|
| 104 |
|
|
|
|
| 200 |
CODE_RETRY_MAX_RETRIES=4
|
| 201 |
```
|
| 202 |
|
| 203 |
+
# 部署
|
| 204 |
|
| 205 |
请查看[部署文档](DEPLOYMENT.md)。
|
| 206 |
|
| 207 |
+
# 贡献
|
| 208 |
|
| 209 |
我对原作品进行了比较大量的修改和重构,使其更符合我的设计想法:
|
| 210 |
|
|
|
|
| 295 |
|
| 296 |
</details>
|
| 297 |
|
| 298 |
+
|
| 299 |
+
# 现状
|
| 300 |
+
|
| 301 |
+
目前,该项目功能非常初级,最大的缺点是生成与渲染花费时间不小,非常慢。我正在试图解决这个问题!
|
| 302 |
+
|
| 303 |
+
# 开源与版权声明
|
| 304 |
|
| 305 |
### 1. 软件协议
|
| 306 |
本项目后端架构及前端部分实现参考/使用了 [manim-video-generator](https://github.com/rohitg00/manim-video-generator) 的核心思想。
|
|
|
|
| 324 |
|
| 325 |
> ManimCat 的诞生正是为了对标并挑战这些闭源商业软件。 我希望通过开源的方式,让每一位老师都能廉价地享受到 AI 带来的教学可视化便利————你只需要支付api的费用,幸运的是,对于优秀的中国LLM大模型来说,这些花费很廉价。为了保护这一愿景不被商业机构剽窃并反向收割用户,我坚决禁止任何对本项目核心提示词及索引数据的商业授权。
|
| 326 |
|
| 327 |
+
# 维护说明
|
| 328 |
|
| 329 |
+
由于作者精力有限(个人业余兴趣开发者,非专业背景),可能无法对外部代码进行有效的审查和长期维护。欢迎PR修复各类问题,不过过多的修改可能作者需要很长时间的审核。感谢理解。
|
| 330 |
|
| 331 |
如果你有好的建议或发现了 Bug,欢迎提交 Issue 进行讨论,我会根据自己的节奏进行改进。如果你希望在本项目基础上进行大规模修改,欢迎 Fork 出属于你自己的版本。
|
| 332 |
|
frontend/src/App.tsx
CHANGED
|
@@ -1,197 +1,262 @@
|
|
| 1 |
-
// 主应用组件
|
| 2 |
-
|
| 3 |
-
import { useState } from 'react';
|
| 4 |
-
import { useGeneration } from './hooks/useGeneration';
|
| 5 |
-
import { InputForm } from './components/InputForm';
|
| 6 |
-
import { LoadingSpinner } from './components/LoadingSpinner';
|
| 7 |
-
import { ResultSection } from './components/ResultSection';
|
| 8 |
-
import {
|
| 9 |
-
import {
|
| 10 |
-
import {
|
| 11 |
-
import {
|
| 12 |
-
import
|
| 13 |
-
import
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
const
|
| 18 |
-
const [
|
| 19 |
-
const [
|
| 20 |
-
|
| 21 |
-
const
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
<
|
| 86 |
-
|
| 87 |
-
</
|
| 88 |
-
</
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
)}
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
<
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// 主应用组件
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect } from 'react';
|
| 4 |
+
import { useGeneration } from './hooks/useGeneration';
|
| 5 |
+
import { InputForm } from './components/InputForm';
|
| 6 |
+
import { LoadingSpinner } from './components/LoadingSpinner';
|
| 7 |
+
import { ResultSection } from './components/ResultSection';
|
| 8 |
+
import { AiModifyModal } from './components/AiModifyModal';
|
| 9 |
+
import { ThemeToggle } from './components/ThemeToggle';
|
| 10 |
+
import { SettingsModal } from './components/SettingsModal';
|
| 11 |
+
import { PromptsManager } from './components/PromptsManager';
|
| 12 |
+
import { DonationModal } from './components/DonationModal';
|
| 13 |
+
import ManimCatLogo from './components/ManimCatLogo';
|
| 14 |
+
import type { Quality } from './types/api';
|
| 15 |
+
|
| 16 |
+
function App() {
|
| 17 |
+
const { status, result, error, jobId, stage, generate, renderWithCode, modifyWithAI, reset, cancel } = useGeneration();
|
| 18 |
+
const [settingsOpen, setSettingsOpen] = useState(false);
|
| 19 |
+
const [currentCode, setCurrentCode] = useState('');
|
| 20 |
+
const [lastRequest, setLastRequest] = useState<{ concept: string; quality: Quality; forceRefresh: boolean } | null>(null);
|
| 21 |
+
const [aiModifyOpen, setAiModifyOpen] = useState(false);
|
| 22 |
+
const [aiModifyInput, setAiModifyInput] = useState('');
|
| 23 |
+
|
| 24 |
+
const [promptsOpen, setPromptsOpen] = useState(false);
|
| 25 |
+
const [donationOpen, setDonationOpen] = useState(false);
|
| 26 |
+
|
| 27 |
+
useEffect(() => {
|
| 28 |
+
if (result?.code) {
|
| 29 |
+
setCurrentCode(result.code);
|
| 30 |
+
}
|
| 31 |
+
}, [result?.code]);
|
| 32 |
+
|
| 33 |
+
const handleSubmit = (data: { concept: string; quality: Quality; forceRefresh: boolean }) => {
|
| 34 |
+
setLastRequest(data);
|
| 35 |
+
generate(data);
|
| 36 |
+
};
|
| 37 |
+
|
| 38 |
+
const handleRerender = () => {
|
| 39 |
+
if (!lastRequest || !currentCode.trim()) {
|
| 40 |
+
return;
|
| 41 |
+
}
|
| 42 |
+
renderWithCode({ ...lastRequest, code: currentCode });
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
+
const handleAiModifySubmit = () => {
|
| 46 |
+
if (!lastRequest || !currentCode.trim()) {
|
| 47 |
+
return;
|
| 48 |
+
}
|
| 49 |
+
const instructions = aiModifyInput.trim();
|
| 50 |
+
if (!instructions) {
|
| 51 |
+
return;
|
| 52 |
+
}
|
| 53 |
+
setAiModifyOpen(false);
|
| 54 |
+
setAiModifyInput('');
|
| 55 |
+
modifyWithAI({
|
| 56 |
+
concept: lastRequest.concept,
|
| 57 |
+
quality: lastRequest.quality,
|
| 58 |
+
instructions,
|
| 59 |
+
code: currentCode
|
| 60 |
+
});
|
| 61 |
+
};
|
| 62 |
+
|
| 63 |
+
const isBusy = status === 'processing';
|
| 64 |
+
|
| 65 |
+
return (
|
| 66 |
+
<div className="min-h-screen bg-bg-primary transition-colors duration-300">
|
| 67 |
+
{/* 左上角图标 */}
|
| 68 |
+
<div className="fixed top-4 left-4 z-50 flex items-center gap-2">
|
| 69 |
+
<a
|
| 70 |
+
href="https://github.com/Wing900/ManimCat"
|
| 71 |
+
target="_blank"
|
| 72 |
+
rel="noopener noreferrer"
|
| 73 |
+
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"
|
| 74 |
+
title="GitHub"
|
| 75 |
+
>
|
| 76 |
+
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
| 77 |
+
<path fillRule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clipRule="evenodd" />
|
| 78 |
+
</svg>
|
| 79 |
+
</a>
|
| 80 |
+
<button
|
| 81 |
+
onClick={() => setDonationOpen(true)}
|
| 82 |
+
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"
|
| 83 |
+
title="请喝可乐"
|
| 84 |
+
>
|
| 85 |
+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 86 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8h1a4 4 0 010 8h-1m-3.413-8.866A6.501 6.501 0 0012 3c-1.93 0-3.694.84-4.9 2.176M4 20h16a1 1 0 001-1v-1a1 1 0 00-1-1H4a1 1 0 00-1 1v1a1 1 0 001 1zm1-9.5V12a3 3 0 003 3h8a3 3 0 003-3v-1.5M9 8h6" />
|
| 87 |
+
</svg>
|
| 88 |
+
</button>
|
| 89 |
+
</div>
|
| 90 |
+
|
| 91 |
+
{/* 右上角按钮 */}
|
| 92 |
+
<div className="fixed top-4 right-4 z-50 flex items-center gap-2">
|
| 93 |
+
<button
|
| 94 |
+
onClick={() => setPromptsOpen(true)}
|
| 95 |
+
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"
|
| 96 |
+
title="提示词管理"
|
| 97 |
+
>
|
| 98 |
+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 99 |
+
<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" />
|
| 100 |
+
</svg>
|
| 101 |
+
</button>
|
| 102 |
+
<button
|
| 103 |
+
onClick={() => setSettingsOpen(true)}
|
| 104 |
+
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"
|
| 105 |
+
title="API 设置"
|
| 106 |
+
>
|
| 107 |
+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 108 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
| 109 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
| 110 |
+
</svg>
|
| 111 |
+
</button>
|
| 112 |
+
<ThemeToggle />
|
| 113 |
+
</div>
|
| 114 |
+
|
| 115 |
+
{/* 主容器 - 使用黄金分割比例调整垂直位置 */}
|
| 116 |
+
<div className="max-w-4xl mx-auto px-4 min-h-screen flex flex-col justify-center" style={{ paddingTop: '20vh', paddingBottom: '32vh' }}>
|
| 117 |
+
{/* 标题 */}
|
| 118 |
+
<div className="text-center mb-12">
|
| 119 |
+
<div className="flex items-center justify-center gap-4 mb-3">
|
| 120 |
+
<ManimCatLogo className="w-16 h-16" />
|
| 121 |
+
<h1 className="text-5xl sm:text-6xl font-light tracking-tight text-text-primary">
|
| 122 |
+
ManimCat
|
| 123 |
+
</h1>
|
| 124 |
+
</div>
|
| 125 |
+
<p className="text-sm text-text-secondary/70 max-w-lg mx-auto">
|
| 126 |
+
用 AI 驱动 Manim 生成数学动画
|
| 127 |
+
</p>
|
| 128 |
+
</div>
|
| 129 |
+
|
| 130 |
+
{/* 状态显示区域 */}
|
| 131 |
+
<div className="mb-6">
|
| 132 |
+
{status === 'idle' && (
|
| 133 |
+
<InputForm
|
| 134 |
+
onSubmit={handleSubmit}
|
| 135 |
+
loading={false}
|
| 136 |
+
/>
|
| 137 |
+
)}
|
| 138 |
+
|
| 139 |
+
{status === 'processing' && (
|
| 140 |
+
<div className="bg-bg-secondary/20 rounded-2xl p-8">
|
| 141 |
+
<LoadingSpinner stage={stage} jobId={jobId || undefined} onCancel={cancel} />
|
| 142 |
+
</div>
|
| 143 |
+
)}
|
| 144 |
+
|
| 145 |
+
{status === 'completed' && result && (
|
| 146 |
+
<div
|
| 147 |
+
className="space-y-6 animate-fade-in"
|
| 148 |
+
style={{
|
| 149 |
+
animation: 'fadeInUp 0.5s ease-out forwards',
|
| 150 |
+
}}
|
| 151 |
+
>
|
| 152 |
+
{/* 结果展示 */}
|
| 153 |
+
<ResultSection
|
| 154 |
+
code={currentCode || result.code || ''}
|
| 155 |
+
videoUrl={result.video_url || ''}
|
| 156 |
+
usedAI={result.used_ai || false}
|
| 157 |
+
renderQuality={result.render_quality || ''}
|
| 158 |
+
generationType={result.generation_type || ''}
|
| 159 |
+
onCodeChange={setCurrentCode}
|
| 160 |
+
onRerender={handleRerender}
|
| 161 |
+
onAiModify={() => setAiModifyOpen(true)}
|
| 162 |
+
isBusy={isBusy}
|
| 163 |
+
/>
|
| 164 |
+
|
| 165 |
+
{/* 重新生成按钮 */}
|
| 166 |
+
<div className="text-center">
|
| 167 |
+
<button
|
| 168 |
+
onClick={() => {
|
| 169 |
+
reset();
|
| 170 |
+
setCurrentCode('');
|
| 171 |
+
setLastRequest(null);
|
| 172 |
+
setAiModifyInput('');
|
| 173 |
+
setAiModifyOpen(false);
|
| 174 |
+
}}
|
| 175 |
+
className="px-8 py-2.5 text-sm text-text-secondary/80 hover:text-accent transition-colors bg-bg-secondary/30 hover:bg-bg-secondary/50 rounded-full"
|
| 176 |
+
>
|
| 177 |
+
生成新的动画
|
| 178 |
+
</button>
|
| 179 |
+
</div>
|
| 180 |
+
</div>
|
| 181 |
+
)}
|
| 182 |
+
|
| 183 |
+
{status === 'error' && (
|
| 184 |
+
<div className="bg-red-50/80 dark:bg-red-900/20 rounded-2xl p-6">
|
| 185 |
+
<div className="flex items-start gap-3">
|
| 186 |
+
<div className="text-red-500 mt-0.5">
|
| 187 |
+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 188 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
| 189 |
+
</svg>
|
| 190 |
+
</div>
|
| 191 |
+
<div className="flex-1">
|
| 192 |
+
<p className="text-text-primary font-medium mb-1">出错了</p>
|
| 193 |
+
<p className="text-text-secondary text-sm">{error || '生成失败,请重试'}</p>
|
| 194 |
+
</div>
|
| 195 |
+
</div>
|
| 196 |
+
<div className="mt-4 flex gap-3">
|
| 197 |
+
<button
|
| 198 |
+
onClick={() => {
|
| 199 |
+
reset();
|
| 200 |
+
setCurrentCode('');
|
| 201 |
+
setLastRequest(null);
|
| 202 |
+
setAiModifyInput('');
|
| 203 |
+
setAiModifyOpen(false);
|
| 204 |
+
}}
|
| 205 |
+
className="px-4 py-2 text-sm text-accent hover:text-accent-hover transition-colors"
|
| 206 |
+
>
|
| 207 |
+
重试
|
| 208 |
+
</button>
|
| 209 |
+
</div>
|
| 210 |
+
</div>
|
| 211 |
+
)}
|
| 212 |
+
</div>
|
| 213 |
+
</div>
|
| 214 |
+
|
| 215 |
+
{/* 添加淡入上浮动画 */}
|
| 216 |
+
<style>{`
|
| 217 |
+
@keyframes fadeInUp {
|
| 218 |
+
0% {
|
| 219 |
+
opacity: 0;
|
| 220 |
+
transform: translateY(30px);
|
| 221 |
+
}
|
| 222 |
+
100% {
|
| 223 |
+
opacity: 1;
|
| 224 |
+
transform: translateY(0);
|
| 225 |
+
}
|
| 226 |
+
}
|
| 227 |
+
`}</style>
|
| 228 |
+
|
| 229 |
+
{/* 设置模态框 */}
|
| 230 |
+
<SettingsModal
|
| 231 |
+
isOpen={settingsOpen}
|
| 232 |
+
onClose={() => setSettingsOpen(false)}
|
| 233 |
+
onSave={(config) => {
|
| 234 |
+
console.log('保存配置:', config);
|
| 235 |
+
}}
|
| 236 |
+
/>
|
| 237 |
+
|
| 238 |
+
{/* 提示词管理 */}
|
| 239 |
+
<PromptsManager
|
| 240 |
+
isOpen={promptsOpen}
|
| 241 |
+
onClose={() => setPromptsOpen(false)}
|
| 242 |
+
/>
|
| 243 |
+
|
| 244 |
+
{/* 捐赠模态框 */}
|
| 245 |
+
<DonationModal
|
| 246 |
+
isOpen={donationOpen}
|
| 247 |
+
onClose={() => setDonationOpen(false)}
|
| 248 |
+
/>
|
| 249 |
+
|
| 250 |
+
<AiModifyModal
|
| 251 |
+
isOpen={aiModifyOpen}
|
| 252 |
+
value={aiModifyInput}
|
| 253 |
+
loading={isBusy}
|
| 254 |
+
onChange={setAiModifyInput}
|
| 255 |
+
onClose={() => setAiModifyOpen(false)}
|
| 256 |
+
onSubmit={handleAiModifySubmit}
|
| 257 |
+
/>
|
| 258 |
+
</div>
|
| 259 |
+
);
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
export default App;
|
frontend/src/components/AiModifyModal.tsx
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// AI 修改对话框
|
| 2 |
+
|
| 3 |
+
interface AiModifyModalProps {
|
| 4 |
+
isOpen: boolean;
|
| 5 |
+
value: string;
|
| 6 |
+
loading?: boolean;
|
| 7 |
+
onChange: (value: string) => void;
|
| 8 |
+
onClose: () => void;
|
| 9 |
+
onSubmit: () => void;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export function AiModifyModal({ isOpen, value, loading = false, onChange, onClose, onSubmit }: AiModifyModalProps) {
|
| 13 |
+
if (!isOpen) return null;
|
| 14 |
+
|
| 15 |
+
return (
|
| 16 |
+
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
| 17 |
+
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
|
| 18 |
+
|
| 19 |
+
<div className="relative w-full max-w-lg bg-bg-secondary rounded-2xl p-6 shadow-xl animate-fade-in">
|
| 20 |
+
<div className="flex items-center justify-between mb-4">
|
| 21 |
+
<div className="flex items-center gap-2">
|
| 22 |
+
<svg className="w-6 h-6 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 23 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.5v15m7.5-7.5h-15" />
|
| 24 |
+
</svg>
|
| 25 |
+
<h2 className="text-lg font-medium text-text-primary">AI修改</h2>
|
| 26 |
+
</div>
|
| 27 |
+
<button
|
| 28 |
+
onClick={onClose}
|
| 29 |
+
className="p-1.5 text-text-secondary/70 hover:text-text-secondary hover:bg-bg-primary/50 rounded-full transition-all"
|
| 30 |
+
>
|
| 31 |
+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 32 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
| 33 |
+
</svg>
|
| 34 |
+
</button>
|
| 35 |
+
</div>
|
| 36 |
+
|
| 37 |
+
<p className="text-text-secondary text-sm mb-4">
|
| 38 |
+
请描述你希望 AI 如何修改当前代码。
|
| 39 |
+
</p>
|
| 40 |
+
|
| 41 |
+
<div className="relative mb-6">
|
| 42 |
+
<label
|
| 43 |
+
htmlFor="aiModifyInput"
|
| 44 |
+
className="absolute left-4 -top-2.5 px-2 bg-bg-secondary text-xs font-medium text-text-secondary"
|
| 45 |
+
>
|
| 46 |
+
修改意见
|
| 47 |
+
</label>
|
| 48 |
+
<textarea
|
| 49 |
+
id="aiModifyInput"
|
| 50 |
+
rows={5}
|
| 51 |
+
value={value}
|
| 52 |
+
onChange={(e) => onChange(e.target.value)}
|
| 53 |
+
placeholder="例如:让标题更醒目,加入颜色渐变,缩短动画时长..."
|
| 54 |
+
className="w-full px-4 py-4 bg-bg-secondary/50 rounded-2xl text-sm text-text-primary placeholder-text-secondary/40 focus:outline-none focus:ring-2 focus:ring-accent/20 focus:bg-bg-secondary/70 transition-all resize-none"
|
| 55 |
+
/>
|
| 56 |
+
</div>
|
| 57 |
+
|
| 58 |
+
<div className="flex gap-3">
|
| 59 |
+
<button
|
| 60 |
+
onClick={onClose}
|
| 61 |
+
disabled={loading}
|
| 62 |
+
className="flex-1 px-4 py-2.5 text-sm text-text-secondary hover:text-text-primary bg-bg-primary hover:bg-bg-tertiary rounded-xl transition-all active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed"
|
| 63 |
+
>
|
| 64 |
+
取消
|
| 65 |
+
</button>
|
| 66 |
+
<button
|
| 67 |
+
onClick={onSubmit}
|
| 68 |
+
disabled={loading || value.trim().length === 0}
|
| 69 |
+
className="flex-1 px-4 py-2.5 text-sm text-white bg-accent hover:bg-accent-hover rounded-xl transition-all active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed"
|
| 70 |
+
>
|
| 71 |
+
{loading ? '提交中...' : '提交修改'}
|
| 72 |
+
</button>
|
| 73 |
+
</div>
|
| 74 |
+
</div>
|
| 75 |
+
|
| 76 |
+
<style>{`
|
| 77 |
+
@keyframes fadeIn {
|
| 78 |
+
0% {
|
| 79 |
+
opacity: 0;
|
| 80 |
+
transform: scale(0.95);
|
| 81 |
+
}
|
| 82 |
+
100% {
|
| 83 |
+
opacity: 1;
|
| 84 |
+
transform: scale(1);
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
.animate-fade-in {
|
| 88 |
+
animation: fadeIn 0.2s ease-out forwards;
|
| 89 |
+
}
|
| 90 |
+
`}</style>
|
| 91 |
+
</div>
|
| 92 |
+
);
|
| 93 |
+
}
|
| 94 |
+
|
frontend/src/components/CodeView.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
// 代码预览组件
|
| 2 |
|
| 3 |
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
| 4 |
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
|
@@ -6,9 +6,12 @@ import { useState } from 'react';
|
|
| 6 |
|
| 7 |
interface CodeViewProps {
|
| 8 |
code: string;
|
|
|
|
|
|
|
|
|
|
| 9 |
}
|
| 10 |
|
| 11 |
-
export function CodeView({ code }: CodeViewProps) {
|
| 12 |
const [copied, setCopied] = useState(false);
|
| 13 |
|
| 14 |
const handleCopy = async () => {
|
|
@@ -17,6 +20,14 @@ export function CodeView({ code }: CodeViewProps) {
|
|
| 17 |
setTimeout(() => setCopied(false), 2000);
|
| 18 |
};
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
return (
|
| 21 |
<div className="h-full flex flex-col bg-bg-secondary/30 rounded-2xl overflow-hidden">
|
| 22 |
{/* 顶部工具栏 */}
|
|
@@ -46,22 +57,32 @@ export function CodeView({ code }: CodeViewProps) {
|
|
| 46 |
|
| 47 |
{/* 代码区域 */}
|
| 48 |
<div className="flex-1 overflow-auto">
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
</div>
|
| 65 |
</div>
|
| 66 |
);
|
| 67 |
-
}
|
|
|
|
| 1 |
+
// 代码预览组件
|
| 2 |
|
| 3 |
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
| 4 |
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
|
|
|
| 6 |
|
| 7 |
interface CodeViewProps {
|
| 8 |
code: string;
|
| 9 |
+
editable?: boolean;
|
| 10 |
+
onChange?: (value: string) => void;
|
| 11 |
+
disabled?: boolean;
|
| 12 |
}
|
| 13 |
|
| 14 |
+
export function CodeView({ code, editable = false, onChange, disabled = false }: CodeViewProps) {
|
| 15 |
const [copied, setCopied] = useState(false);
|
| 16 |
|
| 17 |
const handleCopy = async () => {
|
|
|
|
| 20 |
setTimeout(() => setCopied(false), 2000);
|
| 21 |
};
|
| 22 |
|
| 23 |
+
const textareaClassName = [
|
| 24 |
+
'w-full h-full resize-none bg-transparent p-4 text-[0.75rem] leading-relaxed',
|
| 25 |
+
'font-mono text-text-primary/90 focus:outline-none',
|
| 26 |
+
disabled ? 'opacity-60 cursor-not-allowed' : ''
|
| 27 |
+
]
|
| 28 |
+
.filter(Boolean)
|
| 29 |
+
.join(' ');
|
| 30 |
+
|
| 31 |
return (
|
| 32 |
<div className="h-full flex flex-col bg-bg-secondary/30 rounded-2xl overflow-hidden">
|
| 33 |
{/* 顶部工具栏 */}
|
|
|
|
| 57 |
|
| 58 |
{/* 代码区域 */}
|
| 59 |
<div className="flex-1 overflow-auto">
|
| 60 |
+
{editable ? (
|
| 61 |
+
<textarea
|
| 62 |
+
value={code}
|
| 63 |
+
onChange={(event) => onChange?.(event.target.value)}
|
| 64 |
+
className={textareaClassName}
|
| 65 |
+
disabled={disabled}
|
| 66 |
+
spellCheck={false}
|
| 67 |
+
/>
|
| 68 |
+
) : (
|
| 69 |
+
<SyntaxHighlighter
|
| 70 |
+
language="python"
|
| 71 |
+
style={vscDarkPlus}
|
| 72 |
+
customStyle={{
|
| 73 |
+
margin: 0,
|
| 74 |
+
padding: '1rem',
|
| 75 |
+
fontSize: '0.75rem',
|
| 76 |
+
lineHeight: '1.6',
|
| 77 |
+
fontFamily: 'Monaco, Cascadia Code, Roboto Mono, monospace',
|
| 78 |
+
background: 'transparent'
|
| 79 |
+
}}
|
| 80 |
+
showLineNumbers
|
| 81 |
+
>
|
| 82 |
+
{code}
|
| 83 |
+
</SyntaxHighlighter>
|
| 84 |
+
)}
|
| 85 |
</div>
|
| 86 |
</div>
|
| 87 |
);
|
| 88 |
+
}
|
frontend/src/components/PromptSidebar.tsx
CHANGED
|
@@ -1,189 +1,220 @@
|
|
| 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="
|
| 177 |
-
</svg>
|
| 178 |
-
}
|
| 179 |
-
label="
|
| 180 |
-
active={activeSection === 'system' && activePrompt === '
|
| 181 |
-
onClick={() => onSectionChange('system', '
|
| 182 |
-
indent
|
| 183 |
-
/>
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 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 |
+
{/* AI修改提示词 */}
|
| 168 |
+
<div>
|
| 169 |
+
<h3 className="px-4 text-xs font-medium text-text-secondary/60 uppercase tracking-wider mb-2">
|
| 170 |
+
AI修改
|
| 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="M12 4.354a4 4 0 110 15.292M15 21H9m6 0a3 3 0 01-6 0m6 0H9" />
|
| 177 |
+
</svg>
|
| 178 |
+
}
|
| 179 |
+
label="系统提示词"
|
| 180 |
+
active={activeSection === 'system' && activePrompt === 'codeEdit'}
|
| 181 |
+
onClick={() => onSectionChange('system', 'codeEdit')}
|
| 182 |
+
indent
|
| 183 |
+
/>
|
| 184 |
+
<SidebarItem
|
| 185 |
+
icon={
|
| 186 |
+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 187 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
| 188 |
+
</svg>
|
| 189 |
+
}
|
| 190 |
+
label="用户提示词"
|
| 191 |
+
active={activeSection === 'user' && activePrompt === 'codeEdit'}
|
| 192 |
+
onClick={() => onSectionChange('user', 'codeEdit')}
|
| 193 |
+
indent
|
| 194 |
+
/>
|
| 195 |
+
</div>
|
| 196 |
+
</div>
|
| 197 |
+
|
| 198 |
+
{/* 系统重试提示词 */}
|
| 199 |
+
<div>
|
| 200 |
+
<h3 className="px-4 text-xs font-medium text-text-secondary/60 uppercase tracking-wider mb-2">
|
| 201 |
+
系统重试
|
| 202 |
+
</h3>
|
| 203 |
+
<div className="space-y-1">
|
| 204 |
+
<SidebarItem
|
| 205 |
+
icon={
|
| 206 |
+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 207 |
+
<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" />
|
| 208 |
+
</svg>
|
| 209 |
+
}
|
| 210 |
+
label="重试提示词"
|
| 211 |
+
active={activeSection === 'system' && activePrompt === 'codeRetry'}
|
| 212 |
+
onClick={() => onSectionChange('system', 'codeRetry')}
|
| 213 |
+
indent
|
| 214 |
+
/>
|
| 215 |
+
</div>
|
| 216 |
+
</div>
|
| 217 |
+
</div>
|
| 218 |
+
</div>
|
| 219 |
+
);
|
| 220 |
+
}
|
frontend/src/components/PromptsManager.tsx
CHANGED
|
@@ -1,214 +1,224 @@
|
|
| 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 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
label: '
|
| 41 |
-
placeholder: '输入
|
| 42 |
-
description: '补充说明
|
| 43 |
-
},
|
| 44 |
-
|
| 45 |
-
label: '代码
|
| 46 |
-
placeholder: '输入代码
|
| 47 |
-
description: '代码
|
| 48 |
-
},
|
| 49 |
-
|
| 50 |
-
label: '代码修复提示词',
|
| 51 |
-
placeholder: '输入代码修复阶段的提示词...',
|
| 52 |
-
description: '代码第
|
| 53 |
-
}
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
setSaveStatus('
|
| 109 |
-
setTimeout(() =>
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
</
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
className="
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
<>
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
/>
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
{/*
|
| 189 |
-
<div className="
|
| 190 |
-
<
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
codeEdit: {
|
| 33 |
+
label: 'AI修改系统提示词',
|
| 34 |
+
placeholder: '用于 AI 修改阶段的系统提示词...',
|
| 35 |
+
description: '用于指导 AI 基于现有代码进行修改'
|
| 36 |
+
}
|
| 37 |
+
},
|
| 38 |
+
user: {
|
| 39 |
+
conceptDesigner: {
|
| 40 |
+
label: '概念设计用户提示词',
|
| 41 |
+
placeholder: '输入概念设计阶段的用户提示词...',
|
| 42 |
+
description: '补充说明概念设计的具体需求和风格'
|
| 43 |
+
},
|
| 44 |
+
codeGeneration: {
|
| 45 |
+
label: '代码生成用户提示词',
|
| 46 |
+
placeholder: '输入代码生成阶段的用户提示词...',
|
| 47 |
+
description: '补充说明代码生成的具体要求和规范'
|
| 48 |
+
},
|
| 49 |
+
codeRetryInitial: {
|
| 50 |
+
label: '代码修复初始重试提示词',
|
| 51 |
+
placeholder: '输入代码修复初始重试阶段的提示词...',
|
| 52 |
+
description: '代码第一次失败时的修复指导'
|
| 53 |
+
},
|
| 54 |
+
codeRetryFix: {
|
| 55 |
+
label: '代码修复提示词',
|
| 56 |
+
placeholder: '输入代码修复阶段的提示词...',
|
| 57 |
+
description: '代码第二次失败时的详细修复指导'
|
| 58 |
+
},
|
| 59 |
+
codeEdit: {
|
| 60 |
+
label: 'AI修改用户提示词',
|
| 61 |
+
placeholder: '用于 AI 修改阶段的用户提示词...',
|
| 62 |
+
description: '用于描述修改目标、约束与期望效果'
|
| 63 |
+
}
|
| 64 |
+
}
|
| 65 |
+
};
|
| 66 |
+
|
| 67 |
+
export function PromptsManager({ isOpen, onClose }: PromptsManagerProps) {
|
| 68 |
+
const {
|
| 69 |
+
isLoading,
|
| 70 |
+
activeSection,
|
| 71 |
+
activePrompt,
|
| 72 |
+
setActiveSection,
|
| 73 |
+
setActivePrompt,
|
| 74 |
+
getCurrentPrompt,
|
| 75 |
+
setCurrentPrompt,
|
| 76 |
+
restoreDefault
|
| 77 |
+
} = usePrompts();
|
| 78 |
+
|
| 79 |
+
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'success' | 'error'>('idle');
|
| 80 |
+
|
| 81 |
+
const TRANSITION_MS = 400;
|
| 82 |
+
|
| 83 |
+
const [shouldRender, setShouldRender] = useState(isOpen);
|
| 84 |
+
|
| 85 |
+
const [isVisible, setIsVisible] = useState(isOpen);
|
| 86 |
+
|
| 87 |
+
useEffect(() => {
|
| 88 |
+
if (isOpen) {
|
| 89 |
+
setShouldRender(true);
|
| 90 |
+
// 延迟一帧再显示,确保初始 opacity-0 生效
|
| 91 |
+
setTimeout(() => setIsVisible(true), 50);
|
| 92 |
+
} else {
|
| 93 |
+
setIsVisible(false);
|
| 94 |
+
const timeout = window.setTimeout(() => setShouldRender(false), 400);
|
| 95 |
+
return () => window.clearTimeout(timeout);
|
| 96 |
+
}
|
| 97 |
+
}, [isOpen]);
|
| 98 |
+
|
| 99 |
+
// 处理侧边栏导航
|
| 100 |
+
const handleSectionChange = (section: string, prompt: string) => {
|
| 101 |
+
setActiveSection(section);
|
| 102 |
+
setActivePrompt(prompt);
|
| 103 |
+
setSaveStatus('idle');
|
| 104 |
+
};
|
| 105 |
+
|
| 106 |
+
// 处理保存
|
| 107 |
+
const handleSave = () => {
|
| 108 |
+
setSaveStatus('saving');
|
| 109 |
+
setTimeout(() => {
|
| 110 |
+
setSaveStatus('success');
|
| 111 |
+
setTimeout(() => setSaveStatus('idle'), 2000);
|
| 112 |
+
}, 500);
|
| 113 |
+
};
|
| 114 |
+
|
| 115 |
+
// 处理恢复默认
|
| 116 |
+
const handleRestoreDefault = () => {
|
| 117 |
+
restoreDefault();
|
| 118 |
+
setSaveStatus('success');
|
| 119 |
+
setTimeout(() => setSaveStatus('idle'), 2000);
|
| 120 |
+
};
|
| 121 |
+
|
| 122 |
+
// 获取当前配置
|
| 123 |
+
const currentConfig = activeSection === 'system'
|
| 124 |
+
? PROMPT_CONFIG.system[activePrompt as keyof typeof PROMPT_CONFIG.system]
|
| 125 |
+
: PROMPT_CONFIG.user[activePrompt as keyof typeof PROMPT_CONFIG.user];
|
| 126 |
+
|
| 127 |
+
if (!shouldRender) return null;
|
| 128 |
+
|
| 129 |
+
return (
|
| 130 |
+
<div
|
| 131 |
+
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'}`}
|
| 132 |
+
>
|
| 133 |
+
{/* 顶部导航栏 */}
|
| 134 |
+
<div className="h-16 bg-bg-secondary border-b border-bg-secondary/50 flex items-center justify-between px-6">
|
| 135 |
+
<div className="flex items-center gap-4">
|
| 136 |
+
<button
|
| 137 |
+
onClick={onClose}
|
| 138 |
+
className="p-2 text-text-secondary hover:text-text-primary hover:bg-bg-secondary/50 rounded-lg transition-colors"
|
| 139 |
+
title="返回主界面"
|
| 140 |
+
>
|
| 141 |
+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 142 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
| 143 |
+
</svg>
|
| 144 |
+
</button>
|
| 145 |
+
<h1 className="text-lg font-medium text-text-primary">提示词管理</h1>
|
| 146 |
+
</div>
|
| 147 |
+
<div className="flex items-center gap-3">
|
| 148 |
+
{saveStatus === 'success' && (
|
| 149 |
+
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
|
| 150 |
+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 151 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
| 152 |
+
</svg>
|
| 153 |
+
<span>保存成功</span>
|
| 154 |
+
</div>
|
| 155 |
+
)}
|
| 156 |
+
<button
|
| 157 |
+
onClick={handleSave}
|
| 158 |
+
disabled={saveStatus === 'saving'}
|
| 159 |
+
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"
|
| 160 |
+
>
|
| 161 |
+
{saveStatus === 'saving' ? (
|
| 162 |
+
<>
|
| 163 |
+
<svg className="animate-spin w-4 h-4 inline mr-2" fill="none" viewBox="0 0 24 24">
|
| 164 |
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
| 165 |
+
<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" />
|
| 166 |
+
</svg>
|
| 167 |
+
保存中...
|
| 168 |
+
</>
|
| 169 |
+
) : (
|
| 170 |
+
'保存'
|
| 171 |
+
)}
|
| 172 |
+
</button>
|
| 173 |
+
</div>
|
| 174 |
+
</div>
|
| 175 |
+
|
| 176 |
+
{/* 主内容区 */}
|
| 177 |
+
<div className="flex-1 flex overflow-hidden">
|
| 178 |
+
{/* 侧边栏 */}
|
| 179 |
+
<PromptSidebar
|
| 180 |
+
activeSection={activeSection}
|
| 181 |
+
activePrompt={activePrompt}
|
| 182 |
+
onSectionChange={handleSectionChange}
|
| 183 |
+
/>
|
| 184 |
+
|
| 185 |
+
{/* 主编辑区 */}
|
| 186 |
+
<div className="flex-1 overflow-y-auto p-8">
|
| 187 |
+
<div className="max-w-4xl mx-auto space-y-8">
|
| 188 |
+
{/* 当前提示词信息 */}
|
| 189 |
+
<div className="bg-bg-secondary/30 rounded-xl p-6">
|
| 190 |
+
<h2 className="text-xl font-medium text-text-primary mb-2">
|
| 191 |
+
{currentConfig?.label || '提示词编辑'}
|
| 192 |
+
</h2>
|
| 193 |
+
<p className="text-sm text-text-secondary/70">
|
| 194 |
+
{currentConfig?.description || '在此处编辑提示词,它将用于指导 AI 生成和优化动画'}
|
| 195 |
+
</p>
|
| 196 |
+
</div>
|
| 197 |
+
|
| 198 |
+
{/* 输入组件 */}
|
| 199 |
+
<div className="space-y-6">
|
| 200 |
+
<PromptInput
|
| 201 |
+
value={getCurrentPrompt()}
|
| 202 |
+
onChange={setCurrentPrompt}
|
| 203 |
+
label={currentConfig?.label || '提示词'}
|
| 204 |
+
placeholder={currentConfig?.placeholder}
|
| 205 |
+
showWordCount
|
| 206 |
+
disabled={isLoading}
|
| 207 |
+
onSave={handleSave}
|
| 208 |
+
onRestoreDefault={handleRestoreDefault}
|
| 209 |
+
/>
|
| 210 |
+
|
| 211 |
+
</div>
|
| 212 |
+
</div>
|
| 213 |
+
</div>
|
| 214 |
+
</div>
|
| 215 |
+
|
| 216 |
+
{/* 底部状态栏 */}
|
| 217 |
+
<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">
|
| 218 |
+
<span>提示词管理</span>
|
| 219 |
+
<span>字符限制:20000</span>
|
| 220 |
+
</div>
|
| 221 |
+
</div>
|
| 222 |
+
);
|
| 223 |
+
}
|
| 224 |
+
|
frontend/src/components/ResultSection.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
// 结果展示
|
| 2 |
|
| 3 |
import { CodeView } from './CodeView';
|
| 4 |
import { VideoPreview } from './VideoPreview';
|
|
@@ -9,27 +9,66 @@ interface ResultSectionProps {
|
|
| 9 |
usedAI: boolean;
|
| 10 |
renderQuality: string;
|
| 11 |
generationType: string;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
}
|
| 13 |
|
| 14 |
-
export function ResultSection({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
return (
|
| 16 |
<div className="w-full max-w-4xl mx-auto space-y-5">
|
| 17 |
-
{/* 代码
|
| 18 |
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
| 19 |
<div className="h-[360px]">
|
| 20 |
-
<CodeView code={code} />
|
| 21 |
</div>
|
| 22 |
<div className="h-[360px]">
|
| 23 |
<VideoPreview videoUrl={videoUrl} />
|
| 24 |
</div>
|
| 25 |
</div>
|
| 26 |
|
| 27 |
-
{/*
|
| 28 |
<div className="bg-bg-secondary/30 rounded-xl px-4 py-2.5">
|
| 29 |
<p className="text-xs text-text-secondary/70">
|
| 30 |
{generationType}{usedAI ? ' (AI)' : ''} · {renderQuality}
|
| 31 |
</p>
|
| 32 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
</div>
|
| 34 |
);
|
| 35 |
-
}
|
|
|
|
| 1 |
+
// 结果展示区域
|
| 2 |
|
| 3 |
import { CodeView } from './CodeView';
|
| 4 |
import { VideoPreview } from './VideoPreview';
|
|
|
|
| 9 |
usedAI: boolean;
|
| 10 |
renderQuality: string;
|
| 11 |
generationType: string;
|
| 12 |
+
onCodeChange?: (code: string) => void;
|
| 13 |
+
onRerender?: () => void;
|
| 14 |
+
onAiModify?: () => void;
|
| 15 |
+
isBusy?: boolean;
|
| 16 |
}
|
| 17 |
|
| 18 |
+
export function ResultSection({
|
| 19 |
+
code,
|
| 20 |
+
videoUrl,
|
| 21 |
+
usedAI,
|
| 22 |
+
renderQuality,
|
| 23 |
+
generationType,
|
| 24 |
+
onCodeChange,
|
| 25 |
+
onRerender,
|
| 26 |
+
onAiModify,
|
| 27 |
+
isBusy = false
|
| 28 |
+
}: ResultSectionProps) {
|
| 29 |
+
const hasActions = onRerender || onAiModify;
|
| 30 |
+
|
| 31 |
return (
|
| 32 |
<div className="w-full max-w-4xl mx-auto space-y-5">
|
| 33 |
+
{/* 代码与视频预览 */}
|
| 34 |
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
| 35 |
<div className="h-[360px]">
|
| 36 |
+
<CodeView code={code} editable={Boolean(onCodeChange)} onChange={onCodeChange} disabled={isBusy} />
|
| 37 |
</div>
|
| 38 |
<div className="h-[360px]">
|
| 39 |
<VideoPreview videoUrl={videoUrl} />
|
| 40 |
</div>
|
| 41 |
</div>
|
| 42 |
|
| 43 |
+
{/* 结果信息 */}
|
| 44 |
<div className="bg-bg-secondary/30 rounded-xl px-4 py-2.5">
|
| 45 |
<p className="text-xs text-text-secondary/70">
|
| 46 |
{generationType}{usedAI ? ' (AI)' : ''} · {renderQuality}
|
| 47 |
</p>
|
| 48 |
</div>
|
| 49 |
+
|
| 50 |
+
{hasActions && (
|
| 51 |
+
<div className="flex flex-wrap items-center justify-center gap-3">
|
| 52 |
+
{onRerender && (
|
| 53 |
+
<button
|
| 54 |
+
onClick={onRerender}
|
| 55 |
+
disabled={isBusy}
|
| 56 |
+
className="px-5 py-2 text-xs font-medium text-text-secondary/80 hover:text-text-primary bg-bg-secondary/30 hover:bg-bg-secondary/50 rounded-full transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
| 57 |
+
>
|
| 58 |
+
重新渲染
|
| 59 |
+
</button>
|
| 60 |
+
)}
|
| 61 |
+
{onAiModify && (
|
| 62 |
+
<button
|
| 63 |
+
onClick={onAiModify}
|
| 64 |
+
disabled={isBusy}
|
| 65 |
+
className="px-5 py-2 text-xs font-medium text-white bg-accent hover:bg-accent-hover rounded-full transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-sm shadow-accent/20"
|
| 66 |
+
>
|
| 67 |
+
AI修改
|
| 68 |
+
</button>
|
| 69 |
+
)}
|
| 70 |
+
</div>
|
| 71 |
+
)}
|
| 72 |
</div>
|
| 73 |
);
|
| 74 |
+
}
|
frontend/src/hooks/useGeneration.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
| 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';
|
|
@@ -13,6 +13,8 @@ interface UseGenerationReturn {
|
|
| 13 |
jobId: string | null;
|
| 14 |
stage: ProcessingStage;
|
| 15 |
generate: (request: GenerateRequest) => Promise<void>;
|
|
|
|
|
|
|
| 16 |
reset: () => void;
|
| 17 |
cancel: () => void;
|
| 18 |
}
|
|
@@ -165,6 +167,56 @@ export function useGeneration(): UseGenerationReturn {
|
|
| 165 |
}, POLL_INTERVAL);
|
| 166 |
}, [requestCancel, updateStage]);
|
| 167 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
// 生成动画
|
| 169 |
const generate = useCallback(async (request: GenerateRequest) => {
|
| 170 |
setStatus('processing');
|
|
@@ -247,6 +299,8 @@ export function useGeneration(): UseGenerationReturn {
|
|
| 247 |
jobId,
|
| 248 |
stage,
|
| 249 |
generate,
|
|
|
|
|
|
|
| 250 |
reset,
|
| 251 |
cancel,
|
| 252 |
};
|
|
|
|
| 1 |
// 生成请求 Hook
|
| 2 |
|
| 3 |
import { useState, useCallback, useRef, useEffect } from 'react';
|
| 4 |
+
import { generateAnimation, getJobStatus, cancelJob, modifyAnimation } from '../lib/api';
|
| 5 |
import { loadCustomConfig, generateWithCustomApi } from '../lib/custom-ai';
|
| 6 |
import { loadPrompts } from './usePrompts';
|
| 7 |
+
import type { GenerateRequest, JobResult, ProcessingStage, VideoConfig, ModifyRequest } from '../types/api';
|
| 8 |
|
| 9 |
interface UseGenerationReturn {
|
| 10 |
status: 'idle' | 'processing' | 'completed' | 'error';
|
|
|
|
| 13 |
jobId: string | null;
|
| 14 |
stage: ProcessingStage;
|
| 15 |
generate: (request: GenerateRequest) => Promise<void>;
|
| 16 |
+
renderWithCode: (request: GenerateRequest & { code: string }) => Promise<void>;
|
| 17 |
+
modifyWithAI: (request: ModifyRequest) => Promise<void>;
|
| 18 |
reset: () => void;
|
| 19 |
cancel: () => void;
|
| 20 |
}
|
|
|
|
| 167 |
}, POLL_INTERVAL);
|
| 168 |
}, [requestCancel, updateStage]);
|
| 169 |
|
| 170 |
+
// 使用现有代码重新渲染
|
| 171 |
+
const renderWithCode = useCallback(async (request: GenerateRequest & { code: string }) => {
|
| 172 |
+
setStatus('processing');
|
| 173 |
+
setError(null);
|
| 174 |
+
setResult(null);
|
| 175 |
+
setStage('rendering');
|
| 176 |
+
pollCountRef.current = 0;
|
| 177 |
+
abortControllerRef.current = new AbortController();
|
| 178 |
+
|
| 179 |
+
try {
|
| 180 |
+
const promptOverrides = loadPrompts();
|
| 181 |
+
const response = await generateAnimation(
|
| 182 |
+
{ ...request, promptOverrides },
|
| 183 |
+
abortControllerRef.current.signal
|
| 184 |
+
);
|
| 185 |
+
startPolling(response.jobId);
|
| 186 |
+
} catch (err) {
|
| 187 |
+
if (err instanceof Error && err.name === 'AbortError') {
|
| 188 |
+
return;
|
| 189 |
+
}
|
| 190 |
+
setStatus('error');
|
| 191 |
+
setError(err instanceof Error ? err.message : '重新渲染失败');
|
| 192 |
+
}
|
| 193 |
+
}, [startPolling]);
|
| 194 |
+
|
| 195 |
+
// AI 修改后渲染
|
| 196 |
+
const modifyWithAI = useCallback(async (request: ModifyRequest) => {
|
| 197 |
+
setStatus('processing');
|
| 198 |
+
setError(null);
|
| 199 |
+
setResult(null);
|
| 200 |
+
setStage('generating');
|
| 201 |
+
pollCountRef.current = 0;
|
| 202 |
+
abortControllerRef.current = new AbortController();
|
| 203 |
+
|
| 204 |
+
try {
|
| 205 |
+
const promptOverrides = loadPrompts();
|
| 206 |
+
const response = await modifyAnimation(
|
| 207 |
+
{ ...request, promptOverrides },
|
| 208 |
+
abortControllerRef.current.signal
|
| 209 |
+
);
|
| 210 |
+
startPolling(response.jobId);
|
| 211 |
+
} catch (err) {
|
| 212 |
+
if (err instanceof Error && err.name === 'AbortError') {
|
| 213 |
+
return;
|
| 214 |
+
}
|
| 215 |
+
setStatus('error');
|
| 216 |
+
setError(err instanceof Error ? err.message : 'AI 修改失败');
|
| 217 |
+
}
|
| 218 |
+
}, [startPolling]);
|
| 219 |
+
|
| 220 |
// 生成动画
|
| 221 |
const generate = useCallback(async (request: GenerateRequest) => {
|
| 222 |
setStatus('processing');
|
|
|
|
| 299 |
jobId,
|
| 300 |
stage,
|
| 301 |
generate,
|
| 302 |
+
renderWithCode,
|
| 303 |
+
modifyWithAI,
|
| 304 |
reset,
|
| 305 |
cancel,
|
| 306 |
};
|
frontend/src/hooks/usePrompts.ts
CHANGED
|
@@ -1,173 +1,175 @@
|
|
| 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 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
}
|
| 41 |
-
|
| 42 |
-
}
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
}
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
const [
|
| 62 |
-
const [
|
| 63 |
-
const [
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
}
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
current = prompts.
|
| 144 |
-
}
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
|
|
|
|
|
|
|
|
| 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 |
+
codeEdit: '',
|
| 18 |
+
},
|
| 19 |
+
user: {
|
| 20 |
+
conceptDesigner: '',
|
| 21 |
+
codeGeneration: '',
|
| 22 |
+
codeRetryInitial: '',
|
| 23 |
+
codeRetryFix: '',
|
| 24 |
+
codeEdit: '',
|
| 25 |
+
},
|
| 26 |
+
};
|
| 27 |
+
|
| 28 |
+
/** 从 localStorage 加载提示词配置 */
|
| 29 |
+
/** Load prompt overrides from localStorage if available. */
|
| 30 |
+
export function loadStoredPrompts(): PromptOverrides | null {
|
| 31 |
+
try {
|
| 32 |
+
const saved = localStorage.getItem(PROMPTS_STORAGE_KEY);
|
| 33 |
+
if (saved) {
|
| 34 |
+
const parsed = JSON.parse(saved);
|
| 35 |
+
return {
|
| 36 |
+
system: { ...DEFAULT_PROMPTS.system, ...parsed.system },
|
| 37 |
+
user: { ...DEFAULT_PROMPTS.user, ...parsed.user },
|
| 38 |
+
};
|
| 39 |
+
}
|
| 40 |
+
} catch (error) {
|
| 41 |
+
console.error('Failed to load prompts:', error);
|
| 42 |
+
}
|
| 43 |
+
return null;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
export function loadPrompts(): PromptOverrides {
|
| 47 |
+
return loadStoredPrompts() || DEFAULT_PROMPTS;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
/** 保存提示词配置到 localStorage */
|
| 51 |
+
export function savePrompts(prompts: PromptOverrides): void {
|
| 52 |
+
try {
|
| 53 |
+
localStorage.setItem(PROMPTS_STORAGE_KEY, JSON.stringify(prompts));
|
| 54 |
+
} catch (error) {
|
| 55 |
+
console.error('Failed to save prompts:', error);
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
/** 提示词管理 Hook */
|
| 60 |
+
export function usePrompts() {
|
| 61 |
+
const [prompts, setPrompts] = useState<PromptOverrides>(DEFAULT_PROMPTS);
|
| 62 |
+
const [defaultPrompts, setDefaultPrompts] = useState<PromptOverrides>(DEFAULT_PROMPTS);
|
| 63 |
+
const [isLoading, setIsLoading] = useState<boolean>(true);
|
| 64 |
+
const [activeSection, setActiveSection] = useState<string>('system');
|
| 65 |
+
const [activePrompt, setActivePrompt] = useState<string>('conceptDesigner');
|
| 66 |
+
|
| 67 |
+
// 从 localStorage 加载配置
|
| 68 |
+
useEffect(() => {
|
| 69 |
+
const savedPrompts = loadStoredPrompts();
|
| 70 |
+
if (savedPrompts) {
|
| 71 |
+
setPrompts(savedPrompts);
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
let isActive = true;
|
| 75 |
+
const loadDefaults = async () => {
|
| 76 |
+
setIsLoading(true);
|
| 77 |
+
try {
|
| 78 |
+
const defaults = await getPromptDefaults();
|
| 79 |
+
if (isActive) {
|
| 80 |
+
setDefaultPrompts(defaults);
|
| 81 |
+
}
|
| 82 |
+
} catch (error) {
|
| 83 |
+
console.error('Failed to load prompt defaults:', error);
|
| 84 |
+
} finally {
|
| 85 |
+
if (isActive) {
|
| 86 |
+
setIsLoading(false);
|
| 87 |
+
}
|
| 88 |
+
}
|
| 89 |
+
};
|
| 90 |
+
|
| 91 |
+
void loadDefaults();
|
| 92 |
+
return () => {
|
| 93 |
+
isActive = false;
|
| 94 |
+
};
|
| 95 |
+
}, []);
|
| 96 |
+
|
| 97 |
+
// 保存到 localStorage
|
| 98 |
+
useEffect(() => {
|
| 99 |
+
savePrompts(prompts);
|
| 100 |
+
}, [prompts]);
|
| 101 |
+
|
| 102 |
+
// 更新提示词
|
| 103 |
+
const updatePrompt = useCallback((section: string, type: string, value: string) => {
|
| 104 |
+
setPrompts(prev => {
|
| 105 |
+
const newPrompts = { ...prev };
|
| 106 |
+
|
| 107 |
+
if (section === 'system') {
|
| 108 |
+
if (!newPrompts.system) {
|
| 109 |
+
newPrompts.system = {};
|
| 110 |
+
}
|
| 111 |
+
newPrompts.system[type as keyof PromptOverrides['system']] = value;
|
| 112 |
+
} else if (section === 'user') {
|
| 113 |
+
if (!newPrompts.user) {
|
| 114 |
+
newPrompts.user = {};
|
| 115 |
+
}
|
| 116 |
+
newPrompts.user[type as keyof PromptOverrides['user']] = value;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
return newPrompts;
|
| 120 |
+
});
|
| 121 |
+
}, []);
|
| 122 |
+
|
| 123 |
+
// 恢复默认值
|
| 124 |
+
const restoreDefault = useCallback(() => {
|
| 125 |
+
setPrompts(DEFAULT_PROMPTS);
|
| 126 |
+
}, []);
|
| 127 |
+
|
| 128 |
+
// 获取当前编辑的提示词
|
| 129 |
+
const getDefaultPrompt = useCallback((section: string, type: string) => {
|
| 130 |
+
if (section === 'system') {
|
| 131 |
+
return defaultPrompts.system?.[type as keyof PromptOverrides['system']] || '';
|
| 132 |
+
}
|
| 133 |
+
if (section === 'user') {
|
| 134 |
+
return defaultPrompts.user?.[type as keyof PromptOverrides['user']] || '';
|
| 135 |
+
}
|
| 136 |
+
return '';
|
| 137 |
+
}, [defaultPrompts]);
|
| 138 |
+
|
| 139 |
+
//
|
| 140 |
+
const getCurrentPrompt = useCallback(() => {
|
| 141 |
+
let current = '';
|
| 142 |
+
if (activeSection === 'system') {
|
| 143 |
+
current = prompts.system?.[activePrompt as keyof PromptOverrides['system']] || '';
|
| 144 |
+
} else if (activeSection === 'user') {
|
| 145 |
+
current = prompts.user?.[activePrompt as keyof PromptOverrides['user']] || '';
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
if (current && current.trim().length > 0) {
|
| 149 |
+
return current;
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
return getDefaultPrompt(activeSection, activePrompt);
|
| 153 |
+
}, [prompts, activeSection, activePrompt, getDefaultPrompt]);
|
| 154 |
+
|
| 155 |
+
//
|
| 156 |
+
const setCurrentPrompt = useCallback((value: string) => {
|
| 157 |
+
const defaultValue = getDefaultPrompt(activeSection, activePrompt);
|
| 158 |
+
const nextValue = value === defaultValue ? '' : value;
|
| 159 |
+
updatePrompt(activeSection, activePrompt, nextValue);
|
| 160 |
+
}, [activeSection, activePrompt, getDefaultPrompt, updatePrompt]);
|
| 161 |
+
|
| 162 |
+
return {
|
| 163 |
+
prompts,
|
| 164 |
+
defaultPrompts,
|
| 165 |
+
isLoading,
|
| 166 |
+
activeSection,
|
| 167 |
+
activePrompt,
|
| 168 |
+
setActiveSection,
|
| 169 |
+
setActivePrompt,
|
| 170 |
+
updatePrompt,
|
| 171 |
+
restoreDefault,
|
| 172 |
+
getCurrentPrompt,
|
| 173 |
+
setCurrentPrompt,
|
| 174 |
+
};
|
| 175 |
+
}
|
frontend/src/lib/api.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
// API 请求函数
|
| 2 |
|
| 3 |
-
import type { GenerateRequest, GenerateResponse, JobResult, ApiError, VideoConfig, PromptOverrides } from '../types/api';
|
| 4 |
|
| 5 |
const API_BASE = '/api';
|
| 6 |
|
|
@@ -40,6 +40,25 @@ function getAuthHeaders(): HeadersInit {
|
|
| 40 |
/**
|
| 41 |
* 提交动画生成请求
|
| 42 |
*/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
export async function generateAnimation(request: GenerateRequest, signal?: AbortSignal): Promise<GenerateResponse> {
|
| 44 |
// 如果请求中没有 videoConfig,则从设置中加载默认值
|
| 45 |
const videoConfig = request.videoConfig || loadVideoConfig();
|
|
|
|
| 1 |
// API 请求函数
|
| 2 |
|
| 3 |
+
import type { GenerateRequest, GenerateResponse, JobResult, ApiError, VideoConfig, PromptOverrides, ModifyRequest } from '../types/api';
|
| 4 |
|
| 5 |
const API_BASE = '/api';
|
| 6 |
|
|
|
|
| 40 |
/**
|
| 41 |
* 提交动画生成请求
|
| 42 |
*/
|
| 43 |
+
export async function modifyAnimation(request: ModifyRequest, signal?: AbortSignal): Promise<GenerateResponse> {
|
| 44 |
+
const videoConfig = request.videoConfig || loadVideoConfig();
|
| 45 |
+
|
| 46 |
+
const payload = { ...request, videoConfig };
|
| 47 |
+
const response = await fetch(`${API_BASE}/modify`, {
|
| 48 |
+
method: 'POST',
|
| 49 |
+
headers: getAuthHeaders(),
|
| 50 |
+
body: JSON.stringify(payload),
|
| 51 |
+
signal,
|
| 52 |
+
});
|
| 53 |
+
|
| 54 |
+
if (!response.ok) {
|
| 55 |
+
const error: ApiError = await response.json();
|
| 56 |
+
throw new Error(error.error || 'AI 修改失败');
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
return response.json();
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
export async function generateAnimation(request: GenerateRequest, signal?: AbortSignal): Promise<GenerateResponse> {
|
| 63 |
// 如果请求中没有 videoConfig,则从设置中加载默认值
|
| 64 |
const videoConfig = request.videoConfig || loadVideoConfig();
|
frontend/src/types/api.ts
CHANGED
|
@@ -1,93 +1,105 @@
|
|
| 1 |
-
// API 类型定义
|
| 2 |
-
|
| 3 |
-
/** 视频质量选项 */
|
| 4 |
-
export type Quality = 'low' | 'medium' | 'high';
|
| 5 |
-
|
| 6 |
-
/** API 配置 */
|
| 7 |
-
export interface ApiConfig {
|
| 8 |
-
apiUrl: string;
|
| 9 |
-
apiKey: string;
|
| 10 |
-
model: string;
|
| 11 |
-
manimcatApiKey: string;
|
| 12 |
-
}
|
| 13 |
-
|
| 14 |
-
export interface PromptOverrides {
|
| 15 |
-
system?: {
|
| 16 |
-
conceptDesigner?: string;
|
| 17 |
-
codeGeneration?: string;
|
| 18 |
-
codeRetry?: string;
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
/**
|
| 33 |
-
|
| 34 |
-
/**
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
/**
|
| 58 |
-
|
| 59 |
-
/**
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
success
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// API 类型定义
|
| 2 |
+
|
| 3 |
+
/** 视频质量选项 */
|
| 4 |
+
export type Quality = 'low' | 'medium' | 'high';
|
| 5 |
+
|
| 6 |
+
/** API 配置 */
|
| 7 |
+
export interface ApiConfig {
|
| 8 |
+
apiUrl: string;
|
| 9 |
+
apiKey: string;
|
| 10 |
+
model: string;
|
| 11 |
+
manimcatApiKey: string;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export interface PromptOverrides {
|
| 15 |
+
system?: {
|
| 16 |
+
conceptDesigner?: string;
|
| 17 |
+
codeGeneration?: string;
|
| 18 |
+
codeRetry?: string;
|
| 19 |
+
codeEdit?: string;
|
| 20 |
+
};
|
| 21 |
+
user?: {
|
| 22 |
+
conceptDesigner?: string;
|
| 23 |
+
codeGeneration?: string;
|
| 24 |
+
codeRetryInitial?: string;
|
| 25 |
+
codeRetryFix?: string;
|
| 26 |
+
codeEdit?: string;
|
| 27 |
+
};
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
/** 视频配置 */
|
| 31 |
+
export interface VideoConfig {
|
| 32 |
+
/** 默认质量 */
|
| 33 |
+
quality: Quality;
|
| 34 |
+
/** 帧率 */
|
| 35 |
+
frameRate: number;
|
| 36 |
+
/** 超时时间(秒),默认 600 秒(10 分钟) */
|
| 37 |
+
timeout?: number;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
/** 设置配置 */
|
| 41 |
+
export interface SettingsConfig {
|
| 42 |
+
api: ApiConfig;
|
| 43 |
+
video: VideoConfig;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
/** 任务状态 */
|
| 47 |
+
export type JobStatus = 'processing' | 'completed' | 'failed';
|
| 48 |
+
|
| 49 |
+
/** 处理阶段 */
|
| 50 |
+
export type ProcessingStage = 'analyzing' | 'generating' | 'refining' | 'rendering' | 'still-rendering';
|
| 51 |
+
|
| 52 |
+
/** 生成请求 */
|
| 53 |
+
export interface GenerateRequest {
|
| 54 |
+
concept: string;
|
| 55 |
+
quality?: Quality;
|
| 56 |
+
forceRefresh?: boolean;
|
| 57 |
+
/** 预生成的代码(使用自定义 AI 时) */
|
| 58 |
+
code?: string;
|
| 59 |
+
/** 视频配置 */
|
| 60 |
+
videoConfig?: VideoConfig;
|
| 61 |
+
/** Prompt overrides */
|
| 62 |
+
promptOverrides?: PromptOverrides;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
/** AI 修改请求 */
|
| 66 |
+
export interface ModifyRequest {
|
| 67 |
+
concept: string;
|
| 68 |
+
quality?: Quality;
|
| 69 |
+
instructions: string;
|
| 70 |
+
code: string;
|
| 71 |
+
videoConfig?: VideoConfig;
|
| 72 |
+
promptOverrides?: PromptOverrides;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
/** 生成响应 */
|
| 76 |
+
export interface GenerateResponse {
|
| 77 |
+
success: boolean;
|
| 78 |
+
jobId: string;
|
| 79 |
+
message: string;
|
| 80 |
+
status: JobStatus;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
/** 任务结果 */
|
| 84 |
+
export interface JobResult {
|
| 85 |
+
jobId: string;
|
| 86 |
+
status: JobStatus;
|
| 87 |
+
stage?: ProcessingStage;
|
| 88 |
+
message?: string;
|
| 89 |
+
success?: boolean;
|
| 90 |
+
video_url?: string;
|
| 91 |
+
code?: string;
|
| 92 |
+
used_ai?: boolean;
|
| 93 |
+
render_quality?: string;
|
| 94 |
+
generation_type?: string;
|
| 95 |
+
render_peak_memory_mb?: number;
|
| 96 |
+
|
| 97 |
+
error?: string;
|
| 98 |
+
details?: string;
|
| 99 |
+
cancel_reason?: string;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
/** API 错误 */
|
| 103 |
+
export interface ApiError {
|
| 104 |
+
error: string;
|
| 105 |
+
}
|
src/config/bull.ts
CHANGED
|
@@ -1,24 +1,13 @@
|
|
| 1 |
-
|
| 2 |
-
* Bull Queue Configuration
|
| 3 |
-
* 任务队列配置和初始化
|
| 4 |
-
*/
|
| 5 |
-
|
| 6 |
-
import { default as Bull, type Queue, type QueueOptions } from 'bull'
|
| 7 |
import Redis from 'ioredis'
|
| 8 |
import { redisClient, REDIS_KEYS } from './redis'
|
| 9 |
import { createLogger } from '../utils/logger'
|
| 10 |
|
| 11 |
const logger = createLogger('BullQueue')
|
| 12 |
|
| 13 |
-
/**
|
| 14 |
-
* Bull 队列配置选项
|
| 15 |
-
*/
|
| 16 |
const queueOptions: QueueOptions = {
|
| 17 |
prefix: REDIS_KEYS.QUEUE_PREFIX,
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
// 为 Bull 创建专用的 Redis 客户端
|
| 21 |
-
createClient: (type) => {
|
| 22 |
const REDIS_HOST = process.env.REDIS_HOST || 'localhost'
|
| 23 |
const REDIS_PORT = parseInt(process.env.REDIS_PORT || '6379', 10)
|
| 24 |
const REDIS_PASSWORD = process.env.REDIS_PASSWORD || undefined
|
|
@@ -29,40 +18,31 @@ const queueOptions: QueueOptions = {
|
|
| 29 |
port: REDIS_PORT,
|
| 30 |
password: REDIS_PASSWORD,
|
| 31 |
db: REDIS_DB,
|
| 32 |
-
maxRetriesPerRequest: null,
|
| 33 |
-
enableReadyCheck: false
|
| 34 |
})
|
| 35 |
|
| 36 |
return client
|
| 37 |
},
|
| 38 |
-
|
| 39 |
-
// 默认任务配置
|
| 40 |
defaultJobOptions: {
|
| 41 |
-
attempts: 3,
|
| 42 |
backoff: {
|
| 43 |
-
type: 'exponential',
|
| 44 |
-
delay: 2000
|
| 45 |
},
|
| 46 |
-
removeOnComplete: true,
|
| 47 |
-
removeOnFail: true,
|
| 48 |
-
timeout: 600000
|
| 49 |
},
|
| 50 |
-
|
| 51 |
settings: {
|
| 52 |
-
lockDuration: 600000,
|
| 53 |
-
stalledInterval: 30000,
|
| 54 |
-
maxStalledCount: 1
|
| 55 |
}
|
| 56 |
}
|
| 57 |
|
| 58 |
-
/**
|
| 59 |
-
* 视频生成队列
|
| 60 |
-
*/
|
| 61 |
export const videoQueue: Queue = new Bull('video-generation', queueOptions)
|
| 62 |
|
| 63 |
-
/**
|
| 64 |
-
* 启动时清理旧任务(防止后端重启后僵尸任务残留)
|
| 65 |
-
*/
|
| 66 |
async function cleanupStaleJobs() {
|
| 67 |
try {
|
| 68 |
await videoQueue.isReady()
|
|
@@ -73,9 +53,6 @@ async function cleanupStaleJobs() {
|
|
| 73 |
return
|
| 74 |
}
|
| 75 |
|
| 76 |
-
// 使用 clean() 方法移除旧任务
|
| 77 |
-
// age=0 表示移除所有任务,不限制时间
|
| 78 |
-
// 注意:Bull 的 clean 方法状态参数是 'wait', 'active', 'completed', 'failed', 'delayed'
|
| 79 |
const [removedWait, removedActive, removedFailed, removedCompleted, removedDelayed] = await Promise.all([
|
| 80 |
videoQueue.clean(0, 'wait'),
|
| 81 |
videoQueue.clean(0, 'active'),
|
|
@@ -84,40 +61,34 @@ async function cleanupStaleJobs() {
|
|
| 84 |
videoQueue.clean(0, 'delayed')
|
| 85 |
])
|
| 86 |
|
| 87 |
-
const totalRemoved =
|
| 88 |
|
| 89 |
if (totalRemoved > 0) {
|
| 90 |
-
logger.info(`
|
| 91 |
-
wait: removedWait,
|
| 92 |
-
active: removedActive,
|
| 93 |
-
failed: removedFailed,
|
| 94 |
-
completed: removedCompleted,
|
| 95 |
-
delayed: removedDelayed
|
| 96 |
})
|
| 97 |
}
|
| 98 |
|
| 99 |
-
// 额外的彻底清理:删除 JobStore 存储的任务结果
|
| 100 |
-
// 扫描所有以 'job:result:' 开头的 Key
|
| 101 |
const resultKeys = await redisClient.keys(`${REDIS_KEYS.JOB_RESULT}*`)
|
| 102 |
if (resultKeys.length > 0) {
|
| 103 |
await redisClient.del(...resultKeys)
|
| 104 |
-
logger.info(`
|
| 105 |
}
|
| 106 |
|
| 107 |
if (totalRemoved === 0 && resultKeys.length === 0) {
|
| 108 |
-
logger.info('
|
| 109 |
}
|
| 110 |
} catch (error: any) {
|
| 111 |
-
logger.warn('
|
| 112 |
}
|
| 113 |
}
|
| 114 |
|
| 115 |
-
// 启动时执行清理
|
| 116 |
cleanupStaleJobs()
|
| 117 |
|
| 118 |
-
/**
|
| 119 |
-
* 队列事件监听
|
| 120 |
-
*/
|
| 121 |
videoQueue.on('error', (error) => {
|
| 122 |
logger.error('Queue error', { message: error.message })
|
| 123 |
})
|
|
@@ -130,7 +101,7 @@ videoQueue.on('active', (job) => {
|
|
| 130 |
logger.info(`Job ${job.id} started processing`)
|
| 131 |
})
|
| 132 |
|
| 133 |
-
videoQueue.on('completed', (job
|
| 134 |
logger.info(`Job ${job.id} completed`)
|
| 135 |
})
|
| 136 |
|
|
@@ -146,18 +117,12 @@ videoQueue.on('stalled', (job) => {
|
|
| 146 |
logger.warn(`Job ${job.id} stalled`)
|
| 147 |
})
|
| 148 |
|
| 149 |
-
/**
|
| 150 |
-
* 清理队列(开发调试用)
|
| 151 |
-
*/
|
| 152 |
export async function cleanQueue(): Promise<void> {
|
| 153 |
await videoQueue.clean(0, 'completed')
|
| 154 |
await videoQueue.clean(0, 'failed')
|
| 155 |
logger.info('Queue cleaned')
|
| 156 |
}
|
| 157 |
|
| 158 |
-
/**
|
| 159 |
-
* 获取队列统计信息
|
| 160 |
-
*/
|
| 161 |
export async function getQueueStats() {
|
| 162 |
const [waiting, active, completed, failed, delayed] = await Promise.all([
|
| 163 |
videoQueue.getWaitingCount(),
|
|
@@ -177,24 +142,18 @@ export async function getQueueStats() {
|
|
| 177 |
}
|
| 178 |
}
|
| 179 |
|
| 180 |
-
/**
|
| 181 |
-
* 优雅关闭队列
|
| 182 |
-
*/
|
| 183 |
-
export async function closeQueue(): Promise<void> {
|
| 184 |
-
await videoQueue.close()
|
| 185 |
-
logger.info('Queue closed gracefully')
|
| 186 |
-
}
|
| 187 |
-
|
| 188 |
-
/**
|
| 189 |
-
* 检查队列健康状态
|
| 190 |
-
*/
|
| 191 |
export async function checkQueueHealth(): Promise<boolean> {
|
| 192 |
try {
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
return
|
| 196 |
} catch (error) {
|
| 197 |
-
logger.
|
| 198 |
return false
|
| 199 |
}
|
| 200 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { default as Bull, type Queue, type QueueOptions } from 'bull'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
import Redis from 'ioredis'
|
| 3 |
import { redisClient, REDIS_KEYS } from './redis'
|
| 4 |
import { createLogger } from '../utils/logger'
|
| 5 |
|
| 6 |
const logger = createLogger('BullQueue')
|
| 7 |
|
|
|
|
|
|
|
|
|
|
| 8 |
const queueOptions: QueueOptions = {
|
| 9 |
prefix: REDIS_KEYS.QUEUE_PREFIX,
|
| 10 |
+
createClient: (_type) => {
|
|
|
|
|
|
|
|
|
|
| 11 |
const REDIS_HOST = process.env.REDIS_HOST || 'localhost'
|
| 12 |
const REDIS_PORT = parseInt(process.env.REDIS_PORT || '6379', 10)
|
| 13 |
const REDIS_PASSWORD = process.env.REDIS_PASSWORD || undefined
|
|
|
|
| 18 |
port: REDIS_PORT,
|
| 19 |
password: REDIS_PASSWORD,
|
| 20 |
db: REDIS_DB,
|
| 21 |
+
maxRetriesPerRequest: null,
|
| 22 |
+
enableReadyCheck: false
|
| 23 |
})
|
| 24 |
|
| 25 |
return client
|
| 26 |
},
|
|
|
|
|
|
|
| 27 |
defaultJobOptions: {
|
| 28 |
+
attempts: 3,
|
| 29 |
backoff: {
|
| 30 |
+
type: 'exponential',
|
| 31 |
+
delay: 2000
|
| 32 |
},
|
| 33 |
+
removeOnComplete: true,
|
| 34 |
+
removeOnFail: true,
|
| 35 |
+
timeout: 600000
|
| 36 |
},
|
|
|
|
| 37 |
settings: {
|
| 38 |
+
lockDuration: 600000,
|
| 39 |
+
stalledInterval: 30000,
|
| 40 |
+
maxStalledCount: 1
|
| 41 |
}
|
| 42 |
}
|
| 43 |
|
|
|
|
|
|
|
|
|
|
| 44 |
export const videoQueue: Queue = new Bull('video-generation', queueOptions)
|
| 45 |
|
|
|
|
|
|
|
|
|
|
| 46 |
async function cleanupStaleJobs() {
|
| 47 |
try {
|
| 48 |
await videoQueue.isReady()
|
|
|
|
| 53 |
return
|
| 54 |
}
|
| 55 |
|
|
|
|
|
|
|
|
|
|
| 56 |
const [removedWait, removedActive, removedFailed, removedCompleted, removedDelayed] = await Promise.all([
|
| 57 |
videoQueue.clean(0, 'wait'),
|
| 58 |
videoQueue.clean(0, 'active'),
|
|
|
|
| 61 |
videoQueue.clean(0, 'delayed')
|
| 62 |
])
|
| 63 |
|
| 64 |
+
const totalRemoved = removedWait.length + removedActive.length + removedFailed.length + removedCompleted.length + removedDelayed.length
|
| 65 |
|
| 66 |
if (totalRemoved > 0) {
|
| 67 |
+
logger.info(`Startup cleanup: removed ${totalRemoved} queue jobs`, {
|
| 68 |
+
wait: removedWait.length,
|
| 69 |
+
active: removedActive.length,
|
| 70 |
+
failed: removedFailed.length,
|
| 71 |
+
completed: removedCompleted.length,
|
| 72 |
+
delayed: removedDelayed.length
|
| 73 |
})
|
| 74 |
}
|
| 75 |
|
|
|
|
|
|
|
| 76 |
const resultKeys = await redisClient.keys(`${REDIS_KEYS.JOB_RESULT}*`)
|
| 77 |
if (resultKeys.length > 0) {
|
| 78 |
await redisClient.del(...resultKeys)
|
| 79 |
+
logger.info(`Startup cleanup: removed ${resultKeys.length} job result entries`)
|
| 80 |
}
|
| 81 |
|
| 82 |
if (totalRemoved === 0 && resultKeys.length === 0) {
|
| 83 |
+
logger.info('Startup cleanup: no stale data found')
|
| 84 |
}
|
| 85 |
} catch (error: any) {
|
| 86 |
+
logger.warn('Startup cleanup warning', { error: error?.message || String(error) })
|
| 87 |
}
|
| 88 |
}
|
| 89 |
|
|
|
|
| 90 |
cleanupStaleJobs()
|
| 91 |
|
|
|
|
|
|
|
|
|
|
| 92 |
videoQueue.on('error', (error) => {
|
| 93 |
logger.error('Queue error', { message: error.message })
|
| 94 |
})
|
|
|
|
| 101 |
logger.info(`Job ${job.id} started processing`)
|
| 102 |
})
|
| 103 |
|
| 104 |
+
videoQueue.on('completed', (job) => {
|
| 105 |
logger.info(`Job ${job.id} completed`)
|
| 106 |
})
|
| 107 |
|
|
|
|
| 117 |
logger.warn(`Job ${job.id} stalled`)
|
| 118 |
})
|
| 119 |
|
|
|
|
|
|
|
|
|
|
| 120 |
export async function cleanQueue(): Promise<void> {
|
| 121 |
await videoQueue.clean(0, 'completed')
|
| 122 |
await videoQueue.clean(0, 'failed')
|
| 123 |
logger.info('Queue cleaned')
|
| 124 |
}
|
| 125 |
|
|
|
|
|
|
|
|
|
|
| 126 |
export async function getQueueStats() {
|
| 127 |
const [waiting, active, completed, failed, delayed] = await Promise.all([
|
| 128 |
videoQueue.getWaitingCount(),
|
|
|
|
| 142 |
}
|
| 143 |
}
|
| 144 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
export async function checkQueueHealth(): Promise<boolean> {
|
| 146 |
try {
|
| 147 |
+
await videoQueue.isReady()
|
| 148 |
+
await videoQueue.getJobCounts()
|
| 149 |
+
return true
|
| 150 |
} catch (error) {
|
| 151 |
+
logger.warn('Queue health check failed', { error: String(error) })
|
| 152 |
return false
|
| 153 |
}
|
| 154 |
}
|
| 155 |
+
|
| 156 |
+
export async function closeQueue(): Promise<void> {
|
| 157 |
+
await videoQueue.close()
|
| 158 |
+
logger.info('Queue connection closed')
|
| 159 |
+
}
|
src/prompts/index.ts
CHANGED
|
@@ -61,7 +61,14 @@ export const SYSTEM_PROMPTS = {
|
|
| 61 |
|
| 62 |
- **动态更新**:对于涉及数值变化的过程,优先使用 \`ValueTracker\` 和 \`always_redraw\`。
|
| 63 |
- **公式操作规范**:禁止使用硬编码索引,必须通过 \`substrings_to_isolate\` 配合 \`get_part_by_tex\` 来操作公式的特定部分。
|
| 64 |
-
- **坐标系一致性**:所有图形必须通过 \`axes.c2p\` 映射到坐标轴上,严禁脱离坐标系的自由定位。`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
}
|
| 66 |
|
| 67 |
export const SYSTEM_PROMPT_BASE = SYSTEM_PROMPTS.codeGeneration
|
|
@@ -336,6 +343,42 @@ ${brokenCode}
|
|
| 336 |
请修复上述代码,只输出修复后的完整 Python 代码,不要任何解释。`
|
| 337 |
}
|
| 338 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 339 |
// =====================
|
| 340 |
// Prompt Builder Utilities
|
| 341 |
// =====================
|
|
|
|
| 61 |
|
| 62 |
- **动态更新**:对于涉及数值变化的过程,优先使用 \`ValueTracker\` 和 \`always_redraw\`。
|
| 63 |
- **公式操作规范**:禁止使用硬编码索引,必须通过 \`substrings_to_isolate\` 配合 \`get_part_by_tex\` 来操作公式的特定部分。
|
| 64 |
+
- **坐标系一致性**:所有图形必须通过 \`axes.c2p\` 映射到坐标轴上,严禁脱离坐标系的自由定位。`,
|
| 65 |
+
|
| 66 |
+
/** AI 修改时的 system prompt */
|
| 67 |
+
codeEdit: `你是一位 Manim 动画专家,擅长在既有代码基础上进行精准修改。
|
| 68 |
+
严格遵循提示词规范,确保输出符合 Manim Community Edition (v0.19.2) 的可执行代码。
|
| 69 |
+
|
| 70 |
+
- **保持可运行**:修改后代码必须完整可运行,结构保持为 \`MainScene\`。
|
| 71 |
+
- **只输出代码**:禁止任何解释或 Markdown 包裹。`
|
| 72 |
}
|
| 73 |
|
| 74 |
export const SYSTEM_PROMPT_BASE = SYSTEM_PROMPTS.codeGeneration
|
|
|
|
| 343 |
请修复上述代码,只输出修复后的完整 Python 代码,不要任何解释。`
|
| 344 |
}
|
| 345 |
|
| 346 |
+
/**
|
| 347 |
+
* 生成 AI 修改时的用户 prompt
|
| 348 |
+
*/
|
| 349 |
+
export function generateCodeEditPrompt(
|
| 350 |
+
concept: string,
|
| 351 |
+
instructions: string,
|
| 352 |
+
code: string
|
| 353 |
+
): string {
|
| 354 |
+
return `## 目标
|
| 355 |
+
|
| 356 |
+
### 修改信息
|
| 357 |
+
|
| 358 |
+
- **概念**:${concept}
|
| 359 |
+
- **修改意见**:${instructions}
|
| 360 |
+
|
| 361 |
+
### 输出要求
|
| 362 |
+
|
| 363 |
+
- **仅输出代码**:禁止解释或 Markdown 包裹
|
| 364 |
+
- **锚点协议**:使用 ### START ### 开始,### END ### 结束,仅输出锚点之间的代码
|
| 365 |
+
- **结构规范**:场景类固定为 \`MainScene\`,统一使用 \`from manim import *\`
|
| 366 |
+
|
| 367 |
+
## 知识库
|
| 368 |
+
|
| 369 |
+
### API 索引表
|
| 370 |
+
|
| 371 |
+
${API_INDEX}
|
| 372 |
+
|
| 373 |
+
## 原始代码
|
| 374 |
+
|
| 375 |
+
\`\`\`python
|
| 376 |
+
${code}
|
| 377 |
+
\`\`\`
|
| 378 |
+
|
| 379 |
+
请根据修改意见输出完整的 Manim Python 代码。`;
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
// =====================
|
| 383 |
// Prompt Builder Utilities
|
| 384 |
// =====================
|
src/queues/processors/steps/render-step.ts
CHANGED
|
@@ -1,24 +1,17 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
*/
|
| 5 |
-
|
| 6 |
-
import * as fs from 'fs'
|
| 7 |
-
import * as path from 'path'
|
| 8 |
-
import * as os from 'os'
|
| 9 |
-
import { executeManimCommand, ManimExecuteOptions } from '../../../utils/manim-executor'
|
| 10 |
-
import { findVideoFile } from '../../../utils/file-utils'
|
| 11 |
-
import { storeJobStage } from '../../../services/job-store'
|
| 12 |
import { createLogger } from '../../../utils/logger'
|
| 13 |
import { cleanManimCode } from '../../../utils/manim-code-cleaner'
|
| 14 |
-
import {
|
| 15 |
-
import
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
const logger = createLogger('RenderStep')
|
| 18 |
|
| 19 |
-
/**
|
| 20 |
-
* 渲染结果类型
|
| 21 |
-
*/
|
| 22 |
export interface RenderResult {
|
| 23 |
jobId: string
|
| 24 |
concept: string
|
|
@@ -27,37 +20,10 @@ export interface RenderResult {
|
|
| 27 |
generationType: string
|
| 28 |
quality: string
|
| 29 |
videoUrl: string
|
| 30 |
-
renderPeakMemoryMB: number
|
| 31 |
}
|
| 32 |
-
|
| 33 |
/**
|
| 34 |
-
*
|
| 35 |
-
*/
|
| 36 |
-
export interface GenerationResult {
|
| 37 |
-
manimCode: string
|
| 38 |
-
usedAI: boolean
|
| 39 |
-
generationType: string
|
| 40 |
-
sceneDesign?: string // 场景设计方案,用于重试
|
| 41 |
-
}
|
| 42 |
-
|
| 43 |
-
// 质量对应的分辨率配置
|
| 44 |
-
const QUALITY_RESOLUTION: Record<string, { width: number; height: number }> = {
|
| 45 |
-
low: { width: 854, height: 480 },
|
| 46 |
-
medium: { width: 1280, height: 720 },
|
| 47 |
-
high: { width: 1920, height: 1080 }
|
| 48 |
-
}
|
| 49 |
-
|
| 50 |
-
/**
|
| 51 |
-
* 任务结果
|
| 52 |
-
*/
|
| 53 |
-
export interface TaskResult {
|
| 54 |
-
success: boolean
|
| 55 |
-
source: string
|
| 56 |
-
timings: Record<string, number>
|
| 57 |
-
}
|
| 58 |
-
|
| 59 |
-
/**
|
| 60 |
-
* 渲染视频
|
| 61 |
*/
|
| 62 |
export async function renderVideo(
|
| 63 |
jobId: string,
|
|
@@ -72,12 +38,12 @@ export async function renderVideo(
|
|
| 72 |
): Promise<RenderResult> {
|
| 73 |
const { manimCode, usedAI, generationType, sceneDesign } = codeResult
|
| 74 |
|
| 75 |
-
//
|
| 76 |
const frameRate = videoConfig?.frameRate || 30
|
| 77 |
|
| 78 |
logger.info('Rendering video', { jobId, quality, usedAI, frameRate })
|
| 79 |
|
| 80 |
-
//
|
| 81 |
const tempDir = path.join(os.tmpdir(), `manim-${jobId}`)
|
| 82 |
const mediaDir = path.join(tempDir, 'media')
|
| 83 |
const codeFile = path.join(tempDir, 'scene.py')
|
|
@@ -91,7 +57,7 @@ export async function renderVideo(
|
|
| 91 |
let lastRenderedCode = manimCode
|
| 92 |
let lastRenderPeakMemoryMB = 0
|
| 93 |
|
| 94 |
-
//
|
| 95 |
const renderCode = async (code: string): Promise<{
|
| 96 |
success: boolean
|
| 97 |
stderr: string
|
|
@@ -102,7 +68,7 @@ export async function renderVideo(
|
|
| 102 |
lastRenderedCode = cleaned.code
|
| 103 |
|
| 104 |
if (cleaned.changes.length > 0) {
|
| 105 |
-
logger.info('Manim
|
| 106 |
jobId,
|
| 107 |
changes: cleaned.changes,
|
| 108 |
originalLength: code.length,
|
|
@@ -110,16 +76,16 @@ export async function renderVideo(
|
|
| 110 |
})
|
| 111 |
}
|
| 112 |
|
| 113 |
-
logger.info('
|
| 114 |
jobId,
|
| 115 |
codeLength: cleaned.code.length
|
| 116 |
})
|
| 117 |
|
| 118 |
-
//
|
| 119 |
fs.writeFileSync(codeFile, cleaned.code, 'utf-8')
|
| 120 |
-
logger.info('
|
| 121 |
|
| 122 |
-
//
|
| 123 |
const options: ManimExecuteOptions = {
|
| 124 |
jobId,
|
| 125 |
quality,
|
|
@@ -131,7 +97,7 @@ export async function renderVideo(
|
|
| 131 |
const result = await executeManimCommand(codeFile, options)
|
| 132 |
lastRenderPeakMemoryMB = result.peakMemoryMB
|
| 133 |
|
| 134 |
-
logger.info('Manim
|
| 135 |
jobId,
|
| 136 |
success: result.success,
|
| 137 |
stdoutLength: result.stdout.length,
|
|
@@ -139,41 +105,41 @@ export async function renderVideo(
|
|
| 139 |
peakMemoryMB: result.peakMemoryMB
|
| 140 |
})
|
| 141 |
|
| 142 |
-
//
|
| 143 |
if (result.stdout) {
|
| 144 |
-
logger.info('Manim stdout
|
| 145 |
}
|
| 146 |
|
| 147 |
if (result.stderr) {
|
| 148 |
if (result.success) {
|
| 149 |
-
logger.info('Manim stderr
|
| 150 |
} else {
|
| 151 |
-
logger.error('Manim stderr
|
| 152 |
}
|
| 153 |
}
|
| 154 |
|
| 155 |
return result
|
| 156 |
}
|
| 157 |
|
| 158 |
-
//
|
| 159 |
let finalCode = manimCode
|
| 160 |
let renderResult: { success: boolean; stderr: string; stdout: string; peakMemoryMB: number }
|
| 161 |
|
| 162 |
-
//
|
| 163 |
const hasSceneDesign = usedAI && !!sceneDesign
|
| 164 |
|
| 165 |
if (hasSceneDesign) {
|
| 166 |
-
//
|
| 167 |
logger.info('Using new code retry mechanism', { jobId, hasSceneDesign })
|
| 168 |
|
| 169 |
await storeJobStage(jobId, 'generating')
|
| 170 |
|
| 171 |
const retryStart = Date.now()
|
| 172 |
|
| 173 |
-
//
|
| 174 |
const retryContext = createRetryContext(concept, sceneDesign, promptOverrides)
|
| 175 |
|
| 176 |
-
//
|
| 177 |
const retryManagerResult = await executeCodeRetry(
|
| 178 |
retryContext,
|
| 179 |
renderCode,
|
|
@@ -191,18 +157,18 @@ export async function renderVideo(
|
|
| 191 |
peakMemoryMB: lastRenderPeakMemoryMB
|
| 192 |
}
|
| 193 |
|
| 194 |
-
logger.info('
|
| 195 |
jobId,
|
| 196 |
attempts: retryManagerResult.attempts
|
| 197 |
})
|
| 198 |
} else {
|
| 199 |
-
//
|
| 200 |
throw new Error(
|
| 201 |
`Code retry failed after ${retryManagerResult.attempts} attempts: ${retryManagerResult.lastError}`
|
| 202 |
)
|
| 203 |
}
|
| 204 |
} else {
|
| 205 |
-
//
|
| 206 |
logger.info('Using single render attempt', {
|
| 207 |
jobId,
|
| 208 |
reason: usedAI ? 'no_scene_design' : 'not_ai_generated'
|
|
@@ -212,7 +178,7 @@ export async function renderVideo(
|
|
| 212 |
renderResult = await renderCode(manimCode)
|
| 213 |
|
| 214 |
if (!renderResult.success) {
|
| 215 |
-
logger.error('Manim
|
| 216 |
jobId,
|
| 217 |
stderrLength: renderResult.stderr.length,
|
| 218 |
stdoutLength: renderResult.stdout.length,
|
|
@@ -225,13 +191,13 @@ export async function renderVideo(
|
|
| 225 |
finalCode = lastRenderedCode
|
| 226 |
}
|
| 227 |
|
| 228 |
-
//
|
| 229 |
const videoPath = findVideoFile(mediaDir, quality, frameRate)
|
| 230 |
if (!videoPath) {
|
| 231 |
throw new Error('Video file not found after render')
|
| 232 |
}
|
| 233 |
|
| 234 |
-
//
|
| 235 |
const outputFilename = `${jobId}.mp4`
|
| 236 |
const outputPath = path.join(outputDir, outputFilename)
|
| 237 |
fs.copyFileSync(videoPath, outputPath)
|
|
@@ -249,7 +215,7 @@ export async function renderVideo(
|
|
| 249 |
renderPeakMemoryMB: renderResult.peakMemoryMB
|
| 250 |
}
|
| 251 |
} finally {
|
| 252 |
-
//
|
| 253 |
try {
|
| 254 |
fs.rmSync(tempDir, { recursive: true, force: true })
|
| 255 |
logger.info('Cleaned up temp dir', { jobId })
|
|
@@ -260,7 +226,7 @@ export async function renderVideo(
|
|
| 260 |
}
|
| 261 |
|
| 262 |
/**
|
| 263 |
-
*
|
| 264 |
*/
|
| 265 |
export async function handlePreGeneratedCode(
|
| 266 |
jobId: string,
|
|
@@ -269,14 +235,14 @@ export async function handlePreGeneratedCode(
|
|
| 269 |
preGeneratedCode: string,
|
| 270 |
timings: Record<string, number>,
|
| 271 |
jobData: VideoJobData
|
| 272 |
-
): Promise<
|
| 273 |
logger.info('Using pre-generated code from frontend', {
|
| 274 |
jobId,
|
| 275 |
codeLength: preGeneratedCode.length,
|
| 276 |
hasCustomApi: !!jobData.customApiConfig
|
| 277 |
})
|
| 278 |
|
| 279 |
-
//
|
| 280 |
const renderStart = Date.now()
|
| 281 |
const renderResult = await renderVideo(jobId, concept, quality, {
|
| 282 |
manimCode: preGeneratedCode,
|
|
@@ -285,10 +251,6 @@ export async function handlePreGeneratedCode(
|
|
| 285 |
}, timings, jobData.customApiConfig, jobData.videoConfig, jobData.promptOverrides)
|
| 286 |
timings.render = Date.now() - renderStart
|
| 287 |
|
| 288 |
-
// 存储结果
|
| 289 |
-
const storeStart = Date.now()
|
| 290 |
-
timings.store = Date.now() - storeStart
|
| 291 |
-
|
| 292 |
logger.info('Job completed (pre-generated code)', { jobId, timings })
|
| 293 |
-
return
|
| 294 |
}
|
|
|
|
| 1 |
+
import fs from 'fs'
|
| 2 |
+
import os from 'os'
|
| 3 |
+
import path from 'path'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
import { createLogger } from '../../../utils/logger'
|
| 5 |
import { cleanManimCode } from '../../../utils/manim-code-cleaner'
|
| 6 |
+
import { executeManimCommand, type ManimExecuteOptions } from '../../../utils/manim-executor'
|
| 7 |
+
import { findVideoFile } from '../../../utils/file-utils'
|
| 8 |
+
import { createRetryContext, executeCodeRetry } from '../../../services/code-retry/manager'
|
| 9 |
+
import { storeJobStage } from '../../../services/job-store'
|
| 10 |
+
import type { GenerationResult } from './analysis-step'
|
| 11 |
+
import type { PromptOverrides, VideoConfig, VideoJobData } from '../../../types'
|
| 12 |
|
| 13 |
const logger = createLogger('RenderStep')
|
| 14 |
|
|
|
|
|
|
|
|
|
|
| 15 |
export interface RenderResult {
|
| 16 |
jobId: string
|
| 17 |
concept: string
|
|
|
|
| 20 |
generationType: string
|
| 21 |
quality: string
|
| 22 |
videoUrl: string
|
| 23 |
+
renderPeakMemoryMB?: number
|
| 24 |
}
|
|
|
|
| 25 |
/**
|
| 26 |
+
* 娓叉煋瑙嗛
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
*/
|
| 28 |
export async function renderVideo(
|
| 29 |
jobId: string,
|
|
|
|
| 38 |
): Promise<RenderResult> {
|
| 39 |
const { manimCode, usedAI, generationType, sceneDesign } = codeResult
|
| 40 |
|
| 41 |
+
// 搴旂敤瑙嗛閰嶇疆
|
| 42 |
const frameRate = videoConfig?.frameRate || 30
|
| 43 |
|
| 44 |
logger.info('Rendering video', { jobId, quality, usedAI, frameRate })
|
| 45 |
|
| 46 |
+
// 鍒涘缓涓存椂鐩綍
|
| 47 |
const tempDir = path.join(os.tmpdir(), `manim-${jobId}`)
|
| 48 |
const mediaDir = path.join(tempDir, 'media')
|
| 49 |
const codeFile = path.join(tempDir, 'scene.py')
|
|
|
|
| 57 |
let lastRenderedCode = manimCode
|
| 58 |
let lastRenderPeakMemoryMB = 0
|
| 59 |
|
| 60 |
+
// 娓叉煋鍑芥暟 - 渚涢噸璇曟満鍒朵娇鐢?
|
| 61 |
const renderCode = async (code: string): Promise<{
|
| 62 |
success: boolean
|
| 63 |
stderr: string
|
|
|
|
| 68 |
lastRenderedCode = cleaned.code
|
| 69 |
|
| 70 |
if (cleaned.changes.length > 0) {
|
| 71 |
+
logger.info('Manim code cleaned', {
|
| 72 |
jobId,
|
| 73 |
changes: cleaned.changes,
|
| 74 |
originalLength: code.length,
|
|
|
|
| 76 |
})
|
| 77 |
}
|
| 78 |
|
| 79 |
+
logger.info('Starting render', {
|
| 80 |
jobId,
|
| 81 |
codeLength: cleaned.code.length
|
| 82 |
})
|
| 83 |
|
| 84 |
+
// 鍐欏叆浠g爜鏂囦欢
|
| 85 |
fs.writeFileSync(codeFile, cleaned.code, 'utf-8')
|
| 86 |
+
logger.info('Code written to file', { jobId, codeFile })
|
| 87 |
|
| 88 |
+
// 鎵ц manim
|
| 89 |
const options: ManimExecuteOptions = {
|
| 90 |
jobId,
|
| 91 |
quality,
|
|
|
|
| 97 |
const result = await executeManimCommand(codeFile, options)
|
| 98 |
lastRenderPeakMemoryMB = result.peakMemoryMB
|
| 99 |
|
| 100 |
+
logger.info('Manim 娓叉煋瀹屾垚', {
|
| 101 |
jobId,
|
| 102 |
success: result.success,
|
| 103 |
stdoutLength: result.stdout.length,
|
|
|
|
| 105 |
peakMemoryMB: result.peakMemoryMB
|
| 106 |
})
|
| 107 |
|
| 108 |
+
// 璁板綍瀹屾暣杈撳嚭
|
| 109 |
if (result.stdout) {
|
| 110 |
+
logger.info('Manim stdout 杈撳嚭', { jobId, stdout: result.stdout })
|
| 111 |
}
|
| 112 |
|
| 113 |
if (result.stderr) {
|
| 114 |
if (result.success) {
|
| 115 |
+
logger.info('Manim stderr output (non-error)', { jobId, stderr: result.stderr })
|
| 116 |
} else {
|
| 117 |
+
logger.error('Manim stderr 杈撳嚭锛堥敊璇級', { jobId, stderr: result.stderr })
|
| 118 |
}
|
| 119 |
}
|
| 120 |
|
| 121 |
return result
|
| 122 |
}
|
| 123 |
|
| 124 |
+
// 鍐冲畾浣跨敤鍝娓叉煋绛栫暐
|
| 125 |
let finalCode = manimCode
|
| 126 |
let renderResult: { success: boolean; stderr: string; stdout: string; peakMemoryMB: number }
|
| 127 |
|
| 128 |
+
// 妫€鏌ユ槸鍚︽湁鍦烘櫙璁捐鏂规锛堟潵鑷袱闃舵 AI 鐢熸垚锛?
|
| 129 |
const hasSceneDesign = usedAI && !!sceneDesign
|
| 130 |
|
| 131 |
if (hasSceneDesign) {
|
| 132 |
+
// 浣跨敤鏂扮殑閲嶈瘯鏈哄埗锛氱淮鎶ゅ畬鏁村璇濆巻鍙?
|
| 133 |
logger.info('Using new code retry mechanism', { jobId, hasSceneDesign })
|
| 134 |
|
| 135 |
await storeJobStage(jobId, 'generating')
|
| 136 |
|
| 137 |
const retryStart = Date.now()
|
| 138 |
|
| 139 |
+
// 鍒涘缓閲嶈瘯涓婁笅鏂?
|
| 140 |
const retryContext = createRetryContext(concept, sceneDesign, promptOverrides)
|
| 141 |
|
| 142 |
+
// 鎵ц閲嶈瘯绠$悊鍣?
|
| 143 |
const retryManagerResult = await executeCodeRetry(
|
| 144 |
retryContext,
|
| 145 |
renderCode,
|
|
|
|
| 157 |
peakMemoryMB: lastRenderPeakMemoryMB
|
| 158 |
}
|
| 159 |
|
| 160 |
+
logger.info('浠g爜閲嶈瘯鎴愬姛', {
|
| 161 |
jobId,
|
| 162 |
attempts: retryManagerResult.attempts
|
| 163 |
})
|
| 164 |
} else {
|
| 165 |
+
// 鎵€鏈夐噸璇曞潎澶辫触
|
| 166 |
throw new Error(
|
| 167 |
`Code retry failed after ${retryManagerResult.attempts} attempts: ${retryManagerResult.lastError}`
|
| 168 |
)
|
| 169 |
}
|
| 170 |
} else {
|
| 171 |
+
// 闈濧I鐢熸垚鎴栨棤鍦烘櫙璁捐鏂规锛氬崟娆℃覆鏌?
|
| 172 |
logger.info('Using single render attempt', {
|
| 173 |
jobId,
|
| 174 |
reason: usedAI ? 'no_scene_design' : 'not_ai_generated'
|
|
|
|
| 178 |
renderResult = await renderCode(manimCode)
|
| 179 |
|
| 180 |
if (!renderResult.success) {
|
| 181 |
+
logger.error('Manim 娓叉煋澶辫触', {
|
| 182 |
jobId,
|
| 183 |
stderrLength: renderResult.stderr.length,
|
| 184 |
stdoutLength: renderResult.stdout.length,
|
|
|
|
| 191 |
finalCode = lastRenderedCode
|
| 192 |
}
|
| 193 |
|
| 194 |
+
// 鏌ユ壘鐢熸垚鐨勮棰戞枃浠?
|
| 195 |
const videoPath = findVideoFile(mediaDir, quality, frameRate)
|
| 196 |
if (!videoPath) {
|
| 197 |
throw new Error('Video file not found after render')
|
| 198 |
}
|
| 199 |
|
| 200 |
+
// 澶嶅埗鍒拌緭鍑虹洰褰?
|
| 201 |
const outputFilename = `${jobId}.mp4`
|
| 202 |
const outputPath = path.join(outputDir, outputFilename)
|
| 203 |
fs.copyFileSync(videoPath, outputPath)
|
|
|
|
| 215 |
renderPeakMemoryMB: renderResult.peakMemoryMB
|
| 216 |
}
|
| 217 |
} finally {
|
| 218 |
+
// 娓呯悊涓存椂鐩綍
|
| 219 |
try {
|
| 220 |
fs.rmSync(tempDir, { recursive: true, force: true })
|
| 221 |
logger.info('Cleaned up temp dir', { jobId })
|
|
|
|
| 226 |
}
|
| 227 |
|
| 228 |
/**
|
| 229 |
+
* 澶勭悊棰勭敓鎴愪唬鐮?
|
| 230 |
*/
|
| 231 |
export async function handlePreGeneratedCode(
|
| 232 |
jobId: string,
|
|
|
|
| 235 |
preGeneratedCode: string,
|
| 236 |
timings: Record<string, number>,
|
| 237 |
jobData: VideoJobData
|
| 238 |
+
): Promise<RenderResult> {
|
| 239 |
logger.info('Using pre-generated code from frontend', {
|
| 240 |
jobId,
|
| 241 |
codeLength: preGeneratedCode.length,
|
| 242 |
hasCustomApi: !!jobData.customApiConfig
|
| 243 |
})
|
| 244 |
|
| 245 |
+
// 鐩存帴杩涘叆娓叉煋闃舵
|
| 246 |
const renderStart = Date.now()
|
| 247 |
const renderResult = await renderVideo(jobId, concept, quality, {
|
| 248 |
manimCode: preGeneratedCode,
|
|
|
|
| 251 |
}, timings, jobData.customApiConfig, jobData.videoConfig, jobData.promptOverrides)
|
| 252 |
timings.render = Date.now() - renderStart
|
| 253 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
logger.info('Job completed (pre-generated code)', { jobId, timings })
|
| 255 |
+
return renderResult
|
| 256 |
}
|
src/queues/processors/steps/storage-step.ts
CHANGED
|
@@ -16,9 +16,11 @@ const logger = createLogger('StorageStep')
|
|
| 16 |
*/
|
| 17 |
export async function storeResult(
|
| 18 |
renderResult: RenderResult,
|
| 19 |
-
_timings: Record<string, number>
|
|
|
|
| 20 |
): Promise<void> {
|
| 21 |
const { jobId, concept, manimCode, usedAI, generationType, quality, videoUrl, renderPeakMemoryMB } = renderResult
|
|
|
|
| 22 |
|
| 23 |
// 存储到 Redis(用于 API 查询)
|
| 24 |
await storeJobResult(jobId, {
|
|
|
|
| 16 |
*/
|
| 17 |
export async function storeResult(
|
| 18 |
renderResult: RenderResult,
|
| 19 |
+
_timings: Record<string, number>,
|
| 20 |
+
options: { skipCache?: boolean } = {}
|
| 21 |
): Promise<void> {
|
| 22 |
const { jobId, concept, manimCode, usedAI, generationType, quality, videoUrl, renderPeakMemoryMB } = renderResult
|
| 23 |
+
const { skipCache = false } = options
|
| 24 |
|
| 25 |
// 存储到 Redis(用于 API 查询)
|
| 26 |
await storeJobResult(jobId, {
|
src/queues/processors/video.processor.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
| 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'
|
|
@@ -26,9 +27,9 @@ const logger = createLogger('VideoProcessor')
|
|
| 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> = {}
|
|
@@ -38,7 +39,55 @@ videoQueue.process(async (job) => {
|
|
| 38 |
// 如果有预生成代码,跳过缓存和 AI 生成阶段,直接渲染
|
| 39 |
if (preGeneratedCode) {
|
| 40 |
await ensureJobNotCancelled(jobId, job)
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
}
|
| 43 |
|
| 44 |
// Step 1: 检查缓存
|
|
|
|
| 7 |
|
| 8 |
import { videoQueue } from '../../config/bull'
|
| 9 |
import { storeJobResult } from '../../services/job-store'
|
| 10 |
+
import { generateEditedManimCode } from '../../services/code-edit'
|
| 11 |
import { ensureJobNotCancelled } from '../../services/job-cancel'
|
| 12 |
import { clearJobCancelled } from '../../services/job-cancel-store'
|
| 13 |
import { JobCancelledError } from '../../utils/errors'
|
|
|
|
| 27 |
*/
|
| 28 |
videoQueue.process(async (job) => {
|
| 29 |
const data = job.data as VideoJobData
|
| 30 |
+
const { jobId, concept, quality, forceRefresh = false, preGeneratedCode, editCode, editInstructions, promptOverrides } = data
|
| 31 |
|
| 32 |
+
logger.info('Processing video job', { jobId, concept, quality, hasPreGeneratedCode: !!preGeneratedCode, hasEditRequest: !!editInstructions })
|
| 33 |
|
| 34 |
// 阶段时长追踪
|
| 35 |
const timings: Record<string, number> = {}
|
|
|
|
| 39 |
// 如果有预生成代码,跳过缓存和 AI 生成阶段,直接渲染
|
| 40 |
if (preGeneratedCode) {
|
| 41 |
await ensureJobNotCancelled(jobId, job)
|
| 42 |
+
const renderResult = await handlePreGeneratedCode(jobId, concept, quality, preGeneratedCode, timings, data)
|
| 43 |
+
|
| 44 |
+
const storeStart = Date.now()
|
| 45 |
+
await storeResult(renderResult, timings, { skipCache: true })
|
| 46 |
+
timings.store = Date.now() - storeStart
|
| 47 |
+
timings.total = timings.render + timings.store
|
| 48 |
+
|
| 49 |
+
logger.info('Job completed (pre-generated code)', { jobId, timings })
|
| 50 |
+
return { success: true, source: 'pre-generated', timings }
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
// AI 修改流程
|
| 54 |
+
if (editCode && editInstructions) {
|
| 55 |
+
await ensureJobNotCancelled(jobId, job)
|
| 56 |
+
await storeJobStage(jobId, 'generating')
|
| 57 |
+
const editStart = Date.now()
|
| 58 |
+
const editedCode = await generateEditedManimCode(concept, editInstructions, editCode, data.customApiConfig, promptOverrides)
|
| 59 |
+
timings.edit = Date.now() - editStart
|
| 60 |
+
|
| 61 |
+
if (!editedCode) {
|
| 62 |
+
throw new Error('AI 修改未返回有效代码')
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
await ensureJobNotCancelled(jobId, job)
|
| 66 |
+
const renderStart = Date.now()
|
| 67 |
+
const renderResult = await renderVideo(
|
| 68 |
+
jobId,
|
| 69 |
+
concept,
|
| 70 |
+
quality,
|
| 71 |
+
{
|
| 72 |
+
manimCode: editedCode,
|
| 73 |
+
usedAI: true,
|
| 74 |
+
generationType: 'ai-edit'
|
| 75 |
+
},
|
| 76 |
+
timings,
|
| 77 |
+
data.customApiConfig,
|
| 78 |
+
data.videoConfig,
|
| 79 |
+
promptOverrides,
|
| 80 |
+
() => storeJobStage(jobId, 'rendering')
|
| 81 |
+
)
|
| 82 |
+
timings.render = Date.now() - renderStart
|
| 83 |
+
|
| 84 |
+
const storeStart = Date.now()
|
| 85 |
+
await storeResult(renderResult, timings, { skipCache: true })
|
| 86 |
+
timings.store = Date.now() - storeStart
|
| 87 |
+
timings.total = timings.edit + timings.render + timings.store
|
| 88 |
+
|
| 89 |
+
logger.info('Job completed', { jobId, source: 'ai-edit', timings })
|
| 90 |
+
return { success: true, source: 'ai-edit', timings }
|
| 91 |
}
|
| 92 |
|
| 93 |
// Step 1: 检查缓存
|
src/routes/generate.route.ts
CHANGED
|
@@ -73,13 +73,15 @@ const bodySchema = 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 |
/** 视频配置 */
|
|
|
|
| 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 |
+
codeEdit: z.string().max(20000).optional()
|
| 78 |
}).optional(),
|
| 79 |
user: z.object({
|
| 80 |
conceptDesigner: z.string().max(20000).optional(),
|
| 81 |
codeGeneration: z.string().max(20000).optional(),
|
| 82 |
codeRetryInitial: z.string().max(20000).optional(),
|
| 83 |
+
codeRetryFix: z.string().max(20000).optional(),
|
| 84 |
+
codeEdit: z.string().max(20000).optional()
|
| 85 |
}).optional()
|
| 86 |
}).optional(),
|
| 87 |
/** 视频配置 */
|
src/routes/index.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
| 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'
|
|
@@ -20,6 +21,7 @@ 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)
|
|
|
|
| 7 |
|
| 8 |
import express from 'express'
|
| 9 |
import generateRouter from './generate.route'
|
| 10 |
+
import modifyRouter from './modify.route'
|
| 11 |
import jobStatusRouter from './job-status.route'
|
| 12 |
import jobCancelRouter from './job-cancel.route'
|
| 13 |
import promptsRouter from './prompts.route'
|
|
|
|
| 21 |
|
| 22 |
// 挂载 API 路由(使用 /api 前缀)
|
| 23 |
router.use('/api', generateRouter)
|
| 24 |
+
router.use('/api', modifyRouter)
|
| 25 |
router.use('/api', jobStatusRouter)
|
| 26 |
router.use('/api', jobCancelRouter)
|
| 27 |
router.use('/api', promptsRouter)
|
src/routes/modify.route.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* AI 修改路由
|
| 3 |
+
* POST /api/modify - 基于现有代码进行 AI 修改并渲染
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
import express from 'express'
|
| 7 |
+
import { z } from 'zod'
|
| 8 |
+
import { v4 as uuidv4 } from 'uuid'
|
| 9 |
+
import { videoQueue } from '../config/bull'
|
| 10 |
+
import { storeJobStage } from '../services/job-store'
|
| 11 |
+
import { createLogger } from '../utils/logger'
|
| 12 |
+
import { AuthenticationError, ValidationError } from '../utils/errors'
|
| 13 |
+
import { asyncHandler } from '../middlewares/error-handler'
|
| 14 |
+
import { authMiddleware } from '../middlewares/auth.middleware'
|
| 15 |
+
import type { GenerateResponse } from '../types'
|
| 16 |
+
|
| 17 |
+
const router = express.Router()
|
| 18 |
+
const logger = createLogger('ModifyRoute')
|
| 19 |
+
|
| 20 |
+
function extractToken(authHeader: string | string[] | undefined): string {
|
| 21 |
+
if (!authHeader) return ''
|
| 22 |
+
|
| 23 |
+
if (typeof authHeader === 'string') {
|
| 24 |
+
return authHeader.replace(/^Bearer\s+/i, '')
|
| 25 |
+
}
|
| 26 |
+
if (Array.isArray(authHeader)) {
|
| 27 |
+
return authHeader[0]?.replace(/^Bearer\s+/i, '') || ''
|
| 28 |
+
}
|
| 29 |
+
return ''
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
function hasPromptOverrides(promptOverrides: any): boolean {
|
| 33 |
+
if (!promptOverrides) return false
|
| 34 |
+
const system = promptOverrides.system || {}
|
| 35 |
+
const user = promptOverrides.user || {}
|
| 36 |
+
return Object.values(system).some((value) => typeof value === 'string' && value.trim().length > 0) ||
|
| 37 |
+
Object.values(user).some((value) => typeof value === 'string' && value.trim().length > 0)
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
function requirePromptOverrideAuth(req: express.Request): void {
|
| 41 |
+
const manimcatApiKey = process.env.MANIMCAT_API_KEY
|
| 42 |
+
if (!manimcatApiKey) {
|
| 43 |
+
throw new AuthenticationError('Prompt overrides require MANIMCAT_API_KEY to be set.')
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
const token = extractToken(req.headers?.authorization)
|
| 47 |
+
if (!token || token !== manimcatApiKey) {
|
| 48 |
+
throw new AuthenticationError('Prompt overrides require a valid MANIMCAT_API_KEY token.')
|
| 49 |
+
}
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
const bodySchema = z.object({
|
| 53 |
+
concept: z.string().min(1, '概念不能为空'),
|
| 54 |
+
quality: z.enum(['low', 'medium', 'high']).optional().default('low'),
|
| 55 |
+
instructions: z.string().min(1, '修改意见不能为空'),
|
| 56 |
+
code: z.string().min(1, '原始代码不能为空'),
|
| 57 |
+
customApiConfig: z.object({
|
| 58 |
+
apiUrl: z.string(),
|
| 59 |
+
apiKey: z.string(),
|
| 60 |
+
model: z.string()
|
| 61 |
+
}).optional(),
|
| 62 |
+
promptOverrides: z.object({
|
| 63 |
+
system: z.object({
|
| 64 |
+
conceptDesigner: z.string().max(20000).optional(),
|
| 65 |
+
codeGeneration: z.string().max(20000).optional(),
|
| 66 |
+
codeRetry: z.string().max(20000).optional(),
|
| 67 |
+
codeEdit: z.string().max(20000).optional()
|
| 68 |
+
}).optional(),
|
| 69 |
+
user: z.object({
|
| 70 |
+
conceptDesigner: z.string().max(20000).optional(),
|
| 71 |
+
codeGeneration: z.string().max(20000).optional(),
|
| 72 |
+
codeRetryInitial: z.string().max(20000).optional(),
|
| 73 |
+
codeRetryFix: z.string().max(20000).optional(),
|
| 74 |
+
codeEdit: z.string().max(20000).optional()
|
| 75 |
+
}).optional()
|
| 76 |
+
}).optional(),
|
| 77 |
+
videoConfig: z.object({
|
| 78 |
+
quality: z.enum(['low', 'medium', 'high']).optional(),
|
| 79 |
+
frameRate: z.number().int().min(1).max(120).optional(),
|
| 80 |
+
timeout: z.number().optional()
|
| 81 |
+
}).optional()
|
| 82 |
+
})
|
| 83 |
+
|
| 84 |
+
async function handleModifyRequest(req: express.Request, res: express.Response) {
|
| 85 |
+
const parsed = bodySchema.parse(req.body)
|
| 86 |
+
const { concept, quality, instructions, code, customApiConfig, promptOverrides, videoConfig } = parsed
|
| 87 |
+
|
| 88 |
+
if (hasPromptOverrides(promptOverrides)) {
|
| 89 |
+
requirePromptOverrideAuth(req)
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
const sanitizedConcept = concept.trim().replace(/\s+/g, ' ')
|
| 93 |
+
if (sanitizedConcept.length === 0) {
|
| 94 |
+
throw new ValidationError('提供的概念为空', { concept })
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
const sanitizedInstructions = instructions.trim()
|
| 98 |
+
if (!sanitizedInstructions) {
|
| 99 |
+
throw new ValidationError('修改意见不能为空', { instructions })
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
const jobId = uuidv4()
|
| 103 |
+
|
| 104 |
+
logger.info('收到 AI 修改请求', {
|
| 105 |
+
jobId,
|
| 106 |
+
concept: sanitizedConcept,
|
| 107 |
+
quality,
|
| 108 |
+
hasCode: !!code,
|
| 109 |
+
videoConfig
|
| 110 |
+
})
|
| 111 |
+
|
| 112 |
+
await storeJobStage(jobId, 'generating')
|
| 113 |
+
|
| 114 |
+
await videoQueue.add(
|
| 115 |
+
{
|
| 116 |
+
jobId,
|
| 117 |
+
concept: sanitizedConcept,
|
| 118 |
+
quality,
|
| 119 |
+
editCode: code,
|
| 120 |
+
editInstructions: sanitizedInstructions,
|
| 121 |
+
customApiConfig,
|
| 122 |
+
promptOverrides,
|
| 123 |
+
videoConfig,
|
| 124 |
+
timestamp: new Date().toISOString()
|
| 125 |
+
},
|
| 126 |
+
{ jobId }
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
const response: GenerateResponse = {
|
| 130 |
+
success: true,
|
| 131 |
+
jobId,
|
| 132 |
+
message: 'AI 修改已开始',
|
| 133 |
+
status: 'processing'
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
res.status(202).json(response)
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
router.post('/modify', authMiddleware, asyncHandler(handleModifyRequest))
|
| 140 |
+
|
| 141 |
+
export default router
|
| 142 |
+
|
src/routes/prompts.route.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
| 1 |
-
import express from 'express'
|
| 2 |
|
| 3 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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'
|
|
@@ -12,7 +17,9 @@ const PLACEHOLDERS = {
|
|
| 12 |
seed: '{{seed}}',
|
| 13 |
sceneDesign: '{{sceneDesign}}',
|
| 14 |
errorMessage: '{{errorMessage}}',
|
| 15 |
-
attempt: '{{attempt}}'
|
|
|
|
|
|
|
| 16 |
}
|
| 17 |
|
| 18 |
function buildDefaultPromptTemplates(): PromptOverrides {
|
|
@@ -20,13 +27,15 @@ function buildDefaultPromptTemplates(): PromptOverrides {
|
|
| 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 |
}
|
|
|
|
| 1 |
+
import express from 'express'
|
| 2 |
|
| 3 |
+
import {
|
| 4 |
+
SYSTEM_PROMPTS,
|
| 5 |
+
generateConceptDesignerPrompt,
|
| 6 |
+
generateCodeGenerationPrompt,
|
| 7 |
+
generateCodeEditPrompt
|
| 8 |
+
} from '../prompts'
|
| 9 |
import { CODE_RETRY_SYSTEM_PROMPT, buildInitialCodePrompt } from '../services/code-retry/prompts'
|
| 10 |
import { buildRetryFixPrompt } from '../services/code-retry/manager'
|
| 11 |
import type { PromptOverrides } from '../types'
|
|
|
|
| 17 |
seed: '{{seed}}',
|
| 18 |
sceneDesign: '{{sceneDesign}}',
|
| 19 |
errorMessage: '{{errorMessage}}',
|
| 20 |
+
attempt: '{{attempt}}',
|
| 21 |
+
instructions: '{{instructions}}',
|
| 22 |
+
code: '{{code}}'
|
| 23 |
}
|
| 24 |
|
| 25 |
function buildDefaultPromptTemplates(): PromptOverrides {
|
|
|
|
| 27 |
system: {
|
| 28 |
conceptDesigner: SYSTEM_PROMPTS.conceptDesigner,
|
| 29 |
codeGeneration: SYSTEM_PROMPTS.codeGeneration,
|
| 30 |
+
codeRetry: CODE_RETRY_SYSTEM_PROMPT,
|
| 31 |
+
codeEdit: SYSTEM_PROMPTS.codeEdit
|
| 32 |
},
|
| 33 |
user: {
|
| 34 |
conceptDesigner: generateConceptDesignerPrompt(PLACEHOLDERS.concept, PLACEHOLDERS.seed),
|
| 35 |
codeGeneration: generateCodeGenerationPrompt(PLACEHOLDERS.concept, PLACEHOLDERS.seed, PLACEHOLDERS.sceneDesign),
|
| 36 |
codeRetryInitial: buildInitialCodePrompt(PLACEHOLDERS.concept, PLACEHOLDERS.seed, PLACEHOLDERS.sceneDesign),
|
| 37 |
+
codeRetryFix: buildRetryFixPrompt(PLACEHOLDERS.concept, PLACEHOLDERS.errorMessage, PLACEHOLDERS.attempt),
|
| 38 |
+
codeEdit: generateCodeEditPrompt(PLACEHOLDERS.concept, PLACEHOLDERS.instructions, PLACEHOLDERS.code)
|
| 39 |
}
|
| 40 |
}
|
| 41 |
}
|
src/services/code-edit.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import OpenAI from 'openai'
|
| 2 |
+
import { createLogger } from '../utils/logger'
|
| 3 |
+
import { SYSTEM_PROMPTS, generateCodeEditPrompt } from '../prompts'
|
| 4 |
+
import type { CustomApiConfig, PromptOverrides } from '../types'
|
| 5 |
+
|
| 6 |
+
const logger = createLogger('CodeEditService')
|
| 7 |
+
|
| 8 |
+
const OPENAI_MODEL = process.env.OPENAI_MODEL || 'glm-4-flash'
|
| 9 |
+
const CODER_TEMPERATURE = parseFloat(process.env.AI_TEMPERATURE || '0.7')
|
| 10 |
+
const MAX_TOKENS = parseInt(process.env.AI_MAX_TOKENS || '1200', 10)
|
| 11 |
+
const OPENAI_TIMEOUT = parseInt(process.env.OPENAI_TIMEOUT || '600000', 10)
|
| 12 |
+
|
| 13 |
+
const CUSTOM_API_URL = process.env.CUSTOM_API_URL?.trim()
|
| 14 |
+
|
| 15 |
+
let openaiClient: OpenAI | null = null
|
| 16 |
+
|
| 17 |
+
try {
|
| 18 |
+
const baseConfig = {
|
| 19 |
+
timeout: OPENAI_TIMEOUT,
|
| 20 |
+
defaultHeaders: {
|
| 21 |
+
'User-Agent': 'ManimCat/1.0'
|
| 22 |
+
}
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
if (CUSTOM_API_URL) {
|
| 26 |
+
openaiClient = new OpenAI({
|
| 27 |
+
...baseConfig,
|
| 28 |
+
baseURL: CUSTOM_API_URL,
|
| 29 |
+
apiKey: process.env.OPENAI_API_KEY
|
| 30 |
+
})
|
| 31 |
+
} else {
|
| 32 |
+
openaiClient = new OpenAI(baseConfig)
|
| 33 |
+
}
|
| 34 |
+
} catch (error) {
|
| 35 |
+
logger.warn('OpenAI 客户端初始化失败', { error })
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
function createCustomClient(config: CustomApiConfig): OpenAI {
|
| 39 |
+
return new OpenAI({
|
| 40 |
+
baseURL: config.apiUrl.trim().replace(/\/+$/, ''),
|
| 41 |
+
apiKey: config.apiKey,
|
| 42 |
+
timeout: OPENAI_TIMEOUT,
|
| 43 |
+
defaultHeaders: {
|
| 44 |
+
'User-Agent': 'ManimCat/1.0'
|
| 45 |
+
}
|
| 46 |
+
})
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
function applyPromptTemplate(template: string, values: Record<string, string>): string {
|
| 50 |
+
let output = template
|
| 51 |
+
for (const [key, value] of Object.entries(values)) {
|
| 52 |
+
output = output.replace(new RegExp(`{{\\s*${key}\\s*}}`, 'g'), value)
|
| 53 |
+
}
|
| 54 |
+
return output
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
function extractCodeFromResponse(text: string): string {
|
| 58 |
+
if (!text) return ''
|
| 59 |
+
const sanitized = text.replace(/<think>[\s\S]*?<\/think>/gi, '')
|
| 60 |
+
const anchorMatch = sanitized.match(/### START ###([\s\S]*?)### END ###/)
|
| 61 |
+
if (anchorMatch) {
|
| 62 |
+
return anchorMatch[1].trim()
|
| 63 |
+
}
|
| 64 |
+
const codeMatch = sanitized.match(/```(?:python)?\n([\s\S]*?)```/i)
|
| 65 |
+
if (codeMatch) {
|
| 66 |
+
return codeMatch[1].trim()
|
| 67 |
+
}
|
| 68 |
+
return sanitized.trim()
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
export async function generateEditedManimCode(
|
| 72 |
+
concept: string,
|
| 73 |
+
instructions: string,
|
| 74 |
+
code: string,
|
| 75 |
+
customApiConfig?: CustomApiConfig,
|
| 76 |
+
promptOverrides?: PromptOverrides
|
| 77 |
+
): Promise<string> {
|
| 78 |
+
const client = customApiConfig ? createCustomClient(customApiConfig) : openaiClient
|
| 79 |
+
|
| 80 |
+
if (!client) {
|
| 81 |
+
logger.warn('OpenAI 客户端不可用')
|
| 82 |
+
return ''
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
try {
|
| 86 |
+
const systemPrompt = promptOverrides?.system?.codeEdit || SYSTEM_PROMPTS.codeEdit
|
| 87 |
+
const userPromptOverride = promptOverrides?.user?.codeEdit
|
| 88 |
+
const userPrompt = userPromptOverride
|
| 89 |
+
? applyPromptTemplate(userPromptOverride, { concept, instructions, code })
|
| 90 |
+
: generateCodeEditPrompt(concept, instructions, code)
|
| 91 |
+
|
| 92 |
+
logger.info('开始 AI 修改代码', { concept })
|
| 93 |
+
|
| 94 |
+
const response = await client.chat.completions.create({
|
| 95 |
+
model: OPENAI_MODEL,
|
| 96 |
+
messages: [
|
| 97 |
+
{ role: 'system', content: systemPrompt },
|
| 98 |
+
{ role: 'user', content: userPrompt }
|
| 99 |
+
],
|
| 100 |
+
temperature: CODER_TEMPERATURE,
|
| 101 |
+
max_tokens: MAX_TOKENS
|
| 102 |
+
})
|
| 103 |
+
|
| 104 |
+
const content = response.choices[0]?.message?.content || ''
|
| 105 |
+
if (!content) {
|
| 106 |
+
logger.warn('AI 修改返回空内容')
|
| 107 |
+
return ''
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
const extracted = extractCodeFromResponse(content)
|
| 111 |
+
logger.info('AI 修改完成', { concept, length: extracted.length })
|
| 112 |
+
return extracted
|
| 113 |
+
} catch (error) {
|
| 114 |
+
if (error instanceof OpenAI.APIError) {
|
| 115 |
+
logger.error('AI 修改 API 错误', {
|
| 116 |
+
concept,
|
| 117 |
+
status: error.status,
|
| 118 |
+
code: error.code,
|
| 119 |
+
type: error.type,
|
| 120 |
+
message: error.message
|
| 121 |
+
})
|
| 122 |
+
} else if (error instanceof Error) {
|
| 123 |
+
logger.error('AI 修改失败', { concept, errorName: error.name, errorMessage: error.message })
|
| 124 |
+
} else {
|
| 125 |
+
logger.error('AI 修改失败,未知错误', { concept, error: String(error) })
|
| 126 |
+
}
|
| 127 |
+
return ''
|
| 128 |
+
}
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
export function isCodeEditAvailable(): boolean {
|
| 132 |
+
return openaiClient !== null
|
| 133 |
+
}
|
| 134 |
+
|
src/services/code-retry/types.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
| 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'
|
|
@@ -13,18 +13,19 @@ export interface ChatMessage {
|
|
| 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
|
|
@@ -32,7 +33,7 @@ export interface CodeRetryOptions {
|
|
| 32 |
}
|
| 33 |
|
| 34 |
/**
|
| 35 |
-
*
|
| 36 |
*/
|
| 37 |
export interface CodeRetryResult {
|
| 38 |
success: boolean
|
|
@@ -42,7 +43,7 @@ export interface CodeRetryResult {
|
|
| 42 |
}
|
| 43 |
|
| 44 |
/**
|
| 45 |
-
*
|
| 46 |
*/
|
| 47 |
export interface RenderResult {
|
| 48 |
success: boolean
|
|
@@ -52,7 +53,7 @@ export interface RenderResult {
|
|
| 52 |
}
|
| 53 |
|
| 54 |
/**
|
| 55 |
-
*
|
| 56 |
*/
|
| 57 |
export interface RetryManagerResult {
|
| 58 |
code: string
|
|
|
|
| 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'
|
|
|
|
| 13 |
}
|
| 14 |
|
| 15 |
/**
|
| 16 |
+
* 浠g爜閲嶈瘯涓婁笅鏂?
|
| 17 |
+
* 缁存姢瀹屾暣鐨勫璇濆巻鍙?
|
| 18 |
*/
|
| 19 |
export interface CodeRetryContext {
|
| 20 |
concept: string
|
| 21 |
sceneDesign: string
|
| 22 |
+
originalPrompt: string
|
| 23 |
+
messages: ChatMessage[]
|
| 24 |
+
promptOverrides?: PromptOverrides
|
| 25 |
}
|
| 26 |
|
| 27 |
/**
|
| 28 |
+
* 浠g爜閲嶈瘯閫夐」
|
| 29 |
*/
|
| 30 |
export interface CodeRetryOptions {
|
| 31 |
context: CodeRetryContext
|
|
|
|
| 33 |
}
|
| 34 |
|
| 35 |
/**
|
| 36 |
+
* 浠g爜閲嶈瘯缁撴灉
|
| 37 |
*/
|
| 38 |
export interface CodeRetryResult {
|
| 39 |
success: boolean
|
|
|
|
| 43 |
}
|
| 44 |
|
| 45 |
/**
|
| 46 |
+
* 娓叉煋缁撴灉
|
| 47 |
*/
|
| 48 |
export interface RenderResult {
|
| 49 |
success: boolean
|
|
|
|
| 53 |
}
|
| 54 |
|
| 55 |
/**
|
| 56 |
+
* 閲嶈瘯绠$悊鍣ㄧ粨鏋?
|
| 57 |
*/
|
| 58 |
export interface RetryManagerResult {
|
| 59 |
code: string
|
src/types/index.ts
CHANGED
|
@@ -1,40 +1,40 @@
|
|
| 1 |
-
/**
|
| 2 |
* Type Definitions
|
| 3 |
-
*
|
| 4 |
*/
|
| 5 |
|
| 6 |
/**
|
| 7 |
-
*
|
| 8 |
*/
|
| 9 |
export type VideoQuality = 'low' | 'medium' | 'high'
|
| 10 |
|
| 11 |
/**
|
| 12 |
-
*
|
| 13 |
*/
|
| 14 |
export interface VideoConfig {
|
| 15 |
-
/**
|
| 16 |
quality: VideoQuality
|
| 17 |
-
/**
|
| 18 |
frameRate: number
|
| 19 |
}
|
| 20 |
|
| 21 |
/**
|
| 22 |
-
*
|
| 23 |
*/
|
| 24 |
export type JobStatus = 'queued' | 'processing' | 'completed' | 'failed'
|
| 25 |
|
| 26 |
/**
|
| 27 |
-
*
|
| 28 |
*/
|
| 29 |
export type ProcessingStage = 'analyzing' | 'generating' | 'refining' | 'rendering' | 'still-rendering'
|
| 30 |
|
| 31 |
/**
|
| 32 |
-
*
|
| 33 |
*/
|
| 34 |
export type GenerationType = 'template' | 'ai' | 'cached'
|
| 35 |
|
| 36 |
/**
|
| 37 |
-
*
|
| 38 |
*/
|
| 39 |
export interface CustomApiConfig {
|
| 40 |
apiUrl: string
|
|
@@ -50,17 +50,19 @@ export interface PromptOverrides {
|
|
| 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 |
*/
|
| 65 |
export interface VideoJobData {
|
| 66 |
jobId: string
|
|
@@ -68,16 +70,22 @@ export interface VideoJobData {
|
|
| 68 |
quality: VideoQuality
|
| 69 |
forceRefresh?: boolean
|
| 70 |
timestamp: string
|
| 71 |
-
/**
|
| 72 |
preGeneratedCode?: string
|
| 73 |
-
/**
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
customApiConfig?: CustomApiConfig
|
| 75 |
-
/**
|
| 76 |
videoConfig?: VideoConfig
|
|
|
|
|
|
|
| 77 |
}
|
| 78 |
|
| 79 |
/**
|
| 80 |
-
*
|
| 81 |
*/
|
| 82 |
export interface CompletedJobResult {
|
| 83 |
status: 'completed'
|
|
@@ -93,7 +101,7 @@ export interface CompletedJobResult {
|
|
| 93 |
}
|
| 94 |
|
| 95 |
/**
|
| 96 |
-
*
|
| 97 |
*/
|
| 98 |
export interface FailedJobResult {
|
| 99 |
status: 'failed'
|
|
@@ -106,12 +114,12 @@ export interface FailedJobResult {
|
|
| 106 |
}
|
| 107 |
|
| 108 |
/**
|
| 109 |
-
*
|
| 110 |
*/
|
| 111 |
export type JobResult = CompletedJobResult | FailedJobResult
|
| 112 |
|
| 113 |
/**
|
| 114 |
-
*
|
| 115 |
*/
|
| 116 |
export interface ConceptCacheData {
|
| 117 |
jobId: string
|
|
@@ -126,7 +134,7 @@ export interface ConceptCacheData {
|
|
| 126 |
}
|
| 127 |
|
| 128 |
/**
|
| 129 |
-
* API
|
| 130 |
*/
|
| 131 |
export interface GenerateRequest {
|
| 132 |
concept: string
|
|
@@ -136,7 +144,20 @@ export interface GenerateRequest {
|
|
| 136 |
}
|
| 137 |
|
| 138 |
/**
|
| 139 |
-
* API
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
*/
|
| 141 |
export interface GenerateResponse {
|
| 142 |
success: boolean
|
|
@@ -146,7 +167,7 @@ export interface GenerateResponse {
|
|
| 146 |
}
|
| 147 |
|
| 148 |
/**
|
| 149 |
-
* API
|
| 150 |
*/
|
| 151 |
export interface JobStatusProcessingResponse {
|
| 152 |
status: 'processing' | 'queued'
|
|
@@ -156,8 +177,8 @@ export interface JobStatusProcessingResponse {
|
|
| 156 |
}
|
| 157 |
|
| 158 |
/**
|
| 159 |
-
* API
|
| 160 |
-
*
|
| 161 |
*/
|
| 162 |
export interface JobStatusCompletedResponse {
|
| 163 |
status: 'completed'
|
|
@@ -172,8 +193,8 @@ export interface JobStatusCompletedResponse {
|
|
| 172 |
}
|
| 173 |
|
| 174 |
/**
|
| 175 |
-
* API
|
| 176 |
-
*
|
| 177 |
*/
|
| 178 |
export interface JobStatusFailedResponse {
|
| 179 |
status: 'failed'
|
|
@@ -185,7 +206,7 @@ export interface JobStatusFailedResponse {
|
|
| 185 |
}
|
| 186 |
|
| 187 |
/**
|
| 188 |
-
* API
|
| 189 |
*/
|
| 190 |
export type JobStatusResponse =
|
| 191 |
| JobStatusProcessingResponse
|
|
@@ -193,7 +214,7 @@ export type JobStatusResponse =
|
|
| 193 |
| JobStatusFailedResponse
|
| 194 |
|
| 195 |
/**
|
| 196 |
-
* API
|
| 197 |
*/
|
| 198 |
export interface HealthCheckResponse {
|
| 199 |
status: 'ok' | 'degraded' | 'down'
|
|
@@ -214,7 +235,7 @@ export interface HealthCheckResponse {
|
|
| 214 |
}
|
| 215 |
|
| 216 |
/**
|
| 217 |
-
* API
|
| 218 |
*/
|
| 219 |
export interface ErrorResponse {
|
| 220 |
error: string
|
|
@@ -223,7 +244,7 @@ export interface ErrorResponse {
|
|
| 223 |
}
|
| 224 |
|
| 225 |
/**
|
| 226 |
-
* Bull
|
| 227 |
*/
|
| 228 |
export interface JobProgress {
|
| 229 |
step: string
|
|
@@ -232,7 +253,7 @@ export interface JobProgress {
|
|
| 232 |
}
|
| 233 |
|
| 234 |
/**
|
| 235 |
-
* Manim
|
| 236 |
*/
|
| 237 |
export interface ManimRenderOptions {
|
| 238 |
quality: VideoQuality
|
|
@@ -242,9 +263,10 @@ export interface ManimRenderOptions {
|
|
| 242 |
}
|
| 243 |
|
| 244 |
/**
|
| 245 |
-
*
|
| 246 |
*/
|
| 247 |
export interface CacheCheckResult {
|
| 248 |
hit: boolean
|
| 249 |
data?: ConceptCacheData
|
| 250 |
}
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
* Type Definitions
|
| 3 |
+
* 鍏ㄥ眬绫诲瀷瀹氫箟
|
| 4 |
*/
|
| 5 |
|
| 6 |
/**
|
| 7 |
+
* 瑙嗛璐ㄩ噺閫夐」
|
| 8 |
*/
|
| 9 |
export type VideoQuality = 'low' | 'medium' | 'high'
|
| 10 |
|
| 11 |
/**
|
| 12 |
+
* 瑙嗛閰嶇疆
|
| 13 |
*/
|
| 14 |
export interface VideoConfig {
|
| 15 |
+
/** 榛樿璐ㄩ噺 */
|
| 16 |
quality: VideoQuality
|
| 17 |
+
/** 甯х巼 */
|
| 18 |
frameRate: number
|
| 19 |
}
|
| 20 |
|
| 21 |
/**
|
| 22 |
+
* 浠诲姟鐘舵€?
|
| 23 |
*/
|
| 24 |
export type JobStatus = 'queued' | 'processing' | 'completed' | 'failed'
|
| 25 |
|
| 26 |
/**
|
| 27 |
+
* 澶勭悊闃舵
|
| 28 |
*/
|
| 29 |
export type ProcessingStage = 'analyzing' | 'generating' | 'refining' | 'rendering' | 'still-rendering'
|
| 30 |
|
| 31 |
/**
|
| 32 |
+
* 鐢熸垚绫诲瀷
|
| 33 |
*/
|
| 34 |
export type GenerationType = 'template' | 'ai' | 'cached'
|
| 35 |
|
| 36 |
/**
|
| 37 |
+
* 鑷畾涔?API 閰嶇疆
|
| 38 |
*/
|
| 39 |
export interface CustomApiConfig {
|
| 40 |
apiUrl: string
|
|
|
|
| 50 |
conceptDesigner?: string
|
| 51 |
codeGeneration?: string
|
| 52 |
codeRetry?: string
|
| 53 |
+
codeEdit?: string
|
| 54 |
}
|
| 55 |
user?: {
|
| 56 |
conceptDesigner?: string
|
| 57 |
codeGeneration?: string
|
| 58 |
codeRetryInitial?: string
|
| 59 |
codeRetryFix?: string
|
| 60 |
+
codeEdit?: string
|
| 61 |
}
|
| 62 |
}
|
| 63 |
|
| 64 |
/**
|
| 65 |
+
* 瑙嗛鐢熸垚浠诲姟鏁版嵁
|
| 66 |
*/
|
| 67 |
export interface VideoJobData {
|
| 68 |
jobId: string
|
|
|
|
| 70 |
quality: VideoQuality
|
| 71 |
forceRefresh?: boolean
|
| 72 |
timestamp: string
|
| 73 |
+
/** 棰勭敓鎴愮殑浠g爜锛堜娇鐢ㄨ嚜瀹氫箟 AI 鏃讹級 */
|
| 74 |
preGeneratedCode?: string
|
| 75 |
+
/** AI 淇敼鏃剁殑鍘熷浠g爜 */
|
| 76 |
+
editCode?: string
|
| 77 |
+
/** AI 淇敼鏃剁殑鐢ㄦ埛鎸囦护 */
|
| 78 |
+
editInstructions?: string
|
| 79 |
+
/** 鑷畾涔?API 閰嶇疆锛堢敤浜庝唬鐮佷慨澶嶏級 */
|
| 80 |
customApiConfig?: CustomApiConfig
|
| 81 |
+
/** 瑙嗛閰嶇疆 */
|
| 82 |
videoConfig?: VideoConfig
|
| 83 |
+
/** Prompt 覆盖 */
|
| 84 |
+
promptOverrides?: PromptOverrides
|
| 85 |
}
|
| 86 |
|
| 87 |
/**
|
| 88 |
+
* 浠诲姟缁撴灉 - 瀹屾垚鐘舵€?
|
| 89 |
*/
|
| 90 |
export interface CompletedJobResult {
|
| 91 |
status: 'completed'
|
|
|
|
| 101 |
}
|
| 102 |
|
| 103 |
/**
|
| 104 |
+
* 浠诲姟缁撴灉 - 澶辫触鐘舵€?
|
| 105 |
*/
|
| 106 |
export interface FailedJobResult {
|
| 107 |
status: 'failed'
|
|
|
|
| 114 |
}
|
| 115 |
|
| 116 |
/**
|
| 117 |
+
* 浠诲姟缁撴灉鑱斿悎绫诲瀷
|
| 118 |
*/
|
| 119 |
export type JobResult = CompletedJobResult | FailedJobResult
|
| 120 |
|
| 121 |
/**
|
| 122 |
+
* 姒傚康缂撳瓨鏁版嵁
|
| 123 |
*/
|
| 124 |
export interface ConceptCacheData {
|
| 125 |
jobId: string
|
|
|
|
| 134 |
}
|
| 135 |
|
| 136 |
/**
|
| 137 |
+
* API 璇锋眰 - 鐢熸垚瑙嗛
|
| 138 |
*/
|
| 139 |
export interface GenerateRequest {
|
| 140 |
concept: string
|
|
|
|
| 144 |
}
|
| 145 |
|
| 146 |
/**
|
| 147 |
+
* API 璇锋眰 - AI 淇敼
|
| 148 |
+
*/
|
| 149 |
+
export interface ModifyRequest {
|
| 150 |
+
concept: string
|
| 151 |
+
quality?: VideoQuality
|
| 152 |
+
instructions: string
|
| 153 |
+
code: string
|
| 154 |
+
promptOverrides?: PromptOverrides
|
| 155 |
+
videoConfig?: VideoConfig
|
| 156 |
+
customApiConfig?: CustomApiConfig
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
/**
|
| 160 |
+
* API 鍝嶅簲 - 鐢熸垚瑙嗛
|
| 161 |
*/
|
| 162 |
export interface GenerateResponse {
|
| 163 |
success: boolean
|
|
|
|
| 167 |
}
|
| 168 |
|
| 169 |
/**
|
| 170 |
+
* API 鍝嶅簲 - 浠诲姟鐘舵€侊紙澶勭悊涓級
|
| 171 |
*/
|
| 172 |
export interface JobStatusProcessingResponse {
|
| 173 |
status: 'processing' | 'queued'
|
|
|
|
| 177 |
}
|
| 178 |
|
| 179 |
/**
|
| 180 |
+
* API 鍝嶅簲 - 浠诲姟鐘舵€侊紙瀹屾垚锛?
|
| 181 |
+
* 涓庡墠绔?api.ts JobResult 绫诲瀷鍏煎
|
| 182 |
*/
|
| 183 |
export interface JobStatusCompletedResponse {
|
| 184 |
status: 'completed'
|
|
|
|
| 193 |
}
|
| 194 |
|
| 195 |
/**
|
| 196 |
+
* API 鍝嶅簲 - 浠诲姟鐘舵€侊紙澶辫触锛?
|
| 197 |
+
* 涓庡墠绔?api.ts JobResult 绫诲瀷鍏煎
|
| 198 |
*/
|
| 199 |
export interface JobStatusFailedResponse {
|
| 200 |
status: 'failed'
|
|
|
|
| 206 |
}
|
| 207 |
|
| 208 |
/**
|
| 209 |
+
* API 鍝嶅簲 - 浠诲姟鐘舵€佽仈鍚堢被鍨?
|
| 210 |
*/
|
| 211 |
export type JobStatusResponse =
|
| 212 |
| JobStatusProcessingResponse
|
|
|
|
| 214 |
| JobStatusFailedResponse
|
| 215 |
|
| 216 |
/**
|
| 217 |
+
* API 鍝嶅簲 - 鍋ュ悍妫€鏌?
|
| 218 |
*/
|
| 219 |
export interface HealthCheckResponse {
|
| 220 |
status: 'ok' | 'degraded' | 'down'
|
|
|
|
| 235 |
}
|
| 236 |
|
| 237 |
/**
|
| 238 |
+
* API 閿欒鍝嶅簲
|
| 239 |
*/
|
| 240 |
export interface ErrorResponse {
|
| 241 |
error: string
|
|
|
|
| 244 |
}
|
| 245 |
|
| 246 |
/**
|
| 247 |
+
* Bull 浠诲姟杩涘害鏁版嵁
|
| 248 |
*/
|
| 249 |
export interface JobProgress {
|
| 250 |
step: string
|
|
|
|
| 253 |
}
|
| 254 |
|
| 255 |
/**
|
| 256 |
+
* Manim 娓叉煋閫夐」
|
| 257 |
*/
|
| 258 |
export interface ManimRenderOptions {
|
| 259 |
quality: VideoQuality
|
|
|
|
| 263 |
}
|
| 264 |
|
| 265 |
/**
|
| 266 |
+
* 缂撳瓨鏌ヨ缁撴灉
|
| 267 |
*/
|
| 268 |
export interface CacheCheckResult {
|
| 269 |
hit: boolean
|
| 270 |
data?: ConceptCacheData
|
| 271 |
}
|
| 272 |
+
|