Browse Source

Merge pull request #754 from aatanxiao12-beep/feature/yingqian-movie-recommender

[毕业设计] YingQian - 多智能体电影推荐助手
Sizhou Chen 1 ngày trước cách đây
mục cha
commit
960de61065
63 tập tin đã thay đổi với 7255 bổ sung0 xóa
  1. 18 0
      Co-creation-projects/aatanxiao12-beep-YingQian/.env.example
  2. 9 0
      Co-creation-projects/aatanxiao12-beep-YingQian/.gitignore
  3. 213 0
      Co-creation-projects/aatanxiao12-beep-YingQian/README.md
  4. 18 0
      Co-creation-projects/aatanxiao12-beep-YingQian/backend/.env.example
  5. 1 0
      Co-creation-projects/aatanxiao12-beep-YingQian/backend/app/__init__.py
  6. 0 0
      Co-creation-projects/aatanxiao12-beep-YingQian/backend/app/agents/__init__.py
  7. 697 0
      Co-creation-projects/aatanxiao12-beep-YingQian/backend/app/agents/movie_recommender_agent.py
  8. 0 0
      Co-creation-projects/aatanxiao12-beep-YingQian/backend/app/api/__init__.py
  9. 53 0
      Co-creation-projects/aatanxiao12-beep-YingQian/backend/app/api/exception_handlers.py
  10. 90 0
      Co-creation-projects/aatanxiao12-beep-YingQian/backend/app/api/main.py
  11. 0 0
      Co-creation-projects/aatanxiao12-beep-YingQian/backend/app/api/routes/__init__.py
  12. 95 0
      Co-creation-projects/aatanxiao12-beep-YingQian/backend/app/api/routes/movies.py
  13. 63 0
      Co-creation-projects/aatanxiao12-beep-YingQian/backend/app/api/routes/recommend.py
  14. 118 0
      Co-creation-projects/aatanxiao12-beep-YingQian/backend/app/config.py
  15. 42 0
      Co-creation-projects/aatanxiao12-beep-YingQian/backend/app/exceptions.py
  16. 1 0
      Co-creation-projects/aatanxiao12-beep-YingQian/backend/app/models/__init__.py
  17. 161 0
      Co-creation-projects/aatanxiao12-beep-YingQian/backend/app/models/schemas.py
  18. 1 0
      Co-creation-projects/aatanxiao12-beep-YingQian/backend/app/services/__init__.py
  19. 27 0
      Co-creation-projects/aatanxiao12-beep-YingQian/backend/app/services/llm_service.py
  20. 454 0
      Co-creation-projects/aatanxiao12-beep-YingQian/backend/app/services/movie_service.py
  21. 0 0
      Co-creation-projects/aatanxiao12-beep-YingQian/backend/app/tools/__init__.py
  22. 164 0
      Co-creation-projects/aatanxiao12-beep-YingQian/backend/app/tools/movie_tool.py
  23. 0 0
      Co-creation-projects/aatanxiao12-beep-YingQian/backend/app/utils/__init__.py
  24. 33 0
      Co-creation-projects/aatanxiao12-beep-YingQian/backend/app/utils/logger.py
  25. 32 0
      Co-creation-projects/aatanxiao12-beep-YingQian/backend/pyproject.toml
  26. 15 0
      Co-creation-projects/aatanxiao12-beep-YingQian/backend/run.py
  27. 0 0
      Co-creation-projects/aatanxiao12-beep-YingQian/backend/tests/__init__.py
  28. 24 0
      Co-creation-projects/aatanxiao12-beep-YingQian/frontend/.gitignore
  29. 8 0
      Co-creation-projects/aatanxiao12-beep-YingQian/frontend/.oxlintrc.json
  30. 23 0
      Co-creation-projects/aatanxiao12-beep-YingQian/frontend/index.html
  31. 1423 0
      Co-creation-projects/aatanxiao12-beep-YingQian/frontend/package-lock.json
  32. 26 0
      Co-creation-projects/aatanxiao12-beep-YingQian/frontend/package.json
  33. 0 0
      Co-creation-projects/aatanxiao12-beep-YingQian/frontend/public/favicon.svg
  34. 24 0
      Co-creation-projects/aatanxiao12-beep-YingQian/frontend/public/icons.svg
  35. 17 0
      Co-creation-projects/aatanxiao12-beep-YingQian/frontend/src/App.tsx
  36. 68 0
      Co-creation-projects/aatanxiao12-beep-YingQian/frontend/src/api/client.ts
  37. 36 0
      Co-creation-projects/aatanxiao12-beep-YingQian/frontend/src/api/movies.ts
  38. 9 0
      Co-creation-projects/aatanxiao12-beep-YingQian/frontend/src/api/recommend.ts
  39. BIN
      Co-creation-projects/aatanxiao12-beep-YingQian/frontend/src/assets/hero.png
  40. 0 0
      Co-creation-projects/aatanxiao12-beep-YingQian/frontend/src/assets/react.svg
  41. 0 0
      Co-creation-projects/aatanxiao12-beep-YingQian/frontend/src/assets/vite.svg
  42. 5 0
      Co-creation-projects/aatanxiao12-beep-YingQian/frontend/src/brand.ts
  43. 145 0
      Co-creation-projects/aatanxiao12-beep-YingQian/frontend/src/components/CatalogDetail.tsx
  44. 49 0
      Co-creation-projects/aatanxiao12-beep-YingQian/frontend/src/components/CatalogTile.tsx
  45. 57 0
      Co-creation-projects/aatanxiao12-beep-YingQian/frontend/src/components/DetailFacts.tsx
  46. 16 0
      Co-creation-projects/aatanxiao12-beep-YingQian/frontend/src/components/FallbackAlert.tsx
  47. 116 0
      Co-creation-projects/aatanxiao12-beep-YingQian/frontend/src/components/MovieCard.tsx
  48. 283 0
      Co-creation-projects/aatanxiao12-beep-YingQian/frontend/src/components/PreferenceForm.tsx
  49. 31 0
      Co-creation-projects/aatanxiao12-beep-YingQian/frontend/src/components/ProgressOverlay.tsx
  50. 17 0
      Co-creation-projects/aatanxiao12-beep-YingQian/frontend/src/components/SiteFooter.tsx
  51. 41 0
      Co-creation-projects/aatanxiao12-beep-YingQian/frontend/src/components/SiteNav.tsx
  52. 10 0
      Co-creation-projects/aatanxiao12-beep-YingQian/frontend/src/main.tsx
  53. 281 0
      Co-creation-projects/aatanxiao12-beep-YingQian/frontend/src/pages/BrowsePage.tsx
  54. 164 0
      Co-creation-projects/aatanxiao12-beep-YingQian/frontend/src/pages/HomePage.tsx
  55. 178 0
      Co-creation-projects/aatanxiao12-beep-YingQian/frontend/src/pages/ResultPage.tsx
  56. 1484 0
      Co-creation-projects/aatanxiao12-beep-YingQian/frontend/src/styles/global.css
  57. 144 0
      Co-creation-projects/aatanxiao12-beep-YingQian/frontend/src/types/index.ts
  58. 26 0
      Co-creation-projects/aatanxiao12-beep-YingQian/frontend/tsconfig.app.json
  59. 7 0
      Co-creation-projects/aatanxiao12-beep-YingQian/frontend/tsconfig.json
  60. 23 0
      Co-creation-projects/aatanxiao12-beep-YingQian/frontend/tsconfig.node.json
  61. 21 0
      Co-creation-projects/aatanxiao12-beep-YingQian/frontend/vite.config.ts
  62. 186 0
      Co-creation-projects/aatanxiao12-beep-YingQian/main.ipynb
  63. 8 0
      Co-creation-projects/aatanxiao12-beep-YingQian/requirements.txt

+ 18 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/.env.example

@@ -0,0 +1,18 @@
+# TMDB(二选一即可;Access Token 优先)
+TMDB_ACCESS_TOKEN=
+TMDB_API_KEY=
+
+# OpenAI 兼容 LLM(HelloAgents)
+LLM_API_KEY=
+LLM_BASE_URL=
+LLM_MODEL_ID=
+
+# 服务器
+HOST=0.0.0.0
+PORT=8000
+CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
+LOG_LEVEL=INFO
+
+# HelloAgents Trace(true 时写入 TRACE_DIR;日常建议 false)
+TRACE_ENABLED=false
+TRACE_DIR=memory/traces

+ 9 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/.gitignore

@@ -0,0 +1,9 @@
+.env
+.venv/
+__pycache__/
+*.pyc
+.pytest_cache/
+node_modules/
+dist/
+*.local
+.DS_Store

+ 213 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/README.md

