Commit ·
4ee35df
0
Parent(s):
Sync from main: 3002239 docs: refresh dual-mode studio positioning
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +105 -0
- .env.example +59 -0
- .env.production +46 -0
- .github/pull_request_template.md +9 -0
- .github/workflows/docker-publish.yml +49 -0
- .gitignore +83 -0
- CLA.md +98 -0
- CONTRIBUTING.md +21 -0
- DEPLOYMENT.md +326 -0
- DEPLOYMENT.zh-CN.md +326 -0
- Dockerfile +83 -0
- LICENSE +7 -0
- LICENSES/MIT.txt +23 -0
- LICENSES/ManimCat-NC.txt +27 -0
- LICENSE_POLICY.en.md +36 -0
- LICENSE_POLICY.md +36 -0
- README.md +341 -0
- README.zh-CN.md +332 -0
- THIRD_PARTY_NOTICES.md +18 -0
- THIRD_PARTY_NOTICES.zh-CN.md +18 -0
- docker-compose.yml +105 -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 +49 -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 +322 -0
- frontend/src/components/AiModifyModal.tsx +96 -0
- frontend/src/components/CodeView.tsx +140 -0
- frontend/src/components/CustomSelect.tsx +104 -0
- frontend/src/components/DonationModal.tsx +117 -0
- frontend/src/components/ExampleButtons.tsx +84 -0
- frontend/src/components/HistoryPanel.tsx +206 -0
- frontend/src/components/ImageInputModeModal.tsx +84 -0
- frontend/src/components/ImagePreview.tsx +123 -0
- frontend/src/components/InputForm.tsx +254 -0
- frontend/src/components/LoadingSpinner.tsx +255 -0
- frontend/src/components/ManimCatLogo.tsx +29 -0
- frontend/src/components/ProblemFramingOverlay.tsx +519 -0
- frontend/src/components/PromptInput.tsx +88 -0
- frontend/src/components/PromptSidebar.tsx +112 -0
- frontend/src/components/PromptsManager.tsx +147 -0
.dockerignore
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
!src/prompts/templates/**/*.md
|
| 73 |
+
docs/
|
| 74 |
+
|
| 75 |
+
# Docker files
|
| 76 |
+
docker-compose*.yml
|
| 77 |
+
Dockerfile*
|
| 78 |
+
.dockerignore
|
| 79 |
+
|
| 80 |
+
# CI/CD
|
| 81 |
+
.github/
|
| 82 |
+
.gitlab-ci.yml
|
| 83 |
+
.travis.yml
|
| 84 |
+
|
| 85 |
+
# Python cache (from Manim)
|
| 86 |
+
__pycache__/
|
| 87 |
+
*.py[cod]
|
| 88 |
+
*$py.class
|
| 89 |
+
.Python
|
| 90 |
+
|
| 91 |
+
# Redis dump
|
| 92 |
+
dump.rdb
|
| 93 |
+
*.rdb
|
| 94 |
+
|
| 95 |
+
# Static assets (examples)
|
| 96 |
+
static/gifs/
|
| 97 |
+
|
| 98 |
+
# Other
|
| 99 |
+
.editorconfig
|
| 100 |
+
.prettierrc
|
| 101 |
+
.eslintrc*
|
| 102 |
+
tsconfig*.json
|
| 103 |
+
vite.config.ts
|
| 104 |
+
tailwind.config.js
|
| 105 |
+
postcss.config.js
|
.env.example
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ManimCat local development environment template
|
| 2 |
+
|
| 3 |
+
# Runtime
|
| 4 |
+
NODE_ENV=development
|
| 5 |
+
PORT=3000
|
| 6 |
+
HOST=0.0.0.0
|
| 7 |
+
LOG_LEVEL=info
|
| 8 |
+
# PROD_SUMMARY_LOG_ONLY=false
|
| 9 |
+
|
| 10 |
+
# Redis
|
| 11 |
+
REDIS_HOST=localhost
|
| 12 |
+
REDIS_PORT=6379
|
| 13 |
+
REDIS_DB=0
|
| 14 |
+
# REDIS_PASSWORD=
|
| 15 |
+
|
| 16 |
+
# Recommended server-side upstream routing
|
| 17 |
+
# One Bearer key mapped to one upstream profile.
|
| 18 |
+
MANIMCAT_ROUTE_KEYS=demo-key
|
| 19 |
+
MANIMCAT_ROUTE_API_URLS=https://api.example.com/v1
|
| 20 |
+
MANIMCAT_ROUTE_API_KEYS=sk-example
|
| 21 |
+
MANIMCAT_ROUTE_MODELS=gpt-4o-mini
|
| 22 |
+
|
| 23 |
+
# Optional request-level AI tuning
|
| 24 |
+
# AI_TEMPERATURE=0.7
|
| 25 |
+
# AI_MAX_TOKENS=12000
|
| 26 |
+
# AI_THINKING_TOKENS=20000
|
| 27 |
+
# DESIGNER_TEMPERATURE=0.8
|
| 28 |
+
# DESIGNER_MAX_TOKENS=12000
|
| 29 |
+
# DESIGNER_THINKING_TOKENS=20000
|
| 30 |
+
|
| 31 |
+
# Optional server behavior
|
| 32 |
+
# REQUEST_TIMEOUT=600000
|
| 33 |
+
# JOB_TIMEOUT=600000
|
| 34 |
+
# MANIM_TIMEOUT=600000
|
| 35 |
+
# CORS_ORIGIN=*
|
| 36 |
+
|
| 37 |
+
# Optional media storage paths
|
| 38 |
+
# VIDEO_OUTPUT_DIR=public/videos
|
| 39 |
+
# TEMP_DIR=temp
|
| 40 |
+
|
| 41 |
+
# Optional cleanup and retention
|
| 42 |
+
# MEDIA_RETENTION_HOURS=72
|
| 43 |
+
# MEDIA_CLEANUP_INTERVAL_MINUTES=60
|
| 44 |
+
# JOB_RESULT_RETENTION_HOURS=24
|
| 45 |
+
# USAGE_RETENTION_DAYS=90
|
| 46 |
+
# METRICS_USAGE_RATE_LIMIT_MAX=30
|
| 47 |
+
# METRICS_USAGE_RATE_LIMIT_WINDOW_MS=60000
|
| 48 |
+
|
| 49 |
+
# Optional history persistence
|
| 50 |
+
# ENABLE_HISTORY_DB=true
|
| 51 |
+
# SUPABASE_URL=https://your-project.supabase.co
|
| 52 |
+
# SUPABASE_KEY=your-supabase-key
|
| 53 |
+
|
| 54 |
+
# Optional Studio persistence
|
| 55 |
+
# ENABLE_STUDIO_DB=true
|
| 56 |
+
|
| 57 |
+
# Optional render-failure export
|
| 58 |
+
# ENABLE_RENDER_FAILURE_LOG=true
|
| 59 |
+
# ADMIN_EXPORT_TOKEN=replace_with_long_random_token
|
.env.production
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ManimCat production environment template
|
| 2 |
+
|
| 3 |
+
# Runtime
|
| 4 |
+
NODE_ENV=production
|
| 5 |
+
PORT=3000
|
| 6 |
+
LOG_LEVEL=info
|
| 7 |
+
PROD_SUMMARY_LOG_ONLY=true
|
| 8 |
+
|
| 9 |
+
# Redis
|
| 10 |
+
REDIS_HOST=redis
|
| 11 |
+
REDIS_PORT=6379
|
| 12 |
+
REDIS_DB=0
|
| 13 |
+
# REDIS_PASSWORD=
|
| 14 |
+
|
| 15 |
+
# Recommended server-side upstream routing
|
| 16 |
+
# One Bearer key mapped to one upstream profile.
|
| 17 |
+
MANIMCAT_ROUTE_KEYS=demo-key
|
| 18 |
+
MANIMCAT_ROUTE_API_URLS=https://api.example.com/v1
|
| 19 |
+
MANIMCAT_ROUTE_API_KEYS=sk-example
|
| 20 |
+
MANIMCAT_ROUTE_MODELS=gpt-4o-mini
|
| 21 |
+
|
| 22 |
+
# Optional AI tuning
|
| 23 |
+
# AI_TEMPERATURE=0.7
|
| 24 |
+
# AI_MAX_TOKENS=12000
|
| 25 |
+
# AI_THINKING_TOKENS=20000
|
| 26 |
+
# DESIGNER_TEMPERATURE=0.8
|
| 27 |
+
# DESIGNER_MAX_TOKENS=12000
|
| 28 |
+
# DESIGNER_THINKING_TOKENS=20000
|
| 29 |
+
|
| 30 |
+
# Optional media / retention
|
| 31 |
+
# MEDIA_RETENTION_HOURS=72
|
| 32 |
+
# MEDIA_CLEANUP_INTERVAL_MINUTES=60
|
| 33 |
+
# JOB_RESULT_RETENTION_HOURS=24
|
| 34 |
+
# USAGE_RETENTION_DAYS=90
|
| 35 |
+
|
| 36 |
+
# Optional history persistence
|
| 37 |
+
# ENABLE_HISTORY_DB=true
|
| 38 |
+
# SUPABASE_URL=https://your-project.supabase.co
|
| 39 |
+
# SUPABASE_KEY=your-supabase-key
|
| 40 |
+
|
| 41 |
+
# Optional Studio persistence
|
| 42 |
+
# ENABLE_STUDIO_DB=true
|
| 43 |
+
|
| 44 |
+
# Optional render-failure export
|
| 45 |
+
# ENABLE_RENDER_FAILURE_LOG=true
|
| 46 |
+
# ADMIN_EXPORT_TOKEN=replace_with_long_random_token
|
.github/pull_request_template.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## Checklist
|
| 2 |
+
|
| 3 |
+
- [ ] I have read and agree to `CLA.md`.
|
| 4 |
+
- [ ] I confirm this PR content can be contributed under the project terms.
|
| 5 |
+
|
| 6 |
+
## Notes
|
| 7 |
+
|
| 8 |
+
By opening this PR, I understand this project uses a CLA process to keep commercial-use authorization manageable under a single maintainer workflow, while keeping the project open source and community-oriented.
|
| 9 |
+
|
.github/workflows/docker-publish.yml
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Publish Docker Image
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches:
|
| 6 |
+
- main
|
| 7 |
+
workflow_dispatch:
|
| 8 |
+
|
| 9 |
+
env:
|
| 10 |
+
IMAGE_NAME: wingflow/manimcat
|
| 11 |
+
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'
|
| 12 |
+
|
| 13 |
+
jobs:
|
| 14 |
+
docker:
|
| 15 |
+
if: github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, 'docker')
|
| 16 |
+
runs-on: ubuntu-latest
|
| 17 |
+
permissions:
|
| 18 |
+
contents: read
|
| 19 |
+
|
| 20 |
+
steps:
|
| 21 |
+
- name: Checkout
|
| 22 |
+
uses: actions/checkout@v5
|
| 23 |
+
|
| 24 |
+
- name: Set up Docker Buildx
|
| 25 |
+
uses: docker/setup-buildx-action@v3.12.0
|
| 26 |
+
|
| 27 |
+
- name: Log in to Docker Hub
|
| 28 |
+
uses: docker/login-action@v3.4.0
|
| 29 |
+
with:
|
| 30 |
+
username: ${{ secrets.DOCKER_USERNAME }}
|
| 31 |
+
password: ${{ secrets.DOCKER_PASSWORD }}
|
| 32 |
+
|
| 33 |
+
- name: Extract Docker metadata
|
| 34 |
+
id: meta
|
| 35 |
+
uses: docker/metadata-action@v5.10.0
|
| 36 |
+
with:
|
| 37 |
+
images: ${{ env.IMAGE_NAME }}
|
| 38 |
+
tags: |
|
| 39 |
+
type=raw,value=latest
|
| 40 |
+
type=sha,format=short
|
| 41 |
+
|
| 42 |
+
- name: Build and push Docker image
|
| 43 |
+
uses: docker/build-push-action@v6.18.0
|
| 44 |
+
with:
|
| 45 |
+
context: .
|
| 46 |
+
file: ./Dockerfile
|
| 47 |
+
push: true
|
| 48 |
+
tags: ${{ steps.meta.outputs.tags }}
|
| 49 |
+
labels: ${{ steps.meta.outputs.labels }}
|
.gitignore
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
dump.rdb
|
| 28 |
+
|
| 29 |
+
# OS files
|
| 30 |
+
.DS_Store
|
| 31 |
+
Thumbs.db
|
| 32 |
+
|
| 33 |
+
# IDE
|
| 34 |
+
.vscode/
|
| 35 |
+
.idea/
|
| 36 |
+
|
| 37 |
+
# Temp files
|
| 38 |
+
tmp/
|
| 39 |
+
*.tmp
|
| 40 |
+
.studio-workspace/
|
| 41 |
+
.studio/
|
| 42 |
+
.plot_env_smoke*.png
|
| 43 |
+
circle.png
|
| 44 |
+
triangle.png
|
| 45 |
+
|
| 46 |
+
# AI/Dev tools
|
| 47 |
+
.claude/
|
| 48 |
+
.ruff_cache/
|
| 49 |
+
|
| 50 |
+
# MD files (except README)
|
| 51 |
+
*.md
|
| 52 |
+
!README.md
|
| 53 |
+
!README.zh-CN.md
|
| 54 |
+
!frontend/README.md
|
| 55 |
+
!static/gifs/README.md
|
| 56 |
+
!LICENSE_POLICY.en.md
|
| 57 |
+
!LICENSE_POLICY.md
|
| 58 |
+
!THIRD_PARTY_NOTICES.md
|
| 59 |
+
!THIRD_PARTY_NOTICES.zh-CN.md
|
| 60 |
+
!DEPLOYMENT.md
|
| 61 |
+
!DEPLOYMENT.zh-CN.md
|
| 62 |
+
!CLA.md
|
| 63 |
+
!CONTRIBUTING.md
|
| 64 |
+
!.github/pull_request_template.md
|
| 65 |
+
!src/prompts/templates/**/*.md
|
| 66 |
+
!src/studio-agent/prompts/templates/**/*.md
|
| 67 |
+
|
| 68 |
+
# Flow type files
|
| 69 |
+
*.flow
|
| 70 |
+
|
| 71 |
+
# Batch scripts
|
| 72 |
+
*.bat
|
| 73 |
+
|
| 74 |
+
public/images/*
|
| 75 |
+
public/images/.gitkeep
|
| 76 |
+
public/readme-images/*.png
|
| 77 |
+
!public/readme-images/.gitkeep
|
| 78 |
+
|
| 79 |
+
# Studio / test build artifacts
|
| 80 |
+
dist-tests/
|
| 81 |
+
public/assets/
|
| 82 |
+
public/index.html
|
| 83 |
+
|
CLA.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Contributor License Agreement (CLA)
|
| 2 |
+
|
| 3 |
+
版本:v1.0
|
| 4 |
+
生效日期:2026-03-20
|
| 5 |
+
|
| 6 |
+
本《贡献者许可协议》("本协议")由贡献者与项目维护方就向 `ManimCat` 项目提交代码、文档或其他材料之事项达成。
|
| 7 |
+
|
| 8 |
+
## 1. 定义
|
| 9 |
+
|
| 10 |
+
- "项目":指 `ManimCat` 代码仓库及其衍生发布内容。
|
| 11 |
+
- "贡献":指贡献者提交、上传、推送或以其他方式提供给项目的任何作品,包括但不限于源代码、脚本、文档、设计、测试与配置。
|
| 12 |
+
- "贡献者":指签署本协议的个人,或其代表签署本协议的法人/组织。
|
| 13 |
+
- "维护方":指项目当前及未来的维护者、管理者、发布者及其授权主体。
|
| 14 |
+
|
| 15 |
+
## 2. 版权许可
|
| 16 |
+
|
| 17 |
+
贡献者同意:
|
| 18 |
+
|
| 19 |
+
1. 其保留对自身贡献的著作权;
|
| 20 |
+
2. 向维护方及项目接收者授予一项全球范围、免费、非排他、不可撤销、可再许可的许可,用于复制、修改、改编、发布、分发、公开展示、公开传播及以其他方式利用该贡献,并可将其与项目其他部分合并、再许可或商业化使用。
|
| 21 |
+
|
| 22 |
+
## 3. 专利许可
|
| 23 |
+
|
| 24 |
+
贡献者就其贡献中所包含并由其可授权的专利权,授予维护方及项目接收者一项全球范围、免费、非排他、不可撤销(但受本条终止约定约束)的专利许可,以制造、委托制造、使用、许诺销售、销售、进口及以其他方式处置该贡献及其与项目的组合。
|
| 25 |
+
|
| 26 |
+
如贡献者就项目或贡献提起专利侵权诉讼(含反诉),则其在本条项下授予的专利许可自起诉之日起自动终止。
|
| 27 |
+
|
| 28 |
+
## 4. 声明与保证
|
| 29 |
+
|
| 30 |
+
贡献者声明并保证:
|
| 31 |
+
|
| 32 |
+
1. 其有权签署本协议并作出相应授权;
|
| 33 |
+
2. 其贡献为其原创,或其已取得提交与授权所需的全部权利;
|
| 34 |
+
3. 其知悉贡献可能被公开发布并长期保留在版本历史中;
|
| 35 |
+
4. 其贡献在其知情范围内不故意包含恶意代码、后门或违法内容。
|
| 36 |
+
|
| 37 |
+
## 5. 第三方内容
|
| 38 |
+
|
| 39 |
+
若贡献包含第三方代码或材料,贡献者应确保:
|
| 40 |
+
|
| 41 |
+
1. 已遵守第三方许可条件;
|
| 42 |
+
2. 已在提交说明或文件头中明确标注来源与许可;
|
| 43 |
+
3. 该第三方许可与项目既有许可策略兼容。
|
| 44 |
+
|
| 45 |
+
## 6. 无担保与责任限制
|
| 46 |
+
|
| 47 |
+
除法律另有强制规定外,贡献按"现状"提供,不附带任何明示或默示担保。任何一方均不对间接、附带或后果性损失承担责任。
|
| 48 |
+
|
| 49 |
+
## 7. 许可协议关系
|
| 50 |
+
|
| 51 |
+
本协议仅处理贡献授权与权利声明,不替代项目对外发布所采用的开源或商业许可条款。项目接收者对项目的使用仍受相应发布许可约束。
|
| 52 |
+
|
| 53 |
+
## 8. 适用范围与持续有效
|
| 54 |
+
|
| 55 |
+
1. 本协议适用于贡献者在签署后提交的全部贡献;
|
| 56 |
+
2. 若维护方接受,亦可适用于签署前已提交且由贡献者拥有权利的历史贡献;
|
| 57 |
+
3. 本协议授予的版权许可为不可撤销(法律另有规定除外)。
|
| 58 |
+
|
| 59 |
+
## 9. 法律适用
|
| 60 |
+
|
| 61 |
+
本协议的订立、解释与争议解决,适用维护方主要运营地法律。若双方另有书面约定,以该约定为准。
|
| 62 |
+
|
| 63 |
+
## 10. 签署方式
|
| 64 |
+
|
| 65 |
+
以下任一方式视为有效签署:
|
| 66 |
+
|
| 67 |
+
1. 在 Pull Request/Commit 中声明 "I have read and agree to CLA.md";
|
| 68 |
+
2. 在项目要求的 CLA 系统中点击同意;
|
| 69 |
+
3. 提交书面或电子签署文本。
|
| 70 |
+
|
| 71 |
+
## 11. 维护方声明(解释性说明)
|
| 72 |
+
|
| 73 |
+
为避免误解,维护方公开声明如下:
|
| 74 |
+
|
| 75 |
+
1. 引入 CLA 的目的,是集中管理项目商业使用授权,避免未来商业授权流程中必须逐一联系所有贡献者;
|
| 76 |
+
2. 引入 CLA 并不意味着维护方计划成立 "ManimCat 公司" 或转型为企业管理者,维护方将继续以开发者身份维护本项目;
|
| 77 |
+
3. 维护方承诺项目将长期保持开源,反对垄断;
|
| 78 |
+
4. 因商业授权获得的资金,将优先回馈项目本身,包括但不限于基础设施、维护成本、文档与测试改进、社区活动组织,以及对作出重大贡献的开发者支持;
|
| 79 |
+
5. 对于乐于开源、愿意回馈社区的小型商业公司,授权费用将以象征性标准为主。
|
| 80 |
+
|
| 81 |
+
---
|
| 82 |
+
|
| 83 |
+
## 个人签署信息(可选模板)
|
| 84 |
+
|
| 85 |
+
- 姓名:
|
| 86 |
+
- 邮箱:
|
| 87 |
+
- GitHub/代码平台账号:
|
| 88 |
+
- 签署日期:
|
| 89 |
+
- 签名:
|
| 90 |
+
|
| 91 |
+
## 组织签署信息(可选模板)
|
| 92 |
+
|
| 93 |
+
- 组织名称:
|
| 94 |
+
- 授权代表姓名:
|
| 95 |
+
- 职务:
|
| 96 |
+
- 邮箱:
|
| 97 |
+
- 签署日期:
|
| 98 |
+
- 组织盖章/签名:
|
CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Contributing to ManimCat
|
| 2 |
+
|
| 3 |
+
Thanks for considering a contribution.
|
| 4 |
+
|
| 5 |
+
## CLA Requirement
|
| 6 |
+
|
| 7 |
+
Before a Pull Request can be merged, contributors must agree to `CLA.md`.
|
| 8 |
+
|
| 9 |
+
Accepted forms:
|
| 10 |
+
|
| 11 |
+
1. Sign through the CLA bot flow in the PR.
|
| 12 |
+
2. Include a statement in PR/commit: `I have read and agree to CLA.md`.
|
| 13 |
+
|
| 14 |
+
## Why this CLA exists
|
| 15 |
+
|
| 16 |
+
The CLA is used so the maintainer can centrally handle commercial-use authorization without needing to re-contact every contributor later.
|
| 17 |
+
|
| 18 |
+
This is not a plan to turn ManimCat into a company-run project. The project remains open source, anti-monopoly, and community-oriented.
|
| 19 |
+
|
| 20 |
+
Any commercial authorization income is intended to be reinvested into project development, major contributors, and community activities. For small companies that are open-source-friendly, authorization terms and fees are expected to be symbolic.
|
| 21 |
+
|
DEPLOYMENT.md
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ManimCat Deployment Guide
|
| 2 |
+
|
| 3 |
+
English | [简体中文](https://github.com/Wing900/ManimCat/blob/main/DEPLOYMENT.zh-CN.md)
|
| 4 |
+
|
| 5 |
+
This guide only documents deployment paths that match the current repository state.
|
| 6 |
+
|
| 7 |
+
## How the project actually runs
|
| 8 |
+
|
| 9 |
+
- Backend: Node.js + Express
|
| 10 |
+
- Frontend: built by Vite, then served by the backend
|
| 11 |
+
- Queue and job state: Redis
|
| 12 |
+
- Rendering runtime: Python + ManimCE + LaTeX + `ffmpeg`
|
| 13 |
+
- AI upstreams: preferably configured with `MANIMCAT_ROUTE_*`, or passed per request through `customApiConfig`
|
| 14 |
+
|
| 15 |
+
## Which path to choose
|
| 16 |
+
|
| 17 |
+
- Run it on your own machine with the least abstraction: local native deployment
|
| 18 |
+
- Keep the runtime closer to production: Docker Compose
|
| 19 |
+
- Deploy to Hugging Face: Docker Space
|
| 20 |
+
|
| 21 |
+
## 1. Local Native Deployment
|
| 22 |
+
|
| 23 |
+
### Prerequisites
|
| 24 |
+
|
| 25 |
+
- Node.js 18+
|
| 26 |
+
- Redis 7+ reachable at `localhost:6379` by default
|
| 27 |
+
- Python 3.11+
|
| 28 |
+
- Manim Community Edition 0.19.x
|
| 29 |
+
- `mypy`
|
| 30 |
+
- LaTeX
|
| 31 |
+
- `ffmpeg`
|
| 32 |
+
|
| 33 |
+
### 1. Clone and configure
|
| 34 |
+
|
| 35 |
+
```bash
|
| 36 |
+
git clone https://github.com/Wing900/ManimCat.git
|
| 37 |
+
cd ManimCat
|
| 38 |
+
cp .env.example .env
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
Configure at least one routed upstream:
|
| 42 |
+
|
| 43 |
+
```env
|
| 44 |
+
MANIMCAT_ROUTE_KEYS=demo-key
|
| 45 |
+
MANIMCAT_ROUTE_API_URLS=https://api.example.com/v1
|
| 46 |
+
MANIMCAT_ROUTE_API_KEYS=sk-example
|
| 47 |
+
MANIMCAT_ROUTE_MODELS=gpt-4o-mini
|
| 48 |
+
```
|
| 49 |
+
|
| 50 |
+
Common optional variables:
|
| 51 |
+
|
| 52 |
+
```env
|
| 53 |
+
PORT=3000
|
| 54 |
+
LOG_LEVEL=info
|
| 55 |
+
PROD_SUMMARY_LOG_ONLY=false
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
### 2. Install dependencies
|
| 59 |
+
|
| 60 |
+
```bash
|
| 61 |
+
npm install
|
| 62 |
+
npm --prefix frontend install
|
| 63 |
+
python -m pip install mypy
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
### 3. Start
|
| 67 |
+
|
| 68 |
+
Development:
|
| 69 |
+
|
| 70 |
+
```bash
|
| 71 |
+
npm run dev
|
| 72 |
+
```
|
| 73 |
+
|
| 74 |
+
Production-style run:
|
| 75 |
+
|
| 76 |
+
```bash
|
| 77 |
+
npm run build
|
| 78 |
+
npm start
|
| 79 |
+
```
|
| 80 |
+
|
| 81 |
+
Notes:
|
| 82 |
+
|
| 83 |
+
- `npm run build` currently builds the frontend only.
|
| 84 |
+
- `npm start` runs `tsx src/server.ts`, so the backend does not depend on precompiled JS output.
|
| 85 |
+
|
| 86 |
+
### 4. Verify
|
| 87 |
+
|
| 88 |
+
- App: `http://localhost:3000`
|
| 89 |
+
- Health: `http://localhost:3000/health`
|
| 90 |
+
|
| 91 |
+
---
|
| 92 |
+
|
| 93 |
+
## 2. Docker Compose Deployment
|
| 94 |
+
|
| 95 |
+
This is the most practical default. The repo already includes Redis, the Manim runtime, and the Node runtime in the deployment path.
|
| 96 |
+
|
| 97 |
+
If you have already published the image, you can also deploy from `wingflow/manimcat` instead of rebuilding locally each time.
|
| 98 |
+
|
| 99 |
+
### 1. Prepare environment variables
|
| 100 |
+
|
| 101 |
+
```bash
|
| 102 |
+
cp .env.production .env
|
| 103 |
+
```
|
| 104 |
+
|
| 105 |
+
Set at least one upstream profile:
|
| 106 |
+
|
| 107 |
+
```env
|
| 108 |
+
MANIMCAT_ROUTE_KEYS=demo-key
|
| 109 |
+
MANIMCAT_ROUTE_API_URLS=https://api.example.com/v1
|
| 110 |
+
MANIMCAT_ROUTE_API_KEYS=sk-example
|
| 111 |
+
MANIMCAT_ROUTE_MODELS=gpt-4o-mini
|
| 112 |
+
```
|
| 113 |
+
|
| 114 |
+
If needed, change ports:
|
| 115 |
+
|
| 116 |
+
```env
|
| 117 |
+
PORT=3000
|
| 118 |
+
REDIS_PORT=6379
|
| 119 |
+
```
|
| 120 |
+
|
| 121 |
+
### 2. Build and run
|
| 122 |
+
|
| 123 |
+
```bash
|
| 124 |
+
docker compose build
|
| 125 |
+
docker compose up -d
|
| 126 |
+
```
|
| 127 |
+
|
| 128 |
+
If you want to use the published image directly, replace the `build` section in `docker-compose.yml` with:
|
| 129 |
+
|
| 130 |
+
```yaml
|
| 131 |
+
image: wingflow/manimcat
|
| 132 |
+
```
|
| 133 |
+
|
| 134 |
+
### 3. Verify
|
| 135 |
+
|
| 136 |
+
```bash
|
| 137 |
+
docker compose ps
|
| 138 |
+
```
|
| 139 |
+
|
| 140 |
+
- App: `http://localhost:3000`
|
| 141 |
+
- Health: `http://localhost:3000/health`
|
| 142 |
+
|
| 143 |
+
Notes:
|
| 144 |
+
|
| 145 |
+
- Compose exposes port `3000` to the host.
|
| 146 |
+
- Inside Compose, Redis is reached through service name `redis`.
|
| 147 |
+
- Studio session workspaces are persisted in the `studio-workspace-data` volume at `/app/.studio-workspace`.
|
| 148 |
+
- Generated and uploaded images are persisted in the `image-storage` volume at `/app/public/images`.
|
| 149 |
+
- Generated videos are persisted in the `video-storage` volume at `/app/public/videos`.
|
| 150 |
+
- Manim media cache and intermediate artifacts are persisted in the `manim-media` volume at `/app/media`.
|
| 151 |
+
- Temporary render files are persisted in the `manim-tmp` volume at `/app/tmp`.
|
| 152 |
+
|
| 153 |
+
Inspect volumes if needed:
|
| 154 |
+
|
| 155 |
+
```bash
|
| 156 |
+
docker volume ls
|
| 157 |
+
docker volume inspect manimcat_studio-workspace-data
|
| 158 |
+
```
|
| 159 |
+
|
| 160 |
+
---
|
| 161 |
+
|
| 162 |
+
## 3. Hugging Face Spaces Deployment
|
| 163 |
+
|
| 164 |
+
### Requirements
|
| 165 |
+
|
| 166 |
+
- Use a Docker Space.
|
| 167 |
+
- The app port is `7860`.
|
| 168 |
+
- Environment variables must be defined in Space Settings, not only in a checked-in `.env` file.
|
| 169 |
+
|
| 170 |
+
### 1. Use the existing root `Dockerfile`
|
| 171 |
+
|
| 172 |
+
The repository `Dockerfile` is already the Hugging Face compatible one:
|
| 173 |
+
|
| 174 |
+
- based on `manimcommunity/manim:stable`
|
| 175 |
+
- installs Node.js, Redis, CJK fonts, and `ffmpeg`
|
| 176 |
+
- starts with `node start-with-redis-hf.cjs`
|
| 177 |
+
- defaults to `PORT=7860`
|
| 178 |
+
|
| 179 |
+
Do not follow older instructions that mention `Dockerfile.huggingface`. That file is not part of the current repo.
|
| 180 |
+
|
| 181 |
+
If you have already published a Docker image, other environments can reference `wingflow/manimcat`; however, Hugging Face Spaces still builds from the repository `Dockerfile` rather than running a Docker Hub image directly.
|
| 182 |
+
|
| 183 |
+
### 2. Configure Space Settings
|
| 184 |
+
|
| 185 |
+
Minimum variables:
|
| 186 |
+
|
| 187 |
+
```env
|
| 188 |
+
PORT=7860
|
| 189 |
+
NODE_ENV=production
|
| 190 |
+
MANIMCAT_ROUTE_KEYS=demo-key
|
| 191 |
+
MANIMCAT_ROUTE_API_URLS=https://api.example.com/v1
|
| 192 |
+
MANIMCAT_ROUTE_API_KEYS=sk-example
|
| 193 |
+
MANIMCAT_ROUTE_MODELS=gpt-4o-mini
|
| 194 |
+
```
|
| 195 |
+
|
| 196 |
+
Recommended:
|
| 197 |
+
|
| 198 |
+
```env
|
| 199 |
+
LOG_LEVEL=info
|
| 200 |
+
PROD_SUMMARY_LOG_ONLY=true
|
| 201 |
+
```
|
| 202 |
+
|
| 203 |
+
### 3. Push
|
| 204 |
+
|
| 205 |
+
```bash
|
| 206 |
+
git add .
|
| 207 |
+
git commit -m "Deploy ManimCat"
|
| 208 |
+
git push
|
| 209 |
+
```
|
| 210 |
+
|
| 211 |
+
After deployment:
|
| 212 |
+
|
| 213 |
+
- App: `https://YOUR_SPACE.hf.space/`
|
| 214 |
+
- Health: `https://YOUR_SPACE.hf.space/health`
|
| 215 |
+
|
| 216 |
+
---
|
| 217 |
+
|
| 218 |
+
## Upstream Routing
|
| 219 |
+
|
| 220 |
+
`MANIMCAT_ROUTE_*` is the recommended server-side routing mechanism. It acts as both:
|
| 221 |
+
|
| 222 |
+
- the Bearer-key whitelist
|
| 223 |
+
- the mapping from key to `apiUrl/apiKey/model`
|
| 224 |
+
|
| 225 |
+
Example:
|
| 226 |
+
|
| 227 |
+
```env
|
| 228 |
+
MANIMCAT_ROUTE_KEYS=user_a,user_b
|
| 229 |
+
MANIMCAT_ROUTE_API_URLS=https://api-a.example.com/v1,https://api-b.example.com/v1
|
| 230 |
+
MANIMCAT_ROUTE_API_KEYS=sk-a,sk-b
|
| 231 |
+
MANIMCAT_ROUTE_MODELS=gpt-4o-mini,gemini-2.5-flash
|
| 232 |
+
```
|
| 233 |
+
|
| 234 |
+
Rules:
|
| 235 |
+
|
| 236 |
+
1. All four variables support comma-separated or newline-separated values.
|
| 237 |
+
2. `MANIMCAT_ROUTE_KEYS` is the primary index.
|
| 238 |
+
3. Entries missing `apiUrl` or `apiKey` are skipped.
|
| 239 |
+
4. If a variable only provides one value, that value is reused for all entries.
|
| 240 |
+
5. If `model` is empty, the key can still authenticate but has no usable model.
|
| 241 |
+
|
| 242 |
+
Priority:
|
| 243 |
+
|
| 244 |
+
1. request-body `customApiConfig`
|
| 245 |
+
2. server-side `MANIMCAT_ROUTE_*`
|
| 246 |
+
|
| 247 |
+
Use server-side routing when different users should always hit different upstreams. Use the frontend provider settings when one browser user wants to manage multiple providers locally.
|
| 248 |
+
|
| 249 |
+
---
|
| 250 |
+
|
| 251 |
+
## Optional: Supabase Persistence
|
| 252 |
+
|
| 253 |
+
There are two optional persistence layers:
|
| 254 |
+
|
| 255 |
+
- generation history: `ENABLE_HISTORY_DB=true`
|
| 256 |
+
- Studio Agent session/work persistence: `ENABLE_STUDIO_DB=true`
|
| 257 |
+
|
| 258 |
+
Shared connection variables:
|
| 259 |
+
|
| 260 |
+
```env
|
| 261 |
+
SUPABASE_URL=https://your-project.supabase.co
|
| 262 |
+
SUPABASE_KEY=your-supabase-key
|
| 263 |
+
```
|
| 264 |
+
|
| 265 |
+
### Generation history
|
| 266 |
+
|
| 267 |
+
Apply:
|
| 268 |
+
|
| 269 |
+
- `src/database/migrations/001_create_history.sql`
|
| 270 |
+
|
| 271 |
+
If you also want render-failure export, apply:
|
| 272 |
+
|
| 273 |
+
- `src/database/migrations/002_create_render_failure_events.sql`
|
| 274 |
+
|
| 275 |
+
Then configure:
|
| 276 |
+
|
| 277 |
+
```env
|
| 278 |
+
ENABLE_HISTORY_DB=true
|
| 279 |
+
ENABLE_RENDER_FAILURE_LOG=true
|
| 280 |
+
ADMIN_EXPORT_TOKEN=replace_with_long_random_token
|
| 281 |
+
```
|
| 282 |
+
|
| 283 |
+
Export endpoint:
|
| 284 |
+
|
| 285 |
+
- `GET /api/admin/render-failures/export`
|
| 286 |
+
- header: `x-admin-token`
|
| 287 |
+
|
| 288 |
+
### Studio Agent persistence
|
| 289 |
+
|
| 290 |
+
Apply:
|
| 291 |
+
|
| 292 |
+
- `src/database/migrations/003_create_studio_agent.sql`
|
| 293 |
+
|
| 294 |
+
Then enable:
|
| 295 |
+
|
| 296 |
+
```env
|
| 297 |
+
ENABLE_STUDIO_DB=true
|
| 298 |
+
```
|
| 299 |
+
|
| 300 |
+
---
|
| 301 |
+
|
| 302 |
+
## Troubleshooting
|
| 303 |
+
|
| 304 |
+
### The UI loads, but jobs fail immediately
|
| 305 |
+
|
| 306 |
+
Check:
|
| 307 |
+
|
| 308 |
+
- `MANIMCAT_ROUTE_*` is fully configured
|
| 309 |
+
- the request includes a valid Bearer key
|
| 310 |
+
- the matched route entry does not have an empty `model`
|
| 311 |
+
|
| 312 |
+
### `/health` shows unhealthy Redis or queue
|
| 313 |
+
|
| 314 |
+
Check:
|
| 315 |
+
|
| 316 |
+
- Redis is actually running
|
| 317 |
+
- `REDIS_HOST` and `REDIS_PORT` match your environment
|
| 318 |
+
- in Docker Compose, the backend is pointing to service `redis`
|
| 319 |
+
|
| 320 |
+
### The container starts locally, but Hugging Face build fails
|
| 321 |
+
|
| 322 |
+
Check:
|
| 323 |
+
|
| 324 |
+
- the Space SDK is Docker
|
| 325 |
+
- env vars were added in Space Settings
|
| 326 |
+
- you did not follow stale instructions mentioning `Dockerfile.huggingface`
|
DEPLOYMENT.zh-CN.md
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ManimCat 部署文档
|
| 2 |
+
|
| 3 |
+
简体中文 | [English](https://github.com/Wing900/ManimCat/blob/main/DEPLOYMENT.md)
|
| 4 |
+
|
| 5 |
+
这份文档只保留当前项目实际可用的部署路径,并尽量压缩为最短步骤。
|
| 6 |
+
|
| 7 |
+
## 先确认项目怎么运行
|
| 8 |
+
|
| 9 |
+
- 后端是 Node.js + Express。
|
| 10 |
+
- 前端由 Vite 构建,产物由后端静态托管。
|
| 11 |
+
- 任务队列和状态依赖 Redis。
|
| 12 |
+
- 实际渲染依赖 Python、ManimCE、LaTeX、`ffmpeg`。
|
| 13 |
+
- 上游 AI 不再依赖 `OPENAI_API_KEY` 这类单一全局变量,推荐使用 `MANIMCAT_ROUTE_*`,或由前端按请求传 `customApiConfig`。
|
| 14 |
+
|
| 15 |
+
## 选择哪种部署方式
|
| 16 |
+
|
| 17 |
+
- 只想本机跑起来:用“本地原生部署”。
|
| 18 |
+
- 想减少环境差异:用“Docker Compose”。
|
| 19 |
+
- 想部署到 Hugging Face Space:用“Hugging Face Spaces”。
|
| 20 |
+
|
| 21 |
+
## 一、本地原生部署
|
| 22 |
+
|
| 23 |
+
### 前置条件
|
| 24 |
+
|
| 25 |
+
- Node.js 18+
|
| 26 |
+
- Redis 7+,默认可通过 `localhost:6379` 访问
|
| 27 |
+
- Python 3.11+
|
| 28 |
+
- Manim Community Edition 0.19.x
|
| 29 |
+
- `mypy`
|
| 30 |
+
- LaTeX
|
| 31 |
+
- `ffmpeg`
|
| 32 |
+
|
| 33 |
+
### 1. 拉代码并准备环境变量
|
| 34 |
+
|
| 35 |
+
```bash
|
| 36 |
+
git clone https://github.com/Wing900/ManimCat.git
|
| 37 |
+
cd ManimCat
|
| 38 |
+
cp .env.example .env
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
至少配置一组服务端上游路由:
|
| 42 |
+
|
| 43 |
+
```env
|
| 44 |
+
MANIMCAT_ROUTE_KEYS=demo-key
|
| 45 |
+
MANIMCAT_ROUTE_API_URLS=https://api.example.com/v1
|
| 46 |
+
MANIMCAT_ROUTE_API_KEYS=sk-example
|
| 47 |
+
MANIMCAT_ROUTE_MODELS=gpt-4o-mini
|
| 48 |
+
```
|
| 49 |
+
|
| 50 |
+
常用可选项:
|
| 51 |
+
|
| 52 |
+
```env
|
| 53 |
+
PORT=3000
|
| 54 |
+
LOG_LEVEL=info
|
| 55 |
+
PROD_SUMMARY_LOG_ONLY=false
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
### 2. 安装依赖
|
| 59 |
+
|
| 60 |
+
```bash
|
| 61 |
+
npm install
|
| 62 |
+
npm --prefix frontend install
|
| 63 |
+
python -m pip install mypy
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
### 3. 启动
|
| 67 |
+
|
| 68 |
+
开发模式:
|
| 69 |
+
|
| 70 |
+
```bash
|
| 71 |
+
npm run dev
|
| 72 |
+
```
|
| 73 |
+
|
| 74 |
+
生产式启动:
|
| 75 |
+
|
| 76 |
+
```bash
|
| 77 |
+
npm run build
|
| 78 |
+
npm start
|
| 79 |
+
```
|
| 80 |
+
|
| 81 |
+
说明:
|
| 82 |
+
|
| 83 |
+
- `npm run build` 当前只构建前端。
|
| 84 |
+
- `npm start` 直接用 `tsx src/server.ts` 启动后端,不依赖预编译 JS。
|
| 85 |
+
|
| 86 |
+
### 4. 验证
|
| 87 |
+
|
| 88 |
+
- 页面:`http://localhost:3000`
|
| 89 |
+
- 健康检查:`http://localhost:3000/health`
|
| 90 |
+
|
| 91 |
+
---
|
| 92 |
+
|
| 93 |
+
## 二、Docker Compose 部署
|
| 94 |
+
|
| 95 |
+
这是最推荐的部署方式,仓库已经内置 Redis、Manim 运行时和 Node 运行时。
|
| 96 |
+
|
| 97 |
+
如果你已经将镜像推到了 Docker Hub,也可以直接使用 `wingflow/manimcat`,不必每次都本地 `build`。
|
| 98 |
+
|
| 99 |
+
### 1. 准备环境变量
|
| 100 |
+
|
| 101 |
+
```bash
|
| 102 |
+
cp .env.production .env
|
| 103 |
+
```
|
| 104 |
+
|
| 105 |
+
至少填写一组上游:
|
| 106 |
+
|
| 107 |
+
```env
|
| 108 |
+
MANIMCAT_ROUTE_KEYS=demo-key
|
| 109 |
+
MANIMCAT_ROUTE_API_URLS=https://api.example.com/v1
|
| 110 |
+
MANIMCAT_ROUTE_API_KEYS=sk-example
|
| 111 |
+
MANIMCAT_ROUTE_MODELS=gpt-4o-mini
|
| 112 |
+
```
|
| 113 |
+
|
| 114 |
+
如需改端口:
|
| 115 |
+
|
| 116 |
+
```env
|
| 117 |
+
PORT=3000
|
| 118 |
+
REDIS_PORT=6379
|
| 119 |
+
```
|
| 120 |
+
|
| 121 |
+
### 2. 构建并启动
|
| 122 |
+
|
| 123 |
+
```bash
|
| 124 |
+
docker compose build
|
| 125 |
+
docker compose up -d
|
| 126 |
+
```
|
| 127 |
+
|
| 128 |
+
如果改为直接使用已发布镜像,请把 `docker-compose.yml` 里的 `build` 段替换为:
|
| 129 |
+
|
| 130 |
+
```yaml
|
| 131 |
+
image: wingflow/manimcat
|
| 132 |
+
```
|
| 133 |
+
|
| 134 |
+
### 3. 验证
|
| 135 |
+
|
| 136 |
+
```bash
|
| 137 |
+
docker compose ps
|
| 138 |
+
```
|
| 139 |
+
|
| 140 |
+
- 页面:`http://localhost:3000`
|
| 141 |
+
- 健康检查:`http://localhost:3000/health`
|
| 142 |
+
|
| 143 |
+
说明:
|
| 144 |
+
|
| 145 |
+
- Compose 对外暴露 `3000`。
|
| 146 |
+
- 容器内 Redis 服务名固定为 `redis`。
|
| 147 |
+
- Studio 会话工作目录会持久化到 `studio-workspace-data` volume,挂载到 `/app/.studio-workspace`。
|
| 148 |
+
- 生成图片与上传的参考图会持久化到 `image-storage` volume,挂载到 `/app/public/images`。
|
| 149 |
+
- 生成的视频会持久化到 `video-storage` volume,挂载到 `/app/public/videos`。
|
| 150 |
+
- Manim 的媒体缓存与中间渲染文件会持久化到 `manim-media` volume,挂载到 `/app/media`。
|
| 151 |
+
- 临时渲染目录会持久化到 `manim-tmp` volume,挂载到 `/app/tmp`。
|
| 152 |
+
|
| 153 |
+
如需检查 volume:
|
| 154 |
+
|
| 155 |
+
```bash
|
| 156 |
+
docker volume ls
|
| 157 |
+
docker volume inspect manimcat_studio-workspace-data
|
| 158 |
+
```
|
| 159 |
+
|
| 160 |
+
---
|
| 161 |
+
|
| 162 |
+
## 三、Hugging Face Spaces 部署
|
| 163 |
+
|
| 164 |
+
### 前提
|
| 165 |
+
|
| 166 |
+
- Space 类型必须选 Docker。
|
| 167 |
+
- 运行端口使用 `7860`。
|
| 168 |
+
- 环境变量必须配置在 Space Settings,不是只写进仓库 `.env`。
|
| 169 |
+
|
| 170 |
+
### 1. 直接使用仓库根目录的 `Dockerfile`
|
| 171 |
+
|
| 172 |
+
当前仓库里的 `Dockerfile` 已经是 Hugging Face Space 可用版本:
|
| 173 |
+
|
| 174 |
+
- 基于 `manimcommunity/manim:stable`
|
| 175 |
+
- 容器内安装 Node.js、Redis、中文字体、`ffmpeg`
|
| 176 |
+
- 启动命令是 `node start-with-redis-hf.cjs`
|
| 177 |
+
- 默认监听 `PORT=7860`
|
| 178 |
+
|
| 179 |
+
不需要再额外复制一个 `Dockerfile.huggingface`,仓库里也没有这个文件。
|
| 180 |
+
|
| 181 |
+
如果你已经发布了 Docker 镜像,也可以让其他部署环境直接参考 `wingflow/manimcat` 的内容;但 Hugging Face Space 仍然是基于仓库内 `Dockerfile` 构建,不是直接拉 Docker Hub 镜像运行。
|
| 182 |
+
|
| 183 |
+
### 2. 在 Space Settings 配置变量
|
| 184 |
+
|
| 185 |
+
至少配置:
|
| 186 |
+
|
| 187 |
+
```env
|
| 188 |
+
PORT=7860
|
| 189 |
+
NODE_ENV=production
|
| 190 |
+
MANIMCAT_ROUTE_KEYS=demo-key
|
| 191 |
+
MANIMCAT_ROUTE_API_URLS=https://api.example.com/v1
|
| 192 |
+
MANIMCAT_ROUTE_API_KEYS=sk-example
|
| 193 |
+
MANIMCAT_ROUTE_MODELS=gpt-4o-mini
|
| 194 |
+
```
|
| 195 |
+
|
| 196 |
+
建议再补上:
|
| 197 |
+
|
| 198 |
+
```env
|
| 199 |
+
LOG_LEVEL=info
|
| 200 |
+
PROD_SUMMARY_LOG_ONLY=true
|
| 201 |
+
```
|
| 202 |
+
|
| 203 |
+
### 3. 推送代码
|
| 204 |
+
|
| 205 |
+
```bash
|
| 206 |
+
git add .
|
| 207 |
+
git commit -m "Deploy ManimCat"
|
| 208 |
+
git push
|
| 209 |
+
```
|
| 210 |
+
|
| 211 |
+
部署完成后访问:
|
| 212 |
+
|
| 213 |
+
- 页面:`https://YOUR_SPACE.hf.space/`
|
| 214 |
+
- 健康检查:`https://YOUR_SPACE.hf.space/health`
|
| 215 |
+
|
| 216 |
+
---
|
| 217 |
+
|
| 218 |
+
## 上游路由配置
|
| 219 |
+
|
| 220 |
+
推荐使用 `MANIMCAT_ROUTE_*` 做服务端路由。它同时承担两件事:
|
| 221 |
+
|
| 222 |
+
- Bearer key 白名单
|
| 223 |
+
- key 到上游 `apiUrl/apiKey/model` 的映射
|
| 224 |
+
|
| 225 |
+
示例:
|
| 226 |
+
|
| 227 |
+
```env
|
| 228 |
+
MANIMCAT_ROUTE_KEYS=user_a,user_b
|
| 229 |
+
MANIMCAT_ROUTE_API_URLS=https://api-a.example.com/v1,https://api-b.example.com/v1
|
| 230 |
+
MANIMCAT_ROUTE_API_KEYS=sk-a,sk-b
|
| 231 |
+
MANIMCAT_ROUTE_MODELS=gpt-4o-mini,gemini-2.5-flash
|
| 232 |
+
```
|
| 233 |
+
|
| 234 |
+
规则:
|
| 235 |
+
|
| 236 |
+
1. 四组变量都支持逗号或换行分隔。
|
| 237 |
+
2. `MANIMCAT_ROUTE_KEYS` 是主索引。
|
| 238 |
+
3. `apiUrl` 或 `apiKey` 缺失的条目会被跳过。
|
| 239 |
+
4. 如果某一组变量只写了一个值,这个值会复用到全部条目。
|
| 240 |
+
5. `model` 留空时,该 key 仍可认证,但当前没有可用模型。
|
| 241 |
+
|
| 242 |
+
请求优先级:
|
| 243 |
+
|
| 244 |
+
1. 请求体里的 `customApiConfig`
|
| 245 |
+
2. 服务端 `MANIMCAT_ROUTE_*`
|
| 246 |
+
|
| 247 |
+
如果要给不同用户固定分配不同上游,用服务端路由;如果只是单个浏览器本地切换多个 provider,用前端设置页即可。
|
| 248 |
+
|
| 249 |
+
---
|
| 250 |
+
|
| 251 |
+
## 可选:Supabase 持久化
|
| 252 |
+
|
| 253 |
+
项目有两类可选持久化:
|
| 254 |
+
|
| 255 |
+
- 生成历史:`ENABLE_HISTORY_DB=true`
|
| 256 |
+
- Studio Agent 会话与工作流:`ENABLE_STUDIO_DB=true`
|
| 257 |
+
|
| 258 |
+
公共连接配置:
|
| 259 |
+
|
| 260 |
+
```env
|
| 261 |
+
SUPABASE_URL=https://your-project.supabase.co
|
| 262 |
+
SUPABASE_KEY=your-supabase-key
|
| 263 |
+
```
|
| 264 |
+
|
| 265 |
+
### 生成历史
|
| 266 |
+
|
| 267 |
+
先执行迁移:
|
| 268 |
+
|
| 269 |
+
- `src/database/migrations/001_create_history.sql`
|
| 270 |
+
|
| 271 |
+
如需渲染失败事件导出,再执行:
|
| 272 |
+
|
| 273 |
+
- `src/database/migrations/002_create_render_failure_events.sql`
|
| 274 |
+
|
| 275 |
+
对应变量:
|
| 276 |
+
|
| 277 |
+
```env
|
| 278 |
+
ENABLE_HISTORY_DB=true
|
| 279 |
+
ENABLE_RENDER_FAILURE_LOG=true
|
| 280 |
+
ADMIN_EXPORT_TOKEN=replace_with_long_random_token
|
| 281 |
+
```
|
| 282 |
+
|
| 283 |
+
导出接口:
|
| 284 |
+
|
| 285 |
+
- `GET /api/admin/render-failures/export`
|
| 286 |
+
- 请求头:`x-admin-token`
|
| 287 |
+
|
| 288 |
+
### Studio Agent 持久化
|
| 289 |
+
|
| 290 |
+
先执行迁移:
|
| 291 |
+
|
| 292 |
+
- `src/database/migrations/003_create_studio_agent.sql`
|
| 293 |
+
|
| 294 |
+
再开启:
|
| 295 |
+
|
| 296 |
+
```env
|
| 297 |
+
ENABLE_STUDIO_DB=true
|
| 298 |
+
```
|
| 299 |
+
|
| 300 |
+
---
|
| 301 |
+
|
| 302 |
+
## 排查清单
|
| 303 |
+
|
| 304 |
+
### 页面能开,但提交任务失败
|
| 305 |
+
|
| 306 |
+
优先检查:
|
| 307 |
+
|
| 308 |
+
- `MANIMCAT_ROUTE_*` 是否完整配置
|
| 309 |
+
- 请求头里是否带了合法 Bearer key
|
| 310 |
+
- 当前 key 对应的 `model` 是否为空
|
| 311 |
+
|
| 312 |
+
### `/health` 里 `redis` 或 `queue` 不健康
|
| 313 |
+
|
| 314 |
+
优先检查:
|
| 315 |
+
|
| 316 |
+
- Redis 是否真的启动
|
| 317 |
+
- `REDIS_HOST` / `REDIS_PORT` 是否匹配
|
| 318 |
+
- Docker 部署时后端是否连到了容器内 `redis`
|
| 319 |
+
|
| 320 |
+
### 容器能起,但 Space 一直构建失败
|
| 321 |
+
|
| 322 |
+
优先检查:
|
| 323 |
+
|
| 324 |
+
- Space SDK 是否选了 Docker
|
| 325 |
+
- 是否把环境变量写到了 Space Settings
|
| 326 |
+
- 是否错误照搬了旧文档里的 `Dockerfile.huggingface`
|
Dockerfile
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# =========================================
|
| 2 |
+
# 阶段 1: 准备 Node 环境
|
| 3 |
+
# =========================================
|
| 4 |
+
FROM node:22-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 和中文字体,并刷新字体缓存
|
| 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 fontconfig \
|
| 21 |
+
fonts-noto-cjk fonts-noto-cjk-extra \
|
| 22 |
+
fonts-wqy-zenhei fonts-wqy-microhei fonts-lxgw-wenkai \
|
| 23 |
+
ffmpeg curl ca-certificates && \
|
| 24 |
+
fc-cache -f -v
|
| 25 |
+
|
| 26 |
+
# 2.1 安装 Python 运行时与静态检查依赖
|
| 27 |
+
# 显式声明 matplotlib,避免依赖基础镜像的隐式预装状态。
|
| 28 |
+
RUN python -m pip install --no-cache-dir \
|
| 29 |
+
matplotlib \
|
| 30 |
+
mypy==1.19.1
|
| 31 |
+
|
| 32 |
+
WORKDIR /app
|
| 33 |
+
|
| 34 |
+
# 3. 复制 package.json
|
| 35 |
+
COPY package.json package-lock.json* ./
|
| 36 |
+
COPY frontend/package.json frontend/package-lock.json* ./frontend/
|
| 37 |
+
|
| 38 |
+
# 4. 设置 npm 淘宝源
|
| 39 |
+
RUN npm config set registry https://registry.npmmirror.com
|
| 40 |
+
|
| 41 |
+
# 5. 安装依赖
|
| 42 |
+
RUN npm install && npm --prefix frontend install
|
| 43 |
+
|
| 44 |
+
# 6. 复制源码并构建 React
|
| 45 |
+
COPY . .
|
| 46 |
+
|
| 47 |
+
# 7. 下载 BGM 音频文件(HF Space 同步时可能排除二进制文件)
|
| 48 |
+
# 之前写法使用了 `... && ... && ... || true`,会把前面下载失败静默吞掉。
|
| 49 |
+
RUN set -eux; \
|
| 50 |
+
mkdir -p src/audio/tracks; \
|
| 51 |
+
for file in \
|
| 52 |
+
clavier-music-soft-piano-music-312509.mp3 \
|
| 53 |
+
the_mountain-soft-piano-background-444129.mp3 \
|
| 54 |
+
viacheslavstarostin-relaxing-soft-piano-music-431679.mp3; do \
|
| 55 |
+
rm -f "src/audio/tracks/$file"; \
|
| 56 |
+
for url in \
|
| 57 |
+
"https://raw.githubusercontent.com/Wing900/ManimCat/main/src/audio/tracks/$file" \
|
| 58 |
+
"https://github.com/Wing900/ManimCat/raw/main/src/audio/tracks/$file"; do \
|
| 59 |
+
echo "Downloading $file from $url"; \
|
| 60 |
+
if curl -fL --retry 8 --retry-delay 3 --connect-timeout 10 --max-time 120 -o "src/audio/tracks/$file" "$url"; then \
|
| 61 |
+
break; \
|
| 62 |
+
fi; \
|
| 63 |
+
done; \
|
| 64 |
+
if [ ! -s "src/audio/tracks/$file" ]; then \
|
| 65 |
+
echo "WARNING: failed to download $file"; \
|
| 66 |
+
rm -f "src/audio/tracks/$file"; \
|
| 67 |
+
fi; \
|
| 68 |
+
done; \
|
| 69 |
+
echo "Downloaded tracks:"; \
|
| 70 |
+
ls -lh src/audio/tracks || true; \
|
| 71 |
+
track_count="$(find src/audio/tracks -maxdepth 1 -type f -name '*.mp3' | wc -l)"; \
|
| 72 |
+
echo "BGM track count: $track_count"; \
|
| 73 |
+
if [ "$track_count" -eq 0 ]; then \
|
| 74 |
+
echo "ERROR: no BGM tracks available after download"; \
|
| 75 |
+
exit 1; \
|
| 76 |
+
fi
|
| 77 |
+
|
| 78 |
+
RUN npm run build
|
| 79 |
+
|
| 80 |
+
ENV PORT=7860
|
| 81 |
+
EXPOSE 7860
|
| 82 |
+
|
| 83 |
+
CMD ["node", "start-with-redis-hf.cjs"]
|
LICENSE
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
ManimCat uses a mixed-license model.
|
| 2 |
+
|
| 3 |
+
Please read `LICENSE_POLICY.md` for the binding license scope.
|
| 4 |
+
|
| 5 |
+
- MIT full text: `LICENSES/MIT.txt`
|
| 6 |
+
- ManimCat Non-Commercial License full text: `LICENSES/ManimCat-NC.txt`
|
| 7 |
+
|
LICENSES/MIT.txt
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2024 Rohit Ghumare
|
| 4 |
+
Copyright (c) 2026 ManimCat Contributors
|
| 5 |
+
|
| 6 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 7 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 8 |
+
in the Software without restriction, including without limitation the rights
|
| 9 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 10 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 11 |
+
furnished to do so, subject to the following conditions:
|
| 12 |
+
|
| 13 |
+
The above copyright notice and this permission notice shall be included in all
|
| 14 |
+
copies or substantial portions of the Software.
|
| 15 |
+
|
| 16 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 17 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 18 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 19 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 20 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 21 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 22 |
+
SOFTWARE.
|
| 23 |
+
|
LICENSES/ManimCat-NC.txt
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
ManimCat Non-Commercial License v1.0
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 ManimCat Author and Contributors
|
| 4 |
+
|
| 5 |
+
1) Grant
|
| 6 |
+
Permission is granted to use, copy, modify, and redistribute the licensed
|
| 7 |
+
materials for personal, educational, research, and other non-commercial
|
| 8 |
+
purposes.
|
| 9 |
+
|
| 10 |
+
2) Commercial Restriction
|
| 11 |
+
Without prior written permission from the copyright holder, you may not use
|
| 12 |
+
the licensed materials for commercial purposes, including but not limited to:
|
| 13 |
+
- selling products or services based on the licensed materials;
|
| 14 |
+
- offering paid hosted/API/SaaS services based on the licensed materials;
|
| 15 |
+
- embedding the licensed materials into paid subscription software or services;
|
| 16 |
+
- redistributing for direct commercial gain.
|
| 17 |
+
|
| 18 |
+
3) Attribution
|
| 19 |
+
Redistributions must retain this license text and provide clear attribution to
|
| 20 |
+
ManimCat.
|
| 21 |
+
|
| 22 |
+
4) No Warranty
|
| 23 |
+
THE LICENSED MATERIALS ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
|
| 24 |
+
|
| 25 |
+
5) Scope Control
|
| 26 |
+
This license applies only to files explicitly designated in `LICENSE_POLICY.md`.
|
| 27 |
+
All other files follow the licenses designated there.
|
LICENSE_POLICY.en.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# LICENSE POLICY
|
| 2 |
+
|
| 3 |
+
This file defines the binding license scope for the ManimCat repository.
|
| 4 |
+
|
| 5 |
+
## 1) Priority
|
| 6 |
+
|
| 7 |
+
1. `LICENSE_POLICY.md` (Chinese policy) and this English mirror define file/path-level boundaries.
|
| 8 |
+
2. `LICENSES/MIT.txt` and `LICENSES/ManimCat-NC.txt` provide full license texts.
|
| 9 |
+
3. Files not listed in the MIT list in `LICENSE_POLICY.md` are treated as `ManimCat-NC` (Non-Commercial) by default.
|
| 10 |
+
|
| 11 |
+
## 2) MIT List (Commercial Use Allowed)
|
| 12 |
+
|
| 13 |
+
The following files/paths are under MIT (because they include upstream-related parts or highly similar derivative chains):
|
| 14 |
+
|
| 15 |
+
- `src/services/manim-templates.ts` (because it includes upstream-related template and matching chains)
|
| 16 |
+
- `src/services/manim-templates/**` (because it includes upstream-related template and matching chains)
|
| 17 |
+
- `src/services/openai-client.ts` (because it includes upstream-related call chains)
|
| 18 |
+
- `src/services/job-store.ts` (because it includes upstream-related interface chains)
|
| 19 |
+
- `src/utils/logger.ts` (because it includes upstream-related logging structures)
|
| 20 |
+
- `src/middlewares/error-handler.ts` (because it includes upstream-related error-handling chains)
|
| 21 |
+
|
| 22 |
+
Third-party notices:
|
| 23 |
+
- `THIRD_PARTY_NOTICES.md`
|
| 24 |
+
- `THIRD_PARTY_NOTICES.zh-CN.md`
|
| 25 |
+
|
| 26 |
+
## 3) Non-Commercial Scope (Default)
|
| 27 |
+
|
| 28 |
+
Except for the MIT list above, all other files in this repository default to `LICENSES/ManimCat-NC.txt`.
|
| 29 |
+
|
| 30 |
+
## 4) Separate Commercial Licensing
|
| 31 |
+
|
| 32 |
+
If you want to use files in the non-commercial scope for commercial purposes, a separate written license from the author is required.
|
| 33 |
+
|
| 34 |
+
## 5) Historical Versions
|
| 35 |
+
|
| 36 |
+
This policy defines the scope for current and future versions of this repository. If historical versions were released under different terms, refer to the license statement in those versions.
|
LICENSE_POLICY.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# LICENSE POLICY
|
| 2 |
+
|
| 3 |
+
本文件为 ManimCat 仓库的授权范围说明文件(binding scope)。
|
| 4 |
+
|
| 5 |
+
## 1) 适用优先级
|
| 6 |
+
|
| 7 |
+
1. `LICENSE_POLICY.md`(本文件)定义文件/路径级授权边界。
|
| 8 |
+
2. `LICENSES/MIT.txt` 与 `LICENSES/ManimCat-NC.txt` 提供完整协议文本。
|
| 9 |
+
3. 未在本文件中列入 MIT 清单的文件,默认适用 `ManimCat-NC`(非商业)协议。
|
| 10 |
+
|
| 11 |
+
## 2) MIT 清单(可商用)
|
| 12 |
+
|
| 13 |
+
以下文件/路径适用 MIT(括号说明:因为包含原作者项目相关部分或高度相似衍生链路):
|
| 14 |
+
|
| 15 |
+
- `src/services/manim-templates.ts`(因包含原作者相关模板与匹配链路)
|
| 16 |
+
- `src/services/manim-templates/**`(因包含原作者相关模板与匹配链路)
|
| 17 |
+
- `src/services/openai-client.ts`(因包含原作者相关调用链路)
|
| 18 |
+
- `src/services/job-store.ts`(因包含原作者相关接口链路)
|
| 19 |
+
- `src/utils/logger.ts`(因包含原作者相关日志结构)
|
| 20 |
+
- `src/middlewares/error-handler.ts`(因包含原作者相关错误处理链路)
|
| 21 |
+
|
| 22 |
+
第三方来源说明见:
|
| 23 |
+
- `THIRD_PARTY_NOTICES.md`
|
| 24 |
+
- `THIRD_PARTY_NOTICES.zh-CN.md`
|
| 25 |
+
|
| 26 |
+
## 3) 非商业清单(默认)
|
| 27 |
+
|
| 28 |
+
除“MIT 清单”外,本仓库其余文件默认适用 `LICENSES/ManimCat-NC.txt`。
|
| 29 |
+
|
| 30 |
+
## 4) 另行商业授权
|
| 31 |
+
|
| 32 |
+
若需将非商业范围内文件用于商业用途,需与作者签署书面商业授权。
|
| 33 |
+
|
| 34 |
+
## 5) 历史版本说明
|
| 35 |
+
|
| 36 |
+
本政策用于定义本仓库当前与后续版本的授权边界;历史版本若已在其他条款下发布,请以对应版本中的授权声明为准。
|
README.md
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: ManimCat
|
| 3 |
+
emoji: 🐱
|
| 4 |
+
colorFrom: gray
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
English | [简体中文](https://github.com/Wing900/ManimCat/blob/main/README.zh-CN.md)
|
| 12 |
+
|
| 13 |
+
<div align="center">
|
| 14 |
+
|
| 15 |
+
<!-- Top decorative wave -->
|
| 16 |
+
<img width="100%" src="https://capsule-render.vercel.app/api?type=waving&color=455A64&height=120§ion=header" />
|
| 17 |
+
|
| 18 |
+
<br>
|
| 19 |
+
|
| 20 |
+
<img src="public/logo.svg" width="200" alt="ManimCat Logo" />
|
| 21 |
+
|
| 22 |
+
<!-- Cat paw accent -->
|
| 23 |
+
<div style="opacity: 0.3; margin: 20px 0;">
|
| 24 |
+
<img src="https://raw.githubusercontent.com/Tarikul-Islam-Anik/Animated-Fluent-Emojis/master/Emojis/Animals/Paw%20Prints.png" width="40" alt="paws" />
|
| 25 |
+
</div>
|
| 26 |
+
|
| 27 |
+
<h1>
|
| 28 |
+
<picture>
|
| 29 |
+
<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" />
|
| 30 |
+
</picture>
|
| 31 |
+
</h1>
|
| 32 |
+
|
| 33 |
+
<!-- Math symbol divider -->
|
| 34 |
+
<p align="center">
|
| 35 |
+
<span style="font-family: monospace; font-size: 24px; color: #90A4AE;">
|
| 36 |
+
∫ ∑ ∂ ∞
|
| 37 |
+
</span>
|
| 38 |
+
</p>
|
| 39 |
+
|
| 40 |
+
<p align="center">
|
| 41 |
+
<strong>Dual-Mode AI Workspace for Mathematical Visuals</strong>
|
| 42 |
+
</p>
|
| 43 |
+
|
| 44 |
+
<p align="center">
|
| 45 |
+
Combining direct workflow generation with agent-driven studio collaboration, powered by Manim and matplotlib
|
| 46 |
+
</p>
|
| 47 |
+
|
| 48 |
+
<!-- Geometric divider -->
|
| 49 |
+
<div style="margin: 30px 0;">
|
| 50 |
+
<span style="color: #CFD8DC; font-size: 20px;">◆ ◆ ◆</span>
|
| 51 |
+
</div>
|
| 52 |
+
|
| 53 |
+
<p align="center">
|
| 54 |
+
<img src="https://img.shields.io/badge/ManimCE-0.19.2-455A64?style=for-the-badge&logo=python&logoColor=white" alt="ManimCE" />
|
| 55 |
+
<img src="https://img.shields.io/badge/React-19.2.0-455A64?style=for-the-badge&logo=react&logoColor=white" alt="React" />
|
| 56 |
+
<img src="https://img.shields.io/badge/Node.js-18+-455A64?style=for-the-badge&logo=node.js&logoColor=white" alt="Node.js" />
|
| 57 |
+
<img src="https://img.shields.io/badge/License-Mixed-607D8B?style=for-the-badge" alt="License" />
|
| 58 |
+
</p>
|
| 59 |
+
|
| 60 |
+
<p align="center" style="font-size: 18px;">
|
| 61 |
+
<a href="#overview"><strong>Overview</strong></a> •
|
| 62 |
+
<a href="#examples"><strong>Examples</strong></a> •
|
| 63 |
+
<a href="#quick-start"><strong>Quick Start</strong></a> •
|
| 64 |
+
<a href="#technology"><strong>Technology</strong></a> •
|
| 65 |
+
<a href="#deployment"><strong>Deployment</strong></a> •
|
| 66 |
+
<a href="#major-additions"><strong>Additions</strong></a> •
|
| 67 |
+
<a href="#license-and-copyright"><strong>License</strong></a> •
|
| 68 |
+
<a href="#maintenance-notes"><strong>Maintenance</strong></a>
|
| 69 |
+
</p>
|
| 70 |
+
|
| 71 |
+
<br>
|
| 72 |
+
|
| 73 |
+
<!-- Bottom decorative wave -->
|
| 74 |
+
<img width="100%" src="https://capsule-render.vercel.app/api?type=waving&color=455A64&height=100§ion=footer" />
|
| 75 |
+
|
| 76 |
+
</div>
|
| 77 |
+
|
| 78 |
+
<br>
|
| 79 |
+
|
| 80 |
+
## Overview
|
| 81 |
+
|
| 82 |
+
I am happy to introduce my new project, ManimCat. It is, after all, a cat.
|
| 83 |
+
|
| 84 |
+
Built on top of [manim-video-generator](https://github.com/rohitg00/manim-video-generator), ManimCat is now a much broader AI-assisted creation system for math teaching visuals rather than just a single generation flow.
|
| 85 |
+
|
| 86 |
+
It is designed for classroom explanation, worked-example breakdowns, and visual reasoning tasks. You can use natural language to generate, modify, rerender, and organize both animated and static teaching visuals with `video` and `image` outputs.
|
| 87 |
+
|
| 88 |
+
The project is now organized around three clear axes: `dual-mode`, `dual-engine`, and `dual-studio`.
|
| 89 |
+
|
| 90 |
+
- `Workflow Mode` is for direct generation and rendering when you want fast outputs
|
| 91 |
+
- `Agent Mode` is for Studio-based collaborative work with longer-lived sessions, task state, review, and iteration
|
| 92 |
+
- `Manim` is used for animation and timeline-based mathematical storytelling
|
| 93 |
+
- `matplotlib` is used in Plot Studio for static math visuals, charts, and teaching figures
|
| 94 |
+
- `Plot Studio` is the more mature Studio path today for static visual work and iterative editing
|
| 95 |
+
- `Manim Studio` is the animation-oriented Studio path and is still at an earlier stage
|
| 96 |
+
|
| 97 |
+
### Interface
|
| 98 |
+
|
| 99 |
+
#### UI
|
| 100 |
+
|
| 101 |
+
<div align="center">
|
| 102 |
+
<img src="https://github.com/user-attachments/assets/5abd29f6-adcb-4047-b85c-aba1fa0808a5" width="40%" alt="ManimCat UI screenshot 1" />
|
| 103 |
+
<img src="https://github.com/user-attachments/assets/d18d0f27-15b4-4c59-8a4b-a8b8fb553020" width="40%" alt="ManimCat UI screenshot 2" />
|
| 104 |
+
</div>
|
| 105 |
+
|
| 106 |
+
<div align="center">
|
| 107 |
+
<img src="https://github.com/user-attachments/assets/2c70886f-c381-4995-8ca4-2d0db1974829" width="40%" alt="ManimCat UI screenshot 3" />
|
| 108 |
+
<img src="https://github.com/user-attachments/assets/ce3718e8-4bc4-44db-87fb-3fb486eed144" width="40%" alt="ManimCat UI screenshot 4" />
|
| 109 |
+
</div>
|
| 110 |
+
|
| 111 |
+
#### Workflow
|
| 112 |
+
|
| 113 |
+
<div align="center">
|
| 114 |
+
<img src="https://github.com/user-attachments/assets/b831caaf-10fe-4238-8998-d574f42af524" width="46%" alt="ManimCat Workflow screenshot 1" />
|
| 115 |
+
<img src="https://github.com/user-attachments/assets/30583571-f038-4c7b-9362-c988a896d374" width="46%" alt="ManimCat Workflow screenshot 2" />
|
| 116 |
+
</div>
|
| 117 |
+
|
| 118 |
+
#### Plot Studio
|
| 119 |
+
|
| 120 |
+
<div align="center">
|
| 121 |
+
<img src="https://github.com/user-attachments/assets/0812b601-b896-4137-8e20-a2d4b6feadb9" width="46%" alt="ManimCat Plot Studio screenshot 1" />
|
| 122 |
+
<img src="https://github.com/user-attachments/assets/99ae423f-4b15-431d-8e21-30a6b6171616" width="46%" alt="ManimCat Plot Studio screenshot 2" />
|
| 123 |
+
</div>
|
| 124 |
+
|
| 125 |
+
## Examples
|
| 126 |
+
|
| 127 |
+
<div align="center">
|
| 128 |
+
|
| 129 |
+
> *Prove that $1/4 + 1/16 + 1/64 + \dots = 1/3$ using a beautiful geometric method, elegant zooming, smooth camera movement, a slow pace, at least two minutes of duration, clear logic, a creamy yellow background, and a macaroon-inspired palette.*
|
| 130 |
+
|
| 131 |
+
<br>
|
| 132 |
+
|
| 133 |
+
<a href="https://github.com/user-attachments/assets/38dba3ba-e29f-458d-b8ea-baf10cade4f1">
|
| 134 |
+
<video src="https://github.com/user-attachments/assets/38dba3ba-e29f-458d-b8ea-baf10cade4f1" width="85%" autoplay loop muted playsinline>
|
| 135 |
+
</video>
|
| 136 |
+
</a>
|
| 137 |
+
|
| 138 |
+
<sub>▲ Generated with BGM · Geometric Series Proof · ManimCat</sub>
|
| 139 |
+
|
| 140 |
+
</div>
|
| 141 |
+
|
| 142 |
+
## Quick Start
|
| 143 |
+
|
| 144 |
+
```bash
|
| 145 |
+
npm install
|
| 146 |
+
cd frontend && npm install
|
| 147 |
+
cd ..
|
| 148 |
+
npm run dev
|
| 149 |
+
```
|
| 150 |
+
|
| 151 |
+
Open `http://localhost:3000`. For environment variables and deployment-specific setup, see the [deployment guide](https://github.com/Wing900/ManimCat/blob/main/DEPLOYMENT.md).
|
| 152 |
+
|
| 153 |
+
If you want a direct Docker deployment path, you can also start from the published image `wingflow/manimcat` instead of building locally first.
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
## Technology
|
| 157 |
+
|
| 158 |
+
### Tech Stack
|
| 159 |
+
|
| 160 |
+
- Product structure: Workflow mode for direct generation, Agent mode for Studio-based collaborative work
|
| 161 |
+
- Visual engines: Manim for animation, `matplotlib` for Plot Studio static figures
|
| 162 |
+
- Backend: Express + TypeScript, Bull + Redis, OpenAI-compatible upstream routing, Studio agent runtime, optional Supabase history storage
|
| 163 |
+
- Frontend: React 19, Vite, Tailwind CSS, classic generator UI, Studio workspace shell, Plot Studio minimal workspace UI
|
| 164 |
+
- Agent state model: session / run / task / work / result lifecycle for longer-lived studio interactions
|
| 165 |
+
- Realtime layer: polling for Workflow jobs, Server-Sent Events for Agent sessions, permission requests, and task updates
|
| 166 |
+
- Rendering runtime: Python, Manim Community Edition, `matplotlib`, LaTeX, `ffmpeg`
|
| 167 |
+
- Deployment: Docker / Docker Compose, Hugging Face Spaces
|
| 168 |
+
|
| 169 |
+
### Workflow Mode
|
| 170 |
+
|
| 171 |
+
```mermaid
|
| 172 |
+
flowchart LR
|
| 173 |
+
classDef ui fill:#F6F7FB,stroke:#455A64,color:#263238,stroke-width:1.2px;
|
| 174 |
+
classDef logic fill:#FFF8E1,stroke:#A1887F,color:#4E342E,stroke-width:1.2px;
|
| 175 |
+
classDef api fill:#E8F5E9,stroke:#5D8A66,color:#1B4332,stroke-width:1.2px;
|
| 176 |
+
classDef state fill:#E3F2FD,stroke:#5C6BC0,color:#1A237E,stroke-width:1.2px;
|
| 177 |
+
classDef output fill:#FCE4EC,stroke:#AD5C7D,color:#6A1B4D,stroke-width:1.2px;
|
| 178 |
+
|
| 179 |
+
U[User prompt] --> P1
|
| 180 |
+
P1[Classic UI] --> P2[Problem framing]
|
| 181 |
+
P1 --> P3[Generate / Modify requests]
|
| 182 |
+
P2 --> A1[Workflow APIs]
|
| 183 |
+
P3 --> A1
|
| 184 |
+
A1 --> R1[Upstream routing + AI generation]
|
| 185 |
+
R1 --> C1[Static checks + retry / patch loop]
|
| 186 |
+
C1 --> B1[Queue + job state]
|
| 187 |
+
B1 --> B2[Render pipeline]
|
| 188 |
+
B2 --> O1[Video / images / code / timings]
|
| 189 |
+
P1 -. polling / cancel .-> B1
|
| 190 |
+
O1 --> P1
|
| 191 |
+
|
| 192 |
+
class P1 ui;
|
| 193 |
+
class P2,P3,R1,C1 logic;
|
| 194 |
+
class A1 api;
|
| 195 |
+
class B1,B2 state;
|
| 196 |
+
class O1 output;
|
| 197 |
+
```
|
| 198 |
+
|
| 199 |
+
### Agent Mode
|
| 200 |
+
|
| 201 |
+
```mermaid
|
| 202 |
+
flowchart LR
|
| 203 |
+
classDef ui fill:#F6F7FB,stroke:#455A64,color:#263238,stroke-width:1.2px;
|
| 204 |
+
classDef runtime fill:#FFF8E1,stroke:#A1887F,color:#4E342E,stroke-width:1.2px;
|
| 205 |
+
classDef api fill:#E8F5E9,stroke:#5D8A66,color:#1B4332,stroke-width:1.2px;
|
| 206 |
+
classDef state fill:#E3F2FD,stroke:#5C6BC0,color:#1A237E,stroke-width:1.2px;
|
| 207 |
+
classDef event fill:#FCE4EC,stroke:#AD5C7D,color:#6A1B4D,stroke-width:1.2px;
|
| 208 |
+
|
| 209 |
+
U[User instruction] --> S1
|
| 210 |
+
S1[Studio UI] --> A1[Session / run APIs]
|
| 211 |
+
A1 --> R1[Studio runtime service]
|
| 212 |
+
R1 --> K1[Manim Studio / Plot Studio]
|
| 213 |
+
K1 --> G1[Builder, Designer, Reviewer]
|
| 214 |
+
G1 --> T1[Tools, skills, render / review actions]
|
| 215 |
+
R1 --> S2[Session / run / task / work state]
|
| 216 |
+
R1 --> E1[SSE events + permissions]
|
| 217 |
+
S2 --> S1
|
| 218 |
+
E1 --> S1
|
| 219 |
+
|
| 220 |
+
class S1 ui;
|
| 221 |
+
class A1 api;
|
| 222 |
+
class R1,K1 runtime;
|
| 223 |
+
class G1,T1,S2 state;
|
| 224 |
+
class E1 event;
|
| 225 |
+
```
|
| 226 |
+
|
| 227 |
+
For environment variables, deployment modes, and upstream-routing examples, see the [deployment guide](https://github.com/Wing900/ManimCat/blob/main/DEPLOYMENT.md).
|
| 228 |
+
|
| 229 |
+
## Deployment
|
| 230 |
+
|
| 231 |
+
Please see the [deployment guide](https://github.com/Wing900/ManimCat/blob/main/DEPLOYMENT.md).
|
| 232 |
+
|
| 233 |
+
## Major Additions
|
| 234 |
+
|
| 235 |
+
This project is a substantial rework built on top of the original foundation. The main additions I personally designed and implemented are:
|
| 236 |
+
|
| 237 |
+
### Generation and Rendering
|
| 238 |
+
|
| 239 |
+
- Added a dedicated image workflow alongside video generation
|
| 240 |
+
- Added `YON_IMAGE` anchor-based segmented rendering for multi-image outputs
|
| 241 |
+
- Added two-stage AI generation: a concept designer produces a scene design, then a code generator writes the Manim code
|
| 242 |
+
- Added a static analysis guard (`py_compile` + `mypy`) that checks generated code before rendering, with AI-powered auto-patching for up to 3 passes
|
| 243 |
+
- Added AI-driven code retry: when a render fails, the error is fed back to the model to regenerate and re-render automatically
|
| 244 |
+
- Added rerender-from-code and AI-assisted modify-and-render flows
|
| 245 |
+
- Added stage timing breakdown shared by both image and video jobs
|
| 246 |
+
- Added background music mixing for rendered videos
|
| 247 |
+
- Added render-failure event collection and export for debugging and reliability work
|
| 248 |
+
|
| 249 |
+
### Product and Interface
|
| 250 |
+
|
| 251 |
+
- Rebuilt the frontend as a separate React + TypeScript + Vite application
|
| 252 |
+
- Added problem framing before generation to help structure user requests
|
| 253 |
+
- Added reference image upload support
|
| 254 |
+
- Added a unified workspace for generation history and usage views
|
| 255 |
+
- Added a usage metrics dashboard with daily charts, success rates, and timing breakdowns
|
| 256 |
+
- Added a prompt template manager for viewing and overriding system prompts per role
|
| 257 |
+
- Added dark / light theme toggle
|
| 258 |
+
- Added a waiting-state 2048 mini-game
|
| 259 |
+
- Added a refreshed visual style, settings panels, and provider configuration flows
|
| 260 |
+
|
| 261 |
+
### Infrastructure and Routing
|
| 262 |
+
|
| 263 |
+
- Reworked the backend around Express + Bull + Redis
|
| 264 |
+
- Added retry, timeout, cancellation, and status-query flows
|
| 265 |
+
- Added support for third-party OpenAI-compatible APIs and custom provider configuration
|
| 266 |
+
- Added server-side upstream routing by ManimCat key
|
| 267 |
+
- Kept optional multi-profile frontend provider rotation for local use
|
| 268 |
+
- Added optional Supabase-backed persistent history storage
|
| 269 |
+
|
| 270 |
+
### Studio Agent
|
| 271 |
+
|
| 272 |
+
- Added a separate Agent Mode driven by a Studio runtime rather than the classic one-shot generation flow
|
| 273 |
+
- Defined the long-lived Studio state model around session / run / task / work / result
|
| 274 |
+
- Added builder, designer, and reviewer roles inside the Studio agent system
|
| 275 |
+
- Added workspace tools, render tools, local skills, and subagent orchestration
|
| 276 |
+
- Added Server-Sent Events for live Studio updates plus permission request / reply handling
|
| 277 |
+
- Added Studio review, pipeline, work, and permission panels in the frontend
|
| 278 |
+
|
| 279 |
+
### Plot Studio and Manim Studio
|
| 280 |
+
|
| 281 |
+
- Added two distinct Studio workspaces: Plot Studio for `matplotlib`-based static visuals and Manim Studio for animation-oriented workflows
|
| 282 |
+
- Added Plot Studio output history browsing, work reordering, and a minimal split workspace layout
|
| 283 |
+
- Established the dual-engine product direction: Manim for animated mathematical storytelling, `matplotlib` for static teaching figures and charts
|
| 284 |
+
|
| 285 |
+
## License and Copyright
|
| 286 |
+
|
| 287 |
+
Licensing details are defined in `LICENSE_POLICY.md` (Chinese) and `LICENSE_POLICY.en.md` (English).
|
| 288 |
+
|
| 289 |
+
- Third-party attribution and notices: `THIRD_PARTY_NOTICES.md`
|
| 290 |
+
- Chinese third-party notices: `THIRD_PARTY_NOTICES.zh-CN.md`
|
| 291 |
+
- Contribution agreement: `CLA.md`
|
| 292 |
+
- Contribution guide: `CONTRIBUTING.md`
|
| 293 |
+
|
| 294 |
+
### CLA Intent Statement
|
| 295 |
+
|
| 296 |
+
This project uses a CLA so the maintainer can keep commercial-use authorization under a single workflow, instead of requiring separate consent from every contributor each time.
|
| 297 |
+
|
| 298 |
+
This is not a statement of "project commercialization" as a company path. The maintainer remains a developer, the project stays open source, and the project stance is anti-monopoly.
|
| 299 |
+
|
| 300 |
+
Any commercial authorization income is intended to be reinvested into project development itself, including support for major contributors and community activities. For small companies that are open-source-friendly, authorization terms and fees are expected to be symbolic.
|
| 301 |
+
|
| 302 |
+
## Maintenance Notes
|
| 303 |
+
|
| 304 |
+
Because my time is limited and I am an independent hobbyist rather than a full-time professional maintainer, I currently cannot provide fast review cycles or long-term maintenance for external contributions. Pull requests are welcome, but review may take time.
|
| 305 |
+
|
| 306 |
+
If you have good suggestions or discover a bug, feel free to open an Issue for discussion. I will improve the project at my own pace. If you want to make large-scale changes on top of this work, you are also welcome to fork it and build your own version.
|
| 307 |
+
|
| 308 |
+
If this project gave you useful ideas or helped you in some way, that is already an honor for me.
|
| 309 |
+
|
| 310 |
+
<details>
|
| 311 |
+
<summary><b>If you like this project, you can also buy the author a Coke 🥤</b></summary>
|
| 312 |
+
<br />
|
| 313 |
+
<p>Mainland China:</p>
|
| 314 |
+
<img src="https://github.com/user-attachments/assets/09fd8c5f-9644-4c02-97c1-61f10b0fdd1f" width="360" alt="Support ManimCat" />
|
| 315 |
+
<br />
|
| 316 |
+
<p>International:</p>
|
| 317 |
+
<a href="https://afdian.com/a/wingflow/plan" target="_blank">
|
| 318 |
+
<img src="https://img.shields.io/badge/Support-Aifadian-635cff?style=for-the-badge&logo=shopee&logoColor=white" alt="Support on Aifadian" />
|
| 319 |
+
</a>
|
| 320 |
+
<p><i>Thank you. Your support gives me more energy to keep maintaining the project.</i></p>
|
| 321 |
+
</details>
|
| 322 |
+
|
| 323 |
+
## Star History
|
| 324 |
+
|
| 325 |
+
<a href="https://www.star-history.com/?repos=Wing900%2FManimCat&type=date&legend=top-left">
|
| 326 |
+
<picture>
|
| 327 |
+
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/image?repos=Wing900/ManimCat&type=date&theme=dark&legend=top-left" />
|
| 328 |
+
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/image?repos=Wing900/ManimCat&type=date&legend=top-left" />
|
| 329 |
+
<img alt="Star History Chart" src="https://api.star-history.com/image?repos=Wing900/ManimCat&type=date&legend=top-left" />
|
| 330 |
+
</picture>
|
| 331 |
+
</a>
|
| 332 |
+
|
| 333 |
+
## Acknowledgements
|
| 334 |
+
|
| 335 |
+
- [rohitg00/manim-video-generator](https://github.com/rohitg00/manim-video-generator)
|
| 336 |
+
- [anomalyco/opencode](https://github.com/anomalyco/opencode)
|
| 337 |
+
- [Linux.do](https://linux.do)
|
| 338 |
+
- [Alibaba Cloud Bailian](https://bailian.console.aliyun.com)
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
|
README.zh-CN.md
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
简体中文 | [English](https://github.com/Wing900/ManimCat/blob/main/README.md)
|
| 2 |
+
|
| 3 |
+
<div align="center">
|
| 4 |
+
|
| 5 |
+
<!-- 顶部装饰线 - 统一为深灰色调 -->
|
| 6 |
+
<img width="100%" src="https://capsule-render.vercel.app/api?type=waving&color=455A64&height=120§ion=header" />
|
| 7 |
+
|
| 8 |
+
<br>
|
| 9 |
+
|
| 10 |
+
<img src="public/logo.svg" width="200" alt="ManimCat Logo" />
|
| 11 |
+
|
| 12 |
+
<!-- 装饰:猫咪足迹 -->
|
| 13 |
+
<div style="opacity: 0.3; margin: 20px 0;">
|
| 14 |
+
<img src="https://raw.githubusercontent.com/Tarikul-Islam-Anik/Animated-Fluent-Emojis/master/Emojis/Animals/Paw%20Prints.png" width="40" alt="paws" />
|
| 15 |
+
</div>
|
| 16 |
+
|
| 17 |
+
<h1>
|
| 18 |
+
<picture>
|
| 19 |
+
<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" />
|
| 20 |
+
</picture>
|
| 21 |
+
</h1>
|
| 22 |
+
|
| 23 |
+
<!-- 装饰:数学符号分隔 -->
|
| 24 |
+
<p align="center">
|
| 25 |
+
<span style="font-family: monospace; font-size: 24px; color: #90A4AE;">
|
| 26 |
+
∫ ∑ ∂ ∞
|
| 27 |
+
</span>
|
| 28 |
+
</p>
|
| 29 |
+
|
| 30 |
+
<p align="center">
|
| 31 |
+
<strong>面向数学可视化创作的双模式 AI 工作台</strong>
|
| 32 |
+
</p>
|
| 33 |
+
|
| 34 |
+
<p align="center">
|
| 35 |
+
同时支持直接生成工作流与基于 Agent 的 Studio 协作,并由 Manim 与 matplotlib 双引擎支撑
|
| 36 |
+
</p>
|
| 37 |
+
|
| 38 |
+
<!-- 装饰:几何点阵分隔 -->
|
| 39 |
+
<div style="margin: 30px 0;">
|
| 40 |
+
<span style="color: #CFD8DC; font-size: 20px;">◆ ◆ ◆</span>
|
| 41 |
+
</div>
|
| 42 |
+
|
| 43 |
+
<p align="center">
|
| 44 |
+
<img src="https://img.shields.io/badge/ManimCE-0.19.2-455A64?style=for-the-badge&logo=python&logoColor=white" alt="ManimCE" />
|
| 45 |
+
<img src="https://img.shields.io/badge/React-19.2.0-455A64?style=for-the-badge&logo=react&logoColor=white" alt="React" />
|
| 46 |
+
<img src="https://img.shields.io/badge/Node.js-18+-455A64?style=for-the-badge&logo=node.js&logoColor=white" alt="Node.js" />
|
| 47 |
+
<img src="https://img.shields.io/badge/License-Mixed-607D8B?style=for-the-badge" alt="License" />
|
| 48 |
+
</p>
|
| 49 |
+
|
| 50 |
+
<p align="center" style="font-size: 18px;">
|
| 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 |
+
<a href="#在原项目基础上的主要扩展"><strong>主要扩展</strong></a> •
|
| 57 |
+
<a href="#开源与版权声明"><strong>版权</strong></a> •
|
| 58 |
+
<a href="#维护说明"><strong>维护</strong></a>
|
| 59 |
+
</p>
|
| 60 |
+
|
| 61 |
+
<br>
|
| 62 |
+
|
| 63 |
+
<!-- 底部装饰线 - 统一为深灰色调 -->
|
| 64 |
+
<img width="100%" src="https://capsule-render.vercel.app/api?type=waving&color=455A64&height=100§ion=footer" />
|
| 65 |
+
|
| 66 |
+
</div>
|
| 67 |
+
|
| 68 |
+
<br>
|
| 69 |
+
|
| 70 |
+
## 项目简介
|
| 71 |
+
|
| 72 |
+
很荣幸在这里介绍我的新项目ManimCat,它是~一只猫~
|
| 73 |
+
|
| 74 |
+
本项目基于 [manim-video-generator](https://github.com/rohitg00/manim-video-generator) 进行了大幅重构与再开发,现在已经不只是单一生成流程,而是一个更完整的 AI 数学教学可视化创作系统。
|
| 75 |
+
|
| 76 |
+
它面向课堂讲解、例题拆解与数形结合表达等场景,用户可以通过自然语言生成、修改、重渲染并组织动画与静态两类教学可视化内容,支持 `video` 与 `image` 两种输出。
|
| 77 |
+
|
| 78 |
+
项目现在可以从三个维度理解:`双模式`、`双引擎`、`双 Studio`。
|
| 79 |
+
|
| 80 |
+
- `Workflow Mode` 用于直接生成与渲染,适合快速产出
|
| 81 |
+
- `Agent Mode` 用于基于 Studio 的协作式创作、审阅、任务跟踪与迭代
|
| 82 |
+
- `Manim` 负责动画、镜头与时间线驱动的数学叙事
|
| 83 |
+
- `matplotlib` 负责 Plot Studio 中的静态数学图像、函数图、图表与教学插图
|
| 84 |
+
- `Plot Studio` 是目前更成熟的 Studio 路径,主要面向静态可视化与迭代编辑
|
| 85 |
+
- `Manim Studio` 面向动画创作,但目前仍处于相对更早期的阶段
|
| 86 |
+
|
| 87 |
+
### 界面
|
| 88 |
+
|
| 89 |
+
#### UI 界面
|
| 90 |
+
|
| 91 |
+
<div align="center">
|
| 92 |
+
<img src="https://github.com/user-attachments/assets/5abd29f6-adcb-4047-b85c-aba1fa0808a5" width="40%" alt="ManimCat UI 界面截图 1" />
|
| 93 |
+
<img src="https://github.com/user-attachments/assets/d18d0f27-15b4-4c59-8a4b-a8b8fb553020" width="40%" alt="ManimCat UI 界面截图 2" />
|
| 94 |
+
</div>
|
| 95 |
+
|
| 96 |
+
<div align="center">
|
| 97 |
+
<img src="https://github.com/user-attachments/assets/2c70886f-c381-4995-8ca4-2d0db1974829" width="40%" alt="ManimCat UI 界面截图 3" />
|
| 98 |
+
<img src="https://github.com/user-attachments/assets/ce3718e8-4bc4-44db-87fb-3fb486eed144" width="40%" alt="ManimCat UI 界面截图 4" />
|
| 99 |
+
</div>
|
| 100 |
+
|
| 101 |
+
#### Workflow 界面
|
| 102 |
+
|
| 103 |
+
<div align="center">
|
| 104 |
+
<img src="https://github.com/user-attachments/assets/b831caaf-10fe-4238-8998-d574f42af524" width="46%" alt="ManimCat Workflow 界面截图 1" />
|
| 105 |
+
<img src="https://github.com/user-attachments/assets/30583571-f038-4c7b-9362-c988a896d374" width="46%" alt="ManimCat Workflow 界面截图 2" />
|
| 106 |
+
</div>
|
| 107 |
+
|
| 108 |
+
#### Plot Studio 界面
|
| 109 |
+
|
| 110 |
+
<div align="center">
|
| 111 |
+
<img src="https://github.com/user-attachments/assets/0812b601-b896-4137-8e20-a2d4b6feadb9" width="46%" alt="ManimCat Plot Studio 界面���图 1" />
|
| 112 |
+
<img src="https://github.com/user-attachments/assets/99ae423f-4b15-431d-8e21-30a6b6171616" width="46%" alt="ManimCat Plot Studio 界面截图 2" />
|
| 113 |
+
</div>
|
| 114 |
+
|
| 115 |
+
## 样例
|
| 116 |
+
|
| 117 |
+
<div align="center">
|
| 118 |
+
|
| 119 |
+
> *$1/4 + 1/16 + 1/64 + \dots = 1/3$,证明这个等式,美丽的图形方法,优雅的缩放平稳镜头移动,慢节奏,至少两分钟,逻辑清晰,奶黄色背景,马卡龙色系*
|
| 120 |
+
|
| 121 |
+
<br>
|
| 122 |
+
|
| 123 |
+
<a href="https://github.com/user-attachments/assets/38dba3ba-e29f-458d-b8ea-baf10cade4f1">
|
| 124 |
+
<video src="https://github.com/user-attachments/assets/38dba3ba-e29f-458d-b8ea-baf10cade4f1" width="85%" autoplay loop muted playsinline>
|
| 125 |
+
</video>
|
| 126 |
+
</a>
|
| 127 |
+
|
| 128 |
+
<sub>▲ 含背景音乐 · 几何级数证明 · ManimCat 生成</sub>
|
| 129 |
+
|
| 130 |
+
</div>
|
| 131 |
+
|
| 132 |
+
## 快速开始
|
| 133 |
+
|
| 134 |
+
```bash
|
| 135 |
+
npm install
|
| 136 |
+
cd frontend && npm install
|
| 137 |
+
cd ..
|
| 138 |
+
npm run dev
|
| 139 |
+
```
|
| 140 |
+
|
| 141 |
+
访问 `http://localhost:3000`。环境变量、部署方式以及上游路由示例请查看[部署文档](https://github.com/Wing900/ManimCat/blob/main/DEPLOYMENT.zh-CN.md)。
|
| 142 |
+
|
| 143 |
+
如果你直接用 Docker 镜像部署,也可以从 `wingflow/manimcat` 开始,而不是自行本地构建。
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
## 技术
|
| 147 |
+
|
| 148 |
+
### 技术栈
|
| 149 |
+
|
| 150 |
+
- 产品结构:Workflow 模式用于直接生成,Agent 模式用于 Studio 协作式工作流
|
| 151 |
+
- 图形引擎:Manim 用于动画,`matplotlib` 用于 Plot Studio 静态图像
|
| 152 |
+
- 后端:Express + TypeScript、Bull + Redis、兼容 OpenAI 的上游路由、Studio Agent 运行时、可选 Supabase 历史记录
|
| 153 |
+
- 前端:React 19、Vite、Tailwind CSS、经典生成界面、Studio 工作台界面、Plot Studio 极简工作区 UI
|
| 154 |
+
- Agent 状态模型:围绕 session / run / task / work / result 组织长生命周期 Studio 交互
|
| 155 |
+
- 实时层:Workflow 任务使用轮询,Agent 会话使用 Server-Sent Events 推送事件、权限请求与任务更新
|
| 156 |
+
- 渲染运行时:Python、Manim Community Edition、`matplotlib`、LaTeX、`ffmpeg`
|
| 157 |
+
- 部署:Docker / Docker Compose、Hugging Face Spaces
|
| 158 |
+
|
| 159 |
+
### Workflow Mode
|
| 160 |
+
|
| 161 |
+
```mermaid
|
| 162 |
+
flowchart LR
|
| 163 |
+
classDef ui fill:#F6F7FB,stroke:#455A64,color:#263238,stroke-width:1.2px;
|
| 164 |
+
classDef logic fill:#FFF8E1,stroke:#A1887F,color:#4E342E,stroke-width:1.2px;
|
| 165 |
+
classDef api fill:#E8F5E9,stroke:#5D8A66,color:#1B4332,stroke-width:1.2px;
|
| 166 |
+
classDef state fill:#E3F2FD,stroke:#5C6BC0,color:#1A237E,stroke-width:1.2px;
|
| 167 |
+
classDef output fill:#FCE4EC,stroke:#AD5C7D,color:#6A1B4D,stroke-width:1.2px;
|
| 168 |
+
|
| 169 |
+
U[用户输入] --> P1
|
| 170 |
+
P1[经典生成界面] --> P2[问题规划]
|
| 171 |
+
P1 --> P3[生成 / 修改请求]
|
| 172 |
+
P2 --> A1[Workflow API]
|
| 173 |
+
P3 --> A1
|
| 174 |
+
A1 --> R1[上游路由 + AI 生成]
|
| 175 |
+
R1 --> C1[静态检查 + 重试 / 修补循环]
|
| 176 |
+
C1 --> B1[队列 + 任务状态]
|
| 177 |
+
B1 --> B2[渲染流水线]
|
| 178 |
+
B2 --> O1[视频 / 图片 / 代码 / 耗时]
|
| 179 |
+
P1 -. 轮询 / 取消 .-> B1
|
| 180 |
+
O1 --> P1
|
| 181 |
+
|
| 182 |
+
class P1 ui;
|
| 183 |
+
class P2,P3,R1,C1 logic;
|
| 184 |
+
class A1 api;
|
| 185 |
+
class B1,B2 state;
|
| 186 |
+
class O1 output;
|
| 187 |
+
```
|
| 188 |
+
|
| 189 |
+
### Agent Mode
|
| 190 |
+
|
| 191 |
+
```mermaid
|
| 192 |
+
flowchart LR
|
| 193 |
+
classDef ui fill:#F6F7FB,stroke:#455A64,color:#263238,stroke-width:1.2px;
|
| 194 |
+
classDef runtime fill:#FFF8E1,stroke:#A1887F,color:#4E342E,stroke-width:1.2px;
|
| 195 |
+
classDef api fill:#E8F5E9,stroke:#5D8A66,color:#1B4332,stroke-width:1.2px;
|
| 196 |
+
classDef state fill:#E3F2FD,stroke:#5C6BC0,color:#1A237E,stroke-width:1.2px;
|
| 197 |
+
classDef event fill:#FCE4EC,stroke:#AD5C7D,color:#6A1B4D,stroke-width:1.2px;
|
| 198 |
+
|
| 199 |
+
U[用户指令] --> S1
|
| 200 |
+
S1[Studio 界面] --> A1[Session / Run API]
|
| 201 |
+
A1 --> R1[Studio Runtime Service]
|
| 202 |
+
R1 --> K1[Manim Studio / Plot Studio]
|
| 203 |
+
K1 --> G1[Builder、Designer、Reviewer]
|
| 204 |
+
G1 --> T1[工具、skills、渲染 / 审查动作]
|
| 205 |
+
R1 --> S2[Session / Run / Task / Work 状态]
|
| 206 |
+
R1 --> E1[SSE 实时事件 + 权限]
|
| 207 |
+
S2 --> S1
|
| 208 |
+
E1 --> S1
|
| 209 |
+
|
| 210 |
+
class S1 ui;
|
| 211 |
+
class A1 api;
|
| 212 |
+
class R1,K1 runtime;
|
| 213 |
+
class G1,T1,S2 state;
|
| 214 |
+
class E1 event;
|
| 215 |
+
```
|
| 216 |
+
|
| 217 |
+
## 部署
|
| 218 |
+
|
| 219 |
+
请查看[部署文档](https://github.com/Wing900/ManimCat/blob/main/DEPLOYMENT.zh-CN.md)。
|
| 220 |
+
|
| 221 |
+
## 在原项目基础上的主要扩展
|
| 222 |
+
|
| 223 |
+
这个项目是在原始基础上做的大幅重构与再开发。以下是我本人新增和重构的核心能力:
|
| 224 |
+
|
| 225 |
+
### 生成与渲染
|
| 226 |
+
|
| 227 |
+
- 在视频生成之外,新增了独立的图片工作流
|
| 228 |
+
- 新增 `YON_IMAGE` 锚点分块渲染,支持多图输出
|
| 229 |
+
- 新增两阶段 AI 生成架构:概念设计者生成场景方案,再由代码生成者产出 Manim 代码
|
| 230 |
+
- 新增静态检查守卫(`py_compile` + `mypy`),渲染前自动检查生成代码,并由 AI 自动修补,最多循环 3 轮
|
| 231 |
+
- 新增 AI 驱动的代码重试:渲染失败后自动将错误反馈给模型,重新生成并再次渲染
|
| 232 |
+
- 新增基于现有代码的重渲染,以及 AI 辅助修改后再渲染
|
| 233 |
+
- 新增图片与视频共用的阶段耗时统计
|
| 234 |
+
- 新增视频背景音乐自动混入
|
| 235 |
+
- 新增渲染失败事件采集与导出能力,便于排错和稳定性改进
|
| 236 |
+
|
| 237 |
+
### 产品与界面
|
| 238 |
+
|
| 239 |
+
- 将前端重建为独立的 React + TypeScript + Vite 应用
|
| 240 |
+
- 新增生成前的问题规划能力,帮助整理用户请求
|
| 241 |
+
- 新增参考图上传能力
|
| 242 |
+
- 新增统一的工作空间页面,整合生成历史与用量视图
|
| 243 |
+
- 新增用量仪表盘,提供每日调用量、成功率与耗时趋势图表
|
| 244 |
+
- 新增提示词模板管理,可按角色查看和覆盖系统提示词
|
| 245 |
+
- 新增暗色 / 亮色主题切换
|
| 246 |
+
- 新增长等待过程中的 2048 小游戏
|
| 247 |
+
- 新增整体视觉风格、设置面板与 provider 配置流程
|
| 248 |
+
|
| 249 |
+
### 基础设施与路由
|
| 250 |
+
|
| 251 |
+
- 将后端重构为 Express + Bull + Redis 架构
|
| 252 |
+
- 新增重试、超时、取消与状态查询链路
|
| 253 |
+
- 新增对第三方 OpenAI-compatible API 与自定义 provider 的支持
|
| 254 |
+
- 新增按 ManimCat key 的服务端上游路由能力
|
| 255 |
+
- 保留并扩展前端多 profile provider 轮询能力,便于本地使用
|
| 256 |
+
- 新增可选的 Supabase 持久化历史记录存储
|
| 257 |
+
|
| 258 |
+
### Studio Agent
|
| 259 |
+
|
| 260 |
+
- 新增独立的 Agent Mode,它不再只是经典生成流程的附属页面,而是单独的 Studio runtime 工作模式
|
| 261 |
+
- 定义了围绕 session、run、task、work、result 的长生命周期 Studio 状态模型
|
| 262 |
+
- 新增 builder、designer、reviewer 三种 Studio agent 角色
|
| 263 |
+
- 新增工作区工具、渲染工具、本地 skills 与子代理编排能力
|
| 264 |
+
- 新增基于 Server-Sent Events 的 Studio 实时更新,以及权限请求 / 回复链路
|
| 265 |
+
- 新增 Studio 前端中的 review、pipeline、work、permission 等面板
|
| 266 |
+
|
| 267 |
+
### Plot Studio 与 Manim Studio
|
| 268 |
+
|
| 269 |
+
- 新增两个明确分化的 Studio 工作区:Plot Studio 面向 `matplotlib` 静态可视化,Manim Studio 面向动画工作流
|
| 270 |
+
- 新增 Plot Studio 的历史产物浏览、工作项重排与极简分栏工作区布局
|
| 271 |
+
- 明确形成双引擎产品方向:Manim 负责动态数学叙事,`matplotlib` 负责静态教学图像与图表
|
| 272 |
+
|
| 273 |
+
## 开源与版权声明
|
| 274 |
+
|
| 275 |
+
本项目授权细则见 `LICENSE_POLICY.md`。
|
| 276 |
+
|
| 277 |
+
* 第三方来源与归属说明见 `THIRD_PARTY_NOTICES.md`。
|
| 278 |
+
* 中文版第三方来源与归属说明见 `THIRD_PARTY_NOTICES.zh-CN.md`。
|
| 279 |
+
* 贡献者许可协议见 `CLA.md`。
|
| 280 |
+
* 贡献说明见 `CONTRIBUTING.md`。
|
| 281 |
+
|
| 282 |
+
### CLA 目的说明
|
| 283 |
+
|
| 284 |
+
本项目引入 CLA,是为了让维护者能够集中管理项目的商业使用授权,避免每次商业授权都需要逐一联系所有贡献者。
|
| 285 |
+
|
| 286 |
+
这不代表项目会走向公司化商业运营。维护者始终以开发者身份维护项目,不以成为 ManimCat 公司的老板或经理为目标。项目将长期保持开源立场,反对垄断。
|
| 287 |
+
|
| 288 |
+
若存在商业授权收入,资金将优先回馈项目本身,包括对项目基础建设、文档与测试完善、社区活动组织,以及对作出重大贡献开发者的支持。对于乐于开源、愿意回馈社区的小型商业公司,授权条款与费用将以象征性为主。
|
| 289 |
+
|
| 290 |
+
## 维护说明
|
| 291 |
+
|
| 292 |
+
由于作者精力有限(个人业余兴趣开发者,非专业背景),目前完全无法对外部代码进行有效的审查和长期维护。因此,本项目欢迎 PR,不过代码审查周期长。感谢理解。
|
| 293 |
+
|
| 294 |
+
如果你有好的建议或发现了 Bug,欢迎提交 Issue 进行讨论,我会根据自己的节奏进行改进。如果你希望在本项目基础上进行大规模修改,欢迎 Fork 出属于你自己的版本。
|
| 295 |
+
|
| 296 |
+
如果你觉得有启发与帮助,那是我的荣幸。
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
<details>
|
| 300 |
+
<summary><b>如果你觉得这个作品很好,也欢迎请作者喝可乐🥤</b></summary>
|
| 301 |
+
<br />
|
| 302 |
+
<p>中国大陆用户:</p>
|
| 303 |
+
<img src="https://github.com/user-attachments/assets/09fd8c5f-9644-4c02-97c1-61f10b0fdd1f" width="360" alt="赞助 ManimCat" />
|
| 304 |
+
<br />
|
| 305 |
+
<p>海外用户:</p>
|
| 306 |
+
<a href="https://afdian.com/a/wingflow/plan" target="_blank">
|
| 307 |
+
<img src="https://img.shields.io/badge/赞助-爱发电-635cff?style=for-the-badge&logo=shopee&logoColor=white" alt="爱发电赞助" />
|
| 308 |
+
</a>
|
| 309 |
+
<p><i>感谢你的支持,我会更有动力维护这个项目!</i></p>
|
| 310 |
+
</details>
|
| 311 |
+
|
| 312 |
+
## Star History
|
| 313 |
+
|
| 314 |
+
<a href="https://www.star-history.com/?repos=Wing900%2FManimCat&type=date&legend=top-left">
|
| 315 |
+
<picture>
|
| 316 |
+
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/image?repos=Wing900/ManimCat&type=date&theme=dark&legend=top-left" />
|
| 317 |
+
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/image?repos=Wing900/ManimCat&type=date&legend=top-left" />
|
| 318 |
+
<img alt="Star History Chart" src="https://api.star-history.com/image?repos=Wing900/ManimCat&type=date&legend=top-left" />
|
| 319 |
+
</picture>
|
| 320 |
+
</a>
|
| 321 |
+
|
| 322 |
+
## 致谢
|
| 323 |
+
|
| 324 |
+
- [rohitg00/manim-video-generator](https://github.com/rohitg00/manim-video-generator)
|
| 325 |
+
- [anomalyco/opencode](https://github.com/anomalyco/opencode)
|
| 326 |
+
- [Linux.do](https://linux.do)
|
| 327 |
+
- [阿里云百炼](https://bailian.console.aliyun.com)
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
|
THIRD_PARTY_NOTICES.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Third-Party Notices
|
| 2 |
+
|
| 3 |
+
This project (`ManimCat`) references and builds on ideas and portions of code from:
|
| 4 |
+
|
| 5 |
+
- `manim-video-generator` by Rohit Ghumare
|
| 6 |
+
Repository: https://github.com/rohitg00/manim-video-generator
|
| 7 |
+
License: MIT
|
| 8 |
+
|
| 9 |
+
## Notice Scope
|
| 10 |
+
|
| 11 |
+
- Any inherited or derivative portions from upstream remain subject to the upstream MIT license terms.
|
| 12 |
+
- This repository is distributed under MIT (`LICENSE`).
|
| 13 |
+
- Copyright for original contributions made in this repository remains with their respective authors.
|
| 14 |
+
|
| 15 |
+
## Clarification
|
| 16 |
+
|
| 17 |
+
If you need proprietary licensing for deliverables not included in this repository, contact the author for a separate written agreement.
|
| 18 |
+
|
THIRD_PARTY_NOTICES.zh-CN.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 第三方声明
|
| 2 |
+
|
| 3 |
+
本项目(`ManimCat`)参考并在部分代码层面基于以下项目:
|
| 4 |
+
|
| 5 |
+
- `manim-video-generator`(作者:Rohit Ghumare)
|
| 6 |
+
仓库:https://github.com/rohitg00/manim-video-generator
|
| 7 |
+
许可证:MIT
|
| 8 |
+
|
| 9 |
+
## 声明范围
|
| 10 |
+
|
| 11 |
+
- 继承或衍生自上游的代码部分,继续受上游 MIT 许可证约束。
|
| 12 |
+
- 本仓库以 MIT 协议发布(见 `LICENSE`)。
|
| 13 |
+
- 本仓库中的原创贡献,其著作权归各自作者所有。
|
| 14 |
+
|
| 15 |
+
## 补充说明
|
| 16 |
+
|
| 17 |
+
若你需要对“未包含在本仓库中的交付物”进行专有商业授权,请与作者另行书面协商。
|
| 18 |
+
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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: 600s
|
| 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 |
+
- LOG_LEVEL=${LOG_LEVEL:-info}
|
| 39 |
+
- PROD_SUMMARY_LOG_ONLY=${PROD_SUMMARY_LOG_ONLY:-true}
|
| 40 |
+
- HTTP_PROXY=${HTTP_PROXY:-}
|
| 41 |
+
- HTTPS_PROXY=${HTTPS_PROXY:-}
|
| 42 |
+
- NO_PROXY=${NO_PROXY:-}
|
| 43 |
+
- http_proxy=${http_proxy:-}
|
| 44 |
+
- https_proxy=${https_proxy:-}
|
| 45 |
+
- no_proxy=${no_proxy:-}
|
| 46 |
+
- REDIS_HOST=redis
|
| 47 |
+
- REDIS_PORT=6379
|
| 48 |
+
- REDIS_DB=${REDIS_DB:-0}
|
| 49 |
+
# Upstream routing (required for server-side generation unless frontend passes customApiConfig)
|
| 50 |
+
- MANIMCAT_ROUTE_KEYS=${MANIMCAT_ROUTE_KEYS:-}
|
| 51 |
+
- MANIMCAT_ROUTE_API_URLS=${MANIMCAT_ROUTE_API_URLS:-}
|
| 52 |
+
- MANIMCAT_ROUTE_API_KEYS=${MANIMCAT_ROUTE_API_KEYS:-}
|
| 53 |
+
- MANIMCAT_ROUTE_MODELS=${MANIMCAT_ROUTE_MODELS:-}
|
| 54 |
+
- DISPLAY=:99
|
| 55 |
+
volumes:
|
| 56 |
+
# Persist studio workspace sessions, generated code, and studio outputs
|
| 57 |
+
- studio-workspace-data:/app/.studio-workspace
|
| 58 |
+
# Persist generated images and uploaded reference images
|
| 59 |
+
- image-storage:/app/public/images
|
| 60 |
+
# Persist generated videos
|
| 61 |
+
- video-storage:/app/public/videos
|
| 62 |
+
# Persist Manim media cache and intermediate render artifacts
|
| 63 |
+
- manim-media:/app/media
|
| 64 |
+
# Temp directory for rendering
|
| 65 |
+
- manim-tmp:/app/tmp
|
| 66 |
+
depends_on:
|
| 67 |
+
redis:
|
| 68 |
+
condition: service_healthy
|
| 69 |
+
restart: unless-stopped
|
| 70 |
+
healthcheck:
|
| 71 |
+
test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))\""]
|
| 72 |
+
interval: 30s
|
| 73 |
+
timeout: 600s
|
| 74 |
+
retries: 3
|
| 75 |
+
start_period: 40s
|
| 76 |
+
networks:
|
| 77 |
+
- manimcat-network
|
| 78 |
+
# Resource limits (adjust based on your needs)
|
| 79 |
+
deploy:
|
| 80 |
+
resources:
|
| 81 |
+
limits:
|
| 82 |
+
cpus: '2'
|
| 83 |
+
memory: 4G
|
| 84 |
+
reservations:
|
| 85 |
+
cpus: '1'
|
| 86 |
+
memory: 2G
|
| 87 |
+
|
| 88 |
+
networks:
|
| 89 |
+
manimcat-network:
|
| 90 |
+
driver: bridge
|
| 91 |
+
|
| 92 |
+
volumes:
|
| 93 |
+
studio-workspace-data:
|
| 94 |
+
driver: local
|
| 95 |
+
image-storage:
|
| 96 |
+
driver: local
|
| 97 |
+
manim-tmp:
|
| 98 |
+
driver: local
|
| 99 |
+
manim-media:
|
| 100 |
+
driver: local
|
| 101 |
+
redis-data:
|
| 102 |
+
driver: local
|
| 103 |
+
video-storage:
|
| 104 |
+
driver: local
|
| 105 |
+
|
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="en">
|
| 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,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
"test": "vitest run",
|
| 12 |
+
"test:watch": "vitest"
|
| 13 |
+
},
|
| 14 |
+
"dependencies": {
|
| 15 |
+
"@types/react-syntax-highlighter": "^15.5.13",
|
| 16 |
+
"jszip": "^3.10.1",
|
| 17 |
+
"katex": "^0.16.42",
|
| 18 |
+
"perfect-freehand": "^1.2.3",
|
| 19 |
+
"react": "^19.2.0",
|
| 20 |
+
"react-dom": "^19.2.0",
|
| 21 |
+
"react-markdown": "^10.1.0",
|
| 22 |
+
"react-syntax-highlighter": "^16.1.0",
|
| 23 |
+
"rehype-katex": "^7.0.1",
|
| 24 |
+
"remark-gfm": "^4.0.1",
|
| 25 |
+
"remark-math": "^6.0.0"
|
| 26 |
+
},
|
| 27 |
+
"devDependencies": {
|
| 28 |
+
"@eslint/js": "^9.39.1",
|
| 29 |
+
"@testing-library/jest-dom": "^6.9.1",
|
| 30 |
+
"@testing-library/react": "^16.3.2",
|
| 31 |
+
"@types/node": "^24.10.1",
|
| 32 |
+
"@types/react": "^19.2.5",
|
| 33 |
+
"@types/react-dom": "^19.2.3",
|
| 34 |
+
"@vitejs/plugin-react": "^5.1.1",
|
| 35 |
+
"@vitest/coverage-v8": "^4.1.0",
|
| 36 |
+
"autoprefixer": "^10.4.23",
|
| 37 |
+
"eslint": "^9.39.1",
|
| 38 |
+
"eslint-plugin-react-hooks": "^7.0.1",
|
| 39 |
+
"eslint-plugin-react-refresh": "^0.4.24",
|
| 40 |
+
"globals": "^16.5.0",
|
| 41 |
+
"jsdom": "^29.0.1",
|
| 42 |
+
"postcss": "^8.5.6",
|
| 43 |
+
"tailwindcss": "^3.4.19",
|
| 44 |
+
"typescript": "~5.9.3",
|
| 45 |
+
"typescript-eslint": "^8.46.4",
|
| 46 |
+
"vite": "^7.2.4",
|
| 47 |
+
"vitest": "^4.1.0"
|
| 48 |
+
}
|
| 49 |
+
}
|
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,322 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { StudioKind } from './studio/protocol/studio-agent-types';
|
| 2 |
+
import { useEffect, useRef, useState } from 'react';
|
| 3 |
+
import type { JobResult, OutputMode, Quality, ReferenceImage } from './types/api';
|
| 4 |
+
import { useGeneration } from './hooks/useGeneration';
|
| 5 |
+
import { useProblemFraming } from './hooks/useProblemFraming';
|
| 6 |
+
import { useGame2048 } from './hooks/useGame2048';
|
| 7 |
+
import { useTabTitle } from './hooks/useTabTitle';
|
| 8 |
+
import { AiModifyModal } from './components/AiModifyModal';
|
| 9 |
+
import { SettingsModal } from './components/SettingsModal';
|
| 10 |
+
import { DonationModal } from './components/DonationModal';
|
| 11 |
+
import { ProviderConfigModal } from './components/ProviderConfigModal';
|
| 12 |
+
import { Workspace } from './components/Workspace';
|
| 13 |
+
import { StudioPage } from './pages/StudioPage';
|
| 14 |
+
import { Game2048Page } from './pages/Game2048Page';
|
| 15 |
+
import { PlotStudioShell } from './studio/PlotStudioShell';
|
| 16 |
+
import { StudioShell } from './studio/StudioShell';
|
| 17 |
+
import { StudioTransitionOverlay } from './studio/StudioTransitionOverlay';
|
| 18 |
+
import { useI18n } from './i18n';
|
| 19 |
+
|
| 20 |
+
type Screen = 'classic' | 'manim-studio' | 'plot-studio' | 'game';
|
| 21 |
+
|
| 22 |
+
const STUDIO_TRANSITION_MS = 2000;
|
| 23 |
+
const STUDIO_EXIT_DELAY_MS = 800;
|
| 24 |
+
|
| 25 |
+
function createClassicRenderCacheKey(): string {
|
| 26 |
+
return `classic-${crypto.randomUUID()}`;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
function App() {
|
| 30 |
+
const { status, result, error, jobId, stage, submittedAt, generate, renderWithCode, modifyWithAI, reset, cancel, cancelAndReset } = useGeneration();
|
| 31 |
+
const problemFraming = useProblemFraming();
|
| 32 |
+
const game = useGame2048();
|
| 33 |
+
useTabTitle(status, stage);
|
| 34 |
+
const { t } = useI18n();
|
| 35 |
+
|
| 36 |
+
const [settingsOpen, setSettingsOpen] = useState(false);
|
| 37 |
+
const [donationOpen, setDonationOpen] = useState(false);
|
| 38 |
+
const [providersOpen, setProvidersOpen] = useState(false);
|
| 39 |
+
const [workspaceOpen, setWorkspaceOpen] = useState(false);
|
| 40 |
+
const [aiModifyOpen, setAiModifyOpen] = useState(false);
|
| 41 |
+
const [currentCode, setCurrentCode] = useState('');
|
| 42 |
+
const [concept, setConcept] = useState('');
|
| 43 |
+
const [lastCompletedResult, setLastCompletedResult] = useState<JobResult | null>(null);
|
| 44 |
+
const [screen, setScreen] = useState<Screen>('classic');
|
| 45 |
+
const [activeStudioKind, setActiveStudioKind] = useState<StudioKind>('manim');
|
| 46 |
+
const [studioTransitionVisible, setStudioTransitionVisible] = useState(false);
|
| 47 |
+
const [studioIsExiting, setStudioIsExiting] = useState(false);
|
| 48 |
+
const [studioShellExiting, setStudioShellExiting] = useState(false);
|
| 49 |
+
const [isReturningFromStudio, setIsReturningFromStudio] = useState(false);
|
| 50 |
+
const [problemAdjustment, setProblemAdjustment] = useState('');
|
| 51 |
+
const studioTransitionTimerRef = useRef<number | null>(null);
|
| 52 |
+
const [lastRequest, setLastRequest] = useState<{
|
| 53 |
+
concept: string;
|
| 54 |
+
quality: Quality;
|
| 55 |
+
outputMode: OutputMode;
|
| 56 |
+
referenceImages?: ReferenceImage[];
|
| 57 |
+
renderCacheKey: string;
|
| 58 |
+
} | null>(null);
|
| 59 |
+
|
| 60 |
+
useEffect(() => {
|
| 61 |
+
return () => {
|
| 62 |
+
if (studioTransitionTimerRef.current) {
|
| 63 |
+
window.clearTimeout(studioTransitionTimerRef.current);
|
| 64 |
+
}
|
| 65 |
+
};
|
| 66 |
+
}, []);
|
| 67 |
+
|
| 68 |
+
useEffect(() => {
|
| 69 |
+
if (status === 'completed' && result) {
|
| 70 |
+
setLastCompletedResult(result);
|
| 71 |
+
}
|
| 72 |
+
}, [result, status]);
|
| 73 |
+
|
| 74 |
+
const resetAll = () => {
|
| 75 |
+
reset();
|
| 76 |
+
setCurrentCode('');
|
| 77 |
+
setConcept('');
|
| 78 |
+
setLastRequest(null);
|
| 79 |
+
setAiModifyOpen(false);
|
| 80 |
+
setLastCompletedResult(null);
|
| 81 |
+
setScreen('classic');
|
| 82 |
+
setActiveStudioKind('manim');
|
| 83 |
+
setStudioTransitionVisible(false);
|
| 84 |
+
setStudioIsExiting(false);
|
| 85 |
+
setStudioShellExiting(false);
|
| 86 |
+
setProblemAdjustment('');
|
| 87 |
+
if (studioTransitionTimerRef.current) {
|
| 88 |
+
window.clearTimeout(studioTransitionTimerRef.current);
|
| 89 |
+
studioTransitionTimerRef.current = null;
|
| 90 |
+
}
|
| 91 |
+
problemFraming.reset();
|
| 92 |
+
};
|
| 93 |
+
|
| 94 |
+
const handleOpenStudio = (studioKind: StudioKind) => {
|
| 95 |
+
if (studioTransitionVisible || screen === 'manim-studio' || screen === 'plot-studio') {
|
| 96 |
+
return;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
setActiveStudioKind(studioKind);
|
| 100 |
+
setStudioIsExiting(false);
|
| 101 |
+
setStudioShellExiting(false);
|
| 102 |
+
setStudioTransitionVisible(true);
|
| 103 |
+
|
| 104 |
+
studioTransitionTimerRef.current = window.setTimeout(() => {
|
| 105 |
+
setConcept('');
|
| 106 |
+
setScreen(studioKind === 'plot' ? 'plot-studio' : 'manim-studio');
|
| 107 |
+
|
| 108 |
+
studioTransitionTimerRef.current = window.setTimeout(() => {
|
| 109 |
+
setStudioIsExiting(true);
|
| 110 |
+
|
| 111 |
+
studioTransitionTimerRef.current = window.setTimeout(() => {
|
| 112 |
+
setStudioTransitionVisible(false);
|
| 113 |
+
setStudioIsExiting(false);
|
| 114 |
+
studioTransitionTimerRef.current = null;
|
| 115 |
+
}, STUDIO_EXIT_DELAY_MS);
|
| 116 |
+
}, 300);
|
| 117 |
+
}, STUDIO_TRANSITION_MS);
|
| 118 |
+
};
|
| 119 |
+
|
| 120 |
+
const handleExitStudio = () => {
|
| 121 |
+
if (studioTransitionVisible) return;
|
| 122 |
+
|
| 123 |
+
setStudioIsExiting(false);
|
| 124 |
+
setStudioShellExiting(true); // Studio 界面开始退场动画
|
| 125 |
+
setStudioTransitionVisible(true); // 遮罩开始升起
|
| 126 |
+
|
| 127 |
+
studioTransitionTimerRef.current = window.setTimeout(() => {
|
| 128 |
+
setScreen('classic');
|
| 129 |
+
setStudioShellExiting(false);
|
| 130 |
+
setIsReturningFromStudio(true); // 开启 Classic 入场动画
|
| 131 |
+
|
| 132 |
+
studioTransitionTimerRef.current = window.setTimeout(() => {
|
| 133 |
+
setStudioIsExiting(true);
|
| 134 |
+
|
| 135 |
+
studioTransitionTimerRef.current = window.setTimeout(() => {
|
| 136 |
+
setStudioTransitionVisible(false);
|
| 137 |
+
setStudioIsExiting(false);
|
| 138 |
+
setIsReturningFromStudio(false);
|
| 139 |
+
studioTransitionTimerRef.current = null;
|
| 140 |
+
}, STUDIO_EXIT_DELAY_MS);
|
| 141 |
+
}, 300);
|
| 142 |
+
}, STUDIO_TRANSITION_MS);
|
| 143 |
+
};
|
| 144 |
+
|
| 145 |
+
const handleSubmit = (data: {
|
| 146 |
+
concept: string;
|
| 147 |
+
quality: Quality;
|
| 148 |
+
outputMode: OutputMode;
|
| 149 |
+
referenceImages?: ReferenceImage[];
|
| 150 |
+
}) => {
|
| 151 |
+
setLastCompletedResult(null);
|
| 152 |
+
setConcept(data.concept);
|
| 153 |
+
setProblemAdjustment('');
|
| 154 |
+
void problemFraming.startPlan({ request: data });
|
| 155 |
+
};
|
| 156 |
+
|
| 157 |
+
const handleBackToHome = () => {
|
| 158 |
+
if (status === 'processing' || status === 'cancelling') {
|
| 159 |
+
cancelAndReset();
|
| 160 |
+
return;
|
| 161 |
+
}
|
| 162 |
+
setLastCompletedResult(null);
|
| 163 |
+
reset();
|
| 164 |
+
};
|
| 165 |
+
|
| 166 |
+
const handleProblemRetry = () => {
|
| 167 |
+
if (!problemAdjustment.trim()) {
|
| 168 |
+
return;
|
| 169 |
+
}
|
| 170 |
+
void problemFraming.refinePlan({ feedback: problemAdjustment.trim() });
|
| 171 |
+
};
|
| 172 |
+
|
| 173 |
+
const handleProblemGenerate = () => {
|
| 174 |
+
if (!problemFraming.draft || !problemFraming.plan) {
|
| 175 |
+
return;
|
| 176 |
+
}
|
| 177 |
+
const draft = problemFraming.draft;
|
| 178 |
+
const problemPlan = problemFraming.plan;
|
| 179 |
+
const renderCacheKey = createClassicRenderCacheKey();
|
| 180 |
+
setLastCompletedResult(null);
|
| 181 |
+
setLastRequest({ ...draft, renderCacheKey });
|
| 182 |
+
setConcept(draft.concept);
|
| 183 |
+
setCurrentCode('');
|
| 184 |
+
setProblemAdjustment('');
|
| 185 |
+
problemFraming.reset();
|
| 186 |
+
generate({ ...draft, problemPlan, renderCacheKey });
|
| 187 |
+
};
|
| 188 |
+
|
| 189 |
+
const handleProblemClose = () => {
|
| 190 |
+
setProblemAdjustment('');
|
| 191 |
+
problemFraming.reset();
|
| 192 |
+
};
|
| 193 |
+
|
| 194 |
+
const handleRerender = () => {
|
| 195 |
+
const code = currentCode.trim() || result?.code?.trim() || lastCompletedResult?.code?.trim() || '';
|
| 196 |
+
if (!lastRequest || !code) {
|
| 197 |
+
return;
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
setCurrentCode('');
|
| 201 |
+
renderWithCode({ ...lastRequest, code });
|
| 202 |
+
};
|
| 203 |
+
|
| 204 |
+
const handleAiModifySubmit = (instructions: string) => {
|
| 205 |
+
const code = currentCode.trim() || result?.code?.trim() || lastCompletedResult?.code?.trim() || '';
|
| 206 |
+
if (!lastRequest || !code) {
|
| 207 |
+
return;
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
setAiModifyOpen(false);
|
| 211 |
+
setCurrentCode('');
|
| 212 |
+
modifyWithAI({
|
| 213 |
+
concept: lastRequest.concept,
|
| 214 |
+
outputMode: lastRequest.outputMode,
|
| 215 |
+
quality: lastRequest.quality,
|
| 216 |
+
instructions,
|
| 217 |
+
code,
|
| 218 |
+
renderCacheKey: lastRequest.renderCacheKey,
|
| 219 |
+
});
|
| 220 |
+
};
|
| 221 |
+
|
| 222 |
+
const handleOpenGame = () => {
|
| 223 |
+
if (status !== 'processing' && status !== 'cancelling') {
|
| 224 |
+
return;
|
| 225 |
+
}
|
| 226 |
+
setScreen('game');
|
| 227 |
+
};
|
| 228 |
+
|
| 229 |
+
const isBusy = status === 'processing' || status === 'cancelling';
|
| 230 |
+
const displayResult = result ?? lastCompletedResult;
|
| 231 |
+
|
| 232 |
+
return (
|
| 233 |
+
<div className="min-h-screen bg-bg-primary transition-colors duration-300 overflow-x-hidden">
|
| 234 |
+
{screen === 'classic' ? (
|
| 235 |
+
<div className={isReturningFromStudio ? 'animate-classic-entrance' : ''}>
|
| 236 |
+
<StudioPage
|
| 237 |
+
status={status}
|
| 238 |
+
result={displayResult}
|
| 239 |
+
error={error}
|
| 240 |
+
jobId={jobId}
|
| 241 |
+
stage={stage}
|
| 242 |
+
submittedAt={submittedAt}
|
| 243 |
+
concept={concept}
|
| 244 |
+
currentCode={currentCode || result?.code || ''}
|
| 245 |
+
isBusy={isBusy}
|
| 246 |
+
lastRequest={lastRequest}
|
| 247 |
+
onConceptChange={setConcept}
|
| 248 |
+
onSecretStudioOpen={handleOpenStudio}
|
| 249 |
+
onSubmit={handleSubmit}
|
| 250 |
+
onCodeChange={setCurrentCode}
|
| 251 |
+
onRerender={handleRerender}
|
| 252 |
+
onAiModifyOpen={() => setAiModifyOpen(true)}
|
| 253 |
+
onResetAll={resetAll}
|
| 254 |
+
onBackToHome={handleBackToHome}
|
| 255 |
+
onCancel={cancel}
|
| 256 |
+
onOpenDonation={() => setDonationOpen(true)}
|
| 257 |
+
onOpenProviders={() => setProvidersOpen(true)}
|
| 258 |
+
onOpenWorkspace={() => setWorkspaceOpen(true)}
|
| 259 |
+
onOpenSettings={() => setSettingsOpen(true)}
|
| 260 |
+
onOpenGame={handleOpenGame}
|
| 261 |
+
problemOpen={problemFraming.status !== 'idle'}
|
| 262 |
+
problemStatus={problemFraming.status === 'idle' ? 'loading' : problemFraming.status}
|
| 263 |
+
problemPlan={problemFraming.plan}
|
| 264 |
+
problemError={problemFraming.error}
|
| 265 |
+
problemAdjustment={problemAdjustment}
|
| 266 |
+
onProblemAdjustmentChange={setProblemAdjustment}
|
| 267 |
+
onProblemRetry={handleProblemRetry}
|
| 268 |
+
onProblemClose={handleProblemClose}
|
| 269 |
+
onProblemGenerate={handleProblemGenerate}
|
| 270 |
+
/>
|
| 271 |
+
</div>
|
| 272 |
+
) : screen === 'manim-studio' ? (
|
| 273 |
+
<StudioShell
|
| 274 |
+
onExit={handleExitStudio}
|
| 275 |
+
isExiting={studioShellExiting}
|
| 276 |
+
studioKind={activeStudioKind}
|
| 277 |
+
/>
|
| 278 |
+
) : screen === 'plot-studio' ? (
|
| 279 |
+
<PlotStudioShell
|
| 280 |
+
onExit={handleExitStudio}
|
| 281 |
+
isExiting={studioShellExiting}
|
| 282 |
+
/>
|
| 283 |
+
) : (
|
| 284 |
+
<Game2048Page
|
| 285 |
+
board={game.board}
|
| 286 |
+
score={game.score}
|
| 287 |
+
bestScore={game.bestScore}
|
| 288 |
+
isGameOver={game.isGameOver}
|
| 289 |
+
hasWon={game.hasWon}
|
| 290 |
+
maxTile={game.maxTile}
|
| 291 |
+
generationStatus={status}
|
| 292 |
+
generationStage={stage}
|
| 293 |
+
onMove={game.move}
|
| 294 |
+
onRestart={game.restart}
|
| 295 |
+
onBackToStudio={() => setScreen('classic')}
|
| 296 |
+
/>
|
| 297 |
+
)}
|
| 298 |
+
|
| 299 |
+
<StudioTransitionOverlay visible={studioTransitionVisible} isExiting={studioIsExiting} />
|
| 300 |
+
|
| 301 |
+
<style>{`
|
| 302 |
+
@keyframes fadeInUp {
|
| 303 |
+
0% { opacity: 0; transform: translateY(30px); }
|
| 304 |
+
100% { opacity: 1; transform: translateY(0); }
|
| 305 |
+
}
|
| 306 |
+
`}</style>
|
| 307 |
+
|
| 308 |
+
<SettingsModal key={`settings-${settingsOpen ? 'open' : 'closed'}`} isOpen={settingsOpen} onClose={() => setSettingsOpen(false)} onSave={() => undefined} />
|
| 309 |
+
<DonationModal key={`donation-${donationOpen ? 'open' : 'closed'}`} isOpen={donationOpen} onClose={() => setDonationOpen(false)} />
|
| 310 |
+
<ProviderConfigModal key={`providers-${providersOpen ? 'open' : 'closed'}`} isOpen={providersOpen} onClose={() => setProvidersOpen(false)} onSave={() => undefined} />
|
| 311 |
+
<Workspace key={`workspace-${workspaceOpen ? 'open' : 'closed'}`} isOpen={workspaceOpen} onClose={() => setWorkspaceOpen(false)} />
|
| 312 |
+
<AiModifyModal
|
| 313 |
+
isOpen={aiModifyOpen}
|
| 314 |
+
loading={isBusy}
|
| 315 |
+
onClose={() => setAiModifyOpen(false)}
|
| 316 |
+
onSubmit={handleAiModifySubmit}
|
| 317 |
+
/>
|
| 318 |
+
</div>
|
| 319 |
+
);
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
export default App;
|
frontend/src/components/AiModifyModal.tsx
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// AI 修改对话框
|
| 2 |
+
|
| 3 |
+
import { useEffect, useState } from 'react';
|
| 4 |
+
import { useI18n } from '../i18n';
|
| 5 |
+
import { useModalTransition } from '../hooks/useModalTransition';
|
| 6 |
+
|
| 7 |
+
interface AiModifyModalProps {
|
| 8 |
+
isOpen: boolean;
|
| 9 |
+
loading?: boolean;
|
| 10 |
+
onClose: () => void;
|
| 11 |
+
onSubmit: (value: string) => void;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export function AiModifyModal({ isOpen, loading = false, onClose, onSubmit }: AiModifyModalProps) {
|
| 15 |
+
const { t } = useI18n();
|
| 16 |
+
const { shouldRender, isExiting } = useModalTransition(isOpen);
|
| 17 |
+
const [draft, setDraft] = useState('');
|
| 18 |
+
|
| 19 |
+
useEffect(() => {
|
| 20 |
+
if (isOpen) {
|
| 21 |
+
setDraft('');
|
| 22 |
+
}
|
| 23 |
+
}, [isOpen]);
|
| 24 |
+
|
| 25 |
+
if (!shouldRender) return null;
|
| 26 |
+
|
| 27 |
+
return (
|
| 28 |
+
<div className="fixed inset-0 z-[120] flex items-center justify-center p-4">
|
| 29 |
+
{/* 沉浸式背景 */}
|
| 30 |
+
<div
|
| 31 |
+
className={`absolute inset-0 bg-bg-primary/60 backdrop-blur-md transition-opacity duration-300 ${
|
| 32 |
+
isExiting ? 'opacity-0' : 'animate-overlay-wash-in'
|
| 33 |
+
}`}
|
| 34 |
+
onClick={onClose}
|
| 35 |
+
/>
|
| 36 |
+
|
| 37 |
+
{/* 模态框主体 */}
|
| 38 |
+
<div className={`relative w-full max-w-lg bg-bg-secondary rounded-[2.5rem] p-10 shadow-2xl border border-border/5 ${
|
| 39 |
+
isExiting ? 'animate-fade-out-soft' : 'animate-fade-in-soft'
|
| 40 |
+
}`}>
|
| 41 |
+
<div className="flex items-center justify-between mb-8">
|
| 42 |
+
<div className="flex items-center gap-3">
|
| 43 |
+
<div className="w-2 h-2 rounded-full bg-accent-rgb/40 animate-pulse" />
|
| 44 |
+
<h2 className="text-xl font-medium text-text-primary tracking-tight">{t('aiModify.title')}</h2>
|
| 45 |
+
</div>
|
| 46 |
+
<button
|
| 47 |
+
onClick={onClose}
|
| 48 |
+
className="p-2.5 text-text-secondary/50 hover:text-text-primary hover:bg-bg-primary/50 rounded-2xl transition-all"
|
| 49 |
+
>
|
| 50 |
+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 51 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
| 52 |
+
</svg>
|
| 53 |
+
</button>
|
| 54 |
+
</div>
|
| 55 |
+
|
| 56 |
+
<p className="text-text-secondary text-[15px] mb-8 leading-relaxed font-light">
|
| 57 |
+
{t('aiModify.description')}
|
| 58 |
+
</p>
|
| 59 |
+
|
| 60 |
+
<div className="relative mb-10 group">
|
| 61 |
+
<textarea
|
| 62 |
+
id="aiModifyInput"
|
| 63 |
+
rows={5}
|
| 64 |
+
value={draft}
|
| 65 |
+
onChange={(e) => setDraft(e.target.value)}
|
| 66 |
+
placeholder={t('aiModify.placeholder')}
|
| 67 |
+
className="w-full px-6 py-6 bg-bg-secondary/50 border border-border/5 rounded-3xl text-base text-text-primary placeholder-text-secondary/30 focus:outline-none focus:border-accent-rgb/30 focus:bg-bg-secondary/80 transition-all resize-none shadow-inner"
|
| 68 |
+
/>
|
| 69 |
+
<label
|
| 70 |
+
htmlFor="aiModifyInput"
|
| 71 |
+
className="absolute right-6 -bottom-3 px-3 py-1 bg-bg-secondary border border-border/5 rounded-full text-[10px] uppercase tracking-widest text-text-secondary/40 group-focus-within:text-accent-rgb/60 group-focus-within:border-accent-rgb/20 transition-all"
|
| 72 |
+
>
|
| 73 |
+
{t('aiModify.label')}
|
| 74 |
+
</label>
|
| 75 |
+
</div>
|
| 76 |
+
|
| 77 |
+
<div className="flex gap-4">
|
| 78 |
+
<button
|
| 79 |
+
onClick={onClose}
|
| 80 |
+
disabled={loading}
|
| 81 |
+
className="flex-1 py-4 text-sm text-text-secondary hover:text-text-primary bg-bg-primary/50 hover:bg-bg-tertiary rounded-2xl transition-all active:scale-95 disabled:opacity-50"
|
| 82 |
+
>
|
| 83 |
+
{t('common.cancel')}
|
| 84 |
+
</button>
|
| 85 |
+
<button
|
| 86 |
+
onClick={() => onSubmit(draft.trim())}
|
| 87 |
+
disabled={loading || draft.trim().length === 0}
|
| 88 |
+
className="flex-1 py-4 text-sm text-bg-primary bg-text-primary hover:bg-accent-hover-rgb rounded-2xl transition-all active:scale-95 disabled:opacity-50 shadow-lg font-medium"
|
| 89 |
+
>
|
| 90 |
+
{loading ? t('aiModify.submitting') : t('aiModify.submit')}
|
| 91 |
+
</button>
|
| 92 |
+
</div>
|
| 93 |
+
</div>
|
| 94 |
+
</div>
|
| 95 |
+
);
|
| 96 |
+
}
|
frontend/src/components/CodeView.tsx
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// 代码预览组件
|
| 2 |
+
|
| 3 |
+
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
| 4 |
+
import { oneLight, vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
| 5 |
+
import { memo, useEffect, useState } from 'react';
|
| 6 |
+
import { useI18n } from '../i18n';
|
| 7 |
+
|
| 8 |
+
interface CodeViewProps {
|
| 9 |
+
code: string;
|
| 10 |
+
editable?: boolean;
|
| 11 |
+
onChange?: (value: string) => void;
|
| 12 |
+
disabled?: boolean;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export const CodeView = memo(function CodeView({ code, editable = false, onChange, disabled = false }: CodeViewProps) {
|
| 16 |
+
const { t } = useI18n();
|
| 17 |
+
const [copied, setCopied] = useState(false);
|
| 18 |
+
const [isEditing, setIsEditing] = useState(false);
|
| 19 |
+
const [isDark, setIsDark] = useState(
|
| 20 |
+
typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
|
| 21 |
+
);
|
| 22 |
+
|
| 23 |
+
useEffect(() => {
|
| 24 |
+
if (typeof document === 'undefined') {
|
| 25 |
+
return;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
const updateThemeState = () => {
|
| 29 |
+
setIsDark(document.documentElement.classList.contains('dark'));
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
updateThemeState();
|
| 33 |
+
|
| 34 |
+
const observer = new MutationObserver(updateThemeState);
|
| 35 |
+
observer.observe(document.documentElement, {
|
| 36 |
+
attributes: true,
|
| 37 |
+
attributeFilter: ['class']
|
| 38 |
+
});
|
| 39 |
+
|
| 40 |
+
return () => observer.disconnect();
|
| 41 |
+
}, []);
|
| 42 |
+
|
| 43 |
+
const handleCopy = async () => {
|
| 44 |
+
await navigator.clipboard.writeText(code);
|
| 45 |
+
setCopied(true);
|
| 46 |
+
setTimeout(() => setCopied(false), 2000);
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
+
const textareaClassName = [
|
| 50 |
+
'w-full h-full resize-none bg-transparent p-4 text-[0.75rem] leading-relaxed overflow-auto',
|
| 51 |
+
'font-mono text-text-primary/90 focus:outline-none',
|
| 52 |
+
disabled ? 'opacity-60 cursor-not-allowed' : ''
|
| 53 |
+
]
|
| 54 |
+
.filter(Boolean)
|
| 55 |
+
.join(' ');
|
| 56 |
+
|
| 57 |
+
return (
|
| 58 |
+
<div className="h-full flex flex-col bg-bg-secondary/30 rounded-2xl overflow-hidden">
|
| 59 |
+
{/* 顶部工具栏 */}
|
| 60 |
+
<div className="flex items-center justify-between px-4 py-2.5">
|
| 61 |
+
<h3 className="text-xs font-medium text-text-secondary/80 uppercase tracking-wide">{t('codeView.title')}</h3>
|
| 62 |
+
<div className="flex items-center gap-3">
|
| 63 |
+
{editable && (
|
| 64 |
+
<button
|
| 65 |
+
onClick={() => setIsEditing((prev) => !prev)}
|
| 66 |
+
disabled={disabled}
|
| 67 |
+
className="text-xs text-text-secondary/70 hover:text-accent transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
| 68 |
+
>
|
| 69 |
+
{isEditing ? t('common.preview') : t('common.edit')}
|
| 70 |
+
</button>
|
| 71 |
+
)}
|
| 72 |
+
<button
|
| 73 |
+
onClick={handleCopy}
|
| 74 |
+
className="text-xs text-text-secondary/70 hover:text-accent transition-colors flex items-center gap-1.5"
|
| 75 |
+
>
|
| 76 |
+
{copied ? (
|
| 77 |
+
<>
|
| 78 |
+
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 79 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
| 80 |
+
</svg>
|
| 81 |
+
{t('common.copied')}
|
| 82 |
+
</>
|
| 83 |
+
) : (
|
| 84 |
+
<>
|
| 85 |
+
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 86 |
+
<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" />
|
| 87 |
+
</svg>
|
| 88 |
+
{t('common.copy')}
|
| 89 |
+
</>
|
| 90 |
+
)}
|
| 91 |
+
</button>
|
| 92 |
+
</div>
|
| 93 |
+
</div>
|
| 94 |
+
|
| 95 |
+
{/* 代码区域 */}
|
| 96 |
+
<div className="flex-1 overflow-hidden">
|
| 97 |
+
{editable && isEditing ? (
|
| 98 |
+
<textarea
|
| 99 |
+
value={code}
|
| 100 |
+
onChange={(event) => onChange?.(event.target.value)}
|
| 101 |
+
className={textareaClassName}
|
| 102 |
+
disabled={disabled}
|
| 103 |
+
spellCheck={false}
|
| 104 |
+
/>
|
| 105 |
+
) : (
|
| 106 |
+
<div className="h-full overflow-auto">
|
| 107 |
+
<SyntaxHighlighter
|
| 108 |
+
language="python"
|
| 109 |
+
style={isDark ? vscDarkPlus : oneLight}
|
| 110 |
+
customStyle={{
|
| 111 |
+
margin: 0,
|
| 112 |
+
padding: '1rem',
|
| 113 |
+
fontSize: '0.75rem',
|
| 114 |
+
lineHeight: '1.6',
|
| 115 |
+
minHeight: '100%',
|
| 116 |
+
width: '100%',
|
| 117 |
+
boxSizing: 'border-box',
|
| 118 |
+
fontFamily: 'Monaco, Cascadia Code, Roboto Mono, monospace',
|
| 119 |
+
background: 'transparent',
|
| 120 |
+
whiteSpace: 'pre',
|
| 121 |
+
wordBreak: 'normal',
|
| 122 |
+
overflow: 'visible'
|
| 123 |
+
}}
|
| 124 |
+
codeTagProps={{
|
| 125 |
+
style: {
|
| 126 |
+
fontFamily: 'Monaco, Cascadia Code, Roboto Mono, monospace',
|
| 127 |
+
whiteSpace: 'pre',
|
| 128 |
+
wordBreak: 'normal'
|
| 129 |
+
}
|
| 130 |
+
}}
|
| 131 |
+
showLineNumbers
|
| 132 |
+
>
|
| 133 |
+
{code}
|
| 134 |
+
</SyntaxHighlighter>
|
| 135 |
+
</div>
|
| 136 |
+
)}
|
| 137 |
+
</div>
|
| 138 |
+
</div>
|
| 139 |
+
);
|
| 140 |
+
});
|
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/DonationModal.tsx
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// 支持作者对话框组件
|
| 2 |
+
|
| 3 |
+
import { useState } from 'react';
|
| 4 |
+
import { useI18n } from '../i18n';
|
| 5 |
+
import { useModalTransition } from '../hooks/useModalTransition';
|
| 6 |
+
|
| 7 |
+
interface DonationModalProps {
|
| 8 |
+
isOpen: boolean;
|
| 9 |
+
onClose: () => void;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
const WECHAT_QR_URL = 'https://github.com/user-attachments/assets/09fd8c5f-9644-4c02-97c1-61f10b0fdd1f';
|
| 13 |
+
const AFDIAN_URL = 'https://afdian.com/a/wingflow/plan';
|
| 14 |
+
|
| 15 |
+
export function DonationModal({ isOpen, onClose }: DonationModalProps) {
|
| 16 |
+
const { t } = useI18n();
|
| 17 |
+
const { shouldRender, isExiting } = useModalTransition(isOpen);
|
| 18 |
+
const [showQR, setShowQR] = useState(false);
|
| 19 |
+
const [imgLoaded, setImgLoaded] = useState(false);
|
| 20 |
+
|
| 21 |
+
if (!shouldRender) return null;
|
| 22 |
+
|
| 23 |
+
return (
|
| 24 |
+
<div className="fixed inset-0 z-[120] flex items-center justify-center p-4">
|
| 25 |
+
{/* 遮罩层 */}
|
| 26 |
+
<div
|
| 27 |
+
className={`absolute inset-0 bg-bg-primary/60 backdrop-blur-md transition-opacity duration-300 ${
|
| 28 |
+
isExiting ? 'opacity-0' : 'animate-overlay-wash-in'
|
| 29 |
+
}`}
|
| 30 |
+
onClick={onClose}
|
| 31 |
+
/>
|
| 32 |
+
|
| 33 |
+
{/* 模态框内容 */}
|
| 34 |
+
<div className={`relative bg-bg-secondary rounded-[2.5rem] p-10 max-w-sm w-full shadow-2xl border border-border/5 overflow-hidden ${
|
| 35 |
+
isExiting ? 'animate-fade-out-soft' : 'animate-fade-in-soft'
|
| 36 |
+
}`}>
|
| 37 |
+
{!showQR ? (
|
| 38 |
+
<div className="flex flex-col">
|
| 39 |
+
<div className="flex items-center gap-3 mb-6">
|
| 40 |
+
<div className="p-2.5 rounded-2xl bg-accent-rgb/5 text-accent-rgb/60">
|
| 41 |
+
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 42 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M17 8h1a1 1 0 011 1v5a1 1 0 01-1 1h-1m-6.854 8.03l-1.772-1.603a4.5 4.5 0 00-6.267 0L6.97 21h8.13l-.648-.57a4.5 4.5 0 00-6.267 0l-.94.85M14.5 9l-1-4h-5l-1 4h7z" />
|
| 43 |
+
</svg>
|
| 44 |
+
</div>
|
| 45 |
+
<h2 className="text-xl font-medium text-text-primary tracking-tight">{t('donation.title')}</h2>
|
| 46 |
+
</div>
|
| 47 |
+
|
| 48 |
+
<p className="text-text-secondary text-[15px] mb-10 leading-8 font-light">
|
| 49 |
+
{t('donation.description')}
|
| 50 |
+
</p>
|
| 51 |
+
|
| 52 |
+
<div className="flex flex-col gap-3">
|
| 53 |
+
<button
|
| 54 |
+
onClick={() => setShowQR(true)}
|
| 55 |
+
className="w-full py-4 text-sm text-bg-primary bg-accent hover:bg-accent/90 rounded-2xl transition-all active:scale-95 font-medium shadow-md shadow-accent/10"
|
| 56 |
+
>
|
| 57 |
+
{t('donation.support')}
|
| 58 |
+
</button>
|
| 59 |
+
<button
|
| 60 |
+
onClick={onClose}
|
| 61 |
+
className="w-full py-4 text-sm text-text-secondary hover:text-text-primary bg-bg-primary/50 hover:bg-bg-tertiary rounded-2xl transition-all active:scale-95"
|
| 62 |
+
>
|
| 63 |
+
{t('donation.maybeLater')}
|
| 64 |
+
</button>
|
| 65 |
+
</div>
|
| 66 |
+
</div>
|
| 67 |
+
) : (
|
| 68 |
+
<div className="flex flex-col items-center animate-fade-in">
|
| 69 |
+
<div className="w-full flex items-center justify-between mb-8">
|
| 70 |
+
<button
|
| 71 |
+
onClick={() => setShowQR(false)}
|
| 72 |
+
className="p-2 -ml-2 text-text-secondary/50 hover:text-text-primary transition-colors"
|
| 73 |
+
>
|
| 74 |
+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 75 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
| 76 |
+
</svg>
|
| 77 |
+
</button>
|
| 78 |
+
<span className="text-[11px] uppercase tracking-[0.3em] text-text-secondary/40 font-medium">
|
| 79 |
+
{t('donation.wechatTitle')}
|
| 80 |
+
</span>
|
| 81 |
+
<div className="w-9" />
|
| 82 |
+
</div>
|
| 83 |
+
|
| 84 |
+
<div className="relative w-56 h-56 mb-8 rounded-3xl bg-bg-primary/50 border border-border/5 flex items-center justify-center overflow-hidden shadow-inner group">
|
| 85 |
+
{!imgLoaded && (
|
| 86 |
+
<div className="absolute inset-0 flex items-center justify-center">
|
| 87 |
+
<div className="w-6 h-6 border-2 border-accent-rgb/20 border-t-accent-rgb/60 rounded-full animate-spin" />
|
| 88 |
+
</div>
|
| 89 |
+
)}
|
| 90 |
+
<img
|
| 91 |
+
src={WECHAT_QR_URL}
|
| 92 |
+
alt="WeChat Pay"
|
| 93 |
+
className={`w-full h-full object-contain transition-all duration-700 p-4 ${
|
| 94 |
+
imgLoaded ? 'opacity-100 scale-100' : 'opacity-0 scale-95 blur-sm'
|
| 95 |
+
}`}
|
| 96 |
+
onLoad={() => setImgLoaded(true)}
|
| 97 |
+
/>
|
| 98 |
+
</div>
|
| 99 |
+
|
| 100 |
+
<p className="text-[13px] text-text-secondary/70 font-light mb-10">
|
| 101 |
+
{t('donation.wechatHint')}
|
| 102 |
+
</p>
|
| 103 |
+
|
| 104 |
+
<a
|
| 105 |
+
href={AFDIAN_URL}
|
| 106 |
+
target="_blank"
|
| 107 |
+
rel="noopener noreferrer"
|
| 108 |
+
className="text-[11px] text-text-secondary/30 hover:text-accent-rgb underline underline-offset-4 transition-colors"
|
| 109 |
+
>
|
| 110 |
+
{t('donation.afdianLink')}
|
| 111 |
+
</a>
|
| 112 |
+
</div>
|
| 113 |
+
)}
|
| 114 |
+
</div>
|
| 115 |
+
</div>
|
| 116 |
+
);
|
| 117 |
+
}
|
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/HistoryPanel.tsx
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* 历史记录面板 - 工作空间内嵌模块
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
| 6 |
+
import { getHistoryList, deleteHistoryRecord } from '../lib/api';
|
| 7 |
+
import type { HistoryRecord } from '../types/api';
|
| 8 |
+
import { useI18n } from '../i18n';
|
| 9 |
+
|
| 10 |
+
interface HistoryPanelProps {
|
| 11 |
+
isActive: boolean;
|
| 12 |
+
onReusePrompt?: (prompt: string) => void;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export function HistoryPanel({ isActive, onReusePrompt }: HistoryPanelProps) {
|
| 16 |
+
const { t } = useI18n();
|
| 17 |
+
const [records, setRecords] = useState<HistoryRecord[]>([]);
|
| 18 |
+
const [page, setPage] = useState(1);
|
| 19 |
+
const [hasMore, setHasMore] = useState(true);
|
| 20 |
+
const [loading, setLoading] = useState(false);
|
| 21 |
+
const [error, setError] = useState<string | null>(null);
|
| 22 |
+
const [expandedId, setExpandedId] = useState<string | null>(null);
|
| 23 |
+
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
| 24 |
+
const loadedRef = useRef(false);
|
| 25 |
+
|
| 26 |
+
const loadHistory = useCallback(async (pageNum: number, append = false) => {
|
| 27 |
+
setLoading(true);
|
| 28 |
+
setError(null);
|
| 29 |
+
try {
|
| 30 |
+
const data = await getHistoryList(pageNum, 12);
|
| 31 |
+
setRecords(prev => append ? [...prev, ...data.records] : data.records);
|
| 32 |
+
setHasMore(data.hasMore);
|
| 33 |
+
setPage(pageNum);
|
| 34 |
+
} catch (err) {
|
| 35 |
+
setError(err instanceof Error ? err.message : t('history.loadFailed'));
|
| 36 |
+
} finally {
|
| 37 |
+
setLoading(false);
|
| 38 |
+
}
|
| 39 |
+
}, [t]);
|
| 40 |
+
|
| 41 |
+
useEffect(() => {
|
| 42 |
+
if (isActive && !loadedRef.current) {
|
| 43 |
+
loadedRef.current = true;
|
| 44 |
+
void loadHistory(1);
|
| 45 |
+
}
|
| 46 |
+
}, [isActive, loadHistory]);
|
| 47 |
+
|
| 48 |
+
const handleLoadMore = () => {
|
| 49 |
+
if (!loading && hasMore) {
|
| 50 |
+
void loadHistory(page + 1, true);
|
| 51 |
+
}
|
| 52 |
+
};
|
| 53 |
+
|
| 54 |
+
const handleDelete = async (id: string) => {
|
| 55 |
+
try {
|
| 56 |
+
await deleteHistoryRecord(id);
|
| 57 |
+
setRecords(prev => prev.filter(r => r.id !== id));
|
| 58 |
+
setConfirmDeleteId(null);
|
| 59 |
+
} catch {
|
| 60 |
+
// silently fail
|
| 61 |
+
}
|
| 62 |
+
};
|
| 63 |
+
|
| 64 |
+
const formatDate = (dateStr: string) => {
|
| 65 |
+
const date = new Date(dateStr);
|
| 66 |
+
return date.toLocaleDateString(undefined, {
|
| 67 |
+
month: 'short',
|
| 68 |
+
day: 'numeric',
|
| 69 |
+
hour: '2-digit',
|
| 70 |
+
minute: '2-digit',
|
| 71 |
+
});
|
| 72 |
+
};
|
| 73 |
+
|
| 74 |
+
return (
|
| 75 |
+
<div className="animate-fade-in">
|
| 76 |
+
<h2 className="text-2xl font-light text-text-primary mb-8">{t('history.title')}</h2>
|
| 77 |
+
|
| 78 |
+
{error && (
|
| 79 |
+
<div className="rounded-2xl bg-red-50/80 dark:bg-red-900/20 border border-red-200/50 dark:border-red-700/40 p-4 text-sm text-red-600 dark:text-red-300 mb-6">
|
| 80 |
+
{error}
|
| 81 |
+
</div>
|
| 82 |
+
)}
|
| 83 |
+
|
| 84 |
+
{!loading && records.length === 0 && !error && (
|
| 85 |
+
<div className="flex flex-col items-center justify-center py-24 text-center">
|
| 86 |
+
<svg className="w-16 h-16 text-text-secondary/20 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 87 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
| 88 |
+
</svg>
|
| 89 |
+
<p className="text-text-secondary/50 text-sm">{t('history.empty')}</p>
|
| 90 |
+
<p className="text-text-secondary/30 text-xs mt-1">{t('history.emptyHint')}</p>
|
| 91 |
+
</div>
|
| 92 |
+
)}
|
| 93 |
+
|
| 94 |
+
{records.length > 0 && (
|
| 95 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
| 96 |
+
{records.map(record => (
|
| 97 |
+
<div
|
| 98 |
+
key={record.id}
|
| 99 |
+
className="group rounded-2xl bg-bg-secondary/25 border border-bg-tertiary/30 overflow-hidden hover:border-bg-tertiary/60 transition-colors"
|
| 100 |
+
>
|
| 101 |
+
{/* 卡片头部:状态 + 时间 */}
|
| 102 |
+
<div className="px-4 pt-4 pb-3 flex items-center justify-between">
|
| 103 |
+
<div className="flex items-center gap-2">
|
| 104 |
+
<span className={`w-2 h-2 rounded-full ${record.status === 'completed' ? 'bg-green-400/80' : 'bg-red-400/80'}`} />
|
| 105 |
+
<span className="text-[11px] text-text-secondary/60">{formatDate(record.created_at)}</span>
|
| 106 |
+
</div>
|
| 107 |
+
<div className="flex items-center gap-1">
|
| 108 |
+
<span className="text-[10px] uppercase tracking-wider text-text-secondary/40 bg-bg-tertiary/30 px-2 py-0.5 rounded">
|
| 109 |
+
{record.output_mode}
|
| 110 |
+
</span>
|
| 111 |
+
<span className="text-[10px] uppercase tracking-wider text-text-secondary/40 bg-bg-tertiary/30 px-2 py-0.5 rounded">
|
| 112 |
+
{record.quality}
|
| 113 |
+
</span>
|
| 114 |
+
</div>
|
| 115 |
+
</div>
|
| 116 |
+
|
| 117 |
+
{/* 提示词预览 */}
|
| 118 |
+
<div className="px-4 pb-3">
|
| 119 |
+
<p className="text-sm text-text-primary/80 line-clamp-3 leading-relaxed">{record.prompt}</p>
|
| 120 |
+
</div>
|
| 121 |
+
|
| 122 |
+
{/* 代码展开区 */}
|
| 123 |
+
{expandedId === record.id && record.code && (
|
| 124 |
+
<div className="px-4 pb-3">
|
| 125 |
+
<pre className="text-[11px] text-text-secondary/70 bg-bg-primary/50 rounded-lg p-3 max-h-48 overflow-auto font-mono leading-relaxed">
|
| 126 |
+
{record.code}
|
| 127 |
+
</pre>
|
| 128 |
+
</div>
|
| 129 |
+
)}
|
| 130 |
+
|
| 131 |
+
{/* 操作栏 */}
|
| 132 |
+
<div className="px-4 py-3 border-t border-bg-tertiary/20 flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
| 133 |
+
{record.code && (
|
| 134 |
+
<button
|
| 135 |
+
onClick={() => setExpandedId(expandedId === record.id ? null : record.id)}
|
| 136 |
+
className="text-[11px] text-text-secondary/60 hover:text-text-primary px-2 py-1 rounded hover:bg-bg-tertiary/30 transition-colors"
|
| 137 |
+
>
|
| 138 |
+
{t('history.viewCode')}
|
| 139 |
+
</button>
|
| 140 |
+
)}
|
| 141 |
+
{onReusePrompt && (
|
| 142 |
+
<button
|
| 143 |
+
onClick={() => onReusePrompt(record.prompt)}
|
| 144 |
+
className="text-[11px] text-text-secondary/60 hover:text-text-primary px-2 py-1 rounded hover:bg-bg-tertiary/30 transition-colors"
|
| 145 |
+
>
|
| 146 |
+
{t('history.reuse')}
|
| 147 |
+
</button>
|
| 148 |
+
)}
|
| 149 |
+
<div className="flex-1" />
|
| 150 |
+
{confirmDeleteId === record.id ? (
|
| 151 |
+
<div className="flex items-center gap-1">
|
| 152 |
+
<button
|
| 153 |
+
onClick={() => void handleDelete(record.id)}
|
| 154 |
+
className="text-[11px] text-red-400 hover:text-red-300 px-2 py-1 rounded hover:bg-red-500/10 transition-colors"
|
| 155 |
+
>
|
| 156 |
+
{t('common.confirm')}
|
| 157 |
+
</button>
|
| 158 |
+
<button
|
| 159 |
+
onClick={() => setConfirmDeleteId(null)}
|
| 160 |
+
className="text-[11px] text-text-secondary/60 hover:text-text-primary px-2 py-1 rounded hover:bg-bg-tertiary/30 transition-colors"
|
| 161 |
+
>
|
| 162 |
+
{t('common.cancel')}
|
| 163 |
+
</button>
|
| 164 |
+
</div>
|
| 165 |
+
) : (
|
| 166 |
+
<button
|
| 167 |
+
onClick={() => setConfirmDeleteId(record.id)}
|
| 168 |
+
className="text-[11px] text-text-secondary/40 hover:text-red-400 px-2 py-1 rounded hover:bg-red-500/10 transition-colors"
|
| 169 |
+
>
|
| 170 |
+
{t('history.delete')}
|
| 171 |
+
</button>
|
| 172 |
+
)}
|
| 173 |
+
</div>
|
| 174 |
+
</div>
|
| 175 |
+
))}
|
| 176 |
+
</div>
|
| 177 |
+
)}
|
| 178 |
+
|
| 179 |
+
{/* 加载更多 */}
|
| 180 |
+
{records.length > 0 && (
|
| 181 |
+
<div className="flex justify-center mt-8">
|
| 182 |
+
{hasMore ? (
|
| 183 |
+
<button
|
| 184 |
+
onClick={handleLoadMore}
|
| 185 |
+
disabled={loading}
|
| 186 |
+
className="px-6 py-2 text-xs text-text-secondary/70 hover:text-text-primary bg-bg-secondary/30 hover:bg-bg-secondary/50 rounded-lg transition-colors disabled:opacity-50"
|
| 187 |
+
>
|
| 188 |
+
{loading ? t('common.loading') : t('history.loadMore')}
|
| 189 |
+
</button>
|
| 190 |
+
) : (
|
| 191 |
+
<span className="text-xs text-text-secondary/30">{t('history.noMore')}</span>
|
| 192 |
+
)}
|
| 193 |
+
</div>
|
| 194 |
+
)}
|
| 195 |
+
|
| 196 |
+
{/* 初始加载 */}
|
| 197 |
+
{loading && records.length === 0 && (
|
| 198 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 animate-pulse">
|
| 199 |
+
{Array.from({ length: 6 }).map((_, i) => (
|
| 200 |
+
<div key={i} className="rounded-2xl bg-bg-secondary/25 border border-bg-tertiary/30 h-40" />
|
| 201 |
+
))}
|
| 202 |
+
</div>
|
| 203 |
+
)}
|
| 204 |
+
</div>
|
| 205 |
+
);
|
| 206 |
+
}
|
frontend/src/components/ImageInputModeModal.tsx
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useI18n } from '../i18n';
|
| 2 |
+
import { useModalTransition } from '../hooks/useModalTransition';
|
| 3 |
+
|
| 4 |
+
interface ImageInputModeModalProps {
|
| 5 |
+
isOpen: boolean;
|
| 6 |
+
onClose: () => void;
|
| 7 |
+
onImport: () => void;
|
| 8 |
+
onDraw: () => void;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export function ImageInputModeModal({ isOpen, onClose, onImport, onDraw }: ImageInputModeModalProps) {
|
| 12 |
+
const { t } = useI18n();
|
| 13 |
+
const { shouldRender, isExiting } = useModalTransition(isOpen);
|
| 14 |
+
|
| 15 |
+
if (!shouldRender) {
|
| 16 |
+
return null;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
return (
|
| 20 |
+
<div className="fixed inset-0 z-[120] flex items-center justify-center p-4">
|
| 21 |
+
<div
|
| 22 |
+
className={`absolute inset-0 bg-bg-primary/60 backdrop-blur-md transition-opacity duration-300 ${
|
| 23 |
+
isExiting ? 'opacity-0' : 'animate-overlay-wash-in'
|
| 24 |
+
}`}
|
| 25 |
+
onClick={onClose}
|
| 26 |
+
/>
|
| 27 |
+
|
| 28 |
+
<div
|
| 29 |
+
className={`relative w-full max-w-sm bg-bg-secondary rounded-[2.25rem] p-7 shadow-2xl border border-border/5 ${
|
| 30 |
+
isExiting ? 'animate-fade-out-soft' : 'animate-fade-in-soft'
|
| 31 |
+
}`}
|
| 32 |
+
>
|
| 33 |
+
<div className="flex items-center mb-6">
|
| 34 |
+
<button
|
| 35 |
+
type="button"
|
| 36 |
+
onClick={onClose}
|
| 37 |
+
className="inline-flex items-center gap-2 px-3 py-2 text-sm text-text-secondary hover:text-text-primary hover:bg-bg-primary/50 rounded-2xl transition-all"
|
| 38 |
+
>
|
| 39 |
+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 40 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
| 41 |
+
</svg>
|
| 42 |
+
{t('canvasMode.back')}
|
| 43 |
+
</button>
|
| 44 |
+
</div>
|
| 45 |
+
|
| 46 |
+
<div className="mb-6">
|
| 47 |
+
<div className="flex items-center gap-3 mb-2">
|
| 48 |
+
<div className="w-2 h-2 rounded-full bg-accent-rgb/40 animate-pulse" />
|
| 49 |
+
<h2 className="text-lg font-medium text-text-primary tracking-tight">{t('canvasMode.title')}</h2>
|
| 50 |
+
</div>
|
| 51 |
+
</div>
|
| 52 |
+
|
| 53 |
+
<div className="grid grid-cols-2 gap-3">
|
| 54 |
+
<button
|
| 55 |
+
type="button"
|
| 56 |
+
onClick={onImport}
|
| 57 |
+
className="group flex flex-col items-center justify-center rounded-2xl bg-bg-primary/45 hover:bg-bg-primary/65 px-4 py-4 text-center transition-all"
|
| 58 |
+
>
|
| 59 |
+
<div className="mb-2 inline-flex h-9 w-9 items-center justify-center rounded-xl bg-accent/10 text-accent">
|
| 60 |
+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 61 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 16V4m0 0l-4 4m4-4l4 4M4 20h16" />
|
| 62 |
+
</svg>
|
| 63 |
+
</div>
|
| 64 |
+
<div className="text-sm font-medium text-text-primary">{t('canvasMode.import')}</div>
|
| 65 |
+
</button>
|
| 66 |
+
|
| 67 |
+
<button
|
| 68 |
+
type="button"
|
| 69 |
+
onClick={onDraw}
|
| 70 |
+
className="group flex flex-col items-center justify-center rounded-2xl bg-bg-primary/45 hover:bg-bg-primary/65 px-4 py-4 text-center transition-all"
|
| 71 |
+
>
|
| 72 |
+
<div className="mb-2 inline-flex h-9 w-9 items-center justify-center rounded-xl bg-accent/10 text-accent">
|
| 73 |
+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 74 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 20h9" />
|
| 75 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4 12.5-12.5z" />
|
| 76 |
+
</svg>
|
| 77 |
+
</div>
|
| 78 |
+
<div className="text-sm font-medium text-text-primary">{t('canvasMode.draw')}</div>
|
| 79 |
+
</button>
|
| 80 |
+
</div>
|
| 81 |
+
</div>
|
| 82 |
+
</div>
|
| 83 |
+
);
|
| 84 |
+
}
|
frontend/src/components/ImagePreview.tsx
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { memo, useState, type MouseEvent as ReactMouseEvent } from 'react';
|
| 2 |
+
import { ImageLightbox } from './image-preview/lightbox';
|
| 3 |
+
import { useImageDownload } from './image-preview/use-image-download';
|
| 4 |
+
import { useI18n } from '../i18n';
|
| 5 |
+
|
| 6 |
+
interface ImagePreviewProps {
|
| 7 |
+
imageUrls: string[];
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export const ImagePreview = memo(function ImagePreview({ imageUrls }: ImagePreviewProps) {
|
| 11 |
+
const { t } = useI18n();
|
| 12 |
+
const [activeIndex, setActiveIndex] = useState(0);
|
| 13 |
+
const [isLightboxOpen, setIsLightboxOpen] = useState(false);
|
| 14 |
+
const [zoom, setZoom] = useState(1);
|
| 15 |
+
const { isDownloadingSingle, isDownloadingAll, handleDownloadAll, handleDownloadSingle } = useImageDownload(imageUrls);
|
| 16 |
+
const safeActiveIndex = Math.min(activeIndex, Math.max(0, imageUrls.length - 1));
|
| 17 |
+
const activeImage = imageUrls[safeActiveIndex];
|
| 18 |
+
const hasImages = imageUrls.length > 0;
|
| 19 |
+
|
| 20 |
+
return (
|
| 21 |
+
<div className="h-full flex flex-col bg-bg-secondary/30 rounded-2xl overflow-hidden">
|
| 22 |
+
<div className="flex items-center justify-between px-4 py-2.5">
|
| 23 |
+
<h3 className="text-xs font-medium text-text-secondary/80 uppercase tracking-wide">{t('image.title')}</h3>
|
| 24 |
+
<div className="flex items-center gap-3">
|
| 25 |
+
<button
|
| 26 |
+
onClick={() => setIsLightboxOpen(true)}
|
| 27 |
+
disabled={!hasImages}
|
| 28 |
+
className="text-xs text-text-secondary/70 hover:text-accent transition-colors flex items-center gap-1.5 disabled:opacity-40 disabled:cursor-not-allowed"
|
| 29 |
+
>
|
| 30 |
+
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 31 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 3h6m0 0v6m0-6l-7 7M9 21H3m0 0v-6m0 6l7-7" />
|
| 32 |
+
</svg>
|
| 33 |
+
{t('image.openLightbox')}
|
| 34 |
+
</button>
|
| 35 |
+
<button
|
| 36 |
+
onClick={() => void handleDownloadSingle(activeImage, safeActiveIndex)}
|
| 37 |
+
disabled={!hasImages || isDownloadingSingle || isDownloadingAll}
|
| 38 |
+
className="text-xs text-text-secondary/70 hover:text-accent transition-colors flex items-center gap-1.5 disabled:opacity-40 disabled:cursor-not-allowed"
|
| 39 |
+
>
|
| 40 |
+
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 41 |
+
<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" />
|
| 42 |
+
</svg>
|
| 43 |
+
{isDownloadingSingle ? t('image.downloading') : t('common.download')}
|
| 44 |
+
</button>
|
| 45 |
+
<button
|
| 46 |
+
onClick={() => void handleDownloadAll(imageUrls)}
|
| 47 |
+
disabled={!hasImages || isDownloadingAll || isDownloadingSingle}
|
| 48 |
+
className="text-xs text-text-secondary/70 hover:text-accent transition-colors flex items-center gap-1.5 disabled:opacity-40 disabled:cursor-not-allowed"
|
| 49 |
+
>
|
| 50 |
+
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 51 |
+
<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" />
|
| 52 |
+
</svg>
|
| 53 |
+
{isDownloadingAll ? t('image.zipping') : t('image.downloadAll')}
|
| 54 |
+
</button>
|
| 55 |
+
</div>
|
| 56 |
+
</div>
|
| 57 |
+
|
| 58 |
+
<div className="flex-1 bg-black/90 flex items-center justify-center">
|
| 59 |
+
{activeImage ? (
|
| 60 |
+
<div
|
| 61 |
+
role="button"
|
| 62 |
+
tabIndex={0}
|
| 63 |
+
onClick={(event: ReactMouseEvent<HTMLDivElement>) => {
|
| 64 |
+
if (event.button !== 0) {
|
| 65 |
+
return;
|
| 66 |
+
}
|
| 67 |
+
setIsLightboxOpen(true);
|
| 68 |
+
}}
|
| 69 |
+
onKeyDown={(event) => {
|
| 70 |
+
if (event.key === 'Enter' || event.key === ' ') {
|
| 71 |
+
event.preventDefault();
|
| 72 |
+
setIsLightboxOpen(true);
|
| 73 |
+
}
|
| 74 |
+
}}
|
| 75 |
+
className="w-full h-full cursor-zoom-in"
|
| 76 |
+
title={t('image.openTitle')}
|
| 77 |
+
>
|
| 78 |
+
<img src={activeImage} alt={t('image.itemAlt', { index: safeActiveIndex + 1 })} className="w-full h-full object-contain" />
|
| 79 |
+
</div>
|
| 80 |
+
) : (
|
| 81 |
+
<p className="text-xs text-text-secondary/60">{t('image.empty')}</p>
|
| 82 |
+
)}
|
| 83 |
+
</div>
|
| 84 |
+
|
| 85 |
+
{imageUrls.length > 1 && (
|
| 86 |
+
<div className="px-3 py-2 bg-bg-secondary/40">
|
| 87 |
+
<div className="flex gap-2 overflow-x-auto">
|
| 88 |
+
{imageUrls.map((url, index) => (
|
| 89 |
+
<button
|
| 90 |
+
key={`${url}-${index}`}
|
| 91 |
+
type="button"
|
| 92 |
+
onClick={() => setActiveIndex(index)}
|
| 93 |
+
className={`shrink-0 rounded-md overflow-hidden border transition-all ${
|
| 94 |
+
index === safeActiveIndex ? 'border-accent' : 'border-border/50 opacity-80 hover:opacity-100'
|
| 95 |
+
}`}
|
| 96 |
+
>
|
| 97 |
+
<img
|
| 98 |
+
src={url}
|
| 99 |
+
alt={t('image.thumbAlt', { index: index + 1 })}
|
| 100 |
+
className="w-16 h-12 object-cover"
|
| 101 |
+
/>
|
| 102 |
+
</button>
|
| 103 |
+
))}
|
| 104 |
+
</div>
|
| 105 |
+
</div>
|
| 106 |
+
)}
|
| 107 |
+
|
| 108 |
+
<ImageLightbox
|
| 109 |
+
isOpen={isLightboxOpen}
|
| 110 |
+
activeImage={activeImage}
|
| 111 |
+
activeIndex={safeActiveIndex}
|
| 112 |
+
total={imageUrls.length}
|
| 113 |
+
zoom={zoom}
|
| 114 |
+
maxZoom={3}
|
| 115 |
+
onZoomChange={setZoom}
|
| 116 |
+
onClose={() => {
|
| 117 |
+
setIsLightboxOpen(false);
|
| 118 |
+
setZoom(1);
|
| 119 |
+
}}
|
| 120 |
+
/>
|
| 121 |
+
</div>
|
| 122 |
+
);
|
| 123 |
+
});
|
frontend/src/components/InputForm.tsx
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// 输入表单组件 - MD3 风格
|
| 2 |
+
|
| 3 |
+
import type { StudioKind } from '../studio/protocol/studio-agent-types';
|
| 4 |
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
| 5 |
+
import type { OutputMode, Quality, ReferenceImage } from '../types/api';
|
| 6 |
+
import { loadSettings } from '../lib/settings';
|
| 7 |
+
import { FormToolbar } from './input-form/form-toolbar';
|
| 8 |
+
import { ReferenceImageList } from './input-form/reference-image-list';
|
| 9 |
+
import { useReferenceImages } from './input-form/use-reference-images';
|
| 10 |
+
import { useI18n } from '../i18n';
|
| 11 |
+
import { ImageInputModeModal } from './ImageInputModeModal';
|
| 12 |
+
import { CanvasWorkspaceModal } from './canvas/CanvasWorkspaceModal';
|
| 13 |
+
|
| 14 |
+
interface InputFormProps {
|
| 15 |
+
concept: string;
|
| 16 |
+
onConceptChange: (value: string) => void;
|
| 17 |
+
onSecretStudioOpen?: (studioKind: StudioKind) => void;
|
| 18 |
+
onSubmit: (data: {
|
| 19 |
+
concept: string;
|
| 20 |
+
quality: Quality;
|
| 21 |
+
outputMode: OutputMode;
|
| 22 |
+
referenceImages?: ReferenceImage[];
|
| 23 |
+
}) => void;
|
| 24 |
+
loading: boolean;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
const STUDIO_KEYWORDS: Record<string, StudioKind> = {
|
| 28 |
+
hellomanim: 'manim',
|
| 29 |
+
helloplot: 'plot',
|
| 30 |
+
};
|
| 31 |
+
const TRIGGER_DELAY_MS = 1200;
|
| 32 |
+
|
| 33 |
+
export function InputForm({ concept, onConceptChange, onSecretStudioOpen, onSubmit, loading }: InputFormProps) {
|
| 34 |
+
const { t } = useI18n();
|
| 35 |
+
const [localError, setLocalError] = useState<string | null>(null);
|
| 36 |
+
const [quality, setQuality] = useState<Quality>(loadSettings().video.quality);
|
| 37 |
+
const [outputMode, setOutputMode] = useState<OutputMode>('video');
|
| 38 |
+
const [isRecognizing, setIsRecognizing] = useState(false);
|
| 39 |
+
const [isImageModeOpen, setIsImageModeOpen] = useState(false);
|
| 40 |
+
const [isCanvasOpen, setIsCanvasOpen] = useState(false);
|
| 41 |
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
| 42 |
+
const studioKeywordTriggeredRef = useRef(false);
|
| 43 |
+
const triggerTimerRef = useRef<number | null>(null);
|
| 44 |
+
|
| 45 |
+
const {
|
| 46 |
+
images,
|
| 47 |
+
imageError,
|
| 48 |
+
isDragging,
|
| 49 |
+
fileInputRef,
|
| 50 |
+
addImages,
|
| 51 |
+
appendImages,
|
| 52 |
+
removeImage,
|
| 53 |
+
handleDrop,
|
| 54 |
+
handleDragOver,
|
| 55 |
+
handleDragEnter,
|
| 56 |
+
handleDragLeave,
|
| 57 |
+
} = useReferenceImages();
|
| 58 |
+
|
| 59 |
+
const derivedError = useMemo(() => {
|
| 60 |
+
const trimmed = concept.trim();
|
| 61 |
+
if (!trimmed) {
|
| 62 |
+
return null;
|
| 63 |
+
}
|
| 64 |
+
if (trimmed.length < 5) {
|
| 65 |
+
return t('form.error.minLengthShort');
|
| 66 |
+
}
|
| 67 |
+
return null;
|
| 68 |
+
}, [concept, t]);
|
| 69 |
+
|
| 70 |
+
const handleSubmit = useCallback(() => {
|
| 71 |
+
if (concept.trim().length < 5) {
|
| 72 |
+
setLocalError(t('form.error.minLength'));
|
| 73 |
+
textareaRef.current?.focus();
|
| 74 |
+
return;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
setLocalError(null);
|
| 78 |
+
onSubmit({
|
| 79 |
+
concept: concept.trim(),
|
| 80 |
+
quality,
|
| 81 |
+
outputMode,
|
| 82 |
+
referenceImages: images.length > 0 ? images : undefined,
|
| 83 |
+
});
|
| 84 |
+
}, [concept, quality, outputMode, images, onSubmit, t]);
|
| 85 |
+
|
| 86 |
+
const handleTextareaKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
| 87 |
+
if (event.key === 'Enter' && event.shiftKey && !loading) {
|
| 88 |
+
event.preventDefault();
|
| 89 |
+
handleSubmit();
|
| 90 |
+
}
|
| 91 |
+
};
|
| 92 |
+
|
| 93 |
+
// 组件卸载时清理定时器
|
| 94 |
+
useEffect(() => {
|
| 95 |
+
return () => {
|
| 96 |
+
if (triggerTimerRef.current) clearTimeout(triggerTimerRef.current);
|
| 97 |
+
};
|
| 98 |
+
}, []);
|
| 99 |
+
|
| 100 |
+
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
| 101 |
+
e.preventDefault();
|
| 102 |
+
handleSubmit();
|
| 103 |
+
};
|
| 104 |
+
|
| 105 |
+
const handleOpenImageMode = useCallback(() => {
|
| 106 |
+
setIsImageModeOpen(true);
|
| 107 |
+
}, []);
|
| 108 |
+
|
| 109 |
+
const handleCloseImageMode = useCallback(() => {
|
| 110 |
+
setIsImageModeOpen(false);
|
| 111 |
+
}, []);
|
| 112 |
+
|
| 113 |
+
const handleImportImages = useCallback(() => {
|
| 114 |
+
setIsImageModeOpen(false);
|
| 115 |
+
fileInputRef.current?.click();
|
| 116 |
+
}, [fileInputRef]);
|
| 117 |
+
|
| 118 |
+
const handleDrawMode = useCallback(() => {
|
| 119 |
+
setIsImageModeOpen(false);
|
| 120 |
+
setIsCanvasOpen(true);
|
| 121 |
+
}, []);
|
| 122 |
+
|
| 123 |
+
const handleCanvasComplete = useCallback((nextImages: ReferenceImage[]) => {
|
| 124 |
+
appendImages(nextImages);
|
| 125 |
+
setIsCanvasOpen(false);
|
| 126 |
+
}, [appendImages]);
|
| 127 |
+
|
| 128 |
+
const handleConceptChange = (value: string) => {
|
| 129 |
+
onConceptChange(value);
|
| 130 |
+
|
| 131 |
+
const normalizedConcept = value.trim().toLowerCase();
|
| 132 |
+
const matchedStudioKind = STUDIO_KEYWORDS[normalizedConcept];
|
| 133 |
+
if (matchedStudioKind && !loading) {
|
| 134 |
+
if (!studioKeywordTriggeredRef.current) {
|
| 135 |
+
studioKeywordTriggeredRef.current = true;
|
| 136 |
+
setIsRecognizing(true);
|
| 137 |
+
triggerTimerRef.current = window.setTimeout(() => {
|
| 138 |
+
onSecretStudioOpen?.(matchedStudioKind);
|
| 139 |
+
setIsRecognizing(false);
|
| 140 |
+
}, TRIGGER_DELAY_MS);
|
| 141 |
+
}
|
| 142 |
+
return;
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
if (studioKeywordTriggeredRef.current && !matchedStudioKind) {
|
| 146 |
+
studioKeywordTriggeredRef.current = false;
|
| 147 |
+
setIsRecognizing(false);
|
| 148 |
+
if (triggerTimerRef.current) {
|
| 149 |
+
clearTimeout(triggerTimerRef.current);
|
| 150 |
+
triggerTimerRef.current = null;
|
| 151 |
+
}
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
setLocalError(null);
|
| 155 |
+
};
|
| 156 |
+
|
| 157 |
+
return (
|
| 158 |
+
<div className="w-full max-w-2xl mx-auto">
|
| 159 |
+
<form onSubmit={handleFormSubmit} className="space-y-6">
|
| 160 |
+
<div
|
| 161 |
+
className={`relative transition-all duration-200 ${isDragging ? 'scale-[1.02]' : ''}`}
|
| 162 |
+
onDrop={handleDrop}
|
| 163 |
+
onDragOver={handleDragOver}
|
| 164 |
+
onDragEnter={handleDragEnter}
|
| 165 |
+
onDragLeave={handleDragLeave}
|
| 166 |
+
>
|
| 167 |
+
<label
|
| 168 |
+
htmlFor="concept"
|
| 169 |
+
className={`absolute left-4 -top-2.5 px-2 bg-bg-primary text-xs font-medium transition-all z-10 ${
|
| 170 |
+
isDragging || isRecognizing ? 'text-accent' : (localError || derivedError) ? 'text-red-500' : 'text-text-secondary'
|
| 171 |
+
}`}
|
| 172 |
+
>
|
| 173 |
+
{isDragging ? t('form.label.dragging') : isRecognizing ? '暗号确认中...' : localError || derivedError || t('form.label.default')}
|
| 174 |
+
</label>
|
| 175 |
+
<textarea
|
| 176 |
+
ref={textareaRef}
|
| 177 |
+
id="concept"
|
| 178 |
+
name="concept"
|
| 179 |
+
rows={4}
|
| 180 |
+
placeholder={t('form.placeholder')}
|
| 181 |
+
disabled={loading || isRecognizing}
|
| 182 |
+
value={concept}
|
| 183 |
+
onChange={(e) => handleConceptChange(e.target.value)}
|
| 184 |
+
onKeyDown={handleTextareaKeyDown}
|
| 185 |
+
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 ${
|
| 186 |
+
isDragging
|
| 187 |
+
? 'ring-2 ring-accent/50 bg-accent/5 border-2 border-dashed border-accent/30'
|
| 188 |
+
: isRecognizing
|
| 189 |
+
? 'ring-2 ring-accent/40 bg-accent/[0.03] animate-pulse'
|
| 190 |
+
: (localError || derivedError)
|
| 191 |
+
? 'focus:ring-red-500/20 bg-red-50/50 dark:bg-red-900/10'
|
| 192 |
+
: 'focus:ring-accent/20 focus:bg-bg-secondary/70'
|
| 193 |
+
}`}
|
| 194 |
+
/>
|
| 195 |
+
</div>
|
| 196 |
+
|
| 197 |
+
<FormToolbar
|
| 198 |
+
loading={loading || isRecognizing}
|
| 199 |
+
quality={quality}
|
| 200 |
+
outputMode={outputMode}
|
| 201 |
+
imageCount={images.length}
|
| 202 |
+
fileInputRef={fileInputRef}
|
| 203 |
+
onChangeQuality={setQuality}
|
| 204 |
+
onChangeOutputMode={setOutputMode}
|
| 205 |
+
onOpenImageMode={handleOpenImageMode}
|
| 206 |
+
onUploadFiles={addImages}
|
| 207 |
+
/>
|
| 208 |
+
|
| 209 |
+
<ReferenceImageList images={images} loading={loading || isRecognizing} onRemove={removeImage} />
|
| 210 |
+
|
| 211 |
+
{imageError && <p className="text-xs text-red-500">{imageError}</p>}
|
| 212 |
+
|
| 213 |
+
<div className="flex justify-center pt-4">
|
| 214 |
+
<button
|
| 215 |
+
type="submit"
|
| 216 |
+
disabled={loading || isRecognizing || concept.trim().length < 5}
|
| 217 |
+
className="px-12 py-3.5 bg-accent/85 text-white font-medium rounded-full shadow-sm shadow-accent/5 transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-accent/90 active:bg-accent-hover/90"
|
| 218 |
+
>
|
| 219 |
+
<span className="flex items-center gap-2">
|
| 220 |
+
{loading || isRecognizing ? (
|
| 221 |
+
<>
|
| 222 |
+
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
| 223 |
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
| 224 |
+
<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" />
|
| 225 |
+
</svg>
|
| 226 |
+
{isRecognizing ? '正在启动...' : t('form.submitting')}
|
| 227 |
+
</>
|
| 228 |
+
) : (
|
| 229 |
+
t('form.submit.plan')
|
| 230 |
+
)}
|
| 231 |
+
</span>
|
| 232 |
+
</button>
|
| 233 |
+
</div>
|
| 234 |
+
|
| 235 |
+
<p className="text-center text-xs text-text-secondary/50">
|
| 236 |
+
{t('form.shortcutPrefix')} <kbd className="px-1.5 py-0.5 bg-bg-secondary/50 rounded text-[10px]">Shift</kbd> + <kbd className="px-1.5 py-0.5 bg-bg-secondary/50 rounded text-[10px]">Enter</kbd> {t('form.shortcutSuffix')}
|
| 237 |
+
</p>
|
| 238 |
+
</form>
|
| 239 |
+
|
| 240 |
+
<ImageInputModeModal
|
| 241 |
+
isOpen={isImageModeOpen}
|
| 242 |
+
onClose={handleCloseImageMode}
|
| 243 |
+
onImport={handleImportImages}
|
| 244 |
+
onDraw={handleDrawMode}
|
| 245 |
+
/>
|
| 246 |
+
|
| 247 |
+
<CanvasWorkspaceModal
|
| 248 |
+
isOpen={isCanvasOpen}
|
| 249 |
+
onClose={() => setIsCanvasOpen(false)}
|
| 250 |
+
onComplete={handleCanvasComplete}
|
| 251 |
+
/>
|
| 252 |
+
</div>
|
| 253 |
+
);
|
| 254 |
+
}
|
frontend/src/components/LoadingSpinner.tsx
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// 加载动画组件 - 大猫头 + 波浪猫爪
|
| 2 |
+
|
| 3 |
+
import { useEffect, useState, useRef } from 'react';
|
| 4 |
+
import { useI18n } from '../i18n';
|
| 5 |
+
|
| 6 |
+
type Stage = 'analyzing' | 'generating' | 'refining' | 'rendering' | 'still-rendering';
|
| 7 |
+
|
| 8 |
+
interface LoadingSpinnerProps {
|
| 9 |
+
stage: Stage;
|
| 10 |
+
jobId?: string;
|
| 11 |
+
submittedAt?: string;
|
| 12 |
+
onCancel?: () => void;
|
| 13 |
+
onOpenGame?: () => void;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
const STAGE_CONFIG = {
|
| 17 |
+
analyzing: { key: 'loading.analyzing', start: 0, target: 20 },
|
| 18 |
+
generating: { key: 'loading.generating', start: 20, target: 66 },
|
| 19 |
+
refining: { key: 'loading.refining', start: 66, target: 85 },
|
| 20 |
+
rendering: { key: 'loading.rendering', start: 85, target: 97 },
|
| 21 |
+
'still-rendering': { key: 'loading.stillRendering', start: 85, target: 97 },
|
| 22 |
+
} as const;
|
| 23 |
+
|
| 24 |
+
function usePerceivedProgress(stage: Stage): number {
|
| 25 |
+
const [progress, setProgress] = useState(0);
|
| 26 |
+
const prevStageRef = useRef(stage);
|
| 27 |
+
const stageStartProgressRef = useRef(0);
|
| 28 |
+
const enteredAtRef = useRef<number | null>(null);
|
| 29 |
+
|
| 30 |
+
useEffect(() => {
|
| 31 |
+
if (enteredAtRef.current === null) {
|
| 32 |
+
enteredAtRef.current = Date.now();
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
if (stage !== prevStageRef.current) {
|
| 36 |
+
prevStageRef.current = stage;
|
| 37 |
+
enteredAtRef.current = Date.now();
|
| 38 |
+
stageStartProgressRef.current = Math.max(stageStartProgressRef.current, progress, STAGE_CONFIG[stage].start);
|
| 39 |
+
}
|
| 40 |
+
}, [stage, progress]);
|
| 41 |
+
|
| 42 |
+
useEffect(() => {
|
| 43 |
+
const id = setInterval(() => {
|
| 44 |
+
const enteredAt = enteredAtRef.current ?? Date.now();
|
| 45 |
+
const elapsed = (Date.now() - enteredAt) / 1000;
|
| 46 |
+
const { target } = STAGE_CONFIG[stage];
|
| 47 |
+
const start = Math.max(stageStartProgressRef.current, STAGE_CONFIG[stage].start);
|
| 48 |
+
const range = Math.max(0, target - start);
|
| 49 |
+
const quickGain = range * 0.72 * (1 - Math.exp(-elapsed / 5));
|
| 50 |
+
const comfortGain = elapsed > 10 ? Math.floor((elapsed - 10) / 4) : 0;
|
| 51 |
+
const next = Math.min(target, start + quickGain + comfortGain);
|
| 52 |
+
setProgress((current) => Math.max(current, next));
|
| 53 |
+
}, 120);
|
| 54 |
+
return () => clearInterval(id);
|
| 55 |
+
}, [stage]);
|
| 56 |
+
|
| 57 |
+
return Math.min(97, progress);
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
/** 大猫头 SVG - 眼睛灵敏转动 */
|
| 61 |
+
function CatHead() {
|
| 62 |
+
const [eyeOffset, setEyeOffset] = useState({ x: 0, y: 0 });
|
| 63 |
+
const containerRef = useRef<SVGSVGElement>(null);
|
| 64 |
+
|
| 65 |
+
useEffect(() => {
|
| 66 |
+
const handleMouseMove = (e: MouseEvent) => {
|
| 67 |
+
if (!containerRef.current) return;
|
| 68 |
+
const rect = containerRef.current.getBoundingClientRect();
|
| 69 |
+
const centerX = rect.left + rect.width / 2;
|
| 70 |
+
const centerY = rect.top + rect.height / 2;
|
| 71 |
+
|
| 72 |
+
const dx = e.clientX - centerX;
|
| 73 |
+
const dy = e.clientY - centerY;
|
| 74 |
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
| 75 |
+
|
| 76 |
+
// 灵敏算法:减小阻尼,增大范围
|
| 77 |
+
const limit = 6;
|
| 78 |
+
const sensitivity = 20; // 越小越灵敏
|
| 79 |
+
const moveX = (dx / (dist + sensitivity)) * limit;
|
| 80 |
+
const moveY = (dy / (dist + sensitivity)) * limit;
|
| 81 |
+
|
| 82 |
+
setEyeOffset({ x: moveX, y: moveY });
|
| 83 |
+
};
|
| 84 |
+
|
| 85 |
+
window.addEventListener('mousemove', handleMouseMove);
|
| 86 |
+
return () => window.removeEventListener('mousemove', handleMouseMove);
|
| 87 |
+
}, []);
|
| 88 |
+
|
| 89 |
+
return (
|
| 90 |
+
<svg
|
| 91 |
+
ref={containerRef}
|
| 92 |
+
width={100} height={100} viewBox="0 0 140 140"
|
| 93 |
+
className="drop-shadow-lg"
|
| 94 |
+
>
|
| 95 |
+
<g transform="translate(70, 70)">
|
| 96 |
+
<path
|
| 97 |
+
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"
|
| 98 |
+
fill="#455a64"
|
| 99 |
+
/>
|
| 100 |
+
<circle cx="-35" cy="-5" r="18" fill="#fff" />
|
| 101 |
+
<circle cx="35" cy="-5" r="18" fill="#fff" />
|
| 102 |
+
<circle
|
| 103 |
+
cx="-38" cy="-5" r="6" fill="#455a64"
|
| 104 |
+
style={{ transform: `translate(${eyeOffset.x}px, ${eyeOffset.y}px)`, transition: 'transform 0.08s ease-out' }}
|
| 105 |
+
/>
|
| 106 |
+
<circle
|
| 107 |
+
cx="32" cy="-5" r="6" fill="#455a64"
|
| 108 |
+
style={{ transform: `translate(${eyeOffset.x}px, ${eyeOffset.y}px)`, transition: 'transform 0.08s ease-out' }}
|
| 109 |
+
/>
|
| 110 |
+
</g>
|
| 111 |
+
</svg>
|
| 112 |
+
);
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
function FloatingCat() {
|
| 116 |
+
const [y, setY] = useState(0);
|
| 117 |
+
useEffect(() => {
|
| 118 |
+
let t = 0;
|
| 119 |
+
let id: number;
|
| 120 |
+
const animate = () => {
|
| 121 |
+
t += 0.02;
|
| 122 |
+
setY(Math.sin(t) * 5);
|
| 123 |
+
id = requestAnimationFrame(animate);
|
| 124 |
+
};
|
| 125 |
+
id = requestAnimationFrame(animate);
|
| 126 |
+
return () => cancelAnimationFrame(id);
|
| 127 |
+
}, []);
|
| 128 |
+
return <div style={{ transform: `translateY(${y}px)` }}><CatHead /></div>;
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
function WavingPaw({ index, total }: { index: number; total: number }) {
|
| 132 |
+
const [scale, setScale] = useState(1);
|
| 133 |
+
const [y, setY] = useState(0);
|
| 134 |
+
const [opacity, setOpacity] = useState(0.25);
|
| 135 |
+
|
| 136 |
+
useEffect(() => {
|
| 137 |
+
let t = 0;
|
| 138 |
+
const phase = (index / total) * Math.PI * 2;
|
| 139 |
+
let id: number;
|
| 140 |
+
const animate = () => {
|
| 141 |
+
t += 0.04;
|
| 142 |
+
setY(Math.sin(t + phase) * 4);
|
| 143 |
+
setScale(1 + Math.sin(t + phase) * 0.15);
|
| 144 |
+
setOpacity(0.55 + Math.sin(t + phase) * 0.25);
|
| 145 |
+
id = requestAnimationFrame(animate);
|
| 146 |
+
};
|
| 147 |
+
id = requestAnimationFrame(animate);
|
| 148 |
+
return () => cancelAnimationFrame(id);
|
| 149 |
+
}, [index, total]);
|
| 150 |
+
|
| 151 |
+
return (
|
| 152 |
+
<div style={{ transform: `translateY(${y}px) scale(${scale})`, opacity }}>
|
| 153 |
+
<svg width="24" height="24" viewBox="0 0 24 24">
|
| 154 |
+
<ellipse cx="12" cy="15" rx="5" ry="4" className="fill-text-secondary" />
|
| 155 |
+
<circle cx="7" cy="9" r="2.2" className="fill-text-secondary" />
|
| 156 |
+
<circle cx="12" cy="7" r="2.2" className="fill-text-secondary" />
|
| 157 |
+
<circle cx="17" cy="9" r="2.2" className="fill-text-secondary" />
|
| 158 |
+
</svg>
|
| 159 |
+
</div>
|
| 160 |
+
);
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
export function LoadingSpinner({ stage, jobId, submittedAt, onCancel, onOpenGame }: LoadingSpinnerProps) {
|
| 164 |
+
const { t } = useI18n();
|
| 165 |
+
const progress = usePerceivedProgress(stage);
|
| 166 |
+
const { key } = STAGE_CONFIG[stage];
|
| 167 |
+
const [confirmCancelOpen, setConfirmCancelOpen] = useState(false);
|
| 168 |
+
const [elapsedMs, setElapsedMs] = useState(0);
|
| 169 |
+
|
| 170 |
+
useEffect(() => {
|
| 171 |
+
if (!submittedAt) {
|
| 172 |
+
setElapsedMs(0);
|
| 173 |
+
return;
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
const submittedTime = Date.parse(submittedAt);
|
| 177 |
+
if (!Number.isFinite(submittedTime)) {
|
| 178 |
+
setElapsedMs(0);
|
| 179 |
+
return;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
const updateElapsed = () => {
|
| 183 |
+
setElapsedMs(Math.max(0, Date.now() - submittedTime));
|
| 184 |
+
};
|
| 185 |
+
|
| 186 |
+
updateElapsed();
|
| 187 |
+
const timer = window.setInterval(updateElapsed, 250);
|
| 188 |
+
return () => window.clearInterval(timer);
|
| 189 |
+
}, [submittedAt]);
|
| 190 |
+
|
| 191 |
+
return (
|
| 192 |
+
<div className="flex flex-col items-center justify-center py-6">
|
| 193 |
+
<div className="relative">
|
| 194 |
+
<FloatingCat />
|
| 195 |
+
{onOpenGame && (
|
| 196 |
+
<div className="absolute left-[100px] -top-[50px] flex flex-col-reverse items-start">
|
| 197 |
+
<div className="w-[50px] h-[30px] border-l border-t border-text-secondary/35 rounded-tl-[4px] mt-1" />
|
| 198 |
+
<p className="text-[13px] leading-snug tracking-[0.08em] font-light text-text-secondary/85 whitespace-nowrap">
|
| 199 |
+
{t('game.invite.bubble')}{' '}
|
| 200 |
+
<a href="#" onClick={(e) => { e.preventDefault(); onOpenGame(); }} className="font-semibold text-text-primary underline underline-offset-4">
|
| 201 |
+
2048
|
| 202 |
+
</a>?
|
| 203 |
+
</p>
|
| 204 |
+
</div>
|
| 205 |
+
)}
|
| 206 |
+
</div>
|
| 207 |
+
|
| 208 |
+
<div className="mt-3">
|
| 209 |
+
<div className="flex items-center justify-center gap-2">
|
| 210 |
+
{Array.from({ length: 7 }, (_, i) => <WavingPaw key={i} index={i} total={7} />)}
|
| 211 |
+
</div>
|
| 212 |
+
</div>
|
| 213 |
+
|
| 214 |
+
<div className="mt-4 text-center">
|
| 215 |
+
<p className="text-base text-text-primary/80">{t(key)}</p>
|
| 216 |
+
<p className="text-sm text-text-secondary/60 tabular-nums mt-1">{Math.round(progress)}%</p>
|
| 217 |
+
{elapsedMs > 0 && (
|
| 218 |
+
<p className="text-xs text-text-secondary/50 tabular-nums mt-1">
|
| 219 |
+
{formatElapsed(elapsedMs)}
|
| 220 |
+
</p>
|
| 221 |
+
)}
|
| 222 |
+
</div>
|
| 223 |
+
|
| 224 |
+
<div className="flex items-center gap-3 mt-3">
|
| 225 |
+
{jobId && <span className="text-xs text-text-secondary/40 font-mono">{jobId.slice(0, 8)}</span>}
|
| 226 |
+
{onCancel && (
|
| 227 |
+
<button onClick={() => setConfirmCancelOpen(true)} className="text-xs text-text-secondary/40 hover:text-red-500">
|
| 228 |
+
{t('common.cancel')}
|
| 229 |
+
</button>
|
| 230 |
+
)}
|
| 231 |
+
</div>
|
| 232 |
+
|
| 233 |
+
{confirmCancelOpen && onCancel ? (
|
| 234 |
+
<div className="fixed inset-0 z-50 flex items-center justify-center pb-[10vh] p-4">
|
| 235 |
+
<div className="absolute inset-0 bg-bg-primary/60 backdrop-blur-md animate-overlay-wash-in" onClick={() => setConfirmCancelOpen(false)} />
|
| 236 |
+
<div className="relative w-full max-w-md bg-bg-secondary rounded-2xl p-8 shadow-xl border border-bg-tertiary/30">
|
| 237 |
+
<h2 className="text-base font-medium text-text-primary">中止任务?</h2>
|
| 238 |
+
<p className="text-sm text-text-secondary mt-2">猫猫已经跑了一半了,确定要停下来吗?</p>
|
| 239 |
+
<div className="flex items-center justify-end gap-3 mt-6">
|
| 240 |
+
<button onClick={() => setConfirmCancelOpen(false)} className="px-4 py-2 text-sm text-text-secondary bg-bg-primary rounded-xl">取消</button>
|
| 241 |
+
<button onClick={() => { setConfirmCancelOpen(false); onCancel(); }} className="px-4 py-2 text-sm text-white bg-red-500 rounded-xl">确定停止</button>
|
| 242 |
+
</div>
|
| 243 |
+
</div>
|
| 244 |
+
</div>
|
| 245 |
+
) : null}
|
| 246 |
+
</div>
|
| 247 |
+
);
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
function formatElapsed(ms: number): string {
|
| 251 |
+
const totalSeconds = Math.floor(ms / 1000);
|
| 252 |
+
const minutes = Math.floor(totalSeconds / 60);
|
| 253 |
+
const seconds = totalSeconds % 60;
|
| 254 |
+
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
| 255 |
+
}
|
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/ProblemFramingOverlay.tsx
ADDED
|
@@ -0,0 +1,519 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useRef, useState } from 'react';
|
| 2 |
+
import type { PointerEvent as ReactPointerEvent } from 'react';
|
| 3 |
+
import type { ProblemFramingPlan } from '../types/api';
|
| 4 |
+
import { useI18n } from '../i18n';
|
| 5 |
+
|
| 6 |
+
interface ProblemFramingOverlayProps {
|
| 7 |
+
open: boolean;
|
| 8 |
+
status: 'loading' | 'ready' | 'error';
|
| 9 |
+
plan: ProblemFramingPlan | null;
|
| 10 |
+
error: string | null;
|
| 11 |
+
adjustment: string;
|
| 12 |
+
generating: boolean;
|
| 13 |
+
onAdjustmentChange: (value: string) => void;
|
| 14 |
+
onRetry: () => void;
|
| 15 |
+
onGenerate: () => void;
|
| 16 |
+
onClose: () => void;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
const CARD_LAYOUTS = [
|
| 20 |
+
{ x: 6, y: 14, rotate: -6 },
|
| 21 |
+
{ x: 21, y: 57, rotate: 4 },
|
| 22 |
+
{ x: 42, y: 22, rotate: -4 },
|
| 23 |
+
{ x: 62, y: 52, rotate: 5 },
|
| 24 |
+
{ x: 79, y: 18, rotate: -3 },
|
| 25 |
+
{ x: 74, y: 66, rotate: 3 },
|
| 26 |
+
];
|
| 27 |
+
|
| 28 |
+
const COLLAPSED_CARD = { width: 120, height: 86 };
|
| 29 |
+
const EXPANDED_CARD = { width: 272, height: 236 };
|
| 30 |
+
const ACTIVE_REPEL_DISTANCE = 14;
|
| 31 |
+
const ACTIVE_CARD_CENTER_PULL = 0.28;
|
| 32 |
+
|
| 33 |
+
type CardPosition = {
|
| 34 |
+
x: number;
|
| 35 |
+
y: number;
|
| 36 |
+
rotate: number;
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
+
type DragState = {
|
| 40 |
+
index: number;
|
| 41 |
+
pointerId: number;
|
| 42 |
+
startClientX: number;
|
| 43 |
+
startClientY: number;
|
| 44 |
+
originX: number;
|
| 45 |
+
originY: number;
|
| 46 |
+
moved: boolean;
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
+
function PawStamp({ delay = 0 }: { delay?: number }) {
|
| 50 |
+
return (
|
| 51 |
+
<div
|
| 52 |
+
className="problem-paw text-text-secondary/55"
|
| 53 |
+
style={{ animationDelay: `${delay}s` }}
|
| 54 |
+
>
|
| 55 |
+
<svg width="34" height="34" viewBox="0 0 24 24" fill="currentColor">
|
| 56 |
+
<ellipse cx="12" cy="15" rx="5.2" ry="4.1" />
|
| 57 |
+
<circle cx="7" cy="9.1" r="2.15" />
|
| 58 |
+
<circle cx="12" cy="6.8" r="2.15" />
|
| 59 |
+
<circle cx="17" cy="9.1" r="2.15" />
|
| 60 |
+
</svg>
|
| 61 |
+
</div>
|
| 62 |
+
);
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
function WaitingPaws() {
|
| 66 |
+
return (
|
| 67 |
+
<div className="pointer-events-none absolute right-6 top-5 flex items-center gap-2 opacity-80">
|
| 68 |
+
<PawStamp />
|
| 69 |
+
<PawStamp delay={0.18} />
|
| 70 |
+
<PawStamp delay={0.36} />
|
| 71 |
+
</div>
|
| 72 |
+
);
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
function CanvasTransition() {
|
| 76 |
+
return (
|
| 77 |
+
<div className="pointer-events-none absolute inset-0">
|
| 78 |
+
<div className="absolute left-[12%] top-[24%] h-28 w-28 rounded-full bg-bg-primary/35 blur-2xl animate-pulse" />
|
| 79 |
+
<div className="absolute right-[16%] top-[18%] h-20 w-36 rounded-full bg-bg-primary/30 blur-2xl animate-pulse [animation-delay:180ms]" />
|
| 80 |
+
<div className="absolute left-[36%] bottom-[18%] h-24 w-40 rounded-full bg-bg-primary/28 blur-2xl animate-pulse [animation-delay:320ms]" />
|
| 81 |
+
</div>
|
| 82 |
+
);
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
export function ProblemFramingOverlay({
|
| 86 |
+
open,
|
| 87 |
+
status,
|
| 88 |
+
plan,
|
| 89 |
+
error,
|
| 90 |
+
adjustment,
|
| 91 |
+
generating,
|
| 92 |
+
onAdjustmentChange,
|
| 93 |
+
onRetry,
|
| 94 |
+
onGenerate,
|
| 95 |
+
onClose,
|
| 96 |
+
}: ProblemFramingOverlayProps) {
|
| 97 |
+
if (!open) {
|
| 98 |
+
return null;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
const planKey = plan
|
| 102 |
+
? `${status}-${plan.summary}-${plan.steps.map((step) => `${step.title}:${step.content}`).join('|')}`
|
| 103 |
+
: `empty-${status}`;
|
| 104 |
+
|
| 105 |
+
return (
|
| 106 |
+
<ProblemFramingOverlayContent
|
| 107 |
+
key={planKey}
|
| 108 |
+
status={status}
|
| 109 |
+
plan={plan}
|
| 110 |
+
error={error}
|
| 111 |
+
adjustment={adjustment}
|
| 112 |
+
generating={generating}
|
| 113 |
+
onAdjustmentChange={onAdjustmentChange}
|
| 114 |
+
onRetry={onRetry}
|
| 115 |
+
onGenerate={onGenerate}
|
| 116 |
+
onClose={onClose}
|
| 117 |
+
/>
|
| 118 |
+
);
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
interface ProblemFramingOverlayContentProps {
|
| 122 |
+
status: 'loading' | 'ready' | 'error';
|
| 123 |
+
plan: ProblemFramingPlan | null;
|
| 124 |
+
error: string | null;
|
| 125 |
+
adjustment: string;
|
| 126 |
+
generating: boolean;
|
| 127 |
+
onAdjustmentChange: (value: string) => void;
|
| 128 |
+
onRetry: () => void;
|
| 129 |
+
onGenerate: () => void;
|
| 130 |
+
onClose: () => void;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
function ProblemFramingOverlayContent({
|
| 134 |
+
status,
|
| 135 |
+
plan,
|
| 136 |
+
error,
|
| 137 |
+
adjustment,
|
| 138 |
+
generating,
|
| 139 |
+
onAdjustmentChange,
|
| 140 |
+
onRetry,
|
| 141 |
+
onGenerate,
|
| 142 |
+
onClose,
|
| 143 |
+
}: ProblemFramingOverlayContentProps) {
|
| 144 |
+
const { t } = useI18n();
|
| 145 |
+
const [confirmDiscardOpen, setConfirmDiscardOpen] = useState(false);
|
| 146 |
+
const [activeStepIndex, setActiveStepIndex] = useState<number | null>(null);
|
| 147 |
+
const [draftSteps, setDraftSteps] = useState<Array<{ title: string; content: string }>>(() =>
|
| 148 |
+
plan ? plan.steps.map((step) => ({ title: step.title, content: step.content })) : []
|
| 149 |
+
);
|
| 150 |
+
const [cardPositions, setCardPositions] = useState<CardPosition[]>(() =>
|
| 151 |
+
plan
|
| 152 |
+
? plan.steps.map((_, index) => {
|
| 153 |
+
const layout = CARD_LAYOUTS[index] || CARD_LAYOUTS[index % CARD_LAYOUTS.length];
|
| 154 |
+
return { x: layout.x, y: layout.y, rotate: layout.rotate };
|
| 155 |
+
})
|
| 156 |
+
: []
|
| 157 |
+
);
|
| 158 |
+
const canvasRef = useRef<HTMLDivElement | null>(null);
|
| 159 |
+
const dragStateRef = useRef<DragState | null>(null);
|
| 160 |
+
const statusKey =
|
| 161 |
+
status === 'loading'
|
| 162 |
+
? 'problem.status.loading'
|
| 163 |
+
: status === 'ready'
|
| 164 |
+
? 'problem.status.ready'
|
| 165 |
+
: 'problem.status.error';
|
| 166 |
+
|
| 167 |
+
useEffect(() => {
|
| 168 |
+
dragStateRef.current = null;
|
| 169 |
+
}, []);
|
| 170 |
+
|
| 171 |
+
const stepCount = plan?.steps.length || 4;
|
| 172 |
+
|
| 173 |
+
const updateCardPosition = (index: number, clientX: number, clientY: number) => {
|
| 174 |
+
const canvas = canvasRef.current;
|
| 175 |
+
const current = cardPositions[index];
|
| 176 |
+
if (!canvas || !current) {
|
| 177 |
+
return;
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
const rect = canvas.getBoundingClientRect();
|
| 181 |
+
const isActive = activeStepIndex === index;
|
| 182 |
+
const cardSize = isActive ? EXPANDED_CARD : COLLAPSED_CARD;
|
| 183 |
+
const maxX = Math.max(0, rect.width - cardSize.width);
|
| 184 |
+
const maxY = Math.max(0, rect.height - cardSize.height);
|
| 185 |
+
const nextX = Math.min(Math.max(0, clientX), maxX);
|
| 186 |
+
const nextY = Math.min(Math.max(0, clientY), maxY);
|
| 187 |
+
|
| 188 |
+
setCardPositions((previous) =>
|
| 189 |
+
previous.map((item, itemIndex) =>
|
| 190 |
+
itemIndex === index
|
| 191 |
+
? {
|
| 192 |
+
...item,
|
| 193 |
+
x: rect.width > 0 ? (nextX / rect.width) * 100 : item.x,
|
| 194 |
+
y: rect.height > 0 ? (nextY / rect.height) * 100 : item.y,
|
| 195 |
+
}
|
| 196 |
+
: item
|
| 197 |
+
)
|
| 198 |
+
);
|
| 199 |
+
};
|
| 200 |
+
|
| 201 |
+
const getRenderedPosition = (index: number, position: CardPosition): CardPosition => {
|
| 202 |
+
if (activeStepIndex === index) {
|
| 203 |
+
const centeredX = position.x + (50 - position.x) * ACTIVE_CARD_CENTER_PULL;
|
| 204 |
+
const centeredY = position.y + (44 - position.y) * ACTIVE_CARD_CENTER_PULL;
|
| 205 |
+
|
| 206 |
+
return {
|
| 207 |
+
x: Math.max(2, Math.min(72, centeredX)),
|
| 208 |
+
y: Math.max(3, Math.min(58, centeredY)),
|
| 209 |
+
rotate: 0,
|
| 210 |
+
};
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
if (activeStepIndex === null || activeStepIndex === index) {
|
| 214 |
+
return position;
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
const active = cardPositions[activeStepIndex];
|
| 218 |
+
if (!active) {
|
| 219 |
+
return position;
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
const dx = position.x - active.x;
|
| 223 |
+
const dy = position.y - active.y;
|
| 224 |
+
const distance = Math.hypot(dx, dy) || 1;
|
| 225 |
+
const push = ACTIVE_REPEL_DISTANCE / distance;
|
| 226 |
+
|
| 227 |
+
return {
|
| 228 |
+
x: Math.max(0, Math.min(88, position.x + dx * push)),
|
| 229 |
+
y: Math.max(0, Math.min(78, position.y + dy * push)),
|
| 230 |
+
rotate: position.rotate,
|
| 231 |
+
};
|
| 232 |
+
};
|
| 233 |
+
|
| 234 |
+
const handleCardPointerDown = (index: number, event: ReactPointerEvent<HTMLElement>) => {
|
| 235 |
+
const target = event.target as HTMLElement;
|
| 236 |
+
if (target.closest('textarea')) {
|
| 237 |
+
return;
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
const canvas = canvasRef.current;
|
| 241 |
+
const current = cardPositions[index];
|
| 242 |
+
if (!canvas || !current) {
|
| 243 |
+
return;
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
const rect = canvas.getBoundingClientRect();
|
| 247 |
+
const pointerOriginX = (current.x / 100) * rect.width;
|
| 248 |
+
const pointerOriginY = (current.y / 100) * rect.height;
|
| 249 |
+
|
| 250 |
+
dragStateRef.current = {
|
| 251 |
+
index,
|
| 252 |
+
pointerId: event.pointerId,
|
| 253 |
+
startClientX: event.clientX,
|
| 254 |
+
startClientY: event.clientY,
|
| 255 |
+
originX: pointerOriginX,
|
| 256 |
+
originY: pointerOriginY,
|
| 257 |
+
moved: false,
|
| 258 |
+
};
|
| 259 |
+
|
| 260 |
+
event.currentTarget.setPointerCapture(event.pointerId);
|
| 261 |
+
};
|
| 262 |
+
|
| 263 |
+
const handleCardPointerMove = (event: ReactPointerEvent<HTMLElement>) => {
|
| 264 |
+
const dragState = dragStateRef.current;
|
| 265 |
+
if (!dragState || dragState.pointerId !== event.pointerId) {
|
| 266 |
+
return;
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
const deltaX = event.clientX - dragState.startClientX;
|
| 270 |
+
const deltaY = event.clientY - dragState.startClientY;
|
| 271 |
+
if (!dragState.moved && Math.abs(deltaX) + Math.abs(deltaY) > 3) {
|
| 272 |
+
dragState.moved = true;
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
updateCardPosition(dragState.index, dragState.originX + deltaX, dragState.originY + deltaY);
|
| 276 |
+
};
|
| 277 |
+
|
| 278 |
+
const handleCardPointerUp = (index: number, event: ReactPointerEvent<HTMLElement>) => {
|
| 279 |
+
const dragState = dragStateRef.current;
|
| 280 |
+
if (!dragState || dragState.pointerId !== event.pointerId) {
|
| 281 |
+
return;
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
if (!dragState.moved) {
|
| 285 |
+
setActiveStepIndex((current) => (current === index ? null : index));
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
| 289 |
+
event.currentTarget.releasePointerCapture(event.pointerId);
|
| 290 |
+
}
|
| 291 |
+
dragStateRef.current = null;
|
| 292 |
+
};
|
| 293 |
+
|
| 294 |
+
return (
|
| 295 |
+
<div className="fixed inset-0 z-[80]">
|
| 296 |
+
<div className="animate-overlay-wash-in absolute inset-0 bg-bg-primary/28 backdrop-blur-[6px]" />
|
| 297 |
+
|
| 298 |
+
<div className="relative flex min-h-screen items-center justify-center p-4 sm:p-8">
|
| 299 |
+
<div className="animate-fade-in-soft relative flex h-[min(82vh,760px)] w-full max-w-5xl flex-col overflow-hidden rounded-[2rem] border border-bg-tertiary/30 bg-bg-secondary">
|
| 300 |
+
<div className="absolute inset-0 opacity-35 bg-[radial-gradient(circle_at_1px_1px,rgba(0,0,0,0.05)_1px,transparent_0)] bg-[length:24px_24px]" />
|
| 301 |
+
<WaitingPaws />
|
| 302 |
+
|
| 303 |
+
<div className="relative flex flex-1 flex-col px-5 pb-5 pt-6 sm:px-8 sm:pb-6 sm:pt-7">
|
| 304 |
+
<div className="flex items-start justify-between gap-4 pb-2">
|
| 305 |
+
<button
|
| 306 |
+
type="button"
|
| 307 |
+
onClick={() => setConfirmDiscardOpen(true)}
|
| 308 |
+
className="inline-flex items-center gap-2 px-1 py-1 text-sm text-text-secondary transition-colors hover:text-text-primary"
|
| 309 |
+
>
|
| 310 |
+
<span aria-hidden="true">←</span>
|
| 311 |
+
{t('problem.back')}
|
| 312 |
+
</button>
|
| 313 |
+
</div>
|
| 314 |
+
|
| 315 |
+
<div className="mt-2 flex min-h-0 flex-1 flex-col overflow-hidden">
|
| 316 |
+
<section className="min-h-0 flex-1 overflow-hidden px-4 pb-2 pt-2 sm:px-6 sm:pb-3 sm:pt-3">
|
| 317 |
+
<div className="mx-auto h-full max-w-5xl">
|
| 318 |
+
<div className="h-full">
|
| 319 |
+
{status === 'loading' && (
|
| 320 |
+
<div ref={canvasRef} className="relative h-full min-h-[470px] overflow-hidden">
|
| 321 |
+
<CanvasTransition />
|
| 322 |
+
<p className="absolute right-0 top-0 text-sm text-text-secondary">{t(statusKey)}</p>
|
| 323 |
+
<svg className="pointer-events-none absolute inset-0 h-full w-full opacity-35" viewBox="0 0 1100 620" preserveAspectRatio="none">
|
| 324 |
+
<path d="M 40 220 C 180 90, 280 90, 370 230 S 560 420, 670 250 S 860 90, 1060 250" fill="none" stroke="currentColor" strokeWidth="1.2" strokeDasharray="6 8" className="text-text-secondary/35" />
|
| 325 |
+
</svg>
|
| 326 |
+
{Array.from({ length: stepCount }, (_, index) => {
|
| 327 |
+
const layout = CARD_LAYOUTS[index] || CARD_LAYOUTS[CARD_LAYOUTS.length - 1];
|
| 328 |
+
return (
|
| 329 |
+
<div
|
| 330 |
+
key={index}
|
| 331 |
+
className="absolute h-[86px] w-[120px] rounded-[1.2rem] border border-dashed border-text-secondary/35 bg-bg-primary/76 p-4"
|
| 332 |
+
style={{
|
| 333 |
+
left: `${layout.x}%`,
|
| 334 |
+
top: `${layout.y}%`,
|
| 335 |
+
transform: `rotate(${layout.rotate}deg)`,
|
| 336 |
+
animation: `fadeInUp 0.38s ease-out ${0.28 + index * 0.16}s both`
|
| 337 |
+
}}
|
| 338 |
+
>
|
| 339 |
+
<div className="h-4 w-14 animate-pulse rounded-full bg-bg-secondary/80" />
|
| 340 |
+
<div className="mt-3 h-3 w-full animate-pulse rounded-full bg-bg-secondary/70" />
|
| 341 |
+
<div className="mt-2 h-3 w-4/5 animate-pulse rounded-full bg-bg-secondary/60" />
|
| 342 |
+
</div>
|
| 343 |
+
);
|
| 344 |
+
})}
|
| 345 |
+
</div>
|
| 346 |
+
)}
|
| 347 |
+
|
| 348 |
+
{status !== 'loading' && plan && (
|
| 349 |
+
<div ref={canvasRef} className="relative h-full min-h-[470px] overflow-hidden">
|
| 350 |
+
<p className="absolute right-0 top-0 text-sm text-text-secondary">{t(statusKey)}</p>
|
| 351 |
+
<svg className="pointer-events-none absolute inset-0 h-full w-full opacity-35" viewBox="0 0 1100 620" preserveAspectRatio="none">
|
| 352 |
+
<path d="M 40 220 C 180 90, 280 90, 370 230 S 560 420, 670 250 S 860 90, 1060 250" fill="none" stroke="currentColor" strokeWidth="1.2" strokeDasharray="6 8" className="text-text-secondary/35" />
|
| 353 |
+
</svg>
|
| 354 |
+
|
| 355 |
+
{draftSteps.map((step, index) => {
|
| 356 |
+
const isActive = index === activeStepIndex;
|
| 357 |
+
const position = cardPositions[index] || CARD_LAYOUTS[index] || CARD_LAYOUTS[CARD_LAYOUTS.length - 1];
|
| 358 |
+
const renderedPosition = getRenderedPosition(index, position);
|
| 359 |
+
const collapsedTitle = step.title.slice(0, 6);
|
| 360 |
+
|
| 361 |
+
return (
|
| 362 |
+
<article
|
| 363 |
+
key={`${step.title}-${index}`}
|
| 364 |
+
onPointerDown={(event) => handleCardPointerDown(index, event)}
|
| 365 |
+
onPointerMove={handleCardPointerMove}
|
| 366 |
+
onPointerUp={(event) => handleCardPointerUp(index, event)}
|
| 367 |
+
onPointerCancel={(event) => handleCardPointerUp(index, event)}
|
| 368 |
+
className={`absolute rounded-[1.2rem] border border-dashed bg-bg-primary/78 p-4 transition-all duration-500 ease-out ${
|
| 369 |
+
isActive
|
| 370 |
+
? 'z-20 h-[236px] w-[272px] border-accent/45'
|
| 371 |
+
: 'z-10 h-[86px] w-[120px] border-text-secondary/35 cursor-grab select-none'
|
| 372 |
+
}`}
|
| 373 |
+
style={{
|
| 374 |
+
left: `${renderedPosition.x}%`,
|
| 375 |
+
top: `${renderedPosition.y}%`,
|
| 376 |
+
transform: isActive ? 'rotate(0deg)' : `rotate(${position.rotate}deg)`,
|
| 377 |
+
animation: `fadeInUp 0.32s ease-out ${index * 0.12}s both`
|
| 378 |
+
}}
|
| 379 |
+
>
|
| 380 |
+
<p className="pointer-events-none text-[11px] uppercase tracking-[0.24em] text-text-secondary/55">
|
| 381 |
+
{String(index + 1).padStart(2, '0')}
|
| 382 |
+
</p>
|
| 383 |
+
<h3 className="pointer-events-none mt-1 text-sm leading-5 text-text-primary">
|
| 384 |
+
{isActive ? step.title : collapsedTitle}
|
| 385 |
+
</h3>
|
| 386 |
+
|
| 387 |
+
{isActive ? (
|
| 388 |
+
<textarea
|
| 389 |
+
value={step.content}
|
| 390 |
+
onChange={(event) => {
|
| 391 |
+
const nextSteps = draftSteps.map((item, itemIndex) =>
|
| 392 |
+
itemIndex === index ? { ...item, content: event.target.value } : item
|
| 393 |
+
);
|
| 394 |
+
setDraftSteps(nextSteps);
|
| 395 |
+
}}
|
| 396 |
+
rows={7}
|
| 397 |
+
spellCheck={false}
|
| 398 |
+
className="mt-3 min-h-[140px] w-full resize-none bg-transparent text-sm leading-6 text-text-secondary outline-none overflow-hidden"
|
| 399 |
+
/>
|
| 400 |
+
) : null}
|
| 401 |
+
</article>
|
| 402 |
+
);
|
| 403 |
+
})}
|
| 404 |
+
|
| 405 |
+
{status === 'error' && (
|
| 406 |
+
<div className="absolute bottom-0 left-0 text-sm leading-6 text-red-700">
|
| 407 |
+
{error || t('generation.problemFramingFailed')}
|
| 408 |
+
</div>
|
| 409 |
+
)}
|
| 410 |
+
</div>
|
| 411 |
+
)}
|
| 412 |
+
</div>
|
| 413 |
+
</div>
|
| 414 |
+
</section>
|
| 415 |
+
|
| 416 |
+
<aside className="px-5 pb-5 pt-1 sm:px-7 sm:pb-6">
|
| 417 |
+
<div className="mx-auto grid max-w-[860px] gap-4 lg:grid-cols-[minmax(0,520px)_auto] lg:items-center lg:translate-x-8">
|
| 418 |
+
<div className="lg:translate-y-3">
|
| 419 |
+
<textarea
|
| 420 |
+
value={adjustment}
|
| 421 |
+
onChange={(event) => onAdjustmentChange(event.target.value)}
|
| 422 |
+
rows={2}
|
| 423 |
+
placeholder={t('problem.adjustPlaceholder')}
|
| 424 |
+
className="mt-3 min-h-[68px] w-full resize-none rounded-2xl bg-bg-secondary/50 px-4 py-3 text-sm leading-6 text-text-primary placeholder-text-secondary/40 outline-none transition-all focus:bg-bg-secondary/70 focus:ring-2 focus:ring-accent/20"
|
| 425 |
+
/>
|
| 426 |
+
</div>
|
| 427 |
+
|
| 428 |
+
<div className="flex items-center justify-center gap-5 lg:justify-start lg:translate-x-3 lg:translate-y-3">
|
| 429 |
+
<button
|
| 430 |
+
type="button"
|
| 431 |
+
onClick={onRetry}
|
| 432 |
+
disabled={status === 'loading' || !adjustment.trim()}
|
| 433 |
+
className="px-6 py-3.5 text-sm font-medium text-text-secondary hover:text-text-primary bg-bg-secondary/50 hover:bg-bg-secondary/70 rounded-2xl transition-all disabled:cursor-not-allowed disabled:opacity-45 focus:outline-none focus:ring-2 focus:ring-accent/20"
|
| 434 |
+
>
|
| 435 |
+
{t('problem.adjustAction')}
|
| 436 |
+
</button>
|
| 437 |
+
<button
|
| 438 |
+
type="button"
|
| 439 |
+
onClick={onGenerate}
|
| 440 |
+
disabled={!plan || status === 'loading' || generating}
|
| 441 |
+
className="group relative px-5 py-2.5 bg-accent hover:bg-accent-hover text-white text-sm font-medium rounded-full shadow-sm shadow-accent/10 transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed hover:shadow-md hover:shadow-accent/15 active:scale-[0.97] overflow-hidden"
|
| 442 |
+
>
|
| 443 |
+
<span className="relative z-10 flex items-center gap-2">
|
| 444 |
+
{generating ? (
|
| 445 |
+
<>
|
| 446 |
+
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
| 447 |
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
| 448 |
+
<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" />
|
| 449 |
+
</svg>
|
| 450 |
+
{t('form.submitting')}
|
| 451 |
+
</>
|
| 452 |
+
) : (
|
| 453 |
+
<>
|
| 454 |
+
{t('problem.start')}
|
| 455 |
+
<svg className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 456 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
| 457 |
+
</svg>
|
| 458 |
+
</>
|
| 459 |
+
)}
|
| 460 |
+
</span>
|
| 461 |
+
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent -translate-x-full group-hover:animate-shimmer" />
|
| 462 |
+
</button>
|
| 463 |
+
</div>
|
| 464 |
+
</div>
|
| 465 |
+
</aside>
|
| 466 |
+
</div>
|
| 467 |
+
</div>
|
| 468 |
+
|
| 469 |
+
{confirmDiscardOpen && (
|
| 470 |
+
<div className="absolute inset-0 z-50 flex items-center justify-center p-4">
|
| 471 |
+
<div
|
| 472 |
+
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
| 473 |
+
onClick={() => setConfirmDiscardOpen(false)}
|
| 474 |
+
/>
|
| 475 |
+
<div className="relative w-full max-w-sm rounded-2xl border border-bg-tertiary/30 bg-bg-secondary p-6 shadow-xl">
|
| 476 |
+
<div className="flex items-start justify-between gap-3">
|
| 477 |
+
<div>
|
| 478 |
+
<h3 className="text-base font-medium text-text-primary">{t('problem.discardTitle')}</h3>
|
| 479 |
+
<p className="mt-2 text-sm leading-relaxed text-text-secondary">{t('problem.discardDescription')}</p>
|
| 480 |
+
</div>
|
| 481 |
+
<button
|
| 482 |
+
type="button"
|
| 483 |
+
onClick={() => setConfirmDiscardOpen(false)}
|
| 484 |
+
className="rounded-full p-1.5 text-text-secondary/70 transition-all hover:bg-bg-primary/50 hover:text-text-secondary"
|
| 485 |
+
aria-label={t('common.close')}
|
| 486 |
+
title={t('common.close')}
|
| 487 |
+
>
|
| 488 |
+
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 489 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
| 490 |
+
</svg>
|
| 491 |
+
</button>
|
| 492 |
+
</div>
|
| 493 |
+
<div className="mt-6 flex items-center justify-end gap-3">
|
| 494 |
+
<button
|
| 495 |
+
type="button"
|
| 496 |
+
onClick={() => setConfirmDiscardOpen(false)}
|
| 497 |
+
className="rounded-xl bg-bg-primary px-4 py-2 text-sm text-text-secondary transition-all hover:bg-bg-tertiary hover:text-text-primary"
|
| 498 |
+
>
|
| 499 |
+
{t('common.cancel')}
|
| 500 |
+
</button>
|
| 501 |
+
<button
|
| 502 |
+
type="button"
|
| 503 |
+
onClick={() => {
|
| 504 |
+
setConfirmDiscardOpen(false);
|
| 505 |
+
onClose();
|
| 506 |
+
}}
|
| 507 |
+
className="rounded-xl bg-red-500 px-4 py-2 text-sm font-medium text-bg-primary transition-all hover:bg-red-600"
|
| 508 |
+
>
|
| 509 |
+
{t('common.confirm')}
|
| 510 |
+
</button>
|
| 511 |
+
</div>
|
| 512 |
+
</div>
|
| 513 |
+
</div>
|
| 514 |
+
)}
|
| 515 |
+
</div>
|
| 516 |
+
</div>
|
| 517 |
+
</div>
|
| 518 |
+
);
|
| 519 |
+
}
|
frontend/src/components/PromptInput.tsx
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// 提示词输入组件
|
| 2 |
+
// 提供统一的提示词编辑界面
|
| 3 |
+
|
| 4 |
+
interface PromptInputProps {
|
| 5 |
+
value: string;
|
| 6 |
+
onChange: (value: string) => void;
|
| 7 |
+
label: string;
|
| 8 |
+
placeholder?: string;
|
| 9 |
+
maxLength?: number;
|
| 10 |
+
disabled?: boolean;
|
| 11 |
+
showWordCount?: boolean;
|
| 12 |
+
onSave?: () => void;
|
| 13 |
+
onRestoreDefault?: () => void;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export function PromptInput({
|
| 17 |
+
value,
|
| 18 |
+
onChange,
|
| 19 |
+
label,
|
| 20 |
+
placeholder,
|
| 21 |
+
maxLength = 20000,
|
| 22 |
+
disabled = false,
|
| 23 |
+
showWordCount = true,
|
| 24 |
+
onSave,
|
| 25 |
+
onRestoreDefault
|
| 26 |
+
}: PromptInputProps) {
|
| 27 |
+
const wordCount = value.length;
|
| 28 |
+
const isMaxLength = wordCount >= maxLength;
|
| 29 |
+
|
| 30 |
+
return (
|
| 31 |
+
<div className="w-full space-y-4">
|
| 32 |
+
{/* 标签和操作按钮 */}
|
| 33 |
+
<div className="flex items-center justify-between">
|
| 34 |
+
<label className="text-sm font-medium text-text-primary">
|
| 35 |
+
{label}
|
| 36 |
+
</label>
|
| 37 |
+
<div className="flex gap-2">
|
| 38 |
+
{onRestoreDefault && (
|
| 39 |
+
<button
|
| 40 |
+
onClick={onRestoreDefault}
|
| 41 |
+
disabled={disabled}
|
| 42 |
+
className="px-3 py-1.5 text-xs text-text-secondary hover:text-text-primary hover:bg-bg-secondary/50 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
| 43 |
+
>
|
| 44 |
+
恢复默认
|
| 45 |
+
</button>
|
| 46 |
+
)}
|
| 47 |
+
{onSave && (
|
| 48 |
+
<button
|
| 49 |
+
onClick={onSave}
|
| 50 |
+
disabled={disabled}
|
| 51 |
+
className="px-3 py-1.5 text-xs bg-accent text-white hover:bg-accent-hover rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
| 52 |
+
>
|
| 53 |
+
保存
|
| 54 |
+
</button>
|
| 55 |
+
)}
|
| 56 |
+
</div>
|
| 57 |
+
</div>
|
| 58 |
+
|
| 59 |
+
{/* 输入区域 */}
|
| 60 |
+
<textarea
|
| 61 |
+
value={value}
|
| 62 |
+
onChange={(e) => {
|
| 63 |
+
if (e.target.value.length <= maxLength) {
|
| 64 |
+
onChange(e.target.value);
|
| 65 |
+
}
|
| 66 |
+
}}
|
| 67 |
+
placeholder={placeholder}
|
| 68 |
+
disabled={disabled}
|
| 69 |
+
rows={20}
|
| 70 |
+
className={`w-full px-4 py-3 bg-bg-secondary/50 border border-bg-secondary/50 rounded-xl text-sm text-text-primary placeholder-text-secondary/40 focus:outline-none focus:ring-2 focus:ring-accent/20 focus:bg-bg-secondary/70 focus:border-accent/30 transition-all resize-y min-h-[480px] ${
|
| 71 |
+
isMaxLength
|
| 72 |
+
? 'border-red-500/30 focus:ring-red-500/20 focus:border-red-500/50'
|
| 73 |
+
: ''
|
| 74 |
+
}`}
|
| 75 |
+
/>
|
| 76 |
+
|
| 77 |
+
{/* 字符计数 */}
|
| 78 |
+
{showWordCount && (
|
| 79 |
+
<div className="flex items-center justify-between text-xs text-text-secondary/60">
|
| 80 |
+
<span>{wordCount} / {maxLength} 字符</span>
|
| 81 |
+
{isMaxLength && (
|
| 82 |
+
<span className="text-red-500">已达到字符限制</span>
|
| 83 |
+
)}
|
| 84 |
+
</div>
|
| 85 |
+
)}
|
| 86 |
+
</div>
|
| 87 |
+
);
|
| 88 |
+
}
|
frontend/src/components/PromptSidebar.tsx
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* 提示词侧边栏 - 简洁风格
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import type { RoleType, SharedModuleType } from '../types/api';
|
| 6 |
+
import type { SelectionType } from '../hooks/usePrompts';
|
| 7 |
+
import { useI18n } from '../i18n';
|
| 8 |
+
|
| 9 |
+
// ============================================================================
|
| 10 |
+
// 配置
|
| 11 |
+
// ============================================================================
|
| 12 |
+
|
| 13 |
+
interface Props {
|
| 14 |
+
selection: SelectionType;
|
| 15 |
+
onSelect: (sel: SelectionType) => void;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export function PromptSidebar({ selection, onSelect }: Props) {
|
| 19 |
+
const { t } = useI18n();
|
| 20 |
+
|
| 21 |
+
const roleLabels: Record<RoleType, string> = {
|
| 22 |
+
problemFraming: t('prompts.role.problemFraming'),
|
| 23 |
+
conceptDesigner: t('prompts.role.conceptDesigner'),
|
| 24 |
+
codeGeneration: t('prompts.role.codeGeneration'),
|
| 25 |
+
codeRetry: t('prompts.role.codeRetry'),
|
| 26 |
+
codeEdit: t('prompts.role.codeEdit')
|
| 27 |
+
};
|
| 28 |
+
|
| 29 |
+
const sharedLabels: Record<SharedModuleType, string> = {
|
| 30 |
+
apiIndex: t('prompts.shared.apiIndex'),
|
| 31 |
+
specification: t('prompts.shared.specification')
|
| 32 |
+
};
|
| 33 |
+
|
| 34 |
+
const isRoleSelected = (role: RoleType, promptType: 'system' | 'user') =>
|
| 35 |
+
selection.kind === 'role' &&
|
| 36 |
+
selection.role === role &&
|
| 37 |
+
selection.promptType === promptType;
|
| 38 |
+
|
| 39 |
+
const isSharedSelected = (module: SharedModuleType) =>
|
| 40 |
+
selection.kind === 'shared' && selection.module === module;
|
| 41 |
+
|
| 42 |
+
return (
|
| 43 |
+
<div className="w-56 bg-bg-secondary/20 border-r border-bg-tertiary/30 overflow-y-auto">
|
| 44 |
+
<div className="p-3 space-y-4">
|
| 45 |
+
{/* 角色提示词 */}
|
| 46 |
+
<div>
|
| 47 |
+
<h3 className="px-3 py-1.5 text-xs font-medium text-text-secondary/50 uppercase tracking-wider">
|
| 48 |
+
{t('prompts.roleSection')}
|
| 49 |
+
</h3>
|
| 50 |
+
<div className="space-y-0.5">
|
| 51 |
+
{(Object.keys(roleLabels) as RoleType[]).map(role => (
|
| 52 |
+
<div key={role}>
|
| 53 |
+
{/* 角色名 */}
|
| 54 |
+
<div className="px-3 py-1.5 text-xs text-text-secondary/70">
|
| 55 |
+
{roleLabels[role]}
|
| 56 |
+
</div>
|
| 57 |
+
{/* System / User 按钮 */}
|
| 58 |
+
<div className="flex gap-1 px-3 pb-1">
|
| 59 |
+
<button
|
| 60 |
+
onClick={() => onSelect({ kind: 'role', role, promptType: 'system' })}
|
| 61 |
+
className={`flex-1 px-2 py-1 text-xs rounded transition-colors ${
|
| 62 |
+
isRoleSelected(role, 'system')
|
| 63 |
+
? 'bg-accent/20 text-accent'
|
| 64 |
+
: 'text-text-secondary/60 hover:bg-bg-tertiary/50 hover:text-text-secondary'
|
| 65 |
+
}`}
|
| 66 |
+
>
|
| 67 |
+
{t('common.system')}
|
| 68 |
+
</button>
|
| 69 |
+
<button
|
| 70 |
+
onClick={() => onSelect({ kind: 'role', role, promptType: 'user' })}
|
| 71 |
+
className={`flex-1 px-2 py-1 text-xs rounded transition-colors ${
|
| 72 |
+
isRoleSelected(role, 'user')
|
| 73 |
+
? 'bg-accent/20 text-accent'
|
| 74 |
+
: 'text-text-secondary/60 hover:bg-bg-tertiary/50 hover:text-text-secondary'
|
| 75 |
+
}`}
|
| 76 |
+
>
|
| 77 |
+
{t('common.user')}
|
| 78 |
+
</button>
|
| 79 |
+
</div>
|
| 80 |
+
</div>
|
| 81 |
+
))}
|
| 82 |
+
</div>
|
| 83 |
+
</div>
|
| 84 |
+
|
| 85 |
+
{/* 分隔线 */}
|
| 86 |
+
<div className="border-t border-bg-tertiary/30" />
|
| 87 |
+
|
| 88 |
+
{/* 共享模块 */}
|
| 89 |
+
<div>
|
| 90 |
+
<h3 className="px-3 py-1.5 text-xs font-medium text-text-secondary/50 uppercase tracking-wider">
|
| 91 |
+
{t('prompts.sharedSection')}
|
| 92 |
+
</h3>
|
| 93 |
+
<div className="space-y-0.5">
|
| 94 |
+
{(Object.keys(sharedLabels) as SharedModuleType[]).map(module => (
|
| 95 |
+
<button
|
| 96 |
+
key={module}
|
| 97 |
+
onClick={() => onSelect({ kind: 'shared', module })}
|
| 98 |
+
className={`w-full px-3 py-2 text-left text-sm rounded transition-colors ${
|
| 99 |
+
isSharedSelected(module)
|
| 100 |
+
? 'bg-accent/20 text-accent'
|
| 101 |
+
: 'text-text-secondary/70 hover:bg-bg-tertiary/50 hover:text-text-secondary'
|
| 102 |
+
}`}
|
| 103 |
+
>
|
| 104 |
+
{sharedLabels[module]}
|
| 105 |
+
</button>
|
| 106 |
+
))}
|
| 107 |
+
</div>
|
| 108 |
+
</div>
|
| 109 |
+
</div>
|
| 110 |
+
</div>
|
| 111 |
+
);
|
| 112 |
+
}
|
frontend/src/components/PromptsManager.tsx
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* 提示词管理器 - 简洁风格
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import { PromptSidebar } from './PromptSidebar';
|
| 6 |
+
import { usePrompts } from '../hooks/usePrompts';
|
| 7 |
+
import type { RoleType, SharedModuleType } from '../types/api';
|
| 8 |
+
import { useI18n } from '../i18n';
|
| 9 |
+
|
| 10 |
+
// ============================================================================
|
| 11 |
+
// 配置
|
| 12 |
+
// ============================================================================
|
| 13 |
+
|
| 14 |
+
interface Props {
|
| 15 |
+
isOpen: boolean;
|
| 16 |
+
onClose: () => void;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export function PromptsManager({ isOpen, onClose }: Props) {
|
| 20 |
+
const { t } = useI18n();
|
| 21 |
+
const {
|
| 22 |
+
isLoading,
|
| 23 |
+
selection,
|
| 24 |
+
setSelection,
|
| 25 |
+
getCurrentContent,
|
| 26 |
+
setCurrentContent,
|
| 27 |
+
restoreCurrent,
|
| 28 |
+
hasOverride
|
| 29 |
+
} = usePrompts();
|
| 30 |
+
|
| 31 |
+
// 获取当前标题
|
| 32 |
+
const getTitle = () => {
|
| 33 |
+
const roleLabels: Record<RoleType, string> = {
|
| 34 |
+
problemFraming: t('prompts.role.problemFraming'),
|
| 35 |
+
conceptDesigner: t('prompts.role.conceptDesigner'),
|
| 36 |
+
codeGeneration: t('prompts.role.codeGeneration'),
|
| 37 |
+
codeRetry: t('prompts.role.codeRetry'),
|
| 38 |
+
codeEdit: t('prompts.role.codeEdit')
|
| 39 |
+
};
|
| 40 |
+
|
| 41 |
+
const sharedLabels: Record<SharedModuleType, string> = {
|
| 42 |
+
apiIndex: t('prompts.shared.apiIndex'),
|
| 43 |
+
specification: t('prompts.shared.specification')
|
| 44 |
+
};
|
| 45 |
+
|
| 46 |
+
if (selection.kind === 'role') {
|
| 47 |
+
const roleLabel = roleLabels[selection.role];
|
| 48 |
+
return selection.promptType === 'system'
|
| 49 |
+
? t('prompts.role.systemTitle', { role: roleLabel })
|
| 50 |
+
: t('prompts.role.userTitle', { role: roleLabel });
|
| 51 |
+
}
|
| 52 |
+
return sharedLabels[selection.module];
|
| 53 |
+
};
|
| 54 |
+
|
| 55 |
+
// 获取当前描述
|
| 56 |
+
const getDescription = () => {
|
| 57 |
+
if (selection.kind === 'role') {
|
| 58 |
+
if (selection.promptType === 'system') {
|
| 59 |
+
return t('prompts.role.systemDescription');
|
| 60 |
+
}
|
| 61 |
+
return t('prompts.role.userDescription');
|
| 62 |
+
}
|
| 63 |
+
return selection.module === 'apiIndex'
|
| 64 |
+
? t('prompts.shared.apiIndexDescription')
|
| 65 |
+
: t('prompts.shared.specificationDescription');
|
| 66 |
+
};
|
| 67 |
+
|
| 68 |
+
if (!isOpen) return null;
|
| 69 |
+
|
| 70 |
+
const content = getCurrentContent();
|
| 71 |
+
const isModified = hasOverride();
|
| 72 |
+
|
| 73 |
+
return (
|
| 74 |
+
<div
|
| 75 |
+
className={`fixed inset-0 z-50 flex flex-col bg-bg-primary transition-all duration-300 ${
|
| 76 |
+
'opacity-100'
|
| 77 |
+
}`}
|
| 78 |
+
>
|
| 79 |
+
{/* 顶栏 */}
|
| 80 |
+
<div className="h-14 bg-bg-secondary/50 border-b border-bg-tertiary/30 flex items-center justify-between px-4">
|
| 81 |
+
<div className="flex items-center gap-3">
|
| 82 |
+
<button
|
| 83 |
+
onClick={onClose}
|
| 84 |
+
className="p-2 text-text-secondary/70 hover:text-text-primary hover:bg-bg-tertiary/50 rounded-lg transition-colors"
|
| 85 |
+
>
|
| 86 |
+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 87 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
| 88 |
+
</svg>
|
| 89 |
+
</button>
|
| 90 |
+
<span className="text-sm text-text-primary font-medium">{t('prompts.title')}</span>
|
| 91 |
+
</div>
|
| 92 |
+
|
| 93 |
+
{/* 修改状态 + 恢复按钮 */}
|
| 94 |
+
<div className="flex items-center gap-2">
|
| 95 |
+
{isModified && (
|
| 96 |
+
<>
|
| 97 |
+
<span className="text-xs text-accent/70">{t('prompts.modified')}</span>
|
| 98 |
+
<button
|
| 99 |
+
onClick={restoreCurrent}
|
| 100 |
+
className="px-3 py-1.5 text-xs text-text-secondary/70 hover:text-text-primary hover:bg-bg-tertiary/50 rounded-lg transition-colors"
|
| 101 |
+
>
|
| 102 |
+
{t('prompts.restore')}
|
| 103 |
+
</button>
|
| 104 |
+
</>
|
| 105 |
+
)}
|
| 106 |
+
</div>
|
| 107 |
+
</div>
|
| 108 |
+
|
| 109 |
+
{/* 主内容 */}
|
| 110 |
+
<div className="flex-1 flex overflow-hidden">
|
| 111 |
+
{/* 侧边栏 */}
|
| 112 |
+
<PromptSidebar selection={selection} onSelect={setSelection} />
|
| 113 |
+
|
| 114 |
+
{/* 编辑区 */}
|
| 115 |
+
<div className="flex-1 flex flex-col overflow-hidden">
|
| 116 |
+
{/* 标题区 */}
|
| 117 |
+
<div className="px-6 py-4 border-b border-bg-tertiary/30">
|
| 118 |
+
<h2 className="text-base font-medium text-text-primary">{getTitle()}</h2>
|
| 119 |
+
<p className="text-xs text-text-secondary/60 mt-1">{getDescription()}</p>
|
| 120 |
+
</div>
|
| 121 |
+
|
| 122 |
+
{/* 编辑器 */}
|
| 123 |
+
<div className="flex-1 p-4 overflow-hidden">
|
| 124 |
+
{isLoading ? (
|
| 125 |
+
<div className="h-full flex items-center justify-center text-text-secondary/50 text-sm">
|
| 126 |
+
{t('common.loading')}
|
| 127 |
+
</div>
|
| 128 |
+
) : (
|
| 129 |
+
<textarea
|
| 130 |
+
value={content}
|
| 131 |
+
onChange={e => setCurrentContent(e.target.value)}
|
| 132 |
+
className="w-full h-full px-4 py-3 bg-bg-secondary/30 border border-bg-tertiary/30 rounded-lg text-sm text-text-primary font-mono leading-relaxed resize-none focus:outline-none focus:border-accent/30 focus:ring-1 focus:ring-accent/20 transition-colors"
|
| 133 |
+
placeholder={t('prompts.placeholder')}
|
| 134 |
+
/>
|
| 135 |
+
)}
|
| 136 |
+
</div>
|
| 137 |
+
|
| 138 |
+
{/* 底栏 */}
|
| 139 |
+
<div className="px-6 py-3 border-t border-bg-tertiary/30 flex items-center justify-between text-xs text-text-secondary/50">
|
| 140 |
+
<span>{t('prompts.characters', { count: content.length })}</span>
|
| 141 |
+
<span>{t('prompts.autosave')}</span>
|
| 142 |
+
</div>
|
| 143 |
+
</div>
|
| 144 |
+
</div>
|
| 145 |
+
</div>
|
| 146 |
+
);
|
| 147 |
+
}
|