Bin29 commited on
Commit
9fdb075
·
0 Parent(s):

Clean deploy

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +104 -0
  2. .env.example +76 -0
  3. .env.huggingface +117 -0
  4. .env.production +63 -0
  5. .gitignore +53 -0
  6. Dockerfile +44 -0
  7. Dockerfile.huggingface +43 -0
  8. LICENSE +21 -0
  9. README.md +279 -0
  10. docker-compose.yml +83 -0
  11. dump.rdb +0 -0
  12. frontend/.gitignore +24 -0
  13. frontend/eslint.config.js +23 -0
  14. frontend/index.html +14 -0
  15. frontend/package-lock.json +0 -0
  16. frontend/package.json +35 -0
  17. frontend/postcss.config.js +6 -0
  18. frontend/public/logo-16.png +0 -0
  19. frontend/public/logo-192.png +0 -0
  20. frontend/public/logo-32.png +0 -0
  21. frontend/public/logo-48.png +0 -0
  22. frontend/public/logo-96.png +0 -0
  23. frontend/public/logo.svg +21 -0
  24. frontend/src/App.css +42 -0
  25. frontend/src/App.tsx +148 -0
  26. frontend/src/components/CodeView.tsx +67 -0
  27. frontend/src/components/CustomSelect.tsx +104 -0
  28. frontend/src/components/ExampleButtons.tsx +84 -0
  29. frontend/src/components/InputForm.tsx +254 -0
  30. frontend/src/components/LoadingSpinner.tsx +87 -0
  31. frontend/src/components/ManimCatLogo.tsx +29 -0
  32. frontend/src/components/ResultSection.tsx +35 -0
  33. frontend/src/components/SettingsModal.tsx +369 -0
  34. frontend/src/components/ThemeToggle.tsx +67 -0
  35. frontend/src/components/VideoPreview.tsx +43 -0
  36. frontend/src/hooks/useGeneration.ts +227 -0
  37. frontend/src/index.css +98 -0
  38. frontend/src/lib/api.ts +78 -0
  39. frontend/src/lib/custom-ai.ts +104 -0
  40. frontend/src/main.tsx +10 -0
  41. frontend/src/types/api.ts +75 -0
  42. frontend/tailwind.config.js +57 -0
  43. frontend/tsconfig.app.json +28 -0
  44. frontend/tsconfig.json +7 -0
  45. frontend/tsconfig.node.json +26 -0
  46. frontend/vite.config.ts +37 -0
  47. package-lock.json +0 -0
  48. package.json +55 -0
  49. public/assets/index-0yzmNnTY.js +0 -0
  50. public/assets/index-DkT5mxpT.css +1 -0