@@ -0,0 +1,213 @@
+# 映前 (YingQian) — 多智能体电影推荐助手
+
+> 基于 HelloAgents + TMDB:关灯之前,先把今晚的片定下来。
+
+## 📝 项目简介
+
+「映前」解决「今晚看什么」的选择困难。用户填写心情、观影对象、类型、时长等偏好后,系统通过 **Pipeline + Tool-use** 多智能体流水线,从 TMDB 真实片库中检索候选并给出带理由的精选推荐。
+
+- **解决问题**:偏好分散、候选太多、容易编造片名
+- **特色**:三阶段 Agent 协作 + TMDB 工具取真片 + id 白名单校验
+- **适用场景**:个人/情侣/朋友快速定片;也可当 HelloAgents 多 Agent 编排示例
+
+## ✨ 核心功能
+
+- [x] 画像 Agent:生成口味摘要与检索线索(TasteProfile)
+- [x] 检索 Agent:调用 TMDB `discover` / `search` 拉取真实候选
+- [x] 推荐 Agent:仅在候选 id 内精选并写中文理由
+- [x] 「换一批」:复用画像、排除已出 id
+- [x] 片库浏览 / 详情双通道 REST API
+- [x] React 前端(品牌「映前」)
+
+## 🛠️ 技术栈
+
+- **HelloAgents**:`SimpleAgent` + Tool(多 Agent 串行 Pipeline)
+- **后端**:FastAPI、Pydantic、httpx
+- **数据源**:TMDB API
+- **前端**:Vite + React + TypeScript
+- **LLM**:OpenAI 兼容接口(如 DeepSeek)
+
+## 🚀 快速开始
+
+### 环境要求
+
+- Python 3.10+
+- Node.js 18+(可选,跑前端)
+- TMDB Access Token 或 API Key
+- OpenAI 兼容 LLM(`LLM_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL_ID`)
+
+### 安装依赖
+
+```bash
+cd backend
+python -m venv .venv
+
+# Windows
+.venv\Scripts\activate
+
+# macOS / Linux
+# source .venv/bin/activate
+
+pip install -r ../requirements.txt
+pip install jupyterlab   # 若要跑 main.ipynb
+```
+
+### 配置 API 密钥
+
+**必须把 `.env` 放在 `backend/` 目录**(代码读取 `backend/.env`):
+
+```bash
+# 在项目根目录 aatanxiao12-beep-YingQian/ 下
+cp .env.example backend/.env
+
+# Windows PowerShell
+# Copy-Item .env.example backend\.env
+```
+
+编辑 `backend/.env`,至少填写:
+
+```env
+TMDB_ACCESS_TOKEN=你的TMDB_Token
+# 或 TMDB_API_KEY=你的Key
+
+LLM_API_KEY=你的LLM密钥
+LLM_BASE_URL=https://api.deepseek.com
+LLM_MODEL_ID=deepseek-v4-flash
+```
+
+### 方式 A:Jupyter 快速演示(推荐评审)
+
+```bash
+# 仍在 backend/ 且已激活 venv
+cd ..
+jupyter lab
+# 打开 main.ipynb,按顺序运行单元格
+```
+
+Notebook 会调用完整推荐流水线,打印画像摘要与推荐片单。
+
+### 方式 B:启动 Web 服务
+
+```bash
+cd backend
+python run.py
+```
+
+- API:http://127.0.0.1:8000  
+- 文档:http://127.0.0.1:8000/docs  
+
+前端(可选):
+
+```bash
+cd frontend
+npm install
+npm run dev
+```
+
+浏览器打开 http://127.0.0.1:5173
+
+## 📖 使用示例
+
+### 1)Notebook 一键推荐
+
+见 `main.ipynb`。核心调用等价于:
+
+```python
+import sys
+from pathlib import Path
+sys.path.insert(0, str(Path("backend").resolve()))
+
+from app.models.schemas import RecommendRequest
+from app.agents.movie_recommender_agent import MultiAgentMovieRecommender
+
+req = RecommendRequest(
+    mood="放松",
+    party_type="独自",
+    genres=["剧情", "喜剧"],
+    max_runtime_minutes=120,
+    region_preference="不限",
+    year_preference="近10年",
+    free_text="不要太沉重",
+)
+result, trace_id = MultiAgentMovieRecommender().recommend(req)
+for m in result.movies:
+    print(m.title, m.reason)
+```
+
+### 2)HTTP API
+
+```bash
+curl -X POST http://127.0.0.1:8000/api/recommend \
+  -H "Content-Type: application/json" \
+  -d "{\"mood\":\"虐心\",\"party_type\":\"朋友\",\"genres\":[\"爱情\"],\"region_preference\":\"不限\",\"year_preference\":\"近10年\"}"
+```
+
+成功时返回约 5 部电影卡片(含 `title`、`poster_url`、`reason` 等)以及可选的 `taste_profile`。
+
+### 3)前端
+
+打开首页 → 填写偏好 → 「开始荐片」→ 结果页查看理由;可「换一批」或去「片库」浏览。
+
+## 🎯 项目亮点
+
+- **真片约束**:检索必须走 TMDB Tool,推荐阶段用候选 id 白名单,降低幻觉片名
+- **多 Agent 分工**:画像 / 检索 / 推荐职责清晰,便于教学与扩展
+- **可降级兜底**:检索 Agent 解析失败时可用规则 discover 回退
+- **完整产品形态**:不仅有 Agent Demo,还有 FastAPI + 前端交互
+
+## 📊 性能说明(参考)
+
+在 DeepSeek + 本机可访问 TMDB 的环境下,一次完整推荐大约:
+
+| 阶段 | 参考耗时 |
+|------|----------|
+| 画像 Agent | ~5–8s |
+| 检索 Agent(含 1 次 discover) | ~15–25s |
+| 推荐 Agent | ~10–15s |
+| **合计** | **约 40–50s** |
+
+TMDB 本身通常 <2s;主要时间在 LLM。国内网络若连不上 `api.themoviedb.org`,需代理/VPN。
+
+## 🔮 未来计划
+
+- [ ] 无自由文本时规则化画像,跳过一轮 LLM
+- [ ] 默认规则 discover,复杂意图再启用检索 Agent
+- [ ] 短片过滤 / 时长下限等检索质量优化
+- [ ] 前端进度与真实阶段日志对齐
+
+## 📂 项目结构
+
+```text
+aatanxiao12-beep-YingQian/
+├── README.md
+├── requirements.txt
+├── .env.example
+├── .gitignore
+├── main.ipynb                 # 快速演示入口
+├── backend/
+│   ├── .env.example           # 同根目录示例(便于放 backend/.env)
+│   ├── app/                   # Agents / Tools / API / TMDB
+│   ├── tests/
+│   ├── run.py
+│   └── pyproject.toml
+└── frontend/
+    ├── src/
+    ├── package.json
+    └── ...
+```
+
+## 🤝 贡献指南
+
+欢迎提出 Issue 和 Pull Request!
+
+## 📄 许可证
+
+MIT License
+
+## 👤 作者
+
+- GitHub: [@aatanxiao12-beep](https://github.com/aatanxiao12-beep)
+
+## 🙏 致谢
+
+感谢 Datawhale 社区和 Hello-Agents 项目!

+ 18 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/backend/.env.example

@@ -0,0 +1,18 @@
+# TMDB(二选一即可;Access Token 优先)
+TMDB_ACCESS_TOKEN=
+TMDB_API_KEY=
+
+# OpenAI 兼容 LLM(HelloAgents)
+LLM_API_KEY=
+LLM_BASE_URL=
+LLM_MODEL_ID=
+
+# 服务器
+HOST=0.0.0.0
+PORT=8000
+CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
+LOG_LEVEL=INFO
+
+# HelloAgents Trace(true 时写入 TRACE_DIR;日常建议 false)
+TRACE_ENABLED=false
+TRACE_DIR=memory/traces

+ 1 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/backend/app/__init__.py

@@ -0,0 +1 @@
+"""LLM 电影推荐助手后端"""

+ 0 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/backend/app/agents/__init__.py


+ 697 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/backend/app/agents/movie_recommender_agent.py

@@ -0,0 +1,697 @@
+"""多智能体电影推荐编排(串行流水线)。
+
+范式:Pipeline + Tool-use
+  ① 画像 Agent(无工具)→ TasteProfile
+  ② 检索 Agent(挂 MovieTool)→ 真片候选
+  ③ 推荐 Agent(无工具)→ 仅在候选 id 内产出 RecommendResult
+
+本模块只提供编排器;HTTP 路由后续再接。
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import time
+from datetime import datetime
+from typing import Any, List, Optional, Tuple
+
+from hello_agents import Config, SimpleAgent
+
+from ..config import get_settings
+from ..models.schemas import (
+    CandidateMovie,
+    MovieCard,
+    RecommendRequest,
+    RecommendResult,
+    TasteProfile,
+)
+from ..services.llm_service import get_llm
+from ..services.movie_service import get_movie_service, normalize_tmdb_language
+from ..tools.movie_tool import get_movie_tool
+from ..utils.logger import get_logger
+
+logger = get_logger("app.agents")
+
+# ============ Prompts ============
+
+PROFILE_AGENT_PROMPT = """你是观影口味画像专家。根据用户偏好输出结构化 JSON,不要推荐具体片名。
+
+只返回如下 JSON(不要 Markdown 代码块外的解释):
+{
+  "summary": "一句话口味摘要",
+  "genre_hints": ["类型1", "类型2"],
+  "language_hints": ["仅填 ISO 码:zh / en / ja / ko,可空;禁止写好莱坞、英语等中文"],
+  "avoid": ["需规避的内容"],
+  "discover_notes": "给 TMDB discover 用的简短检索说明"
+}
+"""
+
+SEARCH_AGENT_PROMPT = """你是电影检索专家。必须调用工具从 TMDB 取真实影片,禁止编造片名。
+
+可用工具:
+- movies_discover: 主路径,按类型/年份/时长/语言发现(本轮只允许调用 1 次)
+- movies_search: 仅当需要解析「已看片名」时再用(可选,最多 1~2 次)
+
+硬规则:
+1. movies_discover 只调用一次:用建议参数一次取够候选,禁止换参反复 discover
+2. with_original_language 只能是 zh/en/ja/ko;不要传「好莱坞」「英语」等中文
+3. 拿到工具结果后立即输出最终 JSON,不要再调工具「精炼」
+4. 最终 movies 必须从工具结果原样抄写关键字段(含 poster_url)
+
+取数后,最终回复必须是 JSON(不要多余解释):
+{
+  "movies": [
+    {
+      "id": 123,
+      "title": "...",
+      "year": 2020,
+      "genres": [],
+      "rating": 7.5,
+      "poster_url": "https://...",
+      "overview": "..."
+    }
+  ]
+}
+
+要求:
+1. 尽量返回 15~25 部
+2. id / title / poster_url 等必须来自工具结果,禁止省略 poster_url
+3. 排除用户已给出的 exclude_ids
+"""
+
+RECOMMEND_AGENT_PROMPT = """你是电影推荐专家。你没有外部工具,只能从「候选列表」中挑选 3~5 部。
+
+硬约束:
+1. 每部电影的 id 必须出现在候选列表中
+2. 禁止编造候选之外的片名或 id
+3. 遵守 spoilers_ok:若为 false,overview_safe 不要写结局剧透
+4. why 要贴合用户心情与人群
+5. title / year / genres / rating / poster_url 尽量原样沿用候选列表(勿改写为空)
+
+只返回 JSON:
+{
+  "playlist_name": "片单主题名",
+  "profile_summary": "对用户口味的一句话总结",
+  "movies": [
+    {
+      "id": 123,
+      "title": "...",
+      "year": 2020,
+      "genres": ["..."],
+      "runtime": null,
+      "rating": 7.5,
+      "poster_url": "https://image.tmdb.org/t/p/w500/...",
+      "why": "推荐理由",
+      "vibe_tags": ["标签"],
+      "caution": null,
+      "overview_safe": "安全简介"
+    }
+  ],
+  "is_fallback": false
+}
+"""
+
+
+REGION_LANGUAGE = {
+    "华语": "zh",
+    "好莱坞": "en",
+    "日韩": "ja",  # 简化:先按日语;韩语可由画像 language_hints 覆盖
+    "欧洲": "",
+    "不限": "",
+}
+
+
+class MultiAgentMovieRecommender:
+    """串行三 Agent 推荐编排器(画像 → 检索 → 推荐 + 白名单校验)。"""
+
+    def __init__(self) -> None:
+        """初始化共享 LLM / MovieTool,并创建三个 SimpleAgent。"""
+        self.llm = get_llm()
+        self.movie_tool = get_movie_tool()
+        settings = get_settings()
+        # Trace 开关来自 .env:TRACE_ENABLED / TRACE_DIR
+        agent_config = Config(
+            trace_enabled=settings.trace_enabled,
+            trace_dir=settings.trace_dir,
+        )
+
+        # 画像:只做偏好结构化,禁止挂工具(避免这步就去搜片/编片名)
+        self.profile_agent = SimpleAgent(
+            name="画像专家",
+            llm=self.llm,
+            system_prompt=PROFILE_AGENT_PROMPT,
+            config=agent_config,
+            enable_tool_calling=False,
+        )
+        # 检索:唯一允许碰 TMDB 的 Agent;工具展开为 discover / search
+        # max_tool_iterations=2:1 轮工具 + 1 轮收尾文本;再高容易反复换参 discover
+        self.search_agent = SimpleAgent(
+            name="检索专家",
+            llm=self.llm,
+            system_prompt=SEARCH_AGENT_PROMPT,
+            config=agent_config,
+            max_tool_iterations=2,
+        )
+        self.search_agent.add_tool(self.movie_tool)
+
+        # 推荐:无工具,只能在上游候选里选择与说理(防幻觉核心)
+        self.recommend_agent = SimpleAgent(
+            name="推荐专家",
+            llm=self.llm,
+            system_prompt=RECOMMEND_AGENT_PROMPT,
+            config=agent_config,
+            enable_tool_calling=False,
+        )
+        logger.info(
+            "MultiAgentMovieRecommender 就绪: tools=%s trace=%s",
+            self.search_agent.list_tools(),
+            settings.trace_enabled,
+        )
+
+    def recommend(self, request: RecommendRequest) -> Tuple[RecommendResult, str]:
+        """跑完整推荐流水线。
+
+        Returns:
+            (RecommendResult, message):业务结果 + 给人看的状态说明(含降级提示)。
+        """
+        pipeline_t0 = time.perf_counter()
+        try:
+            logger.info("推荐开始 mood=%s party=%s", request.mood, request.party_type)
+
+            # ① 偏好 → TasteProfile;换一批可携带 taste_profile 跳过画像 LLM
+            t0 = time.perf_counter()
+            profile, profile_reused = self._resolve_profile(request)
+            logger.info(
+                "阶段完成 stage=profile elapsed=%.2fs reused=%s summary=%s",
+                time.perf_counter() - t0,
+                profile_reused,
+                profile.summary,
+            )
+
+            # ② 真片候选;失败则降级,避免在空列表上瞎荐
+            t0 = time.perf_counter()
+            candidates = self._run_search(request, profile)
+            logger.info(
+                "阶段完成 stage=search elapsed=%.2fs candidates=%d",
+                time.perf_counter() - t0,
+                len(candidates),
+            )
+            if not candidates:
+                result = self._fallback_result(request, profile, [], "未取得候选片")
+                return result, "检索无结果,已返回降级片单"
+
+            # ③ 候选内推荐 + 代码层 id 白名单(不信任模型自觉)
+            t0 = time.perf_counter()
+            result = self._run_recommend(request, profile, candidates)
+            logger.info(
+                "阶段完成 stage=recommend_llm elapsed=%.2fs",
+                time.perf_counter() - t0,
+            )
+            t0 = time.perf_counter()
+            result = self._enforce_candidate_ids(result, candidates, profile)
+            result = self._attach_taste_profile(result, profile)
+            logger.info(
+                "阶段完成 stage=enforce elapsed=%.2fs movies=%d fallback=%s total=%.2fs",
+                time.perf_counter() - t0,
+                len(result.movies),
+                result.is_fallback,
+                time.perf_counter() - pipeline_t0,
+            )
+            msg = "推荐生成成功" if not result.is_fallback else "推荐已做 id 校正/降级"
+            if profile_reused:
+                msg = f"{msg}(已跳过画像)"
+            return result, msg
+
+        except Exception as e:
+            # 未捕获异常也返回完整结构,前端不白屏
+            logger.exception("推荐流水线异常")
+            result = self._fallback_result(request, None, [], str(e))
+            return result, f"推荐异常,已降级: {e}"
+
+    # ----- stages -----
+
+    def _resolve_profile(self, request: RecommendRequest) -> Tuple[TasteProfile, bool]:
+        """解析画像:请求携带可用 taste_profile 则复用,否则跑画像 Agent。"""
+        reused = request.taste_profile
+        if reused is not None and (
+            (reused.summary or "").strip()
+            or reused.genre_hints
+            or (reused.discover_notes or "").strip()
+        ):
+            logger.info("跳过画像 Agent,复用请求中的 taste_profile")
+            return self._sanitize_profile(reused, request), True
+        return self._sanitize_profile(self._run_profile(request), request), False
+
+    def _sanitize_profile(
+        self,
+        profile: TasteProfile,
+        request: RecommendRequest,
+    ) -> TasteProfile:
+        """规范化 language_hints 为 ISO 码;非法项丢弃。"""
+        cleaned: List[str] = []
+        for hint in profile.language_hints or []:
+            code = normalize_tmdb_language(hint)
+            if code and code not in cleaned:
+                cleaned.append(code)
+        if not cleaned:
+            fallback = normalize_tmdb_language(
+                REGION_LANGUAGE.get(request.region_preference, "")
+            )
+            if fallback:
+                cleaned = [fallback]
+        if cleaned != list(profile.language_hints or []):
+            logger.info(
+                "画像 language_hints 已归一化: %s -> %s",
+                profile.language_hints,
+                cleaned,
+            )
+        profile.language_hints = cleaned
+        return profile
+
+    def _resolve_language(
+        self,
+        request: RecommendRequest,
+        profile: TasteProfile,
+    ) -> Optional[str]:
+        """解析最终用于 discover 的语言码。"""
+        if profile.language_hints:
+            code = normalize_tmdb_language(profile.language_hints[0])
+            if code:
+                return code
+        return normalize_tmdb_language(
+            REGION_LANGUAGE.get(request.region_preference, "")
+        )
+
+    def _attach_taste_profile(
+        self,
+        result: RecommendResult,
+        profile: TasteProfile,
+    ) -> RecommendResult:
+        """把本次画像挂到结果上,供换一批回传。"""
+        result.taste_profile = profile
+        if not result.profile_summary:
+            result.profile_summary = profile.summary
+        return result
+
+    def _run_profile(self, request: RecommendRequest) -> TasteProfile:
+        """阶段①:调用画像 Agent,解析为 TasteProfile;失败则用表单字段兜底。"""
+        self.profile_agent.clear_history()
+        raw = self.profile_agent.run(self._build_profile_query(request))
+        data = self._extract_json(raw) or {}
+        try:
+            return TasteProfile(**data)
+        except Exception:
+            # 画像 JSON 坏了:用表单字段拼可用 profile,保证后续检索能继续
+            return TasteProfile(
+                summary=f"{request.mood}/{request.party_type} 观影",
+                genre_hints=list(request.genres),
+                language_hints=[REGION_LANGUAGE.get(request.region_preference, "")],
+                avoid=[],
+                discover_notes=request.free_text or "",
+            )
+
+    def _run_search(
+        self,
+        request: RecommendRequest,
+        profile: TasteProfile,
+    ) -> List[CandidateMovie]:
+        """阶段②:检索 Agent 调工具取真片;解析失败则 MovieService 规则 discover 兜底。"""
+        self.search_agent.clear_history()
+        self.movie_tool.begin_search_run(discover_limit=1)
+        t0 = time.perf_counter()
+        try:
+            raw = self.search_agent.run(self._build_search_query(request, profile))
+        finally:
+            self.movie_tool.end_search_run()
+        logger.info(
+            "检索 Agent run 结束 elapsed=%.2fs raw_len=%d",
+            time.perf_counter() - t0,
+            len(raw or ""),
+        )
+        movies = self._parse_candidates(raw, request.exclude_ids)
+        if movies:
+            missing_poster = sum(1 for m in movies if not m.poster_url)
+            logger.info(
+                "检索 Agent 解析成功 count=%d missing_poster=%d",
+                len(movies),
+                missing_poster,
+            )
+            return movies
+
+        # Agent 未给出可用 JSON 时,用 profile 规则直连 MovieService(仍是真数据)
+        logger.warning("检索 Agent 未解析出候选,改用 MovieService 规则兜底")
+        t0 = time.perf_counter()
+        fallback = self._discover_by_profile(request, profile)
+        logger.info(
+            "阶段完成 stage=search_fallback_discover elapsed=%.2fs count=%d",
+            time.perf_counter() - t0,
+            len(fallback),
+        )
+        return fallback
+
+    def _run_recommend(
+        self,
+        request: RecommendRequest,
+        profile: TasteProfile,
+        candidates: List[CandidateMovie],
+    ) -> RecommendResult:
+        """阶段③:推荐 Agent 仅在候选内产出 RecommendResult;JSON 坏则降级。"""
+        self.recommend_agent.clear_history()
+        raw = self.recommend_agent.run(
+            self._build_recommend_query(request, profile, candidates)
+        )
+        data = self._extract_json(raw)
+        if not data:
+            return self._fallback_result(request, profile, candidates, "推荐 JSON 解析失败")
+        try:
+            data.setdefault("is_fallback", False)
+            # 画像由编排器挂载,不采信模型自带的 taste_profile 字段
+            data.pop("taste_profile", None)
+            return RecommendResult(**data)
+        except Exception:
+            return self._fallback_result(request, profile, candidates, "推荐结构校验失败")
+
+    # ----- queries -----
+
+    def _build_profile_query(self, request: RecommendRequest) -> str:
+        """把 RecommendRequest 拼成画像 Agent 的用户输入文本。"""
+        return (
+            f"心情: {request.mood}\n"
+            f"人群: {request.party_type}\n"
+            f"类型偏好: {', '.join(request.genres) or '无'}\n"
+            f"时长上限(分钟): {request.max_runtime_minutes}\n"
+            f"地区: {request.region_preference}\n"
+            f"年代: {request.year_preference}\n"
+            f"已看过: {', '.join(request.exclude_titles) or '无'}\n"
+            f"允许剧透: {request.spoilers_ok}\n"
+            f"额外要求: {request.free_text or '无'}\n"
+            "请输出 TasteProfile JSON。"
+        )
+
+    def _build_search_query(self, request: RecommendRequest, profile: TasteProfile) -> str:
+        """把画像 + 表单约束拼成检索 Agent 输入(含建议的 discover 参数)。"""
+        # 预先算好 discover 参数提示,降低模型乱填工具参数的概率
+        year_gte, year_lte = self._year_bounds(request.year_preference)
+        lang = self._resolve_language(request, profile) or ""
+
+        genres = ",".join(profile.genre_hints or request.genres)
+        parts = [
+            "请只调用一次 movies_discover(用下列建议参数),取到结果后立刻输出 JSON;不要反复换参 discover。",
+            f"画像摘要: {profile.summary}",
+            f"建议 with_genres: {genres or '不限'}",
+            f"建议 year_gte: {year_gte or 0}, year_lte: {year_lte or 0}",
+            f"建议 max_runtime: {request.max_runtime_minutes or 0}",
+            f"建议 with_original_language: {lang or '不限'}(仅 zh/en/ja/ko)",
+            f"discover_notes: {profile.discover_notes}",
+            f"exclude_ids: {request.exclude_ids}",
+            f"已看片名(仅必要时用 movies_search 辅助排除): {request.exclude_titles}",
+            "最终只输出含 movies 数组的 JSON,且每部必须带工具返回的 poster_url。",
+        ]
+        return "\n".join(parts)
+
+    def _build_recommend_query(
+        self,
+        request: RecommendRequest,
+        profile: TasteProfile,
+        candidates: List[CandidateMovie],
+    ) -> str:
+        """把用户偏好 + 精简候选列表拼成推荐 Agent 输入。"""
+        # 只塞精简字段进 prompt;片名/海报等最终以候选元数据为准
+        slim = [
+            {
+                "id": c.id,
+                "title": c.title,
+                "year": c.year,
+                "genres": c.genres,
+                "rating": c.rating,
+                "poster_url": c.poster_url,
+                "overview": (c.overview or "")[:180],
+            }
+            for c in candidates
+        ]
+        return (
+            f"用户心情: {request.mood}; 人群: {request.party_type}; "
+            f"剧透允许: {request.spoilers_ok}\n"
+            f"画像: {profile.summary}\n"
+            f"额外要求: {request.free_text or '无'}\n"
+            f"候选列表(只能从中选):\n{json.dumps(slim, ensure_ascii=False)}\n"
+            "请输出 RecommendResult JSON(3~5 部)。"
+        )
+
+    # ----- helpers -----
+
+    @staticmethod
+    def _year_bounds(year_preference: str) -> Tuple[Optional[int], Optional[int]]:
+        """表单年代偏好 → TMDB discover 的 (year_gte, year_lte)。"""
+        year = datetime.now().year
+        if year_preference == "近5年":
+            return year - 5, None
+        if year_preference == "近10年":
+            return year - 10, None
+        if year_preference == "经典":
+            return None, 2000
+        return None, None
+
+    def _discover_by_profile(
+        self,
+        request: RecommendRequest,
+        profile: TasteProfile,
+    ) -> List[CandidateMovie]:
+        """不经 LLM,按画像字段确定性 discover;空结果自动放宽条件。"""
+        year_gte, year_lte = self._year_bounds(request.year_preference)
+        lang = self._resolve_language(request, profile)
+        genres = ",".join(profile.genre_hints or request.genres) or None
+        return get_movie_service().discover_with_relax(
+            with_genres=genres,
+            year_gte=year_gte,
+            year_lte=year_lte,
+            max_runtime=request.max_runtime_minutes,
+            with_original_language=lang,
+            page=1,
+            exclude_ids=request.exclude_ids,
+        )
+
+    def _parse_candidates(self, raw: str, exclude_ids: List[int]) -> List[CandidateMovie]:
+        """从检索 Agent 文本抽出 movies,过滤 exclude_ids 与空标题。"""
+        data = self._extract_json(raw)
+        if not data:
+            return []
+        items = data.get("movies") if isinstance(data, dict) else None
+        if not isinstance(items, list):
+            return []
+        exclude = set(exclude_ids)
+        out: List[CandidateMovie] = []
+        for item in items:
+            if not isinstance(item, dict) or "id" not in item:
+                continue
+            try:
+                movie = CandidateMovie(
+                    id=int(item["id"]),
+                    title=str(item.get("title") or ""),
+                    year=item.get("year"),
+                    genres=item.get("genres") or [],
+                    runtime=item.get("runtime"),
+                    rating=item.get("rating"),
+                    poster_url=item.get("poster_url"),
+                    overview=item.get("overview") or "",
+                )
+            except Exception:
+                continue
+            if movie.id in exclude or not movie.title:
+                continue
+            out.append(movie)
+        return out
+
+    def _card_from_candidate(
+        self,
+        src: CandidateMovie,
+        *,
+        why: str = "",
+        vibe_tags: Optional[List[str]] = None,
+        caution: Optional[str] = None,
+        overview_safe: str = "",
+        runtime: Optional[int] = None,
+        poster_url: Optional[str] = None,
+    ) -> MovieCard:
+        """候选 → MovieCard;缺海报时按 id 拉 detail 回填(仅最终 3~5 部)。"""
+        poster = src.poster_url or poster_url
+        title = src.title
+        year = src.year
+        genres = list(src.genres or [])
+        rating = src.rating
+        overview = overview_safe or (src.overview or "")[:200]
+        rt = runtime if runtime is not None else src.runtime
+
+        if not poster:
+            try:
+                detail = get_movie_service().get_detail(src.id)
+                poster = detail.poster_url
+                title = title or detail.title
+                year = year if year is not None else detail.year
+                genres = genres or list(detail.genres or [])
+                rating = rating if rating is not None else detail.rating
+                if not overview_safe and detail.overview:
+                    overview = detail.overview[:200]
+                if rt is None:
+                    rt = detail.runtime
+            except Exception:
+                logger.warning("MovieCard 海报回填失败 id=%s", src.id)
+
+        return MovieCard(
+            id=src.id,
+            title=title,
+            year=year,
+            genres=genres,
+            runtime=rt,
+            rating=rating,
+            poster_url=poster,
+            why=why,
+            vibe_tags=vibe_tags or [],
+            caution=caution,
+            overview_safe=overview,
+        )
+
+    def _enforce_candidate_ids(
+        self,
+        result: RecommendResult,
+        candidates: List[CandidateMovie],
+        profile: TasteProfile,
+    ) -> RecommendResult:
+        """白名单闸:丢弃候选外 id;元数据以 TMDB 候选为准;不足 3 部则补齐并降级。"""
+        allowed = {c.id: c for c in candidates}
+        kept: List[MovieCard] = []
+        for card in result.movies:
+            if card.id not in allowed:
+                continue
+            src = allowed[card.id]
+            kept.append(
+                self._card_from_candidate(
+                    src,
+                    why=card.why,
+                    vibe_tags=card.vibe_tags,
+                    caution=card.caution,
+                    overview_safe=card.overview_safe or (src.overview or "")[:200],
+                    runtime=card.runtime if card.runtime is not None else src.runtime,
+                    poster_url=card.poster_url,
+                )
+            )
+        if 3 <= len(kept) <= 5:
+            result.movies = kept
+            return result
+
+        # 合法片不足 3 部:按评分从候选补齐,并标记降级
+        result.is_fallback = True
+        have = {m.id for m in kept}
+        ranked = sorted(
+            candidates,
+            key=lambda m: (m.rating is not None, m.rating or 0),
+            reverse=True,
+        )
+        for c in ranked:
+            if c.id in have:
+                continue
+            kept.append(
+                self._card_from_candidate(
+                    c,
+                    why="系统按候选热度补齐",
+                    overview_safe=(c.overview or "")[:200],
+                )
+            )
+            if len(kept) >= 3:
+                break
+        result.movies = kept[:5]
+        if not result.profile_summary and profile:
+            result.profile_summary = profile.summary
+        if not result.playlist_name:
+            result.playlist_name = "今日候选速选"
+        return result
+
+    def _fallback_result(
+        self,
+        request: RecommendRequest,
+        profile: Optional[TasteProfile],
+        candidates: List[CandidateMovie],
+        reason: str,
+    ) -> RecommendResult:
+        """诚实降级:尽量用真片凑片单,强制 is_fallback=True。"""
+        if not candidates:
+            try:
+                candidates = self._discover_by_profile(
+                    request,
+                    profile
+                    or TasteProfile(
+                        summary=reason,
+                        genre_hints=list(request.genres),
+                    ),
+                )
+            except Exception:
+                candidates = []
+
+        movies: List[MovieCard] = []
+        for c in candidates[:5]:
+            movies.append(
+                self._card_from_candidate(
+                    c,
+                    why=f"降级推荐({reason})",
+                    overview_safe=(c.overview or "")[:200],
+                )
+            )
+        return RecommendResult(
+            playlist_name="降级片单",
+            profile_summary=(profile.summary if profile else reason),
+            movies=movies,
+            is_fallback=True,
+            taste_profile=profile,
+        )
+
+    @staticmethod
+    def _extract_json(text: str) -> Optional[dict]:
+        """从模型文本提取 JSON 对象(纯 JSON / 代码块 / 夹杂说明均可)。"""
+        if not text:
+            return None
+        text = text.strip()
+        try:
+            data = json.loads(text)
+            return data if isinstance(data, dict) else None
+        except json.JSONDecodeError:
+            pass
+        fence = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
+        if fence:
+            try:
+                data = json.loads(fence.group(1))
+                return data if isinstance(data, dict) else None
+            except json.JSONDecodeError:
+                pass
+        start, end = text.find("{"), text.rfind("}")
+        if start >= 0 and end > start:
+            try:
+                data = json.loads(text[start : end + 1])
+                return data if isinstance(data, dict) else None
+            except json.JSONDecodeError:
+                return None
+        return None
+
+    def health_snapshot(self) -> dict[str, Any]:
+        """返回各 Agent 名称与工具数量(供 /api/recommend/health)。"""
+        return {
+            "agents": [
+                {"name": self.profile_agent.name, "tools_count": 0},
+                {
+                    "name": self.search_agent.name,
+                    "tools_count": len(self.search_agent.list_tools()),
+                },
+                {"name": self.recommend_agent.name, "tools_count": 0},
+            ]
+        }
+
+
+_recommender: Optional[MultiAgentMovieRecommender] = None
+
+
+def get_movie_recommender() -> MultiAgentMovieRecommender:
+    """获取进程内编排器单例(懒加载,避免重复初始化 LLM/Agent)。"""
+    global _recommender
+    if _recommender is None:
+        _recommender = MultiAgentMovieRecommender()
+    return _recommender

+ 0 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/backend/app/api/__init__.py


+ 53 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/backend/app/api/exception_handlers.py

@@ -0,0 +1,53 @@
+"""全局异常处理:统一响应 {success, message, error_code}。"""
+
+from __future__ import annotations
+
+from fastapi import FastAPI, Request
+from fastapi.exceptions import RequestValidationError
+from fastapi.responses import JSONResponse
+from starlette.exceptions import HTTPException as StarletteHTTPException
+
+from ..exceptions import AppError
+from ..utils.logger import get_logger
+
+logger = get_logger("app.errors")
+
+
+def _body(message: str, error_code: str) -> dict:
+    return {"success": False, "message": message, "error_code": error_code}
+
+
+def register_exception_handlers(app: FastAPI) -> None:
+    @app.exception_handler(AppError)
+    async def app_error_handler(_: Request, exc: AppError) -> JSONResponse:
+        logger.warning("[%s] %s", exc.code, exc.message)
+        return JSONResponse(
+            status_code=exc.status_code,
+            content=_body(exc.message, exc.code),
+        )
+
+    @app.exception_handler(RequestValidationError)
+    async def validation_handler(_: Request, exc: RequestValidationError) -> JSONResponse:
+        msg = "; ".join(
+            f"{'.'.join(str(x) for x in err.get('loc', ()))}: {err.get('msg')}"
+            for err in exc.errors()
+        )
+        logger.warning("validation: %s", msg)
+        return JSONResponse(status_code=422, content=_body(msg, "VALIDATION_ERROR"))
+
+    @app.exception_handler(StarletteHTTPException)
+    async def http_exception_handler(_: Request, exc: StarletteHTTPException) -> JSONResponse:
+        detail = exc.detail
+        message = detail if isinstance(detail, str) else str(detail)
+        return JSONResponse(
+            status_code=exc.status_code,
+            content=_body(message, "HTTP_ERROR"),
+        )
+
+    @app.exception_handler(Exception)
+    async def unhandled_handler(_: Request, exc: Exception) -> JSONResponse:
+        logger.exception("unhandled: %s", exc)
+        return JSONResponse(
+            status_code=500,
+            content=_body("服务器内部错误", "INTERNAL_ERROR"),
+        )

+ 90 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/backend/app/api/main.py

@@ -0,0 +1,90 @@
+"""FastAPI 主应用(CORS / health / Swagger / startup)"""
+
+from contextlib import asynccontextmanager
+
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+
+from ..config import get_settings, print_config, validate_config
+from ..utils.logger import get_logger, setup_logging
+from .exception_handlers import register_exception_handlers
+from .routes import movies, recommend
+
+settings = get_settings()
+logger = get_logger("app.api")
+
+OPENAPI_TAGS = [
+    {"name": "System", "description": "健康检查与服务信息"},
+    {"name": "Movies", "description": "确定性 TMDB 搜片 / 发现(不经 LLM)"},
+    {"name": "Recommend", "description": "多智能体智能推荐(HelloAgents + TMDB Tool)"},
+]
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+    setup_logging()
+    logger.info("%s v%s 启动中", settings.app_name, settings.app_version)
+    print_config()
+    validate_config()
+    # 0.0.0.0 仅表示监听所有网卡,浏览器请用 localhost / 127.0.0.1
+    docs_host = "127.0.0.1" if settings.host in ("0.0.0.0", "::") else settings.host
+    logger.info("Swagger: http://%s:%s/docs", docs_host, settings.port)
+    yield
+    logger.info("应用关闭")
+
+
+app = FastAPI(
+    title=settings.app_name,
+    version=settings.app_version,
+    description=(
+        "HelloAgents + TMDB 的 LLM 电影推荐 Demo API。\n\n"
+        "- **Movies**:确定性片库通道(search / discover)\n"
+        "- **Recommend**:多智能体推荐(画像 → 检索 → 推荐)\n\n"
+        "密钥仅通过环境变量配置,详见 `.env.example`。"
+    ),
+    docs_url="/docs",
+    redoc_url="/redoc",
+    openapi_url="/openapi.json",
+    openapi_tags=OPENAPI_TAGS,
+    swagger_ui_parameters={
+        "docExpansion": "list",
+        "defaultModelsExpandDepth": 1,
+        "persistAuthorization": True,
+    },
+    lifespan=lifespan,
+)
+
+register_exception_handlers(app)
+
+app.add_middleware(
+    CORSMiddleware,
+    allow_origins=settings.get_cors_origins_list(),
+    allow_credentials=True,
+    allow_methods=["*"],
+    allow_headers=["*"],
+)
+
+app.include_router(movies.router, prefix="/api")
+app.include_router(recommend.router, prefix="/api")
+
+
+@app.get("/", tags=["System"], summary="服务根信息")
+async def root():
+    return {
+        "name": settings.app_name,
+        "version": settings.app_version,
+        "status": "running",
+        "docs": "/docs",
+        "redoc": "/redoc",
+        "openapi": "/openapi.json",
+    }
+
+
+@app.get("/health", tags=["System"], summary="健康检查")
+async def health():
+    return {
+        "status": "healthy",
+        "service": settings.app_name,
+        "version": settings.app_version,
+        "tmdb_configured": settings.has_tmdb_credentials(),
+    }

+ 0 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/backend/app/api/routes/__init__.py


+ 95 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/backend/app/api/routes/movies.py

@@ -0,0 +1,95 @@
+"""确定性搜片路由(D1):不经过 LLM,直接走 MovieService → TMDB。"""
+
+from typing import Optional
+
+from fastapi import APIRouter, Path, Query
+
+from ...models.schemas import MovieDetailResponse, MovieListResponse
+from ...services.movie_service import get_movie_service
+from ...utils.logger import get_logger
+
+router = APIRouter(prefix="/movies", tags=["Movies"])
+logger = get_logger("app.movies")
+
+
+@router.get(
+    "/search",
+    response_model=MovieListResponse,
+    summary="文本搜索电影",
+    description="对应 TMDB `GET /search/movie`,参数 `q` 必填。",
+)
+async def search_movies(
+    q: str = Query(..., min_length=1, description="搜索关键词", examples=["盗梦空间"]),
+    year: Optional[int] = Query(default=None, description="上映年份"),
+    page: int = Query(default=1, ge=1, le=500, description="页码"),
+):
+    movies = get_movie_service().search(q=q, year=year, page=page)
+    logger.info("search q=%r year=%s -> %d", q, year, len(movies))
+    return MovieListResponse(
+        success=True,
+        message=f"搜索成功,共 {len(movies)} 条",
+        data=movies,
+    )
+
+
+@router.get(
+    "/discover",
+    response_model=MovieListResponse,
+    summary="条件发现电影",
+    description="对应 TMDB `GET /discover/movie`;`with_genres` 支持中文类型名或 id。",
+)
+async def discover_movies(
+    with_genres: Optional[str] = Query(
+        default=None,
+        description="类型:名称或 id,逗号分隔,如 剧情,喜剧 或 18,35",
+        examples=["科幻"],
+    ),
+    year: Optional[int] = Query(default=None, description="精确上映年"),
+    year_gte: Optional[int] = Query(default=None, description="上映年起"),
+    year_lte: Optional[int] = Query(default=None, description="上映年止"),
+    max_runtime: Optional[int] = Query(
+        default=None,
+        ge=1,
+        description="最大片长(分钟)→ with_runtime.lte",
+    ),
+    with_original_language: Optional[str] = Query(
+        default=None,
+        description="原始语言,如 zh / en / ja / ko",
+    ),
+    sort_by: str = Query(default="popularity.desc", description="排序字段"),
+    page: int = Query(default=1, ge=1, le=500, description="页码"),
+):
+    movies = get_movie_service().discover(
+        with_genres=with_genres,
+        year=year,
+        year_gte=year_gte,
+        year_lte=year_lte,
+        max_runtime=max_runtime,
+        with_original_language=with_original_language,
+        sort_by=sort_by,
+        page=page,
+    )
+    logger.info("discover genres=%r -> %d", with_genres, len(movies))
+    return MovieListResponse(
+        success=True,
+        message=f"发现成功,共 {len(movies)} 条",
+        data=movies,
+    )
+
+
+@router.get(
+    "/{movie_id}",
+    response_model=MovieDetailResponse,
+    summary="电影详情",
+    description="对应 TMDB `GET /movie/{movie_id}`,补全片长 runtime 等详情字段。",
+)
+async def get_movie_detail(
+    movie_id: int = Path(..., ge=1, description="TMDB 电影 id", examples=[550]),
+):
+    movie = get_movie_service().get_detail(movie_id)
+    logger.info("detail id=%s title=%r runtime=%s", movie_id, movie.title, movie.runtime)
+    return MovieDetailResponse(
+        success=True,
+        message="查询成功",
+        data=movie,
+    )

+ 63 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/backend/app/api/routes/recommend.py

@@ -0,0 +1,63 @@
+"""智能推荐 API:多智能体流水线(画像 → 检索 → 推荐)。
+
+同步 Agent/LLM 通过 asyncio.to_thread 执行,避免阻塞事件循环。
+"""
+
+from __future__ import annotations
+
+import asyncio
+
+from fastapi import APIRouter
+
+from ...agents.movie_recommender_agent import get_movie_recommender
+from ...exceptions import AppError
+from ...models.schemas import RecommendRequest, RecommendResponse
+from ...utils.logger import get_logger
+
+router = APIRouter(prefix="/recommend", tags=["Recommend"])
+logger = get_logger("app.recommend")
+
+
+@router.post(
+    "",
+    response_model=RecommendResponse,
+    summary="智能电影推荐",
+    description=(
+        "串行多智能体:画像(无工具)→ 检索(TMDB Tool)→ 推荐(候选内决策)。"
+        "耗时可能较长(视 LLM),建议客户端超时 ≥ 120s。"
+    ),
+)
+async def recommend_movies(request: RecommendRequest) -> RecommendResponse:
+    logger.info(
+        "recommend 请求 mood=%s party=%s genres=%s",
+        request.mood,
+        request.party_type,
+        request.genres,
+    )
+    agent = get_movie_recommender()
+    # recommend() 内含多次同步 LLM/HTTP,放到线程池
+    result, message = await asyncio.to_thread(agent.recommend, request)
+    return RecommendResponse(success=True, message=message, data=result)
+
+
+@router.get(
+    "/health",
+    summary="推荐服务健康检查",
+    description="返回各 Agent 名称与工具数量;初始化失败时 503。",
+)
+async def recommend_health():
+    try:
+        agent = get_movie_recommender()
+        snap = agent.health_snapshot()
+        return {
+            "status": "healthy",
+            "service": "recommend",
+            **snap,
+        }
+    except Exception as e:
+        logger.exception("recommend health 失败")
+        raise AppError(
+            f"推荐服务不可用: {e}",
+            code="RECOMMEND_UNAVAILABLE",
+            status_code=503,
+        ) from e

+ 118 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/backend/app/config.py

@@ -0,0 +1,118 @@
+"""配置管理 — 密钥全部来自环境变量,禁止硬编码。"""
+
+import os
+from pathlib import Path
+from typing import List, Optional, Tuple
+
+from dotenv import load_dotenv
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+# 优先加载 backend/.env
+_env_path = Path(__file__).resolve().parent.parent / ".env"
+load_dotenv(_env_path)
+load_dotenv()  # 兼容从仓库根目录启动
+
+
+
+class Settings(BaseSettings):
+    """应用配置(pydantic-settings)"""
+
+    model_config = SettingsConfigDict(
+        env_file=str(_env_path),
+        env_file_encoding="utf-8",
+        case_sensitive=False,
+        extra="ignore",
+    )
+
+    app_name: str = "LLM 电影推荐助手"
+    app_version: str = "0.1.0"
+    debug: bool = False
+
+    host: str = "0.0.0.0"
+    port: int = 8000
+
+    # 逗号分隔,代码中再拆成列表
+    cors_origins: str = (
+        "http://localhost:5173,http://127.0.0.1:5173,"
+        "http://localhost:3000,http://127.0.0.1:3000"
+    )
+
+    # TMDB:二选一即可(Access Token 优先)
+    tmdb_access_token: str = ""
+    tmdb_api_key: str = ""
+    tmdb_language: str = "zh-CN"
+    tmdb_include_adult: bool = False
+    tmdb_image_base_url: str = "https://image.tmdb.org/t/p/w500"
+
+    # LLM 也可由 HelloAgents 直接读 LLM_* 环境变量;此处仅作展示/兜底
+    llm_api_key: str = ""
+    llm_base_url: str = ""
+    llm_model_id: str = ""
+
+    log_level: str = "INFO"
+
+    # HelloAgents Trace(写入 memory/traces;调试时再开)
+    trace_enabled: bool = False
+    trace_dir: str = "memory/traces"
+
+    def get_cors_origins_list(self) -> List[str]:
+        return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
+
+    def has_tmdb_credentials(self) -> bool:
+        return bool(self.tmdb_access_token or self.tmdb_api_key)
+
+    def resolve_tmdb_credentials(self) -> Tuple[Optional[str], Optional[str]]:
+        """返回 (access_token, api_key);Access Token 优先用于 Bearer 鉴权。"""
+        token = (self.tmdb_access_token or "").strip() or None
+        api_key = (self.tmdb_api_key or "").strip() or None
+        return token, api_key
+
+
+settings = Settings()
+
+
+def get_settings() -> Settings:
+    return settings
+
+
+def validate_config() -> bool:
+    """startup 校验:本回合仅警告,不阻断启动(便于先跑 /health)。"""
+    warnings: list[str] = []
+
+    if not settings.has_tmdb_credentials():
+        warnings.append("TMDB_ACCESS_TOKEN / TMDB_API_KEY 未配置,片库接口稍后不可用")
+
+    llm_key = (
+        os.getenv("LLM_API_KEY")
+        or settings.llm_api_key
+        or os.getenv("OPENAI_API_KEY")
+    )
+    if not llm_key:
+        warnings.append("LLM_API_KEY 未配置,多智能体推荐稍后可能无法调用模型")
+
+    if warnings:
+        print("\n⚠️  配置警告:")
+        for w in warnings:
+            print(f"  - {w}")
+
+    return True
+
+
+def print_config() -> None:
+    llm_key = (
+        os.getenv("LLM_API_KEY")
+        or settings.llm_api_key
+        or os.getenv("OPENAI_API_KEY")
+    )
+    llm_base = os.getenv("LLM_BASE_URL") or settings.llm_base_url or "(默认)"
+    llm_model = os.getenv("LLM_MODEL_ID") or settings.llm_model_id or "(默认)"
+
+    print(f"应用名称: {settings.app_name}")
+    print(f"版本: {settings.app_version}")
+    print(f"服务器: {settings.host}:{settings.port}")
+    print(f"TMDB: {'已配置' if settings.has_tmdb_credentials() else '未配置'}")
+    print(f"LLM API Key: {'已配置' if llm_key else '未配置'}")
+    print(f"LLM Base URL: {llm_base}")
+    print(f"LLM Model: {llm_model}")
+    print(f"日志级别: {settings.log_level}")
+    print(f"Agent Trace: {'开启' if settings.trace_enabled else '关闭'} ({settings.trace_dir})")

+ 42 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/backend/app/exceptions.py

@@ -0,0 +1,42 @@
+"""统一业务异常:抛出后由全局 handler 转成固定 JSON。"""
+
+from __future__ import annotations
+
+
+class AppError(Exception):
+    """可预期异常基类。"""
+
+    def __init__(
+        self,
+        message: str,
+        *,
+        code: str = "APP_ERROR",
+        status_code: int = 400,
+    ) -> None:
+        self.message = message
+        self.code = code
+        self.status_code = status_code
+        super().__init__(message)
+
+
+class BadRequestError(AppError):
+    def __init__(self, message: str = "请求参数错误") -> None:
+        super().__init__(message, code="BAD_REQUEST", status_code=400)
+
+
+class NotFoundError(AppError):
+    def __init__(self, message: str = "资源不存在") -> None:
+        super().__init__(message, code="NOT_FOUND", status_code=404)
+
+
+class ExternalServiceError(AppError):
+    def __init__(self, message: str = "外部服务调用失败", *, status_code: int = 502) -> None:
+        super().__init__(message, code="EXTERNAL_SERVICE", status_code=status_code)
+
+
+class MovieServiceError(ExternalServiceError):
+    """TMDB / MovieService 相关错误。"""
+
+    def __init__(self, message: str, *, status_code: int = 502) -> None:
+        super().__init__(message, status_code=status_code)
+        self.code = "TMDB_ERROR"

+ 1 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/backend/app/models/__init__.py

@@ -0,0 +1 @@
+from .schemas import *  # noqa: F401,F403

+ 161 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/backend/app/models/schemas.py

@@ -0,0 +1,161 @@
+"""Pydantic 契约(D4)— 前后端对齐的请求/响应模型。"""
+
+from typing import List, Literal, Optional
+
+from pydantic import BaseModel, Field
+
+
+# ============ 枚举字面量(与前端表单对齐) ============
+
+Mood = Literal["放松", "欢乐", "虐心", "烧脑", "紧张刺激", "温馨"]
+PartyType = Literal["独自", "情侣", "家庭", "朋友"]
+RegionPreference = Literal["华语", "好莱坞", "日韩", "欧洲", "不限"]
+YearPreference = Literal["不限", "近5年", "近10年", "经典"]
+
+
+# ============ 请求模型 ============
+
+
+class RecommendRequest(BaseModel):
+    """观影偏好 / 智能推荐请求(F1)"""
+
+    mood: Mood = Field(..., description="当前心情")
+    party_type: PartyType = Field(..., description="观影人群")
+    genres: List[str] = Field(default_factory=list, description="偏好类型标签")
+    max_runtime_minutes: Optional[int] = Field(
+        default=None,
+        description="最大时长(分钟);null=不限",
+        examples=[120],
+    )
+    region_preference: RegionPreference = Field(default="不限", description="地区偏好")
+    year_preference: YearPreference = Field(default="不限", description="年代偏好")
+    exclude_titles: List[str] = Field(default_factory=list, description="已看过片名")
+    spoilers_ok: bool = Field(default=False, description="是否允许剧透")
+    free_text: str = Field(default="", description="额外自由文本要求")
+    exclude_ids: List[int] = Field(
+        default_factory=list,
+        description="换一批时排除的 TMDB 电影 id",
+    )
+    taste_profile: Optional["TasteProfile"] = Field(
+        default=None,
+        description="若传入则跳过画像 Agent(换一批复用)",
+    )
+
+    model_config = {
+        "json_schema_extra": {
+            "example": {
+                "mood": "放松",
+                "party_type": "独自",
+                "genres": ["剧情", "喜剧"],
+                "max_runtime_minutes": 120,
+                "region_preference": "不限",
+                "year_preference": "近10年",
+                "exclude_titles": [],
+                "spoilers_ok": False,
+                "free_text": "不要太沉重",
+                "exclude_ids": [],
+            }
+        }
+    }
+
+
+# ============ 领域子模型 ============
+
+
+class TasteProfile(BaseModel):
+    """画像 Agent 结构化输出(内部契约,后续 Agent 使用)"""
+
+    summary: str = Field(default="", description="口味摘要")
+    genre_hints: List[str] = Field(default_factory=list, description="类型倾向")
+    language_hints: List[str] = Field(default_factory=list, description="语言/地区倾向")
+    avoid: List[str] = Field(default_factory=list, description="禁忌/规避项")
+    discover_notes: str = Field(default="", description="discover 友好检索条件说明")
+
+
+class CandidateMovie(BaseModel):
+    """检索 Agent / MovieService 候选片"""
+
+    id: int = Field(..., description="TMDB movie id")
+    title: str
+    year: Optional[int] = None
+    genres: List[str] = Field(default_factory=list)
+    runtime: Optional[int] = Field(default=None, description="片长(分钟)")
+    rating: Optional[float] = None
+    poster_url: Optional[str] = None
+    overview: Optional[str] = None
+
+
+class MovieDetail(CandidateMovie):
+    """电影详情(TMDB /movie/{id} + credits)"""
+
+    tagline: Optional[str] = None
+    original_title: Optional[str] = None
+    vote_count: Optional[int] = None
+    original_language: Optional[str] = None
+    countries: List[str] = Field(default_factory=list)
+    directors: List[str] = Field(default_factory=list)
+    cast: List[str] = Field(default_factory=list)
+    tmdb_url: Optional[str] = None
+
+
+class MovieCard(BaseModel):
+    """推荐结果卡片(F2 / F3)"""
+
+    id: int = Field(..., description="TMDB movie id")
+    title: str
+    year: Optional[int] = None
+    genres: List[str] = Field(default_factory=list)
+    runtime: Optional[int] = None
+    rating: Optional[float] = None
+    poster_url: Optional[str] = None
+    why: str = Field(default="", description="推荐理由")
+    vibe_tags: List[str] = Field(default_factory=list)
+    caution: Optional[str] = Field(default=None, description="适看提示")
+    overview_safe: str = Field(default="", description="安全简介(遵守 spoilers_ok)")
+
+
+class RecommendResult(BaseModel):
+    """推荐结果主体"""
+
+    playlist_name: str = ""
+    profile_summary: str = ""
+    movies: List[MovieCard] = Field(default_factory=list)
+    is_fallback: bool = Field(default=False, description="是否为降级结果(D5)")
+    taste_profile: Optional[TasteProfile] = Field(
+        default=None,
+        description="本次使用的画像;换一批时可原样回传以跳过画像 Agent",
+    )
+
+# ============ 响应包装 ============
+
+
+class RecommendResponse(BaseModel):
+    success: bool
+    message: str = ""
+    data: Optional[RecommendResult] = None
+
+
+class MovieListResponse(BaseModel):
+    """确定性搜片列表响应(search / discover)"""
+
+    success: bool
+    message: str = ""
+    data: List[CandidateMovie] = Field(default_factory=list)
+
+
+class MovieDetailResponse(BaseModel):
+    """电影详情响应"""
+
+    success: bool
+    message: str = ""
+    data: Optional[MovieDetail] = None
+
+
+class ErrorResponse(BaseModel):
+    success: bool = False
+    message: str
+    error_code: Optional[str] = None
+
+
+RecommendRequest.model_rebuild()
+RecommendResult.model_rebuild()

+ 1 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/backend/app/services/__init__.py

@@ -0,0 +1 @@
+"""服务层:确定性 TMDB 封装,供 REST 与后续 Agent Tool 共用。"""

+ 27 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/backend/app/services/llm_service.py

@@ -0,0 +1,27 @@
+"""LLM 进程内单例(多智能体共享同一模型客户端,避免重复初始化)。"""
+
+from hello_agents import HelloAgentsLLM
+
+from ..utils.logger import get_logger
+
+logger = get_logger("app.llm")
+
+_llm_instance: HelloAgentsLLM | None = None
+
+
+def get_llm() -> HelloAgentsLLM:
+    """HelloAgentsLLM 自动读取 LLM_API_KEY / LLM_BASE_URL / LLM_MODEL_ID。"""
+    global _llm_instance
+    if _llm_instance is None:
+        _llm_instance = HelloAgentsLLM()
+        logger.info(
+            "LLM 初始化: provider=%s model=%s",
+            getattr(_llm_instance, "provider", "?"),
+            getattr(_llm_instance, "model", "?"),
+        )
+    return _llm_instance
+
+
+def reset_llm() -> None:
+    global _llm_instance
+    _llm_instance = None

+ 454 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/backend/app/services/movie_service.py

@@ -0,0 +1,454 @@
+"""MovieService — TMDB HTTP 封装(D1 确定性通道)。
+
+供 /api/movies/* 与 Agent Tool 复用;密钥仅来自环境变量。
+"""
+
+from __future__ import annotations
+
+from typing import Any, Dict, List, Optional
+
+import httpx
+
+from ..config import Settings, get_settings
+from ..exceptions import MovieServiceError
+from ..models.schemas import CandidateMovie, MovieDetail
+from ..utils.logger import get_logger
+
+logger = get_logger("app.movie_service")
+
+TMDB_API_BASE = "https://api.themoviedb.org/3"
+
+# TMDB with_original_language 只认 ISO 639-1;画像常误传「好莱坞」「英语」
+_VALID_TMDB_LANGS = frozenset(
+    {"zh", "en", "ja", "ko", "fr", "de", "es", "it", "hi", "th", "pt", "ru"}
+)
+_LANG_ALIASES = {
+    "华语": "zh",
+    "中文": "zh",
+    "汉语": "zh",
+    "普通话": "zh",
+    "国语": "zh",
+    "好莱坞": "en",
+    "英语": "en",
+    "英文": "en",
+    "english": "en",
+    "美片": "en",
+    "日韩": "ja",
+    "日语": "ja",
+    "日本": "ja",
+    "japanese": "ja",
+    "韩语": "ko",
+    "韩文": "ko",
+    "韩国": "ko",
+    "korean": "ko",
+    "法语": "fr",
+    "德语": "de",
+    "西语": "es",
+    "西班牙语": "es",
+}
+
+
+def normalize_tmdb_language(raw: Optional[str]) -> Optional[str]:
+    """把地区/中文名归一成 TMDB 语言码;无法识别则返回 None。"""
+    if raw is None:
+        return None
+    text = str(raw).strip()
+    if not text:
+        return None
+    lower = text.lower()
+    if lower in _VALID_TMDB_LANGS:
+        return lower
+    mapped = _LANG_ALIASES.get(text) or _LANG_ALIASES.get(lower)
+    if mapped in _VALID_TMDB_LANGS:
+        return mapped
+    # 容错:en-US / zh-CN
+    if "-" in lower or "_" in lower:
+        primary = lower.replace("_", "-").split("-", 1)[0]
+        if primary in _VALID_TMDB_LANGS:
+            return primary
+    return None
+
+
+class MovieService:
+    """TMDB 电影查询服务:search / discover / 类型名 id 映射。"""
+
+    def __init__(self, settings: Optional[Settings] = None) -> None:
+        """初始化配置、类型缓存占位,以及复用的 httpx 客户端。"""
+        self.settings = settings or get_settings()
+        self._genre_id_to_name: Optional[Dict[int, str]] = None
+        self._genre_name_to_id: Optional[Dict[str, int]] = None
+        self._client = httpx.Client(timeout=30.0)
+
+    def close(self) -> None:
+        """关闭底层 HTTP 客户端(进程退出或测试 teardown 时调用)。"""
+        self._client.close()
+
+    def _auth_headers_and_params(self) -> tuple[dict, dict]:
+        """组装鉴权:优先 Bearer Access Token,否则 query 带 api_key。"""
+        token, api_key = self.settings.resolve_tmdb_credentials()
+        if not token and not api_key:
+            raise MovieServiceError(
+                "TMDB 未配置:请在 .env 设置 TMDB_ACCESS_TOKEN 或 TMDB_API_KEY",
+                status_code=503,
+            )
+
+        headers: dict = {"Accept": "application/json"}
+        params: dict = {
+            "language": self.settings.tmdb_language,
+            "include_adult": str(self.settings.tmdb_include_adult).lower(),
+        }
+        if token:
+            headers["Authorization"] = f"Bearer {token}"
+        else:
+            params["api_key"] = api_key  # type: ignore[assignment]
+        return headers, params
+
+    def _get(self, path: str, extra_params: Optional[Dict[str, Any]] = None) -> dict:
+        """对 TMDB 发 GET,统一处理超时、网络错误与非 2xx / 非 JSON。"""
+        headers, params = self._auth_headers_and_params()
+        if extra_params:
+            for k, v in extra_params.items():
+                if v is not None and v != "":
+                    params[k] = v
+
+        url = f"{TMDB_API_BASE}{path}"
+        try:
+            resp = self._client.get(url, headers=headers, params=params)
+        except httpx.TimeoutException as e:
+            raise MovieServiceError(f"TMDB 请求超时: {e}") from e
+        except httpx.HTTPError as e:
+            raise MovieServiceError(f"TMDB 网络错误: {e}") from e
+
+        if resp.status_code == 401:
+            raise MovieServiceError(
+                "TMDB 鉴权失败:请检查 Access Token / API Key",
+                status_code=401,
+            )
+        if resp.status_code == 404:
+            raise MovieServiceError("影片不存在或已下架", status_code=404)
+        if resp.status_code >= 400:
+            raise MovieServiceError(
+                f"TMDB 请求失败: HTTP {resp.status_code}, {resp.text[:200]}"
+            )
+
+        try:
+            return resp.json()
+        except ValueError as e:
+            raise MovieServiceError("TMDB 返回非 JSON") from e
+
+    def _poster_url(self, poster_path: Optional[str]) -> Optional[str]:
+        """把 TMDB 相对 poster_path 拼成可访问的完整图片 URL。"""
+        if not poster_path:
+            return None
+        base = self.settings.tmdb_image_base_url.rstrip("/")
+        return f"{base}{poster_path}"
+
+    def _parse_year(self, release_date: Optional[str]) -> Optional[int]:
+        """从 release_date(YYYY-MM-DD)解析上映年份。"""
+        if not release_date or len(release_date) < 4:
+            return None
+        try:
+            return int(release_date[:4])
+        except ValueError:
+            return None
+
+    def ensure_genres(self) -> None:
+        """拉取并缓存类型 id ↔ 中文名称(仅首次请求时打 TMDB)。"""
+        if self._genre_id_to_name is not None:
+            return
+
+        data = self._get("/genre/movie/list")
+        id_to_name: Dict[int, str] = {}
+        name_to_id: Dict[str, int] = {}
+        for g in data.get("genres") or []:
+            gid = g.get("id")
+            name = (g.get("name") or "").strip()
+            if gid is None or not name:
+                continue
+            id_to_name[int(gid)] = name
+            name_to_id[name] = int(gid)
+            name_to_id[name.lower()] = int(gid)
+
+        self._genre_id_to_name = id_to_name
+        self._genre_name_to_id = name_to_id
+
+    def resolve_genre_ids(self, genres: Optional[str]) -> Optional[str]:
+        """将 '剧情,喜剧' 或 '18,35' 转为 TMDB with_genres 所需的 id 串。"""
+        if not genres or not genres.strip():
+            return None
+
+        self.ensure_genres()
+        assert self._genre_name_to_id is not None
+
+        ids: List[str] = []
+        for part in genres.split(","):
+            raw = part.strip()
+            if not raw:
+                continue
+            if raw.isdigit():
+                ids.append(raw)
+                continue
+            gid = self._genre_name_to_id.get(raw) or self._genre_name_to_id.get(raw.lower())
+            if gid is not None:
+                ids.append(str(gid))
+        return ",".join(ids) if ids else None
+
+    def _map_result(self, item: dict) -> CandidateMovie:
+        """把 TMDB 单条原始结果映射为内部 CandidateMovie。"""
+        self.ensure_genres()
+        assert self._genre_id_to_name is not None
+
+        genre_names: List[str] = []
+        for gid in item.get("genre_ids") or []:
+            name = self._genre_id_to_name.get(int(gid))
+            if name:
+                genre_names.append(name)
+
+        title = item.get("title") or item.get("original_title") or ""
+        return CandidateMovie(
+            id=int(item["id"]),
+            title=title,
+            year=self._parse_year(item.get("release_date")),
+            genres=genre_names,
+            runtime=None,  # 列表接口通常无片长,需 detail 才有
+            rating=item.get("vote_average"),
+            poster_url=self._poster_url(item.get("poster_path")),
+            overview=item.get("overview") or "",
+        )
+
+    def _map_detail(self, item: dict) -> MovieDetail:
+        """把 TMDB /movie/{id}(可含 credits)映射为 MovieDetail。"""
+        genre_names: List[str] = []
+        for g in item.get("genres") or []:
+            name = (g.get("name") or "").strip()
+            if name:
+                genre_names.append(name)
+        if not genre_names and item.get("genre_ids"):
+            self.ensure_genres()
+            assert self._genre_id_to_name is not None
+            for gid in item["genre_ids"]:
+                name = self._genre_id_to_name.get(int(gid))
+                if name:
+                    genre_names.append(name)
+
+        title = item.get("title") or item.get("original_title") or ""
+        runtime = item.get("runtime")
+        if runtime is not None:
+            try:
+                runtime = int(runtime)
+                if runtime <= 0:
+                    runtime = None
+            except (TypeError, ValueError):
+                runtime = None
+
+        countries: List[str] = []
+        for c in item.get("production_countries") or []:
+            name = (c.get("name") or "").strip()
+            if name:
+                countries.append(name)
+
+        directors: List[str] = []
+        cast_names: List[str] = []
+        credits = item.get("credits") or {}
+        for person in credits.get("crew") or []:
+            if person.get("job") == "Director":
+                name = (person.get("name") or "").strip()
+                if name and name not in directors:
+                    directors.append(name)
+        for person in (credits.get("cast") or [])[:8]:
+            name = (person.get("name") or "").strip()
+            if name:
+                cast_names.append(name)
+
+        movie_id = int(item["id"])
+        original_title = (item.get("original_title") or "").strip() or None
+        if original_title and original_title == title:
+            original_title = None
+
+        vote_count = item.get("vote_count")
+        try:
+            vote_count = int(vote_count) if vote_count is not None else None
+        except (TypeError, ValueError):
+            vote_count = None
+
+        return MovieDetail(
+            id=movie_id,
+            title=title,
+            year=self._parse_year(item.get("release_date")),
+            genres=genre_names,
+            runtime=runtime,
+            rating=item.get("vote_average"),
+            poster_url=self._poster_url(item.get("poster_path")),
+            overview=item.get("overview") or "",
+            tagline=(item.get("tagline") or "").strip() or None,
+            original_title=original_title,
+            vote_count=vote_count,
+            original_language=(item.get("original_language") or "").strip() or None,
+            countries=countries,
+            directors=directors,
+            cast=cast_names,
+            tmdb_url=f"https://www.themoviedb.org/movie/{movie_id}",
+        )
+
+    def get_detail(self, movie_id: int) -> MovieDetail:
+        """按 id 取电影详情(含 credits:导演 / 主演)。"""
+        if movie_id <= 0:
+            raise MovieServiceError("movie_id 必须为正整数", status_code=400)
+
+        data = self._get(
+            f"/movie/{movie_id}",
+            {"append_to_response": "credits"},
+        )
+        return self._map_detail(data)
+
+    def search(
+        self,
+        q: str,
+        year: Optional[int] = None,
+        page: int = 1,
+    ) -> List[CandidateMovie]:
+        """按关键词搜索电影(TMDB GET /search/movie)。"""
+        query = (q or "").strip()
+        if not query:
+            raise MovieServiceError("搜索关键词 q 不能为空", status_code=400)
+
+        data = self._get(
+            "/search/movie",
+            {
+                "query": query,
+                "year": year,
+                "page": page,
+            },
+        )
+        return [self._map_result(item) for item in data.get("results") or []]
+
+    def discover(
+        self,
+        with_genres: Optional[str] = None,
+        year: Optional[int] = None,
+        year_gte: Optional[int] = None,
+        year_lte: Optional[int] = None,
+        max_runtime: Optional[int] = None,
+        with_original_language: Optional[str] = None,
+        sort_by: str = "popularity.desc",
+        page: int = 1,
+    ) -> List[CandidateMovie]:
+        """按条件发现电影(TMDB GET /discover/movie)。"""
+        genre_ids = self.resolve_genre_ids(with_genres)
+        lang = normalize_tmdb_language(with_original_language)
+
+        params: Dict[str, Any] = {
+            "sort_by": sort_by or "popularity.desc",
+            "page": page,
+            "with_genres": genre_ids,
+            "with_original_language": lang,
+        }
+        if year is not None:
+            params["primary_release_year"] = year
+        if year_gte is not None:
+            params["primary_release_date.gte"] = f"{year_gte}-01-01"
+        if year_lte is not None:
+            params["primary_release_date.lte"] = f"{year_lte}-12-31"
+        if max_runtime is not None:
+            params["with_runtime.lte"] = max_runtime
+
+        if with_original_language and not lang:
+            logger.warning(
+                "discover 丢弃非法 language=%r",
+                with_original_language,
+            )
+        logger.info(
+            "TMDB discover params genres=%r lang=%r year=%s gte=%s lte=%s runtime_lte=%s sort=%s",
+            genre_ids,
+            lang,
+            year,
+            year_gte,
+            year_lte,
+            max_runtime,
+            sort_by or "popularity.desc",
+        )
+
+        data = self._get("/discover/movie", params)
+        return [self._map_result(item) for item in data.get("results") or []]
+
+    def discover_with_relax(
+        self,
+        with_genres: Optional[str] = None,
+        year: Optional[int] = None,
+        year_gte: Optional[int] = None,
+        year_lte: Optional[int] = None,
+        max_runtime: Optional[int] = None,
+        with_original_language: Optional[str] = None,
+        sort_by: str = "popularity.desc",
+        page: int = 1,
+        exclude_ids: Optional[List[int]] = None,
+    ) -> List[CandidateMovie]:
+        """discover;若空结果则逐步放宽:去语言 → 去片长 → 去年代 → 仅类型。"""
+        exclude = set(exclude_ids or [])
+
+        attempts: List[Dict[str, Any]] = [
+            {
+                "with_genres": with_genres,
+                "year": year,
+                "year_gte": year_gte,
+                "year_lte": year_lte,
+                "max_runtime": max_runtime,
+                "with_original_language": with_original_language,
+            },
+            {
+                "with_genres": with_genres,
+                "year": year,
+                "year_gte": year_gte,
+                "year_lte": year_lte,
+                "max_runtime": max_runtime,
+                "with_original_language": None,
+            },
+            {
+                "with_genres": with_genres,
+                "year": year,
+                "year_gte": year_gte,
+                "year_lte": year_lte,
+                "max_runtime": None,
+                "with_original_language": None,
+            },
+            {
+                "with_genres": with_genres,
+                "year": None,
+                "year_gte": None,
+                "year_lte": None,
+                "max_runtime": None,
+                "with_original_language": None,
+            },
+        ]
+
+        seen_keys: set = set()
+        for i, kwargs in enumerate(attempts):
+            key = tuple(sorted((k, repr(v)) for k, v in kwargs.items()))
+            if key in seen_keys:
+                continue
+            seen_keys.add(key)
+            movies = self.discover(sort_by=sort_by, page=page, **kwargs)
+            kept = [m for m in movies if m.id not in exclude]
+            if kept:
+                if i > 0:
+                    logger.warning(
+                        "discover 空结果已放宽(step=%d) -> %d 部 params=%s",
+                        i,
+                        len(kept),
+                        kwargs,
+                    )
+                return kept
+            logger.warning("discover 无结果 step=%d params=%s", i, kwargs)
+
+        return []
+
+
+_movie_service: Optional[MovieService] = None
+
+
+def get_movie_service() -> MovieService:
+    """获取进程内 MovieService 单例(REST 与 Agent Tool 共用)。"""
+    global _movie_service
+    if _movie_service is None:
+        _movie_service = MovieService()
+    return _movie_service

+ 0 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/backend/app/tools/__init__.py


+ 164 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/backend/app/tools/movie_tool.py

@@ -0,0 +1,164 @@
+"""TMDB MovieTool — 可展开为 movies_discover / movies_search,供检索 Agent 调用。
+
+内部复用 MovieService,与 /api/movies/* 同一数据源(双通道同源)。
+"""
+
+from __future__ import annotations
+
+import json
+from typing import Any, Dict, List, Optional
+
+from hello_agents.tools import Tool, ToolParameter, ToolResponse, tool_action
+from hello_agents.tools.errors import ToolErrorCode
+
+from ..services.movie_service import MovieServiceError, get_movie_service, normalize_tmdb_language
+from ..utils.logger import get_logger
+
+logger = get_logger("app.movie_tool")
+
+
+def _movies_to_payload(movies: list) -> Dict[str, Any]:
+    items = [m.model_dump() for m in movies]
+    text = json.dumps({"count": len(items), "movies": items}, ensure_ascii=False)
+    return {"text": text, "data": {"movies": items, "count": len(items)}}
+
+
+class MovieTool(Tool):
+    """可展开电影工具:注册后变成 movies_discover / movies_search 两个子工具。"""
+
+    def __init__(self) -> None:
+        super().__init__(
+            name="movies",
+            description="TMDB 电影检索:discover 条件发现、search 文本搜索",
+            expandable=True,  # True → Agent.add_tool 时自动展开子工具
+        )
+        self._service = get_movie_service()  # 与 /api/movies 共用同一服务
+        # 编排器可按次 run 设置上限,防止 LLM 一轮内并行狂打 discover
+        self._discover_calls = 0
+        self._discover_call_limit: Optional[int] = None
+
+    def begin_search_run(self, discover_limit: int = 1) -> None:
+        """检索阶段开始:重置计数并设置 movies_discover 调用上限。"""
+        self._discover_calls = 0
+        self._discover_call_limit = discover_limit
+
+    def end_search_run(self) -> None:
+        """检索阶段结束:取消调用上限。"""
+        self._discover_call_limit = None
+        self._discover_calls = 0
+
+    @tool_action("movies_discover", "按类型/年份/时长/语言等条件发现电影")
+    def discover(
+        self,
+        with_genres: str = "",
+        year: int = 0,
+        year_gte: int = 0,
+        year_lte: int = 0,
+        max_runtime: int = 0,
+        with_original_language: str = "",
+        sort_by: str = "popularity.desc",
+        page: int = 1,
+    ) -> ToolResponse:
+        """条件发现电影(主路径)。
+
+        Args:
+            with_genres: 类型名或 id,逗号分隔,如 剧情,科幻
+            year: 精确上映年,0 表示不限
+            year_gte: 上映年起,0 表示不限
+            year_lte: 上映年止,0 表示不限
+            max_runtime: 最大片长分钟,0 表示不限
+            with_original_language: 原始语言代码,如 zh/en/ja/ko
+            sort_by: 排序,默认 popularity.desc
+            page: 页码
+        """
+        if self._discover_call_limit is not None:
+            self._discover_calls += 1
+            if self._discover_calls > self._discover_call_limit:
+                logger.warning(
+                    "movies_discover 已达上限 %d,拒绝第 %d 次调用",
+                    self._discover_call_limit,
+                    self._discover_calls,
+                )
+                return ToolResponse.error(
+                    code=ToolErrorCode.INTERNAL_ERROR,
+                    message=(
+                        f"movies_discover 本轮最多调用 {self._discover_call_limit} 次;"
+                        "请基于已有工具结果直接输出含 movies 的 JSON,"
+                        "并保留工具返回的 poster_url 等字段。"
+                    ),
+                )
+        try:
+            raw_lang = with_original_language or None
+            lang = normalize_tmdb_language(raw_lang)
+            if raw_lang and not lang:
+                logger.warning(
+                    "movies_discover 丢弃非法 language=%r,将按无语言过滤查询",
+                    raw_lang,
+                )
+
+            logger.info(
+                "movies_discover 请求 genres=%r lang=%r(raw=%r) year=%s gte=%s lte=%s "
+                "runtime_lte=%s sort=%s page=%s",
+                with_genres or None,
+                lang,
+                raw_lang,
+                year or None,
+                year_gte or None,
+                year_lte or None,
+                max_runtime or None,
+                sort_by or "popularity.desc",
+                page or 1,
+            )
+
+            # 空结果时自动放宽条件,避免 Agent 一次非法/过严参数直接失败
+            movies = self._service.discover_with_relax(
+                with_genres=with_genres or None,
+                year=year or None,
+                year_gte=year_gte or None,
+                year_lte=year_lte or None,
+                max_runtime=max_runtime or None,
+                with_original_language=raw_lang,
+                sort_by=sort_by or "popularity.desc",
+                page=page or 1,
+            )
+            payload = _movies_to_payload(movies)
+            logger.info("movies_discover -> %d", payload["data"]["count"])
+            return ToolResponse.success(text=payload["text"], data=payload["data"])
+        except MovieServiceError as e:
+            return ToolResponse.error(code=ToolErrorCode.INTERNAL_ERROR, message=str(e))
+
+    @tool_action("movies_search", "按关键词搜索电影")
+    def search(self, q: str, year: int = 0, page: int = 1) -> ToolResponse:
+        """文本搜索电影(已看解析 / 兜底)。
+
+        Args:
+            q: 搜索关键词
+            year: 上映年,0 表示不限
+            page: 页码
+        """
+        try:
+            movies = self._service.search(q=q, year=year or None, page=page or 1)
+            payload = _movies_to_payload(movies)
+            logger.info("movies_search q=%r -> %d", q, payload["data"]["count"])
+            return ToolResponse.success(text=payload["text"], data=payload["data"])
+        except MovieServiceError as e:
+            return ToolResponse.error(code=ToolErrorCode.INTERNAL_ERROR, message=str(e))
+
+    def run(self, parameters: Dict[str, Any]) -> ToolResponse:
+        return ToolResponse.error(
+            code=ToolErrorCode.NOT_IMPLEMENTED,
+            message="请使用子工具 movies_discover 或 movies_search",
+        )
+
+    def get_parameters(self) -> List[ToolParameter]:
+        return []
+
+
+_movie_tool: Optional[MovieTool] = None
+
+
+def get_movie_tool() -> MovieTool:
+    global _movie_tool
+    if _movie_tool is None:
+        _movie_tool = MovieTool()
+    return _movie_tool

+ 0 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/backend/app/utils/__init__.py


+ 33 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/backend/app/utils/logger.py

@@ -0,0 +1,33 @@
+"""全局日志:统一格式与级别,供各模块 get_logger 使用。"""
+
+from __future__ import annotations
+
+import logging
+import sys
+
+from ..config import get_settings
+
+_configured = False
+
+
+def setup_logging() -> None:
+    """按 Settings.log_level 初始化根日志(幂等)。"""
+    global _configured
+    if _configured:
+        return
+
+    level = getattr(logging, get_settings().log_level.upper(), logging.INFO)
+    logging.basicConfig(
+        level=level,
+        format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
+        datefmt="%Y-%m-%d %H:%M:%S",
+        stream=sys.stdout,
+        force=True,
+    )
+    _configured = True
+
+
+def get_logger(name: str = "app") -> logging.Logger:
+    if not _configured:
+        setup_logging()
+    return logging.getLogger(name)

+ 32 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/backend/pyproject.toml

@@ -0,0 +1,32 @@
+[project]
+name = "backend"
+version = "0.1.0"
+description = "Add your description here"
+readme = "README.md"
+requires-python = ">=3.10"
+dependencies = [
+    "fastapi>=0.141.1",
+    "hello-agents>=1.0.0",
+    "httpx>=0.28.1",
+    "pydantic>=2.13.4",
+    "pydantic-settings>=2.14.2",
+    "python-dotenv>=1.2.2",
+    "uvicorn[standard]>=0.52.0",
+]
+
+[[tool.uv.index]]
+url = "https://pypi.tuna.tsinghua.edu.cn/simple"
+default = true
+
+[dependency-groups]
+dev = [
+    "pytest>=9.1.1",
+]
+
+[tool.pytest.ini_options]
+markers = [
+    "integration: 需真实 LLM/TMDB 的端到端测试(较慢)",
+]
+filterwarnings = [
+    "ignore::DeprecationWarning",
+]

+ 15 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/backend/run.py

@@ -0,0 +1,15 @@
+"""启动脚本: python run.py"""
+
+import uvicorn
+
+from app.config import get_settings
+
+if __name__ == "__main__":
+    settings = get_settings()
+    uvicorn.run(
+        "app.api.main:app",
+        host=settings.host,
+        port=settings.port,
+        reload=True,
+        log_level=settings.log_level.lower(),
+    )

+ 0 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/backend/tests/__init__.py


+ 24 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/frontend/.gitignore

@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?

+ 8 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/frontend/.oxlintrc.json

@@ -0,0 +1,8 @@
+{
+  "$schema": "./node_modules/oxlint/configuration_schema.json",
+  "plugins": ["react", "typescript", "oxc"],
+  "rules": {
+    "react/rules-of-hooks": "error",
+    "react/only-export-components": ["warn", { "allowConstantExport": true }]
+  }
+}

+ 23 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/frontend/index.html

@@ -0,0 +1,23 @@
+<!doctype html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="UTF-8" />
+    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <meta
+      name="description"
+      content="映前 — 关灯之前,按心情从片库定下今晚要看的电影。"
+    />
+    <link rel="preconnect" href="https://fonts.googleapis.com" />
+    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
+    <link
+      href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=Noto+Serif+SC:wght@500;600;700&display=swap"
+      rel="stylesheet"
+    />
+    <title>映前</title>
+  </head>
+  <body>
+    <div id="root"></div>
+    <script type="module" src="/src/main.tsx"></script>
+  </body>
+</html>

+ 1423 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/frontend/package-lock.json

@@ -0,0 +1,1423 @@
+{
+  "name": "yingqian-frontend",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "yingqian-frontend",
+      "version": "0.1.0",
+      "dependencies": {
+        "react": "^19.2.8",
+        "react-dom": "^19.2.8",
+        "react-router-dom": "^7.18.2"
+      },
+      "devDependencies": {
+        "@types/node": "^24.13.3",
+        "@types/react": "^19.2.17",
+        "@types/react-dom": "^19.2.3",
+        "@vitejs/plugin-react": "^6.0.4",
+        "oxlint": "^1.75.0",
+        "typescript": "~6.0.2",
+        "vite": "^8.2.0"
+      }
+    },
+    "node_modules/@emnapi/core": {
+      "version": "2.0.0-alpha.3",
+      "resolved": "https://registry.npmmirror.com/@emnapi/core/-/core-2.0.0-alpha.3.tgz",
+      "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "@emnapi/wasi-threads": "2.0.1",
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@emnapi/runtime": {
+      "version": "2.0.0-alpha.3",
+      "resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz",
+      "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@emnapi/wasi-threads": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz",
+      "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@napi-rs/wasm-runtime": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz",
+      "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "@tybys/wasm-util": "^0.10.3"
+      },
+      "engines": {
+        "node": "^20.19.0 || ^22.13.0 || >=23.5.0"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      },
+      "peerDependencies": {
+        "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3",
+        "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3"
+      }
+    },
+    "node_modules/@oxc-project/types": {
+      "version": "0.142.0",
+      "resolved": "https://registry.npmmirror.com/@oxc-project/types/-/types-0.142.0.tgz",
+      "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/Boshen"
+      }
+    },
+    "node_modules/@oxlint/binding-android-arm-eabi": {
+      "version": "1.76.0",
+      "resolved": "https://registry.npmmirror.com/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.76.0.tgz",
+      "integrity": "sha512-ZHIE5Zt9AsPDcY4nOlofXt0YfneEeo+QrKMPcPzLf2Z6Q8VtV2W73d7SFJ920WUwyik783u/doKCs3KXdwG+7w==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@oxlint/binding-android-arm64": {
+      "version": "1.76.0",
+      "resolved": "https://registry.npmmirror.com/@oxlint/binding-android-arm64/-/binding-android-arm64-1.76.0.tgz",
+      "integrity": "sha512-shm/ngQilHK6bs+ElJWa4oHfNj5vL1Gl/iVEJldTQjpr0/67oSgr0KUpbmcnLig5Fo0v/l6j2567A7TOL89ONA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@oxlint/binding-darwin-arm64": {
+      "version": "1.76.0",
+      "resolved": "https://registry.npmmirror.com/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.76.0.tgz",
+      "integrity": "sha512-rvJmrAPKSQ9aWJ6wIS6CK2tJjwzfW0ApQH9qokq6sfDvmHwoyIHxHFMq7z7i7GiV6fdE6s8qvBqWKPTu8RmT6Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@oxlint/binding-darwin-x64": {
+      "version": "1.76.0",
+      "resolved": "https://registry.npmmirror.com/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.76.0.tgz",
+      "integrity": "sha512-U/zYdb7VYKGY6pA9Vd2rYl9O/HlCylcOlb5PGPvVLtg+oLGsk6H3XGKEMHKyqD3nmmtmlmwb/8SwU2vfSAtvMw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@oxlint/binding-freebsd-x64": {
+      "version": "1.76.0",
+      "resolved": "https://registry.npmmirror.com/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.76.0.tgz",
+      "integrity": "sha512-WvKG9CAriuo0XNiFzpXjDngUZcRGFNpaK2kLyMUsnJlShxkT96u+BpJQ3KqdQwGOrvI14L6V8bAwXwAYNNY6Jg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@oxlint/binding-linux-arm-gnueabihf": {
+      "version": "1.76.0",
+      "resolved": "https://registry.npmmirror.com/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.76.0.tgz",
+      "integrity": "sha512-qJ5+RH99TqFRq3UCDxkW0zJJu9c+OAHFY72vGlxZLEpuO+MpKo3POgqb8sYipL9KYm8XY6ofb0HsOuvY6hQNqQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@oxlint/binding-linux-arm-musleabihf": {
+      "version": "1.76.0",
+      "resolved": "https://registry.npmmirror.com/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.76.0.tgz",
+      "integrity": "sha512-PvPCVptkgVARsucgIqFQQcSmJ6xc6GtnVB5bRBekRahTc9eObMtjHfMjy5M+C2tHt5UCMttWM9RuSk/H9NqYeg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@oxlint/binding-linux-arm64-gnu": {
+      "version": "1.76.0",
+      "resolved": "https://registry.npmmirror.com/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.76.0.tgz",
+      "integrity": "sha512-3KeFDx8Bu4HPAXbuHZOr/oHvN+QT+JQhMw/NYPz7Z071xLSsG27Jfh9PIQVEY7hk1I+jr43ExqRIeJ6VKk2yLw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@oxlint/binding-linux-arm64-musl": {
+      "version": "1.76.0",
+      "resolved": "https://registry.npmmirror.com/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.76.0.tgz",
+      "integrity": "sha512-oPFkkKTgl0K/EIg9fQ8oA3IGcI05/Mq1en04iFa41mmNPT+6KEiByVazTOZZJiHMBBrbsns1YJ2e1Scqwzesjw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@oxlint/binding-linux-ppc64-gnu": {
+      "version": "1.76.0",
+      "resolved": "https://registry.npmmirror.com/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.76.0.tgz",
+      "integrity": "sha512-gN7yZ0eqflA5Fhf1wvHxGUltIV3FsvmB1zhNMDEK9vSHhc7E6qg9CuPeBgPZab66Tjzq6w6kHAtNEvnTHf4cyw==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@oxlint/binding-linux-riscv64-gnu": {
+      "version": "1.76.0",
+      "resolved": "https://registry.npmmirror.com/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.76.0.tgz",
+      "integrity": "sha512-S/HqMbn22mQrjtErUxEoS/a55u8kIeXvreIxiJu5G7Le3UecEd6SQZxrDIpuhtgaFnsY/nVra3ytP+pRljDilA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@oxlint/binding-linux-riscv64-musl": {
+      "version": "1.76.0",
+      "resolved": "https://registry.npmmirror.com/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.76.0.tgz",
+      "integrity": "sha512-ZIga3097VJZolGZk6SrIAUokIGfRkxRlhiHDUznZptGBfwrhD7pNfD1rzEzsCwvk/1DX0A1bLz+liuNh5QKIVQ==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@oxlint/binding-linux-s390x-gnu": {
+      "version": "1.76.0",
+      "resolved": "https://registry.npmmirror.com/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.76.0.tgz",
+      "integrity": "sha512-ZGiiA7pFzMJSyMWYZTVlPgbTsx+Vl8ihLGMIujPwaslUF7kIPPWAbVmAlTc+9lWDV+DCiB8Ikixu+lSHeOIIWQ==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@oxlint/binding-linux-x64-gnu": {
+      "version": "1.76.0",
+      "resolved": "https://registry.npmmirror.com/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.76.0.tgz",
+      "integrity": "sha512-JLiy5WuvEBFTT6ErIFV35SLzi0R7Iri6MKU6dZbTxfIx8pndbbPs3Mj780nMipBFcPkti+okAPOJ9POKkHFEgg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@oxlint/binding-linux-x64-musl": {
+      "version": "1.76.0",
+      "resolved": "https://registry.npmmirror.com/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.76.0.tgz",
+      "integrity": "sha512-z7lgKQtbo/I1NIe8G5NHLesxJDv0tRSUWTpXKb9Pm3E9nKFKfO4IOSDtFroKgXtOYb0jQbcdH+0wzTyMXVes+A==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@oxlint/binding-openharmony-arm64": {
+      "version": "1.76.0",
+      "resolved": "https://registry.npmmirror.com/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.76.0.tgz",
+      "integrity": "sha512-JOjKymIpb9QcYfEhZsN6h4V9Ivd474W38cNIBRv6bg2TbIvogbMTH0Mg6YWW9TiRDqfcX+/Hyfsbo5vcSE5guQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@oxlint/binding-win32-arm64-msvc": {
+      "version": "1.76.0",
+      "resolved": "https://registry.npmmirror.com/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.76.0.tgz",
+      "integrity": "sha512-pqDWZiwcmByWUEm1NFUBNiT6aentCcaoMWJv0HbXEmuYermJ4sg8ppVrshubYP2MZ6SHccJJcpr6x469PuDFIw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@oxlint/binding-win32-ia32-msvc": {
+      "version": "1.76.0",
+      "resolved": "https://registry.npmmirror.com/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.76.0.tgz",
+      "integrity": "sha512-Ba0O659kgMv6pwO3z9PdO+K3aMxQRaw9HnG+e6AtOfgwcKFvYilciQYBoUBmxfQvOCKZe1SwjMkuB542NkuDMQ==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@oxlint/binding-win32-x64-msvc": {
+      "version": "1.76.0",
+      "resolved": "https://registry.npmmirror.com/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.76.0.tgz",
+      "integrity": "sha512-5qcirPHO8nKfkoowEVWtpAoVTcYDy6g0UT0NGic450Qv8J2NrOqg4uQ8QppRP4MDTC7Xx47lbZnmadTH03CGGA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-android-arm64": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmmirror.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz",
+      "integrity": "sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-darwin-arm64": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmmirror.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.1.tgz",
+      "integrity": "sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-darwin-x64": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmmirror.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.1.tgz",
+      "integrity": "sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-freebsd-x64": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmmirror.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.1.tgz",
+      "integrity": "sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.1.tgz",
+      "integrity": "sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-arm64-gnu": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.1.tgz",
+      "integrity": "sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-arm64-musl": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.1.tgz",
+      "integrity": "sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.1.tgz",
+      "integrity": "sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-s390x-gnu": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.1.tgz",
+      "integrity": "sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-x64-gnu": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.1.tgz",
+      "integrity": "sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-x64-musl": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.1.tgz",
+      "integrity": "sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-openharmony-arm64": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmmirror.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.1.tgz",
+      "integrity": "sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-wasm32-wasi": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmmirror.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.2.1.tgz",
+      "integrity": "sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "@emnapi/core": "2.0.0-alpha.3",
+        "@emnapi/runtime": "2.0.0-alpha.3",
+        "@napi-rs/wasm-runtime": "^1.2.0"
+      },
+      "engines": {
+        "node": "^20.19.0 || ^22.13.0 || >=23.5.0"
+      }
+    },
+    "node_modules/@rolldown/binding-win32-arm64-msvc": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.1.tgz",
+      "integrity": "sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-win32-x64-msvc": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.1.tgz",
+      "integrity": "sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/pluginutils": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+      "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@tybys/wasm-util": {
+      "version": "0.10.3",
+      "resolved": "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
+      "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@types/node": {
+      "version": "24.13.3",
+      "resolved": "https://registry.npmmirror.com/@types/node/-/node-24.13.3.tgz",
+      "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "undici-types": "~7.18.0"
+      }
+    },
+    "node_modules/@types/react": {
+      "version": "19.2.18",
+      "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.18.tgz",
+      "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "csstype": "^3.2.2"
+      }
+    },
+    "node_modules/@types/react-dom": {
+      "version": "19.2.4",
+      "resolved": "https://registry.npmmirror.com/@types/react-dom/-/react-dom-19.2.4.tgz",
+      "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==",
+      "dev": true,
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/react": "^19.2.0"
+      }
+    },
+    "node_modules/@vitejs/plugin-react": {
+      "version": "6.0.5",
+      "resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz",
+      "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@rolldown/pluginutils": "^1.0.1"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "peerDependencies": {
+        "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
+        "babel-plugin-react-compiler": "^1.0.0",
+        "vite": "^8.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@rolldown/plugin-babel": {
+          "optional": true
+        },
+        "babel-plugin-react-compiler": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/cookie": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmmirror.com/cookie/-/cookie-1.1.1.tgz",
+      "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/csstype": {
+      "version": "3.2.3",
+      "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
+      "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/detect-libc": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
+      "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/fdir": {
+      "version": "6.5.0",
+      "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz",
+      "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "peerDependencies": {
+        "picomatch": "^3 || ^4"
+      },
+      "peerDependenciesMeta": {
+        "picomatch": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/lightningcss": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.33.0.tgz",
+      "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
+      "dev": true,
+      "license": "MPL-2.0",
+      "dependencies": {
+        "detect-libc": "^2.0.3"
+      },
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      },
+      "optionalDependencies": {
+        "lightningcss-android-arm64": "1.33.0",
+        "lightningcss-darwin-arm64": "1.33.0",
+        "lightningcss-darwin-x64": "1.33.0",
+        "lightningcss-freebsd-x64": "1.33.0",
+        "lightningcss-linux-arm-gnueabihf": "1.33.0",
+        "lightningcss-linux-arm64-gnu": "1.33.0",
+        "lightningcss-linux-arm64-musl": "1.33.0",
+        "lightningcss-linux-x64-gnu": "1.33.0",
+        "lightningcss-linux-x64-musl": "1.33.0",
+        "lightningcss-win32-arm64-msvc": "1.33.0",
+        "lightningcss-win32-x64-msvc": "1.33.0"
+      }
+    },
+    "node_modules/lightningcss-android-arm64": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+      "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-darwin-arm64": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+      "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-darwin-x64": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+      "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-freebsd-x64": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+      "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm-gnueabihf": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+      "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm64-gnu": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+      "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm64-musl": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+      "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-x64-gnu": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+      "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-x64-musl": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+      "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-win32-arm64-msvc": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+      "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-win32-x64-msvc": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+      "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.16",
+      "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.16.tgz",
+      "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/oxlint": {
+      "version": "1.76.0",
+      "resolved": "https://registry.npmmirror.com/oxlint/-/oxlint-1.76.0.tgz",
+      "integrity": "sha512-6QoFioEU4fNdiUx/2Eo6TRd6NG7H7njnRCz8rhB66cZmMHDTqcm1Rjvl8Wry+ZTQMBAmyb4Mlf62Mk5X+eHSOw==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "oxlint": "bin/oxlint"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/Boshen"
+      },
+      "optionalDependencies": {
+        "@oxlint/binding-android-arm-eabi": "1.76.0",
+        "@oxlint/binding-android-arm64": "1.76.0",
+        "@oxlint/binding-darwin-arm64": "1.76.0",
+        "@oxlint/binding-darwin-x64": "1.76.0",
+        "@oxlint/binding-freebsd-x64": "1.76.0",
+        "@oxlint/binding-linux-arm-gnueabihf": "1.76.0",
+        "@oxlint/binding-linux-arm-musleabihf": "1.76.0",
+        "@oxlint/binding-linux-arm64-gnu": "1.76.0",
+        "@oxlint/binding-linux-arm64-musl": "1.76.0",
+        "@oxlint/binding-linux-ppc64-gnu": "1.76.0",
+        "@oxlint/binding-linux-riscv64-gnu": "1.76.0",
+        "@oxlint/binding-linux-riscv64-musl": "1.76.0",
+        "@oxlint/binding-linux-s390x-gnu": "1.76.0",
+        "@oxlint/binding-linux-x64-gnu": "1.76.0",
+        "@oxlint/binding-linux-x64-musl": "1.76.0",
+        "@oxlint/binding-openharmony-arm64": "1.76.0",
+        "@oxlint/binding-win32-arm64-msvc": "1.76.0",
+        "@oxlint/binding-win32-ia32-msvc": "1.76.0",
+        "@oxlint/binding-win32-x64-msvc": "1.76.0"
+      },
+      "peerDependencies": {
+        "oxlint-tsgolint": ">=7.0.2001",
+        "vite-plus": "*"
+      },
+      "peerDependenciesMeta": {
+        "oxlint-tsgolint": {
+          "optional": true
+        },
+        "vite-plus": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/picomatch": {
+      "version": "4.0.5",
+      "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.5.tgz",
+      "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/postcss": {
+      "version": "8.5.25",
+      "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.25.tgz",
+      "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "nanoid": "^3.3.16",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.1"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/react": {
+      "version": "19.2.8",
+      "resolved": "https://registry.npmmirror.com/react/-/react-19.2.8.tgz",
+      "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/react-dom": {
+      "version": "19.2.8",
+      "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.8.tgz",
+      "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
+      "license": "MIT",
+      "dependencies": {
+        "scheduler": "^0.27.0"
+      },
+      "peerDependencies": {
+        "react": "^19.2.8"
+      }
+    },
+    "node_modules/react-router": {
+      "version": "7.18.2",
+      "resolved": "https://registry.npmmirror.com/react-router/-/react-router-7.18.2.tgz",
+      "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==",
+      "license": "MIT",
+      "dependencies": {
+        "cookie": "^1.0.1",
+        "set-cookie-parser": "^2.6.0"
+      },
+      "engines": {
+        "node": ">=20.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=18",
+        "react-dom": ">=18"
+      },
+      "peerDependenciesMeta": {
+        "react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/react-router-dom": {
+      "version": "7.18.2",
+      "resolved": "https://registry.npmmirror.com/react-router-dom/-/react-router-dom-7.18.2.tgz",
+      "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==",
+      "license": "MIT",
+      "dependencies": {
+        "react-router": "7.18.2"
+      },
+      "engines": {
+        "node": ">=20.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=18",
+        "react-dom": ">=18"
+      }
+    },
+    "node_modules/rolldown": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmmirror.com/rolldown/-/rolldown-1.2.1.tgz",
+      "integrity": "sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@oxc-project/types": "=0.142.0",
+        "@rolldown/pluginutils": "^1.0.0"
+      },
+      "bin": {
+        "rolldown": "bin/cli.mjs"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "optionalDependencies": {
+        "@rolldown/binding-android-arm64": "1.2.1",
+        "@rolldown/binding-darwin-arm64": "1.2.1",
+        "@rolldown/binding-darwin-x64": "1.2.1",
+        "@rolldown/binding-freebsd-x64": "1.2.1",
+        "@rolldown/binding-linux-arm-gnueabihf": "1.2.1",
+        "@rolldown/binding-linux-arm64-gnu": "1.2.1",
+        "@rolldown/binding-linux-arm64-musl": "1.2.1",
+        "@rolldown/binding-linux-ppc64-gnu": "1.2.1",
+        "@rolldown/binding-linux-s390x-gnu": "1.2.1",
+        "@rolldown/binding-linux-x64-gnu": "1.2.1",
+        "@rolldown/binding-linux-x64-musl": "1.2.1",
+        "@rolldown/binding-openharmony-arm64": "1.2.1",
+        "@rolldown/binding-wasm32-wasi": "1.2.1",
+        "@rolldown/binding-win32-arm64-msvc": "1.2.1",
+        "@rolldown/binding-win32-x64-msvc": "1.2.1"
+      }
+    },
+    "node_modules/scheduler": {
+      "version": "0.27.0",
+      "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz",
+      "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+      "license": "MIT"
+    },
+    "node_modules/set-cookie-parser": {
+      "version": "2.7.2",
+      "resolved": "https://registry.npmmirror.com/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
+      "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
+      "license": "MIT"
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
+      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/tinyglobby": {
+      "version": "0.2.17",
+      "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz",
+      "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "fdir": "^6.5.0",
+        "picomatch": "^4.0.4"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/SuperchupuDev"
+      }
+    },
+    "node_modules/tslib": {
+      "version": "2.8.1",
+      "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
+      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+      "dev": true,
+      "license": "0BSD",
+      "optional": true
+    },
+    "node_modules/typescript": {
+      "version": "6.0.3",
+      "resolved": "https://registry.npmmirror.com/typescript/-/typescript-6.0.3.tgz",
+      "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/undici-types": {
+      "version": "7.18.2",
+      "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.18.2.tgz",
+      "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/vite": {
+      "version": "8.2.0",
+      "resolved": "https://registry.npmmirror.com/vite/-/vite-8.2.0.tgz",
+      "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "lightningcss": "^1.33.0",
+        "picomatch": "^4.0.5",
+        "postcss": "^8.5.23",
+        "rolldown": "~1.2.0",
+        "tinyglobby": "^0.2.17"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      },
+      "peerDependencies": {
+        "@types/node": "^20.19.0 || >=22.12.0",
+        "@vitejs/devtools": "^0.4.0",
+        "esbuild": "^0.27.0 || ^0.28.0",
+        "jiti": ">=1.21.0",
+        "less": "^4.0.0",
+        "sass": "^1.70.0",
+        "sass-embedded": "^1.70.0",
+        "stylus": ">=0.54.8",
+        "sugarss": "^5.0.0",
+        "terser": "^5.16.0",
+        "tsx": "^4.8.1",
+        "yaml": "^2.4.2"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        },
+        "@vitejs/devtools": {
+          "optional": true
+        },
+        "esbuild": {
+          "optional": true
+        },
+        "jiti": {
+          "optional": true
+        },
+        "less": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "sass-embedded": {
+          "optional": true
+        },
+        "stylus": {
+          "optional": true
+        },
+        "sugarss": {
+          "optional": true
+        },
+        "terser": {
+          "optional": true
+        },
+        "tsx": {
+          "optional": true
+        },
+        "yaml": {
+          "optional": true
+        }
+      }
+    }
+  }
+}

+ 26 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/frontend/package.json

@@ -0,0 +1,26 @@
+{
+  "name": "yingqian-frontend",
+  "private": true,
+  "version": "0.1.0",
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "tsc -b && vite build",
+    "lint": "oxlint",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "react": "^19.2.8",
+    "react-dom": "^19.2.8",
+    "react-router-dom": "^7.18.2"
+  },
+  "devDependencies": {
+    "@types/node": "^24.13.3",
+    "@types/react": "^19.2.17",
+    "@types/react-dom": "^19.2.3",
+    "@vitejs/plugin-react": "^6.0.4",
+    "oxlint": "^1.75.0",
+    "typescript": "~6.0.2",
+    "vite": "^8.2.0"
+  }
+}

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/frontend/public/favicon.svg


+ 24 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/frontend/public/icons.svg

@@ -0,0 +1,24 @@
+<svg xmlns="http://www.w3.org/2000/svg">
+  <symbol id="bluesky-icon" viewBox="0 0 16 17">
+    <g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
+    <defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
+  </symbol>
+  <symbol id="discord-icon" viewBox="0 0 20 19">
+    <path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
+  </symbol>
+  <symbol id="documentation-icon" viewBox="0 0 21 20">
+    <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
+    <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
+    <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
+  </symbol>
+  <symbol id="github-icon" viewBox="0 0 19 19">
+    <path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
+  </symbol>
+  <symbol id="social-icon" viewBox="0 0 20 20">
+    <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
+    <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
+  </symbol>
+  <symbol id="x-icon" viewBox="0 0 19 19">
+    <path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
+  </symbol>
+</svg>

+ 17 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/frontend/src/App.tsx

@@ -0,0 +1,17 @@
+import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
+import { BrowsePage } from './pages/BrowsePage'
+import { HomePage } from './pages/HomePage'
+import { ResultPage } from './pages/ResultPage'
+
+export default function App() {
+  return (
+    <BrowserRouter>
+      <Routes>
+        <Route path="/" element={<HomePage />} />
+        <Route path="/browse" element={<BrowsePage />} />
+        <Route path="/result" element={<ResultPage />} />
+        <Route path="*" element={<Navigate to="/" replace />} />
+      </Routes>
+    </BrowserRouter>
+  )
+}

+ 68 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/frontend/src/api/client.ts

@@ -0,0 +1,68 @@
+const DEFAULT_TIMEOUT_MS = 120_000
+
+export class ApiError extends Error {
+  status: number
+
+  constructor(message: string, status = 0) {
+    super(message)
+    this.name = 'ApiError'
+    this.status = status
+  }
+}
+
+export async function apiFetch<T>(
+  path: string,
+  init: RequestInit = {},
+  timeoutMs = DEFAULT_TIMEOUT_MS,
+): Promise<T> {
+  const controller = new AbortController()
+  const timer = window.setTimeout(() => controller.abort(), timeoutMs)
+
+  try {
+    const res = await fetch(path, {
+      ...init,
+      signal: controller.signal,
+      headers: {
+        Accept: 'application/json',
+        ...(init.body ? { 'Content-Type': 'application/json' } : {}),
+        ...init.headers,
+      },
+    })
+
+    let payload: unknown = null
+    const text = await res.text()
+    if (text) {
+      try {
+        payload = JSON.parse(text) as unknown
+      } catch {
+        payload = { message: text }
+      }
+    }
+
+    if (!res.ok) {
+      const detail =
+        payload &&
+        typeof payload === 'object' &&
+        'detail' in payload &&
+        typeof (payload as { detail: unknown }).detail === 'string'
+          ? (payload as { detail: string }).detail
+          : payload &&
+              typeof payload === 'object' &&
+              'message' in payload &&
+              typeof (payload as { message: unknown }).message === 'string'
+            ? (payload as { message: string }).message
+            : `请求失败(${res.status})`
+      throw new ApiError(detail, res.status)
+    }
+
+    return payload as T
+  } catch (err) {
+    if (err instanceof ApiError) throw err
+    if (err instanceof DOMException && err.name === 'AbortError') {
+      throw new ApiError('请求超时,请稍后重试或检查后端服务', 408)
+    }
+    throw new ApiError(err instanceof Error ? err.message : '网络异常', 0)
+  } finally {
+    window.clearTimeout(timer)
+  }
+}

+ 36 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/frontend/src/api/movies.ts

@@ -0,0 +1,36 @@
+import type {
+  MovieDetailResponse,
+  MovieListResponse,
+} from '../types'
+import { apiFetch } from './client'
+
+export function searchMovies(q: string, year?: number): Promise<MovieListResponse> {
+  const params = new URLSearchParams({ q })
+  if (year != null) params.set('year', String(year))
+  return apiFetch<MovieListResponse>(`/api/movies/search?${params}`, {}, 30_000)
+}
+
+export interface DiscoverParams {
+  with_genres?: string
+  year?: number
+  year_gte?: number
+  year_lte?: number
+  max_runtime?: number
+  with_original_language?: string
+  sort_by?: string
+  page?: number
+}
+
+export function discoverMovies(params: DiscoverParams = {}): Promise<MovieListResponse> {
+  const qs = new URLSearchParams()
+  for (const [k, v] of Object.entries(params)) {
+    if (v == null || v === '') continue
+    qs.set(k, String(v))
+  }
+  const suffix = qs.toString() ? `?${qs}` : ''
+  return apiFetch<MovieListResponse>(`/api/movies/discover${suffix}`, {}, 30_000)
+}
+
+export function getMovieDetail(id: number): Promise<MovieDetailResponse> {
+  return apiFetch<MovieDetailResponse>(`/api/movies/${id}`, {}, 30_000)
+}

+ 9 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/frontend/src/api/recommend.ts

@@ -0,0 +1,9 @@
+import type { RecommendRequest, RecommendResponse } from '../types'
+import { apiFetch } from './client'
+
+export function postRecommend(body: RecommendRequest): Promise<RecommendResponse> {
+  return apiFetch<RecommendResponse>('/api/recommend', {
+    method: 'POST',
+    body: JSON.stringify(body),
+  })
+}

BIN
Co-creation-projects/aatanxiao12-beep-YingQian/frontend/src/assets/hero.png


Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/frontend/src/assets/react.svg


Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/frontend/src/assets/vite.svg


+ 5 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/frontend/src/brand.ts

@@ -0,0 +1,5 @@
+/** 产品品牌(展示用) */
+export const BRAND_NAME = '映前'
+export const BRAND_TAGLINE = '关灯之前,先把今晚的片定下来。'
+/** 技术标识 / storage 前缀(非 UI) */
+export const BRAND_SLUG = 'yingqian'

+ 145 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/frontend/src/components/CatalogDetail.tsx

@@ -0,0 +1,145 @@
+import { useEffect, useState } from 'react'
+import { Link, useNavigate } from 'react-router-dom'
+import { getMovieDetail } from '../api/movies'
+import type { CandidateMovie, MovieDetail } from '../types'
+import { formatRating, formatRuntime } from '../lib/format'
+import { isSeen, toggleSeen, type SeenEntry } from '../lib/seen'
+import { DetailFacts } from './DetailFacts'
+
+interface CatalogDetailProps {
+  movie: CandidateMovie
+  onClose: () => void
+  onSeenChange: (list: SeenEntry[]) => void
+}
+
+export function CatalogDetail({ movie, onClose, onSeenChange }: CatalogDetailProps) {
+  const navigate = useNavigate()
+  const [detail, setDetail] = useState<MovieDetail | CandidateMovie>(movie)
+  const [loading, setLoading] = useState(true)
+  const [error, setError] = useState<string | null>(null)
+  const [seen, setSeen] = useState(() => isSeen(movie.id))
+
+  useEffect(() => {
+    let cancelled = false
+    setDetail(movie)
+    setSeen(isSeen(movie.id))
+    setLoading(true)
+    setError(null)
+
+    void getMovieDetail(movie.id)
+      .then((res) => {
+        if (cancelled || !res.data) return
+        setDetail(res.data)
+      })
+      .catch((err: unknown) => {
+        if (cancelled) return
+        setError(err instanceof Error ? err.message : '详情加载失败')
+      })
+      .finally(() => {
+        if (!cancelled) setLoading(false)
+      })
+
+    return () => {
+      cancelled = true
+    }
+  }, [movie])
+
+  function handleToggleSeen() {
+    const next = toggleSeen({
+      id: detail.id,
+      title: detail.title,
+      year: detail.year,
+    })
+    setSeen(next.some((e) => e.id === detail.id))
+    onSeenChange(next)
+  }
+
+  function handleRecommendWithGenre() {
+    const genre = detail.genres[0]
+    navigate('/', {
+      state: genre
+        ? { prefillGenres: [genre], hint: `已带入类型「${genre}」,可继续调整偏好` }
+        : { hint: '请手动选择类型后再生成片单' },
+    })
+  }
+
+  const rating = formatRating(detail.rating)
+  const runtime = formatRuntime(detail.runtime)
+  const rich = 'directors' in detail ? (detail as MovieDetail) : null
+
+  return (
+    <aside className="catalog-detail" aria-label="影片详情">
+      <div className="catalog-detail__toolbar">
+        <button type="button" className="btn btn--ghost btn--compact" onClick={onClose}>
+          关闭
+        </button>
+      </div>
+
+      <div className="catalog-detail__layout">
+        <div className="catalog-detail__poster">
+          {detail.poster_url ? (
+            <img src={detail.poster_url} alt="" />
+          ) : (
+            <div className="catalog-tile__fallback">{detail.title.slice(0, 1)}</div>
+          )}
+        </div>
+
+        <div className="catalog-detail__body">
+          <h2>{detail.title}</h2>
+          <p className="catalog-detail__meta">
+            {[detail.year, rating && `评分 ${rating}`, runtime]
+              .filter(Boolean)
+              .join(' · ')}
+          </p>
+          {detail.genres.length > 0 && (
+            <ul className="catalog-detail__genres">
+              {detail.genres.map((g) => (
+                <li key={g}>{g}</li>
+              ))}
+            </ul>
+          )}
+
+          {loading && <p className="muted">正在拉取完整信息…</p>}
+          {error && <p className="error-text">{error}</p>}
+          {!loading && rich?.tagline && (
+            <p className="detail-tagline">{rich.tagline}</p>
+          )}
+          {!loading && detail.overview && (
+            <p className="catalog-detail__overview">{detail.overview}</p>
+          )}
+          {!loading && !detail.overview && !error && (
+            <p className="muted">暂无简介</p>
+          )}
+          {!loading && rich && <DetailFacts detail={rich} />}
+          {!loading && rich?.tmdb_url && (
+            <p className="detail-link">
+              <a href={rich.tmdb_url} target="_blank" rel="noreferrer">
+                在 TMDB 查看
+              </a>
+            </p>
+          )}
+
+          <div className="catalog-detail__actions">
+            <button
+              type="button"
+              className={seen ? 'btn btn--primary btn--compact' : 'btn btn--ghost btn--compact'}
+              onClick={handleToggleSeen}
+            >
+              {seen ? '已标记已看' : '标记已看'}
+            </button>
+            <button
+              type="button"
+              className="btn btn--ghost btn--compact"
+              onClick={handleRecommendWithGenre}
+            >
+              用此类型去推荐
+            </button>
+            <Link to="/" className="btn btn--ghost btn--compact">
+              返回智能推荐
+            </Link>
+          </div>
+        </div>
+      </div>
+    </aside>
+  )
+}

+ 49 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/frontend/src/components/CatalogTile.tsx

@@ -0,0 +1,49 @@
+import type { CandidateMovie } from '../types'
+import { formatRating } from '../lib/format'
+
+interface CatalogTileProps {
+  movie: CandidateMovie
+  index: number
+  selected?: boolean
+  seen?: boolean
+  onSelect: (movie: CandidateMovie) => void
+}
+
+export function CatalogTile({
+  movie,
+  index,
+  selected,
+  seen,
+  onSelect,
+}: CatalogTileProps) {
+  const rating = formatRating(movie.rating)
+
+  return (
+    <button
+      type="button"
+      className={
+        selected
+          ? 'catalog-tile is-selected'
+          : 'catalog-tile'
+      }
+      style={{ animationDelay: `${Math.min(index, 12) * 40}ms` }}
+      onClick={() => onSelect(movie)}
+    >
+      <div className="catalog-tile__poster">
+        {movie.poster_url ? (
+          <img src={movie.poster_url} alt="" loading="lazy" />
+        ) : (
+          <div className="catalog-tile__fallback">{movie.title.slice(0, 1)}</div>
+        )}
+        {seen && <span className="catalog-tile__badge">已看</span>}
+      </div>
+      <div className="catalog-tile__meta">
+        <span className="catalog-tile__title">{movie.title}</span>
+        <span className="catalog-tile__sub">
+          {movie.year ?? '—'}
+          {rating ? ` · ${rating}` : ''}
+        </span>
+      </div>
+    </button>
+  )
+}

+ 57 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/frontend/src/components/DetailFacts.tsx

@@ -0,0 +1,57 @@
+import type { MovieDetail } from '../types'
+
+const LANG_LABEL: Record<string, string> = {
+  zh: '汉语',
+  en: '英语',
+  ja: '日语',
+  ko: '韩语',
+  fr: '法语',
+  de: '德语',
+  es: '西班牙语',
+  it: '意大利语',
+  hi: '印地语',
+  th: '泰语',
+}
+
+export function languageLabel(code: string | null | undefined): string | null {
+  if (!code) return null
+  return LANG_LABEL[code] ?? code
+}
+
+/** 详情页信息块:导演 / 主演 / 国家等 */
+export function DetailFacts({ detail }: { detail: MovieDetail }) {
+  const lang = languageLabel(detail.original_language)
+  const rows: { label: string; value: string }[] = []
+
+  if (detail.directors.length) {
+    rows.push({ label: '导演', value: detail.directors.join('、') })
+  }
+  if (detail.cast.length) {
+    rows.push({ label: '主演', value: detail.cast.join('、') })
+  }
+  if (detail.countries.length) {
+    rows.push({ label: '国家/地区', value: detail.countries.join('、') })
+  }
+  if (lang) {
+    rows.push({ label: '语言', value: lang })
+  }
+  if (detail.original_title) {
+    rows.push({ label: '原名', value: detail.original_title })
+  }
+  if (detail.vote_count != null && detail.vote_count > 0) {
+    rows.push({ label: '评分人数', value: String(detail.vote_count) })
+  }
+
+  if (rows.length === 0) return null
+
+  return (
+    <dl className="detail-facts">
+      {rows.map((row) => (
+        <div key={row.label} className="detail-facts__row">
+          <dt>{row.label}</dt>
+          <dd>{row.value}</dd>
+        </div>
+      ))}
+    </dl>
+  )
+}

+ 16 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/frontend/src/components/FallbackAlert.tsx

@@ -0,0 +1,16 @@
+interface FallbackAlertProps {
+  message: string
+  isFallback: boolean
+}
+
+export function FallbackAlert({ message, isFallback }: FallbackAlertProps) {
+  const show = isFallback || message.includes('降级')
+  if (!show) return null
+
+  return (
+    <div className="fallback-alert" role="alert">
+      <strong>降级提示</strong>
+      <p>{message || '本次结果为降级推荐,仅供参考。'}</p>
+    </div>
+  )
+}

+ 116 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/frontend/src/components/MovieCard.tsx

@@ -0,0 +1,116 @@
+import { useState } from 'react'
+import type { MovieCard as MovieCardType, MovieDetail } from '../types'
+import { getMovieDetail } from '../api/movies'
+import { DetailFacts } from './DetailFacts'
+import { formatRating, formatRuntime } from '../lib/format'
+
+interface MovieCardProps {
+  movie: MovieCardType
+  index?: number
+}
+
+export function MovieCardView({ movie, index = 0 }: MovieCardProps) {
+  const [runtime, setRuntime] = useState(movie.runtime)
+  const [overview, setOverview] = useState(movie.overview_safe)
+  const [detail, setDetail] = useState<MovieDetail | null>(null)
+  const [expanded, setExpanded] = useState(false)
+  const [loadingDetail, setLoadingDetail] = useState(false)
+  const [detailError, setDetailError] = useState<string | null>(null)
+
+  async function handleToggle() {
+    const next = !expanded
+    setExpanded(next)
+    if (!next || detail) return
+
+    setLoadingDetail(true)
+    setDetailError(null)
+    try {
+      const res = await getMovieDetail(movie.id)
+      if (res.data) {
+        setDetail(res.data)
+        if (res.data.runtime != null) setRuntime(res.data.runtime)
+        if (res.data.overview) setOverview(res.data.overview)
+      }
+    } catch (err) {
+      setDetailError(err instanceof Error ? err.message : '详情加载失败')
+    } finally {
+      setLoadingDetail(false)
+    }
+  }
+
+  const rating = formatRating(detail?.rating ?? movie.rating)
+  const runtimeLabel = formatRuntime(runtime)
+
+  return (
+    <article
+      className="movie-card"
+      style={{ animationDelay: `${Math.min(index, 8) * 60}ms` }}
+    >
+      <button
+        type="button"
+        className="movie-card__hit"
+        onClick={() => void handleToggle()}
+        aria-expanded={expanded}
+      >
+        <div className="movie-card__poster">
+          {movie.poster_url ? (
+            <img src={movie.poster_url} alt="" loading="lazy" />
+          ) : (
+            <div className="movie-card__poster-fallback">{movie.title.slice(0, 1)}</div>
+          )}
+        </div>
+        <div className="movie-card__body">
+          <header className="movie-card__header">
+            <h3>{movie.title}</h3>
+            <div className="movie-card__meta">
+              {movie.year != null && <span>{movie.year}</span>}
+              {rating && <span>评分 {rating}</span>}
+              {runtimeLabel && <span>{runtimeLabel}</span>}
+              {movie.genres.slice(0, 3).map((g) => (
+                <span key={g}>{g}</span>
+              ))}
+            </div>
+          </header>
+          {movie.why && <p className="movie-card__why">{movie.why}</p>}
+          {movie.vibe_tags.length > 0 && (
+            <ul className="movie-card__tags">
+              {movie.vibe_tags.map((tag) => (
+                <li key={tag}>{tag}</li>
+              ))}
+            </ul>
+          )}
+          {movie.caution && (
+            <p className="movie-card__caution">适看提示:{movie.caution}</p>
+          )}
+          <p className="movie-card__more muted">
+            {expanded ? '收起详情' : '展开详情'}
+          </p>
+        </div>
+      </button>
+
+      {expanded && (
+        <div className="movie-card__detail">
+          {loadingDetail && <p className="muted">正在加载详情…</p>}
+          {detailError && <p className="error-text">{detailError}</p>}
+          {!loadingDetail && detail?.tagline && (
+            <p className="detail-tagline">{detail.tagline}</p>
+          )}
+          {!loadingDetail && overview && (
+            <p className="detail-overview">{overview}</p>
+          )}
+          {!loadingDetail && detail && <DetailFacts detail={detail} />}
+          {!loadingDetail && !overview && !detailError && (
+            <p className="muted">暂无简介</p>
+          )}
+          {!loadingDetail && detail?.tmdb_url && (
+            <p className="detail-link">
+              <a href={detail.tmdb_url} target="_blank" rel="noreferrer">
+                在 TMDB 查看
+              </a>
+            </p>
+          )}
+        </div>
+      )}
+    </article>
+  )
+}

+ 283 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/frontend/src/components/PreferenceForm.tsx

@@ -0,0 +1,283 @@
+import { useEffect, useState, type FormEvent } from 'react'
+import type {
+  Mood,
+  PartyType,
+  RecommendRequest,
+  RegionPreference,
+  YearPreference,
+} from '../types'
+import {
+  DEMO_REQUEST,
+  GENRE_OPTIONS,
+  MOODS,
+  PARTY_TYPES,
+  REGIONS,
+  RUNTIME_OPTIONS,
+  YEARS,
+} from '../types'
+
+interface PreferenceFormProps {
+  onSubmit: (request: RecommendRequest) => void
+  disabled?: boolean
+  initialGenres?: string[]
+}
+
+const emptyForm = (): RecommendRequest => ({
+  mood: '放松',
+  party_type: '独自',
+  genres: [],
+  max_runtime_minutes: null,
+  region_preference: '不限',
+  year_preference: '不限',
+  exclude_titles: [],
+  spoilers_ok: false,
+  free_text: '',
+  exclude_ids: [],
+})
+
+export function PreferenceForm({
+  onSubmit,
+  disabled,
+  initialGenres,
+}: PreferenceFormProps) {
+  const [form, setForm] = useState<RecommendRequest>(() => ({
+    ...emptyForm(),
+    genres: initialGenres?.length ? [...initialGenres] : [],
+  }))
+  const [excludeText, setExcludeText] = useState('')
+  const [errors, setErrors] = useState<{ mood?: string; party_type?: string }>({})
+
+  useEffect(() => {
+    if (!initialGenres?.length) return
+    setForm((prev) => ({
+      ...prev,
+      genres: [...new Set([...prev.genres, ...initialGenres])],
+    }))
+  }, [initialGenres])
+
+  function fillDemo() {
+    setForm({ ...DEMO_REQUEST })
+    setExcludeText(DEMO_REQUEST.exclude_titles.join('、'))
+    setErrors({})
+  }
+
+  function toggleGenre(genre: string) {
+    setForm((prev) => {
+      const has = prev.genres.includes(genre)
+      return {
+        ...prev,
+        genres: has
+          ? prev.genres.filter((g) => g !== genre)
+          : [...prev.genres, genre],
+      }
+    })
+  }
+
+  function validate(): boolean {
+    const next: { mood?: string; party_type?: string } = {}
+    if (!form.mood) next.mood = '请选择心情'
+    if (!form.party_type) next.party_type = '请选择观影人群'
+    setErrors(next)
+    return Object.keys(next).length === 0
+  }
+
+  function handleSubmit(e: FormEvent) {
+    e.preventDefault()
+    if (!validate()) return
+
+    const exclude_titles = excludeText
+      .split(/[,,、\n]/)
+      .map((s) => s.trim())
+      .filter(Boolean)
+
+    onSubmit({
+      ...form,
+      exclude_titles,
+      exclude_ids: [],
+    })
+  }
+
+  return (
+    <form className="pref-form" onSubmit={handleSubmit} noValidate>
+      <div className="pref-form__toolbar">
+        <button
+          type="button"
+          className="btn btn--ghost btn--text"
+          onClick={fillDemo}
+          disabled={disabled}
+        >
+          填入示例
+        </button>
+      </div>
+
+      <fieldset className="field-block" disabled={disabled}>
+        <legend>
+          此刻心情 <span className="req">*</span>
+        </legend>
+        <div className="choice-row" role="radiogroup" aria-label="心情">
+          {MOODS.map((mood) => (
+            <label key={mood} className="choice">
+              <input
+                type="radio"
+                name="mood"
+                value={mood}
+                checked={form.mood === mood}
+                onChange={() =>
+                  setForm((p) => ({ ...p, mood: mood as Mood }))
+                }
+              />
+              <span>{mood}</span>
+            </label>
+          ))}
+        </div>
+        {errors.mood && <p className="field-error">{errors.mood}</p>}
+      </fieldset>
+
+      <fieldset className="field-block" disabled={disabled}>
+        <legend>
+          观影人群 <span className="req">*</span>
+        </legend>
+        <div className="choice-row" role="radiogroup" aria-label="观影人群">
+          {PARTY_TYPES.map((party) => (
+            <label key={party} className="choice">
+              <input
+                type="radio"
+                name="party_type"
+                value={party}
+                checked={form.party_type === party}
+                onChange={() =>
+                  setForm((p) => ({ ...p, party_type: party as PartyType }))
+                }
+              />
+              <span>{party}</span>
+            </label>
+          ))}
+        </div>
+        {errors.party_type && (
+          <p className="field-error">{errors.party_type}</p>
+        )}
+      </fieldset>
+
+      <fieldset className="field-block" disabled={disabled}>
+        <legend>偏好类型</legend>
+        <div className="choice-row choice-row--wrap">
+          {GENRE_OPTIONS.map((genre) => (
+            <label key={genre} className="choice choice--check">
+              <input
+                type="checkbox"
+                checked={form.genres.includes(genre)}
+                onChange={() => toggleGenre(genre)}
+              />
+              <span>{genre}</span>
+            </label>
+          ))}
+        </div>
+      </fieldset>
+
+      <div className="field-grid">
+        <label className="field">
+          <span>最长片长</span>
+          <select
+            value={form.max_runtime_minutes ?? ''}
+            onChange={(e) => {
+              const v = e.target.value
+              setForm((p) => ({
+                ...p,
+                max_runtime_minutes: v === '' ? null : Number(v),
+              }))
+            }}
+            disabled={disabled}
+          >
+            {RUNTIME_OPTIONS.map((opt) => (
+              <option key={String(opt.value)} value={opt.value ?? ''}>
+                {opt.label}
+              </option>
+            ))}
+          </select>
+        </label>
+
+        <label className="field">
+          <span>地区偏好</span>
+          <select
+            value={form.region_preference}
+            onChange={(e) =>
+              setForm((p) => ({
+                ...p,
+                region_preference: e.target.value as RegionPreference,
+              }))
+            }
+            disabled={disabled}
+          >
+            {REGIONS.map((r) => (
+              <option key={r} value={r}>
+                {r}
+              </option>
+            ))}
+          </select>
+        </label>
+
+        <label className="field">
+          <span>年代偏好</span>
+          <select
+            value={form.year_preference}
+            onChange={(e) =>
+              setForm((p) => ({
+                ...p,
+                year_preference: e.target.value as YearPreference,
+              }))
+            }
+            disabled={disabled}
+          >
+            {YEARS.map((y) => (
+              <option key={y} value={y}>
+                {y}
+              </option>
+            ))}
+          </select>
+        </label>
+      </div>
+
+      <label className="field">
+        <span>已看过(用顿号或逗号分隔)</span>
+        <input
+          type="text"
+          value={excludeText}
+          onChange={(e) => setExcludeText(e.target.value)}
+          placeholder="例如:盗梦空间、星际穿越"
+          disabled={disabled}
+        />
+      </label>
+
+      <label className="field">
+        <span>额外要求</span>
+        <textarea
+          rows={3}
+          value={form.free_text}
+          onChange={(e) =>
+            setForm((p) => ({ ...p, free_text: e.target.value }))
+          }
+          placeholder="例如:节奏慢一点,适合睡前"
+          disabled={disabled}
+        />
+      </label>
+
+      <label className="field field--inline">
+        <input
+          type="checkbox"
+          checked={form.spoilers_ok}
+          onChange={(e) =>
+            setForm((p) => ({ ...p, spoilers_ok: e.target.checked }))
+          }
+          disabled={disabled}
+        />
+        <span>允许简介含剧透</span>
+      </label>
+
+      <div className="pref-form__actions">
+        <button type="submit" className="btn btn--primary" disabled={disabled}>
+          {disabled ? '生成中…' : '生成片单'}
+        </button>
+      </div>
+    </form>
+  )
+}

+ 31 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/frontend/src/components/ProgressOverlay.tsx

@@ -0,0 +1,31 @@
+import type { ProgressStage } from '../types'
+import { PROGRESS_STAGES } from '../types'
+
+interface ProgressOverlayProps {
+  active: boolean
+  stageIndex: number
+}
+
+export function ProgressOverlay({ active, stageIndex }: ProgressOverlayProps) {
+  if (!active) return null
+
+  return (
+    <div className="progress-overlay" role="status" aria-live="polite">
+      <div className="progress-panel">
+        <p className="progress-kicker">正在生成片单</p>
+        <ol className="progress-stages">
+          {PROGRESS_STAGES.map((label: ProgressStage, i) => {
+            const state =
+              i < stageIndex ? 'done' : i === stageIndex ? 'current' : 'pending'
+            return (
+              <li key={label} className={`progress-stage progress-stage--${state}`}>
+                <span className="progress-dot" aria-hidden="true" />
+                <span>{label}</span>
+              </li>
+            )
+          })}
+        </ol>
+      </div>
+    </div>
+  )
+}

+ 17 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/frontend/src/components/SiteFooter.tsx

@@ -0,0 +1,17 @@
+import { BRAND_NAME, BRAND_TAGLINE } from '../brand'
+
+export function SiteFooter() {
+  return (
+    <footer className="site-footer">
+      <p className="site-footer__brand">{BRAND_NAME}</p>
+      <p className="site-footer__tagline">{BRAND_TAGLINE}</p>
+      <p>
+        使用{' '}
+        <a href="https://www.themoviedb.org/" target="_blank" rel="noreferrer">
+          TMDB
+        </a>{' '}
+        API,但并非 TMDB 认证或赞助的产品。影片数据来自 The Movie Database。
+      </p>
+    </footer>
+  )
+}

+ 41 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/frontend/src/components/SiteNav.tsx

@@ -0,0 +1,41 @@
+import { Link } from 'react-router-dom'
+import { BRAND_NAME } from '../brand'
+
+interface SiteNavProps {
+  active?: 'home' | 'browse' | 'result'
+  /** On the home hero, brand lives in the hero — keep nav quiet. */
+  tone?: 'default' | 'over-hero'
+}
+
+export function SiteNav({ active, tone = 'default' }: SiteNavProps) {
+  return (
+    <nav
+      className={
+        tone === 'over-hero' ? 'site-nav site-nav--over-hero' : 'site-nav'
+      }
+      aria-label="主导航"
+    >
+      {tone === 'over-hero' ? (
+        <span className="site-nav__spacer" aria-hidden="true" />
+      ) : (
+        <Link to="/" className="brand brand--link">
+          {BRAND_NAME}
+        </Link>
+      )}
+      <div className="site-nav__links">
+        <Link
+          to="/"
+          className={active === 'home' ? 'site-nav__link is-active' : 'site-nav__link'}
+        >
+          荐片
+        </Link>
+        <Link
+          to="/browse"
+          className={active === 'browse' ? 'site-nav__link is-active' : 'site-nav__link'}
+        >
+          片库
+        </Link>
+      </div>
+    </nav>
+  )
+}

+ 10 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/frontend/src/main.tsx

@@ -0,0 +1,10 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import './styles/global.css'
+import App from './App.tsx'
+
+createRoot(document.getElementById('root')!).render(
+  <StrictMode>
+    <App />
+  </StrictMode>,
+)

+ 281 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/frontend/src/pages/BrowsePage.tsx

@@ -0,0 +1,281 @@
+import { useCallback, useEffect, useState, type FormEvent } from 'react'
+import { discoverMovies, searchMovies } from '../api/movies'
+import { CatalogDetail } from '../components/CatalogDetail'
+import { CatalogTile } from '../components/CatalogTile'
+import { SiteFooter } from '../components/SiteFooter'
+import { SiteNav } from '../components/SiteNav'
+import { loadSeen, type SeenEntry } from '../lib/seen'
+import type { CandidateMovie } from '../types'
+import { GENRE_OPTIONS } from '../types'
+
+type Mode = 'search' | 'discover'
+
+const SORT_OPTIONS = [
+  { value: 'popularity.desc', label: '热度' },
+  { value: 'vote_average.desc', label: '评分' },
+  { value: 'primary_release_date.desc', label: '新片优先' },
+] as const
+
+const LANG_OPTIONS = [
+  { value: '', label: '语言不限' },
+  { value: 'zh', label: '华语' },
+  { value: 'en', label: '英语' },
+  { value: 'ja', label: '日语' },
+  { value: 'ko', label: '韩语' },
+] as const
+
+export function BrowsePage() {
+  const [mode, setMode] = useState<Mode>('search')
+  const [query, setQuery] = useState('')
+  const [year, setYear] = useState('')
+  const [genres, setGenres] = useState<string[]>([])
+  const [lang, setLang] = useState('')
+  const [sortBy, setSortBy] = useState<string>('popularity.desc')
+  const [yearGte, setYearGte] = useState('')
+  const [maxRuntime, setMaxRuntime] = useState('')
+
+  const [loading, setLoading] = useState(false)
+  const [error, setError] = useState<string | null>(null)
+  const [results, setResults] = useState<CandidateMovie[] | null>(null)
+  const [selected, setSelected] = useState<CandidateMovie | null>(null)
+  const [seenList, setSeenList] = useState<SeenEntry[]>(() => loadSeen())
+
+  const runDiscover = useCallback(async () => {
+    setLoading(true)
+    setError(null)
+    try {
+      const res = await discoverMovies({
+        with_genres: genres.length ? genres.join(',') : undefined,
+        year_gte: yearGte ? Number(yearGte) : undefined,
+        max_runtime: maxRuntime ? Number(maxRuntime) : undefined,
+        with_original_language: lang || undefined,
+        sort_by: sortBy,
+        page: 1,
+      })
+      setResults(res.data)
+      setSelected(null)
+    } catch (err) {
+      setResults(null)
+      setError(err instanceof Error ? err.message : '发现失败')
+    } finally {
+      setLoading(false)
+    }
+  }, [genres, yearGte, maxRuntime, lang, sortBy])
+
+  useEffect(() => {
+    if (mode !== 'discover') return
+    void runDiscover()
+  }, [mode, runDiscover])
+
+  async function onSearchSubmit(e: FormEvent) {
+    e.preventDefault()
+    const q = query.trim()
+    if (!q) return
+
+    setLoading(true)
+    setError(null)
+    try {
+      const res = await searchMovies(q, year ? Number(year) : undefined)
+      setResults(res.data)
+      setSelected(null)
+    } catch (err) {
+      setResults(null)
+      setError(err instanceof Error ? err.message : '搜索失败')
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  function toggleGenre(g: string) {
+    setGenres((prev) =>
+      prev.includes(g) ? prev.filter((x) => x !== g) : [...prev, g].slice(0, 3),
+    )
+  }
+
+  const seenIds = new Set(seenList.map((e) => e.id))
+
+  return (
+    <div className="page page--browse">
+      <SiteNav active="browse" />
+
+      <header className="browse-hero">
+        <p className="section-kicker">片库</p>
+        <h1 className="browse-title">找一部确认今晚</h1>
+        <p className="browse-lead">
+          搜片名,或按类型与年份筛选。点海报看详情,可标记已看。
+        </p>
+      </header>
+
+      <div className="mode-tabs" role="tablist" aria-label="查询方式">
+        <button
+          type="button"
+          role="tab"
+          aria-selected={mode === 'search'}
+          className={mode === 'search' ? 'mode-tab is-active' : 'mode-tab'}
+          onClick={() => {
+            setMode('search')
+            setResults(null)
+            setSelected(null)
+            setError(null)
+          }}
+        >
+          关键词搜索
+        </button>
+        <button
+          type="button"
+          role="tab"
+          aria-selected={mode === 'discover'}
+          className={mode === 'discover' ? 'mode-tab is-active' : 'mode-tab'}
+          onClick={() => setMode('discover')}
+        >
+          条件发现
+        </button>
+      </div>
+
+      {mode === 'search' ? (
+        <form className="browse-toolbar" onSubmit={(e) => void onSearchSubmit(e)}>
+          <input
+            type="search"
+            value={query}
+            onChange={(e) => setQuery(e.target.value)}
+            placeholder="片名、关键词,例如:盗梦空间"
+            autoComplete="off"
+            aria-label="搜索关键词"
+          />
+          <input
+            type="number"
+            className="browse-year"
+            value={year}
+            onChange={(e) => setYear(e.target.value)}
+            placeholder="年份"
+            min={1900}
+            max={2100}
+            aria-label="上映年份"
+          />
+          <button
+            type="submit"
+            className="btn btn--primary"
+            disabled={loading || !query.trim()}
+          >
+            {loading ? '检索中…' : '搜索'}
+          </button>
+        </form>
+      ) : (
+        <div className="browse-filters">
+          <div className="chip-row" aria-label="类型,最多三项">
+            {GENRE_OPTIONS.map((g) => (
+              <button
+                key={g}
+                type="button"
+                className={genres.includes(g) ? 'chip is-on' : 'chip'}
+                onClick={() => toggleGenre(g)}
+              >
+                {g}
+              </button>
+            ))}
+          </div>
+          <div className="browse-filters__row">
+            <label>
+              <span className="sr-only">起始年份</span>
+              <input
+                type="number"
+                value={yearGte}
+                onChange={(e) => setYearGte(e.target.value)}
+                placeholder="起始年"
+                min={1900}
+                max={2100}
+              />
+            </label>
+            <label>
+              <span className="sr-only">片长上限</span>
+              <select
+                value={maxRuntime}
+                onChange={(e) => setMaxRuntime(e.target.value)}
+              >
+                <option value="">片长不限</option>
+                <option value="90">≤ 90 分</option>
+                <option value="120">≤ 120 分</option>
+                <option value="150">≤ 150 分</option>
+              </select>
+            </label>
+            <label>
+              <span className="sr-only">语言</span>
+              <select value={lang} onChange={(e) => setLang(e.target.value)}>
+                {LANG_OPTIONS.map((o) => (
+                  <option key={o.value || 'any'} value={o.value}>
+                    {o.label}
+                  </option>
+                ))}
+              </select>
+            </label>
+            <label>
+              <span className="sr-only">排序</span>
+              <select value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
+                {SORT_OPTIONS.map((o) => (
+                  <option key={o.value} value={o.value}>
+                    {o.label}
+                  </option>
+                ))}
+              </select>
+            </label>
+            <button
+              type="button"
+              className="btn btn--primary"
+              onClick={() => void runDiscover()}
+              disabled={loading}
+            >
+              {loading ? '刷新中…' : '刷新结果'}
+            </button>
+          </div>
+        </div>
+      )}
+
+      {error && (
+        <p className="error-banner" role="alert">
+          {error}
+        </p>
+      )}
+
+      <div className={selected ? 'browse-split has-detail' : 'browse-split'}>
+        <section className="catalog-grid" aria-label="影片列表">
+          {loading && !results && <p className="muted">正在连接片库…</p>}
+          {results && results.length === 0 && (
+            <p className="muted">没有符合条件的影片,换个关键词或放宽筛选试试。</p>
+          )}
+          {results &&
+            results.map((m, i) => (
+              <CatalogTile
+                key={m.id}
+                movie={m}
+                index={i}
+                selected={selected?.id === m.id}
+                seen={seenIds.has(m.id)}
+                onSelect={setSelected}
+              />
+            ))}
+          {!loading && results == null && mode === 'search' && (
+            <p className="browse-empty muted">
+              输入片名确认年份与海报,或切换到「条件发现」按口味逛一圈。
+            </p>
+          )}
+        </section>
+
+        {selected && (
+          <CatalogDetail
+            movie={selected}
+            onClose={() => setSelected(null)}
+            onSeenChange={setSeenList}
+          />
+        )}
+      </div>
+
+      {seenList.length > 0 && (
+        <p className="browse-seen-hint muted">
+          已标记 {seenList.length} 部已看;返回智能推荐时会自动带入排除列表。
+        </p>
+      )}
+
+      <SiteFooter />
+    </div>
+  )
+}

+ 164 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/frontend/src/pages/HomePage.tsx

@@ -0,0 +1,164 @@
+import { useEffect, useRef, useState } from 'react'
+import { Link, useLocation, useNavigate } from 'react-router-dom'
+import { ApiError } from '../api/client'
+import { postRecommend } from '../api/recommend'
+import { BRAND_NAME, BRAND_TAGLINE } from '../brand'
+import { PreferenceForm } from '../components/PreferenceForm'
+import { ProgressOverlay } from '../components/ProgressOverlay'
+import { SiteFooter } from '../components/SiteFooter'
+import { SiteNav } from '../components/SiteNav'
+import { loadSeen } from '../lib/seen'
+import { saveSession } from '../lib/session'
+import type { RecommendRequest } from '../types'
+import { PROGRESS_STAGES } from '../types'
+
+const STAGE_INTERVAL_MS = 4_500
+
+export function HomePage() {
+  const navigate = useNavigate()
+  const location = useLocation()
+  const [loading, setLoading] = useState(false)
+  const [stageIndex, setStageIndex] = useState(0)
+  const [error, setError] = useState<string | null>(null)
+  const [hint, setHint] = useState<string | null>(null)
+  const timersRef = useRef<number[]>([])
+  const formRef = useRef<HTMLElement>(null)
+
+  const navState = location.state as
+    | { prefillGenres?: string[]; hint?: string }
+    | null
+
+  useEffect(() => {
+    return () => {
+      timersRef.current.forEach((id) => window.clearTimeout(id))
+    }
+  }, [])
+
+  useEffect(() => {
+    if (navState?.hint) {
+      setHint(navState.hint)
+      window.requestAnimationFrame(() => {
+        formRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
+      })
+    }
+  }, [navState])
+
+  function startFakeProgress() {
+    timersRef.current.forEach((id) => window.clearTimeout(id))
+    timersRef.current = []
+    setStageIndex(0)
+    PROGRESS_STAGES.forEach((_, i) => {
+      if (i === 0) return
+      const id = window.setTimeout(() => {
+        setStageIndex(i)
+      }, STAGE_INTERVAL_MS * i)
+      timersRef.current.push(id)
+    })
+  }
+
+  function stopFakeProgress() {
+    timersRef.current.forEach((id) => window.clearTimeout(id))
+    timersRef.current = []
+  }
+
+  function scrollToForm() {
+    formRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
+  }
+
+  async function handleSubmit(request: RecommendRequest) {
+    setError(null)
+    setLoading(true)
+    startFakeProgress()
+
+    const seen = loadSeen()
+    const merged: RecommendRequest = {
+      ...request,
+      exclude_titles: [
+        ...new Set([
+          ...request.exclude_titles,
+          ...seen.map((s) => s.title),
+        ]),
+      ],
+      exclude_ids: [
+        ...new Set([...request.exclude_ids, ...seen.map((s) => s.id)]),
+      ],
+    }
+
+    try {
+      const res = await postRecommend(merged)
+      if (!res.success || !res.data) {
+        throw new ApiError(res.message || '推荐失败')
+      }
+
+      setStageIndex(PROGRESS_STAGES.length - 1)
+      saveSession({
+        request: merged,
+        result: res.data,
+        message: res.message,
+      })
+      navigate('/result')
+    } catch (err) {
+      setError(err instanceof Error ? err.message : '推荐失败,请稍后重试')
+    } finally {
+      stopFakeProgress()
+      setLoading(false)
+    }
+  }
+
+  return (
+    <div className="page page--home">
+      <div className="hero-stage">
+        <div className="hero-stage__atmosphere" aria-hidden="true">
+          <div className="hero-stage__wash" />
+          <div className="hero-stage__beam" />
+          <div className="hero-stage__grain" />
+          <div className="hero-stage__aperture" />
+        </div>
+
+        <SiteNav active="home" tone="over-hero" />
+
+        <header className="hero">
+          <h1 className="hero__brand-line">
+            <span className="brand brand--hero">{BRAND_NAME}</span>
+          </h1>
+          <p className="hero__title">{BRAND_TAGLINE}</p>
+          <div className="hero__cta">
+            <button type="button" className="btn btn--primary" onClick={scrollToForm}>
+              开始定片
+            </button>
+            <Link to="/browse" className="btn btn--ghost">
+              逛片库
+            </Link>
+          </div>
+        </header>
+      </div>
+
+      <main className="home-main" id="tonight" ref={formRef}>
+        <div className="home-act">
+          <header className="home-act__header">
+            <p className="section-kicker">第二幕</p>
+            <h2 className="home-act__title">定下今晚</h2>
+            <p className="home-act__lead muted">
+              说说心情与人群,其余可随手带过。
+            </p>
+          </header>
+
+          {hint && <p className="hint-banner">{hint}</p>}
+          <PreferenceForm
+            onSubmit={(req) => void handleSubmit(req)}
+            disabled={loading}
+            initialGenres={navState?.prefillGenres}
+          />
+          {error && (
+            <p className="error-banner" role="alert">
+              {error}
+            </p>
+          )}
+        </div>
+      </main>
+
+      <SiteFooter />
+      <ProgressOverlay active={loading} stageIndex={stageIndex} />
+    </div>
+  )
+}

+ 178 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/frontend/src/pages/ResultPage.tsx

@@ -0,0 +1,178 @@
+import { useEffect, useState } from 'react'
+import { Link, useNavigate } from 'react-router-dom'
+import { ApiError } from '../api/client'
+import { postRecommend } from '../api/recommend'
+import { FallbackAlert } from '../components/FallbackAlert'
+import { MovieCardView } from '../components/MovieCard'
+import { ProgressOverlay } from '../components/ProgressOverlay'
+import { SiteFooter } from '../components/SiteFooter'
+import { SiteNav } from '../components/SiteNav'
+import { formatPlaylistText } from '../lib/format'
+import { loadSession, saveSession } from '../lib/session'
+import type { SessionPayload } from '../types'
+import { PROGRESS_STAGES } from '../types'
+
+export function ResultPage() {
+  const navigate = useNavigate()
+  const [session, setSession] = useState<SessionPayload | null>(null)
+  const [loading, setLoading] = useState(false)
+  const [stageIndex, setStageIndex] = useState(0)
+  const [error, setError] = useState<string | null>(null)
+  const [copied, setCopied] = useState(false)
+
+  useEffect(() => {
+    const data = loadSession()
+    if (!data) {
+      navigate('/', { replace: true })
+      return
+    }
+    setSession(data)
+  }, [navigate])
+
+  useEffect(() => {
+    if (!copied) return
+    const id = window.setTimeout(() => setCopied(false), 2000)
+    return () => window.clearTimeout(id)
+  }, [copied])
+
+  async function handleRefresh() {
+    if (!session) return
+    setError(null)
+    setLoading(true)
+
+    const exclude_ids = session.result.movies.map((m) => m.id)
+    const taste_profile =
+      session.result.taste_profile ??
+      ({
+        summary:
+          session.result.profile_summary ||
+          `${session.request.mood}/${session.request.party_type} 观影`,
+        genre_hints: [...session.request.genres],
+        language_hints: [] as string[],
+        avoid: [] as string[],
+        discover_notes: session.request.free_text || '',
+      })
+
+    // 换一批已跳过画像:假进度从「检索片库」起
+    setStageIndex(1)
+    const timers: number[] = []
+    PROGRESS_STAGES.forEach((_, i) => {
+      if (i <= 1) return
+      timers.push(window.setTimeout(() => setStageIndex(i), 4_500 * (i - 1)))
+    })
+
+    const request = {
+      ...session.request,
+      exclude_ids: [
+        ...new Set([...session.request.exclude_ids, ...exclude_ids]),
+      ],
+      taste_profile,
+    }
+
+    try {
+      const res = await postRecommend(request)
+      if (!res.success || !res.data) {
+        throw new ApiError(res.message || '换一批失败')
+      }
+      const next: SessionPayload = {
+        request: {
+          ...request,
+          taste_profile: undefined,
+        },
+        result: {
+          ...res.data,
+          taste_profile: res.data.taste_profile ?? taste_profile,
+        },
+        message: res.message,
+      }
+      saveSession(next)
+      setSession(next)
+      setStageIndex(PROGRESS_STAGES.length - 1)
+    } catch (err) {
+      setError(err instanceof Error ? err.message : '换一批失败')
+    } finally {
+      timers.forEach((id) => window.clearTimeout(id))
+      setLoading(false)
+    }
+  }
+
+  async function handleCopy() {
+    if (!session) return
+    const text = formatPlaylistText(session.result.movies)
+    try {
+      await navigator.clipboard.writeText(text)
+      setCopied(true)
+    } catch {
+      setError('复制失败,请手动选择文本')
+    }
+  }
+
+  if (!session) {
+    return (
+      <div className="page page--result">
+        <p className="muted">正在载入结果…</p>
+      </div>
+    )
+  }
+
+  const { result, message } = session
+
+  return (
+    <div className="page page--result">
+      <SiteNav active="result" />
+
+      <header className="result-header">
+        <p className="result-kicker">今晚片单</p>
+        <h1 className="result-title">{result.playlist_name || '推荐结果'}</h1>
+        {result.profile_summary && (
+          <p className="result-summary">{result.profile_summary}</p>
+        )}
+      </header>
+
+      <div className="result-actions">
+        <Link to="/" className="btn btn--ghost">
+          调整偏好
+        </Link>
+        <Link to="/browse" className="btn btn--ghost">
+          片库
+        </Link>
+        <button
+          type="button"
+          className="btn btn--ghost"
+          onClick={() => void handleRefresh()}
+          disabled={loading}
+        >
+          换一批
+        </button>
+        <button
+          type="button"
+          className="btn btn--primary"
+          onClick={() => void handleCopy()}
+          disabled={loading || result.movies.length === 0}
+        >
+          {copied ? '已复制' : '复制片单'}
+        </button>
+      </div>
+
+      <FallbackAlert message={message} isFallback={result.is_fallback} />
+      {error && (
+        <p className="error-banner" role="alert">
+          {error}
+        </p>
+      )}
+
+      <section className="movie-grid" aria-label="推荐影片">
+        {result.movies.length === 0 ? (
+          <p className="muted">暂无推荐影片,请返回首页调整偏好后再试。</p>
+        ) : (
+          result.movies.map((movie, i) => (
+            <MovieCardView key={movie.id} movie={movie} index={i} />
+          ))
+        )}
+      </section>
+
+      <SiteFooter />
+      <ProgressOverlay active={loading} stageIndex={stageIndex} />
+    </div>
+  )
+}

+ 1484 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/frontend/src/styles/global.css

@@ -0,0 +1,1484 @@
+/* 映前 — paper-light pre-screening (灯光将暗,纸面留白) */
+
+:root {
+  --bg: #f0eee8;
+  --bg-deep: #e6e3db;
+  --bg-card: #faf9f6;
+  --bg-muted: #e9e6de;
+  --ink: #1c1b19;
+  --text: #3a3834;
+  --text-muted: #6e6a62;
+  --text-strong: #141311;
+  --line: #d4d0c6;
+  --line-strong: #b8b3a8;
+  --accent: #1f7a3a;
+  --accent-soft: #e4f0e6;
+  --accent-ink: #165c2c;
+  --warm: #c4a574;
+  --warm-soft: rgba(196, 165, 116, 0.22);
+  --danger: #b33a2e;
+  --danger-bg: #f8ebe9;
+  --radius: 2px;
+  --font: 'IBM Plex Sans', 'PingFang SC', 'Microsoft YaHei', sans-serif;
+  --font-display: 'Noto Serif SC', 'Songti SC', 'STSong', serif;
+  --font-title: var(--font-display);
+  --max: 640px;
+  --max-wide: 1100px;
+  --ease-out: cubic-bezier(0.22, 1, 0.36, 1);
+}
+
+*,
+*::before,
+*::after {
+  box-sizing: border-box;
+}
+
+html {
+  scroll-behavior: smooth;
+}
+
+body {
+  margin: 0;
+  min-height: 100svh;
+  font-family: var(--font);
+  font-size: 15px;
+  line-height: 1.55;
+  color: var(--text);
+  background-color: var(--bg);
+  background-image:
+    radial-gradient(ellipse 90% 55% at 50% -10%, var(--warm-soft), transparent 55%),
+    radial-gradient(ellipse 70% 40% at 100% 100%, rgba(28, 27, 25, 0.04), transparent 50%),
+    linear-gradient(180deg, #f3f1eb 0%, var(--bg) 40%, #ebe8e0 100%);
+  background-attachment: fixed;
+  -webkit-font-smoothing: antialiased;
+}
+
+#root {
+  min-height: 100svh;
+}
+
+h1,
+h2,
+h3 {
+  font-family: var(--font-display);
+  color: var(--text-strong);
+  font-weight: 600;
+  letter-spacing: 0.02em;
+  margin: 0;
+}
+
+h1 {
+  font-size: clamp(1.65rem, 3.5vw, 2.1rem);
+  line-height: 1.25;
+}
+
+h2 {
+  font-size: 1.2rem;
+}
+
+h3 {
+  font-size: 1.08rem;
+  font-weight: 600;
+  line-height: 1.3;
+}
+
+p {
+  margin: 0;
+}
+
+a {
+  color: var(--accent-ink);
+  text-decoration-thickness: 1px;
+  text-underline-offset: 2px;
+}
+
+a:hover {
+  color: var(--text-strong);
+}
+
+button,
+input,
+select,
+textarea {
+  font: inherit;
+  color: inherit;
+}
+
+img {
+  display: block;
+  max-width: 100%;
+}
+
+.sr-only {
+  position: absolute;
+  width: 1px;
+  height: 1px;
+  padding: 0;
+  margin: -1px;
+  overflow: hidden;
+  clip: rect(0, 0, 0, 0);
+  border: 0;
+}
+
+.page {
+  min-height: 100svh;
+  display: flex;
+  flex-direction: column;
+  padding: 0 clamp(1rem, 3vw, 1.75rem) 2.5rem;
+  position: relative;
+}
+
+.page--home {
+  max-width: none;
+  margin: 0;
+  width: 100%;
+  padding-left: 0;
+  padding-right: 0;
+  padding-bottom: 0;
+}
+
+.page--result,
+.page--browse {
+  max-width: calc(var(--max-wide) + 3.5rem);
+  margin: 0 auto;
+  width: 100%;
+}
+
+.muted {
+  color: var(--text-muted);
+}
+
+.section-kicker {
+  font-family: var(--font);
+  font-size: 0.72rem;
+  font-weight: 600;
+  letter-spacing: 0.18em;
+  text-transform: uppercase;
+  color: var(--text-muted);
+  margin: 0 0 0.55rem;
+}
+
+/* —— Nav —— */
+
+.site-nav {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 1rem;
+  padding: 1rem 0;
+  border-bottom: 1px solid var(--line);
+  margin-bottom: 1.75rem;
+}
+
+.site-nav--over-hero {
+  position: relative;
+  z-index: 2;
+  border-bottom: none;
+  margin-bottom: 0;
+  padding: 1.1rem clamp(1rem, 3vw, 1.75rem);
+  max-width: calc(var(--max) + 3.5rem);
+  margin-inline: auto;
+  width: 100%;
+}
+
+.site-nav__spacer {
+  width: 1px;
+  height: 1px;
+}
+
+.brand {
+  font-family: var(--font-display);
+  font-weight: 600;
+  letter-spacing: 0.28em;
+  color: var(--text-strong);
+  margin: 0;
+  line-height: 1.05;
+}
+
+.brand--link {
+  text-decoration: none;
+  color: var(--text-strong);
+  font-size: 1.2rem;
+  letter-spacing: 0.28em;
+  transition: color 0.2s var(--ease-out);
+}
+
+.brand--link:hover {
+  color: var(--accent);
+}
+
+.site-nav__links {
+  display: flex;
+  gap: 1.35rem;
+}
+
+.site-nav__link {
+  text-decoration: none;
+  color: var(--text-muted);
+  font-size: 0.86rem;
+  font-weight: 600;
+  letter-spacing: 0.06em;
+  padding: 0.25rem 0;
+  border-bottom: 1px solid transparent;
+  transition: color 0.2s var(--ease-out), border-color 0.2s var(--ease-out);
+}
+
+.site-nav__link:hover {
+  color: var(--text-strong);
+}
+
+.site-nav__link.is-active {
+  color: var(--text-strong);
+  border-bottom-color: var(--ink);
+}
+
+.site-nav--over-hero .site-nav__link {
+  color: var(--text-muted);
+}
+
+.site-nav--over-hero .site-nav__link.is-active {
+  color: var(--text-strong);
+  border-bottom-color: var(--ink);
+}
+
+/* —— Home hero (full-bleed atmosphere) —— */
+
+.hero-stage {
+  position: relative;
+  min-height: 100svh;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  isolation: isolate;
+}
+
+.hero-stage__atmosphere {
+  position: absolute;
+  inset: 0;
+  z-index: 0;
+  pointer-events: none;
+}
+
+.hero-stage__wash {
+  position: absolute;
+  inset: 0;
+  background:
+    radial-gradient(ellipse 85% 70% at 50% 18%, #faf8f2 0%, transparent 58%),
+    radial-gradient(ellipse 55% 45% at 78% 72%, rgba(196, 165, 116, 0.18), transparent 60%),
+    radial-gradient(ellipse 50% 40% at 12% 80%, rgba(28, 27, 25, 0.07), transparent 55%),
+    linear-gradient(165deg, #ebe7de 0%, #f3f0e9 42%, #ddd8ce 100%);
+}
+
+.hero-stage__beam {
+  position: absolute;
+  top: -15%;
+  left: 50%;
+  width: min(920px, 140vw);
+  height: 95%;
+  transform: translateX(-50%);
+  background: radial-gradient(
+    ellipse 42% 55% at 50% 0%,
+    rgba(255, 252, 245, 0.85) 0%,
+    rgba(255, 250, 240, 0.28) 35%,
+    transparent 70%
+  );
+  animation: beam-breathe 9s var(--ease-out) infinite alternate;
+  will-change: opacity, transform;
+}
+
+.hero-stage__grain {
+  position: absolute;
+  inset: 0;
+  opacity: 0.35;
+  mix-blend-mode: multiply;
+  background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.55'/%3E%3C/svg%3E");
+  background-size: 180px 180px;
+}
+
+.hero-stage__aperture {
+  position: absolute;
+  left: 50%;
+  bottom: -18%;
+  width: min(640px, 95vw);
+  height: min(420px, 55vh);
+  transform: translateX(-50%);
+  border: 1px solid rgba(28, 27, 25, 0.08);
+  border-radius: 50% 50% 0 0 / 55% 55% 0 0;
+  box-shadow:
+    inset 0 40px 80px rgba(28, 27, 25, 0.04),
+    0 0 0 40px rgba(28, 27, 25, 0.015);
+  opacity: 0.9;
+}
+
+.hero {
+  position: relative;
+  z-index: 1;
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: flex-start;
+  padding: 2rem clamp(1rem, 3vw, 1.75rem) 4.5rem;
+  max-width: calc(var(--max) + 3.5rem);
+  margin: 0 auto;
+  width: 100%;
+}
+
+.hero__brand-line {
+  margin: 0;
+}
+
+.brand--hero {
+  display: inline-block;
+  font-family: var(--font-display);
+  font-size: clamp(4rem, 16vw, 7.5rem);
+  font-weight: 700;
+  letter-spacing: 0.32em;
+  padding-right: 0.08em;
+  color: var(--ink);
+  line-height: 0.95;
+  animation: brand-rise 1.05s var(--ease-out) both;
+}
+
+.hero__title {
+  font-family: var(--font);
+  font-size: clamp(1rem, 2.4vw, 1.15rem);
+  font-weight: 400;
+  color: var(--text);
+  line-height: 1.65;
+  max-width: 22rem;
+  margin: 1.35rem 0 0;
+  letter-spacing: 0.02em;
+  animation: brand-rise 1.05s var(--ease-out) 0.12s both;
+}
+
+.hero__cta {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 0.65rem;
+  margin-top: 2rem;
+  animation: brand-rise 1.05s var(--ease-out) 0.24s both;
+}
+
+.hero__lead {
+  display: none;
+}
+
+/* —— Home act two (form) —— */
+
+.home-main {
+  width: 100%;
+  background: linear-gradient(180deg, #eae6dc 0%, var(--bg) 18%, var(--bg) 100%);
+  border-top: 1px solid var(--line);
+  padding: 3.25rem clamp(1rem, 3vw, 1.75rem) 3rem;
+}
+
+.home-act {
+  max-width: var(--max);
+  margin: 0 auto;
+  width: 100%;
+  animation: act-rise 0.7s var(--ease-out) both;
+}
+
+.home-act__header {
+  margin-bottom: 1.75rem;
+  max-width: 28rem;
+}
+
+.home-act__title {
+  font-family: var(--font-display);
+  font-size: clamp(1.6rem, 3.5vw, 2rem);
+  font-weight: 600;
+  letter-spacing: 0.08em;
+  color: var(--text-strong);
+}
+
+.home-act__lead {
+  margin-top: 0.55rem;
+  font-size: 0.95rem;
+  max-width: 24rem;
+}
+
+.hint-banner {
+  margin: 0 0 1.25rem;
+  padding: 0.7rem 0;
+  border-top: 1px solid var(--accent);
+  border-bottom: 1px solid var(--accent);
+  color: var(--text);
+  font-size: 0.9rem;
+  background: transparent;
+}
+
+/* —— Buttons —— */
+
+.btn {
+  appearance: none;
+  border: 1px solid var(--line-strong);
+  background: transparent;
+  color: var(--text-strong);
+  padding: 0.6rem 1.1rem;
+  border-radius: var(--radius);
+  cursor: pointer;
+  font-weight: 600;
+  font-size: 0.88rem;
+  letter-spacing: 0.04em;
+  text-decoration: none;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  transition:
+    background 0.2s var(--ease-out),
+    border-color 0.2s var(--ease-out),
+    color 0.2s var(--ease-out),
+    transform 0.2s var(--ease-out);
+}
+
+.btn:hover:not(:disabled) {
+  border-color: var(--ink);
+  background: rgba(28, 27, 25, 0.04);
+}
+
+.btn:active:not(:disabled) {
+  transform: translateY(1px);
+}
+
+.btn:disabled {
+  opacity: 0.45;
+  cursor: not-allowed;
+}
+
+.btn--primary {
+  background: var(--ink);
+  border-color: var(--ink);
+  color: #f5f2eb;
+}
+
+.btn--primary:hover:not(:disabled) {
+  background: var(--accent);
+  border-color: var(--accent);
+  color: #fff;
+}
+
+.btn--ghost {
+  background: transparent;
+  border-color: var(--line-strong);
+}
+
+.btn--text {
+  border-color: transparent;
+  padding-inline: 0.35rem;
+  color: var(--text-muted);
+  font-weight: 500;
+}
+
+.btn--text:hover:not(:disabled) {
+  color: var(--text-strong);
+  background: transparent;
+  border-color: transparent;
+}
+
+.btn--compact {
+  padding: 0.4rem 0.7rem;
+  font-size: 0.82rem;
+}
+
+/* —— Preference form —— */
+
+.pref-form {
+  display: flex;
+  flex-direction: column;
+  gap: 1.5rem;
+  padding-top: 0.15rem;
+}
+
+.pref-form__toolbar {
+  display: flex;
+  justify-content: flex-end;
+}
+
+.pref-form__actions {
+  padding-top: 0.5rem;
+}
+
+.pref-form__actions .btn--primary {
+  min-width: 9rem;
+}
+
+.field-block {
+  margin: 0;
+  padding: 0;
+  border: none;
+}
+
+.field-block legend {
+  font-family: var(--font);
+  font-weight: 600;
+  font-size: 0.78rem;
+  letter-spacing: 0.12em;
+  text-transform: uppercase;
+  color: var(--text-muted);
+  margin-bottom: 0.65rem;
+  padding: 0;
+}
+
+.req {
+  color: var(--accent);
+  text-transform: none;
+  letter-spacing: 0;
+}
+
+.choice-row {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 0.4rem;
+}
+
+.choice {
+  position: relative;
+  cursor: pointer;
+}
+
+.choice input {
+  position: absolute;
+  opacity: 0;
+  inset: 0;
+  margin: 0;
+  cursor: pointer;
+}
+
+.choice span {
+  display: inline-block;
+  padding: 0.4rem 0.7rem;
+  border: 1px solid var(--line);
+  border-radius: var(--radius);
+  color: var(--text);
+  background: transparent;
+  font-size: 0.88rem;
+  transition:
+    border-color 0.15s var(--ease-out),
+    background 0.15s var(--ease-out),
+    color 0.15s var(--ease-out);
+}
+
+.choice:hover span {
+  border-color: var(--line-strong);
+}
+
+.choice input:checked + span {
+  border-color: var(--ink);
+  color: var(--text-strong);
+  background: rgba(28, 27, 25, 0.05);
+  font-weight: 600;
+}
+
+.choice input:focus-visible + span {
+  outline: 2px solid var(--accent);
+  outline-offset: 2px;
+}
+
+.field-grid {
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: 0.85rem;
+}
+
+@media (max-width: 640px) {
+  .field-grid {
+    grid-template-columns: 1fr;
+  }
+}
+
+.field {
+  display: flex;
+  flex-direction: column;
+  gap: 0.4rem;
+}
+
+.field > span {
+  font-size: 0.78rem;
+  font-weight: 600;
+  color: var(--text-muted);
+  letter-spacing: 0.1em;
+  text-transform: uppercase;
+}
+
+.field--inline {
+  flex-direction: row;
+  align-items: center;
+  gap: 0.5rem;
+}
+
+.field--inline > span {
+  text-transform: none;
+  letter-spacing: 0;
+  font-weight: 500;
+  color: var(--text);
+}
+
+.field--inline input {
+  width: 1rem;
+  height: 1rem;
+  accent-color: var(--accent);
+}
+
+.field input[type='text'],
+.field input[type='search'],
+.field input[type='number'],
+.field select,
+.field textarea,
+.browse-toolbar input,
+.browse-filters__row input,
+.browse-filters__row select {
+  width: 100%;
+  padding: 0.6rem 0.7rem;
+  border: none;
+  border-bottom: 1px solid var(--line);
+  border-radius: 0;
+  background: transparent;
+  color: var(--text-strong);
+  outline: none;
+  transition: border-color 0.2s var(--ease-out);
+}
+
+.field input:focus,
+.field select:focus,
+.field textarea:focus,
+.browse-toolbar input:focus,
+.browse-filters__row input:focus,
+.browse-filters__row select:focus {
+  border-bottom-color: var(--ink);
+}
+
+.field textarea {
+  resize: vertical;
+  min-height: 4.5rem;
+  border: 1px solid var(--line);
+  border-radius: var(--radius);
+  padding: 0.65rem 0.7rem;
+}
+
+.field textarea:focus {
+  border-color: var(--ink);
+}
+
+.field select,
+.browse-filters__row select {
+  cursor: pointer;
+}
+
+.field-error {
+  margin-top: 0.35rem;
+  color: var(--danger);
+  font-size: 0.85rem;
+}
+
+/* —— Alerts —— */
+
+.error-banner,
+.error-text {
+  color: var(--danger);
+}
+
+.error-banner {
+  margin: 1rem 0;
+  padding: 0.75rem 0;
+  background: transparent;
+  border-top: 1px solid var(--danger);
+  border-bottom: 1px solid var(--danger);
+  border-radius: 0;
+}
+
+.fallback-alert {
+  margin-bottom: 1.25rem;
+  padding: 0.85rem 0;
+  border: none;
+  background: transparent;
+  border-top: 1px solid var(--warm);
+  border-bottom: 1px solid var(--warm);
+  border-radius: 0;
+}
+
+.fallback-alert strong {
+  display: block;
+  color: var(--text-strong);
+  margin-bottom: 0.2rem;
+  font-size: 0.9rem;
+  font-family: var(--font);
+}
+
+.fallback-alert p {
+  color: var(--text-muted);
+  font-size: 0.88rem;
+}
+
+/* —— Result —— */
+
+.result-header {
+  padding: 0.25rem 0 1.25rem;
+  max-width: 38rem;
+  border-bottom: 1px solid var(--line);
+  margin-bottom: 1.15rem;
+  animation: act-rise 0.55s var(--ease-out) both;
+}
+
+.result-kicker {
+  font-size: 0.72rem;
+  font-weight: 600;
+  letter-spacing: 0.16em;
+  text-transform: uppercase;
+  color: var(--text-muted);
+  margin-bottom: 0.45rem;
+  font-family: var(--font);
+}
+
+.result-title {
+  font-family: var(--font-display);
+  font-size: clamp(1.7rem, 3.8vw, 2.35rem);
+  letter-spacing: 0.06em;
+  font-weight: 600;
+  line-height: 1.2;
+}
+
+.result-summary {
+  margin-top: 0.7rem;
+  color: var(--text-muted);
+  font-size: 0.95rem;
+  max-width: 34rem;
+  line-height: 1.6;
+}
+
+.result-actions {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 0.5rem;
+  margin-bottom: 1.5rem;
+}
+
+.movie-grid {
+  display: grid;
+  grid-template-columns: 1fr;
+  gap: 1.25rem;
+  margin-bottom: 2.5rem;
+  align-items: start;
+}
+
+@media (min-width: 820px) {
+  .movie-grid {
+    grid-template-columns: 1fr 1fr;
+    gap: 1.5rem 1.75rem;
+  }
+}
+
+.movie-card {
+  border: none;
+  border-radius: 0;
+  background: transparent;
+  overflow: visible;
+  align-self: start;
+  animation: tile-rise 0.55s var(--ease-out) both;
+}
+
+.movie-card__hit {
+  display: grid;
+  grid-template-columns: 92px 1fr;
+  gap: 1rem;
+  width: 100%;
+  padding: 0;
+  border: none;
+  background: transparent;
+  text-align: left;
+  cursor: pointer;
+  color: inherit;
+}
+
+@media (min-width: 520px) {
+  .movie-card__hit {
+    grid-template-columns: 112px 1fr;
+    gap: 1.15rem;
+  }
+}
+
+.movie-card__poster {
+  aspect-ratio: 2 / 3;
+  overflow: hidden;
+  background: var(--bg-muted);
+  border-radius: 0;
+  transition: transform 0.35s var(--ease-out);
+}
+
+.movie-card__hit:hover .movie-card__poster {
+  transform: translateY(-3px);
+}
+
+.movie-card__poster img,
+.movie-card__poster-fallback {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+}
+
+.movie-card__poster-fallback {
+  display: grid;
+  place-items: center;
+  font-family: var(--font-display);
+  font-size: 1.5rem;
+  color: var(--text-muted);
+  background: var(--bg-muted);
+}
+
+.movie-card__header {
+  display: flex;
+  flex-direction: column;
+  gap: 0.3rem;
+  margin-bottom: 0.45rem;
+}
+
+.movie-card__header h3 {
+  transition: color 0.2s var(--ease-out);
+}
+
+.movie-card__hit:hover .movie-card__header h3 {
+  color: var(--accent-ink);
+}
+
+.movie-card__meta {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 0.35rem 0.65rem;
+  font-size: 0.76rem;
+  color: var(--text-muted);
+  letter-spacing: 0.02em;
+}
+
+.movie-card__why {
+  font-size: 0.9rem;
+  color: var(--text);
+  margin-bottom: 0.45rem;
+  line-height: 1.5;
+}
+
+.movie-card__tags {
+  list-style: none;
+  margin: 0 0 0.35rem;
+  padding: 0;
+  display: flex;
+  flex-wrap: wrap;
+  gap: 0.25rem 0.75rem;
+}
+
+.movie-card__tags li {
+  font-size: 0.74rem;
+  color: var(--accent-ink);
+  font-weight: 600;
+  letter-spacing: 0.02em;
+}
+
+.movie-card__caution {
+  font-size: 0.8rem;
+  color: var(--danger);
+}
+
+.movie-card__more {
+  margin-top: 0.5rem;
+  font-size: 0.76rem;
+  letter-spacing: 0.06em;
+}
+
+.movie-card__detail {
+  padding: 0.85rem 0 0;
+  margin-top: 0.85rem;
+  border-top: 1px solid var(--line);
+  font-size: 0.88rem;
+  color: var(--text);
+  grid-column: 1 / -1;
+}
+
+.detail-tagline {
+  font-family: var(--font-display);
+  font-style: italic;
+  color: var(--text-muted);
+  margin-bottom: 0.55rem;
+  font-size: 0.95rem;
+}
+
+.detail-overview {
+  color: var(--text);
+  line-height: 1.6;
+  margin-bottom: 0.75rem;
+}
+
+.detail-facts {
+  margin: 0 0 0.65rem;
+  padding: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 0.35rem;
+}
+
+.detail-facts__row {
+  display: grid;
+  grid-template-columns: 4.5rem 1fr;
+  gap: 0.5rem;
+  font-size: 0.84rem;
+  line-height: 1.4;
+}
+
+.detail-facts__row dt {
+  margin: 0;
+  color: var(--text-muted);
+  font-weight: 600;
+}
+
+.detail-facts__row dd {
+  margin: 0;
+  color: var(--text-strong);
+}
+
+.detail-link {
+  margin: 0.35rem 0 0;
+  font-size: 0.84rem;
+}
+
+/* —— Browse —— */
+
+.browse-hero {
+  padding: 0.25rem 0 1.25rem;
+  max-width: 34rem;
+  animation: act-rise 0.55s var(--ease-out) both;
+}
+
+.browse-kicker {
+  display: none;
+}
+
+.browse-title {
+  font-family: var(--font-display);
+  font-size: clamp(1.65rem, 3.5vw, 2.2rem);
+  letter-spacing: 0.08em;
+  font-weight: 600;
+}
+
+.browse-lead {
+  margin-top: 0.55rem;
+  color: var(--text-muted);
+  font-size: 0.92rem;
+  line-height: 1.55;
+}
+
+.mode-tabs {
+  display: flex;
+  gap: 1.5rem;
+  margin: 0 0 1.15rem;
+  border-bottom: 1px solid var(--line);
+}
+
+.mode-tab {
+  appearance: none;
+  border: none;
+  background: transparent;
+  color: var(--text-muted);
+  padding: 0.6rem 0;
+  margin-bottom: -1px;
+  cursor: pointer;
+  font-weight: 600;
+  font-size: 0.9rem;
+  letter-spacing: 0.04em;
+  border-bottom: 1px solid transparent;
+  transition: color 0.2s var(--ease-out), border-color 0.2s var(--ease-out);
+}
+
+.mode-tab:hover {
+  color: var(--text-strong);
+}
+
+.mode-tab.is-active {
+  color: var(--text-strong);
+  border-bottom-color: var(--ink);
+}
+
+.browse-toolbar {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 0.55rem;
+  margin-bottom: 1.25rem;
+  align-items: flex-end;
+}
+
+.browse-toolbar input[type='search'] {
+  flex: 1 1 14rem;
+  min-width: 0;
+}
+
+.browse-year {
+  width: 5.5rem;
+}
+
+.browse-filters {
+  display: flex;
+  flex-direction: column;
+  gap: 0.85rem;
+  margin-bottom: 1.25rem;
+}
+
+.browse-filters__row {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 0.55rem;
+  align-items: center;
+}
+
+.browse-filters__row input,
+.browse-filters__row select {
+  width: auto;
+  min-width: 7rem;
+}
+
+.chip-row {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 0.35rem;
+}
+
+.chip {
+  appearance: none;
+  border: 1px solid var(--line);
+  background: transparent;
+  color: var(--text);
+  padding: 0.32rem 0.6rem;
+  border-radius: var(--radius);
+  cursor: pointer;
+  font-size: 0.8rem;
+  transition:
+    border-color 0.15s var(--ease-out),
+    background 0.15s var(--ease-out),
+    color 0.15s var(--ease-out);
+}
+
+.chip:hover {
+  border-color: var(--line-strong);
+}
+
+.chip.is-on {
+  color: var(--text-strong);
+  border-color: var(--ink);
+  background: rgba(28, 27, 25, 0.05);
+  font-weight: 600;
+}
+
+.browse-split {
+  display: grid;
+  gap: 1.25rem;
+  margin-bottom: 1.25rem;
+}
+
+@media (min-width: 960px) {
+  .browse-split.has-detail {
+    grid-template-columns: 1fr min(340px, 36%);
+    align-items: start;
+  }
+}
+
+.catalog-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(118px, 1fr));
+  gap: 1rem 0.75rem;
+  min-height: 6rem;
+}
+
+.browse-empty {
+  grid-column: 1 / -1;
+  padding: 2rem 0;
+}
+
+.catalog-tile {
+  appearance: none;
+  border: none;
+  background: transparent;
+  padding: 0;
+  cursor: pointer;
+  text-align: left;
+  color: inherit;
+  animation: tile-rise 0.5s var(--ease-out) both;
+}
+
+.catalog-tile:hover .catalog-tile__title {
+  color: var(--accent-ink);
+}
+
+.catalog-tile:hover .catalog-tile__poster {
+  transform: translateY(-3px);
+}
+
+.catalog-tile.is-selected .catalog-tile__poster {
+  outline: 1px solid var(--ink);
+  outline-offset: 3px;
+}
+
+.catalog-tile__poster {
+  position: relative;
+  aspect-ratio: 2 / 3;
+  background: var(--bg-muted);
+  overflow: hidden;
+  border-radius: 0;
+  transition: transform 0.35s var(--ease-out);
+}
+
+.catalog-tile__poster img,
+.catalog-tile__fallback {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+}
+
+.catalog-tile__fallback {
+  display: grid;
+  place-items: center;
+  font-family: var(--font-display);
+  font-size: 1.4rem;
+  color: var(--text-muted);
+  background: var(--bg-muted);
+}
+
+.catalog-tile__badge {
+  position: absolute;
+  top: 0;
+  left: 0;
+  font-size: 0.65rem;
+  font-weight: 700;
+  letter-spacing: 0.06em;
+  padding: 0.2rem 0.35rem;
+  background: var(--ink);
+  color: #f5f2eb;
+  border-radius: 0;
+}
+
+.catalog-tile__meta {
+  padding: 0.45rem 0 0;
+  display: flex;
+  flex-direction: column;
+  gap: 0.12rem;
+}
+
+.catalog-tile__title {
+  font-size: 0.78rem;
+  font-weight: 600;
+  color: var(--text-strong);
+  line-height: 1.3;
+  display: -webkit-box;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+  overflow: hidden;
+  transition: color 0.2s var(--ease-out);
+}
+
+.catalog-tile__sub {
+  font-size: 0.7rem;
+  color: var(--text-muted);
+}
+
+.catalog-detail {
+  border: none;
+  border-top: 1px solid var(--line);
+  border-radius: 0;
+  background: transparent;
+  padding: 0.85rem 0 0;
+  position: sticky;
+  top: 0.75rem;
+  animation: act-rise 0.4s var(--ease-out) both;
+}
+
+.catalog-detail__toolbar {
+  display: flex;
+  justify-content: flex-end;
+  margin-bottom: 0.65rem;
+}
+
+.catalog-detail__layout {
+  display: grid;
+  gap: 0.95rem;
+}
+
+@media (min-width: 520px) and (max-width: 959px) {
+  .catalog-detail__layout {
+    grid-template-columns: 120px 1fr;
+  }
+}
+
+.catalog-detail__poster {
+  aspect-ratio: 2 / 3;
+  max-width: 200px;
+  overflow: hidden;
+  background: var(--bg-muted);
+  border-radius: 0;
+}
+
+.catalog-detail__poster img {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+}
+
+.catalog-detail__body h2 {
+  font-family: var(--font-display);
+  font-size: 1.3rem;
+  font-weight: 600;
+  margin-bottom: 0.35rem;
+  letter-spacing: 0.04em;
+}
+
+.catalog-detail__meta {
+  color: var(--text-muted);
+  font-size: 0.85rem;
+  margin-bottom: 0.55rem;
+}
+
+.catalog-detail__genres {
+  list-style: none;
+  margin: 0 0 0.7rem;
+  padding: 0;
+  display: flex;
+  flex-wrap: wrap;
+  gap: 0.35rem 0.75rem;
+}
+
+.catalog-detail__genres li {
+  font-size: 0.75rem;
+  color: var(--accent-ink);
+  font-weight: 600;
+}
+
+.catalog-detail__overview {
+  font-size: 0.88rem;
+  color: var(--text);
+  line-height: 1.6;
+  margin-bottom: 0.85rem;
+  max-height: 11rem;
+  overflow: auto;
+}
+
+.catalog-detail__actions {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 0.4rem;
+}
+
+.browse-seen-hint {
+  margin: 0 0 1.25rem;
+  font-size: 0.82rem;
+}
+
+/* —— Progress —— */
+
+.progress-overlay {
+  position: fixed;
+  inset: 0;
+  z-index: 50;
+  display: grid;
+  place-items: center;
+  background: rgba(240, 238, 232, 0.88);
+  backdrop-filter: blur(2px);
+  animation: fade-in 0.25s var(--ease-out) both;
+}
+
+.progress-panel {
+  width: min(320px, calc(100vw - 2rem));
+  padding: 1.5rem 0;
+  border: none;
+  border-top: 1px solid var(--line);
+  border-bottom: 1px solid var(--line);
+  border-radius: 0;
+  background: transparent;
+}
+
+.progress-kicker {
+  font-family: var(--font-display);
+  font-size: 1.05rem;
+  font-weight: 600;
+  letter-spacing: 0.08em;
+  color: var(--text-strong);
+  margin-bottom: 1.15rem;
+}
+
+.progress-stages {
+  list-style: none;
+  margin: 0;
+  padding: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 0.6rem;
+}
+
+.progress-stage {
+  display: flex;
+  align-items: center;
+  gap: 0.65rem;
+  color: var(--text-muted);
+  font-size: 0.88rem;
+  transition: color 0.25s var(--ease-out);
+}
+
+.progress-dot {
+  width: 5px;
+  height: 5px;
+  border-radius: 50%;
+  background: var(--line-strong);
+  flex-shrink: 0;
+  transition: background 0.25s var(--ease-out), transform 0.25s var(--ease-out);
+}
+
+.progress-stage--done {
+  color: var(--text);
+}
+
+.progress-stage--done .progress-dot {
+  background: var(--accent);
+}
+
+.progress-stage--current {
+  color: var(--text-strong);
+  font-weight: 600;
+}
+
+.progress-stage--current .progress-dot {
+  background: var(--ink);
+  transform: scale(1.35);
+  animation: pulse-dot 1.2s var(--ease-out) infinite;
+}
+
+/* —— Footer —— */
+
+.site-footer {
+  margin-top: auto;
+  padding: 2rem clamp(1rem, 3vw, 1.75rem) 2.25rem;
+  border-top: 1px solid var(--line);
+  max-width: calc(var(--max) + 3.5rem);
+  margin-inline: auto;
+  width: 100%;
+}
+
+.page--result .site-footer,
+.page--browse .site-footer {
+  max-width: none;
+  padding-left: 0;
+  padding-right: 0;
+}
+
+.site-footer__brand {
+  font-family: var(--font-display);
+  font-size: 0.95rem;
+  letter-spacing: 0.28em;
+  color: var(--text-strong);
+  margin-bottom: 0.25rem;
+}
+
+.site-footer__tagline {
+  font-size: 0.8rem;
+  color: var(--text-muted);
+  margin-bottom: 0.85rem;
+}
+
+.site-footer p {
+  font-size: 0.75rem;
+  color: var(--text-muted);
+  line-height: 1.55;
+  max-width: 36rem;
+}
+
+.site-footer a {
+  color: var(--text-muted);
+}
+
+.site-footer a:hover {
+  color: var(--accent-ink);
+}
+
+/* —— Motion —— */
+
+@keyframes brand-rise {
+  from {
+    opacity: 0;
+    transform: translateY(18px);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+
+@keyframes act-rise {
+  from {
+    opacity: 0;
+    transform: translateY(12px);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+
+@keyframes tile-rise {
+  from {
+    opacity: 0;
+    transform: translateY(10px);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+
+@keyframes beam-breathe {
+  from {
+    opacity: 0.72;
+    transform: translateX(-50%) scale(1);
+  }
+  to {
+    opacity: 1;
+    transform: translateX(-50%) scale(1.04);
+  }
+}
+
+@keyframes pulse-dot {
+  0%,
+  100% {
+    opacity: 1;
+  }
+  50% {
+    opacity: 0.45;
+  }
+}
+
+@keyframes fade-in {
+  from {
+    opacity: 0;
+  }
+  to {
+    opacity: 1;
+  }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  html {
+    scroll-behavior: auto;
+  }
+
+  .brand--hero,
+  .hero__title,
+  .hero__cta,
+  .home-act,
+  .result-header,
+  .browse-hero,
+  .movie-card,
+  .catalog-tile,
+  .catalog-detail,
+  .progress-overlay,
+  .hero-stage__beam,
+  .progress-stage--current .progress-dot {
+    animation: none !important;
+  }
+
+  .movie-card__hit:hover .movie-card__poster,
+  .catalog-tile:hover .catalog-tile__poster {
+    transform: none;
+  }
+}

+ 144 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/frontend/src/types/index.ts

@@ -0,0 +1,144 @@
+/** TypeScript 契约,对齐 backend/app/models/schemas.py(D4) */
+
+export type Mood = '放松' | '欢乐' | '虐心' | '烧脑' | '紧张刺激' | '温馨'
+export type PartyType = '独自' | '情侣' | '家庭' | '朋友'
+export type RegionPreference = '华语' | '好莱坞' | '日韩' | '欧洲' | '不限'
+export type YearPreference = '不限' | '近5年' | '近10年' | '经典'
+
+export interface RecommendRequest {
+  mood: Mood
+  party_type: PartyType
+  genres: string[]
+  max_runtime_minutes: number | null
+  region_preference: RegionPreference
+  year_preference: YearPreference
+  exclude_titles: string[]
+  spoilers_ok: boolean
+  free_text: string
+  exclude_ids: number[]
+  /** 换一批时回传,后端跳过画像 Agent */
+  taste_profile?: TasteProfile | null
+}
+
+export interface TasteProfile {
+  summary: string
+  genre_hints: string[]
+  language_hints: string[]
+  avoid: string[]
+  discover_notes: string
+}
+
+export interface CandidateMovie {
+  id: number
+  title: string
+  year: number | null
+  genres: string[]
+  runtime: number | null
+  rating: number | null
+  poster_url: string | null
+  overview: string | null
+}
+
+export interface MovieDetail extends CandidateMovie {
+  tagline: string | null
+  original_title: string | null
+  vote_count: number | null
+  original_language: string | null
+  countries: string[]
+  directors: string[]
+  cast: string[]
+  tmdb_url: string | null
+}
+
+export interface MovieCard {
+  id: number
+  title: string
+  year: number | null
+  genres: string[]
+  runtime: number | null
+  rating: number | null
+  poster_url: string | null
+  why: string
+  vibe_tags: string[]
+  caution: string | null
+  overview_safe: string
+}
+
+export interface RecommendResult {
+  playlist_name: string
+  profile_summary: string
+  movies: MovieCard[]
+  is_fallback: boolean
+  taste_profile?: TasteProfile | null
+}
+
+export interface RecommendResponse {
+  success: boolean
+  message: string
+  data: RecommendResult | null
+}
+
+export interface MovieListResponse {
+  success: boolean
+  message: string
+  data: CandidateMovie[]
+}
+
+export interface MovieDetailResponse {
+  success: boolean
+  message: string
+  data: MovieDetail | null
+}
+
+export const MOODS: Mood[] = ['放松', '欢乐', '虐心', '烧脑', '紧张刺激', '温馨']
+export const PARTY_TYPES: PartyType[] = ['独自', '情侣', '家庭', '朋友']
+export const REGIONS: RegionPreference[] = ['不限', '华语', '好莱坞', '日韩', '欧洲']
+export const YEARS: YearPreference[] = ['不限', '近5年', '近10年', '经典']
+export const GENRE_OPTIONS = [
+  '剧情',
+  '喜剧',
+  '爱情',
+  '科幻',
+  '动画',
+  '悬疑',
+  '纪录',
+  '动作',
+  '冒险',
+  '恐怖',
+  '惊悚',
+  '奇幻',
+] as const
+export const RUNTIME_OPTIONS: { label: string; value: number | null }[] = [
+  { label: '不限', value: null },
+  { label: '90 分钟内', value: 90 },
+  { label: '120 分钟内', value: 120 },
+  { label: '150 分钟内', value: 150 },
+]
+
+export const DEMO_REQUEST: RecommendRequest = {
+  mood: '放松',
+  party_type: '独自',
+  genres: ['剧情', '喜剧'],
+  max_runtime_minutes: 120,
+  region_preference: '不限',
+  year_preference: '近10年',
+  exclude_titles: [],
+  spoilers_ok: false,
+  free_text: '不要太沉重',
+  exclude_ids: [],
+}
+
+export const PROGRESS_STAGES = [
+  '分析口味',
+  '检索片库',
+  '生成推荐',
+  '校验',
+] as const
+
+export type ProgressStage = (typeof PROGRESS_STAGES)[number]
+
+export interface SessionPayload {
+  request: RecommendRequest
+  result: RecommendResult
+  message: string
+}

+ 26 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/frontend/tsconfig.app.json

@@ -0,0 +1,26 @@
+{
+  "compilerOptions": {
+    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+    "target": "es2023",
+    "lib": ["ES2023", "DOM"],
+    "module": "esnext",
+    "types": ["vite/client"],
+    "allowArbitraryExtensions": true,
+    "skipLibCheck": true,
+
+    /* Bundler mode */
+    "moduleResolution": "bundler",
+    "allowImportingTsExtensions": true,
+    "verbatimModuleSyntax": true,
+    "moduleDetection": "force",
+    "noEmit": true,
+    "jsx": "react-jsx",
+
+    /* Linting */
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "erasableSyntaxOnly": true,
+    "noFallthroughCasesInSwitch": true
+  },
+  "include": ["src"]
+}

+ 7 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/frontend/tsconfig.json

@@ -0,0 +1,7 @@
+{
+  "files": [],
+  "references": [
+    { "path": "./tsconfig.app.json" },
+    { "path": "./tsconfig.node.json" }
+  ]
+}

+ 23 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/frontend/tsconfig.node.json

@@ -0,0 +1,23 @@
+{
+  "compilerOptions": {
+    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+    "target": "es2023",
+    "lib": ["ES2023"],
+    "types": ["node"],
+    "skipLibCheck": true,
+
+    /* Bundler mode */
+    "module": "nodenext",
+    "allowImportingTsExtensions": true,
+    "verbatimModuleSyntax": true,
+    "moduleDetection": "force",
+    "noEmit": true,
+
+    /* Linting */
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "erasableSyntaxOnly": true,
+    "noFallthroughCasesInSwitch": true
+  },
+  "include": ["vite.config.ts"]
+}

+ 21 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/frontend/vite.config.ts

@@ -0,0 +1,21 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+// https://vite.dev/config/
+export default defineConfig({
+  plugins: [react()],
+  server: {
+    port: 5173,
+    proxy: {
+      // 开发期把 /api 转到 FastAPI,避免跨域
+      '/api': {
+        target: 'http://127.0.0.1:8000',
+        changeOrigin: true,
+      },
+      '/health': {
+        target: 'http://127.0.0.1:8000',
+        changeOrigin: true,
+      },
+    },
+  },
+})

+ 186 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/main.ipynb

@@ -0,0 +1,186 @@
+{
+  "cells": [
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "# 映前 (YingQian) — 多智能体电影推荐演示\n",
+        "\n",
+        "基于 **HelloAgents** 的 Pipeline + Tool-use:\n",
+        "\n",
+        "1. **画像 Agent**(无工具)→ TasteProfile  \n",
+        "2. **检索 Agent**(TMDB Tool)→ 真实候选片  \n",
+        "3. **推荐 Agent**(无工具)→ 白名单内精选 + 理由  \n",
+        "\n",
+        "## 使用说明\n",
+        "\n",
+        "1. 先配置 `backend/.env`(可从 `.env.example` 复制)  \n",
+        "2. 在项目根目录启动 Jupyter,按顺序运行本 Notebook  \n",
+        "3. 需能访问 TMDB 与你的 LLM API"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "---\n",
+        "\n",
+        "## 第 1 部分:环境准备"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {},
+      "outputs": [],
+      "source": [
+        "import os\n",
+        "import sys\n",
+        "from pathlib import Path\n",
+        "\n",
+        "# 项目根目录 = 本 notebook 所在目录\n",
+        "ROOT = Path.cwd().resolve()\n",
+        "BACKEND = ROOT / \"backend\"\n",
+        "assert (BACKEND / \"app\").exists(), f\"找不到 backend/app,请在项目根目录打开 notebook(当前: {ROOT}\"\n",
+        "\n",
+        "sys.path.insert(0, str(BACKEND))\n",
+        "\n",
+        "# 优先加载 backend/.env\n",
+        "from dotenv import load_dotenv\n",
+        "\n",
+        "env_path = BACKEND / \".env\"\n",
+        "if not env_path.exists():\n",
+        "    raise FileNotFoundError(\n",
+        "        f\"未找到 {env_path}\\n\"\n",
+        "        \"请执行: copy .env.example backend\\\\.env  并填入 TMDB / LLM 密钥\"\n",
+        "    )\n",
+        "load_dotenv(env_path)\n",
+        "\n",
+        "print(\"ROOT   :\", ROOT)\n",
+        "print(\"BACKEND:\", BACKEND)\n",
+        "print(\"TMDB   :\", \"已配置\" if (os.getenv(\"TMDB_ACCESS_TOKEN\") or os.getenv(\"TMDB_API_KEY\")) else \"缺失\")\n",
+        "print(\"LLM    :\", \"已配置\" if os.getenv(\"LLM_API_KEY\") else \"缺失\")\n",
+        "print(\"MODEL  :\", os.getenv(\"LLM_MODEL_ID\") or \"(未设置)\")"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "---\n",
+        "\n",
+        "## 第 2 部分:构造推荐请求"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {},
+      "outputs": [],
+      "source": [
+        "from app.models.schemas import RecommendRequest\n",
+        "\n",
+        "request = RecommendRequest(\n",
+        "    mood=\"放松\",\n",
+        "    party_type=\"独自\",\n",
+        "    genres=[\"剧情\", \"喜剧\"],\n",
+        "    max_runtime_minutes=120,\n",
+        "    region_preference=\"不限\",\n",
+        "    year_preference=\"近10年\",\n",
+        "    exclude_titles=[],\n",
+        "    spoilers_ok=False,\n",
+        "    free_text=\"不要太沉重,适合周末晚上\",\n",
+        "    exclude_ids=[],\n",
+        ")\n",
+        "\n",
+        "print(request.model_dump_json(indent=2, ensure_ascii=False))"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "---\n",
+        "\n",
+        "## 第 3 部分:运行多智能体推荐流水线\n",
+        "\n",
+        "> 完整一次大约 40–60 秒,请耐心等待。"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {},
+      "outputs": [],
+      "source": [
+        "from app.agents.movie_recommender_agent import MultiAgentMovieRecommender\n",
+        "\n",
+        "recommender = MultiAgentMovieRecommender()\n",
+        "result, trace_id = recommender.recommend(request)\n",
+        "\n",
+        "print(\"trace_id:\", trace_id)\n",
+        "print(\"fallback:\", result.fallback)\n",
+        "if result.taste_profile:\n",
+        "    print(\"画像摘要:\", result.taste_profile.summary)\n",
+        "    print(\"类型线索:\", result.taste_profile.genre_hints)\n",
+        "print(f\"推荐数量: {len(result.movies)}\")\n",
+        "print(\"=\" * 50)\n",
+        "for i, m in enumerate(result.movies, 1):\n",
+        "    print(f\"{i}. {m.title} ({m.year or '?'})  评分={m.rating}\")\n",
+        "    print(f\"   理由: {m.reason}\")\n",
+        "    print(f\"   海报: {m.poster_url}\")\n",
+        "    print()"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "---\n",
+        "\n",
+        "## 第 4 部分(可选):仅测 TMDB 连通性"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {},
+      "outputs": [],
+      "source": [
+        "from app.services.movie_service import get_movie_service\n",
+        "\n",
+        "svc = get_movie_service()\n",
+        "movies = svc.discover(with_genres=\"喜剧\", sort_by=\"popularity.desc\", page=1)\n",
+        "print(f\"discover 返回 {len(movies)} 部,前 5 部:\")\n",
+        "for m in movies[:5]:\n",
+        "    print(f\"- {m.id} | {m.title} | {m.year} | {m.rating}\")"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "---\n",
+        "\n",
+        "## 总结\n",
+        "\n",
+        "- 流水线:画像 → 检索(TMDB Tool) → 推荐(id 白名单)  \n",
+        "- Web 形态:`backend` FastAPI + `frontend` React  \n",
+        "- 若 TMDB 连接超时(WinError 10060),请检查代理/VPN 后重试"
+      ]
+    }
+  ],
+  "metadata": {
+    "kernelspec": {
+      "display_name": "Python 3",
+      "language": "python",
+      "name": "python3"
+    },
+    "language_info": {
+      "name": "python",
+      "pygments_lexer": "ipython3"
+    }
+  },
+  "nbformat": 4,
+  "nbformat_minor": 5
+}

+ 8 - 0
Co-creation-projects/aatanxiao12-beep-YingQian/requirements.txt

@@ -0,0 +1,8 @@
+hello-agents>=1.0.0
+fastapi>=0.141.1
+httpx>=0.28.1
+pydantic>=2.13.4
+pydantic-settings>=2.14.2
+python-dotenv>=1.2.2
+uvicorn[standard]>=0.52.0
+pytest>=9.1.1

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác