Spaces:
Sleeping
Sleeping
Commit ·
9fdb075
0
Parent(s):
Clean deploy
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +104 -0
- .env.example +76 -0
- .env.huggingface +117 -0
- .env.production +63 -0
- .gitignore +53 -0
- Dockerfile +44 -0
- Dockerfile.huggingface +43 -0
- LICENSE +21 -0
- README.md +279 -0
- docker-compose.yml +83 -0
- dump.rdb +0 -0
- frontend/.gitignore +24 -0
- frontend/eslint.config.js +23 -0
- frontend/index.html +14 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +35 -0
- frontend/postcss.config.js +6 -0
- frontend/public/logo-16.png +0 -0
- frontend/public/logo-192.png +0 -0
- frontend/public/logo-32.png +0 -0
- frontend/public/logo-48.png +0 -0
- frontend/public/logo-96.png +0 -0
- frontend/public/logo.svg +21 -0
- frontend/src/App.css +42 -0
- frontend/src/App.tsx +148 -0
- frontend/src/components/CodeView.tsx +67 -0
- frontend/src/components/CustomSelect.tsx +104 -0
- frontend/src/components/ExampleButtons.tsx +84 -0
- frontend/src/components/InputForm.tsx +254 -0
- frontend/src/components/LoadingSpinner.tsx +87 -0
- frontend/src/components/ManimCatLogo.tsx +29 -0
- frontend/src/components/ResultSection.tsx +35 -0
- frontend/src/components/SettingsModal.tsx +369 -0
- frontend/src/components/ThemeToggle.tsx +67 -0
- frontend/src/components/VideoPreview.tsx +43 -0
- frontend/src/hooks/useGeneration.ts +227 -0
- frontend/src/index.css +98 -0
- frontend/src/lib/api.ts +78 -0
- frontend/src/lib/custom-ai.ts +104 -0
- frontend/src/main.tsx +10 -0
- frontend/src/types/api.ts +75 -0
- frontend/tailwind.config.js +57 -0
- frontend/tsconfig.app.json +28 -0
- frontend/tsconfig.json +7 -0
- frontend/tsconfig.node.json +26 -0
- frontend/vite.config.ts +37 -0
- package-lock.json +0 -0
- package.json +55 -0
- public/assets/index-0yzmNnTY.js +0 -0
- 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§ion=header" />
|
| 5 |
+
|
| 6 |
+
<br>
|
| 7 |
+
|
| 8 |
+
<img src="public/logo.svg" width="200" alt="ManimCat Logo" />
|
| 9 |
+
|
| 10 |
+
<!-- 装饰:猫咪足迹 -->
|
| 11 |
+
<div style="opacity: 0.3; margin: 20px 0;">
|
| 12 |
+
<img src="https://raw.githubusercontent.com/Tarikul-Islam-Anik/Animated-Fluent-Emojis/master/Emojis/Animals/Paw%20Prints.png" width="40" alt="paws" />
|
| 13 |
+
</div>
|
| 14 |
+
|
| 15 |
+
<h1>
|
| 16 |
+
<picture>
|
| 17 |
+
<img src="https://readme-typing-svg.herokuapp.com?font=Fira+Code&size=40&duration=3000&pause=1000&color=455A64¢er=true&vCenter=true&width=435&lines=ManimCat+%F0%9F%90%BE" alt="ManimCat" />
|
| 18 |
+
</picture>
|
| 19 |
+
</h1>
|
| 20 |
+
|
| 21 |
+
<!-- 装饰:数学符号分隔 -->
|
| 22 |
+
<p align="center">
|
| 23 |
+
<span style="font-family: monospace; font-size: 24px; color: #90A4AE;">
|
| 24 |
+
∫ ∑ ∂ ∞
|
| 25 |
+
</span>
|
| 26 |
+
</p>
|
| 27 |
+
|
| 28 |
+
<p align="center">
|
| 29 |
+
<strong>🎬 AI-Powered Mathematical Animation Generator</strong>
|
| 30 |
+
</p>
|
| 31 |
+
|
| 32 |
+
<p align="center">
|
| 33 |
+
让数学动画创作变得简单优雅 · 基于 Manim 与大语言模型
|
| 34 |
+
</p>
|
| 35 |
+
|
| 36 |
+
<!-- 装饰:几何点阵分隔 -->
|
| 37 |
+
<div style="margin: 30px 0;">
|
| 38 |
+
<span style="color: #CFD8DC; font-size: 20px;">◆ ◆ ◆</span>
|
| 39 |
+
</div>
|
| 40 |
+
|
| 41 |
+
<p align="center">
|
| 42 |
+
<img src="https://img.shields.io/badge/ManimCE-0.19.2-455A64?style=for-the-badge&logo=python&logoColor=white" alt="ManimCE" />
|
| 43 |
+
<img src="https://img.shields.io/badge/React-19.2.0-455A64?style=for-the-badge&logo=react&logoColor=white" alt="React" />
|
| 44 |
+
<img src="https://img.shields.io/badge/Node.js-18+-455A64?style=for-the-badge&logo=node.js&logoColor=white" alt="Node.js" />
|
| 45 |
+
<img src="https://img.shields.io/badge/License-MIT-607D8B?style=for-the-badge" alt="License" />
|
| 46 |
+
</p>
|
| 47 |
+
|
| 48 |
+
<p align="center" style="font-size: 18px;">
|
| 49 |
+
<a href="#前言"><strong>前言</strong></a> •
|
| 50 |
+
<a href="#样例"><strong>样例</strong></a> •
|
| 51 |
+
<a href="#技术"><strong>技术</strong></a> •
|
| 52 |
+
<a href="#部署"><strong>部署</strong></a> •
|
| 53 |
+
<a href="#贡献"><strong>贡献</strong></a> •
|
| 54 |
+
<a href="#思路"><strong>思路</strong></a> •
|
| 55 |
+
<a href="#现状"><strong>现状</strong></a>
|
| 56 |
+
</p>
|
| 57 |
+
|
| 58 |
+
<br>
|
| 59 |
+
|
| 60 |
+
<!-- 底部装饰线 - 统一为深灰色调 -->
|
| 61 |
+
<img width="100%" src="https://capsule-render.vercel.app/api?type=waving&color=455A64&height=100§ion=footer" />
|
| 62 |
+
|
| 63 |
+
</div>
|
| 64 |
+
|
| 65 |
+
<br>
|
| 66 |
+
|
| 67 |
+
## 前言
|
| 68 |
+
|
| 69 |
+
很荣幸在这里介绍我的新项目ManimCat,它是~一只猫~
|
| 70 |
+
|
| 71 |
+
本项目基于[manim-video-generator](https://github.com/rohitg00/manim-video-generator)架构级重构与二次开发而来,在此感谢原作者 Rohit Ghumare。我重写了整个前后端架构,解决了原版在并发和渲染稳定性上的痛点,并加以个人审美设计与应用的理想化改进。
|
| 72 |
+
|
| 73 |
+
ManimCat 是一个基于 AI 的数学动画生成平台,致力于让数学教师使用manim代码生成视频应用到课堂与教学之中。
|
| 74 |
+
|
| 75 |
+
用户只需输入自然语言描述,系统便会通过 AI 自动生成 Manim 代码并渲染出精美的数学可视化视频,支持 LaTeX 公式、模板化生成以及代码错误自动修复,让复杂概念的动态展示变得触手可及。
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
## 样例
|
| 79 |
+
|
| 80 |
+
期待ing!
|
| 81 |
+
|
| 82 |
+
## 技术
|
| 83 |
+
|
| 84 |
+
### 技术栈
|
| 85 |
+
|
| 86 |
+
**后端**
|
| 87 |
+
- Express.js 4.18.0 + TypeScript 5.9.3
|
| 88 |
+
- Bull 4.16.5 + ioredis 5.9.2(Redis 任务队列)
|
| 89 |
+
- OpenAI SDK 4.50.0
|
| 90 |
+
- Zod 3.23.0(数据验证)
|
| 91 |
+
|
| 92 |
+
**前端**
|
| 93 |
+
- React 19.2.0 + TypeScript 5.9.3
|
| 94 |
+
- Vite 7.2.4
|
| 95 |
+
- TailwindCSS 3.4.19
|
| 96 |
+
- react-syntax-highlighter 16.1.0
|
| 97 |
+
|
| 98 |
+
**系统依赖**
|
| 99 |
+
- Python 3.11
|
| 100 |
+
- Manim Community Edition 0.19.2
|
| 101 |
+
- LaTeX(texlive)
|
| 102 |
+
- ffmpeg + Xvfb
|
| 103 |
+
|
| 104 |
+
**部署**
|
| 105 |
+
- Docker + Docker Compose
|
| 106 |
+
- Redis 7
|
| 107 |
+
|
| 108 |
+
### 技术路线
|
| 109 |
+
|
| 110 |
+
```
|
| 111 |
+
用户请求 → POST /api/generate
|
| 112 |
+
↓
|
| 113 |
+
[认证中间件]
|
| 114 |
+
↓
|
| 115 |
+
[Bull 任务队列]
|
| 116 |
+
↓
|
| 117 |
+
┌───────────────────────────────────┐
|
| 118 |
+
│ 视频生成处理器 │
|
| 119 |
+
├───────────────────────────────────┤
|
| 120 |
+
│ 1. 检查概念缓存 │
|
| 121 |
+
│ 2. 概念分析 │
|
| 122 |
+
│ - LaTeX 检测 │
|
| 123 |
+
│ - 模板匹配 │
|
| 124 |
+
│ - AI 生成(两阶段) │
|
| 125 |
+
│ ├─ 阶段1: 概念设计师 │
|
| 126 |
+
│ └─ 阶段2: 代码生成者 │
|
| 127 |
+
│ 3. 代码重试管理器 │
|
| 128 |
+
│ ├─ 首次生成代码 → 渲染 │
|
| 129 |
+
│ ├─ 失败 → 检查错误可修复性 │
|
| 130 |
+
│ ├─ 重试循环(最多4次) │
|
| 131 |
+
│ │ ├─ 发送完整对话历史 │
|
| 132 |
+
│ │ ├─ AI 修复代码 │
|
| 133 |
+
│ │ └─ 重新渲染 │
|
| 134 |
+
│ └─ 成功/失败 → 存储结果 │
|
| 135 |
+
│ 4. 存储结果到 Redis │
|
| 136 |
+
└───────────────────���───────────────┘
|
| 137 |
+
↓
|
| 138 |
+
前端轮询状态
|
| 139 |
+
↓
|
| 140 |
+
GET /api/jobs/:jobId
|
| 141 |
+
```
|
| 142 |
+
|
| 143 |
+
**重试机制说明:**
|
| 144 |
+
- 概念设计师结果会保存,不需要重复设计
|
| 145 |
+
- 每次重试都发送完整的对话历史(原始提示词 + 历史代码 + 错误信息)
|
| 146 |
+
- 最多重试 4 次,失败后任务标记为失败
|
| 147 |
+
|
| 148 |
+
### 环境变量配置
|
| 149 |
+
|
| 150 |
+
| 环境变量 | 默认值 | 说明 |
|
| 151 |
+
|---------|--------|------|
|
| 152 |
+
| `PORT` | `3000` | 后端服务端口 |
|
| 153 |
+
| `REDIS_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))}}
|