.dockerignore ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependencies
2
+ node_modules/
3
+ frontend/node_modules/
4
+
5
+ # Build output (will be created during build)
6
+ dist/
7
+ frontend/dist/
8
+ frontend/build/
9
+
10
+ # Environment files (use Docker env vars instead)
11
+ .env
12
+ .env.local
13
+ .env.*.local
14
+ .env.development
15
+ .env.production
16
+
17
+ # Generated videos (mounted as volume)
18
+ public/videos/*.mp4
19
+ public/videos/*.mov
20
+ public/videos/*.avi
21
+
22
+ # Git
23
+ .git/
24
+ .gitignore
25
+ .gitattributes
26
+
27
+ # IDE and editors
28
+ .vscode/
29
+ .idea/
30
+ *.swp
31
+ *.swo
32
+ *~
33
+ .project
34
+ .classpath
35
+ .settings/
36
+
37
+ # Logs
38
+ logs/
39
+ *.log
40
+ npm-debug.log*
41
+ yarn-debug.log*
42
+ yarn-error.log*
43
+ pnpm-debug.log*
44
+
45
+ # OS files
46
+ .DS_Store
47
+ .DS_Store?
48
+ ._*
49
+ .Spotlight-V100
50
+ .Trashes
51
+ ehthumbs.db
52
+ Thumbs.db
53
+ Desktop.ini
54
+
55
+ # Temp files
56
+ tmp/
57
+ temp/
58
+ *.tmp
59
+ *.temp
60
+ .cache/
61
+
62
+ # Test files
63
+ coverage/
64
+ .nyc_output/
65
+ test-results/
66
+
67
+ # Documentation and project files
68
+ README.md
69
+ CHANGELOG.md
70
+ LICENSE
71
+ *.md
72
+ docs/
73
+
74
+ # Docker files
75
+ docker-compose*.yml
76
+ Dockerfile*
77
+ .dockerignore
78
+
79
+ # CI/CD
80
+ .github/
81
+ .gitlab-ci.yml
82
+ .travis.yml
83
+
84
+ # Python cache (from Manim)
85
+ __pycache__/
86
+ *.py[cod]
87
+ *$py.class
88
+ .Python
89
+
90
+ # Redis dump
91
+ dump.rdb
92
+ *.rdb
93
+
94
+ # Static assets (examples)
95
+ static/gifs/
96
+
97
+ # Other
98
+ .editorconfig
99
+ .prettierrc
100
+ .eslintrc*
101
+ tsconfig*.json
102
+ vite.config.ts
103
+ tailwind.config.js
104
+ postcss.config.js
.env.example ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =============================================================================
2
+ # ManimCat 配置文件说明
3
+ # =============================================================================
4
+ # 优先级说明:CUSTOM_API_URL 优先级最高
5
+ # - 如果设置了 CUSTOM_API_URL:使用自定义的 OpenAI 兼容 API 端点
6
+ # - 如果 CUSTOM_API_URL 为空:使用官方 OpenAI API (https://api.openai.com/v1)
7
+ # =============================================================================
8
+
9
+ # -----------------------------------------------------------------------------
10
+ # 认证配置(可选)
11
+ # -----------------------------------------------------------------------------
12
+ # MANIMCAT_API_KEY: 用于保护 API 端点的自定义密钥
13
+ # - 设置此值可以防止未经授权的访问
14
+ # - 为空时跳过认证
15
+ # - 如果在这里设置,前端设置中必须使用相同的值
16
+ MANIMCAT_API_KEY=
17
+
18
+ # -----------------------------------------------------------------------------
19
+ # AI 服务配置(必填)
20
+ # -----------------------------------------------------------------------------
21
+ # OPENAI_API_KEY: AI 代码生成所需的 API 密钥
22
+ # - 使用官方 OpenAI 或自定义兼容 API 时都需要填写
23
+ # - 官方 OpenAI:从 https://platform.openai.com/api-keys 获取
24
+ # - 自定义 API:使用你的服务提供商提供的密钥
25
+ OPENAI_API_KEY=your-openai-api-key-here
26
+
27
+ # OPENAI_MODEL: 使用的 AI 模型(可选,默认值:glm-4-flash)
28
+ # - 支持任何 OpenAI 兼容的模型
29
+ # - 官方 OpenAI 示例:gpt-4o-mini, gpt-4o, gpt-3.5-turbo
30
+ # - 第三方服务示例:glm-4-flash(智谱 AI), @minimaxai/minimax-m2.1
31
+ # - 本地模型:使用你的自定义 API 支持的任何模型名称
32
+ OPENAI_MODEL=glm-4-flash
33
+
34
+ # CUSTOM_API_URL: 自定义 OpenAI 兼容 API 端点(可选)
35
+ # - 优先级最高:如果设置,则使用此端点而非官方 OpenAI API
36
+ # - 为空时使用:https://api.openai.com/v1(官方 OpenAI)
37
+ # - 必须兼容 OpenAI API(支持 /chat/completions 端点)
38
+ # - 常见示例:
39
+ # - Together AI:https://api.together.xyz/v1
40
+ # - LocalAI:http://localhost:8080/v1
41
+ # - Ollama:http://localhost:11434/v1
42
+ # - 第三方代理:https://proxy/bin/nvidia/v1
43
+ CUSTOM_API_URL=
44
+
45
+ # -----------------------------------------------------------------------------
46
+ # AI 参数配置(可选)
47
+ # -----------------------------------------------------------------------------
48
+ # AI_TEMPERATURE: 控制 AI 输出的随机性(默认值:0.7)
49
+ # - 取值范围:0.0 - 2.0
50
+ # - 数值越低:输出越确定、越聚焦
51
+ # - 数值越高:输出越有创意、越多样
52
+ # AI_TEMPERATURE=0.7
53
+
54
+ # AI_MAX_TOKENS: AI 响应的最大 token 数(默认值:1200)
55
+ # AI_MAX_TOKENS=1200
56
+
57
+ # OPENAI_TIMEOUT: 请求超时时间(毫秒)(默认值:60000)
58
+ # OPENAI_TIMEOUT=60000
59
+
60
+ # -----------------------------------------------------------------------------
61
+ # 两阶段 AI 生成配置
62
+ # -----------------------------------------------------------------------------
63
+ # DESIGNER_TEMPERATURE: 概念设计者的温度参数(默认值:0.8)
64
+ # - 比代码生成者稍高,以获得更有创意的场景设计
65
+ # DESIGNER_TEMPERATURE=0.8
66
+
67
+ # DESIGNER_MAX_TOKENS: 概念设计者的最大 token 数(默认值:800)
68
+ # DESIGNER_MAX_TOKENS=800
69
+
70
+ # -----------------------------------------------------------------------------
71
+ # 应用设置
72
+ # -----------------------------------------------------------------------------
73
+ # NODE_ENV: 运行环境
74
+ # - development(开发环境)
75
+ # - production(生产环境)
76
+ NODE_ENV=development
.env.huggingface ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ManimCat HuggingFace Spaces 环境变量配置
2
+ # 在 HuggingFace Space Settings → Variables and secrets 中配置这些变量
3
+
4
+ # ==================== 必需配置 ====================
5
+
6
+ # OpenAI API Key (必需)
7
+ # 用于 AI 代码生成,支持 OpenAI 或兼容的 API
8
+ OPENAI_API_KEY=your-openai-api-key-here
9
+
10
+ # ==================== 可选配置 ====================
11
+
12
+ # OpenAI 模型 (可选,默认 glm-4-flash)
13
+ # 推荐模型:
14
+ # - glm-4-flash (智谱 AI,快速且便宜)
15
+ # - gpt-4o-mini (OpenAI,轻量级)
16
+ # - gpt-4-turbo (OpenAI,高性能)
17
+ OPENAI_MODEL=glm-4-flash
18
+
19
+ # 自定义 API 端点 (可选)
20
+ # 留空使用 OpenAI 官方 API
21
+ # 示例:
22
+ # - 智谱 AI: https://open.bigmodel.cn/api/paas/v4
23
+ # - Together AI: https://api.together.xyz/v1
24
+ # - LocalAI: http://localhost:8080/v1
25
+ CUSTOM_API_URL=
26
+
27
+ # ManimCat API 认证密钥 (可选但强烈推荐)
28
+ # 设置后,所有 API 请求需在 Header 中包含: x-api-key: YOUR_KEY
29
+ # 留空则跳过认证 (仅推荐测试环境)
30
+ MANIMCAT_API_KEY=
31
+
32
+ # ==================== 系统配置 ====================
33
+
34
+ # Node 环境 (固定为 production)
35
+ NODE_ENV=production
36
+
37
+ # 端口 (HuggingFace Spaces 必须是 7860)
38
+ PORT=7860
39
+
40
+ # Redis 配置 (使用内置 Redis)
41
+ REDIS_HOST=localhost
42
+ REDIS_PORT=6379
43
+ REDIS_DB=0
44
+ REDIS_MAXMEMORY=256mb
45
+
46
+ # ==================== AI 配置 ====================
47
+
48
+ # AI 温度 (控制输出随机性,0-2)
49
+ AI_TEMPERATURE=0.7
50
+
51
+ # AI 最大 Token 数
52
+ AI_MAX_TOKENS=1200
53
+
54
+ # OpenAI 请求超时 (毫秒)
55
+ OPENAI_TIMEOUT=60000
56
+
57
+ # ==================== 缓存配置 ====================
58
+
59
+ # 启用概念缓存 (避免重复生成相同内容)
60
+ ENABLE_CACHING=true
61
+
62
+ # 缓存过期时间 (秒,默认 30 天)
63
+ CACHE_TTL=2592000
64
+
65
+ # ==================== 任务队列配置 ====================
66
+
67
+ # Bull 队列并发数 (根据硬件配置调整)
68
+ # - CPU basic: 1-2
69
+ # - CPU upgrade: 2-4
70
+ # - GPU: 4-8
71
+ WORKERS=2
72
+
73
+ # 任务超时配置 (毫秒)
74
+ REQUEST_TIMEOUT=30000 # 请求超时 30 秒
75
+ JOB_TIMEOUT=600000 # 任务超时 10 分钟
76
+ MANIM_TIMEOUT=300000 # Manim 渲染超时 5 分钟
77
+
78
+ # ==================== 文件系统配置 ====================
79
+
80
+ # 视频输出目录
81
+ VIDEO_OUTPUT_DIR=public/videos
82
+
83
+ # 临时文件目录
84
+ TEMP_DIR=tmp
85
+
86
+ # ==================== CORS 配置 ====================
87
+
88
+ # CORS 允许的来源 (生产环境应设置具体域名)
89
+ CORS_ORIGIN=*
90
+
91
+ # ==================== 日志配置 ====================
92
+
93
+ # 日志级别 (debug, info, warn, error)
94
+ LOG_LEVEL=info
95
+
96
+ # ==================== 显示配置 ====================
97
+
98
+ # Xvfb 显示编号
99
+ DISPLAY=:99
100
+
101
+ # Matplotlib 后端
102
+ MPLBACKEND=Agg
103
+
104
+ # ==================== HuggingFace Spaces 特定配置 ====================
105
+
106
+ # Space 名称 (可选,用于日志)
107
+ SPACE_NAME=
108
+
109
+ # Space 作者 (可选,用于日志)
110
+ SPACE_AUTHOR=
111
+
112
+ # 注意事项:
113
+ # 1. 所有敏感信息 (API Key) 必须在 HF Space Settings 中配置,不要提交到代码仓库
114
+ # 2. PORT 必须是 7860,这是 HF Spaces 的固定端口
115
+ # 3. Redis 使用内置实例,REDIS_HOST 必须是 localhost
116
+ # 4. 根据 HF Space 的硬件配置调整 WORKERS 和超时时间
117
+ # 5. 启用 MANIMCAT_API_KEY 以保护您的服务免受滥用
.env.production ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ManimCat Production Environment Variables
2
+ # 生产环境配置文件
3
+
4
+ # ===========================================
5
+ # 服务配置 / Service Configuration
6
+ # ===========================================
7
+
8
+ # Node 环境(请勿修改)
9
+ NODE_ENV=production
10
+
11
+ # 服务端口
12
+ PORT=3000
13
+
14
+ # ===========================================
15
+ # Redis 配置 / Redis Configuration
16
+ # ===========================================
17
+
18
+ # Redis 主机地址(Docker Compose 中为 "redis")
19
+ REDIS_HOST=redis
20
+
21
+ # Redis 端口
22
+ REDIS_PORT=6379
23
+
24
+ # Redis 密码(可选,如果 Redis 需要认证)
25
+ # REDIS_PASSWORD=
26
+
27
+ # Redis 数据库编号
28
+ REDIS_DB=0
29
+
30
+ # ===========================================
31
+ # OpenAI 配置 / OpenAI Configuration
32
+ # ===========================================
33
+
34
+ # OpenAI API Key(必填)
35
+ # 从 https://platform.openai.com/api-keys 获取
36
+ OPENAI_API_KEY=your-openai-api-key-here
37
+
38
+ # OpenAI 模型选择
39
+ # 默认使用 glm-4-flash(实际支持任何 OpenAI 兼容模型)
40
+ # 示例: gpt-4o-mini, gpt-4-turbo, glm-4-flash 等
41
+ OPENAI_MODEL=glm-4-flash
42
+
43
+ # 自定义 API 端点(可选)
44
+ # 如果使用 OpenAI 官方 API,请留空
45
+ # 示例 - Together AI: https://api.together.xyz/v1
46
+ # 示例 - LocalAI: http://localhost:8080/v1
47
+ CUSTOM_API_URL=
48
+
49
+ # ===========================================
50
+ # 安全配置 / Security Configuration
51
+ # ===========================================
52
+
53
+ # API 认证密钥(可选)
54
+ # 设置后将启用 API 认证保护
55
+ # 如果不需要认证,请留空或删除此行
56
+ # MANIMCAT_API_KEY=
57
+
58
+ # ===========================================
59
+ # 显示配置 / Display Configuration
60
+ # ===========================================
61
+
62
+ # X11 虚拟显示(Xvfb 使用,请勿修改)
63
+ DISPLAY=:99
.gitignore ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependencies
2
+ node_modules/
3
+ .cursor/
4
+
5
+ # Python
6
+ venv/
7
+ __pycache__/
8
+ *.pyc
9
+
10
+ # Build output
11
+ dist/
12
+ .motia/
13
+
14
+ # Environment
15
+ .env
16
+ .env.local
17
+
18
+ # Generated files
19
+ public/videos/*
20
+ public/videos/.gitkeep
21
+ media/
22
+ static/videos/
23
+ static/gifs/
24
+
25
+ # Logs
26
+ *.log
27
+
28
+ # OS files
29
+ .DS_Store
30
+ Thumbs.db
31
+
32
+ # IDE
33
+ .vscode/
34
+ .idea/
35
+
36
+ # Temp files
37
+ tmp/
38
+ *.tmp
39
+
40
+ # AI/Dev tools
41
+ .claude/
42
+
43
+ # MD files (except README)
44
+ *.md
45
+ !README.md
46
+ !frontend/README.md
47
+ !static/gifs/README.md
48
+
49
+ # Flow type files
50
+ *.flow
51
+
52
+ # Batch scripts
53
+ *.bat
Dockerfile ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =========================================
2
+ # 阶段 1: 准备 Node 环境
3
+ # =========================================
4
+ FROM node:18-bookworm-slim AS node_base
5
+
6
+ # =========================================
7
+ # 阶段 2: 构建最终镜像 (基于 Manim)
8
+ # =========================================
9
+ FROM manimcommunity/manim:stable
10
+ USER root
11
+
12
+ # 1. 复制 Node.js (从 node_base 偷过来)
13
+ COPY --from=node_base /usr/local/bin /usr/local/bin
14
+ COPY --from=node_base /usr/local/lib/node_modules /usr/local/lib/node_modules
15
+
16
+ # 2. 【关键】安装 Redis 和 中文字体 (fonts-noto-cjk)
17
+ # 使用阿里云源加速
18
+ RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources && \
19
+ apt-get update && \
20
+ apt-get install -y redis-server fonts-noto-cjk
21
+
22
+ WORKDIR /app
23
+
24
+ # 3. 复制 package.json
25
+ COPY package.json package-lock.json* ./
26
+
27
+ # 4. 设置 npm 淘宝源
28
+ RUN npm config set registry https://registry.npmmirror.com
29
+
30
+ # 5. 安装依赖
31
+ RUN npm install
32
+
33
+ # 6. 复制源码并构建 React
34
+ COPY . .
35
+ RUN npm run build
36
+
37
+ # 7. 启动脚本
38
+ RUN printf '#!/bin/bash\nredis-server --daemonize yes\nnpm run start' > /app/start.sh && chmod +x /app/start.sh
39
+
40
+ ENV PORT=7860
41
+ EXPOSE 7860
42
+
43
+ CMD ["/app/start.sh"]
44
+
Dockerfile.huggingface ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =========================================
2
+ # 阶段 1: 准备 Node 环境
3
+ # =========================================
4
+ FROM node:18-bookworm-slim AS node_base
5
+
6
+ # =========================================
7
+ # 阶段 2: 构建最终镜像 (基于 Manim)
8
+ # =========================================
9
+ FROM manimcommunity/manim:stable
10
+ USER root
11
+
12
+ # 1. 复制 Node.js (从 node_base 偷过来)
13
+ COPY --from=node_base /usr/local/bin /usr/local/bin
14
+ COPY --from=node_base /usr/local/lib/node_modules /usr/local/lib/node_modules
15
+
16
+ # 2. 【关键】安装 Redis 和 中文字体 (fonts-noto-cjk)
17
+ # 使用阿里云源加速
18
+ RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources && \
19
+ apt-get update && \
20
+ apt-get install -y redis-server fonts-noto-cjk
21
+
22
+ WORKDIR /app
23
+
24
+ # 3. 复制 package.json
25
+ COPY package.json package-lock.json* ./
26
+
27
+ # 4. 设置 npm 淘宝源
28
+ RUN npm config set registry https://registry.npmmirror.com
29
+
30
+ # 5. 安装依赖
31
+ RUN npm install
32
+
33
+ # 6. 复制源码并构建 React
34
+ COPY . .
35
+ RUN npm run build
36
+
37
+ # 7. 启动脚本
38
+ RUN printf '#!/bin/bash\nredis-server --daemonize yes\nnpm run start' > /app/start.sh && chmod +x /app/start.sh
39
+
40
+ ENV PORT=7860
41
+ EXPOSE 7860
42
+
43
+ CMD ["/app/start.sh"]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Rohit Ghumare
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <div align="center">
2
+
3
+ <!-- 顶部装饰线 - 统一为深灰色调 -->
4
+ <img width="100%" src="https://capsule-render.vercel.app/api?type=waving&color=455A64&height=120&section=header" />
5
+
6
+ <br>
7
+
8
+ <img src="public/logo.svg" width="200" alt="ManimCat Logo" />
9
+
10
+ <!-- 装饰:猫咪足迹 -->
11
+ <div style="opacity: 0.3; margin: 20px 0;">
12
+ <img src="https://raw.githubusercontent.com/Tarikul-Islam-Anik/Animated-Fluent-Emojis/master/Emojis/Animals/Paw%20Prints.png" width="40" alt="paws" />
13
+ </div>
14
+
15
+ <h1>
16
+ <picture>
17
+ <img src="https://readme-typing-svg.herokuapp.com?font=Fira+Code&size=40&duration=3000&pause=1000&color=455A64&center=true&vCenter=true&width=435&lines=ManimCat+%F0%9F%90%BE" alt="ManimCat" />
18
+ </picture>
19
+ </h1>
20
+
21
+ <!-- 装饰:数学符号分隔 -->
22
+ <p align="center">
23
+ <span style="font-family: monospace; font-size: 24px; color: #90A4AE;">
24
+ ∫ &nbsp; ∑ &nbsp; ∂ &nbsp; ∞
25
+ </span>
26
+ </p>
27
+
28
+ <p align="center">
29
+ <strong>🎬 AI-Powered Mathematical Animation Generator</strong>
30
+ </p>
31
+
32
+ <p align="center">
33
+ 让数学动画创作变得简单优雅 · 基于 Manim 与大语言模型
34
+ </p>
35
+
36
+ <!-- 装饰:几何点阵分隔 -->
37
+ <div style="margin: 30px 0;">
38
+ <span style="color: #CFD8DC; font-size: 20px;">◆ &nbsp; ◆ &nbsp; ◆</span>
39
+ </div>
40
+
41
+ <p align="center">
42
+ <img src="https://img.shields.io/badge/ManimCE-0.19.2-455A64?style=for-the-badge&logo=python&logoColor=white" alt="ManimCE" />
43
+ <img src="https://img.shields.io/badge/React-19.2.0-455A64?style=for-the-badge&logo=react&logoColor=white" alt="React" />
44
+ <img src="https://img.shields.io/badge/Node.js-18+-455A64?style=for-the-badge&logo=node.js&logoColor=white" alt="Node.js" />
45
+ <img src="https://img.shields.io/badge/License-MIT-607D8B?style=for-the-badge" alt="License" />
46
+ </p>
47
+
48
+ <p align="center" style="font-size: 18px;">
49
+ <a href="#前言"><strong>前言</strong></a> •
50
+ <a href="#样例"><strong>样例</strong></a> •
51
+ <a href="#技术"><strong>技术</strong></a> •
52
+ <a href="#部署"><strong>部署</strong></a> •
53
+ <a href="#贡献"><strong>贡献</strong></a> •
54
+ <a href="#思路"><strong>思路</strong></a> •
55
+ <a href="#现状"><strong>现状</strong></a>
56
+ </p>
57
+
58
+ <br>
59
+
60
+ <!-- 底部装饰线 - 统一为深灰色调 -->
61
+ <img width="100%" src="https://capsule-render.vercel.app/api?type=waving&color=455A64&height=100&section=footer" />
62
+
63
+ </div>
64
+
65
+ <br>
66
+
67
+ ## 前言
68
+
69
+ 很荣幸在这里介绍我的新项目ManimCat,它是~一只猫~
70
+
71
+ 本项目基于[manim-video-generator](https://github.com/rohitg00/manim-video-generator)架构级重构与二次开发而来,在此感谢原作者 Rohit Ghumare。我重写了整个前后端架构,解决了原版在并发和渲染稳定性上的痛点,并加以个人审美设计与应用的理想化改进。
72
+
73
+ ManimCat 是一个基于 AI 的数学动画生成平台,致力于让数学教师使用manim代码生成视频应用到课堂与教学之中。
74
+
75
+ 用户只需输入自然语言描述,系统便会通过 AI 自动生成 Manim 代码并渲染出精美的数学可视化视频,支持 LaTeX 公式、模板化生成以及代码错误自动修复,让复杂概念的动态展示变得触手可及。
76
+
77
+
78
+ ## 样例
79
+
80
+ 期待ing!
81
+
82
+ ## 技术
83
+
84
+ ### 技术栈
85
+
86
+ **后端**
87
+ - Express.js 4.18.0 + TypeScript 5.9.3
88
+ - Bull 4.16.5 + ioredis 5.9.2(Redis 任务队列)
89
+ - OpenAI SDK 4.50.0
90
+ - Zod 3.23.0(数据验证)
91
+
92
+ **前端**
93
+ - React 19.2.0 + TypeScript 5.9.3
94
+ - Vite 7.2.4
95
+ - TailwindCSS 3.4.19
96
+ - react-syntax-highlighter 16.1.0
97
+
98
+ **系统依赖**
99
+ - Python 3.11
100
+ - Manim Community Edition 0.19.2
101
+ - LaTeX(texlive)
102
+ - ffmpeg + Xvfb
103
+
104
+ **部署**
105
+ - Docker + Docker Compose
106
+ - Redis 7
107
+
108
+ ### 技术路线
109
+
110
+ ```
111
+ 用户请求 → POST /api/generate
112
+
113
+ [认证中间件]
114
+
115
+ [Bull 任务队列]
116
+
117
+ ┌───────────────────────────────────┐
118
+ │ 视频生成处理器 │
119
+ ├───────────────────────────────────┤
120
+ │ 1. 检查概念缓存 │
121
+ │ 2. 概念分析 │
122
+ │ - LaTeX 检测 │
123
+ │ - 模板匹配 │
124
+ │ - AI 生成(两阶段) │
125
+ │ ├─ 阶段1: 概念设计师 │
126
+ │ └─ 阶段2: 代码生成者 │
127
+ │ 3. 代码重试管理器 │
128
+ │ ├─ 首次生成代码 → 渲染 │
129
+ │ ├─ 失败 → 检查错误可修复性 │
130
+ │ ├─ 重试循环(最多4次) │
131
+ │ │ ├─ 发送完整对话历史 │
132
+ │ │ ├─ AI 修复代码 │
133
+ │ │ └─ 重新渲染 │
134
+ │ └─ 成功/失败 → 存储结果 │
135
+ │ 4. 存储结果到 Redis │
136
+ └───────────────────���───────────────┘
137
+
138
+ 前端轮询状态
139
+
140
+ GET /api/jobs/:jobId
141
+ ```
142
+
143
+ **重试机制说明:**
144
+ - 概念设计师结果会保存,不需要重复设计
145
+ - 每次重试都发送完整的对话历史(原始提示词 + 历史代码 + 错误信息)
146
+ - 最多重试 4 次,失败后任务标记为失败
147
+
148
+ ### 环境变量配置
149
+
150
+ | 环境变量 | 默认值 | 说明 |
151
+ |---------|--------|------|
152
+ | `PORT` | `3000` | 后端服务端口 |
153
+ | `REDIS_URL` | `redis://localhost:6379` | Redis 连接地址 |
154
+ | `OPENAI_API_KEY` | - | OpenAI API Key(必需) |
155
+ | `OPENAI_MODEL` | `glm-4-flash` | 使用的 AI 模型 |
156
+ | `OPENAI_TIMEOUT` | `600000` | OpenAI 请求超时时间(毫秒) |
157
+ | `AI_TEMPERATURE` | `0.7` | AI 温度参数(0-1) |
158
+ | `AI_MAX_CODE_TOKENS` | `1200` | 代码生成最大 Token 数 |
159
+ | `DESIGNER_TEMPERATURE` | `0.8` | 概念设计师温度参数 |
160
+ | `DESIGNER_MAX_TOKENS` | `800` | 概念设计师最大 Token 数 |
161
+ | `ENABLE_AI_CODE_FIX` | `true` | 是否启用 AI 代码修复 |
162
+ | `CODE_RETRY_MAX_RETRIES` | `4` | 代码重试最大次数 |
163
+ | `CUSTOM_API_URL` | - | 自定义 API 地址 |
164
+ | `ENABLE_JOB_CACHE` | `true` | 是否启用任务缓存 |
165
+ | `CACHE_TTL_SECONDS` | `3600` | 缓存过期时间(秒) |
166
+
167
+ **示例 `.env` 文件:**
168
+
169
+ ```bash
170
+ PORT=3000
171
+ REDIS_URL=redis://localhost:6379
172
+ OPENAI_API_KEY=your-api-key-here
173
+ OPENAI_MODEL=glm-4-flash
174
+ AI_TEMPERATURE=0.7
175
+ CODE_RETRY_MAX_RETRIES=4
176
+ ```
177
+
178
+ ## 部署
179
+
180
+ 请查看[部署文档](DEPLOYMENT.md)。
181
+
182
+ ## 贡献
183
+
184
+ 我对原作品进行了一些修改和重构,使其更符合我的设计想法:
185
+
186
+ 1. 框架架构重构
187
+
188
+ - 后端使用 Express.js + Bull 任务队列架构
189
+
190
+ 2. 前后端分离
191
+
192
+ - 前后端分离,React + TypeScript + Vite 独立前端
193
+
194
+ 3. 存储方案升级
195
+
196
+ - Redis 存储(任务结果、状态、缓存,支持持久化)
197
+
198
+ 4. 任务队列系统
199
+
200
+ - Bull + Redis 任务队列,支持重试、超时、指数退避
201
+
202
+ 5. 前端技术栈
203
+
204
+ - React 19 + TailwindCSS + react-syntax-highlighter
205
+
206
+ 6. 项目结构
207
+
208
+ - src/{config,middlewares,routes,services,queues,prompts,types,utils}/
209
+ frontend/src/{components,hooks,lib,types}/
210
+
211
+ 7. 新增功能
212
+
213
+
214
+ - CORS 配置中间件
215
+
216
+ - 前端主题切换、设置模态框等组件
217
+
218
+ - 增加对第三方oai格式的请求支持
219
+
220
+ - 支持第三方自定义api
221
+
222
+ - 增加重试机制,增加前后端状态查询
223
+
224
+ - 重构UI,重构提示词,采取强注入manim api规范的方式
225
+
226
+ - 增加前端自定义视频参数
227
+
228
+ - 支持内存查询端点
229
+
230
+ - 优化提示词管理系统
231
+
232
+ - 对AI的输出结合提示词进行高度优化的正则清理,适配思考模型
233
+
234
+ ## 思路
235
+
236
+ 1. 在原作者使用AI一键生成manim视频并且后端渲染的基础上,增加了fallback机制,提升弱模型的生成完成度
237
+
238
+ 2. 考虑到多数AI的manim语料训练并不多,为了降低AI幻觉率,采用提示词工程的方法,强注入manimv0.19.2的api索引表知识(自行爬取清洗制作)
239
+
240
+ ## 现状
241
+
242
+ 目前仍在完善项目,这只是第一个预览版本。我将致力于设计出更好的提示词与fallback流程。目标是可以对一道中国高考数学题进行完整的可视化。以下是建设的计划:
243
+
244
+ - 优化提示词,生成更长篇幅的Manim代码和更精准的效果
245
+ - 增加调度和重试功能
246
+ - 增加一定的验证页面,以防止滥用 (已经完成)
247
+ - 增加自定义模式功能,使用不同提示词生成不同视频
248
+ - 增加迭代功能,延长生成代码和视频长度
249
+ - 提供可能的打包版本,让非开发者可以本地实现项目
250
+
251
+ ## 开源与版权声明 (License & Copyright)
252
+
253
+ ### 1. 软件协议 (Software License)
254
+ 本项目后端架构及前端部分实现参考/使用了 [manim-video-generator](https://github.com/rohitg00/manim-video-generator) 的核心思想。
255
+ * 继承部分代码遵循 **MIT License**。
256
+ * 本项目新增的重构代码、任务队列逻辑及前端组件,同样以 **MIT License** 向开源社区开放。
257
+
258
+ ### 2. 核心资产版权声明 (Core Assets - **PROHIBITED FOR COMMERCIAL USE**)
259
+ **以下内容为本人(ManimCat 作者)原创,严禁任何形式的商用行为:**
260
+
261
+ * **Prompt Engineering(提示词工程)**:本项目中 `src/prompts/` 目录下所有高度优化的 Manim 代码生成提示词及逻辑,均为本人原创。
262
+ * **API Index Data**:本人自行爬取、清洗并制作的 Manim v0.18.2 API 索引表及相关强约束规则。
263
+ * **特定算法逻辑**:针对思考模型的正则清理算法及 fallback 容错机制。
264
+
265
+ **未经本人书面许可,任何人不得将上述“核心资产”用于:**
266
+ 1. 直接打包作为付费产品销售。
267
+ 2. 集成在付费订阅制的商业 AI 服务中。
268
+ 3. 在未注明出处的情况下进行二次分发并获利。
269
+
270
+ > 事实上,作者已经关注到市面上存在一些闭源商业项目,正利用类似的 AI + Manim 思路向数学教育工作者收取高额费用进行盈利。然而,开源社区目前仍缺乏针对教育场景深度优化的成熟项目。
271
+
272
+ > ManimCat 的诞生正是为了对标并挑战这些闭源商业软件。 我希望通过开源的方式,让每一位老师都能廉价地享受到 AI 带来的教学可视化便利————你只需要支付api的费用,幸运的是,对于优秀的中国LLM大模型来说,这些花费很廉价。为了保护这一愿景不被商业机构剽窃并反向收割用户,我坚决禁止任何对本项目核心提示词及索引数据的商业授权。
273
+
274
+
275
+ ## 维护说明
276
+
277
+ 由于作者精力有限(个人业余兴趣开发者,非专业背景),目前完全无法对外部代码进行有效的审查和长期维护。因此,本项目暂不支持团队协同开发,不接受 PR。感谢理解。
278
+
279
+ 如果你有好的建议或发现了 Bug,欢迎提交 Issue 进行讨论,我会根据自己的节奏进行改进。如果你希望在本项目基础上进行大规模修改,欢迎 Fork 出属于你自己的版本。
docker-compose.yml ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Docker Compose for ManimCat
2
+ # Production-ready with Redis for task queue and state management
3
+
4
+ version: '3.8'
5
+
6
+ services:
7
+ redis:
8
+ image: redis:7-alpine
9
+ container_name: manim-redis
10
+ ports:
11
+ - "${REDIS_PORT:-6379}:6379"
12
+ volumes:
13
+ - redis-data:/data
14
+ restart: unless-stopped
15
+ healthcheck:
16
+ test: ["CMD", "redis-cli", "ping"]
17
+ interval: 5s
18
+ timeout: 3s
19
+ retries: 10
20
+ start_period: 5s
21
+ command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
22
+ networks:
23
+ - manimcat-network
24
+
25
+ manimcat:
26
+ build:
27
+ context: .
28
+ dockerfile: Dockerfile
29
+ args:
30
+ - NODE_ENV=production
31
+ image: manimcat:latest
32
+ container_name: manimcat
33
+ ports:
34
+ - "${PORT:-3000}:3000"
35
+ environment:
36
+ - NODE_ENV=production
37
+ - PORT=3000
38
+ - REDIS_HOST=redis
39
+ - REDIS_PORT=6379
40
+ - REDIS_DB=${REDIS_DB:-0}
41
+ - OPENAI_API_KEY=${OPENAI_API_KEY:?OPENAI_API_KEY is required}
42
+ - OPENAI_MODEL=${OPENAI_MODEL:-glm-4-flash}
43
+ - CUSTOM_API_URL=${CUSTOM_API_URL:-}
44
+ - MANIMCAT_API_KEY=${MANIMCAT_API_KEY:-}
45
+ - DISPLAY=:99
46
+ volumes:
47
+ # Persist generated videos
48
+ - video-storage:/app/public/videos
49
+ # Temp directory for rendering
50
+ - manim-tmp:/app/tmp
51
+ depends_on:
52
+ redis:
53
+ condition: service_healthy
54
+ restart: unless-stopped
55
+ healthcheck:
56
+ test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))\""]
57
+ interval: 30s
58
+ timeout: 10s
59
+ retries: 3
60
+ start_period: 40s
61
+ networks:
62
+ - manimcat-network
63
+ # Resource limits (adjust based on your needs)
64
+ deploy:
65
+ resources:
66
+ limits:
67
+ cpus: '2'
68
+ memory: 4G
69
+ reservations:
70
+ cpus: '1'
71
+ memory: 2G
72
+
73
+ networks:
74
+ manimcat-network:
75
+ driver: bridge
76
+
77
+ volumes:
78
+ manim-tmp:
79
+ driver: local
80
+ redis-data:
81
+ driver: local
82
+ video-storage:
83
+ driver: local
dump.rdb ADDED
Binary file (8.21 kB). View file
 
frontend/.gitignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ node_modules
11
+ dist
12
+ dist-ssr
13
+ *.local
14
+
15
+ # Editor directories and files
16
+ .vscode/*
17
+ !.vscode/extensions.json
18
+ .idea
19
+ .DS_Store
20
+ *.suo
21
+ *.ntvs*
22
+ *.njsproj
23
+ *.sln
24
+ *.sw?
frontend/eslint.config.js ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import js from '@eslint/js'
2
+ import globals from 'globals'
3
+ import reactHooks from 'eslint-plugin-react-hooks'
4
+ import reactRefresh from 'eslint-plugin-react-refresh'
5
+ import tseslint from 'typescript-eslint'
6
+ import { defineConfig, globalIgnores } from 'eslint/config'
7
+
8
+ export default defineConfig([
9
+ globalIgnores(['dist']),
10
+ {
11
+ files: ['**/*.{ts,tsx}'],
12
+ extends: [
13
+ js.configs.recommended,
14
+ tseslint.configs.recommended,
15
+ reactHooks.configs.flat.recommended,
16
+ reactRefresh.configs.vite,
17
+ ],
18
+ languageOptions: {
19
+ ecmaVersion: 2020,
20
+ globals: globals.browser,
21
+ },
22
+ },
23
+ ])
frontend/index.html ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <!-- 直接内嵌 SVG 作为 favicon -->
6
+ <link rel="icon" href="data:image/svg+xml,<svg viewBox='0 0 512 512' xmlns='http://www.w3.org/2000/svg'><rect width='512' height='512' fill='%23faf9f5'/><path d='M 100 400 V 140 L 230 300 L 360 140 V 260' fill='none' stroke='%23455a64' stroke-width='55' stroke-linecap='round' stroke-linejoin='round'/><g transform='translate(360, 340)'><path d='M -70 40 C -80 0, -80 -30, -50 -60 L -20 -30 L 20 -30 L 50 -60 C 80 -30, 80 0, 70 40 C 60 70, -60 70, -70 40 Z' fill='%23455a64'/><circle cx='-35' cy='-5' r='18' fill='%23ffffff'/><circle cx='35' cy='-5' r='18' fill='%23ffffff'/><circle cx='-38' cy='-5' r='6' fill='%23455a64'/><circle cx='32' cy='-5' r='6' fill='%23455a64'/></g></svg>" />
7
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
8
+ <title>ManimCat - 数学动画生成器</title>
9
+ </head>
10
+ <body>
11
+ <div id="root"></div>
12
+ <script type="module" src="/src/main.tsx"></script>
13
+ </body>
14
+ </html>
frontend/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
frontend/package.json ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "manim-cat-frontend",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "lint": "eslint .",
10
+ "preview": "vite preview"
11
+ },
12
+ "dependencies": {
13
+ "@types/react-syntax-highlighter": "^15.5.13",
14
+ "react": "^19.2.0",
15
+ "react-dom": "^19.2.0",
16
+ "react-syntax-highlighter": "^16.1.0"
17
+ },
18
+ "devDependencies": {
19
+ "@eslint/js": "^9.39.1",
20
+ "@types/node": "^24.10.1",
21
+ "@types/react": "^19.2.5",
22
+ "@types/react-dom": "^19.2.3",
23
+ "@vitejs/plugin-react": "^5.1.1",
24
+ "autoprefixer": "^10.4.23",
25
+ "eslint": "^9.39.1",
26
+ "eslint-plugin-react-hooks": "^7.0.1",
27
+ "eslint-plugin-react-refresh": "^0.4.24",
28
+ "globals": "^16.5.0",
29
+ "postcss": "^8.5.6",
30
+ "tailwindcss": "^3.4.19",
31
+ "typescript": "~5.9.3",
32
+ "typescript-eslint": "^8.46.4",
33
+ "vite": "^7.2.4"
34
+ }
35
+ }
frontend/postcss.config.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ export default {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ }
frontend/public/logo-16.png ADDED
frontend/public/logo-192.png ADDED
frontend/public/logo-32.png ADDED
frontend/public/logo-48.png ADDED
frontend/public/logo-96.png ADDED
frontend/public/logo.svg ADDED
frontend/src/App.css ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #root {
2
+ max-width: 1280px;
3
+ margin: 0 auto;
4
+ padding: 2rem;
5
+ text-align: center;
6
+ }
7
+
8
+ .logo {
9
+ height: 6em;
10
+ padding: 1.5em;
11
+ will-change: filter;
12
+ transition: filter 300ms;
13
+ }
14
+ .logo:hover {
15
+ filter: drop-shadow(0 0 2em #646cffaa);
16
+ }
17
+ .logo.react:hover {
18
+ filter: drop-shadow(0 0 2em #61dafbaa);
19
+ }
20
+
21
+ @keyframes logo-spin {
22
+ from {
23
+ transform: rotate(0deg);
24
+ }
25
+ to {
26
+ transform: rotate(360deg);
27
+ }
28
+ }
29
+
30
+ @media (prefers-reduced-motion: no-preference) {
31
+ a:nth-of-type(2) .logo {
32
+ animation: logo-spin infinite 20s linear;
33
+ }
34
+ }
35
+
36
+ .card {
37
+ padding: 2em;
38
+ }
39
+
40
+ .read-the-docs {
41
+ color: #888;
42
+ }
frontend/src/App.tsx ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 { ThemeToggle } from './components/ThemeToggle';
9
+ import { SettingsModal } from './components/SettingsModal';
10
+ import ManimCatLogo from './components/ManimCatLogo';
11
+ import type { Quality } from './types/api';
12
+
13
+ function App() {
14
+ const { status, result, error, jobId, stage, generate, reset, cancel } = useGeneration();
15
+ const [settingsOpen, setSettingsOpen] = useState(false);
16
+
17
+ const handleSubmit = (data: { concept: string; quality: Quality; forceRefresh: boolean }) => {
18
+ generate(data);
19
+ };
20
+
21
+ return (
22
+ <div className="min-h-screen bg-bg-primary transition-colors duration-300">
23
+ {/* 主题切换按钮 */}
24
+ <div className="fixed top-4 right-4 z-50 flex items-center gap-2">
25
+ <button
26
+ onClick={() => setSettingsOpen(true)}
27
+ className="p-2.5 text-text-secondary/70 hover:text-text-secondary hover:bg-bg-secondary/50 rounded-full transition-all"
28
+ title="API 设置"
29
+ >
30
+ <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
31
+ <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" />
32
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
33
+ </svg>
34
+ </button>
35
+ <ThemeToggle />
36
+ </div>
37
+
38
+ {/* 主容器 */}
39
+ <div className="max-w-4xl mx-auto px-4 py-16 sm:py-20">
40
+ {/* 标题 */}
41
+ <div className="text-center mb-12">
42
+ <div className="flex items-center justify-center gap-4 mb-3">
43
+ <ManimCatLogo className="w-16 h-16" />
44
+ <h1 className="text-5xl sm:text-6xl font-light tracking-tight text-text-primary">
45
+ ManimCat
46
+ </h1>
47
+ </div>
48
+ <p className="text-sm text-text-secondary/70 max-w-lg mx-auto">
49
+ 用 AI 驱动 Manim 生成数学动画
50
+ </p>
51
+ </div>
52
+
53
+ {/* 状态显示区域 */}
54
+ <div className="mb-6">
55
+ {status === 'idle' && (
56
+ <InputForm
57
+ onSubmit={handleSubmit}
58
+ loading={false}
59
+ />
60
+ )}
61
+
62
+ {status === 'processing' && (
63
+ <div className="bg-bg-secondary/20 rounded-2xl p-8">
64
+ <LoadingSpinner stage={stage} jobId={jobId || undefined} onCancel={cancel} />
65
+ </div>
66
+ )}
67
+
68
+ {status === 'completed' && result && (
69
+ <div
70
+ className="space-y-6 animate-fade-in"
71
+ style={{
72
+ animation: 'fadeInUp 0.5s ease-out forwards',
73
+ }}
74
+ >
75
+ {/* 结果展示 */}
76
+ <ResultSection
77
+ code={result.code || ''}
78
+ videoUrl={result.video_url || ''}
79
+ usedAI={result.used_ai || false}
80
+ renderQuality={result.render_quality || ''}
81
+ generationType={result.generation_type || ''}
82
+ />
83
+
84
+ {/* 重新生成按钮 */}
85
+ <div className="text-center">
86
+ <button
87
+ onClick={reset}
88
+ 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"
89
+ >
90
+ 生成新的动画
91
+ </button>
92
+ </div>
93
+ </div>
94
+ )}
95
+
96
+ {status === 'error' && (
97
+ <div className="bg-red-50/80 dark:bg-red-900/20 rounded-2xl p-6">
98
+ <div className="flex items-start gap-3">
99
+ <div className="text-red-500 mt-0.5">
100
+ <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
101
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
102
+ </svg>
103
+ </div>
104
+ <div className="flex-1">
105
+ <p className="text-text-primary font-medium mb-1">出错了</p>
106
+ <p className="text-text-secondary text-sm">{error || '生成失败,请重试'}</p>
107
+ </div>
108
+ </div>
109
+ <div className="mt-4 flex gap-3">
110
+ <button
111
+ onClick={reset}
112
+ className="px-4 py-2 text-sm text-accent hover:text-accent-hover transition-colors"
113
+ >
114
+ 重试
115
+ </button>
116
+ </div>
117
+ </div>
118
+ )}
119
+ </div>
120
+ </div>
121
+
122
+ {/* 添加淡入上浮动画 */}
123
+ <style>{`
124
+ @keyframes fadeInUp {
125
+ 0% {
126
+ opacity: 0;
127
+ transform: translateY(30px);
128
+ }
129
+ 100% {
130
+ opacity: 1;
131
+ transform: translateY(0);
132
+ }
133
+ }
134
+ `}</style>
135
+
136
+ {/* 设置模态框 */}
137
+ <SettingsModal
138
+ isOpen={settingsOpen}
139
+ onClose={() => setSettingsOpen(false)}
140
+ onSave={(config) => {
141
+ console.log('保存配置:', config);
142
+ }}
143
+ />
144
+ </div>
145
+ );
146
+ }
147
+
148
+ export default App;
frontend/src/components/CodeView.tsx ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 代码预览组件
2
+
3
+ import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
4
+ import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
5
+ 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 () => {
15
+ await navigator.clipboard.writeText(code);
16
+ setCopied(true);
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
+ {/* 顶部工具栏 */}
23
+ <div className="flex items-center justify-between px-4 py-2.5">
24
+ <h3 className="text-xs font-medium text-text-secondary/80 uppercase tracking-wide">生成的代码</h3>
25
+ <button
26
+ onClick={handleCopy}
27
+ className="text-xs text-text-secondary/70 hover:text-accent transition-colors flex items-center gap-1.5"
28
+ >
29
+ {copied ? (
30
+ <>
31
+ <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
32
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
33
+ </svg>
34
+ 已复制
35
+ </>
36
+ ) : (
37
+ <>
38
+ <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
39
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
40
+ </svg>
41
+ 复制
42
+ </>
43
+ )}
44
+ </button>
45
+ </div>
46
+
47
+ {/* 代码区域 */}
48
+ <div className="flex-1 overflow-auto">
49
+ <SyntaxHighlighter
50
+ language="python"
51
+ style={vscDarkPlus}
52
+ customStyle={{
53
+ margin: 0,
54
+ padding: '1rem',
55
+ fontSize: '0.75rem',
56
+ lineHeight: '1.6',
57
+ fontFamily: 'Monaco, Cascadia Code, Roboto Mono, monospace',
58
+ background: 'transparent',
59
+ }}
60
+ showLineNumbers
61
+ >
62
+ {code}
63
+ </SyntaxHighlighter>
64
+ </div>
65
+ </div>
66
+ );
67
+ }
frontend/src/components/CustomSelect.tsx ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 自定义下拉选择组件 - MD3 风格
2
+
3
+ import { useState, useRef, useEffect } from 'react';
4
+
5
+ export interface SelectOption<T = string> {
6
+ value: T;
7
+ label: string;
8
+ }
9
+
10
+ interface CustomSelectProps<T = string> {
11
+ options: SelectOption<T>[];
12
+ value: T;
13
+ onChange: (value: T) => void;
14
+ label: string;
15
+ className?: string;
16
+ disabled?: boolean;
17
+ }
18
+
19
+ export function CustomSelect<T = string>({
20
+ options,
21
+ value,
22
+ onChange,
23
+ label,
24
+ className = '',
25
+ disabled = false
26
+ }: CustomSelectProps<T>) {
27
+ const [isOpen, setIsOpen] = useState(false);
28
+ const dropdownRef = useRef<HTMLDivElement>(null);
29
+
30
+ const selectedOption = options.find(opt => {
31
+ if (typeof opt.value === 'number' && typeof value === 'number') {
32
+ return opt.value === value;
33
+ }
34
+ return opt.value === value;
35
+ });
36
+
37
+ // 点击外部关闭下拉菜单
38
+ useEffect(() => {
39
+ function handleClickOutside(event: MouseEvent) {
40
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
41
+ setIsOpen(false);
42
+ }
43
+ }
44
+ document.addEventListener('mousedown', handleClickOutside);
45
+ return () => document.removeEventListener('mousedown', handleClickOutside);
46
+ }, []);
47
+
48
+ return (
49
+ <div className={`relative ${className}`} ref={dropdownRef}>
50
+ <label className="absolute left-3 -top-2 px-1.5 bg-bg-secondary text-xs font-medium text-text-secondary">
51
+ {label}
52
+ </label>
53
+
54
+ {/* 触发按钮 */}
55
+ <button
56
+ type="button"
57
+ onClick={() => !disabled && setIsOpen(!isOpen)}
58
+ disabled={disabled}
59
+ className="w-full px-4 py-3.5 pr-10 bg-bg-secondary/50 rounded-2xl text-left text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:bg-bg-secondary/70 transition-all cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed hover:bg-bg-secondary/60"
60
+ >
61
+ <span>{selectedOption?.label}</span>
62
+ </button>
63
+
64
+ {/* 下拉箭头 */}
65
+ <svg
66
+ className={`absolute right-4 top-1/2 -translate-y-1/2 w-5 h-5 text-text-secondary pointer-events-none transition-transform duration-200 ${
67
+ isOpen ? 'rotate-180' : ''
68
+ }`}
69
+ fill="none"
70
+ stroke="currentColor"
71
+ viewBox="0 0 24 24"
72
+ >
73
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
74
+ </svg>
75
+
76
+ {/* 下拉菜单 */}
77
+ {isOpen && (
78
+ <div className="absolute top-full left-0 right-0 mt-2 bg-bg-secondary rounded-2xl shadow-xl shadow-black/10 overflow-hidden z-50 animate-in fade-in slide-in-from-top-1 duration-200">
79
+ {options.map((option) => {
80
+ const isSelected = typeof option.value === 'number' && typeof value === 'number'
81
+ ? option.value === value
82
+ : option.value === value;
83
+
84
+ return (
85
+ <button
86
+ key={String(option.value)}
87
+ type="button"
88
+ onClick={() => {
89
+ onChange(option.value);
90
+ setIsOpen(false);
91
+ }}
92
+ className={`w-full px-4 py-3.5 text-left transition-colors hover:bg-bg-secondary/70 ${
93
+ isSelected ? 'bg-bg-secondary/50' : ''
94
+ }`}
95
+ >
96
+ <span className="text-text-primary">{option.label}</span>
97
+ </button>
98
+ );
99
+ })}
100
+ </div>
101
+ )}
102
+ </div>
103
+ );
104
+ }
frontend/src/components/ExampleButtons.tsx ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 自动滚动示例组件
2
+
3
+ import { useState, useEffect } from 'react';
4
+
5
+ interface ExampleButtonsProps {
6
+ onSelect: (example: string) => void;
7
+ disabled: boolean;
8
+ }
9
+
10
+ /** 示例列表 */
11
+ const EXAMPLES = [
12
+ '演示勾股定理,带动画三角形和正方形',
13
+ '可视化二次函数及其属性并带动画',
14
+ '在单位圆上展示正弦和余弦的关系,带动画角度',
15
+ '创建 3D 曲面图,展示 z = x² + y²',
16
+ '计算并可视化半径为 r 的球体体积',
17
+ '展示如何用动画求立方体的表面积',
18
+ '将导数可视化切线斜率',
19
+ '用动画展示曲线下面积的工作原理',
20
+ '用动画变换演示矩阵运算',
21
+ '可视化 2x2 矩阵的特征值和特征向量',
22
+ '展示复数乘法使用旋转和缩放',
23
+ '动画展示简单微分方程的解',
24
+ ];
25
+
26
+ export function ExampleButtons({ onSelect, disabled }: ExampleButtonsProps) {
27
+ const [currentIndex, setCurrentIndex] = useState(0);
28
+
29
+ useEffect(() => {
30
+ if (disabled) return;
31
+
32
+ const interval = setInterval(() => {
33
+ setCurrentIndex((prev) => (prev + 1) % EXAMPLES.length);
34
+ }, 3000);
35
+
36
+ return () => clearInterval(interval);
37
+ }, [disabled]);
38
+
39
+ return (
40
+ <div className="w-full">
41
+ {/* 自动滚动示例卡片 */}
42
+ <div className="relative overflow-hidden rounded-2xl bg-bg-secondary/40">
43
+ {/* 当前显示的示例 */}
44
+ <div className="p-5 sm:p-6 min-h-[80px] flex items-center justify-center transition-all duration-500">
45
+ <button
46
+ type="button"
47
+ onClick={() => onSelect(EXAMPLES[currentIndex])}
48
+ disabled={disabled}
49
+ className="text-center text-sm sm:text-base text-text-primary/90 hover:text-text-primary transition-all disabled:opacity-50 disabled:cursor-not-allowed active:scale-[0.98]"
50
+ >
51
+ {EXAMPLES[currentIndex]}
52
+ </button>
53
+ </div>
54
+
55
+ {/* 左右导航按钮 */}
56
+ <div className="absolute top-1/2 left-3 -translate-y-1/2">
57
+ <button
58
+ type="button"
59
+ onClick={() => setCurrentIndex((prev) => (prev - 1 + EXAMPLES.length) % EXAMPLES.length)}
60
+ disabled={disabled}
61
+ className="w-8 h-8 flex items-center justify-center rounded-full bg-bg-secondary text-text-secondary hover:text-accent hover:bg-bg-secondary/80 disabled:opacity-30 disabled:cursor-not-allowed transition-all"
62
+ >
63
+ <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
64
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
65
+ </svg>
66
+ </button>
67
+ </div>
68
+
69
+ <div className="absolute top-1/2 right-3 -translate-y-1/2">
70
+ <button
71
+ type="button"
72
+ onClick={() => setCurrentIndex((prev) => (prev + 1) % EXAMPLES.length)}
73
+ disabled={disabled}
74
+ className="w-8 h-8 flex items-center justify-center rounded-full bg-bg-secondary text-text-secondary hover:text-accent hover:bg-bg-secondary/80 disabled:opacity-30 disabled:cursor-not-allowed transition-all"
75
+ >
76
+ <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
77
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
78
+ </svg>
79
+ </button>
80
+ </div>
81
+ </div>
82
+ </div>
83
+ );
84
+ }
frontend/src/components/InputForm.tsx ADDED
@@ -0,0 +1,254 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 输入表单组件 - MD3 风格
2
+
3
+ import { useState, useRef, useEffect, useCallback } from 'react';
4
+ import type { Quality, VideoConfig, SettingsConfig } from '../types/api';
5
+
6
+ interface InputFormProps {
7
+ onSubmit: (data: { concept: string; quality: Quality; forceRefresh: boolean }) => void;
8
+ loading: boolean;
9
+ }
10
+
11
+ /** 质量选项 */
12
+ const QUALITY_OPTIONS = [
13
+ { value: 'low' as Quality, label: '低 (480p)', desc: '最快' },
14
+ { value: 'medium' as Quality, label: '中 (720p)', desc: '' },
15
+ { value: 'high' as Quality, label: '高 (1080p)', desc: '最慢' },
16
+ ];
17
+
18
+ /** 从 localStorage 加载默认质量 */
19
+ function loadDefaultQuality(): Quality {
20
+ try {
21
+ const saved = localStorage.getItem('manimcat_settings');
22
+ if (saved) {
23
+ const parsed = JSON.parse(saved) as SettingsConfig;
24
+ if (parsed.video?.quality) {
25
+ return parsed.video.quality;
26
+ }
27
+ }
28
+ } catch {
29
+ // 忽略错误
30
+ }
31
+ return 'medium'; // 默认值
32
+ }
33
+
34
+ export function InputForm({ onSubmit, loading }: InputFormProps) {
35
+ const [concept, setConcept] = useState('');
36
+ const [error, setError] = useState<string | null>(null);
37
+ const [forceRefresh, setForceRefresh] = useState(false);
38
+ const [quality, setQuality] = useState<Quality>(loadDefaultQuality());
39
+ const [qualityOpen, setQualityOpen] = useState(false);
40
+ const dropdownRef = useRef<HTMLDivElement>(null);
41
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
42
+
43
+ // 点击外部关闭下拉菜单
44
+ useEffect(() => {
45
+ function handleClickOutside(event: MouseEvent) {
46
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
47
+ setQualityOpen(false);
48
+ }
49
+ }
50
+ document.addEventListener('mousedown', handleClickOutside);
51
+ return () => document.removeEventListener('mousedown', handleClickOutside);
52
+ }, []);
53
+
54
+ // 键盘快捷键:Ctrl+Enter 提交
55
+ useEffect(() => {
56
+ const handleKeyDown = (e: KeyboardEvent) => {
57
+ if (e.ctrlKey && e.key === 'Enter' && !loading) {
58
+ e.preventDefault();
59
+ handleSubmit();
60
+ }
61
+ };
62
+ document.addEventListener('keydown', handleKeyDown);
63
+ return () => document.removeEventListener('keydown', handleKeyDown);
64
+ }, [loading, concept, quality, forceRefresh]);
65
+
66
+ // 实时验证
67
+ useEffect(() => {
68
+ if (concept.length > 0 && concept.length < 5) {
69
+ setError('请至少输入 5 个字符');
70
+ } else {
71
+ setError(null);
72
+ }
73
+ }, [concept]);
74
+
75
+ const handleSubmit = useCallback(() => {
76
+ if (concept.trim().length < 5) {
77
+ setError('请至少输入 5 个字符描述你想要动画的内容');
78
+ textareaRef.current?.focus();
79
+ return;
80
+ }
81
+ setError(null);
82
+ onSubmit({ concept: concept.trim(), quality, forceRefresh });
83
+ }, [concept, quality, forceRefresh, onSubmit]);
84
+
85
+ const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
86
+ e.preventDefault();
87
+ handleSubmit();
88
+ };
89
+
90
+ const selectedOption = QUALITY_OPTIONS.find(opt => opt.value === quality);
91
+
92
+ return (
93
+ <div className="w-full max-w-2xl mx-auto">
94
+ <form onSubmit={handleFormSubmit} className="space-y-6">
95
+ {/* 概念输入 - MD3 Filled Text Field */}
96
+ <div className="relative">
97
+ <label
98
+ htmlFor="concept"
99
+ className={`absolute left-4 -top-2.5 px-2 bg-bg-primary text-xs font-medium transition-all ${
100
+ error ? 'text-red-500' : 'text-text-secondary'
101
+ }`}
102
+ >
103
+ {error ? error : '描述你想要的动画'}
104
+ </label>
105
+ <textarea
106
+ ref={textareaRef}
107
+ id="concept"
108
+ name="concept"
109
+ rows={4}
110
+ placeholder="例如:展示单位圆上正弦和余弦的关系..."
111
+ disabled={loading}
112
+ value={concept}
113
+ onChange={(e) => setConcept(e.target.value)}
114
+ className={`w-full px-4 py-4 bg-bg-secondary/50 rounded-2xl text-text-primary placeholder-text-secondary/40 focus:outline-none focus:ring-2 transition-all resize-none ${
115
+ error
116
+ ? 'focus:ring-red-500/20 bg-red-50/50 dark:bg-red-900/10'
117
+ : 'focus:ring-accent/20 focus:bg-bg-secondary/70'
118
+ }`}
119
+ />
120
+ </div>
121
+
122
+ {/* 选项区域 */}
123
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
124
+ {/* 质量选择 - 自定义下拉菜单 */}
125
+ <div className="relative" ref={dropdownRef}>
126
+ <label className="absolute left-3 -top-2 px-1.5 bg-bg-primary text-xs font-medium text-text-secondary">
127
+ 视频质量
128
+ </label>
129
+
130
+ {/* 触发按钮 */}
131
+ <button
132
+ type="button"
133
+ onClick={() => !loading && setQualityOpen(!qualityOpen)}
134
+ disabled={loading}
135
+ className="w-full px-4 py-3.5 pr-10 bg-bg-secondary/50 rounded-2xl text-left text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:bg-bg-secondary/70 transition-all cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed hover:bg-bg-secondary/60"
136
+ >
137
+ <span className="flex items-center justify-between">
138
+ <span>{selectedOption?.label} {selectedOption?.desc && <span className="text-text-secondary/60 text-xs ml-1">- {selectedOption.desc}</span>}</span>
139
+ </span>
140
+ </button>
141
+
142
+ {/* 下拉箭头 */}
143
+ <svg
144
+ className={`absolute right-4 top-1/2 -translate-y-1/2 w-5 h-5 text-text-secondary pointer-events-none transition-transform duration-200 ${
145
+ qualityOpen ? 'rotate-180' : ''
146
+ }`}
147
+ fill="none"
148
+ stroke="currentColor"
149
+ viewBox="0 0 24 24"
150
+ >
151
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
152
+ </svg>
153
+
154
+ {/* 下拉菜单 */}
155
+ {qualityOpen && (
156
+ <div className="absolute top-full left-0 right-0 mt-2 bg-white rounded-2xl shadow-xl shadow-black/10 overflow-hidden z-50 animate-in fade-in slide-in-from-top-1 duration-200">
157
+ {QUALITY_OPTIONS.map((option) => (
158
+ <button
159
+ key={option.value}
160
+ type="button"
161
+ onClick={() => {
162
+ setQuality(option.value);
163
+ setQualityOpen(false);
164
+ }}
165
+ className={`w-full px-4 py-3.5 text-left transition-colors hover:bg-bg-secondary ${
166
+ quality === option.value ? 'bg-bg-secondary/70' : ''
167
+ }`}
168
+ >
169
+ <div className="flex items-center justify-between">
170
+ <span className="text-text-primary">{option.label}</span>
171
+ {option.desc && <span className="text-xs text-text-secondary/60">- {option.desc}</span>}
172
+ </div>
173
+ </button>
174
+ ))}
175
+ </div>
176
+ )}
177
+
178
+ {/* 隐藏的 input 用于表单提交 */}
179
+ <input type="hidden" name="quality" value={quality} />
180
+ </div>
181
+
182
+ {/* 强制刷新 - MD3 Switch */}
183
+ <div className="flex flex-col justify-center">
184
+ <label htmlFor="forceRefresh" className="text-xs font-medium text-text-secondary mb-3">
185
+ 强制重新生成
186
+ </label>
187
+ <div className="flex items-center gap-3">
188
+ {/* MD3 Switch */}
189
+ <button
190
+ type="button"
191
+ onClick={() => setForceRefresh(!forceRefresh)}
192
+ disabled={loading}
193
+ className={`relative w-12 h-7 rounded-full transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-accent/20 ${
194
+ forceRefresh ? 'bg-accent' : 'bg-bg-tertiary'
195
+ }`}
196
+ >
197
+ <span
198
+ className={`absolute top-1 left-1 w-5 h-5 bg-white rounded-full shadow-md transition-transform duration-200 ${
199
+ forceRefresh ? 'translate-x-5' : 'translate-x-0'
200
+ }`}
201
+ />
202
+ </button>
203
+ <input
204
+ type="checkbox"
205
+ id="forceRefresh"
206
+ name="forceRefresh"
207
+ checked={forceRefresh}
208
+ onChange={(e) => setForceRefresh(e.target.checked)}
209
+ disabled={loading}
210
+ className="sr-only"
211
+ />
212
+ <span className="text-xs text-text-secondary/60">跳过缓存</span>
213
+ </div>
214
+ </div>
215
+ </div>
216
+
217
+ {/* 提交按钮 - MD3 Filled Button */}
218
+ <div className="flex justify-center pt-4">
219
+ <button
220
+ type="submit"
221
+ disabled={loading || concept.trim().length < 5}
222
+ className="group relative px-12 py-3.5 bg-accent hover:bg-accent-hover text-white font-medium rounded-full shadow-lg shadow-accent/25 transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed hover:shadow-xl hover:shadow-accent/35 active:scale-[0.97] overflow-hidden"
223
+ >
224
+ <span className="relative z-10 flex items-center gap-2">
225
+ {loading ? (
226
+ <>
227
+ <svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
228
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
229
+ <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" />
230
+ </svg>
231
+ 生成中...
232
+ </>
233
+ ) : (
234
+ <>
235
+ 生成动画
236
+ <svg className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
237
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
238
+ </svg>
239
+ </>
240
+ )}
241
+ </span>
242
+ {/* 按钮光效 */}
243
+ <div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent -translate-x-full group-hover:animate-shimmer" />
244
+ </button>
245
+ </div>
246
+
247
+ {/* 快捷键提示 */}
248
+ <p className="text-center text-xs text-text-secondary/50">
249
+ 按 <kbd className="px-1.5 py-0.5 bg-bg-secondary/50 rounded text-[10px]">Ctrl</kbd> + <kbd className="px-1.5 py-0.5 bg-bg-secondary/50 rounded text-[10px]">Enter</kbd> 快速提交
250
+ </p>
251
+ </form>
252
+ </div>
253
+ );
254
+ }
frontend/src/components/LoadingSpinner.tsx ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 加载动画组件 - Logo 小猫头飘荡
2
+
3
+ import { useEffect, useState } from 'react';
4
+
5
+ interface LoadingSpinnerProps {
6
+ stage: 'analyzing' | 'generating' | 'refining' | 'rendering' | 'still-rendering';
7
+ jobId?: string;
8
+ onCancel?: () => void;
9
+ }
10
+
11
+ /** 处理阶段文案 */
12
+ const STAGE_TEXT: Record<LoadingSpinnerProps['stage'], { text: string; sub: string }> = {
13
+ analyzing: { text: '分析概念中', sub: '检测 LaTeX 和匹配模板' },
14
+ generating: { text: '生成代码中', sub: '创建动画脚本' },
15
+ refining: { text: 'AI 完善中', sub: '正在优化生成结果...' },
16
+ rendering: { text: '渲染动画中', sub: '这可能需要一些时间' },
17
+ 'still-rendering': { text: '仍在渲染中', sub: '复杂动画需要更长时间' },
18
+ };
19
+
20
+ /** Logo 同款小猫头 - 100% 复制原版 */
21
+ function CatHead() {
22
+ return (
23
+ <svg width="80" height="80" viewBox="0 0 140 140" xmlns="http://www.w3.org/2000/svg" className="drop-shadow-lg">
24
+ <g transform="translate(70, 70)">
25
+ {/* 猫头主体 - 完全复制 Logo 原路径 */}
26
+ <path
27
+ d="M -70 40 C -80 0, -80 -30, -50 -60 L -20 -30 L 20 -30 L 50 -60 C 80 -30, 80 0, 70 40 C 60 70, -60 70, -70 40 Z"
28
+ fill="#455a64"
29
+ />
30
+ {/* 左眼白 */}
31
+ <circle cx="-35" cy="-5" r="18" fill="#ffffff" />
32
+ {/* 右眼白 */}
33
+ <circle cx="35" cy="-5" r="18" fill="#ffffff" />
34
+ {/* 左眼珠 */}
35
+ <circle cx="-38" cy="-5" r="6" fill="#455a64" />
36
+ {/* 右眼珠 */}
37
+ <circle cx="32" cy="-5" r="6" fill="#455a64" />
38
+ </g>
39
+ </svg>
40
+ );
41
+ }
42
+
43
+ /** 浮动小猫头 */
44
+ function FloatingCat() {
45
+ const [yOffset, setYOffset] = useState(0);
46
+
47
+ useEffect(() => {
48
+ let time = 0;
49
+ const animate = () => {
50
+ time += 0.025;
51
+ const y = Math.sin(time) * 5;
52
+ setYOffset(y);
53
+ requestAnimationFrame(animate);
54
+ };
55
+ const animationId = requestAnimationFrame(animate);
56
+ return () => cancelAnimationFrame(animationId);
57
+ }, []);
58
+
59
+ return <div style={{ transform: `translateY(${yOffset}px)` }}><CatHead /></div>;
60
+ }
61
+
62
+ export function LoadingSpinner({ stage, jobId, onCancel }: LoadingSpinnerProps) {
63
+ const { text, sub } = STAGE_TEXT[stage];
64
+
65
+ return (
66
+ <div className="flex flex-col items-center justify-center py-12">
67
+ <div className="mb-5">
68
+ <FloatingCat />
69
+ </div>
70
+ <p className="text-base font-medium text-text-primary mb-1">{text}</p>
71
+ <p className="text-sm text-text-secondary/70 mb-3">{sub}</p>
72
+ {jobId && (
73
+ <p className="text-xs text-text-secondary/60 font-mono bg-bg-secondary/40 px-3 py-1 rounded-full">
74
+ {jobId.slice(0, 8)}
75
+ </p>
76
+ )}
77
+ {onCancel && (
78
+ <button
79
+ onClick={onCancel}
80
+ className="mt-5 px-5 py-1.5 text-sm text-text-secondary/70 hover:text-red-500 transition-colors bg-bg-secondary/30 hover:bg-bg-secondary/50 rounded-full"
81
+ >
82
+ 取消生成
83
+ </button>
84
+ )}
85
+ </div>
86
+ );
87
+ }
frontend/src/components/ManimCatLogo.tsx ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const ManimCatLogo = ({ className }: { className?: string }) => (
2
+ <svg
3
+ viewBox="0 0 512 512"
4
+ xmlns="http://www.w3.org/2000/svg"
5
+ className={className}
6
+ >
7
+ <rect width="512" height="512" fill="#faf9f5"/>
8
+ <path
9
+ d="M 100 400 V 140 L 230 300 L 360 140 V 260"
10
+ fill="none"
11
+ stroke="#455a64"
12
+ strokeWidth="55"
13
+ strokeLinecap="round"
14
+ strokeLinejoin="round"
15
+ />
16
+ <g transform="translate(360, 340)">
17
+ <path
18
+ d="M -70 40 C -80 0, -80 -30, -50 -60 L -20 -30 L 20 -30 L 50 -60 C 80 -30, 80 0, 70 40 C 60 70, -60 70, -70 40 Z"
19
+ fill="#455a64"
20
+ />
21
+ <circle cx="-35" cy="-5" r="18" fill="#ffffff" />
22
+ <circle cx="35" cy="-5" r="18" fill="#ffffff" />
23
+ <circle cx="-38" cy="-5" r="6" fill="#455a64" />
24
+ <circle cx="32" cy="-5" r="6" fill="#455a64" />
25
+ </g>
26
+ </svg>
27
+ );
28
+
29
+ export default ManimCatLogo;
frontend/src/components/ResultSection.tsx ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 结果展示组件
2
+
3
+ import { CodeView } from './CodeView';
4
+ import { VideoPreview } from './VideoPreview';
5
+
6
+ interface ResultSectionProps {
7
+ code: string;
8
+ videoUrl: string;
9
+ usedAI: boolean;
10
+ renderQuality: string;
11
+ generationType: string;
12
+ }
13
+
14
+ export function ResultSection({ code, videoUrl, usedAI, renderQuality, generationType }: ResultSectionProps) {
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
+ }
frontend/src/components/SettingsModal.tsx ADDED
@@ -0,0 +1,369 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 设置模态框 - MD3 风格
2
+
3
+ import { useState, useEffect } from 'react';
4
+ import { CustomSelect } from './CustomSelect';
5
+ import type { Quality, ApiConfig, VideoConfig, SettingsConfig } from '../types/api';
6
+
7
+ type TabType = 'api' | 'video';
8
+
9
+ interface TestResult {
10
+ status: 'idle' | 'testing' | 'success' | 'error';
11
+ message: string;
12
+ details?: {
13
+ statusCode?: number;
14
+ statusText?: string;
15
+ responseBody?: string;
16
+ headers?: Record<string, string>;
17
+ duration?: number;
18
+ error?: string;
19
+ };
20
+ }
21
+
22
+ interface SettingsModalProps {
23
+ isOpen: boolean;
24
+ onClose: () => void;
25
+ onSave: (config: SettingsConfig) => void;
26
+ }
27
+
28
+ /** 从 localStorage 加载配置 */
29
+ function loadConfig(): SettingsConfig {
30
+ const saved = localStorage.getItem('manimcat_settings');
31
+ if (saved) {
32
+ try {
33
+ const parsed = JSON.parse(saved) as SettingsConfig;
34
+ return {
35
+ api: parsed.api || { apiUrl: '', apiKey: '', model: '', manimcatApiKey: '' },
36
+ video: parsed.video || { quality: 'medium', frameRate: 30, timeout: 120 }
37
+ };
38
+ } catch {
39
+ return { api: { apiUrl: '', apiKey: '', model: '', manimcatApiKey: '' }, video: { quality: 'medium', frameRate: 30, timeout: 120 } };
40
+ }
41
+ }
42
+ // 同时检查单独存储的 ManimCat API Key(兼容旧版本)
43
+ const manimcatKey = localStorage.getItem('manimcat_api_key') || '';
44
+ return { api: { apiUrl: '', apiKey: '', model: '', manimcatApiKey: manimcatKey }, video: { quality: 'medium', frameRate: 30, timeout: 120 } };
45
+ }
46
+
47
+ /** 保存配置到 localStorage */
48
+ function saveConfig(config: SettingsConfig): void {
49
+ localStorage.setItem('manimcat_settings', JSON.stringify(config));
50
+ // 单独保存 ManimCat API Key(用于 api.ts 的 getAuthHeaders,兼容旧版本)
51
+ if (config.api.manimcatApiKey) {
52
+ localStorage.setItem('manimcat_api_key', config.api.manimcatApiKey);
53
+ } else {
54
+ localStorage.removeItem('manimcat_api_key');
55
+ }
56
+ }
57
+
58
+ export function SettingsModal({ isOpen, onClose, onSave }: SettingsModalProps) {
59
+ const [config, setConfig] = useState<SettingsConfig>({ api: { apiUrl: '', apiKey: '', model: '', manimcatApiKey: '' }, video: { quality: 'medium', frameRate: 30, timeout: 120 } });
60
+ const [testResult, setTestResult] = useState<TestResult>({ status: 'idle', message: '' });
61
+ const [activeTab, setActiveTab] = useState<TabType>('api');
62
+
63
+ const updateApiConfig = (updates: Partial<ApiConfig>) => {
64
+ setConfig(prev => ({ ...prev, api: { ...prev.api, ...updates } }));
65
+ };
66
+
67
+ const updateVideoConfig = (updates: Partial<VideoConfig>) => {
68
+ setConfig(prev => ({ ...prev, video: { ...prev.video, ...updates } }));
69
+ };
70
+
71
+ useEffect(() => {
72
+ if (isOpen) {
73
+ setConfig(loadConfig());
74
+ setTestResult({ status: 'idle', message: '' });
75
+ setActiveTab('api');
76
+ }
77
+ }, [isOpen]);
78
+
79
+ const handleSave = () => {
80
+ saveConfig(config);
81
+ onSave(config);
82
+ onClose();
83
+ };
84
+
85
+ const handleTest = async () => {
86
+ if (!config.api.apiUrl || !config.api.apiKey) {
87
+ setTestResult({
88
+ status: 'error',
89
+ message: '请填写 API 地址和密钥',
90
+ });
91
+ return;
92
+ }
93
+
94
+ setTestResult({ status: 'testing', message: '测试中...', details: {} });
95
+
96
+ const startTime = performance.now();
97
+ const apiUrl = config.api.apiUrl.trim().replace(/\/+$/, '');
98
+
99
+ try {
100
+ const response = await fetch(apiUrl, {
101
+ method: 'POST',
102
+ headers: {
103
+ 'Content-Type': 'application/json',
104
+ 'Authorization': `Bearer ${config.api.apiKey.trim()}`,
105
+ },
106
+ body: JSON.stringify({
107
+ model: config.api.model.trim() || 'gpt-4o-mini',
108
+ messages: [{ role: 'user', content: 'test' }],
109
+ max_tokens: 5,
110
+ }),
111
+ });
112
+
113
+ const endTime = performance.now();
114
+ const duration = Math.round(endTime - startTime);
115
+
116
+ if (response.ok) {
117
+ setTestResult({
118
+ status: 'success',
119
+ message: `连接成功!(${duration}ms)`,
120
+ details: {
121
+ statusCode: response.status,
122
+ statusText: response.statusText,
123
+ duration,
124
+ },
125
+ });
126
+ } else {
127
+ const responseBody = await response.text();
128
+ const headers: Record<string, string> = {};
129
+ response.headers.forEach((value, key) => {
130
+ headers[key] = value;
131
+ });
132
+
133
+ setTestResult({
134
+ status: 'error',
135
+ message: `HTTP ${response.status}: ${response.statusText}`,
136
+ details: {
137
+ statusCode: response.status,
138
+ statusText: response.statusText,
139
+ responseBody: responseBody.slice(0, 2000),
140
+ headers,
141
+ duration,
142
+ },
143
+ });
144
+ }
145
+ } catch (error) {
146
+ const endTime = performance.now();
147
+ const duration = Math.round(endTime - startTime);
148
+
149
+ setTestResult({
150
+ status: 'error',
151
+ message: error instanceof Error ? error.message : '连接失败',
152
+ details: {
153
+ error: error instanceof Error ? `${error.name}: ${error.message}` : String(error),
154
+ duration,
155
+ },
156
+ });
157
+ }
158
+ };
159
+
160
+ if (!isOpen) return null;
161
+
162
+ return (
163
+ <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
164
+ <div className="absolute inset-0 bg-black/50" onClick={onClose} />
165
+ <div className="relative bg-bg-secondary rounded-2xl p-8 max-w-md w-full shadow-xl">
166
+ {/* 标题 */}
167
+ <div className="flex items-center justify-between mb-6">
168
+ <h2 className="text-xl font-medium text-text-primary">设置</h2>
169
+ <button
170
+ onClick={onClose}
171
+ className="p-2 text-text-secondary/60 hover:text-text-secondary transition-colors"
172
+ >
173
+ <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
174
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
175
+ </svg>
176
+ </button>
177
+ </div>
178
+
179
+ {/* Tab 切换 */}
180
+ <div className="flex gap-2 mb-6 p-1 bg-bg-secondary/50 rounded-xl">
181
+ <button
182
+ onClick={() => setActiveTab('api')}
183
+ className={`flex-1 px-4 py-2.5 text-sm font-medium rounded-lg transition-all ${
184
+ activeTab === 'api'
185
+ ? 'text-text-primary bg-bg-secondary shadow-sm'
186
+ : 'text-text-secondary hover:text-text-primary hover:bg-bg-secondary/30'
187
+ }`}
188
+ >
189
+ API 设置
190
+ </button>
191
+ <button
192
+ onClick={() => setActiveTab('video')}
193
+ className={`flex-1 px-4 py-2.5 text-sm font-medium rounded-lg transition-all ${
194
+ activeTab === 'video'
195
+ ? 'text-text-primary bg-bg-secondary shadow-sm'
196
+ : 'text-text-secondary hover:text-text-primary hover:bg-bg-secondary/30'
197
+ }`}
198
+ >
199
+ 视频配置
200
+ </button>
201
+ </div>
202
+
203
+ {/* 表单 */}
204
+ <div className="space-y-5">
205
+ {/* API 设置 */}
206
+ {activeTab === 'api' && (
207
+ <>
208
+ <div className="relative">
209
+ <label
210
+ htmlFor="manimcatApiKey"
211
+ className="absolute left-4 -top-2.5 px-2 bg-bg-secondary text-xs font-medium text-text-secondary transition-all"
212
+ >
213
+ ManimCat API 密钥
214
+ </label>
215
+ <input
216
+ id="manimcatApiKey"
217
+ type="password"
218
+ value={config.api.manimcatApiKey}
219
+ onChange={(e) => updateApiConfig({ manimcatApiKey: e.target.value })}
220
+ placeholder="留空则跳过认证"
221
+ 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"
222
+ />
223
+ </div>
224
+
225
+ <div className="relative">
226
+ <label
227
+ htmlFor="apiUrl"
228
+ className="absolute left-4 -top-2.5 px-2 bg-bg-secondary text-xs font-medium text-text-secondary transition-all"
229
+ >
230
+ API 地址
231
+ </label>
232
+ <input
233
+ id="apiUrl"
234
+ type="text"
235
+ value={config.api.apiUrl}
236
+ onChange={(e) => updateApiConfig({ apiUrl: e.target.value })}
237
+ placeholder="https://api.xiaomimimo.com/v1"
238
+ 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"
239
+ />
240
+ </div>
241
+
242
+ <div className="relative">
243
+ <label
244
+ htmlFor="apiKey"
245
+ className="absolute left-4 -top-2.5 px-2 bg-bg-secondary text-xs font-medium text-text-secondary transition-all"
246
+ >
247
+ API 密钥
248
+ </label>
249
+ <input
250
+ id="apiKey"
251
+ type="password"
252
+ value={config.api.apiKey}
253
+ onChange={(e) => updateApiConfig({ apiKey: e.target.value })}
254
+ placeholder="sk-..."
255
+ 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"
256
+ />
257
+ </div>
258
+
259
+ <div className="relative">
260
+ <label
261
+ htmlFor="model"
262
+ className="absolute left-4 -top-2.5 px-2 bg-bg-secondary text-xs font-medium text-text-secondary transition-all"
263
+ >
264
+ 模型名称
265
+ </label>
266
+ <input
267
+ id="model"
268
+ type="text"
269
+ value={config.api.model}
270
+ onChange={(e) => updateApiConfig({ model: e.target.value })}
271
+ placeholder="mimo-v2-flash"
272
+ 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"
273
+ />
274
+ </div>
275
+
276
+ {testResult.status !== 'idle' && (
277
+ <div className={`rounded-2xl text-sm ${
278
+ testResult.status === 'success'
279
+ ? 'bg-green-50 dark:bg-green-900/20'
280
+ : testResult.status === 'testing'
281
+ ? 'bg-blue-50 dark:bg-blue-900/20'
282
+ : 'bg-red-50 dark:bg-red-900/20'
283
+ }`}>
284
+ <div className={`p-4 ${
285
+ testResult.status === 'success'
286
+ ? 'text-green-600 dark:text-green-400'
287
+ : testResult.status === 'testing'
288
+ ? 'text-blue-600 dark:text-blue-400'
289
+ : 'text-red-600 dark:text-red-400'
290
+ }`}>
291
+ {testResult.status === 'testing' && (
292
+ <div className="flex items-center gap-2">
293
+ <svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
294
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
295
+ <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" />
296
+ </svg>
297
+ <span>{testResult.message}</span>
298
+ </div>
299
+ )}
300
+ {testResult.status !== 'testing' && (
301
+ <span>{testResult.message}</span>
302
+ )}
303
+ </div>
304
+ </div>
305
+ )}
306
+ </>
307
+ )}
308
+
309
+ {/* 视频配置 */}
310
+ {activeTab === 'video' && (
311
+ <>
312
+ <CustomSelect
313
+ options={[
314
+ { value: 'low' as Quality, label: '低 (480p)' },
315
+ { value: 'medium' as Quality, label: '中 (720p)' },
316
+ { value: 'high' as Quality, label: '高 (1080p)' }
317
+ ]}
318
+ value={config.video.quality}
319
+ onChange={(value) => updateVideoConfig({ quality: value })}
320
+ label="默认值"
321
+ />
322
+
323
+ <CustomSelect
324
+ options={[
325
+ { value: 15, label: '15 fps' },
326
+ { value: 30, label: '30 fps' },
327
+ { value: 60, label: '60 fps' }
328
+ ]}
329
+ value={config.video.frameRate}
330
+ onChange={(value) => updateVideoConfig({ frameRate: value })}
331
+ label="帧率"
332
+ />
333
+
334
+ <CustomSelect
335
+ options={[
336
+ { value: 60, label: '1 分钟' },
337
+ { value: 120, label: '2 分钟' },
338
+ { value: 180, label: '3 分钟' },
339
+ { value: 300, label: '5 分钟' },
340
+ { value: 600, label: '10 分钟' }
341
+ ]}
342
+ value={config.video.timeout || 120}
343
+ onChange={(value) => updateVideoConfig({ timeout: value })}
344
+ label="生成超时"
345
+ />
346
+ </>
347
+ )}
348
+ </div>
349
+
350
+ {/* 按钮 */}
351
+ <div className="mt-8 flex gap-3">
352
+ <button
353
+ onClick={handleTest}
354
+ disabled={testResult.status === 'testing'}
355
+ className="flex-1 px-6 py-3.5 text-sm font-medium text-accent hover:text-accent-hover bg-bg-secondary/50 hover:bg-bg-secondary/70 rounded-2xl transition-all disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-accent/20"
356
+ >
357
+ 测试连接
358
+ </button>
359
+ <button
360
+ onClick={handleSave}
361
+ className="flex-1 px-6 py-3.5 text-sm font-medium text-white bg-accent hover:bg-accent-hover rounded-2xl transition-all focus:outline-none focus:ring-2 focus:ring-accent/20 shadow-lg shadow-accent/25"
362
+ >
363
+ 保存
364
+ </button>
365
+ </div>
366
+ </div>
367
+ </div>
368
+ );
369
+ }
frontend/src/components/ThemeToggle.tsx ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 主题切换组件
2
+
3
+ import { useEffect, useState } from 'react';
4
+
5
+ export function ThemeToggle() {
6
+ const [isDark, setIsDark] = useState(false);
7
+ const [mounted, setMounted] = useState(false);
8
+
9
+ // 初始化主题状态
10
+ useEffect(() => {
11
+ setMounted(true);
12
+ const savedTheme = localStorage.getItem('theme');
13
+ const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
14
+ const isDarkMode = savedTheme === 'dark' || (!savedTheme && prefersDark);
15
+ setIsDark(isDarkMode);
16
+ document.documentElement.classList.toggle('dark', isDarkMode);
17
+ }, []);
18
+
19
+ // 切换主题
20
+ const toggleTheme = () => {
21
+ const newIsDark = !isDark;
22
+ setIsDark(newIsDark);
23
+ document.documentElement.classList.toggle('dark', newIsDark);
24
+ localStorage.setItem('theme', newIsDark ? 'dark' : 'light');
25
+ };
26
+
27
+ // 防止水合不匹配
28
+ if (!mounted) {
29
+ return (
30
+ <button
31
+ className="w-10 h-10 rounded-full bg-bg-secondary/50 flex items-center justify-center"
32
+ disabled
33
+ >
34
+ <div className="w-5 h-5" />
35
+ </button>
36
+ );
37
+ }
38
+
39
+ return (
40
+ <button
41
+ onClick={toggleTheme}
42
+ className="w-10 h-10 rounded-full bg-bg-secondary/50 hover:bg-bg-secondary transition-colors flex items-center justify-center"
43
+ aria-label={isDark ? '切换到亮色主题' : '切换到暗黑主题'}
44
+ title={isDark ? '切换到亮色主题' : '切换到暗黑主题'}
45
+ >
46
+ {isDark ? (
47
+ <svg className="w-5 h-5 text-text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
48
+ <path
49
+ strokeLinecap="round"
50
+ strokeLinejoin="round"
51
+ strokeWidth={2}
52
+ d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
53
+ />
54
+ </svg>
55
+ ) : (
56
+ <svg className="w-5 h-5 text-text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
57
+ <path
58
+ strokeLinecap="round"
59
+ strokeLinejoin="round"
60
+ strokeWidth={2}
61
+ d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
62
+ />
63
+ </svg>
64
+ )}
65
+ </button>
66
+ );
67
+ }
frontend/src/components/VideoPreview.tsx ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 视频预览组件
2
+
3
+ interface VideoPreviewProps {
4
+ videoUrl: string;
5
+ }
6
+
7
+ export function VideoPreview({ videoUrl }: VideoPreviewProps) {
8
+ const handleDownload = () => {
9
+ const link = document.createElement('a');
10
+ link.href = videoUrl;
11
+ link.download = `manim-animation-${Date.now()}.mp4`;
12
+ link.click();
13
+ };
14
+
15
+ return (
16
+ <div className="h-full flex flex-col bg-bg-secondary/30 rounded-2xl overflow-hidden">
17
+ {/* 顶部工具栏 */}
18
+ <div className="flex items-center justify-between px-4 py-2.5">
19
+ <h3 className="text-xs font-medium text-text-secondary/80 uppercase tracking-wide">动画预览</h3>
20
+ <button
21
+ onClick={handleDownload}
22
+ className="text-xs text-text-secondary/70 hover:text-accent transition-colors flex items-center gap-1.5"
23
+ >
24
+ <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
25
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
26
+ </svg>
27
+ 下载
28
+ </button>
29
+ </div>
30
+
31
+ {/* 视频播放器 */}
32
+ <div className="flex-1 bg-black flex items-center justify-center">
33
+ <video
34
+ src={videoUrl}
35
+ controls
36
+ className="w-full h-full object-contain"
37
+ >
38
+ 您的浏览器不支持视频播放。
39
+ </video>
40
+ </div>
41
+ </div>
42
+ );
43
+ }
frontend/src/hooks/useGeneration.ts ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 生成请求 Hook
2
+
3
+ import { useState, useCallback, useRef, useEffect } from 'react';
4
+ import { generateAnimation, getJobStatus } from '../lib/api';
5
+ import { loadCustomConfig, generateWithCustomApi } from '../lib/custom-ai';
6
+ import type { GenerateRequest, JobResult, ProcessingStage, VideoConfig } from '../types/api';
7
+
8
+ interface UseGenerationReturn {
9
+ status: 'idle' | 'processing' | 'completed' | 'error';
10
+ result: JobResult | null;
11
+ error: string | null;
12
+ jobId: string | null;
13
+ stage: ProcessingStage;
14
+ generate: (request: GenerateRequest) => Promise<void>;
15
+ reset: () => void;
16
+ cancel: () => void;
17
+ }
18
+
19
+ /** 轮询间隔 */
20
+ const POLL_INTERVAL = 1000;
21
+
22
+ /** 从 localStorage 加载超时配置 */
23
+ function getTimeoutConfig(): number {
24
+ try {
25
+ const saved = localStorage.getItem('manimcat_settings');
26
+ if (saved) {
27
+ const parsed = JSON.parse(saved);
28
+ if (parsed.video?.timeout) {
29
+ return parsed.video.timeout;
30
+ }
31
+ }
32
+ } catch {
33
+ // 忽略错误,使用默认值
34
+ }
35
+ return 120; // 默认 120 秒
36
+ }
37
+
38
+ export function useGeneration(): UseGenerationReturn {
39
+ const [status, setStatus] = useState<'idle' | 'processing' | 'completed' | 'error'>('idle');
40
+ const [result, setResult] = useState<JobResult | null>(null);
41
+ const [error, setError] = useState<string | null>(null);
42
+ const [jobId, setJobId] = useState<string | null>(null);
43
+ const [stage, setStage] = useState<ProcessingStage>('analyzing');
44
+
45
+ const pollCountRef = useRef(0);
46
+ const pollIntervalRef = useRef<number | null>(null);
47
+ const abortControllerRef = useRef<AbortController | null>(null);
48
+
49
+ // 清理轮询和请求
50
+ useEffect(() => {
51
+ return () => {
52
+ if (pollIntervalRef.current) {
53
+ clearInterval(pollIntervalRef.current);
54
+ }
55
+ abortControllerRef.current?.abort();
56
+ };
57
+ }, []);
58
+
59
+ // 更新处理阶段
60
+ const updateStage = useCallback((count: number) => {
61
+ if (count < 5) {
62
+ setStage('analyzing');
63
+ } else if (count < 15) {
64
+ setStage('generating');
65
+ } else if (count < 25) {
66
+ setStage('refining');
67
+ } else if (count < 60) {
68
+ setStage('rendering');
69
+ } else {
70
+ setStage('still-rendering');
71
+ }
72
+ }, []);
73
+
74
+ // 开始轮询
75
+ const startPolling = useCallback((id: string) => {
76
+ pollCountRef.current = 0;
77
+ setJobId(id);
78
+
79
+ // 获取用户配置的超时时间
80
+ const maxPollCount = getTimeoutConfig();
81
+
82
+ pollIntervalRef.current = window.setInterval(async () => {
83
+ pollCountRef.current++;
84
+
85
+ try {
86
+ const data = await getJobStatus(id, abortControllerRef.current?.signal);
87
+
88
+ if (data.status === 'completed') {
89
+ if (pollIntervalRef.current) {
90
+ clearInterval(pollIntervalRef.current);
91
+ }
92
+ setStatus('completed');
93
+ setResult(data);
94
+ } else if (data.status === 'failed') {
95
+ if (pollIntervalRef.current) {
96
+ clearInterval(pollIntervalRef.current);
97
+ }
98
+ setStatus('error');
99
+ setError(data.error || '生成失败');
100
+ } else {
101
+ // 使用后端返回的 stage,如果没有则使用前端估算的 fallback
102
+ if (data.stage) {
103
+ setStage(data.stage);
104
+ } else {
105
+ updateStage(pollCountRef.current);
106
+ }
107
+ }
108
+
109
+ // 超时检查(使用用户配置的超时时间)
110
+ if (pollCountRef.current >= maxPollCount) {
111
+ if (pollIntervalRef.current) {
112
+ clearInterval(pollIntervalRef.current);
113
+ }
114
+ setStatus('error');
115
+ setError(`生成超时(${maxPollCount}秒),请尝试更简单的概念或增加超时时间`);
116
+ }
117
+ } catch (err) {
118
+ if (err instanceof Error && err.name === 'AbortError') {
119
+ return;
120
+ }
121
+
122
+ // 如果是连接错误(后端断开),停止轮询
123
+ if (err instanceof Error && (err.message.includes('ECONNREFUSED') || err.message.includes('Failed to fetch'))) {
124
+ console.error('后端连接断开,停止轮询');
125
+ if (pollIntervalRef.current) {
126
+ clearInterval(pollIntervalRef.current);
127
+ }
128
+ setStatus('error');
129
+ setError('后端服务已断开,请刷新页面重试');
130
+ return;
131
+ }
132
+
133
+ console.error('轮询错误:', err);
134
+
135
+ // 如果是任务未找到 (404) 或明确的失效提示
136
+ if (err instanceof Error && (err.message.includes('未找到任务') || err.message.includes('失效'))) {
137
+ if (pollIntervalRef.current) {
138
+ clearInterval(pollIntervalRef.current);
139
+ pollIntervalRef.current = null;
140
+ }
141
+ setStatus('error');
142
+ setError('任务已失效(可能因服务重启),请重新生成');
143
+ return;
144
+ }
145
+ }
146
+ }, POLL_INTERVAL);
147
+ }, [updateStage]);
148
+
149
+ // 生成动画
150
+ const generate = useCallback(async (request: GenerateRequest) => {
151
+ setStatus('processing');
152
+ setError(null);
153
+ setResult(null);
154
+ setStage('analyzing');
155
+ pollCountRef.current = 0;
156
+ abortControllerRef.current = new AbortController();
157
+
158
+ try {
159
+ // 检查是否有自定义 AI 配置
160
+ const customConfig = loadCustomConfig();
161
+
162
+ if (customConfig) {
163
+ // 使用自定义 AI 生成代码
164
+ setStage('generating');
165
+ const code = await generateWithCustomApi(
166
+ request.concept,
167
+ customConfig,
168
+ abortControllerRef.current.signal
169
+ );
170
+
171
+ // 发送代码到后端渲染
172
+ setStage('rendering');
173
+ const response = await generateAnimation(
174
+ { ...request, code },
175
+ abortControllerRef.current.signal
176
+ );
177
+ startPolling(response.jobId);
178
+ } else {
179
+ // 使用后端 AI
180
+ const response = await generateAnimation(request, abortControllerRef.current.signal);
181
+ startPolling(response.jobId);
182
+ }
183
+ } catch (err) {
184
+ if (err instanceof Error && err.name === 'AbortError') {
185
+ return;
186
+ }
187
+ setStatus('error');
188
+ setError(err instanceof Error ? err.message : '生成请求失败');
189
+ }
190
+ }, [startPolling]);
191
+
192
+ // 重置状态
193
+ const reset = useCallback(() => {
194
+ setStatus('idle');
195
+ setError(null);
196
+ setResult(null);
197
+ setJobId(null);
198
+ setStage('analyzing');
199
+ if (pollIntervalRef.current) {
200
+ clearInterval(pollIntervalRef.current);
201
+ }
202
+ abortControllerRef.current?.abort();
203
+ }, []);
204
+
205
+ // 取消生成
206
+ const cancel = useCallback(() => {
207
+ if (pollIntervalRef.current) {
208
+ clearInterval(pollIntervalRef.current);
209
+ }
210
+ abortControllerRef.current?.abort();
211
+ setStatus('idle');
212
+ setError(null);
213
+ setJobId(null);
214
+ setStage('analyzing');
215
+ }, []);
216
+
217
+ return {
218
+ status,
219
+ result,
220
+ error,
221
+ jobId,
222
+ stage,
223
+ generate,
224
+ reset,
225
+ cancel,
226
+ };
227
+ }
frontend/src/index.css ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import url('https://cdn.jsdelivr.net/npm/lxgw-wenkai-screen-webfont@1.1.0/style.css');
2
+
3
+ @tailwind base;
4
+ @tailwind components;
5
+ @tailwind utilities;
6
+
7
+ /* 亮色主题变量 */
8
+ :root {
9
+ --bg-primary-rgb: 245, 245, 245;
10
+ --bg-secondary-rgb: 238, 238, 238;
11
+ --bg-tertiary-rgb: 224, 224, 224;
12
+ --text-primary-rgb: 33, 33, 33;
13
+ --text-secondary-rgb: 117, 117, 117;
14
+ --text-tertiary-rgb: 158, 158, 158;
15
+ --accent-rgb: 66, 66, 66;
16
+ --accent-hover-rgb: 48, 48, 48;
17
+ --border-rgb: 0, 0, 0;
18
+ }
19
+
20
+ /* 暗黑主题变量 */
21
+ .dark {
22
+ --bg-primary-rgb: 18, 18, 18;
23
+ --bg-secondary-rgb: 28, 28, 28;
24
+ --bg-tertiary-rgb: 38, 38, 38;
25
+ --text-primary-rgb: 245, 245, 245;
26
+ --text-secondary-rgb: 189, 189, 189;
27
+ --text-tertiary-rgb: 117, 117, 117;
28
+ --accent-rgb: 138, 138, 138;
29
+ --accent-hover-rgb: 158, 158, 158;
30
+ --border-rgb: 255, 255, 255;
31
+ }
32
+
33
+ body {
34
+ font-family: "LXGW WenKai Screen", "LXGW WenKai TC", "LXGW WenKai Lite", "LXGW WenKai", sans-serif;
35
+ -webkit-font-smoothing: antialiased;
36
+ -moz-osx-font-smoothing: grayscale;
37
+ @apply bg-bg-primary text-text-primary transition-colors duration-300;
38
+ }
39
+
40
+ /* 自定义下拉菜单样式 */
41
+ select option {
42
+ padding: 10px 16px;
43
+ background-color: #ffffff;
44
+ color: #212121;
45
+ }
46
+
47
+ select option:hover,
48
+ select option:focus {
49
+ background-color: #f5f5f5;
50
+ }
51
+
52
+ select option:checked {
53
+ background-color: #424242;
54
+ color: #ffffff;
55
+ font-weight: 500;
56
+ }
57
+
58
+ /* 极简滚动条 - MD3 风格 */
59
+ ::-webkit-scrollbar {
60
+ width: 6px;
61
+ height: 6px;
62
+ }
63
+
64
+ ::-webkit-scrollbar-track {
65
+ background: transparent;
66
+ }
67
+
68
+ ::-webkit-scrollbar-thumb {
69
+ background: rgba(0, 0, 0, 0.1);
70
+ border-radius: 9999px;
71
+ }
72
+
73
+ .dark ::-webkit-scrollbar-thumb {
74
+ background: rgba(255, 255, 255, 0.15);
75
+ }
76
+
77
+ ::-webkit-scrollbar-thumb:hover {
78
+ background: rgba(0, 0, 0, 0.2);
79
+ }
80
+
81
+ .dark ::-webkit-scrollbar-thumb:hover {
82
+ background: rgba(255, 255, 255, 0.25);
83
+ }
84
+
85
+ /* 代码高亮主题适配暗黑模式 */
86
+ .dark .react-syntax-highlighter {
87
+ background: transparent !important;
88
+ }
89
+
90
+ /* 动画淡入效果 */
91
+ @keyframes fade-in {
92
+ from { opacity: 0; transform: translateY(8px); }
93
+ to { opacity: 1; transform: translateY(0); }
94
+ }
95
+
96
+ .animate-fade-in {
97
+ animation: fade-in 0.3s ease-out;
98
+ }
frontend/src/lib/api.ts ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // API 请求函数
2
+
3
+ import type { GenerateRequest, GenerateResponse, JobResult, ApiError, VideoConfig } from '../types/api';
4
+
5
+ const API_BASE = '/api';
6
+
7
+ /** 从 localStorage 加载视频配置 */
8
+ function loadVideoConfig(): VideoConfig {
9
+ try {
10
+ const saved = localStorage.getItem('manimcat_settings');
11
+ if (saved) {
12
+ const parsed = JSON.parse(saved);
13
+ if (parsed.video) {
14
+ return parsed.video;
15
+ }
16
+ }
17
+ } catch {
18
+ // 忽略错误,使用默认值
19
+ }
20
+ return { quality: 'medium', frameRate: 30, timeout: 120 };
21
+ }
22
+
23
+ /**
24
+ * 获取 API 请求头(包含认证信息)
25
+ */
26
+ function getAuthHeaders(): HeadersInit {
27
+ const headers: HeadersInit = {
28
+ 'Content-Type': 'application/json',
29
+ };
30
+
31
+ // 从 localStorage 获取用户配置的 API Key
32
+ const apiKey = localStorage.getItem('manimcat_api_key');
33
+ if (apiKey) {
34
+ headers['Authorization'] = `Bearer ${apiKey}`;
35
+ }
36
+
37
+ return headers;
38
+ }
39
+
40
+ /**
41
+ * 提交动画生成请求
42
+ */
43
+ export async function generateAnimation(request: GenerateRequest, signal?: AbortSignal): Promise<GenerateResponse> {
44
+ // 如果请求中没有 videoConfig,则从设置中加载默认值
45
+ const videoConfig = request.videoConfig || loadVideoConfig();
46
+
47
+ const payload = { ...request, videoConfig };
48
+ const response = await fetch(`${API_BASE}/generate`, {
49
+ method: 'POST',
50
+ headers: getAuthHeaders(),
51
+ body: JSON.stringify(payload),
52
+ signal,
53
+ });
54
+
55
+ if (!response.ok) {
56
+ const error: ApiError = await response.json();
57
+ throw new Error(error.error || '生成请求失败');
58
+ }
59
+
60
+ return response.json();
61
+ }
62
+
63
+ /**
64
+ * 查询任务状态
65
+ */
66
+ export async function getJobStatus(jobId: string, signal?: AbortSignal): Promise<JobResult> {
67
+ const response = await fetch(`${API_BASE}/jobs/${jobId}`, {
68
+ headers: getAuthHeaders(),
69
+ signal,
70
+ });
71
+
72
+ if (!response.ok) {
73
+ const error: ApiError = await response.json();
74
+ throw new Error(error.error || '查询任务状态失败');
75
+ }
76
+
77
+ return response.json();
78
+ }
frontend/src/lib/custom-ai.ts ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 自定义 AI API 调用服务
2
+ // 当用户配置了自定义 API 时,直接从浏览器调用
3
+
4
+ export interface CustomApiConfig {
5
+ apiUrl: string;
6
+ apiKey: string;
7
+ model: string;
8
+ }
9
+
10
+ /** 从 localStorage 加载配置(使用统一的 manimcat_settings) */
11
+ export function loadCustomConfig(): CustomApiConfig | null {
12
+ const saved = localStorage.getItem('manimcat_settings');
13
+ if (saved) {
14
+ try {
15
+ const parsed = JSON.parse(saved);
16
+ // 从统一的 settings 中读取 API 配置
17
+ if (parsed.api && parsed.api.apiUrl && parsed.api.apiKey) {
18
+ return {
19
+ apiUrl: parsed.api.apiUrl,
20
+ apiKey: parsed.api.apiKey,
21
+ model: parsed.api.model || ''
22
+ };
23
+ }
24
+ } catch {
25
+ // ignore
26
+ }
27
+ }
28
+ return null;
29
+ }
30
+
31
+ /**
32
+ * 使用自定义 API 生成 Manim 代码
33
+ * 返回提取出的代码
34
+ */
35
+ export async function generateWithCustomApi(
36
+ concept: string,
37
+ config: CustomApiConfig,
38
+ signal?: AbortSignal
39
+ ): Promise<string> {
40
+ const apiUrl = config.apiUrl.trim().replace(/\/+$/, '');
41
+ const model = config.model.trim() || 'gpt-4o-mini';
42
+
43
+ // 简化的 prompt
44
+ const userPrompt = `生成一个 Manim 动画代码,用于演示:${concept}
45
+
46
+ 要求:
47
+ 1. 核心类名固定为 MainScene
48
+ 2. 纯代码输出,不要 Markdown 标记
49
+ 3. 使用中文注释
50
+ 4. 背景使用深色调
51
+ 5. 确保代码可运行
52
+
53
+ 请直接输出 Python 代码,不要任何解释。`;
54
+
55
+ const response = await fetch(`${apiUrl}/chat/completions`, {
56
+ method: 'POST',
57
+ headers: {
58
+ 'Content-Type': 'application/json',
59
+ 'Authorization': `Bearer ${config.apiKey.trim()}`,
60
+ },
61
+ body: JSON.stringify({
62
+ model,
63
+ messages: [
64
+ {
65
+ role: 'user',
66
+ content: userPrompt
67
+ }
68
+ ],
69
+ max_tokens: 2000,
70
+ temperature: 0.7,
71
+ }),
72
+ signal,
73
+ });
74
+
75
+ if (!response.ok) {
76
+ const errorText = await response.text();
77
+ throw new Error(`API 请求失败 (${response.status}): ${errorText || response.statusText}`);
78
+ }
79
+
80
+ const data = await response.json();
81
+ const content = data.choices?.[0]?.message?.content;
82
+
83
+ if (!content) {
84
+ throw new Error('API 返回空响应');
85
+ }
86
+
87
+ // 提取代码
88
+ return extractCodeFromResponse(content);
89
+ }
90
+
91
+ /**
92
+ * 从 AI 响应中提取代码
93
+ */
94
+ function extractCodeFromResponse(text: string): string {
95
+ if (!text) return '';
96
+
97
+ // 尝试匹配带语言标识的代码块
98
+ const match = text.match(/```(?:python)?\n([\s\S]*?)```/i);
99
+ if (match) {
100
+ return match[1].trim();
101
+ }
102
+
103
+ return text.trim();
104
+ }
frontend/src/main.tsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import { StrictMode } from 'react'
2
+ import { createRoot } from 'react-dom/client'
3
+ import './index.css'
4
+ import App from './App.tsx'
5
+
6
+ createRoot(document.getElementById('root')!).render(
7
+ <StrictMode>
8
+ <App />
9
+ </StrictMode>,
10
+ )
frontend/src/types/api.ts ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ /** 视频配置 */
15
+ export interface VideoConfig {
16
+ /** 默认质量 */
17
+ quality: Quality;
18
+ /** 帧率 */
19
+ frameRate: number;
20
+ /** 超时时间(秒),默认 120 秒 */
21
+ timeout?: number;
22
+ }
23
+
24
+ /** 设置配置 */
25
+ export interface SettingsConfig {
26
+ api: ApiConfig;
27
+ video: VideoConfig;
28
+ }
29
+
30
+ /** 任务状态 */
31
+ export type JobStatus = 'processing' | 'completed' | 'failed';
32
+
33
+ /** 处理阶段 */
34
+ export type ProcessingStage = 'analyzing' | 'generating' | 'refining' | 'rendering' | 'still-rendering';
35
+
36
+ /** 生成请求 */
37
+ export interface GenerateRequest {
38
+ concept: string;
39
+ quality?: Quality;
40
+ forceRefresh?: boolean;
41
+ /** 预生成的代码(使用自定义 AI 时) */
42
+ code?: string;
43
+ /** 视频配置 */
44
+ videoConfig?: VideoConfig;
45
+ }
46
+
47
+ /** 生成响应 */
48
+ export interface GenerateResponse {
49
+ success: boolean;
50
+ jobId: string;
51
+ message: string;
52
+ status: JobStatus;
53
+ }
54
+
55
+ /** 任务结果 */
56
+ export interface JobResult {
57
+ jobId: string;
58
+ status: JobStatus;
59
+ stage?: ProcessingStage;
60
+ message?: string;
61
+ success?: boolean;
62
+ video_url?: string;
63
+ code?: string;
64
+ used_ai?: boolean;
65
+ render_quality?: string;
66
+ generation_type?: string;
67
+ render_peak_memory_mb?: number;
68
+ error?: string;
69
+ details?: string;
70
+ }
71
+
72
+ /** API 错误 */
73
+ export interface ApiError {
74
+ error: string;
75
+ }
frontend/tailwind.config.js ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** @type {import('tailwindcss').Config} */
2
+ export default {
3
+ darkMode: 'class',
4
+ content: [
5
+ "./index.html",
6
+ "./src/**/*.{js,ts,jsx,tsx}",
7
+ ],
8
+ theme: {
9
+ extend: {
10
+ colors: {
11
+ // 亮色主题(默认)
12
+ bg: {
13
+ primary: 'rgba(var(--bg-primary-rgb), 1)',
14
+ secondary: 'rgba(var(--bg-secondary-rgb), 1)',
15
+ tertiary: 'rgba(var(--bg-tertiary-rgb), 1)',
16
+ },
17
+ text: {
18
+ primary: 'rgba(var(--text-primary-rgb), 1)',
19
+ secondary: 'rgba(var(--text-secondary-rgb), 1)',
20
+ tertiary: 'rgba(var(--text-tertiary-rgb), 1)',
21
+ },
22
+ accent: {
23
+ DEFAULT: 'rgba(var(--accent-rgb), 1)',
24
+ hover: 'rgba(var(--accent-hover-rgb), 1)',
25
+ },
26
+ border: 'rgba(var(--border-rgb), 1)',
27
+ },
28
+ fontFamily: {
29
+ sans: ['Inter', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'],
30
+ mono: ['"SF Mono"', 'Monaco', 'Cascadia Code', 'Roboto Mono', 'monospace'],
31
+ },
32
+ borderRadius: {
33
+ 'md3-sm': '8px',
34
+ 'md3': '12px',
35
+ 'md3-lg': '16px',
36
+ 'md3-xl': '20px',
37
+ 'md3-2xl': '24px',
38
+ },
39
+ boxShadow: {
40
+ 'md3': '0 1px 3px rgba(0, 0, 0, 0.06)',
41
+ 'md3-lg': '0 4px 12px rgba(0, 0, 0, 0.08)',
42
+ 'md3-xl': '0 8px 24px rgba(0, 0, 0, 0.12)',
43
+ },
44
+ keyframes: {
45
+ shimmer: {
46
+ '100%': {
47
+ transform: 'translateX(100%)',
48
+ },
49
+ },
50
+ },
51
+ animation: {
52
+ shimmer: 'shimmer 1.5s infinite',
53
+ },
54
+ },
55
+ },
56
+ plugins: [],
57
+ }
frontend/tsconfig.app.json ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4
+ "target": "ES2022",
5
+ "useDefineForClassFields": true,
6
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
7
+ "module": "ESNext",
8
+ "types": ["vite/client"],
9
+ "skipLibCheck": true,
10
+
11
+ /* Bundler mode */
12
+ "moduleResolution": "bundler",
13
+ "allowImportingTsExtensions": true,
14
+ "verbatimModuleSyntax": true,
15
+ "moduleDetection": "force",
16
+ "noEmit": true,
17
+ "jsx": "react-jsx",
18
+
19
+ /* Linting */
20
+ "strict": true,
21
+ "noUnusedLocals": true,
22
+ "noUnusedParameters": true,
23
+ "erasableSyntaxOnly": true,
24
+ "noFallthroughCasesInSwitch": true,
25
+ "noUncheckedSideEffectImports": true
26
+ },
27
+ "include": ["src"]
28
+ }
frontend/tsconfig.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "files": [],
3
+ "references": [
4
+ { "path": "./tsconfig.app.json" },
5
+ { "path": "./tsconfig.node.json" }
6
+ ]
7
+ }
frontend/tsconfig.node.json ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4
+ "target": "ES2023",
5
+ "lib": ["ES2023"],
6
+ "module": "ESNext",
7
+ "types": ["node"],
8
+ "skipLibCheck": true,
9
+
10
+ /* Bundler mode */
11
+ "moduleResolution": "bundler",
12
+ "allowImportingTsExtensions": true,
13
+ "verbatimModuleSyntax": true,
14
+ "moduleDetection": "force",
15
+ "noEmit": true,
16
+
17
+ /* Linting */
18
+ "strict": true,
19
+ "noUnusedLocals": true,
20
+ "noUnusedParameters": true,
21
+ "erasableSyntaxOnly": true,
22
+ "noFallthroughCasesInSwitch": true,
23
+ "noUncheckedSideEffectImports": true
24
+ },
25
+ "include": ["vite.config.ts"]
26
+ }
frontend/vite.config.ts ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+ import path from 'path'
4
+
5
+ export default defineConfig({
6
+ base: '/',
7
+ plugins: [react()],
8
+ build: {
9
+ outDir: '../public',
10
+ emptyOutDir: true,
11
+ rollupOptions: {
12
+ output: {
13
+ assetFileNames: 'assets/[name]-[hash][extname]',
14
+ chunkFileNames: 'assets/[name]-[hash].js',
15
+ entryFileNames: 'assets/[name]-[hash].js',
16
+ },
17
+ },
18
+ },
19
+ resolve: {
20
+ alias: {
21
+ '@': path.resolve(__dirname, 'src'),
22
+ },
23
+ },
24
+ server: {
25
+ port: 5173,
26
+ proxy: {
27
+ '/api': {
28
+ target: 'http://localhost:3000',
29
+ changeOrigin: true,
30
+ },
31
+ '/videos': {
32
+ target: 'http://localhost:3000',
33
+ changeOrigin: true,
34
+ },
35
+ },
36
+ },
37
+ })
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "manim-cat",
3
+ "version": "2.0.0",
4
+ "description": "ManimCat - AI-powered mathematical animation generator",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "concurrently \"npm:dev:backend\" \"npm:dev:frontend\"",
8
+ "dev:backend": "tsx watch src/server.ts",
9
+ "dev:frontend": "cd frontend && vite",
10
+ "build": "npm run build:frontend",
11
+ "build:backend": "tsc",
12
+ "build:frontend": "cd frontend && npm run build",
13
+ "start": "tsx src/server.ts",
14
+ "test": "echo \"Tests coming soon\" && exit 0"
15
+ },
16
+ "dependencies": {
17
+ "@types/react-syntax-highlighter": "^15.5.13",
18
+ "bull": "^4.16.5",
19
+ "cors": "^2.8.5",
20
+ "dotenv": "^17.2.3",
21
+ "express": "^4.18.0",
22
+ "ioredis": "^5.9.2",
23
+ "openai": "^4.50.0",
24
+ "react": "^19.2.0",
25
+ "react-dom": "^19.2.0",
26
+ "react-syntax-highlighter": "^16.1.0",
27
+ "uuid": "^10.0.0",
28
+ "zod": "^3.23.0"
29
+ },
30
+ "devDependencies": {
31
+ "@eslint/js": "^9.39.1",
32
+ "@types/cors": "^2.8.19",
33
+ "@types/express": "^4.17.0",
34
+ "@types/node": "^20.0.0",
35
+ "@types/react": "^19.2.5",
36
+ "@types/react-dom": "^19.2.3",
37
+ "@types/uuid": "^10.0.0",
38
+ "@vitejs/plugin-react": "^5.1.1",
39
+ "autoprefixer": "^10.4.23",
40
+ "concurrently": "^9.0.0",
41
+ "eslint": "^9.39.1",
42
+ "eslint-plugin-react-hooks": "^7.0.1",
43
+ "eslint-plugin-react-refresh": "^0.4.24",
44
+ "globals": "^16.5.0",
45
+ "postcss": "^8.5.6",
46
+ "tailwindcss": "^3.4.19",
47
+ "tsx": "^4.21.0",
48
+ "typescript": "~5.9.3",
49
+ "typescript-eslint": "^8.46.4",
50
+ "vite": "^7.2.4"
51
+ },
52
+ "engines": {
53
+ "node": ">=18.0.0"
54
+ }
55
+ }
public/assets/index-0yzmNnTY.js ADDED
The diff for this file is too large to render. See raw diff
 
