Bin29 commited on
Commit
4ee35df
·
0 Parent(s):

Sync from main: 3002239 docs: refresh dual-mode studio positioning

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +105 -0
  2. .env.example +59 -0
  3. .env.production +46 -0
  4. .github/pull_request_template.md +9 -0
  5. .github/workflows/docker-publish.yml +49 -0
  6. .gitignore +83 -0
  7. CLA.md +98 -0
  8. CONTRIBUTING.md +21 -0
  9. DEPLOYMENT.md +326 -0
  10. DEPLOYMENT.zh-CN.md +326 -0
  11. Dockerfile +83 -0
  12. LICENSE +7 -0
  13. LICENSES/MIT.txt +23 -0
  14. LICENSES/ManimCat-NC.txt +27 -0
  15. LICENSE_POLICY.en.md +36 -0
  16. LICENSE_POLICY.md +36 -0
  17. README.md +341 -0
  18. README.zh-CN.md +332 -0
  19. THIRD_PARTY_NOTICES.md +18 -0
  20. THIRD_PARTY_NOTICES.zh-CN.md +18 -0
  21. docker-compose.yml +105 -0
  22. frontend/.gitignore +24 -0
  23. frontend/eslint.config.js +23 -0
  24. frontend/index.html +14 -0
  25. frontend/package-lock.json +0 -0
  26. frontend/package.json +49 -0
  27. frontend/postcss.config.js +6 -0
  28. frontend/public/logo-16.png +0 -0
  29. frontend/public/logo-192.png +0 -0
  30. frontend/public/logo-32.png +0 -0
  31. frontend/public/logo-48.png +0 -0
  32. frontend/public/logo-96.png +0 -0
  33. frontend/public/logo.svg +21 -0
  34. frontend/src/App.css +42 -0
  35. frontend/src/App.tsx +322 -0
  36. frontend/src/components/AiModifyModal.tsx +96 -0
  37. frontend/src/components/CodeView.tsx +140 -0
  38. frontend/src/components/CustomSelect.tsx +104 -0
  39. frontend/src/components/DonationModal.tsx +117 -0
  40. frontend/src/components/ExampleButtons.tsx +84 -0
  41. frontend/src/components/HistoryPanel.tsx +206 -0
  42. frontend/src/components/ImageInputModeModal.tsx +84 -0
  43. frontend/src/components/ImagePreview.tsx +123 -0
  44. frontend/src/components/InputForm.tsx +254 -0
  45. frontend/src/components/LoadingSpinner.tsx +255 -0
  46. frontend/src/components/ManimCatLogo.tsx +29 -0
  47. frontend/src/components/ProblemFramingOverlay.tsx +519 -0
  48. frontend/src/components/PromptInput.tsx +88 -0
  49. frontend/src/components/PromptSidebar.tsx +112 -0
  50. 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&section=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&center=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
+ ∫ &nbsp; ∑ &nbsp; ∂ &nbsp; ∞
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;">◆ &nbsp; ◆ &nbsp; ◆</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&section=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&section=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&center=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
+ ∫ &nbsp; ∑ &nbsp; ∂ &nbsp; ∞
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;">◆ &nbsp; ◆ &nbsp; ◆</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&section=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
+ }