public/assets/index-DkT5mxpT.css ADDED
@@ -0,0 +1 @@
 
 
1
+ @import"https://cdn.jsdelivr.net/npm/lxgw-wenkai-webfont@1.1.0/style.css";@import"https://cdn.jsdelivr.net/npm/lxgw-wenkai-lite-webfont@1.1.0/style.css";@import"https://cdn.jsdelivr.net/npm/lxgw-wenkai-tc-webfont@1.0.0/style.css";@import"https://cdn.jsdelivr.net/npm/lxgw-wenkai-screen-webfont@1.1.0/style.css";*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:Inter,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:SF Mono,Monaco,Cascadia Code,Roboto Mono,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.-top-2{top:-.5rem}.-top-2\.5{top:-.625rem}.left-0{left:0}.left-1{left:.25rem}.left-3{left:.75rem}.left-4{left:1rem}.right-0{right:0}.right-3{right:.75rem}.right-4{right:1rem}.top-1{top:.25rem}.top-1\/2{top:50%}.top-4{top:1rem}.top-full{top:100%}.z-10{z-index:10}.z-50{z-index:50}.mx-auto{margin-left:auto;margin-right:auto}.mb-1{margin-bottom:.25rem}.mb-12{margin-bottom:3rem}.mb-3{margin-bottom:.75rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.ml-1{margin-left:.25rem}.mt-0\.5{margin-top:.125rem}.mt-2{margin-top:.5rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-8{margin-top:2rem}.flex{display:flex}.grid{display:grid}.hidden{display:none}.h-10{height:2.5rem}.h-16{height:4rem}.h-3\.5{height:.875rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-7{height:1.75rem}.h-8{height:2rem}.h-\[360px\]{height:360px}.h-full{height:100%}.min-h-\[80px\]{min-height:80px}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-12{width:3rem}.w-16{width:4rem}.w-3\.5{width:.875rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-8{width:2rem}.w-full{width:100%}.max-w-2xl{max-width:42rem}.max-w-4xl{max-width:56rem}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.flex-1{flex:1 1 0%}.-translate-x-full{--tw-translate-x: -100%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-1\/2{--tw-translate-y: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-x-0{--tw-translate-x: 0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-x-5{--tw-translate-x: 1.25rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.rotate-180{--tw-rotate: 180deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes spin{to{transform:rotate(360deg)}}.animate-spin{animation:spin 1s linear infinite}.cursor-pointer{cursor:pointer}.resize-none{resize:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-col{flex-direction:column}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem * var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-xl{border-radius:.75rem}.bg-accent{background-color:rgba(var(--accent-rgb),1)}.bg-bg-primary{background-color:rgba(var(--bg-primary-rgb),1)}.bg-bg-secondary{background-color:rgba(var(--bg-secondary-rgb),1)}.bg-bg-secondary\/20{background-color:rgba(var(--bg-secondary-rgb),.2)}.bg-bg-secondary\/30{background-color:rgba(var(--bg-secondary-rgb),.3)}.bg-bg-secondary\/40{background-color:rgba(var(--bg-secondary-rgb),.4)}.bg-bg-secondary\/50{background-color:rgba(var(--bg-secondary-rgb),.5)}.bg-bg-secondary\/70{background-color:rgba(var(--bg-secondary-rgb),.7)}.bg-bg-tertiary{background-color:rgba(var(--bg-tertiary-rgb),1)}.bg-black{--tw-bg-opacity: 1;background-color:rgb(0 0 0 / var(--tw-bg-opacity, 1))}.bg-black\/50{background-color:#00000080}.bg-blue-50{--tw-bg-opacity: 1;background-color:rgb(239 246 255 / var(--tw-bg-opacity, 1))}.bg-green-50{--tw-bg-opacity: 1;background-color:rgb(240 253 244 / var(--tw-bg-opacity, 1))}.bg-red-50{--tw-bg-opacity: 1;background-color:rgb(254 242 242 / var(--tw-bg-opacity, 1))}.bg-red-50\/50{background-color:#fef2f280}.bg-red-50\/80{background-color:#fef2f2cc}.bg-white{--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.from-transparent{--tw-gradient-from: transparent var(--tw-gradient-from-position);--tw-gradient-to: rgb(0 0 0 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.via-white\/10{--tw-gradient-to: rgb(255 255 255 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), rgb(255 255 255 / .1) var(--tw-gradient-via-position), var(--tw-gradient-to)}.to-transparent{--tw-gradient-to: transparent var(--tw-gradient-to-position)}.object-contain{-o-object-fit:contain;object-fit:contain}.p-1{padding:.25rem}.p-2{padding:.5rem}.p-2\.5{padding:.625rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-12{padding-left:3rem;padding-right:3rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-16{padding-top:4rem;padding-bottom:4rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-3\.5{padding-top:.875rem;padding-bottom:.875rem}.py-4{padding-top:1rem;padding-bottom:1rem}.pr-10{padding-right:2.5rem}.pt-4{padding-top:1rem}.text-left{text-align:left}.text-center{text-align:center}.font-mono{font-family:SF Mono,Monaco,Cascadia Code,Roboto Mono,monospace}.text-5xl{font-size:3rem;line-height:1}.text-\[10px\]{font-size:10px}.text-base{font-size:1rem;line-height:1.5rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-light{font-weight:300}.font-medium{font-weight:500}.uppercase{text-transform:uppercase}.tracking-tight{letter-spacing:-.025em}.tracking-wide{letter-spacing:.025em}.text-accent{color:rgba(var(--accent-rgb),1)}.text-blue-600{--tw-text-opacity: 1;color:rgb(37 99 235 / var(--tw-text-opacity, 1))}.text-green-600{--tw-text-opacity: 1;color:rgb(22 163 74 / var(--tw-text-opacity, 1))}.text-red-500{--tw-text-opacity: 1;color:rgb(239 68 68 / var(--tw-text-opacity, 1))}.text-red-600{--tw-text-opacity: 1;color:rgb(220 38 38 / var(--tw-text-opacity, 1))}.text-text-primary{color:rgba(var(--text-primary-rgb),1)}.text-text-primary\/90{color:rgba(var(--text-primary-rgb),.9)}.text-text-secondary{color:rgba(var(--text-secondary-rgb),1)}.text-text-secondary\/50{color:rgba(var(--text-secondary-rgb),.5)}.text-text-secondary\/60{color:rgba(var(--text-secondary-rgb),.6)}.text-text-secondary\/70{color:rgba(var(--text-secondary-rgb),.7)}.text-text-secondary\/80{color:rgba(var(--text-secondary-rgb),.8)}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.placeholder-text-secondary\/40::-moz-placeholder{color:rgba(var(--text-secondary-rgb),.4)}.placeholder-text-secondary\/40::placeholder{color:rgba(var(--text-secondary-rgb),.4)}.opacity-25{opacity:.25}.opacity-75{opacity:.75}.shadow-lg{--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1);--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow: 0 4px 6px -1px rgb(0 0 0 / .1), 0 2px 4px -2px rgb(0 0 0 / .1);--tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow: 0 1px 2px 0 rgb(0 0 0 / .05);--tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow: 0 20px 25px -5px rgb(0 0 0 / .1), 0 8px 10px -6px rgb(0 0 0 / .1);--tw-shadow-colored: 0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-accent\/25{--tw-shadow-color: rgba(var(--accent-rgb), .25);--tw-shadow: var(--tw-shadow-colored)}.shadow-black\/10{--tw-shadow-color: rgb(0 0 0 / .1);--tw-shadow: var(--tw-shadow-colored)}.drop-shadow-lg{--tw-drop-shadow: drop-shadow(0 10px 8px rgb(0 0 0 / .04)) drop-shadow(0 4px 3px rgb(0 0 0 / .1));filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}.duration-500{transition-duration:.5s}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}:root{--bg-primary-rgb: 245, 245, 245;--bg-secondary-rgb: 238, 238, 238;--bg-tertiary-rgb: 224, 224, 224;--text-primary-rgb: 33, 33, 33;--text-secondary-rgb: 117, 117, 117;--text-tertiary-rgb: 158, 158, 158;--accent-rgb: 66, 66, 66;--accent-hover-rgb: 48, 48, 48;--border-rgb: 0, 0, 0}.dark{--bg-primary-rgb: 18, 18, 18;--bg-secondary-rgb: 28, 28, 28;--bg-tertiary-rgb: 38, 38, 38;--text-primary-rgb: 245, 245, 245;--text-secondary-rgb: 189, 189, 189;--text-tertiary-rgb: 117, 117, 117;--accent-rgb: 138, 138, 138;--accent-hover-rgb: 158, 158, 158;--border-rgb: 255, 255, 255}body{font-family:LXGW WenKai Screen,LXGW WenKai TC,LXGW WenKai Lite,LXGW WenKai,sans-serif;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;background-color:rgba(var(--bg-primary-rgb),1);color:rgba(var(--text-primary-rgb),1);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.3s}select option{padding:10px 16px;background-color:#fff;color:#212121}select option:hover,select option:focus{background-color:#f5f5f5}select option:checked{background-color:#424242;color:#fff;font-weight:500}::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:#0000001a;border-radius:9999px}.dark ::-webkit-scrollbar-thumb{background:#ffffff26}::-webkit-scrollbar-thumb:hover{background:#0003}.dark ::-webkit-scrollbar-thumb:hover{background:#ffffff40}.dark .react-syntax-highlighter{background:transparent!important}@keyframes fade-in{0%{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}.animate-fade-in{animation:fade-in .3s ease-out}.hover\:bg-accent-hover:hover{background-color:rgba(var(--accent-hover-rgb),1)}.hover\:bg-bg-secondary:hover{background-color:rgba(var(--bg-secondary-rgb),1)}.hover\:bg-bg-secondary\/30:hover{background-color:rgba(var(--bg-secondary-rgb),.3)}.hover\:bg-bg-secondary\/50:hover{background-color:rgba(var(--bg-secondary-rgb),.5)}.hover\:bg-bg-secondary\/60:hover{background-color:rgba(var(--bg-secondary-rgb),.6)}.hover\:bg-bg-secondary\/70:hover{background-color:rgba(var(--bg-secondary-rgb),.7)}.hover\:bg-bg-secondary\/80:hover{background-color:rgba(var(--bg-secondary-rgb),.8)}.hover\:text-accent:hover{color:rgba(var(--accent-rgb),1)}.hover\:text-accent-hover:hover{color:rgba(var(--accent-hover-rgb),1)}.hover\:text-red-500:hover{--tw-text-opacity: 1;color:rgb(239 68 68 / var(--tw-text-opacity, 1))}.hover\:text-text-primary:hover{color:rgba(var(--text-primary-rgb),1)}.hover\:text-text-secondary:hover{color:rgba(var(--text-secondary-rgb),1)}.hover\:shadow-xl:hover{--tw-shadow: 0 20px 25px -5px rgb(0 0 0 / .1), 0 8px 10px -6px rgb(0 0 0 / .1);--tw-shadow-colored: 0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.hover\:shadow-accent\/35:hover{--tw-shadow-color: rgba(var(--accent-rgb), .35);--tw-shadow: var(--tw-shadow-colored)}.focus\:bg-bg-secondary\/70:focus{background-color:rgba(var(--bg-secondary-rgb),.7)}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-2:focus{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus\:ring-accent\/20:focus{--tw-ring-color: rgba(var(--accent-rgb), .2)}.focus\:ring-red-500\/20:focus{--tw-ring-color: rgb(239 68 68 / .2)}.active\:scale-\[0\.97\]:active{--tw-scale-x: .97;--tw-scale-y: .97;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.active\:scale-\[0\.98\]:active{--tw-scale-x: .98;--tw-scale-y: .98;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-30:disabled{opacity:.3}.disabled\:opacity-50:disabled{opacity:.5}.group:hover .group-hover\:translate-x-1{--tw-translate-x: .25rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes shimmer{to{transform:translate(100%)}}.group:hover .group-hover\:animate-shimmer{animation:shimmer 1.5s infinite}.dark\:bg-blue-900\/20:is(.dark *){background-color:#1e3a8a33}.dark\:bg-green-900\/20:is(.dark *){background-color:#14532d33}.dark\:bg-red-900\/10:is(.dark *){background-color:#7f1d1d1a}.dark\:bg-red-900\/20:is(.dark *){background-color:#7f1d1d33}.dark\:text-blue-400:is(.dark *){--tw-text-opacity: 1;color:rgb(96 165 250 / var(--tw-text-opacity, 1))}.dark\:text-green-400:is(.dark *){--tw-text-opacity: 1;color:rgb(74 222 128 / var(--tw-text-opacity, 1))}.dark\:text-red-400:is(.dark *){--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity, 1))}@media(min-width:640px){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:p-6{padding:1.5rem}.sm\:py-20{padding-top:5rem;padding-bottom:5rem}.sm\:text-6xl{font-size:3.75rem;line-height:1}.sm\:text-base{font-size:1rem;line-height:1.5rem}}@media(min-width:1024px){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}