[
  {
    "path": ".cursor/commands/agno_agent.md",
    "content": "# Agno Agent Development Quick Commands\n\n## Overview\n\nThis guide covers development of **Agno Agent Tools** - the AI-powered todo management toolkit based on the [Agno framework](https://docs.agno.com/).\n\nThe FreeTodoToolkit provides a set of tools for the Agno Agent to manage todos. For the complete list of available tools, please refer to the source code in `llm/agno_tools/tools/` directory.\n\n---\n\n## 🏗️ Architecture\n\n### Directory Structure\n\n```\nlifetrace/\n├── config/prompts/agno_tools/     # Localized messages & prompts\n│   ├── zh/                        # Chinese messages\n│   └── en/                        # English messages (same structure)\n│\n├── llm/agno_tools/                # Python implementation\n│   ├── __init__.py                # Module exports\n│   ├── base.py                    # Message loader (AgnoToolsMessageLoader)\n│   ├── toolkit.py                 # Main FreeTodoToolkit class\n│   └── tools/                     # Individual tool implementations (organized by category)\n│\n└── observability/                 # Agent monitoring (Phoenix + OpenInference)\n    ├── __init__.py                # Module exports\n    ├── config.py                  # Observability configuration\n    ├── setup.py                   # Initialization entry point\n    └── exporters/\n        ├── __init__.py\n        └── file_exporter.py       # Local JSON file exporter\n```\n\n### Design Patterns\n\n- **Mixin Pattern**: Each tool category is a separate mixin class\n- **Composition**: FreeTodoToolkit inherits from all mixins + Agno Toolkit\n- **i18n**: Messages loaded from language-specific YAML files\n- **Lazy Loading**: Database and LLM clients initialized on-demand\n\n---\n\n## 🔧 Adding a New Tool\n\n### Step 1: Add Messages (Both Languages)\n\nCreate or update YAML files in `config/prompts/agno_tools/zh/` and `en/`:\n\n```yaml\n# config/prompts/agno_tools/zh/my_tool.yaml\nmy_tool_success: \"操作成功: {result}\"\nmy_tool_failed: \"操作失败: {error}\"\nmy_tool_prompt: |\n  这是给 LLM 的提示词模板。\n  参数: {param}\n```\n\n```yaml\n# config/prompts/agno_tools/en/my_tool.yaml\nmy_tool_success: \"Operation successful: {result}\"\nmy_tool_failed: \"Operation failed: {error}\"\nmy_tool_prompt: |\n  This is a prompt template for LLM.\n  Parameter: {param}\n```\n\n### Step 2: Create Tool Mixin\n\nCreate a new file in `llm/agno_tools/tools/`:\n\n```python\n# llm/agno_tools/tools/my_tools.py\n\"\"\"My Tools - Description of what these tools do.\"\"\"\n\nfrom __future__ import annotations\nfrom typing import TYPE_CHECKING\n\nfrom lifetrace.llm.agno_tools.base import get_message\nfrom lifetrace.util.logging_config import get_logger\n\nif TYPE_CHECKING:\n    from lifetrace.repositories.sql_todo_repository import SqlTodoRepository\n\nlogger = get_logger()\n\n\nclass MyTools:\n    \"\"\"My tools mixin\"\"\"\n\n    lang: str\n    todo_repo: \"SqlTodoRepository\"  # If needed\n\n    def _msg(self, key: str, **kwargs) -> str:\n        return get_message(self.lang, key, **kwargs)\n\n    def my_tool_method(self, param: str) -> str:\n        \"\"\"Tool description for LLM to understand when to use it\n\n        Args:\n            param: Description of the parameter\n\n        Returns:\n            Result message\n        \"\"\"\n        try:\n            # Implementation\n            result = f\"processed {param}\"\n            return self._msg(\"my_tool_success\", result=result)\n        except Exception as e:\n            logger.error(f\"Failed: {e}\")\n            return self._msg(\"my_tool_failed\", error=str(e))\n```\n\n### Step 3: Register in Toolkit\n\nUpdate `llm/agno_tools/tools/__init__.py`:\n\n```python\nfrom lifetrace.llm.agno_tools.tools.my_tools import MyTools\n\n__all__ = [..., \"MyTools\"]\n```\n\nUpdate `llm/agno_tools/toolkit.py`:\n\n```python\nfrom lifetrace.llm.agno_tools.tools import (\n    ...,\n    MyTools,\n)\n\nclass FreeTodoToolkit(\n    ...,\n    MyTools,  # Add mixin\n    Toolkit,\n):\n    def __init__(self, lang: str = \"en\", **kwargs):\n        ...\n        tools = [\n            ...,\n            self.my_tool_method,  # Register tool\n        ]\n```\n\n---\n\n## 📝 Message Configuration\n\n### YAML Structure\n\nMessages are organized by functionality in `config/prompts/agno_tools/{lang}/` directory. Each YAML file corresponds to a category of messages.\n\n### Message Format\n\n- Use `{placeholder}` for variable substitution\n- Multi-line prompts use YAML `|` syntax\n- Keep messages concise and informative\n\n```yaml\n# Simple message with placeholder\ncreate_success: \"Created todo #{id}: {name}\"\n\n# Multi-line prompt\nbreakdown_prompt: |\n  Break down this task into subtasks.\n\n  Task: {task_description}\n\n  Return JSON format.\n```\n\n### Accessing Messages\n\n```python\n# In tool methods\ndef _msg(self, key: str, **kwargs) -> str:\n    return get_message(self.lang, key, **kwargs)\n\n# Usage\nreturn self._msg(\"create_success\", id=123, name=\"Buy groceries\")\n```\n\n---\n\n## 🌐 Internationalization\n\n### Language Selection\n\nLanguage is passed through the call chain:\n\n```\nRequest Header (Accept-Language)\n    ↓\nChat Router (get_request_language)\n    ↓\nAgnoAgentService(lang=lang)\n    ↓\nFreeTodoToolkit(lang=lang)\n    ↓\nAgnoToolsMessageLoader(lang)\n```\n\n### Adding a New Language\n\n1. Create new directory: `config/prompts/agno_tools/{lang}/`\n2. Copy all YAML files from `en/`\n3. Translate all messages\n4. The loader will automatically detect the new language\n\n---\n\n## 🧪 Testing Tools\n\n### Quick Test Script\n\n```python\nfrom lifetrace.llm.agno_tools import FreeTodoToolkit\n\n# Test Chinese\ntoolkit_zh = FreeTodoToolkit(lang=\"zh\")\nprint(toolkit_zh.list_todos(status=\"active\", limit=5))\n\n# Test English\ntoolkit_en = FreeTodoToolkit(lang=\"en\")\nprint(toolkit_en.list_todos(status=\"active\", limit=5))\n```\n\n### Running Tests\n\n```bash\nuv run python -c \"\nfrom lifetrace.llm.agno_tools import FreeTodoToolkit\ntk = FreeTodoToolkit(lang='zh')\nprint(tk.parse_time('明天下午3点'))\n\"\n```\n\n---\n\n## 📊 Observability (Agent Monitoring)\n\nThe Agno Agent integrates with [Arize Phoenix](https://arize.com/docs/phoenix) + [OpenInference](https://github.com/arize-ai/openinference) for tracing and monitoring.\n\n### Features\n\n- **Local JSON Export**: Cursor-friendly trace files for AI analysis\n- **Phoenix UI**: Optional web-based visualization\n- **Minimal Terminal Output**: One-line summary per trace\n\n### Configuration\n\nIn `config/config.yaml`:\n\n```yaml\nobservability:\n  enabled: true                    # Enable observability\n  mode: both                       # local | phoenix | both\n  local:\n    traces_dir: traces/            # Trace file directory\n    max_files: 100                 # Max files to keep\n    pretty_print: true             # Format JSON for readability\n  phoenix:\n    endpoint: http://localhost:6006\n    project_name: freetodo-agent\n  terminal:\n    summary_only: true             # One-line output (recommended)\n```\n\n### Trace File Format\n\nEach agent run generates a JSON file in `data/traces/`:\n\n```json\n{\n  \"trace_id\": \"e078e147372a\",\n  \"timestamp\": \"2026-01-23T08:23:48.377470+00:00\",\n  \"duration_ms\": 26910.94,\n  \"agent\": \"breakdown_task\",\n  \"input\": \"{\\\"task_description\\\": \\\"Make a video\\\"}\",\n  \"output_preview\": \"Task breakdown:\\n1. Define topic...\",\n  \"tool_calls\": [\n    {\n      \"name\": \"breakdown_task\",\n      \"args\": {\"task_description\": \"Make a video\"},\n      \"result_preview\": \"Task breakdown...\",\n      \"duration_ms\": 26910.94\n    }\n  ],\n  \"llm_calls\": [],\n  \"status\": \"success\",\n  \"span_count\": 1\n}\n```\n\n### Terminal Output\n\nWith `summary_only: true`:\n\n```\n[Trace] e078e147372a | 1 tools | 26.91s | traces/20260123_082348_e078e147372a.json\n```\n\n### Using Phoenix UI (Optional)\n\n```bash\n# Start Phoenix server\nuv run phoenix serve\n\n# Access http://localhost:6006\n```\n\n---\n\n## ✅ Development Checklist\n\nWhen adding new tools:\n\n- [ ] Create YAML messages in both `zh/` and `en/` directories\n- [ ] Create tool mixin class with proper type hints\n- [ ] Add docstrings for LLM to understand tool usage\n- [ ] Use `_msg()` for all user-facing messages\n- [ ] Handle exceptions and return error messages\n- [ ] Register tool in `tools/__init__.py`\n- [ ] Add mixin to `FreeTodoToolkit` class\n- [ ] Register method in `tools` list\n- [ ] Test with both languages\n"
  },
  {
    "path": ".cursor/commands/agno_agent_CN.md",
    "content": "# Agno Agent 开发快捷命令\n\n## 概述\n\n本指南涵盖 **Agno Agent Tools** 的开发 - 基于 [Agno 框架](https://docs.agno.com/) 的 AI 待办管理工具包。\n\nFreeTodoToolkit 为 Agno Agent 提供一系列工具，用于管理待办事项。具体工具列表请查阅 `llm/agno_tools/tools/` 目录下的源代码。\n\n---\n\n## 🏗️ 架构\n\n### 目录结构\n\n```\nlifetrace/\n├── config/prompts/agno_tools/     # 本地化消息和提示词\n│   ├── zh/                        # 中文消息\n│   └── en/                        # 英文消息（结构相同）\n│\n├── llm/agno_tools/                # Python 实现\n│   ├── __init__.py                # 模块导出\n│   ├── base.py                    # 消息加载器 (AgnoToolsMessageLoader)\n│   ├── toolkit.py                 # 主 FreeTodoToolkit 类\n│   └── tools/                     # 各工具实现（按功能分类）\n│\n└── observability/                 # Agent 监控（Phoenix + OpenInference）\n    ├── __init__.py                # 模块导出\n    ├── config.py                  # 观测配置\n    ├── setup.py                   # 初始化入口\n    └── exporters/\n        ├── __init__.py\n        └── file_exporter.py       # 本地 JSON 文件导出器\n```\n\n### 设计模式\n\n- **Mixin 模式**：每个工具类别是独立的 mixin 类\n- **组合模式**：FreeTodoToolkit 继承所有 mixin + Agno Toolkit\n- **国际化**：消息从语言特定的 YAML 文件加载\n- **懒加载**：数据库和 LLM 客户端按需初始化\n\n---\n\n## 🔧 添加新工具\n\n### 步骤 1：添加消息（中英文）\n\n在 `config/prompts/agno_tools/zh/` 和 `en/` 中创建或更新 YAML 文件：\n\n```yaml\n# config/prompts/agno_tools/zh/my_tool.yaml\nmy_tool_success: \"操作成功: {result}\"\nmy_tool_failed: \"操作失败: {error}\"\nmy_tool_prompt: |\n  这是给 LLM 的提示词模板。\n  参数: {param}\n```\n\n```yaml\n# config/prompts/agno_tools/en/my_tool.yaml\nmy_tool_success: \"Operation successful: {result}\"\nmy_tool_failed: \"Operation failed: {error}\"\nmy_tool_prompt: |\n  This is a prompt template for LLM.\n  Parameter: {param}\n```\n\n### 步骤 2：创建工具 Mixin\n\n在 `llm/agno_tools/tools/` 中创建新文件：\n\n```python\n# llm/agno_tools/tools/my_tools.py\n\"\"\"My Tools - 这些工具的功能描述\"\"\"\n\nfrom __future__ import annotations\nfrom typing import TYPE_CHECKING\n\nfrom lifetrace.llm.agno_tools.base import get_message\nfrom lifetrace.util.logging_config import get_logger\n\nif TYPE_CHECKING:\n    from lifetrace.repositories.sql_todo_repository import SqlTodoRepository\n\nlogger = get_logger()\n\n\nclass MyTools:\n    \"\"\"My tools mixin\"\"\"\n\n    lang: str\n    todo_repo: \"SqlTodoRepository\"  # 如果需要\n\n    def _msg(self, key: str, **kwargs) -> str:\n        return get_message(self.lang, key, **kwargs)\n\n    def my_tool_method(self, param: str) -> str:\n        \"\"\"工具描述，让 LLM 理解何时使用此工具\n\n        Args:\n            param: 参数描述\n\n        Returns:\n            结果消息\n        \"\"\"\n        try:\n            # 实现逻辑\n            result = f\"processed {param}\"\n            return self._msg(\"my_tool_success\", result=result)\n        except Exception as e:\n            logger.error(f\"Failed: {e}\")\n            return self._msg(\"my_tool_failed\", error=str(e))\n```\n\n### 步骤 3：注册到 Toolkit\n\n更新 `llm/agno_tools/tools/__init__.py`：\n\n```python\nfrom lifetrace.llm.agno_tools.tools.my_tools import MyTools\n\n__all__ = [..., \"MyTools\"]\n```\n\n更新 `llm/agno_tools/toolkit.py`：\n\n```python\nfrom lifetrace.llm.agno_tools.tools import (\n    ...,\n    MyTools,\n)\n\nclass FreeTodoToolkit(\n    ...,\n    MyTools,  # 添加 mixin\n    Toolkit,\n):\n    def __init__(self, lang: str = \"en\", **kwargs):\n        ...\n        tools = [\n            ...,\n            self.my_tool_method,  # 注册工具\n        ]\n```\n\n---\n\n## 📝 消息配置\n\n### YAML 结构\n\n消息按功能组织在 `config/prompts/agno_tools/{lang}/` 目录下。每个 YAML 文件对应一类功能的消息。\n\n### 消息格式\n\n- 使用 `{placeholder}` 进行变量替换\n- 多行提示词使用 YAML `|` 语法\n- 保持消息简洁且信息丰富\n\n```yaml\n# 带占位符的简单消息\ncreate_success: \"成功创建待办 #{id}: {name}\"\n\n# 多行提示词\nbreakdown_prompt: |\n  请将此任务拆解为子任务。\n\n  任务: {task_description}\n\n  返回 JSON 格式。\n```\n\n### 访问消息\n\n```python\n# 在工具方法中\ndef _msg(self, key: str, **kwargs) -> str:\n    return get_message(self.lang, key, **kwargs)\n\n# 使用\nreturn self._msg(\"create_success\", id=123, name=\"买菜\")\n```\n\n---\n\n## 🌐 国际化\n\n### 语言选择\n\n语言通过调用链传递：\n\n```\n请求头 (Accept-Language)\n    ↓\nChat Router (get_request_language)\n    ↓\nAgnoAgentService(lang=lang)\n    ↓\nFreeTodoToolkit(lang=lang)\n    ↓\nAgnoToolsMessageLoader(lang)\n```\n\n### 添加新语言\n\n1. 创建新目录：`config/prompts/agno_tools/{lang}/`\n2. 从 `en/` 复制所有 YAML 文件\n3. 翻译所有消息\n4. 加载器会自动检测新语言\n\n---\n\n## 🧪 测试工具\n\n### 快速测试脚本\n\n```python\nfrom lifetrace.llm.agno_tools import FreeTodoToolkit\n\n# 测试中文\ntoolkit_zh = FreeTodoToolkit(lang=\"zh\")\nprint(toolkit_zh.list_todos(status=\"active\", limit=5))\n\n# 测试英文\ntoolkit_en = FreeTodoToolkit(lang=\"en\")\nprint(toolkit_en.list_todos(status=\"active\", limit=5))\n```\n\n### 运行测试\n\n```bash\nuv run python -c \"\nfrom lifetrace.llm.agno_tools import FreeTodoToolkit\ntk = FreeTodoToolkit(lang='zh')\nprint(tk.parse_time('明天下午3点'))\n\"\n```\n\n---\n\n## 📊 可观测性（Agent 监控）\n\nAgno Agent 集成了 [Arize Phoenix](https://arize.com/docs/phoenix) + [OpenInference](https://github.com/arize-ai/openinference) 进行链路追踪和监控。\n\n### 功能特性\n\n- **本地 JSON 导出**：Cursor 友好的 trace 文件，便于 AI 分析\n- **Phoenix UI**：可选的 Web 可视化界面\n- **精简终端输出**：每次 trace 仅输出一行摘要\n\n### 配置方法\n\n在 `config/config.yaml` 中：\n\n```yaml\nobservability:\n  enabled: true                    # 启用观测功能\n  mode: both                       # local | phoenix | both\n  local:\n    traces_dir: traces/            # trace 文件目录\n    max_files: 100                 # 最大保留文件数\n    pretty_print: true             # 格式化 JSON 便于阅读\n  phoenix:\n    endpoint: http://localhost:6006\n    project_name: freetodo-agent\n  terminal:\n    summary_only: true             # 仅输出一行摘要（推荐）\n```\n\n### Trace 文件格式\n\n每次 Agent 运行会在 `data/traces/` 生成一个 JSON 文件：\n\n```json\n{\n  \"trace_id\": \"e078e147372a\",\n  \"timestamp\": \"2026-01-23T08:23:48.377470+00:00\",\n  \"duration_ms\": 26910.94,\n  \"agent\": \"breakdown_task\",\n  \"input\": \"{\\\"task_description\\\": \\\"做视频\\\"}\",\n  \"output_preview\": \"任务拆解结果:\\n1. 确定视频主题...\",\n  \"tool_calls\": [\n    {\n      \"name\": \"breakdown_task\",\n      \"args\": {\"task_description\": \"做视频\"},\n      \"result_preview\": \"任务拆解结果...\",\n      \"duration_ms\": 26910.94\n    }\n  ],\n  \"llm_calls\": [],\n  \"status\": \"success\",\n  \"span_count\": 1\n}\n```\n\n### 终端输出\n\n启用 `summary_only: true` 时：\n\n```\n[Trace] e078e147372a | 1 tools | 26.91s | traces/20260123_082348_e078e147372a.json\n```\n\n### 使用 Phoenix UI（可选）\n\n```bash\n# 启动 Phoenix 服务\nuv run phoenix serve\n\n# 访问 http://localhost:6006\n```\n\n---\n\n## ✅ 开发检查清单\n\n添加新工具时：\n\n- [ ] 在 `zh/` 和 `en/` 目录中创建 YAML 消息\n- [ ] 创建带有正确类型提示的工具 mixin 类\n- [ ] 添加文档字符串让 LLM 理解工具用途\n- [ ] 所有用户可见消息使用 `_msg()`\n- [ ] 处理异常并返回错误消息\n- [ ] 在 `tools/__init__.py` 中注册工具\n- [ ] 将 mixin 添加到 `FreeTodoToolkit` 类\n- [ ] 在 `tools` 列表中注册方法\n- [ ] 使用两种语言测试\n"
  },
  {
    "path": ".cursor/commands/backend.md",
    "content": "# Backend Development Quick Commands (lifetrace version)\n\n## Tech Stack Information\n\n- **Framework**: FastAPI + Uvicorn (async web framework)\n- **Language**: Python 3.12\n- **ORM**: SQLAlchemy 2.x + SQLModel\n- **Database Migration**: Alembic\n- **Data Validation**: Pydantic 2.x\n- **Configuration Management**: Dynaconf (supports YAML hot reload)\n- **Logging**: Loguru\n- **Scheduler**: APScheduler (background task scheduling)\n- **OCR**: RapidOCR (local OCR recognition)\n- **Vector Database**: ChromaDB (optional, for semantic search)\n- **Text Embedding**: sentence-transformers (optional)\n- **LLM**: OpenAI-compatible API\n- **Package Manager**: uv (recommended)\n- **Code Quality**: Ruff (lint/format/check)\n\n---\n\n## 🏗️ Project Architecture\n\n```\nlifetrace/\n├── server.py                 # FastAPI application entry point\n├── config/                   # Configuration files directory\n│   ├── config.yaml          # User configuration\n│   ├── default_config.yaml  # Default configuration\n│   └── prompt.yaml          # LLM Prompt templates\n├── routers/                  # API routing layer\n├── services/                 # Business service layer\n├── repositories/             # Data access layer (Repository pattern)\n├── schemas/                  # Pydantic data models\n├── storage/                  # Data storage layer (SQLAlchemy models)\n├── llm/                      # LLM and AI services\n├── jobs/                     # Background tasks\n├── core/                     # Core dependencies and lazy-loaded services\n└── util/                     # Utility functions\n```\n\n### Layered Architecture Overview\n\n- **Router Layer**: Handles HTTP requests, parameter validation, calls Service layer\n- **Service Layer**: Business logic, orchestrates multiple Repository operations\n- **Repository Layer**: Data access abstraction, encapsulates database queries\n- **Schema Layer**: Request/response Pydantic models\n- **Storage Layer**: SQLAlchemy ORM model definitions\n\n---\n\n## 🔧 Route Development\n\n### Creating New API Routes\n\nCreate new routes in the `lifetrace/routers/` directory:\n- Use `APIRouter` to define route prefixes and tags\n- Follow RESTful API design principles\n- Use dependency injection to get database sessions\n- Add complete type annotations and docstrings\n\n### RESTful Route Conventions\n\n- `GET /api/{resource}` - Get list\n- `GET /api/{resource}/{id}` - Get single resource\n- `POST /api/{resource}` - Create resource\n- `PUT /api/{resource}/{id}` - Full update\n- `PATCH /api/{resource}/{id}` - Partial update\n- `DELETE /api/{resource}/{id}` - Delete resource\n\n### Registering Routes\n\nImport and register new routes in `server.py`:\n- Use `app.include_router(xxx.router)` to register\n- Routes organized by functional modules\n\n---\n\n## 📦 Data Models\n\n### Pydantic Schema Conventions\n\nCreate data models in the `lifetrace/schemas/` directory:\n- Use Pydantic v2 syntax\n- Distinguish models for different scenarios: `Create`, `Update`, `Response`, etc.\n- Use `Field()` to add validation rules and descriptions\n- Enable `model_config = ConfigDict(from_attributes=True)` to support ORM conversion\n\n### Common Model Patterns\n\n- `{Resource}Create` - Request body for creation\n- `{Resource}Update` - Request body for updates (fields typically Optional)\n- `{Resource}Response` - API response format\n- `{Resource}List` - List response (includes pagination info)\n\n### SQLAlchemy Model Conventions\n\nDefine database tables in `lifetrace/storage/models.py`:\n- Use SQLAlchemy 2.x declarative syntax\n- Add indexes for commonly queried fields\n- Use relationships to define table associations\n- Add `created_at` and `updated_at` timestamp fields\n\n---\n\n## 🗄️ Repository Layer\n\n### Creating Repositories\n\nCreate data access classes in the `lifetrace/repositories/` directory:\n- Inherit or implement interfaces defined in `interfaces.py`\n- Encapsulate all database query logic\n- Use async methods (`async def`)\n- Support parameterized queries to prevent SQL injection\n\n### Repository Naming Conventions\n\n- `sql_{resource}_repository.py` - SQL database implementation\n- Class names use `{Resource}Repository` format\n\n---\n\n## 🎯 Service Layer\n\n### Creating Services\n\nCreate business services in the `lifetrace/services/` directory:\n- Implement complex business logic\n- Orchestrate multiple Repository operations\n- Handle transaction boundaries\n- Call external services (LLM, OCR, etc.)\n\n### Service Conventions\n\n- Class names use `{Resource}Service` format\n- Get Repository instances through dependency injection\n- Use custom Exception classes for business exceptions\n- Add detailed logging\n\n---\n\n## 🤖 LLM Services\n\n### LLM Client Usage\n\nThe project uses OpenAI-compatible APIs, encapsulated via `llm/llm_client.py`:\n- Supports Alibaba Cloud Tongyi Qianwen, OpenAI, Claude, etc.\n- Configuration managed through the `llm` section in `config/config.yaml`\n- Supports streaming responses (SSE)\n\n### RAG Service\n\n`llm/rag_service.py` provides Retrieval-Augmented Generation:\n- Smart time parsing (e.g., \"last week\", \"yesterday\")\n- Hybrid retrieval strategy (vector search + full-text search)\n- Context compression and ranking\n\n### Prompt Management\n\nPrompt templates are stored in `config/prompt.yaml`:\n- Use YAML format for easy maintenance\n- Support variable interpolation\n- Organized by functional modules\n\n### Agno Agent\n\n`llm/agno_agent.py` provides AI-powered todo management via [Agno framework](https://docs.agno.com/):\n- FreeTodoToolkit with 14 tools (CRUD, breakdown, time parsing, etc.)\n- Internationalization support (zh/en)\n- Mixin-based architecture for extensibility\n\nSee `.cursor/commands/agno_agent.md` for detailed development guide.\n\n---\n\n## ⏰ Background Tasks\n\n### Task Scheduling\n\nUse APScheduler to manage background tasks:\n- Tasks defined in `lifetrace/jobs/` directory\n- Managed uniformly through `job_manager.py`\n- Supports scheduled tasks and interval tasks\n\n### Task Types\n\n- **recorder**: Screen recorder, scheduled screenshots\n- **ocr**: OCR processor, processes screenshots awaiting recognition\n\n---\n\n## ⚙️ Configuration Management\n\n### Configuration File Structure\n\n- `config/default_config.yaml` - Default configuration (do not modify)\n- `config/config.yaml` - User configuration (overrides default values)\n- Uses Dynaconf to support configuration hot reload\n\n### Accessing Configuration\n\nAccess through the `settings` object in `util/settings.py`:\n- `settings.server.port` - Access nested configuration\n- `settings.get(\"key\", default)` - Access with default value\n\n### Configuration Hot Reload\n\nThe following configurations support hot reload (no restart required):\n- LLM configuration\n- Recording configuration\n- OCR configuration\n\n---\n\n## 📝 Logging\n\n### Using Loguru\n\nImport logger from `util/logging_config.py`:\n- `logger.info()` - General information\n- `logger.warning()` - Warning information\n- `logger.error()` - Error information\n- `logger.debug()` - Debug information\n\n### Logging Conventions\n\n- Critical operations must be logged\n- Exceptions must log full stack traces\n- Sensitive information (API Keys, etc.) must be sanitized\n- Use structured logging for easier analysis\n\n---\n\n## 🗃️ Database Migration\n\n### Using Alembic\n\nThe project uses Alembic to manage database migrations:\n- Configuration file: `alembic.ini`\n- Migration scripts: `migrations/versions/`\n\n### Common Commands\n\n- `alembic revision --autogenerate -m \"description\"` - Generate migration script\n- `alembic upgrade head` - Apply all migrations\n- `alembic downgrade -1` - Rollback one version\n- `alembic history` - View migration history\n\n---\n\n## 🧪 Code Quality\n\n### Ruff Checking and Formatting\n\nThe project uses Ruff for code checking and formatting:\n- `uv run ruff check .` - Check code\n- `uv run ruff check --fix .` - Auto-fix issues\n- `uv run ruff format .` - Format code\n\n### Code Standards\n\n- Follow PEP 8 style guide\n- Maximum 100 characters per line\n- Maximum 500 lines per file (warning threshold 700 lines)\n- Maximum 50 statements per function\n- Cyclomatic complexity should not exceed 15\n\n---\n\n## 🔐 Error Handling\n\n### HTTP Exceptions\n\nUse FastAPI's `HTTPException`:\n- `400` - Request parameter error\n- `404` - Resource not found\n- `422` - Validation error (automatically handled by Pydantic)\n- `500` - Internal server error\n\n### Exception Handling Conventions\n\n- Catch specific exceptions, avoid catching all exceptions\n- Log errors with context\n- Return user-friendly error messages\n- Do not expose sensitive information to clients\n\n---\n\n## 🚀 Performance Optimization\n\n### Database Query Optimization\n\n- Use `selectinload` to avoid N+1 queries\n- Add indexes for commonly queried fields\n- Use pagination to limit returned data\n- Use batch operations instead of looping single operations\n\n### Async Processing\n\n- Use `async/await` for I/O operations\n- Use async sessions for database queries\n- Use async clients for external API calls\n\n### Lazy Loading\n\n- Large services (vector service, OCR) use lazy loading\n- Initialize on-demand through `core/lazy_services.py`\n- Avoid loading all dependencies at startup\n\n---\n\n## 📡 API and Frontend Interaction\n\n### Naming Style Conversion\n\nBackend uses `snake_case`, frontend uses `camelCase`:\n- Frontend fetcher automatically converts\n- Backend Schema uniformly uses `snake_case`\n- OpenAPI Schema automatically generated by FastAPI\n\n### Frontend Code Generation\n\nFrontend uses Orval to automatically generate API code from OpenAPI Schema:\n- After backend API changes, frontend runs `pnpm orval` to regenerate\n- Ensure OpenAPI Schema is complete and accurate\n\n---\n\n## 📋 Dependency Management\n\n### Using uv\n\nThe project uses uv as package manager:\n- `uv sync` - Sync dependencies\n- `uv add <package>` - Add dependency\n- `uv remove <package>` - Remove dependency\n- `uv run <command>` - Run command in virtual environment\n\n### Dependency Groups\n\n- Main dependencies: `dependencies` in `pyproject.toml`\n- Development dependencies: `dependency-groups.dev`\n- Optional dependencies: `dependency-groups.vector` (vector search functionality)\n\n---\n\n## 🔍 Debugging and Troubleshooting\n\n### Starting Development Server\n\n- `python -m lifetrace.server` - Direct start\n- `uvicorn lifetrace.server:app --reload` - Hot reload mode\n\n### API Documentation\n\n- Swagger UI: `http://localhost:8001/docs`\n- ReDoc: `http://localhost:8001/redoc`\n- OpenAPI JSON: `http://localhost:8001/openapi.json`\n\n### Log Viewing\n\n- Log files located at `lifetrace/data/logs/`\n- View via API: `GET /api/logs`\n- Adjust log level: modify `logging.level` in `config/config.yaml`\n\n---\n\n## ✅ Code Review Checklist\n\nBefore submitting code, ensure:\n\n- [ ] Code follows PEP 8 style guide\n- [ ] Running `uv run ruff check .` produces no errors\n- [ ] Running `uv run ruff format .` to format code\n- [ ] All functions and classes have type annotations\n- [ ] All public functions and classes have docstrings\n- [ ] Appropriate error handling has been added\n- [ ] Parameterized queries are used to prevent SQL injection\n- [ ] Necessary logging has been added\n- [ ] Relevant documentation has been updated\n- [ ] API changes are reflected in OpenAPI Schema\n"
  },
  {
    "path": ".cursor/commands/backend_CN.md",
    "content": "# 后端开发快捷命令（lifetrace 版）\n\n## 技术栈信息\n\n- **框架**: FastAPI + Uvicorn（异步 Web 框架）\n- **语言**: Python 3.12\n- **ORM**: SQLAlchemy 2.x + SQLModel\n- **数据库迁移**: Alembic\n- **数据验证**: Pydantic 2.x\n- **配置管理**: Dynaconf（支持 YAML 热重载）\n- **日志**: Loguru\n- **调度器**: APScheduler（后台任务调度）\n- **OCR**: RapidOCR（本地 OCR 识别）\n- **向量数据库**: ChromaDB（可选，用于语义搜索）\n- **文本嵌入**: sentence-transformers（可选）\n- **LLM**: OpenAI 兼容 API\n- **包管理**: uv（推荐）\n- **代码质量**: Ruff（lint/format/check）\n\n---\n\n## 🏗️ 项目架构\n\n```\nlifetrace/\n├── server.py                 # FastAPI 应用入口\n├── config/                   # 配置文件目录\n│   ├── config.yaml          # 用户配置\n│   ├── default_config.yaml  # 默认配置\n│   └── prompt.yaml          # LLM Prompt 模板\n├── routers/                  # API 路由层\n├── services/                 # 业务服务层\n├── repositories/             # 数据访问层（Repository 模式）\n├── schemas/                  # Pydantic 数据模型\n├── storage/                  # 数据存储层（SQLAlchemy 模型）\n├── llm/                      # LLM 和 AI 服务\n├── jobs/                     # 后台任务\n├── core/                     # 核心依赖和懒加载服务\n└── util/                     # 工具函数\n```\n\n### 分层架构说明\n\n- **Router 层**：处理 HTTP 请求，参数验证，调用 Service 层\n- **Service 层**：业务逻辑，编排多个 Repository 操作\n- **Repository 层**：数据访问抽象，封装数据库查询\n- **Schema 层**：请求/响应的 Pydantic 模型\n- **Storage 层**：SQLAlchemy ORM 模型定义\n\n---\n\n## 🔧 路由开发\n\n### 创建新的 API 路由\n\n在 `lifetrace/routers/` 目录下创建新路由：\n- 使用 `APIRouter` 定义路由前缀和标签\n- 遵循 RESTful API 设计规范\n- 使用依赖注入获取数据库会话\n- 添加完整的类型注解和文档字符串\n\n### RESTful 路由规范\n\n- `GET /api/{resource}` - 获取列表\n- `GET /api/{resource}/{id}` - 获取单个资源\n- `POST /api/{resource}` - 创建资源\n- `PUT /api/{resource}/{id}` - 全量更新\n- `PATCH /api/{resource}/{id}` - 部分更新\n- `DELETE /api/{resource}/{id}` - 删除资源\n\n### 注册路由\n\n在 `server.py` 中导入并注册新路由：\n- 使用 `app.include_router(xxx.router)` 注册\n- 路由按功能模块组织\n\n---\n\n## 📦 数据模型\n\n### Pydantic Schema 规范\n\n在 `lifetrace/schemas/` 目录下创建数据模型：\n- 使用 Pydantic v2 语法\n- 区分 `Create`、`Update`、`Response` 等不同场景的模型\n- 使用 `Field()` 添加验证规则和描述\n- 启用 `model_config = ConfigDict(from_attributes=True)` 支持 ORM 转换\n\n### 常用模型模式\n\n- `{Resource}Create` - 创建时的请求体\n- `{Resource}Update` - 更新时的请求体（字段通常为 Optional）\n- `{Resource}Response` - API 响应格式\n- `{Resource}List` - 列表响应（包含分页信息）\n\n### SQLAlchemy 模型规范\n\n在 `lifetrace/storage/models.py` 中定义数据库表：\n- 使用 SQLAlchemy 2.x 声明式语法\n- 为常用查询字段添加索引\n- 使用关系（relationship）定义表关联\n- 添加 `created_at` 和 `updated_at` 时间戳字段\n\n---\n\n## 🗄️ Repository 层\n\n### 创建 Repository\n\n在 `lifetrace/repositories/` 目录下创建数据访问类：\n- 继承或实现 `interfaces.py` 中定义的接口\n- 封装所有数据库查询逻辑\n- 使用异步方法（`async def`）\n- 支持参数化查询，防止 SQL 注入\n\n### Repository 命名规范\n\n- `sql_{resource}_repository.py` - SQL 数据库实现\n- 类名使用 `{Resource}Repository` 格式\n\n---\n\n## 🎯 Service 层\n\n### 创建 Service\n\n在 `lifetrace/services/` 目录下创建业务服务：\n- 实现复杂的业务逻辑\n- 编排多个 Repository 操作\n- 处理事务边界\n- 调用外部服务（LLM、OCR 等）\n\n### Service 规范\n\n- 类名使用 `{Resource}Service` 格式\n- 通过依赖注入获取 Repository 实例\n- 业务异常使用自定义 Exception 类\n- 添加详细的日志记录\n\n---\n\n## 🤖 LLM 服务\n\n### LLM 客户端使用\n\n项目使用 OpenAI 兼容 API，通过 `llm/llm_client.py` 封装：\n- 支持阿里云通义千问、OpenAI、Claude 等\n- 配置通过 `config/config.yaml` 的 `llm` 部分管理\n- 支持流式响应（SSE）\n\n### RAG 服务\n\n`llm/rag_service.py` 提供检索增强生成：\n- 智能时间解析（如\"上周\"、\"昨天\"）\n- 混合检索策略（向量检索 + 全文检索）\n- 上下文压缩和排序\n\n### Prompt 管理\n\nPrompt 模板统一存放在 `config/prompt.yaml`：\n- 使用 YAML 格式便于维护\n- 支持变量插值\n- 按功能模块组织\n\n### Agno Agent\n\n`llm/agno_agent.py` 提供基于 [Agno 框架](https://docs.agno.com/) 的 AI 待办管理：\n- FreeTodoToolkit 包含 14 个工具（CRUD、任务拆解、时间解析等）\n- 国际化支持（中/英文）\n- 基于 Mixin 的可扩展架构\n\n详细开发指南见 `.cursor/commands/agno_agent_CN.md`。\n\n---\n\n## ⏰ 后台任务\n\n### 任务调度\n\n使用 APScheduler 管理后台任务：\n- 任务定义在 `lifetrace/jobs/` 目录\n- 通过 `job_manager.py` 统一管理\n- 支持定时任务和间隔任务\n\n### 任务类型\n\n- **recorder**: 屏幕录制器，定时截图\n- **ocr**: OCR 处理器，处理待识别的截图\n\n---\n\n## ⚙️ 配置管理\n\n### 配置文件结构\n\n- `config/default_config.yaml` - 默认配置（不要修改）\n- `config/config.yaml` - 用户配置（覆盖默认值）\n- 使用 Dynaconf 支持配置热重载\n\n### 访问配置\n\n通过 `util/settings.py` 中的 `settings` 对象访问：\n- `settings.server.port` - 访问嵌套配置\n- `settings.get(\"key\", default)` - 带默认值访问\n\n### 配置热重载\n\n以下配置支持热重载（无需重启）：\n- LLM 配置\n- 录制配置\n- OCR 配置\n\n---\n\n## 📝 日志记录\n\n### 使用 Loguru\n\n从 `util/logging_config.py` 导入 logger：\n- `logger.info()` - 普通信息\n- `logger.warning()` - 警告信息\n- `logger.error()` - 错误信息\n- `logger.debug()` - 调试信息\n\n### 日志规范\n\n- 关键操作必须记录日志\n- 异常必须记录完整堆栈\n- 敏感信息（API Key 等）必须脱敏\n- 使用结构化日志便于分析\n\n---\n\n## 🗃️ 数据库迁移\n\n### 使用 Alembic\n\n项目使用 Alembic 管理数据库迁移：\n- 配置文件：`alembic.ini`\n- 迁移脚本：`migrations/versions/`\n\n### 常用命令\n\n- `alembic revision --autogenerate -m \"描述\"` - 生成迁移脚本\n- `alembic upgrade head` - 应用所有迁移\n- `alembic downgrade -1` - 回滚一个版本\n- `alembic history` - 查看迁移历史\n\n---\n\n## 🧪 代码质量\n\n### Ruff 检查和格式化\n\n项目使用 Ruff 进行代码检查和格式化：\n- `uv run ruff check .` - 检查代码\n- `uv run ruff check --fix .` - 自动修复问题\n- `uv run ruff format .` - 格式化代码\n\n### 代码规范\n\n- 遵循 PEP 8 风格指南\n- 每行不超过 100 字符\n- 单个文件不超过 500 行（警戒线 700 行）\n- 单个函数不超过 50 条语句\n- 圈复杂度不超过 15\n\n---\n\n## 🔐 错误处理\n\n### HTTP 异常\n\n使用 FastAPI 的 `HTTPException`：\n- `400` - 请求参数错误\n- `404` - 资源不存在\n- `422` - 验证错误（Pydantic 自动处理）\n- `500` - 服务器内部错误\n\n### 异常处理规范\n\n- 捕获特定异常，避免捕获所有异常\n- 记录错误日志并包含上下文\n- 返回用户友好的错误信息\n- 敏感信息不要暴露给客户端\n\n---\n\n## 🚀 性能优化\n\n### 数据库查询优化\n\n- 使用 `selectinload` 避免 N+1 查询\n- 为常用查询字段添加索引\n- 使用分页限制返回数据量\n- 批量操作代替循环单条操作\n\n### 异步处理\n\n- 使用 `async/await` 处理 I/O 操作\n- 数据库查询使用异步会话\n- 外部 API 调用使用异步客户端\n\n### 懒加载\n\n- 大型服务（向量服务、OCR）使用懒加载\n- 通过 `core/lazy_services.py` 按需初始化\n- 避免启动时加载所有依赖\n\n---\n\n## 📡 API 与前端交互\n\n### 命名风格转换\n\n后端使用 `snake_case`，前端使用 `camelCase`：\n- 前端 fetcher 自动进行转换\n- 后端 Schema 统一使用 `snake_case`\n- OpenAPI Schema 由 FastAPI 自动生成\n\n### 前端代码生成\n\n前端使用 Orval 根据 OpenAPI Schema 自动生成 API 代码：\n- 后端 API 变更后，前端运行 `pnpm orval` 重新生成\n- 确保 OpenAPI Schema 完整且准确\n\n---\n\n## 📋 依赖管理\n\n### 使用 uv\n\n项目使用 uv 作为包管理器：\n- `uv sync` - 同步依赖\n- `uv add <package>` - 添加依赖\n- `uv remove <package>` - 移除依赖\n- `uv run <command>` - 在虚拟环境中运行命令\n\n### 依赖分组\n\n- 主依赖：`pyproject.toml` 的 `dependencies`\n- 开发依赖：`dependency-groups.dev`\n- 可选依赖：`dependency-groups.vector`（向量搜索功能）\n\n---\n\n## 🔍 调试和排查\n\n### 启动开发服务器\n\n- `python -m lifetrace.server` - 直接启动\n- `uvicorn lifetrace.server:app --reload` - 热重载模式\n\n### API 文档\n\n- Swagger UI: `http://localhost:8001/docs`\n- ReDoc: `http://localhost:8001/redoc`\n- OpenAPI JSON: `http://localhost:8001/openapi.json`\n\n### 日志查看\n\n- 日志文件位于 `lifetrace/data/logs/`\n- 通过 API 查看：`GET /api/logs`\n- 调整日志级别：修改 `config/config.yaml` 的 `logging.level`\n\n---\n\n## ✅ 代码检查清单\n\n在提交代码前，请确保：\n\n- [ ] 代码遵循 PEP 8 风格指南\n- [ ] 运行 `uv run ruff check .` 没有错误\n- [ ] 运行 `uv run ruff format .` 格式化代码\n- [ ] 所有函数和类都有类型注解\n- [ ] 所有公共函数和类都有文档字符串\n- [ ] 添加了适当的错误处理\n- [ ] 使用了参数化查询防止 SQL 注入\n- [ ] 添加了必要的日志记录\n- [ ] 更新了相关文档\n- [ ] API 变更已在 OpenAPI Schema 中反映\n"
  },
  {
    "path": ".cursor/commands/dynamic-island.md",
    "content": "# 灵动岛实现指南（Dynamic Island）\n\n## 概述\n\n灵动岛是一个悬浮 UI 组件，为 Electron 应用提供三种交互模式：\n- **FLOAT 模式**：小型悬浮岛，可拖拽，悬停时展开\n- **PANEL 模式**：可调整大小的面板窗口，显示单个功能\n- **MAXIMIZE 模式**：最大化工作台，显示完整的应用功能\n\n---\n\n## 🚀 实现原理与技术栈\n\n### 核心技术\n\n- **React 19 + TypeScript**：组件化开发，类型安全\n- **Framer Motion**：流畅的动画和布局过渡\n- **Electron IPC**：主进程与渲染进程通信\n- **CSS 注入**：动态修改窗口样式（透明度、圆角等）\n- **窗口管理 API**：`setIgnoreMouseEvents`、`setAlwaysOnTop`、`setBounds` 等\n\n### 全局常驻 Overlay 设计（新实现）\n\n- 灵动岛现在作为一个**全局常驻 overlay 层**存在：\n  - 最外层容器始终是 `position: fixed; inset: 0; pointer-events: none; z-index: 1000002`。\n  - 通过 `ref` 回调 + `requestAnimationFrame` 连续调用 `style.setProperty(..., 'important')`，确保上述属性不会被其他样式覆盖。\n- 三种模式（FLOAT / PANEL / MAXIMIZE）只是改变「内容层」（PanelWindow / 最大化页面）的布局和 Electron 窗口策略：\n  - 灵动岛的布局计算固定使用 `layoutMode = IslandMode.FLOAT`，保证拖拽位置和吸边逻辑在所有模式下统一。\n  - N 徽章等全局元素也应放在这一 overlay 层内，确保不会因为窗口变窄而被“挤进 Panel”。\n\n### Electron IPC 通信机制\n\n**IPC（Inter-Process Communication）** 是 Electron 中主进程（Main Process）和渲染进程（Renderer Process）之间通信的桥梁。\n\n**为什么需要 IPC？**\n- Electron 应用分为主进程和渲染进程，主进程负责窗口管理、系统 API 调用等，渲染进程负责 UI 渲染\n- 出于安全考虑，渲染进程无法直接调用 Node.js API 和 Electron 窗口 API\n- 需要通过 IPC 让渲染进程请求主进程执行窗口操作\n\n**在灵动岛中的使用**：\n- **渲染进程 → 主进程**：通过 `ipcRenderer.send()` 或 `ipcRenderer.invoke()` 发送请求\n  - `collapse-window`：请求折叠窗口到 FLOAT 模式\n  - `expand-window`：请求展开窗口到 PANEL 模式\n  - `expand-window-full`：请求展开窗口到 MAXIMIZE 模式\n  - `set-ignore-mouse-events`：请求设置点击穿透\n  - `move-window`：请求移动窗口位置\n- **主进程处理**：在 `electron/ipc-handlers.ts` 中注册处理器，执行实际的窗口操作\n  - 调用 `BrowserWindow` API 修改窗口属性\n  - 通过 `webContents.insertCSS()` 注入样式\n  - 执行窗口动画过渡\n\n**代码示例**：\n```typescript\n// 渲染进程（前端）\nconst api = getElectronAPI();\nawait api.electronAPI?.collapseWindow?.();\n\n// 主进程（electron/ipc-handlers.ts）\nipcMain.handle(\"collapse-window\", async () => {\n  const win = windowManager.getWindow();\n  // 执行窗口操作...\n});\n```\n\n### 实现总结\n\n灵动岛的实现通过以下技术组合完成：\n\n1. **通过 Electron IPC 通信**，让前端渲染进程请求主进程执行窗口操作（调整大小、位置、属性等）\n2. **通过 CSS 注入**，动态修改窗口样式（透明度、圆角、裁剪路径），实现视觉效果的平滑过渡\n3. **通过窗口动画**，使用缓动函数和定时器，以约 60fps 的频率更新窗口边界，实现平滑的尺寸变化\n4. **通过点击穿透管理**，在 FLOAT 模式下启用 `setIgnoreMouseEvents`，让窗口不阻挡桌面操作，同时通过 `forward: true` 保持鼠标事件检测\n5. **通过 Framer Motion**，在前端实现组件布局的平滑动画，配合窗口动画实现整体过渡效果\n6. **通过状态管理**，使用 Zustand store 管理模式状态，使用 React Context 在组件间共享功能状态\n7. **通过自定义 Hooks**，将拖拽、悬停检测、布局计算等逻辑封装，保持代码模块化和可维护性\n\n这种架构实现了窗口级别的动画（主进程控制）和组件级别的动画（渲染进程控制）的协同工作，创造出流畅的模式切换体验。\n\n### 关键技术点\n\n#### 1. 点击穿透（Click-Through）\n\n**实现方式（两层控制）**：\n\n- **渲染层 hook**：`components/dynamic-island/hooks/useDynamicIslandClickThrough.ts`\n  - 负责灵动岛本身在 FLOAT 模式下，依据悬停/拖拽状态切换局部 `pointer-events`。\n- **窗口层 hook**：`lib/hooks/useElectronClickThrough.ts`\n  - 统一调用 Electron 的 `setIgnoreMouseEvents`，根据模式和鼠标位置控制整窗是否穿透。\n\n**当前行为**：\n\n- **FLOAT 模式**：\n  - 窗口层：`setIgnoreMouseEvents(true, { forward: true })`，整窗穿透但仍可接收 `mousemove`。\n  - 渲染层：灵动岛在 hover/drag 时打开局部 `pointer-events`，实现“悬浮但可交互”。\n- **PANEL 模式**：\n  - 进入 PANEL 时立即 `setIgnoreMouseEvents(false)`，确保一开始就能点击 PanelWindow。\n  - 监听全局 `mousemove`，根据 `[data-panel-window]` 的 `getBoundingClientRect()`：\n    - 鼠标在 panel 内部（含顶部 8px 扩展区域）→ `setIgnoreMouseEvents(false)`。\n    - 鼠标在 panel 外部透明区域 → `setIgnoreMouseEvents(true, { forward: true })`。\n- **MAXIMIZE 模式**：\n  - 始终 `setIgnoreMouseEvents(false)`，整窗可交互。\n\n#### 2. 窗口动画过渡\n\n**实现方式**：\n- 使用 `easeOutCubic` 缓动函数实现平滑过渡\n- 通过 `setBounds()` 以约 60fps 的频率更新窗口边界\n- 动画期间通过 CSS 注入控制透明度，避免内容闪现\n\n**代码位置**：`electron/ipc-handlers.ts` 的 `animateWindowBounds` 函数\n\n```typescript\n// 缓动函数：easeOutCubic\nfunction easeOutCubic(t: number): number {\n  return 1 - (1 - t) ** 3;\n}\n\n// 动画循环：约 60fps\nsetTimeout(animate, 16);\n```\n\n#### 3. 拖拽实现\n\n**实现方式**：\n- 完全手动实现，不依赖 Electron 的 `setMovable`\n- 监听 `mousedown`、`mousemove`、`mouseup` 事件\n- 实时更新 DOM 位置，拖拽结束后通过 Framer Motion 平滑移动到吸附位置\n- 支持边缘吸附（50px 阈值）\n\n**代码位置**：`hooks/useDynamicIslandDrag.ts`\n\n**关键逻辑**：\n1. `mousedown`：记录起始位置，禁用点击穿透\n2. `mousemove`：计算新位置，限制在屏幕范围内\n3. `mouseup`：计算吸附位置，通过 `setPosition` 触发 Framer Motion 动画\n\n#### 4. 悬停检测\n\n**实现方式**：\n- 全局 `mousemove` 事件监听\n- 使用 `getBoundingClientRect()` 检测鼠标是否在区域内\n- 使用 `requestAnimationFrame` 节流，优化性能\n- 10px 容差避免边缘抖动\n\n**代码位置**：`hooks/useDynamicIslandHover.ts`\n\n```typescript\n// 节流处理\nlet rafId: number | null = null;\nconst throttledHandleMouseMove = (e: MouseEvent) => {\n  if (rafId) return;\n  rafId = requestAnimationFrame(() => {\n    handleGlobalMouseMove(e);\n    rafId = null;\n  });\n};\n```\n\n#### 5. 透明度与可见性恢复（配合全局 overlay）\n\n**问题**：从 PANEL/MAXIMIZE 折叠到 FLOAT 时，如果主进程仍保留 `opacity: 0` 等样式，灵动岛窗口可能出现“看不见但还在”的状态。\n\n**解决方案（新实现）**：\n\n- 主进程在折叠/动画期间仍可以注入 `opacity: 0`，避免尺寸变化过程闪现内容。\n- `DynamicIsland` 挂载与模式切换时，通过 `useEffect` 与 `ref` 回调：\n  - 对 overlay 容器本身强制 `opacity: 1; visibility: visible`。\n  - 必要时通过 `<style>` 注入 `html, body, #__next { opacity: 1 !important; }`，覆盖遗留样式。\n- 这样可以保证：只要渲染进程在运行，灵动岛 overlay 层始终可见，不会“突然消失”。\n\n**代码位置**：`components/dynamic-island/DynamicIsland.tsx` 中关于 overlay 容器样式修复的 `useEffect` 与 `ref` 逻辑。\n\n#### 6. 窗口圆角实现\n\n**实现方式**：\n- 使用 `clip-path: inset(0 round 16px)` 实现完美圆角\n- 通过 Electron 的 `insertCSS` API 注入样式\n- 同时设置 `border-radius` 和 `overflow: hidden` 作为后备\n\n**代码位置**：`electron/ipc-handlers.ts` 的 `expand-window` 处理器\n\n```typescript\nwin.webContents.insertCSS(`\n  html, body, #__next {\n    border-radius: 16px !important;\n    clip-path: inset(0 round 16px) !important;\n  }\n`);\n```\n\n#### 7. 布局计算\n\n**实现方式（全局常驻后）**：\n\n- 核心思路：**无论外部 mode 是 FLOAT / PANEL / MAXIMIZE，布局计算统一使用 FLOAT 语义**。\n  - 在 `DynamicIsland` 内部固定 `const layoutMode = IslandMode.FLOAT;`。\n  - 通过 `useDynamicIslandLayout` 只根据拖拽位置/吸边状态计算 `left/right/top/bottom` 和收起/展开尺寸。\n- 尺寸语义：\n  - 收起：约 36x36px。\n  - 展开：约 135x48px。\n- PANEL / MAXIMIZE 模式时：\n  - 灵动岛仍按 FLOAT 语义布局，只是背景内容从桌面 → PanelWindow / 最大化工作台。\n\n**代码位置**：`components/dynamic-island/hooks/useDynamicIslandLayout.ts`\n\n#### 8. Framer Motion 动画\n\n**实现方式**：\n- 使用 `motion.div` 的 `layout` 属性实现自动布局动画\n- 弹簧物理效果：`stiffness: 350, damping: 30, mass: 0.8`\n- 拖拽结束后，通过更新 `position` 状态触发平滑移动\n\n**代码示例**：\n```typescript\n<motion.div\n  layout\n  animate={layoutState}\n  transition={{\n    type: \"spring\",\n    stiffness: 350,\n    damping: 30,\n    mass: 0.8,\n  }}\n/>\n```\n\n### 实现流程\n\n#### FLOAT 模式初始化\n\n1. 窗口创建时设置 `alwaysOnTop: true`、`resizable: false`、`movable: false`。\n2. 启用点击穿透：`setIgnoreMouseEvents(true, { forward: true })`。\n3. 保持窗口背景透明，只通过灵动岛 overlay 渲染内容。\n4. 监听全局鼠标移动，检测悬停。\n\n#### 模式切换流程（窗口层 + 前端层）\n\n1. **FLOAT → PANEL**：\n   - 前端调用 `expandWindow()` IPC，请求展开为「Panel 宽度 + 左侧透明走廊」的宽窗。\n   - 主进程设置窗口可调整大小和可移动，注入 panel 圆角 / 透明背景 CSS，并动画到目标 bounds。\n   - `useElectronClickThrough` 禁用整窗穿透，并根据 `[data-panel-window]` rect 做区域穿透。\n   - 前端切换模式状态为 `PANEL`，`PanelContent` 渲染当前功能。\n\n2. **PANEL → FLOAT**：\n   - 前端调用 `collapseWindow()` IPC。\n   - 主进程注入 `opacity: 0`，动画回到小岛尺寸，动画结束后启用整窗点击穿透。\n   - 前端通过 overlay 样式修复确保灵动岛重新可见，并将模式切回 `FLOAT`。\n\n3. **PANEL → MAXIMIZE**：\n   - 前端调用 `expandWindowFull()` IPC。\n   - 主进程最大化窗口，清理 panel 圆角/clip-path。\n   - 始终禁用整窗穿透。\n   - 前端切换模式状态为 `MAXIMIZE`，`MaximizeControlBar` 渲染。\n\n---\n\n## 🏗️ 项目架构\n\n### 目录结构\n\n```\ncomponents/dynamic-island/\n├── DynamicIsland.tsx                  # 灵动岛主组件（协调三种模式）\n├── DynamicIslandProvider.tsx         # Provider 组件，用于检测 Electron 环境\n├── PanelFeatureContext.tsx           # Panel 模式功能上下文\n├── PanelTitleBar.tsx                 # Panel 模式标题栏\n├── PanelContent.tsx                  # Panel 模式内容区域（包含 BottomDock）\n├── PanelSelectorMenu.tsx             # Panel 模式右键菜单\n├── FloatContent.tsx                  # FLOAT 模式内容（收起/展开）\n├── MaximizeControlBar.tsx            # MAXIMIZE 模式顶部控制栏\n├── ContextMenu.tsx                   # FLOAT 模式右键上下文菜单\n├── ResizeHandle.tsx                  # PANEL 模式自定义缩放把手\n├── electron-api.ts                   # 前端使用的 Electron API 封装\n├── ElectronTransparentScript.tsx     # 透明窗口支持脚本\n├── TransparentBody.tsx               # 透明 body 包装器\n├── types.ts                          # 类型定义（IslandMode 枚举等）\n├── index.ts                          # 公共导出\n└── hooks/                            # 自定义 Hooks\n    ├── useDynamicIslandClickThrough.ts  # 点击穿透管理（渲染层）\n    ├── useDynamicIslandDrag.ts          # FLOAT 模式拖拽\n    ├── useDynamicIslandHover.ts         # FLOAT 模式悬停展开/收起\n    └── useDynamicIslandLayout.ts        # 根据模式计算布局\n\ncomponents/layout/\n├── PanelWindow.tsx                   # Panel 模式右侧窗口容器（含透明占位区 + panel 区域）\n├── PanelRegion.tsx                   # 可复用 Panel 区域（上面 panel 栏 + 下面 BottomDock）\n├── PanelContainer.tsx                # 单个 panel 容器（控制宽度、间距、拖拽态）\n├── PanelContent.tsx                  # PanelRegion 中的业务内容渲染\n├── ResizeHandle.tsx                  # Panel 之间的垂直分隔/拖拽把手\n├── BottomDock.tsx                    # 面板底部 dock（功能切换入口）\n└── AppHeader.tsx                     # 顶部应用 header（包含模式切换按钮）\n\nlib/hooks/\n└── useElectronClickThrough.ts        # 统一控制 Electron setIgnoreMouseEvents 的 hook\n\nelectron/\n├── ipc-handlers.ts                   # 主进程 IPC 入口（collapse/expand/expand-full + 动画）\n└── window-manager.ts                 # BrowserWindow 管理与创建\n```\n\n### 组件层次结构\n\n```\nDynamicIslandProvider\n  └── DynamicIsland (mode: FLOAT | PANEL | MAXIMIZE)\n      ├── FLOAT 模式:\n      │   ├── FloatContent (收起/展开)\n      │   └── ContextMenu (右键菜单)\n      ├── PANEL 模式:\n      │   ├── PanelFeatureProvider\n      │   │   ├── PanelTitleBar\n      │   │   └── PanelContent\n      │   │       └── PanelSelectorMenu (右键菜单)\n      │   └── ResizeHandle (8 个缩放把手)\n      └── MAXIMIZE 模式:\n          └── MaximizeControlBar\n```\n\n---\n\n## 🎨 核心组件\n\n### DynamicIsland.tsx\n\n**用途**：主组件，协调所有三种模式。\n\n**主要职责**：\n- 模式切换逻辑（FLOAT ↔ PANEL ↔ MAXIMIZE）\n- Electron API 集成（窗口缩放、折叠、展开）\n- 模式转换后恢复透明度\n- 键盘快捷键（1、4、5、Escape）\n- 拖拽、悬停和上下文菜单的状态管理\n\n**关键特性**：\n- 使用 `suppressHydrationWarning` 防止水合错误\n- 高 z-index（999999）确保始终置顶\n- 切换到 FLOAT 模式时自动恢复透明度\n- FLOAT 模式的点击穿透管理\n\n### PanelFeatureContext.tsx\n\n**用途**：Context，用于在 PanelTitleBar 和 PanelContent 之间共享当前功能状态。\n\n**使用方式**：\n```typescript\n<PanelFeatureProvider>\n  <PanelTitleBar />\n  <PanelContent />\n</PanelFeatureProvider>\n```\n\n### PanelTitleBar.tsx\n\n**用途**：PANEL 模式的标题栏，显示当前功能名称和控制按钮。\n\n**特性**：\n- 显示当前功能图标和名称\n- 最大化和折叠按钮\n- 支持 WebkitAppRegion 拖拽\n- 与 PanelFeatureContext 同步\n\n### PanelContent.tsx\n\n**用途**：PANEL 模式的内容区域，包含底部 Dock 用于功能切换。\n\n**特性**：\n- 底部 Dock 显示当前功能按钮\n- 右键菜单用于功能选择\n- 通过 `getAvailableFeatures()` 与设置面板开关同步\n- 始终包含 \"settings\" 功能\n- 鼠标移动时自动显示/隐藏 Dock\n\n### FloatContent.tsx\n\n**用途**：FLOAT 模式显示的内容（收起/展开状态）。\n\n**状态**：\n- **收起**：小图标（36x36px）\n- **展开**：完整内容带按钮（135x48px）\n\n### MaximizeControlBar.tsx\n\n**用途**：MAXIMIZE 模式的顶部控制栏。\n\n**特性**：\n- 退出最大化按钮\n- 折叠到灵动岛按钮\n- 固定窗口（不可拖拽、不可调整大小）\n\n---\n\n## 🔧 自定义 Hooks\n\n### useDynamicIslandClickThrough\n\n**用途**：管理灵动岛自身在 FLOAT 模式下的点击穿透与交互区域（渲染层）。\n\n**行为（新实现）**：\n\n- FLOAT 模式：\n  - 配合窗口层的 `setIgnoreMouseEvents(true, { forward: true })`，通过局部 `pointer-events` 控制实际可点击区域。\n  - 悬停/拖拽时打开交互，离开时恢复为只展示但不阻挡桌面。\n- PANEL / MAXIMIZE 模式：\n  - 主要交由 `useElectronClickThrough` 控制整窗行为，本 hook 只做必要的样式修复。\n\n### useDynamicIslandDrag\n\n**用途**：处理 FLOAT 模式的拖拽功能。\n\n**特性**：\n- 手动拖拽实现\n- 吸附到边缘（上、下、左、右）\n- 位置持久化\n- 点击按钮时阻止拖拽\n\n### useDynamicIslandHover\n\n**用途**：管理 FLOAT 模式的悬停状态。\n\n**特性**：\n- 全局鼠标移动检测\n- 悬停时展开，离开时收起\n- 使用 requestAnimationFrame 节流\n- 尊重拖拽状态\n\n### useDynamicIslandLayout\n\n**用途**：计算不同模式的布局状态。\n\n**布局**：\n- **FLOAT**：收起（36x36）或展开（135x48），定位在边缘\n- **PANEL**：全窗口（100% x 100%），圆角（16px）\n- **MAXIMIZE**：全视口（100vw x 100vh）\n\n---\n\n## ⚡ Electron 集成\n\n### 窗口管理\n\n**IPC 处理器**（位于 `electron/ipc-handlers.ts`）：\n\n- `collapse-window`：折叠到 FLOAT 模式\n  - 如当前窗口为 maximized，先 `unmaximize()` 再执行动画。\n  - 转换期间注入 `opacity: 0` 并动画化窗口边界。\n  - 结束后启用整窗点击穿透，并保持窗口置顶。\n- `expand-window`：展开到 PANEL 模式\n  - 使窗口可调整大小和可移动。\n  - 计算 `expandedWidth = panelWidth + overlayGutter`，将 PanelWindow 固定在右侧，左侧保留透明区域给全局 overlay。\n  - 注入圆角与透明背景 CSS。\n  - 窗口级点击穿透由 `useElectronClickThrough` 按鼠标位置实时切换。\n- `expand-window-full`：展开到 MAXIMIZE 模式\n  - 最大化窗口。\n  - 清理 Panel 模式的圆角/clip-path。\n  - 设置 `resizable=false`、`movable=false`，并禁用点击穿透。\n\n### 窗口属性\n\n**FLOAT 模式**：\n- `alwaysOnTop: true`\n- `resizable: false`\n- `movable: false`\n- `ignoreMouseEvents: true`（forward: true）\n\n**PANEL 模式**：\n- `alwaysOnTop: true`\n- `resizable: true`\n- `movable: true`\n- `ignoreMouseEvents`：由 `useElectronClickThrough` 根据鼠标是否在 PanelWindow 内部动态切换。\n\n**MAXIMIZE 模式**：\n- `alwaysOnTop: true`\n- `resizable: false`\n- `movable: false`\n- `ignoreMouseEvents: false`\n\n---\n\n## 📦 状态管理\n\n### 模式状态\n\n由 `lib/store/dynamic-island-store.ts` 管理：\n- `mode: IslandMode` - 当前模式（FLOAT、PANEL、MAXIMIZE）\n- `isEnabled: boolean` - 是否启用灵动岛\n- `setMode(mode)` - 切换模式\n\n### 功能状态（Panel 模式）\n\n由 `PanelFeatureContext` 管理：\n- `currentFeature: PanelFeature` - 当前显示的功能\n- `setCurrentFeature(feature)` - 切换功能\n\n### 设置同步\n\nPanel 模式底部 Dock 通过以下方式与设置同步：\n- `useUiStore().getAvailableFeatures()` - 获取已启用且未分配的功能\n- `useUiStore().isFeatureEnabled(feature)` - 检查功能是否启用\n- Settings 功能始终包含在可用功能列表中\n\n---\n\n## 🔄 模式转换\n\n### FLOAT → PANEL\n\n1. 用户点击展开按钮或按 \"4\" 键。\n2. 前端调用 `expandWindow()` IPC，请求主进程展开到「Panel 宽度 + 左侧透明走廊」。\n3. 窗口动画到目标 bounds，右侧显示 PanelWindow，左侧留出透明区。\n4. 模式切换到 `PANEL`，`PanelContent` 渲染当前功能。\n\n### PANEL → MAXIMIZE\n\n1. 用户点击最大化按钮。\n2. 前端调用 `expandWindowFull()` IPC。\n3. 窗口最大化并清理 Panel 圆角/clip-path。\n4. 模式切换到 `MAXIMIZE`，`MaximizeControlBar` 渲染。\n\n### PANEL → FLOAT\n\n1. 用户点击折叠按钮。\n2. 前端调用 `collapseWindow()` IPC。\n3. 主进程动画窗口到小岛尺寸并重新开启整窗点击穿透。\n4. 前端通过 overlay 样式修复恢复灵动岛可见性，并将模式切换到 `FLOAT`。\n\n### MAXIMIZE → PANEL\n\n1. 用户点击退出最大化按钮。\n2. **不再主动调用 `expandWindow()`**，只切换前端模式为 `PANEL`，保持窗口仍为最大化宽度。\n3. Panel 模式的 PanelWindow 使用右侧布局呈现，灵动岛等全局 overlay 依然按照 fixed 坐标保持在原位置。\n\n### MAXIMIZE → FLOAT\n\n1. 用户点击折叠按钮或按 Escape 键。\n2. 调用 `collapseWindow()` IPC。\n3. 与 PANEL → FLOAT 相同。\n\n---\n\n## ⌨️ 键盘快捷键\n\n- **1**：折叠到 FLOAT 模式\n- **4**：展开到 PANEL 模式\n- **5**：展开到 MAXIMIZE 模式\n- **Escape**：从 PANEL/MAXIMIZE 折叠到 FLOAT\n\n---\n\n## 🎨 样式\n\n### Z-Index 层级（新实现）\n\n- 全局 overlay 容器（灵动岛 + N 徽章等）：`z-index: 1000002`\n- PanelWindow 主容器：`z-index: 1000001`\n- MAXIMIZE 控制栏：`z-index: 100010+`（在内容层之上，但仍低于全局 overlay）\n- Panel 模式缩放把手：`z-index: 50`\n- 上下文菜单：`z-index: 100-101`\n\n### 动画\n\n- **布局转换**：Framer Motion 弹簧动画\n- **悬停展开**：平滑的宽度/高度转换\n- **模式切换**：带弹簧物理效果的布局动画\n- **Dock 显示/隐藏**：带 translateY 的弹簧动画\n\n---\n\n## 🔨 常见模式\n\n### 添加新功能到 Panel 模式\n\n1. 在 `lib/config/panel-config.ts` 中将功能添加到 `ALL_PANEL_FEATURES`\n2. 将功能图标添加到 `FEATURE_ICON_MAP`\n3. 在翻译文件（`messages/*.json`）中添加功能标签\n4. 在 `apps/{feature}/` 中创建功能面板组件\n5. 在 `PanelContent.tsx` 的渲染逻辑中添加功能分支\n\n### 修改模式行为\n\n1. 更新 `hooks/` 目录中对应的 hook\n2. 如需要模式特定逻辑，更新 `DynamicIsland.tsx`\n3. 如窗口行为改变，更新 Electron IPC 处理器\n4. 测试所有模式转换\n\n### 调试模式问题\n\n1. 在 `useDynamicIslandStore()` 中检查 `mode` 状态\n2. 在浏览器控制台中验证 Electron API 调用\n3. 在 Electron DevTools 中检查窗口属性\n4. 在 DOM 中检查透明度样式\n5. 通过 Electron API 验证点击穿透状态\n\n---\n\n## ✅ 最佳实践\n\n1. **保证 overlay 永远可见**：在 `DynamicIsland` 中持续修复 overlay 容器的 `position/z-index/opacity/visibility`。\n2. **使用 Context 共享状态**：在 `PanelTitleBar` 和 `PanelContent` 之间传递当前功能等信息。\n3. **与设置同步**：使用 `getAvailableFeatures()` 获取功能列表，保证 Panel 与设置面板一致。\n4. **处理水合错误**：在需要的地方使用 `suppressHydrationWarning`。\n5. **节流鼠标事件**：使用 `requestAnimationFrame` 提升性能，避免全局 `mousemove` 抖动。\n6. **阻止按钮拖拽**：通过检查 `target.closest('button')` 防止拖拽误触。\n7. **保持正确的 z-index 关系**：确保 overlay > PanelWindow > 其他内容。\n8. **同步更新点击穿透状态**：模式切换时立即更新 `setIgnoreMouseEvents` 与相关 CSS，避免出现几秒钟“看得见但点不到”或“穿透但不可交互”的状态。\n\n---\n\n## 📏 文件大小管理\n\n- **DynamicIsland.tsx**：382 行（在 500 行限制内）\n- 组件已拆分为独立文件：\n  - `PanelFeatureContext.tsx`：Context 和 Provider\n  - `PanelTitleBar.tsx`：标题栏组件\n  - 其他组件已分离\n\n---\n\n## 📚 相关文件\n\n- `lib/store/dynamic-island-store.ts`：模式状态管理\n- `lib/store/ui-store/store.ts`：功能启用/禁用状态\n- `lib/config/panel-config.ts`：功能定义和图标\n- `electron/ipc-handlers.ts`：窗口管理 IPC 处理器\n- `electron/window-manager.ts`：窗口创建和配置\n\n---\n\n## 🔍 调试和排查\n\n### 常见问题\n\n1. **灵动岛消失**：检查透明度样式，确保切换到 FLOAT 模式时恢复 `opacity: 1`\n2. **无法拖拽**：检查 `ignoreMouseEvents` 状态和 `WebkitAppRegion` 设置\n3. **模式切换失败**：检查 Electron IPC 处理器是否正确注册\n4. **功能不同步**：检查 `getAvailableFeatures()` 和 `isFeatureEnabled()` 的实现\n\n### 调试技巧\n\n- 在浏览器控制台查看 Electron API 调用日志\n- 使用 React DevTools 检查组件状态\n- 在 Electron DevTools 中检查窗口属性\n- 检查 DOM 中的样式注入（opacity、z-index 等）\n\n---\n\n## ⚠️ 当前问题与改进方向\n\n### 模式切换存在的问题\n\n目前模式切换存在以下问题，影响用户体验：\n\n#### 1. 瞬变问题\n- **现象**：模式切换时窗口尺寸变化过于突然，缺乏平滑过渡\n- **影响**：视觉上不够自然，用户体验不佳\n\n#### 2. 闪现其他尺寸的页面\n- **现象**：在模式切换过程中，会短暂显示其他尺寸的页面内容\n- **影响**：特别是从 PANEL 模式切换到 FLOAT 模式时，会先闪现最大化画面，然后才缩小到 FLOAT 尺寸\n- **原因分析**：\n  - 窗口尺寸变化和内容渲染不同步\n  - CSS 注入时机不当，导致在窗口尺寸变化过程中内容可见\n  - 前端模式状态切换时机与窗口动画不匹配\n\n#### 3. 不自然的过渡\n- **现象**：窗口从一种尺寸直接跳到另一种尺寸，而不是平滑过渡\n- **影响**：缺乏连贯性，感觉不和谐\n\n### 改进方向\n\n接下来需要实现**模式切换的自然过渡**，主要改进方向：\n\n1. **同步窗口动画与内容渲染**\n   - 在窗口尺寸变化前，先隐藏或透明化内容\n   - 确保窗口动画完成后再显示新尺寸的内容\n   - 避免在动画过程中显示中间状态的内容\n\n2. **优化 CSS 注入时机**\n   - 在窗口动画开始前注入 `opacity: 0`，确保内容不可见\n   - 在窗口动画完成后，再恢复内容可见性\n   - 避免在窗口尺寸变化过程中内容闪现\n\n3. **协调前端状态切换与窗口动画**\n   - 前端模式状态切换应该在窗口动画开始前完成\n   - 或者延迟到窗口动画完成后，确保视觉一致性\n   - 使用 Promise 或回调确保时序正确\n\n4. **改进动画实现**\n   - 确保窗口边界动画平滑，无跳跃\n   - 前端组件布局动画与窗口动画同步\n   - 使用更合适的缓动函数，让过渡更自然\n\n5. **处理 PANEL → FLOAT 的特殊情况**\n   - 这是最明显的问题场景，需要特别处理\n   - 在折叠动画开始前，确保内容已透明\n   - 避免在窗口缩小过程中显示最大化内容\n   - 可以考虑使用截图或遮罩层，在动画期间显示当前窗口的静态图像\n\n### 技术实现要点\n\n- **时序控制**：使用 `async/await` 确保操作顺序\n- **状态同步**：窗口状态与前端状态保持一致\n- **视觉连续性**：使用遮罩、截图或预渲染保持视觉连贯\n- **性能优化**：避免不必要的重渲染和布局计算\n"
  },
  {
    "path": ".cursor/commands/web.md",
    "content": "# Frontend Development Quick Commands (free-todo-frontend version)\n\n## Tech Stack Information\n\n- **Framework**: Next.js 16 + React 19 (App Router)\n- **Language**: Node.js 22.x + TypeScript 5.x\n- **Styling**: Tailwind CSS 4 + shadcn/ui\n- **State Management**: Zustand + React Hooks\n- **Data Fetching**: TanStack Query (React Query) v5\n- **API Generation**: Orval (auto-generated from OpenAPI)\n- **Data Validation**: Zod (runtime type validation)\n- **Theming**: next-themes (light/dark toggle)\n- **Animation/Interaction**: framer-motion, @dnd-kit\n- **Markdown**: react-markdown + remark-gfm\n- **Icons**: lucide-react\n- **Package Manager**: pnpm 10.x\n- **Code Quality**: Biome (lint/format/check)\n\n---\n\n## 🎨 Component Development\n\n### Creating New React Components\n\nCreate a new React component based on project conventions, including:\n- TypeScript type definitions\n- Complete comments\n- Tailwind CSS styling\n- Responsive design\n- Internationalization support (if needed)\n\nPlease create components in the `free-todo-frontend/components/` directory and follow the project's code conventions.\n\n### Creating Shadcn UI Components\n\nCreate custom components based on existing Shadcn UI components:\n- Inherit Shadcn UI's styling system\n- Add project-specific functionality extensions\n- Maintain consistency with project theme\n- Support dark mode\n\n### Optimizing Existing Components\n\n- Use `React.memo/useMemo/useCallback` to control rendering\n- Use `tailwind-merge` to merge class names and avoid duplicate styles\n- Unify interaction animations (framer-motion) and drag-and-drop (@dnd-kit)\n- Add error handling and boundary states (loading/empty/error)\n- Improve type definitions, remove unused props/variables\n\n---\n\n## 🌐 Internationalization\n\nThe project uses next-intl for internationalization, with language switching managed through Zustand store (no URL routing mode).\n\n- **Translation Files**: `free-todo-frontend/messages/zh.json` and `en.json`\n- **Request Configuration**: `free-todo-frontend/i18n/request.ts`\n- **Language Management**: `lib/store/locale.ts` (syncs to cookie on switch)\n- **Access Method**: `useTranslations(namespace)` imported from `next-intl`\n\n### Adding/Modifying Copy\n\n- Add translation keys in both `messages/zh.json` and `en.json`\n- Use nested structures to organize translations, e.g., `page.settings.title`\n- Support ICU MessageFormat interpolation syntax, e.g., `{count}` and plural forms\n\n### Implementing Multilingual Components\n\n- Use `useTranslations(namespace)` hook to get translation function\n- Access translations via `t('key')`, supports `t('key', { param: value })` for parameters\n- Do not hardcode Chinese/English text in components\n- Do not use `locale === \"zh\" ? \"中文\" : \"English\"` ternary expressions\n\n---\n\n## 🎨 Styling Development\n\n### Optimizing Tailwind CSS Styles\n\nImprove component Tailwind CSS styles:\n- Use project's custom theme variables\n- Implement dark mode adaptation\n- Optimize responsive breakpoints\n- Follow DRY principles, extract reusable styles\n\n### Implementing Dark Mode\n\nAdd dark mode support to components:\n- Use `dark:` prefix\n- Use CSS variables to define colors\n- Ensure contrast meets accessibility standards\n- Test theme switching effects\n\n---\n\n## 🔧 State Management\n\n### Creating Custom Hooks\n\nCreate reusable React Hooks:\n- Follow Hook naming conventions (use prefix)\n- Add complete TypeScript types\n- Include detailed comments\n- Implement error handling and edge cases\n\n### Implementing Global State\n\nUse Context API to implement global state management:\n- Create Context and Provider\n- Implement state update logic\n- Add performance optimizations (useMemo, useCallback)\n- Provide type-safe hooks\n\n---\n\n## 📡 API and Data Fetching\n\nThe project uses **Orval + TanStack Query + Zod** to implement type-safe API calls and data validation.\n\n### Orval Code Generation\n\n- **Configuration File**: `orval.config.ts`\n- **Generation Command**: `pnpm orval` (requires backend service running). When backend-frontend interaction APIs change, actively use this command to generate frontend-backend interaction APIs. Do not manually write APIs.\n- **Generated Content**: TypeScript types, Zod schemas, React Query hooks\n- **Output Directory**: `lib/generated/` (split by API tag, e.g., `todos/`, `chat/`)\n\n**Main Configuration**:\n- `input.target`: Backend OpenAPI schema address (http://localhost:8001/openapi.json)\n- `output.client`: Generate hooks using react-query\n- `output.mode`: tags-split to split files by functional modules\n- `override.mutator`: Use custom fetcher (`lib/generated/fetcher.ts`)\n- `override.zod.strict`: Enable strict runtime validation\n\n### Using Orval Generated API Hooks\n\n1. **Direct use of generated hooks**: Import from `lib/generated/[module]/`, includes complete type definitions\n2. **Wrap hooks to add business logic**: Encapsulate in `lib/query/`, add custom query keys, data transformation, cache strategies, etc.\n3. **Reference examples**: `lib/query/todos.ts`, `lib/query/chat.ts`\n\n### TanStack Query Usage Conventions\n\n- **Query Keys**: Managed uniformly in `lib/query/keys.ts`, using hierarchical structure (e.g., `todos.list()`, `todos.detail(id)`)\n- **Optimistic Updates**: Update cache in `onMutate`, rollback in `onError`, refetch in `onSettled`\n- **Debounced Updates**: Use 500ms debounce for frequently changing fields (e.g., description, notes)\n- **Cache Strategy**: Set reasonable `staleTime` (e.g., 30 seconds) to avoid excessive requests\n\n### Zod Data Validation\n\n- **Generated schemas**: Located in `lib/generated/schemas/`, automatically generated by Orval\n- **Runtime validation**: Automatically validate API response format in fetcher\n- **Form validation**: Use with React Hook Form's `zodResolver`\n- **Custom rules**: Can extend custom validation logic based on generated schema\n\n### Custom Fetcher\n\nLocated in `lib/generated/fetcher.ts`, responsible for:\n- Environment adaptation (client/server URL)\n- **Automatic naming style conversion**:\n  - Request: camelCase → snake_case (frontend style → backend style)\n  - Response: snake_case → camelCase (backend style → frontend style)\n- Time string normalization (handling timezone suffix)\n- Unified error handling\n- Zod schema runtime validation\n- Extensible (auth tokens, logging, retry, etc.)\n\nConversion utilities located in `lib/generated/case-transform.ts`, frontend uniformly uses camelCase type definitions (`lib/types/index.ts`).\n\n### Streaming API Handling\n\nOrval does not support Server-Sent Events, need to manually implement in `lib/api.ts`:\n- Use native `fetch` + `ReadableStream`\n- Decode and callback chunk by chunk\n- Examples: `sendChatMessageStream()`, `planQuestionnaireStream()`\n\n### Type Safety Best Practices\n\n1. Prioritize using camelCase types from `lib/types/index.ts` (fetcher automatically converts)\n2. IDs uniformly use `number` type (consistent with backend database)\n3. Orval-generated types only used for API layer, business layer uses unified type definitions\n\n### Development Workflow\n\n1. **Backend API changes**: Run `pnpm orval` to regenerate code, check `git diff lib/generated/`\n2. **New API**: Backend updates OpenAPI → Generate code → Encapsulate in `lib/query/` → Component usage\n3. **Debugging**: Add logs in fetcher to view request/response and validation errors\n\n---\n\n## 🚀 Performance Optimization\n\n### Optimizing Component Performance\n\nAnalyze and optimize component performance:\n- Use React DevTools Profiler for analysis\n- Implement code splitting (dynamic import)\n- Optimize image loading (Next.js Image)\n- Reduce unnecessary re-renders\n- Implement virtual scrolling (if needed)\n\n### Optimizing Bundle Size\n\nReduce frontend bundle size:\n- Analyze bundle size\n- Remove unused dependencies\n- Implement on-demand loading\n- Optimize third-party library imports\n\n---\n\n## 🧪 Testing Development\n\n### Writing Component Tests\n\nWrite test cases for components:\n- Use React Testing Library\n- Test user interactions\n- Test edge cases\n- Ensure test coverage\n\n### Writing E2E Tests\n\nWrite end-to-end tests:\n- Use Playwright or Cypress\n- Test critical user flows\n- Simulate real user scenarios\n- Add visual regression tests\n\n---\n\n## 🔍 Debugging and Fixing\n\n### Fixing TypeScript Errors\n\nFix TypeScript type errors in code:\n- Analyze error messages\n- Add correct type definitions\n- Avoid using `any` type\n- Ensure type safety\n\n### Fixing ESLint Warnings\n\nFix ESLint warnings in code:\n- Follow project's ESLint configuration\n- Fix code style issues\n- Remove unused imports\n- Optimize code structure\n\n### Debugging Runtime Errors\n\nAnalyze and fix runtime errors:\n- Check browser console errors\n- Analyze error stack traces\n- Add error boundary handling\n- Implement graceful degradation\n\n---\n\n## 📦 Dependency Management\n\n### Adding New npm Packages\n\nSafely add new npm dependencies:\n1. Evaluate package necessity and security\n2. Use `pnpm add <package>` to install\n3. Update project documentation\n4. Test if functionality works correctly\n\n### Upgrading Dependencies\n\nUpgrade project dependencies to latest versions:\n1. Check breaking changes\n2. Use `pnpm update` to upgrade\n3. Run tests to ensure compatibility\n4. Update related code\n\n---\n\n## 📚 Documentation Writing\n\n### Writing Component Documentation\n\nWrite documentation for components:\n- Explain component purpose and functionality\n- List all Props and types\n- Provide usage examples\n- Include notes and considerations\n\n### Updating README\n\nUpdate frontend-related README documentation:\n- Synchronize latest tech stack\n- Update development commands\n- Add new feature descriptions\n- Improve troubleshooting guide\n"
  },
  {
    "path": ".cursor/commands/web_CN.md",
    "content": "# 前端开发快捷命令（free-todo-frontend 版）\n\n## 技术栈信息\n\n- **框架**: Next.js 16 + React 19（App Router）\n- **语言**: Node.js 22.x + TypeScript 5.x\n- **样式**: Tailwind CSS 4 + shadcn/ui\n- **状态管理**: Zustand + React Hooks\n- **数据获取**: TanStack Query (React Query) v5\n- **API 生成**: Orval（根据 OpenAPI 自动生成）\n- **数据验证**: Zod（运行时类型验证）\n- **主题**: next-themes（浅/深色切换）\n- **动画/交互**: framer-motion、@dnd-kit\n- **Markdown**: react-markdown + remark-gfm\n- **图标**: lucide-react\n- **包管理**: pnpm 10.x\n- **代码质量**: Biome（lint/format/check）\n\n---\n\n## 🎨 组件开发\n\n### 创建新的 React 组件\n\n基于项目规范创建一个新的 React 组件，包含：\n- TypeScript 类型定义\n- 完整的中文注释\n- Tailwind CSS 样式\n- 响应式设计\n- 国际化支持（如需要）\n\n请在 `free-todo-frontend/components/` 目录下创建组件，并遵循项目的代码规范。\n\n### 创建 Shadcn UI 组件\n\n在现有的 Shadcn UI 组件基础上创建自定义组件：\n- 继承 Shadcn UI 的样式系统\n- 添加项目特定的功能扩展\n- 保持与项目主题的一致性\n- 支持深色模式\n\n### 优化现有组件\n\n- 使用 `React.memo/useMemo/useCallback` 控制渲染\n- 使用 `tailwind-merge` 合并类名，避免重复样式\n- 统一交互动画（framer-motion）与拖拽（@dnd-kit）\n- 补充错误处理与边界状态（loading/empty/error）\n- 完善类型定义，移除未用 props/变量\n\n---\n\n## 🌐 国际化\n\n项目使用 next-intl 实现国际化，通过 Zustand store 管理语言切换（无 URL 路由模式）。\n\n- **翻译文件**：`free-todo-frontend/messages/zh.json` 与 `en.json`\n- **请求配置**：`free-todo-frontend/i18n/request.ts`\n- **语言管理**：`lib/store/locale.ts`（切换时同步到 cookie）\n- **访问方法**：`useTranslations(namespace)` 从 `next-intl` 导入\n\n### 添加/修改文案\n\n- 在 `messages/zh.json` 和 `en.json` 中同步添加翻译 key\n- 使用嵌套结构组织翻译，如 `page.settings.title`\n- 支持 ICU MessageFormat 插值语法，如 `{count}` 和复数形式\n\n### 实现多语言组件\n\n- 使用 `useTranslations(namespace)` hook 获取翻译函数\n- 通过 `t('key')` 访问翻译，支持 `t('key', { param: value })` 传参\n- 禁止在组件中硬编码中文/英文文本\n- 禁止使用 `locale === \"zh\" ? \"中文\" : \"English\"` 三元表达式\n\n---\n\n## 🎨 样式开发\n\n### 优化 Tailwind CSS 样式\n\n改进组件的 Tailwind CSS 样式：\n- 使用项目的自定义主题变量\n- 实现深色模式适配\n- 优化响应式断点\n- 遵循 DRY 原则，提取可复用样式\n\n### 实现深色模式\n\n为组件添加深色模式支持：\n- 使用 `dark:` 前缀\n- 使用 CSS 变量定义颜色\n- 确保对比度符合可访问性标准\n- 测试主题切换效果\n\n---\n\n## 🔧 状态管理\n\n### 创建自定义 Hook\n\n创建可复用的 React Hook：\n- 遵循 Hook 命名规范（use 前缀）\n- 添加完整的 TypeScript 类型\n- 包含详细的中文注释\n- 实现错误处理和边界情况\n\n### 实现全局状态\n\n使用 Context API 实现全局状态管理：\n- 创建 Context 和 Provider\n- 实现状态更新逻辑\n- 添加性能优化（useMemo、useCallback）\n- 提供类型安全的 hook\n\n---\n\n## 📡 API 与数据获取\n\n项目使用 **Orval + TanStack Query + Zod** 实现类型安全的 API 调用和数据验证。\n\n### Orval 代码生成\n\n- **配置文件**：`orval.config.ts`\n- **生成命令**：`pnpm orval`（需后端服务运行）。当后端与前端交互的 api 有变化时，主动使用本命令，在前端主动采用本命令生成的前后端交互 api，不要自己手写 api。\n- **生成内容**：TypeScript 类型、Zod schemas、React Query hooks\n- **输出目录**：`lib/generated/`（按 API tag 分割，如 `todos/`, `chat/`）\n\n**主要配置**：\n- `input.target`：后端 OpenAPI schema 地址（http://localhost:8001/openapi.json）\n- `output.client`：使用 react-query 生成 hooks\n- `output.mode`：tags-split 按功能模块分文件\n- `override.mutator`：使用自定义 fetcher（`lib/generated/fetcher.ts`）\n- `override.zod.strict`：启用严格的运行时验证\n\n### 使用 Orval 生成的 API Hooks\n\n1. **直接使用生成的 hooks**：从 `lib/generated/[module]/` 导入，已包含完整类型定义\n2. **包装 hooks 添加业务逻辑**：在 `lib/query/` 中封装，添加自定义 query key、数据转换、缓存策略等\n3. **参考示例**：`lib/query/todos.ts`、`lib/query/chat.ts`\n\n### TanStack Query 使用规范\n\n- **Query Keys**：统一在 `lib/query/keys.ts` 管理，使用层级结构（如 `todos.list()`, `todos.detail(id)`）\n- **乐观更新**：在 `onMutate` 中更新缓存，`onError` 回滚，`onSettled` 重新获取\n- **防抖更新**：针对频繁变化字段（如描述、备注）使用 500ms 防抖\n- **缓存策略**：设置合理的 `staleTime`（如 30 秒），避免过度请求\n\n### Zod 数据验证\n\n- **生成的 schemas**：位于 `lib/generated/schemas/`，由 Orval 自动生成\n- **运行时验证**：在 fetcher 中自动验证 API 响应格式\n- **表单验证**：配合 React Hook Form 的 `zodResolver` 使用\n- **自定义规则**：可基于生成的 schema 扩展自定义验证逻辑\n\n### 自定义 Fetcher\n\n位于 `lib/generated/fetcher.ts`，负责：\n- 环境适配（客户端/服务端 URL）\n- **命名风格自动转换**：\n  - 请求时：camelCase → snake_case（前端风格 → 后端风格）\n  - 响应时：snake_case → camelCase（后端风格 → 前端风格）\n- 时间字符串标准化（处理无时区后缀）\n- 统一错误处理\n- Zod schema 运行时验证\n- 可扩展（认证 token、日志、重试等）\n\n转换工具位于 `lib/generated/case-transform.ts`，前端统一使用 camelCase 类型定义（`lib/types/index.ts`）。\n\n### 流式 API 处理\n\nOrval 不支持 Server-Sent Events，需在 `lib/api.ts` 手动实现：\n- 使用原生 `fetch` + `ReadableStream`\n- 逐块解码并回调处理\n- 示例：`sendChatMessageStream()`, `planQuestionnaireStream()`\n\n### 类型安全最佳实践\n\n1. 优先使用 `lib/types/index.ts` 中的 camelCase 类型（fetcher 已自动转换）\n2. ID 统一使用 `number` 类型（与后端数据库一致）\n3. Orval 生成的类型仅用于 API 层，业务层使用统一类型定义\n\n### 开发工作流\n\n1. **后端 API 变更**：运行 `pnpm orval` 重新生成代码，检查 `git diff lib/generated/`\n2. **新增 API**：后端更新 OpenAPI → 生成代码 → 在 `lib/query/` 封装 → 组件使用\n3. **调试**：在 fetcher 中添加日志，查看请求/响应和验证错误\n\n---\n\n## 🚀 性能优化\n\n### 优化组件性能\n\n分析并优化组件性能：\n- 使用 React DevTools Profiler 分析\n- 实现代码分割（dynamic import）\n- 优化图片加载（Next.js Image）\n- 减少不必要的重渲染\n- 实现虚拟滚动（如需要）\n\n### 优化包体积\n\n减少前端包体积：\n- 分析 bundle 大小\n- 移除未使用的依赖\n- 实现按需加载\n- 优化第三方库引入\n\n---\n\n## 🧪 测试开发\n\n### 编写组件测试\n\n为组件编写测试用例：\n- 使用 React Testing Library\n- 测试用户交互\n- 测试边界情况\n- 确保测试覆盖率\n\n### 编写 E2E 测试\n\n编写端到端测试：\n- 使用 Playwright 或 Cypress\n- 测试关键用户流程\n- 模拟真实用户场景\n- 添加视觉回归测试\n\n---\n\n## 🔍 调试和修复\n\n### 修复 TypeScript 错误\n\n修复代码中的 TypeScript 类型错误：\n- 分析错误信息\n- 添加正确的类型定义\n- 避免使用 `any` 类型\n- 确保类型安全\n\n### 修复 ESLint 警告\n\n修复代码中的 ESLint 警告：\n- 遵循项目的 ESLint 配置\n- 修复代码风格问题\n- 移除未使用的导入\n- 优化代码结构\n\n### 调试运行时错误\n\n分析并修复运行时错误：\n- 检查浏览器控制台错误\n- 分析错误堆栈信息\n- 添加错误边界处理\n- 实现优雅降级\n\n---\n\n## 📦 依赖管理\n\n### 添加新的 npm 包\n\n安全地添加新的 npm 依赖：\n1. 评估包的必要性和安全性\n2. 使用 `pnpm add <package>` 安装\n3. 更新项目文档\n4. 测试功能是否正常\n\n### 升级依赖版本\n\n升级项目依赖到最新版本：\n1. 检查 breaking changes\n2. 使用 `pnpm update` 升级\n3. 运行测试确保兼容性\n4. 更新相关代码\n\n---\n\n## 📚 文档编写\n\n### 编写组件文档\n\n为组件编写文档：\n- 说明组件用途和功能\n- 列出所有 Props 和类型\n- 提供使用示例\n- 包含注意事项\n\n### 更新 README\n\n更新前端相关的 README 文档：\n- 同步最新的技术栈\n- 更新开发命令\n- 添加新功能说明\n- 完善故障排查指南\n"
  },
  {
    "path": ".cursor/plans/lifetrace_全面优化_76b5f86f.plan.md",
    "content": "---\nname: LifeTrace 全面优化\noverview: 针对 LifeTrace 项目的 6 个核心问题（启动速度、打包体积、插件系统、Todo 标准、社交媒体集成、依赖清理）制定系统性的优化方案。\ntodos:\n  - id: startup-optimization\n    content: 启动速度优化：数据库懒加载、后台任务异步启动、路由延迟导入、Electron 预启动优化\n    status: pending\n  - id: dependency-cleanup\n    content: 依赖项清理：移除未使用依赖、创建依赖分组、重构 pyproject.toml\n    status: pending\n  - id: package-size\n    content: 打包体积优化：分离大型依赖为可选扩展、替换重型依赖、优化 PyInstaller 配置\n    status: pending\n  - id: icalendar-support\n    content: iCalendar 标准支持：添加缺失字段、创建 ICS 导入/导出服务、添加 API 端点\n    status: pending\n  - id: plugin-system\n    content: 插件系统：设计插件接口、实现前端插件注册表和懒加载、实现后端插件系统\n    status: pending\n  - id: telegram-integration\n    content: Telegram Bot 集成：创建 Bot 模块、实现消息处理、添加 Webhook 路由\n    status: pending\n  - id: feishu-integration\n    content: 飞书 Bot 集成：创建 Bot 模块、实现事件回调、添加 Webhook 路由\n    status: pending\nisProject: false\n---\n\n# LifeTrace 全面优化计划\n\n## 问题 1: 启动速度优化（目标: 4秒 -> 1秒内）\n\n### 当前瓶颈分析\n\n根据代码分析，主要耗时点：\n\n- 数据库初始化（含 30+ 索引创建）：1-3 秒\n- 后台任务同步初始化：2-5 秒\n- Python 模块导入：0.5-1 秒\n- Electron + Next.js 启动：额外 1-2 秒\n\n### 优化方案\n\n**1.1 数据库初始化异步化**\n\n修改 [`lifetrace/storage/database.py`](lifetrace/storage/database.py)：\n\n- 将模块级的 `db_base = DatabaseBase()` 改为懒加载\n- 索引创建改为后台异步执行（应用启动后延迟执行）\n```python\n# 改为懒加载单例\n_db_base: DatabaseBase | None = None\n\ndef get_db_base() -> DatabaseBase:\n    global _db_base\n    if _db_base is None:\n        _db_base = DatabaseBase()\n    return _db_base\n```\n\n\n**1.2 后台任务延迟启动**\n\n修改 [`lifetrace/server.py`](lifetrace/server.py) 的 lifespan：\n\n- `job_manager.start_all()` 改为异步执行\n- 使用 `asyncio.create_task()` 并行启动各任务\n\n**1.3 路由模块延迟导入**\n\n使用 Python 的 `importlib` 实现路由模块的按需导入，或者在 lifespan 中注册路由。\n\n**1.4 Electron 预启动优化**\n\n修改 [`electron/main.ts`](free-todo-frontend/electron/main.ts)：\n\n- 先显示启动画面/骨架屏\n- 后端和前端服务并行启动\n- 减少健康检查等待时间（目前最多 180 秒）\n\n---\n\n## 问题 2: 打包体积优化（目标: 2.6GB -> 500MB 以内）\n\n### 当前体积构成分析\n\n根据 [`pyinstaller.spec`](lifetrace/pyinstaller.spec) 分析：\n\n- `torch` + `transformers`（sentence-transformers 间接依赖）：约 2-3GB\n- `chromadb`：约 100-200MB\n- `faster-whisper`：约 200-500MB\n- `opencv-python`：约 50-100MB\n- OCR 模型文件：约 50MB\n\n### 优化方案\n\n**2.1 分离大型依赖为可选扩展包**\n\n修改 [`pyproject.toml`](pyproject.toml)：\n\n```toml\n[dependency-groups]\n# 核心依赖（必需）\ncore = [\n    \"fastapi>=0.100.0\",\n    \"uvicorn[standard]>=0.20.0\",\n    \"pydantic>=2.0.0\",\n    \"sqlalchemy>=2.0.0\",\n    # ... 基础依赖\n]\n\n# 向量搜索扩展（可选下载）\nvector = [\n    \"sentence-transformers>=2.2.0\",\n    \"chromadb>=0.4.0\",\n]\n\n# 语音识别扩展（可选下载）\naudio = [\n    \"faster-whisper>=1.0.0\",\n    \"pyaudio>=0.2.14\",\n]\n```\n\n**2.2 替换重型依赖**\n\n- **sentence-transformers** -> 使用 API 调用（如 OpenAI Embeddings）或轻量级本地模型\n- **chromadb** -> 考虑使用 SQLite FTS5 全文搜索作为基础功能\n- **opencv-python** -> 使用 `opencv-python-headless`（减少约 30MB）\n\n**2.3 PyInstaller 优化**\n\n修改 [`pyinstaller.spec`](lifetrace/pyinstaller.spec)：\n\n- 排除未使用的子模块\n- 使用 `--exclude-module` 排除测试和文档模块\n- 考虑使用 `upx=True` 压缩（需测试兼容性）\n\n**2.4 考虑替代打包方案**\n\n- **Nuitka**：Python 编译为 C，体积更小、启动更快\n- **PyOxidizer**：Rust 实现的打包工具，体积优化更好\n\n---\n\n## 问题 3: 插件系统设计\n\n### 目标\n\n实现一个完善的插件系统，支持：\n\n- 内置模块的启用/禁用\n- 按需加载减少初始加载时间\n- 第三方插件开发和安装\n\n### 架构设计\n\n```\n插件系统架构\n├── 核心插件接口\n│   ├── PanelPlugin（前端面板插件）\n│   ├── BackendPlugin（后端服务插件）\n│   └── AgentToolPlugin（Agent 工具插件）\n├── 插件注册表（Registry）\n│   ├── 内置插件列表\n│   └── 第三方插件列表\n└── 插件加载器（Loader）\n    ├── 前端：动态导入 + React.lazy\n    └── 后端：Python importlib + 依赖注入\n```\n\n**3.1 前端插件系统**\n\n创建 [`lib/plugins/`](free-todo-frontend/lib/plugins/) 目录：\n\n```typescript\n// lib/plugins/types.ts\ninterface PanelPlugin {\n  id: string;\n  name: string;\n  icon: string;\n  version: string;\n  dependencies?: string[];\n  component: () => Promise<{ default: React.ComponentType }>;\n  enabled: boolean;\n}\n\n// lib/plugins/registry.ts\nclass PluginRegistry {\n  private plugins: Map<string, PanelPlugin> = new Map();\n\n  register(plugin: PanelPlugin): void;\n  get(id: string): PanelPlugin | undefined;\n  getEnabled(): PanelPlugin[];\n  setEnabled(id: string, enabled: boolean): void;\n}\n```\n\n修改 [`components/layout/PanelContent.tsx`](free-todo-frontend/components/layout/PanelContent.tsx)：\n\n- 从硬编码 if-else 改为注册表驱动\n- 使用 `React.lazy()` + `Suspense` 实现懒加载\n\n**3.2 后端插件系统**\n\n创建 [`lifetrace/plugins/`](lifetrace/plugins/) 目录：\n\n```python\n# lifetrace/plugins/base.py\nclass BackendPlugin(ABC):\n    id: str\n    name: str\n    version: str\n    dependencies: list[str] = []\n\n    @abstractmethod\n    def register_routes(self, app: FastAPI) -> None: ...\n\n    @abstractmethod\n    def startup(self) -> None: ...\n\n    @abstractmethod\n    def shutdown(self) -> None: ...\n```\n\n**3.3 插件配置持久化**\n\n在 [`config/config.yaml`](lifetrace/config/config.yaml) 中添加：\n\n```yaml\nplugins:\n  enabled:\n    - todos\n    - calendar\n    - chat\n  disabled:\n    - achievements\n    - debugShots\n  third_party:\n    - path: ~/.freetodo/plugins/my-plugin\n```\n\n---\n\n## 问题 4: Todo 标准统一（iCalendar/ICS 格式）\n\n### 当前 Todo 模型与 iCalendar VTODO 对照\n\n| 当前字段 | iCalendar 属性 | 说明 |\n\n|---------|---------------|------|\n\n| `id` | `UID` | 唯一标识符 |\n\n| `name` | `SUMMARY` | 摘要/标题 |\n\n| `description` | `DESCRIPTION` | 详细描述 |\n\n| `deadline` | `DUE` | 截止时间 |\n\n| `start_time` | `DTSTART` | 开始时间 |\n\n| `status` | `STATUS` | 状态（需映射） |\n\n| `priority` | `PRIORITY` | 优先级（需映射） |\n\n| `created_at` | `CREATED` | 创建时间 |\n\n| `updated_at` | `LAST-MODIFIED` | 修改时间 |\n\n| - | `COMPLETED` | 完成时间（需添加） |\n\n| - | `PERCENT-COMPLETE` | 完成百分比（可选） |\n\n| - | `CATEGORIES` | 分类/标签 |\n\n| - | `RRULE` | 重复规则（可选） |\n\n### 实现方案\n\n**4.1 添加缺失字段**\n\n修改 [`lifetrace/storage/models.py`](lifetrace/storage/models.py)：\n\n```python\nclass Todo(TimestampMixin, table=True):\n    # 新增字段\n    completed_at: datetime | None = None  # 完成时间\n    percent_complete: int = Field(default=0, ge=0, le=100)  # 完成百分比\n    rrule: str | None = None  # iCalendar RRULE 格式的重复规则\n    uid: str = Field(default_factory=lambda: str(uuid.uuid4()))  # 全局唯一ID\n```\n\n**4.2 添加 iCalendar 服务**\n\n创建 [`lifetrace/services/icalendar_service.py`](lifetrace/services/icalendar_service.py)：\n\n```python\nfrom icalendar import Calendar, Todo as VTodo\n\nclass ICalendarService:\n    def export_todos(self, todos: list[Todo]) -> str:\n        \"\"\"导出为 ICS 格式\"\"\"\n\n    def import_todos(self, ics_content: str) -> list[TodoCreate]:\n        \"\"\"从 ICS 格式导入\"\"\"\n\n    def _status_to_ical(self, status: str) -> str:\n        \"\"\"状态映射：active->NEEDS-ACTION, completed->COMPLETED, canceled->CANCELLED\"\"\"\n```\n\n**4.3 添加 API 端点**\n\n修改 [`lifetrace/routers/todo.py`](lifetrace/routers/todo.py)：\n\n```python\n@router.get(\"/export/ics\")\nasync def export_ics(status: str | None = None) -> Response:\n    \"\"\"导出为 ICS 文件\"\"\"\n\n@router.post(\"/import/ics\")\nasync def import_ics(file: UploadFile) -> list[TodoResponse]:\n    \"\"\"从 ICS 文件导入\"\"\"\n```\n\n**4.4 添加依赖**\n\n```toml\ndependencies = [\n    \"icalendar>=6.0.0\",  # iCalendar 解析/生成库\n]\n```\n\n---\n\n## 问题 5: 社交媒体 Agent 集成（Telegram + 飞书）\n\n### 架构设计\n\n```\n用户设备 (PC)                    社交媒体平台\n    │                                │\n    ├── FreeTodo Server ◄────────────┤\n    │   ├── Agent Gateway            │\n    │   │   ├── Telegram Bot ◄───────┼── Telegram API\n    │   │   └── Feishu Bot ◄─────────┼── 飞书开放平台\n    │   └── AgnoAgentService         │\n    │       └── FreeTodoToolkit      │\n    └────────────────────────────────┘\n```\n\n**5.1 Telegram Bot 集成**\n\n创建 [`lifetrace/integrations/telegram/`](lifetrace/integrations/telegram/) 目录：\n\n```python\n# lifetrace/integrations/telegram/bot.py\nfrom telegram import Update\nfrom telegram.ext import Application, CommandHandler, MessageHandler\n\nclass FreeTodoTelegramBot:\n    def __init__(self, token: str, agent_service: AgnoAgentService):\n        self.app = Application.builder().token(token).build()\n        self.agent = agent_service\n\n    async def handle_message(self, update: Update, context):\n        \"\"\"处理用户消息，转发给 Agent\"\"\"\n        response = await self.agent.chat(update.message.text)\n        await update.message.reply_text(response)\n```\n\n添加依赖：\n\n```toml\n[dependency-groups]\ntelegram = [\"python-telegram-bot>=21.0\"]\n```\n\n**5.2 飞书 Bot 集成**\n\n创建 [`lifetrace/integrations/feishu/`](lifetrace/integrations/feishu/) 目录：\n\n```python\n# lifetrace/integrations/feishu/bot.py\nimport httpx\n\nclass FreeTodoFeishuBot:\n    def __init__(self, app_id: str, app_secret: str, agent_service: AgnoAgentService):\n        self.app_id = app_id\n        self.app_secret = app_secret\n        self.agent = agent_service\n\n    async def handle_event(self, event: dict):\n        \"\"\"处理飞书事件回调\"\"\"\n\n    async def send_message(self, open_id: str, content: str):\n        \"\"\"发送消息给用户\"\"\"\n```\n\n添加依赖：\n\n```toml\n[dependency-groups]\nfeishu = [\"lark-oapi>=1.0.0\"]\n```\n\n**5.3 统一网关路由**\n\n修改 [`lifetrace/routers/`](lifetrace/routers/) 添加：\n\n```python\n# lifetrace/routers/integrations.py\n@router.post(\"/telegram/webhook\")\nasync def telegram_webhook(update: dict):\n    \"\"\"Telegram Webhook 回调\"\"\"\n\n@router.post(\"/feishu/webhook\")\nasync def feishu_webhook(event: dict):\n    \"\"\"飞书事件回调\"\"\"\n```\n\n**5.4 配置管理**\n\n修改 [`config/config.yaml`](lifetrace/config/config.yaml)：\n\n```yaml\nintegrations:\n  telegram:\n    enabled: false\n    bot_token: \"\"\n  feishu:\n    enabled: false\n    app_id: \"\"\n    app_secret: \"\"\n    verification_token: \"\"\n```\n\n---\n\n## 问题 6: 依赖项清理\n\n### 可能未使用的依赖\n\n根据代码分析，以下依赖可能未使用或使用较少：\n\n| 依赖 | 状态 | 建议 |\n\n|-----|------|------|\n\n| `wikipedia>=1.4.0` | 未找到导入 | 移除或移至可选 |\n\n| `arxiv>=2.4.0` | 未找到导入 | 移除或移至可选 |\n\n| `whisperlivekit>=0.1.0` | 未找到导入 | 移除或移至可选 |\n\n| `python-socks>=2.0.0` | websockets 可选依赖 | 如不需代理可移除 |\n\n| `opencc-python-reimplemented` | 繁简转换，使用较少 | 移至可选 |\n\n### 清理步骤\n\n1. 使用 `pip-autoremove` 或手动检查每个依赖的实际使用情况\n2. 运行测试确保移除后功能正常\n3. 更新 `pyproject.toml` 和 `uv.lock`\n\n### 依赖分组重构\n\n```toml\n[project]\ndependencies = [\n    # 仅保留核心必需依赖\n    \"fastapi>=0.100.0\",\n    \"uvicorn[standard]>=0.20.0\",\n    \"pydantic>=2.0.0\",\n    \"sqlalchemy>=2.0.0\",\n    \"sqlmodel>=0.0.22\",\n    \"alembic>=1.14.0\",\n    \"apscheduler>=3.10.0\",\n    \"dynaconf[yaml]>=3.2.0\",\n    \"loguru>=0.7.3\",\n    \"psutil>=5.9.0\",\n    \"pyyaml>=6.0\",\n    \"openai>=1.0.0\",\n    \"agno>=2.4.1\",\n    # OCR 核心\n    \"mss>=9.0.0\",\n    \"Pillow>=10.0.0\",\n    \"rapidocr-onnxruntime\",\n    \"numpy>=1.21.0,<2.0.0\",\n]\n\n[dependency-groups]\ndev = [\"pre-commit>=4.4.0\", \"ruff>=0.14.4\", \"pyinstaller>=6.0.0\"]\nvector = [\"sentence-transformers>=2.2.0\", \"chromadb>=0.4.0\", \"scipy>=1.9.0\", \"hdbscan>=0.8.0\"]\naudio = [\"faster-whisper>=1.0.0\", \"pyaudio>=0.2.14\"]\nsearch = [\"ddgs>=8.0.0\", \"tavily-python>=0.5.0\"]\ntelegram = [\"python-telegram-bot>=21.0\"]\nfeishu = [\"lark-oapi>=1.0.0\"]\ncalendar = [\"icalendar>=6.0.0\"]\n```\n\n---\n\n## 实施优先级建议\n\n| 优先级 | 任务 | 预计影响 |\n\n|-------|------|---------|\n\n| P0 | 启动速度优化 | 用户体验大幅提升 |\n\n| P0 | 依赖项清理 + 分组 | 为体积优化做准备 |\n\n| P1 | 打包体积优化 | 下载和安装体验改善 |\n\n| P1 | iCalendar 标准支持 | 与其他日历软件互通 |\n\n| P2 | 插件系统（模块化懒加载） | 代码架构改善 |\n\n| P2 | Telegram 集成 | 新功能 |\n\n| P3 | 飞书集成 | 新功能 |\n\n| P3 | 插件系统（第三方支持） | 生态建设 |\n"
  },
  {
    "path": ".cursor/plans/tauri_迁移方案_38d8ea4b.plan.md",
    "content": "---\nname: Tauri 迁移方案\noverview: 使用 Tauri 替代 Electron，采用 HTTP Sidecar 模式与 Python 后端集成。保持现有 API 和前端代码不变，最小化迁移成本。\ntodos:\n  - id: tauri-init\n    content: 初始化 Tauri 项目：pnpm tauri init，配置 tauri.conf.json\n    status: completed\n  - id: rust-main\n    content: 实现 Rust 主进程 main.rs，包含应用启动逻辑\n    status: completed\n  - id: rust-backend\n    content: 实现 backend.rs：Python 后端 Sidecar 管理（启动/健康检查/停止）\n    status: completed\n  - id: rust-nextjs\n    content: 实现 nextjs.rs：Next.js standalone 服务器管理\n    status: completed\n  - id: tauri-tray\n    content: 实现系统托盘功能（Tauri SystemTray API）\n    status: completed\n  - id: tauri-shortcut\n    content: 实现全局快捷键（Tauri GlobalShortcut Plugin）\n    status: completed\n  - id: dev-test\n    content: 测试开发模式运行：tauri dev\n    status: completed\n  - id: prod-build\n    content: 测试生产构建：tauri build\n    status: completed\n  - id: cross-platform\n    content: 跨平台测试：Windows/macOS/Linux\n    status: completed\nisProject: false\n---\n\n# Tauri 替代 Electron 迁移方案\n\n## 当前架构\n\n```mermaid\ngraph TB\n    subgraph electron [Electron App]\n        main[main.ts - Electron 主进程]\n        preload[preload.ts]\n        webview[Chromium WebView]\n    end\n\n    subgraph nextjs [Next.js Server]\n        standalone[standalone/server.js]\n        static[静态资源]\n    end\n\n    subgraph python [Python Backend]\n        fastapi[FastAPI Server]\n        sidecar[Sidecar 进程]\n    end\n\n    main -->|spawn| standalone\n    main -->|spawn| sidecar\n    webview -->|HTTP| standalone\n    webview -->|HTTP| fastapi\n```\n\n## 目标架构\n\n```mermaid\ngraph TB\n    subgraph tauri [Tauri App]\n        rust[main.rs - Rust 主进程]\n        webview[System WebView]\n    end\n\n    subgraph nextjs [Next.js Server]\n        standalone[standalone/server.js]\n        static[静态资源]\n    end\n\n    subgraph python [Python Backend]\n        fastapi[FastAPI Server]\n        sidecar[Sidecar 进程]\n    end\n\n    rust -->|Sidecar API| standalone\n    rust -->|Sidecar API| sidecar\n    webview -->|HTTP| standalone\n    webview -->|HTTP| fastapi\n```\n\n## 迁移策略\n\n采用 **HTTP Sidecar 模式**：\n\n- Python 后端作为独立 Sidecar 进程运行\n- 前端通过 HTTP 直接访问 FastAPI\n- Rust 主进程负责启动/管理 Sidecar 进程\n- 前端代码和后端 API 几乎不需要改动\n\n---\n\n## 第一步：创建 Tauri 项目结构\n\n在 `free-todo-frontend/` 目录下初始化 Tauri：\n\n```\nfree-todo-frontend/\n├── src-tauri/              # 新增：Tauri Rust 代码\n│   ├── Cargo.toml\n│   ├── tauri.conf.json\n│   ├── src/\n│   │   ├── main.rs\n│   │   ├── backend.rs      # Python 后端管理\n│   │   ├── nextjs.rs       # Next.js 服务器管理\n│   │   └── lib.rs\n│   └── icons/\n├── electron/               # 保留：可选择性删除或并行维护\n├── app/                    # 不变：Next.js 前端\n└── ...\n```\n\n**命令**：\n\n```bash\ncd free-todo-frontend\npnpm add -D @tauri-apps/cli@latest\npnpm tauri init\n```\n\n---\n\n## 第二步：配置 tauri.conf.json\n\n```json\n{\n  \"$schema\": \"https://schema.tauri.app/config/2.1\",\n  \"productName\": \"FreeTodo\",\n  \"version\": \"0.1.0\",\n  \"identifier\": \"com.freeugroup.freetodo\",\n  \"build\": {\n    \"beforeBuildCommand\": \"pnpm build\",\n    \"devUrl\": \"http://localhost:3000\",\n    \"frontendDist\": \"../.next/standalone\"\n  },\n  \"app\": {\n    \"withGlobalTauri\": true,\n    \"windows\": [\n      {\n        \"title\": \"FreeTodo\",\n        \"width\": 1200,\n        \"height\": 800,\n        \"resizable\": true,\n        \"fullscreen\": false\n      }\n    ],\n    \"security\": {\n      \"csp\": null\n    }\n  },\n  \"bundle\": {\n    \"active\": true,\n    \"icon\": [\"icons/icon.png\"],\n    \"targets\": [\"dmg\", \"nsis\", \"appimage\"],\n    \"externalBin\": [\"backend/lifetrace\"],\n    \"resources\": [\"standalone/**/*\", \"backend/**/*\"]\n  }\n}\n```\n\n---\n\n## 第三步：实现 Rust 主进程\n\n### 3.1 主入口 (src/main.rs)\n\n```rust\n#![cfg_attr(not(debug_assertions), windows_subsystem = \"windows\")]\n\nmod backend;\nmod nextjs;\n\nuse tauri::Manager;\n\nfn main() {\n    tauri::Builder::default()\n        .setup(|app| {\n            let handle = app.handle().clone();\n\n            // 启动 Python 后端\n            tauri::async_runtime::spawn(async move {\n                if let Err(e) = backend::start_backend(&handle).await {\n                    eprintln!(\"Failed to start backend: {}\", e);\n                }\n            });\n\n            // 启动 Next.js 服务器（打包模式）\n            #[cfg(not(debug_assertions))]\n            tauri::async_runtime::spawn(async move {\n                if let Err(e) = nextjs::start_nextjs(&handle).await {\n                    eprintln!(\"Failed to start Next.js: {}\", e);\n                }\n            });\n\n            Ok(())\n        })\n        .run(tauri::generate_context!())\n        .expect(\"error while running tauri application\");\n}\n```\n\n### 3.2 Python 后端管理 (src/backend.rs)\n\n```rust\nuse std::process::Command;\nuse std::path::PathBuf;\nuse tauri::AppHandle;\n\npub async fn start_backend(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> {\n    let resource_path = app.path().resource_dir()?;\n    let backend_path = resource_path.join(\"backend\").join(\"lifetrace\");\n    let data_dir = app.path().app_data_dir()?;\n\n    // 检测是否已有后端运行\n    if check_backend_health(8001).await {\n        println!(\"Backend already running on port 8001\");\n        return Ok(());\n    }\n\n    // 启动后端进程\n    let mut cmd = Command::new(&backend_path);\n    cmd.args([\"--port\", \"8001\", \"--data-dir\", data_dir.to_str().unwrap()]);\n    cmd.spawn()?;\n\n    // 等待后端就绪\n    wait_for_backend(8001, 30).await?;\n\n    Ok(())\n}\n\nasync fn check_backend_health(port: u16) -> bool {\n    let url = format!(\"http://127.0.0.1:{}/health\", port);\n    reqwest::get(&url).await.map(|r| r.status().is_success()).unwrap_or(false)\n}\n\nasync fn wait_for_backend(port: u16, timeout_secs: u64) -> Result<(), &'static str> {\n    for _ in 0..timeout_secs * 2 {\n        if check_backend_health(port).await {\n            return Ok(());\n        }\n        tokio::time::sleep(std::time::Duration::from_millis(500)).await;\n    }\n    Err(\"Backend did not start in time\")\n}\n```\n\n---\n\n## 第四步：功能迁移对照表\n\n| Electron 模块 | Tauri 对应 | 迁移方式 |\n\n|--------------|-----------|---------|\n\n| [`main.ts`](free-todo-frontend/electron/main.ts) | `src/main.rs` | 重写（Rust） |\n\n| [`backend-server.ts`](free-todo-frontend/electron/backend-server.ts) | `src/backend.rs` | 重写（Rust） |\n\n| [`next-server.ts`](free-todo-frontend/electron/next-server.ts) | `src/nextjs.rs` | 重写（Rust） |\n\n| [`window-manager.ts`](free-todo-frontend/electron/window-manager.ts) | `tauri.conf.json` | 配置化 |\n\n| [`tray-manager.ts`](free-todo-frontend/electron/tray-manager.ts) | Tauri SystemTray API | 重写（Rust） |\n\n| [`global-shortcut-manager.ts`](free-todo-frontend/electron/global-shortcut-manager.ts) | Tauri GlobalShortcut Plugin | 重写（Rust） |\n\n| [`ipc-handlers.ts`](free-todo-frontend/electron/ipc-handlers.ts) | Tauri Commands | 按需迁移 |\n\n| [`preload.ts`](free-todo-frontend/electron/preload.ts) | `withGlobalTauri` | 不需要 |\n\n| [`config.ts`](free-todo-frontend/electron/config.ts) | Rust 配置模块 | 重写（Rust） |\n\n---\n\n## 第五步：打包配置\n\n### 5.1 Cargo.toml 依赖\n\n```toml\n[dependencies]\ntauri = { version = \"2\", features = [\"tray-icon\", \"protocol-asset\"] }\ntauri-plugin-shell = \"2\"\ntauri-plugin-notification = \"2\"\ntauri-plugin-global-shortcut = \"2\"\ntokio = { version = \"1\", features = [\"full\"] }\nreqwest = { version = \"0.12\", features = [\"json\"] }\nserde = { version = \"1\", features = [\"derive\"] }\nserde_json = \"1\"\n\n[build-dependencies]\ntauri-build = \"2\"\n```\n\n### 5.2 打包脚本更新\n\n修改 [`package.json`](free-todo-frontend/package.json)：\n\n```json\n{\n  \"scripts\": {\n    \"tauri:dev\": \"tauri dev\",\n    \"tauri:build\": \"pnpm build && pnpm backend:build && tauri build\",\n    \"tauri:build-win\": \"pnpm build && pnpm backend:build:win && tauri build --target x86_64-pc-windows-msvc\",\n    \"tauri:build-mac\": \"pnpm build && pnpm backend:build && tauri build --target universal-apple-darwin\",\n    \"tauri:build-linux\": \"pnpm build && pnpm backend:build && tauri build --target x86_64-unknown-linux-gnu\"\n  }\n}\n```\n\n---\n\n## 第六步：前端适配\n\n### 6.1 环境检测\n\n修改前端代码检测运行环境：\n\n```typescript\n// lib/utils/platform.ts\nexport const isTauri = () => {\n  return typeof window !== 'undefined' && '__TAURI__' in window;\n};\n\nexport const isElectron = () => {\n  return typeof window !== 'undefined' &&\n    typeof window.process !== 'undefined' &&\n    window.process.type === 'renderer';\n};\n```\n\n### 6.2 API 调用保持不变\n\n由于采用 HTTP Sidecar 模式，前端的 API 调用方式**完全不变**：\n\n```typescript\n// 现有代码继续使用，无需修改\nconst response = await fetch('http://localhost:8001/api/todos');\n```\n\n---\n\n## 第七步：构建流程\n\n### 7.1 开发模式\n\n```bash\n# 终端 1：启动 Python 后端\ncd lifetrace && python -m lifetrace.server\n\n# 终端 2：启动 Next.js 开发服务器\ncd free-todo-frontend && pnpm dev\n\n# 终端 3：启动 Tauri 开发模式\ncd free-todo-frontend && pnpm tauri dev\n```\n\n### 7.2 生产构建\n\n```bash\ncd free-todo-frontend\n\n# 1. 构建 Next.js standalone\npnpm build\n\n# 2. 构建 Python 后端（PyInstaller）\npnpm backend:build\n\n# 3. 构建 Tauri 应用\npnpm tauri build\n```\n\n---\n\n## 预期收益\n\n| 指标 | Electron 现状 | Tauri 预期 |\n\n|-----|--------------|-----------|\n\n| 前端体积 | 150-200 MB (Chromium) | 3-10 MB (System WebView) |\n\n| 内存占用 | 200-400 MB | 50-100 MB |\n\n| 启动速度 | 3-4 秒 | 1-2 秒 |\n\n| 总打包体积 | 2.6 GB | 2.4-2.5 GB |\n\n注：总体积减少有限，因为 Python 后端体积不变。主要收益在启动速度和内存占用。\n\n---\n\n## 风险与注意事项\n\n1. **Rust 学习曲线**：需要学习 Rust 基础语法\n2. **系统 WebView 兼容性**：\n\n   - Windows: WebView2 (Edge)，需要用户安装（Win11 内置）\n   - macOS: WKWebView（系统内置）\n   - Linux: WebKitGTK（需要安装）\n\n3. **Island 模式**：Tauri 的无边框窗口支持有差异，需要适配\n4. **全局快捷键**：Tauri 插件 API 与 Electron 略有不同\n5. **IPC 通信**：如果有 Electron IPC 调用，需要迁移到 Tauri Commands\n\n---\n\n## 迁移步骤清单\n\n1. 初始化 Tauri 项目（`pnpm tauri init`）\n2. 配置 `tauri.conf.json`\n3. 实现 Rust 主进程（`main.rs`）\n4. 实现后端管理模块（`backend.rs`）\n5. 实现 Next.js 服务器管理（`nextjs.rs`）\n6. 实现系统托盘（Tauri SystemTray）\n7. 实现全局快捷键（Tauri GlobalShortcut Plugin）\n8. 测试开发模式运行\n9. 测试生产构建\n10. 跨平台测试（Windows/macOS/Linux）\n"
  },
  {
    "path": ".cursor/plans/后台持续录音方案_c7c8f0fe.plan.md",
    "content": "---\nname: 后台持续录音方案\noverview: 分析\"后端驱动录音\"方案的改造范围，并提供一个基于 Electron 主进程的更优替代方案，使录音不受面板切换影响。\ntodos:\n  - id: analyze-electron-audio\n    content: 实现 Electron 主进程音频录制模块 (audio-recorder.ts)\n    status: pending\n  - id: add-ipc-channels\n    content: 添加录音控制 IPC 通道 (ipc-handlers.ts, preload.ts)\n    status: pending\n  - id: refactor-frontend-hook\n    content: 重构 useAudioRecording hook 使用 IPC 调用\n    status: pending\nisProject: false\n---\n\n# 后台持续录音方案分析\n\n## 当前架构\n\n```mermaid\nsequenceDiagram\n    participant User as 用户\n    participant Panel as AudioPanel渲染进程\n    participant WS as WebSocket\n    participant Backend as Python后端\n    participant ASR as 阿里云FunASR\n\n    User->>Panel: 点击开始录音\n    Panel->>Panel: getUserMedia获取麦克风\n    Panel->>Panel: AudioContext处理音频\n    loop 录音中\n        Panel->>WS: 发送PCM16数据\n        WS->>Backend: 转发音频流\n        Backend->>ASR: 发送到ASR服务\n        ASR-->>Backend: 返回转录结果\n        Backend-->>Panel: 推送转录文本\n    end\n    User->>User: 切换面板\n    Note over Panel: 组件卸载，资源释放\n    Note over Panel: 录音停止！\n```\n\n**问题根源**：录音资源（WebSocket、AudioContext、MediaStream）都在 `AudioPanel` 组件内部，组件卸载时资源被释放。\n\n---\n\n## 方案 3：Python 后端驱动录音\n\n### 需要改造的内容\n\n1. **启用 PyAudio 进行系统级音频采集**\n\n   - 文件：[lifetrace/services/audio_service.py](lifetrace/services/audio_service.py)\n   - 新增：音频录制线程/协程\n   - 依赖：`pyaudio>=0.2.14`（已声明但未使用）\n\n2. **新增后端录音管理 API**\n\n   - `POST /api/audio/recording/start` - 启动录音\n   - `POST /api/audio/recording/stop` - 停止录音\n   - `GET /api/audio/recording/status` - 获取录音状态\n   - WebSocket 仅用于推送转录结果，不再接收音频\n\n3. **跨平台音频设备访问**\n\n   - Windows：需要安装 PyAudio 的 Windows 预编译包\n   - macOS：需要 PortAudio 库\n   - Linux：需要 ALSA 开发库\n\n4. **前端改造**\n\n   - 移除 `useAudioRecording` 中的音频采集逻辑\n   - 改为调用后端 API 控制录音\n   - WebSocket 只接收转录结果\n\n### 为什么改造范围大\n\n| 改造点 | 复杂度 | 原因 |\n\n|--------|--------|------|\n\n| PyAudio 跨平台部署 | 高 | 需要编译原生依赖，不同 OS 配置不同 |\n\n| 音频设备权限管理 | 高 | Python 进程需要独立申请麦克风权限 |\n\n| 后端架构变化 | 中 | 从无状态变为有状态（持有录音资源） |\n\n| 前后端接口重构 | 中 | WebSocket 协议从双向音频流变为单向推送 |\n\n---\n\n## 推荐方案：Electron 主进程驱动录音（方案 3 的变体）\n\n由于这是一个 Electron 桌面应用，**在 Electron 主进程中实现录音**是更优的选择。\n\n### 架构对比\n\n```mermaid\nflowchart TB\n    subgraph current [当前架构]\n        direction TB\n        R1[渲染进程] --> |音频采集| R1\n        R1 --> |WebSocket| B1[Python后端]\n        Note1[面板切换导致组件卸载]\n    end\n\n    subgraph proposed [推荐架构]\n        direction TB\n        M2[Electron主进程] --> |音频采集| M2\n        R2[渲染进程] --> |IPC控制| M2\n        M2 --> |WebSocket| B2[Python后端]\n        Note2[主进程独立于渲染进程]\n    end\n```\n\n### 为什么更优\n\n1. **Electron 主进程独立运行**：不受渲染进程中的 React 组件生命周期影响\n2. **已有预留接口**：`tray-manager.ts` 中已有 `startRecording()`/`stopRecording()` 占位符\n3. **IPC 机制完善**：已有成熟的 `contextBridge` + `ipcMain/ipcRenderer` 通信\n4. **无需跨平台编译**：Electron 自身处理了跨平台问题\n\n### 需要改造的内容\n\n1. **Electron 主进程**（约 200-300 行代码）\n\n   - 新增 [electron/audio-recorder.ts](free-todo-frontend/electron/audio-recorder.ts)：使用 `node-record-lpcm16` 或 `node-microphone` 录音\n   - 修改 [electron/ipc-handlers.ts](free-todo-frontend/electron/ipc-handlers.ts)：添加录音控制 IPC 通道\n   - 修改 [electron/preload.ts](free-todo-frontend/electron/preload.ts)：暴露录音 API\n\n2. **前端组件**（约 50-100 行代码）\n\n   - 修改 [apps/audio/hooks/useAudioRecording.ts](free-todo-frontend/apps/audio/hooks/useAudioRecording.ts)：改用 IPC 调用主进程录音\n   - 可选：添加全局录音状态 store\n\n3. **后端无需改动**：继续通过 WebSocket 接收音频流\n\n### IPC 通道设计\n\n```typescript\n// 新增 IPC 通道\n'audio:start-recording'   // 开始录音\n'audio:stop-recording'    // 停止录音\n'audio:recording-status'  // 录音状态变化（主进程 → 渲染进程）\n'audio:transcription'     // 转录结果推送（主进程 → 渲染进程）\n```\n\n---\n\n## 两种方案对比\n\n| 维度 | Python 后端驱动 | Electron 主进程驱动（推荐） |\n\n|------|----------------|---------------------------|\n\n| 改造范围 | 大（前后端 + 跨平台编译） | 中（仅前端 Electron 层） |\n\n| 跨平台兼容性 | 需要处理 PyAudio 部署 | Electron 原生支持 |\n\n| 架构变化 | 后端从无状态变有状态 | 仅前端内部调整 |\n\n| 复用现有代码 | 较少 | 复用 IPC、WebSocket 等 |\n\n| 开发周期 | 较长 | 较短 |\n\n---\n\n## 结论\n\n**如果您追求最小改动量**，推荐使用 **Electron 主进程驱动录音** 方案。它保持了现有的前后端分离架构，只在 Electron 层做改动，且 Electron 主进程天然不受面板切换影响。\n\n**如果您有其他考虑**（如支持纯 Web 版本、与后端深度集成等），再考虑 Python 后端驱动方案。\n"
  },
  {
    "path": ".cursor/plans/打包与性能优化_ecd1657a.plan.md",
    "content": "---\nname: 打包与性能优化\noverview: 针对启动速度、依赖效率和打包体积三个核心问题，评估 Tauri + Nuitka 方案，并提供依赖精简和模块剔除的具体实施方案。\ntodos:\n  - id: remove-unused-deps\n    content: 移除未使用依赖：faster-whisper, whisperlivekit, wikipedia, arxiv\n    status: pending\n  - id: update-pyinstaller-excludes\n    content: 更新 PyInstaller excludes：添加测试模块、开发工具、文档等排除项\n    status: pending\n  - id: use-opencv-headless\n    content: 替换 opencv-python 为 opencv-python-headless\n    status: pending\n  - id: update-electron-builder\n    content: 更新 electron-builder.yml 排除 .d.ts、测试、文档等\n    status: pending\n  - id: refactor-dependency-groups\n    content: 重构 pyproject.toml 创建 vector/imaging/search 依赖组\n    status: pending\n  - id: tauri-poc\n    content: 创建 Tauri POC 分支验证与 Python 后端的 Sidecar 集成\n    status: pending\n  - id: nuitka-test\n    content: 使用 Nuitka 编译后端并对比体积和启动速度\n    status: pending\nisProject: false\n---\n"
  },
  {
    "path": ".gitattributes",
    "content": "# Auto detect text files and perform LF normalization\n* text=auto\n"
  },
  {
    "path": ".githooks/post-checkout",
    "content": "#!/usr/bin/env sh\nset -u\n\nrepo_root=\"$(git rev-parse --show-toplevel 2>/dev/null || true)\"\nif [ -z \"$repo_root\" ]; then\n  echo \"post-checkout: unable to locate repo root; skipping link_worktree_deps.\" >&2\n  exit 0\nfi\n\nrun_ps=0\nsh_script=\"$repo_root/scripts/link_worktree_deps_here.sh\"\nif [ -f \"$sh_script\" ]; then\n  if command -v bash >/dev/null 2>&1; then\n    bash \"$sh_script\" || run_ps=1\n  else\n    sh \"$sh_script\" || run_ps=1\n  fi\nelse\n  run_ps=1\nfi\n\nif [ \"$run_ps\" -eq 1 ]; then\n  ps_script=\"$repo_root/scripts/link_worktree_deps_here.ps1\"\n  if [ -f \"$ps_script\" ]; then\n    if command -v powershell.exe >/dev/null 2>&1; then\n      powershell.exe -ExecutionPolicy Bypass -File \"$ps_script\" || true\n    elif command -v pwsh >/dev/null 2>&1; then\n      pwsh -ExecutionPolicy Bypass -File \"$ps_script\" || true\n    else\n      echo \"post-checkout: PowerShell not found; skipping link_worktree_deps.\" >&2\n    fi\n  else\n    echo \"post-checkout: missing link_worktree_deps scripts; skipping.\" >&2\n  fi\nfi\n\nexit 0\n"
  },
  {
    "path": ".githooks/pre-commit",
    "content": "#!/usr/bin/env sh\nset -u\n\nif command -v pre-commit >/dev/null 2>&1; then\n  exec pre-commit run --hook-stage pre-commit \"$@\"\nfi\n\nif command -v uv >/dev/null 2>&1; then\n  exec uv run pre-commit run --hook-stage pre-commit \"$@\"\nfi\n\necho \"pre-commit: pre-commit/uv not found; skipping.\" >&2\nexit 0\n"
  },
  {
    "path": ".github/BACKEND_GUIDELINES.md",
    "content": "# Backend Development Guidelines\n\n**Language**: [English](BACKEND_GUIDELINES.md) | [中文](BACKEND_GUIDELINES_CN.md)\n\n## 🐍 Python Backend Development Standards\n\nThis document details the development standards and best practices for the LifeTrace project backend (Python + FastAPI).\n\n### Tech Stack\n\n- **Framework**: FastAPI + Uvicorn (async web framework)\n- **Language**: Python 3.12\n- **ORM**: SQLAlchemy 2.x + SQLModel\n- **Database Migration**: Alembic\n- **Data Validation**: Pydantic 2.x\n- **Configuration Management**: Dynaconf (supports YAML hot reload)\n- **Logging**: Loguru\n- **Scheduler**: APScheduler (background task scheduling)\n- **OCR**: RapidOCR (local OCR recognition)\n- **Vector Database**: ChromaDB (optional, for semantic search)\n- **Text Embeddings**: sentence-transformers (optional)\n- **LLM**: OpenAI-compatible API\n- **Package Manager**: uv (recommended)\n- **Code Quality**: Ruff (lint/format/check)\n\n## 📋 Table of Contents\n\n- [Code Style](#-code-style)\n- [Project Architecture](#️-project-architecture)\n- [Project Structure](#️-project-structure)\n- [Naming Conventions](#-naming-conventions)\n- [Type Annotations](#-type-annotations)\n- [Docstrings](#-docstrings)\n- [Error Handling](#-error-handling)\n- [API Design](#-api-design)\n- [Layered Architecture](#-layered-architecture)\n- [Database Operations](#-database-operations)\n- [Configuration Management](#-configuration-management)\n- [LLM Services](#-llm-services)\n- [Background Tasks](#-background-tasks)\n- [Testing](#-testing)\n- [Logging](#-logging)\n- [Performance](#-performance)\n- [Security](#-security)\n- [API and Frontend Interaction](#-api-and-frontend-interaction)\n- [Dependency Management](#-dependency-management)\n\n## 🎨 Code Style\n\n### PEP 8 Standard\n\nWe follow the [PEP 8](https://peps.python.org/pep-0008/) Python code style guide.\n\n### Using Ruff\n\nThe project uses [Ruff](https://github.com/astral-sh/ruff) as the linter and formatter.\n\n```bash\n# Check code\nuv run ruff check .\n\n# Auto-fix issues\nuv run ruff check --fix .\n\n# Format code\nuv run ruff format .\n```\n\n### Basic Rules\n\n#### Indentation\n\n```python\n# ✅ Correct: Use 4 spaces\ndef my_function():\n    if condition:\n        do_something()\n\n# ❌ Wrong: Use tabs\ndef my_function():\n\tif condition:\n\t\tdo_something()\n```\n\n#### Line Length\n\n```python\n# ✅ Correct: Maximum 100 characters per line\ndef calculate_result(\n    param1: int, param2: str, param3: float\n) -> dict[str, Any]:\n    return {\"result\": param1}\n\n# ❌ Wrong: Line too long\ndef calculate_result(param1: int, param2: str, param3: float, param4: dict, param5: list) -> dict[str, Any]:\n    return {\"result\": param1}\n```\n\n#### File Length Limits\n\nTo maintain code maintainability and readability, we provide the following guidelines for Python file length:\n\n**Code Length Guidelines**:\n\n- **Recommended Standard**: Keep single files under **500 lines**\n- **Warning Threshold**: Consider refactoring when exceeding **700 lines**\n- **Review Required**: Files over **1000 lines** must include justification and refactoring plan in PR\n\n**Refactoring Guidelines**:\n\n- Single function should not exceed **50 statements**\n- Single class should not exceed **400 lines**\n- Cyclomatic complexity must not exceed **15**\n- Prioritize functional cohesion over strict line count limits\n\n```python\n# ✅ Correct: Moderate file length (~450 lines)\n# task_service.py\nclass TaskService:\n    \"\"\"Task service containing complete task business logic.\"\"\"\n\n    def create_task(self, data: dict) -> Task:\n        \"\"\"Create a task.\"\"\"\n        pass\n\n    def update_task(self, task_id: int, data: dict) -> Task:\n        \"\"\"Update a task.\"\"\"\n        pass\n\n    # ... other related methods\n\n# ⚠️ Needs Review: Files over 1000 lines\n# complex_processor.py (1200 lines)\n# PR must explain:\n# 1. Why is this file so long?\n# 2. Can it be split? How?\n# 3. If not, what's the refactoring plan?\n\n# ❌ Wrong: Function too complex (over 50 statements)\ndef process_data(data):\n    # ... 100 lines of code\n    pass\n```\n\n**When to Split Files**:\n\n1. **Multiple Responsibilities**: A file handles multiple unrelated responsibilities\n\n   ```text\n   # Before: user_operations.py (800 lines)\n   # Contains: user management, permissions, data export, email notifications\n\n   # After:\n   users/\n   ├── manager.py          # User management\n   ├── permissions.py      # Permission validation\n   ├── export_service.py   # Data export\n   └── notifications.py    # Email notifications\n   ```\n\n2. **Large Classes**: A single class exceeds 400 lines\n\n   ```text\n   # Before: task_handler.py (600 lines)\n   class TaskHandler:\n       # Contains: CRUD, validation, statistics, reporting\n\n   # After:\n   tasks/\n   ├── manager.py      # TaskManager - CRUD operations\n   ├── validator.py    # TaskValidator - Data validation\n   ├── stats.py        # TaskStats - Statistics\n   └── reporter.py     # TaskReporter - Report generation\n   ```\n\n3. **Long Functions**: A single function exceeds 50 statements, extract sub-functions\n\n   ```python\n   # ❌ Wrong: Function too long\n   def process_order(order_data):\n       # Validate data (20 lines)\n       # Calculate price (30 lines)\n       # Create order (25 lines)\n       # Send notification (15 lines)\n       pass  # Total 90 lines\n\n   # ✅ Correct: Split into multiple functions\n   def process_order(order_data):\n       validated_data = validate_order_data(order_data)\n       price = calculate_order_price(validated_data)\n       order = create_order(validated_data, price)\n       send_order_notification(order)\n       return order\n   ```\n\n#### Imports\n\n```python\n# ✅ Correct: Import order and grouping\n# 1. Standard library imports\nimport os\nimport sys\nfrom datetime import datetime\nfrom typing import Any, Optional\n\n# 2. Third-party library imports\nimport numpy as np\nfrom fastapi import APIRouter, Depends, HTTPException\nfrom pydantic import BaseModel\nfrom sqlalchemy import select\n\n# 3. Local application/library imports\nfrom lifetrace.storage.database import get_db\nfrom lifetrace.schemas.task import TaskCreate, TaskResponse\n\n# ❌ Wrong: Mixed import order\nfrom lifetrace.storage.database import get_db\nimport os\nfrom fastapi import APIRouter\n```\n\n#### Quotes\n\n```python\n# ✅ Correct: Use double quotes\nmessage = \"Hello, World!\"\nquery = \"SELECT * FROM users\"\n\n# ✅ Correct: Triple quotes for docstrings\ndescription = \"\"\"\nThis is a multi-line string.\n\"\"\"\n```\n\n## 🏗️ Project Architecture\n\n### Layered Architecture\n\nThe project follows a layered architecture pattern:\n\n```\nRouter Layer (routers/)     → HTTP request handling, parameter validation\n    ↓\nService Layer (services/)   → Business logic, orchestration of multiple Repository operations\n    ↓\nRepository Layer (repositories/) → Data access abstraction, database query encapsulation\n    ↓\nStorage Layer (storage/)    → SQLAlchemy ORM model definitions\n```\n\n**Layer Responsibilities**:\n\n- **Router Layer**: Handle HTTP requests, parameter validation, call Service layer\n- **Service Layer**: Business logic, orchestrate multiple Repository operations\n- **Repository Layer**: Data access abstraction, encapsulate database queries\n- **Schema Layer**: Request/response Pydantic models\n- **Storage Layer**: SQLAlchemy ORM model definitions\n\n## 🏗️ Project Structure\n\n### Directory Organization\n\n```\nlifetrace/\n├── server.py                 # FastAPI application entry\n├── config/                   # Configuration files\n│   ├── config.yaml          # User configuration\n│   ├── default_config.yaml  # Default configuration\n│   └── prompt.yaml          # LLM Prompt templates\n├── routers/                  # API routes (Router layer)\n├── services/                 # Business services (Service layer)\n├── repositories/             # Data access (Repository layer)\n├── schemas/                  # Pydantic data models\n├── storage/                  # Data storage layer\n│   ├── models.py            # SQLAlchemy models (database tables)\n│   └── *_manager.py         # Data managers\n├── llm/                      # LLM and AI services\n├── jobs/                     # Background tasks\n├── core/                     # Core dependencies and lazy-loaded services\n└── util/                     # Utility functions\n```\n\n## 📝 Naming Conventions\n\n### Variables and Functions\n\n```python\n# ✅ Correct: snake_case\nuser_name = \"Alice\"\nuser_age = 25\n\ndef get_user_profile(user_id: int):\n    pass\n\n# ❌ Wrong: camelCase\nuserName = \"Alice\"\n\ndef getUserProfile(userId: int):\n    pass\n```\n\n### Classes\n\n```python\n# ✅ Correct: PascalCase\nclass UserManager:\n    pass\n\nclass TaskScheduler:\n    pass\n\n# ❌ Wrong: snake_case\nclass user_manager:\n    pass\n```\n\n### Constants\n\n```python\n# ✅ Correct: UPPER_CASE\nMAX_RETRY_COUNT = 3\nDEFAULT_TIMEOUT = 30\nAPI_BASE_URL = \"https://api.example.com\"\n\n# ❌ Wrong: lowercase\nmax_retry_count = 3\n```\n\n## 🔤 Type Annotations\n\n### Basic Type Annotations\n\n```python\n# ✅ Correct: Add type annotations\ndef greet(name: str) -> str:\n    return f\"Hello, {name}!\"\n\ndef add_numbers(a: int, b: int) -> int:\n    return a + b\n\ndef get_user(user_id: int) -> dict | None:\n    return None\n\n# ❌ Wrong: No type annotations\ndef greet(name):\n    return f\"Hello, {name}!\"\n```\n\n### Collection Types\n\n```python\n# Python 3.9+: Use built-in types\ndef process_items(items: list[str]) -> dict[str, int]:\n    return {item: len(item) for item in items}\n\n# Type aliases\nfrom typing import TypeAlias\n\nUserID: TypeAlias = int\nUserData: TypeAlias = dict[str, Any]\n\ndef get_user_data(user_id: UserID) -> UserData:\n    return {\"id\": user_id, \"name\": \"Alice\"}\n```\n\n### Pydantic Models\n\n```python\nfrom pydantic import BaseModel, Field\n\nclass User(BaseModel):\n    \"\"\"User model.\"\"\"\n    id: int\n    name: str = Field(..., min_length=1, max_length=100)\n    email: str\n    age: Optional[int] = Field(None, ge=0, le=150)\n    is_active: bool = True\n\n    class Config:\n        from_attributes = True\n```\n\n## 📚 Docstrings\n\n### Function Docstrings\n\n```python\ndef create_task(\n    title: str,\n    description: str | None = None,\n    project_id: int | None = None\n) -> Task:\n    \"\"\"\n    Create a new task.\n\n    Args:\n        title: Task title, required and non-empty\n        description: Task description, optional\n        project_id: Associated project ID, optional\n\n    Returns:\n        Task: Created task object\n\n    Raises:\n        ValueError: If title is empty\n        DatabaseError: If database operation fails\n\n    Example:\n        >>> task = create_task(\"Complete docs\", \"Write API docs\", 1)\n        >>> print(task.title)\n        Complete docs\n    \"\"\"\n    if not title:\n        raise ValueError(\"Task title cannot be empty\")\n\n    # Implementation...\n    return task\n```\n\n### Class Docstrings\n\n```python\nclass TaskManager:\n    \"\"\"\n    Task manager.\n\n    Provides CRUD operations and advanced query functionality for tasks.\n\n    Attributes:\n        db: Database session object\n        logger: Logger instance\n\n    Example:\n        >>> manager = TaskManager(db_session)\n        >>> task = await manager.create_task(task_data)\n    \"\"\"\n\n    def __init__(self, db: AsyncSession):\n        \"\"\"\n        Initialize task manager.\n\n        Args:\n            db: Async database session\n        \"\"\"\n        self.db = db\n```\n\n## 🚨 Error Handling\n\n### Exception Handling\n\n```python\nfrom fastapi import HTTPException\n\n# ✅ Correct: Catch specific exceptions\nasync def get_task(task_id: int) -> Task:\n    try:\n        task = await task_manager.get_task(task_id)\n        if task is None:\n            raise HTTPException(status_code=404, detail=\"Task not found\")\n        return task\n    except DatabaseError as e:\n        logger.error(f\"Database error: {e}\")\n        raise HTTPException(status_code=500, detail=\"Database operation failed\")\n\n# ❌ Wrong: Catch all exceptions\nasync def get_task(task_id: int) -> Task:\n    try:\n        task = await task_manager.get_task(task_id)\n        return task\n    except Exception as e:  # Too broad\n        raise HTTPException(status_code=500, detail=\"Error occurred\")\n```\n\n## 🌐 API Design\n\n### RESTful API Standards\n\n```python\nfrom fastapi import APIRouter, Depends, Query, Path\nfrom lifetrace.repositories.task_repository import TaskRepository\nfrom lifetrace.services.task_service import TaskService\n\nrouter = APIRouter(prefix=\"/api/tasks\", tags=[\"tasks\"])\n\n# ✅ Correct: RESTful route design\n@router.get(\"/\", response_model=list[TaskResponse])\nasync def list_tasks(\n    skip: int = Query(0, ge=0),\n    limit: int = Query(10, ge=1, le=100),\n    status: Optional[str] = Query(None),\n    task_service: TaskService = Depends(get_task_service)\n):\n    \"\"\"List tasks.\"\"\"\n    return await task_service.list_tasks(skip=skip, limit=limit, status=status)\n\n@router.get(\"/{task_id}\", response_model=TaskResponse)\nasync def get_task(\n    task_id: int = Path(..., gt=0),\n    task_service: TaskService = Depends(get_task_service)\n):\n    \"\"\"Get specific task.\"\"\"\n    return await task_service.get_task(task_id)\n\n@router.post(\"/\", response_model=TaskResponse, status_code=201)\nasync def create_task(\n    task: TaskCreate,\n    task_service: TaskService = Depends(get_task_service)\n):\n    \"\"\"Create new task.\"\"\"\n    return await task_service.create_task(task)\n\n@router.put(\"/{task_id}\", response_model=TaskResponse)\nasync def update_task(\n    task_id: int = Path(..., gt=0),\n    task: TaskUpdate = None,\n    task_service: TaskService = Depends(get_task_service)\n):\n    \"\"\"Update task.\"\"\"\n    return await task_service.update_task(task_id, task)\n\n@router.delete(\"/{task_id}\", status_code=204)\nasync def delete_task(\n    task_id: int = Path(..., gt=0),\n    task_service: TaskService = Depends(get_task_service)\n):\n    \"\"\"Delete task.\"\"\"\n    await task_service.delete_task(task_id)\n```\n\n### Registering Routes\n\nImport and register new routes in `server.py`:\n\n```python\nfrom lifetrace.routers import tasks\n\napp.include_router(tasks.router)\n```\n\n## 🏛️ Layered Architecture\n\n### Router Layer\n\nHandle HTTP requests, parameter validation, call Service layer:\n\n```python\n# routers/tasks.py\nfrom fastapi import APIRouter, Depends\nfrom lifetrace.services.task_service import TaskService\n\nrouter = APIRouter(prefix=\"/api/tasks\", tags=[\"tasks\"])\n\n@router.get(\"/\", response_model=list[TaskResponse])\nasync def list_tasks(\n    skip: int = Query(0, ge=0),\n    limit: int = Query(10, ge=1, le=100),\n    task_service: TaskService = Depends(get_task_service)\n):\n    \"\"\"List tasks.\"\"\"\n    return await task_service.list_tasks(skip=skip, limit=limit)\n```\n\n### Service Layer\n\nImplement complex business logic, orchestrate multiple Repository operations:\n\n```python\n# services/task_service.py\nfrom lifetrace.repositories.task_repository import TaskRepository\nfrom lifetrace.schemas.task import TaskCreate, TaskUpdate\n\nclass TaskService:\n    \"\"\"Task service.\"\"\"\n\n    def __init__(self, task_repository: TaskRepository):\n        self.task_repository = task_repository\n\n    async def create_task(self, task_data: TaskCreate) -> Task:\n        \"\"\"Create task with business logic.\"\"\"\n        # Business validation\n        if len(task_data.title) > 200:\n            raise ValueError(\"Task title too long\")\n\n        # Orchestrate repository operations\n        return await self.task_repository.create(task_data)\n```\n\n### Repository Layer\n\nData access abstraction, encapsulate database queries:\n\n```python\n# repositories/task_repository.py\nfrom sqlalchemy.ext.asyncio import AsyncSession\nfrom sqlalchemy import select\nfrom lifetrace.storage.models import Task\n\nclass TaskRepository:\n    \"\"\"Task repository.\"\"\"\n\n    def __init__(self, db: AsyncSession):\n        self.db = db\n\n    async def get_by_id(self, task_id: int) -> Task | None:\n        \"\"\"Get task by ID.\"\"\"\n        result = await self.db.execute(\n            select(Task).where(Task.id == task_id)\n        )\n        return result.scalar_one_or_none()\n\n    async def create(self, task_data: TaskCreate) -> Task:\n        \"\"\"Create task.\"\"\"\n        task = Task(**task_data.model_dump())\n        self.db.add(task)\n        await self.db.commit()\n        await self.db.refresh(task)\n        return task\n```\n\n## ⚙️ Configuration Management\n\n### Configuration File Structure\n\n- `config/default_config.yaml` - Default configuration (do not modify)\n- `config/config.yaml` - User configuration (overrides default values)\n- Uses Dynaconf for configuration hot reload\n\n### Accessing Configuration\n\nAccess via `settings` object in `util/settings.py`:\n\n```python\nfrom lifetrace.util.settings import settings\n\n# Access nested configuration\nport = settings.server.port\n\n# Access with default value\ntimeout = settings.get(\"timeout\", default=30)\n```\n\n### Configuration Hot Reload\n\nThe following configurations support hot reload (no restart required):\n- LLM configuration\n- Recorder configuration\n- OCR configuration\n\n## 💾 Database Operations\n\n### SQLAlchemy Models\n\n```python\nfrom datetime import datetime\nfrom sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text\nfrom sqlalchemy.orm import relationship\n\nclass Task(Base):\n    \"\"\"Task model.\"\"\"\n    __tablename__ = \"tasks\"\n\n    id = Column(Integer, primary_key=True, index=True)\n    title = Column(String(200), nullable=False, index=True)\n    description = Column(Text, nullable=True)\n    status = Column(String(50), nullable=False, default=\"pending\", index=True)\n    priority = Column(Integer, nullable=False, default=0)\n    project_id = Column(Integer, ForeignKey(\"projects.id\"), nullable=True)\n    created_at = Column(DateTime, nullable=False, default=datetime.utcnow)\n    updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)\n\n    # Relationships\n    project = relationship(\"Project\", back_populates=\"tasks\")\n```\n\n### Database Migrations\n\nThe project uses Alembic for database migrations:\n\n- **Config file**: `alembic.ini`\n- **Migration scripts**: `migrations/versions/`\n\n**Common commands**:\n- `alembic revision --autogenerate -m \"description\"` - Generate migration script\n- `alembic upgrade head` - Apply all migrations\n- `alembic downgrade -1` - Rollback one version\n- `alembic history` - View migration history\n\n### Database Queries\n\nUse Repository layer for database queries (see [Repository Layer](#-repository-layer) above).\n\n## 🧪 Testing\n\n### Unit Tests\n\n```python\nimport pytest\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom lifetrace.schemas.task import TaskCreate\nfrom lifetrace.storage.task_manager import TaskManager\n\n@pytest.mark.asyncio\nasync def test_create_task(db_session: AsyncSession):\n    \"\"\"Test task creation.\"\"\"\n    manager = TaskManager(db_session)\n    task_data = TaskCreate(title=\"Test Task\")\n\n    task = await manager.create_task(task_data)\n\n    assert task.id is not None\n    assert task.title == \"Test Task\"\n    assert task.status == \"pending\"\n```\n\n## 🤖 LLM Services\n\n### LLM Client Usage\n\nThe project uses OpenAI-compatible API, wrapped in `llm/llm_client.py`:\n\n- Supports Alibaba Cloud Tongyi Qianwen, OpenAI, Claude, etc.\n- Configuration managed in `config/config.yaml` under `llm` section\n- Supports streaming responses (SSE)\n\n### RAG Service\n\n`llm/rag_service.py` provides Retrieval-Augmented Generation:\n\n- Intelligent time parsing (e.g., \"last week\", \"yesterday\")\n- Hybrid retrieval strategy (vector retrieval + full-text search)\n- Context compression and ranking\n\n### Prompt Management\n\nPrompt templates are stored in `config/prompt.yaml`:\n\n- Use YAML format for easy maintenance\n- Support variable interpolation\n- Organized by feature modules\n\n## ⏰ Background Tasks\n\n### Task Scheduling\n\nUse APScheduler to manage background tasks:\n\n- Tasks defined in `lifetrace/jobs/` directory\n- Managed through `job_manager.py`\n- Supports scheduled tasks and interval tasks\n\n### Task Types\n\n- **recorder**: Screen recorder, scheduled screenshots\n- **ocr**: OCR processor, processes screenshots waiting for recognition\n\n## 📊 Logging\n\nUse Loguru for logging, import logger from `util/logging_config.py`:\n\n```python\nfrom lifetrace.util.logging_config import logger\n\nclass TaskService:\n    \"\"\"Task service.\"\"\"\n\n    async def create_task(self, task_data: TaskCreate) -> Task:\n        \"\"\"Create task.\"\"\"\n        logger.info(f\"Creating task: {task_data.title}\")\n\n        try:\n            task = Task(**task_data.model_dump())\n            self.db.add(task)\n            await self.db.commit()\n            await self.db.refresh(task)\n\n            logger.info(f\"Task created successfully: ID={task.id}\")\n            return task\n\n        except Exception as e:\n            logger.error(f\"Failed to create task: {e}\")\n            await self.db.rollback()\n            raise\n```\n\n### Logging Guidelines\n\n- Must log critical operations\n- Must log complete stack trace for exceptions\n- Must sanitize sensitive information (API Keys, etc.)\n- Use structured logging for easy analysis\n\n## ⚡ Performance\n\n### Query Optimization\n\n```python\n# ✅ Correct: Use eager loading\nfrom sqlalchemy.orm import selectinload\n\nasync def get_tasks_with_projects(self) -> list[Task]:\n    \"\"\"Get tasks with their associated projects.\"\"\"\n    result = await self.db.execute(\n        select(Task).options(selectinload(Task.project))\n    )\n    return list(result.scalars().all())\n\n# ✅ Correct: Batch insert\nasync def create_tasks_batch(self, tasks_data: list[TaskCreate]) -> list[Task]:\n    \"\"\"Create multiple tasks.\"\"\"\n    tasks = [Task(**data.model_dump()) for data in tasks_data]\n    self.db.add_all(tasks)\n    await self.db.commit()\n    return tasks\n```\n\n## 🔒 Security\n\n### Input Validation\n\n```python\n# ✅ Correct: Use Pydantic for validation\nfrom pydantic import BaseModel, Field, field_validator\n\nclass TaskCreate(BaseModel):\n    title: str = Field(..., min_length=1, max_length=200)\n    description: Optional[str] = Field(None, max_length=2000)\n\n    @field_validator(\"title\")\n    @classmethod\n    def validate_title(cls, v: str) -> str:\n        if \"<script>\" in v.lower():\n            raise ValueError(\"Title contains illegal characters\")\n        return v\n```\n\n### SQL Injection Prevention\n\n```python\n# ✅ Correct: Use parameterized queries (SQLAlchemy handles this)\ntask = await self.db.execute(\n    select(Task).where(Task.id == task_id)\n)\n\n# ❌ Wrong: String concatenation (vulnerable to SQL injection)\nquery = f\"SELECT * FROM tasks WHERE id = {task_id}\"\n```\n\n## 📡 API and Frontend Interaction\n\n### Naming Style Conversion\n\nBackend uses `snake_case`, frontend uses `camelCase`:\n\n- Frontend fetcher automatically performs conversion\n- Backend Schema uniformly uses `snake_case`\n- OpenAPI Schema automatically generated by FastAPI\n\n### Frontend Code Generation\n\nFrontend uses Orval to auto-generate API code from OpenAPI Schema:\n\n- After backend API changes, frontend runs `pnpm orval` to regenerate\n- Ensure OpenAPI Schema is complete and accurate\n\n## 📦 Dependency Management\n\n### Using uv\n\nThe project uses uv as the package manager:\n\n- `uv sync` - Sync dependencies\n- `uv add <package>` - Add dependency\n- `uv remove <package>` - Remove dependency\n- `uv run <command>` - Run command in virtual environment\n\n### Dependency Groups\n\n- Main dependencies: `dependencies` in `pyproject.toml`\n- Development dependencies: `dependency-groups.dev`\n- Optional dependencies: `dependency-groups.vector` (vector search functionality)\n\n## ✅ Code Review Checklist\n\nBefore submitting code, ensure:\n\n- [ ] Code follows PEP 8 style guide\n- [ ] `uv run ruff check .` passes with no errors\n- [ ] `uv run ruff format .` applied\n- [ ] All functions have type annotations\n- [ ] Public functions have docstrings\n- [ ] Proper error handling added\n- [ ] Parameterized queries used (no SQL injection)\n- [ ] Appropriate logging added\n- [ ] Unit tests written\n- [ ] Tests pass\n- [ ] Documentation updated\n- [ ] API changes reflected in OpenAPI Schema\n- [ ] Follows layered architecture (Router → Service → Repository)\n- [ ] Configuration supports hot reload (if applicable)\n- [ ] Background tasks properly scheduled\n\n---\n\nHappy Coding! 🐍\n"
  },
  {
    "path": ".github/BACKEND_GUIDELINES_CN.md",
    "content": "# 后端开发规范\n\n**语言**: [English](BACKEND_GUIDELINES.md) | [中文](BACKEND_GUIDELINES_CN.md)\n\n## 🐍 Python 后端开发规范\n\n本文档详细说明了 LifeTrace 项目后端（Python + FastAPI）的开发规范和最佳实践。\n\n### 技术栈\n\n- **框架**: FastAPI + Uvicorn（异步 Web 框架）\n- **语言**: Python 3.12\n- **ORM**: SQLAlchemy 2.x + SQLModel\n- **数据库迁移**: Alembic\n- **数据验证**: Pydantic 2.x\n- **配置管理**: Dynaconf（支持 YAML 热重载）\n- **日志**: Loguru\n- **调度器**: APScheduler（后台任务调度）\n- **OCR**: RapidOCR（本地 OCR 识别）\n- **向量数据库**: ChromaDB（可选，用于语义搜索）\n- **文本嵌入**: sentence-transformers（可选）\n- **LLM**: OpenAI 兼容 API\n- **包管理**: uv（推荐）\n- **代码质量**: Ruff（lint/format/check）\n\n## 📋 目录\n\n- [代码风格](#-代码风格)\n- [项目架构](#️-项目架构)\n- [项目结构](#️-项目结构)\n- [命名规范](#-命名规范)\n- [类型注解](#-类型注解)\n- [文档字符串](#-文档字符串)\n- [错误处理](#-错误处理)\n- [API 设计](#-api-设计)\n- [分层架构](#-分层架构)\n- [数据库操作](#-数据库操作)\n- [配置管理](#-配置管理)\n- [LLM 服务](#-llm-服务)\n- [后台任务](#-后台任务)\n- [测试](#-测试)\n- [日志记录](#-日志记录)\n- [性能优化](#-性能优化)\n- [安全性](#-安全性)\n- [API 与前端交互](#-api-与前端交互)\n- [依赖管理](#-依赖管理)\n\n## 🎨 代码风格\n\n### PEP 8 标准\n\n我们遵循 [PEP 8](https://peps.python.org/pep-0008/) Python 代码风格指南。\n\n### 使用 Ruff\n\n项目使用 [Ruff](https://github.com/astral-sh/ruff) 作为代码检查器和格式化工具。\n\n```bash\n# 检查代码\nuv run ruff check .\n\n# 自动修复问题\nuv run ruff check --fix .\n\n# 格式化代码\nuv run ruff format .\n```\n\n### 基本规则\n\n#### 缩进和空格\n\n```python\n# ✅ 正确：使用 4 个空格缩进\ndef my_function():\n    if condition:\n        do_something()\n\n# ❌ 错误：使用 Tab 缩进\ndef my_function():\n\tif condition:\n\t\tdo_something()\n```\n\n#### 行长度\n\n```python\n# ✅ 正确：每行不超过 100 字符\ndef calculate_result(\n    param1: int, param2: str, param3: float\n) -> dict[str, Any]:\n    return {\"result\": param1}\n\n# ❌ 错误：行太长\ndef calculate_result(param1: int, param2: str, param3: float, param4: dict, param5: list) -> dict[str, Any]:\n    return {\"result\": param1}\n```\n\n#### 文件长度限制\n\n为了保持代码的可维护性和可读性，我们对单个 Python 文件的行数提供以下指导原则：\n\n**代码长度指导原则**：\n\n- **推荐标准**：单个文件保持在 **500 行**以内\n- **警戒线**：超过 **700 行**时应考虑拆分\n- **必须审查**：超过 **1000 行**的文件需在 PR 中说明理由，并提供重构计划\n\n**拆分建议**：\n\n- 单个函数不超过 **50 条语句**\n- 单个类不超过 **400 行**\n- 圈复杂度不超过 **15**\n- 优先考虑功能内聚性，而不是强制行数限制\n\n```python\n# ✅ 正确：适度的文件长度（约 450 行）\n# task_service.py\nclass TaskService:\n    \"\"\"任务服务，包含完整的任务业务逻辑。\"\"\"\n\n    def create_task(self, data: dict) -> Task:\n        \"\"\"创建任务。\"\"\"\n        pass\n\n    def update_task(self, task_id: int, data: dict) -> Task:\n        \"\"\"更新任务。\"\"\"\n        pass\n\n    # ... 其他相关方法\n\n# ⚠️ 需要审查：超过 1000 行的文件\n# complex_processor.py (1200 行)\n# 在 PR 中需要说明：\n# 1. 为什么这个文件这么长？\n# 2. 是否可以拆分？如何拆分？\n# 3. 如果不能拆分，有什么重构计划？\n\n# ❌ 错误：函数过于复杂（超过 50 条语句）\ndef process_data(data):\n    # ... 100 行代码\n    pass\n```\n\n**何时需要拆分文件**：\n\n1. **文件职责过多**：一个文件承担了多个不相关的职责\n\n   ```text\n   # 拆分前：user_operations.py (800 行)\n   # 包含：用户管理、权限验证、数据导出、邮件通知\n\n   # 拆分后：\n   users/\n   ├── manager.py          # 用户管理\n   ├── permissions.py      # 权限验证\n   ├── export_service.py   # 数据导出\n   └── notifications.py    # 邮件通知\n   ```\n\n2. **类过大**：单个类的代码行数超过 400 行\n\n   ```text\n   # 拆分前：task_handler.py (600 行)\n   class TaskHandler:\n       # 包含：CRUD、验证、统计、报表生成\n\n   # 拆分后：\n   tasks/\n   ├── manager.py      # TaskManager - CRUD 操作\n   ├── validator.py    # TaskValidator - 数据验证\n   ├── stats.py        # TaskStats - 统计功能\n   └── reporter.py     # TaskReporter - 报表生成\n   ```\n\n3. **函数过长**：单个函数超过 50 条语句，应该提取子函数\n\n   ```python\n   # ❌ 错误：函数过长\n   def process_order(order_data):\n       # 验证数据（20 行）\n       # 计算价格（30 行）\n       # 创建订单（25 行）\n       # 发送通知（15 行）\n       pass  # 总共 90 行\n\n   # ✅ 正确：拆分为多个函数\n   def process_order(order_data):\n       validated_data = validate_order_data(order_data)\n       price = calculate_order_price(validated_data)\n       order = create_order(validated_data, price)\n       send_order_notification(order)\n       return order\n   ```\n\n#### 导入语句\n\n```python\n# ✅ 正确：导入顺序和分组\n# 1. 标准库导入\nimport os\nimport sys\nfrom datetime import datetime\nfrom typing import Any, Optional\n\n# 2. 第三方库导入\nimport numpy as np\nfrom fastapi import APIRouter, Depends, HTTPException\nfrom pydantic import BaseModel\nfrom sqlalchemy import select\n\n# 3. 本地应用/库导入\nfrom lifetrace.storage.database import get_db\nfrom lifetrace.schemas.task import TaskCreate, TaskResponse\n\n# ❌ 错误：混乱的导入顺序\nfrom lifetrace.storage.database import get_db\nimport os\nfrom fastapi import APIRouter\n```\n\n#### 引号\n\n```python\n# ✅ 正确：使用双引号\nmessage = \"Hello, World!\"\nquery = \"SELECT * FROM users WHERE id = ?\"\n\n# ✅ 正确：三引号用于多行字符串和文档字符串\ndescription = \"\"\"\n这是一个多行字符串，\n包含多行内容。\n\"\"\"\n```\n\n## 🏗️ 项目架构\n\n### 分层架构\n\n项目采用分层架构模式：\n\n```\n路由层 (routers/)     → HTTP 请求处理，参数验证\n    ↓\n服务层 (services/)   → 业务逻辑，编排多个 Repository 操作\n    ↓\n仓储层 (repositories/) → 数据访问抽象，封装数据库查询\n    ↓\n存储层 (storage/)    → SQLAlchemy ORM 模型定义\n```\n\n**层级职责**：\n\n- **路由层**: 处理 HTTP 请求，参数验证，调用服务层\n- **服务层**: 业务逻辑，编排多个仓储层操作\n- **仓储层**: 数据访问抽象，封装数据库查询\n- **模型层**: 请求/响应的 Pydantic 模型\n- **存储层**: SQLAlchemy ORM 模型定义\n\n## 🏗️ 项目结构\n\n### 目录组织\n\n```\nlifetrace/\n├── server.py                 # FastAPI 应用入口\n├── config/                   # 配置文件目录\n│   ├── config.yaml          # 用户配置\n│   ├── default_config.yaml  # 默认配置\n│   └── prompt.yaml          # LLM Prompt 模板\n├── routers/                  # API 路由（路由层）\n├── services/                 # 业务服务（服务层）\n├── repositories/             # 数据访问（仓储层）\n├── schemas/                  # Pydantic 数据模型\n├── storage/                  # 数据存储层\n│   ├── models.py            # SQLAlchemy 模型（数据库表）\n│   └── *_manager.py         # 数据管理器\n├── llm/                      # LLM 和 AI 服务\n├── jobs/                     # 后台任务\n├── core/                     # 核心依赖和懒加载服务\n└── util/                     # 工具函数\n```\n\n## 📝 命名规范\n\n### 变量和函数\n\n```python\n# ✅ 正确：小写字母和下划线（snake_case）\nuser_name = \"Alice\"\nuser_age = 25\n\ndef get_user_profile(user_id: int):\n    pass\n\n# ❌ 错误：使用驼峰命名\nuserName = \"Alice\"\n\ndef getUserProfile(userId: int):\n    pass\n```\n\n### 类\n\n```python\n# ✅ 正确：驼峰命名（PascalCase）\nclass UserManager:\n    pass\n\nclass TaskScheduler:\n    pass\n\n# ❌ 错误：使用下划线\nclass user_manager:\n    pass\n```\n\n### 常量\n\n```python\n# ✅ 正确：全大写字母和下划线\nMAX_RETRY_COUNT = 3\nDEFAULT_TIMEOUT = 30\nAPI_BASE_URL = \"https://api.example.com\"\n\n# ❌ 错误：使用小写\nmax_retry_count = 3\n```\n\n## 🔤 类型注解\n\n### 基本类型注解\n\n```python\nfrom typing import Any, Optional\n\n# ✅ 正确：为所有函数参数和返回值添加类型注解\ndef greet(name: str) -> str:\n    return f\"Hello, {name}!\"\n\ndef add_numbers(a: int, b: int) -> int:\n    return a + b\n\ndef get_user(user_id: int) -> dict | None:\n    return None\n\n# ❌ 错误：没有类型注解\ndef greet(name):\n    return f\"Hello, {name}!\"\n```\n\n### 集合类型\n\n```python\n# Python 3.9+：使用内置类型\ndef process_items(items: list[str]) -> dict[str, int]:\n    return {item: len(item) for item in items}\n\n# ✅ 正确：为复杂类型使用类型别名\nfrom typing import TypeAlias\n\nUserID: TypeAlias = int\nUserData: TypeAlias = dict[str, Any]\n\ndef get_user_data(user_id: UserID) -> UserData:\n    return {\"id\": user_id, \"name\": \"Alice\"}\n```\n\n### Pydantic 模型\n\n```python\nfrom pydantic import BaseModel, Field\n\nclass User(BaseModel):\n    \"\"\"用户模型。\"\"\"\n    id: int\n    name: str = Field(..., min_length=1, max_length=100)\n    email: str = Field(..., pattern=r\"^[\\w\\.-]+@[\\w\\.-]+\\.\\w+$\")\n    age: Optional[int] = Field(None, ge=0, le=150)\n    is_active: bool = True\n\n    class Config:\n        from_attributes = True\n```\n\n## 📚 文档字符串\n\n### 函数文档字符串\n\n```python\ndef create_task(\n    title: str,\n    description: str | None = None,\n    project_id: int | None = None\n) -> Task:\n    \"\"\"\n    创建新任务。\n\n    Args:\n        title: 任务标题，必填且不能为空\n        description: 任务描述，可选\n        project_id: 关联的项目 ID，可选\n\n    Returns:\n        Task: 创建的任务对象\n\n    Raises:\n        ValueError: 如果标题为空\n        DatabaseError: 如果数据库操作失败\n\n    Example:\n        >>> task = create_task(\"完成文档\", \"编写 API 文档\", 1)\n        >>> print(task.title)\n        完成文档\n    \"\"\"\n    if not title:\n        raise ValueError(\"任务标题不能为空\")\n\n    # 实现逻辑...\n    return task\n```\n\n### 类文档字符串\n\n```python\nclass TaskManager:\n    \"\"\"\n    任务管理器。\n\n    提供任务的 CRUD 操作和高级查询功能。\n\n    Attributes:\n        db: 数据库会话对象\n        logger: 日志记录器\n\n    Example:\n        >>> manager = TaskManager(db_session)\n        >>> task = await manager.create_task(task_data)\n    \"\"\"\n\n    def __init__(self, db: AsyncSession):\n        \"\"\"\n        初始化任务管理器。\n\n        Args:\n            db: 异步数据库会话\n        \"\"\"\n        self.db = db\n```\n\n## 🚨 错误处理\n\n### 异常处理\n\n```python\nfrom fastapi import HTTPException\n\n# ✅ 正确：捕获特定异常\nasync def get_task(task_id: int) -> Task:\n    try:\n        task = await task_manager.get_task(task_id)\n        if task is None:\n            raise HTTPException(status_code=404, detail=\"任务不存在\")\n        return task\n    except DatabaseError as e:\n        logger.error(f\"数据库错误: {e}\")\n        raise HTTPException(status_code=500, detail=\"数据库操作失败\")\n    except ValidationError as e:\n        logger.warning(f\"验证错误: {e}\")\n        raise HTTPException(status_code=400, detail=str(e))\n\n# ❌ 错误：捕获所有异常\nasync def get_task(task_id: int) -> Task:\n    try:\n        task = await task_manager.get_task(task_id)\n        return task\n    except Exception as e:  # 太宽泛\n        raise HTTPException(status_code=500, detail=\"发生错误\")\n```\n\n## 🌐 API 设计\n\n### RESTful API 规范\n\n```python\nfrom fastapi import APIRouter, Depends, Query, Path\nfrom lifetrace.repositories.task_repository import TaskRepository\nfrom lifetrace.services.task_service import TaskService\n\nrouter = APIRouter(prefix=\"/api/tasks\", tags=[\"tasks\"])\n\n# ✅ 正确：RESTful 路由设计\n@router.get(\"/\", response_model=list[TaskResponse])\nasync def list_tasks(\n    skip: int = Query(0, ge=0),\n    limit: int = Query(10, ge=1, le=100),\n    status: Optional[str] = Query(None),\n    task_service: TaskService = Depends(get_task_service)\n):\n    \"\"\"获取任务列表。\"\"\"\n    return await task_service.list_tasks(skip=skip, limit=limit, status=status)\n\n@router.get(\"/{task_id}\", response_model=TaskResponse)\nasync def get_task(\n    task_id: int = Path(..., gt=0),\n    task_service: TaskService = Depends(get_task_service)\n):\n    \"\"\"获取指定任务。\"\"\"\n    return await task_service.get_task(task_id)\n\n@router.post(\"/\", response_model=TaskResponse, status_code=201)\nasync def create_task(\n    task: TaskCreate,\n    task_service: TaskService = Depends(get_task_service)\n):\n    \"\"\"创建新任务。\"\"\"\n    return await task_service.create_task(task)\n\n@router.put(\"/{task_id}\", response_model=TaskResponse)\nasync def update_task(\n    task_id: int = Path(..., gt=0),\n    task: TaskUpdate = None,\n    task_service: TaskService = Depends(get_task_service)\n):\n    \"\"\"更新任务。\"\"\"\n    return await task_service.update_task(task_id, task)\n\n@router.delete(\"/{task_id}\", status_code=204)\nasync def delete_task(\n    task_id: int = Path(..., gt=0),\n    task_service: TaskService = Depends(get_task_service)\n):\n    \"\"\"删除任务。\"\"\"\n    await task_service.delete_task(task_id)\n```\n\n### 注册路由\n\n在 `server.py` 中导入并注册新路由：\n\n```python\nfrom lifetrace.routers import tasks\n\napp.include_router(tasks.router)\n```\n\n## 🏛️ 分层架构\n\n### 路由层\n\n处理 HTTP 请求，参数验证，调用服务层：\n\n```python\n# routers/tasks.py\nfrom fastapi import APIRouter, Depends\nfrom lifetrace.services.task_service import TaskService\n\nrouter = APIRouter(prefix=\"/api/tasks\", tags=[\"tasks\"])\n\n@router.get(\"/\", response_model=list[TaskResponse])\nasync def list_tasks(\n    skip: int = Query(0, ge=0),\n    limit: int = Query(10, ge=1, le=100),\n    task_service: TaskService = Depends(get_task_service)\n):\n    \"\"\"获取任务列表。\"\"\"\n    return await task_service.list_tasks(skip=skip, limit=limit)\n```\n\n### 服务层\n\n实现复杂的业务逻辑，编排多个仓储层操作：\n\n```python\n# services/task_service.py\nfrom lifetrace.repositories.task_repository import TaskRepository\nfrom lifetrace.schemas.task import TaskCreate, TaskUpdate\n\nclass TaskService:\n    \"\"\"任务服务。\"\"\"\n\n    def __init__(self, task_repository: TaskRepository):\n        self.task_repository = task_repository\n\n    async def create_task(self, task_data: TaskCreate) -> Task:\n        \"\"\"创建任务（包含业务逻辑）。\"\"\"\n        # 业务验证\n        if len(task_data.title) > 200:\n            raise ValueError(\"任务标题过长\")\n\n        # 编排仓储层操作\n        return await self.task_repository.create(task_data)\n```\n\n### 仓储层\n\n数据访问抽象，封装数据库查询：\n\n```python\n# repositories/task_repository.py\nfrom sqlalchemy.ext.asyncio import AsyncSession\nfrom sqlalchemy import select\nfrom lifetrace.storage.models import Task\n\nclass TaskRepository:\n    \"\"\"任务仓储。\"\"\"\n\n    def __init__(self, db: AsyncSession):\n        self.db = db\n\n    async def get_by_id(self, task_id: int) -> Task | None:\n        \"\"\"根据 ID 获取任务。\"\"\"\n        result = await self.db.execute(\n            select(Task).where(Task.id == task_id)\n        )\n        return result.scalar_one_or_none()\n\n    async def create(self, task_data: TaskCreate) -> Task:\n        \"\"\"创建任务。\"\"\"\n        task = Task(**task_data.model_dump())\n        self.db.add(task)\n        await self.db.commit()\n        await self.db.refresh(task)\n        return task\n```\n\n## ⚙️ 配置管理\n\n### 配置文件结构\n\n- `config/default_config.yaml` - 默认配置（不要修改）\n- `config/config.yaml` - 用户配置（覆盖默认值）\n- 使用 Dynaconf 支持配置热重载\n\n### 访问配置\n\n通过 `util/settings.py` 中的 `settings` 对象访问：\n\n```python\nfrom lifetrace.util.settings import settings\n\n# 访问嵌套配置\nport = settings.server.port\n\n# 带默认值访问\ntimeout = settings.get(\"timeout\", default=30)\n```\n\n### 配置热重载\n\n以下配置支持热重载（无需重启）：\n- LLM 配置\n- 录制配置\n- OCR 配置\n\n## 💾 数据库操作\n\n### SQLAlchemy 模型\n\n```python\nfrom datetime import datetime\nfrom sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text\nfrom sqlalchemy.orm import relationship\n\nclass Task(Base):\n    \"\"\"任务模型。\"\"\"\n    __tablename__ = \"tasks\"\n\n    id = Column(Integer, primary_key=True, index=True)\n    title = Column(String(200), nullable=False, index=True)\n    description = Column(Text, nullable=True)\n    status = Column(String(50), nullable=False, default=\"pending\", index=True)\n    priority = Column(Integer, nullable=False, default=0)\n    project_id = Column(Integer, ForeignKey(\"projects.id\"), nullable=True)\n    created_at = Column(DateTime, nullable=False, default=datetime.utcnow)\n    updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)\n\n    # 关系\n    project = relationship(\"Project\", back_populates=\"tasks\")\n```\n\n### 数据库迁移\n\n项目使用 Alembic 管理数据库迁移：\n\n- **配置文件**: `alembic.ini`\n- **迁移脚本**: `migrations/versions/`\n\n**常用命令**:\n- `alembic revision --autogenerate -m \"描述\"` - 生成迁移脚本\n- `alembic upgrade head` - 应用所有迁移\n- `alembic downgrade -1` - 回滚一个版本\n- `alembic history` - 查看迁移历史\n\n### 数据库查询\n\n使用仓储层进行数据库查询（参见[仓储层](#-仓储层)部分）。\n\n## 🧪 测试\n\n### 单元测试\n\n```python\nimport pytest\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom lifetrace.schemas.task import TaskCreate\nfrom lifetrace.storage.task_manager import TaskManager\n\n@pytest.mark.asyncio\nasync def test_create_task(db_session: AsyncSession):\n    \"\"\"测试创建任务。\"\"\"\n    manager = TaskManager(db_session)\n    task_data = TaskCreate(title=\"测试任务\")\n\n    task = await manager.create_task(task_data)\n\n    assert task.id is not None\n    assert task.title == \"测试任务\"\n    assert task.status == \"pending\"\n```\n\n## 🤖 LLM 服务\n\n### LLM 客户端使用\n\n项目使用 OpenAI 兼容 API，通过 `llm/llm_client.py` 封装：\n\n- 支持阿里云通义千问、OpenAI、Claude 等\n- 配置通过 `config/config.yaml` 的 `llm` 部分管理\n- 支持流式响应（SSE）\n\n### RAG 服务\n\n`llm/rag_service.py` 提供检索增强生成：\n\n- 智能时间解析（如\"上周\"、\"昨天\"）\n- 混合检索策略（向量检索 + 全文检索）\n- 上下文压缩和排序\n\n### Prompt 管理\n\nPrompt 模板统一存放在 `config/prompt.yaml`：\n\n- 使用 YAML 格式便于维护\n- 支持变量插值\n- 按功能模块组织\n\n## ⏰ 后台任务\n\n### 任务调度\n\n使用 APScheduler 管理后台任务：\n\n- 任务定义在 `lifetrace/jobs/` 目录\n- 通过 `job_manager.py` 统一管理\n- 支持定时任务和间隔任务\n\n### 任务类型\n\n- **recorder**: 屏幕录制器，定时截图\n- **ocr**: OCR 处理器，处理待识别的截图\n\n## 📊 日志记录\n\n使用 Loguru，从 `util/logging_config.py` 导入 logger：\n\n```python\nfrom lifetrace.util.logging_config import logger\n\nclass TaskService:\n    \"\"\"任务服务。\"\"\"\n\n    async def create_task(self, task_data: TaskCreate) -> Task:\n        \"\"\"创建任务。\"\"\"\n        logger.info(f\"创建任务: {task_data.title}\")\n\n        try:\n            task = Task(**task_data.model_dump())\n            self.db.add(task)\n            await self.db.commit()\n            await self.db.refresh(task)\n\n            logger.info(f\"任务创建成功: ID={task.id}\")\n            return task\n\n        except Exception as e:\n            logger.error(f\"创建任务失败: {e}\")\n            await self.db.rollback()\n            raise\n```\n\n### 日志规范\n\n- 关键操作必须记录日志\n- 异常必须记录完整堆栈\n- 敏感信息（API Key 等）必须脱敏\n- 使用结构化日志便于分析\n\n## ⚡ 性能优化\n\n### 数据库查询优化\n\n```python\n# ✅ 正确：使用 eager loading 避免 N+1 查询\nfrom sqlalchemy.orm import selectinload\n\nasync def get_tasks_with_projects(self) -> list[Task]:\n    \"\"\"获取任务及其关联的项目。\"\"\"\n    result = await self.db.execute(\n        select(Task).options(selectinload(Task.project))\n    )\n    return list(result.scalars().all())\n\n# ✅ 正确：批量插入\nasync def create_tasks_batch(self, tasks_data: list[TaskCreate]) -> list[Task]:\n    \"\"\"批量创建任务。\"\"\"\n    tasks = [Task(**data.model_dump()) for data in tasks_data]\n    self.db.add_all(tasks)\n    await self.db.commit()\n    return tasks\n```\n\n## 🔒 安全性\n\n### 输入验证\n\n```python\n# ✅ 正确：使用 Pydantic 验证输入\nfrom pydantic import BaseModel, Field, field_validator\n\nclass TaskCreate(BaseModel):\n    title: str = Field(..., min_length=1, max_length=200)\n    description: Optional[str] = Field(None, max_length=2000)\n\n    @field_validator(\"title\")\n    @classmethod\n    def validate_title(cls, v: str) -> str:\n        # 防止 XSS\n        if \"<script>\" in v.lower():\n            raise ValueError(\"标题包含非法字符\")\n        return v\n```\n\n### SQL 注入防护\n\n```python\n# ✅ 正确：使用参数化查询（SQLAlchemy 自动处理）\ntask = await self.db.execute(\n    select(Task).where(Task.id == task_id)\n)\n\n# ❌ 错误：字符串拼接（容易受到 SQL 注入攻击）\nquery = f\"SELECT * FROM tasks WHERE id = {task_id}\"\n```\n\n## 📡 API 与前端交互\n\n### 命名风格转换\n\n后端使用 `snake_case`，前端使用 `camelCase`：\n\n- 前端 fetcher 自动进行转换\n- 后端 Schema 统一使用 `snake_case`\n- OpenAPI Schema 由 FastAPI 自动生成\n\n### 前端代码生成\n\n前端使用 Orval 根据 OpenAPI Schema 自动生成 API 代码：\n\n- 后端 API 变更后，前端运行 `pnpm orval` 重新生成\n- 确保 OpenAPI Schema 完整且准确\n\n## 📦 依赖管理\n\n### 使用 uv\n\n项目使用 uv 作为包管理器：\n\n- `uv sync` - 同步依赖\n- `uv add <package>` - 添加依赖\n- `uv remove <package>` - 移除依赖\n- `uv run <command>` - 在虚拟环境中运行命令\n\n### 依赖分组\n\n- 主依赖：`pyproject.toml` 的 `dependencies`\n- 开发依赖：`dependency-groups.dev`\n- 可选依赖：`dependency-groups.vector`（向量搜索功能）\n\n## ✅ 代码检查清单\n\n在提交代码前，请确保：\n\n- [ ] 代码遵循 PEP 8 风格指南\n- [ ] 运行 `uv run ruff check .` 没有错误\n- [ ] 运行 `uv run ruff format .` 格式化代码\n- [ ] 所有函数和类都有类型注解\n- [ ] 所有公共函数和类都有文档字符串\n- [ ] 添加了适当的错误处理\n- [ ] 使用了参数化查询防止 SQL 注入\n- [ ] 添加了必要的日志记录\n- [ ] 编写了单元测试\n- [ ] 测试通过\n- [ ] 更新了相关文档\n- [ ] API 变更已在 OpenAPI Schema 中反映\n- [ ] 遵循分层架构（Router → Service → Repository）\n- [ ] 配置支持热重载（如适用）\n- [ ] 后台任务已正确调度\n\n---\n\nHappy Coding! 🐍\n"
  },
  {
    "path": ".github/CONTRIBUTING.md",
    "content": "# Contributing Guide\n\n**Language**: [English](CONTRIBUTING.md) | [中文](CONTRIBUTING_CN.md)\n\n> ⚠️ **Important**: Before starting to contribute, please read and configure the [**Pre-commit Guide**](PRE_COMMIT_GUIDE.md). Pre-commit automatically runs code checks and formatting on every `git commit` to ensure code quality and style consistency. This is an essential tool for maintaining code quality in the project, please complete the installation and configuration.\n\n## 🎉 Welcome Contributors\n\nThank you for your interest in the FreeTodo project! We welcome and appreciate any form of contribution. Whether you're fixing a typo, reporting a bug, or proposing a major new feature, we're grateful.\n\n## 📋 Table of Contents\n\n- [Getting Started](#-getting-started)\n- [Development Setup](#️-development-setup)\n- [Git Flow Workflow](#-git-flow-workflow)\n- [Contribution Workflow](#-contribution-workflow)\n- [Coding Standards](#-coding-standards)\n- [Commit Message Guidelines](#-commit-message-guidelines)\n- [Pull Request Guidelines](#-pull-request-guidelines)\n- [Reporting Issues](#-reporting-issues)\n- [Community](#-community)\n\n### Finding Tasks\n\n1. **Browse Issues**: Check the [Issues page](https://github.com/FreeU-group/FreeTodo/issues)\n2. **Look for Labels**:\n   - `good first issue` - Simple tasks for beginners\n   - `help wanted` - Tasks that need help\n   - `bug` - Bug fixes\n   - `enhancement` - New features\n   - `documentation` - Documentation improvements\n3. **Propose Ideas**: Create an Issue for discussion if you have new ideas\n\n### Types of Contributions\n\n#### 🐛 Bug Reports\n\n- Use the Bug Report template\n- Provide detailed reproduction steps\n- Include environment information\n- Provide screenshots or logs if possible\n\n#### 💡 Feature Requests\n\n- Use the Feature Request template\n- Clearly describe the purpose and value\n- Provide usage scenarios\n- Consider technical feasibility\n\n#### 📝 Documentation\n\n- Fix errors in documentation\n- Add missing documentation\n- Improve code comments\n- Translate documentation\n\n#### 🧪 Testing\n\n- Increase test coverage\n- Fix failing tests\n- Add edge case tests\n\n#### 🔧 Code Contributions\n\n- Fix bugs\n- Implement new features\n- Performance optimization\n- Code refactoring\n\n## 🛠️ Development Setup\n\n### Prerequisites\n\n#### Backend Development\n\n- Python 3.12\n- [uv](https://github.com/astral-sh/uv) package manager\n- Git\n\n#### Frontend Development\n\n- Node.js 20+\n- pnpm package manager\n- Git\n\n### Clone Repository\n\n```bash\n# Clone your forked repository\ngit clone https://github.com/YOUR_USERNAME/FreeTodo.git\ncd FreeTodo\n\n# Add upstream repository\ngit remote add upstream https://github.com/FreeU-group/FreeTodo.git\n```\n\n### Configure Git Hooks (Pre-commit)\n\nThis repo uses a shared `.githooks/` directory. Run the setup script once per clone/worktree:\n\n```bash\n# macOS/Linux\nbash scripts/setup_hooks_here.sh\n\n# Windows (PowerShell)\npowershell -ExecutionPolicy Bypass -File scripts/setup_hooks_here.ps1\n```\n\n> **Note**: Do not run `pre-commit install` here. The repo uses `core.hooksPath` and `pre-commit install` will refuse when it is set.\n\n### Backend Setup\n\n```bash\n# Install uv (if not already installed)\n# macOS/Linux\ncurl -LsSf https://astral.sh/uv/install.sh | sh\n\n# Windows\npowershell -c \"irm https://astral.sh/uv/install.ps1 | iex\"\n\n# Install dependencies\nuv sync\n\n# Activate virtual environment\n# macOS/Linux\nsource .venv/bin/activate\n\n# Windows\n.venv\\Scripts\\activate\n\n# Start backend service\npython -m lifetrace.server\n```\n\n### Frontend Setup\n\n```bash\n# Navigate to frontend directory\ncd free-todo-frontend\n\n# Install pnpm (if not already installed)\nnpm install -g pnpm\n\n# Install dependencies\npnpm install\n\n# Start development server\npnpm dev\n```\n\n### Verify Setup\n\n1. Backend should start searching for an available port from `8001` (default runs on `http://localhost:8001`)\n2. Frontend should start searching for an available port from `3001` (default runs on `http://localhost:3001`)\n3. Frontend automatically detects the running backend port by checking the `/health` endpoint\n4. The actual ports used will be displayed in the console\n5. Visit the API documentation address shown in the console (usually `http://localhost:8001/docs`) to view the API docs\n6. Visit the frontend address shown in the console (usually `http://localhost:3001`) to view the frontend interface\n\n> **Note**: If a port is occupied, both frontend and backend will automatically search for the next available port. The console will display the actual port used.\n\n## 🌿 Git Flow Workflow\n\nFreeTodo project adopts a standardized Git Flow branch management strategy to ensure code quality and standardized development processes.\n\n### Branch Structure\n\nWe maintain the following branches:\n\n- **`main`** - Production environment branch, contains the most stable code, directly deployable\n- **`dev`** - Development environment branch for daily development and feature integration\n- **`test`** - Testing environment branch for complete integration testing\n- **`feat/*`** - Feature development branches, created from `dev`\n- **`fix/*`** - Bug fix branches, created from `dev`, `test`, or `main`\n- **`hotfix/*`** - Emergency fix branches, created from `main`\n\n### Detailed Workflow\n\nFor complete documentation on Git Flow, including branch strategy, workflow, naming conventions, common scenarios, and best practices, please refer to:\n\n📖 **[Git Flow Workflow Documentation](GIT_FLOW.md)**\n\nThis documentation includes:\n\n- 🌳 Complete branch strategy explanation\n- 🔄 Detailed workflows for various scenarios\n- 📝 Branch naming conventions\n- 🎯 Common development scenario examples\n- 💡 Best practices and tips\n- ❓ FAQ\n- 🚦 Workflow diagrams\n- 📚 Git command cheat sheet\n\n### Quick Start\n\nIf you're already familiar with Git Flow, here's a quick reference:\n\n```bash\n# 1. Create feature branch from dev\ngit checkout dev\ngit pull origin dev\ngit checkout -b feat/your-feature-name\n\n# 2. Develop and commit\ngit add .\ngit commit -m \"feat: your feature description\"\n\n# 3. Push and create PR\ngit push origin feat/your-feature-name\n# Create PR to dev branch on GitHub\n```\n\n## 📝 Contribution Workflow\n\n### 1. Create Branch\n\nAlways create a new branch from the latest `main`:\n\n```bash\n# Update local main branch\ngit checkout main\ngit pull upstream main\n\n# Create new branch\ngit checkout -b feat/your-feature-name\n# or\ngit checkout -b fix/your-bug-fix\n```\n\nBranch naming conventions:\n\n- `feat/xxx` - New features\n- `fix/xxx` - Bug fixes\n- `docs/xxx` - Documentation updates\n- `refactor/xxx` - Code refactoring\n- `test/xxx` - Test related\n- `chore/xxx` - Build tools or auxiliary changes\n\n### 2. Make Changes\n\n- Follow project coding standards\n- Write clear code comments\n- Ensure code runs correctly\n- Add or update relevant tests\n- Update relevant documentation\n\n### 3. Commit Changes\n\n```bash\n# Add changed files\ngit add .\n\n# Commit changes (follow commit message guidelines)\ngit commit -m \"feat: add new feature\"\n\n# Push to your fork\ngit push origin feat/your-feature-name\n```\n\n### 4. Create Pull Request\n\n1. Visit your fork on GitHub\n2. Click \"Compare & pull request\"\n3. Fill out the PR template\n4. Wait for review and feedback\n\n## 📐 Coding Standards\n\n### Backend Standards (Python)\n\nFor detailed backend guidelines, see: [**Backend Development Guidelines**](BACKEND_GUIDELINES.md)\n\n**Key Points**:\n\n- Follow PEP 8 style guide\n- Use type annotations (Type Hints)\n- Functions and classes need docstrings\n- Use Ruff for linting and formatting\n- Line length limit: 100 characters\n\n**Quick Check**:\n\n```bash\n# Run linting\nuv run ruff check .\n\n# Auto-format code\nuv run ruff format .\n```\n\n### Frontend Standards (TypeScript/React)\n\nFor detailed frontend guidelines, see: [**Frontend Development Guidelines**](FRONTEND_GUIDELINES.md)\n\n**Key Points**:\n\n- Use TypeScript strict mode\n- Follow React Hooks best practices\n- Use functional components\n- Use ESLint for linting\n- Use Tailwind CSS for styling\n\n**Quick Check**:\n\n```bash\ncd free-todo-frontend\n\n# Run ESLint\npnpm lint\n\n# Build test\npnpm build\n```\n\n## 💬 Commit Message Guidelines\n\nWe use [Conventional Commits](https://www.conventionalcommits.org/) specification.\n\n### Format\n\n```\n<type>(<scope>): <subject>\n\n<body>\n\n<footer>\n```\n\n### Type\n\n- `feat`: New feature\n- `fix`: Bug fix\n- `docs`: Documentation updates\n- `style`: Code formatting (no code logic changes)\n- `refactor`: Refactoring (neither new features nor bug fixes)\n- `perf`: Performance optimization\n- `test`: Adding tests\n- `chore`: Build process or auxiliary tool changes\n- `ci`: CI configuration changes\n- `revert`: Revert previous commit\n\n### Scope (Optional)\n\n- `backend`: Backend related\n- `frontend`: Frontend related\n- `api`: API related\n- `ui`: UI related\n- `db`: Database related\n- `config`: Configuration related\n\n### Examples\n\n```bash\n# New feature\ngit commit -m \"feat(frontend): add dark mode toggle button\"\n\n# Bug fix\ngit commit -m \"fix(backend): resolve screenshot capture error on Windows\"\n\n# Documentation update\ngit commit -m \"docs: update installation guide\"\n\n# Performance optimization\ngit commit -m \"perf(api): improve vector search performance\"\n\n# Multi-line commit message\ngit commit -m \"feat(backend): add task auto-association\n\n- Implement background job for task context mapping\n- Add configuration options for auto-association\n- Update API endpoints to support new feature\n\nCloses #123\"\n```\n\n## 🔍 Pull Request Guidelines\n\n### PR Title\n\nPR titles should follow the same convention as commit messages:\n\n```\n<type>(<scope>): <description>\n```\n\n### PR Description Template\n\n```markdown\n## 📝 Description\n<!-- Briefly describe the purpose and content of this PR -->\n\n## 🔗 Related Issues\n<!-- Link related issues, e.g., Closes #123 -->\n\n## 🎯 Type of Change\n<!-- Check applicable options -->\n- [ ] Bug fix\n- [ ] New feature\n- [ ] Performance optimization\n- [ ] Code refactoring\n- [ ] Documentation update\n- [ ] Test related\n- [ ] Other (please specify)\n\n## 🧪 Testing\n<!-- Describe how to test these changes -->\n- [ ] Tested locally\n- [ ] Added unit tests\n- [ ] Added integration tests\n- [ ] Updated documentation\n\n## 📸 Screenshots (if applicable)\n<!-- Provide screenshots for UI-related changes -->\n\n## ✅ Checklist\n- [ ] Code follows project coding standards\n- [ ] Performed self-review of code\n- [ ] Code has appropriate comments\n- [ ] Updated relevant documentation\n- [ ] Changes generate no new warnings\n- [ ] Added tests proving fix/feature works\n- [ ] New and existing unit tests pass locally\n- [ ] Dependent changes have been merged\n\n## 📚 Additional Notes\n<!-- Any other information reviewers should know -->\n```\n\n### Review Process\n\n1. **Automated Checks**: CI/CD runs tests and checks\n2. **Code Review**: Maintainers review your code\n3. **Feedback**: Address feedback and make changes\n4. **Merge**: After approval, maintainers merge your PR\n\n### Review Standards\n\n- ✅ Code quality and readability\n- ✅ Follow project coding standards\n- ✅ Feature completeness\n- ✅ Test coverage\n- ✅ Documentation completeness\n- ✅ Performance impact\n- ✅ Backward compatibility\n\n## 🐛 Reporting Issues\n\n### Bug Reports\n\nWhen creating a bug report, include:\n\n1. **Issue Description**: Clear and concise description\n2. **Reproduction Steps**: Step-by-step instructions\n3. **Expected Behavior**: What you expected to happen\n4. **Actual Behavior**: What actually happened\n5. **Environment Information**:\n   - OS: [e.g., Windows 11, macOS 13.0, Ubuntu 22.04]\n   - Python Version: [e.g., 3.12.0]\n   - Node.js Version: [e.g., 20.0.0]\n   - Browser: [e.g., Chrome 120.0]\n6. **Screenshots/Logs**: If applicable\n7. **Additional Context**: Any other relevant information\n\n### Feature Requests\n\nWhen creating a feature request, include:\n\n1. **Feature Description**: Clear description of the feature\n2. **Problem Context**: What problem does it solve?\n3. **Proposed Solution**: How do you expect it to work?\n4. **Alternatives**: Other solutions you've considered\n5. **Use Cases**: Specific usage examples\n6. **Additional Context**: Any other relevant information\n\n## 💬 Community\n\n### Getting Help\n\n- **GitHub Issues**: Report issues and request features\n- **GitHub Discussions**: Participate in community discussions\n- **WeChat Group**: Join our WeChat group (see README)\n- **Feishu Group**: Join our Feishu group (see README)\n\n### Stay Connected\n\n- 🌟 Star the project to show support\n- 👀 Watch the repository for updates\n- 🐦 Share the project on social media\n- 📝 Write blog posts about the project\n\n## 🎓 Learning Resources\n\n### Backend\n\n- [FastAPI Documentation](https://fastapi.tiangolo.com/)\n- [SQLAlchemy Documentation](https://docs.sqlalchemy.org/)\n- [Pydantic Documentation](https://docs.pydantic.dev/)\n- [Python Type Hints](https://docs.python.org/3/library/typing.html)\n\n### Frontend\n\n- [Next.js Documentation](https://nextjs.org/docs)\n- [React Documentation](https://react.dev/)\n- [TypeScript Documentation](https://www.typescriptlang.org/docs/)\n- [Tailwind CSS Documentation](https://tailwindcss.com/docs)\n\n### Git\n\n- [FreeTodo Git Flow Workflow](GIT_FLOW.md) - Project-specific Git workflow documentation\n- [Git Guide](https://rogerdudler.github.io/git-guide/)\n- [Git and GitHub Tutorial](https://www.freecodecamp.org/news/git-and-github-for-beginners/)\n\n## 📊 Contributors\n\nThanks to all the people who have contributed to FreeTodo!\n\n![Contributors](https://contrib.rocks/image?repo=FreeU-group/FreeTodo)\n\n## ❓ FAQ\n\n### I'm new to programming. Can I contribute?\n\nAbsolutely! We welcome contributors of all levels. You can start with:\n\n- Fixing typos in documentation\n- Improving documentation and comments\n- Working on `good first issue` labeled issues\n- Reporting bugs and suggesting improvements\n\n### How long until my PR is reviewed?\n\nWe try to review PRs as quickly as possible, typically within 3-7 days. If there's no response after a week, please comment on your PR to remind us.\n\n### Can I work on multiple issues at once?\n\nYes, but we recommend focusing on one issue at a time to ensure quality and efficiency.\n\n### How do I keep my fork in sync with upstream?\n\n```bash\n# Fetch upstream updates\ngit fetch upstream\n\n# Merge into local main branch\ngit checkout main\ngit merge upstream/main\n\n# Push to your fork\ngit push origin main\n```\n\n### What if my PR is rejected?\n\nDon't be discouraged! This is a normal part of the development process. Maintainers will provide feedback and suggestions. Address the feedback, or seek clarification in the discussion.\n\n## 📜 License\n\nFreeTodo is licensed under the **FreeU Community License**, which is based on Apache License 2.0 with additional terms regarding commercial use.\n\nBy contributing code, you agree:\n\n1. **Your contributions will be licensed under the FreeU Community License**\n   - This license is based on Apache License 2.0, with additional commercial use terms\n   - For detailed license terms, please refer to the [LICENSE](../LICENSE) file\n\n2. **As a contributor, you agree that:**\n   - The producer may adjust the open source license as needed (making it more strict or more permissive)\n   - Your contributed code may be used for commercial purposes, including but not limited to cloud versions\n\nFor detailed license terms and contributor conditions, please refer to the [LICENSE](../LICENSE) file.\n\n---\n\n## 🙏 Thanks\n\nThank you for taking the time to read our contribution guidelines! We look forward to your contributions to make FreeTodo better!\n\nIf you have any questions, feel free to ask in Issues or join our community groups.\n\nHappy Coding! 🎉\n"
  },
  {
    "path": ".github/CONTRIBUTING_CN.md",
    "content": "# 贡献指南\n\n**语言**: [English](CONTRIBUTING.md) | [中文](CONTRIBUTING_CN.md)\n\n> ⚠️ **重要提示**：在开始贡献之前，请务必阅读并配置 [**Pre-commit 使用指南**](PRE_COMMIT_GUIDE_CN.md)。Pre-commit 会在每次 `git commit` 时自动运行代码检查和格式化，确保代码质量和风格一致性。这是项目代码质量保障的重要工具，请务必完成安装和配置。\n\n## 🎉 欢迎贡献\n\n感谢您对 FreeTodo 项目的关注！我们非常欢迎并感谢任何形式的贡献。无论您是修复一个拼写错误、报告一个 bug，还是提出一个重大的新功能，我们都非常感激。\n\n## 📋 目录\n\n- [如何开始贡献](#-如何开始贡献)\n- [开发环境设置](#️-开发环境设置)\n- [Git Flow 工作流程](#-git-flow-工作流程)\n- [贡献流程](#-贡献流程)\n- [编码规范](#-编码规范)\n- [提交信息规范](#-提交信息规范)\n- [Pull Request 指南](#-pull-request-指南)\n- [报告问题](#-报告问题)\n- [社区讨论](#-社区讨论)\n\n## 🚀 如何开始贡献\n\n### 寻找合适的任务\n\n1. **浏览 Issues**：查看 [Issues 页面](https://github.com/FreeU-group/FreeTodo/issues)\n2. **查找标签**：\n   - `good first issue` - 适合新手的简单任务\n   - `help wanted` - 需要帮助的任务\n   - `bug` - Bug 修复\n   - `enhancement` - 新功能\n   - `documentation` - 文档改进\n3. **提出想法**：如果有新的想法，先创建一个 Issue 进行讨论\n\n### 贡献类型\n\n#### 🐛 报告 Bug\n\n- 使用 Bug 报告模板\n- 提供详细的复现步骤\n- 包含环境信息（操作系统、Python 版本、Node.js 版本等）\n- 如果可能，提供截图或日志\n\n#### 💡 功能建议\n\n- 使用功能请求模板\n- 清晰描述功能的目的和价值\n- 提供使用场景示例\n- 考虑技术可行性\n\n#### 📝 改进文档\n\n- 修复文档中的错误\n- 添加缺失的文档\n- 改进代码注释\n- 翻译文档\n\n#### 🧪 编写测试\n\n- 增加测试覆盖率\n- 修复失败的测试\n- 添加边界情况测试\n\n#### 🔧 代码贡献\n\n- 修复 Bug\n- 实现新功能\n- 性能优化\n- 代码重构\n\n## 🛠️ 开发环境设置\n\n### 先决条件\n\n#### 后端开发\n\n- Python 3.12\n- [uv](https://github.com/astral-sh/uv) 包管理器\n- Git\n\n#### 前端开发\n\n- Node.js 20+\n- pnpm 包管理器\n- Git\n\n### 克隆仓库\n\n```bash\n# 克隆您 fork 的仓库\ngit clone https://github.com/YOUR_USERNAME/FreeTodo.git\ncd FreeTodo\n\n# 添加上游仓库\ngit remote add upstream https://github.com/FreeU-group/FreeTodo.git\n```\n\n### 配置 Git Hooks（Pre-commit）\n\n本仓库使用共享的 `.githooks/` 目录。每个 clone/worktree 只需执行一次：\n\n```bash\n# macOS/Linux\nbash scripts/setup_hooks_here.sh\n\n# Windows（PowerShell）\npowershell -ExecutionPolicy Bypass -File scripts/setup_hooks_here.ps1\n```\n\n> **注意**：不要运行 `pre-commit install`。仓库使用 `core.hooksPath`，因此 `pre-commit install` 会拒绝执行。\n\n### 后端设置\n\n```bash\n# 安装 uv（如果还没有安装）\n# macOS/Linux\ncurl -LsSf https://astral.sh/uv/install.sh | sh\n\n# Windows\npowershell -c \"irm https://astral.sh/uv/install.ps1 | iex\"\n\n# 安装依赖\nuv sync\n\n# 激活虚拟环境\n# macOS/Linux\nsource .venv/bin/activate\n\n# Windows\n.venv\\Scripts\\activate\n\n# 启动后端服务\npython -m lifetrace.server\n```\n\n### 前端设置\n\n```bash\n# 进入前端目录\ncd frontend\n\n# 安装 pnpm（如果还没有安装）\nnpm install -g pnpm\n\n# 安装依赖\npnpm install\n\n# 启动开发服务器\npnpm dev\n```\n\n### 验证设置\n\n1. 后端应该从 `8001` 端口开始查找可用端口（默认运行在 `http://localhost:8001`）\n2. 前端应该从 `3001` 端口开始查找可用端口（默认运行在 `http://localhost:3001`）\n3. 前端会自动通过检查 `/health` 端点检测运行中的后端端口\n4. 实际使用的端口会在控制台显示\n5. 访问控制台显示的 API 文档地址（通常为 `http://localhost:8001/docs`）查看 API 文档\n6. 访问控制台显示的前端地址（通常为 `http://localhost:3001`）查看前端界面\n\n> **注意**：如果端口被占用，前端和后端都会自动查找下一个可用端口。控制台会显示实际使用的端口。\n\n## 🌿 Git Flow 工作流程\n\nFreeTodo 项目采用规范的 Git Flow 分支管理策略，以确保代码质量和开发流程的规范性。\n\n### 分支结构\n\n我们维护以下分支：\n\n- **`main`** - 生产环境分支，包含最稳定的代码，可直接部署\n- **`dev`** - 开发环境分支，用于日常开发和功能集成\n- **`test`** - 测试环境分支，用于完整的集成测试\n- **`feat/*`** - 功能开发分支，从 `dev` 创建\n- **`fix/*`** - Bug 修复分支，从 `dev`、`test` 或 `main` 创建\n- **`hotfix/*`** - 紧急修复分支，从 `main` 创建\n\n### 详细工作流程\n\n关于 Git Flow 的完整说明，包括分支策略、工作流程、命名规范、常见场景和最佳实践，请参考：\n\n📖 **[Git Flow 工作流程详细文档](GIT_FLOW_CN.md)**\n\n该文档包含：\n\n- 🌳 完整的分支策略说明\n- 🔄 各种场景的详细工作流程\n- 📝 分支命名规范\n- 🎯 常见开发场景示例\n- 💡 最佳实践和技巧\n- ❓ 常见问题解答\n- 🚦 工作流程图\n- 📚 Git 命令速查表\n\n### 快速开始\n\n如果您已经熟悉 Git Flow，以下是快速参考：\n\n```bash\n# 1. 从 dev 创建功能分支\ngit checkout dev\ngit pull origin dev\ngit checkout -b feat/your-feature-name\n\n# 2. 开发并提交\ngit add .\ngit commit -m \"feat: your feature description\"\n\n# 3. 推送并创建 PR\ngit push origin feat/your-feature-name\n# 在 GitHub 上创建 PR 到 dev 分支\n```\n\n## 📝 贡献流程\n\n### 1. 创建分支\n\n始终从最新的 `main` 分支创建新分支：\n\n```bash\n# 更新本地 main 分支\ngit checkout main\ngit pull upstream main\n\n# 创建新分支\ngit checkout -b feat/your-feature-name\n# 或\ngit checkout -b fix/your-bug-fix\n```\n\n分支命名规范：\n\n- `feat/xxx` - 新功能\n- `fix/xxx` - Bug 修复\n- `docs/xxx` - 文档更新\n- `refactor/xxx` - 代码重构\n- `test/xxx` - 测试相关\n- `chore/xxx` - 构建工具或辅助工具的变动\n\n### 2. 进行更改\n\n- 遵循项目的编码规范（见下文）\n- 编写清晰的代码注释\n- 确保代码可以正常运行\n- 添加或更新相关测试\n- 更新相关文档\n\n### 3. 提交更改\n\n```bash\n# 添加更改的文件\ngit add .\n\n# 提交更改（遵循提交信息规范）\ngit commit -m \"feat: add new feature\"\n\n# 推送到您的 fork\ngit push origin feat/your-feature-name\n```\n\n### 4. 创建 Pull Request\n\n1. 访问您的 fork 在 GitHub 上的页面\n2. 点击 \"Compare & pull request\" 按钮\n3. 填写 PR 模板\n4. 等待审查和反馈\n\n## 📐 编码规范\n\n### 后端规范（Python）\n\n详细的后端开发规范请参考：[**后端开发规范**](BACKEND_GUIDELINES_CN.md)\n\n**核心要点**：\n\n- 遵循 PEP 8 风格指南\n- 使用类型注解（Type Hints）\n- 函数和类需要有文档字符串\n- 使用 Ruff 进行代码检查和格式化\n- 行长度限制为 100 个字符\n\n**快速检查**：\n\n```bash\n# 运行代码检查\nuv run ruff check .\n\n# 自动格式化代码\nuv run ruff format .\n```\n\n### 前端规范（TypeScript/React）\n\n详细的前端开发规范请参考：[**前端开发规范**](FRONTEND_GUIDELINES_CN.md)\n\n**核心要点**：\n\n- 使用 TypeScript 严格模式\n- 遵循 React Hooks 最佳实践\n- 组件使用函数式组件\n- 使用 ESLint 进行代码检查\n- 使用 Tailwind CSS 进行样式管理\n\n**快速检查**：\n\n```bash\ncd frontend\n\n# 运行 ESLint 检查\npnpm lint\n\n# 构建测试\npnpm build\n```\n\n## 💬 提交信息规范\n\n我们使用 [Conventional Commits](https://www.conventionalcommits.org/) 规范。\n\n### 格式\n\n```\n<type>(<scope>): <subject>\n\n<body>\n\n<footer>\n```\n\n### Type 类型\n\n- `feat`: 新功能\n- `fix`: Bug 修复\n- `docs`: 文档更新\n- `style`: 代码格式（不影响代码运行的变动）\n- `refactor`: 重构（既不是新增功能，也不是修改 bug 的代码变动）\n- `perf`: 性能优化\n- `test`: 增加测试\n- `chore`: 构建过程或辅助工具的变动\n- `ci`: CI 配置文件和脚本的变动\n- `revert`: 回滚之前的提交\n\n### Scope 范围（可选）\n\n- `backend`: 后端相关\n- `frontend`: 前端相关\n- `api`: API 相关\n- `ui`: UI 相关\n- `db`: 数据库相关\n- `config`: 配置相关\n\n### 示例\n\n```bash\n# 新功能\ngit commit -m \"feat(frontend): add dark mode toggle button\"\n\n# Bug 修复\ngit commit -m \"fix(backend): resolve screenshot capture error on Windows\"\n\n# 文档更新\ngit commit -m \"docs: update installation guide\"\n\n# 性能优化\ngit commit -m \"perf(api): improve vector search performance\"\n\n# 多行提交信息\ngit commit -m \"feat(backend): add task auto-association\n\n- Implement background job for task context mapping\n- Add configuration options for auto-association\n- Update API endpoints to support new feature\n\nCloses #123\"\n```\n\n## 🔍 Pull Request 指南\n\n### PR 标题\n\nPR 标题应该遵循与提交信息相同的规范：\n\n```\n<type>(<scope>): <description>\n```\n\n### PR 描述模板\n\n```markdown\n## 📝 描述\n<!-- 简要描述本 PR 的目的和内容 -->\n\n## 🔗 相关 Issue\n<!-- 关联相关的 Issue，例如：Closes #123 -->\n\n## 🎯 变更类型\n<!-- 在适用的选项前打勾 -->\n- [ ] Bug 修复\n- [ ] 新功能\n- [ ] 性能优化\n- [ ] 代码重构\n- [ ] 文档更新\n- [ ] 测试相关\n- [ ] 其他（请说明）\n\n## 🧪 测试\n<!-- 描述如何测试这些更改 -->\n- [ ] 已在本地测试\n- [ ] 已添加单元测试\n- [ ] 已添加集成测试\n- [ ] 已更新文档\n\n## 📸 截图（如适用）\n<!-- 如果是 UI 相关的更改，请提供截图 -->\n\n## ✅ 检查清单\n- [ ] 代码遵循项目的编码规范\n- [ ] 已进行自我代码审查\n- [ ] 代码有适当的注释\n- [ ] 已更新相关文档\n- [ ] 我的更改没有产生新的警告\n- [ ] 已添加证明修复有效或功能正常的测试\n- [ ] 新的和现有的单元测试在本地通过\n- [ ] 任何依赖的更改已经合并和发布\n\n## 📚 额外说明\n<!-- 任何其他需要审查者知道的信息 -->\n```\n\n### 审查流程\n\n1. **自动检查**：CI/CD 会自动运行测试和检查\n2. **代码审查**：维护者会审查您的代码\n3. **反馈处理**：根据反馈进行修改\n4. **合并**：通过审查后，维护者会合并您的 PR\n\n### 审查标准\n\n- ✅ 代码质量和可读性\n- ✅ 遵循项目编码规范\n- ✅ 功能完整性\n- ✅ 测试覆盖率\n- ✅ 文档完整性\n- ✅ 性能影响\n- ✅ 向后兼容性\n\n## 🐛 报告问题\n\n### Bug 报告\n\n创建 Bug 报告时，请包含以下信息：\n\n1. **问题描述**：清晰简洁地描述问题\n2. **复现步骤**：\n   - 第一步\n   - 第二步\n   - ...\n3. **期望行为**：描述您期望发生什么\n4. **实际行为**：描述实际发生了什么\n5. **环境信息**：\n   - 操作系统：[例如 Windows 11, macOS 13.0, Ubuntu 22.04]\n   - Python 版本：[例如 3.12.9]\n   - Node.js 版本：[例如 18.17.0]\n   - 浏览器：[例如 Chrome 120.0]\n6. **截图或日志**：如果适用，添加截图或日志信息\n7. **附加信息**：任何其他相关的上下文信息\n\n### 功能请求\n\n创建功能请求时，请包含以下信息：\n\n1. **功能描述**：清晰描述您想要的功能\n2. **问题背景**：这个功能解决什么问题？\n3. **建议的解决方案**：您期望如何实现这个功能？\n4. **替代方案**：您考虑过的其他解决方案\n5. **使用场景**：提供具体的使用示例\n6. **附加信息**：任何其他相关的上下文或截图\n\n## 💬 社区讨论\n\n### 获取帮助\n\n- **GitHub Issues**：报告问题和提出功能请求\n- **GitHub Discussions**：参与社区讨论\n- **微信群**：加入我们的微信群（见 README）\n- **飞书群**：加入我们的飞书群（见 README）\n\n### 保持联系\n\n- 🌟 给项目点 Star 以表示支持\n- 👀 Watch 仓库以获取更新通知\n- 🐦 在社交媒体上分享项目\n- 📝 撰写博客文章介绍项目\n\n## 🎓 学习资源\n\n### 后端相关\n\n- [FastAPI 文档](https://fastapi.tiangolo.com/)\n- [SQLAlchemy 文档](https://docs.sqlalchemy.org/)\n- [Pydantic 文档](https://docs.pydantic.dev/)\n- [Python 类型注解](https://docs.python.org/3/library/typing.html)\n\n### 前端相关\n\n- [Next.js 文档](https://nextjs.org/docs)\n- [React 文档](https://react.dev/)\n- [TypeScript 文档](https://www.typescriptlang.org/docs/)\n- [Tailwind CSS 文档](https://tailwindcss.com/docs)\n\n### Git 相关\n\n- [FreeTodo Git Flow 工作流程](GIT_FLOW_CN.md) - 项目专用 Git 工作流程文档\n- [Git 简明教程](https://rogerdudler.github.io/git-guide/index.zh.html)\n- [如何使用 Git 和 GitHub](https://www.freecodecamp.org/chinese/news/git-and-github-for-beginners/)\n\n## 📊 贡献者统计\n\n感谢所有为 FreeTodo 做出贡献的人！\n\n![Contributors](https://contrib.rocks/image?repo=FreeU-group/FreeTodo)\n\n## ❓ 常见问题\n\n### 我是编程新手，可以贡献吗？\n\n当然可以！我们欢迎所有级别的贡献者。您可以从以下方面开始：\n\n- 修复文档中的拼写错误\n- 改进文档和注释\n- 处理标记为 `good first issue` 的问题\n- 报告 Bug 和提出建议\n\n### 我的 PR 需要多长时间才能被审查？\n\n我们会尽快审查 PR，通常在 3-7 天内。如果超过一周没有响应，请在 PR 中留言提醒我们。\n\n### 我可以同时处理多个 Issue 吗？\n\n可以，但建议先专注于一个 Issue，完成后再开始下一个。这样可以确保工作质量和效率。\n\n### 如何保持我的 Fork 与上游同步？\n\n```bash\n# 获取上游更新\ngit fetch upstream\n\n# 合并到本地 main 分支\ngit checkout main\ngit merge upstream/main\n\n# 推送到您的 fork\ngit push origin main\n```\n\n### 我的 PR 被拒绝了怎么办？\n\n不要灰心！这是正常的开发流程。维护者会提供反馈和建议。根据反馈进行修改，或者在讨论中寻求澄清。\n\n## 📜 许可证\n\nFreeTodo 采用 **FreeU Community License** 许可证，该许可证基于 Apache License 2.0，并附加了关于商业使用的条件。\n\n通过贡献代码，您同意：\n\n1. **您的贡献将在 FreeU Community License 下许可**\n   - 该许可证基于 Apache License 2.0，同时包含额外的商业使用条款\n   - 有关详细的许可证条款，请参阅 [LICENSE](../LICENSE) 文件\n\n2. **作为贡献者，您同意：**\n   - 生产者可以根据需要调整开源协议（使其更严格或更宽松）\n   - 您贡献的代码可能用于商业目的，包括但不限于其云版本\n\n有关详细的许可证条款和贡献者条件，请参阅 [LICENSE](../LICENSE) 文件。\n\n---\n\n## 🙏 感谢\n\n感谢您花时间阅读我们的贡献指南！我们期待您的贡献，让 FreeTodo 变得更好！\n\n如果您有任何问题，请随时在 Issues 中提问或加入我们的社区群组。\n\nHappy Coding! 🎉\n"
  },
  {
    "path": ".github/FRONTEND_GUIDELINES.md",
    "content": "# Frontend Development Guidelines\n\n**Language**: [English](FRONTEND_GUIDELINES.md) | [中文](FRONTEND_GUIDELINES_CN.md)\n\n## ⚛️ React + TypeScript Frontend Development Standards\n\nThis document details the development standards and best practices for the FreeTodo project frontend (Next.js + React + TypeScript).\n\n### Tech Stack\n\n- **Framework**: Next.js 16 + React 19 (App Router)\n- **Language**: Node.js 22.x + TypeScript 5.x\n- **Styling**: Tailwind CSS 4 + shadcn/ui\n- **State Management**: Zustand + React Hooks\n- **Data Fetching**: TanStack Query (React Query) v5\n- **API Generation**: Orval (auto-generate from OpenAPI)\n- **Data Validation**: Zod (runtime type validation)\n- **Theming**: next-themes (light/dark mode toggle)\n- **Animation/Interaction**: framer-motion, @dnd-kit\n- **Markdown**: react-markdown + remark-gfm\n- **Icons**: lucide-react\n- **Internationalization**: next-intl\n- **Package Manager**: pnpm 10.x\n- **Code Quality**: Biome (lint/format/check)\n\n## 📋 Table of Contents\n\n- [Code Style](#-code-style)\n- [Project Structure](#️-project-structure)\n- [Naming Conventions](#-naming-conventions)\n- [TypeScript Standards](#-typescript-standards)\n- [React Component Standards](#️-react-component-standards)\n- [State Management](#-state-management)\n- [API Calls](#-api-calls)\n- [Internationalization](#-internationalization)\n- [Styling](#-styling)\n- [Performance](#-performance)\n- [Testing](#-testing)\n- [Accessibility](#-accessibility)\n- [Security](#-security)\n\n## 🎨 Code Style\n\n### Biome Configuration\n\nThe project uses [Biome](https://biomejs.dev/) as the linter, formatter, and type checker.\n\n```bash\n# Check code\npnpm lint\n\n# Auto-fix issues\npnpm lint --fix\n\n# Format code\npnpm format\n\n# Type check\npnpm typecheck\n\n# Build test\npnpm build\n```\n\n### Basic Rules\n\n#### Indentation and Formatting\n\n```typescript\n// ✅ Correct: Use 2 spaces\nfunction MyComponent() {\n  const [count, setCount] = useState(0);\n\n  if (count > 0) {\n    return <div>Count: {count}</div>;\n  }\n\n  return null;\n}\n\n// ❌ Wrong: Use 4 spaces or tabs\nfunction MyComponent() {\n    const [count, setCount] = useState(0);\n    return <div>Count: {count}</div>;\n}\n```\n\n#### Quotes and Semicolons\n\n```typescript\n// ✅ Correct: Use double quotes, no semicolons\nconst message = \"Hello, World!\"\nconst name = \"Alice\"\n\n// ❌ Wrong: Use single quotes and semicolons\nconst message = 'Hello, World!';\n```\n\n#### Imports\n\n```typescript\n// ✅ Correct: Import order and grouping\n// 1. React and Next.js core\nimport { useState, useEffect } from \"react\"\nimport { useRouter } from \"next/navigation\"\nimport Image from \"next/image\"\n\n// 2. Third-party libraries\nimport axios from \"axios\"\nimport clsx from \"clsx\"\n\n// 3. Internal components\nimport { Button } from \"@/components/common/Button\"\nimport { Card } from \"@/components/common/Card\"\n\n// 4. Utils and types\nimport { api } from \"@/lib/api\"\nimport type { Task } from \"@/lib/types\"\n\n// 5. Styles\nimport styles from \"./page.module.css\"\n\n// ❌ Wrong: Mixed order\nimport { Button } from \"@/components/common/Button\"\nimport { useState } from \"react\"\nimport axios from \"axios\"\n```\n\n## 🏗️ Project Structure\n\n```\nfree-todo-frontend/\n├── app/                      # Next.js App Router\n│   ├── layout.tsx           # Root layout\n│   ├── page.tsx             # Home page\n│   └── apps/                # Feature pages\n│       ├── todo-list/       # Todo list\n│       ├── todo-detail/     # Todo detail\n│       └── [feature]/       # Other features\n├── components/              # React components\n│   ├── common/             # Common components\n│   ├── layout/             # Layout components\n│   └── [feature]/          # Feature components\n├── lib/                    # Utilities\n│   ├── api.ts             # API client (streaming APIs)\n│   ├── generated/         # Orval-generated API code\n│   │   ├── [module]/      # Split by feature modules\n│   │   ├── fetcher.ts     # Custom Fetcher\n│   │   └── schemas/       # Zod schemas\n│   ├── query/             # TanStack Query hooks wrapper\n│   │   └── keys.ts        # Query Keys management\n│   ├── types/             # Unified type definitions (camelCase)\n│   ├── store/             # Zustand state management\n│   ├── hooks/             # Custom Hooks\n│   └── utils.ts           # Utility functions\n├── messages/              # Internationalization files\n│   ├── zh.json            # Chinese translations\n│   └── en.json            # English translations\n└── public/                # Static assets\n```\n\n## 📝 Naming Conventions\n\n### File Naming\n\n```\n# ✅ Correct: Components use PascalCase\nButton.tsx\nTaskCard.tsx\nUserProfile.tsx\n\n# ✅ Correct: Non-components use camelCase\napi.ts\nutils.ts\nuse-tasks.ts\n\n# ❌ Wrong: Inconsistent naming\nbutton.tsx\ntask_card.tsx\n```\n\n### Component Naming\n\n```typescript\n// ✅ Correct: PascalCase\nexport function TaskCard() {}\nexport function UserProfile() {}\nexport default function HomePage() {}\n\n// ❌ Wrong: camelCase\nexport function taskCard() {}\n```\n\n### Variables and Functions\n\n```typescript\n// ✅ Correct: camelCase\nconst userName = \"Alice\"\nconst taskCount = 10\n\nfunction getUserProfile() {}\nfunction calculateTotal() {}\n\n// ❌ Wrong: PascalCase or snake_case\nconst UserName = \"Alice\"\nconst task_count = 10\n```\n\n### Constants\n\n```typescript\n// ✅ Correct: UPPER_SNAKE_CASE\nconst MAX_RETRY_COUNT = 3\nconst API_BASE_URL = \"https://api.example.com\"\nconst DEFAULT_PAGE_SIZE = 10\n\n// ❌ Wrong: camelCase\nconst maxRetryCount = 3\n```\n\n### Hooks\n\n```typescript\n// ✅ Correct: Start with \"use\"\nfunction useTasks() {}\nfunction useUser() {}\nfunction useDebounce() {}\n\n// ❌ Wrong: No \"use\" prefix\nfunction getTasks() {}\n```\n\n### Event Handlers\n\n```typescript\n// ✅ Correct: Use \"handle\" prefix\nfunction handleClick() {}\nfunction handleSubmit() {}\nfunction handleChange(e: ChangeEvent<HTMLInputElement>) {}\n\n// ✅ Correct: Callback props use \"on\" prefix\n<Button onClick={handleClick} />\n<Input onChange={handleChange} />\n```\n\n## 🔤 TypeScript Standards\n\n### Enable Strict Mode\n\n```json\n// tsconfig.json\n{\n  \"compilerOptions\": {\n    \"strict\": true,\n    \"noImplicitAny\": true,\n    \"strictNullChecks\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true\n  }\n}\n```\n\n### Type Definitions\n\n```typescript\n// ✅ Correct: Define clear types\ninterface Task {\n  id: number\n  title: string\n  description: string | null\n  status: \"pending\" | \"in_progress\" | \"completed\"\n  priority: number\n  createdAt: string\n  updatedAt: string\n}\n\ntype TaskStatus = \"pending\" | \"in_progress\" | \"completed\"\n\n// ❌ Wrong: Use any\ninterface Task {\n  id: number\n  title: string\n  data: any  // Avoid any\n}\n```\n\n### Component Props\n\n```typescript\n// ✅ Correct: Define Props interface\ninterface TaskCardProps {\n  task: Task\n  onEdit?: (task: Task) => void\n  onDelete?: (taskId: number) => void\n  className?: string\n}\n\nexport function TaskCard({\n  task,\n  onEdit,\n  onDelete,\n  className\n}: TaskCardProps) {\n  // Component implementation\n}\n\n// ✅ Correct: Use generics\ninterface ListProps<T> {\n  items: T[]\n  renderItem: (item: T) => React.ReactNode\n  keyExtractor: (item: T) => string | number\n}\n\nexport function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {\n  return (\n    <div>\n      {items.map(item => (\n        <div key={keyExtractor(item)}>\n          {renderItem(item)}\n        </div>\n      ))}\n    </div>\n  )\n}\n```\n\n## ⚛️ React Component Standards\n\n### Function Components\n\n```typescript\n// ✅ Correct: Use function components\ninterface UserProfileProps {\n  user: User\n  onUpdate: (user: User) => void\n}\n\nexport function UserProfile({ user, onUpdate }: UserProfileProps) {\n  const [isEditing, setIsEditing] = useState(false)\n\n  return (\n    <div>\n      <h2>{user.name}</h2>\n      {/* Component content */}\n    </div>\n  )\n}\n\n// ❌ Wrong: Use class components (unless necessary)\nclass UserProfile extends React.Component<UserProfileProps> {\n  render() {\n    return <div>{this.props.user.name}</div>\n  }\n}\n```\n\n### Custom Hooks\n\n```typescript\n// ✅ Correct: Create custom hooks\nfunction useTasks() {\n  const [tasks, setTasks] = useState<Task[]>([])\n  const [loading, setLoading] = useState(false)\n  const [error, setError] = useState<string | null>(null)\n\n  useEffect(() => {\n    fetchTasks()\n  }, [])\n\n  const fetchTasks = async () => {\n    setLoading(true)\n    setError(null)\n    try {\n      const response = await api.get<Task[]>(\"/api/tasks\")\n      setTasks(response.data)\n    } catch (err) {\n      setError(err instanceof Error ? err.message : \"Failed to fetch tasks\")\n    } finally {\n      setLoading(false)\n    }\n  }\n\n  return { tasks, loading, error, fetchTasks }\n}\n\n// Use custom hook\nfunction TasksPage() {\n  const { tasks, loading, error } = useTasks()\n\n  if (loading) return <div>Loading...</div>\n  if (error) return <div>Error: {error}</div>\n\n  return <TaskList tasks={tasks} />\n}\n```\n\n## 🎯 State Management\n\n### Local State (useState)\n\n```typescript\n// ✅ Correct: Use functional updates\nfunction Counter() {\n  const [count, setCount] = useState(0)\n\n  const increment = () => setCount(prev => prev + 1)\n  const decrement = () => setCount(prev => prev - 1)\n\n  return (\n    <div>\n      <p>Count: {count}</p>\n      <button onClick={increment}>+</button>\n      <button onClick={decrement}>-</button>\n    </div>\n  )\n}\n```\n\n### Global State (Zustand)\n\n```typescript\n// lib/store/taskStore.ts\nimport { create } from \"zustand\"\n\ninterface TaskState {\n  tasks: Task[]\n  loading: boolean\n  error: string | null\n  fetchTasks: () => Promise<void>\n  createTask: (task: TaskCreate) => Promise<void>\n}\n\nexport const useTaskStore = create<TaskState>((set) => ({\n  tasks: [],\n  loading: false,\n  error: null,\n\n  fetchTasks: async () => {\n    set({ loading: true, error: null })\n    try {\n      const response = await api.get<Task[]>(\"/api/tasks\")\n      set({ tasks: response.data, loading: false })\n    } catch (error) {\n      set({ error: \"Failed to fetch tasks\", loading: false })\n    }\n  },\n\n  createTask: async (taskData: TaskCreate) => {\n    try {\n      const response = await api.post<Task>(\"/api/tasks\", taskData)\n      set(state => ({ tasks: [...state.tasks, response.data] }))\n    } catch (error) {\n      set({ error: \"Failed to create task\" })\n      throw error\n    }\n  }\n}))\n```\n\n## 🌐 API Calls\n\nThe project uses **Orval + TanStack Query + Zod** for type-safe API calls and data validation.\n\n### Orval Code Generation\n\n- **Config file**: `orval.config.ts`\n- **Generate command**: `pnpm orval` (requires backend service running)\n- **Generated content**: TypeScript types, Zod schemas, React Query hooks\n- **Output directory**: `lib/generated/` (split by API tags, e.g., `todos/`, `chat/`)\n\n**Main configuration**:\n- `input.target`: Backend OpenAPI schema URL (http://localhost:8001/openapi.json)\n- `output.client`: Use react-query to generate hooks\n- `output.mode`: tags-split by feature modules\n- `override.mutator`: Use custom fetcher (`lib/generated/fetcher.ts`)\n- `override.zod.strict`: Enable strict runtime validation\n\n### Using Orval-Generated API Hooks\n\n```typescript\n// 1. Use generated hooks directly\nimport { useGetTodos, useCreateTodo } from \"@/lib/generated/todos\"\n\nfunction TodoList() {\n  const { data: todos, isLoading } = useGetTodos()\n  const createTodo = useCreateTodo()\n\n  // Use generated hooks\n}\n\n// 2. Wrap hooks in lib/query/ to add business logic\n// lib/query/todos.ts\nimport { useGetTodos as useGetTodosBase } from \"@/lib/generated/todos\"\nimport { queryKeys } from \"./keys\"\n\nexport function useTodos() {\n  return useGetTodosBase({\n    query: {\n      queryKey: queryKeys.todos.list(),\n      staleTime: 30000, // 30 seconds cache\n    },\n  })\n}\n```\n\n### TanStack Query Usage Guidelines\n\n- **Query Keys**: Manage in `lib/query/keys.ts` with hierarchical structure (e.g., `todos.list()`, `todos.detail(id)`)\n- **Optimistic Updates**: Update cache in `onMutate`, rollback in `onError`, refetch in `onSettled`\n- **Debounced Updates**: Use 500ms debounce for frequently changing fields (e.g., description, notes)\n- **Cache Strategy**: Set reasonable `staleTime` (e.g., 30 seconds) to avoid excessive requests\n\n```typescript\n// lib/query/keys.ts\nexport const queryKeys = {\n  todos: {\n    all: () => [\"todos\"] as const,\n    lists: () => [...queryKeys.todos.all(), \"list\"] as const,\n    list: (filters?: string) => [...queryKeys.todos.lists(), { filters }] as const,\n    details: () => [...queryKeys.todos.all(), \"detail\"] as const,\n    detail: (id: number) => [...queryKeys.todos.details(), id] as const,\n  },\n}\n```\n\n### Zod Data Validation\n\n- **Generated schemas**: Located in `lib/generated/schemas/`, auto-generated by Orval\n- **Runtime validation**: Automatically validate API response format in fetcher\n- **Form validation**: Use with React Hook Form's `zodResolver`\n\n### Custom Fetcher\n\nLocated in `lib/generated/fetcher.ts`, responsible for:\n- Environment adaptation (client/server URL)\n- **Automatic naming style conversion**:\n  - Request: camelCase → snake_case (frontend style → backend style)\n  - Response: snake_case → camelCase (backend style → frontend style)\n- Time string normalization (handle missing timezone suffix)\n- Unified error handling\n- Zod schema runtime validation\n\n### Streaming API Handling\n\nOrval doesn't support Server-Sent Events, implement manually in `lib/api.ts`:\n\n```typescript\n// lib/api.ts\nexport async function sendChatMessageStream(\n  message: string,\n  onChunk: (chunk: string) => void\n) {\n  const response = await fetch(`${API_BASE_URL}/api/chat/stream`, {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({ message }),\n  })\n\n  const reader = response.body?.getReader()\n  const decoder = new TextDecoder()\n\n  if (!reader) return\n\n  while (true) {\n    const { done, value } = await reader.read()\n    if (done) break\n\n    const chunk = decoder.decode(value, { stream: true })\n    onChunk(chunk)\n  }\n}\n```\n\n### Type Safety Best Practices\n\n1. Prefer camelCase types from `lib/types/index.ts` (fetcher automatically converts)\n2. IDs use `number` type uniformly (consistent with backend database)\n3. Orval-generated types only for API layer, business layer uses unified type definitions\n\n### Development Workflow\n\n1. **Backend API changes**: Run `pnpm orval` to regenerate code, check `git diff lib/generated/`\n2. **New API**: Backend updates OpenAPI → Generate code → Wrap in `lib/query/` → Use in components\n3. **Debugging**: Add logs in fetcher to view requests/responses and validation errors\n\n## 🌍 Internationalization\n\nThe project uses **next-intl** for internationalization, managed through Zustand store (no URL routing mode).\n\n- **Translation files**: `messages/zh.json` and `en.json`\n- **Request config**: `i18n/request.ts`\n- **Language management**: `lib/store/locale.ts` (syncs to cookie on change)\n- **Access method**: `useTranslations(namespace)` imported from `next-intl`\n\n### Using Internationalization\n\n```typescript\n// ✅ Correct: Use translation hook\nimport { useTranslations } from \"next-intl\"\n\nfunction TaskList() {\n  const t = useTranslations(\"page.todo\")\n\n  return (\n    <div>\n      <h1>{t(\"title\")}</h1>\n      <p>{t(\"description\", { count: tasks.length })}</p>\n    </div>\n  )\n}\n\n// ❌ Wrong: Hard-coded text\nfunction TaskList() {\n  const locale = useLocale()\n  return <h1>{locale === \"zh\" ? \"任务列表\" : \"Task List\"}</h1>\n}\n```\n\n### Adding/Modifying Translations\n\n- Add translation keys synchronously in `messages/zh.json` and `en.json`\n- Use nested structure to organize translations, e.g., `page.settings.title`\n- Support ICU MessageFormat interpolation syntax, e.g., `{count}` and plural forms\n\n## 🎨 Styling\n\n### Tailwind CSS 4\n\nThe project uses Tailwind CSS 4 and shadcn/ui component library.\n\n```typescript\n// ✅ Correct: Use Tailwind utility classes with clsx/tailwind-merge\nimport { cn } from \"@/lib/utils\" // tailwind-merge wrapper\n\nfunction Button({ children, variant = \"primary\", className }: ButtonProps) {\n  return (\n    <button\n      className={cn(\n        \"px-4 py-2 rounded-lg font-medium transition-colors\",\n        variant === \"primary\" && \"bg-blue-500 hover:bg-blue-600 text-white\",\n        variant === \"secondary\" && \"bg-gray-200 hover:bg-gray-300 text-gray-800\",\n        className\n      )}\n    >\n      {children}\n    </button>\n  )\n}\n```\n\n### Dark Mode\n\nUse `next-themes` to manage theme, use `dark:` prefix in components:\n\n```typescript\nfunction Card({ children }: CardProps) {\n  return (\n    <div className=\"bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100\">\n      {children}\n    </div>\n  )\n}\n```\n\n### shadcn/ui Components\n\nUse shadcn/ui provided components, add via `npx shadcn@latest add [component]`:\n\n```typescript\nimport { Button } from \"@/components/ui/button\"\nimport { Card } from \"@/components/ui/card\"\n\nfunction MyComponent() {\n  return (\n    <Card>\n      <Button variant=\"default\">Click</Button>\n    </Card>\n  )\n}\n```\n\n## ⚡ Performance\n\n### React.memo\n\n```typescript\n// ✅ Correct: Use React.memo\nexport const TaskCard = React.memo(function TaskCard({ task }: TaskCardProps) {\n  return (\n    <div>\n      <h3>{task.title}</h3>\n      <p>{task.description}</p>\n    </div>\n  )\n})\n```\n\n### useCallback and useMemo\n\n```typescript\n// ✅ Correct: Use useCallback\nfunction TaskList({ tasks }: TaskListProps) {\n  const handleTaskClick = useCallback((taskId: number) => {\n    console.log(\"Task clicked:\", taskId)\n  }, [])\n\n  return (\n    <div>\n      {tasks.map(task => (\n        <TaskCard key={task.id} task={task} onClick={handleTaskClick} />\n      ))}\n    </div>\n  )\n}\n\n// ✅ Correct: Use useMemo\nfunction TaskStats({ tasks }: TaskStatsProps) {\n  const stats = useMemo(() => ({\n    total: tasks.length,\n    completed: tasks.filter(t => t.status === \"completed\").length,\n    pending: tasks.filter(t => t.status === \"pending\").length\n  }), [tasks])\n\n  return (\n    <div>\n      <p>Total: {stats.total}</p>\n      <p>Completed: {stats.completed}</p>\n      <p>Pending: {stats.pending}</p>\n    </div>\n  )\n}\n```\n\n## 🧪 Testing\n\n```typescript\n// TaskCard.test.tsx\nimport { render, screen, fireEvent } from \"@testing-library/react\"\nimport { TaskCard } from \"./TaskCard\"\n\ndescribe(\"TaskCard\", () => {\n  const mockTask: Task = {\n    id: 1,\n    title: \"Test Task\",\n    description: \"Test Description\",\n    status: \"pending\",\n    priority: 1,\n    createdAt: \"2024-01-01T00:00:00Z\",\n    updatedAt: \"2024-01-01T00:00:00Z\"\n  }\n\n  it(\"renders task title\", () => {\n    render(<TaskCard task={mockTask} />)\n    expect(screen.getByText(\"Test Task\")).toBeInTheDocument()\n  })\n\n  it(\"calls onEdit when edit button is clicked\", () => {\n    const handleEdit = jest.fn()\n    render(<TaskCard task={mockTask} onEdit={handleEdit} />)\n\n    fireEvent.click(screen.getByRole(\"button\", { name: /edit/i }))\n    expect(handleEdit).toHaveBeenCalledWith(mockTask)\n  })\n})\n```\n\n## ♿ Accessibility\n\n### Semantic HTML\n\n```typescript\n// ✅ Correct: Use semantic tags\nfunction TaskList({ tasks }: TaskListProps) {\n  return (\n    <section>\n      <h2>Tasks</h2>\n      <ul>\n        {tasks.map(task => (\n          <li key={task.id}>\n            <article>\n              <h3>{task.title}</h3>\n              <p>{task.description}</p>\n            </article>\n          </li>\n        ))}\n      </ul>\n    </section>\n  )\n}\n\n// ❌ Wrong: Overuse divs\nfunction TaskList({ tasks }: TaskListProps) {\n  return (\n    <div>\n      <div>Tasks</div>\n      <div>\n        {tasks.map(task => (\n          <div key={task.id}>\n            <div>{task.title}</div>\n          </div>\n        ))}\n      </div>\n    </div>\n  )\n}\n```\n\n### ARIA Attributes\n\n```typescript\n// ✅ Correct: Use ARIA attributes\nfunction Button({ loading, children }: ButtonProps) {\n  return (\n    <button\n      aria-busy={loading}\n      aria-label={loading ? \"Loading...\" : undefined}\n      disabled={loading}\n    >\n      {children}\n    </button>\n  )\n}\n```\n\n## 🔒 Security\n\n### XSS Protection\n\n```typescript\n// ✅ Correct: React auto-escapes\nfunction TaskDescription({ description }: { description: string }) {\n  return <p>{description}</p>\n}\n\n// ⚠️ Caution: Use dangerouslySetInnerHTML carefully\nimport DOMPurify from \"dompurify\"\n\nfunction TaskDescription({ html }: { html: string }) {\n  const sanitized = DOMPurify.sanitize(html)\n  return <div dangerouslySetInnerHTML={{ __html: sanitized }} />\n}\n```\n\n### Environment Variables\n\n```typescript\n// ✅ Correct: Use environment variables\nconst API_URL = process.env.NEXT_PUBLIC_API_URL\n// NEXT_PUBLIC_ prefix exposes to client\n// Without prefix, only available on server\n```\n\n## ✅ Code Review Checklist\n\nBefore submitting code, ensure:\n\n- [ ] Code passes Biome (`pnpm lint`)\n- [ ] Code is formatted (`pnpm format`)\n- [ ] Code builds successfully (`pnpm build`)\n- [ ] All components have TypeScript types\n- [ ] Props interfaces are complete\n- [ ] Follow naming conventions\n- [ ] No `any` types (unless necessary)\n- [ ] Large components are split\n- [ ] Proper React Hooks usage\n- [ ] Key props added to lists\n- [ ] Semantic HTML used\n- [ ] Accessibility considered\n- [ ] API calls use Orval-generated hooks (no manual implementation)\n- [ ] Translation text uses `useTranslations`, no hard-coded text\n- [ ] TanStack Query Query Keys managed in `lib/query/keys.ts`\n- [ ] Streaming APIs use manual implementation in `lib/api.ts`\n- [ ] Code has appropriate comments\n- [ ] Documentation updated\n\n---\n\nHappy Coding! ⚛️\n"
  },
  {
    "path": ".github/FRONTEND_GUIDELINES_CN.md",
    "content": "# 前端开发规范\n\n**语言**: [English](FRONTEND_GUIDELINES.md) | [中文](FRONTEND_GUIDELINES_CN.md)\n\n## ⚛️ React + TypeScript 前端开发规范\n\n本文档详细说明了 FreeTodo 项目前端（Next.js + React + TypeScript）的开发规范和最佳实践。\n\n### 技术栈\n\n- **框架**: Next.js 16 + React 19（App Router）\n- **语言**: Node.js 22.x + TypeScript 5.x\n- **样式**: Tailwind CSS 4 + shadcn/ui\n- **状态管理**: Zustand + React Hooks\n- **数据获取**: TanStack Query (React Query) v5\n- **API 生成**: Orval（根据 OpenAPI 自动生成）\n- **数据验证**: Zod（运行时类型验证）\n- **主题**: next-themes（浅/深色切换）\n- **动画/交互**: framer-motion、@dnd-kit\n- **Markdown**: react-markdown + remark-gfm\n- **图标**: lucide-react\n- **国际化**: next-intl\n- **包管理**: pnpm 10.x\n- **代码质量**: Biome（lint/format/check）\n\n## 📋 目录\n\n- [代码风格](#-代码风格)\n- [项目结构](#-项目结构)\n- [命名规范](#-命名规范)\n- [TypeScript 规范](#-typescript-规范)\n- [React 组件规范](#️-react-组件规范)\n- [状态管理](#-状态管理)\n- [API 调用](#-api-调用)\n- [国际化](#-国际化)\n- [样式规范](#-样式规范)\n- [性能优化](#-性能优化)\n- [测试](#-测试)\n- [可访问性](#-可访问性)\n- [安全性](#-安全性)\n\n## 🎨 代码风格\n\n### Biome 配置\n\n项目使用 [Biome](https://biomejs.dev/) 作为代码检查器、格式化工具和类型检查器。\n\n```bash\n# 检查代码\npnpm lint\n\n# 自动修复问题\npnpm lint --fix\n\n# 格式化代码\npnpm format\n\n# 类型检查\npnpm typecheck\n\n# 构建测试\npnpm build\n```\n\n### 基本规则\n\n#### 缩进和格式\n\n```typescript\n// ✅ 正确：使用 2 个空格缩进\nfunction MyComponent() {\n  const [count, setCount] = useState(0);\n\n  if (count > 0) {\n    return <div>Count: {count}</div>;\n  }\n\n  return null;\n}\n\n// ❌ 错误：使用 4 个空格或 Tab\nfunction MyComponent() {\n    const [count, setCount] = useState(0);\n    return <div>Count: {count}</div>;\n}\n```\n\n#### 引号和分号\n\n```typescript\n// ✅ 正确：使用双引号，不使用分号\nconst message = \"Hello, World!\"\nconst name = \"Alice\"\n\n// ❌ 错误：使用单引号和分号\nconst message = 'Hello, World!';\n```\n\n#### 导入语句\n\n```typescript\n// ✅ 正确：导入顺序和分组\n// 1. React 和 Next.js 核心\nimport { useState, useEffect } from \"react\"\nimport { useRouter } from \"next/navigation\"\nimport Image from \"next/image\"\n\n// 2. 第三方库\nimport axios from \"axios\"\nimport clsx from \"clsx\"\n\n// 3. 内部组件\nimport { Button } from \"@/components/common/Button\"\nimport { Card } from \"@/components/common/Card\"\n\n// 4. 工具函数和类型\nimport { api } from \"@/lib/api\"\nimport type { Task } from \"@/lib/types\"\n\n// 5. 样式\nimport styles from \"./page.module.css\"\n\n// ❌ 错误：混乱的导入顺序\nimport { Button } from \"@/components/common/Button\"\nimport { useState } from \"react\"\nimport axios from \"axios\"\n```\n\n## 📦 项目结构\n\n```\nfree-todo-frontend/\n├── app/                      # Next.js App Router\n│   ├── layout.tsx           # 根布局\n│   ├── page.tsx             # 首页\n│   └── apps/                # 功能页面\n│       ├── todo-list/       # 待办列表\n│       ├── todo-detail/     # 待办详情\n│       └── [feature]/       # 其他功能\n├── components/              # React 组件\n│   ├── common/             # 通用组件\n│   ├── layout/             # 布局组件\n│   └── [feature]/          # 功能组件\n├── lib/                    # 工具库\n│   ├── api.ts             # API 客户端（流式 API）\n│   ├── generated/         # Orval 生成的 API 代码\n│   │   ├── [module]/      # 按功能模块分文件\n│   │   ├── fetcher.ts     # 自定义 Fetcher\n│   │   └── schemas/       # Zod schemas\n│   ├── query/             # TanStack Query hooks 封装\n│   │   └── keys.ts        # Query Keys 管理\n│   ├── types/             # 统一类型定义（camelCase）\n│   ├── store/             # Zustand 状态管理\n│   ├── hooks/             # 自定义 Hooks\n│   └── utils.ts           # 工具函数\n├── messages/              # 国际化翻译文件\n│   ├── zh.json            # 中文翻译\n│   └── en.json            # 英文翻译\n└── public/                # 静态资源\n```\n\n## 📝 命名规范\n\n### 文件命名\n\n```\n# ✅ 正确：组件使用 PascalCase\nButton.tsx\nTaskCard.tsx\nUserProfile.tsx\n\n# ✅ 正确：非组件使用 camelCase\napi.ts\nutils.ts\nuse-tasks.ts\n\n# ❌ 错误：不一致的命名\nbutton.tsx\ntask_card.tsx\n```\n\n### 组件命名\n\n```typescript\n// ✅ 正确：使用 PascalCase\nexport function TaskCard() {}\nexport function UserProfile() {}\nexport default function HomePage() {}\n\n// ❌ 错误：使用 camelCase\nexport function taskCard() {}\n```\n\n### 变量和函数命名\n\n```typescript\n// ✅ 正确：使用 camelCase\nconst userName = \"Alice\"\nconst taskCount = 10\n\nfunction getUserProfile() {}\nfunction calculateTotal() {}\n\n// ❌ 错误：使用 PascalCase 或 snake_case\nconst UserName = \"Alice\"\nconst task_count = 10\n```\n\n### 常量命名\n\n```typescript\n// ✅ 正确：使用 UPPER_SNAKE_CASE\nconst MAX_RETRY_COUNT = 3\nconst API_BASE_URL = \"https://api.example.com\"\nconst DEFAULT_PAGE_SIZE = 10\n\n// ❌ 错误：使用 camelCase\nconst maxRetryCount = 3\n```\n\n### Hooks 命名\n\n```typescript\n// ✅ 正确：自定义 Hook 以 use 开头\nfunction useTasks() {}\nfunction useUser() {}\nfunction useDebounce() {}\n\n// ❌ 错误：不以 use 开头\nfunction getTasks() {}\n```\n\n### 事件处理函数命名\n\n```typescript\n// ✅ 正确：使用 handle 前缀\nfunction handleClick() {}\nfunction handleSubmit() {}\nfunction handleChange(e: ChangeEvent<HTMLInputElement>) {}\n\n// ✅ 正确：传递给子组件的回调使用 on 前缀\n<Button onClick={handleClick} />\n<Input onChange={handleChange} />\n```\n\n## 🔤 TypeScript 规范\n\n### 启用严格模式\n\n```json\n// tsconfig.json\n{\n  \"compilerOptions\": {\n    \"strict\": true,\n    \"noImplicitAny\": true,\n    \"strictNullChecks\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true\n  }\n}\n```\n\n### 类型定义\n\n```typescript\n// ✅ 正确：定义清晰的类型\ninterface Task {\n  id: number\n  title: string\n  description: string | null\n  status: \"pending\" | \"in_progress\" | \"completed\"\n  priority: number\n  createdAt: string\n  updatedAt: string\n}\n\ntype TaskStatus = \"pending\" | \"in_progress\" | \"completed\"\n\n// ❌ 错误：使用 any\ninterface Task {\n  id: number\n  title: string\n  data: any  // 避免使用 any\n}\n```\n\n### 组件 Props 类型\n\n```typescript\n// ✅ 正确：定义 Props 接口\ninterface TaskCardProps {\n  task: Task\n  onEdit?: (task: Task) => void\n  onDelete?: (taskId: number) => void\n  className?: string\n}\n\nexport function TaskCard({\n  task,\n  onEdit,\n  onDelete,\n  className\n}: TaskCardProps) {\n  // 组件实现\n}\n\n// ✅ 正确：使用泛型\ninterface ListProps<T> {\n  items: T[]\n  renderItem: (item: T) => React.ReactNode\n  keyExtractor: (item: T) => string | number\n}\n\nexport function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {\n  return (\n    <div>\n      {items.map(item => (\n        <div key={keyExtractor(item)}>\n          {renderItem(item)}\n        </div>\n      ))}\n    </div>\n  )\n}\n```\n\n## ⚛️ React 组件规范\n\n### 函数组件\n\n```typescript\n// ✅ 正确：使用函数组件\ninterface UserProfileProps {\n  user: User\n  onUpdate: (user: User) => void\n}\n\nexport function UserProfile({ user, onUpdate }: UserProfileProps) {\n  const [isEditing, setIsEditing] = useState(false)\n\n  return (\n    <div>\n      <h2>{user.name}</h2>\n      {/* 组件内容 */}\n    </div>\n  )\n}\n\n// ❌ 错误：使用类组件（除非必要）\nclass UserProfile extends React.Component<UserProfileProps> {\n  render() {\n    return <div>{this.props.user.name}</div>\n  }\n}\n```\n\n### 自定义 Hooks\n\n```typescript\n// ✅ 正确：创建自定义 Hook\nfunction useTasks() {\n  const [tasks, setTasks] = useState<Task[]>([])\n  const [loading, setLoading] = useState(false)\n  const [error, setError] = useState<string | null>(null)\n\n  useEffect(() => {\n    fetchTasks()\n  }, [])\n\n  const fetchTasks = async () => {\n    setLoading(true)\n    setError(null)\n    try {\n      const response = await api.get<Task[]>(\"/api/tasks\")\n      setTasks(response.data)\n    } catch (err) {\n      setError(err instanceof Error ? err.message : \"Failed to fetch tasks\")\n    } finally {\n      setLoading(false)\n    }\n  }\n\n  return { tasks, loading, error, fetchTasks }\n}\n\n// 使用自定义 Hook\nfunction TasksPage() {\n  const { tasks, loading, error } = useTasks()\n\n  if (loading) return <div>加载中...</div>\n  if (error) return <div>错误: {error}</div>\n\n  return <TaskList tasks={tasks} />\n}\n```\n\n## 🎯 状态管理\n\n### 本地状态（useState）\n\n```typescript\n// ✅ 正确：使用函数式更新\nfunction Counter() {\n  const [count, setCount] = useState(0)\n\n  const increment = () => setCount(prev => prev + 1)\n  const decrement = () => setCount(prev => prev - 1)\n\n  return (\n    <div>\n      <p>计数: {count}</p>\n      <button onClick={increment}>+</button>\n      <button onClick={decrement}>-</button>\n    </div>\n  )\n}\n```\n\n### 全局状态（Zustand）\n\n```typescript\n// lib/store/taskStore.ts\nimport { create } from \"zustand\"\n\ninterface TaskState {\n  tasks: Task[]\n  loading: boolean\n  error: string | null\n  fetchTasks: () => Promise<void>\n  createTask: (task: TaskCreate) => Promise<void>\n}\n\nexport const useTaskStore = create<TaskState>((set) => ({\n  tasks: [],\n  loading: false,\n  error: null,\n\n  fetchTasks: async () => {\n    set({ loading: true, error: null })\n    try {\n      const response = await api.get<Task[]>(\"/api/tasks\")\n      set({ tasks: response.data, loading: false })\n    } catch (error) {\n      set({ error: \"获取任务失败\", loading: false })\n    }\n  },\n\n  createTask: async (taskData: TaskCreate) => {\n    try {\n      const response = await api.post<Task>(\"/api/tasks\", taskData)\n      set(state => ({ tasks: [...state.tasks, response.data] }))\n    } catch (error) {\n      set({ error: \"创建任务失败\" })\n      throw error\n    }\n  }\n}))\n```\n\n## 🌐 API 调用\n\n项目使用 **Orval + TanStack Query + Zod** 实现类型安全的 API 调用和数据验证。\n\n### Orval 代码生成\n\n- **配置文件**: `orval.config.ts`\n- **生成命令**: `pnpm orval`（需后端服务运行）\n- **生成内容**: TypeScript 类型、Zod schemas、React Query hooks\n- **输出目录**: `lib/generated/`（按 API tag 分割，如 `todos/`, `chat/`）\n\n**主要配置**:\n- `input.target`: 后端 OpenAPI schema 地址（http://localhost:8001/openapi.json）\n- `output.client`: 使用 react-query 生成 hooks\n- `output.mode`: tags-split 按功能模块分文件\n- `override.mutator`: 使用自定义 fetcher（`lib/generated/fetcher.ts`）\n- `override.zod.strict`: 启用严格的运行时验证\n\n### 使用 Orval 生成的 API Hooks\n\n```typescript\n// 1. 直接使用生成的 hooks\nimport { useGetTodos, useCreateTodo } from \"@/lib/generated/todos\"\n\nfunction TodoList() {\n  const { data: todos, isLoading } = useGetTodos()\n  const createTodo = useCreateTodo()\n\n  // 使用生成的 hooks\n}\n\n// 2. 在 lib/query/ 中封装，添加业务逻辑\n// lib/query/todos.ts\nimport { useGetTodos as useGetTodosBase } from \"@/lib/generated/todos\"\nimport { queryKeys } from \"./keys\"\n\nexport function useTodos() {\n  return useGetTodosBase({\n    query: {\n      queryKey: queryKeys.todos.list(),\n      staleTime: 30000, // 30 秒缓存\n    },\n  })\n}\n```\n\n### TanStack Query 使用规范\n\n- **Query Keys**: 统一在 `lib/query/keys.ts` 管理，使用层级结构（如 `todos.list()`, `todos.detail(id)`）\n- **乐观更新**: 在 `onMutate` 中更新缓存，`onError` 回滚，`onSettled` 重新获取\n- **防抖更新**: 针对频繁变化字段（如描述、备注）使用 500ms 防抖\n- **缓存策略**: 设置合理的 `staleTime`（如 30 秒），避免过度请求\n\n```typescript\n// lib/query/keys.ts\nexport const queryKeys = {\n  todos: {\n    all: () => [\"todos\"] as const,\n    lists: () => [...queryKeys.todos.all(), \"list\"] as const,\n    list: (filters?: string) => [...queryKeys.todos.lists(), { filters }] as const,\n    details: () => [...queryKeys.todos.all(), \"detail\"] as const,\n    detail: (id: number) => [...queryKeys.todos.details(), id] as const,\n  },\n}\n```\n\n### Zod 数据验证\n\n- **生成的 schemas**: 位于 `lib/generated/schemas/`，由 Orval 自动生成\n- **运行时验证**: 在 fetcher 中自动验证 API 响应格式\n- **表单验证**: 配合 React Hook Form 的 `zodResolver` 使用\n\n### 自定义 Fetcher\n\n位于 `lib/generated/fetcher.ts`，负责：\n- 环境适配（客户端/服务端 URL）\n- **命名风格自动转换**:\n  - 请求时：camelCase → snake_case（前端风格 → 后端风格）\n  - 响应时：snake_case → camelCase（后端风格 → 前端风格）\n- 时间字符串标准化（处理无时区后缀）\n- 统一错误处理\n- Zod schema 运行时验证\n\n### 流式 API 处理\n\nOrval 不支持 Server-Sent Events，需在 `lib/api.ts` 手动实现：\n\n```typescript\n// lib/api.ts\nexport async function sendChatMessageStream(\n  message: string,\n  onChunk: (chunk: string) => void\n) {\n  const response = await fetch(`${API_BASE_URL}/api/chat/stream`, {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({ message }),\n  })\n\n  const reader = response.body?.getReader()\n  const decoder = new TextDecoder()\n\n  if (!reader) return\n\n  while (true) {\n    const { done, value } = await reader.read()\n    if (done) break\n\n    const chunk = decoder.decode(value, { stream: true })\n    onChunk(chunk)\n  }\n}\n```\n\n### 类型安全最佳实践\n\n1. 优先使用 `lib/types/index.ts` 中的 camelCase 类型（fetcher 已自动转换）\n2. ID 统一使用 `number` 类型（与后端数据库一致）\n3. Orval 生成的类型仅用于 API 层，业务层使用统一类型定义\n\n### 开发工作流\n\n1. **后端 API 变更**: 运行 `pnpm orval` 重新生成代码，检查 `git diff lib/generated/`\n2. **新增 API**: 后端更新 OpenAPI → 生成代码 → 在 `lib/query/` 封装 → 组件使用\n3. **调试**: 在 fetcher 中添加日志，查看请求/响应和验证错误\n\n## 🌍 国际化\n\n项目使用 **next-intl** 实现国际化，通过 Zustand store 管理语言切换（无 URL 路由模式）。\n\n- **翻译文件**: `messages/zh.json` 与 `en.json`\n- **请求配置**: `i18n/request.ts`\n- **语言管理**: `lib/store/locale.ts`（切换时同步到 cookie）\n- **访问方法**: `useTranslations(namespace)` 从 `next-intl` 导入\n\n### 使用国际化\n\n```typescript\n// ✅ 正确：使用翻译 hook\nimport { useTranslations } from \"next-intl\"\n\nfunction TaskList() {\n  const t = useTranslations(\"page.todo\")\n\n  return (\n    <div>\n      <h1>{t(\"title\")}</h1>\n      <p>{t(\"description\", { count: tasks.length })}</p>\n    </div>\n  )\n}\n\n// ❌ 错误：硬编码文本\nfunction TaskList() {\n  const locale = useLocale()\n  return <h1>{locale === \"zh\" ? \"任务列表\" : \"Task List\"}</h1>\n}\n```\n\n### 添加/修改文案\n\n- 在 `messages/zh.json` 和 `en.json` 中同步添加翻译 key\n- 使用嵌套结构组织翻译，如 `page.settings.title`\n- 支持 ICU MessageFormat 插值语法，如 `{count}` 和复数形式\n\n## 🎨 样式规范\n\n### Tailwind CSS 4\n\n项目使用 Tailwind CSS 4 和 shadcn/ui 组件库。\n\n```typescript\n// ✅ 正确：使用 Tailwind 工具类和 clsx/tailwind-merge\nimport { cn } from \"@/lib/utils\" // tailwind-merge 封装\n\nfunction Button({ children, variant = \"primary\", className }: ButtonProps) {\n  return (\n    <button\n      className={cn(\n        \"px-4 py-2 rounded-lg font-medium transition-colors\",\n        variant === \"primary\" && \"bg-blue-500 hover:bg-blue-600 text-white\",\n        variant === \"secondary\" && \"bg-gray-200 hover:bg-gray-300 text-gray-800\",\n        className\n      )}\n    >\n      {children}\n    </button>\n  )\n}\n```\n\n### 深色模式\n\n使用 `next-themes` 管理主题，组件中使用 `dark:` 前缀：\n\n```typescript\nfunction Card({ children }: CardProps) {\n  return (\n    <div className=\"bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100\">\n      {children}\n    </div>\n  )\n}\n```\n\n### shadcn/ui 组件\n\n使用 shadcn/ui 提供的组件，可通过 `npx shadcn@latest add [component]` 添加：\n\n```typescript\nimport { Button } from \"@/components/ui/button\"\nimport { Card } from \"@/components/ui/card\"\n\nfunction MyComponent() {\n  return (\n    <Card>\n      <Button variant=\"default\">点击</Button>\n    </Card>\n  )\n}\n```\n\n## ⚡ 性能优化\n\n### React.memo\n\n```typescript\n// ✅ 正确：使用 React.memo\nexport const TaskCard = React.memo(function TaskCard({ task }: TaskCardProps) {\n  return (\n    <div>\n      <h3>{task.title}</h3>\n      <p>{task.description}</p>\n    </div>\n  )\n})\n```\n\n### useCallback 和 useMemo\n\n```typescript\n// ✅ 正确：使用 useCallback\nfunction TaskList({ tasks }: TaskListProps) {\n  const handleTaskClick = useCallback((taskId: number) => {\n    console.log(\"任务点击:\", taskId)\n  }, [])\n\n  return (\n    <div>\n      {tasks.map(task => (\n        <TaskCard key={task.id} task={task} onClick={handleTaskClick} />\n      ))}\n    </div>\n  )\n}\n\n// ✅ 正确：使用 useMemo\nfunction TaskStats({ tasks }: TaskStatsProps) {\n  const stats = useMemo(() => ({\n    total: tasks.length,\n    completed: tasks.filter(t => t.status === \"completed\").length,\n    pending: tasks.filter(t => t.status === \"pending\").length\n  }), [tasks])\n\n  return (\n    <div>\n      <p>总计: {stats.total}</p>\n      <p>已完成: {stats.completed}</p>\n      <p>待处理: {stats.pending}</p>\n    </div>\n  )\n}\n```\n\n## 🧪 测试\n\n```typescript\n// TaskCard.test.tsx\nimport { render, screen, fireEvent } from \"@testing-library/react\"\nimport { TaskCard } from \"./TaskCard\"\n\ndescribe(\"TaskCard\", () => {\n  const mockTask: Task = {\n    id: 1,\n    title: \"测试任务\",\n    description: \"测试描述\",\n    status: \"pending\",\n    priority: 1,\n    createdAt: \"2024-01-01T00:00:00Z\",\n    updatedAt: \"2024-01-01T00:00:00Z\"\n  }\n\n  it(\"渲染任务标题\", () => {\n    render(<TaskCard task={mockTask} />)\n    expect(screen.getByText(\"测试任务\")).toBeInTheDocument()\n  })\n\n  it(\"点击编辑按钮时调用 onEdit\", () => {\n    const handleEdit = jest.fn()\n    render(<TaskCard task={mockTask} onEdit={handleEdit} />)\n\n    fireEvent.click(screen.getByRole(\"button\", { name: /编辑/i }))\n    expect(handleEdit).toHaveBeenCalledWith(mockTask)\n  })\n})\n```\n\n## ♿ 可访问性\n\n### 语义化 HTML\n\n```typescript\n// ✅ 正确：使用语义化标签\nfunction TaskList({ tasks }: TaskListProps) {\n  return (\n    <section>\n      <h2>任务列表</h2>\n      <ul>\n        {tasks.map(task => (\n          <li key={task.id}>\n            <article>\n              <h3>{task.title}</h3>\n              <p>{task.description}</p>\n            </article>\n          </li>\n        ))}\n      </ul>\n    </section>\n  )\n}\n\n// ❌ 错误：过度使用 div\nfunction TaskList({ tasks }: TaskListProps) {\n  return (\n    <div>\n      <div>任务列表</div>\n      <div>\n        {tasks.map(task => (\n          <div key={task.id}>\n            <div>{task.title}</div>\n          </div>\n        ))}\n      </div>\n    </div>\n  )\n}\n```\n\n### ARIA 属性\n\n```typescript\n// ✅ 正确：使用 ARIA 属性\nfunction Button({ loading, children }: ButtonProps) {\n  return (\n    <button\n      aria-busy={loading}\n      aria-label={loading ? \"加载中...\" : undefined}\n      disabled={loading}\n    >\n      {children}\n    </button>\n  )\n}\n```\n\n## 🔒 安全性\n\n### XSS 防护\n\n```typescript\n// ✅ 正确：React 自动转义\nfunction TaskDescription({ description }: { description: string }) {\n  return <p>{description}</p>\n}\n\n// ⚠️ 注意：使用 dangerouslySetInnerHTML 需谨慎\nimport DOMPurify from \"dompurify\"\n\nfunction TaskDescription({ html }: { html: string }) {\n  const sanitized = DOMPurify.sanitize(html)\n  return <div dangerouslySetInnerHTML={{ __html: sanitized }} />\n}\n```\n\n### 环境变量\n\n```typescript\n// ✅ 正确：使用环境变量\nconst API_URL = process.env.NEXT_PUBLIC_API_URL\n// NEXT_PUBLIC_ 前缀的变量会暴露给客户端\n// 没有前缀的变量只在服务端可用\n```\n\n## ✅ 代码检查清单\n\n在提交代码前，请确保：\n\n- [ ] 代码通过 Biome 检查（`pnpm lint`）\n- [ ] 代码已格式化（`pnpm format`）\n- [ ] 代码可以成功构建（`pnpm build`）\n- [ ] 所有组件和函数都有 TypeScript 类型\n- [ ] Props 接口定义完整\n- [ ] 遵循命名规范\n- [ ] 没有使用 `any` 类型（除非必要）\n- [ ] 大组件已拆分为小组件\n- [ ] 正确使用 React Hooks\n- [ ] 添加了必要的 key 属性\n- [ ] 使用了语义化 HTML 标签\n- [ ] 考虑了可访问性\n- [ ] API 调用使用 Orval 生成的 hooks（不要手写）\n- [ ] 翻译文本使用 `useTranslations`，禁止硬编码\n- [ ] TanStack Query 的 Query Keys 在 `lib/query/keys.ts` 中管理\n- [ ] 流式 API 使用 `lib/api.ts` 中的手动实现\n- [ ] 代码有适当的注释\n- [ ] 更新了相关文档\n\n---\n\nHappy Coding! ⚛️\n"
  },
  {
    "path": ".github/GIT_FLOW.md",
    "content": "# Git Flow Workflow\n\n**Language**: [English](GIT_FLOW.md) | [中文](GIT_FLOW_CN.md)\n\n## 📋 Table of Contents\n\n- [Overview](#-overview)\n- [Branch Strategy](#-branch-strategy)\n- [Workflow](#-workflow)\n- [Branch Naming Convention](#-branch-naming-convention)\n- [Common Scenarios](#-common-scenarios)\n- [Best Practices](#-best-practices)\n- [FAQ](#-faq)\n\n## 📖 Overview\n\nThe FreeTodo project adopts a Git Flow-based branch management strategy to ensure code quality and standardized development processes. This document details our branch model and workflow.\n\n### Core Principles\n\n- 🔒 **Protect Main Branch**: The `main` branch is always stable and deployable\n- 🔄 **Continuous Integration**: Continuous feature integration through the `dev` branch\n- 🧪 **Thorough Testing**: Complete testing and verification on the `test` branch\n- 🌿 **Feature Isolation**: Each feature or fix is developed in an isolated branch\n- 👥 **Collaborative Development**: Clear branch strategy facilitates team collaboration\n\n## 🌳 Branch Strategy\n\n### Long-lived Branches\n\nWe maintain the following long-lived branches:\n\n#### 1. `main` Branch\n\n- **Purpose**: Production environment branch, contains the most stable code\n- **Characteristics**:\n  - 🔒 Protected, no direct pushes allowed\n  - ✅ All code must go through complete review and testing\n  - 🏷️ Each merge should be tagged with a version (e.g., `v1.0.0`)\n  - 🚀 Can be directly deployed to production\n- **Merge Sources**: Only accepts merges from the `test` branch\n\n#### 2. `dev` Branch\n\n- **Purpose**: Development environment branch for daily development and feature integration\n- **Characteristics**:\n  - 🔄 Continuous integration of new features\n  - 👥 Main collaboration branch for team members\n  - 🧪 Relatively stable but may contain untested features\n  - 📦 Can be deployed to development environment for internal testing\n- **Merge Sources**: Accepts merges from `feat/*` and `fix/*` branches\n\n#### 3. `test` Branch\n\n- **Purpose**: Testing environment branch for complete integration and acceptance testing\n- **Characteristics**:\n  - 🧪 Used for QA testing and User Acceptance Testing (UAT)\n  - ✅ Must pass all test cases\n  - 🔍 Performs performance and compatibility testing\n  - 📋 Can only be merged to `main` after passing all tests\n- **Merge Sources**: Accepts merges from the `dev` branch\n\n### Temporary Branches\n\nThe following branch types are temporary and should be deleted after completion:\n\n#### 4. `feat/*` Branches\n\n- **Purpose**: Develop new features\n- **Naming Convention**: `feat/brief-description`\n- **Examples**:\n  - `feat/task-auto-association`\n  - `feat/dark-mode`\n  - `feat/export-data`\n- **Lifecycle**:\n  - Created from the `dev` branch\n  - Merged back to the `dev` branch when complete\n  - Deleted after merging\n\n#### 5. `fix/*` Branches\n\n- **Purpose**: Fix bugs\n- **Naming Convention**: `fix/brief-bug-description`\n- **Examples**:\n  - `fix/screenshot-capture-error`\n  - `fix/memory-leak`\n  - `fix/login-redirect`\n- **Lifecycle**:\n  - Created from `dev` branch (development bugs)\n  - Created from `test` branch (testing bugs)\n  - Created from `main` branch (production emergency bugs, see Hotfix)\n  - Merged back to the original branch when complete\n  - Deleted after merging\n\n#### 6. `hotfix/*` Branches (Special Case)\n\n- **Purpose**: Fix critical production bugs\n- **Naming Convention**: `hotfix/critical-bug-description`\n- **Examples**:\n  - `hotfix/critical-security-issue`\n  - `hotfix/data-loss-bug`\n- **Lifecycle**:\n  - Created from the `main` branch\n  - Merged back to both `main` and `dev` branches when complete\n  - Tagged with a patch version on the `main` branch\n  - Deleted after merging\n\n## 🔄 Workflow\n\n### Feature Development Flow\n\n```mermaid\ngraph LR\n    A[dev] -->|create| B[feat/*]\n    B -->|develop| C[commit code]\n    C -->|complete| D[create PR]\n    D -->|review passed| E[merge to dev]\n    E -->|integration test| F[merge to test]\n    F -->|tests passed| G[merge to main]\n    G -->|tag| H[v1.0.0]\n```\n\n### Detailed Steps\n\n#### Step 1: Create Feature Branch\n\n```bash\n# Ensure local dev branch is up to date\ngit checkout dev\ngit pull origin dev\n\n# Create and switch to new feature branch\ngit checkout -b feat/your-feature-name\n```\n\n#### Step 2: Develop Feature\n\n```bash\n# Do your development work\n# ... write code ...\n\n# Commit changes regularly\ngit add .\ngit commit -m \"feat: add new feature description\"\n\n# Push to remote regularly (backup and collaboration)\ngit push origin feat/your-feature-name\n```\n\n#### Step 3: Keep Branch Updated\n\n```bash\n# Regularly sync latest changes from dev\ngit checkout dev\ngit pull origin dev\n\n# Switch back to feature branch\ngit checkout feat/your-feature-name\n\n# Merge dev updates (rebase recommended)\ngit rebase dev\n# or use merge\ngit merge dev\n\n# Push updates\ngit push origin feat/your-feature-name --force-with-lease  # needed after rebase\n```\n\n#### Step 4: Create Pull Request\n\n1. Push your branch to GitHub\n2. Create a Pull Request on GitHub\n3. Select `dev` as the target branch\n4. Fill out the PR template describing your changes\n5. Wait for code review\n\n#### Step 5: Code Review and Merge\n\n1. Maintainers review the code\n2. Make changes based on feedback\n3. After review approval, maintainers merge the PR\n4. Delete the feature branch\n\n```bash\n# After PR is merged, delete local and remote branches\ngit checkout dev\ngit pull origin dev\ngit branch -d feat/your-feature-name\ngit push origin --delete feat/your-feature-name\n```\n\n### Bug Fix Flow\n\n#### Development Environment Bug (found in dev branch)\n\n```bash\n# Create fix branch from dev\ngit checkout dev\ngit pull origin dev\ngit checkout -b fix/bug-description\n\n# Fix the bug\n# ... write code ...\n\ngit add .\ngit commit -m \"fix: resolve bug description\"\ngit push origin fix/bug-description\n\n# Create PR to dev branch\n```\n\n#### Testing Environment Bug (found in test branch)\n\n```bash\n# Create fix branch from test\ngit checkout test\ngit pull origin test\ngit checkout -b fix/test-bug-description\n\n# After fixing, merge back to test\n# Also need to merge to dev to avoid regression\n```\n\n#### Production Environment Bug (found in main branch)\n\n```bash\n# Create hotfix branch from main\ngit checkout main\ngit pull origin main\ngit checkout -b hotfix/critical-bug\n\n# Fix the bug\n# ... write code ...\n\ngit add .\ngit commit -m \"fix: resolve critical bug\"\ngit push origin hotfix/critical-bug\n\n# Create PR to main branch\n# After merging, also need to merge to dev and test branches\n```\n\n### Release Flow\n\n#### From dev to test\n\n```bash\n# When dev branch has accumulated enough features, ready to release\ngit checkout test\ngit pull origin test\n\n# Merge dev branch\ngit merge dev\n\n# Push to remote\ngit push origin test\n\n# Notify testing team to start testing\n```\n\n#### From test to main\n\n```bash\n# After test branch passes testing\ngit checkout main\ngit pull origin main\n\n# Merge test branch\ngit merge test\n\n# Tag with version\ngit tag -a v1.0.0 -m \"Release version 1.0.0\"\n\n# Push to remote (including tag)\ngit push origin main\ngit push origin v1.0.0\n\n# Release new version\n```\n\n### Version Tagging Convention\n\nWe use [Semantic Versioning](https://semver.org/):\n\n- **Format**: `v<major>.<minor>.<patch>`\n- **Example**: `v1.2.3`\n\n**Version Number Increment Rules**:\n\n- **MAJOR version**: Incompatible API changes\n- **MINOR version**: Backwards-compatible functionality additions\n- **PATCH version**: Backwards-compatible bug fixes\n\n```bash\n# Examples\nv1.0.0  # First official release\nv1.1.0  # Add new features\nv1.1.1  # Bug fixes\nv2.0.0  # Major update, may break compatibility\n```\n\n## 📝 Branch Naming Convention\n\n### Naming Format\n\n```\n<type>/<description>\n```\n\n### Type Categories\n\n| Type | Purpose | Example |\n|------|---------|---------|\n| `feature` | New feature development | `feat/user-authentication` |\n| `fix` | Bug fixes | `fix/login-error` |\n| `hotfix` | Emergency fixes | `hotfix/security-patch` |\n| `docs` | Documentation updates | `docs/api-documentation` |\n| `refactor` | Code refactoring | `refactor/database-layer` |\n| `test` | Testing related | `test/unit-tests` |\n| `chore` | Build/tools related | `chore/update-dependencies` |\n| `perf` | Performance optimization | `perf/query-optimization` |\n\n### Description Naming Rules\n\n- ✅ Use lowercase letters\n- ✅ Use hyphens `-` to separate words\n- ✅ Be concise and clear, describe branch purpose\n- ✅ Use English (for internationalization)\n- ❌ Avoid special characters\n- ❌ Avoid spaces\n- ❌ Avoid overly long names (recommend under 50 characters)\n\n### Naming Examples\n\n```bash\n# Good names\nfeat/task-auto-association\nfix/screenshot-capture-windows\ndocs/contribution-guide\nrefactor/api-error-handling\ntest/integration-tests\nperf/vector-search-optimization\n\n# Bad names\nfeat/new_feature  # Don't use underscores\nfix/bug              # Too vague\nfeat/SOMETHING    # Don't use uppercase\nfeat/这是一个新功能  # Don't use non-English characters\n```\n\n## 🎯 Common Scenarios\n\n### Scenario 1: Develop New Feature\n\n```bash\n# 1. Update dev branch\ngit checkout dev\ngit pull origin dev\n\n# 2. Create feature branch\ngit checkout -b feat/new-export-function\n\n# 3. Develop feature\n# ... write code ...\n\n# 4. Commit changes\ngit add .\ngit commit -m \"feat(backend): add data export API\"\n\n# 5. Push branch\ngit push origin feat/new-export-function\n\n# 6. Create PR to dev branch on GitHub\n```\n\n### Scenario 2: Fix Development Bug\n\n```bash\n# 1. Update dev branch\ngit checkout dev\ngit pull origin dev\n\n# 2. Create fix branch\ngit checkout -b fix/api-response-error\n\n# 3. Fix bug\n# ... write code ...\n\n# 4. Commit changes\ngit add .\ngit commit -m \"fix(api): correct error response format\"\n\n# 5. Push and create PR\ngit push origin fix/api-response-error\n```\n\n### Scenario 3: Emergency Production Fix\n\n```bash\n# 1. Create hotfix branch from main\ngit checkout main\ngit pull origin main\ngit checkout -b hotfix/critical-data-loss\n\n# 2. Fix bug\n# ... write code ...\n\n# 3. Commit changes\ngit add .\ngit commit -m \"fix: prevent data loss in edge case\"\n\n# 4. Push and create PR to main\ngit push origin hotfix/critical-data-loss\n\n# 5. After merging to main, tag with patch version\ngit checkout main\ngit pull origin main\ngit tag -a v1.0.1 -m \"Hotfix: critical data loss\"\ngit push origin v1.0.1\n\n# 6. Sync to dev branch\ngit checkout dev\ngit merge main\ngit push origin dev\n```\n\n### Scenario 4: Resolve Merge Conflicts\n\n```bash\n# 1. Encounter conflicts when trying to merge or rebase\ngit checkout feat/your-feature\ngit rebase dev\n# Conflict notification\n\n# 2. View conflicted files\ngit status\n\n# 3. Manually resolve conflicts\n# Edit conflicted files, remove conflict markers\n# <<<<<<< HEAD\n# =======\n# >>>>>>> dev\n\n# 4. Mark conflicts as resolved\ngit add <resolved-file>\n\n# 5. Continue rebase\ngit rebase --continue\n\n# 6. Force push (history has changed)\ngit push origin feat/your-feature --force-with-lease\n```\n\n### Scenario 5: Sync Hotfix Across Multiple Branches\n\n```bash\n# Hotfix has been merged to main\ngit checkout main\ngit pull origin main\n\n# 1. Merge to test\ngit checkout test\ngit pull origin test\ngit merge main\ngit push origin test\n\n# 2. Merge to dev\ngit checkout dev\ngit pull origin dev\ngit merge main\ngit push origin dev\n```\n\n## 💡 Best Practices\n\n### 1. Regularly Sync Upstream Updates\n\n```bash\n# Sync dev branch at the start of each day\ngit checkout dev\ngit pull origin dev\n\n# Regularly merge dev updates into feature branch\ngit checkout feat/your-feature\ngit rebase dev  # Recommended to keep history clean\n```\n\n### 2. Keep Commit History Clean\n\n```bash\n# Use meaningful commit messages\ngit commit -m \"feat(ui): add dark mode toggle button\"\n\n# Squash small commits (before pushing)\ngit rebase -i HEAD~3  # Interactive rebase last 3 commits\n\n# Use squash when merging PR (optional)\n# Combine multiple commits into one logical unit\n```\n\n### 3. Pre-Review Checklist\n\n- [ ] Code follows project coding standards\n- [ ] All tests pass\n- [ ] Necessary tests added\n- [ ] Related documentation updated\n- [ ] Commit messages follow convention\n- [ ] Branch synced with latest dev code\n- [ ] No unrelated files or debug code\n\n### 4. Branch Protection Rules\n\nSet protection rules for long-lived branches on GitHub:\n\n**main Branch**:\n\n- ✅ Require PR review (at least 1 approval)\n- ✅ Require status checks to pass (CI/CD)\n- ✅ Require branch to be up to date\n- ✅ Restrict who can push\n- ✅ No force pushes allowed\n\n**dev Branch**:\n\n- ✅ Require PR review\n- ✅ Require status checks to pass\n- ⚠️ Allow maintainers to bypass (special cases)\n\n**test Branch**:\n\n- ✅ Require status checks to pass\n- ⚠️ Allow direct pushes (testing needs)\n\n### 5. Regular Branch Cleanup\n\n```bash\n# View merged local branches\ngit branch --merged dev\n\n# Delete merged local branches\ngit branch -d feat/old-feature\n\n# View remote-deleted but locally existing branches\ngit remote prune origin --dry-run\n\n# Clean up these branches\ngit remote prune origin\n\n# Delete all merged local branches (use with caution)\ngit branch --merged dev | grep -v \"\\* dev\" | xargs -n 1 git branch -d\n```\n\n### 6. Use Git Aliases for Efficiency\n\nAdd to `~/.gitconfig`:\n\n```ini\n[alias]\n    # Common command shortcuts\n    co = checkout\n    br = branch\n    ci = commit\n    st = status\n\n    # View graphical log\n    lg = log --graph --pretty=format:'%Cred%h%Creset -%C(yellow)%d%Creset %s %Cgreen(%cr) %C(bold blue)<%an>%Creset' --abbrev-commit\n\n    # View branch relationships\n    tree = log --graph --oneline --all\n\n    # Sync remote branch\n    sync = !git fetch origin && git rebase origin/dev\n\n    # Clean up merged branches\n    cleanup = !git branch --merged dev | grep -v '\\\\* dev' | xargs -n 1 git branch -d\n```\n\nUsage examples:\n\n```bash\ngit co dev          # checkout dev\ngit lg              # View beautified log\ngit tree            # View branch tree\ngit sync            # Sync dev branch\ngit cleanup         # Clean up merged branches\n```\n\n## ❓ FAQ\n\n### Q1: Should I use merge or rebase?\n\n**Recommended approach**:\n\n- **Feature branch syncing with dev**: Use `rebase`\n\n  ```bash\n  git checkout feat/your-feature\n  git rebase dev\n  ```\n\n- **Merging to main branches**: Use `merge` (via PR)\n\n  ```bash\n  # Use \"Squash and merge\" or \"Merge commit\" in GitHub PR\n  ```\n\n**Reasoning**:\n\n- `rebase` keeps history linear and easy to understand\n- `merge` preserves branch history, convenient for tracking feature development\n\n### Q2: My feature branch is many versions behind dev, what should I do?\n\n```bash\n# Method 1: Rebase (recommended, keeps history clean)\ngit checkout feat/your-feature\ngit fetch origin\ngit rebase origin/dev\n\n# If conflicts occur, resolve and continue\ngit add <resolved-files>\ngit rebase --continue\n\n# Push (requires force push as history changed)\ngit push origin feat/your-feature --force-with-lease\n\n# Method 2: Merge (simple, but history will fork)\ngit checkout feat/your-feature\ngit merge origin/dev\ngit push origin feat/your-feature\n```\n\n### Q3: I accidentally developed on the main branch, what do I do?\n\n```bash\n# 1. Create new feature branch, save current work\ngit checkout -b feat/accidental-work\n\n# 2. Push to remote\ngit push origin feat/accidental-work\n\n# 3. Reset main branch to remote state\ngit checkout main\ngit reset --hard origin/main\n\n# 4. Continue working on feature branch\ngit checkout feat/accidental-work\n```\n\n### Q4: How do I undo a pushed commit?\n\n```bash\n# Method 1: Revert (recommended, creates new commit to undo)\ngit revert <commit-hash>\ngit push origin your-branch\n\n# Method 2: Reset + Force Push (dangerous, only for personal branches)\ngit reset --hard <commit-hash>\ngit push origin your-branch --force-with-lease\n```\n\n⚠️ **Warning**: Never use `--force` push on shared branches (main, dev, test)!\n\n### Q5: PR review requested changes, how do I update?\n\n```bash\n# 1. Continue modifying on your feature branch\ngit checkout feat/your-feature\n\n# 2. Make changes and commit\ngit add .\ngit commit -m \"fix: address PR review comments\"\n\n# 3. Push (will automatically update PR)\ngit push origin feat/your-feature\n\n# If you want to combine multiple small fixes into one commit\ngit rebase -i HEAD~3  # Combine last 3 commits\ngit push origin feat/your-feature --force-with-lease\n```\n\n### Q6: How to handle long-running feature branches?\n\n```bash\n# 1. Regularly (daily) sync updates from dev\ngit checkout feat/long-running\ngit fetch origin\ngit rebase origin/dev\n\n# 2. Consider splitting into multiple smaller PRs\n# Create sub-feature branches\ngit checkout -b feat/long-running-part1\n# Submit PR for partial functionality\n\n# 3. Communicate with team to avoid conflicts\n# Inform about features being developed in Issues or discussions\n```\n\n### Q7: Team members modified the same file, how to collaborate?\n\n**Best practices**:\n\n1. **Communicate in advance**: Discuss before starting, assign different tasks\n2. **Small frequent commits**: Commit small changes frequently, reduce conflicts\n3. **Merge promptly**: Review and merge PRs as soon as possible\n4. **Resolve conflicts**: When conflicts occur, communicate with related developers\n\n```bash\n# If simultaneous modifications are necessary\n# 1. Sync frequently\ngit checkout feat/your-work\ngit fetch origin\ngit rebase origin/dev\n\n# 2. Discuss with colleagues when resolving conflicts\n# 3. Consider splitting files into smaller modules\n```\n\n### Q8: When should I create a hotfix branch?\n\n**Scenarios for creating hotfix**:\n\n- 🚨 Critical bug in production\n- 🔒 Security vulnerability needs immediate fix\n- 💥 Data integrity issues\n- 🛑 Service outage or severe performance issues\n\n**Scenarios NOT needing hotfix**:\n\n- Regular bugs (use `fix/*` branch through normal process)\n- Minor UI issues\n- Feature improvements\n- Documentation updates\n\n### Q9: How to find which version introduced a feature?\n\n```bash\n# View modification history of a file\ngit log --follow -- path/to/file\n\n# View tags containing a specific feature\ngit tag --contains <commit-hash>\n\n# View differences between two versions\ngit log v1.0.0..v1.1.0 --oneline\n\n# Search commit messages\ngit log --grep=\"feature name\"\n```\n\n### Q10: My branch is messy, how to start fresh?\n\n```bash\n# 1. Backup current work (if uncommitted changes)\ngit stash\n\n# 2. Create new clean branch\ngit checkout dev\ngit pull origin dev\ngit checkout -b feat/clean-start\n\n# 3. Cherry-pick needed commits\ngit cherry-pick <commit-hash1>\ngit cherry-pick <commit-hash2>\n\n# 4. Or start development from scratch\n# Manually copy code, commit again\n\n# 5. Delete old branch\ngit branch -D feat/old-messy-branch\ngit push origin --delete feat/old-messy-branch\n```\n\n## 📚 Reference Resources\n\n### Git Learning Resources\n\n- [Pro Git Book](https://git-scm.com/book/en/v2)\n- [Git Simple Guide](https://rogerdudler.github.io/git-guide/)\n- [A Successful Git Branching Model](https://nvie.com/posts/a-successful-git-branching-model/)\n- [GitHub Flow](https://docs.github.com/en/get-started/quickstart/github-flow)\n\n### Recommended Git Tools\n\n- **Command Line Tools**:\n  - [tig](https://jonas.github.io/tig/) - Text-mode interface for Git\n  - [lazygit](https://github.com/jesseduffield/lazygit) - Terminal UI tool\n\n- **Graphical Interfaces**:\n  - [GitKraken](https://www.gitkraken.com/) - Cross-platform Git client\n  - [Sourcetree](https://www.sourcetreeapp.com/) - Free Git client\n  - [GitHub Desktop](https://desktop.github.com/) - Official GitHub client\n\n- **VS Code Extensions**:\n  - GitLens - Enhanced Git functionality\n  - Git Graph - Visualize branch graph\n  - Git History - View file history\n\n## 🎓 Git Command Cheat Sheet\n\n### Basic Operations\n\n```bash\n# Clone repository\ngit clone <repository-url>\n\n# Check status\ngit status\n\n# Add files to staging\ngit add <file>\ngit add .\n\n# Commit changes\ngit commit -m \"message\"\n\n# Push to remote\ngit push origin <branch>\n\n# Pull remote changes\ngit pull origin <branch>\n```\n\n### Branch Operations\n\n```bash\n# View branches\ngit branch\ngit branch -a  # Include remote branches\n\n# Create branch\ngit branch <branch-name>\n\n# Switch branch\ngit checkout <branch-name>\n\n# Create and switch to new branch\ngit checkout -b <branch-name>\n\n# Delete local branch\ngit branch -d <branch-name>\ngit branch -D <branch-name>  # Force delete\n\n# Delete remote branch\ngit push origin --delete <branch-name>\n```\n\n### Merge and Rebase\n\n```bash\n# Merge branch\ngit merge <branch-name>\n\n# Rebase\ngit rebase <branch-name>\n\n# Interactive rebase\ngit rebase -i HEAD~3\n\n# Continue after resolving conflicts\ngit rebase --continue\n\n# Abort rebase\ngit rebase --abort\n```\n\n### Remote Operations\n\n```bash\n# View remote repositories\ngit remote -v\n\n# Add remote repository\ngit remote add origin <url>\n\n# Fetch remote updates\ngit fetch origin\n\n# Sync remote deleted branches\ngit remote prune origin\n```\n\n### Undo and Reset\n\n```bash\n# Undo working directory changes\ngit checkout -- <file>\n\n# Unstage\ngit reset HEAD <file>\n\n# Undo commit (keep changes)\ngit reset --soft HEAD~1\n\n# Undo commit (discard changes)\ngit reset --hard HEAD~1\n\n# Revert commit (create new commit)\ngit revert <commit-hash>\n```\n\n### View History\n\n```bash\n# View commit history\ngit log\ngit log --oneline\ngit log --graph --all\n\n# View file history\ngit log -- <file>\n\n# View commit details\ngit show <commit-hash>\n\n# View diff between commits\ngit diff <commit1> <commit2>\n```\n\n### Tag Operations\n\n```bash\n# Create tag\ngit tag v1.0.0\ngit tag -a v1.0.0 -m \"Release version 1.0.0\"\n\n# Push tag\ngit push origin v1.0.0\ngit push origin --tags  # Push all tags\n\n# Delete tag\ngit tag -d v1.0.0\ngit push origin --delete v1.0.0\n```\n\n## 🚦 Workflow Diagram\n\n### Complete Feature Development Flow\n\n```\nDeveloper forks repository\n    ↓\nClone to local\n    ↓\nCreate feat/* branch ← dev branch\n    ↓\nLocal development and testing\n    ↓\nCommit code\n    ↓\nPush to GitHub\n    ↓\nCreate Pull Request → dev branch\n    ↓\nCode review\n    ↓\nCI/CD automated testing\n    ↓\nReview approved\n    ↓\nMerge to dev branch\n    ↓\nIntegration testing\n    ↓\nMerge to test branch\n    ↓\nComplete testing\n    ↓\nMerge to main branch\n    ↓\nTag version\n    ↓\nRelease\n```\n\n---\n\n## 📞 Get Help\n\nIf you have any questions about Git Flow:\n\n1. 📖 Check the FAQ section of this document\n2. 💬 Ask in GitHub Discussions\n3. 🐛 Create an Issue if you find documentation errors\n4. 👥 Consult project maintainers or experienced contributors\n\n---\n\n**Remember**: A good Git workflow is not just a technical issue, it's the foundation of team collaboration. Follow the conventions, maintain communication, and together we'll build a better FreeTodo! 🚀\n"
  },
  {
    "path": ".github/GIT_FLOW_CN.md",
    "content": "# Git Flow 工作流程\n\n**语言**: [English](GIT_FLOW.md) | [中文](GIT_FLOW_CN.md)\n\n## 📋 目录\n\n- [概述](#-概述)\n- [分支策略](#-分支策略)\n- [工作流程](#-工作流程)\n- [分支命名规范](#-分支命名规范)\n- [常见场景](#-常见场景)\n- [最佳实践](#-最佳实践)\n- [常见问题](#-常见问题)\n\n## 📖 概述\n\nFreeTodo 项目采用基于 Git Flow 的分支管理策略，以确保代码质量和开发流程的规范性。本文档详细描述了我们的分支模型和工作流程。\n\n### 核心理念\n\n- 🔒 **保护主分支**：`main` 分支始终保持稳定和可发布状态\n- 🔄 **持续集成**：通过 `dev` 分支进行持续的功能集成\n- 🧪 **充分测试**：在 `test` 分支进行完整的测试验证\n- 🌿 **特性隔离**：每个功能或修复都在独立的分支中开发\n- 👥 **协作开发**：清晰的分支策略便于团队协作\n\n## 🌳 分支策略\n\n### 长期分支\n\n我们维护以下长期存在的分支：\n\n#### 1. `main` 分支\n\n- **用途**：生产环境分支，包含最稳定的代码\n- **特点**：\n  - 🔒 受保护，不允许直接推送\n  - ✅ 所有代码必须经过完整的审查和测试\n  - 🏷️ 每次合并都应该打上版本标签（如 `v1.0.0`）\n  - 🚀 可以直接部署到生产环境\n- **合并来源**：仅接受来自 `test` 分支的合并\n\n#### 2. `dev` 分支\n\n- **用途**：开发环境分支，用于日常开发和功能集成\n- **特点**：\n  - 🔄 持续集成新功能\n  - 👥 团队成员的主要协作分支\n  - 🧪 相对稳定但可能包含未完全测试的功能\n  - 📦 可以部署到开发环境进行内部测试\n- **合并来源**：接受来自 `feat/*` 和 `fix/*` 分支的合并\n\n#### 3. `test` 分支\n\n- **用途**：测试环境分支，用于完整的集成测试和验收测试\n- **特点**：\n  - 🧪 用于 QA 测试和用户验收测试（UAT）\n  - ✅ 必须通过所有测试用例\n  - 🔍 进行性能测试和兼容性测试\n  - 📋 测试通过后才能合并到 `main`\n- **合并来源**：接受来自 `dev` 分支的合并\n\n### 临时分支\n\n以下分支类型是临时的，完成任务后应该删除：\n\n#### 4. `feat/*` 分支\n\n- **用途**：开发新功能\n- **命名规范**：`feat/功能简短描述`\n- **示例**：\n  - `feat/task-auto-association`\n  - `feat/dark-mode`\n  - `feat/export-data`\n- **生命周期**：\n  - 从 `dev` 分支创建\n  - 完成后合并回 `dev` 分支\n  - 合并后删除\n\n#### 5. `fix/*` 分支\n\n- **用途**：修复 Bug\n- **命名规范**：`fix/bug简短描述`\n- **示例**：\n  - `fix/screenshot-capture-error`\n  - `fix/memory-leak`\n  - `fix/login-redirect`\n- **生命周期**：\n  - 从 `dev` 分支创建（开发环境的 Bug）\n  - 从 `test` 分支创建（测试环境的 Bug）\n  - 从 `main` 分支创建（生产环境的紧急 Bug，见 Hotfix）\n  - 完成后合并回原始分支\n  - 合并后删除\n\n#### 6. `hotfix/*` 分支（特殊情况）\n\n- **用途**：修复生产环境的紧急 Bug\n- **命名规范**：`hotfix/紧急bug描述`\n- **示例**：\n  - `hotfix/critical-security-issue`\n  - `hotfix/data-loss-bug`\n- **生命周期**：\n  - 从 `main` 分支创建\n  - 完成后合并回 `main` 和 `dev` 分支\n  - 在 `main` 分支打上 patch 版本标签\n  - 合并后删除\n\n## 🔄 工作流程\n\n### 功能开发流程\n\n```mermaid\ngraph LR\n    A[dev] -->|创建| B[feat/*]\n    B -->|开发| C[提交代码]\n    C -->|完成| D[创建 PR]\n    D -->|审查通过| E[合并到 dev]\n    E -->|集成测试| F[合并到 test]\n    F -->|测试通过| G[合并到 main]\n    G -->|打标签| H[v1.0.0]\n```\n\n### 详细步骤\n\n#### 步骤 1：创建功能分支\n\n```bash\n# 确保本地 dev 分支是最新的\ngit checkout dev\ngit pull origin dev\n\n# 创建并切换到新的功能分支\ngit checkout -b feat/your-feature-name\n```\n\n#### 步骤 2：开发功能\n\n```bash\n# 进行开发工作\n# ... 编写代码 ...\n\n# 定期提交更改\ngit add .\ngit commit -m \"feat: add new feature description\"\n\n# 定期推送到远程仓库（备份和协作）\ngit push origin feat/your-feature-name\n```\n\n#### 步骤 3：保持分支更新\n\n```bash\n# 定期同步 dev 分支的最新更改\ngit checkout dev\ngit pull origin dev\n\n# 切换回功能分支\ngit checkout feat/your-feature-name\n\n# 合并 dev 的更新（推荐使用 rebase）\ngit rebase dev\n# 或者使用 merge\ngit merge dev\n\n# 推送更新\ngit push origin feat/your-feature-name --force-with-lease  # rebase 后需要\n```\n\n#### 步骤 4：创建 Pull Request\n\n1. 推送您的分支到 GitHub\n2. 在 GitHub 上创建 Pull Request\n3. 选择目标分支为 `dev`\n4. 填写 PR 模板，描述您的更改\n5. 等待代码审查\n\n#### 步骤 5：代码审查和合并\n\n1. 维护者审查代码\n2. 根据反馈进行修改\n3. 审查通过后，维护者合并 PR\n4. 删除功能分支\n\n```bash\n# PR 合并后，删除本地和远程分支\ngit checkout dev\ngit pull origin dev\ngit branch -d feat/your-feature-name\ngit push origin --delete feat/your-feature-name\n```\n\n### Bug 修复流程\n\n#### 开发环境 Bug（在 dev 分支发现）\n\n```bash\n# 从 dev 创建修复分支\ngit checkout dev\ngit pull origin dev\ngit checkout -b fix/bug-description\n\n# 修复 Bug\n# ... 编写代码 ...\n\ngit add .\ngit commit -m \"fix: resolve bug description\"\ngit push origin fix/bug-description\n\n# 创建 PR 到 dev 分支\n```\n\n#### 测试环境 Bug（在 test 分支发现）\n\n```bash\n# 从 test 创建修复分支\ngit checkout test\ngit pull origin test\ngit checkout -b fix/test-bug-description\n\n# 修复 Bug 后，合并回 test\n# 同时需要合并到 dev，避免回归\n```\n\n#### 生产环境 Bug（在 main 分支发现）\n\n```bash\n# 从 main 创建 hotfix 分支\ngit checkout main\ngit pull origin main\ngit checkout -b hotfix/critical-bug\n\n# 修复 Bug\n# ... 编写代码 ...\n\ngit add .\ngit commit -m \"fix: resolve critical bug\"\ngit push origin hotfix/critical-bug\n\n# 创建 PR 到 main 分支\n# 合并后，也需要合并到 dev 和 test 分支\n```\n\n### 发布流程\n\n#### 从 dev 到 test\n\n```bash\n# 当 dev 分支积累了足够的功能，准备发布时\ngit checkout test\ngit pull origin test\n\n# 合并 dev 分支\ngit merge dev\n\n# 推送到远程\ngit push origin test\n\n# 通知测试团队开始测试\n```\n\n#### 从 test 到 main\n\n```bash\n# test 分支测试通过后\ngit checkout main\ngit pull origin main\n\n# 合并 test 分支\ngit merge test\n\n# 打上版本标签\ngit tag -a v1.0.0 -m \"Release version 1.0.0\"\n\n# 推送到远程（包括标签）\ngit push origin main\ngit push origin v1.0.0\n\n# 发布新版本\n```\n\n### 版本标签规范\n\n我们使用 [语义化版本](https://semver.org/lang/zh-CN/)（Semantic Versioning）：\n\n- **格式**：`v主版本号.次版本号.修订号`\n- **示例**：`v1.2.3`\n\n**版本号递增规则**：\n\n- **主版本号（MAJOR）**：不兼容的 API 修改\n- **次版本号（MINOR）**：向下兼容的功能性新增\n- **修订号（PATCH）**：向下兼容的问题修正\n\n```bash\n# 示例\nv1.0.0  # 首个正式版本\nv1.1.0  # 添加新功能\nv1.1.1  # Bug 修复\nv2.0.0  # 重大更新，可能不兼容旧版本\n```\n\n## 📝 分支命名规范\n\n### 命名格式\n\n```\n<type>/<description>\n```\n\n### Type 类型\n\n| Type | 用途 | 示例 |\n|------|------|------|\n| `feature` | 新功能开发 | `feat/user-authentication` |\n| `fix` | Bug 修复 | `fix/login-error` |\n| `hotfix` | 紧急修复 | `hotfix/security-patch` |\n| `docs` | 文档更新 | `docs/api-documentation` |\n| `refactor` | 代码重构 | `refactor/database-layer` |\n| `test` | 测试相关 | `test/unit-tests` |\n| `chore` | 构建/工具相关 | `chore/update-dependencies` |\n| `perf` | 性能优化 | `perf/query-optimization` |\n\n### 描述命名规则\n\n- ✅ 使用小写字母\n- ✅ 使用连字符 `-` 分隔单词\n- ✅ 简洁明了，描述分支目的\n- ✅ 使用英文（项目国际化考虑）\n- ❌ 避免使用特殊字符\n- ❌ 避免使用空格\n- ❌ 避免过长的名称（建议不超过 50 个字符）\n\n### 命名示例\n\n```bash\n# 好的命名\nfeat/task-auto-association\nfix/screenshot-capture-windows\ndocs/contribution-guide\nrefactor/api-error-handling\ntest/integration-tests\nperf/vector-search-optimization\n\n# 不好的命名\nfeat/new_feature  # 不要使用下划线\nfix/bug              # 太模糊\nfeat/SOMETHING    # 不要使用大写\nfeat/这是一个新功能  # 不要使用中文\n```\n\n## 🎯 常见场景\n\n### 场景 1：开发新功能\n\n```bash\n# 1. 更新 dev 分支\ngit checkout dev\ngit pull origin dev\n\n# 2. 创建功能分支\ngit checkout -b feat/new-export-function\n\n# 3. 开发功能\n# ... 编写代码 ...\n\n# 4. 提交更改\ngit add .\ngit commit -m \"feat(backend): add data export API\"\n\n# 5. 推送分支\ngit push origin feat/new-export-function\n\n# 6. 在 GitHub 创建 PR 到 dev 分支\n```\n\n### 场景 2：修复开发环境 Bug\n\n```bash\n# 1. 更新 dev 分支\ngit checkout dev\ngit pull origin dev\n\n# 2. 创建修复分支\ngit checkout -b fix/api-response-error\n\n# 3. 修复 Bug\n# ... 编写代码 ...\n\n# 4. 提交更改\ngit add .\ngit commit -m \"fix(api): correct error response format\"\n\n# 5. 推送并创建 PR\ngit push origin fix/api-response-error\n```\n\n### 场景 3：紧急修复生产环境 Bug\n\n```bash\n# 1. 从 main 创建 hotfix 分支\ngit checkout main\ngit pull origin main\ngit checkout -b hotfix/critical-data-loss\n\n# 2. 修复 Bug\n# ... 编写代码 ...\n\n# 3. 提交更改\ngit add .\ngit commit -m \"fix: prevent data loss in edge case\"\n\n# 4. 推送并创建 PR 到 main\ngit push origin hotfix/critical-data-loss\n\n# 5. 合并到 main 后，打上 patch 标签\ngit checkout main\ngit pull origin main\ngit tag -a v1.0.1 -m \"Hotfix: critical data loss\"\ngit push origin v1.0.1\n\n# 6. 同步到 dev 分支\ngit checkout dev\ngit merge main\ngit push origin dev\n```\n\n### 场景 4：解决合并冲突\n\n```bash\n# 1. 尝试合并或 rebase 时遇到冲突\ngit checkout feat/your-feature\ngit rebase dev\n# 冲突提示\n\n# 2. 查看冲突文件\ngit status\n\n# 3. 手动解决冲突\n# 编辑冲突文件，移除冲突标记\n# <<<<<<< HEAD\n# =======\n# >>>>>>> dev\n\n# 4. 标记冲突已解决\ngit add <resolved-file>\n\n# 5. 继续 rebase\ngit rebase --continue\n\n# 6. 强制推送（因为历史已改变）\ngit push origin feat/your-feature --force-with-lease\n```\n\n### 场景 5：同步多个分支的 Hotfix\n\n```bash\n# Hotfix 已合并到 main\ngit checkout main\ngit pull origin main\n\n# 1. 合并到 test\ngit checkout test\ngit pull origin test\ngit merge main\ngit push origin test\n\n# 2. 合并到 dev\ngit checkout dev\ngit pull origin dev\ngit merge main\ngit push origin dev\n```\n\n## 💡 最佳实践\n\n### 1. 及时同步上游更新\n\n```bash\n# 每天开始工作前，同步 dev 分支\ngit checkout dev\ngit pull origin dev\n\n# 定期将 dev 的更新合并到功能分支\ngit checkout feat/your-feature\ngit rebase dev  # 推荐使用 rebase 保持历史清晰\n```\n\n### 2. 保持提交历史清晰\n\n```bash\n# 使用有意义的提交信息\ngit commit -m \"feat(ui): add dark mode toggle button\"\n\n# 合并小的提交（在推送前）\ngit rebase -i HEAD~3  # 交互式 rebase 最近 3 个提交\n\n# 在 PR 合并时使用 squash（可选）\n# 将多个提交合并为一个逻辑单元\n```\n\n### 3. 代码审查前的检查清单\n\n- [ ] 代码遵循项目编码规范\n- [ ] 所有测试通过\n- [ ] 添加了必要的测试\n- [ ] 更新了相关文档\n- [ ] 提交信息符合规范\n- [ ] 分支已同步最新的 dev 代码\n- [ ] 没有无关的文件或调试代码\n\n### 4. 分支保护规则\n\n在 GitHub 上为长期分支设置保护规则：\n\n**main 分支**：\n\n- ✅ 要求 PR 审查（至少 1 人批准）\n- ✅ 要求状态检查通过（CI/CD）\n- ✅ 要求分支是最新的\n- ✅ 限制可以推送的人员\n- ✅ 不允许强制推送\n\n**dev 分支**：\n\n- ✅ 要求 PR 审查\n- ✅ 要求状态检查通过\n- ⚠️ 允许维护者绕过规则（特殊情况）\n\n**test 分支**：\n\n- ✅ 要求状态检查通过\n- ⚠️ 可以直接推送（测试需求）\n\n### 5. 定期清理分支\n\n```bash\n# 查看已合并的本地分支\ngit branch --merged dev\n\n# 删除已合并的本地分支\ngit branch -d feat/old-feature\n\n# 查看远程已删除但本地还存在的分支\ngit remote prune origin --dry-run\n\n# 清理这些分支\ngit remote prune origin\n\n# 删除所有本地的已合并分支（谨慎使用）\ngit branch --merged dev | grep -v \"\\* dev\" | xargs -n 1 git branch -d\n```\n\n### 6. 使用 Git 别名提高效率\n\n在 `~/.gitconfig` 中添加：\n\n```ini\n[alias]\n    # 常用命令简写\n    co = checkout\n    br = branch\n    ci = commit\n    st = status\n\n    # 查看图形化日志\n    lg = log --graph --pretty=format:'%Cred%h%Creset -%C(yellow)%d%Creset %s %Cgreen(%cr) %C(bold blue)<%an>%Creset' --abbrev-commit\n\n    # 查看分支关系\n    tree = log --graph --oneline --all\n\n    # 同步远程分支\n    sync = !git fetch origin && git rebase origin/dev\n\n    # 清理已合并的分支\n    cleanup = !git branch --merged dev | grep -v '\\\\* dev' | xargs -n 1 git branch -d\n```\n\n使用示例：\n\n```bash\ngit co dev          # checkout dev\ngit lg              # 查看美化的日志\ngit tree            # 查看分支树\ngit sync            # 同步 dev 分支\ngit cleanup         # 清理已合并的分支\n```\n\n## ❓ 常见问题\n\n### Q1: 我应该使用 merge 还是 rebase？\n\n**推荐做法**：\n\n- **功能分支同步 dev**：使用 `rebase`\n\n  ```bash\n  git checkout feat/your-feature\n  git rebase dev\n  ```\n\n- **合并到主分支**：使用 `merge`（通过 PR）\n\n  ```bash\n  # 在 GitHub PR 中使用 \"Squash and merge\" 或 \"Merge commit\"\n  ```\n\n**原因**：\n\n- `rebase` 保持历史线性，易于理解\n- `merge` 保留分支历史，便于追踪功能开发过程\n\n### Q2: 我的功能分支落后 dev 很多个版本，怎么办？\n\n```bash\n# 方法 1：Rebase（推荐，保持历史清晰）\ngit checkout feat/your-feature\ngit fetch origin\ngit rebase origin/dev\n\n# 如果有冲突，解决后继续\ngit add <resolved-files>\ngit rebase --continue\n\n# 推送（需要强制推送，因为历史已改变）\ngit push origin feat/your-feature --force-with-lease\n\n# 方法 2：Merge（简单，但历史会有分叉）\ngit checkout feat/your-feature\ngit merge origin/dev\ngit push origin feat/your-feature\n```\n\n### Q3: 我不小心在 main 分支上开发了，怎么办？\n\n```bash\n# 1. 创建新的功能分支，保存当前工作\ngit checkout -b feat/accidental-work\n\n# 2. 推送到远程\ngit push origin feat/accidental-work\n\n# 3. 重置 main 分支到远程状态\ngit checkout main\ngit reset --hard origin/main\n\n# 4. 继续在功能分支上工作\ngit checkout feat/accidental-work\n```\n\n### Q4: 如何撤销已经推送的提交？\n\n```bash\n# 方法 1：Revert（推荐，创建新的提交来撤销）\ngit revert <commit-hash>\ngit push origin your-branch\n\n# 方法 2：Reset + Force Push（危险，仅用于个人分支）\ngit reset --hard <commit-hash>\ngit push origin your-branch --force-with-lease\n```\n\n⚠️ **警告**：永远不要在共享分支（main、dev、test）上使用 `--force` 推送！\n\n### Q5: PR 审查时被要求修改，如何更新？\n\n```bash\n# 1. 在您的功能分支上继续修改\ngit checkout feat/your-feature\n\n# 2. 进行修改并提交\ngit add .\ngit commit -m \"fix: address PR review comments\"\n\n# 3. 推送（会自动更新 PR）\ngit push origin feat/your-feature\n\n# 如果想合并多个小修改到一个提交\ngit rebase -i HEAD~3  # 合并最近 3 个提交\ngit push origin feat/your-feature --force-with-lease\n```\n\n### Q6: 如何处理长期运行的功能分支？\n\n```bash\n# 1. 定期（每天）从 dev 同步更新\ngit checkout feat/long-running\ngit fetch origin\ngit rebase origin/dev\n\n# 2. 考虑拆分为多个小的 PR\n# 创建子功能分支\ngit checkout -b feat/long-running-part1\n# 提交部分功能的 PR\n\n# 3. 与团队沟通，避免冲突\n# 在 Issue 或讨论区告知正在开发的功能\n```\n\n### Q7: 团队成员同时修改了同一个文件，如何协作？\n\n**最佳实践**：\n\n1. **事前沟通**：在开始前讨论，分配不同的任务\n2. **小步快跑**：频繁提交小的更改，减少冲突\n3. **及时合并**：尽快审查和合并 PR\n4. **解决冲突**：遇到冲突时，与相关开发者沟通\n\n```bash\n# 如果确实需要同时修改\n# 1. 频繁同步\ngit checkout feat/your-work\ngit fetch origin\ngit rebase origin/dev\n\n# 2. 解决冲突时与同事讨论\n# 3. 考虑将文件拆分为更小的模块\n```\n\n### Q8: 什么时候应该创建 hotfix 分支？\n\n**创建 hotfix 的场景**：\n\n- 🚨 生产环境出现严重 Bug\n- 🔒 安全漏洞需要立即修复\n- 💥 数据完整性问题\n- 🛑 服务中断或严重性能问题\n\n**不需要 hotfix 的场景**：\n\n- 普通 Bug（使用 `fix/*` 分支走正常流程）\n- 小的 UI 问题\n- 功能改进\n- 文档更新\n\n### Q9: 如何查看某个功能是在哪个版本引入的？\n\n```bash\n# 查看某个文件的修改历史\ngit log --follow -- path/to/file\n\n# 查看包含某个功能的标签\ngit tag --contains <commit-hash>\n\n# 查看两个版本之间的差异\ngit log v1.0.0..v1.1.0 --oneline\n\n# 搜索提交信息\ngit log --grep=\"feature name\"\n```\n\n### Q10: 我的分支太乱了，想重新开始怎么办？\n\n```bash\n# 1. 备份当前工作（如果有未提交的更改）\ngit stash\n\n# 2. 创建新的干净分支\ngit checkout dev\ngit pull origin dev\ngit checkout -b feat/clean-start\n\n# 3. 挑选需要的提交（cherry-pick）\ngit cherry-pick <commit-hash1>\ngit cherry-pick <commit-hash2>\n\n# 4. 或者重新开始开发\n# 手动复制代码，重新提交\n\n# 5. 删除旧分支\ngit branch -D feat/old-messy-branch\ngit push origin --delete feat/old-messy-branch\n```\n\n## 📚 参考资源\n\n### Git 学习资源\n\n- [Pro Git 中文版](https://git-scm.com/book/zh/v2)\n- [Git 简明教程](https://rogerdudler.github.io/git-guide/index.zh.html)\n- [Git 分支管理策略](https://nvie.com/posts/a-successful-git-branching-model/)\n- [GitHub Flow](https://docs.github.com/cn/get-started/quickstart/github-flow)\n\n### Git 工具推荐\n\n- **命令行工具**：\n  - [tig](https://jonas.github.io/tig/) - 文本界面的 Git 仓库浏览器\n  - [lazygit](https://github.com/jesseduffield/lazygit) - 终端 UI 工具\n\n- **图形界面**：\n  - [GitKraken](https://www.gitkraken.com/) - 跨平台 Git 客户端\n  - [Sourcetree](https://www.sourcetreeapp.com/) - 免费的 Git 客户端\n  - [GitHub Desktop](https://desktop.github.com/) - GitHub 官方客户端\n\n- **VS Code 插件**：\n  - GitLens - 增强 Git 功能\n  - Git Graph - 可视化分支图\n  - Git History - 查看文件历史\n\n## 🎓 Git 命令速查表\n\n### 基础操作\n\n```bash\n# 克隆仓库\ngit clone <repository-url>\n\n# 查看状态\ngit status\n\n# 添加文件到暂存区\ngit add <file>\ngit add .\n\n# 提交更改\ngit commit -m \"message\"\n\n# 推送到远程\ngit push origin <branch>\n\n# 拉取远程更改\ngit pull origin <branch>\n```\n\n### 分支操作\n\n```bash\n# 查看分支\ngit branch\ngit branch -a  # 包括远程分支\n\n# 创建分支\ngit branch <branch-name>\n\n# 切换分支\ngit checkout <branch-name>\n\n# 创建并切换到新分支\ngit checkout -b <branch-name>\n\n# 删除本地分支\ngit branch -d <branch-name>\ngit branch -D <branch-name>  # 强制删除\n\n# 删除远程分支\ngit push origin --delete <branch-name>\n```\n\n### 合并与变基\n\n```bash\n# 合并分支\ngit merge <branch-name>\n\n# Rebase\ngit rebase <branch-name>\n\n# 交互式 rebase\ngit rebase -i HEAD~3\n\n# 解决冲突后继续\ngit rebase --continue\n\n# 中止 rebase\ngit rebase --abort\n```\n\n### 远程操作\n\n```bash\n# 查看远程仓库\ngit remote -v\n\n# 添加远程仓库\ngit remote add origin <url>\n\n# 获取远程更新\ngit fetch origin\n\n# 同步远程已删除的分支\ngit remote prune origin\n```\n\n### 撤销与重置\n\n```bash\n# 撤销工作区的修改\ngit checkout -- <file>\n\n# 取消暂存\ngit reset HEAD <file>\n\n# 撤销提交（保留更改）\ngit reset --soft HEAD~1\n\n# 撤销提交（丢弃更改）\ngit reset --hard HEAD~1\n\n# Revert 提交（创建新提交）\ngit revert <commit-hash>\n```\n\n### 查看历史\n\n```bash\n# 查看提交历史\ngit log\ngit log --oneline\ngit log --graph --all\n\n# 查看某个文件的历史\ngit log -- <file>\n\n# 查看某次提交的详情\ngit show <commit-hash>\n\n# 查看两个提交的差异\ngit diff <commit1> <commit2>\n```\n\n### 标签操作\n\n```bash\n# 创建标签\ngit tag v1.0.0\ngit tag -a v1.0.0 -m \"Release version 1.0.0\"\n\n# 推送标签\ngit push origin v1.0.0\ngit push origin --tags  # 推送所有标签\n\n# 删除标签\ngit tag -d v1.0.0\ngit push origin --delete v1.0.0\n```\n\n## 🚦 工作流程图\n\n### 完整的功能开发流程\n\n```\n开发者 Fork 仓库\n    ↓\n克隆到本地\n    ↓\n创建 feat/* 分支 ← dev 分支\n    ↓\n本地开发和测试\n    ↓\n提交代码\n    ↓\n推送到 GitHub\n    ↓\n创建 Pull Request → dev 分支\n    ↓\n代码审查\n    ↓\nCI/CD 自动测试\n    ↓\n审查通过\n    ↓\n合并到 dev 分支\n    ↓\n集成测试\n    ↓\n合并到 test 分支\n    ↓\n完整测试\n    ↓\n合并到 main 分支\n    ↓\n打版本标签\n    ↓\n发布\n```\n\n---\n\n## 📞 获取帮助\n\n如果您对 Git Flow 有任何疑问：\n\n1. 📖 查看本文档的常见问题部分\n2. 💬 在 GitHub Discussions 中提问\n3. 🐛 如果发现文档错误，创建 Issue\n4. 👥 咨询项目维护者或经验丰富的贡献者\n\n---\n\n**记住**：好的 Git 工作流程不仅仅是技术问题，更是团队协作的基础。遵循规范，保持沟通，我们一起构建更好的 FreeTodo！🚀\n"
  },
  {
    "path": ".github/INSTALL.md",
    "content": "# One-Click Install (Full Options)\n\nThis document contains the full one-click install options referenced in the main README.\n\n## Requirements\n- Python 3.12+\n- Node.js 20+\n- Git\n- Rust (only required for Tauri builds)\n\n## Basic usage\n\nmacOS/Linux:\n\n```bash\ncurl -fsSL https://raw.githubusercontent.com/FreeU-group/FreeTodo/main/scripts/install.sh | bash\n```\n\nWindows (PowerShell):\n\n```powershell\niwr -useb https://raw.githubusercontent.com/FreeU-group/FreeTodo/main/scripts/install.ps1 | iex\n```\n\n## Defaults\n\n`mode=tauri`, `variant=web`, `frontend=build`, `backend=script`.\n\n## Environment variables\n\n- `LIFETRACE_DIR`: install directory (defaults to repo name)\n- `LIFETRACE_REPO`: repo URL (defaults to `https://github.com/FreeU-group/FreeTodo.git`)\n- `LIFETRACE_REF`: branch or tag (defaults to `main`, use `dev` for unstable builds)\n- `LIFETRACE_MODE`: `web`, `tauri`, `electron`, or `island`\n- `LIFETRACE_VARIANT`: `web` or `island`\n- `LIFETRACE_FRONTEND`: `build` or `dev` (web defaults to `dev`)\n- `LIFETRACE_BACKEND`: `script` or `pyinstaller`\n- `LIFETRACE_RUN`: `1` (default) to run after install, `0` to only install\n\n## Examples\n\n```bash\n# Web dev\ncurl -fsSL https://raw.githubusercontent.com/FreeU-group/FreeTodo/main/scripts/install.sh | bash -s -- --mode web --frontend dev\n\n# Tauri dev (starts backend + frontend dev server, then tauri dev)\ncurl -fsSL https://raw.githubusercontent.com/FreeU-group/FreeTodo/main/scripts/install.sh | bash -s -- --mode tauri --frontend dev\n\n# Electron island dev\ncurl -fsSL https://raw.githubusercontent.com/FreeU-group/FreeTodo/main/scripts/install.sh | bash -s -- --mode electron --variant island --frontend dev\n\n# Tauri build with PyInstaller backend\ncurl -fsSL https://raw.githubusercontent.com/FreeU-group/FreeTodo/main/scripts/install.sh | bash -s -- --mode tauri --frontend build --backend pyinstaller\n\n# Switch ref\ncurl -fsSL https://raw.githubusercontent.com/FreeU-group/FreeTodo/main/scripts/install.sh | bash -s -- --ref dev\n```\n\n```powershell\n# Web dev\n$env:LIFETRACE_MODE=\"web\"; $env:LIFETRACE_FRONTEND=\"dev\"; iwr -useb https://raw.githubusercontent.com/FreeU-group/FreeTodo/main/scripts/install.ps1 | iex\n\n# Tauri dev (starts backend + frontend dev server, then tauri dev)\n$env:LIFETRACE_MODE=\"tauri\"; $env:LIFETRACE_FRONTEND=\"dev\"; iwr -useb https://raw.githubusercontent.com/FreeU-group/FreeTodo/main/scripts/install.ps1 | iex\n\n# Electron island dev\n$env:LIFETRACE_MODE=\"electron\"; $env:LIFETRACE_VARIANT=\"island\"; $env:LIFETRACE_FRONTEND=\"dev\"; iwr -useb https://raw.githubusercontent.com/FreeU-group/FreeTodo/main/scripts/install.ps1 | iex\n\n# Tauri build with PyInstaller backend\n$env:LIFETRACE_MODE=\"tauri\"; $env:LIFETRACE_FRONTEND=\"build\"; $env:LIFETRACE_BACKEND=\"pyinstaller\"; iwr -useb https://raw.githubusercontent.com/FreeU-group/FreeTodo/main/scripts/install.ps1 | iex\n\n# Switch ref\n$env:LIFETRACE_REF=\"dev\"; iwr -useb https://raw.githubusercontent.com/FreeU-group/FreeTodo/main/scripts/install.ps1 | iex\n```\n"
  },
  {
    "path": ".github/INSTALL_CN.md",
    "content": "# 一键安装（完整选项）\n\n本文件包含主 README 中提到的一键安装完整说明。\n\n## 环境要求\n- Python 3.12+\n- Node.js 20+\n- Git\n- Rust（仅 Tauri 构建需要）\n\n## 基础用法\n\nmacOS/Linux：\n\n```bash\ncurl -fsSL https://raw.githubusercontent.com/FreeU-group/FreeTodo/main/scripts/install.sh | bash\n```\n\nWindows（PowerShell）：\n\n```powershell\niwr -useb https://raw.githubusercontent.com/FreeU-group/FreeTodo/main/scripts/install.ps1 | iex\n```\n\n## 默认值\n\n`mode=tauri`、`variant=web`、`frontend=build`、`backend=script`。\n\n## 可选环境变量\n\n- `LIFETRACE_DIR`：安装目录（默认使用仓库名）\n- `LIFETRACE_REPO`：仓库地址（默认 `https://github.com/FreeU-group/FreeTodo.git`）\n- `LIFETRACE_REF`：分支或标签（默认 `main`，不稳定开发版使用 `dev`）\n- `LIFETRACE_MODE`：`web`、`tauri`、`electron` 或 `island`\n- `LIFETRACE_VARIANT`：`web` 或 `island`\n- `LIFETRACE_FRONTEND`：`build` 或 `dev`（`web` 默认 `dev`）\n- `LIFETRACE_BACKEND`：`script` 或 `pyinstaller`\n- `LIFETRACE_RUN`：`1`（默认）安装后自动运行，`0` 仅安装\n\n## 示例\n\n```bash\n# Web 开发\ncurl -fsSL https://raw.githubusercontent.com/FreeU-group/FreeTodo/main/scripts/install.sh | bash -s -- --mode web --frontend dev\n\n# Tauri 开发（启动后端 + 前端 dev，再运行 tauri dev）\ncurl -fsSL https://raw.githubusercontent.com/FreeU-group/FreeTodo/main/scripts/install.sh | bash -s -- --mode tauri --frontend dev\n\n# Electron Island 开发\ncurl -fsSL https://raw.githubusercontent.com/FreeU-group/FreeTodo/main/scripts/install.sh | bash -s -- --mode electron --variant island --frontend dev\n\n# Tauri 构建（后端 PyInstaller）\ncurl -fsSL https://raw.githubusercontent.com/FreeU-group/FreeTodo/main/scripts/install.sh | bash -s -- --mode tauri --frontend build --backend pyinstaller\n\n# 切换分支\ncurl -fsSL https://raw.githubusercontent.com/FreeU-group/FreeTodo/main/scripts/install.sh | bash -s -- --ref dev\n```\n\n```powershell\n# Web 开发\n$env:LIFETRACE_MODE=\"web\"; $env:LIFETRACE_FRONTEND=\"dev\"; iwr -useb https://raw.githubusercontent.com/FreeU-group/FreeTodo/main/scripts/install.ps1 | iex\n\n# Tauri 开发（启动后端 + 前端 dev，再运行 tauri dev）\n$env:LIFETRACE_MODE=\"tauri\"; $env:LIFETRACE_FRONTEND=\"dev\"; iwr -useb https://raw.githubusercontent.com/FreeU-group/FreeTodo/main/scripts/install.ps1 | iex\n\n# Electron Island 开发\n$env:LIFETRACE_MODE=\"electron\"; $env:LIFETRACE_VARIANT=\"island\"; $env:LIFETRACE_FRONTEND=\"dev\"; iwr -useb https://raw.githubusercontent.com/FreeU-group/FreeTodo/main/scripts/install.ps1 | iex\n\n# Tauri 构建（后端 PyInstaller）\n$env:LIFETRACE_MODE=\"tauri\"; $env:LIFETRACE_FRONTEND=\"build\"; $env:LIFETRACE_BACKEND=\"pyinstaller\"; iwr -useb https://raw.githubusercontent.com/FreeU-group/FreeTodo/main/scripts/install.ps1 | iex\n\n# 切换分支\n$env:LIFETRACE_REF=\"dev\"; iwr -useb https://raw.githubusercontent.com/FreeU-group/FreeTodo/main/scripts/install.ps1 | iex\n```\n"
  },
  {
    "path": ".github/PRE_COMMIT_GUIDE.md",
    "content": "# Pre-commit Usage Guide\n\n## Overview\n\nThis project uses [pre-commit](https://pre-commit.com/) to automatically run code checks and formatting before Git commits, ensuring code quality and style consistency.\n\nPre-commit will automatically check and fix the following issues on each `git commit`:\n- YAML file syntax checking\n- TOML file syntax checking\n- JSON file syntax checking\n- End-of-file newline fixing\n- Trailing whitespace removal\n- Python code linting (ruff)\n- Python code formatting (ruff-format)\n- Frontend code checking (Biome)\n- Frontend TypeScript type checking\n- **Frontend code line count check** (max 500 lines of effective code per file)\n- **Backend code line count check** (max 500 lines of effective code per file)\n\n---\n\n## Installation & Configuration\n\n### 1. Install pre-commit Dependencies\n\n#### Using uv (Recommended)\n\n```bash\n# Sync pre-commit dependencies from pyproject.toml\nuv sync --group dev\n```\n\n### 2. Configure Git Hooks (Repo-Local)\n\nThis repo uses a shared `.githooks/` directory (repo-local) instead of `pre-commit install`.\nRun the setup script once per clone/worktree to set `core.hooksPath`:\n\n```bash\n# macOS/Linux\nbash scripts/setup_hooks_here.sh\n\n# Windows (PowerShell)\npowershell -ExecutionPolicy Bypass -File scripts/setup_hooks_here.ps1\n```\n\n**Note**: After `core.hooksPath` is set, `pre-commit install` will refuse to run. This is expected.\n\n### 3. (Optional) Warm Up Hooks\n\n```bash\npre-commit run --all-files\n```\n---\n\n## Repo Hooks (Post-checkout)\n\nThis repo also ships a `post-checkout` hook under `.githooks/` to keep worktree\ndependencies linked. It runs:\n\n- `scripts/link_worktree_deps_here.sh` (preferred)\n- falls back to `scripts/link_worktree_deps_here.ps1` if needed\n\nThe hook is safe to run repeatedly and will skip existing links unless `--force` is used.\n\n## Usage\n\n### Automatic Trigger (Recommended)\n\nPre-commit will automatically run on each commit:\n\n```bash\ngit add .\ngit commit -m \"your commit message\"\n```\n\nIf checks pass, the commit succeeds; if checks fail, the commit is blocked and you need to fix the issues and commit again.\n\n> **Note**: The repo hook prefers `pre-commit` if available, and falls back to `uv run pre-commit` when `uv` is installed.\n\n**Example Output**:\n```\ncheck-yaml........................................................Passed\ncheck-toml........................................................Passed\ncheck-json........................................................Passed\nend-of-file-fixer................................................Passed\ntrailing-whitespace..............................................Passed\nruff.............................................................Passed\nruff-format......................................................Passed\nbiome-check......................................................Passed\n[main abc123] your commit message\n 1 file changed, 3 insertions(+)\n```\n\n### Manual Execution\n\n#### Run All Checks\n\n```bash\npre-commit run --all-files\n```\n\n#### Run Specific Checks\n\n```bash\n# Check specific files only\npre-commit run --files path/to/file.py\n\n# Run ruff check only\npre-commit run ruff --all-files\n\n# Run ruff format only\npre-commit run ruff-format --all-files\n\n# Run Biome check only\npre-commit run biome-check --all-files\n\n# Run frontend code line count check only\npre-commit run check-frontend-code-lines --all-files\n\n# Run backend code line count check only\npre-commit run check-backend-code-lines --all-files\n```\n\n#### View Detailed Output\n\n```bash\npre-commit run --all-files -v\n```\n\n---\n\n## Common Scenarios\n\n### Scenario 1: Code Line Count Exceeds Limit\n\nIf you see an error like this when committing:\n\n```\nCheck frontend TS/TSX code lines (max 500)............................Failed\n❌ The following files exceed 500 lines:\n  apps/chat/components/ChatPanel.tsx -> 623 lines\n```\n\n**Solution**:\n\n1. Split the oversized file into smaller modules/components\n2. Extract common logic into separate utility files\n3. Consider if there's duplicate code that can be abstracted\n\n**Note**: Line count statistics **exclude** empty lines and comment lines, counting only effective code lines.\n\n### Scenario 2: Check Failed on Commit\n\nIf you see an error like this when committing:\n\n```\nTrailing whitespace..............................................Failed\n- hook id: trailing-whitespace\n- args: [--markdown-linebreak-ext=md]\n\nSome files have trailing whitespace, please remove them.\n```\n\n**Solution**:\n\n1. Fix and re-add files:\n   ```bash\n   git add path/to/file.py\n   ```\n\n2. Commit again:\n   ```bash\n   git commit -m \"your message\"\n   ```\n\n### Scenario 3: Skip Checks (Emergency)\n\n**Not recommended**, use only in emergencies:\n\n```bash\ngit commit -m \"emergency fix\" --no-verify\n```\n---\n\n## Configuration\n\nThe `.pre-commit-config.yaml` file in the project root contains all check configurations:\n\n```yaml\nrepos:\n  - repo: https://github.com/pre-commit/pre-commit-hooks\n    rev: v6.0.0\n    hooks:\n      - id: check-yaml\n        exclude: pnpm-lock.yaml\n      - id: check-toml\n      - id: check-json\n      - id: end-of-file-fixer\n      - id: trailing-whitespace\n        args: [--markdown-linebreak-ext=md]\n  - repo: https://github.com/astral-sh/ruff-pre-commit\n    rev: v0.12.10\n    hooks:\n      # Run the linter.\n      - id: ruff\n        language_version: python3.12\n        files: ^lifetrace/\n        types_or: [ python, pyi ]\n        args: [ --fix ]\n      # Run the formatter.\n      - id: ruff-format\n        language_version: python3.12\n        files: ^lifetrace/\n        types_or: [ python, pyi ]\n  # Biome for frontend (JavaScript/TypeScript)\n  - repo: https://github.com/biomejs/pre-commit\n    rev: \"v0.6.1\"\n    hooks:\n      - id: biome-check\n        additional_dependencies: [\"@biomejs/biome@2.3.13\"]\n        files: ^(free-todo-frontend/)\n\n  # Local hooks\n  - repo: local\n    hooks:\n      # TypeScript type checking\n      - id: tsc-free-todo-frontend\n        name: TypeScript type check (free-todo-frontend)\n        entry: bash -c 'cd free-todo-frontend && pnpm run type-check'\n        language: system\n        files: ^free-todo-frontend/.*\\.(ts|tsx)$\n        pass_filenames: false\n\n      # Frontend code line count check (max 500 lines of effective code)\n      - id: check-frontend-code-lines\n        name: Check frontend TS/TSX code lines (max 500)\n        entry: node free-todo-frontend/scripts/check_code_lines.js --include apps,components,electron,lib --exclude lib/generated\n        language: system\n        files: ^free-todo-frontend/.*\\.(ts|tsx)$\n        pass_filenames: true\n\n      # Backend code line count check (max 500 lines of effective code)\n      - id: check-backend-code-lines\n        name: Check backend Python code lines (max 500)\n        entry: uv run python lifetrace/scripts/check_code_lines.py --include lifetrace --exclude lifetrace/__pycache__,lifetrace/dist,lifetrace/migrations/versions\n        language: system\n        files: ^lifetrace/.*\\.py$\n        pass_filenames: true\n```\n\n**Key Configuration**:\n- `files: ^lifetrace/` - Only check Python files in the `lifetrace/` directory\n- `files: ^free-todo-frontend/` - Only check frontend files in the `free-todo-frontend/` directory\n- `language_version: python3.12` - Specify Python version\n- `args: [ --fix ]` - Automatically fix fixable issues\n- `additional_dependencies` - Specify dependency version for Biome\n- `pass_filenames: true/false` - Whether to pass the list of staged files to the script\n  - `true`: Script only checks passed files (code line count check uses this mode, only checking staged files)\n  - `false`: Script determines its own check scope (TypeScript type check uses this mode, needs to check the entire project)\n\n---\n\n## Troubleshooting\n\n### Issue: pre-commit: command not found\n\n**Cause**: Virtual environment not activated or pre-commit not installed\n\n**Solution**:\n```bash\n# Activate virtual environment\nsource .venv/bin/activate\n\n# Using uv run\nuv run pre-commit --version\n```\n\n### Issue: Checks not triggered on commit\n\n**Cause**: Hooks not configured or `.githooks` missing\n\n**Solution**:\n```bash\n# Ensure hooksPath is set\ngit config --get core.hooksPath\n\n# Re-run repo hook setup (in repo root)\nbash scripts/setup_hooks_here.sh\n# or\npowershell -ExecutionPolicy Bypass -File scripts/setup_hooks_here.ps1\n```\n\n### Issue: pre-commit install fails with core.hooksPath\n\n**Cause**: This repo uses `.githooks/` via `core.hooksPath`, so `pre-commit install` will refuse.\n\n**Solution**:\n```bash\n# Do not run pre-commit install. Use:\npre-commit run --all-files\n```\n\n### Issue: Checks are too slow\n\n**Optimization Methods**:\n\n1. Only check changed files:\n   ```bash\n   pre-commit run\n   ```\n\n2. Use parallel execution:\n   ```bash\n   pre-commit run --all-files --jobs 4\n   ```\n\n---\n\n## Best Practices\n\n1. ✅ **Run checks before each commit**\n   ```bash\n   pre-commit run --all-files\n   ```\n\n2. ✅ **Update check tools regularly**\n   ```bash\n   pre-commit autoupdate\n   ```\n\n3. ✅ **Ensure all team members have hooks installed when collaborating**\n   ```bash\n   git clone <repo>\n   cd <repo>\n   uv sync --group dev\n   bash scripts/setup_hooks_here.sh\n   # or: powershell -ExecutionPolicy Bypass -File scripts/setup_hooks_here.ps1\n   pre-commit run --all-files\n   ```\n\n4. ✅ **Don't use `--no-verify` unless it's an emergency**\n\n5. ✅ **Maintain consistent Python code style**\n\n---\n\n## Code Line Count Check Rules\n\n### Rule Description\n\nTo maintain code readability and maintainability, the project limits the effective code lines per file:\n\n- **Frontend (TS/TSX)**: Max 500 lines of effective code per file\n- **Backend (Python)**: Max 500 lines of effective code per file\n\n### Counting Rules\n\nLine count statistics **exclude** the following:\n- Empty lines (lines that are empty strings after `trim()`/`strip()`)\n- Comment lines:\n  - Frontend: Lines starting with `//`, `/*`, `*`, `*/`\n  - Backend: Lines starting with `#`\n\n### Check Scope\n\n**Frontend Check Directories** (adjustable via parameters):\n- Include: `apps/`, `components/`, `electron/`, `lib/`\n- Exclude: `lib/generated/` (Orval auto-generated API code)\n\n**Backend Check Directories** (adjustable via parameters):\n- Include: `lifetrace/`\n- Exclude: `lifetrace/__pycache__/`, `lifetrace/dist/`, `lifetrace/migrations/versions/`\n\n### Manual Check Execution\n\nThe script supports two execution modes:\n\n**Mode 1: Scan Entire Directory (Standalone Execution)**\n\n```bash\n# Check all frontend TS/TSX files\nnode free-todo-frontend/scripts/check_code_lines.js\n\n# Check all backend Python files\nuv run python lifetrace/scripts/check_code_lines.py\n\n# Use custom parameters\nnode free-todo-frontend/scripts/check_code_lines.js --include apps,components,electron --exclude lib/generated --max 600\nuv run python lifetrace/scripts/check_code_lines.py --include lifetrace --exclude lifetrace/__pycache__ --max 600\n```\n\n**Mode 2: Check Specific Files (Pre-commit Mode)**\n\n```bash\n# Check only specified files\nnode free-todo-frontend/scripts/check_code_lines.js apps/chat/ChatPanel.tsx apps/todo/TodoList.tsx\nuv run python lifetrace/scripts/check_code_lines.py lifetrace/routers/chat.py lifetrace/services/todo.py\n```\n\n> **Note**: During `git commit`, pre-commit automatically passes staged files, checking only these files instead of the entire directory.\n\n### Solutions for Exceeding Limits\n\nWhen a file's code line count exceeds the limit, consider:\n\n1. **Split Files**: Split large files into multiple smaller files by functional modules\n2. **Extract Common Logic**: Abstract duplicate code into independent utility functions/components\n3. **Use Composition Pattern**: Split complex components into multiple sub-components\n4. **Evaluate Comment Volume**: Add appropriate comments (not counted in lines) to explain complex logic\n\n---\n\n## Resources\n\n- [Pre-commit Official Documentation](https://pre-commit.com/)\n- [Ruff Documentation](https://docs.astral.sh/ruff/)\n- [Python Style Guide (PEP 8)](https://peps.python.org/pep-0008/)\n\n---\n\n## FAQ\n\n**Q: Will Pre-commit modify my code?**\nA: Yes! Ruff will automatically fix fixable issues such as unnecessary imports, unused variables, etc. Review your changes and commit again.\n\n**Q: Can I use different pre-commit configurations on different branches?**\nA: Yes! `.pre-commit-config.yaml` can be adjusted per branch.\n\n**Q: What programming languages does Pre-commit support?**\nA: This project configuration supports Python (via Ruff) and JavaScript/TypeScript (via Biome). The Pre-commit framework itself supports multiple languages, including Go, Rust, etc.\n\n**Q: How do I add custom checks?**\nA: Modify the `.pre-commit-config.yaml` file and add new repositories or hooks.\n\n**Q: Can the code line count check threshold be adjusted?**\nA: Yes! Modify the `entry` parameter of the corresponding hook in `.pre-commit-config.yaml` and add `--max <number>`. For example: `--max 600` adjusts the limit to 600 lines.\n\n**Q: Why are certain directories not checked?**\nA: To avoid checking auto-generated code (such as Orval-generated API code), some directories are excluded from the check scope. You can adjust the exclusion list via the `--exclude` parameter.\n\n---\n\n## Contact\n\nIf you encounter issues or need help, please:\n1. Check the troubleshooting section of this guide\n2. Run `pre-commit run --all-files -v` to view detailed errors\n3. Check project Issues or submit a new Issue\n\n---\n\n**Happy Coding! 🎉**\n"
  },
  {
    "path": ".github/PRE_COMMIT_GUIDE_CN.md",
    "content": "# Pre-commit 使用指南\n\n## 概述\n\n本项目使用 [pre-commit](https://pre-commit.com/) 工具在 Git 提交前自动运行代码检查和格式化，确保代码质量和风格一致性。\n\nPre-commit 会在每次 `git commit` 时自动检查并修复以下问题：\n- YAML 文件语法检查\n- TOML 文件语法检查\n- JSON 文件语法检查\n- 文件末尾换行符修复\n- 行尾空格删除\n- Python 代码规范检查（ruff）\n- Python 代码格式化（ruff-format）\n- 前端代码检查（Biome）\n- 前端 TypeScript 类型检查\n- **前端代码行数检查**（单文件有效代码行数不超过 500 行）\n- **后端代码行数检查**（单文件有效代码行数不超过 500 行）\n\n---\n\n## 安装与配置\n\n### 1. 安装 pre-commit 依赖\n\n#### 使用 uv（推荐）\n\n```bash\n# 同步pyproject.toml中的pre-commit依赖\nuv sync --group dev\n```\n\n### 2. 配置 Git Hooks（仓库内）\n\n本仓库使用共享的 `.githooks/` 目录（仓库内），不使用 `pre-commit install`。\n每个 clone/worktree 执行一次即可：\n\n```bash\n# macOS/Linux\nbash scripts/setup_hooks_here.sh\n\n# Windows（PowerShell）\npowershell -ExecutionPolicy Bypass -File scripts/setup_hooks_here.ps1\n```\n\n**注意**：设置了 `core.hooksPath` 后，`pre-commit install` 会拒绝执行，这是预期行为。\n\n### 3.（可选）预热检查\n\n```bash\npre-commit run --all-files\n```\n---\n\n## 仓库 Hook（post-checkout）\n\n本仓库还在 `.githooks/` 中提供了 `post-checkout` hook，用于自动连接 worktree 依赖。它会执行：\n\n- `scripts/link_worktree_deps_here.sh`（优先）\n- 若失败则回退到 `scripts/link_worktree_deps_here.ps1`\n\n该 hook 可重复执行，已存在的链接会被跳过（除非使用 `--force`）。\n\n## 使用方法\n\n### 自动触发（推荐）\n\n每次提交代码时，pre-commit 会自动运行：\n\n```bash\ngit add .\ngit commit -m \"your commit message\"\n```\n\n如果检查通过，提交成功；如果检查失败，提交会被阻止，修复后需重新提交。\n\n> **注意**：仓库 hook 优先使用 `pre-commit`，若未找到则会回退到 `uv run pre-commit`（需要已安装 uv）。\n\n**示例输出**：\n```\ncheck-yaml........................................................Passed\ncheck-toml........................................................Passed\ncheck-json........................................................Passed\nend-of-file-fixer................................................Passed\ntrailing-whitespace..............................................Passed\nruff.............................................................Passed\nruff-format......................................................Passed\nbiome-check......................................................Passed\n[main abc123] your commit message\n 1 file changed, 3 insertions(+)\n```\n\n### 手动运行\n\n#### 运行所有检查\n\n```bash\npre-commit run --all-files\n```\n\n#### 运行特定检查\n\n```bash\n# 仅检查特定文件\npre-commit run --files path/to/file.py\n\n# 仅运行 ruff 检查\npre-commit run ruff --all-files\n\n# 仅运行 ruff 格式化\npre-commit run ruff-format --all-files\n\n# 仅运行 Biome 检查\npre-commit run biome-check --all-files\n\n# 仅运行前端代码行数检查\npre-commit run check-frontend-code-lines --all-files\n\n# 仅运行后端代码行数检查\npre-commit run check-backend-code-lines --all-files\n```\n\n#### 查看详细输出\n\n```bash\npre-commit run --all-files -v\n```\n\n---\n\n## 常见场景\n\n### 场景1：代码行数超过限制\n\n如果提交时看到类似以下错误：\n\n```\nCheck frontend TS/TSX code lines (max 500)............................Failed\n❌ 以下文件代码行数超过 500 行：\n  apps/chat/components/ChatPanel.tsx -> 623 行\n```\n\n**解决方法**：\n\n1. 将超长文件拆分为多个更小的模块/组件\n2. 提取公共逻辑到独立的工具文件\n3. 考虑是否有重复代码可以抽象\n\n**注意**：代码行数统计**不包含**空行和注释行，只统计有效代码行数。\n\n### 场景2：提交时检查失败\n\n如果提交时看到类似以下错误：\n\n```\nTrailing whitespace..............................................Failed\n- hook id: trailing-whitespace\n- args: [--markdown-linebreak-ext=md]\n\nSome files have trailing whitespace, please remove them.\n```\n\n**解决方法**：\n\n1. 修复后重新添加文件：\n   ```bash\n   git add path/to/file.py\n   ```\n\n2. 重新提交：\n   ```bash\n   git commit -m \"your message\"\n   ```\n\n### 场景3：跳过检查（紧急情况）\n\n**不推荐**，仅在紧急情况下使用：\n\n```bash\ngit commit -m \"emergency fix\" --no-verify\n```\n---\n\n## 配置说明\n\n项目根目录的 `.pre-commit-config.yaml` 包含所有检查配置：\n\n```yaml\nrepos:\n  - repo: https://github.com/pre-commit/pre-commit-hooks\n    rev: v6.0.0\n    hooks:\n      - id: check-yaml\n        exclude: pnpm-lock.yaml\n      - id: check-toml\n      - id: check-json\n      - id: end-of-file-fixer\n      - id: trailing-whitespace\n        args: [--markdown-linebreak-ext=md]\n  - repo: https://github.com/astral-sh/ruff-pre-commit\n    rev: v0.12.10\n    hooks:\n      # Run the linter.\n      - id: ruff\n        language_version: python3.12\n        files: ^lifetrace/\n        types_or: [ python, pyi ]\n        args: [ --fix ]\n      # Run the formatter.\n      - id: ruff-format\n        language_version: python3.12\n        files: ^lifetrace/\n        types_or: [ python, pyi ]\n  # Biome for frontend (JavaScript/TypeScript)\n  - repo: https://github.com/biomejs/pre-commit\n    rev: \"v0.6.1\"\n    hooks:\n      - id: biome-check\n        additional_dependencies: [\"@biomejs/biome@2.3.13\"]\n        files: ^(free-todo-frontend/)\n\n  # Local hooks\n  - repo: local\n    hooks:\n      # TypeScript 类型检查\n      - id: tsc-free-todo-frontend\n        name: TypeScript type check (free-todo-frontend)\n        entry: bash -c 'cd free-todo-frontend && pnpm run type-check'\n        language: system\n        files: ^free-todo-frontend/.*\\.(ts|tsx)$\n        pass_filenames: false\n\n      # 前端代码行数检查（有效代码行数上限 500 行）\n      - id: check-frontend-code-lines\n        name: Check frontend TS/TSX code lines (max 500)\n        entry: node free-todo-frontend/scripts/check_code_lines.js --include apps,components,electron,lib --exclude lib/generated\n        language: system\n        files: ^free-todo-frontend/.*\\.(ts|tsx)$\n        pass_filenames: true\n\n      # 后端代码行数检查（有效代码行数上限 500 行）\n      - id: check-backend-code-lines\n        name: Check backend Python code lines (max 500)\n        entry: uv run python lifetrace/scripts/check_code_lines.py --include lifetrace --exclude lifetrace/__pycache__,lifetrace/dist,lifetrace/migrations/versions\n        language: system\n        files: ^lifetrace/.*\\.py$\n        pass_filenames: true\n```\n\n**主要配置**：\n- `files: ^lifetrace/` - 只检查 `lifetrace/` 目录下的 Python 文件\n- `files: ^free-todo-frontend/` - 只检查 `free-todo-frontend/` 目录下的前端文件\n- `language_version: python3.12` - 指定 Python 版本\n- `args: [ --fix ]` - 自动修复可修复的问题\n- `additional_dependencies` - 为 Biome 指定依赖版本\n- `pass_filenames: true/false` - 是否将暂存的文件列表传给脚本\n  - `true`：脚本只检查传入的文件（代码行数检查使用此模式，只检查暂存的文件）\n  - `false`：脚本自行决定检查范围（TypeScript 类型检查使用此模式，需要检查整个项目）\n\n---\n\n## 故障排除\n\n### 问题：pre-commit: command not found\n\n**原因**：虚拟环境未激活或 pre-commit 未安装\n\n**解决**：\n```bash\n# 激活虚拟环境\nsource .venv/bin/activate\n\n# uv run\nuv run pre-commit --version\n```\n\n### 问题：提交时没有触发检查\n\n**原因**：hooks 未配置或 `.githooks` 缺失\n\n**解决**：\n```bash\n# 确认 hooksPath\ngit config --get core.hooksPath\n\n# 重新执行仓库 hook 配置（在仓库根目录）\nbash scripts/setup_hooks_here.sh\n# 或\npowershell -ExecutionPolicy Bypass -File scripts/setup_hooks_here.ps1\n```\n\n### 问题：pre-commit install 报错 core.hooksPath\n\n**原因**：本仓库使用 `.githooks/`，因此 `pre-commit install` 会拒绝执行。\n\n**解决**：\n```bash\n# 不要运行 pre-commit install，直接使用：\npre-commit run --all-files\n```\n\n### 问题：检查速度太慢\n\n**优化方法**：\n\n1. 仅检查变更的文件：\n   ```bash\n   pre-commit run\n   ```\n\n2. 使用并行运行：\n   ```bash\n   pre-commit run --all-files --jobs 4\n   ```\n\n---\n\n## 最佳实践\n\n1. ✅ **每次提交前运行检查**\n   ```bash\n   pre-commit run --all-files\n   ```\n\n2. ✅ **及时更新检查工具**\n   ```bash\n   pre-commit autoupdate\n   ```\n\n3. ✅ **团队协作时确保每个人都安装了 hooks**\n   ```bash\n   git clone <repo>\n   cd <repo>\n   uv sync --group dev\n   bash scripts/setup_hooks_here.sh\n   # 或：powershell -ExecutionPolicy Bypass -File scripts/setup_hooks_here.ps1\n   pre-commit run --all-files\n   ```\n\n4. ✅ **不要使用 `--no-verify` 除非紧急情况**\n\n5. ✅ **保持 Python 代码风格一致**\n\n---\n\n## 代码行数检查规则\n\n### 规则说明\n\n为了保持代码的可读性和可维护性，项目对单文件的有效代码行数进行限制：\n\n- **前端（TS/TSX）**：单文件有效代码行数不超过 500 行\n- **后端（Python）**：单文件有效代码行数不超过 500 行\n\n### 计数规则\n\n代码行数统计**不包含**以下内容：\n- 空行（`trim()`/`strip()` 后为空字符串的行）\n- 注释行：\n  - 前端：以 `//`、`/*`、`*`、`*/` 开头的行\n  - 后端：以 `#` 开头的行\n\n### 检查范围\n\n**前端检查目录**（可通过参数调整）：\n- 包含：`apps/`、`components/`、`electron/`、`lib/`\n- 排除：`lib/generated/`（Orval 自动生成的 API 代码）\n\n**后端检查目录**（可通过参数调整）：\n- 包含：`lifetrace/`\n- 排除：`lifetrace/__pycache__/`、`lifetrace/dist/`、`lifetrace/migrations/versions/`\n\n### 手动运行检查\n\n脚本支持两种运行模式：\n\n**模式 1：扫描整个目录（单独运行）**\n\n```bash\n# 检查前端所有 TS/TSX 文件\nnode free-todo-frontend/scripts/check_code_lines.js\n\n# 检查后端所有 Python 文件\nuv run python lifetrace/scripts/check_code_lines.py\n\n# 使用自定义参数\nnode free-todo-frontend/scripts/check_code_lines.js --include apps,components,electron --exclude lib/generated --max 600\nuv run python lifetrace/scripts/check_code_lines.py --include lifetrace --exclude lifetrace/__pycache__ --max 600\n```\n\n**模式 2：检查指定文件（pre-commit 模式）**\n\n```bash\n# 只检查指定的文件\nnode free-todo-frontend/scripts/check_code_lines.js apps/chat/ChatPanel.tsx apps/todo/TodoList.tsx\nuv run python lifetrace/scripts/check_code_lines.py lifetrace/routers/chat.py lifetrace/services/todo.py\n```\n\n> **注意**：在 `git commit` 时，pre-commit 会自动传入暂存的文件，只检查这些文件而不是整个目录。\n\n### 超限解决方案\n\n当文件代码行数超过限制时，建议：\n\n1. **拆分文件**：将大文件按功能模块拆分为多个小文件\n2. **提取公共逻辑**：将重复代码抽象为独立的工具函数/组件\n3. **使用组合模式**：将复杂组件拆分为多个子组件\n4. **评估注释量**：适当增加注释（不计入行数）来解释复杂逻辑\n\n---\n\n## 相关资源\n\n- [Pre-commit 官方文档](https://pre-commit.com/)\n- [Ruff 文档](https://docs.astral.sh/ruff/)\n- [Python 代码风格指南 (PEP 8)](https://peps.python.org/pep-0008/)\n\n---\n\n## 常见问题 FAQ\n\n**Q: Pre-commit 会修改我的代码吗？**\nA: 会的！Ruff 会自动修复可修复的问题，如不必要的 imports、未使用的变量等。检查您的修改后重新提交即可。\n\n**Q: 我可以在不同分支上使用不同的 pre-commit 配置吗？**\nA: 可以！`.pre-commit-config.yaml` 可以根据分支调整。\n\n**Q: Pre-commit 支持哪些编程语言？**\nA: 本项目配置支持 Python（通过 Ruff）、JavaScript/TypeScript（通过 Biome），Pre-commit 框架本身支持多种语言，包括 Go、Rust 等。\n\n**Q: 如何添加自定义检查？**\nA: 修改 `.pre-commit-config.yaml` 文件，添加新的 repository 或 hooks。\n\n**Q: 代码行数检查的阈值可以调整吗？**\nA: 可以！修改 `.pre-commit-config.yaml` 中对应 hook 的 `entry` 参数，添加 `--max <number>` 即可。例如：`--max 600` 将上限调整为 600 行。\n\n**Q: 为什么某些目录不被检查？**\nA: 为了避免检查自动生成的代码（如 Orval 生成的 API 代码），部分目录被排除在检查范围之外。可以通过 `--exclude` 参数调整排除列表。\n\n---\n\n## 联系方式\n\n如果遇到问题或需要帮助，请：\n1. 查看本指南的故障排除部分\n2. 运行 `pre-commit run --all-files -v` 查看详细错误\n3. 查看项目 Issue 或提交新的 Issue\n\n---\n\n**Happy Coding! 🎉**\n"
  },
  {
    "path": ".github/ROADMAP.md",
    "content": "# FreeU Project Roadmap\n\n## Project Vision\n\n**FreeU Group** (\"为您分忧\" - Sharing Your Worries) aims to build personal context databases for data mining and processing, proactively providing proactive services to users.\n\nBy recording personal databases (**LifeLog**, personal life logging), we are committed to:\n- Building complete personal context databases\n- Conducting data mining and processing\n- Providing proactive services to reduce users' cognitive burden\n- Transforming from \"chaos\" to \"order,\" providing users with psychological security and a warm, reliable, and trustworthy secretary partner\n\n## FreeU Overall Project Roadmap\n\n### 1. LifeTrace Phase (v0.2 Completed)\n\n**Core Mission**: Build personal databases (LifeLog)\n\n**Current Version**: v0.2 (one version ahead of Free Todo v0.1)\n\n#### ✅ Completed Features\n\n- **Computer Activity Flow Construction**: Continuously capturing user computer screen screenshots to generate various personal activity flows\n  - Automatic screen content capture\n  - Generate personal activity flow data\n  - Lay the foundation for subsequent data mining\n\n#### 🔮 Planned Features (Not Yet Started)\n\n- **Audio Acquisition**\n  - 24-hour recording functionality (user can choose to enable)\n  - Extract personal data from audio\n  - Enhance the dimensions of personal databases\n\n- **Video Environment**\n  - Acquire user's surrounding video environment information\n  - Build more accurate personal databases\n  - Provide richer contextual information\n\n- **Smart Bracelet and Data Integration**\n  - Integrate smart bracelets and other devices\n  - Introduce more personal dimension information (privacy information, financial data, etc.)\n  - Build multi-dimensional personal profiles\n\n- **Large Language Model Deployment Evolution**\n  - **Mid-term**: Hybrid use of cloud and local large language models for processing\n  - **Long-term**: After local large language model technology matures, actively optimize local large language model services\n    - All data runs locally\n    - Effectively eliminate data privacy concerns\n\n---\n\n### 2. Free Todo Phase (v0.1 Currently In Progress)\n\n**Core Mission**:\n1. Fix user intentions and form the context of intentions to lay the foundation for future proactive services\n2. Form personal context organization\n\n**Core Philosophy**: Free Todo has only one core function—**Cognitive Offloading** (essentially/cognitively)\n\n**Current Status**: Focusing on building the ultimate To-Do List, and on this basis, integrating the personal database built by LifeTrace to provide better services for individuals.\n\n#### 🚧 Recent Plans (Focus on Input Layer - Currently In Progress)\n\n**Goal**: Collect as much information as possible from users' daily lives and gather it as Todos\n\n##### UI Dynamic Island\n\n- ☐ Control voice input switch\n- ☐ Control screenshot scheduled task switch\n- ☐ Provide convenient window to access Todo list\n- ☐ Provide convenient window to access Todo conversation interface\n\n##### Agent Development\n\n- 🚧 Develop tool scheduling capability for AI chat interface (expected to be delivered within two weeks)\n  - Upgrade from basic conversation to intelligent Agent supporting multiple tool calls\n  - Support completing tasks by calling different tools through Agent\n  - Achieve tool scheduling capability similar to general-purpose Agents\n\n#### Input Layer: Reduce Input Burden, Thought-Stream-Like Capture\n\n**Design Philosophy**:\n- Entry point needs to be \"close\" - input should be simple and clean\n- Whatever the user throws at it, that's what it is (text, screenshots, voice)\n- Classification should be delayed, or let AI decide\n- Capture should be imperceptible\n\n**Feature Planning**:\n\n- **Voice Input**\n  - Dynamic Island voice input\n  - Hotkey to activate voice input\n\n- **Multimodal Input**\n  - Text (user input, AI generation)\n  - Screenshots\n  - Voice\n\n- **Social Software Integration**\n  - WeChat todo capture\n  - Feishu todo capture\n  - Todo capture from other social software\n\n- **Intelligent Extraction**\n  - Todo extraction from chat windows\n  - Todo extraction from messages\n  - Todo creation during chat (Agent calls creation tool)\n\n#### Intermediate Processing Layer: From \"Chaos\" to \"Order\"\n\n**Design Philosophy**: Transform diverse input sources (chaotic inputs) into ordered information\n\n**Feature Planning**:\n\n- **Intent Completion / Task Detail Completion**\n  - AI assists users in completion\n  - For users: input is simpler and faster\n  - For AI: provides more context\n\n- **Automatic Decomposition**\n  - Breaking \"big rocks\" into \"small stones\" - AI task decomposition\n  - Task graph-based organization (currently tree structure of todolist)\n  - Casual recording, AI automatically organizes\n  - Check data → automatic resource system integration\n  - Meaning alignment\n\n- **Automatic Classification and Organization**\n  - Classification should be delayed, or let AI decide\n  - Intelligent tag generation\n  - Task relationship analysis\n\n- **Task Priority Planning**\n  - AI plans task priorities\n  - Identify main tasks to do today\n  - Intelligent task sorting recommendations\n\n- **Environmental Data Collection**\n  - Collect environmental data (integrated with LifeTrace)\n  - Build task context\n\n- **Context Construction**\n  - Form personal context organization\n  - Lay the foundation for proactive services\n\n#### Output Layer: Psychological Security + Warm, Reliable, and Trustworthy Secretary Partner\n\n**Final Effect**: Psychological security + warm, reliable, and trustworthy secretary partner\n\n**Feature Planning**:\n\n- **AI Secretary Personification**\n  - Warm, reliable, and trustworthy secretary partner\n  - Humanized interaction experience\n\n- **Schedule Reminders**\n  - Intelligent reminder system\n  - Multi-dimensional reminder strategies\n\n- **Task Focus Mode**\n  - Only display part of tasks\n  - Help users focus on current important items\n\n- **Completed Task Reinforcement (Merit Ledger)**\n  - Reinforce completed todos\n  - Visualize achievements and progress\n\n- **Overdue Task Re-planning**\n  - Transform overdue tasks into re-planning\n  - Avoid user psychological burden\n  - Provide opportunities to start fresh\n\n#### To-Do List Workbench (Long-term Vision)\n\n**Design Philosophy**:\n- Treat To-Do List as a fixed context center\n- Directly call Agents within Free Todo to generate content (such as PPTs or articles)\n- Similar to Cursor's Plan Mode: asks users questions it feels are unclear, allowing users to supplement context for better results\n- Generate plans and submit them to users for review\n\n**Core Value**:\n- Another meaning of \"Free Todo\": \"Just Do It, Let Go and Do It\"\n- Get things done directly within the To-Do List\n- Provide psychological security, allowing users to focus on important decisions\n\n**Feature Planning**:\n\n- ☐ Fixed context center based on To-Do List\n- ☐ Directly call Agents within Free Todo to generate content\n- ☐ Interactive experience similar to Cursor Plan Mode\n- ☐ Generate plans and submit them to users for review\n- ☐ Support generating various types of content (PPT, articles, code, etc.)\n\n#### 🔬 Features in Development (Panels in the Panel Switch Bar)\n\n**Description**: Free Todo's panel switch bar contains some panels that are currently under development. These panels showcase our future feature directions for community reference and understanding.\n\n**Community Participation**: We warmly welcome community members to participate!\n\n- **Panel Contributions**:\n  - Contribute your own panel designs\n  - Propose improvement suggestions and ideas for existing panels\n\n- **Agent Algorithm Contributions**:\n  - Contribute new Agent algorithms\n  - We actively merge these new algorithms!\n\n---\n\n### 3. Proactive Service Phase (Future Planning)\n\n**Core Mission**: TODO proactive services, intent detection\n\nBased on LifeTrace data and Free Todo intentions, proactively provide services to users:\n\n- Intent detection and proactive services\n- Intelligent recommendations based on personal databases\n- Context-aware service triggers\n\n---\n\n### 4. Agent Ecosystem Phase (Long-term Vision)\n\n**Core Mission**: Build a complete agent ecosystem\n\n**Feature Modules**:\n\n- **Digital Marketing Agent**: computer use iflow digital marketing agent\n- **Digital Deliverables RM**: Digital deliverables resource management\n\n**Interaction Capabilities**:\n\n- **Cross-device**: PC + Mobile + AI Hardware\n- **Cross-modal**: Support multiple interaction modalities (text, voice, images, etc.)\n- **Cross-application**: Integrate various applications and services\n\n---\n\n## Technical Architecture Evolution\n\n### Data Flow\n\n```\nLifeTrace (Personal Database)\n    ↓\nFree Todo (Intent Fixation + Context Organization)\n    ↓\nProactive Services (Based on Data + Intent)\n    ↓\nAgent Ecosystem (Multi-dimensional Services)\n```\n\n### Core Value Realization\n\n1. **Cognitive Offloading**: Reduce user input and processing burden through automation\n2. **From Chaos to Order**: Transform disordered inputs into ordered tasks and actions\n3. **Psychological Security**: Make users feel secure through AI assistance and intelligent planning\n4. **Proactive Services**: Proactively provide valuable suggestions and services based on personal data\n\n---\n\n## Community Participation\n\nWe welcome community members to participate in the project in various ways:\n\n- 🎨 **Panel Development**: Contribute panel designs or propose improvements\n- 🤖 **Agent Algorithms**: Contribute new Agent algorithms, we actively merge them\n- 🐛 **Issue Reporting**: Report issues or propose feature suggestions\n- 📝 **Documentation Improvements**: Improve documentation and tutorials\n- 💻 **Code Contributions**: Implement new features or fix issues\n\nSee our [Contributing Guidelines](../.github/CONTRIBUTING.md) for more details.\n\n---\n\n## Version History\n\n- **LifeTrace v0.2** (Completed): Computer activity flow construction\n- **Free Todo v0.1** (In Progress): To-Do List core feature development\n\n---\n\n*Last updated: 2026.1.11*\n"
  },
  {
    "path": ".github/ROADMAP_CN.md",
    "content": "# FreeU 项目路线图\n\n## 项目愿景\n\n**FreeU Group**（为您分忧）的愿景是通过构建个人的上下文数据库进行数据挖掘和处理，主动地为用户提供主动式服务。\n\n通过记录个人数据库（**LifeLog**，记录个人生活日志），我们致力于：\n- 形成完整的个人上下文数据库\n- 进行数据挖掘和处理\n- 提供主动式服务，减轻用户的认知负担\n- 从\"混沌\"到\"秩序\"，为用户提供心理安全感和温暖可靠的秘书伙伴\n\n## FreeU 整体项目路线图\n\n### 1. LifeTrace 阶段（v0.2 已完成）\n\n**核心使命**：形成个人数据库（LifeLog）\n\n**当前版本**：v0.2（比 Free Todo v0.1 领先一个版本）\n\n#### ✅ 已完成功能\n\n- **电脑活动流构建**：通过不断对用户电脑屏幕进行截图，生成各种个人活动流\n  - 自动截取屏幕内容\n  - 生成个人活动流数据\n  - 为后续数据挖掘打下基础\n\n#### 🔮 计划中功能（尚未开始）\n\n- **音频获取**\n  - 24小时录音功能（用户可选择开启）\n  - 从音频中提取个人数据\n  - 增强个人数据库的维度\n\n- **视频环境**\n  - 获取用户周边视频环境信息\n  - 形成更精确的个人数据库\n  - 提供更丰富的上下文信息\n\n- **智能手环与数据集成**\n  - 引入智能手环等设备\n  - 引入更多个人维度信息（隐私信息、金融数据等）\n  - 构建多维度个人画像\n\n- **大模型部署演进**\n  - **中期**：混合使用云端大模型和本地进行处理\n  - **长期**：本地大模型技术成熟后，积极优化本地大模型服务\n    - 所有数据本地运行\n    - 有效免除数据隐私方面的压力\n\n---\n\n### 2. Free Todo 阶段（v0.1 当前进行中）\n\n**核心使命**：\n1. 通过固定用户的意图，以及形成意图的上下文，为之后的主动服务打下基础\n2. 形成个人的上下文整理\n\n**核心理念**：Free Todo 有且只有 1 个核心功能——**认知卸载**（本质上/认知上）\n\n**当前状态**：聚焦于打造极致的 To-Do List，在其基础上加入 LifeTrace 为我们打下的个人数据库，从而为个人提供更好的服务。\n\n#### 🚧 近期计划（专注输入层 - 正在进行中）\n\n**目标**：尽可能从用户生活中获取各种各样的信息并收集为 Todo\n\n##### UI 灵动岛部分\n\n- ☐ 控制语音输入开关\n- ☐ 控制截图定时任务开关\n- ☐ 提供便捷窗口访问 Todo 列表\n- ☐ 提供便捷窗口访问 Todo 对话界面\n\n##### Agent 开发\n\n- 🚧 开发 AI 聊天界面的工具调度能力（预计未来两周内交付）\n  - 从基础对话升级为支持多工具调用的智能 Agent\n  - 支持通过 Agent 调用不同工具完成任务\n  - 实现类似通用 Agent 的工具调度能力\n\n#### 输入层：减轻输入负担，意念流般的捕获\n\n**设计理念**：\n- 入口要\"贴脸\"——输入的简洁、干净\n- 用户扔给你什么就是什么（文字、截图、语音）\n- 分类要滞后，或让 AI 决定\n- 捕捉要无感\n\n**功能规划**：\n\n- **语音输入**\n  - 灵动岛语音输入\n  - 快捷键呼出语音输入\n\n- **多模态输入**\n  - 文字（用户输入、AI 生成）\n  - 截图\n  - 语音\n\n- **社交软件集成**\n  - 微信 todo 捕获\n  - 飞书 todo 捕获\n  - 其他社交软件的 todo 捕获\n\n- **智能提取**\n  - 聊天窗口的 todo 提取\n  - 消息中的 todo 提取\n  - 聊天时的 todo 创建（Agent 调用创建工具）\n\n#### 中间处理层：从\"混沌\"到\"秩序\"\n\n**设计理念**：将多种多样的输入源（混沌的输入）转变为有序的信息\n\n**功能规划**：\n\n- **意图补全 / 任务详情补全**\n  - AI 辅助用户补全\n  - 对用户：输入更简洁快速\n  - 对 AI：提供更多上下文\n\n- **自动拆解**\n  - \"大石头\"敲成\"小石子\"——AI 任务拆解\n  - 任务图谱化（目前是 todolist 的树状结构）\n  - 随性记录，AI 自动整理\n  - 查资料 → 自动资源体系整合\n  - 意义对齐\n\n- **自动分类与组织**\n  - 分类要滞后，或让 AI 决定\n  - 智能标签生成\n  - 任务关联分析\n\n- **任务优先级规划**\n  - AI 规划任务优先级\n  - 识别今日要做的主要事项\n  - 智能推荐任务排序\n\n- **环境数据收集**\n  - 收集环境数据（结合 LifeTrace）\n  - 构建任务上下文\n\n- **上下文构建**\n  - 形成个人上下文整理\n  - 为主动服务打下基础\n\n#### 输出层：心理安全感 + 温暖可靠且值得信任的秘书伙伴\n\n**最终效果**：心理安全感 + 温暖、可靠且值得信任的秘书伙伴\n\n**功能规划**：\n\n- **AI 秘书人格化**\n  - 温暖、可靠且值得信任的秘书伙伴\n  - 人性化的交互体验\n\n- **日程提醒**\n  - 智能提醒系统\n  - 多维度提醒策略\n\n- **任务专注模式**\n  - 只显示部分任务\n  - 帮助用户聚焦当前重要事项\n\n- **已完成任务强化（功劳簿化）**\n  - 强化已完成的待办\n  - 可视化成就和进展\n\n- **逾期任务重新规划**\n  - 逾期变重新规划\n  - 避免用户心理负担\n  - 提供重新开始的机会\n\n#### To-Do List Workbench（长期愿景）\n\n**设计理念**：\n- 把 To-Do List 当成一个固定的上下文中心\n- 在 Free Todo 内部直接调用 Agent 来生成内容（如 PPT 或文章）\n- 类似 Cursor 的 Plan Mode：会询问用户一些它觉得没说清楚的问题，让用户补充上下文，以便生成更好的结果\n- 生成计划并交由用户审核\n\n**核心价值**：\n- \"Free Todo\" 的另一层含义：\"放手去做，放开手去干\"\n- 直接在 To-Do List 里面把事情做好\n- 提供心理安全感，让用户专注于重要决策\n\n**功能规划**：\n\n- ☐ 基于 To-Do List 的固定上下文中心\n- ☐ 在 Free Todo 内部直接调用 Agent 生成内容\n- ☐ 类似 Cursor Plan Mode 的交互体验\n- ☐ 生成计划并交由用户审核\n- ☐ 支持生成多种类型的内容（PPT、文章、代码等）\n\n#### 🔬 开发中功能（面板开关栏里的开发中的面板）\n\n**说明**：Free Todo 的面板开关栏里有一些正在开发中的面板，这些面板展示了我们未来的功能方向，供社区参考和了解。\n\n**社区参与**：我们非常欢迎社区成员参与进来！\n\n- **面板贡献**：\n  - 贡献自己的面板设计\n  - 针对现有面板提出改进建议和想法\n\n- **Agent 算法贡献**：\n  - 贡献新的 Agent 算法\n  - 我们积极合入这些新的算法！\n\n---\n\n### 3. 主动服务阶段（未来规划）\n\n**核心使命**：TODO 主动服务、意图检测\n\n基于 LifeTrace 数据和 Free Todo 意图，主动为用户提供服务：\n\n- 意图检测与主动服务\n- 基于个人数据库的智能推荐\n- 上下文感知的服务触发\n\n---\n\n### 4. 智能体生态阶段（长期愿景）\n\n**核心使命**：构建完整的智能体生态系统\n\n**功能模块**：\n\n- **数字营销智能体**：computer use iflow 数字营销智能体\n- **数字交付物 RM**：数字交付物资源管理\n\n**交互能力**：\n\n- **跨设备**：PC + 移动端 + AI 硬件\n- **跨模态**：支持多种交互模态（文本、语音、图像等）\n- **跨应用**：集成各种应用和服务\n\n---\n\n## 技术架构演进\n\n### 数据流\n\n```\nLifeTrace (个人数据库)\n    ↓\nFree Todo (意图固定 + 上下文整理)\n    ↓\n主动服务 (基于数据 + 意图)\n    ↓\n智能体生态 (多维度服务)\n```\n\n### 核心价值实现\n\n1. **认知卸载**：通过自动化减轻用户输入和处理负担\n2. **从混沌到秩序**：将无序的输入转化为有序的任务和行动\n3. **心理安全感**：通过 AI 辅助和智能规划，让用户感到安心\n4. **主动服务**：基于个人数据主动提供有价值的建议和服务\n\n---\n\n## 社区参与\n\n我们欢迎社区成员以多种方式参与项目：\n\n- 🎨 **面板开发**：贡献面板设计或提出改进建议\n- 🤖 **Agent 算法**：贡献新的 Agent 算法，我们积极合入\n- 🐛 **问题反馈**：报告问题或提出功能建议\n- 📝 **文档改进**：改进文档和教程\n- 💻 **代码贡献**：实现新功能或修复问题\n\n查看我们的 [贡献指南](../.github/CONTRIBUTING_CN.md) 了解更多详情。\n\n---\n\n## 版本历史\n\n- **LifeTrace v0.2**（已完成）：电脑活动流构建\n- **Free Todo v0.1**（进行中）：To-Do List 核心功能开发\n\n---\n\n*最后更新：2026.1.11*\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "version: 2\nupdates:\n  - package-ecosystem: \"github-actions\"\n    directory: \"/\"\n    schedule:\n      interval: \"weekly\"\n  - package-ecosystem: \"github-actions\"\n    directory: \"/\"\n    schedule:\n      interval: \"weekly\"\n    target-branch: \"dev\"\n  - package-ecosystem: \"pip\"\n    directory: \"/\"\n    schedule:\n      interval: \"weekly\"\n  - package-ecosystem: \"pip\"\n    directory: \"/\"\n    schedule:\n      interval: \"weekly\"\n    target-branch: \"dev\"\n  - package-ecosystem: \"npm\"\n    directory: \"/free-todo-frontend\"\n    schedule:\n      interval: \"weekly\"\n  - package-ecosystem: \"npm\"\n    directory: \"/free-todo-frontend\"\n    schedule:\n      interval: \"weekly\"\n    target-branch: \"dev\"\n"
  },
  {
    "path": ".github/workflows/_disabled/dev-build-verify.yml",
    "content": "name: Dev Build Verify\n\non:\n  push:\n    branches: [dev]\n  pull_request:\n    branches: [dev]\n  workflow_dispatch:\n\nconcurrency:\n  group: dev-build-verify-${{ github.ref }}\n  cancel-in-progress: true\n\njobs:\n  tauri-build-verify:\n    strategy:\n      fail-fast: false\n      matrix:\n        os: [windows-latest, macos-latest, ubuntu-latest]\n        include:\n          - os: windows-latest\n            script_suffix: win\n          - os: macos-latest\n            script_suffix: mac\n          - os: ubuntu-latest\n            script_suffix: linux\n    runs-on: ${{ matrix.os }}\n    env:\n      TAURI_VARIANT: web\n      BACKEND_RUNTIME: script\n      APPIMAGE_EXTRACT_AND_RUN: \"1\"\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-node@v4\n        with:\n          node-version: \"20\"\n      - uses: pnpm/action-setup@v4\n        with:\n          version: \"9\"\n      - uses: dtolnay/rust-toolchain@stable\n      - name: Install macOS targets\n        if: runner.os == 'macOS'\n        run: rustup target add aarch64-apple-darwin x86_64-apple-darwin\n      - name: Install Linux system deps\n        if: runner.os == 'Linux'\n        run: |\n          sudo apt-get update\n          sudo apt-get install -y pkg-config libglib2.0-dev libglib2.0-bin libgtk-3-dev libgtk-3-bin libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libssl-dev desktop-file-utils appstream patchelf squashfs-tools zsync\n          sudo apt-get install -y libfuse2 || sudo apt-get install -y libfuse2t64\n      - name: Install frontend deps\n        run: pnpm install --frozen-lockfile\n        working-directory: free-todo-frontend\n      - name: Build Tauri bundle\n        run: pnpm run build:tauri:${{ env.TAURI_VARIANT }}:${{ env.BACKEND_RUNTIME }}:full:${{ matrix.script_suffix }}\n        working-directory: free-todo-frontend\n\n  electron-build-verify:\n    continue-on-error: true\n    strategy:\n      fail-fast: false\n      matrix:\n        os: [windows-latest, macos-latest, ubuntu-latest]\n    runs-on: ${{ matrix.os }}\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-node@v4\n        with:\n          node-version: \"20\"\n      - uses: pnpm/action-setup@v4\n        with:\n          version: \"9\"\n      - name: Install frontend deps\n        run: pnpm install --frozen-lockfile\n        working-directory: free-todo-frontend\n      - name: Build Electron app (dir)\n        run: pnpm run build:electron:web:script:full:dir\n        working-directory: free-todo-frontend\n\n  web-build-verify:\n    continue-on-error: true\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-node@v4\n        with:\n          node-version: \"20\"\n      - uses: pnpm/action-setup@v4\n        with:\n          version: \"9\"\n      - name: Install frontend deps\n        run: pnpm install --frozen-lockfile\n        working-directory: free-todo-frontend\n      - name: Build frontend (web)\n        run: pnpm run build:frontend:web\n        working-directory: free-todo-frontend\n\n  backend-uv-verify:\n    continue-on-error: true\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - name: Set Lifetrace data dir\n        run: echo \"LIFETRACE_DATA_DIR=$RUNNER_TEMP/lifetrace\" >> \"$GITHUB_ENV\"\n      - uses: actions/setup-python@v5\n        with:\n          python-version: \"3.12\"\n      - uses: astral-sh/setup-uv@v3\n      - name: Install system deps\n        run: |\n          sudo apt-get update\n          sudo apt-get install -y portaudio19-dev\n      - name: Install backend deps\n        run: uv sync --group dev\n      - name: Backend CLI smoke check\n        run: uv run python -m lifetrace.server --help\n\n  install-sh-verify:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - name: Run install.sh (no-run)\n        env:\n          LIFETRACE_REPO: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.clone_url || format('https://github.com/{0}.git', github.repository) }}\n          LIFETRACE_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref_name }}\n          LIFETRACE_DIR: ${{ runner.temp }}/lifetrace-install\n          LIFETRACE_MODE: web\n          LIFETRACE_FRONTEND: build\n          LIFETRACE_BACKEND: script\n          LIFETRACE_RUN: \"0\"\n        run: bash scripts/install.sh\n\n  install-ps1-verify:\n    runs-on: windows-latest\n    steps:\n      - uses: actions/checkout@v4\n      - name: Run install.ps1 (no-run)\n        env:\n          LIFETRACE_REPO: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.clone_url || format('https://github.com/{0}.git', github.repository) }}\n          LIFETRACE_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref_name }}\n          LIFETRACE_DIR: ${{ runner.temp }}\\\\lifetrace-install\n          LIFETRACE_MODE: web\n          LIFETRACE_FRONTEND: build\n          LIFETRACE_BACKEND: script\n          LIFETRACE_RUN: \"0\"\n        run: >\n          powershell -ExecutionPolicy Bypass -File scripts/install.ps1\n          -Repo \"$env:LIFETRACE_REPO\"\n          -Ref \"$env:LIFETRACE_REF\"\n          -Dir \"$env:LIFETRACE_DIR\"\n          -Mode \"$env:LIFETRACE_MODE\"\n          -Variant \"web\"\n          -Frontend \"$env:LIFETRACE_FRONTEND\"\n          -Backend \"$env:LIFETRACE_BACKEND\"\n          -Run \"$env:LIFETRACE_RUN\"\n"
  },
  {
    "path": ".github/workflows/_disabled/tauri-release.yml",
    "content": "name: Tauri Release\n\non:\n  push:\n    tags:\n      - \"v*\"\n\njobs:\n  check-main:\n    runs-on: ubuntu-latest\n    outputs:\n      is_main: ${{ steps.check.outputs.is_main }}\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n      - name: Check tag is on main\n        id: check\n        run: |\n          git fetch origin main --depth=1\n          if git merge-base --is-ancestor \"$GITHUB_SHA\" \"origin/main\"; then\n            echo \"is_main=true\" >> \"$GITHUB_OUTPUT\"\n          else\n            echo \"is_main=false\" >> \"$GITHUB_OUTPUT\"\n          fi\n\n  build:\n    needs: check-main\n    strategy:\n      fail-fast: false\n      matrix:\n        os: [windows-latest, macos-latest, ubuntu-latest]\n        variant: [web, island]\n        include:\n          - os: windows-latest\n            script_suffix: win\n          - os: macos-latest\n            script_suffix: mac\n          - os: ubuntu-latest\n            script_suffix: linux\n    runs-on: ${{ matrix.os }}\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-node@v4\n        with:\n          node-version: \"20\"\n      - uses: pnpm/action-setup@v4\n        with:\n          version: \"9\"\n      - uses: dtolnay/rust-toolchain@stable\n      - name: Install macOS targets\n        if: runner.os == 'macOS'\n        run: rustup target add aarch64-apple-darwin x86_64-apple-darwin\n      - name: Install Linux system deps\n        if: runner.os == 'Linux'\n        run: |\n          sudo apt-get update\n          sudo apt-get install -y pkg-config libglib2.0-dev libgtk-3-dev libwebkit2gtk-4.1-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev\n      - name: Install frontend deps\n        run: pnpm install --frozen-lockfile\n        working-directory: free-todo-frontend\n      - name: Build Tauri bundle\n        run: pnpm run build:tauri:${{ matrix.variant }}:script:full:${{ matrix.script_suffix }}\n        working-directory: free-todo-frontend\n      - name: Upload artifacts\n        uses: actions/upload-artifact@v4\n        with:\n          name: tauri-${{ matrix.variant }}-${{ matrix.script_suffix }}\n          path: free-todo-frontend/dist-artifacts/tauri\n\n  release:\n    needs: [check-main, build]\n    runs-on: ubuntu-latest\n    permissions:\n      contents: write\n    steps:\n      - uses: actions/download-artifact@v4\n        with:\n          path: dist-artifacts\n          merge-multiple: true\n      - uses: softprops/action-gh-release@v2\n        with:\n          draft: ${{ needs.check-main.outputs.is_main != 'true' }}\n          files: dist-artifacts/**/*\n"
  },
  {
    "path": ".github/workflows/dependency-review.yml",
    "content": "name: Dependency Review\n\non:\n  pull_request:\n  workflow_dispatch:\n    inputs:\n      base-ref:\n        description: \"Base ref (e.g. main)\"\n        required: true\n      head-ref:\n        description: \"Head ref (e.g. vk/b02a-ci-monitor-2)\"\n        required: true\n\npermissions:\n  contents: read\n  pull-requests: write\n\njobs:\n  dependency-review:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - name: Check dependency graph status\n        id: dep_graph\n        env:\n          GH_TOKEN: ${{ github.token }}\n        run: |\n          set -euo pipefail\n          status=$(gh api repos/${{ github.repository }} --jq '.security_and_analysis.dependency_graph.status // \"unknown\"' || echo \"unknown\")\n          if [ \"$status\" = \"enabled\" ]; then\n            echo \"enabled=true\" >> \"$GITHUB_OUTPUT\"\n          else\n            echo \"enabled=false\" >> \"$GITHUB_OUTPUT\"\n            echo \"Dependency graph disabled or unavailable; skipping dependency review.\"\n          fi\n      - name: Dependency review (PR)\n        if: github.event_name == 'pull_request' && steps.dep_graph.outputs.enabled == 'true'\n        uses: actions/dependency-review-action@v4\n        with:\n          fail-on-severity: high\n          comment-summary-in-pr: always\n      - name: Dependency review (manual)\n        if: github.event_name == 'workflow_dispatch' && steps.dep_graph.outputs.enabled == 'true'\n        uses: actions/dependency-review-action@v4\n        with:\n          fail-on-severity: high\n          comment-summary-in-pr: never\n          base-ref: ${{ inputs.base-ref }}\n          head-ref: ${{ inputs.head-ref }}\n"
  },
  {
    "path": ".github/workflows/pre-commit.yml",
    "content": "name: Pre-commit\n\non:\n  pull_request:\n  push:\n    branches: [main, master]\n  workflow_dispatch:\n\njobs:\n  changes:\n    runs-on: ubuntu-latest\n    outputs:\n      python: ${{ steps.filter.outputs.python }}\n      frontend: ${{ steps.filter.outputs.frontend }}\n      rust: ${{ steps.filter.outputs.rust }}\n    steps:\n      - uses: actions/checkout@v4\n      - uses: dorny/paths-filter@v3\n        id: filter\n        with:\n          filters: |\n            python:\n              - 'lifetrace/**'\n              - 'scripts/**'\n              - 'tests/**'\n              - 'pyproject.toml'\n              - 'pyrightconfig.json'\n              - 'bandit.yaml'\n              - '.pre-commit-config.yaml'\n            frontend:\n              - 'free-todo-frontend/**'\n              - '.pre-commit-config.yaml'\n            rust:\n              - 'free-todo-frontend/src-tauri/**'\n              - 'scripts/precommit_rustfmt.py'\n              - 'scripts/precommit_clippy.py'\n              - '.pre-commit-config.yaml'\n\n  precommit-base:\n    runs-on: ${{ matrix.os }}\n    strategy:\n      matrix:\n        os: [ubuntu-latest, windows-latest, macos-latest]\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-python@v5\n        with:\n          python-version: \"3.12\"\n      - name: Install pre-commit\n        run: python -m pip install --upgrade pip pre-commit\n      - name: Run base hooks\n        run: |\n          pre-commit run check-yaml --all-files\n          pre-commit run check-toml --all-files\n          pre-commit run check-json --all-files\n          pre-commit run end-of-file-fixer --all-files\n          pre-commit run trailing-whitespace --all-files\n\n  precommit-python:\n    runs-on: ${{ matrix.os }}\n    strategy:\n      matrix:\n        os: [ubuntu-latest, windows-latest, macos-latest]\n    needs: changes\n    if: ${{ needs.changes.outputs.python == 'true' }}\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-python@v5\n        with:\n          python-version: \"3.12\"\n      - uses: astral-sh/setup-uv@v3\n      - name: Install system deps\n        if: runner.os == 'Linux'\n        run: |\n          sudo apt-get update\n          sudo apt-get install -y portaudio19-dev\n      - name: Install backend deps\n        run: uv sync --group dev\n      - name: Install pre-commit\n        run: python -m pip install --upgrade pip pre-commit\n      - name: Run Python hooks\n        run: |\n          pre-commit run ruff --all-files\n          pre-commit run ruff-format --all-files\n          pre-commit run bandit --all-files\n          pre-commit run pyright --all-files\n          pre-commit run pytest-quick --all-files\n          pre-commit run check-backend-code-lines --all-files\n\n  migration-check:\n    runs-on: ubuntu-latest\n    needs: changes\n    if: ${{ needs.changes.outputs.python == 'true' }}\n    steps:\n      - uses: actions/checkout@v4\n      - name: Set Lifetrace data dir\n        run: echo \"LIFETRACE_DATA_DIR=$RUNNER_TEMP/lifetrace\" >> \"$GITHUB_ENV\"\n      - uses: actions/setup-python@v5\n        with:\n          python-version: \"3.12\"\n      - uses: astral-sh/setup-uv@v3\n      - name: Install system deps\n        run: |\n          sudo apt-get update\n          sudo apt-get install -y portaudio19-dev\n      - name: Install backend deps\n        run: uv sync --group dev\n      - name: Prepare data directories\n        run: mkdir -p \"$LIFETRACE_DATA_DIR/data\"\n      - name: Initialize database schema\n        run: |\n          uv run python - <<'PY'\n          from sqlmodel import SQLModel, create_engine\n\n          from lifetrace.storage import models  # noqa: F401\n          from lifetrace.util.path_utils import get_database_path\n\n          engine = create_engine(f\"sqlite:///{get_database_path()}\")\n          SQLModel.metadata.create_all(engine)\n          PY\n      - name: Alembic heads check\n        working-directory: lifetrace\n        run: |\n          set +e\n          uv run alembic -c alembic.ini heads > /tmp/alembic_heads.txt 2> /tmp/alembic_heads_err.txt\n          alembic_status=$?\n          if [ $alembic_status -ne 0 ]; then\n            echo \"Alembic heads command failed with exit code ${alembic_status}.\"\n            echo \"Alembic stdout:\"\n            cat /tmp/alembic_heads.txt || true\n            echo \"Alembic stderr:\"\n            cat /tmp/alembic_heads_err.txt || true\n          elif [ -s /tmp/alembic_heads_err.txt ]; then\n            echo \"Alembic stderr:\"\n            cat /tmp/alembic_heads_err.txt\n          fi\n          python - <<'PY'\n          from pathlib import Path\n\n          output_path = Path(\"/tmp/alembic_heads.txt\")\n          if output_path.exists():\n              output = output_path.read_text().strip().splitlines()\n          else:\n              output = []\n          heads = [line for line in output if line.strip()]\n          if not heads:\n              print(\"Alembic heads output was empty.\")\n          if len(heads) > 1:\n              print(\"Multiple Alembic heads detected:\")\n              print(\"\\n\".join(heads))\n              raise SystemExit(1)\n          PY\n          python_status=$?\n          set -e\n          if [ $python_status -ne 0 ]; then\n            exit $python_status\n          fi\n          if [ $alembic_status -ne 0 ]; then\n            exit $alembic_status\n          fi\n      - name: Alembic upgrade check\n        working-directory: lifetrace\n        run: uv run alembic -c alembic.ini upgrade head\n\n  precommit-frontend:\n    runs-on: ${{ matrix.os }}\n    strategy:\n      matrix:\n        os: [ubuntu-latest, windows-latest, macos-latest]\n    needs: changes\n    if: ${{ needs.changes.outputs.frontend == 'true' }}\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-python@v5\n        with:\n          python-version: \"3.12\"\n      - uses: actions/setup-node@v4\n        with:\n          node-version: \"20\"\n      - uses: pnpm/action-setup@v4\n        with:\n          version: \"9\"\n      - name: Install frontend deps\n        run: pnpm install\n        working-directory: free-todo-frontend\n      - name: Install pre-commit\n        run: python -m pip install --upgrade pip pre-commit\n      - name: Run frontend hooks\n        run: |\n          pre-commit run biome-check --all-files\n          pre-commit run tsc-free-todo-frontend --all-files\n          pre-commit run check-frontend-code-lines --all-files\n\n  precommit-rust:\n    runs-on: ${{ matrix.os }}\n    strategy:\n      matrix:\n        os: [ubuntu-latest, windows-latest, macos-latest]\n    needs: changes\n    if: ${{ needs.changes.outputs.rust == 'true' }}\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-python@v5\n        with:\n          python-version: \"3.12\"\n      - uses: actions/setup-node@v4\n        with:\n          node-version: \"20\"\n      - name: Install Linux system deps\n        if: runner.os == 'Linux'\n        run: |\n          sudo apt-get update\n          sudo apt-get install -y pkg-config libglib2.0-dev libgtk-3-dev libjavascriptcoregtk-4.1-dev libwebkit2gtk-4.1-dev libsoup-3.0-dev\n      - uses: dtolnay/rust-toolchain@stable\n        with:\n          components: rustfmt, clippy\n      - name: Install pre-commit\n        run: python -m pip install --upgrade pip pre-commit\n      - name: Run Rust hooks\n        run: |\n          pre-commit run rustfmt --all-files\n          pre-commit run clippy --all-files\n          pre-commit run check-tauri-rust-code-lines --all-files\n"
  },
  {
    "path": ".gitignore",
    "content": "# Python\n__pycache__/\n*.py[cod]\n*$py.class\n*.so\n.Python\nbuild/\ndevelop-eggs/\ndist/\ndownloads/\neggs/\n.eggs/\n# lib/\nlib64/\nparts/\nsdist/\nvar/\nwheels/\n*.egg-info/\n.installed.cfg\n*.egg\n\n# Virtual Environment\nvenv/\n.venv/\nENV/\nenv/\n\n# IDEs\n.vscode/\n.idea/\n*.swp\n*.swo\n*~\n\n# OS\n.DS_Store\nThumbs.db\n\n# LifeTrace specific\nlifetrace/data/\nlifetrace/config/config.yaml\n\n# Frontend - Node.js cache and build artifacts\n**/node_modules/\n**/.next/\n**/out/\n**/.env.local\n**/.env.production\n**/.vercel\n**/dist-electron*/\n**/dist-electron-app*/\n**/dist/\n**/dist-backend*/\n**/dist-backend-app*/\n**/.pnpm-store/\n**/.yarn/\n**/.pnp.*\n**/.cache/\n**/tsconfig.tsbuildinfo\n\n# Temporary files\n*.log\n*.tmp\n.cache/\n.ruff_cache/\n\n# Build\ndist-backend/\n\n# Rust / Tauri\n**/target/\n**/target-clippy*/\n**/*.rs.bk\n**/Cargo.lock\n**/WixTools/\n**/gen/\n"
  },
  {
    "path": ".pre-commit-config.yaml",
    "content": "repos:\n  - repo: https://github.com/pre-commit/pre-commit-hooks\n    rev: v6.0.0\n    hooks:\n      - id: check-yaml\n        exclude: pnpm-lock.yaml\n      - id: check-toml\n      - id: check-json\n      - id: end-of-file-fixer\n        exclude: ^free-todo-frontend/lib/generated/\n      - id: trailing-whitespace\n        exclude: ^free-todo-frontend/lib/generated/\n        args: [--markdown-linebreak-ext=md]\n  - repo: https://github.com/astral-sh/ruff-pre-commit\n    rev: v0.12.10\n    hooks:\n      # Run the linter.\n      - id: ruff\n        # It is recommended to specify the latest version of Python\n        # supported by your project here, or alternatively use\n        # pre-commit's default_language_version, see\n        # https://pre-commit.com/#top_level-default_language_version\n        language_version: python3.12\n        files: ^(lifetrace|scripts)/\n        exclude: ^lifetrace/migrations/versions/\n        types_or: [ python, pyi ]\n        args: [ --fix ]\n      # Run the formatter.\n      - id: ruff-format\n        language_version: python3.12\n        files: ^(lifetrace|scripts)/\n        types_or: [ python, pyi ]\n  # Biome for frontend (JavaScript/TypeScript)\n  - repo: https://github.com/biomejs/pre-commit\n    rev: \"v0.6.1\"\n    hooks:\n      - id: biome-check\n        additional_dependencies: [\"@biomejs/biome@2.3.13\"]\n        files: ^(free-todo-frontend/)\n  # TypeScript type checking for free-todo-frontend\n  - repo: local\n    hooks:\n      - id: bandit\n        name: Bandit security scan\n        # Avoid uv venv sync races in concurrent hooks (Windows file-lock issues).\n        entry: uv run --no-sync bandit -c bandit.yaml -r lifetrace scripts\n        language: system\n        files: ^(lifetrace|scripts)/.*\\.py$\n        pass_filenames: false\n        require_serial: true\n\n      - id: pyright\n        name: Pyright type check\n        entry: uv run --no-sync python -m pyright --project pyrightconfig.json\n        language: system\n        files: ^(lifetrace|scripts)/.*\\.(py|pyi)$\n        pass_filenames: false\n        require_serial: true\n\n      - id: pytest-quick\n        name: Pytest quick checks\n        entry: uv run --no-sync pytest -q tests/test_todo_service_mapping.py tests/test_icalendar_service.py tests/test_todo_serialization.py\n        language: system\n        files: ^(lifetrace|tests)/.*\\.py$\n        pass_filenames: false\n        require_serial: true\n\n      - id: tsc-free-todo-frontend\n        name: TypeScript type check (free-todo-frontend)\n        entry: bash -c 'cd free-todo-frontend && pnpm run type-check'\n        language: system\n        files: ^free-todo-frontend/.*\\.(ts|tsx)$\n        pass_filenames: false\n\n      # 前端 TS/TSX/JS/JSX 有效代码行数检查（不含空行和注释，上限 500 行）\n      # pre-commit 会传入暂存的文件，只检查这些文件\n      - id: check-frontend-code-lines\n        name: Check frontend TS/TSX/JS/JSX code lines (max 500)\n        entry: node free-todo-frontend/scripts/check_code_lines.js --include apps,components,electron,lib,scripts --exclude lib/generated\n        language: system\n        files: ^(free-todo-frontend|scripts)/.*\\.(ts|tsx|js|jsx)$\n        pass_filenames: true\n\n      # 后端 Python 有效代码行数检查（不含空行和注释，上限 500 行）\n      # pre-commit 会传入暂存的文件，只检查这些文件\n      - id: check-backend-code-lines\n        name: Check backend Python code lines (max 500)\n        entry: uv run --no-sync python lifetrace/scripts/check_code_lines.py --include lifetrace,scripts --exclude lifetrace/__pycache__,lifetrace/dist,lifetrace/migrations/versions\n        language: system\n        files: ^(lifetrace|scripts)/.*\\.py$\n        pass_filenames: true\n        require_serial: true\n\n      # Tauri Rust 有效代码行数检查（不含空行和注释，上限 500 行）\n      # pre-commit 会传入暂存的文件，只检查这些文件\n      - id: check-tauri-rust-code-lines\n        name: Check Tauri Rust code lines (max 500)\n        entry: node free-todo-frontend/scripts/check_rust_code_lines.js --include src-tauri/src --exclude src-tauri/target\n        language: system\n        files: ^free-todo-frontend/src-tauri/.*\\.rs$\n        pass_filenames: true\n\n      # Rust format check (rustfmt)\n      - id: rustfmt\n        name: Rust format (rustfmt)\n        entry: python scripts/precommit_rustfmt.py\n        language: python\n        files: ^free-todo-frontend/src-tauri/.*\\.rs$\n        pass_filenames: false\n\n      # Rust linter (clippy)\n      - id: clippy\n        name: Rust linter (clippy)\n        entry: python scripts/precommit_clippy.py\n        language: python\n        files: ^free-todo-frontend/src-tauri/.*\\.rs$\n        pass_filenames: false\n"
  },
  {
    "path": ".python-version",
    "content": "3.12\n"
  },
  {
    "path": "AGENTS.md",
    "content": "# Repository Guidelines\n\n## Project Structure & Module Organization\n- `lifetrace/`: FastAPI backend (routers, services, repositories, storage, llm, jobs, util).\n- `lifetrace/config/`: runtime configuration (`config.yaml` is generated from `default_config.yaml`).\n- `lifetrace/data/`: runtime data (SQLite DB, vector DB, logs) — do not commit.\n- `free-todo-frontend/`: Next.js + React frontend, Electron wrapper, and scripts.\n- `.github/`: contribution docs, guidelines, and repository assets.\n\n## Build, Test, and Development Commands\nBackend (from repo root):\n- `uv sync` — install Python deps into `.venv`.\n- `python -m lifetrace.server` — start FastAPI server (auto-selects port from 8001).\n- `uv run ruff check .` / `uv run ruff format .` — lint/format backend.\n\nFrontend (from `free-todo-frontend/`):\n- `pnpm install` — install frontend deps.\n- `pnpm dev` — start Next.js dev server with auto-port detection.\n- `pnpm lint` / `pnpm format` / `pnpm check` — Biome lint/format/check.\n- `pnpm type-check` — run TypeScript type checks.\n\nPackaging:\n- `pnpm electron:build` (or `electron:build-win|mac|linux`) — build Electron app.\n- `pnpm tauri:dev` / `pnpm tauri:build` — Tauri dev/build flows.\n\n## Coding Style & Naming Conventions\n- Python: PEP 8, type hints, docstrings; Ruff enforces 4-space indent and 100-char lines.\n- TypeScript: Biome handles formatting/linting; keep components functional and hooks-safe.\n- Naming: use Conventional Commits scopes like `backend`, `frontend`, `ui`, `config`.\n\n## Testing Guidelines\n- No dedicated test runner is configured yet. If you add tests, place Python tests under `tests/`\n  and align with Ruff’s `tests/*.py` per-file ignores. For frontend, prefer a future `pnpm test`\n  script; document any new runner in this file.\n\n## Commit & Pull Request Guidelines\n- Commit messages follow Conventional Commits:\n  - Example: `feat(frontend): add calendar drag and drop`.\n- In worktrees, prefer small, frequent commits. After each small feature change and\n  after relevant checks pass, commit immediately. Only if all pending changes committed, notify the user\n  (or other agents) that the commit is done.\n- PRs should include: a clear description, linked issues (e.g., `Closes #123`),\n  testing notes, and screenshots for UI changes. Use the `.github` PR template when available.\n\n## Parallel Worktrees (Required for Concurrent Tasks)\n- When working on multiple tasks in parallel, always use `git worktree` so each task\n  has its own working directory, tests, and commits.\n- Do not hardcode absolute paths or project names. Derive paths from the git repo root.\n- Default worktree base directory is a sibling to the repo root:\n  `<repo-parent>/_worktrees/<repo-name>/<task-slug>`.\n- Keep the main worktree clean; each task uses its own branch and worktree.\n- Each task must create a brand-new branch (do not reuse old branches).\n- Branch naming must follow: `<Type>/<user>/<short-task>` where:\n  - `Type` is lowercase: feat/chore/fix/hotfix/refactor (or other standard types).\n  - `user` is the current git username (from git config).\n  - `short-task` is a short summary (max 3 words).\n- If a task name is provided, create a worktree first, then make changes in that worktree.\n- Helper script (cross-platform): `python scripts/new_worktree.py \"<task-name>\"`\n- Keep task branches in sync with the current mainline branch. Do not assume the\n  mainline is named `main` or `master`, and do not assume the default remote is\n  `origin`. Prefer syncing to a user-specified local mainline branch (e.g., `dev`,\n  `dev-liji`, `dev-xxx`). If a mainline branch is not specified, ask the user\n  which local branch to track. Only fall back to remote detection when needed,\n  and detect the remote name first (e.g., `origin`, `upstream`).\n\n## Worktree Dependency Sharing (Recommended)\n- Install dependencies only once in the main worktree.\n- Reuse them in other worktrees via linking scripts:\n  - Windows (PowerShell): `powershell -ExecutionPolicy Bypass -File scripts/link_worktree_deps.ps1 -Main \"<main-root>\" -Worktree \"<worktree-root>\"`\n  - macOS/Linux (bash): `scripts/link_worktree_deps.sh --main \"<main-root>\" --worktree \"<worktree-root>\"`\n- Optional: add `--force` to overwrite existing links.\n\n## Integration When Main Is Dirty\n- Keep coding in task worktrees; do not commit on a dirty main worktree.\n- Create a clean integration worktree and cherry-pick task commits into it.\n- Run checks from the integration worktree (using shared dependencies), then merge.\n\n## Security & Configuration Tips\n- Do not commit `lifetrace/config/config.yaml` or `lifetrace/data/`.\n- API keys and secrets should live in local configs or environment variables only.\n"
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "content": "# Contributor Covenant Code of Conduct\n\n## Our Pledge\n\nWe as members, contributors, and leaders pledge to participate in our\ncommunity a harassment-free experience for everyone, regardless of age, body\nsize, visible or invisible disability, ethnicity, sex characteristics, gender\nidentity and expression, level of experience, education, socio-economic status,\nnationality, personal appearance, race, religion, or sexual identity\nand orientation.\n\nWe pledge to act and interact in ways that contribute to an open, welcoming,\ndiverse, inclusive, and healthy community.\n\n## Our Standards\n\nExamples of behavior that contributes to a positive environment for our\ncommunity includes:\n\n- Demonstrating empathy and kindness toward other people\n- Being respectful of differing opinions, viewpoints, and experiences\n- Giving and gracefully accepting constructive feedback\n- Accepting responsibility and apologizing to those affected by our mistakes,\n  and learning from the experience\n- Focusing on what is best not just for us as individuals, but for the\n  overall community\n\n## Examples of unacceptable behavior include:\n\n- The use of sexualized language or imagery, and sexual attention or\n  advances of any kind\n- Trolling, insulting or derogatory comments, and personal or political attacks\n- Public or private harassment\n- Publishing others' private information, such as a physical or email\n  address, without their explicit permission\n- Other conduct that could reasonably be considered inappropriate in a\n  professional setting\n\n## Enforcement Responsibilities\n\nCommunity leaders are responsible for clarifying and enforcing our standards of\nacceptable behavior and will take appropriate and fair corrective action in\nresponse to any behavior that they deem inappropriate, threatening, offensive,\nor harmful.\n\nCommunity leaders have the right and responsibility to remove, edit, or reject\ncomments, commits, code, wiki edits, issues, and other contributions that are\nnot aligned to this Code of Conduct, and will communicate reasons for moderation\ndecisions when appropriate.\n\n## Scope\n\nThis Code of Conduct applies within all community spaces and also applies when\nan individual is officially representing the community in public spaces.\nExamples of representing our community include using an official e-mail address,\nposting via an official social media account, or acting as an appointed\nrepresentative at an online or offline event.\n\n## Enforcement\n\nInstances of abusive, harassing, or otherwise unacceptable behavior may be\nreported to the community leaders responsible for enforcement at\n.\nAll complaints will be reviewed and investigated promptly and fairly.\n\nAll community leaders are obligated to respect the privacy and security of the\nreporter of any incident.\n\n## Enforcement Guidelines\n\nCommunity leaders will follow these Community Impact Guidelines in determining\nthe consequences for any action they deem in violation of this Code of Conduct:\n\n### 1. Correction\n\n**Community Impact**: Use of inappropriate language or other behavior deemed\nunprofessional or unwelcome in the community.\n\n**Consequence**: A private, written warning from community leaders, providing\nclarity around the nature of the violation and an explanation of why the\nbehavior was inappropriate. A public apology may be requested.\n\n### 2. Warning\n\n**Community Impact**: A violation through a single incident or series\nof actions.\n\n**Consequence**: A warning with consequences for continued behavior. No\ninteraction with the people involved, including unsolicited interaction with\nthose enforcing the Code of Conduct, for a specified time. This\nincludes avoiding interactions in community spaces as well as external channels\nlike social media. Violating these terms may lead to a temporary or\npermanent ban.\n\n### 3. Temporary Ban\n\n**Community Impact**: A serious violation of community standards, including\nsustained inappropriate behavior.\n\n**Consequence**: A temporary ban from any sort of interaction or public\ncommunication with the community for a specified time. No public or\nprivate interaction with the people involved, including unsolicited interaction\nwith those enforcing the Code of Conduct, is allowed during this period.\nViolating these terms may lead to a permanent ban.\n\n### 4. Permanent Ban\n\n**Community Impact**: Demonstrating a pattern of violation of community\nstandards, including sustained inappropriate behavior, harassment of an\nindividual, or aggression toward or disparagement of classes of individuals.\n\n**Consequence**: A permanent ban from any sort of public interaction within\nthe community.\n\n## Attribution\n\nThis Code of Conduct is adapted from the [Contributor Covenant][homepage],\nversion 2.0, available at\n<https://www.contributor-covenant.org/version/2/0/code_of_conduct.html>.\n\nCommunity Impact Guidelines were inspired by [Mozilla's code of conduct\nenforcement ladder](https://github.com/mozilla/diversity).\n\nFor answers to common questions about this code of conduct, see the FAQ at\n<https://www.contributor-covenant.org/faq>. Translations are available at\n<https://www.contributor-covenant.org/translations>.\n\n[homepage]: https://www.contributor-covenant.org\n"
  },
  {
    "path": "LICENSE",
    "content": "FreeU Community License\n\nCopyright (c) 2026/01/10 - FreeU Group. All rights reserved.\n\n----------\n\nFrom 1.0, Free Todo is licensed under the FreeU Community License, based on Apache License 2.0 with the following additional conditions:\n\n1. The commercial usage of Free Todo:\n\n  a. Free Todo may be utilized commercially, including as a frontend and backend service without modifying the source code.\n\n  b. a commercial license must be obtained from the producer if you want to develop and distribute a derivative work based on Free Todo.\n\nPlease contact yuanbo@freeyou.club by email to inquire about licensing matters.\n\n\n2. As a contributor, you should agree that:\n\n  a. The producer can adjust the open-source agreement to be more strict or relaxed as deemed necessary.\n\n  b. Your contributed code may be used for commercial purposes, including but not limited to its cloud edition.\n\nApart from the specific conditions mentioned above, all other rights and restrictions follow the Apache License 2.0. Detailed information about the Apache License 2.0 can be found at http://www.apache.org/licenses/LICENSE-2.0.\n"
  },
  {
    "path": "README.md",
    "content": "![FreeTodo Logo](.github/assets/free_todo_banner.png)\n\n![GitHub stars](https://img.shields.io/github/stars/FreeU-group/FreeTodo?style=social) ![GitHub forks](https://img.shields.io/github/forks/FreeU-group/FreeTodo?style=social) ![GitHub issues](https://img.shields.io/github/issues/FreeU-group/FreeTodo) [![License](https://img.shields.io/badge/license-FreeU%20Community-blue.svg)](LICENSE) ![Python version](https://img.shields.io/badge/python-3.12-blue.svg) ![FastAPI](https://img.shields.io/badge/FastAPI-0.100+-green.svg)\n\n**Language**: [English](README.md) | [中文](README_CN.md)\n\n[📖 Documentation](https://freeyou.club/lifetrace/introduction.html) • [🚀 Quick Start](#quick-start) • [💡 Features](#core-features) • [🔧 Development](#development-guide) • [🤝 Contributing](#contributing)\n\n# FreeTodo - Just Do It.\n\n## Project Overview\n\n**FreeTodo** is an AI-powered intelligent todo management application that helps you efficiently manage tasks, boost productivity, and achieve your goals. Through conversational AI interaction and smart task breakdown, FreeTodo transforms complex projects into actionable steps.\n\n## Core Features\n\n### 🤖 AI Smart Assistant\n- **Intelligent Task Breakdown**: AI automatically decomposes complex tasks into manageable subtasks with a guided questionnaire flow\n- **Smart Task Extraction**: Extract actionable todos from AI conversation responses\n- **Context-Aware Suggestions**: AI provides task recommendations based on your current todo context\n\n### ✅ Comprehensive Task Management\n- **Hierarchical Tasks**: Support for parent-child task relationships with unlimited nesting\n- **Priority & Status**: Four priority levels (urgent/high/medium/low) and multiple status states\n- **Tags & Categories**: Organize todos with custom tags for easy filtering\n- **Deadline Management**: Set deadlines with visual reminders\n- **Rich Notes**: Add detailed notes and descriptions to each todo\n\n### 📅 Multi-View Calendar\n- **Day/Week/Month Views**: Flexible calendar views to visualize your schedule\n- **Drag & Drop Scheduling**: Easily drag todos to calendar slots to schedule them\n- **Quick Todo Creation**: Create todos directly from calendar time slots\n\n### 🎨 Modern User Interface\n- **Multi-Panel Layout**: Customizable panel arrangement (Todos + Chat + Detail)\n- **Dark/Light Themes**: Beautiful themes with multiple color schemes\n- **Internationalization**: Full support for English and Chinese\n- **Responsive Design**: Optimized for various screen sizes\n\n### 💻 Desktop Application\n- **Electron App**: Native desktop experience on Windows & macOS\n- **System Integration**: Native notifications and system tray support\n\n## System Architecture\n\nFreeTodo adopts a **frontend-backend separation** architecture:\n\n- **Backend**: FastAPI (Python) - Provides RESTful API (located in `lifetrace/` directory)\n- **Frontend**: Next.js (React + TypeScript) - Modern web interface (located in `free-todo-frontend/` directory)\n- **Data Layer**: SQLite + ChromaDB (for AI features)\n\n## Quick Start\n\n### Environment Requirements\n\n**Backend**:\n\n- Python 3.12\n- Supported OS: Windows, macOS, Linux\n- Optional: CUDA support (for GPU acceleration)\n\n**Frontend**:\n\n- Node.js 20+\n- pnpm package manager\n\n<!-- ### One-Click Install & Start\n\n> Requires Python 3.12+, Node.js 20+, and Git. Tauri/Electron build also requires Rust.\n\n**macOS/Linux**\n\n```bash\ncurl -fsSL https://raw.githubusercontent.com/FreeU-group/FreeTodo/main/scripts/install.sh | bash\n```\n\n**Windows (PowerShell)**\n\n```powershell\niwr -useb https://raw.githubusercontent.com/FreeU-group/FreeTodo/main/scripts/install.ps1 | iex\n```\n\nFor full options, environment variables, and examples, see: [.github/INSTALL.md](.github/INSTALL.md) -->\n\n### Install Dependencies\n\nThis project uses [uv](https://github.com/astral-sh/uv) for fast and reliable dependency management.\n\n**Install uv:**\n\n```bash\n# macOS/Linux\ncurl -LsSf https://astral.sh/uv/install.sh | sh\n\n# Windows\npowershell -c \"irm https://astral.sh/uv/install.ps1 | iex\"\n```\n\n> **Note**: After installation, `uv` may not be immediately available in the current terminal. To activate it in the current session:\n>\n> - **Windows (PowerShell)**: Run `$env:Path = \"$env:USERPROFILE\\.local\\bin;$env:Path\"` to refresh PATH\n> - **macOS/Linux**: Run `exec $SHELL` to reinitialize your shell session, or restart your terminal\n>\n> Alternatively, you can simply open a new terminal window and `uv` will be available automatically.\n\n**Install dependencies and sync environment:**\n\n```bash\n# Sync dependencies from pyproject.toml and uv.lock\nuv sync\n\n# Activate the virtual environment\n# macOS/Linux\nsource .venv/bin/activate\n\n# Windows\n.venv\\Scripts\\activate\n```\n\n### Start the Backend Service\n\n> **Note**: On first run, the system will automatically create `config.yaml` from `default_config.yaml` if it doesn't exist. You can customize your settings by editing `lifetrace/config/config.yaml`.\n\n**Start the server:**\n\n```bash\npython -m lifetrace.server\n```\n\n> **Customize Prompts**: If you want to modify AI prompts for different features, you can edit `lifetrace/config/prompt.yaml`.\n\nThe backend service will automatically find an available port starting from `8001` (or `8100` for build version). If the default port is occupied, it will automatically use the next available port and display the actual port in the console.\n\n- **Default Backend Port**: `http://localhost:8001`\n- **API Documentation**: The actual API docs URL will be displayed in the console (typically `http://localhost:8001/docs`)\n\n### Start the Frontend Service\n\nThe frontend is required to use FreeTodo. Start the frontend development server:\n\n```bash\ncd free-todo-frontend\n\npnpm install\npnpm dev\n```\n\nThe frontend development server will:\n- Automatically find an available port starting from `3001` (default port for development)\n- Automatically detect the running FreeTodo backend port by checking the `/health` endpoint\n- Set up API proxy to the detected backend port\n\nThe actual frontend URL and backend connection status will be displayed in the console. Once both services are running, open your browser and navigate to the displayed frontend URL (typically `http://localhost:3001`) to enjoy FreeTodo! 🎉\n\n> **Note**: If ports are occupied, both frontend and backend will automatically find the next available ports. The console will show the actual ports being used.\n\nFor more details, see: [free-todo-frontend/README.md](free-todo-frontend/README.md)\n\n## 📋 TODO & Roadmap\n\n> 📖 **Full Roadmap**: Check out the detailed [Project Roadmap](.github/ROADMAP.md) to learn about the complete vision and development plan of the FreeU project.\n\n### 🎯 FreeU Overall Project Roadmap\n\n#### 1. LifeTrace (v0.2 Completed)\n- ✓ **Computer Activity Flow Construction**: Generate personal activity flows through screenshots\n- 🔮 **Future Plans**: Audio acquisition, video environment, smart device integration, local LLM optimization\n\n#### 2. Free Todo (v0.1 Currently In Progress)\n- 🚧 **Current Focus**: Building the ultimate To-Do List\n- 🎯 **Core Mission**: Fix user intentions, form personal context organization, lay the foundation for proactive services\n\n#### 3. Proactive Service Phase (Future Planning)\n- Provide proactive services based on LifeTrace data and Free Todo intentions\n\n---\n\n### 🚧 Free Todo Recent Plans (Focus on Input Layer)\n\n**Goal**: Collect as much information as possible from users' daily lives and gather it as Todos\n\n- 🎨 **UI Dynamic Island**\n  - ☐ Control voice input and screenshot scheduled task switches\n  - ☐ Provide convenient windows to access Todo list and conversation interface\n\n- 🤖 **Agent Development**\n  - 🚧 Develop AI tool scheduling capability\n  - ☐ Upgrade from basic conversation to intelligent Agent supporting multiple tool calls\n\n---\n\n### 📐 Free Todo Three-Layer Roadmap\n\n#### Input Layer: Reduce Input Burden, Thought-Stream-Like Capture\n- ☐ Voice input (Dynamic Island, hotkey activation)\n- ☐ Multimodal input (text, screenshots, voice)\n- ☑ Social software integration (WeChat, Feishu todo capture)\n- ☑ Intelligent message todo extraction\n\n#### Intermediate Processing Layer: From \"Chaos\" to \"Order\"\n- ☑ AI task breakdown (\"big rocks\" into \"small stones\")\n- ☑ AI intent completion / task detail completion\n- ☐ Automatic classification and organization\n- ☐ Intelligent task priority planning\n- ☑ Todo context construction\n\n#### Output Layer: Psychological Security + Warm, Reliable Secretary Partner\n- ☐ AI secretary personification\n- ☐ Schedule reminders (currently in progress)\n- ☐ Task focus mode (display only partial tasks)\n- ☐ Completed task reinforcement (merit ledger)\n- ☐ Overdue task re-planning\n\n---\n\n### 🔬 Features in Development\n\nFree Todo's panel switch bar contains some panels that are currently under development. These panels showcase our future feature directions for community reference and understanding.\n\n**🤝 Community Participation**: We warmly welcome community members to participate!\n- 🎨 **Panel Contributions**: Contribute your own panel designs or propose improvement suggestions\n- 🤖 **Agent Algorithm Contributions**: Contribute new Agent algorithms, we actively merge them!\n\n---\n\n### ✅ Recently Completed\n\n- ☑ **AI Task Breakdown** - Intelligent task decomposition with questionnaire flow\n- ☑ **Multi-Panel Interface** - Flexible layout with customizable panels\n- ☑ **Calendar Integration** - Day/Week/Month views with drag-and-drop\n\n---\n\n> 💡 **Want to contribute?** Check out our [Contributing Guidelines](#contributing) and pick up any TODO item that interests you!\n\n## Development Guide\n\n### Git Hooks (Pre-commit)\n\nThis repo uses a shared `.githooks/` directory. Run the setup script once per clone/worktree:\n\n```bash\n# macOS/Linux\nbash scripts/setup_hooks_here.sh\n\n# Windows (PowerShell)\npowershell -ExecutionPolicy Bypass -File scripts/setup_hooks_here.ps1\n```\n\n> **Note**: Do not run `pre-commit install` here. The repo uses `core.hooksPath` and `pre-commit install` will refuse when it is set.\n\nFor details, see: [.github/PRE_COMMIT_GUIDE.md](.github/PRE_COMMIT_GUIDE.md)\n\n### Project Structure\n\n```\n├── .github/                    # GitHub repository assets\n│   ├── assets/                 # Static assets (images for README)\n│   ├── BACKEND_GUIDELINES.md   # Backend development guidelines\n│   ├── FRONTEND_GUIDELINES.md  # Frontend development guidelines\n│   ├── CONTRIBUTING.md         # Contributing guidelines\n│   └── ...                     # Other GitHub repository files\n├── .githooks/                  # Repo-local git hooks (pre-commit, post-checkout)\n├── lifetrace/                  # Backend modules (FastAPI)\n│   ├── server.py               # Web API service entry point\n│   ├── config/                 # Configuration files\n│   │   ├── config.yaml         # Main configuration (auto-generated)\n│   │   ├── default_config.yaml # Default configuration template\n│   │   ├── prompt.yaml         # AI prompt templates\n│   │   └── rapidocr_config.yaml# OCR configuration\n│   ├── routers/                # API route handlers\n│   │   ├── chat.py             # Chat interface endpoints\n│   │   ├── todo.py             # Todo endpoints\n│   │   ├── task.py             # Task management endpoints\n│   │   └── ...                 # Other endpoints\n│   ├── schemas/                # Pydantic data models\n│   ├── services/               # Business logic service layer\n│   ├── repositories/           # Data access layer\n│   ├── storage/                # Data storage layer\n│   ├── llm/                    # LLM and AI services\n│   ├── jobs/                   # Background jobs\n│   ├── util/                   # Utility functions\n│   └── data/                   # Runtime data (generated)\n│       ├── lifetrace.db        # SQLite database\n│       ├── vector_db/          # Vector database storage\n│       └── logs/               # Application logs\n├── free-todo-frontend/         # Frontend application (Next.js) ⭐\n│   ├── app/                    # Next.js app directory\n│   ├── apps/                   # Feature modules\n│   │   ├── todo-list/          # Todo list module\n│   │   ├── todo-detail/        # Todo detail module\n│   │   ├── chat/               # AI chat module\n│   │   ├── calendar/           # Calendar module\n│   │   ├── settings/           # Settings module\n│   │   └── ...                 # Other modules\n│   ├── components/             # React components\n│   ├── lib/                    # Utilities and services\n│   ├── electron/               # Electron desktop app\n│   ├── package.json            # Frontend dependencies\n│   └── README.md               # Frontend documentation\n├── pyproject.toml              # Python project configuration\n├── uv.lock                     # uv lock file\n├── LICENSE                     # FreeU Community License\n├── README.md                   # This file (English)\n└── README_CN.md                # Chinese README\n```\n\n## Contributing\n\nThe FreeTodo community is possible thanks to thousands of kind volunteers like you. We welcome all contributions to the community and are excited to welcome you aboard.\n\n**Recent Contributions:**\n\n![GitHub contributors](https://img.shields.io/github/contributors/FreeU-group/LifeTrace) ![GitHub commit activity](https://img.shields.io/github/commit-activity/m/FreeU-group/LifeTrace) ![GitHub last commit](https://img.shields.io/github/last-commit/FreeU-group/LifeTrace)\n\n### 📚 Contributing Guidelines\n\nWe have comprehensive contributing guidelines to help you get started:\n\n- **[Contributing Guidelines](.github/CONTRIBUTING.md)** - Complete guide on how to contribute\n- **[Backend Development Guidelines](.github/BACKEND_GUIDELINES.md)** - Python/FastAPI coding standards\n- **[Frontend Development Guidelines](.github/FRONTEND_GUIDELINES.md)** - TypeScript/React coding standards\n\n### 🚀 Quick Start for Contributors\n\n1. **🍴 Fork the project** - Create your own copy of the repository\n2. **🌿 Create a feature branch** - `git checkout -b feature/amazing-feature`\n3. **💾 Commit your changes** - `git commit -m 'feat: add some amazing feature'`\n4. **📤 Push to the branch** - `git push origin feature/amazing-feature`\n5. **🔄 Create a Pull Request** - Submit your changes for review\n\n### 🎯 Areas Where You Can Contribute\n\n- 🐛 **Bug Reports** - Help us identify and fix issues\n- 💡 **Feature Requests** - Suggest new functionality\n- 📝 **Documentation** - Improve guides and tutorials\n- 🧪 **Testing** - Write tests and improve coverage\n- 🎨 **UI/UX** - Enhance the user interface\n- 🔧 **Code** - Implement new features and improvements\n\n### 🔰 Getting Started\n\n- Check out our **[Contributing Guidelines](.github/CONTRIBUTING.md)** for detailed instructions\n- Look for issues labeled `good first issue` or `help wanted`\n- Follow **[Backend Guidelines](.github/BACKEND_GUIDELINES.md)** for Python/FastAPI development\n- Follow **[Frontend Guidelines](.github/FRONTEND_GUIDELINES.md)** for TypeScript/React development\n- Join our community discussions in Issues and Pull Requests\n\nWe appreciate all contributions, no matter how small! 🙏\n\n## Join Our Community\n\nConnect with us and other FreeTodo users! Scan the QR codes below to join our community groups:\n\n<table>\n  <tr>\n    <th>WeChat Group</th>\n    <th>Feishu Group</th>\n    <th>Xiaohongshu</th>\n  </tr>\n  <tr>\n    <td align=\"center\">\n      <img src=\".github/assets/wechat.png\" alt=\"WeChat QR Code\" width=\"200\"/>\n      <br/>\n      <em>Scan to join WeChat group</em>\n    </td>\n    <td align=\"center\">\n      <img src=\".github/assets/feishu.png\" alt=\"Feishu QR Code\" width=\"200\"/>\n      <br/>\n      <em>Scan to join Feishu group</em>\n    </td>\n    <td align=\"center\">\n      <img src=\".github/assets/xhs.jpg\" alt=\"Xiaohongshu QR Code\" width=\"200\"/>\n      <br/>\n      <em>Follow us on Xiaohongshu</em>\n    </td>\n  </tr>\n</table>\n\n## Documentation\n\nWe use deepwiki to manage our docs, please refer to this [**website.**](https://deepwiki.com/FreeU-group/LifeTrace/6.2-deployment-and-setup)\n\n## Star History\n\n[![Star History Chart](https://api.star-history.com/svg?repos=FreeU-group/FreeTodo&type=Timeline)](https://www.star-history.com/#FreeU-group/FreeTodo&Timeline)\n\n## License\n\nCopyright © 2026 FreeU.org\n\nFreeTodo is licensed under the **FreeU Community License**, which is based on Apache License 2.0 with additional conditions regarding commercial usage.\n\nFor detailed license terms, please see the [LICENSE](LICENSE) file.\n"
  },
  {
    "path": "README_CN.md",
    "content": "![FreeTodo Logo](.github/assets/free_todo_banner.png)\n\n![GitHub stars](https://img.shields.io/github/stars/FreeU-group/FreeTodo?style=social) ![GitHub forks](https://img.shields.io/github/forks/FreeU-group/FreeTodo?style=social) ![GitHub issues](https://img.shields.io/github/issues/FreeU-group/FreeTodo) [![License](https://img.shields.io/badge/license-FreeU%20Community-blue.svg)](LICENSE) ![Python version](https://img.shields.io/badge/python-3.12-blue.svg) ![FastAPI](https://img.shields.io/badge/FastAPI-0.100+-green.svg)\n\n**语言**: [English](README.md) | [中文](README_CN.md)\n\n[📖 文档](https://freeyou.club/lifetrace/introduction.html) • [🚀 快速开始](#快速开始) • [💡 功能特性](#核心功能) • [🔧 开发指南](#开发指南) • [🤝 贡献指南](#贡献)\n\n# FreeTodo - 放手去做\n\n## 项目概述\n\n**FreeTodo** 是一款 AI 驱动的智能待办管理应用，帮助您高效管理任务、提升生产力、达成目标。通过对话式 AI 交互和智能任务拆分，FreeTodo 将复杂项目转化为可执行的行动步骤。\n\n## 核心功能\n\n### 🤖 AI 智能助手\n- **智能任务拆分**：AI 自动将复杂任务分解为可管理的子任务，通过引导式问卷流程完成\n- **智能任务提取**：从 AI 对话响应中提取可执行的待办事项\n- **上下文感知建议**：AI 根据当前待办上下文提供任务建议\n\n### ✅ 全面的任务管理\n- **层级任务结构**：支持父子任务关系，无限层级嵌套\n- **优先级与状态**：四级优先级（紧急/高/中/低）和多种状态\n- **标签与分类**：使用自定义标签组织待办，便于筛选\n- **截止日期管理**：设置截止日期，可视化提醒\n- **丰富备注**：为每个待办添加详细备注和描述\n\n### 📅 多视图日历\n- **日/周/月视图**：灵活的日历视图，可视化您的日程安排\n- **拖拽排期**：轻松拖拽待办到日历时间槽进行排期\n- **快速创建待办**：直接从日历时间槽创建待办\n\n### 🎨 现代化用户界面\n- **多面板布局**：可自定义的面板排列（待办 + 聊天 + 详情）\n- **深色/浅色主题**：精美主题，多种配色方案\n- **国际化支持**：完整支持中英文\n- **响应式设计**：适配各种屏幕尺寸\n\n### 💻 桌面应用\n- **Electron 应用**：Windows 和 macOS 原生桌面体验\n- **系统集成**：原生通知和系统托盘支持\n\n## 系统架构\n\nFreeTodo 采用**前后端分离**架构：\n\n- **后端**: FastAPI (Python) - 提供 RESTful API（位于 `lifetrace/` 目录）\n- **前端**: Next.js (React + TypeScript) - 现代化 Web 界面（位于 `free-todo-frontend/` 目录）\n- **数据层**: SQLite + ChromaDB（用于 AI 功能）\n\n## 快速开始\n\n### 环境要求\n\n**后端**:\n\n- Python 3.12\n- 支持的操作系统：Windows、macOS、Linux\n- 可选：CUDA 支持（用于 GPU 加速）\n\n**前端**:\n\n- Node.js 20+\n- pnpm 包管理器\n<!--\n### 一键安装并启动\n\n> 需要安装 Python 3.12+、Node.js 20+、Git；Tauri/Electron 构建还需要 Rust。\n\n**macOS/Linux**\n\n```bash\ncurl -fsSL https://raw.githubusercontent.com/FreeU-group/FreeTodo/main/scripts/install.sh | bash\n```\n\n**Windows (PowerShell)**\n\n```powershell\niwr -useb https://raw.githubusercontent.com/FreeU-group/FreeTodo/main/scripts/install.ps1 | iex\n```\n\n完整选项、环境变量和示例请见：[.github/INSTALL_CN.md](.github/INSTALL_CN.md) -->\n\n### 安装依赖\n\n本项目使用 [uv](https://github.com/astral-sh/uv) 进行快速可靠的依赖管理。\n\n**安装 uv:**\n\n```bash\n# macOS/Linux\ncurl -LsSf https://astral.sh/uv/install.sh | sh\n\n# Windows\npowershell -c \"irm https://astral.sh/uv/install.ps1 | iex\"\n```\n\n> **注意**：安装完成后，`uv` 可能无法在当前终端中立即使用。要在当前会话中激活它：\n>\n> - **Windows (PowerShell)**：运行 `$env:Path = \"$env:USERPROFILE\\.local\\bin;$env:Path\"` 来刷新 PATH\n> - **macOS/Linux**：运行 `exec $SHELL` 来重新初始化 shell 会话，或重新打开终端\n>\n> 或者，您也可以直接打开一个新的终端窗口，`uv` 将自动可用。\n\n**安装依赖并同步环境:**\n\n```bash\n# 从 pyproject.toml 和 uv.lock 同步依赖\nuv sync\n\n# 激活虚拟环境\n# macOS/Linux\nsource .venv/bin/activate\n\n# Windows\n.venv\\Scripts\\activate\n```\n\n### 启动后端服务\n\n> **注意**：首次运行时，如果 `config.yaml` 不存在，系统会自动从 `default_config.yaml` 创建。您可以通过编辑 `lifetrace/config/config.yaml` 来自定义设置。\n\n**启动服务器：**\n\n```bash\npython -m lifetrace.server\n```\n\n> **自定义提示词**：如果您想修改不同功能的 AI 提示词，可以编辑 `lifetrace/config/prompt.yaml` 文件。\n\n后端服务会自动从 `8001` 端口（构建版为 `8100`）开始查找可用端口。如果默认端口被占用，会自动使用下一个可用端口，并在控制台显示实际使用的端口。\n\n- **默认后端端口**: `http://localhost:8001`\n- **API 文档**: 实际 API 文档地址会在控制台显示（通常为 `http://localhost:8001/docs`）\n\n### 启动前端服务\n\n前端是使用 FreeTodo 的必需组件。启动前端开发服务器：\n\n```bash\ncd free-todo-frontend\n\npnpm install\npnpm dev\n```\n\n前端开发服务器会：\n- 自动从 `3001` 端口（开发版默认端口）开始查找可用端口\n- 通过检查 `/health` 端点自动检测运行中的 FreeTodo 后端端口\n- 自动设置 API 代理指向检测到的后端端口\n\n实际的前端地址和后端连接状态会在控制台显示。服务启动后，在浏览器中访问控制台显示的前端地址（通常为 `http://localhost:3001`）开始使用 FreeTodo！🎉\n\n> **注意**：如果端口被占用，前端和后端都会自动查找下一个可用端口。控制台会显示实际使用的端口。\n\n## 📋 待办事项与路线图\n\n> 📖 **完整路线图**：查看详细的 [项目路线图](.github/ROADMAP_CN.md) 了解 FreeU 项目的完整愿景和发展规划。\n\n### 🎯 FreeU 整体项目路线图\n\n#### 1. LifeTrace（v0.2 已完成）\n- ✓ **电脑活动流构建**：通过截图生成个人活动流\n- 🔮 **未来规划**：音频获取、视频环境、智能设备集成、本地大模型优化\n\n#### 2. Free Todo（v0.1 当前进行中）\n- 🚧 **当前聚焦**：打造极致的 To-Do List\n- 🎯 **核心使命**：固定用户意图、形成个人上下文整理，为主动服务打下基础\n\n#### 3. 主动服务阶段（未来规划）\n- 基于 LifeTrace 数据和 Free Todo 意图提供主动服务\n\n---\n\n### 🚧 Free Todo 近期计划（专注输入层）\n\n**目标**：尽可能从用户生活中获取各种各样的信息并收集为 Todo\n\n- 🎨 **UI 灵动岛**\n  - ☐ 控制语音输入和截图定时任务开关\n  - ☐ 提供便捷窗口访问 Todo 列表和对话界面\n\n- 🤖 **Agent 开发**\n  - 🚧 开发 AI 工具调度能力\n  - ☐ 从基础对话升级为支持多工具调用的智能 Agent\n\n---\n\n### 📐 Free Todo 三层次路线图\n\n#### 输入层：减轻输入负担，意念流般的捕获\n- ☐ 语音输入（灵动岛、快捷键呼出）\n- ☐ 多模态输入（文字、截图、语音）\n- ☑ 社交软件集成（微信、飞书等 todo 捕获）\n- ☑ 智能消息 todo 提取\n\n#### 中间处理层：从\"混沌\"到\"秩序\"\n- ☑ AI 任务拆分（\"大石头\"变\"小石子\"）\n- ☑ AI 意图补全 / 任务详情补全\n- ☐ 自动分类与组织\n- ☐ 任务优先级智能规划\n- ☑ Todo 上下文构建\n\n#### 输出层：心理安全感 + 温暖可靠的秘书伙伴\n- ☐ AI 秘书人格化\n- ☐ 日程提醒（目前正在做）\n- ☐ 任务专注模式（只显示部分任务）\n- ☐ 已完成任务强化（功劳簿化）\n- ☐ 逾期任务重新规划\n\n---\n\n### 🔬 开发中功能\n\nFree Todo 的面板开关栏里有一些正在开发中的面板，这些面板展示了我们未来的功能方向，供社区参考和了解。\n\n**🤝 社区参与**：我们非常欢迎社区成员参与进来！\n- 🎨 **面板贡献**：贡献自己的面板设计或提出改进建议\n- 🤖 **Agent 算法贡献**：贡献新的 Agent 算法，我们积极合入！\n\n---\n\n### ✅ 最近完成\n\n- ☑ **AI 任务拆分** - 通过问卷流程实现智能任务分解\n- ☑ **多面板界面** - 可自定义面板的灵活布局\n- ☑ **日历集成** - 支持拖拽的日/周/月视图\n\n---\n\n> 💡 **想要贡献？** 查看我们的[贡献指南](#贡献)并选择任何你感兴趣的待办事项！\n\n## 开发指南\n\n### Git Hooks（Pre-commit）\n\n本仓库使用共享的 `.githooks/` 目录。每个 clone/worktree 只需执行一次：\n\n```bash\n# macOS/Linux\nbash scripts/setup_hooks_here.sh\n\n# Windows（PowerShell）\npowershell -ExecutionPolicy Bypass -File scripts/setup_hooks_here.ps1\n```\n\n> **注意**：不要在此仓库里运行 `pre-commit install`。仓库使用 `core.hooksPath`，因此 `pre-commit install` 会拒绝执行。\n\n更多细节请见： [.github/PRE_COMMIT_GUIDE_CN.md](.github/PRE_COMMIT_GUIDE_CN.md)\n\n### 项目结构\n\n```\n├── .github/                    # GitHub 仓库资源\n│   ├── assets/                 # 静态资源（README 图片）\n│   ├── BACKEND_GUIDELINES.md   # 后端开发规范\n│   ├── FRONTEND_GUIDELINES.md  # 前端开发规范\n│   ├── CONTRIBUTING.md         # 贡献指南\n│   └── ...                     # 其他 GitHub 仓库文件\n├── .githooks/                  # 仓库内 Git hooks（pre-commit、post-checkout）\n├── lifetrace/                  # 后端模块 (FastAPI)\n│   ├── server.py               # Web API 服务入口\n│   ├── config/                 # 配置文件\n│   │   ├── config.yaml         # 主配置文件（自动生成）\n│   │   ├── default_config.yaml # 默认配置模板\n│   │   ├── prompt.yaml         # AI 提示词模板\n│   │   └── rapidocr_config.yaml# OCR 配置\n│   ├── routers/                # API 路由处理器\n│   │   ├── chat.py             # 聊天接口端点\n│   │   ├── todo.py             # 待办事项端点\n│   │   ├── task.py             # 任务管理端点\n│   │   └── ...                 # 其他端点\n│   ├── schemas/                # Pydantic 数据模型\n│   ├── services/               # 业务逻辑服务层\n│   ├── repositories/           # 数据访问层\n│   ├── storage/                # 数据存储层\n│   ├── llm/                    # LLM 和 AI 服务\n│   ├── jobs/                   # 后台任务\n│   ├── util/                   # 工具函数\n│   └── data/                   # 运行时数据（自动生成）\n│       ├── lifetrace.db        # SQLite 数据库\n│       ├── vector_db/          # 向量数据库存储\n│       └── logs/               # 应用日志\n├── free-todo-frontend/         # 前端应用 (Next.js) ⭐\n│   ├── app/                    # Next.js 应用目录\n│   ├── apps/                   # 功能模块\n│   │   ├── todo-list/          # 待办列表模块\n│   │   ├── todo-detail/        # 待办详情模块\n│   │   ├── chat/               # AI 聊天模块\n│   │   ├── calendar/           # 日历模块\n│   │   ├── settings/           # 设置模块\n│   │   └── ...                 # 其他模块\n│   ├── components/             # React 组件\n│   ├── lib/                    # 工具和服务\n│   ├── electron/               # Electron 桌面应用\n│   ├── package.json            # 前端依赖\n│   └── README.md               # 前端文档\n├── pyproject.toml              # Python 项目配置\n├── uv.lock                     # uv 锁定文件\n├── LICENSE                     # FreeU Community License 许可证\n├── README.md                   # 英文 README\n└── README_CN.md                # 中文 README（本文件）\n```\n\n## 贡献\n\nFreeTodo 社区的存在离不开像您这样的众多友善志愿者。我们欢迎所有对社区的贡献，并很高兴欢迎您的加入。\n\n**最近的贡献：**\n\n![GitHub contributors](https://img.shields.io/github/contributors/FreeU-group/LifeTrace) ![GitHub commit activity](https://img.shields.io/github/commit-activity/m/FreeU-group/LifeTrace) ![GitHub last commit](https://img.shields.io/github/last-commit/FreeU-group/LifeTrace)\n\n### 📚 贡献指南\n\n我们提供了完整的贡献指南帮助您开始：\n\n- **[贡献指南](.github/CONTRIBUTING_CN.md)** - 完整的贡献流程和规范\n- **[后端开发规范](.github/BACKEND_GUIDELINES_CN.md)** - Python/FastAPI 编码规范\n- **[前端开发规范](.github/FRONTEND_GUIDELINES_CN.md)** - TypeScript/React 编码规范\n\n### 🚀 快速开始贡献\n\n1. **🍴 Fork 项目** - 创建您自己的仓库副本\n2. **🌿 创建功能分支** - `git checkout -b feature/amazing-feature`\n3. **💾 提交您的更改** - `git commit -m 'feat: 添加某个很棒的功能'`\n4. **📤 推送到分支** - `git push origin feature/amazing-feature`\n5. **🔄 创建 Pull Request** - 提交您的更改以供审核\n\n### 🎯 您可以贡献的领域\n\n- 🐛 **错误报告** - 帮助我们识别和修复问题\n- 💡 **功能请求** - 建议新功能\n- 📝 **文档** - 改进指南和教程\n- 🧪 **测试** - 编写测试并提高覆盖率\n- 🎨 **UI/UX** - 增强用户界面\n- 🔧 **代码** - 实现新功能和改进\n\n### 🔰 开始贡献\n\n- 查看我们的 **[贡献指南](.github/CONTRIBUTING_CN.md)** 了解详细说明\n- 寻找标记为 `good first issue` 或 `help wanted` 的问题\n- 后端开发请遵循 **[后端开发规范](.github/BACKEND_GUIDELINES_CN.md)**\n- 前端开发请遵循 **[前端开发规范](.github/FRONTEND_GUIDELINES_CN.md)**\n- 在 Issues 和 Pull Requests 中加入我们的社区讨论\n\n我们感谢所有贡献，无论大小！🙏\n\n## 加入我们的社区\n\n与我们和其他 FreeTodo 用户联系！扫描下方二维码加入我们的社区群组：\n\n<table>\n  <tr>\n    <th>微信群</th>\n    <th>飞书群</th>\n    <th>小红书</th>\n  </tr>\n  <tr>\n    <td align=\"center\">\n      <img src=\".github/assets/wechat.png\" alt=\"微信二维码\" width=\"200\"/>\n      <br/>\n      <em>扫码加入微信群</em>\n    </td>\n    <td align=\"center\">\n      <img src=\".github/assets/feishu.png\" alt=\"飞书二维码\" width=\"200\"/>\n      <br/>\n      <em>扫码加入飞书群</em>\n    </td>\n    <td align=\"center\">\n      <img src=\".github/assets/xhs.jpg\" alt=\"小红书二维码\" width=\"200\"/>\n      <br/>\n      <em>关注我们的小红书</em>\n    </td>\n  </tr>\n</table>\n\n## 文档\n\n我们使用 deepwiki 管理文档，请参考此[**网站**](https://deepwiki.com/FreeU-group/LifeTrace/6.2-deployment-and-setup)。\n\n## Star 历史\n\n[![Star History Chart](https://api.star-history.com/svg?repos=FreeU-group/FreeTodo&type=Timeline)](https://www.star-history.com/#FreeU-group/FreeTodo&Timeline)\n\n## 许可证\n\n版权所有 © 2026 FreeU.org\n\nFreeTodo 采用 **FreeU Community License** 许可证，该许可证基于 Apache License 2.0，并附加了关于商业使用的条件。\n\n有关详细的许可证条款，请参阅 [LICENSE](LICENSE) 文件。\n"
  },
  {
    "path": "bandit.yaml",
    "content": "exclude_dirs:\n  - .venv\n  - data\n  - dist\n  - build\n  - migrations/versions\n  - lifetrace/data\n  - lifetrace/migrations/versions\n  - lifetrace/build\n  - lifetrace/dist\nskips: []\n"
  },
  {
    "path": "biome.json",
    "content": "{\n\t\"$schema\": \"https://biomejs.dev/schemas/2.3.13/schema.json\",\n\t\"files\": {\n\t\t\"includes\": [\"**\", \"!free-todo-frontend/lib/generated/**\"]\n\t},\n\t\"linter\": {\n\t\t\"enabled\": true,\n\t\t\"rules\": {\n\t\t\t\"a11y\": {\n\t\t\t\t\"useSemanticElements\": \"off\"\n\t\t\t}\n\t\t}\n\t},\n\t\"formatter\": {\n\t\t\"enabled\": false\n\t},\n\t\"css\": {\n\t\t\"parser\": {\n\t\t\t\"tailwindDirectives\": true\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "free-todo-frontend/.gitignore",
    "content": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pnp\n.pnp.*\n.pnpm-store/\n.yarn/*\n!.yarn/patches\n!.yarn/plugins\n!.yarn/releases\n!.yarn/versions\n\n# testing\n/coverage\n\n# next.js\n/.next/\n/out/\n\n# production\n/build\n\n# misc\n.DS_Store\n*.pem\n\n# debug\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n.pnpm-debug.log*\n\n# env files (can opt-in for committing if needed)\n.env*\n\n# vercel\n.vercel\n\n# typescript\n*.tsbuildinfo\nnext-env.d.ts\n\n# electron\n/dist-electron/\n/dist-electron-app/\n/dist\n/dist-artifacts/\n\n# Tauri / Rust\n/src-tauri/target/\n/src-tauri/Cargo.lock\n/src-tauri/WixTools/\n/src-tauri/gen/\n"
  },
  {
    "path": "free-todo-frontend/app/globals.css",
    "content": "@import url(\"https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap\");\n@import \"tailwindcss\";\n\n/* Tailwind v4 自定义 dark 模式变体 - 基于 .dark 类 */\n@custom-variant dark (&:where(.dark, .dark *));\n\n/* Tailwind v4 语义色令牌映射：\n * 让组件可以优雅地使用 bg-background、text-foreground、bg-primary/10 等语义类，\n * 而不需要在每个地方手写 bg-[oklch(var(--xxx)/0.xx)] 这种“细节色”。\n */\n@theme {\n\t/* 全局无衬线字体，使用 next/font 注入的 CSS 变量 */\n\t--font-sans: \"Plus Jakarta Sans\", \"Segoe UI\", -apple-system,\n\t\tBlinkMacSystemFont, \"Helvetica Neue\", Arial, sans-serif;\n\n\t--color-background: oklch(var(--background));\n\t--color-foreground: oklch(var(--foreground));\n\n\t--color-card: oklch(var(--card));\n\t--color-card-foreground: oklch(var(--card-foreground));\n\n\t--color-popover: oklch(var(--popover));\n\t--color-popover-foreground: oklch(var(--popover-foreground));\n\n\t--color-primary: oklch(var(--primary));\n\t--color-primary-foreground: oklch(var(--primary-foreground));\n\n\t--color-secondary: oklch(var(--secondary));\n\t--color-secondary-foreground: oklch(var(--secondary-foreground));\n\n\t--color-muted: oklch(var(--muted));\n\t--color-muted-foreground: oklch(var(--muted-foreground));\n\n\t--color-accent: oklch(var(--accent));\n\t--color-accent-foreground: oklch(var(--accent-foreground));\n\n\t--color-destructive: oklch(var(--destructive));\n\n\t--color-border: oklch(var(--border));\n\t--color-input: oklch(var(--input));\n\t--color-ring: oklch(var(--ring));\n\n\t--color-chart-1: oklch(var(--chart-1));\n\t--color-chart-2: oklch(var(--chart-2));\n\t--color-chart-3: oklch(var(--chart-3));\n\t--color-chart-4: oklch(var(--chart-4));\n\t--color-chart-5: oklch(var(--chart-5));\n\n\t--color-sidebar: oklch(var(--sidebar));\n\t--color-sidebar-foreground: oklch(var(--sidebar-foreground));\n\t--color-sidebar-primary: oklch(var(--sidebar-primary));\n\t--color-sidebar-primary-foreground: oklch(var(--sidebar-primary-foreground));\n\t--color-sidebar-accent: oklch(var(--sidebar-accent));\n\t--color-sidebar-accent-foreground: oklch(var(--sidebar-accent-foreground));\n\t--color-sidebar-border: oklch(var(--sidebar-border));\n\t--color-sidebar-ring: oklch(var(--sidebar-ring));\n\n\t--color-overlay: oklch(var(--overlay));\n}\n\n@layer base {\n\t:root,\n\t:root[data-color-theme=\"catppuccin\"] {\n\t\t--radius: 0.625rem;\n\t\t--background: 0.9577 0.0058 264.532;\n\t\t--foreground: 0.4352 0.0432 279.504;\n\t\t--card: 0.9577 0.0058 264.532;\n\t\t--card-foreground: 0.4352 0.0432 279.504;\n\t\t--popover: 0.9577 0.0058 264.532;\n\t\t--popover-foreground: 0.4352 0.0432 279.504;\n\t\t--primary: 0.5545 0.2503 296.969;\n\t\t--primary-foreground: 0.9577 0.0058 264.532;\n\t\t--primary-weak: 0.5545 0.2503 296.969 / 0.08;\n\t\t--primary-weak-hover: 0.5545 0.2503 296.969 / 0.18;\n\t\t--primary-border: 0.5545 0.2503 296.969;\n\t\t--secondary: 0.8575 0.0145 268.666;\n\t\t--secondary-foreground: 0.4352 0.0432 279.504;\n\t\t--muted: 0.8575 0.0145 268.666;\n\t\t--muted-foreground: 0.4923 0.0383 278.997;\n\t\t--accent: 0.8575 0.0145 268.666;\n\t\t--accent-foreground: 0.4352 0.0432 279.504;\n\t\t--destructive: 0.5505 0.2155 19.735;\n\t\t--border: 0.8083 0.0174 271.198;\n\t\t--input: 0.8083 0.0174 271.198;\n\t\t--ring: 0.4352 0.0432 279.504;\n\t\t--chart-1: 0.7571 0.0941 3.250;\n\t\t--chart-2: 0.8088 0.0536 203.148;\n\t\t--chart-3: 0.7886 0.0908 262.510;\n\t\t--chart-4: 0.8542 0.0650 75.981;\n\t\t--chart-5: 0.8215 0.0746 141.799;\n\t\t--sidebar: 0.9482 0.0075 261.511;\n\t\t--sidebar-foreground: 0.4352 0.0432 279.504;\n\t\t--sidebar-primary: 0.5545 0.2503 296.969;\n\t\t--sidebar-primary-foreground: 0.9577 0.0058 264.532;\n\t\t--sidebar-accent: 0.9577 0.0058 264.532;\n\t\t--sidebar-accent-foreground: 0.4352 0.0432 279.504;\n\t\t--sidebar-border: 0.8083 0.0174 271.198;\n\t\t--sidebar-ring: 0.8083 0.0174 271.198;\n\t\t--overlay: 0 0 0 / 0.72;\n\t}\n\n\n\n\n\n\t:root[data-color-theme=\"blue\"] {\n\t\t--radius: 1rem;\n\t\t/* 说明：\n\t\t * 这里的 CSS 变量只保存 OKLCH 的参数（可选包含 / alpha），\n\t\t * 使用时统一写成 oklch(var(--xxx))，避免出现 oklch(oklch(...)) 导致样式失效。\n\t\t */\n\t\t--background: 1 0 0;\n\t\t--foreground: 0.141 0.005 285.823;\n\t\t--card: 1 0 0;\n\t\t--card-foreground: 0.141 0.005 285.823;\n\t\t--popover: 1 0 0;\n\t\t--popover-foreground: 0.141 0.005 285.823;\n\t\t--primary: 0.488 0.243 264.376;\n\t\t--primary-foreground: 0.9538 0.0149 277.85;\n\t\t--primary-weak: 0.488 0.243 264.376 / 0.05;\n\t\t--primary-weak-hover: 0.488 0.243 264.376 / 0.18;\n\t\t--primary-border: 0.488 0.243 264.376;\n\t\t--secondary: 0.967 0.001 286.375;\n\t\t--secondary-foreground: 0.21 0.006 285.885;\n\t\t--muted: 0.967 0.001 286.375;\n\t\t--muted-foreground: 0.552 0.016 285.938;\n\t\t--accent: 0.967 0.001 286.375;\n\t\t--accent-foreground: 0.21 0.006 285.885;\n\t\t--destructive: 0.577 0.245 27.325;\n\t\t--border: 0.92 0.004 286.32;\n\t\t--input: 0.92 0.004 286.32;\n\t\t--ring: 0.708 0 0;\n\t\t--chart-1: 0.809 0.105 251.813;\n\t\t--chart-2: 0.623 0.214 259.815;\n\t\t--chart-3: 0.546 0.245 262.881;\n\t\t--chart-4: 0.488 0.243 264.376;\n\t\t--chart-5: 0.424 0.199 265.638;\n\t\t--sidebar: 0.985 0 0;\n\t\t--sidebar-foreground: 0.141 0.005 285.823;\n\t\t--sidebar-primary: 0.546 0.245 262.881;\n\t\t--sidebar-primary-foreground: 0.97 0.014 254.604;\n\t\t--sidebar-accent: 0.967 0.001 286.375;\n\t\t--sidebar-accent-foreground: 0.21 0.006 285.885;\n\t\t--sidebar-border: 0.92 0.004 286.32;\n\t\t--sidebar-ring: 0.708 0 0;\n\t\t--overlay: 0 0 0 / 0.72;\n\t}\n\n\t:root[data-color-theme=\"neutral\"] {\n\t\t--radius: 0.65rem;\n\t\t--background: 1 0 0;\n\t\t--foreground: 0.145 0 0;\n\t\t--card: 1 0 0;\n\t\t--card-foreground: 0.145 0 0;\n\t\t--popover: 1 0 0;\n\t\t--popover-foreground: 0.145 0 0;\n\t\t--primary: 0.205 0 0;\n\t\t--primary-foreground: 0.985 0 0;\n\t\t--primary-weak: 0.205 0 0 / 0.08;\n\t\t--primary-weak-hover: 0.205 0 0 / 0.12;\n\t\t--primary-border: 0.205 0 0;\n\t\t--secondary: 0.97 0 0;\n\t\t--secondary-foreground: 0.205 0 0;\n\t\t--muted: 0.97 0 0;\n\t\t--muted-foreground: 0.556 0 0;\n\t\t--accent: 0.97 0 0;\n\t\t--accent-foreground: 0.205 0 0;\n\t\t--destructive: 0.577 0.245 27.325;\n\t\t--border: 0.922 0 0;\n\t\t--input: 0.922 0 0;\n\t\t--ring: 0.708 0 0;\n\t\t--chart-1: 0.646 0.222 41.116;\n\t\t--chart-2: 0.6 0.118 184.704;\n\t\t--chart-3: 0.398 0.07 227.392;\n\t\t--chart-4: 0.828 0.189 84.429;\n\t\t--chart-5: 0.769 0.188 70.08;\n\t\t--sidebar: 0.985 0 0;\n\t\t--sidebar-foreground: 0.145 0 0;\n\t\t--sidebar-primary: 0.205 0 0;\n\t\t--sidebar-primary-foreground: 0.985 0 0;\n\t\t--sidebar-accent: 0.97 0 0;\n\t\t--sidebar-accent-foreground: 0.205 0 0;\n\t\t--sidebar-border: 0.922 0 0;\n\t\t--sidebar-ring: 0.708 0 0;\n\t\t--overlay: 0 0 0 / 0.72;\n\t}\n\n\n\n\n\t.dark,\n\t.dark[data-color-theme=\"catppuccin\"] {\n\t\t--background: 0.2429 0.0304 283.911;\n\t\t--foreground: 0.8787 0.0426 272.277;\n\t\t--card: 0.324 0.0319 281.978;\n\t\t--card-foreground: 0.8787 0.0426 272.277;\n\t\t--popover: 0.324 0.0319 281.978;\n\t\t--popover-foreground: 0.8787 0.0426 272.277;\n\t\t--primary: 0.7871 0.1187 304.769;\n\t\t--primary-foreground: 0.2429 0.0304 283.911;\n\t\t--primary-weak: 0.7871 0.1187 304.769 / 0.16;\n\t\t--primary-weak-hover: 0.7871 0.1187 304.769 / 0.3;\n\t\t--primary-border: 0.7871 0.1187 304.769;\n\t\t--secondary: 0.4037 0.032 280.152;\n\t\t--secondary-foreground: 0.8787 0.0426 272.277;\n\t\t--muted: 0.4765 0.034 278.643;\n\t\t--muted-foreground: 0.8168 0.0403 272.862;\n\t\t--accent: 0.4037 0.032 280.152;\n\t\t--accent-foreground: 0.8787 0.0426 272.277;\n\t\t--destructive: 0.704 0.191 22.216;\n\t\t--border: 0.4037 0.032 280.152;\n\t\t--input: 0.4037 0.032 280.152;\n\t\t--ring: 0.7871 0.1187 304.769;\n\t\t--chart-1: 0.7871 0.1187 304.769;\n\t\t--chart-2: 0.7664 0.1113 259.885;\n\t\t--chart-3: 0.8577 0.1092 142.715;\n\t\t--chart-4: 0.8237 0.1015 52.629;\n\t\t--chart-5: 0.9193 0.0704 86.528;\n\t\t--sidebar: 0.2155 0.0254 284.065;\n\t\t--sidebar-foreground: 0.8787 0.0426 272.277;\n\t\t--sidebar-primary: 0.7871 0.1187 304.769;\n\t\t--sidebar-primary-foreground: 0.2429 0.0304 283.911;\n\t\t--sidebar-accent: 0.324 0.0319 281.978;\n\t\t--sidebar-accent-foreground: 0.8787 0.0426 272.277;\n\t\t--sidebar-border: 0.4037 0.032 280.152;\n\t\t--sidebar-ring: 0.7871 0.1187 304.769;\n\t\t--overlay: 0 0 0 / 0.72;\n\t}\n\n\n\n\n\n\t.dark[data-color-theme=\"blue\"] {\n\t\t--background: 0.2667 0.011 254.03;\n\t\t--foreground: 0.985 0 0;\n\t\t--card: 0.21 0.006 285.885;\n\t\t--card-foreground: 0.985 0 0;\n\t\t--popover: 0.21 0.006 285.885;\n\t\t--popover-foreground: 0.985 0 0;\n\t\t--primary: 0.788 0.243 264.376;\n\t\t--primary-foreground: 0.97 0.014 254.604;\n\t\t--primary-weak: 0.788 0.243 264.376 / 0.1;\n\t\t--primary-weak-hover: 0.788 0.243 264.376 / 0.28;\n\t\t--primary-border: 0.788 0.243 264.376;\n\t\t--secondary: 0.274 0.006 286.033;\n\t\t--secondary-foreground: 0.985 0 0;\n\t\t--muted: 0.274 0.006 286.033;\n\t\t--muted-foreground: 0.705 0.015 286.067;\n\t\t--accent: 0.3408 0.0054 271.26;\n\t\t--accent-foreground: 0.985 0 0;\n\t\t--destructive: 0.704 0.191 22.216;\n\t\t--border: 1 0 0 / 10%;\n\t\t--input: 1 0 0 / 15%;\n\t\t--ring: 0.556 0 0;\n\t\t--chart-1: 0.809 0.105 251.813;\n\t\t--chart-2: 0.623 0.214 259.815;\n\t\t--chart-3: 0.546 0.245 262.881;\n\t\t--chart-4: 0.688 0.243 264.376;\n\t\t--chart-5: 0.424 0.199 265.638;\n\t\t--sidebar: 0.21 0.006 285.885;\n\t\t--sidebar-foreground: 0.985 0 0;\n\t\t--sidebar-primary: 0.623 0.214 259.815;\n\t\t--sidebar-primary-foreground: 0.97 0.014 254.604;\n\t\t--sidebar-accent: 0.274 0.006 286.033;\n\t\t--sidebar-accent-foreground: 0.985 0 0;\n\t\t--sidebar-border: 1 0 0 / 10%;\n\t\t--sidebar-ring: 0.439 0 0;\n\t\t--overlay: 0 0 0 / 0.72;\n\t}\n\n\t.dark[data-color-theme=\"neutral\"] {\n\t\t--background: 0.145 0 0;\n\t\t--foreground: 0.985 0 0;\n\t\t--card: 0.205 0 0;\n\t\t--card-foreground: 0.985 0 0;\n\t\t--popover: 0.205 0 0;\n\t\t--popover-foreground: 0.985 0 0;\n\t\t--primary: 0.922 0 0;\n\t\t--primary-foreground: 0.205 0 0;\n\t\t--primary-weak: 0.922 0 0 / 0.1;\n\t\t--primary-weak-hover: 0.922 0 0 / 0.14;\n\t\t--primary-border: 0.922 0 0;\n\t\t--secondary: 0.269 0 0;\n\t\t--secondary-foreground: 0.985 0 0;\n\t\t--muted: 0.269 0 0;\n\t\t--muted-foreground: 0.708 0 0;\n\t\t--accent: 0.269 0 0;\n\t\t--accent-foreground: 0.985 0 0;\n\t\t--destructive: 0.704 0.191 22.216;\n\t\t--border: 1 0 0 / 10%;\n\t\t--input: 1 0 0 / 15%;\n\t\t--ring: 0.556 0 0;\n\t\t--chart-1: 0.488 0.243 264.376;\n\t\t--chart-2: 0.696 0.17 162.48;\n\t\t--chart-3: 0.769 0.188 70.08;\n\t\t--chart-4: 0.627 0.265 303.9;\n\t\t--chart-5: 0.645 0.246 16.439;\n\t\t--sidebar: 0.205 0 0;\n\t\t--sidebar-foreground: 0.985 0 0;\n\t\t--sidebar-primary: 0.488 0.243 264.376;\n\t\t--sidebar-primary-foreground: 0.985 0 0;\n\t\t--sidebar-accent: 0.269 0 0;\n\t\t--sidebar-accent-foreground: 0.985 0 0;\n\t\t--sidebar-border: 1 0 0 / 10%;\n\t\t--sidebar-ring: 0.556 0 0;\n\t\t--overlay: 0 0 0 / 0.72;\n\t}\n\n\n\n\t* {\n\t\tborder-color: oklch(var(--border));\n\t\t/* 滚动条轨道和滑块的基础样式 */\n\t\tscrollbar-width: thin; /* Firefox: 使用细滚动条 */\n\t\tscrollbar-color: transparent transparent; /* Firefox: 默认透明 */\n\t}\n\n\tbody {\n\t\tbackground-color: oklch(var(--background));\n\t\tcolor: oklch(var(--foreground));\n\t\tfont-family: var(--font-sans);\n\t\tfont-weight: 500;\n\t}\n\n\t:root[data-color-theme=\"catppuccin\"] body {\n\t\tfont-weight: 600;\n\t}\n\n\t/* Webkit 浏览器 (Chrome, Safari, Edge) 滚动条样式 */\n\t*::-webkit-scrollbar {\n\t\twidth: 6px; /* 滚动条宽度减半（默认约12px） */\n\t\theight: 6px; /* 水平滚动条高度 */\n\t}\n\n\t*::-webkit-scrollbar-track {\n\t\tbackground: transparent; /* 轨道透明 */\n\t}\n\n\t/* 滚动条样式：简洁版本（无动画，浏览器不支持滚动条伪元素的过渡动画） */\n\t*::-webkit-scrollbar-thumb {\n\t\tbackground: transparent;\n\t\tborder-radius: 3px;\n\t}\n\n\t/* 移除滚动条按钮（上下箭头） */\n\t*::-webkit-scrollbar-button {\n\t\tdisplay: none;\n\t\twidth: 0;\n\t\theight: 0;\n\t}\n\n\t/* 默认状态：滚动条透明 */\n\t[data-panel] *::-webkit-scrollbar-thumb {\n\t\tbackground: transparent;\n\t}\n\n\t/* 当 panel 有 scrollbar-visible 类时：滚动条显示 */\n\t[data-panel].scrollbar-visible *::-webkit-scrollbar-thumb {\n\t\tbackground: rgba(128, 128, 128, 0.25);\n\t}\n\n\t/* hover 时增加颜色强度 */\n\t[data-panel].scrollbar-visible *::-webkit-scrollbar-thumb:hover {\n\t\tbackground: rgba(128, 128, 128, 0.4);\n\t}\n\n\t/* Firefox 滚动条显示控制 */\n\t/* 注意：Firefox 的 scrollbar-color 不支持透明度语法，使用 border 颜色 */\n\t[data-panel=\"panelA\"].scrollbar-visible *,\n\t[data-panel=\"panelB\"].scrollbar-visible *,\n\t[data-panel=\"panelC\"].scrollbar-visible * {\n\t\tscrollbar-color: oklch(var(--border)) transparent;\n\t}\n\n\t/* Text-shaped shimmer loading */\n\t.shimmer-text {\n\t\t@apply bg-clip-text text-transparent;\n\t\tbackground-image: linear-gradient(\n\t\t\t90deg,\n\t\t\trgba(148, 163, 184, 0.2),\n\t\t\trgba(148, 163, 184, 0.9),\n\t\t\trgba(148, 163, 184, 0.2)\n\t\t);\n\t\tbackground-size: 200% 100%;\n\t\tanimation: shimmer-text-move 1.5s linear infinite;\n\t}\n\n\t@keyframes shimmer-text-move {\n\t\t0% {\n\t\t\tbackground-position: 200% 0;\n\t\t}\n\t\t100% {\n\t\t\tbackground-position: -200% 0;\n\t\t}\n\t}\n}\n\n/* Driver.js custom styles for onboarding tour\n   Using high specificity selectors (html body) to override library defaults */\nhtml body .driver-popover {\n\tbackground-color: oklch(var(--card));\n\tcolor: oklch(var(--card-foreground));\n\tborder: 1px solid oklch(var(--border));\n\tborder-radius: var(--radius);\n\tbox-shadow: 0 10px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);\n}\n\nhtml body .driver-popover-title {\n\tfont-size: 1.125rem;\n\tfont-weight: 600;\n\tcolor: oklch(var(--foreground));\n}\n\nhtml body .driver-popover-description {\n\tcolor: oklch(var(--muted-foreground));\n\tline-height: 1.6;\n}\n\nhtml body .driver-popover-progress-text {\n\tcolor: oklch(var(--muted-foreground));\n\tfont-size: 0.75rem;\n}\n\nhtml body .driver-popover-prev-btn,\nhtml body .driver-popover-next-btn {\n\tbackground-color: oklch(var(--primary));\n\tcolor: oklch(var(--primary-foreground));\n\tborder: none;\n\tborder-radius: calc(var(--radius) - 4px);\n\tpadding: 0.5rem 1rem;\n\tfont-weight: 500;\n\ttransition: all 0.2s ease;\n\ttext-shadow: none;\n}\n\nhtml body .driver-popover-prev-btn:hover {\n\tbackground-color: oklch(var(--secondary));\n\tcolor: oklch(var(--primary-weak-hover-foreground));\n\tbox-shadow: 0 4px 16px -2px oklch(var(--secondary)),\n\t\t0 2px 8px -1px oklch(var(--secondary)),\n\t\tinset 0 1px 0 0 oklch(var(--secondary));\n\ttransform: translateY(-1px) scale(1.02);\n}\n\nhtml body .driver-popover-next-btn:hover {\n\tbackground-color: oklch(var(--primary));\n\tcolor: oklch(var(--primary-foreground));\n\tbox-shadow: 0 4px 16px -2px oklch(var(--primary) / 0.5),\n\t\t0 2px 8px -1px oklch(var(--primary) / 0.3),\n\t\tinset 0 1px 0 0 oklch(var(--primary-foreground) / 0.1);\n\ttransform: translateY(-1px) scale(1.02);\n}\n\nhtml body .driver-popover-prev-btn {\n\tbackground-color: oklch(var(--secondary));\n\tcolor: oklch(var(--secondary-foreground));\n}\n\nhtml body .driver-popover-close-btn {\n\tcolor: oklch(var(--muted-foreground));\n}\n\nhtml body .driver-popover-close-btn:hover {\n\tcolor: oklch(var(--foreground));\n}\n\n/* Dark mode specific adjustments */\nhtml body.dark .driver-popover {\n\tbox-shadow: 0 10px 25px -5px rgb(0 0 0 / 0.3), 0 8px 10px -6px rgb(0 0 0 / 0.3);\n}\n"
  },
  {
    "path": "free-todo-frontend/app/home/HomePageClient.tsx",
    "content": "\"use client\";\n\nimport { useTranslations } from \"next-intl\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { AppHeader } from \"@/components/layout/AppHeader\";\nimport { PanelRegion } from \"@/components/layout/PanelRegion\";\nimport { GlobalDndProvider } from \"@/lib/dnd\";\nimport { useAutoRecording } from \"@/lib/hooks/useAutoRecording\";\nimport { useOnboardingTour } from \"@/lib/hooks/useOnboardingTour\";\nimport { usePanelResize } from \"@/lib/hooks/usePanelResize\";\nimport { useWindowAdaptivePanels } from \"@/lib/hooks/useWindowAdaptivePanels\";\nimport { useConfig, useLlmStatus } from \"@/lib/query\";\nimport { getNotificationPoller } from \"@/lib/services/notification-poller\";\nimport { useNotificationStore } from \"@/lib/store/notification-store\";\nimport { useUiStore } from \"@/lib/store/ui-store\";\n\nexport default function HomePageClient() {\n\t// 全局自动录音：根据配置决定是否在应用启动时自动开始录音\n\tuseAutoRecording();\n\n\t// 使用 mounted 状态来避免 SSR 水合不匹配\n\tconst [mounted, setMounted] = useState(false);\n\tuseEffect(() => {\n\t\tsetMounted(true);\n\n\t\t// 清理可能残留的蓝色调试框\n\t\tconst debugDiv = document.getElementById(\"panel-drag-debug\");\n\t\tif (debugDiv) {\n\t\t\tdebugDiv.remove();\n\t\t}\n\n\t\treturn () => {\n\t\t\t// 组件卸载时也清理\n\t\t\tconst debugDivOnUnmount = document.getElementById(\"panel-drag-debug\");\n\t\t\tif (debugDivOnUnmount) {\n\t\t\t\tdebugDivOnUnmount.remove();\n\t\t\t}\n\t\t};\n\t}, []);\n\n\t// 默认打开三列（仍允许用户通过 BottomDock 控制）\n\tuseEffect(() => {\n\t\tconst state = useUiStore.getState();\n\t\tconst next: Partial<typeof state> = {};\n\t\tif (!state.isPanelAOpen) next.isPanelAOpen = true;\n\t\tif (!state.isPanelBOpen) next.isPanelBOpen = true;\n\t\tif (!state.isPanelCOpen) next.isPanelCOpen = true;\n\t\tif (Object.keys(next).length > 0) {\n\t\t\tuseUiStore.setState(next);\n\t\t}\n\t}, []);\n\n\tconst {\n\t\tisPanelCOpen,\n\t\tisPanelBOpen,\n\t\tpanelCWidth,\n\t\tsetPanelAWidth,\n\t\tsetPanelCWidth,\n\t} = useUiStore();\n\tconst { notifications, upsertNotification, removeNotificationsBySource } =\n\t\tuseNotificationStore();\n\tconst [isDraggingPanelA, setIsDraggingPanelA] = useState(false);\n\tconst [isDraggingPanelC, setIsDraggingPanelC] = useState(false);\n\n\t// 国际化\n\tconst t = useTranslations(\"todoExtraction\");\n\n\t// 用户引导 (Onboarding Tour)\n\tconst { startTour, hasCompletedTour } = useOnboardingTour();\n\n\t// 使用 TanStack Query 获取配置\n\tconst { data: config } = useConfig();\n\n\t// 检查 LLM 配置状态\n\tconst { data: llmStatus } = useLlmStatus();\n\n\t// 根据 LLM 配置状态显示或隐藏通知\n\tconst hasLlmConfigNotification = notifications.some(\n\t\t(notification) => notification.source === \"llm-config\",\n\t);\n\n\tuseEffect(() => {\n\t\tif (!llmStatus) return;\n\n\t\tif (!llmStatus.configured) {\n\t\t\t// LLM 未配置，显示通知提示用户去设置\n\t\t\tif (!hasLlmConfigNotification) {\n\t\t\t\tupsertNotification({\n\t\t\t\t\tid: \"llm-config-missing\",\n\t\t\t\t\ttitle: t(\"llmConfigMissing\"),\n\t\t\t\t\tcontent: t(\"llmConfigMissingHint\"),\n\t\t\t\t\ttimestamp: new Date().toISOString(),\n\t\t\t\t\tsource: \"llm-config\",\n\t\t\t\t});\n\t\t\t}\n\t\t} else if (hasLlmConfigNotification) {\n\t\t\t// LLM 已配置，清除提示通知\n\t\t\tremoveNotificationsBySource(\"llm-config\");\n\t\t}\n\t}, [llmStatus, hasLlmConfigNotification, removeNotificationsBySource, t, upsertNotification]);\n\n\tconst containerRef = useRef<HTMLDivElement | null>(null);\n\tconst setGlobalResizeCursor = useCallback((enabled: boolean) => {\n\t\tif (typeof document === \"undefined\") return;\n\t\tdocument.body.style.cursor = enabled ? \"col-resize\" : \"\";\n\t\tdocument.body.style.userSelect = enabled ? \"none\" : \"\";\n\t}, []);\n\n\t// 窗口自适应panel管理（用于完整页面模式）\n\tuseWindowAdaptivePanels(containerRef);\n\n\tuseEffect(() => {\n\t\t// 清理：防止在组件卸载时光标和选择状态残留\n\t\treturn () => setGlobalResizeCursor(false);\n\t}, [setGlobalResizeCursor]);\n\n\t// 用户引导：首次加载且未完成引导时启动 tour\n\t// 使用 ref 确保只在组件挂载时检查一次，避免 restartTour 时重复触发\n\tconst hasCheckedTourRef = useRef(false);\n\tuseEffect(() => {\n\t\tif (hasCheckedTourRef.current) return;\n\t\thasCheckedTourRef.current = true;\n\n\t\tif (!hasCompletedTour) {\n\t\t\t// 延迟启动，确保页面渲染完成\n\t\t\tconst timer = setTimeout(() => {\n\t\t\t\tstartTour();\n\t\t\t}, 800);\n\t\t\treturn () => clearTimeout(timer);\n\t\t}\n\t}, [hasCompletedTour, startTour]);\n\n\t// 初始化并管理轮询\n\tuseEffect(() => {\n\t\tconst poller = getNotificationPoller();\n\t\tconst store = useNotificationStore.getState();\n\n\t\t// 同步当前所有端点\n\t\tconst syncEndpoints = () => {\n\t\t\tconst allEndpoints = store.getAllEndpoints();\n\n\t\t\t// 更新或注册已启用的端点\n\t\t\tfor (const endpoint of allEndpoints) {\n\t\t\t\tif (endpoint.enabled) {\n\t\t\t\t\tpoller.updateEndpoint(endpoint);\n\t\t\t\t} else {\n\t\t\t\t\tpoller.unregisterEndpoint(endpoint.id);\n\t\t\t\t}\n\t\t\t}\n\t\t};\n\n\t\t// 使用 TanStack Query 获取的配置初始化 draft todo 轮询\n\t\tconst autoTodoDetectionEnabled =\n\t\t\t(config?.jobsAutoTodoDetectionEnabled as boolean) ?? false;\n\n\t\t// 注册或更新 draft todo 轮询端点\n\t\tconst existingEndpoint = store.getEndpoint(\"draft-todos\");\n\t\tif (!existingEndpoint) {\n\t\t\tstore.registerEndpoint({\n\t\t\t\tid: \"draft-todos\",\n\t\t\t\turl: \"/api/todos?status=draft&limit=1\",\n\t\t\t\tinterval: 1000, // 1秒轮询一次，实现近实时更新\n\t\t\t\tenabled: autoTodoDetectionEnabled,\n\t\t\t});\n\t\t} else if (existingEndpoint.enabled !== autoTodoDetectionEnabled) {\n\t\t\t// 配置变化时更新端点状态\n\t\t\tstore.registerEndpoint({\n\t\t\t\t...existingEndpoint,\n\t\t\t\tenabled: autoTodoDetectionEnabled,\n\t\t\t});\n\t\t}\n\n\t\tconsole.log(\n\t\t\t`[DraftTodo轮询] 自动待办检测配置: ${autoTodoDetectionEnabled ? \"已启用\" : \"已禁用\"}`,\n\t\t);\n\n\t\t// 注册 DDL 提醒轮询端点\n\t\tconst ddlReminderEndpoint = store.getEndpoint(\"ddl-reminder\");\n\t\tif (!ddlReminderEndpoint) {\n\t\t\tstore.registerEndpoint({\n\t\t\t\tid: \"ddl-reminder\",\n\t\t\t\turl: \"/api/notifications\",\n\t\t\t\tinterval: 10000, // 10秒轮询一次，显著短于后端检查间隔（30秒）\n\t\t\t\tenabled: true, // 默认启用\n\t\t\t});\n\t\t\tconsole.log(\"[DDL提醒轮询] 已注册，间隔: 10秒\");\n\t\t}\n\n\t\t// 初始同步\n\t\tsyncEndpoints();\n\n\t\t// 订阅端点变化\n\t\tconst unsubscribe = useNotificationStore.subscribe(() => {\n\t\t\tsyncEndpoints();\n\t\t});\n\n\t\t// 清理函数\n\t\treturn () => {\n\t\t\tunsubscribe();\n\t\t};\n\t}, [config]);\n\n\t// 使用自定义 hooks 管理 Panel 调整大小\n\tconst { handlePanelAResizePointerDown, handlePanelCResizePointerDown } =\n\t\tusePanelResize({\n\t\t\tcontainerRef,\n\t\t\tisPanelBOpen,\n\t\t\tisPanelCOpen,\n\t\t\tpanelCWidth,\n\t\t\tsetPanelAWidth,\n\t\t\tsetPanelCWidth,\n\t\t\tsetIsDraggingPanelA,\n\t\t\tsetIsDraggingPanelC,\n\t\t\tsetGlobalResizeCursor,\n\t\t});\n\n\treturn (\n\t\t<GlobalDndProvider>\n\t\t\t<main\n\t\t\t\tclassName=\"relative flex h-screen flex-col overflow-hidden text-foreground\"\n\t\t\t\tstyle={{\n\t\t\t\t\tbackgroundColor: \"oklch(var(--background))\",\n\t\t\t\t\tbackground: \"oklch(var(--background))\",\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"relative flex h-screen flex-col text-foreground\"\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\tbackgroundColor: \"oklch(var(--background))\",\n\t\t\t\t\t\tbackground: \"oklch(var(--background))\",\n\t\t\t\t\t\theight: \"100vh\",\n\t\t\t\t\t\twidth: \"100vw\",\n\t\t\t\t\t\toverflow: \"hidden\",\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t<AppHeader hasNotifications={notifications.length > 0} />\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"flex-1 min-h-0 overflow-hidden\"\n\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\tbackgroundColor: \"oklch(var(--background))\",\n\t\t\t\t\t\t\tbackground: \"oklch(var(--background))\",\n\t\t\t\t\t\t\theight: \"calc(100vh - 80px)\",\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t<PanelRegion\n\t\t\t\t\t\t\twidth={mounted ? window.innerWidth : 1920}\n\t\t\t\t\t\t\tisMaximizeMode={true}\n\t\t\t\t\t\t\tisInPanelMode={false}\n\t\t\t\t\t\t\tisDraggingPanelA={isDraggingPanelA}\n\t\t\t\t\t\t\tisDraggingPanelC={isDraggingPanelC}\n\t\t\t\t\t\t\tisResizingPanel={false}\n\t\t\t\t\t\t\tonPanelAResizePointerDown={handlePanelAResizePointerDown}\n\t\t\t\t\t\t\tonPanelCResizePointerDown={handlePanelCResizePointerDown}\n\t\t\t\t\t\t\tcontainerRef={containerRef}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</main>\n\t\t</GlobalDndProvider>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/app/home/HomePageEntry.tsx",
    "content": "\"use client\";\n\nimport dynamic from \"next/dynamic\";\nimport { FrontendBoot } from \"@/components/common/ui/FrontendBoot\";\n\nconst HomePageClient = dynamic(() => import(\"./HomePageClient\"), {\n\tssr: false,\n\tloading: () => <FrontendBoot />,\n});\n\nexport function HomePageEntry() {\n\treturn <HomePageClient />;\n}\n"
  },
  {
    "path": "free-todo-frontend/app/island/island.css",
    "content": "/* Island 页面专用样式 */\n/* 注意: globals.css 已在 layout.tsx 中导入，提供 CSS 变量和主题支持 */\n\n/* 形态1/2: 确保透明背景（用于悬浮窗口） */\n/* 仅在 island-container 存在时设置透明背景 */\nhtml:has(.island-container),\nhtml:has(.island-container) body {\n  background-color: transparent;\n}\n\n.island-root {\n  width: 100%;\n  height: 100vh;\n  overflow: hidden;\n  font-family: \"Plus Jakarta Sans\", \"Segoe UI\", -apple-system, BlinkMacSystemFont,\n    \"Helvetica Neue\", Arial, sans-serif;\n}\n\n/* 隐藏滚动条但保留滚动功能 */\n.scrollbar-hide::-webkit-scrollbar {\n  display: none;\n}\n\n.scrollbar-hide {\n  -ms-overflow-style: none;\n  scrollbar-width: none;\n}\n\n/* Island 容器 - 用于形态1/2的透明悬浮窗 */\n.island-container {\n  width: 100%;\n  height: 100%;\n  position: relative;\n  overflow: hidden;\n  background: transparent;\n  color: oklch(var(--foreground));\n}\n\n/* 形态3/4: 使用 FreeTodo 主题背景 */\n.island-root:has(.bg-background) {\n  background: oklch(var(--background));\n}\n\n/* app-region 用于窗口拖拽 */\n.app-region-drag {\n  -webkit-app-region: drag;\n}\n\n.app-region-no-drag {\n  -webkit-app-region: no-drag;\n}\n"
  },
  {
    "path": "free-todo-frontend/app/island/layout.tsx",
    "content": "import type { Metadata } from \"next\";\nimport { NextIntlClientProvider } from \"next-intl\";\nimport { getLocale, getMessages } from \"next-intl/server\";\nimport { ThemeProvider } from \"@/components/common/theme/ThemeProvider\";\nimport { CapabilitiesSync } from \"@/components/common/ui/CapabilitiesSync\";\nimport { QueryProvider } from \"@/lib/query/provider\";\nimport \"@/app/globals.css\";\nimport \"./island.css\";\n\nexport const metadata: Metadata = {\n  title: \"Dynamic Island\",\n  description: \"FreeTodo Dynamic Island Widget\",\n};\n\n/**\n * Island 页面独立布局\n * 包含必要的 Provider 以支持 FreeTodo 组件\n * 注意：不使用独立的 html/body，而是作为子布局\n */\nexport default async function IslandLayout({\n  children,\n}: {\n  children: React.ReactNode;\n}) {\n  const locale = await getLocale();\n  const messages = await getMessages();\n\n  return (\n    <div className=\"island-root\">\n      <QueryProvider>\n        <NextIntlClientProvider messages={messages} locale={locale}>\n          <ThemeProvider>\n            <CapabilitiesSync />\n            {children}\n          </ThemeProvider>\n        </NextIntlClientProvider>\n      </QueryProvider>\n    </div>\n  );\n}\n"
  },
  {
    "path": "free-todo-frontend/app/island/page.tsx",
    "content": "\"use client\";\n\nimport { useCallback, useEffect, useState } from \"react\";\nimport DynamicIsland from \"@/components/island/DynamicIsland\";\nimport { IslandFullscreenContent } from \"@/components/island/IslandFullscreenContent\";\nimport { IslandSidebarContent } from \"@/components/island/IslandSidebarContent\";\nimport { IslandMode } from \"@/lib/island/types\";\n\n/**\n * Island 页面组件\n * 作为 Dynamic Island 窗口的入口点\n *\n * 形态1/2: 使用 DynamicIsland 动画组件\n * 形态3/4: 直接渲染面板内容，保持与原 FreeTodo 一致的外观\n */\nexport default function IslandPage() {\n  const [mode, setMode] = useState<IslandMode>(IslandMode.FLOAT);\n\n  // 模式切换处理函数（供子组件调用）\n  const handleModeChange = useCallback((newMode: IslandMode) => {\n    setMode(newMode);\n  }, []);\n\n  // 键盘快捷键监听\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      switch (e.key) {\n        case \"1\":\n          setMode(IslandMode.FLOAT);\n          break;\n        case \"2\":\n          setMode(IslandMode.POPUP);\n          break;\n        case \"3\":\n          setMode(IslandMode.SIDEBAR);\n          break;\n        case \"4\":\n          setMode(IslandMode.FULLSCREEN);\n          break;\n        case \"Escape\":\n          // 逐级退出：全屏 -> 侧边栏 -> 悬浮 -> 隐藏\n          if (mode === IslandMode.FULLSCREEN) {\n            setMode(IslandMode.SIDEBAR);\n          } else if (mode === IslandMode.SIDEBAR || mode === IslandMode.POPUP) {\n            setMode(IslandMode.FLOAT);\n          } else if (mode === IslandMode.FLOAT) {\n            // 隐藏 Island 窗口\n            if (typeof window !== \"undefined\" && window.electronAPI?.islandHide) {\n              window.electronAPI.islandHide();\n            }\n          }\n          break;\n        default:\n          break;\n      }\n    };\n\n    window.addEventListener(\"keydown\", handleKeyDown);\n    return () => window.removeEventListener(\"keydown\", handleKeyDown);\n  }, [mode]);\n\n  // 模式变化时通知 Electron 调整窗口大小\n  useEffect(() => {\n    if (typeof window !== \"undefined\" && window.electronAPI?.islandResizeWindow) {\n      window.electronAPI.islandResizeWindow(mode);\n    }\n  }, [mode]);\n\n  // 监听来自主窗口的模式切换消息\n  useEffect(() => {\n    const handleMessage = (event: MessageEvent) => {\n      if (event.data?.type === \"island:set-mode\" && event.data?.mode) {\n        const newMode = event.data.mode as IslandMode;\n        if (Object.values(IslandMode).includes(newMode)) {\n          setMode(newMode);\n        }\n      }\n    };\n\n    window.addEventListener(\"message\", handleMessage);\n    return () => window.removeEventListener(\"message\", handleMessage);\n  }, []);\n\n  // 形态3: 侧边栏模式 - 直接渲染面板内容\n  if (mode === IslandMode.SIDEBAR) {\n    return (\n      <div className=\"w-full h-full bg-background\">\n        <IslandSidebarContent onModeChange={handleModeChange} />\n      </div>\n    );\n  }\n\n  // 形态4: 全屏模式 - 直接渲染完整面板布局\n  if (mode === IslandMode.FULLSCREEN) {\n    return (\n      <div className=\"w-full h-full bg-background\">\n        <IslandFullscreenContent onModeChange={handleModeChange} />\n      </div>\n    );\n  }\n\n  // 形态1/2: 悬浮/弹出模式 - 使用 DynamicIsland 动画组件\n  return (\n    <div className=\"island-container\">\n      <DynamicIsland\n        mode={mode}\n        onModeChange={handleModeChange}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "free-todo-frontend/app/layout.tsx",
    "content": "import type { Metadata } from \"next\";\nimport { NextIntlClientProvider } from \"next-intl\";\nimport { getLocale, getMessages } from \"next-intl/server\";\nimport { ThemeProvider } from \"@/components/common/theme/ThemeProvider\";\nimport { BackendReadyGate } from \"@/components/common/ui/BackendReadyGate\";\nimport { CapabilitiesSync } from \"@/components/common/ui/CapabilitiesSync\";\nimport { DockTriggerZone } from \"@/components/common/ui/DockTriggerZone\";\nimport { LocaleSync } from \"@/components/common/ui/LocaleSync\";\nimport { ScrollbarController } from \"@/components/common/ui/ScrollbarController\";\nimport { QueryProvider } from \"@/lib/query/provider\";\nimport \"./globals.css\";\nimport \"driver.js/dist/driver.css\";\n\ninterface RootLayoutProps {\n\tchildren: React.ReactNode;\n}\n\nexport const metadata: Metadata = {\n\ttitle: \"Free Todo\",\n\tdescription: \"A todo app that tracks your life.\",\n};\n\nexport default async function RootLayout({ children }: RootLayoutProps) {\n\tconst locale = await getLocale();\n\tconst messages = await getMessages();\n\n\treturn (\n\t\t<html lang={locale} suppressHydrationWarning>\n\t\t\t<body\n\t\t\t\tclassName=\"min-h-screen bg-background text-foreground antialiased\"\n\t\t\t\tsuppressHydrationWarning\n\t\t\t>\n\t\t\t\t<ScrollbarController />\n\t\t\t\t<QueryProvider>\n\t\t\t\t\t<NextIntlClientProvider messages={messages}>\n\t\t\t\t\t\t<LocaleSync />\n\t\t\t\t\t\t<CapabilitiesSync />\n\t\t\t\t\t\t<DockTriggerZone />\n\t\t\t\t\t\t<ThemeProvider>\n\t\t\t\t\t\t\t<BackendReadyGate>{children}</BackendReadyGate>\n\t\t\t\t\t\t</ThemeProvider>\n\t\t\t\t\t</NextIntlClientProvider>\n\t\t\t\t</QueryProvider>\n\t\t\t</body>\n\t\t</html>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/app/page.tsx",
    "content": "import { HomePageEntry } from \"./home/HomePageEntry\";\n\nexport default function HomePage() {\n\treturn <HomePageEntry />;\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/achievements/AchievementsPanel.tsx",
    "content": "\"use client\";\n\nimport { Award, Star, Target, Trophy } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport { PanelHeader } from \"@/components/common/layout/PanelHeader\";\n\n/**\n * 成就面板组件\n * 用于展示游戏化的成就系统\n */\nexport function AchievementsPanel() {\n\tconst t = useTranslations(\"page\");\n\tconst tAchievements = useTranslations(\"achievements\");\n\n\treturn (\n\t\t<div className=\"relative flex h-full flex-col overflow-hidden bg-background\">\n\t\t\t{/* 顶部标题栏 */}\n\t\t\t<PanelHeader icon={Award} title={t(\"achievementsLabel\")} />\n\n\t\t\t{/* 成就内容区域 */}\n\t\t\t<div className=\"flex-1 overflow-y-auto px-4 py-6\">\n\t\t\t\t{/* 占位内容 - 后续可替换为实际的成就系统 */}\n\t\t\t\t<div className=\"flex flex-col items-center justify-center h-full text-center\">\n\t\t\t\t\t<div className=\"mb-6 flex items-center justify-center\">\n\t\t\t\t\t\t<div className=\"relative\">\n\t\t\t\t\t\t\t<div className=\"absolute inset-0 rounded-full bg-yellow-500/20 blur-2xl\" />\n\t\t\t\t\t\t\t<div className=\"relative rounded-full bg-linear-to-br from-yellow-400 to-orange-500 p-6\">\n\t\t\t\t\t\t\t\t<Trophy className=\"h-12 w-12 text-white\" />\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t<h3 className=\"mb-2 text-xl font-semibold text-foreground\">\n\t\t\t\t\t\t{tAchievements(\"title\")}\n\t\t\t\t\t</h3>\n\t\t\t\t\t<p className=\"mb-8 max-w-md text-sm text-muted-foreground\">\n\t\t\t\t\t\t{tAchievements(\"placeholder\")}\n\t\t\t\t\t</p>\n\n\t\t\t\t\t{/* 示例成就卡片 */}\n\t\t\t\t\t<div className=\"grid w-full max-w-2xl grid-cols-1 gap-4 md:grid-cols-2\">\n\t\t\t\t\t\t{/* 示例成就 1 */}\n\t\t\t\t\t\t<div className=\"group relative overflow-hidden rounded-lg border border-border bg-card p-4 transition-all hover:shadow-md\">\n\t\t\t\t\t\t\t<div className=\"flex items-start gap-3\">\n\t\t\t\t\t\t\t\t<div className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary/10\">\n\t\t\t\t\t\t\t\t\t<Star className=\"h-5 w-5 text-primary\" />\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div className=\"flex-1\">\n\t\t\t\t\t\t\t\t\t<h4 className=\"mb-1 text-sm font-medium text-foreground\">\n\t\t\t\t\t\t\t\t\t\t{tAchievements(\"achievement1.name\")}\n\t\t\t\t\t\t\t\t\t</h4>\n\t\t\t\t\t\t\t\t\t<p className=\"text-xs text-muted-foreground\">\n\t\t\t\t\t\t\t\t\t\t{tAchievements(\"achievement1.description\")}\n\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t\t<div className=\"mt-2 h-1.5 w-full overflow-hidden rounded-full bg-muted\">\n\t\t\t\t\t\t\t\t\t\t<div className=\"h-full w-0 bg-primary transition-all\" />\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t{/* 示例成就 2 */}\n\t\t\t\t\t\t<div className=\"group relative overflow-hidden rounded-lg border border-border bg-card p-4 transition-all hover:shadow-md opacity-50\">\n\t\t\t\t\t\t\t<div className=\"flex items-start gap-3\">\n\t\t\t\t\t\t\t\t<div className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-purple-500/10\">\n\t\t\t\t\t\t\t\t\t<Target className=\"h-5 w-5 text-purple-500\" />\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div className=\"flex-1\">\n\t\t\t\t\t\t\t\t\t<h4 className=\"mb-1 text-sm font-medium text-foreground\">\n\t\t\t\t\t\t\t\t\t\t{tAchievements(\"achievement2.name\")}\n\t\t\t\t\t\t\t\t\t</h4>\n\t\t\t\t\t\t\t\t\t<p className=\"text-xs text-muted-foreground\">\n\t\t\t\t\t\t\t\t\t\t{tAchievements(\"achievement2.description\")}\n\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t\t<div className=\"mt-2 h-1.5 w-full overflow-hidden rounded-full bg-muted\">\n\t\t\t\t\t\t\t\t\t\t<div className=\"h-full w-0 bg-purple-500 transition-all\" />\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t{/* 示例成就 3 */}\n\t\t\t\t\t\t<div className=\"group relative overflow-hidden rounded-lg border border-border bg-card p-4 transition-all hover:shadow-md opacity-50\">\n\t\t\t\t\t\t\t<div className=\"flex items-start gap-3\">\n\t\t\t\t\t\t\t\t<div className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-green-500/10\">\n\t\t\t\t\t\t\t\t\t<Award className=\"h-5 w-5 text-green-500\" />\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div className=\"flex-1\">\n\t\t\t\t\t\t\t\t\t<h4 className=\"mb-1 text-sm font-medium text-foreground\">\n\t\t\t\t\t\t\t\t\t\t{tAchievements(\"achievement3.name\")}\n\t\t\t\t\t\t\t\t\t</h4>\n\t\t\t\t\t\t\t\t\t<p className=\"text-xs text-muted-foreground\">\n\t\t\t\t\t\t\t\t\t\t{tAchievements(\"achievement3.description\")}\n\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t\t<div className=\"mt-2 h-1.5 w-full overflow-hidden rounded-full bg-muted\">\n\t\t\t\t\t\t\t\t\t\t<div className=\"h-full w-0 bg-green-500 transition-all\" />\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t{/* 示例成就 4 */}\n\t\t\t\t\t\t<div className=\"group relative overflow-hidden rounded-lg border border-border bg-card p-4 transition-all hover:shadow-md opacity-50\">\n\t\t\t\t\t\t\t<div className=\"flex items-start gap-3\">\n\t\t\t\t\t\t\t\t<div className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-orange-500/10\">\n\t\t\t\t\t\t\t\t\t<Trophy className=\"h-5 w-5 text-orange-500\" />\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div className=\"flex-1\">\n\t\t\t\t\t\t\t\t\t<h4 className=\"mb-1 text-sm font-medium text-foreground\">\n\t\t\t\t\t\t\t\t\t\t{tAchievements(\"achievement4.name\")}\n\t\t\t\t\t\t\t\t\t</h4>\n\t\t\t\t\t\t\t\t\t<p className=\"text-xs text-muted-foreground\">\n\t\t\t\t\t\t\t\t\t\t{tAchievements(\"achievement4.description\")}\n\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t\t<div className=\"mt-2 h-1.5 w-full overflow-hidden rounded-full bg-muted\">\n\t\t\t\t\t\t\t\t\t\t<div className=\"h-full w-0 bg-orange-500 transition-all\" />\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/activity/ActivityCard.tsx",
    "content": "import { Clock3 } from \"lucide-react\";\nimport { forwardRef } from \"react\";\nimport type { Activity } from \"@/lib/types\";\nimport { cn } from \"@/lib/utils\";\n\ninterface ActivityCardProps {\n\tactivity: Activity;\n\tisSelected?: boolean;\n\ttimeLabel: string;\n\tonSelect?: (activity: Activity) => void;\n}\n\nexport const ActivityCard = forwardRef<HTMLButtonElement, ActivityCardProps>(\n\tfunction ActivityCard(\n\t\t{ activity, isSelected = false, timeLabel, onSelect },\n\t\tref,\n\t) {\n\t\tconst title = activity.aiTitle || `Activity #${activity.id}`;\n\t\tconst summary = activity.aiSummary || \"No summary available\";\n\n\t\treturn (\n\t\t\t<button\n\t\t\t\tref={ref}\n\t\t\t\ttype=\"button\"\n\t\t\t\tonClick={() => onSelect?.(activity)}\n\t\t\t\tclassName={cn(\n\t\t\t\t\t\"group relative w-full rounded-md border px-2.5 py-2 pl-6 text-left transition\",\n\t\t\t\t\t\"border-border bg-card\",\n\t\t\t\t\t\"hover:border-primary/50 hover:bg-secondary\",\n\t\t\t\t\tisSelected && \"border-primary/70 bg-secondary ring-1 ring-primary/40\",\n\t\t\t\t)}\n\t\t\t>\n\t\t\t\t{/* 时间线指示器 - 竖条和蓝点 */}\n\t\t\t\t<div className=\"absolute left-0 top-0 bottom-0 w-3 flex items-center justify-center\">\n\t\t\t\t\t{/* 竖条 - 居中显示 */}\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\"absolute left-1 top-0 bottom-0 w-0.5 transition-colors rounded-full\",\n\t\t\t\t\t\t\tisSelected\n\t\t\t\t\t\t\t\t? \"bg-primary\"\n\t\t\t\t\t\t\t\t: \"bg-border/30 group-hover:bg-border/50\",\n\t\t\t\t\t\t)}\n\t\t\t\t\t/>\n\t\t\t\t\t{/* 蓝点 - 仅在选中时显示 */}\n\t\t\t\t\t{isSelected && (\n\t\t\t\t\t\t<div className=\"absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-2 h-2 rounded-full bg-primary shadow-md shadow-primary/50 z-10\" />\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t\t<div className=\"flex flex-col gap-1\">\n\t\t\t\t\t<div className=\"flex items-start justify-between gap-2\">\n\t\t\t\t\t\t<div className=\"flex-1 min-w-0\">\n\t\t\t\t\t\t\t<p className=\"text-xs font-semibold text-foreground line-clamp-1 leading-tight\">\n\t\t\t\t\t\t\t\t{title}\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t<p className=\"mt-0.5 text-[10px] text-muted-foreground line-clamp-1 leading-tight opacity-0 group-hover:opacity-100 transition-opacity\">\n\t\t\t\t\t\t\t\t{summary}\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t<div className=\"flex items-center gap-1.5 text-[10px] text-muted-foreground\">\n\t\t\t\t\t\t<Clock3 className=\"h-3 w-3 shrink-0\" />\n\t\t\t\t\t\t<span className=\"truncate\">{timeLabel}</span>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</button>\n\t\t);\n\t},\n);\n"
  },
  {
    "path": "free-todo-frontend/apps/activity/ActivityDetail.tsx",
    "content": "import { AppWindow, Clock3, ListChecks } from \"lucide-react\";\nimport ReactMarkdown from \"react-markdown\";\nimport remarkGfm from \"remark-gfm\";\nimport { ActivitySummary } from \"@/apps/activity/ActivitySummary\";\nimport {\n\tformatRelativeTime,\n\tformatTimeRange,\n} from \"@/apps/activity/utils/timeUtils\";\nimport type { Activity, Event } from \"@/lib/types\";\nimport { cn } from \"@/lib/utils\";\n\ninterface ActivityDetailProps {\n\tactivity?: Activity | null;\n\tevents?: Event[];\n\tloading?: boolean;\n}\n\nexport function ActivityDetail({\n\tactivity,\n\tevents = [],\n\tloading = false,\n}: ActivityDetailProps) {\n\tif (loading) {\n\t\treturn (\n\t\t\t<div className=\"flex h-full items-center justify-center rounded-xl border border-border bg-card text-sm text-muted-foreground\">\n\t\t\t\tLoading activity...\n\t\t\t</div>\n\t\t);\n\t}\n\n\tif (!activity) {\n\t\treturn (\n\t\t\t<div className=\"flex h-full items-center justify-center rounded-xl border border-dashed border-border bg-card text-sm text-muted-foreground\">\n\t\t\t\tSelect an activity to view details.\n\t\t\t</div>\n\t\t);\n\t}\n\n\tconst title = activity.aiTitle || `Activity #${activity.id}`;\n\tconst timeRange = formatTimeRange(activity.startTime, activity.endTime);\n\tconst relative = formatRelativeTime(activity.startTime);\n\tconst uniqueApps = Array.from(\n\t\tnew Set(events.map((e) => e.appName).filter(Boolean)),\n\t).slice(0, 6);\n\n\treturn (\n\t\t<section className=\"flex h-full flex-col gap-4 rounded-xl border border-border bg-card p-5 shadow-xl overflow-hidden\">\n\t\t\t<div className=\"shrink-0 space-y-3\">\n\t\t\t\t<h1 className=\"text-xl font-semibold text-foreground\">{title}</h1>\n\t\t\t\t<div className=\"flex flex-wrap items-center gap-2 text-xs text-foreground\">\n\t\t\t\t\t<span className=\"inline-flex items-center gap-1 rounded-full border border-border bg-secondary px-3 py-1\">\n\t\t\t\t\t\t<Clock3 className=\"h-4 w-4 text-primary\" />\n\t\t\t\t\t\t{timeRange}\n\t\t\t\t\t</span>\n\t\t\t\t\t<span className=\"inline-flex items-center gap-1 rounded-full border border-border bg-secondary px-3 py-1\">\n\t\t\t\t\t\t<ListChecks className=\"h-4 w-4 text-emerald-500\" />\n\t\t\t\t\t\t{activity.eventCount ?? 0} Events\n\t\t\t\t\t</span>\n\t\t\t\t\t<span className=\"inline-flex items-center gap-1 rounded-full border border-border bg-secondary px-3 py-1\">\n\t\t\t\t\t\t<Clock3 className=\"h-4 w-4 text-muted-foreground\" />\n\t\t\t\t\t\t{relative}\n\t\t\t\t\t</span>\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t<div className=\"shrink-0 flex flex-wrap items-center gap-2\">\n\t\t\t\t{uniqueApps.length === 0 ? (\n\t\t\t\t\t<span className=\"text-xs text-muted-foreground\">Apps: N/A</span>\n\t\t\t\t) : (\n\t\t\t\t\tuniqueApps.map((app) => (\n\t\t\t\t\t\t<span\n\t\t\t\t\t\t\tkey={app}\n\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\"inline-flex items-center gap-2 rounded-full border border-primary/30\",\n\t\t\t\t\t\t\t\t\"bg-primary/10 px-3 py-1 text-xs font-medium text-primary\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<AppWindow className=\"h-4 w-4\" />\n\t\t\t\t\t\t\t{app}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t))\n\t\t\t\t)}\n\t\t\t</div>\n\n\t\t\t<div className=\"flex-1 min-h-0 flex flex-col\">\n\t\t\t\t<ActivitySummary summary={activity.aiSummary} />\n\t\t\t</div>\n\n\t\t\t{events.length > 0 && (\n\t\t\t\t<div className=\"shrink-0 space-y-3 rounded-lg border border-border bg-secondary p-4\">\n\t\t\t\t\t<h4 className=\"text-sm font-semibold text-foreground\">Events</h4>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"space-y-2 max-h-[200px] overflow-y-auto\"\n\t\t\t\t\t\tstyle={{ scrollbarWidth: \"thin\" }}\n\t\t\t\t\t>\n\t\t\t\t\t\t{events.map((ev) => (\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tkey={ev.id}\n\t\t\t\t\t\t\t\tclassName=\"rounded-lg border border-border bg-card p-3 text-sm text-foreground\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<div className=\"flex items-center justify-between text-xs text-muted-foreground\">\n\t\t\t\t\t\t\t\t\t<span>#{ev.id}</span>\n\t\t\t\t\t\t\t\t\t<span>{formatRelativeTime(ev.startTime)}</span>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<p className=\"mt-1 text-[13px] font-semibold text-foreground line-clamp-1\">\n\t\t\t\t\t\t\t\t\t{ev.aiTitle || ev.windowTitle || \"Untitled Event\"}\n\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t<div className=\"mt-1 text-xs text-muted-foreground prose prose-sm max-w-none line-clamp-3\">\n\t\t\t\t\t\t\t\t\t<ReactMarkdown\n\t\t\t\t\t\t\t\t\t\tremarkPlugins={[remarkGfm]}\n\t\t\t\t\t\t\t\t\t\tcomponents={{\n\t\t\t\t\t\t\t\t\t\t\tp: ({ node, ...props }) => (\n\t\t\t\t\t\t\t\t\t\t\t\t<p\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"text-xs text-muted-foreground leading-snug my-1\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t{...props}\n\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t\t\t\tul: ({ node, ...props }) => (\n\t\t\t\t\t\t\t\t\t\t\t\t<ul\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"list-disc list-inside text-xs text-muted-foreground my-1 space-y-0.5\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t{...props}\n\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t\t\t\tol: ({ node, ...props }) => (\n\t\t\t\t\t\t\t\t\t\t\t\t<ol\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"list-decimal list-inside text-xs text-muted-foreground my-1 space-y-0.5\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t{...props}\n\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t\t\t\tli: ({ node, ...props }) => (\n\t\t\t\t\t\t\t\t\t\t\t\t<li\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"text-xs text-muted-foreground\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t{...props}\n\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t\t\t\tstrong: ({ node, ...props }) => (\n\t\t\t\t\t\t\t\t\t\t\t\t<strong\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"font-semibold text-foreground\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t{...props}\n\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t\t\t\tem: ({ node, ...props }) => (\n\t\t\t\t\t\t\t\t\t\t\t\t<em\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"italic text-muted-foreground\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t{...props}\n\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{ev.aiSummary || \"No summary\"}\n\t\t\t\t\t\t\t\t\t</ReactMarkdown>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t))}\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</section>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/activity/ActivityHeader.tsx",
    "content": "\"use client\";\n\nimport { Activity, Search } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport { useEffect, useRef, useState } from \"react\";\nimport {\n\tPanelActionButton,\n\tPanelHeader,\n\tusePanelIconStyle,\n} from \"@/components/common/layout/PanelHeader\";\nimport { cn } from \"@/lib/utils\";\n\ninterface ActivityHeaderProps {\n\tsearchValue: string;\n\tonSearchChange: (value: string) => void;\n}\n\nexport function ActivityHeader({\n\tsearchValue,\n\tonSearchChange,\n}: ActivityHeaderProps) {\n\tconst t = useTranslations(\"page\");\n\tconst [isSearchOpen, setIsSearchOpen] = useState(false);\n\tconst searchInputRef = useRef<HTMLInputElement>(null);\n\tconst searchContainerRef = useRef<HTMLDivElement>(null);\n\tconst actionIconStyle = usePanelIconStyle(\"action\");\n\n\tuseEffect(() => {\n\t\tif (isSearchOpen && searchInputRef.current) {\n\t\t\tsearchInputRef.current.focus();\n\t\t}\n\t}, [isSearchOpen]);\n\n\tuseEffect(() => {\n\t\tconst handleClickOutside = (event: MouseEvent) => {\n\t\t\tif (\n\t\t\t\tsearchContainerRef.current &&\n\t\t\t\t!searchContainerRef.current.contains(event.target as Node) &&\n\t\t\t\t!searchValue\n\t\t\t) {\n\t\t\t\tsetIsSearchOpen(false);\n\t\t\t}\n\t\t};\n\n\t\tif (isSearchOpen) {\n\t\t\tdocument.addEventListener(\"mousedown\", handleClickOutside);\n\t\t\treturn () => {\n\t\t\t\tdocument.removeEventListener(\"mousedown\", handleClickOutside);\n\t\t\t};\n\t\t}\n\t}, [isSearchOpen, searchValue]);\n\n\treturn (\n\t\t<PanelHeader\n\t\t\ticon={Activity}\n\t\t\ttitle={t(\"activityLabel\")}\n\t\t\tactions={\n\t\t\t\t<div ref={searchContainerRef} className=\"relative\">\n\t\t\t\t\t{isSearchOpen ? (\n\t\t\t\t\t\t<div className=\"relative\">\n\t\t\t\t\t\t\t<Search\n\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\"absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground\",\n\t\t\t\t\t\t\t\t\tactionIconStyle,\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\tref={searchInputRef}\n\t\t\t\t\t\t\t\tvalue={searchValue}\n\t\t\t\t\t\t\t\tonChange={(e) => onSearchChange(e.target.value)}\n\t\t\t\t\t\t\t\tplaceholder=\"Find activities...\"\n\t\t\t\t\t\t\t\tclassName=\"h-7 w-48 rounded-md border border-primary/20 px-8 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t) : (\n\t\t\t\t\t\t<PanelActionButton\n\t\t\t\t\t\t\tvariant=\"default\"\n\t\t\t\t\t\t\ticon={Search}\n\t\t\t\t\t\t\tonClick={() => setIsSearchOpen(true)}\n\t\t\t\t\t\t\ticonOverrides={{ color: \"text-muted-foreground\" }}\n\t\t\t\t\t\t\tbuttonOverrides={{ hoverTextColor: \"hover:text-foreground\" }}\n\t\t\t\t\t\t\taria-label=\"Find activities...\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t}\n\t\t/>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/activity/ActivityPanel.tsx",
    "content": "\"use client\";\n\nimport { useEffect, useMemo } from \"react\";\nimport { ActivityDetail } from \"@/apps/activity/ActivityDetail\";\nimport { ActivityHeader } from \"@/apps/activity/ActivityHeader\";\nimport { ActivitySidebar } from \"@/apps/activity/ActivitySidebar\";\nimport { groupActivitiesByTime } from \"@/apps/activity/utils/timeUtils\";\nimport { useActivities, useActivityWithEvents } from \"@/lib/query\";\nimport { useActivityStore } from \"@/lib/store/activity-store\";\nimport type { Activity } from \"@/lib/types\";\n\nexport function ActivityPanel() {\n\tconst { selectedActivityId, search, setSelectedActivityId, setSearch } =\n\t\tuseActivityStore();\n\n\t// 使用 TanStack Query 获取 activities\n\tconst {\n\t\tdata: activities = [],\n\t\tisLoading: loadingList,\n\t\terror: listError,\n\t} = useActivities({ limit: 50, offset: 0 });\n\n\t// 根据搜索过滤 activities\n\tconst filteredActivities = useMemo(() => {\n\t\tif (!search.trim()) {\n\t\t\treturn activities;\n\t\t}\n\t\tconst keyword = search.toLowerCase();\n\t\treturn activities.filter(\n\t\t\t(item: Activity) =>\n\t\t\t\titem.aiTitle?.toLowerCase().includes(keyword) ||\n\t\t\t\titem.aiSummary?.toLowerCase().includes(keyword),\n\t\t);\n\t}, [search, activities]);\n\n\t// 自动选中第一个 activity\n\tuseEffect(() => {\n\t\tif (\n\t\t\tfilteredActivities.length > 0 &&\n\t\t\tselectedActivityId === null &&\n\t\t\t!loadingList\n\t\t) {\n\t\t\tsetSelectedActivityId(filteredActivities[0].id);\n\t\t}\n\t}, [\n\t\tfilteredActivities,\n\t\tselectedActivityId,\n\t\tloadingList,\n\t\tsetSelectedActivityId,\n\t]);\n\n\t// 使用组合 hook 获取 activity 详情和 events\n\tconst {\n\t\tactivity: selectedActivity,\n\t\tevents,\n\t\tisLoading: loadingDetail,\n\t} = useActivityWithEvents(selectedActivityId, activities);\n\n\tconst groups = useMemo(\n\t\t() => groupActivitiesByTime(filteredActivities),\n\t\t[filteredActivities],\n\t);\n\n\tif (listError) {\n\t\tconst errorMessage =\n\t\t\tlistError instanceof Error\n\t\t\t\t? listError.message\n\t\t\t\t: String(listError) || \"Unknown error\";\n\t\treturn (\n\t\t\t<div className=\"flex h-full items-center justify-center text-destructive\">\n\t\t\t\t加载失败: {errorMessage}\n\t\t\t</div>\n\t\t);\n\t}\n\n\treturn (\n\t\t<div className=\"flex h-full flex-col overflow-hidden bg-background\">\n\t\t\t<ActivityHeader searchValue={search} onSearchChange={setSearch} />\n\t\t\t<div className=\"flex min-h-0 flex-1 gap-4 overflow-hidden p-4\">\n\t\t\t\t<ActivitySidebar\n\t\t\t\t\tgroups={groups}\n\t\t\t\t\tselectedId={selectedActivityId}\n\t\t\t\t\tonSelect={(activity) => setSelectedActivityId(activity.id)}\n\t\t\t\t\tloading={loadingList}\n\t\t\t\t/>\n\t\t\t\t<div className=\"flex-1 min-w-[500px] shrink-0 overflow-hidden\">\n\t\t\t\t\t<ActivityDetail\n\t\t\t\t\t\tactivity={selectedActivity}\n\t\t\t\t\t\tevents={events}\n\t\t\t\t\t\tloading={loadingDetail}\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/activity/ActivitySidebar.tsx",
    "content": "import { ActivityCard } from \"@/apps/activity/ActivityCard\";\nimport type { ActivityGroup } from \"@/apps/activity/utils/timeUtils\";\nimport { formatTimeRange } from \"@/apps/activity/utils/timeUtils\";\nimport type { Activity } from \"@/lib/types\";\n\ninterface ActivitySidebarProps {\n\tgroups: ActivityGroup[];\n\tselectedId?: number | null;\n\tonSelect?: (activity: Activity) => void;\n\tloading?: boolean;\n}\n\nexport function ActivitySidebar({\n\tgroups,\n\tselectedId,\n\tonSelect,\n\tloading = false,\n}: ActivitySidebarProps) {\n\tconst allActivities = groups.flatMap((group) => group.items);\n\tconst selectedIndex = selectedId\n\t\t? allActivities.findIndex((activity) => activity.id === selectedId)\n\t\t: -1;\n\n\treturn (\n\t\t<aside className=\"relative flex h-full w-[280px] min-w-[220px] max-w-[320px] shrink flex-col overflow-hidden rounded-xl border border-border bg-card\">\n\t\t\t<div className=\"flex items-center justify-between px-3 py-2\">\n\t\t\t\t<h3 className=\"text-xs font-semibold text-foreground\">Timeline</h3>\n\t\t\t\t{loading && (\n\t\t\t\t\t<span className=\"text-[10px] text-muted-foreground\">Loading...</span>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t\t<div className=\"h-px w-full bg-border\" />\n\n\t\t\t<div className=\"relative flex-1 space-y-3 overflow-y-auto px-3 py-3\">\n\t\t\t\t{/* 时间线竖栏 - 仅在选中项存在时显示 */}\n\t\t\t\t{selectedIndex >= 0 && (\n\t\t\t\t\t<div className=\"absolute left-3 top-0 bottom-0 w-0.5 bg-border/30 pointer-events-none\" />\n\t\t\t\t)}\n\n\t\t\t\t{groups.map((group) => (\n\t\t\t\t\t<div key={group.label} className=\"space-y-1.5\">\n\t\t\t\t\t\t<div className=\"flex items-center text-[10px] uppercase tracking-wide text-muted-foreground px-1\">\n\t\t\t\t\t\t\t<span>{group.label}</span>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"space-y-2\">\n\t\t\t\t\t\t\t{group.items.map((activity) => (\n\t\t\t\t\t\t\t\t<ActivityCard\n\t\t\t\t\t\t\t\t\tkey={activity.id}\n\t\t\t\t\t\t\t\t\tactivity={activity}\n\t\t\t\t\t\t\t\t\tisSelected={activity.id === selectedId}\n\t\t\t\t\t\t\t\t\ttimeLabel={formatTimeRange(\n\t\t\t\t\t\t\t\t\t\tactivity.startTime,\n\t\t\t\t\t\t\t\t\t\tactivity.endTime,\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\tonSelect={onSelect}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t))}\n\t\t\t\t{!loading && groups.length === 0 && (\n\t\t\t\t\t<div className=\"rounded-md border border-dashed border-border bg-secondary/40 p-3 text-xs text-muted-foreground\">\n\t\t\t\t\t\tNo activities found.\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\n\t\t</aside>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/activity/ActivitySummary.tsx",
    "content": "import ReactMarkdown, { type Components } from \"react-markdown\";\nimport remarkGfm from \"remark-gfm\";\n\ninterface ActivitySummaryProps {\n\tsummary?: string | null;\n}\n\nconst markdownComponents: Components = {\n\t// 自定义标题样式\n\th1: ({ node: _node, ...props }) => (\n\t\t<h1\n\t\t\tclassName=\"text-lg font-semibold text-foreground mt-4 mb-2\"\n\t\t\t{...props}\n\t\t/>\n\t),\n\th2: ({ node: _node, ...props }) => (\n\t\t<h2\n\t\t\tclassName=\"text-base font-semibold text-foreground mt-3 mb-2\"\n\t\t\t{...props}\n\t\t/>\n\t),\n\th3: ({ node: _node, ...props }) => (\n\t\t<h3\n\t\t\tclassName=\"text-sm font-semibold text-foreground mt-3 mb-1\"\n\t\t\t{...props}\n\t\t/>\n\t),\n\t// 自定义段落样式\n\tp: ({ node: _node, ...props }) => (\n\t\t<p className=\"text-sm text-foreground leading-relaxed my-2\" {...props} />\n\t),\n\t// 自定义列表样式\n\tul: ({ node: _node, ...props }) => (\n\t\t<ul\n\t\t\tclassName=\"list-disc list-inside text-sm text-foreground my-2 space-y-1\"\n\t\t\t{...props}\n\t\t/>\n\t),\n\tol: ({ node: _node, ...props }) => (\n\t\t<ol\n\t\t\tclassName=\"list-decimal list-inside text-sm text-foreground my-2 space-y-1\"\n\t\t\t{...props}\n\t\t/>\n\t),\n\tli: ({ node: _node, ...props }) => (\n\t\t<li className=\"text-sm text-foreground\" {...props} />\n\t),\n\t// 自定义粗体和斜体\n\tstrong: ({ node: _node, ...props }) => (\n\t\t<strong className=\"font-semibold text-foreground\" {...props} />\n\t),\n\tem: ({ node: _node, ...props }) => (\n\t\t<em className=\"italic text-foreground\" {...props} />\n\t),\n\t// 自定义代码块\n\tcode: ({ node: _node, className, children, ...props }) => {\n\t\tconst isInline = !className;\n\t\treturn isInline ? (\n\t\t\t<code\n\t\t\t\tclassName=\"px-1.5 py-0.5 rounded bg-muted text-xs font-mono text-foreground\"\n\t\t\t\t{...props}\n\t\t\t>\n\t\t\t\t{children}\n\t\t\t</code>\n\t\t) : (\n\t\t\t<code className={className} {...props}>\n\t\t\t\t{children}\n\t\t\t</code>\n\t\t);\n\t},\n\tpre: ({ node: _node, ...props }) => (\n\t\t<pre\n\t\t\tclassName=\"rounded-lg bg-muted p-3 overflow-x-auto text-xs my-2\"\n\t\t\t{...props}\n\t\t/>\n\t),\n\t// 自定义链接\n\ta: ({ node: _node, ...props }) => (\n\t\t<a\n\t\t\tclassName=\"text-primary underline underline-offset-2 hover:text-primary/80\"\n\t\t\ttarget=\"_blank\"\n\t\t\trel=\"noopener noreferrer\"\n\t\t\t{...props}\n\t\t/>\n\t),\n};\n\nexport function ActivitySummary({ summary }: ActivitySummaryProps) {\n\tif (!summary) {\n\t\treturn (\n\t\t\t<div className=\"rounded-lg border border-dashed border-border bg-secondary/50 p-4 text-sm text-muted-foreground\">\n\t\t\t\tNo summary yet. Select an activity to view details.\n\t\t\t</div>\n\t\t);\n\t}\n\n\treturn (\n\t\t<div className=\"flex flex-col h-full min-h-0 rounded-lg border border-border bg-secondary p-4\">\n\t\t\t<h4 className=\"text-sm font-semibold text-foreground mb-3 shrink-0\">\n\t\t\t\tSummary\n\t\t\t</h4>\n\t\t\t<div\n\t\t\t\tclassName=\"markdown-content overflow-y-auto flex-1 min-h-0 pr-2\"\n\t\t\t\tstyle={{ scrollbarWidth: \"thin\" }}\n\t\t\t>\n\t\t\t\t<ReactMarkdown\n\t\t\t\t\tremarkPlugins={[remarkGfm]}\n\t\t\t\t\tcomponents={markdownComponents}\n\t\t\t\t>\n\t\t\t\t\t{summary}\n\t\t\t\t</ReactMarkdown>\n\t\t\t</div>\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/activity/utils/timeUtils.ts",
    "content": "import type { Activity } from \"@/lib/types\";\n\nconst ONE_MINUTE = 60 * 1000;\nconst ONE_HOUR = 60 * ONE_MINUTE;\nconst ONE_DAY = 24 * ONE_HOUR;\n\nfunction toDate(value?: string | null): Date | null {\n\tif (!value) return null;\n\tconst d = new Date(value);\n\treturn Number.isNaN(d.getTime()) ? null : d;\n}\n\nfunction isSameDay(a: Date, b: Date): boolean {\n\treturn (\n\t\ta.getFullYear() === b.getFullYear() &&\n\t\ta.getMonth() === b.getMonth() &&\n\t\ta.getDate() === b.getDate()\n\t);\n}\n\nfunction isYesterday(target: Date, now: Date): boolean {\n\tconst yesterday = new Date(now);\n\tyesterday.setDate(now.getDate() - 1);\n\treturn isSameDay(target, yesterday);\n}\n\nfunction isSameWeek(target: Date, now: Date): boolean {\n\t// 以周一为起始\n\tconst day = now.getDay() === 0 ? 7 : now.getDay();\n\tconst weekStart = new Date(now);\n\tweekStart.setHours(0, 0, 0, 0);\n\tweekStart.setDate(now.getDate() - (day - 1));\n\tconst weekEnd = new Date(weekStart);\n\tweekEnd.setDate(weekStart.getDate() + 6);\n\treturn target >= weekStart && target <= weekEnd;\n}\n\nexport function formatRelativeTime(time?: string | null): string {\n\tconst d = toDate(time);\n\tif (!d) return \"Unknown time\";\n\n\tconst now = new Date();\n\tconst diff = now.getTime() - d.getTime();\n\n\tif (diff < ONE_MINUTE) return \"Just now\";\n\tif (diff < ONE_HOUR) {\n\t\tconst minutes = Math.round(diff / ONE_MINUTE);\n\t\treturn `${minutes} minute${minutes > 1 ? \"s\" : \"\"} ago`;\n\t}\n\tif (diff < ONE_DAY) {\n\t\tconst hours = Math.round(diff / ONE_HOUR);\n\t\treturn `${hours} hour${hours > 1 ? \"s\" : \"\"} ago`;\n\t}\n\n\tconst dateStr = d.toLocaleDateString(undefined, {\n\t\tmonth: \"short\",\n\t\tday: \"numeric\",\n\t});\n\treturn dateStr;\n}\n\nexport function formatTimeRange(\n\tstart?: string | null,\n\tend?: string | null,\n): string {\n\tconst startDate = toDate(start);\n\tconst endDate = toDate(end);\n\n\tif (!startDate) return \"Unknown time\";\n\n\tconst formatter = new Intl.DateTimeFormat(undefined, {\n\t\thour: \"numeric\",\n\t\tminute: \"2-digit\",\n\t});\n\n\tconst startLabel = `${formatRelativeTime(start)} ~ ${formatter.format(startDate)}`;\n\tif (!endDate) return startLabel;\n\n\treturn `${startLabel} → ${formatter.format(endDate)}`;\n}\n\nexport type ActivityGroup = {\n\tlabel: string;\n\titems: Activity[];\n};\n\nexport function groupActivitiesByTime(activities: Activity[]): ActivityGroup[] {\n\tconst now = new Date();\n\tconst groups: Record<string, Activity[]> = {\n\t\tToday: [],\n\t\tYesterday: [],\n\t\t\"This Week\": [],\n\t\tOlder: [],\n\t};\n\n\tfor (const activity of activities) {\n\t\tconst startDate = toDate(activity.startTime);\n\t\tif (!startDate) {\n\t\t\tgroups.Older.push(activity);\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (isSameDay(startDate, now)) {\n\t\t\tgroups.Today.push(activity);\n\t\t} else if (isYesterday(startDate, now)) {\n\t\t\tgroups.Yesterday.push(activity);\n\t\t} else if (isSameWeek(startDate, now)) {\n\t\t\tgroups[\"This Week\"].push(activity);\n\t\t} else {\n\t\t\tgroups.Older.push(activity);\n\t\t}\n\t}\n\n\treturn Object.entries(groups)\n\t\t.filter(([, items]) => items.length > 0)\n\t\t.map(([label, items]) => ({ label, items }));\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/audio/AudioPanel.tsx",
    "content": "\"use client\";\n\nimport { useTranslations } from \"next-intl\";\nimport { useCallback, useMemo, useRef, useState } from \"react\";\nimport { PanelHeader } from \"@/components/common/layout/PanelHeader\";\nimport { FEATURE_ICON_MAP } from \"@/lib/config/panel-config\";\nimport { useAudioRecordingStore } from \"@/lib/store/audio-recording-store\";\nimport { toastError } from \"@/lib/toast\";\nimport { AudioExtractionPanel } from \"./components/AudioExtractionPanel\";\nimport { AudioHeader } from \"./components/AudioHeader\";\nimport { AudioPlayer } from \"./components/AudioPlayer\";\nimport { RecordingStatus } from \"./components/RecordingStatus\";\nimport { StopRecordingConfirm } from \"./components/StopRecordingConfirm\";\nimport { TranscriptionView } from \"./components/TranscriptionView\";\nimport { useAudioData } from \"./hooks/useAudioData\";\nimport { useAudioDateSwitching } from \"./hooks/useAudioDateSwitching\";\nimport { useAudioPlayback } from \"./hooks/useAudioPlayback\";\nimport { useAudioRecording } from \"./hooks/useAudioRecording\";\nimport { useSegmentSync } from \"./hooks/useSegmentSync\";\nimport { useStopRecordingConfirm } from \"./hooks/useStopRecordingConfirm\";\nimport { parseTimeToIsoWithDate as parseTimeToIsoWithDateUtil } from \"./utils/parseTimeToIsoWithDate\";\nimport { formatDateTime, formatTime, getSegmentDate } from \"./utils/timeUtils\";\n\nexport function AudioPanel() {\n\tconst t = useTranslations(\"page\");\n\tconst [activeTab, setActiveTab] = useState<\"original\" | \"optimized\">(\"original\");\n\tconst [selectedDate, setSelectedDate] = useState(new Date());\n\n\t// 获取录音状态和控制函数（从全局 store）\n\tconst { isRecording, startRecording, stopRecording } = useAudioRecording();\n\n\t// 从全局 store 获取实时录音数据（用于面板切换时保持状态）\n\tconst storeTranscriptionText = useAudioRecordingStore((state) => state.transcriptionText);\n\tconst storePartialText = useAudioRecordingStore((state) => state.partialText);\n\tconst storeOptimizedText = useAudioRecordingStore((state) => state.optimizedText);\n\tconst storeSegmentTimesSec = useAudioRecordingStore((state) => state.segmentTimesSec);\n\tconst storeSegmentTimeLabels = useAudioRecordingStore((state) => state.segmentTimeLabels);\n\tconst storeSegmentRecordingIds = useAudioRecordingStore((state) => state.segmentRecordingIds);\n\tconst storeSegmentOffsetsSec = useAudioRecordingStore((state) => state.segmentOffsetsSec);\n\tconst storeLiveTodos = useAudioRecordingStore((state) => state.liveTodos);\n\tconst storeLiveSchedules = useAudioRecordingStore((state) => state.liveSchedules);\n\tconst storeRecordingStartedAt = useAudioRecordingStore((state) => state.recordingStartedAt);\n\n\t// 从全局 store 获取更新方法\n\tconst updateLastFinalEnd = useAudioRecordingStore((state) => state.updateLastFinalEnd);\n\tconst appendTranscriptionText = useAudioRecordingStore((state) => state.appendTranscriptionText);\n\tconst setStorePartialText = useAudioRecordingStore((state) => state.setPartialText);\n\tconst setStoreOptimizedText = useAudioRecordingStore((state) => state.setOptimizedText);\n\tconst appendSegmentData = useAudioRecordingStore((state) => state.appendSegmentData);\n\tconst setStoreLiveTodos = useAudioRecordingStore((state) => state.setLiveTodos);\n\tconst setStoreLiveSchedules = useAudioRecordingStore((state) => state.setLiveSchedules);\n\tconst clearSessionData = useAudioRecordingStore((state) => state.clearSessionData);\n\n\t// 本地状态：用于回看模式（从后端加载的历史数据）\n\tconst [localTranscriptionText, setLocalTranscriptionText] = useState(\"\");\n\tconst [localOptimizedText, setLocalOptimizedText] = useState(\"\");\n\n\t// 根据录音状态选择数据源：录音中使用 store 数据，回看使用本地数据\n\tconst transcriptionText = isRecording ? storeTranscriptionText : localTranscriptionText;\n\tconst partialText = isRecording ? storePartialText : \"\";\n\tconst optimizedText = isRecording ? storeOptimizedText : localOptimizedText;\n\n\tconst {\n\t\tselectedRecordingId,\n\t\tsetSelectedRecordingId,\n\t\tselectedRecordingDurationSec,\n\t\tsetSelectedRecordingDurationSec,\n\t\trecordingDurations,\n\t\tsegmentOffsetsSec: dataSegmentOffsetsSec,\n\t\tsetSegmentOffsetsSec: setDataSegmentOffsetsSec,\n\t\tsegmentRecordingIds: dataSegmentRecordingIds,\n\t\tsetSegmentRecordingIds: setDataSegmentRecordingIds,\n\t\tsegmentTimeLabels: dataSegmentTimeLabels,\n\t\tsetSegmentTimeLabels: setDataSegmentTimeLabels,\n\t\tsegmentTimesSec: dataSegmentTimesSec,\n\t\tsetSegmentTimesSec: setDataSegmentTimesSec,\n\t\textractionsByRecordingId,\n\t\toptimizedExtractionsByRecordingId,\n\t\tsetOptimizedExtractionsByRecordingId,\n\t\tloadRecordings,\n\t\tloadTimeline,\n\t} = useAudioData(selectedDate, activeTab, setLocalTranscriptionText, setLocalOptimizedText);\n\n\t// 根据录音状态选择段落数据源\n\tconst segmentTimesSec = isRecording ? storeSegmentTimesSec : dataSegmentTimesSec;\n\tconst segmentTimeLabels = isRecording ? storeSegmentTimeLabels : dataSegmentTimeLabels;\n\tconst segmentRecordingIds = isRecording ? storeSegmentRecordingIds : dataSegmentRecordingIds;\n\tconst segmentOffsetsSec = isRecording ? storeSegmentOffsetsSec : dataSegmentOffsetsSec;\n\tconst liveTodos = isRecording ? storeLiveTodos : [];\n\tconst liveSchedules = isRecording ? storeLiveSchedules : [];\n\n\t// 停止录音确认弹窗和后续轮询逻辑\n\tconst {\n\t\tshowStopConfirm,\n\t\tisExtracting,\n\t\tisLoadingTimeline,\n\t\tsetIsLoadingTimeline,\n\t\topenStopConfirm,\n\t\tcancelStopConfirm,\n\t\tconfirmStop,\n\t} = useStopRecordingConfirm({ selectedDate, stopRecording, loadRecordings, loadTimeline });\n\n\tconst {\n\t\taudioRef, isPlaying, currentTime, duration, playbackRate,\n\t\tensureAudio, playPause, seekByRatio, setPlaybackRate,\n\t} = useAudioPlayback();\n\n\t// 段落选择同步\n\tconst { selectedSegmentIndex, setSelectedSegmentIndex, currentSegmentText } = useSegmentSync({\n\t\tisRecording, selectedRecordingId, currentTime, segmentRecordingIds,\n\t\tsegmentOffsetsSec, activeTab, transcriptionText, optimizedText,\n\t});\n\n\t// 辅助函数：获取本地日期字符串（用于日期比较）\n\tconst getLocalDateStringForCompare = useCallback((date: Date) => {\n\t\tconst year = date.getFullYear();\n\t\tconst month = String(date.getMonth() + 1).padStart(2, \"0\");\n\t\tconst day = String(date.getDate()).padStart(2, \"0\");\n\t\treturn `${year}-${month}-${day}`;\n\t}, []);\n\n\t// 用于存储实时录音的完整状态（持久化，不被清空）\n\tconst liveRecordingStateRef = useRef<{\n\t\ttext: string;\n\t\toptimizedText: string;\n\t\tpartialText: string;\n\t\tsegmentTimesSec: number[];\n\t\tsegmentOffsetsSec: number[];\n\t\tsegmentRecordingIds: number[];\n\t\tsegmentTimeLabels: string[];\n\t\ttodos: Array<{ title: string; description?: string; deadline?: string; source_text?: string }>;\n\t\tschedules: Array<{ title: string; time?: string; description?: string; source_text?: string }>;\n\t}>({\n\t\ttext: \"\",\n\t\toptimizedText: \"\",\n\t\tpartialText: \"\",\n\t\tsegmentTimesSec: [],\n\t\tsegmentOffsetsSec: [],\n\t\tsegmentRecordingIds: [],\n\t\tsegmentTimeLabels: [],\n\t\ttodos: [],\n\t\tschedules: [],\n\t});\n\n\t// 用于手动启动录音的 ref（防止重复启动）\n\tconst isStartingRef = useRef(false);\n\n\t// 计算是否正在查看当前日期\n\tconst isViewingCurrentDate = useMemo(() => {\n\t\tconst now = new Date();\n\t\treturn getLocalDateStringForCompare(selectedDate) === getLocalDateStringForCompare(now);\n\t}, [selectedDate, getLocalDateStringForCompare]);\n\n\t// 跳转到当前日期\n\tconst handleJumpToCurrentDate = useCallback(() => setSelectedDate(new Date()), []);\n\n\t// 手动开始录音\n\tconst handleStartRecording = useCallback(async () => {\n\t\tif (isRecording || isStartingRef.current) return;\n\t\tisStartingRef.current = true;\n\t\ttry {\n\t\t\tclearSessionData();\n\t\t\tsetSelectedSegmentIndex(null);\n\t\t\tawait startRecording(\n\t\t\t\t(text, isFinal) => {\n\t\t\t\t\tif (isFinal && text.startsWith(\"__SEGMENT_SAVED__\")) return;\n\t\t\t\t\tif (isFinal) {\n\t\t\t\t\t\tconst storeState = useAudioRecordingStore.getState();\n\t\t\t\t\t\tconst currentRecordingStartedAt = storeState.recordingStartedAt ?? Date.now();\n\t\t\t\t\t\tconst segmentStartMs = storeState.lastFinalEndMs ?? currentRecordingStartedAt;\n\t\t\t\t\t\tconst elapsedSec = (segmentStartMs - currentRecordingStartedAt) / 1000;\n\t\t\t\t\t\tupdateLastFinalEnd(Date.now());\n\t\t\t\t\t\tappendTranscriptionText(text);\n\t\t\t\t\t\tconst start = storeState.recordingStartedDate ?? new Date();\n\t\t\t\t\t\tconst segmentDate = getSegmentDate(start, elapsedSec, selectedDate);\n\t\t\t\t\t\tappendSegmentData({\n\t\t\t\t\t\t\ttimeSec: elapsedSec,\n\t\t\t\t\t\t\ttimeLabel: formatDateTime(segmentDate),\n\t\t\t\t\t\t\trecordingId: 0,\n\t\t\t\t\t\t\toffsetSec: elapsedSec,\n\t\t\t\t\t\t});\n\t\t\t\t\t\tsetStorePartialText(\"\");\n\t\t\t\t\t} else {\n\t\t\t\t\t\tsetStorePartialText(text);\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t\t(data) => {\n\t\t\t\t\tif (typeof data.optimizedText === \"string\") setStoreOptimizedText(data.optimizedText);\n\t\t\t\t\tif (Array.isArray(data.todos)) setStoreLiveTodos(data.todos);\n\t\t\t\t\tif (Array.isArray(data.schedules)) setStoreLiveSchedules(data.schedules);\n\t\t\t\t},\n\t\t\t\t(error) => {\n\t\t\t\t\tconst errorMessage = error instanceof Error ? error.message : \"录音过程中发生错误\";\n\t\t\t\t\ttoastError(errorMessage, { duration: 5000 });\n\t\t\t\t},\n\t\t\t\ttrue\n\t\t\t);\n\t\t} catch (error) {\n\t\t\tconst errorMessage = error instanceof Error ? error.message : \"启动录音失败\";\n\t\t\ttoastError(errorMessage, { duration: 5000 });\n\t\t} finally {\n\t\t\tisStartingRef.current = false;\n\t\t}\n\t}, [\n\t\tisRecording, clearSessionData, startRecording, updateLastFinalEnd,\n\t\tappendTranscriptionText, appendSegmentData, setStorePartialText,\n\t\tsetStoreOptimizedText, setStoreLiveTodos, setStoreLiveSchedules, selectedDate, setSelectedSegmentIndex,\n\t]);\n\n\t// 手动停止录音（显示确认弹窗）\n\tconst handleStopRecording = useCallback(() => {\n\t\tif (!isRecording) return;\n\t\topenStopConfirm();\n\t}, [isRecording, openStopConfirm]);\n\n\t// 用于防止数据加载错乱：记录当前加载请求的日期\n\tconst currentLoadingDateRef = useRef<string | null>(null);\n\n\t// 创建适配器函数，让 useAudioDateSwitching 能够操作本地状态（用于回看模式）\n\tconst setTranscriptionTextAdapter = useCallback((text: string | ((prev: string) => string)) => {\n\t\tif (typeof text === \"function\") {\n\t\t\tsetLocalTranscriptionText((prev) => text(prev));\n\t\t} else {\n\t\t\tsetLocalTranscriptionText(text);\n\t\t}\n\t}, []);\n\n\tconst setOptimizedTextAdapter = useCallback((text: string | ((prev: string) => string)) => {\n\t\tif (typeof text === \"function\") {\n\t\t\tsetLocalOptimizedText((prev) => text(prev));\n\t\t} else {\n\t\t\tsetLocalOptimizedText(text);\n\t\t}\n\t}, []);\n\n\t// 使用日期切换 hook（操作本地状态用于回看模式）\n\tuseAudioDateSwitching({\n\t\tselectedDate, isRecording, isViewingCurrentDate, liveRecordingStateRef, currentLoadingDateRef,\n\t\tsetTranscriptionText: setTranscriptionTextAdapter, setOptimizedText: setOptimizedTextAdapter,\n\t\tsetPartialText: setStorePartialText, setSegmentTimesSec: setDataSegmentTimesSec,\n\t\tsetSegmentOffsetsSec: setDataSegmentOffsetsSec, setSegmentRecordingIds: setDataSegmentRecordingIds,\n\t\tsetSegmentTimeLabels: setDataSegmentTimeLabels, setLiveTodos: setStoreLiveTodos,\n\t\tsetLiveSchedules: setStoreLiveSchedules, setIsLoadingTimeline, loadTimeline,\n\t});\n\n\tconst formatDate = (date: Date) => `${date.toLocaleDateString(\"zh-CN\", {\n\t\tyear: \"numeric\", month: \"long\", day: \"numeric\",\n\t})} 录音`;\n\n\tconst Icon = FEATURE_ICON_MAP.audio;\n\tconst apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || \"http://localhost:8100\";\n\n\tconst handlePlayFromTranscription = useCallback(() => {\n\t\tif (!selectedRecordingId) return;\n\t\tplayPause(`${apiBaseUrl}/api/audio/recording/${selectedRecordingId}/file`);\n\t}, [apiBaseUrl, selectedRecordingId, playPause]);\n\n\tconst handleSeekToSegment = useCallback((index: number) => {\n\t\tconst recId = segmentRecordingIds[index] ?? selectedRecordingId;\n\t\tif (!recId) return;\n\t\tconst audioUrl = `${apiBaseUrl}/api/audio/recording/${recId}/file`;\n\t\tensureAudio(audioUrl);\n\t\tconst audio = audioRef.current;\n\t\tif (!audio) return;\n\n\t\tconst direct = segmentOffsetsSec[index];\n\t\tconst segmentsCount = Math.max(1, segmentOffsetsSec.length);\n\t\tconst dur = recordingDurations[recId] ?? selectedRecordingDurationSec;\n\t\tconst fallback = dur > 0 ? (index / segmentsCount) * dur : 0;\n\t\tconst target = (Number.isFinite(direct) ? direct : fallback) + 1;\n\n\t\ttry {\n\t\t\taudio.currentTime = Math.max(0, target);\n\t\t\taudio.play().catch(() => {});\n\t\t\tsetSelectedRecordingId(recId);\n\t\t\tsetSelectedSegmentIndex(index);\n\t\t\tif (dur) setSelectedRecordingDurationSec(dur);\n\t\t} catch (e) {\n\t\t\tconsole.error(\"Failed to seek audio:\", e);\n\t\t}\n\t}, [\n\t\tapiBaseUrl, ensureAudio, selectedRecordingId, segmentRecordingIds, segmentOffsetsSec,\n\t\tselectedRecordingDurationSec, recordingDurations, audioRef, setSelectedRecordingId, setSelectedRecordingDurationSec, setSelectedSegmentIndex,\n\t]);\n\n\tconst handlePlayPause = useCallback(() => {\n\t\tif (!audioRef.current) handlePlayFromTranscription();\n\t\telse playPause();\n\t}, [handlePlayFromTranscription, playPause, audioRef]);\n\n\tconst handleSeekInPlayer = useCallback((ratio: number) => seekByRatio(ratio), [seekByRatio]);\n\n\t// 每一条文本段对应的高亮数据\n\tconst segmentTodos = useMemo(() => segmentRecordingIds.map((recId) => {\n\t\tif (recId === 0) return liveTodos;\n\t\tconst ext = recId != null ? extractionsByRecordingId[recId] : undefined;\n\t\treturn ext?.todos ?? [];\n\t}), [segmentRecordingIds, liveTodos, extractionsByRecordingId]);\n\n\tconst segmentSchedules = useMemo(() => segmentRecordingIds.map((recId) => {\n\t\tif (recId === 0) return liveSchedules;\n\t\tconst ext = recId != null ? extractionsByRecordingId[recId] : undefined;\n\t\treturn ext?.schedules ?? [];\n\t}), [segmentRecordingIds, liveSchedules, extractionsByRecordingId]);\n\n\tconst dateKey = useMemo(() => selectedDate.toISOString().split(\"T\")[0], [selectedDate]);\n\tconst parseTimeToIsoWithDate = useCallback(\n\t\t(raw?: string | null) => parseTimeToIsoWithDateUtil(raw, selectedDate),\n\t\t[selectedDate],\n\t);\n\n\treturn (\n\t\t<div className=\"flex h-full flex-col bg-[oklch(var(--background))] overflow-hidden\">\n\t\t\t<PanelHeader icon={Icon} title={t(\"audioLabel\")} />\n\n\t\t\t<AudioHeader\n\t\t\t\tisRecording={isRecording}\n\t\t\t\tselectedDate={selectedDate}\n\t\t\t\tonDateChange={setSelectedDate}\n\t\t\t\tonJumpToCurrentDate={handleJumpToCurrentDate}\n\t\t\t\tonStartRecording={handleStartRecording}\n\t\t\t\tonStopRecording={handleStopRecording}\n\t\t\t/>\n\n\t\t\t<AudioExtractionPanel\n\t\t\t\tdateKey={dateKey}\n\t\t\t\tsegmentRecordingIds={segmentRecordingIds}\n\t\t\t\textractionsByRecordingId={optimizedExtractionsByRecordingId}\n\t\t\t\tsetExtractionsByRecordingId={setOptimizedExtractionsByRecordingId}\n\t\t\t\tparseTimeToIsoWithDate={parseTimeToIsoWithDate}\n\t\t\t\tliveTodos={liveTodos}\n\t\t\t\tliveSchedules={liveSchedules}\n\t\t\t\tisRecording={isRecording}\n\t\t\t\tisExtracting={isExtracting}\n\t\t\t/>\n\n\t\t\t<TranscriptionView\n\t\t\t\toriginalText={transcriptionText}\n\t\t\t\tpartialText={isRecording && isViewingCurrentDate ? partialText : \"\"}\n\t\t\t\toptimizedText={optimizedText}\n\t\t\t\tactiveTab={activeTab}\n\t\t\t\tonTabChange={(tab) => {\n\t\t\t\t\tsetActiveTab(tab);\n\t\t\t\t\tsetIsLoadingTimeline(true);\n\t\t\t\t\tloadTimeline((loading) => setIsLoadingTimeline(loading), false);\n\t\t\t\t}}\n\t\t\t\tsegmentTodos={segmentTodos}\n\t\t\t\tsegmentSchedules={segmentSchedules}\n\t\t\t\tisRecording={isRecording && isViewingCurrentDate}\n\t\t\t\tsegmentTimesSec={segmentTimesSec}\n\t\t\t\tsegmentTimeLabels={segmentTimeLabels}\n\t\t\t\tselectedSegmentIndex={selectedSegmentIndex}\n\t\t\t\tonSegmentClick={(index) => {\n\t\t\t\t\tconst recordingId = segmentRecordingIds[index];\n\t\t\t\t\tif (recordingId && recordingId > 0) handleSeekToSegment(index);\n\t\t\t\t}}\n\t\t\t\tisLoadingTimeline={isLoadingTimeline}\n\t\t\t/>\n\n\t\t\t{isRecording && isViewingCurrentDate ? (\n\t\t\t\t<RecordingStatus isRecording={isRecording} recordingStartedAt={storeRecordingStartedAt || undefined} />\n\t\t\t) : (\n\t\t\t\tselectedRecordingId && (\n\t\t\t\t\t<AudioPlayer\n\t\t\t\t\t\ttitle={formatDate(selectedDate)}\n\t\t\t\t\t\tdate=\"\"\n\t\t\t\t\t\tcurrentTime={formatTime(currentTime)}\n\t\t\t\t\t\ttotalTime={formatTime(duration)}\n\t\t\t\t\t\tisPlaying={isPlaying}\n\t\t\t\t\t\tonPlay={handlePlayPause}\n\t\t\t\t\t\tprogress={duration > 0 ? currentTime / duration : 0}\n\t\t\t\t\t\tonSeek={handleSeekInPlayer}\n\t\t\t\t\t\tcurrentSegmentText={currentSegmentText}\n\t\t\t\t\t\tplaybackRate={playbackRate}\n\t\t\t\t\t\tonPlaybackRateChange={setPlaybackRate}\n\t\t\t\t\t/>\n\t\t\t\t)\n\t\t\t)}\n\n\t\t\t<StopRecordingConfirm isOpen={showStopConfirm} onCancel={cancelStopConfirm} onConfirm={confirmStop} />\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/audio/components/AudioExtractionPanel.tsx",
    "content": "\"use client\";\n\nimport { useTranslations } from \"next-intl\";\nimport { useMemo, useState } from \"react\";\nimport { MessageTodoExtractionModal } from \"@/apps/chat/components/message/MessageTodoExtractionModal\";\nimport { cn } from \"@/lib/utils\";\nimport { useAudioLink } from \"../hooks/useAudioLink\";\n\ntype TodoItem = {\n\tid?: string;\n\tdedupe_key?: string;\n\ttitle: string;\n\tdescription?: string;\n\tstart_time?: string;\n\tstartTime?: string;\n\tdeadline?: string;\n\tsource_text?: string;\n\tlinked?: boolean;\n\tlinked_todo_id?: number | null;\n};\n\ntype ScheduleItem = {\n\tid?: string;\n\tdedupe_key?: string;\n\ttitle: string;\n\ttime?: string;\n\tdescription?: string;\n\tsource_text?: string;\n\tlinked?: boolean;\n\tlinked_todo_id?: number | null;\n};\n\ninterface ExtractionPanelProps {\n\tdateKey: string;\n\tsegmentRecordingIds: number[];\n\textractionsByRecordingId: Record<number, { todos?: TodoItem[]; schedules?: ScheduleItem[] }>;\n\tsetExtractionsByRecordingId: React.Dispatch<\n\t\tReact.SetStateAction<Record<number, { todos?: TodoItem[]; schedules?: ScheduleItem[] }>>\n\t>;\n\tparseTimeToIsoWithDate: (raw?: string | null) => string | undefined;\n\t// 录音中的实时提取结果（recId=0 表示录音中）\n\tliveTodos?: Array<{\n\t\ttitle: string;\n\t\tdescription?: string;\n\t\tstartTime?: string;\n\t\tdeadline?: string;\n\t\tsource_text?: string;\n\t}>;\n\tliveSchedules?: Array<{ title: string; time?: string; description?: string; source_text?: string }>;\n\tisRecording?: boolean;\n\tisExtracting?: boolean; // 后端正在提取中\n}\n\nexport function AudioExtractionPanel({\n\tdateKey,\n\tsegmentRecordingIds,\n\textractionsByRecordingId,\n\tsetExtractionsByRecordingId,\n\tparseTimeToIsoWithDate,\n\tliveTodos = [],\n\tliveSchedules = [],\n\tisRecording = false,\n\tisExtracting = false,\n}: ExtractionPanelProps) {\n\tconst tAudio = useTranslations(\"audio\");\n\tconst [showExtractionModal, setShowExtractionModal] = useState(false);\n\tconst [selectedIndexes, setSelectedIndexes] = useState<Set<number>>(new Set());\n\tconst { linkAndRefresh } = useAudioLink();\n\n\ttype ModalItem = {\n\t\tkey: string;\n\t\tname: string;\n\t\tdescription?: string;\n\t\tdeadline?: string;\n\t\trawTime?: string;\n\t\ttags: string[];\n\t\t_meta: { recordingIds: number[]; kind: \"todo\" | \"schedule\"; itemKey: string };\n\t};\n\n\tconst extractionTodosForModal = useMemo(() => {\n\t\tconst uniqueIds = Array.from(new Set(segmentRecordingIds.filter((id) => id && id > 0)));\n\t\tconst aggregated = new Map<string, ModalItem>();\n\n\t\t// 先处理录音中的实时提取结果（recId=0）\n\t\tif (isRecording && (liveTodos.length > 0 || liveSchedules.length > 0)) {\n\t\t\tfor (const item of liveTodos) {\n\t\t\t\tconst itemKey = (item.source_text || item.title || \"\").toString();\n\t\t\t\tif (!itemKey) continue;\n\t\t\t\tconst mapKey = `todo:${itemKey}`;\n\t\t\t\tconst liveTime = item.startTime ?? item.deadline ?? null;\n\t\t\t\tif (!aggregated.has(mapKey)) {\n\t\t\t\t\taggregated.set(mapKey, {\n\t\t\t\t\t\tkey: `audio:${dateKey}:${mapKey}:live`,\n\t\t\t\t\t\tname: item.source_text || item.title,\n\t\t\t\t\t\tdescription: item.source_text || item.description || undefined,\n\t\t\t\t\t\tdeadline: parseTimeToIsoWithDate(liveTime),\n\t\t\t\t\t\trawTime: liveTime || item.source_text || undefined,\n\t\t\t\t\t\ttags: [tAudio(\"linkTodoTag\")],\n\t\t\t\t\t\t_meta: { recordingIds: [0], kind: \"todo\", itemKey },\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tfor (const item of liveSchedules) {\n\t\t\t\tconst itemKey = (item.source_text || item.title || \"\").toString();\n\t\t\t\tif (!itemKey) continue;\n\t\t\t\tconst mapKey = `schedule:${itemKey}`;\n\t\t\t\tif (!aggregated.has(mapKey)) {\n\t\t\t\t\taggregated.set(mapKey, {\n\t\t\t\t\t\tkey: `audio:${dateKey}:${mapKey}:live`,\n\t\t\t\t\t\tname: item.source_text || item.title || tAudio(\"scheduleFallbackTitle\"),\n\t\t\t\t\t\tdescription: item.source_text || item.description || item.time || undefined,\n\t\t\t\t\t\tdeadline: parseTimeToIsoWithDate(item.time || null),\n\t\t\t\t\t\trawTime: item.time || item.source_text || undefined,\n\t\t\t\t\t\ttags: [tAudio(\"scheduleTag\")],\n\t\t\t\t\t\t_meta: { recordingIds: [0], kind: \"schedule\", itemKey },\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// 然后处理已保存录音的提取结果\n\t\tfor (const recId of uniqueIds) {\n\t\t\tconst ext = extractionsByRecordingId[recId];\n\t\t\tif (!ext) continue;\n\n\t\t\tfor (const item of ext.todos ?? []) {\n\t\t\t\tconst itemKey = (item.dedupe_key || item.id || \"\").toString();\n\t\t\t\tif (!itemKey) continue;\n\t\t\t\tif (item?.linked || item?.linked_todo_id) {\n\t\t\t\t\taggregated.delete(`todo:${itemKey}`);\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tconst mapKey = `todo:${itemKey}`;\n\t\t\t\tconst scheduleTime = item.startTime ?? item.start_time ?? item.deadline ?? null;\n\t\t\t\tconst existing = aggregated.get(mapKey);\n\t\t\t\tif (existing) {\n\t\t\t\t\tif (!existing._meta.recordingIds.includes(recId)) {\n\t\t\t\t\t\texisting._meta.recordingIds.push(recId);\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\taggregated.set(mapKey, {\n\t\t\t\t\t\tkey: `audio:${dateKey}:${mapKey}`,\n\t\t\t\t\t\tname: item.source_text || item.title,\n\t\t\t\t\t\tdescription: item.source_text || item.description || undefined,\n\t\t\t\t\t\tdeadline: parseTimeToIsoWithDate(scheduleTime),\n\t\t\t\t\t\trawTime: scheduleTime || item.source_text || undefined,\n\t\t\t\t\t\ttags: [tAudio(\"linkTodoTag\")],\n\t\t\t\t\t\t_meta: { recordingIds: [recId], kind: \"todo\", itemKey },\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tfor (const item of ext.schedules ?? []) {\n\t\t\t\tconst itemKey = (item.dedupe_key || item.id || \"\").toString();\n\t\t\t\tif (!itemKey) continue;\n\t\t\t\tif (item?.linked || item?.linked_todo_id) {\n\t\t\t\t\taggregated.delete(`schedule:${itemKey}`);\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tconst mapKey = `schedule:${itemKey}`;\n\t\t\t\tconst existing = aggregated.get(mapKey);\n\t\t\t\tif (existing) {\n\t\t\t\t\tif (!existing._meta.recordingIds.includes(recId)) {\n\t\t\t\t\t\texisting._meta.recordingIds.push(recId);\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\taggregated.set(mapKey, {\n\t\t\t\t\t\tkey: `audio:${dateKey}:${mapKey}`,\n\t\t\t\t\t\tname: item.source_text || item.title || tAudio(\"scheduleFallbackTitle\"),\n\t\t\t\t\t\tdescription: item.source_text || item.description || item.time || undefined,\n\t\t\t\t\t\tdeadline: parseTimeToIsoWithDate(item.time || null),\n\t\t\t\t\t\trawTime: item.time || item.source_text || undefined,\n\t\t\t\t\t\ttags: [tAudio(\"scheduleTag\")],\n\t\t\t\t\t\t_meta: { recordingIds: [recId], kind: \"schedule\", itemKey },\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\treturn Array.from(aggregated.values());\n\t}, [\n\t\tdateKey,\n\t\tsegmentRecordingIds,\n\t\textractionsByRecordingId,\n\t\tparseTimeToIsoWithDate,\n\t\ttAudio,\n\t\tisRecording,\n\t\tliveTodos,\n\t\tliveSchedules,\n\t]);\n\n\tconst filteredTodoCount = extractionTodosForModal.filter((x) =>\n\t\tx.tags.includes(tAudio(\"linkTodoTag\"))\n\t).length;\n\tconst filteredScheduleCount = extractionTodosForModal.filter((x) =>\n\t\tx.tags.includes(tAudio(\"scheduleTag\"))\n\t).length;\n\tconst hasExtraction = filteredTodoCount + filteredScheduleCount > 0;\n\n\treturn (\n\t\t<>\n\t\t\t{(hasExtraction || isExtracting || (isRecording && (liveTodos.length > 0 || liveSchedules.length > 0))) ? (\n\t\t\t\t<div className=\"flex items-center justify-between px-4 py-2 border-b border-[oklch(var(--border))] bg-[oklch(var(--muted))]/40\">\n\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t{isExtracting && (\n\t\t\t\t\t\t\t<div className=\"flex items-center gap-1.5 text-xs text-[oklch(var(--muted-foreground))]\">\n\t\t\t\t\t\t\t\t<div className=\"h-3 w-3 border-2 border-[oklch(var(--primary))] border-t-transparent rounded-full animate-spin\" />\n\t\t\t\t\t\t\t\t<span>提取中...</span>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t{(hasExtraction || (isRecording && (liveTodos.length > 0 || liveSchedules.length > 0))) && (\n\t\t\t\t\t\t\t<div className=\"text-sm text-[oklch(var(--muted-foreground))]\">\n\t\t\t\t\t\t\t\t{`待添加 ${filteredTodoCount} 个待办，${filteredScheduleCount} 个日程`}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t\t{hasExtraction && (\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\tsetSelectedIndexes(new Set());\n\t\t\t\t\t\t\t\tsetShowExtractionModal(true);\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\"px-3 py-1.5 text-sm rounded-md\",\n\t\t\t\t\t\t\t\t\"bg-[oklch(var(--primary))] text-white hover:opacity-90 transition-colors\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{tAudio(\"linkTodo\")}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t) : null}\n\n\t\t\t<MessageTodoExtractionModal\n\t\t\t\tisOpen={showExtractionModal}\n\t\t\t\tonClose={() => setShowExtractionModal(false)}\n\t\t\t\ttodos={extractionTodosForModal}\n\t\t\t\tparentTodoId={null}\n\t\t\t\tselectedTodoIndexes={showExtractionModal ? selectedIndexes : undefined}\n\t\t\t\tonSelectedTodoIndexesChange={(next) => setSelectedIndexes(next)}\n\t\t\t\tonSuccessWithCreated={async (created) => {\n\t\t\t\t\t// 聚合按 recordingId 调用 link API（减少请求次数）\n\t\t\t\t\tconst byRec = new Map<\n\t\t\t\t\t\tnumber,\n\t\t\t\t\t\tArray<{ kind: \"todo\" | \"schedule\"; item_id: string; todo_id: number }>\n\t\t\t\t\t>();\n\t\t\t\tfor (const row of created) {\n\t\t\t\t\tconst item = extractionTodosForModal[row.index] as unknown as {\n\t\t\t\t\t\t_meta?: { recordingIds: number[]; kind: \"todo\" | \"schedule\"; itemKey: string };\n\t\t\t\t\t};\n\t\t\t\t\tconst meta = item?._meta;\n\t\t\t\t\tif (!meta?.recordingIds?.length || !meta.itemKey) continue;\n\t\t\t\t\tfor (const recId of meta.recordingIds) {\n\t\t\t\t\t\t// 跳过 recId=0（实时提取结果，还没有保存到数据库）\n\t\t\t\t\t\tif (recId <= 0) continue;\n\t\t\t\t\t\tconst arr = byRec.get(recId) ?? [];\n\t\t\t\t\t\tarr.push({ kind: meta.kind, item_id: meta.itemKey, todo_id: row.todoId });\n\t\t\t\t\t\tbyRec.set(recId, arr);\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t\t// 前端即时标记 linked，避免再次出现（优化用户体验）\n\t\t\t\t\tsetExtractionsByRecordingId((prev) => {\n\t\t\t\t\t\tconst next = { ...prev };\n\t\t\t\t\t\tfor (const [recId, links] of byRec.entries()) {\n\t\t\t\t\t\t\tconst ext = next[recId];\n\t\t\t\t\t\t\tif (!ext) continue;\n\n\t\t\t\t\t\t\tconst todoLinks = links.filter((l) => l.kind === \"todo\");\n\t\t\t\t\t\t\tconst schedLinks = links.filter((l) => l.kind === \"schedule\");\n\n\t\t\t\t\t\t\tif (todoLinks.length > 0) {\n\t\t\t\t\t\t\t\tconst keyToTodoId = new Map(todoLinks.map((l) => [l.item_id, l.todo_id]));\n\t\t\t\t\t\t\t\tnext[recId] = {\n\t\t\t\t\t\t\t\t\t...ext,\n\t\t\t\t\t\t\t\t\ttodos: (ext.todos ?? []).map((t) => {\n\t\t\t\t\t\t\t\t\t\tconst k = (t.dedupe_key || t.id || \"\").toString();\n\t\t\t\t\t\t\t\t\t\tif (!k) return t;\n\t\t\t\t\t\t\t\t\t\tconst linkedTodoId = keyToTodoId.get(k);\n\t\t\t\t\t\t\t\t\t\tif (!linkedTodoId) return t;\n\t\t\t\t\t\t\t\t\t\treturn { ...t, linked: true, linked_todo_id: linkedTodoId };\n\t\t\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t\t\t};\n\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\tif (schedLinks.length > 0) {\n\t\t\t\t\t\t\t\tconst keyToTodoId = new Map(schedLinks.map((l) => [l.item_id, l.todo_id]));\n\t\t\t\t\t\t\t\tnext[recId] = {\n\t\t\t\t\t\t\t\t\t...next[recId],\n\t\t\t\t\t\t\t\t\tschedules: (next[recId].schedules ?? []).map((s) => {\n\t\t\t\t\t\t\t\t\t\tconst k = (s.dedupe_key || s.id || \"\").toString();\n\t\t\t\t\t\t\t\t\t\tif (!k) return s;\n\t\t\t\t\t\t\t\t\t\tconst linkedTodoId = keyToTodoId.get(k);\n\t\t\t\t\t\t\t\t\t\tif (!linkedTodoId) return s;\n\t\t\t\t\t\t\t\t\t\treturn { ...s, linked: true, linked_todo_id: linkedTodoId };\n\t\t\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t\t\t};\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn next;\n\t\t\t\t\t});\n\n\t\t\t\t\t// 使用 hook 进行链接和刷新（始终使用优化文本的提取结果）\n\t\t\t\t\ttry {\n\t\t\t\t\t\tawait linkAndRefresh(byRec, (recordingId, data) => {\n\t\t\t\t\t\t\tsetExtractionsByRecordingId((prev) => {\n\t\t\t\t\t\t\t\tconst next = { ...prev };\n\t\t\t\t\t\t\t\tnext[recordingId] = { todos: data.todos, schedules: data.schedules };\n\t\t\t\t\t\t\t\treturn next;\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t});\n\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\tconsole.error(\"Failed to link and refresh extraction data:\", error);\n\t\t\t\t\t\t// 即使失败，前端已经标记了 linked，所以用户体验不受影响\n\t\t\t\t\t}\n\n\t\t\t\t\tsetShowExtractionModal(false);\n\t\t\t\t}}\n\t\t\t/>\n\t\t</>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/audio/components/AudioHeader.tsx",
    "content": "\"use client\";\n\nimport { motion } from \"framer-motion\";\nimport { Calendar, ChevronLeft, ChevronRight, Mic, MicOff, Radio, Upload } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport { useRef } from \"react\";\n\ninterface AudioHeaderProps {\n\tisRecording: boolean;\n\tselectedDate: Date;\n\tonDateChange: (date: Date) => void;\n\tonUpload?: () => void;\n\tonJumpToCurrentDate?: () => void; // 跳转到当前日期\n\tonStartRecording?: () => void; // 手动开始录音\n\tonStopRecording?: () => void; // 手动停止录音\n}\n\nexport function AudioHeader({\n\tisRecording,\n\tselectedDate,\n\tonDateChange,\n\tonUpload,\n\tonJumpToCurrentDate,\n\tonStartRecording,\n\tonStopRecording,\n}: AudioHeaderProps) {\n\tconst t = useTranslations(\"page\");\n\tconst dateInputRef = useRef<HTMLInputElement | null>(null);\n\n\tconst formatDate = (date: Date) => {\n\t\tconst year = date.getFullYear();\n\t\tconst month = date.getMonth() + 1;\n\t\tconst day = date.getDate();\n\t\treturn `${year}年${month}月${day}日 录音`;\n\t};\n\n\tconst handlePrevDay = () => {\n\t\tconst prevDate = new Date(selectedDate);\n\t\tprevDate.setDate(prevDate.getDate() - 1);\n\t\tonDateChange(prevDate);\n\t};\n\n\tconst handleNextDay = () => {\n\t\tconst nextDate = new Date(selectedDate);\n\t\tnextDate.setDate(nextDate.getDate() + 1);\n\t\tonDateChange(nextDate);\n\t};\n\n\tconst handleToday = () => {\n\t\t// 点击“今天”弹出日历选择器（同时可快速选回今天）\n\t\tif (dateInputRef.current) {\n\t\t\tdateInputRef.current.showPicker?.();\n\t\t\tdateInputRef.current.click();\n\t\t} else {\n\t\t\tonDateChange(new Date());\n\t\t}\n\t};\n\n\treturn (\n\t\t<div className=\"flex items-center justify-between px-4 py-3 border-b border-[oklch(var(--border))]\">\n\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t<input\n\t\t\t\t\tref={dateInputRef}\n\t\t\t\t\ttype=\"date\"\n\t\t\t\t\tclassName=\"sr-only\"\n\t\t\t\t\tvalue={selectedDate.toISOString().slice(0, 10)}\n\t\t\t\t\tonChange={(e) => {\n\t\t\t\t\t\tconst v = e.target.value; // YYYY-MM-DD\n\t\t\t\t\t\tif (v) onDateChange(new Date(`${v}T00:00:00`));\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tclassName=\"p-1.5 rounded hover:bg-[oklch(var(--muted))] transition-colors\"\n\t\t\t\t\tonClick={handlePrevDay}\n\t\t\t\t>\n\t\t\t\t\t<ChevronLeft className=\"h-4 w-4\" />\n\t\t\t\t</button>\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tclassName=\"p-1.5 rounded hover:bg-[oklch(var(--muted))] transition-colors\"\n\t\t\t\t\tonClick={handleToday}\n\t\t\t\t\ttitle=\"选择日期\"\n\t\t\t\t>\n\t\t\t\t\t<Calendar className=\"h-4 w-4\" />\n\t\t\t\t</button>\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tclassName=\"p-1.5 rounded hover:bg-[oklch(var(--muted))] transition-colors\"\n\t\t\t\t\tonClick={handleNextDay}\n\t\t\t\t>\n\t\t\t\t\t<ChevronRight className=\"h-4 w-4\" />\n\t\t\t\t</button>\n\t\t\t\t<span className=\"text-sm text-[oklch(var(--muted-foreground))] ml-2\">\n\t\t\t\t\t{formatDate(selectedDate)}\n\t\t\t\t</span>\n\t\t\t</div>\n\n\t\t\t<div className=\"flex items-center gap-3\">\n\t\t\t\t{onUpload && (\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tclassName=\"px-3 py-1.5 text-sm rounded-md hover:bg-[oklch(var(--muted))] transition-colors\"\n\t\t\t\t\t\tonClick={onUpload}\n\t\t\t\t\t>\n\t\t\t\t\t\t<Upload className=\"h-4 w-4 inline mr-1\" />\n\t\t\t\t\t\t测试音频\n\t\t\t\t\t</button>\n\t\t\t\t)}\n\t\t\t\t{/* 录音控制开关 */}\n\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t{/* 录音状态指示器（点击可跳转到当前日期） */}\n\t\t\t\t\t{isRecording && (\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\tif (onJumpToCurrentDate) {\n\t\t\t\t\t\t\t\t\tonJumpToCurrentDate();\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tclassName=\"flex items-center gap-2 px-3 py-2 rounded-md bg-red-500/10 hover:bg-red-500/20 transition-colors cursor-pointer\"\n\t\t\t\t\t\t\ttitle={t(\"audioRecordingStatus\")}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<div className=\"relative flex items-center justify-center flex-shrink-0\">\n\t\t\t\t\t\t\t\t{/* 脉冲动画 */}\n\t\t\t\t\t\t\t\t<motion.div\n\t\t\t\t\t\t\t\t\tclassName=\"absolute w-full h-full bg-red-500/30 rounded-full\"\n\t\t\t\t\t\t\t\t\tanimate={{ scale: [1, 1.5, 1], opacity: [0.3, 0, 0.3] }}\n\t\t\t\t\t\t\t\t\ttransition={{\n\t\t\t\t\t\t\t\t\t\tduration: 1.5,\n\t\t\t\t\t\t\t\t\t\trepeat: Infinity,\n\t\t\t\t\t\t\t\t\t\tease: \"easeOut\",\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t<div className=\"relative z-10 p-1.5 rounded-full bg-red-500/20\">\n\t\t\t\t\t\t\t\t\t<Mic className=\"h-4 w-4 text-red-500\" />\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<span className=\"text-sm font-medium text-red-500\">{t(\"audioRecording\")}</span>\n\t\t\t\t\t\t\t<motion.div\n\t\t\t\t\t\t\t\tclassName=\"flex items-center\"\n\t\t\t\t\t\t\t\tinitial={{ opacity: 0 }}\n\t\t\t\t\t\t\t\tanimate={{ opacity: [0, 1, 0] }}\n\t\t\t\t\t\t\t\ttransition={{\n\t\t\t\t\t\t\t\t\tduration: 1.5,\n\t\t\t\t\t\t\t\t\trepeat: Infinity,\n\t\t\t\t\t\t\t\t\tease: \"easeInOut\",\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<Radio className=\"h-3 w-3 text-red-500\" />\n\t\t\t\t\t\t\t</motion.div>\n\t\t\t\t\t\t</button>\n\t\t\t\t\t)}\n\n\t\t\t\t\t{/* 录音开关按钮 */}\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\tif (isRecording) {\n\t\t\t\t\t\t\t\tonStopRecording?.();\n\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\tonStartRecording?.();\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tclassName={`flex items-center gap-2 px-3 py-2 rounded-md transition-colors ${\n\t\t\t\t\t\t\tisRecording\n\t\t\t\t\t\t\t\t? \"bg-red-500/10 hover:bg-red-500/20 text-red-500\"\n\t\t\t\t\t\t\t\t: \"bg-green-500/10 hover:bg-green-500/20 text-green-600 dark:text-green-400\"\n\t\t\t\t\t\t}`}\n\t\t\t\t\t\ttitle={isRecording ? t(\"audioStopRecording\") : t(\"audioStartRecording\")}\n\t\t\t\t\t>\n\t\t\t\t\t\t{isRecording ? (\n\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t<MicOff className=\"h-4 w-4\" />\n\t\t\t\t\t\t\t\t<span className=\"text-sm font-medium\">{t(\"audioStopRecording\")}</span>\n\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t<Mic className=\"h-4 w-4\" />\n\t\t\t\t\t\t\t\t<span className=\"text-sm font-medium\">{t(\"audioStartRecording\")}</span>\n\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</button>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/audio/components/AudioList.tsx",
    "content": "\"use client\";\n\nimport { Play } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\ninterface AudioRecording {\n\tid: number;\n\tdate: string;\n\ttime: string;\n\tduration: string;\n\tsize: string;\n\tisCurrent?: boolean;\n}\n\ninterface AudioListProps {\n\trecordings: AudioRecording[];\n\tonPlay?: (id: number) => void;\n}\n\nexport function AudioList({ recordings, onPlay }: AudioListProps) {\n\treturn (\n\t\t<div className=\"px-4 py-2 border-b border-[oklch(var(--border))]\">\n\t\t\t<div className=\"text-xs font-medium mb-2\">♫ 音频列表</div>\n\t\t\t<div className=\"space-y-1\">\n\t\t\t\t{recordings.map((audio) => (\n\t\t\t\t\t<div\n\t\t\t\t\t\tkey={audio.id}\n\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\"flex items-center justify-between p-2 rounded text-xs\",\n\t\t\t\t\t\t\taudio.isCurrent\n\t\t\t\t\t\t\t\t? \"bg-[oklch(var(--primary))]/10\"\n\t\t\t\t\t\t\t\t: \"hover:bg-[oklch(var(--muted))]\"\n\t\t\t\t\t\t)}\n\t\t\t\t\t>\n\t\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t\t{audio.isCurrent && (\n\t\t\t\t\t\t\t\t<span className=\"px-1.5 py-0.5 text-xs rounded bg-[oklch(var(--primary))] text-white\">\n\t\t\t\t\t\t\t\t\t当前\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t<span>{audio.time}</span>\n\t\t\t\t\t\t\t<span className=\"text-[oklch(var(--muted-foreground))]\">{audio.duration}</span>\n\t\t\t\t\t\t\t<span className=\"text-[oklch(var(--muted-foreground))]\">{audio.size}</span>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t{onPlay && (\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\tclassName=\"p-1 rounded hover:bg-[oklch(var(--muted))]\"\n\t\t\t\t\t\t\t\tonClick={() => onPlay(audio.id)}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<Play className=\"h-3 w-3\" />\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t))}\n\t\t\t</div>\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/audio/components/AudioPlayer.tsx",
    "content": "\"use client\";\n\nimport { Pause, Play } from \"lucide-react\";\n\ninterface AudioPlayerProps {\n\ttitle: string;\n\tdate: string;\n\tcurrentTime?: string;\n\ttotalTime?: string;\n\tisPlaying?: boolean;\n\tonPlay?: () => void;\n\t/** 当前进度，0~1 之间 */\n\tprogress?: number;\n\tonSeek?: (ratio: number) => void;\n\t/** 当前段落文本（随点击文本同步） */\n\tcurrentSegmentText?: string;\n\t/** 播放倍速 */\n\tplaybackRate?: number;\n\t/** 设置播放倍速 */\n\tonPlaybackRateChange?: (rate: number) => void;\n}\n\nexport function AudioPlayer({\n\ttitle,\n\tdate,\n\tcurrentTime = \"0:00\",\n\ttotalTime = \"0:00\",\n\tisPlaying = false,\n\tonPlay,\n\tprogress = 0,\n\tonSeek,\n\tcurrentSegmentText = \"\",\n\tplaybackRate = 1.0,\n\tonPlaybackRateChange,\n}: AudioPlayerProps) {\n\tconst clampedProgress = Number.isFinite(progress) ? Math.min(Math.max(progress, 0), 1) : 0;\n\n\treturn (\n\t\t<div className=\"px-4 py-2 flex items-center gap-3 border-t border-[oklch(var(--border))] bg-[oklch(var(--muted))]/30\">\n\t\t\t<button\n\t\t\t\ttype=\"button\"\n\t\t\t\tclassName=\"p-1.5 rounded-full bg-[oklch(var(--primary))] text-white hover:opacity-60\"\n\t\t\t\tonClick={onPlay}\n\t\t\t>\n\t\t\t\t{isPlaying ? <Pause className=\"h-4 w-4\" /> : <Play className=\"h-4 w-4\" />}\n\t\t\t</button>\n\t\t\t<div className=\"flex-1\">\n\t\t\t\t<div className=\"text-xs font-medium\">{title}</div>\n\t\t\t\t<div className=\"text-xs text-[oklch(var(--muted-foreground))]\">{date}</div>\n\t\t\t\t{currentSegmentText ? (\n\t\t\t\t\t<div className=\"mt-1 text-xs text-[oklch(var(--foreground))] line-clamp-1\">\n\t\t\t\t\t\t{currentSegmentText}\n\t\t\t\t\t</div>\n\t\t\t\t) : null}\n\t\t\t\t<div className=\"flex items-center gap-2 mt-1\">\n\t\t\t\t\t<span className=\"text-xs text-[oklch(var(--muted-foreground))]\">{currentTime}</span>\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\taria-label=\"拖动进度条\"\n\t\t\t\t\t\tdisabled={!onSeek}\n\t\t\t\t\t\tclassName=\"relative flex-1 h-1.5 bg-[oklch(var(--muted))] rounded-full cursor-pointer group disabled:cursor-default\"\n\t\t\t\t\t\tonClick={(e) => {\n\t\t\t\t\t\t\tif (!onSeek) return;\n\t\t\t\t\t\t\tconst rect = (e.currentTarget as HTMLButtonElement).getBoundingClientRect();\n\t\t\t\t\t\t\tconst ratio = (e.clientX - rect.left) / rect.width;\n\t\t\t\t\t\t\tonSeek(Math.min(Math.max(ratio, 0), 1));\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tonKeyDown={(e) => {\n\t\t\t\t\t\t\tif (!onSeek) return;\n\t\t\t\t\t\t\tif (e.key !== \"ArrowLeft\" && e.key !== \"ArrowRight\") return;\n\t\t\t\t\t\t\te.preventDefault();\n\t\t\t\t\t\t\tconst step = 0.05;\n\t\t\t\t\t\t\tonSeek(clampedProgress + (e.key === \"ArrowRight\" ? step : -step));\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"absolute left-0 top-0 h-full rounded-full bg-[oklch(var(--primary))] group-hover:bg-[oklch(var(--primary))/80] transition-colors\"\n\t\t\t\t\t\t\tstyle={{ width: `${clampedProgress * 100}%` }}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</button>\n\t\t\t\t\t<span className=\"text-xs text-[oklch(var(--muted-foreground))]\">{totalTime}</span>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t\t<select\n\t\t\t\tvalue={playbackRate}\n\t\t\t\tonChange={(e) => {\n\t\t\t\t\tconst rate = parseFloat(e.target.value);\n\t\t\t\t\tif (onPlaybackRateChange) {\n\t\t\t\t\t\tonPlaybackRateChange(rate);\n\t\t\t\t\t}\n\t\t\t\t}}\n\t\t\t\tclassName=\"text-xs px-2 py-1 rounded border border-[oklch(var(--border))] bg-[oklch(var(--background))]\"\n\t\t\t>\n\t\t\t\t<option value={0.5}>0.5x</option>\n\t\t\t\t<option value={0.75}>0.75x</option>\n\t\t\t\t<option value={1.0}>1x</option>\n\t\t\t\t<option value={1.25}>1.25x</option>\n\t\t\t\t<option value={1.5}>1.5x</option>\n\t\t\t\t<option value={1.75}>1.75x</option>\n\t\t\t\t<option value={2.0}>2x</option>\n\t\t\t</select>\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/audio/components/RecordingStatus.tsx",
    "content": "\"use client\";\n\nimport { motion } from \"framer-motion\";\nimport { Mic, Radio } from \"lucide-react\";\nimport { useEffect, useState } from \"react\";\n\ninterface RecordingStatusProps {\n\tisRecording: boolean;\n\trecordingStartedAt?: number; // 录音开始时间（performance.now()，毫秒）\n\tduration?: number; // 录音时长（秒，预留用于外部传入）\n}\n\nexport function RecordingStatus({ isRecording, recordingStartedAt }: RecordingStatusProps) {\n\tconst [elapsedTime, setElapsedTime] = useState(0);\n\n\t// 实时更新录音时长（基于录音开始时间，不会因为组件重新挂载而重置）\n\tuseEffect(() => {\n\t\tif (!isRecording || !recordingStartedAt) {\n\t\t\tsetElapsedTime(0);\n\t\t\treturn;\n\t\t}\n\n\t\t// 立即计算一次当前已录音时长\n\t\tconst updateElapsedTime = () => {\n\t\t\tconst elapsed = Math.floor((performance.now() - recordingStartedAt) / 1000);\n\t\t\tsetElapsedTime(Math.max(0, elapsed));\n\t\t};\n\n\t\tupdateElapsedTime(); // 立即更新一次\n\n\t\tconst interval = setInterval(updateElapsedTime, 1000);\n\n\t\treturn () => clearInterval(interval);\n\t}, [isRecording, recordingStartedAt]);\n\n\tconst formatTime = (seconds: number) => {\n\t\tconst mins = Math.floor(seconds / 60);\n\t\tconst secs = seconds % 60;\n\t\treturn `${mins}:${secs.toString().padStart(2, \"0\")}`;\n\t};\n\n\treturn (\n\t\t<div className=\"px-4 py-3 flex items-center gap-3 border-t border-[oklch(var(--border))] bg-[oklch(var(--muted))]/30\">\n\t\t\t{/* 录音指示器 */}\n\t\t\t<div className=\"relative flex items-center justify-center flex-shrink-0\">\n\t\t\t\t{isRecording ? (\n\t\t\t\t\t<>\n\t\t\t\t\t\t{/* 脉冲动画 */}\n\t\t\t\t\t\t<motion.div\n\t\t\t\t\t\t\tclassName=\"absolute w-full h-full bg-red-500/30 rounded-full\"\n\t\t\t\t\t\t\tanimate={{ scale: [1, 1.5, 1], opacity: [0.3, 0, 0.3] }}\n\t\t\t\t\t\t\ttransition={{\n\t\t\t\t\t\t\t\tduration: 1.5,\n\t\t\t\t\t\t\t\trepeat: Infinity,\n\t\t\t\t\t\t\t\tease: \"easeOut\",\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<div className=\"relative z-10 p-2 rounded-full bg-red-500/20\">\n\t\t\t\t\t\t\t<Mic className=\"h-4 w-4 text-red-500\" />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</>\n\t\t\t\t) : (\n\t\t\t\t\t<div className=\"p-2 rounded-full bg-[oklch(var(--muted))]\">\n\t\t\t\t\t\t<Mic className=\"h-4 w-4 text-[oklch(var(--muted-foreground))]\" />\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\n\t\t\t{/* 状态信息 */}\n\t\t\t<div className=\"flex-1 min-w-0\">\n\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t{isRecording ? (\n\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t<span className=\"text-sm font-medium text-red-500\">正在录音</span>\n\t\t\t\t\t\t\t<motion.div\n\t\t\t\t\t\t\t\tclassName=\"flex items-center gap-1\"\n\t\t\t\t\t\t\t\tinitial={{ opacity: 0 }}\n\t\t\t\t\t\t\t\tanimate={{ opacity: [0, 1, 0] }}\n\t\t\t\t\t\t\t\ttransition={{\n\t\t\t\t\t\t\t\t\tduration: 1.5,\n\t\t\t\t\t\t\t\t\trepeat: Infinity,\n\t\t\t\t\t\t\t\t\tease: \"easeInOut\",\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<Radio className=\"h-3 w-3 text-red-500\" />\n\t\t\t\t\t\t\t</motion.div>\n\t\t\t\t\t\t</>\n\t\t\t\t\t) : (\n\t\t\t\t\t\t<span className=\"text-sm text-[oklch(var(--muted-foreground))]\">待机中</span>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t\t<div className=\"flex items-center gap-2 mt-1\">\n\t\t\t\t\t<span className=\"text-xs text-[oklch(var(--muted-foreground))]\">\n\t\t\t\t\t\t时长: {formatTime(elapsedTime)}\n\t\t\t\t\t</span>\n\t\t\t\t\t{isRecording && (\n\t\t\t\t\t\t<div className=\"flex items-center gap-1\">\n\t\t\t\t\t\t\t<div className=\"w-1.5 h-1.5 bg-red-500 rounded-full animate-pulse\" />\n\t\t\t\t\t\t\t<span className=\"text-xs text-red-500\">实时转录中</span>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t{/* 波形指示器（可选） */}\n\t\t\t{isRecording && (\n\t\t\t\t<div className=\"flex items-center gap-1 h-6\">\n\t\t\t\t\t{[0, 1, 2, 3].map((i) => (\n\t\t\t\t\t\t<motion.div\n\t\t\t\t\t\t\tkey={i}\n\t\t\t\t\t\t\tclassName=\"w-1 bg-red-500 rounded-full\"\n\t\t\t\t\t\t\tanimate={{\n\t\t\t\t\t\t\t\theight: [4, 16, 4],\n\t\t\t\t\t\t\t\topacity: [0.5, 1, 0.5],\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\ttransition={{\n\t\t\t\t\t\t\t\tduration: 0.8,\n\t\t\t\t\t\t\t\trepeat: Infinity,\n\t\t\t\t\t\t\t\tdelay: i * 0.1,\n\t\t\t\t\t\t\t\tease: \"easeInOut\",\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t))}\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/audio/components/StopRecordingConfirm.tsx",
    "content": "\"use client\";\n\ninterface StopRecordingConfirmProps {\n\tisOpen: boolean;\n\tonCancel: () => void;\n\tonConfirm: () => void;\n}\n\nexport function StopRecordingConfirm({ isOpen, onCancel, onConfirm }: StopRecordingConfirmProps) {\n\tif (!isOpen) return null;\n\treturn (\n\t\t<div className=\"fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm\">\n\t\t\t<div className=\"bg-[oklch(var(--background))] border border-[oklch(var(--border))] rounded-lg shadow-lg w-[320px] p-4 space-y-4\">\n\t\t\t\t<div className=\"text-base font-semibold text-[oklch(var(--foreground))]\">停止录音？</div>\n\t\t\t\t<p className=\"text-sm text-[oklch(var(--muted-foreground))]\">\n\t\t\t\t\t确定要停止当前录音吗？停止后将保存当前音频并结束实时转写。\n\t\t\t\t</p>\n\t\t\t\t<div className=\"flex justify-end gap-2\">\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tclassName=\"px-3 py-1.5 rounded-md text-sm border border-[oklch(var(--border))] text-[oklch(var(--foreground))] hover:bg-[oklch(var(--muted))/50]\"\n\t\t\t\t\t\tonClick={onCancel}\n\t\t\t\t\t>\n\t\t\t\t\t\t取消\n\t\t\t\t\t</button>\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tclassName=\"px-3 py-1.5 rounded-md text-sm bg-[oklch(var(--primary))] text-white shadow hover:opacity-90\"\n\t\t\t\t\t\tonClick={onConfirm}\n\t\t\t\t\t>\n\t\t\t\t\t\t停止\n\t\t\t\t\t</button>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/audio/components/TranscriptionView.tsx",
    "content": "\n\"use client\";\n\nimport { useEffect, useLayoutEffect, useMemo, useRef } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\ninterface TodoItem {\n\ttitle: string;\n\tdescription?: string;\n\tdeadline?: string;\n\t// 后端 LLM 可能返回的原文高亮片段（用于更精准的高亮）\n\tsource_text?: string;\n}\n\ninterface ScheduleItem {\n\ttitle: string;\n\ttime?: string;\n\tdescription?: string;\n\tsource_text?: string;\n}\n\ninterface TranscriptionViewProps {\n\toriginalText: string;\n\toptimizedText: string;\n\tpartialText?: string;\n\tactiveTab: \"original\" | \"optimized\";\n\tonTabChange: (tab: \"original\" | \"optimized\") => void;\n\t/** 每个文本段对应的待办高亮列表（与时间线一一对应） */\n\tsegmentTodos?: TodoItem[][];\n\t/** 每个文本段对应的日程高亮列表（与时间线一一对应） */\n\tsegmentSchedules?: ScheduleItem[][];\n\tisRecording?: boolean;\n\tsegmentTimesSec?: number[];\n\tsegmentTimeLabels?: string[];\n\tselectedSegmentIndex?: number | null;\n\tonSegmentClick?: (index: number) => void;\n\tisLoadingTimeline?: boolean; // 正在加载时间线（显示在文本区域底部）\n}\n\ninterface TextSegment {\n\ttext: string;\n\thighlight?: \"todo\" | \"schedule\";\n}\n\nexport function TranscriptionView({\n\toriginalText,\n\toptimizedText,\n\tpartialText = \"\",\n\tactiveTab,\n\tonTabChange,\n\tsegmentTodos = [],\n\tsegmentSchedules = [],\n\tisRecording = false,\n\tsegmentTimesSec = [],\n\tsegmentTimeLabels = [],\n\tselectedSegmentIndex = null,\n\tonSegmentClick,\n\tisLoadingTimeline = false,\n}: TranscriptionViewProps) {\n\tconst transcriptionRef = useRef<HTMLDivElement>(null);\n\tconst userNearBottomRef = useRef(true);\n\tconst lastContentHashRef = useRef(\"\");\n\n\tconst formatTime = (seconds: number) => {\n\t\tif (!Number.isFinite(seconds) || seconds < 0) return \"\";\n\t\tconst mins = Math.floor(seconds / 60);\n\t\tconst secs = Math.floor(seconds % 60);\n\t\treturn `${mins}:${secs.toString().padStart(2, \"0\")}`;\n\t};\n\n\t// 高亮显示待办和日程，并自动分段\n\tconst highlightedContent = useMemo(() => {\n\t\tconst text = activeTab === \"original\" ? originalText : optimizedText;\n\t\tif (!text) {\n\t\t\treturn [];\n\t\t}\n\n\t\t// 自动分段：按换行符分段，如果没有换行符则按句子结束标记分段\n\t\tconst segments = text.split(\"\\n\").filter((s) => s.trim());\n\t\tif (segments.length === 0) {\n\t\t\t// 如果没有换行符，尝试按句子结束标记分段\n\t\t\tconst sentenceEndings = /[。！？.!?]/g;\n\t\t\tconst parts = text.split(sentenceEndings);\n\t\t\tsegments.push(...parts.filter((s) => s.trim()));\n\t\t}\n\n\t\t// 帮助函数：支持“归一化匹配”（用于处理 LLM/智能优化带来的微小差异）\n\t\t// - 忽略空白字符\n\t\t// - 忽略常见中文/英文标点\n\t\t// - 忽略“钟”（常见于“八点钟” vs “八点”）\n\t\tconst findNormalizedMatch = (segment: string, raw: string) => {\n\t\t\tconst isIgnorable = (ch: string) => {\n\t\t\t\tif (/\\s/.test(ch)) return true;\n\t\t\t\t// 常见标点：中英文 + 顿号/冒号/分号/引号/括号\n\t\t\t\tif (/[，。！？、,.!?；;：:“”\"‘’'（）()【】[\\]《》<>]/.test(ch)) return true;\n\t\t\t\t// “点钟/八点钟”里的“钟”常导致不匹配\n\t\t\t\tif (ch === \"钟\") return true;\n\t\t\t\treturn false;\n\t\t\t};\n\n\t\t\t// 构造“去掉可忽略字符”的版本，并记录映射关系\n\t\t\tconst segChars = Array.from(segment);\n\t\t\tconst compactSeg: string[] = [];\n\t\t\tconst indexMap: number[] = [];\n\t\t\tfor (let i = 0; i < segChars.length; i++) {\n\t\t\t\tconst ch = segChars[i];\n\t\t\t\tif (!isIgnorable(ch)) {\n\t\t\t\t\tcompactSeg.push(ch);\n\t\t\t\t\tindexMap.push(i);\n\t\t\t\t}\n\t\t\t}\n\t\t\tconst compactSegment = compactSeg.join(\"\");\n\n\t\t\tconst candChars = Array.from(raw);\n\t\t\tconst compactCand = candChars.filter((ch) => !isIgnorable(ch)).join(\"\");\n\t\t\tif (!compactCand) return null;\n\n\t\t\tconst startCompact = compactSegment.indexOf(compactCand);\n\t\t\tif (startCompact === -1) return null;\n\n\t\t\tconst endCompact = startCompact + compactCand.length - 1;\n\t\t\tconst start = indexMap[startCompact];\n\t\t\tconst end = indexMap[endCompact] + 1;\n\t\t\tif (start == null || end == null) return null;\n\t\t\treturn { start, end };\n\t\t};\n\n\t\t// 为每个段落创建高亮\n\t\tconst computed = segments.map((segment, segmentIndex) => {\n\t\t\tconst highlights: Array<{ start: number; end: number; type: \"todo\" | \"schedule\" }> = [];\n\n\t\t\t// 当前段落对应的高亮数据（按录音ID预先拆分好）\n\t\t\tconst todosForSegment = segmentTodos[segmentIndex] ?? [];\n\t\t\tconst schedulesForSegment = segmentSchedules[segmentIndex] ?? [];\n\n\t\t\t// 高亮待办事项：优先使用 LLM 明确给出的 source_text，其次再用 title / description / deadline 进行匹配\n\t\t\ttodosForSegment.forEach((todo) => {\n\t\t\t\tconst candidates = new Set<string>();\n\t\t\t\tif (todo.source_text?.trim()) candidates.add(todo.source_text.trim());\n\t\t\t\tif (todo.title?.trim()) candidates.add(todo.title.trim());\n\t\t\t\tif (todo.description?.trim()) candidates.add(todo.description.trim());\n\t\t\t\tif (todo.deadline?.trim()) candidates.add(todo.deadline.trim());\n\n\t\t\t\tcandidates.forEach((searchText) => {\n\t\t\t\t\t// 太短的关键词容易误伤，忽略\n\t\t\t\t\tif (!searchText || searchText.length < 2) return;\n\n\t\t\t\t\tlet hasDirectMatch = false;\n\t\t\t\tlet index = segment.indexOf(searchText);\n\t\t\t\twhile (index !== -1) {\n\t\t\t\t\t\thasDirectMatch = true;\n\t\t\t\t\thighlights.push({\n\t\t\t\t\t\tstart: index,\n\t\t\t\t\t\tend: index + searchText.length,\n\t\t\t\t\t\ttype: \"todo\",\n\t\t\t\t\t});\n\t\t\t\t\tindex = segment.indexOf(searchText, index + searchText.length);\n\t\t\t\t}\n\n\t\t\t\t\t// 如果原文中没有完全相同的子串，再尝试“去空格”的宽松匹配\n\t\t\t\t\tif (!hasDirectMatch) {\n\t\t\t\t\t\tconst normalized = findNormalizedMatch(segment, searchText);\n\t\t\t\t\t\tif (normalized) {\n\t\t\t\t\t\t\thighlights.push({\n\t\t\t\t\t\t\t\tstart: normalized.start,\n\t\t\t\t\t\t\t\tend: normalized.end,\n\t\t\t\t\t\t\t\ttype: \"todo\",\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t\t});\n\n\t\t\t// 高亮日程安排：优先使用 source_text，其次再用 title / description / time\n\t\t\tschedulesForSegment.forEach((schedule) => {\n\t\t\t\tconst candidates = new Set<string>();\n\t\t\t\tif (schedule.source_text?.trim()) candidates.add(schedule.source_text.trim());\n\t\t\t\tif (schedule.title?.trim()) candidates.add(schedule.title.trim());\n\t\t\t\tif (schedule.description?.trim()) candidates.add(schedule.description.trim());\n\t\t\t\tif (schedule.time?.trim()) candidates.add(schedule.time.trim());\n\n\t\t\t\tcandidates.forEach((searchText) => {\n\t\t\t\t\tif (!searchText || searchText.length < 2) return;\n\t\t\t\t\tlet hasDirectMatch = false;\n\t\t\t\tlet index = segment.indexOf(searchText);\n\t\t\t\twhile (index !== -1) {\n\t\t\t\t\t\thasDirectMatch = true;\n\t\t\t\t\thighlights.push({\n\t\t\t\t\t\tstart: index,\n\t\t\t\t\t\tend: index + searchText.length,\n\t\t\t\t\t\ttype: \"schedule\",\n\t\t\t\t\t});\n\t\t\t\t\tindex = segment.indexOf(searchText, index + searchText.length);\n\t\t\t\t}\n\n\t\t\t\t\tif (!hasDirectMatch) {\n\t\t\t\t\t\tconst normalized = findNormalizedMatch(segment, searchText);\n\t\t\t\t\t\tif (normalized) {\n\t\t\t\t\t\t\thighlights.push({\n\t\t\t\t\t\t\t\tstart: normalized.start,\n\t\t\t\t\t\t\t\tend: normalized.end,\n\t\t\t\t\t\t\t\ttype: \"schedule\",\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t\t});\n\n\t\t\t// 按位置排序并合并重叠\n\t\t\thighlights.sort((a, b) => a.start - b.start);\n\t\t\tconst merged: typeof highlights = [];\n\t\t\tfor (const h of highlights) {\n\t\t\t\tif (merged.length === 0 || merged[merged.length - 1].end < h.start) {\n\t\t\t\t\tmerged.push(h);\n\t\t\t\t} else {\n\t\t\t\t\tmerged[merged.length - 1].end = Math.max(merged[merged.length - 1].end, h.end);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// 将文本转换为带高亮的片段数组\n\t\t\tconst textSegments: TextSegment[] = [];\n\t\t\tlet lastIndex = 0;\n\n\t\t\tfor (const h of merged) {\n\t\t\t\t// 添加高亮前的文本\n\t\t\t\tif (h.start > lastIndex) {\n\t\t\t\t\ttextSegments.push({ text: segment.substring(lastIndex, h.start) });\n\t\t\t\t}\n\t\t\t\t// 添加高亮文本\n\t\t\t\ttextSegments.push({\n\t\t\t\t\ttext: segment.substring(h.start, h.end),\n\t\t\t\t\thighlight: h.type,\n\t\t\t\t});\n\t\t\t\tlastIndex = h.end;\n\t\t\t}\n\n\t\t\t// 添加剩余文本\n\t\t\tif (lastIndex < segment.length) {\n\t\t\t\ttextSegments.push({ text: segment.substring(lastIndex) });\n\t\t\t}\n\n\t\t\treturn textSegments.length > 0 ? textSegments : [{ text: segment }];\n\t\t});\n\t\treturn computed;\n\t}, [originalText, optimizedText, activeTab, segmentTodos, segmentSchedules]);\n\n\t// 当前实际展示的整段文本（用于滚动计算等）\n\tconst displayedTextKey = useMemo(\n\t\t() => `${activeTab}:${(activeTab === \"original\" ? originalText : optimizedText).length}`,\n\t\t[activeTab, originalText, optimizedText]\n\t);\n\n\t// 自动滚动策略：\n\t// - 回看模式：不强制滚动（从顶部开始，用户自己滚）\n\t// - 录音模式：仅当用户在底部附近时才自动滚动到最新\n\tuseLayoutEffect(() => {\n\t\tconst el = transcriptionRef.current;\n\t\tif (!el) return;\n\n\t\tconst currentText = activeTab === \"original\" ? originalText : optimizedText;\n\t\tconst contentHash = `${activeTab}:${currentText.length}:${partialText.length}`;\n\t\tif (contentHash === lastContentHashRef.current) return;\n\t\tlastContentHashRef.current = contentHash;\n\n\t\tif (!isRecording) return;\n\t\tif (!userNearBottomRef.current) return;\n\t\tel.scrollTop = el.scrollHeight;\n\t}, [originalText, optimizedText, partialText, activeTab, isRecording]);\n\n\t// 非录音模式：每次文本内容变化时，自动滚动到底部，方便刷新/进入页面后看到最新内容\n\tuseEffect(() => {\n\t\tif (isRecording) return;\n\t\t// 读取 displayedTextKey 以保证依赖关系完整（内容变化时重新滚动）\n\t\t// eslint-disable-next-line @typescript-eslint/no-unused-expressions\n\t\tdisplayedTextKey;\n\t\tconst el = transcriptionRef.current;\n\t\tif (!el) return;\n\t\trequestAnimationFrame(() => {\n\t\t\tel.scrollTop = el.scrollHeight;\n\t\t});\n\t}, [isRecording, displayedTextKey]);\n\n\t// 开始录音时，强制认为在底部并立即滚到底，避免首次不自动滚动\n\tuseEffect(() => {\n\t\tif (!isRecording) return;\n\t\tuserNearBottomRef.current = true;\n\t\tconst el = transcriptionRef.current;\n\t\tif (el) {\n\t\t\trequestAnimationFrame(() => {\n\t\t\t\tel.scrollTop = el.scrollHeight;\n\t\t\t});\n\t\t}\n\t}, [isRecording]);\n\n\tconst currentText = activeTab === \"original\" ? originalText : optimizedText;\n\tconst hasContent = currentText.length > 0 || (activeTab === \"original\" && partialText.length > 0);\n\t// 如果正在加载，显示加载状态而不是空状态\n\tconst showLoading = isLoadingTimeline && !hasContent;\n\n\treturn (\n\t\t<div className=\"flex-1 flex flex-col min-h-0\">\n\t\t\t{/* 标签页：右对齐按钮，风格贴近参考图 */}\n\t\t\t<div className=\"flex justify-start gap-2 px-2 pb-3 mt-2 border-b border-[oklch(var(--border))]\">\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tonClick={() => onTabChange(\"original\")}\n\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\"px-3 py-1 text-[13px] font-medium rounded-md transition-colors\",\n\t\t\t\t\t\tactiveTab === \"original\"\n\t\t\t\t\t\t\t? \"bg-[oklch(var(--primary))] text-white shadow-sm\"\n\t\t\t\t\t\t\t: \"bg-[oklch(var(--muted))] text-[oklch(var(--muted-foreground))] hover:text-[oklch(var(--foreground))]\"\n\t\t\t\t\t)}\n\t\t\t\t>\n\t\t\t\t\t原文\n\t\t\t\t</button>\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tonClick={() => onTabChange(\"optimized\")}\n\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\"px-3 py-1 text-[13px] font-medium rounded-md transition-colors\",\n\t\t\t\t\t\tactiveTab === \"optimized\"\n\t\t\t\t\t\t\t? \"bg-[oklch(var(--primary))] text-white shadow-sm\"\n\t\t\t\t\t\t\t: \"bg-[oklch(var(--muted))] text-[oklch(var(--muted-foreground))] hover:text-[oklch(var(--foreground))]\"\n\t\t\t\t\t)}\n\t\t\t\t>\n\t\t\t\t\t智能优化\n\t\t\t\t</button>\n\t\t\t</div>\n\n\t\t\t{/* 转录内容区域 */}\n\t\t\t<div\n\t\t\t\tref={transcriptionRef}\n\t\t\t\tclassName=\"flex-1 overflow-auto px-4 py-5\"\n\t\t\t\tonScroll={() => {\n\t\t\t\t\tconst el = transcriptionRef.current;\n\t\t\t\t\tif (!el) return;\n\t\t\t\t\tconst distanceToBottom = el.scrollHeight - el.scrollTop - el.clientHeight;\n\t\t\t\t\tuserNearBottomRef.current = distanceToBottom < 80;\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t{showLoading ? (\n\t\t\t\t\t<div className=\"flex flex-col items-center justify-center h-full text-center\">\n\t\t\t\t\t\t<div className=\"flex items-center gap-2 py-4 text-sm text-[oklch(var(--muted-foreground))]\">\n\t\t\t\t\t\t\t<div className=\"h-4 w-4 border-2 border-[oklch(var(--primary))] border-t-transparent rounded-full animate-spin\" />\n\t\t\t\t\t\t\t<span>获取中...</span>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t) : hasContent ? (\n\t\t\t\t\t<div className=\"flex flex-col gap-5\">\n\t\t\t\t\t\t{highlightedContent.map((paragraph, paragraphIndex) => {\n\t\t\t\t\t\t\tconst paragraphKey = `${paragraphIndex}-${paragraph.map((s) => s.text).join(\"\").slice(0, 20)}`;\n\t\t\t\t\t\t\tconst timeLabel =\n\t\t\t\t\t\t\t\tsegmentTimeLabels[paragraphIndex] ?? formatTime(segmentTimesSec[paragraphIndex] ?? NaN);\n\t\t\t\t\t\t\tconst showOptimizedTag = activeTab === \"optimized\";\n\t\t\t\t\t\t\tconst isSelected = selectedSegmentIndex != null && selectedSegmentIndex === paragraphIndex;\n\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\tkey={paragraphKey}\n\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\"flex flex-col items-start text-left bg-transparent border-none p-0 gap-1.5 rounded-md px-1 -mx-1\",\n\t\t\t\t\t\t\t\t\t\tisSelected\n\t\t\t\t\t\t\t\t\t\t\t? \"bg-[oklch(var(--muted))] border border-[oklch(var(--border))] shadow-sm\"\n\t\t\t\t\t\t\t\t\t\t\t: \"\",\n\t\t\t\t\t\t\t\t\t\tonSegmentClick ? \"cursor-pointer\" : \"cursor-default\",\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\tonClick={() => onSegmentClick?.(paragraphIndex)}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-2 text-[12px] text-[oklch(var(--muted-foreground))] tabular-nums leading-none\">\n\t\t\t\t\t\t\t\t\t\t<span className=\"inline-flex h-[14px] w-[14px] rounded-[4px] border border-[oklch(var(--border))] bg-[oklch(var(--muted))]/60 shadow-[0_1px_2px_rgba(0,0,0,0.05)] items-center justify-center text-[10px]\">\n\t\t\t\t\t\t\t\t\t\t\t✨\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t<span className=\"mt-[1px]\">{timeLabel || \"--:--\"}</span>\n\t\t\t\t\t\t\t\t\t\t{showOptimizedTag ? (\n\t\t\t\t\t\t\t\t\t\t\t<span className=\"ml-1 inline-flex items-center px-2 py-[2px] rounded-full text-[11px] font-medium bg-[oklch(var(--primary))/15] text-[oklch(var(--primary))]\">\n\t\t\t\t\t\t\t\t\t\t\t\t智能优化\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<p className=\"text-[15px] leading-[1.8] text-[oklch(var(--foreground))]\">\n\t\t\t\t\t\t\t\t\t{paragraph.map((segment, segmentIndex) => {\n\t\t\t\t\t\t\t\t\t\t\tconst segmentKey = `${paragraphIndex}-${segmentIndex}-${segment.text.slice(\n\t\t\t\t\t\t\t\t\t\t\t\t0,\n\t\t\t\t\t\t\t\t\t\t\t\t10,\n\t\t\t\t\t\t\t\t\t\t\t)}`;\n\t\t\t\t\t\t\t\t\t\tif (segment.highlight) {\n\t\t\t\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tkey={segmentKey}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"px-1 rounded-md bg-[oklch(var(--primary))/18] text-[oklch(var(--primary))] font-semibold\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{segment.text}\n\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\treturn <span key={segmentKey}>{segment.text}</span>;\n\t\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t})}\n\t\t\t\t\t\t{/* 实时未完成文本：用斜体/弱化显示，仅在原文页显示 */}\n\t\t\t\t\t\t{activeTab === \"original\" && partialText ? (\n\t\t\t\t\t\t\t<p className=\"pt-2 text-[oklch(var(--muted-foreground))] italic\">\n\t\t\t\t\t\t\t\t{partialText}\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t) : null}\n\t\t\t\t\t</div>\n\t\t\t\t) : showLoading ? (\n\t\t\t\t\t// 加载状态：显示获取中动效\n\t\t\t\t\t<div className=\"flex flex-col items-center justify-center h-full text-center\">\n\t\t\t\t\t\t<div className=\"mb-4\">\n\t\t\t\t\t\t\t<div className=\"h-12 w-12 border-4 border-[oklch(var(--primary))] border-t-transparent rounded-full animate-spin mx-auto\" />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<p className=\"text-sm font-medium text-[oklch(var(--foreground))] mb-1\">获取中...</p>\n\t\t\t\t\t\t<p className=\"text-xs text-[oklch(var(--muted-foreground))]\">\n\t\t\t\t\t\t\t正在加载该日期的转录内容\n\t\t\t\t\t\t</p>\n\t\t\t\t\t</div>\n\t\t\t\t) : (\n\t\t\t\t\t// 空状态：没有内容且不在加载中\n\t\t\t\t\t<div className=\"flex flex-col items-center justify-center h-full text-center\">\n\t\t\t\t\t\t<div className=\"mb-4 text-[oklch(var(--muted-foreground))]\">\n\t\t\t\t\t\t\t<svg\n\t\t\t\t\t\t\t\tclassName=\"w-16 h-16 mx-auto mb-2\"\n\t\t\t\t\t\t\t\tfill=\"none\"\n\t\t\t\t\t\t\t\tstroke=\"currentColor\"\n\t\t\t\t\t\t\t\tviewBox=\"0 0 24 24\"\n\t\t\t\t\t\t\t\trole=\"img\"\n\t\t\t\t\t\t\t\taria-label=\"文档图标\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<title>文档图标</title>\n\t\t\t\t\t\t\t\t<path\n\t\t\t\t\t\t\t\t\tstrokeLinecap=\"round\"\n\t\t\t\t\t\t\t\t\tstrokeLinejoin=\"round\"\n\t\t\t\t\t\t\t\t\tstrokeWidth={1.5}\n\t\t\t\t\t\t\t\t\td=\"M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z\"\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</svg>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<p className=\"text-sm text-[oklch(var(--muted-foreground))] mb-2\">暂无转录内容</p>\n\t\t\t\t\t\t<p className=\"text-xs text-[oklch(var(--muted-foreground))]\">\n\t\t\t\t\t\t\t当前日期没有转录记录。如果这是已录制的音频，可能需要：\n\t\t\t\t\t\t</p>\n\t\t\t\t\t\t<ul className=\"text-xs text-[oklch(var(--muted-foreground))] mt-2 list-disc list-inside\">\n\t\t\t\t\t\t\t<li>等待转录完成</li>\n\t\t\t\t\t\t\t<li>检查音频是否已上传并处理</li>\n\t\t\t\t\t\t\t<li>确认日期选择是否正确</li>\n\t\t\t\t\t\t</ul>\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t\t{/* 文本区域底部的加载状态（当有内容时显示在底部） */}\n\t\t\t\t{isLoadingTimeline && hasContent && (\n\t\t\t\t\t<div className=\"flex items-center justify-center gap-2 py-4 text-sm text-[oklch(var(--muted-foreground))]\">\n\t\t\t\t\t\t<div className=\"h-4 w-4 border-2 border-[oklch(var(--primary))] border-t-transparent rounded-full animate-spin\" />\n\t\t\t\t\t\t<span>获取中...</span>\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/audio/hooks/useAudioData.ts",
    "content": "\"use client\";\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport {\n\tcalculateSegmentOffset,\n\tformatDateTime,\n\tgetDateString,\n\tgetSegmentDate,\n\tparseLocalDate,\n} from \"../utils/timeUtils\";\n\n// 日期数据缓存（最多保存7天）\ntype DateCacheData = {\n\ttranscriptionText: string;\n\toptimizedText: string;\n\tsegmentOffsetsSec: number[];\n\tsegmentRecordingIds: number[];\n\tsegmentTimeLabels: string[];\n\tsegmentTimesSec: number[];\n\trecordingDurations: Record<number, number>;\n\textractionsByRecordingId: Record<number, { todos?: TodoItem[]; schedules?: ScheduleItem[] }>;\n\toptimizedExtractionsByRecordingId: Record<number, { todos?: TodoItem[]; schedules?: ScheduleItem[] }>;\n\ttimestamp: number; // 缓存时间戳\n};\n\nconst MAX_CACHE_DAYS = 7;\nconst CACHE_EXPIRY_MS = MAX_CACHE_DAYS * 24 * 60 * 60 * 1000; // 7天过期\n\ntype TodoItem = {\n\tid?: string;\n\tdedupe_key?: string;\n\ttitle: string;\n\tdescription?: string;\n\tdeadline?: string;\n\tsource_text?: string;\n\tlinked?: boolean;\n\tlinked_todo_id?: number | null;\n};\n\ntype ScheduleItem = {\n\tid?: string;\n\tdedupe_key?: string;\n\ttitle: string;\n\ttime?: string;\n\tdescription?: string;\n\tsource_text?: string;\n\tlinked?: boolean;\n\tlinked_todo_id?: number | null;\n};\n\nexport function useAudioData(\n\tselectedDate: Date,\n\tactiveTab: \"original\" | \"optimized\",\n\tsetTranscriptionText: (text: string) => void,\n\tsetOptimizedText: (text: string) => void,\n\t// isRecording 参数已移除，日期切换逻辑现在在 AudioPanel 中处理\n) {\n\tconst [selectedRecordingId, setSelectedRecordingId] = useState<number | null>(null);\n\tconst [selectedRecordingDurationSec, setSelectedRecordingDurationSec] = useState<number>(0);\n\tconst [recordingDurations, setRecordingDurations] = useState<Record<number, number>>({});\n\tconst [segmentOffsetsSec, setSegmentOffsetsSec] = useState<number[]>([]);\n\tconst [segmentRecordingIds, setSegmentRecordingIds] = useState<number[]>([]);\n\tconst [segmentTimeLabels, setSegmentTimeLabels] = useState<string[]>([]);\n\tconst [segmentTimesSec, setSegmentTimesSec] = useState<number[]>([]);\n\tconst [extractionsByRecordingId, setExtractionsByRecordingId] = useState<\n\t\tRecord<number, { todos?: TodoItem[]; schedules?: ScheduleItem[] }>\n\t>({});\n\tconst [optimizedExtractionsByRecordingId, setOptimizedExtractionsByRecordingId] = useState<\n\t\tRecord<number, { todos?: TodoItem[]; schedules?: ScheduleItem[] }>\n\t>({});\n\n\t// 数据缓存：按日期字符串存储\n\tconst dateCacheRef = useRef<Map<string, DateCacheData>>(new Map());\n\n\t// 清理过期缓存\n\tconst cleanExpiredCache = useCallback(() => {\n\t\tconst now = Date.now();\n\t\tfor (const [dateStr, cache] of dateCacheRef.current.entries()) {\n\t\t\tif (now - cache.timestamp > CACHE_EXPIRY_MS) {\n\t\t\t\tdateCacheRef.current.delete(dateStr);\n\t\t\t}\n\t\t}\n\t}, []);\n\n\t// 从缓存恢复数据\n\tconst restoreFromCache = useCallback((dateStr: string, tab: \"original\" | \"optimized\") => {\n\t\tconst cache = dateCacheRef.current.get(dateStr);\n\t\tif (!cache) return false;\n\n\t\t// 检查缓存是否过期\n\t\tif (Date.now() - cache.timestamp > CACHE_EXPIRY_MS) {\n\t\t\tdateCacheRef.current.delete(dateStr);\n\t\t\treturn false;\n\t\t}\n\n\t\t// 恢复数据：恢复当前 tab 的文本，但保留另一个 tab 的文本（如果存在）\n\t\tif (tab === \"original\") {\n\t\t\tsetTranscriptionText(cache.transcriptionText);\n\t\t\t// 如果缓存中有 optimized 文本，也恢复它（避免切换 tab 时丢失）\n\t\t\tif (cache.optimizedText) {\n\t\t\t\tsetOptimizedText(cache.optimizedText);\n\t\t\t}\n\t\t} else {\n\t\t\tsetOptimizedText(cache.optimizedText);\n\t\t\t// 如果缓存中有 original 文本，也恢复它（避免切换 tab 时丢失）\n\t\t\tif (cache.transcriptionText) {\n\t\t\t\tsetTranscriptionText(cache.transcriptionText);\n\t\t\t}\n\t\t}\n\t\tsetSegmentOffsetsSec(cache.segmentOffsetsSec);\n\t\tsetSegmentRecordingIds(cache.segmentRecordingIds);\n\t\tsetSegmentTimeLabels(cache.segmentTimeLabels);\n\t\tsetSegmentTimesSec(cache.segmentTimesSec);\n\t\tsetRecordingDurations(cache.recordingDurations);\n\t\tsetExtractionsByRecordingId(cache.extractionsByRecordingId);\n\t\tsetOptimizedExtractionsByRecordingId(cache.optimizedExtractionsByRecordingId);\n\n\t\t// 恢复选中的录音ID\n\t\tif (cache.segmentRecordingIds.length > 0) {\n\t\t\tconst lastRecId = cache.segmentRecordingIds[cache.segmentRecordingIds.length - 1];\n\t\t\tsetSelectedRecordingId(lastRecId);\n\t\t\tif (cache.recordingDurations[lastRecId]) {\n\t\t\t\tsetSelectedRecordingDurationSec(cache.recordingDurations[lastRecId]);\n\t\t\t}\n\t\t}\n\n\t\treturn true;\n\t}, [setTranscriptionText, setOptimizedText]);\n\n\n\tconst loadRecordings = useCallback(async (opts?: { forceSelectLatest?: boolean }) => {\n\t\ttry {\n\t\t\tconst apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || \"http://localhost:8100\";\n\t\t\tconst dateStr = getDateString(selectedDate);\n\t\t\tconst response = await fetch(`${apiBaseUrl}/api/audio/recordings?date=${dateStr}`);\n\t\t\tconst data = await response.json();\n\t\t\tif (data.recordings) {\n\t\t\t\tconst recordings: Array<{ id: number; durationSeconds?: number }> = data.recordings;\n\t\t\t\tif (recordings.length > 0) {\n\t\t\t\t\tconst latest = recordings[recordings.length - 1];\n\t\t\t\t\tconst hasSelected =\n\t\t\t\t\t\tselectedRecordingId && recordings.some((r) => r.id === selectedRecordingId);\n\t\t\t\t\tif (opts?.forceSelectLatest || !hasSelected) {\n\t\t\t\t\t\tsetSelectedRecordingId(latest.id);\n\t\t\t\t\t\tsetSelectedRecordingDurationSec(Number(latest.durationSeconds ?? 0));\n\t\t\t\t\t}\n\t\t\t\t} else if (opts?.forceSelectLatest) {\n\t\t\t\t\tsetSelectedRecordingId(null);\n\t\t\t\t\tsetSelectedRecordingDurationSec(0);\n\t\t\t\t}\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tconsole.error(\"Failed to load recordings:\", error);\n\t\t}\n\t}, [selectedDate, selectedRecordingId]);\n\n\tconst loadTimeline = useCallback(async (onLoadingChange?: (loading: boolean) => void, forceReload = false) => {\n\t\ttry {\n\t\t\tconst dateStr = getDateString(selectedDate);\n\n\t\t\t// 检查缓存\n\t\t\tif (!forceReload) {\n\t\t\t\tconst cached = restoreFromCache(dateStr, activeTab);\n\t\t\t\tif (cached) {\n\t\t\t\t\tconsole.log(\"从缓存恢复数据:\", dateStr);\n\t\t\t\t\tif (onLoadingChange) onLoadingChange(false);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// 通知开始加载\n\t\t\tif (onLoadingChange) onLoadingChange(true);\n\n\t\t\tconst apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || \"http://localhost:8100\";\n\t\t\tconst response = await fetch(\n\t\t\t\t`${apiBaseUrl}/api/audio/timeline?date=${dateStr}&optimized=${activeTab === \"optimized\"}`\n\t\t\t);\n\t\t\tconst data = await response.json();\n\n\t\t\tif (Array.isArray(data.timeline)) {\n\t\t\t\t// 先清空数据，但保持加载状态直到数据处理完成\n\t\t\t\tsetTranscriptionText(\"\");\n\t\t\t\tsetOptimizedText(\"\");\n\t\t\t\tsetSegmentOffsetsSec([]);\n\t\t\t\tsetSegmentRecordingIds([]);\n\t\t\t\tsetSegmentTimeLabels([]);\n\t\t\t\tsetSegmentTimesSec([]);\n\t\t\t\tconst segments: string[] = [];\n\t\t\t\tconst offsets: number[] = [];\n\t\t\t\tconst timeLabels: string[] = [];\n\t\t\t\tconst recIds: number[] = [];\n\t\t\t\tconst durationMap: Record<number, number> = {};\n\n\t\t\t\tdata.timeline.forEach((item: {\n\t\t\t\t\tid: number;\n\t\t\t\t\tstart_time: string;\n\t\t\t\t\tduration: number;\n\t\t\t\t\ttext: string;\n\t\t\t\t\tsegment_timestamps?: number[];\n\t\t\t\t}) => {\n\t\t\t\t\tdurationMap[item.id] = item.duration;\n\t\t\t\t\tconst lines = (item.text || \"\").split(\"\\n\").filter((s: string) => s.trim());\n\t\t\t\t\tconst count = Math.max(1, lines.length);\n\n\t\t\t\t\t// 使用本地时区解析录音开始时间\n\t\t\t\t\tconst recordingStartTime = parseLocalDate(item.start_time);\n\n\t\t\t\t\t// 如果 API 返回了精确时间戳，使用它们；否则使用均匀分配\n\t\t\t\t\tconst hasPreciseTimestamps = Array.isArray(item.segment_timestamps) &&\n\t\t\t\t\t\titem.segment_timestamps.length === lines.length;\n\n\t\t\t\t\tlines.forEach((line: string, idx: number) => {\n\t\t\t\t\t\tsegments.push(line);\n\t\t\t\t\t\t// 优先使用 API 返回的精确时间戳，否则使用均匀分配\n\t\t\t\t\t\tconst offset = hasPreciseTimestamps\n\t\t\t\t\t\t\t? (item.segment_timestamps?.[idx] ?? 0)\n\t\t\t\t\t\t\t: calculateSegmentOffset(\n\t\t\t\t\t\t\t\t\trecordingStartTime,\n\t\t\t\t\t\t\t\t\tidx,\n\t\t\t\t\t\t\t\t\tcount,\n\t\t\t\t\t\t\t\t\titem.duration\n\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\toffsets.push(offset);\n\t\t\t\t\t\trecIds.push(item.id);\n\n\t\t\t\t\t\t// 计算文本段的绝对时间（处理跨日期情况）\n\t\t\t\t\t\tconst segmentDate = getSegmentDate(recordingStartTime, offset, selectedDate);\n\t\t\t\t\t\tconst label = formatDateTime(segmentDate);\n\t\t\t\t\t\ttimeLabels.push(label);\n\t\t\t\t\t});\n\t\t\t\t});\n\n\t\t\t\tconst combinedText = segments.join(\"\\n\");\n\t\t\t\tif (activeTab === \"original\") {\n\t\t\t\t\tsetTranscriptionText(combinedText);\n\t\t\t\t} else {\n\t\t\t\t\tsetOptimizedText(combinedText);\n\t\t\t\t}\n\t\t\t\tsetSegmentTimesSec(offsets);\n\t\t\t\tsetSegmentOffsetsSec(offsets);\n\t\t\t\tsetSegmentRecordingIds(recIds);\n\t\t\t\tsetSegmentTimeLabels(timeLabels);\n\t\t\t\tsetRecordingDurations(durationMap);\n\t\t\t\tif (recIds.length > 0) {\n\t\t\t\t\tconst lastRecId = recIds[recIds.length - 1];\n\t\t\t\t\tsetSelectedRecordingId(lastRecId);\n\t\t\t\t\tif (durationMap[lastRecId]) {\n\t\t\t\t\t\tsetSelectedRecordingDurationSec(durationMap[lastRecId]);\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// 保存到缓存：保留之前 tab 的文本（如果存在），更新当前 tab 的文本\n\t\t\t\tconst existingCache = dateCacheRef.current.get(dateStr);\n\t\t\t\tconst cacheData: DateCacheData = {\n\t\t\t\t\ttranscriptionText: activeTab === \"original\"\n\t\t\t\t\t\t? combinedText\n\t\t\t\t\t\t: (existingCache?.transcriptionText || \"\"),\n\t\t\t\t\toptimizedText: activeTab === \"optimized\"\n\t\t\t\t\t\t? combinedText\n\t\t\t\t\t\t: (existingCache?.optimizedText || \"\"),\n\t\t\t\t\tsegmentOffsetsSec: offsets,\n\t\t\t\t\tsegmentRecordingIds: recIds,\n\t\t\t\t\tsegmentTimeLabels: timeLabels,\n\t\t\t\t\tsegmentTimesSec: offsets,\n\t\t\t\t\trecordingDurations: durationMap,\n\t\t\t\t\textractionsByRecordingId: extractionsByRecordingId, // 使用当前状态\n\t\t\t\t\toptimizedExtractionsByRecordingId: optimizedExtractionsByRecordingId, // 使用当前状态\n\t\t\t\t\ttimestamp: Date.now(),\n\t\t\t\t};\n\t\t\t\tdateCacheRef.current.set(dateStr, cacheData);\n\t\t\t\tcleanExpiredCache();\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tconsole.error(\"Failed to load timeline:\", error);\n\t\t} finally {\n\t\t\t// 通知加载完成\n\t\t\tif (onLoadingChange) onLoadingChange(false);\n\t\t}\n\t}, [activeTab, selectedDate, setTranscriptionText, setOptimizedText, restoreFromCache, cleanExpiredCache, extractionsByRecordingId, optimizedExtractionsByRecordingId]);\n\n\tuseEffect(() => {\n\t\tloadRecordings();\n\t}, [loadRecordings]);\n\n\t// 注意：这个 useEffect 已经被 AudioPanel 中的日期切换逻辑替代\n\t// 保留此逻辑作为备用，但主要逻辑在 AudioPanel 中处理\n\t// useEffect(() => {\n\t// \t// 如果正在录音，需要判断是否查看当前日期\n\t// \t// - 如果查看当前日期：不加载时间线，保留实时录音文本显示\n\t// \t// - 如果查看其他日期：加载该日期的时间线（回看模式），不影响录音\n\t// \tif (isRecording) {\n\t// \t\tconst now = new Date();\n\t// \t\tconst nowDateStr = now.toISOString().split(\"T\")[0];\n\t// \t\tconst selectedDateStr = getDateString(selectedDate);\n\t// \t\tconst isViewingCurrentDate = selectedDateStr === nowDateStr;\n\t//\n\t// \t\t// 如果查看其他日期，加载该日期的时间线（回看模式）\n\t// \t\tif (!isViewingCurrentDate) {\n\t// \t\t\tloadTimeline();\n\t// \t\t}\n\t// \t\t// 如果查看当前日期，不加载时间线，保留实时录音文本\n\t// \t\treturn;\n\t// \t}\n\t// \t// 停止录音后，延迟加载时间线，给后端时间保存录音\n\t// \t// 这样避免立即清空文本，让用户看到\"获取中\"状态\n\t// \tconst timer = setTimeout(() => {\n\t// \t\tloadTimeline();\n\t// \t}, 1000); // 延迟 1 秒，给后端时间保存\n\n\t// \treturn () => clearTimeout(timer);\n\t// }, [loadTimeline, isRecording, selectedDate]);\n\n\t// 按录音ID加载对应的转录提取结果（用于整天时间线所有录音的持久化高亮）\n\tuseEffect(() => {\n\t\tconst uniqueIds = Array.from(new Set(segmentRecordingIds.filter((id) => id && id > 0)));\n\t\tif (uniqueIds.length === 0) return;\n\t\tconst apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || \"http://localhost:8100\";\n\t\tconst controller = new AbortController();\n\t\tconst isOptimized = activeTab === \"optimized\";\n\n\t\t(async () => {\n\t\t\ttry {\n\t\t\t\tconst results = await Promise.all(\n\t\t\t\t\tuniqueIds.map(async (id) => {\n\t\t\t\t\t\tconst resp = await fetch(\n\t\t\t\t\t\t\t`${apiBaseUrl}/api/audio/transcription/${id}?optimized=${isOptimized}`,\n\t\t\t\t\t\t\t{ signal: controller.signal }\n\t\t\t\t\t\t);\n\t\t\t\t\t\tconst data = await resp.json();\n\t\t\t\t\t\tconst todos: TodoItem[] = Array.isArray(data.todos) ? data.todos : [];\n\t\t\t\t\t\tconst schedules: ScheduleItem[] = Array.isArray(data.schedules) ? data.schedules : [];\n\t\t\t\t\t\treturn { id, todos, schedules };\n\t\t\t\t\t})\n\t\t\t\t);\n\t\t\t\tsetExtractionsByRecordingId((prev) => {\n\t\t\t\t\tconst next = { ...prev };\n\t\t\t\t\tfor (const r of results) {\n\t\t\t\t\t\tnext[r.id] = { todos: r.todos, schedules: r.schedules };\n\t\t\t\t\t}\n\t\t\t\t\t// 更新缓存\n\t\t\t\t\tconst dateStr = getDateString(selectedDate);\n\t\t\t\t\tconst cache = dateCacheRef.current.get(dateStr);\n\t\t\t\t\tif (cache) {\n\t\t\t\t\t\tcache.extractionsByRecordingId = next;\n\t\t\t\t\t\tcache.timestamp = Date.now();\n\t\t\t\t\t}\n\t\t\t\t\treturn next;\n\t\t\t\t});\n\t\t\t} catch (e) {\n\t\t\t\tif ((e as Error).name !== \"AbortError\") {\n\t\t\t\t\tconsole.error(\"Failed to load transcription extraction:\", e);\n\t\t\t\t}\n\t\t\t}\n\t\t})();\n\n\t\treturn () => controller.abort();\n\t}, [segmentRecordingIds, activeTab, selectedDate]);\n\n\t// 单独加载优化文本的提取结果，用于\"关联到待办\"弹窗\n\tuseEffect(() => {\n\t\tconst uniqueIds = Array.from(new Set(segmentRecordingIds.filter((id) => id && id > 0)));\n\t\tif (uniqueIds.length === 0) return;\n\t\tconst apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || \"http://localhost:8100\";\n\t\tconst controller = new AbortController();\n\t\tconst missingIds = uniqueIds.filter((id) => !optimizedExtractionsByRecordingId[id]);\n\t\tif (missingIds.length === 0) return;\n\n\t\t(async () => {\n\t\t\ttry {\n\t\t\t\tconst results = await Promise.all(\n\t\t\t\t\tmissingIds.map(async (id) => {\n\t\t\t\t\t\tconst resp = await fetch(`${apiBaseUrl}/api/audio/transcription/${id}?optimized=true`, {\n\t\t\t\t\t\t\tsignal: controller.signal,\n\t\t\t\t\t\t});\n\t\t\t\t\t\tconst data = await resp.json();\n\t\t\t\t\t\tconst todos: TodoItem[] = Array.isArray(data.todos) ? data.todos : [];\n\t\t\t\t\t\tconst schedules: ScheduleItem[] = Array.isArray(data.schedules) ? data.schedules : [];\n\t\t\t\t\t\treturn { id, todos, schedules };\n\t\t\t\t\t})\n\t\t\t\t);\n\t\t\t\tsetOptimizedExtractionsByRecordingId((prev) => {\n\t\t\t\t\tconst next = { ...prev };\n\t\t\t\t\tfor (const r of results) {\n\t\t\t\t\t\tnext[r.id] = { todos: r.todos, schedules: r.schedules };\n\t\t\t\t\t}\n\t\t\t\t\t// 更新缓存\n\t\t\t\t\tconst dateStr = getDateString(selectedDate);\n\t\t\t\t\tconst cache = dateCacheRef.current.get(dateStr);\n\t\t\t\t\tif (cache) {\n\t\t\t\t\t\tcache.optimizedExtractionsByRecordingId = next;\n\t\t\t\t\t\tcache.timestamp = Date.now();\n\t\t\t\t\t}\n\t\t\t\t\treturn next;\n\t\t\t\t});\n\t\t\t} catch (e) {\n\t\t\t\tif ((e as Error).name !== \"AbortError\") {\n\t\t\t\t\tconsole.error(\"Failed to load optimized transcription extraction:\", e);\n\t\t\t\t}\n\t\t\t}\n\t\t})();\n\n\t\treturn () => controller.abort();\n\t}, [segmentRecordingIds, optimizedExtractionsByRecordingId, selectedDate]);\n\n\treturn {\n\t\tselectedRecordingId,\n\t\tsetSelectedRecordingId,\n\t\tselectedRecordingDurationSec,\n\t\tsetSelectedRecordingDurationSec,\n\t\trecordingDurations,\n\t\tsetRecordingDurations,\n\t\tsegmentOffsetsSec,\n\t\tsetSegmentOffsetsSec,\n\t\tsegmentRecordingIds,\n\t\tsetSegmentRecordingIds,\n\t\tsegmentTimeLabels,\n\t\tsetSegmentTimeLabels,\n\t\tsegmentTimesSec,\n\t\tsetSegmentTimesSec,\n\t\textractionsByRecordingId,\n\t\tsetExtractionsByRecordingId,\n\t\toptimizedExtractionsByRecordingId,\n\t\tsetOptimizedExtractionsByRecordingId,\n\t\tloadRecordings,\n\t\tloadTimeline, // 暴露 loadTimeline，允许手动触发\n\t};\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/audio/hooks/useAudioDateSwitching.ts",
    "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport { getLocalDateStringForCompare } from \"../utils/timeUtils\";\n\ninterface UseAudioDateSwitchingProps {\n\tselectedDate: Date;\n\tisRecording: boolean;\n\tisViewingCurrentDate: boolean;\n\tliveRecordingStateRef: React.MutableRefObject<{\n\t\ttext: string;\n\t\toptimizedText: string;\n\t\tpartialText: string;\n\t\tsegmentTimesSec: number[];\n\t\tsegmentOffsetsSec: number[];\n\t\tsegmentRecordingIds: number[];\n\t\tsegmentTimeLabels: string[];\n\t\ttodos: Array<{ title: string; description?: string; deadline?: string; source_text?: string }>;\n\t\tschedules: Array<{ title: string; time?: string; description?: string; source_text?: string }>;\n\t}>;\n\tcurrentLoadingDateRef: React.MutableRefObject<string | null>;\n\tsetTranscriptionText: (text: string | ((prev: string) => string)) => void;\n\tsetOptimizedText: (text: string | ((prev: string) => string)) => void;\n\tsetPartialText: (text: string) => void;\n\tsetSegmentTimesSec: (times: number[] | ((prev: number[]) => number[])) => void;\n\tsetSegmentOffsetsSec: (offsets: number[] | ((prev: number[]) => number[])) => void;\n\tsetSegmentRecordingIds: (ids: number[] | ((prev: number[]) => number[])) => void;\n\tsetSegmentTimeLabels: (labels: string[] | ((prev: string[]) => string[])) => void;\n\tsetLiveTodos: (todos: Array<{ title: string; description?: string; deadline?: string; source_text?: string }>) => void;\n\tsetLiveSchedules: (schedules: Array<{ title: string; time?: string; description?: string; source_text?: string }>) => void;\n\tsetIsLoadingTimeline: (loading: boolean) => void;\n\tloadTimeline: (callback: (loading: boolean) => void, forceReload?: boolean) => void;\n}\n\nexport function useAudioDateSwitching({\n\tselectedDate,\n\tisRecording,\n\tisViewingCurrentDate,\n\tliveRecordingStateRef,\n\tcurrentLoadingDateRef,\n\tsetTranscriptionText,\n\tsetOptimizedText,\n\tsetPartialText,\n\tsetSegmentTimesSec,\n\tsetSegmentOffsetsSec,\n\tsetSegmentRecordingIds,\n\tsetSegmentTimeLabels,\n\tsetLiveTodos,\n\tsetLiveSchedules,\n\tsetIsLoadingTimeline,\n\tloadTimeline,\n}: UseAudioDateSwitchingProps) {\n\tconst prevSelectedDateRef = useRef<string | null>(null);\n\tconst prevIsViewingCurrentDateRef = useRef<boolean | null>(null);\n\n\t// 监听日期切换：处理实时文本显示和加载状态\n\t// 重要：只依赖 selectedDate 的变化，不依赖 state 变量来避免无限循环\n\tuseEffect(() => {\n\t\tconst currentDateStr = getLocalDateStringForCompare(selectedDate);\n\t\tconst prevDateStr = prevSelectedDateRef.current;\n\n\t\t// 如果日期没有变化，跳过执行\n\t\tif (prevDateStr === currentDateStr && prevIsViewingCurrentDateRef.current === isViewingCurrentDate) {\n\t\t\treturn;\n\t\t}\n\n\t\tconsole.log(\"日期切换检查:\", {\n\t\t\tisViewingCurrentDate,\n\t\t\tisRecording,\n\t\t\thasLiveData: liveRecordingStateRef.current.text.length > 0,\n\t\t\tprevDate: prevDateStr,\n\t\t\tcurrentDate: currentDateStr,\n\t\t});\n\n\t\t// 更新 ref\n\t\tprevSelectedDateRef.current = currentDateStr;\n\t\tprevIsViewingCurrentDateRef.current = isViewingCurrentDate;\n\n\t\tif (isViewingCurrentDate) {\n\t\t\t// 切换到当前日期：显示历史数据 + 实时录音数据\n\t\t\tconsole.log(\"切换到当前日期，加载历史数据并合并实时录音状态\", {\n\t\t\t\tliveTextLength: liveRecordingStateRef.current.text.length,\n\t\t\t\tliveSegmentCount: liveRecordingStateRef.current.segmentTimesSec.length,\n\t\t\t});\n\n\t\t\t// 先加载该日期的历史数据（已保存的录音）\n\t\t\tsetIsLoadingTimeline(true);\n\t\t\tcurrentLoadingDateRef.current = currentDateStr; // 记录当前加载的日期\n\n\t\t\tloadTimeline(async (loading) => {\n\t\t\t\t// 检查日期是否仍然匹配（防止快速切换导致数据错乱）\n\t\t\t\tif (currentLoadingDateRef.current !== currentDateStr) {\n\t\t\t\t\tconsole.log(\"日期已切换，忽略此次加载结果\");\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tsetIsLoadingTimeline(loading);\n\n\t\t\t\t// 加载完成后，合并实时录音数据\n\t\t\t\tif (!loading) {\n\t\t\t\t\t// 再次检查日期是否匹配\n\t\t\t\t\tif (currentLoadingDateRef.current !== currentDateStr) {\n\t\t\t\t\t\tconsole.log(\"日期已切换，忽略此次合并\");\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\n\t\t\t\t\t// 等待一个 tick，确保 loadTimeline 的状态更新已完成\n\t\t\t\t\tawait new Promise((resolve) => setTimeout(resolve, 0));\n\n\t\t\t\t\t// 再次检查日期是否匹配\n\t\t\t\t\tif (currentLoadingDateRef.current !== currentDateStr) {\n\t\t\t\t\t\tconsole.log(\"日期已切换，忽略此次合并\");\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\n\t\t\t\t\tconst liveState = liveRecordingStateRef.current;\n\n\t\t\t\t\t// 如果实时数据存在，追加到历史数据后面\n\t\t\t\t\tif (liveState.text || liveState.partialText) {\n\t\t\t\t\t\t// 合并文本：历史数据 + 实时数据\n\t\t\t\t\t\tsetTranscriptionText((prev) => {\n\t\t\t\t\t\t\tif (!liveState.text) {\n\t\t\t\t\t\t\t\treturn prev; // 没有实时文本，保持历史数据\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tif (!prev) {\n\t\t\t\t\t\t\t\treturn liveState.text; // 没有历史数据，直接使用实时数据\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t// 历史数据 + 实时数据（用换行分隔）\n\t\t\t\t\t\t\tconst needsGap = !prev.endsWith(\"\\n\");\n\t\t\t\t\t\t\treturn `${prev}${needsGap ? \"\\n\" : \"\"}${liveState.text}`;\n\t\t\t\t\t\t});\n\n\t\t\t\t\t\t// 合并时间戳数组\n\t\t\t\t\t\tsetSegmentTimesSec((prev) => [...prev, ...liveState.segmentTimesSec]);\n\t\t\t\t\t\tsetSegmentOffsetsSec((prev) => [...prev, ...liveState.segmentOffsetsSec]);\n\t\t\t\t\t\tsetSegmentRecordingIds((prev) => [...prev, ...liveState.segmentRecordingIds]);\n\t\t\t\t\t\tsetSegmentTimeLabels((prev) => [...prev, ...liveState.segmentTimeLabels]);\n\n\t\t\t\t\t\t// 实时数据覆盖优化文本和高亮（因为是最新的）\n\t\t\t\t\t\tif (liveState.optimizedText) {\n\t\t\t\t\t\t\tsetOptimizedText(liveState.optimizedText);\n\t\t\t\t\t\t}\n\t\t\t\t\t\tsetPartialText(liveState.partialText);\n\t\t\t\t\t\tsetLiveTodos(liveState.todos);\n\t\t\t\t\t\tsetLiveSchedules(liveState.schedules);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t});\n\t\t} else {\n\t\t\t// 切换到其他日期：只加载历史数据，不影响录音状态\n\t\t\tconsole.log(\"切换到其他日期，加载历史数据，录音状态保持不变\", {\n\t\t\t\tselectedDate: currentDateStr,\n\t\t\t});\n\n\t\t\tsetIsLoadingTimeline(true);\n\t\t\tcurrentLoadingDateRef.current = currentDateStr; // 记录当前加载的日期\n\n\t\t\t// 清空当前显示，准备加载历史数据（回看状态）\n\t\t\t// 注意：这不会影响 liveRecordingStateRef（录音状态）\n\t\t\tsetTranscriptionText(\"\");\n\t\t\tsetOptimizedText(\"\");\n\t\t\tsetPartialText(\"\");\n\t\t\tsetSegmentTimesSec([]);\n\t\t\tsetSegmentOffsetsSec([]);\n\t\t\tsetSegmentRecordingIds([]);\n\t\t\tsetSegmentTimeLabels([]);\n\t\t\tsetLiveTodos([]);\n\t\t\tsetLiveSchedules([]);\n\n\t\t\t// 加载该日期的时间线（回看数据）\n\t\t\tloadTimeline((loading) => {\n\t\t\t\t// 检查日期是否仍然匹配（防止快速切换导致数据错乱）\n\t\t\t\tif (currentLoadingDateRef.current !== currentDateStr) {\n\t\t\t\t\tconsole.log(\"日期已切换，忽略此次加载结果\");\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tsetIsLoadingTimeline(loading);\n\t\t\t});\n\t\t}\n\t}, [\n\t\tselectedDate,\n\t\tisRecording,\n\t\tisViewingCurrentDate,\n\t\tloadTimeline,\n\t\tsetSegmentOffsetsSec,\n\t\tsetSegmentRecordingIds,\n\t\tsetSegmentTimeLabels,\n\t\tsetSegmentTimesSec,\n\t\tsetTranscriptionText,\n\t\tsetOptimizedText,\n\t\tsetPartialText,\n\t\tsetLiveTodos,\n\t\tsetLiveSchedules,\n\t\tsetIsLoadingTimeline,\n\t\tcurrentLoadingDateRef,\n\t\tliveRecordingStateRef,\n\t]);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/audio/hooks/useAudioLink.ts",
    "content": "\"use client\";\n\nimport { useCallback } from \"react\";\nimport { getTranscriptionApiAudioTranscriptionRecordingIdGet } from \"@/lib/generated/audio/audio\";\n\ntype TodoItem = {\n\tid?: string;\n\tdedupe_key?: string;\n\ttitle: string;\n\tdescription?: string;\n\tdeadline?: string;\n\tsource_text?: string;\n\tlinked?: boolean;\n\tlinked_todo_id?: number | null;\n};\n\ntype ScheduleItem = {\n\tid?: string;\n\tdedupe_key?: string;\n\ttitle: string;\n\ttime?: string;\n\tdescription?: string;\n\tsource_text?: string;\n\tlinked?: boolean;\n\tlinked_todo_id?: number | null;\n};\n\ntype ExtractionData = {\n\ttodos?: TodoItem[];\n\tschedules?: ScheduleItem[];\n};\n\n/**\n * Hook for linking extracted items to todos\n * 用于将提取的待办/日程关联到待办列表\n */\nexport function useAudioLink() {\n\tconst apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || \"http://localhost:8100\";\n\n\t/**\n\t * Link extracted items to todos\n\t * @param recordingId - 录音ID\n\t * @param links - 链接列表，包含 kind (todo/schedule), item_id, todo_id\n\t * @param optimized - 是否更新优化文本的提取结果（默认 true）\n\t */\n\tconst linkExtractedItems = useCallback(\n\t\tasync (\n\t\t\trecordingId: number,\n\t\t\tlinks: Array<{ kind: \"todo\" | \"schedule\"; item_id: string; todo_id: number }>,\n\t\t\toptimized: boolean = true\n\t\t) => {\n\t\t\tconst response = await fetch(\n\t\t\t\t`${apiBaseUrl}/api/audio/transcription/${recordingId}/link?optimized=${optimized}`,\n\t\t\t\t{\n\t\t\t\t\tmethod: \"POST\",\n\t\t\t\t\theaders: { \"Content-Type\": \"application/json\" },\n\t\t\t\t\tbody: JSON.stringify({ links }),\n\t\t\t\t}\n\t\t\t);\n\t\t\tif (!response.ok) {\n\t\t\t\tthrow new Error(`Failed to link items: ${response.status}`);\n\t\t\t}\n\t\t\treturn response.json();\n\t\t},\n\t\t[apiBaseUrl]\n\t);\n\n\t/**\n\t * Get transcription extraction data\n\t * @param recordingId - 录音ID\n\t * @param optimized - 是否获取优化文本的提取结果（默认 true）\n\t */\n\tconst getTranscriptionExtraction = useCallback(\n\t\tasync (recordingId: number, optimized: boolean = true): Promise<ExtractionData> => {\n\t\t\tconst data = (await getTranscriptionApiAudioTranscriptionRecordingIdGet(recordingId, {\n\t\t\t\toptimized,\n\t\t\t})) as ExtractionData;\n\t\t\treturn {\n\t\t\t\ttodos: Array.isArray(data.todos) ? data.todos : [],\n\t\t\t\tschedules: Array.isArray(data.schedules) ? data.schedules : [],\n\t\t\t};\n\t\t},\n\t\t[]\n\t);\n\n\t/**\n\t * Link items and update extraction data\n\t * 关联项目并更新提取数据（完整流程）\n\t * @param byRec - 按 recordingId 分组的链接数据\n\t * @param onUpdate - 更新提取数据的回调\n\t */\n\tconst linkAndRefresh = useCallback(\n\t\tasync (\n\t\t\tbyRec: Map<number, Array<{ kind: \"todo\" | \"schedule\"; item_id: string; todo_id: number }>>,\n\t\t\tonUpdate: (recordingId: number, data: ExtractionData) => void\n\t\t) => {\n\t\t\t// 1. 调用 link API\n\t\t\tawait Promise.all(\n\t\t\t\tArray.from(byRec.entries()).map(async ([recId, links]) => {\n\t\t\t\t\tawait linkExtractedItems(recId, links, true);\n\t\t\t\t})\n\t\t\t);\n\n\t\t\t// 2. 重新拉取数据\n\t\t\ttry {\n\t\t\t\tconst refreshed = await Promise.all(\n\t\t\t\t\tArray.from(byRec.keys()).map(async (recId) => {\n\t\t\t\t\t\tconst data = await getTranscriptionExtraction(recId, true);\n\t\t\t\t\t\treturn { id: recId, ...data };\n\t\t\t\t\t})\n\t\t\t\t);\n\n\t\t\t\t// 3. 调用更新回调\n\t\t\t\tfor (const r of refreshed) {\n\t\t\t\t\tonUpdate(r.id, { todos: r.todos, schedules: r.schedules });\n\t\t\t\t}\n\t\t\t} catch (error) {\n\t\t\t\tconsole.error(\"Failed to refresh extraction data:\", error);\n\t\t\t\tthrow error;\n\t\t\t}\n\t\t},\n\t\t[linkExtractedItems, getTranscriptionExtraction]\n\t);\n\n\treturn {\n\t\tlinkExtractedItems,\n\t\tgetTranscriptionExtraction,\n\t\tlinkAndRefresh,\n\t};\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/audio/hooks/useAudioPlayback.ts",
    "content": "\"use client\";\n\nimport { useCallback, useRef, useState } from \"react\";\n\nexport function useAudioPlayback() {\n\tconst audioRef = useRef<HTMLAudioElement | null>(null);\n\tconst [isPlaying, setIsPlaying] = useState(false);\n\tconst [currentTime, setCurrentTime] = useState(0);\n\tconst [duration, setDuration] = useState(0);\n\tconst [playbackRate, setPlaybackRate] = useState(1.0);\n\n\tconst ensureAudio = useCallback((url: string) => {\n\t\tif (!audioRef.current) {\n\t\t\tconst audio = new Audio(url);\n\t\t\taudio.addEventListener(\"loadedmetadata\", () => {\n\t\t\t\tsetDuration(audio.duration);\n\t\t\t});\n\t\t\taudio.addEventListener(\"timeupdate\", () => {\n\t\t\t\tsetCurrentTime(audio.currentTime);\n\t\t\t});\n\t\t\taudio.addEventListener(\"ended\", () => {\n\t\t\t\tsetIsPlaying(false);\n\t\t\t\tsetCurrentTime(0);\n\t\t\t});\n\t\t\taudio.addEventListener(\"play\", () => setIsPlaying(true));\n\t\t\taudio.addEventListener(\"pause\", () => setIsPlaying(false));\n\t\t\taudio.playbackRate = playbackRate; // 设置初始倍速\n\t\t\taudioRef.current = audio;\n\t\t} else if (audioRef.current.src !== url) {\n\t\t\taudioRef.current.src = url;\n\t\t\taudioRef.current.load();\n\t\t\taudioRef.current.playbackRate = playbackRate; // 设置倍速\n\t\t} else {\n\t\t\t// 确保倍速设置正确\n\t\t\taudioRef.current.playbackRate = playbackRate;\n\t\t}\n\t}, [playbackRate]);\n\n\tconst playPause = useCallback((url?: string) => {\n\t\tif (url) {\n\t\t\tensureAudio(url);\n\t\t}\n\t\tconst audio = audioRef.current;\n\t\tif (!audio) return;\n\n\t\tif (audio.paused) {\n\t\t\taudio.play().catch((e) => console.error(\"Failed to play audio:\", e));\n\t\t} else {\n\t\t\taudio.pause();\n\t\t}\n\t}, [ensureAudio]);\n\n\tconst seek = useCallback((targetTime: number) => {\n\t\tconst audio = audioRef.current;\n\t\tif (!audio) return;\n\t\ttry {\n\t\t\taudio.currentTime = Math.max(0, targetTime);\n\t\t\tsetCurrentTime(audio.currentTime);\n\t\t} catch (e) {\n\t\t\tconsole.error(\"Failed to seek audio:\", e);\n\t\t}\n\t}, []);\n\n\tconst seekByRatio = useCallback((ratio: number) => {\n\t\tconst audio = audioRef.current;\n\t\tif (!audio) return;\n\t\tconst target = Math.max(0, Math.min(1, ratio)) * (audio.duration || 0);\n\t\tseek(target);\n\t}, [seek]);\n\n\tconst setPlaybackRateValue = useCallback((rate: number) => {\n\t\tsetPlaybackRate(rate);\n\t\tif (audioRef.current) {\n\t\t\taudioRef.current.playbackRate = rate;\n\t\t}\n\t}, []);\n\n\treturn {\n\t\taudioRef,\n\t\tisPlaying,\n\t\tcurrentTime,\n\t\tduration,\n\t\tplaybackRate,\n\t\tensureAudio,\n\t\tplayPause,\n\t\tseek,\n\t\tseekByRatio,\n\t\tsetPlaybackRate: setPlaybackRateValue,\n\t};\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/audio/hooks/useAudioRecording.ts",
    "content": "\"use client\";\n\nimport { useCallback } from \"react\";\nimport { useAudioRecordingStore } from \"@/lib/store/audio-recording-store\";\n\n/**\n * 音频录音 Hook\n *\n * 使用全局 store 管理录音状态，确保面板切换时录音不会中断。\n * 这个 hook 是对 useAudioRecordingStore 的封装，提供与原来相同的接口。\n */\nexport function useAudioRecording() {\n\tconst isRecording = useAudioRecordingStore((state) => state.isRecording);\n\tconst startRecordingAction = useAudioRecordingStore((state) => state.startRecording);\n\tconst stopRecordingAction = useAudioRecordingStore((state) => state.stopRecording);\n\n\tconst startRecording = useCallback(\n\t\tasync (\n\t\t\tonTranscription: (text: string, isFinal: boolean) => void,\n\t\t\tonRealtimeNlp?: (data: {\n\t\t\t\toptimizedText?: string;\n\t\t\t\ttodos?: Array<{ title: string; description?: string; deadline?: string }>;\n\t\t\t\tschedules?: Array<{ title: string; time?: string; description?: string }>;\n\t\t\t}) => void,\n\t\t\tonError?: (error: Error) => void,\n\t\t\tis24x7: boolean = false,\n\t\t) => {\n\t\t\tawait startRecordingAction(onTranscription, onRealtimeNlp, onError, is24x7);\n\t\t},\n\t\t[startRecordingAction],\n\t);\n\n\tconst stopRecording = useCallback(\n\t\t(segmentTimestamps?: number[]) => {\n\t\t\tstopRecordingAction(segmentTimestamps);\n\t\t},\n\t\t[stopRecordingAction],\n\t);\n\n\treturn {\n\t\tisRecording,\n\t\tstartRecording,\n\t\tstopRecording,\n\t};\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/audio/hooks/useAudioRecordingControl.ts",
    "content": "\"use client\";\n\nimport { useCallback, useEffect, useRef } from \"react\";\nimport { toastInfo } from \"@/lib/toast\";\nimport { getLocalDateStringForCompare } from \"../utils/timeUtils\";\nimport { useAudioRecording } from \"./useAudioRecording\";\n\ninterface UseAudioRecordingControlProps {\n\tis24x7Enabled: boolean;\n\tconfigLoading: boolean;\n\tselectedDateRef: React.MutableRefObject<Date>;\n\tcurrentRecordingDateRef: React.MutableRefObject<Date | null>;\n\tliveRecordingStateRef: React.MutableRefObject<{\n\t\ttext: string;\n\t\toptimizedText: string;\n\t\tpartialText: string;\n\t\tsegmentTimesSec: number[];\n\t\tsegmentOffsetsSec: number[];\n\t\tsegmentRecordingIds: number[];\n\t\tsegmentTimeLabels: string[];\n\t\ttodos: Array<{ title: string; description?: string; deadline?: string; source_text?: string }>;\n\t\tschedules: Array<{ title: string; time?: string; description?: string; source_text?: string }>;\n\t}>;\n\trecordingStartedAtMsRef: React.MutableRefObject<number>;\n\trecordingStartedAtRef: React.MutableRefObject<Date | null>;\n\tlastFinalEndMsRef: React.MutableRefObject<number | null>;\n\tsetTranscriptionText: (text: string | ((prev: string) => string)) => void;\n\tsetOptimizedText: (text: string | ((prev: string) => string)) => void;\n\tsetPartialText: (text: string) => void;\n\tsetSegmentTimesSec: (times: number[] | ((prev: number[]) => number[])) => void;\n\tsetSegmentOffsetsSec: (offsets: number[] | ((prev: number[]) => number[])) => void;\n\tsetSegmentRecordingIds: (ids: number[] | ((prev: number[]) => number[])) => void;\n\tsetSegmentTimeLabels: (labels: string[] | ((prev: string[]) => string[])) => void;\n\tsetLiveTodos: (todos: Array<{ title: string; description?: string; deadline?: string; source_text?: string }>) => void;\n\tsetLiveSchedules: (schedules: Array<{ title: string; time?: string; description?: string; source_text?: string }>) => void;\n\tsetSelectedSegmentIndex: (index: number | null) => void;\n\tloadTimeline: (callback: (loading: boolean) => void, forceReload?: boolean) => void;\n\tsetIsLoadingTimeline: (loading: boolean) => void;\n\tformatDateTime: (date: Date) => string;\n\tgetSegmentDate: (start: Date, elapsedSec: number, currentDate: Date) => Date;\n}\n\nexport function useAudioRecordingControl({\n\tis24x7Enabled,\n\tconfigLoading,\n\tselectedDateRef,\n\tcurrentRecordingDateRef,\n\tliveRecordingStateRef,\n\trecordingStartedAtMsRef,\n\trecordingStartedAtRef,\n\tlastFinalEndMsRef,\n\tsetTranscriptionText,\n\tsetOptimizedText,\n\tsetPartialText,\n\tsetSegmentTimesSec,\n\tsetSegmentOffsetsSec,\n\tsetSegmentRecordingIds,\n\tsetSegmentTimeLabels,\n\tsetLiveTodos,\n\tsetLiveSchedules,\n\tsetSelectedSegmentIndex,\n\tloadTimeline,\n\tsetIsLoadingTimeline,\n\tformatDateTime,\n\tgetSegmentDate,\n}: UseAudioRecordingControlProps) {\n\tconst { isRecording, startRecording, stopRecording } = useAudioRecording();\n\tconst isRecordingRef = useRef(false);\n\tconst prevConfigRef = useRef<boolean | undefined>(undefined);\n\tconst isStartingRef = useRef(false);\n\n\t// 启动录音的内部函数（可复用）\n\tconst startRecordingInternal = useCallback(async () => {\n\t\t// 使用 ref 检查，避免闭包问题\n\t\tif (isRecordingRef.current) {\n\t\t\tconsole.log(\"已经在录音中，跳过启动\");\n\t\t\treturn; // 已经在录音中，不重复启动\n\t\t}\n\n\t\tconsole.log(\"开始启动录音...\");\n\n\t\t// 录音始终使用当前日期，不管用户在看哪个日期（回看模式）\n\t\tconst now = new Date();\n\t\tconst nowDateStr = getLocalDateStringForCompare(now);\n\t\tcurrentRecordingDateRef.current = now;\n\n\t\t// 如果用户正在查看当前日期，保留已有文本；如果查看其他日期，不影响回看模式\n\t\t// 使用 ref 获取最新的 selectedDate，避免依赖项变化导致重新创建\n\t\tconst currentSelectedDate = selectedDateRef.current;\n\t\tconst selectedDateStr = getLocalDateStringForCompare(currentSelectedDate);\n\t\tconst isViewingCurrentDate = selectedDateStr === nowDateStr;\n\n\t\t// 只有在查看当前日期时，才保留已有文本并追加新内容\n\t\t// 如果查看其他日期，录音在后台进行，不影响回看模式的显示\n\t\tif (isViewingCurrentDate) {\n\t\t\t// 查看当前日期：保留已有文本，在末尾追加新内容\n\t\t\tsetSelectedSegmentIndex(null);\n\t\t} else {\n\t\t\t// 查看其他日期：录音在后台进行，不影响回看模式的显示\n\t\t\t// 不修改 transcriptionText 等状态，让回看模式继续显示历史录音\n\t\t}\n\n\t\t// 标记正在启动，防止重复启动\n\t\tisRecordingRef.current = true;\n\n\t\t// 录制开始：记录起始时间用于段落时间标签\n\t\trecordingStartedAtMsRef.current = performance.now();\n\t\trecordingStartedAtRef.current = now;\n\t\tlastFinalEndMsRef.current = null; // 重置，第一段文本使用录音开始时间\n\t\t// 开始录音前，清空本次会话的实时高亮状态\n\t\tsetLiveTodos([]);\n\t\tsetLiveSchedules([]);\n\n\t\tconsole.log(\"准备调用 startRecording，isRecordingRef.current:\", isRecordingRef.current);\n\t\tawait startRecording(\n\t\t\t(text, isFinal) => {\n\t\t\t\t// 每次回调都检查当前日期，确保使用最新的 selectedDate\n\t\t\t\tconst now = new Date();\n\t\t\t\tconst nowDateStr = getLocalDateStringForCompare(now);\n\t\t\t\tconst currentSelectedDate = selectedDateRef.current;\n\t\t\t\tconst selectedDateStr = getLocalDateStringForCompare(currentSelectedDate);\n\t\t\t\tconst isViewingCurrentDate = selectedDateStr === nowDateStr;\n\n\t\t\t\t// 调试日志：帮助排查日期匹配问题\n\t\t\t\tif (isFinal && text.length > 0 && text !== \"__SEGMENT_SAVED__\") {\n\t\t\t\t\tconsole.log(\"录音回调日期检查:\", {\n\t\t\t\t\t\tnowDateStr,\n\t\t\t\t\t\tselectedDateStr,\n\t\t\t\t\t\tisViewingCurrentDate,\n\t\t\t\t\t\ttext: text.substring(0, 20),\n\t\t\t\t\t});\n\t\t\t\t}\n\n\t\t\t\t// 处理分段保存通知\n\t\t\t\tif (isFinal && text.startsWith(\"__SEGMENT_SAVED__\")) {\n\t\t\t\t\t// 提取分段保存的原因\n\t\t\t\t\tconst reason = text.replace(\"__SEGMENT_SAVED__:\", \"\") || \"分段保存\";\n\n\t\t\t\t\t// 显示状态提示\n\t\t\t\t\tif (reason.includes(\"30分钟\")) {\n\t\t\t\t\t\ttoastInfo(\"达到30分钟分段时间，保存当前段并开始新段\", { duration: 3000 });\n\t\t\t\t\t} else if (reason.includes(\"静音\")) {\n\t\t\t\t\t\tconst silenceMatch = reason.match(/(\\d+)秒/);\n\t\t\t\t\t\tif (silenceMatch) {\n\t\t\t\t\t\t\tconst seconds = parseInt(silenceMatch[1], 10);\n\t\t\t\t\t\t\tconst minutes = Math.floor(seconds / 60);\n\t\t\t\t\t\t\ttoastInfo(`检测到长时间静音（${minutes > 0 ? `${minutes}分` : \"\"}${seconds % 60}秒），保存当前段并开始新段`, { duration: 3000 });\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\ttoastInfo(reason, { duration: 3000 });\n\t\t\t\t\t\t}\n\t\t\t\t\t} else {\n\t\t\t\t\t\ttoastInfo(reason, { duration: 3000 });\n\t\t\t\t\t}\n\n\t\t\t\t\t// 分段已保存，重置时间戳和文本（但保留已显示的文本，因为已经保存到后端）\n\t\t\t\t\t// 重置录音开始时间，用于新段的时间戳计算\n\t\t\t\t\trecordingStartedAtMsRef.current = performance.now();\n\t\t\t\t\trecordingStartedAtRef.current = new Date();\n\t\t\t\t\tlastFinalEndMsRef.current = null;\n\n\t\t\t\t\t// 清空 liveRecordingStateRef，因为数据已经保存到后端\n\t\t\t\t\tliveRecordingStateRef.current = {\n\t\t\t\t\t\ttext: \"\",\n\t\t\t\t\t\toptimizedText: \"\",\n\t\t\t\t\t\tpartialText: \"\",\n\t\t\t\t\t\tsegmentTimesSec: [],\n\t\t\t\t\t\tsegmentOffsetsSec: [],\n\t\t\t\t\t\tsegmentRecordingIds: [],\n\t\t\t\t\t\tsegmentTimeLabels: [],\n\t\t\t\t\t\ttodos: [],\n\t\t\t\t\t\tschedules: [],\n\t\t\t\t\t};\n\n\t\t\t\t\t// 如果正在查看当前日期，需要重新加载时间线以显示已保存的历史数据\n\t\t\t\t\tif (isViewingCurrentDate) {\n\t\t\t\t\t\t// 清空实时状态（因为已保存到后端）\n\t\t\t\t\t\tsetPartialText(\"\");\n\t\t\t\t\t\tsetLiveTodos([]);\n\t\t\t\t\t\tsetLiveSchedules([]);\n\n\t\t\t\t\t\t// 延迟重新加载时间线，给后端时间保存数据\n\t\t\t\t\t\tsetTimeout(() => {\n\t\t\t\t\t\t\t// 检查是否仍在查看当前日期\n\t\t\t\t\t\t\tconst currentSelectedDate = selectedDateRef.current;\n\t\t\t\t\t\t\tconst currentDateStr = getLocalDateStringForCompare(new Date());\n\t\t\t\t\t\t\tconst selectedDateStr = getLocalDateStringForCompare(currentSelectedDate);\n\t\t\t\t\t\t\tif (selectedDateStr === currentDateStr) {\n\t\t\t\t\t\t\t\t// 强制重新加载时间线，显示已保存的历史数据\n\t\t\t\t\t\t\t\tsetIsLoadingTimeline(true);\n\t\t\t\t\t\t\t\tloadTimeline((loading) => {\n\t\t\t\t\t\t\t\t\tsetIsLoadingTimeline(loading);\n\t\t\t\t\t\t\t\t}, true); // forceReload = true，强制重新加载\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}, 1000); // 延迟1秒，确保后端已保存\n\t\t\t\t\t}\n\t\t\t\t\t// 更新当前录音日期\n\t\t\t\t\tcurrentRecordingDateRef.current = now;\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\t// 规则：\n\t\t\t\t// - final=false：作为\"未完成文本\"斜体显示（不落盘）\n\t\t\t\t// - final=true：替换掉未完成文本，并把最终句追加到正文\n\t\t\t\tif (isFinal) {\n\t\t\t\t\t// 使用前一个 final 文本的结束时间作为当前文本的开始时间\n\t\t\t\t\t// 对于第一段文本，使用录音开始时间\n\t\t\t\t\t// 这样能更准确地对应到音频开始位置，避免 ASR 处理延迟的影响\n\t\t\t\t\tconst segmentStartMs = lastFinalEndMsRef.current ?? recordingStartedAtMsRef.current;\n\t\t\t\t\tconst elapsedSec = (segmentStartMs - recordingStartedAtMsRef.current) / 1000;\n\n\t\t\t\t\t// 记录当前 final 文本的结束时间，作为下一段文本的开始时间\n\t\t\t\t\tlastFinalEndMsRef.current = performance.now();\n\n\t\t\t\t\t// 计算新的时间标签\n\t\t\t\t\tconst start = recordingStartedAtRef.current ?? new Date();\n\t\t\t\t\tconst currentDate = currentRecordingDateRef.current ?? new Date();\n\t\t\t\t\tconst segmentDate = getSegmentDate(start, elapsedSec, currentDate);\n\t\t\t\t\tconst newTimeLabel = formatDateTime(segmentDate);\n\n\t\t\t\t\t// 始终更新 liveRecordingStateRef（持久化状态），无论是否在查看当前日期\n\t\t\t\t\t// 这样切换回当前日期时可以恢复\n\t\t\t\t\tconst currentLive = liveRecordingStateRef.current;\n\t\t\t\t\tconst needsGap = currentLive.text && !currentLive.text.endsWith(\"\\n\");\n\t\t\t\t\tliveRecordingStateRef.current = {\n\t\t\t\t\t\t...currentLive,\n\t\t\t\t\t\ttext: `${currentLive.text}${needsGap ? \"\\n\" : \"\"}${text}\\n`,\n\t\t\t\t\t\tsegmentTimesSec: [...currentLive.segmentTimesSec, elapsedSec],\n\t\t\t\t\t\tsegmentOffsetsSec: [...currentLive.segmentOffsetsSec, elapsedSec],\n\t\t\t\t\t\tsegmentRecordingIds: [...currentLive.segmentRecordingIds, 0],\n\t\t\t\t\t\tsegmentTimeLabels: [...currentLive.segmentTimeLabels, newTimeLabel],\n\t\t\t\t\t\tpartialText: \"\", // final 文本到达时，清空 partial\n\t\t\t\t\t};\n\n\t\t\t\t\t// 如果不在当前日期，只更新 ref，不更新 UI\n\t\t\t\t\tif (!isViewingCurrentDate) {\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\n\t\t\t\t\t// 查看当前日期：追加文本到现有内容（历史数据 + 实时数据）\n\t\t\t\t\tsetTranscriptionText((prev) => {\n\t\t\t\t\t\t// 如果已有文本且不以换行结尾，添加换行\n\t\t\t\t\t\tconst needsGap = prev && !prev.endsWith(\"\\n\");\n\t\t\t\t\t\treturn `${prev}${needsGap ? \"\\n\" : \"\"}${text}\\n`;\n\t\t\t\t\t});\n\t\t\t\t\tsetSegmentTimesSec((prev) => [...prev, elapsedSec]);\n\t\t\t\t\tsetSegmentOffsetsSec((prev) => [...prev, elapsedSec]);\n\t\t\t\t\tsetSegmentRecordingIds((prev) => [...prev, 0]);\n\t\t\t\t\tsetSegmentTimeLabels((prev) => [...prev, newTimeLabel]);\n\t\t\t\t\tsetPartialText(\"\");\n\t\t\t\t} else {\n\t\t\t\t\t// partial 文本：始终更新持久化状态\n\t\t\t\t\tliveRecordingStateRef.current = {\n\t\t\t\t\t\t...liveRecordingStateRef.current,\n\t\t\t\t\t\tpartialText: text,\n\t\t\t\t\t};\n\n\t\t\t\t\t// 只有在查看当前日期时才更新 UI\n\t\t\t\t\tif (isViewingCurrentDate) {\n\t\t\t\t\t\tsetPartialText(text);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t},\n\t\t\t(data) => {\n\t\t\t\t// 每次回调都检查当前日期\n\t\t\t\tconst now = new Date();\n\t\t\t\tconst nowDateStr = getLocalDateStringForCompare(now);\n\t\t\t\tconst currentSelectedDate = selectedDateRef.current;\n\t\t\t\tconst selectedDateStr = getLocalDateStringForCompare(currentSelectedDate);\n\t\t\t\tconst isViewingCurrentDate = selectedDateStr === nowDateStr;\n\n\t\t\t\t// 始终更新持久化状态，无论是否在查看当前日期\n\t\t\t\tif (typeof data.optimizedText === \"string\") {\n\t\t\t\t\tliveRecordingStateRef.current = {\n\t\t\t\t\t\t...liveRecordingStateRef.current,\n\t\t\t\t\t\toptimizedText: data.optimizedText,\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t\tif (Array.isArray(data.todos)) {\n\t\t\t\t\tliveRecordingStateRef.current = {\n\t\t\t\t\t\t...liveRecordingStateRef.current,\n\t\t\t\t\t\ttodos: data.todos,\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t\tif (Array.isArray(data.schedules)) {\n\t\t\t\t\tliveRecordingStateRef.current = {\n\t\t\t\t\t\t...liveRecordingStateRef.current,\n\t\t\t\t\t\tschedules: data.schedules,\n\t\t\t\t\t};\n\t\t\t\t}\n\n\t\t\t\t// 如果不在当前日期，只更新 ref，不更新 UI\n\t\t\t\tif (!isViewingCurrentDate) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\t// 查看当前日期：同步更新 UI 状态\n\t\t\t\tif (typeof data.optimizedText === \"string\") setOptimizedText(data.optimizedText);\n\t\t\t\tif (Array.isArray(data.todos)) setLiveTodos(data.todos);\n\t\t\t\tif (Array.isArray(data.schedules)) setLiveSchedules(data.schedules);\n\t\t\t},\n\t\t\t(error) => {\n\t\t\t\tconsole.error(\"Recording error:\", error);\n\t\t\t\t// 显示用户友好的错误提示\n\t\t\t\t// toastError 需要从外部传入，这里只记录日志\n\t\t\t},\n\t\t\tis24x7Enabled\n\t\t);\n\t}, [\n\t\tstartRecording,\n\t\tis24x7Enabled,\n\t\tsetSegmentOffsetsSec,\n\t\tsetSegmentRecordingIds,\n\t\tsetSegmentTimeLabels,\n\t\tsetSegmentTimesSec,\n\t\tloadTimeline,\n\t\tformatDateTime,\n\t\tgetSegmentDate,\n\t\tsetTranscriptionText,\n\t\tsetOptimizedText,\n\t\tsetPartialText,\n\t\tsetLiveTodos,\n\t\tsetLiveSchedules,\n\t\tsetSelectedSegmentIndex,\n\t\tsetIsLoadingTimeline,\n\t\tcurrentRecordingDateRef,\n\t\tlastFinalEndMsRef,\n\t\trecordingStartedAtMsRef,\n\t\tselectedDateRef,\n\t\trecordingStartedAtRef,\n\t\tliveRecordingStateRef,\n\t]);\n\n\t// 自动启动/停止录音：监听配置变化\n\tuseEffect(() => {\n\t\t// 等待配置加载完成\n\t\tif (configLoading) {\n\t\t\tconsole.log(\"配置加载中，等待配置加载完成...\");\n\t\t\treturn;\n\t\t}\n\n\t\t// 调试日志\n\t\tconsole.log(\"配置状态检查:\", {\n\t\t\tis24x7Enabled,\n\t\t\tisRecording,\n\t\t\tisStarting: isStartingRef.current,\n\t\t\tprevConfig: prevConfigRef.current,\n\t\t});\n\n\t\t// 配置开启且未在录音中且未在启动中：自动启动\n\t\tif (is24x7Enabled && !isRecording && !isStartingRef.current) {\n\t\t\tconsole.log(\"✅ 配置已开启，准备自动启动录音...\");\n\t\t\tisStartingRef.current = true; // 标记正在启动\n\n\t\t\t// 延迟一点启动，确保组件完全初始化\n\t\t\tconst timer = setTimeout(async () => {\n\t\t\t\tconsole.log(\"开始执行自动启动录音...\");\n\t\t\t\ttry {\n\t\t\t\t\tawait startRecordingInternal();\n\t\t\t\t\tconsole.log(\"✅ 录音启动成功\");\n\t\t\t\t\tprevConfigRef.current = true;\n\t\t\t\t} catch (error) {\n\t\t\t\t\tconsole.error(\"❌ 自动启动录音失败:\", error);\n\t\t\t\t\t// 启动失败，允许重试\n\t\t\t\t} finally {\n\t\t\t\t\tisStartingRef.current = false;\n\t\t\t\t}\n\t\t\t}, 1000);\n\n\t\t\treturn () => {\n\t\t\t\tclearTimeout(timer);\n\t\t\t\tisStartingRef.current = false;\n\t\t\t};\n\t\t}\n\n\t\t// 配置从 true 变为 false：自动停止\n\t\tif (!is24x7Enabled && isRecording) {\n\t\t\tconsole.log(\"配置已关闭，停止录音...\");\n\t\t\tconst currentSegmentTimesSec = liveRecordingStateRef.current.segmentTimesSec;\n\t\t\tstopRecording(currentSegmentTimesSec.length > 0 ? currentSegmentTimesSec : undefined);\n\t\t\tprevConfigRef.current = false;\n\t\t}\n\n\t\t// 更新配置状态\n\t\tif (!is24x7Enabled && !isRecording) {\n\t\t\tprevConfigRef.current = false;\n\t\t}\n\t}, [is24x7Enabled, isRecording, startRecordingInternal, stopRecording, configLoading, liveRecordingStateRef]);\n\n\treturn {\n\t\tisRecording,\n\t\tstartRecordingInternal,\n\t\tstopRecording,\n\t};\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/audio/hooks/useSegmentSync.ts",
    "content": "\"use client\";\n\nimport { useEffect, useMemo, useState } from \"react\";\n\ninterface UseSegmentSyncOptions {\n\tisRecording: boolean;\n\tselectedRecordingId: number | null;\n\tcurrentTime: number;\n\tsegmentRecordingIds: number[];\n\tsegmentOffsetsSec: number[];\n\tactiveTab: \"original\" | \"optimized\";\n\ttranscriptionText: string;\n\toptimizedText: string;\n}\n\ninterface UseSegmentSyncReturn {\n\tselectedSegmentIndex: number | null;\n\tsetSelectedSegmentIndex: (index: number | null) => void;\n\tcurrentSegmentText: string;\n}\n\n/**\n * 管理段落选择状态同步\n * - 根据播放时间自动更新选中段落\n * - 计算当前选中段落的文本\n */\nexport function useSegmentSync({\n\tisRecording,\n\tselectedRecordingId,\n\tcurrentTime,\n\tsegmentRecordingIds,\n\tsegmentOffsetsSec,\n\tactiveTab,\n\ttranscriptionText,\n\toptimizedText,\n}: UseSegmentSyncOptions): UseSegmentSyncReturn {\n\tconst [selectedSegmentIndex, setSelectedSegmentIndex] = useState<number | null>(null);\n\n\t// 根据当前播放时间自动更新选中的文本段（仅回看模式）\n\tuseEffect(() => {\n\t\tif (isRecording) return;\n\t\tif (!selectedRecordingId) return;\n\t\tif (!segmentRecordingIds.length) return;\n\n\t\tconst indicesForRec: number[] = [];\n\t\tfor (let i = 0; i < segmentRecordingIds.length; i++) {\n\t\t\tif (segmentRecordingIds[i] === selectedRecordingId) {\n\t\t\t\tindicesForRec.push(i);\n\t\t\t}\n\t\t}\n\t\tif (indicesForRec.length === 0) return;\n\n\t\t// 选取\"offset <= currentTime\"中最接近 currentTime 的段落\n\t\tlet bestIndex = indicesForRec[0];\n\t\tlet bestDiff = Number.POSITIVE_INFINITY;\n\t\tfor (const idx of indicesForRec) {\n\t\t\tconst offset = segmentOffsetsSec[idx] ?? 0;\n\t\t\tconst diff = currentTime - offset;\n\t\t\t// 允许一点点负误差\n\t\t\tif (diff >= -0.5 && diff < bestDiff) {\n\t\t\t\tbestDiff = diff;\n\t\t\t\tbestIndex = idx;\n\t\t\t}\n\t\t}\n\t\tif (selectedSegmentIndex !== bestIndex) {\n\t\t\tsetSelectedSegmentIndex(bestIndex);\n\t\t}\n\t}, [\n\t\tisRecording,\n\t\tselectedRecordingId,\n\t\tcurrentTime,\n\t\tsegmentRecordingIds,\n\t\tsegmentOffsetsSec,\n\t\tselectedSegmentIndex,\n\t]);\n\n\t// 计算当前选中段落的文本\n\tconst currentSegmentText = useMemo(() => {\n\t\tif (selectedSegmentIndex == null) return \"\";\n\t\tconst baseText = activeTab === \"original\" ? transcriptionText : optimizedText;\n\t\tconst lines = baseText.split(\"\\n\").filter((s) => s.trim());\n\t\treturn lines[selectedSegmentIndex] ?? \"\";\n\t}, [selectedSegmentIndex, transcriptionText, optimizedText, activeTab]);\n\n\treturn {\n\t\tselectedSegmentIndex,\n\t\tsetSelectedSegmentIndex,\n\t\tcurrentSegmentText,\n\t};\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/audio/hooks/useStopRecordingConfirm.ts",
    "content": "\"use client\";\n\nimport { useCallback, useState } from \"react\";\nimport { useAudioRecordingStore } from \"@/lib/store/audio-recording-store\";\n\ninterface UseStopRecordingConfirmOptions {\n\tselectedDate: Date;\n\tstopRecording: (timestamps?: number[]) => void;\n\tloadRecordings: (options?: { forceSelectLatest?: boolean }) => Promise<void>;\n\tloadTimeline: (setLoading: (loading: boolean) => void, forceReload?: boolean) => Promise<void>;\n}\n\ninterface UseStopRecordingConfirmReturn {\n\tshowStopConfirm: boolean;\n\tisExtracting: boolean;\n\tisLoadingTimeline: boolean;\n\tsetIsLoadingTimeline: (loading: boolean) => void;\n\topenStopConfirm: () => void;\n\tcancelStopConfirm: () => void;\n\tconfirmStop: () => void;\n}\n\n/**\n * 管理停止录音确认弹窗和后续轮询逻辑\n */\nexport function useStopRecordingConfirm({\n\tselectedDate,\n\tstopRecording,\n\tloadRecordings,\n\tloadTimeline,\n}: UseStopRecordingConfirmOptions): UseStopRecordingConfirmReturn {\n\tconst [showStopConfirm, setShowStopConfirm] = useState(false);\n\tconst [isExtracting, setIsExtracting] = useState(false);\n\tconst [isLoadingTimeline, setIsLoadingTimeline] = useState(false);\n\n\tconst openStopConfirm = useCallback(() => {\n\t\tsetShowStopConfirm(true);\n\t}, []);\n\n\tconst cancelStopConfirm = useCallback(() => {\n\t\tsetShowStopConfirm(false);\n\t}, []);\n\n\tconst confirmStop = useCallback(() => {\n\t\tsetShowStopConfirm(false);\n\t\t// 从 store 获取时间戳数组\n\t\tconst storeState = useAudioRecordingStore.getState();\n\t\tconst timestamps = storeState.segmentTimesSec;\n\t\tstopRecording(timestamps.length > 0 ? timestamps : undefined);\n\n\t\t// 停止后后端才会落库录音记录：轮询检查直到新录音出现\n\t\tsetIsExtracting(true);\n\t\tsetIsLoadingTimeline(true);\n\n\t\t// 记录停止前的录音数量，用于判断是否有新录音\n\t\tlet previousRecordingCount = 0;\n\t\tlet pollCount = 0;\n\t\tconst maxPolls = 15; // 最多轮询 15 次（约 7.5 秒）\n\n\t\tconst checkNewRecording = async () => {\n\t\t\tpollCount++;\n\t\t\ttry {\n\t\t\t\tconst apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || \"http://localhost:8100\";\n\t\t\t\tconst dateStr = selectedDate.toISOString().split(\"T\")[0];\n\t\t\t\tconst response = await fetch(`${apiBaseUrl}/api/audio/recordings?date=${dateStr}`);\n\t\t\t\tconst data = await response.json();\n\t\t\t\tif (data.recordings) {\n\t\t\t\t\tconst recordings: Array<{ id: number; durationSeconds?: number }> = data.recordings;\n\t\t\t\t\tconst currentCount = recordings.length;\n\n\t\t\t\t\t// 首次记录数量\n\t\t\t\t\tif (previousRecordingCount === 0) {\n\t\t\t\t\t\tpreviousRecordingCount = currentCount;\n\t\t\t\t\t}\n\n\t\t\t\t\t// 如果有新录音，加载最新录音和时间线\n\t\t\t\t\tif (currentCount > previousRecordingCount) {\n\t\t\t\t\t\tawait loadRecordings({ forceSelectLatest: true });\n\t\t\t\t\t\tawait loadTimeline((loading) => {\n\t\t\t\t\t\t\tsetIsLoadingTimeline(loading);\n\t\t\t\t\t\t});\n\n\t\t\t\t\t\tsetTimeout(() => {\n\t\t\t\t\t\t\tsetIsExtracting(false);\n\t\t\t\t\t\t\tsetIsLoadingTimeline(false);\n\t\t\t\t\t\t}, 1500);\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\n\t\t\t\t\t// 如果已经轮询了足够多次，仍然加载\n\t\t\t\t\tif (pollCount >= maxPolls) {\n\t\t\t\t\t\tawait loadRecordings({ forceSelectLatest: true });\n\t\t\t\t\t\tawait loadTimeline((loading) => {\n\t\t\t\t\t\t\tsetIsLoadingTimeline(loading);\n\t\t\t\t\t\t});\n\t\t\t\t\t\tsetTimeout(() => {\n\t\t\t\t\t\t\tsetIsExtracting(false);\n\t\t\t\t\t\t\tsetIsLoadingTimeline(false);\n\t\t\t\t\t\t}, 1000);\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} catch (error) {\n\t\t\t\tconsole.error(\"Failed to check new recording:\", error);\n\t\t\t\tif (pollCount >= maxPolls) {\n\t\t\t\t\tsetIsExtracting(false);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tsetTimeout(checkNewRecording, 500);\n\t\t};\n\n\t\tsetTimeout(checkNewRecording, 800);\n\t}, [selectedDate, stopRecording, loadRecordings, loadTimeline]);\n\n\treturn {\n\t\tshowStopConfirm,\n\t\tisExtracting,\n\t\tisLoadingTimeline,\n\t\tsetIsLoadingTimeline,\n\t\topenStopConfirm,\n\t\tcancelStopConfirm,\n\t\tconfirmStop,\n\t};\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/audio/utils/parseTimeToIsoWithDate.ts",
    "content": "\"use client\";\n\n/**\n * 将中文/自然语言时间解析为带日期的 ISO 字符串；解析失败返回 undefined。\n */\nexport function parseTimeToIsoWithDate(raw: string | null | undefined, selectedDate: Date): string | undefined {\n\tif (!raw) return undefined;\n\tconst abs = Date.parse(raw);\n\tif (!Number.isNaN(abs)) return new Date(abs).toISOString();\n\n\tconst normalized = raw.replace(/钟/g, \"\").replace(/：/g, \":\").trim();\n\tconst m = normalized.match(\n\t\t/(早上|上午|中午|下午|晚上|傍晚|夜里|凌晨)?\\s*([0-9一二三四五六七八九十两]{1,3})\\s*点(?:\\s*(半|([0-9]{1,2})\\s*分))?/,\n\t);\n\tif (!m) return undefined;\n\n\tconst period = m[1] || \"\";\n\tconst hourRaw = m[2];\n\tconst half = m[3] === \"半\";\n\tconst minRaw = m[4];\n\n\tconst cnToNum = (s: string): number => {\n\t\tif (/^\\d+$/.test(s)) return Number(s);\n\t\tconst map: Record<string, number> = {\n\t\t\t零: 0,\n\t\t\t一: 1,\n\t\t\t二: 2,\n\t\t\t两: 2,\n\t\t\t三: 3,\n\t\t\t四: 4,\n\t\t\t五: 5,\n\t\t\t六: 6,\n\t\t\t七: 7,\n\t\t\t八: 8,\n\t\t\t九: 9,\n\t\t\t十: 10,\n\t\t};\n\t\tif (s === \"十\") return 10;\n\t\tif (s.length === 2 && s[0] === \"十\") return 10 + (map[s[1]] ?? 0);\n\t\tif (s.length === 2 && s[1] === \"十\") return (map[s[0]] ?? 0) * 10;\n\t\tif (s.length === 3 && s[1] === \"十\") return (map[s[0]] ?? 0) * 10 + (map[s[2]] ?? 0);\n\t\treturn map[s] ?? 0;\n\t};\n\n\tlet hour = cnToNum(hourRaw);\n\tlet minute = half ? 30 : minRaw ? Number(minRaw) : 0;\n\tif (!Number.isFinite(hour) || hour < 0 || hour > 23) return undefined;\n\tif (!Number.isFinite(minute) || minute < 0 || minute > 59) minute = 0;\n\n\tif (/(下午|晚上|傍晚|夜里)/.test(period) && hour < 12) hour += 12;\n\tif (/中午/.test(period) && hour >= 1 && hour <= 11) hour += 12;\n\n\tconst d = new Date(selectedDate);\n\td.setHours(hour, minute, 0, 0);\n\treturn d.toISOString();\n}\n\n// formatDateTime 和 formatTime 已移至 timeUtils.ts\n// 请使用 timeUtils 中的函数以保持一致性\nexport { formatDateTime, formatTime } from \"./timeUtils\";\n"
  },
  {
    "path": "free-todo-frontend/apps/audio/utils/timeUtils.ts",
    "content": "\"use client\";\n\n/**\n * 统一的时间处理工具\n * 处理时区、跨日期、时间戳转换等问题\n */\n\n/**\n * 获取本地时区的 Date 对象（不进行时区转换）\n * @param dateStr ISO 8601 字符串或 Date 对象\n * @returns Date 对象（本地时区）\n */\nexport function parseLocalDate(dateStr: string | Date): Date {\n\tif (dateStr instanceof Date) {\n\t\treturn dateStr;\n\t}\n\t// 如果包含时区信息，解析为 UTC 然后转换为本地\n\tif (dateStr.includes(\"T\") || dateStr.includes(\"Z\") || dateStr.includes(\"+\") || dateStr.includes(\"-\", 10)) {\n\t\tconst date = new Date(dateStr);\n\t\t// 返回本地时区的 Date 对象\n\t\treturn new Date(\n\t\t\tdate.getFullYear(),\n\t\t\tdate.getMonth(),\n\t\t\tdate.getDate(),\n\t\t\tdate.getHours(),\n\t\t\tdate.getMinutes(),\n\t\t\tdate.getSeconds(),\n\t\t\tdate.getMilliseconds()\n\t\t);\n\t}\n\t// 简单日期字符串，直接解析\n\treturn new Date(dateStr);\n}\n\n/**\n * 格式化日期为本地时区的 ISO 字符串（不含时区信息）\n * @param date Date 对象\n * @returns ISO 格式字符串（本地时区）\n */\nexport function toLocalISOString(date: Date): string {\n\tconst year = date.getFullYear();\n\tconst month = String(date.getMonth() + 1).padStart(2, \"0\");\n\tconst day = String(date.getDate()).padStart(2, \"0\");\n\tconst hours = String(date.getHours()).padStart(2, \"0\");\n\tconst minutes = String(date.getMinutes()).padStart(2, \"0\");\n\tconst seconds = String(date.getSeconds()).padStart(2, \"0\");\n\tconst milliseconds = String(date.getMilliseconds()).padStart(3, \"0\");\n\treturn `${year}-${month}-${day}T${hours}:${minutes}:${seconds}.${milliseconds}`;\n}\n\n/**\n * 计算文本段在音频中的精确偏移时间\n * 对于录音模式：使用已记录的精确时间戳\n * 对于回看模式：使用录音开始时间 + 均匀分配的偏移（临时方案，理想情况下后端应返回精确时间戳）\n *\n * @param _recordingStartTime 录音开始时间（Date 对象，保留用于未来扩展）\n * @param segmentIndex 文本段索引\n * @param totalSegments 总文本段数\n * @param recordingDuration 录音总时长（秒）\n * @param preciseOffset 精确偏移时间（秒），如果提供则优先使用（录音模式）\n * @returns 偏移时间（秒）\n */\nexport function calculateSegmentOffset(\n\t_recordingStartTime: Date,\n\tsegmentIndex: number,\n\ttotalSegments: number,\n\trecordingDuration: number,\n\tpreciseOffset?: number\n): number {\n\t// 如果有精确偏移（录音模式），直接使用\n\tif (preciseOffset !== undefined && Number.isFinite(preciseOffset)) {\n\t\treturn preciseOffset;\n\t}\n\t// 否则使用均匀分配（回看模式，临时方案）\n\t// TODO: 后端应返回每段文本的精确时间戳\n\tif (recordingDuration > 0 && totalSegments > 0) {\n\t\treturn (segmentIndex / totalSegments) * recordingDuration;\n\t}\n\treturn 0;\n}\n\n/**\n * 计算文本段的绝对时间（用于时间标签显示）\n * @param recordingStartTime 录音开始时间\n * @param offsetSec 偏移时间（秒）\n * @returns Date 对象\n */\nexport function calculateSegmentAbsoluteTime(\n\trecordingStartTime: Date,\n\toffsetSec: number\n): Date {\n\treturn new Date(recordingStartTime.getTime() + offsetSec * 1000);\n}\n\n/**\n * 检查录音是否跨日期\n * @param startTime 录音开始时间\n * @param durationSec 录音时长（秒）\n * @param selectedDate 用户选择的日期\n * @returns 如果跨日期返回 true\n */\nexport function isRecordingCrossDate(\n\tstartTime: Date,\n\tdurationSec: number,\n\tselectedDate: Date\n): boolean {\n\tconst startDateStr = startTime.toISOString().split(\"T\")[0];\n\tconst selectedDateStr = selectedDate.toISOString().split(\"T\")[0];\n\n\t// 如果开始日期与选择日期不同，肯定跨日期\n\tif (startDateStr !== selectedDateStr) {\n\t\treturn true;\n\t}\n\n\t// 计算结束时间\n\tconst endTime = new Date(startTime.getTime() + durationSec * 1000);\n\tconst endDateStr = endTime.toISOString().split(\"T\")[0];\n\n\t// 如果结束日期与开始日期不同，跨日期\n\treturn endDateStr !== startDateStr;\n}\n\n/**\n * 获取录音的实际日期（用于时间标签）\n * 如果录音跨日期，使用录音的实际开始日期\n * @param recordingStartTime 录音开始时间\n * @param offsetSec 文本段偏移时间（秒）\n * @param _selectedDate 用户选择的日期（保留用于未来扩展）\n * @returns Date 对象（用于时间标签的日期部分）\n */\nexport function getSegmentDate(\n\trecordingStartTime: Date,\n\toffsetSec: number,\n\t_selectedDate: Date\n): Date {\n\tconst segmentTime = calculateSegmentAbsoluteTime(recordingStartTime, offsetSec);\n\t// 使用文本段的实际时间，而不是 selectedDate\n\treturn segmentTime;\n}\n\n/**\n * 格式化日期时间（本地时区）\n * @param date Date 对象\n * @returns 格式化的日期时间字符串\n */\nexport function formatDateTime(date: Date): string {\n\treturn date.toLocaleString(\"zh-CN\", {\n\t\tyear: \"numeric\",\n\t\tmonth: \"2-digit\",\n\t\tday: \"2-digit\",\n\t\thour12: false,\n\t\thour: \"2-digit\",\n\t\tminute: \"2-digit\",\n\t\tsecond: \"2-digit\",\n\t});\n}\n\n/**\n * 格式化时间（秒数）\n * @param seconds 秒数\n * @returns \"MM:SS\" 格式\n */\nexport function formatTime(seconds: number): string {\n\tconst mins = Math.floor(seconds / 60);\n\tconst secs = Math.floor(seconds % 60);\n\treturn `${mins}:${secs.toString().padStart(2, \"0\")}`;\n}\n\n/**\n * 获取日期字符串（用于 API 查询）\n * 使用本地时区，避免时区转换问题\n * @param date Date 对象\n * @returns \"YYYY-MM-DD\" 格式\n */\nexport function getDateString(date: Date): string {\n\tconst year = date.getFullYear();\n\tconst month = String(date.getMonth() + 1).padStart(2, \"0\");\n\tconst day = String(date.getDate()).padStart(2, \"0\");\n\treturn `${year}-${month}-${day}`;\n}\n\n/**\n * 获取本地日期字符串（用于日期比较）\n * @param date Date 对象\n * @returns \"YYYY-MM-DD\" 格式\n */\nexport function getLocalDateString(date: Date): string {\n\tconst year = date.getFullYear();\n\tconst month = String(date.getMonth() + 1).padStart(2, \"0\");\n\tconst day = String(date.getDate()).padStart(2, \"0\");\n\treturn `${year}-${month}-${day}`;\n}\n\n/**\n * 获取本地日期字符串（用于日期比较）- 别名\n * @param date Date 对象\n * @returns \"YYYY-MM-DD\" 格式\n */\nexport function getLocalDateStringForCompare(date: Date): string {\n\treturn getLocalDateString(date);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/calendar/CalendarPanel.tsx",
    "content": "\"use client\";\n\n/**\n * 日历面板组件\n * 使用全局 DndContext，支持从其他面板拖拽 Todo 到日期\n */\n\nimport { Calendar, ChevronLeft, ChevronRight, RotateCcw } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport { useCallback, useMemo, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { PanelHeader } from \"@/components/common/layout/PanelHeader\";\nimport { useCreateTodo, useTodos } from \"@/lib/query\";\nimport { normalizeReminderOffsets } from \"@/lib/reminders\";\nimport { useTodoStore } from \"@/lib/store/todo-store\";\nimport { cn } from \"@/lib/utils\";\nimport { QuickCreatePopover } from \"./components/QuickCreatePopover\";\nimport { useMonthScroll } from \"./hooks/useMonthScroll\";\nimport type { CalendarTodo, CalendarView } from \"./types\";\nimport {\n\taddDays,\n\taddMonths,\n\tDEFAULT_NEW_TIME,\n\tendOfDay,\n\tgetWeekOfYear,\n\tparseScheduleTime,\n\tstartOfDay,\n\tstartOfMonth,\n\tstartOfWeek,\n\ttoDateKey,\n} from \"./utils\";\nimport { DayView } from \"./views/DayView\";\nimport { MonthScroller } from \"./views/MonthScroller\";\nimport { WeekView } from \"./views/WeekView\";\n\nexport function CalendarPanel() {\n\tconst t = useTranslations(\"calendar\");\n\n\t// 从 TanStack Query 获取 todos 数据\n\tconst { data: todos = [] } = useTodos();\n\n\t// 从 TanStack Query 获取创建 todo 的 mutation\n\tconst createTodoMutation = useCreateTodo();\n\n\t// 从 Zustand 获取 UI 状态\n\tconst { setSelectedTodoId } = useTodoStore();\n\n\tconst [view, setView] = useState<CalendarView>(\"month\");\n\tconst [currentDate, setCurrentDate] = useState<Date>(startOfDay(new Date()));\n\tconst [quickTargetDate, setQuickTargetDate] = useState<Date | null>(null);\n\tconst [quickTitle, setQuickTitle] = useState(\"\");\n\tconst [quickTime, setQuickTime] = useState(DEFAULT_NEW_TIME);\n\tconst [quickReminderOffsets, setQuickReminderOffsets] = useState<number[]>(\n\t\tnormalizeReminderOffsets(undefined),\n\t);\n\tconst [quickAnchorRect, setQuickAnchorRect] = useState<DOMRect | null>(null);\n\tconst {\n\t\tmonthItems,\n\t\tmonthScrollRef,\n\t\thandleLoadMoreMonths,\n\t\trequestMonthScroll,\n\t\tshouldIgnoreActiveMonthChange,\n\t} = useMonthScroll({ currentDate, view });\n\n\tconst VIEW_OPTIONS: { id: CalendarView; label: string }[] = [\n\t\t{ id: \"month\", label: t(\"monthView\") },\n\t\t{ id: \"week\", label: t(\"weekView\") },\n\t\t{ id: \"day\", label: t(\"dayView\") },\n\t];\n\n\tconst WEEKDAY_LABELS = [\n\t\tt(\"weekdays.monday\"),\n\t\tt(\"weekdays.tuesday\"),\n\t\tt(\"weekdays.wednesday\"),\n\t\tt(\"weekdays.thursday\"),\n\t\tt(\"weekdays.friday\"),\n\t\tt(\"weekdays.saturday\"),\n\t\tt(\"weekdays.sunday\"),\n\t];\n\n\tconst range = useMemo(() => {\n\t\tif (view === \"month\") {\n\t\t\tif (monthItems.length > 0) {\n\t\t\t\tconst first = monthItems[0];\n\t\t\t\tconst last = monthItems[monthItems.length - 1];\n\t\t\t\tconst start = startOfWeek(startOfMonth(first));\n\t\t\t\tconst end = endOfDay(addDays(startOfWeek(startOfMonth(last)), 41));\n\t\t\t\treturn { start, end };\n\t\t\t}\n\t\t\tconst start = startOfWeek(startOfMonth(currentDate));\n\t\t\tconst end = endOfDay(addDays(start, 41));\n\t\t\treturn { start, end };\n\t\t}\n\t\tif (view === \"week\") {\n\t\t\tconst start = startOfWeek(currentDate);\n\t\t\tconst end = endOfDay(addDays(start, 6));\n\t\t\treturn { start, end };\n\t\t}\n\t\tconst start = startOfDay(currentDate);\n\t\tconst end = endOfDay(currentDate);\n\t\treturn { start, end };\n\t}, [currentDate, monthItems, view]);\n\n\tconst todosWithSchedule: CalendarTodo[] = useMemo(() => {\n\t\tconst items: CalendarTodo[] = [];\n\t\tfor (const todo of todos) {\n\t\t\tconst startRaw = todo.startTime ?? todo.endTime;\n\t\t\tconst startTime = parseScheduleTime(startRaw);\n\t\t\tif (!startTime) continue;\n\t\t\tconst endTime = parseScheduleTime(todo.endTime ?? undefined);\n\t\t\tconst startDay = startOfDay(startTime);\n\t\t\tconst endDay = startOfDay(endTime ?? startTime);\n\t\t\tfor (\n\t\t\t\tlet day = startDay;\n\t\t\t\tday.getTime() <= endDay.getTime();\n\t\t\t\tday = addDays(day, 1)\n\t\t\t) {\n\t\t\t\tconst dayValue = new Date(day);\n\t\t\t\titems.push({\n\t\t\t\t\ttodo,\n\t\t\t\t\tstartTime,\n\t\t\t\t\tendTime,\n\t\t\t\t\tdateKey: toDateKey(dayValue),\n\t\t\t\t\tday: dayValue,\n\t\t\t\t\tisAllDay: todo.isAllDay ?? false,\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t\treturn items.sort(\n\t\t\t(a: CalendarTodo, b: CalendarTodo) =>\n\t\t\t\ta.startTime.getTime() - b.startTime.getTime(),\n\t\t);\n\t}, [todos]);\n\n\tconst todosInRange = useMemo(\n\t\t() =>\n\t\t\ttodosWithSchedule.filter(\n\t\t\t\t(item) =>\n\t\t\t\t\titem.day.getTime() >= range.start.getTime() &&\n\t\t\t\t\titem.day.getTime() <= range.end.getTime(),\n\t\t\t),\n\t\t[range.end, range.start, todosWithSchedule],\n\t);\n\n\tconst groupedByDay = useMemo(() => {\n\t\tconst map = new Map<string, CalendarTodo[]>();\n\t\tfor (const item of todosInRange) {\n\t\t\tconst key = item.dateKey;\n\t\t\tif (!map.has(key)) {\n\t\t\t\tmap.set(key, [item]);\n\t\t\t} else {\n\t\t\t\tmap.get(key)?.push(item);\n\t\t\t}\n\t\t}\n\t\treturn map;\n\t}, [todosInRange]);\n\n\tconst handleNavigate = (direction: \"prev\" | \"next\" | \"today\") => {\n\t\tif (direction === \"today\") {\n\t\t\tconst today = startOfDay(new Date());\n\t\t\tif (view === \"month\") {\n\t\t\t\tconst target = startOfMonth(today);\n\t\t\t\trequestMonthScroll(target);\n\t\t\t\tsetCurrentDate(target);\n\t\t\t} else {\n\t\t\t\tsetCurrentDate(today);\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\n\t\tif (view === \"month\") {\n\t\t\tconst nextMonth =\n\t\t\t\tdirection === \"prev\"\n\t\t\t\t\t? addMonths(startOfMonth(currentDate), -1)\n\t\t\t\t\t: addMonths(startOfMonth(currentDate), 1);\n\t\t\trequestMonthScroll(nextMonth);\n\t\t\tsetCurrentDate(nextMonth);\n\t\t\treturn;\n\t\t}\n\n\t\tconst delta = view === \"week\" ? 7 : 1;\n\t\tconst offset = direction === \"prev\" ? -delta : delta;\n\t\tsetCurrentDate((prev) => startOfDay(addDays(prev, offset)));\n\t};\n\n\tconst handleSelectDay = (\n\t\tdate: Date,\n\t\tanchorEl?: HTMLDivElement | null,\n\t\tinCurrentMonth?: boolean,\n\t) => {\n\t\tconst target = startOfDay(date);\n\t\tsetQuickTargetDate(target);\n\t\tsetQuickAnchorRect(anchorEl?.getBoundingClientRect() ?? null);\n\t\tif (view === \"month\" && inCurrentMonth === false) {\n\t\t\treturn;\n\t\t}\n\t\tsetCurrentDate(target);\n\t};\n\n\tconst handleQuickCreate = async () => {\n\t\tif (!quickTargetDate || !quickTitle.trim()) return;\n\t\tconst [hh, mm] = quickTime.split(\":\").map((n) => Number.parseInt(n, 10));\n\t\tconst startTime = startOfDay(quickTargetDate);\n\t\tstartTime.setHours(hh || 0, mm || 0, 0, 0);\n\t\ttry {\n\t\t\tawait createTodoMutation.mutateAsync({\n\t\t\t\tname: quickTitle.trim(),\n\t\t\t\tstartTime: startTime.toISOString(),\n\t\t\t\treminderOffsets: quickReminderOffsets,\n\t\t\t\tstatus: \"active\",\n\t\t\t});\n\t\t\tsetQuickTitle(\"\");\n\t\t\tsetQuickTargetDate(null);\n\t\t\tsetQuickAnchorRect(null);\n\t\t\tsetQuickReminderOffsets(normalizeReminderOffsets(undefined));\n\t\t} catch (err) {\n\t\t\tconsole.error(\"Failed to create todo:\", err);\n\t\t}\n\t};\n\n\tconst renderQuickCreate = (date: Date, _className: string) => {\n\t\tif (!quickTargetDate) return null;\n\t\tif (toDateKey(date) !== toDateKey(quickTargetDate)) return null;\n\t\tconst top = quickAnchorRect ? quickAnchorRect.top + 28 : 120;\n\t\tconst left = quickAnchorRect ? quickAnchorRect.left + 4 : 16;\n\t\tconst closePopover = () => {\n\t\t\tsetQuickTargetDate(null);\n\t\t\tsetQuickTitle(\"\");\n\t\t\tsetQuickAnchorRect(null);\n\t\t\tsetQuickReminderOffsets(normalizeReminderOffsets(undefined));\n\t\t};\n\n\t\treturn createPortal(\n\t\t\t<>\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"fixed inset-0 z-40\"\n\t\t\t\t\taria-hidden\n\t\t\t\t\tonPointerDown={(event) => {\n\t\t\t\t\t\tevent.preventDefault();\n\t\t\t\t\t\tevent.stopPropagation();\n\t\t\t\t\t\tclosePopover();\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"fixed z-[9999] w-72 max-w-[90vw] pointer-events-auto\"\n\t\t\t\t\tstyle={{ top, left }}\n\t\t\t\t\tdata-quick-create\n\t\t\t\t>\n\t\t\t\t\t<QuickCreatePopover\n\t\t\t\t\t\ttargetDate={quickTargetDate}\n\t\t\t\t\t\tvalue={quickTitle}\n\t\t\t\t\t\ttime={quickTime}\n\t\t\t\t\t\treminderOffsets={quickReminderOffsets}\n\t\t\t\t\t\tonChange={setQuickTitle}\n\t\t\t\t\t\tonTimeChange={setQuickTime}\n\t\t\t\t\t\tonReminderChange={setQuickReminderOffsets}\n\t\t\t\t\t\tonConfirm={handleQuickCreate}\n\t\t\t\t\t\tonCancel={closePopover}\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\t\t\t</>,\n\t\t\tdocument.body,\n\t\t);\n\t};\n\n\tconst handleActiveMonthChange = useCallback(\n\t\t(month: Date) => {\n\t\t\tif (view !== \"month\") return;\n\t\t\tconst currentMonth = startOfMonth(currentDate);\n\t\t\tif (shouldIgnoreActiveMonthChange(month)) return;\n\t\t\tif (\n\t\t\t\tcurrentMonth.getFullYear() === month.getFullYear() &&\n\t\t\t\tcurrentMonth.getMonth() === month.getMonth()\n\t\t\t) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tsetCurrentDate(month);\n\t\t},\n\t\t[currentDate, shouldIgnoreActiveMonthChange, view],\n\t);\n\n\treturn (\n\t\t<div className=\"flex h-full flex-col overflow-hidden bg-background\">\n\t\t\t{/* 顶部标题栏 */}\n\t\t\t<PanelHeader icon={Calendar} title={t(\"title\")} />\n\t\t\t{/* 顶部工具栏 */}\n\t\t\t<div className=\"flex flex-wrap items-center justify-between gap-3 border-b border-border px-4 py-3\">\n\t\t\t\t<span className=\"text-sm font-medium text-foreground\">\n\t\t\t\t\t{view === \"month\" &&\n\t\t\t\t\t\tt(\"yearMonth\", {\n\t\t\t\t\t\t\tyear: currentDate.getFullYear(),\n\t\t\t\t\t\t\tmonth: `${currentDate.getMonth() + 1}`.padStart(2, \"0\"),\n\t\t\t\t\t\t})}\n\t\t\t\t\t{view === \"week\" &&\n\t\t\t\t\t\tt(\"yearMonthWeek\", {\n\t\t\t\t\t\t\tyear: currentDate.getFullYear(),\n\t\t\t\t\t\t\tmonth: `${currentDate.getMonth() + 1}`.padStart(2, \"0\"),\n\t\t\t\t\t\t\tweek: getWeekOfYear(currentDate),\n\t\t\t\t\t\t})}\n\t\t\t\t\t{view === \"day\" &&\n\t\t\t\t\t\tt(\"yearMonthDay\", {\n\t\t\t\t\t\t\tyear: currentDate.getFullYear(),\n\t\t\t\t\t\t\tmonth: `${currentDate.getMonth() + 1}`.padStart(2, \"0\"),\n\t\t\t\t\t\t\tday: `${currentDate.getDate()}`.padStart(2, \"0\"),\n\t\t\t\t\t\t})}\n\t\t\t\t</span>\n\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tonClick={() => handleNavigate(\"prev\")}\n\t\t\t\t\t\tclassName=\"inline-flex h-9 w-9 items-center justify-center rounded-md border bg-card text-muted-foreground hover:bg-muted/60\"\n\t\t\t\t\t\taria-label={t(\"previous\")}\n\t\t\t\t\t>\n\t\t\t\t\t\t<ChevronLeft className=\"h-4 w-4\" />\n\t\t\t\t\t</button>\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tonClick={() => handleNavigate(\"today\")}\n\t\t\t\t\t\tclassName=\"inline-flex items-center gap-2 rounded-md border bg-card px-3 py-2 text-sm font-medium text-foreground hover:bg-muted/60\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<RotateCcw className=\"h-4 w-4\" />\n\t\t\t\t\t\t{t(\"today\")}\n\t\t\t\t\t</button>\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tonClick={() => handleNavigate(\"next\")}\n\t\t\t\t\t\tclassName=\"inline-flex h-9 w-9 items-center justify-center rounded-md border bg-card text-muted-foreground hover:bg-muted/60\"\n\t\t\t\t\t\taria-label={t(\"next\")}\n\t\t\t\t\t>\n\t\t\t\t\t\t<ChevronRight className=\"h-4 w-4\" />\n\t\t\t\t\t</button>\n\t\t\t\t</div>\n\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t{VIEW_OPTIONS.map((option) => (\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tkey={option.id}\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tonClick={() => setView(option.id)}\n\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\"rounded-md px-3 py-2 text-sm font-medium transition-colors\",\n\t\t\t\t\t\t\t\tview === option.id\n\t\t\t\t\t\t\t\t\t? \"bg-primary text-primary-foreground shadow-sm\"\n\t\t\t\t\t\t\t\t\t: \"bg-card text-muted-foreground hover:bg-muted/60\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{option.label}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t))}\n\t\t\t\t\t{/* <button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\tsetQuickTargetDate(startOfDay(currentDate));\n\t\t\t\t\t\t\tsetQuickAnchorRect(null);\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tclassName=\"inline-flex items-center gap-2 rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground shadow-sm hover:bg-primary/90\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<Plus className=\"h-4 w-4\" />\n\t\t\t\t\t\t{t(\"create\")}\n\t\t\t\t\t</button> */}\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t{/* 视图主体 */}\n\t\t\t<div\n\t\t\t\tref={monthScrollRef}\n\t\t\t\tclassName=\"flex-1 overflow-y-auto bg-background p-3\"\n\t\t\t>\n\t\t\t\t\t{view === \"month\" && (\n\t\t\t\t\t\t<div className=\"grid grid-cols-7\">\n\t\t\t\t\t\t\t{WEEKDAY_LABELS.map((label) => (\n\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\tkey={label}\n\t\t\t\t\t\t\t\tclassName=\"py-2 text-center text-xs font-medium text-muted-foreground\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{t(\"weekPrefix\")}\n\t\t\t\t\t\t\t\t{label}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t))}\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t\t<div>\n\t\t\t\t\t{view === \"month\" && (\n\t\t\t\t\t\t<MonthScroller\n\t\t\t\t\t\t\tmonths={\n\t\t\t\t\t\t\t\tmonthItems.length > 0\n\t\t\t\t\t\t\t\t\t? monthItems\n\t\t\t\t\t\t\t\t\t: [startOfMonth(currentDate)]\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tactiveMonth={startOfMonth(currentDate)}\n\t\t\t\t\t\t\tgroupedByDay={groupedByDay}\n\t\t\t\t\t\t\tonSelectDay={handleSelectDay}\n\t\t\t\t\t\t\tonSelectTodo={(todo) => setSelectedTodoId(todo.id)}\n\t\t\t\t\t\t\ttodayText={t(\"today\")}\n\t\t\t\t\t\t\trenderQuickCreate={(date) =>\n\t\t\t\t\t\t\t\trenderQuickCreate(date, \"absolute left-1 top-7 z-20 w-72 max-w-[90vw]\")\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tonLoadMore={handleLoadMoreMonths}\n\t\t\t\t\t\t\tonActiveMonthChange={handleActiveMonthChange}\n\t\t\t\t\t\t\tscrollRef={monthScrollRef}\n\t\t\t\t\t\t/>\n\t\t\t\t\t)}\n\t\t\t\t\t{view === \"week\" && (\n\t\t\t\t\t\t<WeekView\n\t\t\t\t\t\t\tcurrentDate={currentDate}\n\t\t\t\t\t\t\ttodos={todos}\n\t\t\t\t\t\t\tonSelectDay={handleSelectDay}\n\t\t\t\t\t\t\tonSelectTodo={(todo) => setSelectedTodoId(todo.id)}\n\t\t\t\t\t\t\ttodayText={t(\"today\")}\n\t\t\t\t\t\t/>\n\t\t\t\t\t)}\n\t\t\t\t\t{view === \"day\" && (\n\t\t\t\t\t\t<DayView\n\t\t\t\t\t\t\tcurrentDate={currentDate}\n\t\t\t\t\t\t\ttodos={todos}\n\t\t\t\t\t\t/>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/calendar/components/DayColumn.tsx",
    "content": "/**\n * 日历日期列 - 作为放置目标\n * 使用 useDroppable 并传递类型化的 DropData\n */\n\nimport { useDroppable } from \"@dnd-kit/core\";\nimport type React from \"react\";\nimport { useMemo } from \"react\";\nimport type { DropData } from \"@/lib/dnd\";\nimport type { Todo } from \"@/lib/types\";\nimport { cn } from \"@/lib/utils\";\nimport type { CalendarDay, CalendarTodo, CalendarView } from \"../types\";\nimport { toDateKey } from \"../utils\";\nimport { DraggableTodo } from \"./DraggableTodo\";\n\nexport function DayColumn({\n\tday,\n\ttodos,\n\tonSelectDay,\n\tonSelectTodo,\n\tview,\n\ttodayText,\n\trenderQuickCreate,\n}: {\n\tday: CalendarDay;\n\ttodos: CalendarTodo[];\n\tonSelectDay: (\n\t\tdate: Date,\n\t\tanchorEl?: HTMLDivElement | null,\n\t\tinCurrentMonth?: boolean,\n\t) => void;\n\tonSelectTodo: (todo: Todo) => void;\n\tview: CalendarView;\n\ttodayText: string;\n\trenderQuickCreate?: (date: Date) => React.ReactNode;\n}) {\n\tconst dateKey = toDateKey(day.date);\n\n\t// 构建类型化的放置区数据\n\tconst dropData: DropData = useMemo(\n\t\t() => ({\n\t\t\ttype: \"CALENDAR_DATE\" as const,\n\t\t\tmetadata: {\n\t\t\t\tdateKey,\n\t\t\t\tdate: day.date,\n\t\t\t},\n\t\t}),\n\t\t[dateKey, day.date],\n\t);\n\n\tconst { isOver, setNodeRef } = useDroppable({\n\t\tid: `day-${dateKey}`,\n\t\tdata: dropData,\n\t});\n\n\tconst isToday = dateKey === toDateKey(new Date());\n\n\treturn (\n\t\t<div\n\t\t\tref={setNodeRef}\n\t\t\tonClick={(event) => {\n\t\t\t\tif (\n\t\t\t\t\t(event.target as HTMLElement | null)?.closest(\n\t\t\t\t\t\t\"[data-quick-create]\",\n\t\t\t\t\t)\n\t\t\t\t) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tonSelectDay(day.date, event.currentTarget, day.inCurrentMonth);\n\t\t\t}}\n\t\t\tonKeyDown={(e) => {\n\t\t\t\tif (e.key === \"Enter\" || e.key === \" \") {\n\t\t\t\t\te.preventDefault();\n\t\t\t\t\tif (\n\t\t\t\t\t\t(e.target as HTMLElement | null)?.closest(\n\t\t\t\t\t\t\t\"[data-quick-create]\",\n\t\t\t\t\t\t)\n\t\t\t\t\t) {\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\tonSelectDay(\n\t\t\t\t\t\tday.date,\n\t\t\t\t\t\te.currentTarget as HTMLDivElement,\n\t\t\t\t\t\tday.inCurrentMonth,\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t}}\n\t\t\trole=\"button\"\n\t\t\ttabIndex={0}\n\t\t\tclassName={cn(\n\t\t\t\t\"group relative flex flex-col gap-1 border-r border-b border-border p-1.5 transition-all duration-200 ease-out\",\n\t\t\t\t\"cursor-default focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 focus-visible:ring-offset-1 focus-visible:ring-offset-background\",\n\t\t\t\t\"hover:bg-muted/40 hover:ring-1 hover:ring-primary/20 hover:shadow-[0_10px_24px_-18px_oklch(var(--primary)/0.55)]\",\n\t\t\t\t\"active:scale-[0.99] active:bg-primary/10\",\n\t\t\t\tisOver &&\n\t\t\t\t\t\"bg-primary/10 ring-1 ring-primary/30 shadow-[0_12px_26px_-20px_oklch(var(--primary)/0.6)]\",\n\t\t\t\tday.inCurrentMonth === false && \"opacity-40 bg-muted/20\",\n\t\t\t\tisToday && \"bg-primary/5\",\n\t\t\t\tview === \"month\" ? \"min-h-[120px]\" : \"min-h-[180px]\",\n\t\t\t)}\n\t\t>\n\t\t\t<div className=\"flex items-center justify-between text-xs text-muted-foreground\">\n\t\t\t\t<span\n\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\"inline-flex h-6 w-6 items-center justify-center rounded-full text-sm font-semibold transition-all\",\n\t\t\t\t\t\tisToday && \"bg-primary text-primary-foreground shadow-sm\",\n\t\t\t\t\t\t!isToday &&\n\t\t\t\t\t\t\t\"group-hover:bg-primary/10 group-hover:text-foreground group-hover:shadow-[inset_0_0_0_1px_oklch(var(--primary)/0.25)] group-hover:scale-[1.04]\",\n\t\t\t\t\t)}\n\t\t\t\t>\n\t\t\t\t\t{day.date.getDate()}\n\t\t\t\t</span>\n\t\t\t\t{isToday && (\n\t\t\t\t\t<span className=\"text-[11px] text-primary\">{todayText}</span>\n\t\t\t\t)}\n\t\t\t</div>\n\n\t\t\t<div className=\"flex flex-col gap-1\">\n\t\t\t\t{todos.map((item) => (\n\t\t\t\t\t<DraggableTodo\n\t\t\t\t\t\tkey={`${item.todo.id}-${item.dateKey}`}\n\t\t\t\t\t\tcalendarTodo={item}\n\t\t\t\t\t\tonSelect={onSelectTodo}\n\t\t\t\t\t/>\n\t\t\t\t))}\n\t\t\t</div>\n\t\t\t{renderQuickCreate ? renderQuickCreate(day.date) : null}\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/calendar/components/DraggableTodo.tsx",
    "content": "/**\n * 日历内的可拖拽 Todo 卡片\n * 使用 useDraggable 并传递类型化的 DragData\n */\n\nimport { useDraggable } from \"@dnd-kit/core\";\nimport { useMemo } from \"react\";\nimport { TodoContextMenu } from \"@/components/common/context-menu/TodoContextMenu\";\nimport { type DragData, usePendingUpdate } from \"@/lib/dnd\";\nimport type { Todo } from \"@/lib/types\";\nimport { cn } from \"@/lib/utils\";\nimport type { CalendarTodo } from \"../types\";\nimport { getStatusStyle } from \"../types\";\nimport { formatTimeLabel } from \"../utils\";\n\nexport function DraggableTodo({\n\tcalendarTodo,\n\tonSelect,\n}: {\n\tcalendarTodo: CalendarTodo;\n\tonSelect: (todo: Todo) => void;\n}) {\n\t// 获取正在进行乐观更新的 todo ID\n\tconst pendingTodoId = usePendingUpdate();\n\t// 检查当前 todo 是否正在进行乐观更新\n\tconst isPendingUpdate = pendingTodoId === calendarTodo.todo.id;\n\n\t// 构建类型化的拖拽数据\n\tconst dragData: DragData = useMemo(\n\t\t() => ({\n\t\t\ttype: \"TODO_CARD\" as const,\n\t\t\tpayload: {\n\t\t\t\ttodo: calendarTodo.todo,\n\t\t\t\tsourcePanel: \"calendar\",\n\t\t\t},\n\t\t}),\n\t\t[calendarTodo.todo],\n\t);\n\n\t// 使用带前缀的 id，避免与 TodoList 中的同一 todo 产生 id 冲突\n\t// 这样当在 TodoList 中拖动时，Calendar 中的对应 todo 不会跟着移动\n\tconst { attributes, listeners, setNodeRef, isDragging } = useDraggable({\n\t\tid: `calendar-${calendarTodo.todo.id}`,\n\t\tdata: dragData,\n\t});\n\n\t// 拖拽时或乐观更新期间，隐藏原始元素避免\"弹回\"效果\n\t// DragOverlay 会显示拖拽预览\n\tif (isDragging || isPendingUpdate) {\n\t\treturn (\n\t\t\t<div\n\t\t\t\tref={setNodeRef}\n\t\t\t\tclassName=\"opacity-0 pointer-events-none\"\n\t\t\t\taria-hidden=\"true\"\n\t\t\t>\n\t\t\t\t<p className=\"truncate text-[12px] font-medium leading-tight\">\n\t\t\t\t\t{calendarTodo.todo.name}\n\t\t\t\t</p>\n\t\t\t</div>\n\t\t);\n\t}\n\n\treturn (\n\t\t<TodoContextMenu todoId={calendarTodo.todo.id}>\n\t\t\t<div\n\t\t\t\tref={setNodeRef}\n\t\t\t\t{...attributes}\n\t\t\t\t{...listeners}\n\t\t\t\tonClick={(event) => {\n\t\t\t\t\tevent.stopPropagation();\n\t\t\t\t\tonSelect(calendarTodo.todo);\n\t\t\t\t}}\n\t\t\t\tonKeyDown={(e) => {\n\t\t\t\t\tif (e.key === \"Enter\" || e.key === \" \") {\n\t\t\t\t\t\te.preventDefault();\n\t\t\t\t\t\te.stopPropagation();\n\t\t\t\t\t\tonSelect(calendarTodo.todo);\n\t\t\t\t\t}\n\t\t\t\t}}\n\t\t\t\trole=\"button\"\n\t\t\t\ttabIndex={0}\n\t\t\t\tclassName={cn(\n\t\t\t\t\t\"group relative rounded-md px-2 py-1 text-xs transition-all duration-200 ease-out\",\n\t\t\t\t\t\"cursor-grab active:cursor-grabbing\",\n\t\t\t\t\t\"hover:-translate-y-[1px] hover:ring-1 hover:ring-primary/20 hover:shadow-[0_8px_18px_-12px_oklch(var(--primary)/0.45)]\",\n\t\t\t\t\t\"active:translate-y-0 active:scale-[0.98]\",\n\t\t\t\t\t\"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 focus-visible:ring-offset-1 focus-visible:ring-offset-background\",\n\t\t\t\t\tgetStatusStyle(calendarTodo.todo.status),\n\t\t\t\t)}\n\t\t\t>\n\t\t\t\t<div className=\"flex items-center justify-between gap-2\">\n\t\t\t\t\t<p className=\"truncate text-[12px] font-medium leading-tight transition-colors group-hover:text-foreground\">\n\t\t\t\t\t\t{calendarTodo.todo.name}\n\t\t\t\t\t</p>\n\t\t\t\t\t<span className=\"shrink-0 text-[10px] text-muted-foreground\">\n\t\t\t\t\t\t{formatTimeLabel(calendarTodo.startTime, \"--:--\")}\n\t\t\t\t\t</span>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</TodoContextMenu>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/calendar/components/FloatingTodoCard.tsx",
    "content": "/**\n * Floating (unscheduled) todo card.\n */\n\nimport { useDraggable } from \"@dnd-kit/core\";\nimport { useMemo } from \"react\";\nimport { TodoContextMenu } from \"@/components/common/context-menu/TodoContextMenu\";\nimport { type DragData, usePendingUpdate } from \"@/lib/dnd\";\nimport type { Todo } from \"@/lib/types\";\nimport { cn } from \"@/lib/utils\";\nimport { getStatusStyle } from \"../types\";\n\nexport function FloatingTodoCard({\n\ttodo,\n\tonSelect,\n}: {\n\ttodo: Todo;\n\tonSelect: (todo: Todo) => void;\n}) {\n\tconst pendingTodoId = usePendingUpdate();\n\tconst isPendingUpdate = pendingTodoId === todo.id;\n\n\tconst dragData: DragData = useMemo(\n\t\t() => ({\n\t\t\ttype: \"TODO_CARD\" as const,\n\t\t\tpayload: {\n\t\t\t\ttodo,\n\t\t\t\tsourcePanel: \"calendar\",\n\t\t\t},\n\t\t}),\n\t\t[todo],\n\t);\n\n\tconst { attributes, listeners, setNodeRef, isDragging } = useDraggable({\n\t\tid: `calendar-floating-${todo.id}`,\n\t\tdata: dragData,\n\t});\n\n\tif (isDragging || isPendingUpdate) {\n\t\treturn (\n\t\t\t<div ref={setNodeRef} className=\"opacity-0 pointer-events-none\" />\n\t\t);\n\t}\n\n\treturn (\n\t\t<TodoContextMenu todoId={todo.id}>\n\t\t\t<button\n\t\t\t\tref={setNodeRef}\n\t\t\t\ttype=\"button\"\n\t\t\t\t{...attributes}\n\t\t\t\t{...listeners}\n\t\t\t\tonClick={(event) => {\n\t\t\t\t\tevent.stopPropagation();\n\t\t\t\t\tonSelect(todo);\n\t\t\t\t}}\n\t\t\t\tclassName={cn(\n\t\t\t\t\t\"rounded-lg border border-border/70 px-3 py-2 text-xs font-medium shadow-sm transition\",\n\t\t\t\t\t\"cursor-grab active:cursor-grabbing\",\n\t\t\t\t\t\"hover:-translate-y-[1px] hover:ring-1 hover:ring-primary/20\",\n\t\t\t\t\tgetStatusStyle(todo.status),\n\t\t\t\t)}\n\t\t\t\tdata-all-day-card\n\t\t\t>\n\t\t\t\t{todo.name}\n\t\t\t</button>\n\t\t</TodoContextMenu>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/calendar/components/QuickCreateBar.tsx",
    "content": "/**\n * 快捷创建 Todo 栏组件\n */\n\nimport { Calendar, Plus, X } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport { cn } from \"@/lib/utils\";\nimport { formatHumanDate } from \"../utils\";\n\nexport function QuickCreateBar({\n\ttargetDate,\n\tvalue,\n\ttime,\n\tonChange,\n\tonTimeChange,\n\tonConfirm,\n\tonCancel,\n}: {\n\ttargetDate: Date | null;\n\tvalue: string;\n\ttime: string;\n\tonChange: (v: string) => void;\n\tonTimeChange: (v: string) => void;\n\tonConfirm: () => void;\n\tonCancel: () => void;\n}) {\n\tconst t = useTranslations(\"calendar\");\n\tif (!targetDate) return null;\n\treturn (\n\t\t<div className=\"fixed bottom-24 left-1/2 z-40 w-full max-w-4xl -translate-x-1/2 px-3\">\n\t\t\t<div className=\"relative flex flex-col gap-3 rounded-2xl border border-border/70 bg-gradient-to-br from-background/95 via-background/90 to-muted/60 p-4 shadow-[0_20px_60px_-30px_oklch(var(--primary)/0.6)] backdrop-blur-xl\">\n\t\t\t\t<div className=\"flex items-center justify-between gap-2\">\n\t\t\t\t\t<div className=\"inline-flex items-center gap-2 rounded-full border border-border/70 bg-background/70 px-3 py-1 text-xs font-medium text-muted-foreground shadow-sm\">\n\t\t\t\t\t\t<Calendar className=\"h-3.5 w-3.5\" />\n\t\t\t\t\t\t<span>\n\t\t\t\t\t\t\t{t(\"createOnDate\", { date: formatHumanDate(targetDate) })}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tonClick={onCancel}\n\t\t\t\t\t\tclassName=\"inline-flex h-8 w-8 items-center justify-center rounded-full border border-border/60 bg-background/70 text-muted-foreground transition hover:bg-muted/70 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40\"\n\t\t\t\t\t\taria-label={t(\"closeCreate\")}\n\t\t\t\t\t>\n\t\t\t\t\t\t<X className=\"h-4 w-4\" />\n\t\t\t\t\t</button>\n\t\t\t\t</div>\n\t\t\t\t<div className=\"flex flex-col gap-3 sm:flex-row sm:items-center\">\n\t\t\t\t\t<input\n\t\t\t\t\t\tvalue={value}\n\t\t\t\t\t\tonChange={(e) => onChange(e.target.value)}\n\t\t\t\t\t\tplaceholder={t(\"inputTodoTitle\")}\n\t\t\t\t\t\tclassName=\"flex-1 rounded-lg border border-border/70 bg-background/80 px-3 py-2 text-sm shadow-sm transition focus:border-primary/60 focus:outline-none focus:ring-2 focus:ring-primary/30 placeholder:text-muted-foreground/60\"\n\t\t\t\t\t/>\n\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\ttype=\"time\"\n\t\t\t\t\t\t\tvalue={time}\n\t\t\t\t\t\t\tonChange={(e) => onTimeChange(e.target.value)}\n\t\t\t\t\t\t\tclassName=\"rounded-lg border border-border/70 bg-background/80 px-2 py-2 text-sm shadow-sm transition focus:border-primary/60 focus:outline-none focus:ring-2 focus:ring-primary/30\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tonClick={onConfirm}\n\t\t\t\t\t\t\tdisabled={!value.trim()}\n\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\"inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow-[0_12px_24px_-16px_oklch(var(--primary)/0.7)] transition-all\",\n\t\t\t\t\t\t\t\t\"hover:-translate-y-[1px] hover:shadow-[0_16px_32px_-18px_oklch(var(--primary)/0.85)]\",\n\t\t\t\t\t\t\t\t\"active:translate-y-0 active:scale-[0.98]\",\n\t\t\t\t\t\t\t\t\"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 focus-visible:ring-offset-1 focus-visible:ring-offset-background\",\n\t\t\t\t\t\t\t\t\"disabled:cursor-not-allowed\",\n\t\t\t\t\t\t\t\t!value.trim() &&\n\t\t\t\t\t\t\t\t\t\"opacity-60 shadow-none hover:translate-y-0 hover:shadow-none\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<Plus className=\"h-4 w-4\" />\n\t\t\t\t\t\t\t{t(\"create\")}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/calendar/components/QuickCreatePopover.tsx",
    "content": "\"use client\";\n\n/**\n * 日历内就地创建 Popover\n */\n\nimport { Calendar, Plus, X } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport { useEffect, useRef } from \"react\";\nimport { ReminderOptions } from \"@/components/common/ReminderOptions\";\nimport { cn } from \"@/lib/utils\";\nimport { formatHumanDate } from \"../utils\";\n\nexport function QuickCreatePopover({\n\ttargetDate,\n\tvalue,\n\ttime,\n\treminderOffsets,\n\tonChange,\n\tonTimeChange,\n\tonReminderChange,\n\tonConfirm,\n\tonCancel,\n}: {\n\ttargetDate: Date | null;\n\tvalue: string;\n\ttime: string;\n\treminderOffsets: number[];\n\tonChange: (v: string) => void;\n\tonTimeChange: (v: string) => void;\n\tonReminderChange: (v: number[]) => void;\n\tonConfirm: () => void;\n\tonCancel: () => void;\n}) {\n\tconst t = useTranslations(\"calendar\");\n\tconst tReminder = useTranslations(\"reminder\");\n\tconst containerRef = useRef<HTMLDivElement>(null);\n\tconst inputRef = useRef<HTMLInputElement>(null);\n\n\tuseEffect(() => {\n\t\tif (targetDate) {\n\t\t\tinputRef.current?.focus();\n\t\t}\n\t}, [targetDate]);\n\n\tuseEffect(() => {\n\t\tif (!targetDate) return;\n\n\t\tconst handlePointerDown = (event: MouseEvent) => {\n\t\t\tif (!containerRef.current) return;\n\t\t\tif (!containerRef.current.contains(event.target as Node)) {\n\t\t\t\tonCancel();\n\t\t\t}\n\t\t};\n\n\t\tconst handleKeyDown = (event: KeyboardEvent) => {\n\t\t\tif (event.key === \"Escape\") {\n\t\t\t\tonCancel();\n\t\t\t}\n\t\t};\n\n\t\tdocument.addEventListener(\"mousedown\", handlePointerDown);\n\t\tdocument.addEventListener(\"keydown\", handleKeyDown);\n\n\t\treturn () => {\n\t\t\tdocument.removeEventListener(\"mousedown\", handlePointerDown);\n\t\t\tdocument.removeEventListener(\"keydown\", handleKeyDown);\n\t\t};\n\t}, [onCancel, targetDate]);\n\n\tif (!targetDate) return null;\n\n\treturn (\n\t\t<div\n\t\t\tref={containerRef}\n\t\t\tdata-quick-create\n\t\t\tonPointerDown={(event) => event.stopPropagation()}\n\t\t\tonMouseDown={(event) => event.stopPropagation()}\n\t\t\tonClick={(event) => event.stopPropagation()}\n\t\t\tonKeyDown={(event) => {\n\t\t\t\tif (event.key === \"Enter\" || event.key === \" \") {\n\t\t\t\t\tevent.stopPropagation();\n\t\t\t\t}\n\t\t\t}}\n\t\t\trole=\"button\"\n\t\t\ttabIndex={0}\n\t\t\tclassName={cn(\n\t\t\t\t\"relative flex flex-col gap-3 rounded-2xl border border-border/70 bg-gradient-to-br from-background/95 via-background/90 to-muted/60 p-4 shadow-[0_20px_60px_-30px_oklch(var(--primary)/0.6)] backdrop-blur-xl\",\n\t\t\t\t\"before:pointer-events-none before:absolute before:inset-x-4 before:top-0 before:h-px before:bg-gradient-to-r before:from-transparent before:via-primary/60 before:to-transparent before:content-['']\",\n\t\t\t)}\n\t\t>\n\t\t\t<div className=\"flex items-center justify-between gap-2\">\n\t\t\t\t<div className=\"flex min-w-0 items-center gap-2 rounded-full border border-border/70 bg-background/70 px-3 py-1 text-xs font-medium text-muted-foreground shadow-sm\">\n\t\t\t\t\t<Calendar className=\"h-3.5 w-3.5\" />\n\t\t\t\t\t<span className=\"min-w-0 truncate\">\n\t\t\t\t\t\t{t(\"createOnDate\", { date: formatHumanDate(targetDate) })}\n\t\t\t\t\t</span>\n\t\t\t\t</div>\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tonPointerDown={(event) => {\n\t\t\t\t\t\tevent.preventDefault();\n\t\t\t\t\t\tevent.stopPropagation();\n\t\t\t\t\t\tonCancel();\n\t\t\t\t\t}}\n\t\t\t\t\tonMouseDown={(event) => {\n\t\t\t\t\t\tevent.preventDefault();\n\t\t\t\t\t\tevent.stopPropagation();\n\t\t\t\t\t}}\n\t\t\t\t\tonClick={(event) => {\n\t\t\t\t\t\tevent.stopPropagation();\n\t\t\t\t\t\tonCancel();\n\t\t\t\t\t}}\n\t\t\t\t\tclassName=\"inline-flex h-8 w-8 items-center justify-center rounded-full border border-border/60 bg-background/70 text-muted-foreground transition hover:bg-muted/70 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40\"\n\t\t\t\t\taria-label={t(\"closeCreate\")}\n\t\t\t\t>\n\t\t\t\t\t<X className=\"h-4 w-4\" />\n\t\t\t\t</button>\n\t\t\t</div>\n\t\t\t<div className=\"flex flex-col gap-2\">\n\t\t\t\t<input\n\t\t\t\t\tref={inputRef}\n\t\t\t\t\tvalue={value}\n\t\t\t\t\tonChange={(e) => onChange(e.target.value)}\n\t\t\t\t\tplaceholder={t(\"inputTodoTitle\")}\n\t\t\t\t\tclassName=\"w-full rounded-lg border border-border/70 bg-background/80 px-3 py-2 text-sm shadow-sm transition focus:border-primary/60 focus:outline-none focus:ring-2 focus:ring-primary/30 placeholder:text-muted-foreground/60\"\n\t\t\t\t/>\n\t\t\t\t<div className=\"flex flex-wrap items-center gap-2\">\n\t\t\t\t\t<input\n\t\t\t\t\t\ttype=\"time\"\n\t\t\t\t\t\tvalue={time}\n\t\t\t\t\t\tonChange={(e) => onTimeChange(e.target.value)}\n\t\t\t\t\t\tclassName=\"w-24 rounded-lg border border-border/70 bg-background/80 px-2 py-2 text-sm shadow-sm transition focus:border-primary/60 focus:outline-none focus:ring-2 focus:ring-primary/30\"\n\t\t\t\t\t/>\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tonClick={onConfirm}\n\t\t\t\t\t\tdisabled={!value.trim()}\n\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\"inline-flex flex-1 items-center justify-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow-[0_12px_24px_-16px_oklch(var(--primary)/0.7)] transition-all\",\n\t\t\t\t\t\t\t\"hover:-translate-y-[1px] hover:shadow-[0_16px_32px_-18px_oklch(var(--primary)/0.85)]\",\n\t\t\t\t\t\t\t\"active:translate-y-0 active:scale-[0.98]\",\n\t\t\t\t\t\t\t\"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 focus-visible:ring-offset-1 focus-visible:ring-offset-background\",\n\t\t\t\t\t\t\t\"disabled:cursor-not-allowed\",\n\t\t\t\t\t\t\t!value.trim() &&\n\t\t\t\t\t\t\t\t\"opacity-60 shadow-none hover:translate-y-0 hover:shadow-none\",\n\t\t\t\t\t\t)}\n\t\t\t\t\t>\n\t\t\t\t\t\t<Plus className=\"h-4 w-4\" />\n\t\t\t\t\t\t{t(\"create\")}\n\t\t\t\t\t</button>\n\t\t\t\t</div>\n\t\t\t\t<div className=\"flex items-center justify-between text-xs text-muted-foreground\">\n\t\t\t\t\t<span>{tReminder(\"label\")}</span>\n\t\t\t\t</div>\n\t\t\t\t<div className=\"rounded-lg border border-border/60 bg-background/70 p-2\">\n\t\t\t\t\t<ReminderOptions\n\t\t\t\t\t\tvalue={reminderOffsets}\n\t\t\t\t\t\tonChange={onReminderChange}\n\t\t\t\t\t\tcompact\n\t\t\t\t\t\tshowClear\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/calendar/components/TimelineColumn.tsx",
    "content": "/**\n * Timeline column with slots + items.\n */\n\nimport type React from \"react\";\nimport { useMemo, useRef, useState } from \"react\";\nimport type { Todo } from \"@/lib/types\";\nimport { cn } from \"@/lib/utils\";\nimport type { TimelineItem } from \"../types\";\nimport {\n\tclampMinutes,\n\tDEFAULT_DURATION_MINUTES,\n\tformatMinutesLabel,\n\tformatTimeRangeLabel,\n\tMINUTES_PER_SLOT,\n} from \"../utils\";\nimport { TimelineSlot } from \"./TimelineSlot\";\nimport { TimelineTodoCard } from \"./TimelineTodoCard\";\n\nconst MIN_ITEM_HEIGHT = 24;\nconst DEADLINE_HEIGHT = 20;\nconst MAX_TIMELINE_MINUTES = 24 * 60 - MINUTES_PER_SLOT;\n\ninterface ResizePreview {\n\tid: number;\n\tstartMinutes: number;\n\tendMinutes: number;\n}\n\nexport function TimelineColumn({\n\tdate,\n\titems,\n\tdisplayStart,\n\tslotMinutes,\n\tslotHeight,\n\tpxPerMinute,\n\tpreview,\n\tonSelect,\n\tonResize,\n\tonSlotPointerDown,\n\tclassName,\n}: {\n\tdate: Date;\n\titems: TimelineItem[];\n\tdisplayStart: number;\n\tslotMinutes: number[];\n\tslotHeight: number;\n\tpxPerMinute: number;\n\tpreview?: {\n\t\tstartMinutes: number;\n\t\tendMinutes: number;\n\t\ttimeLabel: string;\n\t\ttitle: string;\n\t};\n\tonSelect: (todo: Todo) => void;\n\tonResize: (todo: Todo, startMinutes: number, endMinutes: number, date: Date) => void;\n\tonSlotPointerDown?: (args: {\n\t\tdate: Date;\n\t\tminutes: number;\n\t\tanchorRect: DOMRect;\n\t\tclientX: number;\n\t\tclientY: number;\n\t}) => void;\n\tclassName?: string;\n}) {\n\tconst containerRef = useRef<HTMLDivElement | null>(null);\n\tconst [resizePreview, setResizePreview] = useState<ResizePreview | null>(null);\n\tconst previewRef = useRef<ResizePreview | null>(null);\n\n\tconst normalizedItems = useMemo(() => {\n\t\treturn items.map((item) => {\n\t\t\tconst preview =\n\t\t\t\tresizePreview && resizePreview.id === item.todo.id\n\t\t\t\t\t? resizePreview\n\t\t\t\t\t: null;\n\t\t\tconst startMinutes = preview?.startMinutes ?? item.startMinutes;\n\t\t\tconst endMinutes = preview?.endMinutes ?? item.endMinutes;\n\t\t\tconst top = (startMinutes - displayStart) * pxPerMinute;\n\t\t\tconst rawHeight = (endMinutes - startMinutes) * pxPerMinute;\n\t\t\tconst height =\n\t\t\t\titem.kind === \"range\"\n\t\t\t\t\t? Math.max(MIN_ITEM_HEIGHT, rawHeight)\n\t\t\t\t\t: Math.max(DEADLINE_HEIGHT, slotHeight * 1.5);\n\t\t\tconst timeLabel =\n\t\t\t\titem.kind === \"range\"\n\t\t\t\t\t? formatTimeRangeLabel(startMinutes, endMinutes)\n\t\t\t\t\t: formatMinutesLabel(startMinutes);\n\n\t\t\treturn {\n\t\t\t\t...item,\n\t\t\t\tstartMinutes,\n\t\t\t\tendMinutes,\n\t\t\t\ttop,\n\t\t\t\theight,\n\t\t\t\ttimeLabel,\n\t\t\t};\n\t\t});\n\t}, [\n\t\tdisplayStart,\n\t\titems,\n\t\tpxPerMinute,\n\t\tresizePreview,\n\t\tslotHeight,\n\t]);\n\n\tconst previewItem = useMemo(() => {\n\t\tif (!preview) return null;\n\t\tconst startMinutes = preview.startMinutes;\n\t\tconst endMinutes =\n\t\t\tpreview.endMinutes > preview.startMinutes\n\t\t\t\t? preview.endMinutes\n\t\t\t\t: preview.startMinutes + DEFAULT_DURATION_MINUTES;\n\t\tconst top = (startMinutes - displayStart) * pxPerMinute;\n\t\tconst rawHeight = (endMinutes - startMinutes) * pxPerMinute;\n\t\tconst height = Math.max(MIN_ITEM_HEIGHT, rawHeight);\n\t\treturn {\n\t\t\t...preview,\n\t\t\tstartMinutes,\n\t\t\tendMinutes,\n\t\t\ttop,\n\t\t\theight,\n\t\t};\n\t}, [displayStart, preview, pxPerMinute]);\n\n\tconst startResize = (\n\t\titem: TimelineItem,\n\t\tedge: \"start\" | \"end\",\n\t\tevent: React.PointerEvent<HTMLButtonElement>,\n\t) => {\n\t\tevent.preventDefault();\n\t\tevent.stopPropagation();\n\t\tconst initial = {\n\t\t\tid: item.todo.id,\n\t\t\tstartMinutes: item.startMinutes,\n\t\t\tendMinutes:\n\t\t\t\titem.endMinutes > item.startMinutes\n\t\t\t\t\t? item.endMinutes\n\t\t\t\t\t: item.startMinutes + DEFAULT_DURATION_MINUTES,\n\t\t};\n\t\tsetResizePreview(initial);\n\t\tpreviewRef.current = initial;\n\n\t\tconst handleMove = (moveEvent: PointerEvent) => {\n\t\t\tconst rect = containerRef.current?.getBoundingClientRect();\n\t\t\tif (!rect) return;\n\t\t\tconst offset = moveEvent.clientY - rect.top;\n\t\t\tconst minutes = clampMinutes(\n\t\t\t\tdisplayStart +\n\t\t\t\t\tMath.round((offset / pxPerMinute) / MINUTES_PER_SLOT) *\n\t\t\t\t\t\tMINUTES_PER_SLOT,\n\t\t\t\t0,\n\t\t\t\t24 * 60,\n\t\t\t);\n\t\t\tsetResizePreview((prev) => {\n\t\t\t\tif (!prev || prev.id !== item.todo.id) return prev;\n\t\t\t\tconst next =\n\t\t\t\t\tedge === \"start\"\n\t\t\t\t\t\t? {\n\t\t\t\t\t\t\t\t...prev,\n\t\t\t\t\t\t\t\tstartMinutes: Math.min(minutes, prev.endMinutes - MINUTES_PER_SLOT),\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t: {\n\t\t\t\t\t\t\t\t...prev,\n\t\t\t\t\t\t\t\tendMinutes: Math.max(minutes, prev.startMinutes + MINUTES_PER_SLOT),\n\t\t\t\t\t\t\t};\n\t\t\t\tpreviewRef.current = next;\n\t\t\t\treturn next;\n\t\t\t});\n\t\t};\n\n\t\tconst handleUp = () => {\n\t\t\tconst preview = previewRef.current;\n\t\t\tif (preview && preview.id === item.todo.id) {\n\t\t\t\tonResize(item.todo, preview.startMinutes, preview.endMinutes, date);\n\t\t\t}\n\t\t\tsetResizePreview(null);\n\t\t\tpreviewRef.current = null;\n\t\t\twindow.removeEventListener(\"pointermove\", handleMove);\n\t\t\twindow.removeEventListener(\"pointerup\", handleUp);\n\t\t};\n\n\t\twindow.addEventListener(\"pointermove\", handleMove);\n\t\twindow.addEventListener(\"pointerup\", handleUp);\n\t};\n\n\tconst triggerSlotCreate = (\n\t\tanchorRect: DOMRect,\n\t\tclientX: number,\n\t\tclientY: number,\n\t) => {\n\t\tif (!onSlotPointerDown) return;\n\t\tconst minutes = clampMinutes(\n\t\t\tdisplayStart +\n\t\t\t\tMath.round((clientY - anchorRect.top) / pxPerMinute / MINUTES_PER_SLOT) *\n\t\t\t\t\tMINUTES_PER_SLOT,\n\t\t\t0,\n\t\t\tMAX_TIMELINE_MINUTES,\n\t\t);\n\t\tonSlotPointerDown({\n\t\t\tdate,\n\t\t\tminutes,\n\t\t\tanchorRect,\n\t\t\tclientX,\n\t\t\tclientY,\n\t\t});\n\t};\n\n\treturn (\n\t\t<div\n\t\t\tref={containerRef}\n\t\t\tclassName={cn(\"relative h-full w-full\", className)}\n\t\t\tonClick={(event) => {\n\t\t\t\tif (\n\t\t\t\t\t(event.target as HTMLElement | null)?.closest(\"[data-timeline-item]\")\n\t\t\t\t) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst rect = (event.currentTarget as HTMLDivElement).getBoundingClientRect();\n\t\t\t\ttriggerSlotCreate(rect, event.clientX, event.clientY);\n\t\t\t}}\n\t\t\tonKeyDown={(event) => {\n\t\t\t\tif (event.key !== \"Enter\" && event.key !== \" \") return;\n\t\t\t\tevent.preventDefault();\n\t\t\t\tconst rect = (event.currentTarget as HTMLDivElement).getBoundingClientRect();\n\t\t\t\tconst midX = rect.left + rect.width / 2;\n\t\t\t\tconst midY = rect.top + rect.height / 2;\n\t\t\t\ttriggerSlotCreate(rect, midX, midY);\n\t\t\t}}\n\t\t\trole=\"button\"\n\t\t\ttabIndex={0}\n\t\t>\n\t\t\t<div className=\"absolute inset-0\">\n\t\t\t\t{slotMinutes.map((minutes) => (\n\t\t\t\t\t<TimelineSlot\n\t\t\t\t\t\tkey={`${date.toDateString()}-${minutes}`}\n\t\t\t\t\t\tdate={date}\n\t\t\t\t\t\tminutes={minutes}\n\t\t\t\t\t\theight={slotHeight}\n\t\t\t\t\t\tisHour={minutes % 60 === 0}\n\t\t\t\t\t/>\n\t\t\t\t))}\n\t\t\t</div>\n\t\t\t<div className=\"absolute inset-0\">\n\t\t\t\t{normalizedItems.map((item) => (\n\t\t\t\t\t<TimelineTodoCard\n\t\t\t\t\t\tkey={item.todo.id}\n\t\t\t\t\t\ttodo={item.todo}\n\t\t\t\t\t\ttop={item.top}\n\t\t\t\t\t\theight={item.height}\n\t\t\t\t\t\ttimeLabel={item.timeLabel}\n\t\t\t\t\t\tvariant={item.kind}\n\t\t\t\t\t\tonSelect={onSelect}\n\t\t\t\t\t\tonResizeStart={\n\t\t\t\t\t\t\titem.kind === \"range\"\n\t\t\t\t\t\t\t\t? (event) => startResize(item, \"start\", event)\n\t\t\t\t\t\t\t\t: undefined\n\t\t\t\t\t\t}\n\t\t\t\t\t\tonResizeEnd={\n\t\t\t\t\t\t\titem.kind === \"range\"\n\t\t\t\t\t\t\t\t? (event) => startResize(item, \"end\", event)\n\t\t\t\t\t\t\t\t: undefined\n\t\t\t\t\t\t}\n\t\t\t\t\t/>\n\t\t\t\t))}\n\t\t\t\t{previewItem && (\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\"pointer-events-none absolute left-2 right-2 flex flex-col gap-1 rounded-lg border border-dashed border-primary/60 bg-primary/10 px-2 py-1 text-xs text-primary shadow-sm ring-2 ring-primary/30\",\n\t\t\t\t\t\t\t\"shadow-[0_10px_20px_-18px_oklch(var(--primary)/0.7)]\",\n\t\t\t\t\t\t)}\n\t\t\t\t\t\tstyle={{ top: previewItem.top, height: previewItem.height }}\n\t\t\t\t\t>\n\t\t\t\t\t\t<div className=\"flex flex-col gap-0.5\">\n\t\t\t\t\t\t\t<p className=\"truncate text-[12px] font-semibold\">\n\t\t\t\t\t\t\t\t{previewItem.title}\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t<span className=\"text-[11px] text-primary/80\">\n\t\t\t\t\t\t\t\t{previewItem.timeLabel}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/calendar/components/TimelineCreatePopover.tsx",
    "content": "\"use client\";\n\n/**\n * Timeline quick create popover.\n */\n\nimport { Calendar, Plus, X } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport { useEffect, useRef } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { cn } from \"@/lib/utils\";\nimport { formatHumanDate } from \"../utils\";\n\nexport function TimelineCreatePopover({\n\ttargetDate,\n\tvalue,\n\tstartTime,\n\tendTime,\n\tshowTimeFields = true,\n\tanchorPoint,\n\tonChange,\n\tonStartTimeChange,\n\tonEndTimeChange,\n\tonConfirm,\n\tonCancel,\n}: {\n\ttargetDate: Date | null;\n\tvalue: string;\n\tstartTime: string;\n\tendTime: string;\n\tshowTimeFields?: boolean;\n\tanchorPoint: { top: number; left: number } | null;\n\tonChange: (v: string) => void;\n\tonStartTimeChange: (v: string) => void;\n\tonEndTimeChange: (v: string) => void;\n\tonConfirm: () => void;\n\tonCancel: () => void;\n}) {\n\tconst t = useTranslations(\"calendar\");\n\tconst containerRef = useRef<HTMLDivElement>(null);\n\tconst inputRef = useRef<HTMLInputElement>(null);\n\n\tuseEffect(() => {\n\t\tif (targetDate) {\n\t\t\tinputRef.current?.focus();\n\t\t}\n\t}, [targetDate]);\n\n\tuseEffect(() => {\n\t\tif (!targetDate) return;\n\n\t\tconst handlePointerDown = (event: MouseEvent) => {\n\t\t\tif (!containerRef.current) return;\n\t\t\tif (!containerRef.current.contains(event.target as Node)) {\n\t\t\t\tonCancel();\n\t\t\t}\n\t\t};\n\n\t\tconst handleKeyDown = (event: KeyboardEvent) => {\n\t\t\tif (event.key === \"Escape\") {\n\t\t\t\tonCancel();\n\t\t\t}\n\t\t};\n\n\t\tdocument.addEventListener(\"mousedown\", handlePointerDown);\n\t\tdocument.addEventListener(\"keydown\", handleKeyDown);\n\n\t\treturn () => {\n\t\t\tdocument.removeEventListener(\"mousedown\", handlePointerDown);\n\t\t\tdocument.removeEventListener(\"keydown\", handleKeyDown);\n\t\t};\n\t}, [onCancel, targetDate]);\n\n\tif (!targetDate || !anchorPoint) return null;\n\n\treturn createPortal(\n\t\t<>\n\t\t\t<div\n\t\t\t\tclassName=\"fixed inset-0 z-40\"\n\t\t\t\taria-hidden\n\t\t\t\tonPointerDown={(event) => {\n\t\t\t\t\tevent.preventDefault();\n\t\t\t\t\tevent.stopPropagation();\n\t\t\t\t\tonCancel();\n\t\t\t\t}}\n\t\t\t/>\n\t\t\t<div\n\t\t\t\tref={containerRef}\n\t\t\t\tdata-quick-create\n\t\t\t\tonPointerDown={(event) => event.stopPropagation()}\n\t\t\t\tonMouseDown={(event) => event.stopPropagation()}\n\t\t\t\tonClick={(event) => event.stopPropagation()}\n\t\t\t\tonKeyDown={(event) => {\n\t\t\t\t\tif (event.key === \"Enter\" || event.key === \" \") {\n\t\t\t\t\t\tevent.stopPropagation();\n\t\t\t\t\t}\n\t\t\t\t}}\n\t\t\t\trole=\"button\"\n\t\t\t\ttabIndex={0}\n\t\t\t\tclassName=\"fixed z-[9999] w-80 max-w-[92vw] pointer-events-auto\"\n\t\t\t\tstyle={{ top: anchorPoint.top, left: anchorPoint.left }}\n\t\t\t>\n\t\t\t\t<div\n\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\"relative flex flex-col gap-3 rounded-2xl border border-border/70 bg-gradient-to-br from-background/95 via-background/90 to-muted/60 p-4 shadow-[0_20px_60px_-30px_oklch(var(--primary)/0.6)] backdrop-blur-xl\",\n\t\t\t\t\t\t\"before:pointer-events-none before:absolute before:inset-x-4 before:top-0 before:h-px before:bg-gradient-to-r before:from-transparent before:via-primary/60 before:to-transparent before:content-['']\",\n\t\t\t\t\t)}\n\t\t\t\t>\n\t\t\t\t\t<div className=\"flex items-center justify-between gap-2\">\n\t\t\t\t\t\t<div className=\"flex min-w-0 items-center gap-2 rounded-full border border-border/70 bg-background/70 px-3 py-1 text-xs font-medium text-muted-foreground shadow-sm\">\n\t\t\t\t\t\t\t<Calendar className=\"h-3.5 w-3.5\" />\n\t\t\t\t\t\t\t<span className=\"min-w-0 truncate\">\n\t\t\t\t\t\t\t\t{t(\"createOnDate\", { date: formatHumanDate(targetDate) })}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tonPointerDown={(event) => {\n\t\t\t\t\t\t\t\tevent.preventDefault();\n\t\t\t\t\t\t\t\tevent.stopPropagation();\n\t\t\t\t\t\t\t\tonCancel();\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tonMouseDown={(event) => {\n\t\t\t\t\t\t\t\tevent.preventDefault();\n\t\t\t\t\t\t\t\tevent.stopPropagation();\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tonClick={(event) => {\n\t\t\t\t\t\t\t\tevent.stopPropagation();\n\t\t\t\t\t\t\t\tonCancel();\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tclassName=\"inline-flex h-8 w-8 items-center justify-center rounded-full border border-border/60 bg-background/70 text-muted-foreground transition hover:bg-muted/70 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40\"\n\t\t\t\t\t\t\taria-label={t(\"closeCreate\")}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<X className=\"h-4 w-4\" />\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"flex flex-col gap-2\">\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\tref={inputRef}\n\t\t\t\t\t\t\tvalue={value}\n\t\t\t\t\t\t\tonChange={(event) => onChange(event.target.value)}\n\t\t\t\t\t\t\tplaceholder={t(\"inputTodoTitle\")}\n\t\t\t\t\t\t\tclassName=\"w-full rounded-lg border border-border/70 bg-background/80 px-3 py-2 text-sm shadow-sm transition focus:border-primary/60 focus:outline-none focus:ring-2 focus:ring-primary/30 placeholder:text-muted-foreground/60\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t{showTimeFields && (\n\t\t\t\t\t\t\t<div className=\"flex flex-wrap items-center gap-2 text-xs text-muted-foreground\">\n\t\t\t\t\t\t\t\t<label className=\"flex items-center gap-2\">\n\t\t\t\t\t\t\t\t\t<span className=\"min-w-[48px]\">{t(\"startTime\")}</span>\n\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\ttype=\"time\"\n\t\t\t\t\t\t\t\t\t\tstep={900}\n\t\t\t\t\t\t\t\t\t\tvalue={startTime}\n\t\t\t\t\t\t\t\t\t\tonChange={(event) => onStartTimeChange(event.target.value)}\n\t\t\t\t\t\t\t\t\t\tclassName=\"w-24 rounded-lg border border-border/70 bg-background/80 px-2 py-2 text-sm shadow-sm transition focus:border-primary/60 focus:outline-none focus:ring-2 focus:ring-primary/30\"\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t<label className=\"flex items-center gap-2\">\n\t\t\t\t\t\t\t\t\t<span className=\"min-w-[48px]\">{t(\"endTime\")}</span>\n\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\ttype=\"time\"\n\t\t\t\t\t\t\t\t\t\tstep={900}\n\t\t\t\t\t\t\t\t\t\tvalue={endTime}\n\t\t\t\t\t\t\t\t\t\tonChange={(event) => onEndTimeChange(event.target.value)}\n\t\t\t\t\t\t\t\t\t\tclassName=\"w-24 rounded-lg border border-border/70 bg-background/80 px-2 py-2 text-sm shadow-sm transition focus:border-primary/60 focus:outline-none focus:ring-2 focus:ring-primary/30\"\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tonClick={onConfirm}\n\t\t\t\t\t\t\tdisabled={!value.trim()}\n\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\"inline-flex items-center justify-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow-[0_12px_24px_-16px_oklch(var(--primary)/0.7)] transition-all\",\n\t\t\t\t\t\t\t\t\"hover:-translate-y-[1px] hover:shadow-[0_16px_32px_-18px_oklch(var(--primary)/0.85)]\",\n\t\t\t\t\t\t\t\t\"active:translate-y-0 active:scale-[0.98]\",\n\t\t\t\t\t\t\t\t\"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 focus-visible:ring-offset-1 focus-visible:ring-offset-background\",\n\t\t\t\t\t\t\t\t\"disabled:cursor-not-allowed\",\n\t\t\t\t\t\t\t\t!value.trim() &&\n\t\t\t\t\t\t\t\t\t\"opacity-60 shadow-none hover:translate-y-0 hover:shadow-none\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<Plus className=\"h-4 w-4\" />\n\t\t\t\t\t\t\t{t(\"create\")}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</>,\n\t\tdocument.body,\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/calendar/components/TimelineSlot.tsx",
    "content": "/**\n * Timeline droppable slot (15 min).\n */\n\nimport { useDroppable } from \"@dnd-kit/core\";\nimport { useMemo } from \"react\";\nimport type { DropData } from \"@/lib/dnd\";\nimport { cn } from \"@/lib/utils\";\nimport { toDateKey } from \"../utils\";\n\nexport function TimelineSlot({\n\tdate,\n\tminutes,\n\theight,\n\tisHour,\n}: {\n\tdate: Date;\n\tminutes: number;\n\theight: number;\n\tisHour: boolean;\n}) {\n\tconst dateKey = toDateKey(date);\n\tconst dropData: DropData = useMemo(\n\t\t() => ({\n\t\t\ttype: \"CALENDAR_TIMELINE_SLOT\" as const,\n\t\t\tmetadata: {\n\t\t\t\tdate,\n\t\t\t\tdateKey,\n\t\t\t\tminutes,\n\t\t\t},\n\t\t}),\n\t\t[date, dateKey, minutes],\n\t);\n\n\tconst { isOver, setNodeRef } = useDroppable({\n\t\tid: `timeline-${dateKey}-${minutes}`,\n\t\tdata: dropData,\n\t});\n\n\treturn (\n\t\t<div\n\t\t\tref={setNodeRef}\n\t\t\tclassName={cn(\n\t\t\t\t\"relative w-full\",\n\t\t\t\tisHour ? \"border-t border-border/60\" : \"border-t border-transparent\",\n\t\t\t\tisOver && \"bg-primary/10\",\n\t\t\t)}\n\t\t\tstyle={{ height }}\n\t\t/>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/calendar/components/TimelineTodoCard.tsx",
    "content": "/**\n * Draggable timeline todo card.\n */\n\nimport { useDraggable } from \"@dnd-kit/core\";\nimport type React from \"react\";\nimport { useMemo } from \"react\";\nimport { TodoContextMenu } from \"@/components/common/context-menu/TodoContextMenu\";\nimport { type DragData, usePendingUpdate } from \"@/lib/dnd\";\nimport type { Todo } from \"@/lib/types\";\nimport { cn } from \"@/lib/utils\";\nimport { getStatusStyle } from \"../types\";\n\nexport function TimelineTodoCard({\n\ttodo,\n\ttop,\n\theight,\n\ttimeLabel,\n\tvariant,\n\tonSelect,\n\tonResizeStart,\n\tonResizeEnd,\n}: {\n\ttodo: Todo;\n\ttop: number;\n\theight: number;\n\ttimeLabel: string;\n\tvariant: \"deadline\" | \"range\";\n\tonSelect: (todo: Todo) => void;\n\tonResizeStart?: (event: React.PointerEvent<HTMLButtonElement>) => void;\n\tonResizeEnd?: (event: React.PointerEvent<HTMLButtonElement>) => void;\n}) {\n\tconst pendingTodoId = usePendingUpdate();\n\tconst isPendingUpdate = pendingTodoId === todo.id;\n\n\tconst dragData: DragData = useMemo(\n\t\t() => ({\n\t\t\ttype: \"TODO_CARD\" as const,\n\t\t\tpayload: {\n\t\t\t\ttodo,\n\t\t\t\tsourcePanel: \"calendar\",\n\t\t\t},\n\t\t}),\n\t\t[todo],\n\t);\n\n\tconst { attributes, listeners, setNodeRef, isDragging } = useDraggable({\n\t\tid: `calendar-${todo.id}`,\n\t\tdata: dragData,\n\t});\n\n\tif (isDragging || isPendingUpdate) {\n\t\treturn (\n\t\t\t<div\n\t\t\t\tref={setNodeRef}\n\t\t\t\tclassName=\"absolute left-2 right-2 opacity-0 pointer-events-none\"\n\t\t\t\tstyle={{ top, height }}\n\t\t\t\taria-hidden=\"true\"\n\t\t\t/>\n\t\t);\n\t}\n\n\treturn (\n\t\t<TodoContextMenu todoId={todo.id}>\n\t\t\t<div\n\t\t\t\tref={setNodeRef}\n\t\t\t\t{...attributes}\n\t\t\t\t{...listeners}\n\t\t\t\tonClick={(event) => {\n\t\t\t\t\tevent.stopPropagation();\n\t\t\t\t\tonSelect(todo);\n\t\t\t\t}}\n\t\t\t\tonKeyDown={(event) => {\n\t\t\t\t\tif (event.key === \"Enter\" || event.key === \" \") {\n\t\t\t\t\t\tevent.preventDefault();\n\t\t\t\t\t\tevent.stopPropagation();\n\t\t\t\t\t\tonSelect(todo);\n\t\t\t\t\t}\n\t\t\t\t}}\n\t\t\t\trole=\"button\"\n\t\t\t\ttabIndex={0}\n\t\t\t\tdata-timeline-item\n\t\t\t\tclassName={cn(\n\t\t\t\t\t\"group absolute left-2 right-2 flex flex-col gap-1 rounded-lg border px-2 py-1 text-xs shadow-sm transition-all duration-200 ease-out\",\n\t\t\t\t\t\"cursor-grab active:cursor-grabbing\",\n\t\t\t\t\t\"hover:-translate-y-[1px] hover:ring-1 hover:ring-primary/20 hover:shadow-[0_10px_22px_-16px_oklch(var(--primary)/0.45)]\",\n\t\t\t\t\t\"active:translate-y-0 active:scale-[0.99]\",\n\t\t\t\t\t\"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 focus-visible:ring-offset-1 focus-visible:ring-offset-background\",\n\t\t\t\t\tgetStatusStyle(todo.status),\n\t\t\t\t)}\n\t\t\t\tstyle={{ top, height }}\n\t\t\t>\n\t\t\t\t{variant === \"range\" && (onResizeStart || onResizeEnd) && (\n\t\t\t\t\t<div className=\"absolute inset-x-0 -top-1 flex justify-center\">\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tonPointerDown={(event) => {\n\t\t\t\t\t\t\t\tevent.stopPropagation();\n\t\t\t\t\t\t\t\tonResizeStart?.(event);\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\"h-2 w-10 rounded-full bg-foreground/40 opacity-0 transition-opacity group-hover:opacity-100\",\n\t\t\t\t\t\t\t\t\"focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\taria-label=\"Resize start\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t\t<div className=\"flex flex-col gap-0.5\">\n\t\t\t\t\t<p className=\"truncate text-[12px] font-semibold\">{todo.name}</p>\n\t\t\t\t\t<span className=\"text-[11px] text-muted-foreground\">\n\t\t\t\t\t\t{timeLabel}\n\t\t\t\t\t</span>\n\t\t\t\t</div>\n\t\t\t\t{todo.tags && todo.tags.length > 0 && (\n\t\t\t\t\t<div className=\"flex flex-wrap gap-1\">\n\t\t\t\t\t\t{todo.tags.slice(0, 2).map((tag) => (\n\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\tkey={tag}\n\t\t\t\t\t\t\t\tclassName=\"rounded-full bg-white/50 px-2 py-0.5 text-[10px] text-muted-foreground\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{tag}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t))}\n\t\t\t\t\t\t{todo.tags.length > 2 && (\n\t\t\t\t\t\t\t<span className=\"text-[10px] text-muted-foreground\">\n\t\t\t\t\t\t\t\t+{todo.tags.length - 2}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t\t{variant === \"range\" && (onResizeStart || onResizeEnd) && (\n\t\t\t\t\t<div className=\"absolute inset-x-0 -bottom-1 flex justify-center\">\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tonPointerDown={(event) => {\n\t\t\t\t\t\t\t\tevent.stopPropagation();\n\t\t\t\t\t\t\t\tonResizeEnd?.(event);\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\"h-2 w-10 rounded-full bg-foreground/40 opacity-0 transition-opacity group-hover:opacity-100\",\n\t\t\t\t\t\t\t\t\"focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\taria-label=\"Resize end\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t</TodoContextMenu>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/calendar/hooks/useMonthScroll.ts",
    "content": "import { useCallback, useEffect, useRef, useState } from \"react\";\nimport type { CalendarView } from \"../types\";\nimport { addMonths, startOfMonth } from \"../utils\";\n\ntype ScrollDirection = \"prev\" | \"next\";\n\nfunction getMonthKey(date: Date) {\n\treturn `${date.getFullYear()}-${date.getMonth()}`;\n}\n\nexport function useMonthScroll({\n\tcurrentDate,\n\tview,\n}: {\n\tcurrentDate: Date;\n\tview: CalendarView;\n}) {\n\tconst [monthItems, setMonthItems] = useState<Date[]>(() => {\n\t\tconst base = startOfMonth(new Date());\n\t\treturn [\n\t\t\taddMonths(base, -2),\n\t\t\taddMonths(base, -1),\n\t\t\tbase,\n\t\t\taddMonths(base, 1),\n\t\t\taddMonths(base, 2),\n\t\t];\n\t});\n\tconst monthScrollRef = useRef<HTMLDivElement>(null);\n\tconst pendingScrollAdjust = useRef<{\n\t\tprevHeight: number;\n\t\tprevScrollTop: number;\n\t} | null>(null);\n\tconst pendingScrollToMonth = useRef<Date | null>(null);\n\tconst manualScrollTargetKey = useRef<string | null>(null);\n\tconst pendingScrollRaf = useRef<number | null>(null);\n\tconst manualScrollReleaseTimer = useRef<number | null>(null);\n\n\tconst attemptScrollToPending = useCallback(() => {\n\t\tconst target = pendingScrollToMonth.current;\n\t\tif (!target) return false;\n\t\tconst key = getMonthKey(target);\n\t\tconst el = document.querySelector(`[data-month-key=\"${key}\"]`);\n\t\tif (!el) return false;\n\t\t(el as HTMLElement).scrollIntoView({\n\t\t\tblock: \"start\",\n\t\t\tbehavior: \"smooth\",\n\t\t});\n\t\tpendingScrollToMonth.current = null;\n\t\treturn true;\n\t}, []);\n\n\tconst scheduleScrollToPending = useCallback(() => {\n\t\tif (pendingScrollRaf.current) {\n\t\t\tcancelAnimationFrame(pendingScrollRaf.current);\n\t\t}\n\t\tlet retries = 30;\n\t\tconst tick = () => {\n\t\t\tif (attemptScrollToPending()) return;\n\t\t\tif (retries <= 0) {\n\t\t\t\tmanualScrollTargetKey.current = null;\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tretries -= 1;\n\t\t\tpendingScrollRaf.current = requestAnimationFrame(tick);\n\t\t};\n\t\ttick();\n\t}, [attemptScrollToPending]);\n\n\tuseEffect(() => {\n\t\tif (view !== \"month\") return;\n\t\tsetMonthItems((prev) => {\n\t\t\tconst target = startOfMonth(currentDate);\n\t\t\tif (prev.length === 0) {\n\t\t\t\treturn [\n\t\t\t\t\taddMonths(target, -2),\n\t\t\t\t\taddMonths(target, -1),\n\t\t\t\t\ttarget,\n\t\t\t\t\taddMonths(target, 1),\n\t\t\t\t\taddMonths(target, 2),\n\t\t\t\t];\n\t\t\t}\n\n\t\t\tconst hasTarget = prev.some(\n\t\t\t\t(item) =>\n\t\t\t\t\titem.getFullYear() === target.getFullYear() &&\n\t\t\t\t\titem.getMonth() === target.getMonth(),\n\t\t\t);\n\t\t\tif (hasTarget) return prev;\n\n\t\t\tconst first = prev[0];\n\t\t\tconst last = prev[prev.length - 1];\n\t\t\tconst targetIndex = target.getFullYear() * 12 + target.getMonth();\n\t\t\tconst firstIndex = first.getFullYear() * 12 + first.getMonth();\n\t\t\tconst lastIndex = last.getFullYear() * 12 + last.getMonth();\n\n\t\t\tif (targetIndex < firstIndex) {\n\t\t\t\tconst monthsToAdd: Date[] = [];\n\t\t\t\tlet cursor = startOfMonth(first);\n\t\t\t\twhile (\n\t\t\t\t\tcursor.getFullYear() * 12 + cursor.getMonth() > targetIndex\n\t\t\t\t) {\n\t\t\t\t\tcursor = addMonths(cursor, -1);\n\t\t\t\t\tmonthsToAdd.unshift(cursor);\n\t\t\t\t}\n\t\t\t\treturn [...monthsToAdd, ...prev];\n\t\t\t}\n\n\t\t\tif (targetIndex > lastIndex) {\n\t\t\t\tconst monthsToAdd: Date[] = [];\n\t\t\t\tlet cursor = startOfMonth(last);\n\t\t\t\twhile (\n\t\t\t\t\tcursor.getFullYear() * 12 + cursor.getMonth() < targetIndex\n\t\t\t\t) {\n\t\t\t\t\tcursor = addMonths(cursor, 1);\n\t\t\t\t\tmonthsToAdd.push(cursor);\n\t\t\t\t}\n\t\t\t\treturn [...prev, ...monthsToAdd];\n\t\t\t}\n\n\t\t\treturn prev;\n\t\t});\n\t}, [currentDate, view]);\n\n\tuseEffect(() => {\n\t\tif (monthItems.length === 0) return;\n\t\tif (!pendingScrollAdjust.current) return;\n\t\tconst container = monthScrollRef.current;\n\t\tif (!container) return;\n\t\tconst { prevHeight, prevScrollTop } = pendingScrollAdjust.current;\n\t\tpendingScrollAdjust.current = null;\n\t\trequestAnimationFrame(() => {\n\t\t\tconst nextHeight = container.scrollHeight;\n\t\t\tcontainer.scrollTop = prevScrollTop + (nextHeight - prevHeight);\n\t\t});\n\t}, [monthItems.length]);\n\n\tuseEffect(() => {\n\t\tif (view !== \"month\") return;\n\t\tif (monthItems.length === 0) return;\n\t\tif (!pendingScrollToMonth.current) return;\n\t\tscheduleScrollToPending();\n\t\treturn () => {\n\t\t\tif (pendingScrollRaf.current) {\n\t\t\t\tcancelAnimationFrame(pendingScrollRaf.current);\n\t\t\t}\n\t\t};\n\t}, [monthItems.length, scheduleScrollToPending, view]);\n\n\tconst handleLoadMoreMonths = useCallback(\n\t\t(direction: ScrollDirection) => {\n\t\t\tif (direction === \"prev\" && monthScrollRef.current) {\n\t\t\t\tpendingScrollAdjust.current = {\n\t\t\t\t\tprevHeight: monthScrollRef.current.scrollHeight,\n\t\t\t\t\tprevScrollTop: monthScrollRef.current.scrollTop,\n\t\t\t\t};\n\t\t\t}\n\t\t\tsetMonthItems((prev) => {\n\t\t\t\tif (prev.length === 0) return prev;\n\t\t\t\tif (direction === \"prev\") {\n\t\t\t\t\tconst first = prev[0];\n\t\t\t\t\treturn [addMonths(first, -1), ...prev];\n\t\t\t\t}\n\t\t\t\tconst last = prev[prev.length - 1];\n\t\t\t\treturn [...prev, addMonths(last, 1)];\n\t\t\t});\n\t\t},\n\t\t[],\n\t);\n\n\tconst requestMonthScroll = useCallback(\n\t\t(target: Date) => {\n\t\t\tconst key = getMonthKey(target);\n\t\t\tmanualScrollTargetKey.current = key;\n\t\t\tpendingScrollToMonth.current = target;\n\t\t\tscheduleScrollToPending();\n\t\t\tif (manualScrollReleaseTimer.current) {\n\t\t\t\twindow.clearTimeout(manualScrollReleaseTimer.current);\n\t\t\t}\n\t\t\tmanualScrollReleaseTimer.current = window.setTimeout(() => {\n\t\t\t\tmanualScrollTargetKey.current = null;\n\t\t\t}, 1200);\n\t\t},\n\t\t[scheduleScrollToPending],\n\t);\n\n\tconst shouldIgnoreActiveMonthChange = useCallback((month: Date) => {\n\t\tconst nextKey = getMonthKey(month);\n\t\tif (\n\t\t\tmanualScrollTargetKey.current &&\n\t\t\tmanualScrollTargetKey.current !== nextKey\n\t\t) {\n\t\t\treturn true;\n\t\t}\n\t\tif (manualScrollTargetKey.current === nextKey) {\n\t\t\tmanualScrollTargetKey.current = null;\n\t\t}\n\t\treturn false;\n\t}, []);\n\n\treturn {\n\t\tmonthItems,\n\t\tmonthScrollRef,\n\t\thandleLoadMoreMonths,\n\t\trequestMonthScroll,\n\t\tshouldIgnoreActiveMonthChange,\n\t};\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/calendar/types.ts",
    "content": "/**\n * 日历相关类型定义\n */\n\nimport type { Todo, TodoStatus } from \"@/lib/types\";\n\nexport type CalendarView = \"month\" | \"week\" | \"day\";\n\nexport interface CalendarTodo {\n\ttodo: Todo;\n\tstartTime: Date;\n\tendTime?: Date | null;\n\tdateKey: string;\n\tday: Date;\n\tisAllDay?: boolean;\n}\n\nexport interface CalendarDay {\n\tdate: Date;\n\tinCurrentMonth?: boolean;\n}\n\nexport interface TimelineItem {\n\ttodo: Todo;\n\tkind: \"deadline\" | \"range\";\n\tdate: Date;\n\tstartMinutes: number;\n\tendMinutes: number;\n\ttimeLabel: string;\n}\n\nexport function getStatusStyle(status: TodoStatus): string {\n\tswitch (status) {\n\t\tcase \"completed\":\n\t\t\treturn \"bg-green-500/15 text-green-600 border-green-500/30\";\n\t\tcase \"canceled\":\n\t\t\treturn \"bg-gray-500/15 text-gray-500 border-gray-500/30\";\n\t\tcase \"draft\":\n\t\t\treturn \"bg-orange-500/15 text-orange-600 border-orange-500/30\";\n\t\tdefault:\n\t\t\treturn \"bg-primary/10 text-primary border-primary/25\";\n\t}\n}\n\nexport function getScheduleSeverity(\n\tstartTime: Date,\n): \"overdue\" | \"soon\" | \"normal\" {\n\tconst now = new Date();\n\tif (startTime.getTime() < now.getTime()) return \"overdue\";\n\tconst diffHours = (startTime.getTime() - now.getTime()) / (1000 * 60 * 60);\n\treturn diffHours <= 24 ? \"soon\" : \"normal\";\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/calendar/utils.ts",
    "content": "/**\n * 日历工具函数\n */\n\nexport const DEFAULT_NEW_TIME = \"09:00\";\nexport const DEFAULT_WORK_START_MINUTES = 8 * 60;\nexport const DEFAULT_WORK_END_MINUTES = 22 * 60;\nexport const MINUTES_PER_SLOT = 15;\nexport const DEFAULT_DURATION_MINUTES = 30;\n\nexport function startOfDay(date: Date): Date {\n\tconst d = new Date(date);\n\td.setHours(0, 0, 0, 0);\n\treturn d;\n}\n\nexport function endOfDay(date: Date): Date {\n\tconst d = new Date(date);\n\td.setHours(23, 59, 59, 999);\n\treturn d;\n}\n\nexport function addDays(date: Date, days: number): Date {\n\tconst d = new Date(date);\n\td.setDate(d.getDate() + days);\n\treturn d;\n}\n\nexport function addMonths(date: Date, months: number): Date {\n\tconst d = new Date(date.getFullYear(), date.getMonth(), 1);\n\td.setMonth(d.getMonth() + months);\n\treturn startOfMonth(d);\n}\n\nexport function startOfWeek(date: Date): Date {\n\tconst d = startOfDay(date);\n\tconst day = d.getDay(); // Sunday=0\n\tconst diff = (day + 6) % 7; // Monday as first day\n\td.setDate(d.getDate() - diff);\n\treturn d;\n}\n\nexport function startOfMonth(date: Date): Date {\n\treturn startOfDay(new Date(date.getFullYear(), date.getMonth(), 1));\n}\n\nexport function endOfMonth(date: Date): Date {\n\treturn endOfDay(new Date(date.getFullYear(), date.getMonth() + 1, 0));\n}\n\nexport function getWeekOfYear(date: Date): number {\n\tconst d = new Date(date);\n\td.setHours(0, 0, 0, 0);\n\t// Set to Thursday of current week (ISO week starts Monday)\n\td.setDate(d.getDate() + 3 - ((d.getDay() + 6) % 7));\n\tconst yearStart = new Date(d.getFullYear(), 0, 1);\n\tconst weekNumber = Math.ceil(\n\t\t((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7,\n\t);\n\treturn weekNumber;\n}\n\nexport function toDateKey(date: Date): string {\n\tconst y = date.getFullYear();\n\tconst m = `${date.getMonth() + 1}`.padStart(2, \"0\");\n\tconst d = `${date.getDate()}`.padStart(2, \"0\");\n\treturn `${y}-${m}-${d}`;\n}\n\nexport function parseTodoDateTime(value?: string): Date | null {\n\tif (!value) return null;\n\tlet normalizedValue = value;\n\tif (\n\t\tvalue.includes(\"T\") &&\n\t\t!value.includes(\"Z\") &&\n\t\t!value.includes(\"+\") &&\n\t\t!/\\d{2}:\\d{2}:\\d{2}-/.test(value)\n\t) {\n\t\tnormalizedValue = `${value}Z`;\n\t}\n\tconst parsed = new Date(normalizedValue);\n\treturn Number.isNaN(parsed.getTime()) ? null : parsed;\n}\n\nexport function parseScheduleTime(value?: string): Date | null {\n\tif (!value) return null;\n\t// 如果时间字符串没有时区信息（没有 Z 或 +/- 偏移），\n\t// 假设它是 UTC 时间并添加 Z 后缀，避免被解析为本地时间导致日期偏移\n\tlet normalizedValue = value;\n\tif (\n\t\tvalue.includes(\"T\") &&\n\t\t!value.includes(\"Z\") &&\n\t\t!value.includes(\"+\") &&\n\t\t!/\\d{2}:\\d{2}:\\d{2}-/.test(value)\n\t) {\n\t\tnormalizedValue = `${value}Z`;\n\t}\n\tconst parsed = new Date(normalizedValue);\n\treturn Number.isNaN(parsed.getTime()) ? null : parsed;\n}\n\nexport function parseDeadline(deadline?: string): Date | null {\n\treturn parseTodoDateTime(deadline);\n}\n\nexport function isAllDayDeadlineString(value?: string): boolean {\n\tif (!value) return false;\n\tif (/^\\d{4}-\\d{2}-\\d{2}$/.test(value)) return true;\n\tif (\n\t\tvalue.includes(\"T00:00:00\") &&\n\t\t!value.includes(\"Z\") &&\n\t\t!value.includes(\"+\") &&\n\t\t!/\\d{2}:\\d{2}:\\d{2}-/.test(value)\n\t) {\n\t\treturn true;\n\t}\n\treturn false;\n}\n\nexport function formatHumanDate(date: Date): string {\n\treturn `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`;\n}\n\nexport function formatMonthLabel(date: Date): string {\n\treturn `${date.getFullYear()}年${date.getMonth() + 1}月`;\n}\n\nexport function formatTimeLabel(date: Date | null, allDayText: string): string {\n\tif (!date) return allDayText;\n\tconst hh = `${date.getHours()}`.padStart(2, \"0\");\n\tconst mm = `${date.getMinutes()}`.padStart(2, \"0\");\n\treturn `${hh}:${mm}`;\n}\n\nexport function formatTimeRangeLabel(start: number, end: number): string {\n\treturn `${formatMinutesLabel(start)}-${formatMinutesLabel(end)}`;\n}\n\nexport function formatMinutesLabel(minutes: number): string {\n\tconst hh = `${Math.floor(minutes / 60)}`.padStart(2, \"0\");\n\tconst mm = `${minutes % 60}`.padStart(2, \"0\");\n\treturn `${hh}:${mm}`;\n}\n\nexport function isSameDay(a: Date, b: Date): boolean {\n\treturn (\n\t\ta.getFullYear() === b.getFullYear() &&\n\t\ta.getMonth() === b.getMonth() &&\n\t\ta.getDate() === b.getDate()\n\t);\n}\n\nexport function getMinutesFromDate(date: Date): number {\n\treturn date.getHours() * 60 + date.getMinutes();\n}\n\nexport function addMinutes(date: Date, minutes: number): Date {\n\tconst d = new Date(date);\n\td.setMinutes(d.getMinutes() + minutes);\n\treturn d;\n}\n\nexport function setMinutesOnDate(date: Date, minutes: number): Date {\n\tconst d = startOfDay(date);\n\td.setHours(Math.floor(minutes / 60), minutes % 60, 0, 0);\n\treturn d;\n}\n\nexport function floorToMinutes(minutes: number, step = MINUTES_PER_SLOT): number {\n\treturn Math.floor(minutes / step) * step;\n}\n\nexport function ceilToMinutes(minutes: number, step = MINUTES_PER_SLOT): number {\n\treturn Math.ceil(minutes / step) * step;\n}\n\nexport function clampMinutes(value: number, min: number, max: number): number {\n\treturn Math.min(Math.max(value, min), max);\n}\n\nexport function buildMonthDays(\n\tcurrentDate: Date,\n): Array<{ date: Date; inCurrentMonth?: boolean }> {\n\tconst start = startOfMonth(currentDate);\n\tconst startGrid = startOfWeek(start);\n\treturn Array.from({ length: 42 }, (_, idx) => {\n\t\tconst date = addDays(startGrid, idx);\n\t\treturn { date, inCurrentMonth: date.getMonth() === currentDate.getMonth() };\n\t});\n}\n\nexport function buildWeekDays(\n\tcurrentDate: Date,\n): Array<{ date: Date; inCurrentMonth?: boolean }> {\n\tconst start = startOfWeek(currentDate);\n\treturn Array.from({ length: 7 }, (_, idx) => ({ date: addDays(start, idx) }));\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/calendar/views/DayView.tsx",
    "content": "/**\n * Day timeline view.\n */\n\nimport { useTranslations } from \"next-intl\";\nimport type React from \"react\";\nimport { useMemo, useState } from \"react\";\nimport { useTodoMutations } from \"@/lib/query\";\nimport { useTodoStore } from \"@/lib/store/todo-store\";\nimport type { Todo } from \"@/lib/types\";\nimport { FloatingTodoCard } from \"../components/FloatingTodoCard\";\nimport { TimelineColumn } from \"../components/TimelineColumn\";\nimport { TimelineCreatePopover } from \"../components/TimelineCreatePopover\";\nimport type { TimelineItem } from \"../types\";\nimport {\n\taddMinutes,\n\tceilToMinutes,\n\tclampMinutes,\n\tDEFAULT_DURATION_MINUTES,\n\tDEFAULT_NEW_TIME,\n\tDEFAULT_WORK_END_MINUTES,\n\tDEFAULT_WORK_START_MINUTES,\n\tfloorToMinutes,\n\tformatMinutesLabel,\n\tformatTimeRangeLabel,\n\tgetMinutesFromDate,\n\tisAllDayDeadlineString,\n\tisSameDay,\n\tMINUTES_PER_SLOT,\n\tparseTodoDateTime,\n\tsetMinutesOnDate,\n\ttoDateKey,\n} from \"../utils\";\n\nconst SLOT_HEIGHT = 12;\n\ninterface ParsedTodo {\n\ttodo: Todo;\n\tdeadlineRaw?: string;\n\tdeadline: Date | null;\n\tstart: Date | null;\n\tend: Date | null;\n}\n\nexport function DayView({\n\tcurrentDate,\n\ttodos,\n}: {\n\tcurrentDate: Date;\n\ttodos: Todo[];\n}) {\n\tconst t = useTranslations(\"calendar\");\n\tconst { setSelectedTodoId } = useTodoStore();\n\tconst { updateTodo, createTodo } = useTodoMutations();\n\tconst [workingStart, setWorkingStart] = useState(DEFAULT_WORK_START_MINUTES);\n\tconst [workingEnd, setWorkingEnd] = useState(DEFAULT_WORK_END_MINUTES);\n\tconst pxPerMinute = SLOT_HEIGHT / MINUTES_PER_SLOT;\n\tconst [timelineAnchor, setTimelineAnchor] = useState<{\n\t\ttop: number;\n\t\tleft: number;\n\t} | null>(null);\n\tconst [createMode, setCreateMode] = useState<\"timeline\" | \"all-day\" | null>(\n\t\tnull,\n\t);\n\tconst [timelineTitle, setTimelineTitle] = useState(\"\");\n\tconst [timelineStart, setTimelineStart] = useState(\"\");\n\tconst [timelineEnd, setTimelineEnd] = useState(\"\");\n\tconst [timelineDate, setTimelineDate] = useState<Date | null>(null);\n\tconst [timelinePreview, setTimelinePreview] = useState<{\n\t\tdate: Date;\n\t\tstartMinutes: number;\n\t\tendMinutes: number;\n\t} | null>(null);\n\tconst [allDayPreview, setAllDayPreview] = useState<Date | null>(null);\n\tconst weekDayLabels = [\n\t\tt(\"weekdays.monday\"),\n\t\tt(\"weekdays.tuesday\"),\n\t\tt(\"weekdays.wednesday\"),\n\t\tt(\"weekdays.thursday\"),\n\t\tt(\"weekdays.friday\"),\n\t\tt(\"weekdays.saturday\"),\n\t\tt(\"weekdays.sunday\"),\n\t];\n\tconst weekDayIndex = (currentDate.getDay() + 6) % 7;\n\tconst dayHeaderLabel = `${t(\"weekPrefix\")}${weekDayLabels[weekDayIndex]} ${currentDate.getDate()}`;\n\tconst maxTimelineMinutes = 24 * 60 - MINUTES_PER_SLOT;\n\n\tconst parsedTodos = useMemo<ParsedTodo[]>(\n\t\t() =>\n\t\t\ttodos.map((todo) => ({\n\t\t\t\ttodo,\n\t\t\t\tdeadlineRaw: todo.deadline,\n\t\t\t\tdeadline: parseTodoDateTime(todo.deadline),\n\t\t\t\tstart: parseTodoDateTime(todo.startTime),\n\t\t\t\tend: parseTodoDateTime(todo.endTime),\n\t\t\t})),\n\t\t[todos],\n\t);\n\n\tconst { timelineItems, allDayTodos } = useMemo(() => {\n\t\tconst items: TimelineItem[] = [];\n\t\tconst allDay: Todo[] = [];\n\n\t\tfor (const entry of parsedTodos) {\n\t\t\tconst anchor = entry.start ?? entry.end ?? entry.deadline;\n\t\t\tconst hasTime = Boolean(entry.start || entry.end || entry.deadline);\n\t\t\tif (!hasTime) continue;\n\t\t\tif (!anchor || !isSameDay(anchor, currentDate)) continue;\n\n\t\t\tif (\n\t\t\t\t!entry.start &&\n\t\t\t\t!entry.end &&\n\t\t\t\tisAllDayDeadlineString(entry.deadlineRaw)\n\t\t\t) {\n\t\t\t\tallDay.push(entry.todo);\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tif (entry.start || entry.end) {\n\t\t\t\tconst start =\n\t\t\t\t\tentry.start ?? addMinutes(entry.end as Date, -DEFAULT_DURATION_MINUTES);\n\t\t\t\tconst end = entry.end ?? addMinutes(start, DEFAULT_DURATION_MINUTES);\n\t\t\t\tconst startMinutes = getMinutesFromDate(start);\n\t\t\t\tconst endMinutes = Math.max(\n\t\t\t\t\tstartMinutes + MINUTES_PER_SLOT,\n\t\t\t\t\tgetMinutesFromDate(end),\n\t\t\t\t);\n\t\t\t\titems.push({\n\t\t\t\t\ttodo: entry.todo,\n\t\t\t\t\tkind: \"range\",\n\t\t\t\t\tdate: currentDate,\n\t\t\t\t\tstartMinutes,\n\t\t\t\t\tendMinutes,\n\t\t\t\t\ttimeLabel: formatTimeRangeLabel(startMinutes, endMinutes),\n\t\t\t\t});\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tconst deadlineMinutes = getMinutesFromDate(entry.deadline as Date);\n\t\t\titems.push({\n\t\t\t\ttodo: entry.todo,\n\t\t\t\tkind: \"deadline\",\n\t\t\t\tdate: currentDate,\n\t\t\t\tstartMinutes: deadlineMinutes,\n\t\t\t\tendMinutes: deadlineMinutes + MINUTES_PER_SLOT,\n\t\t\t\ttimeLabel: formatMinutesLabel(deadlineMinutes),\n\t\t\t});\n\t\t}\n\n\t\titems.sort((a, b) => a.startMinutes - b.startMinutes);\n\t\treturn { timelineItems: items, allDayTodos: allDay };\n\t}, [currentDate, parsedTodos]);\n\n\tconst { displayStart, displayEnd } = useMemo(() => {\n\t\tif (timelineItems.length === 0) {\n\t\t\treturn { displayStart: workingStart, displayEnd: workingEnd };\n\t\t}\n\t\tconst minStart = Math.min(...timelineItems.map((item) => item.startMinutes));\n\t\tconst maxEnd = Math.max(\n\t\t\t...timelineItems.map((item) =>\n\t\t\t\titem.kind === \"range\" ? item.endMinutes : item.startMinutes,\n\t\t\t),\n\t\t);\n\t\tconst autoStart = floorToMinutes(minStart);\n\t\tconst autoEnd = ceilToMinutes(maxEnd);\n\t\treturn {\n\t\t\tdisplayStart: Math.min(workingStart, autoStart),\n\t\t\tdisplayEnd: Math.max(workingEnd, autoEnd),\n\t\t};\n\t}, [timelineItems, workingEnd, workingStart]);\n\n\tconst slotMinutes = useMemo(() => {\n\t\tconst total = Math.max(\n\t\t\t1,\n\t\t\tMath.ceil((displayEnd - displayStart) / MINUTES_PER_SLOT),\n\t\t);\n\t\treturn Array.from({ length: total }, (_, idx) => displayStart + idx * MINUTES_PER_SLOT);\n\t}, [displayEnd, displayStart]);\n\n\tconst parseTimeInput = (value: string) => {\n\t\tconst [hh, mm] = value.split(\":\").map((part) => Number(part));\n\t\tif (Number.isNaN(hh) || Number.isNaN(mm)) return null;\n\t\treturn clampMinutes(hh * 60 + mm, 0, maxTimelineMinutes);\n\t};\n\n\tconst openTimelineCreateAt = ({\n\t\tdate,\n\t\tminutes,\n\t\tanchorRect,\n\t\tclientY,\n\t}: {\n\t\tdate: Date;\n\t\tminutes: number;\n\t\tanchorRect: DOMRect;\n\t\tclientY: number;\n\t}) => {\n\t\tif (typeof window === \"undefined\") return;\n\t\tconst safeStart = Math.min(minutes, maxTimelineMinutes - MINUTES_PER_SLOT);\n\t\tconst endMinutes = clampMinutes(\n\t\t\tsafeStart + DEFAULT_DURATION_MINUTES,\n\t\t\t0,\n\t\t\tmaxTimelineMinutes,\n\t\t);\n\t\tconst viewportWidth = window.innerWidth;\n\t\tconst viewportHeight = window.innerHeight;\n\t\tconst preferredLeft = anchorRect.left + 16;\n\t\tconst preferredTop = clientY + 8;\n\t\tconst popoverWidth = 340;\n\t\tconst popoverHeight = 260;\n\t\tconst left = Math.min(\n\t\t\tMath.max(12, preferredLeft),\n\t\t\tviewportWidth - popoverWidth,\n\t\t);\n\t\tconst top = Math.min(\n\t\t\tMath.max(12, preferredTop),\n\t\t\tviewportHeight - popoverHeight,\n\t\t);\n\n\t\tsetCreateMode(\"timeline\");\n\t\tsetTimelineDate(date);\n\t\tsetTimelineStart(formatMinutesLabel(safeStart));\n\t\tsetTimelineEnd(formatMinutesLabel(endMinutes));\n\t\tsetTimelineTitle(\"\");\n\t\tsetTimelineAnchor({ top, left });\n\t\tsetTimelinePreview({\n\t\t\tdate,\n\t\t\tstartMinutes: safeStart,\n\t\t\tendMinutes,\n\t\t});\n\t\tsetAllDayPreview(null);\n\t};\n\n\tconst openAllDayCreateAt = (\n\t\ttarget: HTMLElement,\n\t\teventTarget?: HTMLElement,\n\t) => {\n\t\tif (typeof window === \"undefined\") return;\n\t\tif (eventTarget?.closest(\"[data-all-day-card]\")) {\n\t\t\treturn;\n\t\t}\n\t\tconst rect = target.getBoundingClientRect();\n\t\tconst viewportWidth = window.innerWidth;\n\t\tconst viewportHeight = window.innerHeight;\n\t\tconst preferredLeft = rect.left + 16;\n\t\tconst preferredTop = rect.bottom + 8;\n\t\tconst popoverWidth = 340;\n\t\tconst popoverHeight = 220;\n\t\tconst left = Math.min(\n\t\t\tMath.max(12, preferredLeft),\n\t\t\tviewportWidth - popoverWidth,\n\t\t);\n\t\tconst top = Math.min(\n\t\t\tMath.max(12, preferredTop),\n\t\t\tviewportHeight - popoverHeight,\n\t\t);\n\n\t\tsetCreateMode(\"all-day\");\n\t\tsetTimelineDate(currentDate);\n\t\tsetTimelineStart(DEFAULT_NEW_TIME);\n\t\tsetTimelineEnd(DEFAULT_NEW_TIME);\n\t\tsetTimelineTitle(\"\");\n\t\tsetTimelineAnchor({ top, left });\n\t\tsetTimelinePreview(null);\n\t\tsetAllDayPreview(currentDate);\n\t};\n\n\tconst closeTimelineCreate = () => {\n\t\tsetTimelineAnchor(null);\n\t\tsetTimelineDate(null);\n\t\tsetTimelineTitle(\"\");\n\t\tsetTimelineStart(\"\");\n\t\tsetTimelineEnd(\"\");\n\t\tsetCreateMode(null);\n\t\tsetTimelinePreview(null);\n\t\tsetAllDayPreview(null);\n\t};\n\n\tconst handleCreateTimelineTodo = async () => {\n\t\tif (!timelineDate || !timelineTitle.trim()) return;\n\t\tif (createMode === \"all-day\") {\n\t\t\tconst dateKey = toDateKey(timelineDate);\n\t\t\ttry {\n\t\t\t\tawait createTodo({\n\t\t\t\t\tname: timelineTitle.trim(),\n\t\t\t\t\tdeadline: `${dateKey}T00:00:00`,\n\t\t\t\t\tstatus: \"active\",\n\t\t\t\t});\n\t\t\t\tcloseTimelineCreate();\n\t\t\t} catch (error) {\n\t\t\t\tconsole.error(\"Failed to create all-day todo:\", error);\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\t\tconst startMinutes = parseTimeInput(timelineStart);\n\t\tlet endMinutes = parseTimeInput(timelineEnd);\n\t\tif (startMinutes === null) return;\n\t\tif (endMinutes === null || endMinutes <= startMinutes) {\n\t\t\tendMinutes = clampMinutes(\n\t\t\t\tstartMinutes + DEFAULT_DURATION_MINUTES,\n\t\t\t\t0,\n\t\t\t\tmaxTimelineMinutes,\n\t\t\t);\n\t\t}\n\t\tif (endMinutes <= startMinutes) {\n\t\t\tendMinutes = clampMinutes(\n\t\t\t\tstartMinutes + MINUTES_PER_SLOT,\n\t\t\t\t0,\n\t\t\t\tmaxTimelineMinutes,\n\t\t\t);\n\t\t}\n\t\tconst startDate = setMinutesOnDate(timelineDate, startMinutes);\n\t\tconst endDate = setMinutesOnDate(timelineDate, endMinutes);\n\t\ttry {\n\t\t\tawait createTodo({\n\t\t\t\tname: timelineTitle.trim(),\n\t\t\t\tstartTime: startDate.toISOString(),\n\t\t\t\tendTime: endDate.toISOString(),\n\t\t\t\tstatus: \"active\",\n\t\t\t});\n\t\t\tcloseTimelineCreate();\n\t\t} catch (error) {\n\t\t\tconsole.error(\"Failed to create timeline todo:\", error);\n\t\t}\n\t};\n\n\tconst handleResize = async (\n\t\ttodo: Todo,\n\t\tstartMinutes: number,\n\t\tendMinutes: number,\n\t\tdate: Date,\n\t) => {\n\t\tconst startDate = setMinutesOnDate(date, startMinutes);\n\t\tconst endDate = setMinutesOnDate(date, endMinutes);\n\t\tawait updateTodo(todo.id, {\n\t\t\tstartTime: startDate.toISOString(),\n\t\t\tendTime: endDate.toISOString(),\n\t\t});\n\t};\n\n\tconst handleWorkingPointerDown = (\n\t\tedge: \"start\" | \"end\",\n\t\tevent: React.PointerEvent<HTMLDivElement>,\n\t) => {\n\t\tevent.preventDefault();\n\t\tevent.stopPropagation();\n\t\tconst container = event.currentTarget.closest(\n\t\t\t\"[data-timeline-container]\",\n\t\t) as HTMLDivElement | null;\n\t\tif (!container) return;\n\n\t\tconst handleMove = (moveEvent: PointerEvent) => {\n\t\t\tconst rect = container.getBoundingClientRect();\n\t\t\tif (!rect) return;\n\t\t\tconst offset = moveEvent.clientY - rect.top;\n\t\t\tconst rawMinutes =\n\t\t\t\tdisplayStart +\n\t\t\t\tMath.round((offset / pxPerMinute) / MINUTES_PER_SLOT) * MINUTES_PER_SLOT;\n\t\t\tconst minutes = clampMinutes(rawMinutes, 0, 24 * 60);\n\n\t\t\tif (edge === \"start\") {\n\t\t\t\tsetWorkingStart(Math.min(minutes, workingEnd - MINUTES_PER_SLOT));\n\t\t\t} else {\n\t\t\t\tsetWorkingEnd(Math.max(minutes, workingStart + MINUTES_PER_SLOT));\n\t\t\t}\n\t\t};\n\n\t\tconst handleUp = () => {\n\t\t\twindow.removeEventListener(\"pointermove\", handleMove);\n\t\t\twindow.removeEventListener(\"pointerup\", handleUp);\n\t\t};\n\n\t\twindow.addEventListener(\"pointermove\", handleMove);\n\t\twindow.addEventListener(\"pointerup\", handleUp);\n\t};\n\n\treturn (\n\t\t<div className=\"relative flex flex-col gap-4\">\n\t\t\t<div className=\"sticky top-0 z-20 space-y-3 bg-background/95 pb-3 backdrop-blur\">\n\t\t\t\t<div className=\"rounded-xl border border-border bg-card/50 px-4 py-3 text-sm font-semibold text-foreground shadow-sm\">\n\t\t\t\t\t{dayHeaderLabel}\n\t\t\t\t</div>\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"rounded-xl border border-border bg-card/50 p-3 shadow-sm\"\n\t\t\t\t\tonClick={(event) =>\n\t\t\t\t\t\topenAllDayCreateAt(\n\t\t\t\t\t\t\tevent.currentTarget,\n\t\t\t\t\t\t\tevent.target as HTMLElement,\n\t\t\t\t\t\t)\n\t\t\t\t\t}\n\t\t\t\t\tonKeyDown={(event) => {\n\t\t\t\t\t\tif (event.key === \"Enter\" || event.key === \" \") {\n\t\t\t\t\t\t\tevent.preventDefault();\n\t\t\t\t\t\t\topenAllDayCreateAt(event.currentTarget);\n\t\t\t\t\t\t}\n\t\t\t\t\t}}\n\t\t\t\t\trole=\"button\"\n\t\t\t\t\ttabIndex={0}\n\t\t\t\t>\n\t\t\t\t\t<div className=\"mb-2 flex items-center justify-between text-xs font-semibold text-muted-foreground\">\n\t\t\t\t\t\t<span>{t(\"allDay\")}</span>\n\t\t\t\t\t\t<span className=\"text-[11px] text-muted-foreground\">\n\t\t\t\t\t\t\t{allDayTodos.length}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"flex flex-wrap gap-2\">\n\t\t\t\t\t\t{allDayTodos.map((todo) => (\n\t\t\t\t\t\t\t<FloatingTodoCard\n\t\t\t\t\t\t\t\tkey={todo.id}\n\t\t\t\t\t\t\t\ttodo={todo}\n\t\t\t\t\t\t\t\tonSelect={(selected) => setSelectedTodoId(selected.id)}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t))}\n\t\t\t\t\t\t{createMode === \"all-day\" &&\n\t\t\t\t\t\t\tallDayPreview &&\n\t\t\t\t\t\t\tisSameDay(allDayPreview, currentDate) && (\n\t\t\t\t\t\t\t\t<div className=\"rounded-lg border border-dashed border-primary/60 bg-primary/10 px-3 py-2 text-xs font-semibold text-primary shadow-sm ring-2 ring-primary/30\">\n\t\t\t\t\t\t\t\t\t{timelineTitle.trim() || t(\"inputTodoTitle\")}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t<div className=\"rounded-xl border border-border bg-card/50 p-3 shadow-sm\">\n\t\t\t\t<div className=\"mb-3 flex items-center justify-between text-xs text-muted-foreground\">\n\t\t\t\t\t<span className=\"font-semibold\">{t(\"workingHours\")}</span>\n\t\t\t\t\t<span>\n\t\t\t\t\t\t{formatMinutesLabel(displayStart)}-{formatMinutesLabel(displayEnd)}\n\t\t\t\t\t</span>\n\t\t\t\t</div>\n\t\t\t\t<div className=\"flex\">\n\t\t\t\t\t<div className=\"relative w-14 shrink-0 pr-2 text-[11px] text-muted-foreground\">\n\t\t\t\t\t\t{slotMinutes\n\t\t\t\t\t\t\t.filter((minutes) => minutes % 60 === 0)\n\t\t\t\t\t\t\t.map((minutes) => (\n\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\tkey={`label-${minutes}`}\n\t\t\t\t\t\t\t\t\tclassName=\"absolute left-0\"\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\ttop: (minutes - displayStart) * pxPerMinute - 6,\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{formatMinutesLabel(minutes)}\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t</div>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"relative flex-1\"\n\t\t\t\t\t\tstyle={{ height: slotMinutes.length * SLOT_HEIGHT }}\n\t\t\t\t\t\tdata-timeline-container\n\t\t\t\t\t>\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"absolute left-0 right-0 z-10 h-1 cursor-row-resize bg-primary/40\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\ttop: (workingStart - displayStart) * pxPerMinute,\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tonPointerDown={(event) =>\n\t\t\t\t\t\t\t\thandleWorkingPointerDown(\"start\", event)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"absolute left-0 right-0 z-10 h-1 cursor-row-resize bg-primary/40\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\ttop: (workingEnd - displayStart) * pxPerMinute,\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tonPointerDown={(event) => handleWorkingPointerDown(\"end\", event)}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<TimelineColumn\n\t\t\t\t\t\t\tdate={currentDate}\n\t\t\t\t\t\t\titems={timelineItems}\n\t\t\t\t\t\t\tdisplayStart={displayStart}\n\t\t\t\t\t\t\tslotMinutes={slotMinutes}\n\t\t\t\t\t\t\tslotHeight={SLOT_HEIGHT}\n\t\t\t\t\t\t\tpxPerMinute={pxPerMinute}\n\t\t\t\t\t\t\tpreview={\n\t\t\t\t\t\t\t\tcreateMode === \"timeline\" && timelinePreview\n\t\t\t\t\t\t\t\t\t? {\n\t\t\t\t\t\t\t\t\t\t\tstartMinutes: timelinePreview.startMinutes,\n\t\t\t\t\t\t\t\t\t\t\tendMinutes: timelinePreview.endMinutes,\n\t\t\t\t\t\t\t\t\t\t\ttimeLabel: formatTimeRangeLabel(\n\t\t\t\t\t\t\t\t\t\t\t\ttimelinePreview.startMinutes,\n\t\t\t\t\t\t\t\t\t\t\t\ttimelinePreview.endMinutes,\n\t\t\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t\t\t\ttitle: timelineTitle.trim() || t(\"inputTodoTitle\"),\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t: undefined\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tonSelect={(todo) => setSelectedTodoId(todo.id)}\n\t\t\t\t\t\t\tonResize={handleResize}\n\t\t\t\t\t\t\tonSlotPointerDown={openTimelineCreateAt}\n\t\t\t\t\t\t\tclassName=\"border-l border-border/60\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t\t<TimelineCreatePopover\n\t\t\t\ttargetDate={timelineDate}\n\t\t\t\tvalue={timelineTitle}\n\t\t\t\tstartTime={timelineStart}\n\t\t\t\tendTime={timelineEnd}\n\t\t\t\tshowTimeFields={createMode !== \"all-day\"}\n\t\t\t\tanchorPoint={timelineAnchor}\n\t\t\t\tonChange={setTimelineTitle}\n\t\t\t\tonStartTimeChange={setTimelineStart}\n\t\t\t\tonEndTimeChange={setTimelineEnd}\n\t\t\t\tonConfirm={handleCreateTimelineTodo}\n\t\t\t\tonCancel={closeTimelineCreate}\n\t\t\t/>\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/calendar/views/MonthScroller.tsx",
    "content": "/**\n * 月视图滚动容器（无限滚动）\n */\n\nimport type React from \"react\";\nimport { useEffect, useMemo, useRef } from \"react\";\nimport type { Todo } from \"@/lib/types\";\nimport { DayColumn } from \"../components/DayColumn\";\nimport type { CalendarDay, CalendarTodo } from \"../types\";\nimport {\n\taddDays,\n\tendOfMonth,\n\tstartOfMonth,\n\tstartOfWeek,\n\ttoDateKey,\n} from \"../utils\";\n\ntype WeekKey = string;\n\nfunction getWeekKey(date: Date): WeekKey {\n\treturn `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;\n}\n\nexport function MonthScroller({\n\tmonths,\n\tactiveMonth,\n\tgroupedByDay,\n\tonSelectDay,\n\tonSelectTodo,\n\ttodayText,\n\trenderQuickCreate,\n\tonLoadMore,\n\tonActiveMonthChange,\n\tscrollRef,\n}: {\n\tmonths: Date[];\n\tactiveMonth: Date;\n\tgroupedByDay: Map<string, CalendarTodo[]>;\n\tonSelectDay: (\n\t\tdate: Date,\n\t\tanchorEl?: HTMLDivElement | null,\n\t\tinCurrentMonth?: boolean,\n\t) => void;\n\tonSelectTodo: (todo: Todo) => void;\n\ttodayText: string;\n\trenderQuickCreate?: (date: Date) => React.ReactNode;\n\tonLoadMore: (direction: \"prev\" | \"next\") => void;\n\tonActiveMonthChange: (month: Date) => void;\n\tscrollRef: React.RefObject<HTMLDivElement | null>;\n}) {\n\tconst FOCUS_HEIGHT_RATIO = 0.5; // 视窗内的“阅读焦点”高度比例\n\tconst monthsCount = months.length;\n\tconst topSentinelRef = useRef<HTMLDivElement | null>(null);\n\tconst bottomSentinelRef = useRef<HTMLDivElement | null>(null);\n\tconst weekRefs = useRef<Map<WeekKey, HTMLDivElement>>(new Map());\n\tconst rafRef = useRef<number | null>(null);\n\tconst loadingRef = useRef(false);\n\tconst activeMonthKey = `${activeMonth.getFullYear()}-${activeMonth.getMonth()}`;\n\n\tconst weeks = useMemo(() => {\n\t\tif (months.length === 0) return [];\n\t\tconst first = months[0];\n\t\tconst last = months[months.length - 1];\n\t\tconst start = startOfWeek(startOfMonth(first));\n\t\tconst end = startOfWeek(endOfMonth(last));\n\t\tconst list: Date[] = [];\n\t\tfor (\n\t\t\tlet cursor = start;\n\t\t\tcursor.getTime() <= end.getTime();\n\t\t\tcursor = addDays(cursor, 7)\n\t\t) {\n\t\t\tlist.push(cursor);\n\t\t}\n\t\treturn list;\n\t}, [months]);\n\n\tuseEffect(() => {\n\t\tif (monthsCount === 0) return;\n\t\tloadingRef.current = false;\n\t}, [monthsCount]);\n\n\tuseEffect(() => {\n\t\tconst container = scrollRef.current;\n\t\tif (!container) return;\n\n\t\tconst updateActiveMonth = () => {\n\t\t\tconst containerRect = container.getBoundingClientRect();\n\t\t\tconst focusY =\n\t\t\t\tcontainerRect.top + containerRect.height * FOCUS_HEIGHT_RATIO;\n\t\t\tlet focusWeek: Date | null = null;\n\t\t\tlet closestWeek: Date | null = null;\n\t\t\tlet minDistance = Number.POSITIVE_INFINITY;\n\n\t\t\tfor (const weekStart of weeks) {\n\t\t\t\tconst key = getWeekKey(weekStart);\n\t\t\t\tconst el = weekRefs.current.get(key);\n\t\t\t\tif (!el) continue;\n\t\t\t\tconst rect = el.getBoundingClientRect();\n\t\t\t\tif (rect.top <= focusY && rect.bottom >= focusY) {\n\t\t\t\t\tfocusWeek = weekStart;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tconst distance = Math.abs(rect.top - focusY);\n\t\t\t\tif (distance < minDistance) {\n\t\t\t\t\tminDistance = distance;\n\t\t\t\t\tclosestWeek = weekStart;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst targetWeek = focusWeek ?? closestWeek;\n\t\t\tif (targetWeek) {\n\t\t\t\tconst midWeek = addDays(targetWeek, 3);\n\t\t\t\tconst nextMonth = startOfMonth(midWeek);\n\t\t\t\tconst nextKey = `${nextMonth.getFullYear()}-${nextMonth.getMonth()}`;\n\t\t\t\tif (activeMonthKey === nextKey) return;\n\t\t\t\tonActiveMonthChange(nextMonth);\n\t\t\t}\n\t\t};\n\n\t\tconst onScroll = () => {\n\t\t\tif (rafRef.current) {\n\t\t\t\tcancelAnimationFrame(rafRef.current);\n\t\t\t}\n\t\t\trafRef.current = requestAnimationFrame(updateActiveMonth);\n\t\t};\n\n\t\tcontainer.addEventListener(\"scroll\", onScroll, { passive: true });\n\t\tupdateActiveMonth();\n\n\t\treturn () => {\n\t\t\tcontainer.removeEventListener(\"scroll\", onScroll);\n\t\t\tif (rafRef.current) {\n\t\t\t\tcancelAnimationFrame(rafRef.current);\n\t\t\t}\n\t\t};\n\t}, [activeMonthKey, onActiveMonthChange, scrollRef, weeks]);\n\n\tuseEffect(() => {\n\t\tconst container = scrollRef.current;\n\t\tif (!container) return;\n\n\t\tconst observer = new IntersectionObserver(\n\t\t\t(entries) => {\n\t\t\t\tif (loadingRef.current) return;\n\t\t\t\tfor (const entry of entries) {\n\t\t\t\t\tif (!entry.isIntersecting) continue;\n\t\t\t\t\tloadingRef.current = true;\n\t\t\t\t\tif (entry.target === topSentinelRef.current) {\n\t\t\t\t\t\tonLoadMore(\"prev\");\n\t\t\t\t\t}\n\t\t\t\t\tif (entry.target === bottomSentinelRef.current) {\n\t\t\t\t\t\tonLoadMore(\"next\");\n\t\t\t\t\t}\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t},\n\t\t\t{\n\t\t\t\troot: container,\n\t\t\t\trootMargin: \"200px 0px\",\n\t\t\t\tthreshold: 0.01,\n\t\t\t},\n\t\t);\n\n\t\tif (topSentinelRef.current) {\n\t\t\tobserver.observe(topSentinelRef.current);\n\t\t}\n\t\tif (bottomSentinelRef.current) {\n\t\t\tobserver.observe(bottomSentinelRef.current);\n\t\t}\n\n\t\treturn () => observer.disconnect();\n\t}, [onLoadMore, scrollRef]);\n\n\tif (weeks.length === 0) {\n\t\treturn null;\n\t}\n\n\treturn (\n\t\t<div className=\"space-y-0\">\n\t\t\t<div ref={topSentinelRef} aria-hidden className=\"h-px\" />\n\t\t\t<div className=\"border-l border-t border-border\">\n\t\t\t\t{weeks.map((weekStart) => {\n\t\t\t\t\tconst weekKey = getWeekKey(weekStart);\n\t\t\t\t\tconst days: CalendarDay[] = Array.from({ length: 7 }, (_, idx) => {\n\t\t\t\t\t\tconst date = addDays(weekStart, idx);\n\t\t\t\t\t\tconst inCurrentMonth =\n\t\t\t\t\t\t\t`${date.getFullYear()}-${date.getMonth()}` === activeMonthKey;\n\t\t\t\t\t\treturn { date, inCurrentMonth };\n\t\t\t\t\t});\n\t\t\t\t\tconst monthStartInWeek = days.find((day) => day.date.getDate() === 1);\n\t\t\t\t\tconst monthKey = monthStartInWeek\n\t\t\t\t\t\t? `${monthStartInWeek.date.getFullYear()}-${monthStartInWeek.date.getMonth()}`\n\t\t\t\t\t\t: undefined;\n\n\t\t\t\t\treturn (\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tkey={weekKey}\n\t\t\t\t\t\t\tdata-week-key={weekKey}\n\t\t\t\t\t\t\tdata-month-key={monthKey}\n\t\t\t\t\t\t\tref={(el) => {\n\t\t\t\t\t\t\t\tif (el) {\n\t\t\t\t\t\t\t\t\tweekRefs.current.set(weekKey, el);\n\t\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\t\tweekRefs.current.delete(weekKey);\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tclassName=\"grid grid-cols-7\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{days.map((day) => (\n\t\t\t\t\t\t\t\t<DayColumn\n\t\t\t\t\t\t\t\t\tkey={toDateKey(day.date)}\n\t\t\t\t\t\t\t\t\tday={day}\n\t\t\t\t\t\t\t\t\tview=\"month\"\n\t\t\t\t\t\t\t\t\tonSelectDay={onSelectDay}\n\t\t\t\t\t\t\t\t\tonSelectTodo={onSelectTodo}\n\t\t\t\t\t\t\t\t\ttodos={groupedByDay.get(toDateKey(day.date)) || []}\n\t\t\t\t\t\t\t\t\ttodayText={todayText}\n\t\t\t\t\t\t\t\t\trenderQuickCreate={renderQuickCreate}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t);\n\t\t\t\t})}\n\t\t\t</div>\n\t\t\t<div ref={bottomSentinelRef} aria-hidden className=\"h-px\" />\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/calendar/views/MonthView.tsx",
    "content": "/**\n * 月视图组件\n */\n\nimport type React from \"react\";\nimport type { Todo } from \"@/lib/types\";\nimport { DayColumn } from \"../components/DayColumn\";\nimport type { CalendarTodo } from \"../types\";\nimport { buildMonthDays, toDateKey } from \"../utils\";\n\nexport function MonthView({\n\tcurrentDate,\n\tgroupedByDay,\n\tonSelectDay,\n\tonSelectTodo,\n\ttodayText,\n\trenderQuickCreate,\n}: {\n\tcurrentDate: Date;\n\tgroupedByDay: Map<string, CalendarTodo[]>;\n\tonSelectDay: (\n\t\tdate: Date,\n\t\tanchorEl?: HTMLDivElement | null,\n\t\tinCurrentMonth?: boolean,\n\t) => void;\n\tonSelectTodo: (todo: Todo) => void;\n\ttodayText: string;\n\trenderQuickCreate?: (date: Date) => React.ReactNode;\n}) {\n\tconst monthDays = buildMonthDays(currentDate);\n\n\treturn (\n\t\t<div className=\"grid grid-cols-7 border-l border-t border-border\">\n\t\t\t{monthDays.map((day) => (\n\t\t\t\t<DayColumn\n\t\t\t\t\tkey={toDateKey(day.date)}\n\t\t\t\t\tday={day}\n\t\t\t\t\tview=\"month\"\n\t\t\t\t\tonSelectDay={onSelectDay}\n\t\t\t\t\tonSelectTodo={onSelectTodo}\n\t\t\t\t\ttodos={groupedByDay.get(toDateKey(day.date)) || []}\n\t\t\t\t\ttodayText={todayText}\n\t\t\t\t\trenderQuickCreate={renderQuickCreate}\n\t\t\t\t/>\n\t\t\t))}\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/calendar/views/WeekView.tsx",
    "content": "/**\n * Week timeline view.\n */\n\nimport { useTranslations } from \"next-intl\";\nimport { useMemo, useState } from \"react\";\nimport { useTodoMutations } from \"@/lib/query\";\nimport type { Todo } from \"@/lib/types\";\nimport { cn } from \"@/lib/utils\";\nimport { FloatingTodoCard } from \"../components/FloatingTodoCard\";\nimport { TimelineColumn } from \"../components/TimelineColumn\";\nimport { TimelineCreatePopover } from \"../components/TimelineCreatePopover\";\nimport type { TimelineItem } from \"../types\";\nimport {\n\taddMinutes,\n\tbuildWeekDays,\n\tceilToMinutes,\n\tDEFAULT_DURATION_MINUTES,\n\tDEFAULT_WORK_END_MINUTES,\n\tDEFAULT_WORK_START_MINUTES,\n\tfloorToMinutes,\n\tformatMinutesLabel,\n\tformatTimeRangeLabel,\n\tgetMinutesFromDate,\n\tisAllDayDeadlineString,\n\tisSameDay,\n\tMINUTES_PER_SLOT,\n\tparseTodoDateTime,\n\ttoDateKey,\n} from \"../utils\";\nimport { useWeekViewActions } from \"./useWeekViewActions\";\n\nconst SLOT_HEIGHT = 12;\n\ninterface ParsedTodo {\n\ttodo: Todo;\n\tdeadlineRaw?: string;\n\tdeadline: Date | null;\n\tstart: Date | null;\n\tend: Date | null;\n}\n\nexport function WeekView({\n\tcurrentDate,\n\ttodos,\n\tonSelectDay,\n\tonSelectTodo,\n\ttodayText,\n}: {\n\tcurrentDate: Date;\n\ttodos: Todo[];\n\tonSelectDay: (\n\t\tdate: Date,\n\t\tanchorEl?: HTMLDivElement | null,\n\t\tinCurrentMonth?: boolean,\n\t) => void;\n\tonSelectTodo: (todo: Todo) => void;\n\ttodayText: string;\n}) {\n\tconst t = useTranslations(\"calendar\");\n\tconst weekDayLabels = [\n\t\tt(\"weekdays.monday\"),\n\t\tt(\"weekdays.tuesday\"),\n\t\tt(\"weekdays.wednesday\"),\n\t\tt(\"weekdays.thursday\"),\n\t\tt(\"weekdays.friday\"),\n\t\tt(\"weekdays.saturday\"),\n\t\tt(\"weekdays.sunday\"),\n\t];\n\tconst { updateTodo, createTodo } = useTodoMutations();\n\tconst [workingStart, setWorkingStart] = useState(DEFAULT_WORK_START_MINUTES);\n\tconst [workingEnd, setWorkingEnd] = useState(DEFAULT_WORK_END_MINUTES);\n\tconst pxPerMinute = SLOT_HEIGHT / MINUTES_PER_SLOT;\n\tconst weekDays = buildWeekDays(currentDate);\n\tconst maxTimelineMinutes = 24 * 60 - MINUTES_PER_SLOT;\n\n\tconst parsedTodos = useMemo<ParsedTodo[]>(\n\t\t() =>\n\t\t\ttodos.map((todo) => ({\n\t\t\t\ttodo,\n\t\t\t\tdeadlineRaw: todo.deadline,\n\t\t\t\tdeadline: parseTodoDateTime(todo.deadline),\n\t\t\t\tstart: parseTodoDateTime(todo.startTime),\n\t\t\t\tend: parseTodoDateTime(todo.endTime),\n\t\t\t})),\n\t\t[todos],\n\t);\n\n\tconst { itemsByDay, allDayByDay, allTimelineItems } = useMemo(() => {\n\t\tconst map = new Map<string, TimelineItem[]>();\n\t\tconst allDay = new Map<string, Todo[]>();\n\t\tfor (const day of weekDays) {\n\t\t\tconst key = toDateKey(day.date);\n\t\t\tmap.set(key, []);\n\t\t\tallDay.set(key, []);\n\t\t}\n\n\t\tconst allItems: TimelineItem[] = [];\n\n\t\tfor (const entry of parsedTodos) {\n\t\t\tconst anchor = entry.start ?? entry.end ?? entry.deadline;\n\t\t\tconst hasTime = Boolean(entry.start || entry.end || entry.deadline);\n\t\t\tif (!hasTime) continue;\n\t\t\tif (!anchor) continue;\n\t\t\tconst dayKey = toDateKey(anchor);\n\t\t\tif (!map.has(dayKey)) continue;\n\n\t\t\tif (\n\t\t\t\t!entry.start &&\n\t\t\t\t!entry.end &&\n\t\t\t\tisAllDayDeadlineString(entry.deadlineRaw)\n\t\t\t) {\n\t\t\t\tallDay.get(dayKey)?.push(entry.todo);\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tif (entry.start || entry.end) {\n\t\t\t\tconst start =\n\t\t\t\t\tentry.start ?? addMinutes(entry.end as Date, -DEFAULT_DURATION_MINUTES);\n\t\t\t\tconst end = entry.end ?? addMinutes(start, DEFAULT_DURATION_MINUTES);\n\t\t\t\tconst startMinutes = getMinutesFromDate(start);\n\t\t\t\tconst endMinutes = Math.max(\n\t\t\t\t\tstartMinutes + MINUTES_PER_SLOT,\n\t\t\t\t\tgetMinutesFromDate(end),\n\t\t\t\t);\n\t\t\t\tconst item: TimelineItem = {\n\t\t\t\t\ttodo: entry.todo,\n\t\t\t\t\tkind: \"range\",\n\t\t\t\t\tdate: anchor,\n\t\t\t\t\tstartMinutes,\n\t\t\t\t\tendMinutes,\n\t\t\t\t\ttimeLabel: formatTimeRangeLabel(startMinutes, endMinutes),\n\t\t\t\t};\n\t\t\t\tmap.get(dayKey)?.push(item);\n\t\t\t\tallItems.push(item);\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tconst deadlineMinutes = getMinutesFromDate(entry.deadline as Date);\n\t\t\tconst item: TimelineItem = {\n\t\t\t\ttodo: entry.todo,\n\t\t\t\tkind: \"deadline\",\n\t\t\t\tdate: anchor,\n\t\t\t\tstartMinutes: deadlineMinutes,\n\t\t\t\tendMinutes: deadlineMinutes + MINUTES_PER_SLOT,\n\t\t\t\ttimeLabel: formatMinutesLabel(deadlineMinutes),\n\t\t\t};\n\t\t\tmap.get(dayKey)?.push(item);\n\t\t\tallItems.push(item);\n\t\t}\n\n\t\tfor (const list of map.values()) {\n\t\t\tlist.sort((a, b) => a.startMinutes - b.startMinutes);\n\t\t}\n\n\t\treturn { itemsByDay: map, allDayByDay: allDay, allTimelineItems: allItems };\n\t}, [parsedTodos, weekDays]);\n\n\tconst { displayStart, displayEnd } = useMemo(() => {\n\t\tif (allTimelineItems.length === 0) {\n\t\t\treturn { displayStart: workingStart, displayEnd: workingEnd };\n\t\t}\n\t\tconst minStart = Math.min(\n\t\t\t...allTimelineItems.map((item) => item.startMinutes),\n\t\t);\n\t\tconst maxEnd = Math.max(\n\t\t\t...allTimelineItems.map((item) =>\n\t\t\t\titem.kind === \"range\" ? item.endMinutes : item.startMinutes,\n\t\t\t),\n\t\t);\n\t\tconst autoStart = floorToMinutes(minStart);\n\t\tconst autoEnd = ceilToMinutes(maxEnd);\n\t\treturn {\n\t\t\tdisplayStart: Math.min(workingStart, autoStart),\n\t\t\tdisplayEnd: Math.max(workingEnd, autoEnd),\n\t\t};\n\t}, [allTimelineItems, workingEnd, workingStart]);\n\n\tconst slotMinutes = useMemo(() => {\n\t\tconst total = Math.max(\n\t\t\t1,\n\t\t\tMath.ceil((displayEnd - displayStart) / MINUTES_PER_SLOT),\n\t\t);\n\t\treturn Array.from({ length: total }, (_, idx) => displayStart + idx * MINUTES_PER_SLOT);\n\t}, [displayEnd, displayStart]);\n\n\tconst {\n\t\ttimelineAnchor,\n\t\tcreateMode,\n\t\ttimelineTitle,\n\t\ttimelineStart,\n\t\ttimelineEnd,\n\t\ttimelineDate,\n\t\ttimelinePreview,\n\t\tallDayPreview,\n\t\tsetTimelineTitle,\n\t\tsetTimelineStart,\n\t\tsetTimelineEnd,\n\t\topenTimelineCreateAt,\n\t\topenAllDayCreateAt,\n\t\tcloseTimelineCreate,\n\t\thandleCreateTimelineTodo,\n\t\thandleResize,\n\t\thandleWorkingPointerDown,\n\t} = useWeekViewActions({\n\t\tcurrentDate,\n\t\tcreateTodo,\n\t\tupdateTodo,\n\t\tdisplayStart,\n\t\tpxPerMinute,\n\t\tworkingStart,\n\t\tworkingEnd,\n\t\tsetWorkingStart,\n\t\tsetWorkingEnd,\n\t\tmaxTimelineMinutes,\n\t});\n\n\treturn (\n\t\t<div className=\"flex flex-col gap-3\">\n\t\t\t<div className=\"sticky top-0 z-20 space-y-3 bg-background/95 pb-3 backdrop-blur\">\n\t\t\t\t<div className=\"grid grid-cols-[72px_repeat(7,minmax(0,1fr))] overflow-hidden rounded-xl border border-border/70\">\n\t\t\t\t\t<div className=\"bg-muted/30 px-3 py-2 text-xs font-semibold text-muted-foreground\">\n\t\t\t\t\t\t{t(\"weekView\")}\n\t\t\t\t\t</div>\n\t\t\t\t\t{weekDays.map((day, index) => {\n\t\t\t\t\t\tconst dateKey = toDateKey(day.date);\n\t\t\t\t\t\tconst isToday = isSameDay(day.date, new Date());\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tkey={dateKey}\n\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\"flex items-center justify-between gap-2 border-l border-border/70 bg-muted/30 px-3 py-2 text-xs font-semibold text-muted-foreground\",\n\t\t\t\t\t\t\t\t\tisToday && \"text-primary\",\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\tonClick={(event) => {\n\t\t\t\t\t\t\t\t\tonSelectDay(day.date, event.currentTarget, true);\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tonKeyDown={(event) => {\n\t\t\t\t\t\t\t\t\tif (event.key === \"Enter\" || event.key === \" \") {\n\t\t\t\t\t\t\t\t\t\tevent.preventDefault();\n\t\t\t\t\t\t\t\t\t\tonSelectDay(day.date, event.currentTarget, true);\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\trole=\"button\"\n\t\t\t\t\t\t\t\ttabIndex={0}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<span>\n\t\t\t\t\t\t\t\t\t{t(\"weekPrefix\")}\n\t\t\t\t\t\t\t\t\t{weekDayLabels[index]} {day.date.getDate()}\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t{isToday && (\n\t\t\t\t\t\t\t\t\t<span className=\"text-[11px] font-medium\">{todayText}</span>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t);\n\t\t\t\t\t})}\n\t\t\t\t</div>\n\n\t\t\t\t<div className=\"grid grid-cols-[72px_repeat(7,minmax(0,1fr))] overflow-hidden rounded-xl border border-border/70\">\n\t\t\t\t\t<div className=\"border-r border-border/70 bg-card/50 px-3 py-2 text-xs font-semibold text-muted-foreground\">\n\t\t\t\t\t\t{t(\"allDay\")}\n\t\t\t\t\t</div>\n\t\t\t\t\t{weekDays.map((day) => {\n\t\t\t\t\t\tconst dateKey = toDateKey(day.date);\n\t\t\t\t\t\tconst allDayTodos = allDayByDay.get(dateKey) || [];\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tkey={dateKey}\n\t\t\t\t\t\t\t\tclassName=\"border-l border-border/70 bg-card/50 px-3 py-2\"\n\t\t\t\t\t\t\t\tonClick={(event) =>\n\t\t\t\t\t\t\t\t\topenAllDayCreateAt(\n\t\t\t\t\t\t\t\t\t\tday.date,\n\t\t\t\t\t\t\t\t\t\tevent.currentTarget,\n\t\t\t\t\t\t\t\t\t\tevent.target as HTMLElement,\n\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tonKeyDown={(event) => {\n\t\t\t\t\t\t\t\t\tif (event.key === \"Enter\" || event.key === \" \") {\n\t\t\t\t\t\t\t\t\t\tevent.preventDefault();\n\t\t\t\t\t\t\t\t\t\topenAllDayCreateAt(day.date, event.currentTarget);\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\trole=\"button\"\n\t\t\t\t\t\t\t\ttabIndex={0}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<div className=\"flex flex-wrap gap-2\">\n\t\t\t\t\t\t\t\t\t{allDayTodos.map((todo) => (\n\t\t\t\t\t\t\t\t\t\t<FloatingTodoCard\n\t\t\t\t\t\t\t\t\t\t\tkey={todo.id}\n\t\t\t\t\t\t\t\t\t\t\ttodo={todo}\n\t\t\t\t\t\t\t\t\t\t\tonSelect={onSelectTodo}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t{createMode === \"all-day\" &&\n\t\t\t\t\t\t\t\t\t\tallDayPreview &&\n\t\t\t\t\t\t\t\t\t\tisSameDay(allDayPreview, day.date) && (\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"rounded-lg border border-dashed border-primary/60 bg-primary/10 px-3 py-2 text-xs font-semibold text-primary shadow-sm ring-2 ring-primary/30\">\n\t\t\t\t\t\t\t\t\t\t\t\t{timelineTitle.trim() || t(\"inputTodoTitle\")}\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t);\n\t\t\t\t\t})}\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t<div className=\"rounded-xl border border-border/70 bg-card/50\">\n\t\t\t\t<div className=\"flex items-center justify-between border-b border-border/70 px-3 py-2 text-xs text-muted-foreground\">\n\t\t\t\t\t<span className=\"font-semibold\">{t(\"workingHours\")}</span>\n\t\t\t\t\t<span>\n\t\t\t\t\t\t{formatMinutesLabel(displayStart)}-{formatMinutesLabel(displayEnd)}\n\t\t\t\t\t</span>\n\t\t\t\t</div>\n\t\t\t\t<div className=\"grid grid-cols-[72px_repeat(7,minmax(0,1fr))]\">\n\t\t\t\t\t<div className=\"relative border-r border-border/70 px-3 text-[11px] text-muted-foreground\">\n\t\t\t\t\t\t{slotMinutes\n\t\t\t\t\t\t\t.filter((minutes) => minutes % 60 === 0)\n\t\t\t\t\t\t\t.map((minutes) => (\n\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\tkey={`label-${minutes}`}\n\t\t\t\t\t\t\t\t\tclassName=\"absolute left-3\"\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\ttop: (minutes - displayStart) * pxPerMinute - 6,\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{formatMinutesLabel(minutes)}\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t</div>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"relative col-span-7\"\n\t\t\t\t\t\tstyle={{ height: slotMinutes.length * SLOT_HEIGHT }}\n\t\t\t\t\t\tdata-timeline-container\n\t\t\t\t\t>\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"absolute left-0 right-0 z-10 h-1 cursor-row-resize bg-primary/40\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\ttop: (workingStart - displayStart) * pxPerMinute,\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tonPointerDown={(event) =>\n\t\t\t\t\t\t\t\thandleWorkingPointerDown(\"start\", event)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"absolute left-0 right-0 z-10 h-1 cursor-row-resize bg-primary/40\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\ttop: (workingEnd - displayStart) * pxPerMinute,\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tonPointerDown={(event) => handleWorkingPointerDown(\"end\", event)}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<div className=\"grid h-full grid-cols-7\">\n\t\t\t\t\t\t\t{weekDays.map((day) => {\n\t\t\t\t\t\t\t\tconst dateKey = toDateKey(day.date);\n\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\tkey={dateKey}\n\t\t\t\t\t\t\t\t\t\tclassName=\"border-l border-border/60\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<TimelineColumn\n\t\t\t\t\t\t\t\t\t\t\tdate={day.date}\n\t\t\t\t\t\t\t\t\t\t\titems={itemsByDay.get(dateKey) || []}\n\t\t\t\t\t\t\t\t\t\t\tdisplayStart={displayStart}\n\t\t\t\t\t\t\t\t\t\t\tslotMinutes={slotMinutes}\n\t\t\t\t\t\t\t\t\t\t\tslotHeight={SLOT_HEIGHT}\n\t\t\t\t\t\t\t\t\t\t\tpxPerMinute={pxPerMinute}\n\t\t\t\t\t\t\t\t\t\t\tpreview={\n\t\t\t\t\t\t\t\t\t\t\t\tcreateMode === \"timeline\" &&\n\t\t\t\t\t\t\t\t\t\t\t\ttimelinePreview &&\n\t\t\t\t\t\t\t\t\t\t\t\tisSameDay(timelinePreview.date, day.date)\n\t\t\t\t\t\t\t\t\t\t\t\t\t? {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tstartMinutes: timelinePreview.startMinutes,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tendMinutes: timelinePreview.endMinutes,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\ttimeLabel: formatTimeRangeLabel(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\ttimelinePreview.startMinutes,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\ttimelinePreview.endMinutes,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\ttitle:\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\ttimelineTitle.trim() ||\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tt(\"inputTodoTitle\"),\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\t\t: undefined\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\tonSelect={onSelectTodo}\n\t\t\t\t\t\t\t\t\t\t\tonResize={handleResize}\n\t\t\t\t\t\t\t\t\t\t\tonSlotPointerDown={openTimelineCreateAt}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t\t<TimelineCreatePopover\n\t\t\t\ttargetDate={timelineDate}\n\t\t\t\tvalue={timelineTitle}\n\t\t\t\tstartTime={timelineStart}\n\t\t\t\tendTime={timelineEnd}\n\t\t\t\tshowTimeFields={createMode !== \"all-day\"}\n\t\t\t\tanchorPoint={timelineAnchor}\n\t\t\t\tonChange={setTimelineTitle}\n\t\t\t\tonStartTimeChange={setTimelineStart}\n\t\t\t\tonEndTimeChange={setTimelineEnd}\n\t\t\t\tonConfirm={handleCreateTimelineTodo}\n\t\t\t\tonCancel={closeTimelineCreate}\n\t\t\t/>\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/calendar/views/useWeekViewActions.ts",
    "content": "/**\n * Shared handlers/state for WeekView timeline interactions.\n */\n\nimport { useState } from \"react\";\nimport type { CreateTodoInput, Todo, UpdateTodoInput } from \"@/lib/types\";\nimport {\n\tclampMinutes,\n\tDEFAULT_DURATION_MINUTES,\n\tDEFAULT_NEW_TIME,\n\tformatMinutesLabel,\n\tisSameDay,\n\tMINUTES_PER_SLOT,\n\tsetMinutesOnDate,\n\ttoDateKey,\n} from \"../utils\";\n\ninterface UseWeekViewActionsParams {\n\tcurrentDate: Date;\n\tcreateTodo: (input: CreateTodoInput) => Promise<unknown>;\n\tupdateTodo: (id: number, input: UpdateTodoInput) => Promise<unknown>;\n\tdisplayStart: number;\n\tpxPerMinute: number;\n\tworkingStart: number;\n\tworkingEnd: number;\n\tsetWorkingStart: (value: number) => void;\n\tsetWorkingEnd: (value: number) => void;\n\tmaxTimelineMinutes: number;\n}\n\nexport function useWeekViewActions({\n\tcurrentDate,\n\tcreateTodo,\n\tupdateTodo,\n\tdisplayStart,\n\tpxPerMinute,\n\tworkingStart,\n\tworkingEnd,\n\tsetWorkingStart,\n\tsetWorkingEnd,\n\tmaxTimelineMinutes,\n}: UseWeekViewActionsParams) {\n\tconst [timelineAnchor, setTimelineAnchor] = useState<{\n\t\ttop: number;\n\t\tleft: number;\n\t} | null>(null);\n\tconst [createMode, setCreateMode] = useState<\"timeline\" | \"all-day\" | null>(\n\t\tnull,\n\t);\n\tconst [timelineTitle, setTimelineTitle] = useState(\"\");\n\tconst [timelineStart, setTimelineStart] = useState(\"\");\n\tconst [timelineEnd, setTimelineEnd] = useState(\"\");\n\tconst [timelineDate, setTimelineDate] = useState<Date | null>(null);\n\tconst [timelinePreview, setTimelinePreview] = useState<{\n\t\tdate: Date;\n\t\tstartMinutes: number;\n\t\tendMinutes: number;\n\t} | null>(null);\n\tconst [allDayPreview, setAllDayPreview] = useState<Date | null>(null);\n\n\tconst parseTimeInput = (value: string) => {\n\t\tconst [hh, mm] = value.split(\":\").map((part) => Number(part));\n\t\tif (Number.isNaN(hh) || Number.isNaN(mm)) return null;\n\t\treturn clampMinutes(hh * 60 + mm, 0, maxTimelineMinutes);\n\t};\n\n\tconst openTimelineCreateAt = ({\n\t\tdate,\n\t\tminutes,\n\t\tanchorRect,\n\t\tclientY,\n\t}: {\n\t\tdate: Date;\n\t\tminutes: number;\n\t\tanchorRect: DOMRect;\n\t\tclientY: number;\n\t}) => {\n\t\tif (typeof window === \"undefined\") return;\n\t\tconst safeStart = Math.min(\n\t\t\tminutes,\n\t\t\tmaxTimelineMinutes - MINUTES_PER_SLOT,\n\t\t);\n\t\tconst endMinutes = clampMinutes(\n\t\t\tsafeStart + DEFAULT_DURATION_MINUTES,\n\t\t\t0,\n\t\t\tmaxTimelineMinutes,\n\t\t);\n\t\tconst viewportWidth = window.innerWidth;\n\t\tconst viewportHeight = window.innerHeight;\n\t\tconst preferredLeft = anchorRect.left + 16;\n\t\tconst preferredTop = clientY + 8;\n\t\tconst popoverWidth = 340;\n\t\tconst popoverHeight = 260;\n\t\tconst left = Math.min(\n\t\t\tMath.max(12, preferredLeft),\n\t\t\tviewportWidth - popoverWidth,\n\t\t);\n\t\tconst top = Math.min(\n\t\t\tMath.max(12, preferredTop),\n\t\t\tviewportHeight - popoverHeight,\n\t\t);\n\n\t\tsetCreateMode(\"timeline\");\n\t\tsetTimelineDate(date);\n\t\tsetTimelineStart(formatMinutesLabel(safeStart));\n\t\tsetTimelineEnd(formatMinutesLabel(endMinutes));\n\t\tsetTimelineTitle(\"\");\n\t\tsetTimelineAnchor({ top, left });\n\t\tsetTimelinePreview({\n\t\t\tdate,\n\t\t\tstartMinutes: safeStart,\n\t\t\tendMinutes,\n\t\t});\n\t\tsetAllDayPreview(null);\n\t};\n\n\tconst openAllDayCreateAt = (\n\t\tdate: Date,\n\t\ttarget: HTMLElement,\n\t\teventTarget?: HTMLElement,\n\t) => {\n\t\tif (typeof window === \"undefined\") return;\n\t\tif (eventTarget?.closest(\"[data-all-day-card]\")) {\n\t\t\treturn;\n\t\t}\n\t\tconst rect = target.getBoundingClientRect();\n\t\tconst viewportWidth = window.innerWidth;\n\t\tconst viewportHeight = window.innerHeight;\n\t\tconst preferredLeft = rect.left + 16;\n\t\tconst preferredTop = rect.bottom + 8;\n\t\tconst popoverWidth = 340;\n\t\tconst popoverHeight = 220;\n\t\tconst left = Math.min(\n\t\t\tMath.max(12, preferredLeft),\n\t\t\tviewportWidth - popoverWidth,\n\t\t);\n\t\tconst top = Math.min(\n\t\t\tMath.max(12, preferredTop),\n\t\t\tviewportHeight - popoverHeight,\n\t\t);\n\n\t\tsetCreateMode(\"all-day\");\n\t\tsetTimelineDate(date);\n\t\tsetTimelineStart(DEFAULT_NEW_TIME);\n\t\tsetTimelineEnd(DEFAULT_NEW_TIME);\n\t\tsetTimelineTitle(\"\");\n\t\tsetTimelineAnchor({ top, left });\n\t\tsetTimelinePreview(null);\n\t\tsetAllDayPreview(date);\n\t};\n\n\tconst closeTimelineCreate = () => {\n\t\tsetTimelineAnchor(null);\n\t\tsetTimelineDate(null);\n\t\tsetTimelineTitle(\"\");\n\t\tsetTimelineStart(\"\");\n\t\tsetTimelineEnd(\"\");\n\t\tsetCreateMode(null);\n\t\tsetTimelinePreview(null);\n\t\tsetAllDayPreview(null);\n\t};\n\n\tconst handleCreateTimelineTodo = async () => {\n\t\tif (!timelineDate || !timelineTitle.trim()) return;\n\t\tif (createMode === \"all-day\") {\n\t\t\tconst dateKey = toDateKey(timelineDate);\n\t\t\ttry {\n\t\t\t\tawait createTodo({\n\t\t\t\t\tname: timelineTitle.trim(),\n\t\t\t\t\tdeadline: `${dateKey}T00:00:00`,\n\t\t\t\t\tstatus: \"active\",\n\t\t\t\t});\n\t\t\t\tcloseTimelineCreate();\n\t\t\t} catch (error) {\n\t\t\t\tconsole.error(\"Failed to create all-day todo:\", error);\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\t\tconst startMinutes = parseTimeInput(timelineStart);\n\t\tlet endMinutes = parseTimeInput(timelineEnd);\n\t\tif (startMinutes === null) return;\n\t\tif (endMinutes === null || endMinutes <= startMinutes) {\n\t\t\tendMinutes = clampMinutes(\n\t\t\t\tstartMinutes + DEFAULT_DURATION_MINUTES,\n\t\t\t\t0,\n\t\t\t\tmaxTimelineMinutes,\n\t\t\t);\n\t\t}\n\t\tif (endMinutes <= startMinutes) {\n\t\t\tendMinutes = clampMinutes(\n\t\t\t\tstartMinutes + MINUTES_PER_SLOT,\n\t\t\t\t0,\n\t\t\t\tmaxTimelineMinutes,\n\t\t\t);\n\t\t}\n\t\tconst startDate = setMinutesOnDate(timelineDate, startMinutes);\n\t\tconst endDate = setMinutesOnDate(timelineDate, endMinutes);\n\t\ttry {\n\t\t\tawait createTodo({\n\t\t\t\tname: timelineTitle.trim(),\n\t\t\t\tstartTime: startDate.toISOString(),\n\t\t\t\tendTime: endDate.toISOString(),\n\t\t\t\tstatus: \"active\",\n\t\t\t});\n\t\t\tcloseTimelineCreate();\n\t\t} catch (error) {\n\t\t\tconsole.error(\"Failed to create timeline todo:\", error);\n\t\t}\n\t};\n\n\tconst handleResize = async (\n\t\ttodo: Todo,\n\t\tstartMinutes: number,\n\t\tendMinutes: number,\n\t\tdate: Date,\n\t) => {\n\t\tconst targetDate = isSameDay(date, currentDate)\n\t\t\t? currentDate\n\t\t\t: date;\n\t\tconst startDate = setMinutesOnDate(targetDate, startMinutes);\n\t\tconst endDate = setMinutesOnDate(targetDate, endMinutes);\n\t\tawait updateTodo(todo.id, {\n\t\t\tstartTime: startDate.toISOString(),\n\t\t\tendTime: endDate.toISOString(),\n\t\t});\n\t};\n\n\tconst handleWorkingPointerDown = (\n\t\tedge: \"start\" | \"end\",\n\t\tevent: React.PointerEvent<HTMLDivElement>,\n\t) => {\n\t\tevent.preventDefault();\n\t\tevent.stopPropagation();\n\t\tconst container = event.currentTarget.closest(\n\t\t\t\"[data-timeline-container]\",\n\t\t) as HTMLDivElement | null;\n\t\tif (!container) return;\n\n\t\tconst handleMove = (moveEvent: PointerEvent) => {\n\t\t\tconst rect = container.getBoundingClientRect();\n\t\t\tconst offset = moveEvent.clientY - rect.top;\n\t\t\tconst rawMinutes =\n\t\t\t\tdisplayStart +\n\t\t\t\tMath.round((offset / pxPerMinute) / MINUTES_PER_SLOT) * MINUTES_PER_SLOT;\n\t\t\tconst minutes = clampMinutes(rawMinutes, 0, 24 * 60);\n\t\t\tif (edge === \"start\") {\n\t\t\t\tsetWorkingStart(Math.min(minutes, workingEnd - MINUTES_PER_SLOT));\n\t\t\t} else {\n\t\t\t\tsetWorkingEnd(Math.max(minutes, workingStart + MINUTES_PER_SLOT));\n\t\t\t}\n\t\t};\n\n\t\tconst handleUp = () => {\n\t\t\twindow.removeEventListener(\"pointermove\", handleMove);\n\t\t\twindow.removeEventListener(\"pointerup\", handleUp);\n\t\t};\n\n\t\twindow.addEventListener(\"pointermove\", handleMove);\n\t\twindow.addEventListener(\"pointerup\", handleUp);\n\t};\n\n\treturn {\n\t\ttimelineAnchor,\n\t\tcreateMode,\n\t\ttimelineTitle,\n\t\ttimelineStart,\n\t\ttimelineEnd,\n\t\ttimelineDate,\n\t\ttimelinePreview,\n\t\tallDayPreview,\n\t\tsetTimelineTitle,\n\t\tsetTimelineStart,\n\t\tsetTimelineEnd,\n\t\topenTimelineCreateAt,\n\t\topenAllDayCreateAt,\n\t\tcloseTimelineCreate,\n\t\thandleCreateTimelineTodo,\n\t\thandleResize,\n\t\thandleWorkingPointerDown,\n\t};\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/chat/ChatPanel.tsx",
    "content": "\"use client\";\n\nimport { useTranslations } from \"next-intl\";\nimport { useCallback, useEffect, useMemo, useState } from \"react\";\nimport { BreakdownStageRenderer } from \"@/apps/chat/components/breakdown/BreakdownStageRenderer\";\nimport { ChatInputSection } from \"@/apps/chat/components/input/ChatInputSection\";\nimport { PromptSuggestions } from \"@/apps/chat/components/input/PromptSuggestions\";\nimport { HeaderBar } from \"@/apps/chat/components/layout/HeaderBar\";\nimport { HistoryDrawer } from \"@/apps/chat/components/layout/HistoryDrawer\";\nimport { MessageList } from \"@/apps/chat/components/message/MessageList\";\nimport { useBreakdownQuestionnaire } from \"@/apps/chat/hooks/useBreakdownQuestionnaire\";\nimport { useChatController } from \"@/apps/chat/hooks/useChatController\";\nimport { useChatStore } from \"@/lib/store/chat-store\";\nimport { useLocaleStore } from \"@/lib/store/locale\";\nimport { useTodoStore } from \"@/lib/store/todo-store\";\n\nexport function ChatPanel() {\n\tconst { locale } = useLocaleStore();\n\tconst tChat = useTranslations(\"chat\");\n\tconst tPage = useTranslations(\"page\");\n\n\t// 从 Zustand 获取 UI 状态\n\tconst { selectedTodoIds, clearTodoSelection, toggleTodoSelection } =\n\t\tuseTodoStore();\n\n\t// 获取 pendingPrompt（其他组件触发的待发送消息）\n\tconst { pendingPrompt, pendingNewChat, setPendingPrompt } = useChatStore();\n\n\t// 使用 Breakdown Questionnaire hook\n\tconst breakdownQuestionnaire = useBreakdownQuestionnaire();\n\n\t// 使用 Chat Controller hook\n\tconst chatController = useChatController({\n\t\tlocale,\n\t\tselectedTodoIds,\n\t});\n\n\t// 处理预设 Prompt 选择：直接发送消息（复用 sendMessage 逻辑）\n\tconst handleSelectPrompt = useCallback(\n\t\t(prompt: string) => {\n\t\t\tvoid chatController.sendMessage(prompt);\n\t\t},\n\t\t[chatController],\n\t);\n\n\t// 监听 pendingPrompt 变化，自动发送消息（由其他组件触发，如 TodoCard 的\"获取建议\"按钮）\n\tuseEffect(() => {\n\t\tif (pendingPrompt) {\n\t\t\t// 如果需要新开会话，先清空当前会话（keepStreaming=true 让旧的流式输出继续在后台运行）\n\t\t\tif (pendingNewChat) {\n\t\t\t\tchatController.handleNewChat(true);\n\t\t\t}\n\t\t\t// 使用 setTimeout 确保新会话状态已更新后再发送消息\n\t\t\tsetTimeout(() => {\n\t\t\t\tvoid chatController.sendMessage(pendingPrompt);\n\t\t\t}, 0);\n\t\t\t// 清空 pendingPrompt，避免重复发送\n\t\t\tsetPendingPrompt(null);\n\t\t}\n\t}, [pendingPrompt, pendingNewChat, chatController, setPendingPrompt]);\n\n\tconst [showTodosExpanded, setShowTodosExpanded] = useState(false);\n\n\tconst typingText = useMemo(() => tChat(\"aiThinking\"), [tChat]);\n\n\tconst formatMessageCount = useCallback(\n\t\t(count?: number) => tPage(\"messagesCount\", { count: count ?? 0 }),\n\t\t[tPage],\n\t);\n\n\t// 判断是否显示首页（用于在输入框上方显示建议按钮）\n\tconst shouldShowSuggestions = useMemo(() => {\n\t\tconst messages = chatController.messages;\n\t\tif (messages.length === 0) return true;\n\t\tif (messages.length === 1 && messages[0].role === \"assistant\") return true;\n\t\tif (messages.every((msg) => msg.role === \"assistant\")) return true;\n\t\treturn false;\n\t}, [chatController.messages]);\n\n\treturn (\n\t\t<div className=\"flex h-full flex-col bg-background\">\n\t\t\t<HeaderBar\n\t\t\t\tchatHistoryLabel={tPage(\"chatHistory\")}\n\t\t\t\tnewChatLabel={tPage(\"newChat\")}\n\t\t\t\tonToggleHistory={() =>\n\t\t\t\t\tchatController.setHistoryOpen(!chatController.historyOpen)\n\t\t\t\t}\n\t\t\t\tonNewChat={chatController.handleNewChat}\n\t\t\t/>\n\n\t\t\t{chatController.historyOpen && (\n\t\t\t\t<HistoryDrawer\n\t\t\t\t\thistoryLoading={chatController.historyLoading}\n\t\t\t\t\thistoryError={chatController.historyError}\n\t\t\t\t\tsessions={chatController.sessions}\n\t\t\t\t\tconversationId={chatController.conversationId}\n\t\t\t\t\tformatMessageCount={formatMessageCount}\n\t\t\t\t\tlabels={{\n\t\t\t\t\t\trecentSessions: tPage(\"recentSessions\"),\n\t\t\t\t\t\tnoHistory: tPage(\"noHistory\"),\n\t\t\t\t\t\tloading: tChat(\"loading\"),\n\t\t\t\t\t\tchatHistory: tPage(\"chatHistory\"),\n\t\t\t\t\t}}\n\t\t\t\t\tonSelectSession={chatController.handleLoadSession}\n\t\t\t\t/>\n\t\t\t)}\n\n\t\t\t<BreakdownStageRenderer\n\t\t\t\tstage={breakdownQuestionnaire.stage}\n\t\t\t\tquestions={breakdownQuestionnaire.questions}\n\t\t\t\tanswers={breakdownQuestionnaire.answers}\n\t\t\t\tsummary={breakdownQuestionnaire.summary}\n\t\t\t\tsubtasks={breakdownQuestionnaire.subtasks}\n\t\t\t\tbreakdownLoading={breakdownQuestionnaire.breakdownLoading}\n\t\t\t\tisGeneratingSummary={breakdownQuestionnaire.isGeneratingSummary}\n\t\t\t\tsummaryStreamingText={breakdownQuestionnaire.summaryStreamingText}\n\t\t\t\tisGeneratingQuestions={breakdownQuestionnaire.isGeneratingQuestions}\n\t\t\t\tquestionStreamingCount={breakdownQuestionnaire.questionStreamingCount}\n\t\t\t\tquestionStreamingTitle={breakdownQuestionnaire.questionStreamingTitle}\n\t\t\t\tbreakdownError={breakdownQuestionnaire.breakdownError}\n\t\t\t\tlocale={locale}\n\t\t\t\tonAnswerChange={breakdownQuestionnaire.setAnswer}\n\t\t\t\tonSubmit={breakdownQuestionnaire.handleSubmitAnswers}\n\t\t\t\tonAccept={breakdownQuestionnaire.handleAcceptBreakdown}\n\t\t\t/>\n\n\t\t\t{(breakdownQuestionnaire.stage === \"idle\" ||\n\t\t\t\tbreakdownQuestionnaire.stage === \"completed\") && (\n\t\t\t\t<MessageList\n\t\t\t\t\tmessages={chatController.messages}\n\t\t\t\t\tisStreaming={chatController.isStreaming}\n\t\t\t\t\ttypingText={typingText}\n\t\t\t\t\teffectiveTodos={chatController.effectiveTodos}\n\t\t\t\t/>\n\t\t\t)}\n\n\t\t\t{/* 首页时在输入框上方显示建议按钮 */}\n\t\t\t{shouldShowSuggestions &&\n\t\t\t\t(breakdownQuestionnaire.stage === \"idle\" ||\n\t\t\t\t\tbreakdownQuestionnaire.stage === \"completed\") && (\n\t\t\t\t\t<PromptSuggestions onSelect={handleSelectPrompt} className=\"pb-4\" />\n\t\t\t\t)}\n\n\t\t\t<ChatInputSection\n\t\t\t\tlocale={locale}\n\t\t\t\tinputValue={chatController.inputValue}\n\t\t\t\tisStreaming={chatController.isStreaming}\n\t\t\t\terror={chatController.error}\n\t\t\t\teffectiveTodos={chatController.effectiveTodos}\n\t\t\t\thasSelection={chatController.hasSelection}\n\t\t\t\tshowTodosExpanded={showTodosExpanded}\n\t\t\t\tonInputChange={chatController.setInputValue}\n\t\t\t\tonSend={chatController.handleSend}\n\t\t\t\tonStop={chatController.handleStop}\n\t\t\t\tonKeyDown={chatController.handleKeyDown}\n\t\t\t\tonCompositionStart={() => chatController.setIsComposing(true)}\n\t\t\t\tonCompositionEnd={() => chatController.setIsComposing(false)}\n\t\t\t\tonToggleExpand={() => setShowTodosExpanded((prev) => !prev)}\n\t\t\t\tonClearSelection={clearTodoSelection}\n\t\t\t\tonToggleTodo={toggleTodoSelection}\n\t\t\t/>\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/chat/components/breakdown/BreakdownStageRenderer.tsx",
    "content": "\"use client\";\n\nimport { Loader2 } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport { BreakdownSummary } from \"@/apps/chat/components/breakdown/BreakdownSummary\";\nimport { Questionnaire } from \"@/apps/chat/components/breakdown/Questionnaire\";\nimport { SummaryStreaming } from \"@/apps/chat/components/message/SummaryStreaming\";\nimport type { ParsedTodoTree } from \"@/apps/chat/types\";\nimport type { Question } from \"@/lib/store/breakdown-store\";\nimport type { Locale } from \"@/lib/store/locale\";\n\ntype BreakdownStageRendererProps = {\n\tstage: string;\n\tquestions: Question[];\n\tanswers: Record<string, string[]>;\n\tsummary: string | null;\n\tsubtasks: ParsedTodoTree[] | null;\n\tbreakdownLoading: boolean;\n\tisGeneratingSummary: boolean;\n\tsummaryStreamingText: string | null;\n\tisGeneratingQuestions: boolean;\n\tquestionStreamingCount: number;\n\tquestionStreamingTitle: string | null;\n\tbreakdownError: string | null;\n\tlocale: Locale;\n\tonAnswerChange: (questionId: string, options: string[]) => void;\n\tonSubmit: () => void;\n\tonAccept: () => void;\n};\n\nexport function BreakdownStageRenderer({\n\tstage,\n\tquestions,\n\tanswers,\n\tsummary,\n\tsubtasks,\n\tbreakdownLoading,\n\tisGeneratingSummary,\n\tsummaryStreamingText,\n\tisGeneratingQuestions,\n\tquestionStreamingCount,\n\tquestionStreamingTitle,\n\tbreakdownError,\n\tlocale,\n\tonAnswerChange,\n\tonSubmit,\n\tonAccept,\n}: BreakdownStageRendererProps) {\n\tconst tChat = useTranslations(\"chat\");\n\n\t// Breakdown功能：根据阶段显示不同内容\n\tif (stage === \"questionnaire\") {\n\t\tif (questions.length > 0) {\n\t\t\treturn (\n\t\t\t\t<Questionnaire\n\t\t\t\t\tquestions={questions}\n\t\t\t\t\tanswers={answers}\n\t\t\t\t\tonAnswerChange={onAnswerChange}\n\t\t\t\t\tonSubmit={onSubmit}\n\t\t\t\t\tisSubmitting={isGeneratingSummary}\n\t\t\t\t\tdisabled={isGeneratingSummary}\n\t\t\t\t/>\n\t\t\t);\n\t\t}\n\n\t\treturn (\n\t\t\t<div className=\"flex-1 flex items-center justify-center\">\n\t\t\t\t<div className=\"text-center space-y-3\">\n\t\t\t\t\t{isGeneratingQuestions && questionStreamingCount > 0 ? (\n\t\t\t\t\t\t<div className=\"flex flex-col items-center gap-2\">\n\t\t\t\t\t\t\t<div className=\"flex items-center gap-2 rounded-full bg-muted px-4 py-2 text-sm text-muted-foreground\">\n\t\t\t\t\t\t\t\t<Loader2 className=\"h-4 w-4 animate-spin\" />\n\t\t\t\t\t\t\t\t<span>\n\t\t\t\t\t\t\t\t\t{tChat(\"generatingQuestion\", {\n\t\t\t\t\t\t\t\t\t\tcount: questionStreamingCount,\n\t\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t{questionStreamingTitle && (\n\t\t\t\t\t\t\t\t<p className=\"text-sm text-foreground max-w-md\">\n\t\t\t\t\t\t\t\t\t{questionStreamingTitle}\n\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t) : (\n\t\t\t\t\t\t<p className=\"text-muted-foreground\">\n\t\t\t\t\t\t\t{tChat(\"generatingQuestions\")}\n\t\t\t\t\t\t</p>\n\t\t\t\t\t)}\n\t\t\t\t\t{breakdownError && (\n\t\t\t\t\t\t<p className=\"mt-2 text-sm text-destructive\">{breakdownError}</p>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t);\n\t}\n\n\t// 流式生成总结阶段\n\tif (isGeneratingSummary) {\n\t\treturn <SummaryStreaming streamingText={summaryStreamingText || \"\"} />;\n\t}\n\n\t// 总结展示阶段（生成完成后）\n\tif (stage === \"summary\" && summary && subtasks && !isGeneratingSummary) {\n\t\treturn (\n\t\t\t<BreakdownSummary\n\t\t\t\tsummary={summary}\n\t\t\t\tsubtasks={subtasks}\n\t\t\t\tonAccept={onAccept}\n\t\t\t\tisApplying={breakdownLoading}\n\t\t\t\tlocale={locale}\n\t\t\t/>\n\t\t);\n\t}\n\n\treturn null;\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/chat/components/breakdown/BreakdownSummary.tsx",
    "content": "\"use client\";\n\nimport { Check, Loader2 } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport ReactMarkdown from \"react-markdown\";\nimport type { ParsedTodoTree } from \"@/apps/chat/types\";\nimport type { Locale } from \"@/lib/store/locale\";\nimport { cn } from \"@/lib/utils\";\n\ninterface BreakdownSummaryProps {\n\tsummary: string;\n\tsubtasks: ParsedTodoTree[];\n\tonAccept: () => void;\n\tisApplying: boolean;\n\tlocale: Locale;\n}\n\nfunction SubtaskTree({\n\tsubtasks,\n\tdepth = 0,\n}: {\n\tsubtasks: ParsedTodoTree[];\n\tdepth?: number;\n}) {\n\treturn (\n\t\t<ul className={cn(\"space-y-2\", depth > 0 && \"ml-6 mt-2\")}>\n\t\t\t{subtasks.map((subtask) => (\n\t\t\t\t<li key={subtask.name} className=\"text-sm\">\n\t\t\t\t\t<div className=\"flex items-start gap-2\">\n\t\t\t\t\t\t<span className=\"mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-primary\" />\n\t\t\t\t\t\t<div className=\"flex-1\">\n\t\t\t\t\t\t\t<div className=\"font-medium\">{subtask.name}</div>\n\t\t\t\t\t\t\t{subtask.subtasks && subtask.subtasks.length > 0 && (\n\t\t\t\t\t\t\t\t<SubtaskTree subtasks={subtask.subtasks} depth={depth + 1} />\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</li>\n\t\t\t))}\n\t\t</ul>\n\t);\n}\n\nexport function BreakdownSummary({\n\tsummary,\n\tsubtasks,\n\tonAccept,\n\tisApplying,\n\tlocale: _locale,\n}: BreakdownSummaryProps) {\n\tconst t = useTranslations(\"chat\");\n\n\treturn (\n\t\t<div className=\"flex-1 overflow-y-auto px-4 py-4\">\n\t\t\t<div className=\"mx-auto max-w-2xl space-y-6\">\n\t\t\t\t<div className=\"rounded-lg bg-muted/50 p-4\">\n\t\t\t\t\t<h3 className=\"mb-2 text-lg font-semibold\">\n\t\t\t\t\t\t{t(\"breakdownSummary.title\")}\n\t\t\t\t\t</h3>\n\t\t\t\t\t<p className=\"text-sm text-muted-foreground\">\n\t\t\t\t\t\t{t(\"breakdownSummary.description\")}\n\t\t\t\t\t</p>\n\t\t\t\t</div>\n\n\t\t\t\t{/* 待办总结 */}\n\t\t\t\t<div className=\"rounded-lg border bg-card p-4 shadow-sm\">\n\t\t\t\t\t<h4 className=\"mb-3 text-base font-semibold\">\n\t\t\t\t\t\t{t(\"breakdownSummary.taskSummary\")}\n\t\t\t\t\t</h4>\n\t\t\t\t\t<div className=\"prose prose-sm max-w-none dark:prose-invert\">\n\t\t\t\t\t\t<ReactMarkdown>{summary}</ReactMarkdown>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\n\t\t\t\t{/* 子待办列表 */}\n\t\t\t\t<div className=\"rounded-lg border bg-card p-4 shadow-sm\">\n\t\t\t\t\t<h4 className=\"mb-3 text-base font-semibold\">\n\t\t\t\t\t\t{t(\"breakdownSummary.subtaskList\")}\n\t\t\t\t\t</h4>\n\t\t\t\t\t{subtasks.length > 0 ? (\n\t\t\t\t\t\t<SubtaskTree subtasks={subtasks} />\n\t\t\t\t\t) : (\n\t\t\t\t\t\t<p className=\"text-sm text-muted-foreground\">\n\t\t\t\t\t\t\t{t(\"breakdownSummary.noSubtasks\")}\n\t\t\t\t\t\t</p>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\n\t\t\t\t{/* 接收按钮 */}\n\t\t\t\t<div className=\"flex justify-end pt-4\">\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tonClick={onAccept}\n\t\t\t\t\t\tdisabled={isApplying}\n\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\"flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors\",\n\t\t\t\t\t\t\tisApplying\n\t\t\t\t\t\t\t\t? \"cursor-not-allowed opacity-50\"\n\t\t\t\t\t\t\t\t: \"hover:bg-primary/90\",\n\t\t\t\t\t\t)}\n\t\t\t\t\t>\n\t\t\t\t\t\t{isApplying ? (\n\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t<Loader2 className=\"h-4 w-4 animate-spin\" />\n\t\t\t\t\t\t\t\t{t(\"breakdownSummary.applying\")}\n\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t<Check className=\"h-4 w-4\" />\n\t\t\t\t\t\t\t\t{t(\"breakdownSummary.acceptAndApply\")}\n\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</button>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/chat/components/breakdown/Questionnaire.tsx",
    "content": "\"use client\";\n\nimport { Check, Edit2, Loader2 } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport { useMemo, useState } from \"react\";\nimport type { Question } from \"@/lib/store/breakdown-store\";\nimport { cn } from \"@/lib/utils\";\n\ninterface QuestionnaireProps {\n\tquestions: Question[];\n\tanswers: Record<string, string[]>;\n\tonAnswerChange: (questionId: string, options: string[]) => void;\n\tonSubmit: () => void;\n\tisSubmitting: boolean;\n\tdisabled?: boolean; // 提交后禁用整个组件\n}\n\nexport function Questionnaire({\n\tquestions,\n\tanswers,\n\tonAnswerChange,\n\tonSubmit,\n\tisSubmitting,\n\tdisabled = false,\n}: QuestionnaireProps) {\n\tconst t = useTranslations(\"chat\");\n\t// 管理每个问题的自定义答案和编辑状态\n\tconst [customAnswers, setCustomAnswers] = useState<Record<string, string>>(\n\t\t{},\n\t);\n\tconst [editingQuestionId, setEditingQuestionId] = useState<string | null>(\n\t\tnull,\n\t);\n\n\t// 计算已回答的问题数量（用于显示进度，但不影响提交）\n\tconst answeredCount = useMemo(() => {\n\t\treturn questions.filter((q) => {\n\t\t\tconst answer = answers[q.id];\n\t\t\tconst customAnswer = customAnswers[q.id];\n\t\t\t// 有标准答案或自定义答案都算已回答\n\t\t\treturn (\n\t\t\t\t(answer && answer.length > 0) ||\n\t\t\t\t(customAnswer && customAnswer.trim().length > 0)\n\t\t\t);\n\t\t}).length;\n\t}, [questions, answers, customAnswers]);\n\n\tconst SKIP_OPTION = \"不知道/不重要\";\n\tconst CUSTOM_PREFIX = \"custom:\";\n\n\tconst handleOptionToggle = (questionId: string, option: string) => {\n\t\tconst currentAnswers = answers[questionId] || [];\n\t\tconst question = questions.find((q) => q.id === questionId);\n\t\tif (!question) return;\n\n\t\t// 如果选择的是\"不知道/不重要\"，清除自定义答案\n\t\tif (option === SKIP_OPTION) {\n\t\t\t// 如果已经选中，则取消选择；否则选择它并清除其他选项和自定义答案\n\t\t\tif (currentAnswers.includes(SKIP_OPTION)) {\n\t\t\t\tonAnswerChange(questionId, []);\n\t\t\t} else {\n\t\t\t\tonAnswerChange(questionId, [SKIP_OPTION]);\n\t\t\t\t// 清除自定义答案\n\t\t\t\tsetCustomAnswers((prev) => {\n\t\t\t\t\tconst next = { ...prev };\n\t\t\t\t\tdelete next[questionId];\n\t\t\t\t\treturn next;\n\t\t\t\t});\n\t\t\t\tsetEditingQuestionId(null);\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\n\t\t// 如果当前已选择\"不知道/不重要\"或自定义答案，选择其他选项时先清除它们\n\t\tconst hasSkipOption = currentAnswers.includes(SKIP_OPTION);\n\t\tconst hasCustomAnswer = customAnswers[questionId];\n\t\tconst filteredAnswers = hasSkipOption\n\t\t\t? currentAnswers.filter((a) => a !== SKIP_OPTION)\n\t\t\t: currentAnswers;\n\n\t\t// 清除自定义答案\n\t\tif (hasCustomAnswer) {\n\t\t\tsetCustomAnswers((prev) => {\n\t\t\t\tconst next = { ...prev };\n\t\t\t\tdelete next[questionId];\n\t\t\t\treturn next;\n\t\t\t});\n\t\t\tsetEditingQuestionId(null);\n\t\t}\n\n\t\t// 默认多选：切换选项\n\t\tif (filteredAnswers.includes(option)) {\n\t\t\tonAnswerChange(\n\t\t\t\tquestionId,\n\t\t\t\tfilteredAnswers.filter((a) => a !== option),\n\t\t\t);\n\t\t} else {\n\t\t\tonAnswerChange(questionId, [...filteredAnswers, option]);\n\t\t}\n\t};\n\n\tconst handleCustomAnswerChange = (questionId: string, value: string) => {\n\t\tsetCustomAnswers((prev) => ({\n\t\t\t...prev,\n\t\t\t[questionId]: value,\n\t\t}));\n\t\t// 如果有自定义答案，清除标准答案和\"不知道/不重要\"\n\t\tif (value.trim().length > 0) {\n\t\t\tonAnswerChange(questionId, []);\n\t\t}\n\t};\n\n\tconst handleCustomAnswerSubmit = (questionId: string) => {\n\t\tconst customAnswer = customAnswers[questionId]?.trim();\n\t\tif (customAnswer && customAnswer.length > 0) {\n\t\t\t// 将自定义答案作为答案提交\n\t\t\tonAnswerChange(questionId, [`${CUSTOM_PREFIX}${customAnswer}`]);\n\t\t\tsetEditingQuestionId(null);\n\t\t}\n\t};\n\n\tconst hasCustomAnswer = (questionId: string): boolean => {\n\t\tconst answer = answers[questionId] || [];\n\t\treturn answer.some((a) => a.startsWith(CUSTOM_PREFIX));\n\t};\n\n\tconst getCustomAnswerText = (questionId: string): string => {\n\t\tconst answer = answers[questionId] || [];\n\t\tconst customAnswer = answer.find((a) => a.startsWith(CUSTOM_PREFIX));\n\t\tif (customAnswer) {\n\t\t\treturn customAnswer.substring(CUSTOM_PREFIX.length);\n\t\t}\n\t\treturn customAnswers[questionId] || \"\";\n\t};\n\n\tconst isSelected = (questionId: string, option: string): boolean => {\n\t\treturn (answers[questionId] || []).includes(option);\n\t};\n\n\treturn (\n\t\t<div className=\"flex-1 overflow-y-auto px-4 py-4\">\n\t\t\t<div className=\"mx-auto max-w-2xl space-y-6\">\n\t\t\t\t<div className=\"rounded-lg bg-muted/50 p-4\">\n\t\t\t\t\t<h3 className=\"mb-2 text-lg font-semibold\">{t(\"answerQuestions\")}</h3>\n\t\t\t\t\t<p className=\"text-sm text-muted-foreground\">\n\t\t\t\t\t\t{t(\"answerQuestionsDesc\")}\n\t\t\t\t\t</p>\n\t\t\t\t</div>\n\n\t\t\t\t{questions.map((question, index) => (\n\t\t\t\t\t<div\n\t\t\t\t\t\tkey={question.id}\n\t\t\t\t\t\tclassName=\"rounded-lg border bg-card p-4 shadow-sm\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<div className=\"mb-4\">\n\t\t\t\t\t\t\t<div className=\"mb-2 flex items-center gap-2\">\n\t\t\t\t\t\t\t\t<span className=\"flex h-6 w-6 items-center justify-center rounded-full bg-primary text-xs font-semibold text-primary-foreground\">\n\t\t\t\t\t\t\t\t\t{index + 1}\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t<h4 className=\"text-base font-medium\">{question.question}</h4>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<p className=\"ml-8 text-xs text-muted-foreground\">\n\t\t\t\t\t\t\t\t{t(\"multipleChoice\")}\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t<div className=\"ml-8 space-y-2\">\n\t\t\t\t\t\t\t{question.options.map((option) => {\n\t\t\t\t\t\t\t\tconst selected = isSelected(question.id, option);\n\t\t\t\t\t\t\t\tconst isSkipOption = option === SKIP_OPTION;\n\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\tkey={option}\n\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\tonClick={() =>\n\t\t\t\t\t\t\t\t\t\t\t!disabled && handleOptionToggle(question.id, option)\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\tdisabled={disabled}\n\t\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\t\"flex w-full items-center gap-3 rounded-md border p-3 text-left transition-colors\",\n\t\t\t\t\t\t\t\t\t\t\tselected\n\t\t\t\t\t\t\t\t\t\t\t\t? \"border-primary bg-primary/10 text-foreground\"\n\t\t\t\t\t\t\t\t\t\t\t\t: \"border-border bg-background hover:bg-muted\",\n\t\t\t\t\t\t\t\t\t\t\tdisabled && \"cursor-not-allowed opacity-50\",\n\t\t\t\t\t\t\t\t\t\t\tisSkipOption && \"border-dashed\",\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\t\t\"flex h-5 w-5 shrink-0 items-center justify-center rounded-sm border-2\",\n\t\t\t\t\t\t\t\t\t\t\t\tselected\n\t\t\t\t\t\t\t\t\t\t\t\t\t? \"border-primary bg-primary\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t: \"border-muted-foreground/50\",\n\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t{selected && (\n\t\t\t\t\t\t\t\t\t\t\t\t<Check className=\"h-3 w-3 text-primary-foreground\" />\n\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\t\t\"flex-1 text-sm\",\n\t\t\t\t\t\t\t\t\t\t\t\tisSkipOption && \"text-muted-foreground italic\",\n\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t{option}\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t{/* 添加\"不知道/不重要\"选项，支持内联编辑 */}\n\t\t\t\t\t\t\t{editingQuestionId === question.id ? (\n\t\t\t\t\t\t\t\t// 编辑模式：显示输入框\n\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-2 rounded-md border border-dashed border-primary bg-primary/5 p-3\">\n\t\t\t\t\t\t\t\t\t<div className=\"flex h-5 w-5 shrink-0 items-center justify-center rounded border-2 border-primary bg-primary\">\n\t\t\t\t\t\t\t\t\t\t<Edit2 className=\"h-3 w-3 text-primary-foreground\" />\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\t\t\tvalue={getCustomAnswerText(question.id)}\n\t\t\t\t\t\t\t\t\t\tonChange={(e) =>\n\t\t\t\t\t\t\t\t\t\t\thandleCustomAnswerChange(question.id, e.target.value)\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\tonBlur={() => {\n\t\t\t\t\t\t\t\t\t\t\tconst customAnswer = customAnswers[question.id]?.trim();\n\t\t\t\t\t\t\t\t\t\t\tif (customAnswer && customAnswer.length > 0) {\n\t\t\t\t\t\t\t\t\t\t\t\thandleCustomAnswerSubmit(question.id);\n\t\t\t\t\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\t\t\t\t\tsetEditingQuestionId(null);\n\t\t\t\t\t\t\t\t\t\t\t\tif (!hasCustomAnswer(question.id)) {\n\t\t\t\t\t\t\t\t\t\t\t\t\tsetCustomAnswers((prev) => {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tconst next = { ...prev };\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tdelete next[question.id];\n\t\t\t\t\t\t\t\t\t\t\t\t\t\treturn next;\n\t\t\t\t\t\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\tonKeyDown={(e) => {\n\t\t\t\t\t\t\t\t\t\t\tif (e.key === \"Enter\") {\n\t\t\t\t\t\t\t\t\t\t\t\te.preventDefault();\n\t\t\t\t\t\t\t\t\t\t\t\tconst customAnswer = customAnswers[question.id]?.trim();\n\t\t\t\t\t\t\t\t\t\t\t\tif (customAnswer && customAnswer.length > 0) {\n\t\t\t\t\t\t\t\t\t\t\t\t\thandleCustomAnswerSubmit(question.id);\n\t\t\t\t\t\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\t\t\t\t\t\tsetEditingQuestionId(null);\n\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\tif (e.key === \"Escape\") {\n\t\t\t\t\t\t\t\t\t\t\t\tsetEditingQuestionId(null);\n\t\t\t\t\t\t\t\t\t\t\t\tif (!hasCustomAnswer(question.id)) {\n\t\t\t\t\t\t\t\t\t\t\t\t\tsetCustomAnswers((prev) => {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tconst next = { ...prev };\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tdelete next[question.id];\n\t\t\t\t\t\t\t\t\t\t\t\t\t\treturn next;\n\t\t\t\t\t\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\tplaceholder={t(\"customAnswerPlaceholder\")}\n\t\t\t\t\t\t\t\t\t\tdisabled={disabled}\n\t\t\t\t\t\t\t\t\t\t// biome-ignore lint/a11y/noAutofocus: 自定义输入框需要自动聚焦以提升用户体验\n\t\t\t\t\t\t\t\t\t\tautoFocus\n\t\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\t\"flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none\",\n\t\t\t\t\t\t\t\t\t\t\tdisabled && \"cursor-not-allowed opacity-50\",\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t// 显示模式：显示\"不知道/不重要\"选项和编辑按钮\n\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\tonClick={() =>\n\t\t\t\t\t\t\t\t\t\t\t!disabled && handleOptionToggle(question.id, SKIP_OPTION)\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\tdisabled={disabled}\n\t\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\t\"flex flex-1 items-center gap-3 rounded-md border border-dashed p-3 text-left transition-colors\",\n\t\t\t\t\t\t\t\t\t\t\tisSelected(question.id, SKIP_OPTION)\n\t\t\t\t\t\t\t\t\t\t\t\t? \"border-primary bg-primary/10 text-foreground\"\n\t\t\t\t\t\t\t\t\t\t\t\t: \"border-muted-foreground/50 bg-background hover:bg-muted\",\n\t\t\t\t\t\t\t\t\t\t\tdisabled && \"cursor-not-allowed opacity-50\",\n\t\t\t\t\t\t\t\t\t\t\thasCustomAnswer(question.id) && \"border-primary/50\",\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\t\t\"flex h-5 w-5 shrink-0 items-center justify-center rounded-sm border-2\",\n\t\t\t\t\t\t\t\t\t\t\t\tisSelected(question.id, SKIP_OPTION)\n\t\t\t\t\t\t\t\t\t\t\t\t\t? \"border-primary bg-primary\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t: hasCustomAnswer(question.id)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t? \"border-primary/50 bg-primary/5\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t: \"border-muted-foreground/50\",\n\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t{isSelected(question.id, SKIP_OPTION) && (\n\t\t\t\t\t\t\t\t\t\t\t\t<Check className=\"h-3 w-3 text-primary-foreground\" />\n\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t{hasCustomAnswer(question.id) &&\n\t\t\t\t\t\t\t\t\t\t\t\t!isSelected(question.id, SKIP_OPTION) && (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<Edit2 className=\"h-3 w-3 text-primary\" />\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\t\t\"flex-1 text-sm\",\n\t\t\t\t\t\t\t\t\t\t\t\tisSelected(question.id, SKIP_OPTION)\n\t\t\t\t\t\t\t\t\t\t\t\t\t? \"text-muted-foreground italic\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t: hasCustomAnswer(question.id)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t? \"text-foreground\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t: \"text-muted-foreground italic\",\n\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t{hasCustomAnswer(question.id)\n\t\t\t\t\t\t\t\t\t\t\t\t? getCustomAnswerText(question.id)\n\t\t\t\t\t\t\t\t\t\t\t\t: SKIP_OPTION}\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t{/* 编辑按钮 */}\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\t\tif (!disabled) {\n\t\t\t\t\t\t\t\t\t\t\t\tsetEditingQuestionId(question.id);\n\t\t\t\t\t\t\t\t\t\t\t\t// 如果当前有标准答案，清除它们\n\t\t\t\t\t\t\t\t\t\t\t\tif (\n\t\t\t\t\t\t\t\t\t\t\t\t\tanswers[question.id] &&\n\t\t\t\t\t\t\t\t\t\t\t\t\tanswers[question.id].length > 0 &&\n\t\t\t\t\t\t\t\t\t\t\t\t\t!hasCustomAnswer(question.id)\n\t\t\t\t\t\t\t\t\t\t\t\t) {\n\t\t\t\t\t\t\t\t\t\t\t\t\tonAnswerChange(question.id, []);\n\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\t// 如果没有自定义答案，初始化一个空字符串\n\t\t\t\t\t\t\t\t\t\t\t\tif (!customAnswers[question.id]) {\n\t\t\t\t\t\t\t\t\t\t\t\t\tsetCustomAnswers((prev) => ({\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t...prev,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t[question.id]: \"\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t}));\n\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\tdisabled={disabled}\n\t\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\t\"flex h-10 w-10 shrink-0 items-center justify-center rounded-md border border-border bg-background transition-colors hover:bg-muted\",\n\t\t\t\t\t\t\t\t\t\t\tdisabled && \"cursor-not-allowed opacity-50\",\n\t\t\t\t\t\t\t\t\t\t\thasCustomAnswer(question.id) &&\n\t\t\t\t\t\t\t\t\t\t\t\t\"border-primary bg-primary/10\",\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\ttitle={t(\"customAnswer\")}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<Edit2 className=\"h-4 w-4 text-muted-foreground\" />\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t))}\n\n\t\t\t\t<div className=\"flex items-center justify-between pt-4\">\n\t\t\t\t\t{/* 显示回答进度（可选） */}\n\t\t\t\t\t<span className=\"text-sm text-muted-foreground\">\n\t\t\t\t\t\t{t(\"answeredProgress\", {\n\t\t\t\t\t\t\tanswered: answeredCount,\n\t\t\t\t\t\t\ttotal: questions.length,\n\t\t\t\t\t\t})}\n\t\t\t\t\t</span>\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tonClick={onSubmit}\n\t\t\t\t\t\tdisabled={isSubmitting}\n\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\"flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors\",\n\t\t\t\t\t\t\tisSubmitting\n\t\t\t\t\t\t\t\t? \"cursor-not-allowed opacity-50\"\n\t\t\t\t\t\t\t\t: \"hover:bg-primary/90\",\n\t\t\t\t\t\t)}\n\t\t\t\t\t>\n\t\t\t\t\t\t{isSubmitting ? (\n\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t<Loader2 className=\"h-4 w-4 animate-spin\" />\n\t\t\t\t\t\t\t\t{t(\"submitting\")}\n\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\tt(\"submitAnswer\")\n\t\t\t\t\t\t)}\n\t\t\t\t\t</button>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/chat/components/input/ChatInputSection.tsx",
    "content": "\"use client\";\n\nimport { useTranslations } from \"next-intl\";\nimport { useRef } from \"react\";\nimport { InputBox } from \"@/apps/chat/components/input/InputBox\";\nimport { LinkedTodos } from \"@/apps/chat/components/input/LinkedTodos\";\nimport { ToolSelector } from \"@/apps/chat/components/input/ToolSelector\";\nimport type { Todo } from \"@/lib/types\";\n\ntype ChatInputSectionProps = {\n\tlocale: string;\n\tinputValue: string;\n\tisStreaming: boolean;\n\terror: string | null;\n\teffectiveTodos: Todo[];\n\thasSelection: boolean;\n\tshowTodosExpanded: boolean;\n\tonInputChange: (value: string) => void;\n\tonSend: () => void;\n\tonStop?: () => void;\n\tonKeyDown: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void;\n\tonCompositionStart: () => void;\n\tonCompositionEnd: () => void;\n\tonToggleExpand: () => void;\n\tonClearSelection: () => void;\n\tonToggleTodo: (todoId: number) => void;\n};\n\nexport function ChatInputSection({\n\tlocale,\n\tinputValue,\n\tisStreaming,\n\terror,\n\teffectiveTodos,\n\thasSelection,\n\tshowTodosExpanded,\n\tonInputChange,\n\tonSend,\n\tonStop,\n\tonKeyDown,\n\tonCompositionStart,\n\tonCompositionEnd,\n\tonToggleExpand,\n\tonClearSelection,\n\tonToggleTodo,\n}: ChatInputSectionProps) {\n\tconst tPage = useTranslations(\"page\");\n\tconst modeMenuRef = useRef<HTMLDivElement | null>(null);\n\tconst inputPlaceholder = tPage(\"chatInputPlaceholder\");\n\n\treturn (\n\t\t<div className=\"bg-background p-4\">\n\t\t\t<InputBox\n\t\t\t\tlinkedTodos={\n\t\t\t\t\t<LinkedTodos\n\t\t\t\t\t\teffectiveTodos={effectiveTodos}\n\t\t\t\t\t\thasSelection={hasSelection}\n\t\t\t\t\t\tlocale={locale}\n\t\t\t\t\t\tshowTodosExpanded={showTodosExpanded}\n\t\t\t\t\t\tonToggleExpand={onToggleExpand}\n\t\t\t\t\t\tonClearSelection={onClearSelection}\n\t\t\t\t\t\tonToggleTodo={onToggleTodo}\n\t\t\t\t\t/>\n\t\t\t\t}\n\t\t\t\tmodeSwitcher={\n\t\t\t\t\t<div className=\"flex items-center gap-2\" ref={modeMenuRef}>\n\t\t\t\t\t\t<ToolSelector disabled={isStreaming} />\n\t\t\t\t\t</div>\n\t\t\t\t}\n\t\t\t\tinputValue={inputValue}\n\t\t\t\tplaceholder={inputPlaceholder}\n\t\t\t\tisStreaming={isStreaming}\n\t\t\t\tlocale={locale}\n\t\t\t\tonChange={onInputChange}\n\t\t\t\tonSend={onSend}\n\t\t\t\tonStop={onStop}\n\t\t\t\tonKeyDown={onKeyDown}\n\t\t\t\tonCompositionStart={onCompositionStart}\n\t\t\t\tonCompositionEnd={onCompositionEnd}\n\t\t\t/>\n\n\t\t\t{error && <p className=\"mt-2 text-sm\">{error}</p>}\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/chat/components/input/InputBox.tsx",
    "content": "import { AtSign, Send, Square } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport type React from \"react\";\nimport { useCallback, useEffect, useLayoutEffect, useRef } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\ntype InputBoxProps = {\n\tinputValue: string;\n\tplaceholder: string;\n\tisStreaming: boolean;\n\tlocale: string;\n\tonChange: (value: string) => void;\n\tonSend: () => void;\n\tonStop?: () => void;\n\tonKeyDown: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void;\n\tonCompositionStart: () => void;\n\tonCompositionEnd: () => void;\n\tmodeSwitcher?: React.ReactNode;\n\t/** Mode Switcher 菜单是否打开 */\n\tmodeMenuOpen?: boolean;\n\tonAtClick?: () => void;\n\tlinkedTodos?: React.ReactNode;\n\t/** 最大高度，默认为 \"40vh\"（视口高度的40%） */\n\tmaxHeight?: string;\n};\n\n/** textarea 的最小行高（像素） */\nconst MIN_TEXTAREA_HEIGHT = 24;\n/** 单行模式下 textarea 的行数 */\nconst SINGLE_LINE_ROWS = 1;\n/** 多行模式下 textarea 的默认行数 */\nconst MULTI_LINE_ROWS = 1;\n\nexport function InputBox({\n\tinputValue,\n\tplaceholder,\n\tisStreaming,\n\tonChange,\n\tonSend,\n\tonStop,\n\tonKeyDown,\n\tonCompositionStart,\n\tonCompositionEnd,\n\tmodeSwitcher,\n\tmodeMenuOpen = false,\n\tonAtClick,\n\tlinkedTodos,\n\tmaxHeight = \"40vh\",\n}: InputBoxProps) {\n\tconst t = useTranslations(\"chat\");\n\tconst isSendDisabled = !inputValue.trim() || isStreaming;\n\tconst textareaRef = useRef<HTMLTextAreaElement>(null);\n\tconst prevInputValueRef = useRef<string>(inputValue);\n\n\t// 判断是否使用单行紧凑布局：Mode Switcher 菜单没打开的时候使用它\n\tconst isCompactLayout = !modeMenuOpen;\n\t// 判断是否需要显示 Mode Switcher（作为 modeSwitcher 存在）\n\tconst hasModeSwitcher = !!modeSwitcher;\n\t// 展开模式：有 modeSwitcher 且菜单打开时\n\tconst isExpandedLayout = hasModeSwitcher && modeMenuOpen;\n\n\t/** 自动调整 textarea 高度 */\n\tconst adjustHeight = useCallback(() => {\n\t\tconst textarea = textareaRef.current;\n\t\tif (!textarea) return;\n\n\t\t// 先重置高度以获取正确的 scrollHeight\n\t\ttextarea.style.height = \"auto\";\n\t\t// 设置新高度，scrollHeight 会给出实际内容需要的高度\n\t\tconst newHeight = Math.max(textarea.scrollHeight, MIN_TEXTAREA_HEIGHT);\n\t\ttextarea.style.height = `${newHeight}px`;\n\t}, []);\n\n\t// 当 inputValue 从外部改变时（非用户输入）调整高度\n\tuseLayoutEffect(() => {\n\t\tif (prevInputValueRef.current !== inputValue) {\n\t\t\tprevInputValueRef.current = inputValue;\n\t\t\tadjustHeight();\n\t\t}\n\t});\n\n\t// 组件挂载时调整高度\n\tuseEffect(() => {\n\t\tadjustHeight();\n\t}, [adjustHeight]);\n\n\t// 当布局模式改变时重新调整高度（使用 requestAnimationFrame 确保 DOM 更新后再调整）\n\t// biome-ignore lint/correctness/useExhaustiveDependencies: isCompactLayout 用于触发布局变化时的高度重新计算\n\tuseEffect(() => {\n\t\trequestAnimationFrame(adjustHeight);\n\t}, [isCompactLayout, adjustHeight]);\n\n\t// 处理输入变化\n\tconst handleChange = useCallback(\n\t\t(e: React.ChangeEvent<HTMLTextAreaElement>) => {\n\t\t\tonChange(e.target.value);\n\t\t\t// 使用 requestAnimationFrame 确保 DOM 更新后再调整高度\n\t\t\trequestAnimationFrame(() => {\n\t\t\t\tadjustHeight();\n\t\t\t});\n\t\t},\n\t\t[onChange, adjustHeight],\n\t);\n\n\t// 右侧按钮组（@ 按钮和发送/停止按钮）\n\tconst actionButtons = (\n\t\t<div className=\"flex items-center gap-1\">\n\t\t\t<button\n\t\t\t\ttype=\"button\"\n\t\t\t\tonClick={onAtClick}\n\t\t\t\tclassName={cn(\n\t\t\t\t\t\"flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground\",\n\t\t\t\t\t\"hover:bg-foreground/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n\t\t\t\t)}\n\t\t\t\taria-label={t(\"mentionFileOrTodo\")}\n\t\t\t>\n\t\t\t\t<AtSign className=\"h-4 w-4\" />\n\t\t\t</button>\n\n\t\t\t{isStreaming && onStop ? (\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tonClick={onStop}\n\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\"flex h-8 w-8 items-center justify-center rounded-lg\",\n\t\t\t\t\t\t\"bg-primary text-primary-foreground transition-colors\",\n\t\t\t\t\t\t\"hover:bg-primary/90\",\n\t\t\t\t\t\t\"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n\t\t\t\t\t)}\n\t\t\t\t\taria-label={t(\"stop\")}\n\t\t\t\t>\n\t\t\t\t\t<Square className=\"h-4 w-4 fill-current\" />\n\t\t\t\t</button>\n\t\t\t) : (\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tonClick={onSend}\n\t\t\t\t\tdisabled={isSendDisabled}\n\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\"flex h-8 w-8 items-center justify-center rounded-lg\",\n\t\t\t\t\t\t\"bg-primary text-primary-foreground transition-colors\",\n\t\t\t\t\t\t\"hover:bg-primary/90\",\n\t\t\t\t\t\t\"disabled:cursor-not-allowed disabled:opacity-50\",\n\t\t\t\t\t\t\"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n\t\t\t\t\t)}\n\t\t\t\t\taria-label={t(\"send\")}\n\t\t\t\t>\n\t\t\t\t\t<Send className=\"h-4 w-4\" />\n\t\t\t\t</button>\n\t\t\t)}\n\t\t</div>\n\t);\n\n\t// 紧凑布局：输入框和按钮在同一行\n\tif (isCompactLayout && !isExpandedLayout) {\n\t\treturn (\n\t\t\t<div\n\t\t\t\tclassName={cn(\n\t\t\t\t\t\"flex flex-col rounded-xl border border-border\",\n\t\t\t\t\t\"bg-background/60 px-3 py-2 mb-4\",\n\t\t\t\t)}\n\t\t\t>\n\t\t\t\t{/* 关联待办区域 */}\n\t\t\t\t{linkedTodos}\n\n\t\t\t\t{/* 单行布局：输入框和按钮在同一行 */}\n\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t{/* 左侧：mode switcher */}\n\t\t\t\t\t{modeSwitcher && (\n\t\t\t\t\t\t<div className=\"shrink-0\">{modeSwitcher}</div>\n\t\t\t\t\t)}\n\n\t\t\t\t\t{/* 中间：输入框 */}\n\t\t\t\t\t<textarea\n\t\t\t\t\t\tref={textareaRef}\n\t\t\t\t\t\tvalue={inputValue}\n\t\t\t\t\t\tonChange={handleChange}\n\t\t\t\t\t\tonCompositionStart={onCompositionStart}\n\t\t\t\t\t\tonCompositionEnd={onCompositionEnd}\n\t\t\t\t\t\tonKeyDown={onKeyDown}\n\t\t\t\t\t\tplaceholder={placeholder}\n\t\t\t\t\t\trows={SINGLE_LINE_ROWS}\n\t\t\t\t\t\tstyle={{ maxHeight, minHeight: `${MIN_TEXTAREA_HEIGHT}px` }}\n\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\"flex-1 resize-none bg-transparent text-sm text-foreground placeholder:text-muted-foreground\",\n\t\t\t\t\t\t\t\"focus-visible:outline-none overflow-y-auto leading-relaxed\",\n\t\t\t\t\t\t)}\n\t\t\t\t\t/>\n\n\t\t\t\t\t{/* 右侧：按钮组 */}\n\t\t\t\t\t{actionButtons}\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t);\n\t}\n\n\t// 展开布局：输入框在上方，工具栏在下方\n\treturn (\n\t\t<div\n\t\t\tclassName={cn(\n\t\t\t\t\"relative flex flex-col rounded-xl border border-border\",\n\t\t\t\t\"bg-background/60 px-3 pt-2 pb-14\",\n\t\t\t)}\n\t\t>\n\t\t\t{/* 关联待办区域 */}\n\t\t\t{linkedTodos}\n\n\t\t\t<textarea\n\t\t\t\tref={textareaRef}\n\t\t\t\tvalue={inputValue}\n\t\t\t\tonChange={handleChange}\n\t\t\t\tonCompositionStart={onCompositionStart}\n\t\t\t\tonCompositionEnd={onCompositionEnd}\n\t\t\t\tonKeyDown={onKeyDown}\n\t\t\t\tplaceholder={placeholder}\n\t\t\t\trows={MULTI_LINE_ROWS}\n\t\t\t\tstyle={{ maxHeight, minHeight: `${MIN_TEXTAREA_HEIGHT}px` }}\n\t\t\t\tclassName={cn(\n\t\t\t\t\t\"w-full resize-none bg-transparent text-sm text-foreground placeholder:text-muted-foreground\",\n\t\t\t\t\t\"focus-visible:outline-none overflow-y-auto leading-relaxed\",\n\t\t\t\t)}\n\t\t\t/>\n\n\t\t\t{/* 底部工具栏 - 绝对定位在底部 */}\n\t\t\t<div className=\"absolute bottom-2 left-3 right-3 flex items-center justify-between\">\n\t\t\t\t{/* 左下角：mode switcher */}\n\t\t\t\t<div className=\"flex items-center\">{modeSwitcher}</div>\n\n\t\t\t\t{/* 右下角：按钮组 */}\n\t\t\t\t{actionButtons}\n\t\t\t</div>\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/chat/components/input/LinkedTodos.tsx",
    "content": "import { useTranslations } from \"next-intl\";\nimport type { Todo } from \"@/lib/types\";\n\ntype LinkedTodosProps = {\n\teffectiveTodos: Todo[];\n\thasSelection: boolean;\n\tlocale: string;\n\tshowTodosExpanded: boolean;\n\tonToggleExpand: () => void;\n\tonClearSelection: () => void;\n\tonToggleTodo: (id: number) => void;\n};\n\nexport function LinkedTodos({\n\teffectiveTodos,\n\thasSelection,\n\tshowTodosExpanded,\n\tonToggleExpand,\n\tonClearSelection,\n\tonToggleTodo,\n}: LinkedTodosProps) {\n\tconst t = useTranslations(\"chat\");\n\t// 没有关联待办时，不显示任何内容\n\tif (effectiveTodos.length === 0) {\n\t\treturn null;\n\t}\n\n\tconst previewTodos = showTodosExpanded\n\t\t? effectiveTodos\n\t\t: effectiveTodos.slice(0, 3);\n\tconst hiddenCount = Math.max(0, effectiveTodos.length - previewTodos.length);\n\n\treturn (\n\t\t<div className=\"flex flex-wrap items-center gap-2 pb-2 mb-2 border-b border-border/70\">\n\t\t\t<span className=\"text-xs font-semibold text-foreground\">\n\t\t\t\t{t(\"linkedTodos\", { count: effectiveTodos.length })}\n\t\t\t</span>\n\t\t\t{previewTodos.map((todo) => (\n\t\t\t\t<button\n\t\t\t\t\tkey={todo.id}\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tonClick={() => onToggleTodo(todo.id)}\n\t\t\t\t\tclassName=\"rounded-full border border-border/70 bg-card/80 px-2 py-1 text-xs text-foreground hover:bg-accent hover:border-primary/40 transition-colors cursor-pointer\"\n\t\t\t\t>\n\t\t\t\t\t{todo.name}\n\t\t\t\t</button>\n\t\t\t))}\n\t\t\t{hiddenCount > 0 && (\n\t\t\t\t<span className=\"text-xs text-muted-foreground\">+{hiddenCount}</span>\n\t\t\t)}\n\t\t\t{effectiveTodos.length > 3 && (\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tonClick={onToggleExpand}\n\t\t\t\t\tclassName=\"text-[11px] text-muted-foreground transition-colors hover:text-foreground\"\n\t\t\t\t>\n\t\t\t\t\t{showTodosExpanded ? t(\"collapse\") : t(\"expand\")}\n\t\t\t\t</button>\n\t\t\t)}\n\t\t\t{hasSelection && (\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tonClick={onClearSelection}\n\t\t\t\t\tclassName=\"text-[11px] text-[oklch(var(--primary))] transition-colors hover:text-[oklch(var(--primary-border))]\"\n\t\t\t\t>\n\t\t\t\t\t{t(\"clearSelection\")}\n\t\t\t\t</button>\n\t\t\t)}\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/chat/components/input/PromptSuggestions.tsx",
    "content": "\"use client\";\n\nimport { Hammer, Sparkles, TrendingUp } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport { useCallback } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\ntype PromptSuggestion = {\n\tid: string;\n\ticon: React.ComponentType<{ className?: string }>;\n\tlabel: string;\n\tprompt: string;\n};\n\ntype PromptSuggestionsProps = {\n\tonSelect: (prompt: string) => void;\n\tclassName?: string;\n};\n\nexport function PromptSuggestions({\n\tonSelect,\n\tclassName,\n}: PromptSuggestionsProps) {\n\tconst t = useTranslations(\"chat\");\n\n\tconst suggestions: PromptSuggestion[] = [\n\t\t{\n\t\t\tid: \"breakdown\",\n\t\t\ticon: Hammer,\n\t\t\tlabel: t(\"suggestions.breakdown\"),\n\t\t\tprompt: t(\"suggestions.breakdownPrompt\"),\n\t\t},\n\t\t{\n\t\t\tid: \"priority\",\n\t\t\ticon: TrendingUp,\n\t\t\tlabel: t(\"suggestions.priority\"),\n\t\t\tprompt: t(\"suggestions.priorityPrompt\"),\n\t\t},\n\t\t{\n\t\t\tid: \"advice\",\n\t\t\ticon: Sparkles,\n\t\t\tlabel: t(\"suggestions.advice\"),\n\t\t\tprompt: t(\"suggestions.advicePrompt\"),\n\t\t},\n\t];\n\n\tconst handleClick = useCallback(\n\t\t(prompt: string) => {\n\t\t\tonSelect(prompt);\n\t\t},\n\t\t[onSelect],\n\t);\n\n\treturn (\n\t\t<div className={cn(\"flex flex-wrap justify-center gap-3 px-4\", className)}>\n\t\t\t{suggestions.map((suggestion) => {\n\t\t\t\tconst Icon = suggestion.icon;\n\t\t\t\treturn (\n\t\t\t\t\t<button\n\t\t\t\t\t\tkey={suggestion.id}\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tonClick={() => handleClick(suggestion.prompt)}\n\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\"flex items-center gap-2 rounded-full px-4 py-2.5\",\n\t\t\t\t\t\t\t\"bg-[oklch(var(--primary-weak))] hover:bg-[oklch(var(--primary-weak-hover))]\",\n\t\t\t\t\t\t\t\"text-sm font-medium text-foreground\",\n\t\t\t\t\t\t\t\"border border-[oklch(var(--primary-border))]/30 hover:border-[oklch(var(--primary-border))]/60\",\n\t\t\t\t\t\t\t\"transition-all duration-200\",\n\t\t\t\t\t\t\t\"shadow-sm hover:shadow-md\",\n\t\t\t\t\t\t)}\n\t\t\t\t\t>\n\t\t\t\t\t\t<Icon className=\"h-4 w-4 text-[oklch(var(--primary))]\" />\n\t\t\t\t\t\t<span>{suggestion.label}</span>\n\t\t\t\t\t</button>\n\t\t\t\t);\n\t\t\t})}\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/chat/components/input/ToolSelector.tsx",
    "content": "\"use client\";\n\nimport { Check, Globe, Terminal, Wrench } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { useUiStore } from \"@/lib/store/ui-store\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * FreeTodo 工具列表定义\n * 基于 FreeTodoToolkit 的 14 个工具\n */\nconst FREETODO_TOOLS = [\n\t// Todo 管理工具（6个）\n\t{ id: \"create_todo\", category: \"todo\" },\n\t{ id: \"complete_todo\", category: \"todo\" },\n\t{ id: \"update_todo\", category: \"todo\" },\n\t{ id: \"list_todos\", category: \"todo\" },\n\t{ id: \"search_todos\", category: \"todo\" },\n\t{ id: \"delete_todo\", category: \"todo\" },\n\t// 任务拆解工具（1个）\n\t{ id: \"breakdown_task\", category: \"breakdown\" },\n\t// 时间解析工具（1个）\n\t{ id: \"parse_time\", category: \"time\" },\n\t// 冲突检测工具（1个）\n\t{ id: \"check_schedule_conflict\", category: \"conflict\" },\n\t// 统计分析工具（2个）\n\t{ id: \"get_todo_stats\", category: \"stats\" },\n\t{ id: \"get_overdue_todos\", category: \"stats\" },\n\t// 标签管理工具（3个）\n\t{ id: \"list_tags\", category: \"tags\" },\n\t{ id: \"get_todos_by_tag\", category: \"tags\" },\n\t{ id: \"suggest_tags\", category: \"tags\" },\n] as const;\n\n/**\n * 外部工具列表定义\n * 分为搜索类和本地类两大类\n */\nconst EXTERNAL_TOOLS = [\n\t// 搜索类工具\n\t{ id: \"websearch\", category: \"search\" }, // 通用网页搜索（Auto 模式）\n\t{ id: \"hackernews\", category: \"search\" }, // Hacker News\n\t// 本地类工具\n\t{ id: \"file\", category: \"local\" }, // 文件操作（需要 workspace_path）\n\t{ id: \"local_fs\", category: \"local\" }, // 简化文件写入\n\t{ id: \"shell\", category: \"local\" }, // 命令行执行\n\t{ id: \"sleep\", category: \"local\" }, // 暂停执行\n] as const;\n\ninterface ToolSelectorProps {\n\t/** 是否禁用 */\n\tdisabled?: boolean;\n}\n\n/**\n * Agno 模式工具选择器组件\n * 显示为一个按钮，点击后展开多选下拉框\n * 支持 FreeTodo 工具和外部工具（如 DuckDuckGo 搜索）\n */\nexport function ToolSelector({ disabled = false }: ToolSelectorProps) {\n\tconst tChat = useTranslations(\"chat\");\n\tconst tToolCall = useTranslations(\"chat.toolCall\");\n\tconst [isOpen, setIsOpen] = useState(false);\n\tconst dropdownRef = useRef<HTMLDivElement>(null);\n\n\t// FreeTodo 工具状态\n\tconst selectedAgnoTools = useUiStore((state) => state.selectedAgnoTools);\n\tconst setSelectedAgnoTools = useUiStore(\n\t\t(state) => state.setSelectedAgnoTools,\n\t);\n\n\t// 外部工具状态\n\tconst selectedExternalTools = useUiStore(\n\t\t(state) => state.selectedExternalTools,\n\t);\n\tconst setSelectedExternalTools = useUiStore(\n\t\t(state) => state.setSelectedExternalTools,\n\t);\n\n\t// 点击外部关闭下拉框\n\tuseEffect(() => {\n\t\tif (!isOpen) return;\n\n\t\tconst handleClickOutside = (event: MouseEvent) => {\n\t\t\tif (\n\t\t\t\tdropdownRef.current &&\n\t\t\t\t!dropdownRef.current.contains(event.target as Node)\n\t\t\t) {\n\t\t\t\tsetIsOpen(false);\n\t\t\t}\n\t\t};\n\n\t\tdocument.addEventListener(\"mousedown\", handleClickOutside);\n\t\treturn () => document.removeEventListener(\"mousedown\", handleClickOutside);\n\t}, [isOpen]);\n\n\t// FreeTodo 工具切换\n\tconst handleToggleFreetodoTool = (toolId: string) => {\n\t\tconst newTools = selectedAgnoTools.includes(toolId)\n\t\t\t? selectedAgnoTools.filter((id) => id !== toolId)\n\t\t\t: [...selectedAgnoTools, toolId];\n\t\tconsole.log(\"[ToolSelector] Toggling FreeTodo tool:\", toolId);\n\t\tconsole.log(\"[ToolSelector] New selectedAgnoTools:\", newTools);\n\t\tsetSelectedAgnoTools(newTools);\n\t};\n\n\t// 外部工具切换\n\tconst handleToggleExternalTool = (toolId: string) => {\n\t\tconst newTools = selectedExternalTools.includes(toolId)\n\t\t\t? selectedExternalTools.filter((id) => id !== toolId)\n\t\t\t: [...selectedExternalTools, toolId];\n\t\tconsole.log(\"[ToolSelector] Toggling external tool:\", toolId);\n\t\tconsole.log(\"[ToolSelector] New selectedExternalTools:\", newTools);\n\t\tsetSelectedExternalTools(newTools);\n\t};\n\n\tconst handleSelectAllFreetodo = () => {\n\t\tsetSelectedAgnoTools(FREETODO_TOOLS.map((tool) => tool.id));\n\t};\n\n\tconst handleDeselectAllFreetodo = () => {\n\t\tsetSelectedAgnoTools([]);\n\t};\n\n\tconst handleSelectAllExternal = () => {\n\t\tsetSelectedExternalTools(EXTERNAL_TOOLS.map((tool) => tool.id));\n\t};\n\n\tconst handleDeselectAllExternal = () => {\n\t\tsetSelectedExternalTools([]);\n\t};\n\n\t// 计算选中数量\n\tconst freetodoSelectedCount = selectedAgnoTools.length;\n\tconst externalSelectedCount = selectedExternalTools.length;\n\tconst totalSelectedCount = freetodoSelectedCount + externalSelectedCount;\n\tconst totalToolsCount = FREETODO_TOOLS.length + EXTERNAL_TOOLS.length;\n\tconst isAllSelected = totalSelectedCount === totalToolsCount;\n\n\treturn (\n\t\t<div className=\"relative\" ref={dropdownRef}>\n\t\t\t{/* 工具选择按钮 */}\n\t\t\t<button\n\t\t\t\ttype=\"button\"\n\t\t\t\tdisabled={disabled}\n\t\t\t\tonClick={() => setIsOpen(!isOpen)}\n\t\t\t\tclassName={cn(\n\t\t\t\t\t\"flex h-8 items-center gap-2 rounded px-3 text-xs\",\n\t\t\t\t\t\"border border-border bg-background text-foreground\",\n\t\t\t\t\t\"hover:bg-foreground/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n\t\t\t\t\t\"disabled:pointer-events-none disabled:opacity-50\",\n\t\t\t\t)}\n\t\t\t\taria-label={tChat(\"toolSelector.label\")}\n\t\t\t>\n\t\t\t\t<Wrench className=\"h-3.5 w-3.5\" />\n\t\t\t\t<span>{tChat(\"toolSelector.label\")}</span>\n\t\t\t\t{totalSelectedCount > 0 && (\n\t\t\t\t\t<span className=\"text-muted-foreground\">({totalSelectedCount})</span>\n\t\t\t\t)}\n\t\t\t</button>\n\n\t\t\t{/* 下拉多选框 */}\n\t\t\t{isOpen && (\n\t\t\t\t<div className=\"absolute left-0 bottom-full z-50 mb-2 w-80 rounded-md border border-border bg-background shadow-lg\">\n\t\t\t\t\t{/* 标题栏 */}\n\t\t\t\t\t<div className=\"flex items-center justify-between border-b border-border px-3 py-2\">\n\t\t\t\t\t\t<span className=\"text-sm font-medium\">\n\t\t\t\t\t\t\t{tChat(\"toolSelector.title\")}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t{/* 工具列表 */}\n\t\t\t\t\t<div className=\"max-h-96 overflow-y-auto p-2\">\n\t\t\t\t\t\t{/* 外部工具区域 */}\n\t\t\t\t\t\t<div className=\"mb-4\">\n\t\t\t\t\t\t\t<div className=\"flex items-center justify-between mb-2 px-2\">\n\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-1.5\">\n\t\t\t\t\t\t\t\t\t<Globe className=\"h-3.5 w-3.5 text-muted-foreground\" />\n\t\t\t\t\t\t\t\t\t<span className=\"text-xs font-medium text-muted-foreground\">\n\t\t\t\t\t\t\t\t\t\t{tChat(\"toolSelector.externalTools\")}\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div className=\"flex gap-2\">\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\tonClick={handleSelectAllExternal}\n\t\t\t\t\t\t\t\t\t\tclassName=\"text-xs text-primary hover:underline\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{tChat(\"toolSelector.selectAll\")}\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t<span className=\"text-xs text-muted-foreground\">|</span>\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\tonClick={handleDeselectAllExternal}\n\t\t\t\t\t\t\t\t\t\tclassName=\"text-xs text-primary hover:underline\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{tChat(\"toolSelector.deselectAll\")}\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t{/* 按分类显示外部工具 */}\n\t\t\t\t\t\t\t{Object.entries(\n\t\t\t\t\t\t\t\tEXTERNAL_TOOLS.reduce(\n\t\t\t\t\t\t\t\t\t(acc, tool) => {\n\t\t\t\t\t\t\t\t\t\tif (!acc[tool.category]) {\n\t\t\t\t\t\t\t\t\t\t\tacc[tool.category] = [];\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\tacc[tool.category].push(tool);\n\t\t\t\t\t\t\t\t\t\treturn acc;\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t{} as Record<string, Array<(typeof EXTERNAL_TOOLS)[number]>>,\n\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t).map(([category, tools]) => (\n\t\t\t\t\t\t\t\t<div key={category} className=\"mb-3 last:mb-0\">\n\t\t\t\t\t\t\t\t\t{/* 分类标题 */}\n\t\t\t\t\t\t\t\t\t<div className=\"mb-1.5 px-2 text-xs font-medium text-muted-foreground flex items-center gap-1.5\">\n\t\t\t\t\t\t\t\t\t\t{category === \"search\" ? (\n\t\t\t\t\t\t\t\t\t\t\t<Globe className=\"h-3 w-3\" />\n\t\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\t<Terminal className=\"h-3 w-3\" />\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t{tChat(`toolSelector.externalCategories.${category}`)}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t{/* 工具选项 */}\n\t\t\t\t\t\t\t\t\t<div className=\"space-y-0.5\">\n\t\t\t\t\t\t\t\t\t\t{tools.map((tool) => {\n\t\t\t\t\t\t\t\t\t\t\tconst isSelected = selectedExternalTools.includes(\n\t\t\t\t\t\t\t\t\t\t\t\ttool.id,\n\t\t\t\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\t\tkey={tool.id}\n\t\t\t\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={() => handleToggleExternalTool(tool.id)}\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-accent\"\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName={`flex h-4 w-4 shrink-0 items-center justify-center rounded border ${\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tisSelected\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t? \"border-primary bg-primary text-primary-foreground\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t: \"border-input\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t}`}\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{isSelected && <Check className=\"h-3 w-3\" />}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"flex-1 text-left\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{tToolCall(`tools.${tool.id}`)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t{/* 分隔线 */}\n\t\t\t\t\t\t<div className=\"border-t border-border my-2\" />\n\n\t\t\t\t\t\t{/* FreeTodo 工具区域 */}\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<div className=\"flex items-center justify-between mb-2 px-2\">\n\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-1.5\">\n\t\t\t\t\t\t\t\t\t<Wrench className=\"h-3.5 w-3.5 text-muted-foreground\" />\n\t\t\t\t\t\t\t\t\t<span className=\"text-xs font-medium text-muted-foreground\">\n\t\t\t\t\t\t\t\t\t\t{tChat(\"toolSelector.freetodoTools\")}\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div className=\"flex gap-2\">\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\tonClick={handleSelectAllFreetodo}\n\t\t\t\t\t\t\t\t\t\tclassName=\"text-xs text-primary hover:underline\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{tChat(\"toolSelector.selectAll\")}\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t<span className=\"text-xs text-muted-foreground\">|</span>\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\tonClick={handleDeselectAllFreetodo}\n\t\t\t\t\t\t\t\t\t\tclassName=\"text-xs text-primary hover:underline\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{tChat(\"toolSelector.deselectAll\")}\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t{Object.entries(\n\t\t\t\t\t\t\t\tFREETODO_TOOLS.reduce(\n\t\t\t\t\t\t\t\t\t(acc, tool) => {\n\t\t\t\t\t\t\t\t\t\tif (!acc[tool.category]) {\n\t\t\t\t\t\t\t\t\t\t\tacc[tool.category] = [];\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\tacc[tool.category].push(tool);\n\t\t\t\t\t\t\t\t\t\treturn acc;\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t{} as Record<\n\t\t\t\t\t\t\t\t\t\tstring,\n\t\t\t\t\t\t\t\t\t\tArray<(typeof FREETODO_TOOLS)[number]>\n\t\t\t\t\t\t\t\t\t>,\n\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t).map(([category, tools]) => (\n\t\t\t\t\t\t\t\t<div key={category} className=\"mb-3 last:mb-0\">\n\t\t\t\t\t\t\t\t\t{/* 分类标题 */}\n\t\t\t\t\t\t\t\t\t<div className=\"mb-1.5 px-2 text-xs font-medium text-muted-foreground\">\n\t\t\t\t\t\t\t\t\t\t{tChat(`toolSelector.categories.${category}`)}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t{/* 工具选项 */}\n\t\t\t\t\t\t\t\t\t<div className=\"space-y-0.5\">\n\t\t\t\t\t\t\t\t\t\t{tools.map((tool) => {\n\t\t\t\t\t\t\t\t\t\t\tconst isSelected = selectedAgnoTools.includes(tool.id);\n\t\t\t\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\t\tkey={tool.id}\n\t\t\t\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={() => handleToggleFreetodoTool(tool.id)}\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-accent\"\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName={`flex h-4 w-4 shrink-0 items-center justify-center rounded border ${\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tisSelected\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t? \"border-primary bg-primary text-primary-foreground\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t: \"border-input\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t}`}\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{isSelected && <Check className=\"h-3 w-3\" />}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"flex-1 text-left\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{tToolCall(`tools.${tool.id}`)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t{/* 底部状态栏 */}\n\t\t\t\t\t<div className=\"border-t border-border px-3 py-2 text-xs text-muted-foreground\">\n\t\t\t\t\t\t{totalSelectedCount === 0\n\t\t\t\t\t\t\t? tChat(\"toolSelector.noneSelected\")\n\t\t\t\t\t\t\t: isAllSelected\n\t\t\t\t\t\t\t\t? tChat(\"toolSelector.allSelected\")\n\t\t\t\t\t\t\t\t: tChat(\"toolSelector.selectedCount\", { count: totalSelectedCount })}\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/chat/components/layout/HeaderBar.tsx",
    "content": "\"use client\";\n\nimport { History, MessageSquare, PlusCircle } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport {\n\tPanelActionButton,\n\tPanelHeader,\n} from \"@/components/common/layout/PanelHeader\";\n\ntype HeaderBarProps = {\n\tchatHistoryLabel: string;\n\tnewChatLabel: string;\n\tonToggleHistory: () => void;\n\tonNewChat: () => void;\n};\n\nexport function HeaderBar({\n\tchatHistoryLabel,\n\tnewChatLabel,\n\tonToggleHistory,\n\tonNewChat,\n}: HeaderBarProps) {\n\tconst t = useTranslations(\"page\");\n\n\treturn (\n\t\t<PanelHeader\n\t\t\ticon={MessageSquare}\n\t\t\ttitle={t(\"chatLabel\")}\n\t\t\tactions={\n\t\t\t\t<>\n\t\t\t\t\t<PanelActionButton\n\t\t\t\t\t\tvariant=\"default\"\n\t\t\t\t\t\ticon={History}\n\t\t\t\t\t\tonClick={onToggleHistory}\n\t\t\t\t\t\taria-label={chatHistoryLabel}\n\t\t\t\t\t/>\n\t\t\t\t\t<PanelActionButton\n\t\t\t\t\t\tvariant=\"default\"\n\t\t\t\t\t\ticon={PlusCircle}\n\t\t\t\t\t\tonClick={onNewChat}\n\t\t\t\t\t\taria-label={newChatLabel}\n\t\t\t\t\t/>\n\t\t\t\t</>\n\t\t\t}\n\t\t/>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/chat/components/layout/HistoryDrawer.tsx",
    "content": "import { Loader2 } from \"lucide-react\";\nimport type { ChatSessionSummary } from \"@/lib/api\";\nimport { cn } from \"@/lib/utils\";\n\ntype HistoryDrawerProps = {\n\thistoryLoading: boolean;\n\thistoryError: string | null;\n\tsessions: ChatSessionSummary[];\n\tconversationId: string | null;\n\tformatMessageCount: (count?: number) => string;\n\tlabels: {\n\t\trecentSessions: string;\n\t\tnoHistory: string;\n\t\tloading: string;\n\t\tchatHistory: string;\n\t};\n\tonSelectSession: (id: string) => void;\n};\n\nexport function HistoryDrawer({\n\thistoryLoading,\n\thistoryError,\n\tsessions,\n\tconversationId,\n\tformatMessageCount,\n\tlabels,\n\tonSelectSession,\n}: HistoryDrawerProps) {\n\treturn (\n\t\t<div className=\"border-b border-border bg-muted/40 px-4 py-3\">\n\t\t\t<div className=\"mb-2 flex items-center justify-between\">\n\t\t\t\t<p className=\"text-sm font-medium text-foreground\">\n\t\t\t\t\t{labels.recentSessions}\n\t\t\t\t</p>\n\t\t\t\t{historyLoading && (\n\t\t\t\t\t<span className=\"flex items-center gap-2 text-xs text-muted-foreground\">\n\t\t\t\t\t\t<Loader2 className=\"h-3.5 w-3.5 animate-spin\" />\n\t\t\t\t\t\t{labels.loading}\n\t\t\t\t\t</span>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t\t{historyError && (\n\t\t\t\t<p className=\"text-xs text-destructive\">{historyError}</p>\n\t\t\t)}\n\t\t\t{!historyError && (\n\t\t\t\t<div className=\"max-h-72 space-y-2 overflow-y-auto pr-1\">\n\t\t\t\t\t{!historyLoading && sessions.length === 0 ? (\n\t\t\t\t\t\t<p className=\"text-xs text-muted-foreground\">{labels.noHistory}</p>\n\t\t\t\t\t) : (\n\t\t\t\t\t\tsessions.map((session, index) => (\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tkey={\n\t\t\t\t\t\t\t\t\tsession.sessionId\n\t\t\t\t\t\t\t\t\t\t? `${session.sessionId}-${index}`\n\t\t\t\t\t\t\t\t\t\t: `session-${index}`\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\tonClick={() => onSelectSession(session.sessionId)}\n\t\t\t\t\t\t\t\tdisabled={historyLoading}\n\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\"w-full rounded-(--radius) border border-border bg-background px-3 py-2 text-left text-sm\",\n\t\t\t\t\t\t\t\t\t\"transition-colors hover:bg-foreground/5\",\n\t\t\t\t\t\t\t\t\t\"disabled:cursor-not-allowed disabled:opacity-60\",\n\t\t\t\t\t\t\t\t\tsession.sessionId === conversationId\n\t\t\t\t\t\t\t\t\t\t? \"ring-2 ring-ring\"\n\t\t\t\t\t\t\t\t\t\t: \"\",\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<div className=\"flex items-center justify-between gap-2\">\n\t\t\t\t\t\t\t\t\t<span className=\"font-medium text-foreground\">\n\t\t\t\t\t\t\t\t\t\t{session.title || labels.chatHistory}\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t<span className=\"text-[11px] text-muted-foreground\">\n\t\t\t\t\t\t\t\t\t\t{formatMessageCount(session.messageCount)}\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div className=\"mt-1 flex items-center justify-between text-[11px] text-muted-foreground\">\n\t\t\t\t\t\t\t\t\t<span className=\"truncate\">\n\t\t\t\t\t\t\t\t\t\t{session.lastActive || session.sessionId}\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t<span className=\"uppercase tracking-wide\">\n\t\t\t\t\t\t\t\t\t\t{session.chatType || \"default\"}\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t))\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/chat/components/layout/WelcomeGreetings.tsx",
    "content": "\"use client\";\n\nimport Image from \"next/image\";\nimport { useTranslations } from \"next-intl\";\nimport { cn } from \"@/lib/utils\";\n\ntype WelcomeGreetingsProps = {\n\tclassName?: string;\n};\n\nexport function WelcomeGreetings({\n\tclassName,\n}: WelcomeGreetingsProps) {\n\tconst tChat = useTranslations(\"chat\");\n\n\tconst title = tChat(\"greetings.title\");\n\tconst subtitle = tChat(\"greetings.subtitle\");\n\n\treturn (\n\t\t<div\n\t\t\tclassName={cn(\n\t\t\t\t\"flex flex-1 flex-col items-center justify-center px-4\",\n\t\t\t\tclassName,\n\t\t\t)}\n\t\t>\n\t\t\t<div className=\"flex flex-col items-center gap-4\">\n\t\t\t\t{/* 图标 + 主标题 */}\n\t\t\t\t<div className=\"flex items-center gap-4\">\n\t\t\t\t\t<div className=\"flex h-13 w-13 items-center justify-center\">\n\t\t\t\t\t\t{/* 浅色模式图标 */}\n\t\t\t\t\t\t<Image\n\t\t\t\t\t\t\tsrc=\"/free-todo-logos/free_todo_icon_4_dark_with_grid.png\"\n\t\t\t\t\t\t\talt=\"Free Todo Logo\"\n\t\t\t\t\t\t\twidth={128}\n\t\t\t\t\t\t\theight={128}\n\t\t\t\t\t\t\tclassName=\"object-contain block dark:hidden\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t{/* 深色模式图标 */}\n\t\t\t\t\t\t<Image\n\t\t\t\t\t\t\tsrc=\"/free-todo-logos/free_todo_icon_4_with_grid.png\"\n\t\t\t\t\t\t\talt=\"Free Todo Logo\"\n\t\t\t\t\t\t\twidth={128}\n\t\t\t\t\t\t\theight={128}\n\t\t\t\t\t\t\tclassName=\"object-contain hidden dark:block\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t<h1 className=\"text-3xl font-bold tracking-tight text-foreground\">\n\t\t\t\t\t\t{title}\n\t\t\t\t\t</h1>\n\t\t\t\t</div>\n\n\t\t\t\t{/* 副标题 */}\n\t\t\t\t<p className=\"mt-1 max-w-md text-center text-base text-muted-foreground\">\n\t\t\t\t\t{subtitle}\n\t\t\t\t</p>\n\t\t\t</div>\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/chat/components/message/EditModeMessage.tsx",
    "content": "\"use client\";\n\nimport { Check, ChevronDown, Loader2, Plus } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport { useCallback, useEffect, useMemo, useState } from \"react\";\nimport ReactMarkdown from \"react-markdown\";\nimport remarkGfm from \"remark-gfm\";\nimport type { EditContentBlock } from \"@/apps/chat/types\";\nimport {\n\tgetCleanBlockContent,\n\tparseEditBlocks,\n} from \"@/apps/chat/utils/parseEditBlocks\";\nimport type { Todo, UpdateTodoInput } from \"@/lib/types\";\nimport { cn } from \"@/lib/utils\";\n\ntype EditModeMessageProps = {\n\tcontent: string;\n\teffectiveTodos: Todo[];\n\tlocale: string;\n\tonUpdateTodo: (params: {\n\t\tid: number;\n\t\tinput: UpdateTodoInput;\n\t}) => Promise<Todo>;\n\tisUpdating?: boolean;\n};\n\ntype BlockAppendState = {\n\t[blockId: string]: {\n\t\tselectedTodoId: number | null;\n\t\tisDropdownOpen: boolean;\n\t\tstatus: \"idle\" | \"appending\" | \"success\" | \"error\";\n\t};\n};\n\n// Helper to get default state for a block\nconst getDefaultBlockState = (\n\tblock: EditContentBlock,\n\teffectiveTodos: Todo[],\n): BlockAppendState[string] => {\n\t// Pre-select the AI-recommended todo if it exists in effectiveTodos\n\tconst recommendedExists = effectiveTodos.some(\n\t\t(t) => t.id === block.recommendedTodoId,\n\t);\n\t// If no AI recommendation, default to first linked todo\n\tconst defaultTodoId = recommendedExists\n\t\t? block.recommendedTodoId\n\t\t: effectiveTodos.length > 0\n\t\t\t? effectiveTodos[0].id\n\t\t\t: null;\n\n\treturn {\n\t\tselectedTodoId: defaultTodoId,\n\t\tisDropdownOpen: false,\n\t\tstatus: \"idle\",\n\t};\n};\n\nexport function EditModeMessage({\n\tcontent,\n\teffectiveTodos,\n\tonUpdateTodo,\n\tisUpdating = false,\n}: EditModeMessageProps) {\n\tconst t = useTranslations(\"chat.editMode\");\n\tconst blocks = useMemo(() => parseEditBlocks(content), [content]);\n\n\t// Track state for each block\n\tconst [blockStates, setBlockStates] = useState<BlockAppendState>({});\n\n\t// Sync blockStates when blocks change (e.g., during streaming)\n\tuseEffect(() => {\n\t\tsetBlockStates((prev) => {\n\t\t\tconst next: BlockAppendState = {};\n\t\t\tfor (const block of blocks) {\n\t\t\t\t// Keep existing state if it exists, otherwise create default\n\t\t\t\tif (prev[block.id]) {\n\t\t\t\t\tnext[block.id] = prev[block.id];\n\t\t\t\t} else {\n\t\t\t\t\tnext[block.id] = getDefaultBlockState(block, effectiveTodos);\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn next;\n\t\t});\n\t}, [blocks, effectiveTodos]);\n\n\tconst handleToggleDropdown = useCallback((blockId: string) => {\n\t\tsetBlockStates((prev) => {\n\t\t\tconst currentState = prev[blockId];\n\t\t\tif (!currentState) return prev;\n\t\t\treturn {\n\t\t\t\t...prev,\n\t\t\t\t[blockId]: {\n\t\t\t\t\t...currentState,\n\t\t\t\t\tisDropdownOpen: !currentState.isDropdownOpen,\n\t\t\t\t},\n\t\t\t};\n\t\t});\n\t}, []);\n\n\tconst handleSelectTodo = useCallback((blockId: string, todoId: number) => {\n\t\tsetBlockStates((prev) => {\n\t\t\tconst currentState = prev[blockId];\n\t\t\tif (!currentState) return prev;\n\t\t\treturn {\n\t\t\t\t...prev,\n\t\t\t\t[blockId]: {\n\t\t\t\t\t...currentState,\n\t\t\t\t\tselectedTodoId: todoId,\n\t\t\t\t\tisDropdownOpen: false,\n\t\t\t\t},\n\t\t\t};\n\t\t});\n\t}, []);\n\n\tconst handleAppend = useCallback(\n\t\tasync (block: EditContentBlock) => {\n\t\t\tconst state = blockStates[block.id];\n\t\t\tif (!state?.selectedTodoId) return;\n\n\t\t\tconst todo = effectiveTodos.find((t) => t.id === state.selectedTodoId);\n\t\t\tif (!todo) return;\n\n\t\t\t// Set appending state\n\t\t\tsetBlockStates((prev) => {\n\t\t\t\tconst currentState = prev[block.id];\n\t\t\t\tif (!currentState) return prev;\n\t\t\t\treturn {\n\t\t\t\t\t...prev,\n\t\t\t\t\t[block.id]: { ...currentState, status: \"appending\" },\n\t\t\t\t};\n\t\t\t});\n\n\t\t\ttry {\n\t\t\t\tconst cleanContent = getCleanBlockContent(block);\n\t\t\t\tconst existingNotes = todo.userNotes || \"\";\n\t\t\t\tconst newNotes = existingNotes\n\t\t\t\t\t? `${existingNotes}\\n\\n${cleanContent}`\n\t\t\t\t\t: cleanContent;\n\n\t\t\t\tawait onUpdateTodo({\n\t\t\t\t\tid: todo.id,\n\t\t\t\t\tinput: { userNotes: newNotes },\n\t\t\t\t});\n\n\t\t\t\t// Set success state\n\t\t\t\tsetBlockStates((prev) => {\n\t\t\t\t\tconst currentState = prev[block.id];\n\t\t\t\t\tif (!currentState) return prev;\n\t\t\t\t\treturn {\n\t\t\t\t\t\t...prev,\n\t\t\t\t\t\t[block.id]: { ...currentState, status: \"success\" },\n\t\t\t\t\t};\n\t\t\t\t});\n\n\t\t\t\t// Reset to idle after a delay\n\t\t\t\tsetTimeout(() => {\n\t\t\t\t\tsetBlockStates((prev) => {\n\t\t\t\t\t\tconst currentState = prev[block.id];\n\t\t\t\t\t\tif (!currentState) return prev;\n\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\t...prev,\n\t\t\t\t\t\t\t[block.id]: { ...currentState, status: \"idle\" },\n\t\t\t\t\t\t};\n\t\t\t\t\t});\n\t\t\t\t}, 2000);\n\t\t\t} catch (error) {\n\t\t\t\tconsole.error(\"Failed to append to todo:\", error);\n\t\t\t\tsetBlockStates((prev) => {\n\t\t\t\t\tconst currentState = prev[block.id];\n\t\t\t\t\tif (!currentState) return prev;\n\t\t\t\t\treturn {\n\t\t\t\t\t\t...prev,\n\t\t\t\t\t\t[block.id]: { ...currentState, status: \"error\" },\n\t\t\t\t\t};\n\t\t\t\t});\n\n\t\t\t\t// Reset to idle after a delay\n\t\t\t\tsetTimeout(() => {\n\t\t\t\t\tsetBlockStates((prev) => {\n\t\t\t\t\t\tconst currentState = prev[block.id];\n\t\t\t\t\t\tif (!currentState) return prev;\n\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\t...prev,\n\t\t\t\t\t\t\t[block.id]: { ...currentState, status: \"idle\" },\n\t\t\t\t\t\t};\n\t\t\t\t\t});\n\t\t\t\t}, 3000);\n\t\t\t}\n\t\t},\n\t\t[blockStates, effectiveTodos, onUpdateTodo],\n\t);\n\n\t// Close dropdowns when clicking outside\n\tconst handleCloseDropdowns = useCallback(() => {\n\t\tsetBlockStates((prev) => {\n\t\t\tconst next = { ...prev };\n\t\t\tfor (const blockId of Object.keys(next)) {\n\t\t\t\tnext[blockId] = { ...next[blockId], isDropdownOpen: false };\n\t\t\t}\n\t\t\treturn next;\n\t\t});\n\t}, []);\n\n\tif (blocks.length === 0) {\n\t\treturn (\n\t\t\t<div className=\"rounded-2xl bg-muted px-4 py-3 text-sm text-foreground\">\n\t\t\t\t<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>\n\t\t\t</div>\n\t\t);\n\t}\n\n\tconst noTodosMessage = t(\"noLinkedTodos\");\n\n\treturn (\n\t\t// biome-ignore lint/a11y/useKeyWithClickEvents: Click-away behavior for dropdown\n\t\t// biome-ignore lint/a11y/noStaticElementInteractions: Container for dropdown close\n\t\t<div className=\"space-y-4\" onClick={handleCloseDropdowns}>\n\t\t\t{blocks.map((block) => {\n\t\t\t\tconst state = blockStates[block.id] || {\n\t\t\t\t\tselectedTodoId: null,\n\t\t\t\t\tisDropdownOpen: false,\n\t\t\t\t\tstatus: \"idle\",\n\t\t\t\t};\n\t\t\t\tconst selectedTodo = effectiveTodos.find(\n\t\t\t\t\t(t) => t.id === state.selectedTodoId,\n\t\t\t\t);\n\n\t\t\t\treturn (\n\t\t\t\t\t<div\n\t\t\t\t\t\tkey={block.id}\n\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\"rounded-2xl border bg-muted px-4 py-3 text-sm shadow-sm transition-all\",\n\t\t\t\t\t\t\tstate.status === \"success\" &&\n\t\t\t\t\t\t\t\t\"border-green-500/50 bg-green-50/10\",\n\t\t\t\t\t\t\tstate.status === \"error\" && \"border-destructive/50\",\n\t\t\t\t\t\t)}\n\t\t\t\t\t>\n\t\t\t\t\t\t{/* Block content */}\n\t\t\t\t\t\t<div className=\"text-foreground\">\n\t\t\t\t\t\t\t{block.title && (\n\t\t\t\t\t\t\t\t<h3 className=\"mb-2 font-semibold text-base\">{block.title}</h3>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t<div className=\"prose prose-sm max-w-none dark:prose-invert\">\n\t\t\t\t\t\t\t\t<ReactMarkdown remarkPlugins={[remarkGfm]}>\n\t\t\t\t\t\t\t\t\t{block.content}\n\t\t\t\t\t\t\t\t</ReactMarkdown>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t{/* Append controls */}\n\t\t\t\t\t\t<div className=\"mt-3 flex items-center justify-end gap-2 border-t border-border/50 pt-3\">\n\t\t\t\t\t\t\t{effectiveTodos.length === 0 ? (\n\t\t\t\t\t\t\t\t<span className=\"text-xs text-muted-foreground\">\n\t\t\t\t\t\t\t\t\t{noTodosMessage}\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t{/* Todo selector dropdown */}\n\t\t\t\t\t\t\t\t\t{/* biome-ignore lint/a11y/useKeyWithClickEvents: Stop propagation for dropdown */}\n\t\t\t\t\t\t\t\t\t{/* biome-ignore lint/a11y/noStaticElementInteractions: Dropdown container */}\n\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\tclassName=\"relative\"\n\t\t\t\t\t\t\t\t\t\tonClick={(e) => e.stopPropagation()}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\t\tonClick={() => handleToggleDropdown(block.id)}\n\t\t\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\t\t\"flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs transition-colors\",\n\t\t\t\t\t\t\t\t\t\t\t\t\"hover:bg-foreground/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n\t\t\t\t\t\t\t\t\t\t\t\tselectedTodo\n\t\t\t\t\t\t\t\t\t\t\t\t\t? \"border-border bg-background text-foreground\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t: \"border-dashed border-muted-foreground/50 text-muted-foreground\",\n\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\tdisabled={state.status === \"appending\"}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<span className=\"max-w-[150px] truncate\">\n\t\t\t\t\t\t\t\t\t\t\t\t{selectedTodo ? selectedTodo.name : t(\"selectTodo\")}\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t<ChevronDown className=\"h-3 w-3 flex-shrink-0\" />\n\t\t\t\t\t\t\t\t\t\t</button>\n\n\t\t\t\t\t\t\t\t\t\t{/* Dropdown menu */}\n\t\t\t\t\t\t\t\t\t\t{state.isDropdownOpen && (\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"absolute bottom-full right-0 z-30 mb-1 w-56 max-h-48 overflow-y-auto rounded-lg border border-border bg-background shadow-lg\">\n\t\t\t\t\t\t\t\t\t\t\t\t{effectiveTodos.map((todo) => (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tkey={todo.id}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={() => handleSelectTodo(block.id, todo.id)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\ttodo.id === state.selectedTodoId\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t? \"bg-primary/10 text-primary\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t: \"text-foreground hover:bg-foreground/5\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{todo.id === state.selectedTodoId && (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<Check className=\"h-3 w-3 flex-shrink-0\" />\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"truncate\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\ttodo.id !== state.selectedTodoId && \"ml-5\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{todo.name}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{todo.id === block.recommendedTodoId && (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"ml-auto text-[10px] text-muted-foreground\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{t(\"aiRecommended\")}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t\t\t{/* Append button */}\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\tonClick={() => handleAppend(block)}\n\t\t\t\t\t\t\t\t\t\tdisabled={\n\t\t\t\t\t\t\t\t\t\t\t!state.selectedTodoId ||\n\t\t\t\t\t\t\t\t\t\t\tstate.status === \"appending\" ||\n\t\t\t\t\t\t\t\t\t\t\tstate.status === \"success\" ||\n\t\t\t\t\t\t\t\t\t\t\tisUpdating\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\t\"flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-all\",\n\t\t\t\t\t\t\t\t\t\t\t\"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n\t\t\t\t\t\t\t\t\t\t\tstate.status === \"success\"\n\t\t\t\t\t\t\t\t\t\t\t\t? \"bg-green-500 text-white\"\n\t\t\t\t\t\t\t\t\t\t\t\t: state.status === \"error\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t? \"bg-destructive text-destructive-foreground\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t: state.selectedTodoId\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t? \"bg-primary text-primary-foreground hover:bg-primary/90\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t: \"bg-muted-foreground/20 text-muted-foreground cursor-not-allowed\",\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{state.status === \"appending\" ? (\n\t\t\t\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\t\t\t<Loader2 className=\"h-3 w-3 animate-spin\" />\n\t\t\t\t\t\t\t\t\t\t\t\t<span>{t(\"appending\")}</span>\n\t\t\t\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t\t\t\t) : state.status === \"success\" ? (\n\t\t\t\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\t\t\t<Check className=\"h-3 w-3\" />\n\t\t\t\t\t\t\t\t\t\t\t\t<span>{t(\"appended\")}</span>\n\t\t\t\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t\t\t\t) : state.status === \"error\" ? (\n\t\t\t\t\t\t\t\t\t\t\t<span>{t(\"failed\")}</span>\n\t\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\t\t\t<Plus className=\"h-3 w-3\" />\n\t\t\t\t\t\t\t\t\t\t\t\t<span>{t(\"append\")}</span>\n\t\t\t\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t);\n\t\t\t})}\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/chat/components/message/MarkdownComponents.tsx",
    "content": "import type { Components } from \"react-markdown\";\nimport { cn } from \"@/lib/utils\";\n\ntype MessageRole = \"user\" | \"assistant\";\n\n/**\n * 创建 Markdown 组件配置\n * @param messageRole 消息角色，用于适配不同角色的样式\n */\nexport function createMarkdownComponents(\n\tmessageRole: MessageRole,\n): Partial<Components> {\n\tconst isAssistant = messageRole === \"assistant\";\n\n\treturn {\n\t\th1: ({ children }) => (\n\t\t\t<h1\n\t\t\t\tclassName={cn(\n\t\t\t\t\t\"text-lg font-bold mb-2 mt-0\",\n\t\t\t\t\tisAssistant ? \"text-foreground\" : \"text-primary-foreground\",\n\t\t\t\t)}\n\t\t\t>\n\t\t\t\t{children}\n\t\t\t</h1>\n\t\t),\n\t\th2: ({ children }) => (\n\t\t\t<h2\n\t\t\t\tclassName={cn(\n\t\t\t\t\t\"text-base font-semibold mb-2 mt-3\",\n\t\t\t\t\tisAssistant ? \"text-foreground\" : \"text-primary-foreground\",\n\t\t\t\t)}\n\t\t\t>\n\t\t\t\t{children}\n\t\t\t</h2>\n\t\t),\n\t\th3: ({ children }) => (\n\t\t\t<h3\n\t\t\t\tclassName={cn(\n\t\t\t\t\t\"text-sm font-semibold mb-1 mt-2\",\n\t\t\t\t\tisAssistant ? \"text-foreground\" : \"text-primary-foreground\",\n\t\t\t\t)}\n\t\t\t>\n\t\t\t\t{children}\n\t\t\t</h3>\n\t\t),\n\t\tp: ({ children }) => <p className=\"my-1.5 leading-relaxed\">{children}</p>,\n\t\tul: ({ children }) => (\n\t\t\t<ul className=\"my-2 list-disc pl-5 space-y-0.5\">{children}</ul>\n\t\t),\n\t\tol: ({ children }) => (\n\t\t\t<ol className=\"my-2 list-decimal pl-5 space-y-0.5\">{children}</ol>\n\t\t),\n\t\tli: ({ children }) => <li className=\"leading-relaxed\">{children}</li>,\n\t\tstrong: ({ children }) => (\n\t\t\t<strong className=\"font-semibold\">{children}</strong>\n\t\t),\n\t\tcode: ({ children }) => (\n\t\t\t<code\n\t\t\t\tclassName={cn(\n\t\t\t\t\t\"px-1.5 py-0.5 rounded text-xs font-mono\",\n\t\t\t\t\tisAssistant\n\t\t\t\t\t\t? \"bg-background text-foreground\"\n\t\t\t\t\t\t: \"bg-primary-foreground/20 text-primary-foreground\",\n\t\t\t\t)}\n\t\t\t>\n\t\t\t\t{children}\n\t\t\t</code>\n\t\t),\n\t\tpre: ({ children }) => (\n\t\t\t<pre\n\t\t\t\tclassName={cn(\n\t\t\t\t\t\"rounded p-2 overflow-x-auto my-2 text-xs\",\n\t\t\t\t\tisAssistant\n\t\t\t\t\t\t? \"bg-background border border-border\"\n\t\t\t\t\t\t: \"bg-primary-foreground/20\",\n\t\t\t\t)}\n\t\t\t>\n\t\t\t\t{children}\n\t\t\t</pre>\n\t\t),\n\t\tblockquote: ({ children }) => (\n\t\t\t<blockquote\n\t\t\t\tclassName={cn(\n\t\t\t\t\t\"border-l-2 pl-3 my-2 italic\",\n\t\t\t\t\tisAssistant\n\t\t\t\t\t\t? \"border-border opacity-80\"\n\t\t\t\t\t\t: \"border-primary-foreground/50 opacity-90\",\n\t\t\t\t)}\n\t\t\t>\n\t\t\t\t{children}\n\t\t\t</blockquote>\n\t\t),\n\t\ta: ({ href, children }) => {\n\t\t\t// 检查是否是内部锚点链接（来源引用）\n\t\t\tconst isSourceLink = href?.startsWith(\"#source-\") ?? false;\n\t\t\treturn (\n\t\t\t\t<a\n\t\t\t\t\thref={href ?? \"#\"}\n\t\t\t\t\tonClick={\n\t\t\t\t\t\tisSourceLink && href\n\t\t\t\t\t\t\t? (e) => {\n\t\t\t\t\t\t\t\t\te.preventDefault();\n\t\t\t\t\t\t\t\t\tconst targetId = href.substring(1); // 移除 #\n\t\t\t\t\t\t\t\t\tconst targetElement = document.getElementById(targetId);\n\t\t\t\t\t\t\t\t\tif (targetElement) {\n\t\t\t\t\t\t\t\t\t\ttargetElement.scrollIntoView({\n\t\t\t\t\t\t\t\t\t\t\tbehavior: \"smooth\",\n\t\t\t\t\t\t\t\t\t\t\tblock: \"center\",\n\t\t\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\t\t\t// 高亮目标元素\n\t\t\t\t\t\t\t\t\t\ttargetElement.classList.add(\n\t\t\t\t\t\t\t\t\t\t\t\"ring-2\",\n\t\t\t\t\t\t\t\t\t\t\t\"ring-primary\",\n\t\t\t\t\t\t\t\t\t\t\t\"ring-offset-2\",\n\t\t\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t\t\t\tsetTimeout(() => {\n\t\t\t\t\t\t\t\t\t\t\ttargetElement.classList.remove(\n\t\t\t\t\t\t\t\t\t\t\t\t\"ring-2\",\n\t\t\t\t\t\t\t\t\t\t\t\t\"ring-primary\",\n\t\t\t\t\t\t\t\t\t\t\t\t\"ring-offset-2\",\n\t\t\t\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t\t\t\t}, 2000);\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t: undefined\n\t\t\t\t\t}\n\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\tisSourceLink\n\t\t\t\t\t\t\t? \"text-primary font-medium hover:text-primary/80 no-underline\"\n\t\t\t\t\t\t\t: \"underline underline-offset-2\",\n\t\t\t\t\t\t!isSourceLink && isAssistant\n\t\t\t\t\t\t\t? \"hover:opacity-80\"\n\t\t\t\t\t\t\t: \"hover:opacity-90\",\n\t\t\t\t\t)}\n\t\t\t\t\tstyle={\n\t\t\t\t\t\tisSourceLink\n\t\t\t\t\t\t\t? {\n\t\t\t\t\t\t\t\t\tverticalAlign: \"super\",\n\t\t\t\t\t\t\t\t\tfontSize: \"0.75em\",\n\t\t\t\t\t\t\t\t\tmarginLeft: \"0.1em\",\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t: undefined\n\t\t\t\t\t}\n\t\t\t\t\ttarget={isSourceLink ? undefined : \"_blank\"}\n\t\t\t\t\trel={isSourceLink ? undefined : \"noopener noreferrer\"}\n\t\t\t\t>\n\t\t\t\t\t{children}\n\t\t\t\t</a>\n\t\t\t);\n\t\t},\n\t};\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/chat/components/message/MessageContent.tsx",
    "content": "import ReactMarkdown from \"react-markdown\";\nimport remarkGfm from \"remark-gfm\";\nimport type { ChatMessage } from \"@/apps/chat/types\";\nimport { createMarkdownComponents } from \"./MarkdownComponents\";\nimport { MessageSources } from \"./MessageSources\";\nimport {\n\tparseWebSearchMessage,\n\tprocessBodyWithCitations,\n\tremoveToolCalls,\n\tremoveToolEvents,\n\ttype WebSearchSources,\n} from \"./utils/messageContentUtils\";\n\ntype MessageContentProps = {\n\tmessage: ChatMessage;\n};\n\nexport function MessageContent({ message }: MessageContentProps) {\n\t// 移除工具调用标记后的内容\n\tconst contentWithoutToolCalls = message.content\n\t\t? removeToolCalls(removeToolEvents(message.content))\n\t\t: \"\";\n\n\t// 无论是否启用联网搜索，只要消息内容包含 Sources 标记就解析\n\t// 这样可以避免关闭联网搜索后，已包含 Sources 的消息显示异常\n\tconst hasSourcesMarker =\n\t\tmessage.role === \"assistant\" &&\n\t\tcontentWithoutToolCalls &&\n\t\tcontentWithoutToolCalls.includes(\"\\n\\nSources:\");\n\tconst { body, sources } = hasSourcesMarker\n\t\t? parseWebSearchMessage(contentWithoutToolCalls)\n\t\t: { body: contentWithoutToolCalls, sources: [] as WebSearchSources };\n\n\t// 处理引用标记\n\tconst processedBody = processBodyWithCitations(body, message.id, sources);\n\n\tconst markdownComponents = createMarkdownComponents(message.role);\n\n\treturn (\n\t\t<>\n\t\t\t<ReactMarkdown\n\t\t\t\tremarkPlugins={[remarkGfm]}\n\t\t\t\tcomponents={markdownComponents}\n\t\t\t>\n\t\t\t\t{processedBody}\n\t\t\t</ReactMarkdown>\n\t\t\t{/* 来源列表 - 仅在有来源时显示 */}\n\t\t\t{sources.length > 0 && (\n\t\t\t\t<MessageSources sources={sources} messageId={message.id} />\n\t\t\t)}\n\t\t</>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/chat/components/message/MessageContextMenu.tsx",
    "content": "import { ListTodo, Loader2 } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport type { ExtractionState } from \"@/apps/chat/hooks/useMessageExtraction\";\nimport type { ChatMessage } from \"@/apps/chat/types\";\nimport {\n\tBaseContextMenu,\n\ttype MenuItem,\n} from \"@/components/common/context-menu/BaseContextMenu\";\n\ntype MessageContextMenuProps = {\n\tmenuOpenForMessageId: string | null;\n\tmessages: ChatMessage[];\n\textractionStates: Map<string, ExtractionState>;\n\tonExtractTodos: (messageId: string, messages: ChatMessage[]) => Promise<void>;\n\tonClose: () => void;\n\topen: boolean;\n\tposition: { x: number; y: number };\n};\n\nexport function MessageContextMenu({\n\tmenuOpenForMessageId,\n\tmessages,\n\textractionStates,\n\tonExtractTodos,\n\tonClose,\n\topen,\n\tposition,\n}: MessageContextMenuProps) {\n\tconst tContextMenu = useTranslations(\"contextMenu\");\n\n\tif (!menuOpenForMessageId || !open) {\n\t\treturn null;\n\t}\n\n\tconst msg = messages.find((m) => m.id === menuOpenForMessageId);\n\tif (!msg || msg.role !== \"assistant\" || !msg.content) {\n\t\treturn null;\n\t}\n\n\tconst extractionState = extractionStates.get(msg.id);\n\tconst isExtractingForThisMessage = extractionState?.isExtracting ?? false;\n\n\tconst menuItems: MenuItem[] = [\n\t\t{\n\t\t\ticon: isExtractingForThisMessage ? Loader2 : ListTodo,\n\t\t\tlabel: isExtractingForThisMessage\n\t\t\t\t? tContextMenu(\"extracting\") || \"提取中...\"\n\t\t\t\t: tContextMenu(\"extractButton\"),\n\t\t\tonClick: () => {\n\t\t\t\tif (!menuOpenForMessageId) return;\n\t\t\t\tvoid onExtractTodos(menuOpenForMessageId, messages);\n\t\t\t},\n\t\t\tisFirst: true,\n\t\t\tisLast: true,\n\t\t},\n\t];\n\n\treturn (\n\t\t<BaseContextMenu\n\t\t\titems={menuItems}\n\t\t\topen={open}\n\t\t\tposition={position}\n\t\t\tonClose={onClose}\n\t\t/>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/chat/components/message/MessageItem.tsx",
    "content": "import { Loader2, MoreVertical } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport { useState } from \"react\";\nimport type { ExtractionState } from \"@/apps/chat/hooks/useMessageExtraction\";\nimport type { ChatMessage } from \"@/apps/chat/types\";\nimport { cn } from \"@/lib/utils\";\nimport { MessageContent } from \"./MessageContent\";\nimport { MessageTodoExtractionPanel } from \"./MessageTodoExtractionPanel\";\nimport { ToolCallLoading } from \"./ToolCallLoading\";\nimport { ToolCallSteps } from \"./ToolCallSteps\";\nimport {\n\textractToolCalls,\n\tremoveToolCalls,\n\tremoveToolEvents,\n} from \"./utils/messageContentUtils\";\n\ntype MessageItemProps = {\n\tmessage: ChatMessage;\n\tisLastMessage: boolean;\n\tisStreaming: boolean;\n\ttypingText: string;\n\textractionState?: ExtractionState;\n\tonRemoveExtractionState: () => void;\n\tonMenuButtonClick: (event: React.MouseEvent, messageId: string) => void;\n\tonMessageBoxRef: (messageId: string, ref: HTMLDivElement | null) => void;\n};\n\nexport function MessageItem({\n\tmessage,\n\tisLastMessage,\n\tisStreaming,\n\ttypingText,\n\textractionState,\n\tonRemoveExtractionState,\n\tonMenuButtonClick,\n\tonMessageBoxRef,\n}: MessageItemProps) {\n\tconst tContextMenu = useTranslations(\"contextMenu\");\n\tconst [hovered, setHovered] = useState(false);\n\n\tconst sanitizedContent = message.content\n\t\t? removeToolEvents(message.content)\n\t\t: \"\";\n\t// 检测工具调用标记（在消息渲染前）\n\tconst toolCalls = sanitizedContent ? extractToolCalls(sanitizedContent) : [];\n\t// 移除工具调用标记后的内容\n\tconst contentWithoutToolCalls = sanitizedContent\n\t\t? removeToolCalls(sanitizedContent)\n\t\t: \"\";\n\n\t// 获取新的工具调用步骤（来自 toolCallSteps 属性）\n\tconst toolCallSteps = message.toolCallSteps || [];\n\tconst hasToolCallSteps = toolCallSteps.length > 0;\n\n\t// 判断是否正在工具调用（有工具调用标记且移除标记后内容为空）\n\t// 或者有新的 toolCallSteps 且没有内容\n\t// 注意：只要有 toolCallSteps（无论是 running 还是 completed），就显示工具调用步骤\n\tconst isToolCallingOnly =\n\t\tisStreaming &&\n\t\tisLastMessage &&\n\t\tmessage.role === \"assistant\" &&\n\t\t((toolCalls.length > 0 && !contentWithoutToolCalls.trim()) ||\n\t\t\t(hasToolCallSteps && !contentWithoutToolCalls.trim()));\n\n\t// 如果正在工具调用且没有实际内容，显示工具调用步骤\n\tif (isToolCallingOnly) {\n\t\t// 优先使用新的 toolCallSteps\n\t\tif (hasToolCallSteps) {\n\t\t\treturn (\n\t\t\t\t<div className=\"flex flex-col items-start w-full px-4\">\n\t\t\t\t\t<ToolCallSteps steps={toolCallSteps} />\n\t\t\t\t</div>\n\t\t\t);\n\t\t}\n\n\t\t// 降级到旧的 ToolCallLoading（兼容旧的工具调用标记）\n\t\tconst lastToolCall = toolCalls[toolCalls.length - 1];\n\t\t// 提取搜索关键词（如果参数中包含\"关键词:\"）\n\t\tlet searchQuery: string | undefined;\n\t\tif (lastToolCall.params) {\n\t\t\tconst keywordMatch = lastToolCall.params.match(/关键词:\\s*(.+)/);\n\t\t\tif (keywordMatch) {\n\t\t\t\tsearchQuery = keywordMatch[1].trim();\n\t\t\t}\n\t\t}\n\t\treturn (\n\t\t\t<div className=\"flex flex-col items-start w-full px-4\">\n\t\t\t\t<ToolCallLoading\n\t\t\t\t\ttoolName={lastToolCall.name}\n\t\t\t\t\tsearchQuery={searchQuery}\n\t\t\t\t/>\n\t\t\t</div>\n\t\t);\n\t}\n\n\t// 判断是否是正在等待首次回复的空 assistant 消息\n\tconst isEmptyStreamingMessage =\n\t\tisStreaming &&\n\t\tisLastMessage &&\n\t\tmessage.role === \"assistant\" &&\n\t\t!contentWithoutToolCalls.trim();\n\n\t// 跳过没有内容的非 streaming assistant 消息\n\t// 注意：这里使用 contentWithoutToolCalls 来判断，排除工具调用标记\n\tif (\n\t\t!contentWithoutToolCalls.trim() &&\n\t\tmessage.role === \"assistant\" &&\n\t\t!isEmptyStreamingMessage\n\t) {\n\t\treturn null;\n\t}\n\n\t// 是否为 assistant 消息且不是空的 streaming 消息\n\t// 使用 contentWithoutToolCalls 来判断，排除工具调用标记\n\tconst isAssistantMessageWithContent =\n\t\tmessage.role === \"assistant\" &&\n\t\tcontentWithoutToolCalls.trim() &&\n\t\t!isEmptyStreamingMessage;\n\n\t// 处理消息菜单按钮点击\n\tconst handleMessageMenuClick = (event: React.MouseEvent) => {\n\t\tevent.stopPropagation();\n\t\tonMenuButtonClick(event, message.id);\n\t};\n\n\t// 使用 ref callback 来传递 ref\n\tconst handleMessageBoxRef = (el: HTMLDivElement | null) => {\n\t\tonMessageBoxRef(message.id, el);\n\t};\n\n\treturn (\n\t\t<div\n\t\t\tclassName={cn(\n\t\t\t\t\"flex flex-col\",\n\t\t\t\tmessage.role === \"assistant\" ? \"items-start\" : \"items-end\",\n\t\t\t)}\n\t\t>\n\t\t\t{/* 空的 streaming 消息显示 loading 指示器 */}\n\t\t\t{isEmptyStreamingMessage ? (\n\t\t\t\t<div className=\"flex items-center gap-2 rounded-full bg-muted px-3 py-2 text-xs text-muted-foreground\">\n\t\t\t\t\t<Loader2 className=\"h-4 w-4 animate-spin\" />\n\t\t\t\t\t{typingText}\n\t\t\t\t</div>\n\t\t\t) : (\n\t\t\t\t<div className=\"max-w-[80%]\">\n\t\t\t\t\t{/* 工具调用步骤（显示在消息内容之前） */}\n\t\t\t\t\t{message.role === \"assistant\" && hasToolCallSteps && (\n\t\t\t\t\t\t<ToolCallSteps steps={toolCallSteps} className=\"mb-2\" />\n\t\t\t\t\t)}\n\t\t\t\t\t<div\n\t\t\t\t\t\tref={handleMessageBoxRef}\n\t\t\t\t\t\trole=\"group\"\n\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\"relative rounded-2xl px-4 py-3 text-sm shadow-sm\",\n\t\t\t\t\t\t\tmessage.role === \"assistant\"\n\t\t\t\t\t\t\t\t? \"bg-muted/30 text-foreground\"\n\t\t\t\t\t\t\t\t: \"bg-primary/10 dark:bg-primary/20 text-foreground\",\n\t\t\t\t\t\t)}\n\t\t\t\t\t\tonMouseEnter={() => {\n\t\t\t\t\t\t\tif (isAssistantMessageWithContent) {\n\t\t\t\t\t\t\t\tsetHovered(true);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tonMouseLeave={() => {\n\t\t\t\t\t\t\tsetHovered(false);\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t{/* <div className=\"mb-1 text-[11px] uppercase tracking-wide opacity-70\">\n\t\t\t\t\t\t\t{message.role === \"assistant\" ? t(\"assistant\") : t(\"user\")}\n\t\t\t\t\t\t</div> */}\n\t\t\t\t\t\t<div className=\"leading-relaxed relative\">\n\t\t\t\t\t\t\t{/* Hover 时显示的菜单按钮 - 位于右下角 */}\n\t\t\t\t\t\t\t{hovered && isAssistantMessageWithContent && (\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\tonClick={handleMessageMenuClick}\n\t\t\t\t\t\t\t\t\tclassName=\"absolute -bottom-1 -right-1 opacity-70 hover:opacity-100 transition-opacity rounded-full p-1.5 bg-background/80 hover:bg-background shadow-sm border border-border/50\"\n\t\t\t\t\t\t\t\t\taria-label={tContextMenu(\"extractButton\")}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<MoreVertical className=\"h-3.5 w-3.5\" />\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t<MessageContent message={message} />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t)}\n\t\t\t{/* 提取待办面板 - 显示在消息下方 */}\n\t\t\t{extractionState && (\n\t\t\t\t<div\n\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\"w-full\",\n\t\t\t\t\t\tmessage.role === \"assistant\" ? \"max-w-[80%]\" : \"max-w-[80%]\",\n\t\t\t\t\t)}\n\t\t\t\t>\n\t\t\t\t\t<MessageTodoExtractionPanel\n\t\t\t\t\t\ttodos={extractionState.todos}\n\t\t\t\t\t\tparentTodoId={extractionState.parentTodoId}\n\t\t\t\t\t\tisExtracting={extractionState.isExtracting}\n\t\t\t\t\t\tonComplete={onRemoveExtractionState}\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/chat/components/message/MessageList.tsx",
    "content": "import { useMemo, useRef, useState } from \"react\";\nimport { WelcomeGreetings } from \"@/apps/chat/components/layout/WelcomeGreetings\";\nimport { useMessageExtraction } from \"@/apps/chat/hooks/useMessageExtraction\";\nimport { useMessageScroll } from \"@/apps/chat/hooks/useMessageScroll\";\nimport type { ChatMessage } from \"@/apps/chat/types\";\nimport { useContextMenu } from \"@/components/common/context-menu/BaseContextMenu\";\nimport { useTodos } from \"@/lib/query\";\nimport type { Todo } from \"@/lib/types\";\nimport { MessageContextMenu } from \"./MessageContextMenu\";\nimport { MessageItem } from \"./MessageItem\";\n\ntype MessageListProps = {\n\tmessages: ChatMessage[];\n\tisStreaming: boolean;\n\ttypingText: string;\n\teffectiveTodos?: Todo[];\n};\n\nexport function MessageList({\n\tmessages,\n\tisStreaming,\n\ttypingText,\n\teffectiveTodos = [],\n}: MessageListProps) {\n\tconst { data: allTodos = [] } = useTodos();\n\n\t// 使用滚动管理 hook\n\tconst { messageListRef, handleScroll } = useMessageScroll(\n\t\tmessages,\n\t\tisStreaming,\n\t);\n\n\t// 使用提取管理 hook\n\tconst { extractionStates, handleExtractTodos, removeExtractionState } =\n\t\tuseMessageExtraction({\n\t\t\teffectiveTodos,\n\t\t\tallTodos,\n\t\t});\n\n\t// 跟踪每个消息的 hover 状态和菜单状态\n\tconst [menuOpenForMessageId, setMenuOpenForMessageId] = useState<\n\t\tstring | null\n\t>(null);\n\tconst messageMenuRefs = useRef<Map<string, HTMLDivElement>>(new Map());\n\tconst { contextMenu, openContextMenu, closeContextMenu } = useContextMenu();\n\n\t// 检查是否应该显示预设按钮：消息为空或只有一条初始assistant消息\n\tconst shouldShowSuggestions = useMemo(() => {\n\t\tif (messages.length === 0) return true;\n\t\tif (messages.length === 1) {\n\t\t\tconst msg = messages[0];\n\t\t\t// 如果是assistant消息，则显示预设按钮（不依赖内容严格匹配，避免语言切换时的问题）\n\t\t\tif (msg.role === \"assistant\") {\n\t\t\t\treturn true;\n\t\t\t}\n\t\t}\n\t\t// 如果只有assistant消息且没有用户消息，也显示预设按钮\n\t\tif (\n\t\t\tmessages.length > 0 &&\n\t\t\tmessages.every((msg) => msg.role === \"assistant\")\n\t\t) {\n\t\t\treturn true;\n\t\t}\n\t\treturn false;\n\t}, [messages]);\n\n\t// 处理菜单按钮点击\n\tconst handleMenuButtonClick = (\n\t\tevent: React.MouseEvent,\n\t\tmessageId: string,\n\t) => {\n\t\tevent.stopPropagation();\n\t\tconst messageBox = messageMenuRefs.current.get(messageId);\n\t\tif (!messageBox) return;\n\n\t\tconst rect = messageBox.getBoundingClientRect();\n\t\t// 菜单位置：消息框右下角，稍微偏移\n\t\tconst menuWidth = 180;\n\t\tconst menuHeight = 60;\n\t\tconst viewportWidth = window.innerWidth;\n\t\tconst viewportHeight = window.innerHeight;\n\n\t\tconst x = Math.min(\n\t\t\tMath.max(rect.right - menuWidth, 8),\n\t\t\tviewportWidth - menuWidth,\n\t\t);\n\t\tconst y = Math.min(\n\t\t\tMath.max(rect.bottom + 4, 8),\n\t\t\tviewportHeight - menuHeight,\n\t\t);\n\n\t\tsetMenuOpenForMessageId(messageId);\n\t\topenContextMenu(event, {\n\t\t\tmenuWidth,\n\t\t\tmenuHeight,\n\t\t\tcalculatePosition: () => ({ x, y }),\n\t\t});\n\t};\n\n\t// 如果应该显示首页，则显示欢迎界面而不是消息列表\n\tif (shouldShowSuggestions) {\n\t\treturn (\n\t\t\t<div className=\"flex flex-1 overflow-y-auto\" ref={messageListRef}>\n\t\t\t\t<WelcomeGreetings />\n\t\t\t</div>\n\t\t);\n\t}\n\n\treturn (\n\t\t<div\n\t\t\tclassName=\"flex-1 space-y-4 overflow-y-auto px-4 py-4\"\n\t\t\tref={messageListRef}\n\t\t\tonScroll={handleScroll}\n\t\t>\n\t\t\t{messages.map((msg, index) => {\n\t\t\t\tconst isLastMessage = index === messages.length - 1;\n\t\t\t\tconst extractionState = extractionStates.get(msg.id);\n\n\t\t\t\treturn (\n\t\t\t\t\t<MessageItem\n\t\t\t\t\t\tkey={msg.id}\n\t\t\t\t\t\tmessage={msg}\n\t\t\t\t\t\tisLastMessage={isLastMessage}\n\t\t\t\t\t\tisStreaming={isStreaming}\n\t\t\t\t\t\ttypingText={typingText}\n\t\t\t\t\t\textractionState={extractionState}\n\t\t\t\t\t\tonRemoveExtractionState={() => removeExtractionState(msg.id)}\n\t\t\t\t\t\tonMenuButtonClick={handleMenuButtonClick}\n\t\t\t\t\t\tonMessageBoxRef={(messageId, ref) => {\n\t\t\t\t\t\t\tif (ref) {\n\t\t\t\t\t\t\t\tmessageMenuRefs.current.set(messageId, ref);\n\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\tmessageMenuRefs.current.delete(messageId);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}}\n\t\t\t\t\t/>\n\t\t\t\t);\n\t\t\t})}\n\t\t\t{/* 消息菜单 */}\n\t\t\t<MessageContextMenu\n\t\t\t\tmenuOpenForMessageId={menuOpenForMessageId}\n\t\t\t\tmessages={messages}\n\t\t\t\textractionStates={extractionStates}\n\t\t\t\tonExtractTodos={handleExtractTodos}\n\t\t\t\tonClose={() => {\n\t\t\t\t\tsetMenuOpenForMessageId(null);\n\t\t\t\t\tcloseContextMenu();\n\t\t\t\t}}\n\t\t\t\topen={contextMenu.open}\n\t\t\t\tposition={{ x: contextMenu.x, y: contextMenu.y }}\n\t\t\t/>\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/chat/components/message/MessageSources.tsx",
    "content": "import { ExternalLink } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport type { WebSearchSources } from \"./utils/messageContentUtils\";\n\ntype MessageSourcesProps = {\n\tsources: WebSearchSources;\n\tmessageId: string;\n};\n\nexport function MessageSources({ sources, messageId }: MessageSourcesProps) {\n\tconst t = useTranslations(\"chat\");\n\n\tif (sources.length === 0) {\n\t\treturn null;\n\t}\n\n\treturn (\n\t\t<div\n\t\t\tid={`sources-${messageId}`}\n\t\t\tclassName=\"mt-4 pt-4 border-t border-border/50 scroll-mt-4\"\n\t\t>\n\t\t\t<div className=\"mb-2 text-xs font-semibold uppercase tracking-wide opacity-70\">\n\t\t\t\t{t(\"sources\") || \"Sources\"}\n\t\t\t</div>\n\t\t\t<ul className=\"space-y-2\">\n\t\t\t\t{sources.map((source, idx) => {\n\t\t\t\t\tconst sourceId = `source-${messageId}-${idx}`;\n\t\t\t\t\treturn (\n\t\t\t\t\t\t<li\n\t\t\t\t\t\t\tkey={sourceId}\n\t\t\t\t\t\t\tid={sourceId}\n\t\t\t\t\t\t\tclassName=\"flex items-start gap-2 scroll-mt-4 transition-all duration-200\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<span className=\"text-xs text-muted-foreground mt-0.5\">\n\t\t\t\t\t\t\t\t{idx + 1}.\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<a\n\t\t\t\t\t\t\t\thref={source.url}\n\t\t\t\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t\t\t\t\trel=\"noopener noreferrer\"\n\t\t\t\t\t\t\t\tclassName=\"text-xs text-primary hover:underline flex items-center gap-1 flex-1\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<span>{source.title}</span>\n\t\t\t\t\t\t\t\t<ExternalLink className=\"h-3 w-3\" />\n\t\t\t\t\t\t\t</a>\n\t\t\t\t\t\t</li>\n\t\t\t\t\t);\n\t\t\t\t})}\n\t\t\t</ul>\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/chat/components/message/MessageTodoExtractionModal.tsx",
    "content": "\"use client\";\n\nimport { Check, X } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport { useEffect, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { useCreateTodo, useUpdateTodo } from \"@/lib/query\";\nimport type { Todo } from \"@/lib/types\";\nimport { cn } from \"@/lib/utils\";\n\ninterface ExtractedTodo {\n\tname: string;\n\tdescription?: string | null;\n\ttags: string[];\n\tstartTime?: string | null;\n\tdeadline?: string | null;\n\trawTime?: string | null;\n\tkey?: string;\n}\n\ninterface MessageTodoExtractionModalProps {\n\tisOpen: boolean;\n\tonClose: () => void;\n\ttodos: ExtractedTodo[];\n\tparentTodoId: number | null;\n\tonSuccess?: () => void;\n\t/** 可选：由外部控制选中项（用于 Audio 等需要“保留勾选”的场景） */\n\tselectedTodoIndexes?: Set<number>;\n\tonSelectedTodoIndexesChange?: (next: Set<number>) => void;\n\t/** 可选：确认成功后回传本次确认的 item keys（用于去重） */\n\tonSuccessWithKeys?: (keys: string[]) => void;\n\t/** 可选：确认成功后回传创建结果（用于音频把提取项标记为 linked） */\n\tonSuccessWithCreated?: (created: Array<{ index: number; key?: string; todoId: number }>) => void;\n}\n\nexport function MessageTodoExtractionModal({\n\tisOpen,\n\tonClose,\n\ttodos,\n\tparentTodoId,\n\tonSuccess,\n\tselectedTodoIndexes,\n\tonSelectedTodoIndexesChange,\n\tonSuccessWithKeys,\n\tonSuccessWithCreated,\n}: MessageTodoExtractionModalProps) {\n\tconst t = useTranslations(\"contextMenu\");\n\tconst tChat = useTranslations(\"chat\");\n\tconst createTodoMutation = useCreateTodo();\n\tconst updateTodoMutation = useUpdateTodo();\n\tconst [internalSelectedTodos, setInternalSelectedTodos] = useState<Set<number>>(new Set());\n\tconst [isProcessing, setIsProcessing] = useState(false);\n\n\tconst selectedTodos = selectedTodoIndexes ?? internalSelectedTodos;\n\tconst setSelectedTodos = (next: Set<number>) => {\n\t\tonSelectedTodoIndexesChange?.(next);\n\t\tif (!selectedTodoIndexes) {\n\t\t\tsetInternalSelectedTodos(next);\n\t\t}\n\t};\n\n\t// 打开时，如果有外部 selection 则同步到内部，否则保持现有（默认不自动全选）\n\tuseEffect(() => {\n\t\tif (!isOpen) return;\n\t\tif (selectedTodoIndexes) {\n\t\t\tsetInternalSelectedTodos(new Set(selectedTodoIndexes));\n\t\t} else if (internalSelectedTodos.size > todos.length) {\n\t\t\t// 数据变化导致越界时，收敛到有效范围\n\t\t\tsetInternalSelectedTodos(new Set());\n\t\t}\n\t}, [isOpen, selectedTodoIndexes, internalSelectedTodos.size, todos.length]);\n\n\tconst handleToggleTodo = (index: number) => {\n\t\tconst newSelected = new Set(selectedTodos);\n\t\tif (newSelected.has(index)) {\n\t\t\tnewSelected.delete(index);\n\t\t} else {\n\t\t\tnewSelected.add(index);\n\t\t}\n\t\tsetSelectedTodos(newSelected);\n\t};\n\n\tconst normalizeScheduleTime = (value?: string | null): string | undefined => {\n\t\tif (!value) return undefined;\n\t\tconst parsed = Date.parse(value);\n\t\tif (Number.isNaN(parsed)) return undefined;\n\t\treturn new Date(parsed).toISOString();\n\t};\n\n\tconst handleConfirm = async () => {\n\t\tif (selectedTodos.size === 0) {\n\t\t\tonClose();\n\t\t\treturn;\n\t\t}\n\n\t\tsetIsProcessing(true);\n\t\ttry {\n\t\t\t// 创建选中的待办（status 为 draft）\n\t\t\tconst createdTodos: Todo[] = [];\n\t\t\tconst confirmedKeys: string[] = [];\n\t\t\tconst createdMap: Array<{ index: number; key?: string; todoId: number }> = [];\n\t\t\tfor (const index of selectedTodos) {\n\t\t\t\tconst todo = todos[index];\n\t\t\t\tif (todo?.key) confirmedKeys.push(todo.key);\n\t\t\t\t// NOTE: avoid hard dependency on a translation key that may be missing\n\t\t\t\tconst userNotesParts = [\n\t\t\t\t\ttodo.rawTime ? `时间: ${todo.rawTime}` : null,\n\t\t\t\t].filter(Boolean);\n\t\t\t\tconst safeStartTime = normalizeScheduleTime(\n\t\t\t\t\ttodo.startTime ?? todo.deadline,\n\t\t\t\t);\n\t\t\t\tconst created = await createTodoMutation.mutateAsync({\n\t\t\t\t\tname: todo.name,\n\t\t\t\t\tdescription: todo.description || undefined,\n\t\t\t\t\ttags: todo.tags,\n\t\t\t\t\tstatus: \"draft\",\n\t\t\t\t\tparentTodoId: parentTodoId,\n\t\t\t\t\tstartTime: safeStartTime,\n\t\t\t\t\tuserNotes: userNotesParts.length > 0 ? userNotesParts.join(\"\\n\") : undefined,\n\t\t\t\t});\n\t\t\t\tcreatedTodos.push(created);\n\t\t\t\tcreatedMap.push({ index, key: todo?.key, todoId: created.id });\n\t\t\t}\n\n\t\t\t// 将所有创建的待办从 draft 更新为 active\n\t\t\tawait Promise.all(\n\t\t\t\tcreatedTodos.map((todo) =>\n\t\t\t\t\tupdateTodoMutation.mutateAsync({\n\t\t\t\t\t\tid: todo.id,\n\t\t\t\t\t\tinput: { status: \"active\" },\n\t\t\t\t\t}),\n\t\t\t\t),\n\t\t\t);\n\n\t\t\tonSuccessWithKeys?.(confirmedKeys);\n\t\t\tonSuccessWithCreated?.(createdMap);\n\t\t\tonSuccess?.();\n\t\t\tonClose();\n\t\t} catch (error) {\n\t\t\tconsole.error(\"创建待办失败:\", error);\n\t\t} finally {\n\t\t\tsetIsProcessing(false);\n\t\t}\n\t};\n\n\tconst handleCancel = () => {\n\t\tonClose();\n\t};\n\n\tif (!isOpen) return null;\n\n\tconst modalContent = (\n\t\t<div className=\"fixed inset-0 z-50 flex items-center justify-center\">\n\t\t\t{/* 背景遮罩 */}\n\t\t\t<div\n\t\t\t\tclassName=\"absolute inset-0 bg-black/50\"\n\t\t\t\trole=\"button\"\n\t\t\t\ttabIndex={0}\n\t\t\t\tonClick={handleCancel}\n\t\t\t\tonKeyDown={(e) => {\n\t\t\t\t\tif (e.key === \"Enter\" || e.key === \" \" || e.key === \"Escape\") {\n\t\t\t\t\t\te.preventDefault();\n\t\t\t\t\t\thandleCancel();\n\t\t\t\t\t}\n\t\t\t\t}}\n\t\t\t\taria-label=\"关闭对话框\"\n\t\t\t/>\n\n\t\t\t{/* 对话框 */}\n\t\t\t<div className=\"relative z-10 w-full max-w-2xl max-h-[80vh] overflow-hidden rounded-lg border border-border bg-background shadow-lg\">\n\t\t\t\t{/* 标题栏 */}\n\t\t\t\t<div className=\"flex items-center justify-between border-b border-border px-6 py-4\">\n\t\t\t\t\t<h2 className=\"text-lg font-semibold text-foreground\">\n\t\t\t\t\t\t{t(\"extractButton\")}\n\t\t\t\t\t</h2>\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tonClick={handleCancel}\n\t\t\t\t\t\tclassName=\"rounded-md p-1 text-muted-foreground hover:bg-muted transition-colors\"\n\t\t\t\t\t\taria-label={t(\"cancelButton\")}\n\t\t\t\t\t>\n\t\t\t\t\t\t<X className=\"h-5 w-5\" />\n\t\t\t\t\t</button>\n\t\t\t\t</div>\n\n\t\t\t\t{/* 内容区域 */}\n\t\t\t\t<div className=\"overflow-y-auto px-6 py-4 max-h-[calc(80vh-140px)]\">\n\t\t\t\t\t<p className=\"mb-4 text-sm text-muted-foreground\">\n\t\t\t\t\t\t{tChat(\"extractModalDescription\")}\n\t\t\t\t\t</p>\n\n\t\t\t\t\t<div className=\"space-y-2\">\n\t\t\t\t\t\t{todos.map((todo, index) => {\n\t\t\t\t\t\t\tconst isSelected = selectedTodos.has(index);\n\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\tkey={todo.key || `${todo.name}-${index}`}\n\t\t\t\t\t\t\t\t\trole=\"button\"\n\t\t\t\t\t\t\t\t\ttabIndex={0}\n\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\"flex items-start gap-3 rounded-lg border p-4 cursor-pointer transition-colors\",\n\t\t\t\t\t\t\t\t\t\tisSelected\n\t\t\t\t\t\t\t\t\t\t\t? \"border-primary bg-primary/5\"\n\t\t\t\t\t\t\t\t\t\t\t: \"border-border bg-background hover:bg-muted/50\",\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\tonClick={() => handleToggleTodo(index)}\n\t\t\t\t\t\t\t\t\tonKeyDown={(e) => {\n\t\t\t\t\t\t\t\t\t\tif (e.key === \"Enter\" || e.key === \" \") {\n\t\t\t\t\t\t\t\t\t\t\te.preventDefault();\n\t\t\t\t\t\t\t\t\t\t\thandleToggleTodo(index);\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<div className=\"mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded border-2 transition-colors\">\n\t\t\t\t\t\t\t\t\t\t{isSelected && (\n\t\t\t\t\t\t\t\t\t\t\t<Check className=\"h-3.5 w-3.5 text-primary\" />\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div className=\"flex-1 min-w-0\">\n\t\t\t\t\t\t\t\t\t\t<div className=\"font-medium text-foreground\">\n\t\t\t\t\t\t\t\t\t\t\t{todo.name}\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t{todo.description && (\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"mt-1 text-sm text-muted-foreground\">\n\t\t\t\t\t\t\t\t\t\t\t\t{todo.description}\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t{todo.tags.length > 0 && (\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"mt-1 flex flex-wrap gap-1\">\n\t\t\t\t\t\t\t\t\t\t\t\t{todo.tags.map((tag) => (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tkey={tag}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{tag}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t})}\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\n\t\t\t\t{/* 底部操作栏 */}\n\t\t\t\t<div className=\"flex items-center justify-between border-t border-border px-6 py-4\">\n\t\t\t\t\t<div className=\"text-sm text-muted-foreground\">\n\t\t\t\t\t\t{tChat(\"selectedCount\", { count: selectedTodos.size }) ||\n\t\t\t\t\t\t\t`已选择 ${selectedTodos.size} 项`}\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"flex items-center gap-3\">\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tonClick={handleCancel}\n\t\t\t\t\t\t\tdisabled={isProcessing}\n\t\t\t\t\t\t\tclassName=\"rounded-md px-4 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors disabled:opacity-50\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{t(\"cancelButton\")}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tonClick={handleConfirm}\n\t\t\t\t\t\t\tdisabled={isProcessing || selectedTodos.size === 0}\n\t\t\t\t\t\t\tclassName=\"rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow-sm transition-colors hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{isProcessing\n\t\t\t\t\t\t\t\t? tChat(\"applying\")\n\t\t\t\t\t\t\t\t: tChat(\"confirmAdd\", { count: selectedTodos.size })}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t);\n\n\treturn typeof document !== \"undefined\"\n\t\t? createPortal(modalContent, document.body)\n\t\t: null;\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/chat/components/message/MessageTodoExtractionPanel.tsx",
    "content": "\"use client\";\n\nimport { Check, Loader2 } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport { useState } from \"react\";\nimport { useCreateTodo, useUpdateTodo } from \"@/lib/query\";\nimport type { Todo } from \"@/lib/types\";\nimport { cn } from \"@/lib/utils\";\n\ninterface ExtractedTodo {\n\tname: string;\n\tdescription?: string | null;\n\ttags: string[];\n}\n\ninterface MessageTodoExtractionPanelProps {\n\ttodos: ExtractedTodo[];\n\tparentTodoId: number | null;\n\tisExtracting: boolean;\n\tonComplete?: () => void;\n}\n\nexport function MessageTodoExtractionPanel({\n\ttodos,\n\tparentTodoId,\n\tisExtracting,\n\tonComplete,\n}: MessageTodoExtractionPanelProps) {\n\tconst t = useTranslations(\"contextMenu\");\n\tconst tChat = useTranslations(\"chat\");\n\tconst createTodoMutation = useCreateTodo();\n\tconst updateTodoMutation = useUpdateTodo();\n\tconst [selectedTodos, setSelectedTodos] = useState<Set<number>>(\n\t\tnew Set(todos.map((_, index) => index)),\n\t);\n\tconst [isApplying, setIsApplying] = useState(false);\n\n\tconst handleToggleTodo = (index: number) => {\n\t\tconst newSelected = new Set(selectedTodos);\n\t\tif (newSelected.has(index)) {\n\t\t\tnewSelected.delete(index);\n\t\t} else {\n\t\t\tnewSelected.add(index);\n\t\t}\n\t\tsetSelectedTodos(newSelected);\n\t};\n\n\tconst handleApply = async () => {\n\t\tif (selectedTodos.size === 0) return;\n\n\t\tsetIsApplying(true);\n\t\ttry {\n\t\t\t// 创建选中的待办（status 为 draft）\n\t\t\tconst createdTodos: Todo[] = [];\n\t\t\tfor (const index of selectedTodos) {\n\t\t\t\tconst todo = todos[index];\n\t\t\t\tconst created = await createTodoMutation.mutateAsync({\n\t\t\t\t\tname: todo.name,\n\t\t\t\t\tdescription: todo.description || undefined,\n\t\t\t\t\ttags: todo.tags,\n\t\t\t\t\tstatus: \"draft\",\n\t\t\t\t\tparentTodoId: parentTodoId,\n\t\t\t\t});\n\t\t\t\tcreatedTodos.push(created);\n\t\t\t}\n\n\t\t\t// 将所有创建的待办从 draft 更新为 active\n\t\t\tawait Promise.all(\n\t\t\t\tcreatedTodos.map((todo) =>\n\t\t\t\t\tupdateTodoMutation.mutateAsync({\n\t\t\t\t\t\tid: todo.id,\n\t\t\t\t\t\tinput: { status: \"active\" },\n\t\t\t\t\t}),\n\t\t\t\t),\n\t\t\t);\n\n\t\t\tonComplete?.();\n\t\t} catch (error) {\n\t\t\tconsole.error(\"创建待办失败:\", error);\n\t\t} finally {\n\t\t\tsetIsApplying(false);\n\t\t}\n\t};\n\n\tif (todos.length === 0 && !isExtracting) {\n\t\treturn null;\n\t}\n\n\treturn (\n\t\t<div className=\"mt-2 space-y-2 rounded-lg border border-dashed border-primary/50 bg-primary/5 p-3\">\n\t\t\t{isExtracting && (\n\t\t\t\t<div className=\"flex items-center gap-2 text-sm text-muted-foreground\">\n\t\t\t\t\t<Loader2 className=\"h-4 w-4 animate-spin\" />\n\t\t\t\t\t<span>{t(\"extracting\")}</span>\n\t\t\t\t</div>\n\t\t\t)}\n\n\t\t\t{todos.length > 0 && (\n\t\t\t\t<>\n\t\t\t\t\t<div className=\"space-y-2\">\n\t\t\t\t\t\t{todos.map((todo, index) => {\n\t\t\t\t\t\t\tconst isSelected = selectedTodos.has(index);\n\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\tkey={`${todo.name}-${index}`}\n\t\t\t\t\t\t\t\t\trole=\"button\"\n\t\t\t\t\t\t\t\t\ttabIndex={0}\n\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\"flex items-start gap-3 rounded-lg border p-3 cursor-pointer transition-colors\",\n\t\t\t\t\t\t\t\t\t\tisSelected\n\t\t\t\t\t\t\t\t\t\t\t? \"border-primary bg-primary/10\"\n\t\t\t\t\t\t\t\t\t\t\t: \"border-border bg-background hover:bg-muted/50\",\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\tonClick={() => handleToggleTodo(index)}\n\t\t\t\t\t\t\t\t\tonKeyDown={(e) => {\n\t\t\t\t\t\t\t\t\t\tif (e.key === \"Enter\" || e.key === \" \") {\n\t\t\t\t\t\t\t\t\t\t\te.preventDefault();\n\t\t\t\t\t\t\t\t\t\t\thandleToggleTodo(index);\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<div className=\"shrink-0 mt-0.5\">\n\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\t\t\"w-5 h-5 rounded border-2 flex items-center justify-center transition-colors\",\n\t\t\t\t\t\t\t\t\t\t\t\tisSelected\n\t\t\t\t\t\t\t\t\t\t\t\t\t? \"border-primary bg-primary\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t: \"border-border\",\n\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t{isSelected && (\n\t\t\t\t\t\t\t\t\t\t\t\t<Check className=\"h-3.5 w-3.5 text-primary-foreground\" />\n\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div className=\"flex-1 min-w-0\">\n\t\t\t\t\t\t\t\t\t\t<div className=\"font-medium text-foreground\">\n\t\t\t\t\t\t\t\t\t\t\t{todo.name}\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t{todo.tags.length > 0 && (\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"mt-1 flex flex-wrap gap-1\">\n\t\t\t\t\t\t\t\t\t\t\t\t{todo.tags.map((tag) => (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tkey={tag}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{tag}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t})}\n\t\t\t\t\t</div>\n\n\t\t\t\t\t{!isExtracting && (\n\t\t\t\t\t\t<div className=\"flex items-center justify-end gap-2 pt-2 border-t border-border\">\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\tonClick={handleApply}\n\t\t\t\t\t\t\t\tdisabled={isApplying || selectedTodos.size === 0}\n\t\t\t\t\t\t\t\tclassName=\"rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow-sm transition-colors hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{isApplying\n\t\t\t\t\t\t\t\t\t? tChat(\"applying\")\n\t\t\t\t\t\t\t\t\t: tChat(\"confirmAdd\", { count: selectedTodos.size })}\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\t\t\t\t</>\n\t\t\t)}\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/chat/components/message/SummaryStreaming.tsx",
    "content": "\"use client\";\n\nimport { Loader2 } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport { useMemo } from \"react\";\nimport ReactMarkdown from \"react-markdown\";\n\ninterface SummaryStreamingProps {\n\tstreamingText: string;\n}\n\n// 尝试从流式文本中提取部分内容用于预览\nconst extractPreview = (\n\ttext: string,\n): { summary?: string; hasJson: boolean } => {\n\t// 检查是否包含JSON结构\n\tconst jsonMatch = text.match(/\\{[\\s\\S]*?\"summary\"[\\s\\S]*?:\\s*\"([^\"]*)/);\n\tif (jsonMatch?.[1]) {\n\t\treturn {\n\t\t\tsummary: jsonMatch[1],\n\t\t\thasJson: true,\n\t\t};\n\t}\n\n\t// 如果没有JSON，尝试提取可能的总结文本（在\"summary\"字段之后）\n\tconst summaryStart = text.indexOf('\"summary\"');\n\tif (summaryStart !== -1) {\n\t\tconst afterSummary = text.substring(summaryStart + 10); // 跳过 \"summary\":\"\n\t\tconst quoteEnd = afterSummary.indexOf('\"');\n\t\tif (quoteEnd !== -1) {\n\t\t\treturn {\n\t\t\t\tsummary: afterSummary.substring(0, quoteEnd),\n\t\t\t\thasJson: true,\n\t\t\t};\n\t\t}\n\t}\n\n\treturn { hasJson: false };\n};\n\nexport function SummaryStreaming({ streamingText }: SummaryStreamingProps) {\n\tconst t = useTranslations(\"chat\");\n\tconst preview = useMemo(() => extractPreview(streamingText), [streamingText]);\n\n\treturn (\n\t\t<div className=\"flex-1 overflow-y-auto px-4 py-4\">\n\t\t\t<div className=\"mx-auto max-w-2xl space-y-6\">\n\t\t\t\t<div className=\"rounded-lg bg-muted/50 p-4\">\n\t\t\t\t\t<div className=\"mb-2 flex items-center gap-2\">\n\t\t\t\t\t\t<Loader2 className=\"h-4 w-4 animate-spin text-primary\" />\n\t\t\t\t\t\t<h3 className=\"text-lg font-semibold\">{t(\"generatingSummary\")}</h3>\n\t\t\t\t\t</div>\n\t\t\t\t\t<p className=\"text-sm text-muted-foreground\">\n\t\t\t\t\t\t{t(\"generatingSummaryDesc\")}\n\t\t\t\t\t</p>\n\t\t\t\t</div>\n\n\t\t\t\t{/* 流式显示区域 */}\n\t\t\t\t{streamingText && (\n\t\t\t\t\t<div className=\"rounded-lg border bg-card p-4 shadow-sm\">\n\t\t\t\t\t\t<h4 className=\"mb-3 text-base font-semibold\">{t(\"generating\")}</h4>\n\t\t\t\t\t\t{preview.summary ? (\n\t\t\t\t\t\t\t<div className=\"prose prose-sm max-w-none dark:prose-invert\">\n\t\t\t\t\t\t\t\t<ReactMarkdown>{preview.summary}</ReactMarkdown>\n\t\t\t\t\t\t\t\t{preview.hasJson && (\n\t\t\t\t\t\t\t\t\t<div className=\"mt-2 text-xs text-muted-foreground\">\n\t\t\t\t\t\t\t\t\t\t{t(\"parsingContent\")}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<div className=\"prose prose-sm max-w-none dark:prose-invert\">\n\t\t\t\t\t\t\t\t<div className=\"whitespace-pre-wrap text-sm text-muted-foreground\">\n\t\t\t\t\t\t\t\t\t{streamingText.substring(0, 500)}\n\t\t\t\t\t\t\t\t\t{streamingText.length > 500 && \"...\"}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/chat/components/message/ToolCallLoading.tsx",
    "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\n\ntype ToolCallLoadingProps = {\n\ttoolName: string;\n\tsearchQuery?: string;\n\tclassName?: string;\n};\n\nexport function ToolCallLoading({\n\ttoolName,\n\tsearchQuery,\n\tclassName,\n}: ToolCallLoadingProps) {\n\t// 工具名称映射（可选，用于显示更友好的名称）\n\tconst toolNameMap: Record<string, string> = {\n\t\tweb_search: \"联网搜索\",\n\t};\n\n\tconst displayName = toolNameMap[toolName] || toolName;\n\n\treturn (\n\t\t<div className={cn(\"flex flex-col gap-1 text-sm\", className)}>\n\t\t\t<span className=\"shimmer-text font-medium\">\n\t\t\t\t正在使用 {displayName}...\n\t\t\t</span>\n\t\t\t{searchQuery && (\n\t\t\t\t<span className=\"text-xs text-muted-foreground ml-0\">\n\t\t\t\t\t搜索关键词: <span className=\"font-medium\">{searchQuery}</span>\n\t\t\t\t</span>\n\t\t\t)}\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/chat/components/message/ToolCallSteps.tsx",
    "content": "\"use client\";\n\nimport { AlertCircle, CheckCircle2, Loader2, Wrench } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport type { ToolCallStep } from \"@/apps/chat/types\";\nimport { cn } from \"@/lib/utils\";\n\ntype ToolCallStepsProps = {\n\tsteps: ToolCallStep[];\n\tclassName?: string;\n};\n\n/**\n * 工具调用步骤列表组件\n * 显示 Agent 执行过程中的每个工具调用步骤\n */\nexport function ToolCallSteps({ steps, className }: ToolCallStepsProps) {\n\tconst t = useTranslations(\"chat.toolCall\");\n\n\tif (!steps || steps.length === 0) {\n\t\treturn null;\n\t}\n\n\treturn (\n\t\t<div className={cn(\"flex flex-col gap-2 mb-3\", className)}>\n\t\t\t{steps.map((step) => (\n\t\t\t\t<ToolCallStepItem key={step.id} step={step} t={t} />\n\t\t\t))}\n\t\t</div>\n\t);\n}\n\ntype ToolCallStepItemProps = {\n\tstep: ToolCallStep;\n\tt: ReturnType<typeof useTranslations<\"chat.toolCall\">>;\n};\n\n/**\n * 单个工具调用步骤项\n */\nfunction ToolCallStepItem({ step, t }: ToolCallStepItemProps) {\n\tconst { toolName, toolArgs, status, resultPreview } = step;\n\n\t// 获取工具的本地化名称，如果没有翻译则使用原始工具名\n\tconst toolKey = `tools.${toolName}` as Parameters<typeof t>[0];\n\tconst displayName = t.has(toolKey) ? t(toolKey) : toolName;\n\n\t// 状态图标\n\tconst StatusIcon = {\n\t\trunning: Loader2,\n\t\tcompleted: CheckCircle2,\n\t\terror: AlertCircle,\n\t}[status];\n\n\t// 状态颜色\n\tconst statusColorClass = {\n\t\trunning: \"text-primary\",\n\t\tcompleted: \"text-green-500\",\n\t\terror: \"text-red-500\",\n\t}[status];\n\n\t// 边框颜色\n\tconst borderColorClass = {\n\t\trunning: \"border-primary/30 dark:border-primary/50\",\n\t\tcompleted: \"border-green-200 dark:border-green-800\",\n\t\terror: \"border-red-200 dark:border-red-800\",\n\t}[status];\n\n\t// 背景颜色\n\tconst bgColorClass = {\n\t\trunning: \"bg-primary/5 dark:bg-primary/20\",\n\t\tcompleted: \"bg-green-50/50 dark:bg-green-950/30\",\n\t\terror: \"bg-red-50/50 dark:bg-red-950/30\",\n\t}[status];\n\n\t// 格式化工具参数显示\n\tconst formatArgs = (args: Record<string, unknown> | undefined): string => {\n\t\tif (!args || Object.keys(args).length === 0) {\n\t\t\treturn \"\";\n\t\t}\n\t\t// 只显示前几个关键参数\n\t\tconst entries = Object.entries(args).slice(0, 3);\n\t\treturn entries\n\t\t\t.map(([key, value]) => {\n\t\t\t\tconst strValue =\n\t\t\t\t\ttypeof value === \"string\"\n\t\t\t\t\t\t? value.length > 50\n\t\t\t\t\t\t\t? `${value.substring(0, 50)}...`\n\t\t\t\t\t\t\t: value\n\t\t\t\t\t\t: JSON.stringify(value);\n\t\t\t\treturn `${key}: ${strValue}`;\n\t\t\t})\n\t\t\t.join(\", \");\n\t};\n\n\treturn (\n\t\t<div\n\t\t\tclassName={cn(\n\t\t\t\t\"flex items-start gap-3 p-3 rounded-lg border transition-all duration-200\",\n\t\t\t\tborderColorClass,\n\t\t\t\tbgColorClass,\n\t\t\t)}\n\t\t>\n\t\t\t{/* 工具图标 */}\n\t\t\t<div\n\t\t\t\tclassName={cn(\n\t\t\t\t\t\"shrink-0 w-8 h-8 rounded-full flex items-center justify-center\",\n\t\t\t\t\tstatus === \"running\" ? \"bg-primary/10 dark:bg-primary/25\" : \"\",\n\t\t\t\t\tstatus === \"completed\" ? \"bg-green-100 dark:bg-green-900\" : \"\",\n\t\t\t\t\tstatus === \"error\" ? \"bg-red-100 dark:bg-red-900\" : \"\",\n\t\t\t\t)}\n\t\t\t>\n\t\t\t\t<Wrench className={cn(\"w-4 h-4\", statusColorClass)} />\n\t\t\t</div>\n\n\t\t\t{/* 内容区域 */}\n\t\t\t<div className=\"flex-1 min-w-0\">\n\t\t\t\t{/* 标题行 */}\n\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t<span className=\"font-medium text-sm text-foreground\">\n\t\t\t\t\t\t{status === \"running\"\n\t\t\t\t\t\t\t? t(\"calling\", { tool: displayName })\n\t\t\t\t\t\t\t: status === \"completed\"\n\t\t\t\t\t\t\t\t? t(\"completed\", { tool: displayName })\n\t\t\t\t\t\t\t\t: t(\"failed\", { tool: displayName })}\n\t\t\t\t\t</span>\n\t\t\t\t\t<StatusIcon\n\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\"w-4 h-4 shrink-0\",\n\t\t\t\t\t\t\tstatusColorClass,\n\t\t\t\t\t\t\tstatus === \"running\" && \"animate-spin\",\n\t\t\t\t\t\t)}\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\n\t\t\t\t{/* 参数显示 */}\n\t\t\t\t{toolArgs && Object.keys(toolArgs).length > 0 && (\n\t\t\t\t\t<div className=\"mt-1 text-xs text-muted-foreground font-mono truncate\">\n\t\t\t\t\t\t{formatArgs(toolArgs)}\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\n\t\t\t\t{/* 结果预览（完成或错误状态） */}\n\t\t\t\t{(status === \"completed\" || status === \"error\") && resultPreview && (\n\t\t\t\t\t<div className=\"mt-2 text-xs text-muted-foreground bg-background/50 rounded p-2 max-h-20 overflow-auto\">\n\t\t\t\t\t\t<span\n\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\tstatus === \"completed\"\n\t\t\t\t\t\t\t\t\t? \"text-green-600 dark:text-green-400\"\n\t\t\t\t\t\t\t\t\t: \"text-red-600 dark:text-red-400\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{t(\"result\")}:\n\t\t\t\t\t\t</span>{\" \"}\n\t\t\t\t\t\t{resultPreview.length > 200\n\t\t\t\t\t\t\t? `${resultPreview.substring(0, 200)}...`\n\t\t\t\t\t\t\t: resultPreview}\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/chat/components/message/utils/messageContentUtils.ts",
    "content": "// 工具调用标记检测\n// 支持格式：[使用工具: tool_name] 或 [使用工具: tool_name | 关键词: query] 或 [使用工具: tool_name | param: value]\nconst TOOL_CALL_PATTERN = /\\[使用工具:\\s*([^|\\]]+)(?:\\s*\\|\\s*([^\\]]+))?\\]/g;\nconst TOOL_EVENT_PREFIX = \"[TOOL_EVENT:\";\nconst TOOL_EVENT_SUFFIX = \"]\";\n\nexport type ToolCall = {\n\tname: string;\n\tparams?: string;\n\tfullMatch: string;\n};\n\n/**\n * 提取工具调用信息\n */\nexport function extractToolCalls(content: string): Array<ToolCall> {\n\tconst matches: Array<ToolCall> = [];\n\t// 重置正则表达式的 lastIndex\n\tTOOL_CALL_PATTERN.lastIndex = 0;\n\tlet match: RegExpExecArray | null = TOOL_CALL_PATTERN.exec(content);\n\twhile (match !== null) {\n\t\tconst toolName = match[1].trim();\n\t\tconst params = match[2]?.trim();\n\t\tmatches.push({\n\t\t\tname: toolName,\n\t\t\tparams: params,\n\t\t\tfullMatch: match[0],\n\t\t});\n\t\tmatch = TOOL_CALL_PATTERN.exec(content);\n\t}\n\treturn matches;\n}\n\n/**\n * 移除工具调用标记\n */\nexport function removeToolCalls(content: string): string {\n\treturn content.replace(TOOL_CALL_PATTERN, \"\").trim();\n}\n\n/**\n * 移除工具事件标记（如 [TOOL_EVENT:{...}]）\n */\nexport function removeToolEvents(content: string): string {\n\tlet result = content;\n\tlet startIdx = result.indexOf(TOOL_EVENT_PREFIX);\n\n\twhile (startIdx !== -1) {\n\t\tconst endIdx = result.indexOf(\n\t\t\tTOOL_EVENT_SUFFIX,\n\t\t\tstartIdx + TOOL_EVENT_PREFIX.length,\n\t\t);\n\t\tif (endIdx === -1) {\n\t\t\t// 不完整的工具事件标记，直接截断\n\t\t\tresult = result.slice(0, startIdx);\n\t\t\tbreak;\n\t\t}\n\n\t\tlet removeStart = startIdx;\n\t\tlet removeEnd = endIdx + TOOL_EVENT_SUFFIX.length;\n\n\t\t// 尝试移除前后的换行符\n\t\tif (removeStart > 0 && result[removeStart - 1] === \"\\n\") {\n\t\t\tremoveStart -= 1;\n\t\t}\n\t\tif (result[removeEnd] === \"\\n\") {\n\t\t\tremoveEnd += 1;\n\t\t}\n\n\t\tresult = result.slice(0, removeStart) + result.slice(removeEnd);\n\t\tstartIdx = result.indexOf(TOOL_EVENT_PREFIX);\n\t}\n\n\treturn result.trim();\n}\n\nexport type WebSearchSources = Array<{ title: string; url: string }>;\n\nexport type ParsedWebSearchMessage = {\n\tbody: string;\n\tsources: WebSearchSources;\n};\n\n/**\n * 解析 webSearch 模式下的消息内容，分离正文和来源列表\n */\nexport function parseWebSearchMessage(content: string): ParsedWebSearchMessage {\n\t// 查找 Sources: 标记\n\tconst sourcesMarker = \"\\n\\nSources:\";\n\tconst sourcesIndex = content.indexOf(sourcesMarker);\n\n\tif (sourcesIndex === -1) {\n\t\t// 没有 Sources 标记，返回全部内容作为正文\n\t\treturn { body: content, sources: [] };\n\t}\n\n\t// 分离正文和来源部分\n\tconst body = content.substring(0, sourcesIndex).trim();\n\tconst sourcesText = content\n\t\t.substring(sourcesIndex + sourcesMarker.length)\n\t\t.trim();\n\n\t// 解析来源列表（格式：1. 标题 (URL)）\n\tconst sources: WebSearchSources = [];\n\tconst sourceLines = sourcesText.split(\"\\n\");\n\tfor (const line of sourceLines) {\n\t\tconst trimmed = line.trim();\n\t\tif (!trimmed) continue;\n\n\t\t// 匹配格式：数字. 标题 (URL)\n\t\tconst match = trimmed.match(/^\\d+\\.\\s+(.+?)\\s+\\((.+?)\\)$/);\n\t\tif (match) {\n\t\t\tsources.push({\n\t\t\t\ttitle: match[1].trim(),\n\t\t\t\turl: match[2].trim(),\n\t\t\t});\n\t\t}\n\t}\n\n\treturn { body, sources };\n}\n\n/**\n * 将角标引用 [[n]] 替换为可点击的链接（只显示数字，不显示方括号）\n */\nexport function processBodyWithCitations(\n\ttext: string,\n\tmessageId: string,\n\tsources: WebSearchSources,\n): string {\n\tif (sources.length === 0) {\n\t\treturn text;\n\t}\n\t// 匹配 [[数字]] 格式的引用，替换为只显示数字的链接\n\treturn text.replace(/\\[\\[(\\d+)\\]\\]/g, (match, num) => {\n\t\tconst index = parseInt(num, 10) - 1;\n\t\tif (index >= 0 && index < sources.length) {\n\t\t\tconst sourceId = `source-${messageId}-${index}`;\n\t\t\t// 只显示数字，不显示方括号\n\t\t\treturn `[${num}](#${sourceId})`;\n\t\t}\n\t\treturn match;\n\t});\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/chat/hooks/useBreakdownQuestionnaire.ts",
    "content": "import { useTranslations } from \"next-intl\";\nimport { useCallback, useEffect, useMemo } from \"react\";\nimport { useBreakdownService } from \"@/apps/chat/hooks/useBreakdownService\";\nimport { useTodos } from \"@/lib/query\";\nimport { useBreakdownStore } from \"@/lib/store/breakdown-store\";\nimport type { Todo } from \"@/lib/types\";\n\nexport const useBreakdownQuestionnaire = () => {\n\tconst tChat = useTranslations(\"chat\");\n\n\t// 从 TanStack Query 获取 todos 数据（用于 Breakdown 功能）\n\tconst { data: todos = [] } = useTodos();\n\n\t// Breakdown功能相关状态\n\tconst {\n\t\tactiveBreakdownTodoId,\n\t\tstage,\n\t\tquestions,\n\t\tanswers,\n\t\tsummary,\n\t\tsubtasks,\n\t\tisLoading: breakdownLoading,\n\t\tisGeneratingSummary,\n\t\tsummaryStreamingText,\n\t\tisGeneratingQuestions,\n\t\tquestionStreamingCount,\n\t\tquestionStreamingTitle,\n\t\terror: breakdownError,\n\t\tsetQuestions,\n\t\tsetAnswer,\n\t\tsetSummary,\n\t\tsetSummaryStreaming,\n\t\tsetIsGeneratingSummary,\n\t\tsetQuestionStreaming,\n\t\tsetIsGeneratingQuestions,\n\t\tapplyBreakdown,\n\t} = useBreakdownStore();\n\n\tconst { generateQuestions, generateSummary } = useBreakdownService();\n\n\t// 获取当前正在拆分的待办\n\tconst activeBreakdownTodo = useMemo(() => {\n\t\tif (!activeBreakdownTodoId) return null;\n\t\treturn (\n\t\t\ttodos.find((todo: Todo) => todo.id === activeBreakdownTodoId) || null\n\t\t);\n\t}, [activeBreakdownTodoId, todos]);\n\n\t// 当进入questionnaire阶段时，生成选择题\n\tuseEffect(() => {\n\t\tif (\n\t\t\tstage === \"questionnaire\" &&\n\t\t\tactiveBreakdownTodo &&\n\t\t\tquestions.length === 0 &&\n\t\t\tbreakdownLoading\n\t\t) {\n\t\t\tlet cancelled = false;\n\t\t\tconst generate = async () => {\n\t\t\t\ttry {\n\t\t\t\t\tconsole.log(\n\t\t\t\t\t\t\"开始生成选择题，任务名称:\",\n\t\t\t\t\t\tactiveBreakdownTodo.name,\n\t\t\t\t\t\t\"任务ID:\",\n\t\t\t\t\t\tactiveBreakdownTodo.id,\n\t\t\t\t\t);\n\t\t\t\t\tsetIsGeneratingQuestions(true);\n\t\t\t\t\tconst generatedQuestions = await generateQuestions(\n\t\t\t\t\t\tactiveBreakdownTodo.name,\n\t\t\t\t\t\tactiveBreakdownTodo.id,\n\t\t\t\t\t\t(count, title) => {\n\t\t\t\t\t\t\t// 流式更新问题生成进度\n\t\t\t\t\t\t\tif (!cancelled) {\n\t\t\t\t\t\t\t\tsetQuestionStreaming(count, title);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t},\n\t\t\t\t\t);\n\t\t\t\t\tif (!cancelled) {\n\t\t\t\t\t\tconsole.log(\"生成的选择题:\", generatedQuestions);\n\t\t\t\t\t\tsetQuestions(generatedQuestions);\n\t\t\t\t\t\tsetIsGeneratingQuestions(false);\n\t\t\t\t\t}\n\t\t\t\t} catch (error) {\n\t\t\t\t\tif (!cancelled) {\n\t\t\t\t\t\tconsole.error(\"Failed to generate questions:\", error);\n\t\t\t\t\t\t// 错误处理：设置错误状态\n\t\t\t\t\t\tuseBreakdownStore.setState({\n\t\t\t\t\t\t\terror:\n\t\t\t\t\t\t\t\terror instanceof Error\n\t\t\t\t\t\t\t\t\t? error.message\n\t\t\t\t\t\t\t\t\t: tChat(\"generateQuestionsFailed\"),\n\t\t\t\t\t\t\tisLoading: false,\n\t\t\t\t\t\t\tisGeneratingQuestions: false,\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t};\n\t\t\tvoid generate();\n\t\t\treturn () => {\n\t\t\t\tcancelled = true;\n\t\t\t};\n\t\t}\n\t}, [\n\t\tstage,\n\t\tactiveBreakdownTodo,\n\t\tquestions.length,\n\t\tbreakdownLoading,\n\t\tgenerateQuestions,\n\t\tsetQuestions,\n\t\tsetQuestionStreaming,\n\t\tsetIsGeneratingQuestions,\n\t\ttChat,\n\t]);\n\n\t// 处理提交回答\n\tconst handleSubmitAnswers = useCallback(async () => {\n\t\tif (!activeBreakdownTodo) return;\n\n\t\ttry {\n\t\t\t// 设置生成状态\n\t\t\tsetIsGeneratingSummary(true);\n\t\t\tsetSummaryStreaming(\"\");\n\n\t\t\t// 流式生成总结\n\t\t\tconst result = await generateSummary(\n\t\t\t\tactiveBreakdownTodo.name,\n\t\t\t\tanswers,\n\t\t\t\t(streamingText) => {\n\t\t\t\t\t// 实时更新流式文本\n\t\t\t\t\tsetSummaryStreaming(streamingText);\n\t\t\t\t},\n\t\t\t);\n\n\t\t\t// 生成完成，设置最终结果\n\t\t\tsetSummary(result.summary, result.subtasks);\n\t\t} catch (error) {\n\t\t\tconsole.error(\"Failed to generate summary:\", error);\n\t\t\tsetIsGeneratingSummary(false);\n\t\t\tsetSummaryStreaming(null);\n\t\t\t// 设置错误状态\n\t\t\tuseBreakdownStore.setState({\n\t\t\t\terror:\n\t\t\t\t\terror instanceof Error\n\t\t\t\t\t\t? error.message\n\t\t\t\t\t\t: tChat(\"generateSummaryFailed\"),\n\t\t\t});\n\t\t}\n\t}, [\n\t\tactiveBreakdownTodo,\n\t\tanswers,\n\t\tgenerateSummary,\n\t\tsetSummary,\n\t\tsetIsGeneratingSummary,\n\t\tsetSummaryStreaming,\n\t\ttChat,\n\t]);\n\n\t// 处理接收拆分\n\tconst handleAcceptBreakdown = useCallback(async () => {\n\t\tawait applyBreakdown();\n\t}, [applyBreakdown]);\n\n\treturn {\n\t\tactiveBreakdownTodo,\n\t\tstage,\n\t\tquestions,\n\t\tanswers,\n\t\tsummary,\n\t\tsubtasks,\n\t\tbreakdownLoading,\n\t\tisGeneratingSummary,\n\t\tsummaryStreamingText,\n\t\tisGeneratingQuestions,\n\t\tquestionStreamingCount,\n\t\tquestionStreamingTitle,\n\t\tbreakdownError,\n\t\tsetAnswer,\n\t\thandleSubmitAnswers,\n\t\thandleAcceptBreakdown,\n\t};\n};\n"
  },
  {
    "path": "free-todo-frontend/apps/chat/hooks/useBreakdownService.ts",
    "content": "import { useCallback } from \"react\";\nimport type { ParsedTodoTree } from \"@/apps/chat/types\";\nimport { planQuestionnaireStream, planSummaryStream } from \"@/lib/api\";\nimport type { Question } from \"@/lib/store/breakdown-store\";\n\ninterface GenerateQuestionsResponse {\n\tquestions: Question[];\n}\n\ninterface GenerateSummaryResponse {\n\tsummary: string;\n\tsubtasks: ParsedTodoTree[];\n}\n\nconst findJson = (content: string): string | null => {\n\t// 1. 尝试匹配 ```json ... ```\n\tconst fencedJson = content.match(/```json\\s*([\\s\\S]*?)```/i);\n\tif (fencedJson?.[1]) {\n\t\tconst json = fencedJson[1].trim();\n\t\tif (json) return json;\n\t}\n\n\t// 2. 尝试匹配 ``` ... ``` (可能是代码块但没有json标记)\n\tconst fenced = content.match(/```\\s*([\\s\\S]*?)```/);\n\tif (fenced?.[1]) {\n\t\tconst json = fenced[1].trim();\n\t\t// 检查是否看起来像JSON\n\t\tif (\n\t\t\tjson.startsWith(\"{\") &&\n\t\t\t(json.includes(\"questions\") || json.includes(\"summary\"))\n\t\t) {\n\t\t\treturn json;\n\t\t}\n\t}\n\n\t// 3. 尝试找到第一个完整的JSON对象（从第一个 { 到匹配的 }）\n\t// 使用括号匹配来找到完整的JSON对象\n\tlet braceCount = 0;\n\tlet startIdx = -1;\n\tlet inString = false;\n\tlet escapeNext = false;\n\n\tfor (let i = 0; i < content.length; i++) {\n\t\tconst char = content[i];\n\n\t\tif (escapeNext) {\n\t\t\tescapeNext = false;\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (char === \"\\\\\") {\n\t\t\tescapeNext = true;\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (char === '\"' && !escapeNext) {\n\t\t\tinString = !inString;\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (inString) continue;\n\n\t\tif (char === \"{\") {\n\t\t\tif (startIdx === -1) startIdx = i;\n\t\t\tbraceCount++;\n\t\t} else if (char === \"}\") {\n\t\t\tbraceCount--;\n\t\t\tif (braceCount === 0 && startIdx !== -1) {\n\t\t\t\tconst json = content.substring(startIdx, i + 1);\n\t\t\t\t// 验证是否包含我们需要的字段\n\t\t\t\tif (json.includes(\"questions\") || json.includes(\"summary\")) {\n\t\t\t\t\treturn json;\n\t\t\t\t}\n\t\t\t\tstartIdx = -1;\n\t\t\t}\n\t\t}\n\t}\n\n\treturn null;\n};\n\n// 尝试修复常见的JSON错误\nconst tryFixJson = (jsonText: string): string => {\n\tlet fixed = jsonText.trim();\n\n\t// 移除可能的尾随逗号（在数组或对象末尾）\n\tfixed = fixed.replace(/,(\\s*[}\\]])/g, \"$1\");\n\n\t// 尝试修复数组中的尾随逗号\n\tfixed = fixed.replace(/,\\s*]/g, \"]\");\n\n\t// 尝试修复对象中的尾随逗号\n\tfixed = fixed.replace(/,\\s*}/g, \"}\");\n\n\t// 尝试修复未闭合的字符串（简单情况）\n\t// 注意：这个修复可能不完美，但可以处理一些常见情况\n\n\treturn fixed;\n};\n\n// 尝试从不完整的流式响应中解析问题数量和标题\nconst tryParseStreamingQuestions = (\n\ttext: string,\n): { count: number; title: string | null } => {\n\t// 尝试找到 questions 数组\n\tconst questionsMatch = text.match(/\"questions\"\\s*:\\s*\\[/);\n\tif (!questionsMatch) {\n\t\treturn { count: 0, title: null };\n\t}\n\n\tconst afterQuestions = text.substring(\n\t\t(questionsMatch.index ?? 0) + questionsMatch[0].length,\n\t);\n\n\t// 尝试提取所有问题标题\n\tconst questionTitles: string[] = [];\n\tconst questionMatches = afterQuestions.matchAll(\n\t\t/\"question\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"/g,\n\t);\n\n\tfor (const match of questionMatches) {\n\t\tif (match[1]) {\n\t\t\tquestionTitles.push(match[1].replace(/\\\\\"/g, '\"')); // 处理转义的引号\n\t\t}\n\t}\n\n\tconst count = questionTitles.length;\n\tconst title = count > 0 ? questionTitles[count - 1] : null;\n\n\treturn { count, title };\n};\n\nexport const useBreakdownService = () => {\n\tconst generateQuestions = useCallback(\n\t\tasync (\n\t\t\ttodoName: string,\n\t\t\ttodoId?: number,\n\t\t\tonStreaming?: (count: number, title: string | null) => void,\n\t\t): Promise<Question[]> => {\n\t\t\tlet fullResponse = \"\";\n\n\t\t\tconsole.log(\n\t\t\t\t\"[Breakdown] 发送问题生成请求，任务名称:\",\n\t\t\t\ttodoName,\n\t\t\t\t\"任务ID:\",\n\t\t\t\ttodoId,\n\t\t\t);\n\t\t\tawait planQuestionnaireStream(\n\t\t\t\ttodoName,\n\t\t\t\t(chunk) => {\n\t\t\t\t\tfullResponse += chunk;\n\t\t\t\t\t// 实时解析问题数量和标题\n\t\t\t\t\tif (onStreaming) {\n\t\t\t\t\t\tconst { count, title } = tryParseStreamingQuestions(fullResponse);\n\t\t\t\t\t\tif (count > 0 || title) {\n\t\t\t\t\t\t\tonStreaming(count, title);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t\ttodoId,\n\t\t\t);\n\t\t\tconsole.log(\"[Breakdown] 收到完整响应，长度:\", fullResponse.length);\n\t\t\tconsole.log(\"[Breakdown] 完整响应内容:\", fullResponse);\n\n\t\t\tconst jsonText = findJson(fullResponse);\n\t\t\tif (!jsonText) {\n\t\t\t\tconsole.error(\n\t\t\t\t\t\"[Breakdown] 无法从响应中提取JSON，响应内容:\",\n\t\t\t\t\tfullResponse,\n\t\t\t\t);\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`未找到问题JSON，请重试。响应内容：${fullResponse.substring(0, 200)}`,\n\t\t\t\t);\n\t\t\t}\n\t\t\tconsole.log(\"[Breakdown] 提取的JSON文本:\", jsonText);\n\n\t\t\ttry {\n\t\t\t\t// 先尝试直接解析\n\t\t\t\tlet parsed: GenerateQuestionsResponse;\n\t\t\t\ttry {\n\t\t\t\t\tparsed = JSON.parse(jsonText) as GenerateQuestionsResponse;\n\t\t\t\t} catch (parseError) {\n\t\t\t\t\t// 如果解析失败，尝试修复JSON\n\t\t\t\t\tconsole.warn(\"[Breakdown] JSON解析失败，尝试修复:\", parseError);\n\t\t\t\t\tconsole.warn(\n\t\t\t\t\t\t\"[Breakdown] 原始JSON文本（前500字符）:\",\n\t\t\t\t\t\tjsonText.substring(0, 500),\n\t\t\t\t\t);\n\t\t\t\t\tconst fixedJson = tryFixJson(jsonText);\n\t\t\t\t\tconsole.log(\n\t\t\t\t\t\t\"[Breakdown] 修复后的JSON（前500字符）:\",\n\t\t\t\t\t\tfixedJson.substring(0, 500),\n\t\t\t\t\t);\n\t\t\t\t\ttry {\n\t\t\t\t\t\tparsed = JSON.parse(fixedJson) as GenerateQuestionsResponse;\n\t\t\t\t\t} catch (fixError) {\n\t\t\t\t\t\tconsole.error(\"[Breakdown] 修复后仍然无法解析:\", fixError);\n\t\t\t\t\t\t// 显示JSON文本的详细位置信息\n\t\t\t\t\t\tconst errorMsg =\n\t\t\t\t\t\t\tfixError instanceof Error ? fixError.message : String(fixError);\n\t\t\t\t\t\tconst positionMatch = errorMsg.match(/position (\\d+)/);\n\t\t\t\t\t\tif (positionMatch) {\n\t\t\t\t\t\t\tconst pos = Number.parseInt(positionMatch[1], 10);\n\t\t\t\t\t\t\tconst start = Math.max(0, pos - 50);\n\t\t\t\t\t\t\tconst end = Math.min(fixedJson.length, pos + 50);\n\t\t\t\t\t\t\tconsole.error(\n\t\t\t\t\t\t\t\t\"[Breakdown] JSON错误位置附近的文本:\",\n\t\t\t\t\t\t\t\tfixedJson.substring(start, end),\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\tconsole.error(\n\t\t\t\t\t\t\t\t\"[Breakdown] 错误位置:\",\n\t\t\t\t\t\t\t\tpos,\n\t\t\t\t\t\t\t\t\"字符:\",\n\t\t\t\t\t\t\t\tfixedJson[pos],\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t}\n\t\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t\t`JSON解析失败：${errorMsg}。请检查LLM返回的JSON格式。`,\n\t\t\t\t\t\t);\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif (!Array.isArray(parsed.questions)) {\n\t\t\t\t\tthrow new Error(\"Invalid questions format\");\n\t\t\t\t}\n\n\t\t\t\t// 验证和规范化问题\n\t\t\t\tconst questions: Question[] = parsed.questions\n\t\t\t\t\t.map((q, index) => {\n\t\t\t\t\t\tif (\n\t\t\t\t\t\t\t!q ||\n\t\t\t\t\t\t\ttypeof q.question !== \"string\" ||\n\t\t\t\t\t\t\t!Array.isArray(q.options)\n\t\t\t\t\t\t) {\n\t\t\t\t\t\t\treturn null;\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\tid: q.id || `q${index + 1}`,\n\t\t\t\t\t\t\tquestion: q.question.trim(),\n\t\t\t\t\t\t\toptions: q.options\n\t\t\t\t\t\t\t\t.filter((opt): opt is string => typeof opt === \"string\")\n\t\t\t\t\t\t\t\t.map((opt) => opt.trim())\n\t\t\t\t\t\t\t\t.filter(Boolean),\n\t\t\t\t\t\t\t// type 字段可选，默认多选，不再从 LLM 响应中读取\n\t\t\t\t\t\t};\n\t\t\t\t\t})\n\t\t\t\t\t.filter((q): q is Question => q !== null && q.options.length > 0);\n\n\t\t\t\tif (questions.length === 0) {\n\t\t\t\t\tthrow new Error(\"No valid questions generated\");\n\t\t\t\t}\n\n\t\t\t\treturn questions;\n\t\t\t} catch (err) {\n\t\t\t\tconsole.error(\"Failed to parse questions:\", err);\n\t\t\t\tthrow new Error(\"解析问题失败，请重试。\");\n\t\t\t}\n\t\t},\n\t\t[],\n\t);\n\n\tconst generateSummary = useCallback(\n\t\tasync (\n\t\t\ttodoName: string,\n\t\t\tanswers: Record<string, string[]>,\n\t\t\tonStreaming?: (chunk: string) => void,\n\t\t): Promise<{ summary: string; subtasks: ParsedTodoTree[] }> => {\n\t\t\tlet fullResponse = \"\";\n\n\t\t\tconsole.log(\n\t\t\t\t\"[Breakdown] 发送总结生成请求，任务名称:\",\n\t\t\t\ttodoName,\n\t\t\t\t\"回答数量:\",\n\t\t\t\tObject.keys(answers).length,\n\t\t\t);\n\t\t\tawait planSummaryStream(todoName, answers, (chunk) => {\n\t\t\t\tfullResponse += chunk;\n\t\t\t\t// 如果提供了流式回调，实时更新\n\t\t\t\tif (onStreaming) {\n\t\t\t\t\tonStreaming(fullResponse);\n\t\t\t\t}\n\t\t\t});\n\t\t\tconsole.log(\"[Breakdown] 收到完整响应，长度:\", fullResponse.length);\n\t\t\tconsole.log(\"[Breakdown] 完整响应内容:\", fullResponse);\n\n\t\t\tconst jsonText = findJson(fullResponse);\n\t\t\tif (!jsonText) {\n\t\t\t\tconsole.error(\n\t\t\t\t\t\"[Breakdown] 无法从响应中提取JSON，响应内容:\",\n\t\t\t\t\tfullResponse,\n\t\t\t\t);\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`未找到总结JSON，请重试。响应内容：${fullResponse.substring(0, 200)}`,\n\t\t\t\t);\n\t\t\t}\n\t\t\tconsole.log(\"[Breakdown] 提取的JSON文本:\", jsonText);\n\n\t\t\ttry {\n\t\t\t\t// 先尝试直接解析\n\t\t\t\tlet parsed: GenerateSummaryResponse;\n\t\t\t\ttry {\n\t\t\t\t\tparsed = JSON.parse(jsonText) as GenerateSummaryResponse;\n\t\t\t\t} catch (parseError) {\n\t\t\t\t\t// 如果解析失败，尝试修复JSON\n\t\t\t\t\tconsole.warn(\"[Breakdown] JSON解析失败，尝试修复:\", parseError);\n\t\t\t\t\tconsole.warn(\n\t\t\t\t\t\t\"[Breakdown] 原始JSON文本（前500字符）:\",\n\t\t\t\t\t\tjsonText.substring(0, 500),\n\t\t\t\t\t);\n\t\t\t\t\tconst fixedJson = tryFixJson(jsonText);\n\t\t\t\t\tconsole.log(\n\t\t\t\t\t\t\"[Breakdown] 修复后的JSON（前500字符）:\",\n\t\t\t\t\t\tfixedJson.substring(0, 500),\n\t\t\t\t\t);\n\t\t\t\t\ttry {\n\t\t\t\t\t\tparsed = JSON.parse(fixedJson) as GenerateSummaryResponse;\n\t\t\t\t\t} catch (fixError) {\n\t\t\t\t\t\tconsole.error(\"[Breakdown] 修复后仍然无法解析:\", fixError);\n\t\t\t\t\t\t// 显示JSON文本的详细位置信息\n\t\t\t\t\t\tconst errorMsg =\n\t\t\t\t\t\t\tfixError instanceof Error ? fixError.message : String(fixError);\n\t\t\t\t\t\tconst positionMatch = errorMsg.match(/position (\\d+)/);\n\t\t\t\t\t\tif (positionMatch) {\n\t\t\t\t\t\t\tconst pos = Number.parseInt(positionMatch[1], 10);\n\t\t\t\t\t\t\tconst start = Math.max(0, pos - 50);\n\t\t\t\t\t\t\tconst end = Math.min(fixedJson.length, pos + 50);\n\t\t\t\t\t\t\tconsole.error(\n\t\t\t\t\t\t\t\t\"[Breakdown] JSON错误位置附近的文本:\",\n\t\t\t\t\t\t\t\tfixedJson.substring(start, end),\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\tconsole.error(\n\t\t\t\t\t\t\t\t\"[Breakdown] 错误位置:\",\n\t\t\t\t\t\t\t\tpos,\n\t\t\t\t\t\t\t\t\"字符:\",\n\t\t\t\t\t\t\t\tfixedJson[pos],\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t}\n\t\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t\t`JSON解析失败：${errorMsg}。请检查LLM返回的JSON格式。`,\n\t\t\t\t\t\t);\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif (\n\t\t\t\t\ttypeof parsed.summary !== \"string\" ||\n\t\t\t\t\t!Array.isArray(parsed.subtasks)\n\t\t\t\t) {\n\t\t\t\t\tthrow new Error(\"Invalid summary format\");\n\t\t\t\t}\n\n\t\t\t\t// 验证和规范化子任务\n\t\t\t\tconst normalizeTodo = (item: unknown): ParsedTodoTree | null => {\n\t\t\t\t\tif (!item || typeof (item as { name?: unknown }).name !== \"string\") {\n\t\t\t\t\t\treturn null;\n\t\t\t\t\t}\n\t\t\t\t\tconst rawName = (item as { name: string }).name.trim();\n\t\t\t\t\tif (!rawName) return null;\n\n\t\t\t\t\tconst rawDescription = (item as { description?: unknown })\n\t\t\t\t\t\t.description;\n\t\t\t\t\tconst description =\n\t\t\t\t\t\ttypeof rawDescription === \"string\" && rawDescription.trim()\n\t\t\t\t\t\t\t? rawDescription.trim()\n\t\t\t\t\t\t\t: undefined;\n\n\t\t\t\t\tconst rawOrder = (item as { order?: unknown }).order;\n\t\t\t\t\tconst order =\n\t\t\t\t\t\ttypeof rawOrder === \"number\" && !Number.isNaN(rawOrder)\n\t\t\t\t\t\t\t? rawOrder\n\t\t\t\t\t\t\t: undefined;\n\n\t\t\t\t\tconst rawSubtasks = (item as { subtasks?: unknown }).subtasks;\n\t\t\t\t\tconst subtasks = Array.isArray(rawSubtasks)\n\t\t\t\t\t\t? rawSubtasks\n\t\t\t\t\t\t\t\t.map((task: unknown) => normalizeTodo(task))\n\t\t\t\t\t\t\t\t.filter((task): task is ParsedTodoTree => Boolean(task))\n\t\t\t\t\t\t: undefined;\n\n\t\t\t\t\treturn {\n\t\t\t\t\t\tname: rawName,\n\t\t\t\t\t\tdescription,\n\t\t\t\t\t\torder,\n\t\t\t\t\t\tsubtasks,\n\t\t\t\t\t};\n\t\t\t\t};\n\n\t\t\t\tconst subtasks: ParsedTodoTree[] = parsed.subtasks\n\t\t\t\t\t.map((item: unknown) => normalizeTodo(item))\n\t\t\t\t\t.filter(\n\t\t\t\t\t\t(item: ParsedTodoTree | null | undefined): item is ParsedTodoTree =>\n\t\t\t\t\t\t\tBoolean(item),\n\t\t\t\t\t);\n\n\t\t\t\treturn {\n\t\t\t\t\tsummary: parsed.summary.trim(),\n\t\t\t\t\tsubtasks,\n\t\t\t\t};\n\t\t\t} catch (err) {\n\t\t\t\tconsole.error(\"Failed to parse summary:\", err);\n\t\t\t\tthrow new Error(\"解析总结失败，请重试。\");\n\t\t\t}\n\t\t},\n\t\t[],\n\t);\n\n\treturn {\n\t\tgenerateQuestions,\n\t\tgenerateSummary,\n\t};\n};\n"
  },
  {
    "path": "free-todo-frontend/apps/chat/hooks/useChatController.ts",
    "content": "import { useQueryClient } from \"@tanstack/react-query\";\nimport { useTranslations } from \"next-intl\";\nimport type { KeyboardEvent } from \"react\";\nimport { useCallback, useEffect, useMemo, useState } from \"react\";\nimport { useSendMessage } from \"@/apps/chat/hooks/useSendMessage\";\nimport { useSessionCache } from \"@/apps/chat/hooks/useSessionCache\";\nimport { useSessionManager } from \"@/apps/chat/hooks/useSessionManager\";\nimport { useStreamController } from \"@/apps/chat/hooks/useStreamController\";\nimport { useToolCallTracker } from \"@/apps/chat/hooks/useToolCallTracker\";\nimport type { ChatMessage } from \"@/apps/chat/types\";\nimport { useChatHistory, useChatSessions, useTodos } from \"@/lib/query\";\nimport { useBreakdownStore } from \"@/lib/store/breakdown-store\";\nimport { useChatStore } from \"@/lib/store/chat-store\";\nimport { useUiStore } from \"@/lib/store/ui-store\";\nimport type { Todo } from \"@/lib/types\";\n\ntype UseChatControllerParams = {\n\tlocale: string;\n\tselectedTodoIds: number[];\n};\n\nexport const useChatController = ({\n\tlocale,\n\tselectedTodoIds,\n}: UseChatControllerParams) => {\n\tconst t = useTranslations(\"chat\");\n\tconst tCommon = useTranslations(\"common\");\n\tconst queryClient = useQueryClient();\n\n\t// ==================== 基础 Hooks ====================\n\n\tconst sessionCache = useSessionCache();\n\tconst streamController = useStreamController();\n\tconst toolCallTracker = useToolCallTracker();\n\n\t// ==================== Store 数据 ====================\n\n\tconst { data: todos = [] } = useTodos();\n\n\tconst {\n\t\tconversationId,\n\t\thistoryOpen,\n\t\tsetConversationId,\n\t\tsetHistoryOpen,\n\t} = useChatStore();\n\n\tconst resetBreakdown = useBreakdownStore((state) => state.resetBreakdown);\n\tconst selectedAgnoTools = useUiStore((state) => state.selectedAgnoTools);\n\tconst selectedExternalTools = useUiStore(\n\t\t(state) => state.selectedExternalTools,\n\t);\n\n\t// 调试：打印选中的工具\n\tuseEffect(() => {\n\t\tconsole.log(\n\t\t\t\"[useChatController] Current selectedAgnoTools:\",\n\t\t\tselectedAgnoTools,\n\t\t);\n\t\tconsole.log(\n\t\t\t\"[useChatController] Current selectedExternalTools:\",\n\t\t\tselectedExternalTools,\n\t\t);\n\t}, [selectedAgnoTools, selectedExternalTools]);\n\n\t// ==================== TanStack Query ====================\n\n\tconst {\n\t\tdata: sessions = [],\n\t\tisLoading: historyLoading,\n\t\terror: sessionsError,\n\t} = useChatSessions({\n\t\tenabled: historyOpen,\n\t});\n\n\tconst {\n\t\tdata: sessionHistory = [],\n\t\tisFetching: historyFetching,\n\t\tisFetched: historyFetched,\n\t} = useChatHistory(conversationId);\n\n\t// ==================== 本地状态 ====================\n\n\tconst [messages, setMessages] = useState<ChatMessage[]>(() => []);\n\tconst [inputValue, setInputValue] = useState(\"\");\n\tconst [isStreaming, setIsStreaming] = useState(false);\n\tconst [error, setError] = useState<string | null>(null);\n\tconst [isComposing, setIsComposing] = useState(false);\n\n\tconst historyError = sessionsError ? t(\"loadHistoryFailed\") : null;\n\n\t// ==================== 计算属性 ====================\n\n\tconst selectedTodos = useMemo(\n\t\t() => todos.filter((todo: Todo) => selectedTodoIds.includes(todo.id)),\n\t\t[selectedTodoIds, todos],\n\t) as Todo[];\n\n\tconst effectiveTodos = useMemo(\n\t\t() => (selectedTodos.length ? selectedTodos : []),\n\t\t[selectedTodos],\n\t);\n\n\tconst hasSelection = selectedTodoIds.length > 0;\n\n\t// ==================== 组合 Hooks ====================\n\n\t// 会话管理 hook\n\tconst { handleNewChat, handleLoadSession } = useSessionManager({\n\t\tsessionCache,\n\t\tstreamController,\n\t\tresetBreakdown,\n\t\tsetConversationId,\n\t\tsetHistoryOpen,\n\t\tsetMessages,\n\t\tsetInputValue,\n\t\tsetIsStreaming,\n\t\tsetError,\n\t\tsessionHistory,\n\t\thistoryFetched,\n\t\thistoryFetching,\n\t\tconversationId,\n\t});\n\n\t// 发送消息 hook\n\tconst { sendMessage } = useSendMessage({\n\t\tlocale,\n\t\thasSelection,\n\t\teffectiveTodos,\n\t\ttodos,\n\t\tselectedAgnoTools,\n\t\tselectedExternalTools,\n\t\tsessionCache,\n\t\tstreamController,\n\t\ttoolCallTracker,\n\t\tqueryClient,\n\t\tt,\n\t\ttCommon,\n\t\tsetConversationId,\n\t\tsetMessages,\n\t\tsetInputValue,\n\t\tsetIsStreaming,\n\t\tsetError,\n\t});\n\n\t// ==================== 事件处理 ====================\n\n\tconst handleStop = useCallback(() => {\n\t\tstreamController.cancelRequest();\n\t\tsetIsStreaming(false);\n\t}, [streamController]);\n\n\tconst handleSend = useCallback(async () => {\n\t\tawait sendMessage(inputValue, true);\n\t}, [sendMessage, inputValue]);\n\n\tconst handleKeyDown = useCallback(\n\t\t(event: KeyboardEvent<HTMLTextAreaElement>) => {\n\t\t\tif (\n\t\t\t\tevent.key === \"Enter\" &&\n\t\t\t\t!event.shiftKey &&\n\t\t\t\t!isComposing &&\n\t\t\t\t!event.nativeEvent.isComposing\n\t\t\t) {\n\t\t\t\tevent.preventDefault();\n\t\t\t\tvoid handleSend();\n\t\t\t}\n\t\t},\n\t\t[handleSend, isComposing],\n\t);\n\n\t// ==================== 返回接口（保持向后兼容） ====================\n\n\treturn {\n\t\tmessages,\n\t\tsetMessages,\n\t\tinputValue,\n\t\tsetInputValue,\n\t\tconversationId,\n\t\tsetConversationId,\n\t\tisStreaming,\n\t\tsetIsStreaming,\n\t\terror,\n\t\tsetError,\n\t\thistoryOpen,\n\t\tsetHistoryOpen,\n\t\thistoryLoading,\n\t\thistoryError,\n\t\tsessions,\n\t\tisComposing,\n\t\tsetIsComposing,\n\t\tsendMessage,\n\t\thandleSend,\n\t\thandleStop,\n\t\thandleNewChat,\n\t\thandleLoadSession,\n\t\thandleKeyDown,\n\t\teffectiveTodos,\n\t\thasSelection,\n\t\ttodos,\n\t};\n};\n"
  },
  {
    "path": "free-todo-frontend/apps/chat/hooks/useChatPrompts.ts",
    "content": "import { useEffect, useState } from \"react\";\nimport { unwrapApiData } from \"@/lib/api/fetcher\";\nimport { getChatPromptsApiGetChatPromptsGet } from \"@/lib/generated/config/config\";\n\n/**\n * 聊天提示词响应接口\n */\ninterface ChatPromptsResponse {\n\tsuccess: boolean;\n\teditSystemPrompt: string;\n\tplanSystemPrompt: string;\n}\n\n/**\n * 管理 editSystemPrompt 的异步加载\n *\n * @param locale - 语言设置\n * @returns editSystemPrompt 和加载状态\n */\nexport const useChatPrompts = (locale: string) => {\n\tconst [editSystemPrompt, setEditSystemPrompt] = useState<string>(\"\");\n\tconst [isLoading, setIsLoading] = useState(true);\n\n\tuseEffect(() => {\n\t\tlet cancelled = false;\n\n\t\tasync function loadPrompts() {\n\t\t\tsetIsLoading(true);\n\t\t\ttry {\n\t\t\t\tconst response = await getChatPromptsApiGetChatPromptsGet({\n\t\t\t\t\tlocale,\n\t\t\t\t});\n\t\t\t\tconst data = unwrapApiData<ChatPromptsResponse>(response);\n\n\t\t\t\tif (!cancelled && data?.success) {\n\t\t\t\t\tsetEditSystemPrompt(data.editSystemPrompt);\n\t\t\t\t}\n\t\t\t} catch (error) {\n\t\t\t\tconsole.error(\"Failed to load chat prompts:\", error);\n\t\t\t\t// 如果加载失败，使用空字符串（向后兼容）\n\t\t\t\tif (!cancelled) {\n\t\t\t\t\tsetEditSystemPrompt(\"\");\n\t\t\t\t}\n\t\t\t} finally {\n\t\t\t\tif (!cancelled) {\n\t\t\t\t\tsetIsLoading(false);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tvoid loadPrompts();\n\n\t\treturn () => {\n\t\t\tcancelled = true;\n\t\t};\n\t}, [locale]);\n\n\treturn {\n\t\teditSystemPrompt,\n\t\tisLoading,\n\t};\n};\n"
  },
  {
    "path": "free-todo-frontend/apps/chat/hooks/useMessageExtraction.ts",
    "content": "import { useTranslations } from \"next-intl\";\nimport { useCallback, useRef, useState } from \"react\";\nimport type { ChatMessage } from \"@/apps/chat/types\";\nimport { buildHierarchicalTodoContext } from \"@/apps/chat/utils/todoContext\";\nimport { toastError } from \"@/lib/toast\";\nimport type { Todo } from \"@/lib/types\";\n\nexport type ExtractionState = {\n\tisExtracting: boolean;\n\ttodos: Array<{\n\t\tname: string;\n\t\tdescription?: string | null;\n\t\ttags: string[];\n\t}>;\n\tparentTodoId: number | null;\n};\n\ntype UseMessageExtractionParams = {\n\teffectiveTodos: Todo[];\n\tallTodos: Todo[];\n};\n\n/**\n * 管理消息的待办提取功能\n */\nexport function useMessageExtraction({\n\teffectiveTodos,\n\tallTodos,\n}: UseMessageExtractionParams) {\n\tconst t = useTranslations(\"chat\");\n\tconst tCommon = useTranslations(\"common\");\n\tconst [extractionStates, setExtractionStates] = useState<\n\t\tMap<string, ExtractionState>\n\t>(new Map());\n\tconst extractionStatesRef = useRef<Map<string, ExtractionState>>(new Map());\n\n\t// 同步 ref 和 state\n\tconst updateExtractionStates = useCallback(\n\t\t(\n\t\t\tupdater: (\n\t\t\t\tprev: Map<string, ExtractionState>,\n\t\t\t) => Map<string, ExtractionState>,\n\t\t) => {\n\t\t\tsetExtractionStates((prev) => {\n\t\t\t\tconst newMap = updater(prev);\n\t\t\t\textractionStatesRef.current = newMap;\n\t\t\t\treturn newMap;\n\t\t\t});\n\t\t},\n\t\t[],\n\t);\n\n\tconst handleExtractTodos = useCallback(\n\t\tasync (messageId: string, messages: ChatMessage[]) => {\n\t\t\tconst currentState = extractionStatesRef.current.get(messageId);\n\t\t\tif (currentState?.isExtracting) return;\n\n\t\t\t// 获取目标消息及其之前的所有消息\n\t\t\tconst targetIndex = messages.findIndex((m) => m.id === messageId);\n\t\t\tconst messagesForExtraction = messages\n\t\t\t\t.slice(0, targetIndex + 1)\n\t\t\t\t.map((msg) => ({\n\t\t\t\t\trole: msg.role,\n\t\t\t\t\tcontent: msg.content,\n\t\t\t\t}));\n\n\t\t\t// 获取父待办ID（使用第一个关联的待办）\n\t\t\tconst parentTodoId =\n\t\t\t\teffectiveTodos.length > 0 ? effectiveTodos[0].id : null;\n\n\t\t\t// 构建待办上下文\n\t\t\tconst todoContext =\n\t\t\t\teffectiveTodos.length > 0\n\t\t\t\t\t? buildHierarchicalTodoContext(effectiveTodos, allTodos, t, tCommon)\n\t\t\t\t\t: null;\n\n\t\t\t// 设置提取状态\n\t\t\tupdateExtractionStates((prev) => {\n\t\t\t\tconst newMap = new Map(prev);\n\t\t\t\tnewMap.set(messageId, {\n\t\t\t\t\tisExtracting: true,\n\t\t\t\t\ttodos: [],\n\t\t\t\t\tparentTodoId,\n\t\t\t\t});\n\t\t\t\treturn newMap;\n\t\t\t});\n\n\t\ttry {\n\t\t\t// 客户端使用相对路径，通过 Next.js rewrites 代理到后端（支持动态端口）\n\t\t\tconst response = await fetch(`/api/chat/extract-todos-from-messages`, {\n\t\t\t\tmethod: \"POST\",\n\t\t\t\theaders: {\n\t\t\t\t\t\"Content-Type\": \"application/json\",\n\t\t\t\t},\n\t\t\t\tbody: JSON.stringify({\n\t\t\t\t\tmessages: messagesForExtraction,\n\t\t\t\t\tparent_todo_id: parentTodoId,\n\t\t\t\t\ttodo_context: todoContext,\n\t\t\t\t}),\n\t\t\t});\n\n\t\t\tif (!response.ok) {\n\t\t\t\t// 尝试从响应中获取错误信息\n\t\t\t\tlet errorMessage = `提取待办失败 (${response.status})`;\n\t\t\t\ttry {\n\t\t\t\t\tconst errorData = await response.json();\n\t\t\t\t\tif (errorData.detail) {\n\t\t\t\t\t\terrorMessage = errorData.detail;\n\t\t\t\t\t} else if (errorData.error_message) {\n\t\t\t\t\t\terrorMessage = errorData.error_message;\n\t\t\t\t\t} else if (errorData.message) {\n\t\t\t\t\t\terrorMessage = errorData.message;\n\t\t\t\t\t}\n\t\t\t\t} catch {\n\t\t\t\t\t// 如果无法解析 JSON，使用状态文本\n\t\t\t\t\terrorMessage = `提取待办失败: ${response.statusText || response.status}`;\n\t\t\t\t}\n\t\t\t\tthrow new Error(errorMessage);\n\t\t\t}\n\n\t\t\t\tconst data = await response.json();\n\n\t\t\t\tif (data.error_message) {\n\t\t\t\t\ttoastError(data.error_message);\n\t\t\t\t\tupdateExtractionStates((prev) => {\n\t\t\t\t\t\tconst newMap = new Map(prev);\n\t\t\t\t\t\tnewMap.delete(messageId);\n\t\t\t\t\t\treturn newMap;\n\t\t\t\t\t});\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tif (data.todos.length === 0) {\n\t\t\t\t\ttoastError(t(\"noTodosFound\") || \"未发现待办事项\");\n\t\t\t\t\tupdateExtractionStates((prev) => {\n\t\t\t\t\t\tconst newMap = new Map(prev);\n\t\t\t\t\t\tnewMap.delete(messageId);\n\t\t\t\t\t\treturn newMap;\n\t\t\t\t\t});\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\t// 转换格式并模拟流式显示\n\t\t\t\tconst extractedTodos = data.todos.map(\n\t\t\t\t\t(todo: {\n\t\t\t\t\t\tname: string;\n\t\t\t\t\t\tdescription?: string | null;\n\t\t\t\t\t\ttags?: string[];\n\t\t\t\t\t}) => ({\n\t\t\t\t\t\tname: todo.name,\n\t\t\t\t\t\tdescription: todo.description || null,\n\t\t\t\t\t\ttags: todo.tags || [],\n\t\t\t\t\t}),\n\t\t\t\t);\n\n\t\t\t\t// 模拟流式显示：逐个添加待办\n\t\t\t\tfor (let i = 0; i < extractedTodos.length; i++) {\n\t\t\t\t\tawait new Promise((resolve) => setTimeout(resolve, 200));\n\t\t\t\t\tupdateExtractionStates((prev) => {\n\t\t\t\t\t\tconst newMap = new Map(prev);\n\t\t\t\t\t\tconst current = newMap.get(messageId);\n\t\t\t\t\t\tif (current) {\n\t\t\t\t\t\t\tnewMap.set(messageId, {\n\t\t\t\t\t\t\t\t...current,\n\t\t\t\t\t\t\t\ttodos: extractedTodos.slice(0, i + 1),\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn newMap;\n\t\t\t\t\t});\n\t\t\t\t}\n\n\t\t\t\t// 提取完成\n\t\t\t\tupdateExtractionStates((prev) => {\n\t\t\t\t\tconst newMap = new Map(prev);\n\t\t\t\t\tconst current = newMap.get(messageId);\n\t\t\t\t\tif (current) {\n\t\t\t\t\t\tnewMap.set(messageId, {\n\t\t\t\t\t\t\t...current,\n\t\t\t\t\t\t\tisExtracting: false,\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\t\t\t\t\treturn newMap;\n\t\t\t\t});\n\t\t\t} catch (error) {\n\t\t\t\tconsole.error(\"提取待办失败:\", error);\n\t\t\t\ttoastError(\n\t\t\t\t\terror instanceof Error ? error.message : \"提取待办失败，请稍后重试\",\n\t\t\t\t);\n\t\t\t\tupdateExtractionStates((prev) => {\n\t\t\t\t\tconst newMap = new Map(prev);\n\t\t\t\t\tnewMap.delete(messageId);\n\t\t\t\t\treturn newMap;\n\t\t\t\t});\n\t\t\t}\n\t\t},\n\t\t[effectiveTodos, allTodos, t, tCommon, updateExtractionStates],\n\t);\n\n\tconst removeExtractionState = useCallback(\n\t\t(messageId: string) => {\n\t\t\tupdateExtractionStates((prev) => {\n\t\t\t\tconst newMap = new Map(prev);\n\t\t\t\tnewMap.delete(messageId);\n\t\t\t\treturn newMap;\n\t\t\t});\n\t\t},\n\t\t[updateExtractionStates],\n\t);\n\n\treturn {\n\t\textractionStates,\n\t\thandleExtractTodos,\n\t\tremoveExtractionState,\n\t};\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/chat/hooks/useMessageScroll.ts",
    "content": "import { useCallback, useEffect, useRef } from \"react\";\nimport type { ChatMessage } from \"@/apps/chat/types\";\n\n/**\n * 管理消息列表的滚动行为\n */\nexport function useMessageScroll(\n\tmessages: ChatMessage[],\n\tisStreaming: boolean,\n) {\n\tconst messageListRef = useRef<HTMLDivElement>(null);\n\t// 跟踪用户是否在底部（或接近底部）\n\tconst isAtBottomRef = useRef(true);\n\t// 跟踪上一次消息数量，用于检测新消息\n\tconst prevMessageCountRef = useRef(0);\n\n\t// 检查是否在底部（允许 30px 的误差）\n\tconst checkIsAtBottom = useCallback(() => {\n\t\tconst el = messageListRef.current;\n\t\tif (!el) return true;\n\t\tconst threshold = 30;\n\t\treturn el.scrollHeight - el.scrollTop - el.clientHeight <= threshold;\n\t}, []);\n\n\t// 处理滚动事件\n\tconst handleScroll = useCallback(() => {\n\t\tisAtBottomRef.current = checkIsAtBottom();\n\t}, [checkIsAtBottom]);\n\n\t// 滚动到底部\n\tconst scrollToBottom = useCallback((behavior: ScrollBehavior = \"smooth\") => {\n\t\tconst el = messageListRef.current;\n\t\tif (el) {\n\t\t\tel.scrollTo({ top: el.scrollHeight, behavior });\n\t\t}\n\t}, []);\n\n\t// 当用户发送新消息时，强制滚动到底部\n\tuseEffect(() => {\n\t\tif (messages.length === 0) return;\n\n\t\tconst currentCount = messages.length;\n\t\tconst prevCount = prevMessageCountRef.current;\n\t\tprevMessageCountRef.current = currentCount;\n\n\t\t// 检测是否是用户发送了新消息（消息数量增加且最后一条是用户消息）\n\t\tconst lastMessage = messages[messages.length - 1];\n\t\tconst isNewUserMessage =\n\t\t\tcurrentCount > prevCount && lastMessage?.role === \"user\";\n\n\t\tif (isNewUserMessage) {\n\t\t\t// 用户发送新消息时，强制滚动到底部并重置状态\n\t\t\tisAtBottomRef.current = true;\n\t\t\tscrollToBottom();\n\t\t}\n\t}, [messages, scrollToBottom]);\n\n\t// 流式输出时，只有在底部才自动滚动\n\t// biome-ignore lint/correctness/useExhaustiveDependencies: messages dependency is needed to trigger scroll on each streaming update\n\tuseEffect(() => {\n\t\tif (!isStreaming) return;\n\t\tif (!isAtBottomRef.current) return;\n\n\t\t// 使用 requestAnimationFrame 确保 DOM 更新后再滚动\n\t\tconst frameId = requestAnimationFrame(() => {\n\t\t\tscrollToBottom(\"auto\");\n\t\t});\n\n\t\treturn () => cancelAnimationFrame(frameId);\n\t}, [messages, isStreaming, scrollToBottom]);\n\n\treturn {\n\t\tmessageListRef,\n\t\thandleScroll,\n\t};\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/chat/hooks/usePlanParser.ts",
    "content": "import { useCallback, useEffect, useState } from \"react\";\nimport type { ParsedTodoTree } from \"@/apps/chat/types\";\nimport { createId } from \"@/apps/chat/utils/id\";\nimport { unwrapApiData } from \"@/lib/api/fetcher\";\nimport { getChatPromptsApiGetChatPromptsGet } from \"@/lib/generated/config/config\";\nimport type { CreateTodoInput } from \"@/lib/types\";\n\ntype TranslationFunction = (\n\tkey: string,\n\tvalues?: Record<string, string | number | Date>,\n) => string;\n\ninterface ChatPromptsResponse {\n\tsuccess: boolean;\n\teditSystemPrompt: string;\n\tplanSystemPrompt: string;\n}\n\nexport const usePlanParser = (locale: string, t: TranslationFunction) => {\n\t// 从 API 获取任务规划系统提示词\n\tconst [planSystemPrompt, setPlanSystemPrompt] = useState<string>(\"\");\n\tconst enableChatPrompts =\n\t\tprocess.env.NEXT_PUBLIC_ENABLE_CHAT_PROMPTS === \"true\";\n\n\tuseEffect(() => {\n\t\tlet cancelled = false;\n\t\tasync function loadPrompts() {\n\t\t\t// 可配置关闭：后端不可用时跳过调用，避免控制台抛 500\n\t\t\tif (!enableChatPrompts) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\ttry {\n\t\t\t\tconst response = await getChatPromptsApiGetChatPromptsGet({\n\t\t\t\t\tlocale,\n\t\t\t\t});\n\t\t\t\tconst data = unwrapApiData<ChatPromptsResponse>(response);\n\t\t\t\tif (!cancelled && data?.success) {\n\t\t\t\t\tsetPlanSystemPrompt(data.planSystemPrompt);\n\t\t\t\t}\n\t\t\t} catch (_error) {\n\t\t\t\t// 后端不可用时静默降级，避免控制台报 500\n\t\t\t\t// 如果加载失败，使用默认值（向后兼容）\n\t\t\t\tif (!cancelled) {\n\t\t\t\t\tsetPlanSystemPrompt(\"\");\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif (enableChatPrompts) {\n\t\t\tvoid loadPrompts();\n\t\t}\n\t\treturn () => {\n\t\t\tcancelled = true;\n\t\t};\n\t}, [enableChatPrompts, locale]);\n\n\tconst parsePlanTodos = useCallback(\n\t\t(\n\t\t\tcontent: string,\n\t\t): {\n\t\t\ttodos: ParsedTodoTree[];\n\t\t\terror: string | null;\n\t\t} => {\n\t\t\tconst findJson = () => {\n\t\t\t\tconst fencedJson = content.match(/```json\\s*([\\s\\S]*?)```/i);\n\t\t\t\tif (fencedJson?.[1]) return fencedJson[1];\n\n\t\t\t\tconst fenced = content.match(/```\\s*([\\s\\S]*?)```/);\n\t\t\t\tif (fenced?.[1]) return fenced[1];\n\n\t\t\t\tconst inline = content.match(/\\{[\\s\\S]*\"todos\"[\\s\\S]*\\}/);\n\t\t\t\tif (inline?.[0]) return inline[0];\n\t\t\t\treturn null;\n\t\t\t};\n\n\t\t\tconst jsonText = findJson();\n\t\t\tif (!jsonText) {\n\t\t\t\treturn {\n\t\t\t\t\ttodos: [],\n\t\t\t\t\terror: t(\"noPlanJsonFound\"),\n\t\t\t\t};\n\t\t\t}\n\n\t\t\ttry {\n\t\t\t\tconst parsed = JSON.parse(jsonText);\n\t\t\t\tconst rawTodos = Array.isArray(parsed?.todos) ? parsed.todos : [];\n\t\t\t\tconst normalizeTodo = (item: unknown): ParsedTodoTree | null => {\n\t\t\t\t\tif (!item || typeof (item as { name?: unknown }).name !== \"string\") {\n\t\t\t\t\t\treturn null;\n\t\t\t\t\t}\n\t\t\t\t\tconst rawName = (item as { name: string }).name.trim();\n\t\t\t\t\tif (!rawName) return null;\n\n\t\t\t\t\tconst rawDescription = (item as { description?: unknown })\n\t\t\t\t\t\t.description;\n\t\t\t\t\tconst description =\n\t\t\t\t\t\ttypeof rawDescription === \"string\" && rawDescription.trim()\n\t\t\t\t\t\t\t? rawDescription.trim()\n\t\t\t\t\t\t\t: undefined;\n\n\t\t\t\t\tconst rawTags = (item as { tags?: unknown }).tags;\n\t\t\t\t\tconst tags = Array.isArray(rawTags)\n\t\t\t\t\t\t? rawTags\n\t\t\t\t\t\t\t\t.filter((tag): tag is string => typeof tag === \"string\")\n\t\t\t\t\t\t\t\t.map((tag) => tag.trim())\n\t\t\t\t\t\t\t\t.filter(Boolean)\n\t\t\t\t\t\t: undefined;\n\n\t\t\t\t\tconst rawStartTime = (\n\t\t\t\t\t\titem as {\n\t\t\t\t\t\t\tstart_time?: unknown;\n\t\t\t\t\t\t\tstartTime?: unknown;\n\t\t\t\t\t\t\tdeadline?: unknown;\n\t\t\t\t\t\t}\n\t\t\t\t\t).start_time ?? (item as { startTime?: unknown }).startTime ?? (item as { deadline?: unknown }).deadline;\n\t\t\t\t\tconst startTime =\n\t\t\t\t\t\ttypeof rawStartTime === \"string\" && rawStartTime.trim()\n\t\t\t\t\t\t\t? rawStartTime.trim()\n\t\t\t\t\t\t\t: undefined;\n\n\t\t\t\t\tconst rawEndTime = (\n\t\t\t\t\t\titem as { end_time?: unknown; endTime?: unknown }\n\t\t\t\t\t).end_time ?? (item as { endTime?: unknown }).endTime;\n\t\t\t\t\tconst endTime =\n\t\t\t\t\t\ttypeof rawEndTime === \"string\" && rawEndTime.trim()\n\t\t\t\t\t\t\t? rawEndTime.trim()\n\t\t\t\t\t\t\t: undefined;\n\n\t\t\t\t\tconst rawOrder = (item as { order?: unknown }).order;\n\t\t\t\t\tconst order =\n\t\t\t\t\t\ttypeof rawOrder === \"number\" && !Number.isNaN(rawOrder)\n\t\t\t\t\t\t\t? rawOrder\n\t\t\t\t\t\t\t: undefined;\n\n\t\t\t\t\tconst rawSubtasks = (item as { subtasks?: unknown }).subtasks;\n\t\t\t\t\tconst subtasks = Array.isArray(rawSubtasks)\n\t\t\t\t\t\t? rawSubtasks\n\t\t\t\t\t\t\t\t.map((task: unknown) => normalizeTodo(task))\n\t\t\t\t\t\t\t\t.filter((task): task is ParsedTodoTree => Boolean(task))\n\t\t\t\t\t\t: undefined;\n\n\t\t\t\t\treturn {\n\t\t\t\t\t\tname: rawName,\n\t\t\t\t\t\tdescription,\n\t\t\t\t\t\ttags,\n\t\t\t\t\t\tstartTime,\n\t\t\t\t\t\tendTime,\n\t\t\t\t\t\torder,\n\t\t\t\t\t\tsubtasks,\n\t\t\t\t\t};\n\t\t\t\t};\n\n\t\t\t\tconst todos: ParsedTodoTree[] = rawTodos\n\t\t\t\t\t.map((item: unknown) => normalizeTodo(item))\n\t\t\t\t\t.filter(\n\t\t\t\t\t\t(item: ParsedTodoTree | null | undefined): item is ParsedTodoTree =>\n\t\t\t\t\t\t\tBoolean(item),\n\t\t\t\t\t);\n\n\t\t\t\tif (!todos.length) {\n\t\t\t\t\treturn {\n\t\t\t\t\t\ttodos: [],\n\t\t\t\t\t\terror: t(\"parsedNoValidTodos\"),\n\t\t\t\t\t};\n\t\t\t\t}\n\n\t\t\t\treturn { todos, error: null };\n\t\t\t} catch (err) {\n\t\t\t\tconsole.error(err);\n\t\t\t\treturn {\n\t\t\t\t\ttodos: [],\n\t\t\t\t\terror: t(\"parsePlanJsonFailed\"),\n\t\t\t\t};\n\t\t\t}\n\t\t},\n\t\t[t],\n\t);\n\n\tconst buildTodoPayloads = useCallback((trees: ParsedTodoTree[]) => {\n\t\t// Use a temporary string ID for tracking parent-child relationships\n\t\t// These will be replaced with actual numeric IDs during creation\n\t\t// Use Omit to properly override parentTodoId type for temporary string IDs\n\t\tconst payloads: (Omit<CreateTodoInput, \"parentTodoId\"> & {\n\t\t\tid?: string;\n\t\t\tparentTodoId?: string | number | null;\n\t\t})[] = [];\n\t\tconst walk = (nodes: ParsedTodoTree[], parentId?: string | null) => {\n\t\t\tnodes.forEach((node) => {\n\t\t\t\tconst id = createId();\n\t\t\t\tpayloads.push({\n\t\t\t\t\tid, // Temporary string ID for tracking\n\t\t\t\t\tname: node.name,\n\t\t\t\t\tdescription: node.description,\n\t\t\t\t\ttags: node.tags,\n\t\t\t\t\tstartTime: node.startTime,\n\t\t\t\t\tendTime: node.endTime,\n\t\t\t\t\torder: node.order,\n\t\t\t\t\tparentTodoId: parentId ?? null,\n\t\t\t\t});\n\t\t\t\tif (node.subtasks?.length) {\n\t\t\t\t\twalk(node.subtasks, id);\n\t\t\t\t}\n\t\t\t});\n\t\t};\n\t\twalk(trees, null);\n\t\treturn payloads;\n\t}, []);\n\n\treturn {\n\t\tplanSystemPrompt,\n\t\tparsePlanTodos,\n\t\tbuildTodoPayloads,\n\t};\n};\n"
  },
  {
    "path": "free-todo-frontend/apps/chat/hooks/useSendMessage.ts",
    "content": "import type { QueryClient } from \"@tanstack/react-query\";\nimport type { useTranslations } from \"next-intl\";\nimport { useCallback } from \"react\";\nimport { flushSync } from \"react-dom\";\nimport type { SessionCacheReturn } from \"@/apps/chat/hooks/useSessionCache\";\nimport type { StreamControllerReturn } from \"@/apps/chat/hooks/useStreamController\";\nimport type { ToolCallTrackerReturn } from \"@/apps/chat/hooks/useToolCallTracker\";\nimport type { ChatMessage } from \"@/apps/chat/types\";\nimport { createId } from \"@/apps/chat/utils/id\";\nimport {\n\tbuildPayloadMessage,\n\tgetModeForBackend,\n} from \"@/apps/chat/utils/messageBuilder\";\nimport {\n\thandleEmptyResponse,\n\thandleStreamError,\n} from \"@/apps/chat/utils/responseHandlers\";\nimport {\n\tbuildHierarchicalTodoContext,\n\tbuildTodoContextBlock,\n} from \"@/apps/chat/utils/todoContext\";\nimport type { ToolCallEvent } from \"@/lib/api\";\nimport { sendChatMessageStream } from \"@/lib/api\";\nimport { queryKeys } from \"@/lib/query/keys\";\nimport { useChatStore } from \"@/lib/store/chat-store\";\nimport type { Todo } from \"@/lib/types\";\n\n/**\n * useSendMessage 参数\n */\nexport interface UseSendMessageParams {\n\t/** 语言设置 */\n\tlocale: string;\n\t/** 是否有选中的待办 */\n\thasSelection: boolean;\n\t/** 选中的待办列表 */\n\teffectiveTodos: Todo[];\n\t/** 所有待办列表 */\n\ttodos: Todo[];\n\t/** 选中的 Agno 工具 */\n\tselectedAgnoTools: string[];\n\t/** 选中的外部工具 */\n\tselectedExternalTools: string[];\n\t/** 会话缓存 hook */\n\tsessionCache: SessionCacheReturn;\n\t/** 流式控制器 hook */\n\tstreamController: StreamControllerReturn;\n\t/** 工具调用跟踪器 hook */\n\ttoolCallTracker: ToolCallTrackerReturn;\n\t/** Query Client */\n\tqueryClient: QueryClient;\n\t/** 翻译函数 */\n\tt: ReturnType<typeof useTranslations<\"chat\">>;\n\ttCommon: ReturnType<typeof useTranslations<\"common\">>;\n\t/** 设置 conversationId */\n\tsetConversationId: (id: string | null) => void;\n\t/** 设置消息列表 */\n\tsetMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>;\n\t/** 设置输入框内容 */\n\tsetInputValue: React.Dispatch<React.SetStateAction<string>>;\n\t/** 设置流式状态 */\n\tsetIsStreaming: React.Dispatch<React.SetStateAction<boolean>>;\n\t/** 设置错误状态 */\n\tsetError: React.Dispatch<React.SetStateAction<string | null>>;\n}\n\n/**\n * useSendMessage 返回值\n */\nexport interface SendMessageReturn {\n\t/** 发送消息 */\n\tsendMessage: (text: string, clearInput?: boolean) => Promise<void>;\n}\n\n/**\n * 处理发送消息的核心逻辑\n */\nexport const useSendMessage = ({\n\tlocale,\n\thasSelection,\n\teffectiveTodos,\n\ttodos,\n\tselectedAgnoTools,\n\tselectedExternalTools,\n\tsessionCache,\n\tstreamController,\n\ttoolCallTracker,\n\tqueryClient,\n\tt,\n\ttCommon,\n\tsetConversationId,\n\tsetMessages,\n\tsetInputValue,\n\tsetIsStreaming,\n\tsetError,\n}: UseSendMessageParams): SendMessageReturn => {\n\t/**\n\t * 发送消息\n\t * @param text - 要发送的文本\n\t * @param clearInput - 是否清空输入框\n\t */\n\tconst sendMessage = useCallback(\n\t\tasync (text: string, clearInput = false) => {\n\t\t\tconst trimmedText = text.trim();\n\t\t\tif (!trimmedText) return;\n\n\t\t\t// 创建请求\n\t\t\tconst { requestId, abortController } = streamController.createRequest();\n\t\t\tconst currentConversationId = useChatStore.getState().conversationId;\n\n\t\t\tif (clearInput) {\n\t\t\t\tsetInputValue(\"\");\n\t\t\t}\n\t\t\tsetError(null);\n\n\t\t\t// 重置工具调用跟踪器\n\t\t\ttoolCallTracker.reset();\n\n\t\t\t// 构建待办上下文\n\t\t\tconst todoContext = hasSelection\n\t\t\t\t? buildHierarchicalTodoContext(effectiveTodos, todos, t, tCommon)\n\t\t\t\t: buildTodoContextBlock([], t(\"noTodoContext\"), t);\n\t\t\tconst userLabel = t(\"userInput\");\n\n\t\t\t// 使用工具函数构建 payload\n\t\t\tconst { payloadMessage, systemPromptForBackend, contextForBackend } =\n\t\t\t\tbuildPayloadMessage({\n\t\t\t\t\ttrimmedText,\n\t\t\t\t\tuserLabel,\n\t\t\t\t\ttodoContext,\n\t\t\t\t});\n\n\t\t\t// 创建消息\n\t\t\tconst userMessage: ChatMessage = {\n\t\t\t\tid: createId(),\n\t\t\t\trole: \"user\",\n\t\t\t\tcontent: trimmedText,\n\t\t\t};\n\t\t\tconst assistantMessageId = createId();\n\t\t\tconst initialMessages: ChatMessage[] = [\n\t\t\t\tuserMessage,\n\t\t\t\t{ id: assistantMessageId, role: \"assistant\", content: \"\" },\n\t\t\t];\n\n\t\t\tsetMessages((prev) => [...prev, ...initialMessages]);\n\t\t\tsetIsStreaming(true);\n\n\t\t\tlet assistantContent = \"\";\n\t\t\tlet requestSessionId = currentConversationId;\n\n\t\t\t// 本地缓存当前消息的 toolCallSteps，避免竞态条件\n\t\t\tlet cachedToolCallSteps: ReturnType<\n\t\t\t\ttypeof toolCallTracker.getToolCallSteps\n\t\t\t> = [];\n\n\t\t\t// 辅助函数：更新消息\n\t\t\tconst updateAssistantMessage = (\n\t\t\t\tcontent: string,\n\t\t\t\tnewToolCallSteps?: ReturnType<typeof toolCallTracker.getToolCallSteps>,\n\t\t\t) => {\n\t\t\t\t// 如果传入了非空的新步骤，更新本地缓存\n\t\t\t\tif (newToolCallSteps && newToolCallSteps.length > 0) {\n\t\t\t\t\tcachedToolCallSteps = newToolCallSteps;\n\t\t\t\t}\n\n\t\t\t\t// 使用本地缓存的步骤，确保不会丢失\n\t\t\t\tconst stepsToUse =\n\t\t\t\t\tcachedToolCallSteps.length > 0 ? cachedToolCallSteps : undefined;\n\n\t\t\t\tconst messageUpdater = (prev: ChatMessage[]) =>\n\t\t\t\t\tprev.map((msg) =>\n\t\t\t\t\t\tmsg.id === assistantMessageId\n\t\t\t\t\t\t\t? { ...msg, content, toolCallSteps: stepsToUse }\n\t\t\t\t\t\t\t: msg,\n\t\t\t\t\t);\n\n\t\t\t\t// 总是更新缓存\n\t\t\t\tif (requestSessionId) {\n\t\t\t\t\tsessionCache.updateMessages(requestSessionId, messageUpdater);\n\t\t\t\t}\n\n\t\t\t\t// 检查是否应该更新 UI\n\t\t\t\tconst currentDisplayedSessionId =\n\t\t\t\t\tuseChatStore.getState().conversationId;\n\t\t\t\tif (\n\t\t\t\t\trequestSessionId &&\n\t\t\t\t\tcurrentDisplayedSessionId === requestSessionId\n\t\t\t\t) {\n\t\t\t\t\tflushSync(() => {\n\t\t\t\t\t\tsetMessages(messageUpdater);\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t};\n\n\t\t\ttry {\n\t\t\t\tconst modeForBackend = getModeForBackend();\n\n\t\t\t\tawait sendChatMessageStream(\n\t\t\t\t\t{\n\t\t\t\t\t\tmessage: payloadMessage,\n\t\t\t\t\t\tuserInput: trimmedText,\n\t\t\t\t\t\tcontext: contextForBackend,\n\t\t\t\t\t\tsystemPrompt: systemPromptForBackend,\n\t\t\t\t\t\tconversationId: currentConversationId || undefined,\n\t\t\t\t\t\tuseRag: false,\n\t\t\t\t\t\tmode: modeForBackend,\n\t\t\t\t\t\tselectedTools: selectedAgnoTools,\n\t\t\t\t\t\texternalTools: selectedExternalTools,\n\t\t\t\t\t},\n\t\t\t\t\t// onChunk 回调\n\t\t\t\t\t(chunk) => {\n\t\t\t\t\t\tif (abortController.signal.aborted) return;\n\n\t\t\t\t\t\tassistantContent += chunk;\n\t\t\t\t\t\tupdateAssistantMessage(\n\t\t\t\t\t\t\tassistantContent,\n\t\t\t\t\t\t\ttoolCallTracker.getToolCallSteps(),\n\t\t\t\t\t\t);\n\t\t\t\t\t},\n\t\t\t\t\t// onSessionId 回调\n\t\t\t\t\t(sessionId) => {\n\t\t\t\t\t\tconst effectiveSessionId = currentConversationId || sessionId;\n\t\t\t\t\t\trequestSessionId = effectiveSessionId;\n\n\t\t\t\t\t\tsessionCache.markStreaming(effectiveSessionId);\n\n\t\t\t\t\t\t// 初始化缓存\n\t\t\t\t\t\tif (!sessionCache.getMessages(effectiveSessionId)) {\n\t\t\t\t\t\t\tconst currentMsgs = [\n\t\t\t\t\t\t\t\tuserMessage,\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tid: assistantMessageId,\n\t\t\t\t\t\t\t\t\trole: \"assistant\" as const,\n\t\t\t\t\t\t\t\t\tcontent: assistantContent,\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t];\n\t\t\t\t\t\t\tsessionCache.saveMessages(effectiveSessionId, currentMsgs);\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// 只有活跃请求才更新 conversationId\n\t\t\t\t\t\tif (streamController.isActiveRequest(requestId)) {\n\t\t\t\t\t\t\tconst isNewSession = !currentConversationId;\n\t\t\t\t\t\t\tsetConversationId(effectiveSessionId);\n\n\t\t\t\t\t\t\tif (isNewSession) {\n\t\t\t\t\t\t\t\tvoid queryClient.invalidateQueries({\n\t\t\t\t\t\t\t\t\tqueryKey: queryKeys.chatHistory.all,\n\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\tabortController.signal,\n\t\t\t\t\tlocale,\n\t\t\t\t\t// onToolEvent 回调\n\t\t\t\t\t(event: ToolCallEvent) => {\n\t\t\t\t\t\tif (abortController.signal.aborted) return;\n\n\t\t\t\t\t\tconst updatedSteps = toolCallTracker.handleToolEvent(event);\n\t\t\t\t\t\tif (updatedSteps) {\n\t\t\t\t\t\t\tupdateAssistantMessage(assistantContent, updatedSteps);\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t);\n\n\t\t\t\t// 流结束后，强制完成所有还在运行中的工具调用步骤（兜底清理）\n\t\t\t\tconst finalizedSteps = toolCallTracker.finalizeRunningSteps();\n\t\t\t\tif (finalizedSteps) {\n\t\t\t\t\tupdateAssistantMessage(assistantContent, finalizedSteps);\n\t\t\t\t}\n\n\t\t\t\t// 处理响应完成后的逻辑\n\t\t\t\tif (!assistantContent) {\n\t\t\t\t\thandleEmptyResponse(assistantMessageId, t, setMessages);\n\t\t\t\t}\n\t\t\t} catch (err) {\n\t\t\t\thandleStreamError(\n\t\t\t\t\terr,\n\t\t\t\t\tabortController,\n\t\t\t\t\tassistantContent,\n\t\t\t\t\tassistantMessageId,\n\t\t\t\t\tt,\n\t\t\t\t\tsetMessages,\n\t\t\t\t\tsetError,\n\t\t\t\t);\n\t\t\t} finally {\n\t\t\t\t// 清理\n\t\t\t\tif (requestSessionId) {\n\t\t\t\t\tsessionCache.unmarkStreaming(requestSessionId);\n\n\t\t\t\t\t// 保存最终消息状态\n\t\t\t\t\tconst sessionIdToSave = requestSessionId;\n\t\t\t\t\tsetMessages((currentMsgs) => {\n\t\t\t\t\t\tif (currentMsgs.length > 0) {\n\t\t\t\t\t\t\tsessionCache.saveMessages(sessionIdToSave, currentMsgs);\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn currentMsgs;\n\t\t\t\t\t});\n\t\t\t\t}\n\n\t\t\t\t// 检查是否应该更新 isStreaming\n\t\t\t\tconst currentDisplayedSessionId =\n\t\t\t\t\tuseChatStore.getState().conversationId;\n\t\t\t\tif (\n\t\t\t\t\trequestSessionId &&\n\t\t\t\t\tcurrentDisplayedSessionId === requestSessionId\n\t\t\t\t) {\n\t\t\t\t\tsetIsStreaming(false);\n\t\t\t\t}\n\n\t\t\t\t// 清理 abortController\n\t\t\t\tif (streamController.isActiveRequest(requestId)) {\n\t\t\t\t\tstreamController.cleanupAbortController();\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t\t[\n\t\t\teffectiveTodos,\n\t\t\thasSelection,\n\t\t\tlocale,\n\t\t\tqueryClient,\n\t\t\tselectedAgnoTools,\n\t\t\tselectedExternalTools,\n\t\t\tsessionCache,\n\t\t\tsetConversationId,\n\t\t\tsetError,\n\t\t\tsetInputValue,\n\t\t\tsetIsStreaming,\n\t\t\tsetMessages,\n\t\t\tstreamController,\n\t\t\tt,\n\t\t\ttCommon,\n\t\t\ttodos,\n\t\t\ttoolCallTracker,\n\t\t],\n\t);\n\n\treturn { sendMessage };\n};\n"
  },
  {
    "path": "free-todo-frontend/apps/chat/hooks/useSessionCache.ts",
    "content": "import { useCallback, useRef } from \"react\";\nimport type { ChatMessage } from \"@/apps/chat/types\";\n\n/**\n * 会话缓存 Hook 返回值接口\n */\nexport interface SessionCacheReturn {\n\t/** 保存消息到缓存 */\n\tsaveMessages: (sessionId: string, messages: ChatMessage[]) => void;\n\t/** 从缓存获取消息 */\n\tgetMessages: (sessionId: string) => ChatMessage[] | undefined;\n\t/** 标记会话正在流式输出 */\n\tmarkStreaming: (sessionId: string) => void;\n\t/** 取消会话的流式输出标记 */\n\tunmarkStreaming: (sessionId: string) => void;\n\t/** 检查会话是否正在流式输出 */\n\tisStreaming: (sessionId: string) => boolean;\n\t/** 更新缓存中的消息（使用更新函数） */\n\tupdateMessages: (\n\t\tsessionId: string,\n\t\tupdater: (prev: ChatMessage[]) => ChatMessage[],\n\t) => void;\n}\n\n/**\n * 管理会话消息的内存缓存\n *\n * 支持：\n * - 会话切换时保留流式输出\n * - 切换回正在流式输出的会话时恢复显示\n *\n * @returns 缓存操作方法\n */\nexport const useSessionCache = (): SessionCacheReturn => {\n\t// 在内存中保存每个 sessionId 对应的消息列表\n\t// 用于切换对话时恢复状态，正在进行的流式输出不会因为切换对话而丢失\n\tconst messagesMapRef = useRef<Map<string, ChatMessage[]>>(new Map());\n\n\t// 跟踪每个 sessionId 是否正在流式输出\n\tconst streamingSessionsRef = useRef<Set<string>>(new Set());\n\n\tconst saveMessages = useCallback(\n\t\t(sessionId: string, messages: ChatMessage[]) => {\n\t\t\tif (messages.length > 0) {\n\t\t\t\tmessagesMapRef.current.set(sessionId, messages);\n\t\t\t}\n\t\t},\n\t\t[],\n\t);\n\n\tconst getMessages = useCallback((sessionId: string) => {\n\t\treturn messagesMapRef.current.get(sessionId);\n\t}, []);\n\n\tconst markStreaming = useCallback((sessionId: string) => {\n\t\tstreamingSessionsRef.current.add(sessionId);\n\t}, []);\n\n\tconst unmarkStreaming = useCallback((sessionId: string) => {\n\t\tstreamingSessionsRef.current.delete(sessionId);\n\t}, []);\n\n\tconst isStreaming = useCallback((sessionId: string) => {\n\t\treturn streamingSessionsRef.current.has(sessionId);\n\t}, []);\n\n\tconst updateMessages = useCallback(\n\t\t(sessionId: string, updater: (prev: ChatMessage[]) => ChatMessage[]) => {\n\t\t\tconst currentMsgs = messagesMapRef.current.get(sessionId) || [];\n\t\t\tmessagesMapRef.current.set(sessionId, updater(currentMsgs));\n\t\t},\n\t\t[],\n\t);\n\n\treturn {\n\t\tsaveMessages,\n\t\tgetMessages,\n\t\tmarkStreaming,\n\t\tunmarkStreaming,\n\t\tisStreaming,\n\t\tupdateMessages,\n\t};\n};\n"
  },
  {
    "path": "free-todo-frontend/apps/chat/hooks/useSessionManager.ts",
    "content": "import { useCallback, useEffect, useRef } from \"react\";\nimport type { SessionCacheReturn } from \"@/apps/chat/hooks/useSessionCache\";\nimport type { StreamControllerReturn } from \"@/apps/chat/hooks/useStreamController\";\nimport type { ChatMessage, ToolCallStep } from \"@/apps/chat/types\";\nimport { createId } from \"@/apps/chat/utils/id\";\nimport type { ChatHistoryItem } from \"@/lib/api\";\nimport { useChatStore } from \"@/lib/store/chat-store\";\n\ntype ToolEvent = {\n\ttype?: string;\n\ttool_name?: string;\n\ttool_args?: Record<string, unknown>;\n\tresult_preview?: string;\n\terror?: boolean;\n};\n\n/**\n * useSessionManager 参数\n */\nexport interface UseSessionManagerParams {\n\t/** 会话缓存 hook */\n\tsessionCache: SessionCacheReturn;\n\t/** 流式控制器 hook */\n\tstreamController: StreamControllerReturn;\n\t/** 重置拆解状态 */\n\tresetBreakdown: () => void;\n\t/** 设置 conversationId */\n\tsetConversationId: (id: string | null) => void;\n\t/** 设置历史抽屉开关 */\n\tsetHistoryOpen: (open: boolean) => void;\n\t/** 设置消息列表 */\n\tsetMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>;\n\t/** 设置输入框内容 */\n\tsetInputValue: React.Dispatch<React.SetStateAction<string>>;\n\t/** 设置流式状态 */\n\tsetIsStreaming: React.Dispatch<React.SetStateAction<boolean>>;\n\t/** 设置错误状态 */\n\tsetError: React.Dispatch<React.SetStateAction<string | null>>;\n\t/** 当前会话历史记录 */\n\tsessionHistory: ChatHistoryItem[];\n\t/** 历史记录是否已获取 */\n\thistoryFetched: boolean;\n\t/** 历史记录是否正在加载 */\n\thistoryFetching: boolean;\n\t/** 当前 conversationId */\n\tconversationId: string | null;\n}\n\n/**\n * useSessionManager 返回值\n */\nexport interface SessionManagerReturn {\n\t/** 新建聊天 */\n\thandleNewChat: (keepStreaming?: boolean) => void;\n\t/** 加载历史会话 */\n\thandleLoadSession: (sessionId: string) => Promise<void>;\n}\n\nconst parseToolEvents = (extraData?: string): ToolCallStep[] | undefined => {\n\tif (!extraData) return undefined;\n\ttry {\n\t\tconst parsed = JSON.parse(extraData) as { tool_events?: ToolEvent[] };\n\t\tconst events = parsed.tool_events;\n\t\tif (!Array.isArray(events) || events.length === 0) return undefined;\n\n\t\tconst steps: ToolCallStep[] = [];\n\t\tfor (const event of events) {\n\t\t\tif (event.type === \"tool_call_start\" && event.tool_name) {\n\t\t\t\tsteps.push({\n\t\t\t\t\tid: `${event.tool_name}-${steps.length}`,\n\t\t\t\t\ttoolName: event.tool_name,\n\t\t\t\t\ttoolArgs: event.tool_args,\n\t\t\t\t\tstatus: \"running\",\n\t\t\t\t\tstartTime: Date.now(),\n\t\t\t\t});\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tif (event.type === \"tool_call_end\" && event.tool_name) {\n\t\t\t\tconst idx = [...steps]\n\t\t\t\t\t.map((step, index) => ({ step, index }))\n\t\t\t\t\t.reverse()\n\t\t\t\t\t.find((item) =>\n\t\t\t\t\t\titem.step.toolName === event.tool_name &&\n\t\t\t\t\t\titem.step.status === \"running\",\n\t\t\t\t\t)?.index;\n\n\t\t\t\tif (idx !== undefined) {\n\t\t\t\t\tsteps[idx] = {\n\t\t\t\t\t\t...steps[idx],\n\t\t\t\t\t\tstatus: event.error ? \"error\" : \"completed\",\n\t\t\t\t\t\tresultPreview: event.result_preview,\n\t\t\t\t\t\tendTime: Date.now(),\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\treturn steps.length > 0 ? steps : undefined;\n\t} catch (error) {\n\t\tconsole.warn(\"Failed to parse tool events from history:\", error);\n\t\treturn undefined;\n\t}\n};\n\n/**\n * 管理会话切换和新建聊天逻辑\n */\nexport const useSessionManager = ({\n\tsessionCache,\n\tstreamController,\n\tresetBreakdown,\n\tsetConversationId,\n\tsetHistoryOpen,\n\tsetMessages,\n\tsetInputValue,\n\tsetIsStreaming,\n\tsetError,\n\tsessionHistory,\n\thistoryFetched,\n\thistoryFetching,\n\tconversationId,\n}: UseSessionManagerParams): SessionManagerReturn => {\n\t// Refs\n\tconst prevConversationIdRef = useRef<string | null>(null);\n\tconst isLoadingSessionRef = useRef<boolean>(false);\n\n\t/**\n\t * 新建聊天\n\t * @param keepStreaming - 如果为 true，不中断当前流式输出，让它在后台继续\n\t */\n\tconst handleNewChat = useCallback(\n\t\t(keepStreaming = false) => {\n\t\t\tconst currentSessionId = useChatStore.getState().conversationId;\n\n\t\t\t// 保存当前对话的消息到缓存\n\t\t\tif (currentSessionId) {\n\t\t\t\tsetMessages((currentMessages) => {\n\t\t\t\t\tif (currentMessages.length > 0) {\n\t\t\t\t\t\tsessionCache.saveMessages(currentSessionId, currentMessages);\n\t\t\t\t\t}\n\t\t\t\t\treturn currentMessages;\n\t\t\t\t});\n\t\t\t}\n\n\t\t\tif (!keepStreaming) {\n\t\t\t\tstreamController.cancelRequest();\n\t\t\t\tsetIsStreaming(false);\n\t\t\t}\n\n\t\t\t// 如果正在进行待办拆解，放弃拆解并进入新聊天\n\t\t\tresetBreakdown();\n\t\t\tstreamController.clearActiveRequest();\n\t\t\tsetConversationId(null);\n\t\t\tsetMessages([]);\n\t\t\tsetInputValue(\"\");\n\t\t\tsetError(null);\n\t\t\tsetHistoryOpen(false);\n\t\t},\n\t\t[\n\t\t\tsetConversationId,\n\t\t\tsetHistoryOpen,\n\t\t\tresetBreakdown,\n\t\t\tsessionCache,\n\t\t\tstreamController,\n\t\t\tsetMessages,\n\t\t\tsetInputValue,\n\t\t\tsetIsStreaming,\n\t\t\tsetError,\n\t\t],\n\t);\n\n\t/**\n\t * 加载历史会话\n\t */\n\tconst handleLoadSession = useCallback(\n\t\tasync (sessionId: string) => {\n\t\t\tconst currentSessionId = useChatStore.getState().conversationId;\n\n\t\t\t// 1. 先保存当前对话的消息到缓存\n\t\t\tif (currentSessionId) {\n\t\t\t\tsetMessages((currentMessages) => {\n\t\t\t\t\tif (currentMessages.length > 0) {\n\t\t\t\t\t\tsessionCache.saveMessages(currentSessionId, currentMessages);\n\t\t\t\t\t}\n\t\t\t\t\treturn currentMessages;\n\t\t\t\t});\n\t\t\t}\n\n\t\t\t// 2. 清空活跃请求 ID，但不中断流式输出\n\t\t\tstreamController.clearActiveRequest();\n\n\t\t\t// 3. 检查目标 sessionId 是否有内存缓存\n\t\t\tconst cachedMessages = sessionCache.getMessages(sessionId);\n\t\t\tconst isSessionStreaming = sessionCache.isStreaming(sessionId);\n\n\t\t\tif (cachedMessages && cachedMessages.length > 0) {\n\t\t\t\tsetMessages(cachedMessages);\n\t\t\t\tsetIsStreaming(isSessionStreaming);\n\t\t\t\tsetError(null);\n\t\t\t\tisLoadingSessionRef.current = false;\n\t\t\t} else {\n\t\t\t\tisLoadingSessionRef.current = true;\n\t\t\t\tsetMessages([]);\n\t\t\t\tsetIsStreaming(false);\n\t\t\t\tsetError(null);\n\t\t\t}\n\n\t\t\t// 4. 更新 conversationId，触发 TanStack Query 获取历史记录\n\t\t\tsetConversationId(sessionId);\n\t\t\tsetHistoryOpen(false);\n\t\t},\n\t\t[\n\t\t\tsetConversationId,\n\t\t\tsetHistoryOpen,\n\t\t\tsessionCache,\n\t\t\tstreamController,\n\t\t\tsetMessages,\n\t\t\tsetIsStreaming,\n\t\t\tsetError,\n\t\t],\n\t);\n\n\t// 当会话历史加载完成后，更新 messages\n\tuseEffect(() => {\n\t\tif (!isLoadingSessionRef.current) {\n\t\t\treturn;\n\t\t}\n\n\t\tif (!conversationId) {\n\t\t\treturn;\n\t\t}\n\n\t\tif (historyFetching || !historyFetched) {\n\t\t\treturn;\n\t\t}\n\n\t\tconst conversationIdChanged =\n\t\t\tprevConversationIdRef.current !== conversationId;\n\t\tif (conversationIdChanged) {\n\t\t\tprevConversationIdRef.current = conversationId;\n\t\t}\n\n\t\tconst mapped = sessionHistory.map((item: ChatHistoryItem) => ({\n\t\t\tid: createId(),\n\t\t\trole: item.role,\n\t\t\tcontent: item.content,\n\t\t\ttoolCallSteps: parseToolEvents(item.extraData),\n\t\t}));\n\t\tsetMessages(mapped);\n\t\tisLoadingSessionRef.current = false;\n\t}, [\n\t\tconversationId,\n\t\thistoryFetched,\n\t\thistoryFetching,\n\t\tsessionHistory,\n\t\tsetMessages,\n\t]);\n\n\treturn {\n\t\thandleNewChat,\n\t\thandleLoadSession,\n\t};\n};\n"
  },
  {
    "path": "free-todo-frontend/apps/chat/hooks/useStreamController.ts",
    "content": "import { useCallback, useRef } from \"react\";\nimport { createId } from \"@/apps/chat/utils/id\";\n\n/**\n * 创建请求的返回值\n */\nexport interface CreateRequestResult {\n\t/** 请求唯一 ID */\n\trequestId: string;\n\t/** 用于取消请求的 AbortController */\n\tabortController: AbortController;\n}\n\n/**\n * 流式控制器 Hook 返回值接口\n */\nexport interface StreamControllerReturn {\n\t/** 创建新的请求（返回 requestId 和 abortController） */\n\tcreateRequest: () => CreateRequestResult;\n\t/** 取消当前请求 */\n\tcancelRequest: () => void;\n\t/** 检查指定请求是否是当前活跃请求 */\n\tisActiveRequest: (requestId: string) => boolean;\n\t/** 清空活跃请求 ID（让旧请求的回调忽略 UI 更新） */\n\tclearActiveRequest: () => void;\n\t/** 清理 abortController 引用 */\n\tcleanupAbortController: () => void;\n\t/** 获取当前的 abort signal */\n\tgetAbortSignal: () => AbortSignal | undefined;\n}\n\n/**\n * 管理流式请求的取消和活跃状态\n *\n * 支持：\n * - 创建带有唯一 ID 的请求\n * - 取消正在进行的请求\n * - 判断回调是否来自当前活跃请求\n *\n * @returns 流式控制器方法\n */\nexport const useStreamController = (): StreamControllerReturn => {\n\t// 用于取消流式请求的 AbortController\n\tconst abortControllerRef = useRef<AbortController | null>(null);\n\n\t// 跟踪当前活跃的请求 ID，用于在切换对话时忽略旧请求的 UI 更新\n\tconst activeRequestIdRef = useRef<string | null>(null);\n\n\tconst createRequest = useCallback((): CreateRequestResult => {\n\t\t// 生成当前请求的唯一 ID\n\t\tconst requestId = createId();\n\t\tactiveRequestIdRef.current = requestId;\n\n\t\t// 创建新的 AbortController\n\t\tconst abortController = new AbortController();\n\t\tabortControllerRef.current = abortController;\n\n\t\treturn { requestId, abortController };\n\t}, []);\n\n\tconst cancelRequest = useCallback(() => {\n\t\tif (abortControllerRef.current) {\n\t\t\tabortControllerRef.current.abort();\n\t\t\tabortControllerRef.current = null;\n\t\t}\n\t}, []);\n\n\tconst isActiveRequest = useCallback((requestId: string) => {\n\t\treturn activeRequestIdRef.current === requestId;\n\t}, []);\n\n\tconst clearActiveRequest = useCallback(() => {\n\t\tactiveRequestIdRef.current = null;\n\t}, []);\n\n\tconst cleanupAbortController = useCallback(() => {\n\t\tabortControllerRef.current = null;\n\t}, []);\n\n\tconst getAbortSignal = useCallback(() => {\n\t\treturn abortControllerRef.current?.signal;\n\t}, []);\n\n\treturn {\n\t\tcreateRequest,\n\t\tcancelRequest,\n\t\tisActiveRequest,\n\t\tclearActiveRequest,\n\t\tcleanupAbortController,\n\t\tgetAbortSignal,\n\t};\n};\n"
  },
  {
    "path": "free-todo-frontend/apps/chat/hooks/useToolCallTracker.ts",
    "content": "import { useCallback, useRef } from \"react\";\nimport type { ToolCallStep } from \"@/apps/chat/types\";\nimport type { ToolCallEvent } from \"@/lib/api\";\n\n/**\n * 工具调用跟踪器 Hook 返回值接口\n */\nexport interface ToolCallTrackerReturn {\n\t/**\n\t * 处理工具调用事件\n\t * @param event - 工具调用事件\n\t * @returns 更新后的工具调用步骤数组（如果有更新），否则返回 null\n\t */\n\thandleToolEvent: (event: ToolCallEvent) => ToolCallStep[] | null;\n\t/** 获取当前所有工具调用步骤 */\n\tgetToolCallSteps: () => ToolCallStep[];\n\t/** 重置跟踪器状态 */\n\treset: () => void;\n\t/**\n\t * 强制完成所有还在运行中的工具调用步骤\n\t * 用于流结束时的兜底清理，防止工具状态卡在 \"running\"\n\t * @returns 更新后的工具调用步骤数组（如果有更新），否则返回 null\n\t */\n\tfinalizeRunningSteps: () => ToolCallStep[] | null;\n}\n\n/**\n * 跟踪工具调用的状态（开始/完成）\n *\n * 支持：\n * - 处理 tool_call_start 事件（创建新的工具调用步骤）\n * - 处理 tool_call_end 事件（更新工具调用状态和结果）\n * - 获取当前所有工具调用步骤\n *\n * @returns 工具调用跟踪器方法\n */\nexport const useToolCallTracker = (): ToolCallTrackerReturn => {\n\t// 用于跟踪工具调用步骤的 Map\n\tconst toolCallStepsMapRef = useRef<Map<string, ToolCallStep>>(new Map());\n\n\tconst handleToolEvent = useCallback(\n\t\t(event: ToolCallEvent): ToolCallStep[] | null => {\n\t\t\tif (event.type === \"tool_call_start\" && event.tool_name) {\n\t\t\t\t// 创建新的工具调用步骤\n\t\t\t\tconst stepId = `${event.tool_name}-${Date.now()}`;\n\t\t\t\tconst newStep: ToolCallStep = {\n\t\t\t\t\tid: stepId,\n\t\t\t\t\ttoolName: event.tool_name,\n\t\t\t\t\ttoolArgs: event.tool_args,\n\t\t\t\t\tstatus: \"running\",\n\t\t\t\t\tstartTime: Date.now(),\n\t\t\t\t};\n\t\t\t\ttoolCallStepsMapRef.current.set(stepId, newStep);\n\n\t\t\t\treturn Array.from(toolCallStepsMapRef.current.values());\n\t\t\t}\n\n\t\t\tif (event.type === \"tool_call_end\" && event.tool_name) {\n\t\t\t\t// 找到对应的工具调用步骤并更新状态\n\t\t\t\t// 优先匹配还在 running 状态的步骤\n\t\t\t\tconst stepKey = Array.from(toolCallStepsMapRef.current.keys()).find(\n\t\t\t\t\t(key) => {\n\t\t\t\t\t\tif (!key.startsWith(event.tool_name as string)) return false;\n\t\t\t\t\t\tconst step = toolCallStepsMapRef.current.get(key);\n\t\t\t\t\t\treturn step?.status === \"running\";\n\t\t\t\t\t},\n\t\t\t\t);\n\n\t\t\t\tif (stepKey) {\n\t\t\t\t\tconst existingStep = toolCallStepsMapRef.current.get(stepKey);\n\t\t\t\t\tif (existingStep) {\n\t\t\t\t\t\t// 检查是否是错误事件\n\t\t\t\t\t\tconst isError = (event as { error?: boolean }).error === true;\n\t\t\t\t\t\ttoolCallStepsMapRef.current.set(stepKey, {\n\t\t\t\t\t\t\t...existingStep,\n\t\t\t\t\t\t\tstatus: isError ? \"error\" : \"completed\",\n\t\t\t\t\t\t\tresultPreview: event.result_preview,\n\t\t\t\t\t\t\tendTime: Date.now(),\n\t\t\t\t\t\t});\n\n\t\t\t\t\t\treturn Array.from(toolCallStepsMapRef.current.values());\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// 其他事件类型（run_started, run_completed）不需要更新 UI\n\t\t\treturn null;\n\t\t},\n\t\t[],\n\t);\n\n\tconst getToolCallSteps = useCallback(() => {\n\t\treturn Array.from(toolCallStepsMapRef.current.values());\n\t}, []);\n\n\tconst reset = useCallback(() => {\n\t\ttoolCallStepsMapRef.current.clear();\n\t}, []);\n\n\t/**\n\t * 强制完成所有还在运行中的工具调用步骤\n\t * 用于流结束时的兜底清理\n\t */\n\tconst finalizeRunningSteps = useCallback((): ToolCallStep[] | null => {\n\t\tlet hasUpdates = false;\n\n\t\tfor (const [key, step] of toolCallStepsMapRef.current.entries()) {\n\t\t\tif (step.status === \"running\") {\n\t\t\t\ttoolCallStepsMapRef.current.set(key, {\n\t\t\t\t\t...step,\n\t\t\t\t\tstatus: \"completed\",\n\t\t\t\t\tendTime: Date.now(),\n\t\t\t\t\t// 如果没有结果预览，标记为超时/未知完成\n\t\t\t\t\tresultPreview: step.resultPreview || \"[Stream ended]\",\n\t\t\t\t});\n\t\t\t\thasUpdates = true;\n\t\t\t}\n\t\t}\n\n\t\treturn hasUpdates\n\t\t\t? Array.from(toolCallStepsMapRef.current.values())\n\t\t\t: null;\n\t}, []);\n\n\treturn {\n\t\thandleToolEvent,\n\t\tgetToolCallSteps,\n\t\treset,\n\t\tfinalizeRunningSteps,\n\t};\n};\n"
  },
  {
    "path": "free-todo-frontend/apps/chat/types.ts",
    "content": "import type { CreateTodoInput } from \"@/lib/types\";\n\n/**\n * 工具调用步骤状态\n */\nexport type ToolCallStatus = \"running\" | \"completed\" | \"error\";\n\n/**\n * 工具调用步骤\n */\nexport type ToolCallStep = {\n\t/** 步骤唯一 ID */\n\tid: string;\n\t/** 工具名称 */\n\ttoolName: string;\n\t/** 工具参数 */\n\ttoolArgs?: Record<string, unknown>;\n\t/** 执行状态 */\n\tstatus: ToolCallStatus;\n\t/** 结果预览（仅在完成时有值） */\n\tresultPreview?: string;\n\t/** 开始时间 */\n\tstartTime: number;\n\t/** 结束时间（仅在完成时有值） */\n\tendTime?: number;\n};\n\n/**\n * 聊天消息\n */\nexport type ChatMessage = {\n\tid: string;\n\trole: \"user\" | \"assistant\";\n\tcontent: string;\n\t/** 工具调用步骤（仅 assistant 消息可能有） */\n\ttoolCallSteps?: ToolCallStep[];\n};\n\nexport type ChatMode = \"agno\";\n\nexport type ParsedTodo = Pick<\n\tCreateTodoInput,\n\t\"name\" | \"description\" | \"tags\" | \"startTime\" | \"endTime\" | \"order\"\n>;\n\nexport type ParsedTodoTree = ParsedTodo & { subtasks?: ParsedTodoTree[] };\n\n// Edit mode content block with AI-recommended target todo\nexport type EditContentBlock = {\n\tid: string;\n\ttitle: string;\n\tcontent: string;\n\trecommendedTodoId: number | null;\n};\n"
  },
  {
    "path": "free-todo-frontend/apps/chat/utils/id.ts",
    "content": "export const createId = () => {\n\tif (typeof crypto !== \"undefined\" && crypto.randomUUID) {\n\t\treturn crypto.randomUUID();\n\t}\n\treturn `msg-${Date.now()}-${Math.random().toString(16).slice(2)}`;\n};\n"
  },
  {
    "path": "free-todo-frontend/apps/chat/utils/messageBuilder.ts",
    "content": "/**\n * 消息构建参数\n */\nexport interface BuildPayloadMessageParams {\n\ttrimmedText: string;\n\tuserLabel: string;\n\ttodoContext: string;\n}\n\n/**\n * 消息构建结果\n */\nexport interface PayloadMessageResult {\n\t/** 发送给后端的完整消息 */\n\tpayloadMessage: string;\n\t/** 系统提示词（可选，用于后端保存） */\n\tsystemPromptForBackend?: string;\n\t/** 上下文（可选，用于后端保存） */\n\tcontextForBackend?: string;\n}\n\n/**\n * 构建发送给后端的 payload 消息\n */\nexport const buildPayloadMessage = (\n\tparams: BuildPayloadMessageParams,\n): PayloadMessageResult => {\n\tconst { trimmedText, userLabel, todoContext } = params;\n\n\treturn {\n\t\tpayloadMessage: `${todoContext}\n\n${userLabel}: ${trimmedText}`,\n\t\tcontextForBackend: todoContext,\n\t};\n};\n\n/**\n * 将前端聊天模式映射为后端模式\n */\nexport const getModeForBackend = (): string => \"agno\";\n"
  },
  {
    "path": "free-todo-frontend/apps/chat/utils/parseEditBlocks.ts",
    "content": "import type { EditContentBlock } from \"@/apps/chat/types\";\n\n/**\n * Parse AI response in Edit mode into structured content blocks.\n * Each block is separated by ## headers and may contain an [append_to: <id>] marker\n * indicating the recommended todo to append the content to.\n *\n * @example\n * Input:\n * ```\n * ## Project Overview\n * This section summarizes the key aspects...\n *\n * [append_to: 123]\n *\n * ## Next Steps\n * 1. Complete research\n * 2. Schedule meeting\n *\n * [append_to: 456]\n * ```\n *\n * Output:\n * [\n *   { id: \"block-1\", title: \"Project Overview\", content: \"This section...\", recommendedTodoId: 123 },\n *   { id: \"block-2\", title: \"Next Steps\", content: \"1. Complete...\", recommendedTodoId: 456 }\n * ]\n */\nexport function parseEditBlocks(content: string): EditContentBlock[] {\n\tif (!content.trim()) {\n\t\treturn [];\n\t}\n\n\tconst blocks: EditContentBlock[] = [];\n\n\t// Split by ## headers (keeping the header text)\n\t// Match pattern: start of line, ##, space, then capture the title\n\tconst headerPattern = /^##\\s+(.+)$/gm;\n\tconst headerMatches = [...content.matchAll(headerPattern)];\n\n\tif (headerMatches.length === 0) {\n\t\t// No headers found - treat entire content as a single block\n\t\tconst { cleanContent, todoId } = extractAppendToMarker(content);\n\t\tif (cleanContent.trim()) {\n\t\t\tblocks.push({\n\t\t\t\tid: \"block-1\",\n\t\t\t\ttitle: \"\",\n\t\t\t\tcontent: cleanContent.trim(),\n\t\t\t\trecommendedTodoId: todoId,\n\t\t\t});\n\t\t}\n\t\treturn blocks;\n\t}\n\n\t// Process each section between headers\n\tfor (let i = 0; i < headerMatches.length; i++) {\n\t\tconst match = headerMatches[i];\n\t\tconst title = match[1].trim();\n\t\tconst startIndex = (match.index ?? 0) + match[0].length;\n\n\t\t// Find where this section ends (next header or end of content)\n\t\tconst endIndex =\n\t\t\ti < headerMatches.length - 1\n\t\t\t\t? (headerMatches[i + 1].index ?? content.length)\n\t\t\t\t: content.length;\n\n\t\tconst sectionContent = content.slice(startIndex, endIndex);\n\n\t\t// Extract [append_to: id] marker and clean content\n\t\tconst { cleanContent, todoId } = extractAppendToMarker(sectionContent);\n\n\t\tif (cleanContent.trim() || title) {\n\t\t\tblocks.push({\n\t\t\t\tid: `block-${i + 1}`,\n\t\t\t\ttitle,\n\t\t\t\tcontent: cleanContent.trim(),\n\t\t\t\trecommendedTodoId: todoId,\n\t\t\t});\n\t\t}\n\t}\n\n\t// Check if there's content before the first header\n\tif (headerMatches.length > 0 && (headerMatches[0].index ?? 0) > 0) {\n\t\tconst preHeaderContent = content.slice(0, headerMatches[0].index);\n\t\tconst { cleanContent, todoId } = extractAppendToMarker(preHeaderContent);\n\t\tif (cleanContent.trim()) {\n\t\t\t// Insert at the beginning\n\t\t\tblocks.unshift({\n\t\t\t\tid: \"block-0\",\n\t\t\t\ttitle: \"\",\n\t\t\t\tcontent: cleanContent.trim(),\n\t\t\t\trecommendedTodoId: todoId,\n\t\t\t});\n\t\t}\n\t}\n\n\treturn blocks;\n}\n\n/**\n * Extract [append_to: <id>] marker from content and return cleaned content + todo ID\n */\nfunction extractAppendToMarker(content: string): {\n\tcleanContent: string;\n\ttodoId: number | null;\n} {\n\t// Match [append_to: 123] pattern (with optional whitespace)\n\tconst appendToPattern = /\\[append_to:\\s*(\\d+)\\s*\\]/gi;\n\tconst matches = [...content.matchAll(appendToPattern)];\n\n\tlet todoId: number | null = null;\n\n\t// Take the last match if multiple exist\n\tif (matches.length > 0) {\n\t\tconst lastMatch = matches[matches.length - 1];\n\t\ttodoId = Number.parseInt(lastMatch[1], 10);\n\t}\n\n\t// Remove all [append_to: x] markers from content\n\tconst cleanContent = content.replace(appendToPattern, \"\").trim();\n\n\treturn { cleanContent, todoId };\n}\n\n/**\n * Get the clean content from a block (without the append_to marker)\n * Used when appending to todo notes\n */\nexport function getCleanBlockContent(block: EditContentBlock): string {\n\tconst parts: string[] = [];\n\n\tif (block.title) {\n\t\tparts.push(`## ${block.title}`);\n\t}\n\n\tif (block.content) {\n\t\tparts.push(block.content);\n\t}\n\n\treturn parts.join(\"\\n\\n\");\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/chat/utils/responseHandlers.ts",
    "content": "import type { useTranslations } from \"next-intl\";\nimport type { ChatMessage } from \"@/apps/chat/types\";\n\n/**\n * 处理流式请求错误\n */\nexport function handleStreamError(\n\terr: unknown,\n\tabortController: AbortController,\n\tassistantContent: string,\n\tassistantMessageId: string,\n\tt: ReturnType<typeof useTranslations<\"chat\">>,\n\tsetMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>,\n\tsetError: React.Dispatch<React.SetStateAction<string | null>>,\n): void {\n\tif (\n\t\tabortController.signal.aborted ||\n\t\t(err instanceof Error && err.name === \"AbortError\")\n\t) {\n\t\t// 用户主动取消\n\t\tif (!assistantContent) {\n\t\t\tsetMessages((prev) =>\n\t\t\t\tprev.filter((msg) => msg.id !== assistantMessageId),\n\t\t\t);\n\t\t}\n\t} else {\n\t\tconsole.error(err);\n\t\tconst fallback = t(\"errorOccurred\");\n\t\tsetMessages((prev) =>\n\t\t\tprev.map((msg) =>\n\t\t\t\tmsg.id === assistantMessageId ? { ...msg, content: fallback } : msg,\n\t\t\t),\n\t\t);\n\t\tsetError(fallback);\n\t}\n}\n\n/**\n * 处理空响应\n */\nexport function handleEmptyResponse(\n\tassistantMessageId: string,\n\tt: ReturnType<typeof useTranslations<\"chat\">>,\n\tsetMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>,\n): void {\n\tconst fallback = t(\"noResponseReceived\");\n\tsetMessages((prev) =>\n\t\tprev.map((msg) =>\n\t\t\tmsg.id === assistantMessageId ? { ...msg, content: fallback } : msg,\n\t\t),\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/chat/utils/todoContext.ts",
    "content": "import type { Todo, TodoPriority, TodoStatus } from \"@/lib/types\";\n\ntype TranslationFunction = (\n\tkey: string,\n\tvalues?: Record<string, string | number | Date>,\n) => string;\n\n// 格式化优先级\nconst formatPriority = (\n\tpriority: TodoPriority,\n\ttCommon: TranslationFunction,\n): string => {\n\tconst priorityKey = `priority.${priority}`;\n\treturn tCommon(priorityKey);\n};\n\n// 格式化状态\nconst formatStatus = (\n\tstatus: TodoStatus,\n\ttCommon: TranslationFunction,\n): string => {\n\tconst statusKey = `status.${status}`;\n\treturn tCommon(statusKey);\n};\n\n// 查找最高级父待办\nexport const findRootTodo = (todo: Todo, allTodos: Todo[]): Todo => {\n\tif (!todo.parentTodoId) {\n\t\treturn todo;\n\t}\n\tconst parent = allTodos.find((t) => t.id === todo.parentTodoId);\n\tif (!parent) {\n\t\treturn todo;\n\t}\n\treturn findRootTodo(parent, allTodos);\n};\n\n// 递归收集所有子待办（包括子待办的子待办）\nexport const collectAllDescendants = (todo: Todo, allTodos: Todo[]): Todo[] => {\n\tconst children = allTodos.filter((t) => t.parentTodoId === todo.id);\n\tconst descendants: Todo[] = [...children];\n\tfor (const child of children) {\n\t\tdescendants.push(...collectAllDescendants(child, allTodos));\n\t}\n\treturn descendants;\n};\n\n// 构建单个待办的详细信息（包含所有参数）\nexport const buildDetailedTodoInfo = (\n\ttodo: Todo,\n\tallTodos: Todo[],\n\tt: TranslationFunction,\n\ttCommon: TranslationFunction,\n\tindent = \"\",\n): string => {\n\tconst lines: string[] = [];\n\n\t// ID is critical for AI to reference when recommending target todos\n\tconst idLabel = t(\"todoContext.id\");\n\tlines.push(`${indent}${idLabel}: ${todo.id}`);\n\n\tconst label = t(\"todoContext.name\");\n\tlines.push(`${indent}${label}: ${todo.name}`);\n\n\tif (todo.description) {\n\t\tconst descLabel = t(\"todoContext.description\");\n\t\tlines.push(`${indent}${descLabel}: ${todo.description}`);\n\t}\n\n\tif (todo.userNotes) {\n\t\tconst notesLabel = t(\"todoContext.notes\");\n\t\tlines.push(`${indent}${notesLabel}: ${todo.userNotes}`);\n\t}\n\n\tconst scheduleTime = todo.startTime ?? todo.endTime;\n\tif (scheduleTime) {\n\t\tconst ddlLabel = t(\"todoContext.deadline\");\n\t\tlines.push(`${indent}${ddlLabel}: ${scheduleTime}`);\n\t}\n\n\tconst priorityLabel = t(\"todoContext.priority\");\n\tlines.push(\n\t\t`${indent}${priorityLabel}: ${formatPriority(todo.priority, tCommon)}`,\n\t);\n\n\tconst statusLabel = t(\"todoContext.status\");\n\tlines.push(`${indent}${statusLabel}: ${formatStatus(todo.status, tCommon)}`);\n\n\tif (todo.tags?.length) {\n\t\tconst tagsLabel = t(\"todoContext.tags\");\n\t\tlines.push(`${indent}${tagsLabel}: ${todo.tags.join(\", \")}`);\n\t}\n\n\t// Show parentTodoId as numeric ID for AI reference\n\tif (todo.parentTodoId) {\n\t\tconst parentIdLabel = t(\"todoContext.parentTodoId\");\n\t\tlines.push(`${indent}${parentIdLabel}: ${todo.parentTodoId}`);\n\t\t// Also show parent name for context\n\t\tconst parent = allTodos.find((t) => t.id === todo.parentTodoId);\n\t\tif (parent) {\n\t\t\tconst parentNameLabel = t(\"todoContext.parentName\");\n\t\t\tlines.push(`${indent}${parentNameLabel}: ${parent.name}`);\n\t\t}\n\t}\n\n\treturn lines.join(\"\\n\");\n};\n\n// 构建简洁的待办行（用于列表展示）\nexport const buildTodoLine = (todo: Todo, t: TranslationFunction) => {\n\tconst parts: string[] = [todo.name];\n\tconst scheduleTime = todo.startTime ?? todo.endTime;\n\tif (todo.description) {\n\t\tparts.push(todo.description);\n\t}\n\tif (scheduleTime) {\n\t\tparts.push(t(\"todoContext.due\", { deadline: scheduleTime }));\n\t}\n\tif (todo.tags?.length) {\n\t\tparts.push(t(\"todoContext.tagsLabel\", { tags: todo.tags.join(\", \") }));\n\t}\n\treturn `- ${parts.join(\" | \")}`;\n};\n\n// 构建包含层级结构的完整待办上下文\nexport const buildHierarchicalTodoContext = (\n\tselectedTodos: Todo[],\n\tallTodos: Todo[],\n\tt: TranslationFunction,\n\ttCommon: TranslationFunction,\n): string => {\n\tif (!selectedTodos.length) {\n\t\treturn t(\"noTodosAvailable\");\n\t}\n\n\tconst sections: string[] = [];\n\n\tfor (const selectedTodo of selectedTodos) {\n\t\tconst todoSection: string[] = [];\n\n\t\t// 1. 选中待办的详细信息\n\t\tconst selectedLabel = t(\"selectedTodo\");\n\t\ttodoSection.push(selectedLabel);\n\t\ttodoSection.push(buildDetailedTodoInfo(selectedTodo, allTodos, t, tCommon));\n\n\t\t// 2. 查找最高级父待办\n\t\tconst rootTodo = findRootTodo(selectedTodo, allTodos);\n\n\t\t// 如果选中的待办不是根待办，显示根待办信息和完整子树\n\t\tif (rootTodo.id !== selectedTodo.id) {\n\t\t\ttodoSection.push(\"\");\n\t\t\tconst rootLabel = t(\"rootParentTodo\");\n\t\t\ttodoSection.push(rootLabel);\n\t\t\ttodoSection.push(buildDetailedTodoInfo(rootTodo, allTodos, t, tCommon));\n\n\t\t\t// 3. 收集根待办下的所有子待办\n\t\t\tconst allDescendants = collectAllDescendants(rootTodo, allTodos);\n\t\t\tif (allDescendants.length > 0) {\n\t\t\t\ttodoSection.push(\"\");\n\t\t\t\tconst childrenLabel = t(\"allSubTodos\", {\n\t\t\t\t\tcount: allDescendants.length,\n\t\t\t\t});\n\t\t\t\ttodoSection.push(childrenLabel);\n\n\t\t\t\tfor (const child of allDescendants) {\n\t\t\t\t\ttodoSection.push(\"\");\n\t\t\t\t\ttodoSection.push(\n\t\t\t\t\t\tbuildDetailedTodoInfo(child, allTodos, t, tCommon, \"  \"),\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\t// 选中的就是根待办，显示其所有子待办\n\t\t\tconst allDescendants = collectAllDescendants(selectedTodo, allTodos);\n\t\t\tif (allDescendants.length > 0) {\n\t\t\t\ttodoSection.push(\"\");\n\t\t\t\tconst childrenLabel = t(\"allSubTodosRoot\", {\n\t\t\t\t\tcount: allDescendants.length,\n\t\t\t\t});\n\t\t\t\ttodoSection.push(childrenLabel);\n\n\t\t\t\tfor (const child of allDescendants) {\n\t\t\t\t\ttodoSection.push(\"\");\n\t\t\t\t\ttodoSection.push(\n\t\t\t\t\t\tbuildDetailedTodoInfo(child, allTodos, t, tCommon, \"  \"),\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tsections.push(todoSection.join(\"\\n\"));\n\t}\n\n\t// 多个选中待办用分隔线分开\n\tconst separator = \"\\n---\\n\";\n\treturn sections.join(separator);\n};\n\n// 保留原有的简单上下文构建函数（向后兼容）\nexport const buildTodoContextBlock = (\n\tlist: Todo[],\n\tsourceLabel: string,\n\tt: TranslationFunction,\n) => {\n\tif (!list.length) {\n\t\treturn t(\"noTodosAvailable\");\n\t}\n\tconst header = t(\"todoContextHeader\", {\n\t\tsource: sourceLabel,\n\t\tcount: list.length,\n\t});\n\treturn [header, ...list.map((item) => buildTodoLine(item, t))].join(\"\\n\");\n};\n"
  },
  {
    "path": "free-todo-frontend/apps/cost-tracking/CostTrackingPanel.tsx",
    "content": "\"use client\";\n\nimport {\n\tActivity,\n\tAlertCircle,\n\tCalendar,\n\tDollarSign,\n\tRefreshCw,\n\tTrendingUp,\n} from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport { useMemo, useState } from \"react\";\nimport { PanelHeader } from \"@/components/common/layout/PanelHeader\";\nimport { useCostStats } from \"@/lib/query\";\n\nconst DEFAULT_DAYS = 30;\n\nexport function CostTrackingPanel() {\n\tconst t = useTranslations(\"page.costTracking\");\n\tconst tCommon = useTranslations(\"common\");\n\tconst [days, setDays] = useState<number>(DEFAULT_DAYS);\n\n\t// 使用 TanStack Query 获取费用统计\n\tconst {\n\t\tdata: stats,\n\t\tisLoading: loading,\n\t\terror,\n\t\trefetch,\n\t} = useCostStats(days);\n\n\tconst formatCurrency = (amount: number | undefined | null) => {\n\t\tif (amount === undefined || amount === null || Number.isNaN(amount)) {\n\t\t\treturn \"¥0.00\";\n\t\t}\n\t\treturn `¥${amount.toFixed(2)}`;\n\t};\n\n\tconst formatNumber = (num: number | undefined | null) => {\n\t\tif (num === undefined || num === null || Number.isNaN(num)) {\n\t\t\treturn \"0\";\n\t\t}\n\t\tconst numberLocale = tCommon(\"numberLocale\") as string;\n\t\treturn num.toLocaleString(numberLocale);\n\t};\n\n\tconst featureName = (featureId: string) => {\n\t\t// 将 camelCase 转换回 snake_case（因为 fetcher 会转换字段名，但翻译 key 是 snake_case）\n\t\tconst toSnakeCase = (str: string) =>\n\t\t\tstr.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);\n\n\t\t// 转换为 snake_case 格式以匹配翻译 key\n\t\tconst normalizedId = toSnakeCase(featureId);\n\t\tconst translationKey = `featureNames.${normalizedId}` as Parameters<\n\t\t\ttypeof t\n\t\t>[0];\n\n\t\t// 尝试获取翻译\n\t\tconst translation = t(translationKey);\n\n\t\t// 如果翻译结果包含完整的命名空间路径（说明翻译不存在），返回未知功能\n\t\tif (translation.includes(\"page.costTracking.featureNames.\")) {\n\t\t\treturn t(\"featureNames.unknown\");\n\t\t}\n\n\t\treturn translation;\n\t};\n\n\tconst recentData = useMemo(() => {\n\t\tif (!stats || !stats.dailyCosts) return [];\n\t\tconst dates = Object.keys(stats.dailyCosts).sort().slice(-7);\n\t\treturn dates.map((date) => ({\n\t\t\tdate,\n\t\t\tcost: stats.dailyCosts?.[date]?.cost ?? 0,\n\t\t\ttokens: stats.dailyCosts?.[date]?.totalTokens ?? 0,\n\t\t}));\n\t}, [stats]);\n\n\tconst maxCost = useMemo(() => {\n\t\tif (!recentData.length) return 1;\n\t\treturn Math.max(1, ...recentData.map((item) => item.cost || 0));\n\t}, [recentData]);\n\n\treturn (\n\t\t<div className=\"flex h-full flex-col overflow-auto bg-background text-foreground\">\n\t\t\t<PanelHeader icon={DollarSign} title={t(\"title\")} />\n\t\t\t<div className=\"border-b border-border bg-card/80 px-4 py-3\">\n\t\t\t\t<p className=\"text-sm text-muted-foreground\">{t(\"subtitle\")}</p>\n\t\t\t</div>\n\n\t\t\t<div className=\"flex-1 space-y-4 overflow-auto p-4\">\n\t\t\t\t<div className=\"flex flex-wrap items-center gap-3\">\n\t\t\t\t\t<div className=\"flex items-center gap-2 text-sm text-[oklch(var(--muted-foreground))]\">\n\t\t\t\t\t\t<Calendar className=\"h-4 w-4\" />\n\t\t\t\t\t\t<span>{t(\"statisticsPeriod\")}:</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t<select\n\t\t\t\t\t\tvalue={days}\n\t\t\t\t\t\tonChange={(e) => setDays(Number(e.target.value))}\n\t\t\t\t\t\tclassName=\"rounded-lg border border-[oklch(var(--border))] bg-background px-3 py-2 text-sm shadow-sm focus:border-[oklch(var(--primary))] focus:outline-none focus:ring-2 focus:ring-[oklch(var(--primary))]/50\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<option value={7}>{t(\"last7Days\")}</option>\n\t\t\t\t\t\t<option value={30}>{t(\"last30Days\")}</option>\n\t\t\t\t\t\t<option value={90}>{t(\"last90Days\")}</option>\n\t\t\t\t\t</select>\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tonClick={() => refetch()}\n\t\t\t\t\t\tclassName=\"inline-flex items-center gap-2 rounded-lg border border-[oklch(var(--border))] px-3 py-2 text-sm font-medium shadow-sm transition-colors hover:bg-[oklch(var(--muted))]\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<RefreshCw className=\"h-4 w-4\" />\n\t\t\t\t\t\t{t(\"refresh\")}\n\t\t\t\t\t</button>\n\t\t\t\t</div>\n\n\t\t\t\t{error && (\n\t\t\t\t\t<div className=\"flex items-start gap-2 rounded-lg border border-[oklch(var(--destructive))]/40 bg-[oklch(var(--destructive))]/10 px-3 py-2 text-sm text-[oklch(var(--destructive))]\">\n\t\t\t\t\t\t<AlertCircle className=\"mt-0.5 h-4 w-4\" />\n\t\t\t\t\t\t<span>\n\t\t\t\t\t\t\t{error instanceof Error\n\t\t\t\t\t\t\t\t? error.message\n\t\t\t\t\t\t\t\t: String(error) || t(\"loadFailed\")}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\n\t\t\t\t{loading ? (\n\t\t\t\t\t<div className=\"flex h-48 items-center justify-center text-sm text-[oklch(var(--muted-foreground))]\">\n\t\t\t\t\t\t<div className=\"h-6 w-6 animate-spin rounded-full border-2 border-[oklch(var(--primary))]/30 border-t-[oklch(var(--primary))]\" />\n\t\t\t\t\t</div>\n\t\t\t\t) : stats ? (\n\t\t\t\t\t<div className=\"space-y-4\">\n\t\t\t\t\t\t<div className=\"grid grid-cols-1 gap-3 md:grid-cols-3\">\n\t\t\t\t\t\t\t<div className=\"rounded-xl border border-[oklch(var(--border))] bg-background p-4 shadow-sm\">\n\t\t\t\t\t\t\t\t<div className=\"flex items-center justify-between text-sm text-[oklch(var(--muted-foreground))]\">\n\t\t\t\t\t\t\t\t\t<span>{t(\"totalCost\")}</span>\n\t\t\t\t\t\t\t\t\t<DollarSign className=\"h-4 w-4 text-[oklch(var(--primary))]\" />\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<p className=\"mt-2 text-3xl font-bold text-[oklch(var(--primary))]\">\n\t\t\t\t\t\t\t\t\t{formatCurrency(stats.totalCost)}\n\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t<div className=\"rounded-xl border border-[oklch(var(--border))] bg-background p-4 shadow-sm\">\n\t\t\t\t\t\t\t\t<div className=\"flex items-center justify-between text-sm text-[oklch(var(--muted-foreground))]\">\n\t\t\t\t\t\t\t\t\t<span>{t(\"totalTokens\")}</span>\n\t\t\t\t\t\t\t\t\t<Activity className=\"h-4 w-4 text-[oklch(var(--primary))]\" />\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<p className=\"mt-2 text-3xl font-bold\">\n\t\t\t\t\t\t\t\t\t{formatNumber(stats.totalTokens)}\n\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t<div className=\"rounded-xl border border-[oklch(var(--border))] bg-background p-4 shadow-sm\">\n\t\t\t\t\t\t\t\t<div className=\"flex items-center justify-between text-sm text-[oklch(var(--muted-foreground))]\">\n\t\t\t\t\t\t\t\t\t<span>{t(\"totalRequests\")}</span>\n\t\t\t\t\t\t\t\t\t<TrendingUp className=\"h-4 w-4 text-[oklch(var(--primary))]\" />\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<p className=\"mt-2 text-3xl font-bold\">\n\t\t\t\t\t\t\t\t\t{formatNumber(stats.totalRequests)}\n\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t<div className=\"overflow-hidden rounded-xl border border-[oklch(var(--border))] bg-background shadow-sm\">\n\t\t\t\t\t\t\t<div className=\"border-b border-[oklch(var(--border))] px-4 py-3\">\n\t\t\t\t\t\t\t\t<h3 className=\"text-base font-semibold\">\n\t\t\t\t\t\t\t\t\t{t(\"featureCostDetails\")}\n\t\t\t\t\t\t\t\t</h3>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div className=\"overflow-x-auto\">\n\t\t\t\t\t\t\t\t<table className=\"min-w-full text-sm\">\n\t\t\t\t\t\t\t\t\t<thead className=\"bg-[oklch(var(--muted))] text-left text-[oklch(var(--muted-foreground))]\">\n\t\t\t\t\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t\t\t\t\t<th className=\"px-4 py-2 font-medium\">{t(\"feature\")}</th>\n\t\t\t\t\t\t\t\t\t\t\t<th className=\"px-4 py-2 font-medium\">\n\t\t\t\t\t\t\t\t\t\t\t\t{t(\"featureId\")}\n\t\t\t\t\t\t\t\t\t\t\t</th>\n\t\t\t\t\t\t\t\t\t\t\t<th className=\"px-4 py-2 text-right font-medium\">\n\t\t\t\t\t\t\t\t\t\t\t\t{t(\"inputTokens\")}\n\t\t\t\t\t\t\t\t\t\t\t</th>\n\t\t\t\t\t\t\t\t\t\t\t<th className=\"px-4 py-2 text-right font-medium\">\n\t\t\t\t\t\t\t\t\t\t\t\t{t(\"outputTokens\")}\n\t\t\t\t\t\t\t\t\t\t\t</th>\n\t\t\t\t\t\t\t\t\t\t\t<th className=\"px-4 py-2 text-right font-medium\">\n\t\t\t\t\t\t\t\t\t\t\t\t{t(\"requests\")}\n\t\t\t\t\t\t\t\t\t\t\t</th>\n\t\t\t\t\t\t\t\t\t\t\t<th className=\"px-4 py-2 text-right font-medium\">\n\t\t\t\t\t\t\t\t\t\t\t\t{t(\"cost\")}\n\t\t\t\t\t\t\t\t\t\t\t</th>\n\t\t\t\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t\t\t</thead>\n\t\t\t\t\t\t\t\t\t<tbody className=\"divide-y divide-[oklch(var(--border))]\">\n\t\t\t\t\t\t\t\t\t\t{Object.entries(stats.featureCosts || {})\n\t\t\t\t\t\t\t\t\t\t\t.sort(([, a], [, b]) => {\n\t\t\t\t\t\t\t\t\t\t\t\tconst aCost =\n\t\t\t\t\t\t\t\t\t\t\t\t\ttypeof a === \"object\" && a && \"cost\" in a\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t? ((a.cost as number) ?? 0)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t: 0;\n\t\t\t\t\t\t\t\t\t\t\t\tconst bCost =\n\t\t\t\t\t\t\t\t\t\t\t\t\ttypeof b === \"object\" && b && \"cost\" in b\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t? ((b.cost as number) ?? 0)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t: 0;\n\t\t\t\t\t\t\t\t\t\t\t\treturn bCost - aCost;\n\t\t\t\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t\t\t\t\t.map(([featureId, data]) => {\n\t\t\t\t\t\t\t\t\t\t\t\tconst featureData =\n\t\t\t\t\t\t\t\t\t\t\t\t\ttypeof data === \"object\" && data\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t? (data as {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tinputTokens?: number;\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\toutputTokens?: number;\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\trequests?: number;\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tcost?: number;\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t: {};\n\t\t\t\t\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<tr\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tkey={featureId}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"hover:bg-[oklch(var(--muted))]/50\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<td className=\"px-4 py-3 font-medium\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{featureName(featureId)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<td className=\"px-4 py-3 font-mono text-[oklch(var(--muted-foreground))]\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{featureId}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<td className=\"px-4 py-3 text-right\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{formatNumber(featureData.inputTokens)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<td className=\"px-4 py-3 text-right\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{formatNumber(featureData.outputTokens)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<td className=\"px-4 py-3 text-right\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{formatNumber(featureData.requests)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<td className=\"px-4 py-3 text-right font-semibold text-[oklch(var(--primary))]\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{formatCurrency(featureData.cost)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t\t\t</tbody>\n\t\t\t\t\t\t\t\t</table>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t<div className=\"overflow-hidden rounded-xl border border-[oklch(var(--border))] bg-background shadow-sm\">\n\t\t\t\t\t\t\t<div className=\"border-b border-[oklch(var(--border))] px-4 py-3\">\n\t\t\t\t\t\t\t\t<h3 className=\"text-base font-semibold\">\n\t\t\t\t\t\t\t\t\t{t(\"modelCostDetails\")}\n\t\t\t\t\t\t\t\t</h3>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div className=\"overflow-x-auto\">\n\t\t\t\t\t\t\t\t<table className=\"min-w-full text-sm\">\n\t\t\t\t\t\t\t\t\t<thead className=\"bg-[oklch(var(--muted))] text-left text-[oklch(var(--muted-foreground))]\">\n\t\t\t\t\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t\t\t\t\t<th className=\"px-4 py-2 font-medium\">{t(\"model\")}</th>\n\t\t\t\t\t\t\t\t\t\t\t<th className=\"px-4 py-2 text-right font-medium\">\n\t\t\t\t\t\t\t\t\t\t\t\t{t(\"inputTokens\")}\n\t\t\t\t\t\t\t\t\t\t\t</th>\n\t\t\t\t\t\t\t\t\t\t\t<th className=\"px-4 py-2 text-right font-medium\">\n\t\t\t\t\t\t\t\t\t\t\t\t{t(\"outputTokens\")}\n\t\t\t\t\t\t\t\t\t\t\t</th>\n\t\t\t\t\t\t\t\t\t\t\t<th className=\"px-4 py-2 text-right font-medium\">\n\t\t\t\t\t\t\t\t\t\t\t\t{t(\"inputCost\")}\n\t\t\t\t\t\t\t\t\t\t\t</th>\n\t\t\t\t\t\t\t\t\t\t\t<th className=\"px-4 py-2 text-right font-medium\">\n\t\t\t\t\t\t\t\t\t\t\t\t{t(\"outputCost\")}\n\t\t\t\t\t\t\t\t\t\t\t</th>\n\t\t\t\t\t\t\t\t\t\t\t<th className=\"px-4 py-2 text-right font-medium\">\n\t\t\t\t\t\t\t\t\t\t\t\t{t(\"totalCostLabel\")}\n\t\t\t\t\t\t\t\t\t\t\t</th>\n\t\t\t\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t\t\t</thead>\n\t\t\t\t\t\t\t\t\t<tbody className=\"divide-y divide-[oklch(var(--border))]\">\n\t\t\t\t\t\t\t\t\t\t{Object.entries(stats.modelCosts || {})\n\t\t\t\t\t\t\t\t\t\t\t.sort(([, a], [, b]) => {\n\t\t\t\t\t\t\t\t\t\t\t\tconst aCost =\n\t\t\t\t\t\t\t\t\t\t\t\t\ttypeof a === \"object\" && a && \"totalCost\" in a\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t? ((a.totalCost as number) ?? 0)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t: 0;\n\t\t\t\t\t\t\t\t\t\t\t\tconst bCost =\n\t\t\t\t\t\t\t\t\t\t\t\t\ttypeof b === \"object\" && b && \"totalCost\" in b\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t? ((b.totalCost as number) ?? 0)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t: 0;\n\t\t\t\t\t\t\t\t\t\t\t\treturn bCost - aCost;\n\t\t\t\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t\t\t\t\t.map(([model, data]) => {\n\t\t\t\t\t\t\t\t\t\t\t\tconst modelData =\n\t\t\t\t\t\t\t\t\t\t\t\t\ttypeof data === \"object\" && data\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t? (data as {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tinputTokens?: number;\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\toutputTokens?: number;\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tinputCost?: number;\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\toutputCost?: number;\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\ttotalCost?: number;\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t: {};\n\t\t\t\t\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<tr\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tkey={model}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"hover:bg-[oklch(var(--muted))]/50\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<td className=\"px-4 py-3 font-medium\">{model}</td>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<td className=\"px-4 py-3 text-right\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{formatNumber(modelData.inputTokens)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<td className=\"px-4 py-3 text-right\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{formatNumber(modelData.outputTokens)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<td className=\"px-4 py-3 text-right\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{formatCurrency(modelData.inputCost)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<td className=\"px-4 py-3 text-right\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{formatCurrency(modelData.outputCost)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<td className=\"px-4 py-3 text-right font-semibold text-[oklch(var(--primary))]\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{formatCurrency(modelData.totalCost)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t\t\t</tbody>\n\t\t\t\t\t\t\t\t</table>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t{recentData.length > 0 && (\n\t\t\t\t\t\t\t<div className=\"overflow-hidden rounded-xl border border-[oklch(var(--border))] bg-background shadow-sm\">\n\t\t\t\t\t\t\t\t<div className=\"border-b border-[oklch(var(--border))] px-4 py-3\">\n\t\t\t\t\t\t\t\t\t<h3 className=\"text-base font-semibold\">\n\t\t\t\t\t\t\t\t\t\t{t(\"dailyCostTrend\")}\n\t\t\t\t\t\t\t\t\t</h3>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div className=\"space-y-3 p-4\">\n\t\t\t\t\t\t\t\t\t{recentData.map((item) => (\n\t\t\t\t\t\t\t\t\t\t<div key={item.date} className=\"flex items-center gap-3\">\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"w-20 shrink-0 text-xs text-[oklch(var(--muted-foreground))]\">\n\t\t\t\t\t\t\t\t\t\t\t\t{item.date.slice(5)}\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex-1\">\n\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"h-2 flex-1 rounded-full bg-[oklch(var(--muted))]\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"h-2 rounded-full bg-[oklch(var(--primary))]\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\twidth: `${Math.min(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t(item.cost / maxCost) * 100,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t100,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t)}%`,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"w-24 text-right text-sm font-semibold text-[oklch(var(--primary))]\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{formatCurrency(item.cost)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t) : null}\n\t\t\t</div>\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/cost-tracking/index.ts",
    "content": "export { CostTrackingPanel } from \"./CostTrackingPanel\";\n"
  },
  {
    "path": "free-todo-frontend/apps/debug/DebugCapturePanel.tsx",
    "content": "\"use client\";\n\nimport { Camera, ChevronDown, ChevronUp } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport { useEffect, useState } from \"react\";\nimport { TodoExtractionModal } from \"@/apps/todo-list/TodoExtractionModal\";\nimport { PanelHeader } from \"@/components/common/layout/PanelHeader\";\nimport type { Screenshot } from \"@/lib/types\";\nimport { formatDateTime } from \"@/lib/utils\";\nimport {\n\tEventCard,\n\tEventSearchForm,\n\tScreenshotModal,\n\tSelectedEventsBar,\n} from \"./components\";\nimport { useEventActions, useEventData } from \"./hooks\";\n\n/**\n * 调试面板主组件\n * 显示事件时间轴，支持截图预览、事件聚合、待办提取等功能\n */\nexport function DebugCapturePanel() {\n\tconst t = useTranslations(\"todoExtraction\");\n\tconst tDebug = useTranslations(\"debugCapture\");\n\n\t// 事件数据管理\n\tconst {\n\t\tevents,\n\t\ttotalCount,\n\t\teventDetails,\n\t\tgroupedEvents,\n\t\tloading,\n\t\tloadingMore,\n\t\thasMore,\n\t\tstartDate,\n\t\tendDate,\n\t\tappName,\n\t\tsetStartDate,\n\t\tsetEndDate,\n\t\tsetAppName,\n\t\texpandedDates,\n\t\ttoggleDateGroup,\n\t\tloadEvents,\n\t} = useEventData();\n\n\t// 事件操作管理\n\tconst {\n\t\tselectedEvents,\n\t\tisAggregating,\n\t\textractingTodos,\n\t\textractionResult,\n\t\tisExtractionModalOpen,\n\t\ttoggleEventSelection,\n\t\tclearSelection,\n\t\thandleAggregateEvents,\n\t\thandleExtractTodos,\n\t\tcloseExtractionModal,\n\t} = useEventActions({ events, t, tDebug });\n\n\t// UI 状态\n\tconst [isMobile, setIsMobile] = useState(false);\n\tconst [selectedScreenshot, setSelectedScreenshot] =\n\t\tuseState<Screenshot | null>(null);\n\n\t// 检测移动端\n\tuseEffect(() => {\n\t\tconst checkMobile = () => setIsMobile(window.innerWidth < 640);\n\t\tcheckMobile();\n\t\twindow.addEventListener(\"resize\", checkMobile);\n\t\treturn () => window.removeEventListener(\"resize\", checkMobile);\n\t}, []);\n\n\t// 滚动加载更多\n\tuseEffect(() => {\n\t\tconst handleScroll = (e: UIEvent) => {\n\t\t\tif (loading || loadingMore || !hasMore) return;\n\n\t\t\tconst target = e.currentTarget as HTMLElement;\n\t\t\tconst { scrollTop, scrollHeight, clientHeight } = target;\n\n\t\t\tif (scrollTop + clientHeight >= scrollHeight - 100) {\n\t\t\t\tloadEvents(false);\n\t\t\t}\n\t\t};\n\n\t\tconst scrollContainer = document.querySelector(\"[data-scroll-container]\");\n\t\tif (scrollContainer) {\n\t\t\tscrollContainer.addEventListener(\"scroll\", handleScroll as EventListener);\n\t\t\treturn () =>\n\t\t\t\tscrollContainer.removeEventListener(\n\t\t\t\t\t\"scroll\",\n\t\t\t\t\thandleScroll as EventListener,\n\t\t\t\t);\n\t\t}\n\t}, [loading, loadingMore, hasMore, loadEvents]);\n\n\t// 搜索事件\n\tconst handleSearch = (e: React.FormEvent) => {\n\t\te.preventDefault();\n\t\tloadEvents(true);\n\t};\n\n\t// 获取选中截图所属事件的所有截图\n\tconst getScreenshotsForSelectedScreenshot = () => {\n\t\tif (!selectedScreenshot) return null;\n\n\t\tconst eventWithScreenshot = events.find((event) => {\n\t\t\tconst detail = eventDetails[event.id];\n\t\t\treturn detail?.screenshots?.some(\n\t\t\t\t(s: Screenshot) => s.id === selectedScreenshot.id,\n\t\t\t);\n\t\t});\n\n\t\tif (eventWithScreenshot) {\n\t\t\treturn eventDetails[eventWithScreenshot.id]?.screenshots;\n\t\t}\n\t\treturn null;\n\t};\n\n\tconst { grouped, sortedDates } = groupedEvents;\n\n\treturn (\n\t\t<div className=\"flex h-full flex-col overflow-hidden bg-background\">\n\t\t\t{/* 头部 */}\n\t\t\t<PanelHeader icon={Camera} title={tDebug(\"title\")} />\n\n\t\t\t{/* 选中事件提示栏 */}\n\t\t\t<SelectedEventsBar\n\t\t\t\tselectedCount={selectedEvents.size}\n\t\t\t\tisAggregating={isAggregating}\n\t\t\t\tonAggregate={handleAggregateEvents}\n\t\t\t\tonClear={clearSelection}\n\t\t\t/>\n\n\t\t\t{/* 搜索表单 */}\n\t\t\t<EventSearchForm\n\t\t\t\tstartDate={startDate}\n\t\t\t\tendDate={endDate}\n\t\t\t\tappName={appName}\n\t\t\t\tonStartDateChange={setStartDate}\n\t\t\t\tonEndDateChange={setEndDate}\n\t\t\t\tonAppNameChange={setAppName}\n\t\t\t\tonSearch={handleSearch}\n\t\t\t/>\n\n\t\t\t{/* 时间轴区域 */}\n\t\t\t<div className=\"flex-1 overflow-hidden flex flex-col\">\n\t\t\t\t{/* 统计信息 */}\n\t\t\t\t<div className=\"shrink-0 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2 sm:gap-4 border-b border-border bg-muted/30 px-3 sm:px-4 py-2 sm:py-3\">\n\t\t\t\t\t<h2 className=\"text-sm font-medium\">{tDebug(\"eventTimeline\")}</h2>\n\t\t\t\t\t{!loading && (\n\t\t\t\t\t\t<div className=\"text-xs text-muted-foreground\">\n\t\t\t\t\t\t\t{tDebug(\"foundEvents\", { total: totalCount })}\n\t\t\t\t\t\t\t{events.length < totalCount &&\n\t\t\t\t\t\t\t\t` ${tDebug(\"loadedEvents\", { loaded: events.length })}`}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\n\t\t\t\t{/* 事件列表 */}\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"flex-1 overflow-y-auto p-3 sm:p-4\"\n\t\t\t\t\tdata-scroll-container\n\t\t\t\t>\n\t\t\t\t\t{loading ? (\n\t\t\t\t\t\t<div className=\"py-12 text-center text-muted-foreground\">\n\t\t\t\t\t\t\t{tDebug(\"loading\")}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t) : events.length === 0 ? (\n\t\t\t\t\t\t<div className=\"py-12 text-center text-muted-foreground font-medium\">\n\t\t\t\t\t\t\t<p>{tDebug(\"noEventsFound\")}</p>\n\t\t\t\t\t\t\t<p className=\"mt-2 text-sm\">{tDebug(\"adjustSearchCriteria\")}</p>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t) : (\n\t\t\t\t\t\t<div className=\"space-y-6\">\n\t\t\t\t\t\t\t{sortedDates.map((date) => (\n\t\t\t\t\t\t\t\t<DateGroup\n\t\t\t\t\t\t\t\t\tkey={date}\n\t\t\t\t\t\t\t\t\tdate={date}\n\t\t\t\t\t\t\t\t\tevents={grouped[date]}\n\t\t\t\t\t\t\t\t\teventDetails={eventDetails}\n\t\t\t\t\t\t\t\t\tisExpanded={expandedDates.has(date)}\n\t\t\t\t\t\t\t\t\tselectedEvents={selectedEvents}\n\t\t\t\t\t\t\t\t\textractingTodos={extractingTodos}\n\t\t\t\t\t\t\t\t\tisMobile={isMobile}\n\t\t\t\t\t\t\t\t\tonToggleExpand={() => toggleDateGroup(date)}\n\t\t\t\t\t\t\t\t\tonToggleSelection={toggleEventSelection}\n\t\t\t\t\t\t\t\t\tonExtractTodos={handleExtractTodos}\n\t\t\t\t\t\t\t\t\tonScreenshotClick={setSelectedScreenshot}\n\t\t\t\t\t\t\t\t\ttDebug={tDebug}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\n\t\t\t\t\t{/* 加载状态 */}\n\t\t\t\t\t<LoadingIndicator\n\t\t\t\t\t\tloading={loading}\n\t\t\t\t\t\tloadingMore={loadingMore}\n\t\t\t\t\t\thasMore={hasMore}\n\t\t\t\t\t\teventsCount={events.length}\n\t\t\t\t\t\ttDebug={tDebug}\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t{/* 截图模态框 */}\n\t\t\t{selectedScreenshot && (\n\t\t\t\t<ScreenshotModal\n\t\t\t\t\tscreenshot={selectedScreenshot}\n\t\t\t\t\tscreenshots={getScreenshotsForSelectedScreenshot() || undefined}\n\t\t\t\t\tonClose={() => setSelectedScreenshot(null)}\n\t\t\t\t/>\n\t\t\t)}\n\n\t\t\t{/* 待办提取确认弹窗 */}\n\t\t\t{extractionResult && (\n\t\t\t\t<TodoExtractionModal\n\t\t\t\t\tisOpen={isExtractionModalOpen}\n\t\t\t\t\tonClose={closeExtractionModal}\n\t\t\t\t\ttodos={extractionResult.todos}\n\t\t\t\t\teventId={extractionResult.eventId}\n\t\t\t\t\tappName={extractionResult.appName}\n\t\t\t\t/>\n\t\t\t)}\n\t\t</div>\n\t);\n}\n\n// ============== 子组件 ==============\n\ninterface DateGroupProps {\n\tdate: string;\n\tevents: import(\"@/lib/types\").Event[];\n\teventDetails: Record<number, { screenshots?: Screenshot[] }>;\n\tisExpanded: boolean;\n\tselectedEvents: Set<number>;\n\textractingTodos: Set<number>;\n\tisMobile: boolean;\n\tonToggleExpand: () => void;\n\tonToggleSelection: (eventId: number, e?: React.MouseEvent) => void;\n\tonExtractTodos: (eventId: number, appName: string) => void;\n\tonScreenshotClick: (screenshot: Screenshot) => void;\n\ttDebug: (key: string, params?: Record<string, string | number | Date>) => string;\n}\n\n/** 日期分组组件 */\nfunction DateGroup({\n\tdate,\n\tevents,\n\teventDetails,\n\tisExpanded,\n\tselectedEvents,\n\textractingTodos,\n\tisMobile,\n\tonToggleExpand,\n\tonToggleSelection,\n\tonExtractTodos,\n\tonScreenshotClick,\n\ttDebug,\n}: DateGroupProps) {\n\treturn (\n\t\t<div className=\"space-y-4\">\n\t\t\t{/* 日期头部 */}\n\t\t\t<button\n\t\t\t\ttype=\"button\"\n\t\t\t\tonClick={onToggleExpand}\n\t\t\t\tclassName=\"w-full flex items-center justify-between px-3 sm:px-4 py-2 sm:py-3 rounded-lg border border-border bg-card hover:bg-muted/50 transition-colors\"\n\t\t\t>\n\t\t\t\t<div className=\"flex items-center gap-3\">\n\t\t\t\t\t{isExpanded ? (\n\t\t\t\t\t\t<ChevronUp className=\"h-4 w-4 text-muted-foreground\" />\n\t\t\t\t\t) : (\n\t\t\t\t\t\t<ChevronDown className=\"h-4 w-4 text-muted-foreground\" />\n\t\t\t\t\t)}\n\t\t\t\t\t<div className=\"text-left\">\n\t\t\t\t\t\t<div className=\"text-sm font-medium text-foreground\">\n\t\t\t\t\t\t\t{formatDateTime(`${date}T00:00:00`, \"YYYY-MM-DD\")}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"text-xs text-muted-foreground\">\n\t\t\t\t\t\t\t{tDebug(\"eventsCount\", { count: events.length })}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</button>\n\n\t\t\t{/* 事件列表 */}\n\t\t\t{isExpanded && (\n\t\t\t\t<div className=\"relative pl-6 space-y-4\">\n\t\t\t\t\t<div className=\"absolute left-0 top-0 bottom-0 w-px bg-border\" />\n\t\t\t\t\t{events.map((event) => (\n\t\t\t\t\t\t<EventCard\n\t\t\t\t\t\t\tkey={event.id}\n\t\t\t\t\t\t\tevent={event}\n\t\t\t\t\t\t\tscreenshots={eventDetails[event.id]?.screenshots || []}\n\t\t\t\t\t\t\tisSelected={selectedEvents.has(event.id)}\n\t\t\t\t\t\t\tisExtracting={extractingTodos.has(event.id)}\n\t\t\t\t\t\t\tisMobile={isMobile}\n\t\t\t\t\t\t\tonToggleSelection={onToggleSelection}\n\t\t\t\t\t\t\tonExtractTodos={onExtractTodos}\n\t\t\t\t\t\t\tonScreenshotClick={onScreenshotClick}\n\t\t\t\t\t\t/>\n\t\t\t\t\t))}\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</div>\n\t);\n}\n\ninterface LoadingIndicatorProps {\n\tloading: boolean;\n\tloadingMore: boolean;\n\thasMore: boolean;\n\teventsCount: number;\n\ttDebug: (key: string, params?: Record<string, string | number | Date>) => string;\n}\n\n/** 加载状态指示器 */\nfunction LoadingIndicator({\n\tloading,\n\tloadingMore,\n\thasMore,\n\teventsCount,\n\ttDebug,\n}: LoadingIndicatorProps) {\n\tif (loading) return null;\n\n\tif (hasMore) {\n\t\treturn (\n\t\t\t<div className=\"mt-6 flex justify-center\">\n\t\t\t\t<div className=\"text-sm text-muted-foreground\">\n\t\t\t\t\t{loadingMore ? tDebug(\"loadingMore\") : tDebug(\"scrollToLoadMore\")}\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t);\n\t}\n\n\tif (eventsCount > 0) {\n\t\treturn (\n\t\t\t<div className=\"mt-6 text-center text-sm text-muted-foreground\">\n\t\t\t\t{tDebug(\"allEventsLoaded\")}\n\t\t\t</div>\n\t\t);\n\t}\n\n\treturn null;\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/debug/components/EventCard.tsx",
    "content": "\"use client\";\n\nimport { Check, ClipboardList, Square } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport { getScreenshotImage } from \"@/lib/api\";\nimport type { Event, Screenshot } from \"@/lib/types\";\nimport {\n\tcalculateDuration,\n\tcn,\n\tformatDateTime,\n\tformatDuration,\n} from \"@/lib/utils\";\nimport { isWhitelistApp } from \"../utils\";\n\ninterface EventCardProps {\n\tevent: Event;\n\tscreenshots: Screenshot[];\n\tisSelected: boolean;\n\tisExtracting: boolean;\n\tisMobile: boolean;\n\tonToggleSelection: (eventId: number, e?: React.MouseEvent) => void;\n\tonExtractTodos: (eventId: number, appName: string) => void;\n\tonScreenshotClick: (screenshot: Screenshot) => void;\n}\n\n/**\n * 事件卡片组件\n * 显示单个事件的详情，包括截图缩略图、时间、应用信息等\n */\nexport function EventCard({\n\tevent,\n\tscreenshots,\n\tisSelected,\n\tisExtracting,\n\tisMobile,\n\tonToggleSelection,\n\tonExtractTodos,\n\tonScreenshotClick,\n}: EventCardProps) {\n\tconst t = useTranslations(\"todoExtraction\");\n\tconst tDebug = useTranslations(\"debugCapture\");\n\n\tconst duration = event.endTime\n\t\t? calculateDuration(event.startTime, event.endTime)\n\t\t: null;\n\n\t// 合并所有截图的 OCR 文本\n\tconst allOcrText = screenshots\n\t\t.map((s: Screenshot) => s.ocrResult?.textContent)\n\t\t.filter(Boolean)\n\t\t.join(\"\\n\\n\");\n\n\treturn (\n\t\t<div className=\"relative\">\n\t\t\t<div\n\t\t\t\trole=\"button\"\n\t\t\t\ttabIndex={0}\n\t\t\t\tclassName={cn(\n\t\t\t\t\t\"ml-0 border rounded-lg hover:border-primary/50 transition-colors p-3 sm:p-4 bg-card cursor-pointer relative group\",\n\t\t\t\t\tisSelected ? \"border-primary bg-primary/5\" : \"border-border\",\n\t\t\t\t)}\n\t\t\t\tonClick={() => onToggleSelection(event.id)}\n\t\t\t\tonKeyDown={(e) => {\n\t\t\t\t\tif (e.key === \"Enter\" || e.key === \" \") {\n\t\t\t\t\t\te.preventDefault();\n\t\t\t\t\t\tonToggleSelection(event.id);\n\t\t\t\t\t}\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t{/* 选择按钮 */}\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tonClick={(e) => onToggleSelection(event.id, e)}\n\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\"absolute left-2 bottom-2 z-10 rounded p-0.5 transition-all\",\n\t\t\t\t\t\tisSelected ? \"opacity-100\" : \"opacity-0 group-hover:opacity-100\",\n\t\t\t\t\t\t\"hover:bg-muted\",\n\t\t\t\t\t)}\n\t\t\t\t\taria-label={isSelected ? tDebug(\"deselect\") : tDebug(\"select\")}\n\t\t\t\t>\n\t\t\t\t\t{isSelected ? (\n\t\t\t\t\t\t<Check className=\"h-5 w-5 text-primary\" />\n\t\t\t\t\t) : (\n\t\t\t\t\t\t<Square className=\"h-5 w-5 text-primary/60 transition-colors\" />\n\t\t\t\t\t)}\n\t\t\t\t</button>\n\n\t\t\t\t{/* 提取待办按钮（仅白名单应用显示） */}\n\t\t\t\t{isWhitelistApp(event.appName) && (\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tonClick={(e) => {\n\t\t\t\t\t\t\te.stopPropagation();\n\t\t\t\t\t\t\tonExtractTodos(event.id, event.appName);\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tdisabled={isExtracting}\n\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\"absolute right-2 top-2 z-50\",\n\t\t\t\t\t\t\t\"flex items-center gap-1.5\",\n\t\t\t\t\t\t\t\"rounded-md px-2 py-1.5\",\n\t\t\t\t\t\t\t\"text-xs font-medium\",\n\t\t\t\t\t\t\t\"bg-background/95 backdrop-blur-sm text-primary border border-primary/30 shadow-lg\",\n\t\t\t\t\t\t\t\"hover:bg-background hover:border-primary/50\",\n\t\t\t\t\t\t\t\"transition-all\",\n\t\t\t\t\t\t\t\"opacity-0 group-hover:opacity-100\",\n\t\t\t\t\t\t\t\"disabled:opacity-50 disabled:cursor-not-allowed\",\n\t\t\t\t\t\t)}\n\t\t\t\t\t\taria-label={t(\"extractButton\")}\n\t\t\t\t\t>\n\t\t\t\t\t\t{isExtracting ? (\n\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t<div className=\"h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent\" />\n\t\t\t\t\t\t\t\t<span className=\"hidden sm:inline\">{t(\"extracting\")}</span>\n\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t<ClipboardList className=\"h-3.5 w-3.5\" />\n\t\t\t\t\t\t\t\t<span className=\"hidden sm:inline\">{t(\"extractButton\")}</span>\n\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</button>\n\t\t\t\t)}\n\n\t\t\t\t<div className=\"flex flex-col sm:flex-row gap-4\">\n\t\t\t\t\t{/* 事件信息 */}\n\t\t\t\t\t<div className=\"flex-1 min-w-0 space-y-2\">\n\t\t\t\t\t\t<div className=\"flex flex-col sm:flex-row items-start sm:items-center gap-2 flex-wrap\">\n\t\t\t\t\t\t\t<h3 className=\"text-sm sm:text-base font-semibold text-foreground wrap-break-word\">\n\t\t\t\t\t\t\t\t{event.windowTitle || tDebug(\"unknownWindow\")}\n\t\t\t\t\t\t\t</h3>\n\t\t\t\t\t\t\t<span className=\"inline-flex items-center rounded-md bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary\">\n\t\t\t\t\t\t\t\t{event.appName}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t<div className=\"text-xs sm:text-sm text-muted-foreground\">\n\t\t\t\t\t\t\t{formatDateTime(event.startTime, \"MM/DD HH:mm\")}\n\t\t\t\t\t\t\t{event.endTime && (\n\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t{\" - \"}\n\t\t\t\t\t\t\t\t\t{formatDateTime(event.endTime, \"MM/DD HH:mm\")}\n\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t{duration !== null ? (\n\t\t\t\t\t\t\t\t<span>\n\t\t\t\t\t\t\t\t\t{\" \"}\n\t\t\t\t\t\t\t\t\t({tDebug(\"duration\", { duration: formatDuration(duration) })})\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t<span className=\"text-green-600 dark:text-green-400\">\n\t\t\t\t\t\t\t\t\t{\" \"}\n\t\t\t\t\t\t\t\t\t({tDebug(\"inProgress\")})\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t<div className=\"text-xs sm:text-sm text-foreground/80 leading-relaxed line-clamp-2 sm:line-clamp-none\">\n\t\t\t\t\t\t\t{event.aiSummary ||\n\t\t\t\t\t\t\t\tallOcrText?.slice(0, 100) +\n\t\t\t\t\t\t\t\t\t(allOcrText?.length > 100 ? \"...\" : \"\") ||\n\t\t\t\t\t\t\t\ttDebug(\"noDescription\")}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t{/* 截图缩略图 */}\n\t\t\t\t\t{screenshots.length > 0 && (\n\t\t\t\t\t\t<ScreenshotThumbnails\n\t\t\t\t\t\t\teventId={event.id}\n\t\t\t\t\t\t\tscreenshots={screenshots}\n\t\t\t\t\t\t\tisMobile={isMobile}\n\t\t\t\t\t\t\tonScreenshotClick={onScreenshotClick}\n\t\t\t\t\t\t/>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t);\n}\n\ninterface ScreenshotThumbnailsProps {\n\teventId: number;\n\tscreenshots: Screenshot[];\n\tisMobile: boolean;\n\tonScreenshotClick: (screenshot: Screenshot) => void;\n}\n\n/**\n * 截图缩略图组件\n * 显示堆叠的截图缩略图，支持点击查看大图\n */\nfunction ScreenshotThumbnails({\n\teventId,\n\tscreenshots,\n\tisMobile,\n\tonScreenshotClick,\n}: ScreenshotThumbnailsProps) {\n\tconst tDebug = useTranslations(\"debugCapture\");\n\n\treturn (\n\t\t<div className=\"shrink-0 flex justify-start sm:justify-end w-full sm:w-auto\">\n\t\t\t<div\n\t\t\t\tclassName=\"relative h-24 sm:h-32\"\n\t\t\t\tstyle={{\n\t\t\t\t\twidth: `calc(${Math.min(screenshots.length, 10)} * 16px + 96px)`,\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t{screenshots.slice(0, 10).map((screenshot: Screenshot, index: number) => {\n\t\t\t\t\tconst zIndex = 10 - index;\n\t\t\t\t\tconst isLast = index === screenshots.length - 1 || index === 9;\n\n\t\t\t\t\treturn (\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tkey={`${eventId}-${screenshot.id}`}\n\t\t\t\t\t\t\tclassName=\"absolute cursor-pointer transition-all duration-200 hover:scale-105 hover:z-50 border-0 bg-transparent p-0 top-0\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tleft: isMobile ? `${index * 16}px` : `${index * 20}px`,\n\t\t\t\t\t\t\t\tzIndex: zIndex,\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tonClick={(e) => {\n\t\t\t\t\t\t\t\te.stopPropagation();\n\t\t\t\t\t\t\t\tonScreenshotClick(screenshot);\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<div className=\"relative rounded-md overflow-hidden border border-border bg-muted w-24 h-24 sm:w-32 sm:h-32 shadow-sm\">\n\t\t\t\t\t\t\t\t{/* biome-ignore lint/performance/noImgElement: 使用动态URL，Next.js Image需要已知域名 */}\n\t\t\t\t\t\t\t\t<img\n\t\t\t\t\t\t\t\t\tsrc={getScreenshotImage(screenshot.id)}\n\t\t\t\t\t\t\t\t\talt={`${tDebug(\"screenshot\")} ${index + 1}`}\n\t\t\t\t\t\t\t\t\tclassName=\"w-full h-full object-cover\"\n\t\t\t\t\t\t\t\t\tloading=\"lazy\"\n\t\t\t\t\t\t\t\t\tonError={(e) => {\n\t\t\t\t\t\t\t\t\t\tconst target = e.currentTarget;\n\t\t\t\t\t\t\t\t\t\ttarget.style.display = \"none\";\n\t\t\t\t\t\t\t\t\t\tconst errorDiv = document.createElement(\"div\");\n\t\t\t\t\t\t\t\t\t\terrorDiv.className =\n\t\t\t\t\t\t\t\t\t\t\t\"flex h-full w-full items-center justify-center text-muted-foreground text-xs bg-destructive/10\";\n\t\t\t\t\t\t\t\t\t\terrorDiv.textContent = tDebug(\"loadFailed\");\n\t\t\t\t\t\t\t\t\t\tif (target.parentElement) {\n\t\t\t\t\t\t\t\t\t\t\ttarget.parentElement.appendChild(errorDiv);\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t{isLast && screenshots.length > 10 && (\n\t\t\t\t\t\t\t\t\t<div className=\"absolute inset-0 bg-[oklch(var(--overlay))] flex items-center justify-center\">\n\t\t\t\t\t\t\t\t\t\t<span className=\"text-[oklch(var(--foreground))] font-semibold text-xs\">\n\t\t\t\t\t\t\t\t\t\t\t+{screenshots.length - 10}\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</button>\n\t\t\t\t\t);\n\t\t\t\t})}\n\t\t\t\t<div className=\"absolute bottom-0 right-0 rounded-md bg-[oklch(var(--overlay))] px-1.5 sm:px-2 py-0.5 sm:py-1 text-[10px] sm:text-xs font-semibold text-[oklch(var(--foreground))] z-60 pointer-events-none\">\n\t\t\t\t\t{tDebug(\"screenshotCount\", { count: screenshots.length })}\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/debug/components/EventSearchForm.tsx",
    "content": "\"use client\";\n\nimport { Search } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\n\ninterface EventSearchFormProps {\n\tstartDate: string;\n\tendDate: string;\n\tappName: string;\n\tonStartDateChange: (value: string) => void;\n\tonEndDateChange: (value: string) => void;\n\tonAppNameChange: (value: string) => void;\n\tonSearch: (e: React.FormEvent) => void;\n}\n\n/**\n * 事件搜索表单组件\n * 包含日期范围和应用名称筛选\n */\nexport function EventSearchForm({\n\tstartDate,\n\tendDate,\n\tappName,\n\tonStartDateChange,\n\tonEndDateChange,\n\tonAppNameChange,\n\tonSearch,\n}: EventSearchFormProps) {\n\tconst t = useTranslations(\"debugCapture\");\n\n\treturn (\n\t\t<div className=\"shrink-0 border-b border-border bg-muted/30 p-3 sm:p-4\">\n\t\t\t<form\n\t\t\t\tonSubmit={onSearch}\n\t\t\t\tclassName=\"flex flex-col gap-3 sm:gap-4 sm:flex-row sm:items-end\"\n\t\t\t>\n\t\t\t\t<div className=\"flex-1 grid grid-cols-1 gap-3 sm:gap-4 sm:grid-cols-4\">\n\t\t\t\t\t<div className=\"space-y-1\">\n\t\t\t\t\t\t<label\n\t\t\t\t\t\t\thtmlFor=\"start-date\"\n\t\t\t\t\t\t\tclassName=\"text-xs font-medium text-muted-foreground\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{t(\"startDate\")}\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\tid=\"start-date\"\n\t\t\t\t\t\t\ttype=\"date\"\n\t\t\t\t\t\t\tvalue={startDate}\n\t\t\t\t\t\t\tonChange={(e) => onStartDateChange(e.target.value)}\n\t\t\t\t\t\t\tclassName=\"w-full rounded-md border border-input bg-background px-2 sm:px-3 py-1.5 sm:py-2 text-xs sm:text-sm\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"space-y-1\">\n\t\t\t\t\t\t<label\n\t\t\t\t\t\t\thtmlFor=\"end-date\"\n\t\t\t\t\t\t\tclassName=\"text-xs font-medium text-muted-foreground\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{t(\"endDate\")}\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\tid=\"end-date\"\n\t\t\t\t\t\t\ttype=\"date\"\n\t\t\t\t\t\t\tvalue={endDate}\n\t\t\t\t\t\t\tonChange={(e) => onEndDateChange(e.target.value)}\n\t\t\t\t\t\t\tclassName=\"w-full rounded-md border border-input bg-background px-2 sm:px-3 py-1.5 sm:py-2 text-xs sm:text-sm\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"space-y-1\">\n\t\t\t\t\t\t<label\n\t\t\t\t\t\t\thtmlFor=\"app-name\"\n\t\t\t\t\t\t\tclassName=\"text-xs font-medium text-muted-foreground\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{t(\"appName\")}\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\tid=\"app-name\"\n\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\tplaceholder={t(\"appNamePlaceholder\")}\n\t\t\t\t\t\t\tvalue={appName}\n\t\t\t\t\t\t\tonChange={(e) => onAppNameChange(e.target.value)}\n\t\t\t\t\t\t\tclassName=\"w-full rounded-md border border-input bg-background px-2 sm:px-3 py-1.5 sm:py-2 text-xs sm:text-sm\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"flex items-end\">\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"submit\"\n\t\t\t\t\t\t\tclassName=\"w-full sm:w-auto flex items-center justify-center gap-2 rounded-md bg-primary px-3 sm:px-4 py-1.5 sm:py-2 text-xs sm:text-sm font-medium text-primary-foreground hover:bg-primary/90\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<Search className=\"h-3.5 w-3.5 sm:h-4 sm:w-4\" />\n\t\t\t\t\t\t\t<span className=\"hidden sm:inline\">{t(\"search\")}</span>\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</form>\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/debug/components/ScreenshotModal.tsx",
    "content": "\"use client\";\n\nimport { ChevronLeft, ChevronRight, X } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport { useCallback, useEffect, useState } from \"react\";\nimport { getScreenshotImage } from \"@/lib/api\";\nimport type { Screenshot } from \"@/lib/types\";\nimport { cn, formatDateTime } from \"@/lib/utils\";\n\ninterface ScreenshotModalProps {\n\tscreenshot: Screenshot;\n\tscreenshots?: Screenshot[];\n\tonClose: () => void;\n}\n\n/**\n * 截图模态框组件\n * 支持预览截图、左右切换浏览、显示截图详情和 OCR 结果\n */\nexport function ScreenshotModal({\n\tscreenshot,\n\tscreenshots,\n\tonClose,\n}: ScreenshotModalProps) {\n\tconst t = useTranslations(\"debugCapture\");\n\tconst allScreenshots = screenshots || [screenshot];\n\tconst initialIndex = allScreenshots.findIndex((s) => s.id === screenshot.id);\n\tconst [currentIndex, setCurrentIndex] = useState(\n\t\tinitialIndex >= 0 ? initialIndex : 0,\n\t);\n\tconst [isOpen, setIsOpen] = useState(false);\n\tconst [imageError, setImageError] = useState(false);\n\tconst [imageLoading, setImageLoading] = useState(true);\n\tconst currentScreenshot = allScreenshots[currentIndex];\n\n\t// 上一张\n\tconst goToPrevious = useCallback(() => {\n\t\tsetCurrentIndex((prev) =>\n\t\t\tprev > 0 ? prev - 1 : allScreenshots.length - 1,\n\t\t);\n\t\tsetImageError(false);\n\t\tsetImageLoading(true);\n\t}, [allScreenshots.length]);\n\n\t// 下一张\n\tconst goToNext = useCallback(() => {\n\t\tsetCurrentIndex((prev) =>\n\t\t\tprev < allScreenshots.length - 1 ? prev + 1 : 0,\n\t\t);\n\t\tsetImageError(false);\n\t\tsetImageLoading(true);\n\t}, [allScreenshots.length]);\n\n\tuseEffect(() => {\n\t\tsetIsOpen(true);\n\t\tdocument.body.style.overflow = \"hidden\";\n\n\t\tconst handleKeyDown = (e: KeyboardEvent) => {\n\t\t\tif (e.key === \"Escape\") {\n\t\t\t\tonClose();\n\t\t\t} else if (e.key === \"ArrowLeft\") {\n\t\t\t\tgoToPrevious();\n\t\t\t} else if (e.key === \"ArrowRight\") {\n\t\t\t\tgoToNext();\n\t\t\t}\n\t\t};\n\t\tdocument.addEventListener(\"keydown\", handleKeyDown);\n\n\t\treturn () => {\n\t\t\tdocument.body.style.overflow = \"unset\";\n\t\t\tdocument.removeEventListener(\"keydown\", handleKeyDown);\n\t\t};\n\t}, [onClose, goToPrevious, goToNext]);\n\n\tuseEffect(() => {\n\t\tconst newIndex = allScreenshots.findIndex((s) => s.id === screenshot.id);\n\t\tif (newIndex >= 0) {\n\t\t\tsetCurrentIndex(newIndex);\n\t\t\tsetImageError(false);\n\t\t\tsetImageLoading(true);\n\t\t}\n\t}, [screenshot.id, allScreenshots]);\n\n\treturn (\n\t\t<div\n\t\t\trole=\"button\"\n\t\t\ttabIndex={0}\n\t\t\tclassName={cn(\n\t\t\t\t\"fixed inset-0 z-200 flex items-center justify-center p-4\",\n\t\t\t\t\"bg-black/80 backdrop-blur-sm\",\n\t\t\t\t\"transition-opacity duration-200\",\n\t\t\t\tisOpen ? \"opacity-100\" : \"opacity-0\",\n\t\t\t)}\n\t\t\tonClick={onClose}\n\t\t\tonKeyDown={(e) => {\n\t\t\t\tif (e.key === \"Escape\" || e.key === \"Enter\" || e.key === \" \") {\n\t\t\t\t\tonClose();\n\t\t\t\t}\n\t\t\t}}\n\t\t>\n\t\t\t<div\n\t\t\t\trole=\"dialog\"\n\t\t\t\tclassName={cn(\n\t\t\t\t\t\"relative w-full max-w-5xl max-h-[90vh]\",\n\t\t\t\t\t\"bg-background border border-border\",\n\t\t\t\t\t\"rounded-lg shadow-lg\",\n\t\t\t\t\t\"overflow-hidden\",\n\t\t\t\t\t\"transition-all duration-200\",\n\t\t\t\t\tisOpen ? \"scale-100 opacity-100\" : \"scale-95 opacity-0\",\n\t\t\t\t)}\n\t\t\t\tonClick={(e) => e.stopPropagation()}\n\t\t\t\tonKeyDown={(e) => {\n\t\t\t\t\t// 阻止键盘事件冒泡，但不处理任何键盘操作\n\t\t\t\t\te.stopPropagation();\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t{/* 头部 */}\n\t\t\t\t<div className=\"sticky top-0 z-10 flex items-center justify-between border-b border-border bg-background/95 backdrop-blur-sm px-4 py-3\">\n\t\t\t\t\t<h2 className=\"text-xl font-semibold\">{t(\"screenshotDetail\")}</h2>\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tonClick={onClose}\n\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\"rounded-md p-1.5\",\n\t\t\t\t\t\t\t\"text-muted-foreground hover:text-foreground\",\n\t\t\t\t\t\t\t\"hover:bg-muted\",\n\t\t\t\t\t\t\t\"transition-colors\",\n\t\t\t\t\t\t\t\"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n\t\t\t\t\t\t)}\n\t\t\t\t\t\taria-label={t(\"close\")}\n\t\t\t\t\t>\n\t\t\t\t\t\t<X className=\"h-5 w-5\" />\n\t\t\t\t\t</button>\n\t\t\t\t</div>\n\n\t\t\t\t<div className=\"overflow-y-auto max-h-[calc(90vh-65px)]\">\n\t\t\t\t\t<div className=\"space-y-0\">\n\t\t\t\t\t\t{/* 图片预览区域 */}\n\t\t\t\t\t\t<div className=\"relative overflow-hidden bg-muted/30 min-h-[400px] flex items-center justify-center\">\n\t\t\t\t\t\t\t{imageLoading && !imageError && (\n\t\t\t\t\t\t\t\t<div className=\"absolute inset-0 flex items-center justify-center bg-muted/50\">\n\t\t\t\t\t\t\t\t\t<div className=\"text-center\">\n\t\t\t\t\t\t\t\t\t\t<div className=\"inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent\" />\n\t\t\t\t\t\t\t\t\t\t<p className=\"mt-2 text-sm text-muted-foreground\">\n\t\t\t\t\t\t\t\t\t\t\t{t(\"loading\")}\n\t\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t{imageError ? (\n\t\t\t\t\t\t\t\t<div className=\"flex h-full w-full items-center justify-center text-muted-foreground\">\n\t\t\t\t\t\t\t\t\t<div className=\"text-center\">\n\t\t\t\t\t\t\t\t\t\t<svg\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"mx-auto h-12 w-12\"\n\t\t\t\t\t\t\t\t\t\t\tfill=\"none\"\n\t\t\t\t\t\t\t\t\t\t\tviewBox=\"0 0 24 24\"\n\t\t\t\t\t\t\t\t\t\t\tstroke=\"currentColor\"\n\t\t\t\t\t\t\t\t\t\t\trole=\"img\"\n\t\t\t\t\t\t\t\t\t\t\taria-label={t(\"loadFailed\")}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<title>{t(\"loadFailed\")}</title>\n\t\t\t\t\t\t\t\t\t\t\t<path\n\t\t\t\t\t\t\t\t\t\t\t\tstrokeLinecap=\"round\"\n\t\t\t\t\t\t\t\t\t\t\t\tstrokeLinejoin=\"round\"\n\t\t\t\t\t\t\t\t\t\t\t\tstrokeWidth={2}\n\t\t\t\t\t\t\t\t\t\t\t\td=\"M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z\"\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t</svg>\n\t\t\t\t\t\t\t\t\t\t<p className=\"mt-2 text-sm font-medium\">\n\t\t\t\t\t\t\t\t\t\t\t{t(\"imageLoadFailed\")}\n\t\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t\t\t<p className=\"mt-1 text-xs text-muted-foreground\">\n\t\t\t\t\t\t\t\t\t\t\t{t(\"screenshotId\")}: {currentScreenshot.id}\n\t\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t// biome-ignore lint/performance/noImgElement: 使用动态URL，Next.js Image需要已知域名\n\t\t\t\t\t\t\t\t<img\n\t\t\t\t\t\t\t\t\tkey={currentScreenshot.id}\n\t\t\t\t\t\t\t\t\tsrc={getScreenshotImage(currentScreenshot.id)}\n\t\t\t\t\t\t\t\t\talt={t(\"screenshot\")}\n\t\t\t\t\t\t\t\t\tclassName={`w-full h-auto object-contain ${imageLoading ? \"opacity-0\" : \"opacity-100\"} transition-opacity`}\n\t\t\t\t\t\t\t\t\tonLoad={() => {\n\t\t\t\t\t\t\t\t\t\tsetImageLoading(false);\n\t\t\t\t\t\t\t\t\t\tsetImageError(false);\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\tonError={() => {\n\t\t\t\t\t\t\t\t\t\tsetImageError(true);\n\t\t\t\t\t\t\t\t\t\tsetImageLoading(false);\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t\t{/* 图片计数器 */}\n\t\t\t\t\t\t\t{allScreenshots.length > 1 && (\n\t\t\t\t\t\t\t\t<div className=\"absolute bottom-3 right-3 rounded-md bg-black/80 backdrop-blur-sm px-3 py-1.5 text-sm font-medium text-white shadow-lg\">\n\t\t\t\t\t\t\t\t\t{currentIndex + 1} / {allScreenshots.length}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t\t{/* 导航按钮 */}\n\t\t\t\t\t\t\t{allScreenshots.length > 1 && (\n\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\tonClick={(e) => {\n\t\t\t\t\t\t\t\t\t\t\te.stopPropagation();\n\t\t\t\t\t\t\t\t\t\t\tgoToPrevious();\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\t\"absolute left-3 top-1/2 -translate-y-1/2\",\n\t\t\t\t\t\t\t\t\t\t\t\"rounded-md bg-background/90 backdrop-blur-sm border border-border\",\n\t\t\t\t\t\t\t\t\t\t\t\"p-2 text-foreground\",\n\t\t\t\t\t\t\t\t\t\t\t\"shadow-lg\",\n\t\t\t\t\t\t\t\t\t\t\t\"transition-all\",\n\t\t\t\t\t\t\t\t\t\t\t\"hover:bg-background hover:scale-105\",\n\t\t\t\t\t\t\t\t\t\t\t\"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\taria-label={t(\"previous\")}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<ChevronLeft className=\"h-5 w-5\" />\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\tonClick={(e) => {\n\t\t\t\t\t\t\t\t\t\t\te.stopPropagation();\n\t\t\t\t\t\t\t\t\t\t\tgoToNext();\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\t\"absolute right-3 top-1/2 -translate-y-1/2\",\n\t\t\t\t\t\t\t\t\t\t\t\"rounded-md bg-background/90 backdrop-blur-sm border border-border\",\n\t\t\t\t\t\t\t\t\t\t\t\"p-2 text-foreground\",\n\t\t\t\t\t\t\t\t\t\t\t\"shadow-lg\",\n\t\t\t\t\t\t\t\t\t\t\t\"transition-all\",\n\t\t\t\t\t\t\t\t\t\t\t\"hover:bg-background hover:scale-105\",\n\t\t\t\t\t\t\t\t\t\t\t\"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\taria-label={t(\"next\")}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<ChevronRight className=\"h-5 w-5\" />\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t{/* 详情信息 */}\n\t\t\t\t\t\t<div className=\"border-t border-border p-4 space-y-4\">\n\t\t\t\t\t\t\t<h3 className=\"text-base font-semibold\">{t(\"details\")}</h3>\n\t\t\t\t\t\t\t<div className=\"grid grid-cols-1 gap-4 sm:grid-cols-2\">\n\t\t\t\t\t\t\t\t<div className=\"space-y-1\">\n\t\t\t\t\t\t\t\t\t<div className=\"text-sm font-medium text-muted-foreground\">\n\t\t\t\t\t\t\t\t\t\t{t(\"time\")}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div className=\"text-sm text-foreground\">\n\t\t\t\t\t\t\t\t\t\t{formatDateTime(\n\t\t\t\t\t\t\t\t\t\t\tcurrentScreenshot.createdAt,\n\t\t\t\t\t\t\t\t\t\t\t\"YYYY-MM-DD HH:mm:ss\",\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div className=\"space-y-1\">\n\t\t\t\t\t\t\t\t\t<div className=\"text-sm font-medium text-muted-foreground\">\n\t\t\t\t\t\t\t\t\t\t{t(\"app\")}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div className=\"text-sm text-foreground\">\n\t\t\t\t\t\t\t\t\t\t{currentScreenshot.appName || t(\"unknown\")}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div className=\"space-y-1 sm:col-span-2\">\n\t\t\t\t\t\t\t\t\t<div className=\"text-sm font-medium text-muted-foreground\">\n\t\t\t\t\t\t\t\t\t\t{t(\"windowTitle\")}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div className=\"text-sm text-foreground\">\n\t\t\t\t\t\t\t\t\t\t{currentScreenshot.windowTitle || t(\"none\")}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div className=\"space-y-1\">\n\t\t\t\t\t\t\t\t\t<div className=\"text-sm font-medium text-muted-foreground\">\n\t\t\t\t\t\t\t\t\t\t{t(\"size\")}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div className=\"text-sm text-foreground\">\n\t\t\t\t\t\t\t\t\t\t{currentScreenshot.width} × {currentScreenshot.height}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t{/* OCR 结果 */}\n\t\t\t\t\t\t\t{currentScreenshot.ocrResult?.textContent && (\n\t\t\t\t\t\t\t\t<div className=\"space-y-2 pt-4 border-t border-border\">\n\t\t\t\t\t\t\t\t\t<div className=\"text-sm font-medium text-muted-foreground\">\n\t\t\t\t\t\t\t\t\t\t{t(\"ocrResult\")}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div className=\"rounded-md border border-border bg-muted/50 p-4 max-h-64 overflow-y-auto\">\n\t\t\t\t\t\t\t\t\t\t<pre className=\"whitespace-pre-wrap text-sm text-foreground leading-relaxed font-mono\">\n\t\t\t\t\t\t\t\t\t\t\t{currentScreenshot.ocrResult.textContent}\n\t\t\t\t\t\t\t\t\t\t</pre>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/debug/components/SelectedEventsBar.tsx",
    "content": "\"use client\";\n\nimport { Activity, Check } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\n\ninterface SelectedEventsBarProps {\n\tselectedCount: number;\n\tisAggregating: boolean;\n\tonAggregate: () => void;\n\tonClear: () => void;\n}\n\n/**\n * 选中事件提示栏组件\n * 显示已选中的事件数量，并提供聚合和清除操作\n */\nexport function SelectedEventsBar({\n\tselectedCount,\n\tisAggregating,\n\tonAggregate,\n\tonClear,\n}: SelectedEventsBarProps) {\n\tconst t = useTranslations(\"debugCapture\");\n\n\tif (selectedCount === 0) {\n\t\treturn null;\n\t}\n\n\treturn (\n\t\t<div className=\"shrink-0 flex items-center justify-between rounded-lg mx-3 sm:mx-4 mt-3 sm:mt-4 px-4 py-3 border bg-primary/10 border-primary/20\">\n\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t<Check className=\"h-5 w-5 text-primary\" />\n\t\t\t\t<span className=\"font-medium text-primary\">\n\t\t\t\t\t{t(\"selectedEvents\", { count: selectedCount })}\n\t\t\t\t</span>\n\t\t\t</div>\n\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tonClick={onAggregate}\n\t\t\t\t\tdisabled={isAggregating || selectedCount === 0}\n\t\t\t\t\tclassName=\"flex items-center gap-2 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed\"\n\t\t\t\t>\n\t\t\t\t\t{isAggregating ? (\n\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t<div className=\"h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent\" />\n\t\t\t\t\t\t\t<span>{t(\"aggregating\")}</span>\n\t\t\t\t\t\t</>\n\t\t\t\t\t) : (\n\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t<Activity className=\"h-4 w-4\" />\n\t\t\t\t\t\t\t<span>{t(\"aggregateActivity\", { count: selectedCount })}</span>\n\t\t\t\t\t\t</>\n\t\t\t\t\t)}\n\t\t\t\t</button>\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tonClick={onClear}\n\t\t\t\t\tdisabled={isAggregating}\n\t\t\t\t\tclassName=\"rounded-md border border-input bg-background px-3 py-1.5 text-sm font-medium hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed\"\n\t\t\t\t>\n\t\t\t\t\t{t(\"clearSelection\")}\n\t\t\t\t</button>\n\t\t\t</div>\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/debug/components/index.ts",
    "content": "/**\n * Debug Capture 组件导出\n */\n\nexport { EventCard } from \"./EventCard\";\nexport { EventSearchForm } from \"./EventSearchForm\";\nexport { ScreenshotModal } from \"./ScreenshotModal\";\nexport { SelectedEventsBar } from \"./SelectedEventsBar\";\n"
  },
  {
    "path": "free-todo-frontend/apps/debug/hooks/index.ts",
    "content": "/**\n * Debug Capture Hooks 导出\n */\n\nexport { useEventActions } from \"./useEventActions\";\nexport { useEventData } from \"./useEventData\";\n"
  },
  {
    "path": "free-todo-frontend/apps/debug/hooks/useEventActions.ts",
    "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { unwrapApiData } from \"@/lib/api/fetcher\";\nimport { useCreateActivityManualApiActivitiesManualPost } from \"@/lib/generated/activity/activity\";\nimport type {\n\tLifetraceSchemasTodoExtractionExtractedTodo,\n\tManualActivityCreateResponse,\n\tTodoExtractionResponse,\n} from \"@/lib/generated/schemas\";\nimport { useExtractTodosFromEventApiTodoExtractionExtractPost } from \"@/lib/generated/todo-extraction/todo-extraction\";\nimport { toastError, toastInfo, toastSuccess } from \"@/lib/toast\";\nimport type { Event } from \"@/lib/types\";\nimport { isWhitelistApp } from \"../utils\";\n\ninterface ExtractionResult {\n\ttodos: LifetraceSchemasTodoExtractionExtractedTodo[];\n\teventId: number;\n\tappName: string | null;\n}\n\ntype TranslationFunction = (\n\tkey: string,\n\tparams?: Record<string, string | number | Date>,\n) => string;\n\ninterface UseEventActionsOptions {\n\tevents: Event[];\n\tt: TranslationFunction;\n\ttDebug: TranslationFunction;\n}\n\n/**\n * 事件操作 Hook\n * 处理事件选择、聚合活动、提取待办等操作\n */\nexport function useEventActions({\n\tevents,\n\tt,\n\ttDebug,\n}: UseEventActionsOptions) {\n\t// 选中状态\n\tconst [selectedEvents, setSelectedEvents] = useState<Set<number>>(new Set());\n\tconst [aggregating, setAggregating] = useState(false);\n\tconst [extractingTodos, setExtractingTodos] = useState<Set<number>>(\n\t\tnew Set(),\n\t);\n\n\t// 待办提取结果\n\tconst [extractionResult, setExtractionResult] =\n\t\tuseState<ExtractionResult | null>(null);\n\tconst [isExtractionModalOpen, setIsExtractionModalOpen] = useState(false);\n\n\t// Mutation hooks\n\tconst createActivityMutation =\n\t\tuseCreateActivityManualApiActivitiesManualPost();\n\tconst extractTodosMutation =\n\t\tuseExtractTodosFromEventApiTodoExtractionExtractPost();\n\n\tconst isAggregating = aggregating || createActivityMutation.isPending;\n\n\t/**\n\t * 切换事件选中状态\n\t */\n\tconst toggleEventSelection = (eventId: number, e?: React.MouseEvent) => {\n\t\te?.stopPropagation();\n\t\tconst newSet = new Set(selectedEvents);\n\t\tif (newSet.has(eventId)) {\n\t\t\tnewSet.delete(eventId);\n\t\t} else {\n\t\t\tnewSet.add(eventId);\n\t\t}\n\t\tsetSelectedEvents(newSet);\n\t};\n\n\t/**\n\t * 清除选中状态\n\t */\n\tconst clearSelection = () => {\n\t\tsetSelectedEvents(new Set());\n\t};\n\n\t/**\n\t * 聚合选中事件为活动\n\t */\n\tconst handleAggregateEvents = async () => {\n\t\tif (selectedEvents.size === 0) {\n\t\t\talert(tDebug(\"selectEventsPrompt\"));\n\t\t\treturn;\n\t\t}\n\n\t\t// 检查是否有未结束的事件\n\t\tconst unendedEvents = Array.from(selectedEvents).filter((eventId) => {\n\t\t\tconst event = events.find((e) => e.id === eventId);\n\t\t\treturn event && !event.endTime;\n\t\t});\n\n\t\tif (unendedEvents.length > 0) {\n\t\t\talert(tDebug(\"unendedEventsError\"));\n\t\t\treturn;\n\t\t}\n\n\t\tsetAggregating(true);\n\t\ttry {\n\t\t\tconst eventIds = Array.from(selectedEvents);\n\t\t\tconst response = await createActivityMutation.mutateAsync({\n\t\t\t\tdata: { event_ids: eventIds },\n\t\t\t});\n\t\t\tconst created = unwrapApiData<ManualActivityCreateResponse>(response);\n\n\t\t\talert(\n\t\t\t\ttDebug(\"activityCreated\", {\n\t\t\t\t\ttitle: created?.ai_title || tDebug(\"activity\"),\n\t\t\t\t\tcount: eventIds.length,\n\t\t\t\t}),\n\t\t\t);\n\n\t\t\tsetSelectedEvents(new Set());\n\t\t} catch (error: unknown) {\n\t\t\tconsole.error(\"聚合事件失败:\", error);\n\t\t\tconst errorMsg =\n\t\t\t\terror instanceof Error ? error.message : tDebug(\"aggregateFailed\");\n\t\t\talert(errorMsg);\n\t\t} finally {\n\t\t\tsetAggregating(false);\n\t\t}\n\t};\n\n\t/**\n\t * 提取待办事项\n\t */\n\tconst handleExtractTodos = async (eventId: number, eventAppName: string) => {\n\t\tif (!isWhitelistApp(eventAppName)) {\n\t\t\ttoastError(t(\"notWhitelistApp\"));\n\t\t\treturn;\n\t\t}\n\n\t\tsetExtractingTodos((prev) => new Set(prev).add(eventId));\n\t\ttoastInfo(t(\"extracting\"));\n\n\t\ttry {\n\t\t\tconst response = await extractTodosMutation.mutateAsync({\n\t\t\t\tdata: { event_id: eventId },\n\t\t\t});\n\t\t\tconst extracted = unwrapApiData<TodoExtractionResponse>(response);\n\t\t\tif (!extracted) {\n\t\t\t\tthrow new Error(\"Invalid extraction response\");\n\t\t\t}\n\n\t\t\tif (extracted.error_message) {\n\t\t\t\ttoastError(t(\"extractFailed\", { error: extracted.error_message }));\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst todos = extracted.todos || [];\n\t\t\tif (todos.length === 0) {\n\t\t\t\ttoastInfo(t(\"noTodosFound\"));\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\ttoastSuccess(t(\"extractSuccess\", { count: todos.length }));\n\n\t\t\tsetExtractionResult({\n\t\t\t\ttodos,\n\t\t\t\teventId: extracted.event_id,\n\t\t\t\tappName: extracted.app_name || null,\n\t\t\t});\n\t\t\tsetIsExtractionModalOpen(true);\n\t\t} catch (error: unknown) {\n\t\t\tconsole.error(\"提取待办失败:\", error);\n\t\t\tconst errorMsg =\n\t\t\t\terror instanceof Error ? error.message : tDebug(\"extractFailed\");\n\t\t\ttoastError(t(\"extractFailed\", { error: errorMsg }));\n\t\t} finally {\n\t\t\tsetExtractingTodos((prev) => {\n\t\t\t\tconst newSet = new Set(prev);\n\t\t\t\tnewSet.delete(eventId);\n\t\t\t\treturn newSet;\n\t\t\t});\n\t\t}\n\t};\n\n\t/**\n\t * 关闭待办提取弹窗\n\t */\n\tconst closeExtractionModal = () => {\n\t\tsetIsExtractionModalOpen(false);\n\t\tsetExtractionResult(null);\n\t};\n\n\treturn {\n\t\t// 选中状态\n\t\tselectedEvents,\n\t\tisAggregating,\n\t\textractingTodos,\n\n\t\t// 待办提取\n\t\textractionResult,\n\t\tisExtractionModalOpen,\n\n\t\t// 操作方法\n\t\ttoggleEventSelection,\n\t\tclearSelection,\n\t\thandleAggregateEvents,\n\t\thandleExtractTodos,\n\t\tcloseExtractionModal,\n\t};\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/debug/hooks/useEventData.ts",
    "content": "\"use client\";\n\nimport { useCallback, useEffect, useMemo, useState } from \"react\";\nimport {\n\tgetEventDetailApiEventsEventIdGet,\n\tlistEventsApiEventsGet,\n} from \"@/lib/generated/event/event\";\nimport { getScreenshotApiScreenshotsScreenshotIdGet } from \"@/lib/generated/screenshot/screenshot\";\nimport type { Event, Screenshot } from \"@/lib/types\";\nimport { formatDateTime } from \"@/lib/utils\";\nimport { formatDate } from \"../utils\";\n\n/** 每页加载的事件数量 */\nconst PAGE_SIZE = 10;\n\n/** 事件详情类型（包含截图） */\ninterface EventDetail {\n\tscreenshots?: Screenshot[];\n}\n\n/** 分组后的事件数据 */\ninterface GroupedEvents {\n\tgrouped: Record<string, Event[]>;\n\tsortedDates: string[];\n}\n\n/**\n * 事件数据管理 Hook\n * 负责加载、分页、搜索事件列表和事件详情\n */\nexport function useEventData() {\n\t// 事件列表状态\n\tconst [events, setEvents] = useState<Event[]>([]);\n\tconst [totalCount, setTotalCount] = useState(0);\n\tconst [loading, setLoading] = useState(true);\n\tconst [loadingMore, setLoadingMore] = useState(false);\n\tconst [hasMore, setHasMore] = useState(true);\n\tconst [offset, setOffset] = useState(0);\n\n\t// 搜索筛选状态\n\tconst [startDate, setStartDate] = useState(\"\");\n\tconst [endDate, setEndDate] = useState(\"\");\n\tconst [appName, setAppName] = useState(\"\");\n\n\t// 事件详情状态（包含截图）\n\tconst [eventDetails, setEventDetails] = useState<Record<number, EventDetail>>(\n\t\t{},\n\t);\n\n\t// 日期展开状态\n\tconst [expandedDates, setExpandedDates] = useState<Set<string>>(new Set());\n\n\t/**\n\t * 加载单个事件的详情（包含截图和 OCR）\n\t */\n\tconst loadEventDetail = useCallback(async (eventId: number) => {\n\t\ttry {\n\t\t\tconst eventData = await getEventDetailApiEventsEventIdGet(eventId);\n\t\t\tconst eventDataTyped = (eventData || {}) as unknown as Partial<Event> & {\n\t\t\t\tscreenshots?: Screenshot[];\n\t\t\t};\n\n\t\t\t// 为每个截图加载 OCR 结果（如果需要）\n\t\t\tif (eventDataTyped.screenshots && eventDataTyped.screenshots.length > 0) {\n\t\t\t\tconst screenshotsWithOcr = await Promise.all(\n\t\t\t\t\teventDataTyped.screenshots.map(async (screenshot: Screenshot) => {\n\t\t\t\t\t\tif (screenshot.ocrResult) {\n\t\t\t\t\t\t\treturn screenshot;\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tconst screenshotData =\n\t\t\t\t\t\t\t\tawait getScreenshotApiScreenshotsScreenshotIdGet(screenshot.id);\n\t\t\t\t\t\t\tif (screenshotData) {\n\t\t\t\t\t\t\t\tconst data = screenshotData as {\n\t\t\t\t\t\t\t\t\tocrResult?: { textContent: string };\n\t\t\t\t\t\t\t\t\t[id: string]: unknown;\n\t\t\t\t\t\t\t\t};\n\t\t\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\t\t\t...screenshot,\n\t\t\t\t\t\t\t\t\tocrResult: data.ocrResult,\n\t\t\t\t\t\t\t\t};\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t} catch (_error) {\n\t\t\t\t\t\t\t// 静默失败\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn screenshot;\n\t\t\t\t\t}),\n\t\t\t\t);\n\t\t\t\teventDataTyped.screenshots = screenshotsWithOcr;\n\t\t\t}\n\n\t\t\tsetEventDetails((prev) => {\n\t\t\t\tif (prev[eventId]) return prev;\n\t\t\t\treturn {\n\t\t\t\t\t...prev,\n\t\t\t\t\t[eventId]: eventDataTyped,\n\t\t\t\t};\n\t\t\t});\n\t\t} catch (_error) {\n\t\t\t// 静默失败\n\t\t}\n\t}, []);\n\n\t/**\n\t * 加载事件列表\n\t */\n\tconst loadEvents = useCallback(\n\t\tasync (reset = false) => {\n\t\t\tif (reset) {\n\t\t\t\tsetLoading(true);\n\t\t\t\tsetOffset(0);\n\t\t\t\tsetEvents([]);\n\t\t\t} else {\n\t\t\t\tsetLoadingMore(true);\n\t\t\t}\n\n\t\t\ttry {\n\t\t\t\tconst currentOffset = reset ? 0 : offset;\n\t\t\t\tconst params: {\n\t\t\t\t\tlimit: number;\n\t\t\t\t\toffset: number;\n\t\t\t\t\tstart_date?: string;\n\t\t\t\t\tend_date?: string;\n\t\t\t\t\tapp_name?: string;\n\t\t\t\t} = {\n\t\t\t\t\tlimit: PAGE_SIZE,\n\t\t\t\t\toffset: currentOffset,\n\t\t\t\t};\n\n\t\t\t\tif (startDate) params.start_date = `${startDate}T00:00:00`;\n\t\t\t\tif (endDate) params.end_date = `${endDate}T23:59:59`;\n\t\t\t\tif (appName) params.app_name = appName;\n\n\t\t\t\tconst response = await listEventsApiEventsGet(params);\n\t\t\t\tconst responseData = response || {};\n\n\t\t\t\tlet newEvents: Event[] = [];\n\t\t\t\tlet total = 0;\n\n\t\t\t\tif (Array.isArray(responseData)) {\n\t\t\t\t\tnewEvents = responseData;\n\t\t\t\t\ttotal = responseData.length;\n\t\t\t\t} else if (\n\t\t\t\t\tresponseData &&\n\t\t\t\t\ttypeof responseData === \"object\" &&\n\t\t\t\t\t\"events\" in responseData\n\t\t\t\t) {\n\t\t\t\t\tconst eventListResponse = responseData as unknown as {\n\t\t\t\t\t\tevents?: Event[];\n\t\t\t\t\t\ttotal?: number;\n\t\t\t\t\t};\n\t\t\t\t\tnewEvents = eventListResponse.events || [];\n\t\t\t\t\ttotal = eventListResponse.total ?? 0;\n\t\t\t\t}\n\n\t\t\t\tif (reset) {\n\t\t\t\t\tsetEvents(newEvents);\n\t\t\t\t\tsetTotalCount(total);\n\t\t\t\t\tsetOffset(PAGE_SIZE);\n\t\t\t\t\tsetHasMore(newEvents.length < total);\n\t\t\t\t} else {\n\t\t\t\t\tsetEvents((prev) => {\n\t\t\t\t\t\tconst eventMap = new Map(prev.map((e) => [e.id, e]));\n\t\t\t\t\t\tnewEvents.forEach((event: Event) => {\n\t\t\t\t\t\t\teventMap.set(event.id, event);\n\t\t\t\t\t\t});\n\t\t\t\t\t\tconst updatedEvents = Array.from(eventMap.values());\n\t\t\t\t\t\tsetHasMore(updatedEvents.length < total);\n\t\t\t\t\t\treturn updatedEvents;\n\t\t\t\t\t});\n\t\t\t\t\tsetOffset((prev) => prev + PAGE_SIZE);\n\t\t\t\t\tif (total > 0) {\n\t\t\t\t\t\tsetTotalCount(total);\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// 加载每个事件的详情\n\t\t\t\tnewEvents.forEach((event: Event) => {\n\t\t\t\t\tloadEventDetail(event.id);\n\t\t\t\t});\n\t\t\t} catch (_error) {\n\t\t\t\t// 静默失败\n\t\t\t} finally {\n\t\t\t\tsetLoading(false);\n\t\t\t\tsetLoadingMore(false);\n\t\t\t}\n\t\t},\n\t\t[offset, startDate, endDate, appName, loadEventDetail],\n\t);\n\n\t/**\n\t * 按日期分组事件，并按时间倒序排列\n\t */\n\tconst groupedEvents: GroupedEvents = useMemo(() => {\n\t\tif (events.length === 0) {\n\t\t\treturn { grouped: {}, sortedDates: [] };\n\t\t}\n\n\t\tconst sortedEvents = [...events].sort(\n\t\t\t(a, b) =>\n\t\t\t\tnew Date(b.startTime).getTime() - new Date(a.startTime).getTime(),\n\t\t);\n\n\t\tconst grouped: Record<string, Event[]> = {};\n\t\tsortedEvents.forEach((event) => {\n\t\t\tconst date = formatDateTime(event.startTime, \"YYYY-MM-DD\");\n\t\t\tif (!grouped[date]) {\n\t\t\t\tgrouped[date] = [];\n\t\t\t}\n\t\t\tgrouped[date].push(event);\n\t\t});\n\n\t\t// 每组内按时间倒序\n\t\tObject.keys(grouped).forEach((date) => {\n\t\t\tgrouped[date].sort(\n\t\t\t\t(a, b) =>\n\t\t\t\t\tnew Date(b.startTime).getTime() - new Date(a.startTime).getTime(),\n\t\t\t);\n\t\t});\n\n\t\tconst sortedDates = Object.keys(grouped).sort(\n\t\t\t(a, b) => new Date(b).getTime() - new Date(a).getTime(),\n\t\t);\n\n\t\treturn { grouped, sortedDates };\n\t}, [events]);\n\n\t/**\n\t * 切换日期组的展开/折叠状态\n\t */\n\tconst toggleDateGroup = useCallback((date: string) => {\n\t\tsetExpandedDates((prev) => {\n\t\t\tconst newSet = new Set(prev);\n\t\t\tif (newSet.has(date)) {\n\t\t\t\tnewSet.delete(date);\n\t\t\t} else {\n\t\t\t\tnewSet.add(date);\n\t\t\t}\n\t\t\treturn newSet;\n\t\t});\n\t}, []);\n\n\t// 默认展开所有日期组\n\tuseEffect(() => {\n\t\tif (groupedEvents.sortedDates.length > 0) {\n\t\t\tsetExpandedDates((prev) => {\n\t\t\t\tconst hasNewDate = groupedEvents.sortedDates.some(\n\t\t\t\t\t(date) => !prev.has(date),\n\t\t\t\t);\n\t\t\t\tif (!hasNewDate) return prev;\n\t\t\t\tconst newSet = new Set(prev);\n\t\t\t\tfor (const date of groupedEvents.sortedDates) {\n\t\t\t\t\tnewSet.add(date);\n\t\t\t\t}\n\t\t\t\treturn newSet;\n\t\t\t});\n\t\t}\n\t}, [groupedEvents.sortedDates]);\n\n\t// 初始化：设置默认日期并加载事件\n\tuseEffect(() => {\n\t\tconst today = new Date();\n\t\tconst weekAgo = new Date(today);\n\t\tweekAgo.setDate(today.getDate() - 7);\n\n\t\tconst todayStr = formatDate(today);\n\t\tconst weekAgoStr = formatDate(weekAgo);\n\n\t\tsetEndDate(todayStr);\n\t\tsetStartDate(weekAgoStr);\n\n\t\tconst loadInitialEvents = async () => {\n\t\t\tsetLoading(true);\n\t\t\ttry {\n\t\t\t\tconst params = {\n\t\t\t\t\tlimit: PAGE_SIZE,\n\t\t\t\t\toffset: 0,\n\t\t\t\t\tstart_date: `${weekAgoStr}T00:00:00`,\n\t\t\t\t\tend_date: `${todayStr}T23:59:59`,\n\t\t\t\t};\n\n\t\t\t\tconst response = await listEventsApiEventsGet(params);\n\t\t\t\tconst responseData = (response || {}) as unknown as {\n\t\t\t\t\tevents?: Event[];\n\t\t\t\t\ttotal?: number;\n\t\t\t\t};\n\n\t\t\t\tconst newEvents = responseData.events || [];\n\t\t\t\tconst total = responseData.total ?? 0;\n\n\t\t\t\tsetEvents(newEvents);\n\t\t\t\tsetTotalCount(total);\n\t\t\t\tsetOffset(PAGE_SIZE);\n\t\t\t\tsetHasMore(newEvents.length < total);\n\n\t\t\t\tnewEvents.forEach((event: Event) => {\n\t\t\t\t\tloadEventDetail(event.id);\n\t\t\t\t});\n\t\t\t} catch (_error) {\n\t\t\t\t// 静默失败\n\t\t\t} finally {\n\t\t\t\tsetLoading(false);\n\t\t\t}\n\t\t};\n\n\t\tloadInitialEvents();\n\t\t// eslint-disable-next-line react-hooks/exhaustive-deps\n\t}, [loadEventDetail]);\n\n\treturn {\n\t\t// 事件数据\n\t\tevents,\n\t\ttotalCount,\n\t\teventDetails,\n\t\tgroupedEvents,\n\n\t\t// 加载状态\n\t\tloading,\n\t\tloadingMore,\n\t\thasMore,\n\n\t\t// 搜索筛选\n\t\tstartDate,\n\t\tendDate,\n\t\tappName,\n\t\tsetStartDate,\n\t\tsetEndDate,\n\t\tsetAppName,\n\n\t\t// 日期展开\n\t\texpandedDates,\n\t\ttoggleDateGroup,\n\n\t\t// 操作方法\n\t\tloadEvents,\n\t};\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/debug/utils.ts",
    "content": "/**\n * Debug Capture 调试面板工具函数\n */\n\n/**\n * 格式化日期为 YYYY-MM-DD（使用本地时区）\n */\nexport function formatDate(date: Date): string {\n\tconst year = date.getFullYear();\n\tconst month = String(date.getMonth() + 1).padStart(2, \"0\");\n\tconst day = String(date.getDate()).padStart(2, \"0\");\n\treturn `${year}-${month}-${day}`;\n}\n\n/**\n * 白名单应用列表\n * 这些应用支持从截图中提取待办事项\n */\nexport const WHITELIST_APPS = [\n\t\"微信\",\n\t\"WeChat\",\n\t\"飞书\",\n\t\"Feishu\",\n\t\"Lark\",\n\t\"钉钉\",\n\t\"DingTalk\",\n] as const;\n\n/**\n * 检查应用是否在白名单中\n * @param appName - 应用名称\n * @returns 是否在白名单中\n */\nexport function isWhitelistApp(appName: string | null | undefined): boolean {\n\tif (!appName) return false;\n\tconst appLower = appName.toLowerCase();\n\treturn WHITELIST_APPS.some((app) => appLower.includes(app.toLowerCase()));\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/diary/DiaryEditor.tsx",
    "content": "\"use client\";\n\nimport { useTranslations } from \"next-intl\";\nimport { DiaryTabs, type JournalTab } from \"@/apps/diary/DiaryTabs\";\nimport type { JournalDraft } from \"@/apps/diary/types\";\nimport { Button } from \"@/components/ui/button\";\n\ninterface DiaryEditorProps {\n\tdraft: JournalDraft;\n\tactiveTab: JournalTab;\n\tonTabChange: (tab: JournalTab) => void;\n\tonTitleChange: (value: string) => void;\n\tonTitleBlur: (value: string) => void;\n\tonUserNotesChange: (value: string) => void;\n\tonUserNotesBlur: (value: string) => void;\n\tonGenerateObjective: () => void;\n\tonGenerateAi: () => void;\n\tonAutoLink: () => void;\n\tonCopyToOriginal: (content: string) => void;\n\tautoLinkMessage: string | null;\n\tisGeneratingObjective: boolean;\n\tisGeneratingAi: boolean;\n\tisAutoLinking: boolean;\n\thasJournalId: boolean;\n}\n\nexport function DiaryEditor({\n\tdraft,\n\tactiveTab,\n\tonTabChange,\n\tonTitleChange,\n\tonTitleBlur,\n\tonUserNotesChange,\n\tonUserNotesBlur,\n\tonGenerateObjective,\n\tonGenerateAi,\n\tonAutoLink,\n\tonCopyToOriginal,\n\t// autoLinkMessage,\n\tisGeneratingObjective,\n\tisGeneratingAi,\n\tisAutoLinking,\n\thasJournalId,\n}: DiaryEditorProps) {\n\tconst t = useTranslations(\"journalPanel\");\n\n\treturn (\n\t\t<div className=\"flex min-h-0 flex-1 flex-col gap-4 overflow-hidden px-4 py-4\">\n\t\t\t<div className=\"flex flex-wrap items-center justify-between gap-3\">\n\t\t\t\t<DiaryTabs activeTab={activeTab} onChange={onTabChange} />\n\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t<Button\n\t\t\t\t\t\tvariant=\"outline\"\n\t\t\t\t\t\tsize=\"sm\"\n\t\t\t\t\t\tonClick={onGenerateObjective}\n\t\t\t\t\t\tdisabled={!hasJournalId || isGeneratingObjective}\n\t\t\t\t\t>\n\t\t\t\t\t\t{isGeneratingObjective\n\t\t\t\t\t\t\t? t(\"generatingObjective\")\n\t\t\t\t\t\t\t: t(\"generateObjective\")}\n\t\t\t\t\t</Button>\n\t\t\t\t\t<Button\n\t\t\t\t\t\tvariant=\"outline\"\n\t\t\t\t\t\tsize=\"sm\"\n\t\t\t\t\t\tonClick={onGenerateAi}\n\t\t\t\t\t\tdisabled={!hasJournalId || isGeneratingAi}\n\t\t\t\t\t>\n\t\t\t\t\t\t{isGeneratingAi ? t(\"generatingAi\") : t(\"generateAi\")}\n\t\t\t\t\t</Button>\n\t\t\t\t\t<Button\n\t\t\t\t\t\tvariant=\"ghost\"\n\t\t\t\t\t\tsize=\"sm\"\n\t\t\t\t\t\tonClick={onAutoLink}\n\t\t\t\t\t\tdisabled={!hasJournalId || isAutoLinking}\n\t\t\t\t\t>\n\t\t\t\t\t\t{isAutoLinking ? t(\"autoLinking\") : t(\"autoLink\")}\n\t\t\t\t\t</Button>\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t<div className=\"flex min-h-0 flex-1 flex-col gap-3\">\n\t\t\t\t{activeTab === \"original\" && (\n\t\t\t\t\t<div className=\"flex min-h-0 flex-1 flex-col rounded-2xl border border-border bg-background px-4 py-4 shadow-sm\">\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\tvalue={draft.name}\n\t\t\t\t\t\t\tonChange={(event) => onTitleChange(event.target.value)}\n\t\t\t\t\t\t\tonBlur={(event) => onTitleBlur(event.currentTarget.value)}\n\t\t\t\t\t\t\tplaceholder={t(\"titlePlaceholder\")}\n\t\t\t\t\t\t\tclassName=\"text-2xl font-semibold leading-tight text-foreground placeholder:text-muted-foreground/70 focus-visible:outline-none md:text-3xl\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<textarea\n\t\t\t\t\t\t\tvalue={draft.userNotes}\n\t\t\t\t\t\t\tonChange={(event) => onUserNotesChange(event.target.value)}\n\t\t\t\t\t\t\tonBlur={(event) => onUserNotesBlur(event.currentTarget.value)}\n\t\t\t\t\t\t\tplaceholder={t(\"contentPlaceholder\")}\n\t\t\t\t\t\t\tclassName=\"mt-3 min-h-[240px] flex-1 resize-none bg-transparent text-sm leading-relaxed text-foreground placeholder:text-muted-foreground/70 focus-visible:outline-none\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t\t{activeTab === \"objective\" && (\n\t\t\t\t\t<div className=\"flex min-h-0 flex-1 flex-col gap-2\">\n\t\t\t\t\t\t<textarea\n\t\t\t\t\t\t\tvalue={draft.contentObjective}\n\t\t\t\t\t\t\treadOnly\n\t\t\t\t\t\t\tplaceholder={t(\"objectivePlaceholder\")}\n\t\t\t\t\t\t\tclassName=\"min-h-[240px] flex-1 rounded-xl border border-border bg-muted/20 p-4 text-sm leading-relaxed\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t{draft.contentObjective && (\n\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\tvariant=\"outline\"\n\t\t\t\t\t\t\t\tsize=\"sm\"\n\t\t\t\t\t\t\t\tonClick={() => onCopyToOriginal(draft.contentObjective)}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{t(\"copyToOriginal\")}\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t\t{activeTab === \"ai\" && (\n\t\t\t\t\t<div className=\"flex min-h-0 flex-1 flex-col gap-2\">\n\t\t\t\t\t\t<textarea\n\t\t\t\t\t\t\tvalue={draft.contentAi}\n\t\t\t\t\t\t\treadOnly\n\t\t\t\t\t\t\tplaceholder={t(\"aiPlaceholder\")}\n\t\t\t\t\t\t\tclassName=\"min-h-[240px] flex-1 rounded-xl border border-border bg-muted/20 p-4 text-sm leading-relaxed\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t{draft.contentAi && (\n\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\tvariant=\"outline\"\n\t\t\t\t\t\t\t\tsize=\"sm\"\n\t\t\t\t\t\t\t\tonClick={() => onCopyToOriginal(draft.contentAi)}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{t(\"copyToOriginal\")}\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t\t{/* {autoLinkMessage && (\n\t\t\t\t\t<div className=\"rounded-md border border-dashed border-border px-3 py-2 text-xs text-muted-foreground\">\n\t\t\t\t\t\t{autoLinkMessage}\n\t\t\t\t\t</div>\n\t\t\t\t)} */}\n\t\t\t</div>\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/diary/DiaryHeader.tsx",
    "content": "\"use client\";\n\nimport { useTranslations } from \"next-intl\";\nimport { useId } from \"react\";\nimport type { JournalDraft } from \"@/apps/diary/types\";\n\ninterface DiaryHeaderProps {\n\tdraft: JournalDraft;\n\ttagInput: string;\n\tonNameChange: (value: string) => void;\n\tonTagInputChange: (value: string) => void;\n\tonTagsCommit: (value: string) => void;\n\tonAutoSave: (options?: {\n\t\ttagValue?: string;\n\t\tdraftOverride?: Partial<JournalDraft>;\n\t}) => void;\n}\n\nexport function DiaryHeader({\n\tdraft,\n\ttagInput,\n\tonNameChange,\n\tonTagInputChange,\n\tonTagsCommit,\n\tonAutoSave,\n}: DiaryHeaderProps) {\n\tconst t = useTranslations(\"journalPanel\");\n\tconst titleId = useId();\n\tconst tagsId = useId();\n\n\treturn (\n\t\t<div className=\"grid gap-3\">\n\t\t\t<div className=\"grid gap-3 md:grid-cols-[2fr,1fr]\">\n\t\t\t\t<div className=\"space-y-1\">\n\t\t\t\t\t<label\n\t\t\t\t\t\thtmlFor={titleId}\n\t\t\t\t\t\tclassName=\"text-xs font-medium text-muted-foreground\"\n\t\t\t\t\t>\n\t\t\t\t\t\t{t(\"titleLabel\")}\n\t\t\t\t\t</label>\n\t\t\t\t\t<input\n\t\t\t\t\t\tid={titleId}\n\t\t\t\t\t\tvalue={draft.name}\n\t\t\t\t\t\tonChange={(event) => onNameChange(event.target.value)}\n\t\t\t\t\t\tonBlur={(event) =>\n\t\t\t\t\t\t\tonAutoSave({ draftOverride: { name: event.currentTarget.value } })\n\t\t\t\t\t\t}\n\t\t\t\t\t\tplaceholder={t(\"titlePlaceholder\")}\n\t\t\t\t\t\tclassName=\"h-9 w-full rounded-md border border-border bg-background px-3 text-sm\"\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\t\t\t\t<div className=\"space-y-1\">\n\t\t\t\t\t<label\n\t\t\t\t\t\thtmlFor={tagsId}\n\t\t\t\t\t\tclassName=\"text-xs font-medium text-muted-foreground\"\n\t\t\t\t\t>\n\t\t\t\t\t\t{t(\"tagsLabel\")}\n\t\t\t\t\t</label>\n\t\t\t\t\t<input\n\t\t\t\t\t\tid={tagsId}\n\t\t\t\t\t\tvalue={tagInput}\n\t\t\t\t\t\tonChange={(event) => onTagInputChange(event.target.value)}\n\t\t\t\t\t\tonKeyDown={(event) => {\n\t\t\t\t\t\t\tif (event.key === \"Enter\") {\n\t\t\t\t\t\t\t\tevent.preventDefault();\n\t\t\t\t\t\t\t\tonTagsCommit(event.currentTarget.value);\n\t\t\t\t\t\t\t\tonAutoSave({ tagValue: event.currentTarget.value });\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tonBlur={(event) => {\n\t\t\t\t\t\t\tonTagsCommit(event.target.value);\n\t\t\t\t\t\t\tonAutoSave({ tagValue: event.target.value });\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tplaceholder={t(\"tagsPlaceholder\")}\n\t\t\t\t\t\tclassName=\"h-9 w-full rounded-md border border-border bg-background px-3 text-sm\"\n\t\t\t\t\t/>\n\t\t\t\t\t{draft.tags.length > 0 && (\n\t\t\t\t\t\t<div className=\"flex flex-wrap gap-2\">\n\t\t\t\t\t\t\t{draft.tags.map((tag) => (\n\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\tkey={tag}\n\t\t\t\t\t\t\t\t\tclassName=\"rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{tag}\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/diary/DiaryPanel.tsx",
    "content": "\"use client\";\n\nimport { BookOpen, CalendarDays } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport { DiaryEditor } from \"@/apps/diary/DiaryEditor\";\nimport type { JournalTab } from \"@/apps/diary/DiaryTabs\";\nimport {\n\tformatDateInput,\n\tgetDayRange,\n\tnormalizeDateOnly,\n\tparseJournalDate,\n\tresolveBucketRange,\n} from \"@/apps/diary/journal-utils\";\nimport type { JournalDraft } from \"@/apps/diary/types\";\nimport {\n\tPanelHeader,\n\tusePanelActionButtonStyle,\n\tusePanelIconStyle,\n} from \"@/components/common/layout/PanelHeader\";\nimport { DateOnlyPickerPopover } from \"@/components/date-picker/DateOnlyPickerPopover\";\nimport type {\n\tJournalAutoLinkRequest,\n\tJournalCreate,\n\tJournalGenerateRequest,\n} from \"@/lib/generated/schemas\";\nimport {\n\ttype JournalView,\n\tuseJournalMutations,\n\tuseJournals,\n} from \"@/lib/query\";\nimport { useJournalStore } from \"@/lib/store/journal-store\";\nimport { useLocaleStore } from \"@/lib/store/locale\";\nimport { cn } from \"@/lib/utils\";\n\nconst emptyDraft = (date: Date): JournalDraft => ({\n\tid: null,\n\tname: \"\",\n\tuserNotes: \"\",\n\tcontentObjective: \"\",\n\tcontentAi: \"\",\n\tmood: \"\",\n\tenergy: null,\n\ttags: [],\n\trelatedTodoIds: [],\n\trelatedActivityIds: [],\n\tdate: normalizeDateOnly(date),\n});\n\nconst parseTags = (input: string) =>\n\tinput.split(\",\").map((tag) => tag.trim()).filter((tag) => tag.length > 0);\n\nexport function DiaryPanel() {\n\tconst t = useTranslations(\"journalPanel\");\n\tconst tDatePicker = useTranslations(\"datePicker\");\n\tconst { locale } = useLocaleStore();\n\tconst [selectedDate, setSelectedDate] = useState(() =>\n\t\tnormalizeDateOnly(new Date()),\n\t);\n\tconst [draft, setDraft] = useState<JournalDraft>(() =>\n\t\temptyDraft(new Date()),\n\t);\n\tconst [tagInput, setTagInput] = useState(\"\");\n\tconst [activeTab, setActiveTab] = useState<JournalTab>(\"original\");\n\tconst [autoLinkMessage, setAutoLinkMessage] = useState<string | null>(null);\n\tconst [isDatePickerOpen, setIsDatePickerOpen] = useState(false);\n\tconst datePickerRef = useRef<HTMLButtonElement | null>(null);\n\tconst lastSyncKey = useRef<string | null>(null);\n\tconst {\n\t\trefreshMode,\n\t\tfixedTime,\n\t\tworkHoursEnd,\n\t\tcustomTime,\n\t\tautoLinkEnabled,\n\t\tautoGenerateObjectiveEnabled,\n\t\tautoGenerateAiEnabled,\n\t} = useJournalStore();\n\tconst dayRange = useMemo(() => getDayRange(selectedDate), [selectedDate]);\n\tconst bucket = useMemo(\n\t\t() =>\n\t\t\tresolveBucketRange(\n\t\t\t\tnew Date(\n\t\t\t\t\tselectedDate.getFullYear(),\n\t\t\t\t\tselectedDate.getMonth(),\n\t\t\t\t\tselectedDate.getDate(),\n\t\t\t\t\t12,\n\t\t\t\t\t0,\n\t\t\t\t\t0,\n\t\t\t\t\t0,\n\t\t\t\t),\n\t\t\t\trefreshMode,\n\t\t\t\tfixedTime,\n\t\t\t\tworkHoursEnd,\n\t\t\t\tcustomTime,\n\t\t\t),\n\t\t[selectedDate, refreshMode, fixedTime, workHoursEnd, customTime],\n\t);\n\tconst {\n\t\tdata: journalResponse,\n\t\tisLoading: isJournalLoading,\n\t\terror: journalError,\n\t} = useJournals({\n\t\tlimit: 1,\n\t\toffset: 0,\n\t\tstartDate: dayRange.start.toISOString(),\n\t\tendDate: dayRange.end.toISOString(),\n\t});\n\tconst activeJournal = useMemo(\n\t\t() => journalResponse?.journals?.[0] ?? null,\n\t\t[journalResponse?.journals],\n\t);\n\tconst dateLabelFormatter = useMemo(\n\t\t() =>\n\t\t\tnew Intl.DateTimeFormat(locale, {\n\t\t\t\tyear: \"numeric\",\n\t\t\t\tmonth: \"short\",\n\t\t\t\tday: \"numeric\",\n\t\t\t}),\n\t\t[locale],\n\t);\n\tconst dateButtonStyle = usePanelActionButtonStyle(\"default\", {\n\t\tsize: \"h-7 w-auto\",\n\t\ttextColor: \"text-foreground\",\n\t\tclassName: \"gap-2 px-2 text-xs font-medium\",\n\t});\n\tconst actionIconStyle = usePanelIconStyle(\"action\");\n\tconst {\n\t\tcreateJournal,\n\t\tupdateJournal,\n\t\tautoLinkJournal,\n\t\tgenerateObjective,\n\t\tgenerateAiView,\n\t\tisCreating,\n\t\tisUpdating,\n\t\tisAutoLinking,\n\t\tisGeneratingObjective,\n\t\tisGeneratingAi,\n\t} = useJournalMutations();\n\tconst syncDraftFromJournal = useCallback(\n\t\t(journal: JournalView) => {\n\t\t\tconst journalDate = parseJournalDate(journal.date);\n\t\t\tsetDraft({\n\t\t\t\tid: journal.id,\n\t\t\t\tname: journal.name ?? \"\",\n\t\t\t\tuserNotes: journal.userNotes ?? \"\",\n\t\t\t\tcontentObjective: journal.contentObjective ?? \"\",\n\t\t\t\tcontentAi: journal.contentAi ?? \"\",\n\t\t\t\tmood: journal.mood ?? \"\",\n\t\t\t\tenergy: journal.energy ?? null,\n\t\t\t\ttags: (journal.tags ?? []).map((tag) => tag.tagName),\n\t\t\t\trelatedTodoIds: journal.relatedTodoIds ?? [],\n\t\t\t\trelatedActivityIds: journal.relatedActivityIds ?? [],\n\t\t\t\tdate: journalDate,\n\t\t\t});\n\t\t\tsetSelectedDate(journalDate);\n\t\t\tsetTagInput((journal.tags ?? []).map((tag) => tag.tagName).join(\", \"));\n\t\t\tsetAutoLinkMessage(null);\n\t\t\tsetActiveTab(\"original\");\n\t\t},\n\t\t[],\n\t);\n\tuseEffect(() => {\n\t\tif (isJournalLoading) return;\n\t\tconst syncKey = `${bucket.bucketStart.toISOString()}-${activeJournal?.id ?? \"new\"}`;\n\t\tif (lastSyncKey.current === syncKey) return;\n\t\tlastSyncKey.current = syncKey;\n\n\t\tif (activeJournal) {\n\t\t\tconst activeDate = parseJournalDate(activeJournal.date);\n\t\t\tconst activeTime = activeDate.getTime();\n\t\t\tif (\n\t\t\t\tactiveTime >= dayRange.start.getTime() &&\n\t\t\t\tactiveTime <= dayRange.end.getTime()\n\t\t\t) {\n\t\t\t\tsyncDraftFromJournal(activeJournal);\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\n\t\tsetDraft(emptyDraft(selectedDate));\n\t\tsetTagInput(\"\");\n\t\tsetAutoLinkMessage(null);\n\t\tsetActiveTab(\"original\");\n\t}, [\n\t\tactiveJournal,\n\t\tbucket.bucketStart,\n\t\tdayRange,\n\t\tisJournalLoading,\n\t\tselectedDate,\n\t\tsyncDraftFromJournal,\n\t]);\n\n\tconst handleDateChange = (value: Date) => {\n\t\tconst nextDate = normalizeDateOnly(value);\n\t\tif (formatDateInput(nextDate) === formatDateInput(selectedDate)) return;\n\t\tsetSelectedDate(nextDate);\n\t\tsetDraft(emptyDraft(nextDate));\n\t\tsetTagInput(\"\");\n\t\tsetAutoLinkMessage(null);\n\t\tsetActiveTab(\"original\");\n\t};\n\n\tconst buildSavePayload = (\n\t\tupdatedDraft: JournalDraft,\n\t\ttags: string[],\n\t): JournalCreate => ({\n\t\tname: updatedDraft.name || undefined,\n\t\tuser_notes: updatedDraft.userNotes,\n\t\tdate: formatDateInput(updatedDraft.date),\n\t\tcontent_format: \"markdown\",\n\t\tcontent_objective: updatedDraft.contentObjective || null,\n\t\tcontent_ai: updatedDraft.contentAi || null,\n\t\tmood: updatedDraft.mood || null,\n\t\tenergy: updatedDraft.energy,\n\t\tday_bucket_start: bucket.bucketStart.toISOString(),\n\t\ttags,\n\t\trelated_todo_ids: updatedDraft.relatedTodoIds,\n\t\trelated_activity_ids: updatedDraft.relatedActivityIds,\n\t});\n\tconst runAutoLink = async (\n\t\tjournalId: number,\n\t\tsnapshot?: { title: string; content: string; date: Date },\n\t) => {\n\t\tconst payload: JournalAutoLinkRequest = {\n\t\t\tjournal_id: journalId,\n\t\t\ttitle: snapshot?.title ?? draft.name,\n\t\t\tcontent_original: snapshot?.content ?? draft.userNotes,\n\t\t\tdate: formatDateInput(snapshot?.date ?? draft.date),\n\t\t\tday_bucket_start: bucket.bucketStart.toISOString(),\n\t\t\tmax_items: 3,\n\t\t};\n\t\tconst result = await autoLinkJournal(payload);\n\t\tsetDraft((prev) => ({\n\t\t\t...prev,\n\t\t\trelatedTodoIds: result.relatedTodoIds,\n\t\t\trelatedActivityIds: result.relatedActivityIds,\n\t\t}));\n\t\tsetAutoLinkMessage(\n\t\t\tt(\"autoLinkSuccess\", {\n\t\t\t\ttodoCount: result.relatedTodoIds.length,\n\t\t\t\tactivityCount: result.relatedActivityIds.length,\n\t\t\t}),\n\t\t);\n\t};\n\tconst runObjectiveGeneration = async (\n\t\tjournalId: number,\n\t\tsnapshot?: { title: string; content: string; date: Date },\n\t) => {\n\t\tconst payload: JournalGenerateRequest = {\n\t\t\tjournal_id: journalId,\n\t\t\ttitle: snapshot?.title ?? draft.name,\n\t\t\tcontent_original: snapshot?.content ?? draft.userNotes,\n\t\t\tdate: formatDateInput(snapshot?.date ?? draft.date),\n\t\t\tday_bucket_start: bucket.bucketStart.toISOString(),\n\t\t\tlanguage: locale,\n\t\t};\n\t\tconst result = await generateObjective(payload);\n\t\tsetDraft((prev) => ({ ...prev, contentObjective: result.content }));\n\t\tsetActiveTab(\"objective\");\n\t};\n\tconst runAiGeneration = async (\n\t\tjournalId: number,\n\t\tsnapshot?: { title: string; content: string; date: Date },\n\t) => {\n\t\tconst payload: JournalGenerateRequest = {\n\t\t\tjournal_id: journalId,\n\t\t\ttitle: snapshot?.title ?? draft.name,\n\t\t\tcontent_original: snapshot?.content ?? draft.userNotes,\n\t\t\tdate: formatDateInput(snapshot?.date ?? draft.date),\n\t\t\tday_bucket_start: bucket.bucketStart.toISOString(),\n\t\t\tlanguage: locale,\n\t\t};\n\t\tconst result = await generateAiView(payload);\n\t\tsetDraft((prev) => ({ ...prev, contentAi: result.content }));\n\t\tsetActiveTab(\"ai\");\n\t};\n\tconst handleSave = async (options?: {\n\t\ttagsOverride?: string[];\n\t\tdraftOverride?: Partial<JournalDraft>;\n\t}) => {\n\t\tconst tags = options?.tagsOverride ?? parseTags(tagInput);\n\t\tconst updatedDraft = { ...draft, ...options?.draftOverride, tags };\n\t\tsetDraft(updatedDraft);\n\t\tsetTagInput(tags.join(\", \"));\n\t\tconst payload = buildSavePayload(updatedDraft, tags);\n\n\t\tlet saved = null;\n\t\ttry {\n\t\t\tif (updatedDraft.id) {\n\t\t\t\tconst { uid: _uid, ...updatePayload } = payload;\n\t\t\t\tsaved = await updateJournal(updatedDraft.id, updatePayload);\n\t\t\t} else {\n\t\t\t\tsaved = await createJournal(payload);\n\t\t\t}\n\t\t} catch (_error) {\n\t\t\tsetAutoLinkMessage(t(\"saveFailed\"));\n\t\t\treturn;\n\t\t}\n\n\t\tif (!saved) return;\n\n\t\tconst savedDate = parseJournalDate(saved.date);\n\t\tsetDraft({\n\t\t\tid: saved.id,\n\t\t\tname: saved.name ?? \"\",\n\t\t\tuserNotes: saved.userNotes ?? \"\",\n\t\t\tcontentObjective: saved.contentObjective ?? \"\",\n\t\t\tcontentAi: saved.contentAi ?? \"\",\n\t\t\tmood: saved.mood ?? \"\",\n\t\t\tenergy: saved.energy ?? null,\n\t\t\ttags: (saved.tags ?? []).map((tag) => tag.tagName),\n\t\t\trelatedTodoIds: saved.relatedTodoIds ?? [],\n\t\t\trelatedActivityIds: saved.relatedActivityIds ?? [],\n\t\t\tdate: savedDate,\n\t\t});\n\t\tsetSelectedDate(savedDate);\n\t\tsetTagInput((saved.tags ?? []).map((tag) => tag.tagName).join(\", \"));\n\t\tsetAutoLinkMessage(t(\"saveSuccess\"));\n\n\t\tconst snapshot = {\n\t\t\ttitle: saved.name ?? \"\",\n\t\t\tcontent: saved.userNotes ?? \"\",\n\t\t\tdate: savedDate,\n\t\t};\n\n\t\tif (autoLinkEnabled) {\n\t\t\ttry {\n\t\t\t\tawait runAutoLink(saved.id, snapshot);\n\t\t\t} catch (_error) {\n\t\t\t\tsetAutoLinkMessage(t(\"autoLinkFailed\"));\n\t\t\t}\n\t\t}\n\t\tif (autoGenerateObjectiveEnabled && !saved.contentObjective) {\n\t\t\ttry {\n\t\t\t\tawait runObjectiveGeneration(saved.id, snapshot);\n\t\t\t} catch (_error) {\n\t\t\t\tsetAutoLinkMessage(t(\"generateFailed\"));\n\t\t\t}\n\t\t}\n\t\tif (autoGenerateAiEnabled && !saved.contentAi) {\n\t\t\ttry {\n\t\t\t\tawait runAiGeneration(saved.id, snapshot);\n\t\t\t} catch (_error) {\n\t\t\t\tsetAutoLinkMessage(t(\"generateFailed\"));\n\t\t\t}\n\t\t}\n\t};\n\tconst handleAutoSave = (options?: {\n\t\ttagValue?: string;\n\t\tdraftOverride?: Partial<JournalDraft>;\n\t}) => {\n\t\tif (isCreating || isUpdating) return;\n\t\tconst tags =\n\t\t\toptions?.tagValue !== undefined\n\t\t\t\t? parseTags(options.tagValue)\n\t\t\t\t: parseTags(tagInput);\n\t\tconst draftSnapshot = { ...draft, ...options?.draftOverride, tags };\n\t\tconst hasContent =\n\t\t\tdraftSnapshot.name.trim().length > 0 ||\n\t\t\tdraftSnapshot.userNotes.trim().length > 0 ||\n\t\t\ttags.length > 0 ||\n\t\t\t(draftSnapshot.contentObjective ?? \"\").trim().length > 0 ||\n\t\t\t(draftSnapshot.contentAi ?? \"\").trim().length > 0;\n\n\t\tif (!draftSnapshot.id && !hasContent) return;\n\t\tvoid handleSave({\n\t\t\ttagsOverride: tags,\n\t\t\tdraftOverride: options?.draftOverride,\n\t\t});\n\t};\n\tconst handleCopyToOriginal = (content: string) => {\n\t\tsetDraft((prev) => {\n\t\t\tconst trimmed = content.trim();\n\t\t\tif (!trimmed) return prev;\n\t\t\tconst separator = prev.userNotes.trim().length > 0 ? \"\\n\\n\" : \"\";\n\t\t\treturn {\n\t\t\t\t...prev,\n\t\t\t\tuserNotes: `${prev.userNotes}${separator}${trimmed}`,\n\t\t\t};\n\t\t});\n\t\tsetActiveTab(\"original\");\n\t};\n\tconst handleGenerateObjectiveClick = async () => {\n\t\tif (!draft.id) return;\n\t\ttry {\n\t\t\tawait runObjectiveGeneration(draft.id);\n\t\t} catch (_error) {\n\t\t\tsetAutoLinkMessage(t(\"generateFailed\"));\n\t\t}\n\t};\n\tconst handleGenerateAiClick = async () => {\n\t\tif (!draft.id) return;\n\t\ttry {\n\t\t\tawait runAiGeneration(draft.id);\n\t\t} catch (_error) {\n\t\t\tsetAutoLinkMessage(t(\"generateFailed\"));\n\t\t}\n\t};\n\tconst handleAutoLinkClick = async () => {\n\t\tif (!draft.id || isAutoLinking) return;\n\t\ttry {\n\t\t\tawait runAutoLink(draft.id);\n\t\t} catch (_error) {\n\t\t\tsetAutoLinkMessage(t(\"autoLinkFailed\"));\n\t\t}\n\t};\n\n\tif (journalError) {\n\t\tconst errorMessage =\n\t\t\tjournalError instanceof Error\n\t\t\t\t? journalError.message\n\t\t\t\t: String(journalError);\n\t\treturn (\n\t\t\t<div className=\"flex h-full items-center justify-center text-destructive\">\n\t\t\t\t{t(\"loadFailed\", { error: errorMessage })}\n\t\t\t</div>\n\t\t);\n\t}\n\n\treturn (\n\t\t<div className=\"flex h-full flex-col overflow-hidden bg-background\">\n\t\t\t<PanelHeader\n\t\t\t\ticon={BookOpen}\n\t\t\t\ttitle={t(\"panelTitle\")}\n\t\t\t\tactions={\n\t\t\t\t\t<div className=\"relative\">\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tclassName={cn(dateButtonStyle, \"whitespace-nowrap\")}\n\t\t\t\t\t\t\taria-label={tDatePicker(\"pickDate\")}\n\t\t\t\t\t\t\ttitle={tDatePicker(\"pickDate\")}\n\t\t\t\t\t\t\taria-expanded={isDatePickerOpen}\n\t\t\t\t\t\t\tref={datePickerRef}\n\t\t\t\t\t\t\tonClick={() => setIsDatePickerOpen((prev) => !prev)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<CalendarDays className={actionIconStyle} />\n\t\t\t\t\t\t\t<span>{dateLabelFormatter.format(selectedDate)}</span>\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</div>\n\t\t\t\t}\n\t\t\t/>\n\n\t\t\t<div className=\"flex min-h-0 flex-1 flex-col\">\n\t\t\t\t{isDatePickerOpen && (\n\t\t\t\t\t<DateOnlyPickerPopover\n\t\t\t\t\t\tanchorRef={datePickerRef}\n\t\t\t\t\t\tselectedDate={selectedDate}\n\t\t\t\t\t\tonSelectDate={(value) => handleDateChange(value)}\n\t\t\t\t\t\tonClose={() => setIsDatePickerOpen(false)}\n\t\t\t\t\t/>\n\t\t\t\t)}\n\t\t\t\t<DiaryEditor\n\t\t\t\t\tdraft={draft}\n\t\t\t\t\tactiveTab={activeTab}\n\t\t\t\t\tonTabChange={setActiveTab}\n\t\t\t\t\tonTitleChange={(value) =>\n\t\t\t\t\t\tsetDraft((prev) => ({ ...prev, name: value }))\n\t\t\t\t\t}\n\t\t\t\t\tonTitleBlur={(value) =>\n\t\t\t\t\t\thandleAutoSave({ draftOverride: { name: value } })\n\t\t\t\t\t}\n\t\t\t\t\tonUserNotesChange={(value) =>\n\t\t\t\t\t\tsetDraft((prev) => ({ ...prev, userNotes: value }))\n\t\t\t\t\t}\n\t\t\t\t\tonUserNotesBlur={(value) =>\n\t\t\t\t\t\thandleAutoSave({ draftOverride: { userNotes: value } })\n\t\t\t\t\t}\n\t\t\t\t\tonGenerateObjective={handleGenerateObjectiveClick}\n\t\t\t\t\tonGenerateAi={handleGenerateAiClick}\n\t\t\t\t\tonAutoLink={handleAutoLinkClick}\n\t\t\t\t\tonCopyToOriginal={handleCopyToOriginal}\n\t\t\t\t\tautoLinkMessage={autoLinkMessage}\n\t\t\t\t\tisGeneratingObjective={isGeneratingObjective}\n\t\t\t\t\tisGeneratingAi={isGeneratingAi}\n\t\t\t\t\tisAutoLinking={isAutoLinking}\n\t\t\t\t\thasJournalId={Boolean(draft.id)}\n\t\t\t\t/>\n\t\t\t</div>\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/diary/DiarySettings.tsx",
    "content": "\"use client\";\n\nimport { useTranslations } from \"next-intl\";\nimport { useId } from \"react\";\nimport type { JournalRefreshMode } from \"@/lib/store/journal-store\";\n\ninterface DiarySettingsProps {\n\trefreshMode: JournalRefreshMode;\n\tfixedTime: string;\n\tworkHoursStart: string;\n\tworkHoursEnd: string;\n\tcustomTime: string;\n\tautoLinkEnabled: boolean;\n\tautoGenerateObjectiveEnabled: boolean;\n\tautoGenerateAiEnabled: boolean;\n\tonRefreshModeChange: (value: JournalRefreshMode) => void;\n\tonFixedTimeChange: (value: string) => void;\n\tonWorkHoursStartChange: (value: string) => void;\n\tonWorkHoursEndChange: (value: string) => void;\n\tonCustomTimeChange: (value: string) => void;\n\tonAutoLinkChange: (value: boolean) => void;\n\tonAutoGenerateObjectiveChange: (value: boolean) => void;\n\tonAutoGenerateAiChange: (value: boolean) => void;\n}\n\nexport function DiarySettings({\n\trefreshMode,\n\tfixedTime,\n\tworkHoursStart,\n\tworkHoursEnd,\n\tcustomTime,\n\tautoLinkEnabled,\n\tautoGenerateObjectiveEnabled,\n\tautoGenerateAiEnabled,\n\tonRefreshModeChange,\n\tonFixedTimeChange,\n\tonWorkHoursStartChange,\n\tonWorkHoursEndChange,\n\tonCustomTimeChange,\n\tonAutoLinkChange,\n\tonAutoGenerateObjectiveChange,\n\tonAutoGenerateAiChange,\n}: DiarySettingsProps) {\n\tconst t = useTranslations(\"journalPanel\");\n\tconst fixedId = useId();\n\tconst workStartId = useId();\n\tconst workEndId = useId();\n\tconst customId = useId();\n\n\treturn (\n\t\t<div className=\"rounded-xl border border-border bg-muted/10 p-4\">\n\t\t\t<div className=\"mb-3 text-sm font-semibold\">{t(\"settingsTitle\")}</div>\n\t\t\t<div className=\"grid gap-3\">\n\t\t\t\t<div className=\"flex items-center gap-3\">\n\t\t\t\t\t<span className=\"text-xs text-muted-foreground\">\n\t\t\t\t\t\t{t(\"refreshModeLabel\")}\n\t\t\t\t\t</span>\n\t\t\t\t\t<select\n\t\t\t\t\t\tvalue={refreshMode}\n\t\t\t\t\t\tonChange={(event) =>\n\t\t\t\t\t\t\tonRefreshModeChange(\n\t\t\t\t\t\t\t\tevent.target.value as JournalRefreshMode,\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tclassName=\"h-8 rounded-md border border-border bg-background px-2 text-xs\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<option value=\"fixed\">{t(\"refreshModeFixed\")}</option>\n\t\t\t\t\t\t<option value=\"workHours\">{t(\"refreshModeWorkHours\")}</option>\n\t\t\t\t\t\t<option value=\"custom\">{t(\"refreshModeCustom\")}</option>\n\t\t\t\t\t</select>\n\t\t\t\t</div>\n\t\t\t\t<div className=\"grid gap-3 md:grid-cols-3\">\n\t\t\t\t\t<div className=\"space-y-1\">\n\t\t\t\t\t\t<label htmlFor={fixedId} className=\"text-xs text-muted-foreground\">\n\t\t\t\t\t\t\t{t(\"fixedTimeLabel\")}\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\tid={fixedId}\n\t\t\t\t\t\t\ttype=\"time\"\n\t\t\t\t\t\t\tvalue={fixedTime}\n\t\t\t\t\t\t\tonChange={(event) => onFixedTimeChange(event.target.value)}\n\t\t\t\t\t\t\tclassName=\"h-8 w-full rounded-md border border-border bg-background px-2 text-xs\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"space-y-1\">\n\t\t\t\t\t\t<label\n\t\t\t\t\t\t\thtmlFor={workStartId}\n\t\t\t\t\t\t\tclassName=\"text-xs text-muted-foreground\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{t(\"workHoursLabel\")}\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\tid={workStartId}\n\t\t\t\t\t\t\t\ttype=\"time\"\n\t\t\t\t\t\t\t\tvalue={workHoursStart}\n\t\t\t\t\t\t\t\tonChange={(event) => onWorkHoursStartChange(event.target.value)}\n\t\t\t\t\t\t\t\tclassName=\"h-8 w-full rounded-md border border-border bg-background px-2 text-xs\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<span className=\"text-xs text-muted-foreground\">-</span>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\tid={workEndId}\n\t\t\t\t\t\t\t\ttype=\"time\"\n\t\t\t\t\t\t\t\tvalue={workHoursEnd}\n\t\t\t\t\t\t\t\tonChange={(event) => onWorkHoursEndChange(event.target.value)}\n\t\t\t\t\t\t\t\tclassName=\"h-8 w-full rounded-md border border-border bg-background px-2 text-xs\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"space-y-1\">\n\t\t\t\t\t\t<label\n\t\t\t\t\t\t\thtmlFor={customId}\n\t\t\t\t\t\t\tclassName=\"text-xs text-muted-foreground\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{t(\"customTimeLabel\")}\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\tid={customId}\n\t\t\t\t\t\t\ttype=\"time\"\n\t\t\t\t\t\t\tvalue={customTime}\n\t\t\t\t\t\t\tonChange={(event) => onCustomTimeChange(event.target.value)}\n\t\t\t\t\t\t\tclassName=\"h-8 w-full rounded-md border border-border bg-background px-2 text-xs\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<div className=\"grid gap-3 md:grid-cols-3\">\n\t\t\t\t\t<label className=\"flex items-center gap-2 text-xs text-muted-foreground\">\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\ttype=\"checkbox\"\n\t\t\t\t\t\t\tchecked={autoLinkEnabled}\n\t\t\t\t\t\t\tonChange={(event) => onAutoLinkChange(event.target.checked)}\n\t\t\t\t\t\t\tclassName=\"h-4 w-4 rounded border border-border\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t{t(\"autoLinkToggle\")}\n\t\t\t\t\t</label>\n\t\t\t\t\t<label className=\"flex items-center gap-2 text-xs text-muted-foreground\">\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\ttype=\"checkbox\"\n\t\t\t\t\t\t\tchecked={autoGenerateObjectiveEnabled}\n\t\t\t\t\t\t\tonChange={(event) =>\n\t\t\t\t\t\t\t\tonAutoGenerateObjectiveChange(event.target.checked)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tclassName=\"h-4 w-4 rounded border border-border\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t{t(\"autoObjectiveToggle\")}\n\t\t\t\t\t</label>\n\t\t\t\t\t<label className=\"flex items-center gap-2 text-xs text-muted-foreground\">\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\ttype=\"checkbox\"\n\t\t\t\t\t\t\tchecked={autoGenerateAiEnabled}\n\t\t\t\t\t\t\tonChange={(event) =>\n\t\t\t\t\t\t\t\tonAutoGenerateAiChange(event.target.checked)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tclassName=\"h-4 w-4 rounded border border-border\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t{t(\"autoAiToggle\")}\n\t\t\t\t\t</label>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/diary/DiaryTabs.tsx",
    "content": "\"use client\";\n\nimport { useTranslations } from \"next-intl\";\nimport { cn } from \"@/lib/utils\";\n\nexport type JournalTab = \"original\" | \"objective\" | \"ai\";\n\ninterface DiaryTabsProps {\n\tactiveTab: JournalTab;\n\tonChange: (tab: JournalTab) => void;\n}\n\nexport function DiaryTabs({ activeTab, onChange }: DiaryTabsProps) {\n\tconst t = useTranslations(\"journalPanel\");\n\n\tconst tabs: { id: JournalTab; label: string }[] = [\n\t\t{ id: \"original\", label: t(\"tabOriginal\") },\n\t\t{ id: \"objective\", label: t(\"tabObjective\") },\n\t\t{ id: \"ai\", label: t(\"tabAi\") },\n\t];\n\n\treturn (\n\t\t<div className=\"inline-flex rounded-full border border-border bg-muted/20 p-1\">\n\t\t\t{tabs.map((tab) => (\n\t\t\t\t<button\n\t\t\t\t\tkey={tab.id}\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tonClick={() => onChange(tab.id)}\n\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\"rounded-full px-3 py-1 text-xs font-medium transition\",\n\t\t\t\t\t\tactiveTab === tab.id\n\t\t\t\t\t\t\t? \"bg-background text-foreground shadow\"\n\t\t\t\t\t\t\t: \"text-muted-foreground hover:text-foreground\",\n\t\t\t\t\t)}\n\t\t\t\t>\n\t\t\t\t\t{tab.label}\n\t\t\t\t</button>\n\t\t\t))}\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/diary/JournalHistory.tsx",
    "content": "\"use client\";\n\nimport type { JournalView } from \"@/lib/query\";\nimport { cn } from \"@/lib/utils\";\n\ninterface JournalHistoryProps {\n\ttitle: string;\n\tloadingLabel: string;\n\temptyLabel: string;\n\tuntitledLabel: string;\n\tjournals: JournalView[];\n\tisLoading: boolean;\n\tactiveId: number | null;\n\tonSelect: (journal: JournalView) => void;\n\tformatDate: (date: Date) => string;\n}\n\nexport function JournalHistory({\n\ttitle,\n\tloadingLabel,\n\temptyLabel,\n\tuntitledLabel,\n\tjournals,\n\tisLoading,\n\tactiveId,\n\tonSelect,\n\tformatDate,\n}: JournalHistoryProps) {\n\treturn (\n\t\t<aside className=\"flex w-full shrink-0 flex-col border-b border-border/70 bg-muted/5 md:w-64 md:border-b-0 md:border-r\">\n\t\t\t<div className=\"px-4 py-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground\">\n\t\t\t\t{title}\n\t\t\t</div>\n\t\t\t<div className=\"flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto px-3 pb-4\">\n\t\t\t\t{isLoading && (\n\t\t\t\t\t<div className=\"text-xs text-muted-foreground\">{loadingLabel}</div>\n\t\t\t\t)}\n\t\t\t\t{!isLoading && journals.length === 0 && (\n\t\t\t\t\t<div className=\"text-xs text-muted-foreground\">{emptyLabel}</div>\n\t\t\t\t)}\n\t\t\t\t{journals.map((journal) => {\n\t\t\t\t\tconst isActive = journal.id === activeId;\n\t\t\t\t\treturn (\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tkey={journal.id}\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tonClick={() => onSelect(journal)}\n\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\"rounded-md border border-transparent px-3 py-2 text-left transition\",\n\t\t\t\t\t\t\t\tisActive\n\t\t\t\t\t\t\t\t\t? \"border-primary/40 bg-primary/5\"\n\t\t\t\t\t\t\t\t\t: \"hover:bg-muted/40\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<div className=\"text-sm font-medium text-foreground\">\n\t\t\t\t\t\t\t\t{journal.name?.trim() ? journal.name : untitledLabel}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div className=\"text-xs text-muted-foreground\">\n\t\t\t\t\t\t\t\t{formatDate(new Date(journal.date))}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</button>\n\t\t\t\t\t);\n\t\t\t\t})}\n\t\t\t</div>\n\t\t</aside>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/diary/index.ts",
    "content": "export { DiaryPanel } from \"@/apps/diary/DiaryPanel\";\n"
  },
  {
    "path": "free-todo-frontend/apps/diary/journal-utils.ts",
    "content": "import type { JournalRefreshMode } from \"@/lib/store/journal-store\";\n\nconst pad = (value: number) => value.toString().padStart(2, \"0\");\n\nexport const formatDateInput = (value: Date) => {\n\treturn `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}`;\n};\n\nexport const parseDateInput = (value: string) => {\n\tconst [year, month, day] = value.split(\"-\").map(Number);\n\tif (!year || !month || !day) return new Date();\n\treturn new Date(year, month - 1, day);\n};\n\nexport const parseJournalDate = (value: string) => {\n\tconst datePart = value.split(\"T\")[0] ?? value;\n\treturn parseDateInput(datePart);\n};\n\nexport const normalizeDateOnly = (value: Date) =>\n\tnew Date(value.getFullYear(), value.getMonth(), value.getDate());\n\nexport const getDayRange = (value: Date) => {\n\tconst start = new Date(\n\t\tvalue.getFullYear(),\n\t\tvalue.getMonth(),\n\t\tvalue.getDate(),\n\t\t0,\n\t\t0,\n\t\t0,\n\t\t0,\n\t);\n\tconst end = new Date(\n\t\tvalue.getFullYear(),\n\t\tvalue.getMonth(),\n\t\tvalue.getDate(),\n\t\t23,\n\t\t59,\n\t\t59,\n\t\t999,\n\t);\n\treturn { start, end };\n};\n\nconst parseTimeString = (value: string) => {\n\tconst [hours = \"0\", minutes = \"0\"] = value.split(\":\");\n\treturn {\n\t\thours: Number(hours),\n\t\tminutes: Number(minutes),\n\t};\n};\n\nexport const resolveBucketRange = (\n\treference: Date,\n\tmode: JournalRefreshMode,\n\tfixedTime: string,\n\tworkHoursEnd: string,\n\tcustomTime: string,\n) => {\n\tconst timeSource =\n\t\tmode === \"workHours\"\n\t\t\t? workHoursEnd\n\t\t\t: mode === \"custom\"\n\t\t\t\t? customTime\n\t\t\t\t: fixedTime;\n\tconst { hours, minutes } = parseTimeString(timeSource);\n\n\tconst bucketStart = new Date(reference);\n\tbucketStart.setHours(hours, minutes, 0, 0);\n\tif (reference < bucketStart) {\n\t\tbucketStart.setDate(bucketStart.getDate() - 1);\n\t}\n\n\tconst bucketEnd = new Date(bucketStart);\n\tbucketEnd.setDate(bucketEnd.getDate() + 1);\n\n\treturn { bucketStart, bucketEnd, bucketTime: timeSource };\n};\n"
  },
  {
    "path": "free-todo-frontend/apps/diary/types.ts",
    "content": "export interface JournalDraft {\n\tid: number | null;\n\tname: string;\n\tuserNotes: string;\n\tcontentObjective: string;\n\tcontentAi: string;\n\tmood: string;\n\tenergy: number | null;\n\ttags: string[];\n\trelatedTodoIds: number[];\n\trelatedActivityIds: number[];\n\tdate: Date;\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/settings/SettingsPanel.tsx",
    "content": "\"use client\";\n\nimport { LayoutGrid, LifeBuoy, Settings, Sparkles, Wrench, Zap } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { PanelHeader } from \"@/components/common/layout/PanelHeader\";\nimport { useConfig } from \"@/lib/query\";\nimport { useUiStore } from \"@/lib/store/ui-store\";\nimport { cn } from \"@/lib/utils\";\nimport {\n\tAudioAsrConfigSection,\n\tAudioConfigSection,\n\tAutomationTasksSection,\n\tAutoTodoDetectionSection,\n\t// DifyConfigSection,\n\tDockDisplayModeSection,\n\tJournalSettingsSection,\n\tLlmConfigSection,\n\tNotificationPermissionSection,\n\tOnboardingSection,\n\tPanelSwitchesSection,\n\tRecorderConfigSection,\n\tSchedulerSection,\n\ttype SettingsCategory,\n\ttype SettingsCategoryId,\n\tSettingsCategoryPanel,\n\tSettingsSearchAction,\n\tSettingsSearchProvider,\n\tSettingsSection,\n\tTavilyConfigSection,\n\tVersionInfoSection,\n} from \"./components\";\nimport { useSettingsSearchMatchStats } from \"./hooks/useSettingsSearchMatchStats\";\n\nconst SETTINGS_CATEGORY_IDS: SettingsCategoryId[] = [\n\t\"ai\",\n\t\"workspace\",\n\t\"automation\",\n\t\"developer\",\n\t\"help\",\n];\n\n/**\n * 设置面板组件\n * 用于配置系统各项功能\n */\nexport function SettingsPanel() {\n\tconst tPage = useTranslations(\"page\");\n\tconst tSettings = useTranslations(\"page.settings\");\n\n\t// 使用 TanStack Query 获取配置\n\tconst { data: config, isLoading: configLoading } = useConfig();\n\n\t// 获取面板启用状态\n\tconst isFeatureEnabled = useUiStore((state) => state.isFeatureEnabled);\n\tconst isAudioPanelEnabled = isFeatureEnabled(\"audio\");\n\n\tconst categories: SettingsCategory[] = [\n\t\t{\n\t\t\tid: \"ai\",\n\t\t\tlabel: tSettings(\"categoryAiTitle\"),\n\t\t\tdescription: tSettings(\"categoryAiDescription\"),\n\t\t\ticon: Sparkles,\n\t\t},\n\t\t{\n\t\t\tid: \"workspace\",\n\t\t\tlabel: tSettings(\"categoryWorkspaceTitle\"),\n\t\t\tdescription: tSettings(\"categoryWorkspaceDescription\"),\n\t\t\ticon: LayoutGrid,\n\t\t},\n\t\t{\n\t\t\tid: \"automation\",\n\t\t\tlabel: tSettings(\"categoryAutomationTitle\"),\n\t\t\tdescription: tSettings(\"categoryAutomationDescription\"),\n\t\t\ticon: Zap,\n\t\t},\n\t\t{\n\t\t\tid: \"developer\",\n\t\t\tlabel: tSettings(\"categoryDeveloperTitle\"),\n\t\t\tdescription: tSettings(\"categoryDeveloperDescription\"),\n\t\t\ticon: Wrench,\n\t\t},\n\t\t{\n\t\t\tid: \"help\",\n\t\t\tlabel: tSettings(\"categoryHelpTitle\"),\n\t\t\tdescription: tSettings(\"categoryHelpDescription\"),\n\t\t\ticon: LifeBuoy,\n\t\t},\n\t];\n\n\tconst [activeCategory, setActiveCategory] =\n\t\tuseState<SettingsCategoryId>(\"workspace\");\n\tconst contentRef = useRef<HTMLDivElement | null>(null);\n\tconst [searchQuery, setSearchQuery] = useState(\"\");\n\tconst isSearchActive = searchQuery.trim().length > 0;\n\n\tconst loading = configLoading;\n\tconst activeCategoryMeta = categories.find(\n\t\t(category) => category.id === activeCategory,\n\t);\n\n\tconst { handleCategoryMatchChange, showNoResults } =\n\t\tuseSettingsSearchMatchStats({\n\t\t\tcategoriesCount: categories.length,\n\t\t\tisSearchActive,\n\t\t});\n\n\tuseEffect(() => {\n\t\tconst handleSetCategory = (\n\t\t\tevent: CustomEvent<{ category?: SettingsCategoryId }>,\n\t\t) => {\n\t\t\tconst nextCategory = event.detail?.category;\n\t\t\tif (nextCategory && SETTINGS_CATEGORY_IDS.includes(nextCategory)) {\n\t\t\t\tsetActiveCategory(nextCategory);\n\t\t\t}\n\t\t};\n\n\t\twindow.addEventListener(\n\t\t\t\"settings:set-category\",\n\t\t\thandleSetCategory as EventListener,\n\t\t);\n\t\treturn () => {\n\t\t\twindow.removeEventListener(\n\t\t\t\t\"settings:set-category\",\n\t\t\t\thandleSetCategory as EventListener,\n\t\t\t);\n\t\t};\n\t}, []);\n\n\tconst renderCategoryContent = (categoryId: SettingsCategoryId) => {\n\t\tswitch (categoryId) {\n\t\t\tcase \"workspace\":\n\t\t\t\treturn (\n\t\t\t\t\t<>\n\t\t\t\t\t\t<DockDisplayModeSection loading={loading} />\n\t\t\t\t\t\t<PanelSwitchesSection loading={loading} />\n\t\t\t\t\t\t<NotificationPermissionSection loading={loading} />\n\t\t\t\t\t</>\n\t\t\t\t);\n\t\t\tcase \"automation\":\n\t\t\t\treturn (\n\t\t\t\t\t<>\n\t\t\t\t\t\t<JournalSettingsSection />\n\t\t\t\t\t\t<AutoTodoDetectionSection config={config} loading={loading} />\n\t\t\t\t\t\t<AutomationTasksSection loading={loading} />\n\t\t\t\t\t</>\n\t\t\t\t);\n\t\t\tcase \"ai\":\n\t\t\t\treturn (\n\t\t\t\t\t<>\n\t\t\t\t\t\t<LlmConfigSection config={config} loading={loading} />\n\t\t\t\t\t\t<TavilyConfigSection config={config} loading={loading} />\n\t\t\t\t\t</>\n\t\t\t\t);\n\t\t\tcase \"developer\":\n\t\t\t\treturn (\n\t\t\t\t\t<>\n\t\t\t\t\t\t{/* <DifyConfigSection config={config} loading={loading} /> */}\n\t\t\t\t\t\t<SchedulerSection loading={loading} />\n\t\t\t\t\t\t<RecorderConfigSection config={config} loading={loading} />\n\t\t\t\t\t\t{isAudioPanelEnabled && (\n\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t<AudioConfigSection config={config} loading={loading} />\n\t\t\t\t\t\t\t\t<AudioAsrConfigSection config={config} loading={loading} />\n\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</>\n\t\t\t\t);\n\t\t\tcase \"help\":\n\t\t\t\treturn (\n\t\t\t\t\t<>\n\t\t\t\t\t\t<OnboardingSection loading={loading} />\n\t\t\t\t\t\t<SettingsSection\n\t\t\t\t\t\t\ttitle={tSettings(\"aboutTitle\")}\n\t\t\t\t\t\t\tdescription={tSettings(\"aboutDescription\")}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<VersionInfoSection />\n\t\t\t\t\t\t</SettingsSection>\n\t\t\t\t\t</>\n\t\t\t\t);\n\t\t\tdefault:\n\t\t\t\treturn null;\n\t\t}\n\t};\n\n\tuseEffect(() => {\n\t\tif (!activeCategory) return;\n\t\tcontentRef.current?.scrollTo({ top: 0, behavior: \"smooth\" });\n\t}, [activeCategory]);\n\n\tuseEffect(() => {\n\t\tif (isSearchActive) {\n\t\t\tcontentRef.current?.scrollTo({ top: 0, behavior: \"smooth\" });\n\t\t}\n\t}, [isSearchActive]);\n\n\treturn (\n\t\t<div className=\"relative flex h-full flex-col overflow-hidden bg-background\">\n\t\t\t{/* 顶部标题栏 */}\n\t\t\t<PanelHeader\n\t\t\t\ticon={Settings}\n\t\t\t\ttitle={tPage(\"settingsLabel\")}\n\t\t\t\tactions={\n\t\t\t\t\t<SettingsSearchAction\n\t\t\t\t\t\tvalue={searchQuery}\n\t\t\t\t\t\tonChange={setSearchQuery}\n\t\t\t\t\t/>\n\t\t\t\t}\n\t\t\t/>\n\n\t\t\t{/* 设置内容区域 */}\n\t\t\t<SettingsSearchProvider query={searchQuery}>\n\t\t\t\t<div\n\t\t\t\t\tdata-tour=\"settings-content\"\n\t\t\t\t\tref={contentRef}\n\t\t\t\t\tclassName=\"flex-1 overflow-y-auto\"\n\t\t\t\t>\n\t\t\t\t\t{!isSearchActive && (\n\t\t\t\t\t\t<div className=\"sticky top-0 z-10 border-b border-border/70 bg-background/90 px-4 py-4 backdrop-blur\">\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\trole=\"tablist\"\n\t\t\t\t\t\t\t\taria-label={tPage(\"settingsLabel\")}\n\t\t\t\t\t\t\t\tclassName=\"flex flex-wrap items-center gap-2 pb-1\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{categories.map((category) => {\n\t\t\t\t\t\t\t\t\tconst isActive = category.id === activeCategory;\n\t\t\t\t\t\t\t\t\tconst Icon = category.icon;\n\n\t\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\tkey={category.id}\n\t\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\t\trole=\"tab\"\n\t\t\t\t\t\t\t\t\t\t\tid={`settings-category-tab-${category.id}`}\n\t\t\t\t\t\t\t\t\t\t\taria-selected={isActive}\n\t\t\t\t\t\t\t\t\t\t\taria-controls={`settings-category-panel-${category.id}`}\n\t\t\t\t\t\t\t\t\t\t\tonClick={() => setActiveCategory(category.id)}\n\t\t\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\t\t\"flex shrink-0 items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium transition\",\n\t\t\t\t\t\t\t\t\t\t\t\t\"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n\t\t\t\t\t\t\t\t\t\t\t\tisActive\n\t\t\t\t\t\t\t\t\t\t\t\t\t? \"border-primary bg-primary text-primary-foreground shadow-sm\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t: \"border-transparent bg-muted/40 text-foreground hover:bg-muted/70\",\n\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<Icon className=\"h-4 w-4\" />\n\t\t\t\t\t\t\t\t\t\t\t<span>{category.label}</span>\n\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t{activeCategoryMeta?.description && (\n\t\t\t\t\t\t\t\t<p className=\"mt-2 text-xs text-muted-foreground\">\n\t\t\t\t\t\t\t\t\t{activeCategoryMeta.description}\n\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\n\t\t\t\t\t<div className=\"space-y-6 px-4 py-6\">\n\t\t\t\t\t\t{showNoResults && (\n\t\t\t\t\t\t\t<div className=\"flex min-h-50 items-center justify-center\">\n\t\t\t\t\t\t\t\t<div className=\"text-center\">\n\t\t\t\t\t\t\t\t\t<p className=\"text-sm font-medium text-foreground\">\n\t\t\t\t\t\t\t\t\t\t{tSettings(\"searchNoResultsTitle\")}\n\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t\t<p className=\"mt-1 text-xs text-muted-foreground\">\n\t\t\t\t\t\t\t\t\t\t{tSettings(\"searchNoResultsHint\")}\n\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t{categories.map((category) => (\n\t\t\t\t\t\t\t<SettingsCategoryPanel\n\t\t\t\t\t\t\t\tkey={category.id}\n\t\t\t\t\t\t\t\tcategory={category}\n\t\t\t\t\t\t\t\tisSearchActive={isSearchActive}\n\t\t\t\t\t\t\t\tactiveCategory={activeCategory}\n\t\t\t\t\t\t\t\trenderCategoryContent={renderCategoryContent}\n\t\t\t\t\t\t\t\tonMatchChange={handleCategoryMatchChange}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t))}\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</SettingsSearchProvider>\n\t\t</div>\n\t);\n}\n\n// 兼容默认导出，避免构建器找不到导出时报错\nexport default SettingsPanel;\n"
  },
  {
    "path": "free-todo-frontend/apps/settings/components/AudioAsrConfigSection.tsx",
    "content": "\"use client\";\n\nimport { useTranslations } from \"next-intl\";\nimport { useEffect, useState } from \"react\";\n// 注意：需要运行 orval 生成 API hooks 后才能使用\nimport { useTestAsrConfigApiTestAsrConfigPost } from \"@/lib/generated/config/config\";\nimport { useSaveConfig } from \"@/lib/query\";\nimport { toastError, toastSuccess } from \"@/lib/toast\";\nimport { SettingsSection } from \"./SettingsSection\";\n\ninterface AudioAsrConfigSectionProps {\n\tconfig: Record<string, unknown> | undefined;\n\tloading?: boolean;\n}\n\n/**\n * 音频识别（ASR）配置区块\n * 参考 LLM 配置样式，支持设置 apiKey / baseUrl / model / sampleRate / format / 语义断句 / 静音阈值 / 心跳\n */\nexport function AudioAsrConfigSection({ config, loading = false }: AudioAsrConfigSectionProps) {\n\tconst t = useTranslations(\"page.settings\");\n\tconst saveConfigMutation = useSaveConfig();\n\tconst testAsrMutation = useTestAsrConfigApiTestAsrConfigPost();\n\n\tconst [apiKey, setApiKey] = useState((config?.audioAsrApiKey as string) || \"\");\n\tconst [baseUrl, setBaseUrl] = useState(\n\t\t(config?.audioAsrBaseUrl as string) || \"wss://dashscope.aliyuncs.com/api-ws/v1/inference/\"\n\t);\n\tconst [model, setModel] = useState((config?.audioAsrModel as string) || \"fun-asr-realtime\");\n\tconst [sampleRate, setSampleRate] = useState((config?.audioAsrSampleRate as number) ?? 16000);\n\tconst [format, setFormat] = useState((config?.audioAsrFormat as string) || \"pcm\");\n\tconst [semanticPunc, setSemanticPunc] = useState(\n\t\t(config?.audioAsrSemanticPunctuationEnabled as boolean) ?? false\n\t);\n\tconst [maxSilence, setMaxSilence] = useState((config?.audioAsrMaxSentenceSilence as number) ?? 1300);\n\tconst [heartbeat, setHeartbeat] = useState((config?.audioAsrHeartbeat as boolean) ?? false);\n\tconst [testMessage, setTestMessage] = useState<{\n\t\ttype: \"success\" | \"error\";\n\t\ttext: string;\n\t} | null>(null);\n\n\tconst isLoading = loading || saveConfigMutation.isPending || testAsrMutation.isPending;\n\n\t// 配置加载后同步本地状态\n\tuseEffect(() => {\n\t\tif (!config) return;\n\t\tif (config.audioAsrApiKey !== undefined) setApiKey((config.audioAsrApiKey as string) || \"\");\n\t\tif (config.audioAsrBaseUrl !== undefined)\n\t\t\tsetBaseUrl((config.audioAsrBaseUrl as string) || \"wss://dashscope.aliyuncs.com/api-ws/v1/inference/\");\n\t\tif (config.audioAsrModel !== undefined) setModel((config.audioAsrModel as string) || \"fun-asr-realtime\");\n\t\tif (config.audioAsrSampleRate !== undefined)\n\t\t\tsetSampleRate((config.audioAsrSampleRate as number) ?? 16000);\n\t\tif (config.audioAsrFormat !== undefined) setFormat((config.audioAsrFormat as string) || \"pcm\");\n\t\tif (config.audioAsrSemanticPunctuationEnabled !== undefined)\n\t\t\tsetSemanticPunc((config.audioAsrSemanticPunctuationEnabled as boolean) ?? false);\n\t\tif (config.audioAsrMaxSentenceSilence !== undefined)\n\t\t\tsetMaxSilence((config.audioAsrMaxSentenceSilence as number) ?? 1300);\n\t\tif (config.audioAsrHeartbeat !== undefined) setHeartbeat((config.audioAsrHeartbeat as boolean) ?? false);\n\t}, [config]);\n\n\tconst handleSave = async () => {\n\t\ttry {\n\t\t\tawait saveConfigMutation.mutateAsync({\n\t\t\t\tdata: {\n\t\t\t\t\taudioAsrApiKey: apiKey.trim(),\n\t\t\t\t\taudioAsrBaseUrl: baseUrl.trim(),\n\t\t\t\t\taudioAsrModel: model.trim(),\n\t\t\t\t\taudioAsrSampleRate: Number(sampleRate) || 16000,\n\t\t\t\t\taudioAsrFormat: format.trim() || \"pcm\",\n\t\t\t\t\taudioAsrSemanticPunctuationEnabled: semanticPunc,\n\t\t\t\t\taudioAsrMaxSentenceSilence: Number(maxSilence) || 1300,\n\t\t\t\t\taudioAsrHeartbeat: heartbeat,\n\t\t\t\t},\n\t\t\t});\n\t\t\ttoastSuccess(t(\"saveSuccess\"));\n\t\t} catch (error) {\n\t\t\tconst msg = error instanceof Error ? error.message : String(error);\n\t\t\ttoastError(t(\"saveFailed\", { error: msg }));\n\t\t}\n\t};\n\n\t// 测试 ASR 连接\n\tconst handleTestAsr = async () => {\n\t\tconst currentApiKey = apiKey.trim();\n\t\tconst currentBaseUrl = baseUrl.trim();\n\t\tconst currentModel = model.trim();\n\n\t\tif (!currentApiKey || !currentBaseUrl) {\n\t\t\tsetTestMessage({\n\t\t\t\ttype: \"error\",\n\t\t\t\ttext: t(\"apiKeyRequired\") || \"API Key 和 Base URL 不能为空\",\n\t\t\t});\n\t\t\treturn;\n\t\t}\n\n\t\tsetTestMessage(null);\n\t\ttry {\n\t\t\tconst response = await testAsrMutation.mutateAsync({\n\t\t\t\tdata: {\n\t\t\t\t\taudioAsrApiKey: currentApiKey,\n\t\t\t\t\taudioAsrBaseUrl: currentBaseUrl,\n\t\t\t\t\taudioAsrModel: currentModel,\n\t\t\t\t\taudioAsrSampleRate: Number(sampleRate) || 16000,\n\t\t\t\t\taudioAsrFormat: format.trim() || \"pcm\",\n\t\t\t\t\taudioAsrSemanticPunctuationEnabled: semanticPunc,\n\t\t\t\t\taudioAsrMaxSentenceSilence: Number(maxSilence) || 1300,\n\t\t\t\t\taudioAsrHeartbeat: heartbeat,\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tconst result = response as { success?: boolean; error?: string };\n\t\t\tif (result.success) {\n\t\t\t\tsetTestMessage({\n\t\t\t\t\ttype: \"success\",\n\t\t\t\t\ttext: t(\"testSuccess\") || \"配置验证成功\",\n\t\t\t\t});\n\t\t\t} else {\n\t\t\t\tsetTestMessage({\n\t\t\t\t\ttype: \"error\",\n\t\t\t\t\ttext: `${t(\"testFailed\") || \"测试失败\"}: ${result.error || \"Unknown error\"}`,\n\t\t\t\t});\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tconst errorMsg = error instanceof Error ? error.message : \"Network error\";\n\t\t\tsetTestMessage({\n\t\t\t\ttype: \"error\",\n\t\t\t\ttext: `${t(\"testFailed\") || \"测试失败\"}: ${errorMsg}`,\n\t\t\t});\n\t\t}\n\t};\n\n\treturn (\n\t\t<SettingsSection title={t(\"audioAsrConfig\")}>\n\t\t\t<div className=\"space-y-3\">\n\t\t\t\t{/* 消息提示 */}\n\t\t\t\t{testMessage && (\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName={`rounded-lg px-3 py-2 text-sm font-medium ${\n\t\t\t\t\t\t\ttestMessage.type === \"success\"\n\t\t\t\t\t\t\t\t? \"bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400\"\n\t\t\t\t\t\t\t\t: \"bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400\"\n\t\t\t\t\t\t}`}\n\t\t\t\t\t>\n\t\t\t\t\t\t{testMessage.text}\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\n\t\t\t\t{/* API Key 和 Base URL 单列布局 */}\n\t\t\t\t<div className=\"space-y-3\">\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<label htmlFor=\"asr-api-key\" className=\"mb-1 block text-sm font-medium text-foreground\">\n\t\t\t\t\t\t\tAPI Key <span className=\"text-red-500\">*</span>\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\tid=\"asr-api-key\"\n\t\t\t\t\t\t\ttype=\"password\"\n\t\t\t\t\t\t\tclassName=\"w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50\"\n\t\t\t\t\t\t\tplaceholder=\"sk-...\"\n\t\t\t\t\t\t\tvalue={apiKey}\n\t\t\t\t\t\t\tonChange={(e) => setApiKey(e.target.value)}\n\t\t\t\t\t\t\tonBlur={handleSave}\n\t\t\t\t\t\t\tdisabled={isLoading}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<p className=\"mt-1 text-xs text-muted-foreground\">\n\t\t\t\t\t\t\t{t(\"apiKeyHint\")}{\" \"}\n\t\t\t\t\t\t\t<a\n\t\t\t\t\t\t\t\thref=\"https://bailian.console.aliyun.com/?tab=api#/api\"\n\t\t\t\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t\t\t\t\trel=\"noopener noreferrer\"\n\t\t\t\t\t\t\t\tclassName=\"text-primary hover:underline\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{t(\"apiKeyLink\")}\n\t\t\t\t\t\t\t</a>\n\t\t\t\t\t\t</p>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<label htmlFor=\"asr-base-url\" className=\"mb-1 block text-sm font-medium text-foreground\">\n\t\t\t\t\t\t\tBase URL <span className=\"text-red-500\">*</span>\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\tid=\"asr-base-url\"\n\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\tclassName=\"w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50\"\n\t\t\t\t\t\t\tplaceholder=\"wss://dashscope.aliyuncs.com/api-ws/v1/inference/\"\n\t\t\t\t\t\t\tvalue={baseUrl}\n\t\t\t\t\t\t\tonChange={(e) => setBaseUrl(e.target.value)}\n\t\t\t\t\t\t\tonBlur={handleSave}\n\t\t\t\t\t\t\tdisabled={isLoading}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\n\t\t\t\t{/* 其他字段两栏布局 */}\n\t\t\t\t<div className=\"grid grid-cols-1 md:grid-cols-2 gap-3\">\n\n\t\t\t\t<div>\n\t\t\t\t\t<label htmlFor=\"asr-model\" className=\"mb-1 block text-sm font-medium text-foreground\">模型</label>\n\t\t\t\t\t<input\n\t\t\t\t\t\tid=\"asr-model\"\n\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\tclassName=\"w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50\"\n\t\t\t\t\t\tplaceholder=\"fun-asr-realtime\"\n\t\t\t\t\t\tvalue={model}\n\t\t\t\t\t\tonChange={(e) => setModel(e.target.value)}\n\t\t\t\t\t\tonBlur={handleSave}\n\t\t\t\t\t\tdisabled={isLoading}\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\n\t\t\t\t<div>\n\t\t\t\t\t<label htmlFor=\"asr-sample-rate\" className=\"mb-1 block text-sm font-medium text-foreground\">采样率 (Hz)</label>\n\t\t\t\t\t<input\n\t\t\t\t\t\tid=\"asr-sample-rate\"\n\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\tmin={8000}\n\t\t\t\t\t\tstep={1000}\n\t\t\t\t\t\tclassName=\"w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50\"\n\t\t\t\t\t\tvalue={sampleRate}\n\t\t\t\t\t\tonChange={(e) => setSampleRate(parseInt(e.target.value, 10) || 16000)}\n\t\t\t\t\t\tonBlur={handleSave}\n\t\t\t\t\t\tdisabled={isLoading}\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\n\t\t\t\t<div>\n\t\t\t\t\t<label htmlFor=\"asr-format\" className=\"mb-1 block text-sm font-medium text-foreground\">格式</label>\n\t\t\t\t\t<input\n\t\t\t\t\t\tid=\"asr-format\"\n\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\tclassName=\"w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50\"\n\t\t\t\t\t\tplaceholder=\"pcm\"\n\t\t\t\t\t\tvalue={format}\n\t\t\t\t\t\tonChange={(e) => setFormat(e.target.value)}\n\t\t\t\t\t\tonBlur={handleSave}\n\t\t\t\t\t\tdisabled={isLoading}\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\n\t\t\t\t<div>\n\t\t\t\t\t<label htmlFor=\"asr-max-silence\" className=\"mb-1 block text-sm font-medium text-foreground\">静音阈值 (ms)</label>\n\t\t\t\t\t<input\n\t\t\t\t\t\tid=\"asr-max-silence\"\n\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\tmin={200}\n\t\t\t\t\t\tstep={100}\n\t\t\t\t\t\tclassName=\"w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50\"\n\t\t\t\t\t\tvalue={maxSilence}\n\t\t\t\t\t\tonChange={(e) => setMaxSilence(parseInt(e.target.value, 10) || 1300)}\n\t\t\t\t\t\tonBlur={handleSave}\n\t\t\t\t\t\tdisabled={isLoading}\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\n\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t<input\n\t\t\t\t\t\ttype=\"checkbox\"\n\t\t\t\t\t\tid=\"semantic-punc\"\n\t\t\t\t\t\tchecked={semanticPunc}\n\t\t\t\t\t\tonChange={(e) => {\n\t\t\t\t\t\t\tsetSemanticPunc(e.target.checked);\n\t\t\t\t\t\t\t// 立即保存\n\t\t\t\t\t\t\tsaveConfigMutation.mutate({\n\t\t\t\t\t\t\t\tdata: { audioAsrSemanticPunctuationEnabled: e.target.checked },\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tdisabled={isLoading}\n\t\t\t\t\t\tclassName=\"h-4 w-4 rounded border-input text-primary focus:ring-2 focus:ring-offset-2 focus:ring-primary\"\n\t\t\t\t\t/>\n\t\t\t\t\t<label htmlFor=\"semantic-punc\" className=\"text-sm text-foreground\">\n\t\t\t\t\t\t语义断句（semantic_punctuation）\n\t\t\t\t\t</label>\n\t\t\t\t</div>\n\n\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t<input\n\t\t\t\t\t\ttype=\"checkbox\"\n\t\t\t\t\t\tid=\"asr-heartbeat\"\n\t\t\t\t\t\tchecked={heartbeat}\n\t\t\t\t\t\tonChange={(e) => {\n\t\t\t\t\t\t\tsetHeartbeat(e.target.checked);\n\t\t\t\t\t\t\tsaveConfigMutation.mutate({\n\t\t\t\t\t\t\t\tdata: { audioAsrHeartbeat: e.target.checked },\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tdisabled={isLoading}\n\t\t\t\t\t\tclassName=\"h-4 w-4 rounded border-input text-primary focus:ring-2 focus:ring-offset-2 focus:ring-primary\"\n\t\t\t\t\t/>\n\t\t\t\t\t<label htmlFor=\"asr-heartbeat\" className=\"text-sm text-foreground\">\n\t\t\t\t\t\tWebSocket 心跳（heartbeat）\n\t\t\t\t\t</label>\n\t\t\t\t</div>\n\t\t\t\t</div>\n\n\t\t\t\t{/* 测试按钮 */}\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tonClick={async () => {\n\t\t\t\t\t\tif (document.activeElement instanceof HTMLElement) {\n\t\t\t\t\t\t\tdocument.activeElement.blur();\n\t\t\t\t\t\t}\n\t\t\t\t\t\tawait new Promise((resolve) => setTimeout(resolve, 50));\n\t\t\t\t\t\tawait handleTestAsr();\n\t\t\t\t\t}}\n\t\t\t\t\tdisabled={isLoading || !apiKey.trim() || !baseUrl.trim()}\n\t\t\t\t\tclassName=\"w-full rounded-md border border-input bg-background px-4 py-2 text-sm font-medium ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50\"\n\t\t\t\t>\n\t\t\t\t\t{testAsrMutation.isPending\n\t\t\t\t\t\t? `${t(\"testConnection\") || \"测试连接\"}...`\n\t\t\t\t\t\t: t(\"testConnection\") || \"测试连接\"}\n\t\t\t\t</button>\n\t\t\t</div>\n\t\t</SettingsSection>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/settings/components/AudioConfigSection.tsx",
    "content": "\"use client\";\n\nimport { useTranslations } from \"next-intl\";\nimport { useEffect, useState } from \"react\";\nimport { useSaveConfig } from \"@/lib/query\";\nimport { toastError, toastSuccess } from \"@/lib/toast\";\nimport { SettingsSection } from \"./SettingsSection\";\nimport { ToggleSwitch } from \"./ToggleSwitch\";\n\ninterface AudioConfigSectionProps {\n\tconfig: Record<string, unknown> | undefined;\n\tloading?: boolean;\n}\n\n/**\n * 音频录制配置区块组件\n * 配置自动启动录音开关\n */\nexport function AudioConfigSection({\n\tconfig,\n\tloading = false,\n}: AudioConfigSectionProps) {\n\tconst t = useTranslations(\"page.settings\");\n\tconst saveConfigMutation = useSaveConfig();\n\n\tconst [is24x7Enabled, setIs24x7Enabled] = useState<boolean>(\n\t\t(config?.audioIs24x7 as boolean | undefined) ?? false,\n\t);\n\n\tconst isLoading = loading || saveConfigMutation.isPending;\n\n\t// 当配置加载完成后，同步本地状态\n\tuseEffect(() => {\n\t\tif (config && config.audioIs24x7 !== undefined) {\n\t\t\tsetIs24x7Enabled((config.audioIs24x7 as boolean) ?? false);\n\t\t}\n\t}, [config]);\n\n\tconst handleToggle24x7 = async (newValue: boolean) => {\n\t\tsetIs24x7Enabled(newValue);\n\t\ttry {\n\t\t\tawait saveConfigMutation.mutateAsync({\n\t\t\t\tdata: {\n\t\t\t\t\taudioIs24x7: newValue,\n\t\t\t\t},\n\t\t\t});\n\t\t\ttoastSuccess(t(\"saveSuccess\"));\n\t\t} catch (error) {\n\t\t\tsetIs24x7Enabled(!newValue);\n\t\t\tconsole.error(\"保存自动启动录音配置失败:\", error);\n\t\t\tconst errorMsg = error instanceof Error ? error.message : String(error);\n\t\t\ttoastError(t(\"saveFailed\", { error: errorMsg }));\n\t\t}\n\t};\n\n\treturn (\n\t\t<SettingsSection title={t(\"audioSettings\")}>\n\t\t\t<div className=\"space-y-4\">\n\t\t\t\t{/* 自动启动录音开关 */}\n\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t<div className=\"flex-1\">\n\t\t\t\t\t\t<p className=\"text-sm font-medium text-foreground\">\n\t\t\t\t\t\t\t{t(\"enable24x7Recording\")}\n\t\t\t\t\t\t</p>\n\t\t\t\t\t\t<p className=\"mt-0.5 text-xs text-muted-foreground\">\n\t\t\t\t\t\t\t{t(\"enable24x7RecordingDesc\")}\n\t\t\t\t\t\t</p>\n\t\t\t\t\t</div>\n\t\t\t\t\t<ToggleSwitch\n\t\t\t\t\t\tenabled={is24x7Enabled}\n\t\t\t\t\t\tdisabled={isLoading}\n\t\t\t\t\t\tonToggle={handleToggle24x7}\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</SettingsSection>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/settings/components/AutoTodoDetectionSection.tsx",
    "content": "\"use client\";\n\nimport { X } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { useSaveConfig } from \"@/lib/query\";\nimport { useNotificationStore } from \"@/lib/store/notification-store\";\nimport { toastError, toastSuccess } from \"@/lib/toast\";\nimport { SettingsSection } from \"./SettingsSection\";\nimport { ToggleSwitch } from \"./ToggleSwitch\";\n\ninterface AutoTodoDetectionSectionProps {\n\tconfig: Record<string, unknown> | undefined;\n\tloading?: boolean;\n}\n\n/**\n * 自动待办检测设置区块组件\n */\nexport function AutoTodoDetectionSection({\n\tconfig,\n\tloading = false,\n}: AutoTodoDetectionSectionProps) {\n\tconst tSettings = useTranslations(\"page.settings\");\n\tconst saveConfigMutation = useSaveConfig();\n\n\t// 状态管理\n\tconst [autoTodoDetectionEnabled, setAutoTodoDetectionEnabled] =\n\t\tuseState(false);\n\tconst [whitelistApps, setWhitelistApps] = useState<string[]>([]);\n\tconst [whitelistInput, setWhitelistInput] = useState(\"\");\n\n\t// 用于跟踪最后一次保存的时间戳，防止保存后立即被 refetch 覆盖\n\tconst lastSaveTimeRef = useRef<number>(0);\n\n\t// 当配置加载完成后，同步本地状态\n\t// 但如果刚刚保存过配置（500ms 内），则跳过同步，避免被旧值覆盖\n\tuseEffect(() => {\n\t\tif (config) {\n\t\t\tconst now = Date.now();\n\t\t\t// 如果刚刚保存过配置（500ms 内），跳过同步\n\t\t\tif (now - lastSaveTimeRef.current < 500) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tsetAutoTodoDetectionEnabled(\n\t\t\t\t(config.jobsAutoTodoDetectionEnabled as boolean) ?? false,\n\t\t\t);\n\t\t\t// 同步白名单配置（去重处理，避免 React key 冲突）\n\t\t\tconst apps = config.jobsAutoTodoDetectionParamsWhitelistApps;\n\t\t\tif (Array.isArray(apps)) {\n\t\t\t\t// 使用 Set 去重\n\t\t\t\tsetWhitelistApps([...new Set(apps as string[])]);\n\t\t\t} else if (apps && typeof apps === \"string\") {\n\t\t\t\tconst appsStr = apps as string;\n\t\t\t\tconst parsedApps = appsStr\n\t\t\t\t\t.split(\",\")\n\t\t\t\t\t.map((s: string) => s.trim())\n\t\t\t\t\t.filter((s: string) => s);\n\t\t\t\t// 使用 Set 去重\n\t\t\t\tsetWhitelistApps([...new Set(parsedApps)]);\n\t\t\t}\n\t\t}\n\t}, [config]);\n\n\tconst isLoading = loading || saveConfigMutation.isPending;\n\n\t// 自动待办检测处理\n\t// 与 Todo 专用录制任务联动：同时开启/关闭两个功能\n\tconst handleToggleAutoTodoDetection = async (enabled: boolean) => {\n\t\ttry {\n\t\t\t// 记录保存时间戳\n\t\t\tlastSaveTimeRef.current = Date.now();\n\n\t\t\t// 同时保存 autoTodoDetection 和 todoRecorder 的状态\n\t\t\t// 后端 ConfigService 会自动处理联动逻辑\n\t\t\tawait saveConfigMutation.mutateAsync({\n\t\t\t\tdata: {\n\t\t\t\t\tjobsAutoTodoDetectionEnabled: enabled,\n\t\t\t\t\tjobsTodoRecorderEnabled: enabled, // 联动 Todo 专用录制\n\t\t\t\t},\n\t\t\t});\n\t\t\tsetAutoTodoDetectionEnabled(enabled);\n\n\t\t\t// 同步更新轮询端点状态\n\t\t\tconst store = useNotificationStore.getState();\n\t\t\tconst existingEndpoint = store.getEndpoint(\"draft-todos\");\n\t\t\tif (existingEndpoint) {\n\t\t\t\tstore.registerEndpoint({\n\t\t\t\t\t...existingEndpoint,\n\t\t\t\t\tenabled: enabled,\n\t\t\t\t});\n\t\t\t}\n\n\t\t\ttoastSuccess(\n\t\t\t\tenabled\n\t\t\t\t\t? tSettings(\"autoTodoDetectionEnabled\")\n\t\t\t\t\t: tSettings(\"autoTodoDetectionDisabled\"),\n\t\t\t);\n\t\t} catch (error) {\n\t\t\tconsole.error(\"保存配置失败:\", error);\n\t\t\tconst errorMsg = error instanceof Error ? error.message : String(error);\n\t\t\ttoastError(tSettings(\"saveFailed\", { error: errorMsg }));\n\t\t\t// 失败时清除保存时间戳，允许后续同步\n\t\t\tlastSaveTimeRef.current = 0;\n\t\t\tsetAutoTodoDetectionEnabled(!enabled);\n\t\t}\n\t};\n\n\t// 白名单处理函数\n\tconst handleAddWhitelistApp = async (app: string) => {\n\t\tconst trimmedApp = app.trim();\n\t\tif (trimmedApp && !whitelistApps.includes(trimmedApp)) {\n\t\t\tconst newApps = [...whitelistApps, trimmedApp];\n\t\t\tsetWhitelistApps(newApps);\n\t\t\tsetWhitelistInput(\"\");\n\t\t\ttry {\n\t\t\t\tlastSaveTimeRef.current = Date.now();\n\t\t\t\tawait saveConfigMutation.mutateAsync({\n\t\t\t\t\tdata: {\n\t\t\t\t\t\tjobsAutoTodoDetectionParamsWhitelistApps: newApps,\n\t\t\t\t\t},\n\t\t\t\t});\n\t\t\t} catch (error) {\n\t\t\t\tsetWhitelistApps(whitelistApps);\n\t\t\t\tconsole.error(\"保存白名单失败:\", error);\n\t\t\t\tconst errorMsg = error instanceof Error ? error.message : String(error);\n\t\t\t\ttoastError(tSettings(\"saveFailed\", { error: errorMsg }));\n\t\t\t\tlastSaveTimeRef.current = 0;\n\t\t\t}\n\t\t}\n\t};\n\n\tconst handleRemoveWhitelistApp = async (app: string) => {\n\t\tconst newApps = whitelistApps.filter((a) => a !== app);\n\t\tconst oldApps = whitelistApps;\n\t\tsetWhitelistApps(newApps);\n\t\ttry {\n\t\t\tlastSaveTimeRef.current = Date.now();\n\t\t\tawait saveConfigMutation.mutateAsync({\n\t\t\t\tdata: {\n\t\t\t\t\tjobsAutoTodoDetectionParamsWhitelistApps: newApps,\n\t\t\t\t},\n\t\t\t});\n\t\t} catch (error) {\n\t\t\tsetWhitelistApps(oldApps);\n\t\t\tconsole.error(\"保存白名单失败:\", error);\n\t\t\tconst errorMsg = error instanceof Error ? error.message : String(error);\n\t\t\ttoastError(tSettings(\"saveFailed\", { error: errorMsg }));\n\t\t\tlastSaveTimeRef.current = 0;\n\t\t}\n\t};\n\n\tconst handleWhitelistKeyDown = async (\n\t\te: React.KeyboardEvent<HTMLInputElement>,\n\t) => {\n\t\tif (e.key === \"Enter\" && whitelistInput.trim()) {\n\t\t\te.preventDefault();\n\t\t\tawait handleAddWhitelistApp(whitelistInput);\n\t\t} else if (\n\t\t\te.key === \"Backspace\" &&\n\t\t\t!whitelistInput &&\n\t\t\twhitelistApps.length > 0\n\t\t) {\n\t\t\tconst lastApp = whitelistApps[whitelistApps.length - 1];\n\t\t\tawait handleRemoveWhitelistApp(lastApp);\n\t\t}\n\t};\n\n\treturn (\n\t\t<SettingsSection\n\t\t\ttitle={tSettings(\"autoTodoDetectionTitle\")}\n\t\t\tdescription={tSettings(\"autoTodoDetectionDescription\")}\n\t\t>\n\t\t\t<div className=\"space-y-4\">\n\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t<div className=\"flex-1\">\n\t\t\t\t\t\t<label\n\t\t\t\t\t\t\thtmlFor=\"auto-todo-detection-toggle\"\n\t\t\t\t\t\t\tclassName=\"text-sm font-medium text-foreground\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{tSettings(\"autoTodoDetectionLabel\")}\n\t\t\t\t\t\t</label>\n\t\t\t\t\t</div>\n\t\t\t\t\t<ToggleSwitch\n\t\t\t\t\t\tid=\"auto-todo-detection-toggle\"\n\t\t\t\t\t\tenabled={autoTodoDetectionEnabled}\n\t\t\t\t\t\tdisabled={isLoading}\n\t\t\t\t\t\tonToggle={handleToggleAutoTodoDetection}\n\t\t\t\t\t\tariaLabel={tSettings(\"autoTodoDetectionLabel\")}\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\t\t\t\t{autoTodoDetectionEnabled && (\n\t\t\t\t\t<>\n\t\t\t\t\t\t<div className=\"rounded-md bg-primary/10 p-3\">\n\t\t\t\t\t\t\t<p className=\"text-xs text-primary\">\n\t\t\t\t\t\t\t\t{tSettings(\"autoTodoDetectionHint\")}\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t{/* 应用白名单 */}\n\t\t\t\t\t\t<div className=\"pl-4 border-l-2 border-border\">\n\t\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t\thtmlFor=\"whitelist-input\"\n\t\t\t\t\t\t\t\tclassName=\"mb-1 block text-sm font-medium text-foreground\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{tSettings(\"whitelistApps\")}\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<div className=\"min-h-[38px] flex flex-wrap gap-1.5 items-center rounded-md border border-input bg-background px-2 py-1.5 focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 transition-all\">\n\t\t\t\t\t\t\t\t{whitelistApps.map((app) => (\n\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\tkey={app}\n\t\t\t\t\t\t\t\t\t\tclassName=\"inline-flex items-center gap-1 px-2 py-0.5 text-sm bg-primary/10 text-primary rounded-md border border-primary/20\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{app}\n\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\t\tonClick={() => handleRemoveWhitelistApp(app)}\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"hover:bg-primary/20 rounded-full p-0.5 transition-colors\"\n\t\t\t\t\t\t\t\t\t\t\taria-label={`删除 ${app}`}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<X className=\"h-3 w-3\" />\n\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\tid=\"whitelist-input\"\n\t\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\t\tclassName=\"flex-1 min-w-[120px] outline-none bg-transparent text-sm placeholder:text-muted-foreground px-1\"\n\t\t\t\t\t\t\t\t\tplaceholder={tSettings(\"whitelistAppsPlaceholder\")}\n\t\t\t\t\t\t\t\t\tvalue={whitelistInput}\n\t\t\t\t\t\t\t\t\tonChange={(e) => setWhitelistInput(e.target.value)}\n\t\t\t\t\t\t\t\t\tonKeyDown={handleWhitelistKeyDown}\n\t\t\t\t\t\t\t\t\tdisabled={isLoading}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<p className=\"mt-1 text-xs text-muted-foreground\">\n\t\t\t\t\t\t\t\t{tSettings(\"whitelistAppsDesc\")}\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t</SettingsSection>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/settings/components/AutomationTasksSection.tsx",
    "content": "\"use client\";\n\nimport {\n\tCheck,\n\tClock,\n\tPlay,\n\tPlus,\n\tPower,\n\tTrash2,\n} from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport { useMemo, useState } from \"react\";\nimport {\n\tuseAutomationTasks,\n\tuseCreateAutomationTask,\n\tuseDeleteAutomationTask,\n\tuseRunAutomationTask,\n\tuseToggleAutomationTask,\n} from \"@/lib/query\";\nimport { toastError, toastSuccess } from \"@/lib/toast\";\nimport type { AutomationSchedule, AutomationScheduleType } from \"@/lib/types\";\nimport { cn } from \"@/lib/utils\";\nimport { SettingsSection } from \"./SettingsSection\";\n\ninterface AutomationTasksSectionProps {\n\tloading?: boolean;\n}\n\nexport function AutomationTasksSection({\n\tloading = false,\n}: AutomationTasksSectionProps) {\n\tconst t = useTranslations(\"automationTasks\");\n\tconst { data, isLoading } = useAutomationTasks();\n\tconst createMutation = useCreateAutomationTask();\n\tconst deleteMutation = useDeleteAutomationTask();\n\tconst runMutation = useRunAutomationTask();\n\tconst toggleMutation = useToggleAutomationTask();\n\n\tconst [name, setName] = useState(\"\");\n\tconst [description, setDescription] = useState(\"\");\n\tconst [url, setUrl] = useState(\"\");\n\tconst [method, setMethod] = useState(\"GET\");\n\tconst [scheduleType, setScheduleType] =\n\t\tuseState<AutomationScheduleType>(\"interval\");\n\tconst [intervalMinutes, setIntervalMinutes] = useState(30);\n\tconst [cronExpr, setCronExpr] = useState(\"0 9 * * *\");\n\tconst [runAt, setRunAt] = useState(\"\");\n\tconst [enabled, setEnabled] = useState(true);\n\n\tconst tasks = data?.tasks ?? [];\n\tconst busy =\n\t\tloading ||\n\t\tisLoading ||\n\t\tcreateMutation.isPending ||\n\t\tdeleteMutation.isPending ||\n\t\trunMutation.isPending ||\n\t\ttoggleMutation.isPending;\n\n\tconst scheduleSummary = (task: {\n\t\tschedule: {\n\t\t\ttype: AutomationScheduleType;\n\t\t\tintervalSeconds?: number;\n\t\t\tcron?: string;\n\t\t\trunAt?: string;\n\t\t};\n\t}) => {\n\t\tconst schedule = task.schedule;\n\t\tif (schedule.type === \"interval\") {\n\t\t\tconst minutes = Math.max(\n\t\t\t\t1,\n\t\t\t\tMath.round((schedule.intervalSeconds ?? 60) / 60),\n\t\t\t);\n\t\t\treturn t(\"scheduleSummary.interval\", { minutes });\n\t\t}\n\t\tif (schedule.type === \"cron\") {\n\t\t\treturn t(\"scheduleSummary.cron\", { cron: schedule.cron ?? \"-\" });\n\t\t}\n\t\tif (schedule.type === \"once\") {\n\t\t\tconst label = schedule.runAt\n\t\t\t\t? new Date(schedule.runAt).toLocaleString(t(\"dateLocale\"))\n\t\t\t\t: \"-\";\n\t\t\treturn t(\"scheduleSummary.once\", { time: label });\n\t\t}\n\t\treturn \"-\";\n\t};\n\n\tconst lastRunLabel = (value?: string) => {\n\t\tif (!value) return t(\"status.never\");\n\t\treturn new Date(value).toLocaleString(t(\"dateLocale\"));\n\t};\n\n\tconst schedulePayload = useMemo<AutomationSchedule>(() => {\n\t\tif (scheduleType === \"interval\") {\n\t\t\treturn {\n\t\t\t\ttype: \"interval\",\n\t\t\t\tintervalSeconds: intervalMinutes * 60,\n\t\t\t};\n\t\t}\n\t\tif (scheduleType === \"cron\") {\n\t\t\treturn { type: \"cron\", cron: cronExpr };\n\t\t}\n\t\tconst runAtIso = runAt ? new Date(runAt).toISOString() : undefined;\n\t\treturn { type: \"once\", runAt: runAtIso };\n\t}, [cronExpr, intervalMinutes, runAt, scheduleType]);\n\n\tconst handleCreate = async () => {\n\t\tif (!name.trim()) {\n\t\t\ttoastError(t(\"errors.nameRequired\"));\n\t\t\treturn;\n\t\t}\n\t\tif (!url.trim()) {\n\t\t\ttoastError(t(\"errors.urlRequired\"));\n\t\t\treturn;\n\t\t}\n\t\tif (scheduleType === \"interval\" && intervalMinutes <= 0) {\n\t\t\ttoastError(t(\"errors.intervalRequired\"));\n\t\t\treturn;\n\t\t}\n\t\tif (scheduleType === \"cron\" && !cronExpr.trim()) {\n\t\t\ttoastError(t(\"errors.cronRequired\"));\n\t\t\treturn;\n\t\t}\n\t\tif (scheduleType === \"once\" && !runAt) {\n\t\t\ttoastError(t(\"errors.runAtRequired\"));\n\t\t\treturn;\n\t\t}\n\t\ttry {\n\t\t\tawait createMutation.mutateAsync({\n\t\t\t\tname: name.trim(),\n\t\t\t\tdescription: description.trim() || undefined,\n\t\t\t\tenabled,\n\t\t\t\tschedule: schedulePayload,\n\t\t\t\taction: {\n\t\t\t\t\ttype: \"web_fetch\",\n\t\t\t\t\tpayload: {\n\t\t\t\t\t\turl: url.trim(),\n\t\t\t\t\t\tmethod,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t});\n\t\t\ttoastSuccess(t(\"messages.created\"));\n\t\t\tsetName(\"\");\n\t\t\tsetDescription(\"\");\n\t\t\tsetUrl(\"\");\n\t\t\tsetMethod(\"GET\");\n\t\t\tsetScheduleType(\"interval\");\n\t\t\tsetIntervalMinutes(30);\n\t\t\tsetCronExpr(\"0 9 * * *\");\n\t\t\tsetRunAt(\"\");\n\t\t\tsetEnabled(true);\n\t\t} catch (error) {\n\t\t\tconst msg = error instanceof Error ? error.message : String(error);\n\t\t\ttoastError(t(\"errors.createFailed\", { error: msg }));\n\t\t}\n\t};\n\n\tconst handleRun = async (id: number) => {\n\t\ttry {\n\t\t\tawait runMutation.mutateAsync(id);\n\t\t\ttoastSuccess(t(\"messages.ran\"));\n\t\t} catch (error) {\n\t\t\tconst msg = error instanceof Error ? error.message : String(error);\n\t\t\ttoastError(t(\"errors.runFailed\", { error: msg }));\n\t\t}\n\t};\n\n\tconst handleToggle = async (id: number, nextEnabled: boolean) => {\n\t\ttry {\n\t\t\tawait toggleMutation.mutateAsync({ id, enabled: nextEnabled });\n\t\t\ttoastSuccess(\n\t\t\t\tnextEnabled ? t(\"messages.enabled\") : t(\"messages.disabled\"),\n\t\t\t);\n\t\t} catch (error) {\n\t\t\tconst msg = error instanceof Error ? error.message : String(error);\n\t\t\ttoastError(t(\"errors.updateFailed\", { error: msg }));\n\t\t}\n\t};\n\n\tconst handleDelete = async (id: number) => {\n\t\tif (!window.confirm(t(\"confirmDelete\"))) {\n\t\t\treturn;\n\t\t}\n\t\ttry {\n\t\t\tawait deleteMutation.mutateAsync(id);\n\t\t\ttoastSuccess(t(\"messages.deleted\"));\n\t\t} catch (error) {\n\t\t\tconst msg = error instanceof Error ? error.message : String(error);\n\t\t\ttoastError(t(\"errors.deleteFailed\", { error: msg }));\n\t\t}\n\t};\n\n\treturn (\n\t\t<SettingsSection title={t(\"title\")} description={t(\"description\")}>\n\t\t\t<div className=\"rounded-lg border border-border/70 bg-muted/30 p-4\">\n\t\t\t\t<div className=\"mb-3 flex items-center justify-between\">\n\t\t\t\t\t<p className=\"text-sm font-medium text-foreground\">\n\t\t\t\t\t\t{t(\"createTitle\")}\n\t\t\t\t\t</p>\n\t\t\t\t\t<span className=\"text-xs text-muted-foreground\">\n\t\t\t\t\t\t{t(\"createHint\")}\n\t\t\t\t\t</span>\n\t\t\t\t</div>\n\t\t\t\t<div className=\"grid gap-3 md:grid-cols-2\">\n\t\t\t\t\t<div className=\"space-y-2\">\n\t\t\t\t\t\t<label\n\t\t\t\t\t\t\thtmlFor=\"automation-task-name\"\n\t\t\t\t\t\t\tclassName=\"text-xs text-muted-foreground\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{t(\"labels.name\")}\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\tid=\"automation-task-name\"\n\t\t\t\t\t\t\tvalue={name}\n\t\t\t\t\t\t\tonChange={(event) => setName(event.target.value)}\n\t\t\t\t\t\t\tplaceholder={t(\"placeholders.name\")}\n\t\t\t\t\t\t\tclassName=\"w-full rounded-md border border-border bg-background px-3 py-2 text-sm\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"space-y-2\">\n\t\t\t\t\t\t<label\n\t\t\t\t\t\t\thtmlFor=\"automation-task-url\"\n\t\t\t\t\t\t\tclassName=\"text-xs text-muted-foreground\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{t(\"labels.url\")}\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\tid=\"automation-task-url\"\n\t\t\t\t\t\t\tvalue={url}\n\t\t\t\t\t\t\tonChange={(event) => setUrl(event.target.value)}\n\t\t\t\t\t\t\tplaceholder=\"https://\"\n\t\t\t\t\t\t\tclassName=\"w-full rounded-md border border-border bg-background px-3 py-2 text-sm\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"space-y-2 md:col-span-2\">\n\t\t\t\t\t\t<label\n\t\t\t\t\t\t\thtmlFor=\"automation-task-description\"\n\t\t\t\t\t\t\tclassName=\"text-xs text-muted-foreground\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{t(\"labels.description\")}\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\tid=\"automation-task-description\"\n\t\t\t\t\t\t\tvalue={description}\n\t\t\t\t\t\t\tonChange={(event) => setDescription(event.target.value)}\n\t\t\t\t\t\t\tplaceholder={t(\"placeholders.description\")}\n\t\t\t\t\t\t\tclassName=\"w-full rounded-md border border-border bg-background px-3 py-2 text-sm\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"space-y-2\">\n\t\t\t\t\t\t<label\n\t\t\t\t\t\t\thtmlFor=\"automation-task-method\"\n\t\t\t\t\t\t\tclassName=\"text-xs text-muted-foreground\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{t(\"labels.method\")}\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<select\n\t\t\t\t\t\t\tid=\"automation-task-method\"\n\t\t\t\t\t\t\tvalue={method}\n\t\t\t\t\t\t\tonChange={(event) => setMethod(event.target.value)}\n\t\t\t\t\t\t\tclassName=\"w-full rounded-md border border-border bg-background px-3 py-2 text-sm\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<option value=\"GET\">GET</option>\n\t\t\t\t\t\t\t<option value=\"POST\">POST</option>\n\t\t\t\t\t\t</select>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"space-y-2\">\n\t\t\t\t\t\t<label\n\t\t\t\t\t\t\thtmlFor=\"automation-task-enabled\"\n\t\t\t\t\t\t\tclassName=\"text-xs text-muted-foreground\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{t(\"labels.enabled\")}\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tid=\"automation-task-enabled\"\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tonClick={() => setEnabled((prev) => !prev)}\n\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\"inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm\",\n\t\t\t\t\t\t\t\tenabled\n\t\t\t\t\t\t\t\t\t? \"border-primary/40 bg-primary/10 text-primary\"\n\t\t\t\t\t\t\t\t\t: \"border-border text-muted-foreground\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<Power className=\"h-4 w-4\" />\n\t\t\t\t\t\t\t{enabled ? t(\"labels.enabledOn\") : t(\"labels.enabledOff\")}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"space-y-2\">\n\t\t\t\t\t\t<label\n\t\t\t\t\t\t\thtmlFor=\"automation-task-schedule-type\"\n\t\t\t\t\t\t\tclassName=\"text-xs text-muted-foreground\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{t(\"labels.scheduleType\")}\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<select\n\t\t\t\t\t\t\tid=\"automation-task-schedule-type\"\n\t\t\t\t\t\t\tvalue={scheduleType}\n\t\t\t\t\t\t\tonChange={(event) =>\n\t\t\t\t\t\t\t\tsetScheduleType(event.target.value as AutomationScheduleType)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tclassName=\"w-full rounded-md border border-border bg-background px-3 py-2 text-sm\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<option value=\"interval\">{t(\"scheduleType.interval\")}</option>\n\t\t\t\t\t\t\t<option value=\"cron\">{t(\"scheduleType.cron\")}</option>\n\t\t\t\t\t\t\t<option value=\"once\">{t(\"scheduleType.once\")}</option>\n\t\t\t\t\t\t</select>\n\t\t\t\t\t</div>\n\t\t\t\t\t{scheduleType === \"interval\" && (\n\t\t\t\t\t\t<div className=\"space-y-2\">\n\t\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t\thtmlFor=\"automation-task-interval\"\n\t\t\t\t\t\t\t\tclassName=\"text-xs text-muted-foreground\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{t(\"labels.intervalMinutes\")}\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\tid=\"automation-task-interval\"\n\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\tmin={1}\n\t\t\t\t\t\t\t\tvalue={intervalMinutes}\n\t\t\t\t\t\t\t\tonChange={(event) =>\n\t\t\t\t\t\t\t\t\tsetIntervalMinutes(\n\t\t\t\t\t\t\t\t\t\tMath.max(1, Number.parseInt(event.target.value, 10) || 1),\n\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tclassName=\"w-full rounded-md border border-border bg-background px-3 py-2 text-sm\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\t\t\t\t\t{scheduleType === \"cron\" && (\n\t\t\t\t\t\t<div className=\"space-y-2\">\n\t\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t\thtmlFor=\"automation-task-cron\"\n\t\t\t\t\t\t\t\tclassName=\"text-xs text-muted-foreground\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{t(\"labels.cron\")}\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\tid=\"automation-task-cron\"\n\t\t\t\t\t\t\t\tvalue={cronExpr}\n\t\t\t\t\t\t\t\tonChange={(event) => setCronExpr(event.target.value)}\n\t\t\t\t\t\t\t\tplaceholder=\"0 9 * * *\"\n\t\t\t\t\t\t\t\tclassName=\"w-full rounded-md border border-border bg-background px-3 py-2 text-sm\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\t\t\t\t\t{scheduleType === \"once\" && (\n\t\t\t\t\t\t<div className=\"space-y-2\">\n\t\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t\thtmlFor=\"automation-task-run-at\"\n\t\t\t\t\t\t\t\tclassName=\"text-xs text-muted-foreground\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{t(\"labels.runAt\")}\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\tid=\"automation-task-run-at\"\n\t\t\t\t\t\t\t\ttype=\"datetime-local\"\n\t\t\t\t\t\t\t\tvalue={runAt}\n\t\t\t\t\t\t\t\tonChange={(event) => setRunAt(event.target.value)}\n\t\t\t\t\t\t\t\tclassName=\"w-full rounded-md border border-border bg-background px-3 py-2 text-sm\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t\t<div className=\"mt-4 flex items-center gap-2\">\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tonClick={handleCreate}\n\t\t\t\t\t\tdisabled={busy}\n\t\t\t\t\t\tclassName=\"inline-flex items-center gap-2 rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground disabled:opacity-50\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<Plus className=\"h-4 w-4\" />\n\t\t\t\t\t\t{t(\"actions.create\")}\n\t\t\t\t\t</button>\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t<div className=\"mt-4 space-y-2\">\n\t\t\t\t{tasks.length === 0 && !isLoading && (\n\t\t\t\t\t<p className=\"text-sm text-muted-foreground\">{t(\"empty\")}</p>\n\t\t\t\t)}\n\n\t\t\t\t{tasks.map((task) => (\n\t\t\t\t\t<div\n\t\t\t\t\t\tkey={task.id}\n\t\t\t\t\t\tclassName=\"rounded-lg border border-border bg-background/70 px-3 py-3\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<div className=\"flex flex-wrap items-center justify-between gap-2\">\n\t\t\t\t\t\t\t<div className=\"min-w-0\">\n\t\t\t\t\t\t\t\t<p className=\"text-sm font-medium text-foreground\">\n\t\t\t\t\t\t\t\t\t{task.name}\n\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t{task.description && (\n\t\t\t\t\t\t\t\t\t<p className=\"text-xs text-muted-foreground truncate\">\n\t\t\t\t\t\t\t\t\t\t{task.description}\n\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\tonClick={() => handleRun(task.id)}\n\t\t\t\t\t\t\t\t\tdisabled={busy}\n\t\t\t\t\t\t\t\t\tclassName=\"inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-xs text-foreground\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<Play className=\"h-3 w-3\" />\n\t\t\t\t\t\t\t\t\t{t(\"actions.run\")}\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\tonClick={() => handleToggle(task.id, !task.enabled)}\n\t\t\t\t\t\t\t\t\tdisabled={busy}\n\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\"inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs\",\n\t\t\t\t\t\t\t\t\t\ttask.enabled\n\t\t\t\t\t\t\t\t\t\t\t? \"border-primary/40 text-primary\"\n\t\t\t\t\t\t\t\t\t\t\t: \"border-border text-muted-foreground\",\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<Check className=\"h-3 w-3\" />\n\t\t\t\t\t\t\t\t\t{task.enabled\n\t\t\t\t\t\t\t\t\t\t? t(\"actions.disable\")\n\t\t\t\t\t\t\t\t\t\t: t(\"actions.enable\")}\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\tonClick={() => handleDelete(task.id)}\n\t\t\t\t\t\t\t\t\tdisabled={busy}\n\t\t\t\t\t\t\t\t\tclassName=\"inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-xs text-destructive\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<Trash2 className=\"h-3 w-3\" />\n\t\t\t\t\t\t\t\t\t{t(\"actions.delete\")}\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"mt-2 flex flex-wrap items-center gap-3 text-xs text-muted-foreground\">\n\t\t\t\t\t\t\t<span className=\"inline-flex items-center gap-1\">\n\t\t\t\t\t\t\t\t<Clock className=\"h-3 w-3\" />\n\t\t\t\t\t\t\t\t{scheduleSummary(task)}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<span>{t(\"labels.lastRun\", { time: lastRunLabel(task.lastRunAt) })}</span>\n\t\t\t\t\t\t\t<span>\n\t\t\t\t\t\t\t\t{t(\"labels.status\")}:{\" \"}\n\t\t\t\t\t\t\t\t{task.lastStatus ? t(`status.${task.lastStatus}`) : t(\"status.never\")}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t{task.lastError && (\n\t\t\t\t\t\t\t<p className=\"mt-2 text-xs text-destructive\">{task.lastError}</p>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t{task.lastOutput && (\n\t\t\t\t\t\t\t<p className=\"mt-2 text-xs text-muted-foreground line-clamp-2\">\n\t\t\t\t\t\t\t\t{task.lastOutput}\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t))}\n\t\t\t</div>\n\t\t</SettingsSection>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/settings/components/DifyConfigSection.tsx",
    "content": "\"use client\";\n\nimport { useTranslations } from \"next-intl\";\nimport { useEffect, useState } from \"react\";\nimport { useSaveConfig } from \"@/lib/query\";\nimport type { AppConfig } from \"@/lib/query/config\";\nimport { toastError, toastSuccess } from \"@/lib/toast\";\nimport { SettingsSection, ToggleSwitch } from \"./index\";\n\ninterface DifyConfigSectionProps {\n\tconfig: AppConfig | undefined;\n\tloading?: boolean;\n}\n\n/**\n * Dify 配置区块\n * - 通过 dynaconf 管理：dify.enabled / dify.api_key / dify.base_url\n * - 前端字段映射：difyEnabled / difyApiKey / difyBaseUrl\n */\nexport function DifyConfigSection({\n\tconfig,\n\tloading = false,\n}: DifyConfigSectionProps) {\n\tconst t = useTranslations(\"page.settings\");\n\tconst saveConfigMutation = useSaveConfig();\n\n\tconst [enabled, setEnabled] = useState<boolean>(\n\t\t(config?.difyEnabled as boolean | undefined) ?? true,\n\t);\n\tconst [apiKey, setApiKey] = useState<string>(\n\t\t(config?.difyApiKey as string | undefined) ?? \"\",\n\t);\n\tconst [baseUrl, setBaseUrl] = useState<string>(\n\t\t(config?.difyBaseUrl as string | undefined) ?? \"https://api.dify.ai/v1\",\n\t);\n\n\tconst isSaving = loading || saveConfigMutation.isPending;\n\n\t// 当配置加载完成后，同步本地状态\n\tuseEffect(() => {\n\t\tif (config) {\n\t\t\t// 只在配置值存在时更新，避免覆盖用户正在编辑的值\n\t\t\tif (config.difyEnabled !== undefined) {\n\t\t\t\tsetEnabled((config.difyEnabled as boolean) ?? true);\n\t\t\t}\n\t\t\tif (config.difyApiKey !== undefined) {\n\t\t\t\tsetApiKey((config.difyApiKey as string) || \"\");\n\t\t\t}\n\t\t\tif (config.difyBaseUrl !== undefined) {\n\t\t\t\tsetBaseUrl((config.difyBaseUrl as string) || \"https://api.dify.ai/v1\");\n\t\t\t}\n\t\t}\n\t}, [config]);\n\n\tconst handleSave = async (\n\t\tpartial?: Partial<{ enabled: boolean; apiKey: string; baseUrl: string }>,\n\t) => {\n\t\ttry {\n\t\t\tconst payload = {\n\t\t\t\tdifyEnabled: partial?.enabled ?? enabled,\n\t\t\t\tdifyApiKey: partial?.apiKey ?? apiKey,\n\t\t\t\tdifyBaseUrl: partial?.baseUrl ?? baseUrl,\n\t\t\t};\n\n\t\t\tawait saveConfigMutation.mutateAsync({ data: payload });\n\t\t\ttoastSuccess(t(\"difySaveSuccess\"));\n\t\t} catch (error) {\n\t\t\tconsole.error(\"保存 Dify 配置失败:\", error);\n\t\t\tconst errorMsg = error instanceof Error ? error.message : String(error);\n\t\t\ttoastError(t(\"saveFailed\", { error: errorMsg }));\n\t\t}\n\t};\n\n\tconst handleToggleEnabled = async (value: boolean) => {\n\t\tsetEnabled(value);\n\t\tawait handleSave({ enabled: value });\n\t};\n\n\treturn (\n\t\t<SettingsSection title={t(\"difyConfigTitle\")}>\n\t\t\t<div className=\"space-y-4\">\n\t\t\t\t{/* 开关 */}\n\t\t\t\t<div className=\"flex items-center justify-between gap-4\">\n\t\t\t\t\t<div className=\"flex-1\">\n\t\t\t\t\t\t<p className=\"text-sm font-medium text-foreground\">\n\t\t\t\t\t\t\t{t(\"difyEnabledLabel\")}\n\t\t\t\t\t\t</p>\n\t\t\t\t\t\t<p className=\"mt-1 text-xs text-muted-foreground\">\n\t\t\t\t\t\t\t{t(\"difyEnabledDescription\")}\n\t\t\t\t\t\t</p>\n\t\t\t\t\t</div>\n\t\t\t\t\t<ToggleSwitch\n\t\t\t\t\t\tid=\"dify-enabled-toggle\"\n\t\t\t\t\t\tenabled={enabled}\n\t\t\t\t\t\tdisabled={isSaving}\n\t\t\t\t\t\tonToggle={(v) => void handleToggleEnabled(v)}\n\t\t\t\t\t\tariaLabel={t(\"difyEnabledLabel\")}\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\n\t\t\t\t{/* API Key */}\n\t\t\t\t<div className=\"space-y-1\">\n\t\t\t\t\t<label\n\t\t\t\t\t\thtmlFor=\"dify-api-key\"\n\t\t\t\t\t\tclassName=\"block text-sm font-medium text-foreground\"\n\t\t\t\t\t>\n\t\t\t\t\t\t{t(\"apiKey\")}\n\t\t\t\t\t</label>\n\t\t\t\t\t<input\n\t\t\t\t\t\tid=\"dify-api-key\"\n\t\t\t\t\t\ttype=\"password\"\n\t\t\t\t\t\tclassName=\"w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50\"\n\t\t\t\t\t\tplaceholder=\"Dify API Key\"\n\t\t\t\t\t\tvalue={apiKey}\n\t\t\t\t\t\tonChange={(e) => setApiKey(e.target.value)}\n\t\t\t\t\t\tonBlur={() => void handleSave()}\n\t\t\t\t\t\tdisabled={isSaving}\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\n\t\t\t\t{/* Base URL */}\n\t\t\t\t<div className=\"space-y-1\">\n\t\t\t\t\t<label\n\t\t\t\t\t\thtmlFor=\"dify-base-url\"\n\t\t\t\t\t\tclassName=\"block text-sm font-medium text-foreground\"\n\t\t\t\t\t>\n\t\t\t\t\t\t{t(\"baseUrl\")}\n\t\t\t\t\t</label>\n\t\t\t\t\t<input\n\t\t\t\t\t\tid=\"dify-base-url\"\n\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\tclassName=\"w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50\"\n\t\t\t\t\t\tplaceholder=\"https://api.dify.ai/v1\"\n\t\t\t\t\t\tvalue={baseUrl}\n\t\t\t\t\t\tonChange={(e) => setBaseUrl(e.target.value)}\n\t\t\t\t\t\tonBlur={() => void handleSave()}\n\t\t\t\t\t\tdisabled={isSaving}\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</SettingsSection>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/settings/components/DockDisplayModeSection.tsx",
    "content": "\"use client\";\n\nimport { useTranslations } from \"next-intl\";\nimport type { DockDisplayMode } from \"@/lib/store/ui-store\";\nimport { useUiStore } from \"@/lib/store/ui-store\";\nimport { toastSuccess } from \"@/lib/toast\";\nimport { SettingsSection } from \"./SettingsSection\";\n\ninterface DockDisplayModeSectionProps {\n\tloading?: boolean;\n}\n\n/**\n * Dock 显示模式设置区块组件\n */\nexport function DockDisplayModeSection({\n\tloading = false,\n}: DockDisplayModeSectionProps) {\n\tconst tSettings = useTranslations(\"page.settings\");\n\tconst dockDisplayMode = useUiStore((state) => state.dockDisplayMode);\n\tconst setDockDisplayMode = useUiStore((state) => state.setDockDisplayMode);\n\n\t// Dock 显示模式处理\n\tconst handleDockDisplayModeChange = (mode: DockDisplayMode) => {\n\t\tsetDockDisplayMode(mode);\n\t\ttoastSuccess(tSettings(\"dockDisplayModeChanged\"));\n\t};\n\n\treturn (\n\t\t<SettingsSection\n\t\t\ttitle={tSettings(\"dockDisplayModeTitle\")}\n\t\t\tdescription={tSettings(\"dockDisplayModeDescription\")}\n\t\t>\n\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t<label\n\t\t\t\t\thtmlFor=\"dock-display-mode-select\"\n\t\t\t\t\tclassName=\"text-sm font-medium text-foreground\"\n\t\t\t\t>\n\t\t\t\t\t{tSettings(\"dockDisplayModeLabel\")}\n\t\t\t\t</label>\n\t\t\t\t<select\n\t\t\t\t\tid=\"dock-display-mode-select\"\n\t\t\t\t\tvalue={dockDisplayMode}\n\t\t\t\t\tonChange={(e) =>\n\t\t\t\t\t\thandleDockDisplayModeChange(e.target.value as DockDisplayMode)\n\t\t\t\t\t}\n\t\t\t\t\tdisabled={loading}\n\t\t\t\t\tclassName=\"rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary\"\n\t\t\t\t>\n\t\t\t\t\t<option value=\"fixed\">{tSettings(\"dockDisplayModeFixed\")}</option>\n\t\t\t\t\t<option value=\"auto-hide\">\n\t\t\t\t\t\t{tSettings(\"dockDisplayModeAutoHide\")}\n\t\t\t\t\t</option>\n\t\t\t\t</select>\n\t\t\t</div>\n\t\t</SettingsSection>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/settings/components/JournalSettingsSection.tsx",
    "content": "\"use client\";\n\nimport { useTranslations } from \"next-intl\";\nimport { useId } from \"react\";\nimport { useJournalStore } from \"@/lib/store/journal-store\";\nimport { SettingsSection } from \"./SettingsSection\";\nimport { ToggleSwitch } from \"./ToggleSwitch\";\n\nexport function JournalSettingsSection() {\n\tconst tSettings = useTranslations(\"page.settings\");\n\tconst {\n\t\trefreshMode,\n\t\tfixedTime,\n\t\tworkHoursStart,\n\t\tworkHoursEnd,\n\t\tcustomTime,\n\t\tautoLinkEnabled,\n\t\tautoGenerateObjectiveEnabled,\n\t\tautoGenerateAiEnabled,\n\t\tsetRefreshMode,\n\t\tsetFixedTime,\n\t\tsetWorkHoursStart,\n\t\tsetWorkHoursEnd,\n\t\tsetCustomTime,\n\t\tsetAutoLinkEnabled,\n\t\tsetAutoGenerateObjectiveEnabled,\n\t\tsetAutoGenerateAiEnabled,\n\t} = useJournalStore();\n\n\tconst fixedId = useId();\n\tconst workStartId = useId();\n\tconst workEndId = useId();\n\tconst customId = useId();\n\tconst refreshModeId = useId();\n\tconst autoLinkId = useId();\n\tconst autoObjectiveId = useId();\n\tconst autoAiId = useId();\n\n\treturn (\n\t\t<SettingsSection\n\t\t\ttitle={tSettings(\"journalSettingsTitle\")}\n\t\t\tdescription={tSettings(\"journalSettingsDescription\")}\n\t\t\tsearchKeywords={[\n\t\t\t\ttSettings(\"journalRefreshModeLabel\"),\n\t\t\t\ttSettings(\"journalAutoLinkLabel\"),\n\t\t\t]}\n\t\t>\n\t\t\t<div className=\"space-y-4\">\n\t\t\t\t<div className=\"grid gap-3 md:grid-cols-2\">\n\t\t\t\t\t<div className=\"space-y-1\">\n\t\t\t\t\t\t<label\n\t\t\t\t\t\t\thtmlFor={refreshModeId}\n\t\t\t\t\t\t\tclassName=\"text-sm font-medium text-foreground\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{tSettings(\"journalRefreshModeLabel\")}\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<select\n\t\t\t\t\t\t\tid={refreshModeId}\n\t\t\t\t\t\t\tvalue={refreshMode}\n\t\t\t\t\t\t\tonChange={(event) =>\n\t\t\t\t\t\t\t\tsetRefreshMode(\n\t\t\t\t\t\t\t\t\tevent.target.value as typeof refreshMode,\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tclassName=\"h-9 w-full rounded-md border border-border bg-background px-3 text-sm\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<option value=\"fixed\">\n\t\t\t\t\t\t\t\t{tSettings(\"journalRefreshModeFixed\")}\n\t\t\t\t\t\t\t</option>\n\t\t\t\t\t\t\t<option value=\"workHours\">\n\t\t\t\t\t\t\t\t{tSettings(\"journalRefreshModeWorkHours\")}\n\t\t\t\t\t\t\t</option>\n\t\t\t\t\t\t\t<option value=\"custom\">\n\t\t\t\t\t\t\t\t{tSettings(\"journalRefreshModeCustom\")}\n\t\t\t\t\t\t\t</option>\n\t\t\t\t\t\t</select>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\n\t\t\t\t<div className=\"grid gap-3 md:grid-cols-3\">\n\t\t\t\t\t<div className=\"space-y-1\">\n\t\t\t\t\t\t<label\n\t\t\t\t\t\t\thtmlFor={fixedId}\n\t\t\t\t\t\t\tclassName=\"text-sm text-muted-foreground\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{tSettings(\"journalFixedTimeLabel\")}\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\tid={fixedId}\n\t\t\t\t\t\t\ttype=\"time\"\n\t\t\t\t\t\t\tvalue={fixedTime}\n\t\t\t\t\t\t\tonChange={(event) => setFixedTime(event.target.value)}\n\t\t\t\t\t\t\tclassName=\"h-9 w-full rounded-md border border-border bg-background px-3 text-sm\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"space-y-1\">\n\t\t\t\t\t\t<label\n\t\t\t\t\t\t\thtmlFor={workStartId}\n\t\t\t\t\t\t\tclassName=\"text-sm text-muted-foreground\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{tSettings(\"journalWorkHoursLabel\")}\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\tid={workStartId}\n\t\t\t\t\t\t\t\ttype=\"time\"\n\t\t\t\t\t\t\t\tvalue={workHoursStart}\n\t\t\t\t\t\t\t\tonChange={(event) =>\n\t\t\t\t\t\t\t\t\tsetWorkHoursStart(event.target.value)\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tclassName=\"h-9 w-full rounded-md border border-border bg-background px-3 text-sm\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<span className=\"text-xs text-muted-foreground\">-</span>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\tid={workEndId}\n\t\t\t\t\t\t\t\ttype=\"time\"\n\t\t\t\t\t\t\t\tvalue={workHoursEnd}\n\t\t\t\t\t\t\t\tonChange={(event) => setWorkHoursEnd(event.target.value)}\n\t\t\t\t\t\t\t\tclassName=\"h-9 w-full rounded-md border border-border bg-background px-3 text-sm\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"space-y-1\">\n\t\t\t\t\t\t<label\n\t\t\t\t\t\t\thtmlFor={customId}\n\t\t\t\t\t\t\tclassName=\"text-sm text-muted-foreground\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{tSettings(\"journalCustomTimeLabel\")}\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\tid={customId}\n\t\t\t\t\t\t\ttype=\"time\"\n\t\t\t\t\t\t\tvalue={customTime}\n\t\t\t\t\t\t\tonChange={(event) => setCustomTime(event.target.value)}\n\t\t\t\t\t\t\tclassName=\"h-9 w-full rounded-md border border-border bg-background px-3 text-sm\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\n\t\t\t\t<div className=\"grid gap-3\">\n\t\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t\t<label\n\t\t\t\t\t\t\thtmlFor={autoLinkId}\n\t\t\t\t\t\t\tclassName=\"text-sm font-medium text-foreground\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{tSettings(\"journalAutoLinkLabel\")}\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<ToggleSwitch\n\t\t\t\t\t\t\tid={autoLinkId}\n\t\t\t\t\t\t\tenabled={autoLinkEnabled}\n\t\t\t\t\t\t\tonToggle={setAutoLinkEnabled}\n\t\t\t\t\t\t\tariaLabel={tSettings(\"journalAutoLinkLabel\")}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t\t<label\n\t\t\t\t\t\t\thtmlFor={autoObjectiveId}\n\t\t\t\t\t\t\tclassName=\"text-sm font-medium text-foreground\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{tSettings(\"journalAutoObjectiveLabel\")}\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<ToggleSwitch\n\t\t\t\t\t\t\tid={autoObjectiveId}\n\t\t\t\t\t\t\tenabled={autoGenerateObjectiveEnabled}\n\t\t\t\t\t\t\tonToggle={setAutoGenerateObjectiveEnabled}\n\t\t\t\t\t\t\tariaLabel={tSettings(\"journalAutoObjectiveLabel\")}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t\t<label\n\t\t\t\t\t\t\thtmlFor={autoAiId}\n\t\t\t\t\t\t\tclassName=\"text-sm font-medium text-foreground\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{tSettings(\"journalAutoAiLabel\")}\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<ToggleSwitch\n\t\t\t\t\t\t\tid={autoAiId}\n\t\t\t\t\t\t\tenabled={autoGenerateAiEnabled}\n\t\t\t\t\t\t\tonToggle={setAutoGenerateAiEnabled}\n\t\t\t\t\t\t\tariaLabel={tSettings(\"journalAutoAiLabel\")}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</SettingsSection>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/settings/components/LlmConfigSection.tsx",
    "content": "\"use client\";\n\nimport { useQueryClient } from \"@tanstack/react-query\";\nimport { useTranslations } from \"next-intl\";\nimport { useEffect, useState } from \"react\";\nimport {\n\tuseSaveAndInitLlmApiSaveAndInitLlmPost,\n\tuseTestLlmConfigApiTestLlmConfigPost,\n} from \"@/lib/generated/config/config\";\nimport { useSaveConfig } from \"@/lib/query\";\nimport { toastError } from \"@/lib/toast\";\nimport { SettingsSection } from \"./SettingsSection\";\n\ninterface LlmConfigSectionProps {\n\tconfig: Record<string, unknown> | undefined;\n\tloading?: boolean;\n}\n\n/**\n * LLM 配置区块组件\n */\nexport function LlmConfigSection({\n\tconfig,\n\tloading = false,\n}: LlmConfigSectionProps) {\n\tconst t = useTranslations(\"page.settings\");\n\tconst queryClient = useQueryClient();\n\tconst saveConfigMutation = useSaveConfig();\n\tconst testLlmMutation = useTestLlmConfigApiTestLlmConfigPost();\n\tconst saveAndInitLlmMutation = useSaveAndInitLlmApiSaveAndInitLlmPost();\n\n\t// LLM 配置状态\n\tconst [llmApiKey, setLlmApiKey] = useState(\n\t\t(config?.llmApiKey as string) || \"\",\n\t);\n\tconst [llmBaseUrl, setLlmBaseUrl] = useState(\n\t\t(config?.llmBaseUrl as string) || \"\",\n\t);\n\tconst [llmModel, setLlmModel] = useState(\n\t\t(config?.llmModel as string) || \"qwen-plus\",\n\t);\n\tconst [llmTemperature, setLlmTemperature] = useState(\n\t\t(config?.llmTemperature as number) ?? 0.7,\n\t);\n\tconst [llmMaxTokens, setLlmMaxTokens] = useState(\n\t\t(config?.llmMaxTokens as number) ?? 2048,\n\t);\n\tconst [initialLlmConfig, setInitialLlmConfig] = useState({\n\t\tllmApiKey: (config?.llmApiKey as string) || \"\",\n\t\tllmBaseUrl: (config?.llmBaseUrl as string) || \"\",\n\t\tllmModel: (config?.llmModel as string) || \"qwen-plus\",\n\t\tllmTemperature: (config?.llmTemperature as number) ?? 0.7,\n\t\tllmMaxTokens: (config?.llmMaxTokens as number) ?? 2048,\n\t});\n\tconst [testMessage, setTestMessage] = useState<{\n\t\ttype: \"success\" | \"error\";\n\t\ttext: string;\n\t} | null>(null);\n\n\tconst isLoading =\n\t\tloading ||\n\t\tsaveConfigMutation.isPending ||\n\t\ttestLlmMutation.isPending ||\n\t\tsaveAndInitLlmMutation.isPending;\n\n\t// 当配置加载完成后，同步本地状态\n\tuseEffect(() => {\n\t\tif (config) {\n\t\t\t// 只在配置值存在时更新，避免覆盖用户正在编辑的值\n\t\t\tif (config.llmApiKey !== undefined) {\n\t\t\t\tsetLlmApiKey((config.llmApiKey as string) || \"\");\n\t\t\t}\n\t\t\tif (config.llmBaseUrl !== undefined) {\n\t\t\t\tsetLlmBaseUrl((config.llmBaseUrl as string) || \"\");\n\t\t\t}\n\t\t\tif (config.llmModel !== undefined) {\n\t\t\t\tsetLlmModel((config.llmModel as string) || \"qwen-plus\");\n\t\t\t}\n\t\t\tif (config.llmTemperature !== undefined) {\n\t\t\t\tsetLlmTemperature((config.llmTemperature as number) ?? 0.7);\n\t\t\t}\n\t\t\tif (config.llmMaxTokens !== undefined) {\n\t\t\t\tsetLlmMaxTokens((config.llmMaxTokens as number) ?? 2048);\n\t\t\t}\n\t\t\t// 更新初始配置（用于检测变更）\n\t\t\tsetInitialLlmConfig({\n\t\t\t\tllmApiKey: (config.llmApiKey as string) || \"\",\n\t\t\t\tllmBaseUrl: (config.llmBaseUrl as string) || \"\",\n\t\t\t\tllmModel: (config.llmModel as string) || \"qwen-plus\",\n\t\t\t\tllmTemperature: (config.llmTemperature as number) ?? 0.7,\n\t\t\t\tllmMaxTokens: (config.llmMaxTokens as number) ?? 2048,\n\t\t\t});\n\t\t}\n\t}, [config]);\n\n\t// 测试 LLM 连接\n\tconst handleTestLlm = async () => {\n\t\tconst currentApiKey = llmApiKey.trim();\n\t\tconst currentBaseUrl = llmBaseUrl.trim();\n\t\tconst currentModel = llmModel.trim();\n\n\t\tif (!currentApiKey || !currentBaseUrl) {\n\t\t\tsetTestMessage({\n\t\t\t\ttype: \"error\",\n\t\t\t\ttext: t(\"apiKeyRequired\"),\n\t\t\t});\n\t\t\treturn;\n\t\t}\n\n\t\tsetTestMessage(null);\n\t\ttry {\n\t\t\tconst response = await testLlmMutation.mutateAsync({\n\t\t\t\tdata: {\n\t\t\t\t\tllmApiKey: currentApiKey,\n\t\t\t\t\tllmBaseUrl: currentBaseUrl,\n\t\t\t\t\tllmModel: currentModel,\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tconst result = response as { success?: boolean; error?: string };\n\t\t\tif (result.success) {\n\t\t\t\tsetTestMessage({\n\t\t\t\t\ttype: \"success\",\n\t\t\t\t\ttext: t(\"testSuccess\"),\n\t\t\t\t});\n\t\t\t} else {\n\t\t\t\tsetTestMessage({\n\t\t\t\t\ttype: \"error\",\n\t\t\t\t\ttext: `${t(\"testFailed\")}: ${result.error || \"Unknown error\"}`,\n\t\t\t\t});\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tconst errorMsg = error instanceof Error ? error.message : \"Network error\";\n\t\t\tsetTestMessage({\n\t\t\t\ttype: \"error\",\n\t\t\t\ttext: `${t(\"testFailed\")}: ${errorMsg}`,\n\t\t\t});\n\t\t}\n\t};\n\n\t// 保存 LLM 配置（失去焦点时触发）\n\tconst handleSaveLlmConfig = async () => {\n\t\tconst currentApiKey = llmApiKey.trim();\n\t\tconst currentBaseUrl = llmBaseUrl.trim();\n\t\tconst currentModel = llmModel.trim();\n\n\t\t// 检查核心配置是否改变（API Key, Base URL, Model）\n\t\tconst llmCoreConfigChanged =\n\t\t\tcurrentApiKey !== initialLlmConfig.llmApiKey ||\n\t\t\tcurrentBaseUrl !== initialLlmConfig.llmBaseUrl ||\n\t\t\tcurrentModel !== initialLlmConfig.llmModel;\n\n\t\t// 检查其他配置是否改变（Temperature, Max Tokens）\n\t\tconst otherConfigChanged =\n\t\t\tllmTemperature !== initialLlmConfig.llmTemperature ||\n\t\t\tllmMaxTokens !== initialLlmConfig.llmMaxTokens;\n\n\t\t// 如果没有任何改动，不需要保存\n\t\tif (!llmCoreConfigChanged && !otherConfigChanged) {\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\t// 1. 始终保存用户输入的配置到文件（即使配置不完整）\n\t\t\tawait saveConfigMutation.mutateAsync({\n\t\t\t\tdata: {\n\t\t\t\t\tllmApiKey: currentApiKey,\n\t\t\t\t\tllmBaseUrl: currentBaseUrl,\n\t\t\t\t\tllmModel: currentModel,\n\t\t\t\t\tllmTemperature,\n\t\t\t\t\tllmMaxTokens,\n\t\t\t\t},\n\t\t\t});\n\n\t\t\t// 更新初始配置状态\n\t\t\tsetInitialLlmConfig({\n\t\t\t\tllmApiKey: currentApiKey,\n\t\t\t\tllmBaseUrl: currentBaseUrl,\n\t\t\t\tllmModel: currentModel,\n\t\t\t\tllmTemperature,\n\t\t\t\tllmMaxTokens,\n\t\t\t});\n\n\t\t\t// 2. 只有当核心配置改变且配置完整时，才测试并初始化 LLM\n\t\t\tif (llmCoreConfigChanged && currentApiKey && currentBaseUrl) {\n\t\t\t\ttry {\n\t\t\t\t\tconst result = await saveAndInitLlmMutation.mutateAsync({\n\t\t\t\t\t\tdata: {\n\t\t\t\t\t\t\tllmApiKey: currentApiKey,\n\t\t\t\t\t\t\tllmBaseUrl: currentBaseUrl,\n\t\t\t\t\t\t\tllmModel: currentModel,\n\t\t\t\t\t\t},\n\t\t\t\t\t});\n\n\t\t\t\t\t// 检查返回结果\n\t\t\t\t\tconst response = result as { success?: boolean; error?: string };\n\t\t\t\t\tif (response.success) {\n\t\t\t\t\t\t// 测试成功，更新消息提示并刷新 LLM 状态\n\t\t\t\t\t\tsetTestMessage({\n\t\t\t\t\t\t\ttype: \"success\",\n\t\t\t\t\t\t\ttext: t(\"testSuccess\"),\n\t\t\t\t\t\t});\n\t\t\t\t\t\tawait queryClient.invalidateQueries({ queryKey: [\"llm-status\"] });\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// 测试失败，显示错误信息\n\t\t\t\t\t\tsetTestMessage({\n\t\t\t\t\t\t\ttype: \"error\",\n\t\t\t\t\t\t\ttext: `${t(\"testFailed\")}: ${response.error || \"Unknown error\"}`,\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\t\t\t\t} catch (initError) {\n\t\t\t\t\t// 初始化失败，显示错误信息\n\t\t\t\t\tconst errorMsg =\n\t\t\t\t\t\tinitError instanceof Error ? initError.message : String(initError);\n\t\t\t\t\tsetTestMessage({\n\t\t\t\t\t\ttype: \"error\",\n\t\t\t\t\t\ttext: `${t(\"testFailed\")}: ${errorMsg}`,\n\t\t\t\t\t});\n\t\t\t\t\tconsole.warn(\"LLM 初始化失败，配置已保存:\", initError);\n\t\t\t\t}\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tconsole.error(\"保存 LLM 配置失败:\", error);\n\t\t\tconst errorMsg = error instanceof Error ? error.message : String(error);\n\t\t\ttoastError(t(\"saveFailed\", { error: errorMsg }));\n\t\t}\n\t};\n\n\treturn (\n\t\t<SettingsSection title={t(\"llmConfig\")}>\n\t\t\t<div className=\"space-y-3\">\n\t\t\t\t{/* 消息提示 */}\n\t\t\t\t{testMessage && (\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName={`rounded-lg px-3 py-2 text-sm font-medium ${\n\t\t\t\t\t\t\ttestMessage.type === \"success\"\n\t\t\t\t\t\t\t\t? \"bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400\"\n\t\t\t\t\t\t\t\t: \"bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400\"\n\t\t\t\t\t\t}`}\n\t\t\t\t\t>\n\t\t\t\t\t\t{testMessage.text}\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\n\t\t\t\t{/* API Key */}\n\t\t\t\t<div>\n\t\t\t\t\t<label\n\t\t\t\t\t\thtmlFor=\"llm-api-key\"\n\t\t\t\t\t\tclassName=\"mb-1 block text-sm font-medium text-foreground\"\n\t\t\t\t\t>\n\t\t\t\t\t\t{t(\"apiKey\")} <span className=\"text-red-500\">*</span>\n\t\t\t\t\t</label>\n\t\t\t\t\t<input\n\t\t\t\t\t\tid=\"llm-api-key\"\n\t\t\t\t\t\ttype=\"password\"\n\t\t\t\t\t\tclassName=\"w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50\"\n\t\t\t\t\t\tplaceholder={t(\"apiKey\")}\n\t\t\t\t\t\tvalue={llmApiKey}\n\t\t\t\t\t\tonChange={(e) => setLlmApiKey(e.target.value)}\n\t\t\t\t\t\tonBlur={handleSaveLlmConfig}\n\t\t\t\t\t\tdisabled={isLoading}\n\t\t\t\t\t/>\n\t\t\t\t\t<p className=\"mt-1 text-xs text-muted-foreground\">\n\t\t\t\t\t\t{t(\"apiKeyHint\")}{\" \"}\n\t\t\t\t\t\t<a\n\t\t\t\t\t\t\thref=\"https://bailian.console.aliyun.com/?tab=api#/api\"\n\t\t\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t\t\t\trel=\"noopener noreferrer\"\n\t\t\t\t\t\t\tclassName=\"text-primary hover:underline\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{t(\"apiKeyLink\")}\n\t\t\t\t\t\t</a>\n\t\t\t\t\t</p>\n\t\t\t\t</div>\n\n\t\t\t\t{/* Base URL */}\n\t\t\t\t<div>\n\t\t\t\t\t<label\n\t\t\t\t\t\thtmlFor=\"llm-base-url\"\n\t\t\t\t\t\tclassName=\"mb-1 block text-sm font-medium text-foreground\"\n\t\t\t\t\t>\n\t\t\t\t\t\t{t(\"baseUrl\")} <span className=\"text-red-500\">*</span>\n\t\t\t\t\t</label>\n\t\t\t\t\t<input\n\t\t\t\t\t\tid=\"llm-base-url\"\n\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\tclassName=\"w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50\"\n\t\t\t\t\t\tplaceholder=\"https://dashscope.aliyuncs.com/compatible-mode/v1\"\n\t\t\t\t\t\tvalue={llmBaseUrl}\n\t\t\t\t\t\tonChange={(e) => setLlmBaseUrl(e.target.value)}\n\t\t\t\t\t\tonBlur={handleSaveLlmConfig}\n\t\t\t\t\t\tdisabled={isLoading}\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\n\t\t\t\t{/* Model / Temperature / Max Tokens */}\n\t\t\t\t<div className=\"grid grid-cols-3 gap-3\">\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<label\n\t\t\t\t\t\t\thtmlFor=\"llm-model\"\n\t\t\t\t\t\t\tclassName=\"mb-1 block text-sm font-medium text-foreground\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{t(\"model\")}\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\tid=\"llm-model\"\n\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\tclassName=\"w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50\"\n\t\t\t\t\t\t\tplaceholder=\"qwen-plus\"\n\t\t\t\t\t\t\tvalue={llmModel}\n\t\t\t\t\t\t\tonChange={(e) => setLlmModel(e.target.value)}\n\t\t\t\t\t\t\tonBlur={handleSaveLlmConfig}\n\t\t\t\t\t\t\tdisabled={isLoading}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<label\n\t\t\t\t\t\t\thtmlFor=\"llm-temperature\"\n\t\t\t\t\t\t\tclassName=\"mb-1 block text-sm font-medium text-foreground\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{t(\"temperature\")}\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\tid=\"llm-temperature\"\n\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\tstep=\"0.1\"\n\t\t\t\t\t\t\tmin=\"0\"\n\t\t\t\t\t\t\tmax=\"2\"\n\t\t\t\t\t\t\tclassName=\"w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50\"\n\t\t\t\t\t\t\tvalue={llmTemperature}\n\t\t\t\t\t\t\tonChange={(e) => setLlmTemperature(parseFloat(e.target.value))}\n\t\t\t\t\t\t\tonBlur={handleSaveLlmConfig}\n\t\t\t\t\t\t\tdisabled={isLoading}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<label\n\t\t\t\t\t\t\thtmlFor=\"llm-max-tokens\"\n\t\t\t\t\t\t\tclassName=\"mb-1 block text-sm font-medium text-foreground\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{t(\"maxTokens\")}\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\tid=\"llm-max-tokens\"\n\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\tclassName=\"w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50\"\n\t\t\t\t\t\t\tvalue={llmMaxTokens}\n\t\t\t\t\t\t\tonChange={(e) => setLlmMaxTokens(parseInt(e.target.value, 10))}\n\t\t\t\t\t\t\tonBlur={handleSaveLlmConfig}\n\t\t\t\t\t\t\tdisabled={isLoading}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\n\t\t\t\t{/* 测试按钮 */}\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tonClick={async () => {\n\t\t\t\t\t\tif (document.activeElement instanceof HTMLElement) {\n\t\t\t\t\t\t\tdocument.activeElement.blur();\n\t\t\t\t\t\t}\n\t\t\t\t\t\tawait new Promise((resolve) => setTimeout(resolve, 50));\n\t\t\t\t\t\tawait handleTestLlm();\n\t\t\t\t\t}}\n\t\t\t\t\tdisabled={isLoading || !llmApiKey.trim() || !llmBaseUrl.trim()}\n\t\t\t\t\tclassName=\"w-full rounded-md border border-input bg-background px-4 py-2 text-sm font-medium ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50\"\n\t\t\t\t>\n\t\t\t\t\t{testLlmMutation.isPending\n\t\t\t\t\t\t? `${t(\"testConnection\")}...`\n\t\t\t\t\t\t: t(\"testConnection\")}\n\t\t\t\t</button>\n\t\t\t</div>\n\t\t</SettingsSection>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/settings/components/NotificationPermissionSection.tsx",
    "content": "\"use client\";\n\nimport { useTranslations } from \"next-intl\";\nimport { useEffect, useMemo, useState } from \"react\";\nimport { toastError, toastInfo, toastSuccess, toastWarning } from \"@/lib/toast\";\nimport { cn } from \"@/lib/utils\";\nimport { isElectron, isTauri, isWeb } from \"@/lib/utils/platform\";\nimport { SettingsSection } from \"./SettingsSection\";\n\ntype PermissionStatus =\n\t| \"granted\"\n\t| \"denied\"\n\t| \"default\"\n\t| \"unknown\"\n\t| \"not_required\";\n\ntype TauriNotificationApi = {\n\tisPermissionGranted?: () => Promise<boolean> | boolean;\n\trequestPermission?: () =>\n\t\t| Promise<NotificationPermission | boolean>\n\t\t| NotificationPermission\n\t\t| boolean;\n};\n\nconst getTauriNotificationApi = (): TauriNotificationApi | null => {\n\tif (typeof window === \"undefined\") return null;\n\tconst tauri = (window as Window & { __TAURI__?: { notification?: TauriNotificationApi } })\n\t\t.__TAURI__;\n\treturn tauri?.notification ?? null;\n};\n\nconst normalizePermission = (value: unknown): PermissionStatus => {\n\tif (value === \"granted\" || value === \"denied\" || value === \"default\") {\n\t\treturn value;\n\t}\n\tif (value === true) return \"granted\";\n\tif (value === false) return \"default\";\n\treturn \"unknown\";\n};\n\ninterface NotificationPermissionSectionProps {\n\tloading?: boolean;\n}\n\nexport function NotificationPermissionSection({\n\tloading = false,\n}: NotificationPermissionSectionProps) {\n\tconst tSettings = useTranslations(\"page.settings\");\n\tconst [permission, setPermission] = useState<PermissionStatus>(\"unknown\");\n\tconst [isRequesting, setIsRequesting] = useState(false);\n\n\tconst canRequest = isWeb() || isTauri();\n\n\tconst statusLabel = useMemo(() => {\n\t\tswitch (permission) {\n\t\t\tcase \"granted\":\n\t\t\t\treturn tSettings(\"notificationPermissionStatusGranted\");\n\t\t\tcase \"denied\":\n\t\t\t\treturn tSettings(\"notificationPermissionStatusDenied\");\n\t\t\tcase \"default\":\n\t\t\t\treturn tSettings(\"notificationPermissionStatusDefault\");\n\t\t\tcase \"not_required\":\n\t\t\t\treturn tSettings(\"notificationPermissionStatusNotRequired\");\n\t\t\tdefault:\n\t\t\t\treturn tSettings(\"notificationPermissionStatusUnknown\");\n\t\t}\n\t}, [permission, tSettings]);\n\n\tconst statusClasses = useMemo(() => {\n\t\tswitch (permission) {\n\t\t\tcase \"granted\":\n\t\t\t\treturn \"border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-200\";\n\t\t\tcase \"denied\":\n\t\t\t\treturn \"border-red-200 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-200\";\n\t\t\tcase \"default\":\n\t\t\t\treturn \"border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-200\";\n\t\t\tcase \"not_required\":\n\t\t\t\treturn \"border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-200\";\n\t\t\tdefault:\n\t\t\t\treturn \"border-border bg-muted/40 text-muted-foreground\";\n\t\t}\n\t}, [permission]);\n\n\tuseEffect(() => {\n\t\tconst refreshPermission = async () => {\n\t\t\tif (isElectron()) {\n\t\t\t\tsetPermission(\"not_required\");\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (isWeb()) {\n\t\t\t\tif (typeof window === \"undefined\" || !(\"Notification\" in window)) {\n\t\t\t\t\tsetPermission(\"unknown\");\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tsetPermission(normalizePermission(Notification.permission));\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (isTauri()) {\n\t\t\t\tconst api = getTauriNotificationApi();\n\t\t\t\tif (!api?.isPermissionGranted) {\n\t\t\t\t\tsetPermission(\"unknown\");\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\ttry {\n\t\t\t\t\tconst granted = await api.isPermissionGranted();\n\t\t\t\t\tsetPermission(granted ? \"granted\" : \"default\");\n\t\t\t\t} catch {\n\t\t\t\t\tsetPermission(\"unknown\");\n\t\t\t\t}\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tsetPermission(\"unknown\");\n\t\t};\n\n\t\tvoid refreshPermission();\n\t}, []);\n\n\tconst requestPermission = async () => {\n\t\tif (!canRequest || loading || isRequesting) {\n\t\t\tif (!canRequest) {\n\t\t\t\ttoastInfo(tSettings(\"notificationPermissionNotSupported\"));\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\n\t\tsetIsRequesting(true);\n\t\ttry {\n\t\t\tif (isWeb()) {\n\t\t\t\tif (typeof window === \"undefined\" || !(\"Notification\" in window)) {\n\t\t\t\t\ttoastInfo(tSettings(\"notificationPermissionNotSupported\"));\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst result = await Notification.requestPermission();\n\t\t\t\tconst normalized = normalizePermission(result);\n\t\t\t\tsetPermission(normalized);\n\t\t\t\tif (normalized === \"granted\") {\n\t\t\t\t\ttoastSuccess(tSettings(\"notificationPermissionRequestSuccess\"));\n\t\t\t\t} else if (normalized === \"denied\") {\n\t\t\t\t\ttoastWarning(tSettings(\"notificationPermissionRequestDenied\"));\n\t\t\t\t}\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (isTauri()) {\n\t\t\t\tconst api = getTauriNotificationApi();\n\t\t\t\tif (!api?.requestPermission) {\n\t\t\t\t\ttoastInfo(tSettings(\"notificationPermissionNotSupported\"));\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst result = await api.requestPermission();\n\t\t\t\tconst normalized = normalizePermission(result);\n\t\t\t\tsetPermission(normalized);\n\t\t\t\tif (normalized === \"granted\") {\n\t\t\t\t\ttoastSuccess(tSettings(\"notificationPermissionRequestSuccess\"));\n\t\t\t\t} else if (normalized === \"denied\") {\n\t\t\t\t\ttoastWarning(tSettings(\"notificationPermissionRequestDenied\"));\n\t\t\t\t}\n\t\t\t\treturn;\n\t\t\t}\n\t\t\ttoastInfo(tSettings(\"notificationPermissionNotSupported\"));\n\t\t} catch (error) {\n\t\t\tconst errorMsg = error instanceof Error ? error.message : String(error);\n\t\t\ttoastError(tSettings(\"notificationPermissionRequestFailed\", { error: errorMsg }));\n\t\t} finally {\n\t\t\tsetIsRequesting(false);\n\t\t}\n\t};\n\n\tconst hint = isElectron()\n\t\t? tSettings(\"notificationPermissionElectronHint\")\n\t\t: tSettings(\"notificationPermissionHint\");\n\n\treturn (\n\t\t<SettingsSection\n\t\t\ttitle={tSettings(\"notificationPermissionTitle\")}\n\t\t\tdescription={tSettings(\"notificationPermissionDescription\")}\n\t\t\tsearchKeywords={[\n\t\t\t\t\"notification\",\n\t\t\t\t\"permission\",\n\t\t\t\t\"通知\",\n\t\t\t\t\"权限\",\n\t\t\t\ttSettings(\"notificationPermissionTitle\"),\n\t\t\t]}\n\t\t>\n\t\t\t<div className=\"space-y-3\">\n\t\t\t\t<div className=\"flex flex-wrap items-center gap-2 text-sm\">\n\t\t\t\t\t<span className=\"text-muted-foreground\">\n\t\t\t\t\t\t{tSettings(\"notificationPermissionStatusLabel\")}\n\t\t\t\t\t</span>\n\t\t\t\t\t<span\n\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\"rounded-full border px-2.5 py-0.5 text-xs font-medium\",\n\t\t\t\t\t\t\tstatusClasses,\n\t\t\t\t\t\t)}\n\t\t\t\t\t>\n\t\t\t\t\t\t{statusLabel}\n\t\t\t\t\t</span>\n\t\t\t\t</div>\n\n\t\t\t\t<div className=\"flex flex-wrap items-center gap-3\">\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tonClick={requestPermission}\n\t\t\t\t\t\tdisabled={!canRequest || loading || isRequesting}\n\t\t\t\t\t\tclassName=\"flex items-center gap-2 rounded-md border border-input bg-background px-4 py-2 text-sm font-medium ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50\"\n\t\t\t\t\t>\n\t\t\t\t\t\t{isRequesting\n\t\t\t\t\t\t\t? tSettings(\"notificationPermissionRequesting\")\n\t\t\t\t\t\t\t: tSettings(\"notificationPermissionRequest\")}\n\t\t\t\t\t</button>\n\t\t\t\t\t<span className=\"text-xs text-muted-foreground\">{hint}</span>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</SettingsSection>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/settings/components/OnboardingSection.tsx",
    "content": "\"use client\";\n\nimport { RotateCcw } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport { useOnboardingTour } from \"@/lib/hooks/useOnboardingTour\";\nimport { SettingsSection } from \"./SettingsSection\";\n\ninterface OnboardingSectionProps {\n\tloading?: boolean;\n}\n\n/**\n * 用户引导设置区块组件\n * 提供重新开始引导的功能\n */\nexport function OnboardingSection({ loading = false }: OnboardingSectionProps) {\n\tconst t = useTranslations(\"onboarding\");\n\tconst { restartTour } = useOnboardingTour();\n\n\treturn (\n\t\t<SettingsSection title={t(\"restartTour\")}>\n\t\t\t<div className=\"space-y-3\">\n\t\t\t\t<p className=\"text-sm text-muted-foreground\">\n\t\t\t\t\t{t(\"restartTourDescription\")}\n\t\t\t\t</p>\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tonClick={restartTour}\n\t\t\t\t\tdisabled={loading}\n\t\t\t\t\tclassName=\"flex items-center gap-2 rounded-md border border-input bg-background px-4 py-2 text-sm font-medium ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50\"\n\t\t\t\t>\n\t\t\t\t\t<RotateCcw className=\"h-4 w-4\" />\n\t\t\t\t\t{t(\"restartTour\")}\n\t\t\t\t</button>\n\t\t\t</div>\n\t\t</SettingsSection>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/settings/components/PanelSwitchesSection.tsx",
    "content": "\"use client\";\n\nimport { useTranslations } from \"next-intl\";\nimport { useState } from \"react\";\nimport { CollapsibleSection } from \"@/components/common/layout/CollapsibleSection\";\nimport {\n\tALL_PANEL_FEATURES,\n\tDEV_IN_PROGRESS_FEATURES,\n\tFEATURE_ICON_MAP,\n\ttype PanelFeature,\n} from \"@/lib/config/panel-config\";\nimport { useUiStore } from \"@/lib/store/ui-store\";\nimport { toastError, toastSuccess } from \"@/lib/toast\";\nimport { SettingsSection } from \"./SettingsSection\";\nimport { ToggleSwitch } from \"./ToggleSwitch\";\n\ninterface PanelSwitchesSectionProps {\n\tloading?: boolean;\n}\n\n/**\n * 面板开关设置区块组件\n */\nexport function PanelSwitchesSection({\n\tloading = false,\n}: PanelSwitchesSectionProps) {\n\tconst tSettings = useTranslations(\"page.settings\");\n\tconst tBottomDock = useTranslations(\"bottomDock\");\n\tconst setFeatureEnabled = useUiStore((state) => state.setFeatureEnabled);\n\tconst isFeatureEnabled = useUiStore((state) => state.isFeatureEnabled);\n\tconst backendDisabledFeatures = useUiStore(\n\t\t(state) => state.backendDisabledFeatures,\n\t);\n\tconst [showDevPanels, setShowDevPanels] = useState(false);\n\n\t// 获取所有可用的面板（排除 settings）\n\tconst availablePanels = ALL_PANEL_FEATURES.filter(\n\t\t(feature) => feature !== \"settings\",\n\t);\n\n\t// 开发中的面板 & 常规面板分组\n\tconst devPanels = availablePanels.filter((feature) =>\n\t\tDEV_IN_PROGRESS_FEATURES.includes(feature),\n\t);\n\tconst regularPanels = availablePanels.filter(\n\t\t(feature) => !DEV_IN_PROGRESS_FEATURES.includes(feature),\n\t);\n\tconst panelKeywords = [...regularPanels, ...devPanels].map(\n\t\t(feature) => tBottomDock(feature) || feature,\n\t);\n\n\t// 面板开关处理\n\tconst handleTogglePanel = async (feature: PanelFeature, enabled: boolean) => {\n\t\ttry {\n\t\t\tsetFeatureEnabled(feature, enabled);\n\n\t\t\ttoastSuccess(\n\t\t\t\tenabled\n\t\t\t\t\t? `${tBottomDock(feature)} ${tSettings(\"panelEnabled\")}`\n\t\t\t\t\t: `${tBottomDock(feature)} ${tSettings(\"panelDisabled\")}`,\n\t\t\t);\n\t\t} catch (error) {\n\t\t\tconsole.error(\"切换面板失败:\", error);\n\t\t\tconst errorMsg = error instanceof Error ? error.message : String(error);\n\t\t\ttoastError(tSettings(\"saveFailed\", { error: errorMsg }));\n\t\t\t// 回滚状态\n\t\t\tsetFeatureEnabled(feature, !enabled);\n\t\t}\n\t};\n\n\treturn (\n\t\t<SettingsSection\n\t\t\ttitle={tSettings(\"panelSwitchesTitle\")}\n\t\t\tdescription={tSettings(\"panelSwitchesDescription\")}\n\t\t\tsearchKeywords={panelKeywords}\n\t\t>\n\t\t\t<div className=\"space-y-3\">\n\t\t\t\t{regularPanels.map((feature) => {\n\t\t\t\t\tconst enabled = isFeatureEnabled(feature);\n\t\t\t\t\tconst backendDisabled = backendDisabledFeatures.includes(feature);\n\t\t\t\t\tconst panelLabel = tBottomDock(feature) || feature;\n\t\t\t\t\tconst Icon = FEATURE_ICON_MAP[feature];\n\n\t\t\t\t\treturn (\n\t\t\t\t\t\t<div key={feature} className=\"flex items-center justify-between\">\n\t\t\t\t\t\t\t<div className=\"flex-1 flex items-center gap-2\">\n\t\t\t\t\t\t\t\t{Icon && (\n\t\t\t\t\t\t\t\t\t<Icon className=\"h-4 w-4 text-muted-foreground shrink-0\" />\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t\t\thtmlFor={`panel-toggle-${feature}`}\n\t\t\t\t\t\t\t\t\tclassName=\"text-sm font-medium text-foreground cursor-pointer\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{panelLabel}\n\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<ToggleSwitch\n\t\t\t\t\t\t\t\tid={`panel-toggle-${feature}`}\n\t\t\t\t\t\t\t\tenabled={enabled}\n\t\t\t\t\t\t\t\tdisabled={loading || backendDisabled}\n\t\t\t\t\t\t\t\tonToggle={(newEnabled) =>\n\t\t\t\t\t\t\t\t\thandleTogglePanel(feature, newEnabled)\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tariaLabel={panelLabel}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t);\n\t\t\t\t})}\n\n\t\t\t\t{/* 开发中的面板（折叠分组，位于面板开关内部底部） */}\n\t\t\t\t{devPanels.length > 0 && (\n\t\t\t\t\t<CollapsibleSection\n\t\t\t\t\t\ttitle={tSettings(\"devPanelsTitle\")}\n\t\t\t\t\t\tshow={showDevPanels}\n\t\t\t\t\t\tonToggle={() => setShowDevPanels((prev) => !prev)}\n\t\t\t\t\t\tclassName=\"mt-4\"\n\t\t\t\t\t\tcontentClassName=\"mt-3\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<SettingsSection\n\t\t\t\t\t\t\ttitle={tSettings(\"devPanelsTitle\")}\n\t\t\t\t\t\t\tdescription={tSettings(\"devPanelsDescription\")}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<div className=\"space-y-3\">\n\t\t\t\t\t\t\t\t{devPanels.map((feature) => {\n\t\t\t\t\t\t\t\t\tconst enabled = isFeatureEnabled(feature);\n\t\t\t\t\t\t\t\t\tconst backendDisabled =\n\t\t\t\t\t\t\t\t\t\tbackendDisabledFeatures.includes(feature);\n\t\t\t\t\t\t\t\t\tconst panelLabel = tBottomDock(feature) || feature;\n\t\t\t\t\t\t\t\t\tconst Icon = FEATURE_ICON_MAP[feature];\n\n\t\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\tkey={feature}\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"flex items-center justify-between\"\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex-1 flex items-center gap-2\">\n\t\t\t\t\t\t\t\t\t\t\t\t{Icon && (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<Icon className=\"h-4 w-4 text-muted-foreground shrink-0\" />\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t\t\t\t\t\t\thtmlFor={`panel-toggle-${feature}`}\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"text-sm font-medium text-foreground cursor-pointer\"\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{panelLabel}\n\t\t\t\t\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t<ToggleSwitch\n\t\t\t\t\t\t\t\t\t\t\t\tid={`panel-toggle-${feature}`}\n\t\t\t\t\t\t\t\t\t\t\t\tenabled={enabled}\n\t\t\t\t\t\t\t\t\t\t\t\tdisabled={loading || backendDisabled}\n\t\t\t\t\t\t\t\t\t\t\t\tonToggle={(newEnabled) =>\n\t\t\t\t\t\t\t\t\t\t\t\t\thandleTogglePanel(feature, newEnabled)\n\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\tariaLabel={panelLabel}\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</SettingsSection>\n\t\t\t\t\t</CollapsibleSection>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t</SettingsSection>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/settings/components/RecorderConfigSection.tsx",
    "content": "\"use client\";\n\nimport { X } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport { useEffect, useState } from \"react\";\nimport { useSaveConfig } from \"@/lib/query\";\nimport { toastError } from \"@/lib/toast\";\nimport { SettingsSection } from \"./SettingsSection\";\nimport { ToggleSwitch } from \"./ToggleSwitch\";\n\ninterface RecorderConfigSectionProps {\n\tconfig: Record<string, unknown> | undefined;\n\tloading?: boolean;\n}\n\n/**\n * 录制配置区块组件（应用黑名单设置）\n * 注：录制开关已移至定时任务管理中的\"屏幕录制\"任务\n */\nexport function RecorderConfigSection({\n\tconfig,\n\tloading = false,\n}: RecorderConfigSectionProps) {\n\tconst t = useTranslations(\"page.settings\");\n\tconst saveConfigMutation = useSaveConfig();\n\n\t// 黑名单配置状态\n\tconst [blacklistEnabled, setBlacklistEnabled] = useState(\n\t\t(config?.jobsRecorderParamsBlacklistEnabled as boolean) ?? false,\n\t);\n\tconst [blacklistApps, setBlacklistApps] = useState<string[]>(() => {\n\t\tconst apps = config?.jobsRecorderParamsBlacklistApps;\n\t\tif (Array.isArray(apps)) {\n\t\t\treturn apps as string[];\n\t\t}\n\t\tconst appsStr = String(apps || \"\");\n\t\tif (appsStr) {\n\t\t\treturn appsStr\n\t\t\t\t.split(\",\")\n\t\t\t\t.map((s: string) => s.trim())\n\t\t\t\t.filter((s: string) => s);\n\t\t}\n\t\treturn [];\n\t});\n\tconst [blacklistInput, setBlacklistInput] = useState(\"\");\n\n\tconst isLoading = loading || saveConfigMutation.isPending;\n\n\t// 当配置加载完成后，同步本地状态\n\tuseEffect(() => {\n\t\tif (config) {\n\t\t\t// 只在配置值存在时更新，避免覆盖用户正在编辑的值\n\t\t\tif (config.jobsRecorderParamsBlacklistEnabled !== undefined) {\n\t\t\t\tsetBlacklistEnabled(\n\t\t\t\t\t(config.jobsRecorderParamsBlacklistEnabled as boolean) ?? false,\n\t\t\t\t);\n\t\t\t}\n\t\t\tif (config.jobsRecorderParamsBlacklistApps !== undefined) {\n\t\t\t\tconst apps = config.jobsRecorderParamsBlacklistApps;\n\t\t\t\tif (Array.isArray(apps)) {\n\t\t\t\t\tsetBlacklistApps(apps as string[]);\n\t\t\t\t} else {\n\t\t\t\t\tconst appsStr = String(apps || \"\");\n\t\t\t\t\tif (appsStr) {\n\t\t\t\t\t\tsetBlacklistApps(\n\t\t\t\t\t\t\tappsStr\n\t\t\t\t\t\t\t\t.split(\",\")\n\t\t\t\t\t\t\t\t.map((s: string) => s.trim())\n\t\t\t\t\t\t\t\t.filter((s: string) => s),\n\t\t\t\t\t\t);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tsetBlacklistApps([]);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}, [config]);\n\n\t// 黑名单处理\n\tconst handleAddBlacklistApp = async (app: string) => {\n\t\tconst trimmedApp = app.trim();\n\t\tif (trimmedApp && !blacklistApps.includes(trimmedApp)) {\n\t\t\tconst newApps = [...blacklistApps, trimmedApp];\n\t\t\tsetBlacklistApps(newApps);\n\t\t\tsetBlacklistInput(\"\");\n\t\t\ttry {\n\t\t\t\tawait saveConfigMutation.mutateAsync({\n\t\t\t\t\tdata: {\n\t\t\t\t\t\tjobsRecorderParamsBlacklistApps: newApps,\n\t\t\t\t\t},\n\t\t\t\t});\n\t\t\t} catch (error) {\n\t\t\t\tsetBlacklistApps(blacklistApps);\n\t\t\t\tconsole.error(\"保存黑名单失败:\", error);\n\t\t\t}\n\t\t}\n\t};\n\n\tconst handleRemoveBlacklistApp = async (app: string) => {\n\t\tconst newApps = blacklistApps.filter((a) => a !== app);\n\t\tconst oldApps = blacklistApps;\n\t\tsetBlacklistApps(newApps);\n\t\ttry {\n\t\t\tawait saveConfigMutation.mutateAsync({\n\t\t\t\tdata: {\n\t\t\t\t\tjobsRecorderParamsBlacklistApps: newApps,\n\t\t\t\t},\n\t\t\t});\n\t\t} catch (error) {\n\t\t\tsetBlacklistApps(oldApps);\n\t\t\tconsole.error(\"保存黑名单失败:\", error);\n\t\t}\n\t};\n\n\tconst handleBlacklistKeyDown = async (\n\t\te: React.KeyboardEvent<HTMLInputElement>,\n\t) => {\n\t\tif (e.key === \"Enter\" && blacklistInput.trim()) {\n\t\t\te.preventDefault();\n\t\t\tawait handleAddBlacklistApp(blacklistInput);\n\t\t} else if (\n\t\t\te.key === \"Backspace\" &&\n\t\t\t!blacklistInput &&\n\t\t\tblacklistApps.length > 0\n\t\t) {\n\t\t\tconst lastApp = blacklistApps[blacklistApps.length - 1];\n\t\t\tawait handleRemoveBlacklistApp(lastApp);\n\t\t}\n\t};\n\n\tconst handleToggleBlacklist = async (newValue: boolean) => {\n\t\tsetBlacklistEnabled(newValue);\n\t\ttry {\n\t\t\tawait saveConfigMutation.mutateAsync({\n\t\t\t\tdata: {\n\t\t\t\t\tjobsRecorderParamsBlacklistEnabled: newValue,\n\t\t\t\t},\n\t\t\t});\n\t\t} catch (error) {\n\t\t\tsetBlacklistEnabled(blacklistEnabled);\n\t\t\tconsole.error(\"保存黑名单设置失败:\", error);\n\t\t\tconst errorMsg = error instanceof Error ? error.message : String(error);\n\t\t\ttoastError(t(\"saveFailed\", { error: errorMsg }));\n\t\t}\n\t};\n\n\treturn (\n\t\t<SettingsSection title={t(\"basicSettings\")}>\n\t\t\t<div className=\"space-y-4\">\n\t\t\t\t{/* 启用黑名单 */}\n\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t<div className=\"flex-1\">\n\t\t\t\t\t\t<p className=\"text-sm font-medium text-foreground\">\n\t\t\t\t\t\t\t{t(\"enableBlacklist\")}\n\t\t\t\t\t\t</p>\n\t\t\t\t\t\t<p className=\"mt-0.5 text-xs text-muted-foreground\">\n\t\t\t\t\t\t\t{t(\"enableBlacklistDesc\")}\n\t\t\t\t\t\t</p>\n\t\t\t\t\t</div>\n\t\t\t\t\t<ToggleSwitch\n\t\t\t\t\t\tenabled={blacklistEnabled}\n\t\t\t\t\t\tdisabled={isLoading}\n\t\t\t\t\t\tonToggle={handleToggleBlacklist}\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\n\t\t\t\t{/* 应用黑名单列表 */}\n\t\t\t\t{blacklistEnabled && (\n\t\t\t\t\t<div className=\"pl-4 border-l-2 border-border\">\n\t\t\t\t\t\t<label\n\t\t\t\t\t\t\thtmlFor=\"blacklist-input\"\n\t\t\t\t\t\t\tclassName=\"mb-1 block text-sm font-medium text-foreground\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{t(\"appBlacklist\")}\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<div className=\"min-h-[38px] flex flex-wrap gap-1.5 items-center rounded-md border border-input bg-background px-2 py-1.5 focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 transition-all\">\n\t\t\t\t\t\t\t{blacklistApps.map((app) => (\n\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\tkey={app}\n\t\t\t\t\t\t\t\t\tclassName=\"inline-flex items-center gap-1 px-2 py-0.5 text-sm bg-primary/10 text-primary rounded-md border border-primary/20\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{app}\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\tonClick={() => handleRemoveBlacklistApp(app)}\n\t\t\t\t\t\t\t\t\t\tclassName=\"hover:bg-primary/20 rounded-full p-0.5 transition-colors\"\n\t\t\t\t\t\t\t\t\t\taria-label={`删除 ${app}`}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<X className=\"h-3 w-3\" />\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\tid=\"blacklist-input\"\n\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\tclassName=\"flex-1 min-w-[120px] outline-none bg-transparent text-sm placeholder:text-muted-foreground px-1\"\n\t\t\t\t\t\t\t\tplaceholder={t(\"blacklistPlaceholder\")}\n\t\t\t\t\t\t\t\tvalue={blacklistInput}\n\t\t\t\t\t\t\t\tonChange={(e) => setBlacklistInput(e.target.value)}\n\t\t\t\t\t\t\t\tonKeyDown={handleBlacklistKeyDown}\n\t\t\t\t\t\t\t\tdisabled={isLoading}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<p className=\"mt-1 text-xs text-muted-foreground\">\n\t\t\t\t\t\t\t{t(\"blacklistDesc\")}\n\t\t\t\t\t\t</p>\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t</SettingsSection>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/settings/components/SchedulerSection.tsx",
    "content": "\"use client\";\n\nimport { useQueryClient } from \"@tanstack/react-query\";\nimport { Check, Clock, Edit2, Pause, Play, RefreshCw, X } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport { useState } from \"react\";\nimport { unwrapApiData } from \"@/lib/api/fetcher\";\nimport {\n\tgetGetAllJobsApiSchedulerJobsGetQueryKey,\n\tgetGetSchedulerStatusApiSchedulerStatusGetQueryKey,\n\tuseGetAllJobsApiSchedulerJobsGet,\n\tuseGetSchedulerStatusApiSchedulerStatusGet,\n\tusePauseAllJobsApiSchedulerJobsPauseAllPost,\n\tusePauseJobApiSchedulerJobsJobIdPausePost,\n\tuseResumeAllJobsApiSchedulerJobsResumeAllPost,\n\tuseResumeJobApiSchedulerJobsJobIdResumePost,\n\tuseUpdateJobIntervalApiSchedulerJobsJobIdIntervalPut,\n} from \"@/lib/generated/scheduler/scheduler\";\nimport type { JobInfo, JobListResponse } from \"@/lib/generated/schemas\";\nimport { toastError, toastSuccess } from \"@/lib/toast\";\nimport { SettingsSection } from \"./SettingsSection\";\n\n// Legacy 任务列表\nconst LEGACY_JOB_IDS = [\"task_context_mapper_job\", \"task_summary_job\"];\n\ninterface SchedulerSectionProps {\n\tloading?: boolean;\n}\n\n/**\n * 调度器管理设置区块\n */\nexport function SchedulerSection({ loading = false }: SchedulerSectionProps) {\n\tconst t = useTranslations(\"scheduler\");\n\tconst queryClient = useQueryClient();\n\tconst [editingJobId, setEditingJobId] = useState<string | null>(null);\n\tconst [editInterval, setEditInterval] = useState({\n\t\thours: 0,\n\t\tminutes: 0,\n\t\tseconds: 0,\n\t});\n\tconst [showLegacy, setShowLegacy] = useState(false);\n\n\t// 获取任务列表和状态\n\tconst { data: jobsData, isLoading: jobsLoading } =\n\t\tuseGetAllJobsApiSchedulerJobsGet({\n\t\t\tquery: {\n\t\t\t\trefetchInterval: 10000, // 每10秒刷新一次\n\t\t\t},\n\t\t});\n\n\tconst { data: statusData, isLoading: statusLoading } =\n\t\tuseGetSchedulerStatusApiSchedulerStatusGet({\n\t\t\tquery: {\n\t\t\t\trefetchInterval: 10000,\n\t\t\t},\n\t\t});\n\n\t// 操作 mutations\n\tconst pauseJobMutation = usePauseJobApiSchedulerJobsJobIdPausePost();\n\tconst resumeJobMutation = useResumeJobApiSchedulerJobsJobIdResumePost();\n\tconst pauseAllMutation = usePauseAllJobsApiSchedulerJobsPauseAllPost();\n\tconst resumeAllMutation = useResumeAllJobsApiSchedulerJobsResumeAllPost();\n\tconst updateIntervalMutation =\n\t\tuseUpdateJobIntervalApiSchedulerJobsJobIdIntervalPut();\n\n\tconst isLoading =\n\t\tloading ||\n\t\tjobsLoading ||\n\t\tstatusLoading ||\n\t\tpauseJobMutation.isPending ||\n\t\tresumeJobMutation.isPending ||\n\t\tpauseAllMutation.isPending ||\n\t\tresumeAllMutation.isPending ||\n\t\tupdateIntervalMutation.isPending;\n\n\t// 刷新数据\n\tconst handleRefresh = () => {\n\t\tqueryClient.invalidateQueries({\n\t\t\tqueryKey: getGetAllJobsApiSchedulerJobsGetQueryKey(),\n\t\t});\n\t\tqueryClient.invalidateQueries({\n\t\t\tqueryKey: getGetSchedulerStatusApiSchedulerStatusGetQueryKey(),\n\t\t});\n\t};\n\n\t// 暂停单个任务\n\tconst handlePauseJob = async (jobId: string) => {\n\t\ttry {\n\t\t\tawait pauseJobMutation.mutateAsync({ jobId });\n\t\t\ttoastSuccess(t(\"jobPaused\", { job: getJobName(jobId) }));\n\t\t\thandleRefresh();\n\t\t} catch (error) {\n\t\t\tconst msg = error instanceof Error ? error.message : String(error);\n\t\t\ttoastError(t(\"pauseFailed\", { error: msg }));\n\t\t}\n\t};\n\n\t// 恢复单个任务\n\tconst handleResumeJob = async (jobId: string) => {\n\t\ttry {\n\t\t\tawait resumeJobMutation.mutateAsync({ jobId });\n\t\t\ttoastSuccess(t(\"jobResumed\", { job: getJobName(jobId) }));\n\t\t\thandleRefresh();\n\t\t} catch (error) {\n\t\t\tconst msg = error instanceof Error ? error.message : String(error);\n\t\t\ttoastError(t(\"resumeFailed\", { error: msg }));\n\t\t}\n\t};\n\n\t// 暂停所有任务（不包括 legacy）\n\tconst handlePauseAll = async () => {\n\t\ttry {\n\t\t\tawait pauseAllMutation.mutateAsync();\n\t\t\ttoastSuccess(t(\"allJobsPaused\"));\n\t\t\thandleRefresh();\n\t\t} catch (error) {\n\t\t\tconst msg = error instanceof Error ? error.message : String(error);\n\t\t\ttoastError(t(\"pauseFailed\", { error: msg }));\n\t\t}\n\t};\n\n\t// 恢复所有任务（不包括 legacy）\n\tconst handleResumeAll = async () => {\n\t\ttry {\n\t\t\tawait resumeAllMutation.mutateAsync();\n\t\t\ttoastSuccess(t(\"allJobsResumed\"));\n\t\t\thandleRefresh();\n\t\t} catch (error) {\n\t\t\tconst msg = error instanceof Error ? error.message : String(error);\n\t\t\ttoastError(t(\"resumeFailed\", { error: msg }));\n\t\t}\n\t};\n\n\t// 开始编辑间隔\n\tconst handleStartEditInterval = (jobId: string, trigger: string) => {\n\t\tconst parsed = parseIntervalToNumbers(trigger);\n\t\tsetEditInterval(parsed);\n\t\tsetEditingJobId(jobId);\n\t};\n\n\t// 取消编辑\n\tconst handleCancelEdit = () => {\n\t\tsetEditingJobId(null);\n\t\tsetEditInterval({ hours: 0, minutes: 0, seconds: 0 });\n\t};\n\n\t// 保存间隔\n\tconst handleSaveInterval = async (jobId: string) => {\n\t\tconst { hours, minutes, seconds } = editInterval;\n\n\t\t// 验证至少有一个值\n\t\tif (hours === 0 && minutes === 0 && seconds === 0) {\n\t\t\ttoastError(t(\"intervalCannotBeZero\"));\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\tawait updateIntervalMutation.mutateAsync({\n\t\t\t\tjobId,\n\t\t\t\tdata: {\n\t\t\t\t\tjob_id: jobId,\n\t\t\t\t\thours: hours > 0 ? hours : undefined,\n\t\t\t\t\tminutes: minutes > 0 ? minutes : undefined,\n\t\t\t\t\tseconds: seconds > 0 ? seconds : undefined,\n\t\t\t\t},\n\t\t\t});\n\t\t\ttoastSuccess(t(\"intervalUpdated\", { job: getJobName(jobId) }));\n\t\t\thandleCancelEdit();\n\t\t\thandleRefresh();\n\t\t} catch (error) {\n\t\t\tconst msg = error instanceof Error ? error.message : String(error);\n\t\t\ttoastError(t(\"updateFailed\", { error: msg }));\n\t\t}\n\t};\n\n\t// 获取任务显示名称\n\tconst getJobName = (jobId: string) => {\n\t\ttry {\n\t\t\treturn t(`jobs.${jobId}` as Parameters<typeof t>[0]);\n\t\t} catch {\n\t\t\treturn jobId;\n\t\t}\n\t};\n\n\t// 获取任务描述\n\tconst getJobDescription = (jobId: string) => {\n\t\ttry {\n\t\t\treturn t(`jobDescriptions.${jobId}` as Parameters<typeof t>[0]);\n\t\t} catch {\n\t\t\treturn \"\";\n\t\t}\n\t};\n\n\t// 检查是否为 legacy 任务\n\tconst isLegacyJob = (jobId: string) => {\n\t\treturn LEGACY_JOB_IDS.includes(jobId);\n\t};\n\n\t// 格式化下次运行时间\n\tconst formatNextRunTime = (nextRunTime: string | null) => {\n\t\tif (!nextRunTime) {\n\t\t\treturn t(\"paused\");\n\t\t}\n\t\tconst dateLocale = t(\"dateLocale\");\n\t\tconst date = new Date(nextRunTime);\n\t\treturn date.toLocaleString(dateLocale, {\n\t\t\tmonth: \"short\",\n\t\t\tday: \"numeric\",\n\t\t\thour: \"2-digit\",\n\t\t\tminute: \"2-digit\",\n\t\t\tsecond: \"2-digit\",\n\t\t});\n\t};\n\n\t// 解析 trigger 字符串获取间隔文字\n\tconst parseInterval = (trigger: string) => {\n\t\tconst match = trigger.match(/interval\\[(\\d+):(\\d+):(\\d+)\\]/);\n\t\tif (match) {\n\t\t\tconst hours = parseInt(match[1], 10);\n\t\t\tconst minutes = parseInt(match[2], 10);\n\t\t\tconst seconds = parseInt(match[3], 10);\n\t\t\tconst parts: string[] = [];\n\t\t\tif (hours > 0) parts.push(`${hours}${t(\"hour\")}`);\n\t\t\tif (minutes > 0) parts.push(`${minutes}${t(\"minute\")}`);\n\t\t\tif (seconds > 0) parts.push(`${seconds}${t(\"second\")}`);\n\t\t\treturn parts.join(\" \") || trigger;\n\t\t}\n\t\treturn trigger;\n\t};\n\n\t// 解析 trigger 字符串获取间隔数值\n\tconst parseIntervalToNumbers = (trigger: string) => {\n\t\tconst match = trigger.match(/interval\\[(\\d+):(\\d+):(\\d+)\\]/);\n\t\tif (match) {\n\t\t\treturn {\n\t\t\t\thours: parseInt(match[1], 10),\n\t\t\t\tminutes: parseInt(match[2], 10),\n\t\t\t\tseconds: parseInt(match[3], 10),\n\t\t\t};\n\t\t}\n\t\treturn { hours: 0, minutes: 0, seconds: 10 };\n\t};\n\n\tconst status = unwrapApiData<{\n\t\trunning?: boolean;\n\t\ttotalJobs?: number;\n\t\trunningJobs?: number;\n\t\tpausedJobs?: number;\n\t}>(statusData);\n\tconst jobsResponse = unwrapApiData<JobListResponse>(jobsData);\n\tconst allJobs = jobsResponse?.jobs || [];\n\n\t// 分离活跃任务和 legacy 任务\n\tconst activeJobs = allJobs.filter((job) => !isLegacyJob(job.id));\n\tconst legacyJobs = allJobs.filter((job) => isLegacyJob(job.id));\n\n\t// 渲染单个任务项\n\tconst renderJobItem = (job: JobInfo, isLegacy = false) => {\n\t\tconst isRunning = job.pending ?? false;\n\t\tconst isEditing = editingJobId === job.id;\n\n\t\treturn (\n\t\t\t<div\n\t\t\t\tkey={job.id}\n\t\t\t\tclassName={`rounded-md border px-3 py-2 ${\n\t\t\t\t\tisLegacy\n\t\t\t\t\t\t? \"border-border/50 bg-muted/30 opacity-70\"\n\t\t\t\t\t\t: \"border-border bg-background/50\"\n\t\t\t\t}`}\n\t\t\t>\n\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t<div className=\"flex items-center gap-3 flex-1 min-w-0\">\n\t\t\t\t\t\t<span\n\t\t\t\t\t\t\tclassName={`h-2 w-2 rounded-full shrink-0 ${\n\t\t\t\t\t\t\t\tisRunning ? \"bg-green-500\" : \"bg-yellow-500\"\n\t\t\t\t\t\t\t}`}\n\t\t\t\t\t\t\ttitle={isRunning ? t(\"running\") : t(\"paused\")}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<div className=\"min-w-0 flex-1\">\n\t\t\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t\t\t<p className=\"text-sm font-medium text-foreground truncate\">\n\t\t\t\t\t\t\t\t\t{getJobName(job.id)}\n\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t{isLegacy && (\n\t\t\t\t\t\t\t\t\t<span className=\"shrink-0 px-1.5 py-0.5 text-[10px] font-medium rounded bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400\">\n\t\t\t\t\t\t\t\t\t\tLegacy\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<p className=\"text-xs text-muted-foreground truncate\" title={job.id === \"audio_recording_job\" ? \"此任务的间隔是状态检查间隔（用于监控录音状态），不是录音间隔。实际录音由前端WebSocket持续控制，不受此间隔影响。\" : undefined}>\n\t\t\t\t\t\t\t\t{getJobDescription(job.id)}\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tonClick={() =>\n\t\t\t\t\t\t\tisRunning ? handlePauseJob(job.id) : handleResumeJob(job.id)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tdisabled={isLoading}\n\t\t\t\t\t\tclassName={`shrink-0 inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium transition-colors disabled:opacity-50 ${\n\t\t\t\t\t\t\tisRunning\n\t\t\t\t\t\t\t\t? \"bg-yellow-100 text-yellow-800 hover:bg-yellow-200 dark:bg-yellow-900/30 dark:text-yellow-400\"\n\t\t\t\t\t\t\t\t: \"bg-green-100 text-green-800 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400\"\n\t\t\t\t\t\t}`}\n\t\t\t\t\t>\n\t\t\t\t\t\t{isRunning ? (\n\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t<Pause className=\"h-3 w-3\" />\n\t\t\t\t\t\t\t\t{t(\"pause\")}\n\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t<Play className=\"h-3 w-3\" />\n\t\t\t\t\t\t\t\t{t(\"resume\")}\n\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</button>\n\t\t\t\t</div>\n\n\t\t\t\t{/* 间隔配置行 */}\n\t\t\t\t<div className=\"mt-2 flex items-center gap-2 text-xs text-muted-foreground\">\n\t\t\t\t\t<Clock className=\"h-3 w-3 shrink-0\" />\n\t\t\t\t\t{isEditing ? (\n\t\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t\t<div className=\"flex items-center gap-1\">\n\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\t\tmin=\"0\"\n\t\t\t\t\t\t\t\t\tmax=\"23\"\n\t\t\t\t\t\t\t\t\tvalue={editInterval.hours}\n\t\t\t\t\t\t\t\t\tonChange={(e) =>\n\t\t\t\t\t\t\t\t\t\tsetEditInterval((prev) => ({\n\t\t\t\t\t\t\t\t\t\t\t...prev,\n\t\t\t\t\t\t\t\t\t\t\thours: parseInt(e.target.value, 10) || 0,\n\t\t\t\t\t\t\t\t\t\t}))\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\tclassName=\"w-12 rounded border border-input bg-background px-1 py-0.5 text-xs text-center\"\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t<span>{t(\"hour\")}</span>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div className=\"flex items-center gap-1\">\n\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\t\tmin=\"0\"\n\t\t\t\t\t\t\t\t\tmax=\"59\"\n\t\t\t\t\t\t\t\t\tvalue={editInterval.minutes}\n\t\t\t\t\t\t\t\t\tonChange={(e) =>\n\t\t\t\t\t\t\t\t\t\tsetEditInterval((prev) => ({\n\t\t\t\t\t\t\t\t\t\t\t...prev,\n\t\t\t\t\t\t\t\t\t\t\tminutes: parseInt(e.target.value, 10) || 0,\n\t\t\t\t\t\t\t\t\t\t}))\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\tclassName=\"w-12 rounded border border-input bg-background px-1 py-0.5 text-xs text-center\"\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t<span>{t(\"minute\")}</span>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div className=\"flex items-center gap-1\">\n\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\t\tmin=\"0\"\n\t\t\t\t\t\t\t\t\tmax=\"59\"\n\t\t\t\t\t\t\t\t\tvalue={editInterval.seconds}\n\t\t\t\t\t\t\t\t\tonChange={(e) =>\n\t\t\t\t\t\t\t\t\t\tsetEditInterval((prev) => ({\n\t\t\t\t\t\t\t\t\t\t\t...prev,\n\t\t\t\t\t\t\t\t\t\t\tseconds: parseInt(e.target.value, 10) || 0,\n\t\t\t\t\t\t\t\t\t\t}))\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\tclassName=\"w-12 rounded border border-input bg-background px-1 py-0.5 text-xs text-center\"\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t<span>{t(\"second\")}</span>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\tonClick={() => handleSaveInterval(job.id)}\n\t\t\t\t\t\t\t\tdisabled={isLoading}\n\t\t\t\t\t\t\t\tclassName=\"p-1 rounded hover:bg-accent text-green-600\"\n\t\t\t\t\t\t\t\ttitle={t(\"save\")}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<Check className=\"h-3 w-3\" />\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\tonClick={handleCancelEdit}\n\t\t\t\t\t\t\t\tclassName=\"p-1 rounded hover:bg-accent text-red-600\"\n\t\t\t\t\t\t\t\ttitle={t(\"cancel\")}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<X className=\"h-3 w-3\" />\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t) : (\n\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t<span>\n\t\t\t\t\t\t\t\t{t(\"interval\")}: {parseInterval(job.trigger)}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\tonClick={() => handleStartEditInterval(job.id, job.trigger)}\n\t\t\t\t\t\t\t\tdisabled={isLoading}\n\t\t\t\t\t\t\t\tclassName=\"p-0.5 rounded hover:bg-accent\"\n\t\t\t\t\t\t\t\ttitle={t(\"editInterval\")}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<Edit2 className=\"h-3 w-3\" />\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t<span className=\"mx-1\">•</span>\n\t\t\t\t\t\t\t<span>\n\t\t\t\t\t\t\t\t{t(\"next\")}: {formatNextRunTime(job.next_run_time ?? null)}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t</>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t);\n\t};\n\n\treturn (\n\t\t<SettingsSection title={t(\"title\")} description={t(\"description\")}>\n\t\t\t{/* 状态概览 */}\n\t\t\t<div className=\"mb-4 flex items-center justify-between\">\n\t\t\t\t<div className=\"flex items-center gap-4 text-sm text-muted-foreground\">\n\t\t\t\t\t<span className=\"flex items-center gap-1\">\n\t\t\t\t\t\t<span\n\t\t\t\t\t\t\tclassName={`h-2 w-2 rounded-full ${\n\t\t\t\t\t\t\t\tstatus?.running ? \"bg-green-500\" : \"bg-red-500\"\n\t\t\t\t\t\t\t}`}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t{status?.running ? t(\"schedulerRunning\") : t(\"schedulerStopped\")}\n\t\t\t\t\t</span>\n\t\t\t\t\t<span>\n\t\t\t\t\t\t{t(\"runningCount\", {\n\t\t\t\t\t\t\trunning: status?.runningJobs || 0,\n\t\t\t\t\t\t\tpaused: status?.pausedJobs || 0,\n\t\t\t\t\t\t})}\n\t\t\t\t\t</span>\n\t\t\t\t</div>\n\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tonClick={handleRefresh}\n\t\t\t\t\t\tdisabled={isLoading}\n\t\t\t\t\t\tclassName=\"inline-flex items-center gap-1 rounded-md border border-input bg-background px-2 py-1 text-xs font-medium transition-colors hover:bg-accent disabled:opacity-50\"\n\t\t\t\t\t\ttitle={t(\"refresh\")}\n\t\t\t\t\t>\n\t\t\t\t\t\t<RefreshCw\n\t\t\t\t\t\t\tclassName={`h-3 w-3 ${isLoading ? \"animate-spin\" : \"\"}`}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</button>\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tonClick={handlePauseAll}\n\t\t\t\t\t\tdisabled={isLoading}\n\t\t\t\t\t\tclassName=\"inline-flex items-center gap-1 rounded-md border border-input bg-background px-2 py-1 text-xs font-medium transition-colors hover:bg-accent disabled:opacity-50\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<Pause className=\"h-3 w-3\" />\n\t\t\t\t\t\t{t(\"pauseAll\")}\n\t\t\t\t\t</button>\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tonClick={handleResumeAll}\n\t\t\t\t\t\tdisabled={isLoading}\n\t\t\t\t\t\tclassName=\"inline-flex items-center gap-1 rounded-md border border-input bg-background px-2 py-1 text-xs font-medium transition-colors hover:bg-accent disabled:opacity-50\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<Play className=\"h-3 w-3\" />\n\t\t\t\t\t\t{t(\"resumeAll\")}\n\t\t\t\t\t</button>\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t{/* 活跃任务列表 */}\n\t\t\t<div className=\"space-y-2\">\n\t\t\t\t{activeJobs.map((job) => renderJobItem(job))}\n\n\t\t\t\t{activeJobs.length === 0 && !jobsLoading && (\n\t\t\t\t\t<div className=\"py-4 text-center text-sm text-muted-foreground\">\n\t\t\t\t\t\t{t(\"noJobs\")}\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\n\t\t\t\t{jobsLoading && (\n\t\t\t\t\t<div className=\"py-4 text-center text-sm text-muted-foreground\">\n\t\t\t\t\t\t{t(\"loading\")}\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\n\t\t\t{/* Legacy 任务区域 */}\n\t\t\t{legacyJobs.length > 0 && (\n\t\t\t\t<div className=\"mt-4 pt-4 border-t border-border\">\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tonClick={() => setShowLegacy(!showLegacy)}\n\t\t\t\t\t\tclassName=\"flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<span\n\t\t\t\t\t\t\tclassName={`transition-transform ${showLegacy ? \"rotate-90\" : \"\"}`}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t▶\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t{t(\"legacyJobs\")} ({legacyJobs.length})\n\t\t\t\t\t\t<span className=\"text-[10px] px-1.5 py-0.5 rounded bg-muted\">\n\t\t\t\t\t\t\t{t(\"legacyNotNeeded\")}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t</button>\n\n\t\t\t\t\t{showLegacy && (\n\t\t\t\t\t\t<div className=\"mt-2 space-y-2\">\n\t\t\t\t\t\t\t{legacyJobs.map((job) => renderJobItem(job, true))}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</SettingsSection>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/settings/components/SettingsCategoryPanel.tsx",
    "content": "\"use client\";\n\nimport type { LucideIcon } from \"lucide-react\";\nimport { type ReactNode, useCallback, useEffect, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { SettingsSearchMatchProvider } from \"./SettingsSection\";\n\nexport type SettingsCategoryId =\n\t| \"workspace\"\n\t| \"automation\"\n\t| \"ai\"\n\t| \"developer\"\n\t| \"help\";\n\nexport interface SettingsCategory {\n\tid: SettingsCategoryId;\n\tlabel: string;\n\tdescription: string;\n\ticon: LucideIcon;\n}\n\ninterface SettingsCategoryPanelProps {\n\tcategory: SettingsCategory;\n\tisSearchActive: boolean;\n\tactiveCategory: SettingsCategoryId;\n\trenderCategoryContent: (categoryId: SettingsCategoryId) => ReactNode;\n\tonMatchChange: (categoryId: SettingsCategoryId, hasMatches: boolean) => void;\n}\n\nexport function SettingsCategoryPanel({\n\tcategory,\n\tisSearchActive,\n\tactiveCategory,\n\trenderCategoryContent,\n\tonMatchChange,\n}: SettingsCategoryPanelProps) {\n\tconst [matchedSectionIds, setMatchedSectionIds] = useState<\n\t\tRecord<string, boolean>\n\t>({});\n\n\tconst handleMatchChange = useCallback(\n\t\t(id: string, isMatch: boolean) => {\n\t\t\tsetMatchedSectionIds((prev) => {\n\t\t\t\tconst hasMatch = Boolean(prev[id]);\n\t\t\t\tif (hasMatch === isMatch) return prev;\n\t\t\t\tconst next = { ...prev };\n\t\t\t\tif (isMatch) {\n\t\t\t\t\tnext[id] = true;\n\t\t\t\t} else {\n\t\t\t\t\tdelete next[id];\n\t\t\t\t}\n\t\t\t\treturn next;\n\t\t\t});\n\t\t},\n\t\t[],\n\t);\n\n\tconst hasMatches = Object.keys(matchedSectionIds).length > 0;\n\n\tuseEffect(() => {\n\t\tonMatchChange(category.id, hasMatches);\n\t}, [category.id, hasMatches, onMatchChange]);\n\n\tconst isActive = isSearchActive ? hasMatches : category.id === activeCategory;\n\tconst Icon = category.icon;\n\n\tconst labelId = `settings-category-label-${category.id}`;\n\n\tconst content = (\n\t\t<>\n\t\t\t{(!isSearchActive || hasMatches) && (\n\t\t\t\t<div className=\"rounded-lg border border-border/60 bg-muted/30 p-4\">\n\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t<Icon className=\"h-4 w-4 text-primary\" />\n\t\t\t\t\t\t<h3\n\t\t\t\t\t\t\tid={labelId}\n\t\t\t\t\t\t\tclassName=\"text-sm font-semibold text-foreground\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{category.label}\n\t\t\t\t\t\t</h3>\n\t\t\t\t\t</div>\n\t\t\t\t\t<p className=\"mt-1 text-sm text-muted-foreground\">\n\t\t\t\t\t\t{category.description}\n\t\t\t\t\t</p>\n\t\t\t\t</div>\n\t\t\t)}\n\t\t\t<SettingsSearchMatchProvider onMatchChange={handleMatchChange}>\n\t\t\t\t{renderCategoryContent(category.id)}\n\t\t\t</SettingsSearchMatchProvider>\n\t\t</>\n\t);\n\n\tif (isSearchActive) {\n\t\treturn (\n\t\t\t<div\n\t\t\t\tid={`settings-category-panel-${category.id}`}\n\t\t\t\thidden={!isActive}\n\t\t\t\tclassName={cn(\"space-y-6\", !isActive && \"hidden\")}\n\t\t\t>\n\t\t\t\t{content}\n\t\t\t</div>\n\t\t);\n\t}\n\n\treturn (\n\t\t<div\n\t\t\tid={`settings-category-panel-${category.id}`}\n\t\t\trole=\"tabpanel\"\n\t\t\taria-labelledby={`settings-category-tab-${category.id}`}\n\t\t\thidden={!isActive}\n\t\t\tclassName={cn(\"space-y-6\", !isActive && \"hidden\")}\n\t\t>\n\t\t\t{content}\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/settings/components/SettingsSearchAction.tsx",
    "content": "\"use client\";\n\nimport { Search } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport { useEffect, useRef, useState } from \"react\";\nimport {\n\tPanelActionButton,\n\tusePanelIconStyle,\n} from \"@/components/common/layout/PanelHeader\";\nimport { cn } from \"@/lib/utils\";\n\ninterface SettingsSearchActionProps {\n\tvalue: string;\n\tonChange: (value: string) => void;\n}\n\nexport function SettingsSearchAction({\n\tvalue,\n\tonChange,\n}: SettingsSearchActionProps) {\n\tconst tSettings = useTranslations(\"page.settings\");\n\tconst [isSearchOpen, setIsSearchOpen] = useState(false);\n\tconst searchInputRef = useRef<HTMLInputElement>(null);\n\tconst searchContainerRef = useRef<HTMLDivElement>(null);\n\tconst actionIconStyle = usePanelIconStyle(\"action\");\n\n\tuseEffect(() => {\n\t\tif (isSearchOpen && searchInputRef.current) {\n\t\t\tsearchInputRef.current.focus();\n\t\t}\n\t}, [isSearchOpen]);\n\n\tuseEffect(() => {\n\t\tconst handleClickOutside = (event: MouseEvent) => {\n\t\t\tif (\n\t\t\t\tsearchContainerRef.current &&\n\t\t\t\t!searchContainerRef.current.contains(event.target as Node) &&\n\t\t\t\t!value\n\t\t\t) {\n\t\t\t\tsetIsSearchOpen(false);\n\t\t\t}\n\t\t};\n\n\t\tif (isSearchOpen) {\n\t\t\tdocument.addEventListener(\"mousedown\", handleClickOutside);\n\t\t\treturn () => {\n\t\t\t\tdocument.removeEventListener(\"mousedown\", handleClickOutside);\n\t\t\t};\n\t\t}\n\t}, [isSearchOpen, value]);\n\n\treturn (\n\t\t<div ref={searchContainerRef} className=\"relative\">\n\t\t\t{isSearchOpen ? (\n\t\t\t\t<div className=\"relative\">\n\t\t\t\t\t<Search\n\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\"absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground\",\n\t\t\t\t\t\t\tactionIconStyle,\n\t\t\t\t\t\t)}\n\t\t\t\t\t/>\n\t\t\t\t\t<input\n\t\t\t\t\t\tref={searchInputRef}\n\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\tvalue={value}\n\t\t\t\t\t\tonChange={(e) => onChange(e.target.value)}\n\t\t\t\t\t\tplaceholder={tSettings(\"searchPlaceholder\")}\n\t\t\t\t\t\tclassName=\"h-7 w-48 rounded-md border border-primary/20 px-8 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary\"\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\t\t\t) : (\n\t\t\t\t<PanelActionButton\n\t\t\t\t\tvariant=\"default\"\n\t\t\t\t\ticon={Search}\n\t\t\t\t\tonClick={() => setIsSearchOpen(true)}\n\t\t\t\t\ticonOverrides={{ color: \"text-muted-foreground\" }}\n\t\t\t\t\tbuttonOverrides={{ hoverTextColor: \"hover:text-foreground\" }}\n\t\t\t\t\taria-label={tSettings(\"searchPlaceholder\")}\n\t\t\t\t/>\n\t\t\t)}\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/settings/components/SettingsSection.tsx",
    "content": "\"use client\";\n\nimport {\n\tcreateContext,\n\ttype ReactNode,\n\tuseContext,\n\tuseEffect,\n\tuseId,\n} from \"react\";\n\nconst SettingsSearchContext = createContext<string>(\"\");\nconst SettingsSearchMatchContext = createContext<\n\t((id: string, isMatch: boolean) => void) | null\n>(null);\n\nexport function SettingsSearchProvider({\n\tquery = \"\",\n\tchildren,\n}: {\n\tquery?: string;\n\tchildren: ReactNode;\n}) {\n\treturn (\n\t\t<SettingsSearchContext.Provider value={query}>\n\t\t\t{children}\n\t\t</SettingsSearchContext.Provider>\n\t);\n}\n\nexport function SettingsSearchMatchProvider({\n\tonMatchChange,\n\tchildren,\n}: {\n\tonMatchChange: (id: string, isMatch: boolean) => void;\n\tchildren: ReactNode;\n}) {\n\treturn (\n\t\t<SettingsSearchMatchContext.Provider value={onMatchChange}>\n\t\t\t{children}\n\t\t</SettingsSearchMatchContext.Provider>\n\t);\n}\n\nconst normalizeSearchValue = (value: string) => value.toLowerCase().trim();\n\nconst doesSearchMatch = (\n\tquery: string,\n\tvalues: Array<string | undefined>,\n) => {\n\tconst normalizedQuery = normalizeSearchValue(query);\n\tif (!normalizedQuery) return true;\n\n\tconst haystack = values.filter(Boolean).join(\" \").toLowerCase();\n\tif (!haystack) return false;\n\n\tconst tokens = normalizedQuery.split(/\\s+/).filter(Boolean);\n\treturn tokens.every((token) => haystack.includes(token));\n};\n\ninterface SettingsSectionProps {\n\ttitle: string;\n\tdescription?: string;\n\tchildren: ReactNode;\n\tsearchKeywords?: Array<string | undefined>;\n}\n\n/**\n * 设置区块容器组件\n */\nexport function SettingsSection({\n\ttitle,\n\tdescription,\n\tchildren,\n\tsearchKeywords,\n}: SettingsSectionProps) {\n\tconst searchQuery = useContext(SettingsSearchContext);\n\tconst isSearchActive = normalizeSearchValue(searchQuery).length > 0;\n\tconst reportMatch = useContext(SettingsSearchMatchContext);\n\tconst sectionId = useId();\n\n\tconst isMatch = doesSearchMatch(searchQuery, [\n\t\ttitle,\n\t\tdescription,\n\t\t...(searchKeywords ?? []),\n\t]);\n\n\tuseEffect(() => {\n\t\tif (!reportMatch) return;\n\t\treportMatch(sectionId, isMatch);\n\t\treturn () => {\n\t\t\treportMatch(sectionId, false);\n\t\t};\n\t}, [reportMatch, sectionId, isMatch]);\n\n\tif (!isMatch) {\n\t\treturn null;\n\t}\n\n\treturn (\n\t\t<div\n\t\t\tclassName={\n\t\t\t\tisSearchActive\n\t\t\t\t\t? \"rounded-lg border border-primary/40 bg-primary/5 p-4 ring-1 ring-primary/20\"\n\t\t\t\t\t: \"rounded-lg border border-border p-4\"\n\t\t\t}\n\t\t>\n\t\t\t<div className=\"mb-4\">\n\t\t\t\t<h3 className=\"mb-1 text-base font-semibold text-foreground\">\n\t\t\t\t\t{title}\n\t\t\t\t</h3>\n\t\t\t\t{description && (\n\t\t\t\t\t<p className=\"text-sm text-muted-foreground\">{description}</p>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t\t{children}\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/settings/components/TavilyConfigSection.tsx",
    "content": "\"use client\";\n\nimport { Check, Loader2 } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport { useEffect, useState } from \"react\";\nimport { useSaveConfig } from \"@/lib/query\";\nimport type { AppConfig } from \"@/lib/query/config\";\nimport { toastError, toastSuccess } from \"@/lib/toast\";\nimport { cn } from \"@/lib/utils\";\nimport { SettingsSection } from \"./index\";\n\ninterface TavilyConfigSectionProps {\n\tconfig: AppConfig | undefined;\n\tloading?: boolean;\n}\n\n/**\n * Tavily 配置区块\n * - 通过 dynaconf 管理：tavily.api_key\n * - 前端字段映射：tavilyApiKey\n */\nexport function TavilyConfigSection({\n\tconfig,\n\tloading = false,\n}: TavilyConfigSectionProps) {\n\tconst t = useTranslations(\"page.settings\");\n\tconst saveConfigMutation = useSaveConfig();\n\n\tconst [apiKey, setApiKey] = useState<string>(\n\t\t(config?.tavilyApiKey as string | undefined) ?? \"\",\n\t);\n\tconst [savedApiKey, setSavedApiKey] = useState<string>(\n\t\t(config?.tavilyApiKey as string | undefined) ?? \"\",\n\t);\n\tconst [isVerifying, setIsVerifying] = useState(false);\n\tconst [verificationMessage, setVerificationMessage] = useState<{\n\t\ttype: \"success\" | \"error\";\n\t\ttext: string;\n\t} | null>(null);\n\n\tconst isSaving = loading || saveConfigMutation.isPending;\n\tconst hasChanges = apiKey !== savedApiKey;\n\n\t// 当配置加载完成后，同步本地状态\n\tuseEffect(() => {\n\t\tif (config) {\n\t\t\t// 只在配置值存在时更新，避免覆盖用户正在编辑的值\n\t\t\tif (config.tavilyApiKey !== undefined) {\n\t\t\t\tconst newApiKey = (config.tavilyApiKey as string) || \"\";\n\t\t\t\tsetApiKey(newApiKey);\n\t\t\t\tsetSavedApiKey(newApiKey);\n\t\t\t}\n\t\t}\n\t}, [config]);\n\n\tconst handleVerify = async (keyToVerify: string) => {\n\t\tif (!keyToVerify.trim()) {\n\t\t\tsetVerificationMessage({\n\t\t\t\ttype: \"error\",\n\t\t\t\ttext: t(\"apiKeyRequired\") || \"API Key 不能为空\",\n\t\t\t});\n\t\t\treturn;\n\t\t}\n\n\t\tsetIsVerifying(true);\n\t\tsetVerificationMessage(null);\n\n\t\ttry {\n\t\t\t// 客户端使用相对路径，通过 Next.js rewrites 代理到后端（支持动态端口）\n\t\t\tconst response = await fetch(`/api/test-tavily-config`, {\n\t\t\t\tmethod: \"POST\",\n\t\t\t\theaders: {\n\t\t\t\t\t\"Content-Type\": \"application/json\",\n\t\t\t\t},\n\t\t\t\tbody: JSON.stringify({\n\t\t\t\t\ttavilyApiKey: keyToVerify,\n\t\t\t\t}),\n\t\t\t});\n\n\t\t\tconst data = await response.json();\n\n\t\t\tif (data.success) {\n\t\t\t\tsetVerificationMessage({\n\t\t\t\t\ttype: \"success\",\n\t\t\t\t\ttext: t(\"testSuccess\") || \"✓ API 配置验证成功！\",\n\t\t\t\t});\n\t\t\t} else {\n\t\t\t\tsetVerificationMessage({\n\t\t\t\t\ttype: \"error\",\n\t\t\t\t\ttext: `${t(\"testFailed\") || \"✗ API 配置验证失败\"}: ${data.error || \"Unknown error\"}`,\n\t\t\t\t});\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tconsole.error(\"验证 Tavily 配置失败:\", error);\n\t\t\tconst errorMsg = error instanceof Error ? error.message : \"Network error\";\n\t\t\tsetVerificationMessage({\n\t\t\t\ttype: \"error\",\n\t\t\t\ttext: `${t(\"testFailed\") || \"✗ API 配置验证失败\"}: ${errorMsg}`,\n\t\t\t});\n\t\t} finally {\n\t\t\tsetIsVerifying(false);\n\t\t}\n\t};\n\n\tconst handleSave = async () => {\n\t\tif (!hasChanges) return;\n\n\t\ttry {\n\t\t\tconst payload = {\n\t\t\t\ttavilyApiKey: apiKey,\n\t\t\t};\n\n\t\t\tawait saveConfigMutation.mutateAsync({ data: payload });\n\t\t\tsetSavedApiKey(apiKey);\n\t\t\ttoastSuccess(t(\"tavilySaveSuccess\"));\n\n\t\t\t// 保存成功后立即验证\n\t\t\tif (apiKey.trim()) {\n\t\t\t\tawait handleVerify(apiKey);\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tconsole.error(\"保存 Tavily 配置失败:\", error);\n\t\t\tconst errorMsg = error instanceof Error ? error.message : String(error);\n\t\t\ttoastError(t(\"saveFailed\", { error: errorMsg }));\n\t\t}\n\t};\n\n\treturn (\n\t\t<SettingsSection title={t(\"tavilyConfigTitle\")}>\n\t\t\t<div className=\"space-y-4\">\n\t\t\t\t{/* 验证消息提示 */}\n\t\t\t\t{verificationMessage && (\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\"rounded-lg px-3 py-2 text-sm font-medium\",\n\t\t\t\t\t\t\tverificationMessage.type === \"success\"\n\t\t\t\t\t\t\t\t? \"bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400\"\n\t\t\t\t\t\t\t\t: \"bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400\",\n\t\t\t\t\t\t)}\n\t\t\t\t\t>\n\t\t\t\t\t\t{verificationMessage.text}\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\n\t\t\t\t{/* API Key */}\n\t\t\t\t<div className=\"space-y-1\">\n\t\t\t\t\t<label\n\t\t\t\t\t\thtmlFor=\"tavily-api-key\"\n\t\t\t\t\t\tclassName=\"block text-sm font-medium text-foreground\"\n\t\t\t\t\t>\n\t\t\t\t\t\t{t(\"apiKey\")}\n\t\t\t\t\t</label>\n\t\t\t\t\t<div className=\"flex gap-2\">\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\tid=\"tavily-api-key\"\n\t\t\t\t\t\t\ttype=\"password\"\n\t\t\t\t\t\t\tclassName=\"flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50\"\n\t\t\t\t\t\t\tplaceholder=\"Tavily API Key\"\n\t\t\t\t\t\t\tvalue={apiKey}\n\t\t\t\t\t\t\tonChange={(e) => {\n\t\t\t\t\t\t\t\tsetApiKey(e.target.value);\n\t\t\t\t\t\t\t\t// 清除之前的验证消息\n\t\t\t\t\t\t\t\tif (verificationMessage) {\n\t\t\t\t\t\t\t\t\tsetVerificationMessage(null);\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tonKeyDown={(e) => {\n\t\t\t\t\t\t\t\tif (e.key === \"Enter\" && hasChanges) {\n\t\t\t\t\t\t\t\t\tvoid handleSave();\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tdisabled={isSaving || isVerifying}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tonClick={() => void handleSave()}\n\t\t\t\t\t\t\tdisabled={isSaving || isVerifying || !hasChanges}\n\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\"flex items-center justify-center rounded-md border px-3 py-2 text-sm font-medium transition-colors shrink-0\",\n\t\t\t\t\t\t\t\t\"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n\t\t\t\t\t\t\t\thasChanges && !isSaving && !isVerifying\n\t\t\t\t\t\t\t\t\t? \"border-primary bg-primary text-primary-foreground hover:bg-primary/90\"\n\t\t\t\t\t\t\t\t\t: \"border-input bg-background text-muted-foreground cursor-not-allowed opacity-50\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\taria-label={t(\"save\") || \"Save\"}\n\t\t\t\t\t\t\ttitle={\n\t\t\t\t\t\t\t\thasChanges\n\t\t\t\t\t\t\t\t\t? t(\"save\") || \"Save\"\n\t\t\t\t\t\t\t\t\t: t(\"tavilySaveSuccess\") || \"Saved\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{isSaving || isVerifying ? (\n\t\t\t\t\t\t\t\t<Loader2 className=\"h-4 w-4 animate-spin\" />\n\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t<Check className=\"h-4 w-4\" />\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</div>\n\t\t\t\t\t<p className=\"mt-1 text-xs text-muted-foreground\">\n\t\t\t\t\t\t{t(\"tavilyApiKeyHint\")}{\" \"}\n\t\t\t\t\t\t<a\n\t\t\t\t\t\t\thref=\"https://app.tavily.com/\"\n\t\t\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t\t\t\trel=\"noopener noreferrer\"\n\t\t\t\t\t\t\tclassName=\"text-primary hover:underline\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{t(\"tavilyApiKeyLink\")}\n\t\t\t\t\t\t</a>\n\t\t\t\t\t</p>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</SettingsSection>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/settings/components/ToggleSwitch.tsx",
    "content": "\"use client\";\n\ninterface ToggleSwitchProps {\n\tid?: string;\n\tenabled: boolean;\n\tdisabled?: boolean;\n\tonToggle: (enabled: boolean) => void;\n\tariaLabel?: string;\n}\n\n/**\n * 通用开关组件\n */\nexport function ToggleSwitch({\n\tid,\n\tenabled,\n\tdisabled = false,\n\tonToggle,\n\tariaLabel,\n}: ToggleSwitchProps) {\n\treturn (\n\t\t<button\n\t\t\ttype=\"button\"\n\t\t\tid={id}\n\t\t\tdisabled={disabled}\n\t\t\tonClick={() => onToggle(!enabled)}\n\t\t\tclassName={`\n        relative inline-flex h-6 w-11 items-center rounded-full transition-colors\n        focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2\n        disabled:opacity-50 disabled:cursor-not-allowed\n        ${enabled ? \"bg-primary\" : \"bg-muted\"}\n      `}\n\t\t\taria-label={ariaLabel}\n\t\t>\n\t\t\t<span\n\t\t\t\tclassName={`\n          inline-block h-4 w-4 transform rounded-full bg-white transition-transform\n          ${enabled ? \"translate-x-6\" : \"translate-x-1\"}\n        `}\n\t\t\t/>\n\t\t</button>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/settings/components/VersionInfoSection.tsx",
    "content": "\"use client\";\n\nimport { useTranslations } from \"next-intl\";\n\n/**\n * 版本信息组件\n * 显示当前应用版本号、构建类型和 Git Commit\n */\nexport function VersionInfoSection() {\n\tconst t = useTranslations(\"page.settings\");\n\n\tconst version = process.env.NEXT_PUBLIC_APP_VERSION || \"unknown\";\n\tconst gitCommit = process.env.NEXT_PUBLIC_GIT_COMMIT || \"unknown\";\n\tconst buildType = process.env.NEXT_PUBLIC_BUILD_TYPE || \"unknown\";\n\n\t// 格式：版本号_版本类型_Git Commit\n\tconst versionString = `${version}_${buildType}_${gitCommit}`;\n\n\treturn (\n\t\t<div className=\"text-center text-sm text-muted-foreground\">\n\t\t\t<span>{t(\"currentVersion\")}：</span>\n\t\t\t<span className=\"font-mono\">{versionString}</span>\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/settings/components/index.ts",
    "content": "export { AudioAsrConfigSection } from \"./AudioAsrConfigSection\";\nexport { AudioConfigSection } from \"./AudioConfigSection\";\nexport { AutomationTasksSection } from \"./AutomationTasksSection\";\nexport { AutoTodoDetectionSection } from \"./AutoTodoDetectionSection\";\nexport { DifyConfigSection } from \"./DifyConfigSection\";\nexport { DockDisplayModeSection } from \"./DockDisplayModeSection\";\nexport { JournalSettingsSection } from \"./JournalSettingsSection\";\nexport { LlmConfigSection } from \"./LlmConfigSection\";\nexport { NotificationPermissionSection } from \"./NotificationPermissionSection\";\nexport { OnboardingSection } from \"./OnboardingSection\";\nexport { PanelSwitchesSection } from \"./PanelSwitchesSection\";\nexport { RecorderConfigSection } from \"./RecorderConfigSection\";\nexport { SchedulerSection } from \"./SchedulerSection\";\nexport {\n\ttype SettingsCategory,\n\ttype SettingsCategoryId,\n\tSettingsCategoryPanel,\n} from \"./SettingsCategoryPanel\";\nexport { SettingsSearchAction } from \"./SettingsSearchAction\";\nexport {\n\tSettingsSearchMatchProvider,\n\tSettingsSearchProvider,\n\tSettingsSection,\n} from \"./SettingsSection\";\nexport { TavilyConfigSection } from \"./TavilyConfigSection\";\nexport { ToggleSwitch } from \"./ToggleSwitch\";\nexport { VersionInfoSection } from \"./VersionInfoSection\";\n"
  },
  {
    "path": "free-todo-frontend/apps/settings/hooks/useSettingsSearchMatchStats.ts",
    "content": "\"use client\";\n\nimport { useCallback, useMemo, useState } from \"react\";\nimport type { SettingsCategoryId } from \"../components/SettingsCategoryPanel\";\n\ninterface UseSettingsSearchMatchStatsOptions {\n\tcategoriesCount: number;\n\tisSearchActive: boolean;\n}\n\nexport function useSettingsSearchMatchStats({\n\tcategoriesCount,\n\tisSearchActive,\n}: UseSettingsSearchMatchStatsOptions) {\n\tconst [matchesByCategory, setMatchesByCategory] = useState<\n\t\tPartial<Record<SettingsCategoryId, boolean>>\n\t>({});\n\n\tconst handleCategoryMatchChange = useCallback(\n\t\t(categoryId: SettingsCategoryId, hasMatches: boolean) => {\n\t\t\tsetMatchesByCategory((prev) => {\n\t\t\t\tif (prev[categoryId] === hasMatches) return prev;\n\t\t\t\treturn { ...prev, [categoryId]: hasMatches };\n\t\t\t});\n\t\t},\n\t\t[],\n\t);\n\n\tconst hasSearchMatches = useMemo(\n\t\t() => Object.values(matchesByCategory).some(Boolean),\n\t\t[matchesByCategory],\n\t);\n\tconst searchMatchesReady = useMemo(\n\t\t() => Object.keys(matchesByCategory).length === categoriesCount,\n\t\t[matchesByCategory, categoriesCount],\n\t);\n\tconst showNoResults =\n\t\tisSearchActive && searchMatchesReady && !hasSearchMatches;\n\n\treturn {\n\t\thandleCategoryMatchChange,\n\t\thasSearchMatches,\n\t\tsearchMatchesReady,\n\t\tshowNoResults,\n\t};\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/settings/index.ts",
    "content": "export { default as SettingsPanelDefault, SettingsPanel } from \"./SettingsPanel\";\n"
  },
  {
    "path": "free-todo-frontend/apps/todo-detail/TodoDetail.tsx",
    "content": "\"use client\";\n\nimport { useQueryClient } from \"@tanstack/react-query\";\nimport { useTranslations } from \"next-intl\";\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { removeTodoAttachment, uploadTodoAttachments } from \"@/lib/attachments\";\nimport { useTodoMutations, useTodos } from \"@/lib/query\";\nimport { queryKeys } from \"@/lib/query/keys\";\nimport { useTodoStore } from \"@/lib/store/todo-store\";\nimport { useUiStore } from \"@/lib/store/ui-store\";\nimport { getPositionByFeature } from \"@/lib/store/ui-store/utils\";\nimport { toastError } from \"@/lib/toast\";\nimport type { Todo, TodoAttachment } from \"@/lib/types\";\nimport { ArtifactsView } from \"./components/ArtifactsView\";\nimport { AttachmentPreviewPanel } from \"./components/AttachmentPreviewPanel\";\nimport { BackgroundSection } from \"./components/BackgroundSection\";\nimport { ChildTodoSection } from \"./components/ChildTodoSection\";\nimport { DetailHeader } from \"./components/DetailHeader\";\nimport { DetailTitle } from \"./components/DetailTitle\";\nimport { MetaSection } from \"./components/MetaSection\";\nimport { NotesEditor } from \"./components/NotesEditor\";\n\nconst collectChildIds = (parentId: number, allTodos: Todo[]): number[] => {\n\tconst childIds: number[] = [];\n\tconst children = allTodos.filter(\n\t\t(t: Todo) => t.parentTodoId === parentId,\n\t);\n\tfor (const child of children) {\n\t\tchildIds.push(child.id);\n\t\tchildIds.push(...collectChildIds(child.id, allTodos));\n\t}\n\treturn childIds;\n};\n\nexport function TodoDetail() {\n\tconst t = useTranslations(\"todoDetail\");\n\tconst queryClient = useQueryClient();\n\t// 从 TanStack Query 获取 todos 数据\n\tconst { data: todos = [] } = useTodos();\n\n\t// 从 TanStack Query 获取 mutation 操作\n\tconst { createTodo, updateTodo, deleteTodo, toggleTodoStatus } =\n\t\tuseTodoMutations();\n\n\t// 从 Zustand 获取 UI 状态\n\tconst { selectedTodoId, setSelectedTodoId, onTodoDeleted } = useTodoStore();\n\tconst { panelFeatureMap, isPanelAOpen, isPanelBOpen } = useUiStore();\n\n\t// 各 section 的折叠状态\n\tconst [showDescription, setShowDescription] = useState(false);\n\tconst [showNotes, setShowNotes] = useState(true);\n\tconst [showChildTodos, setShowChildTodos] = useState(true);\n\tconst [activeView, setActiveView] = useState<\"detail\" | \"artifacts\">(\n\t\t\"detail\",\n\t);\n\tconst [selectedAttachment, setSelectedAttachment] =\n\t\tuseState<TodoAttachment | null>(null);\n\tconst [showDeleteConfirm, setShowDeleteConfirm] = useState(false);\n\n\t// 本地状态管理 userNotes，用于即时输入响应\n\tconst [localUserNotes, setLocalUserNotes] = useState<string>(\"\");\n\tconst debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\tconst isUpdatingRef = useRef<boolean>(false);\n\tconst lastSyncedTodoIdRef = useRef<number | null>(null);\n\n\tconst todo = useMemo(\n\t\t() =>\n\t\t\tselectedTodoId ? todos.find((t: Todo) => t.id === selectedTodoId) : null,\n\t\t[selectedTodoId, todos],\n\t);\n\n\t// 只在 todo.id 变化时同步本地状态（切换 todo 时）\n\tuseEffect(() => {\n\t\tif (todo && todo.id !== lastSyncedTodoIdRef.current) {\n\t\t\t// 清理之前的防抖定时器\n\t\t\tif (debounceTimerRef.current) {\n\t\t\t\tclearTimeout(debounceTimerRef.current);\n\t\t\t\tdebounceTimerRef.current = null;\n\t\t\t}\n\t\t\tsetLocalUserNotes(todo.userNotes || \"\");\n\t\t\tlastSyncedTodoIdRef.current = todo.id;\n\t\t\tisUpdatingRef.current = false;\n\t\t}\n\t}, [todo, todo?.id, todo?.userNotes]);\n\n\tconst childTodos = useMemo(\n\t\t() =>\n\t\t\ttodo?.id\n\t\t\t\t? todos.filter((item: Todo) => item.parentTodoId === todo.id)\n\t\t\t\t: [],\n\t\t[todo?.id, todos],\n\t);\n\n\tconst childIds = useMemo(\n\t\t() => (todo?.id ? collectChildIds(todo.id, todos) : []),\n\t\t[todo?.id, todos],\n\t);\n\n\tuseEffect(() => {\n\t\tif (todo?.id == null) {\n\t\t\tsetSelectedAttachment(null);\n\t\t\treturn;\n\t\t}\n\t\tsetSelectedAttachment(null);\n\t}, [todo?.id]);\n\n\tuseEffect(() => {\n\t\tif (todo?.id == null) {\n\t\t\tsetShowDeleteConfirm(false);\n\t\t\treturn;\n\t\t}\n\t\tsetShowDeleteConfirm(false);\n\t}, [todo?.id]);\n\n\t// 清理防抖定时器\n\tuseEffect(() => {\n\t\treturn () => {\n\t\t\tif (debounceTimerRef.current) {\n\t\t\t\tclearTimeout(debounceTimerRef.current);\n\t\t\t}\n\t\t};\n\t}, []);\n\n\tif (!todo) {\n\t\treturn (\n\t\t\t<div className=\"flex h-full items-center justify-center text-sm text-muted-foreground bg-background\">\n\t\t\t\t{t(\"selectTodoPrompt\")}\n\t\t\t</div>\n\t\t);\n\t}\n\n\tconst handleNotesChange = (userNotes: string) => {\n\t\t// 立即更新本地状态，保证输入流畅\n\t\tsetLocalUserNotes(userNotes);\n\n\t\t// 标记正在更新\n\t\tisUpdatingRef.current = true;\n\n\t\t// 清除之前的防抖定时器\n\t\tif (debounceTimerRef.current) {\n\t\t\tclearTimeout(debounceTimerRef.current);\n\t\t}\n\n\t\t// 设置新的防抖定时器，延迟更新服务器\n\t\tdebounceTimerRef.current = setTimeout(async () => {\n\t\t\ttry {\n\t\t\t\tawait updateTodo(todo.id, { userNotes });\n\t\t\t\t// 更新成功后，标记更新完成\n\t\t\t\tisUpdatingRef.current = false;\n\t\t\t} catch (err) {\n\t\t\t\tconsole.error(\"Failed to update notes:\", err);\n\t\t\t\t// 如果更新失败，恢复本地状态到服务器值\n\t\t\t\tsetLocalUserNotes(todo.userNotes || \"\");\n\t\t\t\tisUpdatingRef.current = false;\n\t\t\t}\n\t\t}, 500);\n\t};\n\n\tconst handleNotesBlur = async () => {\n\t\t// 失去焦点时，立即同步状态到服务器\n\t\t// 如果有待处理的防抖更新，先取消它并立即执行\n\t\tif (debounceTimerRef.current) {\n\t\t\tclearTimeout(debounceTimerRef.current);\n\t\t\tdebounceTimerRef.current = null;\n\t\t}\n\n\t\t// 如果本地状态与服务器状态不同，立即更新\n\t\tif (localUserNotes !== (todo.userNotes || \"\")) {\n\t\t\ttry {\n\t\t\t\tisUpdatingRef.current = true;\n\t\t\t\tawait updateTodo(todo.id, { userNotes: localUserNotes });\n\t\t\t\tisUpdatingRef.current = false;\n\t\t\t} catch (err) {\n\t\t\t\tconsole.error(\"Failed to update notes on blur:\", err);\n\t\t\t\t// 如果更新失败，恢复本地状态到服务器值\n\t\t\t\tsetLocalUserNotes(todo.userNotes || \"\");\n\t\t\t\tisUpdatingRef.current = false;\n\t\t\t}\n\t\t}\n\t};\n\n\tconst handleDescriptionChange = async (description: string) => {\n\t\ttry {\n\t\t\tawait updateTodo(todo.id, { description });\n\t\t} catch (err) {\n\t\t\tconsole.error(\"Failed to update description:\", err);\n\t\t}\n\t};\n\n\tconst handleNameChange = async (name: string) => {\n\t\ttry {\n\t\t\tawait updateTodo(todo.id, { name });\n\t\t} catch (err) {\n\t\t\tconsole.error(\"Failed to update name:\", err);\n\t\t}\n\t};\n\n\tconst handleToggleComplete = async () => {\n\t\ttry {\n\t\t\tawait toggleTodoStatus(todo.id);\n\t\t} catch (err) {\n\t\t\tconsole.error(\"Failed to toggle status:\", err);\n\t\t}\n\t};\n\n\tconst handleDelete = async () => {\n\t\ttry {\n\t\t\tconst allIdsToDelete = [todo.id, ...childIds];\n\n\t\t\tawait deleteTodo(todo.id);\n\t\t\tonTodoDeleted(allIdsToDelete);\n\t\t\tsetSelectedTodoId(null);\n\t\t} catch (err) {\n\t\t\tconsole.error(\"Failed to delete todo:\", err);\n\t\t}\n\t};\n\n\tconst handleUploadAttachments = async (files: File[]) => {\n\t\tif (!todo) return;\n\t\tsetActiveView(\"artifacts\");\n\t\ttry {\n\t\t\tawait uploadTodoAttachments(todo.id, files);\n\t\t\tqueryClient.invalidateQueries({ queryKey: queryKeys.todos.all });\n\t\t} catch (err) {\n\t\t\tconsole.error(\"Failed to upload attachments:\", err);\n\t\t\ttoastError(t(\"uploadFailed\"));\n\t\t}\n\t};\n\n\tconst handleRemoveAttachment = async (attachmentId: number) => {\n\t\tif (!todo) return;\n\t\ttry {\n\t\t\tawait removeTodoAttachment(todo.id, attachmentId);\n\t\t\tif (selectedAttachment?.id === attachmentId) {\n\t\t\t\tsetSelectedAttachment(null);\n\t\t\t}\n\t\t\tqueryClient.invalidateQueries({ queryKey: queryKeys.todos.all });\n\t\t} catch (err) {\n\t\t\tconsole.error(\"Failed to remove attachment:\", err);\n\t\t\ttoastError(t(\"removeAttachmentFailed\"));\n\t\t}\n\t};\n\n\tconst handleSelectAttachment = (attachment: TodoAttachment) => {\n\t\tsetActiveView(\"artifacts\");\n\t\tsetSelectedAttachment(attachment);\n\t};\n\n\tconst handleCreateChild = async (name: string) => {\n\t\ttry {\n\t\t\tawait createTodo({\n\t\t\t\tname,\n\t\t\t\tparentTodoId: todo.id,\n\t\t\t});\n\t\t} catch (err) {\n\t\t\tconsole.error(\"Failed to create child todo:\", err);\n\t\t}\n\t};\n\n\tconst handleDeleteRequest = () => {\n\t\tsetShowDeleteConfirm(true);\n\t};\n\n\tconst handleDeleteConfirm = async () => {\n\t\tsetShowDeleteConfirm(false);\n\t\tawait handleDelete();\n\t};\n\n\tif (!todo) {\n\t\treturn (\n\t\t\t<div className=\"flex h-full items-center justify-center text-sm text-muted-foreground bg-background\">\n\t\t\t\t{t(\"selectTodoPrompt\")}\n\t\t\t</div>\n\t\t);\n\t}\n\n\tconst detailPosition = getPositionByFeature(\"todoDetail\", panelFeatureMap);\n\tconst leftNeighbor =\n\t\tdetailPosition === \"panelC\"\n\t\t\t? \"panelB\"\n\t\t\t: detailPosition === \"panelB\"\n\t\t\t\t? \"panelA\"\n\t\t\t\t: null;\n\tconst leftNeighborOpen =\n\t\tleftNeighbor === \"panelA\"\n\t\t\t? isPanelAOpen\n\t\t\t: leftNeighbor === \"panelB\"\n\t\t\t\t? isPanelBOpen\n\t\t\t\t: false;\n\tconst leftNeighborFeature = leftNeighbor\n\t\t? panelFeatureMap[leftNeighbor]\n\t\t: null;\n\tconst previewPlacement =\n\t\tleftNeighborOpen && leftNeighborFeature === \"chat\" ? \"left\" : \"right\";\n\n\treturn (\n\t\t<div className=\"flex h-full flex-col overflow-hidden bg-background\">\n\t\t\t<DetailHeader\n\t\t\t\tonToggleComplete={handleToggleComplete}\n\t\t\t\tonDelete={handleDeleteRequest}\n\t\t\t\tactiveView={activeView}\n\t\t\t\tonViewChange={setActiveView}\n\t\t\t/>\n\n\t\t\t{showDeleteConfirm && (\n\t\t\t\t<div className=\"mx-4 mt-3 rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3\">\n\t\t\t\t\t<div className=\"flex flex-wrap items-start justify-between gap-3\">\n\t\t\t\t\t\t<div className=\"min-w-[200px]\">\n\t\t\t\t\t\t\t<p className=\"text-sm font-semibold text-foreground\">\n\t\t\t\t\t\t\t\t{t(\"deleteConfirmTitle\")}\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t<p className=\"mt-1 text-xs text-muted-foreground\">\n\t\t\t\t\t\t\t\t{childIds.length > 0\n\t\t\t\t\t\t\t\t\t? t(\"deleteConfirmWithChildren\", {\n\t\t\t\t\t\t\t\t\t\t\tcount: childIds.length,\n\t\t\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t\t\t: t(\"deleteConfirmDescription\")}\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\tvariant=\"outline\"\n\t\t\t\t\t\t\t\tsize=\"sm\"\n\t\t\t\t\t\t\t\tonClick={() => setShowDeleteConfirm(false)}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{t(\"deleteConfirmCancel\")}\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\tvariant=\"destructive\"\n\t\t\t\t\t\t\t\tsize=\"sm\"\n\t\t\t\t\t\t\t\tonClick={handleDeleteConfirm}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{t(\"deleteConfirmDelete\")}\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t)}\n\n\t\t\t<div className=\"flex-1 overflow-y-auto px-4 py-6\">\n\t\t\t\t{activeView === \"detail\" ? (\n\t\t\t\t\t<>\n\t\t\t\t\t\t<DetailTitle name={todo.name} onNameChange={handleNameChange} />\n\n\t\t\t\t\t\t<MetaSection\n\t\t\t\t\t\t\ttodo={todo}\n\t\t\t\t\t\t\tonStatusChange={(status) => updateTodo(todo.id, { status })}\n\t\t\t\t\t\t\tonPriorityChange={(priority) => updateTodo(todo.id, { priority })}\n\t\t\t\t\t\t\tonTagsChange={(tags) => updateTodo(todo.id, { tags })}\n\t\t\t\t\t\t\tonScheduleChange={(input) => updateTodo(todo.id, input)}\n\t\t\t\t\t\t/>\n\n\t\t\t\t\t\t<BackgroundSection\n\t\t\t\t\t\t\tdescription={todo.description}\n\t\t\t\t\t\t\tshow={showDescription}\n\t\t\t\t\t\t\tonToggle={() => setShowDescription((prev) => !prev)}\n\t\t\t\t\t\t\tonDescriptionChange={handleDescriptionChange}\n\t\t\t\t\t\t/>\n\n\t\t\t\t\t\t<NotesEditor\n\t\t\t\t\t\t\tvalue={localUserNotes}\n\t\t\t\t\t\t\tshow={showNotes}\n\t\t\t\t\t\t\tonToggle={() => setShowNotes((prev) => !prev)}\n\t\t\t\t\t\t\tonChange={handleNotesChange}\n\t\t\t\t\t\t\tonBlur={handleNotesBlur}\n\t\t\t\t\t\t/>\n\n\t\t\t\t\t\t<ChildTodoSection\n\t\t\t\t\t\t\tchildTodos={childTodos}\n\t\t\t\t\t\t\tallTodos={todos}\n\t\t\t\t\t\t\tshow={showChildTodos}\n\t\t\t\t\t\t\tonToggle={() => setShowChildTodos((prev) => !prev)}\n\t\t\t\t\t\t\tonSelectTodo={setSelectedTodoId}\n\t\t\t\t\t\t\tonCreateChild={handleCreateChild}\n\t\t\t\t\t\t\tonToggleStatus={toggleTodoStatus}\n\t\t\t\t\t\t\tonUpdateTodo={updateTodo}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</>\n\t\t\t\t) : (\n\t\t\t\t\t<div className=\"flex h-full min-h-0 gap-4\">\n\t\t\t\t\t\t{previewPlacement === \"left\" && selectedAttachment && (\n\t\t\t\t\t\t\t<AttachmentPreviewPanel\n\t\t\t\t\t\t\t\tattachment={selectedAttachment}\n\t\t\t\t\t\t\t\tonClose={() => setSelectedAttachment(null)}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t<ArtifactsView\n\t\t\t\t\t\t\ttodo={todo}\n\t\t\t\t\t\t\tattachments={todo.attachments ?? []}\n\t\t\t\t\t\t\tonUpload={handleUploadAttachments}\n\t\t\t\t\t\t\tonRemove={handleRemoveAttachment}\n\t\t\t\t\t\t\tonSelectAttachment={handleSelectAttachment}\n\t\t\t\t\t\t\tonShowDetail={() => setActiveView(\"detail\")}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t{previewPlacement === \"right\" && selectedAttachment && (\n\t\t\t\t\t\t\t<AttachmentPreviewPanel\n\t\t\t\t\t\t\t\tattachment={selectedAttachment}\n\t\t\t\t\t\t\t\tonClose={() => setSelectedAttachment(null)}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/todo-detail/components/ArtifactsView.tsx",
    "content": "\"use client\";\n\nimport {\n\tFileUp,\n\tFolderOpen,\n\tGripVertical,\n\tImageIcon,\n\tNotebookText,\n\tPaperclip,\n\tTrash2,\n} from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport { type ChangeEvent, useMemo, useRef } from \"react\";\nimport ReactMarkdown from \"react-markdown\";\nimport remarkGfm from \"remark-gfm\";\nimport { getAttachmentFileUrl, MAX_ATTACHMENT_SIZE_BYTES } from \"@/lib/attachments\";\nimport { toastError } from \"@/lib/toast\";\nimport type { Todo, TodoAttachment } from \"@/lib/types\";\n\ninterface ArtifactsViewProps {\n\ttodo: Todo;\n\tattachments: TodoAttachment[];\n\tonUpload: (files: File[]) => void;\n\tonRemove: (attachmentId: number) => void;\n\tonSelectAttachment: (attachment: TodoAttachment) => void;\n\tonShowDetail: () => void;\n}\n\nconst formatBytes = (value?: number) => {\n\tif (!value && value !== 0) return \"—\";\n\tconst units = [\"B\", \"KB\", \"MB\", \"GB\"];\n\tlet size = value;\n\tlet unitIndex = 0;\n\twhile (size >= 1024 && unitIndex < units.length - 1) {\n\t\tsize /= 1024;\n\t\tunitIndex += 1;\n\t}\n\treturn `${size.toFixed(size >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;\n};\n\nexport function ArtifactsView({\n\ttodo,\n\tattachments,\n\tonUpload,\n\tonRemove,\n\tonSelectAttachment,\n\tonShowDetail,\n}: ArtifactsViewProps) {\n\tconst t = useTranslations(\"todoDetail\");\n\tconst uploadInputRef = useRef<HTMLInputElement | null>(null);\n\n\tconst { artifacts, contextAttachments } = useMemo(() => {\n\t\tconst artifactsList: TodoAttachment[] = [];\n\t\tconst contextList: TodoAttachment[] = [];\n\t\tfor (const attachment of attachments) {\n\t\t\tif (attachment.source === \"ai\") {\n\t\t\t\tartifactsList.push(attachment);\n\t\t\t} else {\n\t\t\t\tcontextList.push(attachment);\n\t\t\t}\n\t\t}\n\t\treturn { artifacts: artifactsList, contextAttachments: contextList };\n\t}, [attachments]);\n\n\tconst handleSelectFiles = (event: ChangeEvent<HTMLInputElement>) => {\n\t\tconst files = Array.from(event.target.files || []);\n\t\tif (files.length === 0) return;\n\n\t\tconst oversized = files.find((file) => file.size > MAX_ATTACHMENT_SIZE_BYTES);\n\t\tif (oversized) {\n\t\t\ttoastError(t(\"uploadSizeLimit\"));\n\t\t\tevent.target.value = \"\";\n\t\t\treturn;\n\t\t}\n\n\t\tonUpload(files);\n\t\tevent.target.value = \"\";\n\t};\n\n\tconst renderAttachmentRow = (attachment: TodoAttachment) => {\n\t\treturn (\n\t\t\t<div\n\t\t\t\tkey={attachment.id}\n\t\t\t\tclassName=\"flex items-center gap-3 rounded-md border border-border bg-background px-3 py-2 text-xs\"\n\t\t\t>\n\t\t\t\t<div className=\"flex h-8 w-8 items-center justify-center rounded-md bg-muted/40\">\n\t\t\t\t\t{attachment.mimeType?.startsWith(\"image/\") ? (\n\t\t\t\t\t\t<ImageIcon className=\"h-4 w-4 text-muted-foreground\" />\n\t\t\t\t\t) : (\n\t\t\t\t\t\t<Paperclip className=\"h-4 w-4 text-muted-foreground\" />\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t\t<div className=\"flex-1 truncate\">\n\t\t\t\t\t<div className=\"truncate text-sm font-medium text-foreground\">\n\t\t\t\t\t\t{attachment.fileName}\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"flex items-center gap-2 text-xs text-muted-foreground\">\n\t\t\t\t\t\t<span>{attachment.mimeType || \"unknown\"}</span>\n\t\t\t\t\t\t<span>•</span>\n\t\t\t\t\t\t<span>{formatBytes(attachment.fileSize)}</span>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tonClick={() => onSelectAttachment(attachment)}\n\t\t\t\t\tclassName=\"rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors\"\n\t\t\t\t>\n\t\t\t\t\t{t(\"previewLabel\")}\n\t\t\t\t</button>\n\t\t\t\t<a\n\t\t\t\t\thref={getAttachmentFileUrl(attachment.id)}\n\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t\trel=\"noreferrer\"\n\t\t\t\t\tclassName=\"rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors\"\n\t\t\t\t>\n\t\t\t\t\t{t(\"downloadLabel\")}\n\t\t\t\t</a>\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tonClick={() => onRemove(attachment.id)}\n\t\t\t\t\tclassName=\"rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors\"\n\t\t\t\t>\n\t\t\t\t\t<Trash2 className=\"h-3.5 w-3.5\" />\n\t\t\t\t</button>\n\t\t\t</div>\n\t\t);\n\t};\n\n\treturn (\n\t\t<div className=\"flex min-w-0 flex-1 flex-col gap-6\">\n\t\t\t<section className=\"rounded-xl border border-border bg-muted/20 px-4 py-4\">\n\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t<div className=\"flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground\">\n\t\t\t\t\t\t<GripVertical className=\"h-4 w-4\" />\n\t\t\t\t\t\t<span>{t(\"progressLabel\")}</span>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<div className=\"mt-3 space-y-2 text-sm text-muted-foreground\">\n\t\t\t\t\t<p>{t(\"progressEmptyTitle\")}</p>\n\t\t\t\t\t<p className=\"text-xs\">{t(\"progressEmptyHint\")}</p>\n\t\t\t\t</div>\n\t\t\t</section>\n\n\t\t\t<section className=\"rounded-xl border border-border bg-background px-4 py-4\">\n\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t<div className=\"flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground\">\n\t\t\t\t\t\t<FolderOpen className=\"h-4 w-4\" />\n\t\t\t\t\t\t<span>{t(\"artifactsLabel\")}</span>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<div className=\"mt-4 space-y-2\">\n\t\t\t\t\t{artifacts.length === 0 ? (\n\t\t\t\t\t\t<div className=\"rounded-md border border-dashed border-border bg-muted/20 px-4 py-6 text-center text-sm text-muted-foreground\">\n\t\t\t\t\t\t\t{t(\"artifactsEmpty\")}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t) : (\n\t\t\t\t\t\tartifacts.map(renderAttachmentRow)\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t</section>\n\n\t\t\t<section className=\"rounded-xl border border-border bg-background px-4 py-4\">\n\t\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t\t<div className=\"flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground\">\n\t\t\t\t\t\t<NotebookText className=\"h-4 w-4\" />\n\t\t\t\t\t\t<span>{t(\"contextLabel\")}</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tonClick={onShowDetail}\n\t\t\t\t\t\tclassName=\"text-xs text-muted-foreground hover:text-foreground transition-colors\"\n\t\t\t\t\t>\n\t\t\t\t\t\t{t(\"editContext\")}\n\t\t\t\t\t</button>\n\t\t\t\t</div>\n\n\t\t\t\t<div className=\"mt-4 grid gap-4\">\n\t\t\t\t\t<div className=\"rounded-lg border border-border bg-muted/20 px-3 py-3\">\n\t\t\t\t\t\t<div className=\"mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground\">\n\t\t\t\t\t\t\t{t(\"backgroundLabel\")}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t{todo.description ? (\n\t\t\t\t\t\t\t<div className=\"text-sm text-foreground markdown-content\">\n\t\t\t\t\t\t\t\t<ReactMarkdown remarkPlugins={[remarkGfm]}>\n\t\t\t\t\t\t\t\t\t{todo.description}\n\t\t\t\t\t\t\t\t</ReactMarkdown>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<p className=\"text-sm text-muted-foreground\">\n\t\t\t\t\t\t\t\t{t(\"backgroundEmptyPlaceholder\")}\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"rounded-lg border border-border bg-muted/20 px-3 py-3\">\n\t\t\t\t\t\t<div className=\"mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground\">\n\t\t\t\t\t\t\t{t(\"notesLabel\")}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t{todo.userNotes ? (\n\t\t\t\t\t\t\t<div className=\"text-sm text-foreground markdown-content\">\n\t\t\t\t\t\t\t\t<ReactMarkdown remarkPlugins={[remarkGfm]}>\n\t\t\t\t\t\t\t\t\t{todo.userNotes}\n\t\t\t\t\t\t\t\t</ReactMarkdown>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<p className=\"text-sm text-muted-foreground\">\n\t\t\t\t\t\t\t\t{t(\"notesEmptyPlaceholder\")}\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\n\t\t\t\t\t<div className=\"rounded-lg border border-border bg-muted/10 px-3 py-3\">\n\t\t\t\t\t\t<div className=\"mb-3 flex items-center justify-between\">\n\t\t\t\t\t\t\t<div className=\"flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground\">\n\t\t\t\t\t\t\t\t<Paperclip className=\"h-4 w-4\" />\n\t\t\t\t\t\t\t\t<span>{t(\"contextAttachmentsLabel\")}</span>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\tref={uploadInputRef}\n\t\t\t\t\t\t\t\ttype=\"file\"\n\t\t\t\t\t\t\t\tmultiple\n\t\t\t\t\t\t\t\tclassName=\"hidden\"\n\t\t\t\t\t\t\t\tonChange={handleSelectFiles}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\tonClick={() => uploadInputRef.current?.click()}\n\t\t\t\t\t\t\t\tclassName=\"inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-2.5 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<FileUp className=\"h-3.5 w-3.5\" />\n\t\t\t\t\t\t\t\t{t(\"uploadLabel\")}\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t{contextAttachments.length === 0 ? (\n\t\t\t\t\t\t\t<div className=\"rounded-md border border-dashed border-border bg-background px-4 py-4 text-center text-sm text-muted-foreground\">\n\t\t\t\t\t\t\t\t{t(\"contextAttachmentsEmpty\")}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<div className=\"space-y-2\">\n\t\t\t\t\t\t\t\t{contextAttachments.map(renderAttachmentRow)}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t<p className=\"mt-2 text-xs text-muted-foreground\">\n\t\t\t\t\t\t\t{t(\"uploadHint\")}\n\t\t\t\t\t\t</p>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</section>\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/todo-detail/components/AttachmentPreviewPanel.tsx",
    "content": "\"use client\";\n\nimport { Download, X } from \"lucide-react\";\nimport Image from \"next/image\";\nimport { useTranslations } from \"next-intl\";\nimport { getAttachmentFileUrl } from \"@/lib/attachments\";\nimport type { TodoAttachment } from \"@/lib/types\";\n\ninterface AttachmentPreviewPanelProps {\n\tattachment: TodoAttachment;\n\tonClose: () => void;\n}\n\nconst formatBytes = (value?: number) => {\n\tif (!value && value !== 0) return \"—\";\n\tconst units = [\"B\", \"KB\", \"MB\", \"GB\"];\n\tlet size = value;\n\tlet unitIndex = 0;\n\twhile (size >= 1024 && unitIndex < units.length - 1) {\n\t\tsize /= 1024;\n\t\tunitIndex += 1;\n\t}\n\treturn `${size.toFixed(size >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;\n};\n\nexport function AttachmentPreviewPanel({\n\tattachment,\n\tonClose,\n}: AttachmentPreviewPanelProps) {\n\tconst t = useTranslations(\"todoDetail\");\n\tconst previewUrl = getAttachmentFileUrl(attachment.id);\n\tconst isImage = attachment.mimeType?.startsWith(\"image/\");\n\n\treturn (\n\t\t<div className=\"flex min-w-[240px] flex-1 flex-col rounded-xl border border-border bg-background px-4 py-4\">\n\t\t\t<div className=\"flex items-center justify-between\">\n\t\t\t\t<div className=\"text-xs font-semibold uppercase tracking-wider text-muted-foreground\">\n\t\t\t\t\t{t(\"previewLabel\")}\n\t\t\t\t</div>\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tonClick={onClose}\n\t\t\t\t\tclassName=\"rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors\"\n\t\t\t\t>\n\t\t\t\t\t<X className=\"h-4 w-4\" />\n\t\t\t\t</button>\n\t\t\t</div>\n\n\t\t\t<div className=\"mt-3 flex-1 overflow-hidden rounded-lg border border-border bg-muted/10 relative\">\n\t\t\t\t{isImage ? (\n\t\t\t\t\t<Image\n\t\t\t\t\t\tsrc={previewUrl}\n\t\t\t\t\t\talt={attachment.fileName}\n\t\t\t\t\t\tfill\n\t\t\t\t\t\tsizes=\"(max-width: 768px) 100vw, 50vw\"\n\t\t\t\t\t\tclassName=\"object-contain\"\n\t\t\t\t\t/>\n\t\t\t\t) : (\n\t\t\t\t\t<div className=\"flex h-full items-center justify-center text-sm text-muted-foreground\">\n\t\t\t\t\t\t{t(\"previewUnavailable\")}\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\n\t\t\t<div className=\"mt-4 space-y-2\">\n\t\t\t\t<div className=\"truncate text-sm font-medium text-foreground\">\n\t\t\t\t\t{attachment.fileName}\n\t\t\t\t</div>\n\t\t\t\t<div className=\"flex items-center gap-2 text-xs text-muted-foreground\">\n\t\t\t\t\t<span>{attachment.mimeType || \"unknown\"}</span>\n\t\t\t\t\t<span>•</span>\n\t\t\t\t\t<span>{formatBytes(attachment.fileSize)}</span>\n\t\t\t\t</div>\n\t\t\t\t<a\n\t\t\t\t\thref={previewUrl}\n\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t\trel=\"noreferrer\"\n\t\t\t\t\tclassName=\"inline-flex items-center gap-1.5 rounded-md border border-border bg-muted/10 px-2.5 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors\"\n\t\t\t\t>\n\t\t\t\t\t<Download className=\"h-3.5 w-3.5\" />\n\t\t\t\t\t{t(\"downloadLabel\")}\n\t\t\t\t</a>\n\t\t\t</div>\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/todo-detail/components/BackgroundSection.tsx",
    "content": "\"use client\";\n\nimport { Check, X } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport ReactMarkdown from \"react-markdown\";\nimport remarkGfm from \"remark-gfm\";\nimport { SectionHeader } from \"@/components/common/layout/SectionHeader\";\n\ninterface BackgroundSectionProps {\n\tdescription?: string;\n\tshow: boolean;\n\tonToggle: () => void;\n\tonDescriptionChange?: (description: string) => void;\n}\n\nexport function BackgroundSection({\n\tdescription,\n\tshow,\n\tonToggle,\n\tonDescriptionChange,\n}: BackgroundSectionProps) {\n\tconst t = useTranslations(\"todoDetail\");\n\tconst [isEditing, setIsEditing] = useState(false);\n\tconst [editValue, setEditValue] = useState(description || \"\");\n\tconst [displayValue, setDisplayValue] = useState(description || \"\");\n\tconst [isHovered, setIsHovered] = useState(false);\n\tconst textareaRef = useRef<HTMLTextAreaElement>(null);\n\tconst justSavedRef = useRef<boolean>(false);\n\n\tuseEffect(() => {\n\t\tif (!isEditing && !justSavedRef.current) {\n\t\t\tif (description !== displayValue) {\n\t\t\t\tsetDisplayValue(description || \"\");\n\t\t\t}\n\t\t}\n\t\tif (justSavedRef.current) {\n\t\t\tjustSavedRef.current = false;\n\t\t}\n\t}, [description, isEditing, displayValue]);\n\n\tconst adjustTextareaHeight = useCallback(() => {\n\t\tconst textarea = textareaRef.current;\n\t\tif (textarea) {\n\t\t\ttextarea.style.height = \"auto\";\n\t\t\ttextarea.style.height = `${textarea.scrollHeight}px`;\n\t\t}\n\t}, []);\n\n\tuseEffect(() => {\n\t\tif (isEditing && textareaRef.current) {\n\t\t\ttextareaRef.current.focus();\n\t\t\tadjustTextareaHeight();\n\t\t}\n\t}, [isEditing, adjustTextareaHeight]);\n\n\tconst handleStartEdit = () => {\n\t\tsetEditValue(displayValue || \"\");\n\t\tsetIsEditing(true);\n\t};\n\n\tconst handleSave = async () => {\n\t\tconst trimmedValue = editValue.trim();\n\t\tjustSavedRef.current = true;\n\t\tsetDisplayValue(trimmedValue);\n\t\tsetIsEditing(false);\n\n\t\tif (onDescriptionChange) {\n\t\t\ttry {\n\t\t\t\tawait onDescriptionChange(trimmedValue);\n\t\t\t} catch (err) {\n\t\t\t\tsetDisplayValue(description || \"\");\n\t\t\t\tjustSavedRef.current = false;\n\t\t\t\tconsole.error(\"Failed to update background:\", err);\n\t\t\t}\n\t\t}\n\t};\n\n\tconst handleCancel = () => {\n\t\tsetEditValue(displayValue || \"\");\n\t\tsetIsEditing(false);\n\t};\n\n\tconst handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {\n\t\tif (e.key === \"Escape\") {\n\t\t\thandleCancel();\n\t\t} else if (e.key === \"Enter\" && (e.metaKey || e.ctrlKey)) {\n\t\t\te.preventDefault();\n\t\t\thandleSave();\n\t\t}\n\t};\n\n\tconst handleBlur = (e: React.FocusEvent<HTMLTextAreaElement>) => {\n\t\tconst relatedTarget = e.relatedTarget as HTMLElement | null;\n\t\tif (relatedTarget?.closest(\"[data-background-actions]\")) {\n\t\t\treturn;\n\t\t}\n\t\thandleSave();\n\t};\n\n\treturn (\n\t\t<div\n\t\t\trole=\"group\"\n\t\t\tclassName=\"mb-8\"\n\t\t\tonMouseEnter={() => setIsHovered(true)}\n\t\t\tonMouseLeave={() => setIsHovered(false)}\n\t\t>\n\t\t\t<SectionHeader\n\t\t\t\ttitle={t(\"backgroundLabel\")}\n\t\t\t\tshow={show}\n\t\t\t\tonToggle={onToggle}\n\t\t\t\theaderClassName=\"mb-3\"\n\t\t\t\tisHovered={isHovered}\n\t\t\t/>\n\t\t\t{show &&\n\t\t\t\t(isEditing ? (\n\t\t\t\t\t<div className=\"relative\">\n\t\t\t\t\t\t<textarea\n\t\t\t\t\t\t\tref={textareaRef}\n\t\t\t\t\t\t\tvalue={editValue}\n\t\t\t\t\t\t\tonChange={(e) => {\n\t\t\t\t\t\t\t\tsetEditValue(e.target.value);\n\t\t\t\t\t\t\t\tadjustTextareaHeight();\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tonKeyDown={handleKeyDown}\n\t\t\t\t\t\t\tonBlur={handleBlur}\n\t\t\t\t\t\t\tplaceholder={t(\"backgroundPlaceholder\")}\n\t\t\t\t\t\t\tclassName=\"w-full min-h-[80px] resize-none rounded-md border border-primary bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<div data-background-actions className=\"mt-2 flex justify-end gap-2\">\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\tonClick={handleCancel}\n\t\t\t\t\t\t\t\tclassName=\"flex items-center gap-1 rounded px-3 py-1.5 text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<X className=\"h-3.5 w-3.5\" />\n\t\t\t\t\t\t\t\t{t(\"cancel\")}\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\tonClick={handleSave}\n\t\t\t\t\t\t\t\tclassName=\"flex items-center gap-1 rounded bg-primary px-3 py-1.5 text-xs text-primary-foreground hover:bg-primary/90 transition-colors\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<Check className=\"h-3.5 w-3.5\" />\n\t\t\t\t\t\t\t\t{t(\"save\")}\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t) : (\n\t\t\t\t\t<div\n\t\t\t\t\t\trole=\"button\"\n\t\t\t\t\t\ttabIndex={0}\n\t\t\t\t\t\tonClick={handleStartEdit}\n\t\t\t\t\t\tonKeyDown={(e) => {\n\t\t\t\t\t\t\tif (e.key === \"Enter\" || e.key === \" \") {\n\t\t\t\t\t\t\t\te.preventDefault();\n\t\t\t\t\t\t\t\thandleStartEdit();\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tclassName=\"w-full text-left group cursor-pointer rounded-md border border-border bg-muted/20 px-4 py-3 hover:border-primary/50 hover:bg-muted/40 transition-colors focus:outline-none focus:ring-2 focus:ring-primary\"\n\t\t\t\t\t>\n\t\t\t\t\t\t{displayValue ? (\n\t\t\t\t\t\t\t<div className=\"markdown-content\">\n\t\t\t\t\t\t\t\t<ReactMarkdown remarkPlugins={[remarkGfm]}>\n\t\t\t\t\t\t\t\t\t{displayValue}\n\t\t\t\t\t\t\t\t</ReactMarkdown>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<span className=\"text-sm text-muted-foreground\">\n\t\t\t\t\t\t\t\t{t(\"backgroundEmptyPlaceholder\")}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t))}\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/todo-detail/components/ChildTodoSection.tsx",
    "content": "\"use client\";\n\nimport { Calendar, Plus, Tag as TagIcon, X } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport { TodoContextMenu } from \"@/components/common/context-menu/TodoContextMenu\";\nimport { SectionHeader } from \"@/components/common/layout/SectionHeader\";\nimport type { Todo, TodoStatus, UpdateTodoInput } from \"@/lib/types\";\nimport { cn, sortTodosByOrder } from \"@/lib/utils\";\nimport {\n\tformatDateTime,\n\tgetChildProgress,\n\tgetPriorityBorderColor,\n} from \"../helpers\";\n\ninterface ChildTodoSectionProps {\n\tchildTodos: Todo[];\n\tallTodos: Todo[];\n\tshow: boolean;\n\tonToggle: () => void;\n\tonSelectTodo: (id: number) => void;\n\tonCreateChild: (name: string) => void;\n\tonToggleStatus: (id: number) => Promise<Todo>;\n\tonUpdateTodo: (id: number, input: UpdateTodoInput) => Promise<Todo>;\n}\n\nexport function ChildTodoSection({\n\tchildTodos,\n\tallTodos,\n\tshow,\n\tonToggle,\n\tonSelectTodo,\n\tonCreateChild,\n\tonToggleStatus,\n\tonUpdateTodo,\n}: ChildTodoSectionProps) {\n\tconst tTodoDetail = useTranslations(\"todoDetail\");\n\tconst [isAddingChild, setIsAddingChild] = useState(false);\n\tconst [childName, setChildName] = useState(\"\");\n\tconst [isHovered, setIsHovered] = useState(false);\n\tconst inputRef = useRef<HTMLInputElement>(null);\n\n\t// 使用与 TodoList 相同的排序逻辑：按 order 字段排序，如果 order 相同则按创建时间排序\n\tconst sortedChildTodos = useMemo(\n\t\t() => sortTodosByOrder(childTodos),\n\t\t[childTodos],\n\t);\n\n\tuseEffect(() => {\n\t\tif (isAddingChild) {\n\t\t\tinputRef.current?.focus();\n\t\t}\n\t}, [isAddingChild]);\n\n\tconst handleSubmit = (event?: React.FormEvent) => {\n\t\tif (event) event.preventDefault();\n\t\tconst name = childName.trim();\n\t\tif (!name) return;\n\t\tonCreateChild(name);\n\t\tsetChildName(\"\");\n\t};\n\n\tconst handleAddChildFromMenu = () => {\n\t\tsetIsAddingChild(true);\n\t\tsetChildName(\"\");\n\t};\n\n\tconst handleToggleStatus = async (\n\t\te: React.MouseEvent | React.KeyboardEvent,\n\t\tchild: Todo,\n\t) => {\n\t\te.stopPropagation();\n\t\ttry {\n\t\t\tif (child.status === \"canceled\") {\n\t\t\t\t// 如果是 canceled 状态，点击复选框回到 active 状态\n\t\t\t\tawait onUpdateTodo(child.id, { status: \"active\" as TodoStatus });\n\t\t\t} else {\n\t\t\t\t// 其他状态使用通用的切换逻辑\n\t\t\t\tawait onToggleStatus(child.id);\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tconsole.error(\"Failed to toggle todo status:\", err);\n\t\t}\n\t};\n\n\treturn (\n\t\t<div\n\t\t\trole=\"group\"\n\t\t\tclassName=\"mb-4\"\n\t\t\tonMouseEnter={() => setIsHovered(true)}\n\t\t\tonMouseLeave={() => setIsHovered(false)}\n\t\t>\n\t\t\t<SectionHeader\n\t\t\t\ttitle={\n\t\t\t\t\t<>\n\t\t\t\t\t\t{tTodoDetail(\"childTodos\")}\n\t\t\t\t\t\t{sortedChildTodos.length > 0 && (\n\t\t\t\t\t\t\t<span className=\"ml-1\">\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tsortedChildTodos.filter((c) => c.status === \"completed\")\n\t\t\t\t\t\t\t\t\t\t.length\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t/{sortedChildTodos.length}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</>\n\t\t\t\t}\n\t\t\t\tshow={show}\n\t\t\t\tonToggle={onToggle}\n\t\t\t\theaderClassName=\"mb-2\"\n\t\t\t\tisHovered={isHovered}\n\t\t\t/>\n\t\t\t{show && (\n\t\t\t\t<>\n\t\t\t\t\t<div className=\"space-y-1\">\n\t\t\t\t\t\t{sortedChildTodos.map((child) => {\n\t\t\t\t\t\t\tconst { completed, total } = getChildProgress(allTodos, child.id);\n\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t<TodoContextMenu\n\t\t\t\t\t\t\t\t\tkey={child.id}\n\t\t\t\t\t\t\t\t\ttodoId={child.id}\n\t\t\t\t\t\t\t\t\tonAddChild={handleAddChildFromMenu}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\tonClick={() => onSelectTodo(child.id)}\n\t\t\t\t\t\t\t\t\t\tclassName=\"flex w-full items-center justify-between gap-3 rounded-lg px-1 py-2 text-left transition-colors hover:bg-muted/40\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<div className=\"flex flex-col gap-1\">\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\t\trole=\"button\"\n\t\t\t\t\t\t\t\t\t\t\t\t\ttabIndex={0}\n\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={(e) => handleToggleStatus(e, child)}\n\t\t\t\t\t\t\t\t\t\t\t\t\tonKeyDown={(e) => {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tif (e.key === \"Enter\" || e.key === \" \") {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\te.preventDefault();\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\thandleToggleStatus(e, child);\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"shrink-0 cursor-pointer\"\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{child.status === \"completed\" ? (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex h-4 w-4 items-center justify-center rounded-md bg-[oklch(var(--primary))] border border-[oklch(var(--primary))] shadow-inner\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-[8px] text-[oklch(var(--primary-foreground))] font-semibold\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t✓\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t\t) : child.status === \"canceled\" ? (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"flex h-4 w-4 items-center justify-center rounded-md border-2\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tgetPriorityBorderColor(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tchild.priority ?? \"none\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"bg-muted/30 text-muted-foreground/70\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"transition-colors\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"hover:bg-muted/40 hover:text-muted-foreground\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<X className=\"h-2.5 w-2.5\" strokeWidth={2.5} />\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t\t) : child.status === \"draft\" ? (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex h-4 w-4 items-center justify-center rounded-md bg-orange-500 border border-orange-600 dark:border-orange-500 shadow-inner\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-[10px] text-white dark:text-orange-50 font-semibold\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t—\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"h-4 w-4 rounded-md border-2 transition-colors\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tgetPriorityBorderColor(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tchild.priority ?? \"none\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"hover:border-foreground\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-sm text-foreground\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t{child.name}\n\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex flex-wrap items-center gap-2 text-xs text-muted-foreground\">\n\t\t\t\t\t\t\t\t\t\t\t\t{(child.startTime || child.endTime) && (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-1 rounded-md bg-muted/40 px-2 py-1\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<Calendar className=\"h-3 w-3\" />\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{formatDateTime(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tchild.startTime ?? child.endTime,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tchild.timeZone,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t{child.tags && child.tags.length > 0 && (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-1\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<TagIcon className=\"h-3 w-3\" />\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex flex-wrap items-center gap-1\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{child.tags.map((tag) => (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tkey={tag}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-foreground\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{tag}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t{total > 0 && (\n\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-xs text-muted-foreground\">\n\t\t\t\t\t\t\t\t\t\t\t\t{completed}/{total}\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t</TodoContextMenu>\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t})}\n\t\t\t\t\t</div>\n\n\t\t\t\t\t{isAddingChild ? (\n\t\t\t\t\t\t<form\n\t\t\t\t\t\t\tonSubmit={handleSubmit}\n\t\t\t\t\t\t\tclassName=\"mt-2 flex flex-wrap items-center gap-2\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\tref={inputRef}\n\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\tvalue={childName}\n\t\t\t\t\t\t\t\tonChange={(e) => setChildName(e.target.value)}\n\t\t\t\t\t\t\t\tplaceholder={tTodoDetail(\"addChildPlaceholder\")}\n\t\t\t\t\t\t\t\tclassName=\"flex-1 rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\ttype=\"submit\"\n\t\t\t\t\t\t\t\tclassName=\"inline-flex items-center gap-2 rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground shadow-sm transition-colors hover:bg-primary/90\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<Plus className=\"h-4 w-4\" />\n\t\t\t\t\t\t\t\t{tTodoDetail(\"add\")}\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\tsetIsAddingChild(false);\n\t\t\t\t\t\t\t\t\tsetChildName(\"\");\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tclassName=\"rounded-md px-3 py-2 text-sm text-muted-foreground hover:bg-muted/60 transition-colors\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{tTodoDetail(\"cancel\")}\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</form>\n\t\t\t\t\t) : (\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\tsetIsAddingChild(true);\n\t\t\t\t\t\t\t\tsetChildName(\"\");\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tclassName=\"mt-2 flex w-full items-center gap-2 rounded-lg px-1 py-2 text-sm font-medium text-foreground transition-colors hover:bg-muted/40\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<Plus className=\"h-4 w-4\" />\n\t\t\t\t\t\t\t<span>{tTodoDetail(\"addChild\")}</span>\n\t\t\t\t\t\t</button>\n\t\t\t\t\t)}\n\t\t\t\t</>\n\t\t\t)}\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/todo-detail/components/DatePickerCalendar.tsx",
    "content": "\"use client\";\n\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\nimport type { useTranslations } from \"next-intl\";\nimport { cn } from \"@/lib/utils\";\nimport {\n\ttype CalendarDay,\n\tstartOfDay,\n\ttoDateKey,\n\tWEEKDAY_KEYS,\n\ttype WeekdayKey,\n} from \"../utils\";\nimport { SOLAR_TERMS } from \"../utils/lunar-utils\";\n\ninterface MonthNavigationProps {\n\tcurrentMonth: Date;\n\tonPrevMonth: () => void;\n\tonNextMonth: () => void;\n\tonToday: () => void;\n\ttCalendar: ReturnType<typeof useTranslations<\"calendar\">>;\n}\n\nexport function MonthNavigation({\n\tcurrentMonth,\n\tonPrevMonth,\n\tonNextMonth,\n\tonToday,\n\ttCalendar,\n}: MonthNavigationProps) {\n\treturn (\n\t\t<div className=\"flex items-center justify-between px-1 py-2\">\n\t\t\t<span className=\"text-sm font-medium\">\n\t\t\t\t{tCalendar(\"yearMonth\", {\n\t\t\t\t\tyear: currentMonth.getFullYear(),\n\t\t\t\t\tmonth: currentMonth.getMonth() + 1,\n\t\t\t\t})}\n\t\t\t</span>\n\t\t\t<div className=\"flex items-center gap-1\">\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tonClick={onPrevMonth}\n\t\t\t\t\tclassName=\"rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground\"\n\t\t\t\t>\n\t\t\t\t\t<ChevronLeft className=\"h-4 w-4\" />\n\t\t\t\t</button>\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tonClick={onToday}\n\t\t\t\t\tclassName=\"rounded-full p-1.5 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground\"\n\t\t\t\t>\n\t\t\t\t\t<span className=\"flex h-4 w-4 items-center justify-center text-xs\">\n\t\t\t\t\t\t○\n\t\t\t\t\t</span>\n\t\t\t\t</button>\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tonClick={onNextMonth}\n\t\t\t\t\tclassName=\"rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground\"\n\t\t\t\t>\n\t\t\t\t\t<ChevronRight className=\"h-4 w-4\" />\n\t\t\t\t</button>\n\t\t\t</div>\n\t\t</div>\n\t);\n}\n\ninterface WeekdayHeaderProps {\n\ttCalendar: ReturnType<typeof useTranslations<\"calendar\">>;\n}\n\nexport function WeekdayHeader({ tCalendar }: WeekdayHeaderProps) {\n\treturn (\n\t\t<div className=\"grid grid-cols-7 px-2\">\n\t\t\t{WEEKDAY_KEYS.map((key, idx) => (\n\t\t\t\t<span\n\t\t\t\t\tkey={key}\n\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\"py-1 text-center text-xs font-medium\",\n\t\t\t\t\t\tidx >= 5 ? \"text-muted-foreground/70\" : \"text-muted-foreground\",\n\t\t\t\t\t)}\n\t\t\t\t>\n\t\t\t\t\t{tCalendar(`weekdays.${key}` as `weekdays.${WeekdayKey}`)}\n\t\t\t\t</span>\n\t\t\t))}\n\t\t</div>\n\t);\n}\n\ninterface CalendarGridProps {\n\tmonthDays: CalendarDay[];\n\tselectedDate: Date | null;\n\trangeStart: Date | null;\n\trangeEnd: Date | null;\n\tshowLunar: boolean;\n\tonSelectDate: (day: CalendarDay) => void;\n}\n\nexport function CalendarGrid({\n\tmonthDays,\n\tselectedDate,\n\trangeStart,\n\trangeEnd,\n\tshowLunar,\n\tonSelectDate,\n}: CalendarGridProps) {\n\tconst startKey = rangeStart ? toDateKey(rangeStart) : null;\n\tconst endKey = rangeEnd ? toDateKey(rangeEnd) : null;\n\tconst selectedKey = selectedDate ? toDateKey(selectedDate) : null;\n\n\tconst rangeStartDay = rangeStart ? startOfDay(rangeStart) : null;\n\tconst rangeEndDay = rangeEnd ? startOfDay(rangeEnd) : null;\n\tconst rangeMin =\n\t\trangeStartDay && rangeEndDay\n\t\t\t? rangeStartDay < rangeEndDay\n\t\t\t\t? rangeStartDay\n\t\t\t\t: rangeEndDay\n\t\t\t: null;\n\tconst rangeMax =\n\t\trangeStartDay && rangeEndDay\n\t\t\t? rangeStartDay > rangeEndDay\n\t\t\t\t? rangeStartDay\n\t\t\t\t: rangeEndDay\n\t\t\t: null;\n\n\treturn (\n\t\t<div className=\"grid grid-cols-7 gap-0.5 px-2 pb-2\">\n\t\t\t{monthDays.map((day, idx) => {\n\t\t\t\tconst dayKey = toDateKey(day.date);\n\t\t\t\tconst isSelected = selectedKey && dayKey === selectedKey;\n\t\t\t\tconst isRangeStart = startKey && dayKey === startKey;\n\t\t\t\tconst isRangeEnd = endKey && dayKey === endKey;\n\t\t\t\tconst isInRange =\n\t\t\t\t\trangeMin &&\n\t\t\t\t\trangeMax &&\n\t\t\t\t\tstartOfDay(day.date) >= rangeMin &&\n\t\t\t\t\tstartOfDay(day.date) <= rangeMax;\n\t\t\t\tconst dayOfWeek = (idx % 7) + 1;\n\t\t\t\tconst isWeekend = dayOfWeek >= 6;\n\n\t\t\t\treturn (\n\t\t\t\t\t<button\n\t\t\t\t\t\tkey={toDateKey(day.date)}\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tonClick={() => onSelectDate(day)}\n\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\"relative flex flex-col items-center rounded-lg py-1 transition-colors\",\n\t\t\t\t\t\t\t!day.inCurrentMonth && \"opacity-40\",\n\t\t\t\t\t\t\t(isSelected || isRangeStart || isRangeEnd) &&\n\t\t\t\t\t\t\t\t\"bg-primary text-primary-foreground\",\n\t\t\t\t\t\t\t!isSelected &&\n\t\t\t\t\t\t\t\t!isRangeStart &&\n\t\t\t\t\t\t\t\t!isRangeEnd &&\n\t\t\t\t\t\t\t\tisInRange &&\n\t\t\t\t\t\t\t\t\"bg-primary/10 text-primary\",\n\t\t\t\t\t\t\t!isSelected &&\n\t\t\t\t\t\t\t\t!isRangeStart &&\n\t\t\t\t\t\t\t\t!isRangeEnd &&\n\t\t\t\t\t\t\t\t!isInRange &&\n\t\t\t\t\t\t\t\t(day.isToday\n\t\t\t\t\t\t\t\t\t? \"bg-primary/5 text-primary\"\n\t\t\t\t\t\t\t\t\t: \"hover:bg-muted/50\"),\n\t\t\t\t\t\t)}\n\t\t\t\t\t>\n\t\t\t\t\t\t<span\n\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\"text-sm font-medium\",\n\t\t\t\t\t\t\t\tisWeekend && !(isSelected || isRangeStart || isRangeEnd) &&\n\t\t\t\t\t\t\t\t\t\"text-muted-foreground/80\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{day.date.getDate()}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<span\n\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\"text-[10px] leading-tight\",\n\t\t\t\t\t\t\t\tisSelected || isRangeStart || isRangeEnd\n\t\t\t\t\t\t\t\t\t? \"text-primary-foreground/80\"\n\t\t\t\t\t\t\t\t\t: day.lunarText.includes(\"月\") ||\n\t\t\t\t\t\t\t\t\t\t\tSOLAR_TERMS.includes(\n\t\t\t\t\t\t\t\t\t\t\t\tday.lunarText as (typeof SOLAR_TERMS)[number],\n\t\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t\t? \"text-orange-500\"\n\t\t\t\t\t\t\t\t\t\t: \"text-muted-foreground/60\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{showLunar ? day.lunarText : \"\"}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t{day.holiday?.isHoliday !== undefined && (\n\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\"absolute -right-0.5 -top-0.5 flex h-3.5 w-3.5 items-center justify-center rounded-sm text-[8px] font-bold\",\n\t\t\t\t\t\t\t\t\tday.holiday.isHoliday\n\t\t\t\t\t\t\t\t\t\t? \"bg-green-500 text-white\"\n\t\t\t\t\t\t\t\t\t\t: \"bg-orange-500 text-white\",\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{day.holiday.isHoliday ? \"休\" : \"班\"}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</button>\n\t\t\t\t);\n\t\t\t})}\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/todo-detail/components/DatePickerPopover.tsx",
    "content": "\"use client\";\n\nimport { X } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport {\n\ttype RefObject,\n\tuseCallback,\n\tuseEffect,\n\tuseMemo,\n\tuseRef,\n\tuseState,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { normalizeReminderOffsets } from \"@/lib/reminders\";\nimport { useLocaleStore } from \"@/lib/store/locale\";\nimport type { UpdateTodoInput } from \"@/lib/types\";\nimport { cn } from \"@/lib/utils\";\nimport {\n\tbuildMonthDays,\n\ttype CalendarDay,\n\tstartOfDay,\n} from \"../utils\";\nimport { CalendarGrid, MonthNavigation, WeekdayHeader } from \"./DatePickerCalendar\";\nimport { DatePickerSidePanel } from \"./DatePickerSidePanel\";\nimport {\n\taddMinutes,\n\tbuildIsoWithZone,\n\tDEFAULT_RANGE_MINUTES,\n\tDEFAULT_TIME,\n\tgetTimeZoneOptions,\n\tresolveTimeZone,\n\ttoCalendarDate,\n\ttoTimeValue,\n} from \"./datePickerUtils\";\n\ninterface DatePickerPopoverProps {\n\tanchorRef: RefObject<HTMLElement | null>;\n\tstartTime?: string;\n\tendTime?: string;\n\ttimeZone?: string;\n\tisAllDay?: boolean;\n\treminderOffsets?: number[] | null;\n\trrule?: string | null;\n\tonSave: (input: UpdateTodoInput) => void;\n\tonClose: () => void;\n}\n\nconst POPOVER_MARGIN = 8;\n\ntype TabKey = \"date\" | \"range\";\n\ntype DateTarget = \"start\" | \"end\";\n\nexport function DatePickerPopover({\n\tanchorRef,\n\tstartTime,\n\tendTime,\n\ttimeZone,\n\tisAllDay,\n\treminderOffsets,\n\trrule,\n\tonSave,\n\tonClose,\n}: DatePickerPopoverProps) {\n\tconst popoverRef = useRef<HTMLDivElement>(null);\n\tconst { locale } = useLocaleStore();\n\tconst tCalendar = useTranslations(\"calendar\");\n\tconst tDatePicker = useTranslations(\"datePicker\");\n\tconst tReminder = useTranslations(\"reminder\");\n\tconst tTodoDetail = useTranslations(\"todoDetail\");\n\n\tconst initialTimeZone = useMemo(\n\t\t() => resolveTimeZone(timeZone),\n\t\t[timeZone],\n\t);\n\tconst initialStartDate = useMemo(\n\t\t() => toCalendarDate(startTime ?? endTime, initialTimeZone),\n\t\t[startTime, endTime, initialTimeZone],\n\t);\n\tconst initialEndDate = useMemo(\n\t\t() => toCalendarDate(endTime, initialTimeZone),\n\t\t[endTime, initialTimeZone],\n\t);\n\n\tconst [activeTab, setActiveTab] = useState<TabKey>(() =>\n\t\tendTime ? \"range\" : \"date\",\n\t);\n\tconst [activeDateTarget, setActiveDateTarget] = useState<DateTarget>(() =>\n\t\tendTime ? \"end\" : \"start\",\n\t);\n\tconst [currentMonth, setCurrentMonth] = useState<Date>(\n\t\t() => initialStartDate ?? new Date(),\n\t);\n\tconst [selectedStartDate, setSelectedStartDate] = useState<Date | null>(\n\t\t() => initialStartDate,\n\t);\n\tconst [selectedEndDate, setSelectedEndDate] = useState<Date | null>(\n\t\t() => initialEndDate,\n\t);\n\tconst [startTimeInput, setStartTimeInput] = useState<string>(() =>\n\t\tisAllDay ? \"\" : toTimeValue(startTime, initialTimeZone),\n\t);\n\tconst [endTimeInput, setEndTimeInput] = useState<string>(() => {\n\t\tconst endValue = toTimeValue(endTime, initialTimeZone);\n\t\tif (endValue) return endValue;\n\t\tconst startValue = toTimeValue(startTime, initialTimeZone);\n\t\treturn startValue ? addMinutes(startValue, DEFAULT_RANGE_MINUTES) : \"\";\n\t});\n\tconst [draftReminderOffsets, setDraftReminderOffsets] = useState<number[]>(\n\t\t() => normalizeReminderOffsets(reminderOffsets),\n\t);\n\tconst [draftRrule, setDraftRrule] = useState<string | null>(() => rrule ?? null);\n\tconst [draftTimeZone, setDraftTimeZone] = useState<string>(initialTimeZone);\n\n\tuseEffect(() => {\n\t\tif (activeTab === \"date\") {\n\t\t\tsetActiveDateTarget(\"start\");\n\t\t}\n\t}, [activeTab]);\n\n\tconst monthDays = useMemo(\n\t\t() => buildMonthDays(currentMonth),\n\t\t[currentMonth],\n\t);\n\tconst showLunar = locale === \"zh\";\n\n\tconst timeZoneOptions = useMemo(() => {\n\t\tconst options = getTimeZoneOptions();\n\t\tif (!draftTimeZone) return options;\n\t\tif (options.includes(draftTimeZone)) return options;\n\t\treturn [draftTimeZone, ...options];\n\t}, [draftTimeZone]);\n\n\tconst updatePosition = useCallback(() => {\n\t\tif (typeof window === \"undefined\") return;\n\t\tconst anchor = anchorRef.current;\n\t\tconst popover = popoverRef.current;\n\t\tif (!anchor || !popover) return;\n\n\t\tconst anchorRect = anchor.getBoundingClientRect();\n\t\tconst popoverRect = popover.getBoundingClientRect();\n\t\tconst viewportWidth = window.innerWidth;\n\t\tconst viewportHeight = window.innerHeight;\n\n\t\tlet left = anchorRect.left;\n\t\tlet top = anchorRect.bottom + POPOVER_MARGIN;\n\n\t\tif (left + popoverRect.width > viewportWidth - POPOVER_MARGIN) {\n\t\t\tleft = viewportWidth - popoverRect.width - POPOVER_MARGIN;\n\t\t}\n\t\tif (left < POPOVER_MARGIN) {\n\t\t\tleft = POPOVER_MARGIN;\n\t\t}\n\t\tif (top + popoverRect.height > viewportHeight - POPOVER_MARGIN) {\n\t\t\ttop = anchorRect.top - popoverRect.height - POPOVER_MARGIN;\n\t\t}\n\t\tif (top < POPOVER_MARGIN) {\n\t\t\ttop = POPOVER_MARGIN;\n\t\t}\n\n\t\tpopover.style.left = `${Math.round(left)}px`;\n\t\tpopover.style.top = `${Math.round(top)}px`;\n\t}, [anchorRef]);\n\n\tuseEffect(() => {\n\t\tupdatePosition();\n\t\tconst handleResize = () => updatePosition();\n\t\twindow.addEventListener(\"resize\", handleResize);\n\t\twindow.addEventListener(\"scroll\", handleResize, true);\n\n\t\treturn () => {\n\t\t\twindow.removeEventListener(\"resize\", handleResize);\n\t\t\twindow.removeEventListener(\"scroll\", handleResize, true);\n\t\t};\n\t}, [updatePosition]);\n\n\tuseEffect(() => {\n\t\tconst raf = window.requestAnimationFrame(updatePosition);\n\t\treturn () => window.cancelAnimationFrame(raf);\n\t}, [updatePosition]);\n\n\tuseEffect(() => {\n\t\tconst handleClickOutside = (event: MouseEvent) => {\n\t\t\tconst target = event.target as Node;\n\t\t\tif (popoverRef.current?.contains(target)) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tonClose();\n\t\t};\n\n\t\tconst handleKeyDown = (event: KeyboardEvent) => {\n\t\t\tif (event.key === \"Escape\") {\n\t\t\t\tonClose();\n\t\t\t}\n\t\t};\n\n\t\tdocument.addEventListener(\"mousedown\", handleClickOutside);\n\t\tdocument.addEventListener(\"keydown\", handleKeyDown);\n\n\t\treturn () => {\n\t\t\tdocument.removeEventListener(\"mousedown\", handleClickOutside);\n\t\t\tdocument.removeEventListener(\"keydown\", handleKeyDown);\n\t\t};\n\t}, [onClose]);\n\n\tconst handlePrevMonth = () => {\n\t\tsetCurrentMonth((prev) => {\n\t\t\tconst next = new Date(prev);\n\t\t\tnext.setMonth(next.getMonth() - 1);\n\t\t\treturn next;\n\t\t});\n\t};\n\n\tconst handleNextMonth = () => {\n\t\tsetCurrentMonth((prev) => {\n\t\t\tconst next = new Date(prev);\n\t\t\tnext.setMonth(next.getMonth() + 1);\n\t\t\treturn next;\n\t\t});\n\t};\n\n\tconst handleToday = () => {\n\t\tconst today = new Date();\n\t\tsetCurrentMonth(today);\n\t\tsetSelectedStartDate(today);\n\t\tif (activeTab === \"range\" && activeDateTarget === \"end\") {\n\t\t\tsetSelectedEndDate(today);\n\t\t}\n\t};\n\n\tconst handleSelectDate = (day: CalendarDay) => {\n\t\tif (activeTab === \"range\" && activeDateTarget === \"end\") {\n\t\t\tsetSelectedEndDate(day.date);\n\t\t\tif (!selectedStartDate) {\n\t\t\t\tsetSelectedStartDate(day.date);\n\t\t\t} else if (startOfDay(day.date) < startOfDay(selectedStartDate)) {\n\t\t\t\tsetSelectedStartDate(day.date);\n\t\t\t}\n\t\t} else {\n\t\t\tsetSelectedStartDate(day.date);\n\t\t\tif (\n\t\t\t\tselectedEndDate &&\n\t\t\t\tstartOfDay(selectedEndDate) < startOfDay(day.date)\n\t\t\t) {\n\t\t\t\tsetSelectedEndDate(day.date);\n\t\t\t}\n\t\t}\n\t\tif (day.date.getMonth() !== currentMonth.getMonth()) {\n\t\t\tsetCurrentMonth(day.date);\n\t\t}\n\t};\n\n\tconst handleClear = () => {\n\t\tonSave({\n\t\t\tstartTime: null,\n\t\t\tendTime: null,\n\t\t\treminderOffsets: [],\n\t\t\trrule: null,\n\t\t\ttimeZone: null,\n\t\t\tisAllDay: null,\n\t\t});\n\t\tonClose();\n\t};\n\n\tconst handleSave = () => {\n\t\tif (!selectedStartDate) return;\n\t\tconst payload: UpdateTodoInput = {\n\t\t\treminderOffsets: draftReminderOffsets,\n\t\t\trrule: draftRrule,\n\t\t\ttimeZone: draftTimeZone,\n\t\t};\n\t\tconst zone = resolveTimeZone(draftTimeZone);\n\n\t\tif (activeTab === \"date\") {\n\t\t\tconst timeValue = startTimeInput || \"00:00\";\n\t\t\tpayload.startTime = buildIsoWithZone(selectedStartDate, timeValue, zone);\n\t\t\tpayload.endTime = null;\n\t\t\tpayload.isAllDay = !startTimeInput;\n\t\t} else {\n\t\t\tconst effectiveEndDate = selectedEndDate ?? selectedStartDate;\n\t\t\tconst startValue = startTimeInput || DEFAULT_TIME;\n\t\t\tconst endValue = endTimeInput || addMinutes(startValue, DEFAULT_RANGE_MINUTES);\n\t\t\tpayload.startTime = buildIsoWithZone(selectedStartDate, startValue, zone);\n\t\t\tpayload.endTime = buildIsoWithZone(effectiveEndDate, endValue, zone);\n\t\t\tpayload.isAllDay = false;\n\t\t}\n\n\t\tonSave(payload);\n\t\tonClose();\n\t};\n\n\tconst canSave = useMemo(() => {\n\t\tif (!selectedStartDate) return false;\n\t\tif (activeTab === \"range\") {\n\t\t\treturn Boolean(startTimeInput && endTimeInput);\n\t\t}\n\t\treturn true;\n\t}, [activeTab, endTimeInput, selectedStartDate, startTimeInput]);\n\n\tif (typeof document === \"undefined\") {\n\t\treturn null;\n\t}\n\n\treturn createPortal(\n\t\t<div className=\"fixed inset-0 z-[10000] pointer-events-none\">\n\t\t\t<div\n\t\t\t\tref={popoverRef}\n\t\t\t\tclassName={cn(\n\t\t\t\t\t\"pointer-events-auto w-[620px] max-w-[95vw] overflow-hidden rounded-2xl border border-border bg-popover text-popover-foreground shadow-[0_40px_80px_-40px_oklch(var(--primary)/0.5)]\",\n\t\t\t\t)}\n\t\t\t\tstyle={{ position: \"absolute\", left: -9999, top: -9999 }}\n\t\t\t>\n\t\t\t\t<div className=\"flex items-center justify-between gap-4 border-b border-border/70 px-4 py-3\">\n\t\t\t\t\t<div className=\"flex rounded-full bg-muted/60 p-1 text-xs\">\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tonClick={() => setActiveTab(\"date\")}\n\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\"px-3 py-1.5 rounded-full font-medium transition\",\n\t\t\t\t\t\t\t\tactiveTab === \"date\"\n\t\t\t\t\t\t\t\t\t? \"bg-background text-foreground shadow-sm\"\n\t\t\t\t\t\t\t\t\t: \"text-muted-foreground hover:text-foreground\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{tDatePicker(\"dateTab\")}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tonClick={() => setActiveTab(\"range\")}\n\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\"px-3 py-1.5 rounded-full font-medium transition\",\n\t\t\t\t\t\t\t\tactiveTab === \"range\"\n\t\t\t\t\t\t\t\t\t? \"bg-background text-foreground shadow-sm\"\n\t\t\t\t\t\t\t\t\t: \"text-muted-foreground hover:text-foreground\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{tDatePicker(\"rangeTab\")}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</div>\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tonClick={onClose}\n\t\t\t\t\t\tclassName=\"inline-flex h-8 w-8 items-center justify-center rounded-full border border-border/70 text-muted-foreground transition hover:bg-muted/60 hover:text-foreground\"\n\t\t\t\t\t\taria-label={tTodoDetail(\"cancel\")}\n\t\t\t\t\t>\n\t\t\t\t\t\t<X className=\"h-4 w-4\" />\n\t\t\t\t\t</button>\n\t\t\t\t</div>\n\n\t\t\t\t<div className=\"grid grid-cols-[1fr_240px] gap-0\">\n\t\t\t\t\t<div className=\"px-4 py-3\">\n\t\t\t\t\t\t<MonthNavigation\n\t\t\t\t\t\t\tcurrentMonth={currentMonth}\n\t\t\t\t\t\t\tonPrevMonth={handlePrevMonth}\n\t\t\t\t\t\t\tonNextMonth={handleNextMonth}\n\t\t\t\t\t\t\tonToday={handleToday}\n\t\t\t\t\t\t\ttCalendar={tCalendar}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<WeekdayHeader tCalendar={tCalendar} />\n\t\t\t\t\t\t<CalendarGrid\n\t\t\t\t\t\t\tmonthDays={monthDays}\n\t\t\t\t\t\t\tselectedDate={selectedStartDate}\n\t\t\t\t\t\t\trangeStart={activeTab === \"range\" ? selectedStartDate : null}\n\t\t\t\t\t\t\trangeEnd={activeTab === \"range\" ? selectedEndDate : null}\n\t\t\t\t\t\t\tshowLunar={showLunar}\n\t\t\t\t\t\t\tonSelectDate={handleSelectDate}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t<DatePickerSidePanel\n\t\t\t\t\t\tactiveTab={activeTab}\n\t\t\t\t\t\tactiveDateTarget={activeDateTarget}\n\t\t\t\t\t\tselectedStartDate={selectedStartDate}\n\t\t\t\t\t\tselectedEndDate={selectedEndDate}\n\t\t\t\t\t\tstartTimeInput={startTimeInput}\n\t\t\t\t\t\tendTimeInput={endTimeInput}\n\t\t\t\t\t\tonStartTimeChange={setStartTimeInput}\n\t\t\t\t\t\tonEndTimeChange={setEndTimeInput}\n\t\t\t\t\t\tonActiveDateTargetChange={setActiveDateTarget}\n\t\t\t\t\t\tdraftReminderOffsets={draftReminderOffsets}\n\t\t\t\t\t\tonReminderOffsetsChange={setDraftReminderOffsets}\n\t\t\t\t\t\tdraftRrule={draftRrule}\n\t\t\t\t\t\tonRruleChange={setDraftRrule}\n\t\t\t\t\t\ttimeZoneOptions={timeZoneOptions}\n\t\t\t\t\t\tdraftTimeZone={draftTimeZone}\n\t\t\t\t\t\tonTimeZoneChange={setDraftTimeZone}\n\t\t\t\t\t\ttDatePicker={tDatePicker}\n\t\t\t\t\t\ttReminder={tReminder}\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\n\t\t\t\t<div className=\"flex items-center gap-2 border-t border-border/70 p-3\">\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tonClick={handleClear}\n\t\t\t\t\t\tclassName=\"flex-1 rounded-lg border border-border py-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground\"\n\t\t\t\t\t>\n\t\t\t\t\t\t{tTodoDetail(\"clear\")}\n\t\t\t\t\t</button>\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tonClick={handleSave}\n\t\t\t\t\t\tdisabled={!canSave}\n\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\"flex-1 rounded-lg bg-primary py-2 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90\",\n\t\t\t\t\t\t\t!canSave && \"cursor-not-allowed opacity-60\",\n\t\t\t\t\t\t)}\n\t\t\t\t\t>\n\t\t\t\t\t\t{tTodoDetail(\"save\")}\n\t\t\t\t\t</button>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>,\n\t\tdocument.body,\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/todo-detail/components/DatePickerSidePanel.tsx",
    "content": "\"use client\";\n\nimport { Bell, Calendar, Clock, Globe, Repeat } from \"lucide-react\";\nimport type { useTranslations } from \"next-intl\";\nimport { ReminderOptions } from \"@/components/common/ReminderOptions\";\nimport { cn } from \"@/lib/utils\";\nimport { formatDateLabel } from \"./datePickerUtils\";\n\ntype TabKey = \"date\" | \"range\";\ntype DateTarget = \"start\" | \"end\";\n\ninterface DatePickerSidePanelProps {\n\tactiveTab: TabKey;\n\tactiveDateTarget: DateTarget;\n\tselectedStartDate: Date | null;\n\tselectedEndDate: Date | null;\n\tstartTimeInput: string;\n\tendTimeInput: string;\n\tonStartTimeChange: (value: string) => void;\n\tonEndTimeChange: (value: string) => void;\n\tonActiveDateTargetChange: (value: DateTarget) => void;\n\tdraftReminderOffsets: number[];\n\tonReminderOffsetsChange: (value: number[]) => void;\n\tdraftRrule: string | null;\n\tonRruleChange: (value: string | null) => void;\n\ttimeZoneOptions: string[];\n\tdraftTimeZone: string;\n\tonTimeZoneChange: (value: string) => void;\n\ttDatePicker: ReturnType<typeof useTranslations<\"datePicker\">>;\n\ttReminder: ReturnType<typeof useTranslations<\"reminder\">>;\n}\n\nexport function DatePickerSidePanel({\n\tactiveTab,\n\tactiveDateTarget,\n\tselectedStartDate,\n\tselectedEndDate,\n\tstartTimeInput,\n\tendTimeInput,\n\tonStartTimeChange,\n\tonEndTimeChange,\n\tonActiveDateTargetChange,\n\tdraftReminderOffsets,\n\tonReminderOffsetsChange,\n\tdraftRrule,\n\tonRruleChange,\n\ttimeZoneOptions,\n\tdraftTimeZone,\n\tonTimeZoneChange,\n\ttDatePicker,\n\ttReminder,\n}: DatePickerSidePanelProps) {\n\tconst repeatOptions = [\n\t\t{ value: \"\", label: tDatePicker(\"repeatNone\") },\n\t\t{ value: \"FREQ=DAILY\", label: tDatePicker(\"repeatDaily\") },\n\t\t{ value: \"FREQ=WEEKLY\", label: tDatePicker(\"repeatWeekly\") },\n\t\t{ value: \"FREQ=MONTHLY\", label: tDatePicker(\"repeatMonthly\") },\n\t\t{ value: \"FREQ=YEARLY\", label: tDatePicker(\"repeatYearly\") },\n\t];\n\n\treturn (\n\t\t<div className=\"border-l border-border/70 px-4 py-4 space-y-4\">\n\t\t\t{activeTab === \"date\" ? (\n\t\t\t\t<div className=\"space-y-4\">\n\t\t\t\t\t<div className=\"space-y-2\">\n\t\t\t\t\t\t<span className=\"text-xs font-medium text-muted-foreground\">\n\t\t\t\t\t\t\t{tDatePicker(\"dateLabel\")}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tonClick={() => onActiveDateTargetChange(\"start\")}\n\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\"flex w-full items-center justify-between rounded-lg border px-3 py-2 text-sm\",\n\t\t\t\t\t\t\t\tactiveDateTarget === \"start\"\n\t\t\t\t\t\t\t\t\t? \"border-primary/60 bg-primary/5 text-foreground\"\n\t\t\t\t\t\t\t\t\t: \"border-border/70 text-muted-foreground\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<span className=\"flex items-center gap-2\">\n\t\t\t\t\t\t\t\t<Calendar className=\"h-4 w-4\" />\n\t\t\t\t\t\t\t\t{formatDateLabel(selectedStartDate) ||\n\t\t\t\t\t\t\t\t\ttDatePicker(\"pickDate\")}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"space-y-2\">\n\t\t\t\t\t\t<span className=\"text-xs font-medium text-muted-foreground\">\n\t\t\t\t\t\t\t{tDatePicker(\"timeLabel\")}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"time\"\n\t\t\t\t\t\t\t\tvalue={startTimeInput}\n\t\t\t\t\t\t\t\tonChange={(event) => onStartTimeChange(event.target.value)}\n\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\"flex-1 rounded-lg border border-border/70 bg-background px-3 py-2 text-sm\",\n\t\t\t\t\t\t\t\t\t\"focus:outline-none focus:ring-2 focus:ring-primary/30\",\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\tonClick={() => onStartTimeChange(\"\")}\n\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\"rounded-lg border border-border/70 px-3 py-2 text-xs font-medium\",\n\t\t\t\t\t\t\t\t\t!startTimeInput\n\t\t\t\t\t\t\t\t\t\t? \"bg-primary/10 text-primary\"\n\t\t\t\t\t\t\t\t\t\t: \"text-muted-foreground hover:bg-muted/60\",\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{tDatePicker(\"allDay\")}\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t<div className=\"space-y-2\">\n\t\t\t\t\t\t<span className=\"flex items-center gap-2 text-xs font-medium text-muted-foreground\">\n\t\t\t\t\t\t\t<Bell className=\"h-3.5 w-3.5\" />\n\t\t\t\t\t\t\t{tReminder(\"label\")}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\"rounded-lg border border-border/60 bg-background/70 p-2\",\n\t\t\t\t\t\t\t\t!selectedStartDate && \"pointer-events-none opacity-60\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<ReminderOptions\n\t\t\t\t\t\t\t\tvalue={draftReminderOffsets}\n\t\t\t\t\t\t\t\tonChange={onReminderOffsetsChange}\n\t\t\t\t\t\t\t\tcompact\n\t\t\t\t\t\t\t\tshowClear\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t<div className=\"space-y-2\">\n\t\t\t\t\t\t<span className=\"flex items-center gap-2 text-xs font-medium text-muted-foreground\">\n\t\t\t\t\t\t\t<Repeat className=\"h-3.5 w-3.5\" />\n\t\t\t\t\t\t\t{tDatePicker(\"repeatLabel\")}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<select\n\t\t\t\t\t\t\tvalue={draftRrule ?? \"\"}\n\t\t\t\t\t\t\tonChange={(event) => onRruleChange(event.target.value || null)}\n\t\t\t\t\t\t\tclassName=\"w-full rounded-lg border border-border/70 bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{repeatOptions.map((option) => (\n\t\t\t\t\t\t\t\t<option key={option.value} value={option.value}>\n\t\t\t\t\t\t\t\t\t{option.label}\n\t\t\t\t\t\t\t\t</option>\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t</select>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t) : (\n\t\t\t\t<div className=\"space-y-4\">\n\t\t\t\t\t<div className=\"space-y-2\">\n\t\t\t\t\t\t<span className=\"flex items-center gap-2 text-xs font-medium text-muted-foreground\">\n\t\t\t\t\t\t\t<Clock className=\"h-3.5 w-3.5\" />\n\t\t\t\t\t\t\t{tDatePicker(\"rangeLabel\")}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\tonClick={() => onActiveDateTargetChange(\"start\")}\n\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\"flex-1 rounded-lg border px-3 py-2 text-left\",\n\t\t\t\t\t\t\t\t\tactiveDateTarget === \"start\"\n\t\t\t\t\t\t\t\t\t\t? \"border-primary/60 bg-primary/5\"\n\t\t\t\t\t\t\t\t\t\t: \"border-border/70\",\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<div className=\"text-[11px] text-muted-foreground\">\n\t\t\t\t\t\t\t\t\t{tDatePicker(\"startLabel\")}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div className=\"text-sm text-foreground\">\n\t\t\t\t\t\t\t\t\t{formatDateLabel(selectedStartDate) ||\n\t\t\t\t\t\t\t\t\t\ttDatePicker(\"pickDate\")}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"time\"\n\t\t\t\t\t\t\t\tvalue={startTimeInput}\n\t\t\t\t\t\t\t\tonChange={(event) => onStartTimeChange(event.target.value)}\n\t\t\t\t\t\t\t\tclassName=\"w-24 rounded-lg border border-border/70 bg-background px-2 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\tonClick={() => onActiveDateTargetChange(\"end\")}\n\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\"flex-1 rounded-lg border px-3 py-2 text-left\",\n\t\t\t\t\t\t\t\t\tactiveDateTarget === \"end\"\n\t\t\t\t\t\t\t\t\t\t? \"border-primary/60 bg-primary/5\"\n\t\t\t\t\t\t\t\t\t\t: \"border-border/70\",\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<div className=\"text-[11px] text-muted-foreground\">\n\t\t\t\t\t\t\t\t\t{tDatePicker(\"endLabel\")}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div className=\"text-sm text-foreground\">\n\t\t\t\t\t\t\t\t\t{formatDateLabel(selectedEndDate ?? selectedStartDate) ||\n\t\t\t\t\t\t\t\t\t\ttDatePicker(\"pickDate\")}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"time\"\n\t\t\t\t\t\t\t\tvalue={endTimeInput}\n\t\t\t\t\t\t\t\tonChange={(event) => onEndTimeChange(event.target.value)}\n\t\t\t\t\t\t\t\tclassName=\"w-24 rounded-lg border border-border/70 bg-background px-2 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t)}\n\n\t\t\t<div className=\"space-y-2\">\n\t\t\t\t<span className=\"flex items-center gap-2 text-xs font-medium text-muted-foreground\">\n\t\t\t\t\t<Globe className=\"h-3.5 w-3.5\" />\n\t\t\t\t\t{tDatePicker(\"timeZoneLabel\")}\n\t\t\t\t</span>\n\t\t\t\t<select\n\t\t\t\t\tvalue={draftTimeZone}\n\t\t\t\t\tonChange={(event) => onTimeZoneChange(event.target.value)}\n\t\t\t\t\tclassName=\"w-full rounded-lg border border-border/70 bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30\"\n\t\t\t\t>\n\t\t\t\t\t{timeZoneOptions.map((option) => (\n\t\t\t\t\t\t<option key={option} value={option}>\n\t\t\t\t\t\t\t{option}\n\t\t\t\t\t\t</option>\n\t\t\t\t\t))}\n\t\t\t\t</select>\n\t\t\t</div>\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/todo-detail/components/DetailHeader.tsx",
    "content": "\"use client\";\n\nimport { CheckCircle2, FileText, Trash2 } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport {\n\tPanelActionButton,\n\tPanelHeader,\n} from \"@/components/common/layout/PanelHeader\";\nimport { cn } from \"@/lib/utils\";\n\ninterface DetailHeaderProps {\n\tonToggleComplete: () => void;\n\tonDelete: () => void;\n\tactiveView: \"detail\" | \"artifacts\";\n\tonViewChange: (view: \"detail\" | \"artifacts\") => void;\n}\n\nexport function DetailHeader({\n\tonToggleComplete,\n\tonDelete,\n\tactiveView,\n\tonViewChange,\n}: DetailHeaderProps) {\n\tconst t = useTranslations(\"page\");\n\tconst tTodoDetail = useTranslations(\"todoDetail\");\n\n\treturn (\n\t\t<PanelHeader\n\t\t\ticon={FileText}\n\t\t\ttitle={t(\"todoDetailLabel\")}\n\t\t\tactions={\n\t\t\t\t<>\n\t\t\t\t\t<div className=\"flex items-center rounded-full border border-border bg-muted/40 p-0.5 text-xs\">\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tonClick={() => onViewChange(\"detail\")}\n\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\"rounded-full px-2.5 py-1 font-medium transition-colors\",\n\t\t\t\t\t\t\t\tactiveView === \"detail\"\n\t\t\t\t\t\t\t\t\t? \"bg-foreground text-background\"\n\t\t\t\t\t\t\t\t\t: \"text-muted-foreground hover:text-foreground\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{tTodoDetail(\"detailViewLabel\")}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tonClick={() => onViewChange(\"artifacts\")}\n\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\"rounded-full px-2.5 py-1 font-medium transition-colors\",\n\t\t\t\t\t\t\t\tactiveView === \"artifacts\"\n\t\t\t\t\t\t\t\t\t? \"bg-foreground text-background\"\n\t\t\t\t\t\t\t\t\t: \"text-muted-foreground hover:text-foreground\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{tTodoDetail(\"artifactsViewLabel\")}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</div>\n\t\t\t\t\t<PanelActionButton\n\t\t\t\t\t\tvariant=\"default\"\n\t\t\t\t\t\ticon={CheckCircle2}\n\t\t\t\t\t\tonClick={onToggleComplete}\n\t\t\t\t\t\taria-label={tTodoDetail(\"markAsComplete\")}\n\t\t\t\t\t/>\n\t\t\t\t\t<PanelActionButton\n\t\t\t\t\t\tvariant=\"destructive\"\n\t\t\t\t\t\ticon={Trash2}\n\t\t\t\t\t\tonClick={onDelete}\n\t\t\t\t\t\taria-label={tTodoDetail(\"delete\")}\n\t\t\t\t\t/>\n\t\t\t\t</>\n\t\t\t}\n\t\t/>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/todo-detail/components/DetailTitle.tsx",
    "content": "\"use client\";\n\nimport { useTranslations } from \"next-intl\";\nimport { useEffect, useRef, useState } from \"react\";\n\ninterface DetailTitleProps {\n\tname: string;\n\tonNameChange?: (newName: string) => void;\n}\n\nexport function DetailTitle({ name, onNameChange }: DetailTitleProps) {\n\tconst t = useTranslations(\"todoDetail\");\n\tconst [isEditing, setIsEditing] = useState(false);\n\tconst [editValue, setEditValue] = useState(name);\n\tconst [isComposing, setIsComposing] = useState(false);\n\tconst inputRef = useRef<HTMLInputElement>(null);\n\n\t// 当进入编辑模式时，聚焦输入框\n\tuseEffect(() => {\n\t\tif (isEditing && inputRef.current) {\n\t\t\tinputRef.current.focus();\n\t\t\tinputRef.current.select();\n\t\t}\n\t}, [isEditing]);\n\n\t// 同步外部 name 的变化\n\tuseEffect(() => {\n\t\tsetEditValue(name);\n\t}, [name]);\n\n\tconst handleStartEdit = () => {\n\t\tsetIsEditing(true);\n\t\tsetEditValue(name);\n\t};\n\n\tconst handleSave = () => {\n\t\tconst trimmedValue = editValue.trim();\n\t\tif (trimmedValue && trimmedValue !== name) {\n\t\t\tonNameChange?.(trimmedValue);\n\t\t} else {\n\t\t\tsetEditValue(name);\n\t\t}\n\t\tsetIsEditing(false);\n\t};\n\n\tconst handleCancel = () => {\n\t\tsetEditValue(name);\n\t\tsetIsEditing(false);\n\t};\n\n\tconst handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {\n\t\t// 如果正在使用输入法，不处理回车键（让输入法完成输入）\n\t\tif (e.key === \"Enter\" && !isComposing) {\n\t\t\te.preventDefault();\n\t\t\thandleSave();\n\t\t} else if (e.key === \"Escape\") {\n\t\t\te.preventDefault();\n\t\t\thandleCancel();\n\t\t}\n\t};\n\n\treturn (\n\t\t<div className=\"mb-4 flex items-center justify-between gap-3\">\n\t\t\t{isEditing ? (\n\t\t\t\t<input\n\t\t\t\t\tref={inputRef}\n\t\t\t\t\ttype=\"text\"\n\t\t\t\t\tvalue={editValue}\n\t\t\t\t\tonChange={(e) => setEditValue(e.target.value)}\n\t\t\t\t\tonBlur={handleSave}\n\t\t\t\t\tonKeyDown={handleKeyDown}\n\t\t\t\t\tonCompositionStart={() => setIsComposing(true)}\n\t\t\t\t\tonCompositionEnd={() => setIsComposing(false)}\n\t\t\t\t\tclassName=\"flex-1 text-xl text-foreground bg-transparent border-b-2 border-primary focus:outline-none\"\n\t\t\t\t/>\n\t\t\t) : (\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tonClick={handleStartEdit}\n\t\t\t\t\taria-label={t(\"editTitle\")}\n\t\t\t\t\tclassName=\"text-xl text-foreground cursor-pointer hover:text-primary/80 transition-colors focus:outline-none focus:ring-2 focus:ring-primary/50 rounded-sm text-left flex-1\"\n\t\t\t\t>\n\t\t\t\t\t{name}\n\t\t\t\t</button>\n\t\t\t)}\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/todo-detail/components/MetaSection.tsx",
    "content": "\"use client\";\n\nimport { Calendar, Flag, Tag as TagIcon } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport { useEffect, useRef, useState } from \"react\";\nimport type { Todo, TodoPriority, TodoStatus, UpdateTodoInput } from \"@/lib/types\";\nimport { cn, getPriorityLabel, getStatusLabel } from \"@/lib/utils\";\nimport {\n\tformatScheduleSummary,\n\tgetPriorityClassNames,\n\tgetStatusClassNames,\n\tpriorityOptions,\n\tstatusOptions,\n} from \"../helpers\";\nimport { DatePickerPopover } from \"./DatePickerPopover\";\n\ninterface MetaSectionProps {\n\ttodo: Todo;\n\tonStatusChange: (status: TodoStatus) => void;\n\tonPriorityChange: (priority: TodoPriority) => void;\n\tonTagsChange: (tags: string[]) => void;\n\tonScheduleChange: (input: UpdateTodoInput) => void;\n}\n\nexport function MetaSection({\n\ttodo,\n\tonStatusChange,\n\tonPriorityChange,\n\tonTagsChange,\n\tonScheduleChange,\n}: MetaSectionProps) {\n\tconst tCommon = useTranslations(\"common\");\n\tconst tTodoDetail = useTranslations(\"todoDetail\");\n\tconst statusMenuRef = useRef<HTMLDivElement | null>(null);\n\tconst priorityMenuRef = useRef<HTMLDivElement | null>(null);\n\tconst scheduleButtonRef = useRef<HTMLButtonElement | null>(null);\n\n\tconst [isStatusMenuOpen, setIsStatusMenuOpen] = useState(false);\n\tconst [isPriorityMenuOpen, setIsPriorityMenuOpen] = useState(false);\n\tconst [isDatePickerOpen, setIsDatePickerOpen] = useState(false);\n\tconst [isEditingTags, setIsEditingTags] = useState(false);\n\tconst [tagsInput, setTagsInput] = useState(todo.tags?.join(\", \") ?? \"\");\n\n\tuseEffect(() => {\n\t\tconst handleClickOutside = (event: MouseEvent) => {\n\t\t\tconst target = event.target as Node;\n\t\t\tif (statusMenuRef.current && !statusMenuRef.current.contains(target)) {\n\t\t\t\tsetIsStatusMenuOpen(false);\n\t\t\t}\n\t\t\tif (\n\t\t\t\tpriorityMenuRef.current &&\n\t\t\t\t!priorityMenuRef.current.contains(target)\n\t\t\t) {\n\t\t\t\tsetIsPriorityMenuOpen(false);\n\t\t\t}\n\t\t};\n\n\t\tconst handleKeyDown = (event: KeyboardEvent) => {\n\t\t\tif (event.key === \"Escape\") {\n\t\t\t\tsetIsStatusMenuOpen(false);\n\t\t\t\tsetIsPriorityMenuOpen(false);\n\t\t\t}\n\t\t};\n\n\t\tdocument.addEventListener(\"mousedown\", handleClickOutside);\n\t\tdocument.addEventListener(\"keydown\", handleKeyDown);\n\n\t\treturn () => {\n\t\t\tdocument.removeEventListener(\"mousedown\", handleClickOutside);\n\t\t\tdocument.removeEventListener(\"keydown\", handleKeyDown);\n\t\t};\n\t}, []);\n\n\tuseEffect(() => {\n\t\tsetIsStatusMenuOpen(false);\n\t\tsetIsPriorityMenuOpen(false);\n\t\tsetIsDatePickerOpen(false);\n\t\tsetIsEditingTags(false);\n\t\tsetTagsInput(todo.tags?.join(\", \") ?? \"\");\n\t}, [todo.tags]);\n\n\tconst handleTagsSave = () => {\n\t\tconst parsedTags = tagsInput\n\t\t\t.split(\",\")\n\t\t\t.map((t) => t.trim())\n\t\t\t.filter(Boolean);\n\t\tonTagsChange(parsedTags);\n\t\tsetIsEditingTags(false);\n\t};\n\n\tconst handleTagsClear = () => {\n\t\tonTagsChange([]);\n\t\tsetTagsInput(\"\");\n\t\tsetIsEditingTags(false);\n\t};\n\n\tconst scheduleSummary =\n\t\tformatScheduleSummary({\n\t\t\tstartTime: todo.startTime,\n\t\t\tendTime: todo.endTime,\n\t\t\ttimeZone: todo.timeZone,\n\t\t\tisAllDay: todo.isAllDay,\n\t\t}) || tTodoDetail(\"addDeadline\");\n\n\treturn (\n\t\t<div className=\"mb-6 text-sm text-muted-foreground\">\n\t\t\t<div className=\"flex flex-wrap items-center gap-3\">\n\t\t\t\t<div className=\"relative flex items-center\" ref={statusMenuRef}>\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tonClick={() => setIsStatusMenuOpen((prev) => !prev)}\n\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\tgetStatusClassNames(todo.status),\n\t\t\t\t\t\t\t\"transition-colors hover:bg-muted/40\",\n\t\t\t\t\t\t)}\n\t\t\t\t\t\taria-expanded={isStatusMenuOpen}\n\t\t\t\t\t\taria-haspopup=\"listbox\"\n\t\t\t\t\t>\n\t\t\t\t\t\t{getStatusLabel(todo.status, tCommon)}\n\t\t\t\t\t</button>\n\t\t\t\t\t{isStatusMenuOpen && (\n\t\t\t\t\t\t<div className=\"pointer-events-auto absolute left-0 top-full z-120 mt-2 min-w-[170px] rounded-md border border-border bg-background shadow-lg\">\n\t\t\t\t\t\t\t<div className=\"py-1\" role=\"listbox\">\n\t\t\t\t\t\t\t\t{statusOptions.map((status) => (\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\tkey={status}\n\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\t\tif (status !== todo.status) {\n\t\t\t\t\t\t\t\t\t\t\t\tonStatusChange(status);\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\tsetIsStatusMenuOpen(false);\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\t\"flex w-full items-center justify-between px-3 py-2 text-left text-sm transition-colors\",\n\t\t\t\t\t\t\t\t\t\t\tstatus === todo.status\n\t\t\t\t\t\t\t\t\t\t\t\t? \"bg-muted/60 text-foreground\"\n\t\t\t\t\t\t\t\t\t\t\t\t: \"text-foreground hover:bg-muted/70\",\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\trole=\"option\"\n\t\t\t\t\t\t\t\t\t\taria-selected={status === todo.status}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<span className={getStatusClassNames(status)}>\n\t\t\t\t\t\t\t\t\t\t\t{getStatusLabel(status, tCommon)}\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t{status === todo.status && (\n\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-[11px] text-primary\">\n\t\t\t\t\t\t\t\t\t\t\t\t{tTodoDetail(\"current\")}\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\n\t\t\t\t<div className=\"relative flex items-center\" ref={priorityMenuRef}>\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tonClick={() => setIsPriorityMenuOpen((prev) => !prev)}\n\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\tgetPriorityClassNames(todo.priority ?? \"none\"),\n\t\t\t\t\t\t\t\"transition-colors hover:bg-muted/40\",\n\t\t\t\t\t\t)}\n\t\t\t\t\t\taria-expanded={isPriorityMenuOpen}\n\t\t\t\t\t\taria-haspopup=\"listbox\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<Flag className=\"h-3 w-3\" fill=\"currentColor\" aria-hidden />\n\t\t\t\t\t\t{getPriorityLabel(todo.priority ?? \"none\", tCommon)}\n\t\t\t\t\t</button>\n\t\t\t\t\t{isPriorityMenuOpen && (\n\t\t\t\t\t\t<div className=\"pointer-events-auto absolute left-0 top-full z-120 mt-2 min-w-[170px] rounded-md border border-border bg-background shadow-lg\">\n\t\t\t\t\t\t\t<div className=\"py-1\" role=\"listbox\">\n\t\t\t\t\t\t\t\t{priorityOptions.map((priority) => (\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\tkey={priority}\n\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\t\tif (priority !== (todo.priority ?? \"none\")) {\n\t\t\t\t\t\t\t\t\t\t\t\tonPriorityChange(priority);\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\tsetIsPriorityMenuOpen(false);\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\t\"flex w-full items-center justify-between px-3 py-2 text-left text-sm transition-colors\",\n\t\t\t\t\t\t\t\t\t\t\tpriority === (todo.priority ?? \"none\")\n\t\t\t\t\t\t\t\t\t\t\t\t? \"bg-muted/60 text-foreground\"\n\t\t\t\t\t\t\t\t\t\t\t\t: \"text-foreground hover:bg-muted/70\",\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\trole=\"option\"\n\t\t\t\t\t\t\t\t\t\taria-selected={priority === (todo.priority ?? \"none\")}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<span className={getPriorityClassNames(priority)}>\n\t\t\t\t\t\t\t\t\t\t\t<Flag\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"h-3.5 w-3.5\"\n\t\t\t\t\t\t\t\t\t\t\t\tfill=\"currentColor\"\n\t\t\t\t\t\t\t\t\t\t\t\taria-hidden\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t{getPriorityLabel(priority, tCommon)}\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t{priority === (todo.priority ?? \"none\") && (\n\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-[11px] text-primary\">\n\t\t\t\t\t\t\t\t\t\t\t\t{tTodoDetail(\"current\")}\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\n\t\t\t\t<div className=\"relative flex items-center\">\n\t\t\t\t\t<button\n\t\t\t\t\t\tref={scheduleButtonRef}\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tonClick={() => setIsDatePickerOpen((prev) => !prev)}\n\t\t\t\t\t\tclassName=\"flex items-center gap-1 rounded-md border border-transparent px-2 py-1 text-xs transition-colors hover:border-border hover:bg-muted/40\"\n\t\t\t\t\t\taria-expanded={isDatePickerOpen}\n\t\t\t\t\t\taria-haspopup=\"dialog\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<Calendar className=\"h-3 w-3\" />\n\t\t\t\t\t\t<span className=\"truncate\">{scheduleSummary}</span>\n\t\t\t\t\t</button>\n\t\t\t\t\t{isDatePickerOpen && (\n\t\t\t\t\t\t<DatePickerPopover\n\t\t\t\t\t\t\tanchorRef={scheduleButtonRef}\n\t\t\t\t\t\t\tstartTime={todo.startTime}\n\t\t\t\t\t\t\tendTime={todo.endTime}\n\t\t\t\t\t\t\ttimeZone={todo.timeZone}\n\t\t\t\t\t\t\tisAllDay={todo.isAllDay}\n\t\t\t\t\t\t\treminderOffsets={todo.reminderOffsets}\n\t\t\t\t\t\t\trrule={todo.rrule}\n\t\t\t\t\t\t\tonSave={(input) => onScheduleChange(input)}\n\t\t\t\t\t\t\tonClose={() => setIsDatePickerOpen(false)}\n\t\t\t\t\t\t/>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\tsetTagsInput(todo.tags?.join(\", \") ?? \"\");\n\t\t\t\t\t\tsetIsEditingTags(true);\n\t\t\t\t\t}}\n\t\t\t\t\tclassName=\"flex items-center gap-1 rounded-md border border-transparent px-2 py-1 text-xs transition-colors hover:border-border hover:bg-muted/40\"\n\t\t\t\t>\n\t\t\t\t\t<TagIcon className=\"h-3 w-3\" />\n\t\t\t\t\t<span className=\"truncate\">\n\t\t\t\t\t\t{todo.tags && todo.tags.length > 0\n\t\t\t\t\t\t\t? todo.tags.join(\", \")\n\t\t\t\t\t\t\t: tTodoDetail(\"addTags\")}\n\t\t\t\t\t</span>\n\t\t\t\t</button>\n\n\t\t\t</div>\n\n\t\t\t{isEditingTags && (\n\t\t\t\t<div className=\"mt-2 flex flex-wrap items-center gap-2 text-xs text-foreground\">\n\t\t\t\t\t<input\n\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\tvalue={tagsInput}\n\t\t\t\t\t\tonChange={(e) => setTagsInput(e.target.value)}\n\t\t\t\t\t\tplaceholder={tTodoDetail(\"tagsPlaceholder\")}\n\t\t\t\t\t\tclassName=\"min-w-[240px] rounded-md border border-border bg-background px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-primary\"\n\t\t\t\t\t/>\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tonClick={handleTagsSave}\n\t\t\t\t\t\tclassName=\"rounded-md bg-primary px-2 py-1 text-sm text-primary-foreground transition-colors hover:bg-primary/90\"\n\t\t\t\t\t>\n\t\t\t\t\t\t{tTodoDetail(\"save\")}\n\t\t\t\t\t</button>\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\tsetIsEditingTags(false);\n\t\t\t\t\t\t\tsetTagsInput(todo.tags?.join(\", \") ?? \"\");\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tclassName=\"rounded-md border border-border px-2 py-1 text-sm text-muted-foreground transition-colors hover:bg-muted/40\"\n\t\t\t\t\t>\n\t\t\t\t\t\t{tTodoDetail(\"cancel\")}\n\t\t\t\t\t</button>\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tonClick={handleTagsClear}\n\t\t\t\t\t\tclassName=\"rounded-md border border-destructive/40 px-2 py-1 text-sm text-destructive transition-colors hover:bg-destructive/10\"\n\t\t\t\t\t>\n\t\t\t\t\t\t{tTodoDetail(\"clear\")}\n\t\t\t\t\t</button>\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/todo-detail/components/NotesEditor.tsx",
    "content": "\"use client\";\n\nimport type { Editor } from \"@tiptap/core\";\nimport Placeholder from \"@tiptap/extension-placeholder\";\nimport { EditorContent, useEditor } from \"@tiptap/react\";\nimport StarterKit from \"@tiptap/starter-kit\";\nimport MarkdownIt from \"markdown-it\";\nimport { useTranslations } from \"next-intl\";\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport TurndownService from \"turndown\";\nimport { SectionHeader } from \"@/components/common/layout/SectionHeader\";\n\ninterface NotesEditorProps {\n\tvalue: string;\n\tshow: boolean;\n\tonToggle: () => void;\n\tonChange: (value: string) => void;\n\tonBlur?: () => void;\n}\n\nexport function NotesEditor({\n\tvalue,\n\tshow,\n\tonToggle,\n\tonChange,\n\tonBlur,\n}: NotesEditorProps) {\n\tconst t = useTranslations(\"todoDetail\");\n\tconst [isHovered, setIsHovered] = useState(false);\n\tconst lastValueRef = useRef(value);\n\n\tconst markdownParser = useMemo(\n\t\t() => new MarkdownIt({ breaks: true, linkify: true }),\n\t\t[],\n\t);\n\tconst turndown = useMemo(() => {\n\t\tconst service = new TurndownService({\n\t\t\tcodeBlockStyle: \"fenced\",\n\t\t\temDelimiter: \"*\",\n\t\t});\n\t\tservice.keep([\"del\"]);\n\t\treturn service;\n\t}, []);\n\n\tconst editor = useEditor({\n\t\timmediatelyRender: false,\n\t\textensions: [\n\t\t\tStarterKit,\n\t\t\tPlaceholder.configure({\n\t\t\t\tplaceholder: t(\"notesPlaceholder\"),\n\t\t\t\temptyEditorClass: \"text-muted-foreground\",\n\t\t\t}),\n\t\t],\n\t\tcontent: value ? markdownParser.render(value) : \"\",\n\t\tonUpdate: ({ editor }: { editor: Editor }) => {\n\t\t\tconst html = editor.getHTML();\n\t\t\tconst markdown = turndown.turndown(html);\n\t\t\tlastValueRef.current = markdown;\n\t\t\tonChange(markdown);\n\t\t},\n\t\tonBlur: () => {\n\t\t\tonBlur?.();\n\t\t},\n\t\teditorProps: {\n\t\t\tattributes: {\n\t\t\t\tclass:\n\t\t\t\t\t\"min-h-[140px] w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary\",\n\t\t\t},\n\t\t},\n\t});\n\n\tuseEffect(() => {\n\t\tif (!editor) return;\n\t\tif (value === lastValueRef.current) return;\n\t\teditor.commands.setContent(value ? markdownParser.render(value) : \"\", {\n\t\t\temitUpdate: false,\n\t\t});\n\t\tlastValueRef.current = value;\n\t}, [editor, markdownParser, value]);\n\n\treturn (\n\t\t<div\n\t\t\trole=\"group\"\n\t\t\tclassName=\"mb-8\"\n\t\t\tonMouseEnter={() => setIsHovered(true)}\n\t\t\tonMouseLeave={() => setIsHovered(false)}\n\t\t>\n\t\t\t<SectionHeader\n\t\t\t\ttitle={t(\"notesLabel\")}\n\t\t\t\tshow={show}\n\t\t\t\tonToggle={onToggle}\n\t\t\t\theaderClassName=\"mb-2\"\n\t\t\t\tisHovered={isHovered}\n\t\t\t/>\n\t\t\t{show && (\n\t\t\t\t<div className=\"prose prose-sm max-w-none\">\n\t\t\t\t\t<EditorContent editor={editor} />\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/todo-detail/components/datePickerUtils.ts",
    "content": "import dayjs from \"dayjs\";\nimport timezone from \"dayjs/plugin/timezone\";\nimport utc from \"dayjs/plugin/utc\";\n\ndayjs.extend(utc);\ndayjs.extend(timezone);\n\nconst FALLBACK_TIMEZONES = [\n\t\"Asia/Shanghai\",\n\t\"Asia/Tokyo\",\n\t\"Europe/London\",\n\t\"Europe/Berlin\",\n\t\"America/New_York\",\n\t\"America/Los_Angeles\",\n\t\"UTC\",\n];\n\nexport const DEFAULT_TIME = \"09:00\";\nexport const DEFAULT_RANGE_MINUTES = 60;\n\nexport const getTimeZoneOptions = () => {\n\tif (typeof Intl === \"undefined\") return FALLBACK_TIMEZONES;\n\tconst supported = (Intl as unknown as { supportedValuesOf?: (kind: string) => string[] })\n\t\t.supportedValuesOf;\n\tif (typeof supported === \"function\") {\n\t\treturn supported(\"timeZone\");\n\t}\n\treturn FALLBACK_TIMEZONES;\n};\n\nexport const resolveTimeZone = (value?: string) => value || dayjs.tz.guess();\n\nconst toZoned = (value?: string, zone?: string) => {\n\tif (!value) return null;\n\tconst parsed = dayjs(value);\n\tif (!parsed.isValid()) return null;\n\treturn parsed.tz(resolveTimeZone(zone));\n};\n\nexport const toCalendarDate = (value?: string, zone?: string): Date | null => {\n\tconst zoned = toZoned(value, zone);\n\tif (!zoned) return null;\n\treturn new Date(zoned.year(), zoned.month(), zoned.date());\n};\n\nexport const toTimeValue = (value?: string, zone?: string): string => {\n\tconst zoned = toZoned(value, zone);\n\tif (!zoned) return \"\";\n\treturn zoned.format(\"HH:mm\");\n};\n\nexport const formatDateLabel = (date: Date | null) =>\n\tdate ? dayjs(date).format(\"YYYY-MM-DD\") : \"\";\n\nexport const addMinutes = (timeValue: string, minutes: number) => {\n\tif (!timeValue) return \"\";\n\tconst [hh, mm] = timeValue.split(\":\").map((n) => Number.parseInt(n, 10));\n\tif (Number.isNaN(hh) || Number.isNaN(mm)) return \"\";\n\tconst total = (hh * 60 + mm + minutes) % 1440;\n\tconst nextH = Math.floor(total / 60);\n\tconst nextM = total % 60;\n\treturn `${`${nextH}`.padStart(2, \"0\")}:${`${nextM}`.padStart(2, \"0\")}`;\n};\n\nexport const buildIsoWithZone = (date: Date, time: string, zone: string) => {\n\tconst dateLabel = dayjs(date).format(\"YYYY-MM-DD\");\n\tconst timeLabel = time || \"00:00\";\n\treturn dayjs\n\t\t.tz(`${dateLabel} ${timeLabel}`, \"YYYY-MM-DD HH:mm\", zone)\n\t\t.toISOString();\n};\n"
  },
  {
    "path": "free-todo-frontend/apps/todo-detail/helpers.ts",
    "content": "import dayjs from \"dayjs\";\nimport timezone from \"dayjs/plugin/timezone\";\nimport utc from \"dayjs/plugin/utc\";\nimport type { Todo, TodoPriority, TodoStatus } from \"@/lib/types\";\nimport { cn } from \"@/lib/utils\";\n\ndayjs.extend(utc);\ndayjs.extend(timezone);\n\nexport const statusOptions: TodoStatus[] = [\n\t\"active\",\n\t\"completed\",\n\t\"canceled\",\n\t\"draft\",\n];\nexport const priorityOptions: TodoPriority[] = [\n\t\"high\",\n\t\"medium\",\n\t\"low\",\n\t\"none\",\n];\n\nexport const getStatusClassNames = (status: TodoStatus) =>\n\tcn(\n\t\t\"inline-flex items-center justify-center rounded-full border px-2 py-1 text-xs font-medium leading-none\",\n\t\tstatus === \"active\"\n\t\t\t? \"border-primary/70 bg-primary/20 text-primary\"\n\t\t\t: status === \"completed\"\n\t\t\t\t? \"border-green-500/60 bg-green-500/12 text-green-600 dark:text-green-500\"\n\t\t\t\t: status === \"draft\"\n\t\t\t\t\t? \"border-orange-500/50 bg-orange-500/8 text-orange-600 dark:text-orange-400\"\n\t\t\t\t\t: \"border-muted-foreground/40 bg-muted/15 text-muted-foreground\",\n\t);\n\nexport const getPriorityClassNames = (priority: TodoPriority) =>\n\tcn(\n\t\t\"inline-flex items-center justify-center gap-1 rounded-full border px-2 py-1 text-xs font-medium leading-none\",\n\t\tpriority === \"high\"\n\t\t\t? \"border-destructive/60 bg-destructive/10 text-destructive\"\n\t\t\t: priority === \"medium\"\n\t\t\t\t? \"border-primary/60 bg-primary/10 text-primary\"\n\t\t\t\t: priority === \"low\"\n\t\t\t\t\t? \"border-secondary/60 bg-secondary/20 text-secondary-foreground\"\n\t\t\t\t\t: \"border-muted-foreground/40 text-muted-foreground\",\n\t);\n\nexport const getPriorityIconColor = (priority: TodoPriority) => {\n\tswitch (priority) {\n\t\tcase \"high\":\n\t\t\treturn \"text-destructive\";\n\t\tcase \"medium\":\n\t\t\treturn \"text-primary\";\n\t\tcase \"low\":\n\t\t\treturn \"text-secondary-foreground\";\n\t\tdefault:\n\t\t\treturn \"text-muted-foreground\";\n\t}\n};\n\nexport const getPriorityBorderColor = (priority: TodoPriority) => {\n\tswitch (priority) {\n\t\tcase \"high\":\n\t\t\treturn \"border-destructive/60\";\n\t\tcase \"medium\":\n\t\t\treturn \"border-primary/60\";\n\t\tcase \"low\":\n\t\t\treturn \"border-secondary/60\";\n\t\tdefault:\n\t\t\treturn \"border-muted-foreground/40\";\n\t}\n};\n\nconst resolveTimeZone = (timeZone?: string) =>\n\ttimeZone || dayjs.tz.guess();\n\nconst toZoned = (value?: string, timeZone?: string) => {\n\tif (!value) return null;\n\tconst parsed = dayjs(value);\n\tif (!parsed.isValid()) return null;\n\treturn parsed.tz(resolveTimeZone(timeZone));\n};\n\nexport const formatDateTime = (value?: string, timeZone?: string): string => {\n\tconst zoned = toZoned(value, timeZone);\n\tif (!zoned) return \"\";\n\treturn zoned.format(\"YYYY-MM-DD HH:mm\");\n};\n\nexport const formatDateOnly = (value?: string, timeZone?: string): string => {\n\tconst zoned = toZoned(value, timeZone);\n\tif (!zoned) return \"\";\n\treturn zoned.format(\"YYYY-MM-DD\");\n};\n\nexport const formatScheduleSummary = ({\n\tstartTime,\n\tendTime,\n\ttimeZone,\n\tisAllDay,\n}: {\n\tstartTime?: string;\n\tendTime?: string;\n\ttimeZone?: string;\n\tisAllDay?: boolean;\n}): string => {\n\tconst baseStart = startTime ?? endTime;\n\tconst startZoned = toZoned(baseStart, timeZone);\n\tif (!startZoned) return \"\";\n\tconst endZoned = endTime ? toZoned(endTime, timeZone) : null;\n\n\tconst startDate = startZoned.format(\"YYYY-MM-DD\");\n\tconst endDate = endZoned?.format(\"YYYY-MM-DD\");\n\n\tif (isAllDay) {\n\t\tif (endDate && endDate !== startDate) {\n\t\t\treturn `${startDate} - ${endDate}`;\n\t\t}\n\t\treturn startDate;\n\t}\n\n\tconst startTimeLabel = startZoned.format(\"HH:mm\");\n\tif (!endZoned) return `${startDate} ${startTimeLabel}`;\n\n\tconst endTimeLabel = endZoned.format(\"HH:mm\");\n\tif (endDate && endDate !== startDate) {\n\t\treturn `${startDate} ${startTimeLabel} - ${endDate} ${endTimeLabel}`;\n\t}\n\treturn `${startDate} ${startTimeLabel} - ${endTimeLabel}`;\n};\n\nexport const getChildProgress = (todos: Todo[], parentId: number) => {\n\tconst children = todos.filter((item) => item.parentTodoId === parentId);\n\tconst completed = children.filter(\n\t\t(item) => item.status === \"completed\",\n\t).length;\n\treturn { completed, total: children.length };\n};\n"
  },
  {
    "path": "free-todo-frontend/apps/todo-detail/hooks/useNotesAutosize.ts",
    "content": "import { useCallback, useEffect, useRef } from \"react\";\n\nexport function useNotesAutosize(deps: unknown[]) {\n\tconst notesRef = useRef<HTMLTextAreaElement | null>(null);\n\n\tconst adjustNotesHeight = useCallback(() => {\n\t\tconst el = notesRef.current;\n\t\tif (!el) return;\n\n\t\tel.style.height = \"auto\";\n\n\t\tconst BOTTOM_DOCK_ESTIMATED_HEIGHT = 84;\n\t\tconst SAFE_GAP = 16;\n\t\tconst MIN_HEIGHT = 120;\n\n\t\tconst availableHeight =\n\t\t\ttypeof window !== \"undefined\"\n\t\t\t\t? Math.max(\n\t\t\t\t\t\tMIN_HEIGHT,\n\t\t\t\t\t\twindow.innerHeight -\n\t\t\t\t\t\t\tel.getBoundingClientRect().top -\n\t\t\t\t\t\t\t(BOTTOM_DOCK_ESTIMATED_HEIGHT + SAFE_GAP),\n\t\t\t\t\t)\n\t\t\t\t: el.scrollHeight;\n\n\t\tconst nextHeight = Math.min(el.scrollHeight, availableHeight);\n\t\tel.style.height = `${nextHeight}px`;\n\t}, []);\n\n\tuseEffect(() => {\n\t\tadjustNotesHeight();\n\t\tconst handleResize = () => adjustNotesHeight();\n\t\twindow.addEventListener(\"resize\", handleResize);\n\t\treturn () => window.removeEventListener(\"resize\", handleResize);\n\t\t// eslint-disable-next-line react-hooks/exhaustive-deps\n\t}, [adjustNotesHeight, ...deps]);\n\n\treturn { notesRef, adjustNotesHeight };\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/todo-detail/index.ts",
    "content": "export { TodoDetail } from \"./TodoDetail\";\n"
  },
  {
    "path": "free-todo-frontend/apps/todo-detail/utils/date-utils.ts",
    "content": "/**\n * 日期选择器工具函数\n * 提供日期计算和格式化功能\n */\n\nimport { getHolidayInfo, type HolidayInfo } from \"./holiday-utils\";\nimport { getLunarDayText } from \"./lunar-utils\";\n\n/**\n * 日历单元格数据\n */\nexport interface CalendarDay {\n\t/** 日期对象 */\n\tdate: Date;\n\t/** 是否在当前显示月份内 */\n\tinCurrentMonth: boolean;\n\t/** 是否为今天 */\n\tisToday: boolean;\n\t/** 农历文本 */\n\tlunarText: string;\n\t/** 节假日信息 */\n\tholiday?: HolidayInfo;\n}\n\n/**\n * 获取日期的开始时间（0:00:00）\n */\nexport function startOfDay(date: Date): Date {\n\tconst d = new Date(date);\n\td.setHours(0, 0, 0, 0);\n\treturn d;\n}\n\n/**\n * 日期加减天数\n * @param date - 基础日期\n * @param days - 要添加的天数（可为负数）\n */\nexport function addDays(date: Date, days: number): Date {\n\tconst d = new Date(date);\n\td.setDate(d.getDate() + days);\n\treturn d;\n}\n\n/**\n * 获取日期所在周的周一\n */\nexport function startOfWeek(date: Date): Date {\n\tconst d = startOfDay(date);\n\tconst day = d.getDay();\n\tconst diff = (day + 6) % 7;\n\td.setDate(d.getDate() - diff);\n\treturn d;\n}\n\n/**\n * 获取日期所在月份的第一天\n */\nexport function startOfMonth(date: Date): Date {\n\treturn startOfDay(new Date(date.getFullYear(), date.getMonth(), 1));\n}\n\n/**\n * 将日期转换为 YYYY-MM-DD 格式字符串\n */\nexport function toDateKey(date: Date): string {\n\tconst y = date.getFullYear();\n\tconst m = `${date.getMonth() + 1}`.padStart(2, \"0\");\n\tconst d = `${date.getDate()}`.padStart(2, \"0\");\n\treturn `${y}-${m}-${d}`;\n}\n\n/**\n * 构建月历数据\n * @param currentDate - 当前显示的月份日期\n * @returns 包含 42 天的日历数据（6周 × 7天）\n */\nexport function buildMonthDays(currentDate: Date): CalendarDay[] {\n\tconst start = startOfMonth(currentDate);\n\tconst startGrid = startOfWeek(start);\n\tconst today = toDateKey(new Date());\n\n\treturn Array.from({ length: 42 }, (_, idx) => {\n\t\tconst date = addDays(startGrid, idx);\n\t\treturn {\n\t\t\tdate,\n\t\t\tinCurrentMonth: date.getMonth() === currentDate.getMonth(),\n\t\t\tisToday: toDateKey(date) === today,\n\t\t\tlunarText: getLunarDayText(date),\n\t\t\tholiday: getHolidayInfo(date) || undefined,\n\t\t};\n\t});\n}\n\n/**\n * 星期名称的国际化键值\n */\nexport const WEEKDAY_KEYS = [\n\t\"monday\",\n\t\"tuesday\",\n\t\"wednesday\",\n\t\"thursday\",\n\t\"friday\",\n\t\"saturday\",\n\t\"sunday\",\n] as const;\n\nexport type WeekdayKey = (typeof WEEKDAY_KEYS)[number];\n"
  },
  {
    "path": "free-todo-frontend/apps/todo-detail/utils/holiday-utils.ts",
    "content": "/**\n * 节假日工具函数\n * 管理中国法定节假日和调休信息\n */\n\n/**\n * 节假日信息接口\n */\nexport interface HolidayInfo {\n\t/** 节日名称 */\n\tname: string;\n\t/** 是否放假（true=休息，false=调休上班，undefined=普通节日） */\n\tisHoliday?: boolean;\n}\n\n/**\n * 年度节假日配置类型\n */\ntype YearHolidayConfig = Record<string, HolidayInfo>;\n\n/**\n * 2025年节假日配置\n * 格式: \"月-日\": { name: \"节日名称\", isHoliday: true/false }\n */\nconst HOLIDAYS_2025: YearHolidayConfig = {\n\t// 元旦\n\t\"1-1\": { name: \"元旦\", isHoliday: true },\n\t\"1-2\": { name: \"\", isHoliday: true },\n\t\"1-3\": { name: \"\", isHoliday: true },\n\t\"1-4\": { name: \"\", isHoliday: true },\n\t// 西方节日（仅标记，不放假）\n\t\"12-24\": { name: \"平安夜\" },\n\t\"12-25\": { name: \"圣诞节\" },\n};\n\n/**\n * 2026年节假日配置\n */\nconst HOLIDAYS_2026: YearHolidayConfig = {\n\t// 元旦\n\t\"1-1\": { name: \"元旦\", isHoliday: true },\n\t\"1-2\": { name: \"\", isHoliday: true },\n\t\"1-3\": { name: \"\", isHoliday: true },\n};\n\n/**\n * 所有年份的节假日配置\n */\nconst HOLIDAYS_BY_YEAR: Record<number, YearHolidayConfig> = {\n\t2025: HOLIDAYS_2025,\n\t2026: HOLIDAYS_2026,\n};\n\n/**\n * 获取指定日期的节假日信息\n * @param date - 日期对象\n * @returns 节假日信息，如果不是节假日则返回 null\n */\nexport function getHolidayInfo(date: Date): HolidayInfo | null {\n\tconst year = date.getFullYear();\n\tconst month = date.getMonth() + 1;\n\tconst day = date.getDate();\n\tconst key = `${month}-${day}`;\n\n\tconst yearHolidays = HOLIDAYS_BY_YEAR[year];\n\tif (yearHolidays) {\n\t\treturn yearHolidays[key] || null;\n\t}\n\n\treturn null;\n}\n\n/**\n * 检查指定日期是否为法定假日\n * @param date - 日期对象\n * @returns 是否为法定假日\n */\nexport function isHoliday(date: Date): boolean {\n\tconst info = getHolidayInfo(date);\n\treturn info?.isHoliday === true;\n}\n\n/**\n * 检查指定日期是否为调休工作日\n * @param date - 日期对象\n * @returns 是否为调休工作日\n */\nexport function isWorkday(date: Date): boolean {\n\tconst info = getHolidayInfo(date);\n\treturn info?.isHoliday === false;\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/todo-detail/utils/index.ts",
    "content": "/**\n * Todo Detail 工具函数导出\n */\n\n// 日期工具\nexport {\n\taddDays,\n\tbuildMonthDays,\n\ttype CalendarDay,\n\tstartOfDay,\n\tstartOfMonth,\n\tstartOfWeek,\n\ttoDateKey,\n\tWEEKDAY_KEYS,\n\ttype WeekdayKey,\n} from \"./date-utils\";\n\n// 节假日工具\nexport {\n\tgetHolidayInfo,\n\ttype HolidayInfo,\n\tisHoliday,\n\tisWorkday,\n} from \"./holiday-utils\";\n\n// 农历工具\nexport {\n\tgetLunarDate,\n\tgetLunarDayText,\n\tLUNAR_DAY,\n\tLUNAR_INFO,\n\tLUNAR_MONTH,\n\ttype LunarDate,\n\tSOLAR_TERM_DAYS,\n\tSOLAR_TERMS,\n} from \"./lunar-utils\";\n"
  },
  {
    "path": "free-todo-frontend/apps/todo-detail/utils/lunar-utils.ts",
    "content": "/**\n * 农历计算工具函数\n * 基于简化版农历算法，支持 1900-2100 年\n */\n\n/**\n * 农历信息数据表\n * 每个元素代表一年的农历信息\n */\nexport const LUNAR_INFO = [\n\t0x04bd8, 0x04ae0, 0x0a570, 0x054d5, 0x0d260, 0x0d950, 0x16554, 0x056a0,\n\t0x09ad0, 0x055d2, 0x04ae0, 0x0a5b6, 0x0a4d0, 0x0d250, 0x1d255, 0x0b540,\n\t0x0d6a0, 0x0ada2, 0x095b0, 0x14977, 0x04970, 0x0a4b0, 0x0b4b5, 0x06a50,\n\t0x06d40, 0x1ab54, 0x02b60, 0x09570, 0x052f2, 0x04970, 0x06566, 0x0d4a0,\n\t0x0ea50, 0x16a95, 0x05ad0, 0x02b60, 0x186e3, 0x092e0, 0x1c8d7, 0x0c950,\n\t0x0d4a0, 0x1d8a6, 0x0b550, 0x056a0, 0x1a5b4, 0x025d0, 0x092d0, 0x0d2b2,\n\t0x0a950, 0x0b557, 0x06ca0, 0x0b550, 0x15355, 0x04da0, 0x0a5b0, 0x14573,\n\t0x052b0, 0x0a9a8, 0x0e950, 0x06aa0, 0x0aea6, 0x0ab50, 0x04b60, 0x0aae4,\n\t0x0a570, 0x05260, 0x0f263, 0x0d950, 0x05b57, 0x056a0, 0x096d0, 0x04dd5,\n\t0x04ad0, 0x0a4d0, 0x0d4d4, 0x0d250, 0x0d558, 0x0b540, 0x0b6a0, 0x195a6,\n\t0x095b0, 0x049b0, 0x0a974, 0x0a4b0, 0x0b27a, 0x06a50, 0x06d40, 0x0af46,\n\t0x0ab60, 0x09570, 0x04af5, 0x04970, 0x064b0, 0x074a3, 0x0ea50, 0x06b58,\n\t0x05ac0, 0x0ab60, 0x096d5, 0x092e0, 0x0c960, 0x0d954, 0x0d4a0, 0x0da50,\n\t0x07552, 0x056a0, 0x0abb7, 0x025d0, 0x092d0, 0x0cab5, 0x0a950, 0x0b4a0,\n\t0x0baa4, 0x0ad50, 0x055d9, 0x04ba0, 0x0a5b0, 0x15176, 0x052b0, 0x0a930,\n\t0x07954, 0x06aa0, 0x0ad50, 0x05b52, 0x04b60, 0x0a6e6, 0x0a4e0, 0x0d260,\n\t0x0ea65, 0x0d530, 0x05aa0, 0x076a3, 0x096d0, 0x04afb, 0x04ad0, 0x0a4d0,\n\t0x1d0b6, 0x0d250, 0x0d520, 0x0dd45, 0x0b5a0, 0x056d0, 0x055b2, 0x049b0,\n\t0x0a577, 0x0a4b0, 0x0aa50, 0x1b255, 0x06d20, 0x0ada0, 0x14b63, 0x09370,\n\t0x049f8, 0x04970, 0x064b0, 0x168a6, 0x0ea50, 0x06aa0, 0x1a6c4, 0x0aae0,\n\t0x092e0, 0x0d2e3, 0x0c960, 0x0d557, 0x0d4a0, 0x0da50, 0x05d55, 0x056a0,\n\t0x0a6d0, 0x055d4, 0x052d0, 0x0a9b8, 0x0a950, 0x0b4a0, 0x0b6a6, 0x0ad50,\n\t0x055a0, 0x0aba4, 0x0a5b0, 0x052b0, 0x0b273, 0x06930, 0x07337, 0x06aa0,\n\t0x0ad50, 0x14b55, 0x04b60, 0x0a570, 0x054e4, 0x0d160, 0x0e968, 0x0d520,\n\t0x0daa0, 0x16aa6, 0x056d0, 0x04ae0, 0x0a9d4, 0x0a2d0, 0x0d150, 0x0f252,\n\t0x0d520,\n] as const;\n\n/** 农历月份名称 */\nexport const LUNAR_MONTH = [\n\t\"正\",\n\t\"二\",\n\t\"三\",\n\t\"四\",\n\t\"五\",\n\t\"六\",\n\t\"七\",\n\t\"八\",\n\t\"九\",\n\t\"十\",\n\t\"冬\",\n\t\"腊\",\n] as const;\n\n/** 农历日期名称 */\nexport const LUNAR_DAY = [\n\t\"初一\",\n\t\"初二\",\n\t\"初三\",\n\t\"初四\",\n\t\"初五\",\n\t\"初六\",\n\t\"初七\",\n\t\"初八\",\n\t\"初九\",\n\t\"初十\",\n\t\"十一\",\n\t\"十二\",\n\t\"十三\",\n\t\"十四\",\n\t\"十五\",\n\t\"十六\",\n\t\"十七\",\n\t\"十八\",\n\t\"十九\",\n\t\"二十\",\n\t\"廿一\",\n\t\"廿二\",\n\t\"廿三\",\n\t\"廿四\",\n\t\"廿五\",\n\t\"廿六\",\n\t\"廿七\",\n\t\"廿八\",\n\t\"廿九\",\n\t\"三十\",\n] as const;\n\n/** 节气名称列表 */\nexport const SOLAR_TERMS = [\n\t\"小寒\",\n\t\"大寒\",\n\t\"立春\",\n\t\"雨水\",\n\t\"惊蛰\",\n\t\"春分\",\n\t\"清明\",\n\t\"谷雨\",\n\t\"立夏\",\n\t\"小满\",\n\t\"芒种\",\n\t\"夏至\",\n\t\"小暑\",\n\t\"大暑\",\n\t\"立秋\",\n\t\"处暑\",\n\t\"白露\",\n\t\"秋分\",\n\t\"寒露\",\n\t\"霜降\",\n\t\"立冬\",\n\t\"小雪\",\n\t\"大雪\",\n\t\"冬至\",\n] as const;\n\n/** 节气日期（近似值，按月份索引） */\nexport const SOLAR_TERM_DAYS: Record<string, number[]> = {\n\t\"1\": [6, 20],\n\t\"2\": [4, 19],\n\t\"3\": [6, 21],\n\t\"4\": [5, 20],\n\t\"5\": [6, 21],\n\t\"6\": [6, 21],\n\t\"7\": [7, 23],\n\t\"8\": [8, 23],\n\t\"9\": [8, 23],\n\t\"10\": [8, 24],\n\t\"11\": [8, 22],\n\t\"12\": [7, 22],\n};\n\n/**\n * 计算农历某年的总天数\n */\nfunction lYearDays(y: number): number {\n\tlet i: number;\n\tlet sum = 348;\n\tfor (i = 0x8000; i > 0x8; i >>= 1) {\n\t\tsum += LUNAR_INFO[y - 1900] & i ? 1 : 0;\n\t}\n\treturn sum + leapDays(y);\n}\n\n/**\n * 获取农历某年的闰月月份（0 表示无闰月）\n */\nfunction leapMonth(y: number): number {\n\treturn LUNAR_INFO[y - 1900] & 0xf;\n}\n\n/**\n * 计算农历某年闰月的天数\n */\nfunction leapDays(y: number): number {\n\tif (leapMonth(y)) {\n\t\treturn LUNAR_INFO[y - 1900] & 0x10000 ? 30 : 29;\n\t}\n\treturn 0;\n}\n\n/**\n * 计算农历某年某月的天数\n */\nfunction monthDays(y: number, m: number): number {\n\treturn LUNAR_INFO[y - 1900] & (0x10000 >> m) ? 30 : 29;\n}\n\n/**\n * 农历日期信息\n */\nexport interface LunarDate {\n\tmonth: number;\n\tday: number;\n\tisLeap: boolean;\n}\n\n/**\n * 将公历日期转换为农历日期\n * @param date - 公历日期\n * @returns 农历日期信息\n */\nexport function getLunarDate(date: Date): LunarDate {\n\tlet i: number;\n\tlet leap = 0;\n\tlet temp = 0;\n\tconst baseDate = new Date(1900, 0, 31);\n\tlet offset = Math.floor((date.getTime() - baseDate.getTime()) / 86400000);\n\n\tfor (i = 1900; i < 2101 && offset > 0; i++) {\n\t\ttemp = lYearDays(i);\n\t\toffset -= temp;\n\t}\n\tif (offset < 0) {\n\t\toffset += temp;\n\t\ti--;\n\t}\n\n\tconst year = i;\n\tleap = leapMonth(i);\n\tlet isLeap = false;\n\n\tfor (i = 1; i < 13 && offset > 0; i++) {\n\t\tif (leap > 0 && i === leap + 1 && !isLeap) {\n\t\t\t--i;\n\t\t\tisLeap = true;\n\t\t\ttemp = leapDays(year);\n\t\t} else {\n\t\t\ttemp = monthDays(year, i);\n\t\t}\n\t\tif (isLeap && i === leap + 1) {\n\t\t\tisLeap = false;\n\t\t}\n\t\toffset -= temp;\n\t}\n\n\tif (offset === 0 && leap > 0 && i === leap + 1) {\n\t\tif (isLeap) {\n\t\t\tisLeap = false;\n\t\t} else {\n\t\t\tisLeap = true;\n\t\t\t--i;\n\t\t}\n\t}\n\tif (offset < 0) {\n\t\toffset += temp;\n\t\t--i;\n\t}\n\n\treturn { month: i, day: offset + 1, isLeap };\n}\n\n/**\n * 获取日期的农历文本（节气 > 月份 > 日期）\n * @param date - 公历日期\n * @returns 农历显示文本\n */\nexport function getLunarDayText(date: Date): string {\n\tconst lunar = getLunarDate(date);\n\tconst month = date.getMonth() + 1;\n\tconst day = date.getDate();\n\n\t// 检查节气\n\tconst termDays = SOLAR_TERM_DAYS[String(month)];\n\tif (termDays) {\n\t\tconst termIndex = (month - 1) * 2;\n\t\tif (day === termDays[0]) {\n\t\t\treturn SOLAR_TERMS[termIndex];\n\t\t}\n\t\tif (day === termDays[1]) {\n\t\t\treturn SOLAR_TERMS[termIndex + 1];\n\t\t}\n\t}\n\n\t// 农历初一显示月份\n\tif (lunar.day === 1) {\n\t\treturn `${lunar.isLeap ? \"闰\" : \"\"}${LUNAR_MONTH[lunar.month - 1]}月`;\n\t}\n\n\treturn LUNAR_DAY[lunar.day - 1];\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/todo-list/CreateTodoForm.tsx",
    "content": "\"use client\";\nimport { useTranslations } from \"next-intl\";\nimport { useState } from \"react\";\nimport { priorityOptions } from \"@/apps/todo-detail/helpers\";\nimport { useCreateTodo } from \"@/lib/query\";\nimport type { CreateTodoInput, TodoPriority } from \"@/lib/types\";\nimport { cn, getPriorityLabel } from \"@/lib/utils\";\n\ninterface CreateTodoFormProps {\n\tonSuccess?: () => void;\n}\n\nexport function CreateTodoForm({ onSuccess }: CreateTodoFormProps) {\n\tconst tCommon = useTranslations(\"common\");\n\tconst tTodoList = useTranslations(\"todoList\");\n\tconst createTodoMutation = useCreateTodo();\n\tconst [isExpanded, setIsExpanded] = useState(false);\n\tconst [name, setName] = useState(\"\");\n\tconst [description, setDescription] = useState(\"\");\n\tconst [tags, setTags] = useState(\"\");\n\tconst [userNotes, setUserNotes] = useState(\"\");\n\tconst [priority, setPriority] = useState<TodoPriority>(\"none\");\n\n\tconst handleSubmit = async (e: React.FormEvent) => {\n\t\te.preventDefault();\n\t\tif (!name.trim()) return;\n\n\t\tconst input: CreateTodoInput = {\n\t\t\tname: name.trim(),\n\t\t\tdescription: description.trim() || undefined,\n\t\t\tuserNotes: userNotes.trim() || undefined,\n\t\t\tpriority,\n\t\t\ttags:\n\t\t\t\ttags\n\t\t\t\t\t.split(\",\")\n\t\t\t\t\t.map((t) => t.trim())\n\t\t\t\t\t.filter(Boolean) || [],\n\t\t};\n\n\t\ttry {\n\t\t\tawait createTodoMutation.mutateAsync(input);\n\t\t\tsetName(\"\");\n\t\t\tsetDescription(\"\");\n\t\t\tsetTags(\"\");\n\t\t\tsetUserNotes(\"\");\n\t\t\tsetPriority(\"none\");\n\t\t\tsetIsExpanded(false);\n\t\t\tonSuccess?.();\n\t\t} catch (err) {\n\t\t\tconsole.error(\"Failed to create todo:\", err);\n\t\t}\n\t};\n\n\treturn (\n\t\t<form\n\t\t\tonSubmit={handleSubmit}\n\t\t\tclassName={cn(\"bg-muted/30 transition-all\", isExpanded && \"bg-muted/50\")}\n\t\t>\n\t\t\t<div className=\"p-4\">\n\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t<input\n\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\tvalue={name}\n\t\t\t\t\t\tonChange={(e) => setName(e.target.value)}\n\t\t\t\t\t\tonFocus={() => setIsExpanded(true)}\n\t\t\t\t\t\tplaceholder={tTodoList(\"addTodo\")}\n\t\t\t\t\t\tclassName=\"flex-1 bg-transparent text-sm font-medium text-foreground placeholder:text-muted-foreground focus:outline-none\"\n\t\t\t\t\t/>\n\t\t\t\t\t{name.trim() && (\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"submit\"\n\t\t\t\t\t\t\tclassName=\"rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{tTodoList(\"add\")}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\n\t\t\t\t{isExpanded && (\n\t\t\t\t\t<div className=\"mt-3 space-y-3\">\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t\thtmlFor=\"description-input\"\n\t\t\t\t\t\t\t\tclassName=\"mb-1 block text-xs font-medium text-muted-foreground\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{tTodoList(\"description\")}\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<textarea\n\t\t\t\t\t\t\t\tid=\"description-input\"\n\t\t\t\t\t\t\t\tvalue={description}\n\t\t\t\t\t\t\t\tonChange={(e) => setDescription(e.target.value)}\n\t\t\t\t\t\t\t\tplaceholder={tTodoList(\"descriptionPlaceholder\")}\n\t\t\t\t\t\t\t\tclassName=\"w-full min-h-[80px] rounded-md border border-border bg-background px-2 py-1.5 text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t\thtmlFor=\"tags-input\"\n\t\t\t\t\t\t\t\tclassName=\"mb-1 block text-xs font-medium text-muted-foreground\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{tTodoList(\"tags\")}\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\tid=\"tags-input\"\n\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\tvalue={tags}\n\t\t\t\t\t\t\t\tonChange={(e) => setTags(e.target.value)}\n\t\t\t\t\t\t\t\tplaceholder={tTodoList(\"tagsPlaceholder\")}\n\t\t\t\t\t\t\t\tclassName=\"w-full rounded-md border border-border bg-background px-2 py-1.5 text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t\thtmlFor=\"priority-select\"\n\t\t\t\t\t\t\t\tclassName=\"mb-1 block text-xs font-medium text-muted-foreground\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{tTodoList(\"priority\")}\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<select\n\t\t\t\t\t\t\t\tid=\"priority-select\"\n\t\t\t\t\t\t\t\tvalue={priority}\n\t\t\t\t\t\t\t\tonChange={(e) => setPriority(e.target.value as TodoPriority)}\n\t\t\t\t\t\t\t\tclassName=\"w-full rounded-md border border-border bg-background px-2 py-1.5 text-xs text-foreground focus:outline-none focus:ring-2 focus:ring-primary\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{priorityOptions.map((p) => (\n\t\t\t\t\t\t\t\t\t<option key={p} value={p}>\n\t\t\t\t\t\t\t\t\t\t{getPriorityLabel(p, tCommon)}\n\t\t\t\t\t\t\t\t\t</option>\n\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t</select>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t\thtmlFor=\"user-notes-input\"\n\t\t\t\t\t\t\t\tclassName=\"mb-1 block text-xs font-medium text-muted-foreground\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{tTodoList(\"notes\")}\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<textarea\n\t\t\t\t\t\t\t\tid=\"user-notes-input\"\n\t\t\t\t\t\t\t\tvalue={userNotes}\n\t\t\t\t\t\t\t\tonChange={(e) => setUserNotes(e.target.value)}\n\t\t\t\t\t\t\t\tplaceholder={tTodoList(\"notesPlaceholder\")}\n\t\t\t\t\t\t\t\tclassName=\"w-full min-h-[60px] rounded-md border border-border bg-background px-2 py-1.5 text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t</form>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/todo-list/NewTodoInlineForm.tsx",
    "content": "\"use client\";\n\nimport { Plus } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport type React from \"react\";\nimport { useEffect, useRef } from \"react\";\n\ninterface NewTodoInlineFormProps {\n\tvalue: string;\n\tonChange: (value: string) => void;\n\tonSubmit: (e?: React.FormEvent) => void;\n\tonCancel: () => void;\n}\n\nexport function NewTodoInlineForm({\n\tvalue,\n\tonChange,\n\tonSubmit,\n\tonCancel,\n}: NewTodoInlineFormProps) {\n\tconst t = useTranslations(\"todoList\");\n\tconst inputRef = useRef<HTMLInputElement>(null);\n\n\tuseEffect(() => {\n\t\tinputRef.current?.focus();\n\t}, []);\n\n\tuseEffect(() => {\n\t\tif (value === \"\") {\n\t\t\tinputRef.current?.focus();\n\t\t}\n\t}, [value]);\n\n\treturn (\n\t\t<form\n\t\t\tonSubmit={onSubmit}\n\t\t\tonReset={onCancel}\n\t\t\tclassName=\"group flex items-center gap-3 rounded-lg border border-border/60 bg-muted/30 px-3 py-2 transition-colors focus-within:border-primary focus-within:bg-background focus-within:ring-2 focus-within:ring-primary/40\"\n\t\t\tonClick={() => inputRef.current?.focus()}\n\t\t\tonKeyDown={(e) => {\n\t\t\t\t// 仅在表单容器聚焦时处理键盘操作，避免阻断输入框的 Enter 提交\n\t\t\t\tif (e.currentTarget !== e.target) return;\n\t\t\t\tif (e.key === \" \") {\n\t\t\t\t\te.preventDefault();\n\t\t\t\t\tinputRef.current?.focus();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (e.key === \"Enter\") {\n\t\t\t\t\tinputRef.current?.focus();\n\t\t\t\t}\n\t\t\t}}\n\t\t>\n\t\t\t<Plus className=\"h-4 w-4 text-muted-foreground group-focus-within:text-primary\" />\n\t\t\t<input\n\t\t\t\tref={inputRef}\n\t\t\t\ttype=\"text\"\n\t\t\t\tvalue={value}\n\t\t\t\tonChange={(e) => onChange(e.target.value)}\n\t\t\t\tplaceholder={t(\"addTodo\")}\n\t\t\t\tclassName=\"flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none\"\n\t\t\t\trequired\n\t\t\t/>\n\t\t\t<button type=\"submit\" className=\"sr-only\">\n\t\t\t\t{t(\"submit\")}\n\t\t\t</button>\n\t\t\t<button type=\"reset\" className=\"sr-only\">\n\t\t\t\t{t(\"reset\")}\n\t\t\t</button>\n\t\t</form>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/todo-list/TodoCard.tsx",
    "content": "\"use client\";\n\nimport { CSS } from \"@dnd-kit/utilities\";\nimport { Hammer, Paperclip, Sparkles } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport type React from \"react\";\nimport { useMemo } from \"react\";\nimport { TodoContextMenu } from \"@/components/common/context-menu/TodoContextMenu\";\nimport { useTodos } from \"@/lib/query\";\nimport { useTodoStore } from \"@/lib/store/todo-store\";\nimport type { Todo } from \"@/lib/types\";\nimport { cn } from \"@/lib/utils\";\nimport { TodoCardCheckbox } from \"./components/TodoCardCheckbox\";\nimport { TodoCardChildForm } from \"./components/TodoCardChildForm\";\nimport { TodoCardDropZone } from \"./components/TodoCardDropZone\";\nimport { TodoCardExpandButton } from \"./components/TodoCardExpandButton\";\nimport { TodoCardMetadata } from \"./components/TodoCardMetadata\";\nimport { TodoCardName } from \"./components/TodoCardName\";\nimport { useTodoCardDrag } from \"./hooks/useTodoCardDrag\";\nimport { useTodoCardHandlers } from \"./hooks/useTodoCardHandlers\";\nimport { useTodoCardState } from \"./hooks/useTodoCardState\";\n\nexport interface TodoCardProps {\n\ttodo: Todo;\n\tdepth?: number; // 树形结构的层级深度\n\tisDragging?: boolean;\n\tselected?: boolean;\n\tisOverlay?: boolean;\n\thasMultipleSelection?: boolean; // 是否有多个 todo 被选中\n\tonSelect: (e: React.MouseEvent<HTMLDivElement>) => void;\n\tonSelectSingle: () => void;\n}\n\nexport function TodoCard({\n\ttodo,\n\tdepth = 0,\n\tisDragging,\n\tselected,\n\tisOverlay,\n\thasMultipleSelection = false,\n\tonSelect,\n\tonSelectSingle,\n}: TodoCardProps) {\n\tconst tTodoDetail = useTranslations(\"todoDetail\");\n\t// 从 TanStack Query 获取 todos 数据（用于检查是否有子任务）\n\tconst { data: todos = [] } = useTodos();\n\n\t// 从 Zustand 获取 UI 状态操作\n\tconst { toggleTodoExpanded, isTodoExpanded } = useTodoStore();\n\n\t// 使用自定义 hooks\n\tconst state = useTodoCardState(todo);\n\tconst drag = useTodoCardDrag({ todo, depth, isOverlay: isOverlay ?? false });\n\tconst handlers = useTodoCardHandlers({\n\t\ttodo,\n\t\tsetIsAddingChild: state.setIsAddingChild,\n\t\tchildName: state.childName,\n\t\tsetChildName: state.setChildName,\n\t\tsetIsEditingName: state.setIsEditingName,\n\t\teditingName: state.editingName,\n\t\tsetEditingName: state.setEditingName,\n\t});\n\n\t// 检查是否有子任务\n\tconst hasChildren = useMemo(() => {\n\t\treturn todos.some((t: Todo) => t.parentTodoId === todo.id);\n\t}, [todos, todo.id]);\n\n\tconst isExpanded = isTodoExpanded(todo.id);\n\n\tconst style = !isOverlay\n\t\t? {\n\t\t\t\ttransform: CSS.Transform.toString(drag.transform),\n\t\t\t\ttransition: drag.isSortableDragging ? \"none\" : drag.transition,\n\t\t\t\topacity: drag.isSortableDragging ? 0.5 : 1,\n\t\t\t}\n\t\t: undefined;\n\n\tconst cardContent = (\n\t\t<div\n\t\t\t{...(!isOverlay ? { ...drag.attributes, ...drag.listeners } : {})}\n\t\t\tref={drag.setNodeRef}\n\t\t\tstyle={style}\n\t\t\trole=\"button\"\n\t\t\ttabIndex={0}\n\t\t\tonClick={onSelect}\n\t\t\tonMouseDown={(e) => {\n\t\t\t\t// 阻止文本选择（当按住 Shift 或 Ctrl/Cmd 进行多选时）\n\t\t\t\tif (e.shiftKey || e.metaKey || e.ctrlKey) {\n\t\t\t\t\te.preventDefault();\n\t\t\t\t}\n\t\t\t}}\n\t\t\tdata-state={selected ? \"selected\" : \"default\"}\n\t\t\tonKeyDown={(e) => {\n\t\t\t\tif (e.key === \"Enter\" || e.key === \" \") {\n\t\t\t\t\te.preventDefault();\n\t\t\t\t\tonSelectSingle();\n\t\t\t\t}\n\t\t\t}}\n\t\t\tclassName={cn(\n\t\t\t\t\"todo-card group relative flex max-h-32 flex-col justify-start gap-1 rounded-lg px-1 py-2 cursor-pointer\",\n\t\t\t\t\"border border-transparent transition-all duration-200\",\n\t\t\t\t\"bg-card dark:bg-background hover:bg-muted/40 dark:hover:bg-accent/70\",\n\t\t\t\t\"select-none\", // 阻止文本选择\n\t\t\t\tselected &&\n\t\t\t\t\t\"bg-[oklch(var(--primary-weak))] dark:bg-primary/17 border-[oklch(var(--primary-border)/0.3)] dark:border-primary/30\",\n\t\t\t\tselected &&\n\t\t\t\t\t\"hover:bg-[oklch(var(--primary-weak-hover))] dark:hover:bg-primary/30\",\n\t\t\t\tisDragging && \"ring-2 ring-primary/30\",\n\t\t\t)}\n\t\t>\n\t\t\t<div className=\"flex items-start gap-1\">\n\t\t\t\t<TodoCardExpandButton\n\t\t\t\t\thasChildren={hasChildren}\n\t\t\t\t\tisExpanded={isExpanded}\n\t\t\t\t\tonToggle={() => toggleTodoExpanded(todo.id)}\n\t\t\t\t/>\n\n\t\t\t\t<div className=\"mt-1\">\n\t\t\t\t\t<TodoCardCheckbox\n\t\t\t\t\t\ttodo={todo}\n\t\t\t\t\t\tonToggle={handlers.handleToggleStatus}\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\n\t\t\t\t<div className=\"flex-1 min-w-0\">\n\t\t\t\t\t<div className=\"flex items-start justify-between gap-2\">\n\t\t\t\t\t\t<div className=\"min-w-0 flex-1\">\n\t\t\t\t\t\t\t<TodoCardName\n\t\t\t\t\t\t\t\ttodo={todo}\n\t\t\t\t\t\t\t\tisEditing={state.isEditingName}\n\t\t\t\t\t\t\t\teditingName={state.editingName}\n\t\t\t\t\t\t\t\tnameInputRef={state.nameInputRef}\n\t\t\t\t\t\t\t\tonStartEdit={handlers.handleStartEditName}\n\t\t\t\t\t\t\t\tonSave={handlers.handleSaveName}\n\t\t\t\t\t\t\t\tonCancel={handlers.handleCancelEditName}\n\t\t\t\t\t\t\t\tonChange={state.setEditingName}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t{/* AI 操作按钮组 - hover时显示 */}\n\t\t\t\t\t\t<div className=\"opacity-0 group-hover:opacity-100 flex items-center gap-0.5 shrink-0 self-start mt-0.5\">\n\t\t\t\t\t\t\t{/* AI 拆解任务按钮 */}\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\tonClick={(e) => {\n\t\t\t\t\t\t\t\t\te.stopPropagation();\n\t\t\t\t\t\t\t\t\thandlers.handleStartBreakdown();\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tclassName=\"flex h-5 w-5 items-center justify-center rounded-md hover:bg-muted/50 transition-all\"\n\t\t\t\t\t\t\t\taria-label={tTodoDetail(\"useAiPlan\")}\n\t\t\t\t\t\t\t\ttitle={tTodoDetail(\"useAiPlanTitle\")}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<Hammer className=\"h-4 w-4 text-primary\" />\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t{/* 获取建议按钮 */}\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\tonClick={(e) => {\n\t\t\t\t\t\t\t\t\te.stopPropagation();\n\t\t\t\t\t\t\t\t\thandlers.handleGetAdvice();\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tclassName=\"flex h-5 w-5 items-center justify-center rounded-md hover:bg-muted/50 transition-all\"\n\t\t\t\t\t\t\t\taria-label={tTodoDetail(\"getAdvice\")}\n\t\t\t\t\t\t\t\ttitle={tTodoDetail(\"getAdviceTitle\")}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<Sparkles className=\"h-4 w-4 text-amber-500\" />\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t<div className=\"flex items-center gap-2 shrink-0\">\n\t\t\t\t\t\t\t{todo.attachments && todo.attachments.length > 0 && (\n\t\t\t\t\t\t\t\t<span className=\"flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] text-muted-foreground bg-muted/50\">\n\t\t\t\t\t\t\t\t\t<Paperclip className=\"h-3 w-3\" />\n\t\t\t\t\t\t\t\t\t{todo.attachments.length}\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t<TodoCardMetadata todo={todo} />\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t{state.isAddingChild && (\n\t\t\t\t<TodoCardChildForm\n\t\t\t\t\tchildName={state.childName}\n\t\t\t\t\tchildInputRef={state.childInputRef}\n\t\t\t\t\tonChange={state.setChildName}\n\t\t\t\t\tonSubmit={handlers.handleCreateChild}\n\t\t\t\t\tonCancel={() => {\n\t\t\t\t\t\tstate.setIsAddingChild(false);\n\t\t\t\t\t\tstate.setChildName(\"\");\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t)}\n\n\t\t\t{/* 放置区域：设为子任务 */}\n\t\t\t{drag.showNestDropZone && (\n\t\t\t\t<TodoCardDropZone droppable={drag.nestDroppable} />\n\t\t\t)}\n\t\t</div>\n\t);\n\n\t// 如果是拖拽覆盖层，不需要右键菜单\n\tif (isOverlay) {\n\t\treturn cardContent;\n\t}\n\n\t// 如果有多选，不显示单个 todo 的右键菜单（由 MultiTodoContextMenu 处理）\n\tif (hasMultipleSelection) {\n\t\treturn cardContent;\n\t}\n\n\treturn (\n\t\t<TodoContextMenu\n\t\t\ttodoId={todo.id}\n\t\t\tonAddChild={handlers.handleAddChildFromMenu}\n\t\t\tonContextMenuOpen={onSelectSingle}\n\t\t>\n\t\t\t{cardContent}\n\t\t</TodoContextMenu>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/todo-list/TodoExtractionModal.tsx",
    "content": "\"use client\";\n\nimport { Check, Clock, X } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport { useState } from \"react\";\nimport type { LifetraceSchemasTodoExtractionExtractedTodo } from \"@/lib/generated/schemas\";\nimport { useCreateTodo } from \"@/lib/query\";\nimport { toastError, toastSuccess } from \"@/lib/toast\";\nimport type { CreateTodoInput } from \"@/lib/types\";\nimport { cn, formatDateTime } from \"@/lib/utils\";\n\ninterface TodoExtractionModalProps {\n\tisOpen: boolean;\n\tonClose: () => void;\n\ttodos: LifetraceSchemasTodoExtractionExtractedTodo[];\n\teventId?: number; // 可选，手动截图可能没有 event_id\n\tappName?: string | null;\n}\n\nexport function TodoExtractionModal({\n\tisOpen,\n\tonClose,\n\ttodos,\n\teventId,\n\tappName,\n}: TodoExtractionModalProps) {\n\tconst t = useTranslations(\"todoExtraction\");\n\tconst createTodoMutation = useCreateTodo();\n\tconst [selectedTodos, setSelectedTodos] = useState<Set<number>>(\n\t\tnew Set(todos.map((_, index) => index)),\n\t);\n\n\tconst handleToggle = (index: number) => {\n\t\tconst newSelected = new Set(selectedTodos);\n\t\tif (newSelected.has(index)) {\n\t\t\tnewSelected.delete(index);\n\t\t} else {\n\t\t\tnewSelected.add(index);\n\t\t}\n\t\tsetSelectedTodos(newSelected);\n\t};\n\n\tconst handleSelectAll = () => {\n\t\tif (selectedTodos.size === todos.length) {\n\t\t\tsetSelectedTodos(new Set());\n\t\t} else {\n\t\t\tsetSelectedTodos(new Set(todos.map((_, index) => index)));\n\t\t}\n\t};\n\n\tconst handleConfirm = async () => {\n\t\tif (selectedTodos.size === 0) {\n\t\t\ttoastError(t(\"noTodosFound\"));\n\t\t\treturn;\n\t\t}\n\n\t\tlet successCount = 0;\n\t\tlet failCount = 0;\n\n\t\t// 使用 Promise.all 并发创建 todos\n\t\tconst createPromises = Array.from(selectedTodos).map(async (index) => {\n\t\t\tconst todo = todos[index];\n\t\t\ttry {\n\t\t\t\tconst userNotesParts = [\n\t\t\t\t\ttodo.source_text ? `${t(\"source\")}: ${todo.source_text}` : \"\",\n\t\t\t\t\ttodo.time_info?.raw_text ? `${t(\"time\")}: ${todo.time_info.raw_text}` : \"\",\n\t\t\t\t\teventId !== undefined ? `${t(\"eventId\")}: ${eventId}` : \"\",\n\t\t\t\t].filter(Boolean);\n\n\t\t\t\tconst todoInput: CreateTodoInput = {\n\t\t\t\t\tname: todo.title,\n\t\t\t\t\tdescription: todo.description || todo.source_text || undefined,\n\t\t\t\t\tstartTime: todo.scheduled_time || undefined,\n\t\t\t\t\ttags: [t(\"autoExtracted\")],\n\t\t\t\t\tuserNotes: userNotesParts.length > 0 ? userNotesParts.join(\"\\n\") : undefined,\n\t\t\t\t};\n\n\t\t\t\tawait createTodoMutation.mutateAsync(todoInput);\n\t\t\t\treturn { success: true };\n\t\t\t} catch (error) {\n\t\t\t\tconsole.error(\"添加待办失败:\", error);\n\t\t\t\treturn { success: false };\n\t\t\t}\n\t\t});\n\n\t\tconst results = await Promise.all(createPromises);\n\t\tfor (const result of results) {\n\t\t\tif (result.success) {\n\t\t\t\tsuccessCount++;\n\t\t\t} else {\n\t\t\t\tfailCount++;\n\t\t\t}\n\t\t}\n\n\t\tif (successCount > 0) {\n\t\t\ttoastSuccess(t(\"addSuccess\", { count: successCount }));\n\t\t}\n\t\tif (failCount > 0) {\n\t\t\ttoastError(\n\t\t\t\tt(\"addFailed\", { error: t(\"failedItems\", { count: failCount }) }),\n\t\t\t);\n\t\t}\n\n\t\tonClose();\n\t\tsetSelectedTodos(new Set());\n\t};\n\n\tconst formatTimeDisplay = (todo: LifetraceSchemasTodoExtractionExtractedTodo): string => {\n\t\tif (todo.scheduled_time) {\n\t\t\tconst scheduled = formatDateTime(todo.scheduled_time, \"YYYY-MM-DD HH:mm\");\n\t\t\tconst rawTime = todo.time_info.raw_text;\n\t\t\treturn `${rawTime} (${scheduled})`;\n\t\t}\n\t\treturn todo.time_info.raw_text || t(\"noTimeSpecified\");\n\t};\n\n\tif (!isOpen) return null;\n\n\treturn (\n\t\t<div\n\t\t\trole=\"button\"\n\t\t\ttabIndex={0}\n\t\t\tclassName=\"fixed inset-0 z-[200] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm\"\n\t\t\tonClick={onClose}\n\t\t\tonKeyDown={(e) => {\n\t\t\t\tif (e.key === \"Escape\") {\n\t\t\t\t\tonClose();\n\t\t\t\t}\n\t\t\t}}\n\t\t>\n\t\t\t<div\n\t\t\t\trole=\"dialog\"\n\t\t\t\tclassName=\"relative w-full max-w-3xl max-h-[90vh] bg-background border border-border rounded-lg shadow-lg overflow-hidden flex flex-col\"\n\t\t\t\tonClick={(e) => e.stopPropagation()}\n\t\t\t\tonKeyDown={(e) => {\n\t\t\t\t\tif (e.key === \"Escape\") {\n\t\t\t\t\t\tonClose();\n\t\t\t\t\t}\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t{/* 头部 */}\n\t\t\t\t<div className=\"flex-shrink-0 flex items-center justify-between border-b border-border bg-muted/30 px-4 py-3\">\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<h2 className=\"text-lg font-semibold\">{t(\"modalTitle\")}</h2>\n\t\t\t\t\t\t{appName && (\n\t\t\t\t\t\t\t<p className=\"text-sm text-muted-foreground mt-1\">\n\t\t\t\t\t\t\t\t{eventId !== undefined ? `事件 #${eventId} - ` : \"\"}{appName}\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t{!appName && eventId !== undefined && (\n\t\t\t\t\t\t\t<p className=\"text-sm text-muted-foreground mt-1\">\n\t\t\t\t\t\t\t\t事件 #{eventId}\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tonClick={onClose}\n\t\t\t\t\t\tclassName=\"rounded-md p-1.5 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors\"\n\t\t\t\t\t\taria-label={t(\"cancel\")}\n\t\t\t\t\t>\n\t\t\t\t\t\t<X className=\"h-5 w-5\" />\n\t\t\t\t\t</button>\n\t\t\t\t</div>\n\n\t\t\t\t{/* 描述 */}\n\t\t\t\t<div className=\"flex-shrink-0 px-4 py-2 border-b border-border bg-muted/20\">\n\t\t\t\t\t<p className=\"text-sm text-muted-foreground\">\n\t\t\t\t\t\t{t(\"modalDescription\")}\n\t\t\t\t\t</p>\n\t\t\t\t</div>\n\n\t\t\t\t{/* 操作栏 */}\n\t\t\t\t<div className=\"flex-shrink-0 flex items-center justify-between px-4 py-2 border-b border-border\">\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tonClick={handleSelectAll}\n\t\t\t\t\t\tclassName=\"text-sm text-primary hover:underline\"\n\t\t\t\t\t>\n\t\t\t\t\t\t{selectedTodos.size === todos.length\n\t\t\t\t\t\t\t? t(\"deselectAll\")\n\t\t\t\t\t\t\t: t(\"selectAll\")}\n\t\t\t\t\t</button>\n\t\t\t\t\t<span className=\"text-sm text-muted-foreground\">\n\t\t\t\t\t\t{t(\"selectedCount\", { count: selectedTodos.size })}\n\t\t\t\t\t</span>\n\t\t\t\t</div>\n\n\t\t\t\t{/* 待办列表 */}\n\t\t\t\t<div className=\"flex-1 overflow-y-auto p-4 space-y-3\">\n\t\t\t\t\t{todos.length === 0 ? (\n\t\t\t\t\t\t<div className=\"text-center py-8 text-muted-foreground\">\n\t\t\t\t\t\t\t<p>{t(\"noTodosFound\")}</p>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t) : (\n\t\t\t\t\t\ttodos.map((todo, index) => {\n\t\t\t\t\t\t\tconst isSelected = selectedTodos.has(index);\n\t\t\t\t\t\t\tconst todoKey = `todo-${todo.title}-${index}-${(todo.screenshot_ids || []).join(\"-\")}`;\n\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\tkey={todoKey}\n\t\t\t\t\t\t\t\t\trole=\"button\"\n\t\t\t\t\t\t\t\t\ttabIndex={0}\n\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\"border rounded-lg p-4 cursor-pointer transition-colors\",\n\t\t\t\t\t\t\t\t\t\tisSelected\n\t\t\t\t\t\t\t\t\t\t\t? \"border-primary bg-primary/5\"\n\t\t\t\t\t\t\t\t\t\t\t: \"border-border hover:border-primary/50\",\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\tonClick={() => handleToggle(index)}\n\t\t\t\t\t\t\t\t\tonKeyDown={(e) => {\n\t\t\t\t\t\t\t\t\t\tif (e.key === \"Enter\" || e.key === \" \") {\n\t\t\t\t\t\t\t\t\t\t\te.preventDefault();\n\t\t\t\t\t\t\t\t\t\t\thandleToggle(index);\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<div className=\"flex items-start gap-3\">\n\t\t\t\t\t\t\t\t\t\t<div className=\"flex-shrink-0 mt-0.5\">\n\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\"w-5 h-5 rounded border-2 flex items-center justify-center transition-colors\",\n\t\t\t\t\t\t\t\t\t\t\t\t\tisSelected\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t? \"border-primary bg-primary\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t: \"border-border\",\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t{isSelected && (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<Check className=\"h-3.5 w-3.5 text-primary-foreground\" />\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t<div className=\"flex-1 min-w-0 space-y-2\">\n\t\t\t\t\t\t\t\t\t\t\t<h3 className=\"font-medium text-foreground\">\n\t\t\t\t\t\t\t\t\t\t\t\t{todo.title}\n\t\t\t\t\t\t\t\t\t\t\t</h3>\n\t\t\t\t\t\t\t\t\t\t\t{todo.description && (\n\t\t\t\t\t\t\t\t\t\t\t\t<p className=\"text-sm text-muted-foreground\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t{todo.description}\n\t\t\t\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex flex-wrap items-center gap-3 text-xs text-muted-foreground\">\n\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-1\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t<Clock className=\"h-3.5 w-3.5\" />\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span>{formatTimeDisplay(todo)}</span>\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t{todo.confidence && (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t置信度: {(todo.confidence * 100).toFixed(0)}%\n\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t<div className=\"mt-2 pt-2 border-t border-border\">\n\t\t\t\t\t\t\t\t\t\t\t\t<p className=\"text-xs text-muted-foreground italic\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t{t(\"todoSource\")}: \"{todo.source_text}\"\n\t\t\t\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t})\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\n\t\t\t\t{/* 底部操作 */}\n\t\t\t\t<div className=\"flex-shrink-0 flex items-center justify-end gap-2 px-4 py-3 border-t border-border bg-muted/30\">\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tonClick={onClose}\n\t\t\t\t\t\tclassName=\"px-4 py-2 rounded-md border border-input bg-background hover:bg-muted text-sm font-medium transition-colors\"\n\t\t\t\t\t>\n\t\t\t\t\t\t{t(\"cancel\")}\n\t\t\t\t\t</button>\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tonClick={handleConfirm}\n\t\t\t\t\t\tdisabled={selectedTodos.size === 0}\n\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\"px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium transition-colors\",\n\t\t\t\t\t\t\t\"hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed\",\n\t\t\t\t\t\t)}\n\t\t\t\t\t>\n\t\t\t\t\t\t{t(\"confirmAdd\", { count: selectedTodos.size })}\n\t\t\t\t\t</button>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/todo-list/TodoList.tsx",
    "content": "\"use client\";\n\n/**\n * Todo 列表主组件\n * 使用全局 DndContext，通过 useDndMonitor 监听拖拽事件处理内部排序\n */\n\nimport { type DragEndEvent, useDndMonitor } from \"@dnd-kit/core\";\nimport { arrayMove } from \"@dnd-kit/sortable\";\nimport { ChevronRight } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport type React from \"react\";\nimport { useCallback, useState } from \"react\";\nimport { MultiTodoContextMenu } from \"@/components/common/context-menu/MultiTodoContextMenu\";\nimport type { DragData } from \"@/lib/dnd\";\nimport { useTodoMutations, useTodos } from \"@/lib/query\";\nimport type { ReorderTodoItem } from \"@/lib/query/todos\";\nimport { useTodoStore } from \"@/lib/store/todo-store\";\nimport type { CreateTodoInput, Todo } from \"@/lib/types\";\nimport { cn } from \"@/lib/utils\";\nimport type { TodoFilterState } from \"./components/TodoFilter\";\nimport { useOrderedTodos } from \"./hooks/useOrderedTodos\";\nimport { NewTodoInlineForm } from \"./NewTodoInlineForm\";\nimport { TodoToolbar } from \"./TodoToolbar\";\nimport { TodoTreeList } from \"./TodoTreeList\";\n\nexport function TodoList() {\n\tconst tTodoList = useTranslations(\"todoList\");\n\t// 从 TanStack Query 获取 todos 数据\n\tconst { data: todos = [], isLoading, error } = useTodos();\n\n\t// 从 TanStack Query 获取 mutation 操作\n\tconst { createTodo, reorderTodos } = useTodoMutations();\n\n\t// 从 Zustand 获取 UI 状态\n\tconst {\n\t\tselectedTodoIds,\n\t\tsetSelectedTodoId,\n\t\tsetSelectedTodoIds,\n\t\ttoggleTodoSelection,\n\t\tcollapsedTodoIds,\n\t\tanchorTodoId,\n\t\tsetAnchorTodoId,\n\t} = useTodoStore();\n\n\tconst [searchQuery, setSearchQuery] = useState(\"\");\n\tconst [newTodoName, setNewTodoName] = useState(\"\");\n\tconst [isCompletedCollapsed, setIsCompletedCollapsed] = useState(true);\n\tconst [filter, setFilter] = useState<TodoFilterState>({\n\t\tstatus: \"all\",\n\t\ttag: \"all\",\n\t\tdueTime: \"all\",\n\t});\n\n\tconst {\n\t\tfilteredTodos,\n\t\torderedTodos,\n\t\tcompletedOrderedTodos,\n\t\tcompletedRootCount,\n\t} = useOrderedTodos(\n\t\ttodos,\n\t\tsearchQuery,\n\t\tcollapsedTodoIds,\n\t\tfilter,\n\t);\n\n\t// 处理内部排序 - 当 TODO_CARD 在列表内移动时\n\tconst handleInternalReorder = useCallback(\n\t\tasync (event: DragEndEvent) => {\n\t\t\tconst { active, over } = event;\n\n\t\t\tif (!over || active.id === over.id) return;\n\n\t\t\t// 检查是否是 TODO_CARD 类型的拖拽\n\t\t\tconst dragData = active.data.current as DragData | undefined;\n\t\t\tif (dragData?.type !== \"TODO_CARD\") return;\n\n\t\t\tconst activeId = Number(active.id);\n\t\t\tconst overId = Number(over.id);\n\n\t\t\t// 获取拖拽的 todo\n\t\t\tconst activeTodo = todos.find((t: Todo) => t.id === activeId);\n\n\t\t\tif (!activeTodo) return;\n\n\t\t\t// 检查放置数据类型\n\t\t\tconst overData = over.data.current as\n\t\t\t\t| DragData\n\t\t\t\t| { type: string; metadata?: { position?: string; todoId?: number } }\n\t\t\t\t| undefined;\n\n\t\t\t// 情况1: 拖放到 todo 上设置父子关系（通过特殊放置区域）\n\t\t\tif (overData?.type === \"TODO_DROP_ZONE\") {\n\t\t\t\tconst metadata = (\n\t\t\t\t\toverData as { metadata?: { position?: string; todoId?: number } }\n\t\t\t\t)?.metadata;\n\t\t\t\tconst position = metadata?.position;\n\t\t\t\t// 从放置区域的 metadata 中获取目标 todo ID\n\t\t\t\tconst targetTodoId = metadata?.todoId;\n\n\t\t\t\tif (position === \"nest\" && targetTodoId !== undefined) {\n\t\t\t\t\t// 设置为子任务\n\t\t\t\t\t// 防止将任务设置为自己的子任务或子孙的子任务\n\t\t\t\t\tconst isDescendant = (\n\t\t\t\t\t\tparentId: number,\n\t\t\t\t\t\tchildId: number,\n\t\t\t\t\t\tallTodos: Todo[],\n\t\t\t\t\t): boolean => {\n\t\t\t\t\t\tlet current = allTodos.find((t) => t.id === childId);\n\t\t\t\t\t\twhile (current?.parentTodoId) {\n\t\t\t\t\t\t\tif (current.parentTodoId === parentId) return true;\n\t\t\t\t\t\t\tcurrent = allTodos.find((t) => t.id === current?.parentTodoId);\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn false;\n\t\t\t\t\t};\n\n\t\t\t\t\tif (\n\t\t\t\t\t\tactiveId !== targetTodoId &&\n\t\t\t\t\t\t!isDescendant(activeId, targetTodoId, todos)\n\t\t\t\t\t) {\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t// 获取目标父任务下的子任务\n\t\t\t\t\t\t\tconst siblings = todos.filter(\n\t\t\t\t\t\t\t\t(t: Todo) => t.parentTodoId === targetTodoId,\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t// 计算新的 order\n\t\t\t\t\t\t\tconst maxOrder = Math.max(\n\t\t\t\t\t\t\t\t0,\n\t\t\t\t\t\t\t\t...siblings.map((t: Todo) => t.order ?? 0),\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\tconst newOrder = maxOrder + 1;\n\n\t\t\t\t\t\t\tawait reorderTodos([\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tid: activeId,\n\t\t\t\t\t\t\t\t\torder: newOrder,\n\t\t\t\t\t\t\t\t\tparentTodoId: targetTodoId,\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t]);\n\t\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\t\tconsole.error(\"Failed to set parent-child relationship:\", err);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// 情况2: 常规列表内排序\n\t\t\tconst overTodo = todos.find((t: Todo) => t.id === overId);\n\t\t\tif (!overTodo) return;\n\n\t\t\tconst isInternalDrop = orderedTodos.some(\n\t\t\t\t({ todo }) => todo.id === overId,\n\t\t\t);\n\n\t\t\tif (isInternalDrop) {\n\t\t\t\tconst oldIndex = orderedTodos.findIndex(\n\t\t\t\t\t({ todo }) => todo.id === activeId,\n\t\t\t\t);\n\t\t\t\tconst newIndex = orderedTodos.findIndex(\n\t\t\t\t\t({ todo }) => todo.id === overId,\n\t\t\t\t);\n\n\t\t\t\tif (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {\n\t\t\t\t\t// 检查是否是同级排序（同一个父级）\n\t\t\t\t\tconst isSameLevel = activeTodo.parentTodoId === overTodo.parentTodoId;\n\n\t\t\t\t\tif (isSameLevel) {\n\t\t\t\t\t\t// 同级排序：更新同级 todos 的 order\n\t\t\t\t\t\tconst parentId = activeTodo.parentTodoId;\n\t\t\t\t\t\tconst siblings = todos.filter(\n\t\t\t\t\t\t\t(t: Todo) => t.parentTodoId === parentId,\n\t\t\t\t\t\t);\n\n\t\t\t\t\t\t// 找到在 orderedTodos 中的索引\n\t\t\t\t\t\tconst siblingIds = siblings.map((t: Todo) => t.id);\n\t\t\t\t\t\tconst oldSiblingIndex = siblingIds.indexOf(activeId);\n\t\t\t\t\t\tconst newSiblingIndex = siblingIds.indexOf(overId);\n\n\t\t\t\t\t\tif (oldSiblingIndex !== -1 && newSiblingIndex !== -1) {\n\t\t\t\t\t\t\t// 重新排列数组\n\t\t\t\t\t\t\tconst reorderedSiblings = arrayMove(\n\t\t\t\t\t\t\t\tsiblings,\n\t\t\t\t\t\t\t\toldSiblingIndex,\n\t\t\t\t\t\t\t\tnewSiblingIndex,\n\t\t\t\t\t\t\t);\n\n\t\t\t\t\t\t\t// 构建更新请求\n\t\t\t\t\t\t\tconst reorderItems: ReorderTodoItem[] = reorderedSiblings.map(\n\t\t\t\t\t\t\t\t(todo: Todo, index: number) => ({\n\t\t\t\t\t\t\t\t\tid: todo.id,\n\t\t\t\t\t\t\t\t\torder: index,\n\t\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t\t);\n\n\t\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t\tawait reorderTodos(reorderItems);\n\t\t\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\t\t\tconsole.error(\"Failed to reorder todos:\", err);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// 跨级移动：将任务移动到目标位置附近，并更新父级关系\n\t\t\t\t\t\tconst newParentId = overTodo.parentTodoId;\n\t\t\t\t\t\tconst newSiblings = todos.filter(\n\t\t\t\t\t\t\t(t: Todo) => t.parentTodoId === newParentId && t.id !== activeId,\n\t\t\t\t\t\t);\n\n\t\t\t\t\t\t// 找到插入位置\n\t\t\t\t\t\tconst overSiblingIndex = newSiblings.findIndex(\n\t\t\t\t\t\t\t(t: Todo) => t.id === overId,\n\t\t\t\t\t\t);\n\t\t\t\t\t\tconst insertIndex =\n\t\t\t\t\t\t\toverSiblingIndex !== -1 ? overSiblingIndex : newSiblings.length;\n\n\t\t\t\t\t\t// 在目标位置插入\n\t\t\t\t\t\tconst reorderedSiblings = [...newSiblings];\n\t\t\t\t\t\treorderedSiblings.splice(insertIndex, 0, activeTodo);\n\n\t\t\t\t\t\t// 构建更新请求\n\t\t\t\t\t\tconst reorderItems: ReorderTodoItem[] = reorderedSiblings.map(\n\t\t\t\t\t\t\t(todo: Todo, index: number) => ({\n\t\t\t\t\t\t\t\tid: todo.id,\n\t\t\t\t\t\t\t\torder: index,\n\t\t\t\t\t\t\t\t...(todo.id === activeId ? { parentTodoId: newParentId } : {}),\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t);\n\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tawait reorderTodos(reorderItems);\n\t\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\t\tconsole.error(\"Failed to move todo:\", err);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t\t[orderedTodos, todos, reorderTodos],\n\t);\n\n\t// 使用 useDndMonitor 监听全局拖拽事件\n\tuseDndMonitor({\n\t\tonDragEnd: handleInternalReorder,\n\t});\n\n\tconst handleSelect = (\n\t\ttodoId: number,\n\t\tevent: React.MouseEvent<HTMLDivElement>,\n\t) => {\n\t\tconst isShift = event.shiftKey;\n\t\tconst isMulti = event.metaKey || event.ctrlKey;\n\n\t\t// Shift 键范围选择\n\t\tif (isShift && !isMulti) {\n\t\t\t// 如果有锚点，进行范围选择\n\t\t\tif (anchorTodoId !== null) {\n\t\t\t\t// 找到锚点和当前点击的 todo 在 orderedTodos 中的索引\n\t\t\t\tconst anchorIndex = orderedTodos.findIndex(\n\t\t\t\t\t({ todo }) => todo.id === anchorTodoId,\n\t\t\t\t);\n\t\t\t\tconst currentIndex = orderedTodos.findIndex(\n\t\t\t\t\t({ todo }) => todo.id === todoId,\n\t\t\t\t);\n\n\t\t\t\t// 如果两个索引都有效\n\t\t\t\tif (anchorIndex !== -1 && currentIndex !== -1) {\n\t\t\t\t\t// 确定范围（从较小的索引到较大的索引）\n\t\t\t\t\tconst startIndex = Math.min(anchorIndex, currentIndex);\n\t\t\t\t\tconst endIndex = Math.max(anchorIndex, currentIndex);\n\n\t\t\t\t\t// 选择范围内的所有 todo\n\t\t\t\t\tconst rangeTodoIds = orderedTodos\n\t\t\t\t\t\t.slice(startIndex, endIndex + 1)\n\t\t\t\t\t\t.map(({ todo }) => todo.id);\n\n\t\t\t\t\tsetSelectedTodoIds(rangeTodoIds);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// 如果没有锚点或找不到索引，只选择当前 todo 并设置为锚点\n\t\t\tsetSelectedTodoId(todoId);\n\t\t\tsetAnchorTodoId(todoId);\n\t\t\treturn;\n\t\t}\n\n\t\t// Ctrl/Cmd 键多选\n\t\tif (isMulti && !isShift) {\n\t\t\ttoggleTodoSelection(todoId);\n\t\t\t// 多选时不改变锚点，保持上一次单独点击的锚点\n\t\t\treturn;\n\t\t}\n\n\t\t// 普通单击：只选择当前 todo\n\t\tsetSelectedTodoId(todoId);\n\t\tsetAnchorTodoId(todoId);\n\t};\n\n\tconst handleCreateTodo = async (e?: React.FormEvent) => {\n\t\tif (e) e.preventDefault();\n\t\tif (!newTodoName.trim()) return;\n\n\t\tconst input: CreateTodoInput = {\n\t\t\tname: newTodoName.trim(),\n\t\t};\n\n\t\ttry {\n\t\t\tawait createTodo(input);\n\t\t\tsetNewTodoName(\"\");\n\t\t} catch (err) {\n\t\t\tconsole.error(\"Failed to create todo:\", err);\n\t\t}\n\t};\n\n\t// 加载状态\n\tif (isLoading) {\n\t\treturn (\n\t\t\t<div className=\"flex h-full items-center justify-center\">\n\t\t\t\t<div className=\"h-6 w-6 animate-spin rounded-full border-2 border-primary/30 border-t-primary\" />\n\t\t\t</div>\n\t\t);\n\t}\n\n\t// 错误状态\n\tif (error) {\n\t\tconst errorMessage =\n\t\t\terror instanceof Error ? error.message : String(error) || \"Unknown error\";\n\t\treturn (\n\t\t\t<div className=\"flex h-full items-center justify-center text-destructive\">\n\t\t\t\t{tTodoList(\"loadFailed\", { error: errorMessage })}\n\t\t\t</div>\n\t\t);\n\t}\n\n\treturn (\n\t\t<div className=\"relative flex h-full flex-col overflow-hidden bg-background dark:bg-background\">\n\t\t\t<TodoToolbar\n\t\t\t\tsearchQuery={searchQuery}\n\t\t\t\tonSearch={setSearchQuery}\n\t\t\t\ttodos={todos}\n\t\t\t\tfilter={filter}\n\t\t\t\tonFilterChange={setFilter}\n\t\t\t/>\n\n\t\t\t<MultiTodoContextMenu selectedTodoIds={selectedTodoIds}>\n\t\t\t\t<div className=\"flex-1 overflow-y-auto\">\n\t\t\t\t\t<div className=\"px-6 py-4 pb-4\">\n\t\t\t\t\t\t<NewTodoInlineForm\n\t\t\t\t\t\t\tvalue={newTodoName}\n\t\t\t\t\t\t\tonChange={setNewTodoName}\n\t\t\t\t\t\t\tonSubmit={handleCreateTodo}\n\t\t\t\t\t\t\tonCancel={() => setNewTodoName(\"\")}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t{filteredTodos.length === 0 ? (\n\t\t\t\t\t\t<div className=\"flex h-[200px] items-center justify-center px-4 text-sm text-muted-foreground\">\n\t\t\t\t\t\t\t{tTodoList(\"noTodos\")}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t) : (\n\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t{orderedTodos.length > 0 && (\n\t\t\t\t\t\t\t\t<TodoTreeList\n\t\t\t\t\t\t\t\t\torderedTodos={orderedTodos}\n\t\t\t\t\t\t\t\t\tselectedTodoIds={selectedTodoIds}\n\t\t\t\t\t\t\t\t\tonSelect={handleSelect}\n\t\t\t\t\t\t\t\t\tonSelectSingle={(id) => setSelectedTodoId(id)}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t{filter.status === \"all\" && completedRootCount > 0 && (\n\t\t\t\t\t\t\t\t<div className=\"px-6 pb-6\">\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\tonClick={() => setIsCompletedCollapsed((prev) => !prev)}\n\t\t\t\t\t\t\t\t\t\tclassName=\"flex w-full items-center justify-between rounded-lg border border-dashed border-border bg-muted/20 px-3 py-2 text-sm text-muted-foreground hover:bg-muted/30\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<span className=\"flex items-center gap-2 font-medium\">\n\t\t\t\t\t\t\t\t\t\t\t<ChevronRight\n\t\t\t\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\"h-4 w-4 transition-transform\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t!isCompletedCollapsed && \"rotate-90\",\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t{tTodoList(\"statusCompleted\")}\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t<span className=\"text-xs text-muted-foreground\">\n\t\t\t\t\t\t\t\t\t\t\t{completedRootCount}\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t{!isCompletedCollapsed &&\n\t\t\t\t\t\t\t\t\t\tcompletedOrderedTodos.length > 0 && (\n\t\t\t\t\t\t\t\t\t\t\t<TodoTreeList\n\t\t\t\t\t\t\t\t\t\t\t\torderedTodos={completedOrderedTodos}\n\t\t\t\t\t\t\t\t\t\t\t\tselectedTodoIds={selectedTodoIds}\n\t\t\t\t\t\t\t\t\t\t\t\tonSelect={handleSelect}\n\t\t\t\t\t\t\t\t\t\t\t\tonSelectSingle={(id) => setSelectedTodoId(id)}\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t</MultiTodoContextMenu>\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/todo-list/TodoToolbar.tsx",
    "content": "\"use client\";\n\nimport { ListTodo, Search } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport { useEffect, useRef, useState } from \"react\";\nimport {\n\tPanelActionButton,\n\tPanelHeader,\n\tusePanelIconStyle,\n} from \"@/components/common/layout/PanelHeader\";\nimport type { Todo } from \"@/lib/types\";\nimport { cn } from \"@/lib/utils\";\nimport type { TodoFilterState } from \"./components/TodoFilter\";\nimport { TodoFilter } from \"./components/TodoFilter\";\n\ninterface TodoToolbarProps {\n\tsearchQuery: string;\n\tonSearch: (value: string) => void;\n\ttodos: Todo[];\n\tfilter: TodoFilterState;\n\tonFilterChange: (filter: TodoFilterState) => void;\n}\n\nexport function TodoToolbar({\n\tsearchQuery,\n\tonSearch,\n\ttodos,\n\tfilter,\n\tonFilterChange,\n}: TodoToolbarProps) {\n\tconst t = useTranslations(\"page\");\n\tconst tTodoList = useTranslations(\"todoList\");\n\tconst [isSearchOpen, setIsSearchOpen] = useState(false);\n\tconst searchInputRef = useRef<HTMLInputElement>(null);\n\tconst searchContainerRef = useRef<HTMLDivElement>(null);\n\tconst actionIconStyle = usePanelIconStyle(\"action\");\n\n\tuseEffect(() => {\n\t\tif (isSearchOpen && searchInputRef.current) {\n\t\t\tsearchInputRef.current.focus();\n\t\t}\n\t}, [isSearchOpen]);\n\n\tuseEffect(() => {\n\t\tconst handleClickOutside = (event: MouseEvent) => {\n\t\t\tif (\n\t\t\t\tsearchContainerRef.current &&\n\t\t\t\t!searchContainerRef.current.contains(event.target as Node) &&\n\t\t\t\t!searchQuery\n\t\t\t) {\n\t\t\t\tsetIsSearchOpen(false);\n\t\t\t}\n\t\t};\n\n\t\tif (isSearchOpen) {\n\t\t\tdocument.addEventListener(\"mousedown\", handleClickOutside);\n\t\t\treturn () => {\n\t\t\t\tdocument.removeEventListener(\"mousedown\", handleClickOutside);\n\t\t\t};\n\t\t}\n\t}, [isSearchOpen, searchQuery]);\n\n\treturn (\n\t\t<PanelHeader\n\t\t\ticon={ListTodo}\n\t\t\ttitle={t(\"todoListTitle\")}\n\t\t\tactions={\n\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t<TodoFilter\n\t\t\t\t\t\ttodos={todos}\n\t\t\t\t\t\tfilter={filter}\n\t\t\t\t\t\tonFilterChange={onFilterChange}\n\t\t\t\t\t/>\n\t\t\t\t\t<div ref={searchContainerRef} className=\"relative\">\n\t\t\t\t\t\t{isSearchOpen ? (\n\t\t\t\t\t\t\t<div className=\"relative\">\n\t\t\t\t\t\t\t\t<Search\n\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\"absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground\",\n\t\t\t\t\t\t\t\t\t\tactionIconStyle,\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\tref={searchInputRef}\n\t\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\t\tvalue={searchQuery}\n\t\t\t\t\t\t\t\t\tonChange={(e) => onSearch(e.target.value)}\n\t\t\t\t\t\t\t\t\tplaceholder={tTodoList(\"searchPlaceholder\")}\n\t\t\t\t\t\t\t\t\tclassName=\"h-7 w-48 rounded-md border border-primary/20 px-8 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary\"\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<PanelActionButton\n\t\t\t\t\t\t\t\tvariant=\"default\"\n\t\t\t\t\t\t\t\ticon={Search}\n\t\t\t\t\t\t\t\tonClick={() => setIsSearchOpen(true)}\n\t\t\t\t\t\t\t\ticonOverrides={{ color: \"text-muted-foreground\" }}\n\t\t\t\t\t\t\t\tbuttonOverrides={{ hoverTextColor: \"hover:text-foreground\" }}\n\t\t\t\t\t\t\t\taria-label={tTodoList(\"searchPlaceholder\")}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t}\n\t\t/>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/todo-list/TodoTreeList.tsx",
    "content": "\"use client\";\n\n/**\n * Todo 树形列表组件\n * 使用 SortableContext 实现列表内排序\n * DndContext 由全局 GlobalDndProvider 提供\n */\n\nimport {\n\tSortableContext,\n\tverticalListSortingStrategy,\n} from \"@dnd-kit/sortable\";\nimport type React from \"react\";\nimport { useGlobalDndSafe } from \"@/lib/dnd\";\nimport type { OrderedTodo } from \"./hooks/useOrderedTodos\";\nimport { TodoCard } from \"./TodoCard\";\n\ninterface TodoTreeListProps {\n\torderedTodos: OrderedTodo[];\n\tselectedTodoIds: number[];\n\tonSelect: (todoId: number, event: React.MouseEvent<HTMLDivElement>) => void;\n\tonSelectSingle: (todoId: number) => void;\n}\n\nexport function TodoTreeList({\n\torderedTodos,\n\tselectedTodoIds,\n\tonSelect,\n\tonSelectSingle,\n}: TodoTreeListProps) {\n\t// 从全局上下文获取活动拖拽状态\n\tconst dndContext = useGlobalDndSafe();\n\tconst activeId = dndContext?.activeDrag?.id ?? null;\n\n\treturn (\n\t\t<SortableContext\n\t\t\titems={orderedTodos.map(({ todo }) => todo.id)}\n\t\t\tstrategy={verticalListSortingStrategy}\n\t\t>\n\t\t\t<div className=\"px-4 pb-6 flex flex-col gap-0\">\n\t\t\t\t{orderedTodos.map(({ todo, depth }) => (\n\t\t\t\t\t<div\n\t\t\t\t\t\tkey={todo.id}\n\t\t\t\t\t\tstyle={{ marginLeft: depth * 16 }}\n\t\t\t\t\t\tclassName={depth > 0 ? \"relative\" : undefined}\n\t\t\t\t\t>\n\t\t\t\t\t\t<TodoCard\n\t\t\t\t\t\t\ttodo={todo}\n\t\t\t\t\t\t\tdepth={depth}\n\t\t\t\t\t\t\tisDragging={activeId === todo.id}\n\t\t\t\t\t\t\tselected={selectedTodoIds.includes(todo.id)}\n\t\t\t\t\t\t\thasMultipleSelection={selectedTodoIds.length > 1}\n\t\t\t\t\t\t\tonSelect={(event) => onSelect(todo.id, event)}\n\t\t\t\t\t\t\tonSelectSingle={() => onSelectSingle(todo.id)}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t))}\n\t\t\t</div>\n\t\t</SortableContext>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/todo-list/components/TodoCardCheckbox.tsx",
    "content": "import { X } from \"lucide-react\";\nimport type { Todo } from \"@/lib/types\";\nimport { cn } from \"@/lib/utils\";\nimport { getPriorityBorderColor } from \"../utils/todoCardUtils\";\n\ninterface TodoCardCheckboxProps {\n\ttodo: Todo;\n\tonToggle: (e: React.MouseEvent) => void;\n}\n\nexport function TodoCardCheckbox({ todo, onToggle }: TodoCardCheckboxProps) {\n\treturn (\n\t\t<button\n\t\t\ttype=\"button\"\n\t\t\tonClick={onToggle}\n\t\t\tclassName=\"shrink-0 flex items-center\"\n\t\t>\n\t\t\t{todo.status === \"completed\" ? (\n\t\t\t\t<div className=\"flex h-4 w-4 items-center justify-center rounded-md bg-[oklch(var(--primary))] border border-[oklch(var(--primary))] shadow-inner\">\n\t\t\t\t\t<span className=\"text-[8px] text-[oklch(var(--primary-foreground))] font-semibold\">\n\t\t\t\t\t\t✓\n\t\t\t\t\t</span>\n\t\t\t\t</div>\n\t\t\t) : todo.status === \"canceled\" ? (\n\t\t\t\t<div\n\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\"flex h-4 w-4 items-center justify-center rounded-md border-2\",\n\t\t\t\t\t\tgetPriorityBorderColor(todo.priority ?? \"none\"),\n\t\t\t\t\t\t\"bg-muted/30 text-muted-foreground/70\",\n\t\t\t\t\t\t\"transition-colors\",\n\t\t\t\t\t\t\"hover:bg-muted/40 hover:text-muted-foreground\",\n\t\t\t\t\t)}\n\t\t\t\t>\n\t\t\t\t\t<X className=\"h-2.5 w-2.5\" strokeWidth={2.5} />\n\t\t\t\t</div>\n\t\t\t) : todo.status === \"draft\" ? (\n\t\t\t\t<div className=\"flex h-4 w-4 items-center justify-center rounded-md bg-orange-500 border border-orange-600 dark:border-orange-500 shadow-inner\">\n\t\t\t\t\t<span className=\"text-[10px] text-white dark:text-orange-50 font-semibold\">\n\t\t\t\t\t\t—\n\t\t\t\t\t</span>\n\t\t\t\t</div>\n\t\t\t) : (\n\t\t\t\t<div\n\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\"h-4 w-4 rounded-md border-2 transition-colors\",\n\t\t\t\t\t\tgetPriorityBorderColor(todo.priority ?? \"none\"),\n\t\t\t\t\t\t\"hover:border-foreground\",\n\t\t\t\t\t)}\n\t\t\t\t/>\n\t\t\t)}\n\t\t</button>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/todo-list/components/TodoCardChildForm.tsx",
    "content": "import { Plus } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport type React from \"react\";\n\ninterface TodoCardChildFormProps {\n\tchildName: string;\n\tchildInputRef: React.RefObject<HTMLInputElement | null>;\n\tonChange: (value: string) => void;\n\tonSubmit: (e?: React.FormEvent) => void;\n\tonCancel: () => void;\n}\n\nexport function TodoCardChildForm({\n\tchildName,\n\tchildInputRef,\n\tonChange,\n\tonSubmit,\n\tonCancel,\n}: TodoCardChildFormProps) {\n\tconst tTodoDetail = useTranslations(\"todoDetail\");\n\n\treturn (\n\t\t<form\n\t\t\tonSubmit={onSubmit}\n\t\t\tonMouseDown={(e) => e.stopPropagation()}\n\t\t\tclassName=\"mt-2 space-y-2 rounded-lg border border-dashed border-primary/50 bg-primary/5 p-2\"\n\t\t>\n\t\t\t<input\n\t\t\t\tref={childInputRef}\n\t\t\t\ttype=\"text\"\n\t\t\t\tvalue={childName}\n\t\t\t\tonChange={(e) => onChange(e.target.value)}\n\t\t\t\tonKeyDown={(e) => {\n\t\t\t\t\t// 阻止所有键盘事件冒泡到父元素，避免空格等键被父元素拦截\n\t\t\t\t\te.stopPropagation();\n\t\t\t\t\tif (e.key === \"Enter\" && !e.nativeEvent.isComposing) {\n\t\t\t\t\t\t// 只在非输入法组合状态下处理回车键，避免干扰中文输入法\n\t\t\t\t\t\te.preventDefault(); // 阻止表单提交，避免重复创建\n\t\t\t\t\t\tonSubmit();\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\tif (e.key === \"Escape\") {\n\t\t\t\t\t\tonCancel();\n\t\t\t\t\t}\n\t\t\t\t}}\n\t\t\t\tplaceholder={tTodoDetail(\"addChildPlaceholder\")}\n\t\t\t\tclassName=\"w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary\"\n\t\t\t/>\n\t\t\t<div className=\"flex items-center justify-end gap-2\">\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tonClick={onCancel}\n\t\t\t\t\tclassName=\"rounded-md px-3 py-1.5 text-xs font-medium text-muted-foreground hover:bg-muted transition-colors\"\n\t\t\t\t>\n\t\t\t\t\t{tTodoDetail(\"cancel\")}\n\t\t\t\t</button>\n\t\t\t\t<button\n\t\t\t\t\ttype=\"submit\"\n\t\t\t\t\tclassName=\"inline-flex items-center gap-1 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground shadow-sm transition-colors hover:bg-primary/90\"\n\t\t\t\t>\n\t\t\t\t\t<Plus className=\"h-4 w-4\" />\n\t\t\t\t\t{tTodoDetail(\"add\")}\n\t\t\t\t</button>\n\t\t\t</div>\n\t\t</form>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/todo-list/components/TodoCardDropZone.tsx",
    "content": "import type { useDroppable } from \"@dnd-kit/core\";\nimport { CornerDownRight } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport { cn } from \"@/lib/utils\";\n\ninterface TodoCardDropZoneProps {\n\tdroppable: ReturnType<typeof useDroppable>;\n}\n\nexport function TodoCardDropZone({ droppable }: TodoCardDropZoneProps) {\n\tconst tTodoDetail = useTranslations(\"todoDetail\");\n\n\treturn (\n\t\t<div\n\t\t\tref={droppable.setNodeRef}\n\t\t\tclassName={cn(\n\t\t\t\t\"absolute inset-0 z-10 flex items-center justify-center rounded-xl border-2 border-dashed transition-all duration-200\",\n\t\t\t\tdroppable.isOver\n\t\t\t\t\t? \"border-primary bg-primary/10\"\n\t\t\t\t\t: \"border-muted-foreground/30 bg-muted/20\",\n\t\t\t)}\n\t\t>\n\t\t\t<div\n\t\t\t\tclassName={cn(\n\t\t\t\t\t\"flex items-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium transition-colors\",\n\t\t\t\t\tdroppable.isOver\n\t\t\t\t\t\t? \"bg-primary text-primary-foreground\"\n\t\t\t\t\t\t: \"bg-muted text-muted-foreground\",\n\t\t\t\t)}\n\t\t\t>\n\t\t\t\t<CornerDownRight className=\"h-4 w-4\" />\n\t\t\t\t<span>{tTodoDetail(\"setAsChild\")}</span>\n\t\t\t</div>\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/todo-list/components/TodoCardExpandButton.tsx",
    "content": "import { ChevronRight } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport { cn } from \"@/lib/utils\";\n\ninterface TodoCardExpandButtonProps {\n\thasChildren: boolean;\n\tisExpanded: boolean;\n\tonToggle: () => void;\n}\n\nexport function TodoCardExpandButton({\n\thasChildren,\n\tisExpanded,\n\tonToggle,\n}: TodoCardExpandButtonProps) {\n\tconst tTodoDetail = useTranslations(\"todoDetail\");\n\n\tif (!hasChildren) {\n\t\treturn <div className=\"w-4 shrink-0\" />;\n\t}\n\n\treturn (\n\t\t<button\n\t\t\ttype=\"button\"\n\t\t\tonClick={(e) => {\n\t\t\t\te.stopPropagation();\n\t\t\t\tonToggle();\n\t\t\t}}\n\t\t\tclassName=\"shrink-0 flex h-4 w-4 items-center justify-center rounded-md hover:bg-muted/50 transition-colors self-start mt-1\"\n\t\t\taria-label={\n\t\t\t\tisExpanded\n\t\t\t\t\t? tTodoDetail(\"collapseSubTasks\")\n\t\t\t\t\t: tTodoDetail(\"expandSubTasks\")\n\t\t\t}\n\t\t>\n\t\t\t<ChevronRight\n\t\t\t\tclassName={cn(\n\t\t\t\t\t\"h-3 w-3 text-muted-foreground transition-transform duration-200\",\n\t\t\t\t\tisExpanded && \"rotate-90\",\n\t\t\t\t)}\n\t\t\t/>\n\t\t</button>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/todo-list/components/TodoCardMetadata.tsx",
    "content": "import { Calendar, Paperclip, Tag } from \"lucide-react\";\nimport type { Todo } from \"@/lib/types\";\nimport { formatScheduleLabel } from \"../utils/todoCardUtils\";\n\ninterface TodoCardMetadataProps {\n\ttodo: Todo;\n}\n\nexport function TodoCardMetadata({ todo }: TodoCardMetadataProps) {\n\tconst hasMetadata =\n\t\ttodo.startTime ||\n\t\ttodo.endTime ||\n\t\t(todo.attachments && todo.attachments.length > 0) ||\n\t\t(todo.tags && todo.tags.length > 0);\n\n\tif (!hasMetadata) {\n\t\treturn null;\n\t}\n\n\tconst scheduleLabel = formatScheduleLabel(todo.startTime, todo.endTime);\n\n\treturn (\n\t\t<div className=\"flex flex-wrap items-center gap-2 text-xs text-muted-foreground mt-1\">\n\t\t\t{scheduleLabel && (\n\t\t\t\t<div className=\"flex items-center gap-1 rounded-md bg-muted/40 px-2 py-1\">\n\t\t\t\t\t<Calendar className=\"h-3 w-3\" />\n\t\t\t\t\t<span>{scheduleLabel}</span>\n\t\t\t\t</div>\n\t\t\t)}\n\n\t\t\t{todo.attachments && todo.attachments.length > 0 && (\n\t\t\t\t<div className=\"flex items-center gap-1 rounded-md bg-muted/40 px-2 py-1\">\n\t\t\t\t\t<Paperclip className=\"h-3 w-3\" />\n\t\t\t\t\t<span>{todo.attachments.length}</span>\n\t\t\t\t</div>\n\t\t\t)}\n\n\t\t\t{todo.tags && todo.tags.length > 0 && (\n\t\t\t\t<div className=\"flex flex-wrap items-center gap-1\">\n\t\t\t\t\t<Tag className=\"h-3 w-3\" />\n\t\t\t\t\t{todo.tags.slice(0, 3).map((tag) => (\n\t\t\t\t\t\t<span\n\t\t\t\t\t\t\tkey={tag}\n\t\t\t\t\t\t\tclassName=\"px-2 py-0.5 rounded-full bg-muted text-[11px] font-medium text-foreground\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{tag}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t))}\n\t\t\t\t\t{todo.tags.length > 3 && (\n\t\t\t\t\t\t<span className=\"text-[11px] text-muted-foreground\">\n\t\t\t\t\t\t\t+{todo.tags.length - 3}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/todo-list/components/TodoCardName.tsx",
    "content": "import type React from \"react\";\nimport type { Todo } from \"@/lib/types\";\nimport { cn } from \"@/lib/utils\";\n\ninterface TodoCardNameProps {\n\ttodo: Todo;\n\tisEditing: boolean;\n\teditingName: string;\n\tnameInputRef: React.RefObject<HTMLInputElement | null>;\n\tonStartEdit: (e: React.MouseEvent) => void;\n\tonSave: () => void;\n\tonCancel: () => void;\n\tonChange: (value: string) => void;\n}\n\nexport function TodoCardName({\n\ttodo,\n\tisEditing,\n\teditingName,\n\tnameInputRef,\n\tonStartEdit,\n\tonSave,\n\tonCancel,\n\tonChange,\n}: TodoCardNameProps) {\n\tif (isEditing) {\n\t\treturn (\n\t\t\t<input\n\t\t\t\tref={nameInputRef}\n\t\t\t\ttype=\"text\"\n\t\t\t\tvalue={editingName}\n\t\t\t\tonChange={(e) => onChange(e.target.value)}\n\t\t\t\tonBlur={onSave}\n\t\t\t\tonKeyDown={(e) => {\n\t\t\t\t\te.stopPropagation();\n\t\t\t\t\tif (e.key === \"Enter\" && !e.nativeEvent.isComposing) {\n\t\t\t\t\t\te.preventDefault();\n\t\t\t\t\t\tonSave();\n\t\t\t\t\t} else if (e.key === \"Escape\") {\n\t\t\t\t\t\tonCancel();\n\t\t\t\t\t}\n\t\t\t\t}}\n\t\t\t\tonMouseDown={(e) => e.stopPropagation()}\n\t\t\t\tclassName={cn(\n\t\t\t\t\t\"w-full text-sm text-foreground leading-5 m-0 px-1 py-0.5 rounded-md\",\n\t\t\t\t\t\"bg-background border border-primary focus:outline-none focus:ring-2 focus:ring-primary\",\n\t\t\t\t\t\"wrap-break-word\",\n\t\t\t\t)}\n\t\t\t/>\n\t\t);\n\t}\n\n\treturn (\n\t\t<div\n\t\t\tclassName={cn(\n\t\t\t\t\"text-sm text-foreground leading-5 m-0 wrap-break-word line-clamp-3\",\n\t\t\t\t\"rounded-md px-1 py-0.5\",\n\t\t\t\ttodo.status === \"completed\" && \"line-through text-muted-foreground\",\n\t\t\t\ttodo.status === \"canceled\" && \"line-through text-muted-foreground\",\n\t\t\t)}\n\t\t>\n\t\t\t<span\n\t\t\t\trole=\"button\"\n\t\t\t\ttabIndex={0}\n\t\t\t\tonClick={onStartEdit}\n\t\t\t\tonKeyDown={(e) => {\n\t\t\t\t\tif (e.key === \"Enter\" || e.key === \" \") {\n\t\t\t\t\t\te.preventDefault();\n\t\t\t\t\t\tonStartEdit(e as unknown as React.MouseEvent);\n\t\t\t\t\t}\n\t\t\t\t}}\n\t\t\t\tclassName=\"cursor-text hover:bg-muted/30 rounded transition-colors\"\n\t\t\t>\n\t\t\t\t{todo.name}\n\t\t\t</span>\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/todo-list/components/TodoFilter.tsx",
    "content": "\"use client\";\n\nimport { ChevronDown, Filter, X } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { PanelActionButton } from \"@/components/common/layout/PanelHeader\";\nimport type { Todo, TodoStatus } from \"@/lib/types\";\nimport { cn } from \"@/lib/utils\";\n\nexport type DueTimeFilter =\n\t| \"all\"\n\t| \"overdue\"\n\t| \"today\"\n\t| \"tomorrow\"\n\t| \"thisWeek\"\n\t| \"thisMonth\"\n\t| \"future\";\n\nexport interface TodoFilterState {\n\tstatus: TodoStatus | \"all\";\n\ttag: string | \"all\";\n\tdueTime: DueTimeFilter;\n}\n\ninterface TodoFilterProps {\n\ttodos: Todo[];\n\tfilter: TodoFilterState;\n\tonFilterChange: (filter: TodoFilterState) => void;\n}\n\nexport function TodoFilter({ todos, filter, onFilterChange }: TodoFilterProps) {\n\tconst tTodoList = useTranslations(\"todoList\");\n\tconst [isOpen, setIsOpen] = useState(false);\n\tconst filterContainerRef = useRef<HTMLDivElement>(null);\n\n\t// Extract all unique tags from todos\n\tconst allTags = Array.from(\n\t\tnew Set(todos.flatMap((todo) => todo.tags || [])),\n\t).sort();\n\n\t// Check if any filter is active\n\tconst isFilterActive =\n\t\tfilter.status !== \"all\" || filter.tag !== \"all\" || filter.dueTime !== \"all\";\n\n\tuseEffect(() => {\n\t\tconst handleClickOutside = (event: MouseEvent) => {\n\t\t\tif (\n\t\t\t\tfilterContainerRef.current &&\n\t\t\t\t!filterContainerRef.current.contains(event.target as Node)\n\t\t\t) {\n\t\t\t\tsetIsOpen(false);\n\t\t\t}\n\t\t};\n\n\t\tif (isOpen) {\n\t\t\tdocument.addEventListener(\"mousedown\", handleClickOutside);\n\t\t\treturn () => {\n\t\t\t\tdocument.removeEventListener(\"mousedown\", handleClickOutside);\n\t\t\t};\n\t\t}\n\t}, [isOpen]);\n\n\tconst handleStatusChange = (status: TodoStatus | \"all\") => {\n\t\tonFilterChange({ ...filter, status });\n\t};\n\n\tconst handleTagChange = (tag: string | \"all\") => {\n\t\tonFilterChange({ ...filter, tag });\n\t};\n\n\tconst handleDueTimeChange = (dueTime: DueTimeFilter) => {\n\t\tonFilterChange({ ...filter, dueTime });\n\t};\n\n\tconst handleClearFilters = () => {\n\t\tonFilterChange({\n\t\t\tstatus: \"all\",\n\t\t\ttag: \"all\",\n\t\t\tdueTime: \"all\",\n\t\t});\n\t};\n\n\t// Quick time filter options\n\tconst quickTimeOptions: { value: DueTimeFilter; label: string }[] = [\n\t\t{ value: \"today\", label: tTodoList(\"dueTimeToday\") },\n\t\t{ value: \"tomorrow\", label: tTodoList(\"dueTimeTomorrow\") },\n\t\t{ value: \"thisWeek\", label: tTodoList(\"dueTimeThisWeek\") },\n\t];\n\n\t// All time filter options for dropdown\n\tconst allTimeOptions: { value: DueTimeFilter; label: string }[] = [\n\t\t{ value: \"all\", label: tTodoList(\"filterAll\") },\n\t\t{ value: \"overdue\", label: tTodoList(\"dueTimeOverdue\") },\n\t\t{ value: \"today\", label: tTodoList(\"dueTimeToday\") },\n\t\t{ value: \"tomorrow\", label: tTodoList(\"dueTimeTomorrow\") },\n\t\t{ value: \"thisWeek\", label: tTodoList(\"dueTimeThisWeek\") },\n\t\t{ value: \"thisMonth\", label: tTodoList(\"dueTimeThisMonth\") },\n\t\t{ value: \"future\", label: tTodoList(\"dueTimeFuture\") },\n\t];\n\n\t// Status options\n\tconst statusOptions: { value: TodoStatus | \"all\"; label: string }[] = [\n\t\t{ value: \"all\", label: tTodoList(\"filterAll\") },\n\t\t{ value: \"active\", label: tTodoList(\"statusActive\") },\n\t\t{ value: \"completed\", label: tTodoList(\"statusCompleted\") },\n\t\t{ value: \"canceled\", label: tTodoList(\"statusCanceled\") },\n\t\t{ value: \"draft\", label: tTodoList(\"statusDraft\") },\n\t];\n\n\t// Common status options for quick selection\n\tconst commonStatusOptions: { value: TodoStatus; label: string }[] = [\n\t\t{ value: \"active\", label: tTodoList(\"statusActive\") },\n\t\t{ value: \"completed\", label: tTodoList(\"statusCompleted\") },\n\t];\n\n\treturn (\n\t\t<div ref={filterContainerRef} className=\"relative\">\n\t\t\t<PanelActionButton\n\t\t\t\tvariant=\"default\"\n\t\t\t\ticon={Filter}\n\t\t\t\tonClick={() => setIsOpen(!isOpen)}\n\t\t\t\ticonOverrides={{\n\t\t\t\t\tcolor: isFilterActive ? \"text-primary\" : \"text-muted-foreground\",\n\t\t\t\t}}\n\t\t\t\tbuttonOverrides={{\n\t\t\t\t\thoverTextColor: \"hover:text-foreground\",\n\t\t\t\t}}\n\t\t\t\taria-label={tTodoList(\"filter\")}\n\t\t\t/>\n\t\t\t{isOpen && (\n\t\t\t\t<div className=\"absolute right-0 top-8 z-50 w-54 rounded-lg border border-border bg-background shadow-lg p-4 space-y-4\">\n\t\t\t\t\t{/* Due Time Quick Filters */}\n\t\t\t\t\t<div className=\"space-y-2\">\n\t\t\t\t\t\t<div className=\"text-xs font-semibold text-foreground uppercase tracking-wide\">\n\t\t\t\t\t\t\t{tTodoList(\"filterDueTime\")}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"flex flex-wrap gap-2\">\n\t\t\t\t\t\t\t{quickTimeOptions.map((option) => (\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tkey={option.value}\n\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\tonClick={() => handleDueTimeChange(option.value)}\n\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\"px-3 py-1.5 rounded-md text-xs font-medium transition-colors\",\n\t\t\t\t\t\t\t\t\t\tfilter.dueTime === option.value\n\t\t\t\t\t\t\t\t\t\t\t? \"bg-primary text-primary-foreground shadow-sm\"\n\t\t\t\t\t\t\t\t\t\t\t: \"bg-muted/50 text-foreground hover:bg-muted hover:text-foreground\",\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{option.label}\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t{/* Dropdown for all options */}\n\t\t\t\t\t\t<div className=\"relative\">\n\t\t\t\t\t\t\t<select\n\t\t\t\t\t\t\t\tvalue={filter.dueTime}\n\t\t\t\t\t\t\t\tonChange={(e) =>\n\t\t\t\t\t\t\t\t\thandleDueTimeChange(e.target.value as DueTimeFilter)\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tclassName=\"w-full h-8 appearance-none rounded-md border border-border bg-background px-2.5 pr-8 text-xs text-foreground focus:border-ring focus:outline-none focus:ring-2 focus:ring-ring\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{allTimeOptions.map((option) => (\n\t\t\t\t\t\t\t\t\t<option key={option.value} value={option.value}>\n\t\t\t\t\t\t\t\t\t\t{option.label}\n\t\t\t\t\t\t\t\t\t</option>\n\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t</select>\n\t\t\t\t\t\t\t<ChevronDown className=\"pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground\" />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t{/* Status Quick Filters */}\n\t\t\t\t\t<div className=\"space-y-2\">\n\t\t\t\t\t\t<div className=\"text-xs font-semibold text-foreground uppercase tracking-wide\">\n\t\t\t\t\t\t\t{tTodoList(\"filterStatus\")}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"flex flex-wrap gap-2\">\n\t\t\t\t\t\t\t{commonStatusOptions.map((option) => (\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tkey={option.value}\n\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\tonClick={() => handleStatusChange(option.value)}\n\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\"px-3 py-1.5 rounded-md text-xs font-medium transition-colors\",\n\t\t\t\t\t\t\t\t\t\tfilter.status === option.value\n\t\t\t\t\t\t\t\t\t\t\t? \"bg-primary text-primary-foreground shadow-sm\"\n\t\t\t\t\t\t\t\t\t\t\t: \"bg-muted/50 text-foreground hover:bg-muted hover:text-foreground\",\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{option.label}\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t{/* Dropdown for all status options */}\n\t\t\t\t\t\t<div className=\"relative\">\n\t\t\t\t\t\t\t<select\n\t\t\t\t\t\t\t\tvalue={filter.status}\n\t\t\t\t\t\t\t\tonChange={(e) =>\n\t\t\t\t\t\t\t\t\thandleStatusChange(e.target.value as TodoStatus | \"all\")\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tclassName=\"w-full h-8 appearance-none rounded-md border border-border bg-background px-2.5 pr-8 text-xs text-foreground focus:border-ring focus:outline-none focus:ring-2 focus:ring-ring\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{statusOptions.map((option) => (\n\t\t\t\t\t\t\t\t\t<option key={option.value} value={option.value}>\n\t\t\t\t\t\t\t\t\t\t{option.label}\n\t\t\t\t\t\t\t\t\t</option>\n\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t</select>\n\t\t\t\t\t\t\t<ChevronDown className=\"pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground\" />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t{/* Tag Filter */}\n\t\t\t\t\t{allTags.length > 0 && (\n\t\t\t\t\t\t<div className=\"space-y-2\">\n\t\t\t\t\t\t\t<div className=\"text-xs font-semibold text-foreground uppercase tracking-wide\">\n\t\t\t\t\t\t\t\t{tTodoList(\"filterTag\")}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div className=\"relative\">\n\t\t\t\t\t\t\t\t<select\n\t\t\t\t\t\t\t\t\tvalue={filter.tag}\n\t\t\t\t\t\t\t\t\tonChange={(e) => handleTagChange(e.target.value)}\n\t\t\t\t\t\t\t\t\tclassName=\"w-full h-8 appearance-none rounded-md border border-border bg-background px-2.5 pr-8 text-xs text-foreground focus:border-ring focus:outline-none focus:ring-2 focus:ring-ring\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<option value=\"all\">{tTodoList(\"filterAll\")}</option>\n\t\t\t\t\t\t\t\t\t{allTags.map((tag) => (\n\t\t\t\t\t\t\t\t\t\t<option key={tag} value={tag}>\n\t\t\t\t\t\t\t\t\t\t\t{tag}\n\t\t\t\t\t\t\t\t\t\t</option>\n\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t</select>\n\t\t\t\t\t\t\t\t<ChevronDown className=\"pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground\" />\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\n\t\t\t\t\t{/* Clear Filters Button */}\n\t\t\t\t\t{isFilterActive && (\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tonClick={handleClearFilters}\n\t\t\t\t\t\t\tclassName=\"w-full flex items-center justify-center gap-1.5 h-8 rounded-md border border-border bg-background text-xs font-medium text-muted-foreground hover:bg-muted/50 hover:text-foreground transition-colors\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<X className=\"h-3.5 w-3.5\" />\n\t\t\t\t\t\t\t{tTodoList(\"clearFilters\")}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/todo-list/hooks/useOrderedTodos.ts",
    "content": "import { useMemo } from \"react\";\nimport type { Todo } from \"@/lib/types\";\nimport { sortTodosByOrder, sortTodosByOriginalOrder } from \"@/lib/utils\";\nimport type { DueTimeFilter, TodoFilterState } from \"../components/TodoFilter\";\n\nexport type OrderedTodo = {\n\ttodo: Todo;\n\tdepth: number;\n};\n\nfunction isDueTimeMatch(todo: Todo, dueTimeFilter: DueTimeFilter): boolean {\n\tconst scheduleTime = todo.startTime ?? todo.endTime;\n\tif (!scheduleTime) {\n\t\t// If no schedule time, only match \"all\" or \"future\"\n\t\treturn dueTimeFilter === \"all\" || dueTimeFilter === \"future\";\n\t}\n\n\tconst deadline = new Date(scheduleTime);\n\tconst now = new Date();\n\n\t// Normalize to date only (remove time)\n\tconst deadlineDate = new Date(\n\t\tdeadline.getFullYear(),\n\t\tdeadline.getMonth(),\n\t\tdeadline.getDate(),\n\t);\n\tconst today = new Date(now.getFullYear(), now.getMonth(), now.getDate());\n\tconst tomorrow = new Date(today);\n\ttomorrow.setDate(tomorrow.getDate() + 1);\n\n\t// Calculate start of week (Monday)\n\tconst startOfWeek = new Date(today);\n\tconst dayOfWeek = startOfWeek.getDay();\n\tconst diff = startOfWeek.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1);\n\tstartOfWeek.setDate(diff);\n\tstartOfWeek.setHours(0, 0, 0, 0);\n\n\t// Calculate end of week (Sunday end of day)\n\tconst endOfWeek = new Date(startOfWeek);\n\tendOfWeek.setDate(endOfWeek.getDate() + 6);\n\tendOfWeek.setHours(23, 59, 59, 999);\n\n\t// Calculate start and end of month\n\tconst startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);\n\tconst endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0);\n\tendOfMonth.setHours(23, 59, 59, 999);\n\n\tswitch (dueTimeFilter) {\n\t\tcase \"all\":\n\t\t\treturn true;\n\t\tcase \"overdue\":\n\t\t\treturn deadlineDate < today;\n\t\tcase \"today\":\n\t\t\treturn deadlineDate.getTime() === today.getTime();\n\t\tcase \"tomorrow\":\n\t\t\treturn deadlineDate.getTime() === tomorrow.getTime();\n\t\tcase \"thisWeek\":\n\t\t\treturn deadlineDate >= startOfWeek && deadlineDate <= endOfWeek;\n\t\tcase \"thisMonth\":\n\t\t\treturn deadlineDate >= startOfMonth && deadlineDate <= endOfMonth;\n\t\tcase \"future\":\n\t\t\treturn deadlineDate > today;\n\t\tdefault:\n\t\t\treturn true;\n\t}\n}\n\nexport function useOrderedTodos(\n\ttodos: Todo[],\n\tsearchQuery: string,\n\tcollapsedTodoIds?: Set<number>,\n\tfilter?: TodoFilterState,\n) {\n\treturn useMemo(() => {\n\t\tlet result = todos;\n\n\t\t// Apply filters\n\t\tif (filter) {\n\t\t\t// Status filter\n\t\t\tif (filter.status !== \"all\") {\n\t\t\t\tresult = result.filter((todo) => todo.status === filter.status);\n\t\t\t}\n\n\t\t\t// Tag filter\n\t\t\tif (filter.tag !== \"all\") {\n\t\t\t\tresult = result.filter((todo) => todo.tags?.includes(filter.tag));\n\t\t\t}\n\n\t\t\t// Due time filter\n\t\t\tif (filter.dueTime !== \"all\") {\n\t\t\t\tresult = result.filter((todo) => isDueTimeMatch(todo, filter.dueTime));\n\t\t\t}\n\t\t}\n\n\t\t// Apply search query\n\t\tif (searchQuery.trim()) {\n\t\t\tconst query = searchQuery.toLowerCase();\n\t\t\tresult = result.filter(\n\t\t\t\t(todo) =>\n\t\t\t\t\ttodo.name.toLowerCase().includes(query) ||\n\t\t\t\t\ttodo.description?.toLowerCase().includes(query) ||\n\t\t\t\t\ttodo.tags?.some((tag) => tag.toLowerCase().includes(query)),\n\t\t\t);\n\t\t}\n\n\t\tconst orderMap = new Map(result.map((todo, index) => [todo.id, index]));\n\t\tconst visibleIds = new Set(result.map((todo) => todo.id));\n\t\tconst childrenMap = new Map<number, Todo[]>();\n\t\tconst roots: Todo[] = [];\n\n\t\tresult.forEach((todo) => {\n\t\t\tconst parentId = todo.parentTodoId;\n\t\t\tif (parentId && visibleIds.has(parentId)) {\n\t\t\t\tconst list = childrenMap.get(parentId) ?? [];\n\t\t\t\tlist.push(todo);\n\t\t\t\tchildrenMap.set(parentId, list);\n\t\t\t} else {\n\t\t\t\troots.push(todo);\n\t\t\t}\n\t\t});\n\n\t\tconst ordered: OrderedTodo[] = [];\n\t\tconst completedOrdered: OrderedTodo[] = [];\n\t\tconst shouldSplitCompleted = !filter || filter.status === \"all\";\n\t\tconst traverse = (\n\t\t\titems: Todo[],\n\t\t\tdepth: number,\n\t\t\tisRoot: boolean = false,\n\t\t\ttarget: OrderedTodo[] = ordered,\n\t\t) => {\n\t\t\t// 根任务按原始顺序排序（支持用户拖拽），子任务按order字段排序\n\t\t\tconst sortedItems = isRoot\n\t\t\t\t? sortTodosByOriginalOrder(items, orderMap)\n\t\t\t\t: sortTodosByOrder(items);\n\t\t\tsortedItems.forEach((item) => {\n\t\t\t\ttarget.push({ todo: item, depth });\n\t\t\t\tconst children = childrenMap.get(item.id);\n\t\t\t\t// 如果有子任务且父任务已展开（collapsedTodoIds 为空或未定义时默认展开，否则检查是否不在 Set 中）\n\t\t\t\tif (children?.length) {\n\t\t\t\t\tconst isExpanded =\n\t\t\t\t\t\tcollapsedTodoIds === undefined || !collapsedTodoIds.has(item.id);\n\t\t\t\t\tif (isExpanded) {\n\t\t\t\t\t\t// 子任务优先按order字段排序，其次按创建时间排序\n\t\t\t\t\t\ttraverse(children, depth + 1, false, target);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t});\n\t\t};\n\n\t\tif (shouldSplitCompleted) {\n\t\t\tconst activeRoots = roots.filter((todo) => todo.status !== \"completed\");\n\t\t\tconst completedRoots = roots.filter((todo) => todo.status === \"completed\");\n\t\t\ttraverse(activeRoots, 0, true, ordered);\n\t\t\ttraverse(completedRoots, 0, true, completedOrdered);\n\t\t} else {\n\t\t\ttraverse(roots, 0, true, ordered);\n\t\t}\n\n\t\treturn {\n\t\t\tfilteredTodos: result,\n\t\t\torderedTodos: ordered,\n\t\t\tcompletedOrderedTodos: completedOrdered,\n\t\t\tcompletedRootCount: shouldSplitCompleted\n\t\t\t\t? roots.filter((todo) => todo.status === \"completed\").length\n\t\t\t\t: 0,\n\t\t};\n\t}, [todos, searchQuery, collapsedTodoIds, filter]);\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/todo-list/hooks/useTodoCardDrag.ts",
    "content": "import { useDroppable } from \"@dnd-kit/core\";\nimport { useSortable } from \"@dnd-kit/sortable\";\nimport { useMemo } from \"react\";\nimport type { DragData } from \"@/lib/dnd\";\nimport { useGlobalDndSafe } from \"@/lib/dnd\";\nimport { useTodos } from \"@/lib/query\";\nimport type { Todo } from \"@/lib/types\";\n\ninterface UseTodoCardDragParams {\n\ttodo: Todo;\n\tdepth: number;\n\tisOverlay: boolean;\n}\n\nexport function useTodoCardDrag({\n\ttodo,\n\tdepth,\n\tisOverlay,\n}: UseTodoCardDragParams) {\n\tconst { data: todos = [] } = useTodos();\n\n\t// 构建类型化的拖拽数据\n\tconst dragData: DragData = useMemo(\n\t\t() => ({\n\t\t\ttype: \"TODO_CARD\" as const,\n\t\t\tpayload: {\n\t\t\t\ttodo,\n\t\t\t\tdepth,\n\t\t\t\tsourcePanel: \"todoList\",\n\t\t\t},\n\t\t}),\n\t\t[todo, depth],\n\t);\n\n\tconst sortable = useSortable({\n\t\tid: todo.id,\n\t\tdisabled: isOverlay,\n\t\tdata: dragData,\n\t});\n\n\tconst attributes = isOverlay ? {} : sortable.attributes;\n\tconst listeners = isOverlay ? {} : sortable.listeners;\n\tconst setNodeRef = sortable.setNodeRef;\n\tconst transform = sortable.transform;\n\tconst transition = sortable.transition;\n\tconst isSortableDragging = sortable.isDragging;\n\n\t// 放置区域：用于将其他 todo 设为此 todo 的子任务\n\tconst nestDroppable = useDroppable({\n\t\tid: `${todo.id}-nest`,\n\t\tdisabled: isOverlay,\n\t\tdata: {\n\t\t\ttype: \"TODO_DROP_ZONE\",\n\t\t\tmetadata: {\n\t\t\t\ttodoId: todo.id,\n\t\t\t\tposition: \"nest\",\n\t\t\t},\n\t\t},\n\t});\n\n\t// 获取全局拖拽状态\n\tconst dndContext = useGlobalDndSafe();\n\tconst isOtherDragging =\n\t\tdndContext?.activeDrag !== null &&\n\t\tdndContext?.activeDrag?.id !== todo.id &&\n\t\tdndContext?.activeDrag?.data?.type === \"TODO_CARD\";\n\n\t// 检查当前拖拽的 todo 是否是此 todo 的子孙（防止循环引用）\n\tconst isDescendantDragging = useMemo(() => {\n\t\tif (!dndContext?.activeDrag?.data) return false;\n\t\tconst draggedData = dndContext.activeDrag.data;\n\t\tif (draggedData.type !== \"TODO_CARD\") return false;\n\t\tconst draggedTodo = draggedData.payload.todo;\n\n\t\t// 检查当前 todo 是否是被拖拽 todo 的子孙\n\t\tconst checkIsDescendant = (\n\t\t\tpotentialParentId: number,\n\t\t\tpotentialChildId: number,\n\t\t): boolean => {\n\t\t\tlet current = todos.find((t: Todo) => t.id === potentialChildId);\n\t\t\twhile (current?.parentTodoId) {\n\t\t\t\tif (current.parentTodoId === potentialParentId) return true;\n\t\t\t\tcurrent = todos.find((t: Todo) => t.id === current?.parentTodoId);\n\t\t\t}\n\t\t\treturn false;\n\t\t};\n\n\t\treturn checkIsDescendant(draggedTodo.id, todo.id);\n\t}, [dndContext?.activeDrag, todos, todo.id]);\n\n\t// 是否显示放置区域\n\tconst showNestDropZone =\n\t\tisOtherDragging && !isDescendantDragging && !isSortableDragging;\n\n\treturn {\n\t\tattributes,\n\t\tlisteners,\n\t\tsetNodeRef,\n\t\ttransform,\n\t\ttransition,\n\t\tisSortableDragging,\n\t\tnestDroppable,\n\t\tshowNestDropZone,\n\t};\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/todo-list/hooks/useTodoCardHandlers.ts",
    "content": "import { useTranslations } from \"next-intl\";\nimport type React from \"react\";\nimport { useTodoMutations } from \"@/lib/query\";\nimport { useBreakdownStore } from \"@/lib/store/breakdown-store\";\nimport { useChatStore } from \"@/lib/store/chat-store\";\nimport { useTodoStore } from \"@/lib/store/todo-store\";\nimport { useUiStore } from \"@/lib/store/ui-store\";\nimport type { Todo } from \"@/lib/types\";\n\ninterface UseTodoCardHandlersParams {\n\ttodo: Todo;\n\tsetIsAddingChild: (value: boolean) => void;\n\tchildName: string;\n\tsetChildName: (value: string) => void;\n\tsetIsEditingName: (value: boolean) => void;\n\teditingName: string;\n\tsetEditingName: (value: string) => void;\n}\n\nexport function useTodoCardHandlers({\n\ttodo,\n\tsetIsAddingChild,\n\tchildName,\n\tsetChildName,\n\tsetIsEditingName,\n\teditingName,\n\tsetEditingName,\n}: UseTodoCardHandlersParams) {\n\tconst tChat = useTranslations(\"chat\");\n\tconst { createTodo, updateTodo, toggleTodoStatus } = useTodoMutations();\n\tconst { startBreakdown } = useBreakdownStore();\n\tconst { setPendingPrompt } = useChatStore();\n\tconst { setSelectedTodoIds } = useTodoStore();\n\tconst { setPanelFeature, getFeatureByPosition } = useUiStore();\n\n\tconst handleCreateChild = async (e?: React.FormEvent) => {\n\t\tif (e) e.preventDefault();\n\t\tconst name = childName.trim();\n\t\tif (!name) return;\n\n\t\ttry {\n\t\t\tawait createTodo({ name, parentTodoId: todo.id });\n\t\t\tsetChildName(\"\");\n\t\t\tsetIsAddingChild(false);\n\t\t} catch (err) {\n\t\t\tconsole.error(\"Failed to create child todo:\", err);\n\t\t}\n\t};\n\n\t// 打开聊天面板的通用逻辑\n\tconst ensureChatPanelOpen = () => {\n\t\tconst chatPosition = getFeatureByPosition(\"panelA\");\n\t\tif (chatPosition !== \"chat\") {\n\t\t\t// 找到聊天功能所在的位置，或分配到第一个可用位置\n\t\t\tconst positions: Array<\"panelA\" | \"panelB\" | \"panelC\"> = [\n\t\t\t\t\"panelA\",\n\t\t\t\t\"panelB\",\n\t\t\t\t\"panelC\",\n\t\t\t];\n\t\t\tfor (const pos of positions) {\n\t\t\t\tif (getFeatureByPosition(pos) === \"chat\") {\n\t\t\t\t\t// 如果聊天功能已经在某个位置，确保该位置打开\n\t\t\t\t\tif (pos === \"panelA\" && !useUiStore.getState().isPanelAOpen) {\n\t\t\t\t\t\tuseUiStore.getState().togglePanelA();\n\t\t\t\t\t} else if (pos === \"panelB\" && !useUiStore.getState().isPanelBOpen) {\n\t\t\t\t\t\tuseUiStore.getState().togglePanelB();\n\t\t\t\t\t} else if (pos === \"panelC\" && !useUiStore.getState().isPanelCOpen) {\n\t\t\t\t\t\tuseUiStore.getState().togglePanelC();\n\t\t\t\t\t}\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\t\t\t// 如果聊天功能不在任何位置，分配到panelB\n\t\t\tif (!positions.some((pos) => getFeatureByPosition(pos) === \"chat\")) {\n\t\t\t\tsetPanelFeature(\"panelB\", \"chat\");\n\t\t\t\tif (!useUiStore.getState().isPanelBOpen) {\n\t\t\t\t\tuseUiStore.getState().togglePanelB();\n\t\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\t// 如果聊天功能在panelA，确保panelA打开\n\t\t\tif (!useUiStore.getState().isPanelAOpen) {\n\t\t\t\tuseUiStore.getState().togglePanelA();\n\t\t\t}\n\t\t}\n\t};\n\n\tconst handleStartBreakdown = () => {\n\t\tensureChatPanelOpen();\n\t\t// 开始Breakdown流程\n\t\tstartBreakdown(todo.id);\n\t};\n\n\t// 获取建议：选中当前 todo，打开聊天面板，新开会话并发送建议 prompt\n\tconst handleGetAdvice = () => {\n\t\t// 选中当前 todo（让 ChatPanel 可以基于此 todo 的上下文）\n\t\tsetSelectedTodoIds([todo.id]);\n\t\t// 打开聊天面板\n\t\tensureChatPanelOpen();\n\t\t// 设置待发送的 prompt，并标记需要新开会话\n\t\tsetPendingPrompt(tChat(\"suggestions.advicePrompt\"), true);\n\t};\n\n\tconst handleToggleStatus = async (e: React.MouseEvent) => {\n\t\te.stopPropagation();\n\t\ttry {\n\t\t\tif (todo.status === \"canceled\") {\n\t\t\t\t// 如果是 canceled 状态，点击复选框回到 active 状态\n\t\t\t\tawait updateTodo(todo.id, { status: \"active\" });\n\t\t\t} else {\n\t\t\t\t// 其他状态使用通用的切换逻辑\n\t\t\t\tawait toggleTodoStatus(todo.id);\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tconsole.error(\"Failed to toggle todo status:\", err);\n\t\t}\n\t};\n\n\tconst handleAddChildFromMenu = () => {\n\t\tsetIsAddingChild(true);\n\t};\n\n\tconst handleStartEditName = (e: React.MouseEvent) => {\n\t\te.stopPropagation();\n\t\tsetEditingName(todo.name);\n\t\tsetIsEditingName(true);\n\t};\n\n\tconst handleSaveName = async () => {\n\t\tconst trimmedName = editingName.trim();\n\t\tif (!trimmedName) {\n\t\t\t// 如果名称为空，恢复原值\n\t\t\tsetEditingName(todo.name);\n\t\t\tsetIsEditingName(false);\n\t\t\treturn;\n\t\t}\n\n\t\tif (trimmedName === todo.name) {\n\t\t\t// 如果没有变化，直接退出编辑模式\n\t\t\tsetIsEditingName(false);\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\tawait updateTodo(todo.id, { name: trimmedName });\n\t\t\tsetIsEditingName(false);\n\t\t} catch (err) {\n\t\t\tconsole.error(\"Failed to update todo name:\", err);\n\t\t\t// 保存失败时恢复原值\n\t\t\tsetEditingName(todo.name);\n\t\t\tsetIsEditingName(false);\n\t\t}\n\t};\n\n\tconst handleCancelEditName = () => {\n\t\tsetEditingName(todo.name);\n\t\tsetIsEditingName(false);\n\t};\n\n\treturn {\n\t\thandleCreateChild,\n\t\thandleStartBreakdown,\n\t\thandleGetAdvice,\n\t\thandleToggleStatus,\n\t\thandleAddChildFromMenu,\n\t\thandleStartEditName,\n\t\thandleSaveName,\n\t\thandleCancelEditName,\n\t};\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/todo-list/hooks/useTodoCardState.ts",
    "content": "import { useEffect, useRef, useState } from \"react\";\nimport type { Todo } from \"@/lib/types\";\n\nexport function useTodoCardState(todo: Todo) {\n\tconst [isAddingChild, setIsAddingChild] = useState(false);\n\tconst [childName, setChildName] = useState(\"\");\n\tconst childInputRef = useRef<HTMLInputElement | null>(null);\n\n\t// 就地编辑 todo name 的状态\n\tconst [isEditingName, setIsEditingName] = useState(false);\n\tconst [editingName, setEditingName] = useState(\"\");\n\tconst nameInputRef = useRef<HTMLInputElement | null>(null);\n\n\t// 当开始添加子任务时，聚焦输入框\n\tuseEffect(() => {\n\t\tif (isAddingChild) {\n\t\t\tchildInputRef.current?.focus();\n\t\t}\n\t}, [isAddingChild]);\n\n\t// 当开始编辑名称时，聚焦并选中输入框\n\tuseEffect(() => {\n\t\tif (isEditingName) {\n\t\t\tnameInputRef.current?.focus();\n\t\t\tnameInputRef.current?.select();\n\t\t}\n\t}, [isEditingName]);\n\n\t// 当 todo.name 变化时，如果不在编辑模式，同步 editingName\n\tuseEffect(() => {\n\t\tif (!isEditingName) {\n\t\t\tsetEditingName(todo.name);\n\t\t}\n\t}, [todo.name, isEditingName]);\n\n\treturn {\n\t\tisAddingChild,\n\t\tsetIsAddingChild,\n\t\tchildName,\n\t\tsetChildName,\n\t\tchildInputRef,\n\t\tisEditingName,\n\t\tsetIsEditingName,\n\t\teditingName,\n\t\tsetEditingName,\n\t\tnameInputRef,\n\t};\n}\n"
  },
  {
    "path": "free-todo-frontend/apps/todo-list/index.ts",
    "content": "export { TodoList } from \"./TodoList\";\n"
  },
  {
    "path": "free-todo-frontend/apps/todo-list/utils/todoCardUtils.ts",
    "content": "import type { TodoPriority } from \"@/lib/types\";\n\n/**\n * 格式化日期字符串\n */\nexport function formatScheduleLabel(\n\tstartTime?: string,\n\tendTime?: string,\n): string | null {\n\tconst schedule = startTime ?? endTime;\n\tif (!schedule) return null;\n\tconst startDate = new Date(schedule);\n\tif (Number.isNaN(startDate.getTime())) return null;\n\n\tconst dateLabel = startDate.toLocaleDateString(\"en-US\", {\n\t\tyear: \"numeric\",\n\t\tmonth: \"short\",\n\t\tday: \"numeric\",\n\t});\n\tconst timeLabel = startDate.toLocaleTimeString(\"en-US\", {\n\t\thour: \"2-digit\",\n\t\tminute: \"2-digit\",\n\t});\n\tconst startLabel =\n\t\tstartDate.getHours() === 0 && startDate.getMinutes() === 0\n\t\t\t? dateLabel\n\t\t\t: `${dateLabel} ${timeLabel}`;\n\n\tif (!endTime) return startLabel;\n\tconst endDate = new Date(endTime);\n\tif (Number.isNaN(endDate.getTime())) return startLabel;\n\tconst sameDay = startDate.toDateString() === endDate.toDateString();\n\tconst endDateLabel = endDate.toLocaleDateString(\"en-US\", {\n\t\tyear: \"numeric\",\n\t\tmonth: \"short\",\n\t\tday: \"numeric\",\n\t});\n\tconst endTimeLabel = endDate.toLocaleTimeString(\"en-US\", {\n\t\thour: \"2-digit\",\n\t\tminute: \"2-digit\",\n\t});\n\tconst endLabel = sameDay ? endTimeLabel : `${endDateLabel} ${endTimeLabel}`;\n\n\treturn `${startLabel} - ${endLabel}`;\n}\n\n/**\n * 根据优先级获取边框颜色类名\n */\nexport function getPriorityBorderColor(priority: TodoPriority): string {\n\tswitch (priority) {\n\t\tcase \"high\":\n\t\t\treturn \"border-destructive/60\";\n\t\tcase \"medium\":\n\t\t\treturn \"border-primary/60\";\n\t\tcase \"low\":\n\t\t\treturn \"border-secondary/60\";\n\t\tdefault:\n\t\t\treturn \"border-muted-foreground/40\";\n\t}\n}\n"
  },
  {
    "path": "free-todo-frontend/components/common/ReminderOptions.tsx",
    "content": "\"use client\";\n\nimport { useTranslations } from \"next-intl\";\nimport { useId, useMemo, useState } from \"react\";\nimport {\n\tformatReminderOffset,\n\tREMINDER_PRESET_MINUTES,\n\ttype ReminderUnit,\n\tsanitizeReminderOffsets,\n} from \"@/lib/reminders\";\nimport { cn } from \"@/lib/utils\";\n\ninterface ReminderOptionsProps {\n\tvalue: number[];\n\tonChange: (value: number[]) => void;\n\tcompact?: boolean;\n\tshowClear?: boolean;\n}\n\nexport function ReminderOptions({\n\tvalue,\n\tonChange,\n\tcompact = false,\n\tshowClear = true,\n}: ReminderOptionsProps) {\n\tconst t = useTranslations(\"reminder\");\n\tconst customInputId = useId();\n\tconst [customValue, setCustomValue] = useState(\"\");\n\tconst [customUnit, setCustomUnit] = useState<ReminderUnit>(\"minutes\");\n\n\tconst selected = useMemo(() => new Set(value), [value]);\n\n\tconst toggleOffset = (minutes: number) => {\n\t\tconst next = selected.has(minutes)\n\t\t\t? value.filter((item) => item !== minutes)\n\t\t\t: [...value, minutes];\n\t\tonChange(sanitizeReminderOffsets(next));\n\t};\n\n\tconst handleAddCustom = () => {\n\t\tconst amount = Number.parseInt(customValue, 10);\n\t\tif (!Number.isFinite(amount) || amount <= 0) {\n\t\t\treturn;\n\t\t}\n\t\tconst multiplier =\n\t\t\tcustomUnit === \"days\" ? 1440 : customUnit === \"hours\" ? 60 : 1;\n\t\tconst minutes = amount * multiplier;\n\t\tonChange(sanitizeReminderOffsets([...value, minutes]));\n\t\tsetCustomValue(\"\");\n\t};\n\n\tconst sizeClasses = compact\n\t\t? \"px-2 py-1 text-xs\"\n\t\t: \"px-2.5 py-1.5 text-xs\";\n\n\treturn (\n\t\t<div className=\"flex flex-col gap-2\">\n\t\t\t<div className={cn(\"grid gap-2\", compact ? \"grid-cols-3\" : \"grid-cols-2\")}>\n\t\t\t\t{REMINDER_PRESET_MINUTES.map((minutes) => (\n\t\t\t\t\t<button\n\t\t\t\t\t\tkey={minutes}\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tonClick={() => toggleOffset(minutes)}\n\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\"rounded-md border text-left transition-colors\",\n\t\t\t\t\t\t\tsizeClasses,\n\t\t\t\t\t\t\tselected.has(minutes)\n\t\t\t\t\t\t\t\t? \"border-primary/60 bg-primary/10 text-primary\"\n\t\t\t\t\t\t\t\t: \"border-border/60 text-muted-foreground hover:bg-muted/60\",\n\t\t\t\t\t\t)}\n\t\t\t\t\t>\n\t\t\t\t\t\t{formatReminderOffset(t, minutes)}\n\t\t\t\t\t</button>\n\t\t\t\t))}\n\t\t\t</div>\n\t\t\t<div className=\"flex flex-wrap items-center gap-2\">\n\t\t\t\t<label\n\t\t\t\t\tclassName=\"text-xs text-muted-foreground\"\n\t\t\t\t\thtmlFor={customInputId}\n\t\t\t\t>\n\t\t\t\t\t{t(\"custom\")}\n\t\t\t\t</label>\n\t\t\t\t<input\n\t\t\t\t\tid={customInputId}\n\t\t\t\t\ttype=\"number\"\n\t\t\t\t\tmin={1}\n\t\t\t\t\tvalue={customValue}\n\t\t\t\t\tonChange={(event) => setCustomValue(event.target.value)}\n\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\"w-20 rounded-md border border-border bg-background px-2 py-1 text-xs\",\n\t\t\t\t\t\t\"focus:outline-none focus:ring-2 focus:ring-primary/30\",\n\t\t\t\t\t)}\n\t\t\t\t/>\n\t\t\t\t<select\n\t\t\t\t\tvalue={customUnit}\n\t\t\t\t\tonChange={(event) =>\n\t\t\t\t\t\tsetCustomUnit(event.target.value as ReminderUnit)\n\t\t\t\t\t}\n\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\"rounded-md border border-border bg-background px-2 py-1 text-xs\",\n\t\t\t\t\t\t\"focus:outline-none focus:ring-2 focus:ring-primary/30\",\n\t\t\t\t\t)}\n\t\t\t\t>\n\t\t\t\t\t<option value=\"minutes\">{t(\"unit.minutes\")}</option>\n\t\t\t\t\t<option value=\"hours\">{t(\"unit.hours\")}</option>\n\t\t\t\t\t<option value=\"days\">{t(\"unit.days\")}</option>\n\t\t\t\t</select>\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tonClick={handleAddCustom}\n\t\t\t\t\tclassName=\"rounded-md border border-primary/40 px-2 py-1 text-xs text-primary transition-colors hover:bg-primary/10\"\n\t\t\t\t>\n\t\t\t\t\t{t(\"add\")}\n\t\t\t\t</button>\n\t\t\t\t{showClear && (\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tonClick={() => onChange([])}\n\t\t\t\t\t\tclassName=\"rounded-md border border-destructive/40 px-2 py-1 text-xs text-destructive transition-colors hover:bg-destructive/10\"\n\t\t\t\t\t>\n\t\t\t\t\t\t{t(\"clear\")}\n\t\t\t\t\t</button>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/components/common/context-menu/BaseContextMenu.tsx",
    "content": "\"use client\";\n\nimport type React from \"react\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface MenuItem {\n\ticon?: React.ComponentType<{ className?: string }>;\n\tlabel: string;\n\tonClick: () => void;\n\t/** 是否为第一个菜单项（用于添加 first:rounded-t-md） */\n\tisFirst?: boolean;\n\t/** 是否为最后一个菜单项（用于添加 last:rounded-b-md） */\n\tisLast?: boolean;\n}\n\ninterface BaseContextMenuProps {\n\t/** 菜单项列表 */\n\titems: MenuItem[];\n\t/** 菜单是否打开 */\n\topen: boolean;\n\t/** 菜单位置 */\n\tposition: { x: number; y: number };\n\t/** 关闭菜单的回调 */\n\tonClose: () => void;\n\t/** 可选的头部内容（如选中数量显示） */\n\theader?: React.ReactNode;\n\t/** 菜单的最小宽度，默认 170px */\n\tminWidth?: number;\n}\n\n/**\n * 基础上下文菜单组件，提供通用的菜单功能：\n * - 点击外部关闭\n * - ESC 键关闭\n * - 滚动时关闭\n * - 统一的样式\n */\nexport function BaseContextMenu({\n\titems,\n\topen,\n\tposition,\n\tonClose,\n\theader,\n\tminWidth = 170,\n}: BaseContextMenuProps) {\n\tconst menuRef = useRef<HTMLDivElement | null>(null);\n\n\t// 点击外部、滚动或按下 ESC 时关闭\n\tuseEffect(() => {\n\t\tif (!open) return;\n\n\t\tconst handleClickOutside = (event: MouseEvent) => {\n\t\t\tconst target = event.target as Node;\n\t\t\tif (menuRef.current?.contains(target)) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tonClose();\n\t\t};\n\n\t\tconst handleEscape = (event: KeyboardEvent) => {\n\t\t\tif (event.key === \"Escape\") {\n\t\t\t\tonClose();\n\t\t\t}\n\t\t};\n\n\t\tdocument.addEventListener(\"mousedown\", handleClickOutside);\n\t\tdocument.addEventListener(\"keydown\", handleEscape);\n\t\tdocument.addEventListener(\"scroll\", onClose, true);\n\n\t\treturn () => {\n\t\t\tdocument.removeEventListener(\"mousedown\", handleClickOutside);\n\t\t\tdocument.removeEventListener(\"keydown\", handleEscape);\n\t\t\tdocument.removeEventListener(\"scroll\", onClose, true);\n\t\t};\n\t}, [open, onClose]);\n\n\tif (!open || typeof document === \"undefined\") {\n\t\treturn null;\n\t}\n\n\treturn createPortal(\n\t\t<div className=\"fixed inset-0 z-120 pointer-events-none\">\n\t\t\t<div\n\t\t\t\tref={menuRef}\n\t\t\t\tclassName=\"pointer-events-auto rounded-md border border-border bg-background shadow-lg\"\n\t\t\t\tstyle={{\n\t\t\t\t\ttop: position.y,\n\t\t\t\t\tleft: position.x,\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tminWidth: `${minWidth}px`,\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t{header && (\n\t\t\t\t\t<div className=\"px-3 py-2 text-xs text-muted-foreground border-b border-border\">\n\t\t\t\t\t\t{header}\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t\t{items.map((item) => {\n\t\t\t\t\tconst Icon = item.icon;\n\t\t\t\t\treturn (\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tkey={item.label}\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\"flex w-full items-center gap-2 px-3 py-2 text-sm text-foreground hover:bg-muted/70 transition-colors\",\n\t\t\t\t\t\t\t\titem.isFirst && \"first:rounded-t-md\",\n\t\t\t\t\t\t\t\titem.isLast && \"last:rounded-b-md\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\titem.onClick();\n\t\t\t\t\t\t\t\tonClose();\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{Icon && <Icon className=\"h-4 w-4\" />}\n\t\t\t\t\t\t\t<span>{item.label}</span>\n\t\t\t\t\t\t</button>\n\t\t\t\t\t);\n\t\t\t\t})}\n\t\t\t</div>\n\t\t</div>,\n\t\tdocument.body,\n\t);\n}\n\n/**\n * Hook 用于管理上下文菜单的状态和位置计算\n */\nexport function useContextMenu() {\n\tconst [contextMenu, setContextMenu] = useState({\n\t\topen: false,\n\t\tx: 0,\n\t\ty: 0,\n\t});\n\n\tconst openContextMenu = (\n\t\tevent: React.MouseEvent,\n\t\toptions?: {\n\t\t\tmenuWidth?: number;\n\t\t\tmenuHeight?: number;\n\t\t\t/** 自定义位置计算函数 */\n\t\t\tcalculatePosition?: (event: React.MouseEvent) => { x: number; y: number };\n\t\t},\n\t) => {\n\t\tevent.preventDefault();\n\t\tevent.stopPropagation();\n\n\t\tconst menuWidth = options?.menuWidth ?? 180;\n\t\tconst menuHeight = options?.menuHeight ?? 160;\n\t\tconst viewportWidth =\n\t\t\ttypeof window !== \"undefined\" ? window.innerWidth : menuWidth;\n\t\tconst viewportHeight =\n\t\t\ttypeof window !== \"undefined\" ? window.innerHeight : menuHeight;\n\n\t\tlet x: number;\n\t\tlet y: number;\n\n\t\tif (options?.calculatePosition) {\n\t\t\tconst pos = options.calculatePosition(event);\n\t\t\tx = pos.x;\n\t\t\ty = pos.y;\n\t\t} else {\n\t\t\t// 默认位置计算：确保菜单不超出视口\n\t\t\tx = Math.min(Math.max(event.clientX, 8), viewportWidth - menuWidth);\n\t\t\ty = Math.min(Math.max(event.clientY, 8), viewportHeight - menuHeight);\n\t\t}\n\n\t\tsetContextMenu({\n\t\t\topen: true,\n\t\t\tx,\n\t\t\ty,\n\t\t});\n\t};\n\n\tconst closeContextMenu = () => {\n\t\tsetContextMenu((state) => (state.open ? { ...state, open: false } : state));\n\t};\n\n\treturn {\n\t\tcontextMenu,\n\t\topenContextMenu,\n\t\tcloseContextMenu,\n\t};\n}\n"
  },
  {
    "path": "free-todo-frontend/components/common/context-menu/MultiTodoContextMenu.tsx",
    "content": "\"use client\";\n\nimport { Trash2, X } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport React from \"react\";\nimport {\n\tBaseContextMenu,\n\ttype MenuItem,\n\tuseContextMenu,\n} from \"@/components/common/context-menu/BaseContextMenu\";\nimport { useTodoMutations, useTodos } from \"@/lib/query\";\nimport { useTodoStore } from \"@/lib/store/todo-store\";\nimport type { Todo } from \"@/lib/types\";\n\ninterface MultiTodoContextMenuProps {\n\tselectedTodoIds: number[];\n\tchildren: React.ReactElement;\n}\n\nexport function MultiTodoContextMenu({\n\tselectedTodoIds,\n\tchildren,\n}: MultiTodoContextMenuProps) {\n\tconst t = useTranslations(\"contextMenu\");\n\t// 从 TanStack Query 获取 mutation 操作和 todos 数据\n\tconst { data: todos = [] } = useTodos();\n\tconst { deleteTodo, updateTodo } = useTodoMutations();\n\n\t// 从 Zustand 获取 UI 状态操作\n\tconst { onTodoDeleted, clearTodoSelection } = useTodoStore();\n\n\t// 使用通用菜单 hook\n\tconst { contextMenu, openContextMenu, closeContextMenu } = useContextMenu();\n\n\tconst handleOpenContextMenu = (event: React.MouseEvent) => {\n\t\t// 只在有多个选中时才显示菜单\n\t\tif (selectedTodoIds.length <= 1) {\n\t\t\treturn;\n\t\t}\n\n\t\tevent.preventDefault();\n\t\tevent.stopPropagation();\n\n\t\topenContextMenu(event, {\n\t\t\tmenuWidth: 180,\n\t\t\tmenuHeight: 100,\n\t\t});\n\t};\n\n\tconst handleCancel = async () => {\n\t\ttry {\n\t\t\t// 批量取消所有选中的 todo（更新状态为 canceled）\n\t\t\tawait Promise.all(\n\t\t\t\tselectedTodoIds.map((id) =>\n\t\t\t\t\tupdateTodo(id, { status: \"canceled\" }).catch((err) => {\n\t\t\t\t\t\tconsole.error(`Failed to cancel todo ${id}:`, err);\n\t\t\t\t\t}),\n\t\t\t\t),\n\t\t\t);\n\t\t} catch (err) {\n\t\t\tconsole.error(\"Failed to cancel todos:\", err);\n\t\t}\n\t\tcloseContextMenu();\n\t\tclearTodoSelection();\n\t};\n\n\tconst handleDelete = async () => {\n\t\ttry {\n\t\t\t// 递归查找所有子任务 ID\n\t\t\tconst findAllChildIds = (\n\t\t\t\tparentId: number,\n\t\t\t\tallTodos: Todo[],\n\t\t\t): number[] => {\n\t\t\t\tconst childIds: number[] = [];\n\t\t\t\tconst children = allTodos.filter(\n\t\t\t\t\t(t: Todo) => t.parentTodoId === parentId,\n\t\t\t\t);\n\t\t\t\tfor (const child of children) {\n\t\t\t\t\tchildIds.push(child.id);\n\t\t\t\t\tchildIds.push(...findAllChildIds(child.id, allTodos));\n\t\t\t\t}\n\t\t\t\treturn childIds;\n\t\t\t};\n\n\t\t\tconst selectedSet = new Set(selectedTodoIds);\n\n\t\t\t// 找出\"根\"选中项：父任务不在选中列表中的 todo\n\t\t\t// 只需要删除这些根项，后端会级联删除子任务\n\t\t\tconst rootIdsToDelete: number[] = [];\n\t\t\tfor (const id of selectedTodoIds) {\n\t\t\t\tconst todo = todos.find((t) => t.id === id);\n\t\t\t\t// 如果没有父任务，或者父任务不在选中列表中，则为根项\n\t\t\t\tif (!todo?.parentTodoId || !selectedSet.has(todo.parentTodoId)) {\n\t\t\t\t\trootIdsToDelete.push(id);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// 收集所有要从 UI 中移除的 ID（包括子任务，用于清理状态）\n\t\t\tconst allIdsToRemove = new Set<number>();\n\t\t\tfor (const id of selectedTodoIds) {\n\t\t\t\tallIdsToRemove.add(id);\n\t\t\t\tconst childIds = findAllChildIds(id, todos);\n\t\t\t\tfor (const childId of childIds) {\n\t\t\t\t\tallIdsToRemove.add(childId);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// 只删除根项，后端会级联删除子任务\n\t\t\tawait Promise.all(\n\t\t\t\trootIdsToDelete.map((id) =>\n\t\t\t\t\tdeleteTodo(id).catch((err) => {\n\t\t\t\t\t\tconsole.error(`Failed to delete todo ${id}:`, err);\n\t\t\t\t\t}),\n\t\t\t\t),\n\t\t\t);\n\n\t\t\t// 清理 UI 状态\n\t\t\tonTodoDeleted(Array.from(allIdsToRemove));\n\t\t} catch (err) {\n\t\t\tconsole.error(\"Failed to delete todos:\", err);\n\t\t}\n\t\tcloseContextMenu();\n\t\tclearTodoSelection();\n\t};\n\n\t// 构建菜单项\n\tconst menuItems: MenuItem[] = [\n\t\t{\n\t\t\ticon: X,\n\t\t\tlabel: t(\"batchCancel\"),\n\t\t\tonClick: handleCancel,\n\t\t},\n\t\t{\n\t\t\ticon: Trash2,\n\t\t\tlabel: t(\"batchDelete\"),\n\t\t\tonClick: handleDelete,\n\t\t\tisLast: true,\n\t\t},\n\t];\n\n\t// 克隆子元素并添加 onContextMenu 处理器\n\tconst childWithContextMenu = React.cloneElement(children, {\n\t\tonContextMenu: handleOpenContextMenu,\n\t} as React.HTMLAttributes<HTMLElement>);\n\n\treturn (\n\t\t<>\n\t\t\t{childWithContextMenu}\n\n\t\t\t{contextMenu.open && selectedTodoIds.length > 1 && (\n\t\t\t\t<BaseContextMenu\n\t\t\t\t\titems={menuItems}\n\t\t\t\t\topen={contextMenu.open}\n\t\t\t\t\tposition={{ x: contextMenu.x, y: contextMenu.y }}\n\t\t\t\t\tonClose={closeContextMenu}\n\t\t\t\t\theader={t(\"selectedCount\", { count: selectedTodoIds.length })}\n\t\t\t\t/>\n\t\t\t)}\n\t\t</>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/components/common/context-menu/TodoContextMenu.tsx",
    "content": "\"use client\";\n\nimport { Plus, Sparkles, Trash2, X } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport type React from \"react\";\nimport { cloneElement, useEffect, useRef, useState } from \"react\";\nimport {\n\tBaseContextMenu,\n\ttype MenuItem,\n\tuseContextMenu,\n} from \"@/components/common/context-menu/BaseContextMenu\";\nimport { useTodoMutations, useTodos } from \"@/lib/query\";\nimport { useBreakdownStore } from \"@/lib/store/breakdown-store\";\nimport { useTodoStore } from \"@/lib/store/todo-store\";\nimport { useUiStore } from \"@/lib/store/ui-store\";\nimport type { Todo } from \"@/lib/types\";\n\ninterface TodoContextMenuProps {\n\ttodoId: number;\n\tchildren: React.ReactElement;\n\t/** 点击\"添加子待办\"时的回调，如果提供则不会在内部创建子待办 */\n\tonAddChild?: () => void;\n\t/** 右键菜单打开时的回调 */\n\tonContextMenuOpen?: () => void;\n}\n\nexport function TodoContextMenu({\n\ttodoId,\n\tchildren,\n\tonAddChild,\n\tonContextMenuOpen,\n}: TodoContextMenuProps) {\n\tconst t = useTranslations(\"contextMenu\");\n\t// 从 TanStack Query 获取 mutation 操作和 todos 数据\n\tconst { data: todos = [] } = useTodos();\n\tconst { createTodo, updateTodo, deleteTodo } = useTodoMutations();\n\n\t// 从 Zustand 获取 UI 状态操作\n\tconst { onTodoDeleted } = useTodoStore();\n\tconst { startBreakdown } = useBreakdownStore();\n\tconst { setPanelFeature, getFeatureByPosition } = useUiStore();\n\n\t// 使用通用菜单 hook\n\tconst { contextMenu, openContextMenu, closeContextMenu } = useContextMenu();\n\n\t// 内部添加子待办的状态（当没有提供 onAddChild 时使用）\n\tconst [isAddingChild, setIsAddingChild] = useState(false);\n\tconst [childName, setChildName] = useState(\"\");\n\tconst childInputRef = useRef<HTMLInputElement | null>(null);\n\n\tuseEffect(() => {\n\t\tif (isAddingChild) {\n\t\t\tchildInputRef.current?.focus();\n\t\t}\n\t}, [isAddingChild]);\n\n\tconst handleOpenContextMenu = (event: React.MouseEvent) => {\n\t\tevent.preventDefault();\n\t\tevent.stopPropagation();\n\t\tonContextMenuOpen?.();\n\t\topenContextMenu(event, {\n\t\t\tmenuWidth: 180,\n\t\t\tmenuHeight: 160,\n\t\t});\n\t};\n\n\tconst handleAddChildClick = () => {\n\t\tcloseContextMenu();\n\t\tif (onAddChild) {\n\t\t\tonAddChild();\n\t\t} else {\n\t\t\tsetIsAddingChild(true);\n\t\t\tsetChildName(\"\");\n\t\t}\n\t};\n\n\tconst handleCreateChild = async (e?: React.FormEvent) => {\n\t\tif (e) e.preventDefault();\n\t\tconst name = childName.trim();\n\t\tif (!name) return;\n\n\t\ttry {\n\t\t\tawait createTodo({ name, parentTodoId: todoId });\n\t\t\tsetChildName(\"\");\n\t\t\tsetIsAddingChild(false);\n\t\t} catch (err) {\n\t\t\tconsole.error(\"Failed to create child todo:\", err);\n\t\t}\n\t};\n\n\tconst handleStartBreakdown = () => {\n\t\t// 确保聊天Panel打开并切换到聊天功能\n\t\tconst chatPosition = getFeatureByPosition(\"panelA\");\n\t\tif (chatPosition !== \"chat\") {\n\t\t\t// 找到聊天功能所在的位置，或分配到第一个可用位置\n\t\t\tconst positions: Array<\"panelA\" | \"panelB\" | \"panelC\"> = [\n\t\t\t\t\"panelA\",\n\t\t\t\t\"panelB\",\n\t\t\t\t\"panelC\",\n\t\t\t];\n\t\t\tfor (const pos of positions) {\n\t\t\t\tif (getFeatureByPosition(pos) === \"chat\") {\n\t\t\t\t\t// 如果聊天功能已经在某个位置，确保该位置打开\n\t\t\t\t\tif (pos === \"panelA\" && !useUiStore.getState().isPanelAOpen) {\n\t\t\t\t\t\tuseUiStore.getState().togglePanelA();\n\t\t\t\t\t} else if (pos === \"panelB\" && !useUiStore.getState().isPanelBOpen) {\n\t\t\t\t\t\tuseUiStore.getState().togglePanelB();\n\t\t\t\t\t} else if (pos === \"panelC\" && !useUiStore.getState().isPanelCOpen) {\n\t\t\t\t\t\tuseUiStore.getState().togglePanelC();\n\t\t\t\t\t}\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\t\t\t// 如果聊天功能不在任何位置，分配到panelB\n\t\t\tif (!positions.some((pos) => getFeatureByPosition(pos) === \"chat\")) {\n\t\t\t\tsetPanelFeature(\"panelB\", \"chat\");\n\t\t\t\tif (!useUiStore.getState().isPanelBOpen) {\n\t\t\t\t\tuseUiStore.getState().togglePanelB();\n\t\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\t// 如果聊天功能在panelA，确保panelA打开\n\t\t\tif (!useUiStore.getState().isPanelAOpen) {\n\t\t\t\tuseUiStore.getState().togglePanelA();\n\t\t\t}\n\t\t}\n\n\t\t// 开始Breakdown流程\n\t\tstartBreakdown(todoId);\n\t\tcloseContextMenu();\n\t};\n\n\tconst handleCancel = async () => {\n\t\ttry {\n\t\t\tawait updateTodo(todoId, { status: \"canceled\" });\n\t\t} catch (err) {\n\t\t\tconsole.error(\"Failed to cancel todo:\", err);\n\t\t}\n\t\tcloseContextMenu();\n\t};\n\n\tconst handleDelete = async () => {\n\t\ttry {\n\t\t\t// 递归查找所有子任务 ID\n\t\t\tconst findAllChildIds = (\n\t\t\t\tparentId: number,\n\t\t\t\tallTodos: Todo[],\n\t\t\t): number[] => {\n\t\t\t\tconst childIds: number[] = [];\n\t\t\t\tconst children = allTodos.filter(\n\t\t\t\t\t(t: Todo) => t.parentTodoId === parentId,\n\t\t\t\t);\n\t\t\t\tfor (const child of children) {\n\t\t\t\t\tchildIds.push(child.id);\n\t\t\t\t\tchildIds.push(...findAllChildIds(child.id, allTodos));\n\t\t\t\t}\n\t\t\t\treturn childIds;\n\t\t\t};\n\n\t\t\tconst allIdsToDelete = [todoId, ...findAllChildIds(todoId, todos)];\n\n\t\t\tawait deleteTodo(todoId);\n\t\t\t// 清理 UI 状态\n\t\t\tonTodoDeleted(allIdsToDelete);\n\t\t} catch (err) {\n\t\t\tconsole.error(\"Failed to delete todo:\", err);\n\t\t}\n\t\tcloseContextMenu();\n\t};\n\n\t// 构建菜单项\n\tconst menuItems: MenuItem[] = [\n\t\t{\n\t\t\ticon: Plus,\n\t\t\tlabel: t(\"addChild\"),\n\t\t\tonClick: handleAddChildClick,\n\t\t\tisFirst: true,\n\t\t},\n\t\t{\n\t\t\ticon: Sparkles,\n\t\t\tlabel: t(\"useAiPlan\"),\n\t\t\tonClick: handleStartBreakdown,\n\t\t},\n\t\t{\n\t\t\ticon: X,\n\t\t\tlabel: t(\"cancel\"),\n\t\t\tonClick: handleCancel,\n\t\t},\n\t\t{\n\t\t\ticon: Trash2,\n\t\t\tlabel: t(\"delete\"),\n\t\t\tonClick: handleDelete,\n\t\t\tisLast: true,\n\t\t},\n\t];\n\n\t// 克隆子元素并添加 onContextMenu 处理器\n\tconst childWithContextMenu = cloneElement(children, {\n\t\tonContextMenu: handleOpenContextMenu,\n\t} as React.HTMLAttributes<HTMLElement>);\n\n\treturn (\n\t\t<>\n\t\t\t{childWithContextMenu}\n\n\t\t\t{/* 内部添加子待办表单（当没有提供 onAddChild 时显示） */}\n\t\t\t{isAddingChild && !onAddChild && (\n\t\t\t\t<form\n\t\t\t\t\tonSubmit={handleCreateChild}\n\t\t\t\t\tonMouseDown={(e) => e.stopPropagation()}\n\t\t\t\t\tclassName=\"mt-3 space-y-2 rounded-lg border border-dashed border-primary/50 bg-primary/5 p-3\"\n\t\t\t\t>\n\t\t\t\t\t<input\n\t\t\t\t\t\tref={childInputRef}\n\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\tvalue={childName}\n\t\t\t\t\t\tonChange={(e) => setChildName(e.target.value)}\n\t\t\t\t\t\tonKeyDown={(e) => {\n\t\t\t\t\t\t\te.stopPropagation();\n\t\t\t\t\t\t\tif (e.key === \"Enter\") {\n\t\t\t\t\t\t\t\thandleCreateChild();\n\t\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tif (e.key === \"Escape\") {\n\t\t\t\t\t\t\t\tsetIsAddingChild(false);\n\t\t\t\t\t\t\t\tsetChildName(\"\");\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tplaceholder={t(\"childNamePlaceholder\")}\n\t\t\t\t\t\tclassName=\"w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary\"\n\t\t\t\t\t/>\n\t\t\t\t\t<div className=\"flex items-center justify-end gap-2\">\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\tsetIsAddingChild(false);\n\t\t\t\t\t\t\t\tsetChildName(\"\");\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tclassName=\"rounded-md px-3 py-1.5 text-xs font-medium text-muted-foreground hover:bg-muted transition-colors\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{t(\"cancelButton\")}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"submit\"\n\t\t\t\t\t\t\tclassName=\"inline-flex items-center gap-1 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground shadow-sm transition-colors hover:bg-primary/90\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<Plus className=\"h-4 w-4\" />\n\t\t\t\t\t\t\t{t(\"addButton\")}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</div>\n\t\t\t\t</form>\n\t\t\t)}\n\n\t\t\t<BaseContextMenu\n\t\t\t\titems={menuItems}\n\t\t\t\topen={contextMenu.open}\n\t\t\t\tposition={{ x: contextMenu.x, y: contextMenu.y }}\n\t\t\t\tonClose={closeContextMenu}\n\t\t\t/>\n\t\t</>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/components/common/layout/CollapsibleSection.tsx",
    "content": "\"use client\";\n\nimport type { ReactNode } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { SectionHeader } from \"./SectionHeader\";\n\ninterface CollapsibleSectionProps {\n\ttitle: ReactNode;\n\tshow: boolean;\n\tonToggle: () => void;\n\tchildren: ReactNode;\n\tclassName?: string;\n\theaderClassName?: string;\n\ttitleClassName?: string;\n\tcontentClassName?: string;\n\tshowToggleButton?: boolean;\n}\n\nexport function CollapsibleSection({\n\ttitle,\n\tshow,\n\tonToggle,\n\tchildren,\n\tclassName,\n\theaderClassName,\n\ttitleClassName,\n\tcontentClassName,\n\tshowToggleButton = true,\n}: CollapsibleSectionProps) {\n\treturn (\n\t\t<div className={cn(\"\", className)}>\n\t\t\t<SectionHeader\n\t\t\t\ttitle={title}\n\t\t\t\tshow={show}\n\t\t\t\tonToggle={onToggle}\n\t\t\t\theaderClassName={headerClassName}\n\t\t\t\ttitleClassName={titleClassName}\n\t\t\t\tshowToggleButton={showToggleButton}\n\t\t\t/>\n\t\t\t{show && <div className={cn(\"\", contentClassName)}>{children}</div>}\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/components/common/layout/LayoutSelector.tsx",
    "content": "\"use client\";\n\nimport {\n\tCheck,\n\tChevronDown,\n\tLayoutGrid,\n\tMoreHorizontal,\n\tPencil,\n\tTrash2,\n} from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport { useEffect, useRef, useState } from \"react\";\nimport {\n\tDropdownMenu,\n\tDropdownMenuContent,\n\tDropdownMenuItem,\n\tDropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport type { PanelFeature } from \"@/lib/config/panel-config\";\nimport { LAYOUT_PRESETS, useUiStore } from \"@/lib/store/ui-store\";\nimport { cn } from \"@/lib/utils\";\nimport {\n\ttype LayoutFormState,\n\tLayoutSelectorDialogs,\n\ttype OverwriteConfirmState,\n} from \"./LayoutSelectorDialogs\";\n\ninterface LayoutSelectorProps {\n\t/**\n\t * 是否显示下拉箭头\n\t * 顶部工具栏等紧凑区域可以关闭箭头，只保留图标入口\n\t * @default true\n\t */\n\tshowChevron?: boolean;\n\t/**\n\t * 是否显示当前布局名称\n\t * @default true\n\t */\n\tshowLabel?: boolean;\n}\n\nexport function LayoutSelector({\n\tshowChevron = true,\n\tshowLabel = true,\n}: LayoutSelectorProps) {\n\tconst {\n\t\tapplyLayout,\n\t\tpanelFeatureMap,\n\t\tisFeatureEnabled,\n\t\tisPanelAOpen,\n\t\tisPanelBOpen,\n\t\tisPanelCOpen,\n\t\tpanelAWidth,\n\t\tpanelCWidth,\n\t\tcustomLayouts,\n\t\tsaveCustomLayout,\n\t\trenameCustomLayout,\n\t\tdeleteCustomLayout,\n\t} = useUiStore();\n\tconst [mounted, setMounted] = useState(false);\n\tconst [isOpen, setIsOpen] = useState(false);\n\tconst [openMenuId, setOpenMenuId] = useState<string | null>(null);\n\tconst [layoutForm, setLayoutForm] = useState<LayoutFormState | null>(null);\n\tconst [formError, setFormError] = useState<string | null>(null);\n\tconst [overwriteConfirm, setOverwriteConfirm] =\n\t\tuseState<OverwriteConfirmState | null>(null);\n\tconst overwriteActionRef = useRef<\"confirm\" | \"cancel\" | null>(null);\n\tconst [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);\n\tconst _menuContentRef = useRef<HTMLDivElement | null>(null);\n\tconst _nameInputRef = useRef<HTMLInputElement | null>(null);\n\tconst dropdownRef = useRef<HTMLDivElement>(null);\n\tconst t = useTranslations(\"layoutSelector\");\n\tconst tDock = useTranslations(\"bottomDock\");\n\n\tuseEffect(() => {\n\t\tsetMounted(true);\n\t}, []);\n\n\t// 点击外部关闭下拉框\n\tuseEffect(() => {\n\t\tconst handleClickOutside = (event: MouseEvent) => {\n\t\t\tif (\n\t\t\t\tdropdownRef.current &&\n\t\t\t\t!dropdownRef.current.contains(event.target as Node)\n\t\t\t) {\n\t\t\t\tif (\n\t\t\t\t\t_menuContentRef.current?.contains(event.target as Node)\n\t\t\t\t) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tsetIsOpen(false);\n\t\t\t}\n\t\t};\n\n\t\tif (isOpen) {\n\t\t\tdocument.addEventListener(\"mousedown\", handleClickOutside);\n\t\t}\n\n\t\treturn () => {\n\t\t\tdocument.removeEventListener(\"mousedown\", handleClickOutside);\n\t\t};\n\t}, [isOpen]);\n\n\tuseEffect(() => {\n\t\tif (!layoutForm) return;\n\t\tif (layoutForm.mode !== \"rename\") return;\n\t\tconst id = requestAnimationFrame(() => {\n\t\t\t_nameInputRef.current?.focus();\n\t\t\t_nameInputRef.current?.select();\n\t\t});\n\t\treturn () => cancelAnimationFrame(id);\n\t}, [layoutForm]);\n\n\tuseEffect(() => {\n\t\tif (!isOpen) {\n\t\t\tsetOpenMenuId(null);\n\t\t}\n\t}, [isOpen]);\n\n\tif (!mounted) {\n\t\treturn <div className=\"h-9 w-9\" />;\n\t}\n\n\tconst layoutMatches = (layout: (typeof LAYOUT_PRESETS)[number]) => {\n\t\tconst featureMatch =\n\t\t\tlayout.panelFeatureMap.panelA === panelFeatureMap.panelA &&\n\t\t\tlayout.panelFeatureMap.panelB === panelFeatureMap.panelB &&\n\t\t\tlayout.panelFeatureMap.panelC === panelFeatureMap.panelC;\n\t\tif (!featureMatch) return false;\n\n\t\tif (\n\t\t\tlayout.isPanelAOpen !== isPanelAOpen ||\n\t\t\tlayout.isPanelBOpen !== isPanelBOpen ||\n\t\t\tlayout.isPanelCOpen !== isPanelCOpen\n\t\t) {\n\t\t\treturn false;\n\t\t}\n\n\t\tconst aWidthMatch =\n\t\t\tlayout.panelAWidth === undefined ||\n\t\t\tMath.abs(layout.panelAWidth - panelAWidth) < 0.001;\n\t\tconst cWidthMatch =\n\t\t\tlayout.panelCWidth === undefined ||\n\t\t\tMath.abs(layout.panelCWidth - panelCWidth) < 0.001;\n\n\t\treturn aWidthMatch && cWidthMatch;\n\t};\n\n\tconst currentLayout =\n\t\tLAYOUT_PRESETS.find(layoutMatches) ||\n\t\tcustomLayouts.find(layoutMatches) ||\n\t\tnull;\n\tconst currentLayoutId = currentLayout?.id ?? null;\n\tconst currentLayoutName = currentLayout\n\t\t? customLayouts.some((layout) => layout.id === currentLayout.id)\n\t\t\t? currentLayout.name\n\t\t\t: t(`layouts.${currentLayout.id}`)\n\t\t: t(\"customLayout\");\n\n\t// 过滤掉包含已禁用面板功能的预设布局\n\tconst availableBaseLayouts = LAYOUT_PRESETS.filter((preset) => {\n\t\tconst features = Object.values(preset.panelFeatureMap).filter(\n\t\t\t(feature): feature is PanelFeature => feature !== null,\n\t\t);\n\t\treturn features.every((feature) => isFeatureEnabled(feature));\n\t});\n\tconst availableCustomLayouts = customLayouts.filter((preset) => {\n\t\tconst features = Object.values(preset.panelFeatureMap).filter(\n\t\t\t(feature): feature is PanelFeature => feature !== null,\n\t\t);\n\t\treturn features.every((feature) => isFeatureEnabled(feature));\n\t});\n\tconst availableLayouts = [...availableBaseLayouts, ...availableCustomLayouts];\n\tconst customLayoutIds = new Set(customLayouts.map((layout) => layout.id));\n\n\tconst getLayoutName = (layoutId: string) => {\n\t\tconst customLayout = customLayouts.find((layout) => layout.id === layoutId);\n\t\tif (customLayout) return customLayout.name;\n\t\treturn t(`layouts.${layoutId}`);\n\t};\n\n\tconst formatFeatureLabel = (feature: PanelFeature | null) =>\n\t\tfeature ? tDock(feature) : tDock(\"unassigned\");\n\n\tconst getAutoName = () => {\n\t\tconst featureA = formatFeatureLabel(panelFeatureMap.panelA);\n\t\tconst featureB = formatFeatureLabel(panelFeatureMap.panelB);\n\t\tconst featureC = formatFeatureLabel(panelFeatureMap.panelC);\n\n\t\tconst showA = isPanelAOpen;\n\t\tconst showB = isPanelBOpen;\n\t\tconst showC = isPanelCOpen;\n\n\t\tlet a = 0;\n\t\tlet b = 0;\n\t\tlet c = 0;\n\n\t\tif (showA && showB && showC) {\n\t\t\tconst baseWidth = Math.max(0, 1 - panelCWidth);\n\t\t\ta = baseWidth * panelAWidth;\n\t\t\tb = Math.max(0, baseWidth - a);\n\t\t\tc = Math.max(0, panelCWidth);\n\t\t} else if (showA && showC && !showB) {\n\t\t\ta = panelAWidth;\n\t\t\tc = Math.max(0, 1 - panelAWidth);\n\t\t} else if (showA && showB && !showC) {\n\t\t\ta = panelAWidth;\n\t\t\tb = Math.max(0, 1 - panelAWidth);\n\t\t} else if (showB && showC && !showA) {\n\t\t\tb = Math.max(0, 1 - panelCWidth);\n\t\t\tc = Math.max(0, panelCWidth);\n\t\t} else if (showA) {\n\t\t\ta = 1;\n\t\t} else if (showB) {\n\t\t\tb = 1;\n\t\t} else if (showC) {\n\t\t\tc = 1;\n\t\t}\n\n\t\tconst parts: string[] = [];\n\t\tif (showA) parts.push(`A:${featureA} ${Math.round(a * 100)}%`);\n\t\tif (showB) parts.push(`B:${featureB} ${Math.round(b * 100)}%`);\n\t\tif (showC) parts.push(`C:${featureC} ${Math.round(c * 100)}%`);\n\n\t\treturn parts.join(\" · \");\n\t};\n\n\tconst openSaveDialog = () => {\n\t\tsetLayoutForm({ mode: \"save\", name: getAutoName() });\n\t\tsetFormError(null);\n\t\tsetIsOpen(false);\n\t\tsetOpenMenuId(null);\n\t};\n\n\tconst openRenameDialog = (layoutId: string) => {\n\t\tconst target = customLayouts.find((layout) => layout.id === layoutId);\n\t\tif (!target) return;\n\t\tsetLayoutForm({ mode: \"rename\", name: target.name, targetId: layoutId });\n\t\tsetFormError(null);\n\t\tsetIsOpen(false);\n\t\tsetOpenMenuId(null);\n\t};\n\n\tconst openDeleteDialog = (layoutId: string) => {\n\t\tsetDeleteTargetId(layoutId);\n\t\tsetIsOpen(false);\n\t\tsetOpenMenuId(null);\n\t};\n\n\tconst handleLayoutFormSubmit = () => {\n\t\tif (!layoutForm) return;\n\t\tconst trimmed = layoutForm.name.trim();\n\t\tif (!trimmed) {\n\t\t\tsetFormError(t(\"layoutNameRequired\"));\n\t\t\treturn;\n\t\t}\n\n\t\tconst nameKey = trimmed.toLocaleLowerCase();\n\t\tconst duplicate = customLayouts.find(\n\t\t\t(layout) => layout.name.toLocaleLowerCase() === nameKey,\n\t\t);\n\t\tconst isDuplicate =\n\t\t\tduplicate &&\n\t\t\t(layoutForm.mode === \"save\" || duplicate.id !== layoutForm.targetId);\n\t\tif (isDuplicate) {\n\t\t\tsetOverwriteConfirm({\n\t\t\t\tmode: layoutForm.mode,\n\t\t\t\tname: trimmed,\n\t\t\t\ttargetId: layoutForm.targetId,\n\t\t\t});\n\t\t\tsetLayoutForm(null);\n\t\t\tsetFormError(null);\n\t\t\treturn;\n\t\t}\n\n\t\tconst success =\n\t\t\tlayoutForm.mode === \"save\"\n\t\t\t\t? saveCustomLayout(trimmed)\n\t\t\t\t: renameCustomLayout(layoutForm.targetId ?? \"\", trimmed);\n\t\tif (success) {\n\t\t\tsetLayoutForm(null);\n\t\t}\n\t};\n\n\tconst handleOverwriteConfirm = () => {\n\t\tif (!overwriteConfirm) return;\n\t\toverwriteActionRef.current = \"confirm\";\n\t\tif (overwriteConfirm.mode === \"save\") {\n\t\t\tsaveCustomLayout(overwriteConfirm.name, { overwrite: true });\n\t\t} else {\n\t\t\trenameCustomLayout(overwriteConfirm.targetId ?? \"\", overwriteConfirm.name, {\n\t\t\t\toverwrite: true,\n\t\t\t});\n\t\t}\n\t\tsetOverwriteConfirm(null);\n\t\tsetLayoutForm(null);\n\t};\n\n\tconst handleOverwriteCancel = () => {\n\t\tif (!overwriteConfirm) return;\n\t\toverwriteActionRef.current = \"cancel\";\n\t\tsetLayoutForm({\n\t\t\tmode: overwriteConfirm.mode,\n\t\t\tname: overwriteConfirm.name,\n\t\t\ttargetId: overwriteConfirm.targetId,\n\t\t});\n\t\tsetOverwriteConfirm(null);\n\t\tsetFormError(null);\n\t};\n\n\tconst handleDeleteConfirm = () => {\n\t\tif (!deleteTargetId) return;\n\t\tdeleteCustomLayout(deleteTargetId);\n\t\tsetDeleteTargetId(null);\n\t};\n\n\tconst deleteTarget = deleteTargetId\n\t\t? customLayouts.find((layout) => layout.id === deleteTargetId)\n\t\t: null;\n\n\treturn (\n\t\t<div ref={dropdownRef} className=\"relative\">\n\t\t\t<button\n\t\t\t\ttype=\"button\"\n\t\t\t\tonClick={() => setIsOpen(!isOpen)}\n\t\t\t\tclassName={cn(\n\t\t\t\t\t\"flex items-center gap-2 rounded-md border border-border bg-background/70 px-2.5 py-1.5 text-sm text-foreground shadow-sm transition-all duration-200\",\n\t\t\t\t\t\"hover:bg-accent/40 hover:shadow-md active:scale-[0.98] active:shadow-sm\",\n\t\t\t\t\t\"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n\t\t\t\t)}\n\t\t\t\ttitle={currentLayoutName}\n\t\t\t\taria-label={`${t(\"selectLayout\")}：${currentLayoutName}`}\n\t\t\t\taria-expanded={isOpen}\n\t\t\t\taria-haspopup=\"listbox\"\n\t\t\t>\n\t\t\t\t<LayoutGrid className=\"h-4 w-4 shrink-0 text-muted-foreground\" />\n\t\t\t\t{showLabel && (\n\t\t\t\t\t<span className=\"max-w-[160px] truncate font-medium\">\n\t\t\t\t\t\t{currentLayoutName}\n\t\t\t\t\t</span>\n\t\t\t\t)}\n\t\t\t\t{showChevron && (\n\t\t\t\t\t<ChevronDown\n\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\"h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform duration-200\",\n\t\t\t\t\t\t\tisOpen && \"rotate-180\",\n\t\t\t\t\t\t)}\n\t\t\t\t\t/>\n\t\t\t\t)}\n\t\t\t</button>\n\n\t\t\t{isOpen && (\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"absolute right-0 top-full z-50 mt-1 w-full min-w-[180px] rounded-md border border-border bg-popover p-1 shadow-md animate-in fade-in-0 zoom-in-95\"\n\t\t\t\t\trole=\"listbox\"\n\t\t\t\t\taria-label={t(\"selectLayout\")}\n\t\t\t\t>\n\t\t\t\t\t{availableLayouts.map((layout) => {\n\t\t\t\t\t\tconst isCustom = customLayoutIds.has(layout.id);\n\t\t\t\t\t\tconst isActive = currentLayoutId === layout.id;\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tkey={layout.id}\n\t\t\t\t\t\t\t\trole=\"option\"\n\t\t\t\t\t\t\t\taria-selected={isActive}\n\t\t\t\t\t\t\t\ttabIndex={0}\n\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\tapplyLayout(layout.id);\n\t\t\t\t\t\t\t\t\tsetOpenMenuId(null);\n\t\t\t\t\t\t\t\t\tsetIsOpen(false);\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tonKeyDown={(event) => {\n\t\t\t\t\t\t\t\t\tif (event.key === \"Enter\" || event.key === \" \") {\n\t\t\t\t\t\t\t\t\t\tevent.preventDefault();\n\t\t\t\t\t\t\t\t\t\tapplyLayout(layout.id);\n\t\t\t\t\t\t\t\t\t\tsetOpenMenuId(null);\n\t\t\t\t\t\t\t\t\t\tsetIsOpen(false);\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\"group flex w-full cursor-pointer items-center justify-between gap-2 rounded-sm px-3 py-2 text-sm transition-colors\",\n\t\t\t\t\t\t\t\t\t\"hover:bg-accent hover:text-accent-foreground\",\n\t\t\t\t\t\t\t\t\t\"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring\",\n\t\t\t\t\t\t\t\t\tisActive && \"bg-accent/50\",\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<span className=\"flex-1 truncate\">\n\t\t\t\t\t\t\t\t\t{getLayoutName(layout.id)}\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-1\">\n\t\t\t\t\t\t\t\t\t{isActive && <Check className=\"h-4 w-4 text-primary\" />}\n\t\t\t\t\t\t\t\t\t{isCustom && (\n\t\t\t\t\t\t\t\t\t\t<DropdownMenu\n\t\t\t\t\t\t\t\t\t\t\topen={openMenuId === layout.id}\n\t\t\t\t\t\t\t\t\t\t\tonOpenChange={(open) =>\n\t\t\t\t\t\t\t\t\t\t\t\tsetOpenMenuId(open ? layout.id : null)\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<DropdownMenuTrigger asChild>\n\t\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tonPointerDown={(event) => event.stopPropagation()}\n\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={(event) => event.stopPropagation()}\n\t\t\t\t\t\t\t\t\t\t\t\t\tonKeyDown={(event) => event.stopPropagation()}\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"rounded-sm p-1 text-muted-foreground transition-opacity\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"opacity-0 group-hover:opacity-100 group-focus-within:opacity-100\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t\taria-label={t(\"layoutActions\")}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<MoreHorizontal className=\"h-4 w-4\" />\n\t\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t</DropdownMenuTrigger>\n\t\t\t\t\t\t\t\t\t\t\t<DropdownMenuContent\n\t\t\t\t\t\t\t\t\t\t\t\talign=\"end\"\n\t\t\t\t\t\t\t\t\t\t\t\tsideOffset={6}\n\t\t\t\t\t\t\t\t\t\t\t\tref={_menuContentRef}\n\t\t\t\t\t\t\t\t\t\t\t\tonClick={(event) => event.stopPropagation()}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t<DropdownMenuItem\n\t\t\t\t\t\t\t\t\t\t\t\t\tonSelect={() => {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tsetOpenMenuId(null);\n\t\t\t\t\t\t\t\t\t\t\t\t\t\topenRenameDialog(layout.id);\n\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<Pencil className=\"mr-2 h-3.5 w-3.5\" />\n\t\t\t\t\t\t\t\t\t\t\t\t\t{t(\"renameLayout\")}\n\t\t\t\t\t\t\t\t\t\t\t\t</DropdownMenuItem>\n\t\t\t\t\t\t\t\t\t\t\t\t<DropdownMenuItem\n\t\t\t\t\t\t\t\t\t\t\t\t\tonSelect={() => {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tsetOpenMenuId(null);\n\t\t\t\t\t\t\t\t\t\t\t\t\t\topenDeleteDialog(layout.id);\n\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"text-destructive focus:text-destructive\"\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<Trash2 className=\"mr-2 h-3.5 w-3.5\" />\n\t\t\t\t\t\t\t\t\t\t\t\t\t{t(\"deleteLayout\")}\n\t\t\t\t\t\t\t\t\t\t\t\t</DropdownMenuItem>\n\t\t\t\t\t\t\t\t\t\t\t</DropdownMenuContent>\n\t\t\t\t\t\t\t\t\t\t</DropdownMenu>\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t);\n\t\t\t\t\t})}\n\t\t\t\t\t<div className=\"my-1 border-t border-border\" />\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tonClick={openSaveDialog}\n\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\"flex w-full items-center gap-2 rounded-sm px-3 py-2 text-sm transition-colors\",\n\t\t\t\t\t\t\t\"hover:bg-accent hover:text-accent-foreground\",\n\t\t\t\t\t\t\t\"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring\",\n\t\t\t\t\t\t)}\n\t\t\t\t\t>\n\t\t\t\t\t\t<span className=\"text-muted-foreground\">\n\t\t\t\t\t\t\t{t(\"saveCurrentLayout\")}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t</button>\n\t\t\t\t</div>\n\t\t\t)}\n\t\t\t<LayoutSelectorDialogs\n\t\t\t\tlayoutForm={layoutForm}\n\t\t\t\tsetLayoutForm={setLayoutForm}\n\t\t\t\tformError={formError}\n\t\t\t\tsetFormError={setFormError}\n\t\t\t\tonLayoutFormSubmit={handleLayoutFormSubmit}\n\t\t\t\tnameInputRef={_nameInputRef}\n\t\t\t\toverwriteConfirm={overwriteConfirm}\n\t\t\t\tonOverwriteConfirm={handleOverwriteConfirm}\n\t\t\t\tonOverwriteCancel={handleOverwriteCancel}\n\t\t\t\toverwriteActionRef={overwriteActionRef}\n\t\t\t\tdeleteTargetName={deleteTarget?.name ?? null}\n\t\t\t\tonDeleteConfirm={handleDeleteConfirm}\n\t\t\t\tonDeleteCancel={() => setDeleteTargetId(null)}\n\t\t\t\tt={t}\n\t\t\t/>\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/components/common/layout/LayoutSelectorDialogs.tsx",
    "content": "\"use client\";\n\nimport { X } from \"lucide-react\";\nimport type { useTranslations } from \"next-intl\";\nimport type { Dispatch, RefObject, SetStateAction } from \"react\";\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n\tDialog,\n\tDialogClose,\n\tDialogContent,\n\tDialogDescription,\n\tDialogFooter,\n\tDialogHeader,\n\tDialogTitle,\n} from \"@/components/ui/dialog\";\nimport { cn } from \"@/lib/utils\";\n\nexport type LayoutFormMode = \"save\" | \"rename\";\n\nexport interface LayoutFormState {\n\tmode: LayoutFormMode;\n\tname: string;\n\ttargetId?: string;\n}\n\nexport interface OverwriteConfirmState {\n\tmode: LayoutFormMode;\n\tname: string;\n\ttargetId?: string;\n}\n\ntype Translator = ReturnType<typeof useTranslations>;\n\ninterface LayoutSelectorDialogsProps {\n\tlayoutForm: LayoutFormState | null;\n\tsetLayoutForm: Dispatch<SetStateAction<LayoutFormState | null>>;\n\tformError: string | null;\n\tsetFormError: (value: string | null) => void;\n\tonLayoutFormSubmit: () => void;\n\tnameInputRef: RefObject<HTMLInputElement | null>;\n\toverwriteConfirm: OverwriteConfirmState | null;\n\tonOverwriteConfirm: () => void;\n\tonOverwriteCancel: () => void;\n\toverwriteActionRef: RefObject<\"confirm\" | \"cancel\" | null>;\n\tdeleteTargetName: string | null;\n\tonDeleteConfirm: () => void;\n\tonDeleteCancel: () => void;\n\tt: Translator;\n}\n\nexport function LayoutSelectorDialogs({\n\tlayoutForm,\n\tsetLayoutForm,\n\tformError,\n\tsetFormError,\n\tonLayoutFormSubmit,\n\tnameInputRef,\n\toverwriteConfirm,\n\tonOverwriteConfirm,\n\tonOverwriteCancel,\n\toverwriteActionRef,\n\tdeleteTargetName,\n\tonDeleteConfirm,\n\tonDeleteCancel,\n\tt,\n}: LayoutSelectorDialogsProps) {\n\treturn (\n\t\t<>\n\t\t\t<Dialog\n\t\t\t\topen={Boolean(layoutForm)}\n\t\t\t\tonOpenChange={(open) => {\n\t\t\t\t\tif (!open) {\n\t\t\t\t\t\tsetLayoutForm(null);\n\t\t\t\t\t\tsetFormError(null);\n\t\t\t\t\t}\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t<DialogContent className=\"p-0\">\n\t\t\t\t\t<div className=\"flex items-start justify-between gap-4 border-b border-border px-4 py-3\">\n\t\t\t\t\t\t<DialogHeader className=\"space-y-1\">\n\t\t\t\t\t\t\t<DialogTitle>\n\t\t\t\t\t\t\t\t{layoutForm?.mode === \"save\"\n\t\t\t\t\t\t\t\t\t? t(\"saveLayoutTitle\")\n\t\t\t\t\t\t\t\t\t: t(\"renameLayoutTitle\")}\n\t\t\t\t\t\t\t</DialogTitle>\n\t\t\t\t\t\t\t<DialogDescription>\n\t\t\t\t\t\t\t\t{layoutForm?.mode === \"save\"\n\t\t\t\t\t\t\t\t\t? t(\"saveLayoutDescription\")\n\t\t\t\t\t\t\t\t\t: t(\"renameLayoutDescription\")}\n\t\t\t\t\t\t\t</DialogDescription>\n\t\t\t\t\t\t</DialogHeader>\n\t\t\t\t\t\t<DialogClose asChild>\n\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\tvariant=\"ghost\"\n\t\t\t\t\t\t\t\tsize=\"icon\"\n\t\t\t\t\t\t\t\taria-label={t(\"close\")}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<X className=\"h-4 w-4\" />\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t</DialogClose>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"px-4 py-3\">\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\tautoFocus\n\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\"w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm outline-none\",\n\t\t\t\t\t\t\t\t\"focus-visible:ring-2 focus-visible:ring-ring\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\tplaceholder={\n\t\t\t\t\t\t\t\tlayoutForm?.mode === \"save\"\n\t\t\t\t\t\t\t\t\t? t(\"saveLayoutPlaceholder\")\n\t\t\t\t\t\t\t\t\t: t(\"renameLayoutPlaceholder\")\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tvalue={layoutForm?.name ?? \"\"}\n\t\t\t\t\t\t\tonChange={(event) => {\n\t\t\t\t\t\t\t\tsetLayoutForm((prev) =>\n\t\t\t\t\t\t\t\t\tprev ? { ...prev, name: event.target.value } : prev,\n\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t\tif (formError) setFormError(null);\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tref={nameInputRef}\n\t\t\t\t\t\t\tonKeyDown={(event) => {\n\t\t\t\t\t\t\t\tif (event.key === \"Enter\") {\n\t\t\t\t\t\t\t\t\tevent.preventDefault();\n\t\t\t\t\t\t\t\t\tonLayoutFormSubmit();\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t{formError && (\n\t\t\t\t\t\t\t<p className=\"mt-2 text-xs text-destructive\">{formError}</p>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t\t<DialogFooter className=\"border-t border-border px-4 py-3 sm:flex-row sm:justify-end\">\n\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tvariant=\"outline\"\n\t\t\t\t\t\t\tsize=\"sm\"\n\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\tsetLayoutForm(null);\n\t\t\t\t\t\t\t\tsetFormError(null);\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{t(\"cancel\")}\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t<Button type=\"button\" size=\"sm\" onClick={onLayoutFormSubmit}>\n\t\t\t\t\t\t\t{t(\"confirm\")}\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</DialogFooter>\n\t\t\t\t</DialogContent>\n\t\t\t</Dialog>\n\n\t\t\t<AlertDialog\n\t\t\t\topen={Boolean(overwriteConfirm)}\n\t\t\t\tonOpenChange={(open) => {\n\t\t\t\t\tif (!open) {\n\t\t\t\t\t\tif (overwriteActionRef.current) {\n\t\t\t\t\t\t\toverwriteActionRef.current = null;\n\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t}\n\t\t\t\t\t\tonOverwriteCancel();\n\t\t\t\t\t\toverwriteActionRef.current = null;\n\t\t\t\t\t}\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t<AlertDialogContent>\n\t\t\t\t\t<AlertDialogHeader className=\"border-b border-border px-4 py-3\">\n\t\t\t\t\t\t<AlertDialogTitle>{t(\"overwriteConfirmTitle\")}</AlertDialogTitle>\n\t\t\t\t\t</AlertDialogHeader>\n\t\t\t\t\t<AlertDialogDescription className=\"px-4 py-3\">\n\t\t\t\t\t\t{overwriteConfirm\n\t\t\t\t\t\t\t? t(\"overwriteConfirmDescription\", {\n\t\t\t\t\t\t\t\t\tname: overwriteConfirm.name,\n\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t: null}\n\t\t\t\t\t</AlertDialogDescription>\n\t\t\t\t\t<AlertDialogFooter className=\"border-t border-border px-4 py-3 sm:flex-row sm:justify-end\">\n\t\t\t\t\t\t<Button asChild variant=\"outline\" size=\"sm\">\n\t\t\t\t\t\t\t<AlertDialogCancel onClick={onOverwriteCancel}>\n\t\t\t\t\t\t\t\t{t(\"cancel\")}\n\t\t\t\t\t\t\t</AlertDialogCancel>\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t<Button asChild size=\"sm\">\n\t\t\t\t\t\t\t<AlertDialogAction onClick={onOverwriteConfirm}>\n\t\t\t\t\t\t\t\t{t(\"confirm\")}\n\t\t\t\t\t\t\t</AlertDialogAction>\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</AlertDialogFooter>\n\t\t\t\t</AlertDialogContent>\n\t\t\t</AlertDialog>\n\n\t\t\t<AlertDialog\n\t\t\t\topen={Boolean(deleteTargetName)}\n\t\t\t\tonOpenChange={(open) => {\n\t\t\t\t\tif (!open) {\n\t\t\t\t\t\tonDeleteCancel();\n\t\t\t\t\t}\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t<AlertDialogContent>\n\t\t\t\t\t<AlertDialogHeader className=\"border-b border-border px-4 py-3\">\n\t\t\t\t\t\t<AlertDialogTitle>{t(\"deleteLayoutTitle\")}</AlertDialogTitle>\n\t\t\t\t\t</AlertDialogHeader>\n\t\t\t\t\t<AlertDialogDescription className=\"px-4 py-3\">\n\t\t\t\t\t\t{deleteTargetName\n\t\t\t\t\t\t\t? t(\"deleteLayoutDescription\", { name: deleteTargetName })\n\t\t\t\t\t\t\t: null}\n\t\t\t\t\t</AlertDialogDescription>\n\t\t\t\t\t<AlertDialogFooter className=\"border-t border-border px-4 py-3 sm:flex-row sm:justify-end\">\n\t\t\t\t\t\t<Button asChild variant=\"outline\" size=\"sm\">\n\t\t\t\t\t\t\t<AlertDialogCancel onClick={onDeleteCancel}>\n\t\t\t\t\t\t\t\t{t(\"cancel\")}\n\t\t\t\t\t\t\t</AlertDialogCancel>\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t<Button asChild variant=\"destructive\" size=\"sm\">\n\t\t\t\t\t\t\t<AlertDialogAction onClick={onDeleteConfirm}>\n\t\t\t\t\t\t\t\t{t(\"deleteLayout\")}\n\t\t\t\t\t\t\t</AlertDialogAction>\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</AlertDialogFooter>\n\t\t\t\t</AlertDialogContent>\n\t\t\t</AlertDialog>\n\t\t</>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/components/common/layout/PanelHeader.tsx",
    "content": "\"use client\";\n\nimport { useDraggable } from \"@dnd-kit/core\";\nimport { CSS } from \"@dnd-kit/utilities\";\nimport type { LucideIcon } from \"lucide-react\";\nimport {\n\tCheck,\n\tExternalLink,\n\tLayoutGrid,\n\tMoreHorizontal,\n\tPin,\n\tPinOff,\n\tX,\n} from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport type { ReactNode } from \"react\";\nimport { createContext, useContext, useMemo } from \"react\";\nimport {\n\tDropdownMenu,\n\tDropdownMenuContent,\n\tDropdownMenuItem,\n\tDropdownMenuSeparator,\n\tDropdownMenuSub,\n\tDropdownMenuSubContent,\n\tDropdownMenuSubTrigger,\n\tDropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport {\n\tALL_PANEL_FEATURES,\n\tFEATURE_ICON_MAP,\n\ttype PanelPosition,\n} from \"@/lib/config/panel-config\";\nimport type { DragData } from \"@/lib/dnd\";\nimport { useUiStore } from \"@/lib/store/ui-store\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Panel Icon 样式配置接口\n */\nexport interface PanelIconStyleConfig {\n\t/** 图标大小类名（如 \"h-4 w-4\", \"h-5 w-5\"） */\n\tsize?: string;\n\t/** 图标颜色类名（如 \"text-primary\", \"text-foreground\"） */\n\tcolor?: string;\n\t/** 图标粗细类名（如 \"stroke-[1.5]\", \"stroke-[2]\"） */\n\tstrokeWidth?: string;\n\t/** 额外的自定义类名 */\n\tclassName?: string;\n}\n\n/**\n * Panel Action Button 样式配置接口\n */\nexport interface PanelActionButtonStyleConfig {\n\t/** 按钮大小类名（如 \"h-7 w-7\", \"h-8 w-8\"） */\n\tsize?: string;\n\t/** 按钮背景色类名（如 \"bg-primary\", \"bg-muted/50\"） */\n\tbackground?: string;\n\t/** 按钮文字颜色类名（如 \"text-primary-foreground\", \"text-muted-foreground\"） */\n\ttextColor?: string;\n\t/** 按钮边框圆角类名（如 \"rounded-md\", \"rounded-lg\"） */\n\trounded?: string;\n\t/** Hover 状态背景色类名（如 \"hover:bg-primary/90\", \"hover:bg-muted/50\"） */\n\thoverBackground?: string;\n\t/** Hover 状态文字颜色类名（如 \"hover:text-foreground\"） */\n\thoverTextColor?: string;\n\t/** Focus 可见时的样式类名（如 \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"） */\n\tfocusVisible?: string;\n\t/** 过渡效果类名（如 \"transition-colors\"） */\n\ttransition?: string;\n\t/** 额外的自定义类名 */\n\tclassName?: string;\n}\n\n/**\n * 默认的 Panel Header Icon 样式配置\n * 用于 PanelHeader 最左侧代表 panel 的 icon\n */\nconst DEFAULT_HEADER_ICON_STYLE: PanelIconStyleConfig = {\n\tsize: \"h-4.5 w-4.5\", // 图标大小，可改为 \"h-5 w-5\", \"h-6 w-6\" 等\n\tcolor: \"text-primary\", // 图标颜色，可改为 \"text-foreground\", \"text-primary\" 等\n\tstrokeWidth: \"stroke-[2]\", // 图标粗细，可改为 \"stroke-[1.5]\", \"stroke-[2]\" 等\n\tclassName: undefined, // 额外的自定义类名\n};\n\n/**\n * 默认的 Panel Action Icon 样式配置\n * 用于 PanelHeader actions 区域中的 icon\n */\nconst DEFAULT_ACTION_ICON_STYLE: PanelIconStyleConfig = {\n\tsize: \"h-4.5 w-4.5\", // 图标大小，可改为 \"h-5 w-5\", \"h-6 w-6\" 等\n\tcolor: \"text-muted-foreground\", // 图标颜色，可改为 \"text-foreground\", \"text-primary\" 等\n\tstrokeWidth: \"stroke-[2.2]\", // 图标粗细，可改为 \"stroke-[1.5]\", \"stroke-[2]\" 等\n\tclassName: undefined, // 额外的自定义类名\n};\n\n/**\n * 默认的 Panel Action Button 样式配置\n *\n * 在这里统一调整所有 PanelHeader actions 区域中 button 的样式\n *\n * 支持三种变体：\n * - default: 普通按钮（默认）\n * - primary: 主按钮（蓝色背景）\n * - destructive: 危险按钮（红色文字）\n */\nconst DEFAULT_ACTION_BUTTON_STYLE: PanelActionButtonStyleConfig = {\n\tsize: \"h-6 w-6\", // 按钮大小，可改为 \"h-8 w-8\" 等\n\tbackground: undefined, // 默认无背景\n\ttextColor: \"text-muted-foreground\", // 文字颜色\n\trounded: \"rounded-md\", // 圆角\n\thoverBackground: \"hover:bg-muted/50\", // Hover 背景\n\thoverTextColor: \"hover:text-foreground\", // Hover 文字颜色\n\tfocusVisible:\n\t\t\"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\", // Focus 样式\n\ttransition: \"transition-colors\", // 过渡效果\n\tclassName: undefined, // 额外的自定义类名\n};\n\nconst PRIMARY_ACTION_BUTTON_STYLE: PanelActionButtonStyleConfig = {\n\t...DEFAULT_ACTION_BUTTON_STYLE,\n\tbackground: \"bg-primary\",\n\ttextColor: \"text-primary-foreground\",\n\thoverBackground: \"hover:bg-primary/90\",\n\thoverTextColor: undefined, // 主按钮 hover 时文字颜色不变\n};\n\nconst DESTRUCTIVE_ACTION_BUTTON_STYLE: PanelActionButtonStyleConfig = {\n\t...DEFAULT_ACTION_BUTTON_STYLE,\n\ttextColor: \"text-destructive\",\n\thoverBackground: \"hover:bg-destructive/10\",\n\thoverTextColor: undefined, // 危险按钮 hover 时文字颜色不变\n};\n\n/**\n * Panel Icon 样式配置 Context\n * 用于在全局范围内统一配置所有 PanelHeader 中的 icon 样式\n */\ninterface PanelIconConfigContextValue {\n\t/** 主标题 icon 的样式配置 */\n\theaderIcon: PanelIconStyleConfig;\n\t/** Actions 区域中 icon 的样式配置 */\n\tactionIcon: PanelIconStyleConfig;\n\t/** 普通按钮样式配置 */\n\tdefaultButton: PanelActionButtonStyleConfig;\n\t/** 主按钮样式配置 */\n\tprimaryButton: PanelActionButtonStyleConfig;\n\t/** 危险按钮样式配置 */\n\tdestructiveButton: PanelActionButtonStyleConfig;\n}\n\nconst PanelIconConfigContext = createContext<PanelIconConfigContextValue>({\n\theaderIcon: DEFAULT_HEADER_ICON_STYLE,\n\tactionIcon: DEFAULT_ACTION_ICON_STYLE,\n\tdefaultButton: DEFAULT_ACTION_BUTTON_STYLE,\n\tprimaryButton: PRIMARY_ACTION_BUTTON_STYLE,\n\tdestructiveButton: DESTRUCTIVE_ACTION_BUTTON_STYLE,\n});\n\n/**\n * Panel Icon 样式配置 Provider\n * 用于在应用顶层或特定区域统一配置 icon 和 button 样式\n */\nexport function PanelIconConfigProvider({\n\theaderIcon,\n\tactionIcon,\n\tdefaultButton,\n\tprimaryButton,\n\tdestructiveButton,\n\tchildren,\n}: {\n\t/** 主标题 icon 的样式配置（会与默认配置合并） */\n\theaderIcon?: Partial<PanelIconStyleConfig>;\n\t/** Actions 区域中 icon 的样式配置（会与默认配置合并） */\n\tactionIcon?: Partial<PanelIconStyleConfig>;\n\t/** 普通按钮样式配置（会与默认配置合并） */\n\tdefaultButton?: Partial<PanelActionButtonStyleConfig>;\n\t/** 主按钮样式配置（会与默认配置合并） */\n\tprimaryButton?: Partial<PanelActionButtonStyleConfig>;\n\t/** 危险按钮样式配置（会与默认配置合并） */\n\tdestructiveButton?: Partial<PanelActionButtonStyleConfig>;\n\tchildren: ReactNode;\n}) {\n\tconst value = useMemo<PanelIconConfigContextValue>(\n\t\t() => ({\n\t\t\theaderIcon: { ...DEFAULT_HEADER_ICON_STYLE, ...headerIcon },\n\t\t\tactionIcon: { ...DEFAULT_ACTION_ICON_STYLE, ...actionIcon },\n\t\t\tdefaultButton: { ...DEFAULT_ACTION_BUTTON_STYLE, ...defaultButton },\n\t\t\tprimaryButton: { ...PRIMARY_ACTION_BUTTON_STYLE, ...primaryButton },\n\t\t\tdestructiveButton: {\n\t\t\t\t...DESTRUCTIVE_ACTION_BUTTON_STYLE,\n\t\t\t\t...destructiveButton,\n\t\t\t},\n\t\t}),\n\t\t[headerIcon, actionIcon, defaultButton, primaryButton, destructiveButton],\n\t);\n\n\treturn (\n\t\t<PanelIconConfigContext.Provider value={value}>\n\t\t\t{children}\n\t\t</PanelIconConfigContext.Provider>\n\t);\n}\n\n/**\n * Hook: 获取 Panel Icon 样式类名\n * 用于在 actions 区域或其他地方统一使用 icon 样式\n * @param type - icon 类型：'header' 用于主标题 icon，'action' 用于操作区域的 icon\n * @param overrides - 可选的样式覆盖\n * @returns 合并后的样式类名字符串\n */\nexport function usePanelIconStyle(\n\ttype: \"header\" | \"action\" = \"action\",\n\toverrides?: Partial<PanelIconStyleConfig>,\n): string {\n\tconst config = useContext(PanelIconConfigContext);\n\tconst baseConfig = type === \"header\" ? config.headerIcon : config.actionIcon;\n\tconst mergedConfig = { ...baseConfig, ...overrides };\n\n\treturn cn(\n\t\tmergedConfig.size,\n\t\tmergedConfig.color,\n\t\tmergedConfig.strokeWidth,\n\t\tmergedConfig.className,\n\t);\n}\n\n/**\n * Hook: 获取 Panel Action Button 样式类名\n * 用于在 actions 区域统一使用 button 样式\n * @param variant - 按钮变体：'default' 普通按钮，'primary' 主按钮，'destructive' 危险按钮\n * @param overrides - 可选的样式覆盖\n * @returns 合并后的样式类名字符串\n */\nexport function usePanelActionButtonStyle(\n\tvariant: \"default\" | \"primary\" | \"destructive\" = \"default\",\n\toverrides?: Partial<PanelActionButtonStyleConfig>,\n): string {\n\tconst config = useContext(PanelIconConfigContext);\n\tconst baseConfig =\n\t\tvariant === \"primary\"\n\t\t\t? config.primaryButton\n\t\t\t: variant === \"destructive\"\n\t\t\t\t? config.destructiveButton\n\t\t\t\t: config.defaultButton;\n\tconst mergedConfig = { ...baseConfig, ...overrides };\n\n\treturn cn(\n\t\t\"flex items-center justify-center\",\n\t\tmergedConfig.size,\n\t\tmergedConfig.background,\n\t\tmergedConfig.textColor,\n\t\tmergedConfig.rounded,\n\t\tmergedConfig.hoverBackground,\n\t\tmergedConfig.hoverTextColor,\n\t\tmergedConfig.focusVisible,\n\t\tmergedConfig.transition,\n\t\tmergedConfig.className,\n\t);\n}\n\n/**\n * 统一的 Panel Action Button 组件\n * 用于在 PanelHeader 的 actions 区域创建统一样式的按钮\n */\nexport interface PanelActionButtonProps\n\textends React.ButtonHTMLAttributes<HTMLButtonElement> {\n\t/** 按钮变体 */\n\tvariant?: \"default\" | \"primary\" | \"destructive\";\n\t/** 按钮内的图标 */\n\ticon: LucideIcon;\n\t/** 图标样式覆盖 */\n\ticonOverrides?: Partial<PanelIconStyleConfig>;\n\t/** 按钮样式覆盖 */\n\tbuttonOverrides?: Partial<PanelActionButtonStyleConfig>;\n\t/** 无障碍标签 */\n\t\"aria-label\": string;\n}\n\nexport function PanelActionButton({\n\tvariant = \"default\",\n\ticon: Icon,\n\ticonOverrides,\n\tbuttonOverrides,\n\tclassName,\n\t...buttonProps\n}: PanelActionButtonProps) {\n\tconst buttonStyle = usePanelActionButtonStyle(variant, buttonOverrides);\n\n\t// 对于 destructive 按钮，如果没有显式覆盖 icon 颜色，则自动使用红色\n\tconst finalIconOverrides =\n\t\tvariant === \"destructive\" && !iconOverrides?.color\n\t\t\t? { ...iconOverrides, color: \"text-destructive\" }\n\t\t\t: iconOverrides;\n\n\tconst iconStyle = usePanelIconStyle(\"action\", finalIconOverrides);\n\n\treturn (\n\t\t<button\n\t\t\ttype=\"button\"\n\t\t\tclassName={cn(buttonStyle, className)}\n\t\t\t{...buttonProps}\n\t\t>\n\t\t\t<Icon className={iconStyle} />\n\t\t</button>\n\t);\n}\n\n/**\n * Panel Position Context\n * 用于在面板内容中传递位置信息\n */\nconst PanelPositionContext = createContext<PanelPosition | null>(null);\n\nexport function usePanelPosition(): PanelPosition | null {\n\treturn useContext(PanelPositionContext);\n}\n\nexport function PanelPositionProvider({\n\tposition,\n\tchildren,\n}: {\n\tposition: PanelPosition;\n\tchildren: ReactNode;\n}) {\n\treturn (\n\t\t<PanelPositionContext.Provider value={position}>\n\t\t\t{children}\n\t\t</PanelPositionContext.Provider>\n\t);\n}\n\nfunction PanelHeaderMenu({ position }: { position: PanelPosition }) {\n\tconst t = useTranslations(\"panelMenu\");\n\tconst tDock = useTranslations(\"bottomDock\");\n\tconst menuButtonStyle = usePanelActionButtonStyle(\"default\");\n\tconst menuIconStyle = usePanelIconStyle(\"action\");\n\tconst {\n\t\tpanelFeatureMap,\n\t\tpanelPinMap,\n\t\tdisabledFeatures,\n\t\tbackendDisabledFeatures,\n\t\tsetPanelPinned,\n\t\tsetPanelFeature,\n\t\ttogglePanelA,\n\t\ttogglePanelB,\n\t\ttogglePanelC,\n\t} = useUiStore();\n\tconst currentFeature = panelFeatureMap[position];\n\tconst isPinned = panelPinMap[position];\n\n\tconst switchableFeatures = useMemo(() => {\n\t\tconst disabledSet = new Set([\n\t\t\t...disabledFeatures,\n\t\t\t...backendDisabledFeatures,\n\t\t]);\n\t\treturn ALL_PANEL_FEATURES.filter((feature) => !disabledSet.has(feature));\n\t}, [disabledFeatures, backendDisabledFeatures]);\n\n\tconst handleClose = () => {\n\t\tswitch (position) {\n\t\t\tcase \"panelA\":\n\t\t\t\ttogglePanelA();\n\t\t\t\tbreak;\n\t\t\tcase \"panelB\":\n\t\t\t\ttogglePanelB();\n\t\t\t\tbreak;\n\t\t\tcase \"panelC\":\n\t\t\t\ttogglePanelC();\n\t\t\t\tbreak;\n\t\t}\n\t};\n\n\treturn (\n\t\t<DropdownMenu>\n\t\t\t<DropdownMenuTrigger asChild>\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tclassName={menuButtonStyle}\n\t\t\t\t\tonPointerDown={(event) => event.stopPropagation()}\n\t\t\t\t\taria-label={t(\"moreActions\")}\n\t\t\t\t>\n\t\t\t\t\t<MoreHorizontal className={menuIconStyle} />\n\t\t\t\t</button>\n\t\t\t</DropdownMenuTrigger>\n\t\t\t<DropdownMenuContent align=\"end\" sideOffset={8}>\n\t\t\t\t<DropdownMenuSub>\n\t\t\t\t\t<DropdownMenuSubTrigger\n\t\t\t\t\t\tdisabled={isPinned || switchableFeatures.length === 0}\n\t\t\t\t\t>\n\t\t\t\t\t\t<LayoutGrid className=\"mr-2 h-4 w-4 text-muted-foreground\" />\n\t\t\t\t\t\t{t(\"switchPanel\")}\n\t\t\t\t\t</DropdownMenuSubTrigger>\n\t\t\t\t\t<DropdownMenuSubContent alignOffset={-6} sideOffset={10}>\n\t\t\t\t\t\t{switchableFeatures.map((feature) => {\n\t\t\t\t\t\t\tconst Icon = FEATURE_ICON_MAP[feature];\n\t\t\t\t\t\t\tconst isActive = feature === currentFeature;\n\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t<DropdownMenuItem\n\t\t\t\t\t\t\t\t\tkey={feature}\n\t\t\t\t\t\t\t\t\tdisabled={isPinned}\n\t\t\t\t\t\t\t\t\tonSelect={() => {\n\t\t\t\t\t\t\t\t\t\tsetPanelFeature(position, feature);\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<Icon className=\"mr-2 h-4 w-4 text-muted-foreground\" />\n\t\t\t\t\t\t\t\t\t<span>{tDock(feature)}</span>\n\t\t\t\t\t\t\t\t\t{isActive && (\n\t\t\t\t\t\t\t\t\t\t<Check className=\"ml-auto h-4 w-4 text-primary\" />\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t</DropdownMenuItem>\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t})}\n\t\t\t\t\t</DropdownMenuSubContent>\n\t\t\t\t</DropdownMenuSub>\n\t\t\t\t<DropdownMenuSeparator />\n\t\t\t\t<DropdownMenuItem onSelect={handleClose}>\n\t\t\t\t\t<X className=\"mr-2 h-4 w-4 text-muted-foreground\" />\n\t\t\t\t\t{t(\"closePanel\")}\n\t\t\t\t</DropdownMenuItem>\n\t\t\t\t<DropdownMenuItem\n\t\t\t\t\tonSelect={() => setPanelPinned(position, !isPinned)}\n\t\t\t\t>\n\t\t\t\t\t{isPinned ? (\n\t\t\t\t\t\t<PinOff className=\"mr-2 h-4 w-4 text-muted-foreground\" />\n\t\t\t\t\t) : (\n\t\t\t\t\t\t<Pin className=\"mr-2 h-4 w-4 text-muted-foreground\" />\n\t\t\t\t\t)}\n\t\t\t\t\t{isPinned ? t(\"unpinPanel\") : t(\"pinPanel\")}\n\t\t\t\t</DropdownMenuItem>\n\t\t\t\t<DropdownMenuSeparator />\n\t\t\t\t<DropdownMenuItem disabled>\n\t\t\t\t\t<ExternalLink className=\"mr-2 h-4 w-4 text-muted-foreground\" />\n\t\t\t\t\t{t(\"openInNewWindow\")}\n\t\t\t\t</DropdownMenuItem>\n\t\t\t</DropdownMenuContent>\n\t\t</DropdownMenu>\n\t);\n}\n\n/**\n * 统一的面板头部组件\n * 确保所有面板的 headerbar 高度一致\n * 如果 PanelPositionContext 提供了位置信息，则自动启用拖拽功能\n */\ninterface PanelHeaderProps {\n\t/** 标题图标 */\n\ticon: LucideIcon;\n\t/** 标题文本 */\n\ttitle: string;\n\t/** 右侧操作区域 */\n\tactions?: ReactNode;\n\t/** 自定义类名 */\n\tclassName?: string;\n\t/** 是否禁用拖拽（即使有 position context） */\n\tdisableDrag?: boolean;\n\t/** 自定义标题 icon 的样式（会覆盖全局配置） */\n\ticonClassName?: string;\n}\n\nexport function PanelHeader({\n\ticon: Icon,\n\ttitle,\n\tactions,\n\tclassName,\n\tdisableDrag = false,\n\ticonClassName,\n}: PanelHeaderProps) {\n\tconst position = usePanelPosition();\n\tconst isDraggable = !disableDrag && position !== null;\n\tconst headerIconStyle = usePanelIconStyle(\"header\");\n\tconst tPanelMenu = useTranslations(\"panelMenu\");\n\tconst panelPinMap = useUiStore((state) => state.panelPinMap);\n\tconst isPinned = position ? panelPinMap[position] : false;\n\n\t// 构建拖拽数据\n\tconst dragData: DragData | undefined = useMemo(\n\t\t() =>\n\t\t\tisDraggable && position\n\t\t\t\t? {\n\t\t\t\t\t\ttype: \"PANEL_HEADER\" as const,\n\t\t\t\t\t\tpayload: {\n\t\t\t\t\t\t\tposition,\n\t\t\t\t\t\t},\n\t\t\t\t\t}\n\t\t\t\t: undefined,\n\t\t[isDraggable, position],\n\t);\n\n\tconst { attributes, listeners, setNodeRef, transform, isDragging } =\n\t\tuseDraggable({\n\t\t\tid: isDraggable\n\t\t\t\t? `panel-header-${position}`\n\t\t\t\t: `panel-header-static-${title}`,\n\t\t\tdata: dragData,\n\t\t\tdisabled: !isDraggable,\n\t\t});\n\n\tconst style = transform\n\t\t? {\n\t\t\t\ttransform: CSS.Translate.toString(transform),\n\t\t\t}\n\t\t: undefined;\n\n\tconst headerContent = (\n\t\t<div\n\t\t\tclassName={cn(\n\t\t\t\t\"shrink-0 bg-background border-b\",\n\t\t\t\tisDragging && \"opacity-50\",\n\t\t\t)}\n\t\t>\n\t\t\t<div\n\t\t\t\tref={isDraggable ? setNodeRef : undefined}\n\t\t\t\tstyle={style}\n\t\t\t\t{...(isDraggable ? { ...attributes, ...listeners } : {})}\n\t\t\t\tclassName={cn(\n\t\t\t\t\t\"flex items-center justify-between px-4 py-2.5\",\n\t\t\t\t\tisDraggable && \"cursor-grab active:cursor-grabbing\",\n\t\t\t\t\tclassName,\n\t\t\t\t)}\n\t\t\t>\n\t\t\t\t<h2 className=\"flex items-center gap-2 text-base font-medium text-foreground\">\n\t\t\t\t\t<Icon className={cn(headerIconStyle, iconClassName)} />\n\t\t\t\t\t{title}\n\t\t\t\t\t{isPinned && (\n\t\t\t\t\t\t<span className=\"inline-flex items-center gap-1 rounded-full border border-amber-200/70 bg-amber-50/80 px-2 py-0.5 text-[11px] font-medium text-amber-700 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200\">\n\t\t\t\t\t\t\t<Pin className=\"h-3 w-3\" />\n\t\t\t\t\t\t\t{tPanelMenu(\"pinnedBadge\")}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t)}\n\t\t\t\t</h2>\n\t\t\t\t{(actions || position) && (\n\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t{actions}\n\t\t\t\t\t\t{position && <PanelHeaderMenu position={position} />}\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t</div>\n\t);\n\n\treturn headerContent;\n}\n"
  },
  {
    "path": "free-todo-frontend/components/common/layout/SectionHeader.tsx",
    "content": "\"use client\";\n\nimport { ChevronDown, ChevronUp } from \"lucide-react\";\nimport { type ReactNode, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\ninterface SectionHeaderProps {\n\ttitle: ReactNode;\n\tshow?: boolean;\n\tonToggle?: () => void;\n\theaderClassName?: string;\n\ttitleClassName?: string;\n\tbuttonClassName?: string;\n\tshowToggleButton?: boolean;\n\tisHovered?: boolean;\n}\n\nexport function SectionHeader({\n\ttitle,\n\tshow,\n\tonToggle,\n\theaderClassName,\n\ttitleClassName,\n\tbuttonClassName,\n\tshowToggleButton = true,\n\tisHovered: externalIsHovered,\n}: SectionHeaderProps) {\n\tconst [internalIsHovered, setInternalIsHovered] = useState(false);\n\n\t// 如果外部提供了 isHovered，使用外部的；否则使用内部状态\n\tconst isHovered =\n\t\texternalIsHovered !== undefined ? externalIsHovered : internalIsHovered;\n\n\treturn (\n\t\t<div\n\t\t\trole=\"group\"\n\t\t\tclassName={cn(\"flex items-center justify-between\", headerClassName)}\n\t\t\tonMouseEnter={\n\t\t\t\texternalIsHovered === undefined\n\t\t\t\t\t? () => setInternalIsHovered(true)\n\t\t\t\t\t: undefined\n\t\t\t}\n\t\t\tonMouseLeave={\n\t\t\t\texternalIsHovered === undefined\n\t\t\t\t\t? () => setInternalIsHovered(false)\n\t\t\t\t\t: undefined\n\t\t\t}\n\t\t>\n\t\t\t<div\n\t\t\t\tclassName={cn(\n\t\t\t\t\t\"text-xs font-semibold uppercase tracking-wider text-muted-foreground\",\n\t\t\t\t\ttitleClassName,\n\t\t\t\t)}\n\t\t\t>\n\t\t\t\t{title}\n\t\t\t</div>\n\t\t\t{showToggleButton && onToggle && (\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tonClick={onToggle}\n\t\t\t\t\taria-pressed={show}\n\t\t\t\t\taria-label={show ? \"折叠\" : \"展开\"}\n\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\"rounded-md px-2 py-1 transition-all hover:bg-muted/40 text-muted-foreground\",\n\t\t\t\t\t\tisHovered ? \"opacity-100\" : \"opacity-0 pointer-events-none\",\n\t\t\t\t\t\tbuttonClassName,\n\t\t\t\t\t)}\n\t\t\t\t>\n\t\t\t\t\t{show ? (\n\t\t\t\t\t\t<ChevronUp className=\"h-4 w-4\" />\n\t\t\t\t\t) : (\n\t\t\t\t\t\t<ChevronDown className=\"h-4 w-4\" />\n\t\t\t\t\t)}\n\t\t\t\t</button>\n\t\t\t)}\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/components/common/theme/ThemeProvider.tsx",
    "content": "\"use client\";\n\nimport { ThemeProvider as NextThemesProvider } from \"next-themes\";\nimport { useEffect } from \"react\";\nimport { useColorThemeStore } from \"@/lib/store/color-theme\";\n\ninterface ThemeProviderProps {\n\tchildren: React.ReactNode;\n}\n\nfunction ColorThemeApplier() {\n\tconst colorTheme = useColorThemeStore((state) => state.colorTheme);\n\n\tuseEffect(() => {\n\t\tif (typeof document === \"undefined\") return;\n\t\tdocument.documentElement.dataset.colorTheme = colorTheme;\n\t}, [colorTheme]);\n\n\treturn null;\n}\n\nexport function ThemeProvider({ children }: ThemeProviderProps) {\n\treturn (\n\t\t<NextThemesProvider\n\t\t\tattribute=\"class\"\n\t\t\tdefaultTheme=\"system\"\n\t\t\tenableSystem\n\t\t\tstorageKey=\"theme\"\n\t\t\tdisableTransitionOnChange={false}\n\t\t>\n\t\t\t<ColorThemeApplier />\n\t\t\t{children}\n\t\t</NextThemesProvider>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/components/common/theme/ThemeStyleSelect.tsx",
    "content": "\"use client\";\n\nimport { Check, Paintbrush } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { type ColorTheme, useColorThemeStore } from \"@/lib/store/color-theme\";\nimport { cn } from \"@/lib/utils\";\n\nexport function ThemeStyleSelect() {\n\tconst { colorTheme, setColorTheme } = useColorThemeStore();\n\tconst t = useTranslations(\"colorTheme\");\n\tconst [mounted, setMounted] = useState(false);\n\tconst [open, setOpen] = useState(false);\n\tconst wrapperRef = useRef<HTMLDivElement | null>(null);\n\n\tuseEffect(() => {\n\t\tsetMounted(true);\n\t}, []);\n\n\tuseEffect(() => {\n\t\tif (!open) return;\n\t\tconst handleClickOutside = (event: MouseEvent) => {\n\t\t\tif (!wrapperRef.current) return;\n\t\t\tif (wrapperRef.current.contains(event.target as Node)) return;\n\t\t\tsetOpen(false);\n\t\t};\n\t\tconst handleEscape = (event: KeyboardEvent) => {\n\t\t\tif (event.key === \"Escape\") {\n\t\t\t\tsetOpen(false);\n\t\t\t}\n\t\t};\n\t\tdocument.addEventListener(\"mousedown\", handleClickOutside);\n\t\tdocument.addEventListener(\"keydown\", handleEscape);\n\t\treturn () => {\n\t\t\tdocument.removeEventListener(\"mousedown\", handleClickOutside);\n\t\t\tdocument.removeEventListener(\"keydown\", handleEscape);\n\t\t};\n\t}, [open]);\n\n\tif (!mounted) {\n\t\treturn <div className=\"h-9 w-35\" />;\n\t}\n\n\tconst options: { value: ColorTheme; label: string }[] = [\n\t\t{ value: \"catppuccin\", label: t(\"catppuccin\") },\n\t\t{ value: \"blue\", label: t(\"blue\") },\n\t\t{ value: \"neutral\", label: t(\"neutral\") },\n\t];\n\n\treturn (\n\t\t<div className=\"relative\" ref={wrapperRef}>\n\t\t\t<span className=\"sr-only\">{t(\"label\")}</span>\n\t\t\t<button\n\t\t\t\ttype=\"button\"\n\t\t\t\tonClick={() => setOpen((prev) => !prev)}\n\t\t\t\tclassName={cn(\n\t\t\t\t\t\"flex items-center justify-center rounded-md p-2\",\n\t\t\t\t\t\"text-muted-foreground transition-all duration-200\",\n\t\t\t\t\t\"hover:bg-muted hover:text-foreground hover:shadow-md\",\n\t\t\t\t\t\"active:scale-95 active:shadow-sm\",\n\t\t\t\t\t\"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n\t\t\t\t)}\n\t\t\t\taria-haspopup=\"listbox\"\n\t\t\t\taria-expanded={open}\n\t\t\t\ttitle={t(\"label\")}\n\t\t\t\taria-label={t(\"label\")}\n\t\t\t>\n\t\t\t\t<Paintbrush className=\"h-5 w-5\" />\n\t\t\t</button>\n\t\t\t{open && (\n\t\t\t\t<div\n\t\t\t\t\tclassName=\"absolute right-0 z-30 mt-1 w-40 overflow-hidden rounded-lg border border-border bg-background shadow-lg\"\n\t\t\t\t\trole=\"listbox\"\n\t\t\t\t>\n\t\t\t\t\t{options.map((option) => {\n\t\t\t\t\t\tconst isActive = option.value === colorTheme;\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tkey={option.value}\n\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\trole=\"option\"\n\t\t\t\t\t\t\t\taria-selected={isActive}\n\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\tsetColorTheme(option.value);\n\t\t\t\t\t\t\t\t\tsetOpen(false);\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\"flex w-full items-center justify-between px-3 py-2 text-sm transition-colors\",\n\t\t\t\t\t\t\t\t\tisActive\n\t\t\t\t\t\t\t\t\t\t? \"bg-foreground/5 text-foreground\"\n\t\t\t\t\t\t\t\t\t\t: \"text-foreground hover:bg-foreground/5\",\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<span>{option.label}</span>\n\t\t\t\t\t\t\t\t{isActive && <Check className=\"h-4 w-4 text-primary\" />}\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t);\n\t\t\t\t\t})}\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/components/common/theme/ThemeToggle.tsx",
    "content": "\"use client\";\n\nimport { Monitor, Moon, Sun } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport { useTheme } from \"next-themes\";\nimport { useEffect, useState } from \"react\";\n\nexport function ThemeToggle() {\n\tconst { theme, setTheme } = useTheme();\n\tconst [mounted, setMounted] = useState(false);\n\tconst tTheme = useTranslations(\"theme\");\n\tconst tLayout = useTranslations(\"layout\");\n\n\tuseEffect(() => {\n\t\tsetMounted(true);\n\t}, []);\n\n\tif (!mounted) {\n\t\treturn <div className=\"h-9 w-9\" />;\n\t}\n\n\tconst themes = [\n\t\t{ value: \"light\" as const, icon: Sun, label: tTheme(\"light\") },\n\t\t{ value: \"dark\" as const, icon: Moon, label: tTheme(\"dark\") },\n\t\t{ value: \"system\" as const, icon: Monitor, label: tTheme(\"system\") },\n\t];\n\n\tconst validThemes = themes.map((t) => t.value);\n\tconst currentTheme =\n\t\ttheme && validThemes.includes(theme as (typeof validThemes)[number])\n\t\t\t? (theme as (typeof validThemes)[number])\n\t\t\t: \"system\";\n\tconst currentIndex = themes.findIndex(\n\t\t(themeItem) => themeItem.value === currentTheme,\n\t);\n\tconst currentThemeLabel =\n\t\tthemes.find((themeItem) => themeItem.value === currentTheme)?.label || \"\";\n\n\tconst CurrentIcon =\n\t\tthemes.find((themeItem) => themeItem.value === currentTheme)?.icon ||\n\t\tMonitor;\n\n\treturn (\n\t\t<button\n\t\t\ttype=\"button\"\n\t\t\tonClick={() => {\n\t\t\t\tconst nextIndex = (currentIndex + 1) % themes.length;\n\t\t\t\tconst newTheme = themes[nextIndex].value;\n\t\t\t\tsetTheme(newTheme);\n\t\t\t}}\n\t\t\tclassName=\"rounded-md p-2 text-muted-foreground transition-all duration-200 hover:bg-muted hover:text-foreground hover:shadow-md active:scale-95 active:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n\t\t\ttitle={`${tLayout(\"currentTheme\")}: ${currentThemeLabel}`}\n\t\t\taria-label={`${tLayout(\"currentTheme\")}: ${currentThemeLabel}`}\n\t\t>\n\t\t\t<CurrentIcon className=\"h-5 w-5\" />\n\t\t</button>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/components/common/ui/BackendReadyGate.tsx",
    "content": "\"use client\";\n\nimport type { ReactNode } from \"react\";\nimport { useEffect, useState } from \"react\";\nimport { isDesktop, isTauri } from \"@/lib/utils/platform\";\n\ninterface BackendReadyGateProps {\n\tchildren: ReactNode;\n}\n\nfunction getBackendHealthUrl(): string {\n\tconst baseUrl = process.env.NEXT_PUBLIC_API_URL || \"http://localhost:8100\";\n\treturn `${baseUrl}/health`;\n}\n\nexport function BackendReadyGate({ children }: BackendReadyGateProps) {\n\tconst [ready, setReady] = useState(false);\n\tconst [visible, setVisible] = useState(true);\n\tconst [phase, setPhase] = useState<\"boot\" | \"backend\">(\"boot\");\n\tconst [logs, setLogs] = useState<string[]>([]);\n\n\tuseEffect(() => {\n\t\tif (!isDesktop()) {\n\t\t\tsetReady(true);\n\t\t\tsetVisible(false);\n\t\t\treturn;\n\t\t}\n\n\t\tlet cancelled = false;\n\t\tlet unlisten: (() => void) | null = null;\n\t\tconst healthUrl = getBackendHealthUrl();\n\t\tsetPhase(\"backend\");\n\n\t\tconst setupLogListener = async () => {\n\t\t\tif (!isTauri()) return;\n\t\t\ttry {\n\t\t\t\tconst { listen } = await import(\"@tauri-apps/api/event\");\n\t\t\t\tunlisten = await listen<string>(\"backend-log\", (event) => {\n\t\t\t\t\tif (cancelled) return;\n\t\t\t\t\tsetLogs((prev) => {\n\t\t\t\t\t\tconst next = [...prev, event.payload];\n\t\t\t\t\t\treturn next.slice(-200);\n\t\t\t\t\t});\n\t\t\t\t});\n\t\t\t} catch (error) {\n\t\t\t\tsetLogs((prev) => [\n\t\t\t\t\t...prev,\n\t\t\t\t\t`Failed to listen for backend logs: ${String(error)}`,\n\t\t\t\t]);\n\t\t\t}\n\t\t};\n\n\t\tconst checkHealth = async () => {\n\t\t\ttry {\n\t\t\t\tconst response = await fetch(healthUrl, { cache: \"no-store\" });\n\t\t\t\tif (response.ok && !cancelled) {\n\t\t\t\t\tsetReady(true);\n\t\t\t\t\tsetVisible(false);\n\t\t\t\t}\n\t\t\t} catch {\n\t\t\t\t// Ignore until backend is ready\n\t\t\t}\n\t\t};\n\n\t\tconst interval = setInterval(checkHealth, 500);\n\t\tcheckHealth();\n\t\tsetupLogListener();\n\n\t\treturn () => {\n\t\t\tcancelled = true;\n\t\t\tclearInterval(interval);\n\t\t\tif (unlisten) unlisten();\n\t\t};\n\t}, []);\n\n\treturn (\n\t\t<>\n\t\t\t{children}\n\t\t\t{!ready && visible && (\n\t\t\t\t<div className=\"fixed inset-0 z-[9999] flex items-center justify-center bg-neutral-950/90 text-white backdrop-blur\">\n\t\t\t\t\t<div className=\"flex flex-col items-center gap-3 rounded-2xl border border-white/10 bg-neutral-900/80 px-6 py-5 shadow-lg\">\n\t\t\t\t\t\t<div className=\"h-8 w-8 animate-spin rounded-full border-2 border-white/20 border-t-white\" />\n\t\t\t\t\t\t<div className=\"text-sm font-medium tracking-wide\">\n\t\t\t\t\t\t\t{phase === \"boot\" ? \"正在启动前端界面\" : \"正在连接后端服务\"}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className=\"text-xs text-white/60\">首次启动可能需要几秒钟…</div>\n\t\t\t\t\t\t{logs.length > 0 && (\n\t\t\t\t\t\t\t<div className=\"mt-2 max-h-40 w-[min(560px,80vw)] overflow-auto rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-[11px] leading-5 text-white/80\">\n\t\t\t\t\t\t\t\t{logs.map((line, index) => (\n\t\t\t\t\t\t\t\t\t<div key={`${index}-${line.slice(0, 12)}`}>{line}</div>\n\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/components/common/ui/CapabilitiesSync.tsx",
    "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport { customFetcher } from \"@/lib/api/fetcher\";\nimport type { PanelFeature } from \"@/lib/config/panel-config\";\nimport { getPanelPlugins } from \"@/lib/plugins/registry\";\nimport { useUiStore } from \"@/lib/store/ui-store\";\n\ntype CapabilitiesResponse = {\n\tenabledModules?: string[];\n\tavailableModules?: string[];\n\tdisabledModules?: string[];\n\tmissingDeps?: Record<string, string[]>;\n};\n\nfunction computeBackendDisabledFeatures(\n\tcapabilities: CapabilitiesResponse,\n): PanelFeature[] {\n\tconst enabled = new Set(capabilities.enabledModules ?? []);\n\tconst available = new Set(capabilities.availableModules ?? []);\n\tconst activeModules = new Set(\n\t\t[...enabled].filter((module) => available.has(module)),\n\t);\n\n\treturn getPanelPlugins()\n\t\t.filter((plugin) => {\n\t\t\tif (!plugin.backendModules || plugin.backendModules.length === 0) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\treturn plugin.backendModules.some(\n\t\t\t\t(module) => !activeModules.has(module),\n\t\t\t);\n\t\t})\n\t\t.map((plugin) => plugin.id);\n}\n\nexport function CapabilitiesSync() {\n\tconst setBackendDisabledFeatures = useUiStore(\n\t\t(state) => state.setBackendDisabledFeatures,\n\t);\n\tconst hasRequested = useRef(false);\n\n\tuseEffect(() => {\n\t\tif (hasRequested.current) return;\n\t\thasRequested.current = true;\n\n\t\tconst loadCapabilities = async () => {\n\t\t\ttry {\n\t\t\t\tconst data = await customFetcher<CapabilitiesResponse>(\n\t\t\t\t\t\"/api/capabilities\",\n\t\t\t\t\t{\n\t\t\t\t\t\tmethod: \"GET\",\n\t\t\t\t\t},\n\t\t\t\t);\n\t\t\t\tif (!data) return;\n\t\t\t\tconst backendDisabled = computeBackendDisabledFeatures(data);\n\t\t\t\tsetBackendDisabledFeatures(backendDisabled);\n\t\t\t} catch (error) {\n\t\t\t\tconsole.warn(\"[capabilities] Failed to load backend capabilities\", error);\n\t\t\t}\n\t\t};\n\n\t\tvoid loadCapabilities();\n\t}, [setBackendDisabledFeatures]);\n\n\treturn null;\n}\n"
  },
  {
    "path": "free-todo-frontend/components/common/ui/DockTriggerZone.tsx",
    "content": "\"use client\";\n\nimport { motion } from \"framer-motion\";\nimport { useEffect, useState } from \"react\";\n\n/**\n * 底部 Dock 触发区域高亮组件\n * 用于引导用户将鼠标移至底部以触发 Dock 出现\n *\n * 注意：元素始终存在于 DOM 中（用于 driver.js 定位），\n * 只有 isVisible 为 true 时才显示视觉效果\n */\nexport function DockTriggerZone() {\n\tconst [isVisible, setIsVisible] = useState(false);\n\n\tuseEffect(() => {\n\t\tconst handleShow = () => setIsVisible(true);\n\t\tconst handleHide = () => setIsVisible(false);\n\n\t\twindow.addEventListener(\"onboarding:show-dock-trigger-zone\", handleShow);\n\t\twindow.addEventListener(\"onboarding:hide-dock-trigger-zone\", handleHide);\n\n\t\treturn () => {\n\t\t\twindow.removeEventListener(\"onboarding:show-dock-trigger-zone\", handleShow);\n\t\t\twindow.removeEventListener(\"onboarding:hide-dock-trigger-zone\", handleHide);\n\t\t};\n\t}, []);\n\n\t// 元素始终存在，但只在 isVisible 时显示视觉效果\n\treturn (\n\t\t<motion.div\n\t\t\tdata-tour=\"dock-trigger-zone\"\n\t\t\tinitial={{ opacity: 0 }}\n\t\t\tanimate={{ opacity: isVisible ? 1 : 0 }}\n\t\t\ttransition={{ duration: 0.3 }}\n\t\t\tclassName=\"fixed bottom-0 left-0 right-0 h-20 z-40 pointer-events-none\"\n\t\t\tstyle={{\n\t\t\t\tbackground: isVisible\n\t\t\t\t\t? \"linear-gradient(to top, rgba(var(--primary-rgb), 0.3), transparent)\"\n\t\t\t\t\t: \"transparent\",\n\t\t\t\tborderTop: isVisible ? \"2px dashed rgba(var(--primary-rgb), 0.5)\" : \"none\",\n\t\t\t}}\n\t\t>\n\t\t\t{/* 向下箭头动画指示 */}\n\t\t\t{isVisible && (\n\t\t\t\t<motion.div\n\t\t\t\t\tclassName=\"absolute left-1/2 -top-2 -translate-x-1/2\"\n\t\t\t\t\tanimate={{ y: [0, 8, 0] }}\n\t\t\t\t\ttransition={{ duration: 1.5, repeat: Infinity, ease: \"easeInOut\" }}\n\t\t\t\t>\n\t\t\t\t\t<svg\n\t\t\t\t\t\twidth=\"32\"\n\t\t\t\t\t\theight=\"32\"\n\t\t\t\t\t\tviewBox=\"0 0 24 24\"\n\t\t\t\t\t\tfill=\"none\"\n\t\t\t\t\t\tstroke=\"currentColor\"\n\t\t\t\t\t\tstrokeWidth=\"2\"\n\t\t\t\t\t\tstrokeLinecap=\"round\"\n\t\t\t\t\t\tstrokeLinejoin=\"round\"\n\t\t\t\t\t\tclassName=\"text-primary\"\n\t\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<path d=\"M12 5v14M19 12l-7 7-7-7\" />\n\t\t\t\t\t</svg>\n\t\t\t\t</motion.div>\n\t\t\t)}\n\t\t</motion.div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/components/common/ui/FrontendBoot.tsx",
    "content": "export function FrontendBoot() {\n\treturn (\n\t\t<div className=\"flex h-screen w-screen items-center justify-center bg-background text-foreground\">\n\t\t\t<div className=\"flex flex-col items-center gap-3 rounded-2xl border border-border/60 bg-card/80 px-6 py-5 shadow-lg\">\n\t\t\t\t<div className=\"h-8 w-8 animate-spin rounded-full border-2 border-foreground/20 border-t-foreground\" />\n\t\t\t\t<div className=\"text-sm font-medium tracking-wide\">Loading interface</div>\n\t\t\t\t<div className=\"text-xs text-muted-foreground\">Starting up...</div>\n\t\t\t</div>\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/components/common/ui/LanguageToggle.tsx",
    "content": "\"use client\";\n\nimport { Languages } from \"lucide-react\";\nimport { useRouter } from \"next/navigation\";\nimport { useTranslations } from \"next-intl\";\nimport { useEffect, useState } from \"react\";\nimport { type Locale, useLocaleStore } from \"@/lib/store/locale\";\n\nexport function LanguageToggle() {\n\tconst { locale, setLocale } = useLocaleStore();\n\tconst [mounted, setMounted] = useState(false);\n\tconst router = useRouter();\n\tconst tLang = useTranslations(\"language\");\n\tconst tLayout = useTranslations(\"layout\");\n\n\tuseEffect(() => {\n\t\tsetMounted(true);\n\t}, []);\n\n\tif (!mounted) {\n\t\treturn <div className=\"h-9 w-9\" />;\n\t}\n\n\tconst languages: { value: Locale; label: string }[] = [\n\t\t{ value: \"zh\", label: tLang(\"zh\") },\n\t\t{ value: \"en\", label: tLang(\"en\") },\n\t];\n\n\tconst handleToggle = () => {\n\t\tconst currentIndex = languages.findIndex((l) => l.value === locale);\n\t\tconst nextIndex = (currentIndex + 1) % languages.length;\n\t\tconst newLocale = languages[nextIndex].value;\n\t\tsetLocale(newLocale);\n\t\t// 使用 router.refresh() 重新获取服务端数据，无白屏闪烁\n\t\trouter.refresh();\n\t};\n\n\tconst currentLanguage = languages.find((l) => l.value === locale);\n\n\treturn (\n\t\t<button\n\t\t\ttype=\"button\"\n\t\t\tonClick={handleToggle}\n\t\t\tclassName=\"rounded-md p-2 text-muted-foreground transition-all duration-200 hover:bg-muted hover:text-foreground hover:shadow-md active:scale-95 active:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n\t\t\ttitle={`${tLayout(\"currentLanguage\")}: ${currentLanguage?.label}`}\n\t\t\taria-label={`${tLayout(\"currentLanguage\")}: ${currentLanguage?.label}`}\n\t\t>\n\t\t\t<Languages className=\"h-5 w-5\" />\n\t\t</button>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/components/common/ui/LocaleSync.tsx",
    "content": "\"use client\";\n\nimport { useRouter } from \"next/navigation\";\nimport { useLocale } from \"next-intl\";\nimport { useEffect, useRef } from \"react\";\nimport { useLocaleStore } from \"@/lib/store/locale\";\n\n/**\n * 同步 locale 到 cookie\n * 确保在刷新前 cookie 已经被正确设置\n */\nfunction syncLocaleToCookie(locale: string) {\n\tif (typeof document === \"undefined\") return;\n\t// 使用 document.cookie 设置（兼容所有浏览器）\n\t// biome-ignore lint/suspicious/noDocumentCookie: 需要直接设置 cookie 来同步语言\n\tdocument.cookie = `locale=${locale};path=/;max-age=${60 * 60 * 24 * 365};SameSite=Lax`;\n}\n\n/**\n * 语言同步组件\n *\n * 解决首次访问时语言不一致的问题：\n * - 服务端渲染时，cookie 为空，使用默认语言 (en)\n * - 客户端 hydration 后，store 检测系统语言并设置 cookie\n * - 此时页面已经用默认语言渲染，需要刷新以应用正确的语言\n *\n * 此组件在 hydration 完成后检测不一致并自动刷新页面\n * 重要：必须等待 Zustand store hydration 完成后再执行，否则会读取到未 hydrate 的初始值\n */\nexport function LocaleSync() {\n\tconst router = useRouter();\n\t// 服务端渲染时使用的 locale（来自 cookie 或默认值）\n\tconst serverLocale = useLocale();\n\t// 客户端 store 中的 locale（可能是检测系统语言得到的）\n\tconst storeLocale = useLocaleStore((state) => state.locale);\n\t// 等待 store hydration 完成\n\tconst hasHydrated = useLocaleStore((state) => state._hasHydrated);\n\tconst hasRefreshed = useRef(false);\n\n\tuseEffect(() => {\n\t\t// 必须等待 hydration 完成，否则 storeLocale 可能是未 hydrate 的初始值\n\t\tif (!hasHydrated) return;\n\n\t\t// 只在第一次检测到不一致时刷新，避免无限刷新\n\t\tif (!hasRefreshed.current && serverLocale !== storeLocale) {\n\t\t\thasRefreshed.current = true;\n\t\t\t// 确保 cookie 已设置为正确的 locale\n\t\t\tsyncLocaleToCookie(storeLocale);\n\t\t\t// 使用 router.refresh() 重新获取服务端数据\n\t\t\trouter.refresh();\n\t\t}\n\t}, [serverLocale, storeLocale, hasHydrated, router]);\n\n\treturn null;\n}\n"
  },
  {
    "path": "free-todo-frontend/components/common/ui/ScrollbarController.tsx",
    "content": "\"use client\";\n\nimport { useEffect } from \"react\";\n\n/**\n * 滚动条控制器组件\n * 监听滚动和键盘事件，控制滚动条的显示/隐藏\n * 只显示正在滚动的 panel 的滚动条\n */\nexport function ScrollbarController() {\n\tuseEffect(() => {\n\t\tconst hideTimeouts = new Map<HTMLElement, NodeJS.Timeout>();\n\n\t\t/**\n\t\t * 查找滚动事件发生的 panel 容器\n\t\t */\n\t\tconst findPanelContainer = (\n\t\t\ttarget: EventTarget | null,\n\t\t): HTMLElement | null => {\n\t\t\tif (!(target instanceof HTMLElement)) return null;\n\n\t\t\t// 向上查找，找到带有 data-panel 属性的元素\n\t\t\tlet element: HTMLElement | null = target;\n\t\t\twhile (element) {\n\t\t\t\tif (element.dataset.panel) {\n\t\t\t\t\treturn element;\n\t\t\t\t}\n\t\t\t\telement = element.parentElement;\n\t\t\t}\n\n\t\t\treturn null;\n\t\t};\n\n\t\tconst showScrollbar = (panelElement: HTMLElement) => {\n\t\t\t// 清除该 panel 之前的隐藏定时器\n\t\t\tconst existingTimeout = hideTimeouts.get(panelElement);\n\t\t\tif (existingTimeout) {\n\t\t\t\tclearTimeout(existingTimeout);\n\t\t\t\thideTimeouts.delete(panelElement);\n\t\t\t}\n\n\t\t\t// 添加显示类到对应的 panel\n\t\t\tpanelElement.classList.add(\"scrollbar-visible\");\n\t\t};\n\n\t\tconst hideScrollbar = (panelElement: HTMLElement) => {\n\t\t\t// 清除之前的定时器\n\t\t\tconst existingTimeout = hideTimeouts.get(panelElement);\n\t\t\tif (existingTimeout) {\n\t\t\t\tclearTimeout(existingTimeout);\n\t\t\t}\n\n\t\t\t// 2秒后隐藏滚动条\n\t\t\tconst timeout = setTimeout(() => {\n\t\t\t\tpanelElement.classList.remove(\"scrollbar-visible\");\n\t\t\t\thideTimeouts.delete(panelElement);\n\t\t\t}, 2000);\n\n\t\t\thideTimeouts.set(panelElement, timeout);\n\t\t};\n\n\t\t// 处理滚动事件（包括所有滚动容器）\n\t\tconst handleWheel = (event: WheelEvent) => {\n\t\t\t// 检查是否有垂直或水平滚动\n\t\t\tif (event.deltaY !== 0 || event.deltaX !== 0) {\n\t\t\t\tconst panelElement = findPanelContainer(event.target);\n\t\t\t\tif (panelElement) {\n\t\t\t\t\tshowScrollbar(panelElement);\n\t\t\t\t\thideScrollbar(panelElement);\n\t\t\t\t}\n\t\t\t}\n\t\t};\n\n\t\t// 处理滚动容器的滚动事件\n\t\tconst handleScroll = (event: Event) => {\n\t\t\tconst panelElement = findPanelContainer(event.target);\n\t\t\tif (panelElement) {\n\t\t\t\tshowScrollbar(panelElement);\n\t\t\t\thideScrollbar(panelElement);\n\t\t\t}\n\t\t};\n\n\t\t// 处理键盘滚动事件\n\t\tconst handleKeyDown = (event: KeyboardEvent) => {\n\t\t\t// 检查是否是滚动相关的按键\n\t\t\tconst scrollKeys = [\n\t\t\t\t\"ArrowUp\",\n\t\t\t\t\"ArrowDown\",\n\t\t\t\t\"ArrowLeft\",\n\t\t\t\t\"ArrowRight\",\n\t\t\t\t\"PageUp\",\n\t\t\t\t\"PageDown\",\n\t\t\t\t\"Home\",\n\t\t\t\t\"End\",\n\t\t\t];\n\n\t\t\t// 如果按下了滚动键，且没有按 Ctrl/Cmd（避免与快捷键冲突）\n\t\t\tif (scrollKeys.includes(event.key) && !event.ctrlKey && !event.metaKey) {\n\t\t\t\tconst panelElement = findPanelContainer(event.target);\n\t\t\t\tif (panelElement) {\n\t\t\t\t\tshowScrollbar(panelElement);\n\t\t\t\t\thideScrollbar(panelElement);\n\t\t\t\t}\n\t\t\t} else if (\n\t\t\t\tevent.key === \" \" &&\n\t\t\t\t!event.ctrlKey &&\n\t\t\t\t!event.metaKey &&\n\t\t\t\tevent.target instanceof HTMLElement &&\n\t\t\t\t![\"INPUT\", \"TEXTAREA\"].includes(event.target.tagName)\n\t\t\t) {\n\t\t\t\t// Space 键只在非输入元素时触发\n\t\t\t\tconst panelElement = findPanelContainer(event.target);\n\t\t\t\tif (panelElement) {\n\t\t\t\t\tshowScrollbar(panelElement);\n\t\t\t\t\thideScrollbar(panelElement);\n\t\t\t\t}\n\t\t\t}\n\t\t};\n\n\t\t// 处理触摸滚动（移动设备）\n\t\tlet touchStartY = 0;\n\t\tlet touchStartX = 0;\n\t\tlet isTouching = false;\n\n\t\tconst handleTouchStart = (event: TouchEvent) => {\n\t\t\ttouchStartY = event.touches[0].clientY;\n\t\t\ttouchStartX = event.touches[0].clientX;\n\t\t\tisTouching = true;\n\t\t};\n\n\t\tconst handleTouchMove = (event: TouchEvent) => {\n\t\t\tif (!isTouching) return;\n\n\t\t\tconst touchY = event.touches[0].clientY;\n\t\t\tconst touchX = event.touches[0].clientX;\n\n\t\t\t// 检查是否有滚动\n\t\t\tif (\n\t\t\t\tMath.abs(touchY - touchStartY) > 5 ||\n\t\t\t\tMath.abs(touchX - touchStartX) > 5\n\t\t\t) {\n\t\t\t\tconst panelElement = findPanelContainer(event.target);\n\t\t\t\tif (panelElement) {\n\t\t\t\t\tshowScrollbar(panelElement);\n\t\t\t\t\thideScrollbar(panelElement);\n\t\t\t\t}\n\t\t\t}\n\t\t};\n\n\t\tconst handleTouchEnd = () => {\n\t\t\tisTouching = false;\n\t\t};\n\n\t\t// 添加事件监听器\n\t\t// 使用捕获阶段来捕获所有滚动事件\n\t\twindow.addEventListener(\"wheel\", handleWheel, { passive: true });\n\t\twindow.addEventListener(\"scroll\", handleScroll, {\n\t\t\tpassive: true,\n\t\t\tcapture: true,\n\t\t});\n\t\twindow.addEventListener(\"keydown\", handleKeyDown);\n\t\twindow.addEventListener(\"touchstart\", handleTouchStart, {\n\t\t\tpassive: true,\n\t\t});\n\t\twindow.addEventListener(\"touchmove\", handleTouchMove, { passive: true });\n\t\twindow.addEventListener(\"touchend\", handleTouchEnd, { passive: true });\n\n\t\t// 清理函数\n\t\treturn () => {\n\t\t\twindow.removeEventListener(\"wheel\", handleWheel);\n\t\t\twindow.removeEventListener(\"scroll\", handleScroll, { capture: true });\n\t\t\twindow.removeEventListener(\"keydown\", handleKeyDown);\n\t\t\twindow.removeEventListener(\"touchstart\", handleTouchStart);\n\t\t\twindow.removeEventListener(\"touchmove\", handleTouchMove);\n\t\t\twindow.removeEventListener(\"touchend\", handleTouchEnd);\n\n\t\t\t// 清理所有定时器\n\t\t\tfor (const timeout of hideTimeouts.values()) {\n\t\t\t\tclearTimeout(timeout);\n\t\t\t}\n\t\t\thideTimeouts.clear();\n\n\t\t\t// 移除所有 panel 的滚动条显示类\n\t\t\tconst allPanels = document.querySelectorAll(\"[data-panel]\");\n\t\t\tfor (const panel of allPanels) {\n\t\t\t\tpanel.classList.remove(\"scrollbar-visible\");\n\t\t\t}\n\t\t};\n\t}, []);\n\n\treturn null; // 此组件不渲染任何内容\n}\n"
  },
  {
    "path": "free-todo-frontend/components/common/ui/SettingsToggle.tsx",
    "content": "\"use client\";\n\nimport { Settings } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport { useEffect, useState } from \"react\";\nimport { useOpenSettings } from \"@/lib/hooks/useOpenSettings\";\n\n/**\n * 设置按钮组件\n * 点击后切换到设置界面：\n * - 如果 Panel B 已激活，切换 Panel B 到设置\n * - 否则找到最宽的 Panel（A 或 C），激活并切换到设置\n */\nexport function SettingsToggle() {\n\tconst [mounted, setMounted] = useState(false);\n\tconst tLayout = useTranslations(\"layout\");\n\tconst tPage = useTranslations(\"page\");\n\n\tconst { openSettings } = useOpenSettings();\n\n\tuseEffect(() => {\n\t\tsetMounted(true);\n\t}, []);\n\n\tif (!mounted) {\n\t\treturn <div className=\"h-9 w-9\" />;\n\t}\n\n\treturn (\n\t\t<button\n\t\t\ttype=\"button\"\n\t\t\tonClick={openSettings}\n\t\t\tclassName=\"rounded-md p-2 text-foreground transition-all duration-200 hover:bg-muted hover:text-foreground hover:shadow-md active:scale-95 active:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n\t\t\ttitle={tPage(\"settingsLabel\")}\n\t\t\taria-label={tLayout(\"openSettings\")}\n\t\t\tdata-tour=\"settings-toggle\"\n\t\t>\n\t\t\t<Settings className=\"h-5 w-5\" />\n\t\t</button>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/components/common/ui/UserAvatar.tsx",
    "content": "\"use client\";\n\nimport { User } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\n\nexport function UserAvatar() {\n\tconst t = useTranslations(\"layout\");\n\n\treturn (\n\t\t<button\n\t\t\ttype=\"button\"\n\t\t\tclassName=\"flex h-9 w-9 items-center justify-center rounded-full bg-muted text-muted-foreground transition-all duration-200 hover:bg-muted/80 hover:text-foreground hover:shadow-md active:scale-95 active:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n\t\t\ttitle={t(\"userSettings\")}\n\t\t\taria-label={t(\"userSettings\")}\n\t\t>\n\t\t\t<User className=\"h-5 w-5\" />\n\t\t</button>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/components/date-picker/DateOnlyPickerCalendar.tsx",
    "content": "\"use client\";\n\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\nimport type { useTranslations } from \"next-intl\";\nimport { cn } from \"@/lib/utils\";\nimport {\n\tbuildMonthDays,\n\ttype CalendarDay,\n\tstartOfDay,\n\ttoDateKey,\n\tWEEKDAY_KEYS,\n\ttype WeekdayKey,\n} from \"./date-picker-utils\";\n\ninterface MonthNavigationProps {\n\tcurrentMonth: Date;\n\tonPrevMonth: () => void;\n\tonNextMonth: () => void;\n\ttCalendar: ReturnType<typeof useTranslations<\"calendar\">>;\n}\n\nexport function MonthNavigation({\n\tcurrentMonth,\n\tonPrevMonth,\n\tonNextMonth,\n\ttCalendar,\n}: MonthNavigationProps) {\n\treturn (\n\t\t<div className=\"flex items-center justify-between px-1 py-2\">\n\t\t\t<span className=\"text-sm font-medium\">\n\t\t\t\t{tCalendar(\"yearMonth\", {\n\t\t\t\t\tyear: currentMonth.getFullYear(),\n\t\t\t\t\tmonth: currentMonth.getMonth() + 1,\n\t\t\t\t})}\n\t\t\t</span>\n\t\t\t<div className=\"flex items-center gap-1\">\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tonClick={onPrevMonth}\n\t\t\t\t\tclassName=\"rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground\"\n\t\t\t\t>\n\t\t\t\t\t<ChevronLeft className=\"h-4 w-4\" />\n\t\t\t\t</button>\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tonClick={onNextMonth}\n\t\t\t\t\tclassName=\"rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground\"\n\t\t\t\t>\n\t\t\t\t\t<ChevronRight className=\"h-4 w-4\" />\n\t\t\t\t</button>\n\t\t\t</div>\n\t\t</div>\n\t);\n}\n\ninterface WeekdayHeaderProps {\n\ttCalendar: ReturnType<typeof useTranslations<\"calendar\">>;\n}\n\nexport function WeekdayHeader({ tCalendar }: WeekdayHeaderProps) {\n\treturn (\n\t\t<div className=\"grid grid-cols-7 px-2\">\n\t\t\t{WEEKDAY_KEYS.map((key, idx) => (\n\t\t\t\t<span\n\t\t\t\t\tkey={key}\n\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\"py-1 text-center text-xs font-medium\",\n\t\t\t\t\t\tidx >= 5 ? \"text-muted-foreground/70\" : \"text-muted-foreground\",\n\t\t\t\t\t)}\n\t\t\t\t>\n\t\t\t\t\t{tCalendar(`weekdays.${key}` as `weekdays.${WeekdayKey}`)}\n\t\t\t\t</span>\n\t\t\t))}\n\t\t</div>\n\t);\n}\n\ninterface CalendarGridProps {\n\tmonthDays: CalendarDay[];\n\tselectedDate: Date | null;\n\tonSelectDate: (day: CalendarDay) => void;\n}\n\nexport function CalendarGrid({\n\tmonthDays,\n\tselectedDate,\n\tonSelectDate,\n}: CalendarGridProps) {\n\tconst selectedKey = selectedDate ? toDateKey(selectedDate) : null;\n\tconst selectedDay = selectedDate ? startOfDay(selectedDate).getTime() : null;\n\n\treturn (\n\t\t<div className=\"grid grid-cols-7 gap-0.5 px-2 pb-2\">\n\t\t\t{monthDays.map((day, idx) => {\n\t\t\t\tconst dayKey = toDateKey(day.date);\n\t\t\t\tconst isSelected = selectedKey && dayKey === selectedKey;\n\t\t\t\tconst isToday = day.isToday;\n\t\t\t\tconst dayStart = startOfDay(day.date).getTime();\n\t\t\t\tconst isWeekend = (idx % 7) + 1 >= 6;\n\t\t\t\tconst showToday = isToday && !isSelected && selectedDay !== dayStart;\n\n\t\t\t\treturn (\n\t\t\t\t\t<button\n\t\t\t\t\t\tkey={dayKey}\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tonClick={() => onSelectDate(day)}\n\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\"relative flex items-center justify-center rounded-lg py-2 text-sm font-medium transition-colors\",\n\t\t\t\t\t\t\t!day.inCurrentMonth && \"opacity-40\",\n\t\t\t\t\t\t\tisSelected && \"bg-primary text-primary-foreground\",\n\t\t\t\t\t\t\t!isSelected && (showToday ? \"bg-primary/5 text-primary\" : \"hover:bg-muted/50\"),\n\t\t\t\t\t\t)}\n\t\t\t\t\t>\n\t\t\t\t\t\t<span\n\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\tisWeekend && !isSelected && \"text-muted-foreground/80\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{day.date.getDate()}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t</button>\n\t\t\t\t);\n\t\t\t})}\n\t\t</div>\n\t);\n}\n\nexport const buildCalendarMonthDays = buildMonthDays;\n"
  },
  {
    "path": "free-todo-frontend/components/date-picker/DateOnlyPickerPopover.tsx",
    "content": "\"use client\";\n\nimport { useTranslations } from \"next-intl\";\nimport {\n\ttype RefObject,\n\tuseCallback,\n\tuseEffect,\n\tuseMemo,\n\tuseRef,\n\tuseState,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { cn } from \"@/lib/utils\";\nimport { buildCalendarMonthDays, CalendarGrid, MonthNavigation, WeekdayHeader } from \"./DateOnlyPickerCalendar\";\nimport type { CalendarDay } from \"./date-picker-utils\";\n\ninterface DateOnlyPickerPopoverProps {\n\tanchorRef: RefObject<HTMLElement | null>;\n\tselectedDate: Date | null;\n\tonSelectDate: (date: Date) => void;\n\tonClose: () => void;\n}\n\nconst POPOVER_MARGIN = 8;\n\nexport function DateOnlyPickerPopover({\n\tanchorRef,\n\tselectedDate,\n\tonSelectDate,\n\tonClose,\n}: DateOnlyPickerPopoverProps) {\n\tconst popoverRef = useRef<HTMLDivElement>(null);\n\tconst tCalendar = useTranslations(\"calendar\");\n\tconst [currentMonth, setCurrentMonth] = useState<Date>(\n\t\t() => selectedDate ?? new Date(),\n\t);\n\n\tuseEffect(() => {\n\t\tif (selectedDate) {\n\t\t\tsetCurrentMonth(selectedDate);\n\t\t}\n\t}, [selectedDate]);\n\n\tconst monthDays = useMemo(\n\t\t() => buildCalendarMonthDays(currentMonth),\n\t\t[currentMonth],\n\t);\n\n\tconst updatePosition = useCallback(() => {\n\t\tif (typeof window === \"undefined\") return;\n\t\tconst anchor = anchorRef.current;\n\t\tconst popover = popoverRef.current;\n\t\tif (!anchor || !popover) return;\n\n\t\tconst anchorRect = anchor.getBoundingClientRect();\n\t\tconst popoverRect = popover.getBoundingClientRect();\n\t\tconst viewportWidth = window.innerWidth;\n\t\tconst viewportHeight = window.innerHeight;\n\n\t\tlet left = anchorRect.left;\n\t\tlet top = anchorRect.bottom + POPOVER_MARGIN;\n\n\t\tif (left + popoverRect.width > viewportWidth - POPOVER_MARGIN) {\n\t\t\tleft = viewportWidth - popoverRect.width - POPOVER_MARGIN;\n\t\t}\n\t\tif (left < POPOVER_MARGIN) {\n\t\t\tleft = POPOVER_MARGIN;\n\t\t}\n\t\tif (top + popoverRect.height > viewportHeight - POPOVER_MARGIN) {\n\t\t\ttop = anchorRect.top - popoverRect.height - POPOVER_MARGIN;\n\t\t}\n\t\tif (top < POPOVER_MARGIN) {\n\t\t\ttop = POPOVER_MARGIN;\n\t\t}\n\n\t\tpopover.style.left = `${Math.round(left)}px`;\n\t\tpopover.style.top = `${Math.round(top)}px`;\n\t}, [anchorRef]);\n\n\tuseEffect(() => {\n\t\tupdatePosition();\n\t\tconst handleResize = () => updatePosition();\n\t\twindow.addEventListener(\"resize\", handleResize);\n\t\twindow.addEventListener(\"scroll\", handleResize, true);\n\n\t\treturn () => {\n\t\t\twindow.removeEventListener(\"resize\", handleResize);\n\t\t\twindow.removeEventListener(\"scroll\", handleResize, true);\n\t\t};\n\t}, [updatePosition]);\n\n\tuseEffect(() => {\n\t\tconst handleClickOutside = (event: MouseEvent) => {\n\t\t\tconst target = event.target as Node;\n\t\t\tif (anchorRef.current?.contains(target)) return;\n\t\t\tif (popoverRef.current?.contains(target)) return;\n\t\t\tonClose();\n\t\t};\n\n\t\tconst handleKeyDown = (event: KeyboardEvent) => {\n\t\t\tif (event.key === \"Escape\") {\n\t\t\t\tonClose();\n\t\t\t}\n\t\t};\n\n\t\tdocument.addEventListener(\"mousedown\", handleClickOutside);\n\t\tdocument.addEventListener(\"keydown\", handleKeyDown);\n\n\t\treturn () => {\n\t\t\tdocument.removeEventListener(\"mousedown\", handleClickOutside);\n\t\t\tdocument.removeEventListener(\"keydown\", handleKeyDown);\n\t\t};\n\t}, [anchorRef, onClose]);\n\n\tconst handlePrevMonth = () => {\n\t\tsetCurrentMonth((prev) => {\n\t\t\tconst next = new Date(prev);\n\t\t\tnext.setMonth(next.getMonth() - 1);\n\t\t\treturn next;\n\t\t});\n\t};\n\n\tconst handleNextMonth = () => {\n\t\tsetCurrentMonth((prev) => {\n\t\t\tconst next = new Date(prev);\n\t\t\tnext.setMonth(next.getMonth() + 1);\n\t\t\treturn next;\n\t\t});\n\t};\n\n\tconst handleSelectDate = (day: CalendarDay) => {\n\t\tonSelectDate(day.date);\n\t\tonClose();\n\t\tif (day.date.getMonth() !== currentMonth.getMonth()) {\n\t\t\tsetCurrentMonth(day.date);\n\t\t}\n\t};\n\n\tif (typeof document === \"undefined\") {\n\t\treturn null;\n\t}\n\n\treturn createPortal(\n\t\t<div className=\"fixed inset-0 z-[10000] pointer-events-none\">\n\t\t\t<div\n\t\t\t\tref={popoverRef}\n\t\t\t\tclassName={cn(\n\t\t\t\t\t\"pointer-events-auto w-[320px] max-w-[90vw] overflow-hidden rounded-2xl border border-border bg-popover text-popover-foreground shadow-[0_30px_60px_-40px_oklch(var(--primary)/0.4)]\",\n\t\t\t\t)}\n\t\t\t\tstyle={{ position: \"absolute\", left: -9999, top: -9999 }}\n\t\t\t>\n\t\t\t\t<div className=\"px-4 py-3\">\n\t\t\t\t\t<MonthNavigation\n\t\t\t\t\t\tcurrentMonth={currentMonth}\n\t\t\t\t\t\tonPrevMonth={handlePrevMonth}\n\t\t\t\t\t\tonNextMonth={handleNextMonth}\n\t\t\t\t\t\ttCalendar={tCalendar}\n\t\t\t\t\t/>\n\t\t\t\t\t<WeekdayHeader tCalendar={tCalendar} />\n\t\t\t\t\t<CalendarGrid\n\t\t\t\t\t\tmonthDays={monthDays}\n\t\t\t\t\t\tselectedDate={selectedDate}\n\t\t\t\t\t\tonSelectDate={handleSelectDate}\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>,\n\t\tdocument.body,\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/components/date-picker/date-picker-utils.ts",
    "content": "\"use client\";\n\nexport interface CalendarDay {\n\tdate: Date;\n\tinCurrentMonth: boolean;\n\tisToday: boolean;\n}\n\nconst pad = (value: number) => `${value}`.padStart(2, \"0\");\n\nexport const toDateKey = (date: Date) =>\n\t`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;\n\nexport const startOfDay = (date: Date) => {\n\tconst next = new Date(date);\n\tnext.setHours(0, 0, 0, 0);\n\treturn next;\n};\n\nconst addDays = (date: Date, days: number) => {\n\tconst next = new Date(date);\n\tnext.setDate(next.getDate() + days);\n\treturn next;\n};\n\nconst startOfWeek = (date: Date) => {\n\tconst day = date.getDay();\n\tconst diff = (day + 6) % 7;\n\treturn addDays(date, -diff);\n};\n\nconst startOfMonth = (date: Date) =>\n\tstartOfDay(new Date(date.getFullYear(), date.getMonth(), 1));\n\nexport const buildMonthDays = (currentDate: Date): CalendarDay[] => {\n\tconst start = startOfMonth(currentDate);\n\tconst gridStart = startOfWeek(start);\n\tconst todayKey = toDateKey(new Date());\n\n\treturn Array.from({ length: 42 }, (_, idx) => {\n\t\tconst date = addDays(gridStart, idx);\n\t\treturn {\n\t\t\tdate,\n\t\t\tinCurrentMonth: date.getMonth() === currentDate.getMonth(),\n\t\t\tisToday: toDateKey(date) === todayKey,\n\t\t};\n\t});\n};\n\nexport const WEEKDAY_KEYS = [\n\t\"monday\",\n\t\"tuesday\",\n\t\"wednesday\",\n\t\"thursday\",\n\t\"friday\",\n\t\"saturday\",\n\t\"sunday\",\n] as const;\n\nexport type WeekdayKey = (typeof WEEKDAY_KEYS)[number];\n"
  },
  {
    "path": "free-todo-frontend/components/island/DynamicIsland.tsx",
    "content": "\"use client\";\n\nimport { useQueryClient } from \"@tanstack/react-query\";\nimport { AnimatePresence, motion } from \"framer-motion\";\nimport type React from \"react\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { unwrapApiData } from \"@/lib/api/fetcher\";\nimport { listTodosApiTodosGet } from \"@/lib/generated/todos/todos\";\nimport { IslandMode } from \"@/lib/island/types\";\nimport { queryKeys } from \"@/lib/query/keys\";\nimport { useUiStore } from \"@/lib/store/ui-store\";\nimport type { TodoListResponse } from \"@/lib/types\";\nimport {\n  FloatContent,\n  PopupContent,\n} from \"./IslandContent\";\nimport { IslandSidebarContent } from \"./IslandSidebarContent\";\n\ninterface DynamicIslandProps {\n  mode: IslandMode;\n  onModeChange?: (mode: IslandMode) => void;\n}\n\nconst DynamicIsland: React.FC<DynamicIslandProps> = ({ mode, onModeChange }) => {\n  const prevModeRef = useRef<IslandMode | null>(null);\n  const queryClient = useQueryClient();\n  const [popupTodos, setPopupTodos] = useState<{ id: number; name: string }[]>([]);\n  const lastTodoIdsRef = useRef<number[]>([]);\n  const seenDraftIdsRef = useRef<number[]>([]);\n  const setPanelFeature = useUiStore((state) => state.setPanelFeature);\n  const togglePanelB = useUiStore((state) => state.togglePanelB);\n  const isPanelBOpen = useUiStore((state) => state.isPanelBOpen);\n  const [isDragging, setIsDragging] = useState(false);\n  const [anchorPoint, setAnchorPoint] = useState<'top' | 'bottom' | null>('top');\n  const [currentY, setCurrentY] = useState(20);\n  const [screenHeight, setScreenHeight] = useState(1080);\n\n  // 自定义拖拽处理器（仅允许垂直拖动）\n  const handleDragStart = useCallback((e: React.MouseEvent) => {\n    // 仅在非全屏模式下允许拖拽\n    if (mode === IslandMode.FULLSCREEN) return;\n\n    // 仅响应左键\n    if (e.button !== 0) return;\n\n    setIsDragging(true);\n\n    // 发送拖拽开始事件到主进程\n    if (typeof window !== \"undefined\" && window.electronAPI?.islandDragStart) {\n      window.electronAPI.islandDragStart(e.screenY);\n    }\n  }, [mode]);\n\n  // 用于 FLOAT/POPUP 模式的整体拖拽（整个区域可拖）\n  const handleMouseDown = useCallback((e: React.MouseEvent) => {\n    // SIDEBAR 模式由 Header 独立处理，不在此处理\n    if (mode === IslandMode.SIDEBAR) return;\n    handleDragStart(e);\n  }, [mode, handleDragStart]);\n\n  const handleMouseMove = useCallback((e: MouseEvent) => {\n    if (!isDragging) return;\n\n    // 发送拖拽移动事件到主进程\n    if (typeof window !== \"undefined\" && window.electronAPI?.islandDragMove) {\n      window.electronAPI.islandDragMove(e.screenY);\n    }\n  }, [isDragging]);\n\n  const handleMouseUp = useCallback(() => {\n    if (!isDragging) return;\n\n    setIsDragging(false);\n\n    // 发送拖拽结束事件到主进程\n    if (typeof window !== \"undefined\" && window.electronAPI?.islandDragEnd) {\n      window.electronAPI.islandDragEnd();\n    }\n  }, [isDragging]);\n\n  // 设置全局鼠标事件监听器\n  useEffect(() => {\n    if (isDragging) {\n      window.addEventListener(\"mousemove\", handleMouseMove);\n      window.addEventListener(\"mouseup\", handleMouseUp);\n\n      return () => {\n        window.removeEventListener(\"mousemove\", handleMouseMove);\n        window.removeEventListener(\"mouseup\", handleMouseUp);\n      };\n    }\n  }, [isDragging, handleMouseMove, handleMouseUp]);\n\n  // 监听位置和锚点更新\n  useEffect(() => {\n    if (typeof window !== \"undefined\" && window.electronAPI) {\n      // 监听位置更新（拖拽时）\n      const cleanupPosition = window.electronAPI.onIslandPositionUpdate?.((data) => {\n        setCurrentY(data.y);\n        setScreenHeight(data.screenHeight);\n      });\n\n      // 监听锚点更新（模式切换时）\n      const cleanupAnchor = window.electronAPI.onIslandAnchorUpdate?.((data) => {\n        setAnchorPoint(data.anchor);\n        setCurrentY(data.y);\n      });\n\n      return () => {\n        cleanupPosition?.();\n        cleanupAnchor?.();\n      };\n    }\n  }, []);\n\n  // 监听 draft 状态的待办变化：有新 draft 待办时，如果当前是 FLOAT 模式，则切换到 POPUP 模式并展示待办名称。\n  // 用户从 POPUP 点击进入 SIDEBAR 后，同一批 draft 不会再次触发 POPUP。\n  useEffect(() => {\n    let isMounted = true;\n\n    const fetchDraftTodos = async () => {\n      try {\n        const result = await listTodosApiTodosGet({\n          status: \"draft\",\n          limit: 3,\n          offset: 0,\n        });\n\n        const data = unwrapApiData<TodoListResponse>(result);\n        const todos = data?.todos ?? [];\n        const ids = todos\n          .map((t) => t.id)\n          .filter((id): id is number => typeof id === \"number\");\n\n        if (!isMounted || ids.length === 0) {\n          return;\n        }\n\n        const lastTodoIds = lastTodoIdsRef.current;\n        const sameAsLast =\n          ids.length === lastTodoIds.length &&\n          ids.every((id, idx) => id === lastTodoIds[idx]);\n\n        if (sameAsLast) {\n          return;\n        }\n\n        lastTodoIdsRef.current = ids;\n\n        // 如果这一批 draft 已经通过 POPUP 被用户处理过，则不再弹出\n        const seenIds = seenDraftIdsRef.current;\n        const sameAsSeen =\n          ids.length === seenIds.length &&\n          ids.every((id, idx) => id === seenIds[idx]);\n\n        if (sameAsSeen) {\n          return;\n        }\n\n        const simplified = todos\n          .filter((t) => typeof t.id === \"number\")\n          .map((t) => ({\n            id: t.id as number,\n            name: t.name || \"未命名待办\",\n          }));\n\n        if (!isMounted || simplified.length === 0) {\n          return;\n        }\n\n        setPopupTodos(simplified);\n\n        // 草稿待办有更新时，让所有 Todo 查询变为 stale 并触发刷新，\n        // 确保 Sidebar 中的 Todo 列表能及时显示最新的 draft 任务。\n        try {\n          void queryClient.invalidateQueries({ queryKey: queryKeys.todos.all });\n        } catch {\n          // 静默失败，避免影响后续逻辑\n        }\n\n        if (mode === IslandMode.FLOAT && onModeChange) {\n          onModeChange(IslandMode.POPUP);\n        }\n      } catch {\n        // 静默失败，避免打扰用户\n      }\n    };\n\n    // 初次加载时获取一次\n    void fetchDraftTodos();\n\n    const timer = window.setInterval(() => {\n      void fetchDraftTodos();\n    }, 1000);\n\n    return () => {\n      isMounted = false;\n      window.clearInterval(timer);\n    };\n  }, [mode, onModeChange, queryClient]);\n\n  // Electron Click-Through Handling & Window Resizing\n  useEffect(() => {\n    const setIgnoreMouse = (ignore: boolean) => {\n      // 使用 electronAPI（通过 preload 暴露）\n      if (typeof window !== \"undefined\" && window.electronAPI) {\n        try {\n          if (ignore) {\n            window.electronAPI.setIgnoreMouseEvents(true, { forward: true });\n          } else {\n            window.electronAPI.setIgnoreMouseEvents(false);\n          }\n        } catch (e) {\n          console.error(\"Electron API call failed\", e);\n        }\n      }\n    };\n\n    // Resize window based on mode\n    const resizeWindow = () => {\n      if (typeof window !== \"undefined\" && prevModeRef.current !== mode) {\n        try {\n          // 尝试使用 electronAPI\n          if (window.electronAPI?.islandResizeWindow) {\n            window.electronAPI.islandResizeWindow(mode);\n          }\n          // 降级：使用 require('electron')\n          else if (typeof window !== \"undefined\" && \"require\" in window) {\n            // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call\n            const electron = (window as { require: (module: string) => { ipcRenderer: { send: (channel: string, ...args: unknown[]) => void } } }).require(\"electron\");\n            electron.ipcRenderer.send(\"resize-window\", mode);\n          }\n          prevModeRef.current = mode;\n        } catch (e) {\n          console.error(\"Failed to resize window\", e);\n        }\n      }\n    };\n\n    // Resize window when mode changes\n    if (prevModeRef.current !== null) {\n      resizeWindow();\n    } else {\n      if (mode !== IslandMode.FLOAT) {\n        resizeWindow();\n      } else {\n        prevModeRef.current = mode;\n      }\n    }\n\n    // Always allow mouse events\n    setIgnoreMouse(false);\n  }, [mode]);\n\n  const getLayoutState = (mode: IslandMode) => {\n    switch (mode) {\n      case IslandMode.FLOAT:\n        return {\n          width: \"100%\",\n          height: \"100%\",\n          borderRadius: 9999, // 完美胶囊形状 (fully rounded, 与 rounded-full 语义一致)\n        };\n      case IslandMode.POPUP:\n        return {\n          width: \"100%\",\n          height: \"100%\",\n          borderRadius: 32,\n        };\n      case IslandMode.SIDEBAR:\n        return {\n          width: \"100%\",\n          height: \"100%\",\n          borderRadius: 48,\n        };\n      case IslandMode.FULLSCREEN:\n        return {\n          width: \"100%\",\n          height: \"100%\",\n          borderRadius: 0,\n        };\n      default:\n        return {\n          width: \"100%\",\n          height: \"100%\",\n          borderRadius: 28,\n        };\n    }\n  };\n\n  const layoutState = getLayoutState(mode);\n  const isFullscreen = mode === IslandMode.FULLSCREEN;\n\n  // 计算 transform-origin 基于锚点\n  const getTransformOrigin = (): string => {\n    if (anchorPoint === null || mode === IslandMode.FULLSCREEN) {\n      return \"center right\";\n    }\n\n    // 对于 SIDEBAR 模式，使用智能锚点\n    if (mode === IslandMode.SIDEBAR) {\n      return `${anchorPoint} right`;\n    }\n\n    // 对于 FLOAT/POPUP 模式，根据当前位置决定\n    const isInUpperHalf = currentY < screenHeight / 2;\n    return isInUpperHalf ? \"top right\" : \"bottom right\";\n  };\n\n  return (\n    <div className=\"relative w-full h-full pointer-events-none overflow-hidden\">\n      <motion.div\n        layout\n        initial={false}\n        animate={{\n          width: layoutState.width,\n          height: layoutState.height,\n          borderRadius: layoutState.borderRadius,\n        }}\n        transition={{\n          type: \"spring\",\n          stiffness: 340,\n          damping: 28,\n          mass: 0.6,\n          restDelta: 0.001,\n        }}\n        className={`absolute overflow-hidden pointer-events-auto ${\n          (mode === IslandMode.SIDEBAR || mode === IslandMode.FULLSCREEN) ? \"bg-background\" : \"\"\n        }`}\n        onMouseDown={handleMouseDown}\n        style={{\n          right: 0,\n          bottom: 0,\n          transformOrigin: getTransformOrigin(),\n          // FLOAT/POPUP: 整个区域可拖拽，显示 ns-resize 光标\n          // SIDEBAR: 仅 Header 可拖拽，主区域使用默认光标\n          // FULLSCREEN: 不可拖拽\n          cursor: (mode === IslandMode.FLOAT || mode === IslandMode.POPUP)\n            ? (isDragging ? \"grabbing\" : \"ns-resize\")\n            : \"default\",\n          // Only apply box-shadow for SIDEBAR mode (FLOAT/POPUP are fully transparent, FULLSCREEN has no shadow)\n          boxShadow: mode === IslandMode.SIDEBAR\n            ? \"0px 20px 50px -10px rgba(0, 0, 0, 0.5), 0px 10px 20px -10px rgba(0,0,0,0.3)\"\n            : \"none\",\n        }}\n      >\n        {/* 背景层 - 仅在 SIDEBAR/FULLSCREEN 模式显示（FLOAT/POPUP 完全透明） */}\n        {(mode === IslandMode.SIDEBAR || mode === IslandMode.FULLSCREEN) && (\n          <div\n            className={`absolute inset-0 bg-primary-foreground/90 dark:bg-accent/90 backdrop-blur-[80px] transition-colors duration-700 ease-out ${\n              isFullscreen ? \"bg-primary-foreground/98 dark:bg-accent/98\" : \"\"\n            }`}\n          />\n        )}\n\n        {/* 噪点纹理 - 仅在 SIDEBAR/FULLSCREEN 模式显示 */}\n        {(mode === IslandMode.SIDEBAR || mode === IslandMode.FULLSCREEN) && (\n          <div className=\"absolute inset-0 opacity-[0.035] bg-[url('https://grainy-gradients.vercel.app/noise.svg')] pointer-events-none mix-blend-overlay\" />\n        )}\n\n        {/* 光晕效果 - 仅在 SIDEBAR/FULLSCREEN 模式显示 */}\n        {(mode === IslandMode.SIDEBAR || mode === IslandMode.FULLSCREEN) && (\n          <div className=\"absolute inset-0\">\n            <div className=\"absolute top-[-50%] left-[-20%] w-full h-full rounded-full bg-primary/10 blur-[120px] mix-blend-screen\" />\n            <div className=\"absolute bottom-[-20%] right-[-20%] w-[80%] h-[80%] rounded-full bg-primary/8 blur-[120px] mix-blend-screen\" />\n          </div>\n        )}\n\n        {/* 边框 - 仅在 SIDEBAR 模式显示 */}\n        {mode === IslandMode.SIDEBAR && (\n          <div className=\"absolute inset-0 rounded-[inherit] border border-border pointer-events-none shadow-[inset_0_0_20px_oklch(var(--foreground)/0.03)]\" />\n        )}\n\n        {/* 内容区域 */}\n        <div className=\"absolute inset-0 w-full h-full text-foreground font-sans antialiased overflow-hidden\">\n          <AnimatePresence>\n            {mode === IslandMode.FLOAT && (\n              <motion.div key=\"float\" className=\"absolute inset-0 w-full h-full\">\n                <FloatContent onModeChange={onModeChange} />\n              </motion.div>\n            )}\n            {mode === IslandMode.POPUP && (\n              <motion.div key=\"popup\" className=\"absolute inset-0 w-full h-full\">\n                <PopupContent\n                  todos={popupTodos}\n                  onOpenSidebar={() => {\n                    // 记录这一批 draft 已被用户查看，避免再次自动弹出 POPUP\n                    if (popupTodos.length > 0) {\n                      seenDraftIdsRef.current = popupTodos.map((t) => t.id);\n                    }\n                    // 确保 SIDEBAR 中有 TODO 面板：将 panelB 设为 todos 并打开\n                    try {\n                      setPanelFeature(\"panelB\", \"todos\");\n                      if (!isPanelBOpen) {\n                        togglePanelB();\n                      }\n                    } catch {\n                      // 静默失败，避免影响模式切换\n                    }\n                    if (onModeChange) {\n                      onModeChange(IslandMode.SIDEBAR);\n                    }\n                  }}\n                />\n              </motion.div>\n            )}\n            {mode === IslandMode.SIDEBAR && (\n              <motion.div key=\"sidebar\" className=\"absolute inset-0 w-full h-full\">\n                <IslandSidebarContent\n                  onModeChange={onModeChange || (() => {})}\n                  onHeaderDragStart={handleDragStart}\n                  isDragging={isDragging}\n                />\n              </motion.div>\n            )}\n          </AnimatePresence>\n        </div>\n      </motion.div>\n    </div>\n  );\n};\n\nexport default DynamicIsland;\n"
  },
  {
    "path": "free-todo-frontend/components/island/IslandContent.tsx",
    "content": "\"use client\";\n\nimport { motion, type Variants } from \"framer-motion\";\nimport {\n  Camera,\n  CheckCircle2,\n  Hexagon,\n  MessageCircle,\n  Mic,\n} from \"lucide-react\";\nimport Image from \"next/image\";\nimport type React from \"react\";\nimport { IslandMode } from \"@/lib/island/types\";\n\nconst fadeVariants: Variants = {\n  initial: { opacity: 0, filter: \"blur(8px)\", scale: 0.98 },\n  animate: {\n    opacity: 1,\n    filter: \"blur(0px)\",\n    scale: 1,\n    transition: { duration: 0.4, ease: \"easeOut\", delay: 0.1 },\n  },\n  exit: { opacity: 0, filter: \"blur(8px)\", scale: 1.05, transition: { duration: 0.2 } },\n};\n\n// 图标按钮组件 - 胶囊设计（无独立背景）\ninterface IconButtonProps {\n  icon: React.ReactNode;\n  onClick?: () => void;\n  title?: string;\n  color?: string;\n  hoverBgColor?: string;\n}\n\nconst IconButton: React.FC<IconButtonProps> = ({ icon, onClick, title, color, hoverBgColor }) => (\n  <button\n    type=\"button\"\n    onClick={onClick}\n    title={title}\n    className={`w-8 h-8 flex items-center justify-center rounded-full\n               transition-all duration-200 ease-out\n               hover:scale-110 active:scale-95\n               ${hoverBgColor || 'hover:bg-accent/30'}\n               ${color || 'text-muted-foreground hover:text-foreground'}`}\n    style={{\n      // @ts-expect-error - WebkitAppRegion is valid in Electron\n      WebkitAppRegion: \"no-drag\",\n    }}\n  >\n    {icon}\n  </button>\n);\n\ninterface FloatContentProps {\n  onModeChange?: (mode: IslandMode) => void;\n}\n\n// --- 1. FLOAT STATE: 三个功能图标 - 录音、截图、全屏 ---\n// 紧凑胶囊设计：完美的圆角胶囊，图标靠近边缘，黄金比例布局\nexport const FloatContent: React.FC<FloatContentProps> = ({ onModeChange }) => (\n  <motion.div\n    variants={fadeVariants}\n    initial=\"initial\"\n    animate=\"animate\"\n    exit=\"exit\"\n    className=\"w-full h-full flex items-center justify-center relative\"\n  >\n    {/* 胶囊容器 - 统一背景，完美圆角，填满整个窗口 */}\n    <div className=\"w-full h-full rounded-full bg-card/95 backdrop-blur-md\n                    border-2 border-border/60 shadow-xl\n                    flex items-center justify-between px-5\">\n      {/* 录音按钮 - 红色 */}\n      <IconButton\n        icon={<Mic size={16} strokeWidth={2.5} />}\n        title=\"开始录音\"\n        color=\"text-red-500 hover:text-red-400\"\n        hoverBgColor=\"hover:bg-red-500/10\"\n        onClick={() => {\n          // TODO: 触发录音功能，可能会切换到形态2\n          console.log(\"Start recording\");\n        }}\n      />\n\n      {/* 截图按钮 - 绿色 */}\n      <IconButton\n        icon={<Camera size={16} strokeWidth={2.5} />}\n        title=\"截图\"\n        color=\"text-green-500 hover:text-green-400\"\n        hoverBgColor=\"hover:bg-green-500/10\"\n        onClick={() => {\n          // TODO: 触发截图功能，可能会切换到形态2\n          console.log(\"Take screenshot\");\n        }}\n      />\n\n      {/* 全屏按钮 - 蓝色，点击进入形态3 */}\n      <IconButton\n        icon={<Hexagon size={16} strokeWidth={2.5} />}\n        title=\"展开\"\n        color=\"text-primary hover:text-primary/80\"\n        hoverBgColor=\"hover:bg-primary/10\"\n        onClick={() => {\n          // 切换到侧边栏模式（形态3）\n          onModeChange?.(IslandMode.SIDEBAR);\n        }}\n      />\n    </div>\n  </motion.div>\n);\n\n// --- 2. POPUP STATE: FreeTodo 风格的通知弹窗 ---\ninterface PopupContentProps {\n  todos: { id: number; name: string }[];\n  onOpenSidebar?: () => void;\n}\n\nexport const PopupContent: React.FC<PopupContentProps> = ({ todos, onOpenSidebar }) => {\n  const todoCount = todos.length;\n\n  return (\n  <motion.div\n    variants={fadeVariants}\n    initial=\"initial\"\n    animate=\"animate\"\n    exit=\"exit\"\n    className=\"w-full h-full flex items-center justify-center relative\"\n  >\n    {/* 弹窗容器 - 与 FloatContent 相同的背景样式 */}\n    <div className=\"w-full h-full rounded-[32px] bg-card/95 backdrop-blur-md\n                    border-2 border-border/60 shadow-xl\n                    p-4 flex items-center gap-4 relative overflow-hidden\">\n      {/* Background Accent */}\n      <div className=\"absolute -left-4 top-0 w-24 h-full bg-linear-to-r from-primary/10 to-transparent blur-lg\" />\n\n      {/* Logo */}\n      <div className=\"relative shrink-0 z-10\">\n        <div className=\"w-14 h-14 rounded-2xl border border-border overflow-hidden shadow-lg bg-card flex items-center justify-center\">\n          {/* Light mode logo */}\n          <Image\n            src=\"/free-todo-logos/free_todo_icon_4_dark_with_grid.png\"\n            alt=\"Free Todo Logo\"\n            width={36}\n            height={36}\n            className=\"object-contain block dark:hidden\"\n          />\n          {/* Dark mode logo */}\n          <Image\n            src=\"/free-todo-logos/free_todo_icon_4_with_grid.png\"\n            alt=\"Free Todo Logo\"\n            width={36}\n            height={36}\n            className=\"object-contain hidden dark:block\"\n          />\n        </div>\n        <div className=\"absolute -bottom-0.5 -right-0.5 w-5 h-5 bg-emerald-500 border-2 border-card/95 rounded-full z-10 flex items-center justify-center\">\n          <CheckCircle2 size={10} className=\"text-white\" />\n        </div>\n      </div>\n\n      {/* Message Content */}\n      <div className=\"flex flex-col flex-1 min-w-0 justify-center z-10\">\n        <div className=\"flex items-center justify-between mb-1\">\n          <span className=\"text-base font-semibold text-foreground tracking-tight\">\n            待办提醒\n          </span>\n          <span className=\"text-[10px] text-muted-foreground font-medium\">刚刚</span>\n        </div>\n        <p className=\"text-sm text-muted-foreground leading-snug line-clamp-2\">\n          {todoCount > 0\n            ? `已识别出 ${todoCount} 条待办，点击查看详情`\n            : \"已识别出新的待办，点击查看详情\"}\n        </p>\n        {todoCount > 0 && (\n          <div className=\"mt-1.5 max-h-16 overflow-hidden\">\n            <div className=\"flex flex-wrap gap-1.5\">\n              {todos.map((todo) => (\n                <span\n                  key={todo.id}\n                  className=\"px-2 py-0.5 rounded-full bg-accent/70 border border-border/60 text-[11px] text-foreground/90 max-w-[140px] truncate\"\n                  title={todo.name}\n                >\n                  {todo.name || \"未命名待办\"}\n                </span>\n              ))}\n            </div>\n          </div>\n        )}\n        <div className=\"flex items-center gap-2 mt-2.5\">\n          <button\n            type=\"button\"\n            onClick={onOpenSidebar}\n            className=\"flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-accent border border-border hover:bg-accent/80 transition-colors cursor-pointer\"\n          >\n            <MessageCircle size={12} className=\"text-primary\" />\n            <span className=\"text-[11px] text-muted-foreground font-medium\">查看详情</span>\n          </button>\n        </div>\n      </div>\n    </div>\n  </motion.div>\n  );\n};\n"
  },
  {
    "path": "free-todo-frontend/components/island/IslandFullscreenContent.tsx",
    "content": "\"use client\";\n\n/**\n * Island 全屏内容组件\n * 在 FULLSCREEN 模式下显示完整的 FreeTodo 三栏面板布局\n * 直接使用 FreeTodo 原有的样式，保持一致性\n */\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { IslandHeader } from \"@/components/island/IslandHeader\";\nimport { PanelRegion } from \"@/components/layout/PanelRegion\";\nimport type { PanelFeature } from \"@/lib/config/panel-config\";\nimport { GlobalDndProvider } from \"@/lib/dnd\";\nimport { usePanelResize } from \"@/lib/hooks/usePanelResize\";\nimport { IslandMode } from \"@/lib/island/types\";\nimport { useUiStore } from \"@/lib/store/ui-store\";\n\ninterface IslandFullscreenContentProps {\n  onModeChange: (mode: IslandMode) => void;\n}\n\nexport function IslandFullscreenContent({ onModeChange }: IslandFullscreenContentProps) {\n  const containerRef = useRef<HTMLDivElement>(null);\n  const [dimensions, setDimensions] = useState({ width: 1920, height: 1080 });\n  const [_mounted, setMounted] = useState(false);\n  const [isDraggingPanelA, setIsDraggingPanelA] = useState(false);\n  const [isDraggingPanelC, setIsDraggingPanelC] = useState(false);\n\n  const {\n    isPanelCOpen,\n    isPanelBOpen,\n    panelCWidth,\n    setPanelAWidth,\n    setPanelCWidth,\n    setPanelFeature,\n    getAvailableFeatures,\n  } = useUiStore();\n\n  // 确保三栏全部打开，并确保所有面板都有功能分配\n  useEffect(() => {\n    const state = useUiStore.getState();\n    const updates: Partial<typeof state> = {};\n    if (!state.isPanelAOpen) updates.isPanelAOpen = true;\n    if (!state.isPanelBOpen) updates.isPanelBOpen = true;\n    if (!state.isPanelCOpen) updates.isPanelCOpen = true;\n    if (Object.keys(updates).length > 0) {\n      useUiStore.setState(updates);\n    }\n\n    // 确保所有面板都有功能分配\n    const currentFeatureMap = state.panelFeatureMap;\n    const availableFeatures = getAvailableFeatures();\n\n    // 定义每个面板的优先功能（如果没有分配）\n    const preferredFeatures: Record<\"panelA\" | \"panelB\" | \"panelC\", PanelFeature> = {\n      panelA: \"todos\",\n      panelB: \"chat\",\n      panelC: \"todoDetail\",\n    };\n\n    // 检查并分配缺失的功能\n    ([\"panelA\", \"panelB\", \"panelC\"] as const).forEach((position) => {\n      if (!currentFeatureMap[position]) {\n        // 该位置没有功能，需要分配\n        const preferred = preferredFeatures[position];\n\n        // 优先使用偏好功能（如果可用），否则使用第一个可用功能\n        let featureToAssign: PanelFeature | null = null;\n\n        if (availableFeatures.includes(preferred)) {\n          featureToAssign = preferred;\n        } else if (availableFeatures.length > 0) {\n          featureToAssign = availableFeatures[0];\n        }\n\n        if (featureToAssign) {\n          setPanelFeature(position, featureToAssign);\n          // 更新可用功能列表（移除已分配的）\n          const index = availableFeatures.indexOf(featureToAssign);\n          if (index > -1) {\n            availableFeatures.splice(index, 1);\n          }\n        }\n      }\n    });\n  }, [setPanelFeature, getAvailableFeatures]);\n\n  useEffect(() => {\n    setMounted(true);\n  }, []);\n\n  // 设置全局调整大小光标\n  const setGlobalResizeCursor = useCallback((enabled: boolean) => {\n    if (typeof document === \"undefined\") return;\n    document.body.style.cursor = enabled ? \"col-resize\" : \"\";\n    document.body.style.userSelect = enabled ? \"none\" : \"\";\n  }, []);\n\n  // 清理光标状态\n  useEffect(() => {\n    return () => setGlobalResizeCursor(false);\n  }, [setGlobalResizeCursor]);\n\n  // 使用 usePanelResize hook 进行面板拖拽调整\n  const { handlePanelAResizePointerDown, handlePanelCResizePointerDown } = usePanelResize({\n    containerRef,\n    isPanelBOpen,\n    isPanelCOpen,\n    panelCWidth,\n    setPanelAWidth,\n    setPanelCWidth,\n    setIsDraggingPanelA,\n    setIsDraggingPanelC,\n    setGlobalResizeCursor,\n  });\n\n  // 监听窗口尺寸变化\n  useEffect(() => {\n    const updateDimensions = () => {\n      setDimensions({\n        width: window.innerWidth,\n        height: window.innerHeight,\n      });\n    };\n\n    updateDimensions();\n    window.addEventListener(\"resize\", updateDimensions);\n    return () => window.removeEventListener(\"resize\", updateDimensions);\n  }, []);\n\n  return (\n    <GlobalDndProvider>\n      <div\n        ref={containerRef}\n        className=\"w-full h-full flex flex-col overflow-hidden bg-background\"\n      >\n        {/* Island 专用 Header */}\n        <IslandHeader mode={IslandMode.FULLSCREEN} onModeChange={onModeChange} />\n\n        {/* 面板区域 */}\n        <div className=\"flex-1 min-h-0 overflow-hidden\">\n          <PanelRegion\n            width={dimensions.width}\n            isMaximizeMode={true}\n            isInPanelMode={false}\n            isDraggingPanelA={isDraggingPanelA}\n            isDraggingPanelC={isDraggingPanelC}\n            isResizingPanel={false}\n            onPanelAResizePointerDown={handlePanelAResizePointerDown}\n            onPanelCResizePointerDown={handlePanelCResizePointerDown}\n            containerRef={containerRef}\n          />\n        </div>\n      </div>\n    </GlobalDndProvider>\n  );\n}\n"
  },
  {
    "path": "free-todo-frontend/components/island/IslandHeader.tsx",
    "content": "\"use client\";\n\n/**\n * Island 专用 Header 组件\n * 用于形态3/4，提供窗口控制按钮\n * 与原 FreeTodo Header 共享 HeaderIsland 组件\n */\n\nimport { Maximize2, Minimize2, Pin, PinOff, X } from \"lucide-react\";\nimport Image from \"next/image\";\nimport type React from \"react\";\nimport { useState } from \"react\";\nimport { LayoutSelector } from \"@/components/common/layout/LayoutSelector\";\nimport { ThemeStyleSelect } from \"@/components/common/theme/ThemeStyleSelect\";\nimport { ThemeToggle } from \"@/components/common/theme/ThemeToggle\";\nimport { LanguageToggle } from \"@/components/common/ui/LanguageToggle\";\nimport { SettingsToggle } from \"@/components/common/ui/SettingsToggle\";\nimport { HeaderIsland } from \"@/components/notification/HeaderIsland\";\nimport { IslandMode } from \"@/lib/island/types\";\nimport { useNotificationStore } from \"@/lib/store/notification-store\";\n\ninterface IslandHeaderProps {\n  /** 当前模式 */\n  mode: IslandMode;\n  /** 模式切换回调 */\n  onModeChange: (mode: IslandMode) => void;\n  /** SIDEBAR 模式下是否已展开到 2+ 栏（可选） */\n  isExpanded?: boolean;\n  /** 拖拽开始回调（用于 SIDEBAR 模式垂直拖拽，可选） */\n  onDragStart?: (e: React.MouseEvent) => void;\n  /** 是否正在拖拽（可选） */\n  isDragging?: boolean;\n}\n\nexport function IslandHeader({ mode, onModeChange, isExpanded = false, onDragStart, isDragging = false }: IslandHeaderProps) {\n  const isSidebar = mode === IslandMode.SIDEBAR;\n  const isFullscreen = mode === IslandMode.FULLSCREEN;\n\n  // Pin state - default to true (pinned)\n  const [isPinned, setIsPinned] = useState(true);\n\n  // 获取当前通知状态\n  const { notifications } = useNotificationStore();\n\n  // 检测是否为 Electron 环境\n  const isElectron = typeof window !== \"undefined\" && !!window.electronAPI;\n\n  // 显示通知岛的条件：有通知\n  const showNotification = notifications.length > 0;\n\n  // 显示工具按钮的条件：全屏模式 或 侧边栏已展开到 2+ 栏（宽度足够）\n  const shouldShowTools = isFullscreen || (isSidebar && isExpanded);\n\n  // Handle pin toggle\n  const handlePinToggle = () => {\n    const newPinState = !isPinned;\n    setIsPinned(newPinState);\n\n    // Notify Electron main process\n    if (isElectron && window.electronAPI?.islandSetPinned) {\n      window.electronAPI.islandSetPinned(newPinState);\n    }\n  };\n\n  // 在 SIDEBAR 模式下，Header 作为垂直拖拽的 Handle\n  const canDrag = isSidebar && onDragStart;\n\n  return (\n    // biome-ignore lint/a11y/noStaticElementInteractions: Header conditionally acts as a drag handle in SIDEBAR mode\n    <header\n      className={`relative flex h-15 shrink-0 items-center bg-primary-foreground dark:bg-accent px-4 text-foreground overflow-visible ${!canDrag ? 'app-region-drag' : ''}`}\n      onMouseDown={canDrag ? onDragStart : undefined}\n      role={canDrag ? \"button\" : undefined}\n      tabIndex={canDrag ? -1 : undefined}\n      style={{\n        cursor: canDrag ? (isDragging ? \"grabbing\" : \"ns-resize\") : undefined,\n      }}\n    >\n      {/* 左侧：Logo + 应用名称 */}\n      <div className={`flex items-center gap-2 shrink-0 ${!canDrag ? 'app-region-no-drag' : ''}`}>\n        <div className=\"relative h-8 w-8 shrink-0\">\n          {/* 浅色模式图标 */}\n          <Image\n            src=\"/free-todo-logos/free_todo_icon_4_dark_with_grid.png\"\n            alt=\"Free Todo Logo\"\n            width={32}\n            height={32}\n            className=\"object-contain block dark:hidden\"\n          />\n          {/* 深色模式图标 */}\n          <Image\n            src=\"/free-todo-logos/free_todo_icon_4_with_grid.png\"\n            alt=\"Free Todo Logo\"\n            width={32}\n            height={32}\n            className=\"object-contain hidden dark:block\"\n          />\n        </div>\n        <h1 className=\"text-lg font-semibold tracking-tight text-foreground\">\n          {isSidebar ? \"FreeTodo\" : \"Free Todo: Your AI Secretary\"}\n        </h1>\n      </div>\n\n      {/* 中间：HeaderIsland 通知区域（与原 FreeTodo 共享，仅在有通知时显示） */}\n      {showNotification ? (\n        <div className={`flex-1 flex items-center justify-center relative min-w-0 overflow-visible ${!canDrag ? 'app-region-no-drag' : ''}`}>\n          <HeaderIsland />\n        </div>\n      ) : (\n        <div className=\"flex-1\" />\n      )}\n\n      {/* 右侧：工具按钮 + 窗口控制 */}\n      <div className={`flex items-center gap-2 ${!canDrag ? 'app-region-no-drag' : ''}`}>\n        {/* 工具按钮 - 全屏模式或 SIDEBAR 已展开时显示 */}\n        {shouldShowTools && (\n          <>\n            <LayoutSelector />\n            <ThemeStyleSelect />\n            <ThemeToggle />\n            <LanguageToggle />\n            <SettingsToggle />\n            <div className=\"w-px h-4 bg-border mx-1\" />\n          </>\n        )}\n\n        {/* 窗口控制按钮 */}\n        {/* Pin button - only show in SIDEBAR mode */}\n        {isSidebar && (\n          <button\n            type=\"button\"\n            onClick={handlePinToggle}\n            className={`w-7 h-7 flex items-center justify-center rounded-md\n                       hover:bg-accent active:bg-accent/80\n                       transition-colors ${\n                         isPinned\n                           ? \"text-primary hover:text-primary\"\n                           : \"text-muted-foreground hover:text-foreground\"\n                       }`}\n            title={isPinned ? \"取消固定（窗口将在失焦时最小化）\" : \"固定窗口（始终保持在桌面上）\"}\n          >\n            {isPinned ? <Pin size={14} /> : <PinOff size={14} />}\n          </button>\n        )}\n\n        {/* 缩小/展开按钮 */}\n        {isSidebar ? (\n          <button\n            type=\"button\"\n            onClick={() => onModeChange(IslandMode.FULLSCREEN)}\n            className=\"w-7 h-7 flex items-center justify-center rounded-md\n                       hover:bg-accent active:bg-accent/80\n                       text-muted-foreground hover:text-foreground\n                       transition-colors\"\n            title=\"全屏\"\n          >\n            <Maximize2 size={14} />\n          </button>\n        ) : isFullscreen ? (\n          <button\n            type=\"button\"\n            onClick={() => onModeChange(IslandMode.SIDEBAR)}\n            className=\"w-7 h-7 flex items-center justify-center rounded-md\n                       hover:bg-accent active:bg-accent/80\n                       text-muted-foreground hover:text-foreground\n                       transition-colors\"\n            title=\"缩小\"\n          >\n            <Minimize2 size={14} />\n          </button>\n        ) : null}\n\n        {/* 关闭按钮 - 回到形态1 */}\n        <button\n          type=\"button\"\n          onClick={() => onModeChange(IslandMode.FLOAT)}\n          className=\"w-7 h-7 flex items-center justify-center rounded-md\n                     hover:bg-destructive/10 active:bg-destructive/20\n                     text-muted-foreground hover:text-destructive\n                     transition-colors\"\n          title=\"收起\"\n        >\n          <X size={14} />\n        </button>\n      </div>\n    </header>\n  );\n}\n"
  },
  {
    "path": "free-todo-frontend/components/island/IslandSidebarContent.tsx",
    "content": "\"use client\";\n\n/**\n * Island 侧边栏内容组件\n * 在 SIDEBAR 模式下支持单栏/双栏/三栏展开\n * 直接使用 FreeTodo 原有的样式，保持一致性\n */\n\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\nimport type React from \"react\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { IslandHeader } from \"@/components/island/IslandHeader\";\nimport { BottomDock } from \"@/components/layout/BottomDock\";\nimport { PanelContainer } from \"@/components/layout/PanelContainer\";\nimport { PanelContent } from \"@/components/layout/PanelContent\";\nimport { ResizeHandle } from \"@/components/layout/ResizeHandle\";\nimport type { PanelFeature } from \"@/lib/config/panel-config\";\nimport { GlobalDndProvider } from \"@/lib/dnd\";\nimport { usePanelResize } from \"@/lib/hooks/usePanelResize\";\nimport { IslandMode } from \"@/lib/island/types\";\nimport { type DockDisplayMode, useUiStore } from \"@/lib/store/ui-store\";\n\ninterface IslandSidebarContentProps {\n  onModeChange: (mode: IslandMode) => void;\n  /** 拖拽开始回调（用于 Header 垂直拖拽） */\n  onHeaderDragStart?: (e: React.MouseEvent) => void;\n  /** 是否正在拖拽 */\n  isDragging?: boolean;\n}\n\nexport function IslandSidebarContent({ onModeChange, onHeaderDragStart, isDragging: isDraggingProp }: IslandSidebarContentProps) {\n  const containerRef = useRef<HTMLDivElement>(null);\n  const [mounted, setMounted] = useState(false);\n  const [isLeftExpanded, setIsLeftExpanded] = useState(false); // Panel A\n  const [isRightExpanded, setIsRightExpanded] = useState(false); // Panel C\n  const [isDraggingPanelA, setIsDraggingPanelA] = useState(false);\n  const [isDraggingPanelC, setIsDraggingPanelC] = useState(false);\n\n  // SIDEBAR 模式独立的拖拽状态和处理器\n  const [isDraggingWindow, setIsDraggingWindow] = useState(false);\n\n  // 自定义拖拽处理器（仅允许垂直拖动）\n  const handleDragStart = useCallback((e: React.MouseEvent) => {\n    // 仅响应左键\n    if (e.button !== 0) return;\n\n    setIsDraggingWindow(true);\n\n    // 发送拖拽开始事件到主进程\n    if (typeof window !== \"undefined\" && window.electronAPI?.islandDragStart) {\n      window.electronAPI.islandDragStart(e.screenY);\n    }\n  }, []);\n\n  const handleMouseMove = useCallback((e: MouseEvent) => {\n    if (!isDraggingWindow) return;\n\n    // 发送拖拽移动事件到主进程\n    if (typeof window !== \"undefined\" && window.electronAPI?.islandDragMove) {\n      window.electronAPI.islandDragMove(e.screenY);\n    }\n  }, [isDraggingWindow]);\n\n  const handleMouseUp = useCallback(() => {\n    if (!isDraggingWindow) return;\n\n    setIsDraggingWindow(false);\n\n    // 发送拖拽结束事件到主进程\n    if (typeof window !== \"undefined\" && window.electronAPI?.islandDragEnd) {\n      window.electronAPI.islandDragEnd();\n    }\n  }, [isDraggingWindow]);\n\n  // 设置全局鼠标事件监听器\n  useEffect(() => {\n    if (isDraggingWindow) {\n      window.addEventListener(\"mousemove\", handleMouseMove);\n      window.addEventListener(\"mouseup\", handleMouseUp);\n\n      return () => {\n        window.removeEventListener(\"mousemove\", handleMouseMove);\n        window.removeEventListener(\"mouseup\", handleMouseUp);\n      };\n    }\n  }, [isDraggingWindow, handleMouseMove, handleMouseUp]);\n\n  const {\n    isPanelAOpen,\n    isPanelBOpen,\n    isPanelCOpen,\n    panelAWidth,\n    panelCWidth,\n    setPanelAWidth,\n    setPanelCWidth,\n    dockDisplayMode,\n    setDockDisplayMode,\n    setPanelFeature,\n    getAvailableFeatures,\n    panelFeatureMap,\n  } = useUiStore();\n\n  const previousDockModeRef = useRef<DockDisplayMode | null>(null);\n\n  useEffect(() => {\n    setMounted(true);\n  }, []);\n\n  const visiblePanelCount: 1 | 2 | 3 = (1 + (isLeftExpanded ? 1 : 0) + (isRightExpanded ? 1 : 0)) as\n    | 1\n    | 2\n    | 3;\n\n  // SIDEBAR 默认只显示中间栏（Panel B），并在进入时同步窗口尺寸\n  useEffect(() => {\n    if (!mounted) return;\n    setIsLeftExpanded(false);\n    setIsRightExpanded(false);\n\n    // 默认：只显示 Panel B，并确保 Panel B 有分配功能\n    useUiStore.setState({ isPanelAOpen: false, isPanelBOpen: true, isPanelCOpen: false });\n\n    // 取消分配隐藏面板的功能，释放给可见面板使用\n    useUiStore.setState((state) => ({\n      panelFeatureMap: {\n        ...state.panelFeatureMap,\n        panelA: null,\n        panelC: null,\n        // 确保 Panel B 有功能分配，如果没有则分配 chat\n        panelB: state.panelFeatureMap.panelB || (\"chat\" as PanelFeature),\n      },\n    }));\n\n    if (typeof window !== \"undefined\" && window.electronAPI?.islandResizeSidebar) {\n      window.electronAPI.islandResizeSidebar(1);\n    }\n  }, [mounted]);\n\n\n  useEffect(() => {\n    // Save current dock mode only on first mount (when ref is null)\n    if (previousDockModeRef.current === null) {\n      previousDockModeRef.current = dockDisplayMode;\n    }\n    // Force dock to be always visible in Island sidebar mode\n    setDockDisplayMode(\"fixed\");\n\n    // Restore previous dock mode on unmount\n    return () => {\n      if (previousDockModeRef.current !== null) {\n        setDockDisplayMode(previousDockModeRef.current);\n      }\n    };\n  }, [dockDisplayMode, setDockDisplayMode]);\n\n  // 设置全局调整大小光标\n  const setGlobalResizeCursor = useCallback((enabled: boolean) => {\n    if (typeof document === \"undefined\") return;\n    document.body.style.cursor = enabled ? \"col-resize\" : \"\";\n    document.body.style.userSelect = enabled ? \"none\" : \"\";\n  }, []);\n\n  // 清理光标状态\n  useEffect(() => {\n    return () => setGlobalResizeCursor(false);\n  }, [setGlobalResizeCursor]);\n\n  // 使用 usePanelResize hook 进行面板拖拽调整\n  const { handlePanelAResizePointerDown, handlePanelCResizePointerDown } = usePanelResize({\n    containerRef,\n    isPanelBOpen,\n    isPanelCOpen: isRightExpanded && isPanelCOpen,\n    panelCWidth,\n    setPanelAWidth,\n    setPanelCWidth,\n    setIsDraggingPanelA,\n    setIsDraggingPanelC,\n    setGlobalResizeCursor,\n  });\n\n  const resizeSidebarWindow = useCallback((count: 1 | 2 | 3) => {\n    if (typeof window !== \"undefined\" && window.electronAPI?.islandResizeSidebar) {\n      window.electronAPI.islandResizeSidebar(count);\n    }\n  }, []);\n\n  const handleToggleLeft = useCallback(() => {\n    // 左侧按钮：展开/收起 Panel A\n    if (!isLeftExpanded) {\n      // 展开 Panel A\n      setIsLeftExpanded(true);\n      useUiStore.setState({ isPanelAOpen: true });\n\n      // 如果 Panel A 没有分配功能，自动分配一个可用功能\n      if (!panelFeatureMap.panelA) {\n        const availableFeatures = getAvailableFeatures();\n        if (availableFeatures.length > 0) {\n          // 优先分配 todos，如果不可用则分配第一个可用功能\n          const featureToAssign = availableFeatures.includes(\"todos\" as PanelFeature)\n            ? (\"todos\" as PanelFeature)\n            : availableFeatures[0];\n          setPanelFeature(\"panelA\", featureToAssign);\n        }\n      }\n\n      const nextCount = (1 + 1 + (isRightExpanded ? 1 : 0)) as 2 | 3;\n      resizeSidebarWindow(nextCount);\n      return;\n    }\n\n    // 收起 Panel A\n    setIsLeftExpanded(false);\n    useUiStore.setState({ isPanelAOpen: false });\n\n    // 取消分配 Panel A 的功能，释放给其他面板使用\n    useUiStore.setState((state) => ({\n      panelFeatureMap: { ...state.panelFeatureMap, panelA: null },\n    }));\n\n    const nextCount = (1 + (isRightExpanded ? 1 : 0)) as 1 | 2;\n    resizeSidebarWindow(nextCount);\n  }, [isLeftExpanded, isRightExpanded, resizeSidebarWindow, panelFeatureMap, getAvailableFeatures, setPanelFeature]);\n\n  const handleToggleRight = useCallback(() => {\n    // 右侧按钮：展开/收起 Panel C\n    if (!isRightExpanded) {\n      // 展开 Panel C\n      setIsRightExpanded(true);\n      useUiStore.setState({ isPanelCOpen: true });\n\n      // 如果 Panel C 没有分配功能，自动分配一个可用功能\n      if (!panelFeatureMap.panelC) {\n        const availableFeatures = getAvailableFeatures();\n        if (availableFeatures.length > 0) {\n          // 优先分配 todoDetail，如果不可用则分配第一个可用功能\n          const featureToAssign = availableFeatures.includes(\"todoDetail\" as PanelFeature)\n            ? (\"todoDetail\" as PanelFeature)\n            : availableFeatures[0];\n          setPanelFeature(\"panelC\", featureToAssign);\n        }\n      }\n\n      const nextCount = (1 + 1 + (isLeftExpanded ? 1 : 0)) as 2 | 3;\n      resizeSidebarWindow(nextCount);\n      return;\n    }\n\n    // 收起 Panel C\n    setIsRightExpanded(false);\n    useUiStore.setState({ isPanelCOpen: false });\n\n    // 取消分配 Panel C 的功能，释放给其他面板使用\n    useUiStore.setState((state) => ({\n      panelFeatureMap: { ...state.panelFeatureMap, panelC: null },\n    }));\n\n    const nextCount = (1 + (isLeftExpanded ? 1 : 0)) as 1 | 2;\n    resizeSidebarWindow(nextCount);\n  }, [isRightExpanded, isLeftExpanded, resizeSidebarWindow, panelFeatureMap, getAvailableFeatures, setPanelFeature]);\n\n  // 计算面板宽度布局\n  const layoutState = useCallback(() => {\n    const clampedPanelA = Math.min(Math.max(panelAWidth, 0.1), 0.9);\n    const clampedPanelC = Math.min(Math.max(panelCWidth, 0.1), 0.9);\n\n    // 三栏布局：A + B + C\n    if (isLeftExpanded && isRightExpanded) {\n      // 三栏布局\n      const baseWidth = 1 - panelCWidth;\n      const safeBase = baseWidth > 0 ? baseWidth : 1;\n      const a = safeBase * clampedPanelA;\n      const c = panelCWidth;\n      const b = Math.max(0, 1 - a - c);\n\n      return {\n        panelAWidth: a,\n        panelBWidth: b,\n        panelCWidth: c,\n      };\n    }\n\n    // 双栏布局：A + B\n    if (isLeftExpanded && !isRightExpanded) {\n      return {\n        panelAWidth: clampedPanelA,\n        panelBWidth: 1 - clampedPanelA,\n        panelCWidth: 0,\n      };\n    }\n\n    // 双栏布局：B + C\n    if (!isLeftExpanded && isRightExpanded) {\n      return {\n        panelAWidth: 0,\n        panelBWidth: 1 - clampedPanelC,\n        panelCWidth: clampedPanelC,\n      };\n    }\n\n    // 单栏布局：只有 B\n    return {\n      panelAWidth: 0,\n      panelBWidth: 1,\n      panelCWidth: 0,\n    };\n  }, [isLeftExpanded, isRightExpanded, panelAWidth, panelCWidth]);\n\n  const layout = layoutState();\n\n  if (!mounted) {\n    return (\n      <div className=\"w-full h-full flex flex-col overflow-hidden bg-background\">\n        <div className=\"h-12 shrink-0 bg-primary-foreground dark:bg-accent\" />\n        <div className=\"flex-1\" />\n      </div>\n    );\n  }\n\n  return (\n    <GlobalDndProvider>\n      <div className=\"w-full h-full flex flex-col overflow-hidden bg-background\">\n        {/* Island 专用 Header（可作为垂直拖拽 Handle） */}\n        <IslandHeader\n          mode={IslandMode.SIDEBAR}\n          onModeChange={onModeChange}\n          isExpanded={isLeftExpanded || isRightExpanded}\n          onDragStart={onHeaderDragStart || handleDragStart}\n          isDragging={isDraggingProp !== undefined ? isDraggingProp : isDraggingWindow}\n        />\n\n        {/* 面板区域 */}\n        <div\n          ref={containerRef}\n          className=\"flex-1 min-h-0 overflow-hidden relative bg-primary-foreground dark:bg-accent flex px-3\"\n        >\n          {/* 左侧收起按钮（点击区域在面板两侧，避免 dock 内部塞按钮） */}\n          <button\n            type=\"button\"\n            onClick={handleToggleLeft}\n            className=\"absolute left-1 top-1/2 -translate-y-1/2 z-50 pointer-events-auto\n                       h-20 w-8 rounded-xl\n                       bg-[oklch(var(--card))]/70 dark:bg-background/70 opacity-50\n                       backdrop-blur-md border border-[oklch(var(--border))]\n                       shadow-lg\n                       text-[oklch(var(--muted-foreground))] hover:text-[oklch(var(--foreground))] hover:opacity-100\n                       hover:bg-[oklch(var(--card))]/90\n                       focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[oklch(var(--ring))] focus-visible:ring-offset-2\"\n            aria-label={isLeftExpanded ? \"收起左侧栏\" : \"展开左侧栏\"}\n            title={isLeftExpanded ? \"收起左侧\" : \"展开左侧\"}\n          >\n            {isLeftExpanded ? (\n              <ChevronRight className=\"mx-auto h-5 w-5\" />\n            ) : (\n              <ChevronLeft className=\"mx-auto h-5 w-5\" />\n            )}\n          </button>\n\n          {/* Panel A - 左侧展开时显示 */}\n          {isLeftExpanded && (\n            <PanelContainer\n              position=\"panelA\"\n              isVisible={isPanelAOpen}\n              width={layout.panelAWidth}\n              isDragging={isDraggingPanelA || isDraggingPanelC}\n              className=\"mx-1\"\n            >\n              <PanelContent position=\"panelA\" />\n            </PanelContainer>\n          )}\n\n          {/* Panel A / B 之间的 ResizeHandle（左侧展开时显示） */}\n          {isLeftExpanded && (\n            <ResizeHandle\n              onPointerDown={handlePanelAResizePointerDown}\n              isDragging={isDraggingPanelA}\n              isVisible={isPanelAOpen && isPanelBOpen}\n            />\n          )}\n\n          {/* Panel B - SIDEBAR 默认中间栏（可被用户关闭） */}\n          <PanelContainer\n            position=\"panelB\"\n            isVisible={isPanelBOpen}\n            width={layout.panelBWidth}\n            isDragging={isDraggingPanelA || isDraggingPanelC}\n            className=\"mx-1\"\n          >\n            <PanelContent position=\"panelB\" />\n          </PanelContainer>\n\n          {/* Panel B / C 之间的 ResizeHandle（右侧展开时显示） */}\n          {isRightExpanded && (\n            <ResizeHandle\n              onPointerDown={handlePanelCResizePointerDown}\n              isDragging={isDraggingPanelC}\n              isVisible={isPanelCOpen && (isPanelBOpen || isPanelAOpen)}\n            />\n          )}\n\n          {/* Panel C - 右侧展开时显示 */}\n          {isRightExpanded && (\n            <PanelContainer\n              position=\"panelC\"\n              isVisible={isPanelCOpen}\n              width={layout.panelCWidth}\n              isDragging={isDraggingPanelA || isDraggingPanelC}\n              className=\"mx-1\"\n            >\n              <PanelContent position=\"panelC\" />\n            </PanelContainer>\n          )}\n\n          {/* 右侧展开按钮（点击区域在面板两侧，避免 dock 内部塞按钮） */}\n          <button\n            type=\"button\"\n            onClick={handleToggleRight}\n            className=\"absolute right-1 top-1/2 -translate-y-1/2 z-50 pointer-events-auto\n                       h-20 w-8 rounded-xl\n                       bg-[oklch(var(--card))]/70 dark:bg-background/70 opacity-50\n                       backdrop-blur-md border border-[oklch(var(--border))]\n                       shadow-lg\n                       text-[oklch(var(--muted-foreground))] hover:text-[oklch(var(--foreground))]\n                       hover:bg-[oklch(var(--card))] dark:hover:bg-background hover:opacity-100\n                       focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[oklch(var(--ring))] focus-visible:ring-offset-2\"\n            aria-label={isRightExpanded ? \"收起右侧栏\" : \"展开右侧栏\"}\n            title={isRightExpanded ? \"收起右侧\" : \"展开右侧\"}\n          >\n            {isRightExpanded ? (\n              <ChevronLeft className=\"mx-auto h-5 w-5\" />\n            ) : (\n              <ChevronRight className=\"mx-auto h-5 w-5\" />\n            )}\n          </button>\n        </div>\n\n        {/* 底部 Dock - 用于切换面板和展开/收起栏数 */}\n        <div className=\"shrink-0 flex justify-center px-2 pb-2 bg-primary-foreground dark:bg-accent\">\n          <BottomDock\n            isInPanelMode={true}\n            panelContainerRef={containerRef}\n            visiblePanelCount={visiblePanelCount}\n            visiblePositions={\n              visiblePanelCount === 1\n                ? [\"panelB\"]\n                : visiblePanelCount === 2\n                  ? isLeftExpanded\n                    ? [\"panelA\", \"panelB\"]\n                    : [\"panelB\", \"panelC\"]\n                  : [\"panelA\", \"panelB\", \"panelC\"]\n            }\n          />\n        </div>\n      </div>\n    </GlobalDndProvider>\n  );\n}\n"
  },
  {
    "path": "free-todo-frontend/components/island/index.ts",
    "content": "/**\n * Island 组件导出\n */\n\nexport { default as DynamicIsland } from \"./DynamicIsland\";\nexport * from \"./IslandContent\";\nexport { IslandFullscreenContent } from \"./IslandFullscreenContent\";\nexport { IslandSidebarContent } from \"./IslandSidebarContent\";\n"
  },
  {
    "path": "free-todo-frontend/components/layout/AppHeader.tsx",
    "content": "/**\n * 可复用的应用 Header 组件\n * 左侧：Logo + 应用名称\n * 中间：通知区域（可选）\n * 右侧：工具按钮（LayoutSelector, ThemeToggle, LanguageToggle, SettingsToggle）\n */\n\n\"use client\";\n\nimport Image from \"next/image\";\nimport { LayoutSelector } from \"@/components/common/layout/LayoutSelector\";\nimport { ThemeStyleSelect } from \"@/components/common/theme/ThemeStyleSelect\";\nimport { ThemeToggle } from \"@/components/common/theme/ThemeToggle\";\nimport { LanguageToggle } from \"@/components/common/ui/LanguageToggle\";\nimport { SettingsToggle } from \"@/components/common/ui/SettingsToggle\";\nimport { HeaderIsland } from \"@/components/notification/HeaderIsland\";\n\ninterface AppHeaderProps {\n\t/** 是否有通知（可选） */\n\thasNotifications?: boolean;\n}\n\nexport function AppHeader({ hasNotifications = false }: AppHeaderProps) {\n\tconst showNotification = hasNotifications;\n\n\treturn (\n\t\t<header className=\"relative flex h-15 shrink-0 items-center bg-primary-foreground dark:bg-accent px-4 text-foreground overflow-visible\">\n\t\t\t{/* 左侧：Logo + 应用名称（复用 MaximizeHeader 的样式） */}\n\t\t\t<div className=\"flex items-center gap-2 shrink-0\">\n\t\t\t\t<div className=\"relative h-8 w-8 shrink-0\">\n\t\t\t\t\t{/* 浅色模式图标 */}\n\t\t\t\t\t<Image\n\t\t\t\t\t\tsrc=\"/free-todo-logos/free_todo_icon_4_dark_with_grid.png\"\n\t\t\t\t\t\talt=\"Free Todo Logo\"\n\t\t\t\t\t\twidth={32}\n\t\t\t\t\t\theight={32}\n\t\t\t\t\t\tclassName=\"object-contain block dark:hidden\"\n\t\t\t\t\t/>\n\t\t\t\t\t{/* 深色模式图标 */}\n\t\t\t\t\t<Image\n\t\t\t\t\t\tsrc=\"/free-todo-logos/free_todo_icon_4_with_grid.png\"\n\t\t\t\t\t\talt=\"Free Todo Logo\"\n\t\t\t\t\t\twidth={32}\n\t\t\t\t\t\theight={32}\n\t\t\t\t\t\tclassName=\"object-contain hidden dark:block\"\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\t\t\t\t<h1 className=\"text-lg font-semibold tracking-tight text-foreground hidden md:block\">\n\t\t\t\t\tFree Todo: Your AI Secretary\n\t\t\t\t</h1>\n\t\t\t</div>\n\n\t\t\t{/* 中间：通知区域 */}\n\t\t\t{showNotification ? (\n\t\t\t\t<div className=\"flex-1 flex items-center justify-center relative min-w-0 overflow-visible\">\n\t\t\t\t\t<HeaderIsland />\n\t\t\t\t</div>\n\t\t\t) : (\n\t\t\t\t<div className=\"flex-1\" />\n\t\t\t)}\n\n\t\t\t{/* 右侧：工具按钮 */}\n\t\t\t<div className=\"flex items-center gap-2 min-w-0 shrink-0\">\n\t\t\t\t<LayoutSelector />\n\t\t\t\t<ThemeStyleSelect />\n\t\t\t\t<ThemeToggle />\n\t\t\t\t<LanguageToggle />\n\t\t\t\t<SettingsToggle />\n\t\t\t</div>\n\t\t</header>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/components/layout/BottomDock.tsx",
    "content": "\"use client\";\n\nimport { useDraggable, useDroppable } from \"@dnd-kit/core\";\nimport { CSS } from \"@dnd-kit/utilities\";\nimport { motion } from \"framer-motion\";\nimport type { LucideIcon } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport type { PanelFeature, PanelPosition } from \"@/lib/config/panel-config\";\nimport { FEATURE_ICON_MAP } from \"@/lib/config/panel-config\";\nimport type { DragData, DropData } from \"@/lib/dnd\";\nimport { useLocaleStore } from \"@/lib/store/locale\";\nimport { useUiStore } from \"@/lib/store/ui-store\";\nimport { cn } from \"@/lib/utils\";\nimport { PanelSelectorMenu } from \"./PanelSelectorMenu\";\n\n// 动画配置常量\nconst DOCK_ANIMATION_CONFIG = {\n\tspring: {\n\t\ttype: \"spring\" as const,\n\t\tstiffness: 350,\n\t\tdamping: 30,\n\t\tmass: 0.8,\n\t},\n};\n\n// Dock 高度相关常量（单位: px）\nconst DOCK_TRIGGER_ZONE = 80; // 触发展开的底部区域高度（鼠标进入此区域时展开）\nconst HIDE_DELAY_MS = 1000; // 鼠标离开触发区域后收起的延迟时间\nconst DOCK_BOTTOM_OFFSET = 12; // 收起时，向下隐藏的偏移量\nconst PANEL_VISIBLE_OFFSET = 8; // Panel 模式展开时，向上抬高的偏移量，让 dock 离底边更高一点\n\ninterface BottomDockProps {\n\tclassName?: string;\n\t/** 是否在 Panel 模式下（用于调整鼠标位置检测和动画） */\n\tisInPanelMode?: boolean;\n\t/** Panel 容器的 ref（用于计算相对于 Panel 的鼠标位置） */\n\tpanelContainerRef?: React.RefObject<HTMLElement | null>;\n\t/** 当前显示的 panel 个数（1, 2, 或 3），用于决定显示哪些 dock items */\n\tvisiblePanelCount?: number;\n\t/** 指定需要显示的 panel 槽位（优先级高于 visiblePanelCount） */\n\tvisiblePositions?: PanelPosition[];\n}\n\ninterface DockItem {\n\tid: string;\n\ticon: LucideIcon;\n\tlabel: string;\n\tisActive: boolean;\n\tonClick: () => void;\n\tgroup?: string;\n}\n\nconst FEATURE_LABEL_MAP: Partial<Record<PanelFeature, string>> = {\n\tcalendar: \"calendar\",\n\tactivity: \"activity\",\n\ttodos: \"todos\",\n\tchat: \"chat\",\n\ttodoDetail: \"todoDetail\",\n\tdiary: \"diary\",\n\tsettings: \"settings\",\n\tcostTracking: \"costTracking\",\n\tachievements: \"achievements\",\n\tdebugShots: \"debugShots\",\n\taudio: \"audio\",\n};\n\n// 功能到翻译键的映射配置，缺失项回退到 todos\nfunction getFeatureLabelKey(feature: PanelFeature): string {\n\treturn FEATURE_LABEL_MAP[feature] ?? \"todos\";\n}\n\n// Dock Item 组件 - 单独组件以正确使用 hooks\ninterface DockItemButtonProps {\n\titem: DockItem;\n\tposition: PanelPosition;\n\tonContextMenu: (\n\t\te: React.MouseEvent<HTMLButtonElement>,\n\t\tposition: PanelPosition,\n\t) => void;\n\tsetItemRef: (position: PanelPosition, el: HTMLButtonElement | null) => void;\n\tmounted: boolean;\n}\n\nfunction DockItemButton({\n\titem,\n\tposition,\n\tonContextMenu,\n\tsetItemRef,\n\tmounted,\n}: DockItemButtonProps) {\n\tconst Icon = item.icon;\n\n\t// 构建拖拽数据\n\tconst dragData: DragData = useMemo(\n\t\t() => ({\n\t\t\ttype: \"PANEL_HEADER\" as const,\n\t\t\tpayload: {\n\t\t\t\tposition,\n\t\t\t},\n\t\t}),\n\t\t[position],\n\t);\n\n\t// 构建放置数据\n\tconst dropData: DropData = useMemo(\n\t\t() => ({\n\t\t\ttype: \"PANEL_HEADER\" as const,\n\t\t\tmetadata: {\n\t\t\t\tposition,\n\t\t\t},\n\t\t}),\n\t\t[position],\n\t);\n\n\t// 可拖拽 - 只在客户端挂载后使用，避免 SSR hydration 问题\n\tconst {\n\t\tattributes: dragAttributes,\n\t\tlisteners: dragListeners,\n\t\tsetNodeRef: setDragRef,\n\t\ttransform: dragTransform,\n\t\tisDragging: isDraggingItem,\n\t} = useDraggable({\n\t\tid: `dock-item-${position}`,\n\t\tdata: dragData,\n\t\tdisabled: !mounted,\n\t});\n\n\t// 可放置 - 只在客户端挂载后使用\n\tconst { setNodeRef: setDropRef, isOver: isOverItem } = useDroppable({\n\t\tid: `dock-drop-${position}`,\n\t\tdata: dropData,\n\t\tdisabled: !mounted,\n\t});\n\n\t// 合并 refs\n\tconst setRefs = (el: HTMLButtonElement | null) => {\n\t\tsetItemRef(position, el);\n\t\tif (mounted) {\n\t\t\tsetDragRef(el);\n\t\t\tsetDropRef(el);\n\t\t}\n\t};\n\n\tconst dragStyle = dragTransform\n\t\t? {\n\t\t\t\ttransform: CSS.Translate.toString(dragTransform),\n\t\t\t}\n\t\t: undefined;\n\n\treturn (\n\t\t<button\n\t\t\tref={setRefs}\n\t\t\ttype=\"button\"\n\t\t\tstyle={dragStyle}\n\t\t\tdata-tour={`dock-item-${position}`}\n\t\t\t{...(mounted ? dragAttributes : {})}\n\t\t\t{...(mounted ? dragListeners : {})}\n\t\t\tonClick={item.onClick}\n\t\t\tonContextMenu={(e) => onContextMenu(e, position)}\n\t\t\tclassName={cn(\n\t\t\t\t\"relative flex items-center gap-2\",\n\t\t\t\t\"px-3 py-2 rounded-lg\",\n\t\t\t\t\"transition-all duration-200\",\n\t\t\t\t\"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[oklch(var(--ring))] focus-visible:ring-offset-2\",\n\t\t\t\tmounted && \"cursor-grab active:cursor-grabbing\",\n\t\t\t\tisDraggingItem && \"opacity-50\",\n\t\t\t\tisOverItem && !isDraggingItem && \"ring-2 ring-primary/50 ring-offset-2\",\n\t\t\t\titem.isActive\n\t\t\t\t\t? \"bg-[oklch(var(--primary-weak))] dark:bg-[oklch(var(--primary-weak-hover))] text-[oklch(var(--primary))] dark:text-[oklch(var(--foreground))] shadow-[0_0_0_1px_oklch(var(--primary))] hover:bg-[oklch(var(--primary-weak-hover))] dark:hover:bg-[oklch(var(--primary-weak))]\"\n\t\t\t\t\t: \"text-[oklch(var(--foreground))] hover:bg-[oklch(var(--muted))] hover:text-[oklch(var(--foreground))]\",\n\t\t\t)}\n\t\t\taria-label={item.label}\n\t\t\taria-pressed={item.isActive}\n\t\t>\n\t\t\t<Icon\n\t\t\t\tclassName={cn(\n\t\t\t\t\t\"h-5 w-5\",\n\t\t\t\t\titem.isActive\n\t\t\t\t\t\t? \"text-[oklch(var(--primary))] dark:text-[oklch(var(--foreground))]\"\n\t\t\t\t\t\t: \"text-[oklch(var(--foreground))]\",\n\t\t\t\t)}\n\t\t\t/>\n\t\t\t<span\n\t\t\t\tclassName={cn(\n\t\t\t\t\t\"text-sm font-semibold\",\n\t\t\t\t\titem.isActive\n\t\t\t\t\t\t? \"text-[oklch(var(--primary))] dark:text-[oklch(var(--foreground))]\"\n\t\t\t\t\t\t: \"text-[oklch(var(--foreground))]\",\n\t\t\t\t)}\n\t\t\t>\n\t\t\t\t{item.label}\n\t\t\t</span>\n\t\t\t{item.isActive && (\n\t\t\t\t<span className=\"absolute bottom-0 left-1/2 -translate-x-1/2 w-3 h-0.5 bg-[oklch(var(--primary))]\" />\n\t\t\t)}\n\t\t</button>\n\t);\n}\n\nexport function BottomDock({\n\tclassName,\n\tisInPanelMode = false,\n\tpanelContainerRef,\n\tvisiblePanelCount,\n\tvisiblePositions,\n}: BottomDockProps) {\n\tconst {\n\t\tisPanelAOpen,\n\t\tisPanelBOpen,\n\t\tisPanelCOpen,\n\t\ttogglePanelA,\n\t\ttogglePanelB,\n\t\ttogglePanelC,\n\t\tsetPanelFeature,\n\t\tdockDisplayMode,\n\t\tpanelFeatureMap, // ✅ 直接订阅 panelFeatureMap，确保交换位置后能触发重新渲染\n\t\tdisabledFeatures, // ✅ 也需要订阅 disabledFeatures，确保禁用功能被正确处理\n\t\tbackendDisabledFeatures,\n\t\tgetFeatureByPosition, // ✅ 用于根据位置获取功能（用于引导流程）\n\t} = useUiStore();\n\tconst { locale: _ } = useLocaleStore();\n\tconst t = useTranslations(\"bottomDock\");\n\tconst [mounted, setMounted] = useState(false);\n\n\t// Dock 展开/收起状态\n\tconst [isExpanded, setIsExpanded] = useState(false);\n\tconst hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\tconst dockRef = useRef<HTMLDivElement | null>(null);\n\tconst [dockHeight, setDockHeight] = useState(52); // 默认高度估算值\n\n\tconst [menuState, setMenuState] = useState<{\n\t\tisOpen: boolean;\n\t\tposition: PanelPosition | null;\n\t\tanchorElement: HTMLElement | null;\n\t}>({\n\t\tisOpen: false,\n\t\tposition: null,\n\t\tanchorElement: null,\n\t});\n\n\tuseEffect(() => {\n\t\tsetMounted(true);\n\t}, []);\n\n\t// 根据当前显示的 panel 个数决定显示哪些 dock items\n\t// 如果 visiblePositions 指定，则按指定显示；否则使用 visiblePanelCount（兼容完整页面模式）\n\tconst visiblePositionsResolved: PanelPosition[] = useMemo(() => {\n\t\tif (visiblePositions && visiblePositions.length > 0) return visiblePositions;\n\n\t\t// Panel 模式 & 完整页面模式都按 visiblePanelCount 显示 1/2/3 个，保证与面板数量同步\n\t\tconst count = visiblePanelCount ?? 3;\n\t\treturn count === 1\n\t\t\t? [\"panelA\"]\n\t\t\t: count === 2\n\t\t\t\t? [\"panelA\", \"panelB\"]\n\t\t\t\t: [\"panelA\", \"panelB\", \"panelC\"]; // 默认或 3 个\n\t}, [visiblePanelCount, visiblePositions]);\n\n\t// 监听外部事件以程序化打开右键菜单（用于引导流程）\n\tuseEffect(() => {\n\t\tconst handleOpenMenu = (\n\t\t\te: CustomEvent<{ feature?: PanelFeature; position?: PanelPosition }>,\n\t\t) => {\n\t\t\tconst { feature: targetFeature, position: targetPosition } = e.detail;\n\n\t\t\t// 优先使用 position 参数\n\t\t\tif (targetPosition) {\n\t\t\t\tconst anchorEl = itemRefs.current[targetPosition];\n\t\t\t\tif (anchorEl) {\n\t\t\t\t\tsetMenuState({\n\t\t\t\t\t\tisOpen: true,\n\t\t\t\t\t\tposition: targetPosition,\n\t\t\t\t\t\tanchorElement: anchorEl,\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// 回退到使用 feature 参数\n\t\t\tif (targetFeature) {\n\t\t\t\tconst positions: PanelPosition[] = [\"panelA\", \"panelB\", \"panelC\"];\n\t\t\t\tfor (const pos of positions) {\n\t\t\t\t\tif (getFeatureByPosition(pos) === targetFeature) {\n\t\t\t\t\t\tconst anchorEl = itemRefs.current[pos];\n\t\t\t\t\t\tif (anchorEl) {\n\t\t\t\t\t\t\tsetMenuState({\n\t\t\t\t\t\t\t\tisOpen: true,\n\t\t\t\t\t\t\t\tposition: pos,\n\t\t\t\t\t\t\t\tanchorElement: anchorEl,\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t}\n\t\t\t\t\t\tbreak;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t};\n\n\t\twindow.addEventListener(\n\t\t\t\"onboarding:open-dock-menu\",\n\t\t\thandleOpenMenu as EventListener,\n\t\t);\n\t\treturn () => {\n\t\t\twindow.removeEventListener(\n\t\t\t\t\"onboarding:open-dock-menu\",\n\t\t\t\thandleOpenMenu as EventListener,\n\t\t\t);\n\t\t};\n\t}, [getFeatureByPosition]);\n\n\t// 基于配置生成 dock items，每个位置槽位对应一个 item\n\t// 在 SSR 时使用默认值，避免 hydration 错误\n\tconst DOCK_ITEMS: DockItem[] = useMemo(() => {\n\t\tconst disabledSet = new Set([\n\t\t\t...disabledFeatures,\n\t\t\t...backendDisabledFeatures,\n\t\t]);\n\n\t\treturn visiblePositionsResolved.map((position) => {\n\t\t// 在 SSR 时使用默认功能分配，客户端挂载后使用实际值\n\t\tconst defaultFeatureMap: Record<PanelPosition, PanelFeature> = {\n\t\t\tpanelA: \"todos\",\n\t\t\tpanelB: \"todoDetail\",\n\t\t\tpanelC: \"chat\",\n\t\t};\n\t\t// ✅ 修复：直接使用 panelFeatureMap，而不是 getFeatureByPosition，确保交换位置后能触发重新计算\n\t\t// 同时检查功能是否被禁用\n\t\tconst rawFeature = mounted ? (panelFeatureMap[position] || null) : defaultFeatureMap[position];\n\t\tconst feature = rawFeature && disabledSet.has(rawFeature) ? null : rawFeature;\n\n\t\t// 获取位置对应的状态和 toggle 方法（无论是否分配功能都需要）\n\t\tlet isActive: boolean;\n\t\tlet onClick: () => void;\n\t\tswitch (position) {\n\t\t\tcase \"panelA\":\n\t\t\t\tisActive = isPanelAOpen;\n\t\t\t\tonClick = togglePanelA;\n\t\t\t\tbreak;\n\t\t\tcase \"panelB\":\n\t\t\t\tisActive = isPanelBOpen;\n\t\t\t\tonClick = togglePanelB;\n\t\t\t\tbreak;\n\t\t\tcase \"panelC\":\n\t\t\t\tisActive = isPanelCOpen;\n\t\t\t\tonClick = togglePanelC;\n\t\t\t\tbreak;\n\t\t}\n\n\t\tif (!feature) {\n\t\t\t// 如果位置没有分配功能，返回一个占位 item\n\t\t\t// 但仍然需要显示激活状态，并允许点击关闭\n\t\t\treturn {\n\t\t\t\tid: position,\n\t\t\t\ticon: FEATURE_ICON_MAP.todos,\n\t\t\t\tlabel: t(\"unassigned\"),\n\t\t\t\tisActive,\n\t\t\t\tonClick,\n\t\t\t\tgroup: \"views\",\n\t\t\t};\n\t\t}\n\t\tconst Icon = FEATURE_ICON_MAP[feature];\n\t\tconst labelKey = getFeatureLabelKey(feature);\n\n\t\treturn {\n\t\t\tid: position,\n\t\t\ticon: Icon,\n\t\t\tlabel: t(labelKey),\n\t\t\tisActive,\n\t\t\tonClick,\n\t\t\tgroup: \"views\",\n\t\t};\n\t\t});\n\t}, [visiblePositionsResolved, mounted, panelFeatureMap, disabledFeatures, backendDisabledFeatures, isPanelAOpen, isPanelBOpen, isPanelCOpen, togglePanelA, togglePanelB, togglePanelC, t]); // ✅ 修复：依赖 panelFeatureMap 和 disabledFeatures，确保交换位置后能触发重新计算\n\n\t// 测量 dock 实际高度\n\tuseEffect(() => {\n\t\tif (dockRef.current) {\n\t\t\tconst height = dockRef.current.offsetHeight;\n\t\t\tif (height > 0) {\n\t\t\t\tsetDockHeight(height);\n\t\t\t}\n\t\t}\n\t}, []);\n\n\t// 全局鼠标位置监听 - 当鼠标接近屏幕底部时展开 dock（仅在自动隐藏模式下生效）\n\tuseEffect(() => {\n\t\tif (!mounted) return;\n\n\t\t// 固定模式：始终展开，不需要监听鼠标事件\n\t\tif (dockDisplayMode === \"fixed\") {\n\t\t\tsetIsExpanded(true);\n\t\t\t// 清除可能存在的隐藏定时器\n\t\t\tif (hideTimeoutRef.current) {\n\t\t\t\tclearTimeout(hideTimeoutRef.current);\n\t\t\t\thideTimeoutRef.current = null;\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\n\t\tconst handleMouseMove = (e: MouseEvent) => {\n\t\t\t// 如果右键菜单打开，保持 dock 展开，不执行隐藏逻辑\n\t\t\tif (menuState.isOpen) {\n\t\t\t\t// 清除可能存在的隐藏定时器\n\t\t\t\tif (hideTimeoutRef.current) {\n\t\t\t\t\tclearTimeout(hideTimeoutRef.current);\n\t\t\t\t\thideTimeoutRef.current = null;\n\t\t\t\t}\n\t\t\t\tsetIsExpanded(true);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tlet distanceFromBottom: number;\n\n\t\t\tif (isInPanelMode && panelContainerRef?.current) {\n\t\t\t\t// Panel 模式：使用 Panel 容器的底边，确保在面板底部附近就能触发展开\n\t\t\t\tconst rect = panelContainerRef.current.getBoundingClientRect();\n\t\t\t\tconst mouseY = e.clientY;\n\t\t\t\tdistanceFromBottom = rect.bottom - mouseY;\n\t\t\t} else {\n\t\t\t\t// 全屏 / 浏览器模式：使用窗口高度\n\t\t\t\tconst windowHeight = window.innerHeight;\n\t\t\t\tconst mouseY = e.clientY;\n\t\t\t\tdistanceFromBottom = windowHeight - mouseY;\n\t\t\t}\n\n\t\t\t// 鼠标在底部触发区域内\n\t\t\tif (distanceFromBottom <= DOCK_TRIGGER_ZONE) {\n\t\t\t\t// 清除可能存在的隐藏定时器\n\t\t\t\tif (hideTimeoutRef.current) {\n\t\t\t\t\tclearTimeout(hideTimeoutRef.current);\n\t\t\t\t\thideTimeoutRef.current = null;\n\t\t\t\t}\n\t\t\t\tsetIsExpanded(true);\n\t\t\t} else {\n\t\t\t\t// 鼠标离开触发区域，启动延迟收起\n\t\t\t\tif (!hideTimeoutRef.current) {\n\t\t\t\t\thideTimeoutRef.current = setTimeout(() => {\n\t\t\t\t\t\tsetIsExpanded(false);\n\t\t\t\t\t\thideTimeoutRef.current = null;\n\t\t\t\t\t}, HIDE_DELAY_MS);\n\t\t\t\t}\n\t\t\t}\n\t\t};\n\n\t\twindow.addEventListener(\"mousemove\", handleMouseMove);\n\n\t\treturn () => {\n\t\t\twindow.removeEventListener(\"mousemove\", handleMouseMove);\n\t\t\tif (hideTimeoutRef.current) {\n\t\t\t\tclearTimeout(hideTimeoutRef.current);\n\t\t\t}\n\t\t};\n\t}, [mounted, menuState.isOpen, dockDisplayMode, isInPanelMode, panelContainerRef]);\n\n\t// 计算收起时的 translateY 值\n\t// 收起时，dock 完全隐藏到屏幕底部外\n\tconst hiddenTranslateY = dockHeight + DOCK_BOTTOM_OFFSET;\n\t// 展开时的 translateY：Panel 模式下稍微往上抬一点，避免贴边/遮挡\n\tconst visibleTranslateY = isInPanelMode ? -PANEL_VISIBLE_OFFSET : 0;\n\n\tconst itemRefs = useRef<Record<PanelPosition, HTMLButtonElement | null>>({\n\t\tpanelA: null,\n\t\tpanelB: null,\n\t\tpanelC: null,\n\t});\n\n\t// 按组分组，用于添加分隔符\n\tconst groupedItems = DOCK_ITEMS.reduce(\n\t\t(acc, item) => {\n\t\t\tconst group = item.group || \"default\";\n\t\t\tif (!acc[group]) {\n\t\t\t\tacc[group] = [];\n\t\t\t}\n\t\t\tacc[group].push(item);\n\t\t\treturn acc;\n\t\t},\n\t\t{} as Record<string, DockItem[]>,\n\t);\n\n\tconst groupEntries = Object.entries(groupedItems);\n\tconst hasMultipleGroups = groupEntries.length > 1;\n\n\treturn (\n\t\t<motion.div\n\t\t\tclassName={cn(\n\t\t\t\tisInPanelMode\n\t\t\t\t\t? \"pointer-events-auto relative z-1000000\"\n\t\t\t\t\t: \"pointer-events-auto fixed bottom-1 left-1/2 z-1000000\",\n\t\t\t\tclassName,\n\t\t\t)}\n\t\t\tinitial={false}\n\t\t\tanimate={isInPanelMode\n\t\t\t\t? {\n\t\t\t\t\tx: 0,\n\t\t\t\t\t// Panel 模式：展开时略微抬高，隐藏时继续向下滑出容器\n\t\t\t\t\ty: isExpanded ? visibleTranslateY : hiddenTranslateY,\n\t\t\t\t}\n\t\t\t\t: {\n\t\t\t\t\t// 全屏 / 浏览器模式：行为保持不变\n\t\t\t\t\tx: \"-50%\",\n\t\t\t\t\ty: isExpanded ? 0 : hiddenTranslateY,\n\t\t\t\t}\n\t\t\t}\n\t\t\ttransition={DOCK_ANIMATION_CONFIG.spring}\n\t\t>\n\t\t<div\n\t\t\tref={dockRef}\n\t\t\tdata-tour=\"bottom-dock\"\n\t\t\tclassName={cn(\n\t\t\t\t\"flex items-center gap-2\",\n\t\t\t\t\"bg-[oklch(var(--card))] dark:bg-[oklch(var(--card))]/60\",\n\t\t\t\t\"backdrop-blur-md\",\n\t\t\t\t\"border border-[oklch(var(--border))]\",\n\t\t\t\t\"shadow-lg dark:shadow-[0_12px_32px_-18px_oklch(var(--overlay))]\",\n\t\t\t\t\"px-2 py-1.5\",\n\t\t\t\t\"rounded-xl\",\n\t\t\t)}\n\t\t>\n\t\t\t\t{groupEntries.map(([groupName, groupItems], groupIndex) => (\n\t\t\t\t\t<div key={groupName} className=\"flex items-center gap-2\">\n\t\t\t\t\t\t{groupIndex > 0 && hasMultipleGroups && (\n\t\t\t\t\t\t\t<div className=\"h-6 w-px bg-[oklch(var(--border))] mx-1\" />\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t{groupItems.map((item) => {\n\t\t\t\t\t\t\tconst position = item.id as PanelPosition;\n\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t<DockItemButton\n\t\t\t\t\t\t\t\t\tkey={item.id}\n\t\t\t\t\t\t\t\t\titem={item}\n\t\t\t\t\t\t\t\t\tposition={position}\n\t\t\t\t\t\t\t\t\tmounted={mounted}\n\t\t\t\t\t\t\t\t\tonContextMenu={(e, pos) => {\n\t\t\t\t\t\t\t\t\t\te.preventDefault();\n\t\t\t\t\t\t\t\t\t\tsetMenuState({\n\t\t\t\t\t\t\t\t\t\t\tisOpen: true,\n\t\t\t\t\t\t\t\t\t\t\tposition: pos,\n\t\t\t\t\t\t\t\t\t\t\tanchorElement: e.currentTarget,\n\t\t\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\tsetItemRef={(pos, el) => {\n\t\t\t\t\t\t\t\t\t\titemRefs.current[pos] = el;\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t})}\n\t\t\t\t\t</div>\n\t\t\t\t))}\n\t\t\t</div>\n\t\t\t{menuState.position && (\n\t\t\t\t<PanelSelectorMenu\n\t\t\t\t\tposition={menuState.position}\n\t\t\t\t\tisOpen={menuState.isOpen}\n\t\t\t\t\tonClose={() =>\n\t\t\t\t\t\tsetMenuState({\n\t\t\t\t\t\t\tisOpen: false,\n\t\t\t\t\t\t\tposition: null,\n\t\t\t\t\t\t\tanchorElement: null,\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t\tonSelect={(feature) => {\n\t\t\t\t\t\tif (menuState.position) {\n\t\t\t\t\t\t\tsetPanelFeature(menuState.position, feature);\n\t\t\t\t\t\t\t// ✅ 如果 Panel 当前未激活，则自动激活它\n\t\t\t\t\t\t\tconst pos = menuState.position;\n\t\t\t\t\t\t\tconst isPanelActive =\n\t\t\t\t\t\t\t\tpos === \"panelA\" ? isPanelAOpen :\n\t\t\t\t\t\t\t\tpos === \"panelB\" ? isPanelBOpen :\n\t\t\t\t\t\t\t\tisPanelCOpen;\n\t\t\t\t\t\t\tif (!isPanelActive) {\n\t\t\t\t\t\t\t\tconst toggleFn =\n\t\t\t\t\t\t\t\t\tpos === \"panelA\" ? togglePanelA :\n\t\t\t\t\t\t\t\t\tpos === \"panelB\" ? togglePanelB :\n\t\t\t\t\t\t\t\t\ttogglePanelC;\n\t\t\t\t\t\t\t\ttoggleFn();\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t// ✅ 修复：选择功能后立即关闭菜单，重置状态\n\t\t\t\t\t\t\tsetMenuState({\n\t\t\t\t\t\t\t\tisOpen: false,\n\t\t\t\t\t\t\t\tposition: null,\n\t\t\t\t\t\t\t\tanchorElement: null,\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t}\n\t\t\t\t\t}}\n\t\t\t\t\tanchorElement={menuState.anchorElement}\n\t\t\t\t/>\n\t\t\t)}\n\t\t</motion.div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/components/layout/FullscreenHeader.tsx",
    "content": "/**\n * Maximize 模式 Header 组件\n */\n\n\"use client\";\n\nimport Image from \"next/image\";\nimport { LayoutSelector } from \"@/components/common/layout/LayoutSelector\";\nimport { ThemeStyleSelect } from \"@/components/common/theme/ThemeStyleSelect\";\nimport { ThemeToggle } from \"@/components/common/theme/ThemeToggle\";\nimport { LanguageToggle } from \"@/components/common/ui/LanguageToggle\";\nimport { SettingsToggle } from \"@/components/common/ui/SettingsToggle\";\nimport { HeaderIsland } from \"@/components/notification/HeaderIsland\";\n\ninterface MaximizeHeaderProps {\n\thasNotifications: boolean;\n}\n\nexport function MaximizeHeader({ hasNotifications }: MaximizeHeaderProps) {\n\treturn (\n\t\t<header className=\"relative flex h-15 shrink-0 items-center bg-primary-foreground dark:bg-accent px-4 text-foreground overflow-visible\">\n\t\t\t{/* 左侧：Logo */}\n\t\t\t<div className=\"flex items-center gap-2 shrink-0\">\n\t\t\t\t<div className=\"relative h-8 w-8 shrink-0\">\n\t\t\t\t\t{/* 浅色模式图标 */}\n\t\t\t\t\t<Image\n\t\t\t\t\t\tsrc=\"/free-todo-logos/free_todo_icon_4_dark_with_grid.png\"\n\t\t\t\t\t\talt=\"Free Todo Logo\"\n\t\t\t\t\t\twidth={32}\n\t\t\t\t\t\theight={32}\n\t\t\t\t\t\tclassName=\"object-contain block dark:hidden\"\n\t\t\t\t\t/>\n\t\t\t\t\t{/* 深色模式图标 */}\n\t\t\t\t\t<Image\n\t\t\t\t\t\tsrc=\"/free-todo-logos/free_todo_icon_4_with_grid.png\"\n\t\t\t\t\t\talt=\"Free Todo Logo\"\n\t\t\t\t\t\twidth={32}\n\t\t\t\t\t\theight={32}\n\t\t\t\t\t\tclassName=\"object-contain hidden dark:block\"\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\t\t\t\t<h1 className=\"text-lg font-semibold tracking-tight text-foreground\">\n\t\t\t\t\tFree Todo: Your AI Secretary\n\t\t\t\t</h1>\n\t\t\t</div>\n\n\t\t\t{/* 中间：通知区域 */}\n\t\t\t{hasNotifications && (\n\t\t\t\t<div className=\"flex-1 flex items-center justify-center relative min-w-0 overflow-visible\">\n\t\t\t\t\t<HeaderIsland />\n\t\t\t\t</div>\n\t\t\t)}\n\n\t\t\t{/* 占位符：当没有通知时保持布局平衡 */}\n\t\t\t{!hasNotifications && <div className=\"flex-1\" />}\n\n\t\t\t{/* 右侧：工具 */}\n\t\t\t<div className=\"flex items-center gap-2 shrink-0\">\n\t\t\t\t<LayoutSelector />\n\t\t\t\t<ThemeStyleSelect />\n\t\t\t\t<ThemeToggle />\n\t\t\t\t<LanguageToggle />\n\t\t\t\t<SettingsToggle />\n\t\t\t</div>\n\t\t</header>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/components/layout/PanelContainer.tsx",
    "content": "\"use client\";\n\nimport { useDroppable } from \"@dnd-kit/core\";\nimport { motion } from \"framer-motion\";\nimport { useEffect, useState } from \"react\";\nimport type { PanelPosition } from \"@/lib/config/panel-config\";\nimport { useUiStore } from \"@/lib/store/ui-store\";\nimport { cn } from \"@/lib/utils\";\n\ninterface PanelContainerProps {\n\tposition: PanelPosition;\n\tisVisible: boolean;\n\twidth: number;\n\tchildren: React.ReactNode;\n\tclassName?: string;\n\tisDragging?: boolean;\n}\n\n// 动画配置常量 - 优化后的弹簧动画参数，确保平滑且快速\nconst ANIMATION_CONFIG = {\n\tspring: {\n\t\ttype: \"spring\" as const,\n\t\tstiffness: 280,\n\t\tdamping: 28,\n\t\tmass: 0.9,\n\t},\n};\n\nexport function PanelContainer({\n\tposition,\n\tisVisible,\n\twidth,\n\tchildren,\n\tclassName,\n\tisDragging = false,\n}: PanelContainerProps) {\n\tconst { getFeatureByPosition } = useUiStore();\n\tconst [mounted, setMounted] = useState(false);\n\n\t// 确保客户端 hydration 完成后再渲染，避免 SSR 和客户端不一致\n\tuseEffect(() => {\n\t\tsetMounted(true);\n\t}, []);\n\n\tconst flexBasis = `${Math.round(width * 1000) / 10}%`;\n\n\t// 获取位置对应的功能，用于 aria-label\n\t// 在 SSR 时使用默认值，避免 hydration 错误\n\tconst feature = mounted ? getFeatureByPosition(position) : null;\n\tconst ariaLabelMap: Record<string, string> = {\n\t\tcalendar: \"Calendar Panel\",\n\t\ttodos: \"Todos Panel\",\n\t\tchat: \"Chat Panel\",\n\t\ttodoDetail: \"Todo Detail Panel\",\n\t\tdiary: \"Diary Panel\",\n\t\tsettings: \"Settings Panel\",\n\t\tcostTracking: \"Cost Tracking Panel\",\n\t};\n\n\t// 拖动时使用即时更新，禁用动画\n\tconst transition = isDragging ? { duration: 0 } : ANIMATION_CONFIG.spring;\n\n\t// 在 SSR 时使用默认值，避免 hydration 错误\n\tconst ariaLabel =\n\t\tmounted && feature ? ariaLabelMap[feature] || \"Panel\" : \"Panel\";\n\n\t// 设置面板header作为可放置区域\n\tconst { setNodeRef: setDroppableRef, isOver } = useDroppable({\n\t\tid: `panel-drop-${position}`,\n\t\tdata: {\n\t\t\ttype: \"PANEL_HEADER\",\n\t\t\tmetadata: {\n\t\t\t\tposition,\n\t\t\t},\n\t\t},\n\t});\n\n\treturn (\n\t\t<motion.section\n\t\t\tkey={position}\n\t\t\taria-label={ariaLabel}\n\t\t\tsuppressHydrationWarning\n\t\t\tdata-panel={position}\n\t\t\tref={setDroppableRef}\n\t\t\tclassName={cn(\n\t\t\t\t\"relative flex h-full min-h-0 flex-col\",\n\t\t\t\t\"bg-[oklch(var(--card))]\",\n\t\t\t\t\"rounded-(--radius)\",\n\t\t\t\t\"overflow-hidden\",\n\t\t\t\t// 边框样式：正常状态 vs 拖拽悬停状态\n\t\t\t\tisOver && isVisible\n\t\t\t\t\t? \"border-2 border-primary/70\"\n\t\t\t\t\t: \"border border-[oklch(var(--border))]\",\n\t\t\t\t// 当不可见时，隐藏边框和背景，避免残留视觉元素\n\t\t\t\t!isVisible && \"border-transparent bg-transparent\",\n\t\t\t\tclassName,\n\t\t\t)}\n\t\t\tinitial={false}\n\t\t\tanimate={{\n\t\t\t\tflexBasis: isVisible ? flexBasis : \"0%\",\n\t\t\t\topacity: isVisible ? 1 : 0,\n\t\t\t\tscale: isVisible ? 1 : 0,\n\t\t\t\t// 确保隐藏时不占用任何空间\n\t\t\t\twidth: isVisible ? \"auto\" : 0,\n\t\t\t\tpadding: isVisible ? undefined : 0,\n\t\t\t\tborderWidth: isVisible ? undefined : 0,\n\t\t\t}}\n\t\t\ttransition={transition}\n\t\t\tstyle={{\n\t\t\t\tminWidth: 0,\n\t\t\t\t// 当不可见时，不需要占用空间\n\t\t\t\tflexGrow: isVisible ? 1 : 0,\n\t\t\t\tflexShrink: isVisible ? 1 : 0,\n\t\t\t\t...(isVisible ? {} : { margin: 0 }),\n\t\t\t\twillChange: isDragging\n\t\t\t\t\t? \"flex-basis\"\n\t\t\t\t\t: \"flex-basis, transform, opacity\",\n\t\t\t}}\n\t\t>\n\t\t\t{children}\n\t\t</motion.section>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/components/layout/PanelContent.tsx",
    "content": "\"use client\";\n\nimport { useTranslations } from \"next-intl\";\nimport { Suspense, useEffect, useState } from \"react\";\nimport {\n\tPanelHeader,\n\tPanelPositionProvider,\n} from \"@/components/common/layout/PanelHeader\";\nimport type { PanelPosition } from \"@/lib/config/panel-config\";\nimport {\n\tgetPanelLazyComponent,\n\tgetPanelPlugin,\n} from \"@/lib/plugins/registry\";\nimport { useUiStore } from \"@/lib/store/ui-store\";\n\ninterface PanelContentProps {\n\tposition: PanelPosition;\n}\n\nexport function PanelContent({ position }: PanelContentProps) {\n\tconst { getFeatureByPosition, panelFeatureMap, backendDisabledFeatures } =\n\t\tuseUiStore();\n\tconst t = useTranslations(\"page\");\n\tconst [mounted, setMounted] = useState(false);\n\n\tuseEffect(() => {\n\t\tsetMounted(true);\n\t}, []);\n\n\t// 在 SSR 时使用 null，避免 hydration 错误\n\tconst feature = mounted ? getFeatureByPosition(position) : null;\n\tconst assignedFeature = mounted ? panelFeatureMap[position] : null;\n\tconst backendDisabled =\n\t\tassignedFeature !== null &&\n\t\tbackendDisabledFeatures.includes(assignedFeature);\n\tconst plugin = assignedFeature ? getPanelPlugin(assignedFeature) : null;\n\tconst LazyPanel = feature ? getPanelLazyComponent(feature) : null;\n\tconst label = mounted && plugin ? t(plugin.labelKey) : \"\";\n\tconst placeholder = mounted && plugin ? t(plugin.placeholderKey) : \"\";\n\tconst Icon = plugin?.icon ?? null;\n\tconst unavailableBadge = backendDisabled ? (\n\t\t<span\n\t\t\tclassName=\"rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-medium text-amber-700\"\n\t\t\ttitle={t(\"backendUnavailableTooltip\")}\n\t\t>\n\t\t\t{t(\"backendUnavailableBadge\")}\n\t\t</span>\n\t) : null;\n\n\tconst placeholderView = (\n\t\t<div className=\"flex h-full flex-col rounded-(--radius) overflow-hidden\">\n\t\t\t{Icon && (\n\t\t\t\t<PanelHeader icon={Icon} title={label} actions={unavailableBadge} />\n\t\t\t)}\n\t\t\t<div className=\"flex flex-1 items-center justify-center text-sm text-muted-foreground\">\n\t\t\t\t{backendDisabled ? (\n\t\t\t\t\t<div className=\"flex flex-col items-center gap-1 px-4 text-center\">\n\t\t\t\t\t\t<span className=\"font-medium\">\n\t\t\t\t\t\t\t{t(\"backendUnavailableTitle\")}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<span className=\"text-xs text-muted-foreground\">\n\t\t\t\t\t\t\t{t(\"backendUnavailableDescription\")}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t</div>\n\t\t\t\t) : (\n\t\t\t\t\tplaceholder\n\t\t\t\t)}\n\t\t\t</div>\n\t\t</div>\n\t);\n\n\tif (LazyPanel) {\n\t\treturn (\n\t\t\t<PanelPositionProvider position={position}>\n\t\t\t\t<Suspense fallback={placeholderView}>\n\t\t\t\t\t<LazyPanel />\n\t\t\t\t</Suspense>\n\t\t\t</PanelPositionProvider>\n\t\t);\n\t}\n\n\treturn <PanelPositionProvider position={position}>{placeholderView}</PanelPositionProvider>;\n}\n"
  },
  {
    "path": "free-todo-frontend/components/layout/PanelRegion.tsx",
    "content": "\"use client\";\n\nimport { useEffect, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport { useWindowAdaptivePanels } from \"@/lib/hooks/useWindowAdaptivePanels\";\nimport { useUiStore } from \"@/lib/store/ui-store\";\nimport { cn } from \"@/lib/utils\";\nimport { BottomDock } from \"./BottomDock\";\nimport { PanelContainer } from \"./PanelContainer\";\nimport { PanelContent } from \"./PanelContent\";\nimport { ResizeHandle } from \"./ResizeHandle\";\n\n// ========== 布局常量 ==========\n/** BottomDock 容器的高度 (px) */\nconst BOTTOM_DOCK_HEIGHT = 60;\n/** Dock 和面板内容区之间的间距 (px) */\nconst DOCK_MARGIN_TOP = 0;\n\ninterface PanelRegionProps {\n\t/** Panel 区域的宽度（用于计算显示多少个 Panel） */\n\twidth: number;\n\t/** PanelRegion 的总高度（包括 Panels 容器 + BottomDock），用于计算 Panels 容器固定高度 */\n\theight?: number;\n\t/** 是否在 MAXIMIZE 模式下（MAXIMIZE 模式下始终显示 3 个 panel，PANEL 模式下根据宽度显示） */\n\tisMaximizeMode?: boolean;\n\t/** 是否在 PANEL 模式下（用于 BottomDock 的显示逻辑） */\n\tisInPanelMode?: boolean;\n\t/** 是否正在拖拽 Panel A */\n\tisDraggingPanelA?: boolean;\n\t/** 是否正在拖拽 Panel C */\n\tisDraggingPanelC?: boolean;\n\t/** 是否正在调整 Panel 窗口大小 */\n\tisResizingPanel?: boolean;\n\t/** Panel A 调整宽度的回调 */\n\tonPanelAResizePointerDown?: (e: React.PointerEvent<HTMLDivElement>) => void;\n\t/** Panel C 调整宽度的回调 */\n\tonPanelCResizePointerDown?: (e: React.PointerEvent<HTMLDivElement>) => void;\n\t/** 容器引用（用于拖动计算） */\n\tcontainerRef?: React.RefObject<HTMLDivElement | null>;\n}\n\n/**\n * PanelRegion 组件：可复用的 Panel 区域\n * 包含 Panels 容器和 BottomDock\n * 用于 Panel 窗口和完整页面\n */\nexport function PanelRegion({\n\twidth,\n\theight, // PanelRegion 总高度（包括 Panels 容器 + BottomDock）\n\t// isMaximizeMode 目前仅用于 props 兼容，逻辑上宽度规则一致，故不再解构使用\n\tisInPanelMode = true,\n\tisDraggingPanelA = false,\n\tisDraggingPanelC = false,\n\tisResizingPanel = false,\n\tonPanelAResizePointerDown,\n\tonPanelCResizePointerDown,\n\tcontainerRef: externalContainerRef,\n}: PanelRegionProps) {\n\tconst internalContainerRef = useRef<HTMLDivElement>(null);\n\tconst containerRef = externalContainerRef || internalContainerRef;\n\t// BottomDock 容器 ref（用于鼠标位置检测）\n\tconst bottomDockContainerRef = useRef<HTMLDivElement>(null);\n\n\t// 确保客户端 hydration 完成后再渲染，避免 SSR 和客户端不一致\n\tconst [mounted, setMounted] = useState(false);\n\n\tuseEffect(() => {\n\t\tsetMounted(true);\n\t}, []);\n\n\t// 获取 panel 的打开状态和当前宽度配置\n\tconst { isPanelAOpen, isPanelBOpen, isPanelCOpen, panelAWidth, panelCWidth } =\n\t\tuseUiStore();\n\n\t// 使用 useWindowAdaptivePanels 进行自适应管理\n\tuseWindowAdaptivePanels(containerRef);\n\n\t// 根据宽度决定显示哪些 Panel 槽位（只受宽度影响）\n\t// 无论 MAXIMIZE 还是 PANEL 模式，都按统一阈值：\n\t// - width < 800：只显示 A\n\t// - 800 <= width < 1200：显示 A / B\n\t// - width >= 1200：显示 A / B / C\n\tconst PANEL_DUAL_THRESHOLD = 800;\n\tconst PANEL_TRIPLE_THRESHOLD = 1200;\n\t// 在 SSR 时使用默认值，避免 hydration 不匹配\n\tconst shouldShowPanelB = mounted ? width >= PANEL_DUAL_THRESHOLD : false;\n\tconst shouldShowPanelC = mounted ? width >= PANEL_TRIPLE_THRESHOLD : false;\n\n\t// 面板可见性：由 store 控制（在 MAXIMIZE 入口处会确保三者默认打开）\n\t// 在 SSR 时使用默认值，避免 hydration 不匹配\n\tconst panelAVisible = mounted ? isPanelAOpen : true;\n\tconst panelBVisible = mounted ? isPanelBOpen : false;\n\tconst panelCVisible = mounted ? isPanelCOpen : false;\n\n\t// 实际应该渲染的 Panel（需要同时满足：宽度允许该槽位 + 该 Panel 处于打开状态）\n\tconst showPanelA = panelAVisible;\n\tconst showPanelB = shouldShowPanelB && panelBVisible;\n\tconst showPanelC = shouldShowPanelC && panelCVisible;\n\tconst showPanelAHandle = showPanelA && showPanelB;\n\tconst showPanelCHandle = showPanelC && (showPanelB || showPanelA);\n\tconst isACOnly = showPanelA && showPanelC && !showPanelB;\n\n\t// 计算实际显示的 panel 槽位数量（用于 BottomDock）\n\t// - 只由宽度阈值决定（1 / 2 / 3），和页面上可分配的槽位数保持同步\n\tconst visiblePanelCount = useMemo(() => {\n\t\tif (shouldShowPanelC) return 3;\n\t\tif (shouldShowPanelB) return 2;\n\t\treturn 1;\n\t}, [shouldShowPanelB, shouldShowPanelC]);\n\n\t// 计算 layoutState：根据「实际可见」的 Panel 数量 + 当前 store 中的宽度配置分配宽度\n\t// 在 SSR 时使用默认值，避免 hydration 不匹配\n\tconst layoutState = useMemo(() => {\n\t\t// SSR 时返回默认布局（只有 Panel A）\n\t\tif (!mounted) {\n\t\t\treturn {\n\t\t\t\tpanelAWidth: 1,\n\t\t\t\tpanelBWidth: 0,\n\t\t\t\tpanelCWidth: 0,\n\t\t\t};\n\t\t}\n\n\t\tconst clampedPanelA = Math.min(Math.max(panelAWidth, 0.1), 0.9);\n\n\t\t// 三栏场景：A / B / C（A/B/C 都可见）\n\t\tif (showPanelA && showPanelB && showPanelC) {\n\t\t\t// panelCWidth 是右侧占比，panelAWidth 是左侧在剩余宽度中的比例\n\t\t\tconst baseWidth = 1 - panelCWidth;\n\t\t\tconst safeBase = baseWidth > 0 ? baseWidth : 1;\n\t\t\tconst a = safeBase * clampedPanelA;\n\t\t\tconst c = panelCWidth;\n\t\t\tconst b = Math.max(0, 1 - a - c);\n\n\t\t\treturn {\n\t\t\t\tpanelAWidth: a,\n\t\t\t\tpanelBWidth: b,\n\t\t\t\tpanelCWidth: c,\n\t\t\t};\n\t\t}\n\n\t\t// 双栏场景 1：A / C（A 和 C 可见，B 不可见）\n\t\tif (showPanelA && !showPanelB && showPanelC) {\n\t\t\treturn {\n\t\t\t\tpanelAWidth: clampedPanelA,\n\t\t\t\tpanelBWidth: 0,\n\t\t\t\tpanelCWidth: 1 - clampedPanelA,\n\t\t\t};\n\t\t}\n\n\t\t// 双栏场景 2：A / B（A 和 B 可见，C 不可见）\n\t\tif (showPanelA && showPanelB && !showPanelC) {\n\t\t\treturn {\n\t\t\t\tpanelAWidth: clampedPanelA,\n\t\t\t\tpanelBWidth: 1 - clampedPanelA,\n\t\t\t\tpanelCWidth: 0,\n\t\t\t};\n\t\t}\n\n\t\t// 双栏场景 3：B / C（只有 B 和 C 可见）\n\t\tif (!showPanelA && showPanelB && showPanelC) {\n\t\t\tconst baseWidth = 1 - panelCWidth;\n\t\t\tconst safeBase = baseWidth > 0 ? baseWidth : 1;\n\t\t\tconst b = safeBase; // 左侧全部给 B\n\t\t\tconst c = panelCWidth;\n\t\t\treturn {\n\t\t\t\tpanelAWidth: 0,\n\t\t\t\tpanelBWidth: b,\n\t\t\t\tpanelCWidth: c,\n\t\t\t};\n\t\t}\n\n\t\t// 单栏场景 1：只有 A\n\t\tif (showPanelA && !showPanelB && !showPanelC) {\n\t\t\treturn {\n\t\t\t\tpanelAWidth: 1,\n\t\t\t\tpanelBWidth: 0,\n\t\t\t\tpanelCWidth: 0,\n\t\t\t};\n\t\t}\n\n\t\t// 单栏场景 2：只有 B\n\t\tif (!showPanelA && showPanelB && !showPanelC) {\n\t\t\treturn {\n\t\t\t\tpanelAWidth: 0,\n\t\t\t\tpanelBWidth: 1,\n\t\t\t\tpanelCWidth: 0,\n\t\t\t};\n\t\t}\n\n\t\t// 单栏场景 3：只有 C\n\t\tif (!showPanelA && !showPanelB && showPanelC) {\n\t\t\treturn {\n\t\t\t\tpanelAWidth: 0,\n\t\t\t\tpanelBWidth: 0,\n\t\t\t\tpanelCWidth: 1,\n\t\t\t};\n\t\t}\n\n\t\t// 兜底：没有任何 Panel 被认为可见时，仍然保留 A\n\t\treturn {\n\t\t\tpanelAWidth: 1,\n\t\t\tpanelBWidth: 0,\n\t\t\tpanelCWidth: 0,\n\t\t};\n\t}, [mounted, showPanelA, showPanelB, showPanelC, panelAWidth, panelCWidth]);\n\n\t// 计算 Panels 容器的固定高度：PanelRegion 总高度 - BottomDock 高度 - Dock 上方间距\n\tconst panelsContainerHeight = useMemo(() => {\n\t\tif (height && height > 0) {\n\t\t\treturn height - BOTTOM_DOCK_HEIGHT - DOCK_MARGIN_TOP;\n\t\t}\n\t\treturn undefined; // 如果没有提供 height，使用 flex-1 自适应（兼容完整页面模式）\n\t}, [height]);\n\n\t// ✅ 关键修复：使用 useLayoutEffect 持续确保底部容器高度固定，不受宽度变化影响\n\tuseLayoutEffect(() => {\n\t\tconst container = bottomDockContainerRef.current;\n\t\tif (!container) return;\n\n\t\t// 使用双重 requestAnimationFrame 确保在 React 应用 style 之后执行\n\t\trequestAnimationFrame(() => {\n\t\t\trequestAnimationFrame(() => {\n\t\t\t\tif (container) {\n\t\t\t\t\t// 强制设置高度，使用 !important 确保优先级\n\t\t\t\t\tcontainer.style.setProperty('height', '60px', 'important');\n\t\t\t\t\tcontainer.style.setProperty('min-height', '60px', 'important');\n\t\t\t\t\tcontainer.style.setProperty('max-height', '60px', 'important');\n\t\t\t\t}\n\t\t\t});\n\t\t});\n\t}, []); // 只在挂载时设置一次\n\n\t// ✅ 关键修复：使用 useLayoutEffect 持续确保 Panels 容器高度固定\n\t// biome-ignore lint/correctness/useExhaustiveDependencies: containerRef.current is stable and doesn't need to be in deps\n\tuseLayoutEffect(() => {\n\t\tconst panelsContainer = containerRef.current;\n\t\tif (!panelsContainer || !panelsContainerHeight) return;\n\n\t\t// 使用双重 requestAnimationFrame 确保在 React 应用 style 之后执行\n\t\trequestAnimationFrame(() => {\n\t\t\trequestAnimationFrame(() => {\n\t\t\t\tif (panelsContainer && panelsContainerHeight) {\n\t\t\t\t\t// 强制设置高度，使用 !important 确保优先级\n\t\t\t\t\tpanelsContainer.style.setProperty('height', `${panelsContainerHeight}px`, 'important');\n\t\t\t\t\tpanelsContainer.style.setProperty('min-height', `${panelsContainerHeight}px`, 'important');\n\t\t\t\t\tpanelsContainer.style.setProperty('max-height', `${panelsContainerHeight}px`, 'important');\n\t\t\t\t}\n\t\t\t});\n\t\t});\n\t}, [panelsContainerHeight]); // 只在高度变化时重新设置\n\n\treturn (\n\t\t<div className=\"flex flex-col h-full w-full bg-primary-foreground dark:bg-accent\" style={{ opacity: 1 }}>\n\t\t\t{/* Panels 容器：固定高度 = PanelRegion 总高度 - BottomDock 高度 - 间距 */}\n\t\t\t<div\n\t\t\t\tref={containerRef}\n\t\t\t\tclassName={cn(\n\t\t\t\t\t\"relative bg-primary-foreground dark:bg-accent flex min-h-0 overflow-hidden px-3\",\n\t\t\t\t\tpanelsContainerHeight ? \"\" : \"flex-1\" // 如果有固定高度，不使用 flex-1；否则使用 flex-1（兼容完整页面模式）\n\t\t\t\t)}\n\t\t\t\tstyle={{\n\t\t\t\t\tpointerEvents: \"auto\",\n\t\t\t\t\topacity: 1,\n\t\t\t\t\t...(panelsContainerHeight\n\t\t\t\t\t\t? {\n\t\t\t\t\t\theight: `${panelsContainerHeight}px`,\n\t\t\t\t\t\tminHeight: `${panelsContainerHeight}px`,\n\t\t\t\t\t\tmaxHeight: `${panelsContainerHeight}px`,\n\t\t\t\t\t  }\n\t\t\t\t\t\t: {})\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t{/* 根据窗口宽度显示对应数量的 Panel */}\n\t\t\t\t<PanelContainer\n\t\t\t\t\tkey=\"panelA\"\n\t\t\t\t\tposition=\"panelA\"\n\t\t\t\t\tisVisible={panelAVisible}\n\t\t\t\t\twidth={\n\t\t\t\t\t\tshouldShowPanelC\n\t\t\t\t\t\t\t? layoutState.panelAWidth\n\t\t\t\t\t\t\t: shouldShowPanelB\n\t\t\t\t\t\t\t\t? layoutState.panelAWidth\n\t\t\t\t\t\t\t\t: 1\n\t\t\t\t\t}\n\t\t\t\t\tisDragging={isDraggingPanelA || isDraggingPanelC || isResizingPanel}\n\t\t\t\t\t// 当只有 A / C 两个 Panel（B 被关闭）时，A/C 中间不再额外加双倍边距，只留中间一条分隔条\n\t\t\t\t\tclassName={\n\t\t\t\t\t\t\"mx-1\"\n\t\t\t\t\t}\n\t\t\t\t>\n\t\t\t\t\t<PanelContent position=\"panelA\" />\n\t\t\t\t</PanelContainer>\n\n\t\t\t\t{shouldShowPanelB && (\n\t\t\t\t\t<>\n\t\t\t\t\t\t<ResizeHandle\n\t\t\t\t\t\t\tkey=\"panelA-resize-handle\"\n\t\t\t\t\t\t\tonPointerDown={onPanelAResizePointerDown || (() => {})}\n\t\t\t\t\t\t\tisDragging={isDraggingPanelA}\n\t\t\t\t\t\t\t// 当中间 Panel 实际关闭时，隐藏这条分隔线，避免出现双分隔条\n\t\t\t\t\t\t\tisVisible={showPanelAHandle}\n\t\t\t\t\t\t/>\n\n\t\t\t\t\t\t<PanelContainer\n\t\t\t\t\t\t\tkey=\"panelB\"\n\t\t\t\t\t\t\tposition=\"panelB\"\n\t\t\t\t\t\t\tisVisible={panelBVisible}\n\t\t\t\t\t\t\twidth={\n\t\t\t\t\t\t\t\tshouldShowPanelC\n\t\t\t\t\t\t\t\t\t? layoutState.panelBWidth\n\t\t\t\t\t\t\t\t\t: 1 - layoutState.panelAWidth\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tisDragging={isDraggingPanelA || isDraggingPanelC || isResizingPanel}\n\t\t\t\t\t\t\tclassName=\"mx-1\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<PanelContent position=\"panelB\" />\n\t\t\t\t\t\t</PanelContainer>\n\t\t\t\t\t</>\n\t\t\t\t)}\n\n\t\t\t\t{shouldShowPanelC && (\n\t\t\t\t\t<>\n\t\t\t\t\t\t<ResizeHandle\n\t\t\t\t\t\t\tkey=\"panelC-resize-handle\"\n\t\t\t\t\t\t\tonPointerDown={\n\t\t\t\t\t\t\t\t(isACOnly\n\t\t\t\t\t\t\t\t\t? onPanelAResizePointerDown\n\t\t\t\t\t\t\t\t\t: onPanelCResizePointerDown) || (() => {})\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tisDragging={isACOnly ? isDraggingPanelA : isDraggingPanelC}\n\t\t\t\t\t\t\t// 右侧分隔线只在右侧 Panel 实际可见时显示\n\t\t\t\t\t\t\tisVisible={showPanelCHandle}\n\t\t\t\t\t\t/>\n\n\t\t\t\t\t\t<PanelContainer\n\t\t\t\t\t\t\tkey=\"panelC\"\n\t\t\t\t\t\t\tposition=\"panelC\"\n\t\t\t\t\t\t\tisVisible={panelCVisible}\n\t\t\t\t\t\t\twidth={layoutState.panelCWidth}\n\t\t\t\t\t\t\tisDragging={isDraggingPanelA || isDraggingPanelC || isResizingPanel}\n\t\t\t\t\t\t\tclassName={\n\t\t\t\t\t\t\t\t\"mx-1\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<PanelContent position=\"panelC\" />\n\t\t\t\t\t\t</PanelContainer>\n\t\t\t\t\t</>\n\t\t\t\t)}\n\t\t\t</div>\n\n\t\t\t{/* BottomDock 底部区域 */}\n\t\t\t<div\n\t\t\t\tref={(el) => {\n\t\t\t\t\tbottomDockContainerRef.current = el;\n\t\t\t\t\t// 强制固定高度，防止随内容抖动\n\t\t\t\t\tif (el) {\n\t\t\t\t\t\trequestAnimationFrame(() => {\n\t\t\t\t\t\t\tif (el) {\n\t\t\t\t\t\t\t\tel.style.setProperty(\"height\", `${BOTTOM_DOCK_HEIGHT}px`, \"important\");\n\t\t\t\t\t\t\t\tel.style.setProperty(\"min-height\", `${BOTTOM_DOCK_HEIGHT}px`, \"important\");\n\t\t\t\t\t\t\t\tel.style.setProperty(\"max-height\", `${BOTTOM_DOCK_HEIGHT}px`, \"important\");\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\t\t\t\t}}\n\t\t\t\tclassName=\"relative flex shrink-0 items-center justify-center bg-primary-foreground dark:bg-accent\"\n\t\t\t\tstyle={{\n\t\t\t\t\tpointerEvents: \"auto\",\n\t\t\t\t\theight: BOTTOM_DOCK_HEIGHT,\n\t\t\t\t\tmarginTop: DOCK_MARGIN_TOP,\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t<BottomDock\n\t\t\t\t\tclassName={isInPanelMode ? \"!relative !bottom-auto !left-auto !translate-x-0\" : undefined}\n\t\t\t\t\tisInPanelMode={isInPanelMode}\n\t\t\t\t\tpanelContainerRef={bottomDockContainerRef as React.RefObject<HTMLElement | null>}\n\t\t\t\t\tvisiblePanelCount={visiblePanelCount}\n\t\t\t\t/>\n\t\t\t</div>\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/components/layout/PanelSelectorMenu.tsx",
    "content": "\"use client\";\n\nimport { AnimatePresence, motion } from \"framer-motion\";\nimport { useTranslations } from \"next-intl\";\nimport { useEffect, useMemo, useRef } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport type { PanelFeature, PanelPosition } from \"@/lib/config/panel-config\";\nimport { FEATURE_ICON_MAP } from \"@/lib/config/panel-config\";\nimport { useUiStore } from \"@/lib/store/ui-store\";\nimport { cn } from \"@/lib/utils\";\n\ninterface PanelSelectorMenuProps {\n\tposition: PanelPosition;\n\tisOpen: boolean;\n\tonClose: () => void;\n\tonSelect: (feature: PanelFeature) => void;\n\tanchorElement: HTMLElement | null;\n}\n\n// 功能到翻译键的映射\nfunction getFeatureLabelKey(feature: PanelFeature): string {\n\treturn feature;\n}\n\nexport function PanelSelectorMenu({\n\tposition: _position,\n\tisOpen,\n\tonClose,\n\tonSelect,\n\tanchorElement,\n}: PanelSelectorMenuProps) {\n\tconst menuRef = useRef<HTMLDivElement>(null);\n\tconst { getAvailableFeatures } = useUiStore();\n\tconst t = useTranslations(\"bottomDock\");\n\n\t// ✅ 修复：订阅 disabledFeatures 状态，确保与设置页面同步\n\t// 使用 useMemo 确保当 disabledFeatures 变化时，可用功能列表会重新计算\n\tconst availableFeatures = useMemo(() => {\n\t\t// 使用 getAvailableFeatures 来同步设置界面的开启/关闭状态\n\t\t// 但是要确保settings始终包含（设置功能一定被包含的）\n\t\tconst baseAvailableFeatures = getAvailableFeatures();\n\t\tconst allAvailableFeatures: PanelFeature[] = baseAvailableFeatures.includes(\"settings\")\n\t\t\t? baseAvailableFeatures\n\t\t\t: [...baseAvailableFeatures, \"settings\" as PanelFeature];\n\t\treturn allAvailableFeatures;\n\t}, [getAvailableFeatures]);\n\n\t// 点击外部关闭菜单\n\tuseEffect(() => {\n\t\tif (!isOpen) return;\n\n\t\tconst handleClickOutside = (event: MouseEvent) => {\n\t\t\tif (\n\t\t\t\tmenuRef.current &&\n\t\t\t\t!menuRef.current.contains(event.target as Node) &&\n\t\t\t\tanchorElement &&\n\t\t\t\t!anchorElement.contains(event.target as Node)\n\t\t\t) {\n\t\t\t\tonClose();\n\t\t\t}\n\t\t};\n\n\t\tconst handleEscape = (event: KeyboardEvent) => {\n\t\t\tif (event.key === \"Escape\") {\n\t\t\t\tonClose();\n\t\t\t}\n\t\t};\n\n\t\tdocument.addEventListener(\"mousedown\", handleClickOutside);\n\t\tdocument.addEventListener(\"keydown\", handleEscape);\n\n\t\treturn () => {\n\t\t\tdocument.removeEventListener(\"mousedown\", handleClickOutside);\n\t\t\tdocument.removeEventListener(\"keydown\", handleEscape);\n\t\t};\n\t}, [isOpen, onClose, anchorElement]);\n\n\t// 计算菜单位置：使用 bottom 锚定，让菜单「底部」紧贴按钮「顶部」\n\tconst getMenuPosition = () => {\n\t\tif (!anchorElement) {\n\t\t\treturn { top: 0, left: 0 } as const;\n\t\t}\n\n\t\tconst rect = anchorElement.getBoundingClientRect();\n\t\tconst windowHeight = window.innerHeight;\n\n\t\treturn {\n\t\t\tbottom: windowHeight - rect.top, // 菜单底部贴在按钮顶部\n\t\t\tleft: rect.left,\n\t\t} as const;\n\t};\n\n\tif (availableFeatures.length === 0) {\n\t\treturn null;\n\t}\n\n\tconst menuPosition = getMenuPosition();\n\n\t// 使用 Portal 将菜单渲染到 body，避免被父元素样式影响\n\tconst menuContent = (\n\t\t<AnimatePresence>\n\t\t\t{isOpen && (\n\t\t\t\t<>\n\t\t\t\t\t{/* 背景遮罩 */}\n\t\t\t\t\t<motion.div\n\t\t\t\t\t\tinitial={{ opacity: 0 }}\n\t\t\t\t\t\tanimate={{ opacity: 1 }}\n\t\t\t\t\t\texit={{ opacity: 0 }}\n\t\t\t\t\t\tclassName=\"fixed inset-0 z-[100]\"\n\t\t\t\t\t\tonClick={onClose}\n\t\t\t\t\t/>\n\t\t\t\t\t{/* 菜单 */}\n\t\t\t\t\t<motion.div\n\t\t\t\t\t\tref={menuRef}\n\t\t\t\t\t\tdata-tour=\"panel-selector-menu\"\n\t\t\t\t\t\tinitial={{ opacity: 0, y: 10, scale: 0.95 }}\n\t\t\t\t\t\tanimate={{ opacity: 1, y: 0, scale: 1 }}\n\t\t\t\t\t\texit={{ opacity: 0, y: 10, scale: 0.95 }}\n\t\t\t\t\t\ttransition={{ duration: 0.15 }}\n\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\"fixed z-[101]\",\n\t\t\t\t\t\t\t\"bg-[oklch(var(--card))]/95\",\n\t\t\t\t\t\t\t\"border border-[oklch(var(--border))]\",\n\t\t\t\t\t\t\t\"rounded-lg\",\n\t\t\t\t\t\t\t\"shadow-lg\",\n\t\t\t\t\t\t\t\"py-1\",\n\t\t\t\t\t\t\t\"min-w-[110px]\",\n\t\t\t\t\t\t\t\"backdrop-blur-sm\",\n\t\t\t\t\t\t)}\n\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t// 仅设置其中一个：我们现在通过 bottom 来对齐\n\t\t\t\t\t\t\t...(Object.hasOwn(menuPosition, \"top\")\n\t\t\t\t\t\t\t\t? { top: `${(menuPosition as { top: number }).top}px` }\n\t\t\t\t\t\t\t\t: {}),\n\t\t\t\t\t\t\t...(Object.hasOwn(menuPosition, \"bottom\")\n\t\t\t\t\t\t\t\t? {\n\t\t\t\t\t\t\t\t\t\tbottom: `${(menuPosition as { bottom: number }).bottom}px`,\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t: {}),\n\t\t\t\t\t\t\tleft: `${menuPosition.left}px`,\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t{availableFeatures.map((feature) => {\n\t\t\t\t\t\t\tconst Icon = FEATURE_ICON_MAP[feature];\n\t\t\t\t\t\t\tconst labelKey = getFeatureLabelKey(feature);\n\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tkey={feature}\n\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\tonSelect(feature);\n\t\t\t\t\t\t\t\t\t\tonClose();\n\t\t\t\t\t\t\t\t\t\t// 派发事件通知引导流程面板已选择\n\t\t\t\t\t\t\t\t\t\twindow.dispatchEvent(\n\t\t\t\t\t\t\t\t\t\t\tnew CustomEvent(\"onboarding:panel-selected\", {\n\t\t\t\t\t\t\t\t\t\t\t\tdetail: { feature },\n\t\t\t\t\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\"w-full flex items-center gap-2\",\n\t\t\t\t\t\t\t\t\t\t\"px-3 py-2\",\n\t\t\t\t\t\t\t\t\t\t\"text-sm font-medium\",\n\t\t\t\t\t\t\t\t\t\t\"text-[oklch(var(--foreground))]\",\n\t\t\t\t\t\t\t\t\t\t\"hover:bg-[oklch(var(--muted))] dark:hover:bg-[oklch(var(--primary-weak-hover))]\",\n\t\t\t\t\t\t\t\t\t\t\"hover:text-[oklch(var(--foreground))]\",\n\t\t\t\t\t\t\t\t\t\t\"transition-colors\",\n\t\t\t\t\t\t\t\t\t\t\"first:rounded-t-lg last:rounded-b-lg\",\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<Icon className=\"h-4 w-4 shrink-0 text-[oklch(var(--primary))]\" />\n\t\t\t\t\t\t\t\t\t<span>{t(labelKey)}</span>\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t})}\n\t\t\t\t\t</motion.div>\n\t\t\t\t</>\n\t\t\t)}\n\t\t</AnimatePresence>\n\t);\n\n\t// 在客户端渲染时使用 Portal\n\tif (typeof window !== \"undefined\") {\n\t\treturn createPortal(menuContent, document.body);\n\t}\n\n\treturn null;\n}\n"
  },
  {
    "path": "free-todo-frontend/components/layout/ResizeHandle.tsx",
    "content": "\"use client\";\n\nimport { motion } from \"framer-motion\";\nimport type {\n\tMouseEvent as ReactMouseEvent,\n\tPointerEvent as ReactPointerEvent,\n} from \"react\";\nimport { useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\ninterface ResizeHandleProps {\n\tonPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void;\n\tisDragging: boolean;\n\tisVisible?: boolean;\n}\n\n// 与 PanelContainer 使用相同的动画配置，确保同步\nconst ANIMATION_CONFIG = {\n\tspring: {\n\t\ttype: \"spring\" as const,\n\t\tstiffness: 280,\n\t\tdamping: 28,\n\t\tmass: 0.9,\n\t},\n};\n\nexport function ResizeHandle({\n\tonPointerDown,\n\tisDragging,\n\tisVisible = true,\n}: ResizeHandleProps) {\n\tconst [isHovered, setIsHovered] = useState(false);\n\n\treturn (\n\t\t<motion.div\n\t\t\trole=\"separator\"\n\t\t\taria-orientation=\"vertical\"\n\t\t\tonPointerDown={isVisible ? onPointerDown : undefined}\n\t\t\t// 兼容性更好：同时监听 mouseDown，转交给同一个处理函数\n\t\t\tonMouseDown={\n\t\t\t\tisVisible\n\t\t\t\t\t? (event: ReactMouseEvent<HTMLDivElement>) =>\n\t\t\t\t\t\t\tonPointerDown(\n\t\t\t\t\t\t\t\tevent as unknown as ReactPointerEvent<HTMLDivElement>,\n\t\t\t\t\t\t\t)\n\t\t\t\t\t: undefined\n\t\t\t}\n\t\t\tonMouseEnter={() => isVisible && setIsHovered(true)}\n\t\t\tonMouseLeave={() => setIsHovered(false)}\n\t\t\tinitial={false}\n\t\t\tanimate={{\n\t\t\t\topacity: isVisible ? 1 : 0,\n\t\t\t\tscaleX: isVisible ? 1 : 0,\n\t\t\t\t// 分隔条整体宽度（含可点击区域）\n\t\t\t\twidth: isVisible ? 5 : 0,\n\t\t\t\t// 取消左右 margin，让 panel 之间的间距再小一点\n\t\t\t\tmarginLeft: 0,\n\t\t\t\tmarginRight: 0,\n\t\t\t}}\n\t\t\ttransition={ANIMATION_CONFIG.spring}\n\t\t\tclassName={cn(\n\t\t\t\t\"relative z-10 flex h-full items-center justify-center select-none touch-none\",\n\t\t\t\tisVisible && \"cursor-col-resize\",\n\t\t\t\tisDragging || isHovered ? \"bg-foreground/5\" : \"bg-transparent\",\n\t\t\t)}\n\t\t\tstyle={{\n\t\t\t\t// 当不可见时不占用 flex 空间\n\t\t\t\tflexShrink: isVisible ? 0 : 1,\n\t\t\t}}\n\t\t>\n\t\t\t<div\n\t\t\t\tclassName={cn(\n\t\t\t\t\t// 分隔条本体宽度 2px\n\t\t\t\t\t\"pointer-events-none h-7 w-0.5 rounded-full transition-all duration-150\",\n\t\t\t\t\tisDragging\n\t\t\t\t\t\t? \"bg-primary shadow-[0_0_0_1px_oklch(var(--primary))]\"\n\t\t\t\t\t\t: isHovered\n\t\t\t\t\t\t\t? \"bg-muted-foreground/60 shadow-[0_0_0_1px_oklch(var(--border))]\"\n\t\t\t\t\t\t\t: \"bg-border\",\n\t\t\t\t)}\n\t\t\t/>\n\t\t</motion.div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/components/notification/HeaderIsland.tsx",
    "content": "\"use client\";\n\nimport { AnimatePresence, motion } from \"framer-motion\";\nimport { Bell, Check, Clock, Settings, X } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { deleteNotificationApiNotificationsNotificationIdDelete } from \"@/lib/generated/notifications/notifications\";\nimport { useOpenSettings } from \"@/lib/hooks/useOpenSettings\";\nimport { useUpdateTodo } from \"@/lib/query\";\nimport { useNotificationStore } from \"@/lib/store/notification-store\";\nimport { toastError, toastSuccess } from \"@/lib/toast\";\n\n// 简单的相对时间格式化\nfunction formatTime(\n\ttimestamp: string,\n\tt: ReturnType<typeof useTranslations>,\n): string {\n\tconst date = new Date(timestamp);\n\tif (Number.isNaN(date.getTime())) {\n\t\treturn \"\";\n\t}\n\tconst now = new Date();\n\tconst diffMs = now.getTime() - date.getTime();\n\tconst diffMins = Math.floor(diffMs / 60000);\n\tif (diffMins < 1) {\n\t\treturn t(\"justNow\");\n\t}\n\tif (diffMins < 60) {\n\t\treturn t(\"minutesAgo\", { count: diffMins });\n\t}\n\tconst diffHours = Math.floor(diffMins / 60);\n\tif (diffHours < 24) {\n\t\treturn t(\"hoursAgo\", { count: diffHours });\n\t}\n\tconst diffDays = Math.floor(diffHours / 24);\n\treturn t(\"daysAgo\", { count: diffDays });\n}\n\n// 格式化当前时间\nfunction formatCurrentTime(t: ReturnType<typeof useTranslations>): {\n\ttime: string;\n\tdate: string;\n} {\n\tconst now = new Date();\n\n\t// 时间格式：HH:MM\n\tconst hours = now.getHours().toString().padStart(2, \"0\");\n\tconst minutes = now.getMinutes().toString().padStart(2, \"0\");\n\tconst time = `${hours}:${minutes}`;\n\n\t// 日期格式\n\tconst month = (now.getMonth() + 1).toString().padStart(2, \"0\");\n\tconst day = now.getDate().toString().padStart(2, \"0\");\n\tconst date = t(\"dateFormat\", { month, day });\n\n\treturn { time, date };\n}\n\nexport function HeaderIsland() {\n\tconst {\n\t\tnotifications,\n\t\tisExpanded,\n\t\ttoggleExpanded,\n\t\tremoveNotification,\n\t\tremoveNotificationsBySource,\n\t\tsetExpanded,\n\t} = useNotificationStore();\n\tconst t = useTranslations(\"todoExtraction\");\n\tconst tLayout = useTranslations(\"layout\");\n\tconst containerRef = useRef<HTMLDivElement>(null);\n\tconst [currentTime, setCurrentTime] = useState(() => formatCurrentTime(t));\n\tconst updateTodoMutation = useUpdateTodo();\n\tconst isProcessing = updateTodoMutation.isPending;\n\n\tconst currentNotification = notifications[0] ?? null;\n\tconst notificationCount = notifications.length;\n\n\t// 使用共享的打开设置 hook\n\tconst { openSettings } = useOpenSettings();\n\n\t// 检查是否是 LLM 配置通知\n\tconst isLlmConfigNotification = currentNotification?.source === \"llm-config\";\n\n\t// 更新时间\n\tuseEffect(() => {\n\t\tconst updateTime = () => {\n\t\t\tsetCurrentTime(formatCurrentTime(t));\n\t\t};\n\n\t\t// 立即更新一次\n\t\tupdateTime();\n\n\t\t// 每秒更新一次\n\t\tconst interval = setInterval(updateTime, 1000);\n\n\t\treturn () => clearInterval(interval);\n\t}, [t]);\n\n\t// 点击外部关闭\n\tuseEffect(() => {\n\t\tif (!isExpanded || notifications.length === 0) return;\n\n\t\tconst handleClickOutside = (event: MouseEvent) => {\n\t\t\tif (\n\t\t\t\tcontainerRef.current &&\n\t\t\t\t!containerRef.current.contains(event.target as Node)\n\t\t\t) {\n\t\t\t\tuseNotificationStore.getState().setExpanded(false);\n\t\t\t}\n\t\t};\n\n\t\tdocument.addEventListener(\"mousedown\", handleClickOutside);\n\t\treturn () => {\n\t\t\tdocument.removeEventListener(\"mousedown\", handleClickOutside);\n\t\t};\n\t}, [isExpanded, notifications.length]);\n\n\tconst closeNotification = async (notificationId: string, source?: string) => {\n\t\tif (source === \"ddl-reminder\" && notificationId) {\n\t\t\ttry {\n\t\t\t\tawait deleteNotificationApiNotificationsNotificationIdDelete(notificationId);\n\t\t\t} catch (error) {\n\t\t\t\t// 静默处理错误，即使删除失败也关闭前端通知\n\t\t\t\tconsole.warn(\"Failed to delete notification from backend:\", error);\n\t\t\t}\n\t\t}\n\n\t\tremoveNotification(notificationId);\n\t\tif (useNotificationStore.getState().notifications.length === 0) {\n\t\t\tsetExpanded(false);\n\t\t}\n\t};\n\n\t// 同意 draft todo\n\tconst handleAccept = async (\n\t\ttodoId: number | undefined,\n\t\te: React.MouseEvent,\n\t) => {\n\t\te.stopPropagation();\n\t\tif (!todoId || isProcessing) return;\n\n\t\ttry {\n\t\t\tawait updateTodoMutation.mutateAsync({\n\t\t\t\tid: todoId,\n\t\t\t\tinput: { status: \"active\" },\n\t\t\t});\n\t\t\ttoastSuccess(t(\"acceptSuccess\"));\n\t\t\tremoveNotificationsBySource(\"draft-todos\");\n\t\t\tsetExpanded(false);\n\t\t} catch (error) {\n\t\t\tconst errorMsg = error instanceof Error ? error.message : String(error);\n\t\t\t// 如果是 404 错误（todo 已被删除），静默关闭通知\n\t\t\tif (\n\t\t\t\terrorMsg.includes(\"404\") ||\n\t\t\t\terrorMsg.includes(\"Not Found\") ||\n\t\t\t\terrorMsg.includes(\"不存在\")\n\t\t\t) {\n\t\t\t\tremoveNotificationsBySource(\"draft-todos\");\n\t\t\t\tsetExpanded(false);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\ttoastError(t(\"acceptFailed\", { error: errorMsg }));\n\t\t}\n\t};\n\n\t// 拒绝 draft todo\n\tconst handleReject = async (\n\t\ttodoId: number | undefined,\n\t\te: React.MouseEvent,\n\t) => {\n\t\te.stopPropagation();\n\t\tif (!todoId || isProcessing) return;\n\n\t\ttry {\n\t\t\tawait updateTodoMutation.mutateAsync({\n\t\t\t\tid: todoId,\n\t\t\t\tinput: { status: \"canceled\" },\n\t\t\t});\n\t\t\ttoastSuccess(t(\"rejectSuccess\"));\n\t\t\tremoveNotificationsBySource(\"draft-todos\");\n\t\t\tsetExpanded(false);\n\t\t} catch (error) {\n\t\t\tconst errorMsg = error instanceof Error ? error.message : String(error);\n\t\t\t// 如果是 404 错误（todo 已被删除），静默关闭通知\n\t\t\tif (\n\t\t\t\terrorMsg.includes(\"404\") ||\n\t\t\t\terrorMsg.includes(\"Not Found\") ||\n\t\t\t\terrorMsg.includes(\"不存在\")\n\t\t\t) {\n\t\t\t\tremoveNotificationsBySource(\"draft-todos\");\n\t\t\t\tsetExpanded(false);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\ttoastError(t(\"rejectFailed\", { error: errorMsg }));\n\t\t}\n\t};\n\n\treturn (\n\t\t<div\n\t\t\tref={containerRef}\n\t\t\tclassName=\"absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-50\"\n\t\t>\n\t\t\t<motion.div\n\t\t\t\tinitial={false}\n\t\t\t\tanimate={{\n\t\t\t\t\twidth: isExpanded ? \"auto\" : \"auto\",\n\t\t\t\t\theight: isExpanded ? \"auto\" : \"auto\",\n\t\t\t\t\tminWidth: isExpanded ? 800 : currentNotification ? 400 : 200,\n\t\t\t\t\tmaxWidth: isExpanded ? 1200 : currentNotification ? 500 : 300,\n\t\t\t\t}}\n\t\t\t\ttransition={{\n\t\t\t\t\ttype: \"spring\",\n\t\t\t\t\tstiffness: 300,\n\t\t\t\t\tdamping: 30,\n\t\t\t\t}}\n\t\t\t\tclassName=\"relative\"\n\t\t\t>\n\t\t\t\t{currentNotification ? (\n\t\t\t\t\t// 有通知时显示通知内容\n\t\t\t\t\t<motion.div\n\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\tif (!isExpanded && isLlmConfigNotification) {\n\t\t\t\t\t\t\t\topenSettings();\n\t\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\ttoggleExpanded();\n\t\t\t\t\t\t}}\n\t\t\t\t\t\twhileHover={{\n\t\t\t\t\t\t\tscale: 1.02,\n\t\t\t\t\t\t\ty: -2,\n\t\t\t\t\t\t}}\n\t\t\t\t\t\twhileTap={{\n\t\t\t\t\t\t\tscale: 0.98,\n\t\t\t\t\t\t}}\n\t\t\t\t\t\trole=\"button\"\n\t\t\t\t\t\ttabIndex={0}\n\t\t\t\t\t\tonKeyDown={(e) => {\n\t\t\t\t\t\t\tif (e.key === \"Enter\" || e.key === \" \") {\n\t\t\t\t\t\t\t\te.preventDefault();\n\t\t\t\t\t\t\t\tif (!isExpanded && isLlmConfigNotification) {\n\t\t\t\t\t\t\t\t\topenSettings();\n\t\t\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\ttoggleExpanded();\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tclassName={`\n\t\t\t\t\t\trelative flex items-center gap-2 overflow-hidden rounded-full\n\t\t\t\t\t\tbg-background/95 backdrop-blur-sm border border-border/50\n\t\t\t\t\t\tshadow-lg transition-all duration-300 cursor-pointer\n\t\t\t\t\t\thover:shadow-2xl hover:shadow-primary/10 hover:border-primary/30\n\t\t\t\t\t\thover:bg-background\n\t\t\t\t\t\tfocus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\n\t\t\t\t\t\t${isExpanded ? \"px-4 py-2.5\" : \"px-3 py-2\"}\n\t\t\t\t\t`}\n\t\t\t\t\t\taria-label={\n\t\t\t\t\t\t\tisExpanded ? t(\"collapseNotification\") : t(\"expandNotification\")\n\t\t\t\t\t\t}\n\t\t\t\t\t>\n\t\t\t\t\t\t<AnimatePresence mode=\"wait\">\n\t\t\t\t\t\t\t{!isExpanded ? (\n\t\t\t\t\t\t\t\t// 收起状态：显示简略内容\n\t\t\t\t\t\t\t\t<motion.div\n\t\t\t\t\t\t\t\t\tkey=\"collapsed\"\n\t\t\t\t\t\t\t\t\tinitial={{ opacity: 0, scale: 0.8 }}\n\t\t\t\t\t\t\t\t\tanimate={{ opacity: 1, scale: 1 }}\n\t\t\t\t\t\t\t\t\texit={{ opacity: 0, scale: 0.8 }}\n\t\t\t\t\t\t\t\t\ttransition={{ duration: 0.2 }}\n\t\t\t\t\t\t\t\t\tclassName=\"flex items-center gap-2\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<motion.div\n\t\t\t\t\t\t\t\t\t\tanimate={{\n\t\t\t\t\t\t\t\t\t\t\trotate: isLlmConfigNotification\n\t\t\t\t\t\t\t\t\t\t\t\t? [0, 360]\n\t\t\t\t\t\t\t\t\t\t\t\t: [0, -10, 10, -10, 0],\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\ttransition={\n\t\t\t\t\t\t\t\t\t\t\tisLlmConfigNotification\n\t\t\t\t\t\t\t\t\t\t\t\t? {\n\t\t\t\t\t\t\t\t\t\t\t\t\tduration: 2,\n\t\t\t\t\t\t\t\t\t\t\t\t\trepeat: Infinity,\n\t\t\t\t\t\t\t\t\t\t\t\t\tease: \"linear\",\n\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\t: {\n\t\t\t\t\t\t\t\t\t\t\t\t\tduration: 0.5,\n\t\t\t\t\t\t\t\t\t\t\t\t\trepeat: Infinity,\n\t\t\t\t\t\t\t\t\t\t\t\t\trepeatDelay: 2,\n\t\t\t\t\t\t\t\t\t\t\t\t\tease: \"easeInOut\",\n\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{isLlmConfigNotification ? (\n\t\t\t\t\t\t\t\t\t\t\t<Settings className=\"h-4 w-4 text-amber-500 shrink-0\" />\n\t\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\t<Bell className=\"h-4 w-4 text-primary shrink-0\" />\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t</motion.div>\n\t\t\t\t\t\t\t\t\t<span className=\"text-sm font-medium text-foreground truncate max-w-[200px]\">\n\t\t\t\t\t\t\t\t\t\t{currentNotification.title || t(\"newNotification\")}\n\t\t\t\t\t\t\t\t\t\t{currentNotification.content && (\n\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-muted-foreground/70\">\n\t\t\t\t\t\t\t\t\t\t\t\t{\" \"}\n\t\t\t\t\t\t\t\t\t\t\t\t（{currentNotification.content}）\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t{notificationCount > 1 && (\n\t\t\t\t\t\t\t\t\t\t<span className=\"text-xs font-semibold text-primary bg-primary/10 px-2 py-0.5 rounded-full\">\n\t\t\t\t\t\t\t\t\t\t\t{notificationCount}\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t</motion.div>\n\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t// 展开状态：显示完整列表\n\t\t\t\t\t\t\t\t<motion.div\n\t\t\t\t\t\t\t\t\tkey=\"expanded\"\n\t\t\t\t\t\t\t\t\tinitial={{ opacity: 0, scale: 0.95 }}\n\t\t\t\t\t\t\t\t\tanimate={{ opacity: 1, scale: 1 }}\n\t\t\t\t\t\t\t\t\texit={{ opacity: 0, scale: 0.95 }}\n\t\t\t\t\t\t\t\t\ttransition={{ duration: 0.2 }}\n\t\t\t\t\t\t\t\t\tclassName=\"flex flex-col gap-2 w-full max-h-[60vh] overflow-y-auto\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{notifications.map((notification) => {\n\t\t\t\t\t\t\t\t\t\tconst isDraftTodo =\n\t\t\t\t\t\t\t\t\t\t\tnotification.source === \"draft-todos\" &&\n\t\t\t\t\t\t\t\t\t\t\tnotification.todoId;\n\t\t\t\t\t\t\t\t\t\tconst isLlmConfig =\n\t\t\t\t\t\t\t\t\t\t\tnotification.source === \"llm-config\";\n\n\t\t\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\tkey={notification.id}\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"flex items-center gap-3 w-full border border-border/40 rounded-2xl px-3 py-2 bg-background/80\"\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t{isLlmConfig ? (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<Settings className=\"h-4 w-4 text-amber-500 shrink-0\" />\n\t\t\t\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<Bell className=\"h-4 w-4 text-primary shrink-0\" />\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex-1 min-w-0 flex items-center gap-2\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t<h3 className=\"text-sm font-semibold text-foreground truncate max-w-[500px]\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{notification.title || t(\"newNotification\")}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{notification.content && (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-muted-foreground/70\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{\" \"}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t（{notification.content}）\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</h3>\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t{notification.timestamp && (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"text-xs text-muted-foreground/70 shrink-0 whitespace-nowrap\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{formatTime(notification.timestamp, t)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t{isLlmConfig && (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<motion.button\n\t\t\t\t\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={(e) => {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\te.stopPropagation();\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\topenSettings();\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\twhileHover={{ scale: 1.05 }}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\twhileTap={{ scale: 0.95 }}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"text-xs font-medium px-2.5 py-1 rounded-full bg-amber-500/10 text-amber-600\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{tLayout(\"openSettings\")}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</motion.button>\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t{isDraftTodo && (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-2 shrink-0 border-l border-border/50 pl-3\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<motion.button\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={(e) =>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\thandleAccept(notification.todoId, e)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tdisabled={isProcessing}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\twhileHover={!isProcessing ? { scale: 1.05 } : {}}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\twhileTap={!isProcessing ? { scale: 0.95 } : {}}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm font-medium\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\taria-label={t(\"accept\")}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{isProcessing ? (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<motion.div\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tanimate={{ rotate: 360 }}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\ttransition={{\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tduration: 1,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\trepeat: Infinity,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tease: \"linear\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<Clock className=\"h-4 w-4\" />\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t</motion.div>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span>{t(\"accepting\")}</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<Check className=\"h-4 w-4\" />\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span>{t(\"accept\")}</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t</motion.button>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<motion.button\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={(e) =>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\thandleReject(notification.todoId, e)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tdisabled={isProcessing}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\twhileHover={!isProcessing ? { scale: 1.05 } : {}}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\twhileTap={!isProcessing ? { scale: 0.95 } : {}}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg bg-muted text-muted-foreground hover:bg-muted/80 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm font-medium\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\taria-label={t(\"reject\")}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{isProcessing ? (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<motion.div\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tanimate={{ rotate: 360 }}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\ttransition={{\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tduration: 1,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\trepeat: Infinity,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tease: \"linear\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<Clock className=\"h-4 w-4\" />\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t</motion.div>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span>{t(\"rejecting\")}</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<X className=\"h-4 w-4\" />\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span>{t(\"reject\")}</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t</motion.button>\n\t\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t<motion.button\n\t\t\t\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={(e) => {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\te.stopPropagation();\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tcloseNotification(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnotification.id,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnotification.source,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\twhileHover={{ scale: 1.1, rotate: 90 }}\n\t\t\t\t\t\t\t\t\t\t\t\t\twhileTap={{ scale: 0.9 }}\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"shrink-0 rounded-full p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive transition-colors ml-1\"\n\t\t\t\t\t\t\t\t\t\t\t\t\taria-label={t(\"closeNotification\")}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<X className=\"h-3.5 w-3.5\" />\n\t\t\t\t\t\t\t\t\t\t\t\t</motion.button>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t\t</motion.div>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</AnimatePresence>\n\t\t\t\t\t</motion.div>\n\t\t\t\t) : (\n\t\t\t\t\t// 没有通知时显示当前时间\n\t\t\t\t\t<motion.div\n\t\t\t\t\t\tinitial={{ opacity: 0, scale: 0.9 }}\n\t\t\t\t\t\tanimate={{ opacity: 1, scale: 1 }}\n\t\t\t\t\t\twhileHover={{\n\t\t\t\t\t\t\tscale: 1.03,\n\t\t\t\t\t\t\ty: -2,\n\t\t\t\t\t\t}}\n\t\t\t\t\t\ttransition={{\n\t\t\t\t\t\t\tduration: 0.3,\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tclassName=\"relative flex items-center gap-2 overflow-hidden rounded-full\n\t\t\t\t\t\tbg-background/95 backdrop-blur-sm border border-border/50\n\t\t\t\t\t\tshadow-lg px-3 py-2\n\t\t\t\t\t\thover:shadow-2xl hover:shadow-primary/5 hover:border-primary/20\n\t\t\t\t\t\thover:bg-background transition-all duration-300\n\t\t\t\t\t\tcursor-default\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<Clock className=\"h-3.5 w-3.5 text-muted-foreground/70 shrink-0\" />\n\t\t\t\t\t\t<div className=\"flex items-baseline gap-1.5\">\n\t\t\t\t\t\t\t<motion.span\n\t\t\t\t\t\t\t\tkey={currentTime.time}\n\t\t\t\t\t\t\t\tinitial={{ opacity: 0, y: -4 }}\n\t\t\t\t\t\t\t\tanimate={{ opacity: 1, y: 0 }}\n\t\t\t\t\t\t\t\ttransition={{ duration: 0.2 }}\n\t\t\t\t\t\t\t\tclassName=\"text-sm font-medium text-foreground tabular-nums\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{currentTime.time}\n\t\t\t\t\t\t\t</motion.span>\n\t\t\t\t\t\t\t<span className=\"text-xs text-muted-foreground/70 font-normal\">\n\t\t\t\t\t\t\t\t{currentTime.date}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</motion.div>\n\t\t\t\t)}\n\t\t\t</motion.div>\n\t\t</div>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/components/ui/alert-dialog.tsx",
    "content": "\"use client\";\n\nimport * as AlertDialogPrimitive from \"@radix-ui/react-alert-dialog\";\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nconst AlertDialog = AlertDialogPrimitive.Root;\nconst AlertDialogTrigger = AlertDialogPrimitive.Trigger;\nconst AlertDialogPortal = AlertDialogPrimitive.Portal;\nconst AlertDialogOverlay = React.forwardRef<\n\tReact.ElementRef<typeof AlertDialogPrimitive.Overlay>,\n\tReact.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>\n>(({ className, ...props }, ref) => (\n\t<AlertDialogPrimitive.Overlay\n\t\tref={ref}\n\t\tclassName={cn(\n\t\t\t\"fixed inset-0 z-[200] bg-black/60 backdrop-blur-sm\",\n\t\t\t\"data-[state=open]:animate-in data-[state=closed]:animate-out\",\n\t\t\t\"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0\",\n\t\t\tclassName,\n\t\t)}\n\t\t{...props}\n\t/>\n));\nAlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;\n\nconst AlertDialogContent = React.forwardRef<\n\tReact.ElementRef<typeof AlertDialogPrimitive.Content>,\n\tReact.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>\n>(({ className, ...props }, ref) => (\n\t<AlertDialogPortal>\n\t\t<AlertDialogOverlay />\n\t\t<AlertDialogPrimitive.Content\n\t\t\tref={ref}\n\t\t\tclassName={cn(\n\t\t\t\t\"fixed left-[50%] top-[50%] z-[200] w-full max-w-sm translate-x-[-50%] translate-y-[-50%] rounded-lg border border-border bg-background shadow-lg\",\n\t\t\t\t\"data-[state=open]:animate-in data-[state=closed]:animate-out\",\n\t\t\t\t\"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0\",\n\t\t\t\t\"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95\",\n\t\t\t\tclassName,\n\t\t\t)}\n\t\t\t{...props}\n\t\t/>\n\t</AlertDialogPortal>\n));\nAlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;\n\nconst AlertDialogHeader = ({\n\tclassName,\n\t...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n\t<div className={cn(\"flex flex-col space-y-1.5\", className)} {...props} />\n);\nAlertDialogHeader.displayName = \"AlertDialogHeader\";\n\nconst AlertDialogFooter = ({\n\tclassName,\n\t...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n\t<div\n\t\tclassName={cn(\n\t\t\t\"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end\",\n\t\t\tclassName,\n\t\t)}\n\t\t{...props}\n\t/>\n);\nAlertDialogFooter.displayName = \"AlertDialogFooter\";\n\nconst AlertDialogTitle = React.forwardRef<\n\tReact.ElementRef<typeof AlertDialogPrimitive.Title>,\n\tReact.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>\n>(({ className, ...props }, ref) => (\n\t<AlertDialogPrimitive.Title\n\t\tref={ref}\n\t\tclassName={cn(\"text-base font-semibold\", className)}\n\t\t{...props}\n\t/>\n));\nAlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;\n\nconst AlertDialogDescription = React.forwardRef<\n\tReact.ElementRef<typeof AlertDialogPrimitive.Description>,\n\tReact.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>\n>(({ className, ...props }, ref) => (\n\t<AlertDialogPrimitive.Description\n\t\tref={ref}\n\t\tclassName={cn(\"text-sm text-muted-foreground\", className)}\n\t\t{...props}\n\t/>\n));\nAlertDialogDescription.displayName =\n\tAlertDialogPrimitive.Description.displayName;\n\nconst AlertDialogAction = React.forwardRef<\n\tReact.ElementRef<typeof AlertDialogPrimitive.Action>,\n\tReact.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>\n>(({ className, ...props }, ref) => (\n\t<AlertDialogPrimitive.Action\n\t\tref={ref}\n\t\tclassName={cn(\n\t\t\t\"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors\",\n\t\t\t\"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n\t\t\t\"disabled:pointer-events-none disabled:opacity-50\",\n\t\t\tclassName,\n\t\t)}\n\t\t{...props}\n\t/>\n));\nAlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;\n\nconst AlertDialogCancel = React.forwardRef<\n\tReact.ElementRef<typeof AlertDialogPrimitive.Cancel>,\n\tReact.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>\n>(({ className, ...props }, ref) => (\n\t<AlertDialogPrimitive.Cancel\n\t\tref={ref}\n\t\tclassName={cn(\n\t\t\t\"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors\",\n\t\t\t\"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n\t\t\t\"disabled:pointer-events-none disabled:opacity-50\",\n\t\t\tclassName,\n\t\t)}\n\t\t{...props}\n\t/>\n));\nAlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;\n\nexport {\n\tAlertDialog,\n\tAlertDialogTrigger,\n\tAlertDialogPortal,\n\tAlertDialogOverlay,\n\tAlertDialogContent,\n\tAlertDialogHeader,\n\tAlertDialogFooter,\n\tAlertDialogTitle,\n\tAlertDialogDescription,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n};\n"
  },
  {
    "path": "free-todo-frontend/components/ui/button.tsx",
    "content": "\"use client\";\n\nimport { Slot } from \"@radix-ui/react-slot\";\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\n\ntype ButtonVariant = \"default\" | \"outline\" | \"ghost\" | \"destructive\";\ntype ButtonSize = \"default\" | \"sm\" | \"lg\" | \"icon\";\n\nexport interface ButtonProps\n\textends React.ButtonHTMLAttributes<HTMLButtonElement> {\n\tvariant?: ButtonVariant;\n\tsize?: ButtonSize;\n\tasChild?: boolean;\n}\n\nconst baseClasses =\n\t\"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors \" +\n\t\"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring \" +\n\t\"disabled:pointer-events-none disabled:opacity-50\";\n\nconst variantClasses: Record<ButtonVariant, string> = {\n\tdefault: \"bg-primary text-primary-foreground hover:bg-primary/90\",\n\toutline: \"border border-input bg-background text-foreground hover:bg-muted\",\n\tghost: \"text-foreground hover:bg-muted\",\n\tdestructive: \"bg-destructive text-destructive-foreground hover:bg-destructive/90\",\n};\n\nconst sizeClasses: Record<ButtonSize, string> = {\n\tdefault: \"h-9 px-4 py-2\",\n\tsm: \"h-8 px-3 text-sm\",\n\tlg: \"h-10 px-6 text-base\",\n\ticon: \"h-9 w-9\",\n};\n\nexport const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(\n\t(\n\t\t{\n\t\t\tclassName,\n\t\t\tvariant = \"default\",\n\t\t\tsize = \"default\",\n\t\t\tasChild = false,\n\t\t\t...props\n\t\t},\n\t\tref,\n\t) => {\n\t\tconst Comp = asChild ? Slot : \"button\";\n\t\treturn (\n\t\t\t<Comp\n\t\t\t\tref={ref}\n\t\t\t\tclassName={cn(baseClasses, variantClasses[variant], sizeClasses[size], className)}\n\t\t\t\t{...props}\n\t\t\t/>\n\t\t);\n\t},\n);\n\nButton.displayName = \"Button\";\n"
  },
  {
    "path": "free-todo-frontend/components/ui/dialog.tsx",
    "content": "\"use client\";\n\nimport * as DialogPrimitive from \"@radix-ui/react-dialog\";\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nconst Dialog = DialogPrimitive.Root;\nconst DialogTrigger = DialogPrimitive.Trigger;\nconst DialogPortal = DialogPrimitive.Portal;\nconst DialogClose = DialogPrimitive.Close;\n\nconst DialogOverlay = React.forwardRef<\n\tReact.ElementRef<typeof DialogPrimitive.Overlay>,\n\tReact.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>\n>(({ className, ...props }, ref) => (\n\t<DialogPrimitive.Overlay\n\t\tref={ref}\n\t\tclassName={cn(\n\t\t\t\"fixed inset-0 z-[200] bg-black/60 backdrop-blur-sm\",\n\t\t\t\"data-[state=open]:animate-in data-[state=closed]:animate-out\",\n\t\t\t\"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0\",\n\t\t\tclassName,\n\t\t)}\n\t\t{...props}\n\t/>\n));\nDialogOverlay.displayName = DialogPrimitive.Overlay.displayName;\n\nconst DialogContent = React.forwardRef<\n\tReact.ElementRef<typeof DialogPrimitive.Content>,\n\tReact.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>\n>(({ className, ...props }, ref) => (\n\t<DialogPortal>\n\t\t<DialogOverlay />\n\t\t<DialogPrimitive.Content\n\t\t\tref={ref}\n\t\t\tclassName={cn(\n\t\t\t\t\"fixed left-[50%] top-[50%] z-[200] w-full max-w-md translate-x-[-50%] translate-y-[-50%] rounded-lg border border-border bg-background shadow-lg\",\n\t\t\t\t\"data-[state=open]:animate-in data-[state=closed]:animate-out\",\n\t\t\t\t\"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0\",\n\t\t\t\t\"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95\",\n\t\t\t\tclassName,\n\t\t\t)}\n\t\t\t{...props}\n\t\t/>\n\t</DialogPortal>\n));\nDialogContent.displayName = DialogPrimitive.Content.displayName;\n\nconst DialogHeader = ({\n\tclassName,\n\t...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n\t<div className={cn(\"flex flex-col space-y-1.5\", className)} {...props} />\n);\nDialogHeader.displayName = \"DialogHeader\";\n\nconst DialogFooter = ({\n\tclassName,\n\t...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n\t<div\n\t\tclassName={cn(\n\t\t\t\"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end\",\n\t\t\tclassName,\n\t\t)}\n\t\t{...props}\n\t/>\n);\nDialogFooter.displayName = \"DialogFooter\";\n\nconst DialogTitle = React.forwardRef<\n\tReact.ElementRef<typeof DialogPrimitive.Title>,\n\tReact.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>\n>(({ className, ...props }, ref) => (\n\t<DialogPrimitive.Title\n\t\tref={ref}\n\t\tclassName={cn(\"text-base font-semibold\", className)}\n\t\t{...props}\n\t/>\n));\nDialogTitle.displayName = DialogPrimitive.Title.displayName;\n\nconst DialogDescription = React.forwardRef<\n\tReact.ElementRef<typeof DialogPrimitive.Description>,\n\tReact.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>\n>(({ className, ...props }, ref) => (\n\t<DialogPrimitive.Description\n\t\tref={ref}\n\t\tclassName={cn(\"text-sm text-muted-foreground\", className)}\n\t\t{...props}\n\t/>\n));\nDialogDescription.displayName = DialogPrimitive.Description.displayName;\n\nexport {\n\tDialog,\n\tDialogTrigger,\n\tDialogPortal,\n\tDialogClose,\n\tDialogOverlay,\n\tDialogContent,\n\tDialogHeader,\n\tDialogFooter,\n\tDialogTitle,\n\tDialogDescription,\n};\n"
  },
  {
    "path": "free-todo-frontend/components/ui/dropdown-menu.tsx",
    "content": "\"use client\";\n\nimport * as DropdownMenuPrimitive from \"@radix-ui/react-dropdown-menu\";\nimport { ChevronRight } from \"lucide-react\";\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nconst DropdownMenu = DropdownMenuPrimitive.Root;\nconst DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;\nconst DropdownMenuGroup = DropdownMenuPrimitive.Group;\nconst DropdownMenuPortal = DropdownMenuPrimitive.Portal;\nconst DropdownMenuSub = DropdownMenuPrimitive.Sub;\nconst DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;\n\nconst DropdownMenuContent = React.forwardRef<\n\tReact.ElementRef<typeof DropdownMenuPrimitive.Content>,\n\tReact.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>\n>(({ className, sideOffset = 4, ...props }, ref) => (\n\t<DropdownMenuPrimitive.Portal>\n\t\t<DropdownMenuPrimitive.Content\n\t\t\tref={ref}\n\t\t\tsideOffset={sideOffset}\n\t\t\tclassName={cn(\n\t\t\t\t\"z-[200] min-w-[8rem] rounded-md border border-border bg-popover p-1 shadow-md\",\n\t\t\t\t\"data-[state=open]:animate-in data-[state=closed]:animate-out\",\n\t\t\t\t\"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0\",\n\t\t\t\t\"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95\",\n\t\t\t\tclassName,\n\t\t\t)}\n\t\t\t{...props}\n\t\t/>\n\t</DropdownMenuPrimitive.Portal>\n));\nDropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;\n\nconst DropdownMenuItem = React.forwardRef<\n\tReact.ElementRef<typeof DropdownMenuPrimitive.Item>,\n\tReact.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>\n>(({ className, ...props }, ref) => (\n\t<DropdownMenuPrimitive.Item\n\t\tref={ref}\n\t\tclassName={cn(\n\t\t\t\"relative flex cursor-default select-none items-center rounded-sm px-2.5 py-2 text-sm outline-none transition-colors\",\n\t\t\t\"focus:bg-accent focus:text-accent-foreground\",\n\t\t\t\"data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n\t\t\tclassName,\n\t\t)}\n\t\t{...props}\n\t/>\n));\nDropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;\n\nconst DropdownMenuSubTrigger = React.forwardRef<\n\tReact.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,\n\tReact.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger>\n>(({ className, children, ...props }, ref) => (\n\t<DropdownMenuPrimitive.SubTrigger\n\t\tref={ref}\n\t\tclassName={cn(\n\t\t\t\"relative flex cursor-default select-none items-center rounded-sm px-2.5 py-2 text-sm outline-none transition-colors\",\n\t\t\t\"focus:bg-accent focus:text-accent-foreground\",\n\t\t\t\"data-[state=open]:bg-accent data-[state=open]:text-accent-foreground\",\n\t\t\t\"data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n\t\t\tclassName,\n\t\t)}\n\t\t{...props}\n\t>\n\t\t{children}\n\t\t<ChevronRight className=\"ml-auto h-4 w-4 text-muted-foreground\" />\n\t</DropdownMenuPrimitive.SubTrigger>\n));\nDropdownMenuSubTrigger.displayName =\n\tDropdownMenuPrimitive.SubTrigger.displayName;\n\nconst DropdownMenuSubContent = React.forwardRef<\n\tReact.ElementRef<typeof DropdownMenuPrimitive.SubContent>,\n\tReact.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>\n>(({ className, sideOffset = 8, ...props }, ref) => (\n\t<DropdownMenuPrimitive.SubContent\n\t\tref={ref}\n\t\tsideOffset={sideOffset}\n\t\tclassName={cn(\n\t\t\t\"z-[200] min-w-[8rem] rounded-md border border-border bg-popover p-1 shadow-md\",\n\t\t\t\"data-[state=open]:animate-in data-[state=closed]:animate-out\",\n\t\t\t\"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0\",\n\t\t\t\"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95\",\n\t\t\tclassName,\n\t\t)}\n\t\t{...props}\n\t/>\n));\nDropdownMenuSubContent.displayName =\n\tDropdownMenuPrimitive.SubContent.displayName;\n\nconst DropdownMenuSeparator = React.forwardRef<\n\tReact.ElementRef<typeof DropdownMenuPrimitive.Separator>,\n\tReact.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>\n>(({ className, ...props }, ref) => (\n\t<DropdownMenuPrimitive.Separator\n\t\tref={ref}\n\t\tclassName={cn(\"-mx-1 my-1 h-px bg-border\", className)}\n\t\t{...props}\n\t/>\n));\nDropdownMenuSeparator.displayName =\n\tDropdownMenuPrimitive.Separator.displayName;\n\nexport {\n\tDropdownMenu,\n\tDropdownMenuTrigger,\n\tDropdownMenuGroup,\n\tDropdownMenuPortal,\n\tDropdownMenuSub,\n\tDropdownMenuRadioGroup,\n\tDropdownMenuContent,\n\tDropdownMenuItem,\n\tDropdownMenuSubTrigger,\n\tDropdownMenuSubContent,\n\tDropdownMenuSeparator,\n};\n"
  },
  {
    "path": "free-todo-frontend/electron/PACKAGING_GUIDE.md",
    "content": "# FreeTodo Electron App Packaging Guide\n\nThis document describes how to package the FreeTodo application (Next.js frontend + Python backend) as a standalone desktop application.\n\n## Table of Contents\n\n- [Quick Start](#quick-start)\n- [System Requirements](#system-requirements)\n- [Packaging Process](#packaging-process)\n- [Build Output](#build-output)\n- [Log Files](#log-files)\n- [Troubleshooting](#troubleshooting)\n- [Common Issues](#common-issues)\n\n## Quick Start\n\n### macOS\n\n```bash\n# Clean previous builds (optional)\nrm -rf dist-electron-app dist-electron .next\n\n# Build for macOS\npnpm build:desktop:web:full:mac\n```\n\nOutput files will be in `dist-electron-app/`:\n- `FreeTodo-x.x.x-mac-arm64.dmg` - Apple Silicon Mac\n- `FreeTodo-x.x.x-mac-x64.dmg` - Intel Mac\n\n### Windows\n\n```bash\npnpm build:desktop:web:full:win\n```\n\n### Linux\n\n```bash\npnpm build:desktop:web:full:linux\n```\n\n## System Requirements\n\n### macOS\n\n- **OS**: macOS 10.15 (Catalina) or later\n- **Architecture**: Apple Silicon (arm64) or Intel (x64)\n- **Tools**:\n  - Node.js 18+ and pnpm\n  - Python 3.12 (installed automatically on first launch)\n\n### Disk Space\n\n- **Build process**: depends on Next.js build output\n- **Final DMG**: depends on frontend assets and backend models\n\n## Packaging Process\n\nThe complete packaging flow (`pnpm build:desktop:web:full:mac`) executes these steps:\n\n### 1. Next.js Production Build\n\n```bash\npnpm build:frontend:web\n```\n\nGenerates:\n- `.next/standalone/` - Standalone server files\n- `.next/static/` - Static assets (CSS, JS chunks)\n- `.next/server/` - Server-side code\n\n### 2. Backend Runtime Packaging (Source)\n\nThe backend source (`lifetrace/`) and `requirements-runtime.txt` are bundled into\nthe Electron app. On first launch, the app installs Python 3.12 and backend\ndependencies automatically.\n\n### 3. Resolve Symlinks\n\n```bash\npnpm electron:resolve-symlinks\n```\n\nConverts pnpm symlinks in `node_modules` to actual files for packaging compatibility.\n\n### 4. Copy Missing Dependencies\n\n```bash\npnpm electron:copy-missing-deps\n```\n\nCopies runtime dependencies that Next.js standalone build may not include:\n- `styled-jsx`\n- `@swc/helpers`\n- `@next/env`\n- `client-only`\n\n### 5. Build Electron Main Process (Web mode)\n\n```bash\npnpm build:desktop:web:frontend-shell\n```\n\nCompiles TypeScript main process code to `dist-electron/main.js` with Web window mode.\n\n### 6. Create Installer\n\n```bash\npnpm build:desktop:web:full:mac\n```\n\nCreates platform-specific installers using `electron-builder.yml` configuration.\n\n## Build Output\n\n### Application Structure\n\n```\nFreeTodo.app/Contents/\n├── MacOS/\n│   └── FreeTodo              # Electron executable\n├── Resources/\n│   ├── app/\n│   │   └── dist-electron/\n│   │       └── main.js       # Main process code\n│   ├── standalone/           # Next.js server\n│   │   ├── server.js\n│   │   ├── node_modules/\n│   │   ├── .next/\n│   │   └── public/\n│   └── backend/              # Python backend (source)\n│       ├── lifetrace/\n│       └── requirements-runtime.txt\n└── ...\n```\n\n### User Data Directory\n\n**macOS**: `~/Library/Application Support/FreeTodo/lifetrace-data/`\n- `config/` - User configuration files\n- `data/` - Database and screenshots\n- `logs/` - Backend application logs\n\n## Log Files\n\n### Log File Naming\n\nBoth frontend and backend use the same naming convention:\n- Format: `YYYY-MM-DD-N.log` (N is the session number, starting from 0)\n- Each application launch creates a new log file\n- Timestamps are in **UTC** format\n\n### Electron Main Process Logs\n\n**Location**: `~/Library/Logs/FreeTodo/`\n\nExample: `2026-01-11-0.log`, `2026-01-11-1.log`\n\nContains:\n- Application startup info\n- Backend/frontend server status\n- Process stdout/stderr output\n- Health check results\n\n### Backend Application Logs\n\n**Location**: `~/Library/Application Support/FreeTodo/lifetrace-data/logs/`\n\nExample: `2026-01-11-0.log`, `2026-01-11-0.error.log`\n\nContains:\n- FastAPI server logs\n- Background job status\n- Error details with stack traces\n\n### Viewing Logs\n\n```bash\n# View latest Electron logs\nls -lt ~/Library/Logs/FreeTodo/*.log | head -5\ntail -100 ~/Library/Logs/FreeTodo/$(ls -t ~/Library/Logs/FreeTodo/*.log | head -1)\n\n# View latest backend logs\nls -lt ~/Library/Application\\ Support/FreeTodo/lifetrace-data/logs/*.log | head -5\ntail -100 \"$(ls -t ~/Library/Application\\ Support/FreeTodo/lifetrace-data/logs/*.log | head -1)\"\n```\n\n## Troubleshooting\n\n### Port Configuration\n\nThe application uses **dynamic port allocation**:\n\n| Mode | Frontend Port | Backend Port |\n|------|--------------|--------------|\n| DEV | 3001 (default) | 8001 (default) |\n| Build | 3100 (default) | 8100 (default) |\n\nPorts automatically increment if the default is occupied.\n\n### Startup Sequence\n\n1. **Backend Server Start**\n   - Ensure Python 3.12 + backend dependencies\n   - Start `lifetrace/scripts/start_backend.py`\n   - Wait for health check (up to 180 seconds)\n\n2. **Frontend Server Start**\n   - Start Next.js standalone server\n   - Wait for ready (up to 30 seconds)\n\n3. **Window Creation**\n   - Load frontend URL\n   - Show application window\n\n### Checking Backend Status\n\n```bash\n# Check if backend process is running\nps aux | grep lifetrace\n\n# Check port usage (example for Build mode)\nlsof -i :8100\n\n# Test health endpoint\ncurl http://localhost:8100/health\n```\n\n### Checking Frontend Status\n\n```bash\n# Check port usage\nlsof -i :3100\n\n# Test frontend\ncurl http://localhost:3100\n```\n\n## Common Issues\n\n### Issue 1: Backend Runtime Files Not Found\n\n**Symptoms**:\n- \"Backend source files were not found\" error\n- Application fails to start\n\n**Solutions**:\n1. Check if backend files exist:\n   ```bash\n   ls -la /Applications/FreeTodo.app/Contents/Resources/backend/lifetrace\n   ```\n\n2. Rebuild the installer and reinstall the app.\n\n### Issue 2: Next.js Server Exits Immediately\n\n**Symptoms**:\n- \"Server exited unexpectedly with code 0\"\n- Empty stdout/stderr\n\n**Solutions**:\n1. Ensure all build steps were executed:\n   ```bash\n   pnpm electron:resolve-symlinks\n   pnpm electron:copy-missing-deps\n   ```\n\n2. Test server manually:\n   ```bash\n   cd /Applications/FreeTodo.app/Contents/Resources/standalone\n   PORT=3100 HOSTNAME=localhost NODE_ENV=production node server.js\n   ```\n\n3. If \"Cannot find module\" error, add the module to `scripts/copy-missing-deps.js`\n\n### Issue 3: API 500 Errors\n\n**Symptoms**:\n- Frontend shows \"API error: 500\"\n- Requests fail to reach backend\n\n**Common Causes**:\n1. Backend not running - check logs\n2. Port mismatch - ensure `NEXT_PUBLIC_API_URL` is correct\n3. Backend health check timeout - increase timeout or check backend logs\n\n### Issue 4: CSS/Styles Missing\n\n**Symptoms**:\n- Page displays without styling\n- Plain text appearance\n\n**Solution**:\nCheck that `.next/static` was copied to `standalone/.next/static`:\n```bash\nls /Applications/FreeTodo.app/Contents/Resources/standalone/.next/static\n```\n\n### Issue 5: macOS Security Warning\n\n**Symptoms**:\n- \"Cannot be opened because developer cannot be verified\"\n\n**Solutions**:\n\nOption 1: Allow in System Settings\n- System Settings > Privacy & Security > Click \"Open Anyway\"\n\nOption 2: Remove quarantine attribute\n```bash\nxattr -cr /Applications/FreeTodo.app\n```\n\n### Issue 6: Build Size Too Large\n\n**Symptoms**:\n- DMG exceeds 2 GB\n\n**Common causes**:\n- Node.js runtime\n- Next.js standalone output\n- ONNX models for OCR\n\nTo reduce size:\n- Prune unused frontend assets\n- Remove unused backend models or optional dependencies\n\n## Related Files\n\n### Frontend\n- `electron/main.ts` - Electron main process\n- `electron-builder.yml` - electron-builder configuration\n- `scripts/resolve-symlinks.js` - Symlink resolver\n- `scripts/copy-missing-deps.js` - Missing dependency copier\n- `next.config.ts` - Next.js configuration\n\n### Backend\n- `lifetrace/scripts/start_backend.py` - Backend startup entrypoint\n- `requirements-runtime.txt` - Runtime dependency list\n\n---\n\n**Last Updated**: 2026-01-29\n**Applicable Versions**:\n- Next.js 16.x\n- Electron 39.x\n- electron-builder 26.x\n"
  },
  {
    "path": "free-todo-frontend/electron/PACKAGING_GUIDE_CN.md",
    "content": "# FreeTodo Electron 应用打包指南\n\n本文档描述如何将 FreeTodo 应用（Next.js 前端 + Python 后端）打包为独立的桌面应用程序。\n\n## 目录\n\n- [快速开始](#快速开始)\n- [系统要求](#系统要求)\n- [打包流程](#打包流程)\n- [构建产物](#构建产物)\n- [日志文件](#日志文件)\n- [故障排查](#故障排查)\n- [常见问题](#常见问题)\n\n## 快速开始\n\n### macOS 打包\n\n```bash\n# 清理之前的构建产物（可选）\nrm -rf dist-electron-app dist-electron .next\n\n# 执行 Mac 打包\npnpm build:desktop:web:full:mac\n```\n\n打包完成后，DMG 文件将生成在 `dist-electron-app/` 目录下：\n- `FreeTodo-x.x.x-mac-arm64.dmg` - Apple Silicon Mac 版本\n- `FreeTodo-x.x.x-mac-x64.dmg` - Intel Mac 版本\n\n### Windows 打包\n\n```bash\npnpm build:desktop:web:full:win\n```\n\n### Linux 打包\n\n```bash\npnpm build:desktop:web:full:linux\n```\n\n## 系统要求\n\n### macOS 打包要求\n\n- **操作系统**: macOS 10.15 (Catalina) 或更高版本\n- **架构支持**:\n  - Apple Silicon (arm64) - M1/M2/M3 芯片\n  - Intel (x64) - Intel 芯片\n- **开发工具**:\n  - Node.js 18+ 和 pnpm\n  - Python 3.12（首次启动自动安装）\n\n### 磁盘空间\n\n- **构建过程**: 取决于 Next.js 构建输出\n- **最终 DMG**: 取决于前端资源和后端模型\n\n## 打包流程\n\n完整的打包流程（`pnpm build:desktop:web:full:mac`）包含以下步骤：\n\n### 1. Next.js 生产构建\n\n```bash\npnpm build:frontend:web\n```\n\n**输出**:\n- `.next/standalone/` - 独立的服务器文件\n- `.next/static/` - 静态资源文件（CSS、JS chunks）\n- `.next/server/` - 服务器端代码\n\n### 2. 后端运行时打包（源码）\n\n后端源码（`lifetrace/`）和 `requirements-runtime.txt` 会被打包进应用中。\n首次启动时会自动安装 Python 3.12 和后端依赖。\n\n### 3. 解析符号链接\n\n```bash\npnpm electron:resolve-symlinks\n```\n\n将 pnpm 在 `node_modules` 中创建的符号链接转换为实际文件，确保打包兼容性。\n\n### 4. 复制缺失的依赖\n\n```bash\npnpm electron:copy-missing-deps\n```\n\n复制 Next.js standalone 构建中可能缺失的运行时依赖：\n- `styled-jsx`\n- `@swc/helpers`\n- `@next/env`\n- `client-only`\n\n### 5. 编译 Electron 主进程（Web 模式）\n\n```bash\npnpm build:desktop:web:frontend-shell\n```\n\n将 TypeScript 主进程代码编译到 `dist-electron/main.js`，并启用 Web 窗口模式。\n\n### 6. 打包应用\n\n```bash\npnpm build:desktop:web:full:mac\n```\n\n使用 `electron-builder.yml` 配置创建平台特定的安装包。\n\n## 构建产物\n\n### 应用结构\n\n```\nFreeTodo.app/Contents/\n├── MacOS/\n│   └── FreeTodo              # Electron 可执行文件\n├── Resources/\n│   ├── app/\n│   │   └── dist-electron/\n│   │       └── main.js       # 主进程代码\n│   ├── standalone/           # Next.js 服务器\n│   │   ├── server.js\n│   │   ├── node_modules/\n│   │   ├── .next/\n│   │   └── public/\n│   └── backend/              # Python 后端（源码）\n│       ├── lifetrace/\n│       └── requirements-runtime.txt\n└── ...\n```\n\n### 用户数据目录\n\n**macOS**: `~/Library/Application Support/FreeTodo/lifetrace-data/`\n- `config/` - 用户配置文件\n- `data/` - 数据库和截图\n- `logs/` - 后端应用日志\n\n## 日志文件\n\n### 日志文件命名\n\n前端和后端使用相同的命名规则：\n- 格式：`YYYY-MM-DD-N.log`（N 是当天第几次启动，从 0 开始）\n- 每次启动应用都会创建新的日志文件\n- 时间戳使用 **UTC** 格式\n\n### Electron 主进程日志\n\n**位置**: `~/Library/Logs/FreeTodo/`\n\n示例：`2026-01-11-0.log`、`2026-01-11-1.log`\n\n包含内容：\n- 应用启动信息\n- 后端/前端服务器状态\n- 进程标准输出/错误输出\n- 健康检查结果\n\n### 后端应用日志\n\n**位置**: `~/Library/Application Support/FreeTodo/lifetrace-data/logs/`\n\n示例：`2026-01-11-0.log`、`2026-01-11-0.error.log`\n\n包含内容：\n- FastAPI 服务器日志\n- 后台任务状态\n- 错误详情和堆栈信息\n\n### 查看日志\n\n```bash\n# 查看最新的 Electron 日志\nls -lt ~/Library/Logs/FreeTodo/*.log | head -5\ntail -100 ~/Library/Logs/FreeTodo/$(ls -t ~/Library/Logs/FreeTodo/*.log | head -1)\n\n# 查看最新的后端日志\nls -lt ~/Library/Application\\ Support/FreeTodo/lifetrace-data/logs/*.log | head -5\ntail -100 \"$(ls -t ~/Library/Application\\ Support/FreeTodo/lifetrace-data/logs/*.log | head -1)\"\n```\n\n## 故障排查\n\n### 端口配置\n\n应用使用**动态端口分配**：\n\n| 模式 | 前端端口 | 后端端口 |\n|------|---------|---------|\n| DEV | 3001（默认） | 8001（默认） |\n| Build | 3100（默认） | 8100（默认） |\n\n如果默认端口被占用，会自动递增查找可用端口。\n\n### 启动顺序\n\n1. **后端服务器启动**\n   - 确保 Python 3.12 与后端依赖已安装\n   - 启动 `lifetrace/scripts/start_backend.py`\n   - 等待健康检查通过（最多 180 秒）\n\n2. **前端服务器启动**\n   - 启动 Next.js standalone 服务器\n   - 等待服务器就绪（最多 30 秒）\n\n3. **创建窗口**\n   - 加载前端 URL\n   - 显示应用窗口\n\n### 检查后端状态\n\n```bash\n# 检查后端进程是否运行\nps aux | grep lifetrace\n\n# 检查端口占用（Build 模式示例）\nlsof -i :8100\n\n# 测试健康端点\ncurl http://localhost:8100/health\n```\n\n### 检查前端状态\n\n```bash\n# 检查端口占用\nlsof -i :3100\n\n# 测试前端\ncurl http://localhost:3100\n```\n\n## 常见问题\n\n### 问题 1: 后端运行时文件未找到\n\n**症状**:\n- 显示 \"Backend source files were not found\" 错误\n- 应用无法启动\n\n**解决方案**:\n1. 检查后端文件是否存在：\n   ```bash\n   ls -la /Applications/FreeTodo.app/Contents/Resources/backend/lifetrace\n   ```\n\n2. 重新打包并重新安装应用。\n\n### 问题 2: Next.js 服务器立即退出\n\n**症状**:\n- \"Server exited unexpectedly with code 0\"\n- stdout/stderr 都是空的\n\n**解决方案**:\n1. 确保所有构建步骤都已执行：\n   ```bash\n   pnpm electron:resolve-symlinks\n   pnpm electron:copy-missing-deps\n   ```\n\n2. 手动测试服务器：\n   ```bash\n   cd /Applications/FreeTodo.app/Contents/Resources/standalone\n   PORT=3100 HOSTNAME=localhost NODE_ENV=production node server.js\n   ```\n\n3. 如果出现 \"Cannot find module\" 错误，将该模块添加到 `scripts/copy-missing-deps.js`\n\n### 问题 3: API 500 错误\n\n**症状**:\n- 前端显示 \"API error: 500\"\n- 请求无法到达后端\n\n**常见原因**:\n1. 后端未运行 - 检查日志\n2. 端口不匹配 - 确保 `NEXT_PUBLIC_API_URL` 正确\n3. 后端健康检查超时 - 增加超时时间或检查后端日志\n\n### 问题 4: CSS/样式丢失\n\n**症状**:\n- 页面显示没有样式\n- 只显示纯文本\n\n**解决方案**:\n检查 `.next/static` 是否已复制到 `standalone/.next/static`：\n```bash\nls /Applications/FreeTodo.app/Contents/Resources/standalone/.next/static\n```\n\n### 问题 5: macOS 安全警告\n\n**症状**:\n- \"无法打开，因为来自身份不明的开发者\"\n\n**解决方案**:\n\n方法 1: 在系统设置中允许\n- 系统设置 > 隐私与安全性 > 点击\"仍要打开\"\n\n方法 2: 移除隔离属性\n```bash\nxattr -cr /Applications/FreeTodo.app\n```\n\n### 问题 6: 构建产物过大\n\n**症状**:\n- DMG 文件超过 2 GB\n\n**常见原因**：\n- Node.js 运行时\n- Next.js standalone 输出\n- OCR 所需的 ONNX 模型\n\n如需减小体积：\n- 精简前端资源\n- 移除未使用的后端模型或可选依赖\n\n## 相关文件\n\n### 前端相关\n- `electron/main.ts` - Electron 主进程\n- `electron-builder.yml` - electron-builder 配置\n- `scripts/resolve-symlinks.js` - 符号链接解析脚本\n- `scripts/copy-missing-deps.js` - 缺失依赖复制脚本\n- `next.config.ts` - Next.js 配置\n\n### 后端相关\n- `lifetrace/scripts/start_backend.py` - 后端启动入口\n- `requirements-runtime.txt` - 运行时依赖清单\n\n---\n\n**最后更新**: 2026-01-29\n**适用版本**:\n- Next.js 16.x\n- Electron 39.x\n- electron-builder 26.x\n"
  },
  {
    "path": "free-todo-frontend/electron/backend-server.ts",
    "content": "/**\n * 后端服务器管理\n * 继承 ProcessManager 实现后端服务器的启动和管理\n */\n\nimport { spawn } from \"node:child_process\";\nimport fs from \"node:fs\";\nimport http from \"node:http\";\nimport path from \"node:path\";\nimport { app, dialog } from \"electron\";\nimport { isCancelled } from \"./bootstrap-control\";\nimport { emitLog, emitStatus } from \"./bootstrap-status\";\nimport {\n\tgetBackendRuntime,\n\tgetServerMode,\n\tHEALTH_CHECK_INTERVAL,\n\tPORT_CONFIG,\n\tPROCESS_CONFIG,\n\ttype ServerMode,\n\tTIMEOUT_CONFIG,\n} from \"./config\";\nimport { getGitCommit } from \"./git-info\";\nimport { logger } from \"./logger\";\nimport { portManager } from \"./port-manager\";\nimport { ProcessManager } from \"./process-manager\";\nimport { ensurePythonRuntime } from \"./python-runtime\";\nimport { resolveRuntimeRoot } from \"./runtime-paths\";\n\n/**\n * 后端服务器管理类\n * 负责启动、监控和停止 Python 后端服务器\n */\nexport class BackendServer extends ProcessManager {\n\t/** 后端源码根目录（包含 lifetrace/ 和 requirements 文件） */\n\tprivate backendSourceDir: string | null = null;\n\t/** 后端入口脚本路径 */\n\tprivate backendEntryScript: string | null = null;\n\t/** 后端依赖 requirements 路径 */\n\tprivate backendRequirementsPath: string | null = null;\n\t/** 后端虚拟环境目录 */\n\tprivate venvDir: string | null = null;\n\t/** 虚拟环境 Python 路径 */\n\tprivate venvPythonPath: string | null = null;\n\t/** 数据目录路径 */\n\tprivate dataDir: string | null = null;\n\t/** 服务器模式（dev 或 build） */\n\tprivate serverMode: ServerMode;\n\t/** 后端运行时（script 或 pyinstaller） */\n\tprivate backendRuntime: ReturnType<typeof getBackendRuntime>;\n\t/** 当前前端 Git Commit（用于匹配后端实例） */\n\tprivate gitCommit: string | null;\n\n\tconstructor() {\n\t\tsuper(\n\t\t\t{\n\t\t\t\tname: \"Backend\",\n\t\t\t\thealthEndpoint: \"/health\",\n\t\t\t\thealthCheckInterval: HEALTH_CHECK_INTERVAL.backend,\n\t\t\t\treadyTimeout: TIMEOUT_CONFIG.backendReady,\n\t\t\t\tacceptedStatusCodes: { min: 200, max: 400 },\n\t\t\t},\n\t\t\tPORT_CONFIG.backend.default,\n\t\t);\n\t\tthis.serverMode = getServerMode();\n\t\tthis.backendRuntime = getBackendRuntime();\n\t\tconst commit = getGitCommit();\n\t\tthis.gitCommit = commit && commit !== \"unknown\" ? commit : null;\n\t}\n\n\t/**\n\t * 获取当前服务器模式\n\t */\n\tgetServerMode(): ServerMode {\n\t\treturn this.serverMode;\n\t}\n\n\t/**\n\t * 解析后端源码与运行时路径\n\t * 根据打包状态和平台确定正确的路径\n\t */\n\tprivate resolveBackendPaths(): void {\n\t\tif (app.isPackaged) {\n\t\t\tthis.backendSourceDir = path.join(process.resourcesPath, \"backend\");\n\t\t} else {\n\t\t\tconst projectRoot = path.resolve(__dirname, \"../..\");\n\t\t\tthis.backendSourceDir =\n\t\t\t\tthis.backendRuntime === \"pyinstaller\"\n\t\t\t\t\t? path.join(projectRoot, \"dist-backend\")\n\t\t\t\t\t: projectRoot;\n\t\t}\n\n\t\tif (this.backendSourceDir) {\n\t\t\tif (this.backendRuntime === \"pyinstaller\") {\n\t\t\t\tthis.backendEntryScript = path.join(\n\t\t\t\t\tthis.backendSourceDir,\n\t\t\t\t\tPROCESS_CONFIG.backendExecutable,\n\t\t\t\t);\n\t\t\t\tthis.backendRequirementsPath = null;\n\t\t\t} else {\n\t\t\t\tthis.backendEntryScript = path.join(\n\t\t\t\t\tthis.backendSourceDir,\n\t\t\t\t\tPROCESS_CONFIG.backendEntryScript,\n\t\t\t\t);\n\t\t\t\tthis.backendRequirementsPath = path.join(\n\t\t\t\t\tthis.backendSourceDir,\n\t\t\t\t\tPROCESS_CONFIG.backendRequirementsFile,\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\n\t\t// 数据目录\n\t\tconst userDataDir = app.getPath(\"userData\");\n\t\tthis.dataDir = path.join(userDataDir, PROCESS_CONFIG.backendDataDir);\n\t\tconst runtimeRoot = resolveRuntimeRoot();\n\t\tthis.venvDir = path.join(runtimeRoot, PROCESS_CONFIG.backendVenvDir);\n\t\temitStatus({ venvPath: this.venvDir });\n\t}\n\n\t/**\n\t * 检查后端脚本与依赖文件是否存在\n\t */\n\tprivate checkBackendExists(): boolean {\n\t\tconst entryMissing =\n\t\t\t!this.backendEntryScript || !fs.existsSync(this.backendEntryScript);\n\t\tconst requirementsMissing =\n\t\t\tthis.backendRuntime === \"script\" &&\n\t\t\t(!this.backendRequirementsPath ||\n\t\t\t\t!fs.existsSync(this.backendRequirementsPath));\n\n\t\tif (entryMissing || requirementsMissing) {\n\t\t\tconst errorMsg =\n\t\t\t\t`The backend source files were not found.\\n\\n` +\n\t\t\t\t`Runtime: ${this.backendRuntime}\\n` +\n\t\t\t\t`Entry: ${this.backendEntryScript ?? \"unknown\"}\\n` +\n\t\t\t\t`Requirements: ${this.backendRequirementsPath ?? \"n/a\"}\\n\\n` +\n\t\t\t\t\"Please reinstall or rebuild the application.\";\n\t\t\tlogger.error(errorMsg);\n\t\t\tdialog.showErrorBox(\"Backend Not Found\", errorMsg);\n\t\t\treturn false;\n\t\t}\n\t\treturn true;\n\t}\n\n\t/**\n\t * 检查指定端口是否运行着 LifeTrace 后端\n\t * 通过调用 /health 端点并验证 app 标识来确认是 LifeTrace 后端\n\t * @param port 要检测的端口\n\t * @returns 如果是 LifeTrace 后端则返回 true\n\t */\n\tprivate async isLifeTraceBackend(port: number): Promise<boolean> {\n\t\treturn new Promise((resolve) => {\n\t\t\tconst req = http.get(\n\t\t\t\t{\n\t\t\t\t\thostname: \"127.0.0.1\",\n\t\t\t\t\tport,\n\t\t\t\t\tpath: this.config.healthEndpoint,\n\t\t\t\t\ttimeout: 2000, // 2秒超时\n\t\t\t\t},\n\t\t\t\t(res) => {\n\t\t\t\t\tlet data = \"\";\n\t\t\t\t\tres.on(\"data\", (chunk) => {\n\t\t\t\t\t\tdata += chunk.toString();\n\t\t\t\t\t});\n\t\t\t\t\tres.on(\"end\", () => {\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tconst json = JSON.parse(data);\n\t\t\t\t\t\t\t// 验证是否是 LifeTrace 后端\n\t\t\t\t\t\t\tif (json.app !== \"lifetrace\") {\n\t\t\t\t\t\t\t\tresolve(false);\n\t\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tconst backendCommit =\n\t\t\t\t\t\t\t\ttypeof json.git_commit === \"string\"\n\t\t\t\t\t\t\t\t\t? json.git_commit\n\t\t\t\t\t\t\t\t\t: null;\n\t\t\t\t\t\t\tif (this.gitCommit && backendCommit !== this.gitCommit) {\n\t\t\t\t\t\t\t\tresolve(false);\n\t\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tresolve(true);\n\t\t\t\t\t\t} catch {\n\t\t\t\t\t\t\tresolve(false);\n\t\t\t\t\t\t}\n\t\t\t\t\t});\n\t\t\t\t},\n\t\t\t);\n\n\t\t\treq.on(\"error\", () => resolve(false));\n\t\t\treq.on(\"timeout\", () => {\n\t\t\t\treq.destroy();\n\t\t\t\tresolve(false);\n\t\t\t});\n\t\t});\n\t}\n\n\t/**\n\t * 检测运行中的后端服务器端口\n\t * 通过调用 /health 端点并验证 app 标识来确认是 LifeTrace 后端\n\t * @returns 检测到的后端端口，如果没有检测到则返回 null\n\t */\n\tasync detectRunningBackendPort(): Promise<number | null> {\n\t\t// 先检查优先级端口（开发版和 Build 版默认端口）\n\t\tconst priorityPorts = [\n\t\t\tPORT_CONFIG.backend.default,\n\t\t\tPORT_CONFIG.backend.default + 1,\n\t\t];\n\t\tfor (const port of priorityPorts) {\n\t\t\tif (await this.isLifeTraceBackend(port)) {\n\t\t\t\tlogger.info(`Detected backend running on port: ${port}`);\n\t\t\t\treturn port;\n\t\t\t}\n\t\t}\n\n\t\t// 再检查其他可能的端口（跳过已检查的）\n\t\tconst startPort = PORT_CONFIG.backend.default + 2;\n\t\tconst endPort = PORT_CONFIG.backend.default + 100;\n\t\tfor (let port = startPort; port < endPort; port++) {\n\t\t\tif (await this.isLifeTraceBackend(port)) {\n\t\t\t\tlogger.info(`Detected backend running on port: ${port}`);\n\t\t\t\treturn port;\n\t\t\t}\n\t\t}\n\n\t\treturn null;\n\t}\n\n\t/**\n\t * 设置后端端口（用于检测到的已运行后端）\n\t * @param port 后端端口\n\t */\n\tsetPort(port: number): void {\n\t\tthis.port = port;\n\t}\n\n\t/**\n\t * 等待后端服务器就绪并完成必要校验\n\t * @param timeout 超时时间（毫秒）\n\t */\n\tasync waitForReadyAndVerify(timeout: number): Promise<void> {\n\t\tconst backendUrl = this.getUrl();\n\t\tawait this.waitForReady(backendUrl, timeout);\n\t\temitStatus({ message: \"后端服务已就绪\", progress: 78 });\n\t\tawait this.verifyBackendMode();\n\t\tthis.startHealthCheck();\n\t}\n\n\t/**\n\t * 确保健康检查已启动（公共方法）\n\t */\n\tensureHealthCheck(): void {\n\t\tif (!this.healthCheckTimer) {\n\t\t\tthis.startHealthCheck();\n\t\t}\n\t}\n\n\t/**\n\t * 启动后端服务器\n\t */\n\tasync start(options?: { waitForReady?: boolean }): Promise<void> {\n\t\tif (this.process) {\n\t\t\tlogger.info(\"Backend server is already running\");\n\t\t\treturn;\n\t\t}\n\n\t\t// 如果端口已设置（通过 detectRunningBackendPort 检测到的），直接使用\n\t\tif (this.port !== PORT_CONFIG.backend.default) {\n\t\t\t// 端口已被设置，说明是检测到的已运行后端\n\t\t\tlogger.info(`Using existing backend server at port ${this.port}`);\n\t\t\t// 启动健康检查（但不管理进程）\n\t\t\tthis.startHealthCheck();\n\t\t\treturn;\n\t\t}\n\n\t\t// 解析路径\n\t\tthis.resolveBackendPaths();\n\n\t\t// 检查后端脚本与依赖文件\n\t\tif (!this.checkBackendExists()) {\n\t\t\t// 在开发模式下，如果文件不存在，尝试使用默认端口\n\t\t\t// 可能后端是通过其他方式启动的\n\t\t\tif (!app.isPackaged) {\n\t\t\t\tlogger.warn(\n\t\t\t\t\t\"Backend source not found, but will try to connect to default port\",\n\t\t\t\t);\n\t\t\t\tthis.port = PORT_CONFIG.backend.default;\n\t\t\t\t// 等待后端就绪\n\t\t\t\tlogger.console(\n\t\t\t\t\t`Waiting for backend server at ${this.getUrl()} to be ready...`,\n\t\t\t\t);\n\t\t\t\ttry {\n\t\t\t\t\tawait this.waitForReady(\n\t\t\t\t\t\tthis.getUrl(),\n\t\t\t\t\t\tthis.config.readyTimeout,\n\t\t\t\t\t);\n\t\t\t\t\tlogger.console(\n\t\t\t\t\t\t`Backend server is ready at ${this.getUrl()}!`,\n\t\t\t\t\t);\n\t\t\t\t\t// 启动健康检查\n\t\t\t\t\tthis.startHealthCheck();\n\t\t\t\t\treturn;\n\t\t\t\t} catch (error) {\n\t\t\t\t\tconst errorMsg = `Failed to connect to backend: ${error instanceof Error ? error.message : String(error)}`;\n\t\t\t\t\tlogger.error(errorMsg);\n\t\t\t\t\tdialog.showErrorBox(\"Backend Connection Error\", errorMsg);\n\t\t\t\t\tthrow error;\n\t\t\t\t}\n\t\t\t}\n\t\t\t// 打包模式下必须找到运行时文件\n\t\t\tthrow new Error(\"Backend runtime files not found\");\n\t\t}\n\n\t\t// 确保路径已解析（用于类型收窄）\n\t\tif (!this.backendEntryScript || !this.backendSourceDir || !this.dataDir) {\n\t\t\tthrow new Error(\"Backend paths not resolved\");\n\t\t}\n\n\t\tif (this.backendRuntime === \"script\") {\n\t\t\t// 确保 Python 运行时与依赖已安装\n\t\t\tif (!this.backendRequirementsPath || !this.venvDir) {\n\t\t\t\tthrow new Error(\"Backend requirements not resolved\");\n\t\t\t}\n\t\t\ttry {\n\t\t\t\temitStatus({ message: \"准备后端运行时\", progress: 30 });\n\t\t\t\tthis.venvPythonPath = await ensurePythonRuntime(\n\t\t\t\t\tthis.venvDir,\n\t\t\t\t\tthis.backendRequirementsPath,\n\t\t\t\t);\n\t\t\t} catch (error) {\n\t\t\t\tconst errorMsg = `Failed to prepare Python runtime: ${error instanceof Error ? error.message : String(error)}`;\n\t\t\t\tlogger.error(errorMsg);\n\t\t\t\tif (!isCancelled()) {\n\t\t\t\t\tdialog.showErrorBox(\n\t\t\t\t\t\t\"Python Runtime Error\",\n\t\t\t\t\t\t`${errorMsg}\\n\\nCheck logs at: ${logger.getLogFilePath()}`,\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tthrow error;\n\t\t\t}\n\t\t\tif (!this.venvPythonPath) {\n\t\t\t\tthrow new Error(\"Python runtime not available\");\n\t\t\t}\n\t\t}\n\t\temitStatus({ message: \"启动后端服务\", progress: 75 });\n\n\t\t// 动态端口分配\n\t\ttry {\n\t\t\tthis.port = await portManager.findAvailablePort(\n\t\t\t\tPORT_CONFIG.backend.default,\n\t\t\t\tPORT_CONFIG.backend.maxAttempts,\n\t\t\t);\n\t\t\tlogger.info(`Backend will use port: ${this.port}`);\n\t\t} catch (error) {\n\t\t\tconst errorMsg = `Failed to find available backend port: ${error instanceof Error ? error.message : String(error)}`;\n\t\t\tlogger.error(errorMsg);\n\t\t\tdialog.showErrorBox(\"Port Allocation Error\", errorMsg);\n\t\t\tthrow error;\n\t\t}\n\n\t\tlogger.info(\"Starting backend server...\");\n\t\temitLog(`Backend entry: ${this.backendEntryScript}`);\n\t\temitLog(`Backend venv: ${this.venvDir}`);\n\t\tlogger.info(`Backend entry: ${this.backendEntryScript}`);\n\t\tlogger.info(`Backend source dir: ${this.backendSourceDir}`);\n\t\tlogger.info(`Backend runtime: ${this.backendRuntime}`);\n\t\tlogger.info(`Backend requirements: ${this.backendRequirementsPath ?? \"n/a\"}`);\n\t\tlogger.info(`Backend venv: ${this.venvDir}`);\n\t\tlogger.info(`Data directory: ${this.dataDir}`);\n\t\tlogger.info(`Backend port: ${this.port}`);\n\n\t\t// 启动后端进程，传递模式参数\n\t\tconst backendArgs = [\n\t\t\t\"--port\",\n\t\t\tString(this.port),\n\t\t\t\"--data-dir\",\n\t\t\tthis.dataDir,\n\t\t\t\"--mode\",\n\t\t\tthis.serverMode,\n\t\t];\n\t\tconst spawnCommand =\n\t\t\tthis.backendRuntime === \"pyinstaller\"\n\t\t\t\t? this.backendEntryScript\n\t\t\t\t: this.venvPythonPath;\n\t\tconst spawnArgs =\n\t\t\tthis.backendRuntime === \"pyinstaller\"\n\t\t\t\t? backendArgs\n\t\t\t\t: [this.backendEntryScript, ...backendArgs];\n\n\t\tif (!spawnCommand) {\n\t\t\tthrow new Error(\"Backend executable not resolved\");\n\t\t}\n\t\tif (this.backendRuntime === \"script\" && !this.backendEntryScript) {\n\t\t\tthrow new Error(\"Backend entry script not resolved\");\n\t\t}\n\n\t\tthis.process = spawn(spawnCommand, spawnArgs, {\n\t\t\tcwd: this.backendSourceDir,\n\t\t\tenv: {\n\t\t\t\t...process.env,\n\t\t\t\tPYTHONUNBUFFERED: \"1\",\n\t\t\t\tPYTHONUTF8: \"1\",\n\t\t\t\t...(this.gitCommit && {\n\t\t\t\t\tLIFETRACE_GIT_COMMIT: this.gitCommit,\n\t\t\t\t}),\n\t\t\t\t...(this.serverMode === \"build\" && {\n\t\t\t\t\tLIFETRACE__OBSERVABILITY__ENABLED: \"false\",\n\t\t\t\t\tLIFETRACE__SERVER__DEBUG: \"false\",\n\t\t\t\t}),\n\t\t\t},\n\t\t\tstdio: [\"ignore\", \"pipe\", \"pipe\"],\n\t\t});\n\n\t\t// 设置输出监听器\n\t\tthis.setupProcessOutputListeners(this.process);\n\n\t\t// 设置错误处理\n\t\tthis.process.on(\"error\", (error) => {\n\t\t\tconst errorMsg = `Failed to start backend server: ${error.message}`;\n\t\t\tlogger.error(errorMsg);\n\t\t\tdialog.showErrorBox(\n\t\t\t\t\"Backend Start Error\",\n\t\t\t\t`${errorMsg}\\n\\nCheck logs at: ${logger.getLogFilePath()}`,\n\t\t\t);\n\t\t\tthis.process = null;\n\t\t});\n\n\t\t// 设置退出处理\n\t\tthis.process.on(\"exit\", (code, signal) => {\n\t\t\tconst exitMsg = `Backend server exited with code ${code}${signal ? `, signal ${signal}` : \"\"}`;\n\t\t\tthis.process = null;\n\n\t\t\t// 如果是主动关闭（调用了 stop() 方法），不显示错误对话框\n\t\t\tif (this.isStopping) {\n\t\t\t\tlogger.info(`${exitMsg} (intentional shutdown)`);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tlogger.error(exitMsg);\n\n\t\t\t// 只在非正常退出时显示错误对话框（code 不为 0 且不为 null）\n\t\t\tif (code !== 0 && code !== null) {\n\t\t\t\tdialog.showErrorBox(\n\t\t\t\t\t\"Backend Server Exited\",\n\t\t\t\t\t`The backend server exited unexpectedly.\\n\\n${exitMsg}\\n\\nCheck logs at: ${logger.getLogFilePath()}\\n\\nBackend entry: ${this.backendEntryScript}\\nData directory: ${this.dataDir}`,\n\t\t\t\t);\n\t\t\t}\n\t\t});\n\n\t\tif (options?.waitForReady !== false) {\n\t\t\t// 等待后端就绪\n\t\t\tlogger.console(`Waiting for backend server at ${this.getUrl()} to be ready...`);\n\t\t\tawait this.waitForReady(this.getUrl(), this.config.readyTimeout);\n\t\t\tlogger.console(`Backend server is ready at ${this.getUrl()}!`);\n\t\t\temitStatus({ message: \"后端服务已就绪\", progress: 78 });\n\n\t\t\t// 验证后端模式是否匹配\n\t\t\tawait this.verifyBackendMode();\n\n\t\t\t// 启动健康检查\n\t\t\tthis.startHealthCheck();\n\t\t} else {\n\t\t\tlogger.info(\"Backend server started (waiting for readiness in background)\");\n\t\t}\n\t}\n\n\t/**\n\t * 验证后端服务器模式是否与前端期望一致\n\t * 防止 DEV 前端连接到 Build 后端，或反之\n\t */\n\tprivate async verifyBackendMode(): Promise<void> {\n\t\ttry {\n\t\t\tconst healthUrl = `${this.getUrl()}/health`;\n\t\t\tconst response = await fetch(healthUrl);\n\n\t\t\tif (!response.ok) {\n\t\t\t\tlogger.warn(`Cannot verify backend mode: health check returned ${response.status}`);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst data = (await response.json()) as {\n\t\t\t\tapp?: string;\n\t\t\t\tserver_mode?: string;\n\t\t\t\tgit_commit?: string;\n\t\t\t};\n\n\t\t\t// 检查应用标识\n\t\t\tif (data.app !== \"lifetrace\") {\n\t\t\t\tconst errorMsg = `Backend at ${this.getUrl()} is not a FreeTodo server (app: ${data.app})`;\n\t\t\t\tlogger.error(errorMsg);\n\t\t\t\tthrow new Error(errorMsg);\n\t\t\t}\n\n\t\t\t// 检查服务器模式\n\t\t\tconst backendMode = data.server_mode;\n\t\t\tif (backendMode && backendMode !== this.serverMode) {\n\t\t\t\tconst errorMsg = `Backend mode mismatch: expected \"${this.serverMode}\", got \"${backendMode}\". This may indicate another version of FreeTodo is running.`;\n\t\t\t\tlogger.error(errorMsg);\n\t\t\t\tdialog.showErrorBox(\n\t\t\t\t\t\"Backend Mode Mismatch\",\n\t\t\t\t\t`The backend server is running in \"${backendMode}\" mode, but this application is running in \"${this.serverMode}\" mode.\\n\\nThis usually happens when both DEV and Build versions are running simultaneously.\\n\\nPlease close the other version and restart this application.`,\n\t\t\t\t);\n\t\t\t\tthrow new Error(errorMsg);\n\t\t\t}\n\n\t\t\t// 检查 Git Commit\n\t\t\tif (this.gitCommit && data.git_commit && data.git_commit !== this.gitCommit) {\n\t\t\t\tconst errorMsg = `Backend commit mismatch: expected \"${this.gitCommit}\", got \"${data.git_commit}\".`;\n\t\t\t\tlogger.error(errorMsg);\n\t\t\t\tdialog.showErrorBox(\n\t\t\t\t\t\"Backend Commit Mismatch\",\n\t\t\t\t\t`The backend server is running with a different git commit.\\n\\nExpected: ${this.gitCommit}\\nDetected: ${data.git_commit}\\n\\nPlease close the other version and restart this application.`,\n\t\t\t\t);\n\t\t\t\tthrow new Error(errorMsg);\n\t\t\t}\n\n\t\t\tlogger.info(`Backend mode verified: ${backendMode || \"unknown\"}`);\n\t\t} catch (error) {\n\t\t\tif (\n\t\t\t\terror instanceof Error &&\n\t\t\t\t(error.message.includes(\"mode mismatch\") ||\n\t\t\t\t\terror.message.includes(\"commit mismatch\"))\n\t\t\t) {\n\t\t\t\tthrow error;\n\t\t\t}\n\t\t\t// 其他错误（网络问题等）只记录警告，不阻止启动\n\t\t\tlogger.warn(`Cannot verify backend mode: ${error instanceof Error ? error.message : String(error)}`);\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "free-todo-frontend/electron/bootstrap-control.ts",
    "content": "/**\n * Bootstrap cancellation control\n */\n\nimport type { ChildProcess } from \"node:child_process\";\n\nlet cancelled = false;\nlet activeProcess: ChildProcess | null = null;\nconst listeners = new Set<() => void>();\n\nexport function isCancelled(): boolean {\n\treturn cancelled;\n}\n\nexport function setActiveProcess(proc: ChildProcess | null): void {\n\tactiveProcess = proc;\n}\n\nexport function clearActiveProcess(proc: ChildProcess | null): void {\n\tif (activeProcess === proc) {\n\t\tactiveProcess = null;\n\t}\n}\n\nexport function onCancel(handler: () => void): () => void {\n\tlisteners.add(handler);\n\treturn () => listeners.delete(handler);\n}\n\nexport function cancelBootstrap(): void {\n\tif (cancelled) {\n\t\treturn;\n\t}\n\tcancelled = true;\n\tif (activeProcess && !activeProcess.killed) {\n\t\ttry {\n\t\t\tactiveProcess.kill();\n\t\t} catch {\n\t\t\t// ignore kill errors\n\t\t}\n\t}\n\tfor (const listener of listeners) {\n\t\ttry {\n\t\t\tlistener();\n\t\t} catch {\n\t\t\t// ignore listener errors\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "free-todo-frontend/electron/bootstrap-status.ts",
    "content": "/**\n * Bootstrap status broadcaster (main process only)\n */\n\nimport { EventEmitter } from \"node:events\";\n\nexport type BootstrapStatus = {\n\tmessage?: string;\n\tprogress?: number;\n\tdetail?: string;\n\tlevel?: \"info\" | \"warn\" | \"error\";\n\tinstallPath?: string;\n\tpythonPath?: string;\n\tvenvPath?: string;\n};\n\nconst emitter = new EventEmitter();\n\nexport function emitStatus(status: BootstrapStatus): void {\n\temitter.emit(\"status\", status);\n}\n\nexport function emitLog(line: string): void {\n\temitter.emit(\"log\", line);\n}\n\nexport function emitComplete(): void {\n\temitter.emit(\"complete\");\n}\n\nexport function onStatus(handler: (status: BootstrapStatus) => void): void {\n\temitter.on(\"status\", handler);\n}\n\nexport function onLog(handler: (line: string) => void): void {\n\temitter.on(\"log\", handler);\n}\n\nexport function onComplete(handler: () => void): void {\n\temitter.on(\"complete\", handler);\n}\n"
  },
  {
    "path": "free-todo-frontend/electron/bootstrap-window.ts",
    "content": "/**\n * Bootstrap window (shows install progress and logs)\n */\n\nimport { BrowserWindow } from \"electron\";\nimport { onComplete, onLog, onStatus } from \"./bootstrap-status\";\n\nlet bootstrapWindow: BrowserWindow | null = null;\nlet listenersAttached = false;\n\nfunction getBootstrapHtml(): string {\n\treturn `<!doctype html>\n<html lang=\"en\">\n\t<head>\n\t\t<meta charset=\"utf-8\" />\n\t\t<title>FreeTodo Setup</title>\n\t\t<style>\n\t\t\t:root {\n\t\t\t\tcolor-scheme: light;\n\t\t\t\t--bg: #f8f6f1;\n\t\t\t\t--panel: #ffffff;\n\t\t\t\t--text: #1c1b1f;\n\t\t\t\t--muted: #6b6a6f;\n\t\t\t\t--accent: #2f6bff;\n\t\t\t\t--accent-soft: #dbe6ff;\n\t\t\t\t--border: #e5e2ea;\n\t\t\t}\n\t\t\t* { box-sizing: border-box; }\n\t\t\tbody {\n\t\t\t\tmargin: 0;\n\t\t\t\tfont-family: \"SF Pro Display\", \"Segoe UI\", \"Noto Sans\", sans-serif;\n\t\t\t\tbackground: var(--bg);\n\t\t\t\tcolor: var(--text);\n\t\t\t}\n\t\t\t.container {\n\t\t\t\tpadding: 28px 28px 24px;\n\t\t\t}\n\t\t\t.card {\n\t\t\t\tbackground: var(--panel);\n\t\t\t\tborder: 1px solid var(--border);\n\t\t\t\tborder-radius: 16px;\n\t\t\t\tpadding: 20px;\n\t\t\t\tbox-shadow: 0 10px 30px rgba(24, 24, 24, 0.08);\n\t\t\t}\n\t\t\t.title {\n\t\t\t\tfont-size: 18px;\n\t\t\t\tfont-weight: 600;\n\t\t\t\tmargin-bottom: 8px;\n\t\t\t}\n\t\t\t.subtitle {\n\t\t\t\tcolor: var(--muted);\n\t\t\t\tfont-size: 13px;\n\t\t\t\tmargin-bottom: 16px;\n\t\t\t}\n\t\t\t.progress {\n\t\t\t\tbackground: var(--accent-soft);\n\t\t\t\tborder-radius: 999px;\n\t\t\t\theight: 10px;\n\t\t\t\toverflow: hidden;\n\t\t\t}\n\t\t\t.progress > div {\n\t\t\t\theight: 100%;\n\t\t\t\twidth: 0%;\n\t\t\t\tbackground: linear-gradient(90deg, #2f6bff, #3fa5ff);\n\t\t\t\ttransition: width 0.25s ease;\n\t\t\t}\n\t\t\t.status {\n\t\t\t\tmargin-top: 16px;\n\t\t\t\tfont-size: 14px;\n\t\t\t\tfont-weight: 500;\n\t\t\t}\n\t\t\t.detail {\n\t\t\t\tmargin-top: 6px;\n\t\t\t\tfont-size: 12px;\n\t\t\t\tcolor: var(--muted);\n\t\t\t\tmin-height: 16px;\n\t\t\t}\n\t\t\t.meta {\n\t\t\t\tmargin-top: 12px;\n\t\t\t\tfont-size: 12px;\n\t\t\t\tcolor: var(--muted);\n\t\t\t\tdisplay: grid;\n\t\t\t\tgap: 6px;\n\t\t\t}\n\t\t\t.meta span {\n\t\t\t\tcolor: var(--text);\n\t\t\t}\n\t\t\t.actions {\n\t\t\t\tmargin-top: 14px;\n\t\t\t\tdisplay: flex;\n\t\t\t\tjustify-content: space-between;\n\t\t\t\talign-items: center;\n\t\t\t\tgap: 12px;\n\t\t\t}\n\t\t\t.button {\n\t\t\t\tbackground: transparent;\n\t\t\t\tborder: none;\n\t\t\t\tcolor: var(--accent);\n\t\t\t\tfont-size: 12px;\n\t\t\t\tfont-weight: 600;\n\t\t\t\tcursor: pointer;\n\t\t\t}\n\t\t\t.stop-button {\n\t\t\t\tcolor: #d93025;\n\t\t\t}\n\t\t\t.start-button {\n\t\t\t\tbackground: var(--accent);\n\t\t\t\tcolor: #ffffff;\n\t\t\t\tborder-radius: 999px;\n\t\t\t\tpadding: 6px 14px;\n\t\t\t\tfont-size: 12px;\n\t\t\t\tfont-weight: 600;\n\t\t\t\tborder: none;\n\t\t\t\tcursor: pointer;\n\t\t\t\topacity: 0.6;\n\t\t\t}\n\t\t\t.start-button.enabled {\n\t\t\t\topacity: 1;\n\t\t\t}\n\t\t\t.log {\n\t\t\t\tmargin-top: 12px;\n\t\t\t\tdisplay: none;\n\t\t\t\tborder: 1px solid var(--border);\n\t\t\t\tborder-radius: 12px;\n\t\t\t\tbackground: #0f1115;\n\t\t\t\tcolor: #e5e7eb;\n\t\t\t\tpadding: 12px;\n\t\t\t\tfont-size: 11px;\n\t\t\t\theight: 160px;\n\t\t\t\toverflow: auto;\n\t\t\t\twhite-space: pre-wrap;\n\t\t\t}\n\t\t\t.log.visible { display: block; }\n\t\t</style>\n\t</head>\n\t<body>\n\t\t<div class=\"container\">\n\t\t\t<div class=\"card\">\n\t\t\t\t<div class=\"title\">正在准备 FreeTodo</div>\n\t\t\t\t<div class=\"subtitle\">首次启动会自动安装 Python 3.12 与依赖。</div>\n\t\t\t\t<div class=\"progress\"><div id=\"progressBar\"></div></div>\n\t\t\t\t<div class=\"status\" id=\"statusText\">准备中...</div>\n\t\t\t\t<div class=\"detail\" id=\"statusDetail\"></div>\n\t\t\t\t<div class=\"meta\">\n\t\t\t\t\t<div>安装位置: <span id=\"installPath\">-</span></div>\n\t\t\t\t\t<div>Python 环境: <span id=\"pythonPath\">-</span></div>\n\t\t\t\t\t<div>虚拟环境: <span id=\"venvPath\">-</span></div>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"actions\">\n\t\t\t\t\t<span id=\"statusPercent\">0%</span>\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<button class=\"button\" id=\"selectPython\">选择 Python</button>\n\t\t\t\t\t\t<button class=\"button stop-button\" id=\"stopInstall\">停止安装</button>\n\t\t\t\t\t\t<button class=\"button\" id=\"toggleLog\">查看日志</button>\n\t\t\t\t\t\t<button class=\"start-button\" id=\"startButton\" disabled>开始使用</button>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<pre class=\"log\" id=\"logView\"></pre>\n\t\t\t</div>\n\t\t</div>\n\t\t<script>\n\t\t\tconst { ipcRenderer } = require(\"electron\");\n\t\t\tconst progressBar = document.getElementById(\"progressBar\");\n\t\t\tconst statusText = document.getElementById(\"statusText\");\n\t\t\tconst statusDetail = document.getElementById(\"statusDetail\");\n\t\t\tconst statusPercent = document.getElementById(\"statusPercent\");\n\t\t\tconst logView = document.getElementById(\"logView\");\n\t\t\tconst toggleLog = document.getElementById(\"toggleLog\");\n\t\t\tconst startButton = document.getElementById(\"startButton\");\n\t\t\tconst selectPython = document.getElementById(\"selectPython\");\n\t\t\tconst stopInstall = document.getElementById(\"stopInstall\");\n\t\t\tconst installPath = document.getElementById(\"installPath\");\n\t\t\tconst pythonPath = document.getElementById(\"pythonPath\");\n\t\t\tconst venvPath = document.getElementById(\"venvPath\");\n\n\t\t\tlet logVisible = false;\n\t\t\ttoggleLog.addEventListener(\"click\", () => {\n\t\t\t\tlogVisible = !logVisible;\n\t\t\t\tlogView.classList.toggle(\"visible\", logVisible);\n\t\t\t\ttoggleLog.textContent = logVisible ? \"收起日志\" : \"查看日志\";\n\t\t\t});\n\n\t\t\tstartButton.addEventListener(\"click\", () => {\n\t\t\t\tif (startButton.disabled) return;\n\t\t\t\tipcRenderer.send(\"bootstrap:continue\");\n\t\t\t});\n\n\t\t\tselectPython.addEventListener(\"click\", () => {\n\t\t\t\tipcRenderer.send(\"bootstrap:select-python\");\n\t\t\t});\n\n\t\t\tstopInstall.addEventListener(\"click\", () => {\n\t\t\t\tipcRenderer.send(\"bootstrap:stop\");\n\t\t\t});\n\n\t\t\tipcRenderer.on(\"bootstrap:status\", (_event, status) => {\n\t\t\t\tif (status.message) statusText.textContent = status.message;\n\t\t\t\tif (status.detail) statusDetail.textContent = status.detail;\n\t\t\t\tif (status.installPath) installPath.textContent = status.installPath;\n\t\t\t\tif (status.pythonPath) pythonPath.textContent = status.pythonPath;\n\t\t\t\tif (status.venvPath) venvPath.textContent = status.venvPath;\n\t\t\t\tif (typeof status.progress === \"number\") {\n\t\t\t\t\tconst value = Math.max(0, Math.min(100, status.progress));\n\t\t\t\t\tprogressBar.style.width = value + \"%\";\n\t\t\t\t\tstatusPercent.textContent = value + \"%\";\n\t\t\t\t}\n\t\t\t});\n\n\t\t\tipcRenderer.on(\"bootstrap:complete\", () => {\n\t\t\t\tstartButton.disabled = false;\n\t\t\t\tstartButton.classList.add(\"enabled\");\n\t\t\t});\n\n\t\t\tipcRenderer.on(\"bootstrap:log\", (_event, line) => {\n\t\t\t\tif (!line) return;\n\t\t\t\tlogView.textContent += line.endsWith(\"\\\\n\") ? line : line + \"\\\\n\";\n\t\t\t\tlogView.scrollTop = logView.scrollHeight;\n\t\t\t});\n\t\t</script>\n\t</body>\n</html>`;\n}\n\nexport function createBootstrapWindow(): BrowserWindow {\n\tif (bootstrapWindow) {\n\t\treturn bootstrapWindow;\n\t}\n\n\tbootstrapWindow = new BrowserWindow({\n\t\twidth: 520,\n\t\theight: 400,\n\t\tresizable: false,\n\t\tshow: false,\n\t\ttitle: \"FreeTodo Setup\",\n\t\tclosable: true,\n\t\talwaysOnTop: true,\n\t\tbackgroundColor: \"#f8f6f1\",\n\t\twebPreferences: {\n\t\t\tnodeIntegration: true,\n\t\t\tcontextIsolation: false,\n\t\t},\n\t});\n\n\tbootstrapWindow.setMenuBarVisibility(false);\n\tbootstrapWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(getBootstrapHtml())}`);\n\tbootstrapWindow.once(\"ready-to-show\", () => {\n\t\tbootstrapWindow?.show();\n\t});\n\tbootstrapWindow.on(\"closed\", () => {\n\t\tbootstrapWindow = null;\n\t});\n\n\tif (!listenersAttached) {\n\t\tonStatus((status) => {\n\t\t\tif (!bootstrapWindow) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tbootstrapWindow.webContents.send(\"bootstrap:status\", status);\n\t\t});\n\t\tonLog((line) => {\n\t\t\tif (!bootstrapWindow) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tbootstrapWindow.webContents.send(\"bootstrap:log\", line);\n\t\t});\n\t\tonComplete(() => {\n\t\t\tif (!bootstrapWindow) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tbootstrapWindow.webContents.send(\"bootstrap:complete\");\n\t\t});\n\t\tlistenersAttached = true;\n\t}\n\n\treturn bootstrapWindow;\n}\n\nexport function getBootstrapWindow(): BrowserWindow | null {\n\treturn bootstrapWindow;\n}\n\nexport function closeBootstrapWindow(): void {\n\tif (!bootstrapWindow) {\n\t\treturn;\n\t}\n\tbootstrapWindow.close();\n\tbootstrapWindow = null;\n}\n"
  },
  {
    "path": "free-todo-frontend/electron/config.ts",
    "content": "/**\n * Electron 主进程配置常量\n * 集中管理所有配置项，消除魔法数字\n */\n\nimport { app } from \"electron\";\n\n/**\n * 服务器模式类型\n * - dev: 开发模式（从源码运行或 pnpm dev）\n * - build: 打包模式（Electron 打包后运行）\n */\nexport type ServerMode = \"dev\" | \"build\";\n\n/**\n * 获取当前服务器模式\n * 打包后的应用为 \"build\" 模式，开发时为 \"dev\" 模式\n */\nexport function getServerMode(): ServerMode {\n\t// 如果 app.isPackaged 为 true，说明是打包后的应用\n\t// 注意：此函数必须在 app ready 之后调用才能获得正确的 isPackaged 值\n\t// 但 PORT_CONFIG 是在模块加载时就需要的，所以我们使用环境变量来判断\n\t// 在开发模式下 NODE_ENV 通常不是 \"production\" 或者 app.isPackaged 为 false\n\n\t// 首先检查显式设置的环境变量\n\tif (process.env.SERVER_MODE === \"build\") {\n\t\treturn \"build\";\n\t}\n\tif (process.env.SERVER_MODE === \"dev\") {\n\t\treturn \"dev\";\n\t}\n\n\t// 尝试使用 app.isPackaged（如果 app 已经初始化）\n\ttry {\n\t\treturn app.isPackaged ? \"build\" : \"dev\";\n\t} catch {\n\t\t// app 未初始化，使用 NODE_ENV 判断\n\t\treturn process.env.NODE_ENV === \"production\" ? \"build\" : \"dev\";\n\t}\n}\n\n/**\n * 端口范围配置\n * DEV 模式和 Build 模式使用不同的端口范围，避免冲突\n */\nconst PORT_RANGES = {\n\t/** DEV 模式端口范围 */\n\tdev: {\n\t\tfrontend: 3001, // DEV 前端从 3001 开始\n\t\tbackend: 8001, // DEV 后端从 8001 开始\n\t},\n\t/** Build 模式端口范围 */\n\tbuild: {\n\t\tfrontend: 3100, // Build 前端从 3100 开始\n\t\tbackend: 8100, // Build 后端从 8100 开始\n\t},\n} as const;\n\n/**\n * 端口配置\n * 根据服务器模式动态选择端口范围\n */\nexport const PORT_CONFIG = {\n\t/** 前端服务器端口配置 */\n\tfrontend: {\n\t\t/** 默认端口（可通过 PORT 环境变量覆盖） */\n\t\tget default(): number {\n\t\t\tif (process.env.PORT) {\n\t\t\t\treturn Number.parseInt(process.env.PORT, 10);\n\t\t\t}\n\t\t\tconst mode = getServerMode();\n\t\t\treturn PORT_RANGES[mode].frontend;\n\t\t},\n\t\t/** 端口探测最大尝试次数 */\n\t\tmaxAttempts: 50,\n\t},\n\t/** 后端服务器端口配置 */\n\tbackend: {\n\t\t/** 默认端口（可通过 BACKEND_PORT 环境变量覆盖） */\n\t\tget default(): number {\n\t\t\tif (process.env.BACKEND_PORT) {\n\t\t\t\treturn Number.parseInt(process.env.BACKEND_PORT, 10);\n\t\t\t}\n\t\t\tconst mode = getServerMode();\n\t\t\treturn PORT_RANGES[mode].backend;\n\t\t},\n\t\t/** 端口探测最大尝试次数 */\n\t\tmaxAttempts: 50,\n\t},\n} as const;\n\n/**\n * 超时配置（毫秒）\n */\nexport const TIMEOUT_CONFIG = {\n\t/** 等待后端服务器就绪的超时时间（3 分钟） */\n\tbackendReady: 180_000,\n\t/** 等待前端服务器就绪的超时时间（30 秒） */\n\tfrontendReady: 30_000,\n\t/** 单次健康检查的超时时间（5 秒） */\n\thealthCheck: 5_000,\n\t/** 健康检查重试间隔（500 毫秒） */\n\thealthCheckRetry: 500,\n\t/** 应用退出延迟（让用户看到错误消息，3 秒） */\n\tquitDelay: 3_000,\n} as const;\n\n/**\n * 健康检查间隔配置（毫秒）\n */\nexport const HEALTH_CHECK_INTERVAL = {\n\t/** 前端服务器健康检查间隔（10 秒） */\n\tfrontend: 10_000,\n\t/** 后端服务器健康检查间隔（30 秒） */\n\tbackend: 30_000,\n} as const;\n\n/**\n * 窗口配置\n */\nexport const WINDOW_CONFIG = {\n\t/** 初始宽度 */\n\twidth: 1200,\n\t/** 初始高度 */\n\theight: 800,\n\t/** 最小宽度 */\n\tminWidth: 800,\n\t/** 最小高度 */\n\tminHeight: 600,\n\t/** 背景颜色（深色主题） */\n\tbackgroundColor: \"#1a1a1a\",\n} as const;\n\n/**\n * 窗口模式类型\n * - island: 灵动岛模式（默认，透明悬浮窗）\n * - web: Web 界面模式（普通窗口，类似浏览器）\n */\nexport type WindowMode = \"island\" | \"web\";\n\n/**\n * 编译时注入的默认窗口模式\n * 由 esbuild 在构建时通过 define 选项设置\n * 如果未定义，默认为 \"web\"\n */\ndeclare const __DEFAULT_WINDOW_MODE__: string | undefined;\n\n/**\n * 后端运行时类型\n * - script: 使用系统 Python + venv\n * - pyinstaller: 使用 PyInstaller 打包的可执行文件\n */\nexport type BackendRuntime = \"script\" | \"pyinstaller\";\n\n/**\n * 编译时注入的默认后端运行时\n */\ndeclare const __DEFAULT_BACKEND_RUNTIME__: string | undefined;\n\n/**\n * 获取当前窗口模式\n *\n * 优先级：\n * 1. 运行时环境变量 WINDOW_MODE（方便调试）\n * 2. 编译时注入的默认值 __DEFAULT_WINDOW_MODE__\n * 3. 硬编码默认值 \"web\"\n */\nexport function getWindowMode(): WindowMode {\n\t// 运行时环境变量优先（方便调试和开发）\n\tconst envMode = process.env.WINDOW_MODE?.toLowerCase();\n\tif (envMode === \"web\" || envMode === \"island\") {\n\t\treturn envMode;\n\t}\n\n\t// 编译时注入的默认值\n\ttry {\n\t\tconst buildTimeDefault = typeof __DEFAULT_WINDOW_MODE__ !== \"undefined\"\n\t\t\t? __DEFAULT_WINDOW_MODE__\n\t\t\t: undefined;\n\t\tif (buildTimeDefault === \"web\") {\n\t\t\treturn \"web\";\n\t\t}\n\t} catch {\n\t\t// __DEFAULT_WINDOW_MODE__ 未定义，使用硬编码默认值\n\t}\n\n\t// 硬编码默认值\n\treturn \"web\";\n}\n\n/**\n * 获取后端运行时类型\n *\n * 优先级：\n * 1. 运行时环境变量 FREETODO_BACKEND_RUNTIME\n * 2. 编译时注入的默认值 __DEFAULT_BACKEND_RUNTIME__\n * 3. 硬编码默认值 \"script\"\n */\nexport function getBackendRuntime(): BackendRuntime {\n\tconst envRuntime = process.env.FREETODO_BACKEND_RUNTIME?.toLowerCase();\n\tif (envRuntime === \"script\" || envRuntime === \"pyinstaller\") {\n\t\treturn envRuntime;\n\t}\n\n\ttry {\n\t\tconst buildTimeDefault =\n\t\t\ttypeof __DEFAULT_BACKEND_RUNTIME__ !== \"undefined\"\n\t\t\t\t? __DEFAULT_BACKEND_RUNTIME__\n\t\t\t\t: undefined;\n\t\tif (buildTimeDefault === \"pyinstaller\") {\n\t\t\treturn \"pyinstaller\";\n\t\t}\n\t} catch {\n\t\t// ignore\n\t}\n\n\treturn \"script\";\n}\n\n/**\n * 日志配置\n */\nexport const LOG_CONFIG = {\n\t/** 日志缓冲区显示的最大字符数 */\n\tbufferDisplayLimit: 2000,\n\t/** 错误对话框中显示的日志最大字符数 */\n\tdialogDisplayLimit: 1000,\n} as const;\n\n/**\n * 进程配置\n */\nexport const PROCESS_CONFIG = {\n\t/** 后端入口脚本（相对 backend 根目录） */\n\tbackendEntryScript: \"lifetrace/scripts/start_backend.py\",\n\t/** 后端可执行文件名称 */\n\tbackendExecutable:\n\t\tprocess.platform === \"win32\" ? \"lifetrace.exe\" : \"lifetrace\",\n\t/** 后端依赖清单（相对 backend 根目录） */\n\tbackendRequirementsFile: \"requirements-runtime.txt\",\n\t/** 后端运行时目录名（应用安装目录下） */\n\tbackendRuntimeDir: \"runtime\",\n\t/** 后端虚拟环境目录名（运行时目录下） */\n\tbackendVenvDir: \"python-venv\",\n\t/** 后端数据目录名 */\n\tbackendDataDir: \"lifetrace-data\",\n} as const;\n\n/**\n * 判断当前是否为开发模式\n * 打包的应用始终为生产模式\n */\nexport function isDevelopment(isPackaged: boolean): boolean {\n\treturn !isPackaged && process.env.NODE_ENV !== \"production\";\n}\n"
  },
  {
    "path": "free-todo-frontend/electron/git-info.ts",
    "content": "import { execSync } from \"node:child_process\";\nimport path from \"node:path\";\n\nlet cachedCommit: string | null = null;\n\nexport function getGitCommit(): string | null {\n\tconst envCommit = process.env.FREETODO_GIT_COMMIT || process.env.GIT_COMMIT;\n\tif (envCommit) {\n\t\treturn envCommit;\n\t}\n\n\tif (cachedCommit !== null) {\n\t\treturn cachedCommit;\n\t}\n\n\tconst repoRoot = path.resolve(__dirname, \"..\");\n\tif (repoRoot.includes(\".asar\")) {\n\t\tcachedCommit = null;\n\t\treturn cachedCommit;\n\t}\n\n\ttry {\n\t\tconst commit = execSync(\"git rev-parse HEAD\", {\n\t\t\tcwd: repoRoot,\n\t\t\tstdio: [\"ignore\", \"pipe\", \"ignore\"],\n\t\t})\n\t\t\t.toString()\n\t\t\t.trim();\n\t\tcachedCommit = commit || null;\n\t} catch {\n\t\tcachedCommit = null;\n\t}\n\n\treturn cachedCommit;\n}\n"
  },
  {
    "path": "free-todo-frontend/electron/global-shortcut-manager.ts",
    "content": "/**\n * Global Keyboard Shortcuts Manager\n * Centralized management for all global keyboard shortcuts\n * Supports user-customizable shortcuts (future enhancement)\n */\n\nimport { app, globalShortcut } from \"electron\";\nimport type { IslandWindowManager } from \"./island-window-manager\";\nimport { logger } from \"./logger\";\n\n/**\n * Shortcut configuration interface\n */\ninterface ShortcutConfig {\n\t/** Keyboard accelerator string (e.g., \"CommandOrControl+Shift+I\") */\n\taccelerator: string;\n\t/** Human-readable description */\n\tdescription: string;\n\t/** Handler function */\n\thandler: () => void;\n}\n\n/**\n * GlobalShortcutManager class\n * Manages all global keyboard shortcuts for the application\n */\nexport class GlobalShortcutManager {\n\t/** Island window manager reference */\n\tprivate islandWindowManager: IslandWindowManager;\n\t/** Map of registered shortcuts: name -> config */\n\tprivate shortcuts: Map<string, ShortcutConfig> = new Map();\n\t/** Track which shortcuts are successfully registered */\n\tprivate registeredAccelerators: Set<string> = new Set();\n\n\t/**\n\t * Default shortcut configurations\n\t * Can be overridden by user preferences in the future\n\t */\n\tprivate readonly defaultShortcuts = {\n\t\ttoggleIsland: {\n\t\t\taccelerator: \"CommandOrControl+Shift+I\",\n\t\t\tdescription: \"Toggle Island window visibility\",\n\t\t},\n\t\t// Future shortcuts can be added here:\n\t\t// startRecording: {\n\t\t//   accelerator: \"CommandOrControl+Shift+R\",\n\t\t//   description: \"Start/stop recording\",\n\t\t// },\n\t\t// takeScreenshot: {\n\t\t//   accelerator: \"CommandOrControl+Shift+S\",\n\t\t//   description: \"Take screenshot\",\n\t\t// },\n\t};\n\n\t/**\n\t * Constructor\n\t * @param islandWindowManager Island window manager instance\n\t */\n\tconstructor(islandWindowManager: IslandWindowManager) {\n\t\tthis.islandWindowManager = islandWindowManager;\n\t\tthis.setupCleanup();\n\t}\n\n\t/**\n\t * Register all default shortcuts\n\t */\n\tregisterDefaults(): void {\n\t\tlogger.info(\"Registering default global shortcuts...\");\n\n\t\t// Register toggle island shortcut\n\t\tthis.register(\n\t\t\t\"toggleIsland\",\n\t\t\tthis.defaultShortcuts.toggleIsland.accelerator,\n\t\t\tthis.defaultShortcuts.toggleIsland.description,\n\t\t\t() => {\n\t\t\t\tthis.islandWindowManager.toggle();\n\t\t\t\tlogger.info(\"Island toggled via global shortcut\");\n\t\t\t},\n\t\t);\n\n\t\t// Future: register additional shortcuts here\n\n\t\t// Log registration summary\n\t\tlogger.info(\n\t\t\t`Global shortcuts registered: ${this.registeredAccelerators.size}/${this.shortcuts.size}`,\n\t\t);\n\t}\n\n\t/**\n\t * Register a global shortcut\n\t * @param name Unique name for the shortcut\n\t * @param accelerator Keyboard accelerator (e.g., \"Ctrl+Shift+X\")\n\t * @param description Human-readable description\n\t * @param handler Function to execute when shortcut is triggered\n\t * @returns true if registered successfully, false otherwise\n\t */\n\tregister(\n\t\tname: string,\n\t\taccelerator: string,\n\t\tdescription: string,\n\t\thandler: () => void,\n\t): boolean {\n\t\t// Store the shortcut configuration\n\t\tconst config: ShortcutConfig = {\n\t\t\taccelerator,\n\t\t\tdescription,\n\t\t\thandler,\n\t\t};\n\t\tthis.shortcuts.set(name, config);\n\n\t\t// Attempt to register with Electron\n\t\ttry {\n\t\t\tconst registered = globalShortcut.register(accelerator, () => {\n\t\t\t\tlogger.info(`Global shortcut triggered: ${name} (${accelerator})`);\n\t\t\t\thandler();\n\t\t\t});\n\n\t\t\tif (registered) {\n\t\t\t\tthis.registeredAccelerators.add(accelerator);\n\t\t\t\tlogger.info(`Global shortcut registered: ${name} (${accelerator}) - ${description}`);\n\t\t\t\treturn true;\n\t\t\t}\n\n\t\t\tlogger.warn(\n\t\t\t\t`Failed to register global shortcut: ${name} (${accelerator}) - may be in use by another application`,\n\t\t\t);\n\t\t\treturn false;\n\t\t} catch (error) {\n\t\t\tlogger.error(\n\t\t\t\t`Error registering global shortcut ${name}: ${error instanceof Error ? error.message : String(error)}`,\n\t\t\t);\n\t\t\treturn false;\n\t\t}\n\t}\n\n\t/**\n\t * Unregister a specific shortcut\n\t * @param name Name of the shortcut to unregister\n\t */\n\tunregister(name: string): void {\n\t\tconst config = this.shortcuts.get(name);\n\t\tif (!config) {\n\t\t\tlogger.warn(`Shortcut not found: ${name}`);\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\tglobalShortcut.unregister(config.accelerator);\n\t\t\tthis.registeredAccelerators.delete(config.accelerator);\n\t\t\tthis.shortcuts.delete(name);\n\t\t\tlogger.info(`Global shortcut unregistered: ${name} (${config.accelerator})`);\n\t\t} catch (error) {\n\t\t\tlogger.error(\n\t\t\t\t`Error unregistering global shortcut ${name}: ${error instanceof Error ? error.message : String(error)}`,\n\t\t\t);\n\t\t}\n\t}\n\n\t/**\n\t * Unregister all shortcuts\n\t */\n\tunregisterAll(): void {\n\t\ttry {\n\t\t\tglobalShortcut.unregisterAll();\n\t\t\tthis.registeredAccelerators.clear();\n\t\t\tthis.shortcuts.clear();\n\t\t\tlogger.info(\"All global shortcuts unregistered\");\n\t\t} catch (error) {\n\t\t\tlogger.error(\n\t\t\t\t`Error unregistering all shortcuts: ${error instanceof Error ? error.message : String(error)}`,\n\t\t\t);\n\t\t}\n\t}\n\n\t/**\n\t * Check if a specific accelerator is registered\n\t * @param accelerator Keyboard accelerator to check\n\t * @returns true if registered, false otherwise\n\t */\n\tisRegistered(accelerator: string): boolean {\n\t\treturn globalShortcut.isRegistered(accelerator);\n\t}\n\n\t/**\n\t * Get all registered shortcuts\n\t * @returns Map of shortcut name to configuration\n\t */\n\tgetShortcuts(): Map<string, ShortcutConfig> {\n\t\treturn new Map(this.shortcuts);\n\t}\n\n\t/**\n\t * Update a shortcut's accelerator (future feature)\n\t * Useful for user-customizable shortcuts\n\t * @param name Name of the shortcut\n\t * @param newAccelerator New keyboard accelerator\n\t * @returns true if updated successfully, false otherwise\n\t */\n\tupdateShortcut(name: string, newAccelerator: string): boolean {\n\t\tconst config = this.shortcuts.get(name);\n\t\tif (!config) {\n\t\t\tlogger.warn(`Shortcut not found: ${name}`);\n\t\t\treturn false;\n\t\t}\n\n\t\t// Unregister old shortcut\n\t\ttry {\n\t\t\tglobalShortcut.unregister(config.accelerator);\n\t\t\tthis.registeredAccelerators.delete(config.accelerator);\n\t\t} catch (error) {\n\t\t\tlogger.error(\n\t\t\t\t`Error unregistering old shortcut: ${error instanceof Error ? error.message : String(error)}`,\n\t\t\t);\n\t\t}\n\n\t\t// Register with new accelerator\n\t\tconst registered = this.register(\n\t\t\tname,\n\t\t\tnewAccelerator,\n\t\t\tconfig.description,\n\t\t\tconfig.handler,\n\t\t);\n\n\t\tif (registered) {\n\t\t\tlogger.info(`Shortcut ${name} updated to ${newAccelerator}`);\n\t\t} else {\n\t\t\t// Rollback: re-register with old accelerator\n\t\t\tlogger.warn(`Failed to update shortcut ${name}, rolling back to ${config.accelerator}`);\n\t\t\tthis.register(name, config.accelerator, config.description, config.handler);\n\t\t}\n\n\t\treturn registered;\n\t}\n\n\t/**\n\t * Setup cleanup handlers to unregister shortcuts on app quit\n\t */\n\tprivate setupCleanup(): void {\n\t\t// Unregister all shortcuts before app quits\n\t\tapp.on(\"will-quit\", () => {\n\t\t\tlogger.info(\"App quitting, cleaning up global shortcuts...\");\n\t\t\tthis.unregisterAll();\n\t\t});\n\n\t\t// Also clean up on process termination signals\n\t\tconst cleanup = () => {\n\t\t\tthis.unregisterAll();\n\t\t};\n\n\t\tprocess.on(\"SIGINT\", cleanup);\n\t\tprocess.on(\"SIGTERM\", cleanup);\n\t}\n\n\t/**\n\t * Future: Load custom shortcuts from user preferences\n\t * This would read from a config file or electron-store\n\t */\n\t// loadCustomShortcuts(): void {\n\t//   // TODO: Implement loading from persistent storage\n\t//   logger.info(\"Loading custom shortcuts from preferences...\");\n\t// }\n\n\t/**\n\t * Future: Save custom shortcuts to user preferences\n\t * This would write to a config file or electron-store\n\t */\n\t// saveCustomShortcuts(): void {\n\t//   // TODO: Implement saving to persistent storage\n\t//   logger.info(\"Saving custom shortcuts to preferences...\");\n\t// }\n}\n"
  },
  {
    "path": "free-todo-frontend/electron/ipc-handlers-todo-capture.ts",
    "content": "/**\n * 待办提取相关的 IPC 处理器\n * 从 ipc-handlers.ts 中提取，以保持文件大小在限制内\n */\n\nimport { desktopCapturer, ipcMain, net, screen } from \"electron\";\nimport { Jimp } from \"jimp\";\nimport { logger } from \"./logger\";\nimport type { WindowManager } from \"./window-manager\";\n\ntype JimpScanContext = {\n\tbitmap: {\n\t\tdata: Buffer;\n\t};\n};\n\n/**\n * 发送截图到后端进行待办提取\n */\nasync function sendToBackend(\n\tapiUrl: string,\n\timageBase64: string,\n\tcreateTodos: boolean = true,\n): Promise<{\n\tsuccess: boolean;\n\tmessage: string;\n\textractedTodos: Array<{\n\t\ttitle: string;\n\t\tdescription?: string;\n\t\ttime_info?: Record<string, unknown>;\n\t\tsource_text?: string;\n\t\tconfidence: number;\n\t}>;\n\tcreatedCount: number;\n}> {\n\treturn new Promise((resolve, reject) => {\n\t\tconst postData = JSON.stringify({\n\t\t\timage_base64: imageBase64,\n\t\t\tcreate_todos: createTodos, // 自动创建 draft 状态的待办\n\t\t});\n\n\t\tconst request = net.request({\n\t\t\tmethod: \"POST\",\n\t\t\turl: apiUrl,\n\t\t});\n\n\t\trequest.setHeader(\"Content-Type\", \"application/json\");\n\n\t\tlet responseData = \"\";\n\n\t\trequest.on(\"response\", (response) => {\n\t\t\tresponse.on(\"data\", (chunk) => {\n\t\t\t\tresponseData += chunk.toString();\n\t\t\t});\n\n\t\t\tresponse.on(\"end\", () => {\n\t\t\t\ttry {\n\t\t\t\t\tconst result = JSON.parse(responseData);\n\t\t\t\t\tresolve({\n\t\t\t\t\t\tsuccess: result.success ?? false,\n\t\t\t\t\t\tmessage: result.message ?? \"未知响应\",\n\t\t\t\t\t\textractedTodos:\n\t\t\t\t\t\t\tresult.extracted_todos?.map(\n\t\t\t\t\t\t\t\t(todo: {\n\t\t\t\t\t\t\t\t\ttitle: string;\n\t\t\t\t\t\t\t\t\tdescription?: string;\n\t\t\t\t\t\t\t\t\ttime_info?: Record<string, unknown>;\n\t\t\t\t\t\t\t\t\tsource_text?: string;\n\t\t\t\t\t\t\t\t\tconfidence: number;\n\t\t\t\t\t\t\t\t}) => ({\n\t\t\t\t\t\t\t\t\ttitle: todo.title,\n\t\t\t\t\t\t\t\t\tdescription: todo.description,\n\t\t\t\t\t\t\t\t\ttime_info: todo.time_info,\n\t\t\t\t\t\t\t\t\tsource_text: todo.source_text,\n\t\t\t\t\t\t\t\t\tconfidence: todo.confidence ?? 0.5,\n\t\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t\t) ?? [],\n\t\t\t\t\t\tcreatedCount: result.created_count ?? 0,\n\t\t\t\t\t});\n\t\t\t\t} catch (error) {\n\t\t\t\t\treject(new Error(`解析响应失败: ${error}`));\n\t\t\t\t}\n\t\t\t});\n\n\t\t\tresponse.on(\"error\", (error) => {\n\t\t\t\treject(error);\n\t\t\t});\n\t\t});\n\n\t\trequest.on(\"error\", (error) => {\n\t\t\treject(error);\n\t\t});\n\n\t\trequest.write(postData);\n\t\trequest.end();\n\t});\n}\n\n/**\n * 在截图上绘制遮罩，遮住面板区域\n * @param imageBuffer 图片的 Buffer\n * @param windowBounds 窗口位置和尺寸（屏幕坐标）\n * @param screenBounds 屏幕位置和尺寸\n * @returns 处理后的图片 Buffer\n */\nasync function maskWindowArea(\n\timageBuffer: Buffer,\n\twindowBounds: { x: number; y: number; width: number; height: number },\n\tscreenBounds: { x: number; y: number; width: number; height: number },\n): Promise<Buffer> {\n\ttry {\n\t\t// 使用 Jimp 加载图片\n\t\tconst image = await Jimp.read(imageBuffer);\n\t\tconst imageWidth = image.width;\n\t\tconst imageHeight = image.height;\n\n\t\t// 计算窗口相对于屏幕的位置\n\t\t// 截图是屏幕的截图，窗口位置是相对于主显示器的\n\t\tconst relativeX = windowBounds.x - screenBounds.x;\n\t\tconst relativeY = windowBounds.y - screenBounds.y;\n\n\t\t// 计算缩放比例（截图可能被缩放了）\n\t\tconst scaleX = imageWidth / screenBounds.width;\n\t\tconst scaleY = imageHeight / screenBounds.height;\n\n\t\t// 将窗口坐标缩放以匹配截图尺寸\n\t\tconst scaledX = relativeX * scaleX;\n\t\tconst scaledY = relativeY * scaleY;\n\t\tconst scaledWidth = windowBounds.width * scaleX;\n\t\tconst scaledHeight = windowBounds.height * scaleY;\n\n\t\t// 确保遮罩区域在图片范围内\n\t\tconst maskX = Math.max(0, Math.min(Math.round(scaledX), imageWidth));\n\t\tconst maskY = Math.max(0, Math.min(Math.round(scaledY), imageHeight));\n\t\tconst maskWidth = Math.min(\n\t\t\tMath.round(scaledWidth),\n\t\t\timageWidth - maskX,\n\t\t);\n\t\tconst maskHeight = Math.min(\n\t\t\tMath.round(scaledHeight),\n\t\t\timageHeight - maskY,\n\t\t);\n\n\t\t// 如果窗口不在截图范围内，直接返回原图\n\t\tif (maskWidth <= 0 || maskHeight <= 0) {\n\t\t\tlogger.warn(\n\t\t\t\t\"Window is outside screenshot bounds, skipping mask\",\n\t\t\t);\n\t\t\treturn imageBuffer;\n\t\t}\n\n\t\t// 创建遮罩：使用半透明黑色矩形（90% 不透明度）\n\t\t// 使用 Jimp 的 scan 方法直接操作像素\n\t\timage.scan(\n\t\t\tmaskX,\n\t\t\tmaskY,\n\t\t\tmaskWidth,\n\t\t\tmaskHeight,\n\t\t\tfunction (this: JimpScanContext, _x: number, _y: number, idx: number) {\n\t\t\t\t// 获取当前像素的颜色（RGBA 格式）\n\t\t\t\t// Jimp 的 bitmap.data 是 RGBA 格式：R, G, B, A\n\t\t\t\tconst r = this.bitmap.data[idx] || 0;\n\t\t\t\tconst g = this.bitmap.data[idx + 1] || 0;\n\t\t\t\tconst b = this.bitmap.data[idx + 2] || 0;\n\t\t\t\tconst a = this.bitmap.data[idx + 3] || 255;\n\n\t\t\t\t// 混合黑色遮罩（90% 不透明度）\n\t\t\t\t// 使用简单的 alpha 混合：result = source * (1 - alpha) + mask * alpha\n\t\t\t\tconst alpha = 0.9;\n\t\t\t\tconst newR = Math.round(r * (1 - alpha) + 0 * alpha);\n\t\t\t\tconst newG = Math.round(g * (1 - alpha) + 0 * alpha);\n\t\t\t\tconst newB = Math.round(b * (1 - alpha) + 0 * alpha);\n\n\t\t\t\t// 设置新颜色（保持原始 alpha 通道）\n\t\t\t\tthis.bitmap.data[idx] = newR;\n\t\t\t\tthis.bitmap.data[idx + 1] = newG;\n\t\t\t\tthis.bitmap.data[idx + 2] = newB;\n\t\t\t\t// alpha 通道保持不变\n\t\t\t\tthis.bitmap.data[idx + 3] = a;\n\t\t\t},\n\t\t);\n\n\t\t// 返回处理后的图片 Buffer\n\t\treturn await image.getBuffer(\"image/png\");\n\t} catch (error) {\n\t\tlogger.error(\n\t\t\t`Failed to mask window area: ${error instanceof Error ? error.message : String(error)}`,\n\t\t);\n\t\t// 如果遮罩失败，返回原图\n\t\treturn imageBuffer;\n\t}\n}\n\n/**\n * 设置待办提取相关的 IPC 处理器\n */\nexport function setupTodoCaptureIpcHandlers(\n\twindowManager: WindowManager,\n): void {\n\t// 截图并提取待办\n\tipcMain.handle(\n\t\t\"capture-and-extract-todos\",\n\t\tasync (\n\t\t\t_event,\n\t\t\tpanelBounds?: { x: number; y: number; width: number; height: number } | null,\n\t\t): Promise<{\n\t\t\tsuccess: boolean;\n\t\t\tmessage: string;\n\t\t\textractedTodos: Array<{\n\t\t\t\ttitle: string;\n\t\t\t\tdescription?: string;\n\t\t\t\ttime_info?: Record<string, unknown>;\n\t\t\t\tsource_text?: string;\n\t\t\t\tconfidence: number;\n\t\t\t}>;\n\t\t\tcreatedCount: number;\n\t\t}> => {\n\t\t\ttry {\n\t\t\t\tlogger.info(\"Capturing screen for todo extraction...\");\n\n\t\t\t\t// 不再隐藏窗口，直接截图\n\t\t\t\tconst mainWin = windowManager.getWindow();\n\t\t\t\tif (!mainWin) {\n\t\t\t\t\tthrow new Error(\"主窗口不存在\");\n\t\t\t\t}\n\n\t\t\t\t// 获取窗口位置和尺寸（屏幕坐标）\n\t\t\t\tconst windowBounds = mainWin.getBounds();\n\n\t\t\t\t// 获取主显示器的信息\n\t\t\t\tconst primaryDisplay = screen.getPrimaryDisplay();\n\t\t\t\tconst screenBounds = primaryDisplay.bounds;\n\t\t\t\tconst displaySize = primaryDisplay.size;\n\n\t\t\t\t// 计算 panel 在屏幕上的绝对位置\n\t\t\t\t// panelBounds 是相对于视口的位置，需要转换为屏幕坐标\n\t\t\t\tlet targetBounds: { x: number; y: number; width: number; height: number } | null = null;\n\t\t\t\tif (panelBounds) {\n\t\t\t\t\t// panelBounds 已经是相对于视口的位置（通过 getBoundingClientRect 获取）\n\t\t\t\t\t// 需要加上窗口在屏幕上的位置，转换为屏幕坐标\n\t\t\t\t\ttargetBounds = {\n\t\t\t\t\t\tx: windowBounds.x + panelBounds.x,\n\t\t\t\t\t\ty: windowBounds.y + panelBounds.y,\n\t\t\t\t\t\twidth: panelBounds.width,\n\t\t\t\t\t\theight: panelBounds.height,\n\t\t\t\t\t};\n\t\t\t\t}\n\n\t\t\t\t// 获取所有屏幕源（使用实际屏幕尺寸）\n\t\t\t\tconst sources = await desktopCapturer.getSources({\n\t\t\t\t\ttypes: [\"screen\"],\n\t\t\t\t\tthumbnailSize: {\n\t\t\t\t\t\twidth: displaySize.width,\n\t\t\t\t\t\theight: displaySize.height,\n\t\t\t\t\t},\n\t\t\t\t});\n\n\t\t\t\tif (sources.length === 0) {\n\t\t\t\t\tlogger.error(\"No screen sources found\");\n\t\t\t\t\treturn {\n\t\t\t\t\t\tsuccess: false,\n\t\t\t\t\t\tmessage: \"未找到屏幕源\",\n\t\t\t\t\t\textractedTodos: [],\n\t\t\t\t\t\tcreatedCount: 0,\n\t\t\t\t\t};\n\t\t\t\t}\n\n\t\t\t\t// 使用主屏幕的截图\n\t\t\t\tconst primarySource = sources[0];\n\t\t\t\tconst thumbnail = primarySource.thumbnail;\n\n\t\t\t\t// 将 nativeImage 转换为 Buffer\n\t\t\t\tconst pngBuffer = thumbnail.toPNG();\n\n\t\t\t\t// 在截图上绘制遮罩，遮住面板区域（只遮罩 panel，不是整个窗口）\n\t\t\t\t// 如果 targetBounds 为 null，不遮罩（直接使用原图）\n\t\t\t\tconst maskedBuffer = targetBounds\n\t\t\t\t\t? await maskWindowArea(\n\t\t\t\t\t\t\tpngBuffer,\n\t\t\t\t\t\t\ttargetBounds,\n\t\t\t\t\t\t\tscreenBounds,\n\t\t\t\t\t\t)\n\t\t\t\t\t: pngBuffer;\n\n\t\t\t\t// 将处理后的图片转换为 base64\n\t\t\t\tconst base64Data = maskedBuffer.toString(\"base64\");\n\n\t\t\t\t// 获取后端 URL（从 next-server 模块）\n\t\t\t\tconst nextServerModule = await import(\"./next-server\");\n\t\t\t\tconst backendUrl = nextServerModule.getBackendUrl();\n\t\t\t\tif (!backendUrl) {\n\t\t\t\t\tthrow new Error(\"后端 URL 未设置，请等待后端服务器启动\");\n\t\t\t\t}\n\t\t\t\tconst apiUrl = `${backendUrl}/api/floating-capture/extract-todos`;\n\n\t\t\t\t// 发送到后端（自动创建 draft 状态的待办）\n\t\t\t\tconst response = await sendToBackend(apiUrl, base64Data, true);\n\n\t\t\t\tlogger.info(\n\t\t\t\t\t`Todo extraction completed: ${response.extractedTodos.length} todos extracted`,\n\t\t\t\t);\n\n\t\t\t\treturn response;\n\t\t\t} catch (error) {\n\t\t\t\tconst errorMsg = `Failed to capture and extract todos: ${error instanceof Error ? error.message : String(error)}`;\n\t\t\t\tlogger.error(errorMsg);\n\t\t\t\treturn {\n\t\t\t\t\tsuccess: false,\n\t\t\t\t\tmessage: errorMsg,\n\t\t\t\t\textractedTodos: [],\n\t\t\t\t\tcreatedCount: 0,\n\t\t\t\t};\n\t\t\t}\n\t\t},\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/electron/ipc-handlers.ts",
    "content": "/**\n * IPC 通信处理器\n * 集中管理所有主进程与渲染进程之间的 IPC 通信\n */\n\nimport { app, BrowserWindow, ipcMain, screen } from \"electron\";\nimport { setupTodoCaptureIpcHandlers } from \"./ipc-handlers-todo-capture\";\nimport type { IslandWindowManager } from \"./island-window-manager\";\nimport { logger } from \"./logger\";\nimport {\n\ttype NotificationData,\n\tshowSystemNotification,\n} from \"./notification\";\nimport type { WindowManager } from \"./window-manager\";\n\n/**\n * 设置所有 IPC 处理器\n * @param windowManager 窗口管理器实例\n * @param islandWindowManager Island 窗口管理器实例（可选）\n */\nexport function setupIpcHandlers(\n\twindowManager: WindowManager,\n\tislandWindowManager?: IslandWindowManager,\n): void {\n\t// 处理来自渲染进程的通知请求\n\tipcMain.handle(\n\t\t\"show-notification\",\n\t\tasync (_event, data: NotificationData) => {\n\t\t\ttry {\n\t\t\t\tlogger.info(`Received notification request: ${data.id} - ${data.title}`);\n\t\t\t\tshowSystemNotification(data, windowManager);\n\t\t\t} catch (error) {\n\t\t\t\tconst errorMsg = `Failed to handle notification request: ${error instanceof Error ? error.message : String(error)}`;\n\t\t\t\tlogger.error(errorMsg);\n\t\t\t\tthrow error;\n\t\t\t}\n\t\t},\n\t);\n\n\t// ========== 窗口管理 IPC 处理器 ==========\n\n\t// 设置窗口是否忽略鼠标事件（用于透明窗口点击穿透）\n\tipcMain.on(\n\t\t\"set-ignore-mouse-events\",\n\t\t(event, ignore: boolean, options?: { forward?: boolean }) => {\n\t\t\tconst win = BrowserWindow.fromWebContents(event.sender);\n\t\t\tif (win) {\n\t\t\t\twin.setIgnoreMouseEvents(ignore, options || {});\n\t\t\t}\n\t\t},\n\t);\n\n\t// 移动窗口到指定位置（用于拖拽）\n\tipcMain.on(\"move-window\", (event, x: number, y: number) => {\n\t\tconst win = BrowserWindow.fromWebContents(event.sender);\n\t\tif (win) {\n\t\t\twin.setPosition(Math.round(x), Math.round(y));\n\t\t}\n\t});\n\n\t// 获取窗口当前位置\n\tipcMain.handle(\"get-window-position\", () => {\n\t\tconst win = windowManager.getWindow();\n\t\tif (win) {\n\t\t\tconst [x, y] = win.getPosition();\n\t\t\treturn { x, y };\n\t\t}\n\t\treturn { x: 0, y: 0 };\n\t});\n\n\t// 获取屏幕信息\n\tipcMain.handle(\"get-screen-info\", () => {\n\t\tconst { width, height } = screen.getPrimaryDisplay().workAreaSize;\n\t\treturn { screenWidth: width, screenHeight: height };\n\t});\n\n\t// 退出应用\n\tipcMain.on(\"app-quit\", () => {\n\t\tapp.quit();\n\t});\n\n\t// 透明背景就绪通知\n\tipcMain.on(\"transparent-background-ready\", () => {\n\t\tconst win = windowManager.getWindow();\n\t\tif (win) {\n\t\t\twin.setBackgroundColor(\"#00000000\");\n\t\t}\n\t});\n\n\t// 设置窗口背景色\n\tipcMain.on(\"set-window-background-color\", (event, color: string) => {\n\t\tconst win = BrowserWindow.fromWebContents(event.sender);\n\t\tif (win) {\n\t\t\twin.setBackgroundColor(color);\n\t\t\tlogger.info(`Window background color set to: ${color}`);\n\t\t}\n\t});\n\n\t// ========== 待办提取相关 IPC 处理器 ==========\n\t// 已提取到 ipc-handlers-todo-capture.ts 以保持文件大小\n\tsetupTodoCaptureIpcHandlers(windowManager);\n\n\t// ========== Island 动态岛相关 IPC 处理器 ==========\n\tif (islandWindowManager) {\n\t\tsetupIslandIpcHandlers(islandWindowManager);\n\t}\n}\n\n/**\n * 设置 Island 相关的 IPC 处理器\n * @param islandWindowManager Island 窗口管理器实例\n */\nfunction setupIslandIpcHandlers(islandWindowManager: IslandWindowManager): void {\n\t// 显示 Island 窗口\n\tipcMain.on(\"island:show\", () => {\n\t\tislandWindowManager.show();\n\t\tlogger.info(\"Island window shown via IPC\");\n\t});\n\n\t// 隐藏 Island 窗口\n\tipcMain.on(\"island:hide\", () => {\n\t\tislandWindowManager.hide();\n\t\tlogger.info(\"Island window hidden via IPC\");\n\t});\n\n\t// 切换 Island 窗口显示/隐藏\n\tipcMain.on(\"island:toggle\", () => {\n\t\tislandWindowManager.toggle();\n\t\tlogger.info(\"Island window toggled via IPC\");\n\t});\n\n\t// 调整 Island 窗口大小（切换模式）\n\t// 注意：island:resize-window 在 island-window-manager.ts 中已处理\n}\n"
  },
  {
    "path": "free-todo-frontend/electron/island-window-manager.ts",
    "content": "/**\n * Island 窗口管理器\n * 负责创建和管理 Dynamic Island 悬浮窗口\n */\n\nimport path from \"node:path\";\nimport { app, BrowserWindow, ipcMain, screen } from \"electron\";\nimport { logger } from \"./logger\";\n\n/**\n * Island 模式枚举（与前端保持一致）\n */\nexport enum IslandMode {\n  FLOAT = \"FLOAT\",\n  POPUP = \"POPUP\",\n  SIDEBAR = \"SIDEBAR\",\n  FULLSCREEN = \"FULLSCREEN\",\n}\n\n/**\n * 各模式对应的窗口尺寸\n */\nconst ISLAND_SIZES: Record<IslandMode, { width: number; height: number }> = {\n  [IslandMode.FLOAT]: { width: 200, height: 56 },\n  [IslandMode.POPUP]: { width: 380, height: 120 },\n  [IslandMode.SIDEBAR]: { width: 420, height: 700 },\n  [IslandMode.FULLSCREEN]: { width: 0, height: 0 }, // 动态计算\n};\n\n/**\n * Island 窗口管理器类\n */\nexport class IslandWindowManager {\n  /** Island 窗口实例 */\n  private islandWindow: BrowserWindow | null = null;\n  /** 当前模式 */\n  private currentMode: IslandMode = IslandMode.FLOAT;\n  /** 是否启用 Island */\n  private enabled: boolean = false;\n  /** 窗口位置配置 */\n  private readonly marginRight: number = 20;\n  private readonly marginTop: number = 20;\n  /** 当前 Y 位置（用于垂直拖动时保持位置） */\n  private currentY: number = 20;\n  /** SIDEBAR 模式的固定状态（默认为 true）*/\n  private sidebarPinned: boolean = true;\n  /** 可见性变化回调 */\n  private onVisibilityChange?: (visible: boolean) => void;\n\n  /**\n   * 获取 preload 脚本路径\n   */\n  private getPreloadPath(): string {\n    if (app.isPackaged) {\n      return path.join(app.getAppPath(), \"dist-electron\", \"preload.js\");\n    }\n    return path.join(__dirname, \"preload.js\");\n  }\n\n  /**\n   * 计算窗口 X 位置（右边缘对齐）\n   * 所有非全屏模式共享相同的 X 位置，以便平滑过渡\n   */\n  private calculateRightAlignedX(width: number): number {\n    const { width: screenWidth } = screen.getPrimaryDisplay().workAreaSize;\n    return screenWidth - width - this.marginRight;\n  }\n\n  /**\n   * 计算窗口 Y 位置\n   * 如果 preferredY 未提供，则使用保存的位置；否则约束在屏幕边界内\n   */\n  private calculateYPosition(height: number, preferredY?: number): number {\n    const { height: screenHeight } = screen.getPrimaryDisplay().workAreaSize;\n\n    if (preferredY !== undefined) {\n      // 约束在屏幕边界内\n      const minY = this.marginTop;\n      const maxY = screenHeight - height - this.marginTop;\n      return Math.max(minY, Math.min(preferredY, maxY));\n    }\n\n    // 使用已保存的位置\n    return this.currentY;\n  }\n\n  /**\n   * 智能计算 SIDEBAR 的 Y 位置\n   * 根据当前窗口位置和可用空间，选择最佳的锚点（顶部或底部对齐）\n   * @param sidebarHeight SIDEBAR 窗口的高度\n   * @returns 计算出的 Y 位置和使用的锚点类型\n   */\n  private calculateSmartSidebarPosition(\n    sidebarHeight: number\n  ): { y: number; anchor: 'top' | 'bottom' } {\n    const { height: screenHeight } = screen.getPrimaryDisplay().workAreaSize;\n    const currentWindowY = this.currentY;\n    const currentWindowHeight = this.islandWindow?.getBounds().height || 56;\n\n    // 计算当前窗口的底部位置\n    const currentWindowBottom = currentWindowY + currentWindowHeight;\n\n    // 计算如果使用底部对齐，SIDEBAR 的顶部位置\n    const bottomAlignedY = currentWindowBottom - sidebarHeight;\n\n    // 计算如果使用顶部对齐，SIDEBAR 的顶部位置\n    const topAlignedY = currentWindowY;\n\n    // 检查底部对齐是否可行（SIDEBAR 完全在屏幕内）\n    const canBottomAlign = bottomAlignedY >= this.marginTop;\n\n    // 检查顶部对齐是否可行\n    const canTopAlign = (topAlignedY + sidebarHeight) <= (screenHeight - this.marginTop);\n\n    // 决策逻辑：\n    // 1. 如果当前窗口在屏幕上半部分，优先使用顶部对齐\n    // 2. 如果当前窗口在屏幕下半部分，优先使用底部对齐\n    // 3. 如果首选方案不可行，尝试另一种\n    // 4. 如果两种都不可行，居中显示并调整位置\n\n    const isInUpperHalf = currentWindowY < screenHeight / 2;\n\n    if (isInUpperHalf) {\n      // 上半部分：优先顶部对齐\n      if (canTopAlign) {\n        return { y: topAlignedY, anchor: 'top' };\n      } else if (canBottomAlign) {\n        return { y: bottomAlignedY, anchor: 'bottom' };\n      }\n    } else {\n      // 下半部分：优先底部对齐\n      if (canBottomAlign) {\n        return { y: bottomAlignedY, anchor: 'bottom' };\n      } else if (canTopAlign) {\n        return { y: topAlignedY, anchor: 'top' };\n      }\n    }\n\n    // 如果两种对齐都不可行，计算一个安全的居中位置\n    const safeY = Math.max(\n      this.marginTop,\n      Math.min(\n        screenHeight - sidebarHeight - this.marginTop,\n        currentWindowY\n      )\n    );\n\n    logger.warn(`SIDEBAR doesn't fit with current anchor, adjusted to Y=${safeY}`);\n    return { y: safeY, anchor: isInUpperHalf ? 'top' : 'bottom' };\n  }\n\n  /**\n   * 获取指定模式的窗口尺寸\n   */\n  private getSizeForMode(mode: IslandMode): { width: number; height: number } {\n    if (mode === IslandMode.FULLSCREEN) {\n      return screen.getPrimaryDisplay().workAreaSize;\n    }\n    return ISLAND_SIZES[mode];\n  }\n\n  /**\n   * 创建 Island 窗口\n   * @param serverUrl 前端服务器 URL\n   */\n  create(serverUrl: string): void {\n    if (this.islandWindow) {\n      logger.warn(\"Island window already exists\");\n      return;\n    }\n\n    const preloadPath = this.getPreloadPath();\n    const { width, height } = this.getSizeForMode(this.currentMode);\n    const x = this.calculateRightAlignedX(width);\n    const y = this.calculateYPosition(height);\n    this.currentY = y; // 初始化 Y 位置\n\n    this.islandWindow = new BrowserWindow({\n      width,\n      height,\n      x,\n      y,\n      frame: false,\n      transparent: true,\n      alwaysOnTop: true,\n      skipTaskbar: true,\n      resizable: false,\n      movable: false, // 禁用原生拖动，使用自定义拖动\n      hasShadow: false, // 禁用系统阴影以避免透明窗口出现黑边，使用 CSS box-shadow 代替\n      focusable: true,\n      webPreferences: {\n        nodeIntegration: false,\n        contextIsolation: true,\n        preload: preloadPath,\n      },\n      show: false,\n      backgroundColor: \"#00000000\",\n    });\n\n    // 设置窗口级别，使其始终在最上层（包括全屏应用之上）\n    this.islandWindow.setAlwaysOnTop(true, \"floating\");\n\n    // macOS 特定：设置窗口在所有工作区可见\n    if (process.platform === \"darwin\") {\n      this.islandWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });\n    }\n\n    // 加载 Island 页面\n    const islandUrl = `${serverUrl}/island`;\n    this.islandWindow.loadURL(islandUrl);\n\n    // 窗口准备好后显示\n    this.islandWindow.once(\"ready-to-show\", () => {\n      this.islandWindow?.show();\n      logger.info(\"Island window ready and shown\");\n    });\n\n    // 窗口关闭时清理引用\n    this.islandWindow.on(\"closed\", () => {\n      this.islandWindow = null;\n      logger.info(\"Island window closed\");\n      // Island 是主窗口，关闭时退出应用（macOS 除外）\n      if (process.platform !== \"darwin\") {\n        app.quit();\n      }\n    });\n\n    // 设置 IPC 处理器\n    this.setupIpcHandlers();\n\n    // 设置自定义拖拽处理器\n    this.setupCustomDragHandlers();\n\n    this.enabled = true;\n    logger.info(`Island window created at ${islandUrl}`);\n  }\n\n  /**\n   * 设置 Island 专用的 IPC 处理器\n   */\n  private setupIpcHandlers(): void {\n    // 处理窗口大小调整请求\n    ipcMain.on(\"island:resize-window\", (_event, mode: string) => {\n      this.resizeToMode(mode as IslandMode);\n    });\n\n    // 处理 SIDEBAR 模式多栏展开/收起请求\n    ipcMain.on(\"island:resize-sidebar\", (_event, columnCount: number) => {\n      this.resizeSidebarToColumns(columnCount as 1 | 2 | 3);\n    });\n\n    // 处理 SIDEBAR 模式固定状态变化\n    ipcMain.on(\"island:set-pinned\", (event, isPinned: boolean) => {\n      // 只处理来自 Island 窗口的请求\n      if (this.islandWindow && event.sender === this.islandWindow.webContents) {\n        this.setSidebarPinned(isPinned);\n      }\n    });\n\n    // 兼容旧的 resize-window 通道（来自原始 Island 代码）\n    ipcMain.on(\"resize-window\", (event, mode: string) => {\n      // 只处理来自 Island 窗口的请求\n      if (this.islandWindow && event.sender === this.islandWindow.webContents) {\n        this.resizeToMode(mode as IslandMode);\n      }\n    });\n  }\n\n  /**\n   * 设置自定义拖拽处理器（仅允许垂直拖动）\n   */\n  private setupCustomDragHandlers(): void {\n    // 存储拖拽起始位置\n    let dragStartY = 0;\n    let windowStartY = 0;\n\n    // 处理拖拽开始\n    ipcMain.on(\"island:drag-start\", (event, mouseY: number) => {\n      // 只处理来自 Island 窗口的请求\n      if (!this.islandWindow || event.sender !== this.islandWindow.webContents) return;\n\n      // 全屏模式不允许拖拽\n      if (this.currentMode === IslandMode.FULLSCREEN) return;\n\n      const [, currentY] = this.islandWindow.getPosition();\n      dragStartY = mouseY;\n      windowStartY = currentY;\n    });\n\n    // 处理拖拽移动\n    ipcMain.on(\"island:drag-move\", (event, mouseY: number) => {\n      // 只处理来自 Island 窗口的请求\n      if (!this.islandWindow || event.sender !== this.islandWindow.webContents) return;\n\n      // 全屏模式不允许拖拽\n      if (this.currentMode === IslandMode.FULLSCREEN) return;\n\n      const { width, height } = this.islandWindow.getBounds();\n\n      // 计算新的 Y 位置（仅垂直移动）\n      const deltaY = mouseY - dragStartY;\n      const newY = windowStartY + deltaY;\n\n      // 锁定 X 位置到右边缘\n      const x = this.calculateRightAlignedX(width);\n\n      // 约束 Y 在屏幕边界内\n      const y = this.calculateYPosition(height, newY);\n\n      // 更新窗口位置\n      this.islandWindow.setPosition(x, y);\n\n      // 发送位置更新到渲染进程\n      const { height: screenHeight } = screen.getPrimaryDisplay().workAreaSize;\n      this.islandWindow.webContents.send('island:position-update', {\n        y: y,\n        screenHeight: screenHeight\n      });\n    });\n\n    // 处理拖拽结束\n    ipcMain.on(\"island:drag-end\", (event) => {\n      // 只处理来自 Island 窗口的请求\n      if (!this.islandWindow || event.sender !== this.islandWindow.webContents) return;\n\n      // 保存最终的 Y 位置\n      const [, currentY] = this.islandWindow.getPosition();\n      this.currentY = currentY;\n\n      // 发送最终位置更新到渲染进程\n      const { height: screenHeight } = screen.getPrimaryDisplay().workAreaSize;\n      this.islandWindow.webContents.send('island:position-update', {\n        y: currentY,\n        screenHeight: screenHeight\n      });\n    });\n  }\n\n  /**\n   * 调整窗口到指定模式\n   */\n  resizeToMode(mode: IslandMode): void {\n    if (!this.islandWindow) return;\n\n    const validModes = Object.values(IslandMode);\n    if (!validModes.includes(mode)) {\n      logger.warn(`Invalid Island mode: ${mode}`);\n      return;\n    }\n\n    this.currentMode = mode;\n    const { width, height } = this.getSizeForMode(mode);\n\n    // 形态3/4 使用正常窗口样式，形态1/2 使用透明悬浮窗样式\n    // SIDEBAR 模式下根据 pin 状态决定行为\n    const isExpandedMode = mode === IslandMode.SIDEBAR || mode === IslandMode.FULLSCREEN;\n    const shouldAlwaysOnTop = mode === IslandMode.SIDEBAR\n      ? this.sidebarPinned  // SIDEBAR: 根据 pin 状态\n      : !isExpandedMode;     // 其他模式: FLOAT/POPUP 为 true, FULLSCREEN 为 false\n\n    // 设置窗口属性\n    this.islandWindow.setAlwaysOnTop(shouldAlwaysOnTop, shouldAlwaysOnTop ? \"floating\" : \"normal\");\n    this.islandWindow.setSkipTaskbar(shouldAlwaysOnTop);\n\n    // macOS 特定：根据 pin 状态设置工作区可见性\n    if (process.platform === \"darwin\") {\n      this.islandWindow.setVisibleOnAllWorkspaces(shouldAlwaysOnTop, { visibleOnFullScreen: shouldAlwaysOnTop });\n    }\n\n    if (mode === IslandMode.FULLSCREEN) {\n      // 全屏模式：覆盖整个工作区\n      const { x: screenX, y: screenY } = screen.getPrimaryDisplay().workArea;\n      this.islandWindow.setBounds({ x: screenX, y: screenY, width, height });\n      logger.info(`Island window resized to mode: ${mode} (${width}x${height})`);\n\n      // 发送锚点更新到渲染进程（全屏无锚点）\n      this.islandWindow.webContents.send('island:anchor-update', {\n        anchor: null,\n        y: screenY\n      });\n    } else if (mode === IslandMode.SIDEBAR) {\n      // SIDEBAR 模式：使用智能定位算法\n      const x = this.calculateRightAlignedX(width);\n      const { y, anchor } = this.calculateSmartSidebarPosition(height);\n      this.currentY = y; // 保存位置\n      this.islandWindow.setBounds({ x, y, width, height });\n      logger.info(`Island window resized to mode: ${mode} (${width}x${height}) with ${anchor} anchor at Y=${y}`);\n\n      // 发送锚点更新到渲染进程\n      this.islandWindow.webContents.send('island:anchor-update', {\n        anchor: anchor,\n        y: y\n      });\n    } else {\n      // FLOAT/POPUP 模式：右边缘对齐，保持当前 Y 位置\n      const x = this.calculateRightAlignedX(width);\n      const y = this.calculateYPosition(height);\n      this.currentY = y; // 保存位置以供下次调整使用\n      this.islandWindow.setBounds({ x, y, width, height });\n      logger.info(`Island window resized to mode: ${mode} (${width}x${height})`);\n\n      // 发送锚点更新到渲染进程（FLOAT/POPUP 使用当前位置）\n      const { height: screenHeight } = screen.getPrimaryDisplay().workAreaSize;\n      const isInUpperHalf = y < screenHeight / 2;\n      this.islandWindow.webContents.send('island:anchor-update', {\n        anchor: isInUpperHalf ? 'top' : 'bottom',\n        y: y\n      });\n    }\n  }\n\n  /**\n   * 调整 SIDEBAR 窗口到指定栏数\n   * @param columnCount 栏数: 1 | 2 | 3\n   */\n  resizeSidebarToColumns(columnCount: 1 | 2 | 3): void {\n    if (!this.islandWindow) return;\n\n    // 验证栏数有效性\n    if (columnCount < 1 || columnCount > 3) {\n      logger.warn(`Invalid column count: ${columnCount}`);\n      return;\n    }\n\n    // 定义各栏数的宽度\n    const widthMap: Record<1 | 2 | 3, number> = {\n      1: 420,\n      2: 800,\n      3: 1200,\n    };\n\n    const width = widthMap[columnCount];\n    const height = 700;\n\n    // 右边缘对齐，使用智能定位算法（如果当前是 SIDEBAR 模式）\n    const x = this.calculateRightAlignedX(width);\n    let y: number;\n\n    if (this.currentMode === IslandMode.SIDEBAR) {\n      // SIDEBAR 模式：使用智能定位算法\n      const { y: smartY, anchor } = this.calculateSmartSidebarPosition(height);\n      y = smartY;\n      logger.info(`Island sidebar resized to ${columnCount} column(s): ${width}x${height} with ${anchor} anchor at Y=${y}`);\n    } else {\n      // 其他模式：保持当前 Y 位置\n      y = this.calculateYPosition(height);\n      logger.info(`Island sidebar resized to ${columnCount} column(s): ${width}x${height}`);\n    }\n\n    this.islandWindow.setBounds({ x, y, width, height });\n    this.currentY = y; // 保存位置\n  }\n\n  /**\n   * 显示 Island 窗口\n   */\n  show(): void {\n    if (this.islandWindow && !this.islandWindow.isVisible()) {\n      this.islandWindow.show();\n      this.notifyVisibilityChange(true);\n      logger.info(\"Island window shown\");\n    }\n  }\n\n  /**\n   * 隐藏 Island 窗口\n   */\n  hide(): void {\n    if (this.islandWindow?.isVisible()) {\n      this.islandWindow.hide();\n      this.notifyVisibilityChange(false);\n      logger.info(\"Island window hidden\");\n    }\n  }\n\n  /**\n   * 切换 Island 窗口显示/隐藏\n   */\n  toggle(): void {\n    if (this.islandWindow) {\n      if (this.islandWindow.isVisible()) {\n        this.hide();\n      } else {\n        this.show();\n      }\n    }\n  }\n\n  /**\n   * 销毁 Island 窗口\n   */\n  destroy(): void {\n    if (this.islandWindow) {\n      this.islandWindow.close();\n      this.islandWindow = null;\n    }\n    this.enabled = false;\n  }\n\n  /**\n   * 获取 Island 窗口实例\n   */\n  getWindow(): BrowserWindow | null {\n    return this.islandWindow;\n  }\n\n  /**\n   * 检查 Island 是否已启用\n   */\n  isEnabled(): boolean {\n    return this.enabled;\n  }\n\n  /**\n   * 检查窗口是否存在\n   */\n  hasWindow(): boolean {\n    return this.islandWindow !== null && !this.islandWindow.isDestroyed();\n  }\n\n  /**\n   * 获取当前模式\n   */\n  getCurrentMode(): IslandMode {\n    return this.currentMode;\n  }\n\n  /**\n   * 向 Island 窗口发送消息\n   */\n  sendMessage(channel: string, ...args: unknown[]): void {\n    if (this.islandWindow && !this.islandWindow.isDestroyed()) {\n      this.islandWindow.webContents.send(channel, ...args);\n    }\n  }\n\n  /**\n   * 设置可见性变化回调\n   * @param callback 回调函数，接收 visible 参数\n   */\n  setVisibilityChangeCallback(callback: (visible: boolean) => void): void {\n    this.onVisibilityChange = callback;\n  }\n\n  /**\n   * 通知可见性变化\n   * @param visible 当前可见性状态\n   */\n  private notifyVisibilityChange(visible: boolean): void {\n    if (this.onVisibilityChange) {\n      try {\n        this.onVisibilityChange(visible);\n      } catch (error) {\n        logger.error(\n          `Error in visibility change callback: ${error instanceof Error ? error.message : String(error)}`,\n        );\n      }\n    }\n  }\n\n  /**\n   * 检查窗口当前是否可见\n   */\n  isVisible(): boolean {\n    return this.islandWindow?.isVisible() ?? false;\n  }\n\n  /**\n   * 设置 SIDEBAR 模式的固定状态\n   * @param isPinned true = 固定（始终在顶部），false = 非固定（正常窗口行为）\n   */\n  setSidebarPinned(isPinned: boolean): void {\n    if (!this.islandWindow) return;\n\n    this.sidebarPinned = isPinned;\n\n    // 如果当前是 SIDEBAR 模式，立即更新窗口属性\n    if (this.currentMode === IslandMode.SIDEBAR) {\n      this.islandWindow.setAlwaysOnTop(isPinned, isPinned ? \"floating\" : \"normal\");\n      this.islandWindow.setSkipTaskbar(isPinned);\n\n      // macOS 特定：根据 pin 状态设置工作区可见性\n      if (process.platform === \"darwin\") {\n        this.islandWindow.setVisibleOnAllWorkspaces(isPinned, { visibleOnFullScreen: isPinned });\n      }\n\n      logger.info(`Island SIDEBAR pin state changed to: ${isPinned ? \"pinned\" : \"unpinned\"}`);\n    }\n  }\n}\n"
  },
  {
    "path": "free-todo-frontend/electron/logger.ts",
    "content": "/**\n * Electron 主进程日志服务\n * 封装日志逻辑，支持不同级别和来源标记\n * 每次启动生成新的日志文件，文件名格式：YYYY-MM-DD-N.log\n */\n\nimport fs from \"node:fs\";\nimport path from \"node:path\";\nimport { app } from \"electron\";\n\n/**\n * 日志级别枚举\n */\ntype LogLevel = \"INFO\" | \"WARN\" | \"ERROR\" | \"FATAL\";\n\n/**\n * 日志服务类\n * 提供统一的日志记录接口，支持文件写入和控制台输出\n */\nclass Logger {\n\tprivate logFile: string;\n\tprivate logDir: string;\n\n\tconstructor() {\n\t\tthis.logDir = app.getPath(\"logs\");\n\t\tthis.ensureLogDir();\n\t\tthis.logFile = this.generateLogFileName();\n\t\tthis.writeStartMarker();\n\t}\n\n\t/**\n\t * 确保日志目录存在\n\t */\n\tprivate ensureLogDir(): void {\n\t\ttry {\n\t\t\tif (!fs.existsSync(this.logDir)) {\n\t\t\t\tfs.mkdirSync(this.logDir, { recursive: true });\n\t\t\t}\n\t\t} catch {\n\t\t\t// 忽略目录创建错误\n\t\t}\n\t}\n\n\t/**\n\t * 获取当天的日期字符串（YYYY-MM-DD）\n\t */\n\tprivate getTodayDateString(): string {\n\t\tconst now = new Date();\n\t\tconst year = now.getFullYear();\n\t\tconst month = String(now.getMonth() + 1).padStart(2, \"0\");\n\t\tconst day = String(now.getDate()).padStart(2, \"0\");\n\t\treturn `${year}-${month}-${day}`;\n\t}\n\n\t/**\n\t * 生成带日期和序列号的日志文件名\n\t * 格式：YYYY-MM-DD-N.log（N 为当天第几次启动，从 0 开始）\n\t */\n\tprivate generateLogFileName(): string {\n\t\tconst dateStr = this.getTodayDateString();\n\t\tconst pattern = new RegExp(`^${dateStr}-(\\\\d+)\\\\.log$`);\n\n\t\t// 扫描现有日志文件，找出当天的最大序列号\n\t\tlet maxSeq = -1;\n\t\ttry {\n\t\t\tconst files = fs.readdirSync(this.logDir);\n\t\t\tfor (const file of files) {\n\t\t\t\tconst match = file.match(pattern);\n\t\t\t\tif (match) {\n\t\t\t\t\tconst seq = Number.parseInt(match[1], 10);\n\t\t\t\t\tif (seq > maxSeq) {\n\t\t\t\t\t\tmaxSeq = seq;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t} catch {\n\t\t\t// 忽略读取错误\n\t\t}\n\n\t\t// 新的序列号 = 最大序列号 + 1\n\t\tconst newSeq = maxSeq + 1;\n\t\tconst fileName = `${dateStr}-${newSeq}.log`;\n\n\t\treturn path.join(this.logDir, fileName);\n\t}\n\n\t/**\n\t * 写入启动标记\n\t */\n\tprivate writeStartMarker(): void {\n\t\ttry {\n\t\t\tconst timestamp = new Date().toISOString();\n\t\t\tconst marker =\n\t\t\t\t`\\n${\"=\".repeat(80)}\\n` +\n\t\t\t\t`[${timestamp}] [INFO] Application started - Log file: ${path.basename(this.logFile)}\\n` +\n\t\t\t\t`${\"=\".repeat(80)}\\n\\n`;\n\t\t\tfs.writeFileSync(this.logFile, marker);\n\t\t} catch {\n\t\t\t// 忽略写入错误\n\t\t}\n\t}\n\n\t/**\n\t * 写入日志到文件\n\t */\n\tprivate write(level: LogLevel, message: string, source?: string): void {\n\t\ttry {\n\t\t\tconst timestamp = new Date().toISOString();\n\t\t\tconst sourceTag = source ? `[${source}] ` : \"\";\n\t\t\tconst logLine = `[${timestamp}] [${level}] ${sourceTag}${message}\\n`;\n\t\t\tfs.appendFileSync(this.logFile, logLine);\n\t\t} catch {\n\t\t\t// 忽略写入错误\n\t\t}\n\t}\n\n\t/**\n\t * 获取日志文件路径\n\t */\n\tgetLogFilePath(): string {\n\t\treturn this.logFile;\n\t}\n\n\t/**\n\t * 记录信息级别日志\n\t */\n\tinfo(message: string, source?: string): void {\n\t\tthis.write(\"INFO\", message, source);\n\t}\n\n\t/**\n\t * 记录警告级别日志\n\t */\n\twarn(message: string, source?: string): void {\n\t\tthis.write(\"WARN\", message, source);\n\t}\n\n\t/**\n\t * 记录错误级别日志\n\t */\n\terror(message: string, source?: string): void {\n\t\tthis.write(\"ERROR\", message, source);\n\t}\n\n\t/**\n\t * 记录致命错误级别日志\n\t */\n\tfatal(message: string, source?: string): void {\n\t\tthis.write(\"FATAL\", message, source);\n\t}\n\n\t/**\n\t * 记录子进程标准输出\n\t */\n\tstdout(source: string, data: string): void {\n\t\tconst trimmed = data.trim();\n\t\tif (trimmed) {\n\t\t\tthis.write(\"INFO\", trimmed, `${source} STDOUT`);\n\t\t}\n\t}\n\n\t/**\n\t * 记录子进程标准错误输出\n\t */\n\tstderr(source: string, data: string): void {\n\t\tconst trimmed = data.trim();\n\t\tif (trimmed) {\n\t\t\tthis.write(\"INFO\", trimmed, `${source} STDERR`);\n\t\t}\n\t}\n\n\t/**\n\t * 记录带堆栈信息的错误\n\t */\n\terrorWithStack(message: string, error: Error, source?: string): void {\n\t\tthis.error(message, source);\n\t\tif (error.stack) {\n\t\t\tthis.error(`Stack: ${error.stack}`, source);\n\t\t}\n\t}\n\n\t/**\n\t * 同时输出到控制台和日志文件\n\t */\n\tconsole(message: string, source?: string): void {\n\t\tconsole.log(message);\n\t\tthis.info(message, source);\n\t}\n\n\t/**\n\t * 同时输出错误到控制台和日志文件\n\t */\n\tconsoleError(message: string, source?: string): void {\n\t\tconsole.error(message);\n\t\tthis.error(message, source);\n\t}\n\n\t/**\n\t * 写入结束标记（在应用退出时调用）\n\t */\n\twriteEndMarker(): void {\n\t\ttry {\n\t\t\tconst timestamp = new Date().toISOString();\n\t\t\tconst marker =\n\t\t\t\t`\\n${\"=\".repeat(80)}\\n` +\n\t\t\t\t`[${timestamp}] [INFO] Application ended\\n` +\n\t\t\t\t`${\"=\".repeat(80)}\\n`;\n\t\t\tfs.appendFileSync(this.logFile, marker);\n\t\t} catch {\n\t\t\t// 忽略写入错误\n\t\t}\n\t}\n}\n\n/**\n * 全局日志服务实例\n */\nexport const logger = new Logger();\n"
  },
  {
    "path": "free-todo-frontend/electron/main.ts",
    "content": "/**\n * Electron 主进程入口\n * 应用启动协调层，负责初始化各模块并管理应用生命周期\n */\n\n// Set console encoding to UTF-8 for Windows\nif (process.platform === \"win32\") {\n\t\ttry {\n\t\t\t// Try to set console code page to UTF-8\n\t\t\trequire(\"node:child_process\").exec(\"chcp 65001\", () => {});\n\t\t} catch {\n\t\t\t// Ignore errors\n\t\t}\n}\n\nimport path from \"node:path\";\nimport { app, dialog, ipcMain } from \"electron\";\nimport { BackendServer } from \"./backend-server\";\nimport { cancelBootstrap } from \"./bootstrap-control\";\nimport { emitComplete, emitStatus } from \"./bootstrap-status\";\nimport { closeBootstrapWindow, createBootstrapWindow, getBootstrapWindow } from \"./bootstrap-window\";\nimport {\n\tgetBackendRuntime,\n\tgetServerMode,\n\tgetWindowMode,\n\tisDevelopment,\n\tPROCESS_CONFIG,\n\tTIMEOUT_CONFIG,\n} from \"./config\";\nimport { GlobalShortcutManager } from \"./global-shortcut-manager\";\nimport { setupIpcHandlers } from \"./ipc-handlers\";\nimport { IslandWindowManager } from \"./island-window-manager\";\nimport { logger } from \"./logger\";\nimport {\n\tgetServerUrl,\n\tsetBackendUrl,\n\tstartNextServer,\n\tstopNextServer,\n\twaitForServerPublic,\n} from \"./next-server\";\nimport { requestNotificationPermission } from \"./notification\";\nimport { isRuntimePrepared, setPreferredPythonPath, validatePythonPath } from \"./python-runtime\";\nimport { getInstallRoot, resolveRuntimeRoot } from \"./runtime-paths\";\nimport { TrayManager } from \"./tray-manager\";\nimport { WindowManager } from \"./window-manager\";\n\n// 判断是否为开发模式\nconst isDev = isDevelopment(app.isPackaged);\n\n// 获取服务器模式\nconst serverMode = getServerMode();\n\n// 获取窗口模式（island 或 web）\nconst windowMode = getWindowMode();\n\nlet bootstrapCompleted = false;\nlet stopPromptOpen = false;\n\n// 确保只有相同模式的应用实例运行\n// DEV 和 Build 版本使用不同的锁名称，允许它们同时运行\n// 但同一模式下只允许一个实例\nconst lockName = `freetodo-${serverMode}`;\nconst gotTheLock = app.requestSingleInstanceLock({ lockName } as never);\n\nif (!gotTheLock) {\n\t// 如果已经有实例在运行，退出当前实例\n\tapp.quit();\n} else {\n\t// 初始化各管理器实例\n\tconst backendServer = new BackendServer();\n\tconst windowManager = new WindowManager();\n\tconst islandWindowManager = new IslandWindowManager();\n\n\t// 初始化 Tray 和 GlobalShortcut 管理器（在 Island 创建后初始化）\n\tlet trayManager: TrayManager | null = null;\n\tlet shortcutManager: GlobalShortcutManager | null = null;\n\n\t// 设置全局异常处理\n\tsetupGlobalErrorHandlers();\n\n\t// 处理 Ctrl+C (SIGINT) 和 SIGTERM 信号，确保正常退出\n\tlet isQuitting = false;\n\tconst gracefulShutdown = async (signal: string) => {\n\t\tif (isQuitting) {\n\t\t\tconsole.log(`\\nReceived ${signal} signal again, forcing exit...`);\n\t\t\tprocess.exit(1);\n\t\t\treturn;\n\t\t}\n\n\t\tisQuitting = true;\n\t\tconsole.log(`\\nReceived ${signal} signal, shutting down gracefully...`);\n\n\t\ttry {\n\t\t\t// Only stop frontend server (Next.js), backend doesn't need to stop\n\t\t\tconsole.log(\"\\nStopping Next.js server...\");\n\t\t\tstopNextServer();\n\t\t\tconst { getNextProcess } = await import(\"./next-server\");\n\t\t\tconst nextProcess = getNextProcess();\n\t\t\tif (nextProcess && !nextProcess.killed) {\n\t\t\t\t// Wait for Next.js process to exit (this is critical)\n\t\t\t\tawait new Promise<void>((resolve) => {\n\t\t\t\t\tconst timeout = setTimeout(() => {\n\t\t\t\t\t\tconsole.log(\"Next.js process did not exit within 5 seconds, forcing exit...\");\n\t\t\t\t\t\tif (nextProcess && !nextProcess.killed) {\n\t\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t\t// On Windows, use SIGKILL to force kill\n\t\t\t\t\t\t\t\tif (process.platform === \"win32\") {\n\t\t\t\t\t\t\t\t\tnextProcess.kill(\"SIGKILL\");\n\t\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\t\tnextProcess.kill(\"SIGKILL\");\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\t\t\tconsole.warn(`Failed to kill Next.js process: ${err instanceof Error ? err.message : String(err)}`);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\tresolve();\n\t\t\t\t\t}, 5000);\n\n\t\t\t\t\tnextProcess.once(\"exit\", () => {\n\t\t\t\t\t\tclearTimeout(timeout);\n\t\t\t\t\t\tconsole.log(\"Next.js process exited successfully\");\n\t\t\t\t\t\tresolve();\n\t\t\t\t\t});\n\t\t\t\t});\n\t\t\t} else {\n\t\t\t\tconsole.log(\"Next.js process already stopped\");\n\t\t\t}\n\n\t\t\tconsole.log(\"Frontend process stopped, exiting...\");\n\t\t\t// Ensure app exits\n\t\t\tsetTimeout(() => {\n\t\t\t\tapp.quit();\n\t\t\t\tprocess.exit(0);\n\t\t\t}, 100);\n\t\t} catch (error) {\n\t\t\tconsole.error(\n\t\t\t\t`Error during graceful shutdown: ${error instanceof Error ? error.message : String(error)}`,\n\t\t\t);\n\t\t\tprocess.exit(1);\n\t\t}\n\t};\n\n\t// 监听 SIGINT (Ctrl+C) 和 SIGTERM 信号\n\tprocess.on(\"SIGINT\", () => gracefulShutdown(\"SIGINT\"));\n\tprocess.on(\"SIGTERM\", () => gracefulShutdown(\"SIGTERM\"));\n\n\t// 当另一个实例尝试启动时，聚焦到主窗口\n\tapp.on(\"second-instance\", () => {\n\t\tif (windowMode === \"web\") {\n\t\t\t// Web 模式：使用普通窗口\n\t\t\tif (windowManager.hasWindow()) {\n\t\t\t\twindowManager.focus();\n\t\t\t} else if (app.isReady()) {\n\t\t\t\twindowManager.create(getServerUrl());\n\t\t\t} else {\n\t\t\t\tapp.once(\"ready\", () => {\n\t\t\t\t\twindowManager.create(getServerUrl());\n\t\t\t\t});\n\t\t\t}\n\t\t} else {\n\t\t\t// Island 模式：使用灵动岛窗口\n\t\t\tif (islandWindowManager.hasWindow()) {\n\t\t\t\tislandWindowManager.show();\n\t\t\t\tislandWindowManager.getWindow()?.focus();\n\t\t\t} else if (app.isReady()) {\n\t\t\t\tislandWindowManager.create(getServerUrl());\n\t\t\t} else {\n\t\t\t\tapp.once(\"ready\", () => {\n\t\t\t\t\tislandWindowManager.create(getServerUrl());\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t});\n\n\t// macOS: 点击 dock 图标时显示或重建窗口\n\tapp.on(\"activate\", () => {\n\t\tif (windowMode === \"web\") {\n\t\t\t// Web 模式：使用普通窗口\n\t\t\tif (windowManager.hasWindow()) {\n\t\t\t\twindowManager.focus();\n\t\t\t} else {\n\t\t\t\twindowManager.create(getServerUrl());\n\t\t\t}\n\t\t} else {\n\t\t\t// Island 模式：使用灵动岛窗口\n\t\t\tif (islandWindowManager.hasWindow()) {\n\t\t\t\tislandWindowManager.show();\n\t\t\t} else {\n\t\t\t\tislandWindowManager.create(getServerUrl());\n\t\t\t}\n\t\t}\n\t});\n\n\t// 所有窗口关闭时退出应用（macOS 除外）\n\tapp.on(\"window-all-closed\", () => {\n\t\tif (process.platform !== \"darwin\") {\n\t\t\tapp.quit();\n\t\t}\n\t});\n\n\t// 应用退出前清理（不等待，快速退出）\n\tapp.on(\"before-quit\", () => {\n\t\tcleanup(backendServer, trayManager, shortcutManager, false);\n\t});\n\n\t// 应用退出时确保清理（不等待，快速退出）\n\tapp.on(\"quit\", () => {\n\t\tcleanup(backendServer, trayManager, shortcutManager, false);\n\t});\n\n\t// 应用准备就绪后启动\n\tapp.whenReady().then(async () => {\n\t\tif (app.isPackaged) {\n\t\t\tconst backendRuntime = getBackendRuntime();\n\t\t\tif (backendRuntime === \"script\") {\n\t\t\t\tconst runtimeRoot = resolveRuntimeRoot();\n\t\t\t\tconst venvDir = path.join(runtimeRoot, PROCESS_CONFIG.backendVenvDir);\n\t\t\t\tconst requirementsPath = app.isPackaged\n\t\t\t\t\t? path.join(process.resourcesPath, \"backend\", PROCESS_CONFIG.backendRequirementsFile)\n\t\t\t\t\t: path.join(getInstallRoot(), PROCESS_CONFIG.backendRequirementsFile);\n\t\t\t\tif (!isRuntimePrepared(runtimeRoot, venvDir, requirementsPath)) {\n\t\t\t\t\tcreateBootstrapWindow();\n\t\t\t\t\tattachBootstrapHandlers();\n\t\t\t\t} else {\n\t\t\t\t\tbootstrapCompleted = true;\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tbootstrapCompleted = true;\n\t\t\t}\n\t\t}\n\t\tconst managers = await bootstrap(backendServer, windowManager, islandWindowManager);\n\t\ttrayManager = managers.trayManager;\n\t\tshortcutManager = managers.shortcutManager;\n\t});\n}\n\n/**\n * 设置全局错误处理器\n */\nfunction setupGlobalErrorHandlers(): void {\n\tprocess.on(\"uncaughtException\", (error) => {\n\t\tlogger.fatal(`UNCAUGHT EXCEPTION: ${error.message}`);\n\t\tif (error.stack) {\n\t\t\tlogger.fatal(`Stack: ${error.stack}`);\n\t\t}\n\t});\n\n\tprocess.on(\"unhandledRejection\", (reason) => {\n\t\tlogger.fatal(`UNHANDLED REJECTION: ${reason}`);\n\t});\n}\n\nfunction attachBootstrapHandlers(): void {\n\tconst bootstrapWindow = getBootstrapWindow();\n\tif (bootstrapWindow) {\n\t\tbootstrapWindow.on(\"close\", async (event) => {\n\t\t\tif (bootstrapCompleted) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tevent.preventDefault();\n\t\t\tawait confirmStopInstallation();\n\t\t});\n\t}\n\n\tipcMain.removeAllListeners(\"bootstrap:stop\");\n\tipcMain.removeAllListeners(\"bootstrap:select-python\");\n\tipcMain.on(\"bootstrap:stop\", async () => {\n\t\tawait confirmStopInstallation();\n\t});\n\n\tipcMain.on(\"bootstrap:select-python\", async () => {\n\t\tconst dialogOptions: Electron.OpenDialogOptions = {\n\t\t\tproperties: [\"openFile\"],\n\t\t\ttitle: \"选择 Python 3.12 可执行文件\",\n\t\t};\n\t\tif (process.platform === \"win32\") {\n\t\t\tdialogOptions.filters = [{ name: \"Python\", extensions: [\"exe\"] }];\n\t\t}\n\t\tconst result = await dialog.showOpenDialog(dialogOptions);\n\t\tif (result.canceled || result.filePaths.length === 0) {\n\t\t\treturn;\n\t\t}\n\t\tconst selectedPath = result.filePaths[0];\n\t\tconst info = await validatePythonPath(selectedPath);\n\t\tif (!info || !info.version.startsWith(\"3.12\")) {\n\t\t\tdialog.showErrorBox(\n\t\t\t\t\"Python 版本不匹配\",\n\t\t\t\t\"请选择 Python 3.12 的可执行文件。\",\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\t\tsetPreferredPythonPath(selectedPath);\n\t\temitStatus({\n\t\t\tmessage: \"已选择 Python 3.12\",\n\t\t\tpythonPath: info.executable,\n\t\t});\n\t});\n}\n\nasync function confirmStopInstallation(): Promise<void> {\n\tif (bootstrapCompleted || stopPromptOpen) {\n\t\treturn;\n\t}\n\tstopPromptOpen = true;\n\tconst result = await dialog.showMessageBox({\n\t\ttype: \"warning\",\n\t\tbuttons: [\"继续等待\", \"停止安装\"],\n\t\tdefaultId: 0,\n\t\tcancelId: 0,\n\t\tmessage: \"确定要停止安装 FreeTodo 吗？\",\n\t\tdetail: \"停止后需要重新启动安装流程。\",\n\t});\n\tstopPromptOpen = false;\n\tif (result.response === 1) {\n\t\temitStatus({ message: \"正在停止安装\", progress: 0 });\n\t\tcancelBootstrap();\n\t\tapp.quit();\n\t}\n}\n\nfunction waitForBootstrapContinue(): Promise<void> {\n\treturn new Promise((resolve) => {\n\t\tipcMain.once(\"bootstrap:continue\", () => resolve());\n\t});\n}\n\n/**\n * 应用启动流程\n */\nasync function bootstrap(\n\tbackendServer: BackendServer,\n\twindowManager: WindowManager,\n\tislandWindowManager: IslandWindowManager,\n): Promise<{ trayManager: TrayManager; shortcutManager: GlobalShortcutManager }> {\n\ttry {\n\t\t// 记录启动信息\n\t\tlogStartupInfo();\n\t\tconst installPath = getInstallRoot();\n\t\tconst runtimeRoot = resolveRuntimeRoot();\n\t\tconst venvPath = path.join(runtimeRoot, PROCESS_CONFIG.backendVenvDir);\n\t\temitStatus({\n\t\t\tmessage: \"启动初始化\",\n\t\t\tprogress: 0,\n\t\t\tinstallPath,\n\t\t\tvenvPath,\n\t\t});\n\n\t\t\t// 设置 IPC 处理器（包含 Island 相关）\n\t\tsetupIpcHandlers(windowManager, islandWindowManager);\n\n\t\t// 请求通知权限\n\t\t\tawait requestNotificationPermission();\n\n\t\t// 1. 自动检测后端端口（如果后端已运行）\n\t\tlogger.info(\"Detecting running backend server...\");\n\t\temitStatus({ message: \"检测后端服务\", progress: 15 });\n\t\tconst detectedBackendPort = await backendServer.detectRunningBackendPort();\n\t\tif (detectedBackendPort) {\n\t\t\tbackendServer.setPort(detectedBackendPort);\n\t\t\tlogger.info(`Detected backend running on port: ${detectedBackendPort}`);\n\t\t\temitStatus({ message: \"检测到已运行后端\", progress: 20 });\n\t\t} else {\n\t\t\t// 如果检测不到，启动后端服务器\n\t\t\tlogger.info(\"No running backend detected, will start backend server...\");\n\t\t\tawait backendServer.start({ waitForReady: false });\n\t\t}\n\n\t\t// 更新 NextServer 的后端 URL（后端可能使用了动态端口）\n\t\tconst backendUrl = backendServer.getUrl();\n\t\tsetBackendUrl(backendUrl);\n\n\t\t// 2. 启动 Next.js 前端服务器（无需等待后端完全就绪）\n\t\tawait startNextServer();\n\t\tconst serverUrl = getServerUrl();\n\n\t\t// 3. 根据窗口模式创建主窗口（先展示加载界面）\n\t\tif (windowMode === \"web\") {\n\t\t\twindowManager.create(serverUrl, { waitForServer: false, showLoading: true });\n\t\t\tlogger.info(\"Web main window created (loading)\");\n\t\t}\n\n\t\t// 并行等待后端与前端就绪\n\t\tconst backendReadyPromise = backendServer\n\t\t\t.waitForReadyAndVerify(TIMEOUT_CONFIG.backendReady * 6)\n\t\t\t.then(() => {\n\t\t\t\tlogger.console(`Backend server is ready at ${backendUrl}!`);\n\t\t\t\temitStatus({ message: \"后端健康检查通过\", progress: 80 });\n\t\t\t})\n\t\t\t.catch((error) => {\n\t\t\t\tconst errorMsg = `Backend server not available: ${error instanceof Error ? error.message : String(error)}`;\n\t\t\t\tlogger.warn(errorMsg);\n\t\t\t\tif (!isDev) {\n\t\t\t\t\tthrow error;\n\t\t\t\t}\n\t\t\t});\n\n\t\tconst frontendReadyPromise = waitForServerPublic(serverUrl, 30000)\n\t\t\t.then(() => {\n\t\t\t\tlogger.console(`Next.js server is ready at ${serverUrl}!`);\n\t\t\t\temitStatus({ message: \"前端服务已就绪\", progress: 92 });\n\t\t\t})\n\t\t\t.catch((error) => {\n\t\t\t\tconst errorMsg = `Next.js server did not start within 30000ms: ${error instanceof Error ? error.message : String(error)}`;\n\t\t\t\tlogger.error(errorMsg);\n\t\t\t\tif (!isDev) {\n\t\t\t\t\tthrow error;\n\t\t\t\t}\n\t\t\t});\n\n\t\tawait Promise.all([backendReadyPromise, frontendReadyPromise]);\n\n\t\t// 4. 根据窗口模式创建主窗口\n\t\tif (app.isPackaged && !bootstrapCompleted) {\n\t\t\temitStatus({\n\t\t\t\tmessage: \"安装完成\",\n\t\t\t\tdetail: \"点击“开始使用”进入应用\",\n\t\t\t\tprogress: 100,\n\t\t\t});\n\t\t\temitComplete();\n\t\t\tawait waitForBootstrapContinue();\n\t\t\tbootstrapCompleted = true;\n\t\t}\n\n\t\tif (windowMode === \"web\") {\n\t\t\t// Web 模式：创建普通窗口，加载主页面\n\t\t\tif (!windowManager.hasWindow()) {\n\t\t\t\twindowManager.create(serverUrl);\n\t\t\t} else {\n\t\t\t\twindowManager.load(serverUrl);\n\t\t\t}\n\t\t\tlogger.info(\"Web main window created\");\n\t\t} else {\n\t\t\t// Island 模式：创建灵动岛窗口\n\t\t\tislandWindowManager.create(serverUrl);\n\t\t\tlogger.info(\"Island main window created\");\n\t\t}\n\t\tcloseBootstrapWindow();\n\n\t\t// 5. 初始化 Tray 和 Global Shortcuts\n\t\t// 注意：Web 模式下 TrayManager 和 GlobalShortcutManager 仍然使用 islandWindowManager\n\t\t// 这样即使在 Web 模式下，用户也可以通过快捷键或托盘切换到 Island 模式\n\t\tconst trayManager = new TrayManager(islandWindowManager);\n\t\ttrayManager.create();\n\t\tlogger.info(\"System tray icon created\");\n\n\t\tconst shortcutManager = new GlobalShortcutManager(islandWindowManager);\n\t\tshortcutManager.registerDefaults();\n\t\tlogger.info(\"Global shortcuts registered\");\n\n\t\tlogger.info(\n\t\t\t`Window created successfully. Frontend: ${getServerUrl()}, Backend: ${backendServer.getUrl()}`,\n\t\t);\n\n\t\treturn { trayManager, shortcutManager };\n\t} catch (error) {\n\t\thandleStartupError(error);\n\t\t// Return dummy instances on error (will be cleaned up)\n\t\treturn {\n\t\t\ttrayManager: new TrayManager(islandWindowManager),\n\t\t\tshortcutManager: new GlobalShortcutManager(islandWindowManager),\n\t\t};\n\t}\n}\n\n/**\n * 记录启动信息\n */\nfunction logStartupInfo(): void {\n\tlogger.info(\"Application starting...\");\n\tlogger.info(`App isPackaged: ${app.isPackaged}`);\n\tlogger.info(`NODE_ENV: ${process.env.NODE_ENV || \"not set\"}`);\n\tlogger.info(`isDev: ${isDev}`);\n\tlogger.info(`Server mode: ${serverMode}`);\n\tlogger.info(`Window mode: ${windowMode}`);\n\tlogger.info(`Will start built-in server: ${!isDev || app.isPackaged}`);\n}\n\n/**\n * 处理启动错误\n */\nfunction handleStartupError(error: unknown): void {\n\t\t\tconst errorMsg = `Failed to start application: ${error instanceof Error ? error.message : String(error)}`;\n\t\t\tconsole.error(errorMsg);\n\tlogger.fatal(errorMsg);\n\n\t\t\tif (error instanceof Error && error.stack) {\n\t\tlogger.fatal(`Stack trace: ${error.stack}`);\n\t\t\t}\n\n\t\t\tdialog.showErrorBox(\n\t\t\t\t\"Startup Error\",\n\t\t`Failed to start application:\\n${errorMsg}\\n\\nCheck logs at: ${logger.getLogFilePath()}`,\n\t\t\t);\n\n\t\t\tsetTimeout(() => {\n\t\t\t\tapp.quit();\n\t}, TIMEOUT_CONFIG.quitDelay);\n}\n\n/**\n * 清理资源\n * @param backendServer 后端服务器实例\n * @param trayManager Tray 管理器实例\n * @param shortcutManager 全局快捷键管理器实例\n * @param waitForExit 是否等待进程退出（默认 false，用于快速退出）\n */\nfunction cleanup(\n\tbackendServer: BackendServer,\n\ttrayManager: TrayManager | null,\n\tshortcutManager: GlobalShortcutManager | null,\n\twaitForExit = false,\n): void {\n\tlogger.info(\"Cleaning up resources...\");\n\n\t// 清理 Tray\n\tif (trayManager) {\n\t\ttrayManager.destroy();\n\t}\n\n\t// 清理全局快捷键（在 GlobalShortcutManager 中已自动注册清理）\n\tif (shortcutManager) {\n\t\tshortcutManager.unregisterAll();\n\t}\n\n\t// 如果 waitForExit 为 false，快速停止（不等待）\n\tbackendServer.stop(waitForExit);\n\tstopNextServer();\n}\n"
  },
  {
    "path": "free-todo-frontend/electron/next-server.ts",
    "content": "/**\n * Next.js 服务器管理模块\n * 负责 Next.js 服务器的启动、停止和进程管理\n */\n\nimport { type ChildProcess, fork, spawn } from \"node:child_process\";\nimport fs from \"node:fs\";\nimport path from \"node:path\";\nimport { app, BrowserWindow, dialog } from \"electron\";\nimport { emitStatus } from \"./bootstrap-status\";\nimport {\n\tisDevelopment,\n\tLOG_CONFIG,\n\tPORT_CONFIG,\n} from \"./config\";\nimport { logger } from \"./logger\";\nimport { portManager } from \"./port-manager\";\n\n// 需要从 health-check 导入的函数（如果不存在则创建）\n// 暂时使用内联实现，后续可以提取到 health-check.ts\nfunction setNextProcessRef(_proc: { killed: boolean } | null): void {\n\t// 设置进程引用（用于健康检查，如果需要）\n}\n\nfunction stopHealthCheck(): void {\n\t// 健康检查停止逻辑（如果需要）\n}\n\nfunction waitForServer(url: string, timeout: number): Promise<void> {\n\treturn new Promise((resolve, reject) => {\n\t\tconst startTime = Date.now();\n\t\tconst http = require(\"node:http\") as typeof import(\"node:http\");\n\n\t\tconst check = () => {\n\t\t\thttp\n\t\t\t\t.get(url, (res: import(\"node:http\").IncomingMessage) => {\n\t\t\t\t\tif (res.statusCode === 200 || res.statusCode === 304) {\n\t\t\t\t\t\tresolve();\n\t\t\t\t\t} else {\n\t\t\t\t\t\tretry();\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t\t.on(\"error\", () => {\n\t\t\t\t\tretry();\n\t\t\t\t});\n\t\t};\n\n\t\tconst retry = () => {\n\t\t\tif (Date.now() - startTime >= timeout) {\n\t\t\t\treject(new Error(`Server did not start within ${timeout}ms`));\n\t\t\t} else {\n\t\t\t\tsetTimeout(check, 500);\n\t\t\t}\n\t\t};\n\n\t\tcheck();\n\t});\n}\n\nlet nextProcess: ChildProcess | null = null;\nlet isStopping = false;\n\n\t/**\n * 获取 Next.js 进程\n */\nexport function getNextProcess(): ChildProcess | null {\n\treturn nextProcess;\n\t}\n\n\t/**\n * 设置 Next.js 进程\n */\nexport function setNextProcess(proc: ChildProcess | null): void {\n\tnextProcess = proc;\n\tsetNextProcessRef(proc);\n}\n\n// 动态端口（运行时确定）\nlet actualFrontendPort: number = PORT_CONFIG.frontend.default;\n\n/**\n * 获取当前前端端口\n */\nfunction getActualFrontendPort(): number {\n\treturn actualFrontendPort;\n}\n\n/**\n * 设置前端端口\n */\nfunction setActualFrontendPort(port: number): void {\n\tactualFrontendPort = port;\n\t\t}\n\n\n/**\n * 获取后端服务器 URL（需要从外部传入）\n */\nlet backendUrl = \"http://localhost:8000\";\n\n/**\n * 设置后端 URL\n */\nexport function setBackendUrl(url: string): void {\n\tbackendUrl = url;\n}\n\n/**\n * 获取后端服务器 URL\n */\nexport function getBackendUrl(): string {\n\treturn backendUrl;\n}\n\n\t/**\n * 启动 Next.js 服务器（支持动态端口）\n * 在打包的应用中，总是启动内置的生产服务器\n\t */\nexport async function startNextServer(): Promise<void> {\n\tconst isDev = isDevelopment(app.isPackaged);\n\temitStatus({ message: \"启动前端服务\", progress: 82 });\n\n\t// 如果应用已打包，必须启动内置服务器，不允许依赖外部 dev 服务器\n\t\tif (app.isPackaged) {\n\t\t\tlogger.info(\"App is packaged - starting built-in production server\");\n\t} else if (isDev) {\n\t\t// 开发模式下，尝试探测可用的前端端口（以防开发服务器未启动）\n\t\ttry {\n\t\t\tconst port = await portManager.findAvailablePort(\n\t\t\t\tPORT_CONFIG.frontend.default,\n\t\t\t);\n\t\t\tsetActualFrontendPort(port);\n\t\t} catch {\n\t\t\tsetActualFrontendPort(PORT_CONFIG.frontend.default);\n\t\t}\n\t\tconst serverUrl = getServerUrl();\n\t\tconst msg = `Development mode: expecting Next.js dev server at ${serverUrl}`;\n\t\tlogger.console(msg);\n\t\tlogger.info(msg);\n\n\t\t// 检查是否已经有 Next.js 服务器在运行\n\t\ttry {\n\t\t\tawait waitForServer(serverUrl, 2000);\n\t\t\tlogger.info(\"Next.js dev server is already running\");\n\t\t\treturn;\n\t\t} catch {\n\t\t\t// 没有运行，需要启动\n\t\t}\n\n\t\t// 启动 Next.js dev 服务器\n\t\t// 在 Windows 上，需要使用 shell: true 来运行 .cmd 文件\n\t\tconst devCommand = process.platform === \"win32\" ? \"pnpm.cmd\" : \"pnpm\";\n\t\tconst devArgs = [\"dev\"];\n\n\t\tlogger.info(\n\t\t\t`Starting Next.js dev server: ${devCommand} ${devArgs.join(\" \")}`,\n\t\t);\n\t\tlogger.info(`Working directory: ${path.join(__dirname, \"..\")}`);\n\n\t\t// Set console encoding to UTF-8 for Windows\n\t\tif (process.platform === \"win32\") {\n\t\t\ttry {\n\t\t\t\t// Try to set console code page to UTF-8\n\t\t\t\trequire(\"node:child_process\").exec(\"chcp 65001\", () => {});\n\t\t\t} catch {\n\t\t\t\t// Ignore errors\n\t\t\t}\n\t\t}\n\n\t\tnextProcess = spawn(devCommand, devArgs, {\n\t\t\tcwd: path.join(__dirname, \"..\"),\n\t\t\tenv: {\n\t\t\t\t...process.env,\n\t\t\t\tPORT: String(getActualFrontendPort()),\n\t\t\t\tNODE_ENV: \"development\",\n\t\t\t\t// Set UTF-8 encoding for child process\n\t\t\t\t...(process.platform === \"win32\" && { CHCP: \"65001\" }),\n\t\t\t},\n\t\t\tstdio: [\"ignore\", \"pipe\", \"pipe\"],\n\t\t\tshell: process.platform === \"win32\", // Windows needs shell\n\t\t\tdetached: false, // Ensure child process is part of the same process group\n\t\t});\n\t\tsetNextProcessRef(nextProcess);\n\n\t\t// Listen to output - output directly to console, don't log to file (avoid garbled characters)\n\t\tif (nextProcess.stdout) {\n\t\t\tnextProcess.stdout.setEncoding(\"utf8\");\n\t\t\tnextProcess.stdout.on(\"data\", (data) => {\n\t\t\t\tconst output = String(data);\n\t\t\t\t// Output directly to console (just like pnpm dev)\n\t\t\t\t// Use Buffer to ensure correct encoding\n\t\t\t\ttry {\n\t\t\t\t\tprocess.stdout.write(Buffer.from(output, \"utf8\"));\n\t\t\t\t} catch {\n\t\t\t\t\tprocess.stdout.write(output);\n\t\t\t\t}\n\t\t\t});\n\t\t}\n\n\t\tif (nextProcess.stderr) {\n\t\t\tnextProcess.stderr.setEncoding(\"utf8\");\n\t\t\tnextProcess.stderr.on(\"data\", (data) => {\n\t\t\t\tconst output = String(data);\n\t\t\t\t// Output directly to console (just like pnpm dev)\n\t\t\t\t// Use Buffer to ensure correct encoding\n\t\t\t\ttry {\n\t\t\t\t\tprocess.stderr.write(Buffer.from(output, \"utf8\"));\n\t\t\t\t} catch {\n\t\t\t\t\tprocess.stderr.write(output);\n\t\t\t\t}\n\t\t\t});\n\t\t}\n\n\t\tnextProcess.on(\"error\", (error) => {\n\t\t\tlogger.error(`Failed to start Next.js dev server: ${error.message}`);\n\t\t});\n\n\t\tnextProcess.on(\"exit\", (code) => {\n\t\t\tlogger.error(`Next.js dev server exited with code ${code}`);\n\t\t});\n\n\t\treturn;\n\t\t} else {\n\t\t\tlogger.info(\n\t\t\t\t\"Running in production mode (not packaged) - starting built-in server\",\n\t\t\t);\n\t\t}\n\n\t// 动态端口分配：查找可用的前端端口\n\t\ttry {\n\t\tconst port = await portManager.findAvailablePort(\n\t\t\t\tPORT_CONFIG.frontend.default,\n\t\t\t);\n\t\tsetActualFrontendPort(port);\n\t\tlogger.info(`Frontend will use port: ${port}`);\n\t\t} catch (error) {\n\t\t\tconst errorMsg = `Failed to find available frontend port: ${error instanceof Error ? error.message : String(error)}`;\n\t\t\tlogger.error(errorMsg);\n\t\t\tdialog.showErrorBox(\"Port Allocation Error\", errorMsg);\n\t\t\tthrow error;\n\t\t}\n\n\t\tconst serverPath = path.join(\n\t\t\tprocess.resourcesPath,\n\t\t\t\"standalone\",\n\t\t\t\"server.js\",\n\t\t);\n\n\tconst msg = `Starting Next.js server from: ${serverPath}`;\n\tlogger.console(msg);\n\tlogger.info(msg);\n\temitStatus({ message: \"启动前端服务\", progress: 85, detail: serverPath });\n\n\t// 检查服务器文件是否存在\n\t\tif (!fs.existsSync(serverPath)) {\n\t\t\tconst errorMsg = `Server file not found: ${serverPath}`;\n\t\t\tlogger.error(errorMsg);\n\t\t\tdialog.showErrorBox(\n\t\t\t\t\"Server Not Found\",\n\t\t\t\t`The Next.js server file was not found at:\\n${serverPath}\\n\\nPlease rebuild the application.`,\n\t\t\t);\n\t\t\tthrow new Error(errorMsg);\n\t\t}\n\n\t// 设置工作目录为 standalone 目录，这样相对路径可以正确解析\n\t\tconst serverDir = path.dirname(serverPath);\n\n\t\tlogger.info(`Server directory: ${serverDir}`);\n\t\tlogger.info(`Server path: ${serverPath}`);\n\tlogger.info(`PORT: ${getActualFrontendPort()}, HOSTNAME: localhost`);\n\tlogger.info(`NEXT_PUBLIC_API_URL: ${getBackendUrl()}`);\n\n\t// 检查关键文件是否存在\n\t\tconst nextServerDir = path.join(serverDir, \".next\", \"server\");\n\t\tif (!fs.existsSync(nextServerDir)) {\n\t\t\tconst errorMsg = `Required directory not found: ${nextServerDir}`;\n\t\t\tlogger.error(errorMsg);\n\t\t\tthrow new Error(errorMsg);\n\t\t}\n\t\tlogger.info(\"Verified .next/server directory exists\");\n\n\t// 强制设置生产环境变量，确保服务器以生产模式运行\n\t// 创建新的环境对象，避免直接修改 process.env\n\tconst serverEnv: Record<string, string | undefined> = {};\n\n\t// 复制所有环境变量，但排除 dev 相关变量\n\tfor (const key in process.env) {\n\t\tif (!key.startsWith(\"NEXT_DEV\") && !key.startsWith(\"TURBOPACK\")) {\n\t\t\tserverEnv[key] = process.env[key];\n\t\t}\n\t}\n\n\t// 强制设置生产模式环境变量，使用动态分配的端口\n\tserverEnv.PORT = String(getActualFrontendPort());\n\t\tserverEnv.HOSTNAME = \"localhost\";\n\tserverEnv.NODE_ENV = \"production\"; // 强制生产模式\n\t// 注入后端 URL，让 Next.js 的 rewrite 和 API 调用使用正确的后端地址\n\tserverEnv.NEXT_PUBLIC_API_URL = getBackendUrl();\n\n\t// 使用 fork 启动 Node.js 服务器进程\n\t// fork 是 spawn 的特殊情况，专门用于 Node.js 脚本，提供更好的 IPC 支持\n\t// 注意：fork 会自动设置 execPath，所以我们只需要传递脚本路径\n\tnextProcess = fork(serverPath, [], {\n\t\tcwd: serverDir, // 设置工作目录\n\t\t\tenv: serverEnv as NodeJS.ProcessEnv,\n\t\tstdio: [\"ignore\", \"pipe\", \"pipe\", \"ipc\"], // stdin: ignore, stdout/stderr: pipe, ipc channel\n\t\tsilent: false, // 不静默，允许输出\n\t\t});\n\tsetNextProcessRef(nextProcess);\n\n\tlogger.info(`Spawned process with PID: ${nextProcess.pid}`);\n\n\t// 确保进程引用被保持\n\tif (!nextProcess.pid) {\n\t\t\tconst errorMsg = \"Failed to spawn process - no PID assigned\";\n\t\t\tlogger.error(errorMsg);\n\t\t\tthrow new Error(errorMsg);\n\t\t}\n\n\t// 监听进程的 spawn 事件\n\tnextProcess.on(\"spawn\", () => {\n\t\tlogger.info(`Process spawned successfully with PID: ${nextProcess?.pid}`);\n\t});\n\n\t// 收集所有输出用于日志\n\tlet stdoutBuffer = \"\";\n\tlet stderrBuffer = \"\";\n\n\t// 立即设置数据监听器，避免丢失早期输出\n\t// 直接输出到控制台，不记录到日志文件（避免乱码）\n\tif (nextProcess.stdout) {\n\t\tnextProcess.stdout.setEncoding(\"utf8\");\n\t\tnextProcess.stdout.on(\"data\", (data) => {\n\t\t\tconst output = String(data);\n\t\t\tstdoutBuffer += output;\n\t\t\t// 直接输出到控制台\n\t\t\tprocess.stdout.write(output);\n\t\t});\n\t\tnextProcess.stdout.on(\"end\", () => {\n\t\t\tlogger.info(\"[Next.js STDOUT] stream ended\");\n\t\t});\n\t\tnextProcess.stdout.on(\"error\", (err) => {\n\t\t\tlogger.error(`[Next.js STDOUT] stream error: ${err.message}`);\n\t\t});\n\t}\n\n\tif (nextProcess.stderr) {\n\t\tnextProcess.stderr.setEncoding(\"utf8\");\n\t\tnextProcess.stderr.on(\"data\", (data) => {\n\t\t\tconst output = String(data);\n\t\t\tstderrBuffer += output;\n\t\t\t// 直接输出到控制台\n\t\t\tprocess.stderr.write(output);\n\t\t});\n\t\tnextProcess.stderr.on(\"end\", () => {\n\t\t\tlogger.info(\"[Next.js STDERR] stream ended\");\n\t\t});\n\t\tnextProcess.stderr.on(\"error\", (err) => {\n\t\t\tlogger.error(`[Next.js STDERR] stream error: ${err.message}`);\n\t\t});\n\t}\n\n\tnextProcess.on(\"error\", (error) => {\n\t\t\tconst errorMsg = `Failed to start Next.js server: ${error.message}`;\n\t\tlogger.error(errorMsg);\n\t\tif (error.stack) {\n\t\t\tlogger.error(`Error stack: ${error.stack}`);\n\t\t}\n\n\t\t// 显示错误对话框\n\t\tconst windows = BrowserWindow.getAllWindows();\n\t\tif (windows.length > 0) {\n\t\t\tdialog.showErrorBox(\n\t\t\t\t\"Server Start Error\",\n\t\t\t\t`Failed to start Next.js server:\\n${error.message}\\n\\nCheck logs at: ${logger.getLogFilePath()}`,\n\t\t\t);\n\t\t}\n\n\t\t\ttry {\n\t\t\t\tconsole.error(errorMsg, error);\n\t\t\t} catch {\n\t\t\t\t// 忽略 EPIPE 错误\n\t\t\t}\n\t\t});\n\n\t// 监听未捕获的异常（可能在子进程中）\n\tprocess.on(\"uncaughtException\", (error) => {\n\t\tlogger.error(`UNCAUGHT EXCEPTION: ${error.message}`);\n\t\tif (error.stack) {\n\t\t\tlogger.error(`Stack: ${error.stack}`);\n\t}\n\t});\n\n\tprocess.on(\"unhandledRejection\", (reason) => {\n\t\tlogger.error(`UNHANDLED REJECTION: ${reason}`);\n\t});\n\n\tnextProcess.on(\"exit\", (code, signal) => {\n\t\tconst exitMsg = `Next.js server exited with code ${code}, signal ${signal}`;\n\n\t\t// 如果是主动关闭（调用了 stop() 方法），不显示错误对话框\n\t\tif (isStopping) {\n\t\t\tlogger.info(`${exitMsg} (intentional shutdown)`);\n\t\t\tisStopping = false; // 重置标志\n\t\t\treturn;\n\t\t}\n\n\t\tlogger.error(exitMsg);\n\t\tlogger.info(\n\t\t\t`STDOUT buffer (last ${LOG_CONFIG.bufferDisplayLimit} chars): ${stdoutBuffer.slice(-LOG_CONFIG.bufferDisplayLimit)}`,\n\t\t);\n\t\tlogger.info(\n\t\t\t`STDERR buffer (last ${LOG_CONFIG.bufferDisplayLimit} chars): ${stderrBuffer.slice(-LOG_CONFIG.bufferDisplayLimit)}`,\n\t\t);\n\n\t\t// 检查 node_modules 是否存在\n\t\tconst nodeModulesPath = path.join(serverDir, \"node_modules\");\n\t\tconst nextModulePath = path.join(nodeModulesPath, \"next\");\n\t\tlogger.info(`Checking node_modules: ${nodeModulesPath}`);\n\t\tlogger.info(`node_modules exists: ${fs.existsSync(nodeModulesPath)}`);\n\t\tlogger.info(`next module exists: ${fs.existsSync(nextModulePath)}`);\n\n\t\t// 检查关键依赖\n\t\tconst styledJsxPath = path.join(nodeModulesPath, \"styled-jsx\");\n\t\tconst swcHelpersPath = path.join(nodeModulesPath, \"@swc\", \"helpers\");\n\t\tlogger.info(`styled-jsx exists: ${fs.existsSync(styledJsxPath)}`);\n\t\tlogger.info(`@swc/helpers exists: ${fs.existsSync(swcHelpersPath)}`);\n\n\t\t// 如果服务器在启动后很快退出（无论是 code 0 还是其他），都认为是错误\n\t\t// 因为服务器应该持续运行\n\t\tconst errorMsg = `Server exited unexpectedly with code ${code}${signal ? `, signal ${signal}` : \"\"}. Check logs at: ${logger.getLogFilePath()}`;\n\t\tlogger.error(errorMsg);\n\n\t\tconst windows = BrowserWindow.getAllWindows();\n\t\tif (windows.length > 0) {\n\t\tdialog.showErrorBox(\n\t\t\t\"Server Exited Unexpectedly\",\n\t\t\t\t`The Next.js server exited unexpectedly.\\n\\n${errorMsg}\\n\\nSTDOUT:\\n${stdoutBuffer.slice(-LOG_CONFIG.dialogDisplayLimit) || \"(empty)\"}\\n\\nSTDERR:\\n${stderrBuffer.slice(-LOG_CONFIG.dialogDisplayLimit) || \"(empty)\"}\\n\\nCheck logs at: ${logger.getLogFilePath()}`,\n\t\t);\n\t\t}\n\n\t\t// 延迟退出，让用户看到错误消息\n\t\tsetTimeout(() => {\n\t\t\tapp.quit();\n\t\t}, 3000);\n\t});\n\t}\n\n\t/**\n * 关闭 Next.js 服务器\n * 注意：这个函数只发送停止信号，不等待进程退出\n * 实际的等待逻辑在 cleanup 函数中处理\n */\nexport function stopNextServer(): void {\n\tisStopping = true;\n\tstopHealthCheck();\n\tif (nextProcess && !nextProcess.killed) {\n\t\tlogger.info(\"Stopping Next.js server...\");\n\t\ttry {\n\t\t\t// 发送优雅关闭信号（SIGTERM）\n\t\t\tnextProcess.kill(\"SIGTERM\");\n\t\t} catch (error) {\n\t\t\tlogger.error(\n\t\t\t\t`Error stopping Next.js server: ${error instanceof Error ? error.message : String(error)}`,\n\t\t\t);\n\t\t}\n\t\t// 不立即设置为 null，让 cleanup 函数可以等待进程退出\n\t\t}\n\t}\n\n\t/**\n * 获取服务器 URL（用于外部调用）\n\t */\nexport function getServerUrl(): string {\n\treturn `http://localhost:${actualFrontendPort}`;\n\t}\n\n\t/**\n * 等待服务器就绪（公共方法）\n */\nexport async function waitForServerPublic(\n\turl: string,\n\ttimeout: number,\n): Promise<void> {\n\tawait waitForServer(url, timeout);\n}\n"
  },
  {
    "path": "free-todo-frontend/electron/notification.ts",
    "content": "/**\n * 系统通知服务\n * 提供系统原生通知功能\n */\n\nimport { Notification } from \"electron\";\nimport { logger } from \"./logger\";\nimport type { WindowManager } from \"./window-manager\";\n\n/**\n * 通知数据接口\n */\nexport interface NotificationData {\n\t/** 通知 ID */\n\tid: string;\n\t/** 通知标题 */\n\ttitle: string;\n\t/** 通知内容 */\n\tcontent: string;\n\t/** 时间戳 */\n\ttimestamp: string;\n}\n\n/**\n * 请求通知权限\n * 注意：Electron 会在首次显示通知时自动请求权限，无需手动检查\n * macOS 10.14+ 会弹出权限请求对话框\n * Windows 和 Linux 通常不需要显式权限请求\n */\nexport async function requestNotificationPermission(): Promise<void> {\n\tlogger.info(\n\t\t\"Notification permission will be requested automatically on first notification\",\n\t);\n}\n\n/**\n * 显示系统通知\n * @param data 通知数据\n * @param windowManager 窗口管理器（用于点击通知时聚焦窗口）\n */\nexport function showSystemNotification(\n\tdata: NotificationData,\n\twindowManager: WindowManager,\n): void {\n\tif (!windowManager.hasWindow()) {\n\t\tlogger.warn(\"Cannot show notification - mainWindow is null\");\n\t\treturn;\n\t}\n\n\ttry {\n\t\tconst notification = new Notification({\n\t\t\ttitle: data.title,\n\t\t\tbody: data.content,\n\t\t\tsilent: false, // 允许通知声音\n\t\t});\n\n\t\t// 处理通知点击事件\n\t\tnotification.on(\"click\", () => {\n\t\t\tlogger.info(`Notification ${data.id} clicked - focusing window`);\n\t\t\twindowManager.focus();\n\t\t});\n\n\t\t// 处理通知显示事件\n\t\tnotification.on(\"show\", () => {\n\t\t\tlogger.info(`Notification ${data.id} shown: ${data.title}`);\n\t\t});\n\n\t\t// 处理通知关闭事件\n\t\tnotification.on(\"close\", () => {\n\t\t\tlogger.info(`Notification ${data.id} closed`);\n\t\t});\n\n\t\t// 显示通知\n\t\tnotification.show();\n\t} catch (error) {\n\t\tconst errorMsg = `Failed to show notification: ${error instanceof Error ? error.message : String(error)}`;\n\t\tlogger.error(errorMsg);\n\t\t// 静默失败，不影响应用运行\n\t}\n}\n"
  },
  {
    "path": "free-todo-frontend/electron/port-manager.ts",
    "content": "/**\n * 端口管理服务\n * 提供端口可用性检测和动态端口分配功能\n */\n\nimport net from \"node:net\";\nimport { PORT_CONFIG } from \"./config\";\nimport { logger } from \"./logger\";\n\n/**\n * 端口管理器类\n * 负责检测端口可用性和查找可用端口\n */\nclass PortManager {\n\t/**\n\t * 检查指定端口是否可用\n\t * @param port 要检查的端口号\n\t * @returns 端口是否可用\n\t */\n\tasync isPortAvailable(port: number): Promise<boolean> {\n\t\treturn new Promise((resolve) => {\n\t\t\tconst server = net.createServer();\n\t\t\tserver.once(\"error\", () => resolve(false));\n\t\t\tserver.once(\"listening\", () => {\n\t\t\t\tserver.close();\n\t\t\t\tresolve(true);\n\t\t\t});\n\t\t\tserver.listen(port, \"127.0.0.1\");\n\t\t});\n\t}\n\n\t/**\n\t * 查找可用端口\n\t * 从 startPort 开始，依次尝试直到找到可用端口\n\t * @param startPort 起始端口号\n\t * @param maxAttempts 最大尝试次数，默认 100\n\t * @returns 可用的端口号\n\t * @throws 如果在指定范围内找不到可用端口\n\t */\n\tasync findAvailablePort(\n\t\tstartPort: number,\n\t\tmaxAttempts: number = PORT_CONFIG.frontend.maxAttempts,\n\t): Promise<number> {\n\t\tfor (let offset = 0; offset < maxAttempts; offset++) {\n\t\t\tconst port = startPort + offset;\n\t\t\tif (await this.isPortAvailable(port)) {\n\t\t\t\tif (offset > 0) {\n\t\t\t\t\tlogger.info(\n\t\t\t\t\t\t`Port ${startPort} was occupied, using port ${port} instead`,\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\treturn port;\n\t\t\t}\n\t\t\tlogger.info(`Port ${port} is occupied, trying next...`);\n\t\t}\n\n\t\tthrow new Error(\n\t\t\t`No available port found in range ${startPort}-${startPort + maxAttempts}`,\n\t\t);\n\t}\n}\n\n/**\n * 全局端口管理器实例\n */\nexport const portManager = new PortManager();\n"
  },
  {
    "path": "free-todo-frontend/electron/preload.ts",
    "content": "/**\n * Electron Preload Script\n * 用于在渲染进程中安全地访问 Electron API\n */\n\nimport { contextBridge, ipcRenderer } from \"electron\";\n\n/**\n * 通知数据接口\n */\nexport interface NotificationData {\n\tid: string;\n\ttitle: string;\n\tcontent: string;\n\ttimestamp: string;\n}\n\n// 立即设置透明背景（在页面加载前执行）\n// 这样可以避免 Next.js SSR 导致的窗口显示问题\n(() => {\n\tfunction setTransparentBackground() {\n\t\t// 检查 DOM 是否可用\n\t\tif (typeof document === \"undefined\" || !document.documentElement) {\n\t\t\treturn;\n\t\t}\n\n\t\t// 立即设置透明背景，使用 !important\n\t\tconst html = document.documentElement;\n\t\tconst body = document.body;\n\n\t\tif (html) {\n\t\t\thtml.setAttribute(\"data-electron\", \"true\");\n\t\t\thtml.style.setProperty(\"background-color\", \"transparent\", \"important\");\n\t\t\thtml.style.setProperty(\"background\", \"transparent\", \"important\");\n\t\t}\n\n\t\tif (body) {\n\t\t\tbody.style.setProperty(\"background-color\", \"transparent\", \"important\");\n\t\t\tbody.style.setProperty(\"background\", \"transparent\", \"important\");\n\t\t}\n\n\t\t// 原来这里会直接通知主进程 \"transparent-background-ready\"\n\t\t// 但这发生在 React 完成水合之前，可能导致窗口过早显示整页 UI\n\t\t// 现在改为只由前端的 ElectronTransparentScript 通知，确保已进入 Electron 专用布局后再显示窗口\n\t}\n\n\t// 等待 DOM 可用后再执行\n\tif (typeof document !== \"undefined\") {\n\t\t// 如果 DOM 已经加载完成\n\t\tif (\n\t\t\tdocument.readyState === \"complete\" ||\n\t\t\tdocument.readyState === \"interactive\"\n\t\t) {\n\t\t\tsetTransparentBackground();\n\t\t} else {\n\t\t\t// 监听 DOMContentLoaded\n\t\t\tdocument.addEventListener(\"DOMContentLoaded\", setTransparentBackground, {\n\t\t\t\tonce: true,\n\t\t\t});\n\t\t}\n\n\t\t// 也监听 body 的创建（如果 body 还不存在）\n\t\tif (!document.body && document.documentElement) {\n\t\t\tconst observer = new MutationObserver((_mutations, obs) => {\n\t\t\t\tif (document.body) {\n\t\t\t\t\tsetTransparentBackground();\n\t\t\t\t\tobs.disconnect();\n\t\t\t\t}\n\t\t\t});\n\n\t\t\t// 确保 documentElement 存在且是有效的 Node\n\t\t\tif (document.documentElement && document.documentElement.nodeType === 1) {\n\t\t\t\tobserver.observe(document.documentElement, {\n\t\t\t\t\tchildList: true,\n\t\t\t\t\tsubtree: true,\n\t\t\t\t});\n\t\t\t}\n\t\t} else if (document.body) {\n\t\t\t// body 已存在，直接设置\n\t\t\tsetTransparentBackground();\n\t\t}\n\t}\n})();\n\n// 暴露安全的 API 给渲染进程\ncontextBridge.exposeInMainWorld(\"electronAPI\", {\n\t/**\n\t * 显示系统通知\n\t * @param data 通知数据\n\t * @returns Promise<void>\n\t */\n\tshowNotification: (data: NotificationData): Promise<void> => {\n\t\treturn ipcRenderer.invoke(\"show-notification\", data);\n\t},\n\n\t/**\n\t * 设置窗口是否忽略鼠标事件（用于透明窗口点击穿透）\n\t */\n\tsetIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {\n\t\tipcRenderer.send(\"set-ignore-mouse-events\", ignore, options);\n\t},\n\n\t/**\n\t * 获取屏幕信息\n\t */\n\tgetScreenInfo: () => ipcRenderer.invoke(\"get-screen-info\"),\n\n\t/**\n\t * 通知主进程透明背景已设置完成\n\t */\n\ttransparentBackgroundReady: () => {\n\t\tipcRenderer.send(\"transparent-background-ready\");\n\t},\n\n\t/**\n\t * 移动窗口到指定位置\n\t */\n\tmoveWindow: (x: number, y: number) => {\n\t\tipcRenderer.send(\"move-window\", x, y);\n\t},\n\n\t/**\n\t * 获取窗口当前位置\n\t */\n\tgetWindowPosition: async () => {\n\t\treturn await ipcRenderer.invoke(\"get-window-position\");\n\t},\n\n\t/**\n\t * 退出应用\n\t */\n\tquit: () => {\n\t\tipcRenderer.send(\"app-quit\");\n\t},\n\n\t/**\n\t * 设置窗口背景色\n\t */\n\tsetWindowBackgroundColor: (color: string) => {\n\t\tipcRenderer.send(\"set-window-background-color\", color);\n\t},\n\n\t/**\n\t * 截图并提取待办事项\n\t */\n\tcaptureAndExtractTodos: async (\n\t\tpanelBounds?: { x: number; y: number; width: number; height: number } | null,\n\t): Promise<{\n\t\tsuccess: boolean;\n\t\tmessage: string;\n\t\textractedTodos: Array<{\n\t\t\ttitle: string;\n\t\t\tdescription?: string;\n\t\t\ttime_info?: Record<string, unknown>;\n\t\t\tsource_text?: string;\n\t\t\tconfidence: number;\n\t\t}>;\n\t\tcreatedCount: number;\n\t}> => {\n\t\treturn await ipcRenderer.invoke(\"capture-and-extract-todos\", panelBounds);\n\t},\n\n\t// ========== Island 动态岛相关 API ==========\n\n\t/**\n\t * 调整 Island 窗口大小（切换模式）\n\t * @param mode Island 模式: \"FLOAT\" | \"POPUP\" | \"SIDEBAR\" | \"FULLSCREEN\"\n\t */\n\tislandResizeWindow: (mode: string) => {\n\t\tipcRenderer.send(\"island:resize-window\", mode);\n\t},\n\n\t/**\n\t * 调整 SIDEBAR 模式窗口大小（多栏展开/收起）\n\t * @param columnCount 栏数: 1 | 2 | 3\n\t */\n\tislandResizeSidebar: (columnCount: number) => {\n\t\tipcRenderer.send(\"island:resize-sidebar\", columnCount);\n\t},\n\n\t/**\n\t * 显示 Island 窗口\n\t */\n\tislandShow: () => {\n\t\tipcRenderer.send(\"island:show\");\n\t},\n\n\t/**\n\t * 隐藏 Island 窗口\n\t */\n\tislandHide: () => {\n\t\tipcRenderer.send(\"island:hide\");\n\t},\n\n\t/**\n\t * 切换 Island 窗口显示/隐藏\n\t */\n\tislandToggle: () => {\n\t\tipcRenderer.send(\"island:toggle\");\n\t},\n\n\t/**\n\t * Island 窗口拖拽开始（自定义拖拽，仅垂直方向）\n\t * @param mouseY 鼠标屏幕 Y 坐标\n\t */\n\tislandDragStart: (mouseY: number) => {\n\t\tipcRenderer.send(\"island:drag-start\", mouseY);\n\t},\n\n\t/**\n\t * Island 窗口拖拽移动（自定义拖拽，仅垂直方向）\n\t * @param mouseY 鼠标屏幕 Y 坐标\n\t */\n\tislandDragMove: (mouseY: number) => {\n\t\tipcRenderer.send(\"island:drag-move\", mouseY);\n\t},\n\n\t/**\n\t * Island 窗口拖拽结束（自定义拖拽）\n\t */\n\tislandDragEnd: () => {\n\t\tipcRenderer.send(\"island:drag-end\");\n\t},\n\n\t/**\n\t * 设置 Island SIDEBAR 模式的固定状态\n\t * @param isPinned true = 固定（始终在顶部），false = 非固定（正常窗口行为）\n\t */\n\tislandSetPinned: (isPinned: boolean) => {\n\t\tipcRenderer.send(\"island:set-pinned\", isPinned);\n\t},\n\n\t/**\n\t * 监听 Island 窗口位置更新（拖拽时实时更新）\n\t * @param callback 回调函数，接收位置数据\n\t */\n\tonIslandPositionUpdate: (callback: (data: { y: number; screenHeight: number }) => void) => {\n\t\tconst listener = (_event: Electron.IpcRendererEvent, data: { y: number; screenHeight: number }) => callback(data);\n\t\tipcRenderer.on('island:position-update', listener);\n\t\treturn () => {\n\t\t\tipcRenderer.removeListener('island:position-update', listener);\n\t\t};\n\t},\n\n\t/**\n\t * 监听 Island 窗口锚点更新（模式切换时更新）\n\t * @param callback 回调函数，接收锚点数据\n\t */\n\tonIslandAnchorUpdate: (callback: (data: { anchor: 'top' | 'bottom' | null; y: number }) => void) => {\n\t\tconst listener = (_event: Electron.IpcRendererEvent, data: { anchor: 'top' | 'bottom' | null; y: number }) => callback(data);\n\t\tipcRenderer.on('island:anchor-update', listener);\n\t\treturn () => {\n\t\t\tipcRenderer.removeListener('island:anchor-update', listener);\n\t\t};\n\t},\n});\n"
  },
  {
    "path": "free-todo-frontend/electron/process-manager.ts",
    "content": "/**\n * 进程管理基类\n * 抽象前端/后端服务器的共同逻辑\n */\n\nimport type { ChildProcess } from \"node:child_process\";\nimport http from \"node:http\";\nimport { TIMEOUT_CONFIG } from \"./config\";\nimport { logger } from \"./logger\";\n\n/**\n * 服务器配置接口\n */\nexport interface ServerConfig {\n\t/** 服务器名称（用于日志） */\n\tname: string;\n\t/** 健康检查端点路径（如 \"/\" 或 \"/health\"） */\n\thealthEndpoint: string;\n\t/** 健康检查间隔（毫秒） */\n\thealthCheckInterval: number;\n\t/** 等待服务就绪的超时时间（毫秒） */\n\treadyTimeout: number;\n\t/** 健康检查接受的状态码范围 */\n\tacceptedStatusCodes?: { min: number; max: number };\n}\n\n/**\n * 进程管理器抽象基类\n * 提供子进程生命周期管理、健康检查等通用功能\n */\nexport abstract class ProcessManager {\n\t/** 子进程实例 */\n\tprotected process: ChildProcess | null = null;\n\t/** 健康检查定时器 */\n\tprotected healthCheckTimer: NodeJS.Timeout | null = null;\n\t/** 实际使用的端口 */\n\tprotected port: number;\n\t/** 服务器配置 */\n\tprotected readonly config: ServerConfig;\n\t/** 标准输出缓冲区 */\n\tprotected stdoutBuffer = \"\";\n\t/** 标准错误输出缓冲区 */\n\tprotected stderrBuffer = \"\";\n\t/** 标记是否正在主动停止（用于区分正常关闭和意外退出） */\n\tprotected isStopping = false;\n\n\tconstructor(config: ServerConfig, defaultPort: number) {\n\t\tthis.config = config;\n\t\tthis.port = defaultPort;\n\t}\n\n\t/**\n\t * 启动服务器（由子类实现）\n\t */\n\tabstract start(options?: { waitForReady?: boolean }): Promise<void>;\n\n\t/**\n\t * 获取服务器 URL\n\t */\n\tgetUrl(): string {\n\t\treturn `http://localhost:${this.port}`;\n\t}\n\n\t/**\n\t * 获取当前端口\n\t */\n\tgetPort(): number {\n\t\treturn this.port;\n\t}\n\n\t/**\n\t * 检查进程是否正在运行\n\t */\n\tisRunning(): boolean {\n\t\treturn this.process !== null && !this.process.killed;\n\t}\n\n\t/**\n\t * 停止服务器\n\t * @param waitForExit 是否等待进程退出（默认 false）\n\t * @returns Promise，如果 waitForExit 为 true，则等待进程退出后 resolve\n\t */\n\tstop(waitForExit = false): Promise<void> | void {\n\t\tthis.isStopping = true;\n\t\tthis.stopHealthCheck();\n\t\tif (this.process) {\n\t\t\tlogger.info(`Stopping ${this.config.name}...`);\n\t\t\tconst proc = this.process;\n\t\t\tproc.kill(\"SIGTERM\");\n\n\t\t\tif (waitForExit) {\n\t\t\t\treturn new Promise((resolve) => {\n\t\t\t\t\tconst timeout = setTimeout(() => {\n\t\t\t\t\t\tlogger.warn(`${this.config.name} did not exit within 3 seconds, forcing exit...`);\n\t\t\t\t\t\tif (proc && !proc.killed) {\n\t\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t\tproc.kill(\"SIGKILL\");\n\t\t\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\t\t\tlogger.warn(`Failed to kill ${this.config.name}: ${err instanceof Error ? err.message : String(err)}`);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\tthis.process = null;\n\t\t\t\t\t\tresolve();\n\t\t\t\t\t}, 3000);\n\n\t\t\t\t\tproc.once(\"exit\", () => {\n\t\t\t\t\t\tclearTimeout(timeout);\n\t\t\t\t\t\tthis.process = null;\n\t\t\t\t\t\tlogger.info(`${this.config.name} exited`);\n\t\t\t\t\t\tresolve();\n\t\t\t\t\t});\n\t\t\t\t});\n\t\t\t} else {\n\t\t\t\tthis.process = null;\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * 检查是否正在主动停止\n\t */\n\tisIntentionallyStopping(): boolean {\n\t\treturn this.isStopping;\n\t}\n\n\t/**\n\t * 等待服务器就绪\n\t * @param url 服务器 URL\n\t * @param timeout 超时时间（毫秒）\n\t */\n\tprotected waitForReady(url: string, timeout: number): Promise<void> {\n\t\treturn new Promise((resolve, reject) => {\n\t\t\tconst startTime = Date.now();\n\t\t\tconst { acceptedStatusCodes } = this.config;\n\t\t\tconst minStatus = acceptedStatusCodes?.min ?? 200;\n\t\t\tconst maxStatus = acceptedStatusCodes?.max ?? 400;\n\n\t\t\tconst check = () => {\n\t\t\t\tconst checkUrl = this.config.healthEndpoint\n\t\t\t\t\t? `${url}${this.config.healthEndpoint}`\n\t\t\t\t\t: url;\n\n\t\t\t\thttp\n\t\t\t\t\t.get(checkUrl, (res) => {\n\t\t\t\t\t\tconst statusCode = res.statusCode ?? 0;\n\t\t\t\t\t\tif (statusCode >= minStatus && statusCode < maxStatus) {\n\t\t\t\t\t\t\tlogger.info(\n\t\t\t\t\t\t\t\t`${this.config.name} health check passed: ${statusCode}`,\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\tresolve();\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tretry();\n\t\t\t\t\t\t}\n\t\t\t\t\t})\n\t\t\t\t\t.on(\"error\", (err) => {\n\t\t\t\t\t\tconst elapsed = Date.now() - startTime;\n\t\t\t\t\t\t// 每 10 秒记录一次\n\t\t\t\t\t\tif (elapsed % 10000 < TIMEOUT_CONFIG.healthCheckRetry) {\n\t\t\t\t\t\t\tlogger.info(\n\t\t\t\t\t\t\t\t`${this.config.name} health check failed (${elapsed}ms elapsed): ${err.message}`,\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t}\n\t\t\t\t\t\tretry();\n\t\t\t\t\t})\n\t\t\t\t\t.setTimeout(TIMEOUT_CONFIG.healthCheck, () => {\n\t\t\t\t\t\tretry();\n\t\t\t\t\t});\n\t\t\t};\n\n\t\t\tconst retry = () => {\n\t\t\t\tif (Date.now() - startTime >= timeout) {\n\t\t\t\t\treject(\n\t\t\t\t\t\tnew Error(\n\t\t\t\t\t\t\t`${this.config.name} did not start within ${timeout}ms`,\n\t\t\t\t\t\t),\n\t\t\t\t\t);\n\t\t\t\t} else {\n\t\t\t\t\tsetTimeout(check, TIMEOUT_CONFIG.healthCheckRetry);\n\t\t\t\t}\n\t\t\t};\n\n\t\t\tcheck();\n\t\t});\n\t}\n\n\t/**\n\t * 启动定期健康检查\n\t */\n\tprotected startHealthCheck(): void {\n\t\tif (this.healthCheckTimer) {\n\t\t\tclearInterval(this.healthCheckTimer);\n\t\t}\n\n\t\tconst url = this.getUrl();\n\t\tconst { acceptedStatusCodes } = this.config;\n\t\tconst minStatus = acceptedStatusCodes?.min ?? 200;\n\t\tconst maxStatus = acceptedStatusCodes?.max ?? 400;\n\n\t\tthis.healthCheckTimer = setInterval(() => {\n\t\t\tif (!this.isRunning()) {\n\t\t\t\tlogger.warn(`${this.config.name} process is not running`);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst checkUrl = this.config.healthEndpoint\n\t\t\t\t? `${url}${this.config.healthEndpoint}`\n\t\t\t\t: url;\n\n\t\t\thttp\n\t\t\t\t.get(checkUrl, (res) => {\n\t\t\t\t\tconst statusCode = res.statusCode ?? 0;\n\t\t\t\t\tif (statusCode < minStatus || statusCode >= maxStatus) {\n\t\t\t\t\t\tlogger.warn(\n\t\t\t\t\t\t\t`${this.config.name} returned status ${statusCode}`,\n\t\t\t\t\t\t);\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t\t.on(\"error\", (error) => {\n\t\t\t\t\tlogger.warn(\n\t\t\t\t\t\t`${this.config.name} health check failed: ${error.message}`,\n\t\t\t\t\t);\n\t\t\t\t\tif (this.isRunning()) {\n\t\t\t\t\t\tlogger.warn(\n\t\t\t\t\t\t\t`${this.config.name} process exists but not responding`,\n\t\t\t\t\t\t);\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t\t.setTimeout(TIMEOUT_CONFIG.healthCheck, () => {\n\t\t\t\t\tlogger.warn(`${this.config.name} health check timeout`);\n\t\t\t\t});\n\t\t}, this.config.healthCheckInterval);\n\t}\n\n\t/**\n\t * 停止健康检查\n\t */\n\tprotected stopHealthCheck(): void {\n\t\tif (this.healthCheckTimer) {\n\t\t\tclearInterval(this.healthCheckTimer);\n\t\t\tthis.healthCheckTimer = null;\n\t\t}\n\t}\n\n\t/**\n\t * 设置子进程的输出监听器\n\t * @param proc 子进程实例\n\t */\n\tprotected setupProcessOutputListeners(proc: ChildProcess): void {\n\t\tif (proc.stdout) {\n\t\t\tproc.stdout.setEncoding(\"utf8\");\n\t\t\tproc.stdout.on(\"data\", (data) => {\n\t\t\t\tconst output = String(data);\n\t\t\t\tthis.stdoutBuffer += output;\n\t\t\t\tlogger.stdout(this.config.name, output);\n\t\t\t});\n\t\t\tproc.stdout.on(\"end\", () => {\n\t\t\t\tlogger.info(`${this.config.name} stdout stream ended`);\n\t\t\t});\n\t\t\tproc.stdout.on(\"error\", (err) => {\n\t\t\t\tlogger.error(`${this.config.name} stdout stream error: ${err.message}`);\n\t\t\t});\n\t\t}\n\n\t\tif (proc.stderr) {\n\t\t\tproc.stderr.setEncoding(\"utf8\");\n\t\t\tproc.stderr.on(\"data\", (data) => {\n\t\t\t\tconst output = String(data);\n\t\t\t\tthis.stderrBuffer += output;\n\t\t\t\tlogger.stderr(this.config.name, output);\n\t\t\t});\n\t\t\tproc.stderr.on(\"end\", () => {\n\t\t\t\tlogger.info(`${this.config.name} stderr stream ended`);\n\t\t\t});\n\t\t\tproc.stderr.on(\"error\", (err) => {\n\t\t\t\tlogger.error(`${this.config.name} stderr stream error: ${err.message}`);\n\t\t\t});\n\t\t}\n\t}\n\n\t/**\n\t * 获取输出缓冲区内容（用于错误报告）\n\t */\n\tgetOutputBuffers(): { stdout: string; stderr: string } {\n\t\treturn {\n\t\t\tstdout: this.stdoutBuffer,\n\t\t\tstderr: this.stderrBuffer,\n\t\t};\n\t}\n\n\t/**\n\t * 清空输出缓冲区\n\t */\n\tclearOutputBuffers(): void {\n\t\tthis.stdoutBuffer = \"\";\n\t\tthis.stderrBuffer = \"\";\n\t}\n}\n"
  },
  {
    "path": "free-todo-frontend/electron/python-runtime-command.ts",
    "content": "/**\n * Shared command runner for Python runtime bootstrap\n */\n\nimport { spawn } from \"node:child_process\";\nimport {\n\tclearActiveProcess,\n\tisCancelled,\n\tonCancel,\n\tsetActiveProcess,\n} from \"./bootstrap-control\";\n\nexport type CommandResult = {\n\tcode: number | null;\n\tstdout: string;\n\tstderr: string;\n};\n\nexport type CommandOptions = {\n\tcwd?: string;\n\tenv?: NodeJS.ProcessEnv;\n\twindowsHide?: boolean;\n\tonStdout?: (chunk: string) => void;\n\tonStderr?: (chunk: string) => void;\n};\n\nexport async function runCommand(\n\tcommand: string,\n\targs: string[],\n\toptions: CommandOptions = {},\n): Promise<CommandResult> {\n\tif (isCancelled()) {\n\t\treturn { code: 1, stdout: \"\", stderr: \"Installation cancelled\" };\n\t}\n\treturn new Promise((resolve) => {\n\t\tconst child = spawn(command, args, {\n\t\t\tcwd: options.cwd,\n\t\t\tenv: options.env,\n\t\t\twindowsHide: options.windowsHide ?? true,\n\t\t});\n\t\tsetActiveProcess(child);\n\t\tconst unsubscribe = onCancel(() => {\n\t\t\ttry {\n\t\t\t\tchild.kill();\n\t\t\t} catch {\n\t\t\t\t// ignore kill errors\n\t\t\t}\n\t\t});\n\n\t\tlet stdout = \"\";\n\t\tlet stderr = \"\";\n\n\t\tchild.stdout?.on(\"data\", (data) => {\n\t\t\tstdout += data.toString();\n\t\t\toptions.onStdout?.(data.toString());\n\t\t});\n\t\tchild.stderr?.on(\"data\", (data) => {\n\t\t\tstderr += data.toString();\n\t\t\toptions.onStderr?.(data.toString());\n\t\t});\n\n\t\tchild.on(\"close\", (code) => {\n\t\t\tunsubscribe();\n\t\t\tclearActiveProcess(child);\n\t\t\tresolve({ code, stdout, stderr });\n\t\t});\n\n\t\tchild.on(\"error\", (error) => {\n\t\t\tunsubscribe();\n\t\t\tclearActiveProcess(child);\n\t\t\tresolve({ code: 1, stdout: \"\", stderr: error.message });\n\t\t});\n\t});\n}\n"
  },
  {
    "path": "free-todo-frontend/electron/python-runtime-env.ts",
    "content": "/**\n * Python runtime environment helpers (mirrors, region detection)\n */\n\nimport { app } from \"electron\";\nimport { emitLog } from \"./bootstrap-status\";\nimport { runCommand } from \"./python-runtime-command\";\n\nconst PIP_INDEX_CN = \"https://pypi.tuna.tsinghua.edu.cn/simple\";\nconst PIP_INDEX_GLOBAL = \"https://pypi.org/simple\";\n\nlet pipMirrorLogged = false;\nlet condaMirrorConfigured = false;\n\nfunction isMainlandChina(): boolean {\n\tconst override = process.env.FREETODO_REGION?.toLowerCase();\n\tif (override === \"cn\") {\n\t\treturn true;\n\t}\n\tif (override === \"global\" || override === \"intl\") {\n\t\treturn false;\n\t}\n\tconst locale = app.getLocale?.() ?? \"\";\n\tconst languages = app.getPreferredSystemLanguages?.() ?? [];\n\tconst timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone ?? \"\";\n\tif (locale.toLowerCase().startsWith(\"zh-cn\")) {\n\t\treturn true;\n\t}\n\tif (languages.some((lang) => lang.toLowerCase().startsWith(\"zh-cn\"))) {\n\t\treturn true;\n\t}\n\treturn [\n\t\t\"Asia/Shanghai\",\n\t\t\"Asia/Chongqing\",\n\t\t\"Asia/Harbin\",\n\t\t\"Asia/Urumqi\",\n\t\t\"Asia/Beijing\",\n\t].includes(timeZone);\n}\n\nexport function getPipEnv(): NodeJS.ProcessEnv {\n\tconst useCn = isMainlandChina();\n\tif (!pipMirrorLogged) {\n\t\tconst label = useCn ? \"清华源\" : \"PyPI 官方源\";\n\t\temitLog(`Pip index selected: ${label}`);\n\t\tpipMirrorLogged = true;\n\t}\n\tconst baseEnv: NodeJS.ProcessEnv = {\n\t\t...process.env,\n\t\tPIP_DISABLE_PIP_VERSION_CHECK: \"1\",\n\t\tPIP_NO_INPUT: \"1\",\n\t};\n\tif (useCn) {\n\t\treturn {\n\t\t\t...baseEnv,\n\t\t\tPIP_INDEX_URL: PIP_INDEX_CN,\n\t\t\tPIP_EXTRA_INDEX_URL: PIP_INDEX_GLOBAL,\n\t\t};\n\t}\n\treturn {\n\t\t...baseEnv,\n\t\tPIP_INDEX_URL: PIP_INDEX_GLOBAL,\n\t};\n}\n\nexport function getUvEnv(): NodeJS.ProcessEnv {\n\tconst pipEnv = getPipEnv();\n\tconst useCn = isMainlandChina();\n\tif (useCn) {\n\t\treturn {\n\t\t\t...pipEnv,\n\t\t\tUV_INDEX_URL: PIP_INDEX_CN,\n\t\t\tUV_EXTRA_INDEX_URL: PIP_INDEX_GLOBAL,\n\t\t};\n\t}\n\treturn {\n\t\t...pipEnv,\n\t\tUV_INDEX_URL: PIP_INDEX_GLOBAL,\n\t};\n}\n\nexport async function configureCondaMirror(): Promise<void> {\n\tif (condaMirrorConfigured || !isMainlandChina()) {\n\t\treturn;\n\t}\n\tconst condaCheck = await runCommand(\"conda\", [\"--version\"]);\n\tif (condaCheck.code !== 0) {\n\t\temitLog(\"Conda not found; skipping mirror config.\");\n\t\treturn;\n\t}\n\tconst commands: string[][] = [\n\t\t[\"config\", \"--set\", \"show_channel_urls\", \"yes\"],\n\t\t[\n\t\t\t\"config\",\n\t\t\t\"--add\",\n\t\t\t\"channels\",\n\t\t\t\"https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/main/\",\n\t\t],\n\t\t[\n\t\t\t\"config\",\n\t\t\t\"--add\",\n\t\t\t\"channels\",\n\t\t\t\"https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/free/\",\n\t\t],\n\t\t[\n\t\t\t\"config\",\n\t\t\t\"--add\",\n\t\t\t\"channels\",\n\t\t\t\"https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/r/\",\n\t\t],\n\t];\n\tfor (const args of commands) {\n\t\tconst result = await runCommand(\"conda\", args);\n\t\tif (result.code !== 0) {\n\t\t\temitLog(`Conda mirror config failed: ${result.stderr || result.stdout}`);\n\t\t\treturn;\n\t\t}\n\t}\n\tcondaMirrorConfigured = true;\n\temitLog(\"Conda mirror configured to Tsinghua.\");\n}\n"
  },
  {
    "path": "free-todo-frontend/electron/python-runtime-installer.ts",
    "content": "/**\n * Python 3.12 installer helpers (download + system install)\n */\n\nimport fs from \"node:fs\";\nimport https from \"node:https\";\nimport path from \"node:path\";\nimport { pipeline } from \"node:stream/promises\";\nimport { app } from \"electron\";\nimport { onCancel } from \"./bootstrap-control\";\nimport { emitLog, emitStatus } from \"./bootstrap-status\";\nimport { logger } from \"./logger\";\nimport { runCommand } from \"./python-runtime-command\";\n\nconst PYTHON_VERSION_FALLBACK = \"3.12.9\";\nconst PYTHON_DOWNLOAD_BASE = \"https://www.python.org/ftp/python\";\nconst PYTHON_RELEASES_API =\n\t\"https://www.python.org/api/v2/downloads/release/?is_published=1\";\n\ntype PythonRelease = {\n\tversion?: string;\n\tname?: string;\n};\n\nasync function fetchJson<T>(url: string): Promise<T> {\n\treturn await new Promise<T>((resolve, reject) => {\n\t\tconst request = https.get(url, (response) => {\n\t\t\tconst status = response.statusCode ?? 0;\n\t\t\tif (status !== 200) {\n\t\t\t\treject(new Error(`Request failed (${status}) from ${url}`));\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst chunks: Array<Buffer> = [];\n\t\t\tresponse.on(\"data\", (chunk) => chunks.push(Buffer.from(chunk)));\n\t\t\tresponse.on(\"end\", () => {\n\t\t\t\ttry {\n\t\t\t\t\tconst json = JSON.parse(Buffer.concat(chunks).toString(\"utf8\")) as T;\n\t\t\t\t\tresolve(json);\n\t\t\t\t} catch (error) {\n\t\t\t\t\treject(error);\n\t\t\t\t}\n\t\t\t});\n\t\t});\n\n\t\tconst unsubscribe = onCancel(() => {\n\t\t\trequest.destroy(new Error(\"Installation cancelled\"));\n\t\t});\n\n\t\trequest.on(\"error\", (error) => {\n\t\t\tunsubscribe();\n\t\t\treject(error);\n\t\t});\n\t});\n}\n\nfunction compareSemver(a: string, b: string): number {\n\tconst aParts = a.split(\".\").map((part) => Number(part));\n\tconst bParts = b.split(\".\").map((part) => Number(part));\n\tconst length = Math.max(aParts.length, bParts.length);\n\tfor (let index = 0; index < length; index += 1) {\n\t\tconst aValue = aParts[index] ?? 0;\n\t\tconst bValue = bParts[index] ?? 0;\n\t\tif (aValue !== bValue) {\n\t\t\treturn aValue - bValue;\n\t\t}\n\t}\n\treturn 0;\n}\n\nasync function getLatestPython312Version(): Promise<string> {\n\ttry {\n\t\tconst releases = await fetchJson<PythonRelease[]>(PYTHON_RELEASES_API);\n\t\tconst versions = releases\n\t\t\t.map((release) => release.version ?? release.name ?? \"\")\n\t\t\t.filter((version) => version.startsWith(\"3.12.\"));\n\n\t\tif (versions.length === 0) {\n\t\t\tlogger.warn(\"No Python 3.12 release found, using fallback.\");\n\t\t\treturn PYTHON_VERSION_FALLBACK;\n\t\t}\n\n\t\tconst sorted = versions.sort(compareSemver);\n\t\tconst latest = sorted[sorted.length - 1] ?? PYTHON_VERSION_FALLBACK;\n\t\tlogger.info(`Resolved Python 3.12 version: ${latest}`);\n\t\treturn latest;\n\t} catch (error) {\n\t\tlogger.warn(`Failed to resolve latest Python 3.12: ${String(error)}`);\n\t\treturn PYTHON_VERSION_FALLBACK;\n\t}\n}\n\nasync function downloadFile(\n\turl: string,\n\tdestination: string,\n\tredirectsLeft = 5,\n): Promise<void> {\n\tawait new Promise<void>((resolve, reject) => {\n\t\tconst request = https.get(url, (response) => {\n\t\t\tconst status = response.statusCode ?? 0;\n\t\t\tconst location = response.headers.location;\n\t\t\tif (status >= 300 && status < 400 && location && redirectsLeft > 0) {\n\t\t\t\tresponse.resume();\n\t\t\t\tconst redirectUrl = new URL(location, url).toString();\n\t\t\t\tdownloadFile(redirectUrl, destination, redirectsLeft - 1)\n\t\t\t\t\t.then(resolve)\n\t\t\t\t\t.catch(reject);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (status !== 200) {\n\t\t\t\treject(new Error(`Download failed (${status}) from ${url}`));\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst fileStream = fs.createWriteStream(destination);\n\t\t\tpipeline(response, fileStream)\n\t\t\t\t.then(() => {\n\t\t\t\t\tunsubscribe();\n\t\t\t\t\tresolve();\n\t\t\t\t})\n\t\t\t\t.catch((error) => {\n\t\t\t\t\tunsubscribe();\n\t\t\t\t\treject(error);\n\t\t\t\t});\n\t\t});\n\n\t\tconst unsubscribe = onCancel(() => {\n\t\t\trequest.destroy(new Error(\"Installation cancelled\"));\n\t\t});\n\n\t\trequest.on(\"error\", (error) => {\n\t\t\tunsubscribe();\n\t\t\treject(error);\n\t\t});\n\t});\n}\n\nasync function installPythonWindows(version: string): Promise<void> {\n\temitStatus({ message: \"安装 Python 3.12\", progress: 20 });\n\tconst wingetCheck = await runCommand(\"winget\", [\"--version\"]);\n\tif (wingetCheck.code === 0) {\n\t\tlogger.info(\"Installing Python via winget...\");\n\t\temitLog(\"Using winget to install Python 3.12...\");\n\t\tconst install = await runCommand(\n\t\t\t\"winget\",\n\t\t\t[\n\t\t\t\t\"install\",\n\t\t\t\t\"--id\",\n\t\t\t\t\"Python.Python.3.12\",\n\t\t\t\t\"--exact\",\n\t\t\t\t\"--silent\",\n\t\t\t\t\"--accept-source-agreements\",\n\t\t\t\t\"--accept-package-agreements\",\n\t\t\t\t\"--scope\",\n\t\t\t\t\"user\",\n\t\t\t],\n\t\t\t{ onStdout: emitLog, onStderr: emitLog },\n\t\t);\n\t\tif (install.code === 0) {\n\t\t\temitLog(\"Winget install completed.\");\n\t\t\treturn;\n\t\t}\n\t\tlogger.warn(`Winget install failed: ${install.stderr || install.stdout}`);\n\t\temitLog(`Winget install failed: ${install.stderr || install.stdout}`);\n\t}\n\n\tconst arch = process.arch === \"arm64\" ? \"arm64\" : \"amd64\";\n\tconst fileName = `python-${version}-${arch}.exe`;\n\tconst url = `${PYTHON_DOWNLOAD_BASE}/${version}/${fileName}`;\n\tconst tempDir = path.join(app.getPath(\"temp\"), \"freetodo-python\");\n\tfs.mkdirSync(tempDir, { recursive: true });\n\tconst installerPath = path.join(tempDir, fileName);\n\n\tlogger.info(`Downloading Python installer from ${url}`);\n\temitStatus({ message: \"下载 Python 安装包\", progress: 25, detail: url });\n\tawait downloadFile(url, installerPath);\n\n\tlogger.info(\"Running Python installer...\");\n\temitStatus({ message: \"运行 Python 安装程序\", progress: 35 });\n\tconst install = await runCommand(installerPath, [\n\t\t\"/quiet\",\n\t\t\"InstallAllUsers=0\",\n\t\t\"PrependPath=1\",\n\t\t\"Include_test=0\",\n\t]);\n\n\tif (install.code !== 0) {\n\t\tthrow new Error(`Python installer failed: ${install.stderr || install.stdout}`);\n\t}\n}\n\nasync function installPythonMac(version: string): Promise<void> {\n\temitStatus({ message: \"安装 Python 3.12\", progress: 20 });\n\tconst fileName = `python-${version}-macos11.pkg`;\n\tconst url = `${PYTHON_DOWNLOAD_BASE}/${version}/${fileName}`;\n\tconst tempDir = path.join(app.getPath(\"temp\"), \"freetodo-python\");\n\tfs.mkdirSync(tempDir, { recursive: true });\n\tconst pkgPath = path.join(tempDir, fileName);\n\n\tlogger.info(`Downloading Python installer from ${url}`);\n\temitStatus({ message: \"下载 Python 安装包\", progress: 25, detail: url });\n\tawait downloadFile(url, pkgPath);\n\n\tconst installerCommand = `installer -pkg \"${pkgPath}\" -target /`;\n\tconst script = `do shell script \"${installerCommand.replace(/\"/g, '\\\\\"')}\" with administrator privileges`;\n\n\tlogger.info(\"Running Python installer with admin privileges...\");\n\temitStatus({ message: \"运行 Python 安装程序\", progress: 35 });\n\tconst install = await runCommand(\"osascript\", [\"-e\", script]);\n\tif (install.code !== 0) {\n\t\tthrow new Error(`Python installer failed: ${install.stderr || install.stdout}`);\n\t}\n}\n\nasync function installPythonLinux(): Promise<void> {\n\temitStatus({ message: \"安装 Python 3.12\", progress: 20 });\n\tconst installers: Array<{ command: string; args: string[] }> = [\n\t\t{ command: \"apt-get\", args: [\"install\", \"-y\", \"python3.12\", \"python3.12-venv\"] },\n\t\t{ command: \"dnf\", args: [\"install\", \"-y\", \"python3.12\"] },\n\t\t{ command: \"zypper\", args: [\"--non-interactive\", \"install\", \"python312\"] },\n\t];\n\n\tfor (const installer of installers) {\n\t\temitLog(`Attempting ${installer.command} install...`);\n\t\tconst result = await runCommand(\"pkexec\", [\n\t\t\tinstaller.command,\n\t\t\t...installer.args,\n\t\t]);\n\t\tif (result.code === 0) {\n\t\t\temitLog(`${installer.command} install completed.`);\n\t\t\treturn;\n\t\t}\n\t\tlogger.warn(`Linux installer failed: ${result.stderr || result.stdout}`);\n\t\temitLog(`Linux installer failed: ${result.stderr || result.stdout}`);\n\t}\n\n\tthrow new Error(\"Automatic Python install failed on Linux.\");\n}\n\nexport async function installPython312(): Promise<void> {\n\tconst version = await getLatestPython312Version();\n\tif (process.platform === \"win32\") {\n\t\tawait installPythonWindows(version);\n\t\treturn;\n\t}\n\tif (process.platform === \"darwin\") {\n\t\tawait installPythonMac(version);\n\t\treturn;\n\t}\n\tif (process.platform === \"linux\") {\n\t\tawait installPythonLinux();\n\t\treturn;\n\t}\n\tthrow new Error(\"Unsupported platform for Python install.\");\n}\n"
  },
  {
    "path": "free-todo-frontend/electron/python-runtime.ts",
    "content": "/**\n * Python runtime bootstrapper\n * Ensures Python 3.12 and backend dependencies are installed before starting the backend.\n */\n\nimport crypto from \"node:crypto\";\nimport fs from \"node:fs\";\nimport path from \"node:path\";\nimport { dialog } from \"electron\";\nimport { isCancelled } from \"./bootstrap-control\";\nimport { emitLog, emitStatus } from \"./bootstrap-status\";\nimport { logger } from \"./logger\";\nimport { runCommand } from \"./python-runtime-command\";\nimport { configureCondaMirror, getPipEnv, getUvEnv } from \"./python-runtime-env\";\nimport { installPython312 } from \"./python-runtime-installer\";\n\nconst REQUIRED_PYTHON_MAJOR = 3;\nconst REQUIRED_PYTHON_MINOR = 12;\nconst PYTHON_VERSION_SHORT = `${REQUIRED_PYTHON_MAJOR}.${REQUIRED_PYTHON_MINOR}`;\nconst DEP_MARKER_FILE = \".freetodo-deps.json\";\nconst RUNTIME_MANIFEST_FILE = \".freetodo-runtime.json\";\n\nlet preferredPythonPath: string | null = null;\n\ntype PythonInfo = {\n\texecutable: string;\n\tversion: string;\n\tprefix: string;\n\tisConda: boolean;\n};\n\nexport function setPreferredPythonPath(value: string | null): void {\n\tpreferredPythonPath = value;\n}\n\nfunction getVenvPythonPath(venvDir: string): string {\n\tif (process.platform === \"win32\") {\n\t\treturn path.join(venvDir, \"Scripts\", \"python.exe\");\n\t}\n\treturn path.join(venvDir, \"bin\", \"python3\");\n}\n\nfunction getVenvUvPath(venvDir: string): string {\n\tif (process.platform === \"win32\") {\n\t\treturn path.join(venvDir, \"Scripts\", \"uv.exe\");\n\t}\n\treturn path.join(venvDir, \"bin\", \"uv\");\n}\n\nfunction readFileHash(filePath: string): string {\n\tconst contents = fs.readFileSync(filePath);\n\treturn crypto.createHash(\"sha256\").update(contents).digest(\"hex\");\n}\n\nfunction readDepsMarker(venvDir: string): { requirementsHash?: string } | null {\n\tconst markerPath = path.join(venvDir, DEP_MARKER_FILE);\n\tif (!fs.existsSync(markerPath)) {\n\t\treturn null;\n\t}\n\ttry {\n\t\tconst raw = fs.readFileSync(markerPath, \"utf8\");\n\t\treturn JSON.parse(raw) as { requirementsHash?: string };\n\t} catch {\n\t\treturn null;\n\t}\n}\n\ntype RuntimeManifest = {\n\tpythonPath: string;\n\tvenvPath?: string;\n\trequirementsHash: string;\n\tcreatedAt: string;\n};\n\nfunction readRuntimeManifest(runtimeRoot: string): RuntimeManifest | null {\n\tconst manifestPath = path.join(runtimeRoot, RUNTIME_MANIFEST_FILE);\n\tif (!fs.existsSync(manifestPath)) {\n\t\treturn null;\n\t}\n\ttry {\n\t\tconst raw = fs.readFileSync(manifestPath, \"utf8\");\n\t\treturn JSON.parse(raw) as RuntimeManifest;\n\t} catch {\n\t\treturn null;\n\t}\n}\n\nfunction writeRuntimeManifest(runtimeRoot: string, manifest: RuntimeManifest): void {\n\tconst manifestPath = path.join(runtimeRoot, RUNTIME_MANIFEST_FILE);\n\tfs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));\n}\n\nfunction writeDepsMarker(venvDir: string, requirementsHash: string): void {\n\tconst markerPath = path.join(venvDir, DEP_MARKER_FILE);\n\tconst payload = {\n\t\trequirementsHash,\n\t\tcreatedAt: new Date().toISOString(),\n\t};\n\tfs.writeFileSync(markerPath, JSON.stringify(payload, null, 2));\n}\n\nfunction isRequiredPython(version: string): boolean {\n\treturn version.trim() === PYTHON_VERSION_SHORT;\n}\n\nfunction normalizeOutput(value: string): string {\n\treturn value.replace(/\\r/g, \"\").trim();\n}\n\nfunction assertNotCancelled(): void {\n\tif (isCancelled()) {\n\t\tthrow new Error(\"Installation cancelled\");\n\t}\n}\n\nfunction getVersionFromOutput(output: string): PythonInfo | null {\n\tconst line = normalizeOutput(output).split(\"\\n\")[0];\n\tif (!line) {\n\t\treturn null;\n\t}\n\ttry {\n\t\tconst parsed = JSON.parse(line) as {\n\t\t\tversion?: string;\n\t\t\texecutable?: string;\n\t\t\tprefix?: string;\n\t\t\tis_conda?: boolean;\n\t\t};\n\t\tif (!parsed.version || !parsed.executable || !parsed.prefix) {\n\t\t\treturn null;\n\t\t}\n\t\treturn {\n\t\t\tversion: parsed.version.trim(),\n\t\t\texecutable: parsed.executable.trim(),\n\t\t\tprefix: parsed.prefix.trim(),\n\t\t\tisConda: Boolean(parsed.is_conda),\n\t\t};\n\t} catch {\n\t\treturn null;\n\t}\n}\n\nasync function getPythonInfo(command: string, args: string[]): Promise<PythonInfo | null> {\n\tconst result = await runCommand(command, [\n\t\t...args,\n\t\t\"-c\",\n\t\t\"import json, os, sys; prefix=sys.prefix; is_conda=os.path.exists(os.path.join(prefix, 'conda-meta')); print(json.dumps({'version': f'{sys.version_info[0]}.{sys.version_info[1]}', 'executable': sys.executable, 'prefix': prefix, 'is_conda': is_conda}))\",\n\t]);\n\tif (result.code !== 0) {\n\t\treturn null;\n\t}\n\treturn getVersionFromOutput(result.stdout);\n}\n\nexport async function validatePythonPath(pythonPath: string): Promise<PythonInfo | null> {\n\tif (!pythonPath) {\n\t\treturn null;\n\t}\n\treturn getPythonInfo(pythonPath, []);\n}\n\nexport function isRuntimePrepared(\n\truntimeRoot: string,\n\tvenvDir: string,\n\trequirementsPath: string,\n): boolean {\n\tif (!fs.existsSync(requirementsPath)) {\n\t\treturn false;\n\t}\n\tconst manifest = readRuntimeManifest(runtimeRoot);\n\tif (!manifest) {\n\t\treturn false;\n\t}\n\tconst requirementsHash = readFileHash(requirementsPath);\n\tif (manifest.requirementsHash !== requirementsHash) {\n\t\treturn false;\n\t}\n\tif (manifest.venvPath) {\n\t\tconst venvPython = getVenvPythonPath(venvDir);\n\t\treturn fs.existsSync(venvPython);\n\t}\n\treturn fs.existsSync(manifest.pythonPath);\n}\n\nasync function findInstalledPython312(): Promise<PythonInfo | null> {\n\tconst candidates: Array<{ command: string; args: string[] }> = [];\n\n\tif (process.platform === \"win32\") {\n\t\tcandidates.push({ command: \"py\", args: [\"-3.12\"] });\n\t\tcandidates.push({ command: \"python3.12\", args: [] });\n\t\tcandidates.push({ command: \"python\", args: [] });\n\t\tcandidates.push({ command: \"python3\", args: [] });\n\t} else {\n\t\tcandidates.push({ command: \"python3.12\", args: [] });\n\t\tcandidates.push({ command: \"python3\", args: [] });\n\t\tcandidates.push({ command: \"python\", args: [] });\n\t}\n\n\tfor (const candidate of candidates) {\n\t\tconst info = await getPythonInfo(candidate.command, candidate.args);\n\t\tif (!info || !isRequiredPython(info.version)) {\n\t\t\tcontinue;\n\t\t}\n\t\tif (fs.existsSync(info.executable)) {\n\t\t\treturn info;\n\t\t}\n\t}\n\n\tconst fallbackPaths: string[] = [];\n\tif (process.platform === \"win32\") {\n\t\tconst localAppData = process.env.LOCALAPPDATA ?? \"\";\n\t\tconst programFiles = process.env.ProgramFiles ?? \"\";\n\t\tconst programFilesX86 = process.env[\"ProgramFiles(x86)\"] ?? \"\";\n\t\tfallbackPaths.push(\n\t\t\tpath.join(localAppData, \"Programs\", \"Python\", \"Python312\", \"python.exe\"),\n\t\t\tpath.join(programFiles, \"Python312\", \"python.exe\"),\n\t\t\tpath.join(programFilesX86, \"Python312\", \"python.exe\"),\n\t\t);\n\t} else if (process.platform === \"darwin\") {\n\t\tfallbackPaths.push(\n\t\t\t\"/usr/local/bin/python3.12\",\n\t\t\t\"/opt/homebrew/bin/python3.12\",\n\t\t\t\"/Library/Frameworks/Python.framework/Versions/3.12/bin/python3.12\",\n\t\t);\n\t} else {\n\t\tfallbackPaths.push(\"/usr/bin/python3.12\", \"/usr/local/bin/python3.12\");\n\t}\n\n\tfor (const candidatePath of fallbackPaths) {\n\t\tif (!candidatePath || !fs.existsSync(candidatePath)) {\n\t\t\tcontinue;\n\t\t}\n\t\tconst info = await getPythonInfo(candidatePath, []);\n\t\tif (info && isRequiredPython(info.version)) {\n\t\t\treturn info;\n\t\t}\n\t}\n\n\treturn null;\n}\n\nasync function ensurePython312Installed(): Promise<PythonInfo> {\n\temitStatus({ message: \"检查 Python 3.12\", progress: 10 });\n\tif (preferredPythonPath) {\n\t\tconst preferredInfo = await validatePythonPath(preferredPythonPath);\n\t\tif (preferredInfo && isRequiredPython(preferredInfo.version)) {\n\t\t\temitLog(`Using selected Python: ${preferredInfo.executable}`);\n\t\t\treturn preferredInfo;\n\t\t}\n\t\temitLog(\"Selected Python is not compatible with 3.12.\");\n\t}\n\n\tconst existing = await findInstalledPython312();\n\tif (existing) {\n\t\temitLog(`Found Python 3.12 at ${existing.executable}`);\n\t\treturn existing;\n\t}\n\n\twhile (true) {\n\t\tconst response = await dialog.showMessageBox({\n\t\t\ttype: \"info\",\n\t\t\tbuttons: [\"选择已有 Python\", \"自动安装\", \"取消\"],\n\t\t\tdefaultId: 1,\n\t\t\tcancelId: 2,\n\t\t\tmessage: \"FreeTodo 需要 Python 3.12 才能运行本地后端。\",\n\t\t\tdetail:\n\t\t\t\t\"你可以选择已有的 Python 3.12 环境，或者让程序自动安装。自动安装需要联网，可能会花费几分钟。\",\n\t\t});\n\n\t\tif (response.response === 0) {\n\t\t\tconst dialogOptions: Electron.OpenDialogOptions = {\n\t\t\t\tproperties: [\"openFile\"],\n\t\t\t\ttitle: \"选择 Python 3.12 可执行文件\",\n\t\t\t};\n\t\t\tif (process.platform === \"win32\") {\n\t\t\t\tdialogOptions.filters = [{ name: \"Python\", extensions: [\"exe\"] }];\n\t\t\t}\n\t\t\tconst selected = await dialog.showOpenDialog(dialogOptions);\n\t\t\tif (selected.canceled || selected.filePaths.length === 0) {\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tconst chosenPath = selected.filePaths[0];\n\t\t\tconst info = await validatePythonPath(chosenPath);\n\t\t\tif (!info || !isRequiredPython(info.version)) {\n\t\t\t\tdialog.showErrorBox(\"Python 版本不匹配\", \"请选择 Python 3.12 的可执行文件。\");\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tpreferredPythonPath = info.executable;\n\t\t\temitStatus({ pythonPath: info.executable });\n\t\t\treturn info;\n\t\t}\n\n\t\tif (response.response === 1) {\n\t\t\tbreak;\n\t\t}\n\n\t\tthrow new Error(\"Python 3.12 installation cancelled by user.\");\n\t}\n\n\tawait installPython312();\n\n\tconst installed = await findInstalledPython312();\n\tif (!installed) {\n\t\tthrow new Error(\"Python 3.12 installation completed but was not detected.\");\n\t}\n\n\treturn installed;\n}\n\nasync function ensureVenv(\n\tsystemPythonPath: string,\n\tvenvDir: string,\n): Promise<void> {\n\tif (fs.existsSync(getVenvPythonPath(venvDir))) {\n\t\treturn;\n\t}\n\temitStatus({ message: \"创建 Python 虚拟环境\", progress: 45 });\n\temitLog(`Creating virtual environment at: ${venvDir}`);\n\tfs.mkdirSync(venvDir, { recursive: true });\n\tconst uvCheck = await runCommand(\"uv\", [\"--version\"]);\n\tconst useUv = uvCheck.code === 0;\n\tconst result = useUv\n\t\t? await runCommand(\"uv\", [\"venv\", venvDir, \"--python\", systemPythonPath], {\n\t\t\t\tenv: getUvEnv(),\n\t\t  })\n\t\t: await runCommand(systemPythonPath, [\"-m\", \"venv\", venvDir]);\n\tif (result.code !== 0) {\n\t\tthrow new Error(\n\t\t\t`Failed to create venv: ${result.stderr || result.stdout}`,\n\t\t);\n\t}\n}\n\nasync function ensureUvInVenv(\n\tvenvPython: string,\n\tvenvDir: string,\n): Promise<string> {\n\tconst uvPath = getVenvUvPath(venvDir);\n\tif (fs.existsSync(uvPath)) {\n\t\treturn uvPath;\n\t}\n\temitStatus({ message: \"安装 uv\", progress: 52 });\n\temitLog(\"Installing uv into virtual environment...\");\n\tconst env = getPipEnv();\n\tconst install = await runCommand(\n\t\tvenvPython,\n\t\t[\"-m\", \"pip\", \"install\", \"--upgrade\", \"uv\"],\n\t\t{ env, onStdout: emitLog, onStderr: emitLog },\n\t);\n\tif (install.code !== 0) {\n\t\tthrow new Error(`Failed to install uv: ${install.stderr || install.stdout}`);\n\t}\n\tif (!fs.existsSync(uvPath)) {\n\t\tthrow new Error(\"uv installed but executable was not found in venv.\");\n\t}\n\treturn uvPath;\n}\n\nasync function ensureDependencies(\n\tvenvPython: string,\n\tvenvDir: string,\n\trequirementsPath: string,\n): Promise<void> {\n\tif (!fs.existsSync(requirementsPath)) {\n\t\tthrow new Error(`Requirements file not found: ${requirementsPath}`);\n\t}\n\temitLog(`Using requirements: ${requirementsPath}`);\n\n\tconst requirementsHash = readFileHash(requirementsPath);\n\tconst marker = readDepsMarker(venvDir);\n\tif (marker?.requirementsHash === requirementsHash) {\n\t\treturn;\n\t}\n\n\tawait dialog.showMessageBox({\n\t\ttype: \"info\",\n\t\tbuttons: [\"Continue\"],\n\t\tmessage: \"Installing backend dependencies\",\n\t\tdetail:\n\t\t\t\"This is the first launch. FreeTodo will now download and install Python dependencies. It may take several minutes depending on your network.\",\n\t});\n\n\tassertNotCancelled();\n\tconst uvPath = await ensureUvInVenv(venvPython, venvDir);\n\temitStatus({ message: \"安装后端依赖\", progress: 60 });\n\tconst env = getUvEnv();\n\tconst install = await runCommand(\n\t\tuvPath,\n\t\t[\"pip\", \"install\", \"-r\", requirementsPath, \"--python\", venvPython],\n\t\t{ env, onStdout: emitLog, onStderr: emitLog },\n\t);\n\tif (install.code !== 0) {\n\t\tthrow new Error(`Failed to install dependencies: ${install.stderr || install.stdout}`);\n\t}\n\n\twriteDepsMarker(venvDir, requirementsHash);\n}\n\nasync function ensureVenvPythonVersion(venvPython: string): Promise<boolean> {\n\tconst info = await getPythonInfo(venvPython, []);\n\treturn !!info && isRequiredPython(info.version);\n}\n\nexport async function ensurePythonRuntime(\n\tvenvDir: string,\n\trequirementsPath: string,\n): Promise<string> {\n\temitStatus({ message: \"准备 Python 运行时\", progress: 5 });\n\tconst venvPython = getVenvPythonPath(venvDir);\n\temitStatus({ venvPath: venvDir });\n\tassertNotCancelled();\n\tconst runtimeRoot = path.dirname(venvDir);\n\n\tif (fs.existsSync(venvPython)) {\n\t\tconst versionOk = await ensureVenvPythonVersion(venvPython);\n\t\tif (versionOk) {\n\t\t\temitStatus({ message: \"检查后端依赖\", progress: 55 });\n\t\t\tawait ensureDependencies(venvPython, venvDir, requirementsPath);\n\t\t\temitStatus({ message: \"Python 运行时就绪\", progress: 70 });\n\t\t\twriteRuntimeManifest(runtimeRoot, {\n\t\t\t\tpythonPath: venvPython,\n\t\t\t\tvenvPath: venvDir,\n\t\t\t\trequirementsHash: readFileHash(requirementsPath),\n\t\t\t\tcreatedAt: new Date().toISOString(),\n\t\t\t});\n\t\t\treturn venvPython;\n\t\t}\n\t\tlogger.warn(\"Existing venv does not match Python 3.12, recreating.\");\n\t\temitLog(\"Existing venv does not match Python 3.12, recreating.\");\n\t}\n\n\tconst systemPython = await ensurePython312Installed();\n\temitStatus({ pythonPath: systemPython.executable });\n\tassertNotCancelled();\n\tif (systemPython.isConda) {\n\t\temitLog(\"Detected conda environment.\");\n\t\tawait configureCondaMirror();\n\t}\n\n\tawait ensureVenv(systemPython.executable, venvDir);\n\temitStatus({ pythonPath: venvPython });\n\n\tif (!fs.existsSync(venvPython)) {\n\t\tthrow new Error(\"Virtual environment was created but python executable is missing.\");\n\t}\n\n\tawait ensureDependencies(venvPython, venvDir, requirementsPath);\n\temitStatus({ message: \"Python 运行时就绪\", progress: 70 });\n\twriteRuntimeManifest(runtimeRoot, {\n\t\tpythonPath: venvPython,\n\t\tvenvPath: venvDir,\n\t\trequirementsHash: readFileHash(requirementsPath),\n\t\tcreatedAt: new Date().toISOString(),\n\t});\n\treturn venvPython;\n}\n\nexport { getVenvPythonPath };\n"
  },
  {
    "path": "free-todo-frontend/electron/runtime-paths.ts",
    "content": "/**\n * Runtime path helpers\n */\n\nimport fs from \"node:fs\";\nimport path from \"node:path\";\nimport { app } from \"electron\";\nimport { emitLog, emitStatus } from \"./bootstrap-status\";\nimport { PROCESS_CONFIG } from \"./config\";\n\nexport function getInstallRoot(): string {\n\tif (!app.isPackaged) {\n\t\treturn path.resolve(__dirname, \"../..\");\n\t}\n\tif (process.platform === \"darwin\") {\n\t\treturn path.resolve(process.execPath, \"..\", \"..\", \"..\");\n\t}\n\treturn path.dirname(process.execPath);\n}\n\nfunction canWrite(dir: string): boolean {\n\ttry {\n\t\tfs.mkdirSync(dir, { recursive: true });\n\t\tconst testFile = path.join(dir, \".freetodo-write-test\");\n\t\tfs.writeFileSync(testFile, \"ok\");\n\t\tfs.unlinkSync(testFile);\n\t\treturn true;\n\t} catch {\n\t\treturn false;\n\t}\n}\n\nexport function resolveRuntimeRoot(): string {\n\tconst envOverride = process.env.FREETODO_RUNTIME_DIR;\n\tif (envOverride) {\n\t\tfs.mkdirSync(envOverride, { recursive: true });\n\t\treturn envOverride;\n\t}\n\n\tconst installRoot = getInstallRoot();\n\tconst preferred = path.join(installRoot, PROCESS_CONFIG.backendRuntimeDir);\n\tif (canWrite(preferred)) {\n\t\treturn preferred;\n\t}\n\n\temitLog(`Install directory not writable: ${preferred}`);\n\tconst fallback = path.join(app.getPath(\"userData\"), PROCESS_CONFIG.backendRuntimeDir);\n\tfs.mkdirSync(fallback, { recursive: true });\n\temitStatus({\n\t\tmessage: \"安装目录不可写，已切换运行时目录\",\n\t\tdetail: fallback,\n\t\tvenvPath: fallback,\n\t});\n\treturn fallback;\n}\n\nexport function resolveVenvDir(): string {\n\tconst runtimeRoot = resolveRuntimeRoot();\n\tconst venvDir = path.join(runtimeRoot, PROCESS_CONFIG.backendVenvDir);\n\treturn venvDir;\n}\n"
  },
  {
    "path": "free-todo-frontend/electron/tray-manager.ts",
    "content": "/**\n * System Tray / Menu Bar Manager\n * Manages the application tray icon and context menu\n * Provides extensible menu structure for future features\n */\n\nimport path from \"node:path\";\nimport { app, Menu, type MenuItemConstructorOptions, nativeImage, Tray } from \"electron\";\nimport type { IslandWindowManager } from \"./island-window-manager\";\nimport { logger } from \"./logger\";\n\n/**\n * TrayManager class\n * Manages system tray icon and menu for cross-platform support\n */\nexport class TrayManager {\n\t/** Tray instance */\n\tprivate tray: Tray | null = null;\n\t/** Island window manager reference */\n\tprivate islandWindowManager: IslandWindowManager;\n\t/** Context menu instance */\n\tprivate contextMenu: Menu | null = null;\n\n\t/**\n\t * Constructor\n\t * @param islandWindowManager Island window manager instance\n\t */\n\tconstructor(islandWindowManager: IslandWindowManager) {\n\t\tthis.islandWindowManager = islandWindowManager;\n\n\t\t// Set up visibility change callback to update tray icon\n\t\tthis.islandWindowManager.setVisibilityChangeCallback((visible) => {\n\t\t\tthis.onIslandVisibilityChange(visible);\n\t\t});\n\t}\n\n\t/**\n\t * Create and initialize the tray icon\n\t */\n\tcreate(): void {\n\t\tif (this.tray) {\n\t\t\tlogger.warn(\"Tray already exists\");\n\t\t\treturn;\n\t\t}\n\n\t\tconst iconPath = this.getTrayIconPath();\n\t\tif (!iconPath) {\n\t\t\tlogger.error(\"Failed to get tray icon path\");\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\t// Create tray icon\n\t\t\tconst icon = nativeImage.createFromPath(iconPath);\n\n\t\t\t// Resize for proper display (16x16 on macOS, 16x16 on Windows)\n\t\t\tconst resizedIcon = icon.resize({ width: 16, height: 16 });\n\n\t\t\tthis.tray = new Tray(resizedIcon);\n\n\t\t\t// Set tooltip\n\t\t\tthis.tray.setToolTip(\"Free Todo - Dynamic Island\");\n\n\t\t\t// Build context menu\n\t\t\tthis.buildContextMenu();\n\n\t\t\t// Set up event handlers\n\t\t\tthis.setupEventHandlers();\n\n\t\t\tlogger.info(\"Tray icon created successfully\");\n\t\t} catch (error) {\n\t\t\tlogger.error(`Failed to create tray icon: ${error instanceof Error ? error.message : String(error)}`);\n\t\t}\n\t}\n\n\t/**\n\t * Get the appropriate tray icon path based on platform\n\t */\n\tprivate getTrayIconPath(): string | null {\n\t\ttry {\n\t\t\t// Use the Free Todo icon as tray icon from public/free-todo-logos\n\t\t\tif (app.isPackaged) {\n\t\t\t\t// Production: use packaged public folder\n\t\t\t\tconst resourcesPath = process.resourcesPath;\n\t\t\t\treturn path.join(resourcesPath, \"standalone\", \"public\", \"free-todo-logos\", \"free_todo_icon_4.png\");\n\t\t\t}\n\n\t\t\t// Development: use public folder icons\n\t\t\treturn path.join(__dirname, \"..\", \"public\", \"free-todo-logos\", \"free_todo_icon_4.png\");\n\t\t} catch (error) {\n\t\t\tlogger.error(`Error getting tray icon path: ${error instanceof Error ? error.message : String(error)}`);\n\t\t\treturn null;\n\t\t}\n\t}\n\n\t/**\n\t * Build the context menu\n\t */\n\tprivate buildContextMenu(): void {\n\t\tconst menuTemplate: MenuItemConstructorOptions[] = [\n\t\t\t{\n\t\t\t\tlabel: \"Show/Hide Island\",\n\t\t\t\taccelerator: \"CommandOrControl+Shift+I\",\n\t\t\t\tclick: () => this.toggleIsland(),\n\t\t\t},\n\t\t\t{ type: \"separator\" },\n\t\t\t{\n\t\t\t\tlabel: \"Recording\",\n\t\t\t\tsubmenu: [\n\t\t\t\t\t{\n\t\t\t\t\t\tlabel: \"Start Recording\",\n\t\t\t\t\t\tenabled: false, // Future feature\n\t\t\t\t\t\tclick: () => this.startRecording(),\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tlabel: \"Stop Recording\",\n\t\t\t\t\t\tenabled: false, // Future feature\n\t\t\t\t\t\tclick: () => this.stopRecording(),\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t},\n\t\t\t{\n\t\t\t\tlabel: \"Screenshots\",\n\t\t\t\tsubmenu: [\n\t\t\t\t\t{\n\t\t\t\t\t\tlabel: \"Take Screenshot\",\n\t\t\t\t\t\tenabled: false, // Future feature\n\t\t\t\t\t\tclick: () => this.takeScreenshot(),\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tlabel: \"View Recent...\",\n\t\t\t\t\t\tenabled: false, // Future feature\n\t\t\t\t\t\tclick: () => this.viewScreenshots(),\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t},\n\t\t\t{ type: \"separator\" },\n\t\t\t{\n\t\t\t\tlabel: \"Preferences...\",\n\t\t\t\tclick: () => this.openPreferences(),\n\t\t\t},\n\t\t\t{ type: \"separator\" },\n\t\t\t{\n\t\t\t\tlabel: \"Quit Free Todo\",\n\t\t\t\trole: \"quit\",\n\t\t\t},\n\t\t];\n\n\t\tthis.contextMenu = Menu.buildFromTemplate(menuTemplate);\n\t\tthis.tray?.setContextMenu(this.contextMenu);\n\t}\n\n\t/**\n\t * Setup tray event handlers\n\t */\n\tprivate setupEventHandlers(): void {\n\t\tif (!this.tray) return;\n\n\t\t// Left-click: toggle island visibility\n\t\tthis.tray.on(\"click\", () => {\n\t\t\tthis.toggleIsland();\n\t\t});\n\n\t\t// Right-click: show context menu (handled automatically on Windows)\n\t\t// On macOS, we need to handle it explicitly\n\t\tif (process.platform === \"darwin\") {\n\t\t\tthis.tray.on(\"right-click\", () => {\n\t\t\t\tif (this.contextMenu && this.tray) {\n\t\t\t\t\tthis.tray.popUpContextMenu(this.contextMenu);\n\t\t\t\t}\n\t\t\t});\n\t\t}\n\t}\n\n\t/**\n\t * Toggle island window visibility\n\t */\n\tprivate toggleIsland(): void {\n\t\ttry {\n\t\t\tthis.islandWindowManager.toggle();\n\t\t\tthis.updateTrayIcon();\n\t\t} catch (error) {\n\t\t\tlogger.error(`Failed to toggle island: ${error instanceof Error ? error.message : String(error)}`);\n\t\t}\n\t}\n\n\t/**\n\t * Update tray icon appearance based on island visibility\n\t * Future: could show different icon states\n\t */\n\tprivate updateTrayIcon(): void {\n\t\tif (!this.tray) return;\n\n\t\tconst isVisible = this.islandWindowManager.isVisible();\n\n\t\t// Update tooltip to reflect current state\n\t\tthis.tray.setToolTip(\n\t\t\tisVisible\n\t\t\t\t? \"Free Todo - Dynamic Island (Visible)\"\n\t\t\t\t: \"Free Todo - Dynamic Island (Hidden)\"\n\t\t);\n\n\t\t// Future: could change icon appearance here\n\t\t// For example, use a dimmed icon when hidden\n\t}\n\n\t/**\n\t * Handle island visibility change events\n\t * @param visible Current visibility state\n\t */\n\tprivate onIslandVisibilityChange(visible: boolean): void {\n\t\tthis.updateTrayIcon();\n\t\tlogger.info(`Tray updated: Island is now ${visible ? \"visible\" : \"hidden\"}`);\n\t}\n\n\t/**\n\t * Show the island window\n\t */\n\tshow(): void {\n\t\tthis.islandWindowManager.show();\n\t\tthis.updateTrayIcon();\n\t}\n\n\t/**\n\t * Hide the island window\n\t */\n\thide(): void {\n\t\tthis.islandWindowManager.hide();\n\t\tthis.updateTrayIcon();\n\t}\n\n\t/**\n\t * Update the context menu\n\t * Call this when menu state needs to change\n\t */\n\tupdateMenu(): void {\n\t\tthis.buildContextMenu();\n\t}\n\n\t/**\n\t * Destroy the tray icon\n\t */\n\tdestroy(): void {\n\t\tif (this.tray) {\n\t\t\tthis.tray.destroy();\n\t\t\tthis.tray = null;\n\t\t\tlogger.info(\"Tray icon destroyed\");\n\t\t}\n\t}\n\n\t/**\n\t * Get tray instance\n\t */\n\tgetTray(): Tray | null {\n\t\treturn this.tray;\n\t}\n\n\t// ========== Future Feature Placeholders ==========\n\n\t/**\n\t * Start recording (future feature)\n\t */\n\tprivate startRecording(): void {\n\t\tlogger.info(\"Start recording - feature not yet implemented\");\n\t\t// TODO: Implement recording functionality\n\t}\n\n\t/**\n\t * Stop recording (future feature)\n\t */\n\tprivate stopRecording(): void {\n\t\tlogger.info(\"Stop recording - feature not yet implemented\");\n\t\t// TODO: Implement recording functionality\n\t}\n\n\t/**\n\t * Take screenshot (future feature)\n\t */\n\tprivate takeScreenshot(): void {\n\t\tlogger.info(\"Take screenshot - feature not yet implemented\");\n\t\t// TODO: Implement screenshot functionality\n\t}\n\n\t/**\n\t * View screenshots (future feature)\n\t */\n\tprivate viewScreenshots(): void {\n\t\tlogger.info(\"View screenshots - feature not yet implemented\");\n\t\t// TODO: Implement screenshot viewer\n\t}\n\n\t/**\n\t * Open preferences window (future feature)\n\t */\n\tprivate openPreferences(): void {\n\t\tlogger.info(\"Open preferences - feature not yet implemented\");\n\t\t// TODO: Implement preferences window\n\t\t// For now, just show the island\n\t\tthis.show();\n\t}\n}\n"
  },
  {
    "path": "free-todo-frontend/electron/tsconfig.json",
    "content": "{\n\t\"compilerOptions\": {\n\t\t\"target\": \"ES2020\",\n\t\t\"module\": \"CommonJS\",\n\t\t\"moduleResolution\": \"node\",\n\t\t\"strict\": true,\n\t\t\"esModuleInterop\": true,\n\t\t\"skipLibCheck\": true,\n\t\t\"forceConsistentCasingInFileNames\": true,\n\t\t\"outDir\": \"../dist-electron\",\n\t\t\"rootDir\": \".\",\n\t\t\"declaration\": false,\n\t\t\"sourceMap\": true\n\t},\n\t\"include\": [\"./**/*.ts\"],\n\t\"exclude\": [\"node_modules\"]\n}\n"
  },
  {
    "path": "free-todo-frontend/electron/window-manager.ts",
    "content": "/**\n * 窗口管理服务\n * 封装 BrowserWindow 创建和事件处理\n */\n\nimport http from \"node:http\";\nimport path from \"node:path\";\nimport { app, BrowserWindow, dialog } from \"electron\";\nimport {\n\tWINDOW_CONFIG,\n} from \"./config\";\nimport { logger } from \"./logger\";\n\n/**\n * 窗口管理器类\n * 负责主窗口的创建、管理和事件处理\n */\nexport class WindowManager {\n\t/** 主窗口实例 */\n\tprivate mainWindow: BrowserWindow | null = null;\n\t/** 保存窗口的原始位置和尺寸（用于从全屏模式恢复） */\n\tprivate originalBounds: {\n\t\tx: number;\n\t\ty: number;\n\t\twidth: number;\n\t\theight: number;\n\t} | null = null;\n\n\t/**\n\t * 获取 preload 脚本路径\n\t */\n\tprivate getPreloadPath(): string {\n\t\tif (app.isPackaged) {\n\t\t\t// 打包环境：preload.js 在 dist-electron 目录下（和 main.js 在同一目录）\n\t\t\treturn path.join(app.getAppPath(), \"dist-electron\", \"preload.js\");\n\t\t}\n\t\t// 开发环境：使用编译后的文件路径（dist-electron 目录）\n\t\treturn path.join(__dirname, \"preload.js\");\n\t}\n\n\t/**\n\t * 等待服务器就绪\n\t * @param url 服务器 URL\n\t * @param timeout 超时时间（毫秒）\n\t */\n\tprivate async waitForServer(url: string, timeout: number): Promise<void> {\n\t\treturn new Promise((resolve, reject) => {\n\t\t\tconst startTime = Date.now();\n\n\t\t\tconst check = () => {\n\t\t\t\thttp\n\t\t\t\t\t.get(url, (res) => {\n\t\t\t\t\t\tif (res.statusCode === 200 || res.statusCode === 304) {\n\t\t\t\t\t\t\tresolve();\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tretry();\n\t\t\t\t\t\t}\n\t\t\t\t\t})\n\t\t\t\t\t.on(\"error\", () => {\n\t\t\t\t\t\tretry();\n\t\t\t\t\t});\n\t\t\t};\n\n\t\t\tconst retry = () => {\n\t\t\t\tif (Date.now() - startTime >= timeout) {\n\t\t\t\t\treject(new Error(`Server did not start within ${timeout}ms`));\n\t\t\t\t} else {\n\t\t\t\t\tsetTimeout(check, 500);\n\t\t\t\t}\n\t\t\t};\n\n\t\t\tcheck();\n\t\t});\n\t}\n\n\t/**\n\t * 获取原始窗口边界\n\t */\n\tgetOriginalBounds(): typeof this.originalBounds {\n\t\treturn this.originalBounds;\n\t}\n\n\t/**\n\t * 创建主窗口\n\t * @param serverUrl 前端服务器 URL\n\t */\n\tcreate(\n\t\tserverUrl: string,\n\t\toptions?: { waitForServer?: boolean; showLoading?: boolean },\n\t): void {\n\t\tconst { waitForServer = true, showLoading = false } = options ?? {};\n\t\tconst preloadPath = this.getPreloadPath();\n\n\t\t// 保存原始位置和尺寸（用于从全屏模式恢复）\n\t\tif (!this.originalBounds) {\n\t\t\tthis.originalBounds = {\n\t\t\t\tx: 0,\n\t\t\t\ty: 0,\n\t\t\t\twidth: WINDOW_CONFIG.width,\n\t\t\t\theight: WINDOW_CONFIG.height,\n\t\t\t};\n\t\t}\n\n\t\tthis.mainWindow = new BrowserWindow({\n\t\t\twidth: WINDOW_CONFIG.width,\n\t\t\theight: WINDOW_CONFIG.height,\n\t\t\tx: 0,\n\t\t\ty: 0,\n\t\t\tminWidth: WINDOW_CONFIG.minWidth,\n\t\t\tminHeight: WINDOW_CONFIG.minHeight,\n\t\t\tframe: true,\n\t\t\ttransparent: false,\n\t\t\talwaysOnTop: false,\n\t\t\thasShadow: true,\n\t\t\tresizable: true,\n\t\t\tmovable: true,\n\t\t\tskipTaskbar: false,\n\t\t\twebPreferences: {\n\t\t\t\tnodeIntegration: false,\n\t\t\t\tcontextIsolation: true,\n\t\t\t\tpreload: preloadPath,\n\t\t\t},\n\t\t\tshow: false, // 等待内容加载完成再显示\n\t\t\tbackgroundColor: WINDOW_CONFIG.backgroundColor,\n\t\t});\n\n\n\t\t// 监听页面加载完成，检查 preload 脚本是否正确加载\n\t\tthis.mainWindow.webContents.once(\"did-finish-load\", () => {\n\t\t\tlogger.info(\"Page finished loading, checking preload script...\");\n\t\t\t// 注入调试代码检查 electronAPI\n\t\t\tthis.mainWindow?.webContents\n\t\t\t\t.executeJavaScript(`\n\t\t\t\t(function() {\n\t\t\t\t\tconst hasElectronAPI = typeof window.electronAPI !== 'undefined';\n\t\t\t\t\tconst result = {\n\t\t\t\t\t\thasElectronAPI,\n\t\t\t\t\t\telectronAPIKeys: hasElectronAPI ? Object.keys(window.electronAPI) : [],\n\t\t\t\t\t\tuserAgent: navigator.userAgent,\n\t\t\t\t\t};\n\t\t\t\t\tconsole.log('[Electron Main] Preload script check:', result);\n\t\t\t\t\treturn result;\n\t\t\t\t})();\n\t\t\t`)\n\t\t\t\t.then((result) => {\n\t\t\t\t\tlogger.info(\n\t\t\t\t\t\t`Preload script check result: ${JSON.stringify(result, null, 2)}`,\n\t\t\t\t\t);\n\t\t\t\t\tif (!result.hasElectronAPI) {\n\t\t\t\t\t\tlogger.warn(\n\t\t\t\t\t\t\t\"WARNING: electronAPI is not available in renderer process!\",\n\t\t\t\t\t\t);\n\t\t\t\t\t\tconsole.warn(\n\t\t\t\t\t\t\t\"[WARN] electronAPI is not available. Check preload script loading.\",\n\t\t\t\t\t\t);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tlogger.info(\"✅ electronAPI is available in renderer process\");\n\t\t\t\t\t\tlogger.info(`Available methods: ${result.electronAPIKeys.join(\", \")}`);\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t\t.catch((err) => {\n\t\t\t\t\tlogger.error(`Error checking preload script: ${err instanceof Error ? err.message : String(err)}`);\n\t\t\t\t\tconsole.error(\"Error checking preload script:\", err);\n\t\t\t\t});\n\t\t});\n\n\t\t// 设置 ready-to-show 事件监听器\n\t\tthis.mainWindow.once(\"ready-to-show\", () => {\n\t\t\tif (this.mainWindow) {\n\t\t\t\tthis.mainWindow.show();\n\t\t\t\tlogger.info(\"Window is ready to show\");\n\t\t\t}\n\t\t});\n\n\t\t// 拦截导航，防止加载到错误的 URL（如 DevTools URL）\n\t\tthis.mainWindow.webContents.on(\"will-navigate\", (event, navigationUrl) => {\n\t\t\tconst parsedUrl = new URL(navigationUrl);\n\t\t\t// 只允许加载 localhost:PORT 的 URL\n\t\t\tif (\n\t\t\t\tparsedUrl.hostname !== \"localhost\" &&\n\t\t\t\tparsedUrl.hostname !== \"127.0.0.1\"\n\t\t\t) {\n\t\t\t\tevent.preventDefault();\n\t\t\t\tlogger.info(`Navigation blocked to: ${navigationUrl}`);\n\t\t\t}\n\t\t\t// 阻止加载 DevTools URL\n\t\t\tif (navigationUrl.startsWith(\"devtools://\")) {\n\t\t\t\tevent.preventDefault();\n\t\t\t\tlogger.info(`DevTools URL blocked: ${navigationUrl}`);\n\t\t\t}\n\t\t});\n\n\t\t// 窗口关闭\n\t\tthis.mainWindow.on(\"closed\", () => {\n\t\t\tlogger.info(\"Window closed\");\n\t\t\tthis.mainWindow = null;\n\t\t});\n\n\t\t// 处理窗口加载失败\n\t\tthis.mainWindow.webContents.on(\n\t\t\t\"did-fail-load\",\n\t\t\t(_event, errorCode, errorDescription) => {\n\t\t\t\tconst errorMsg = `Window failed to load: ${errorCode} - ${errorDescription}`;\n\t\t\t\tlogger.error(errorMsg);\n\t\t\t\tconsole.error(errorMsg);\n\n\t\t\t\t// 连接被拒绝或名称解析失败\n\t\t\t\tif (errorCode === -106 || errorCode === -105) {\n\t\t\t\t\tdialog.showErrorBox(\n\t\t\t\t\t\t\"Connection Error\",\n\t\t\t\t\t\t`Failed to connect to server at ${serverUrl}\\n\\nError: ${errorDescription}\\n\\nCheck logs at: ${logger.getLogFilePath()}`,\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t},\n\t\t);\n\n\t\t// 处理渲染进程崩溃\n\t\tthis.mainWindow.webContents.on(\"render-process-gone\", (_event, details) => {\n\t\t\tconst errorMsg = `Render process crashed: ${details.reason} (exit code: ${details.exitCode})`;\n\t\t\tlogger.fatal(errorMsg);\n\t\t\tconsole.error(errorMsg);\n\n\t\t\tdialog.showErrorBox(\n\t\t\t\t\"Application Crashed\",\n\t\t\t\t`The application window crashed:\\n${details.reason}\\n\\nCheck logs at: ${logger.getLogFilePath()}`,\n\t\t\t);\n\t\t});\n\n\t\t// 处理未捕获的异常\n\t\tthis.mainWindow.webContents.on(\"unresponsive\", () => {\n\t\t\tlogger.warn(\"Window became unresponsive\");\n\t\t});\n\n\t\tthis.mainWindow.webContents.on(\"responsive\", () => {\n\t\t\tlogger.info(\"Window became responsive again\");\n\t\t});\n\n\t\tif (showLoading && this.mainWindow) {\n\t\t\tconst loadingHtml = this.getLoadingPageHtml();\n\t\t\tconst dataUrl = `data:text/html;charset=utf-8,${encodeURIComponent(loadingHtml)}`;\n\t\t\tthis.mainWindow.loadURL(dataUrl);\n\t\t}\n\n\t\t// 确保服务器已经启动后再加载 URL\n\t\tconst loadWindow = async () => {\n\t\t\ttry {\n\t\t\t\t// 确保服务器就绪\n\t\t\t\tawait this.waitForServer(serverUrl, 5000);\n\t\t\t\tlogger.info(`Loading URL: ${serverUrl}`);\n\t\t\t\tif (this.mainWindow && !this.mainWindow.isDestroyed()) {\n\t\t\t\t\tthis.mainWindow.loadURL(serverUrl);\n\t\t\t\t}\n\t\t\t} catch (error) {\n\t\t\t\tlogger.warn(\n\t\t\t\t\t`Failed to verify server, loading URL anyway: ${error instanceof Error ? error.message : String(error)}`,\n\t\t\t\t);\n\t\t\t\t// 即使检查失败，也尝试加载（可能服务器刚启动）\n\t\t\t\tif (this.mainWindow && !this.mainWindow.isDestroyed()) {\n\t\t\t\t\tthis.mainWindow.loadURL(serverUrl);\n\t\t\t\t}\n\t\t\t}\n\t\t};\n\n\t\tif (waitForServer) {\n\t\t\t// 延迟一点加载，确保窗口完全创建\n\t\t\tsetTimeout(() => {\n\t\t\t\tloadWindow();\n\t\t\t}, 100);\n\t\t}\n\t}\n\n\t/**\n\t * 主动加载指定 URL（用于延迟加载）\n\t */\n\tload(serverUrl: string): void {\n\t\tif (this.mainWindow && !this.mainWindow.isDestroyed()) {\n\t\t\tthis.mainWindow.loadURL(serverUrl);\n\t\t}\n\t}\n\n\t/**\n\t * 内置加载界面\n\t */\n\tprivate getLoadingPageHtml(): string {\n\t\treturn `\n<!doctype html>\n<html lang=\"zh\">\n  <head>\n    <meta charset=\"utf-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n    <title>FreeTodo 加载中</title>\n    <style>\n      html, body { margin: 0; padding: 0; width: 100%; height: 100%; background: #0f1115; color: #e5e7eb; font-family: \"Segoe UI\", Arial, sans-serif; }\n      .wrap { display: flex; align-items: center; justify-content: center; height: 100%; flex-direction: column; gap: 14px; }\n      .logo { font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; font-size: 14px; color: #9ca3af; }\n      .spinner { width: 32px; height: 32px; border-radius: 50%; border: 3px solid #2b303b; border-top-color: #3b82f6; animation: spin 1s linear infinite; }\n      .hint { font-size: 13px; color: #9ca3af; }\n      @keyframes spin { to { transform: rotate(360deg); } }\n    </style>\n  </head>\n  <body>\n    <div class=\"wrap\">\n      <div class=\"spinner\"></div>\n      <div class=\"logo\">FreeTodo</div>\n      <div class=\"hint\">正在启动服务...</div>\n    </div>\n  </body>\n</html>\n`;\n\t}\n\n\n\t/**\n\t * 聚焦窗口\n\t * 如果窗口最小化则恢复，然后聚焦\n\t */\n\tfocus(): void {\n\t\tif (this.mainWindow) {\n\t\t\tif (this.mainWindow.isMinimized()) {\n\t\t\t\tthis.mainWindow.restore();\n\t\t\t}\n\t\t\tthis.mainWindow.focus();\n\t\t}\n\t}\n\n\t/**\n\t * 获取主窗口实例\n\t */\n\tgetWindow(): BrowserWindow | null {\n\t\treturn this.mainWindow;\n\t}\n\n\n\t/**\n\t * 检查窗口是否存在\n\t */\n\thasWindow(): boolean {\n\t\treturn this.mainWindow !== null;\n\t}\n\n\t/**\n\t * 检查是否有任何窗口打开\n\t */\n\tstatic hasAnyWindows(): boolean {\n\t\treturn BrowserWindow.getAllWindows().length > 0;\n\t}\n}\n"
  },
  {
    "path": "free-todo-frontend/electron-builder.island.pyinstaller.yml",
    "content": "appId: com.freeugroup.freetodo.island\nproductName: FreeTodo Island\ncopyright: Copyright © 2026 FreeU Group\n\ndirectories:\n  output: dist-artifacts/electron/island/pyinstaller\n  buildResources: build\n\nfiles:\n  - 'dist-electron/**/*'\n\nextraResources:\n  - from: '.next/standalone'\n    to: 'standalone'\n    filter:\n      - '**/*'\n      - '!**/.pnpm/**'\n  - from: '.next/standalone/node_modules'\n    to: 'standalone/node_modules'\n    filter:\n      - '**/*'\n  - from: '.next/static'\n    to: 'standalone/.next/static'\n    filter:\n      - '**/*'\n  - from: 'public'\n    to: 'standalone/public'\n    filter:\n      - '**/*'\n  - from: '../dist-backend'\n    to: 'backend'\n    filter:\n      - '**/*'\n\nwin:\n  target:\n    - target: nsis\n      arch:\n        - x64\n  icon: public/favicon.ico\n  artifactName: '${productName}-${version}-pyinstaller-win-${arch}.${ext}'\n\nnsis:\n  oneClick: false\n  perMachine: false\n  allowToChangeInstallationDirectory: true\n  deleteAppDataOnUninstall: false\n  createDesktopShortcut: true\n  createStartMenuShortcut: true\n  include: build/installer.nsh\n\nmac:\n  target:\n    - target: dmg\n      arch:\n        - x64\n        - arm64\n  icon: public/logo.png\n  category: public.app-category.productivity\n  artifactName: '${productName}-${version}-pyinstaller-mac-${arch}.${ext}'\n\ndmg:\n  contents:\n    - x: 130\n      y: 220\n    - x: 410\n      y: 220\n      type: link\n      path: /Applications\n\nlinux:\n  target:\n    - target: AppImage\n      arch:\n        - x64\n  icon: public/logo.png\n  category: Utility\n  artifactName: '${productName}-${version}-pyinstaller-linux-${arch}.${ext}'\n\nasar: false\n"
  },
  {
    "path": "free-todo-frontend/electron-builder.island.script.yml",
    "content": "appId: com.freeugroup.freetodo.island\nproductName: FreeTodo Island\ncopyright: Copyright © 2026 FreeU Group\n\ndirectories:\n  output: dist-artifacts/electron/island/script\n  buildResources: build\n\nfiles:\n  - 'dist-electron/**/*'\n\nextraResources:\n  - from: '.next/standalone'\n    to: 'standalone'\n    filter:\n      - '**/*'\n      - '!**/.pnpm/**'\n  - from: '.next/standalone/node_modules'\n    to: 'standalone/node_modules'\n    filter:\n      - '**/*'\n  - from: '.next/static'\n    to: 'standalone/.next/static'\n    filter:\n      - '**/*'\n  - from: 'public'\n    to: 'standalone/public'\n    filter:\n      - '**/*'\n  - from: '../lifetrace'\n    to: 'backend/lifetrace'\n    filter:\n      - '**/*'\n      - '!**/__pycache__/**'\n      - '!**/*.pyc'\n      - '!**/data/**'\n      - '!**/config/config.yaml'\n      - '!**/.venv/**'\n      - '!**/.ruff_cache/**'\n  - from: '../requirements-runtime.txt'\n    to: 'backend/requirements-runtime.txt'\n\nwin:\n  target:\n    - target: nsis\n      arch:\n        - x64\n  icon: public/favicon.ico\n  artifactName: '${productName}-${version}-script-win-${arch}.${ext}'\n\nnsis:\n  oneClick: false\n  perMachine: false\n  allowToChangeInstallationDirectory: true\n  deleteAppDataOnUninstall: false\n  createDesktopShortcut: true\n  createStartMenuShortcut: true\n  include: build/installer.nsh\n\nmac:\n  target:\n    - target: dmg\n      arch:\n        - x64\n        - arm64\n  icon: public/logo.png\n  category: public.app-category.productivity\n  artifactName: '${productName}-${version}-script-mac-${arch}.${ext}'\n\ndmg:\n  contents:\n    - x: 130\n      y: 220\n    - x: 410\n      y: 220\n      type: link\n      path: /Applications\n\nlinux:\n  target:\n    - target: AppImage\n      arch:\n        - x64\n  icon: public/logo.png\n  category: Utility\n  artifactName: '${productName}-${version}-script-linux-${arch}.${ext}'\n\nasar: false\n"
  },
  {
    "path": "free-todo-frontend/electron-builder.island.yml",
    "content": "appId: com.freeugroup.freetodo\nproductName: FreeTodo\ncopyright: Copyright © 2026 FreeU Group\n\ndirectories:\n  output: dist-artifacts/electron/island\n  buildResources: build\n\nfiles:\n  - 'dist-electron/**/*'\n\nextraResources:\n  - from: '.next/standalone'\n    to: 'standalone'\n    filter:\n      - '**/*'\n      - '!**/.pnpm/**'\n  - from: '.next/standalone/node_modules'\n    to: 'standalone/node_modules'\n    filter:\n      - '**/*'\n  - from: '.next/static'\n    to: 'standalone/.next/static'\n    filter:\n      - '**/*'\n  - from: 'public'\n    to: 'standalone/public'\n    filter:\n      - '**/*'\n  - from: '../lifetrace'\n    to: 'backend/lifetrace'\n    filter:\n      - '**/*'\n      - '!**/__pycache__/**'\n      - '!**/*.pyc'\n      - '!**/data/**'\n      - '!**/config/config.yaml'\n      - '!**/.venv/**'\n      - '!**/.ruff_cache/**'\n  - from: '../requirements-runtime.txt'\n    to: 'backend/requirements-runtime.txt'\n\nwin:\n  target:\n    - target: nsis\n      arch:\n        - x64\n  icon: public/favicon.ico\n  artifactName: '${productName}-${version}-island-win-${arch}.${ext}'\n\nnsis:\n  oneClick: false\n  perMachine: false\n  allowToChangeInstallationDirectory: true\n  deleteAppDataOnUninstall: false\n  createDesktopShortcut: true\n  createStartMenuShortcut: true\n  include: build/installer.nsh\n\nmac:\n  target:\n    - target: dmg\n      arch:\n        - x64\n        - arm64\n  icon: public/logo.png\n  category: public.app-category.productivity\n  artifactName: '${productName}-${version}-island-mac-${arch}.${ext}'\n\ndmg:\n  contents:\n    - x: 130\n      y: 220\n    - x: 410\n      y: 220\n      type: link\n      path: /Applications\n\nlinux:\n  target:\n    - target: AppImage\n      arch:\n        - x64\n  icon: public/logo.png\n  category: Utility\n  artifactName: '${productName}-${version}-island-linux-${arch}.${ext}'\n\nasar: false\n"
  },
  {
    "path": "free-todo-frontend/electron-builder.web.pyinstaller.yml",
    "content": "appId: com.freeugroup.freetodo\nproductName: FreeTodo\ncopyright: Copyright © 2026 FreeU Group\n\ndirectories:\n  output: dist-artifacts/electron/web/pyinstaller\n  buildResources: build\n\nfiles:\n  - 'dist-electron/**/*'\n\nextraResources:\n  - from: '.next/standalone'\n    to: 'standalone'\n    filter:\n      - '**/*'\n      - '!**/.pnpm/**'\n  - from: '.next/standalone/node_modules'\n    to: 'standalone/node_modules'\n    filter:\n      - '**/*'\n  - from: '.next/static'\n    to: 'standalone/.next/static'\n    filter:\n      - '**/*'\n  - from: 'public'\n    to: 'standalone/public'\n    filter:\n      - '**/*'\n  - from: '../dist-backend'\n    to: 'backend'\n    filter:\n      - '**/*'\n\nwin:\n  target:\n    - target: nsis\n      arch:\n        - x64\n  icon: public/favicon.ico\n  artifactName: '${productName}-${version}-web-pyinstaller-win-${arch}.${ext}'\n\nnsis:\n  oneClick: false\n  perMachine: false\n  allowToChangeInstallationDirectory: true\n  deleteAppDataOnUninstall: false\n  createDesktopShortcut: true\n  createStartMenuShortcut: true\n  include: build/installer.nsh\n\nmac:\n  target:\n    - target: dmg\n      arch:\n        - x64\n        - arm64\n  icon: public/logo.png\n  category: public.app-category.productivity\n  artifactName: '${productName}-${version}-web-pyinstaller-mac-${arch}.${ext}'\n\ndmg:\n  contents:\n    - x: 130\n      y: 220\n    - x: 410\n      y: 220\n      type: link\n      path: /Applications\n\nlinux:\n  target:\n    - target: AppImage\n      arch:\n        - x64\n  icon: public/logo.png\n  category: Utility\n  artifactName: '${productName}-${version}-web-pyinstaller-linux-${arch}.${ext}'\n\nasar: false\n"
  },
  {
    "path": "free-todo-frontend/electron-builder.web.script.yml",
    "content": "appId: com.freeugroup.freetodo\nproductName: FreeTodo\ncopyright: Copyright © 2026 FreeU Group\n\ndirectories:\n  output: dist-artifacts/electron/web/script\n  buildResources: build\n\nfiles:\n  - 'dist-electron/**/*'\n\nextraResources:\n  - from: '.next/standalone'\n    to: 'standalone'\n    filter:\n      - '**/*'\n      - '!**/.pnpm/**'\n  - from: '.next/standalone/node_modules'\n    to: 'standalone/node_modules'\n    filter:\n      - '**/*'\n  - from: '.next/static'\n    to: 'standalone/.next/static'\n    filter:\n      - '**/*'\n  - from: 'public'\n    to: 'standalone/public'\n    filter:\n      - '**/*'\n  - from: '../lifetrace'\n    to: 'backend/lifetrace'\n    filter:\n      - '**/*'\n      - '!**/__pycache__/**'\n      - '!**/*.pyc'\n      - '!**/data/**'\n      - '!**/config/config.yaml'\n      - '!**/.venv/**'\n      - '!**/.ruff_cache/**'\n  - from: '../requirements-runtime.txt'\n    to: 'backend/requirements-runtime.txt'\n\nwin:\n  target:\n    - target: nsis\n      arch:\n        - x64\n  icon: public/favicon.ico\n  artifactName: '${productName}-${version}-web-script-win-${arch}.${ext}'\n\nnsis:\n  oneClick: false\n  perMachine: false\n  allowToChangeInstallationDirectory: true\n  deleteAppDataOnUninstall: false\n  createDesktopShortcut: true\n  createStartMenuShortcut: true\n  include: build/installer.nsh\n\nmac:\n  target:\n    - target: dmg\n      arch:\n        - x64\n        - arm64\n  icon: public/logo.png\n  category: public.app-category.productivity\n  artifactName: '${productName}-${version}-web-script-mac-${arch}.${ext}'\n\ndmg:\n  contents:\n    - x: 130\n      y: 220\n    - x: 410\n      y: 220\n      type: link\n      path: /Applications\n\nlinux:\n  target:\n    - target: AppImage\n      arch:\n        - x64\n  icon: public/logo.png\n  category: Utility\n  artifactName: '${productName}-${version}-web-script-linux-${arch}.${ext}'\n\nasar: false\n"
  },
  {
    "path": "free-todo-frontend/electron-builder.web.yml",
    "content": "appId: com.freeugroup.freetodo\nproductName: FreeTodo\ncopyright: Copyright © 2026 FreeU Group\n\ndirectories:\n  output: dist-artifacts/electron/web\n  buildResources: build\n\nfiles:\n  - 'dist-electron/**/*'\n\nextraResources:\n  - from: '.next/standalone'\n    to: 'standalone'\n    filter:\n      - '**/*'\n      - '!**/.pnpm/**'\n  - from: '.next/standalone/node_modules'\n    to: 'standalone/node_modules'\n    filter:\n      - '**/*'\n  - from: '.next/static'\n    to: 'standalone/.next/static'\n    filter:\n      - '**/*'\n  - from: 'public'\n    to: 'standalone/public'\n    filter:\n      - '**/*'\n  - from: '../lifetrace'\n    to: 'backend/lifetrace'\n    filter:\n      - '**/*'\n      - '!**/__pycache__/**'\n      - '!**/*.pyc'\n      - '!**/data/**'\n      - '!**/config/config.yaml'\n      - '!**/.venv/**'\n      - '!**/.ruff_cache/**'\n  - from: '../requirements-runtime.txt'\n    to: 'backend/requirements-runtime.txt'\n\nwin:\n  target:\n    - target: nsis\n      arch:\n        - x64\n  icon: public/favicon.ico\n  artifactName: '${productName}-${version}-web-win-${arch}.${ext}'\n\nnsis:\n  oneClick: false\n  perMachine: false\n  allowToChangeInstallationDirectory: true\n  deleteAppDataOnUninstall: false\n  createDesktopShortcut: true\n  createStartMenuShortcut: true\n  include: build/installer.nsh\n\nmac:\n  target:\n    - target: dmg\n      arch:\n        - x64\n        - arm64\n  icon: public/logo.png\n  category: public.app-category.productivity\n  artifactName: '${productName}-${version}-web-mac-${arch}.${ext}'\n\ndmg:\n  contents:\n    - x: 130\n      y: 220\n    - x: 410\n      y: 220\n      type: link\n      path: /Applications\n\nlinux:\n  target:\n    - target: AppImage\n      arch:\n        - x64\n  icon: public/logo.png\n  category: Utility\n  artifactName: '${productName}-${version}-web-linux-${arch}.${ext}'\n\nasar: false\n"
  },
  {
    "path": "free-todo-frontend/electron-builder.yml",
    "content": "appId: com.freeugroup.freetodo\nproductName: FreeTodo\ncopyright: Copyright © 2026 FreeU Group\n\ndirectories:\n  output: dist-electron-app\n  buildResources: build\n\n# 主进程文件\nfiles:\n  - 'dist-electron/**/*'\n\n# 将 Next.js standalone 构建结果作为资源打包\nextraResources:\n  # 复制 standalone 构建（包含 server.js 和必要的运行时文件）\n  - from: '.next/standalone'\n    to: 'standalone'\n    filter:\n      - '**/*'\n      # 注意：.pnpm 目录在 resolve-symlinks 后应该已经不需要了\n      # 但如果仍有缺失依赖，可以取消下面的排除\n      - '!**/.pnpm/**' # 排除 .pnpm 目录（已通过 resolve-symlinks 解析）\n  # 明确复制 node_modules（确保包含所有依赖）\n  - from: '.next/standalone/node_modules'\n    to: 'standalone/node_modules'\n    filter:\n      - '**/*'\n  # 复制静态文件（CSS, JS chunks等）- Next.js standalone 不包含这些文件，必须单独复制\n  - from: '.next/static'\n    to: 'standalone/.next/static'\n    filter:\n      - '**/*'\n  # 复制 public 目录（图片、字体等静态资源）\n  - from: 'public'\n    to: 'standalone/public'\n    filter:\n      - '**/*'\n  # 复制后端源码与依赖清单（运行时使用本机 Python）\n  - from: '../lifetrace'\n    to: 'backend/lifetrace'\n    filter:\n      - '**/*'\n      - '!**/__pycache__/**'\n      - '!**/*.pyc'\n      - '!**/data/**'\n      - '!**/config/config.yaml'\n      - '!**/.venv/**'\n      - '!**/.ruff_cache/**'\n  - from: '../requirements-runtime.txt'\n    to: 'backend/requirements-runtime.txt'\n\n# Windows 配置\nwin:\n  target:\n    - target: nsis\n      arch:\n        - x64\n  icon: public/favicon.ico\n  artifactName: '${productName}-${version}-win-${arch}.${ext}'\n\nnsis:\n  oneClick: false\n  perMachine: false\n  allowToChangeInstallationDirectory: true\n  deleteAppDataOnUninstall: false\n  createDesktopShortcut: true\n  createStartMenuShortcut: true\n  include: build/installer.nsh\n\n# macOS 配置\nmac:\n  target:\n    - target: dmg\n      arch:\n        - x64\n        - arm64\n  icon: public/logo.png\n  category: public.app-category.productivity\n  artifactName: '${productName}-${version}-mac-${arch}.${ext}'\n\ndmg:\n  contents:\n    - x: 130\n      y: 220\n    - x: 410\n      y: 220\n      type: link\n      path: /Applications\n\n# Linux 配置\nlinux:\n  target:\n    - target: AppImage\n      arch:\n        - x64\n  icon: public/logo.png\n  category: Utility\n  artifactName: '${productName}-${version}-linux-${arch}.${ext}'\n\n# 打包时不使用 asar（因为需要运行 Node.js 服务器）\nasar: false\n"
  },
  {
    "path": "free-todo-frontend/global.d.ts",
    "content": "import type messages from \"./lib/i18n/messages/zh.json\";\n\ntype Messages = typeof messages;\n\ndeclare global {\n\t// Use type safe message keys with `auto-complete`\n\tinterface IntlMessages extends Messages {}\n\n\t// Cookie Store API 类型声明\n\tinterface CookieStoreSetOptions {\n\t\tname: string;\n\t\tvalue: string;\n\t\texpires?: number | Date;\n\t\tmaxAge?: number;\n\t\tdomain?: string;\n\t\tpath?: string;\n\t\tsameSite?: \"strict\" | \"lax\" | \"none\";\n\t\tsecure?: boolean;\n\t\tpartitioned?: boolean;\n\t}\n\n\tinterface CookieStoreApi {\n\t\tset(options: CookieStoreSetOptions): Promise<void>;\n\t\tset(name: string, value: string): Promise<void>;\n\t\tget(name: string): Promise<{ name: string; value: string } | null>;\n\t\tdelete(name: string): Promise<void>;\n\t}\n\n\tinterface Window {\n\t\tcookieStore?: CookieStoreApi;\n\t\telectronAPI?: {\n\t\t\t/**\n\t\t\t * 显示系统通知\n\t\t\t * @param data 通知数据\n\t\t\t */\n\t\t\tshowNotification: (data: {\n\t\t\t\tid: string;\n\t\t\t\ttitle: string;\n\t\t\t\tcontent: string;\n\t\t\t\ttimestamp: string;\n\t\t\t}) => Promise<void>;\n\n\t\t\t/**\n\t\t\t * 设置窗口是否忽略鼠标事件\n\t\t\t */\n\t\t\tsetIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void;\n\n\t\t\t/**\n\t\t\t * 获取屏幕信息\n\t\t\t */\n\t\t\tgetScreenInfo: () => Promise<{ screenWidth: number; screenHeight: number }>;\n\n\t\t\t/**\n\t\t\t * 移动窗口到指定位置\n\t\t\t */\n\t\t\tmoveWindow: (x: number, y: number) => void;\n\n\t\t\t/**\n\t\t\t * 获取窗口当前位置\n\t\t\t */\n\t\t\tgetWindowPosition: () => Promise<{ x: number; y: number }>;\n\n\t\t\t/**\n\t\t\t * 退出应用\n\t\t\t */\n\t\t\tquit: () => void;\n\n\t\t\t/**\n\t\t\t * 设置窗口背景色\n\t\t\t */\n\t\t\tsetWindowBackgroundColor: (color: string) => void;\n\n\t\t\t// ========== Island 动态岛相关 API ==========\n\n\t\t/**\n\t\t * 调整 Island 窗口大小（切换模式）\n\t\t * @param mode Island 模式: \"FLOAT\" | \"POPUP\" | \"SIDEBAR\" | \"FULLSCREEN\"\n\t\t */\n\t\tislandResizeWindow: (mode: string) => void;\n\n\t\t/**\n\t\t * 调整 SIDEBAR 模式窗口大小（多栏展开/收起）\n\t\t * @param columnCount 栏数: 1 | 2 | 3\n\t\t */\n\t\tislandResizeSidebar: (columnCount: number) => void;\n\n\t\t/**\n\t\t * 显示 Island 窗口\n\t\t */\n\t\tislandShow: () => void;\n\n\t\t\t/**\n\t\t\t * 隐藏 Island 窗口\n\t\t\t */\n\t\t\tislandHide: () => void;\n\n\t\t/**\n\t\t * 切换 Island 窗口显示/隐藏\n\t\t */\n\t\tislandToggle: () => void;\n\n\t\t/**\n\t\t * Island 窗口拖拽开始（自定义拖拽）\n\t\t * @param mouseY 鼠标屏幕 Y 坐标\n\t\t */\n\t\tislandDragStart: (mouseY: number) => void;\n\n\t\t/**\n\t\t * Island 窗口拖拽移动（自定义拖拽）\n\t\t * @param mouseY 鼠标屏幕 Y 坐标\n\t\t */\n\t\tislandDragMove: (mouseY: number) => void;\n\n\t\t/**\n\t\t * Island 窗口拖拽结束（自定义拖拽）\n\t\t */\n\t\tislandDragEnd: () => void;\n\n\t\t/**\n\t\t * 设置 Island SIDEBAR 模式的固定状态\n\t\t * @param isPinned true = 固定（始终在顶部），false = 非固定（正常窗口行为）\n\t\t */\n\t\tislandSetPinned: (isPinned: boolean) => void;\n\n\t\t/**\n\t\t * 监听 Island 窗口位置更新（拖拽时实时更新）\n\t\t * @param callback 回调函数，接收位置数据 { y: number, screenHeight: number }\n\t\t * @returns 清理函数，用于取消监听\n\t\t */\n\t\tonIslandPositionUpdate: (callback: (data: { y: number; screenHeight: number }) => void) => () => void;\n\n\t\t/**\n\t\t * 监听 Island 窗口锚点更新（模式切换时更新）\n\t\t * @param callback 回调函数，接收锚点数据 { anchor: 'top' | 'bottom' | null, y: number }\n\t\t * @returns 清理函数，用于取消监听\n\t\t */\n\t\tonIslandAnchorUpdate: (callback: (data: { anchor: 'top' | 'bottom' | null; y: number }) => void) => () => void;\n\n\t\t// ========== Future Extensions ==========\n\n\t\t\t/**\n\t\t\t * 监听 Island 窗口可见性变化（未来功能）\n\t\t\t * @param callback 回调函数，接收可见性状态\n\t\t\t */\n\t\t\tonIslandVisibilityChange?: (callback: (visible: boolean) => void) => void;\n\n\t\t\t/**\n\t\t\t * 取消监听 Island 窗口可见性变化（未来功能）\n\t\t\t */\n\t\t\toffIslandVisibilityChange?: (callback: (visible: boolean) => void) => void;\n\t\t};\n\t}\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/api/fetcher.ts",
    "content": "import type { ZodType } from \"zod\";\nimport { camelToSnake, snakeToCamel } from \"../generated/case-transform\";\n\ntype CustomFetcherOptions<T> = RequestInit & {\n\tparams?: Record<string, unknown>;\n\tdata?: unknown;\n\tresponseSchema?: ZodType<T>;\n};\n\n// 标准化时间字符串（处理无时区后缀问题）\nfunction normalizeTimestamps(obj: unknown): unknown {\n\tif (obj === null || obj === undefined) return obj;\n\tif (typeof obj === \"string\") {\n\t\t// ISO 时间格式但无时区，假设为 UTC\n\t\tif (/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}$/.test(obj)) {\n\t\t\treturn `${obj}Z`;\n\t\t}\n\t\treturn obj;\n\t}\n\tif (Array.isArray(obj)) {\n\t\treturn obj.map(normalizeTimestamps);\n\t}\n\tif (typeof obj === \"object\") {\n\t\treturn Object.fromEntries(\n\t\t\tObject.entries(obj).map(([k, v]) => [k, normalizeTimestamps(v)]),\n\t\t);\n\t}\n\treturn obj;\n}\n\nconst normalizeHeaders = (headers?: HeadersInit): Record<string, string> => {\n\tconst normalized: Record<string, string> = {};\n\tif (headers instanceof Headers) {\n\t\theaders.forEach((value, key) => {\n\t\t\tnormalized[key] = value;\n\t\t});\n\t\treturn normalized;\n\t}\n\tif (Array.isArray(headers)) {\n\t\tfor (const [key, value] of headers) {\n\t\t\tnormalized[key] = value;\n\t\t}\n\t\treturn normalized;\n\t}\n\tif (headers) {\n\t\tObject.assign(normalized, headers);\n\t}\n\treturn normalized;\n};\n\nconst shouldParseBody = (body: BodyInit | null | undefined): unknown => {\n\tif (typeof body !== \"string\") return undefined;\n\tconst trimmed = body.trim();\n\tif (!trimmed.startsWith(\"{\") && !trimmed.startsWith(\"[\")) return undefined;\n\ttry {\n\t\treturn JSON.parse(trimmed);\n\t} catch {\n\t\treturn undefined;\n\t}\n};\n\nexport const unwrapApiData = <T>(response: unknown): T | null => {\n\tif (response === null || response === undefined) return null;\n\tif (typeof response === \"object\" && response !== null && \"data\" in response) {\n\t\tconst data = (response as { data?: T }).data;\n\t\treturn data ?? null;\n\t}\n\treturn response as T;\n};\n\nexport async function customFetcher<T>(\n\turl: string,\n\toptions?: CustomFetcherOptions<T>,\n): Promise<T> {\n\t// 客户端使用相对路径（通过 Next.js rewrites 代理）\n\t// SSR 环境使用环境变量（由 Electron 启动时注入动态端口）\n\tconst baseUrl =\n\t\ttypeof window !== \"undefined\"\n\t\t\t? \"\"\n\t\t\t: process.env.NEXT_PUBLIC_API_URL || \"http://localhost:8100\";\n\n\tconst { params, data, responseSchema, body, headers, ...fetchOptions } =\n\t\toptions ?? {};\n\n\tconst filteredParams = params\n\t\t? Object.fromEntries(\n\t\t\t\tObject.entries(params).filter(\n\t\t\t\t\t([_, value]) => value !== undefined && value !== null,\n\t\t\t\t),\n\t\t\t)\n\t\t: {};\n\n\tconst [path, existingQuery = \"\"] = url.split(\"?\");\n\tconst queryParams = new URLSearchParams(existingQuery);\n\tObject.entries(filteredParams).forEach(([key, value]) => {\n\t\tqueryParams.append(key, String(value));\n\t});\n\tconst queryString = queryParams.toString();\n\tconst finalUrl = queryString.length > 0 ? `${path}?${queryString}` : url;\n\n\tlet requestBody: BodyInit | undefined = body ?? undefined;\n\tlet isJsonBody = false;\n\n\tif (data !== undefined) {\n\t\trequestBody = JSON.stringify(camelToSnake(data));\n\t\tisJsonBody = true;\n\t} else if (body !== undefined) {\n\t\tconst parsed = shouldParseBody(body);\n\t\tif (parsed !== undefined) {\n\t\t\trequestBody = JSON.stringify(camelToSnake(parsed));\n\t\t\tisJsonBody = true;\n\t\t}\n\t}\n\n\tconst finalHeaders = normalizeHeaders(headers);\n\tif (isJsonBody) {\n\t\tconst hasContentType = Object.keys(finalHeaders).some(\n\t\t\t(key) => key.toLowerCase() === \"content-type\",\n\t\t);\n\t\tif (!hasContentType) {\n\t\t\tfinalHeaders[\"Content-Type\"] = \"application/json\";\n\t\t}\n\t}\n\n\tconst fetchInit: RequestInit = {\n\t\t...fetchOptions,\n\t\theaders: finalHeaders,\n\t};\n\tif (requestBody !== undefined) {\n\t\tfetchInit.body = requestBody;\n\t}\n\n\tconst response = await fetch(`${baseUrl}${finalUrl}`, fetchInit);\n\n\tif (!response.ok) {\n\t\tthrow new Error(`API Error: ${response.status}`);\n\t}\n\n\t// 处理空响应体（如 204 No Content 或 DELETE 操作）\n\tconst contentType = response.headers.get(\"content-type\");\n\tconst contentLength = response.headers.get(\"content-length\");\n\n\tif (response.status === 204 || contentLength === \"0\") {\n\t\treturn undefined as T;\n\t}\n\n\tconst text = await response.text();\n\tif (!text || text.trim() === \"\") {\n\t\treturn undefined as T;\n\t}\n\n\tlet json: unknown;\n\ttry {\n\t\tjson = JSON.parse(text);\n\t} catch (error) {\n\t\tif (!contentType?.includes(\"application/json\")) {\n\t\t\treturn text as T;\n\t\t}\n\t\tthrow new Error(\n\t\t\t`Failed to parse JSON response: ${\n\t\t\t\terror instanceof Error ? error.message : String(error)\n\t\t\t}`,\n\t\t);\n\t}\n\n\tjson = normalizeTimestamps(json);\n\tjson = snakeToCamel(json);\n\n\tif (responseSchema) {\n\t\tconst result = responseSchema.safeParse(json);\n\t\tif (!result.success) {\n\t\t\tconsole.error(\"[API] Schema validation failed:\", result.error.issues);\n\t\t\tif (process.env.NODE_ENV === \"development\") {\n\t\t\t\tthrow new Error(\"Schema validation failed\");\n\t\t\t}\n\t\t}\n\t\treturn result.success ? result.data : (json as T);\n\t}\n\n\treturn json as T;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/api.ts",
    "content": "/**\n * 获取流式 API 的基础 URL\n * 流式请求直接调用后端 API，绕过 Next.js 代理，避免 gzip 压缩破坏流式传输\n */\nfunction getStreamApiBaseUrl(): string {\n\t// 流式请求始终直接调用后端，避免 Next.js 代理导致的缓冲/压缩问题\n\treturn process.env.NEXT_PUBLIC_API_URL || \"http://localhost:8100\";\n}\n\n// ============================================================================\n// 流式 API（Orval 不支持 Server-Sent Events，需要手动实现）\n// ============================================================================\n\nexport interface SendChatParams {\n\tmessage: string; // 发送给 LLM 的完整消息（包含 system prompt + context + user input）\n\tuserInput?: string; // 用户真正输入的内容（用于保存到历史记录）\n\tcontext?: string; // 待办上下文（可选）\n\tsystemPrompt?: string; // 系统提示词（可选）\n\tconversationId?: string;\n\tuseRag?: boolean;\n\tmode?: string;\n\tselectedTools?: string[];\n\texternalTools?: string[];\n}\n\n/**\n * 工具调用事件类型（从后端流式响应中解析）\n */\nexport type ToolCallEventType =\n\t| \"tool_call_start\"\n\t| \"tool_call_end\"\n\t| \"run_started\"\n\t| \"run_completed\";\n\n/**\n * 工具调用事件数据\n */\nexport interface ToolCallEvent {\n\ttype: ToolCallEventType;\n\ttool_name?: string;\n\ttool_args?: Record<string, unknown>;\n\tresult_preview?: string;\n}\n\n// 工具调用事件标记（与后端保持一致）\nconst TOOL_EVENT_PREFIX = \"\\n[TOOL_EVENT:\";\nconst TOOL_EVENT_SUFFIX = \"]\\n\";\n\n/**\n * 解析流式响应中的工具调用事件\n * 返回 [解析出的事件列表, 剩余的纯内容]\n */\nfunction parseToolEvents(chunk: string): [ToolCallEvent[], string] {\n\tconst events: ToolCallEvent[] = [];\n\tlet content = chunk;\n\n\t// 循环查找并解析所有工具调用事件\n\tlet startIdx = content.indexOf(TOOL_EVENT_PREFIX);\n\twhile (startIdx !== -1) {\n\t\tconst endIdx = content.indexOf(TOOL_EVENT_SUFFIX, startIdx);\n\t\tif (endIdx === -1) {\n\t\t\t// 事件标记不完整，等待更多数据\n\t\t\tbreak;\n\t\t}\n\n\t\t// 提取 JSON 部分\n\t\tconst jsonStart = startIdx + TOOL_EVENT_PREFIX.length;\n\t\tconst jsonStr = content.substring(jsonStart, endIdx);\n\n\t\ttry {\n\t\t\tconst event = JSON.parse(jsonStr) as ToolCallEvent;\n\t\t\tevents.push(event);\n\t\t} catch (e) {\n\t\t\tconsole.error(\"[parseToolEvents] Failed to parse event:\", jsonStr, e);\n\t\t}\n\n\t\t// 移除已解析的事件标记\n\t\tcontent =\n\t\t\tcontent.substring(0, startIdx) +\n\t\t\tcontent.substring(endIdx + TOOL_EVENT_SUFFIX.length);\n\n\t\t// 继续查找下一个事件\n\t\tstartIdx = content.indexOf(TOOL_EVENT_PREFIX);\n\t}\n\n\treturn [events, content];\n}\n\n/**\n * 发送聊天消息并以流式方式接收回复\n * @param params - 聊天参数\n * @param onChunk - 内容块回调\n * @param onSessionId - 会话 ID 回调\n * @param signal - 取消信号\n * @param locale - 语言设置\n * @param onToolEvent - 工具调用事件回调（可选）\n */\nexport async function sendChatMessageStream(\n\tparams: SendChatParams,\n\tonChunk: (chunk: string) => void,\n\tonSessionId?: (sessionId: string) => void,\n\tsignal?: AbortSignal,\n\tlocale?: string,\n\tonToolEvent?: (event: ToolCallEvent) => void,\n): Promise<void> {\n\t// 流式请求直接调用后端 API，绕过 Next.js 代理\n\tconst baseUrl = getStreamApiBaseUrl();\n\tconst apiUrl = `${baseUrl}/api/chat/stream`;\n\n\t// 调试日志\n\tconsole.log(\"[sendChatMessageStream] baseUrl:\", baseUrl);\n\tconsole.log(\"[sendChatMessageStream] apiUrl:\", apiUrl);\n\tconsole.log(\"[sendChatMessageStream] params:\", params);\n\tconsole.log(\"[sendChatMessageStream] selectedTools:\", params.selectedTools);\n\n\tlet response: Response;\n\ttry {\n\t\tconst requestBody = {\n\t\t\tmessage: params.message,\n\t\t\tuser_input: params.userInput,\n\t\t\tcontext: params.context,\n\t\t\tsystem_prompt: params.systemPrompt,\n\t\t\tconversation_id: params.conversationId,\n\t\t\tuse_rag: params.useRag,\n\t\t\tmode: params.mode,\n\t\t\tselected_tools: params.selectedTools,\n\t\t\texternal_tools: params.externalTools,\n\t\t};\n\t\tconsole.log(\"[sendChatMessageStream] Request body:\", requestBody);\n\n\t\tresponse = await fetch(apiUrl, {\n\t\t\tmethod: \"POST\",\n\t\t\theaders: {\n\t\t\t\t\"Content-Type\": \"application/json\",\n\t\t\t\t\"Accept-Language\": locale || \"en\",\n\t\t\t},\n\t\t\tbody: JSON.stringify(requestBody),\n\t\t\tsignal,\n\t\t});\n\t} catch (error) {\n\t\t// 调试日志\n\t\tconsole.error(\"[sendChatMessageStream] fetch error:\", error);\n\n\t\t// 如果是取消操作，静默返回\n\t\tif (\n\t\t\tsignal?.aborted ||\n\t\t\t(error instanceof Error && error.name === \"AbortError\")\n\t\t) {\n\t\t\treturn;\n\t\t}\n\t\tthrow error;\n\t}\n\n\tif (!response.ok) {\n\t\tthrow new Error(`Request failed with status ${response.status}`);\n\t}\n\n\t// 从响应头中获取 session_id\n\tconst sessionId = response.headers.get(\"X-Session-Id\");\n\tif (sessionId && onSessionId) {\n\t\tonSessionId(sessionId);\n\t}\n\n\tif (!response.body) {\n\t\tthrow new Error(\"ReadableStream is not supported in this environment\");\n\t}\n\n\tconst reader = response.body.getReader();\n\tconst decoder = new TextDecoder();\n\n\t// 用于处理跨 chunk 的不完整事件标记\n\tlet pendingChunk = \"\";\n\n\ttry {\n\t\twhile (true) {\n\t\t\t// 检查是否已取消\n\t\t\tif (signal?.aborted) {\n\t\t\t\tawait reader.cancel();\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tconst { done, value } = await reader.read();\n\t\t\tif (done) break;\n\n\t\t\tif (value) {\n\t\t\t\tconst rawChunk = decoder.decode(value, { stream: true });\n\t\t\t\tif (rawChunk) {\n\t\t\t\t\t// 将待处理的部分与新数据合并\n\t\t\t\t\tconst fullChunk = pendingChunk + rawChunk;\n\n\t\t\t\t\t// 解析工具调用事件\n\t\t\t\t\tconst [events, content] = parseToolEvents(fullChunk);\n\n\t\t\t\t\t// 触发工具调用事件回调\n\t\t\t\t\tif (onToolEvent) {\n\t\t\t\t\t\tfor (const event of events) {\n\t\t\t\t\t\t\tonToolEvent(event);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\t// 检查是否有不完整的事件标记\n\t\t\t\t\tconst incompleteEventIdx = content.indexOf(TOOL_EVENT_PREFIX);\n\t\t\t\t\tif (incompleteEventIdx !== -1) {\n\t\t\t\t\t\t// 有不完整的事件标记，保存到下次处理\n\t\t\t\t\t\tpendingChunk = content.substring(incompleteEventIdx);\n\t\t\t\t\t\tconst completeContent = content.substring(0, incompleteEventIdx);\n\t\t\t\t\t\tif (completeContent) {\n\t\t\t\t\t\t\tonChunk(completeContent);\n\t\t\t\t\t\t}\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// 没有不完整的事件标记\n\t\t\t\t\t\tpendingChunk = \"\";\n\t\t\t\t\t\tif (content) {\n\t\t\t\t\t\t\tonChunk(content);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// 处理最后剩余的内容\n\t\tif (pendingChunk) {\n\t\t\tonChunk(pendingChunk);\n\t\t}\n\t} catch (error) {\n\t\t// 如果是取消操作，不抛出错误\n\t\tif (\n\t\t\tsignal?.aborted ||\n\t\t\t(error instanceof Error && error.name === \"AbortError\")\n\t\t) {\n\t\t\tawait reader.cancel();\n\t\t\treturn;\n\t\t}\n\t\tthrow error;\n\t}\n}\n\n/**\n * Plan功能：生成选择题（流式输出）\n */\nexport async function planQuestionnaireStream(\n\ttodoName: string,\n\tonChunk: (chunk: string) => void,\n\ttodoId?: number,\n): Promise<void> {\n\t// 流式请求直接调用后端 API，绕过 Next.js 代理\n\tconst baseUrl = getStreamApiBaseUrl();\n\tconst response = await fetch(\n\t\t`${baseUrl}/api/chat/plan/questionnaire/stream`,\n\t\t{\n\t\t\tmethod: \"POST\",\n\t\t\theaders: {\n\t\t\t\t\"Content-Type\": \"application/json\",\n\t\t\t},\n\t\t\tbody: JSON.stringify({\n\t\t\t\ttodo_name: todoName,\n\t\t\t\ttodo_id: todoId,\n\t\t\t}),\n\t\t},\n\t);\n\n\tif (!response.ok) {\n\t\tthrow new Error(`Request failed with status ${response.status}`);\n\t}\n\n\tif (!response.body) {\n\t\tthrow new Error(\"ReadableStream is not supported in this environment\");\n\t}\n\n\tconst reader = response.body.getReader();\n\tconst decoder = new TextDecoder();\n\n\twhile (true) {\n\t\tconst { done, value } = await reader.read();\n\t\tif (done) break;\n\n\t\tif (value) {\n\t\t\tconst chunk = decoder.decode(value, { stream: true });\n\t\t\tif (chunk) {\n\t\t\t\tonChunk(chunk);\n\t\t\t}\n\t\t}\n\t}\n}\n\n/**\n * Plan功能：生成任务总结和子任务（流式输出）\n */\nexport async function planSummaryStream(\n\ttodoName: string,\n\tanswers: Record<string, string[]>,\n\tonChunk: (chunk: string) => void,\n): Promise<void> {\n\t// 流式请求直接调用后端 API，绕过 Next.js 代理\n\tconst baseUrl = getStreamApiBaseUrl();\n\tconst response = await fetch(`${baseUrl}/api/chat/plan/summary/stream`, {\n\t\tmethod: \"POST\",\n\t\theaders: {\n\t\t\t\"Content-Type\": \"application/json\",\n\t\t},\n\t\tbody: JSON.stringify({\n\t\t\ttodo_name: todoName,\n\t\t\tanswers: answers,\n\t\t}),\n\t});\n\n\tif (!response.ok) {\n\t\tthrow new Error(`Request failed with status ${response.status}`);\n\t}\n\n\tif (!response.body) {\n\t\tthrow new Error(\"ReadableStream is not supported in this environment\");\n\t}\n\n\tconst reader = response.body.getReader();\n\tconst decoder = new TextDecoder();\n\n\twhile (true) {\n\t\tconst { done, value } = await reader.read();\n\t\tif (done) break;\n\n\t\tif (value) {\n\t\t\tconst chunk = decoder.decode(value, { stream: true });\n\t\t\tif (chunk) {\n\t\t\t\tonChunk(chunk);\n\t\t\t}\n\t\t}\n\t}\n}\n\n// ============================================================================\n// 工具函数\n// ============================================================================\n\n/**\n * 获取截图图片 URL\n * 辅助函数，用于构建截图图片的 URL\n */\nexport function getScreenshotImage(id: number): string {\n\t// 在客户端使用相对路径，通过 Next.js rewrites 代理\n\t// 在服务端使用完整 URL\n\tconst baseUrl =\n\t\ttypeof window !== \"undefined\"\n\t\t\t? \"\"\n\t\t\t: process.env.NEXT_PUBLIC_API_URL || \"http://localhost:8100\";\n\treturn `${baseUrl}/api/screenshots/${id}/image`;\n}\n\n// ============================================================================\n// 类型导出（从 Orval 生成的 schemas 重新导出，保持向后兼容）\n// ============================================================================\n\n// 这些类型现在应该从 @/lib/generated/schemas 导入\n// 保留这些重导出以保持向后兼容\nexport type {\n\tExtractedTodo,\n\tManualActivityCreateRequest,\n\tManualActivityCreateResponse,\n\tTodoAttachmentResponse as ApiTodoAttachment,\n\tTodoCreate,\n\tTodoExtractionResponse,\n\tTodoPriority,\n\tTodoResponse as ApiTodo,\n\tTodoStatus,\n\tTodoTimeInfo,\n\tTodoUpdate,\n} from \"@/lib/generated/schemas\";\n\n// Chat 相关类型（这些类型在后端 OpenAPI spec 中可能没有定义，手动定义）\n// 注意：使用 camelCase，因为 fetcher 会自动将后端的 snake_case 转换为 camelCase\nexport type ChatSessionSummary = {\n\tsessionId: string;\n\ttitle?: string;\n\tlastActive?: string;\n\tmessageCount?: number;\n\tchatType?: string;\n};\n\nexport type ChatHistoryItem = {\n\trole: \"user\" | \"assistant\";\n\tcontent: string;\n\ttimestamp?: string;\n\textraData?: string;\n};\n\nexport type ChatHistoryResponse = {\n\tsessions?: ChatSessionSummary[];\n\thistory?: ChatHistoryItem[];\n};\n\n// ============================================================================\n// 注：通知相关的 API（fetchNotification、deleteNotification）已被 Orval 生成的 API 替换\n// 请直接使用 @/lib/generated/notifications/notifications 中的函数\n// ============================================================================\n"
  },
  {
    "path": "free-todo-frontend/lib/attachments.ts",
    "content": "\"use client\";\n\nimport { snakeToCamel } from \"@/lib/generated/case-transform\";\nimport type { TodoAttachment } from \"@/lib/types\";\n\nexport const MAX_ATTACHMENT_SIZE_BYTES = 50 * 1024 * 1024;\n\nfunction getApiBaseUrl(): string {\n\treturn typeof window !== \"undefined\"\n\t\t? \"\"\n\t\t: process.env.NEXT_PUBLIC_API_URL || \"http://localhost:8100\";\n}\n\nexport function getAttachmentFileUrl(attachmentId: number): string {\n\tconst baseUrl = getApiBaseUrl();\n\treturn `${baseUrl}/api/todos/attachments/${attachmentId}/file`;\n}\n\nexport async function uploadTodoAttachments(\n\ttodoId: number,\n\tfiles: File[],\n): Promise<TodoAttachment[]> {\n\tconst baseUrl = getApiBaseUrl();\n\tconst formData = new FormData();\n\n\tfor (const file of files) {\n\t\tformData.append(\"files\", file, file.name);\n\t}\n\n\tconst response = await fetch(`${baseUrl}/api/todos/${todoId}/attachments`, {\n\t\tmethod: \"POST\",\n\t\tbody: formData,\n\t});\n\n\tif (!response.ok) {\n\t\tthrow new Error(`Upload failed: ${response.status}`);\n\t}\n\n\tconst json = await response.json();\n\treturn snakeToCamel(json) as TodoAttachment[];\n}\n\nexport async function removeTodoAttachment(\n\ttodoId: number,\n\tattachmentId: number,\n): Promise<void> {\n\tconst baseUrl = getApiBaseUrl();\n\tconst response = await fetch(\n\t\t`${baseUrl}/api/todos/${todoId}/attachments/${attachmentId}`,\n\t\t{\n\t\t\tmethod: \"DELETE\",\n\t\t},\n\t);\n\n\tif (!response.ok) {\n\t\tthrow new Error(`Remove attachment failed: ${response.status}`);\n\t}\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/config/panel-config.ts",
    "content": "/**\n * Panel 配置层\n * 定义功能到位置的映射关系\n * 现在使用动态分配系统，功能可以动态分配到位置\n */\n\nimport {\n\tActivity,\n\tAward,\n\tBookOpen,\n\tCalendarDays,\n\tCamera,\n\tDollarSign,\n\tFileText,\n\tListTodo,\n\ttype LucideIcon,\n\tMessageSquare,\n\tMic,\n\tSettings,\n} from \"lucide-react\";\n\nexport type PanelPosition = \"panelA\" | \"panelB\" | \"panelC\";\nexport type PanelFeature =\n\t| \"calendar\"\n\t| \"activity\"\n\t| \"todos\"\n\t| \"chat\"\n\t| \"todoDetail\"\n\t| \"diary\"\n\t| \"settings\"\n\t| \"costTracking\"\n\t| \"achievements\"\n\t| \"debugShots\"\n\t| \"audio\";\n\n/**\n * 开发中的面板功能列表\n * 这些功能默认在 UI 中处于关闭状态，由用户手动开启\n * 在设置面板的\"开发选项\"中统一管理\n */\nexport const DEV_IN_PROGRESS_FEATURES: PanelFeature[] = [\n\t\"diary\",\n\t\"activity\",\n\t\"debugShots\",\n\t\"achievements\",\n\t\"audio\",\n];\n\n/**\n * 所有可用的功能列表\n */\nexport const ALL_PANEL_FEATURES: PanelFeature[] = [\n\t\"calendar\",\n\t\"activity\",\n\t\"todos\",\n\t\"chat\",\n\t\"todoDetail\",\n\t\"diary\",\n\t\"settings\",\n\t\"costTracking\",\n\t\"achievements\",\n\t\"debugShots\",\n\t\"audio\",\n];\n\n/**\n * 功能到图标的映射配置\n */\nexport const FEATURE_ICON_MAP: Record<PanelFeature, LucideIcon> = {\n\tcalendar: CalendarDays,\n\tactivity: Activity,\n\ttodos: ListTodo,\n\tchat: MessageSquare,\n\ttodoDetail: FileText,\n\tdiary: BookOpen,\n\tsettings: Settings,\n\tcostTracking: DollarSign,\n\tachievements: Award,\n\tdebugShots: Camera,\n\taudio: Mic,\n};\n"
  },
  {
    "path": "free-todo-frontend/lib/dnd/context.tsx",
    "content": "\"use client\";\n\n/**\n * 全局拖拽上下文提供者\n * Global Drag and Drop Context Provider\n */\n\nimport {\n\ttype CollisionDetection,\n\tclosestCenter,\n\tDndContext,\n\ttype DragCancelEvent,\n\ttype DragEndEvent,\n\ttype DragStartEvent,\n\tPointerSensor,\n\tpointerWithin,\n\trectIntersection,\n\tuseSensor,\n\tuseSensors,\n} from \"@dnd-kit/core\";\nimport { createContext, useCallback, useContext, useState } from \"react\";\nimport { dispatchDragDrop } from \"./handlers\";\nimport { GlobalDragOverlay } from \"./overlays\";\nimport type {\n\tActiveDragState,\n\tDragData,\n\tDropData,\n\tGlobalDndContextValue,\n} from \"./types\";\n\n// ============================================================================\n// Context 创建\n// ============================================================================\n\n// 用于跟踪正在进行乐观更新的 todo，确保在数据同步前卡片保持隐藏\nexport const PendingUpdateContext = createContext<{\n\tpendingTodoId: number | null;\n\tsetPendingTodoId: (id: number | null) => void;\n} | null>(null);\n\nconst GlobalDndContext = createContext<GlobalDndContextValue | null>(null);\n\n/**\n * 使用全局拖拽上下文\n */\nexport function useGlobalDnd(): GlobalDndContextValue {\n\tconst context = useContext(GlobalDndContext);\n\tif (!context) {\n\t\tthrow new Error(\"useGlobalDnd must be used within GlobalDndProvider\");\n\t}\n\treturn context;\n}\n\n/**\n * 安全获取全局拖拽上下文（可能为 null）\n */\nexport function useGlobalDndSafe(): GlobalDndContextValue | null {\n\treturn useContext(GlobalDndContext);\n}\n\n/**\n * 获取正在进行乐观更新的 todo ID\n * 用于在数据同步完成前保持被拖拽的卡片隐藏\n */\nexport function usePendingUpdate() {\n\tconst context = useContext(PendingUpdateContext);\n\treturn context?.pendingTodoId ?? null;\n}\n\n// ============================================================================\n// 自定义碰撞检测\n// ============================================================================\n\n/**\n * 自定义碰撞检测策略\n * 优先使用 pointerWithin，然后 rectIntersection，最后 closestCenter\n */\nconst customCollisionDetection: CollisionDetection = (args) => {\n\t// 首先尝试 pointerWithin（指针在目标内部）\n\tconst pointerCollisions = pointerWithin(args);\n\tif (pointerCollisions.length > 0) {\n\t\treturn pointerCollisions;\n\t}\n\n\t// 然后尝试 rectIntersection（矩形相交）\n\tconst rectCollisions = rectIntersection(args);\n\tif (rectCollisions.length > 0) {\n\t\treturn rectCollisions;\n\t}\n\n\t// 最后使用 closestCenter（最近中心点）\n\treturn closestCenter(args);\n};\n\n// ============================================================================\n// Provider 组件\n// ============================================================================\n\ninterface GlobalDndProviderProps {\n\tchildren: React.ReactNode;\n}\n\nexport function GlobalDndProvider({ children }: GlobalDndProviderProps) {\n\tconst [activeDrag, setActiveDrag] = useState<ActiveDragState | null>(null);\n\t// 跟踪正在进行乐观更新的 todo ID，用于在数据同步完成前保持卡片隐藏\n\tconst [pendingTodoId, setPendingTodoId] = useState<number | null>(null);\n\n\t// 配置传感器\n\tconst sensors = useSensors(\n\t\tuseSensor(PointerSensor, {\n\t\t\tactivationConstraint: {\n\t\t\t\tdistance: 8, // 需要移动 8px 才触发拖拽，避免误触\n\t\t\t},\n\t\t}),\n\t);\n\n\t// 拖拽开始\n\tconst handleDragStart = useCallback((event: DragStartEvent) => {\n\t\tconst data = event.active.data.current as DragData | undefined;\n\n\t\tif (data) {\n\t\t\t// 保持原始 ID 类型，不做转换\n\t\t\t// Calendar 使用 \"calendar-{id}\" 格式，TodoList 使用数字 ID\n\t\t\tsetActiveDrag({\n\t\t\t\tid: event.active.id,\n\t\t\t\tdata,\n\t\t\t});\n\t\t\tconsole.log(\"[DnD] Drag started:\", data.type, event.active.id);\n\t\t}\n\t}, []);\n\n\t// 拖拽结束\n\tconst handleDragEnd = useCallback((event: DragEndEvent) => {\n\t\tconst { active, over } = event;\n\n\t\tif (over) {\n\t\t\tconst dragData = active.data.current as DragData | undefined;\n\t\t\tconst dropData = over.data.current as DropData | undefined;\n\n\t\t\tconsole.log(\"[DnD] Drag ended:\", {\n\t\t\t\tactiveId: active.id,\n\t\t\t\toverId: over.id,\n\t\t\t\tdragType: dragData?.type,\n\t\t\t\tdropType: dropData?.type,\n\t\t\t});\n\n\t\t\t// 提取被拖拽的 todo ID，用于乐观更新期间保持卡片隐藏\n\t\t\tif (dragData?.type === \"TODO_CARD\") {\n\t\t\t\tconst todoId = dragData.payload.todo.id;\n\t\t\t\tsetPendingTodoId(todoId);\n\t\t\t\t// 在短暂延迟后清除 pending 状态，让 React Query 有时间传播更新\n\t\t\t\tsetTimeout(() => {\n\t\t\t\t\tsetPendingTodoId(null);\n\t\t\t\t}, 150);\n\t\t\t}\n\n\t\t\t// 分发到对应的处理器\n\t\t\tdispatchDragDrop(dragData, dropData);\n\t\t}\n\n\t\tsetActiveDrag(null);\n\t}, []);\n\n\t// 拖拽取消\n\tconst handleDragCancel = useCallback((event: DragCancelEvent) => {\n\t\tconsole.log(\"[DnD] Drag cancelled:\", event.active.id);\n\t\tsetActiveDrag(null);\n\t}, []);\n\n\tconst contextValue: GlobalDndContextValue = {\n\t\tactiveDrag,\n\t};\n\n\tconst pendingUpdateValue = {\n\t\tpendingTodoId,\n\t\tsetPendingTodoId,\n\t};\n\n\treturn (\n\t\t<DndContext\n\t\t\tsensors={sensors}\n\t\t\tcollisionDetection={customCollisionDetection}\n\t\t\tonDragStart={handleDragStart}\n\t\t\tonDragEnd={handleDragEnd}\n\t\t\tonDragCancel={handleDragCancel}\n\t\t>\n\t\t\t<PendingUpdateContext.Provider value={pendingUpdateValue}>\n\t\t\t\t<GlobalDndContext.Provider value={contextValue}>\n\t\t\t\t\t{children}\n\t\t\t\t</GlobalDndContext.Provider>\n\t\t\t</PendingUpdateContext.Provider>\n\t\t\t<GlobalDragOverlay activeDrag={activeDrag} />\n\t\t</DndContext>\n\t);\n}\n\n// ============================================================================\n// 导出\n// ============================================================================\n\nexport { GlobalDndContext };\n"
  },
  {
    "path": "free-todo-frontend/lib/dnd/handlers.ts",
    "content": "/**\n * 拖拽处理器 - 策略模式分发\n * Drag Drop Handlers - Strategy Pattern Dispatch\n */\n\nimport { flushSync } from \"react-dom\";\nimport type { TodoListResponse, TodoResponse } from \"@/lib/generated/schemas\";\nimport { updateTodoApiTodosTodoIdPut } from \"@/lib/generated/todos/todos\";\nimport { getQueryClient, queryKeys } from \"@/lib/query\";\nimport { useUiStore } from \"@/lib/store/ui-store\";\nimport type {\n\tDragData,\n\tDragDropHandler,\n\tDragDropResult,\n\tDropData,\n\tHandlerKey,\n} from \"./types\";\n\n// ============================================================================\n// 处理器注册表 (Handler Registry)\n// ============================================================================\n\n/**\n * 策略模式处理器映射表\n * 键格式: \"SOURCE_TYPE->TARGET_TYPE\"\n */\nconst handlerRegistry: Partial<Record<HandlerKey, DragDropHandler>> = {};\n\n// Normalize date strings that may lack timezone info.\nconst normalizeTodoDate = (value?: string) => {\n\tif (!value) return null;\n\tlet normalized = value;\n\tif (\n\t\tvalue.includes(\"T\") &&\n\t\t!value.includes(\"Z\") &&\n\t\t!value.includes(\"+\") &&\n\t\t!/\\d{2}:\\d{2}:\\d{2}-/.test(value)\n\t) {\n\t\tnormalized = `${value}Z`;\n\t}\n\tconst parsed = new Date(normalized);\n\treturn Number.isNaN(parsed.getTime()) ? null : parsed;\n};\n\nconst updateTodoCache = (\n\ttodoId: number,\n\tupdates: {\n\t\tdeadline?: string;\n\t\tstartTime?: string;\n\t\tendTime?: string;\n\t},\n) => {\n\tconst queryClient = getQueryClient();\n\n\tvoid queryClient.cancelQueries({ queryKey: queryKeys.todos.all });\n\n\tconst previousTodos = queryClient.getQueryData(queryKeys.todos.list());\n\n\tflushSync(() => {\n\t\tqueryClient.setQueryData<TodoListResponse>(\n\t\t\tqueryKeys.todos.list(),\n\t\t\t(oldData) => {\n\t\t\t\tif (!oldData) return oldData;\n\n\t\t\t\tif (oldData && \"todos\" in oldData && Array.isArray(oldData.todos)) {\n\t\t\t\t\tconst updatedTodos = oldData.todos.map((t: TodoResponse) => {\n\t\t\t\t\t\tif (t.id !== todoId) return t;\n\t\t\t\t\t\tconst tRecord = t as unknown as Record<string, unknown>;\n\t\t\t\t\t\tconst updated = {\n\t\t\t\t\t\t\t...t,\n\t\t\t\t\t\t\t...(updates.deadline ? { deadline: updates.deadline } : {}),\n\t\t\t\t\t\t} as Record<string, unknown>;\n\t\t\t\t\t\tif (\"start_time\" in tRecord) {\n\t\t\t\t\t\t\tupdated.start_time = updates.startTime ?? tRecord.start_time;\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif (\"startTime\" in tRecord) {\n\t\t\t\t\t\t\tupdated.startTime = updates.startTime ?? tRecord.startTime;\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif (\"end_time\" in tRecord) {\n\t\t\t\t\t\t\tupdated.end_time = updates.endTime ?? tRecord.end_time;\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif (\"endTime\" in tRecord) {\n\t\t\t\t\t\t\tupdated.endTime = updates.endTime ?? tRecord.endTime;\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn updated as unknown as TodoResponse;\n\t\t\t\t\t});\n\t\t\t\t\treturn {\n\t\t\t\t\t\t...oldData,\n\t\t\t\t\t\ttodos: updatedTodos,\n\t\t\t\t\t};\n\t\t\t\t}\n\n\t\t\t\tif (Array.isArray(oldData)) {\n\t\t\t\t\treturn oldData.map((t) =>\n\t\t\t\t\t\tt.id === todoId\n\t\t\t\t\t\t\t? {\n\t\t\t\t\t\t\t\t\t...t,\n\t\t\t\t\t\t\t\t\t...(updates.deadline ? { deadline: updates.deadline } : {}),\n\t\t\t\t\t\t\t\t\t...(updates.startTime ? { startTime: updates.startTime } : {}),\n\t\t\t\t\t\t\t\t\t...(updates.endTime ? { endTime: updates.endTime } : {}),\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t: t,\n\t\t\t\t\t) as unknown as TodoListResponse;\n\t\t\t\t}\n\n\t\t\t\treturn oldData;\n\t\t\t},\n\t\t);\n\t});\n\n\treturn previousTodos;\n};\n\n/**\n * 注册拖拽处理器\n */\nexport function registerHandler(key: HandlerKey, handler: DragDropHandler) {\n\thandlerRegistry[key] = handler;\n}\n\n/**\n * 获取处理器\n */\nexport function getHandler(key: HandlerKey): DragDropHandler | undefined {\n\treturn handlerRegistry[key];\n}\n\n// ============================================================================\n// 内置处理器 (Built-in Handlers)\n// ============================================================================\n\n/**\n * TODO_CARD -> CALENDAR_DATE\n * 将待办拖到日历日期上，设置 startTime/endTime\n * 使用乐观更新：先更新前端缓存，再调用 API\n */\nconst handleTodoToCalendarDate: DragDropHandler = (\n\tdragData,\n\tdropData,\n): DragDropResult => {\n\tif (dragData.type !== \"TODO_CARD\" || dropData.type !== \"CALENDAR_DATE\") {\n\t\treturn { success: false, message: \"Invalid drag/drop type combination\" };\n\t}\n\n\tconst { todo } = dragData.payload;\n\tconst { date } = dropData.metadata;\n\n\tconst applyDate = (targetDate: Date, timeSource: Date) => {\n\t\tconst updated = new Date(targetDate);\n\t\tupdated.setHours(\n\t\t\ttimeSource.getHours(),\n\t\t\ttimeSource.getMinutes(),\n\t\t\ttimeSource.getSeconds(),\n\t\t\ttimeSource.getMilliseconds(),\n\t\t);\n\t\treturn updated;\n\t};\n\n\tconst existingStart = normalizeTodoDate(todo.startTime);\n\tconst existingEnd = normalizeTodoDate(todo.endTime);\n\tconst baseStart = existingStart;\n\tconst durationMs =\n\t\texistingStart && existingEnd\n\t\t\t? existingEnd.getTime() - existingStart.getTime()\n\t\t\t: null;\n\n\tconst newStart = baseStart\n\t\t? applyDate(date, baseStart)\n\t\t: applyDate(date, new Date(0));\n\tif (!baseStart) {\n\t\t// 默认设置为上午9点\n\t\tnewStart.setHours(9, 0, 0, 0);\n\t}\n\tconst newEnd = existingEnd\n\t\t? durationMs !== null\n\t\t\t? new Date(newStart.getTime() + durationMs)\n\t\t\t: applyDate(date, existingEnd)\n\t\t: null;\n\n\tconst newStartStr = newStart ? newStart.toISOString() : undefined;\n\tconst newEndStr = newEnd ? newEnd.toISOString() : undefined;\n\tconst queryClient = getQueryClient();\n\n\t// 取消正在进行的 todos 查询，避免竞态条件\n\tvoid queryClient.cancelQueries({ queryKey: queryKeys.todos.all });\n\n\t// 保存旧数据用于回滚\n\tconst previousTodos = queryClient.getQueryData(queryKeys.todos.list());\n\n\t// 乐观更新：使用 flushSync 强制同步渲染，确保在 onDragEnd 返回前 UI 已更新\n\t// 这样可以避免 \"先弹回再闪现\" 的视觉 Bug\n\tflushSync(() => {\n\t\tqueryClient.setQueryData<TodoListResponse>(\n\t\t\tqueryKeys.todos.list(),\n\t\t\t(oldData) => {\n\t\t\t\tif (!oldData) return oldData;\n\n\t\t\t\t// 处理原始 API 响应结构 { total, todos: TodoResponse[] }\n\t\t\t\tif (oldData && \"todos\" in oldData && Array.isArray(oldData.todos)) {\n\t\t\t\t\tconst updatedTodos = oldData.todos.map((t: TodoResponse) => {\n\t\t\t\t\t\tif (t.id === todo.id) {\n\t\t\t\t\t\t\tconst tRecord = t as unknown as Record<string, unknown>;\n\t\t\t\t\t\t\tconst updated = {\n\t\t\t\t\t\t\t\t...t,\n\t\t\t\t\t\t\t} as Record<string, unknown>;\n\t\t\t\t\t\t\tif (\"start_time\" in tRecord) {\n\t\t\t\t\t\t\t\tupdated.start_time = newStartStr ?? tRecord.start_time;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tif (\"startTime\" in tRecord) {\n\t\t\t\t\t\t\t\tupdated.startTime = newStartStr ?? tRecord.startTime;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tif (\"end_time\" in tRecord) {\n\t\t\t\t\t\t\t\tupdated.end_time = newEndStr ?? tRecord.end_time;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tif (\"endTime\" in tRecord) {\n\t\t\t\t\t\t\t\tupdated.endTime = newEndStr ?? tRecord.endTime;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\treturn updated as unknown as TodoResponse;\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn t;\n\t\t\t\t\t});\n\t\t\t\t\treturn {\n\t\t\t\t\t\t...oldData,\n\t\t\t\t\t\ttodos: updatedTodos,\n\t\t\t\t\t};\n\t\t\t\t}\n\n\t\t\t\t// 向后兼容：如果是数组格式（不应该发生，但为了安全）\n\t\t\t\tif (Array.isArray(oldData)) {\n\t\t\t\t\treturn oldData.map((t) =>\n\t\t\t\t\t\tt.id === todo.id\n\t\t\t\t\t\t\t? {\n\t\t\t\t\t\t\t\t\t...t,\n\t\t\t\t\t\t\t\t\tstartTime: newStartStr ?? t.startTime,\n\t\t\t\t\t\t\t\t\tendTime: newEndStr ?? t.endTime,\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t: t,\n\t\t\t\t\t) as unknown as TodoListResponse;\n\t\t\t\t}\n\n\t\t\t\treturn oldData;\n\t\t\t},\n\t\t);\n\t});\n\n\t// 异步调用 API\n\tvoid updateTodoApiTodosTodoIdPut(todo.id, {\n\t\t...(newStartStr ? { start_time: newStartStr } : {}),\n\t\t...(newEndStr ? { end_time: newEndStr } : {}),\n\t})\n\t\t.then(() => {\n\t\t\t// API 成功后刷新缓存以确保数据一致性\n\t\t\tvoid getQueryClient().invalidateQueries({ queryKey: queryKeys.todos.all });\n\t\t})\n\t\t.catch((error) => {\n\t\t\t// API 失败时回滚到之前的数据\n\t\t\tconsole.error(\"[DnD] Failed to update schedule:\", error);\n\t\t\tif (previousTodos) {\n\t\t\t\tgetQueryClient().setQueryData(queryKeys.todos.list(), previousTodos);\n\t\t\t}\n\t\t\tvoid getQueryClient().invalidateQueries({ queryKey: queryKeys.todos.all });\n\t\t});\n\n\treturn {\n\t\tsuccess: true,\n\t\tmessage: `已将 \"${todo.name}\" 设置到 ${dropData.metadata.dateKey}`,\n\t};\n};\n\n/**\n * TODO_CARD -> CALENDAR_TIMELINE_SLOT\n * Move todo into timeline slot (deadline or start/end).\n */\nconst handleTodoToCalendarTimelineSlot: DragDropHandler = (\n\tdragData,\n\tdropData,\n): DragDropResult => {\n\tif (\n\t\tdragData.type !== \"TODO_CARD\" ||\n\t\tdropData.type !== \"CALENDAR_TIMELINE_SLOT\"\n\t) {\n\t\treturn { success: false, message: \"Invalid drag/drop type combination\" };\n\t}\n\n\tconst { todo } = dragData.payload;\n\tconst { date, minutes } = dropData.metadata;\n\n\tconst slotDate = new Date(date);\n\tslotDate.setHours(Math.floor(minutes / 60), minutes % 60, 0, 0);\n\n\tconst existingStart = normalizeTodoDate(todo.startTime);\n\tconst existingEnd = normalizeTodoDate(todo.endTime);\n\tconst existingDeadline = normalizeTodoDate(todo.deadline);\n\tconst hasRange = Boolean(existingStart || existingEnd);\n\n\tconst MINUTES_PER_SLOT = 15;\n\tconst DEFAULT_DURATION_MINUTES = 30;\n\n\tconst getDurationMinutes = () => {\n\t\tif (existingStart && existingEnd) {\n\t\t\tconst diff = (existingEnd.getTime() - existingStart.getTime()) / 60000;\n\t\t\tif (Number.isFinite(diff) && diff > 0) return diff;\n\t\t}\n\t\treturn DEFAULT_DURATION_MINUTES;\n\t};\n\n\tconst rawDuration = getDurationMinutes();\n\tconst snappedDuration = Math.max(\n\t\tMINUTES_PER_SLOT,\n\t\tMath.round(rawDuration / MINUTES_PER_SLOT) * MINUTES_PER_SLOT,\n\t);\n\n\tlet newDeadline: Date | null = null;\n\tlet newStart: Date | null = null;\n\tlet newEnd: Date | null = null;\n\n\tif (hasRange) {\n\t\tnewStart = slotDate;\n\t\tnewEnd = new Date(slotDate.getTime() + snappedDuration * 60000);\n\t} else if (existingDeadline) {\n\t\tnewDeadline = slotDate;\n\t} else {\n\t\tnewStart = slotDate;\n\t\tnewEnd = new Date(slotDate.getTime() + DEFAULT_DURATION_MINUTES * 60000);\n\t}\n\n\tconst newDeadlineStr = newDeadline ? newDeadline.toISOString() : undefined;\n\tconst newStartStr = newStart ? newStart.toISOString() : undefined;\n\tconst newEndStr = newEnd ? newEnd.toISOString() : undefined;\n\n\tconst previousTodos = updateTodoCache(todo.id, {\n\t\t...(newDeadlineStr ? { deadline: newDeadlineStr } : {}),\n\t\t...(newStartStr ? { startTime: newStartStr } : {}),\n\t\t...(newEndStr ? { endTime: newEndStr } : {}),\n\t});\n\n\tvoid updateTodoApiTodosTodoIdPut(todo.id, {\n\t\t...(newDeadlineStr ? { deadline: newDeadlineStr } : {}),\n\t\t...(newStartStr ? { startTime: newStartStr } : {}),\n\t\t...(newEndStr ? { endTime: newEndStr } : {}),\n\t})\n\t\t.then(() => {\n\t\t\tvoid getQueryClient().invalidateQueries({ queryKey: queryKeys.todos.all });\n\t\t})\n\t\t.catch((error) => {\n\t\t\tconsole.error(\"[DnD] Failed to update timeline slot:\", error);\n\t\t\tif (previousTodos) {\n\t\t\t\tgetQueryClient().setQueryData(queryKeys.todos.list(), previousTodos);\n\t\t\t}\n\t\t\tvoid getQueryClient().invalidateQueries({ queryKey: queryKeys.todos.all });\n\t\t});\n\n\treturn { success: true };\n};\n\n/**\n * TODO_CARD -> TODO_LIST\n * 待办在列表内重新排序\n * 注意：内部排序由 TodoList 组件通过 useDndMonitor 处理\n * 使用乐观更新：先更新前端缓存，再调用 API\n */\nconst handleTodoToTodoList: DragDropHandler = (\n\tdragData,\n\tdropData,\n): DragDropResult => {\n\tif (dragData.type !== \"TODO_CARD\" || dropData.type !== \"TODO_LIST\") {\n\t\treturn { success: false, message: \"Invalid drag/drop type combination\" };\n\t}\n\n\tconst { todo } = dragData.payload;\n\tconst { parentTodoId } = dropData.metadata;\n\n\t// 如果指定了父级 ID，更新父子关系\n\tif (parentTodoId !== undefined) {\n\t\tconst queryClient = getQueryClient();\n\n\t\t// 取消正在进行的 todos 查询\n\t\tvoid queryClient.cancelQueries({ queryKey: queryKeys.todos.all });\n\n\t\t// 保存旧数据用于回滚\n\t\tconst previousTodos = queryClient.getQueryData(queryKeys.todos.list());\n\n\t\t// 乐观更新：立即更新前端缓存（使用与 useTodos 相同的 key）\n\t\tqueryClient.setQueryData<TodoListResponse>(\n\t\t\tqueryKeys.todos.list(),\n\t\t\t(oldData) => {\n\t\t\t\tif (!oldData) return oldData;\n\n\t\t\t\t// 处理原始 API 响应结构 { total, todos: TodoResponse[] }\n\t\t\t\tif (oldData && \"todos\" in oldData && Array.isArray(oldData.todos)) {\n\t\t\t\t\tconst updatedTodos = oldData.todos.map((t: TodoResponse) => {\n\t\t\t\t\t\tif (t.id === todo.id) {\n\t\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\t\t...t,\n\t\t\t\t\t\t\t\tparent_todo_id: parentTodoId ?? null,\n\t\t\t\t\t\t\t};\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn t;\n\t\t\t\t\t});\n\t\t\t\t\treturn {\n\t\t\t\t\t\t...oldData,\n\t\t\t\t\t\ttodos: updatedTodos,\n\t\t\t\t\t};\n\t\t\t\t}\n\n\t\t\t\t// 向后兼容：如果是数组格式（不应该发生，但为了安全）\n\t\t\t\tif (Array.isArray(oldData)) {\n\t\t\t\t\treturn oldData.map((t) =>\n\t\t\t\t\t\tt.id === todo.id ? { ...t, parentTodoId: parentTodoId ?? null } : t,\n\t\t\t\t\t) as unknown as TodoListResponse;\n\t\t\t\t}\n\n\t\t\t\treturn oldData;\n\t\t\t},\n\t\t);\n\n\t\tvoid updateTodoApiTodosTodoIdPut(todo.id, {\n\t\t\tparent_todo_id: parentTodoId ?? null,\n\t\t})\n\t\t\t.then(() => {\n\t\t\t\tvoid queryClient.invalidateQueries({ queryKey: queryKeys.todos.all });\n\t\t\t})\n\t\t\t.catch((error) => {\n\t\t\t\tconsole.error(\"[DnD] Failed to update parent:\", error);\n\t\t\t\tif (previousTodos) {\n\t\t\t\t\tqueryClient.setQueryData(queryKeys.todos.list(), previousTodos);\n\t\t\t\t}\n\t\t\t\tvoid queryClient.invalidateQueries({ queryKey: queryKeys.todos.all });\n\t\t\t});\n\t}\n\n\t// 注意：列表内部排序由 TodoList 组件的 useDndMonitor 处理\n\treturn { success: true };\n};\n\n/**\n * TODO_CARD -> TODO_CARD_SLOT\n * 待办拖到另一个待办的前面或后面\n */\nconst handleTodoToTodoCardSlot: DragDropHandler = (\n\tdragData,\n\tdropData,\n): DragDropResult => {\n\tif (dragData.type !== \"TODO_CARD\" || dropData.type !== \"TODO_CARD_SLOT\") {\n\t\treturn { success: false, message: \"Invalid drag/drop type combination\" };\n\t}\n\n\t// TODO: 实现插入逻辑\n\treturn { success: true };\n};\n\n/**\n * TODO_CARD -> TODO_DROP_ZONE\n * 将待办设置为另一个待办的子任务\n * 注意：实际的父子关系设置由 TodoList 组件处理，这里主要做记录\n */\nconst handleTodoToTodoDropZone: DragDropHandler = (\n\tdragData,\n\tdropData,\n): DragDropResult => {\n\tif (dragData.type !== \"TODO_CARD\" || dropData.type !== \"TODO_DROP_ZONE\") {\n\t\treturn { success: false, message: \"Invalid drag/drop type combination\" };\n\t}\n\n\tconst { todo } = dragData.payload;\n\tconst { todoId, position } = dropData.metadata;\n\n\tif (position === \"nest\") {\n\t\tconsole.log(`[DnD] 设置 \"${todo.name}\" 为 todo ${todoId} 的子任务`);\n\t\t// 实际的 API 调用由 TodoList 组件的 handleInternalReorder 处理\n\t\treturn {\n\t\t\tsuccess: true,\n\t\t\tmessage: `已将 \"${todo.name}\" 设置为子任务`,\n\t\t};\n\t}\n\n\treturn { success: false, message: \"Unknown position\" };\n};\n\n/**\n * PANEL_HEADER -> PANEL_HEADER\n * 交换两个面板的位置（功能分配）\n */\nconst handlePanelHeaderToPanelHeader: DragDropHandler = (\n\tdragData,\n\tdropData,\n): DragDropResult => {\n\tif (dragData.type !== \"PANEL_HEADER\" || dropData.type !== \"PANEL_HEADER\") {\n\t\treturn { success: false, message: \"Invalid drag/drop type combination\" };\n\t}\n\n\tconst { position: sourcePosition } = dragData.payload;\n\tconst { position: targetPosition } = dropData.metadata;\n\n\t// 如果源位置和目标位置相同，不需要交换\n\tif (sourcePosition === targetPosition) {\n\t\treturn { success: false, message: \"Cannot swap panel with itself\" };\n\t}\n\n\t// 交换面板位置\n\tuseUiStore.getState().swapPanelPositions(sourcePosition, targetPosition);\n\n\treturn {\n\t\tsuccess: true,\n\t\tmessage: `已交换 ${sourcePosition} 和 ${targetPosition} 的位置`,\n\t};\n};\n\n// ============================================================================\n// 注册内置处理器\n// ============================================================================\n\nregisterHandler(\"TODO_CARD->CALENDAR_DATE\", handleTodoToCalendarDate);\nregisterHandler(\n\t\"TODO_CARD->CALENDAR_TIMELINE_SLOT\",\n\thandleTodoToCalendarTimelineSlot,\n);\nregisterHandler(\"TODO_CARD->TODO_LIST\", handleTodoToTodoList);\nregisterHandler(\"TODO_CARD->TODO_CARD_SLOT\", handleTodoToTodoCardSlot);\nregisterHandler(\"TODO_CARD->TODO_DROP_ZONE\", handleTodoToTodoDropZone);\nregisterHandler(\"PANEL_HEADER->PANEL_HEADER\", handlePanelHeaderToPanelHeader);\n\n// ============================================================================\n// 分发函数 (Dispatch Function)\n// ============================================================================\n\n/**\n * 分发拖拽事件到对应的处理器\n */\nexport function dispatchDragDrop(\n\tdragData: DragData | undefined,\n\tdropData: DropData | undefined,\n): DragDropResult {\n\tif (!dragData || !dropData) {\n\t\treturn { success: false, message: \"Missing drag or drop data\" };\n\t}\n\n\tconst key = `${dragData.type}->${dropData.type}` as HandlerKey;\n\tconst handler = getHandler(key);\n\n\tif (!handler) {\n\t\tconsole.warn(`[DnD] No handler registered for: ${key}`);\n\t\treturn { success: false, message: `No handler for ${key}` };\n\t}\n\n\ttry {\n\t\tconst result = handler(dragData, dropData);\n\t\tif (result.success) {\n\t\t\tconsole.log(`[DnD] ${key}: ${result.message || \"Success\"}`);\n\t\t} else {\n\t\t\tconsole.warn(`[DnD] ${key} failed: ${result.message}`);\n\t\t}\n\t\treturn result;\n\t} catch (error) {\n\t\tconsole.error(`[DnD] Handler error for ${key}:`, error);\n\t\treturn { success: false, message: String(error) };\n\t}\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/dnd/index.ts",
    "content": "/**\n * 跨面板拖拽系统入口\n * Cross-Panel Drag and Drop System Entry\n */\n\n// 上下文\nexport {\n\tGlobalDndContext,\n\tGlobalDndProvider,\n\tuseGlobalDnd,\n\tuseGlobalDndSafe,\n\tusePendingUpdate,\n} from \"./context\";\n// 处理器\nexport {\n\tdispatchDragDrop,\n\tgetHandler,\n\tregisterHandler,\n} from \"./handlers\";\n// 预览组件\nexport { GlobalDragOverlay } from \"./overlays\";\n// 类型导出\nexport type {\n\tActiveDragState,\n\tDragData,\n\tDragDropHandler,\n\tDragDropResult,\n\tDragSourceType,\n\tDropData,\n\tDropTargetType,\n\tGlobalDndContextValue,\n\tHandlerKey,\n} from \"./types\";\n// 类型守卫\nexport {\n\tisCalendarDateDropData,\n\tisTodoCardDragData,\n\tisTodoDropZoneDropData,\n\tisTodoListDropData,\n} from \"./types\";\n"
  },
  {
    "path": "free-todo-frontend/lib/dnd/overlays.tsx",
    "content": "\"use client\";\n\n/**\n * 全局拖拽预览组件\n * Global Drag Overlay Components\n */\n\nimport { DragOverlay } from \"@dnd-kit/core\";\nimport { Calendar, Flag, Paperclip, Tag, X } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport { createPortal } from \"react-dom\";\nimport type { Todo, TodoPriority, TodoStatus } from \"@/lib/types\";\nimport { cn, getPriorityLabel, getStatusLabel } from \"@/lib/utils\";\nimport type { ActiveDragState, DragData } from \"./types\";\n\n// ============================================================================\n// 样式辅助函数\n// ============================================================================\n\nfunction getStatusColor(status: TodoStatus) {\n\tswitch (status) {\n\t\tcase \"active\":\n\t\t\treturn \"border-primary/70 bg-primary/20 text-primary\";\n\t\tcase \"completed\":\n\t\t\treturn \"border-green-500/60 bg-green-500/12 text-green-600 dark:text-green-500\";\n\t\tcase \"draft\":\n\t\t\treturn \"border-orange-500/50 bg-orange-500/8 text-orange-600 dark:text-orange-400\";\n\t\tdefault:\n\t\t\treturn \"border-muted-foreground/40 bg-muted/15 text-muted-foreground\";\n\t}\n}\n\nfunction getPriorityBgColor(priority: TodoPriority) {\n\tswitch (priority) {\n\t\tcase \"high\":\n\t\t\treturn \"border-destructive/60 bg-destructive/10 text-destructive\";\n\t\tcase \"medium\":\n\t\t\treturn \"border-primary/60 bg-primary/10 text-primary\";\n\t\tcase \"low\":\n\t\t\treturn \"border-secondary/60 bg-secondary/20 text-secondary-foreground\";\n\t\tdefault:\n\t\t\treturn \"border-muted-foreground/40 text-muted-foreground\";\n\t}\n}\n\nfunction formatScheduleLabel(startTime?: string, endTime?: string) {\n\tconst schedule = startTime ?? endTime;\n\tif (!schedule) return null;\n\tconst startDate = new Date(schedule);\n\tif (Number.isNaN(startDate.getTime())) return null;\n\tconst dateLabel = startDate.toLocaleDateString(\"en-US\", {\n\t\tyear: \"numeric\",\n\t\tmonth: \"short\",\n\t\tday: \"numeric\",\n\t});\n\tconst timeLabel = startDate.toLocaleTimeString(\"en-US\", {\n\t\thour: \"2-digit\",\n\t\tminute: \"2-digit\",\n\t});\n\tconst startLabel =\n\t\tstartDate.getHours() === 0 && startDate.getMinutes() === 0\n\t\t\t? dateLabel\n\t\t\t: `${dateLabel} ${timeLabel}`;\n\n\tif (!endTime) return startLabel;\n\tconst endDate = new Date(endTime);\n\tif (Number.isNaN(endDate.getTime())) return startLabel;\n\tconst sameDay = startDate.toDateString() === endDate.toDateString();\n\tconst endDateLabel = endDate.toLocaleDateString(\"en-US\", {\n\t\tyear: \"numeric\",\n\t\tmonth: \"short\",\n\t\tday: \"numeric\",\n\t});\n\tconst endTimeLabel = endDate.toLocaleTimeString(\"en-US\", {\n\t\thour: \"2-digit\",\n\t\tminute: \"2-digit\",\n\t});\n\tconst endLabel = sameDay ? endTimeLabel : `${endDateLabel} ${endTimeLabel}`;\n\treturn `${startLabel} - ${endLabel}`;\n}\n\n// ============================================================================\n// Todo 卡片预览组件\n// ============================================================================\n\ninterface TodoCardOverlayProps {\n\ttodo: Todo;\n\tdepth?: number;\n}\n\nfunction TodoCardOverlay({ todo, depth = 0 }: TodoCardOverlayProps) {\n\tconst tCommon = useTranslations(\"common\");\n\tconst tTodoDetail = useTranslations(\"todoDetail\");\n\treturn (\n\t\t<div\n\t\t\tclassName=\"opacity-90 pointer-events-none\"\n\t\t\tstyle={{ marginLeft: depth * 16 }}\n\t\t>\n\t\t\t<div\n\t\t\t\tclassName={cn(\n\t\t\t\t\t\"todo-card group relative flex h-full flex-col gap-3 rounded-xl p-3\",\n\t\t\t\t\t\"border border-transparent transition-all duration-200\",\n\t\t\t\t\t\"bg-card shadow-lg ring-2 ring-primary/30\",\n\t\t\t\t)}\n\t\t\t>\n\t\t\t\t<div className=\"flex items-start gap-2\">\n\t\t\t\t\t<div className=\"w-5 shrink-0\" />\n\t\t\t\t\t<div className=\"shrink-0\">\n\t\t\t\t\t\t{todo.status === \"completed\" ? (\n\t\t\t\t\t\t\t<div className=\"flex h-5 w-5 items-center justify-center rounded-md bg-[oklch(var(--primary))] border border-[oklch(var(--primary))] shadow-inner\">\n\t\t\t\t\t\t\t\t<span className=\"text-[10px] text-[oklch(var(--primary-foreground))] font-semibold\">\n\t\t\t\t\t\t\t\t\t✓\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t) : todo.status === \"canceled\" ? (\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\"flex h-5 w-5 items-center justify-center rounded-md border-2\",\n\t\t\t\t\t\t\t\t\t\"border-muted-foreground/40 bg-muted/30 text-muted-foreground/70\",\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<X className=\"h-3.5 w-3.5\" strokeWidth={2.5} />\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t) : todo.status === \"draft\" ? (\n\t\t\t\t\t\t\t<div className=\"flex h-5 w-5 items-center justify-center rounded-md bg-orange-500 border border-orange-600 dark:border-orange-500 shadow-inner\">\n\t\t\t\t\t\t\t\t<span className=\"text-[12px] text-white dark:text-orange-50 font-semibold\">\n\t\t\t\t\t\t\t\t\t—\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<div className=\"h-5 w-5 rounded-md border-2 border-muted-foreground/40\" />\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\n\t\t\t\t\t<div className=\"flex-1 min-w-0 space-y-1\">\n\t\t\t\t\t\t<div className=\"flex items-start justify-between gap-2\">\n\t\t\t\t\t\t\t<div className=\"min-w-0 flex-1 space-y-1\">\n\t\t\t\t\t\t\t\t<h3\n\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\"text-sm font-semibold text-foreground\",\n\t\t\t\t\t\t\t\t\t\ttodo.status === \"completed\" &&\n\t\t\t\t\t\t\t\t\t\t\t\"line-through text-muted-foreground\",\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{todo.name}\n\t\t\t\t\t\t\t\t</h3>\n\t\t\t\t\t\t\t\t{todo.description && (\n\t\t\t\t\t\t\t\t\t<p className=\"text-xs text-muted-foreground line-clamp-2\">\n\t\t\t\t\t\t\t\t\t\t{todo.description}\n\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t<div className=\"flex items-center gap-2 shrink-0\">\n\t\t\t\t\t\t\t\t{todo.priority && todo.priority !== \"none\" && (\n\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\t\"flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium\",\n\t\t\t\t\t\t\t\t\t\t\tgetPriorityBgColor(todo.priority),\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\ttitle={tTodoDetail(\"priorityLabel\", {\n\t\t\t\t\t\t\t\t\t\t\tpriority: getPriorityLabel(todo.priority, tCommon),\n\t\t\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<Flag className=\"h-3.5 w-3.5\" fill=\"currentColor\" />\n\t\t\t\t\t\t\t\t\t\t<span>{getPriorityLabel(todo.priority, tCommon)}</span>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t{todo.status && (\n\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\t\"px-2 py-0.5 rounded-full text-xs font-medium border shadow-sm\",\n\t\t\t\t\t\t\t\t\t\t\tgetStatusColor(todo.status),\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{getStatusLabel(todo.status, tCommon)}\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t<div className=\"flex flex-wrap items-center gap-2 text-xs text-muted-foreground\">\n\t\t\t\t\t\t\t{(todo.startTime || todo.endTime) && (\n\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-1 rounded-md bg-muted/40 px-2 py-1\">\n\t\t\t\t\t\t\t\t\t<Calendar className=\"h-3 w-3\" />\n\t\t\t\t\t\t\t\t\t<span>\n\t\t\t\t\t\t\t\t\t\t{formatScheduleLabel(todo.startTime, todo.endTime)}\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t\t{todo.attachments && todo.attachments.length > 0 && (\n\t\t\t\t\t\t\t\t<div className=\"flex items-center gap-1 rounded-md bg-muted/40 px-2 py-1\">\n\t\t\t\t\t\t\t\t\t<Paperclip className=\"h-3 w-3\" />\n\t\t\t\t\t\t\t\t\t<span>{todo.attachments.length}</span>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t\t{todo.tags && todo.tags.length > 0 && (\n\t\t\t\t\t\t\t\t<div className=\"flex flex-wrap items-center gap-1\">\n\t\t\t\t\t\t\t\t\t<Tag className=\"h-3 w-3\" />\n\t\t\t\t\t\t\t\t\t{todo.tags.slice(0, 3).map((tag) => (\n\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\tkey={tag}\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"px-2 py-0.5 rounded-full bg-muted text-[11px] font-medium text-foreground\"\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t{tag}\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t{todo.tags.length > 3 && (\n\t\t\t\t\t\t\t\t\t\t<span className=\"text-[11px] text-muted-foreground\">\n\t\t\t\t\t\t\t\t\t\t\t+{todo.tags.length - 3}\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t);\n}\n\n// ============================================================================\n// 简化的日历 Todo 预览\n// ============================================================================\n\ninterface CalendarTodoOverlayProps {\n\ttodo: Todo;\n}\n\nfunction CalendarTodoOverlay({ todo }: CalendarTodoOverlayProps) {\n\treturn (\n\t\t<div\n\t\t\tclassName={cn(\n\t\t\t\t\"opacity-90 pointer-events-none flex flex-col gap-1 rounded-lg border bg-card p-2 text-xs shadow-lg ring-2 ring-primary/30\",\n\t\t\t\tgetStatusColor(todo.status),\n\t\t\t)}\n\t\t>\n\t\t\t<div className=\"flex items-center justify-between gap-2\">\n\t\t\t\t<p className=\"truncate text-[13px] font-semibold\">{todo.name}</p>\n\t\t\t</div>\n\t\t\t{todo.tags && todo.tags.length > 0 && (\n\t\t\t\t<div className=\"flex flex-wrap gap-1\">\n\t\t\t\t\t{todo.tags.slice(0, 2).map((tag) => (\n\t\t\t\t\t\t<span\n\t\t\t\t\t\t\tkey={tag}\n\t\t\t\t\t\t\tclassName=\"rounded-full bg-white/50 px-2 py-0.5 text-[10px] text-muted-foreground\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{tag}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t))}\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</div>\n\t);\n}\n\n// ============================================================================\n// 根据拖拽类型渲染预览\n// ============================================================================\n\ninterface DragOverlayContentProps {\n\tdata: DragData;\n}\n\nfunction DragOverlayContent({ data }: DragOverlayContentProps) {\n\tswitch (data.type) {\n\t\tcase \"TODO_CARD\": {\n\t\t\tconst { todo, depth, sourcePanel } = data.payload;\n\t\t\t// 根据来源面板决定使用哪种预览样式\n\t\t\tif (sourcePanel === \"calendar\") {\n\t\t\t\treturn <CalendarTodoOverlay todo={todo} />;\n\t\t\t}\n\t\t\treturn <TodoCardOverlay todo={todo} depth={depth} />;\n\t\t}\n\t\tcase \"FILE\": {\n\t\t\treturn (\n\t\t\t\t<div className=\"flex items-center gap-2 rounded-lg border bg-card p-3 shadow-lg\">\n\t\t\t\t\t<Paperclip className=\"h-4 w-4 text-muted-foreground\" />\n\t\t\t\t\t<span className=\"text-sm font-medium\">\n\t\t\t\t\t\t{data.payload.file.fileName}\n\t\t\t\t\t</span>\n\t\t\t\t</div>\n\t\t\t);\n\t\t}\n\t\tcase \"USER\": {\n\t\t\treturn (\n\t\t\t\t<div className=\"flex items-center gap-2 rounded-lg border bg-card p-3 shadow-lg\">\n\t\t\t\t\t<div className=\"h-6 w-6 rounded-full bg-primary/20\" />\n\t\t\t\t\t<span className=\"text-sm font-medium\">{data.payload.userName}</span>\n\t\t\t\t</div>\n\t\t\t);\n\t\t}\n\t\tcase \"PANEL_HEADER\": {\n\t\t\t// 不显示拖拽预览\n\t\t\treturn null;\n\t\t}\n\t\tdefault:\n\t\t\treturn null;\n\t}\n}\n\n// ============================================================================\n// 全局拖拽预览组件\n// ============================================================================\n\ninterface GlobalDragOverlayProps {\n\tactiveDrag: ActiveDragState | null;\n}\n\nexport function GlobalDragOverlay({ activeDrag }: GlobalDragOverlayProps) {\n\t// 使用 Portal 渲染到 body，避免父容器 transform 导致的坐标偏移\n\tif (typeof document === \"undefined\") {\n\t\treturn null;\n\t}\n\n\treturn createPortal(\n\t\t<DragOverlay dropAnimation={null}>\n\t\t\t{activeDrag ? <DragOverlayContent data={activeDrag.data} /> : null}\n\t\t</DragOverlay>,\n\t\tdocument.body,\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/dnd/types.ts",
    "content": "/**\n * 跨面板拖拽系统类型定义\n * Cross-Panel Drag and Drop Type Definitions\n */\n\nimport type { UniqueIdentifier } from \"@dnd-kit/core\";\nimport type { Todo, TodoAttachment } from \"@/lib/types\";\n\n// ============================================================================\n// 拖拽源类型 (Drag Source Types)\n// ============================================================================\n\n/**\n * 可拖拽元素的类型，可扩展\n */\nexport type DragSourceType = \"TODO_CARD\" | \"FILE\" | \"USER\" | \"PANEL_HEADER\";\n\n/**\n * 类型安全的拖拽数据 - 使用可辨识联合类型\n * Type-safe drag data using discriminated union\n */\nexport type DragData =\n\t| {\n\t\t\ttype: \"TODO_CARD\";\n\t\t\tpayload: {\n\t\t\t\ttodo: Todo;\n\t\t\t\tdepth?: number; // 用于侧边栏树形结构的缩进层级\n\t\t\t\tsourcePanel?: string; // 来源面板标识\n\t\t\t};\n\t  }\n\t| {\n\t\t\ttype: \"FILE\";\n\t\t\tpayload: {\n\t\t\t\tfile: TodoAttachment;\n\t\t\t\tsourceTodoId?: number;\n\t\t\t};\n\t  }\n\t| {\n\t\t\ttype: \"USER\";\n\t\t\tpayload: {\n\t\t\t\tuserId: string;\n\t\t\t\tuserName: string;\n\t\t\t};\n\t  }\n\t| {\n\t\t\ttype: \"PANEL_HEADER\";\n\t\t\tpayload: {\n\t\t\t\tposition: \"panelA\" | \"panelB\" | \"panelC\";\n\t\t\t};\n\t  };\n\n// ============================================================================\n// 放置目标类型 (Drop Target Types)\n// ============================================================================\n\n/**\n * 可放置区域的类型，可扩展\n */\nexport type DropTargetType =\n\t| \"CALENDAR_DATE\"\n\t| \"CALENDAR_TIMELINE_SLOT\"\n\t| \"TODO_LIST\"\n\t| \"TODO_CARD_SLOT\"\n\t| \"TODO_DROP_ZONE\"\n\t| \"CHAT_WINDOW\"\n\t| \"PANEL_HEADER\";\n\n/**\n * 类型安全的放置区数据 - 使用可辨识联合类型\n * Type-safe drop data using discriminated union\n */\nexport type DropData =\n\t| {\n\t\t\ttype: \"CALENDAR_DATE\";\n\t\t\tmetadata: {\n\t\t\t\tdateKey: string; // 格式: YYYY-MM-DD\n\t\t\t\tdate: Date;\n\t\t\t};\n\t  }\n\t| {\n\t\t\ttype: \"CALENDAR_TIMELINE_SLOT\";\n\t\t\tmetadata: {\n\t\t\t\tdateKey: string; // 格式: YYYY-MM-DD\n\t\t\t\tdate: Date;\n\t\t\t\tminutes: number;\n\t\t\t};\n\t  }\n\t| {\n\t\t\ttype: \"TODO_LIST\";\n\t\t\tmetadata: {\n\t\t\t\ttargetIndex?: number;\n\t\t\t\tparentTodoId?: number | null;\n\t\t\t};\n\t  }\n\t| {\n\t\t\ttype: \"TODO_CARD_SLOT\";\n\t\t\tmetadata: {\n\t\t\t\ttodoId: number;\n\t\t\t\tposition: \"before\" | \"after\";\n\t\t\t};\n\t  }\n\t| {\n\t\t\ttype: \"TODO_DROP_ZONE\";\n\t\t\tmetadata: {\n\t\t\t\ttodoId: number;\n\t\t\t\tposition: \"nest\"; // 设为子任务\n\t\t\t};\n\t  }\n\t| {\n\t\t\ttype: \"CHAT_WINDOW\";\n\t\t\tmetadata: {\n\t\t\t\tconversationId?: string;\n\t\t\t};\n\t  }\n\t| {\n\t\t\ttype: \"PANEL_HEADER\";\n\t\t\tmetadata: {\n\t\t\t\tposition: \"panelA\" | \"panelB\" | \"panelC\";\n\t\t\t};\n\t  };\n\n// ============================================================================\n// 活动拖拽状态 (Active Drag State)\n// ============================================================================\n\n/**\n * 当前正在拖拽的元素状态\n * id 使用 dnd-kit 的 UniqueIdentifier 类型 (string | number)\n * 因为不同面板可能使用不同格式的 ID（如 calendar 使用 \"calendar-{todoId}\"）\n */\nexport interface ActiveDragState {\n\tid: UniqueIdentifier;\n\tdata: DragData;\n}\n\n// ============================================================================\n// 处理器相关类型 (Handler Types)\n// ============================================================================\n\n/**\n * 拖拽处理结果\n */\nexport interface DragDropResult {\n\tsuccess: boolean;\n\tmessage?: string;\n}\n\n/**\n * 拖拽处理器的键类型\n * 格式: \"SOURCE_TYPE->TARGET_TYPE\"\n */\nexport type HandlerKey = `${DragSourceType}->${DropTargetType}`;\n\n/**\n * 拖拽处理器函数签名\n */\nexport type DragDropHandler = (\n\tdragData: DragData,\n\tdropData: DropData,\n) => DragDropResult;\n\n// ============================================================================\n// 上下文类型 (Context Types)\n// ============================================================================\n\n/**\n * 全局拖拽上下文值\n */\nexport interface GlobalDndContextValue {\n\tactiveDrag: ActiveDragState | null;\n}\n\n// ============================================================================\n// 类型守卫 (Type Guards)\n// ============================================================================\n\n/**\n * 检查是否为 TODO_CARD 类型的拖拽数据\n */\nexport function isTodoCardDragData(\n\tdata: DragData,\n): data is Extract<DragData, { type: \"TODO_CARD\" }> {\n\treturn data.type === \"TODO_CARD\";\n}\n\n/**\n * 检查是否为 CALENDAR_DATE 类型的放置数据\n */\nexport function isCalendarDateDropData(\n\tdata: DropData,\n): data is Extract<DropData, { type: \"CALENDAR_DATE\" }> {\n\treturn data.type === \"CALENDAR_DATE\";\n}\n\n/**\n * 检查是否为 TODO_LIST 类型的放置数据\n */\nexport function isTodoListDropData(\n\tdata: DropData,\n): data is Extract<DropData, { type: \"TODO_LIST\" }> {\n\treturn data.type === \"TODO_LIST\";\n}\n\n/**\n * 检查是否为 TODO_DROP_ZONE 类型的放置数据\n */\nexport function isTodoDropZoneDropData(\n\tdata: DropData,\n): data is Extract<DropData, { type: \"TODO_DROP_ZONE\" }> {\n\treturn data.type === \"TODO_DROP_ZONE\";\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/activity/activity.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport {\n  useMutation,\n  useQuery\n} from '@tanstack/react-query';\nimport type {\n  DataTag,\n  DefinedInitialDataOptions,\n  DefinedUseQueryResult,\n  MutationFunction,\n  QueryClient,\n  QueryFunction,\n  QueryKey,\n  UndefinedInitialDataOptions,\n  UseMutationOptions,\n  UseMutationResult,\n  UseQueryOptions,\n  UseQueryResult\n} from '@tanstack/react-query';\n\nimport type {\n  ActivityEventsResponse,\n  ActivityListResponse,\n  HTTPValidationError,\n  ListActivitiesApiActivitiesGetParams,\n  ManualActivityCreateRequest,\n  ManualActivityCreateResponse\n} from '.././schemas';\n\nimport { customFetcher } from '../../api/fetcher';\n\n\ntype SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];\n\n\n\n/**\n * 获取活动列表（活动=聚合的事件窗口）\n * @summary List Activities\n */\nexport type listActivitiesApiActivitiesGetResponse200 = {\n  data: ActivityListResponse\n  status: 200\n}\n\nexport type listActivitiesApiActivitiesGetResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type listActivitiesApiActivitiesGetResponseSuccess = (listActivitiesApiActivitiesGetResponse200) & {\n  headers: Headers;\n};\nexport type listActivitiesApiActivitiesGetResponseError = (listActivitiesApiActivitiesGetResponse422) & {\n  headers: Headers;\n};\n\nexport type listActivitiesApiActivitiesGetResponse = (listActivitiesApiActivitiesGetResponseSuccess | listActivitiesApiActivitiesGetResponseError)\n\nexport const getListActivitiesApiActivitiesGetUrl = (params?: ListActivitiesApiActivitiesGetParams,) => {\n  const normalizedParams = new URLSearchParams();\n\n  Object.entries(params || {}).forEach(([key, value]) => {\n    \n    if (value !== undefined) {\n      normalizedParams.append(key, value === null ? 'null' : value.toString())\n    }\n  });\n\n  const stringifiedParams = normalizedParams.toString();\n\n  return stringifiedParams.length > 0 ? `/api/activities?${stringifiedParams}` : `/api/activities`\n}\n\nexport const listActivitiesApiActivitiesGet = async (params?: ListActivitiesApiActivitiesGetParams, options?: RequestInit): Promise<listActivitiesApiActivitiesGetResponse> => {\n  \n  return customFetcher<listActivitiesApiActivitiesGetResponse>(getListActivitiesApiActivitiesGetUrl(params),\n  {      \n    ...options,\n    method: 'GET'\n    \n    \n  }\n);}\n\n\n\n\n\nexport const getListActivitiesApiActivitiesGetQueryKey = (params?: ListActivitiesApiActivitiesGetParams,) => {\n    return [\n    `/api/activities`, ...(params ? [params] : [])\n    ] as const;\n    }\n\n    \nexport const getListActivitiesApiActivitiesGetQueryOptions = <TData = Awaited<ReturnType<typeof listActivitiesApiActivitiesGet>>, TError = HTTPValidationError>(params?: ListActivitiesApiActivitiesGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listActivitiesApiActivitiesGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n) => {\n\nconst {query: queryOptions, request: requestOptions} = options ?? {};\n\n  const queryKey =  queryOptions?.queryKey ?? getListActivitiesApiActivitiesGetQueryKey(params);\n\n  \n\n    const queryFn: QueryFunction<Awaited<ReturnType<typeof listActivitiesApiActivitiesGet>>> = ({ signal }) => listActivitiesApiActivitiesGet(params, { signal, ...requestOptions });\n\n      \n\n      \n\n   return  { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof listActivitiesApiActivitiesGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }\n}\n\nexport type ListActivitiesApiActivitiesGetQueryResult = NonNullable<Awaited<ReturnType<typeof listActivitiesApiActivitiesGet>>>\nexport type ListActivitiesApiActivitiesGetQueryError = HTTPValidationError\n\n\nexport function useListActivitiesApiActivitiesGet<TData = Awaited<ReturnType<typeof listActivitiesApiActivitiesGet>>, TError = HTTPValidationError>(\n params: undefined |  ListActivitiesApiActivitiesGetParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof listActivitiesApiActivitiesGet>>, TError, TData>> & Pick<\n        DefinedInitialDataOptions<\n          Awaited<ReturnType<typeof listActivitiesApiActivitiesGet>>,\n          TError,\n          Awaited<ReturnType<typeof listActivitiesApiActivitiesGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useListActivitiesApiActivitiesGet<TData = Awaited<ReturnType<typeof listActivitiesApiActivitiesGet>>, TError = HTTPValidationError>(\n params?: ListActivitiesApiActivitiesGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listActivitiesApiActivitiesGet>>, TError, TData>> & Pick<\n        UndefinedInitialDataOptions<\n          Awaited<ReturnType<typeof listActivitiesApiActivitiesGet>>,\n          TError,\n          Awaited<ReturnType<typeof listActivitiesApiActivitiesGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useListActivitiesApiActivitiesGet<TData = Awaited<ReturnType<typeof listActivitiesApiActivitiesGet>>, TError = HTTPValidationError>(\n params?: ListActivitiesApiActivitiesGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listActivitiesApiActivitiesGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\n/**\n * @summary List Activities\n */\n\nexport function useListActivitiesApiActivitiesGet<TData = Awaited<ReturnType<typeof listActivitiesApiActivitiesGet>>, TError = HTTPValidationError>(\n params?: ListActivitiesApiActivitiesGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listActivitiesApiActivitiesGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient \n ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {\n\n  const queryOptions = getListActivitiesApiActivitiesGetQueryOptions(params,options)\n\n  const query = useQuery(queryOptions, queryClient) as  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };\n\n  return { ...query, queryKey: queryOptions.queryKey };\n}\n\n\n\n\n/**\n * 获取指定活动关联的事件ID列表\n * @summary Get Activity Events\n */\nexport type getActivityEventsApiActivitiesActivityIdEventsGetResponse200 = {\n  data: ActivityEventsResponse\n  status: 200\n}\n\nexport type getActivityEventsApiActivitiesActivityIdEventsGetResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type getActivityEventsApiActivitiesActivityIdEventsGetResponseSuccess = (getActivityEventsApiActivitiesActivityIdEventsGetResponse200) & {\n  headers: Headers;\n};\nexport type getActivityEventsApiActivitiesActivityIdEventsGetResponseError = (getActivityEventsApiActivitiesActivityIdEventsGetResponse422) & {\n  headers: Headers;\n};\n\nexport type getActivityEventsApiActivitiesActivityIdEventsGetResponse = (getActivityEventsApiActivitiesActivityIdEventsGetResponseSuccess | getActivityEventsApiActivitiesActivityIdEventsGetResponseError)\n\nexport const getGetActivityEventsApiActivitiesActivityIdEventsGetUrl = (activityId: number,) => {\n\n\n  \n\n  return `/api/activities/${activityId}/events`\n}\n\nexport const getActivityEventsApiActivitiesActivityIdEventsGet = async (activityId: number, options?: RequestInit): Promise<getActivityEventsApiActivitiesActivityIdEventsGetResponse> => {\n  \n  return customFetcher<getActivityEventsApiActivitiesActivityIdEventsGetResponse>(getGetActivityEventsApiActivitiesActivityIdEventsGetUrl(activityId),\n  {      \n    ...options,\n    method: 'GET'\n    \n    \n  }\n);}\n\n\n\n\n\nexport const getGetActivityEventsApiActivitiesActivityIdEventsGetQueryKey = (activityId: number,) => {\n    return [\n    `/api/activities/${activityId}/events`\n    ] as const;\n    }\n\n    \nexport const getGetActivityEventsApiActivitiesActivityIdEventsGetQueryOptions = <TData = Awaited<ReturnType<typeof getActivityEventsApiActivitiesActivityIdEventsGet>>, TError = HTTPValidationError>(activityId: number, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getActivityEventsApiActivitiesActivityIdEventsGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n) => {\n\nconst {query: queryOptions, request: requestOptions} = options ?? {};\n\n  const queryKey =  queryOptions?.queryKey ?? getGetActivityEventsApiActivitiesActivityIdEventsGetQueryKey(activityId);\n\n  \n\n    const queryFn: QueryFunction<Awaited<ReturnType<typeof getActivityEventsApiActivitiesActivityIdEventsGet>>> = ({ signal }) => getActivityEventsApiActivitiesActivityIdEventsGet(activityId, { signal, ...requestOptions });\n\n      \n\n      \n\n   return  { queryKey, queryFn, enabled: !!(activityId), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getActivityEventsApiActivitiesActivityIdEventsGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }\n}\n\nexport type GetActivityEventsApiActivitiesActivityIdEventsGetQueryResult = NonNullable<Awaited<ReturnType<typeof getActivityEventsApiActivitiesActivityIdEventsGet>>>\nexport type GetActivityEventsApiActivitiesActivityIdEventsGetQueryError = HTTPValidationError\n\n\nexport function useGetActivityEventsApiActivitiesActivityIdEventsGet<TData = Awaited<ReturnType<typeof getActivityEventsApiActivitiesActivityIdEventsGet>>, TError = HTTPValidationError>(\n activityId: number, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getActivityEventsApiActivitiesActivityIdEventsGet>>, TError, TData>> & Pick<\n        DefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getActivityEventsApiActivitiesActivityIdEventsGet>>,\n          TError,\n          Awaited<ReturnType<typeof getActivityEventsApiActivitiesActivityIdEventsGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetActivityEventsApiActivitiesActivityIdEventsGet<TData = Awaited<ReturnType<typeof getActivityEventsApiActivitiesActivityIdEventsGet>>, TError = HTTPValidationError>(\n activityId: number, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getActivityEventsApiActivitiesActivityIdEventsGet>>, TError, TData>> & Pick<\n        UndefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getActivityEventsApiActivitiesActivityIdEventsGet>>,\n          TError,\n          Awaited<ReturnType<typeof getActivityEventsApiActivitiesActivityIdEventsGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetActivityEventsApiActivitiesActivityIdEventsGet<TData = Awaited<ReturnType<typeof getActivityEventsApiActivitiesActivityIdEventsGet>>, TError = HTTPValidationError>(\n activityId: number, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getActivityEventsApiActivitiesActivityIdEventsGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\n/**\n * @summary Get Activity Events\n */\n\nexport function useGetActivityEventsApiActivitiesActivityIdEventsGet<TData = Awaited<ReturnType<typeof getActivityEventsApiActivitiesActivityIdEventsGet>>, TError = HTTPValidationError>(\n activityId: number, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getActivityEventsApiActivitiesActivityIdEventsGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient \n ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {\n\n  const queryOptions = getGetActivityEventsApiActivitiesActivityIdEventsGetQueryOptions(activityId,options)\n\n  const query = useQuery(queryOptions, queryClient) as  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };\n\n  return { ...query, queryKey: queryOptions.queryKey };\n}\n\n\n\n\n/**\n * 手动聚合指定事件集合为活动\n\nArgs:\n    request: 包含事件ID列表的请求\n\nReturns:\n    创建的活动信息\n * @summary Create Activity Manual\n */\nexport type createActivityManualApiActivitiesManualPostResponse201 = {\n  data: ManualActivityCreateResponse\n  status: 201\n}\n\nexport type createActivityManualApiActivitiesManualPostResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type createActivityManualApiActivitiesManualPostResponseSuccess = (createActivityManualApiActivitiesManualPostResponse201) & {\n  headers: Headers;\n};\nexport type createActivityManualApiActivitiesManualPostResponseError = (createActivityManualApiActivitiesManualPostResponse422) & {\n  headers: Headers;\n};\n\nexport type createActivityManualApiActivitiesManualPostResponse = (createActivityManualApiActivitiesManualPostResponseSuccess | createActivityManualApiActivitiesManualPostResponseError)\n\nexport const getCreateActivityManualApiActivitiesManualPostUrl = () => {\n\n\n  \n\n  return `/api/activities/manual`\n}\n\nexport const createActivityManualApiActivitiesManualPost = async (manualActivityCreateRequest: ManualActivityCreateRequest, options?: RequestInit): Promise<createActivityManualApiActivitiesManualPostResponse> => {\n  \n  return customFetcher<createActivityManualApiActivitiesManualPostResponse>(getCreateActivityManualApiActivitiesManualPostUrl(),\n  {      \n    ...options,\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json', ...options?.headers },\n    body: JSON.stringify(\n      manualActivityCreateRequest,)\n  }\n);}\n\n\n\n\nexport const getCreateActivityManualApiActivitiesManualPostMutationOptions = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof createActivityManualApiActivitiesManualPost>>, TError,{data: ManualActivityCreateRequest}, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof createActivityManualApiActivitiesManualPost>>, TError,{data: ManualActivityCreateRequest}, TContext> => {\n\nconst mutationKey = ['createActivityManualApiActivitiesManualPost'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof createActivityManualApiActivitiesManualPost>>, {data: ManualActivityCreateRequest}> = (props) => {\n          const {data} = props ?? {};\n\n          return  createActivityManualApiActivitiesManualPost(data,requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type CreateActivityManualApiActivitiesManualPostMutationResult = NonNullable<Awaited<ReturnType<typeof createActivityManualApiActivitiesManualPost>>>\n    export type CreateActivityManualApiActivitiesManualPostMutationBody = ManualActivityCreateRequest\n    export type CreateActivityManualApiActivitiesManualPostMutationError = HTTPValidationError\n\n    /**\n * @summary Create Activity Manual\n */\nexport const useCreateActivityManualApiActivitiesManualPost = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof createActivityManualApiActivitiesManualPost>>, TError,{data: ManualActivityCreateRequest}, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof createActivityManualApiActivitiesManualPost>>,\n        TError,\n        {data: ManualActivityCreateRequest},\n        TContext\n      > => {\n      return useMutation(getCreateActivityManualApiActivitiesManualPostMutationOptions(options), queryClient);\n    }\n    "
  },
  {
    "path": "free-todo-frontend/lib/generated/audio/audio.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport {\n  useMutation,\n  useQuery\n} from '@tanstack/react-query';\nimport type {\n  DataTag,\n  DefinedInitialDataOptions,\n  DefinedUseQueryResult,\n  MutationFunction,\n  QueryClient,\n  QueryFunction,\n  QueryKey,\n  UndefinedInitialDataOptions,\n  UseMutationOptions,\n  UseMutationResult,\n  UseQueryOptions,\n  UseQueryResult\n} from '@tanstack/react-query';\n\nimport type {\n  AudioLinkRequest,\n  ExtractTodosAndSchedulesApiAudioExtractPostParams,\n  GetRecordingsApiAudioRecordingsGetParams,\n  GetTimelineApiAudioTimelineGetParams,\n  GetTranscriptionApiAudioTranscriptionRecordingIdGetParams,\n  HTTPValidationError,\n  LinkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostParams,\n  OptimizeTranscriptionApiAudioOptimizePostParams\n} from '.././schemas';\n\nimport { customFetcher } from '../../api/fetcher';\n\n\ntype SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];\n\n\n\n/**\n * 获取录音列表\n * @summary Get Recordings\n */\nexport type getRecordingsApiAudioRecordingsGetResponse200 = {\n  data: unknown\n  status: 200\n}\n\nexport type getRecordingsApiAudioRecordingsGetResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type getRecordingsApiAudioRecordingsGetResponseSuccess = (getRecordingsApiAudioRecordingsGetResponse200) & {\n  headers: Headers;\n};\nexport type getRecordingsApiAudioRecordingsGetResponseError = (getRecordingsApiAudioRecordingsGetResponse422) & {\n  headers: Headers;\n};\n\nexport type getRecordingsApiAudioRecordingsGetResponse = (getRecordingsApiAudioRecordingsGetResponseSuccess | getRecordingsApiAudioRecordingsGetResponseError)\n\nexport const getGetRecordingsApiAudioRecordingsGetUrl = (params?: GetRecordingsApiAudioRecordingsGetParams,) => {\n  const normalizedParams = new URLSearchParams();\n\n  Object.entries(params || {}).forEach(([key, value]) => {\n    \n    if (value !== undefined) {\n      normalizedParams.append(key, value === null ? 'null' : value.toString())\n    }\n  });\n\n  const stringifiedParams = normalizedParams.toString();\n\n  return stringifiedParams.length > 0 ? `/api/audio/recordings?${stringifiedParams}` : `/api/audio/recordings`\n}\n\nexport const getRecordingsApiAudioRecordingsGet = async (params?: GetRecordingsApiAudioRecordingsGetParams, options?: RequestInit): Promise<getRecordingsApiAudioRecordingsGetResponse> => {\n  \n  return customFetcher<getRecordingsApiAudioRecordingsGetResponse>(getGetRecordingsApiAudioRecordingsGetUrl(params),\n  {      \n    ...options,\n    method: 'GET'\n    \n    \n  }\n);}\n\n\n\n\n\nexport const getGetRecordingsApiAudioRecordingsGetQueryKey = (params?: GetRecordingsApiAudioRecordingsGetParams,) => {\n    return [\n    `/api/audio/recordings`, ...(params ? [params] : [])\n    ] as const;\n    }\n\n    \nexport const getGetRecordingsApiAudioRecordingsGetQueryOptions = <TData = Awaited<ReturnType<typeof getRecordingsApiAudioRecordingsGet>>, TError = HTTPValidationError>(params?: GetRecordingsApiAudioRecordingsGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getRecordingsApiAudioRecordingsGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n) => {\n\nconst {query: queryOptions, request: requestOptions} = options ?? {};\n\n  const queryKey =  queryOptions?.queryKey ?? getGetRecordingsApiAudioRecordingsGetQueryKey(params);\n\n  \n\n    const queryFn: QueryFunction<Awaited<ReturnType<typeof getRecordingsApiAudioRecordingsGet>>> = ({ signal }) => getRecordingsApiAudioRecordingsGet(params, { signal, ...requestOptions });\n\n      \n\n      \n\n   return  { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getRecordingsApiAudioRecordingsGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }\n}\n\nexport type GetRecordingsApiAudioRecordingsGetQueryResult = NonNullable<Awaited<ReturnType<typeof getRecordingsApiAudioRecordingsGet>>>\nexport type GetRecordingsApiAudioRecordingsGetQueryError = HTTPValidationError\n\n\nexport function useGetRecordingsApiAudioRecordingsGet<TData = Awaited<ReturnType<typeof getRecordingsApiAudioRecordingsGet>>, TError = HTTPValidationError>(\n params: undefined |  GetRecordingsApiAudioRecordingsGetParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getRecordingsApiAudioRecordingsGet>>, TError, TData>> & Pick<\n        DefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getRecordingsApiAudioRecordingsGet>>,\n          TError,\n          Awaited<ReturnType<typeof getRecordingsApiAudioRecordingsGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetRecordingsApiAudioRecordingsGet<TData = Awaited<ReturnType<typeof getRecordingsApiAudioRecordingsGet>>, TError = HTTPValidationError>(\n params?: GetRecordingsApiAudioRecordingsGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getRecordingsApiAudioRecordingsGet>>, TError, TData>> & Pick<\n        UndefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getRecordingsApiAudioRecordingsGet>>,\n          TError,\n          Awaited<ReturnType<typeof getRecordingsApiAudioRecordingsGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetRecordingsApiAudioRecordingsGet<TData = Awaited<ReturnType<typeof getRecordingsApiAudioRecordingsGet>>, TError = HTTPValidationError>(\n params?: GetRecordingsApiAudioRecordingsGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getRecordingsApiAudioRecordingsGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\n/**\n * @summary Get Recordings\n */\n\nexport function useGetRecordingsApiAudioRecordingsGet<TData = Awaited<ReturnType<typeof getRecordingsApiAudioRecordingsGet>>, TError = HTTPValidationError>(\n params?: GetRecordingsApiAudioRecordingsGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getRecordingsApiAudioRecordingsGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient \n ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {\n\n  const queryOptions = getGetRecordingsApiAudioRecordingsGetQueryOptions(params,options)\n\n  const query = useQuery(queryOptions, queryClient) as  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };\n\n  return { ...query, queryKey: queryOptions.queryKey };\n}\n\n\n\n\n/**\n * 按日期返回录音时间线（含转录文本）\n * @summary Get Timeline\n */\nexport type getTimelineApiAudioTimelineGetResponse200 = {\n  data: unknown\n  status: 200\n}\n\nexport type getTimelineApiAudioTimelineGetResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type getTimelineApiAudioTimelineGetResponseSuccess = (getTimelineApiAudioTimelineGetResponse200) & {\n  headers: Headers;\n};\nexport type getTimelineApiAudioTimelineGetResponseError = (getTimelineApiAudioTimelineGetResponse422) & {\n  headers: Headers;\n};\n\nexport type getTimelineApiAudioTimelineGetResponse = (getTimelineApiAudioTimelineGetResponseSuccess | getTimelineApiAudioTimelineGetResponseError)\n\nexport const getGetTimelineApiAudioTimelineGetUrl = (params?: GetTimelineApiAudioTimelineGetParams,) => {\n  const normalizedParams = new URLSearchParams();\n\n  Object.entries(params || {}).forEach(([key, value]) => {\n    \n    if (value !== undefined) {\n      normalizedParams.append(key, value === null ? 'null' : value.toString())\n    }\n  });\n\n  const stringifiedParams = normalizedParams.toString();\n\n  return stringifiedParams.length > 0 ? `/api/audio/timeline?${stringifiedParams}` : `/api/audio/timeline`\n}\n\nexport const getTimelineApiAudioTimelineGet = async (params?: GetTimelineApiAudioTimelineGetParams, options?: RequestInit): Promise<getTimelineApiAudioTimelineGetResponse> => {\n  \n  return customFetcher<getTimelineApiAudioTimelineGetResponse>(getGetTimelineApiAudioTimelineGetUrl(params),\n  {      \n    ...options,\n    method: 'GET'\n    \n    \n  }\n);}\n\n\n\n\n\nexport const getGetTimelineApiAudioTimelineGetQueryKey = (params?: GetTimelineApiAudioTimelineGetParams,) => {\n    return [\n    `/api/audio/timeline`, ...(params ? [params] : [])\n    ] as const;\n    }\n\n    \nexport const getGetTimelineApiAudioTimelineGetQueryOptions = <TData = Awaited<ReturnType<typeof getTimelineApiAudioTimelineGet>>, TError = HTTPValidationError>(params?: GetTimelineApiAudioTimelineGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getTimelineApiAudioTimelineGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n) => {\n\nconst {query: queryOptions, request: requestOptions} = options ?? {};\n\n  const queryKey =  queryOptions?.queryKey ?? getGetTimelineApiAudioTimelineGetQueryKey(params);\n\n  \n\n    const queryFn: QueryFunction<Awaited<ReturnType<typeof getTimelineApiAudioTimelineGet>>> = ({ signal }) => getTimelineApiAudioTimelineGet(params, { signal, ...requestOptions });\n\n      \n\n      \n\n   return  { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getTimelineApiAudioTimelineGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }\n}\n\nexport type GetTimelineApiAudioTimelineGetQueryResult = NonNullable<Awaited<ReturnType<typeof getTimelineApiAudioTimelineGet>>>\nexport type GetTimelineApiAudioTimelineGetQueryError = HTTPValidationError\n\n\nexport function useGetTimelineApiAudioTimelineGet<TData = Awaited<ReturnType<typeof getTimelineApiAudioTimelineGet>>, TError = HTTPValidationError>(\n params: undefined |  GetTimelineApiAudioTimelineGetParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getTimelineApiAudioTimelineGet>>, TError, TData>> & Pick<\n        DefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getTimelineApiAudioTimelineGet>>,\n          TError,\n          Awaited<ReturnType<typeof getTimelineApiAudioTimelineGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetTimelineApiAudioTimelineGet<TData = Awaited<ReturnType<typeof getTimelineApiAudioTimelineGet>>, TError = HTTPValidationError>(\n params?: GetTimelineApiAudioTimelineGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getTimelineApiAudioTimelineGet>>, TError, TData>> & Pick<\n        UndefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getTimelineApiAudioTimelineGet>>,\n          TError,\n          Awaited<ReturnType<typeof getTimelineApiAudioTimelineGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetTimelineApiAudioTimelineGet<TData = Awaited<ReturnType<typeof getTimelineApiAudioTimelineGet>>, TError = HTTPValidationError>(\n params?: GetTimelineApiAudioTimelineGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getTimelineApiAudioTimelineGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\n/**\n * @summary Get Timeline\n */\n\nexport function useGetTimelineApiAudioTimelineGet<TData = Awaited<ReturnType<typeof getTimelineApiAudioTimelineGet>>, TError = HTTPValidationError>(\n params?: GetTimelineApiAudioTimelineGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getTimelineApiAudioTimelineGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient \n ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {\n\n  const queryOptions = getGetTimelineApiAudioTimelineGetQueryOptions(params,options)\n\n  const query = useQuery(queryOptions, queryClient) as  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };\n\n  return { ...query, queryKey: queryOptions.queryKey };\n}\n\n\n\n\n/**\n * 获取录音文件（用于前端播放）\n * @summary Get Recording File\n */\nexport type getRecordingFileApiAudioRecordingRecordingIdFileGetResponse200 = {\n  data: unknown\n  status: 200\n}\n\nexport type getRecordingFileApiAudioRecordingRecordingIdFileGetResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type getRecordingFileApiAudioRecordingRecordingIdFileGetResponseSuccess = (getRecordingFileApiAudioRecordingRecordingIdFileGetResponse200) & {\n  headers: Headers;\n};\nexport type getRecordingFileApiAudioRecordingRecordingIdFileGetResponseError = (getRecordingFileApiAudioRecordingRecordingIdFileGetResponse422) & {\n  headers: Headers;\n};\n\nexport type getRecordingFileApiAudioRecordingRecordingIdFileGetResponse = (getRecordingFileApiAudioRecordingRecordingIdFileGetResponseSuccess | getRecordingFileApiAudioRecordingRecordingIdFileGetResponseError)\n\nexport const getGetRecordingFileApiAudioRecordingRecordingIdFileGetUrl = (recordingId: number,) => {\n\n\n  \n\n  return `/api/audio/recording/${recordingId}/file`\n}\n\nexport const getRecordingFileApiAudioRecordingRecordingIdFileGet = async (recordingId: number, options?: RequestInit): Promise<getRecordingFileApiAudioRecordingRecordingIdFileGetResponse> => {\n  \n  return customFetcher<getRecordingFileApiAudioRecordingRecordingIdFileGetResponse>(getGetRecordingFileApiAudioRecordingRecordingIdFileGetUrl(recordingId),\n  {      \n    ...options,\n    method: 'GET'\n    \n    \n  }\n);}\n\n\n\n\n\nexport const getGetRecordingFileApiAudioRecordingRecordingIdFileGetQueryKey = (recordingId: number,) => {\n    return [\n    `/api/audio/recording/${recordingId}/file`\n    ] as const;\n    }\n\n    \nexport const getGetRecordingFileApiAudioRecordingRecordingIdFileGetQueryOptions = <TData = Awaited<ReturnType<typeof getRecordingFileApiAudioRecordingRecordingIdFileGet>>, TError = HTTPValidationError>(recordingId: number, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getRecordingFileApiAudioRecordingRecordingIdFileGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n) => {\n\nconst {query: queryOptions, request: requestOptions} = options ?? {};\n\n  const queryKey =  queryOptions?.queryKey ?? getGetRecordingFileApiAudioRecordingRecordingIdFileGetQueryKey(recordingId);\n\n  \n\n    const queryFn: QueryFunction<Awaited<ReturnType<typeof getRecordingFileApiAudioRecordingRecordingIdFileGet>>> = ({ signal }) => getRecordingFileApiAudioRecordingRecordingIdFileGet(recordingId, { signal, ...requestOptions });\n\n      \n\n      \n\n   return  { queryKey, queryFn, enabled: !!(recordingId), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getRecordingFileApiAudioRecordingRecordingIdFileGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }\n}\n\nexport type GetRecordingFileApiAudioRecordingRecordingIdFileGetQueryResult = NonNullable<Awaited<ReturnType<typeof getRecordingFileApiAudioRecordingRecordingIdFileGet>>>\nexport type GetRecordingFileApiAudioRecordingRecordingIdFileGetQueryError = HTTPValidationError\n\n\nexport function useGetRecordingFileApiAudioRecordingRecordingIdFileGet<TData = Awaited<ReturnType<typeof getRecordingFileApiAudioRecordingRecordingIdFileGet>>, TError = HTTPValidationError>(\n recordingId: number, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getRecordingFileApiAudioRecordingRecordingIdFileGet>>, TError, TData>> & Pick<\n        DefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getRecordingFileApiAudioRecordingRecordingIdFileGet>>,\n          TError,\n          Awaited<ReturnType<typeof getRecordingFileApiAudioRecordingRecordingIdFileGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetRecordingFileApiAudioRecordingRecordingIdFileGet<TData = Awaited<ReturnType<typeof getRecordingFileApiAudioRecordingRecordingIdFileGet>>, TError = HTTPValidationError>(\n recordingId: number, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getRecordingFileApiAudioRecordingRecordingIdFileGet>>, TError, TData>> & Pick<\n        UndefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getRecordingFileApiAudioRecordingRecordingIdFileGet>>,\n          TError,\n          Awaited<ReturnType<typeof getRecordingFileApiAudioRecordingRecordingIdFileGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetRecordingFileApiAudioRecordingRecordingIdFileGet<TData = Awaited<ReturnType<typeof getRecordingFileApiAudioRecordingRecordingIdFileGet>>, TError = HTTPValidationError>(\n recordingId: number, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getRecordingFileApiAudioRecordingRecordingIdFileGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\n/**\n * @summary Get Recording File\n */\n\nexport function useGetRecordingFileApiAudioRecordingRecordingIdFileGet<TData = Awaited<ReturnType<typeof getRecordingFileApiAudioRecordingRecordingIdFileGet>>, TError = HTTPValidationError>(\n recordingId: number, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getRecordingFileApiAudioRecordingRecordingIdFileGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient \n ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {\n\n  const queryOptions = getGetRecordingFileApiAudioRecordingRecordingIdFileGetQueryOptions(recordingId,options)\n\n  const query = useQuery(queryOptions, queryClient) as  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };\n\n  return { ...query, queryKey: queryOptions.queryKey };\n}\n\n\n\n\n/**\n * 获取转录文本\n * @summary Get Transcription\n */\nexport type getTranscriptionApiAudioTranscriptionRecordingIdGetResponse200 = {\n  data: unknown\n  status: 200\n}\n\nexport type getTranscriptionApiAudioTranscriptionRecordingIdGetResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type getTranscriptionApiAudioTranscriptionRecordingIdGetResponseSuccess = (getTranscriptionApiAudioTranscriptionRecordingIdGetResponse200) & {\n  headers: Headers;\n};\nexport type getTranscriptionApiAudioTranscriptionRecordingIdGetResponseError = (getTranscriptionApiAudioTranscriptionRecordingIdGetResponse422) & {\n  headers: Headers;\n};\n\nexport type getTranscriptionApiAudioTranscriptionRecordingIdGetResponse = (getTranscriptionApiAudioTranscriptionRecordingIdGetResponseSuccess | getTranscriptionApiAudioTranscriptionRecordingIdGetResponseError)\n\nexport const getGetTranscriptionApiAudioTranscriptionRecordingIdGetUrl = (recordingId: number,\n    params?: GetTranscriptionApiAudioTranscriptionRecordingIdGetParams,) => {\n  const normalizedParams = new URLSearchParams();\n\n  Object.entries(params || {}).forEach(([key, value]) => {\n    \n    if (value !== undefined) {\n      normalizedParams.append(key, value === null ? 'null' : value.toString())\n    }\n  });\n\n  const stringifiedParams = normalizedParams.toString();\n\n  return stringifiedParams.length > 0 ? `/api/audio/transcription/${recordingId}?${stringifiedParams}` : `/api/audio/transcription/${recordingId}`\n}\n\nexport const getTranscriptionApiAudioTranscriptionRecordingIdGet = async (recordingId: number,\n    params?: GetTranscriptionApiAudioTranscriptionRecordingIdGetParams, options?: RequestInit): Promise<getTranscriptionApiAudioTranscriptionRecordingIdGetResponse> => {\n  \n  return customFetcher<getTranscriptionApiAudioTranscriptionRecordingIdGetResponse>(getGetTranscriptionApiAudioTranscriptionRecordingIdGetUrl(recordingId,params),\n  {      \n    ...options,\n    method: 'GET'\n    \n    \n  }\n);}\n\n\n\n\n\nexport const getGetTranscriptionApiAudioTranscriptionRecordingIdGetQueryKey = (recordingId: number,\n    params?: GetTranscriptionApiAudioTranscriptionRecordingIdGetParams,) => {\n    return [\n    `/api/audio/transcription/${recordingId}`, ...(params ? [params] : [])\n    ] as const;\n    }\n\n    \nexport const getGetTranscriptionApiAudioTranscriptionRecordingIdGetQueryOptions = <TData = Awaited<ReturnType<typeof getTranscriptionApiAudioTranscriptionRecordingIdGet>>, TError = HTTPValidationError>(recordingId: number,\n    params?: GetTranscriptionApiAudioTranscriptionRecordingIdGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getTranscriptionApiAudioTranscriptionRecordingIdGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n) => {\n\nconst {query: queryOptions, request: requestOptions} = options ?? {};\n\n  const queryKey =  queryOptions?.queryKey ?? getGetTranscriptionApiAudioTranscriptionRecordingIdGetQueryKey(recordingId,params);\n\n  \n\n    const queryFn: QueryFunction<Awaited<ReturnType<typeof getTranscriptionApiAudioTranscriptionRecordingIdGet>>> = ({ signal }) => getTranscriptionApiAudioTranscriptionRecordingIdGet(recordingId,params, { signal, ...requestOptions });\n\n      \n\n      \n\n   return  { queryKey, queryFn, enabled: !!(recordingId), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getTranscriptionApiAudioTranscriptionRecordingIdGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }\n}\n\nexport type GetTranscriptionApiAudioTranscriptionRecordingIdGetQueryResult = NonNullable<Awaited<ReturnType<typeof getTranscriptionApiAudioTranscriptionRecordingIdGet>>>\nexport type GetTranscriptionApiAudioTranscriptionRecordingIdGetQueryError = HTTPValidationError\n\n\nexport function useGetTranscriptionApiAudioTranscriptionRecordingIdGet<TData = Awaited<ReturnType<typeof getTranscriptionApiAudioTranscriptionRecordingIdGet>>, TError = HTTPValidationError>(\n recordingId: number,\n    params: undefined |  GetTranscriptionApiAudioTranscriptionRecordingIdGetParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getTranscriptionApiAudioTranscriptionRecordingIdGet>>, TError, TData>> & Pick<\n        DefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getTranscriptionApiAudioTranscriptionRecordingIdGet>>,\n          TError,\n          Awaited<ReturnType<typeof getTranscriptionApiAudioTranscriptionRecordingIdGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetTranscriptionApiAudioTranscriptionRecordingIdGet<TData = Awaited<ReturnType<typeof getTranscriptionApiAudioTranscriptionRecordingIdGet>>, TError = HTTPValidationError>(\n recordingId: number,\n    params?: GetTranscriptionApiAudioTranscriptionRecordingIdGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getTranscriptionApiAudioTranscriptionRecordingIdGet>>, TError, TData>> & Pick<\n        UndefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getTranscriptionApiAudioTranscriptionRecordingIdGet>>,\n          TError,\n          Awaited<ReturnType<typeof getTranscriptionApiAudioTranscriptionRecordingIdGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetTranscriptionApiAudioTranscriptionRecordingIdGet<TData = Awaited<ReturnType<typeof getTranscriptionApiAudioTranscriptionRecordingIdGet>>, TError = HTTPValidationError>(\n recordingId: number,\n    params?: GetTranscriptionApiAudioTranscriptionRecordingIdGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getTranscriptionApiAudioTranscriptionRecordingIdGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\n/**\n * @summary Get Transcription\n */\n\nexport function useGetTranscriptionApiAudioTranscriptionRecordingIdGet<TData = Awaited<ReturnType<typeof getTranscriptionApiAudioTranscriptionRecordingIdGet>>, TError = HTTPValidationError>(\n recordingId: number,\n    params?: GetTranscriptionApiAudioTranscriptionRecordingIdGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getTranscriptionApiAudioTranscriptionRecordingIdGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient \n ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {\n\n  const queryOptions = getGetTranscriptionApiAudioTranscriptionRecordingIdGetQueryOptions(recordingId,params,options)\n\n  const query = useQuery(queryOptions, queryClient) as  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };\n\n  return { ...query, queryKey: queryOptions.queryKey };\n}\n\n\n\n\n/**\n * Mark extracted items as linked to todos (persisted in transcription JSON).\n\nArgs:\n    recording_id: 录音ID\n    request: 链接请求\n    optimized: 是否更新优化文本的提取结果\n * @summary Link Extracted Items\n */\nexport type linkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostResponse200 = {\n  data: unknown\n  status: 200\n}\n\nexport type linkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type linkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostResponseSuccess = (linkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostResponse200) & {\n  headers: Headers;\n};\nexport type linkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostResponseError = (linkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostResponse422) & {\n  headers: Headers;\n};\n\nexport type linkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostResponse = (linkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostResponseSuccess | linkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostResponseError)\n\nexport const getLinkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostUrl = (recordingId: number,\n    params?: LinkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostParams,) => {\n  const normalizedParams = new URLSearchParams();\n\n  Object.entries(params || {}).forEach(([key, value]) => {\n    \n    if (value !== undefined) {\n      normalizedParams.append(key, value === null ? 'null' : value.toString())\n    }\n  });\n\n  const stringifiedParams = normalizedParams.toString();\n\n  return stringifiedParams.length > 0 ? `/api/audio/transcription/${recordingId}/link?${stringifiedParams}` : `/api/audio/transcription/${recordingId}/link`\n}\n\nexport const linkExtractedItemsApiAudioTranscriptionRecordingIdLinkPost = async (recordingId: number,\n    audioLinkRequest: AudioLinkRequest,\n    params?: LinkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostParams, options?: RequestInit): Promise<linkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostResponse> => {\n  \n  return customFetcher<linkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostResponse>(getLinkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostUrl(recordingId,params),\n  {      \n    ...options,\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json', ...options?.headers },\n    body: JSON.stringify(\n      audioLinkRequest,)\n  }\n);}\n\n\n\n\nexport const getLinkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostMutationOptions = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof linkExtractedItemsApiAudioTranscriptionRecordingIdLinkPost>>, TError,{recordingId: number;data: AudioLinkRequest;params?: LinkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostParams}, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof linkExtractedItemsApiAudioTranscriptionRecordingIdLinkPost>>, TError,{recordingId: number;data: AudioLinkRequest;params?: LinkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostParams}, TContext> => {\n\nconst mutationKey = ['linkExtractedItemsApiAudioTranscriptionRecordingIdLinkPost'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof linkExtractedItemsApiAudioTranscriptionRecordingIdLinkPost>>, {recordingId: number;data: AudioLinkRequest;params?: LinkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostParams}> = (props) => {\n          const {recordingId,data,params} = props ?? {};\n\n          return  linkExtractedItemsApiAudioTranscriptionRecordingIdLinkPost(recordingId,data,params,requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type LinkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostMutationResult = NonNullable<Awaited<ReturnType<typeof linkExtractedItemsApiAudioTranscriptionRecordingIdLinkPost>>>\n    export type LinkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostMutationBody = AudioLinkRequest\n    export type LinkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostMutationError = HTTPValidationError\n\n    /**\n * @summary Link Extracted Items\n */\nexport const useLinkExtractedItemsApiAudioTranscriptionRecordingIdLinkPost = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof linkExtractedItemsApiAudioTranscriptionRecordingIdLinkPost>>, TError,{recordingId: number;data: AudioLinkRequest;params?: LinkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostParams}, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof linkExtractedItemsApiAudioTranscriptionRecordingIdLinkPost>>,\n        TError,\n        {recordingId: number;data: AudioLinkRequest;params?: LinkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostParams},\n        TContext\n      > => {\n      return useMutation(getLinkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostMutationOptions(options), queryClient);\n    }\n    /**\n * 优化转录文本（使用LLM）\n * @summary Optimize Transcription\n */\nexport type optimizeTranscriptionApiAudioOptimizePostResponse200 = {\n  data: unknown\n  status: 200\n}\n\nexport type optimizeTranscriptionApiAudioOptimizePostResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type optimizeTranscriptionApiAudioOptimizePostResponseSuccess = (optimizeTranscriptionApiAudioOptimizePostResponse200) & {\n  headers: Headers;\n};\nexport type optimizeTranscriptionApiAudioOptimizePostResponseError = (optimizeTranscriptionApiAudioOptimizePostResponse422) & {\n  headers: Headers;\n};\n\nexport type optimizeTranscriptionApiAudioOptimizePostResponse = (optimizeTranscriptionApiAudioOptimizePostResponseSuccess | optimizeTranscriptionApiAudioOptimizePostResponseError)\n\nexport const getOptimizeTranscriptionApiAudioOptimizePostUrl = (params: OptimizeTranscriptionApiAudioOptimizePostParams,) => {\n  const normalizedParams = new URLSearchParams();\n\n  Object.entries(params || {}).forEach(([key, value]) => {\n    \n    if (value !== undefined) {\n      normalizedParams.append(key, value === null ? 'null' : value.toString())\n    }\n  });\n\n  const stringifiedParams = normalizedParams.toString();\n\n  return stringifiedParams.length > 0 ? `/api/audio/optimize?${stringifiedParams}` : `/api/audio/optimize`\n}\n\nexport const optimizeTranscriptionApiAudioOptimizePost = async (params: OptimizeTranscriptionApiAudioOptimizePostParams, options?: RequestInit): Promise<optimizeTranscriptionApiAudioOptimizePostResponse> => {\n  \n  return customFetcher<optimizeTranscriptionApiAudioOptimizePostResponse>(getOptimizeTranscriptionApiAudioOptimizePostUrl(params),\n  {      \n    ...options,\n    method: 'POST'\n    \n    \n  }\n);}\n\n\n\n\nexport const getOptimizeTranscriptionApiAudioOptimizePostMutationOptions = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof optimizeTranscriptionApiAudioOptimizePost>>, TError,{params: OptimizeTranscriptionApiAudioOptimizePostParams}, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof optimizeTranscriptionApiAudioOptimizePost>>, TError,{params: OptimizeTranscriptionApiAudioOptimizePostParams}, TContext> => {\n\nconst mutationKey = ['optimizeTranscriptionApiAudioOptimizePost'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof optimizeTranscriptionApiAudioOptimizePost>>, {params: OptimizeTranscriptionApiAudioOptimizePostParams}> = (props) => {\n          const {params} = props ?? {};\n\n          return  optimizeTranscriptionApiAudioOptimizePost(params,requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type OptimizeTranscriptionApiAudioOptimizePostMutationResult = NonNullable<Awaited<ReturnType<typeof optimizeTranscriptionApiAudioOptimizePost>>>\n    \n    export type OptimizeTranscriptionApiAudioOptimizePostMutationError = HTTPValidationError\n\n    /**\n * @summary Optimize Transcription\n */\nexport const useOptimizeTranscriptionApiAudioOptimizePost = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof optimizeTranscriptionApiAudioOptimizePost>>, TError,{params: OptimizeTranscriptionApiAudioOptimizePostParams}, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof optimizeTranscriptionApiAudioOptimizePost>>,\n        TError,\n        {params: OptimizeTranscriptionApiAudioOptimizePostParams},\n        TContext\n      > => {\n      return useMutation(getOptimizeTranscriptionApiAudioOptimizePostMutationOptions(options), queryClient);\n    }\n    /**\n * 提取待办事项和日程安排\n\nArgs:\n    recording_id: 录音ID\n    optimized: 是否从优化文本提取（False=从原文提取）\n * @summary Extract Todos And Schedules\n */\nexport type extractTodosAndSchedulesApiAudioExtractPostResponse200 = {\n  data: unknown\n  status: 200\n}\n\nexport type extractTodosAndSchedulesApiAudioExtractPostResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type extractTodosAndSchedulesApiAudioExtractPostResponseSuccess = (extractTodosAndSchedulesApiAudioExtractPostResponse200) & {\n  headers: Headers;\n};\nexport type extractTodosAndSchedulesApiAudioExtractPostResponseError = (extractTodosAndSchedulesApiAudioExtractPostResponse422) & {\n  headers: Headers;\n};\n\nexport type extractTodosAndSchedulesApiAudioExtractPostResponse = (extractTodosAndSchedulesApiAudioExtractPostResponseSuccess | extractTodosAndSchedulesApiAudioExtractPostResponseError)\n\nexport const getExtractTodosAndSchedulesApiAudioExtractPostUrl = (params: ExtractTodosAndSchedulesApiAudioExtractPostParams,) => {\n  const normalizedParams = new URLSearchParams();\n\n  Object.entries(params || {}).forEach(([key, value]) => {\n    \n    if (value !== undefined) {\n      normalizedParams.append(key, value === null ? 'null' : value.toString())\n    }\n  });\n\n  const stringifiedParams = normalizedParams.toString();\n\n  return stringifiedParams.length > 0 ? `/api/audio/extract?${stringifiedParams}` : `/api/audio/extract`\n}\n\nexport const extractTodosAndSchedulesApiAudioExtractPost = async (params: ExtractTodosAndSchedulesApiAudioExtractPostParams, options?: RequestInit): Promise<extractTodosAndSchedulesApiAudioExtractPostResponse> => {\n  \n  return customFetcher<extractTodosAndSchedulesApiAudioExtractPostResponse>(getExtractTodosAndSchedulesApiAudioExtractPostUrl(params),\n  {      \n    ...options,\n    method: 'POST'\n    \n    \n  }\n);}\n\n\n\n\nexport const getExtractTodosAndSchedulesApiAudioExtractPostMutationOptions = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof extractTodosAndSchedulesApiAudioExtractPost>>, TError,{params: ExtractTodosAndSchedulesApiAudioExtractPostParams}, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof extractTodosAndSchedulesApiAudioExtractPost>>, TError,{params: ExtractTodosAndSchedulesApiAudioExtractPostParams}, TContext> => {\n\nconst mutationKey = ['extractTodosAndSchedulesApiAudioExtractPost'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof extractTodosAndSchedulesApiAudioExtractPost>>, {params: ExtractTodosAndSchedulesApiAudioExtractPostParams}> = (props) => {\n          const {params} = props ?? {};\n\n          return  extractTodosAndSchedulesApiAudioExtractPost(params,requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type ExtractTodosAndSchedulesApiAudioExtractPostMutationResult = NonNullable<Awaited<ReturnType<typeof extractTodosAndSchedulesApiAudioExtractPost>>>\n    \n    export type ExtractTodosAndSchedulesApiAudioExtractPostMutationError = HTTPValidationError\n\n    /**\n * @summary Extract Todos And Schedules\n */\nexport const useExtractTodosAndSchedulesApiAudioExtractPost = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof extractTodosAndSchedulesApiAudioExtractPost>>, TError,{params: ExtractTodosAndSchedulesApiAudioExtractPostParams}, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof extractTodosAndSchedulesApiAudioExtractPost>>,\n        TError,\n        {params: ExtractTodosAndSchedulesApiAudioExtractPostParams},\n        TContext\n      > => {\n      return useMutation(getExtractTodosAndSchedulesApiAudioExtractPostMutationOptions(options), queryClient);\n    }\n    "
  },
  {
    "path": "free-todo-frontend/lib/generated/case-transform.ts",
    "content": "/**\n * snake_case <-> camelCase conversion utilities\n * Used by customFetcher to auto-transform API request/response keys\n */\n\n/**\n * Convert snake_case string to camelCase\n * @example \"user_notes\" -> \"userNotes\"\n */\nexport function toCamelCase(str: string): string {\n\treturn str.replace(/_([a-z])/g, (_, c) => c.toUpperCase());\n}\n\n/**\n * Convert camelCase string to snake_case\n * @example \"userNotes\" -> \"user_notes\"\n */\nexport function toSnakeCase(str: string): string {\n\treturn str.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);\n}\n\n/**\n * Recursively transform all keys of an object using the provided transformer function\n * Handles nested objects, arrays, and primitive values\n */\nexport function transformKeys<T>(\n\tobj: unknown,\n\ttransformer: (key: string) => string,\n): T {\n\tif (obj === null || obj === undefined) return obj as T;\n\tif (Array.isArray(obj)) {\n\t\treturn obj.map((item) => transformKeys(item, transformer)) as T;\n\t}\n\tif (typeof obj === \"object\" && obj instanceof Date) {\n\t\treturn obj as T;\n\t}\n\tif (typeof obj === \"object\") {\n\t\treturn Object.fromEntries(\n\t\t\tObject.entries(obj as Record<string, unknown>).map(([k, v]) => [\n\t\t\t\ttransformer(k),\n\t\t\t\ttransformKeys(v, transformer),\n\t\t\t]),\n\t\t) as T;\n\t}\n\treturn obj as T;\n}\n\n/**\n * Convert all keys from snake_case to camelCase\n * Used for API response transformation\n */\nexport const snakeToCamel = <T>(obj: unknown): T =>\n\ttransformKeys<T>(obj, toCamelCase);\n\n/**\n * Convert all keys from camelCase to snake_case\n * Used for API request transformation\n */\nexport const camelToSnake = <T>(obj: unknown): T =>\n\ttransformKeys<T>(obj, toSnakeCase);\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/chat/chat.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport {\n  useMutation,\n  useQuery\n} from '@tanstack/react-query';\nimport type {\n  DataTag,\n  DefinedInitialDataOptions,\n  DefinedUseQueryResult,\n  MutationFunction,\n  QueryClient,\n  QueryFunction,\n  QueryKey,\n  UndefinedInitialDataOptions,\n  UseMutationOptions,\n  UseMutationResult,\n  UseQueryOptions,\n  UseQueryResult\n} from '@tanstack/react-query';\n\nimport type {\n  AddMessageRequest,\n  ChatMessage,\n  ChatMessageWithContext,\n  ChatResponse,\n  GetChatHistoryApiChatHistoryGetParams,\n  GetQuerySuggestionsApiChatSuggestionsGetParams,\n  HTTPValidationError,\n  MessageTodoExtractionRequest,\n  MessageTodoExtractionResponse,\n  NewChatRequest,\n  NewChatResponse,\n  PlanQuestionnaireRequest,\n  PlanSummaryRequest\n} from '.././schemas';\n\nimport { customFetcher } from '../../api/fetcher';\n\n\ntype SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];\n\n\n\n/**\n * 带事件上下文的流式聊天接口\n * @summary Chat With Context Stream\n */\nexport type chatWithContextStreamApiChatStreamWithContextPostResponse200 = {\n  data: unknown\n  status: 200\n}\n\nexport type chatWithContextStreamApiChatStreamWithContextPostResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type chatWithContextStreamApiChatStreamWithContextPostResponseSuccess = (chatWithContextStreamApiChatStreamWithContextPostResponse200) & {\n  headers: Headers;\n};\nexport type chatWithContextStreamApiChatStreamWithContextPostResponseError = (chatWithContextStreamApiChatStreamWithContextPostResponse422) & {\n  headers: Headers;\n};\n\nexport type chatWithContextStreamApiChatStreamWithContextPostResponse = (chatWithContextStreamApiChatStreamWithContextPostResponseSuccess | chatWithContextStreamApiChatStreamWithContextPostResponseError)\n\nexport const getChatWithContextStreamApiChatStreamWithContextPostUrl = () => {\n\n\n  \n\n  return `/api/chat/stream-with-context`\n}\n\nexport const chatWithContextStreamApiChatStreamWithContextPost = async (chatMessageWithContext: ChatMessageWithContext, options?: RequestInit): Promise<chatWithContextStreamApiChatStreamWithContextPostResponse> => {\n  \n  return customFetcher<chatWithContextStreamApiChatStreamWithContextPostResponse>(getChatWithContextStreamApiChatStreamWithContextPostUrl(),\n  {      \n    ...options,\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json', ...options?.headers },\n    body: JSON.stringify(\n      chatMessageWithContext,)\n  }\n);}\n\n\n\n\nexport const getChatWithContextStreamApiChatStreamWithContextPostMutationOptions = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof chatWithContextStreamApiChatStreamWithContextPost>>, TError,{data: ChatMessageWithContext}, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof chatWithContextStreamApiChatStreamWithContextPost>>, TError,{data: ChatMessageWithContext}, TContext> => {\n\nconst mutationKey = ['chatWithContextStreamApiChatStreamWithContextPost'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof chatWithContextStreamApiChatStreamWithContextPost>>, {data: ChatMessageWithContext}> = (props) => {\n          const {data} = props ?? {};\n\n          return  chatWithContextStreamApiChatStreamWithContextPost(data,requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type ChatWithContextStreamApiChatStreamWithContextPostMutationResult = NonNullable<Awaited<ReturnType<typeof chatWithContextStreamApiChatStreamWithContextPost>>>\n    export type ChatWithContextStreamApiChatStreamWithContextPostMutationBody = ChatMessageWithContext\n    export type ChatWithContextStreamApiChatStreamWithContextPostMutationError = HTTPValidationError\n\n    /**\n * @summary Chat With Context Stream\n */\nexport const useChatWithContextStreamApiChatStreamWithContextPost = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof chatWithContextStreamApiChatStreamWithContextPost>>, TError,{data: ChatMessageWithContext}, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof chatWithContextStreamApiChatStreamWithContextPost>>,\n        TError,\n        {data: ChatMessageWithContext},\n        TContext\n      > => {\n      return useMutation(getChatWithContextStreamApiChatStreamWithContextPostMutationOptions(options), queryClient);\n    }\n    /**\n * 与LLM聊天接口 - 集成RAG功能\n * @summary Chat With Llm\n */\nexport type chatWithLlmApiChatPostResponse200 = {\n  data: ChatResponse\n  status: 200\n}\n\nexport type chatWithLlmApiChatPostResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type chatWithLlmApiChatPostResponseSuccess = (chatWithLlmApiChatPostResponse200) & {\n  headers: Headers;\n};\nexport type chatWithLlmApiChatPostResponseError = (chatWithLlmApiChatPostResponse422) & {\n  headers: Headers;\n};\n\nexport type chatWithLlmApiChatPostResponse = (chatWithLlmApiChatPostResponseSuccess | chatWithLlmApiChatPostResponseError)\n\nexport const getChatWithLlmApiChatPostUrl = () => {\n\n\n  \n\n  return `/api/chat`\n}\n\nexport const chatWithLlmApiChatPost = async (chatMessage: ChatMessage, options?: RequestInit): Promise<chatWithLlmApiChatPostResponse> => {\n  \n  return customFetcher<chatWithLlmApiChatPostResponse>(getChatWithLlmApiChatPostUrl(),\n  {      \n    ...options,\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json', ...options?.headers },\n    body: JSON.stringify(\n      chatMessage,)\n  }\n);}\n\n\n\n\nexport const getChatWithLlmApiChatPostMutationOptions = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof chatWithLlmApiChatPost>>, TError,{data: ChatMessage}, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof chatWithLlmApiChatPost>>, TError,{data: ChatMessage}, TContext> => {\n\nconst mutationKey = ['chatWithLlmApiChatPost'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof chatWithLlmApiChatPost>>, {data: ChatMessage}> = (props) => {\n          const {data} = props ?? {};\n\n          return  chatWithLlmApiChatPost(data,requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type ChatWithLlmApiChatPostMutationResult = NonNullable<Awaited<ReturnType<typeof chatWithLlmApiChatPost>>>\n    export type ChatWithLlmApiChatPostMutationBody = ChatMessage\n    export type ChatWithLlmApiChatPostMutationError = HTTPValidationError\n\n    /**\n * @summary Chat With Llm\n */\nexport const useChatWithLlmApiChatPost = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof chatWithLlmApiChatPost>>, TError,{data: ChatMessage}, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof chatWithLlmApiChatPost>>,\n        TError,\n        {data: ChatMessage},\n        TContext\n      > => {\n      return useMutation(getChatWithLlmApiChatPostMutationOptions(options), queryClient);\n    }\n    /**\n * 与LLM聊天接口（流式输出）\n\n支持额外的 mode 字段：\n- 默认为现有行为（走本地 LLM + RAG）\n- 当 mode == \"dify_test\" 时，走 Dify 测试通道\n- 当 mode == \"agno\" 时，走 Agno Agent 通道（支持 file/shell 等外部工具）\n * @summary Chat With Llm Stream\n */\nexport type chatWithLlmStreamApiChatStreamPostResponse200 = {\n  data: unknown\n  status: 200\n}\n\nexport type chatWithLlmStreamApiChatStreamPostResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type chatWithLlmStreamApiChatStreamPostResponseSuccess = (chatWithLlmStreamApiChatStreamPostResponse200) & {\n  headers: Headers;\n};\nexport type chatWithLlmStreamApiChatStreamPostResponseError = (chatWithLlmStreamApiChatStreamPostResponse422) & {\n  headers: Headers;\n};\n\nexport type chatWithLlmStreamApiChatStreamPostResponse = (chatWithLlmStreamApiChatStreamPostResponseSuccess | chatWithLlmStreamApiChatStreamPostResponseError)\n\nexport const getChatWithLlmStreamApiChatStreamPostUrl = () => {\n\n\n  \n\n  return `/api/chat/stream`\n}\n\nexport const chatWithLlmStreamApiChatStreamPost = async (chatMessage: ChatMessage, options?: RequestInit): Promise<chatWithLlmStreamApiChatStreamPostResponse> => {\n  \n  return customFetcher<chatWithLlmStreamApiChatStreamPostResponse>(getChatWithLlmStreamApiChatStreamPostUrl(),\n  {      \n    ...options,\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json', ...options?.headers },\n    body: JSON.stringify(\n      chatMessage,)\n  }\n);}\n\n\n\n\nexport const getChatWithLlmStreamApiChatStreamPostMutationOptions = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof chatWithLlmStreamApiChatStreamPost>>, TError,{data: ChatMessage}, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof chatWithLlmStreamApiChatStreamPost>>, TError,{data: ChatMessage}, TContext> => {\n\nconst mutationKey = ['chatWithLlmStreamApiChatStreamPost'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof chatWithLlmStreamApiChatStreamPost>>, {data: ChatMessage}> = (props) => {\n          const {data} = props ?? {};\n\n          return  chatWithLlmStreamApiChatStreamPost(data,requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type ChatWithLlmStreamApiChatStreamPostMutationResult = NonNullable<Awaited<ReturnType<typeof chatWithLlmStreamApiChatStreamPost>>>\n    export type ChatWithLlmStreamApiChatStreamPostMutationBody = ChatMessage\n    export type ChatWithLlmStreamApiChatStreamPostMutationError = HTTPValidationError\n\n    /**\n * @summary Chat With Llm Stream\n */\nexport const useChatWithLlmStreamApiChatStreamPost = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof chatWithLlmStreamApiChatStreamPost>>, TError,{data: ChatMessage}, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof chatWithLlmStreamApiChatStreamPost>>,\n        TError,\n        {data: ChatMessage},\n        TContext\n      > => {\n      return useMutation(getChatWithLlmStreamApiChatStreamPostMutationOptions(options), queryClient);\n    }\n    /**\n * 从消息中提取待办事项\n\nArgs:\n    request: 包含消息列表、父待办ID和待办上下文的请求\n\nReturns:\n    提取的待办列表\n\nRaises:\n    HTTPException: 当提取失败时\n * @summary Extract Todos From Messages\n */\nexport type extractTodosFromMessagesApiChatExtractTodosFromMessagesPostResponse200 = {\n  data: MessageTodoExtractionResponse\n  status: 200\n}\n\nexport type extractTodosFromMessagesApiChatExtractTodosFromMessagesPostResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type extractTodosFromMessagesApiChatExtractTodosFromMessagesPostResponseSuccess = (extractTodosFromMessagesApiChatExtractTodosFromMessagesPostResponse200) & {\n  headers: Headers;\n};\nexport type extractTodosFromMessagesApiChatExtractTodosFromMessagesPostResponseError = (extractTodosFromMessagesApiChatExtractTodosFromMessagesPostResponse422) & {\n  headers: Headers;\n};\n\nexport type extractTodosFromMessagesApiChatExtractTodosFromMessagesPostResponse = (extractTodosFromMessagesApiChatExtractTodosFromMessagesPostResponseSuccess | extractTodosFromMessagesApiChatExtractTodosFromMessagesPostResponseError)\n\nexport const getExtractTodosFromMessagesApiChatExtractTodosFromMessagesPostUrl = () => {\n\n\n  \n\n  return `/api/chat/extract-todos-from-messages`\n}\n\nexport const extractTodosFromMessagesApiChatExtractTodosFromMessagesPost = async (messageTodoExtractionRequest: MessageTodoExtractionRequest, options?: RequestInit): Promise<extractTodosFromMessagesApiChatExtractTodosFromMessagesPostResponse> => {\n  \n  return customFetcher<extractTodosFromMessagesApiChatExtractTodosFromMessagesPostResponse>(getExtractTodosFromMessagesApiChatExtractTodosFromMessagesPostUrl(),\n  {      \n    ...options,\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json', ...options?.headers },\n    body: JSON.stringify(\n      messageTodoExtractionRequest,)\n  }\n);}\n\n\n\n\nexport const getExtractTodosFromMessagesApiChatExtractTodosFromMessagesPostMutationOptions = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof extractTodosFromMessagesApiChatExtractTodosFromMessagesPost>>, TError,{data: MessageTodoExtractionRequest}, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof extractTodosFromMessagesApiChatExtractTodosFromMessagesPost>>, TError,{data: MessageTodoExtractionRequest}, TContext> => {\n\nconst mutationKey = ['extractTodosFromMessagesApiChatExtractTodosFromMessagesPost'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof extractTodosFromMessagesApiChatExtractTodosFromMessagesPost>>, {data: MessageTodoExtractionRequest}> = (props) => {\n          const {data} = props ?? {};\n\n          return  extractTodosFromMessagesApiChatExtractTodosFromMessagesPost(data,requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type ExtractTodosFromMessagesApiChatExtractTodosFromMessagesPostMutationResult = NonNullable<Awaited<ReturnType<typeof extractTodosFromMessagesApiChatExtractTodosFromMessagesPost>>>\n    export type ExtractTodosFromMessagesApiChatExtractTodosFromMessagesPostMutationBody = MessageTodoExtractionRequest\n    export type ExtractTodosFromMessagesApiChatExtractTodosFromMessagesPostMutationError = HTTPValidationError\n\n    /**\n * @summary Extract Todos From Messages\n */\nexport const useExtractTodosFromMessagesApiChatExtractTodosFromMessagesPost = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof extractTodosFromMessagesApiChatExtractTodosFromMessagesPost>>, TError,{data: MessageTodoExtractionRequest}, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof extractTodosFromMessagesApiChatExtractTodosFromMessagesPost>>,\n        TError,\n        {data: MessageTodoExtractionRequest},\n        TContext\n      > => {\n      return useMutation(getExtractTodosFromMessagesApiChatExtractTodosFromMessagesPostMutationOptions(options), queryClient);\n    }\n    /**\n * 创建新对话会话\n * @summary Create New Chat\n */\nexport type createNewChatApiChatNewPostResponse200 = {\n  data: NewChatResponse\n  status: 200\n}\n\nexport type createNewChatApiChatNewPostResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type createNewChatApiChatNewPostResponseSuccess = (createNewChatApiChatNewPostResponse200) & {\n  headers: Headers;\n};\nexport type createNewChatApiChatNewPostResponseError = (createNewChatApiChatNewPostResponse422) & {\n  headers: Headers;\n};\n\nexport type createNewChatApiChatNewPostResponse = (createNewChatApiChatNewPostResponseSuccess | createNewChatApiChatNewPostResponseError)\n\nexport const getCreateNewChatApiChatNewPostUrl = () => {\n\n\n  \n\n  return `/api/chat/new`\n}\n\nexport const createNewChatApiChatNewPost = async (newChatRequestNull: NewChatRequest | null, options?: RequestInit): Promise<createNewChatApiChatNewPostResponse> => {\n  \n  return customFetcher<createNewChatApiChatNewPostResponse>(getCreateNewChatApiChatNewPostUrl(),\n  {      \n    ...options,\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json', ...options?.headers },\n    body: JSON.stringify(\n      newChatRequestNull,)\n  }\n);}\n\n\n\n\nexport const getCreateNewChatApiChatNewPostMutationOptions = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof createNewChatApiChatNewPost>>, TError,{data: NewChatRequest | null}, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof createNewChatApiChatNewPost>>, TError,{data: NewChatRequest | null}, TContext> => {\n\nconst mutationKey = ['createNewChatApiChatNewPost'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof createNewChatApiChatNewPost>>, {data: NewChatRequest | null}> = (props) => {\n          const {data} = props ?? {};\n\n          return  createNewChatApiChatNewPost(data,requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type CreateNewChatApiChatNewPostMutationResult = NonNullable<Awaited<ReturnType<typeof createNewChatApiChatNewPost>>>\n    export type CreateNewChatApiChatNewPostMutationBody = NewChatRequest | null\n    export type CreateNewChatApiChatNewPostMutationError = HTTPValidationError\n\n    /**\n * @summary Create New Chat\n */\nexport const useCreateNewChatApiChatNewPost = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof createNewChatApiChatNewPost>>, TError,{data: NewChatRequest | null}, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof createNewChatApiChatNewPost>>,\n        TError,\n        {data: NewChatRequest | null},\n        TContext\n      > => {\n      return useMutation(getCreateNewChatApiChatNewPostMutationOptions(options), queryClient);\n    }\n    /**\n * 添加消息到会话（消息已在流式聊天中自动保存，此接口保持兼容性）\n * @summary Add Message To Session\n */\nexport type addMessageToSessionApiChatSessionSessionIdMessagePostResponse200 = {\n  data: unknown\n  status: 200\n}\n\nexport type addMessageToSessionApiChatSessionSessionIdMessagePostResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type addMessageToSessionApiChatSessionSessionIdMessagePostResponseSuccess = (addMessageToSessionApiChatSessionSessionIdMessagePostResponse200) & {\n  headers: Headers;\n};\nexport type addMessageToSessionApiChatSessionSessionIdMessagePostResponseError = (addMessageToSessionApiChatSessionSessionIdMessagePostResponse422) & {\n  headers: Headers;\n};\n\nexport type addMessageToSessionApiChatSessionSessionIdMessagePostResponse = (addMessageToSessionApiChatSessionSessionIdMessagePostResponseSuccess | addMessageToSessionApiChatSessionSessionIdMessagePostResponseError)\n\nexport const getAddMessageToSessionApiChatSessionSessionIdMessagePostUrl = (sessionId: string,) => {\n\n\n  \n\n  return `/api/chat/session/${sessionId}/message`\n}\n\nexport const addMessageToSessionApiChatSessionSessionIdMessagePost = async (sessionId: string,\n    addMessageRequest: AddMessageRequest, options?: RequestInit): Promise<addMessageToSessionApiChatSessionSessionIdMessagePostResponse> => {\n  \n  return customFetcher<addMessageToSessionApiChatSessionSessionIdMessagePostResponse>(getAddMessageToSessionApiChatSessionSessionIdMessagePostUrl(sessionId),\n  {      \n    ...options,\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json', ...options?.headers },\n    body: JSON.stringify(\n      addMessageRequest,)\n  }\n);}\n\n\n\n\nexport const getAddMessageToSessionApiChatSessionSessionIdMessagePostMutationOptions = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof addMessageToSessionApiChatSessionSessionIdMessagePost>>, TError,{sessionId: string;data: AddMessageRequest}, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof addMessageToSessionApiChatSessionSessionIdMessagePost>>, TError,{sessionId: string;data: AddMessageRequest}, TContext> => {\n\nconst mutationKey = ['addMessageToSessionApiChatSessionSessionIdMessagePost'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof addMessageToSessionApiChatSessionSessionIdMessagePost>>, {sessionId: string;data: AddMessageRequest}> = (props) => {\n          const {sessionId,data} = props ?? {};\n\n          return  addMessageToSessionApiChatSessionSessionIdMessagePost(sessionId,data,requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type AddMessageToSessionApiChatSessionSessionIdMessagePostMutationResult = NonNullable<Awaited<ReturnType<typeof addMessageToSessionApiChatSessionSessionIdMessagePost>>>\n    export type AddMessageToSessionApiChatSessionSessionIdMessagePostMutationBody = AddMessageRequest\n    export type AddMessageToSessionApiChatSessionSessionIdMessagePostMutationError = HTTPValidationError\n\n    /**\n * @summary Add Message To Session\n */\nexport const useAddMessageToSessionApiChatSessionSessionIdMessagePost = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof addMessageToSessionApiChatSessionSessionIdMessagePost>>, TError,{sessionId: string;data: AddMessageRequest}, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof addMessageToSessionApiChatSessionSessionIdMessagePost>>,\n        TError,\n        {sessionId: string;data: AddMessageRequest},\n        TContext\n      > => {\n      return useMutation(getAddMessageToSessionApiChatSessionSessionIdMessagePostMutationOptions(options), queryClient);\n    }\n    /**\n * 清除指定会话的上下文\n * @summary Clear Chat Session\n */\nexport type clearChatSessionApiChatSessionSessionIdDeleteResponse200 = {\n  data: unknown\n  status: 200\n}\n\nexport type clearChatSessionApiChatSessionSessionIdDeleteResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type clearChatSessionApiChatSessionSessionIdDeleteResponseSuccess = (clearChatSessionApiChatSessionSessionIdDeleteResponse200) & {\n  headers: Headers;\n};\nexport type clearChatSessionApiChatSessionSessionIdDeleteResponseError = (clearChatSessionApiChatSessionSessionIdDeleteResponse422) & {\n  headers: Headers;\n};\n\nexport type clearChatSessionApiChatSessionSessionIdDeleteResponse = (clearChatSessionApiChatSessionSessionIdDeleteResponseSuccess | clearChatSessionApiChatSessionSessionIdDeleteResponseError)\n\nexport const getClearChatSessionApiChatSessionSessionIdDeleteUrl = (sessionId: string,) => {\n\n\n  \n\n  return `/api/chat/session/${sessionId}`\n}\n\nexport const clearChatSessionApiChatSessionSessionIdDelete = async (sessionId: string, options?: RequestInit): Promise<clearChatSessionApiChatSessionSessionIdDeleteResponse> => {\n  \n  return customFetcher<clearChatSessionApiChatSessionSessionIdDeleteResponse>(getClearChatSessionApiChatSessionSessionIdDeleteUrl(sessionId),\n  {      \n    ...options,\n    method: 'DELETE'\n    \n    \n  }\n);}\n\n\n\n\nexport const getClearChatSessionApiChatSessionSessionIdDeleteMutationOptions = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof clearChatSessionApiChatSessionSessionIdDelete>>, TError,{sessionId: string}, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof clearChatSessionApiChatSessionSessionIdDelete>>, TError,{sessionId: string}, TContext> => {\n\nconst mutationKey = ['clearChatSessionApiChatSessionSessionIdDelete'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof clearChatSessionApiChatSessionSessionIdDelete>>, {sessionId: string}> = (props) => {\n          const {sessionId} = props ?? {};\n\n          return  clearChatSessionApiChatSessionSessionIdDelete(sessionId,requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type ClearChatSessionApiChatSessionSessionIdDeleteMutationResult = NonNullable<Awaited<ReturnType<typeof clearChatSessionApiChatSessionSessionIdDelete>>>\n    \n    export type ClearChatSessionApiChatSessionSessionIdDeleteMutationError = HTTPValidationError\n\n    /**\n * @summary Clear Chat Session\n */\nexport const useClearChatSessionApiChatSessionSessionIdDelete = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof clearChatSessionApiChatSessionSessionIdDelete>>, TError,{sessionId: string}, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof clearChatSessionApiChatSessionSessionIdDelete>>,\n        TError,\n        {sessionId: string},\n        TContext\n      > => {\n      return useMutation(getClearChatSessionApiChatSessionSessionIdDeleteMutationOptions(options), queryClient);\n    }\n    /**\n * 获取聊天历史记录（从数据库读取）\n * @summary Get Chat History\n */\nexport type getChatHistoryApiChatHistoryGetResponse200 = {\n  data: unknown\n  status: 200\n}\n\nexport type getChatHistoryApiChatHistoryGetResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type getChatHistoryApiChatHistoryGetResponseSuccess = (getChatHistoryApiChatHistoryGetResponse200) & {\n  headers: Headers;\n};\nexport type getChatHistoryApiChatHistoryGetResponseError = (getChatHistoryApiChatHistoryGetResponse422) & {\n  headers: Headers;\n};\n\nexport type getChatHistoryApiChatHistoryGetResponse = (getChatHistoryApiChatHistoryGetResponseSuccess | getChatHistoryApiChatHistoryGetResponseError)\n\nexport const getGetChatHistoryApiChatHistoryGetUrl = (params?: GetChatHistoryApiChatHistoryGetParams,) => {\n  const normalizedParams = new URLSearchParams();\n\n  Object.entries(params || {}).forEach(([key, value]) => {\n    \n    if (value !== undefined) {\n      normalizedParams.append(key, value === null ? 'null' : value.toString())\n    }\n  });\n\n  const stringifiedParams = normalizedParams.toString();\n\n  return stringifiedParams.length > 0 ? `/api/chat/history?${stringifiedParams}` : `/api/chat/history`\n}\n\nexport const getChatHistoryApiChatHistoryGet = async (params?: GetChatHistoryApiChatHistoryGetParams, options?: RequestInit): Promise<getChatHistoryApiChatHistoryGetResponse> => {\n  \n  return customFetcher<getChatHistoryApiChatHistoryGetResponse>(getGetChatHistoryApiChatHistoryGetUrl(params),\n  {      \n    ...options,\n    method: 'GET'\n    \n    \n  }\n);}\n\n\n\n\n\nexport const getGetChatHistoryApiChatHistoryGetQueryKey = (params?: GetChatHistoryApiChatHistoryGetParams,) => {\n    return [\n    `/api/chat/history`, ...(params ? [params] : [])\n    ] as const;\n    }\n\n    \nexport const getGetChatHistoryApiChatHistoryGetQueryOptions = <TData = Awaited<ReturnType<typeof getChatHistoryApiChatHistoryGet>>, TError = HTTPValidationError>(params?: GetChatHistoryApiChatHistoryGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getChatHistoryApiChatHistoryGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n) => {\n\nconst {query: queryOptions, request: requestOptions} = options ?? {};\n\n  const queryKey =  queryOptions?.queryKey ?? getGetChatHistoryApiChatHistoryGetQueryKey(params);\n\n  \n\n    const queryFn: QueryFunction<Awaited<ReturnType<typeof getChatHistoryApiChatHistoryGet>>> = ({ signal }) => getChatHistoryApiChatHistoryGet(params, { signal, ...requestOptions });\n\n      \n\n      \n\n   return  { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getChatHistoryApiChatHistoryGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }\n}\n\nexport type GetChatHistoryApiChatHistoryGetQueryResult = NonNullable<Awaited<ReturnType<typeof getChatHistoryApiChatHistoryGet>>>\nexport type GetChatHistoryApiChatHistoryGetQueryError = HTTPValidationError\n\n\nexport function useGetChatHistoryApiChatHistoryGet<TData = Awaited<ReturnType<typeof getChatHistoryApiChatHistoryGet>>, TError = HTTPValidationError>(\n params: undefined |  GetChatHistoryApiChatHistoryGetParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getChatHistoryApiChatHistoryGet>>, TError, TData>> & Pick<\n        DefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getChatHistoryApiChatHistoryGet>>,\n          TError,\n          Awaited<ReturnType<typeof getChatHistoryApiChatHistoryGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetChatHistoryApiChatHistoryGet<TData = Awaited<ReturnType<typeof getChatHistoryApiChatHistoryGet>>, TError = HTTPValidationError>(\n params?: GetChatHistoryApiChatHistoryGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getChatHistoryApiChatHistoryGet>>, TError, TData>> & Pick<\n        UndefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getChatHistoryApiChatHistoryGet>>,\n          TError,\n          Awaited<ReturnType<typeof getChatHistoryApiChatHistoryGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetChatHistoryApiChatHistoryGet<TData = Awaited<ReturnType<typeof getChatHistoryApiChatHistoryGet>>, TError = HTTPValidationError>(\n params?: GetChatHistoryApiChatHistoryGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getChatHistoryApiChatHistoryGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\n/**\n * @summary Get Chat History\n */\n\nexport function useGetChatHistoryApiChatHistoryGet<TData = Awaited<ReturnType<typeof getChatHistoryApiChatHistoryGet>>, TError = HTTPValidationError>(\n params?: GetChatHistoryApiChatHistoryGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getChatHistoryApiChatHistoryGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient \n ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {\n\n  const queryOptions = getGetChatHistoryApiChatHistoryGetQueryOptions(params,options)\n\n  const query = useQuery(queryOptions, queryClient) as  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };\n\n  return { ...query, queryKey: queryOptions.queryKey };\n}\n\n\n\n\n/**\n * 获取查询建议\n * @summary Get Query Suggestions\n */\nexport type getQuerySuggestionsApiChatSuggestionsGetResponse200 = {\n  data: unknown\n  status: 200\n}\n\nexport type getQuerySuggestionsApiChatSuggestionsGetResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type getQuerySuggestionsApiChatSuggestionsGetResponseSuccess = (getQuerySuggestionsApiChatSuggestionsGetResponse200) & {\n  headers: Headers;\n};\nexport type getQuerySuggestionsApiChatSuggestionsGetResponseError = (getQuerySuggestionsApiChatSuggestionsGetResponse422) & {\n  headers: Headers;\n};\n\nexport type getQuerySuggestionsApiChatSuggestionsGetResponse = (getQuerySuggestionsApiChatSuggestionsGetResponseSuccess | getQuerySuggestionsApiChatSuggestionsGetResponseError)\n\nexport const getGetQuerySuggestionsApiChatSuggestionsGetUrl = (params?: GetQuerySuggestionsApiChatSuggestionsGetParams,) => {\n  const normalizedParams = new URLSearchParams();\n\n  Object.entries(params || {}).forEach(([key, value]) => {\n    \n    if (value !== undefined) {\n      normalizedParams.append(key, value === null ? 'null' : value.toString())\n    }\n  });\n\n  const stringifiedParams = normalizedParams.toString();\n\n  return stringifiedParams.length > 0 ? `/api/chat/suggestions?${stringifiedParams}` : `/api/chat/suggestions`\n}\n\nexport const getQuerySuggestionsApiChatSuggestionsGet = async (params?: GetQuerySuggestionsApiChatSuggestionsGetParams, options?: RequestInit): Promise<getQuerySuggestionsApiChatSuggestionsGetResponse> => {\n  \n  return customFetcher<getQuerySuggestionsApiChatSuggestionsGetResponse>(getGetQuerySuggestionsApiChatSuggestionsGetUrl(params),\n  {      \n    ...options,\n    method: 'GET'\n    \n    \n  }\n);}\n\n\n\n\n\nexport const getGetQuerySuggestionsApiChatSuggestionsGetQueryKey = (params?: GetQuerySuggestionsApiChatSuggestionsGetParams,) => {\n    return [\n    `/api/chat/suggestions`, ...(params ? [params] : [])\n    ] as const;\n    }\n\n    \nexport const getGetQuerySuggestionsApiChatSuggestionsGetQueryOptions = <TData = Awaited<ReturnType<typeof getQuerySuggestionsApiChatSuggestionsGet>>, TError = HTTPValidationError>(params?: GetQuerySuggestionsApiChatSuggestionsGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getQuerySuggestionsApiChatSuggestionsGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n) => {\n\nconst {query: queryOptions, request: requestOptions} = options ?? {};\n\n  const queryKey =  queryOptions?.queryKey ?? getGetQuerySuggestionsApiChatSuggestionsGetQueryKey(params);\n\n  \n\n    const queryFn: QueryFunction<Awaited<ReturnType<typeof getQuerySuggestionsApiChatSuggestionsGet>>> = ({ signal }) => getQuerySuggestionsApiChatSuggestionsGet(params, { signal, ...requestOptions });\n\n      \n\n      \n\n   return  { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getQuerySuggestionsApiChatSuggestionsGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }\n}\n\nexport type GetQuerySuggestionsApiChatSuggestionsGetQueryResult = NonNullable<Awaited<ReturnType<typeof getQuerySuggestionsApiChatSuggestionsGet>>>\nexport type GetQuerySuggestionsApiChatSuggestionsGetQueryError = HTTPValidationError\n\n\nexport function useGetQuerySuggestionsApiChatSuggestionsGet<TData = Awaited<ReturnType<typeof getQuerySuggestionsApiChatSuggestionsGet>>, TError = HTTPValidationError>(\n params: undefined |  GetQuerySuggestionsApiChatSuggestionsGetParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getQuerySuggestionsApiChatSuggestionsGet>>, TError, TData>> & Pick<\n        DefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getQuerySuggestionsApiChatSuggestionsGet>>,\n          TError,\n          Awaited<ReturnType<typeof getQuerySuggestionsApiChatSuggestionsGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetQuerySuggestionsApiChatSuggestionsGet<TData = Awaited<ReturnType<typeof getQuerySuggestionsApiChatSuggestionsGet>>, TError = HTTPValidationError>(\n params?: GetQuerySuggestionsApiChatSuggestionsGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getQuerySuggestionsApiChatSuggestionsGet>>, TError, TData>> & Pick<\n        UndefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getQuerySuggestionsApiChatSuggestionsGet>>,\n          TError,\n          Awaited<ReturnType<typeof getQuerySuggestionsApiChatSuggestionsGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetQuerySuggestionsApiChatSuggestionsGet<TData = Awaited<ReturnType<typeof getQuerySuggestionsApiChatSuggestionsGet>>, TError = HTTPValidationError>(\n params?: GetQuerySuggestionsApiChatSuggestionsGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getQuerySuggestionsApiChatSuggestionsGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\n/**\n * @summary Get Query Suggestions\n */\n\nexport function useGetQuerySuggestionsApiChatSuggestionsGet<TData = Awaited<ReturnType<typeof getQuerySuggestionsApiChatSuggestionsGet>>, TError = HTTPValidationError>(\n params?: GetQuerySuggestionsApiChatSuggestionsGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getQuerySuggestionsApiChatSuggestionsGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient \n ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {\n\n  const queryOptions = getGetQuerySuggestionsApiChatSuggestionsGetQueryOptions(params,options)\n\n  const query = useQuery(queryOptions, queryClient) as  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };\n\n  return { ...query, queryKey: queryOptions.queryKey };\n}\n\n\n\n\n/**\n * 获取支持的查询类型\n * @summary Get Supported Query Types\n */\nexport type getSupportedQueryTypesApiChatQueryTypesGetResponse200 = {\n  data: unknown\n  status: 200\n}\n    \nexport type getSupportedQueryTypesApiChatQueryTypesGetResponseSuccess = (getSupportedQueryTypesApiChatQueryTypesGetResponse200) & {\n  headers: Headers;\n};\n;\n\nexport type getSupportedQueryTypesApiChatQueryTypesGetResponse = (getSupportedQueryTypesApiChatQueryTypesGetResponseSuccess)\n\nexport const getGetSupportedQueryTypesApiChatQueryTypesGetUrl = () => {\n\n\n  \n\n  return `/api/chat/query-types`\n}\n\nexport const getSupportedQueryTypesApiChatQueryTypesGet = async ( options?: RequestInit): Promise<getSupportedQueryTypesApiChatQueryTypesGetResponse> => {\n  \n  return customFetcher<getSupportedQueryTypesApiChatQueryTypesGetResponse>(getGetSupportedQueryTypesApiChatQueryTypesGetUrl(),\n  {      \n    ...options,\n    method: 'GET'\n    \n    \n  }\n);}\n\n\n\n\n\nexport const getGetSupportedQueryTypesApiChatQueryTypesGetQueryKey = () => {\n    return [\n    `/api/chat/query-types`\n    ] as const;\n    }\n\n    \nexport const getGetSupportedQueryTypesApiChatQueryTypesGetQueryOptions = <TData = Awaited<ReturnType<typeof getSupportedQueryTypesApiChatQueryTypesGet>>, TError = unknown>( options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSupportedQueryTypesApiChatQueryTypesGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n) => {\n\nconst {query: queryOptions, request: requestOptions} = options ?? {};\n\n  const queryKey =  queryOptions?.queryKey ?? getGetSupportedQueryTypesApiChatQueryTypesGetQueryKey();\n\n  \n\n    const queryFn: QueryFunction<Awaited<ReturnType<typeof getSupportedQueryTypesApiChatQueryTypesGet>>> = ({ signal }) => getSupportedQueryTypesApiChatQueryTypesGet({ signal, ...requestOptions });\n\n      \n\n      \n\n   return  { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getSupportedQueryTypesApiChatQueryTypesGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }\n}\n\nexport type GetSupportedQueryTypesApiChatQueryTypesGetQueryResult = NonNullable<Awaited<ReturnType<typeof getSupportedQueryTypesApiChatQueryTypesGet>>>\nexport type GetSupportedQueryTypesApiChatQueryTypesGetQueryError = unknown\n\n\nexport function useGetSupportedQueryTypesApiChatQueryTypesGet<TData = Awaited<ReturnType<typeof getSupportedQueryTypesApiChatQueryTypesGet>>, TError = unknown>(\n  options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSupportedQueryTypesApiChatQueryTypesGet>>, TError, TData>> & Pick<\n        DefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getSupportedQueryTypesApiChatQueryTypesGet>>,\n          TError,\n          Awaited<ReturnType<typeof getSupportedQueryTypesApiChatQueryTypesGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetSupportedQueryTypesApiChatQueryTypesGet<TData = Awaited<ReturnType<typeof getSupportedQueryTypesApiChatQueryTypesGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSupportedQueryTypesApiChatQueryTypesGet>>, TError, TData>> & Pick<\n        UndefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getSupportedQueryTypesApiChatQueryTypesGet>>,\n          TError,\n          Awaited<ReturnType<typeof getSupportedQueryTypesApiChatQueryTypesGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetSupportedQueryTypesApiChatQueryTypesGet<TData = Awaited<ReturnType<typeof getSupportedQueryTypesApiChatQueryTypesGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSupportedQueryTypesApiChatQueryTypesGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\n/**\n * @summary Get Supported Query Types\n */\n\nexport function useGetSupportedQueryTypesApiChatQueryTypesGet<TData = Awaited<ReturnType<typeof getSupportedQueryTypesApiChatQueryTypesGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSupportedQueryTypesApiChatQueryTypesGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient \n ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {\n\n  const queryOptions = getGetSupportedQueryTypesApiChatQueryTypesGetQueryOptions(options)\n\n  const query = useQuery(queryOptions, queryClient) as  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };\n\n  return { ...query, queryKey: queryOptions.queryKey };\n}\n\n\n\n\n/**\n * 获取可用的 Agno Agent 工具列表\n\n返回两种类型的工具：\n1. FreeTodo 工具：待办管理相关（create_todo, list_todos 等）\n2. 外部工具：联网搜索等（duckduckgo 等）\n * @summary Get Available Agno Tools\n */\nexport type getAvailableAgnoToolsApiChatAgnoToolsGetResponse200 = {\n  data: unknown\n  status: 200\n}\n    \nexport type getAvailableAgnoToolsApiChatAgnoToolsGetResponseSuccess = (getAvailableAgnoToolsApiChatAgnoToolsGetResponse200) & {\n  headers: Headers;\n};\n;\n\nexport type getAvailableAgnoToolsApiChatAgnoToolsGetResponse = (getAvailableAgnoToolsApiChatAgnoToolsGetResponseSuccess)\n\nexport const getGetAvailableAgnoToolsApiChatAgnoToolsGetUrl = () => {\n\n\n  \n\n  return `/api/chat/agno/tools`\n}\n\nexport const getAvailableAgnoToolsApiChatAgnoToolsGet = async ( options?: RequestInit): Promise<getAvailableAgnoToolsApiChatAgnoToolsGetResponse> => {\n  \n  return customFetcher<getAvailableAgnoToolsApiChatAgnoToolsGetResponse>(getGetAvailableAgnoToolsApiChatAgnoToolsGetUrl(),\n  {      \n    ...options,\n    method: 'GET'\n    \n    \n  }\n);}\n\n\n\n\n\nexport const getGetAvailableAgnoToolsApiChatAgnoToolsGetQueryKey = () => {\n    return [\n    `/api/chat/agno/tools`\n    ] as const;\n    }\n\n    \nexport const getGetAvailableAgnoToolsApiChatAgnoToolsGetQueryOptions = <TData = Awaited<ReturnType<typeof getAvailableAgnoToolsApiChatAgnoToolsGet>>, TError = unknown>( options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getAvailableAgnoToolsApiChatAgnoToolsGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n) => {\n\nconst {query: queryOptions, request: requestOptions} = options ?? {};\n\n  const queryKey =  queryOptions?.queryKey ?? getGetAvailableAgnoToolsApiChatAgnoToolsGetQueryKey();\n\n  \n\n    const queryFn: QueryFunction<Awaited<ReturnType<typeof getAvailableAgnoToolsApiChatAgnoToolsGet>>> = ({ signal }) => getAvailableAgnoToolsApiChatAgnoToolsGet({ signal, ...requestOptions });\n\n      \n\n      \n\n   return  { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getAvailableAgnoToolsApiChatAgnoToolsGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }\n}\n\nexport type GetAvailableAgnoToolsApiChatAgnoToolsGetQueryResult = NonNullable<Awaited<ReturnType<typeof getAvailableAgnoToolsApiChatAgnoToolsGet>>>\nexport type GetAvailableAgnoToolsApiChatAgnoToolsGetQueryError = unknown\n\n\nexport function useGetAvailableAgnoToolsApiChatAgnoToolsGet<TData = Awaited<ReturnType<typeof getAvailableAgnoToolsApiChatAgnoToolsGet>>, TError = unknown>(\n  options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getAvailableAgnoToolsApiChatAgnoToolsGet>>, TError, TData>> & Pick<\n        DefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getAvailableAgnoToolsApiChatAgnoToolsGet>>,\n          TError,\n          Awaited<ReturnType<typeof getAvailableAgnoToolsApiChatAgnoToolsGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetAvailableAgnoToolsApiChatAgnoToolsGet<TData = Awaited<ReturnType<typeof getAvailableAgnoToolsApiChatAgnoToolsGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getAvailableAgnoToolsApiChatAgnoToolsGet>>, TError, TData>> & Pick<\n        UndefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getAvailableAgnoToolsApiChatAgnoToolsGet>>,\n          TError,\n          Awaited<ReturnType<typeof getAvailableAgnoToolsApiChatAgnoToolsGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetAvailableAgnoToolsApiChatAgnoToolsGet<TData = Awaited<ReturnType<typeof getAvailableAgnoToolsApiChatAgnoToolsGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getAvailableAgnoToolsApiChatAgnoToolsGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\n/**\n * @summary Get Available Agno Tools\n */\n\nexport function useGetAvailableAgnoToolsApiChatAgnoToolsGet<TData = Awaited<ReturnType<typeof getAvailableAgnoToolsApiChatAgnoToolsGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getAvailableAgnoToolsApiChatAgnoToolsGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient \n ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {\n\n  const queryOptions = getGetAvailableAgnoToolsApiChatAgnoToolsGetQueryOptions(options)\n\n  const query = useQuery(queryOptions, queryClient) as  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };\n\n  return { ...query, queryKey: queryOptions.queryKey };\n}\n\n\n\n\n/**\n * Plan功能：生成选择题（流式输出）\n * @summary Plan Questionnaire Stream\n */\nexport type planQuestionnaireStreamApiChatPlanQuestionnaireStreamPostResponse200 = {\n  data: unknown\n  status: 200\n}\n\nexport type planQuestionnaireStreamApiChatPlanQuestionnaireStreamPostResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type planQuestionnaireStreamApiChatPlanQuestionnaireStreamPostResponseSuccess = (planQuestionnaireStreamApiChatPlanQuestionnaireStreamPostResponse200) & {\n  headers: Headers;\n};\nexport type planQuestionnaireStreamApiChatPlanQuestionnaireStreamPostResponseError = (planQuestionnaireStreamApiChatPlanQuestionnaireStreamPostResponse422) & {\n  headers: Headers;\n};\n\nexport type planQuestionnaireStreamApiChatPlanQuestionnaireStreamPostResponse = (planQuestionnaireStreamApiChatPlanQuestionnaireStreamPostResponseSuccess | planQuestionnaireStreamApiChatPlanQuestionnaireStreamPostResponseError)\n\nexport const getPlanQuestionnaireStreamApiChatPlanQuestionnaireStreamPostUrl = () => {\n\n\n  \n\n  return `/api/chat/plan/questionnaire/stream`\n}\n\nexport const planQuestionnaireStreamApiChatPlanQuestionnaireStreamPost = async (planQuestionnaireRequest: PlanQuestionnaireRequest, options?: RequestInit): Promise<planQuestionnaireStreamApiChatPlanQuestionnaireStreamPostResponse> => {\n  \n  return customFetcher<planQuestionnaireStreamApiChatPlanQuestionnaireStreamPostResponse>(getPlanQuestionnaireStreamApiChatPlanQuestionnaireStreamPostUrl(),\n  {      \n    ...options,\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json', ...options?.headers },\n    body: JSON.stringify(\n      planQuestionnaireRequest,)\n  }\n);}\n\n\n\n\nexport const getPlanQuestionnaireStreamApiChatPlanQuestionnaireStreamPostMutationOptions = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof planQuestionnaireStreamApiChatPlanQuestionnaireStreamPost>>, TError,{data: PlanQuestionnaireRequest}, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof planQuestionnaireStreamApiChatPlanQuestionnaireStreamPost>>, TError,{data: PlanQuestionnaireRequest}, TContext> => {\n\nconst mutationKey = ['planQuestionnaireStreamApiChatPlanQuestionnaireStreamPost'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof planQuestionnaireStreamApiChatPlanQuestionnaireStreamPost>>, {data: PlanQuestionnaireRequest}> = (props) => {\n          const {data} = props ?? {};\n\n          return  planQuestionnaireStreamApiChatPlanQuestionnaireStreamPost(data,requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type PlanQuestionnaireStreamApiChatPlanQuestionnaireStreamPostMutationResult = NonNullable<Awaited<ReturnType<typeof planQuestionnaireStreamApiChatPlanQuestionnaireStreamPost>>>\n    export type PlanQuestionnaireStreamApiChatPlanQuestionnaireStreamPostMutationBody = PlanQuestionnaireRequest\n    export type PlanQuestionnaireStreamApiChatPlanQuestionnaireStreamPostMutationError = HTTPValidationError\n\n    /**\n * @summary Plan Questionnaire Stream\n */\nexport const usePlanQuestionnaireStreamApiChatPlanQuestionnaireStreamPost = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof planQuestionnaireStreamApiChatPlanQuestionnaireStreamPost>>, TError,{data: PlanQuestionnaireRequest}, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof planQuestionnaireStreamApiChatPlanQuestionnaireStreamPost>>,\n        TError,\n        {data: PlanQuestionnaireRequest},\n        TContext\n      > => {\n      return useMutation(getPlanQuestionnaireStreamApiChatPlanQuestionnaireStreamPostMutationOptions(options), queryClient);\n    }\n    /**\n * Plan功能：生成任务总结和子任务（流式输出）\n * @summary Plan Summary Stream\n */\nexport type planSummaryStreamApiChatPlanSummaryStreamPostResponse200 = {\n  data: unknown\n  status: 200\n}\n\nexport type planSummaryStreamApiChatPlanSummaryStreamPostResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type planSummaryStreamApiChatPlanSummaryStreamPostResponseSuccess = (planSummaryStreamApiChatPlanSummaryStreamPostResponse200) & {\n  headers: Headers;\n};\nexport type planSummaryStreamApiChatPlanSummaryStreamPostResponseError = (planSummaryStreamApiChatPlanSummaryStreamPostResponse422) & {\n  headers: Headers;\n};\n\nexport type planSummaryStreamApiChatPlanSummaryStreamPostResponse = (planSummaryStreamApiChatPlanSummaryStreamPostResponseSuccess | planSummaryStreamApiChatPlanSummaryStreamPostResponseError)\n\nexport const getPlanSummaryStreamApiChatPlanSummaryStreamPostUrl = () => {\n\n\n  \n\n  return `/api/chat/plan/summary/stream`\n}\n\nexport const planSummaryStreamApiChatPlanSummaryStreamPost = async (planSummaryRequest: PlanSummaryRequest, options?: RequestInit): Promise<planSummaryStreamApiChatPlanSummaryStreamPostResponse> => {\n  \n  return customFetcher<planSummaryStreamApiChatPlanSummaryStreamPostResponse>(getPlanSummaryStreamApiChatPlanSummaryStreamPostUrl(),\n  {      \n    ...options,\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json', ...options?.headers },\n    body: JSON.stringify(\n      planSummaryRequest,)\n  }\n);}\n\n\n\n\nexport const getPlanSummaryStreamApiChatPlanSummaryStreamPostMutationOptions = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof planSummaryStreamApiChatPlanSummaryStreamPost>>, TError,{data: PlanSummaryRequest}, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof planSummaryStreamApiChatPlanSummaryStreamPost>>, TError,{data: PlanSummaryRequest}, TContext> => {\n\nconst mutationKey = ['planSummaryStreamApiChatPlanSummaryStreamPost'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof planSummaryStreamApiChatPlanSummaryStreamPost>>, {data: PlanSummaryRequest}> = (props) => {\n          const {data} = props ?? {};\n\n          return  planSummaryStreamApiChatPlanSummaryStreamPost(data,requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type PlanSummaryStreamApiChatPlanSummaryStreamPostMutationResult = NonNullable<Awaited<ReturnType<typeof planSummaryStreamApiChatPlanSummaryStreamPost>>>\n    export type PlanSummaryStreamApiChatPlanSummaryStreamPostMutationBody = PlanSummaryRequest\n    export type PlanSummaryStreamApiChatPlanSummaryStreamPostMutationError = HTTPValidationError\n\n    /**\n * @summary Plan Summary Stream\n */\nexport const usePlanSummaryStreamApiChatPlanSummaryStreamPost = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof planSummaryStreamApiChatPlanSummaryStreamPost>>, TError,{data: PlanSummaryRequest}, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof planSummaryStreamApiChatPlanSummaryStreamPost>>,\n        TError,\n        {data: PlanSummaryRequest},\n        TContext\n      > => {\n      return useMutation(getPlanSummaryStreamApiChatPlanSummaryStreamPostMutationOptions(options), queryClient);\n    }\n    "
  },
  {
    "path": "free-todo-frontend/lib/generated/config/config.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport {\n  useMutation,\n  useQuery\n} from '@tanstack/react-query';\nimport type {\n  DataTag,\n  DefinedInitialDataOptions,\n  DefinedUseQueryResult,\n  MutationFunction,\n  QueryClient,\n  QueryFunction,\n  QueryKey,\n  UndefinedInitialDataOptions,\n  UseMutationOptions,\n  UseMutationResult,\n  UseQueryOptions,\n  UseQueryResult\n} from '@tanstack/react-query';\n\nimport type {\n  GetChatPromptsApiGetChatPromptsGetParams,\n  HTTPValidationError,\n  SaveAndInitLlmApiSaveAndInitLlmPostBody,\n  SaveConfigApiSaveConfigPostBody,\n  TestAsrConfigApiTestAsrConfigPostBody,\n  TestLlmConfigApiTestLlmConfigPostBody,\n  TestTavilyConfigApiTestTavilyConfigPostBody\n} from '.././schemas';\n\nimport { customFetcher } from '../../api/fetcher';\n\n\ntype SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];\n\n\n\n/**\n * 测试LLM配置是否可用（仅验证认证）\n * @summary Test Llm Config\n */\nexport type testLlmConfigApiTestLlmConfigPostResponse200 = {\n  data: unknown\n  status: 200\n}\n\nexport type testLlmConfigApiTestLlmConfigPostResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type testLlmConfigApiTestLlmConfigPostResponseSuccess = (testLlmConfigApiTestLlmConfigPostResponse200) & {\n  headers: Headers;\n};\nexport type testLlmConfigApiTestLlmConfigPostResponseError = (testLlmConfigApiTestLlmConfigPostResponse422) & {\n  headers: Headers;\n};\n\nexport type testLlmConfigApiTestLlmConfigPostResponse = (testLlmConfigApiTestLlmConfigPostResponseSuccess | testLlmConfigApiTestLlmConfigPostResponseError)\n\nexport const getTestLlmConfigApiTestLlmConfigPostUrl = () => {\n\n\n  \n\n  return `/api/test-llm-config`\n}\n\nexport const testLlmConfigApiTestLlmConfigPost = async (testLlmConfigApiTestLlmConfigPostBody: TestLlmConfigApiTestLlmConfigPostBody, options?: RequestInit): Promise<testLlmConfigApiTestLlmConfigPostResponse> => {\n  \n  return customFetcher<testLlmConfigApiTestLlmConfigPostResponse>(getTestLlmConfigApiTestLlmConfigPostUrl(),\n  {      \n    ...options,\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json', ...options?.headers },\n    body: JSON.stringify(\n      testLlmConfigApiTestLlmConfigPostBody,)\n  }\n);}\n\n\n\n\nexport const getTestLlmConfigApiTestLlmConfigPostMutationOptions = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof testLlmConfigApiTestLlmConfigPost>>, TError,{data: TestLlmConfigApiTestLlmConfigPostBody}, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof testLlmConfigApiTestLlmConfigPost>>, TError,{data: TestLlmConfigApiTestLlmConfigPostBody}, TContext> => {\n\nconst mutationKey = ['testLlmConfigApiTestLlmConfigPost'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof testLlmConfigApiTestLlmConfigPost>>, {data: TestLlmConfigApiTestLlmConfigPostBody}> = (props) => {\n          const {data} = props ?? {};\n\n          return  testLlmConfigApiTestLlmConfigPost(data,requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type TestLlmConfigApiTestLlmConfigPostMutationResult = NonNullable<Awaited<ReturnType<typeof testLlmConfigApiTestLlmConfigPost>>>\n    export type TestLlmConfigApiTestLlmConfigPostMutationBody = TestLlmConfigApiTestLlmConfigPostBody\n    export type TestLlmConfigApiTestLlmConfigPostMutationError = HTTPValidationError\n\n    /**\n * @summary Test Llm Config\n */\nexport const useTestLlmConfigApiTestLlmConfigPost = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof testLlmConfigApiTestLlmConfigPost>>, TError,{data: TestLlmConfigApiTestLlmConfigPostBody}, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof testLlmConfigApiTestLlmConfigPost>>,\n        TError,\n        {data: TestLlmConfigApiTestLlmConfigPostBody},\n        TContext\n      > => {\n      return useMutation(getTestLlmConfigApiTestLlmConfigPostMutationOptions(options), queryClient);\n    }\n    /**\n * 测试Tavily配置是否可用（仅验证认证）\n * @summary Test Tavily Config\n */\nexport type testTavilyConfigApiTestTavilyConfigPostResponse200 = {\n  data: unknown\n  status: 200\n}\n\nexport type testTavilyConfigApiTestTavilyConfigPostResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type testTavilyConfigApiTestTavilyConfigPostResponseSuccess = (testTavilyConfigApiTestTavilyConfigPostResponse200) & {\n  headers: Headers;\n};\nexport type testTavilyConfigApiTestTavilyConfigPostResponseError = (testTavilyConfigApiTestTavilyConfigPostResponse422) & {\n  headers: Headers;\n};\n\nexport type testTavilyConfigApiTestTavilyConfigPostResponse = (testTavilyConfigApiTestTavilyConfigPostResponseSuccess | testTavilyConfigApiTestTavilyConfigPostResponseError)\n\nexport const getTestTavilyConfigApiTestTavilyConfigPostUrl = () => {\n\n\n  \n\n  return `/api/test-tavily-config`\n}\n\nexport const testTavilyConfigApiTestTavilyConfigPost = async (testTavilyConfigApiTestTavilyConfigPostBody: TestTavilyConfigApiTestTavilyConfigPostBody, options?: RequestInit): Promise<testTavilyConfigApiTestTavilyConfigPostResponse> => {\n  \n  return customFetcher<testTavilyConfigApiTestTavilyConfigPostResponse>(getTestTavilyConfigApiTestTavilyConfigPostUrl(),\n  {      \n    ...options,\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json', ...options?.headers },\n    body: JSON.stringify(\n      testTavilyConfigApiTestTavilyConfigPostBody,)\n  }\n);}\n\n\n\n\nexport const getTestTavilyConfigApiTestTavilyConfigPostMutationOptions = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof testTavilyConfigApiTestTavilyConfigPost>>, TError,{data: TestTavilyConfigApiTestTavilyConfigPostBody}, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof testTavilyConfigApiTestTavilyConfigPost>>, TError,{data: TestTavilyConfigApiTestTavilyConfigPostBody}, TContext> => {\n\nconst mutationKey = ['testTavilyConfigApiTestTavilyConfigPost'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof testTavilyConfigApiTestTavilyConfigPost>>, {data: TestTavilyConfigApiTestTavilyConfigPostBody}> = (props) => {\n          const {data} = props ?? {};\n\n          return  testTavilyConfigApiTestTavilyConfigPost(data,requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type TestTavilyConfigApiTestTavilyConfigPostMutationResult = NonNullable<Awaited<ReturnType<typeof testTavilyConfigApiTestTavilyConfigPost>>>\n    export type TestTavilyConfigApiTestTavilyConfigPostMutationBody = TestTavilyConfigApiTestTavilyConfigPostBody\n    export type TestTavilyConfigApiTestTavilyConfigPostMutationError = HTTPValidationError\n\n    /**\n * @summary Test Tavily Config\n */\nexport const useTestTavilyConfigApiTestTavilyConfigPost = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof testTavilyConfigApiTestTavilyConfigPost>>, TError,{data: TestTavilyConfigApiTestTavilyConfigPostBody}, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof testTavilyConfigApiTestTavilyConfigPost>>,\n        TError,\n        {data: TestTavilyConfigApiTestTavilyConfigPostBody},\n        TContext\n      > => {\n      return useMutation(getTestTavilyConfigApiTestTavilyConfigPostMutationOptions(options), queryClient);\n    }\n    /**\n * 测试ASR配置是否可用（验证WebSocket连接和认证）\n * @summary Test Asr Config\n */\nexport type testAsrConfigApiTestAsrConfigPostResponse200 = {\n  data: unknown\n  status: 200\n}\n\nexport type testAsrConfigApiTestAsrConfigPostResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type testAsrConfigApiTestAsrConfigPostResponseSuccess = (testAsrConfigApiTestAsrConfigPostResponse200) & {\n  headers: Headers;\n};\nexport type testAsrConfigApiTestAsrConfigPostResponseError = (testAsrConfigApiTestAsrConfigPostResponse422) & {\n  headers: Headers;\n};\n\nexport type testAsrConfigApiTestAsrConfigPostResponse = (testAsrConfigApiTestAsrConfigPostResponseSuccess | testAsrConfigApiTestAsrConfigPostResponseError)\n\nexport const getTestAsrConfigApiTestAsrConfigPostUrl = () => {\n\n\n  \n\n  return `/api/test-asr-config`\n}\n\nexport const testAsrConfigApiTestAsrConfigPost = async (testAsrConfigApiTestAsrConfigPostBody: TestAsrConfigApiTestAsrConfigPostBody, options?: RequestInit): Promise<testAsrConfigApiTestAsrConfigPostResponse> => {\n  \n  return customFetcher<testAsrConfigApiTestAsrConfigPostResponse>(getTestAsrConfigApiTestAsrConfigPostUrl(),\n  {      \n    ...options,\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json', ...options?.headers },\n    body: JSON.stringify(\n      testAsrConfigApiTestAsrConfigPostBody,)\n  }\n);}\n\n\n\n\nexport const getTestAsrConfigApiTestAsrConfigPostMutationOptions = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof testAsrConfigApiTestAsrConfigPost>>, TError,{data: TestAsrConfigApiTestAsrConfigPostBody}, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof testAsrConfigApiTestAsrConfigPost>>, TError,{data: TestAsrConfigApiTestAsrConfigPostBody}, TContext> => {\n\nconst mutationKey = ['testAsrConfigApiTestAsrConfigPost'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof testAsrConfigApiTestAsrConfigPost>>, {data: TestAsrConfigApiTestAsrConfigPostBody}> = (props) => {\n          const {data} = props ?? {};\n\n          return  testAsrConfigApiTestAsrConfigPost(data,requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type TestAsrConfigApiTestAsrConfigPostMutationResult = NonNullable<Awaited<ReturnType<typeof testAsrConfigApiTestAsrConfigPost>>>\n    export type TestAsrConfigApiTestAsrConfigPostMutationBody = TestAsrConfigApiTestAsrConfigPostBody\n    export type TestAsrConfigApiTestAsrConfigPostMutationError = HTTPValidationError\n\n    /**\n * @summary Test Asr Config\n */\nexport const useTestAsrConfigApiTestAsrConfigPost = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof testAsrConfigApiTestAsrConfigPost>>, TError,{data: TestAsrConfigApiTestAsrConfigPostBody}, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof testAsrConfigApiTestAsrConfigPost>>,\n        TError,\n        {data: TestAsrConfigApiTestAsrConfigPostBody},\n        TContext\n      > => {\n      return useMutation(getTestAsrConfigApiTestAsrConfigPostMutationOptions(options), queryClient);\n    }\n    /**\n * 检查 LLM 是否已正确配置并通过连接测试\n\nReturns:\n    dict: 包含 configured 字段，表示 LLM 是否已配置且连接验证成功\n * @summary Get Llm Status\n */\nexport type getLlmStatusApiLlmStatusGetResponse200 = {\n  data: unknown\n  status: 200\n}\n    \nexport type getLlmStatusApiLlmStatusGetResponseSuccess = (getLlmStatusApiLlmStatusGetResponse200) & {\n  headers: Headers;\n};\n;\n\nexport type getLlmStatusApiLlmStatusGetResponse = (getLlmStatusApiLlmStatusGetResponseSuccess)\n\nexport const getGetLlmStatusApiLlmStatusGetUrl = () => {\n\n\n  \n\n  return `/api/llm-status`\n}\n\nexport const getLlmStatusApiLlmStatusGet = async ( options?: RequestInit): Promise<getLlmStatusApiLlmStatusGetResponse> => {\n  \n  return customFetcher<getLlmStatusApiLlmStatusGetResponse>(getGetLlmStatusApiLlmStatusGetUrl(),\n  {      \n    ...options,\n    method: 'GET'\n    \n    \n  }\n);}\n\n\n\n\n\nexport const getGetLlmStatusApiLlmStatusGetQueryKey = () => {\n    return [\n    `/api/llm-status`\n    ] as const;\n    }\n\n    \nexport const getGetLlmStatusApiLlmStatusGetQueryOptions = <TData = Awaited<ReturnType<typeof getLlmStatusApiLlmStatusGet>>, TError = unknown>( options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getLlmStatusApiLlmStatusGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n) => {\n\nconst {query: queryOptions, request: requestOptions} = options ?? {};\n\n  const queryKey =  queryOptions?.queryKey ?? getGetLlmStatusApiLlmStatusGetQueryKey();\n\n  \n\n    const queryFn: QueryFunction<Awaited<ReturnType<typeof getLlmStatusApiLlmStatusGet>>> = ({ signal }) => getLlmStatusApiLlmStatusGet({ signal, ...requestOptions });\n\n      \n\n      \n\n   return  { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getLlmStatusApiLlmStatusGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }\n}\n\nexport type GetLlmStatusApiLlmStatusGetQueryResult = NonNullable<Awaited<ReturnType<typeof getLlmStatusApiLlmStatusGet>>>\nexport type GetLlmStatusApiLlmStatusGetQueryError = unknown\n\n\nexport function useGetLlmStatusApiLlmStatusGet<TData = Awaited<ReturnType<typeof getLlmStatusApiLlmStatusGet>>, TError = unknown>(\n  options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getLlmStatusApiLlmStatusGet>>, TError, TData>> & Pick<\n        DefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getLlmStatusApiLlmStatusGet>>,\n          TError,\n          Awaited<ReturnType<typeof getLlmStatusApiLlmStatusGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetLlmStatusApiLlmStatusGet<TData = Awaited<ReturnType<typeof getLlmStatusApiLlmStatusGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getLlmStatusApiLlmStatusGet>>, TError, TData>> & Pick<\n        UndefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getLlmStatusApiLlmStatusGet>>,\n          TError,\n          Awaited<ReturnType<typeof getLlmStatusApiLlmStatusGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetLlmStatusApiLlmStatusGet<TData = Awaited<ReturnType<typeof getLlmStatusApiLlmStatusGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getLlmStatusApiLlmStatusGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\n/**\n * @summary Get Llm Status\n */\n\nexport function useGetLlmStatusApiLlmStatusGet<TData = Awaited<ReturnType<typeof getLlmStatusApiLlmStatusGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getLlmStatusApiLlmStatusGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient \n ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {\n\n  const queryOptions = getGetLlmStatusApiLlmStatusGetQueryOptions(options)\n\n  const query = useQuery(queryOptions, queryClient) as  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };\n\n  return { ...query, queryKey: queryOptions.queryKey };\n}\n\n\n\n\n/**\n * 获取当前配置（返回驼峰格式的配置键）\n * @summary Get Config Detailed\n */\nexport type getConfigDetailedApiGetConfigGetResponse200 = {\n  data: unknown\n  status: 200\n}\n    \nexport type getConfigDetailedApiGetConfigGetResponseSuccess = (getConfigDetailedApiGetConfigGetResponse200) & {\n  headers: Headers;\n};\n;\n\nexport type getConfigDetailedApiGetConfigGetResponse = (getConfigDetailedApiGetConfigGetResponseSuccess)\n\nexport const getGetConfigDetailedApiGetConfigGetUrl = () => {\n\n\n  \n\n  return `/api/get-config`\n}\n\nexport const getConfigDetailedApiGetConfigGet = async ( options?: RequestInit): Promise<getConfigDetailedApiGetConfigGetResponse> => {\n  \n  return customFetcher<getConfigDetailedApiGetConfigGetResponse>(getGetConfigDetailedApiGetConfigGetUrl(),\n  {      \n    ...options,\n    method: 'GET'\n    \n    \n  }\n);}\n\n\n\n\n\nexport const getGetConfigDetailedApiGetConfigGetQueryKey = () => {\n    return [\n    `/api/get-config`\n    ] as const;\n    }\n\n    \nexport const getGetConfigDetailedApiGetConfigGetQueryOptions = <TData = Awaited<ReturnType<typeof getConfigDetailedApiGetConfigGet>>, TError = unknown>( options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getConfigDetailedApiGetConfigGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n) => {\n\nconst {query: queryOptions, request: requestOptions} = options ?? {};\n\n  const queryKey =  queryOptions?.queryKey ?? getGetConfigDetailedApiGetConfigGetQueryKey();\n\n  \n\n    const queryFn: QueryFunction<Awaited<ReturnType<typeof getConfigDetailedApiGetConfigGet>>> = ({ signal }) => getConfigDetailedApiGetConfigGet({ signal, ...requestOptions });\n\n      \n\n      \n\n   return  { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getConfigDetailedApiGetConfigGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }\n}\n\nexport type GetConfigDetailedApiGetConfigGetQueryResult = NonNullable<Awaited<ReturnType<typeof getConfigDetailedApiGetConfigGet>>>\nexport type GetConfigDetailedApiGetConfigGetQueryError = unknown\n\n\nexport function useGetConfigDetailedApiGetConfigGet<TData = Awaited<ReturnType<typeof getConfigDetailedApiGetConfigGet>>, TError = unknown>(\n  options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getConfigDetailedApiGetConfigGet>>, TError, TData>> & Pick<\n        DefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getConfigDetailedApiGetConfigGet>>,\n          TError,\n          Awaited<ReturnType<typeof getConfigDetailedApiGetConfigGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetConfigDetailedApiGetConfigGet<TData = Awaited<ReturnType<typeof getConfigDetailedApiGetConfigGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getConfigDetailedApiGetConfigGet>>, TError, TData>> & Pick<\n        UndefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getConfigDetailedApiGetConfigGet>>,\n          TError,\n          Awaited<ReturnType<typeof getConfigDetailedApiGetConfigGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetConfigDetailedApiGetConfigGet<TData = Awaited<ReturnType<typeof getConfigDetailedApiGetConfigGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getConfigDetailedApiGetConfigGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\n/**\n * @summary Get Config Detailed\n */\n\nexport function useGetConfigDetailedApiGetConfigGet<TData = Awaited<ReturnType<typeof getConfigDetailedApiGetConfigGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getConfigDetailedApiGetConfigGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient \n ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {\n\n  const queryOptions = getGetConfigDetailedApiGetConfigGetQueryOptions(options)\n\n  const query = useQuery(queryOptions, queryClient) as  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };\n\n  return { ...query, queryKey: queryOptions.queryKey };\n}\n\n\n\n\n/**\n * 保存配置并重新初始化LLM服务\n * @summary Save And Init Llm\n */\nexport type saveAndInitLlmApiSaveAndInitLlmPostResponse200 = {\n  data: unknown\n  status: 200\n}\n\nexport type saveAndInitLlmApiSaveAndInitLlmPostResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type saveAndInitLlmApiSaveAndInitLlmPostResponseSuccess = (saveAndInitLlmApiSaveAndInitLlmPostResponse200) & {\n  headers: Headers;\n};\nexport type saveAndInitLlmApiSaveAndInitLlmPostResponseError = (saveAndInitLlmApiSaveAndInitLlmPostResponse422) & {\n  headers: Headers;\n};\n\nexport type saveAndInitLlmApiSaveAndInitLlmPostResponse = (saveAndInitLlmApiSaveAndInitLlmPostResponseSuccess | saveAndInitLlmApiSaveAndInitLlmPostResponseError)\n\nexport const getSaveAndInitLlmApiSaveAndInitLlmPostUrl = () => {\n\n\n  \n\n  return `/api/save-and-init-llm`\n}\n\nexport const saveAndInitLlmApiSaveAndInitLlmPost = async (saveAndInitLlmApiSaveAndInitLlmPostBody: SaveAndInitLlmApiSaveAndInitLlmPostBody, options?: RequestInit): Promise<saveAndInitLlmApiSaveAndInitLlmPostResponse> => {\n  \n  return customFetcher<saveAndInitLlmApiSaveAndInitLlmPostResponse>(getSaveAndInitLlmApiSaveAndInitLlmPostUrl(),\n  {      \n    ...options,\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json', ...options?.headers },\n    body: JSON.stringify(\n      saveAndInitLlmApiSaveAndInitLlmPostBody,)\n  }\n);}\n\n\n\n\nexport const getSaveAndInitLlmApiSaveAndInitLlmPostMutationOptions = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof saveAndInitLlmApiSaveAndInitLlmPost>>, TError,{data: SaveAndInitLlmApiSaveAndInitLlmPostBody}, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof saveAndInitLlmApiSaveAndInitLlmPost>>, TError,{data: SaveAndInitLlmApiSaveAndInitLlmPostBody}, TContext> => {\n\nconst mutationKey = ['saveAndInitLlmApiSaveAndInitLlmPost'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof saveAndInitLlmApiSaveAndInitLlmPost>>, {data: SaveAndInitLlmApiSaveAndInitLlmPostBody}> = (props) => {\n          const {data} = props ?? {};\n\n          return  saveAndInitLlmApiSaveAndInitLlmPost(data,requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type SaveAndInitLlmApiSaveAndInitLlmPostMutationResult = NonNullable<Awaited<ReturnType<typeof saveAndInitLlmApiSaveAndInitLlmPost>>>\n    export type SaveAndInitLlmApiSaveAndInitLlmPostMutationBody = SaveAndInitLlmApiSaveAndInitLlmPostBody\n    export type SaveAndInitLlmApiSaveAndInitLlmPostMutationError = HTTPValidationError\n\n    /**\n * @summary Save And Init Llm\n */\nexport const useSaveAndInitLlmApiSaveAndInitLlmPost = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof saveAndInitLlmApiSaveAndInitLlmPost>>, TError,{data: SaveAndInitLlmApiSaveAndInitLlmPostBody}, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof saveAndInitLlmApiSaveAndInitLlmPost>>,\n        TError,\n        {data: SaveAndInitLlmApiSaveAndInitLlmPostBody},\n        TContext\n      > => {\n      return useMutation(getSaveAndInitLlmApiSaveAndInitLlmPostMutationOptions(options), queryClient);\n    }\n    /**\n * 保存配置到config.yaml文件\n * @summary Save Config\n */\nexport type saveConfigApiSaveConfigPostResponse200 = {\n  data: unknown\n  status: 200\n}\n\nexport type saveConfigApiSaveConfigPostResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type saveConfigApiSaveConfigPostResponseSuccess = (saveConfigApiSaveConfigPostResponse200) & {\n  headers: Headers;\n};\nexport type saveConfigApiSaveConfigPostResponseError = (saveConfigApiSaveConfigPostResponse422) & {\n  headers: Headers;\n};\n\nexport type saveConfigApiSaveConfigPostResponse = (saveConfigApiSaveConfigPostResponseSuccess | saveConfigApiSaveConfigPostResponseError)\n\nexport const getSaveConfigApiSaveConfigPostUrl = () => {\n\n\n  \n\n  return `/api/save-config`\n}\n\nexport const saveConfigApiSaveConfigPost = async (saveConfigApiSaveConfigPostBody: SaveConfigApiSaveConfigPostBody, options?: RequestInit): Promise<saveConfigApiSaveConfigPostResponse> => {\n  \n  return customFetcher<saveConfigApiSaveConfigPostResponse>(getSaveConfigApiSaveConfigPostUrl(),\n  {      \n    ...options,\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json', ...options?.headers },\n    body: JSON.stringify(\n      saveConfigApiSaveConfigPostBody,)\n  }\n);}\n\n\n\n\nexport const getSaveConfigApiSaveConfigPostMutationOptions = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof saveConfigApiSaveConfigPost>>, TError,{data: SaveConfigApiSaveConfigPostBody}, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof saveConfigApiSaveConfigPost>>, TError,{data: SaveConfigApiSaveConfigPostBody}, TContext> => {\n\nconst mutationKey = ['saveConfigApiSaveConfigPost'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof saveConfigApiSaveConfigPost>>, {data: SaveConfigApiSaveConfigPostBody}> = (props) => {\n          const {data} = props ?? {};\n\n          return  saveConfigApiSaveConfigPost(data,requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type SaveConfigApiSaveConfigPostMutationResult = NonNullable<Awaited<ReturnType<typeof saveConfigApiSaveConfigPost>>>\n    export type SaveConfigApiSaveConfigPostMutationBody = SaveConfigApiSaveConfigPostBody\n    export type SaveConfigApiSaveConfigPostMutationError = HTTPValidationError\n\n    /**\n * @summary Save Config\n */\nexport const useSaveConfigApiSaveConfigPost = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof saveConfigApiSaveConfigPost>>, TError,{data: SaveConfigApiSaveConfigPostBody}, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof saveConfigApiSaveConfigPost>>,\n        TError,\n        {data: SaveConfigApiSaveConfigPostBody},\n        TContext\n      > => {\n      return useMutation(getSaveConfigApiSaveConfigPostMutationOptions(options), queryClient);\n    }\n    /**\n * 获取前端聊天功能所需的 prompt\n\nArgs:\n    locale: 语言代码，'zh' 或 'en'，默认为 'zh'\n\nReturns:\n    包含 editSystemPrompt 和 planSystemPrompt 的字典\n * @summary Get Chat Prompts\n */\nexport type getChatPromptsApiGetChatPromptsGetResponse200 = {\n  data: unknown\n  status: 200\n}\n\nexport type getChatPromptsApiGetChatPromptsGetResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type getChatPromptsApiGetChatPromptsGetResponseSuccess = (getChatPromptsApiGetChatPromptsGetResponse200) & {\n  headers: Headers;\n};\nexport type getChatPromptsApiGetChatPromptsGetResponseError = (getChatPromptsApiGetChatPromptsGetResponse422) & {\n  headers: Headers;\n};\n\nexport type getChatPromptsApiGetChatPromptsGetResponse = (getChatPromptsApiGetChatPromptsGetResponseSuccess | getChatPromptsApiGetChatPromptsGetResponseError)\n\nexport const getGetChatPromptsApiGetChatPromptsGetUrl = (params?: GetChatPromptsApiGetChatPromptsGetParams,) => {\n  const normalizedParams = new URLSearchParams();\n\n  Object.entries(params || {}).forEach(([key, value]) => {\n    \n    if (value !== undefined) {\n      normalizedParams.append(key, value === null ? 'null' : value.toString())\n    }\n  });\n\n  const stringifiedParams = normalizedParams.toString();\n\n  return stringifiedParams.length > 0 ? `/api/get-chat-prompts?${stringifiedParams}` : `/api/get-chat-prompts`\n}\n\nexport const getChatPromptsApiGetChatPromptsGet = async (params?: GetChatPromptsApiGetChatPromptsGetParams, options?: RequestInit): Promise<getChatPromptsApiGetChatPromptsGetResponse> => {\n  \n  return customFetcher<getChatPromptsApiGetChatPromptsGetResponse>(getGetChatPromptsApiGetChatPromptsGetUrl(params),\n  {      \n    ...options,\n    method: 'GET'\n    \n    \n  }\n);}\n\n\n\n\n\nexport const getGetChatPromptsApiGetChatPromptsGetQueryKey = (params?: GetChatPromptsApiGetChatPromptsGetParams,) => {\n    return [\n    `/api/get-chat-prompts`, ...(params ? [params] : [])\n    ] as const;\n    }\n\n    \nexport const getGetChatPromptsApiGetChatPromptsGetQueryOptions = <TData = Awaited<ReturnType<typeof getChatPromptsApiGetChatPromptsGet>>, TError = HTTPValidationError>(params?: GetChatPromptsApiGetChatPromptsGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getChatPromptsApiGetChatPromptsGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n) => {\n\nconst {query: queryOptions, request: requestOptions} = options ?? {};\n\n  const queryKey =  queryOptions?.queryKey ?? getGetChatPromptsApiGetChatPromptsGetQueryKey(params);\n\n  \n\n    const queryFn: QueryFunction<Awaited<ReturnType<typeof getChatPromptsApiGetChatPromptsGet>>> = ({ signal }) => getChatPromptsApiGetChatPromptsGet(params, { signal, ...requestOptions });\n\n      \n\n      \n\n   return  { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getChatPromptsApiGetChatPromptsGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }\n}\n\nexport type GetChatPromptsApiGetChatPromptsGetQueryResult = NonNullable<Awaited<ReturnType<typeof getChatPromptsApiGetChatPromptsGet>>>\nexport type GetChatPromptsApiGetChatPromptsGetQueryError = HTTPValidationError\n\n\nexport function useGetChatPromptsApiGetChatPromptsGet<TData = Awaited<ReturnType<typeof getChatPromptsApiGetChatPromptsGet>>, TError = HTTPValidationError>(\n params: undefined |  GetChatPromptsApiGetChatPromptsGetParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getChatPromptsApiGetChatPromptsGet>>, TError, TData>> & Pick<\n        DefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getChatPromptsApiGetChatPromptsGet>>,\n          TError,\n          Awaited<ReturnType<typeof getChatPromptsApiGetChatPromptsGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetChatPromptsApiGetChatPromptsGet<TData = Awaited<ReturnType<typeof getChatPromptsApiGetChatPromptsGet>>, TError = HTTPValidationError>(\n params?: GetChatPromptsApiGetChatPromptsGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getChatPromptsApiGetChatPromptsGet>>, TError, TData>> & Pick<\n        UndefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getChatPromptsApiGetChatPromptsGet>>,\n          TError,\n          Awaited<ReturnType<typeof getChatPromptsApiGetChatPromptsGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetChatPromptsApiGetChatPromptsGet<TData = Awaited<ReturnType<typeof getChatPromptsApiGetChatPromptsGet>>, TError = HTTPValidationError>(\n params?: GetChatPromptsApiGetChatPromptsGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getChatPromptsApiGetChatPromptsGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\n/**\n * @summary Get Chat Prompts\n */\n\nexport function useGetChatPromptsApiGetChatPromptsGet<TData = Awaited<ReturnType<typeof getChatPromptsApiGetChatPromptsGet>>, TError = HTTPValidationError>(\n params?: GetChatPromptsApiGetChatPromptsGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getChatPromptsApiGetChatPromptsGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient \n ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {\n\n  const queryOptions = getGetChatPromptsApiGetChatPromptsGetQueryOptions(params,options)\n\n  const query = useQuery(queryOptions, queryClient) as  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };\n\n  return { ...query, queryKey: queryOptions.queryKey };\n}\n\n\n\n\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/cost-tracking/cost-tracking.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport {\n  useQuery\n} from '@tanstack/react-query';\nimport type {\n  DataTag,\n  DefinedInitialDataOptions,\n  DefinedUseQueryResult,\n  QueryClient,\n  QueryFunction,\n  QueryKey,\n  UndefinedInitialDataOptions,\n  UseQueryOptions,\n  UseQueryResult\n} from '@tanstack/react-query';\n\nimport type {\n  GetCostStatsApiCostTrackingStatsGetParams,\n  HTTPValidationError\n} from '.././schemas';\n\nimport { customFetcher } from '../../api/fetcher';\n\n\ntype SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];\n\n\n\n/**\n * 获取费用统计数据\n\nArgs:\n    days: 统计最近多少天的数据\n * @summary Get Cost Stats\n */\nexport type getCostStatsApiCostTrackingStatsGetResponse200 = {\n  data: unknown\n  status: 200\n}\n\nexport type getCostStatsApiCostTrackingStatsGetResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type getCostStatsApiCostTrackingStatsGetResponseSuccess = (getCostStatsApiCostTrackingStatsGetResponse200) & {\n  headers: Headers;\n};\nexport type getCostStatsApiCostTrackingStatsGetResponseError = (getCostStatsApiCostTrackingStatsGetResponse422) & {\n  headers: Headers;\n};\n\nexport type getCostStatsApiCostTrackingStatsGetResponse = (getCostStatsApiCostTrackingStatsGetResponseSuccess | getCostStatsApiCostTrackingStatsGetResponseError)\n\nexport const getGetCostStatsApiCostTrackingStatsGetUrl = (params?: GetCostStatsApiCostTrackingStatsGetParams,) => {\n  const normalizedParams = new URLSearchParams();\n\n  Object.entries(params || {}).forEach(([key, value]) => {\n    \n    if (value !== undefined) {\n      normalizedParams.append(key, value === null ? 'null' : value.toString())\n    }\n  });\n\n  const stringifiedParams = normalizedParams.toString();\n\n  return stringifiedParams.length > 0 ? `/api/cost-tracking/stats?${stringifiedParams}` : `/api/cost-tracking/stats`\n}\n\nexport const getCostStatsApiCostTrackingStatsGet = async (params?: GetCostStatsApiCostTrackingStatsGetParams, options?: RequestInit): Promise<getCostStatsApiCostTrackingStatsGetResponse> => {\n  \n  return customFetcher<getCostStatsApiCostTrackingStatsGetResponse>(getGetCostStatsApiCostTrackingStatsGetUrl(params),\n  {      \n    ...options,\n    method: 'GET'\n    \n    \n  }\n);}\n\n\n\n\n\nexport const getGetCostStatsApiCostTrackingStatsGetQueryKey = (params?: GetCostStatsApiCostTrackingStatsGetParams,) => {\n    return [\n    `/api/cost-tracking/stats`, ...(params ? [params] : [])\n    ] as const;\n    }\n\n    \nexport const getGetCostStatsApiCostTrackingStatsGetQueryOptions = <TData = Awaited<ReturnType<typeof getCostStatsApiCostTrackingStatsGet>>, TError = HTTPValidationError>(params?: GetCostStatsApiCostTrackingStatsGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getCostStatsApiCostTrackingStatsGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n) => {\n\nconst {query: queryOptions, request: requestOptions} = options ?? {};\n\n  const queryKey =  queryOptions?.queryKey ?? getGetCostStatsApiCostTrackingStatsGetQueryKey(params);\n\n  \n\n    const queryFn: QueryFunction<Awaited<ReturnType<typeof getCostStatsApiCostTrackingStatsGet>>> = ({ signal }) => getCostStatsApiCostTrackingStatsGet(params, { signal, ...requestOptions });\n\n      \n\n      \n\n   return  { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getCostStatsApiCostTrackingStatsGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }\n}\n\nexport type GetCostStatsApiCostTrackingStatsGetQueryResult = NonNullable<Awaited<ReturnType<typeof getCostStatsApiCostTrackingStatsGet>>>\nexport type GetCostStatsApiCostTrackingStatsGetQueryError = HTTPValidationError\n\n\nexport function useGetCostStatsApiCostTrackingStatsGet<TData = Awaited<ReturnType<typeof getCostStatsApiCostTrackingStatsGet>>, TError = HTTPValidationError>(\n params: undefined |  GetCostStatsApiCostTrackingStatsGetParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getCostStatsApiCostTrackingStatsGet>>, TError, TData>> & Pick<\n        DefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getCostStatsApiCostTrackingStatsGet>>,\n          TError,\n          Awaited<ReturnType<typeof getCostStatsApiCostTrackingStatsGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetCostStatsApiCostTrackingStatsGet<TData = Awaited<ReturnType<typeof getCostStatsApiCostTrackingStatsGet>>, TError = HTTPValidationError>(\n params?: GetCostStatsApiCostTrackingStatsGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getCostStatsApiCostTrackingStatsGet>>, TError, TData>> & Pick<\n        UndefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getCostStatsApiCostTrackingStatsGet>>,\n          TError,\n          Awaited<ReturnType<typeof getCostStatsApiCostTrackingStatsGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetCostStatsApiCostTrackingStatsGet<TData = Awaited<ReturnType<typeof getCostStatsApiCostTrackingStatsGet>>, TError = HTTPValidationError>(\n params?: GetCostStatsApiCostTrackingStatsGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getCostStatsApiCostTrackingStatsGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\n/**\n * @summary Get Cost Stats\n */\n\nexport function useGetCostStatsApiCostTrackingStatsGet<TData = Awaited<ReturnType<typeof getCostStatsApiCostTrackingStatsGet>>, TError = HTTPValidationError>(\n params?: GetCostStatsApiCostTrackingStatsGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getCostStatsApiCostTrackingStatsGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient \n ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {\n\n  const queryOptions = getGetCostStatsApiCostTrackingStatsGetQueryOptions(params,options)\n\n  const query = useQuery(queryOptions, queryClient) as  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };\n\n  return { ...query, queryKey: queryOptions.queryKey };\n}\n\n\n\n\n/**\n * 获取费用统计配置\n * @summary Get Cost Config\n */\nexport type getCostConfigApiCostTrackingConfigGetResponse200 = {\n  data: unknown\n  status: 200\n}\n    \nexport type getCostConfigApiCostTrackingConfigGetResponseSuccess = (getCostConfigApiCostTrackingConfigGetResponse200) & {\n  headers: Headers;\n};\n;\n\nexport type getCostConfigApiCostTrackingConfigGetResponse = (getCostConfigApiCostTrackingConfigGetResponseSuccess)\n\nexport const getGetCostConfigApiCostTrackingConfigGetUrl = () => {\n\n\n  \n\n  return `/api/cost-tracking/config`\n}\n\nexport const getCostConfigApiCostTrackingConfigGet = async ( options?: RequestInit): Promise<getCostConfigApiCostTrackingConfigGetResponse> => {\n  \n  return customFetcher<getCostConfigApiCostTrackingConfigGetResponse>(getGetCostConfigApiCostTrackingConfigGetUrl(),\n  {      \n    ...options,\n    method: 'GET'\n    \n    \n  }\n);}\n\n\n\n\n\nexport const getGetCostConfigApiCostTrackingConfigGetQueryKey = () => {\n    return [\n    `/api/cost-tracking/config`\n    ] as const;\n    }\n\n    \nexport const getGetCostConfigApiCostTrackingConfigGetQueryOptions = <TData = Awaited<ReturnType<typeof getCostConfigApiCostTrackingConfigGet>>, TError = unknown>( options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getCostConfigApiCostTrackingConfigGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n) => {\n\nconst {query: queryOptions, request: requestOptions} = options ?? {};\n\n  const queryKey =  queryOptions?.queryKey ?? getGetCostConfigApiCostTrackingConfigGetQueryKey();\n\n  \n\n    const queryFn: QueryFunction<Awaited<ReturnType<typeof getCostConfigApiCostTrackingConfigGet>>> = ({ signal }) => getCostConfigApiCostTrackingConfigGet({ signal, ...requestOptions });\n\n      \n\n      \n\n   return  { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getCostConfigApiCostTrackingConfigGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }\n}\n\nexport type GetCostConfigApiCostTrackingConfigGetQueryResult = NonNullable<Awaited<ReturnType<typeof getCostConfigApiCostTrackingConfigGet>>>\nexport type GetCostConfigApiCostTrackingConfigGetQueryError = unknown\n\n\nexport function useGetCostConfigApiCostTrackingConfigGet<TData = Awaited<ReturnType<typeof getCostConfigApiCostTrackingConfigGet>>, TError = unknown>(\n  options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getCostConfigApiCostTrackingConfigGet>>, TError, TData>> & Pick<\n        DefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getCostConfigApiCostTrackingConfigGet>>,\n          TError,\n          Awaited<ReturnType<typeof getCostConfigApiCostTrackingConfigGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetCostConfigApiCostTrackingConfigGet<TData = Awaited<ReturnType<typeof getCostConfigApiCostTrackingConfigGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getCostConfigApiCostTrackingConfigGet>>, TError, TData>> & Pick<\n        UndefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getCostConfigApiCostTrackingConfigGet>>,\n          TError,\n          Awaited<ReturnType<typeof getCostConfigApiCostTrackingConfigGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetCostConfigApiCostTrackingConfigGet<TData = Awaited<ReturnType<typeof getCostConfigApiCostTrackingConfigGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getCostConfigApiCostTrackingConfigGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\n/**\n * @summary Get Cost Config\n */\n\nexport function useGetCostConfigApiCostTrackingConfigGet<TData = Awaited<ReturnType<typeof getCostConfigApiCostTrackingConfigGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getCostConfigApiCostTrackingConfigGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient \n ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {\n\n  const queryOptions = getGetCostConfigApiCostTrackingConfigGetQueryOptions(options)\n\n  const query = useQuery(queryOptions, queryClient) as  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };\n\n  return { ...query, queryKey: queryOptions.queryKey };\n}\n\n\n\n\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/default/default.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport {\n  useQuery\n} from '@tanstack/react-query';\nimport type {\n  DataTag,\n  DefinedInitialDataOptions,\n  DefinedUseQueryResult,\n  QueryClient,\n  QueryFunction,\n  QueryKey,\n  UndefinedInitialDataOptions,\n  UseQueryOptions,\n  UseQueryResult\n} from '@tanstack/react-query';\n\nimport { customFetcher } from '../../api/fetcher';\n\n\ntype SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];\n\n\n\n/**\n * 健康检查\n * @summary Health Check\n */\nexport type healthCheckHealthGetResponse200 = {\n  data: unknown\n  status: 200\n}\n    \nexport type healthCheckHealthGetResponseSuccess = (healthCheckHealthGetResponse200) & {\n  headers: Headers;\n};\n;\n\nexport type healthCheckHealthGetResponse = (healthCheckHealthGetResponseSuccess)\n\nexport const getHealthCheckHealthGetUrl = () => {\n\n\n  \n\n  return `/health`\n}\n\nexport const healthCheckHealthGet = async ( options?: RequestInit): Promise<healthCheckHealthGetResponse> => {\n  \n  return customFetcher<healthCheckHealthGetResponse>(getHealthCheckHealthGetUrl(),\n  {      \n    ...options,\n    method: 'GET'\n    \n    \n  }\n);}\n\n\n\n\n\nexport const getHealthCheckHealthGetQueryKey = () => {\n    return [\n    `/health`\n    ] as const;\n    }\n\n    \nexport const getHealthCheckHealthGetQueryOptions = <TData = Awaited<ReturnType<typeof healthCheckHealthGet>>, TError = unknown>( options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof healthCheckHealthGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n) => {\n\nconst {query: queryOptions, request: requestOptions} = options ?? {};\n\n  const queryKey =  queryOptions?.queryKey ?? getHealthCheckHealthGetQueryKey();\n\n  \n\n    const queryFn: QueryFunction<Awaited<ReturnType<typeof healthCheckHealthGet>>> = ({ signal }) => healthCheckHealthGet({ signal, ...requestOptions });\n\n      \n\n      \n\n   return  { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof healthCheckHealthGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }\n}\n\nexport type HealthCheckHealthGetQueryResult = NonNullable<Awaited<ReturnType<typeof healthCheckHealthGet>>>\nexport type HealthCheckHealthGetQueryError = unknown\n\n\nexport function useHealthCheckHealthGet<TData = Awaited<ReturnType<typeof healthCheckHealthGet>>, TError = unknown>(\n  options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof healthCheckHealthGet>>, TError, TData>> & Pick<\n        DefinedInitialDataOptions<\n          Awaited<ReturnType<typeof healthCheckHealthGet>>,\n          TError,\n          Awaited<ReturnType<typeof healthCheckHealthGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useHealthCheckHealthGet<TData = Awaited<ReturnType<typeof healthCheckHealthGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof healthCheckHealthGet>>, TError, TData>> & Pick<\n        UndefinedInitialDataOptions<\n          Awaited<ReturnType<typeof healthCheckHealthGet>>,\n          TError,\n          Awaited<ReturnType<typeof healthCheckHealthGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useHealthCheckHealthGet<TData = Awaited<ReturnType<typeof healthCheckHealthGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof healthCheckHealthGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\n/**\n * @summary Health Check\n */\n\nexport function useHealthCheckHealthGet<TData = Awaited<ReturnType<typeof healthCheckHealthGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof healthCheckHealthGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient \n ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {\n\n  const queryOptions = getHealthCheckHealthGetQueryOptions(options)\n\n  const query = useQuery(queryOptions, queryClient) as  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };\n\n  return { ...query, queryKey: queryOptions.queryKey };\n}\n\n\n\n\n/**\n * LLM服务健康检查\n * @summary Llm Health Check\n */\nexport type llmHealthCheckHealthLlmGetResponse200 = {\n  data: unknown\n  status: 200\n}\n    \nexport type llmHealthCheckHealthLlmGetResponseSuccess = (llmHealthCheckHealthLlmGetResponse200) & {\n  headers: Headers;\n};\n;\n\nexport type llmHealthCheckHealthLlmGetResponse = (llmHealthCheckHealthLlmGetResponseSuccess)\n\nexport const getLlmHealthCheckHealthLlmGetUrl = () => {\n\n\n  \n\n  return `/health/llm`\n}\n\nexport const llmHealthCheckHealthLlmGet = async ( options?: RequestInit): Promise<llmHealthCheckHealthLlmGetResponse> => {\n  \n  return customFetcher<llmHealthCheckHealthLlmGetResponse>(getLlmHealthCheckHealthLlmGetUrl(),\n  {      \n    ...options,\n    method: 'GET'\n    \n    \n  }\n);}\n\n\n\n\n\nexport const getLlmHealthCheckHealthLlmGetQueryKey = () => {\n    return [\n    `/health/llm`\n    ] as const;\n    }\n\n    \nexport const getLlmHealthCheckHealthLlmGetQueryOptions = <TData = Awaited<ReturnType<typeof llmHealthCheckHealthLlmGet>>, TError = unknown>( options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof llmHealthCheckHealthLlmGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n) => {\n\nconst {query: queryOptions, request: requestOptions} = options ?? {};\n\n  const queryKey =  queryOptions?.queryKey ?? getLlmHealthCheckHealthLlmGetQueryKey();\n\n  \n\n    const queryFn: QueryFunction<Awaited<ReturnType<typeof llmHealthCheckHealthLlmGet>>> = ({ signal }) => llmHealthCheckHealthLlmGet({ signal, ...requestOptions });\n\n      \n\n      \n\n   return  { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof llmHealthCheckHealthLlmGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }\n}\n\nexport type LlmHealthCheckHealthLlmGetQueryResult = NonNullable<Awaited<ReturnType<typeof llmHealthCheckHealthLlmGet>>>\nexport type LlmHealthCheckHealthLlmGetQueryError = unknown\n\n\nexport function useLlmHealthCheckHealthLlmGet<TData = Awaited<ReturnType<typeof llmHealthCheckHealthLlmGet>>, TError = unknown>(\n  options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof llmHealthCheckHealthLlmGet>>, TError, TData>> & Pick<\n        DefinedInitialDataOptions<\n          Awaited<ReturnType<typeof llmHealthCheckHealthLlmGet>>,\n          TError,\n          Awaited<ReturnType<typeof llmHealthCheckHealthLlmGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useLlmHealthCheckHealthLlmGet<TData = Awaited<ReturnType<typeof llmHealthCheckHealthLlmGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof llmHealthCheckHealthLlmGet>>, TError, TData>> & Pick<\n        UndefinedInitialDataOptions<\n          Awaited<ReturnType<typeof llmHealthCheckHealthLlmGet>>,\n          TError,\n          Awaited<ReturnType<typeof llmHealthCheckHealthLlmGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useLlmHealthCheckHealthLlmGet<TData = Awaited<ReturnType<typeof llmHealthCheckHealthLlmGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof llmHealthCheckHealthLlmGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\n/**\n * @summary Llm Health Check\n */\n\nexport function useLlmHealthCheckHealthLlmGet<TData = Awaited<ReturnType<typeof llmHealthCheckHealthLlmGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof llmHealthCheckHealthLlmGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient \n ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {\n\n  const queryOptions = getLlmHealthCheckHealthLlmGetQueryOptions(options)\n\n  const query = useQuery(queryOptions, queryClient) as  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };\n\n  return { ...query, queryKey: queryOptions.queryKey };\n}\n\n\n\n\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/event/event.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport {\n  useMutation,\n  useQuery\n} from '@tanstack/react-query';\nimport type {\n  DataTag,\n  DefinedInitialDataOptions,\n  DefinedUseQueryResult,\n  MutationFunction,\n  QueryClient,\n  QueryFunction,\n  QueryKey,\n  UndefinedInitialDataOptions,\n  UseMutationOptions,\n  UseMutationResult,\n  UseQueryOptions,\n  UseQueryResult\n} from '@tanstack/react-query';\n\nimport type {\n  CountEventsApiEventsCountGetParams,\n  EventDetailResponse,\n  EventListResponse,\n  HTTPValidationError,\n  ListEventsApiEventsGetParams\n} from '.././schemas';\n\nimport { customFetcher } from '../../api/fetcher';\n\n\ntype SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];\n\n\n\n/**\n * 获取事件列表（事件=前台应用使用阶段），用于事件级别展示与检索，同时返回总数\n * @summary List Events\n */\nexport type listEventsApiEventsGetResponse200 = {\n  data: EventListResponse\n  status: 200\n}\n\nexport type listEventsApiEventsGetResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type listEventsApiEventsGetResponseSuccess = (listEventsApiEventsGetResponse200) & {\n  headers: Headers;\n};\nexport type listEventsApiEventsGetResponseError = (listEventsApiEventsGetResponse422) & {\n  headers: Headers;\n};\n\nexport type listEventsApiEventsGetResponse = (listEventsApiEventsGetResponseSuccess | listEventsApiEventsGetResponseError)\n\nexport const getListEventsApiEventsGetUrl = (params?: ListEventsApiEventsGetParams,) => {\n  const normalizedParams = new URLSearchParams();\n\n  Object.entries(params || {}).forEach(([key, value]) => {\n    \n    if (value !== undefined) {\n      normalizedParams.append(key, value === null ? 'null' : value.toString())\n    }\n  });\n\n  const stringifiedParams = normalizedParams.toString();\n\n  return stringifiedParams.length > 0 ? `/api/events?${stringifiedParams}` : `/api/events`\n}\n\nexport const listEventsApiEventsGet = async (params?: ListEventsApiEventsGetParams, options?: RequestInit): Promise<listEventsApiEventsGetResponse> => {\n  \n  return customFetcher<listEventsApiEventsGetResponse>(getListEventsApiEventsGetUrl(params),\n  {      \n    ...options,\n    method: 'GET'\n    \n    \n  }\n);}\n\n\n\n\n\nexport const getListEventsApiEventsGetQueryKey = (params?: ListEventsApiEventsGetParams,) => {\n    return [\n    `/api/events`, ...(params ? [params] : [])\n    ] as const;\n    }\n\n    \nexport const getListEventsApiEventsGetQueryOptions = <TData = Awaited<ReturnType<typeof listEventsApiEventsGet>>, TError = HTTPValidationError>(params?: ListEventsApiEventsGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listEventsApiEventsGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n) => {\n\nconst {query: queryOptions, request: requestOptions} = options ?? {};\n\n  const queryKey =  queryOptions?.queryKey ?? getListEventsApiEventsGetQueryKey(params);\n\n  \n\n    const queryFn: QueryFunction<Awaited<ReturnType<typeof listEventsApiEventsGet>>> = ({ signal }) => listEventsApiEventsGet(params, { signal, ...requestOptions });\n\n      \n\n      \n\n   return  { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof listEventsApiEventsGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }\n}\n\nexport type ListEventsApiEventsGetQueryResult = NonNullable<Awaited<ReturnType<typeof listEventsApiEventsGet>>>\nexport type ListEventsApiEventsGetQueryError = HTTPValidationError\n\n\nexport function useListEventsApiEventsGet<TData = Awaited<ReturnType<typeof listEventsApiEventsGet>>, TError = HTTPValidationError>(\n params: undefined |  ListEventsApiEventsGetParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof listEventsApiEventsGet>>, TError, TData>> & Pick<\n        DefinedInitialDataOptions<\n          Awaited<ReturnType<typeof listEventsApiEventsGet>>,\n          TError,\n          Awaited<ReturnType<typeof listEventsApiEventsGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useListEventsApiEventsGet<TData = Awaited<ReturnType<typeof listEventsApiEventsGet>>, TError = HTTPValidationError>(\n params?: ListEventsApiEventsGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listEventsApiEventsGet>>, TError, TData>> & Pick<\n        UndefinedInitialDataOptions<\n          Awaited<ReturnType<typeof listEventsApiEventsGet>>,\n          TError,\n          Awaited<ReturnType<typeof listEventsApiEventsGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useListEventsApiEventsGet<TData = Awaited<ReturnType<typeof listEventsApiEventsGet>>, TError = HTTPValidationError>(\n params?: ListEventsApiEventsGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listEventsApiEventsGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\n/**\n * @summary List Events\n */\n\nexport function useListEventsApiEventsGet<TData = Awaited<ReturnType<typeof listEventsApiEventsGet>>, TError = HTTPValidationError>(\n params?: ListEventsApiEventsGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listEventsApiEventsGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient \n ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {\n\n  const queryOptions = getListEventsApiEventsGetQueryOptions(params,options)\n\n  const query = useQuery(queryOptions, queryClient) as  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };\n\n  return { ...query, queryKey: queryOptions.queryKey };\n}\n\n\n\n\n/**\n * 获取事件总数\n * @summary Count Events\n */\nexport type countEventsApiEventsCountGetResponse200 = {\n  data: unknown\n  status: 200\n}\n\nexport type countEventsApiEventsCountGetResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type countEventsApiEventsCountGetResponseSuccess = (countEventsApiEventsCountGetResponse200) & {\n  headers: Headers;\n};\nexport type countEventsApiEventsCountGetResponseError = (countEventsApiEventsCountGetResponse422) & {\n  headers: Headers;\n};\n\nexport type countEventsApiEventsCountGetResponse = (countEventsApiEventsCountGetResponseSuccess | countEventsApiEventsCountGetResponseError)\n\nexport const getCountEventsApiEventsCountGetUrl = (params?: CountEventsApiEventsCountGetParams,) => {\n  const normalizedParams = new URLSearchParams();\n\n  Object.entries(params || {}).forEach(([key, value]) => {\n    \n    if (value !== undefined) {\n      normalizedParams.append(key, value === null ? 'null' : value.toString())\n    }\n  });\n\n  const stringifiedParams = normalizedParams.toString();\n\n  return stringifiedParams.length > 0 ? `/api/events/count?${stringifiedParams}` : `/api/events/count`\n}\n\nexport const countEventsApiEventsCountGet = async (params?: CountEventsApiEventsCountGetParams, options?: RequestInit): Promise<countEventsApiEventsCountGetResponse> => {\n  \n  return customFetcher<countEventsApiEventsCountGetResponse>(getCountEventsApiEventsCountGetUrl(params),\n  {      \n    ...options,\n    method: 'GET'\n    \n    \n  }\n);}\n\n\n\n\n\nexport const getCountEventsApiEventsCountGetQueryKey = (params?: CountEventsApiEventsCountGetParams,) => {\n    return [\n    `/api/events/count`, ...(params ? [params] : [])\n    ] as const;\n    }\n\n    \nexport const getCountEventsApiEventsCountGetQueryOptions = <TData = Awaited<ReturnType<typeof countEventsApiEventsCountGet>>, TError = HTTPValidationError>(params?: CountEventsApiEventsCountGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof countEventsApiEventsCountGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n) => {\n\nconst {query: queryOptions, request: requestOptions} = options ?? {};\n\n  const queryKey =  queryOptions?.queryKey ?? getCountEventsApiEventsCountGetQueryKey(params);\n\n  \n\n    const queryFn: QueryFunction<Awaited<ReturnType<typeof countEventsApiEventsCountGet>>> = ({ signal }) => countEventsApiEventsCountGet(params, { signal, ...requestOptions });\n\n      \n\n      \n\n   return  { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof countEventsApiEventsCountGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }\n}\n\nexport type CountEventsApiEventsCountGetQueryResult = NonNullable<Awaited<ReturnType<typeof countEventsApiEventsCountGet>>>\nexport type CountEventsApiEventsCountGetQueryError = HTTPValidationError\n\n\nexport function useCountEventsApiEventsCountGet<TData = Awaited<ReturnType<typeof countEventsApiEventsCountGet>>, TError = HTTPValidationError>(\n params: undefined |  CountEventsApiEventsCountGetParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof countEventsApiEventsCountGet>>, TError, TData>> & Pick<\n        DefinedInitialDataOptions<\n          Awaited<ReturnType<typeof countEventsApiEventsCountGet>>,\n          TError,\n          Awaited<ReturnType<typeof countEventsApiEventsCountGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useCountEventsApiEventsCountGet<TData = Awaited<ReturnType<typeof countEventsApiEventsCountGet>>, TError = HTTPValidationError>(\n params?: CountEventsApiEventsCountGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof countEventsApiEventsCountGet>>, TError, TData>> & Pick<\n        UndefinedInitialDataOptions<\n          Awaited<ReturnType<typeof countEventsApiEventsCountGet>>,\n          TError,\n          Awaited<ReturnType<typeof countEventsApiEventsCountGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useCountEventsApiEventsCountGet<TData = Awaited<ReturnType<typeof countEventsApiEventsCountGet>>, TError = HTTPValidationError>(\n params?: CountEventsApiEventsCountGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof countEventsApiEventsCountGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\n/**\n * @summary Count Events\n */\n\nexport function useCountEventsApiEventsCountGet<TData = Awaited<ReturnType<typeof countEventsApiEventsCountGet>>, TError = HTTPValidationError>(\n params?: CountEventsApiEventsCountGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof countEventsApiEventsCountGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient \n ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {\n\n  const queryOptions = getCountEventsApiEventsCountGetQueryOptions(params,options)\n\n  const query = useQuery(queryOptions, queryClient) as  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };\n\n  return { ...query, queryKey: queryOptions.queryKey };\n}\n\n\n\n\n/**\n * 获取事件详情（包含该事件下的截图列表）\n * @summary Get Event Detail\n */\nexport type getEventDetailApiEventsEventIdGetResponse200 = {\n  data: EventDetailResponse\n  status: 200\n}\n\nexport type getEventDetailApiEventsEventIdGetResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type getEventDetailApiEventsEventIdGetResponseSuccess = (getEventDetailApiEventsEventIdGetResponse200) & {\n  headers: Headers;\n};\nexport type getEventDetailApiEventsEventIdGetResponseError = (getEventDetailApiEventsEventIdGetResponse422) & {\n  headers: Headers;\n};\n\nexport type getEventDetailApiEventsEventIdGetResponse = (getEventDetailApiEventsEventIdGetResponseSuccess | getEventDetailApiEventsEventIdGetResponseError)\n\nexport const getGetEventDetailApiEventsEventIdGetUrl = (eventId: number,) => {\n\n\n  \n\n  return `/api/events/${eventId}`\n}\n\nexport const getEventDetailApiEventsEventIdGet = async (eventId: number, options?: RequestInit): Promise<getEventDetailApiEventsEventIdGetResponse> => {\n  \n  return customFetcher<getEventDetailApiEventsEventIdGetResponse>(getGetEventDetailApiEventsEventIdGetUrl(eventId),\n  {      \n    ...options,\n    method: 'GET'\n    \n    \n  }\n);}\n\n\n\n\n\nexport const getGetEventDetailApiEventsEventIdGetQueryKey = (eventId: number,) => {\n    return [\n    `/api/events/${eventId}`\n    ] as const;\n    }\n\n    \nexport const getGetEventDetailApiEventsEventIdGetQueryOptions = <TData = Awaited<ReturnType<typeof getEventDetailApiEventsEventIdGet>>, TError = HTTPValidationError>(eventId: number, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getEventDetailApiEventsEventIdGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n) => {\n\nconst {query: queryOptions, request: requestOptions} = options ?? {};\n\n  const queryKey =  queryOptions?.queryKey ?? getGetEventDetailApiEventsEventIdGetQueryKey(eventId);\n\n  \n\n    const queryFn: QueryFunction<Awaited<ReturnType<typeof getEventDetailApiEventsEventIdGet>>> = ({ signal }) => getEventDetailApiEventsEventIdGet(eventId, { signal, ...requestOptions });\n\n      \n\n      \n\n   return  { queryKey, queryFn, enabled: !!(eventId), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getEventDetailApiEventsEventIdGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }\n}\n\nexport type GetEventDetailApiEventsEventIdGetQueryResult = NonNullable<Awaited<ReturnType<typeof getEventDetailApiEventsEventIdGet>>>\nexport type GetEventDetailApiEventsEventIdGetQueryError = HTTPValidationError\n\n\nexport function useGetEventDetailApiEventsEventIdGet<TData = Awaited<ReturnType<typeof getEventDetailApiEventsEventIdGet>>, TError = HTTPValidationError>(\n eventId: number, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getEventDetailApiEventsEventIdGet>>, TError, TData>> & Pick<\n        DefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getEventDetailApiEventsEventIdGet>>,\n          TError,\n          Awaited<ReturnType<typeof getEventDetailApiEventsEventIdGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetEventDetailApiEventsEventIdGet<TData = Awaited<ReturnType<typeof getEventDetailApiEventsEventIdGet>>, TError = HTTPValidationError>(\n eventId: number, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getEventDetailApiEventsEventIdGet>>, TError, TData>> & Pick<\n        UndefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getEventDetailApiEventsEventIdGet>>,\n          TError,\n          Awaited<ReturnType<typeof getEventDetailApiEventsEventIdGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetEventDetailApiEventsEventIdGet<TData = Awaited<ReturnType<typeof getEventDetailApiEventsEventIdGet>>, TError = HTTPValidationError>(\n eventId: number, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getEventDetailApiEventsEventIdGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\n/**\n * @summary Get Event Detail\n */\n\nexport function useGetEventDetailApiEventsEventIdGet<TData = Awaited<ReturnType<typeof getEventDetailApiEventsEventIdGet>>, TError = HTTPValidationError>(\n eventId: number, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getEventDetailApiEventsEventIdGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient \n ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {\n\n  const queryOptions = getGetEventDetailApiEventsEventIdGetQueryOptions(eventId,options)\n\n  const query = useQuery(queryOptions, queryClient) as  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };\n\n  return { ...query, queryKey: queryOptions.queryKey };\n}\n\n\n\n\n/**\n * 获取事件的OCR文本上下文\n * @summary Get Event Context\n */\nexport type getEventContextApiEventsEventIdContextGetResponse200 = {\n  data: unknown\n  status: 200\n}\n\nexport type getEventContextApiEventsEventIdContextGetResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type getEventContextApiEventsEventIdContextGetResponseSuccess = (getEventContextApiEventsEventIdContextGetResponse200) & {\n  headers: Headers;\n};\nexport type getEventContextApiEventsEventIdContextGetResponseError = (getEventContextApiEventsEventIdContextGetResponse422) & {\n  headers: Headers;\n};\n\nexport type getEventContextApiEventsEventIdContextGetResponse = (getEventContextApiEventsEventIdContextGetResponseSuccess | getEventContextApiEventsEventIdContextGetResponseError)\n\nexport const getGetEventContextApiEventsEventIdContextGetUrl = (eventId: number,) => {\n\n\n  \n\n  return `/api/events/${eventId}/context`\n}\n\nexport const getEventContextApiEventsEventIdContextGet = async (eventId: number, options?: RequestInit): Promise<getEventContextApiEventsEventIdContextGetResponse> => {\n  \n  return customFetcher<getEventContextApiEventsEventIdContextGetResponse>(getGetEventContextApiEventsEventIdContextGetUrl(eventId),\n  {      \n    ...options,\n    method: 'GET'\n    \n    \n  }\n);}\n\n\n\n\n\nexport const getGetEventContextApiEventsEventIdContextGetQueryKey = (eventId: number,) => {\n    return [\n    `/api/events/${eventId}/context`\n    ] as const;\n    }\n\n    \nexport const getGetEventContextApiEventsEventIdContextGetQueryOptions = <TData = Awaited<ReturnType<typeof getEventContextApiEventsEventIdContextGet>>, TError = HTTPValidationError>(eventId: number, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getEventContextApiEventsEventIdContextGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n) => {\n\nconst {query: queryOptions, request: requestOptions} = options ?? {};\n\n  const queryKey =  queryOptions?.queryKey ?? getGetEventContextApiEventsEventIdContextGetQueryKey(eventId);\n\n  \n\n    const queryFn: QueryFunction<Awaited<ReturnType<typeof getEventContextApiEventsEventIdContextGet>>> = ({ signal }) => getEventContextApiEventsEventIdContextGet(eventId, { signal, ...requestOptions });\n\n      \n\n      \n\n   return  { queryKey, queryFn, enabled: !!(eventId), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getEventContextApiEventsEventIdContextGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }\n}\n\nexport type GetEventContextApiEventsEventIdContextGetQueryResult = NonNullable<Awaited<ReturnType<typeof getEventContextApiEventsEventIdContextGet>>>\nexport type GetEventContextApiEventsEventIdContextGetQueryError = HTTPValidationError\n\n\nexport function useGetEventContextApiEventsEventIdContextGet<TData = Awaited<ReturnType<typeof getEventContextApiEventsEventIdContextGet>>, TError = HTTPValidationError>(\n eventId: number, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getEventContextApiEventsEventIdContextGet>>, TError, TData>> & Pick<\n        DefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getEventContextApiEventsEventIdContextGet>>,\n          TError,\n          Awaited<ReturnType<typeof getEventContextApiEventsEventIdContextGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetEventContextApiEventsEventIdContextGet<TData = Awaited<ReturnType<typeof getEventContextApiEventsEventIdContextGet>>, TError = HTTPValidationError>(\n eventId: number, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getEventContextApiEventsEventIdContextGet>>, TError, TData>> & Pick<\n        UndefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getEventContextApiEventsEventIdContextGet>>,\n          TError,\n          Awaited<ReturnType<typeof getEventContextApiEventsEventIdContextGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetEventContextApiEventsEventIdContextGet<TData = Awaited<ReturnType<typeof getEventContextApiEventsEventIdContextGet>>, TError = HTTPValidationError>(\n eventId: number, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getEventContextApiEventsEventIdContextGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\n/**\n * @summary Get Event Context\n */\n\nexport function useGetEventContextApiEventsEventIdContextGet<TData = Awaited<ReturnType<typeof getEventContextApiEventsEventIdContextGet>>, TError = HTTPValidationError>(\n eventId: number, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getEventContextApiEventsEventIdContextGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient \n ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {\n\n  const queryOptions = getGetEventContextApiEventsEventIdContextGetQueryOptions(eventId,options)\n\n  const query = useQuery(queryOptions, queryClient) as  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };\n\n  return { ...query, queryKey: queryOptions.queryKey };\n}\n\n\n\n\n/**\n * 手动触发单个事件的摘要生成\n * @summary Generate Event Summary\n */\nexport type generateEventSummaryApiEventsEventIdGenerateSummaryPostResponse200 = {\n  data: unknown\n  status: 200\n}\n\nexport type generateEventSummaryApiEventsEventIdGenerateSummaryPostResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type generateEventSummaryApiEventsEventIdGenerateSummaryPostResponseSuccess = (generateEventSummaryApiEventsEventIdGenerateSummaryPostResponse200) & {\n  headers: Headers;\n};\nexport type generateEventSummaryApiEventsEventIdGenerateSummaryPostResponseError = (generateEventSummaryApiEventsEventIdGenerateSummaryPostResponse422) & {\n  headers: Headers;\n};\n\nexport type generateEventSummaryApiEventsEventIdGenerateSummaryPostResponse = (generateEventSummaryApiEventsEventIdGenerateSummaryPostResponseSuccess | generateEventSummaryApiEventsEventIdGenerateSummaryPostResponseError)\n\nexport const getGenerateEventSummaryApiEventsEventIdGenerateSummaryPostUrl = (eventId: number,) => {\n\n\n  \n\n  return `/api/events/${eventId}/generate-summary`\n}\n\nexport const generateEventSummaryApiEventsEventIdGenerateSummaryPost = async (eventId: number, options?: RequestInit): Promise<generateEventSummaryApiEventsEventIdGenerateSummaryPostResponse> => {\n  \n  return customFetcher<generateEventSummaryApiEventsEventIdGenerateSummaryPostResponse>(getGenerateEventSummaryApiEventsEventIdGenerateSummaryPostUrl(eventId),\n  {      \n    ...options,\n    method: 'POST'\n    \n    \n  }\n);}\n\n\n\n\nexport const getGenerateEventSummaryApiEventsEventIdGenerateSummaryPostMutationOptions = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof generateEventSummaryApiEventsEventIdGenerateSummaryPost>>, TError,{eventId: number}, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof generateEventSummaryApiEventsEventIdGenerateSummaryPost>>, TError,{eventId: number}, TContext> => {\n\nconst mutationKey = ['generateEventSummaryApiEventsEventIdGenerateSummaryPost'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof generateEventSummaryApiEventsEventIdGenerateSummaryPost>>, {eventId: number}> = (props) => {\n          const {eventId} = props ?? {};\n\n          return  generateEventSummaryApiEventsEventIdGenerateSummaryPost(eventId,requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type GenerateEventSummaryApiEventsEventIdGenerateSummaryPostMutationResult = NonNullable<Awaited<ReturnType<typeof generateEventSummaryApiEventsEventIdGenerateSummaryPost>>>\n    \n    export type GenerateEventSummaryApiEventsEventIdGenerateSummaryPostMutationError = HTTPValidationError\n\n    /**\n * @summary Generate Event Summary\n */\nexport const useGenerateEventSummaryApiEventsEventIdGenerateSummaryPost = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof generateEventSummaryApiEventsEventIdGenerateSummaryPost>>, TError,{eventId: number}, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof generateEventSummaryApiEventsEventIdGenerateSummaryPost>>,\n        TError,\n        {eventId: number},\n        TContext\n      > => {\n      return useMutation(getGenerateEventSummaryApiEventsEventIdGenerateSummaryPostMutationOptions(options), queryClient);\n    }\n    "
  },
  {
    "path": "free-todo-frontend/lib/generated/floating-capture/floating-capture.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport {\n  useMutation,\n  useQuery\n} from '@tanstack/react-query';\nimport type {\n  DataTag,\n  DefinedInitialDataOptions,\n  DefinedUseQueryResult,\n  MutationFunction,\n  QueryClient,\n  QueryFunction,\n  QueryKey,\n  UndefinedInitialDataOptions,\n  UseMutationOptions,\n  UseMutationResult,\n  UseQueryOptions,\n  UseQueryResult\n} from '@tanstack/react-query';\n\nimport type {\n  FloatingCaptureRequest,\n  FloatingCaptureResponse,\n  HTTPValidationError\n} from '.././schemas';\n\nimport { customFetcher } from '../../api/fetcher';\n\n\ntype SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];\n\n\n\n/**\n * 从悬浮窗截图中提取待办事项\n\nArgs:\n    request: 包含 base64 编码截图的请求\n\nReturns:\n    提取和创建的待办事项列表\n * @summary Extract Todos From Capture\n */\nexport type extractTodosFromCaptureApiFloatingCaptureExtractTodosPostResponse200 = {\n  data: FloatingCaptureResponse\n  status: 200\n}\n\nexport type extractTodosFromCaptureApiFloatingCaptureExtractTodosPostResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type extractTodosFromCaptureApiFloatingCaptureExtractTodosPostResponseSuccess = (extractTodosFromCaptureApiFloatingCaptureExtractTodosPostResponse200) & {\n  headers: Headers;\n};\nexport type extractTodosFromCaptureApiFloatingCaptureExtractTodosPostResponseError = (extractTodosFromCaptureApiFloatingCaptureExtractTodosPostResponse422) & {\n  headers: Headers;\n};\n\nexport type extractTodosFromCaptureApiFloatingCaptureExtractTodosPostResponse = (extractTodosFromCaptureApiFloatingCaptureExtractTodosPostResponseSuccess | extractTodosFromCaptureApiFloatingCaptureExtractTodosPostResponseError)\n\nexport const getExtractTodosFromCaptureApiFloatingCaptureExtractTodosPostUrl = () => {\n\n\n  \n\n  return `/api/floating-capture/extract-todos`\n}\n\nexport const extractTodosFromCaptureApiFloatingCaptureExtractTodosPost = async (floatingCaptureRequest: FloatingCaptureRequest, options?: RequestInit): Promise<extractTodosFromCaptureApiFloatingCaptureExtractTodosPostResponse> => {\n  \n  return customFetcher<extractTodosFromCaptureApiFloatingCaptureExtractTodosPostResponse>(getExtractTodosFromCaptureApiFloatingCaptureExtractTodosPostUrl(),\n  {      \n    ...options,\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json', ...options?.headers },\n    body: JSON.stringify(\n      floatingCaptureRequest,)\n  }\n);}\n\n\n\n\nexport const getExtractTodosFromCaptureApiFloatingCaptureExtractTodosPostMutationOptions = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof extractTodosFromCaptureApiFloatingCaptureExtractTodosPost>>, TError,{data: FloatingCaptureRequest}, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof extractTodosFromCaptureApiFloatingCaptureExtractTodosPost>>, TError,{data: FloatingCaptureRequest}, TContext> => {\n\nconst mutationKey = ['extractTodosFromCaptureApiFloatingCaptureExtractTodosPost'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof extractTodosFromCaptureApiFloatingCaptureExtractTodosPost>>, {data: FloatingCaptureRequest}> = (props) => {\n          const {data} = props ?? {};\n\n          return  extractTodosFromCaptureApiFloatingCaptureExtractTodosPost(data,requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type ExtractTodosFromCaptureApiFloatingCaptureExtractTodosPostMutationResult = NonNullable<Awaited<ReturnType<typeof extractTodosFromCaptureApiFloatingCaptureExtractTodosPost>>>\n    export type ExtractTodosFromCaptureApiFloatingCaptureExtractTodosPostMutationBody = FloatingCaptureRequest\n    export type ExtractTodosFromCaptureApiFloatingCaptureExtractTodosPostMutationError = HTTPValidationError\n\n    /**\n * @summary Extract Todos From Capture\n */\nexport const useExtractTodosFromCaptureApiFloatingCaptureExtractTodosPost = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof extractTodosFromCaptureApiFloatingCaptureExtractTodosPost>>, TError,{data: FloatingCaptureRequest}, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof extractTodosFromCaptureApiFloatingCaptureExtractTodosPost>>,\n        TError,\n        {data: FloatingCaptureRequest},\n        TContext\n      > => {\n      return useMutation(getExtractTodosFromCaptureApiFloatingCaptureExtractTodosPostMutationOptions(options), queryClient);\n    }\n    /**\n * 健康检查\n * @summary Health Check\n */\nexport type healthCheckApiFloatingCaptureHealthGetResponse200 = {\n  data: unknown\n  status: 200\n}\n    \nexport type healthCheckApiFloatingCaptureHealthGetResponseSuccess = (healthCheckApiFloatingCaptureHealthGetResponse200) & {\n  headers: Headers;\n};\n;\n\nexport type healthCheckApiFloatingCaptureHealthGetResponse = (healthCheckApiFloatingCaptureHealthGetResponseSuccess)\n\nexport const getHealthCheckApiFloatingCaptureHealthGetUrl = () => {\n\n\n  \n\n  return `/api/floating-capture/health`\n}\n\nexport const healthCheckApiFloatingCaptureHealthGet = async ( options?: RequestInit): Promise<healthCheckApiFloatingCaptureHealthGetResponse> => {\n  \n  return customFetcher<healthCheckApiFloatingCaptureHealthGetResponse>(getHealthCheckApiFloatingCaptureHealthGetUrl(),\n  {      \n    ...options,\n    method: 'GET'\n    \n    \n  }\n);}\n\n\n\n\n\nexport const getHealthCheckApiFloatingCaptureHealthGetQueryKey = () => {\n    return [\n    `/api/floating-capture/health`\n    ] as const;\n    }\n\n    \nexport const getHealthCheckApiFloatingCaptureHealthGetQueryOptions = <TData = Awaited<ReturnType<typeof healthCheckApiFloatingCaptureHealthGet>>, TError = unknown>( options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof healthCheckApiFloatingCaptureHealthGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n) => {\n\nconst {query: queryOptions, request: requestOptions} = options ?? {};\n\n  const queryKey =  queryOptions?.queryKey ?? getHealthCheckApiFloatingCaptureHealthGetQueryKey();\n\n  \n\n    const queryFn: QueryFunction<Awaited<ReturnType<typeof healthCheckApiFloatingCaptureHealthGet>>> = ({ signal }) => healthCheckApiFloatingCaptureHealthGet({ signal, ...requestOptions });\n\n      \n\n      \n\n   return  { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof healthCheckApiFloatingCaptureHealthGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }\n}\n\nexport type HealthCheckApiFloatingCaptureHealthGetQueryResult = NonNullable<Awaited<ReturnType<typeof healthCheckApiFloatingCaptureHealthGet>>>\nexport type HealthCheckApiFloatingCaptureHealthGetQueryError = unknown\n\n\nexport function useHealthCheckApiFloatingCaptureHealthGet<TData = Awaited<ReturnType<typeof healthCheckApiFloatingCaptureHealthGet>>, TError = unknown>(\n  options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof healthCheckApiFloatingCaptureHealthGet>>, TError, TData>> & Pick<\n        DefinedInitialDataOptions<\n          Awaited<ReturnType<typeof healthCheckApiFloatingCaptureHealthGet>>,\n          TError,\n          Awaited<ReturnType<typeof healthCheckApiFloatingCaptureHealthGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useHealthCheckApiFloatingCaptureHealthGet<TData = Awaited<ReturnType<typeof healthCheckApiFloatingCaptureHealthGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof healthCheckApiFloatingCaptureHealthGet>>, TError, TData>> & Pick<\n        UndefinedInitialDataOptions<\n          Awaited<ReturnType<typeof healthCheckApiFloatingCaptureHealthGet>>,\n          TError,\n          Awaited<ReturnType<typeof healthCheckApiFloatingCaptureHealthGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useHealthCheckApiFloatingCaptureHealthGet<TData = Awaited<ReturnType<typeof healthCheckApiFloatingCaptureHealthGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof healthCheckApiFloatingCaptureHealthGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\n/**\n * @summary Health Check\n */\n\nexport function useHealthCheckApiFloatingCaptureHealthGet<TData = Awaited<ReturnType<typeof healthCheckApiFloatingCaptureHealthGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof healthCheckApiFloatingCaptureHealthGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient \n ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {\n\n  const queryOptions = getHealthCheckApiFloatingCaptureHealthGetQueryOptions(options)\n\n  const query = useQuery(queryOptions, queryClient) as  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };\n\n  return { ...query, queryKey: queryOptions.queryKey };\n}\n\n\n\n\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/journals/journals.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport {\n  useMutation,\n  useQuery\n} from '@tanstack/react-query';\nimport type {\n  DataTag,\n  DefinedInitialDataOptions,\n  DefinedUseQueryResult,\n  MutationFunction,\n  QueryClient,\n  QueryFunction,\n  QueryKey,\n  UndefinedInitialDataOptions,\n  UseMutationOptions,\n  UseMutationResult,\n  UseQueryOptions,\n  UseQueryResult\n} from '@tanstack/react-query';\n\nimport type {\n  HTTPValidationError,\n  JournalAutoLinkRequest,\n  JournalAutoLinkResponse,\n  JournalCreate,\n  JournalGenerateRequest,\n  JournalGenerateResponse,\n  JournalListResponse,\n  JournalResponse,\n  JournalUpdate,\n  ListJournalsApiJournalsGetParams\n} from '.././schemas';\n\nimport { customFetcher } from '../../api/fetcher';\n\n\ntype SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];\n\n\n\n/**\n * 创建日记\n * @summary Create Journal\n */\nexport type createJournalApiJournalsPostResponse201 = {\n  data: JournalResponse\n  status: 201\n}\n\nexport type createJournalApiJournalsPostResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type createJournalApiJournalsPostResponseSuccess = (createJournalApiJournalsPostResponse201) & {\n  headers: Headers;\n};\nexport type createJournalApiJournalsPostResponseError = (createJournalApiJournalsPostResponse422) & {\n  headers: Headers;\n};\n\nexport type createJournalApiJournalsPostResponse = (createJournalApiJournalsPostResponseSuccess | createJournalApiJournalsPostResponseError)\n\nexport const getCreateJournalApiJournalsPostUrl = () => {\n\n\n  \n\n  return `/api/journals`\n}\n\nexport const createJournalApiJournalsPost = async (journalCreate: JournalCreate, options?: RequestInit): Promise<createJournalApiJournalsPostResponse> => {\n  \n  return customFetcher<createJournalApiJournalsPostResponse>(getCreateJournalApiJournalsPostUrl(),\n  {      \n    ...options,\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json', ...options?.headers },\n    body: JSON.stringify(\n      journalCreate,)\n  }\n);}\n\n\n\n\nexport const getCreateJournalApiJournalsPostMutationOptions = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof createJournalApiJournalsPost>>, TError,{data: JournalCreate}, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof createJournalApiJournalsPost>>, TError,{data: JournalCreate}, TContext> => {\n\nconst mutationKey = ['createJournalApiJournalsPost'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof createJournalApiJournalsPost>>, {data: JournalCreate}> = (props) => {\n          const {data} = props ?? {};\n\n          return  createJournalApiJournalsPost(data,requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type CreateJournalApiJournalsPostMutationResult = NonNullable<Awaited<ReturnType<typeof createJournalApiJournalsPost>>>\n    export type CreateJournalApiJournalsPostMutationBody = JournalCreate\n    export type CreateJournalApiJournalsPostMutationError = HTTPValidationError\n\n    /**\n * @summary Create Journal\n */\nexport const useCreateJournalApiJournalsPost = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof createJournalApiJournalsPost>>, TError,{data: JournalCreate}, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof createJournalApiJournalsPost>>,\n        TError,\n        {data: JournalCreate},\n        TContext\n      > => {\n      return useMutation(getCreateJournalApiJournalsPostMutationOptions(options), queryClient);\n    }\n    /**\n * 获取日记列表\n * @summary List Journals\n */\nexport type listJournalsApiJournalsGetResponse200 = {\n  data: JournalListResponse\n  status: 200\n}\n\nexport type listJournalsApiJournalsGetResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type listJournalsApiJournalsGetResponseSuccess = (listJournalsApiJournalsGetResponse200) & {\n  headers: Headers;\n};\nexport type listJournalsApiJournalsGetResponseError = (listJournalsApiJournalsGetResponse422) & {\n  headers: Headers;\n};\n\nexport type listJournalsApiJournalsGetResponse = (listJournalsApiJournalsGetResponseSuccess | listJournalsApiJournalsGetResponseError)\n\nexport const getListJournalsApiJournalsGetUrl = (params?: ListJournalsApiJournalsGetParams,) => {\n  const normalizedParams = new URLSearchParams();\n\n  Object.entries(params || {}).forEach(([key, value]) => {\n    \n    if (value !== undefined) {\n      normalizedParams.append(key, value === null ? 'null' : value.toString())\n    }\n  });\n\n  const stringifiedParams = normalizedParams.toString();\n\n  return stringifiedParams.length > 0 ? `/api/journals?${stringifiedParams}` : `/api/journals`\n}\n\nexport const listJournalsApiJournalsGet = async (params?: ListJournalsApiJournalsGetParams, options?: RequestInit): Promise<listJournalsApiJournalsGetResponse> => {\n  \n  return customFetcher<listJournalsApiJournalsGetResponse>(getListJournalsApiJournalsGetUrl(params),\n  {      \n    ...options,\n    method: 'GET'\n    \n    \n  }\n);}\n\n\n\n\n\nexport const getListJournalsApiJournalsGetQueryKey = (params?: ListJournalsApiJournalsGetParams,) => {\n    return [\n    `/api/journals`, ...(params ? [params] : [])\n    ] as const;\n    }\n\n    \nexport const getListJournalsApiJournalsGetQueryOptions = <TData = Awaited<ReturnType<typeof listJournalsApiJournalsGet>>, TError = HTTPValidationError>(params?: ListJournalsApiJournalsGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listJournalsApiJournalsGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n) => {\n\nconst {query: queryOptions, request: requestOptions} = options ?? {};\n\n  const queryKey =  queryOptions?.queryKey ?? getListJournalsApiJournalsGetQueryKey(params);\n\n  \n\n    const queryFn: QueryFunction<Awaited<ReturnType<typeof listJournalsApiJournalsGet>>> = ({ signal }) => listJournalsApiJournalsGet(params, { signal, ...requestOptions });\n\n      \n\n      \n\n   return  { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof listJournalsApiJournalsGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }\n}\n\nexport type ListJournalsApiJournalsGetQueryResult = NonNullable<Awaited<ReturnType<typeof listJournalsApiJournalsGet>>>\nexport type ListJournalsApiJournalsGetQueryError = HTTPValidationError\n\n\nexport function useListJournalsApiJournalsGet<TData = Awaited<ReturnType<typeof listJournalsApiJournalsGet>>, TError = HTTPValidationError>(\n params: undefined |  ListJournalsApiJournalsGetParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof listJournalsApiJournalsGet>>, TError, TData>> & Pick<\n        DefinedInitialDataOptions<\n          Awaited<ReturnType<typeof listJournalsApiJournalsGet>>,\n          TError,\n          Awaited<ReturnType<typeof listJournalsApiJournalsGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useListJournalsApiJournalsGet<TData = Awaited<ReturnType<typeof listJournalsApiJournalsGet>>, TError = HTTPValidationError>(\n params?: ListJournalsApiJournalsGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listJournalsApiJournalsGet>>, TError, TData>> & Pick<\n        UndefinedInitialDataOptions<\n          Awaited<ReturnType<typeof listJournalsApiJournalsGet>>,\n          TError,\n          Awaited<ReturnType<typeof listJournalsApiJournalsGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useListJournalsApiJournalsGet<TData = Awaited<ReturnType<typeof listJournalsApiJournalsGet>>, TError = HTTPValidationError>(\n params?: ListJournalsApiJournalsGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listJournalsApiJournalsGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\n/**\n * @summary List Journals\n */\n\nexport function useListJournalsApiJournalsGet<TData = Awaited<ReturnType<typeof listJournalsApiJournalsGet>>, TError = HTTPValidationError>(\n params?: ListJournalsApiJournalsGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listJournalsApiJournalsGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient \n ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {\n\n  const queryOptions = getListJournalsApiJournalsGetQueryOptions(params,options)\n\n  const query = useQuery(queryOptions, queryClient) as  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };\n\n  return { ...query, queryKey: queryOptions.queryKey };\n}\n\n\n\n\n/**\n * 获取日记详情\n * @summary Get Journal\n */\nexport type getJournalApiJournalsJournalIdGetResponse200 = {\n  data: JournalResponse\n  status: 200\n}\n\nexport type getJournalApiJournalsJournalIdGetResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type getJournalApiJournalsJournalIdGetResponseSuccess = (getJournalApiJournalsJournalIdGetResponse200) & {\n  headers: Headers;\n};\nexport type getJournalApiJournalsJournalIdGetResponseError = (getJournalApiJournalsJournalIdGetResponse422) & {\n  headers: Headers;\n};\n\nexport type getJournalApiJournalsJournalIdGetResponse = (getJournalApiJournalsJournalIdGetResponseSuccess | getJournalApiJournalsJournalIdGetResponseError)\n\nexport const getGetJournalApiJournalsJournalIdGetUrl = (journalId: number,) => {\n\n\n  \n\n  return `/api/journals/${journalId}`\n}\n\nexport const getJournalApiJournalsJournalIdGet = async (journalId: number, options?: RequestInit): Promise<getJournalApiJournalsJournalIdGetResponse> => {\n  \n  return customFetcher<getJournalApiJournalsJournalIdGetResponse>(getGetJournalApiJournalsJournalIdGetUrl(journalId),\n  {      \n    ...options,\n    method: 'GET'\n    \n    \n  }\n);}\n\n\n\n\n\nexport const getGetJournalApiJournalsJournalIdGetQueryKey = (journalId: number,) => {\n    return [\n    `/api/journals/${journalId}`\n    ] as const;\n    }\n\n    \nexport const getGetJournalApiJournalsJournalIdGetQueryOptions = <TData = Awaited<ReturnType<typeof getJournalApiJournalsJournalIdGet>>, TError = HTTPValidationError>(journalId: number, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getJournalApiJournalsJournalIdGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n) => {\n\nconst {query: queryOptions, request: requestOptions} = options ?? {};\n\n  const queryKey =  queryOptions?.queryKey ?? getGetJournalApiJournalsJournalIdGetQueryKey(journalId);\n\n  \n\n    const queryFn: QueryFunction<Awaited<ReturnType<typeof getJournalApiJournalsJournalIdGet>>> = ({ signal }) => getJournalApiJournalsJournalIdGet(journalId, { signal, ...requestOptions });\n\n      \n\n      \n\n   return  { queryKey, queryFn, enabled: !!(journalId), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getJournalApiJournalsJournalIdGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }\n}\n\nexport type GetJournalApiJournalsJournalIdGetQueryResult = NonNullable<Awaited<ReturnType<typeof getJournalApiJournalsJournalIdGet>>>\nexport type GetJournalApiJournalsJournalIdGetQueryError = HTTPValidationError\n\n\nexport function useGetJournalApiJournalsJournalIdGet<TData = Awaited<ReturnType<typeof getJournalApiJournalsJournalIdGet>>, TError = HTTPValidationError>(\n journalId: number, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getJournalApiJournalsJournalIdGet>>, TError, TData>> & Pick<\n        DefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getJournalApiJournalsJournalIdGet>>,\n          TError,\n          Awaited<ReturnType<typeof getJournalApiJournalsJournalIdGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetJournalApiJournalsJournalIdGet<TData = Awaited<ReturnType<typeof getJournalApiJournalsJournalIdGet>>, TError = HTTPValidationError>(\n journalId: number, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getJournalApiJournalsJournalIdGet>>, TError, TData>> & Pick<\n        UndefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getJournalApiJournalsJournalIdGet>>,\n          TError,\n          Awaited<ReturnType<typeof getJournalApiJournalsJournalIdGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetJournalApiJournalsJournalIdGet<TData = Awaited<ReturnType<typeof getJournalApiJournalsJournalIdGet>>, TError = HTTPValidationError>(\n journalId: number, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getJournalApiJournalsJournalIdGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\n/**\n * @summary Get Journal\n */\n\nexport function useGetJournalApiJournalsJournalIdGet<TData = Awaited<ReturnType<typeof getJournalApiJournalsJournalIdGet>>, TError = HTTPValidationError>(\n journalId: number, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getJournalApiJournalsJournalIdGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient \n ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {\n\n  const queryOptions = getGetJournalApiJournalsJournalIdGetQueryOptions(journalId,options)\n\n  const query = useQuery(queryOptions, queryClient) as  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };\n\n  return { ...query, queryKey: queryOptions.queryKey };\n}\n\n\n\n\n/**\n * 更新日记\n * @summary Update Journal\n */\nexport type updateJournalApiJournalsJournalIdPutResponse200 = {\n  data: JournalResponse\n  status: 200\n}\n\nexport type updateJournalApiJournalsJournalIdPutResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type updateJournalApiJournalsJournalIdPutResponseSuccess = (updateJournalApiJournalsJournalIdPutResponse200) & {\n  headers: Headers;\n};\nexport type updateJournalApiJournalsJournalIdPutResponseError = (updateJournalApiJournalsJournalIdPutResponse422) & {\n  headers: Headers;\n};\n\nexport type updateJournalApiJournalsJournalIdPutResponse = (updateJournalApiJournalsJournalIdPutResponseSuccess | updateJournalApiJournalsJournalIdPutResponseError)\n\nexport const getUpdateJournalApiJournalsJournalIdPutUrl = (journalId: number,) => {\n\n\n  \n\n  return `/api/journals/${journalId}`\n}\n\nexport const updateJournalApiJournalsJournalIdPut = async (journalId: number,\n    journalUpdateNull: JournalUpdate | null, options?: RequestInit): Promise<updateJournalApiJournalsJournalIdPutResponse> => {\n  \n  return customFetcher<updateJournalApiJournalsJournalIdPutResponse>(getUpdateJournalApiJournalsJournalIdPutUrl(journalId),\n  {      \n    ...options,\n    method: 'PUT',\n    headers: { 'Content-Type': 'application/json', ...options?.headers },\n    body: JSON.stringify(\n      journalUpdateNull,)\n  }\n);}\n\n\n\n\nexport const getUpdateJournalApiJournalsJournalIdPutMutationOptions = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateJournalApiJournalsJournalIdPut>>, TError,{journalId: number;data: JournalUpdate | null}, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof updateJournalApiJournalsJournalIdPut>>, TError,{journalId: number;data: JournalUpdate | null}, TContext> => {\n\nconst mutationKey = ['updateJournalApiJournalsJournalIdPut'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof updateJournalApiJournalsJournalIdPut>>, {journalId: number;data: JournalUpdate | null}> = (props) => {\n          const {journalId,data} = props ?? {};\n\n          return  updateJournalApiJournalsJournalIdPut(journalId,data,requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type UpdateJournalApiJournalsJournalIdPutMutationResult = NonNullable<Awaited<ReturnType<typeof updateJournalApiJournalsJournalIdPut>>>\n    export type UpdateJournalApiJournalsJournalIdPutMutationBody = JournalUpdate | null\n    export type UpdateJournalApiJournalsJournalIdPutMutationError = HTTPValidationError\n\n    /**\n * @summary Update Journal\n */\nexport const useUpdateJournalApiJournalsJournalIdPut = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateJournalApiJournalsJournalIdPut>>, TError,{journalId: number;data: JournalUpdate | null}, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof updateJournalApiJournalsJournalIdPut>>,\n        TError,\n        {journalId: number;data: JournalUpdate | null},\n        TContext\n      > => {\n      return useMutation(getUpdateJournalApiJournalsJournalIdPutMutationOptions(options), queryClient);\n    }\n    /**\n * 删除日记\n * @summary Delete Journal\n */\nexport type deleteJournalApiJournalsJournalIdDeleteResponse204 = {\n  data: void\n  status: 204\n}\n\nexport type deleteJournalApiJournalsJournalIdDeleteResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type deleteJournalApiJournalsJournalIdDeleteResponseSuccess = (deleteJournalApiJournalsJournalIdDeleteResponse204) & {\n  headers: Headers;\n};\nexport type deleteJournalApiJournalsJournalIdDeleteResponseError = (deleteJournalApiJournalsJournalIdDeleteResponse422) & {\n  headers: Headers;\n};\n\nexport type deleteJournalApiJournalsJournalIdDeleteResponse = (deleteJournalApiJournalsJournalIdDeleteResponseSuccess | deleteJournalApiJournalsJournalIdDeleteResponseError)\n\nexport const getDeleteJournalApiJournalsJournalIdDeleteUrl = (journalId: number,) => {\n\n\n  \n\n  return `/api/journals/${journalId}`\n}\n\nexport const deleteJournalApiJournalsJournalIdDelete = async (journalId: number, options?: RequestInit): Promise<deleteJournalApiJournalsJournalIdDeleteResponse> => {\n  \n  return customFetcher<deleteJournalApiJournalsJournalIdDeleteResponse>(getDeleteJournalApiJournalsJournalIdDeleteUrl(journalId),\n  {      \n    ...options,\n    method: 'DELETE'\n    \n    \n  }\n);}\n\n\n\n\nexport const getDeleteJournalApiJournalsJournalIdDeleteMutationOptions = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof deleteJournalApiJournalsJournalIdDelete>>, TError,{journalId: number}, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof deleteJournalApiJournalsJournalIdDelete>>, TError,{journalId: number}, TContext> => {\n\nconst mutationKey = ['deleteJournalApiJournalsJournalIdDelete'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof deleteJournalApiJournalsJournalIdDelete>>, {journalId: number}> = (props) => {\n          const {journalId} = props ?? {};\n\n          return  deleteJournalApiJournalsJournalIdDelete(journalId,requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type DeleteJournalApiJournalsJournalIdDeleteMutationResult = NonNullable<Awaited<ReturnType<typeof deleteJournalApiJournalsJournalIdDelete>>>\n    \n    export type DeleteJournalApiJournalsJournalIdDeleteMutationError = HTTPValidationError\n\n    /**\n * @summary Delete Journal\n */\nexport const useDeleteJournalApiJournalsJournalIdDelete = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof deleteJournalApiJournalsJournalIdDelete>>, TError,{journalId: number}, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof deleteJournalApiJournalsJournalIdDelete>>,\n        TError,\n        {journalId: number},\n        TContext\n      > => {\n      return useMutation(getDeleteJournalApiJournalsJournalIdDeleteMutationOptions(options), queryClient);\n    }\n    /**\n * 自动关联 Todo/活动\n * @summary Auto Link Journal\n */\nexport type autoLinkJournalApiJournalsAutoLinkPostResponse200 = {\n  data: JournalAutoLinkResponse\n  status: 200\n}\n\nexport type autoLinkJournalApiJournalsAutoLinkPostResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type autoLinkJournalApiJournalsAutoLinkPostResponseSuccess = (autoLinkJournalApiJournalsAutoLinkPostResponse200) & {\n  headers: Headers;\n};\nexport type autoLinkJournalApiJournalsAutoLinkPostResponseError = (autoLinkJournalApiJournalsAutoLinkPostResponse422) & {\n  headers: Headers;\n};\n\nexport type autoLinkJournalApiJournalsAutoLinkPostResponse = (autoLinkJournalApiJournalsAutoLinkPostResponseSuccess | autoLinkJournalApiJournalsAutoLinkPostResponseError)\n\nexport const getAutoLinkJournalApiJournalsAutoLinkPostUrl = () => {\n\n\n  \n\n  return `/api/journals/auto-link`\n}\n\nexport const autoLinkJournalApiJournalsAutoLinkPost = async (journalAutoLinkRequest: JournalAutoLinkRequest, options?: RequestInit): Promise<autoLinkJournalApiJournalsAutoLinkPostResponse> => {\n  \n  return customFetcher<autoLinkJournalApiJournalsAutoLinkPostResponse>(getAutoLinkJournalApiJournalsAutoLinkPostUrl(),\n  {      \n    ...options,\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json', ...options?.headers },\n    body: JSON.stringify(\n      journalAutoLinkRequest,)\n  }\n);}\n\n\n\n\nexport const getAutoLinkJournalApiJournalsAutoLinkPostMutationOptions = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof autoLinkJournalApiJournalsAutoLinkPost>>, TError,{data: JournalAutoLinkRequest}, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof autoLinkJournalApiJournalsAutoLinkPost>>, TError,{data: JournalAutoLinkRequest}, TContext> => {\n\nconst mutationKey = ['autoLinkJournalApiJournalsAutoLinkPost'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof autoLinkJournalApiJournalsAutoLinkPost>>, {data: JournalAutoLinkRequest}> = (props) => {\n          const {data} = props ?? {};\n\n          return  autoLinkJournalApiJournalsAutoLinkPost(data,requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type AutoLinkJournalApiJournalsAutoLinkPostMutationResult = NonNullable<Awaited<ReturnType<typeof autoLinkJournalApiJournalsAutoLinkPost>>>\n    export type AutoLinkJournalApiJournalsAutoLinkPostMutationBody = JournalAutoLinkRequest\n    export type AutoLinkJournalApiJournalsAutoLinkPostMutationError = HTTPValidationError\n\n    /**\n * @summary Auto Link Journal\n */\nexport const useAutoLinkJournalApiJournalsAutoLinkPost = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof autoLinkJournalApiJournalsAutoLinkPost>>, TError,{data: JournalAutoLinkRequest}, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof autoLinkJournalApiJournalsAutoLinkPost>>,\n        TError,\n        {data: JournalAutoLinkRequest},\n        TContext\n      > => {\n      return useMutation(getAutoLinkJournalApiJournalsAutoLinkPostMutationOptions(options), queryClient);\n    }\n    /**\n * 生成客观记录\n * @summary Generate Objective Journal\n */\nexport type generateObjectiveJournalApiJournalsGenerateObjectivePostResponse200 = {\n  data: JournalGenerateResponse\n  status: 200\n}\n\nexport type generateObjectiveJournalApiJournalsGenerateObjectivePostResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type generateObjectiveJournalApiJournalsGenerateObjectivePostResponseSuccess = (generateObjectiveJournalApiJournalsGenerateObjectivePostResponse200) & {\n  headers: Headers;\n};\nexport type generateObjectiveJournalApiJournalsGenerateObjectivePostResponseError = (generateObjectiveJournalApiJournalsGenerateObjectivePostResponse422) & {\n  headers: Headers;\n};\n\nexport type generateObjectiveJournalApiJournalsGenerateObjectivePostResponse = (generateObjectiveJournalApiJournalsGenerateObjectivePostResponseSuccess | generateObjectiveJournalApiJournalsGenerateObjectivePostResponseError)\n\nexport const getGenerateObjectiveJournalApiJournalsGenerateObjectivePostUrl = () => {\n\n\n  \n\n  return `/api/journals/generate-objective`\n}\n\nexport const generateObjectiveJournalApiJournalsGenerateObjectivePost = async (journalGenerateRequest: JournalGenerateRequest, options?: RequestInit): Promise<generateObjectiveJournalApiJournalsGenerateObjectivePostResponse> => {\n  \n  return customFetcher<generateObjectiveJournalApiJournalsGenerateObjectivePostResponse>(getGenerateObjectiveJournalApiJournalsGenerateObjectivePostUrl(),\n  {      \n    ...options,\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json', ...options?.headers },\n    body: JSON.stringify(\n      journalGenerateRequest,)\n  }\n);}\n\n\n\n\nexport const getGenerateObjectiveJournalApiJournalsGenerateObjectivePostMutationOptions = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof generateObjectiveJournalApiJournalsGenerateObjectivePost>>, TError,{data: JournalGenerateRequest}, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof generateObjectiveJournalApiJournalsGenerateObjectivePost>>, TError,{data: JournalGenerateRequest}, TContext> => {\n\nconst mutationKey = ['generateObjectiveJournalApiJournalsGenerateObjectivePost'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof generateObjectiveJournalApiJournalsGenerateObjectivePost>>, {data: JournalGenerateRequest}> = (props) => {\n          const {data} = props ?? {};\n\n          return  generateObjectiveJournalApiJournalsGenerateObjectivePost(data,requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type GenerateObjectiveJournalApiJournalsGenerateObjectivePostMutationResult = NonNullable<Awaited<ReturnType<typeof generateObjectiveJournalApiJournalsGenerateObjectivePost>>>\n    export type GenerateObjectiveJournalApiJournalsGenerateObjectivePostMutationBody = JournalGenerateRequest\n    export type GenerateObjectiveJournalApiJournalsGenerateObjectivePostMutationError = HTTPValidationError\n\n    /**\n * @summary Generate Objective Journal\n */\nexport const useGenerateObjectiveJournalApiJournalsGenerateObjectivePost = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof generateObjectiveJournalApiJournalsGenerateObjectivePost>>, TError,{data: JournalGenerateRequest}, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof generateObjectiveJournalApiJournalsGenerateObjectivePost>>,\n        TError,\n        {data: JournalGenerateRequest},\n        TContext\n      > => {\n      return useMutation(getGenerateObjectiveJournalApiJournalsGenerateObjectivePostMutationOptions(options), queryClient);\n    }\n    /**\n * 生成 AI 视角记录\n * @summary Generate Ai Journal\n */\nexport type generateAiJournalApiJournalsGenerateAiPostResponse200 = {\n  data: JournalGenerateResponse\n  status: 200\n}\n\nexport type generateAiJournalApiJournalsGenerateAiPostResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type generateAiJournalApiJournalsGenerateAiPostResponseSuccess = (generateAiJournalApiJournalsGenerateAiPostResponse200) & {\n  headers: Headers;\n};\nexport type generateAiJournalApiJournalsGenerateAiPostResponseError = (generateAiJournalApiJournalsGenerateAiPostResponse422) & {\n  headers: Headers;\n};\n\nexport type generateAiJournalApiJournalsGenerateAiPostResponse = (generateAiJournalApiJournalsGenerateAiPostResponseSuccess | generateAiJournalApiJournalsGenerateAiPostResponseError)\n\nexport const getGenerateAiJournalApiJournalsGenerateAiPostUrl = () => {\n\n\n  \n\n  return `/api/journals/generate-ai`\n}\n\nexport const generateAiJournalApiJournalsGenerateAiPost = async (journalGenerateRequest: JournalGenerateRequest, options?: RequestInit): Promise<generateAiJournalApiJournalsGenerateAiPostResponse> => {\n  \n  return customFetcher<generateAiJournalApiJournalsGenerateAiPostResponse>(getGenerateAiJournalApiJournalsGenerateAiPostUrl(),\n  {      \n    ...options,\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json', ...options?.headers },\n    body: JSON.stringify(\n      journalGenerateRequest,)\n  }\n);}\n\n\n\n\nexport const getGenerateAiJournalApiJournalsGenerateAiPostMutationOptions = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof generateAiJournalApiJournalsGenerateAiPost>>, TError,{data: JournalGenerateRequest}, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof generateAiJournalApiJournalsGenerateAiPost>>, TError,{data: JournalGenerateRequest}, TContext> => {\n\nconst mutationKey = ['generateAiJournalApiJournalsGenerateAiPost'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof generateAiJournalApiJournalsGenerateAiPost>>, {data: JournalGenerateRequest}> = (props) => {\n          const {data} = props ?? {};\n\n          return  generateAiJournalApiJournalsGenerateAiPost(data,requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type GenerateAiJournalApiJournalsGenerateAiPostMutationResult = NonNullable<Awaited<ReturnType<typeof generateAiJournalApiJournalsGenerateAiPost>>>\n    export type GenerateAiJournalApiJournalsGenerateAiPostMutationBody = JournalGenerateRequest\n    export type GenerateAiJournalApiJournalsGenerateAiPostMutationError = HTTPValidationError\n\n    /**\n * @summary Generate Ai Journal\n */\nexport const useGenerateAiJournalApiJournalsGenerateAiPost = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof generateAiJournalApiJournalsGenerateAiPost>>, TError,{data: JournalGenerateRequest}, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof generateAiJournalApiJournalsGenerateAiPost>>,\n        TError,\n        {data: JournalGenerateRequest},\n        TContext\n      > => {\n      return useMutation(getGenerateAiJournalApiJournalsGenerateAiPostMutationOptions(options), queryClient);\n    }\n    "
  },
  {
    "path": "free-todo-frontend/lib/generated/logs/logs.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport {\n  useQuery\n} from '@tanstack/react-query';\nimport type {\n  DataTag,\n  DefinedInitialDataOptions,\n  DefinedUseQueryResult,\n  QueryClient,\n  QueryFunction,\n  QueryKey,\n  UndefinedInitialDataOptions,\n  UseQueryOptions,\n  UseQueryResult\n} from '@tanstack/react-query';\n\nimport type {\n  GetLogContentApiLogsContentGetParams,\n  HTTPValidationError\n} from '.././schemas';\n\nimport { customFetcher } from '../../api/fetcher';\n\n\ntype SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];\n\n\n\n/**\n * 获取日志文件列表\n * @summary Get Log Files\n */\nexport type getLogFilesApiLogsFilesGetResponse200 = {\n  data: unknown\n  status: 200\n}\n    \nexport type getLogFilesApiLogsFilesGetResponseSuccess = (getLogFilesApiLogsFilesGetResponse200) & {\n  headers: Headers;\n};\n;\n\nexport type getLogFilesApiLogsFilesGetResponse = (getLogFilesApiLogsFilesGetResponseSuccess)\n\nexport const getGetLogFilesApiLogsFilesGetUrl = () => {\n\n\n  \n\n  return `/api/logs/files`\n}\n\nexport const getLogFilesApiLogsFilesGet = async ( options?: RequestInit): Promise<getLogFilesApiLogsFilesGetResponse> => {\n  \n  return customFetcher<getLogFilesApiLogsFilesGetResponse>(getGetLogFilesApiLogsFilesGetUrl(),\n  {      \n    ...options,\n    method: 'GET'\n    \n    \n  }\n);}\n\n\n\n\n\nexport const getGetLogFilesApiLogsFilesGetQueryKey = () => {\n    return [\n    `/api/logs/files`\n    ] as const;\n    }\n\n    \nexport const getGetLogFilesApiLogsFilesGetQueryOptions = <TData = Awaited<ReturnType<typeof getLogFilesApiLogsFilesGet>>, TError = unknown>( options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getLogFilesApiLogsFilesGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n) => {\n\nconst {query: queryOptions, request: requestOptions} = options ?? {};\n\n  const queryKey =  queryOptions?.queryKey ?? getGetLogFilesApiLogsFilesGetQueryKey();\n\n  \n\n    const queryFn: QueryFunction<Awaited<ReturnType<typeof getLogFilesApiLogsFilesGet>>> = ({ signal }) => getLogFilesApiLogsFilesGet({ signal, ...requestOptions });\n\n      \n\n      \n\n   return  { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getLogFilesApiLogsFilesGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }\n}\n\nexport type GetLogFilesApiLogsFilesGetQueryResult = NonNullable<Awaited<ReturnType<typeof getLogFilesApiLogsFilesGet>>>\nexport type GetLogFilesApiLogsFilesGetQueryError = unknown\n\n\nexport function useGetLogFilesApiLogsFilesGet<TData = Awaited<ReturnType<typeof getLogFilesApiLogsFilesGet>>, TError = unknown>(\n  options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getLogFilesApiLogsFilesGet>>, TError, TData>> & Pick<\n        DefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getLogFilesApiLogsFilesGet>>,\n          TError,\n          Awaited<ReturnType<typeof getLogFilesApiLogsFilesGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetLogFilesApiLogsFilesGet<TData = Awaited<ReturnType<typeof getLogFilesApiLogsFilesGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getLogFilesApiLogsFilesGet>>, TError, TData>> & Pick<\n        UndefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getLogFilesApiLogsFilesGet>>,\n          TError,\n          Awaited<ReturnType<typeof getLogFilesApiLogsFilesGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetLogFilesApiLogsFilesGet<TData = Awaited<ReturnType<typeof getLogFilesApiLogsFilesGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getLogFilesApiLogsFilesGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\n/**\n * @summary Get Log Files\n */\n\nexport function useGetLogFilesApiLogsFilesGet<TData = Awaited<ReturnType<typeof getLogFilesApiLogsFilesGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getLogFilesApiLogsFilesGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient \n ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {\n\n  const queryOptions = getGetLogFilesApiLogsFilesGetQueryOptions(options)\n\n  const query = useQuery(queryOptions, queryClient) as  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };\n\n  return { ...query, queryKey: queryOptions.queryKey };\n}\n\n\n\n\n/**\n * 获取日志文件内容\n * @summary Get Log Content\n */\nexport type getLogContentApiLogsContentGetResponse200 = {\n  data: string\n  status: 200\n}\n\nexport type getLogContentApiLogsContentGetResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type getLogContentApiLogsContentGetResponseSuccess = (getLogContentApiLogsContentGetResponse200) & {\n  headers: Headers;\n};\nexport type getLogContentApiLogsContentGetResponseError = (getLogContentApiLogsContentGetResponse422) & {\n  headers: Headers;\n};\n\nexport type getLogContentApiLogsContentGetResponse = (getLogContentApiLogsContentGetResponseSuccess | getLogContentApiLogsContentGetResponseError)\n\nexport const getGetLogContentApiLogsContentGetUrl = (params: GetLogContentApiLogsContentGetParams,) => {\n  const normalizedParams = new URLSearchParams();\n\n  Object.entries(params || {}).forEach(([key, value]) => {\n    \n    if (value !== undefined) {\n      normalizedParams.append(key, value === null ? 'null' : value.toString())\n    }\n  });\n\n  const stringifiedParams = normalizedParams.toString();\n\n  return stringifiedParams.length > 0 ? `/api/logs/content?${stringifiedParams}` : `/api/logs/content`\n}\n\nexport const getLogContentApiLogsContentGet = async (params: GetLogContentApiLogsContentGetParams, options?: RequestInit): Promise<getLogContentApiLogsContentGetResponse> => {\n  \n  return customFetcher<getLogContentApiLogsContentGetResponse>(getGetLogContentApiLogsContentGetUrl(params),\n  {      \n    ...options,\n    method: 'GET'\n    \n    \n  }\n);}\n\n\n\n\n\nexport const getGetLogContentApiLogsContentGetQueryKey = (params?: GetLogContentApiLogsContentGetParams,) => {\n    return [\n    `/api/logs/content`, ...(params ? [params] : [])\n    ] as const;\n    }\n\n    \nexport const getGetLogContentApiLogsContentGetQueryOptions = <TData = Awaited<ReturnType<typeof getLogContentApiLogsContentGet>>, TError = HTTPValidationError>(params: GetLogContentApiLogsContentGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getLogContentApiLogsContentGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n) => {\n\nconst {query: queryOptions, request: requestOptions} = options ?? {};\n\n  const queryKey =  queryOptions?.queryKey ?? getGetLogContentApiLogsContentGetQueryKey(params);\n\n  \n\n    const queryFn: QueryFunction<Awaited<ReturnType<typeof getLogContentApiLogsContentGet>>> = ({ signal }) => getLogContentApiLogsContentGet(params, { signal, ...requestOptions });\n\n      \n\n      \n\n   return  { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getLogContentApiLogsContentGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }\n}\n\nexport type GetLogContentApiLogsContentGetQueryResult = NonNullable<Awaited<ReturnType<typeof getLogContentApiLogsContentGet>>>\nexport type GetLogContentApiLogsContentGetQueryError = HTTPValidationError\n\n\nexport function useGetLogContentApiLogsContentGet<TData = Awaited<ReturnType<typeof getLogContentApiLogsContentGet>>, TError = HTTPValidationError>(\n params: GetLogContentApiLogsContentGetParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getLogContentApiLogsContentGet>>, TError, TData>> & Pick<\n        DefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getLogContentApiLogsContentGet>>,\n          TError,\n          Awaited<ReturnType<typeof getLogContentApiLogsContentGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetLogContentApiLogsContentGet<TData = Awaited<ReturnType<typeof getLogContentApiLogsContentGet>>, TError = HTTPValidationError>(\n params: GetLogContentApiLogsContentGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getLogContentApiLogsContentGet>>, TError, TData>> & Pick<\n        UndefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getLogContentApiLogsContentGet>>,\n          TError,\n          Awaited<ReturnType<typeof getLogContentApiLogsContentGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetLogContentApiLogsContentGet<TData = Awaited<ReturnType<typeof getLogContentApiLogsContentGet>>, TError = HTTPValidationError>(\n params: GetLogContentApiLogsContentGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getLogContentApiLogsContentGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\n/**\n * @summary Get Log Content\n */\n\nexport function useGetLogContentApiLogsContentGet<TData = Awaited<ReturnType<typeof getLogContentApiLogsContentGet>>, TError = HTTPValidationError>(\n params: GetLogContentApiLogsContentGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getLogContentApiLogsContentGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient \n ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {\n\n  const queryOptions = getGetLogContentApiLogsContentGetQueryOptions(params,options)\n\n  const query = useQuery(queryOptions, queryClient) as  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };\n\n  return { ...query, queryKey: queryOptions.queryKey };\n}\n\n\n\n\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/notifications/notifications.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport {\n  useMutation,\n  useQuery\n} from '@tanstack/react-query';\nimport type {\n  DataTag,\n  DefinedInitialDataOptions,\n  DefinedUseQueryResult,\n  MutationFunction,\n  QueryClient,\n  QueryFunction,\n  QueryKey,\n  UndefinedInitialDataOptions,\n  UseMutationOptions,\n  UseMutationResult,\n  UseQueryOptions,\n  UseQueryResult\n} from '@tanstack/react-query';\n\nimport type {\n  HTTPValidationError\n} from '.././schemas';\n\nimport { customFetcher } from '../../api/fetcher';\n\n\ntype SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];\n\n\n\n/**\n * 获取通知列表（按时间倒序）\n\n返回格式：\n[\n    {\n        \"id\": \"通知ID\",\n        \"title\": \"通知标题\",\n        \"content\": \"通知内容\",\n        \"timestamp\": \"时间戳（ISO格式）\",\n        \"todo_id\": 待办ID（可选）\n    }\n]\n * @summary Get Notification\n */\nexport type getNotificationApiNotificationsGetResponse200 = {\n  data: unknown\n  status: 200\n}\n    \nexport type getNotificationApiNotificationsGetResponseSuccess = (getNotificationApiNotificationsGetResponse200) & {\n  headers: Headers;\n};\n;\n\nexport type getNotificationApiNotificationsGetResponse = (getNotificationApiNotificationsGetResponseSuccess)\n\nexport const getGetNotificationApiNotificationsGetUrl = () => {\n\n\n  \n\n  return `/api/notifications`\n}\n\nexport const getNotificationApiNotificationsGet = async ( options?: RequestInit): Promise<getNotificationApiNotificationsGetResponse> => {\n  \n  return customFetcher<getNotificationApiNotificationsGetResponse>(getGetNotificationApiNotificationsGetUrl(),\n  {      \n    ...options,\n    method: 'GET'\n    \n    \n  }\n);}\n\n\n\n\n\nexport const getGetNotificationApiNotificationsGetQueryKey = () => {\n    return [\n    `/api/notifications`\n    ] as const;\n    }\n\n    \nexport const getGetNotificationApiNotificationsGetQueryOptions = <TData = Awaited<ReturnType<typeof getNotificationApiNotificationsGet>>, TError = unknown>( options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getNotificationApiNotificationsGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n) => {\n\nconst {query: queryOptions, request: requestOptions} = options ?? {};\n\n  const queryKey =  queryOptions?.queryKey ?? getGetNotificationApiNotificationsGetQueryKey();\n\n  \n\n    const queryFn: QueryFunction<Awaited<ReturnType<typeof getNotificationApiNotificationsGet>>> = ({ signal }) => getNotificationApiNotificationsGet({ signal, ...requestOptions });\n\n      \n\n      \n\n   return  { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getNotificationApiNotificationsGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }\n}\n\nexport type GetNotificationApiNotificationsGetQueryResult = NonNullable<Awaited<ReturnType<typeof getNotificationApiNotificationsGet>>>\nexport type GetNotificationApiNotificationsGetQueryError = unknown\n\n\nexport function useGetNotificationApiNotificationsGet<TData = Awaited<ReturnType<typeof getNotificationApiNotificationsGet>>, TError = unknown>(\n  options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getNotificationApiNotificationsGet>>, TError, TData>> & Pick<\n        DefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getNotificationApiNotificationsGet>>,\n          TError,\n          Awaited<ReturnType<typeof getNotificationApiNotificationsGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetNotificationApiNotificationsGet<TData = Awaited<ReturnType<typeof getNotificationApiNotificationsGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getNotificationApiNotificationsGet>>, TError, TData>> & Pick<\n        UndefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getNotificationApiNotificationsGet>>,\n          TError,\n          Awaited<ReturnType<typeof getNotificationApiNotificationsGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetNotificationApiNotificationsGet<TData = Awaited<ReturnType<typeof getNotificationApiNotificationsGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getNotificationApiNotificationsGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\n/**\n * @summary Get Notification\n */\n\nexport function useGetNotificationApiNotificationsGet<TData = Awaited<ReturnType<typeof getNotificationApiNotificationsGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getNotificationApiNotificationsGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient \n ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {\n\n  const queryOptions = getGetNotificationApiNotificationsGetQueryOptions(options)\n\n  const query = useQuery(queryOptions, queryClient) as  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };\n\n  return { ...query, queryKey: queryOptions.queryKey };\n}\n\n\n\n\n/**\n * 删除指定通知\n\nArgs:\n    notification_id: 通知ID\n\nReturns:\n    {\"success\": True, \"message\": \"通知已删除\"}\n * @summary Delete Notification\n */\nexport type deleteNotificationApiNotificationsNotificationIdDeleteResponse200 = {\n  data: unknown\n  status: 200\n}\n\nexport type deleteNotificationApiNotificationsNotificationIdDeleteResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type deleteNotificationApiNotificationsNotificationIdDeleteResponseSuccess = (deleteNotificationApiNotificationsNotificationIdDeleteResponse200) & {\n  headers: Headers;\n};\nexport type deleteNotificationApiNotificationsNotificationIdDeleteResponseError = (deleteNotificationApiNotificationsNotificationIdDeleteResponse422) & {\n  headers: Headers;\n};\n\nexport type deleteNotificationApiNotificationsNotificationIdDeleteResponse = (deleteNotificationApiNotificationsNotificationIdDeleteResponseSuccess | deleteNotificationApiNotificationsNotificationIdDeleteResponseError)\n\nexport const getDeleteNotificationApiNotificationsNotificationIdDeleteUrl = (notificationId: string,) => {\n\n\n  \n\n  return `/api/notifications/${notificationId}`\n}\n\nexport const deleteNotificationApiNotificationsNotificationIdDelete = async (notificationId: string, options?: RequestInit): Promise<deleteNotificationApiNotificationsNotificationIdDeleteResponse> => {\n  \n  return customFetcher<deleteNotificationApiNotificationsNotificationIdDeleteResponse>(getDeleteNotificationApiNotificationsNotificationIdDeleteUrl(notificationId),\n  {      \n    ...options,\n    method: 'DELETE'\n    \n    \n  }\n);}\n\n\n\n\nexport const getDeleteNotificationApiNotificationsNotificationIdDeleteMutationOptions = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof deleteNotificationApiNotificationsNotificationIdDelete>>, TError,{notificationId: string}, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof deleteNotificationApiNotificationsNotificationIdDelete>>, TError,{notificationId: string}, TContext> => {\n\nconst mutationKey = ['deleteNotificationApiNotificationsNotificationIdDelete'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof deleteNotificationApiNotificationsNotificationIdDelete>>, {notificationId: string}> = (props) => {\n          const {notificationId} = props ?? {};\n\n          return  deleteNotificationApiNotificationsNotificationIdDelete(notificationId,requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type DeleteNotificationApiNotificationsNotificationIdDeleteMutationResult = NonNullable<Awaited<ReturnType<typeof deleteNotificationApiNotificationsNotificationIdDelete>>>\n    \n    export type DeleteNotificationApiNotificationsNotificationIdDeleteMutationError = HTTPValidationError\n\n    /**\n * @summary Delete Notification\n */\nexport const useDeleteNotificationApiNotificationsNotificationIdDelete = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof deleteNotificationApiNotificationsNotificationIdDelete>>, TError,{notificationId: string}, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof deleteNotificationApiNotificationsNotificationIdDelete>>,\n        TError,\n        {notificationId: string},\n        TContext\n      > => {\n      return useMutation(getDeleteNotificationApiNotificationsNotificationIdDeleteMutationOptions(options), queryClient);\n    }\n    "
  },
  {
    "path": "free-todo-frontend/lib/generated/ocr/ocr.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport {\n  useMutation,\n  useQuery\n} from '@tanstack/react-query';\nimport type {\n  DataTag,\n  DefinedInitialDataOptions,\n  DefinedUseQueryResult,\n  MutationFunction,\n  QueryClient,\n  QueryFunction,\n  QueryKey,\n  UndefinedInitialDataOptions,\n  UseMutationOptions,\n  UseMutationResult,\n  UseQueryOptions,\n  UseQueryResult\n} from '@tanstack/react-query';\n\nimport type {\n  HTTPValidationError,\n  ProcessOcrApiOcrProcessPostParams\n} from '.././schemas';\n\nimport { customFetcher } from '../../api/fetcher';\n\n\ntype SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];\n\n\n\n/**\n * 手动触发OCR处理\n * @summary Process Ocr\n */\nexport type processOcrApiOcrProcessPostResponse200 = {\n  data: unknown\n  status: 200\n}\n\nexport type processOcrApiOcrProcessPostResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type processOcrApiOcrProcessPostResponseSuccess = (processOcrApiOcrProcessPostResponse200) & {\n  headers: Headers;\n};\nexport type processOcrApiOcrProcessPostResponseError = (processOcrApiOcrProcessPostResponse422) & {\n  headers: Headers;\n};\n\nexport type processOcrApiOcrProcessPostResponse = (processOcrApiOcrProcessPostResponseSuccess | processOcrApiOcrProcessPostResponseError)\n\nexport const getProcessOcrApiOcrProcessPostUrl = (params: ProcessOcrApiOcrProcessPostParams,) => {\n  const normalizedParams = new URLSearchParams();\n\n  Object.entries(params || {}).forEach(([key, value]) => {\n    \n    if (value !== undefined) {\n      normalizedParams.append(key, value === null ? 'null' : value.toString())\n    }\n  });\n\n  const stringifiedParams = normalizedParams.toString();\n\n  return stringifiedParams.length > 0 ? `/api/ocr/process?${stringifiedParams}` : `/api/ocr/process`\n}\n\nexport const processOcrApiOcrProcessPost = async (params: ProcessOcrApiOcrProcessPostParams, options?: RequestInit): Promise<processOcrApiOcrProcessPostResponse> => {\n  \n  return customFetcher<processOcrApiOcrProcessPostResponse>(getProcessOcrApiOcrProcessPostUrl(params),\n  {      \n    ...options,\n    method: 'POST'\n    \n    \n  }\n);}\n\n\n\n\nexport const getProcessOcrApiOcrProcessPostMutationOptions = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof processOcrApiOcrProcessPost>>, TError,{params: ProcessOcrApiOcrProcessPostParams}, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof processOcrApiOcrProcessPost>>, TError,{params: ProcessOcrApiOcrProcessPostParams}, TContext> => {\n\nconst mutationKey = ['processOcrApiOcrProcessPost'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof processOcrApiOcrProcessPost>>, {params: ProcessOcrApiOcrProcessPostParams}> = (props) => {\n          const {params} = props ?? {};\n\n          return  processOcrApiOcrProcessPost(params,requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type ProcessOcrApiOcrProcessPostMutationResult = NonNullable<Awaited<ReturnType<typeof processOcrApiOcrProcessPost>>>\n    \n    export type ProcessOcrApiOcrProcessPostMutationError = HTTPValidationError\n\n    /**\n * @summary Process Ocr\n */\nexport const useProcessOcrApiOcrProcessPost = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof processOcrApiOcrProcessPost>>, TError,{params: ProcessOcrApiOcrProcessPostParams}, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof processOcrApiOcrProcessPost>>,\n        TError,\n        {params: ProcessOcrApiOcrProcessPostParams},\n        TContext\n      > => {\n      return useMutation(getProcessOcrApiOcrProcessPostMutationOptions(options), queryClient);\n    }\n    /**\n * 获取OCR处理统计\n * @summary Get Ocr Statistics\n */\nexport type getOcrStatisticsApiOcrStatisticsGetResponse200 = {\n  data: unknown\n  status: 200\n}\n    \nexport type getOcrStatisticsApiOcrStatisticsGetResponseSuccess = (getOcrStatisticsApiOcrStatisticsGetResponse200) & {\n  headers: Headers;\n};\n;\n\nexport type getOcrStatisticsApiOcrStatisticsGetResponse = (getOcrStatisticsApiOcrStatisticsGetResponseSuccess)\n\nexport const getGetOcrStatisticsApiOcrStatisticsGetUrl = () => {\n\n\n  \n\n  return `/api/ocr/statistics`\n}\n\nexport const getOcrStatisticsApiOcrStatisticsGet = async ( options?: RequestInit): Promise<getOcrStatisticsApiOcrStatisticsGetResponse> => {\n  \n  return customFetcher<getOcrStatisticsApiOcrStatisticsGetResponse>(getGetOcrStatisticsApiOcrStatisticsGetUrl(),\n  {      \n    ...options,\n    method: 'GET'\n    \n    \n  }\n);}\n\n\n\n\n\nexport const getGetOcrStatisticsApiOcrStatisticsGetQueryKey = () => {\n    return [\n    `/api/ocr/statistics`\n    ] as const;\n    }\n\n    \nexport const getGetOcrStatisticsApiOcrStatisticsGetQueryOptions = <TData = Awaited<ReturnType<typeof getOcrStatisticsApiOcrStatisticsGet>>, TError = unknown>( options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getOcrStatisticsApiOcrStatisticsGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n) => {\n\nconst {query: queryOptions, request: requestOptions} = options ?? {};\n\n  const queryKey =  queryOptions?.queryKey ?? getGetOcrStatisticsApiOcrStatisticsGetQueryKey();\n\n  \n\n    const queryFn: QueryFunction<Awaited<ReturnType<typeof getOcrStatisticsApiOcrStatisticsGet>>> = ({ signal }) => getOcrStatisticsApiOcrStatisticsGet({ signal, ...requestOptions });\n\n      \n\n      \n\n   return  { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getOcrStatisticsApiOcrStatisticsGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }\n}\n\nexport type GetOcrStatisticsApiOcrStatisticsGetQueryResult = NonNullable<Awaited<ReturnType<typeof getOcrStatisticsApiOcrStatisticsGet>>>\nexport type GetOcrStatisticsApiOcrStatisticsGetQueryError = unknown\n\n\nexport function useGetOcrStatisticsApiOcrStatisticsGet<TData = Awaited<ReturnType<typeof getOcrStatisticsApiOcrStatisticsGet>>, TError = unknown>(\n  options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getOcrStatisticsApiOcrStatisticsGet>>, TError, TData>> & Pick<\n        DefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getOcrStatisticsApiOcrStatisticsGet>>,\n          TError,\n          Awaited<ReturnType<typeof getOcrStatisticsApiOcrStatisticsGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetOcrStatisticsApiOcrStatisticsGet<TData = Awaited<ReturnType<typeof getOcrStatisticsApiOcrStatisticsGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getOcrStatisticsApiOcrStatisticsGet>>, TError, TData>> & Pick<\n        UndefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getOcrStatisticsApiOcrStatisticsGet>>,\n          TError,\n          Awaited<ReturnType<typeof getOcrStatisticsApiOcrStatisticsGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetOcrStatisticsApiOcrStatisticsGet<TData = Awaited<ReturnType<typeof getOcrStatisticsApiOcrStatisticsGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getOcrStatisticsApiOcrStatisticsGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\n/**\n * @summary Get Ocr Statistics\n */\n\nexport function useGetOcrStatisticsApiOcrStatisticsGet<TData = Awaited<ReturnType<typeof getOcrStatisticsApiOcrStatisticsGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getOcrStatisticsApiOcrStatisticsGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient \n ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {\n\n  const queryOptions = getGetOcrStatisticsApiOcrStatisticsGetQueryOptions(options)\n\n  const query = useQuery(queryOptions, queryClient) as  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };\n\n  return { ...query, queryKey: queryOptions.queryKey };\n}\n\n\n\n\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/proactive-ocr/proactive-ocr.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport {\n  useMutation,\n  useQuery\n} from '@tanstack/react-query';\nimport type {\n  DataTag,\n  DefinedInitialDataOptions,\n  DefinedUseQueryResult,\n  MutationFunction,\n  QueryClient,\n  QueryFunction,\n  QueryKey,\n  UndefinedInitialDataOptions,\n  UseMutationOptions,\n  UseMutationResult,\n  UseQueryOptions,\n  UseQueryResult\n} from '@tanstack/react-query';\n\nimport { customFetcher } from '../../api/fetcher';\n\n\ntype SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];\n\n\n\n/**\n * 启动主动OCR监控服务\n * @summary Start Proactive Ocr\n */\nexport type startProactiveOcrApiProactiveOcrStartPostResponse200 = {\n  data: unknown\n  status: 200\n}\n    \nexport type startProactiveOcrApiProactiveOcrStartPostResponseSuccess = (startProactiveOcrApiProactiveOcrStartPostResponse200) & {\n  headers: Headers;\n};\n;\n\nexport type startProactiveOcrApiProactiveOcrStartPostResponse = (startProactiveOcrApiProactiveOcrStartPostResponseSuccess)\n\nexport const getStartProactiveOcrApiProactiveOcrStartPostUrl = () => {\n\n\n  \n\n  return `/api/proactive-ocr/start`\n}\n\nexport const startProactiveOcrApiProactiveOcrStartPost = async ( options?: RequestInit): Promise<startProactiveOcrApiProactiveOcrStartPostResponse> => {\n  \n  return customFetcher<startProactiveOcrApiProactiveOcrStartPostResponse>(getStartProactiveOcrApiProactiveOcrStartPostUrl(),\n  {      \n    ...options,\n    method: 'POST'\n    \n    \n  }\n);}\n\n\n\n\nexport const getStartProactiveOcrApiProactiveOcrStartPostMutationOptions = <TError = unknown,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof startProactiveOcrApiProactiveOcrStartPost>>, TError,void, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof startProactiveOcrApiProactiveOcrStartPost>>, TError,void, TContext> => {\n\nconst mutationKey = ['startProactiveOcrApiProactiveOcrStartPost'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof startProactiveOcrApiProactiveOcrStartPost>>, void> = () => {\n          \n\n          return  startProactiveOcrApiProactiveOcrStartPost(requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type StartProactiveOcrApiProactiveOcrStartPostMutationResult = NonNullable<Awaited<ReturnType<typeof startProactiveOcrApiProactiveOcrStartPost>>>\n    \n    export type StartProactiveOcrApiProactiveOcrStartPostMutationError = unknown\n\n    /**\n * @summary Start Proactive Ocr\n */\nexport const useStartProactiveOcrApiProactiveOcrStartPost = <TError = unknown,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof startProactiveOcrApiProactiveOcrStartPost>>, TError,void, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof startProactiveOcrApiProactiveOcrStartPost>>,\n        TError,\n        void,\n        TContext\n      > => {\n      return useMutation(getStartProactiveOcrApiProactiveOcrStartPostMutationOptions(options), queryClient);\n    }\n    /**\n * 停止主动OCR监控服务\n * @summary Stop Proactive Ocr\n */\nexport type stopProactiveOcrApiProactiveOcrStopPostResponse200 = {\n  data: unknown\n  status: 200\n}\n    \nexport type stopProactiveOcrApiProactiveOcrStopPostResponseSuccess = (stopProactiveOcrApiProactiveOcrStopPostResponse200) & {\n  headers: Headers;\n};\n;\n\nexport type stopProactiveOcrApiProactiveOcrStopPostResponse = (stopProactiveOcrApiProactiveOcrStopPostResponseSuccess)\n\nexport const getStopProactiveOcrApiProactiveOcrStopPostUrl = () => {\n\n\n  \n\n  return `/api/proactive-ocr/stop`\n}\n\nexport const stopProactiveOcrApiProactiveOcrStopPost = async ( options?: RequestInit): Promise<stopProactiveOcrApiProactiveOcrStopPostResponse> => {\n  \n  return customFetcher<stopProactiveOcrApiProactiveOcrStopPostResponse>(getStopProactiveOcrApiProactiveOcrStopPostUrl(),\n  {      \n    ...options,\n    method: 'POST'\n    \n    \n  }\n);}\n\n\n\n\nexport const getStopProactiveOcrApiProactiveOcrStopPostMutationOptions = <TError = unknown,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof stopProactiveOcrApiProactiveOcrStopPost>>, TError,void, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof stopProactiveOcrApiProactiveOcrStopPost>>, TError,void, TContext> => {\n\nconst mutationKey = ['stopProactiveOcrApiProactiveOcrStopPost'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof stopProactiveOcrApiProactiveOcrStopPost>>, void> = () => {\n          \n\n          return  stopProactiveOcrApiProactiveOcrStopPost(requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type StopProactiveOcrApiProactiveOcrStopPostMutationResult = NonNullable<Awaited<ReturnType<typeof stopProactiveOcrApiProactiveOcrStopPost>>>\n    \n    export type StopProactiveOcrApiProactiveOcrStopPostMutationError = unknown\n\n    /**\n * @summary Stop Proactive Ocr\n */\nexport const useStopProactiveOcrApiProactiveOcrStopPost = <TError = unknown,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof stopProactiveOcrApiProactiveOcrStopPost>>, TError,void, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof stopProactiveOcrApiProactiveOcrStopPost>>,\n        TError,\n        void,\n        TContext\n      > => {\n      return useMutation(getStopProactiveOcrApiProactiveOcrStopPostMutationOptions(options), queryClient);\n    }\n    /**\n * 手动触发一次捕获和OCR处理\n * @summary Capture Once\n */\nexport type captureOnceApiProactiveOcrCapturePostResponse200 = {\n  data: unknown\n  status: 200\n}\n    \nexport type captureOnceApiProactiveOcrCapturePostResponseSuccess = (captureOnceApiProactiveOcrCapturePostResponse200) & {\n  headers: Headers;\n};\n;\n\nexport type captureOnceApiProactiveOcrCapturePostResponse = (captureOnceApiProactiveOcrCapturePostResponseSuccess)\n\nexport const getCaptureOnceApiProactiveOcrCapturePostUrl = () => {\n\n\n  \n\n  return `/api/proactive-ocr/capture`\n}\n\nexport const captureOnceApiProactiveOcrCapturePost = async ( options?: RequestInit): Promise<captureOnceApiProactiveOcrCapturePostResponse> => {\n  \n  return customFetcher<captureOnceApiProactiveOcrCapturePostResponse>(getCaptureOnceApiProactiveOcrCapturePostUrl(),\n  {      \n    ...options,\n    method: 'POST'\n    \n    \n  }\n);}\n\n\n\n\nexport const getCaptureOnceApiProactiveOcrCapturePostMutationOptions = <TError = unknown,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof captureOnceApiProactiveOcrCapturePost>>, TError,void, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof captureOnceApiProactiveOcrCapturePost>>, TError,void, TContext> => {\n\nconst mutationKey = ['captureOnceApiProactiveOcrCapturePost'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof captureOnceApiProactiveOcrCapturePost>>, void> = () => {\n          \n\n          return  captureOnceApiProactiveOcrCapturePost(requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type CaptureOnceApiProactiveOcrCapturePostMutationResult = NonNullable<Awaited<ReturnType<typeof captureOnceApiProactiveOcrCapturePost>>>\n    \n    export type CaptureOnceApiProactiveOcrCapturePostMutationError = unknown\n\n    /**\n * @summary Capture Once\n */\nexport const useCaptureOnceApiProactiveOcrCapturePost = <TError = unknown,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof captureOnceApiProactiveOcrCapturePost>>, TError,void, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof captureOnceApiProactiveOcrCapturePost>>,\n        TError,\n        void,\n        TContext\n      > => {\n      return useMutation(getCaptureOnceApiProactiveOcrCapturePostMutationOptions(options), queryClient);\n    }\n    /**\n * 获取主动OCR服务状态\n * @summary Get Proactive Ocr Status\n */\nexport type getProactiveOcrStatusApiProactiveOcrStatusGetResponse200 = {\n  data: unknown\n  status: 200\n}\n    \nexport type getProactiveOcrStatusApiProactiveOcrStatusGetResponseSuccess = (getProactiveOcrStatusApiProactiveOcrStatusGetResponse200) & {\n  headers: Headers;\n};\n;\n\nexport type getProactiveOcrStatusApiProactiveOcrStatusGetResponse = (getProactiveOcrStatusApiProactiveOcrStatusGetResponseSuccess)\n\nexport const getGetProactiveOcrStatusApiProactiveOcrStatusGetUrl = () => {\n\n\n  \n\n  return `/api/proactive-ocr/status`\n}\n\nexport const getProactiveOcrStatusApiProactiveOcrStatusGet = async ( options?: RequestInit): Promise<getProactiveOcrStatusApiProactiveOcrStatusGetResponse> => {\n  \n  return customFetcher<getProactiveOcrStatusApiProactiveOcrStatusGetResponse>(getGetProactiveOcrStatusApiProactiveOcrStatusGetUrl(),\n  {      \n    ...options,\n    method: 'GET'\n    \n    \n  }\n);}\n\n\n\n\n\nexport const getGetProactiveOcrStatusApiProactiveOcrStatusGetQueryKey = () => {\n    return [\n    `/api/proactive-ocr/status`\n    ] as const;\n    }\n\n    \nexport const getGetProactiveOcrStatusApiProactiveOcrStatusGetQueryOptions = <TData = Awaited<ReturnType<typeof getProactiveOcrStatusApiProactiveOcrStatusGet>>, TError = unknown>( options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getProactiveOcrStatusApiProactiveOcrStatusGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n) => {\n\nconst {query: queryOptions, request: requestOptions} = options ?? {};\n\n  const queryKey =  queryOptions?.queryKey ?? getGetProactiveOcrStatusApiProactiveOcrStatusGetQueryKey();\n\n  \n\n    const queryFn: QueryFunction<Awaited<ReturnType<typeof getProactiveOcrStatusApiProactiveOcrStatusGet>>> = ({ signal }) => getProactiveOcrStatusApiProactiveOcrStatusGet({ signal, ...requestOptions });\n\n      \n\n      \n\n   return  { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getProactiveOcrStatusApiProactiveOcrStatusGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }\n}\n\nexport type GetProactiveOcrStatusApiProactiveOcrStatusGetQueryResult = NonNullable<Awaited<ReturnType<typeof getProactiveOcrStatusApiProactiveOcrStatusGet>>>\nexport type GetProactiveOcrStatusApiProactiveOcrStatusGetQueryError = unknown\n\n\nexport function useGetProactiveOcrStatusApiProactiveOcrStatusGet<TData = Awaited<ReturnType<typeof getProactiveOcrStatusApiProactiveOcrStatusGet>>, TError = unknown>(\n  options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getProactiveOcrStatusApiProactiveOcrStatusGet>>, TError, TData>> & Pick<\n        DefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getProactiveOcrStatusApiProactiveOcrStatusGet>>,\n          TError,\n          Awaited<ReturnType<typeof getProactiveOcrStatusApiProactiveOcrStatusGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetProactiveOcrStatusApiProactiveOcrStatusGet<TData = Awaited<ReturnType<typeof getProactiveOcrStatusApiProactiveOcrStatusGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getProactiveOcrStatusApiProactiveOcrStatusGet>>, TError, TData>> & Pick<\n        UndefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getProactiveOcrStatusApiProactiveOcrStatusGet>>,\n          TError,\n          Awaited<ReturnType<typeof getProactiveOcrStatusApiProactiveOcrStatusGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetProactiveOcrStatusApiProactiveOcrStatusGet<TData = Awaited<ReturnType<typeof getProactiveOcrStatusApiProactiveOcrStatusGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getProactiveOcrStatusApiProactiveOcrStatusGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\n/**\n * @summary Get Proactive Ocr Status\n */\n\nexport function useGetProactiveOcrStatusApiProactiveOcrStatusGet<TData = Awaited<ReturnType<typeof getProactiveOcrStatusApiProactiveOcrStatusGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getProactiveOcrStatusApiProactiveOcrStatusGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient \n ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {\n\n  const queryOptions = getGetProactiveOcrStatusApiProactiveOcrStatusGetQueryOptions(options)\n\n  const query = useQuery(queryOptions, queryClient) as  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };\n\n  return { ...query, queryKey: queryOptions.queryKey };\n}\n\n\n\n\n/**\n * 健康检查\n * @summary Health Check\n */\nexport type healthCheckApiProactiveOcrHealthGetResponse200 = {\n  data: unknown\n  status: 200\n}\n    \nexport type healthCheckApiProactiveOcrHealthGetResponseSuccess = (healthCheckApiProactiveOcrHealthGetResponse200) & {\n  headers: Headers;\n};\n;\n\nexport type healthCheckApiProactiveOcrHealthGetResponse = (healthCheckApiProactiveOcrHealthGetResponseSuccess)\n\nexport const getHealthCheckApiProactiveOcrHealthGetUrl = () => {\n\n\n  \n\n  return `/api/proactive-ocr/health`\n}\n\nexport const healthCheckApiProactiveOcrHealthGet = async ( options?: RequestInit): Promise<healthCheckApiProactiveOcrHealthGetResponse> => {\n  \n  return customFetcher<healthCheckApiProactiveOcrHealthGetResponse>(getHealthCheckApiProactiveOcrHealthGetUrl(),\n  {      \n    ...options,\n    method: 'GET'\n    \n    \n  }\n);}\n\n\n\n\n\nexport const getHealthCheckApiProactiveOcrHealthGetQueryKey = () => {\n    return [\n    `/api/proactive-ocr/health`\n    ] as const;\n    }\n\n    \nexport const getHealthCheckApiProactiveOcrHealthGetQueryOptions = <TData = Awaited<ReturnType<typeof healthCheckApiProactiveOcrHealthGet>>, TError = unknown>( options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof healthCheckApiProactiveOcrHealthGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n) => {\n\nconst {query: queryOptions, request: requestOptions} = options ?? {};\n\n  const queryKey =  queryOptions?.queryKey ?? getHealthCheckApiProactiveOcrHealthGetQueryKey();\n\n  \n\n    const queryFn: QueryFunction<Awaited<ReturnType<typeof healthCheckApiProactiveOcrHealthGet>>> = ({ signal }) => healthCheckApiProactiveOcrHealthGet({ signal, ...requestOptions });\n\n      \n\n      \n\n   return  { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof healthCheckApiProactiveOcrHealthGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }\n}\n\nexport type HealthCheckApiProactiveOcrHealthGetQueryResult = NonNullable<Awaited<ReturnType<typeof healthCheckApiProactiveOcrHealthGet>>>\nexport type HealthCheckApiProactiveOcrHealthGetQueryError = unknown\n\n\nexport function useHealthCheckApiProactiveOcrHealthGet<TData = Awaited<ReturnType<typeof healthCheckApiProactiveOcrHealthGet>>, TError = unknown>(\n  options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof healthCheckApiProactiveOcrHealthGet>>, TError, TData>> & Pick<\n        DefinedInitialDataOptions<\n          Awaited<ReturnType<typeof healthCheckApiProactiveOcrHealthGet>>,\n          TError,\n          Awaited<ReturnType<typeof healthCheckApiProactiveOcrHealthGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useHealthCheckApiProactiveOcrHealthGet<TData = Awaited<ReturnType<typeof healthCheckApiProactiveOcrHealthGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof healthCheckApiProactiveOcrHealthGet>>, TError, TData>> & Pick<\n        UndefinedInitialDataOptions<\n          Awaited<ReturnType<typeof healthCheckApiProactiveOcrHealthGet>>,\n          TError,\n          Awaited<ReturnType<typeof healthCheckApiProactiveOcrHealthGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useHealthCheckApiProactiveOcrHealthGet<TData = Awaited<ReturnType<typeof healthCheckApiProactiveOcrHealthGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof healthCheckApiProactiveOcrHealthGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\n/**\n * @summary Health Check\n */\n\nexport function useHealthCheckApiProactiveOcrHealthGet<TData = Awaited<ReturnType<typeof healthCheckApiProactiveOcrHealthGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof healthCheckApiProactiveOcrHealthGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient \n ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {\n\n  const queryOptions = getHealthCheckApiProactiveOcrHealthGetQueryOptions(options)\n\n  const query = useQuery(queryOptions, queryClient) as  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };\n\n  return { ...query, queryKey: queryOptions.queryKey };\n}\n\n\n\n\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/rag/rag.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport {\n  useQuery\n} from '@tanstack/react-query';\nimport type {\n  DataTag,\n  DefinedInitialDataOptions,\n  DefinedUseQueryResult,\n  QueryClient,\n  QueryFunction,\n  QueryKey,\n  UndefinedInitialDataOptions,\n  UseQueryOptions,\n  UseQueryResult\n} from '@tanstack/react-query';\n\nimport type {\n  HTTPValidationError\n} from '.././schemas';\n\nimport { customFetcher } from '../../api/fetcher';\n\n\ntype SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];\n\n\n\n/**\n * RAG服务健康检查\n * @summary Rag Health Check\n */\nexport type ragHealthCheckApiRagHealthGetResponse200 = {\n  data: unknown\n  status: 200\n}\n    \nexport type ragHealthCheckApiRagHealthGetResponseSuccess = (ragHealthCheckApiRagHealthGetResponse200) & {\n  headers: Headers;\n};\n;\n\nexport type ragHealthCheckApiRagHealthGetResponse = (ragHealthCheckApiRagHealthGetResponseSuccess)\n\nexport const getRagHealthCheckApiRagHealthGetUrl = () => {\n\n\n  \n\n  return `/api/rag/health`\n}\n\nexport const ragHealthCheckApiRagHealthGet = async ( options?: RequestInit): Promise<ragHealthCheckApiRagHealthGetResponse> => {\n  \n  return customFetcher<ragHealthCheckApiRagHealthGetResponse>(getRagHealthCheckApiRagHealthGetUrl(),\n  {      \n    ...options,\n    method: 'GET'\n    \n    \n  }\n);}\n\n\n\n\n\nexport const getRagHealthCheckApiRagHealthGetQueryKey = () => {\n    return [\n    `/api/rag/health`\n    ] as const;\n    }\n\n    \nexport const getRagHealthCheckApiRagHealthGetQueryOptions = <TData = Awaited<ReturnType<typeof ragHealthCheckApiRagHealthGet>>, TError = unknown>( options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof ragHealthCheckApiRagHealthGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n) => {\n\nconst {query: queryOptions, request: requestOptions} = options ?? {};\n\n  const queryKey =  queryOptions?.queryKey ?? getRagHealthCheckApiRagHealthGetQueryKey();\n\n  \n\n    const queryFn: QueryFunction<Awaited<ReturnType<typeof ragHealthCheckApiRagHealthGet>>> = ({ signal }) => ragHealthCheckApiRagHealthGet({ signal, ...requestOptions });\n\n      \n\n      \n\n   return  { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof ragHealthCheckApiRagHealthGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }\n}\n\nexport type RagHealthCheckApiRagHealthGetQueryResult = NonNullable<Awaited<ReturnType<typeof ragHealthCheckApiRagHealthGet>>>\nexport type RagHealthCheckApiRagHealthGetQueryError = unknown\n\n\nexport function useRagHealthCheckApiRagHealthGet<TData = Awaited<ReturnType<typeof ragHealthCheckApiRagHealthGet>>, TError = unknown>(\n  options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof ragHealthCheckApiRagHealthGet>>, TError, TData>> & Pick<\n        DefinedInitialDataOptions<\n          Awaited<ReturnType<typeof ragHealthCheckApiRagHealthGet>>,\n          TError,\n          Awaited<ReturnType<typeof ragHealthCheckApiRagHealthGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useRagHealthCheckApiRagHealthGet<TData = Awaited<ReturnType<typeof ragHealthCheckApiRagHealthGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof ragHealthCheckApiRagHealthGet>>, TError, TData>> & Pick<\n        UndefinedInitialDataOptions<\n          Awaited<ReturnType<typeof ragHealthCheckApiRagHealthGet>>,\n          TError,\n          Awaited<ReturnType<typeof ragHealthCheckApiRagHealthGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useRagHealthCheckApiRagHealthGet<TData = Awaited<ReturnType<typeof ragHealthCheckApiRagHealthGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof ragHealthCheckApiRagHealthGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\n/**\n * @summary Rag Health Check\n */\n\nexport function useRagHealthCheckApiRagHealthGet<TData = Awaited<ReturnType<typeof ragHealthCheckApiRagHealthGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof ragHealthCheckApiRagHealthGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient \n ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {\n\n  const queryOptions = getRagHealthCheckApiRagHealthGetQueryOptions(options)\n\n  const query = useQuery(queryOptions, queryClient) as  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };\n\n  return { ...query, queryKey: queryOptions.queryKey };\n}\n\n\n\n\n/**\n * 获取应用图标\n根据映射表返回对应的图标文件\n\nArgs:\n    app_name: 应用名称\n\nReturns:\n    图标文件\n * @summary Get App Icon\n */\nexport type getAppIconApiAppIconAppNameGetResponse200 = {\n  data: unknown\n  status: 200\n}\n\nexport type getAppIconApiAppIconAppNameGetResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type getAppIconApiAppIconAppNameGetResponseSuccess = (getAppIconApiAppIconAppNameGetResponse200) & {\n  headers: Headers;\n};\nexport type getAppIconApiAppIconAppNameGetResponseError = (getAppIconApiAppIconAppNameGetResponse422) & {\n  headers: Headers;\n};\n\nexport type getAppIconApiAppIconAppNameGetResponse = (getAppIconApiAppIconAppNameGetResponseSuccess | getAppIconApiAppIconAppNameGetResponseError)\n\nexport const getGetAppIconApiAppIconAppNameGetUrl = (appName: string,) => {\n\n\n  \n\n  return `/api/app-icon/${appName}`\n}\n\nexport const getAppIconApiAppIconAppNameGet = async (appName: string, options?: RequestInit): Promise<getAppIconApiAppIconAppNameGetResponse> => {\n  \n  return customFetcher<getAppIconApiAppIconAppNameGetResponse>(getGetAppIconApiAppIconAppNameGetUrl(appName),\n  {      \n    ...options,\n    method: 'GET'\n    \n    \n  }\n);}\n\n\n\n\n\nexport const getGetAppIconApiAppIconAppNameGetQueryKey = (appName: string,) => {\n    return [\n    `/api/app-icon/${appName}`\n    ] as const;\n    }\n\n    \nexport const getGetAppIconApiAppIconAppNameGetQueryOptions = <TData = Awaited<ReturnType<typeof getAppIconApiAppIconAppNameGet>>, TError = HTTPValidationError>(appName: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getAppIconApiAppIconAppNameGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n) => {\n\nconst {query: queryOptions, request: requestOptions} = options ?? {};\n\n  const queryKey =  queryOptions?.queryKey ?? getGetAppIconApiAppIconAppNameGetQueryKey(appName);\n\n  \n\n    const queryFn: QueryFunction<Awaited<ReturnType<typeof getAppIconApiAppIconAppNameGet>>> = ({ signal }) => getAppIconApiAppIconAppNameGet(appName, { signal, ...requestOptions });\n\n      \n\n      \n\n   return  { queryKey, queryFn, enabled: !!(appName), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getAppIconApiAppIconAppNameGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }\n}\n\nexport type GetAppIconApiAppIconAppNameGetQueryResult = NonNullable<Awaited<ReturnType<typeof getAppIconApiAppIconAppNameGet>>>\nexport type GetAppIconApiAppIconAppNameGetQueryError = HTTPValidationError\n\n\nexport function useGetAppIconApiAppIconAppNameGet<TData = Awaited<ReturnType<typeof getAppIconApiAppIconAppNameGet>>, TError = HTTPValidationError>(\n appName: string, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getAppIconApiAppIconAppNameGet>>, TError, TData>> & Pick<\n        DefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getAppIconApiAppIconAppNameGet>>,\n          TError,\n          Awaited<ReturnType<typeof getAppIconApiAppIconAppNameGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetAppIconApiAppIconAppNameGet<TData = Awaited<ReturnType<typeof getAppIconApiAppIconAppNameGet>>, TError = HTTPValidationError>(\n appName: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getAppIconApiAppIconAppNameGet>>, TError, TData>> & Pick<\n        UndefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getAppIconApiAppIconAppNameGet>>,\n          TError,\n          Awaited<ReturnType<typeof getAppIconApiAppIconAppNameGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetAppIconApiAppIconAppNameGet<TData = Awaited<ReturnType<typeof getAppIconApiAppIconAppNameGet>>, TError = HTTPValidationError>(\n appName: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getAppIconApiAppIconAppNameGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\n/**\n * @summary Get App Icon\n */\n\nexport function useGetAppIconApiAppIconAppNameGet<TData = Awaited<ReturnType<typeof getAppIconApiAppIconAppNameGet>>, TError = HTTPValidationError>(\n appName: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getAppIconApiAppIconAppNameGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient \n ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {\n\n  const queryOptions = getGetAppIconApiAppIconAppNameGetQueryOptions(appName,options)\n\n  const query = useQuery(queryOptions, queryClient) as  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };\n\n  return { ...query, queryKey: queryOptions.queryKey };\n}\n\n\n\n\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/scheduler/scheduler.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport {\n  useMutation,\n  useQuery\n} from '@tanstack/react-query';\nimport type {\n  DataTag,\n  DefinedInitialDataOptions,\n  DefinedUseQueryResult,\n  MutationFunction,\n  QueryClient,\n  QueryFunction,\n  QueryKey,\n  UndefinedInitialDataOptions,\n  UseMutationOptions,\n  UseMutationResult,\n  UseQueryOptions,\n  UseQueryResult\n} from '@tanstack/react-query';\n\nimport type {\n  HTTPValidationError,\n  JobInfo,\n  JobIntervalUpdateRequest,\n  JobListResponse,\n  JobOperationResponse\n} from '.././schemas';\n\nimport { customFetcher } from '../../api/fetcher';\n\n\ntype SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];\n\n\n\n/**\n * 获取所有定时任务\n * @summary Get All Jobs\n */\nexport type getAllJobsApiSchedulerJobsGetResponse200 = {\n  data: JobListResponse\n  status: 200\n}\n    \nexport type getAllJobsApiSchedulerJobsGetResponseSuccess = (getAllJobsApiSchedulerJobsGetResponse200) & {\n  headers: Headers;\n};\n;\n\nexport type getAllJobsApiSchedulerJobsGetResponse = (getAllJobsApiSchedulerJobsGetResponseSuccess)\n\nexport const getGetAllJobsApiSchedulerJobsGetUrl = () => {\n\n\n  \n\n  return `/api/scheduler/jobs`\n}\n\nexport const getAllJobsApiSchedulerJobsGet = async ( options?: RequestInit): Promise<getAllJobsApiSchedulerJobsGetResponse> => {\n  \n  return customFetcher<getAllJobsApiSchedulerJobsGetResponse>(getGetAllJobsApiSchedulerJobsGetUrl(),\n  {      \n    ...options,\n    method: 'GET'\n    \n    \n  }\n);}\n\n\n\n\n\nexport const getGetAllJobsApiSchedulerJobsGetQueryKey = () => {\n    return [\n    `/api/scheduler/jobs`\n    ] as const;\n    }\n\n    \nexport const getGetAllJobsApiSchedulerJobsGetQueryOptions = <TData = Awaited<ReturnType<typeof getAllJobsApiSchedulerJobsGet>>, TError = unknown>( options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getAllJobsApiSchedulerJobsGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n) => {\n\nconst {query: queryOptions, request: requestOptions} = options ?? {};\n\n  const queryKey =  queryOptions?.queryKey ?? getGetAllJobsApiSchedulerJobsGetQueryKey();\n\n  \n\n    const queryFn: QueryFunction<Awaited<ReturnType<typeof getAllJobsApiSchedulerJobsGet>>> = ({ signal }) => getAllJobsApiSchedulerJobsGet({ signal, ...requestOptions });\n\n      \n\n      \n\n   return  { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getAllJobsApiSchedulerJobsGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }\n}\n\nexport type GetAllJobsApiSchedulerJobsGetQueryResult = NonNullable<Awaited<ReturnType<typeof getAllJobsApiSchedulerJobsGet>>>\nexport type GetAllJobsApiSchedulerJobsGetQueryError = unknown\n\n\nexport function useGetAllJobsApiSchedulerJobsGet<TData = Awaited<ReturnType<typeof getAllJobsApiSchedulerJobsGet>>, TError = unknown>(\n  options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getAllJobsApiSchedulerJobsGet>>, TError, TData>> & Pick<\n        DefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getAllJobsApiSchedulerJobsGet>>,\n          TError,\n          Awaited<ReturnType<typeof getAllJobsApiSchedulerJobsGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetAllJobsApiSchedulerJobsGet<TData = Awaited<ReturnType<typeof getAllJobsApiSchedulerJobsGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getAllJobsApiSchedulerJobsGet>>, TError, TData>> & Pick<\n        UndefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getAllJobsApiSchedulerJobsGet>>,\n          TError,\n          Awaited<ReturnType<typeof getAllJobsApiSchedulerJobsGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetAllJobsApiSchedulerJobsGet<TData = Awaited<ReturnType<typeof getAllJobsApiSchedulerJobsGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getAllJobsApiSchedulerJobsGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\n/**\n * @summary Get All Jobs\n */\n\nexport function useGetAllJobsApiSchedulerJobsGet<TData = Awaited<ReturnType<typeof getAllJobsApiSchedulerJobsGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getAllJobsApiSchedulerJobsGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient \n ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {\n\n  const queryOptions = getGetAllJobsApiSchedulerJobsGetQueryOptions(options)\n\n  const query = useQuery(queryOptions, queryClient) as  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };\n\n  return { ...query, queryKey: queryOptions.queryKey };\n}\n\n\n\n\n/**\n * 获取指定任务的详细信息\n * @summary Get Job Detail\n */\nexport type getJobDetailApiSchedulerJobsJobIdGetResponse200 = {\n  data: JobInfo\n  status: 200\n}\n\nexport type getJobDetailApiSchedulerJobsJobIdGetResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type getJobDetailApiSchedulerJobsJobIdGetResponseSuccess = (getJobDetailApiSchedulerJobsJobIdGetResponse200) & {\n  headers: Headers;\n};\nexport type getJobDetailApiSchedulerJobsJobIdGetResponseError = (getJobDetailApiSchedulerJobsJobIdGetResponse422) & {\n  headers: Headers;\n};\n\nexport type getJobDetailApiSchedulerJobsJobIdGetResponse = (getJobDetailApiSchedulerJobsJobIdGetResponseSuccess | getJobDetailApiSchedulerJobsJobIdGetResponseError)\n\nexport const getGetJobDetailApiSchedulerJobsJobIdGetUrl = (jobId: string,) => {\n\n\n  \n\n  return `/api/scheduler/jobs/${jobId}`\n}\n\nexport const getJobDetailApiSchedulerJobsJobIdGet = async (jobId: string, options?: RequestInit): Promise<getJobDetailApiSchedulerJobsJobIdGetResponse> => {\n  \n  return customFetcher<getJobDetailApiSchedulerJobsJobIdGetResponse>(getGetJobDetailApiSchedulerJobsJobIdGetUrl(jobId),\n  {      \n    ...options,\n    method: 'GET'\n    \n    \n  }\n);}\n\n\n\n\n\nexport const getGetJobDetailApiSchedulerJobsJobIdGetQueryKey = (jobId: string,) => {\n    return [\n    `/api/scheduler/jobs/${jobId}`\n    ] as const;\n    }\n\n    \nexport const getGetJobDetailApiSchedulerJobsJobIdGetQueryOptions = <TData = Awaited<ReturnType<typeof getJobDetailApiSchedulerJobsJobIdGet>>, TError = HTTPValidationError>(jobId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getJobDetailApiSchedulerJobsJobIdGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n) => {\n\nconst {query: queryOptions, request: requestOptions} = options ?? {};\n\n  const queryKey =  queryOptions?.queryKey ?? getGetJobDetailApiSchedulerJobsJobIdGetQueryKey(jobId);\n\n  \n\n    const queryFn: QueryFunction<Awaited<ReturnType<typeof getJobDetailApiSchedulerJobsJobIdGet>>> = ({ signal }) => getJobDetailApiSchedulerJobsJobIdGet(jobId, { signal, ...requestOptions });\n\n      \n\n      \n\n   return  { queryKey, queryFn, enabled: !!(jobId), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getJobDetailApiSchedulerJobsJobIdGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }\n}\n\nexport type GetJobDetailApiSchedulerJobsJobIdGetQueryResult = NonNullable<Awaited<ReturnType<typeof getJobDetailApiSchedulerJobsJobIdGet>>>\nexport type GetJobDetailApiSchedulerJobsJobIdGetQueryError = HTTPValidationError\n\n\nexport function useGetJobDetailApiSchedulerJobsJobIdGet<TData = Awaited<ReturnType<typeof getJobDetailApiSchedulerJobsJobIdGet>>, TError = HTTPValidationError>(\n jobId: string, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getJobDetailApiSchedulerJobsJobIdGet>>, TError, TData>> & Pick<\n        DefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getJobDetailApiSchedulerJobsJobIdGet>>,\n          TError,\n          Awaited<ReturnType<typeof getJobDetailApiSchedulerJobsJobIdGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetJobDetailApiSchedulerJobsJobIdGet<TData = Awaited<ReturnType<typeof getJobDetailApiSchedulerJobsJobIdGet>>, TError = HTTPValidationError>(\n jobId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getJobDetailApiSchedulerJobsJobIdGet>>, TError, TData>> & Pick<\n        UndefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getJobDetailApiSchedulerJobsJobIdGet>>,\n          TError,\n          Awaited<ReturnType<typeof getJobDetailApiSchedulerJobsJobIdGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetJobDetailApiSchedulerJobsJobIdGet<TData = Awaited<ReturnType<typeof getJobDetailApiSchedulerJobsJobIdGet>>, TError = HTTPValidationError>(\n jobId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getJobDetailApiSchedulerJobsJobIdGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\n/**\n * @summary Get Job Detail\n */\n\nexport function useGetJobDetailApiSchedulerJobsJobIdGet<TData = Awaited<ReturnType<typeof getJobDetailApiSchedulerJobsJobIdGet>>, TError = HTTPValidationError>(\n jobId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getJobDetailApiSchedulerJobsJobIdGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient \n ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {\n\n  const queryOptions = getGetJobDetailApiSchedulerJobsJobIdGetQueryOptions(jobId,options)\n\n  const query = useQuery(queryOptions, queryClient) as  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };\n\n  return { ...query, queryKey: queryOptions.queryKey };\n}\n\n\n\n\n/**\n * 删除指定任务\n * @summary Remove Job\n */\nexport type removeJobApiSchedulerJobsJobIdDeleteResponse200 = {\n  data: JobOperationResponse\n  status: 200\n}\n\nexport type removeJobApiSchedulerJobsJobIdDeleteResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type removeJobApiSchedulerJobsJobIdDeleteResponseSuccess = (removeJobApiSchedulerJobsJobIdDeleteResponse200) & {\n  headers: Headers;\n};\nexport type removeJobApiSchedulerJobsJobIdDeleteResponseError = (removeJobApiSchedulerJobsJobIdDeleteResponse422) & {\n  headers: Headers;\n};\n\nexport type removeJobApiSchedulerJobsJobIdDeleteResponse = (removeJobApiSchedulerJobsJobIdDeleteResponseSuccess | removeJobApiSchedulerJobsJobIdDeleteResponseError)\n\nexport const getRemoveJobApiSchedulerJobsJobIdDeleteUrl = (jobId: string,) => {\n\n\n  \n\n  return `/api/scheduler/jobs/${jobId}`\n}\n\nexport const removeJobApiSchedulerJobsJobIdDelete = async (jobId: string, options?: RequestInit): Promise<removeJobApiSchedulerJobsJobIdDeleteResponse> => {\n  \n  return customFetcher<removeJobApiSchedulerJobsJobIdDeleteResponse>(getRemoveJobApiSchedulerJobsJobIdDeleteUrl(jobId),\n  {      \n    ...options,\n    method: 'DELETE'\n    \n    \n  }\n);}\n\n\n\n\nexport const getRemoveJobApiSchedulerJobsJobIdDeleteMutationOptions = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof removeJobApiSchedulerJobsJobIdDelete>>, TError,{jobId: string}, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof removeJobApiSchedulerJobsJobIdDelete>>, TError,{jobId: string}, TContext> => {\n\nconst mutationKey = ['removeJobApiSchedulerJobsJobIdDelete'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof removeJobApiSchedulerJobsJobIdDelete>>, {jobId: string}> = (props) => {\n          const {jobId} = props ?? {};\n\n          return  removeJobApiSchedulerJobsJobIdDelete(jobId,requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type RemoveJobApiSchedulerJobsJobIdDeleteMutationResult = NonNullable<Awaited<ReturnType<typeof removeJobApiSchedulerJobsJobIdDelete>>>\n    \n    export type RemoveJobApiSchedulerJobsJobIdDeleteMutationError = HTTPValidationError\n\n    /**\n * @summary Remove Job\n */\nexport const useRemoveJobApiSchedulerJobsJobIdDelete = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof removeJobApiSchedulerJobsJobIdDelete>>, TError,{jobId: string}, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof removeJobApiSchedulerJobsJobIdDelete>>,\n        TError,\n        {jobId: string},\n        TContext\n      > => {\n      return useMutation(getRemoveJobApiSchedulerJobsJobIdDeleteMutationOptions(options), queryClient);\n    }\n    /**\n * 暂停指定任务\n * @summary Pause Job\n */\nexport type pauseJobApiSchedulerJobsJobIdPausePostResponse200 = {\n  data: JobOperationResponse\n  status: 200\n}\n\nexport type pauseJobApiSchedulerJobsJobIdPausePostResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type pauseJobApiSchedulerJobsJobIdPausePostResponseSuccess = (pauseJobApiSchedulerJobsJobIdPausePostResponse200) & {\n  headers: Headers;\n};\nexport type pauseJobApiSchedulerJobsJobIdPausePostResponseError = (pauseJobApiSchedulerJobsJobIdPausePostResponse422) & {\n  headers: Headers;\n};\n\nexport type pauseJobApiSchedulerJobsJobIdPausePostResponse = (pauseJobApiSchedulerJobsJobIdPausePostResponseSuccess | pauseJobApiSchedulerJobsJobIdPausePostResponseError)\n\nexport const getPauseJobApiSchedulerJobsJobIdPausePostUrl = (jobId: string,) => {\n\n\n  \n\n  return `/api/scheduler/jobs/${jobId}/pause`\n}\n\nexport const pauseJobApiSchedulerJobsJobIdPausePost = async (jobId: string, options?: RequestInit): Promise<pauseJobApiSchedulerJobsJobIdPausePostResponse> => {\n  \n  return customFetcher<pauseJobApiSchedulerJobsJobIdPausePostResponse>(getPauseJobApiSchedulerJobsJobIdPausePostUrl(jobId),\n  {      \n    ...options,\n    method: 'POST'\n    \n    \n  }\n);}\n\n\n\n\nexport const getPauseJobApiSchedulerJobsJobIdPausePostMutationOptions = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof pauseJobApiSchedulerJobsJobIdPausePost>>, TError,{jobId: string}, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof pauseJobApiSchedulerJobsJobIdPausePost>>, TError,{jobId: string}, TContext> => {\n\nconst mutationKey = ['pauseJobApiSchedulerJobsJobIdPausePost'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof pauseJobApiSchedulerJobsJobIdPausePost>>, {jobId: string}> = (props) => {\n          const {jobId} = props ?? {};\n\n          return  pauseJobApiSchedulerJobsJobIdPausePost(jobId,requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type PauseJobApiSchedulerJobsJobIdPausePostMutationResult = NonNullable<Awaited<ReturnType<typeof pauseJobApiSchedulerJobsJobIdPausePost>>>\n    \n    export type PauseJobApiSchedulerJobsJobIdPausePostMutationError = HTTPValidationError\n\n    /**\n * @summary Pause Job\n */\nexport const usePauseJobApiSchedulerJobsJobIdPausePost = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof pauseJobApiSchedulerJobsJobIdPausePost>>, TError,{jobId: string}, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof pauseJobApiSchedulerJobsJobIdPausePost>>,\n        TError,\n        {jobId: string},\n        TContext\n      > => {\n      return useMutation(getPauseJobApiSchedulerJobsJobIdPausePostMutationOptions(options), queryClient);\n    }\n    /**\n * 恢复指定任务\n * @summary Resume Job\n */\nexport type resumeJobApiSchedulerJobsJobIdResumePostResponse200 = {\n  data: JobOperationResponse\n  status: 200\n}\n\nexport type resumeJobApiSchedulerJobsJobIdResumePostResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type resumeJobApiSchedulerJobsJobIdResumePostResponseSuccess = (resumeJobApiSchedulerJobsJobIdResumePostResponse200) & {\n  headers: Headers;\n};\nexport type resumeJobApiSchedulerJobsJobIdResumePostResponseError = (resumeJobApiSchedulerJobsJobIdResumePostResponse422) & {\n  headers: Headers;\n};\n\nexport type resumeJobApiSchedulerJobsJobIdResumePostResponse = (resumeJobApiSchedulerJobsJobIdResumePostResponseSuccess | resumeJobApiSchedulerJobsJobIdResumePostResponseError)\n\nexport const getResumeJobApiSchedulerJobsJobIdResumePostUrl = (jobId: string,) => {\n\n\n  \n\n  return `/api/scheduler/jobs/${jobId}/resume`\n}\n\nexport const resumeJobApiSchedulerJobsJobIdResumePost = async (jobId: string, options?: RequestInit): Promise<resumeJobApiSchedulerJobsJobIdResumePostResponse> => {\n  \n  return customFetcher<resumeJobApiSchedulerJobsJobIdResumePostResponse>(getResumeJobApiSchedulerJobsJobIdResumePostUrl(jobId),\n  {      \n    ...options,\n    method: 'POST'\n    \n    \n  }\n);}\n\n\n\n\nexport const getResumeJobApiSchedulerJobsJobIdResumePostMutationOptions = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof resumeJobApiSchedulerJobsJobIdResumePost>>, TError,{jobId: string}, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof resumeJobApiSchedulerJobsJobIdResumePost>>, TError,{jobId: string}, TContext> => {\n\nconst mutationKey = ['resumeJobApiSchedulerJobsJobIdResumePost'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof resumeJobApiSchedulerJobsJobIdResumePost>>, {jobId: string}> = (props) => {\n          const {jobId} = props ?? {};\n\n          return  resumeJobApiSchedulerJobsJobIdResumePost(jobId,requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type ResumeJobApiSchedulerJobsJobIdResumePostMutationResult = NonNullable<Awaited<ReturnType<typeof resumeJobApiSchedulerJobsJobIdResumePost>>>\n    \n    export type ResumeJobApiSchedulerJobsJobIdResumePostMutationError = HTTPValidationError\n\n    /**\n * @summary Resume Job\n */\nexport const useResumeJobApiSchedulerJobsJobIdResumePost = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof resumeJobApiSchedulerJobsJobIdResumePost>>, TError,{jobId: string}, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof resumeJobApiSchedulerJobsJobIdResumePost>>,\n        TError,\n        {jobId: string},\n        TContext\n      > => {\n      return useMutation(getResumeJobApiSchedulerJobsJobIdResumePostMutationOptions(options), queryClient);\n    }\n    /**\n * 更新任务执行间隔\n * @summary Update Job Interval\n */\nexport type updateJobIntervalApiSchedulerJobsJobIdIntervalPutResponse200 = {\n  data: JobOperationResponse\n  status: 200\n}\n\nexport type updateJobIntervalApiSchedulerJobsJobIdIntervalPutResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type updateJobIntervalApiSchedulerJobsJobIdIntervalPutResponseSuccess = (updateJobIntervalApiSchedulerJobsJobIdIntervalPutResponse200) & {\n  headers: Headers;\n};\nexport type updateJobIntervalApiSchedulerJobsJobIdIntervalPutResponseError = (updateJobIntervalApiSchedulerJobsJobIdIntervalPutResponse422) & {\n  headers: Headers;\n};\n\nexport type updateJobIntervalApiSchedulerJobsJobIdIntervalPutResponse = (updateJobIntervalApiSchedulerJobsJobIdIntervalPutResponseSuccess | updateJobIntervalApiSchedulerJobsJobIdIntervalPutResponseError)\n\nexport const getUpdateJobIntervalApiSchedulerJobsJobIdIntervalPutUrl = (jobId: string,) => {\n\n\n  \n\n  return `/api/scheduler/jobs/${jobId}/interval`\n}\n\nexport const updateJobIntervalApiSchedulerJobsJobIdIntervalPut = async (jobId: string,\n    jobIntervalUpdateRequest: JobIntervalUpdateRequest, options?: RequestInit): Promise<updateJobIntervalApiSchedulerJobsJobIdIntervalPutResponse> => {\n  \n  return customFetcher<updateJobIntervalApiSchedulerJobsJobIdIntervalPutResponse>(getUpdateJobIntervalApiSchedulerJobsJobIdIntervalPutUrl(jobId),\n  {      \n    ...options,\n    method: 'PUT',\n    headers: { 'Content-Type': 'application/json', ...options?.headers },\n    body: JSON.stringify(\n      jobIntervalUpdateRequest,)\n  }\n);}\n\n\n\n\nexport const getUpdateJobIntervalApiSchedulerJobsJobIdIntervalPutMutationOptions = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateJobIntervalApiSchedulerJobsJobIdIntervalPut>>, TError,{jobId: string;data: JobIntervalUpdateRequest}, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof updateJobIntervalApiSchedulerJobsJobIdIntervalPut>>, TError,{jobId: string;data: JobIntervalUpdateRequest}, TContext> => {\n\nconst mutationKey = ['updateJobIntervalApiSchedulerJobsJobIdIntervalPut'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof updateJobIntervalApiSchedulerJobsJobIdIntervalPut>>, {jobId: string;data: JobIntervalUpdateRequest}> = (props) => {\n          const {jobId,data} = props ?? {};\n\n          return  updateJobIntervalApiSchedulerJobsJobIdIntervalPut(jobId,data,requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type UpdateJobIntervalApiSchedulerJobsJobIdIntervalPutMutationResult = NonNullable<Awaited<ReturnType<typeof updateJobIntervalApiSchedulerJobsJobIdIntervalPut>>>\n    export type UpdateJobIntervalApiSchedulerJobsJobIdIntervalPutMutationBody = JobIntervalUpdateRequest\n    export type UpdateJobIntervalApiSchedulerJobsJobIdIntervalPutMutationError = HTTPValidationError\n\n    /**\n * @summary Update Job Interval\n */\nexport const useUpdateJobIntervalApiSchedulerJobsJobIdIntervalPut = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateJobIntervalApiSchedulerJobsJobIdIntervalPut>>, TError,{jobId: string;data: JobIntervalUpdateRequest}, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof updateJobIntervalApiSchedulerJobsJobIdIntervalPut>>,\n        TError,\n        {jobId: string;data: JobIntervalUpdateRequest},\n        TContext\n      > => {\n      return useMutation(getUpdateJobIntervalApiSchedulerJobsJobIdIntervalPutMutationOptions(options), queryClient);\n    }\n    /**\n * 获取调度器状态\n * @summary Get Scheduler Status\n */\nexport type getSchedulerStatusApiSchedulerStatusGetResponse200 = {\n  data: unknown\n  status: 200\n}\n    \nexport type getSchedulerStatusApiSchedulerStatusGetResponseSuccess = (getSchedulerStatusApiSchedulerStatusGetResponse200) & {\n  headers: Headers;\n};\n;\n\nexport type getSchedulerStatusApiSchedulerStatusGetResponse = (getSchedulerStatusApiSchedulerStatusGetResponseSuccess)\n\nexport const getGetSchedulerStatusApiSchedulerStatusGetUrl = () => {\n\n\n  \n\n  return `/api/scheduler/status`\n}\n\nexport const getSchedulerStatusApiSchedulerStatusGet = async ( options?: RequestInit): Promise<getSchedulerStatusApiSchedulerStatusGetResponse> => {\n  \n  return customFetcher<getSchedulerStatusApiSchedulerStatusGetResponse>(getGetSchedulerStatusApiSchedulerStatusGetUrl(),\n  {      \n    ...options,\n    method: 'GET'\n    \n    \n  }\n);}\n\n\n\n\n\nexport const getGetSchedulerStatusApiSchedulerStatusGetQueryKey = () => {\n    return [\n    `/api/scheduler/status`\n    ] as const;\n    }\n\n    \nexport const getGetSchedulerStatusApiSchedulerStatusGetQueryOptions = <TData = Awaited<ReturnType<typeof getSchedulerStatusApiSchedulerStatusGet>>, TError = unknown>( options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSchedulerStatusApiSchedulerStatusGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n) => {\n\nconst {query: queryOptions, request: requestOptions} = options ?? {};\n\n  const queryKey =  queryOptions?.queryKey ?? getGetSchedulerStatusApiSchedulerStatusGetQueryKey();\n\n  \n\n    const queryFn: QueryFunction<Awaited<ReturnType<typeof getSchedulerStatusApiSchedulerStatusGet>>> = ({ signal }) => getSchedulerStatusApiSchedulerStatusGet({ signal, ...requestOptions });\n\n      \n\n      \n\n   return  { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getSchedulerStatusApiSchedulerStatusGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }\n}\n\nexport type GetSchedulerStatusApiSchedulerStatusGetQueryResult = NonNullable<Awaited<ReturnType<typeof getSchedulerStatusApiSchedulerStatusGet>>>\nexport type GetSchedulerStatusApiSchedulerStatusGetQueryError = unknown\n\n\nexport function useGetSchedulerStatusApiSchedulerStatusGet<TData = Awaited<ReturnType<typeof getSchedulerStatusApiSchedulerStatusGet>>, TError = unknown>(\n  options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSchedulerStatusApiSchedulerStatusGet>>, TError, TData>> & Pick<\n        DefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getSchedulerStatusApiSchedulerStatusGet>>,\n          TError,\n          Awaited<ReturnType<typeof getSchedulerStatusApiSchedulerStatusGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetSchedulerStatusApiSchedulerStatusGet<TData = Awaited<ReturnType<typeof getSchedulerStatusApiSchedulerStatusGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSchedulerStatusApiSchedulerStatusGet>>, TError, TData>> & Pick<\n        UndefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getSchedulerStatusApiSchedulerStatusGet>>,\n          TError,\n          Awaited<ReturnType<typeof getSchedulerStatusApiSchedulerStatusGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetSchedulerStatusApiSchedulerStatusGet<TData = Awaited<ReturnType<typeof getSchedulerStatusApiSchedulerStatusGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSchedulerStatusApiSchedulerStatusGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\n/**\n * @summary Get Scheduler Status\n */\n\nexport function useGetSchedulerStatusApiSchedulerStatusGet<TData = Awaited<ReturnType<typeof getSchedulerStatusApiSchedulerStatusGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSchedulerStatusApiSchedulerStatusGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient \n ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {\n\n  const queryOptions = getGetSchedulerStatusApiSchedulerStatusGetQueryOptions(options)\n\n  const query = useQuery(queryOptions, queryClient) as  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };\n\n  return { ...query, queryKey: queryOptions.queryKey };\n}\n\n\n\n\n/**\n * 暂停所有任务\n * @summary Pause All Jobs\n */\nexport type pauseAllJobsApiSchedulerJobsPauseAllPostResponse200 = {\n  data: JobOperationResponse\n  status: 200\n}\n    \nexport type pauseAllJobsApiSchedulerJobsPauseAllPostResponseSuccess = (pauseAllJobsApiSchedulerJobsPauseAllPostResponse200) & {\n  headers: Headers;\n};\n;\n\nexport type pauseAllJobsApiSchedulerJobsPauseAllPostResponse = (pauseAllJobsApiSchedulerJobsPauseAllPostResponseSuccess)\n\nexport const getPauseAllJobsApiSchedulerJobsPauseAllPostUrl = () => {\n\n\n  \n\n  return `/api/scheduler/jobs/pause-all`\n}\n\nexport const pauseAllJobsApiSchedulerJobsPauseAllPost = async ( options?: RequestInit): Promise<pauseAllJobsApiSchedulerJobsPauseAllPostResponse> => {\n  \n  return customFetcher<pauseAllJobsApiSchedulerJobsPauseAllPostResponse>(getPauseAllJobsApiSchedulerJobsPauseAllPostUrl(),\n  {      \n    ...options,\n    method: 'POST'\n    \n    \n  }\n);}\n\n\n\n\nexport const getPauseAllJobsApiSchedulerJobsPauseAllPostMutationOptions = <TError = unknown,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof pauseAllJobsApiSchedulerJobsPauseAllPost>>, TError,void, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof pauseAllJobsApiSchedulerJobsPauseAllPost>>, TError,void, TContext> => {\n\nconst mutationKey = ['pauseAllJobsApiSchedulerJobsPauseAllPost'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof pauseAllJobsApiSchedulerJobsPauseAllPost>>, void> = () => {\n          \n\n          return  pauseAllJobsApiSchedulerJobsPauseAllPost(requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type PauseAllJobsApiSchedulerJobsPauseAllPostMutationResult = NonNullable<Awaited<ReturnType<typeof pauseAllJobsApiSchedulerJobsPauseAllPost>>>\n    \n    export type PauseAllJobsApiSchedulerJobsPauseAllPostMutationError = unknown\n\n    /**\n * @summary Pause All Jobs\n */\nexport const usePauseAllJobsApiSchedulerJobsPauseAllPost = <TError = unknown,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof pauseAllJobsApiSchedulerJobsPauseAllPost>>, TError,void, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof pauseAllJobsApiSchedulerJobsPauseAllPost>>,\n        TError,\n        void,\n        TContext\n      > => {\n      return useMutation(getPauseAllJobsApiSchedulerJobsPauseAllPostMutationOptions(options), queryClient);\n    }\n    /**\n * 恢复所有任务\n * @summary Resume All Jobs\n */\nexport type resumeAllJobsApiSchedulerJobsResumeAllPostResponse200 = {\n  data: JobOperationResponse\n  status: 200\n}\n    \nexport type resumeAllJobsApiSchedulerJobsResumeAllPostResponseSuccess = (resumeAllJobsApiSchedulerJobsResumeAllPostResponse200) & {\n  headers: Headers;\n};\n;\n\nexport type resumeAllJobsApiSchedulerJobsResumeAllPostResponse = (resumeAllJobsApiSchedulerJobsResumeAllPostResponseSuccess)\n\nexport const getResumeAllJobsApiSchedulerJobsResumeAllPostUrl = () => {\n\n\n  \n\n  return `/api/scheduler/jobs/resume-all`\n}\n\nexport const resumeAllJobsApiSchedulerJobsResumeAllPost = async ( options?: RequestInit): Promise<resumeAllJobsApiSchedulerJobsResumeAllPostResponse> => {\n  \n  return customFetcher<resumeAllJobsApiSchedulerJobsResumeAllPostResponse>(getResumeAllJobsApiSchedulerJobsResumeAllPostUrl(),\n  {      \n    ...options,\n    method: 'POST'\n    \n    \n  }\n);}\n\n\n\n\nexport const getResumeAllJobsApiSchedulerJobsResumeAllPostMutationOptions = <TError = unknown,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof resumeAllJobsApiSchedulerJobsResumeAllPost>>, TError,void, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof resumeAllJobsApiSchedulerJobsResumeAllPost>>, TError,void, TContext> => {\n\nconst mutationKey = ['resumeAllJobsApiSchedulerJobsResumeAllPost'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof resumeAllJobsApiSchedulerJobsResumeAllPost>>, void> = () => {\n          \n\n          return  resumeAllJobsApiSchedulerJobsResumeAllPost(requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type ResumeAllJobsApiSchedulerJobsResumeAllPostMutationResult = NonNullable<Awaited<ReturnType<typeof resumeAllJobsApiSchedulerJobsResumeAllPost>>>\n    \n    export type ResumeAllJobsApiSchedulerJobsResumeAllPostMutationError = unknown\n\n    /**\n * @summary Resume All Jobs\n */\nexport const useResumeAllJobsApiSchedulerJobsResumeAllPost = <TError = unknown,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof resumeAllJobsApiSchedulerJobsResumeAllPost>>, TError,void, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof resumeAllJobsApiSchedulerJobsResumeAllPost>>,\n        TError,\n        void,\n        TContext\n      > => {\n      return useMutation(getResumeAllJobsApiSchedulerJobsResumeAllPostMutationOptions(options), queryClient);\n    }\n    "
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/activityEventsResponse.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport interface ActivityEventsResponse {\n  event_ids: number[];\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/activityListResponse.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport type { ActivityResponse } from './activityResponse';\n\nexport interface ActivityListResponse {\n  activities: ActivityResponse[];\n  total_count: number;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/activityResponse.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport interface ActivityResponse {\n  id: number;\n  start_time: string;\n  end_time: string;\n  ai_title?: string | null;\n  ai_summary?: string | null;\n  event_count: number;\n  created_at?: string | null;\n  updated_at?: string | null;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/activityResponseAiSummary.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type ActivityResponseAiSummary = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/activityResponseAiTitle.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type ActivityResponseAiTitle = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/activityResponseCreatedAt.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type ActivityResponseCreatedAt = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/activityResponseUpdatedAt.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type ActivityResponseUpdatedAt = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/addMessageRequest.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport interface AddMessageRequest {\n  role: string;\n  content: string;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/audioLinkItem.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport interface AudioLinkItem {\n  /** todo|schedule */\n  kind: string;\n  /** extracted item id */\n  item_id: string;\n  /** linked todo id */\n  todo_id: number;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/audioLinkRequest.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport type { AudioLinkItem } from './audioLinkItem';\n\nexport interface AudioLinkRequest {\n  links: AudioLinkItem[];\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/bodyImportIcsApiTodosImportIcsPost.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport interface BodyImportIcsApiTodosImportIcsPost {\n  file: Blob;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/bodyUploadAttachmentsApiTodosTodoIdAttachmentsPost.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport interface BodyUploadAttachmentsApiTodosTodoIdAttachmentsPost {\n  /** 附件列表 */\n  files: Blob[];\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/capabilitiesResponse.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport type { CapabilitiesResponseMissingDeps } from './capabilitiesResponseMissingDeps';\n\nexport interface CapabilitiesResponse {\n  enabled_modules: string[];\n  available_modules: string[];\n  disabled_modules: string[];\n  missing_deps: CapabilitiesResponseMissingDeps;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/capabilitiesResponseMissingDeps.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport type CapabilitiesResponseMissingDeps = {[key: string]: string[]};\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/chatMessage.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport interface ChatMessage {\n  message: string;\n  user_input?: string | null;\n  context?: string | null;\n  system_prompt?: string | null;\n  conversation_id?: string | null;\n  use_rag?: boolean;\n  mode?: string | null;\n  selected_tools?: string[] | null;\n  external_tools?: string[] | null;\n  workspace_path?: string | null;\n  enable_file_delete?: boolean;\n  [key: string]: unknown;\n }\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/chatMessageContext.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type ChatMessageContext = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/chatMessageConversationId.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type ChatMessageConversationId = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/chatMessageExternalTools.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type ChatMessageExternalTools = string[] | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/chatMessageMode.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type ChatMessageMode = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/chatMessageProjectId.ts",
    "content": "/**\n * Generated by orval v7.17.0 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type ChatMessageProjectId = number | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/chatMessageSelectedTools.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type ChatMessageSelectedTools = string[] | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/chatMessageSystemPrompt.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type ChatMessageSystemPrompt = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/chatMessageTaskIds.ts",
    "content": "/**\n * Generated by orval v7.17.0 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type ChatMessageTaskIds = number[] | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/chatMessageUserInput.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type ChatMessageUserInput = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/chatMessageWithContext.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport type { ChatMessageWithContextEventContext } from './chatMessageWithContextEventContext';\n\nexport interface ChatMessageWithContext {\n  message: string;\n  conversation_id?: string | null;\n  event_context?: ChatMessageWithContextEventContext;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/chatMessageWithContextConversationId.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type ChatMessageWithContextConversationId = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/chatMessageWithContextEventContext.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport type ChatMessageWithContextEventContext = { [key: string]: unknown }[] | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/chatMessageWithContextEventContextAnyOfItem.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type ChatMessageWithContextEventContextAnyOfItem = { [key: string]: unknown };\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/chatMessageWorkspacePath.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type ChatMessageWorkspacePath = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/chatResponse.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport type { ChatResponsePerformance } from './chatResponsePerformance';\nimport type { ChatResponseQueryInfo } from './chatResponseQueryInfo';\nimport type { ChatResponseRetrievalInfo } from './chatResponseRetrievalInfo';\n\nexport interface ChatResponse {\n  response: string;\n  timestamp: string;\n  query_info?: ChatResponseQueryInfo;\n  retrieval_info?: ChatResponseRetrievalInfo;\n  performance?: ChatResponsePerformance;\n  session_id?: string | null;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/chatResponsePerformance.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport type ChatResponsePerformance = { [key: string]: unknown } | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/chatResponsePerformanceAnyOf.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type ChatResponsePerformanceAnyOf = { [key: string]: unknown };\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/chatResponseQueryInfo.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport type ChatResponseQueryInfo = { [key: string]: unknown } | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/chatResponseQueryInfoAnyOf.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type ChatResponseQueryInfoAnyOf = { [key: string]: unknown };\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/chatResponseRetrievalInfo.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport type ChatResponseRetrievalInfo = { [key: string]: unknown } | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/chatResponseRetrievalInfoAnyOf.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type ChatResponseRetrievalInfoAnyOf = { [key: string]: unknown };\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/chatResponseSessionId.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type ChatResponseSessionId = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/cleanupOldDataApiCleanupPostParams.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport type CleanupOldDataApiCleanupPostParams = {\n/**\n * @minimum 1\n */\ndays?: number;\n};\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/contextListResponse.ts",
    "content": "/**\n * Generated by orval v7.17.0 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\nimport type { ContextResponse } from \"./contextResponse\";\n\n/**\n * 上下文列表响应模型\n */\nexport interface ContextListResponse {\n\t/** 总数 */\n\ttotal: number;\n\t/** 上下文列表 */\n\tcontexts: ContextResponse[];\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/contextResponse.ts",
    "content": "/**\n * Generated by orval v7.17.0 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nimport type { ContextResponseAiSummary } from \"./contextResponseAiSummary\";\nimport type { ContextResponseAiTitle } from \"./contextResponseAiTitle\";\nimport type { ContextResponseAppName } from \"./contextResponseAppName\";\nimport type { ContextResponseCreatedAt } from \"./contextResponseCreatedAt\";\nimport type { ContextResponseEndTime } from \"./contextResponseEndTime\";\nimport type { ContextResponseProjectId } from \"./contextResponseProjectId\";\nimport type { ContextResponseStartTime } from \"./contextResponseStartTime\";\nimport type { ContextResponseTaskId } from \"./contextResponseTaskId\";\nimport type { ContextResponseWindowTitle } from \"./contextResponseWindowTitle\";\n\n/**\n * 上下文响应模型\n */\nexport interface ContextResponse {\n\t/** 上下文ID */\n\tid: number;\n\t/** 应用名称 */\n\tapp_name?: ContextResponseAppName;\n\t/** 窗口标题 */\n\twindow_title?: ContextResponseWindowTitle;\n\t/** 开始时间 */\n\tstart_time?: ContextResponseStartTime;\n\t/** 结束时间 */\n\tend_time?: ContextResponseEndTime;\n\t/** AI生成的标题 */\n\tai_title?: ContextResponseAiTitle;\n\t/** AI生成的摘要 */\n\tai_summary?: ContextResponseAiSummary;\n\t/** 关联的项目ID */\n\tproject_id?: ContextResponseProjectId;\n\t/** 关联的任务ID */\n\ttask_id?: ContextResponseTaskId;\n\t/** 创建时间 */\n\tcreated_at?: ContextResponseCreatedAt;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/contextResponseAiSummary.ts",
    "content": "/**\n * Generated by orval v7.17.0 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * AI生成的摘要\n */\nexport type ContextResponseAiSummary = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/contextResponseAiTitle.ts",
    "content": "/**\n * Generated by orval v7.17.0 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * AI生成的标题\n */\nexport type ContextResponseAiTitle = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/contextResponseAppName.ts",
    "content": "/**\n * Generated by orval v7.17.0 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 应用名称\n */\nexport type ContextResponseAppName = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/contextResponseCreatedAt.ts",
    "content": "/**\n * Generated by orval v7.17.0 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 创建时间\n */\nexport type ContextResponseCreatedAt = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/contextResponseEndTime.ts",
    "content": "/**\n * Generated by orval v7.17.0 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 结束时间\n */\nexport type ContextResponseEndTime = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/contextResponseProjectId.ts",
    "content": "/**\n * Generated by orval v7.17.0 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 关联的项目ID\n */\nexport type ContextResponseProjectId = number | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/contextResponseStartTime.ts",
    "content": "/**\n * Generated by orval v7.17.0 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 开始时间\n */\nexport type ContextResponseStartTime = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/contextResponseTaskId.ts",
    "content": "/**\n * Generated by orval v7.17.0 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 关联的任务ID\n */\nexport type ContextResponseTaskId = number | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/contextResponseWindowTitle.ts",
    "content": "/**\n * Generated by orval v7.17.0 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 窗口标题\n */\nexport type ContextResponseWindowTitle = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/contextUpdateRequest.ts",
    "content": "/**\n * Generated by orval v7.17.0 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\nimport type { ContextUpdateRequestProjectId } from \"./contextUpdateRequestProjectId\";\nimport type { ContextUpdateRequestTaskId } from \"./contextUpdateRequestTaskId\";\n\n/**\n * 上下文更新请求模型\n */\nexport interface ContextUpdateRequest {\n\t/** 关联的项目ID（可选） */\n\tproject_id?: ContextUpdateRequestProjectId;\n\t/** 关联的任务ID（null表示解除关联） */\n\ttask_id?: ContextUpdateRequestTaskId;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/contextUpdateRequestProjectId.ts",
    "content": "/**\n * Generated by orval v7.17.0 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 关联的项目ID（可选）\n */\nexport type ContextUpdateRequestProjectId = number | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/contextUpdateRequestTaskId.ts",
    "content": "/**\n * Generated by orval v7.17.0 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 关联的任务ID（null表示解除关联）\n */\nexport type ContextUpdateRequestTaskId = number | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/countEventsApiEventsCountGetParams.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport type CountEventsApiEventsCountGetParams = {\nstart_date?: string | null;\nend_date?: string | null;\napp_name?: string | null;\n};\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/createdTodo.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\n/**\n * 创建的待办项\n */\nexport interface CreatedTodo {\n  /** 待办 ID */\n  id: number;\n  /** 待办名称 */\n  name: string;\n  /** 计划时间 */\n  scheduled_time?: string | null;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/createdTodoScheduledTime.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 计划时间\n */\nexport type CreatedTodoScheduledTime = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/eventDetailResponse.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport type { ScreenshotResponse } from './screenshotResponse';\n\nexport interface EventDetailResponse {\n  id: number;\n  app_name: string | null;\n  window_title: string | null;\n  start_time: string;\n  end_time: string | null;\n  screenshots: ScreenshotResponse[];\n  ai_title?: string | null;\n  ai_summary?: string | null;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/eventDetailResponseAiSummary.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type EventDetailResponseAiSummary = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/eventDetailResponseAiTitle.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type EventDetailResponseAiTitle = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/eventDetailResponseAppName.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type EventDetailResponseAppName = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/eventDetailResponseEndTime.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type EventDetailResponseEndTime = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/eventDetailResponseWindowTitle.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type EventDetailResponseWindowTitle = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/eventListResponse.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport type { EventResponse } from './eventResponse';\n\n/**\n * 事件列表响应，包含事件列表和总数\n */\nexport interface EventListResponse {\n  events: EventResponse[];\n  total_count: number;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/eventResponse.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport interface EventResponse {\n  id: number;\n  app_name: string | null;\n  window_title: string | null;\n  start_time: string;\n  end_time: string | null;\n  screenshot_count: number;\n  first_screenshot_id: number | null;\n  ai_title?: string | null;\n  ai_summary?: string | null;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/eventResponseAiSummary.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type EventResponseAiSummary = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/eventResponseAiTitle.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type EventResponseAiTitle = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/eventResponseAppName.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type EventResponseAppName = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/eventResponseEndTime.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type EventResponseEndTime = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/eventResponseFirstScreenshotId.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type EventResponseFirstScreenshotId = number | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/eventResponseWindowTitle.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type EventResponseWindowTitle = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/exportIcsApiTodosExportIcsGetParams.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport type ExportIcsApiTodosExportIcsGetParams = {\n/**\n * 导出数量限制\n * @minimum 1\n * @maximum 2000\n */\nlimit?: number;\n/**\n * 导出偏移量\n * @minimum 0\n */\noffset?: number;\n/**\n * 状态筛选：active/completed/canceled\n */\nstatus?: string | null;\n};\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/extractTodosAndSchedulesApiAudioExtractPostParams.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport type ExtractTodosAndSchedulesApiAudioExtractPostParams = {\nrecording_id: number;\noptimized?: boolean;\n};\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/extractedMessageTodo.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\n/**\n * 从消息中提取的待办项结构\n */\nexport interface ExtractedMessageTodo {\n  /**\n   * 待办名称\n   * @minLength 1\n   * @maxLength 100\n   */\n  name: string;\n  /** 待办描述（可选） */\n  description?: string | null;\n  /** 标签列表 */\n  tags?: string[];\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/extractedMessageTodoDescription.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 待办描述（可选）\n */\nexport type ExtractedMessageTodoDescription = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/extractedTodo.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport type { TodoTimeInfo } from './todoTimeInfo';\n\n/**\n * 提取的待办项结构\n */\nexport interface ExtractedTodo {\n  /**\n   * 待办标题\n   * @minLength 1\n   * @maxLength 100\n   */\n  title: string;\n  /** 待办描述（可选） */\n  description?: string | null;\n  /** 时间信息 */\n  time_info: TodoTimeInfo;\n  /** 解析后的绝对时间（程序计算得出） */\n  scheduled_time?: string | null;\n  /** 来源文本片段，用于验证 */\n  source_text: string;\n  /** 置信度（0.0-1.0），可选 */\n  confidence?: number | null;\n  /** 相关的截图ID列表 */\n  screenshot_ids?: number[];\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/extractedTodoConfidence.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 置信度（0.0-1.0），可选\n */\nexport type ExtractedTodoConfidence = number | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/extractedTodoDescription.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 待办描述（可选）\n */\nexport type ExtractedTodoDescription = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/extractedTodoScheduledTime.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 解析后的绝对时间（程序计算得出）\n */\nexport type ExtractedTodoScheduledTime = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/extractedTodoSourceText.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 来源文本\n */\nexport type ExtractedTodoSourceText = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/extractedTodoTimeInfo.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\n/**\n * 时间信息\n */\nexport type ExtractedTodoTimeInfo = { [key: string]: unknown } | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/extractedTodoTimeInfoAnyOf.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type ExtractedTodoTimeInfoAnyOf = { [key: string]: unknown };\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/floatingCaptureRequest.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\n/**\n * 悬浮窗截图请求模型\n */\nexport interface FloatingCaptureRequest {\n  /** Base64 编码的截图数据（不含 data:image/png;base64, 前缀） */\n  image_base64: string;\n  /** 是否自动创建待办（draft 状态） */\n  create_todos?: boolean;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/floatingCaptureResponse.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport type { CreatedTodo } from './createdTodo';\nimport type { LifetraceSchemasFloatingCaptureExtractedTodo } from './lifetraceSchemasFloatingCaptureExtractedTodo';\n\n/**\n * 悬浮窗截图响应模型\n */\nexport interface FloatingCaptureResponse {\n  /** 是否成功 */\n  success: boolean;\n  /** 处理消息 */\n  message: string;\n  /** 提取的待办列表 */\n  extracted_todos?: LifetraceSchemasFloatingCaptureExtractedTodo[];\n  /** 创建的待办列表 */\n  created_todos?: CreatedTodo[];\n  /** 创建的待办数量 */\n  created_count?: number;\n  /** 响应时间戳 */\n  timestamp?: string;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/generateTasksResponse.ts",
    "content": "/**\n * Generated by orval v7.17.0 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\nimport type { GeneratedTaskItem } from \"./generatedTaskItem\";\n\n/**\n * AI任务拆解响应模型\n */\nexport interface GenerateTasksResponse {\n\t/** 生成的任务列表 */\n\ttasks: GeneratedTaskItem[];\n\t/** 操作结果消息 */\n\tmessage: string;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/generatedTaskItem.ts",
    "content": "/**\n * Generated by orval v7.17.0 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\nimport type { GeneratedTaskItemDescription } from \"./generatedTaskItemDescription\";\n\n/**\n * AI生成的任务项\n */\nexport interface GeneratedTaskItem {\n\t/** 任务ID */\n\tid: number;\n\t/** 任务名称 */\n\tname: string;\n\t/** 任务描述 */\n\tdescription?: GeneratedTaskItemDescription;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/generatedTaskItemDescription.ts",
    "content": "/**\n * Generated by orval v7.17.0 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 任务描述\n */\nexport type GeneratedTaskItemDescription = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/getChatHistoryApiChatHistoryGetParams.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport type GetChatHistoryApiChatHistoryGetParams = {\nsession_id?: string | null;\n/**\n * 聊天类型过滤：event, project, general\n */\nchat_type?: string | null;\n};\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/getChatPromptsApiGetChatPromptsGetParams.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport type GetChatPromptsApiGetChatPromptsGetParams = {\nlocale?: string;\n};\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/getContextsApiContextsGetParams.ts",
    "content": "/**\n * Generated by orval v7.17.0 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type GetContextsApiContextsGetParams = {\n\t/**\n\t * 是否已关联任务（true/false）\n\t */\n\tassociated?: boolean | null;\n\t/**\n\t * 按任务ID过滤\n\t */\n\ttask_id?: number | null;\n\t/**\n\t * 返回数量限制\n\t * @minimum 1\n\t * @maximum 1000\n\t */\n\tlimit?: number;\n\t/**\n\t * 偏移量\n\t * @minimum 0\n\t */\n\toffset?: number;\n};\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/getCostStatsApiCostTrackingStatsGetParams.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport type GetCostStatsApiCostTrackingStatsGetParams = {\n/**\n * 统计天数\n */\ndays?: number;\n};\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/getLogContentApiLogsContentGetParams.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport type GetLogContentApiLogsContentGetParams = {\n/**\n * 日志文件相对路径\n */\nfile: string;\n};\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/getProjectTasksApiProjectsProjectIdTasksGetParams.ts",
    "content": "/**\n * Generated by orval v7.17.0 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type GetProjectTasksApiProjectsProjectIdTasksGetParams = {\n\t/**\n\t * 返回数量限制\n\t * @minimum 1\n\t * @maximum 1000\n\t */\n\tlimit?: number;\n\t/**\n\t * 偏移量\n\t * @minimum 0\n\t */\n\toffset?: number;\n};\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/getProjectsApiProjectsGetParams.ts",
    "content": "/**\n * Generated by orval v7.17.0 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type GetProjectsApiProjectsGetParams = {\n\t/**\n\t * 返回数量限制\n\t * @minimum 1\n\t * @maximum 1000\n\t */\n\tlimit?: number;\n\t/**\n\t * 偏移量\n\t * @minimum 0\n\t */\n\toffset?: number;\n\t/**\n\t * 项目状态筛选（active/archived/completed）\n\t */\n\tstatus?: string | null;\n};\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/getQuerySuggestionsApiChatSuggestionsGetParams.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport type GetQuerySuggestionsApiChatSuggestionsGetParams = {\n/**\n * 部分查询文本\n */\npartial_query?: string;\n};\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/getRecordingsApiAudioRecordingsGetParams.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport type GetRecordingsApiAudioRecordingsGetParams = {\ndate?: string | null;\n};\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/getScreenshotsApiScreenshotsGetParams.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport type GetScreenshotsApiScreenshotsGetParams = {\n/**\n * @minimum 1\n * @maximum 200\n */\nlimit?: number;\n/**\n * @minimum 0\n */\noffset?: number;\nstart_date?: string | null;\nend_date?: string | null;\napp_name?: string | null;\n};\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/getTaskProgressApiProjectsProjectIdTasksTaskIdProgressGetParams.ts",
    "content": "/**\n * Generated by orval v7.17.0 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type GetTaskProgressApiProjectsProjectIdTasksTaskIdProgressGetParams = {\n\t/**\n\t * 返回数量限制\n\t * @minimum 1\n\t * @maximum 100\n\t */\n\tlimit?: number;\n\t/**\n\t * 偏移量\n\t * @minimum 0\n\t */\n\toffset?: number;\n};\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/getTaskProgressLatestApiProjectsProjectIdTasksTaskIdProgressLatestGet200.ts",
    "content": "/**\n * Generated by orval v7.17.0 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\nimport type { TaskProgressResponse } from \"./taskProgressResponse\";\n\nexport type GetTaskProgressLatestApiProjectsProjectIdTasksTaskIdProgressLatestGet200 =\n\tTaskProgressResponse | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/getTimeAllocationApiTimeAllocationGetParams.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport type GetTimeAllocationApiTimeAllocationGetParams = {\n/**\n * 开始日期, YYYY-MM-DD 格式\n */\nstart_date?: string | null;\n/**\n * 结束日期, YYYY-MM-DD 格式\n */\nend_date?: string | null;\n/**\n * 统计天数 (弃用, 仅用于兼容)\n * @minimum 1\n * @maximum 365\n */\ndays?: number;\n};\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/getTimelineApiAudioTimelineGetParams.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport type GetTimelineApiAudioTimelineGetParams = {\ndate?: string | null;\noptimized?: boolean;\n};\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/getTranscriptionApiAudioTranscriptionRecordingIdGetParams.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport type GetTranscriptionApiAudioTranscriptionRecordingIdGetParams = {\noptimized?: boolean;\n};\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/hTTPValidationError.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport type { ValidationError } from './validationError';\n\nexport interface HTTPValidationError {\n  detail?: ValidationError[];\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/index.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport * from './activityEventsResponse';\nexport * from './activityListResponse';\nexport * from './activityResponse';\nexport * from './activityResponseAiSummary';\nexport * from './activityResponseAiTitle';\nexport * from './activityResponseCreatedAt';\nexport * from './activityResponseUpdatedAt';\nexport * from './addMessageRequest';\nexport * from './audioLinkItem';\nexport * from './audioLinkRequest';\nexport * from './bodyImportIcsApiTodosImportIcsPost';\nexport * from './bodyUploadAttachmentsApiTodosTodoIdAttachmentsPost';\nexport * from './capabilitiesResponse';\nexport * from './capabilitiesResponseMissingDeps';\nexport * from './chatMessage';\nexport * from './chatMessageContext';\nexport * from './chatMessageConversationId';\nexport * from './chatMessageExternalTools';\nexport * from './chatMessageMode';\nexport * from './chatMessageProjectId';\nexport * from './chatMessageSelectedTools';\nexport * from './chatMessageSystemPrompt';\nexport * from './chatMessageTaskIds';\nexport * from './chatMessageUserInput';\nexport * from './chatMessageWithContext';\nexport * from './chatMessageWithContextConversationId';\nexport * from './chatMessageWithContextEventContext';\nexport * from './chatMessageWithContextEventContextAnyOfItem';\nexport * from './chatMessageWorkspacePath';\nexport * from './chatResponse';\nexport * from './chatResponsePerformance';\nexport * from './chatResponsePerformanceAnyOf';\nexport * from './chatResponseQueryInfo';\nexport * from './chatResponseQueryInfoAnyOf';\nexport * from './chatResponseRetrievalInfo';\nexport * from './chatResponseRetrievalInfoAnyOf';\nexport * from './chatResponseSessionId';\nexport * from './cleanupOldDataApiCleanupPostParams';\nexport * from './contextListResponse';\nexport * from './contextResponse';\nexport * from './contextResponseAiSummary';\nexport * from './contextResponseAiTitle';\nexport * from './contextResponseAppName';\nexport * from './contextResponseCreatedAt';\nexport * from './contextResponseEndTime';\nexport * from './contextResponseProjectId';\nexport * from './contextResponseStartTime';\nexport * from './contextResponseTaskId';\nexport * from './contextResponseWindowTitle';\nexport * from './contextUpdateRequest';\nexport * from './contextUpdateRequestProjectId';\nexport * from './contextUpdateRequestTaskId';\nexport * from './countEventsApiEventsCountGetParams';\nexport * from './createdTodo';\nexport * from './createdTodoScheduledTime';\nexport * from './eventDetailResponse';\nexport * from './eventDetailResponseAiSummary';\nexport * from './eventDetailResponseAiTitle';\nexport * from './eventDetailResponseAppName';\nexport * from './eventDetailResponseEndTime';\nexport * from './eventDetailResponseWindowTitle';\nexport * from './eventListResponse';\nexport * from './eventResponse';\nexport * from './eventResponseAiSummary';\nexport * from './eventResponseAiTitle';\nexport * from './eventResponseAppName';\nexport * from './eventResponseEndTime';\nexport * from './eventResponseFirstScreenshotId';\nexport * from './eventResponseWindowTitle';\nexport * from './exportIcsApiTodosExportIcsGetParams';\nexport * from './extractedMessageTodo';\nexport * from './extractedMessageTodoDescription';\nexport * from './extractedTodo';\nexport * from './extractedTodoConfidence';\nexport * from './extractedTodoDescription';\nexport * from './extractedTodoScheduledTime';\nexport * from './extractedTodoSourceText';\nexport * from './extractedTodoTimeInfo';\nexport * from './extractedTodoTimeInfoAnyOf';\nexport * from './extractTodosAndSchedulesApiAudioExtractPostParams';\nexport * from './floatingCaptureRequest';\nexport * from './floatingCaptureResponse';\nexport * from './generatedTaskItem';\nexport * from './generatedTaskItemDescription';\nexport * from './generateTasksResponse';\nexport * from './getChatHistoryApiChatHistoryGetParams';\nexport * from './getChatPromptsApiGetChatPromptsGetParams';\nexport * from './getContextsApiContextsGetParams';\nexport * from './getCostStatsApiCostTrackingStatsGetParams';\nexport * from './getLogContentApiLogsContentGetParams';\nexport * from './getProjectsApiProjectsGetParams';\nexport * from './getProjectTasksApiProjectsProjectIdTasksGetParams';\nexport * from './getQuerySuggestionsApiChatSuggestionsGetParams';\nexport * from './getRecordingsApiAudioRecordingsGetParams';\nexport * from './getScreenshotsApiScreenshotsGetParams';\nexport * from './getTaskProgressApiProjectsProjectIdTasksTaskIdProgressGetParams';\nexport * from './getTaskProgressLatestApiProjectsProjectIdTasksTaskIdProgressLatestGet200';\nexport * from './getTimeAllocationApiTimeAllocationGetParams';\nexport * from './getTimelineApiAudioTimelineGetParams';\nexport * from './getTranscriptionApiAudioTranscriptionRecordingIdGetParams';\nexport * from './hTTPValidationError';\nexport * from './jobInfo';\nexport * from './jobInfoName';\nexport * from './jobInfoNextRunTime';\nexport * from './jobIntervalUpdateRequest';\nexport * from './jobIntervalUpdateRequestHours';\nexport * from './jobIntervalUpdateRequestMinutes';\nexport * from './jobIntervalUpdateRequestSeconds';\nexport * from './jobListResponse';\nexport * from './jobOperationResponse';\nexport * from './journalAutoLinkCandidate';\nexport * from './journalAutoLinkRequest';\nexport * from './journalAutoLinkResponse';\nexport * from './journalCreate';\nexport * from './journalGenerateRequest';\nexport * from './journalGenerateResponse';\nexport * from './journalListResponse';\nexport * from './journalResponse';\nexport * from './journalResponseDeletedAt';\nexport * from './journalTag';\nexport * from './journalUpdate';\nexport * from './journalUpdateContentFormat';\nexport * from './journalUpdateDate';\nexport * from './journalUpdateName';\nexport * from './journalUpdateTagIds';\nexport * from './journalUpdateUserNotes';\nexport * from './lifetraceSchemasFloatingCaptureExtractedTodo';\nexport * from './lifetraceSchemasFloatingCaptureExtractedTodoDescription';\nexport * from './lifetraceSchemasFloatingCaptureExtractedTodoSourceText';\nexport * from './lifetraceSchemasFloatingCaptureExtractedTodoTimeInfo';\nexport * from './lifetraceSchemasFloatingCaptureExtractedTodoTimeInfoAnyOf';\nexport * from './lifetraceSchemasTodoExtractionExtractedTodo';\nexport * from './lifetraceSchemasTodoExtractionExtractedTodoConfidence';\nexport * from './lifetraceSchemasTodoExtractionExtractedTodoDescription';\nexport * from './lifetraceSchemasTodoExtractionExtractedTodoScheduledTime';\nexport * from './linkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostParams';\nexport * from './listActivitiesApiActivitiesGetParams';\nexport * from './listEventsApiEventsGetParams';\nexport * from './listJournalsApiJournalsGetParams';\nexport * from './listTodosApiTodosGetParams';\nexport * from './manualActivityCreateRequest';\nexport * from './manualActivityCreateResponse';\nexport * from './manualActivityCreateResponseAiSummary';\nexport * from './manualActivityCreateResponseAiTitle';\nexport * from './manualActivityCreateResponseCreatedAt';\nexport * from './messageTodoExtractionRequest';\nexport * from './messageTodoExtractionRequestMessagesItem';\nexport * from './messageTodoExtractionRequestParentTodoId';\nexport * from './messageTodoExtractionRequestTodoContext';\nexport * from './messageTodoExtractionResponse';\nexport * from './messageTodoExtractionResponseErrorMessage';\nexport * from './newChatRequest';\nexport * from './newChatRequestSessionId';\nexport * from './newChatResponse';\nexport * from './optimizeTranscriptionApiAudioOptimizePostParams';\nexport * from './planQuestionnaireRequest';\nexport * from './planQuestionnaireRequestSessionId';\nexport * from './planQuestionnaireRequestTodoId';\nexport * from './planSummaryRequest';\nexport * from './planSummaryRequestAnswers';\nexport * from './planSummaryRequestSessionId';\nexport * from './processInfo';\nexport * from './processOcrApiOcrProcessPostParams';\nexport * from './projectCreate';\nexport * from './projectCreateDefinitionOfDone';\nexport * from './projectCreateDescription';\nexport * from './projectListResponse';\nexport * from './projectResponse';\nexport * from './projectResponseDefinitionOfDone';\nexport * from './projectResponseDescription';\nexport * from './projectStatus';\nexport * from './projectUpdate';\nexport * from './projectUpdateDefinitionOfDone';\nexport * from './projectUpdateDescription';\nexport * from './projectUpdateName';\nexport * from './projectUpdateStatus';\nexport * from './saveAndInitLlmApiSaveAndInitLlmPostBody';\nexport * from './saveConfigApiSaveConfigPostBody';\nexport * from './screenshotResponse';\nexport * from './screenshotResponseAppName';\nexport * from './screenshotResponseTextContent';\nexport * from './screenshotResponseWindowTitle';\nexport * from './searchRequest';\nexport * from './searchRequestAppName';\nexport * from './searchRequestEndDate';\nexport * from './searchRequestQuery';\nexport * from './searchRequestStartDate';\nexport * from './semanticSearchRequest';\nexport * from './semanticSearchRequestFilters';\nexport * from './semanticSearchRequestFiltersAnyOf';\nexport * from './semanticSearchRequestRetrieveK';\nexport * from './semanticSearchResult';\nexport * from './semanticSearchResultMetadata';\nexport * from './semanticSearchResultOcrResult';\nexport * from './semanticSearchResultOcrResultAnyOf';\nexport * from './semanticSearchResultScreenshot';\nexport * from './semanticSearchResultScreenshotAnyOf';\nexport * from './statisticsResponse';\nexport * from './syncVectorDatabaseApiVectorSyncPostParams';\nexport * from './systemResourcesResponse';\nexport * from './systemResourcesResponseCpu';\nexport * from './systemResourcesResponseDisk';\nexport * from './systemResourcesResponseMemory';\nexport * from './systemResourcesResponseStorage';\nexport * from './systemResourcesResponseSummary';\nexport * from './taskBatchDeleteRequest';\nexport * from './taskBatchDeleteResponse';\nexport * from './taskCreate';\nexport * from './taskCreateDescription';\nexport * from './taskListResponse';\nexport * from './taskProgressListResponse';\nexport * from './taskProgressResponse';\nexport * from './taskResponse';\nexport * from './taskResponseDescription';\nexport * from './taskStatus';\nexport * from './taskUpdate';\nexport * from './taskUpdateDescription';\nexport * from './taskUpdateName';\nexport * from './taskUpdateStatus';\nexport * from './testAsrConfigApiTestAsrConfigPostBody';\nexport * from './testLlmConfigApiTestLlmConfigPostBody';\nexport * from './testTavilyConfigApiTestTavilyConfigPostBody';\nexport * from './timeAllocationResponse';\nexport * from './timeAllocationResponseAppDetailsItem';\nexport * from './timeAllocationResponseDailyDistributionItem';\nexport * from './todoAttachmentResponse';\nexport * from './todoAttachmentResponseFileSize';\nexport * from './todoAttachmentResponseMimeType';\nexport * from './todoCreate';\nexport * from './todoCreateCompletedAt';\nexport * from './todoCreateDeadline';\nexport * from './todoCreateDescription';\nexport * from './todoCreateEndTime';\nexport * from './todoCreateParentTodoId';\nexport * from './todoCreatePercentComplete';\nexport * from './todoCreateRrule';\nexport * from './todoCreateStartTime';\nexport * from './todoCreateUid';\nexport * from './todoCreateUserNotes';\nexport * from './todoExtractionRequest';\nexport * from './todoExtractionRequestScreenshotSampleRatio';\nexport * from './todoExtractionResponse';\nexport * from './todoExtractionResponseAppName';\nexport * from './todoExtractionResponseErrorMessage';\nexport * from './todoExtractionResponseEventEndTime';\nexport * from './todoExtractionResponseEventStartTime';\nexport * from './todoExtractionResponseWindowTitle';\nexport * from './todoItemType';\nexport * from './todoListResponse';\nexport * from './todoPriority';\nexport * from './todoReorderItem';\nexport * from './todoReorderItemParentTodoId';\nexport * from './todoReorderRequest';\nexport * from './todoResponse';\nexport * from './todoResponseCompletedAt';\nexport * from './todoResponseDeadline';\nexport * from './todoResponseDescription';\nexport * from './todoResponseEndTime';\nexport * from './todoResponseParentTodoId';\nexport * from './todoResponseRrule';\nexport * from './todoResponseStartTime';\nexport * from './todoResponseUserNotes';\nexport * from './todoStatus';\nexport * from './todoTimeInfo';\nexport * from './todoTimeInfoAbsoluteTime';\nexport * from './todoTimeInfoRelativeDays';\nexport * from './todoTimeInfoRelativeTime';\nexport * from './todoTimeInfoTimeType';\nexport * from './todoUpdate';\nexport * from './todoUpdateCompletedAt';\nexport * from './todoUpdateDeadline';\nexport * from './todoUpdateDescription';\nexport * from './todoUpdateEndTime';\nexport * from './todoUpdateName';\nexport * from './todoUpdateOrder';\nexport * from './todoUpdateParentTodoId';\nexport * from './todoUpdatePercentComplete';\nexport * from './todoUpdatePriority';\nexport * from './todoUpdateRelatedActivities';\nexport * from './todoUpdateRrule';\nexport * from './todoUpdateStartTime';\nexport * from './todoUpdateStatus';\nexport * from './todoUpdateTags';\nexport * from './todoUpdateUserNotes';\nexport * from './updateJournalApiJournalsJournalIdPutBody';\nexport * from './validationError';\nexport * from './validationErrorLocItem';\nexport * from './vectorStatsResponse';\nexport * from './vectorStatsResponseCollectionName';\nexport * from './vectorStatsResponseDocumentCount';\nexport * from './vectorStatsResponseError';\nexport * from './visionChatRequest';\nexport * from './visionChatRequestMaxTokens';\nexport * from './visionChatRequestModel';\nexport * from './visionChatRequestTemperature';\nexport * from './visionChatResponse';\nexport * from './visionChatResponseModel';\nexport * from './visionChatResponseUsageInfo';\nexport * from './visionChatResponseUsageInfoAnyOf';"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/jobInfo.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\n/**\n * 任务信息\n */\nexport interface JobInfo {\n  id: string;\n  name?: string | null;\n  func: string;\n  trigger: string;\n  next_run_time?: string | null;\n  pending?: boolean;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/jobInfoName.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type JobInfoName = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/jobInfoNextRunTime.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type JobInfoNextRunTime = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/jobIntervalUpdateRequest.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\n/**\n * 任务间隔更新请求\n */\nexport interface JobIntervalUpdateRequest {\n  job_id: string;\n  seconds?: number | null;\n  minutes?: number | null;\n  hours?: number | null;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/jobIntervalUpdateRequestHours.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type JobIntervalUpdateRequestHours = number | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/jobIntervalUpdateRequestMinutes.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type JobIntervalUpdateRequestMinutes = number | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/jobIntervalUpdateRequestSeconds.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type JobIntervalUpdateRequestSeconds = number | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/jobListResponse.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport type { JobInfo } from './jobInfo';\n\n/**\n * 任务列表响应\n */\nexport interface JobListResponse {\n  total: number;\n  jobs: JobInfo[];\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/jobOperationResponse.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\n/**\n * 任务操作响应\n */\nexport interface JobOperationResponse {\n  success: boolean;\n  message: string;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/journalAutoLinkCandidate.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\n/**\n * 自动关联候选\n */\nexport interface JournalAutoLinkCandidate {\n  /** 候选ID */\n  id: number;\n  /** 候选标题 */\n  name: string;\n  /** 匹配分 */\n  score: number;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/journalAutoLinkRequest.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\n/**\n * 自动关联请求\n */\nexport interface JournalAutoLinkRequest {\n  /** 日记ID */\n  journal_id?: number | null;\n  /** 日记标题 */\n  title?: string | null;\n  /** 日记原文 */\n  content_original?: string | null;\n  /** 日记日期 */\n  date: string;\n  /** 日记归属刷新点 */\n  day_bucket_start?: string | null;\n  /**\n   * 默认关联数量\n   * @minimum 1\n   * @maximum 10\n   */\n  max_items?: number;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/journalAutoLinkResponse.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport type { JournalAutoLinkCandidate } from './journalAutoLinkCandidate';\n\n/**\n * 自动关联响应\n */\nexport interface JournalAutoLinkResponse {\n  /** 关联待办ID列表 */\n  related_todo_ids?: number[];\n  /** 关联活动ID列表 */\n  related_activity_ids?: number[];\n  /** 待办候选 */\n  todo_candidates?: JournalAutoLinkCandidate[];\n  /** 活动候选 */\n  activity_candidates?: JournalAutoLinkCandidate[];\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/journalCreate.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\n/**\n * 创建日记请求模型\n */\nexport interface JournalCreate {\n  /** iCalendar UID */\n  uid?: string | null;\n  /** 日记标题 */\n  name?: string | null;\n  /** 日记内容（富文本） */\n  user_notes: string;\n  /** 日记日期 */\n  date: string;\n  /**\n   * 内容格式：markdown/html/json\n   * @maxLength 20\n   */\n  content_format?: string;\n  /** 客观记录 */\n  content_objective?: string | null;\n  /** AI 视角 */\n  content_ai?: string | null;\n  /** 情绪 */\n  mood?: string | null;\n  /** 精力 */\n  energy?: number | null;\n  /** 日记归属刷新点 */\n  day_bucket_start?: string | null;\n  /** 关联的标签列表 */\n  tags?: string[];\n  /** 关联待办ID列表 */\n  related_todo_ids?: number[];\n  /** 关联活动ID列表 */\n  related_activity_ids?: number[];\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/journalGenerateRequest.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\n/**\n * 生成客观记录/AI 视角请求\n */\nexport interface JournalGenerateRequest {\n  /** 日记ID */\n  journal_id?: number | null;\n  /** 日记标题 */\n  title?: string | null;\n  /** 日记原文 */\n  content_original?: string | null;\n  /** 日记日期 */\n  date?: string | null;\n  /** 日记归属刷新点 */\n  day_bucket_start?: string | null;\n  /**\n   * 语言\n   * @maxLength 10\n   */\n  language?: string;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/journalGenerateResponse.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\n/**\n * 生成结果响应\n */\nexport interface JournalGenerateResponse {\n  /** 生成内容 */\n  content: string;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/journalListResponse.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport type { JournalResponse } from './journalResponse';\n\n/**\n * 日记列表响应模型\n */\nexport interface JournalListResponse {\n  /** 总数 */\n  total: number;\n  /** 日记列表 */\n  journals: JournalResponse[];\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/journalResponse.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport type { JournalTag } from './journalTag';\n\n/**\n * 日记响应模型\n */\nexport interface JournalResponse {\n  /** 日记ID */\n  id: number;\n  /** iCalendar UID */\n  uid: string;\n  /** 日记标题 */\n  name: string;\n  /** 日记内容（富文本） */\n  user_notes: string;\n  /** 日记日期 */\n  date: string;\n  /** 内容格式 */\n  content_format: string;\n  /** 客观记录 */\n  content_objective?: string | null;\n  /** AI 视角 */\n  content_ai?: string | null;\n  /** 情绪 */\n  mood?: string | null;\n  /** 精力 */\n  energy?: number | null;\n  /** 日记归属刷新点 */\n  day_bucket_start?: string | null;\n  /** 创建时间 */\n  created_at: string;\n  /** 更新时间 */\n  updated_at: string;\n  /** 删除时间 */\n  deleted_at?: string | null;\n  /** 关联标签列表 */\n  tags?: JournalTag[];\n  /** 关联待办ID列表 */\n  related_todo_ids?: number[];\n  /** 关联活动ID列表 */\n  related_activity_ids?: number[];\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/journalResponseDeletedAt.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 删除时间\n */\nexport type JournalResponseDeletedAt = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/journalTag.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\n/**\n * 日记关联的标签\n */\nexport interface JournalTag {\n  /** 标签ID */\n  id: number;\n  /** 标签名称 */\n  tag_name: string;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/journalUpdate.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\n/**\n * 更新日记请求模型\n */\nexport interface JournalUpdate {\n  /** 日记标题 */\n  name?: string | null;\n  /** 日记内容（富文本） */\n  user_notes?: string | null;\n  /** 日记日期 */\n  date?: string | null;\n  /** 内容格式：markdown/html/json */\n  content_format?: string | null;\n  /** 客观记录 */\n  content_objective?: string | null;\n  /** AI 视角 */\n  content_ai?: string | null;\n  /** 情绪 */\n  mood?: string | null;\n  /** 精力 */\n  energy?: number | null;\n  /** 日记归属刷新点 */\n  day_bucket_start?: string | null;\n  /** 关联的标签列表（覆盖替换） */\n  tags?: string[] | null;\n  /** 关联待办ID列表 */\n  related_todo_ids?: number[] | null;\n  /** 关联活动ID列表 */\n  related_activity_ids?: number[] | null;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/journalUpdateContentFormat.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 内容格式：markdown/html/json\n */\nexport type JournalUpdateContentFormat = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/journalUpdateDate.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 日记日期\n */\nexport type JournalUpdateDate = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/journalUpdateName.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 日记标题\n */\nexport type JournalUpdateName = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/journalUpdateTagIds.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 关联的标签ID列表（覆盖替换）\n */\nexport type JournalUpdateTagIds = number[] | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/journalUpdateUserNotes.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 日记内容（富文本）\n */\nexport type JournalUpdateUserNotes = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/lifetraceSchemasFloatingCaptureExtractedTodo.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport type { LifetraceSchemasFloatingCaptureExtractedTodoTimeInfo } from './lifetraceSchemasFloatingCaptureExtractedTodoTimeInfo';\n\n/**\n * 提取的待办项\n */\nexport interface LifetraceSchemasFloatingCaptureExtractedTodo {\n  /** 待办标题 */\n  title: string;\n  /** 待办描述 */\n  description?: string | null;\n  /** 时间信息 */\n  time_info?: LifetraceSchemasFloatingCaptureExtractedTodoTimeInfo;\n  /** 来源文本 */\n  source_text?: string | null;\n  /**\n   * 置信度\n   * @minimum 0\n   * @maximum 1\n   */\n  confidence?: number;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/lifetraceSchemasFloatingCaptureExtractedTodoDescription.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 待办描述\n */\nexport type LifetraceSchemasFloatingCaptureExtractedTodoDescription = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/lifetraceSchemasFloatingCaptureExtractedTodoSourceText.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 来源文本\n */\nexport type LifetraceSchemasFloatingCaptureExtractedTodoSourceText = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/lifetraceSchemasFloatingCaptureExtractedTodoTimeInfo.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\n/**\n * 时间信息\n */\nexport type LifetraceSchemasFloatingCaptureExtractedTodoTimeInfo = { [key: string]: unknown } | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/lifetraceSchemasFloatingCaptureExtractedTodoTimeInfoAnyOf.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type LifetraceSchemasFloatingCaptureExtractedTodoTimeInfoAnyOf = { [key: string]: unknown };\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/lifetraceSchemasTodoExtractionExtractedTodo.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport type { TodoTimeInfo } from './todoTimeInfo';\n\n/**\n * 提取的待办项结构\n */\nexport interface LifetraceSchemasTodoExtractionExtractedTodo {\n  /**\n   * 待办标题\n   * @minLength 1\n   * @maxLength 100\n   */\n  title: string;\n  /** 待办描述（可选） */\n  description?: string | null;\n  /** 时间信息 */\n  time_info: TodoTimeInfo;\n  /** 解析后的绝对时间（程序计算得出） */\n  scheduled_time?: string | null;\n  /** 来源文本片段，用于验证 */\n  source_text: string;\n  /** 置信度（0.0-1.0），可选 */\n  confidence?: number | null;\n  /** 相关的截图ID列表 */\n  screenshot_ids?: number[];\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/lifetraceSchemasTodoExtractionExtractedTodoConfidence.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 置信度（0.0-1.0），可选\n */\nexport type LifetraceSchemasTodoExtractionExtractedTodoConfidence = number | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/lifetraceSchemasTodoExtractionExtractedTodoDescription.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 待办描述（可选）\n */\nexport type LifetraceSchemasTodoExtractionExtractedTodoDescription = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/lifetraceSchemasTodoExtractionExtractedTodoScheduledTime.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 解析后的绝对时间（程序计算得出）\n */\nexport type LifetraceSchemasTodoExtractionExtractedTodoScheduledTime = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/linkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostParams.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport type LinkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostParams = {\noptimized?: boolean;\n};\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/listActivitiesApiActivitiesGetParams.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport type ListActivitiesApiActivitiesGetParams = {\n/**\n * @minimum 1\n * @maximum 200\n */\nlimit?: number;\n/**\n * @minimum 0\n */\noffset?: number;\nstart_date?: string | null;\nend_date?: string | null;\n};\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/listEventsApiEventsGetParams.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport type ListEventsApiEventsGetParams = {\n/**\n * @minimum 1\n * @maximum 200\n */\nlimit?: number;\n/**\n * @minimum 0\n */\noffset?: number;\nstart_date?: string | null;\nend_date?: string | null;\napp_name?: string | null;\n};\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/listJournalsApiJournalsGetParams.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport type ListJournalsApiJournalsGetParams = {\n/**\n * 返回数量限制\n * @minimum 1\n * @maximum 1000\n */\nlimit?: number;\n/**\n * 偏移量\n * @minimum 0\n */\noffset?: number;\n/**\n * 开始日期筛选\n */\nstart_date?: string | null;\n/**\n * 结束日期筛选\n */\nend_date?: string | null;\n};\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/listTodosApiTodosGetParams.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport type ListTodosApiTodosGetParams = {\n/**\n * 返回数量限制\n * @minimum 1\n * @maximum 2000\n */\nlimit?: number;\n/**\n * 偏移量\n * @minimum 0\n */\noffset?: number;\n/**\n * 状态筛选：active/completed/canceled\n */\nstatus?: string | null;\n};\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/manualActivityCreateRequest.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport interface ManualActivityCreateRequest {\n  event_ids: number[];\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/manualActivityCreateResponse.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport interface ManualActivityCreateResponse {\n  id: number;\n  start_time: string;\n  end_time: string;\n  ai_title?: string | null;\n  ai_summary?: string | null;\n  event_count: number;\n  created_at?: string | null;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/manualActivityCreateResponseAiSummary.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type ManualActivityCreateResponseAiSummary = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/manualActivityCreateResponseAiTitle.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type ManualActivityCreateResponseAiTitle = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/manualActivityCreateResponseCreatedAt.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type ManualActivityCreateResponseCreatedAt = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/messageTodoExtractionRequest.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport type { MessageTodoExtractionRequestMessagesItem } from './messageTodoExtractionRequestMessagesItem';\n\n/**\n * 从消息中提取待办的请求模型\n */\nexport interface MessageTodoExtractionRequest {\n  /** 消息列表，包含 role 和 content 字段 */\n  messages: MessageTodoExtractionRequestMessagesItem[];\n  /** 父待办ID，提取的待办将作为该待办的子待办 */\n  parent_todo_id?: number | null;\n  /** 待办上下文信息，用于帮助AI理解关联的待办 */\n  todo_context?: string | null;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/messageTodoExtractionRequestMessagesItem.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport type MessageTodoExtractionRequestMessagesItem = {[key: string]: string};\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/messageTodoExtractionRequestParentTodoId.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 父待办ID，提取的待办将作为该待办的子待办\n */\nexport type MessageTodoExtractionRequestParentTodoId = number | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/messageTodoExtractionRequestTodoContext.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 待办上下文信息，用于帮助AI理解关联的待办\n */\nexport type MessageTodoExtractionRequestTodoContext = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/messageTodoExtractionResponse.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport type { ExtractedMessageTodo } from './extractedMessageTodo';\n\n/**\n * 从消息中提取待办的响应模型\n */\nexport interface MessageTodoExtractionResponse {\n  /** 提取的待办列表 */\n  todos?: ExtractedMessageTodo[];\n  /** 错误信息（如果有） */\n  error_message?: string | null;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/messageTodoExtractionResponseErrorMessage.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 错误信息（如果有）\n */\nexport type MessageTodoExtractionResponseErrorMessage = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/newChatRequest.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport interface NewChatRequest {\n  session_id?: string | null;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/newChatRequestSessionId.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type NewChatRequestSessionId = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/newChatResponse.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport interface NewChatResponse {\n  session_id: string;\n  message: string;\n  timestamp: string;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/optimizeTranscriptionApiAudioOptimizePostParams.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport type OptimizeTranscriptionApiAudioOptimizePostParams = {\nrecording_id: number;\n};\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/planQuestionnaireRequest.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport interface PlanQuestionnaireRequest {\n  todo_name: string;\n  todo_id?: number | null;\n  session_id?: string | null;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/planQuestionnaireRequestSessionId.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type PlanQuestionnaireRequestSessionId = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/planQuestionnaireRequestTodoId.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type PlanQuestionnaireRequestTodoId = number | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/planSummaryRequest.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport type { PlanSummaryRequestAnswers } from './planSummaryRequestAnswers';\n\nexport interface PlanSummaryRequest {\n  todo_name: string;\n  answers: PlanSummaryRequestAnswers;\n  session_id?: string | null;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/planSummaryRequestAnswers.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport type PlanSummaryRequestAnswers = {[key: string]: string[]};\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/planSummaryRequestSessionId.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type PlanSummaryRequestSessionId = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/processInfo.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport interface ProcessInfo {\n  pid: number;\n  name: string;\n  cmdline: string;\n  memory_mb: number;\n  memory_vms_mb: number;\n  cpu_percent: number;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/processOcrApiOcrProcessPostParams.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport type ProcessOcrApiOcrProcessPostParams = {\nscreenshot_id: number;\n};\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/projectCreate.ts",
    "content": "/**\n * Generated by orval v7.17.0 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\nimport type { ProjectCreateDefinitionOfDone } from \"./projectCreateDefinitionOfDone\";\nimport type { ProjectCreateDescription } from \"./projectCreateDescription\";\nimport type { ProjectStatus } from \"./projectStatus\";\n\n/**\n * 创建项目请求模型\n */\nexport interface ProjectCreate {\n\t/**\n\t * 项目名称\n\t * @minLength 1\n\t * @maxLength 200\n\t */\n\tname: string;\n\t/** 项目“完成”的定义 */\n\tdefinition_of_done?: ProjectCreateDefinitionOfDone;\n\t/** 项目状态：active, archived, completed */\n\tstatus?: ProjectStatus;\n\t/** 项目描述或为 AI Advisor 提供的系统级上下文摘要 */\n\tdescription?: ProjectCreateDescription;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/projectCreateDefinitionOfDone.ts",
    "content": "/**\n * Generated by orval v7.17.0 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 项目“完成”的定义\n */\nexport type ProjectCreateDefinitionOfDone = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/projectCreateDescription.ts",
    "content": "/**\n * Generated by orval v7.17.0 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 项目描述或为 AI Advisor 提供的系统级上下文摘要\n */\nexport type ProjectCreateDescription = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/projectListResponse.ts",
    "content": "/**\n * Generated by orval v7.17.0 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\nimport type { ProjectResponse } from \"./projectResponse\";\n\n/**\n * 项目列表响应模型\n */\nexport interface ProjectListResponse {\n\t/** 总数 */\n\ttotal: number;\n\t/** 项目列表 */\n\tprojects: ProjectResponse[];\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/projectResponse.ts",
    "content": "/**\n * Generated by orval v7.17.0 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\nimport type { ProjectResponseDefinitionOfDone } from \"./projectResponseDefinitionOfDone\";\nimport type { ProjectResponseDescription } from \"./projectResponseDescription\";\n\n/**\n * 项目响应模型\n */\nexport interface ProjectResponse {\n\t/** 项目ID */\n\tid: number;\n\t/** 项目名称 */\n\tname: string;\n\t/** 项目“完成”的定义 */\n\tdefinition_of_done?: ProjectResponseDefinitionOfDone;\n\t/** 项目状态：active, archived, completed */\n\tstatus: string;\n\t/** 项目描述或为 AI Advisor 提供的系统级上下文摘要 */\n\tdescription?: ProjectResponseDescription;\n\t/** 创建时间 */\n\tcreated_at: string;\n\t/** 更新时间 */\n\tupdated_at: string;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/projectResponseDefinitionOfDone.ts",
    "content": "/**\n * Generated by orval v7.17.0 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 项目“完成”的定义\n */\nexport type ProjectResponseDefinitionOfDone = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/projectResponseDescription.ts",
    "content": "/**\n * Generated by orval v7.17.0 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 项目描述或为 AI Advisor 提供的系统级上下文摘要\n */\nexport type ProjectResponseDescription = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/projectStatus.ts",
    "content": "/**\n * Generated by orval v7.17.0 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 项目状态枚举\n */\nexport type ProjectStatus = (typeof ProjectStatus)[keyof typeof ProjectStatus];\n\n// eslint-disable-next-line @typescript-eslint/no-redeclare\nexport const ProjectStatus = {\n\tactive: \"active\",\n\tarchived: \"archived\",\n\tcompleted: \"completed\",\n} as const;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/projectUpdate.ts",
    "content": "/**\n * Generated by orval v7.17.0 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nimport type { ProjectUpdateDefinitionOfDone } from \"./projectUpdateDefinitionOfDone\";\nimport type { ProjectUpdateDescription } from \"./projectUpdateDescription\";\nimport type { ProjectUpdateName } from \"./projectUpdateName\";\nimport type { ProjectUpdateStatus } from \"./projectUpdateStatus\";\n\n/**\n * 更新项目请求模型\n */\nexport interface ProjectUpdate {\n\t/** 项目名称 */\n\tname?: ProjectUpdateName;\n\t/** 项目“完成”的定义 */\n\tdefinition_of_done?: ProjectUpdateDefinitionOfDone;\n\t/** 项目状态：active, archived, completed */\n\tstatus?: ProjectUpdateStatus;\n\t/** 项目描述或为 AI Advisor 提供的系统级上下文摘要 */\n\tdescription?: ProjectUpdateDescription;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/projectUpdateDefinitionOfDone.ts",
    "content": "/**\n * Generated by orval v7.17.0 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 项目“完成”的定义\n */\nexport type ProjectUpdateDefinitionOfDone = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/projectUpdateDescription.ts",
    "content": "/**\n * Generated by orval v7.17.0 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 项目描述或为 AI Advisor 提供的系统级上下文摘要\n */\nexport type ProjectUpdateDescription = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/projectUpdateName.ts",
    "content": "/**\n * Generated by orval v7.17.0 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 项目名称\n */\nexport type ProjectUpdateName = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/projectUpdateStatus.ts",
    "content": "/**\n * Generated by orval v7.17.0 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\nimport type { ProjectStatus } from \"./projectStatus\";\n\n/**\n * 项目状态：active, archived, completed\n */\nexport type ProjectUpdateStatus = ProjectStatus | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/saveAndInitLlmApiSaveAndInitLlmPostBody.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport type SaveAndInitLlmApiSaveAndInitLlmPostBody = {[key: string]: string};\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/saveConfigApiSaveConfigPostBody.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport type SaveConfigApiSaveConfigPostBody = { [key: string]: unknown };\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/screenshotResponse.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport interface ScreenshotResponse {\n  id: number;\n  file_path: string;\n  app_name: string | null;\n  window_title: string | null;\n  created_at: string;\n  text_content: string | null;\n  width: number;\n  height: number;\n  file_deleted?: boolean;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/screenshotResponseAppName.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type ScreenshotResponseAppName = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/screenshotResponseTextContent.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type ScreenshotResponseTextContent = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/screenshotResponseWindowTitle.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type ScreenshotResponseWindowTitle = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/searchRequest.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport interface SearchRequest {\n  query?: string | null;\n  start_date?: string | null;\n  end_date?: string | null;\n  app_name?: string | null;\n  limit?: number;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/searchRequestAppName.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type SearchRequestAppName = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/searchRequestEndDate.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type SearchRequestEndDate = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/searchRequestQuery.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type SearchRequestQuery = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/searchRequestStartDate.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type SearchRequestStartDate = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/semanticSearchRequest.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport type { SemanticSearchRequestFilters } from './semanticSearchRequestFilters';\n\nexport interface SemanticSearchRequest {\n  query: string;\n  top_k?: number;\n  use_rerank?: boolean;\n  retrieve_k?: number | null;\n  filters?: SemanticSearchRequestFilters;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/semanticSearchRequestFilters.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport type SemanticSearchRequestFilters = { [key: string]: unknown } | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/semanticSearchRequestFiltersAnyOf.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type SemanticSearchRequestFiltersAnyOf = { [key: string]: unknown };\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/semanticSearchRequestRetrieveK.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type SemanticSearchRequestRetrieveK = number | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/semanticSearchResult.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport type { SemanticSearchResultMetadata } from './semanticSearchResultMetadata';\nimport type { SemanticSearchResultOcrResult } from './semanticSearchResultOcrResult';\nimport type { SemanticSearchResultScreenshot } from './semanticSearchResultScreenshot';\n\nexport interface SemanticSearchResult {\n  text: string;\n  score: number;\n  metadata: SemanticSearchResultMetadata;\n  ocr_result?: SemanticSearchResultOcrResult;\n  screenshot?: SemanticSearchResultScreenshot;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/semanticSearchResultMetadata.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport type SemanticSearchResultMetadata = { [key: string]: unknown };\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/semanticSearchResultOcrResult.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport type SemanticSearchResultOcrResult = { [key: string]: unknown } | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/semanticSearchResultOcrResultAnyOf.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type SemanticSearchResultOcrResultAnyOf = { [key: string]: unknown };\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/semanticSearchResultScreenshot.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport type SemanticSearchResultScreenshot = { [key: string]: unknown } | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/semanticSearchResultScreenshotAnyOf.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type SemanticSearchResultScreenshotAnyOf = { [key: string]: unknown };\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/statisticsResponse.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport interface StatisticsResponse {\n  total_screenshots: number;\n  processed_screenshots: number;\n  today_screenshots: number;\n  processing_rate: number;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/syncVectorDatabaseApiVectorSyncPostParams.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport type SyncVectorDatabaseApiVectorSyncPostParams = {\n/**\n * 同步的最大记录数\n */\nlimit?: number | null;\n/**\n * 是否强制重置向量数据库\n */\nforce_reset?: boolean;\n};\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/systemResourcesResponse.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport type { ProcessInfo } from './processInfo';\nimport type { SystemResourcesResponseCpu } from './systemResourcesResponseCpu';\nimport type { SystemResourcesResponseDisk } from './systemResourcesResponseDisk';\nimport type { SystemResourcesResponseMemory } from './systemResourcesResponseMemory';\nimport type { SystemResourcesResponseStorage } from './systemResourcesResponseStorage';\nimport type { SystemResourcesResponseSummary } from './systemResourcesResponseSummary';\n\nexport interface SystemResourcesResponse {\n  memory: SystemResourcesResponseMemory;\n  cpu: SystemResourcesResponseCpu;\n  disk: SystemResourcesResponseDisk;\n  lifetrace_processes: ProcessInfo[];\n  storage: SystemResourcesResponseStorage;\n  summary: SystemResourcesResponseSummary;\n  timestamp: string;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/systemResourcesResponseCpu.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport type SystemResourcesResponseCpu = { [key: string]: unknown };\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/systemResourcesResponseDisk.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport type SystemResourcesResponseDisk = {[key: string]: {[key: string]: number}};\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/systemResourcesResponseMemory.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport type SystemResourcesResponseMemory = {[key: string]: number};\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/systemResourcesResponseStorage.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport type SystemResourcesResponseStorage = { [key: string]: unknown };\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/systemResourcesResponseSummary.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport type SystemResourcesResponseSummary = { [key: string]: unknown };\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/taskBatchDeleteRequest.ts",
    "content": "/**\n * Generated by orval v7.17.0 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 批量删除任务请求模型\n */\nexport interface TaskBatchDeleteRequest {\n\t/**\n\t * 要删除的任务ID列表\n\t * @minItems 1\n\t */\n\ttask_ids: number[];\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/taskBatchDeleteResponse.ts",
    "content": "/**\n * Generated by orval v7.17.0 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 批量删除任务响应模型\n */\nexport interface TaskBatchDeleteResponse {\n\t/** 成功删除的任务数量 */\n\tdeleted_count: number;\n\t/** 删除失败的任务ID */\n\tfailed_ids?: number[];\n\t/** 未找到的任务ID */\n\tnot_found_ids?: number[];\n\t/** 不属于该项目的任务ID */\n\twrong_project_ids?: number[];\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/taskCreate.ts",
    "content": "/**\n * Generated by orval v7.17.0 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\nimport type { TaskCreateDescription } from \"./taskCreateDescription\";\nimport type { TaskStatus } from \"./taskStatus\";\n\n/**\n * 创建任务请求模型\n */\nexport interface TaskCreate {\n\t/**\n\t * 任务名称\n\t * @minLength 1\n\t * @maxLength 200\n\t */\n\tname: string;\n\t/** 任务描述 */\n\tdescription?: TaskCreateDescription;\n\t/** 任务状态 */\n\tstatus?: TaskStatus;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/taskCreateDescription.ts",
    "content": "/**\n * Generated by orval v7.17.0 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 任务描述\n */\nexport type TaskCreateDescription = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/taskListResponse.ts",
    "content": "/**\n * Generated by orval v7.17.0 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\nimport type { TaskResponse } from \"./taskResponse\";\n\n/**\n * 任务列表响应模型\n */\nexport interface TaskListResponse {\n\t/** 总数 */\n\ttotal: number;\n\t/** 任务列表 */\n\ttasks: TaskResponse[];\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/taskProgressListResponse.ts",
    "content": "/**\n * Generated by orval v7.17.0 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\nimport type { TaskProgressResponse } from \"./taskProgressResponse\";\n\n/**\n * 任务进展列表响应模型\n */\nexport interface TaskProgressListResponse {\n\t/** 总数 */\n\ttotal: number;\n\t/** 进展记录列表 */\n\tprogress_list: TaskProgressResponse[];\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/taskProgressResponse.ts",
    "content": "/**\n * Generated by orval v7.17.0 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 任务进展响应模型\n */\nexport interface TaskProgressResponse {\n\t/** 进展记录ID */\n\tid: number;\n\t/** 任务ID */\n\ttask_id: number;\n\t/** 进展摘要内容 */\n\tsummary: string;\n\t/** 基于多少个上下文生成 */\n\tcontext_count: number;\n\t/** 生成时间 */\n\tgenerated_at: string;\n\t/** 创建时间 */\n\tcreated_at: string;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/taskResponse.ts",
    "content": "/**\n * Generated by orval v7.17.0 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\nimport type { TaskResponseDescription } from \"./taskResponseDescription\";\n\n/**\n * 任务响应模型\n */\nexport interface TaskResponse {\n\t/** 任务ID */\n\tid: number;\n\t/** 项目ID */\n\tproject_id: number;\n\t/** 任务名称 */\n\tname: string;\n\t/** 任务描述 */\n\tdescription?: TaskResponseDescription;\n\t/** 任务状态 */\n\tstatus: string;\n\t/** 创建时间 */\n\tcreated_at: string;\n\t/** 更新时间 */\n\tupdated_at: string;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/taskResponseDescription.ts",
    "content": "/**\n * Generated by orval v7.17.0 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 任务描述\n */\nexport type TaskResponseDescription = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/taskStatus.ts",
    "content": "/**\n * Generated by orval v7.17.0 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 任务状态枚举\n */\nexport type TaskStatus = (typeof TaskStatus)[keyof typeof TaskStatus];\n\n// eslint-disable-next-line @typescript-eslint/no-redeclare\nexport const TaskStatus = {\n\tpending: \"pending\",\n\tin_progress: \"in_progress\",\n\tcompleted: \"completed\",\n\tcancelled: \"cancelled\",\n} as const;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/taskUpdate.ts",
    "content": "/**\n * Generated by orval v7.17.0 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nimport type { TaskUpdateDescription } from \"./taskUpdateDescription\";\nimport type { TaskUpdateName } from \"./taskUpdateName\";\nimport type { TaskUpdateStatus } from \"./taskUpdateStatus\";\n\n/**\n * 更新任务请求模型\n */\nexport interface TaskUpdate {\n\t/** 任务名称 */\n\tname?: TaskUpdateName;\n\t/** 任务描述 */\n\tdescription?: TaskUpdateDescription;\n\t/** 任务状态 */\n\tstatus?: TaskUpdateStatus;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/taskUpdateDescription.ts",
    "content": "/**\n * Generated by orval v7.17.0 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 任务描述\n */\nexport type TaskUpdateDescription = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/taskUpdateName.ts",
    "content": "/**\n * Generated by orval v7.17.0 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 任务名称\n */\nexport type TaskUpdateName = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/taskUpdateStatus.ts",
    "content": "/**\n * Generated by orval v7.17.0 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\nimport type { TaskStatus } from \"./taskStatus\";\n\n/**\n * 任务状态\n */\nexport type TaskUpdateStatus = TaskStatus | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/testAsrConfigApiTestAsrConfigPostBody.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport type TestAsrConfigApiTestAsrConfigPostBody = { [key: string]: unknown };\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/testLlmConfigApiTestLlmConfigPostBody.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport type TestLlmConfigApiTestLlmConfigPostBody = {[key: string]: string};\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/testTavilyConfigApiTestTavilyConfigPostBody.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport type TestTavilyConfigApiTestTavilyConfigPostBody = {[key: string]: string};\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/timeAllocationResponse.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport type { TimeAllocationResponseAppDetailsItem } from './timeAllocationResponseAppDetailsItem';\nimport type { TimeAllocationResponseDailyDistributionItem } from './timeAllocationResponseDailyDistributionItem';\n\n/**\n * 时间分配响应模型\n */\nexport interface TimeAllocationResponse {\n  total_time: number;\n  daily_distribution: TimeAllocationResponseDailyDistributionItem[];\n  app_details: TimeAllocationResponseAppDetailsItem[];\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/timeAllocationResponseAppDetailsItem.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport type TimeAllocationResponseAppDetailsItem = { [key: string]: unknown };\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/timeAllocationResponseDailyDistributionItem.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport type TimeAllocationResponseDailyDistributionItem = { [key: string]: unknown };\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoAttachmentResponse.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\n/**\n * Todo 附件响应模型\n */\nexport interface TodoAttachmentResponse {\n  /** 附件ID */\n  id: number;\n  /** 文件名 */\n  file_name: string;\n  /** 文件路径 */\n  file_path: string;\n  /** 文件大小（字节） */\n  file_size?: number | null;\n  /** MIME 类型 */\n  mime_type?: string | null;\n  /** 来源(user/ai) */\n  source?: string | null;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoAttachmentResponseFileSize.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 文件大小（字节）\n */\nexport type TodoAttachmentResponseFileSize = number | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoAttachmentResponseMimeType.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * MIME 类型\n */\nexport type TodoAttachmentResponseMimeType = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoCreate.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport type { TodoItemType } from './todoItemType';\nimport type { TodoPriority } from './todoPriority';\nimport type { TodoStatus } from './todoStatus';\n\n/**\n * 创建 Todo 请求模型\n */\nexport interface TodoCreate {\n  /** iCalendar UID */\n  uid?: string | null;\n  /**\n   * 待办名称\n   * @minLength 1\n   * @maxLength 200\n   */\n  name: string;\n  /** iCalendar SUMMARY */\n  summary?: string | null;\n  /** 描述 */\n  description?: string | null;\n  /** 用户笔记 */\n  user_notes?: string | null;\n  /** 父级待办ID */\n  parent_todo_id?: number | null;\n  /** iCalendar 条目类型 */\n  item_type?: TodoItemType | null;\n  /** iCalendar LOCATION */\n  location?: string | null;\n  /** iCalendar CATEGORIES */\n  categories?: string | null;\n  /** iCalendar CLASS */\n  classification?: string | null;\n  /** 截止时间（旧字段，逐步废弃） */\n  deadline?: string | null;\n  /** 开始时间 */\n  start_time?: string | null;\n  /** 结束时间 */\n  end_time?: string | null;\n  /** iCalendar DTSTART */\n  dtstart?: string | null;\n  /** iCalendar DTEND */\n  dtend?: string | null;\n  /** iCalendar DUE */\n  due?: string | null;\n  /** iCalendar DURATION (ISO 8601) */\n  duration?: string | null;\n  /** 时区（IANA） */\n  time_zone?: string | null;\n  /** iCalendar TZID */\n  tzid?: string | null;\n  /** 是否全天 */\n  is_all_day?: boolean | null;\n  /** iCalendar DTSTAMP */\n  dtstamp?: string | null;\n  /** iCalendar CREATED */\n  created?: string | null;\n  /** iCalendar LAST-MODIFIED */\n  last_modified?: string | null;\n  /** iCalendar SEQUENCE */\n  sequence?: number | null;\n  /** iCalendar RDATE */\n  rdate?: string | null;\n  /** iCalendar EXDATE */\n  exdate?: string | null;\n  /** iCalendar RECURRENCE-ID */\n  recurrence_id?: string | null;\n  /** iCalendar RELATED-TO UID */\n  related_to_uid?: string | null;\n  /** iCalendar RELATED-TO RELTYPE */\n  related_to_reltype?: string | null;\n  /** iCalendar STATUS */\n  ical_status?: string | null;\n  /** 提醒偏移列表（分钟，基于 dtstart/due） */\n  reminder_offsets?: number[] | null;\n  /** 状态 */\n  status?: TodoStatus;\n  /** 优先级 */\n  priority?: TodoPriority;\n  /** 完成时间 */\n  completed_at?: string | null;\n  /** 完成百分比（0-100） */\n  percent_complete?: number | null;\n  /** iCalendar RRULE */\n  rrule?: string | null;\n  /** 同级待办之间的展示排序 */\n  order?: number;\n  /** 标签名称列表 */\n  tags?: string[];\n  /** 关联活动ID列表 */\n  related_activities?: number[];\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoCreateCompletedAt.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 完成时间\n */\nexport type TodoCreateCompletedAt = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoCreateDeadline.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 截止时间\n */\nexport type TodoCreateDeadline = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoCreateDescription.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 描述\n */\nexport type TodoCreateDescription = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoCreateEndTime.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 结束时间\n */\nexport type TodoCreateEndTime = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoCreateParentTodoId.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 父级待办ID\n */\nexport type TodoCreateParentTodoId = number | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoCreatePercentComplete.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 完成百分比（0-100）\n */\nexport type TodoCreatePercentComplete = number | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoCreateRrule.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * iCalendar RRULE\n */\nexport type TodoCreateRrule = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoCreateStartTime.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 开始时间\n */\nexport type TodoCreateStartTime = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoCreateUid.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * iCalendar UID\n */\nexport type TodoCreateUid = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoCreateUserNotes.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 用户笔记\n */\nexport type TodoCreateUserNotes = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoExtractionRequest.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\n/**\n * 待办提取请求模型\n */\nexport interface TodoExtractionRequest {\n  /**\n   * 事件ID\n   * @exclusiveMinimum 0\n   */\n  event_id: number;\n  /** 截图采样比例（每N张选1张），默认3 */\n  screenshot_sample_ratio?: number | null;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoExtractionRequestScreenshotSampleRatio.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 截图采样比例（每N张选1张），默认3\n */\nexport type TodoExtractionRequestScreenshotSampleRatio = number | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoExtractionResponse.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport type { ExtractedTodo } from './extractedTodo';\n\n/**\n * 待办提取响应模型\n */\nexport interface TodoExtractionResponse {\n  /** 事件ID */\n  event_id: number;\n  /** 应用名称 */\n  app_name?: string | null;\n  /** 窗口标题 */\n  window_title?: string | null;\n  /** 事件开始时间 */\n  event_start_time?: string | null;\n  /** 事件结束时间 */\n  event_end_time?: string | null;\n  /** 提取的待办列表 */\n  todos?: ExtractedTodo[];\n  /** 提取时间戳 */\n  extraction_timestamp?: string;\n  /** 实际分析的截图数量 */\n  screenshot_count?: number;\n  /** 错误信息（如果有） */\n  error_message?: string | null;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoExtractionResponseAppName.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 应用名称\n */\nexport type TodoExtractionResponseAppName = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoExtractionResponseErrorMessage.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 错误信息（如果有）\n */\nexport type TodoExtractionResponseErrorMessage = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoExtractionResponseEventEndTime.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 事件结束时间\n */\nexport type TodoExtractionResponseEventEndTime = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoExtractionResponseEventStartTime.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 事件开始时间\n */\nexport type TodoExtractionResponseEventStartTime = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoExtractionResponseWindowTitle.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 窗口标题\n */\nexport type TodoExtractionResponseWindowTitle = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoItemType.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\n/**\n * iCalendar 条目类型\n */\nexport type TodoItemType = typeof TodoItemType[keyof typeof TodoItemType];\n\n\nexport const TodoItemType = {\n  VTODO: 'VTODO',\n  VEVENT: 'VEVENT',\n} as const;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoListResponse.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport type { TodoResponse } from './todoResponse';\n\n/**\n * Todo 列表响应模型\n */\nexport interface TodoListResponse {\n  /** 总数 */\n  total: number;\n  /** 待办列表 */\n  todos: TodoResponse[];\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoPriority.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\n/**\n * Todo 优先级（与前端保持一致）\n */\nexport type TodoPriority = typeof TodoPriority[keyof typeof TodoPriority];\n\n\nexport const TodoPriority = {\n  high: 'high',\n  medium: 'medium',\n  low: 'low',\n  none: 'none',\n} as const;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoReorderItem.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\n/**\n * 单个待办排序项\n */\nexport interface TodoReorderItem {\n  /** 待办ID */\n  id: number;\n  /** 新的排序值 */\n  order: number;\n  /** 父级待办ID（可选，用于设置父子关系） */\n  parent_todo_id?: number | null;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoReorderItemParentTodoId.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 父级待办ID（可选，用于设置父子关系）\n */\nexport type TodoReorderItemParentTodoId = number | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoReorderRequest.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport type { TodoReorderItem } from './todoReorderItem';\n\n/**\n * 批量重排序请求模型\n */\nexport interface TodoReorderRequest {\n  /** 待排序的待办列表 */\n  items: TodoReorderItem[];\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoResponse.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport type { TodoAttachmentResponse } from './todoAttachmentResponse';\n\n/**\n * Todo 响应模型\n */\nexport interface TodoResponse {\n  /** 待办ID */\n  id: number;\n  /** iCalendar UID */\n  uid: string;\n  /** 待办名称 */\n  name: string;\n  /** iCalendar SUMMARY */\n  summary?: string | null;\n  /** 描述 */\n  description?: string | null;\n  /** 用户笔记 */\n  user_notes?: string | null;\n  /** 父级待办ID */\n  parent_todo_id?: number | null;\n  /** iCalendar 条目类型 */\n  item_type?: string | null;\n  /** iCalendar LOCATION */\n  location?: string | null;\n  /** iCalendar CATEGORIES */\n  categories?: string | null;\n  /** iCalendar CLASS */\n  classification?: string | null;\n  /** 截止时间（旧字段） */\n  deadline?: string | null;\n  /** 开始时间 */\n  start_time?: string | null;\n  /** 结束时间 */\n  end_time?: string | null;\n  /** iCalendar DTSTART */\n  dtstart?: string | null;\n  /** iCalendar DTEND */\n  dtend?: string | null;\n  /** iCalendar DUE */\n  due?: string | null;\n  /** iCalendar DURATION */\n  duration?: string | null;\n  /** 时区（IANA） */\n  time_zone?: string | null;\n  /** iCalendar TZID */\n  tzid?: string | null;\n  /** 是否全天 */\n  is_all_day?: boolean;\n  /** iCalendar DTSTAMP */\n  dtstamp?: string | null;\n  /** iCalendar CREATED */\n  created?: string | null;\n  /** iCalendar LAST-MODIFIED */\n  last_modified?: string | null;\n  /** iCalendar SEQUENCE */\n  sequence?: number | null;\n  /** iCalendar RDATE */\n  rdate?: string | null;\n  /** iCalendar EXDATE */\n  exdate?: string | null;\n  /** iCalendar RECURRENCE-ID */\n  recurrence_id?: string | null;\n  /** iCalendar RELATED-TO UID */\n  related_to_uid?: string | null;\n  /** iCalendar RELATED-TO RELTYPE */\n  related_to_reltype?: string | null;\n  /** iCalendar STATUS */\n  ical_status?: string | null;\n  /** 提醒偏移列表（分钟，基于 dtstart/due） */\n  reminder_offsets?: number[] | null;\n  /** 状态 */\n  status: string;\n  /** 优先级 */\n  priority: string;\n  /** 完成时间 */\n  completed_at?: string | null;\n  /** 完成百分比（0-100） */\n  percent_complete?: number;\n  /** iCalendar RRULE */\n  rrule?: string | null;\n  /** 同级待办之间的展示排序 */\n  order?: number;\n  /** 标签名称列表 */\n  tags?: string[];\n  /** 附件列表 */\n  attachments?: TodoAttachmentResponse[];\n  /** 关联活动ID列表 */\n  related_activities?: number[];\n  /** 创建时间 */\n  created_at: string;\n  /** 更新时间 */\n  updated_at: string;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoResponseCompletedAt.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 完成时间\n */\nexport type TodoResponseCompletedAt = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoResponseDeadline.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 截止时间\n */\nexport type TodoResponseDeadline = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoResponseDescription.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 描述\n */\nexport type TodoResponseDescription = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoResponseEndTime.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 结束时间\n */\nexport type TodoResponseEndTime = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoResponseParentTodoId.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 父级待办ID\n */\nexport type TodoResponseParentTodoId = number | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoResponseRrule.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * iCalendar RRULE\n */\nexport type TodoResponseRrule = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoResponseStartTime.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 开始时间\n */\nexport type TodoResponseStartTime = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoResponseUserNotes.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 用户笔记\n */\nexport type TodoResponseUserNotes = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoStatus.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\n/**\n * Todo 状态枚举（与前端保持一致）\n */\nexport type TodoStatus = typeof TodoStatus[keyof typeof TodoStatus];\n\n\nexport const TodoStatus = {\n  active: 'active',\n  completed: 'completed',\n  canceled: 'canceled',\n  draft: 'draft',\n} as const;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoTimeInfo.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport type { TodoTimeInfoTimeType } from './todoTimeInfoTimeType';\n\n/**\n * 待办时间信息结构\n */\nexport interface TodoTimeInfo {\n  /** 时间类型：relative（相对时间）或 absolute（绝对时间） */\n  time_type: TodoTimeInfoTimeType;\n  /** 相对天数（0=今天，1=明天，2=后天，-1=昨天） */\n  relative_days?: number | null;\n  /** 相对时间点，24小时制格式（如：'13:00', '15:30'） */\n  relative_time?: string | null;\n  /** 绝对时间（ISO 8601格式），仅在time_type为absolute时使用 */\n  absolute_time?: string | null;\n  /** 原始时间文本，用于验证和调试 */\n  raw_text: string;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoTimeInfoAbsoluteTime.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 绝对时间（ISO 8601格式），仅在time_type为absolute时使用\n */\nexport type TodoTimeInfoAbsoluteTime = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoTimeInfoRelativeDays.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 相对天数（0=今天，1=明天，2=后天，-1=昨天）\n */\nexport type TodoTimeInfoRelativeDays = number | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoTimeInfoRelativeTime.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 相对时间点，24小时制格式（如：'13:00', '15:30'）\n */\nexport type TodoTimeInfoRelativeTime = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoTimeInfoTimeType.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\n/**\n * 时间类型：relative（相对时间）或 absolute（绝对时间）\n */\nexport type TodoTimeInfoTimeType = typeof TodoTimeInfoTimeType[keyof typeof TodoTimeInfoTimeType];\n\n\nexport const TodoTimeInfoTimeType = {\n  relative: 'relative',\n  absolute: 'absolute',\n} as const;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoUpdate.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport type { TodoItemType } from './todoItemType';\nimport type { TodoPriority } from './todoPriority';\nimport type { TodoStatus } from './todoStatus';\n\n/**\n * 更新 Todo 请求模型（字段均可选）\n */\nexport interface TodoUpdate {\n  /** 待办名称 */\n  name?: string | null;\n  /** iCalendar SUMMARY */\n  summary?: string | null;\n  /** 描述 */\n  description?: string | null;\n  /** 用户笔记 */\n  user_notes?: string | null;\n  /** 父级待办ID（显式传 null 可清空） */\n  parent_todo_id?: number | null;\n  /** iCalendar 条目类型 */\n  item_type?: TodoItemType | null;\n  /** iCalendar LOCATION */\n  location?: string | null;\n  /** iCalendar CATEGORIES */\n  categories?: string | null;\n  /** iCalendar CLASS */\n  classification?: string | null;\n  /** 截止时间（旧字段，显式传 null 可清空） */\n  deadline?: string | null;\n  /** 开始时间（显式传 null 可清空） */\n  start_time?: string | null;\n  /** 结束时间（显式传 null 可清空） */\n  end_time?: string | null;\n  /** iCalendar DTSTART（显式传 null 可清空） */\n  dtstart?: string | null;\n  /** iCalendar DTEND（显式传 null 可清空） */\n  dtend?: string | null;\n  /** iCalendar DUE（显式传 null 可清空） */\n  due?: string | null;\n  /** iCalendar DURATION（显式传 null 可清空） */\n  duration?: string | null;\n  /** 时区（显式传 null 可清空） */\n  time_zone?: string | null;\n  /** iCalendar TZID（显式传 null 可清空） */\n  tzid?: string | null;\n  /** 是否全天（显式传 null 可清空） */\n  is_all_day?: boolean | null;\n  /** iCalendar DTSTAMP（显式传 null 可清空） */\n  dtstamp?: string | null;\n  /** iCalendar CREATED（显式传 null 可清空） */\n  created?: string | null;\n  /** iCalendar LAST-MODIFIED（显式传 null 可清空） */\n  last_modified?: string | null;\n  /** iCalendar SEQUENCE（显式传 null 可清空） */\n  sequence?: number | null;\n  /** iCalendar RDATE（显式传 null 可清空） */\n  rdate?: string | null;\n  /** iCalendar EXDATE（显式传 null 可清空） */\n  exdate?: string | null;\n  /** iCalendar RECURRENCE-ID（显式传 null 可清空） */\n  recurrence_id?: string | null;\n  /** iCalendar RELATED-TO UID（显式传 null 可清空） */\n  related_to_uid?: string | null;\n  /** iCalendar RELATED-TO RELTYPE（显式传 null 可清空） */\n  related_to_reltype?: string | null;\n  /** iCalendar STATUS（显式传 null 可清空） */\n  ical_status?: string | null;\n  /** 提醒偏移列表（分钟，显式传 null 可回退默认） */\n  reminder_offsets?: number[] | null;\n  /** 状态 */\n  status?: TodoStatus | null;\n  /** 优先级 */\n  priority?: TodoPriority | null;\n  /** 完成时间（显式传 null 可清空） */\n  completed_at?: string | null;\n  /** 完成百分比（0-100） */\n  percent_complete?: number | null;\n  /** iCalendar RRULE（显式传 null 可清空） */\n  rrule?: string | null;\n  /** 同级待办之间的展示排序 */\n  order?: number | null;\n  /** 标签名称列表（显式传空数组将清空） */\n  tags?: string[] | null;\n  /** 关联活动ID列表（显式传空数组将清空） */\n  related_activities?: number[] | null;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoUpdateCompletedAt.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 完成时间（显式传 null 可清空）\n */\nexport type TodoUpdateCompletedAt = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoUpdateDeadline.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 截止时间（显式传 null 可清空）\n */\nexport type TodoUpdateDeadline = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoUpdateDescription.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 描述\n */\nexport type TodoUpdateDescription = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoUpdateEndTime.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 结束时间（显式传 null 可清空）\n */\nexport type TodoUpdateEndTime = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoUpdateName.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 待办名称\n */\nexport type TodoUpdateName = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoUpdateOrder.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 同级待办之间的展示排序\n */\nexport type TodoUpdateOrder = number | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoUpdateParentTodoId.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 父级待办ID（显式传 null 可清空）\n */\nexport type TodoUpdateParentTodoId = number | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoUpdatePercentComplete.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 完成百分比（0-100）\n */\nexport type TodoUpdatePercentComplete = number | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoUpdatePriority.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\nimport type { TodoPriority } from './todoPriority';\n\n/**\n * 优先级\n */\nexport type TodoUpdatePriority = TodoPriority | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoUpdateRelatedActivities.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 关联活动ID列表（显式传空数组将清空）\n */\nexport type TodoUpdateRelatedActivities = number[] | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoUpdateRrule.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * iCalendar RRULE（显式传 null 可清空）\n */\nexport type TodoUpdateRrule = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoUpdateStartTime.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 开始时间（显式传 null 可清空）\n */\nexport type TodoUpdateStartTime = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoUpdateStatus.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\nimport type { TodoStatus } from './todoStatus';\n\n/**\n * 状态\n */\nexport type TodoUpdateStatus = TodoStatus | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoUpdateTags.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 标签名称列表（显式传空数组将清空）\n */\nexport type TodoUpdateTags = string[] | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/todoUpdateUserNotes.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 用户笔记\n */\nexport type TodoUpdateUserNotes = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/updateJournalApiJournalsJournalIdPutBody.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\nimport type { JournalUpdate } from './journalUpdate';\n\nexport type UpdateJournalApiJournalsJournalIdPutBody = JournalUpdate | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/validationError.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport interface ValidationError {\n  loc: (string | number)[];\n  msg: string;\n  type: string;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/validationErrorLocItem.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type ValidationErrorLocItem = string | number;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/vectorStatsResponse.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\nexport interface VectorStatsResponse {\n  enabled: boolean;\n  collection_name?: string | null;\n  document_count?: number | null;\n  error?: string | null;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/vectorStatsResponseCollectionName.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type VectorStatsResponseCollectionName = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/vectorStatsResponseDocumentCount.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type VectorStatsResponseDocumentCount = number | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/vectorStatsResponseError.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type VectorStatsResponseError = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/visionChatRequest.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\n/**\n * 视觉多模态聊天请求模型\n */\nexport interface VisionChatRequest {\n  /** 截图ID列表，至少包含一个截图ID */\n  screenshot_ids: number[];\n  /**\n   * 文本提示词\n   * @minLength 1\n   */\n  prompt: string;\n  /** 视觉模型名称，如果不提供则使用配置中的默认模型 */\n  model?: string | null;\n  /** 温度参数，控制输出的随机性 */\n  temperature?: number | null;\n  /** 最大生成token数 */\n  max_tokens?: number | null;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/visionChatRequestMaxTokens.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 最大生成token数\n */\nexport type VisionChatRequestMaxTokens = number | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/visionChatRequestModel.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 视觉模型名称，如果不提供则使用配置中的默认模型\n */\nexport type VisionChatRequestModel = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/visionChatRequestTemperature.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 温度参数，控制输出的随机性\n */\nexport type VisionChatRequestTemperature = number | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/visionChatResponse.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport type { VisionChatResponseUsageInfo } from './visionChatResponseUsageInfo';\n\n/**\n * 视觉多模态聊天响应模型\n */\nexport interface VisionChatResponse {\n  /** 模型生成的响应文本 */\n  response: string;\n  /** 响应时间戳 */\n  timestamp?: string;\n  /** Token使用信息 */\n  usage_info?: VisionChatResponseUsageInfo;\n  /** 实际使用的模型名称 */\n  model?: string | null;\n  /** 实际处理的截图数量 */\n  screenshot_count: number;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/visionChatResponseModel.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\n/**\n * 实际使用的模型名称\n */\nexport type VisionChatResponseModel = string | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/visionChatResponseUsageInfo.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\n\n/**\n * Token使用信息\n */\nexport type VisionChatResponseUsageInfo = { [key: string]: unknown } | null;\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/schemas/visionChatResponseUsageInfoAnyOf.ts",
    "content": "/**\n * Generated by orval v7.17.2 🍺\n * Do not edit manually.\n * LifeTrace API\n * 智能生活记录系统 API\n * OpenAPI spec version: 0.1.0\n */\n\nexport type VisionChatResponseUsageInfoAnyOf = { [key: string]: unknown };\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/screenshot/screenshot.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport {\n  useQuery\n} from '@tanstack/react-query';\nimport type {\n  DataTag,\n  DefinedInitialDataOptions,\n  DefinedUseQueryResult,\n  QueryClient,\n  QueryFunction,\n  QueryKey,\n  UndefinedInitialDataOptions,\n  UseQueryOptions,\n  UseQueryResult\n} from '@tanstack/react-query';\n\nimport type {\n  GetScreenshotsApiScreenshotsGetParams,\n  HTTPValidationError,\n  ScreenshotResponse\n} from '.././schemas';\n\nimport { customFetcher } from '../../api/fetcher';\n\n\ntype SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];\n\n\n\n/**\n * 获取截图列表\n * @summary Get Screenshots\n */\nexport type getScreenshotsApiScreenshotsGetResponse200 = {\n  data: ScreenshotResponse[]\n  status: 200\n}\n\nexport type getScreenshotsApiScreenshotsGetResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type getScreenshotsApiScreenshotsGetResponseSuccess = (getScreenshotsApiScreenshotsGetResponse200) & {\n  headers: Headers;\n};\nexport type getScreenshotsApiScreenshotsGetResponseError = (getScreenshotsApiScreenshotsGetResponse422) & {\n  headers: Headers;\n};\n\nexport type getScreenshotsApiScreenshotsGetResponse = (getScreenshotsApiScreenshotsGetResponseSuccess | getScreenshotsApiScreenshotsGetResponseError)\n\nexport const getGetScreenshotsApiScreenshotsGetUrl = (params?: GetScreenshotsApiScreenshotsGetParams,) => {\n  const normalizedParams = new URLSearchParams();\n\n  Object.entries(params || {}).forEach(([key, value]) => {\n    \n    if (value !== undefined) {\n      normalizedParams.append(key, value === null ? 'null' : value.toString())\n    }\n  });\n\n  const stringifiedParams = normalizedParams.toString();\n\n  return stringifiedParams.length > 0 ? `/api/screenshots?${stringifiedParams}` : `/api/screenshots`\n}\n\nexport const getScreenshotsApiScreenshotsGet = async (params?: GetScreenshotsApiScreenshotsGetParams, options?: RequestInit): Promise<getScreenshotsApiScreenshotsGetResponse> => {\n  \n  return customFetcher<getScreenshotsApiScreenshotsGetResponse>(getGetScreenshotsApiScreenshotsGetUrl(params),\n  {      \n    ...options,\n    method: 'GET'\n    \n    \n  }\n);}\n\n\n\n\n\nexport const getGetScreenshotsApiScreenshotsGetQueryKey = (params?: GetScreenshotsApiScreenshotsGetParams,) => {\n    return [\n    `/api/screenshots`, ...(params ? [params] : [])\n    ] as const;\n    }\n\n    \nexport const getGetScreenshotsApiScreenshotsGetQueryOptions = <TData = Awaited<ReturnType<typeof getScreenshotsApiScreenshotsGet>>, TError = HTTPValidationError>(params?: GetScreenshotsApiScreenshotsGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getScreenshotsApiScreenshotsGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n) => {\n\nconst {query: queryOptions, request: requestOptions} = options ?? {};\n\n  const queryKey =  queryOptions?.queryKey ?? getGetScreenshotsApiScreenshotsGetQueryKey(params);\n\n  \n\n    const queryFn: QueryFunction<Awaited<ReturnType<typeof getScreenshotsApiScreenshotsGet>>> = ({ signal }) => getScreenshotsApiScreenshotsGet(params, { signal, ...requestOptions });\n\n      \n\n      \n\n   return  { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getScreenshotsApiScreenshotsGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }\n}\n\nexport type GetScreenshotsApiScreenshotsGetQueryResult = NonNullable<Awaited<ReturnType<typeof getScreenshotsApiScreenshotsGet>>>\nexport type GetScreenshotsApiScreenshotsGetQueryError = HTTPValidationError\n\n\nexport function useGetScreenshotsApiScreenshotsGet<TData = Awaited<ReturnType<typeof getScreenshotsApiScreenshotsGet>>, TError = HTTPValidationError>(\n params: undefined |  GetScreenshotsApiScreenshotsGetParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getScreenshotsApiScreenshotsGet>>, TError, TData>> & Pick<\n        DefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getScreenshotsApiScreenshotsGet>>,\n          TError,\n          Awaited<ReturnType<typeof getScreenshotsApiScreenshotsGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetScreenshotsApiScreenshotsGet<TData = Awaited<ReturnType<typeof getScreenshotsApiScreenshotsGet>>, TError = HTTPValidationError>(\n params?: GetScreenshotsApiScreenshotsGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getScreenshotsApiScreenshotsGet>>, TError, TData>> & Pick<\n        UndefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getScreenshotsApiScreenshotsGet>>,\n          TError,\n          Awaited<ReturnType<typeof getScreenshotsApiScreenshotsGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetScreenshotsApiScreenshotsGet<TData = Awaited<ReturnType<typeof getScreenshotsApiScreenshotsGet>>, TError = HTTPValidationError>(\n params?: GetScreenshotsApiScreenshotsGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getScreenshotsApiScreenshotsGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\n/**\n * @summary Get Screenshots\n */\n\nexport function useGetScreenshotsApiScreenshotsGet<TData = Awaited<ReturnType<typeof getScreenshotsApiScreenshotsGet>>, TError = HTTPValidationError>(\n params?: GetScreenshotsApiScreenshotsGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getScreenshotsApiScreenshotsGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient \n ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {\n\n  const queryOptions = getGetScreenshotsApiScreenshotsGetQueryOptions(params,options)\n\n  const query = useQuery(queryOptions, queryClient) as  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };\n\n  return { ...query, queryKey: queryOptions.queryKey };\n}\n\n\n\n\n/**\n * 获取单个截图详情\n * @summary Get Screenshot\n */\nexport type getScreenshotApiScreenshotsScreenshotIdGetResponse200 = {\n  data: unknown\n  status: 200\n}\n\nexport type getScreenshotApiScreenshotsScreenshotIdGetResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type getScreenshotApiScreenshotsScreenshotIdGetResponseSuccess = (getScreenshotApiScreenshotsScreenshotIdGetResponse200) & {\n  headers: Headers;\n};\nexport type getScreenshotApiScreenshotsScreenshotIdGetResponseError = (getScreenshotApiScreenshotsScreenshotIdGetResponse422) & {\n  headers: Headers;\n};\n\nexport type getScreenshotApiScreenshotsScreenshotIdGetResponse = (getScreenshotApiScreenshotsScreenshotIdGetResponseSuccess | getScreenshotApiScreenshotsScreenshotIdGetResponseError)\n\nexport const getGetScreenshotApiScreenshotsScreenshotIdGetUrl = (screenshotId: number,) => {\n\n\n  \n\n  return `/api/screenshots/${screenshotId}`\n}\n\nexport const getScreenshotApiScreenshotsScreenshotIdGet = async (screenshotId: number, options?: RequestInit): Promise<getScreenshotApiScreenshotsScreenshotIdGetResponse> => {\n  \n  return customFetcher<getScreenshotApiScreenshotsScreenshotIdGetResponse>(getGetScreenshotApiScreenshotsScreenshotIdGetUrl(screenshotId),\n  {      \n    ...options,\n    method: 'GET'\n    \n    \n  }\n);}\n\n\n\n\n\nexport const getGetScreenshotApiScreenshotsScreenshotIdGetQueryKey = (screenshotId: number,) => {\n    return [\n    `/api/screenshots/${screenshotId}`\n    ] as const;\n    }\n\n    \nexport const getGetScreenshotApiScreenshotsScreenshotIdGetQueryOptions = <TData = Awaited<ReturnType<typeof getScreenshotApiScreenshotsScreenshotIdGet>>, TError = HTTPValidationError>(screenshotId: number, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getScreenshotApiScreenshotsScreenshotIdGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n) => {\n\nconst {query: queryOptions, request: requestOptions} = options ?? {};\n\n  const queryKey =  queryOptions?.queryKey ?? getGetScreenshotApiScreenshotsScreenshotIdGetQueryKey(screenshotId);\n\n  \n\n    const queryFn: QueryFunction<Awaited<ReturnType<typeof getScreenshotApiScreenshotsScreenshotIdGet>>> = ({ signal }) => getScreenshotApiScreenshotsScreenshotIdGet(screenshotId, { signal, ...requestOptions });\n\n      \n\n      \n\n   return  { queryKey, queryFn, enabled: !!(screenshotId), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getScreenshotApiScreenshotsScreenshotIdGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }\n}\n\nexport type GetScreenshotApiScreenshotsScreenshotIdGetQueryResult = NonNullable<Awaited<ReturnType<typeof getScreenshotApiScreenshotsScreenshotIdGet>>>\nexport type GetScreenshotApiScreenshotsScreenshotIdGetQueryError = HTTPValidationError\n\n\nexport function useGetScreenshotApiScreenshotsScreenshotIdGet<TData = Awaited<ReturnType<typeof getScreenshotApiScreenshotsScreenshotIdGet>>, TError = HTTPValidationError>(\n screenshotId: number, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getScreenshotApiScreenshotsScreenshotIdGet>>, TError, TData>> & Pick<\n        DefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getScreenshotApiScreenshotsScreenshotIdGet>>,\n          TError,\n          Awaited<ReturnType<typeof getScreenshotApiScreenshotsScreenshotIdGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetScreenshotApiScreenshotsScreenshotIdGet<TData = Awaited<ReturnType<typeof getScreenshotApiScreenshotsScreenshotIdGet>>, TError = HTTPValidationError>(\n screenshotId: number, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getScreenshotApiScreenshotsScreenshotIdGet>>, TError, TData>> & Pick<\n        UndefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getScreenshotApiScreenshotsScreenshotIdGet>>,\n          TError,\n          Awaited<ReturnType<typeof getScreenshotApiScreenshotsScreenshotIdGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetScreenshotApiScreenshotsScreenshotIdGet<TData = Awaited<ReturnType<typeof getScreenshotApiScreenshotsScreenshotIdGet>>, TError = HTTPValidationError>(\n screenshotId: number, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getScreenshotApiScreenshotsScreenshotIdGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\n/**\n * @summary Get Screenshot\n */\n\nexport function useGetScreenshotApiScreenshotsScreenshotIdGet<TData = Awaited<ReturnType<typeof getScreenshotApiScreenshotsScreenshotIdGet>>, TError = HTTPValidationError>(\n screenshotId: number, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getScreenshotApiScreenshotsScreenshotIdGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient \n ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {\n\n  const queryOptions = getGetScreenshotApiScreenshotsScreenshotIdGetQueryOptions(screenshotId,options)\n\n  const query = useQuery(queryOptions, queryClient) as  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };\n\n  return { ...query, queryKey: queryOptions.queryKey };\n}\n\n\n\n\n/**\n * 获取截图图片文件\n * @summary Get Screenshot Image\n */\nexport type getScreenshotImageApiScreenshotsScreenshotIdImageGetResponse200 = {\n  data: unknown\n  status: 200\n}\n\nexport type getScreenshotImageApiScreenshotsScreenshotIdImageGetResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type getScreenshotImageApiScreenshotsScreenshotIdImageGetResponseSuccess = (getScreenshotImageApiScreenshotsScreenshotIdImageGetResponse200) & {\n  headers: Headers;\n};\nexport type getScreenshotImageApiScreenshotsScreenshotIdImageGetResponseError = (getScreenshotImageApiScreenshotsScreenshotIdImageGetResponse422) & {\n  headers: Headers;\n};\n\nexport type getScreenshotImageApiScreenshotsScreenshotIdImageGetResponse = (getScreenshotImageApiScreenshotsScreenshotIdImageGetResponseSuccess | getScreenshotImageApiScreenshotsScreenshotIdImageGetResponseError)\n\nexport const getGetScreenshotImageApiScreenshotsScreenshotIdImageGetUrl = (screenshotId: number,) => {\n\n\n  \n\n  return `/api/screenshots/${screenshotId}/image`\n}\n\nexport const getScreenshotImageApiScreenshotsScreenshotIdImageGet = async (screenshotId: number, options?: RequestInit): Promise<getScreenshotImageApiScreenshotsScreenshotIdImageGetResponse> => {\n  \n  return customFetcher<getScreenshotImageApiScreenshotsScreenshotIdImageGetResponse>(getGetScreenshotImageApiScreenshotsScreenshotIdImageGetUrl(screenshotId),\n  {      \n    ...options,\n    method: 'GET'\n    \n    \n  }\n);}\n\n\n\n\n\nexport const getGetScreenshotImageApiScreenshotsScreenshotIdImageGetQueryKey = (screenshotId: number,) => {\n    return [\n    `/api/screenshots/${screenshotId}/image`\n    ] as const;\n    }\n\n    \nexport const getGetScreenshotImageApiScreenshotsScreenshotIdImageGetQueryOptions = <TData = Awaited<ReturnType<typeof getScreenshotImageApiScreenshotsScreenshotIdImageGet>>, TError = HTTPValidationError>(screenshotId: number, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getScreenshotImageApiScreenshotsScreenshotIdImageGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n) => {\n\nconst {query: queryOptions, request: requestOptions} = options ?? {};\n\n  const queryKey =  queryOptions?.queryKey ?? getGetScreenshotImageApiScreenshotsScreenshotIdImageGetQueryKey(screenshotId);\n\n  \n\n    const queryFn: QueryFunction<Awaited<ReturnType<typeof getScreenshotImageApiScreenshotsScreenshotIdImageGet>>> = ({ signal }) => getScreenshotImageApiScreenshotsScreenshotIdImageGet(screenshotId, { signal, ...requestOptions });\n\n      \n\n      \n\n   return  { queryKey, queryFn, enabled: !!(screenshotId), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getScreenshotImageApiScreenshotsScreenshotIdImageGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }\n}\n\nexport type GetScreenshotImageApiScreenshotsScreenshotIdImageGetQueryResult = NonNullable<Awaited<ReturnType<typeof getScreenshotImageApiScreenshotsScreenshotIdImageGet>>>\nexport type GetScreenshotImageApiScreenshotsScreenshotIdImageGetQueryError = HTTPValidationError\n\n\nexport function useGetScreenshotImageApiScreenshotsScreenshotIdImageGet<TData = Awaited<ReturnType<typeof getScreenshotImageApiScreenshotsScreenshotIdImageGet>>, TError = HTTPValidationError>(\n screenshotId: number, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getScreenshotImageApiScreenshotsScreenshotIdImageGet>>, TError, TData>> & Pick<\n        DefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getScreenshotImageApiScreenshotsScreenshotIdImageGet>>,\n          TError,\n          Awaited<ReturnType<typeof getScreenshotImageApiScreenshotsScreenshotIdImageGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetScreenshotImageApiScreenshotsScreenshotIdImageGet<TData = Awaited<ReturnType<typeof getScreenshotImageApiScreenshotsScreenshotIdImageGet>>, TError = HTTPValidationError>(\n screenshotId: number, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getScreenshotImageApiScreenshotsScreenshotIdImageGet>>, TError, TData>> & Pick<\n        UndefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getScreenshotImageApiScreenshotsScreenshotIdImageGet>>,\n          TError,\n          Awaited<ReturnType<typeof getScreenshotImageApiScreenshotsScreenshotIdImageGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetScreenshotImageApiScreenshotsScreenshotIdImageGet<TData = Awaited<ReturnType<typeof getScreenshotImageApiScreenshotsScreenshotIdImageGet>>, TError = HTTPValidationError>(\n screenshotId: number, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getScreenshotImageApiScreenshotsScreenshotIdImageGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\n/**\n * @summary Get Screenshot Image\n */\n\nexport function useGetScreenshotImageApiScreenshotsScreenshotIdImageGet<TData = Awaited<ReturnType<typeof getScreenshotImageApiScreenshotsScreenshotIdImageGet>>, TError = HTTPValidationError>(\n screenshotId: number, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getScreenshotImageApiScreenshotsScreenshotIdImageGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient \n ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {\n\n  const queryOptions = getGetScreenshotImageApiScreenshotsScreenshotIdImageGetQueryOptions(screenshotId,options)\n\n  const query = useQuery(queryOptions, queryClient) as  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };\n\n  return { ...query, queryKey: queryOptions.queryKey };\n}\n\n\n\n\n/**\n * 获取截图文件路径\n * @summary Get Screenshot Path\n */\nexport type getScreenshotPathApiScreenshotsScreenshotIdPathGetResponse200 = {\n  data: unknown\n  status: 200\n}\n\nexport type getScreenshotPathApiScreenshotsScreenshotIdPathGetResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type getScreenshotPathApiScreenshotsScreenshotIdPathGetResponseSuccess = (getScreenshotPathApiScreenshotsScreenshotIdPathGetResponse200) & {\n  headers: Headers;\n};\nexport type getScreenshotPathApiScreenshotsScreenshotIdPathGetResponseError = (getScreenshotPathApiScreenshotsScreenshotIdPathGetResponse422) & {\n  headers: Headers;\n};\n\nexport type getScreenshotPathApiScreenshotsScreenshotIdPathGetResponse = (getScreenshotPathApiScreenshotsScreenshotIdPathGetResponseSuccess | getScreenshotPathApiScreenshotsScreenshotIdPathGetResponseError)\n\nexport const getGetScreenshotPathApiScreenshotsScreenshotIdPathGetUrl = (screenshotId: number,) => {\n\n\n  \n\n  return `/api/screenshots/${screenshotId}/path`\n}\n\nexport const getScreenshotPathApiScreenshotsScreenshotIdPathGet = async (screenshotId: number, options?: RequestInit): Promise<getScreenshotPathApiScreenshotsScreenshotIdPathGetResponse> => {\n  \n  return customFetcher<getScreenshotPathApiScreenshotsScreenshotIdPathGetResponse>(getGetScreenshotPathApiScreenshotsScreenshotIdPathGetUrl(screenshotId),\n  {      \n    ...options,\n    method: 'GET'\n    \n    \n  }\n);}\n\n\n\n\n\nexport const getGetScreenshotPathApiScreenshotsScreenshotIdPathGetQueryKey = (screenshotId: number,) => {\n    return [\n    `/api/screenshots/${screenshotId}/path`\n    ] as const;\n    }\n\n    \nexport const getGetScreenshotPathApiScreenshotsScreenshotIdPathGetQueryOptions = <TData = Awaited<ReturnType<typeof getScreenshotPathApiScreenshotsScreenshotIdPathGet>>, TError = HTTPValidationError>(screenshotId: number, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getScreenshotPathApiScreenshotsScreenshotIdPathGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n) => {\n\nconst {query: queryOptions, request: requestOptions} = options ?? {};\n\n  const queryKey =  queryOptions?.queryKey ?? getGetScreenshotPathApiScreenshotsScreenshotIdPathGetQueryKey(screenshotId);\n\n  \n\n    const queryFn: QueryFunction<Awaited<ReturnType<typeof getScreenshotPathApiScreenshotsScreenshotIdPathGet>>> = ({ signal }) => getScreenshotPathApiScreenshotsScreenshotIdPathGet(screenshotId, { signal, ...requestOptions });\n\n      \n\n      \n\n   return  { queryKey, queryFn, enabled: !!(screenshotId), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getScreenshotPathApiScreenshotsScreenshotIdPathGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }\n}\n\nexport type GetScreenshotPathApiScreenshotsScreenshotIdPathGetQueryResult = NonNullable<Awaited<ReturnType<typeof getScreenshotPathApiScreenshotsScreenshotIdPathGet>>>\nexport type GetScreenshotPathApiScreenshotsScreenshotIdPathGetQueryError = HTTPValidationError\n\n\nexport function useGetScreenshotPathApiScreenshotsScreenshotIdPathGet<TData = Awaited<ReturnType<typeof getScreenshotPathApiScreenshotsScreenshotIdPathGet>>, TError = HTTPValidationError>(\n screenshotId: number, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getScreenshotPathApiScreenshotsScreenshotIdPathGet>>, TError, TData>> & Pick<\n        DefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getScreenshotPathApiScreenshotsScreenshotIdPathGet>>,\n          TError,\n          Awaited<ReturnType<typeof getScreenshotPathApiScreenshotsScreenshotIdPathGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetScreenshotPathApiScreenshotsScreenshotIdPathGet<TData = Awaited<ReturnType<typeof getScreenshotPathApiScreenshotsScreenshotIdPathGet>>, TError = HTTPValidationError>(\n screenshotId: number, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getScreenshotPathApiScreenshotsScreenshotIdPathGet>>, TError, TData>> & Pick<\n        UndefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getScreenshotPathApiScreenshotsScreenshotIdPathGet>>,\n          TError,\n          Awaited<ReturnType<typeof getScreenshotPathApiScreenshotsScreenshotIdPathGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetScreenshotPathApiScreenshotsScreenshotIdPathGet<TData = Awaited<ReturnType<typeof getScreenshotPathApiScreenshotsScreenshotIdPathGet>>, TError = HTTPValidationError>(\n screenshotId: number, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getScreenshotPathApiScreenshotsScreenshotIdPathGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\n/**\n * @summary Get Screenshot Path\n */\n\nexport function useGetScreenshotPathApiScreenshotsScreenshotIdPathGet<TData = Awaited<ReturnType<typeof getScreenshotPathApiScreenshotsScreenshotIdPathGet>>, TError = HTTPValidationError>(\n screenshotId: number, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getScreenshotPathApiScreenshotsScreenshotIdPathGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient \n ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {\n\n  const queryOptions = getGetScreenshotPathApiScreenshotsScreenshotIdPathGetQueryOptions(screenshotId,options)\n\n  const query = useQuery(queryOptions, queryClient) as  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };\n\n  return { ...query, queryKey: queryOptions.queryKey };\n}\n\n\n\n\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/search/search.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport {\n  useMutation\n} from '@tanstack/react-query';\nimport type {\n  MutationFunction,\n  QueryClient,\n  UseMutationOptions,\n  UseMutationResult\n} from '@tanstack/react-query';\n\nimport type {\n  EventResponse,\n  HTTPValidationError,\n  ScreenshotResponse,\n  SearchRequest\n} from '.././schemas';\n\nimport { customFetcher } from '../../api/fetcher';\n\n\ntype SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];\n\n\n\n/**\n * 搜索截图\n * @summary Search Screenshots\n */\nexport type searchScreenshotsApiSearchPostResponse200 = {\n  data: ScreenshotResponse[]\n  status: 200\n}\n\nexport type searchScreenshotsApiSearchPostResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type searchScreenshotsApiSearchPostResponseSuccess = (searchScreenshotsApiSearchPostResponse200) & {\n  headers: Headers;\n};\nexport type searchScreenshotsApiSearchPostResponseError = (searchScreenshotsApiSearchPostResponse422) & {\n  headers: Headers;\n};\n\nexport type searchScreenshotsApiSearchPostResponse = (searchScreenshotsApiSearchPostResponseSuccess | searchScreenshotsApiSearchPostResponseError)\n\nexport const getSearchScreenshotsApiSearchPostUrl = () => {\n\n\n  \n\n  return `/api/search`\n}\n\nexport const searchScreenshotsApiSearchPost = async (searchRequest: SearchRequest, options?: RequestInit): Promise<searchScreenshotsApiSearchPostResponse> => {\n  \n  return customFetcher<searchScreenshotsApiSearchPostResponse>(getSearchScreenshotsApiSearchPostUrl(),\n  {      \n    ...options,\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json', ...options?.headers },\n    body: JSON.stringify(\n      searchRequest,)\n  }\n);}\n\n\n\n\nexport const getSearchScreenshotsApiSearchPostMutationOptions = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof searchScreenshotsApiSearchPost>>, TError,{data: SearchRequest}, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof searchScreenshotsApiSearchPost>>, TError,{data: SearchRequest}, TContext> => {\n\nconst mutationKey = ['searchScreenshotsApiSearchPost'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof searchScreenshotsApiSearchPost>>, {data: SearchRequest}> = (props) => {\n          const {data} = props ?? {};\n\n          return  searchScreenshotsApiSearchPost(data,requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type SearchScreenshotsApiSearchPostMutationResult = NonNullable<Awaited<ReturnType<typeof searchScreenshotsApiSearchPost>>>\n    export type SearchScreenshotsApiSearchPostMutationBody = SearchRequest\n    export type SearchScreenshotsApiSearchPostMutationError = HTTPValidationError\n\n    /**\n * @summary Search Screenshots\n */\nexport const useSearchScreenshotsApiSearchPost = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof searchScreenshotsApiSearchPost>>, TError,{data: SearchRequest}, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof searchScreenshotsApiSearchPost>>,\n        TError,\n        {data: SearchRequest},\n        TContext\n      > => {\n      return useMutation(getSearchScreenshotsApiSearchPostMutationOptions(options), queryClient);\n    }\n    /**\n * 事件级简单文本搜索：按OCR分组后返回事件摘要\n * @summary Search Events\n */\nexport type searchEventsApiEventSearchPostResponse200 = {\n  data: EventResponse[]\n  status: 200\n}\n\nexport type searchEventsApiEventSearchPostResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type searchEventsApiEventSearchPostResponseSuccess = (searchEventsApiEventSearchPostResponse200) & {\n  headers: Headers;\n};\nexport type searchEventsApiEventSearchPostResponseError = (searchEventsApiEventSearchPostResponse422) & {\n  headers: Headers;\n};\n\nexport type searchEventsApiEventSearchPostResponse = (searchEventsApiEventSearchPostResponseSuccess | searchEventsApiEventSearchPostResponseError)\n\nexport const getSearchEventsApiEventSearchPostUrl = () => {\n\n\n  \n\n  return `/api/event-search`\n}\n\nexport const searchEventsApiEventSearchPost = async (searchRequest: SearchRequest, options?: RequestInit): Promise<searchEventsApiEventSearchPostResponse> => {\n  \n  return customFetcher<searchEventsApiEventSearchPostResponse>(getSearchEventsApiEventSearchPostUrl(),\n  {      \n    ...options,\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json', ...options?.headers },\n    body: JSON.stringify(\n      searchRequest,)\n  }\n);}\n\n\n\n\nexport const getSearchEventsApiEventSearchPostMutationOptions = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof searchEventsApiEventSearchPost>>, TError,{data: SearchRequest}, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof searchEventsApiEventSearchPost>>, TError,{data: SearchRequest}, TContext> => {\n\nconst mutationKey = ['searchEventsApiEventSearchPost'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof searchEventsApiEventSearchPost>>, {data: SearchRequest}> = (props) => {\n          const {data} = props ?? {};\n\n          return  searchEventsApiEventSearchPost(data,requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type SearchEventsApiEventSearchPostMutationResult = NonNullable<Awaited<ReturnType<typeof searchEventsApiEventSearchPost>>>\n    export type SearchEventsApiEventSearchPostMutationBody = SearchRequest\n    export type SearchEventsApiEventSearchPostMutationError = HTTPValidationError\n\n    /**\n * @summary Search Events\n */\nexport const useSearchEventsApiEventSearchPost = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof searchEventsApiEventSearchPost>>, TError,{data: SearchRequest}, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof searchEventsApiEventSearchPost>>,\n        TError,\n        {data: SearchRequest},\n        TContext\n      > => {\n      return useMutation(getSearchEventsApiEventSearchPostMutationOptions(options), queryClient);\n    }\n    "
  },
  {
    "path": "free-todo-frontend/lib/generated/system/system.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport {\n  useMutation,\n  useQuery\n} from '@tanstack/react-query';\nimport type {\n  DataTag,\n  DefinedInitialDataOptions,\n  DefinedUseQueryResult,\n  MutationFunction,\n  QueryClient,\n  QueryFunction,\n  QueryKey,\n  UndefinedInitialDataOptions,\n  UseMutationOptions,\n  UseMutationResult,\n  UseQueryOptions,\n  UseQueryResult\n} from '@tanstack/react-query';\n\nimport type {\n  CapabilitiesResponse,\n  CleanupOldDataApiCleanupPostParams,\n  HTTPValidationError,\n  StatisticsResponse,\n  SystemResourcesResponse\n} from '.././schemas';\n\nimport { customFetcher } from '../../api/fetcher';\n\n\ntype SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];\n\n\n\n/**\n * 获取系统统计信息\n * @summary Get Statistics\n */\nexport type getStatisticsApiStatisticsGetResponse200 = {\n  data: StatisticsResponse\n  status: 200\n}\n    \nexport type getStatisticsApiStatisticsGetResponseSuccess = (getStatisticsApiStatisticsGetResponse200) & {\n  headers: Headers;\n};\n;\n\nexport type getStatisticsApiStatisticsGetResponse = (getStatisticsApiStatisticsGetResponseSuccess)\n\nexport const getGetStatisticsApiStatisticsGetUrl = () => {\n\n\n  \n\n  return `/api/statistics`\n}\n\nexport const getStatisticsApiStatisticsGet = async ( options?: RequestInit): Promise<getStatisticsApiStatisticsGetResponse> => {\n  \n  return customFetcher<getStatisticsApiStatisticsGetResponse>(getGetStatisticsApiStatisticsGetUrl(),\n  {      \n    ...options,\n    method: 'GET'\n    \n    \n  }\n);}\n\n\n\n\n\nexport const getGetStatisticsApiStatisticsGetQueryKey = () => {\n    return [\n    `/api/statistics`\n    ] as const;\n    }\n\n    \nexport const getGetStatisticsApiStatisticsGetQueryOptions = <TData = Awaited<ReturnType<typeof getStatisticsApiStatisticsGet>>, TError = unknown>( options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getStatisticsApiStatisticsGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n) => {\n\nconst {query: queryOptions, request: requestOptions} = options ?? {};\n\n  const queryKey =  queryOptions?.queryKey ?? getGetStatisticsApiStatisticsGetQueryKey();\n\n  \n\n    const queryFn: QueryFunction<Awaited<ReturnType<typeof getStatisticsApiStatisticsGet>>> = ({ signal }) => getStatisticsApiStatisticsGet({ signal, ...requestOptions });\n\n      \n\n      \n\n   return  { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getStatisticsApiStatisticsGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }\n}\n\nexport type GetStatisticsApiStatisticsGetQueryResult = NonNullable<Awaited<ReturnType<typeof getStatisticsApiStatisticsGet>>>\nexport type GetStatisticsApiStatisticsGetQueryError = unknown\n\n\nexport function useGetStatisticsApiStatisticsGet<TData = Awaited<ReturnType<typeof getStatisticsApiStatisticsGet>>, TError = unknown>(\n  options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getStatisticsApiStatisticsGet>>, TError, TData>> & Pick<\n        DefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getStatisticsApiStatisticsGet>>,\n          TError,\n          Awaited<ReturnType<typeof getStatisticsApiStatisticsGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetStatisticsApiStatisticsGet<TData = Awaited<ReturnType<typeof getStatisticsApiStatisticsGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getStatisticsApiStatisticsGet>>, TError, TData>> & Pick<\n        UndefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getStatisticsApiStatisticsGet>>,\n          TError,\n          Awaited<ReturnType<typeof getStatisticsApiStatisticsGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetStatisticsApiStatisticsGet<TData = Awaited<ReturnType<typeof getStatisticsApiStatisticsGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getStatisticsApiStatisticsGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\n/**\n * @summary Get Statistics\n */\n\nexport function useGetStatisticsApiStatisticsGet<TData = Awaited<ReturnType<typeof getStatisticsApiStatisticsGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getStatisticsApiStatisticsGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient \n ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {\n\n  const queryOptions = getGetStatisticsApiStatisticsGetQueryOptions(options)\n\n  const query = useQuery(queryOptions, queryClient) as  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };\n\n  return { ...query, queryKey: queryOptions.queryKey };\n}\n\n\n\n\n/**\n * 清理旧数据\n * @summary Cleanup Old Data\n */\nexport type cleanupOldDataApiCleanupPostResponse200 = {\n  data: unknown\n  status: 200\n}\n\nexport type cleanupOldDataApiCleanupPostResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type cleanupOldDataApiCleanupPostResponseSuccess = (cleanupOldDataApiCleanupPostResponse200) & {\n  headers: Headers;\n};\nexport type cleanupOldDataApiCleanupPostResponseError = (cleanupOldDataApiCleanupPostResponse422) & {\n  headers: Headers;\n};\n\nexport type cleanupOldDataApiCleanupPostResponse = (cleanupOldDataApiCleanupPostResponseSuccess | cleanupOldDataApiCleanupPostResponseError)\n\nexport const getCleanupOldDataApiCleanupPostUrl = (params?: CleanupOldDataApiCleanupPostParams,) => {\n  const normalizedParams = new URLSearchParams();\n\n  Object.entries(params || {}).forEach(([key, value]) => {\n    \n    if (value !== undefined) {\n      normalizedParams.append(key, value === null ? 'null' : value.toString())\n    }\n  });\n\n  const stringifiedParams = normalizedParams.toString();\n\n  return stringifiedParams.length > 0 ? `/api/cleanup?${stringifiedParams}` : `/api/cleanup`\n}\n\nexport const cleanupOldDataApiCleanupPost = async (params?: CleanupOldDataApiCleanupPostParams, options?: RequestInit): Promise<cleanupOldDataApiCleanupPostResponse> => {\n  \n  return customFetcher<cleanupOldDataApiCleanupPostResponse>(getCleanupOldDataApiCleanupPostUrl(params),\n  {      \n    ...options,\n    method: 'POST'\n    \n    \n  }\n);}\n\n\n\n\nexport const getCleanupOldDataApiCleanupPostMutationOptions = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof cleanupOldDataApiCleanupPost>>, TError,{params?: CleanupOldDataApiCleanupPostParams}, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof cleanupOldDataApiCleanupPost>>, TError,{params?: CleanupOldDataApiCleanupPostParams}, TContext> => {\n\nconst mutationKey = ['cleanupOldDataApiCleanupPost'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof cleanupOldDataApiCleanupPost>>, {params?: CleanupOldDataApiCleanupPostParams}> = (props) => {\n          const {params} = props ?? {};\n\n          return  cleanupOldDataApiCleanupPost(params,requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type CleanupOldDataApiCleanupPostMutationResult = NonNullable<Awaited<ReturnType<typeof cleanupOldDataApiCleanupPost>>>\n    \n    export type CleanupOldDataApiCleanupPostMutationError = HTTPValidationError\n\n    /**\n * @summary Cleanup Old Data\n */\nexport const useCleanupOldDataApiCleanupPost = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof cleanupOldDataApiCleanupPost>>, TError,{params?: CleanupOldDataApiCleanupPostParams}, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof cleanupOldDataApiCleanupPost>>,\n        TError,\n        {params?: CleanupOldDataApiCleanupPostParams},\n        TContext\n      > => {\n      return useMutation(getCleanupOldDataApiCleanupPostMutationOptions(options), queryClient);\n    }\n    /**\n * 获取系统资源使用情况\n * @summary Get System Resources\n */\nexport type getSystemResourcesApiSystemResourcesGetResponse200 = {\n  data: SystemResourcesResponse\n  status: 200\n}\n    \nexport type getSystemResourcesApiSystemResourcesGetResponseSuccess = (getSystemResourcesApiSystemResourcesGetResponse200) & {\n  headers: Headers;\n};\n;\n\nexport type getSystemResourcesApiSystemResourcesGetResponse = (getSystemResourcesApiSystemResourcesGetResponseSuccess)\n\nexport const getGetSystemResourcesApiSystemResourcesGetUrl = () => {\n\n\n  \n\n  return `/api/system-resources`\n}\n\nexport const getSystemResourcesApiSystemResourcesGet = async ( options?: RequestInit): Promise<getSystemResourcesApiSystemResourcesGetResponse> => {\n  \n  return customFetcher<getSystemResourcesApiSystemResourcesGetResponse>(getGetSystemResourcesApiSystemResourcesGetUrl(),\n  {      \n    ...options,\n    method: 'GET'\n    \n    \n  }\n);}\n\n\n\n\n\nexport const getGetSystemResourcesApiSystemResourcesGetQueryKey = () => {\n    return [\n    `/api/system-resources`\n    ] as const;\n    }\n\n    \nexport const getGetSystemResourcesApiSystemResourcesGetQueryOptions = <TData = Awaited<ReturnType<typeof getSystemResourcesApiSystemResourcesGet>>, TError = unknown>( options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSystemResourcesApiSystemResourcesGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n) => {\n\nconst {query: queryOptions, request: requestOptions} = options ?? {};\n\n  const queryKey =  queryOptions?.queryKey ?? getGetSystemResourcesApiSystemResourcesGetQueryKey();\n\n  \n\n    const queryFn: QueryFunction<Awaited<ReturnType<typeof getSystemResourcesApiSystemResourcesGet>>> = ({ signal }) => getSystemResourcesApiSystemResourcesGet({ signal, ...requestOptions });\n\n      \n\n      \n\n   return  { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getSystemResourcesApiSystemResourcesGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }\n}\n\nexport type GetSystemResourcesApiSystemResourcesGetQueryResult = NonNullable<Awaited<ReturnType<typeof getSystemResourcesApiSystemResourcesGet>>>\nexport type GetSystemResourcesApiSystemResourcesGetQueryError = unknown\n\n\nexport function useGetSystemResourcesApiSystemResourcesGet<TData = Awaited<ReturnType<typeof getSystemResourcesApiSystemResourcesGet>>, TError = unknown>(\n  options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSystemResourcesApiSystemResourcesGet>>, TError, TData>> & Pick<\n        DefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getSystemResourcesApiSystemResourcesGet>>,\n          TError,\n          Awaited<ReturnType<typeof getSystemResourcesApiSystemResourcesGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetSystemResourcesApiSystemResourcesGet<TData = Awaited<ReturnType<typeof getSystemResourcesApiSystemResourcesGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSystemResourcesApiSystemResourcesGet>>, TError, TData>> & Pick<\n        UndefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getSystemResourcesApiSystemResourcesGet>>,\n          TError,\n          Awaited<ReturnType<typeof getSystemResourcesApiSystemResourcesGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetSystemResourcesApiSystemResourcesGet<TData = Awaited<ReturnType<typeof getSystemResourcesApiSystemResourcesGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSystemResourcesApiSystemResourcesGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\n/**\n * @summary Get System Resources\n */\n\nexport function useGetSystemResourcesApiSystemResourcesGet<TData = Awaited<ReturnType<typeof getSystemResourcesApiSystemResourcesGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSystemResourcesApiSystemResourcesGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient \n ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {\n\n  const queryOptions = getGetSystemResourcesApiSystemResourcesGetQueryOptions(options)\n\n  const query = useQuery(queryOptions, queryClient) as  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };\n\n  return { ...query, queryKey: queryOptions.queryKey };\n}\n\n\n\n\n/**\n * 获取后端模块能力状态\n * @summary Get Capabilities\n */\nexport type getCapabilitiesApiCapabilitiesGetResponse200 = {\n  data: CapabilitiesResponse\n  status: 200\n}\n    \nexport type getCapabilitiesApiCapabilitiesGetResponseSuccess = (getCapabilitiesApiCapabilitiesGetResponse200) & {\n  headers: Headers;\n};\n;\n\nexport type getCapabilitiesApiCapabilitiesGetResponse = (getCapabilitiesApiCapabilitiesGetResponseSuccess)\n\nexport const getGetCapabilitiesApiCapabilitiesGetUrl = () => {\n\n\n  \n\n  return `/api/capabilities`\n}\n\nexport const getCapabilitiesApiCapabilitiesGet = async ( options?: RequestInit): Promise<getCapabilitiesApiCapabilitiesGetResponse> => {\n  \n  return customFetcher<getCapabilitiesApiCapabilitiesGetResponse>(getGetCapabilitiesApiCapabilitiesGetUrl(),\n  {      \n    ...options,\n    method: 'GET'\n    \n    \n  }\n);}\n\n\n\n\n\nexport const getGetCapabilitiesApiCapabilitiesGetQueryKey = () => {\n    return [\n    `/api/capabilities`\n    ] as const;\n    }\n\n    \nexport const getGetCapabilitiesApiCapabilitiesGetQueryOptions = <TData = Awaited<ReturnType<typeof getCapabilitiesApiCapabilitiesGet>>, TError = unknown>( options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getCapabilitiesApiCapabilitiesGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n) => {\n\nconst {query: queryOptions, request: requestOptions} = options ?? {};\n\n  const queryKey =  queryOptions?.queryKey ?? getGetCapabilitiesApiCapabilitiesGetQueryKey();\n\n  \n\n    const queryFn: QueryFunction<Awaited<ReturnType<typeof getCapabilitiesApiCapabilitiesGet>>> = ({ signal }) => getCapabilitiesApiCapabilitiesGet({ signal, ...requestOptions });\n\n      \n\n      \n\n   return  { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getCapabilitiesApiCapabilitiesGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }\n}\n\nexport type GetCapabilitiesApiCapabilitiesGetQueryResult = NonNullable<Awaited<ReturnType<typeof getCapabilitiesApiCapabilitiesGet>>>\nexport type GetCapabilitiesApiCapabilitiesGetQueryError = unknown\n\n\nexport function useGetCapabilitiesApiCapabilitiesGet<TData = Awaited<ReturnType<typeof getCapabilitiesApiCapabilitiesGet>>, TError = unknown>(\n  options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getCapabilitiesApiCapabilitiesGet>>, TError, TData>> & Pick<\n        DefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getCapabilitiesApiCapabilitiesGet>>,\n          TError,\n          Awaited<ReturnType<typeof getCapabilitiesApiCapabilitiesGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetCapabilitiesApiCapabilitiesGet<TData = Awaited<ReturnType<typeof getCapabilitiesApiCapabilitiesGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getCapabilitiesApiCapabilitiesGet>>, TError, TData>> & Pick<\n        UndefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getCapabilitiesApiCapabilitiesGet>>,\n          TError,\n          Awaited<ReturnType<typeof getCapabilitiesApiCapabilitiesGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetCapabilitiesApiCapabilitiesGet<TData = Awaited<ReturnType<typeof getCapabilitiesApiCapabilitiesGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getCapabilitiesApiCapabilitiesGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\n/**\n * @summary Get Capabilities\n */\n\nexport function useGetCapabilitiesApiCapabilitiesGet<TData = Awaited<ReturnType<typeof getCapabilitiesApiCapabilitiesGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getCapabilitiesApiCapabilitiesGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient \n ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {\n\n  const queryOptions = getGetCapabilitiesApiCapabilitiesGetQueryOptions(options)\n\n  const query = useQuery(queryOptions, queryClient) as  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };\n\n  return { ...query, queryKey: queryOptions.queryKey };\n}\n\n\n\n\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/time-allocation/time-allocation.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport {\n  useQuery\n} from '@tanstack/react-query';\nimport type {\n  DataTag,\n  DefinedInitialDataOptions,\n  DefinedUseQueryResult,\n  QueryClient,\n  QueryFunction,\n  QueryKey,\n  UndefinedInitialDataOptions,\n  UseQueryOptions,\n  UseQueryResult\n} from '@tanstack/react-query';\n\nimport type {\n  GetTimeAllocationApiTimeAllocationGetParams,\n  HTTPValidationError,\n  TimeAllocationResponse\n} from '.././schemas';\n\nimport { customFetcher } from '../../api/fetcher';\n\n\ntype SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];\n\n\n\n/**\n * 获取时间分配数据（支持日期区间或天数）\n * @summary Get Time Allocation\n */\nexport type getTimeAllocationApiTimeAllocationGetResponse200 = {\n  data: TimeAllocationResponse\n  status: 200\n}\n\nexport type getTimeAllocationApiTimeAllocationGetResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type getTimeAllocationApiTimeAllocationGetResponseSuccess = (getTimeAllocationApiTimeAllocationGetResponse200) & {\n  headers: Headers;\n};\nexport type getTimeAllocationApiTimeAllocationGetResponseError = (getTimeAllocationApiTimeAllocationGetResponse422) & {\n  headers: Headers;\n};\n\nexport type getTimeAllocationApiTimeAllocationGetResponse = (getTimeAllocationApiTimeAllocationGetResponseSuccess | getTimeAllocationApiTimeAllocationGetResponseError)\n\nexport const getGetTimeAllocationApiTimeAllocationGetUrl = (params?: GetTimeAllocationApiTimeAllocationGetParams,) => {\n  const normalizedParams = new URLSearchParams();\n\n  Object.entries(params || {}).forEach(([key, value]) => {\n    \n    if (value !== undefined) {\n      normalizedParams.append(key, value === null ? 'null' : value.toString())\n    }\n  });\n\n  const stringifiedParams = normalizedParams.toString();\n\n  return stringifiedParams.length > 0 ? `/api/time-allocation?${stringifiedParams}` : `/api/time-allocation`\n}\n\nexport const getTimeAllocationApiTimeAllocationGet = async (params?: GetTimeAllocationApiTimeAllocationGetParams, options?: RequestInit): Promise<getTimeAllocationApiTimeAllocationGetResponse> => {\n  \n  return customFetcher<getTimeAllocationApiTimeAllocationGetResponse>(getGetTimeAllocationApiTimeAllocationGetUrl(params),\n  {      \n    ...options,\n    method: 'GET'\n    \n    \n  }\n);}\n\n\n\n\n\nexport const getGetTimeAllocationApiTimeAllocationGetQueryKey = (params?: GetTimeAllocationApiTimeAllocationGetParams,) => {\n    return [\n    `/api/time-allocation`, ...(params ? [params] : [])\n    ] as const;\n    }\n\n    \nexport const getGetTimeAllocationApiTimeAllocationGetQueryOptions = <TData = Awaited<ReturnType<typeof getTimeAllocationApiTimeAllocationGet>>, TError = HTTPValidationError>(params?: GetTimeAllocationApiTimeAllocationGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getTimeAllocationApiTimeAllocationGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n) => {\n\nconst {query: queryOptions, request: requestOptions} = options ?? {};\n\n  const queryKey =  queryOptions?.queryKey ?? getGetTimeAllocationApiTimeAllocationGetQueryKey(params);\n\n  \n\n    const queryFn: QueryFunction<Awaited<ReturnType<typeof getTimeAllocationApiTimeAllocationGet>>> = ({ signal }) => getTimeAllocationApiTimeAllocationGet(params, { signal, ...requestOptions });\n\n      \n\n      \n\n   return  { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getTimeAllocationApiTimeAllocationGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }\n}\n\nexport type GetTimeAllocationApiTimeAllocationGetQueryResult = NonNullable<Awaited<ReturnType<typeof getTimeAllocationApiTimeAllocationGet>>>\nexport type GetTimeAllocationApiTimeAllocationGetQueryError = HTTPValidationError\n\n\nexport function useGetTimeAllocationApiTimeAllocationGet<TData = Awaited<ReturnType<typeof getTimeAllocationApiTimeAllocationGet>>, TError = HTTPValidationError>(\n params: undefined |  GetTimeAllocationApiTimeAllocationGetParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getTimeAllocationApiTimeAllocationGet>>, TError, TData>> & Pick<\n        DefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getTimeAllocationApiTimeAllocationGet>>,\n          TError,\n          Awaited<ReturnType<typeof getTimeAllocationApiTimeAllocationGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetTimeAllocationApiTimeAllocationGet<TData = Awaited<ReturnType<typeof getTimeAllocationApiTimeAllocationGet>>, TError = HTTPValidationError>(\n params?: GetTimeAllocationApiTimeAllocationGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getTimeAllocationApiTimeAllocationGet>>, TError, TData>> & Pick<\n        UndefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getTimeAllocationApiTimeAllocationGet>>,\n          TError,\n          Awaited<ReturnType<typeof getTimeAllocationApiTimeAllocationGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetTimeAllocationApiTimeAllocationGet<TData = Awaited<ReturnType<typeof getTimeAllocationApiTimeAllocationGet>>, TError = HTTPValidationError>(\n params?: GetTimeAllocationApiTimeAllocationGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getTimeAllocationApiTimeAllocationGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\n/**\n * @summary Get Time Allocation\n */\n\nexport function useGetTimeAllocationApiTimeAllocationGet<TData = Awaited<ReturnType<typeof getTimeAllocationApiTimeAllocationGet>>, TError = HTTPValidationError>(\n params?: GetTimeAllocationApiTimeAllocationGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getTimeAllocationApiTimeAllocationGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient \n ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {\n\n  const queryOptions = getGetTimeAllocationApiTimeAllocationGetQueryOptions(params,options)\n\n  const query = useQuery(queryOptions, queryClient) as  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };\n\n  return { ...query, queryKey: queryOptions.queryKey };\n}\n\n\n\n\n"
  },
  {
    "path": "free-todo-frontend/lib/generated/todo-extraction/todo-extraction.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport {\n  useMutation\n} from '@tanstack/react-query';\nimport type {\n  MutationFunction,\n  QueryClient,\n  UseMutationOptions,\n  UseMutationResult\n} from '@tanstack/react-query';\n\nimport type {\n  HTTPValidationError,\n  TodoExtractionRequest,\n  TodoExtractionResponse\n} from '.././schemas';\n\nimport { customFetcher } from '../../api/fetcher';\n\n\ntype SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];\n\n\n\n/**\n * 从事件中提取待办事项\n\n针对白名单应用（微信、飞书等）的事件，使用多模态大模型分析截图，\n提取用户承诺的待办事项，特别是带时间信息的待办。\n\nArgs:\n    request: 待办提取请求，包含事件ID和可选的截图采样比例\n\nReturns:\n    待办提取响应，包含提取的待办列表和元信息\n\nRaises:\n    HTTPException: 当请求参数无效或提取失败时\n * @summary Extract Todos From Event\n */\nexport type extractTodosFromEventApiTodoExtractionExtractPostResponse200 = {\n  data: TodoExtractionResponse\n  status: 200\n}\n\nexport type extractTodosFromEventApiTodoExtractionExtractPostResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type extractTodosFromEventApiTodoExtractionExtractPostResponseSuccess = (extractTodosFromEventApiTodoExtractionExtractPostResponse200) & {\n  headers: Headers;\n};\nexport type extractTodosFromEventApiTodoExtractionExtractPostResponseError = (extractTodosFromEventApiTodoExtractionExtractPostResponse422) & {\n  headers: Headers;\n};\n\nexport type extractTodosFromEventApiTodoExtractionExtractPostResponse = (extractTodosFromEventApiTodoExtractionExtractPostResponseSuccess | extractTodosFromEventApiTodoExtractionExtractPostResponseError)\n\nexport const getExtractTodosFromEventApiTodoExtractionExtractPostUrl = () => {\n\n\n  \n\n  return `/api/todo-extraction/extract`\n}\n\nexport const extractTodosFromEventApiTodoExtractionExtractPost = async (todoExtractionRequest: TodoExtractionRequest, options?: RequestInit): Promise<extractTodosFromEventApiTodoExtractionExtractPostResponse> => {\n  \n  return customFetcher<extractTodosFromEventApiTodoExtractionExtractPostResponse>(getExtractTodosFromEventApiTodoExtractionExtractPostUrl(),\n  {      \n    ...options,\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json', ...options?.headers },\n    body: JSON.stringify(\n      todoExtractionRequest,)\n  }\n);}\n\n\n\n\nexport const getExtractTodosFromEventApiTodoExtractionExtractPostMutationOptions = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof extractTodosFromEventApiTodoExtractionExtractPost>>, TError,{data: TodoExtractionRequest}, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof extractTodosFromEventApiTodoExtractionExtractPost>>, TError,{data: TodoExtractionRequest}, TContext> => {\n\nconst mutationKey = ['extractTodosFromEventApiTodoExtractionExtractPost'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof extractTodosFromEventApiTodoExtractionExtractPost>>, {data: TodoExtractionRequest}> = (props) => {\n          const {data} = props ?? {};\n\n          return  extractTodosFromEventApiTodoExtractionExtractPost(data,requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type ExtractTodosFromEventApiTodoExtractionExtractPostMutationResult = NonNullable<Awaited<ReturnType<typeof extractTodosFromEventApiTodoExtractionExtractPost>>>\n    export type ExtractTodosFromEventApiTodoExtractionExtractPostMutationBody = TodoExtractionRequest\n    export type ExtractTodosFromEventApiTodoExtractionExtractPostMutationError = HTTPValidationError\n\n    /**\n * @summary Extract Todos From Event\n */\nexport const useExtractTodosFromEventApiTodoExtractionExtractPost = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof extractTodosFromEventApiTodoExtractionExtractPost>>, TError,{data: TodoExtractionRequest}, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof extractTodosFromEventApiTodoExtractionExtractPost>>,\n        TError,\n        {data: TodoExtractionRequest},\n        TContext\n      > => {\n      return useMutation(getExtractTodosFromEventApiTodoExtractionExtractPostMutationOptions(options), queryClient);\n    }\n    "
  },
  {
    "path": "free-todo-frontend/lib/generated/todos/todos.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport {\n  useMutation,\n  useQuery\n} from '@tanstack/react-query';\nimport type {\n  DataTag,\n  DefinedInitialDataOptions,\n  DefinedUseQueryResult,\n  MutationFunction,\n  QueryClient,\n  QueryFunction,\n  QueryKey,\n  UndefinedInitialDataOptions,\n  UseMutationOptions,\n  UseMutationResult,\n  UseQueryOptions,\n  UseQueryResult\n} from '@tanstack/react-query';\n\nimport type {\n  BodyImportIcsApiTodosImportIcsPost,\n  BodyUploadAttachmentsApiTodosTodoIdAttachmentsPost,\n  ExportIcsApiTodosExportIcsGetParams,\n  HTTPValidationError,\n  ListTodosApiTodosGetParams,\n  TodoAttachmentResponse,\n  TodoCreate,\n  TodoListResponse,\n  TodoReorderRequest,\n  TodoResponse,\n  TodoUpdate\n} from '.././schemas';\n\nimport { customFetcher } from '../../api/fetcher';\n\n\ntype SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];\n\n\n\n/**\n * 获取待办列表\n * @summary List Todos\n */\nexport type listTodosApiTodosGetResponse200 = {\n  data: TodoListResponse\n  status: 200\n}\n\nexport type listTodosApiTodosGetResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type listTodosApiTodosGetResponseSuccess = (listTodosApiTodosGetResponse200) & {\n  headers: Headers;\n};\nexport type listTodosApiTodosGetResponseError = (listTodosApiTodosGetResponse422) & {\n  headers: Headers;\n};\n\nexport type listTodosApiTodosGetResponse = (listTodosApiTodosGetResponseSuccess | listTodosApiTodosGetResponseError)\n\nexport const getListTodosApiTodosGetUrl = (params?: ListTodosApiTodosGetParams,) => {\n  const normalizedParams = new URLSearchParams();\n\n  Object.entries(params || {}).forEach(([key, value]) => {\n    \n    if (value !== undefined) {\n      normalizedParams.append(key, value === null ? 'null' : value.toString())\n    }\n  });\n\n  const stringifiedParams = normalizedParams.toString();\n\n  return stringifiedParams.length > 0 ? `/api/todos?${stringifiedParams}` : `/api/todos`\n}\n\nexport const listTodosApiTodosGet = async (params?: ListTodosApiTodosGetParams, options?: RequestInit): Promise<listTodosApiTodosGetResponse> => {\n  \n  return customFetcher<listTodosApiTodosGetResponse>(getListTodosApiTodosGetUrl(params),\n  {      \n    ...options,\n    method: 'GET'\n    \n    \n  }\n);}\n\n\n\n\n\nexport const getListTodosApiTodosGetQueryKey = (params?: ListTodosApiTodosGetParams,) => {\n    return [\n    `/api/todos`, ...(params ? [params] : [])\n    ] as const;\n    }\n\n    \nexport const getListTodosApiTodosGetQueryOptions = <TData = Awaited<ReturnType<typeof listTodosApiTodosGet>>, TError = HTTPValidationError>(params?: ListTodosApiTodosGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listTodosApiTodosGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n) => {\n\nconst {query: queryOptions, request: requestOptions} = options ?? {};\n\n  const queryKey =  queryOptions?.queryKey ?? getListTodosApiTodosGetQueryKey(params);\n\n  \n\n    const queryFn: QueryFunction<Awaited<ReturnType<typeof listTodosApiTodosGet>>> = ({ signal }) => listTodosApiTodosGet(params, { signal, ...requestOptions });\n\n      \n\n      \n\n   return  { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof listTodosApiTodosGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }\n}\n\nexport type ListTodosApiTodosGetQueryResult = NonNullable<Awaited<ReturnType<typeof listTodosApiTodosGet>>>\nexport type ListTodosApiTodosGetQueryError = HTTPValidationError\n\n\nexport function useListTodosApiTodosGet<TData = Awaited<ReturnType<typeof listTodosApiTodosGet>>, TError = HTTPValidationError>(\n params: undefined |  ListTodosApiTodosGetParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof listTodosApiTodosGet>>, TError, TData>> & Pick<\n        DefinedInitialDataOptions<\n          Awaited<ReturnType<typeof listTodosApiTodosGet>>,\n          TError,\n          Awaited<ReturnType<typeof listTodosApiTodosGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useListTodosApiTodosGet<TData = Awaited<ReturnType<typeof listTodosApiTodosGet>>, TError = HTTPValidationError>(\n params?: ListTodosApiTodosGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listTodosApiTodosGet>>, TError, TData>> & Pick<\n        UndefinedInitialDataOptions<\n          Awaited<ReturnType<typeof listTodosApiTodosGet>>,\n          TError,\n          Awaited<ReturnType<typeof listTodosApiTodosGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useListTodosApiTodosGet<TData = Awaited<ReturnType<typeof listTodosApiTodosGet>>, TError = HTTPValidationError>(\n params?: ListTodosApiTodosGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listTodosApiTodosGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\n/**\n * @summary List Todos\n */\n\nexport function useListTodosApiTodosGet<TData = Awaited<ReturnType<typeof listTodosApiTodosGet>>, TError = HTTPValidationError>(\n params?: ListTodosApiTodosGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listTodosApiTodosGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient \n ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {\n\n  const queryOptions = getListTodosApiTodosGetQueryOptions(params,options)\n\n  const query = useQuery(queryOptions, queryClient) as  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };\n\n  return { ...query, queryKey: queryOptions.queryKey };\n}\n\n\n\n\n/**\n * 创建待办\n * @summary Create Todo\n */\nexport type createTodoApiTodosPostResponse201 = {\n  data: TodoResponse\n  status: 201\n}\n\nexport type createTodoApiTodosPostResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type createTodoApiTodosPostResponseSuccess = (createTodoApiTodosPostResponse201) & {\n  headers: Headers;\n};\nexport type createTodoApiTodosPostResponseError = (createTodoApiTodosPostResponse422) & {\n  headers: Headers;\n};\n\nexport type createTodoApiTodosPostResponse = (createTodoApiTodosPostResponseSuccess | createTodoApiTodosPostResponseError)\n\nexport const getCreateTodoApiTodosPostUrl = () => {\n\n\n  \n\n  return `/api/todos`\n}\n\nexport const createTodoApiTodosPost = async (todoCreate: TodoCreate, options?: RequestInit): Promise<createTodoApiTodosPostResponse> => {\n  \n  return customFetcher<createTodoApiTodosPostResponse>(getCreateTodoApiTodosPostUrl(),\n  {      \n    ...options,\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json', ...options?.headers },\n    body: JSON.stringify(\n      todoCreate,)\n  }\n);}\n\n\n\n\nexport const getCreateTodoApiTodosPostMutationOptions = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof createTodoApiTodosPost>>, TError,{data: TodoCreate}, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof createTodoApiTodosPost>>, TError,{data: TodoCreate}, TContext> => {\n\nconst mutationKey = ['createTodoApiTodosPost'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof createTodoApiTodosPost>>, {data: TodoCreate}> = (props) => {\n          const {data} = props ?? {};\n\n          return  createTodoApiTodosPost(data,requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type CreateTodoApiTodosPostMutationResult = NonNullable<Awaited<ReturnType<typeof createTodoApiTodosPost>>>\n    export type CreateTodoApiTodosPostMutationBody = TodoCreate\n    export type CreateTodoApiTodosPostMutationError = HTTPValidationError\n\n    /**\n * @summary Create Todo\n */\nexport const useCreateTodoApiTodosPost = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof createTodoApiTodosPost>>, TError,{data: TodoCreate}, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof createTodoApiTodosPost>>,\n        TError,\n        {data: TodoCreate},\n        TContext\n      > => {\n      return useMutation(getCreateTodoApiTodosPostMutationOptions(options), queryClient);\n    }\n    /**\n * 获取单个待办\n * @summary Get Todo\n */\nexport type getTodoApiTodosTodoIdGetResponse200 = {\n  data: TodoResponse\n  status: 200\n}\n\nexport type getTodoApiTodosTodoIdGetResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type getTodoApiTodosTodoIdGetResponseSuccess = (getTodoApiTodosTodoIdGetResponse200) & {\n  headers: Headers;\n};\nexport type getTodoApiTodosTodoIdGetResponseError = (getTodoApiTodosTodoIdGetResponse422) & {\n  headers: Headers;\n};\n\nexport type getTodoApiTodosTodoIdGetResponse = (getTodoApiTodosTodoIdGetResponseSuccess | getTodoApiTodosTodoIdGetResponseError)\n\nexport const getGetTodoApiTodosTodoIdGetUrl = (todoId: number,) => {\n\n\n  \n\n  return `/api/todos/${todoId}`\n}\n\nexport const getTodoApiTodosTodoIdGet = async (todoId: number, options?: RequestInit): Promise<getTodoApiTodosTodoIdGetResponse> => {\n  \n  return customFetcher<getTodoApiTodosTodoIdGetResponse>(getGetTodoApiTodosTodoIdGetUrl(todoId),\n  {      \n    ...options,\n    method: 'GET'\n    \n    \n  }\n);}\n\n\n\n\n\nexport const getGetTodoApiTodosTodoIdGetQueryKey = (todoId: number,) => {\n    return [\n    `/api/todos/${todoId}`\n    ] as const;\n    }\n\n    \nexport const getGetTodoApiTodosTodoIdGetQueryOptions = <TData = Awaited<ReturnType<typeof getTodoApiTodosTodoIdGet>>, TError = HTTPValidationError>(todoId: number, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getTodoApiTodosTodoIdGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n) => {\n\nconst {query: queryOptions, request: requestOptions} = options ?? {};\n\n  const queryKey =  queryOptions?.queryKey ?? getGetTodoApiTodosTodoIdGetQueryKey(todoId);\n\n  \n\n    const queryFn: QueryFunction<Awaited<ReturnType<typeof getTodoApiTodosTodoIdGet>>> = ({ signal }) => getTodoApiTodosTodoIdGet(todoId, { signal, ...requestOptions });\n\n      \n\n      \n\n   return  { queryKey, queryFn, enabled: !!(todoId), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getTodoApiTodosTodoIdGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }\n}\n\nexport type GetTodoApiTodosTodoIdGetQueryResult = NonNullable<Awaited<ReturnType<typeof getTodoApiTodosTodoIdGet>>>\nexport type GetTodoApiTodosTodoIdGetQueryError = HTTPValidationError\n\n\nexport function useGetTodoApiTodosTodoIdGet<TData = Awaited<ReturnType<typeof getTodoApiTodosTodoIdGet>>, TError = HTTPValidationError>(\n todoId: number, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getTodoApiTodosTodoIdGet>>, TError, TData>> & Pick<\n        DefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getTodoApiTodosTodoIdGet>>,\n          TError,\n          Awaited<ReturnType<typeof getTodoApiTodosTodoIdGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetTodoApiTodosTodoIdGet<TData = Awaited<ReturnType<typeof getTodoApiTodosTodoIdGet>>, TError = HTTPValidationError>(\n todoId: number, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getTodoApiTodosTodoIdGet>>, TError, TData>> & Pick<\n        UndefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getTodoApiTodosTodoIdGet>>,\n          TError,\n          Awaited<ReturnType<typeof getTodoApiTodosTodoIdGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetTodoApiTodosTodoIdGet<TData = Awaited<ReturnType<typeof getTodoApiTodosTodoIdGet>>, TError = HTTPValidationError>(\n todoId: number, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getTodoApiTodosTodoIdGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\n/**\n * @summary Get Todo\n */\n\nexport function useGetTodoApiTodosTodoIdGet<TData = Awaited<ReturnType<typeof getTodoApiTodosTodoIdGet>>, TError = HTTPValidationError>(\n todoId: number, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getTodoApiTodosTodoIdGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient \n ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {\n\n  const queryOptions = getGetTodoApiTodosTodoIdGetQueryOptions(todoId,options)\n\n  const query = useQuery(queryOptions, queryClient) as  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };\n\n  return { ...query, queryKey: queryOptions.queryKey };\n}\n\n\n\n\n/**\n * 更新待办\n * @summary Update Todo\n */\nexport type updateTodoApiTodosTodoIdPutResponse200 = {\n  data: TodoResponse\n  status: 200\n}\n\nexport type updateTodoApiTodosTodoIdPutResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type updateTodoApiTodosTodoIdPutResponseSuccess = (updateTodoApiTodosTodoIdPutResponse200) & {\n  headers: Headers;\n};\nexport type updateTodoApiTodosTodoIdPutResponseError = (updateTodoApiTodosTodoIdPutResponse422) & {\n  headers: Headers;\n};\n\nexport type updateTodoApiTodosTodoIdPutResponse = (updateTodoApiTodosTodoIdPutResponseSuccess | updateTodoApiTodosTodoIdPutResponseError)\n\nexport const getUpdateTodoApiTodosTodoIdPutUrl = (todoId: number,) => {\n\n\n  \n\n  return `/api/todos/${todoId}`\n}\n\nexport const updateTodoApiTodosTodoIdPut = async (todoId: number,\n    todoUpdateNull: TodoUpdate | null, options?: RequestInit): Promise<updateTodoApiTodosTodoIdPutResponse> => {\n  \n  return customFetcher<updateTodoApiTodosTodoIdPutResponse>(getUpdateTodoApiTodosTodoIdPutUrl(todoId),\n  {      \n    ...options,\n    method: 'PUT',\n    headers: { 'Content-Type': 'application/json', ...options?.headers },\n    body: JSON.stringify(\n      todoUpdateNull,)\n  }\n);}\n\n\n\n\nexport const getUpdateTodoApiTodosTodoIdPutMutationOptions = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateTodoApiTodosTodoIdPut>>, TError,{todoId: number;data: TodoUpdate | null}, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof updateTodoApiTodosTodoIdPut>>, TError,{todoId: number;data: TodoUpdate | null}, TContext> => {\n\nconst mutationKey = ['updateTodoApiTodosTodoIdPut'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof updateTodoApiTodosTodoIdPut>>, {todoId: number;data: TodoUpdate | null}> = (props) => {\n          const {todoId,data} = props ?? {};\n\n          return  updateTodoApiTodosTodoIdPut(todoId,data,requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type UpdateTodoApiTodosTodoIdPutMutationResult = NonNullable<Awaited<ReturnType<typeof updateTodoApiTodosTodoIdPut>>>\n    export type UpdateTodoApiTodosTodoIdPutMutationBody = TodoUpdate | null\n    export type UpdateTodoApiTodosTodoIdPutMutationError = HTTPValidationError\n\n    /**\n * @summary Update Todo\n */\nexport const useUpdateTodoApiTodosTodoIdPut = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateTodoApiTodosTodoIdPut>>, TError,{todoId: number;data: TodoUpdate | null}, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof updateTodoApiTodosTodoIdPut>>,\n        TError,\n        {todoId: number;data: TodoUpdate | null},\n        TContext\n      > => {\n      return useMutation(getUpdateTodoApiTodosTodoIdPutMutationOptions(options), queryClient);\n    }\n    /**\n * 删除待办\n * @summary Delete Todo\n */\nexport type deleteTodoApiTodosTodoIdDeleteResponse204 = {\n  data: void\n  status: 204\n}\n\nexport type deleteTodoApiTodosTodoIdDeleteResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type deleteTodoApiTodosTodoIdDeleteResponseSuccess = (deleteTodoApiTodosTodoIdDeleteResponse204) & {\n  headers: Headers;\n};\nexport type deleteTodoApiTodosTodoIdDeleteResponseError = (deleteTodoApiTodosTodoIdDeleteResponse422) & {\n  headers: Headers;\n};\n\nexport type deleteTodoApiTodosTodoIdDeleteResponse = (deleteTodoApiTodosTodoIdDeleteResponseSuccess | deleteTodoApiTodosTodoIdDeleteResponseError)\n\nexport const getDeleteTodoApiTodosTodoIdDeleteUrl = (todoId: number,) => {\n\n\n  \n\n  return `/api/todos/${todoId}`\n}\n\nexport const deleteTodoApiTodosTodoIdDelete = async (todoId: number, options?: RequestInit): Promise<deleteTodoApiTodosTodoIdDeleteResponse> => {\n  \n  return customFetcher<deleteTodoApiTodosTodoIdDeleteResponse>(getDeleteTodoApiTodosTodoIdDeleteUrl(todoId),\n  {      \n    ...options,\n    method: 'DELETE'\n    \n    \n  }\n);}\n\n\n\n\nexport const getDeleteTodoApiTodosTodoIdDeleteMutationOptions = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof deleteTodoApiTodosTodoIdDelete>>, TError,{todoId: number}, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof deleteTodoApiTodosTodoIdDelete>>, TError,{todoId: number}, TContext> => {\n\nconst mutationKey = ['deleteTodoApiTodosTodoIdDelete'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof deleteTodoApiTodosTodoIdDelete>>, {todoId: number}> = (props) => {\n          const {todoId} = props ?? {};\n\n          return  deleteTodoApiTodosTodoIdDelete(todoId,requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type DeleteTodoApiTodosTodoIdDeleteMutationResult = NonNullable<Awaited<ReturnType<typeof deleteTodoApiTodosTodoIdDelete>>>\n    \n    export type DeleteTodoApiTodosTodoIdDeleteMutationError = HTTPValidationError\n\n    /**\n * @summary Delete Todo\n */\nexport const useDeleteTodoApiTodosTodoIdDelete = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof deleteTodoApiTodosTodoIdDelete>>, TError,{todoId: number}, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof deleteTodoApiTodosTodoIdDelete>>,\n        TError,\n        {todoId: number},\n        TContext\n      > => {\n      return useMutation(getDeleteTodoApiTodosTodoIdDeleteMutationOptions(options), queryClient);\n    }\n    /**\n * 上传附件并绑定到 Todo\n * @summary Upload Attachments\n */\nexport type uploadAttachmentsApiTodosTodoIdAttachmentsPostResponse201 = {\n  data: TodoAttachmentResponse[]\n  status: 201\n}\n\nexport type uploadAttachmentsApiTodosTodoIdAttachmentsPostResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type uploadAttachmentsApiTodosTodoIdAttachmentsPostResponseSuccess = (uploadAttachmentsApiTodosTodoIdAttachmentsPostResponse201) & {\n  headers: Headers;\n};\nexport type uploadAttachmentsApiTodosTodoIdAttachmentsPostResponseError = (uploadAttachmentsApiTodosTodoIdAttachmentsPostResponse422) & {\n  headers: Headers;\n};\n\nexport type uploadAttachmentsApiTodosTodoIdAttachmentsPostResponse = (uploadAttachmentsApiTodosTodoIdAttachmentsPostResponseSuccess | uploadAttachmentsApiTodosTodoIdAttachmentsPostResponseError)\n\nexport const getUploadAttachmentsApiTodosTodoIdAttachmentsPostUrl = (todoId: number,) => {\n\n\n  \n\n  return `/api/todos/${todoId}/attachments`\n}\n\nexport const uploadAttachmentsApiTodosTodoIdAttachmentsPost = async (todoId: number,\n    bodyUploadAttachmentsApiTodosTodoIdAttachmentsPost: BodyUploadAttachmentsApiTodosTodoIdAttachmentsPost, options?: RequestInit): Promise<uploadAttachmentsApiTodosTodoIdAttachmentsPostResponse> => {\n    const formData = new FormData();\nbodyUploadAttachmentsApiTodosTodoIdAttachmentsPost.files.forEach(value => formData.append(`files`, value));\n\n  return customFetcher<uploadAttachmentsApiTodosTodoIdAttachmentsPostResponse>(getUploadAttachmentsApiTodosTodoIdAttachmentsPostUrl(todoId),\n  {      \n    ...options,\n    method: 'POST'\n    ,\n    body: \n      formData,\n  }\n);}\n\n\n\n\nexport const getUploadAttachmentsApiTodosTodoIdAttachmentsPostMutationOptions = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof uploadAttachmentsApiTodosTodoIdAttachmentsPost>>, TError,{todoId: number;data: BodyUploadAttachmentsApiTodosTodoIdAttachmentsPost}, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof uploadAttachmentsApiTodosTodoIdAttachmentsPost>>, TError,{todoId: number;data: BodyUploadAttachmentsApiTodosTodoIdAttachmentsPost}, TContext> => {\n\nconst mutationKey = ['uploadAttachmentsApiTodosTodoIdAttachmentsPost'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof uploadAttachmentsApiTodosTodoIdAttachmentsPost>>, {todoId: number;data: BodyUploadAttachmentsApiTodosTodoIdAttachmentsPost}> = (props) => {\n          const {todoId,data} = props ?? {};\n\n          return  uploadAttachmentsApiTodosTodoIdAttachmentsPost(todoId,data,requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type UploadAttachmentsApiTodosTodoIdAttachmentsPostMutationResult = NonNullable<Awaited<ReturnType<typeof uploadAttachmentsApiTodosTodoIdAttachmentsPost>>>\n    export type UploadAttachmentsApiTodosTodoIdAttachmentsPostMutationBody = BodyUploadAttachmentsApiTodosTodoIdAttachmentsPost\n    export type UploadAttachmentsApiTodosTodoIdAttachmentsPostMutationError = HTTPValidationError\n\n    /**\n * @summary Upload Attachments\n */\nexport const useUploadAttachmentsApiTodosTodoIdAttachmentsPost = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof uploadAttachmentsApiTodosTodoIdAttachmentsPost>>, TError,{todoId: number;data: BodyUploadAttachmentsApiTodosTodoIdAttachmentsPost}, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof uploadAttachmentsApiTodosTodoIdAttachmentsPost>>,\n        TError,\n        {todoId: number;data: BodyUploadAttachmentsApiTodosTodoIdAttachmentsPost},\n        TContext\n      > => {\n      return useMutation(getUploadAttachmentsApiTodosTodoIdAttachmentsPostMutationOptions(options), queryClient);\n    }\n    /**\n * 解绑附件（不删除实际文件）\n * @summary Delete Attachment\n */\nexport type deleteAttachmentApiTodosTodoIdAttachmentsAttachmentIdDeleteResponse204 = {\n  data: void\n  status: 204\n}\n\nexport type deleteAttachmentApiTodosTodoIdAttachmentsAttachmentIdDeleteResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type deleteAttachmentApiTodosTodoIdAttachmentsAttachmentIdDeleteResponseSuccess = (deleteAttachmentApiTodosTodoIdAttachmentsAttachmentIdDeleteResponse204) & {\n  headers: Headers;\n};\nexport type deleteAttachmentApiTodosTodoIdAttachmentsAttachmentIdDeleteResponseError = (deleteAttachmentApiTodosTodoIdAttachmentsAttachmentIdDeleteResponse422) & {\n  headers: Headers;\n};\n\nexport type deleteAttachmentApiTodosTodoIdAttachmentsAttachmentIdDeleteResponse = (deleteAttachmentApiTodosTodoIdAttachmentsAttachmentIdDeleteResponseSuccess | deleteAttachmentApiTodosTodoIdAttachmentsAttachmentIdDeleteResponseError)\n\nexport const getDeleteAttachmentApiTodosTodoIdAttachmentsAttachmentIdDeleteUrl = (todoId: number,\n    attachmentId: number,) => {\n\n\n  \n\n  return `/api/todos/${todoId}/attachments/${attachmentId}`\n}\n\nexport const deleteAttachmentApiTodosTodoIdAttachmentsAttachmentIdDelete = async (todoId: number,\n    attachmentId: number, options?: RequestInit): Promise<deleteAttachmentApiTodosTodoIdAttachmentsAttachmentIdDeleteResponse> => {\n  \n  return customFetcher<deleteAttachmentApiTodosTodoIdAttachmentsAttachmentIdDeleteResponse>(getDeleteAttachmentApiTodosTodoIdAttachmentsAttachmentIdDeleteUrl(todoId,attachmentId),\n  {      \n    ...options,\n    method: 'DELETE'\n    \n    \n  }\n);}\n\n\n\n\nexport const getDeleteAttachmentApiTodosTodoIdAttachmentsAttachmentIdDeleteMutationOptions = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof deleteAttachmentApiTodosTodoIdAttachmentsAttachmentIdDelete>>, TError,{todoId: number;attachmentId: number}, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof deleteAttachmentApiTodosTodoIdAttachmentsAttachmentIdDelete>>, TError,{todoId: number;attachmentId: number}, TContext> => {\n\nconst mutationKey = ['deleteAttachmentApiTodosTodoIdAttachmentsAttachmentIdDelete'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof deleteAttachmentApiTodosTodoIdAttachmentsAttachmentIdDelete>>, {todoId: number;attachmentId: number}> = (props) => {\n          const {todoId,attachmentId} = props ?? {};\n\n          return  deleteAttachmentApiTodosTodoIdAttachmentsAttachmentIdDelete(todoId,attachmentId,requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type DeleteAttachmentApiTodosTodoIdAttachmentsAttachmentIdDeleteMutationResult = NonNullable<Awaited<ReturnType<typeof deleteAttachmentApiTodosTodoIdAttachmentsAttachmentIdDelete>>>\n    \n    export type DeleteAttachmentApiTodosTodoIdAttachmentsAttachmentIdDeleteMutationError = HTTPValidationError\n\n    /**\n * @summary Delete Attachment\n */\nexport const useDeleteAttachmentApiTodosTodoIdAttachmentsAttachmentIdDelete = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof deleteAttachmentApiTodosTodoIdAttachmentsAttachmentIdDelete>>, TError,{todoId: number;attachmentId: number}, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof deleteAttachmentApiTodosTodoIdAttachmentsAttachmentIdDelete>>,\n        TError,\n        {todoId: number;attachmentId: number},\n        TContext\n      > => {\n      return useMutation(getDeleteAttachmentApiTodosTodoIdAttachmentsAttachmentIdDeleteMutationOptions(options), queryClient);\n    }\n    /**\n * 下载附件文件\n * @summary Get Attachment File\n */\nexport type getAttachmentFileApiTodosAttachmentsAttachmentIdFileGetResponse200 = {\n  data: unknown\n  status: 200\n}\n\nexport type getAttachmentFileApiTodosAttachmentsAttachmentIdFileGetResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type getAttachmentFileApiTodosAttachmentsAttachmentIdFileGetResponseSuccess = (getAttachmentFileApiTodosAttachmentsAttachmentIdFileGetResponse200) & {\n  headers: Headers;\n};\nexport type getAttachmentFileApiTodosAttachmentsAttachmentIdFileGetResponseError = (getAttachmentFileApiTodosAttachmentsAttachmentIdFileGetResponse422) & {\n  headers: Headers;\n};\n\nexport type getAttachmentFileApiTodosAttachmentsAttachmentIdFileGetResponse = (getAttachmentFileApiTodosAttachmentsAttachmentIdFileGetResponseSuccess | getAttachmentFileApiTodosAttachmentsAttachmentIdFileGetResponseError)\n\nexport const getGetAttachmentFileApiTodosAttachmentsAttachmentIdFileGetUrl = (attachmentId: number,) => {\n\n\n  \n\n  return `/api/todos/attachments/${attachmentId}/file`\n}\n\nexport const getAttachmentFileApiTodosAttachmentsAttachmentIdFileGet = async (attachmentId: number, options?: RequestInit): Promise<getAttachmentFileApiTodosAttachmentsAttachmentIdFileGetResponse> => {\n  \n  return customFetcher<getAttachmentFileApiTodosAttachmentsAttachmentIdFileGetResponse>(getGetAttachmentFileApiTodosAttachmentsAttachmentIdFileGetUrl(attachmentId),\n  {      \n    ...options,\n    method: 'GET'\n    \n    \n  }\n);}\n\n\n\n\n\nexport const getGetAttachmentFileApiTodosAttachmentsAttachmentIdFileGetQueryKey = (attachmentId: number,) => {\n    return [\n    `/api/todos/attachments/${attachmentId}/file`\n    ] as const;\n    }\n\n    \nexport const getGetAttachmentFileApiTodosAttachmentsAttachmentIdFileGetQueryOptions = <TData = Awaited<ReturnType<typeof getAttachmentFileApiTodosAttachmentsAttachmentIdFileGet>>, TError = HTTPValidationError>(attachmentId: number, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getAttachmentFileApiTodosAttachmentsAttachmentIdFileGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n) => {\n\nconst {query: queryOptions, request: requestOptions} = options ?? {};\n\n  const queryKey =  queryOptions?.queryKey ?? getGetAttachmentFileApiTodosAttachmentsAttachmentIdFileGetQueryKey(attachmentId);\n\n  \n\n    const queryFn: QueryFunction<Awaited<ReturnType<typeof getAttachmentFileApiTodosAttachmentsAttachmentIdFileGet>>> = ({ signal }) => getAttachmentFileApiTodosAttachmentsAttachmentIdFileGet(attachmentId, { signal, ...requestOptions });\n\n      \n\n      \n\n   return  { queryKey, queryFn, enabled: !!(attachmentId), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getAttachmentFileApiTodosAttachmentsAttachmentIdFileGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }\n}\n\nexport type GetAttachmentFileApiTodosAttachmentsAttachmentIdFileGetQueryResult = NonNullable<Awaited<ReturnType<typeof getAttachmentFileApiTodosAttachmentsAttachmentIdFileGet>>>\nexport type GetAttachmentFileApiTodosAttachmentsAttachmentIdFileGetQueryError = HTTPValidationError\n\n\nexport function useGetAttachmentFileApiTodosAttachmentsAttachmentIdFileGet<TData = Awaited<ReturnType<typeof getAttachmentFileApiTodosAttachmentsAttachmentIdFileGet>>, TError = HTTPValidationError>(\n attachmentId: number, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getAttachmentFileApiTodosAttachmentsAttachmentIdFileGet>>, TError, TData>> & Pick<\n        DefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getAttachmentFileApiTodosAttachmentsAttachmentIdFileGet>>,\n          TError,\n          Awaited<ReturnType<typeof getAttachmentFileApiTodosAttachmentsAttachmentIdFileGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetAttachmentFileApiTodosAttachmentsAttachmentIdFileGet<TData = Awaited<ReturnType<typeof getAttachmentFileApiTodosAttachmentsAttachmentIdFileGet>>, TError = HTTPValidationError>(\n attachmentId: number, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getAttachmentFileApiTodosAttachmentsAttachmentIdFileGet>>, TError, TData>> & Pick<\n        UndefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getAttachmentFileApiTodosAttachmentsAttachmentIdFileGet>>,\n          TError,\n          Awaited<ReturnType<typeof getAttachmentFileApiTodosAttachmentsAttachmentIdFileGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetAttachmentFileApiTodosAttachmentsAttachmentIdFileGet<TData = Awaited<ReturnType<typeof getAttachmentFileApiTodosAttachmentsAttachmentIdFileGet>>, TError = HTTPValidationError>(\n attachmentId: number, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getAttachmentFileApiTodosAttachmentsAttachmentIdFileGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\n/**\n * @summary Get Attachment File\n */\n\nexport function useGetAttachmentFileApiTodosAttachmentsAttachmentIdFileGet<TData = Awaited<ReturnType<typeof getAttachmentFileApiTodosAttachmentsAttachmentIdFileGet>>, TError = HTTPValidationError>(\n attachmentId: number, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getAttachmentFileApiTodosAttachmentsAttachmentIdFileGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient \n ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {\n\n  const queryOptions = getGetAttachmentFileApiTodosAttachmentsAttachmentIdFileGetQueryOptions(attachmentId,options)\n\n  const query = useQuery(queryOptions, queryClient) as  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };\n\n  return { ...query, queryKey: queryOptions.queryKey };\n}\n\n\n\n\n/**\n * 批量更新待办的排序和父子关系\n * @summary Reorder Todos\n */\nexport type reorderTodosApiTodosReorderPostResponse200 = {\n  data: unknown\n  status: 200\n}\n\nexport type reorderTodosApiTodosReorderPostResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type reorderTodosApiTodosReorderPostResponseSuccess = (reorderTodosApiTodosReorderPostResponse200) & {\n  headers: Headers;\n};\nexport type reorderTodosApiTodosReorderPostResponseError = (reorderTodosApiTodosReorderPostResponse422) & {\n  headers: Headers;\n};\n\nexport type reorderTodosApiTodosReorderPostResponse = (reorderTodosApiTodosReorderPostResponseSuccess | reorderTodosApiTodosReorderPostResponseError)\n\nexport const getReorderTodosApiTodosReorderPostUrl = () => {\n\n\n  \n\n  return `/api/todos/reorder`\n}\n\nexport const reorderTodosApiTodosReorderPost = async (todoReorderRequest: TodoReorderRequest, options?: RequestInit): Promise<reorderTodosApiTodosReorderPostResponse> => {\n  \n  return customFetcher<reorderTodosApiTodosReorderPostResponse>(getReorderTodosApiTodosReorderPostUrl(),\n  {      \n    ...options,\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json', ...options?.headers },\n    body: JSON.stringify(\n      todoReorderRequest,)\n  }\n);}\n\n\n\n\nexport const getReorderTodosApiTodosReorderPostMutationOptions = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof reorderTodosApiTodosReorderPost>>, TError,{data: TodoReorderRequest}, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof reorderTodosApiTodosReorderPost>>, TError,{data: TodoReorderRequest}, TContext> => {\n\nconst mutationKey = ['reorderTodosApiTodosReorderPost'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof reorderTodosApiTodosReorderPost>>, {data: TodoReorderRequest}> = (props) => {\n          const {data} = props ?? {};\n\n          return  reorderTodosApiTodosReorderPost(data,requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type ReorderTodosApiTodosReorderPostMutationResult = NonNullable<Awaited<ReturnType<typeof reorderTodosApiTodosReorderPost>>>\n    export type ReorderTodosApiTodosReorderPostMutationBody = TodoReorderRequest\n    export type ReorderTodosApiTodosReorderPostMutationError = HTTPValidationError\n\n    /**\n * @summary Reorder Todos\n */\nexport const useReorderTodosApiTodosReorderPost = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof reorderTodosApiTodosReorderPost>>, TError,{data: TodoReorderRequest}, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof reorderTodosApiTodosReorderPost>>,\n        TError,\n        {data: TodoReorderRequest},\n        TContext\n      > => {\n      return useMutation(getReorderTodosApiTodosReorderPostMutationOptions(options), queryClient);\n    }\n    /**\n * 导出 Todo 为 ICS 文件\n * @summary Export Ics\n */\nexport type exportIcsApiTodosExportIcsGetResponse200 = {\n  data: unknown\n  status: 200\n}\n\nexport type exportIcsApiTodosExportIcsGetResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type exportIcsApiTodosExportIcsGetResponseSuccess = (exportIcsApiTodosExportIcsGetResponse200) & {\n  headers: Headers;\n};\nexport type exportIcsApiTodosExportIcsGetResponseError = (exportIcsApiTodosExportIcsGetResponse422) & {\n  headers: Headers;\n};\n\nexport type exportIcsApiTodosExportIcsGetResponse = (exportIcsApiTodosExportIcsGetResponseSuccess | exportIcsApiTodosExportIcsGetResponseError)\n\nexport const getExportIcsApiTodosExportIcsGetUrl = (params?: ExportIcsApiTodosExportIcsGetParams,) => {\n  const normalizedParams = new URLSearchParams();\n\n  Object.entries(params || {}).forEach(([key, value]) => {\n    \n    if (value !== undefined) {\n      normalizedParams.append(key, value === null ? 'null' : value.toString())\n    }\n  });\n\n  const stringifiedParams = normalizedParams.toString();\n\n  return stringifiedParams.length > 0 ? `/api/todos/export/ics?${stringifiedParams}` : `/api/todos/export/ics`\n}\n\nexport const exportIcsApiTodosExportIcsGet = async (params?: ExportIcsApiTodosExportIcsGetParams, options?: RequestInit): Promise<exportIcsApiTodosExportIcsGetResponse> => {\n  \n  return customFetcher<exportIcsApiTodosExportIcsGetResponse>(getExportIcsApiTodosExportIcsGetUrl(params),\n  {      \n    ...options,\n    method: 'GET'\n    \n    \n  }\n);}\n\n\n\n\n\nexport const getExportIcsApiTodosExportIcsGetQueryKey = (params?: ExportIcsApiTodosExportIcsGetParams,) => {\n    return [\n    `/api/todos/export/ics`, ...(params ? [params] : [])\n    ] as const;\n    }\n\n    \nexport const getExportIcsApiTodosExportIcsGetQueryOptions = <TData = Awaited<ReturnType<typeof exportIcsApiTodosExportIcsGet>>, TError = HTTPValidationError>(params?: ExportIcsApiTodosExportIcsGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof exportIcsApiTodosExportIcsGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n) => {\n\nconst {query: queryOptions, request: requestOptions} = options ?? {};\n\n  const queryKey =  queryOptions?.queryKey ?? getExportIcsApiTodosExportIcsGetQueryKey(params);\n\n  \n\n    const queryFn: QueryFunction<Awaited<ReturnType<typeof exportIcsApiTodosExportIcsGet>>> = ({ signal }) => exportIcsApiTodosExportIcsGet(params, { signal, ...requestOptions });\n\n      \n\n      \n\n   return  { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof exportIcsApiTodosExportIcsGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }\n}\n\nexport type ExportIcsApiTodosExportIcsGetQueryResult = NonNullable<Awaited<ReturnType<typeof exportIcsApiTodosExportIcsGet>>>\nexport type ExportIcsApiTodosExportIcsGetQueryError = HTTPValidationError\n\n\nexport function useExportIcsApiTodosExportIcsGet<TData = Awaited<ReturnType<typeof exportIcsApiTodosExportIcsGet>>, TError = HTTPValidationError>(\n params: undefined |  ExportIcsApiTodosExportIcsGetParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof exportIcsApiTodosExportIcsGet>>, TError, TData>> & Pick<\n        DefinedInitialDataOptions<\n          Awaited<ReturnType<typeof exportIcsApiTodosExportIcsGet>>,\n          TError,\n          Awaited<ReturnType<typeof exportIcsApiTodosExportIcsGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useExportIcsApiTodosExportIcsGet<TData = Awaited<ReturnType<typeof exportIcsApiTodosExportIcsGet>>, TError = HTTPValidationError>(\n params?: ExportIcsApiTodosExportIcsGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof exportIcsApiTodosExportIcsGet>>, TError, TData>> & Pick<\n        UndefinedInitialDataOptions<\n          Awaited<ReturnType<typeof exportIcsApiTodosExportIcsGet>>,\n          TError,\n          Awaited<ReturnType<typeof exportIcsApiTodosExportIcsGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useExportIcsApiTodosExportIcsGet<TData = Awaited<ReturnType<typeof exportIcsApiTodosExportIcsGet>>, TError = HTTPValidationError>(\n params?: ExportIcsApiTodosExportIcsGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof exportIcsApiTodosExportIcsGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\n/**\n * @summary Export Ics\n */\n\nexport function useExportIcsApiTodosExportIcsGet<TData = Awaited<ReturnType<typeof exportIcsApiTodosExportIcsGet>>, TError = HTTPValidationError>(\n params?: ExportIcsApiTodosExportIcsGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof exportIcsApiTodosExportIcsGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient \n ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {\n\n  const queryOptions = getExportIcsApiTodosExportIcsGetQueryOptions(params,options)\n\n  const query = useQuery(queryOptions, queryClient) as  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };\n\n  return { ...query, queryKey: queryOptions.queryKey };\n}\n\n\n\n\n/**\n * 从 ICS 文件导入 Todo\n * @summary Import Ics\n */\nexport type importIcsApiTodosImportIcsPostResponse200 = {\n  data: TodoResponse[]\n  status: 200\n}\n\nexport type importIcsApiTodosImportIcsPostResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type importIcsApiTodosImportIcsPostResponseSuccess = (importIcsApiTodosImportIcsPostResponse200) & {\n  headers: Headers;\n};\nexport type importIcsApiTodosImportIcsPostResponseError = (importIcsApiTodosImportIcsPostResponse422) & {\n  headers: Headers;\n};\n\nexport type importIcsApiTodosImportIcsPostResponse = (importIcsApiTodosImportIcsPostResponseSuccess | importIcsApiTodosImportIcsPostResponseError)\n\nexport const getImportIcsApiTodosImportIcsPostUrl = () => {\n\n\n  \n\n  return `/api/todos/import/ics`\n}\n\nexport const importIcsApiTodosImportIcsPost = async (bodyImportIcsApiTodosImportIcsPost: BodyImportIcsApiTodosImportIcsPost, options?: RequestInit): Promise<importIcsApiTodosImportIcsPostResponse> => {\n    const formData = new FormData();\nformData.append(`file`, bodyImportIcsApiTodosImportIcsPost.file);\n\n  return customFetcher<importIcsApiTodosImportIcsPostResponse>(getImportIcsApiTodosImportIcsPostUrl(),\n  {      \n    ...options,\n    method: 'POST'\n    ,\n    body: \n      formData,\n  }\n);}\n\n\n\n\nexport const getImportIcsApiTodosImportIcsPostMutationOptions = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof importIcsApiTodosImportIcsPost>>, TError,{data: BodyImportIcsApiTodosImportIcsPost}, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof importIcsApiTodosImportIcsPost>>, TError,{data: BodyImportIcsApiTodosImportIcsPost}, TContext> => {\n\nconst mutationKey = ['importIcsApiTodosImportIcsPost'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof importIcsApiTodosImportIcsPost>>, {data: BodyImportIcsApiTodosImportIcsPost}> = (props) => {\n          const {data} = props ?? {};\n\n          return  importIcsApiTodosImportIcsPost(data,requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type ImportIcsApiTodosImportIcsPostMutationResult = NonNullable<Awaited<ReturnType<typeof importIcsApiTodosImportIcsPost>>>\n    export type ImportIcsApiTodosImportIcsPostMutationBody = BodyImportIcsApiTodosImportIcsPost\n    export type ImportIcsApiTodosImportIcsPostMutationError = HTTPValidationError\n\n    /**\n * @summary Import Ics\n */\nexport const useImportIcsApiTodosImportIcsPost = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof importIcsApiTodosImportIcsPost>>, TError,{data: BodyImportIcsApiTodosImportIcsPost}, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof importIcsApiTodosImportIcsPost>>,\n        TError,\n        {data: BodyImportIcsApiTodosImportIcsPost},\n        TContext\n      > => {\n      return useMutation(getImportIcsApiTodosImportIcsPostMutationOptions(options), queryClient);\n    }\n    "
  },
  {
    "path": "free-todo-frontend/lib/generated/vector/vector.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport {\n  useMutation,\n  useQuery\n} from '@tanstack/react-query';\nimport type {\n  DataTag,\n  DefinedInitialDataOptions,\n  DefinedUseQueryResult,\n  MutationFunction,\n  QueryClient,\n  QueryFunction,\n  QueryKey,\n  UndefinedInitialDataOptions,\n  UseMutationOptions,\n  UseMutationResult,\n  UseQueryOptions,\n  UseQueryResult\n} from '@tanstack/react-query';\n\nimport type {\n  EventResponse,\n  HTTPValidationError,\n  SemanticSearchRequest,\n  SemanticSearchResult,\n  SyncVectorDatabaseApiVectorSyncPostParams,\n  VectorStatsResponse\n} from '.././schemas';\n\nimport { customFetcher } from '../../api/fetcher';\n\n\ntype SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];\n\n\n\n/**\n * 语义搜索 OCR 结果\n * @summary Semantic Search\n */\nexport type semanticSearchApiSemanticSearchPostResponse200 = {\n  data: SemanticSearchResult[]\n  status: 200\n}\n\nexport type semanticSearchApiSemanticSearchPostResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type semanticSearchApiSemanticSearchPostResponseSuccess = (semanticSearchApiSemanticSearchPostResponse200) & {\n  headers: Headers;\n};\nexport type semanticSearchApiSemanticSearchPostResponseError = (semanticSearchApiSemanticSearchPostResponse422) & {\n  headers: Headers;\n};\n\nexport type semanticSearchApiSemanticSearchPostResponse = (semanticSearchApiSemanticSearchPostResponseSuccess | semanticSearchApiSemanticSearchPostResponseError)\n\nexport const getSemanticSearchApiSemanticSearchPostUrl = () => {\n\n\n  \n\n  return `/api/semantic-search`\n}\n\nexport const semanticSearchApiSemanticSearchPost = async (semanticSearchRequest: SemanticSearchRequest, options?: RequestInit): Promise<semanticSearchApiSemanticSearchPostResponse> => {\n  \n  return customFetcher<semanticSearchApiSemanticSearchPostResponse>(getSemanticSearchApiSemanticSearchPostUrl(),\n  {      \n    ...options,\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json', ...options?.headers },\n    body: JSON.stringify(\n      semanticSearchRequest,)\n  }\n);}\n\n\n\n\nexport const getSemanticSearchApiSemanticSearchPostMutationOptions = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof semanticSearchApiSemanticSearchPost>>, TError,{data: SemanticSearchRequest}, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof semanticSearchApiSemanticSearchPost>>, TError,{data: SemanticSearchRequest}, TContext> => {\n\nconst mutationKey = ['semanticSearchApiSemanticSearchPost'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof semanticSearchApiSemanticSearchPost>>, {data: SemanticSearchRequest}> = (props) => {\n          const {data} = props ?? {};\n\n          return  semanticSearchApiSemanticSearchPost(data,requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type SemanticSearchApiSemanticSearchPostMutationResult = NonNullable<Awaited<ReturnType<typeof semanticSearchApiSemanticSearchPost>>>\n    export type SemanticSearchApiSemanticSearchPostMutationBody = SemanticSearchRequest\n    export type SemanticSearchApiSemanticSearchPostMutationError = HTTPValidationError\n\n    /**\n * @summary Semantic Search\n */\nexport const useSemanticSearchApiSemanticSearchPost = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof semanticSearchApiSemanticSearchPost>>, TError,{data: SemanticSearchRequest}, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof semanticSearchApiSemanticSearchPost>>,\n        TError,\n        {data: SemanticSearchRequest},\n        TContext\n      > => {\n      return useMutation(getSemanticSearchApiSemanticSearchPostMutationOptions(options), queryClient);\n    }\n    /**\n * 事件级语义搜索（基于事件聚合文本）\n * @summary Event Semantic Search\n */\nexport type eventSemanticSearchApiEventSemanticSearchPostResponse200 = {\n  data: EventResponse[]\n  status: 200\n}\n\nexport type eventSemanticSearchApiEventSemanticSearchPostResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type eventSemanticSearchApiEventSemanticSearchPostResponseSuccess = (eventSemanticSearchApiEventSemanticSearchPostResponse200) & {\n  headers: Headers;\n};\nexport type eventSemanticSearchApiEventSemanticSearchPostResponseError = (eventSemanticSearchApiEventSemanticSearchPostResponse422) & {\n  headers: Headers;\n};\n\nexport type eventSemanticSearchApiEventSemanticSearchPostResponse = (eventSemanticSearchApiEventSemanticSearchPostResponseSuccess | eventSemanticSearchApiEventSemanticSearchPostResponseError)\n\nexport const getEventSemanticSearchApiEventSemanticSearchPostUrl = () => {\n\n\n  \n\n  return `/api/event-semantic-search`\n}\n\nexport const eventSemanticSearchApiEventSemanticSearchPost = async (semanticSearchRequest: SemanticSearchRequest, options?: RequestInit): Promise<eventSemanticSearchApiEventSemanticSearchPostResponse> => {\n  \n  return customFetcher<eventSemanticSearchApiEventSemanticSearchPostResponse>(getEventSemanticSearchApiEventSemanticSearchPostUrl(),\n  {      \n    ...options,\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json', ...options?.headers },\n    body: JSON.stringify(\n      semanticSearchRequest,)\n  }\n);}\n\n\n\n\nexport const getEventSemanticSearchApiEventSemanticSearchPostMutationOptions = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof eventSemanticSearchApiEventSemanticSearchPost>>, TError,{data: SemanticSearchRequest}, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof eventSemanticSearchApiEventSemanticSearchPost>>, TError,{data: SemanticSearchRequest}, TContext> => {\n\nconst mutationKey = ['eventSemanticSearchApiEventSemanticSearchPost'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof eventSemanticSearchApiEventSemanticSearchPost>>, {data: SemanticSearchRequest}> = (props) => {\n          const {data} = props ?? {};\n\n          return  eventSemanticSearchApiEventSemanticSearchPost(data,requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type EventSemanticSearchApiEventSemanticSearchPostMutationResult = NonNullable<Awaited<ReturnType<typeof eventSemanticSearchApiEventSemanticSearchPost>>>\n    export type EventSemanticSearchApiEventSemanticSearchPostMutationBody = SemanticSearchRequest\n    export type EventSemanticSearchApiEventSemanticSearchPostMutationError = HTTPValidationError\n\n    /**\n * @summary Event Semantic Search\n */\nexport const useEventSemanticSearchApiEventSemanticSearchPost = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof eventSemanticSearchApiEventSemanticSearchPost>>, TError,{data: SemanticSearchRequest}, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof eventSemanticSearchApiEventSemanticSearchPost>>,\n        TError,\n        {data: SemanticSearchRequest},\n        TContext\n      > => {\n      return useMutation(getEventSemanticSearchApiEventSemanticSearchPostMutationOptions(options), queryClient);\n    }\n    /**\n * 获取向量数据库统计信息\n * @summary Get Vector Stats\n */\nexport type getVectorStatsApiVectorStatsGetResponse200 = {\n  data: VectorStatsResponse\n  status: 200\n}\n    \nexport type getVectorStatsApiVectorStatsGetResponseSuccess = (getVectorStatsApiVectorStatsGetResponse200) & {\n  headers: Headers;\n};\n;\n\nexport type getVectorStatsApiVectorStatsGetResponse = (getVectorStatsApiVectorStatsGetResponseSuccess)\n\nexport const getGetVectorStatsApiVectorStatsGetUrl = () => {\n\n\n  \n\n  return `/api/vector-stats`\n}\n\nexport const getVectorStatsApiVectorStatsGet = async ( options?: RequestInit): Promise<getVectorStatsApiVectorStatsGetResponse> => {\n  \n  return customFetcher<getVectorStatsApiVectorStatsGetResponse>(getGetVectorStatsApiVectorStatsGetUrl(),\n  {      \n    ...options,\n    method: 'GET'\n    \n    \n  }\n);}\n\n\n\n\n\nexport const getGetVectorStatsApiVectorStatsGetQueryKey = () => {\n    return [\n    `/api/vector-stats`\n    ] as const;\n    }\n\n    \nexport const getGetVectorStatsApiVectorStatsGetQueryOptions = <TData = Awaited<ReturnType<typeof getVectorStatsApiVectorStatsGet>>, TError = unknown>( options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getVectorStatsApiVectorStatsGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n) => {\n\nconst {query: queryOptions, request: requestOptions} = options ?? {};\n\n  const queryKey =  queryOptions?.queryKey ?? getGetVectorStatsApiVectorStatsGetQueryKey();\n\n  \n\n    const queryFn: QueryFunction<Awaited<ReturnType<typeof getVectorStatsApiVectorStatsGet>>> = ({ signal }) => getVectorStatsApiVectorStatsGet({ signal, ...requestOptions });\n\n      \n\n      \n\n   return  { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getVectorStatsApiVectorStatsGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }\n}\n\nexport type GetVectorStatsApiVectorStatsGetQueryResult = NonNullable<Awaited<ReturnType<typeof getVectorStatsApiVectorStatsGet>>>\nexport type GetVectorStatsApiVectorStatsGetQueryError = unknown\n\n\nexport function useGetVectorStatsApiVectorStatsGet<TData = Awaited<ReturnType<typeof getVectorStatsApiVectorStatsGet>>, TError = unknown>(\n  options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getVectorStatsApiVectorStatsGet>>, TError, TData>> & Pick<\n        DefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getVectorStatsApiVectorStatsGet>>,\n          TError,\n          Awaited<ReturnType<typeof getVectorStatsApiVectorStatsGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetVectorStatsApiVectorStatsGet<TData = Awaited<ReturnType<typeof getVectorStatsApiVectorStatsGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getVectorStatsApiVectorStatsGet>>, TError, TData>> & Pick<\n        UndefinedInitialDataOptions<\n          Awaited<ReturnType<typeof getVectorStatsApiVectorStatsGet>>,\n          TError,\n          Awaited<ReturnType<typeof getVectorStatsApiVectorStatsGet>>\n        > , 'initialData'\n      >, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\nexport function useGetVectorStatsApiVectorStatsGet<TData = Awaited<ReturnType<typeof getVectorStatsApiVectorStatsGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getVectorStatsApiVectorStatsGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient\n  ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }\n/**\n * @summary Get Vector Stats\n */\n\nexport function useGetVectorStatsApiVectorStatsGet<TData = Awaited<ReturnType<typeof getVectorStatsApiVectorStatsGet>>, TError = unknown>(\n  options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getVectorStatsApiVectorStatsGet>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient \n ):  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {\n\n  const queryOptions = getGetVectorStatsApiVectorStatsGetQueryOptions(options)\n\n  const query = useQuery(queryOptions, queryClient) as  UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };\n\n  return { ...query, queryKey: queryOptions.queryKey };\n}\n\n\n\n\n/**\n * 同步 SQLite 数据库到向量数据库\n * @summary Sync Vector Database\n */\nexport type syncVectorDatabaseApiVectorSyncPostResponse200 = {\n  data: unknown\n  status: 200\n}\n\nexport type syncVectorDatabaseApiVectorSyncPostResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type syncVectorDatabaseApiVectorSyncPostResponseSuccess = (syncVectorDatabaseApiVectorSyncPostResponse200) & {\n  headers: Headers;\n};\nexport type syncVectorDatabaseApiVectorSyncPostResponseError = (syncVectorDatabaseApiVectorSyncPostResponse422) & {\n  headers: Headers;\n};\n\nexport type syncVectorDatabaseApiVectorSyncPostResponse = (syncVectorDatabaseApiVectorSyncPostResponseSuccess | syncVectorDatabaseApiVectorSyncPostResponseError)\n\nexport const getSyncVectorDatabaseApiVectorSyncPostUrl = (params?: SyncVectorDatabaseApiVectorSyncPostParams,) => {\n  const normalizedParams = new URLSearchParams();\n\n  Object.entries(params || {}).forEach(([key, value]) => {\n    \n    if (value !== undefined) {\n      normalizedParams.append(key, value === null ? 'null' : value.toString())\n    }\n  });\n\n  const stringifiedParams = normalizedParams.toString();\n\n  return stringifiedParams.length > 0 ? `/api/vector-sync?${stringifiedParams}` : `/api/vector-sync`\n}\n\nexport const syncVectorDatabaseApiVectorSyncPost = async (params?: SyncVectorDatabaseApiVectorSyncPostParams, options?: RequestInit): Promise<syncVectorDatabaseApiVectorSyncPostResponse> => {\n  \n  return customFetcher<syncVectorDatabaseApiVectorSyncPostResponse>(getSyncVectorDatabaseApiVectorSyncPostUrl(params),\n  {      \n    ...options,\n    method: 'POST'\n    \n    \n  }\n);}\n\n\n\n\nexport const getSyncVectorDatabaseApiVectorSyncPostMutationOptions = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof syncVectorDatabaseApiVectorSyncPost>>, TError,{params?: SyncVectorDatabaseApiVectorSyncPostParams}, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof syncVectorDatabaseApiVectorSyncPost>>, TError,{params?: SyncVectorDatabaseApiVectorSyncPostParams}, TContext> => {\n\nconst mutationKey = ['syncVectorDatabaseApiVectorSyncPost'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof syncVectorDatabaseApiVectorSyncPost>>, {params?: SyncVectorDatabaseApiVectorSyncPostParams}> = (props) => {\n          const {params} = props ?? {};\n\n          return  syncVectorDatabaseApiVectorSyncPost(params,requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type SyncVectorDatabaseApiVectorSyncPostMutationResult = NonNullable<Awaited<ReturnType<typeof syncVectorDatabaseApiVectorSyncPost>>>\n    \n    export type SyncVectorDatabaseApiVectorSyncPostMutationError = HTTPValidationError\n\n    /**\n * @summary Sync Vector Database\n */\nexport const useSyncVectorDatabaseApiVectorSyncPost = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof syncVectorDatabaseApiVectorSyncPost>>, TError,{params?: SyncVectorDatabaseApiVectorSyncPostParams}, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof syncVectorDatabaseApiVectorSyncPost>>,\n        TError,\n        {params?: SyncVectorDatabaseApiVectorSyncPostParams},\n        TContext\n      > => {\n      return useMutation(getSyncVectorDatabaseApiVectorSyncPostMutationOptions(options), queryClient);\n    }\n    /**\n * 重置向量数据库\n * @summary Reset Vector Database\n */\nexport type resetVectorDatabaseApiVectorResetPostResponse200 = {\n  data: unknown\n  status: 200\n}\n    \nexport type resetVectorDatabaseApiVectorResetPostResponseSuccess = (resetVectorDatabaseApiVectorResetPostResponse200) & {\n  headers: Headers;\n};\n;\n\nexport type resetVectorDatabaseApiVectorResetPostResponse = (resetVectorDatabaseApiVectorResetPostResponseSuccess)\n\nexport const getResetVectorDatabaseApiVectorResetPostUrl = () => {\n\n\n  \n\n  return `/api/vector-reset`\n}\n\nexport const resetVectorDatabaseApiVectorResetPost = async ( options?: RequestInit): Promise<resetVectorDatabaseApiVectorResetPostResponse> => {\n  \n  return customFetcher<resetVectorDatabaseApiVectorResetPostResponse>(getResetVectorDatabaseApiVectorResetPostUrl(),\n  {      \n    ...options,\n    method: 'POST'\n    \n    \n  }\n);}\n\n\n\n\nexport const getResetVectorDatabaseApiVectorResetPostMutationOptions = <TError = unknown,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof resetVectorDatabaseApiVectorResetPost>>, TError,void, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof resetVectorDatabaseApiVectorResetPost>>, TError,void, TContext> => {\n\nconst mutationKey = ['resetVectorDatabaseApiVectorResetPost'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof resetVectorDatabaseApiVectorResetPost>>, void> = () => {\n          \n\n          return  resetVectorDatabaseApiVectorResetPost(requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type ResetVectorDatabaseApiVectorResetPostMutationResult = NonNullable<Awaited<ReturnType<typeof resetVectorDatabaseApiVectorResetPost>>>\n    \n    export type ResetVectorDatabaseApiVectorResetPostMutationError = unknown\n\n    /**\n * @summary Reset Vector Database\n */\nexport const useResetVectorDatabaseApiVectorResetPost = <TError = unknown,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof resetVectorDatabaseApiVectorResetPost>>, TError,void, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof resetVectorDatabaseApiVectorResetPost>>,\n        TError,\n        void,\n        TContext\n      > => {\n      return useMutation(getResetVectorDatabaseApiVectorResetPostMutationOptions(options), queryClient);\n    }\n    "
  },
  {
    "path": "free-todo-frontend/lib/generated/vision/vision.ts",
    "content": "/**\n * Generated by orval v8.2.0 🍺\n * Do not edit manually.\n * FreeTodo API\n * FreeTodo API (part of FreeU Project)\n * OpenAPI spec version: 0.1.2\n */\nimport {\n  useMutation\n} from '@tanstack/react-query';\nimport type {\n  MutationFunction,\n  QueryClient,\n  UseMutationOptions,\n  UseMutationResult\n} from '@tanstack/react-query';\n\nimport type {\n  HTTPValidationError,\n  VisionChatRequest,\n  VisionChatResponse\n} from '.././schemas';\n\nimport { customFetcher } from '../../api/fetcher';\n\n\ntype SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];\n\n\n\n/**\n * 视觉多模态聊天接口\n\n使用通义千问视觉模型分析多张截图，支持文本提示词。\n\nArgs:\n    request: 视觉聊天请求，包含截图ID列表和提示词\n\nReturns:\n    视觉聊天响应，包含模型生成的文本和元信息\n\nRaises:\n    HTTPException: 当请求参数无效或API调用失败时\n * @summary Vision Chat\n */\nexport type visionChatApiVisionChatPostResponse200 = {\n  data: VisionChatResponse\n  status: 200\n}\n\nexport type visionChatApiVisionChatPostResponse422 = {\n  data: HTTPValidationError\n  status: 422\n}\n    \nexport type visionChatApiVisionChatPostResponseSuccess = (visionChatApiVisionChatPostResponse200) & {\n  headers: Headers;\n};\nexport type visionChatApiVisionChatPostResponseError = (visionChatApiVisionChatPostResponse422) & {\n  headers: Headers;\n};\n\nexport type visionChatApiVisionChatPostResponse = (visionChatApiVisionChatPostResponseSuccess | visionChatApiVisionChatPostResponseError)\n\nexport const getVisionChatApiVisionChatPostUrl = () => {\n\n\n  \n\n  return `/api/vision/chat`\n}\n\nexport const visionChatApiVisionChatPost = async (visionChatRequest: VisionChatRequest, options?: RequestInit): Promise<visionChatApiVisionChatPostResponse> => {\n  \n  return customFetcher<visionChatApiVisionChatPostResponse>(getVisionChatApiVisionChatPostUrl(),\n  {      \n    ...options,\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json', ...options?.headers },\n    body: JSON.stringify(\n      visionChatRequest,)\n  }\n);}\n\n\n\n\nexport const getVisionChatApiVisionChatPostMutationOptions = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof visionChatApiVisionChatPost>>, TError,{data: VisionChatRequest}, TContext>, request?: SecondParameter<typeof customFetcher>}\n): UseMutationOptions<Awaited<ReturnType<typeof visionChatApiVisionChatPost>>, TError,{data: VisionChatRequest}, TContext> => {\n\nconst mutationKey = ['visionChatApiVisionChatPost'];\nconst {mutation: mutationOptions, request: requestOptions} = options ?\n      options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?\n      options\n      : {...options, mutation: {...options.mutation, mutationKey}}\n      : {mutation: { mutationKey, }, request: undefined};\n\n      \n\n\n      const mutationFn: MutationFunction<Awaited<ReturnType<typeof visionChatApiVisionChatPost>>, {data: VisionChatRequest}> = (props) => {\n          const {data} = props ?? {};\n\n          return  visionChatApiVisionChatPost(data,requestOptions)\n        }\n\n\n\n        \n\n\n  return  { mutationFn, ...mutationOptions }}\n\n    export type VisionChatApiVisionChatPostMutationResult = NonNullable<Awaited<ReturnType<typeof visionChatApiVisionChatPost>>>\n    export type VisionChatApiVisionChatPostMutationBody = VisionChatRequest\n    export type VisionChatApiVisionChatPostMutationError = HTTPValidationError\n\n    /**\n * @summary Vision Chat\n */\nexport const useVisionChatApiVisionChatPost = <TError = HTTPValidationError,\n    TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof visionChatApiVisionChatPost>>, TError,{data: VisionChatRequest}, TContext>, request?: SecondParameter<typeof customFetcher>}\n , queryClient?: QueryClient): UseMutationResult<\n        Awaited<ReturnType<typeof visionChatApiVisionChatPost>>,\n        TError,\n        {data: VisionChatRequest},\n        TContext\n      > => {\n      return useMutation(getVisionChatApiVisionChatPostMutationOptions(options), queryClient);\n    }\n    "
  },
  {
    "path": "free-todo-frontend/lib/hooks/useAutoRecording.ts",
    "content": "\"use client\";\n\nimport { useCallback, useEffect, useRef } from \"react\";\nimport {\n\tformatDateTime,\n\tgetSegmentDate,\n} from \"@/apps/audio/utils/timeUtils\";\nimport { useConfig } from \"@/lib/query\";\nimport { useAudioRecordingStore } from \"@/lib/store/audio-recording-store\";\n\n/**\n * 全局自动录音 Hook\n *\n * 在应用启动时，根据\"自动启动录音\"配置决定是否自动开始录音。\n * - 配置开启：应用启动时自动开始录音\n * - 配置关闭：需要用户在音频面板中手动点击\"开始录音\"\n *\n * 注意：此 hook 应该在应用入口（如 page.tsx）中调用一次\n */\nexport function useAutoRecording() {\n\tconst { data: config, isLoading: configLoading } = useConfig();\n\tconst autoStartEnabled = (config?.audioIs24x7 as boolean | undefined) ?? false;\n\n\t// 从全局 store 获取状态和方法\n\tconst isRecording = useAudioRecordingStore((state) => state.isRecording);\n\tconst startRecording = useAudioRecordingStore((state) => state.startRecording);\n\tconst updateLastFinalEnd = useAudioRecordingStore((state) => state.updateLastFinalEnd);\n\tconst appendTranscriptionText = useAudioRecordingStore((state) => state.appendTranscriptionText);\n\tconst setPartialText = useAudioRecordingStore((state) => state.setPartialText);\n\tconst setOptimizedText = useAudioRecordingStore((state) => state.setOptimizedText);\n\tconst appendSegmentData = useAudioRecordingStore((state) => state.appendSegmentData);\n\tconst setLiveTodos = useAudioRecordingStore((state) => state.setLiveTodos);\n\tconst setLiveSchedules = useAudioRecordingStore((state) => state.setLiveSchedules);\n\tconst clearSessionData = useAudioRecordingStore((state) => state.clearSessionData);\n\n\t// 用于防止重复启动\n\tconst isStartingRef = useRef(false);\n\tconst hasAutoStartedRef = useRef(false);\n\n\t// 启动录音的核心逻辑\n\tconst doStartRecording = useCallback(async () => {\n\t\tif (isRecording || isStartingRef.current) {\n\t\t\tconsole.log(\"[useAutoRecording] 已在录音中或正在启动，忽略启动请求\");\n\t\t\treturn false;\n\t\t}\n\n\t\tconsole.log(\"[useAutoRecording] 开始启动录音...\");\n\t\tisStartingRef.current = true;\n\n\t\ttry {\n\t\t\t// 清空会话数据\n\t\t\tclearSessionData();\n\n\t\t\t// 启动录音，始终使用 7×24 模式（启用分段保存和自动重连）\n\t\t\tawait startRecording(\n\t\t\t\t(text, isFinal) => {\n\t\t\t\t\t// 处理分段保存通知\n\t\t\t\t\tif (isFinal && text.startsWith(\"__SEGMENT_SAVED__\")) {\n\t\t\t\t\t\tconsole.log(\"[useAutoRecording] 收到分段保存通知\");\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\n\t\t\t\t\tif (isFinal) {\n\t\t\t\t\t\t// 获取 store 状态计算时间\n\t\t\t\t\t\tconst storeState = useAudioRecordingStore.getState();\n\t\t\t\t\t\tconst currentRecordingStartedAt = storeState.recordingStartedAt ?? Date.now();\n\t\t\t\t\t\tconst currentLastFinalEndMs = storeState.lastFinalEndMs;\n\t\t\t\t\t\tconst segmentStartMs = currentLastFinalEndMs ?? currentRecordingStartedAt;\n\t\t\t\t\t\tconst elapsedSec = (segmentStartMs - currentRecordingStartedAt) / 1000;\n\n\t\t\t\t\t\t// 更新时间戳\n\t\t\t\t\t\tupdateLastFinalEnd(Date.now());\n\n\t\t\t\t\t\t// 追加转录文本\n\t\t\t\t\t\tappendTranscriptionText(text);\n\n\t\t\t\t\t\t// 追加段落数据\n\t\t\t\t\t\tconst start = storeState.recordingStartedDate ?? new Date();\n\t\t\t\t\t\tconst segmentDate = getSegmentDate(start, elapsedSec, new Date());\n\t\t\t\t\t\tappendSegmentData({\n\t\t\t\t\t\t\ttimeSec: elapsedSec,\n\t\t\t\t\t\t\ttimeLabel: formatDateTime(segmentDate),\n\t\t\t\t\t\t\trecordingId: 0,\n\t\t\t\t\t\t\toffsetSec: elapsedSec,\n\t\t\t\t\t\t});\n\t\t\t\t\t\tsetPartialText(\"\");\n\t\t\t\t\t} else {\n\t\t\t\t\t\tsetPartialText(text);\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t\t(data) => {\n\t\t\t\t\tif (typeof data.optimizedText === \"string\") setOptimizedText(data.optimizedText);\n\t\t\t\t\tif (Array.isArray(data.todos)) setLiveTodos(data.todos);\n\t\t\t\t\tif (Array.isArray(data.schedules)) setLiveSchedules(data.schedules);\n\t\t\t\t},\n\t\t\t\t(error) => {\n\t\t\t\t\tconsole.error(\"[useAutoRecording] Recording error:\", error);\n\t\t\t\t},\n\t\t\t\ttrue // 始终使用 7×24 模式\n\t\t\t);\n\n\t\t\tconsole.log(\"[useAutoRecording] ✅ 录音启动成功\");\n\t\t\treturn true;\n\t\t} catch (error) {\n\t\t\tconsole.error(\"[useAutoRecording] ❌ 启动录音失败:\", error);\n\t\t\treturn false;\n\t\t} finally {\n\t\t\tisStartingRef.current = false;\n\t\t}\n\t}, [\n\t\tisRecording,\n\t\tclearSessionData,\n\t\tstartRecording,\n\t\tupdateLastFinalEnd,\n\t\tappendTranscriptionText,\n\t\tappendSegmentData,\n\t\tsetPartialText,\n\t\tsetOptimizedText,\n\t\tsetLiveTodos,\n\t\tsetLiveSchedules,\n\t]);\n\n\t// 应用启动时自动开始录音（仅在配置开启时）\n\tuseEffect(() => {\n\t\t// 等待配置加载完成\n\t\tif (configLoading) {\n\t\t\treturn;\n\t\t}\n\n\t\t// 如果已经自动启动过，不再重复启动\n\t\tif (hasAutoStartedRef.current) {\n\t\t\treturn;\n\t\t}\n\n\t\t// 如果配置开启且未在录音中，自动启动录音\n\t\tif (autoStartEnabled && !isRecording && !isStartingRef.current) {\n\t\t\tconsole.log(\"[useAutoRecording] 自动启动录音已开启，准备自动启动...\");\n\t\t\thasAutoStartedRef.current = true;\n\n\t\t\t// 延迟一点启动，确保应用完全初始化\n\t\t\tconst timer = setTimeout(() => {\n\t\t\t\tdoStartRecording();\n\t\t\t}, 1500);\n\n\t\t\treturn () => {\n\t\t\t\tclearTimeout(timer);\n\t\t\t};\n\t\t}\n\t}, [autoStartEnabled, isRecording, configLoading, doStartRecording]);\n\n\treturn {\n\t\tisRecording,\n\t\tautoStartEnabled,\n\t\tconfigLoading,\n\t};\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/hooks/useOnboardingTour.ts",
    "content": "\"use client\";\n\nimport { type Driver, driver } from \"driver.js\";\nimport { useTranslations } from \"next-intl\";\nimport { useCallback, useRef } from \"react\";\nimport { useOnboardingStore } from \"@/lib/store/onboarding-store\";\nimport { useUiStore } from \"@/lib/store/ui-store\";\nimport { useOpenSettings } from \"./useOpenSettings\";\n\n/**\n * 滚动设置面板到顶部\n */\nfunction scrollSettingsPanelToTop(): Promise<void> {\n\treturn new Promise((resolve) => {\n\t\t// 查找设置面板的滚动容器\n\t\tconst settingsContent = document.querySelector(\n\t\t\t'[data-tour=\"settings-content\"]',\n\t\t);\n\t\tif (settingsContent) {\n\t\t\tsettingsContent.scrollTo({ top: 0, behavior: \"smooth\" });\n\t\t\t// 等待滚动完成\n\t\t\tsetTimeout(resolve, 300);\n\t\t} else {\n\t\t\tresolve();\n\t\t}\n\t});\n}\n\nfunction selectSettingsCategory(category: string): void {\n\twindow.dispatchEvent(\n\t\tnew CustomEvent(\"settings:set-category\", { detail: { category } }),\n\t);\n}\n\n/**\n * Hook for managing the onboarding tour\n * Provides methods to start, skip, and check tour status\n */\nexport function useOnboardingTour() {\n\tconst { hasCompletedTour, completeTour, setCurrentStep } =\n\t\tuseOnboardingStore();\n\tconst { setDockDisplayMode } = useUiStore();\n\tconst { openSettings } = useOpenSettings();\n\tconst t = useTranslations(\"onboarding\");\n\tconst driverRef = useRef<Driver | null>(null);\n\n\t/**\n\t * Create and start the driver tour\n\t */\n\tconst createAndStartTour = useCallback(() => {\n\t\t// 引导期间保持 dock 固定显示\n\t\tsetDockDisplayMode(\"fixed\");\n\n\t\tconst driverObj = driver({\n\t\t\tshowProgress: true,\n\t\t\tprogressText: \"{{current}} / {{total}}\",\n\t\t\tallowClose: true,\n\t\t\toverlayColor: \"#000\",\n\t\t\toverlayOpacity: 0.7,\n\t\t\tstagePadding: 10,\n\t\t\tstageRadius: 8,\n\t\t\tanimate: true,\n\t\t\tsmoothScroll: true,\n\t\t\tallowKeyboardControl: true,\n\n\t\t\t// Button text\n\t\t\tnextBtnText: t(\"nextBtn\"),\n\t\t\tprevBtnText: t(\"prevBtn\"),\n\t\t\tdoneBtnText: t(\"doneBtn\"),\n\n\t\t\t// Custom popover class for styling\n\t\t\tpopoverClass: \"onboarding-popover\",\n\n\t\t\t// Lifecycle hooks\n\t\t\tonHighlightStarted: (_element, _step, { state }) => {\n\t\t\t\tsetCurrentStep(state.activeIndex ?? null);\n\t\t\t},\n\t\t\tonDestroyed: () => {\n\t\t\t\tcompleteTour();\n\t\t\t\tsetCurrentStep(null);\n\t\t\t\t// 引导结束后保持 dock 固定显示并隐藏触发区域\n\t\t\t\tsetDockDisplayMode(\"fixed\");\n\t\t\t\twindow.dispatchEvent(new Event(\"onboarding:hide-dock-trigger-zone\"));\n\t\t\t},\n\n\t\t\tsteps: [\n\t\t\t\t// Step 1: Welcome modal - 同时打开设置面板准备下一步\n\t\t\t\t{\n\t\t\t\t\tpopover: {\n\t\t\t\t\t\ttitle: t(\"welcomeTitle\"),\n\t\t\t\t\t\tdescription: t(\"welcomeDescription\"),\n\t\t\t\t\t\tside: \"over\" as const,\n\t\t\t\t\t\talign: \"center\" as const,\n\t\t\t\t\t},\n\t\t\t\t\tonHighlightStarted: () => {\n\t\t\t\t\t\t// 在欢迎步骤就打开设置面板，为下一步做准备\n\t\t\t\t\t\topenSettings();\n\t\t\t\t\t\t// 滚动到顶部\n\t\t\t\t\t\tsetTimeout(() => {\n\t\t\t\t\t\t\tselectSettingsCategory(\"ai\");\n\t\t\t\t\t\t\tscrollSettingsPanelToTop();\n\t\t\t\t\t\t}, 200);\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t// Step 2: API Key 配置\n\t\t\t\t{\n\t\t\t\t\telement: \"#llm-api-key\",\n\t\t\t\t\tpopover: {\n\t\t\t\t\t\ttitle: t(\"apiKeyStepTitle\"),\n\t\t\t\t\t\tdescription: t(\"apiKeyStepDescription\"),\n\t\t\t\t\t\tside: \"bottom\" as const,\n\t\t\t\t\t\talign: \"start\" as const,\n\t\t\t\t\t},\n\t\t\t\t\tonHighlightStarted: () => {\n\t\t\t\t\t\t// 确保元素可见\n\t\t\t\t\t\tselectSettingsCategory(\"ai\");\n\t\t\t\t\t\tconst element = document.getElementById(\"llm-api-key\");\n\t\t\t\t\t\tif (element) {\n\t\t\t\t\t\t\telement.scrollIntoView({ behavior: \"smooth\", block: \"center\" });\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t// Step 3: Bottom Dock 功能介绍\n\t\t\t\t{\n\t\t\t\t\telement: '[data-tour=\"bottom-dock\"]',\n\t\t\t\t\tpopover: {\n\t\t\t\t\t\ttitle: t(\"dockStepTitle\"),\n\t\t\t\t\t\tdescription: t(\"dockStepDescription\"),\n\t\t\t\t\t\tside: \"top\" as const,\n\t\t\t\t\t\talign: \"center\" as const,\n\t\t\t\t\t},\n\t\t\t\t\tonHighlightStarted: () => {\n\t\t\t\t\t\t// 固定显示 dock\n\t\t\t\t\t\tsetDockDisplayMode(\"fixed\");\n\t\t\t\t\t\t// 恢复 overlay 的点击阻止功能\n\t\t\t\t\t\tconst overlay = document.querySelector(\".driver-overlay\");\n\t\t\t\t\t\tif (overlay) {\n\t\t\t\t\t\t\t(overlay as HTMLElement).style.pointerEvents = \"\";\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t// Step 4: 右键点击引导（高亮 Panel B 的 dock item）\n\t\t\t\t{\n\t\t\t\t\telement: '[data-tour=\"dock-item-panelB\"]',\n\t\t\t\t\tpopover: {\n\t\t\t\t\t\ttitle: t(\"dockRightClickTitle\"),\n\t\t\t\t\t\tdescription: t(\"dockRightClickDescription\"),\n\t\t\t\t\t\tside: \"top\" as const,\n\t\t\t\t\t\talign: \"center\" as const,\n\t\t\t\t\t},\n\t\t\t\t\tonHighlightStarted: () => {\n\t\t\t\t\t\t// 让 overlay 允许点击穿透，这样用户可以右键点击\n\t\t\t\t\t\tconst overlay = document.querySelector(\".driver-overlay\");\n\t\t\t\t\t\tif (overlay) {\n\t\t\t\t\t\t\t(overlay as HTMLElement).style.pointerEvents = \"none\";\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// 监听 Panel B 上的右键点击事件\n\t\t\t\t\t\tconst panelBElement = document.querySelector(\n\t\t\t\t\t\t\t'[data-tour=\"dock-item-panelB\"]',\n\t\t\t\t\t\t);\n\t\t\t\t\t\tif (panelBElement) {\n\t\t\t\t\t\t\tconst handleContextMenu = () => {\n\t\t\t\t\t\t\t\t// 用户已右键点击，菜单会由 BottomDock 自动打开\n\t\t\t\t\t\t\t\t// 短暂延迟后进入下一步，等待菜单渲染\n\t\t\t\t\t\t\t\tsetTimeout(() => {\n\t\t\t\t\t\t\t\t\tdriverObj.moveNext();\n\t\t\t\t\t\t\t\t}, 100);\n\t\t\t\t\t\t\t\t// 移除监听器\n\t\t\t\t\t\t\t\tpanelBElement.removeEventListener(\n\t\t\t\t\t\t\t\t\t\"contextmenu\",\n\t\t\t\t\t\t\t\t\thandleContextMenu,\n\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t};\n\t\t\t\t\t\t\tpanelBElement.addEventListener(\"contextmenu\", handleContextMenu);\n\n\t\t\t\t\t\t\t// 存储清理函数\n\t\t\t\t\t\t\t(window as unknown as Record<string, () => void>)\n\t\t\t\t\t\t\t\t.__onboardingContextMenuCleanup = () => {\n\t\t\t\t\t\t\t\tpanelBElement.removeEventListener(\n\t\t\t\t\t\t\t\t\t\"contextmenu\",\n\t\t\t\t\t\t\t\t\thandleContextMenu,\n\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t};\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\tonDeselected: () => {\n\t\t\t\t\t\t// 清理事件监听器\n\t\t\t\t\t\tconst cleanup = (window as unknown as Record<string, () => void>)\n\t\t\t\t\t\t\t.__onboardingContextMenuCleanup;\n\t\t\t\t\t\tif (cleanup) {\n\t\t\t\t\t\t\tcleanup();\n\t\t\t\t\t\t\tdelete (window as unknown as Record<string, () => void>)\n\t\t\t\t\t\t\t\t.__onboardingContextMenuCleanup;\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// 恢复 overlay 的点击阻止功能\n\t\t\t\t\t\tconst overlay = document.querySelector(\".driver-overlay\");\n\t\t\t\t\t\tif (overlay) {\n\t\t\t\t\t\t\t(overlay as HTMLElement).style.pointerEvents = \"\";\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// 如果菜单还没打开（用户点击了\"下一步\"按钮），则程序化打开菜单\n\t\t\t\t\t\tconst menu = document.querySelector(\n\t\t\t\t\t\t\t'[data-tour=\"panel-selector-menu\"]',\n\t\t\t\t\t\t);\n\t\t\t\t\t\tif (!menu) {\n\t\t\t\t\t\t\twindow.dispatchEvent(\n\t\t\t\t\t\t\t\tnew CustomEvent(\"onboarding:open-dock-menu\", {\n\t\t\t\t\t\t\t\t\tdetail: { position: \"panelB\" },\n\t\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t// Step 5: 右键菜单高亮（同时高亮 Panel B 和菜单）\n\t\t\t\t{\n\t\t\t\t\telement: '[data-tour=\"panel-selector-menu\"]',\n\t\t\t\t\tpopover: {\n\t\t\t\t\t\ttitle: t(\"dockMenuTitle\"),\n\t\t\t\t\t\tdescription: t(\"dockMenuDescription\"),\n\t\t\t\t\t\tside: \"left\" as const,\n\t\t\t\t\t\talign: \"start\" as const,\n\t\t\t\t\t},\n\t\t\t\t\tonHighlightStarted: () => {\n\t\t\t\t\t\t// 确保菜单已打开（如果从其他方式进入此步骤）\n\t\t\t\t\t\tconst menu = document.querySelector(\n\t\t\t\t\t\t\t'[data-tour=\"panel-selector-menu\"]',\n\t\t\t\t\t\t);\n\t\t\t\t\t\tif (!menu) {\n\t\t\t\t\t\t\twindow.dispatchEvent(\n\t\t\t\t\t\t\t\tnew CustomEvent(\"onboarding:open-dock-menu\", {\n\t\t\t\t\t\t\t\t\tdetail: { position: \"panelB\" },\n\t\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// 让 overlay 允许点击穿透，让用户可以点击菜单项\n\t\t\t\t\t\tconst overlay = document.querySelector(\".driver-overlay\");\n\t\t\t\t\t\tif (overlay) {\n\t\t\t\t\t\t\t(overlay as HTMLElement).style.pointerEvents = \"none\";\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// 监听面板选择事件，用户选择任意面板后自动进入下一步\n\t\t\t\t\t\tconst handlePanelSelected = () => {\n\t\t\t\t\t\t\t// 短暂延迟后进入下一步，让面板切换动画完成\n\t\t\t\t\t\t\tsetTimeout(() => {\n\t\t\t\t\t\t\t\tdriverObj.moveNext();\n\t\t\t\t\t\t\t}, 150);\n\t\t\t\t\t\t\t// 移除监听器\n\t\t\t\t\t\t\twindow.removeEventListener(\n\t\t\t\t\t\t\t\t\"onboarding:panel-selected\",\n\t\t\t\t\t\t\t\thandlePanelSelected,\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t};\n\t\t\t\t\t\twindow.addEventListener(\n\t\t\t\t\t\t\t\"onboarding:panel-selected\",\n\t\t\t\t\t\t\thandlePanelSelected,\n\t\t\t\t\t\t);\n\n\t\t\t\t\t\t// 存储清理函数\n\t\t\t\t\t\t(window as unknown as Record<string, () => void>)\n\t\t\t\t\t\t\t.__onboardingPanelSelectedCleanup = () => {\n\t\t\t\t\t\t\twindow.removeEventListener(\n\t\t\t\t\t\t\t\t\"onboarding:panel-selected\",\n\t\t\t\t\t\t\t\thandlePanelSelected,\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t};\n\t\t\t\t\t},\n\t\t\t\t\tonDeselected: () => {\n\t\t\t\t\t\t// 清理事件监听器\n\t\t\t\t\t\tconst cleanup = (window as unknown as Record<string, () => void>)\n\t\t\t\t\t\t\t.__onboardingPanelSelectedCleanup;\n\t\t\t\t\t\tif (cleanup) {\n\t\t\t\t\t\t\tcleanup();\n\t\t\t\t\t\t\tdelete (window as unknown as Record<string, () => void>)\n\t\t\t\t\t\t\t\t.__onboardingPanelSelectedCleanup;\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// 恢复 overlay 的点击阻止功能\n\t\t\t\t\t\tconst overlay = document.querySelector(\".driver-overlay\");\n\t\t\t\t\t\tif (overlay) {\n\t\t\t\t\t\t\t(overlay as HTMLElement).style.pointerEvents = \"\";\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t// Step 6: Completion modal\n\t\t\t\t{\n\t\t\t\t\tpopover: {\n\t\t\t\t\t\ttitle: t(\"completeTitle\"),\n\t\t\t\t\t\tdescription: t(\"completeDescription\"),\n\t\t\t\t\t\tside: \"over\" as const,\n\t\t\t\t\t\talign: \"center\" as const,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t],\n\t\t});\n\n\t\tdriverRef.current = driverObj;\n\t\tdriverObj.drive();\n\t}, [completeTour, setCurrentStep, setDockDisplayMode, openSettings, t]);\n\n\t/**\n\t * Start the onboarding tour (only if not completed)\n\t */\n\tconst startTour = useCallback(() => {\n\t\tif (hasCompletedTour) return;\n\t\tcreateAndStartTour();\n\t}, [hasCompletedTour, createAndStartTour]);\n\n\t/**\n\t * Restart the tour (reset state and start immediately)\n\t * This is used when the user wants to see the tour again\n\t */\n\tconst restartTour = useCallback(() => {\n\t\t// Reset the tour state first\n\t\tuseOnboardingStore.getState().resetTour();\n\t\t// Start the tour after a short delay to ensure state is updated\n\t\tsetTimeout(() => {\n\t\t\tcreateAndStartTour();\n\t\t}, 100);\n\t}, [createAndStartTour]);\n\n\t/**\n\t * Skip the tour without completing it\n\t */\n\tconst skipTour = useCallback(() => {\n\t\tif (driverRef.current) {\n\t\t\tdriverRef.current.destroy();\n\t\t}\n\t\tcompleteTour();\n\t}, [completeTour]);\n\n\t/**\n\t * Reset the tour state to allow re-onboarding\n\t */\n\tconst resetTour = useCallback(() => {\n\t\tuseOnboardingStore.getState().resetTour();\n\t}, []);\n\n\treturn {\n\t\tstartTour,\n\t\trestartTour,\n\t\tskipTour,\n\t\tresetTour,\n\t\thasCompletedTour,\n\t};\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/hooks/useOpenSettings.ts",
    "content": "\"use client\";\n\nimport { useCallback } from \"react\";\nimport { useUiStore } from \"@/lib/store/ui-store\";\n\n/**\n * 计算各面板的实际宽度比例\n * 返回各面板的实际显示宽度（0~1）\n */\nfunction calculatePanelWidths(\n\tisPanelAOpen: boolean,\n\tisPanelBOpen: boolean,\n\tisPanelCOpen: boolean,\n\tpanelAWidth: number,\n\tpanelCWidth: number,\n): { panelA: number; panelB: number; panelC: number } {\n\t// 计算基础宽度（不包括 panelC）\n\tconst baseWidth = isPanelCOpen ? 1 - panelCWidth : 1;\n\tconst actualPanelCWidth = isPanelCOpen ? panelCWidth : 0;\n\n\t// 所有面板都关闭\n\tif (!isPanelAOpen && !isPanelBOpen && !isPanelCOpen) {\n\t\treturn { panelA: 0, panelB: 0, panelC: 0 };\n\t}\n\n\t// 三个面板都打开\n\tif (isPanelAOpen && isPanelBOpen && isPanelCOpen) {\n\t\treturn {\n\t\t\tpanelA: panelAWidth * baseWidth,\n\t\t\tpanelB: (1 - panelAWidth) * baseWidth,\n\t\t\tpanelC: actualPanelCWidth,\n\t\t};\n\t}\n\n\t// panelA 和 panelB 打开\n\tif (isPanelAOpen && isPanelBOpen) {\n\t\treturn {\n\t\t\tpanelA: panelAWidth,\n\t\t\tpanelB: 1 - panelAWidth,\n\t\t\tpanelC: 0,\n\t\t};\n\t}\n\n\t// panelB 和 panelC 打开\n\tif (isPanelBOpen && isPanelCOpen) {\n\t\treturn {\n\t\t\tpanelA: 0,\n\t\t\tpanelB: baseWidth,\n\t\t\tpanelC: actualPanelCWidth,\n\t\t};\n\t}\n\n\t// panelA 和 panelC 打开\n\tif (isPanelAOpen && isPanelCOpen) {\n\t\treturn {\n\t\t\tpanelA: baseWidth,\n\t\t\tpanelB: 0,\n\t\t\tpanelC: actualPanelCWidth,\n\t\t};\n\t}\n\n\t// 只有 panelA 打开\n\tif (isPanelAOpen) {\n\t\treturn { panelA: 1, panelB: 0, panelC: 0 };\n\t}\n\n\t// 只有 panelB 打开\n\tif (isPanelBOpen) {\n\t\treturn { panelA: 0, panelB: 1, panelC: 0 };\n\t}\n\n\t// 只有 panelC 打开\n\treturn { panelA: 0, panelB: 0, panelC: 1 };\n}\n\n/**\n * 提供打开设置页面的功能\n * 复用于 SettingsToggle 和 HeaderIsland 等组件\n *\n * 打开设置的逻辑：\n * - 如果 Panel B 已激活，直接切换 Panel B 到设置\n * - 否则找到最宽的 Panel（A 或 C），激活并切换到设置\n */\nexport function useOpenSettings() {\n\tconst {\n\t\tisPanelAOpen,\n\t\tisPanelBOpen,\n\t\tisPanelCOpen,\n\t\tpanelAWidth,\n\t\tpanelCWidth,\n\t\tpanelFeatureMap,\n\t\tsetPanelFeature,\n\t\ttogglePanelA,\n\t\ttogglePanelB,\n\t\ttogglePanelC,\n\t} = useUiStore();\n\n\t/**\n\t * 计算各面板的实际宽度比例\n\t */\n\tconst getPanelWidths = useCallback(() => {\n\t\treturn calculatePanelWidths(\n\t\t\tisPanelAOpen,\n\t\t\tisPanelBOpen,\n\t\t\tisPanelCOpen,\n\t\t\tpanelAWidth,\n\t\t\tpanelCWidth,\n\t\t);\n\t}, [isPanelAOpen, isPanelBOpen, isPanelCOpen, panelAWidth, panelCWidth]);\n\n\t/**\n\t * 打开设置页面\n\t */\n\tconst openSettings = useCallback(() => {\n\t\t// 检查当前是否已经有面板显示设置\n\t\tconst isSettingsInA = panelFeatureMap.panelA === \"settings\";\n\t\tconst isSettingsInB = panelFeatureMap.panelB === \"settings\";\n\t\tconst isSettingsInC = panelFeatureMap.panelC === \"settings\";\n\n\t\t// 如果设置已经在某个打开的面板中显示，不做任何操作\n\t\tif (\n\t\t\t(isSettingsInA && isPanelAOpen) ||\n\t\t\t(isSettingsInB && isPanelBOpen) ||\n\t\t\t(isSettingsInC && isPanelCOpen)\n\t\t) {\n\t\t\treturn;\n\t\t}\n\n\t\t// 情况 1: Panel B 已激活，直接切换到设置\n\t\tif (isPanelBOpen) {\n\t\t\tsetPanelFeature(\"panelB\", \"settings\");\n\t\t\treturn;\n\t\t}\n\n\t\t// 情况 2: Panel B 未激活，找最宽的面板\n\t\tconst widths = getPanelWidths();\n\n\t\t// 如果没有面板打开，打开 Panel B 并设置为设置\n\t\tif (widths.panelA === 0 && widths.panelC === 0) {\n\t\t\ttogglePanelB();\n\t\t\tsetPanelFeature(\"panelB\", \"settings\");\n\t\t\treturn;\n\t\t}\n\n\t\t// 找到最宽的面板并激活/切换到设置\n\t\tif (widths.panelA >= widths.panelC) {\n\t\t\t// Panel A 更宽或相等\n\t\t\tif (isPanelAOpen) {\n\t\t\t\tsetPanelFeature(\"panelA\", \"settings\");\n\t\t\t} else {\n\t\t\t\ttogglePanelA();\n\t\t\t\tsetPanelFeature(\"panelA\", \"settings\");\n\t\t\t}\n\t\t} else {\n\t\t\t// Panel C 更宽\n\t\t\tif (isPanelCOpen) {\n\t\t\t\tsetPanelFeature(\"panelC\", \"settings\");\n\t\t\t} else {\n\t\t\t\ttogglePanelC();\n\t\t\t\tsetPanelFeature(\"panelC\", \"settings\");\n\t\t\t}\n\t\t}\n\t}, [\n\t\tisPanelAOpen,\n\t\tisPanelBOpen,\n\t\tisPanelCOpen,\n\t\tpanelFeatureMap,\n\t\tsetPanelFeature,\n\t\ttogglePanelA,\n\t\ttogglePanelB,\n\t\ttogglePanelC,\n\t\tgetPanelWidths,\n\t]);\n\n\treturn { openSettings, getPanelWidths };\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/hooks/usePanelLayout.ts",
    "content": "/**\n * Panel 布局计算 Hook\n * 计算 Panel A、B、C 的显示状态和宽度\n */\n\nimport { useMemo } from \"react\";\n\ninterface UsePanelLayoutOptions {\n\tisPanelAOpen: boolean;\n\tisPanelBOpen: boolean;\n\tisPanelCOpen: boolean;\n\tpanelAWidth: number;\n\tpanelCWidth: number;\n}\n\nexport function usePanelLayout({\n\tisPanelAOpen,\n\tisPanelBOpen,\n\tisPanelCOpen,\n\tpanelAWidth,\n\tpanelCWidth,\n}: UsePanelLayoutOptions) {\n\tconst layoutState = useMemo(() => {\n\t\t// 计算基础宽度（不包括 panelC）\n\t\tconst baseWidth = isPanelCOpen ? 1 - panelCWidth : 1;\n\t\tconst actualPanelCWidth = isPanelCOpen ? panelCWidth : 0;\n\n\t\t// 所有面板都关闭的情况\n\t\tif (!isPanelAOpen && !isPanelBOpen && !isPanelCOpen) {\n\t\t\treturn {\n\t\t\t\tshowPanelA: false,\n\t\t\t\tshowPanelB: false,\n\t\t\t\tshowPanelC: false,\n\t\t\t\tpanelAWidth: 0,\n\t\t\t\tpanelBWidth: 0,\n\t\t\t\tpanelCWidth: 0,\n\t\t\t\tshowPanelAResizeHandle: false,\n\t\t\t\tshowPanelCResizeHandle: false,\n\t\t\t};\n\t\t}\n\n\t\tif (isPanelAOpen && isPanelBOpen && isPanelCOpen) {\n\t\t\t// 三个面板都打开\n\t\t\treturn {\n\t\t\t\tshowPanelA: true,\n\t\t\t\tshowPanelB: true,\n\t\t\t\tshowPanelC: true,\n\t\t\t\tpanelAWidth: panelAWidth * baseWidth,\n\t\t\t\tpanelBWidth: (1 - panelAWidth) * baseWidth,\n\t\t\t\tpanelCWidth: actualPanelCWidth,\n\t\t\t\tshowPanelAResizeHandle: true,\n\t\t\t\tshowPanelCResizeHandle: true,\n\t\t\t};\n\t\t}\n\n\t\tif (isPanelAOpen && isPanelBOpen) {\n\t\t\t// 只有 panelA 和 panelB 打开\n\t\t\treturn {\n\t\t\t\tshowPanelA: true,\n\t\t\t\tshowPanelB: true,\n\t\t\t\tshowPanelC: false,\n\t\t\t\tpanelAWidth: panelAWidth,\n\t\t\t\tpanelBWidth: 1 - panelAWidth,\n\t\t\t\tpanelCWidth: 0,\n\t\t\t\tshowPanelAResizeHandle: true,\n\t\t\t\tshowPanelCResizeHandle: false,\n\t\t\t};\n\t\t}\n\n\t\tif (isPanelBOpen && isPanelCOpen) {\n\t\t\t// 只有 panelB 和 panelC 打开\n\t\t\treturn {\n\t\t\t\tshowPanelA: false,\n\t\t\t\tshowPanelB: true,\n\t\t\t\tshowPanelC: true,\n\t\t\t\tpanelAWidth: 0,\n\t\t\t\tpanelBWidth: baseWidth,\n\t\t\t\tpanelCWidth: actualPanelCWidth,\n\t\t\t\tshowPanelAResizeHandle: false,\n\t\t\t\tshowPanelCResizeHandle: true,\n\t\t\t};\n\t\t}\n\n\t\tif (isPanelAOpen && isPanelCOpen) {\n\t\t\t// 只有 panelA 和 panelC 打开\n\t\t\treturn {\n\t\t\t\tshowPanelA: true,\n\t\t\t\tshowPanelB: false,\n\t\t\t\tshowPanelC: true,\n\t\t\t\tpanelAWidth: baseWidth,\n\t\t\t\tpanelBWidth: 0,\n\t\t\t\tpanelCWidth: actualPanelCWidth,\n\t\t\t\tshowPanelAResizeHandle: false,\n\t\t\t\tshowPanelCResizeHandle: true,\n\t\t\t};\n\t\t}\n\n\t\tif (isPanelAOpen && !isPanelBOpen) {\n\t\t\t// 只有 panelA 打开\n\t\t\treturn {\n\t\t\t\tshowPanelA: true,\n\t\t\t\tshowPanelB: false,\n\t\t\t\tshowPanelC: isPanelCOpen,\n\t\t\t\tpanelAWidth: baseWidth,\n\t\t\t\tpanelBWidth: 0,\n\t\t\t\tpanelCWidth: actualPanelCWidth,\n\t\t\t\tshowPanelAResizeHandle: false,\n\t\t\t\tshowPanelCResizeHandle: isPanelCOpen,\n\t\t\t};\n\t\t}\n\n\t\tif (!isPanelAOpen && isPanelBOpen) {\n\t\t\t// 只有 panelB 打开\n\t\t\treturn {\n\t\t\t\tshowPanelA: false,\n\t\t\t\tshowPanelB: true,\n\t\t\t\tshowPanelC: isPanelCOpen,\n\t\t\t\tpanelAWidth: 0,\n\t\t\t\tpanelBWidth: baseWidth,\n\t\t\t\tpanelCWidth: actualPanelCWidth,\n\t\t\t\tshowPanelAResizeHandle: false,\n\t\t\t\tshowPanelCResizeHandle: isPanelCOpen,\n\t\t\t};\n\t\t}\n\n\t\t// 只有 panelC 打开\n\t\treturn {\n\t\t\tshowPanelA: false,\n\t\t\tshowPanelB: false,\n\t\t\tshowPanelC: true,\n\t\t\tpanelAWidth: 0,\n\t\t\tpanelBWidth: 0,\n\t\t\tpanelCWidth: actualPanelCWidth,\n\t\t\tshowPanelAResizeHandle: false,\n\t\t\tshowPanelCResizeHandle: false,\n\t\t};\n\t}, [isPanelAOpen, isPanelBOpen, isPanelCOpen, panelAWidth, panelCWidth]);\n\n\treturn layoutState;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/hooks/usePanelResize.ts",
    "content": "/**\n * Panel A 和 Panel C 调整大小 Hook\n * 处理 Panel A 和 Panel C 的宽度调整逻辑\n */\n\nimport type { PointerEvent as ReactPointerEvent } from \"react\";\nimport { useCallback } from \"react\";\n\ninterface UsePanelResizeOptions {\n\tcontainerRef: React.RefObject<HTMLDivElement | null>;\n\tisPanelBOpen: boolean;\n\tisPanelCOpen: boolean;\n\tpanelCWidth: number;\n\tsetPanelAWidth: (width: number) => void;\n\tsetPanelCWidth: (width: number) => void;\n\tsetIsDraggingPanelA: (isDragging: boolean) => void;\n\tsetIsDraggingPanelC: (isDragging: boolean) => void;\n\tsetGlobalResizeCursor: (enabled: boolean) => void;\n}\n\nexport function usePanelResize({\n\tcontainerRef,\n\tisPanelBOpen,\n\tisPanelCOpen,\n\tpanelCWidth,\n\tsetPanelAWidth,\n\tsetPanelCWidth,\n\tsetIsDraggingPanelA,\n\tsetIsDraggingPanelC,\n\tsetGlobalResizeCursor,\n}: UsePanelResizeOptions) {\n\tconst handlePanelADragAtClientX = useCallback(\n\t\t(clientX: number) => {\n\t\t\tconst container = containerRef.current;\n\t\t\tif (!container) return;\n\n\t\t\tconst rect = container.getBoundingClientRect();\n\t\t\tif (rect.width <= 0) return;\n\n\t\t\tconst relativeX = clientX - rect.left;\n\t\t\tconst ratio = relativeX / rect.width;\n\n\t\t\t// 当 panelC 打开时，panelA 的宽度是相对于 baseWidth 的比例\n\t\t\t// baseWidth = 1 - panelCWidth\n\t\t\t// 所以需要将 ratio 转换为相对于 baseWidth 的比例\n\t\t\tif (isPanelCOpen && isPanelBOpen) {\n\t\t\t\tconst baseWidth = 1 - panelCWidth;\n\t\t\t\tif (baseWidth > 0) {\n\t\t\t\t\tconst adjustedRatio = ratio / baseWidth;\n\t\t\t\t\tsetPanelAWidth(adjustedRatio);\n\t\t\t\t} else {\n\t\t\t\t\tsetPanelAWidth(0.5);\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tsetPanelAWidth(ratio);\n\t\t\t}\n\t\t},\n\t\t[setPanelAWidth, isPanelCOpen, isPanelBOpen, panelCWidth, containerRef],\n\t);\n\n\tconst handlePanelCDragAtClientX = useCallback(\n\t\t(clientX: number) => {\n\t\t\tconst container = containerRef.current;\n\t\t\tif (!container) return;\n\n\t\t\tconst rect = container.getBoundingClientRect();\n\t\t\tif (rect.width <= 0) return;\n\n\t\t\tconst relativeX = clientX - rect.left;\n\t\t\tconst ratio = relativeX / rect.width;\n\t\t\t// panelCWidth 是从右侧开始计算的，所以是 1 - ratio\n\t\t\tsetPanelCWidth(1 - ratio);\n\t\t},\n\t\t[setPanelCWidth, containerRef],\n\t);\n\n\tconst handlePanelAResizePointerDown = useCallback(\n\t\t(event: ReactPointerEvent<HTMLDivElement>) => {\n\t\t\tevent.preventDefault();\n\t\t\tevent.stopPropagation();\n\n\t\t\tsetIsDraggingPanelA(true);\n\t\t\tsetGlobalResizeCursor(true);\n\t\t\thandlePanelADragAtClientX(event.clientX);\n\n\t\t\tconst handlePointerMove = (moveEvent: PointerEvent) => {\n\t\t\t\thandlePanelADragAtClientX(moveEvent.clientX);\n\t\t\t};\n\n\t\t\tconst handlePointerUp = () => {\n\t\t\t\tsetIsDraggingPanelA(false);\n\t\t\t\tsetGlobalResizeCursor(false);\n\t\t\t\twindow.removeEventListener(\"pointermove\", handlePointerMove);\n\t\t\t\twindow.removeEventListener(\"pointerup\", handlePointerUp);\n\t\t\t};\n\n\t\t\twindow.addEventListener(\"pointermove\", handlePointerMove);\n\t\t\twindow.addEventListener(\"pointerup\", handlePointerUp);\n\t\t},\n\t\t[handlePanelADragAtClientX, setIsDraggingPanelA, setGlobalResizeCursor],\n\t);\n\n\tconst handlePanelCResizePointerDown = useCallback(\n\t\t(event: ReactPointerEvent<HTMLDivElement>) => {\n\t\t\tevent.preventDefault();\n\t\t\tevent.stopPropagation();\n\n\t\t\tsetIsDraggingPanelC(true);\n\t\t\tsetGlobalResizeCursor(true);\n\t\t\thandlePanelCDragAtClientX(event.clientX);\n\n\t\t\tconst handlePointerMove = (moveEvent: PointerEvent) => {\n\t\t\t\thandlePanelCDragAtClientX(moveEvent.clientX);\n\t\t\t};\n\n\t\t\tconst handlePointerUp = () => {\n\t\t\t\tsetIsDraggingPanelC(false);\n\t\t\t\tsetGlobalResizeCursor(false);\n\t\t\t\twindow.removeEventListener(\"pointermove\", handlePointerMove);\n\t\t\t\twindow.removeEventListener(\"pointerup\", handlePointerUp);\n\t\t\t};\n\n\t\t\twindow.addEventListener(\"pointermove\", handlePointerMove);\n\t\t\twindow.addEventListener(\"pointerup\", handlePointerUp);\n\t\t},\n\t\t[handlePanelCDragAtClientX, setIsDraggingPanelC, setGlobalResizeCursor],\n\t);\n\n\treturn {\n\t\thandlePanelAResizePointerDown,\n\t\thandlePanelCResizePointerDown,\n\t};\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/hooks/usePanelWindowResize.ts",
    "content": "/**\n * Panel 窗口调整大小 Hook\n * 处理 Panel 窗口的宽度调整逻辑\n */\n\nimport { useCallback } from \"react\";\nimport { getElectronAPI } from \"@/lib/utils/electron-api\";\n\ninterface UsePanelWindowResizeOptions {\n\tpanelWindowWidth: number;\n\tpanelWindowPosition: { x: number; y: number };\n\tpanelWindowHeight: number;\n\tisElectron: boolean;\n\tMIN_PANEL_WIDTH: number;\n\tMAX_PANEL_WIDTH: number;\n\tMIN_PANEL_HEIGHT: number;\n\tMAX_PANEL_HEIGHT: number;\n\tsetPanelWindowWidth: (width: number) => void;\n\tsetPanelWindowPosition: (position: { x: number; y: number }) => void;\n\tsetPanelWindowHeight: (height: number) => void;\n\tsetIsResizingPanel: (isResizing: boolean) => void;\n\tsetIsUserInteracting: (isInteracting: boolean) => void;\n}\n\nexport function usePanelWindowResize({\n\tpanelWindowWidth,\n\tpanelWindowPosition,\n\tpanelWindowHeight,\n\tisElectron,\n\tMIN_PANEL_WIDTH,\n\tMAX_PANEL_WIDTH,\n\tMIN_PANEL_HEIGHT,\n\tMAX_PANEL_HEIGHT,\n\tsetPanelWindowWidth,\n\tsetPanelWindowPosition,\n\tsetPanelWindowHeight,\n\tsetIsResizingPanel,\n\tsetIsUserInteracting,\n}: UsePanelWindowResizeOptions) {\n\tconst handlePanelResizeStart = useCallback((e: React.PointerEvent<HTMLDivElement> | React.MouseEvent<HTMLDivElement>, resizeSide: 'left' | 'right' | 'top' | 'bottom') => {\n\t\te.preventDefault();\n\t\te.stopPropagation();\n\n\t\t// ✅ 设置交互标志，防止定时器干扰\n\t\tsetIsUserInteracting(true);\n\t\tsetIsResizingPanel(true);\n\n\t\t// ✅ 立即禁用点击穿透（只调用一次，避免频繁 IPC 调用）\n\t\tif (isElectron) {\n\t\t\tconst api = getElectronAPI();\n\t\t\tapi.electronAPI?.setIgnoreMouseEvents?.(false);\n\t\t}\n\n\t\tconst startX = e.clientX;\n\t\tconst startY = e.clientY;\n\t\tconst startWidth = panelWindowWidth;\n\t\t// ✅ 修复：参考左右调整的实现，只在开始时获取一次高度，避免卡顿\n\t\t// panelWindowHeight 是 PanelRegion 的高度（包括 Panels 容器 + BottomDock 60px）\n\t\t// 如果 panelWindowHeight 为 0，使用默认 PanelRegion 高度计算\n\t\tlet actualStartHeight: number;\n\t\tif (panelWindowHeight > 0) {\n\t\t\tactualStartHeight = panelWindowHeight; // PanelRegion 高度\n\t\t} else {\n\t\t\t// 如果高度为 0，使用默认 PanelRegion 高度（参考左右调整，不查询 DOM）\n\t\t\t// PanelRegion 高度 = 窗口高度 - 顶部偏移(40px) - 标题栏(48px)\n\t\t\tactualStartHeight = typeof window !== 'undefined' ? window.innerHeight - 40 - 48 : 1000;\n\t\t}\n\t\tconst startHeight = actualStartHeight; // 这是 PanelRegion 的高度\n\t\tconst startXPosition = panelWindowPosition.x;\n\t\tconst startYPosition = panelWindowPosition.y;\n\n\t\t// ✅ 参考左右调整的完美实现：不使用 requestAnimationFrame，直接更新状态\n\t\tconst handlePointerMove = (moveEvent: PointerEvent | MouseEvent) => {\n\t\t\tif (resizeSide === 'top') {\n\t\t\t\t// ✅ 顶部调整：参考左边调整的逻辑\n\t\t\t\t// 向上拖拽（deltaY < 0）增加高度，向下拖拽（deltaY > 0）减少高度\n\t\t\t\t// deltaY = moveEvent.clientY - startY\n\t\t\t\t// 向上拖拽时，鼠标向上移动，deltaY < 0，高度应该增加\n\t\t\t\t// 向下拖拽时，鼠标向下移动，deltaY > 0，高度应该减少\n\t\t\t\t// 所以：newHeight = startHeight - deltaY\n\t\t\t\tconst deltaY = moveEvent.clientY - startY;\n\t\t\t\tconst newHeight = Math.max(\n\t\t\t\t\tMIN_PANEL_HEIGHT,\n\t\t\t\t\tMath.min(MAX_PANEL_HEIGHT, startHeight - deltaY)\n\t\t\t\t);\n\t\t\t\t// 当高度改变时，y 位置也需要调整，以保持底部边界不变\n\t\t\t\t// 高度增加了 deltaHeight，y 位置需要减少相同的量（向上移动）\n\t\t\t\tconst deltaHeight = newHeight - startHeight;\n\t\t\t\tconst newY = Math.max(0, startYPosition - deltaHeight);\n\t\t\t\tsetPanelWindowHeight(newHeight);\n\t\t\t\tsetPanelWindowPosition({ ...panelWindowPosition, y: newY });\n\t\t\t} else if (resizeSide === 'bottom') {\n\t\t\t\t// ✅ 底部调整：参考右边调整的逻辑\n\t\t\t\t// 向下拖拽（deltaY > 0）增加高度，向上拖拽（deltaY < 0）减少高度\n\t\t\t\t// deltaY = moveEvent.clientY - startY\n\t\t\t\t// 向下拖拽时，鼠标向下移动，deltaY > 0，高度应该增加\n\t\t\t\t// 向上拖拽时，鼠标向上移动，deltaY < 0，高度应该减少\n\t\t\t\t// 所以：newHeight = startHeight + deltaY\n\t\t\t\tconst deltaY = moveEvent.clientY - startY;\n\t\t\t\tconst newHeight = Math.max(\n\t\t\t\t\tMIN_PANEL_HEIGHT,\n\t\t\t\t\tMath.min(MAX_PANEL_HEIGHT, startHeight + deltaY)\n\t\t\t\t);\n\t\t\t\t// 底部调整时，y 位置不变，只改变高度\n\t\t\t\tsetPanelWindowHeight(newHeight);\n\t\t\t} else if (resizeSide === 'left') {\n\t\t\t\t// 左边调整：向左拖拽增加宽度，向右拖拽减少宽度\n\t\t\t\t// deltaX > 0 表示向左移动（增加宽度），deltaX < 0 表示向右移动（减少宽度）\n\t\t\t\tconst deltaX = startX - moveEvent.clientX;\n\t\t\t\tconst newWidth = Math.max(\n\t\t\t\t\tMIN_PANEL_WIDTH,\n\t\t\t\t\tMath.min(MAX_PANEL_WIDTH, startWidth + deltaX)\n\t\t\t\t);\n\t\t\t\t// 当宽度改变时，x 位置也需要调整，以保持右边界不变\n\t\t\t\t// 宽度增加了 deltaWidth，x 位置需要减少相同的量\n\t\t\t\tconst deltaWidth = newWidth - startWidth;\n\t\t\t\tconst newX = Math.max(0, startXPosition - deltaWidth);\n\t\t\t\tsetPanelWindowWidth(newWidth);\n\t\t\t\tsetPanelWindowPosition({ ...panelWindowPosition, x: newX });\n\t\t\t} else {\n\t\t\t\t// 右边调整：向右拖拽增加宽度，向左拖拽减少宽度\n\t\t\t\t// deltaX < 0 表示向左移动（减少宽度），deltaX > 0 表示向右移动（增加宽度）\n\t\t\t\tconst deltaX = moveEvent.clientX - startX;\n\t\t\t\tconst newWidth = Math.max(\n\t\t\t\t\tMIN_PANEL_WIDTH,\n\t\t\t\t\tMath.min(MAX_PANEL_WIDTH, startWidth + deltaX)\n\t\t\t\t);\n\t\t\t\t// 右边调整时，x 位置不变，只改变宽度\n\t\t\t\tsetPanelWindowWidth(newWidth);\n\t\t\t}\n\t\t};\n\n\t\tconst handlePointerUp = () => {\n\t\t\tsetIsResizingPanel(false);\n\t\t\t// ✅ 清除交互标志\n\t\t\tsetIsUserInteracting(false);\n\n\t\t\t// ✅ 清理后确保点击穿透仍然关闭\n\t\t\tif (isElectron) {\n\t\t\t\tconst api = getElectronAPI();\n\t\t\t\tapi.electronAPI?.setIgnoreMouseEvents?.(false);\n\t\t\t}\n\t\t\tdocument.removeEventListener(\"pointermove\", handlePointerMove);\n\t\t\tdocument.removeEventListener(\"pointerup\", handlePointerUp);\n\t\t\tdocument.removeEventListener(\"mousemove\", handlePointerMove);\n\t\t\tdocument.removeEventListener(\"mouseup\", handlePointerUp);\n\t\t};\n\n\t\t// 同时监听 pointer 和 mouse 事件以确保兼容性\n\t\tdocument.addEventListener(\"pointermove\", handlePointerMove);\n\t\tdocument.addEventListener(\"pointerup\", handlePointerUp);\n\t\tdocument.addEventListener(\"mousemove\", handlePointerMove);\n\t\tdocument.addEventListener(\"mouseup\", handlePointerUp);\n\t}, [panelWindowWidth, panelWindowPosition, panelWindowHeight, isElectron, MIN_PANEL_WIDTH, MAX_PANEL_WIDTH, MIN_PANEL_HEIGHT, MAX_PANEL_HEIGHT, setPanelWindowWidth, setPanelWindowPosition, setPanelWindowHeight, setIsResizingPanel, setIsUserInteracting]);\n\n\treturn { handlePanelResizeStart };\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/hooks/usePanelWindowStyles.ts",
    "content": "/**\n * Panel 窗口样式管理 Hook\n * 确保 Panel 窗口在拖动和调整大小时保持可见\n */\n\nimport { useLayoutEffect } from \"react\";\n\ninterface UsePanelWindowStylesOptions {\n\tisPanelMode: boolean;\n\tpanelWindowHeight: number;\n}\n\nexport function usePanelWindowStyles({\n\tisPanelMode,\n\tpanelWindowHeight,\n}: UsePanelWindowStylesOptions) {\n\t// ✅ 关键修复：监听拖动状态，确保 Panel DOM 元素在拖动时保持可见\n\t// 使用 useLayoutEffect + 三重 requestAnimationFrame 确保在 React 应用 style 之后执行\n\tuseLayoutEffect(() => {\n\t\tif (!isPanelMode) return;\n\n\t\t// 使用三重 requestAnimationFrame 确保在 React 应用 style prop 之后执行\n\t\t// 这样设置的样式不会被 React 的 style prop 覆盖\n\t\trequestAnimationFrame(() => {\n\t\t\trequestAnimationFrame(() => {\n\t\t\t\trequestAnimationFrame(() => {\n\t\t\t\t\tconst panelWindow = document.querySelector('[data-panel-window]') as HTMLElement;\n\t\t\t\t\tif (!panelWindow) return;\n\n\t\t\t\t\t// 强制设置样式，使用 !important 确保优先级高于 React 的 style prop\n\t\t\t\t\tpanelWindow.style.setProperty('opacity', '1', 'important');\n\t\t\t\t\tpanelWindow.style.setProperty('background-color', 'white', 'important');\n\t\t\t\t\tpanelWindow.style.setProperty('background', 'white', 'important');\n\t\t\t\t\tpanelWindow.style.setProperty('visibility', 'visible', 'important');\n\t\t\t\t\tpanelWindow.style.setProperty('display', 'flex', 'important');\n\t\t\t\t\tpanelWindow.style.setProperty('z-index', '1000001', 'important'); // ✅ 确保高于 DynamicIsland\n\t\t\t\t\tpanelWindow.style.setProperty('position', 'fixed', 'important');\n\t\t\t\t\t// ✅ 移除 bottom，使用固定高度，不随 y 位置变化\n\t\t\t\t\tpanelWindow.style.removeProperty('bottom');\n\t\t\t\t\t// 保持高度固定，避免拖动时高度变化\n\t\t\t\t\t// panelWindowHeight 是 PanelRegion 高度（包括 Panels 容器 + BottomDock 60px），窗口总高度 = 标题栏(48px) + panelWindowHeight\n\t\t\t\t\tconst heightValue = panelWindowHeight > 0\n\t\t\t\t\t\t? `${panelWindowHeight + 48}px`\n\t\t\t\t\t\t: `calc(100vh - 40px)`;\n\t\t\t\t\tpanelWindow.style.setProperty('height', heightValue, 'important');\n\t\t\t\t});\n\t\t\t});\n\t\t});\n\t}, [isPanelMode, panelWindowHeight]);\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/hooks/useTodoCapture.ts",
    "content": "\"use client\";\n\nimport { useQueryClient } from \"@tanstack/react-query\";\nimport { useCallback, useState } from \"react\";\nimport { queryKeys } from \"@/lib/query/keys\";\nimport { toastError, toastInfo, toastSuccess } from \"@/lib/toast\";\nimport { getElectronAPI } from \"@/lib/utils/electron-api\";\n\ninterface ExtractedTodoResponse {\n\ttitle: string;\n\tdescription?: string;\n\ttime_info?: Record<string, unknown>;\n\tsource_text?: string;\n\tconfidence: number;\n}\n\ninterface CaptureExtractResult {\n\tsuccess: boolean;\n\tmessage?: string;\n\textractedTodos: ExtractedTodoResponse[];\n\tcreatedCount?: number;\n}\n\n/**\n * 截图并提取待办的 Hook\n * 封装截图+提取逻辑，管理 Loading/Success/Error 状态\n */\nexport function useTodoCapture() {\n\tconst [isCapturing, setIsCapturing] = useState(false);\n\tconst [result, setResult] = useState<CaptureExtractResult | null>(null);\n\tconst queryClient = useQueryClient();\n\n\tconst captureAndExtract = useCallback(async () => {\n\t\tconst api = getElectronAPI();\n\n\t\t// 检查是否在 Electron 环境中\n\t\tif (!api.electronAPI?.captureAndExtractTodos) {\n\t\t\ttoastError(\"请在桌面应用中使用此功能\");\n\t\t\treturn null;\n\t\t}\n\n\t\ttry {\n\t\t\tsetIsCapturing(true);\n\t\t\tsetResult(null);\n\t\t\ttoastInfo(\"正在截图...\");\n\n\t\t\t// 获取 panel 的位置信息（如果存在）\n\t\t\tlet panelBounds: { x: number; y: number; width: number; height: number } | null = null;\n\t\t\ttry {\n\t\t\t\tconst panelElement = document.querySelector('[data-panel-window]') as HTMLElement;\n\t\t\t\tif (panelElement) {\n\t\t\t\t\tconst rect = panelElement.getBoundingClientRect();\n\t\t\t\t\t// getBoundingClientRect 返回的是相对于视口的位置\n\t\t\t\t\t// 在 Electron 中，窗口坐标就是相对于窗口左上角的\n\t\t\t\t\t// 后端会加上窗口在屏幕上的位置来得到屏幕坐标\n\t\t\t\t\tpanelBounds = {\n\t\t\t\t\t\tx: rect.left,\n\t\t\t\t\t\ty: rect.top,\n\t\t\t\t\t\twidth: rect.width,\n\t\t\t\t\t\theight: rect.height,\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t} catch (error) {\n\t\t\t\tconsole.warn(\"Failed to get panel bounds:\", error);\n\t\t\t}\n\n\t\t\t// 调用 Electron API 截图并提取待办\n\t\t\tconst response = await api.electronAPI.captureAndExtractTodos(panelBounds);\n\n\t\t\tif (response.success) {\n\t\t\t\tconst createdCount = response.createdCount ?? 0;\n\n\t\t\t\tif (createdCount > 0) {\n\t\t\t\t\t// 后端已直接创建了 draft 状态的待办\n\t\t\t\t\ttoastSuccess(`已创建 ${createdCount} 个待办事项（草稿状态）`);\n\t\t\t\t\t// 强制刷新待办列表\n\t\t\t\t\tqueryClient.invalidateQueries({ queryKey: queryKeys.todos.all });\n\t\t\t\t} else if (response.extractedTodos.length > 0) {\n\t\t\t\t\t// 提取到了但未创建（理论上不应该发生，因为 create_todos=true）\n\t\t\t\t\ttoastInfo(`已提取 ${response.extractedTodos.length} 个待办事项，但未创建`);\n\t\t\t\t} else {\n\t\t\t\t\ttoastInfo(\"未检测到待办事项\");\n\t\t\t\t}\n\n\t\t\t\tconst captureResult: CaptureExtractResult = {\n\t\t\t\t\tsuccess: true,\n\t\t\t\t\tmessage: response.message,\n\t\t\t\t\textractedTodos: response.extractedTodos,\n\t\t\t\t\tcreatedCount,\n\t\t\t\t};\n\n\t\t\t\tsetResult(captureResult);\n\t\t\t\treturn captureResult;\n\t\t\t} else {\n\t\t\t\tconst errorMessage = response.message || \"提取失败\";\n\t\t\t\ttoastError(errorMessage);\n\t\t\t\tsetResult({\n\t\t\t\t\tsuccess: false,\n\t\t\t\t\tmessage: errorMessage,\n\t\t\t\t\textractedTodos: [],\n\t\t\t\t\tcreatedCount: 0,\n\t\t\t\t});\n\t\t\t\treturn null;\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tconst errorMessage =\n\t\t\t\terror instanceof Error ? error.message : \"未知错误\";\n\t\t\ttoastError(`提取待办失败: ${errorMessage}`);\n\t\t\tsetResult({\n\t\t\t\tsuccess: false,\n\t\t\t\tmessage: errorMessage,\n\t\t\t\textractedTodos: [],\n\t\t\t\tcreatedCount: 0,\n\t\t\t});\n\t\t\treturn null;\n\t\t} finally {\n\t\t\tsetIsCapturing(false);\n\t\t}\n\t}, [queryClient]);\n\n\tconst clearResult = useCallback(() => {\n\t\tsetResult(null);\n\t}, []);\n\n\treturn {\n\t\tisCapturing,\n\t\tresult,\n\t\tcaptureAndExtract,\n\t\tclearResult,\n\t};\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/hooks/useWindowAdaptivePanels.ts",
    "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport type { PanelPosition } from \"@/lib/config/panel-config\";\nimport { useUiStore } from \"@/lib/store/ui-store\";\n\nconst MIN_PANEL_WIDTH_PX = 300;\n\n/**\n * Hook for adaptive panel management based on window width\n * Automatically closes/opens panels when window width changes\n */\nexport function useWindowAdaptivePanels(\n\tcontainerRef: React.RefObject<HTMLDivElement | null>,\n) {\n\tconst { setAutoClosePanel, restoreAutoClosedPanel } = useUiStore();\n\n\t// 使用ref来存储上一次的宽度，避免重复计算\n\tconst lastWidthRef = useRef<number>(0);\n\tconst timeoutRef = useRef<NodeJS.Timeout | null>(null);\n\t// 使用ref存储store的getState函数，避免依赖变化\n\tconst storeRef = useRef(useUiStore.getState());\n\n\t// 更新store引用\n\tuseEffect(() => {\n\t\tstoreRef.current = useUiStore.getState();\n\t});\n\n\tuseEffect(() => {\n\t\tconst container = containerRef.current;\n\t\tif (!container) return;\n\n\t\t// 计算当前打开的panel数量\n\t\tconst getOpenPanelCount = (): number => {\n\t\t\tconst state = storeRef.current;\n\t\t\tlet count = 0;\n\t\t\tif (state.isPanelAOpen) count++;\n\t\t\tif (state.isPanelBOpen) count++;\n\t\t\tif (state.isPanelCOpen) count++;\n\t\t\treturn count;\n\t\t};\n\n\t\t// 找到最右侧打开的panel\n\t\tconst getRightmostOpenPanel = (): PanelPosition | null => {\n\t\t\tconst state = storeRef.current;\n\t\t\t// 优先级：panelC > panelB > panelA（从右到左）\n\t\t\tif (state.isPanelCOpen) return \"panelC\";\n\t\t\tif (state.isPanelBOpen) return \"panelB\";\n\t\t\tif (state.isPanelAOpen) return \"panelA\";\n\t\t\treturn null;\n\t\t};\n\n\t\t// 处理窗口宽度变化\n\t\tconst handleResize = () => {\n\t\t\t// 清除之前的timeout\n\t\t\tif (timeoutRef.current) {\n\t\t\t\tclearTimeout(timeoutRef.current);\n\t\t\t}\n\n\t\t\t// 防抖处理：200ms延迟\n\t\t\ttimeoutRef.current = setTimeout(() => {\n\t\t\t\tconst rect = container.getBoundingClientRect();\n\t\t\t\tconst containerWidth = rect.width;\n\n\t\t\t\t// 如果宽度没有变化，跳过\n\t\t\t\tif (Math.abs(containerWidth - lastWidthRef.current) < 1) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tlastWidthRef.current = containerWidth;\n\n\t\t\t\t// 计算能容纳的最大panel数量\n\t\t\t\tconst maxPanels = Math.floor(containerWidth / MIN_PANEL_WIDTH_PX);\n\t\t\t\tconst openPanelCount = getOpenPanelCount();\n\t\t\t\tconst state = storeRef.current;\n\n\t\t\t\t// 如果打开的panel数量超过能容纳的数量，关闭最右侧的panel\n\t\t\t\tif (openPanelCount > maxPanels) {\n\t\t\t\t\tconst rightmostPanel = getRightmostOpenPanel();\n\t\t\t\t\tif (rightmostPanel) {\n\t\t\t\t\t\tsetAutoClosePanel(rightmostPanel);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\t// 如果打开的panel数量小于能容纳的数量，且有自动关闭的panel，恢复最近关闭的panel\n\t\t\t\telse if (\n\t\t\t\t\topenPanelCount < maxPanels &&\n\t\t\t\t\tstate.autoClosedPanels.length > 0\n\t\t\t\t) {\n\t\t\t\t\trestoreAutoClosedPanel();\n\t\t\t\t}\n\t\t\t}, 200);\n\t\t};\n\n\t\t// 使用ResizeObserver监听容器宽度变化\n\t\tconst resizeObserver = new ResizeObserver(handleResize);\n\t\tresizeObserver.observe(container);\n\n\t\t// 初始检查\n\t\thandleResize();\n\n\t\t// 清理函数\n\t\treturn () => {\n\t\t\tresizeObserver.disconnect();\n\t\t\tif (timeoutRef.current) {\n\t\t\t\tclearTimeout(timeoutRef.current);\n\t\t\t}\n\t\t};\n\t}, [containerRef, setAutoClosePanel, restoreAutoClosedPanel]);\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/i18n/messages/en.json",
    "content": "{\n\t\"language\": {\n\t\t\"zh\": \"中文\",\n\t\t\"en\": \"English\"\n\t},\n\t\"colorTheme\": {\n\t\t\"catppuccin\": \"Catppuccin\",\n\t\t\"blue\": \"Blue\",\n\t\t\"neutral\": \"Neutral\",\n\t\t\"label\": \"Theme Style\"\n\t},\n\t\"theme\": {\n\t\t\"light\": \"Light\",\n\t\t\"dark\": \"Dark\",\n\t\t\"system\": \"System\"\n\t},\n\t\"layout\": {\n\t\t\"currentLanguage\": \"Current Language\",\n\t\t\"currentTheme\": \"Current Theme\",\n\t\t\"userSettings\": \"User Settings\",\n\t\t\"openSettings\": \"Open Settings\"\n\t},\n\t\"page\": {\n\t\t\"title\": \"Free Todo Canvas\",\n\t\t\"subtitle\": \"Calendar and Todos view side by side. Toggle via bottom dock and resize panels by dragging the handle.\",\n\t\t\"calendarLabel\": \"Calendar View\",\n\t\t\"calendarPlaceholder\": \"Placeholder: plug a calendar component here\",\n\t\t\"activityLabel\": \"Activity Stream\",\n\t\t\"activityPlaceholder\": \"Placeholder: plug an activity dashboard here\",\n\t\t\"todosLabel\": \"Todos View\",\n\t\t\"todosPlaceholder\": \"Placeholder: plug a Todo list here\",\n\t\t\"todoListTitle\": \"Todos\",\n\t\t\"chatLabel\": \"AI Chat\",\n\t\t\"chatPlaceholder\": \"Placeholder: plug an AI chat component here\",\n\t\t\"chatTitle\": \"Free Todo - AI Assistant\",\n\t\t\"chatSubtitle\": \"A personalized AI chat app that helps you manage todos and boost productivity.\",\n\t\t\"chatQuestion\": \"How can I help you today?\",\n\t\t\"chatSuggestion1\": \"Break down today's todos and prioritize\",\n\t\t\"chatSuggestion2\": \"Plan my week with calendar and todos\",\n\t\t\"chatSuggestion3\": \"Summarize project tasks and next steps\",\n\t\t\"chatInputPlaceholder\": \"I am working on\",\n\t\t\"chatSendButton\": \"Send\",\n\t\t\"chatHistory\": \"History\",\n\t\t\"newChat\": \"New chat\",\n\t\t\"recentSessions\": \"Recent sessions\",\n\t\t\"noHistory\": \"No history yet\",\n\t\t\"messagesCount\": \"{count} messages\",\n\t\t\"loadHistoryFailed\": \"Failed to load history\",\n\t\t\"loadSessionFailed\": \"Failed to load session\",\n\t\t\"sessionLoaded\": \"Session loaded\",\n\t\t\"todoDetailLabel\": \"Todo Detail\",\n\t\t\"todoDetailPlaceholder\": \"Placeholder: plug a todo detail component here\",\n\t\t\"diaryLabel\": \"Diary\",\n\t\t\"diaryPlaceholder\": \"Placeholder: plug a diary component here\",\n\t\t\"settingsLabel\": \"Settings\",\n\t\t\"settingsPlaceholder\": \"Placeholder: plug a settings component here\",\n\t\t\"costTrackingLabel\": \"Cost Tracking\",\n\t\t\"costTrackingPlaceholder\": \"Placeholder: view cost tracking here\",\n\t\t\"achievementsLabel\": \"Achievements\",\n\t\t\"achievementsPlaceholder\": \"Placeholder: plug an achievements component here\",\n\t\t\"screenshotsLabel\": \"Screenshots (Debug)\",\n\t\t\"screenshotsPlaceholder\": \"Event timeline with screenshots (dev mode only)\",\n\t\t\"debugShotsLabel\": \"Debug Shots\",\n\t\t\"debugShotsPlaceholder\": \"Placeholder: manage debugging screenshots (dev mode only)\",\n\t\t\"audioLabel\": \"Audio Recording\",\n\t\t\"audioPlaceholder\": \"Placeholder: plug an audio recording component here\",\n\t\t\"backendUnavailableBadge\": \"Backend unavailable\",\n\t\t\"backendUnavailableTooltip\": \"Backend module is disabled or missing dependencies.\",\n\t\t\"backendUnavailableTitle\": \"Feature unavailable\",\n\t\t\"backendUnavailableDescription\": \"Backend module is disabled or missing dependencies.\",\n\t\t\"audioRecording\": \"Recording\",\n\t\t\"audioRecordingStopped\": \"Stopped\",\n\t\t\"audioStandby\": \"Standby\",\n\t\t\"audioStartRecording\": \"Start Recording\",\n\t\t\"audioStopRecording\": \"Stop Recording\",\n\t\t\"audioRecordingStatus\": \"Recording Status\",\n\t\t\"settings\": {\n\t\t\t\"categoryWorkspaceTitle\": \"Workspace & Panels\",\n\t\t\t\"categoryWorkspaceDescription\": \"Adjust dock behavior and panel visibility.\",\n\t\t\t\"categoryAutomationTitle\": \"Automation\",\n\t\t\t\"categoryAutomationDescription\": \"Manage automation features like auto todo detection.\",\n\t\t\t\"categoryAiTitle\": \"AI & Integrations\",\n\t\t\t\"categoryAiDescription\": \"Configure LLM and web search services.\",\n\t\t\t\"categoryDeveloperTitle\": \"Developer & Advanced\",\n\t\t\t\"categoryDeveloperDescription\": \"Advanced and experimental settings (recording, audio, scheduler).\",\n\t\t\t\"categoryHelpTitle\": \"Help & About\",\n\t\t\t\"categoryHelpDescription\": \"Onboarding tour, version info, and help.\",\n\t\t\t\"searchPlaceholder\": \"Search settings...\",\n\t\t\t\"searchNoResultsTitle\": \"No matching settings\",\n\t\t\t\"searchNoResultsHint\": \"Try a different keyword.\",\n\t\t\t\"aboutTitle\": \"Version & Info\",\n\t\t\t\"aboutDescription\": \"View current version and build details.\",\n\t\t\t\"journalSettingsTitle\": \"Journal Settings\",\n\t\t\t\"journalSettingsDescription\": \"Configure daily reset and auto generation for journals.\",\n\t\t\t\"journalRefreshModeLabel\": \"Refresh mode\",\n\t\t\t\"journalRefreshModeFixed\": \"Fixed time\",\n\t\t\t\"journalRefreshModeWorkHours\": \"Work hours\",\n\t\t\t\"journalRefreshModeCustom\": \"Custom time\",\n\t\t\t\"journalFixedTimeLabel\": \"Fixed time\",\n\t\t\t\"journalWorkHoursLabel\": \"Work hours\",\n\t\t\t\"journalCustomTimeLabel\": \"Custom time\",\n\t\t\t\"journalAutoLinkLabel\": \"Auto-link after save\",\n\t\t\t\"journalAutoObjectiveLabel\": \"Auto-generate objective log\",\n\t\t\t\"journalAutoAiLabel\": \"Auto-generate AI view\",\n\t\t\t\"autoTodoDetectionTitle\": \"Auto Todo Detection\",\n\t\t\t\"autoTodoDetectionDescription\": \"Automatically detect todos from screenshots of whitelisted apps and create draft todos for confirmation\",\n\t\t\t\"autoTodoDetectionLabel\": \"Enable Auto Todo Detection\",\n\t\t\t\"autoTodoDetectionHint\": \"Enabled: System will automatically detect todos from whitelisted apps\",\n\t\t\t\"autoTodoDetectionEnabled\": \"Auto todo detection enabled\",\n\t\t\t\"autoTodoDetectionDisabled\": \"Auto todo detection disabled\",\n\t\t\t\"whitelistApps\": \"App Whitelist\",\n\t\t\t\"whitelistAppsPlaceholder\": \"Enter app name and press Enter to add\",\n\t\t\t\"whitelistAppsDesc\": \"Only screenshots from these apps will trigger auto todo detection\",\n\t\t\t\"loadFailed\": \"Failed to load config: {error}\",\n\t\t\t\"saveFailed\": \"Failed to save config: {error}\",\n\t\t\t\"saveSuccess\": \"Configuration saved\",\n\t\t\t\"costTrackingPanelTitle\": \"Cost Panel\",\n\t\t\t\"costTrackingPanelDescription\": \"Control whether the cost tracking feature is available in panels\",\n\t\t\t\"costTrackingPanelLabel\": \"Enable cost tracking panel\",\n\t\t\t\"costTrackingPanelHint\": \"Disable to hide the cost tracking panel from the selector\",\n\t\t\t\"costTrackingPanelEnabled\": \"Cost tracking panel enabled\",\n\t\t\t\"costTrackingPanelDisabled\": \"Cost tracking panel disabled\",\n\t\t\t\"panelSwitchesTitle\": \"Panel Switches\",\n\t\t\t\"panelSwitchesDescription\": \"Control the visibility of each panel\",\n\t\t\t\"panelEnabled\": \"Enabled\",\n\t\t\t\"panelDisabled\": \"Disabled\",\n\t\t\t\"llmConfig\": \"LLM Configuration\",\n\t\t\t\"difyConfigTitle\": \"Dify Test Configuration\",\n\t\t\t\"difyEnabledLabel\": \"Enable Dify test mode\",\n\t\t\t\"difyEnabledDescription\": \"When enabled, the Dify Test mode in chat will use Dify API for responses.\",\n\t\t\t\"difySaveSuccess\": \"Dify configuration saved\",\n\t\t\t\"tavilyConfigTitle\": \"Tavily Web Search Configuration\",\n\t\t\t\"tavilySaveSuccess\": \"Tavily configuration saved\",\n\t\t\t\"tavilyApiKeyHint\": \"How to get API Key? Please visit\",\n\t\t\t\"tavilyApiKeyLink\": \"Tavily Console\",\n\t\t\t\"save\": \"Save\",\n\t\t\t\"apiKey\": \"API Key\",\n\t\t\t\"baseUrl\": \"Base URL\",\n\t\t\t\"model\": \"Model\",\n\t\t\t\"temperature\": \"Temperature\",\n\t\t\t\"maxTokens\": \"Max Tokens\",\n\t\t\t\"testConnection\": \"Test API Connection\",\n\t\t\t\"testSuccess\": \"✓ API configuration verified successfully!\",\n\t\t\t\"testFailed\": \"✗ API configuration verification failed\",\n\t\t\t\"apiKeyRequired\": \"Please fill in API Key and Base URL\",\n\t\t\t\"apiKeyHint\": \"How to get API Key? Please visit\",\n\t\t\t\"apiKeyLink\": \"Aliyun Bailian Console\",\n\t\t\t\"basicSettings\": \"Screen Recording Settings\",\n\t\t\t\"enableRecording\": \"Enable Recording\",\n\t\t\t\"enableRecordingDesc\": \"Enable screen recording and screenshot features\",\n\t\t\t\"screenshotInterval\": \"Screenshot Interval (seconds)\",\n\t\t\t\"enableBlacklist\": \"Enable Blacklist (for screenshot debug panel and activity panel)\",\n\t\t\t\"enableBlacklistDesc\": \"Enable to set apps that do not need screenshots\",\n\t\t\t\"appBlacklist\": \"App Blacklist\",\n\t\t\t\"blacklistPlaceholder\": \"Enter app name and press Enter to add\",\n\t\t\t\"blacklistDesc\": \"Windows of these apps will not be recorded\",\n\t\t\t\"dockDisplayModeTitle\": \"Dock Display Mode\",\n\t\t\t\"dockDisplayModeDescription\": \"Control the display behavior of the bottom dock\",\n\t\t\t\"dockDisplayModeLabel\": \"Display Mode\",\n\t\t\t\"dockDisplayModeFixed\": \"Always Visible\",\n\t\t\t\"dockDisplayModeAutoHide\": \"Auto Hide\",\n\t\t\t\"dockDisplayModeChanged\": \"Dock display mode changed\",\n\t\t\t\"notificationPermissionTitle\": \"Notification Permission\",\n\t\t\t\"notificationPermissionDescription\": \"Manually request system notification permission (web and Tauri).\",\n\t\t\t\"notificationPermissionStatusLabel\": \"Permission status\",\n\t\t\t\"notificationPermissionStatusGranted\": \"Granted\",\n\t\t\t\"notificationPermissionStatusDenied\": \"Denied\",\n\t\t\t\"notificationPermissionStatusDefault\": \"Not granted\",\n\t\t\t\"notificationPermissionStatusUnknown\": \"Unknown\",\n\t\t\t\"notificationPermissionStatusNotRequired\": \"Not required\",\n\t\t\t\"notificationPermissionRequest\": \"Request notification permission\",\n\t\t\t\"notificationPermissionRequesting\": \"Requesting...\",\n\t\t\t\"notificationPermissionRequestSuccess\": \"Notification permission granted\",\n\t\t\t\"notificationPermissionRequestDenied\": \"Notification permission denied\",\n\t\t\t\"notificationPermissionRequestFailed\": \"Failed to request permission: {error}\",\n\t\t\t\"notificationPermissionNotSupported\": \"Notification permission is not supported here\",\n\t\t\t\"notificationPermissionHint\": \"After granting, reminders will show in system notifications.\",\n\t\t\t\"notificationPermissionElectronHint\": \"Electron requests permission automatically on first notification.\",\n\t\t\t\"developerSectionTitle\": \"Developer Options\",\n\t\t\t\"developerSectionDescription\": \"Advanced and experimental settings for developers or power users.\",\n\t\t\t\"devPanelsTitle\": \"Panels in Development\",\n\t\t\t\"devPanelsDescription\": \"These panels are still under development. They are hidden from the dock by default; you can enable them here.\",\n\t\t\t\"modeSwitcherTitle\": \"Chat Mode Switcher\",\n\t\t\t\"modeSwitcherDescription\": \"Control whether the chat panel shows Ask/Plan/Edit mode switching options\",\n\t\t\t\"modeSwitcherLabel\": \"Show Mode Switcher\",\n\t\t\t\"modeSwitcherHint\": \"When disabled, chat panel will only use Ask mode\",\n\t\t\t\"modeSwitcherEnabled\": \"Mode switcher enabled\",\n\t\t\t\"modeSwitcherDisabled\": \"Mode switcher disabled\",\n\t\t\t\"defaultChatModeLabel\": \"Default Chat Mode\",\n\t\t\t\"defaultChatModeHint\": \"This mode will be automatically selected when the page refreshes\",\n\t\t\t\"defaultChatModeChanged\": \"Default chat mode updated\",\n\t\t\t\"agnoToolSelectorTitle\": \"Agno Tool Selector\",\n\t\t\t\"agnoToolSelectorLabel\": \"Show Tool Selection Button\",\n\t\t\t\"agnoToolSelectorHint\": \"When enabled, a tool selection button will appear in Agno mode\",\n\t\t\t\"agnoToolSelectorEnabled\": \"Tool selector enabled\",\n\t\t\t\"agnoToolSelectorDisabled\": \"Tool selector disabled\",\n\t\t\t\"audioSettings\": \"Audio Recording Settings\",\n\t\t\t\"enable24x7Recording\": \"Auto Start Recording\",\n\t\t\t\"enable24x7RecordingDesc\": \"When enabled, recording will automatically start when opening the app\",\n\t\t\t\"audioAsrConfig\": \"Audio Recognition (ASR) Configuration\",\n\t\t\t\"currentVersion\": \"Current Version\"\n\t\t},\n\t\t\"costTracking\": {\n\t\t\t\"title\": \"Cost Tracking\",\n\t\t\t\"subtitle\": \"View LLM usage and cost statistics\",\n\t\t\t\"statisticsPeriod\": \"Statistics Period\",\n\t\t\t\"last7Days\": \"Last 7 Days\",\n\t\t\t\"last30Days\": \"Last 30 Days\",\n\t\t\t\"last90Days\": \"Last 90 Days\",\n\t\t\t\"refresh\": \"Refresh\",\n\t\t\t\"totalCost\": \"Total Cost\",\n\t\t\t\"totalTokens\": \"Total Tokens\",\n\t\t\t\"totalRequests\": \"Total Requests\",\n\t\t\t\"featureCostDetails\": \"Feature Cost Details\",\n\t\t\t\"feature\": \"Feature\",\n\t\t\t\"featureId\": \"ID\",\n\t\t\t\"inputTokens\": \"Input Tokens\",\n\t\t\t\"outputTokens\": \"Output Tokens\",\n\t\t\t\"requests\": \"Requests\",\n\t\t\t\"cost\": \"Cost\",\n\t\t\t\"modelCostDetails\": \"Model Cost Details\",\n\t\t\t\"model\": \"Model\",\n\t\t\t\"inputCost\": \"Input Cost\",\n\t\t\t\"outputCost\": \"Output Cost\",\n\t\t\t\"totalCostLabel\": \"Total Cost\",\n\t\t\t\"dailyCostTrend\": \"Daily Cost Trend\",\n\t\t\t\"loadFailed\": \"Load failed\",\n\t\t\t\"featureNames\": {\n\t\t\t\t\"event_assistant\": \"Event Assistant\",\n\t\t\t\t\"event_summary\": \"Event Summary\",\n\t\t\t\t\"project_assistant\": \"Project Assistant\",\n\t\t\t\t\"job_task_context_mapper\": \"Task Context Mapping\",\n\t\t\t\t\"job_task_summary\": \"Task Summary Generation\",\n\t\t\t\t\"task_summary\": \"Task Summary (Manual)\",\n\t\t\t\t\"activity_summary\": \"Activity Summary\",\n\t\t\t\t\"vision_assistant\": \"Auto Todo Detection\",\n\t\t\t\t\"workspace_assistant\": \"Workspace Assistant\",\n\t\t\t\t\"plan_assistant\": \"Plan Assistant\",\n\t\t\t\t\"unknown\": \"Unknown Feature\"\n\t\t\t}\n\t\t}\n\t},\n\t\"journalPanel\": {\n\t\t\"panelTitle\": \"Journal\",\n\t\t\"panelSubtitle\": \"Write freely and link today's todos and activities.\",\n\t\t\"historyTitle\": \"History\",\n\t\t\"historyLoading\": \"Loading...\",\n\t\t\"historyEmpty\": \"No entries yet\",\n\t\t\"untitled\": \"Untitled\",\n\t\t\"titleLabel\": \"Title\",\n\t\t\"titlePlaceholder\": \"Optional title\",\n\t\t\"dateLabel\": \"Date\",\n\t\t\"moodLabel\": \"Mood\",\n\t\t\"moodPlaceholder\": \"Select mood\",\n\t\t\"moodCalm\": \"Calm\",\n\t\t\"moodFocused\": \"Focused\",\n\t\t\"moodTired\": \"Tired\",\n\t\t\"moodEnergized\": \"Energized\",\n\t\t\"moodAnxious\": \"Anxious\",\n\t\t\"energyLabel\": \"Energy\",\n\t\t\"energyPlaceholder\": \"Select energy\",\n\t\t\"energyLow\": \"Low\",\n\t\t\"energyMid\": \"Medium\",\n\t\t\"energyHigh\": \"High\",\n\t\t\"tagsLabel\": \"Tags\",\n\t\t\"tagsPlaceholder\": \"Comma-separated\",\n\t\t\"relatedTodosLabel\": \"Related Todos\",\n\t\t\"relatedTodosPlaceholder\": \"Todo ID\",\n\t\t\"relatedActivitiesLabel\": \"Related Activities\",\n\t\t\"relatedActivitiesPlaceholder\": \"Activity ID\",\n\t\t\"settingsTitle\": \"Daily Reset\",\n\t\t\"refreshModeLabel\": \"Refresh mode\",\n\t\t\"refreshModeFixed\": \"Fixed time\",\n\t\t\"refreshModeWorkHours\": \"Work hours\",\n\t\t\"refreshModeCustom\": \"Custom time\",\n\t\t\"fixedTimeLabel\": \"Fixed\",\n\t\t\"workHoursLabel\": \"Work hours\",\n\t\t\"customTimeLabel\": \"Custom\",\n\t\t\"autoLinkToggle\": \"Auto-link after save\",\n\t\t\"autoObjectiveToggle\": \"Auto-generate objective log\",\n\t\t\"autoAiToggle\": \"Auto-generate AI view\",\n\t\t\"tabOriginal\": \"Original\",\n\t\t\"tabObjective\": \"Objective\",\n\t\t\"tabAi\": \"AI View\",\n\t\t\"save\": \"Save\",\n\t\t\"saving\": \"Saving...\",\n\t\t\"jumpToToday\": \"Today\",\n\t\t\"generateObjective\": \"Generate Objective\",\n\t\t\"generatingObjective\": \"Generating...\",\n\t\t\"generateAi\": \"Generate AI View\",\n\t\t\"generatingAi\": \"Generating...\",\n\t\t\"autoLink\": \"Auto Link\",\n\t\t\"autoLinking\": \"Linking...\",\n\t\t\"copyToOriginal\": \"Copy to original\",\n\t\t\"contentPlaceholder\": \"Write freely...\",\n\t\t\"objectivePlaceholder\": \"Generate the objective log\",\n\t\t\"aiPlaceholder\": \"Generate AI view\",\n\t\t\"saveSuccess\": \"Saved\",\n\t\t\"saveFailed\": \"Save failed\",\n\t\t\"autoLinkSuccess\": \"Linked {todoCount} todos, {activityCount} activities\",\n\t\t\"autoLinkFailed\": \"Auto-link failed\",\n\t\t\"generateFailed\": \"Generation failed\",\n\t\t\"loadFailed\": \"Load failed: {error}\"\n\t},\n\t\"bottomDock\": {\n\t\t\"calendar\": \"Calendar\",\n\t\t\"activity\": \"Activity\",\n\t\t\"todos\": \"Todos\",\n\t\t\"chat\": \"Chat\",\n\t\t\"todoDetail\": \"Todo Detail\",\n\t\t\"diary\": \"Diary\",\n\t\t\"settings\": \"Settings\",\n\t\t\"costTracking\": \"Cost\",\n\t\t\"achievements\": \"Achievements\",\n\t\t\"screenshots\": \"Screenshots\",\n\t\t\"debugShots\": \"Debug Shots\",\n\t\t\"audio\": \"Audio\",\n\t\t\"unassigned\": \"Unassigned\"\n\t},\n\t\"panelMenu\": {\n\t\t\"moreActions\": \"Panel actions\",\n\t\t\"switchPanel\": \"Switch panel\",\n\t\t\"closePanel\": \"Close panel\",\n\t\t\"pinPanel\": \"Pin panel\",\n\t\t\"unpinPanel\": \"Unpin panel\",\n\t\t\"openInNewWindow\": \"Open in new window\",\n\t\t\"pinnedBadge\": \"Pinned\"\n\t},\n\t\"todoExtraction\": {\n\t\t\"extractButton\": \"Extract Todos\",\n\t\t\"extracting\": \"Extracting todos...\",\n\t\t\"extractSuccess\": \"Extracted {count} todos\",\n\t\t\"extractFailed\": \"Failed to extract todos: {error}\",\n\t\t\"noTodosFound\": \"No todos found\",\n\t\t\"notWhitelistApp\": \"This app does not support todo extraction\",\n\t\t\"modalTitle\": \"Confirm Todos\",\n\t\t\"modalDescription\": \"Please select todos to add to your list:\",\n\t\t\"selectAll\": \"Select All\",\n\t\t\"deselectAll\": \"Deselect All\",\n\t\t\"confirmAdd\": \"Confirm Add ({count})\",\n\t\t\"cancel\": \"Cancel\",\n\t\t\"todoTitle\": \"Title\",\n\t\t\"todoDescription\": \"Description\",\n\t\t\"todoTime\": \"Time\",\n\t\t\"todoSource\": \"Source\",\n\t\t\"todoScheduledTime\": \"Scheduled Time\",\n\t\t\"source\": \"Source\",\n\t\t\"time\": \"Time\",\n\t\t\"eventId\": \"Event ID\",\n\t\t\"addSuccess\": \"Successfully added {count} todos\",\n\t\t\"addFailed\": \"Failed to add todos: {error}\",\n\t\t\"selectedCount\": \"Selected {count} items\",\n\t\t\"newDraftTodoNotification\": \"New Todo Pending Confirmation\",\n\t\t\"accept\": \"Accept\",\n\t\t\"reject\": \"Reject\",\n\t\t\"accepting\": \"Processing...\",\n\t\t\"rejecting\": \"Processing...\",\n\t\t\"acceptSuccess\": \"Added to todo list\",\n\t\t\"rejectSuccess\": \"Rejected\",\n\t\t\"acceptFailed\": \"Failed: {error}\",\n\t\t\"rejectFailed\": \"Failed: {error}\",\n\t\t\"autoExtracted\": \"Auto Extracted\",\n\t\t\"noTimeSpecified\": \"No time specified\",\n\t\t\"failedItems\": \"{count} items failed\",\n\t\t\"newNotification\": \"New notification\",\n\t\t\"collapseNotification\": \"Collapse notification\",\n\t\t\"expandNotification\": \"Expand notification\",\n\t\t\"closeNotification\": \"Close notification\",\n\t\t\"justNow\": \"just now\",\n\t\t\"minutesAgo\": \"{count}m ago\",\n\t\t\"hoursAgo\": \"{count}h ago\",\n\t\t\"daysAgo\": \"{count}d ago\",\n\t\t\"dateFormat\": \"{month}/{day}\",\n\t\t\"llmConfigMissing\": \"Please configure AI service\",\n\t\t\"llmConfigMissingHint\": \"Click to set API Key\"\n\t},\n\t\"audio\": {\n\t\t\"extractionSummary\": \"Extracted {todoCount} todos and {scheduleCount} schedules\",\n\t\t\"linkTodo\": \"Link to todos\",\n\t\t\"extractionModalTitle\": \"Review extracted items\",\n\t\t\"extractionModalDesc\": \"Select todos/schedules to add into your todo list:\",\n\t\t\"todoSection\": \"Todos ({count})\",\n\t\t\"scheduleSection\": \"Schedules ({count})\",\n\t\t\"scheduleTag\": \"Schedule\",\n\t\t\"linkTodoTag\": \"Extracted\",\n\t\t\"scheduleFallbackTitle\": \"Schedule\",\n\t\t\"noExtractions\": \"No extracted items to add\",\n\t\t\"selectedCount\": \"Selected {count} items\",\n\t\t\"confirmAdd\": \"Add selected ({count})\",\n\t\t\"addSuccess\": \"Added {count} items\",\n\t\t\"addFailed\": \"Failed to add {count} items\"\n\t},\n\t\"chat\": {\n\t\t\"greetings\": {\n\t\t\t\"title\": \"Free Todo, Just Do It\",\n\t\t\t\"subtitle\": \"Your intelligent todo assistant for breaking down tasks, prioritizing, and getting things done\"\n\t\t},\n\t\t\"planModeInputPlaceholder\": \"e.g. Help me plan the todos for moving this weekend\",\n\t\t\"difyTest\": {\n\t\t\t\"inputPlaceholder\": \"Prompt for Dify test channel...\"\n\t\t},\n\t\t\"aiThinking\": \"AI is thinking...\",\n\t\t\"generatingQuestions\": \"AI is generating questions...\",\n\t\t\"generateQuestionsFailed\": \"Failed to generate questions, please try again\",\n\t\t\"generateSummaryFailed\": \"Failed to generate summary, please try again\",\n\t\t\"loading\": \"Loading\",\n\t\t\"suggestions\": {\n\t\t\t\"breakdown\": \"Break Down Task\",\n\t\t\t\"breakdownPrompt\": \"Help me break down this task\",\n\t\t\t\"plan\": \"Create Plan\",\n\t\t\t\"planPrompt\": \"Help me create a detailed plan\",\n\t\t\t\"priority\": \"Prioritize\",\n\t\t\t\"priorityPrompt\": \"Help me prioritize these tasks\",\n\t\t\t\"time\": \"Time Management\",\n\t\t\t\"timePrompt\": \"Give me some time management advice\",\n\t\t\t\"review\": \"Task Review\",\n\t\t\t\"reviewPrompt\": \"Help me review recent task completion\",\n\t\t\t\"optimize\": \"Optimize Workflow\",\n\t\t\t\"optimizePrompt\": \"How can I optimize my workflow?\",\n\t\t\t\"goal\": \"Set Goals\",\n\t\t\t\"goalPrompt\": \"Help me set some goals\",\n\t\t\t\"advice\": \"Get Advice\",\n\t\t\t\"advicePrompt\": \"Give me some suggestions for the current to-do\"\n\t\t},\n\t\t\"assistant\": \"Assistant\",\n\t\t\"user\": \"You\",\n\t\t\"chatMode\": \"Chat mode\",\n\t\t\"toggleMode\": \"Toggle Ask/Plan/Edit mode\",\n\t\t\"mentionFileOrTodo\": \"Mention a file or todo\",\n\t\t\"send\": \"Send\",\n\t\t\"stop\": \"Stop\",\n\t\t\"linkedTodos\": \"Linked todos ({count})\",\n\t\t\"linkedTodosEn\": \"Linked todos ({count})\",\n\t\t\"collapse\": \"Collapse\",\n\t\t\"expand\": \"Expand\",\n\t\t\"clearSelection\": \"Clear selection\",\n\t\t\"generatingQuestion\": \"Generating question {count}\",\n\t\t\"answerQuestions\": \"Please answer the following questions to complete task details\",\n\t\t\"answerQuestionsDesc\": \"Your answers will help AI better understand task requirements and generate a detailed plan\",\n\t\t\"multipleChoice\": \"(Multiple choice)\",\n\t\t\"customAnswer\": \"Custom answer\",\n\t\t\"customAnswerPlaceholder\": \"Enter your custom answer...\",\n\t\t\"submitting\": \"Submitting...\",\n\t\t\"submitAnswer\": \"Submit answer\",\n\t\t\"answeredProgress\": \"Answered {answered}/{total} questions\",\n\t\t\"generatingSummary\": \"AI is generating summary...\",\n\t\t\"generatingSummaryDesc\": \"Please wait, AI is generating a detailed task summary and subtask list based on your answers\",\n\t\t\"generating\": \"Generating...\",\n\t\t\"parsingContent\": \"Parsing full content...\",\n\t\t\"noTodoContext\": \"No todo context\",\n\t\t\"userInput\": \"User input\",\n\t\t\"loadHistoryFailed\": \"Failed to load history\",\n\t\t\"noTodosAvailable\": \"No todos available; chat context is empty.\",\n\t\t\"noTodosFound\": \"No todos found\",\n\t\t\"extractModalDescription\": \"Please select the todos to add:\",\n\t\t\"selectedCount\": \"Selected {count} items\",\n\t\t\"confirmAdd\": \"Confirm Add ({count})\",\n\t\t\"applying\": \"Applying...\",\n\t\t\"selectedTodo\": \"[Selected Todo]\",\n\t\t\"rootParentTodo\": \"[Root Parent Todo]\",\n\t\t\"allSubTodos\": \"[All Sub-todos] (total {count})\",\n\t\t\"allSubTodosRoot\": \"[All Sub-todos] (total {count})\",\n\t\t\"todoContextHeader\": \"{source} (total {count}):\",\n\t\t\"todoContextHeaderEn\": \"{source} (total {count}):\",\n\t\t\"noPlanJsonFound\": \"No todo JSON found; no tasks created.\",\n\t\t\"parsedNoValidTodos\": \"Parsed but no valid todos found.\",\n\t\t\"parsePlanJsonFailed\": \"Failed to parse plan JSON; no tasks created.\",\n\t\t\"noResponseReceived\": \"No response received, please try again.\",\n\t\t\"addedTodos\": \"Added {count} todos to the list.\",\n\t\t\"errorOccurred\": \"Something went wrong. Please try again.\",\n\t\t\"breakdownSummary\": {\n\t\t\t\"title\": \"Task Breakdown Result\",\n\t\t\t\"description\": \"Based on your answers, we have broken down the task into the following subtasks:\",\n\t\t\t\"taskSummary\": \"Task Summary\",\n\t\t\t\"subtaskList\": \"Subtask List\",\n\t\t\t\"noSubtasks\": \"No subtasks\",\n\t\t\t\"applying\": \"Applying...\",\n\t\t\t\"acceptAndApply\": \"Accept and Apply\"\n\t\t},\n\t\t\"planSummary\": {\n\t\t\t\"title\": \"Todo Planning Result\",\n\t\t\t\"description\": \"Please review the AI-generated todo summary and subtask list, then click Accept to confirm\",\n\t\t\t\"taskSummary\": \"Todo Summary\",\n\t\t\t\"subtaskList\": \"Sub-todo List\",\n\t\t\t\"noSubtasks\": \"No sub-todos generated\",\n\t\t\t\"applying\": \"Applying...\",\n\t\t\t\"acceptAndApply\": \"Accept & Apply\"\n\t\t},\n\t\t\"editMode\": {\n\t\t\t\"inputPlaceholder\": \"Describe what content you want to generate...\",\n\t\t\t\"appendTo\": \"Append to\",\n\t\t\t\"appendSuccess\": \"Appended to todo notes\",\n\t\t\t\"appendFailed\": \"Failed to append\",\n\t\t\t\"selectTodo\": \"Select todo\",\n\t\t\t\"noLinkedTodos\": \"Please link todos first\",\n\t\t\t\"append\": \"Append\",\n\t\t\t\"appending\": \"Appending\",\n\t\t\t\"appended\": \"Appended\",\n\t\t\t\"failed\": \"Failed\",\n\t\t\t\"aiRecommended\": \"AI\"\n\t\t},\n\t\t\"modes\": {\n\t\t\t\"ask\": {\n\t\t\t\t\"label\": \"Ask\",\n\t\t\t\t\"description\": \"Chat freely\"\n\t\t\t},\n\t\t\t\"plan\": {\n\t\t\t\t\"label\": \"Plan\",\n\t\t\t\t\"description\": \"Break down and add todos\"\n\t\t\t},\n\t\t\t\"edit\": {\n\t\t\t\t\"label\": \"Edit\",\n\t\t\t\t\"description\": \"Generate and append to notes\"\n\t\t\t},\n\t\t\t\"difyTest\": {\n\t\t\t\t\"label\": \"Dify Test\",\n\t\t\t\t\"description\": \"Use Dify API test channel for replies\"\n\t\t\t},\n\t\t\t\"agno\": {\n\t\t\t\t\"label\": \"Agno\",\n\t\t\t\t\"description\": \"Intelligent Agent based on Agno framework\"\n\t\t\t},\n\t\t\t\"active\": \"Active\"\n\t\t},\n\t\t\"webSearch\": {\n\t\t\t\"label\": \"Web Search\",\n\t\t\t\"toggle\": \"Toggle web search\",\n\t\t\t\"enabled\": \"Web search enabled\",\n\t\t\t\"disabled\": \"Web search disabled\"\n\t\t},\n\t\t\"toolSelector\": {\n\t\t\t\"label\": \"Tools\",\n\t\t\t\"title\": \"Select Tools\",\n\t\t\t\"selectAll\": \"Select All\",\n\t\t\t\"deselectAll\": \"Deselect All\",\n\t\t\t\"selectedCount\": \"{count} tools selected\",\n\t\t\t\"allSelected\": \"All tools enabled\",\n\t\t\t\"noneSelected\": \"No tools selected\",\n\t\t\t\"externalTools\": \"External Tools\",\n\t\t\t\"freetodoTools\": \"Todo Tools\",\n\t\t\t\"categories\": {\n\t\t\t\t\"todo\": \"Todo Management\",\n\t\t\t\t\"breakdown\": \"Task Breakdown\",\n\t\t\t\t\"time\": \"Time Parsing\",\n\t\t\t\t\"conflict\": \"Conflict Detection\",\n\t\t\t\t\"stats\": \"Statistics\",\n\t\t\t\t\"tags\": \"Tag Management\",\n\t\t\t\t\"search\": \"Web Search\"\n\t\t\t},\n\t\t\t\"externalCategories\": {\n\t\t\t\t\"search\": \"Search\",\n\t\t\t\t\"local\": \"Local\"\n\t\t\t}\n\t\t},\n\t\t\"sources\": \"Sources\",\n\t\t\"todoContext\": {\n\t\t\t\"id\": \"ID\",\n\t\t\t\"name\": \"Name\",\n\t\t\t\"description\": \"Description\",\n\t\t\t\"notes\": \"Notes\",\n\t\t\t\"deadline\": \"Time\",\n\t\t\t\"priority\": \"Priority\",\n\t\t\t\"status\": \"Status\",\n\t\t\t\"tags\": \"Tags\",\n\t\t\t\"parentTodoId\": \"Parent Todo ID\",\n\t\t\t\"parentName\": \"Parent Name\",\n\t\t\t\"due\": \"Time: {deadline}\",\n\t\t\t\"tagsLabel\": \"Tags: {tags}\"\n\t\t},\n\t\t\"toolCall\": {\n\t\t\t\"calling\": \"Calling {tool}...\",\n\t\t\t\"completed\": \"{tool} completed\",\n\t\t\t\"failed\": \"{tool} failed\",\n\t\t\t\"result\": \"Result\",\n\t\t\t\"tools\": {\n\t\t\t\t\"create_todo\": \"Create Todo\",\n\t\t\t\t\"complete_todo\": \"Complete Todo\",\n\t\t\t\t\"update_todo\": \"Update Todo\",\n\t\t\t\t\"list_todos\": \"List Todos\",\n\t\t\t\t\"search_todos\": \"Search Todos\",\n\t\t\t\t\"delete_todo\": \"Delete Todo\",\n\t\t\t\t\"breakdown_task\": \"Task Breakdown\",\n\t\t\t\t\"parse_time\": \"Time Parse\",\n\t\t\t\t\"check_schedule_conflict\": \"Conflict Check\",\n\t\t\t\t\"get_todo_stats\": \"Statistics\",\n\t\t\t\t\"get_overdue_todos\": \"Overdue Todos\",\n\t\t\t\t\"list_tags\": \"List Tags\",\n\t\t\t\t\"get_todos_by_tag\": \"Get by Tag\",\n\t\t\t\t\"suggest_tags\": \"Suggest Tags\",\n\t\t\t\t\"web_search\": \"Web Search\",\n\t\t\t\t\"search_news\": \"News Search\",\n\t\t\t\t\"duckduckgo\": \"DuckDuckGo Search\",\n\t\t\t\t\"websearch\": \"Web Search\",\n\t\t\t\t\"hackernews\": \"Hacker News\",\n\t\t\t\t\"file\": \"File Operations\",\n\t\t\t\t\"local_fs\": \"File Write\",\n\t\t\t\t\"shell\": \"Shell Command\",\n\t\t\t\t\"sleep\": \"Sleep\",\n\t\t\t\t\"unknown\": \"Unknown Tool\"\n\t\t\t}\n\t\t}\n\t},\n\t\"calendar\": {\n\t\t\"title\": \"Calendar\",\n\t\t\"monthView\": \"Month\",\n\t\t\"weekView\": \"Week\",\n\t\t\"dayView\": \"Day\",\n\t\t\"today\": \"Today\",\n\t\t\"previous\": \"Previous\",\n\t\t\"next\": \"Next\",\n\t\t\"create\": \"Create\",\n\t\t\"yearMonth\": \"{year}/{month}\",\n\t\t\"yearMonthWeek\": \"{year}/{month} (Week {week})\",\n\t\t\"yearMonthDay\": \"{year}/{month}/{day}\",\n\t\t\"createOnDate\": \"Create todo on {date}\",\n\t\t\"closeCreate\": \"Close\",\n\t\t\"inputTodoTitle\": \"Enter todo title...\",\n\t\t\"noTodosDue\": \"No todos due, click below to create one\",\n\t\t\"allDay\": \"All day\",\n\t\t\"startTime\": \"Start\",\n\t\t\"endTime\": \"End\",\n\t\t\"floating\": \"Floating\",\n\t\t\"floatingEmpty\": \"No floating todos\",\n\t\t\"workingHours\": \"Working hours\",\n\t\t\"weekdays\": {\n\t\t\t\"monday\": \"Mon\",\n\t\t\t\"tuesday\": \"Tue\",\n\t\t\t\"wednesday\": \"Wed\",\n\t\t\t\"thursday\": \"Thu\",\n\t\t\t\"friday\": \"Fri\",\n\t\t\t\"saturday\": \"Sat\",\n\t\t\t\"sunday\": \"Sun\"\n\t\t},\n\t\t\"weekPrefix\": \"\"\n\t},\n\t\"reminder\": {\n\t\t\"label\": \"Reminder\",\n\t\t\"noReminder\": \"No reminder\",\n\t\t\"atTime\": \"At time\",\n\t\t\"minutesBefore\": \"{count} min before\",\n\t\t\"hoursBefore\": \"{count} hr before\",\n\t\t\"daysBefore\": \"{count} day before\",\n\t\t\"custom\": \"Custom\",\n\t\t\"add\": \"Add\",\n\t\t\"clear\": \"Clear\",\n\t\t\"needsDeadline\": \"Reminders trigger relative to the deadline.\",\n\t\t\"needsSchedule\": \"Reminders trigger relative to the scheduled time.\",\n\t\t\"unit\": {\n\t\t\t\"minutes\": \"minutes\",\n\t\t\t\"hours\": \"hours\",\n\t\t\t\"days\": \"days\"\n\t\t}\n\t},\n\t\"datePicker\": {\n\t\t\"dateTab\": \"Date\",\n\t\t\"rangeTab\": \"Time range\",\n\t\t\"dateLabel\": \"Date\",\n\t\t\"timeLabel\": \"Time\",\n\t\t\"pickDate\": \"Pick a date\",\n\t\t\"allDay\": \"All day\",\n\t\t\"repeatLabel\": \"Repeat\",\n\t\t\"repeatNone\": \"Does not repeat\",\n\t\t\"repeatDaily\": \"Daily\",\n\t\t\"repeatWeekly\": \"Weekly\",\n\t\t\"repeatMonthly\": \"Monthly\",\n\t\t\"repeatYearly\": \"Yearly\",\n\t\t\"rangeLabel\": \"Time range\",\n\t\t\"startLabel\": \"Start\",\n\t\t\"endLabel\": \"End\",\n\t\t\"timeZoneLabel\": \"Time zone\",\n\t\t\"time\": \"Time\",\n\t\t\"timeRange\": \"Time range\",\n\t\t\"startTime\": \"Start\",\n\t\t\"endTime\": \"End\",\n\t\t\"clear\": \"Clear\",\n\t\t\"confirm\": \"Confirm\"\n\t},\n\t\"layoutSelector\": {\n\t\t\"label\": \"Layout\",\n\t\t\"selectLayout\": \"Select layout\",\n\t\t\"customLayout\": \"Custom Layout\",\n\t\t\"saveCurrentLayout\": \"Save current layout\",\n\t\t\"layoutActions\": \"Layout actions\",\n\t\t\"saveLayoutTitle\": \"Save layout\",\n\t\t\"saveLayoutDescription\": \"Name the current layout\",\n\t\t\"saveLayoutPlaceholder\": \"Enter a layout name\",\n\t\t\"renameLayout\": \"Rename\",\n\t\t\"renameLayoutTitle\": \"Rename layout\",\n\t\t\"renameLayoutDescription\": \"Set a new name for this layout\",\n\t\t\"renameLayoutPlaceholder\": \"Enter a new name\",\n\t\t\"deleteLayout\": \"Delete\",\n\t\t\"deleteLayoutTitle\": \"Delete layout\",\n\t\t\"deleteLayoutDescription\": \"Delete \\\"{name}\\\"? This action cannot be undone.\",\n\t\t\"overwriteConfirmTitle\": \"Overwrite existing layout\",\n\t\t\"overwriteConfirmDescription\": \"A layout named \\\"{name}\\\" already exists. Overwrite it?\",\n\t\t\"layoutNameRequired\": \"Layout name is required\",\n\t\t\"confirm\": \"Confirm\",\n\t\t\"cancel\": \"Cancel\",\n\t\t\"close\": \"Close\",\n\t\t\"layouts\": {\n\t\t\t\"default\": \"Todo List Mode\",\n\t\t\t\"calendar\": \"Todo Calendar Mode\",\n\t\t\t\"lifetrace\": \"LifeTrace Mode\"\n\t\t}\n\t},\n\t\"scheduler\": {\n\t\t\"title\": \"Scheduler Management\",\n\t\t\"description\": \"Manage background scheduled jobs and their intervals\",\n\t\t\"running\": \"Running\",\n\t\t\"paused\": \"Paused\",\n\t\t\"schedulerRunning\": \"Scheduler Running\",\n\t\t\"schedulerStopped\": \"Scheduler Stopped\",\n\t\t\"runningCount\": \"{running} running / {paused} paused\",\n\t\t\"refresh\": \"Refresh\",\n\t\t\"pauseAll\": \"Pause All\",\n\t\t\"resumeAll\": \"Resume All\",\n\t\t\"pause\": \"Pause\",\n\t\t\"resume\": \"Resume\",\n\t\t\"interval\": \"Interval\",\n\t\t\"next\": \"Next\",\n\t\t\"hour\": \"h\",\n\t\t\"minute\": \"m\",\n\t\t\"second\": \"s\",\n\t\t\"save\": \"Save\",\n\t\t\"cancel\": \"Cancel\",\n\t\t\"editInterval\": \"Edit interval\",\n\t\t\"noJobs\": \"No scheduled jobs\",\n\t\t\"loading\": \"Loading...\",\n\t\t\"legacyJobs\": \"Legacy Jobs\",\n\t\t\"legacyNotNeeded\": \"Not needed in this frontend\",\n\t\t\"intervalCannotBeZero\": \"Interval cannot be 0\",\n\t\t\"jobPaused\": \"Job {job} paused\",\n\t\t\"jobResumed\": \"Job {job} resumed\",\n\t\t\"pauseFailed\": \"Pause failed: {error}\",\n\t\t\"resumeFailed\": \"Resume failed: {error}\",\n\t\t\"allJobsPaused\": \"All jobs paused\",\n\t\t\"allJobsResumed\": \"All jobs resumed\",\n\t\t\"intervalUpdated\": \"Job {job} interval updated\",\n\t\t\"updateFailed\": \"Update failed: {error}\",\n\t\t\"dateLocale\": \"en-US\",\n\t\t\"jobs\": {\n\t\t\t\"recorder_job\": \"Screen Recorder\",\n\t\t\t\"ocr_job\": \"OCR Processing\",\n\t\t\t\"task_context_mapper_job\": \"Task Context Mapper\",\n\t\t\t\"task_summary_job\": \"Task Summary\",\n\t\t\t\"clean_data_job\": \"Data Cleanup\",\n\t\t\t\"activity_aggregator_job\": \"Activity Aggregator\",\n\t\t\t\"deadline_reminder_job\": \"DDL Reminder\",\n\t\t\t\"todo_recorder_job\": \"Screen Recorder (Todo)\",\n\t\t\t\"proactive_ocr_job\": \"Proactive OCR\",\n\t\t\t\"audio_recording_job\": \"Audio Recording\"\n\t\t},\n\t\t\"jobDescriptions\": {\n\t\t\t\"recorder_job\": \"Capture screenshots periodically\",\n\t\t\t\"ocr_job\": \"Extract text from screenshots\",\n\t\t\t\"task_context_mapper_job\": \"(Legacy) Associate screenshots with task context\",\n\t\t\t\"task_summary_job\": \"(Legacy) Generate task execution summary\",\n\t\t\t\"clean_data_job\": \"Clean up expired screenshots and data\",\n\t\t\t\"activity_aggregator_job\": \"Aggregate user activity events\",\n\t\t\t\"deadline_reminder_job\": \"Check todo deadlines and send notifications based on reminder settings\",\n\t\t\t\"todo_recorder_job\": \"Only capture screenshots from whitelisted apps for auto todo detection\",\n\t\t\t\"proactive_ocr_job\": \"Automatically detect and process WeChat/Feishu windows for OCR (Windows only)\",\n\t\t\t\"audio_recording_job\": \"7x24 continuous audio recording with real-time transcription and information extraction\"\n\t\t}\n\t},\n\t\"common\": {\n\t\t\"priority\": {\n\t\t\t\"high\": \"High\",\n\t\t\t\"medium\": \"Medium\",\n\t\t\t\"low\": \"Low\",\n\t\t\t\"none\": \"None\"\n\t\t},\n\t\t\"status\": {\n\t\t\t\"active\": \"Active\",\n\t\t\t\"completed\": \"Completed\",\n\t\t\t\"canceled\": \"Canceled\",\n\t\t\t\"draft\": \"Draft\"\n\t\t},\n\t\t\"numberLocale\": \"en-US\"\n\t},\n\t\"debugCapture\": {\n\t\t\"title\": \"Screenshot Management (Debug)\",\n\t\t\"screenshotDetail\": \"Screenshot Details\",\n\t\t\"close\": \"Close\",\n\t\t\"loading\": \"Loading...\",\n\t\t\"loadFailed\": \"Load Failed\",\n\t\t\"imageLoadFailed\": \"Image Load Failed\",\n\t\t\"screenshotId\": \"Screenshot ID\",\n\t\t\"screenshot\": \"Screenshot\",\n\t\t\"previous\": \"Previous\",\n\t\t\"next\": \"Next\",\n\t\t\"details\": \"Details\",\n\t\t\"time\": \"Time\",\n\t\t\"app\": \"App\",\n\t\t\"unknown\": \"Unknown\",\n\t\t\"windowTitle\": \"Window Title\",\n\t\t\"none\": \"None\",\n\t\t\"size\": \"Size\",\n\t\t\"ocrResult\": \"OCR Result\",\n\t\t\"selectedEvents\": \"Selected {count} events\",\n\t\t\"aggregating\": \"Aggregating...\",\n\t\t\"aggregateActivity\": \"Aggregate as Activity ({count})\",\n\t\t\"clearSelection\": \"Clear Selection\",\n\t\t\"startDate\": \"Start Date\",\n\t\t\"endDate\": \"End Date\",\n\t\t\"appName\": \"App Name\",\n\t\t\"appNamePlaceholder\": \"App name\",\n\t\t\"search\": \"Search\",\n\t\t\"eventTimeline\": \"Event Timeline\",\n\t\t\"foundEvents\": \"Found {total} events\",\n\t\t\"loadedEvents\": \"({loaded} loaded)\",\n\t\t\"loadingMore\": \"Loading more...\",\n\t\t\"scrollToLoadMore\": \"Scroll to load more\",\n\t\t\"allEventsLoaded\": \"All events loaded\",\n\t\t\"noEventsFound\": \"No events found\",\n\t\t\"adjustSearchCriteria\": \"Please adjust search criteria\",\n\t\t\"eventsCount\": \"{count} events\",\n\t\t\"select\": \"Select\",\n\t\t\"deselect\": \"Deselect\",\n\t\t\"unknownWindow\": \"Unknown Window\",\n\t\t\"duration\": \"Duration {duration}\",\n\t\t\"inProgress\": \"In Progress\",\n\t\t\"noDescription\": \"No description\",\n\t\t\"screenshotCount\": \"{count} screenshots\",\n\t\t\"selectEventsPrompt\": \"Please select events to aggregate first\",\n\t\t\"unendedEventsError\": \"Selected events contain unended events, cannot aggregate\",\n\t\t\"activityCreated\": \"Successfully created activity: {title}\\nContains {count} events\",\n\t\t\"activity\": \"Activity\",\n\t\t\"aggregateFailed\": \"Failed to aggregate events, please try again later\",\n\t\t\"extractFailed\": \"Failed to extract todos, please try again later\"\n\t},\n\t\"achievements\": {\n\t\t\"title\": \"Achievement System\",\n\t\t\"placeholder\": \"Placeholder: plug an achievements component here\",\n\t\t\"achievement1\": {\n\t\t\t\"name\": \"First Steps\",\n\t\t\t\"description\": \"Complete first todo\"\n\t\t},\n\t\t\"achievement2\": {\n\t\t\t\"name\": \"Todo Master\",\n\t\t\t\"description\": \"Complete 10 todos\"\n\t\t},\n\t\t\"achievement3\": {\n\t\t\t\"name\": \"Efficiency Star\",\n\t\t\t\"description\": \"Complete tasks for 7 consecutive days\"\n\t\t},\n\t\t\"achievement4\": {\n\t\t\t\"name\": \"Perfectionist\",\n\t\t\t\"description\": \"Complete 100 tasks\"\n\t\t}\n\t},\n\t\"todoList\": {\n\t\t\"addTodo\": \"Add todo\",\n\t\t\"add\": \"Add\",\n\t\t\"submit\": \"Submit\",\n\t\t\"reset\": \"Reset\",\n\t\t\"searchPlaceholder\": \"Search todos...\",\n\t\t\"loadFailed\": \"Load failed: {error}\",\n\t\t\"noTodos\": \"No todos\",\n\t\t\"deadline\": \"Date\",\n\t\t\"description\": \"Description\",\n\t\t\"descriptionPlaceholder\": \"Describe task details...\",\n\t\t\"tags\": \"Tags (comma-separated)\",\n\t\t\"tagsPlaceholder\": \"e.g., work, report\",\n\t\t\"priority\": \"Priority\",\n\t\t\"notes\": \"Notes\",\n\t\t\"notesPlaceholder\": \"Personal notes or action items\",\n\t\t\"filter\": \"Filter\",\n\t\t\"filterStatus\": \"Status\",\n\t\t\"filterTag\": \"Tag\",\n\t\t\"filterDueTime\": \"Time\",\n\t\t\"filterAll\": \"All\",\n\t\t\"statusActive\": \"Active\",\n\t\t\"statusCompleted\": \"Completed\",\n\t\t\"statusCanceled\": \"Canceled\",\n\t\t\"statusDraft\": \"Draft\",\n\t\t\"dueTimeOverdue\": \"Overdue\",\n\t\t\"dueTimeToday\": \"Today\",\n\t\t\"dueTimeTomorrow\": \"Tomorrow\",\n\t\t\"dueTimeThisWeek\": \"This Week\",\n\t\t\"dueTimeThisMonth\": \"This Month\",\n\t\t\"dueTimeFuture\": \"Future\",\n\t\t\"clearFilters\": \"Clear Filters\",\n\t\t\"quickFilters\": \"Quick Filters\",\n\t\t\"moreOptions\": \"More Options\"\n\t},\n\t\"todoDetail\": {\n\t\t\"editTitle\": \"Click to edit title\",\n\t\t\"viewDescription\": \"View description\",\n\t\t\"selectTodoPrompt\": \"Please select a todo to view details\",\n\t\t\"detailViewLabel\": \"Detail\",\n\t\t\"artifactsViewLabel\": \"Artifacts\",\n\t\t\"markAsComplete\": \"Mark as complete\",\n\t\t\"delete\": \"Delete\",\n\t\t\"addDeadline\": \"Add date\",\n\t\t\"addTags\": \"Add tags\",\n\t\t\"tagsPlaceholder\": \"Separate multiple tags with commas\",\n\t\t\"save\": \"Save\",\n\t\t\"cancel\": \"Cancel\",\n\t\t\"clear\": \"Clear\",\n\t\t\"current\": \"Current\",\n\t\t\"priorityLabel\": \"Priority: {priority}\",\n\t\t\"setAsChild\": \"Set as child task\",\n\t\t\"collapseSubTasks\": \"Collapse sub-tasks\",\n\t\t\"expandSubTasks\": \"Expand sub-tasks\",\n\t\t\"addChildPlaceholder\": \"Enter child todo name...\",\n\t\t\"add\": \"Add\",\n\t\t\"addChild\": \"Add child todo\",\n\t\t\"childTodos\": \"Child Todos\",\n\t\t\"useAiPlan\": \"AI Breakdown\",\n\t\t\"useAiPlanTitle\": \"Break down task with AI\",\n\t\t\"getAdvice\": \"Get Advice\",\n\t\t\"getAdviceTitle\": \"Get AI advice for this task\",\n\t\t\"descriptionLabel\": \"Description\",\n\t\t\"backgroundLabel\": \"Background\",\n\t\t\"notesLabel\": \"Notes\",\n\t\t\"descriptionEmptyPlaceholder\": \"No description (click to add)\",\n\t\t\"backgroundPlaceholder\": \"Add background context...\",\n\t\t\"backgroundEmptyPlaceholder\": \"No background (click to add)\",\n\t\t\"notesPlaceholder\": \"Insert your notes here\",\n\t\t\"notesEmptyPlaceholder\": \"No notes yet\",\n\t\t\"progressLabel\": \"Progress\",\n\t\t\"progressEmptyTitle\": \"No workbench steps yet\",\n\t\t\"progressEmptyHint\": \"Progress will appear here when workbench tasks run.\",\n\t\t\"artifactsLabel\": \"Artifacts\",\n\t\t\"artifactsEmpty\": \"No artifacts yet\",\n\t\t\"contextLabel\": \"Context\",\n\t\t\"editContext\": \"Edit detail\",\n\t\t\"contextAttachmentsLabel\": \"Context attachments\",\n\t\t\"contextAttachmentsEmpty\": \"No attachments yet\",\n\t\t\"uploadLabel\": \"Upload\",\n\t\t\"uploadHint\": \"Max 50MB per file\",\n\t\t\"previewLabel\": \"Preview\",\n\t\t\"previewUnavailable\": \"Preview not available for this file\",\n\t\t\"downloadLabel\": \"Download\",\n\t\t\"uploadFailed\": \"Upload failed\",\n\t\t\"removeAttachmentFailed\": \"Failed to remove attachment\",\n\t\t\"uploadSizeLimit\": \"Single file exceeds 50MB\",\n\t\t\"deleteConfirmTitle\": \"Delete this todo?\",\n\t\t\"deleteConfirmDescription\": \"This action cannot be undone.\",\n\t\t\"deleteConfirmWithChildren\": \"This will delete this todo and {count} child todos.\",\n\t\t\"deleteConfirmCancel\": \"Cancel\",\n\t\t\"deleteConfirmDelete\": \"Delete\"\n\t},\n\t\"automationTasks\": {\n\t\t\"title\": \"Automation tasks\",\n\t\t\"description\": \"Create lightweight scheduled jobs like fetching content at a specific time.\",\n\t\t\"createTitle\": \"New task\",\n\t\t\"createHint\": \"Only web fetch is supported for now\",\n\t\t\"empty\": \"No automation tasks yet.\",\n\t\t\"dateLocale\": \"en-US\",\n\t\t\"labels\": {\n\t\t\t\"name\": \"Task name\",\n\t\t\t\"description\": \"Description\",\n\t\t\t\"url\": \"Fetch URL\",\n\t\t\t\"method\": \"Method\",\n\t\t\t\"enabled\": \"Enabled\",\n\t\t\t\"enabledOn\": \"Enabled\",\n\t\t\t\"enabledOff\": \"Disabled\",\n\t\t\t\"scheduleType\": \"Schedule type\",\n\t\t\t\"intervalMinutes\": \"Every (minutes)\",\n\t\t\t\"cron\": \"Cron expression\",\n\t\t\t\"runAt\": \"Run at\",\n\t\t\t\"lastRun\": \"Last run: {time}\",\n\t\t\t\"status\": \"Status\"\n\t\t},\n\t\t\"placeholders\": {\n\t\t\t\"name\": \"Daily check-in\",\n\t\t\t\"description\": \"Optional notes\"\n\t\t},\n\t\t\"scheduleType\": {\n\t\t\t\"interval\": \"Interval\",\n\t\t\t\"cron\": \"Cron\",\n\t\t\t\"once\": \"Once\"\n\t\t},\n\t\t\"scheduleSummary\": {\n\t\t\t\"interval\": \"Every {minutes} min\",\n\t\t\t\"cron\": \"Cron: {cron}\",\n\t\t\t\"once\": \"Once at {time}\"\n\t\t},\n\t\t\"actions\": {\n\t\t\t\"create\": \"Create task\",\n\t\t\t\"run\": \"Run now\",\n\t\t\t\"enable\": \"Enable\",\n\t\t\t\"disable\": \"Disable\",\n\t\t\t\"delete\": \"Delete\"\n\t\t},\n\t\t\"status\": {\n\t\t\t\"success\": \"Success\",\n\t\t\t\"error\": \"Error\",\n\t\t\t\"never\": \"Never\"\n\t\t},\n\t\t\"errors\": {\n\t\t\t\"nameRequired\": \"Task name is required\",\n\t\t\t\"urlRequired\": \"URL is required\",\n\t\t\t\"intervalRequired\": \"Interval minutes must be greater than 0\",\n\t\t\t\"cronRequired\": \"Cron expression is required\",\n\t\t\t\"runAtRequired\": \"Run time is required\",\n\t\t\t\"createFailed\": \"Failed to create task: {error}\",\n\t\t\t\"runFailed\": \"Failed to run task: {error}\",\n\t\t\t\"updateFailed\": \"Failed to update task: {error}\",\n\t\t\t\"deleteFailed\": \"Failed to delete task: {error}\"\n\t\t},\n\t\t\"messages\": {\n\t\t\t\"created\": \"Task created\",\n\t\t\t\"ran\": \"Task executed\",\n\t\t\t\"enabled\": \"Task enabled\",\n\t\t\t\"disabled\": \"Task disabled\",\n\t\t\t\"deleted\": \"Task deleted\"\n\t\t},\n\t\t\"confirmDelete\": \"Delete this task?\"\n\t},\n\t\"contextMenu\": {\n\t\t\"selectedCount\": \"Selected {count} items\",\n\t\t\"batchCancel\": \"Batch Cancel\",\n\t\t\"batchDelete\": \"Batch Delete\",\n\t\t\"addChild\": \"Add child todo\",\n\t\t\"useAiPlan\": \"Use AI to plan\",\n\t\t\"cancel\": \"Cancel\",\n\t\t\"delete\": \"Delete\",\n\t\t\"childNamePlaceholder\": \"Enter child todo name...\",\n\t\t\"cancelButton\": \"Cancel\",\n\t\t\"addButton\": \"Add\",\n\t\t\"extractButton\": \"Extract Todos\",\n\t\t\"extracting\": \"Extracting todos...\"\n\t},\n\t\"onboarding\": {\n\t\t\"welcomeTitle\": \"Welcome to Free Todo\",\n\t\t\"welcomeDescription\": \"Let's quickly set up your AI assistant.\",\n\t\t\"apiKeyStepTitle\": \"API Key\",\n\t\t\"apiKeyStepDescription\": \"Enter your Aliyun Bailian API Key.\",\n\t\t\"dockTriggerTitle\": \"Move Mouse Down\",\n\t\t\"dockTriggerDescription\": \"Move your mouse to the bottom edge to reveal the Dock.\",\n\t\t\"dockStepTitle\": \"Bottom Dock\",\n\t\t\"dockStepDescription\": \"Click to toggle panels, drag to reorder.\",\n\t\t\"dockRightClickTitle\": \"Try Right-Click\",\n\t\t\"dockRightClickDescription\": \"Right-click on this item to see more options.\",\n\t\t\"dockMenuTitle\": \"Panel Selector\",\n\t\t\"dockMenuDescription\": \"Select a panel to switch. Try clicking \\\"Chat\\\".\",\n\t\t\"completeTitle\": \"All Set!\",\n\t\t\"completeDescription\": \"Start managing your tasks with AI assistance.\",\n\t\t\"nextBtn\": \"Next\",\n\t\t\"prevBtn\": \"Previous\",\n\t\t\"doneBtn\": \"Get Started\",\n\t\t\"skipBtn\": \"Skip Tour\",\n\t\t\"restartTour\": \"Restart Tour\",\n\t\t\"restartTourDescription\": \"Click this button to start or restart the onboarding tour anytime\"\n\t}\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/i18n/messages/zh.json",
    "content": "{\n\t\"language\": {\n\t\t\"zh\": \"中文\",\n\t\t\"en\": \"English\"\n\t},\n\t\"colorTheme\": {\n\t\t\"catppuccin\": \"Catppuccin\",\n\t\t\"blue\": \"蓝色\",\n\t\t\"neutral\": \"中性\",\n\t\t\"label\": \"配色风格\"\n\t},\n\t\"theme\": {\n\t\t\"light\": \"浅色\",\n\t\t\"dark\": \"深色\",\n\t\t\"system\": \"跟随系统\"\n\t},\n\t\"layout\": {\n\t\t\"currentLanguage\": \"当前语言\",\n\t\t\"currentTheme\": \"当前主题\",\n\t\t\"userSettings\": \"用户设置\",\n\t\t\"openSettings\": \"打开设置\"\n\t},\n\t\"page\": {\n\t\t\"title\": \"Free Todo Canvas\",\n\t\t\"subtitle\": \"日历视图与待办视图并列排布，可通过底部 Dock 快速切换与组合，并支持拖拽调整宽度。\",\n\t\t\"calendarLabel\": \"日历视图\",\n\t\t\"calendarPlaceholder\": \"占位：在这里接入日历组件\",\n\t\t\"activityLabel\": \"活动流\",\n\t\t\"activityPlaceholder\": \"占位：在这里接入活动流仪表盘\",\n\t\t\"todosLabel\": \"待办视图\",\n\t\t\"todosPlaceholder\": \"占位：在这里接入 Todo 待办\",\n\t\t\"todoListTitle\": \"待办\",\n\t\t\"chatLabel\": \"AI 聊天\",\n\t\t\"chatPlaceholder\": \"占位：在这里接入 AI 聊天组件\",\n\t\t\"chatTitle\": \"Free Todo - AI 智能助手\",\n\t\t\"chatSubtitle\": \"一个个性化的 AI 聊天应用，帮助您管理待办事项、提高效率。\",\n\t\t\"chatQuestion\": \"今天我能为您做些什么？\",\n\t\t\"chatSuggestion1\": \"拆解并排序今天的待办\",\n\t\t\"chatSuggestion2\": \"结合日历规划一周安排\",\n\t\t\"chatSuggestion3\": \"总结项目任务并给出下一步\",\n\t\t\"chatInputPlaceholder\": \"在此输入你的想法\",\n\t\t\"chatSendButton\": \"发送\",\n\t\t\"chatHistory\": \"历史记录\",\n\t\t\"newChat\": \"新建对话\",\n\t\t\"recentSessions\": \"最近会话\",\n\t\t\"noHistory\": \"暂无历史记录\",\n\t\t\"messagesCount\": \"{count} 条消息\",\n\t\t\"loadHistoryFailed\": \"加载历史记录失败\",\n\t\t\"loadSessionFailed\": \"加载会话失败\",\n\t\t\"sessionLoaded\": \"已加载历史会话\",\n\t\t\"todoDetailLabel\": \"待办详情\",\n\t\t\"todoDetailPlaceholder\": \"占位：在这里接入待办详情组件\",\n\t\t\"diaryLabel\": \"日记\",\n\t\t\"diaryPlaceholder\": \"占位：在这里接入日记组件\",\n\t\t\"settingsLabel\": \"设置\",\n\t\t\"settingsPlaceholder\": \"占位：在这里接入设置组件\",\n\t\t\"costTrackingLabel\": \"费用统计\",\n\t\t\"costTrackingPlaceholder\": \"占位：在这里查看费用统计\",\n\t\t\"achievementsLabel\": \"成就\",\n\t\t\"achievementsPlaceholder\": \"占位：在这里接入成就组件\",\n\t\t\"screenshotsLabel\": \"截图管理（调试）\",\n\t\t\"screenshotsPlaceholder\": \"事件时间轴与截图展示（仅开发模式可见）\",\n\t\t\"debugShotsLabel\": \"截图管理（调试）\",\n\t\t\"debugShotsPlaceholder\": \"占位：用于截图采集/管理的调试面板（仅开发模式可见）\",\n\t\t\"audioLabel\": \"音频录制\",\n\t\t\"audioPlaceholder\": \"占位：在这里接入音频录制组件\",\n\t\t\"backendUnavailableBadge\": \"后端不可用\",\n\t\t\"backendUnavailableTooltip\": \"后端模块未启用或缺少依赖。\",\n\t\t\"backendUnavailableTitle\": \"功能不可用\",\n\t\t\"backendUnavailableDescription\": \"后端模块未启用或缺少依赖。\",\n\t\t\"audioRecording\": \"录音中\",\n\t\t\"audioRecordingStopped\": \"已停止\",\n\t\t\"audioStandby\": \"待机中\",\n\t\t\"audioStartRecording\": \"开始录音\",\n\t\t\"audioStopRecording\": \"停止录音\",\n\t\t\"audioRecordingStatus\": \"录音状态\",\n\t\t\"settings\": {\n\t\t\t\"categoryWorkspaceTitle\": \"工作区与面板\",\n\t\t\t\"categoryWorkspaceDescription\": \"调整底部 Dock 的显示方式与面板开关。\",\n\t\t\t\"categoryAutomationTitle\": \"自动化\",\n\t\t\t\"categoryAutomationDescription\": \"管理自动待办检测等自动化能力。\",\n\t\t\t\"categoryAiTitle\": \"AI 与集成\",\n\t\t\t\"categoryAiDescription\": \"配置大模型与联网搜索等 AI 服务。\",\n\t\t\t\"categoryDeveloperTitle\": \"开发者与高级\",\n\t\t\t\"categoryDeveloperDescription\": \"高级与实验性配置（录制、音频、调度）。\",\n\t\t\t\"categoryHelpTitle\": \"引导与关于\",\n\t\t\t\"categoryHelpDescription\": \"新手引导、版本信息与帮助入口。\",\n\t\t\t\"searchPlaceholder\": \"搜索设置...\",\n\t\t\t\"searchNoResultsTitle\": \"未找到匹配的设置\",\n\t\t\t\"searchNoResultsHint\": \"试试其他关键词。\",\n\t\t\t\"aboutTitle\": \"版本与信息\",\n\t\t\t\"aboutDescription\": \"查看当前版本号与构建信息。\",\n\t\t\t\"journalSettingsTitle\": \"日记设置\",\n\t\t\t\"journalSettingsDescription\": \"配置日记的每日刷新点与自动生成策略。\",\n\t\t\t\"journalRefreshModeLabel\": \"刷新模式\",\n\t\t\t\"journalRefreshModeFixed\": \"固定时间\",\n\t\t\t\"journalRefreshModeWorkHours\": \"工作时段\",\n\t\t\t\"journalRefreshModeCustom\": \"自定义\",\n\t\t\t\"journalFixedTimeLabel\": \"固定时间\",\n\t\t\t\"journalWorkHoursLabel\": \"工作时段\",\n\t\t\t\"journalCustomTimeLabel\": \"自定义时间\",\n\t\t\t\"journalAutoLinkLabel\": \"保存后自动关联\",\n\t\t\t\"journalAutoObjectiveLabel\": \"自动生成客观记录\",\n\t\t\t\"journalAutoAiLabel\": \"自动生成 AI 视角\",\n\t\t\t\"autoTodoDetectionTitle\": \"自动待办检测\",\n\t\t\t\"autoTodoDetectionDescription\": \"自动从白名单应用截图中检测待办事项，并创建为待确认的草稿待办\",\n\t\t\t\"autoTodoDetectionLabel\": \"启用自动待办检测\",\n\t\t\t\"autoTodoDetectionHint\": \"已启用：系统将自动检测白名单应用中的待办事项\",\n\t\t\t\"autoTodoDetectionEnabled\": \"已启用自动待办检测\",\n\t\t\t\"autoTodoDetectionDisabled\": \"已关闭自动待办检测\",\n\t\t\t\"whitelistApps\": \"应用白名单\",\n\t\t\t\"whitelistAppsPlaceholder\": \"输入应用名称后按回车添加\",\n\t\t\t\"whitelistAppsDesc\": \"只有这些应用的截图才会触发自动待办检测\",\n\t\t\t\"loadFailed\": \"加载配置失败：{error}\",\n\t\t\t\"saveFailed\": \"保存配置失败：{error}\",\n\t\t\t\"saveSuccess\": \"保存成功\",\n\t\t\t\"costTrackingPanelTitle\": \"费用面板\",\n\t\t\t\"costTrackingPanelDescription\": \"控制是否在面板中展示费用统计功能\",\n\t\t\t\"costTrackingPanelLabel\": \"启用费用统计面板\",\n\t\t\t\"costTrackingPanelHint\": \"关闭后费用统计不会显示在面板选择器中\",\n\t\t\t\"costTrackingPanelEnabled\": \"已启用费用统计面板\",\n\t\t\t\"costTrackingPanelDisabled\": \"已关闭费用统计面板\",\n\t\t\t\"panelSwitchesTitle\": \"面板开关\",\n\t\t\t\"panelSwitchesDescription\": \"控制各个面板的显示与隐藏\",\n\t\t\t\"panelEnabled\": \"已启用\",\n\t\t\t\"panelDisabled\": \"已禁用\",\n\t\t\t\"llmConfig\": \"LLM 配置\",\n\t\t\t\"difyConfigTitle\": \"Dify 测试配置\",\n\t\t\t\"difyEnabledLabel\": \"启用 Dify 测试模式\",\n\t\t\t\"difyEnabledDescription\": \"开启后，聊天面板中的 Dify Test 模式将通过 Dify API 返回回复。\",\n\t\t\t\"difySaveSuccess\": \"Dify 配置已保存\",\n\t\t\t\"tavilyConfigTitle\": \"Tavily 联网搜索配置\",\n\t\t\t\"tavilySaveSuccess\": \"Tavily 配置已保存\",\n\t\t\t\"tavilyApiKeyHint\": \"如何获取 API Key？请访问\",\n\t\t\t\"tavilyApiKeyLink\": \"Tavily 控制台\",\n\t\t\t\"save\": \"保存\",\n\t\t\t\"apiKey\": \"API Key\",\n\t\t\t\"baseUrl\": \"Base URL\",\n\t\t\t\"model\": \"模型\",\n\t\t\t\"temperature\": \"Temperature\",\n\t\t\t\"maxTokens\": \"Max Tokens\",\n\t\t\t\"testConnection\": \"测试 API 连接\",\n\t\t\t\"testSuccess\": \"✓ API 配置验证成功！\",\n\t\t\t\"testFailed\": \"✗ API 配置验证失败\",\n\t\t\t\"apiKeyRequired\": \"请先填写 API Key 和 Base URL\",\n\t\t\t\"apiKeyHint\": \"如何获取 API Key？请访问\",\n\t\t\t\"apiKeyLink\": \"阿里云百炼控制台\",\n\t\t\t\"basicSettings\": \"屏幕录制设置\",\n\t\t\t\"enableRecording\": \"启用录制\",\n\t\t\t\"enableRecordingDesc\": \"开启屏幕录制和截图功能\",\n\t\t\t\"screenshotInterval\": \"截图间隔（秒）\",\n\t\t\t\"enableBlacklist\": \"启用黑名单（用于截图调试面板和活动面板）\",\n\t\t\t\"enableBlacklistDesc\": \"开启后可以设置不需要截图的应用\",\n\t\t\t\"appBlacklist\": \"应用黑名单\",\n\t\t\t\"blacklistPlaceholder\": \"输入应用名称后按回车添加\",\n\t\t\t\"blacklistDesc\": \"这些应用的窗口将不会被截图记录\",\n\t\t\t\"dockDisplayModeTitle\": \"底部 Dock 显示模式\",\n\t\t\t\"dockDisplayModeDescription\": \"控制底部 Dock 显示行为\",\n\t\t\t\"dockDisplayModeLabel\": \"显示模式\",\n\t\t\t\"dockDisplayModeFixed\": \"固定显示\",\n\t\t\t\"dockDisplayModeAutoHide\": \"自动隐藏\",\n\t\t\t\"dockDisplayModeChanged\": \"底部 Dock 显示模式已更改\",\n\t\t\t\"notificationPermissionTitle\": \"通知权限\",\n\t\t\t\"notificationPermissionDescription\": \"手动触发系统通知权限申请（浏览器与 Tauri）。\",\n\t\t\t\"notificationPermissionStatusLabel\": \"当前权限\",\n\t\t\t\"notificationPermissionStatusGranted\": \"已授权\",\n\t\t\t\"notificationPermissionStatusDenied\": \"已拒绝\",\n\t\t\t\"notificationPermissionStatusDefault\": \"未授权\",\n\t\t\t\"notificationPermissionStatusUnknown\": \"未知\",\n\t\t\t\"notificationPermissionStatusNotRequired\": \"无需授权\",\n\t\t\t\"notificationPermissionRequest\": \"请求通知权限\",\n\t\t\t\"notificationPermissionRequesting\": \"正在请求...\",\n\t\t\t\"notificationPermissionRequestSuccess\": \"通知权限已授予\",\n\t\t\t\"notificationPermissionRequestDenied\": \"通知权限被拒绝\",\n\t\t\t\"notificationPermissionRequestFailed\": \"请求通知权限失败：{error}\",\n\t\t\t\"notificationPermissionNotSupported\": \"当前环境不支持通知权限\",\n\t\t\t\"notificationPermissionHint\": \"授权后可在系统通知栏显示提醒。\",\n\t\t\t\"notificationPermissionElectronHint\": \"Electron 会在首次通知时自动请求权限。\",\n\t\t\t\"developerSectionTitle\": \"开发者选项\",\n\t\t\t\"developerSectionDescription\": \"高级和实验性功能配置，仅推荐开发者或高级用户使用。\",\n\t\t\t\"devPanelsTitle\": \"开发中的面板\",\n\t\t\t\"devPanelsDescription\": \"这些面板仍在开发中，默认不会显示在底部 Dock 中，你可以在这里手动开启。\",\n\t\t\t\"modeSwitcherTitle\": \"聊天模式切换器\",\n\t\t\t\"modeSwitcherDescription\": \"控制聊天面板是否显示 Ask/Plan/Edit 等模式切换选项\",\n\t\t\t\"modeSwitcherLabel\": \"显示模式切换器\",\n\t\t\t\"modeSwitcherHint\": \"关闭后，聊天面板将只使用 Ask 模式\",\n\t\t\t\"modeSwitcherEnabled\": \"已启用模式切换器\",\n\t\t\t\"modeSwitcherDisabled\": \"已关闭模式切换器\",\n\t\t\t\"defaultChatModeLabel\": \"默认聊天模式\",\n\t\t\t\"defaultChatModeHint\": \"页面刷新时将自动进入此模式\",\n\t\t\t\"defaultChatModeChanged\": \"默认聊天模式已更新\",\n\t\t\t\"agnoToolSelectorTitle\": \"Agno 工具选择器\",\n\t\t\t\"agnoToolSelectorLabel\": \"显示工具选择按钮\",\n\t\t\t\"agnoToolSelectorHint\": \"开启后，在 Agno 模式下会显示工具选择按钮\",\n\t\t\t\"agnoToolSelectorEnabled\": \"已启用工具选择器\",\n\t\t\t\"agnoToolSelectorDisabled\": \"已关闭工具选择器\",\n\t\t\t\"audioSettings\": \"音频录制设置\",\n\t\t\t\"enable24x7Recording\": \"自动启动录音\",\n\t\t\t\"enable24x7RecordingDesc\": \"启用后，打开应用时将自动开始录音\",\n\t\t\t\"audioAsrConfig\": \"音频识别（ASR）配置\",\n\t\t\t\"currentVersion\": \"当前版本\"\n\t\t},\n\t\t\"costTracking\": {\n\t\t\t\"title\": \"费用统计\",\n\t\t\t\"subtitle\": \"查看 LLM 使用情况和费用统计\",\n\t\t\t\"statisticsPeriod\": \"统计周期\",\n\t\t\t\"last7Days\": \"最近 7 天\",\n\t\t\t\"last30Days\": \"最近 30 天\",\n\t\t\t\"last90Days\": \"最近 90 天\",\n\t\t\t\"refresh\": \"刷新\",\n\t\t\t\"totalCost\": \"总费用\",\n\t\t\t\"totalTokens\": \"总 Token 数\",\n\t\t\t\"totalRequests\": \"总请求数\",\n\t\t\t\"featureCostDetails\": \"功能费用明细\",\n\t\t\t\"feature\": \"功能\",\n\t\t\t\"featureId\": \"ID\",\n\t\t\t\"inputTokens\": \"输入 Token\",\n\t\t\t\"outputTokens\": \"输出 Token\",\n\t\t\t\"requests\": \"请求数\",\n\t\t\t\"cost\": \"费用\",\n\t\t\t\"modelCostDetails\": \"模型费用明细\",\n\t\t\t\"model\": \"模型\",\n\t\t\t\"inputCost\": \"输入费用\",\n\t\t\t\"outputCost\": \"输出费用\",\n\t\t\t\"totalCostLabel\": \"总费用\",\n\t\t\t\"dailyCostTrend\": \"每日费用趋势\",\n\t\t\t\"loadFailed\": \"加载失败\",\n\t\t\t\"featureNames\": {\n\t\t\t\t\"event_assistant\": \"事件助手\",\n\t\t\t\t\"event_summary\": \"事件摘要\",\n\t\t\t\t\"project_assistant\": \"项目助手\",\n\t\t\t\t\"job_task_context_mapper\": \"任务上下文映射\",\n\t\t\t\t\"job_task_summary\": \"任务摘要生成\",\n\t\t\t\t\"task_summary\": \"任务摘要生成（手动）\",\n\t\t\t\t\"activity_summary\": \"活动总结\",\n\t\t\t\t\"vision_assistant\": \"自动待办检测\",\n\t\t\t\t\"workspace_assistant\": \"工作区助手\",\n\t\t\t\t\"plan_assistant\": \"计划助手\",\n\t\t\t\t\"unknown\": \"未知功能\"\n\t\t\t}\n\t\t}\n\t},\n\t\"journalPanel\": {\n\t\t\"panelTitle\": \"日记\",\n\t\t\"panelSubtitle\": \"自由记录，并自动关联今日待办与活动。\",\n\t\t\"historyTitle\": \"历史记录\",\n\t\t\"historyLoading\": \"加载中...\",\n\t\t\"historyEmpty\": \"暂无日记\",\n\t\t\"untitled\": \"无标题\",\n\t\t\"titleLabel\": \"标题\",\n\t\t\"titlePlaceholder\": \"可选标题\",\n\t\t\"dateLabel\": \"日期\",\n\t\t\"moodLabel\": \"情绪\",\n\t\t\"moodPlaceholder\": \"选择情绪\",\n\t\t\"moodCalm\": \"平静\",\n\t\t\"moodFocused\": \"专注\",\n\t\t\"moodTired\": \"疲惫\",\n\t\t\"moodEnergized\": \"充满能量\",\n\t\t\"moodAnxious\": \"焦虑\",\n\t\t\"energyLabel\": \"精力\",\n\t\t\"energyPlaceholder\": \"选择精力\",\n\t\t\"energyLow\": \"低\",\n\t\t\"energyMid\": \"中\",\n\t\t\"energyHigh\": \"高\",\n\t\t\"tagsLabel\": \"标签\",\n\t\t\"tagsPlaceholder\": \"逗号分隔\",\n\t\t\"relatedTodosLabel\": \"关联待办\",\n\t\t\"relatedTodosPlaceholder\": \"待办ID\",\n\t\t\"relatedActivitiesLabel\": \"关联活动\",\n\t\t\"relatedActivitiesPlaceholder\": \"活动ID\",\n\t\t\"settingsTitle\": \"每日刷新点\",\n\t\t\"refreshModeLabel\": \"刷新模式\",\n\t\t\"refreshModeFixed\": \"固定时间\",\n\t\t\"refreshModeWorkHours\": \"工作时段\",\n\t\t\"refreshModeCustom\": \"自定义\",\n\t\t\"fixedTimeLabel\": \"固定时间\",\n\t\t\"workHoursLabel\": \"工作时段\",\n\t\t\"customTimeLabel\": \"自定义时间\",\n\t\t\"autoLinkToggle\": \"保存后自动关联\",\n\t\t\"autoObjectiveToggle\": \"自动生成客观记录\",\n\t\t\"autoAiToggle\": \"自动生成 AI 视角\",\n\t\t\"tabOriginal\": \"原文\",\n\t\t\"tabObjective\": \"客观记录\",\n\t\t\"tabAi\": \"AI 视角\",\n\t\t\"save\": \"保存\",\n\t\t\"saving\": \"保存中...\",\n\t\t\"jumpToToday\": \"回到今天\",\n\t\t\"generateObjective\": \"生成客观记录\",\n\t\t\"generatingObjective\": \"生成中...\",\n\t\t\"generateAi\": \"生成 AI 视角\",\n\t\t\"generatingAi\": \"生成中...\",\n\t\t\"autoLink\": \"自动关联\",\n\t\t\"autoLinking\": \"关联中...\",\n\t\t\"copyToOriginal\": \"复制到原文\",\n\t\t\"contentPlaceholder\": \"写下今天的想法...\",\n\t\t\"objectivePlaceholder\": \"点击生成客观记录\",\n\t\t\"aiPlaceholder\": \"点击生成 AI 视角\",\n\t\t\"saveSuccess\": \"已保存\",\n\t\t\"saveFailed\": \"保存失败\",\n\t\t\"autoLinkSuccess\": \"已关联 {todoCount} 个待办，{activityCount} 个活动\",\n\t\t\"autoLinkFailed\": \"自动关联失败\",\n\t\t\"generateFailed\": \"生成失败\",\n\t\t\"loadFailed\": \"加载失败: {error}\"\n\t},\n\t\"bottomDock\": {\n\t\t\"calendar\": \"日历\",\n\t\t\"activity\": \"活动\",\n\t\t\"todos\": \"待办\",\n\t\t\"chat\": \"聊天\",\n\t\t\"todoDetail\": \"待办详情\",\n\t\t\"diary\": \"日记\",\n\t\t\"settings\": \"设置\",\n\t\t\"costTracking\": \"费用\",\n\t\t\"achievements\": \"成就\",\n\t\t\"screenshots\": \"截图\",\n\t\t\"debugShots\": \"截图调试\",\n\t\t\"audio\": \"音频\",\n\t\t\"unassigned\": \"未分配\"\n\t},\n\t\"panelMenu\": {\n\t\t\"moreActions\": \"面板操作\",\n\t\t\"switchPanel\": \"切换面板\",\n\t\t\"closePanel\": \"关闭面板\",\n\t\t\"pinPanel\": \"固定面板\",\n\t\t\"unpinPanel\": \"解锁面板\",\n\t\t\"openInNewWindow\": \"在新窗口中打开\",\n\t\t\"pinnedBadge\": \"已固定\"\n\t},\n\t\"todoExtraction\": {\n\t\t\"extractButton\": \"提取待办\",\n\t\t\"extracting\": \"正在提取待办事项...\",\n\t\t\"extractSuccess\": \"提取到 {count} 个待办事项\",\n\t\t\"extractFailed\": \"提取待办失败：{error}\",\n\t\t\"noTodosFound\": \"未发现待办事项\",\n\t\t\"notWhitelistApp\": \"该应用不支持待办提取\",\n\t\t\"modalTitle\": \"待办事项确认\",\n\t\t\"modalDescription\": \"请选择要添加到待办列表的项：\",\n\t\t\"selectAll\": \"全选\",\n\t\t\"deselectAll\": \"取消全选\",\n\t\t\"confirmAdd\": \"确认添加 ({count})\",\n\t\t\"cancel\": \"取消\",\n\t\t\"todoTitle\": \"标题\",\n\t\t\"todoDescription\": \"描述\",\n\t\t\"todoTime\": \"时间\",\n\t\t\"todoSource\": \"来源\",\n\t\t\"todoScheduledTime\": \"计划时间\",\n\t\t\"source\": \"来源\",\n\t\t\"time\": \"时间\",\n\t\t\"eventId\": \"事件ID\",\n\t\t\"addSuccess\": \"成功添加 {count} 个待办事项\",\n\t\t\"addFailed\": \"添加待办失败：{error}\",\n\t\t\"selectedCount\": \"已选择 {count} 项\",\n\t\t\"newDraftTodoNotification\": \"新待办事项待确认\",\n\t\t\"accept\": \"同意\",\n\t\t\"reject\": \"拒绝\",\n\t\t\"accepting\": \"处理中...\",\n\t\t\"rejecting\": \"处理中...\",\n\t\t\"acceptSuccess\": \"已添加到待办列表\",\n\t\t\"rejectSuccess\": \"已拒绝\",\n\t\t\"acceptFailed\": \"操作失败：{error}\",\n\t\t\"rejectFailed\": \"操作失败：{error}\",\n\t\t\"autoExtracted\": \"自动提取\",\n\t\t\"noTimeSpecified\": \"未指定时间\",\n\t\t\"failedItems\": \"失败 {count} 项\",\n\t\t\"newNotification\": \"新通知\",\n\t\t\"collapseNotification\": \"收起通知\",\n\t\t\"expandNotification\": \"展开通知\",\n\t\t\"closeNotification\": \"关闭通知\",\n\t\t\"justNow\": \"刚刚\",\n\t\t\"minutesAgo\": \"{count}分钟前\",\n\t\t\"hoursAgo\": \"{count}小时前\",\n\t\t\"daysAgo\": \"{count}天前\",\n\t\t\"dateFormat\": \"{month}月{day}日\",\n\t\t\"llmConfigMissing\": \"请配置 AI 服务\",\n\t\t\"llmConfigMissingHint\": \"点击设置 API Key\"\n\t},\n\t\"audio\": {\n\t\t\"extractionSummary\": \"已提取 {todoCount} 个待办，{scheduleCount} 个日程\",\n\t\t\"linkTodo\": \"关联到待办\",\n\t\t\"extractionModalTitle\": \"提取结果确认\",\n\t\t\"extractionModalDesc\": \"请选择要添加到待办列表的日程/待办项：\",\n\t\t\"todoSection\": \"待办（{count}）\",\n\t\t\"scheduleSection\": \"日程（{count}）\",\n\t\t\"scheduleTag\": \"日程\",\n\t\t\"linkTodoTag\": \"提取\",\n\t\t\"scheduleFallbackTitle\": \"日程\",\n\t\t\"noExtractions\": \"暂无可添加的提取结果\",\n\t\t\"selectedCount\": \"已选择 {count} 项\",\n\t\t\"confirmAdd\": \"确认添加 ({count})\",\n\t\t\"addSuccess\": \"已添加 {count} 项\",\n\t\t\"addFailed\": \"添加失败 {count} 项\"\n\t},\n\t\"chat\": {\n\t\t\"greetings\": {\n\t\t\t\"title\": \"Free Todo，放手去做\",\n\t\t\t\"subtitle\": \"智能待办助手，帮你拆解任务、规划优先级、提升效率\"\n\t\t},\n\t\t\"planModeInputPlaceholder\": \"例如：帮我规划周末搬家需要做的事\",\n\t\t\"difyTest\": {\n\t\t\t\"inputPlaceholder\": \"这里是 Dify 测试通道的提示词...\"\n\t\t},\n\t\t\"aiThinking\": \"AI 正在思考...\",\n\t\t\"generatingQuestions\": \"AI正在拆解待办...\",\n\t\t\"generateQuestionsFailed\": \"拆解待办失败，请重试\",\n\t\t\"generateSummaryFailed\": \"生成总结失败，请重试\",\n\t\t\"loading\": \"加载中\",\n\t\t\"suggestions\": {\n\t\t\t\"breakdown\": \"拆解任务\",\n\t\t\t\"breakdownPrompt\": \"帮我拆解一下这个任务\",\n\t\t\t\"plan\": \"制定计划\",\n\t\t\t\"planPrompt\": \"帮我制定一个详细的计划\",\n\t\t\t\"priority\": \"优先级排序\",\n\t\t\t\"priorityPrompt\": \"帮我按优先级排序这些任务\",\n\t\t\t\"time\": \"时间管理\",\n\t\t\t\"timePrompt\": \"给我一些时间管理的建议\",\n\t\t\t\"review\": \"任务回顾\",\n\t\t\t\"reviewPrompt\": \"帮我回顾一下最近的任务完成情况\",\n\t\t\t\"optimize\": \"优化工作流\",\n\t\t\t\"optimizePrompt\": \"如何优化我的工作流程？\",\n\t\t\t\"goal\": \"目标设定\",\n\t\t\t\"goalPrompt\": \"帮我设定一些目标\",\n\t\t\t\"advice\": \"获取建议\",\n\t\t\t\"advicePrompt\": \"针对目前的待办，给我一些建议\"\n\t\t},\n\t\t\"assistant\": \"助理\",\n\t\t\"user\": \"我\",\n\t\t\"chatMode\": \"对话模式\",\n\t\t\"toggleMode\": \"切换 Ask/Plan/Edit 模式\",\n\t\t\"mentionFileOrTodo\": \"提及文件或任务\",\n\t\t\"send\": \"发送\",\n\t\t\"stop\": \"停止\",\n\t\t\"linkedTodos\": \"关联待办（{count}）\",\n\t\t\"linkedTodosEn\": \"Linked todos ({count})\",\n\t\t\"collapse\": \"收起\",\n\t\t\"expand\": \"展开\",\n\t\t\"clearSelection\": \"清空选择\",\n\t\t\"generatingQuestion\": \"正在生成第 {count} 个问题\",\n\t\t\"answerQuestions\": \"请回答以下问题以完善任务详情\",\n\t\t\"answerQuestionsDesc\": \"您的回答将帮助AI更好地理解任务需求并生成详细的计划\",\n\t\t\"multipleChoice\": \"（可多选）\",\n\t\t\"customAnswer\": \"自定义回答\",\n\t\t\"customAnswerPlaceholder\": \"请输入您的自定义回答...\",\n\t\t\"submitting\": \"提交中...\",\n\t\t\"submitAnswer\": \"提交回答\",\n\t\t\"answeredProgress\": \"已回答 {answered}/{total} 个问题\",\n\t\t\"generatingSummary\": \"AI正在生成总结...\",\n\t\t\"generatingSummaryDesc\": \"请稍候，AI正在根据您的回答生成详细的任务总结和子任务列表\",\n\t\t\"generating\": \"生成中...\",\n\t\t\"parsingContent\": \"正在解析完整内容...\",\n\t\t\"noTodoContext\": \"无待办上下文\",\n\t\t\"userInput\": \"用户输入\",\n\t\t\"loadHistoryFailed\": \"加载历史记录失败\",\n\t\t\"noTodosAvailable\": \"当前没有待办，聊天上下文为空。\",\n\t\t\"noTodosFound\": \"未发现待办事项\",\n\t\t\"extractModalDescription\": \"请选择要添加的待办事项：\",\n\t\t\"selectedCount\": \"已选择 {count} 项\",\n\t\t\"confirmAdd\": \"确认添加 ({count})\",\n\t\t\"applying\": \"正在应用...\",\n\t\t\"selectedTodo\": \"【当前选中待办】\",\n\t\t\"rootParentTodo\": \"【最高级父待办】\",\n\t\t\"allSubTodos\": \"【该父待办下的所有子待办】（共 {count} 条）\",\n\t\t\"allSubTodosRoot\": \"【所有子待办】（共 {count} 条）\",\n\t\t\"todoContextHeader\": \"{source}（共 {count} 条）：\",\n\t\t\"todoContextHeaderEn\": \"{source} (total {count}):\",\n\t\t\"noPlanJsonFound\": \"未找到计划 JSON，未创建待办。\",\n\t\t\"parsedNoValidTodos\": \"解析完成，但没有有效的待办项。\",\n\t\t\"parsePlanJsonFailed\": \"解析计划 JSON 失败，未创建待办。\",\n\t\t\"noResponseReceived\": \"没有收到回复，请稍后再试。\",\n\t\t\"addedTodos\": \"已添加 {count} 条待办到列表。\",\n\t\t\"errorOccurred\": \"出错了，请稍后再试。\",\n\t\t\"breakdownSummary\": {\n\t\t\t\"title\": \"任务拆分结果\",\n\t\t\t\"description\": \"根据您的回答，我们已将任务拆分为以下子任务：\",\n\t\t\t\"taskSummary\": \"任务总结\",\n\t\t\t\"subtaskList\": \"子任务列表\",\n\t\t\t\"noSubtasks\": \"暂无子任务\",\n\t\t\t\"applying\": \"正在应用...\",\n\t\t\t\"acceptAndApply\": \"接受并应用\"\n\t\t},\n\t\t\"planSummary\": {\n\t\t\t\"title\": \"待办规划结果\",\n\t\t\t\"description\": \"请查看AI生成的待办总结和子待办列表，确认后点击接收\",\n\t\t\t\"taskSummary\": \"待办总结\",\n\t\t\t\"subtaskList\": \"子待办列表\",\n\t\t\t\"noSubtasks\": \"没有生成子待办\",\n\t\t\t\"applying\": \"应用中...\",\n\t\t\t\"acceptAndApply\": \"接收并应用\"\n\t\t},\n\t\t\"editMode\": {\n\t\t\t\"inputPlaceholder\": \"描述你想要生成的内容...\",\n\t\t\t\"appendTo\": \"追加到\",\n\t\t\t\"appendSuccess\": \"已追加到待办备注\",\n\t\t\t\"appendFailed\": \"追加失败\",\n\t\t\t\"selectTodo\": \"选择待办\",\n\t\t\t\"noLinkedTodos\": \"请先关联待办\",\n\t\t\t\"append\": \"追加\",\n\t\t\t\"appending\": \"追加中\",\n\t\t\t\"appended\": \"已追加\",\n\t\t\t\"failed\": \"失败\",\n\t\t\t\"aiRecommended\": \"推荐\"\n\t\t},\n\t\t\"modes\": {\n\t\t\t\"ask\": {\n\t\t\t\t\"label\": \"Ask 模式\",\n\t\t\t\t\"description\": \"直接聊天或提问\"\n\t\t\t},\n\t\t\t\"plan\": {\n\t\t\t\t\"label\": \"Plan 模式\",\n\t\t\t\t\"description\": \"拆解需求并生成待办\"\n\t\t\t},\n\t\t\t\"edit\": {\n\t\t\t\t\"label\": \"Edit 模式\",\n\t\t\t\t\"description\": \"生成内容追加到待办备注\"\n\t\t\t},\n\t\t\t\"difyTest\": {\n\t\t\t\t\"label\": \"Dify Test 模式\",\n\t\t\t\t\"description\": \"通过 Dify 测试通道返回回复\"\n\t\t\t},\n\t\t\t\"agno\": {\n\t\t\t\t\"label\": \"Agno 模式\",\n\t\t\t\t\"description\": \"基于 Agno 框架的智能 Agent\"\n\t\t\t},\n\t\t\t\"active\": \"当前\"\n\t\t},\n\t\t\"webSearch\": {\n\t\t\t\"label\": \"联网搜索\",\n\t\t\t\"toggle\": \"切换联网搜索\",\n\t\t\t\"enabled\": \"联网搜索已启用\",\n\t\t\t\"disabled\": \"联网搜索已禁用\"\n\t\t},\n\t\t\"toolSelector\": {\n\t\t\t\"label\": \"工具\",\n\t\t\t\"title\": \"选择工具\",\n\t\t\t\"selectAll\": \"全选\",\n\t\t\t\"deselectAll\": \"取消全选\",\n\t\t\t\"selectedCount\": \"已选择 {count} 个工具\",\n\t\t\t\"allSelected\": \"使用所有工具\",\n\t\t\t\"noneSelected\": \"未选择任何工具\",\n\t\t\t\"externalTools\": \"外部工具\",\n\t\t\t\"freetodoTools\": \"待办工具\",\n\t\t\t\"categories\": {\n\t\t\t\t\"todo\": \"待办管理\",\n\t\t\t\t\"breakdown\": \"任务拆解\",\n\t\t\t\t\"time\": \"时间解析\",\n\t\t\t\t\"conflict\": \"冲突检测\",\n\t\t\t\t\"stats\": \"统计分析\",\n\t\t\t\t\"tags\": \"标签管理\",\n\t\t\t\t\"search\": \"联网搜索\"\n\t\t\t},\n\t\t\t\"externalCategories\": {\n\t\t\t\t\"search\": \"搜索类\",\n\t\t\t\t\"local\": \"本地类\"\n\t\t\t}\n\t\t},\n\t\t\"sources\": \"来源\",\n\t\t\"todoContext\": {\n\t\t\t\"id\": \"ID\",\n\t\t\t\"name\": \"名称\",\n\t\t\t\"description\": \"描述\",\n\t\t\t\"notes\": \"备注\",\n\t\t\t\"deadline\": \"时间\",\n\t\t\t\"priority\": \"优先级\",\n\t\t\t\"status\": \"状态\",\n\t\t\t\"tags\": \"标签\",\n\t\t\t\"parentTodoId\": \"父待办ID\",\n\t\t\t\"parentName\": \"父待办名称\",\n\t\t\t\"due\": \"时间: {deadline}\",\n\t\t\t\"tagsLabel\": \"标签: {tags}\"\n\t\t},\n\t\t\"toolCall\": {\n\t\t\t\"calling\": \"正在调用 {tool}...\",\n\t\t\t\"completed\": \"{tool} 执行完成\",\n\t\t\t\"failed\": \"{tool} 执行失败\",\n\t\t\t\"result\": \"结果\",\n\t\t\t\"tools\": {\n\t\t\t\t\"create_todo\": \"创建待办\",\n\t\t\t\t\"complete_todo\": \"完成待办\",\n\t\t\t\t\"update_todo\": \"更新待办\",\n\t\t\t\t\"list_todos\": \"列出待办\",\n\t\t\t\t\"search_todos\": \"搜索待办\",\n\t\t\t\t\"delete_todo\": \"删除待办\",\n\t\t\t\t\"breakdown_task\": \"任务拆解\",\n\t\t\t\t\"parse_time\": \"时间解析\",\n\t\t\t\t\"check_schedule_conflict\": \"冲突检测\",\n\t\t\t\t\"get_todo_stats\": \"统计分析\",\n\t\t\t\t\"get_overdue_todos\": \"逾期待办\",\n\t\t\t\t\"list_tags\": \"列出标签\",\n\t\t\t\t\"get_todos_by_tag\": \"按标签查询\",\n\t\t\t\t\"suggest_tags\": \"标签推荐\",\n\t\t\t\t\"web_search\": \"联网搜索\",\n\t\t\t\t\"search_news\": \"新闻搜索\",\n\t\t\t\t\"duckduckgo\": \"DuckDuckGo 搜索\",\n\t\t\t\t\"websearch\": \"网页搜索\",\n\t\t\t\t\"hackernews\": \"Hacker News\",\n\t\t\t\t\"file\": \"文件操作\",\n\t\t\t\t\"local_fs\": \"文件写入\",\n\t\t\t\t\"shell\": \"命令行\",\n\t\t\t\t\"sleep\": \"暂停执行\",\n\t\t\t\t\"unknown\": \"未知工具\"\n\t\t\t}\n\t\t}\n\t},\n\t\"calendar\": {\n\t\t\"title\": \"日历\",\n\t\t\"monthView\": \"月视图\",\n\t\t\"weekView\": \"周视图\",\n\t\t\"dayView\": \"日视图\",\n\t\t\"today\": \"今天\",\n\t\t\"previous\": \"上一段\",\n\t\t\"next\": \"下一段\",\n\t\t\"create\": \"创建\",\n\t\t\"yearMonth\": \"{year} 年 {month} 月\",\n\t\t\"yearMonthWeek\": \"{year} 年 {month} 月（第{week}周）\",\n\t\t\"yearMonthDay\": \"{year} 年 {month} 月 {day} 日\",\n\t\t\"createOnDate\": \"在 {date} 创建待办\",\n\t\t\"closeCreate\": \"关闭创建\",\n\t\t\"inputTodoTitle\": \"输入待办标题...\",\n\t\t\"noTodosDue\": \"无截止待办，点击下方创建一个吧\",\n\t\t\"allDay\": \"全天\",\n\t\t\"startTime\": \"开始\",\n\t\t\"endTime\": \"结束\",\n\t\t\"floating\": \"未定时\",\n\t\t\"floatingEmpty\": \"暂无未定时任务\",\n\t\t\"workingHours\": \"工作时间\",\n\t\t\"weekdays\": {\n\t\t\t\"monday\": \"一\",\n\t\t\t\"tuesday\": \"二\",\n\t\t\t\"wednesday\": \"三\",\n\t\t\t\"thursday\": \"四\",\n\t\t\t\"friday\": \"五\",\n\t\t\t\"saturday\": \"六\",\n\t\t\t\"sunday\": \"日\"\n\t\t},\n\t\t\"weekPrefix\": \"周\"\n\t},\n\t\"reminder\": {\n\t\t\"label\": \"提醒\",\n\t\t\"noReminder\": \"不提醒\",\n\t\t\"atTime\": \"准时\",\n\t\t\"minutesBefore\": \"{count} 分钟前\",\n\t\t\"hoursBefore\": \"{count} 小时前\",\n\t\t\"daysBefore\": \"{count} 天前\",\n\t\t\"custom\": \"自定义\",\n\t\t\"add\": \"添加\",\n\t\t\"clear\": \"清空\",\n\t\t\"needsDeadline\": \"提醒会基于截止时间触发\",\n\t\t\"needsSchedule\": \"提醒会基于时间触发\",\n\t\t\"unit\": {\n\t\t\t\"minutes\": \"分钟\",\n\t\t\t\"hours\": \"小时\",\n\t\t\t\"days\": \"天\"\n\t\t}\n\t},\n\t\"datePicker\": {\n\t\t\"dateTab\": \"日期\",\n\t\t\"rangeTab\": \"时间段\",\n\t\t\"dateLabel\": \"日期\",\n\t\t\"timeLabel\": \"时间\",\n\t\t\"pickDate\": \"选择日期\",\n\t\t\"allDay\": \"全天\",\n\t\t\"repeatLabel\": \"重复\",\n\t\t\"repeatNone\": \"不重复\",\n\t\t\"repeatDaily\": \"每天\",\n\t\t\"repeatWeekly\": \"每周\",\n\t\t\"repeatMonthly\": \"每月\",\n\t\t\"repeatYearly\": \"每年\",\n\t\t\"rangeLabel\": \"时间段\",\n\t\t\"startLabel\": \"开始\",\n\t\t\"endLabel\": \"结束\",\n\t\t\"timeZoneLabel\": \"时区\",\n\t\t\"time\": \"时间\",\n\t\t\"timeRange\": \"时间段\",\n\t\t\"startTime\": \"开始时间\",\n\t\t\"endTime\": \"结束时间\",\n\t\t\"clear\": \"清除\",\n\t\t\"confirm\": \"确定\"\n\t},\n\t\"layoutSelector\": {\n\t\t\"label\": \"布局\",\n\t\t\"selectLayout\": \"选择布局\",\n\t\t\"customLayout\": \"自定义布局\",\n\t\t\"saveCurrentLayout\": \"保存当前布局\",\n\t\t\"layoutActions\": \"布局操作\",\n\t\t\"saveLayoutTitle\": \"保存布局\",\n\t\t\"saveLayoutDescription\": \"为当前布局命名\",\n\t\t\"saveLayoutPlaceholder\": \"输入布局名称\",\n\t\t\"renameLayout\": \"重命名\",\n\t\t\"renameLayoutTitle\": \"重命名布局\",\n\t\t\"renameLayoutDescription\": \"为该布局设置新名称\",\n\t\t\"renameLayoutPlaceholder\": \"输入新名称\",\n\t\t\"deleteLayout\": \"删除\",\n\t\t\"deleteLayoutTitle\": \"删除布局\",\n\t\t\"deleteLayoutDescription\": \"确定要删除“{name}”吗？此操作无法撤销。\",\n\t\t\"overwriteConfirmTitle\": \"覆盖已有布局\",\n\t\t\"overwriteConfirmDescription\": \"已存在名为“{name}”的布局，是否覆盖？\",\n\t\t\"layoutNameRequired\": \"布局名称不能为空\",\n\t\t\"confirm\": \"确定\",\n\t\t\"cancel\": \"取消\",\n\t\t\"close\": \"关闭\",\n\t\t\"layouts\": {\n\t\t\t\"default\": \"待办列表模式\",\n\t\t\t\"calendar\": \"待办日历模式\",\n\t\t\t\"lifetrace\": \"LifeTrace 模式\"\n\t\t}\n\t},\n\t\"scheduler\": {\n\t\t\"title\": \"定时任务管理\",\n\t\t\"description\": \"管理后台定时任务的运行状态和执行间隔\",\n\t\t\"running\": \"运行中\",\n\t\t\"paused\": \"已暂停\",\n\t\t\"schedulerRunning\": \"调度器运行中\",\n\t\t\"schedulerStopped\": \"调度器已停止\",\n\t\t\"runningCount\": \"{running} 运行 / {paused} 暂停\",\n\t\t\"refresh\": \"刷新\",\n\t\t\"pauseAll\": \"全部暂停\",\n\t\t\"resumeAll\": \"全部恢复\",\n\t\t\"pause\": \"暂停\",\n\t\t\"resume\": \"恢复\",\n\t\t\"interval\": \"间隔\",\n\t\t\"next\": \"下次\",\n\t\t\"hour\": \"时\",\n\t\t\"minute\": \"分\",\n\t\t\"second\": \"秒\",\n\t\t\"save\": \"保存\",\n\t\t\"cancel\": \"取消\",\n\t\t\"editInterval\": \"编辑间隔\",\n\t\t\"noJobs\": \"暂无定时任务\",\n\t\t\"loading\": \"加载中...\",\n\t\t\"legacyJobs\": \"旧版任务\",\n\t\t\"legacyNotNeeded\": \"此前端不需要\",\n\t\t\"intervalCannotBeZero\": \"间隔时间不能为0\",\n\t\t\"jobPaused\": \"任务 {job} 已暂停\",\n\t\t\"jobResumed\": \"任务 {job} 已恢复\",\n\t\t\"pauseFailed\": \"暂停失败: {error}\",\n\t\t\"resumeFailed\": \"恢复失败: {error}\",\n\t\t\"allJobsPaused\": \"已暂停所有任务\",\n\t\t\"allJobsResumed\": \"已恢复所有任务\",\n\t\t\"intervalUpdated\": \"任务 {job} 间隔已更新\",\n\t\t\"updateFailed\": \"更新失败: {error}\",\n\t\t\"dateLocale\": \"zh-CN\",\n\t\t\"jobs\": {\n\t\t\t\"recorder_job\": \"屏幕录制\",\n\t\t\t\"ocr_job\": \"文字识别\",\n\t\t\t\"task_context_mapper_job\": \"任务上下文关联\",\n\t\t\t\"task_summary_job\": \"任务总结\",\n\t\t\t\"clean_data_job\": \"数据清理\",\n\t\t\t\"activity_aggregator_job\": \"活动聚合\",\n\t\t\t\"deadline_reminder_job\": \"DDL提醒\",\n\t\t\t\"todo_recorder_job\": \"屏幕录制（Todo生成）\",\n\t\t\t\"proactive_ocr_job\": \"主动OCR\",\n\t\t\t\"audio_recording_job\": \"音频录制\"\n\t\t},\n\t\t\"jobDescriptions\": {\n\t\t\t\"recorder_job\": \"定时截取屏幕截图\",\n\t\t\t\"ocr_job\": \"识别截图中的文字内容\",\n\t\t\t\"task_context_mapper_job\": \"（旧版）关联截图与任务上下文\",\n\t\t\t\"task_summary_job\": \"（旧版）生成任务执行总结\",\n\t\t\t\"clean_data_job\": \"清理过期的截图和数据\",\n\t\t\t\"activity_aggregator_job\": \"聚合用户活动事件\",\n\t\t\t\"deadline_reminder_job\": \"检查待办事项的截止日期，根据提醒设置发送通知\",\n\t\t\t\"todo_recorder_job\": \"仅录制白名单应用的截图，用于自动待办检测\",\n\t\t\t\"proactive_ocr_job\": \"自动检测并处理微信/飞书窗口进行OCR识别（仅Windows）\",\n\t\t\t\"audio_recording_job\": \"7x24小时持续录音，实时转录和提取信息（间隔为状态检查间隔，非录音间隔）\"\n\t\t}\n\t},\n\t\"common\": {\n\t\t\"priority\": {\n\t\t\t\"high\": \"高\",\n\t\t\t\"medium\": \"中\",\n\t\t\t\"low\": \"低\",\n\t\t\t\"none\": \"无\"\n\t\t},\n\t\t\"status\": {\n\t\t\t\"active\": \"进行中\",\n\t\t\t\"completed\": \"已完成\",\n\t\t\t\"canceled\": \"已取消\",\n\t\t\t\"draft\": \"草稿\"\n\t\t},\n\t\t\"numberLocale\": \"zh-CN\"\n\t},\n\t\"debugCapture\": {\n\t\t\"title\": \"截图管理（开发调试）\",\n\t\t\"screenshotDetail\": \"截图详情\",\n\t\t\"close\": \"关闭\",\n\t\t\"loading\": \"加载中...\",\n\t\t\"loadFailed\": \"加载失败\",\n\t\t\"imageLoadFailed\": \"图片加载失败\",\n\t\t\"screenshotId\": \"截图 ID\",\n\t\t\"screenshot\": \"截图\",\n\t\t\"previous\": \"上一张\",\n\t\t\"next\": \"下一张\",\n\t\t\"details\": \"详细信息\",\n\t\t\"time\": \"时间\",\n\t\t\"app\": \"应用\",\n\t\t\"unknown\": \"未知\",\n\t\t\"windowTitle\": \"窗口标题\",\n\t\t\"none\": \"无\",\n\t\t\"size\": \"尺寸\",\n\t\t\"ocrResult\": \"OCR 结果\",\n\t\t\"selectedEvents\": \"已选择 {count} 个事件\",\n\t\t\"aggregating\": \"聚合中...\",\n\t\t\"aggregateActivity\": \"聚合为活动 ({count})\",\n\t\t\"clearSelection\": \"清空选择\",\n\t\t\"startDate\": \"开始日期\",\n\t\t\"endDate\": \"结束日期\",\n\t\t\"appName\": \"应用名称\",\n\t\t\"appNamePlaceholder\": \"应用名称\",\n\t\t\"search\": \"搜索\",\n\t\t\"eventTimeline\": \"事件时间轴\",\n\t\t\"foundEvents\": \"找到 {total} 个事件\",\n\t\t\"loadedEvents\": \"（已加载 {loaded} 个）\",\n\t\t\"loadingMore\": \"加载更多中...\",\n\t\t\"scrollToLoadMore\": \"滚动到底部加载更多\",\n\t\t\"allEventsLoaded\": \"已加载所有事件\",\n\t\t\"noEventsFound\": \"未找到事件\",\n\t\t\"adjustSearchCriteria\": \"请调整搜索条件\",\n\t\t\"eventsCount\": \"{count} 个事件\",\n\t\t\"select\": \"选择\",\n\t\t\"deselect\": \"取消选择\",\n\t\t\"unknownWindow\": \"未知窗口\",\n\t\t\"duration\": \"时长 {duration}\",\n\t\t\"inProgress\": \"进行中\",\n\t\t\"noDescription\": \"无描述\",\n\t\t\"screenshotCount\": \"{count} 张\",\n\t\t\"selectEventsPrompt\": \"请先选择要聚合的事件\",\n\t\t\"unendedEventsError\": \"所选事件中包含未结束的事件，无法聚合\",\n\t\t\"activityCreated\": \"成功创建活动: {title}\\n包含 {count} 个事件\",\n\t\t\"activity\": \"活动\",\n\t\t\"aggregateFailed\": \"聚合事件失败，请稍后重试\",\n\t\t\"extractFailed\": \"提取待办失败，请稍后重试\"\n\t},\n\t\"achievements\": {\n\t\t\"title\": \"成就系统\",\n\t\t\"placeholder\": \"占位：在这里接入成就组件\",\n\t\t\"achievement1\": {\n\t\t\t\"name\": \"初出茅庐\",\n\t\t\t\"description\": \"完成第一个待办\"\n\t\t},\n\t\t\"achievement2\": {\n\t\t\t\"name\": \"待办达人\",\n\t\t\t\"description\": \"完成 10 个待办\"\n\t\t},\n\t\t\"achievement3\": {\n\t\t\t\"name\": \"效率之星\",\n\t\t\t\"description\": \"连续 7 天完成任务\"\n\t\t},\n\t\t\"achievement4\": {\n\t\t\t\"name\": \"完美主义者\",\n\t\t\t\"description\": \"完成 100 个任务\"\n\t\t}\n\t},\n\t\"todoList\": {\n\t\t\"addTodo\": \"添加待办\",\n\t\t\"add\": \"添加\",\n\t\t\"submit\": \"提交\",\n\t\t\"reset\": \"重置\",\n\t\t\"searchPlaceholder\": \"搜索待办...\",\n\t\t\"loadFailed\": \"加载失败: {error}\",\n\t\t\"noTodos\": \"暂无待办事项\",\n\t\t\"deadline\": \"日期\",\n\t\t\"description\": \"描述\",\n\t\t\"descriptionPlaceholder\": \"描述任务细节...\",\n\t\t\"tags\": \"标签（逗号分隔）\",\n\t\t\"tagsPlaceholder\": \"如：工作, 报告\",\n\t\t\"priority\": \"优先级\",\n\t\t\"notes\": \"备注\",\n\t\t\"notesPlaceholder\": \"个人备注或行动项\",\n\t\t\"filter\": \"筛选\",\n\t\t\"filterStatus\": \"状态\",\n\t\t\"filterTag\": \"标签\",\n\t\t\"filterDueTime\": \"时间\",\n\t\t\"filterAll\": \"全部\",\n\t\t\"statusActive\": \"进行中\",\n\t\t\"statusCompleted\": \"已完成\",\n\t\t\"statusCanceled\": \"已取消\",\n\t\t\"statusDraft\": \"草稿\",\n\t\t\"dueTimeOverdue\": \"已逾期\",\n\t\t\"dueTimeToday\": \"今天\",\n\t\t\"dueTimeTomorrow\": \"明天\",\n\t\t\"dueTimeThisWeek\": \"本周\",\n\t\t\"dueTimeThisMonth\": \"本月\",\n\t\t\"dueTimeFuture\": \"未来\",\n\t\t\"clearFilters\": \"清除筛选\",\n\t\t\"quickFilters\": \"快速筛选\",\n\t\t\"moreOptions\": \"更多选项\"\n\t},\n\t\"todoDetail\": {\n\t\t\"editTitle\": \"点击编辑标题\",\n\t\t\"viewDescription\": \"查看描述\",\n\t\t\"selectTodoPrompt\": \"请选择一个待办事项查看详情\",\n\t\t\"detailViewLabel\": \"Detail\",\n\t\t\"artifactsViewLabel\": \"Artifacts\",\n\t\t\"markAsComplete\": \"标记为完成\",\n\t\t\"delete\": \"删除\",\n\t\t\"addDeadline\": \"添加日期\",\n\t\t\"addTags\": \"添加标签\",\n\t\t\"tagsPlaceholder\": \"使用逗号分隔多个标签\",\n\t\t\"save\": \"保存\",\n\t\t\"cancel\": \"取消\",\n\t\t\"clear\": \"清空\",\n\t\t\"current\": \"当前\",\n\t\t\"priorityLabel\": \"优先级：{priority}\",\n\t\t\"setAsChild\": \"设为子任务\",\n\t\t\"collapseSubTasks\": \"折叠子任务\",\n\t\t\"expandSubTasks\": \"展开子任务\",\n\t\t\"addChildPlaceholder\": \"输入子待办名称...\",\n\t\t\"add\": \"添加\",\n\t\t\"addChild\": \"添加子待办\",\n\t\t\"childTodos\": \"子待办\",\n\t\t\"useAiPlan\": \"AI拆解任务\",\n\t\t\"useAiPlanTitle\": \"使用AI拆解任务\",\n\t\t\"getAdvice\": \"获取建议\",\n\t\t\"getAdviceTitle\": \"针对此任务获取AI建议\",\n\t\t\"descriptionLabel\": \"描述\",\n\t\t\"backgroundLabel\": \"背景\",\n\t\t\"notesLabel\": \"备注\",\n\t\t\"descriptionEmptyPlaceholder\": \"暂无描述（点击添加）\",\n\t\t\"backgroundPlaceholder\": \"输入背景信息...\",\n\t\t\"backgroundEmptyPlaceholder\": \"暂无背景（点击添加）\",\n\t\t\"notesPlaceholder\": \"在此输入备注...\",\n\t\t\"notesEmptyPlaceholder\": \"暂无备注\",\n\t\t\"progressLabel\": \"Progress\",\n\t\t\"progressEmptyTitle\": \"暂无工作台任务\",\n\t\t\"progressEmptyHint\": \"生成产物时会在这里显示进度。\",\n\t\t\"artifactsLabel\": \"产物\",\n\t\t\"artifactsEmpty\": \"暂无产物\",\n\t\t\"contextLabel\": \"上下文\",\n\t\t\"editContext\": \"编辑详情\",\n\t\t\"contextAttachmentsLabel\": \"上下文附件\",\n\t\t\"contextAttachmentsEmpty\": \"暂无附件\",\n\t\t\"uploadLabel\": \"上传\",\n\t\t\"uploadHint\": \"单文件不超过 50MB\",\n\t\t\"previewLabel\": \"预览\",\n\t\t\"previewUnavailable\": \"该格式暂不支持预览\",\n\t\t\"downloadLabel\": \"下载\",\n\t\t\"uploadFailed\": \"上传失败\",\n\t\t\"removeAttachmentFailed\": \"移除附件失败\",\n\t\t\"uploadSizeLimit\": \"单文件不能超过 50MB\",\n\t\t\"deleteConfirmTitle\": \"确认删除此待办？\",\n\t\t\"deleteConfirmDescription\": \"该操作无法撤销。\",\n\t\t\"deleteConfirmWithChildren\": \"将同时删除此待办及其 {count} 个子待办。\",\n\t\t\"deleteConfirmCancel\": \"取消\",\n\t\t\"deleteConfirmDelete\": \"删除\"\n\t},\n\t\"automationTasks\": {\n\t\t\"title\": \"自定义定时任务\",\n\t\t\"description\": \"创建轻量定时任务，比如在指定时间抓取内容。\",\n\t\t\"createTitle\": \"新建任务\",\n\t\t\"createHint\": \"暂时仅支持网页抓取\",\n\t\t\"empty\": \"暂无定时任务\",\n\t\t\"dateLocale\": \"zh-CN\",\n\t\t\"labels\": {\n\t\t\t\"name\": \"任务名称\",\n\t\t\t\"description\": \"描述\",\n\t\t\t\"url\": \"抓取 URL\",\n\t\t\t\"method\": \"请求方式\",\n\t\t\t\"enabled\": \"启用\",\n\t\t\t\"enabledOn\": \"已启用\",\n\t\t\t\"enabledOff\": \"已停用\",\n\t\t\t\"scheduleType\": \"调度类型\",\n\t\t\t\"intervalMinutes\": \"间隔（分钟）\",\n\t\t\t\"cron\": \"Cron 表达式\",\n\t\t\t\"runAt\": \"执行时间\",\n\t\t\t\"lastRun\": \"上次执行：{time}\",\n\t\t\t\"status\": \"状态\"\n\t\t},\n\t\t\"placeholders\": {\n\t\t\t\"name\": \"每日检查\",\n\t\t\t\"description\": \"可选备注\"\n\t\t},\n\t\t\"scheduleType\": {\n\t\t\t\"interval\": \"固定间隔\",\n\t\t\t\"cron\": \"Cron\",\n\t\t\t\"once\": \"仅一次\"\n\t\t},\n\t\t\"scheduleSummary\": {\n\t\t\t\"interval\": \"每 {minutes} 分钟\",\n\t\t\t\"cron\": \"Cron：{cron}\",\n\t\t\t\"once\": \"执行于 {time}\"\n\t\t},\n\t\t\"actions\": {\n\t\t\t\"create\": \"创建任务\",\n\t\t\t\"run\": \"立即执行\",\n\t\t\t\"enable\": \"启用\",\n\t\t\t\"disable\": \"停用\",\n\t\t\t\"delete\": \"删除\"\n\t\t},\n\t\t\"status\": {\n\t\t\t\"success\": \"成功\",\n\t\t\t\"error\": \"失败\",\n\t\t\t\"never\": \"从未\"\n\t\t},\n\t\t\"errors\": {\n\t\t\t\"nameRequired\": \"需要填写任务名称\",\n\t\t\t\"urlRequired\": \"需要填写 URL\",\n\t\t\t\"intervalRequired\": \"间隔必须大于 0\",\n\t\t\t\"cronRequired\": \"需要填写 Cron 表达式\",\n\t\t\t\"runAtRequired\": \"需要选择执行时间\",\n\t\t\t\"createFailed\": \"创建任务失败：{error}\",\n\t\t\t\"runFailed\": \"执行任务失败：{error}\",\n\t\t\t\"updateFailed\": \"更新任务失败：{error}\",\n\t\t\t\"deleteFailed\": \"删除任务失败：{error}\"\n\t\t},\n\t\t\"messages\": {\n\t\t\t\"created\": \"任务已创建\",\n\t\t\t\"ran\": \"任务已执行\",\n\t\t\t\"enabled\": \"任务已启用\",\n\t\t\t\"disabled\": \"任务已停用\",\n\t\t\t\"deleted\": \"任务已删除\"\n\t\t},\n\t\t\"confirmDelete\": \"确认删除该任务？\"\n\t},\n\t\"contextMenu\": {\n\t\t\"selectedCount\": \"已选中 {count} 项\",\n\t\t\"batchCancel\": \"批量放弃\",\n\t\t\"batchDelete\": \"批量删除\",\n\t\t\"addChild\": \"添加子待办\",\n\t\t\"useAiPlan\": \"使用AI规划\",\n\t\t\"cancel\": \"放弃\",\n\t\t\"delete\": \"删除\",\n\t\t\"childNamePlaceholder\": \"输入子待办名称...\",\n\t\t\"cancelButton\": \"取消\",\n\t\t\"addButton\": \"添加\",\n\t\t\"extractButton\": \"提取待办\",\n\t\t\"extracting\": \"正在提取待办事项...\"\n\t},\n\t\"onboarding\": {\n\t\t\"welcomeTitle\": \"欢迎使用 Free Todo\",\n\t\t\"welcomeDescription\": \"快速配置你的 AI 助手。\",\n\t\t\"apiKeyStepTitle\": \"API Key\",\n\t\t\"apiKeyStepDescription\": \"填写阿里云百炼大模型 API Key。\",\n\t\t\"dockTriggerTitle\": \"下移鼠标\",\n\t\t\"dockTriggerDescription\": \"将鼠标移至底部边缘，Dock 会自动出现。\",\n\t\t\"dockStepTitle\": \"底部 Dock\",\n\t\t\"dockStepDescription\": \"单击以开关面板，拖拽以调整顺序。\",\n\t\t\"dockRightClickTitle\": \"尝试右键点击\",\n\t\t\"dockRightClickDescription\": \"右键点击此项目查看更多选项。\",\n\t\t\"dockMenuTitle\": \"面板选择器\",\n\t\t\"dockMenuDescription\": \"选择一个面板进行切换，试试点击「聊天」。\",\n\t\t\"completeTitle\": \"准备就绪！\",\n\t\t\"completeDescription\": \"开始使用 AI 助手管理待办事项。\",\n\t\t\"nextBtn\": \"下一步\",\n\t\t\"prevBtn\": \"上一步\",\n\t\t\"doneBtn\": \"开始使用\",\n\t\t\"skipBtn\": \"跳过引导\",\n\t\t\"restartTour\": \"重新开始引导\",\n\t\t\"restartTourDescription\": \"点击此按钮可以随时开始或重新查看新手引导流程\"\n\t}\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/i18n/request.ts",
    "content": "import { cookies, headers } from \"next/headers\";\nimport { getRequestConfig } from \"next-intl/server\";\n\n// Supported locales - add new languages here\n// Must match the files in ./messages/ directory\nconst SUPPORTED_LOCALES = [\"zh\", \"en\"] as const;\ntype Locale = (typeof SUPPORTED_LOCALES)[number];\n\n// Default locale when no match is found\nconst DEFAULT_LOCALE: Locale = \"en\";\n\nconst isValidLocale = (value: string | undefined): value is Locale => {\n\treturn value !== undefined && SUPPORTED_LOCALES.includes(value as Locale);\n};\n\n/**\n * 从 Accept-Language header 解析用户偏好的语言\n * 格式示例: \"zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6\"\n */\nfunction parseAcceptLanguage(acceptLanguage: string | null): Locale | null {\n\tif (!acceptLanguage) return null;\n\n\t// 解析并按权重排序\n\tconst languages = acceptLanguage\n\t\t.split(\",\")\n\t\t.map((lang) => {\n\t\t\tconst [code, qValue] = lang.trim().split(\";q=\");\n\t\t\treturn {\n\t\t\t\t// 取语言代码的前缀部分 (zh-CN -> zh, en-US -> en)\n\t\t\t\tcode: code.split(\"-\")[0].toLowerCase(),\n\t\t\t\t// 默认权重为 1\n\t\t\t\tq: qValue ? Number.parseFloat(qValue) : 1,\n\t\t\t};\n\t\t})\n\t\t.sort((a, b) => b.q - a.q);\n\n\t// 找到第一个匹配的支持语言\n\tfor (const { code } of languages) {\n\t\tif (SUPPORTED_LOCALES.includes(code as Locale)) {\n\t\t\treturn code as Locale;\n\t\t}\n\t}\n\n\treturn null;\n}\n\nexport default getRequestConfig(async () => {\n\tconst cookieStore = await cookies();\n\tconst localeCookie = cookieStore.get(\"locale\")?.value;\n\n\tlet locale: Locale;\n\n\tif (isValidLocale(localeCookie)) {\n\t\t// 优先使用用户手动选择的语言（cookie）\n\t\tlocale = localeCookie;\n\t} else {\n\t\t// Cookie 为空时，从 Accept-Language header 检测浏览器语言\n\t\tconst headerStore = await headers();\n\t\tconst acceptLanguage = headerStore.get(\"accept-language\");\n\t\tconst browserLocale = parseAcceptLanguage(acceptLanguage);\n\t\tlocale = browserLocale ?? DEFAULT_LOCALE;\n\t}\n\n\treturn {\n\t\tlocale,\n\t\tmessages: (await import(`./messages/${locale}.json`)).default,\n\t};\n});\n"
  },
  {
    "path": "free-todo-frontend/lib/island/types.ts",
    "content": "/**\n * Island 动态岛类型定义\n */\n\n/**\n * 动态岛模式枚举\n */\nexport enum IslandMode {\n  /** 悬浮小窗 - 始终可见的小药丸形状 (180×48px) */\n  FLOAT = \"FLOAT\",\n  /** 弹出通知 - 中等大小的通知卡片 (340×110px) */\n  POPUP = \"POPUP\",\n  /** 侧边栏 - 侧边面板 (400px 宽, ~500px 高) */\n  SIDEBAR = \"SIDEBAR\",\n  /** 全屏 - 全屏显示 */\n  FULLSCREEN = \"FULLSCREEN\",\n}\n\n/**\n * 各模式对应的窗口尺寸配置\n */\nexport const ISLAND_SIZES: Record<IslandMode, { width: number; height: number }> = {\n  [IslandMode.FLOAT]: { width: 200, height: 56 }, // 紧凑胶囊设计，黄金比例布局\n  [IslandMode.POPUP]: { width: 380, height: 120 },\n  [IslandMode.SIDEBAR]: { width: 420, height: 700 },\n  [IslandMode.FULLSCREEN]: { width: 0, height: 0 }, // 全屏时使用屏幕尺寸\n};\n\n/**\n * SIDEBAR 模式各栏数的窗口尺寸配置\n */\nexport const SIDEBAR_COLUMN_SIZES: Record<1 | 2 | 3, { width: number; height: number }> = {\n  1: { width: 420, height: 700 },   // 单栏（现有）\n  2: { width: 800, height: 700 },   // 双栏\n  3: { width: 1200, height: 700 },  // 三栏\n};\n\n/**\n * Island 窗口配置\n */\nexport const ISLAND_WINDOW_CONFIG = {\n  /** 默认初始模式 */\n  defaultMode: IslandMode.FLOAT,\n  /** 距离屏幕右边缘的距离 */\n  marginRight: 20,\n  /** 距离屏幕顶部的距离 */\n  marginTop: 20,\n  /** 窗口背景色（透明） */\n  backgroundColor: \"#00000000\",\n};\n"
  },
  {
    "path": "free-todo-frontend/lib/plugins/registry.ts",
    "content": "import { type ComponentType, type LazyExoticComponent, lazy } from \"react\";\n\nimport type { PanelFeature } from \"@/lib/config/panel-config\";\nimport { FEATURE_ICON_MAP } from \"@/lib/config/panel-config\";\n\nexport type PanelPlugin = {\n\tid: PanelFeature;\n\tlabelKey: string;\n\tplaceholderKey: string;\n\ticon: (typeof FEATURE_ICON_MAP)[PanelFeature];\n\tloader?: () => Promise<{ default: ComponentType }>;\n\tbackendModules?: string[];\n};\n\nconst panelRegistry: Record<PanelFeature, PanelPlugin> = {\n\tcalendar: {\n\t\tid: \"calendar\",\n\t\tlabelKey: \"calendarLabel\",\n\t\tplaceholderKey: \"calendarPlaceholder\",\n\t\ticon: FEATURE_ICON_MAP.calendar,\n\t\tbackendModules: [\"event\"],\n\t\tloader: () =>\n\t\t\timport(\"@/apps/calendar/CalendarPanel\").then((mod) => ({\n\t\t\t\tdefault: mod.CalendarPanel,\n\t\t\t})),\n\t},\n\tactivity: {\n\t\tid: \"activity\",\n\t\tlabelKey: \"activityLabel\",\n\t\tplaceholderKey: \"activityPlaceholder\",\n\t\ticon: FEATURE_ICON_MAP.activity,\n\t\tbackendModules: [\"activity\"],\n\t\tloader: () =>\n\t\t\timport(\"@/apps/activity/ActivityPanel\").then((mod) => ({\n\t\t\t\tdefault: mod.ActivityPanel,\n\t\t\t})),\n\t},\n\ttodos: {\n\t\tid: \"todos\",\n\t\tlabelKey: \"todosLabel\",\n\t\tplaceholderKey: \"todosPlaceholder\",\n\t\ticon: FEATURE_ICON_MAP.todos,\n\t\tbackendModules: [\"todo\"],\n\t\tloader: () =>\n\t\t\timport(\"@/apps/todo-list\").then((mod) => ({\n\t\t\t\tdefault: mod.TodoList,\n\t\t\t})),\n\t},\n\tchat: {\n\t\tid: \"chat\",\n\t\tlabelKey: \"chatLabel\",\n\t\tplaceholderKey: \"chatPlaceholder\",\n\t\ticon: FEATURE_ICON_MAP.chat,\n\t\tbackendModules: [\"chat\"],\n\t\tloader: () =>\n\t\t\timport(\"@/apps/chat/ChatPanel\").then((mod) => ({\n\t\t\t\tdefault: mod.ChatPanel,\n\t\t\t})),\n\t},\n\ttodoDetail: {\n\t\tid: \"todoDetail\",\n\t\tlabelKey: \"todoDetailLabel\",\n\t\tplaceholderKey: \"todoDetailPlaceholder\",\n\t\ticon: FEATURE_ICON_MAP.todoDetail,\n\t\tbackendModules: [\"todo\"],\n\t\tloader: () =>\n\t\t\timport(\"@/apps/todo-detail\").then((mod) => ({\n\t\t\t\tdefault: mod.TodoDetail,\n\t\t\t})),\n\t},\n\tdiary: {\n\t\tid: \"diary\",\n\t\tlabelKey: \"diaryLabel\",\n\t\tplaceholderKey: \"diaryPlaceholder\",\n\t\ticon: FEATURE_ICON_MAP.diary,\n\t\tbackendModules: [\"journal\"],\n\t\tloader: () =>\n\t\t\timport(\"@/apps/diary\").then((mod) => ({\n\t\t\t\tdefault: mod.DiaryPanel,\n\t\t\t})),\n\t},\n\tsettings: {\n\t\tid: \"settings\",\n\t\tlabelKey: \"settingsLabel\",\n\t\tplaceholderKey: \"settingsPlaceholder\",\n\t\ticon: FEATURE_ICON_MAP.settings,\n\t\tbackendModules: [\"config\"],\n\t\tloader: () =>\n\t\t\timport(\"@/apps/settings\").then((mod) => ({\n\t\t\t\tdefault: mod.SettingsPanel,\n\t\t\t})),\n\t},\n\tcostTracking: {\n\t\tid: \"costTracking\",\n\t\tlabelKey: \"costTrackingLabel\",\n\t\tplaceholderKey: \"costTrackingPlaceholder\",\n\t\ticon: FEATURE_ICON_MAP.costTracking,\n\t\tbackendModules: [\"cost_tracking\"],\n\t\tloader: () =>\n\t\t\timport(\"@/apps/cost-tracking\").then((mod) => ({\n\t\t\t\tdefault: mod.CostTrackingPanel,\n\t\t\t})),\n\t},\n\tachievements: {\n\t\tid: \"achievements\",\n\t\tlabelKey: \"achievementsLabel\",\n\t\tplaceholderKey: \"achievementsPlaceholder\",\n\t\ticon: FEATURE_ICON_MAP.achievements,\n\t\tloader: () =>\n\t\t\timport(\"@/apps/achievements/AchievementsPanel\").then((mod) => ({\n\t\t\t\tdefault: mod.AchievementsPanel,\n\t\t\t})),\n\t},\n\tdebugShots: {\n\t\tid: \"debugShots\",\n\t\tlabelKey: \"debugShotsLabel\",\n\t\tplaceholderKey: \"debugShotsPlaceholder\",\n\t\ticon: FEATURE_ICON_MAP.debugShots,\n\t\tbackendModules: [\"event\"],\n\t\tloader: () =>\n\t\t\timport(\"@/apps/debug/DebugCapturePanel\").then((mod) => ({\n\t\t\t\tdefault: mod.DebugCapturePanel,\n\t\t\t})),\n\t},\n\taudio: {\n\t\tid: \"audio\",\n\t\tlabelKey: \"audioLabel\",\n\t\tplaceholderKey: \"audioPlaceholder\",\n\t\ticon: FEATURE_ICON_MAP.audio,\n\t\tbackendModules: [\"audio\"],\n\t\tloader: () =>\n\t\t\timport(\"@/apps/audio/AudioPanel\").then((mod) => ({\n\t\t\t\tdefault: mod.AudioPanel,\n\t\t\t})),\n\t},\n};\n\nconst lazyPanelCache = new Map<PanelFeature, LazyExoticComponent<ComponentType>>();\n\nexport function getPanelPlugin(feature: PanelFeature | null): PanelPlugin | null {\n\tif (!feature) return null;\n\treturn panelRegistry[feature] ?? null;\n}\n\nexport function getPanelPlugins(): PanelPlugin[] {\n\treturn Object.values(panelRegistry);\n}\n\nexport function getPanelLazyComponent(\n\tfeature: PanelFeature | null,\n): LazyExoticComponent<ComponentType> | null {\n\tif (!feature) return null;\n\tconst plugin = panelRegistry[feature];\n\tif (!plugin?.loader) return null;\n\tconst cached = lazyPanelCache.get(feature);\n\tif (cached) return cached;\n\tconst lazyComponent = lazy(plugin.loader);\n\tlazyPanelCache.set(feature, lazyComponent);\n\treturn lazyComponent;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/query/activities.ts",
    "content": "\"use client\";\n\nimport { useQuery } from \"@tanstack/react-query\";\nimport {\n\tuseGetActivityEventsApiActivitiesActivityIdEventsGet,\n\tuseListActivitiesApiActivitiesGet,\n} from \"@/lib/generated/activity/activity\";\nimport {\n\tuseGetEventDetailApiEventsEventIdGet,\n\tuseListEventsApiEventsGet,\n} from \"@/lib/generated/event/event\";\nimport type {\n\tActivity,\n\tActivityEventsResponse,\n\tActivityListResponse,\n\tActivityWithEvents,\n\tEvent,\n\tEventListResponse,\n} from \"@/lib/types\";\nimport { queryKeys } from \"./keys\";\n\n// ============================================================================\n// Helper Functions\n// ============================================================================\n\n/**\n * Normalize API response to ensure consistent Activity type\n * Now that fetcher auto-converts snake_case -> camelCase\n */\nfunction normalizeActivity(raw: Record<string, unknown>): Activity {\n\treturn {\n\t\tid: raw.id as number,\n\t\tstartTime: raw.startTime as string,\n\t\tendTime: raw.endTime as string,\n\t\taiTitle: (raw.aiTitle as string) ?? undefined,\n\t\taiSummary: (raw.aiSummary as string) ?? undefined,\n\t\teventCount: raw.eventCount as number,\n\t\tcreatedAt: (raw.createdAt as string) ?? undefined,\n\t\tupdatedAt: (raw.updatedAt as string) ?? undefined,\n\t};\n}\n\n/**\n * Normalize API response to ensure consistent Event type\n */\nfunction normalizeEvent(raw: Record<string, unknown>): Event {\n\tconst screenshots = (raw.screenshots as unknown[]) || [];\n\tconst screenshotCount = screenshots.length ?? 0;\n\tconst firstScreenshotId =\n\t\t((screenshots[0] as Record<string, unknown>)?.id as number) ?? undefined;\n\n\treturn {\n\t\tid: raw.id as number,\n\t\tappName: (raw.appName as string) || \"\",\n\t\twindowTitle: (raw.windowTitle as string) || \"\",\n\t\tstartTime: raw.startTime as string,\n\t\tendTime: (raw.endTime as string) ?? undefined,\n\t\tscreenshotCount,\n\t\tfirstScreenshotId,\n\t\taiTitle: (raw.aiTitle as string) ?? undefined,\n\t\taiSummary: (raw.aiSummary as string) ?? undefined,\n\t\tscreenshots: screenshots as Event[\"screenshots\"],\n\t};\n}\n\n// ============================================================================\n// Query Hooks\n// ============================================================================\n\ninterface UseActivitiesParams {\n\tlimit?: number;\n\toffset?: number;\n\tstart_date?: string;\n\tend_date?: string;\n}\n\n/**\n * 获取 Activity 列表的 Query Hook\n * 使用 Orval 生成的 hook\n */\nexport function useActivities(params?: UseActivitiesParams) {\n\treturn useListActivitiesApiActivitiesGet(\n\t\t{\n\t\t\tlimit: params?.limit ?? 50,\n\t\t\toffset: params?.offset ?? 0,\n\t\t\tstart_date: params?.start_date,\n\t\t\tend_date: params?.end_date,\n\t\t},\n\t\t{\n\t\t\tquery: {\n\t\t\t\tqueryKey: queryKeys.activities.list(params),\n\t\t\t\tstaleTime: 30 * 1000,\n\t\t\t\tselect: (data: unknown) => {\n\t\t\t\t\t// Data is now auto-converted to camelCase by the fetcher\n\t\t\t\t\tconst response = data as ActivityListResponse;\n\t\t\t\t\treturn (response?.activities ?? []).map((raw) =>\n\t\t\t\t\t\tnormalizeActivity(raw as unknown as Record<string, unknown>),\n\t\t\t\t\t);\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t);\n}\n\n/**\n * 获取单个 Activity 的事件 ID 列表\n * 使用 Orval 生成的 hook\n */\nexport function useActivityEvents(activityId: number | null) {\n\treturn useGetActivityEventsApiActivitiesActivityIdEventsGet(activityId ?? 0, {\n\t\tquery: {\n\t\t\tqueryKey: queryKeys.activities.events(activityId ?? 0),\n\t\t\tenabled: activityId !== null,\n\t\t\tstaleTime: 60 * 1000,\n\t\t\tselect: (data: unknown) => {\n\t\t\t\t// Data is now auto-converted to camelCase by the fetcher\n\t\t\t\tconst response = data as ActivityEventsResponse;\n\t\t\t\treturn response?.eventIds ?? [];\n\t\t\t},\n\t\t},\n\t});\n}\n\n/**\n * 获取单个 Event 详情的 Query Hook\n * 使用 Orval 生成的 hook\n */\nexport function useEvent(eventId: number | null) {\n\treturn useGetEventDetailApiEventsEventIdGet(eventId ?? 0, {\n\t\tquery: {\n\t\t\tqueryKey: queryKeys.events.detail(eventId ?? 0),\n\t\t\tenabled: eventId !== null,\n\t\t\tstaleTime: 60 * 1000,\n\t\t\tselect: (data: unknown) => {\n\t\t\t\tif (!data) return null;\n\t\t\t\treturn normalizeEvent(data as Record<string, unknown>);\n\t\t\t},\n\t\t},\n\t});\n}\n\n/**\n * 批量获取多个 Event 详情的 Query Hook\n * 使用自定义查询组合多个 event 请求\n */\nexport function useEvents(eventIds: number[]) {\n\treturn useQuery({\n\t\tqueryKey: [\"events\", \"batch\", eventIds],\n\t\tqueryFn: async () => {\n\t\t\tif (eventIds.length === 0) return [];\n\n\t\t\t// 使用 Orval 生成的 fetcher 函数\n\t\t\tconst { getEventDetailApiEventsEventIdGet } = await import(\n\t\t\t\t\"@/lib/generated/event/event\"\n\t\t\t);\n\n\t\t\tconst results = await Promise.all(\n\t\t\t\teventIds.map(async (id) => {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst data = await getEventDetailApiEventsEventIdGet(id);\n\t\t\t\t\t\tif (!data) return null;\n\t\t\t\t\t\treturn normalizeEvent(data as unknown as Record<string, unknown>);\n\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\tconsole.error(\"Failed to load event\", id, error);\n\t\t\t\t\t\treturn null;\n\t\t\t\t\t}\n\t\t\t\t}),\n\t\t\t);\n\n\t\t\treturn results.filter((e): e is Event => e !== null);\n\t\t},\n\t\tenabled: eventIds.length > 0,\n\t\tstaleTime: 60 * 1000,\n\t});\n}\n\ninterface UseEventsListParams {\n\tlimit?: number;\n\toffset?: number;\n\tstart_date?: string;\n\tend_date?: string;\n\tapp_name?: string;\n}\n\n/**\n * 获取 Event 列表的 Query Hook\n * 使用 Orval 生成的 hook\n */\nexport function useEventsList(params?: UseEventsListParams) {\n\treturn useListEventsApiEventsGet(params, {\n\t\tquery: {\n\t\t\tqueryKey: queryKeys.events.list(params),\n\t\t\tstaleTime: 30 * 1000,\n\t\t\tselect: (data: unknown) => {\n\t\t\t\tconst response = data as EventListResponse;\n\t\t\t\treturn response?.events ?? [];\n\t\t\t},\n\t\t},\n\t});\n}\n\n// ============================================================================\n// 组合 Hook：获取 Activity 详情（包含关联的 Events）\n// ============================================================================\n\n/**\n * 获取 Activity 详情及其关联的 Events\n * 组合了 activities、activity events 和 event details 三个查询\n */\nexport function useActivityWithEvents(\n\tactivityId: number | null,\n\tactivities: Activity[],\n) {\n\t// 获取 activity 的事件 ID 列表\n\tconst {\n\t\tdata: eventIds = [],\n\t\tisLoading: isLoadingEvents,\n\t\terror: eventsError,\n\t} = useActivityEvents(activityId);\n\n\t// 批量获取事件详情\n\tconst {\n\t\tdata: events = [],\n\t\tisLoading: isLoadingEventDetails,\n\t\terror: eventDetailsError,\n\t} = useEvents(eventIds);\n\n\t// 查找当前 activity\n\tconst activity = activityId\n\t\t? (activities.find((a) => a.id === activityId) ?? null)\n\t\t: null;\n\n\t// 构建带事件的 activity\n\tconst activityWithEvents: ActivityWithEvents | null = activity\n\t\t? {\n\t\t\t\t...activity,\n\t\t\t\teventIds,\n\t\t\t\tevents,\n\t\t\t}\n\t\t: null;\n\n\treturn {\n\t\tactivity: activityWithEvents,\n\t\tevents,\n\t\tisLoading: isLoadingEvents || isLoadingEventDetails,\n\t\terror: eventsError || eventDetailsError,\n\t};\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/query/automation.ts",
    "content": "import { useMutation, useQuery, useQueryClient } from \"@tanstack/react-query\";\nimport { customFetcher } from \"@/lib/api/fetcher\";\nimport { queryKeys } from \"@/lib/query/keys\";\nimport type {\n\tAutomationTask,\n\tAutomationTaskCreateInput,\n\tAutomationTaskListResponse,\n\tAutomationTaskUpdateInput,\n} from \"@/lib/types\";\n\nexport const useAutomationTasks = () =>\n\tuseQuery({\n\t\tqueryKey: queryKeys.automationTasks.list(),\n\t\tqueryFn: () =>\n\t\t\tcustomFetcher<AutomationTaskListResponse>(\"/api/automation/tasks\"),\n\t});\n\nexport const useCreateAutomationTask = () => {\n\tconst queryClient = useQueryClient();\n\treturn useMutation({\n\t\tmutationFn: (input: AutomationTaskCreateInput) =>\n\t\t\tcustomFetcher<AutomationTask>(\"/api/automation/tasks\", {\n\t\t\t\tmethod: \"POST\",\n\t\t\t\tdata: input,\n\t\t\t}),\n\t\tonSuccess: () => {\n\t\t\tqueryClient.invalidateQueries({\n\t\t\t\tqueryKey: queryKeys.automationTasks.all,\n\t\t\t});\n\t\t},\n\t});\n};\n\nexport const useUpdateAutomationTask = () => {\n\tconst queryClient = useQueryClient();\n\treturn useMutation({\n\t\tmutationFn: ({\n\t\t\tid,\n\t\t\tinput,\n\t\t}: {\n\t\t\tid: number;\n\t\t\tinput: AutomationTaskUpdateInput;\n\t\t}) =>\n\t\t\tcustomFetcher<AutomationTask>(`/api/automation/tasks/${id}`, {\n\t\t\t\tmethod: \"PUT\",\n\t\t\t\tdata: input,\n\t\t\t}),\n\t\tonSuccess: () => {\n\t\t\tqueryClient.invalidateQueries({\n\t\t\t\tqueryKey: queryKeys.automationTasks.all,\n\t\t\t});\n\t\t},\n\t});\n};\n\nexport const useDeleteAutomationTask = () => {\n\tconst queryClient = useQueryClient();\n\treturn useMutation({\n\t\tmutationFn: (id: number) =>\n\t\t\tcustomFetcher(`/api/automation/tasks/${id}`, {\n\t\t\t\tmethod: \"DELETE\",\n\t\t\t}),\n\t\tonSuccess: () => {\n\t\t\tqueryClient.invalidateQueries({\n\t\t\t\tqueryKey: queryKeys.automationTasks.all,\n\t\t\t});\n\t\t},\n\t});\n};\n\nexport const useRunAutomationTask = () => {\n\tconst queryClient = useQueryClient();\n\treturn useMutation({\n\t\tmutationFn: (id: number) =>\n\t\t\tcustomFetcher(`/api/automation/tasks/${id}/run`, {\n\t\t\t\tmethod: \"POST\",\n\t\t\t}),\n\t\tonSuccess: () => {\n\t\t\tqueryClient.invalidateQueries({\n\t\t\t\tqueryKey: queryKeys.automationTasks.all,\n\t\t\t});\n\t\t},\n\t});\n};\n\nexport const useToggleAutomationTask = () => {\n\tconst queryClient = useQueryClient();\n\treturn useMutation({\n\t\tmutationFn: ({ id, enabled }: { id: number; enabled: boolean }) =>\n\t\t\tcustomFetcher(\n\t\t\t\t`/api/automation/tasks/${id}/${enabled ? \"resume\" : \"pause\"}`,\n\t\t\t\t{\n\t\t\t\t\tmethod: \"POST\",\n\t\t\t\t},\n\t\t\t),\n\t\tonSuccess: () => {\n\t\t\tqueryClient.invalidateQueries({\n\t\t\t\tqueryKey: queryKeys.automationTasks.all,\n\t\t\t});\n\t\t},\n\t});\n};\n"
  },
  {
    "path": "free-todo-frontend/lib/query/chat.ts",
    "content": "\"use client\";\n\nimport type { ChatHistoryItem, ChatSessionSummary } from \"@/lib/api\";\nimport { useGetChatHistoryApiChatHistoryGet } from \"@/lib/generated/chat/chat\";\nimport { queryKeys } from \"./keys\";\n\n// Chat history response type (since API returns unknown, we define it based on usage)\ninterface ChatHistoryResponse {\n\tsessions?: Array<{ id: string; [key: string]: unknown }>;\n\thistory?: Array<{ [key: string]: unknown }>;\n}\n\n// ============================================================================\n// Query Hooks\n// ============================================================================\n\n/**\n * 将会话数据转换为 ChatSessionSummary 类型\n * fetcher 已自动将 snake_case 转换为 camelCase\n */\nfunction mapSessionToSummary(session: {\n\t[key: string]: unknown;\n}): ChatSessionSummary {\n\treturn {\n\t\tsessionId: typeof session.sessionId === \"string\" ? session.sessionId : \"\",\n\t\ttitle: typeof session.title === \"string\" ? session.title : undefined,\n\t\tlastActive:\n\t\t\ttypeof session.lastActive === \"string\" ? session.lastActive : undefined,\n\t\tmessageCount:\n\t\t\ttypeof session.messageCount === \"number\"\n\t\t\t\t? session.messageCount\n\t\t\t\t: undefined,\n\t\tchatType:\n\t\t\ttypeof session.chatType === \"string\" ? session.chatType : undefined,\n\t};\n}\n\n/**\n * 获取聊天会话列表的 Query Hook\n * 使用 Orval 生成的 hook\n */\nexport function useChatSessions(options?: {\n\tlimit?: number;\n\tchatType?: string;\n\tenabled?: boolean;\n}) {\n\tconst { chatType, enabled = true } = options ?? {};\n\n\treturn useGetChatHistoryApiChatHistoryGet(\n\t\t{\n\t\t\tchat_type: chatType,\n\t\t\t// session_id 不传，获取会话列表\n\t\t},\n\t\t{\n\t\t\tquery: {\n\t\t\t\tqueryKey: queryKeys.chatHistory.sessions(chatType),\n\t\t\t\tenabled,\n\t\t\t\tstaleTime: 30 * 1000,\n\t\t\t\tselect: (data: unknown) => {\n\t\t\t\t\t// 返回会话列表，转换为 ChatSessionSummary[]\n\t\t\t\t\tconst response = data as ChatHistoryResponse;\n\t\t\t\t\treturn (response?.sessions ?? []).map(mapSessionToSummary);\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t);\n}\n\n/**\n * 将历史记录数据转换为 ChatHistoryItem 类型\n */\nfunction mapHistoryItem(item: {\n\t[key: string]: unknown;\n}): ChatHistoryItem | null {\n\tif (\n\t\ttypeof item.role !== \"string\" ||\n\t\t(item.role !== \"user\" && item.role !== \"assistant\") ||\n\t\ttypeof item.content !== \"string\"\n\t) {\n\t\treturn null;\n\t}\n\n\treturn {\n\t\trole: item.role as \"user\" | \"assistant\",\n\t\tcontent: item.content,\n\t\ttimestamp: typeof item.timestamp === \"string\" ? item.timestamp : undefined,\n\t\textraData: typeof item.extraData === \"string\" ? item.extraData : undefined,\n\t};\n}\n\n/**\n * 获取单个会话的消息历史的 Query Hook\n * 使用 Orval 生成的 hook\n */\nexport function useChatHistory(\n\tsessionId: string | null,\n\toptions?: { limit?: number; enabled?: boolean },\n) {\n\tconst { enabled = true } = options ?? {};\n\n\treturn useGetChatHistoryApiChatHistoryGet(\n\t\t{\n\t\t\tsession_id: sessionId ?? undefined,\n\t\t},\n\t\t{\n\t\t\tquery: {\n\t\t\t\tqueryKey: queryKeys.chatHistory.session(sessionId ?? \"\"),\n\t\t\t\tenabled: enabled && sessionId !== null,\n\t\t\t\tstaleTime: 30 * 1000,\n\t\t\t\tselect: (data: unknown) => {\n\t\t\t\t\t// 返回消息历史，转换为 ChatHistoryItem[]\n\t\t\t\t\tconst response = data as ChatHistoryResponse;\n\t\t\t\t\treturn (response?.history ?? [])\n\t\t\t\t\t\t.map(mapHistoryItem)\n\t\t\t\t\t\t.filter((item): item is ChatHistoryItem => item !== null);\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/query/config.ts",
    "content": "\"use client\";\n\nimport { useQueryClient } from \"@tanstack/react-query\";\nimport {\n\tuseGetConfigDetailedApiGetConfigGet,\n\tuseGetLlmStatusApiLlmStatusGet,\n\tuseSaveConfigApiSaveConfigPost,\n} from \"@/lib/generated/config/config\";\nimport { queryKeys } from \"./keys\";\n\n// ============================================================================\n// LLM 状态检查\n// ============================================================================\n\ninterface LlmStatusResponse {\n\tconfigured: boolean;\n}\n\n/**\n * 检查 LLM 是否已配置的 Query Hook\n * 用于应用启动时检查配置状态\n * 使用 Orval 生成的 hook\n */\nexport function useLlmStatus() {\n\treturn useGetLlmStatusApiLlmStatusGet({\n\t\tquery: {\n\t\t\tqueryKey: [\"llm-status\"],\n\t\t\tstaleTime: 60 * 1000, // 1 分钟\n\t\t\tretry: 1, // 只重试一次\n\t\t\tselect: (data: unknown) => data as LlmStatusResponse,\n\t\t},\n\t});\n}\n\n// ============================================================================\n// 类型定义\n// ============================================================================\n\nexport interface AppConfig {\n\t// 现有配置\n\tjobsAutoTodoDetectionEnabled?: boolean;\n\t// 自动待办检测白名单配置\n\tjobsAutoTodoDetectionParamsWhitelistApps?: string[];\n\t// LLM 配置\n\tllmApiKey?: string;\n\tllmBaseUrl?: string;\n\tllmModel?: string;\n\tllmTemperature?: number;\n\tllmMaxTokens?: number;\n\t// 录制配置\n\tjobsRecorderEnabled?: boolean;\n\tjobsRecorderInterval?: number;\n\tjobsRecorderParamsBlacklistEnabled?: boolean;\n\tjobsRecorderParamsBlacklistApps?: string[];\n\t[key: string]: unknown;\n}\n\n// ============================================================================\n// Query Hooks\n// ============================================================================\n\n/**\n * 获取应用配置的 Query Hook\n * 使用 Orval 生成的 hook，保持相同的 API\n */\nexport function useConfig() {\n\treturn useGetConfigDetailedApiGetConfigGet({\n\t\tquery: {\n\t\t\tqueryKey: queryKeys.config,\n\t\t\tstaleTime: 60 * 1000, // 1 分钟\n\t\t\tselect: (data: unknown) => {\n\t\t\t\t// 处理响应格式：{ success: boolean, config?: Record<string, unknown> }\n\t\t\t\tconst response = data as {\n\t\t\t\t\tsuccess?: boolean;\n\t\t\t\t\tconfig?: AppConfig;\n\t\t\t\t\terror?: string;\n\t\t\t\t};\n\t\t\t\tif (response?.success && response?.config) {\n\t\t\t\t\treturn response.config;\n\t\t\t\t}\n\t\t\t\tthrow new Error(response?.error || \"Failed to load config\");\n\t\t\t},\n\t\t},\n\t});\n}\n\n// ============================================================================\n// Mutation Hooks\n// ============================================================================\n\n/**\n * 保存应用配置的 Mutation Hook\n * 使用 Orval 生成的 hook，添加乐观更新逻辑\n */\nexport function useSaveConfig() {\n\tconst queryClient = useQueryClient();\n\n\treturn useSaveConfigApiSaveConfigPost({\n\t\tmutation: {\n\t\t\tonMutate: async (variables) => {\n\t\t\t\tconst newConfig = variables.data;\n\n\t\t\t\t// 取消正在进行的查询\n\t\t\t\tawait queryClient.cancelQueries({ queryKey: queryKeys.config });\n\n\t\t\t\t// 保存之前的数据\n\t\t\t\tconst previousConfig = queryClient.getQueryData<AppConfig>(\n\t\t\t\t\tqueryKeys.config,\n\t\t\t\t);\n\n\t\t\t\t// 乐观更新\n\t\t\t\tqueryClient.setQueryData<AppConfig>(queryKeys.config, (old) => ({\n\t\t\t\t\t...old,\n\t\t\t\t\t...newConfig,\n\t\t\t\t}));\n\n\t\t\t\treturn { previousConfig };\n\t\t\t},\n\t\t\tonError: (_err, _variables, context) => {\n\t\t\t\t// 发生错误时回滚\n\t\t\t\tif (context?.previousConfig) {\n\t\t\t\t\tqueryClient.setQueryData(queryKeys.config, context.previousConfig);\n\t\t\t\t}\n\t\t\t},\n\t\t\tonSettled: () => {\n\t\t\t\t// 重新获取最新数据\n\t\t\t\tqueryClient.invalidateQueries({ queryKey: queryKeys.config });\n\t\t\t},\n\t\t},\n\t});\n}\n\n// ============================================================================\n// 组合 Hook\n// ============================================================================\n\n/**\n * 提供配置的读写操作\n */\nexport function useConfigMutations() {\n\tconst saveConfigMutation = useSaveConfig();\n\n\treturn {\n\t\tsaveConfig: (config: Partial<AppConfig>) =>\n\t\t\tsaveConfigMutation.mutateAsync({ data: config }),\n\t\tisSaving: saveConfigMutation.isPending,\n\t\tsaveError: saveConfigMutation.error,\n\t};\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/query/cost.ts",
    "content": "\"use client\";\n\nimport {\n\tuseGetCostConfigApiCostTrackingConfigGet,\n\tuseGetCostStatsApiCostTrackingStatsGet,\n} from \"@/lib/generated/cost-tracking/cost-tracking\";\nimport { queryKeys } from \"./keys\";\n\n// Cost stats response type (since API returns unknown, we define it based on usage)\n// Note: fetcher converts snake_case to camelCase, so we use camelCase here\ninterface CostStatsResponse {\n\tdata?: {\n\t\ttotalCost?: number;\n\t\ttotalTokens?: number;\n\t\ttotalRequests?: number;\n\t\tdailyCosts?: Record<string, { cost?: number; totalTokens?: number }>;\n\t\tfeatureCosts?: Record<\n\t\t\tstring,\n\t\t\t{\n\t\t\t\tinputTokens?: number;\n\t\t\t\toutputTokens?: number;\n\t\t\t\trequests?: number;\n\t\t\t\tcost?: number;\n\t\t\t}\n\t\t>;\n\t\tmodelCosts?: Record<\n\t\t\tstring,\n\t\t\t{\n\t\t\t\tinputTokens?: number;\n\t\t\t\toutputTokens?: number;\n\t\t\t\tinputCost?: number;\n\t\t\t\toutputCost?: number;\n\t\t\t\ttotalCost?: number;\n\t\t\t}\n\t\t>;\n\t};\n}\n\ninterface CostConfigResponse {\n\tdata?: Record<string, unknown>;\n}\n\n// ============================================================================\n// Query Hooks\n// ============================================================================\n\n/**\n * 获取费用统计数据的 Query Hook\n * 使用 Orval 生成的 hook\n */\nexport function useCostStats(days: number) {\n\treturn useGetCostStatsApiCostTrackingStatsGet(\n\t\t{ days: days },\n\t\t{\n\t\t\tquery: {\n\t\t\t\tqueryKey: queryKeys.costStats(days),\n\t\t\t\tstaleTime: 60 * 1000, // 1 分钟内数据被认为是新鲜的\n\t\t\t\tselect: (data: unknown) => {\n\t\t\t\t\t// 处理响应格式：{ success: boolean, data?: CostStats }\n\t\t\t\t\tconst response = data as CostStatsResponse;\n\t\t\t\t\tif (response?.data) {\n\t\t\t\t\t\treturn response.data;\n\t\t\t\t\t}\n\t\t\t\t\tthrow new Error(\"Failed to load cost stats\");\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t);\n}\n\n/**\n * 获取费用配置的 Query Hook\n * 使用 Orval 生成的 hook\n */\nexport function useCostConfig() {\n\treturn useGetCostConfigApiCostTrackingConfigGet({\n\t\tquery: {\n\t\t\tqueryKey: [\"costConfig\"],\n\t\t\tstaleTime: 5 * 60 * 1000, // 5 分钟\n\t\t\tselect: (data: unknown) => {\n\t\t\t\t// 处理响应格式：{ success: boolean, data?: {...} }\n\t\t\t\tconst response = data as CostConfigResponse;\n\t\t\t\tif (response?.data) {\n\t\t\t\t\treturn response.data;\n\t\t\t\t}\n\t\t\t\tthrow new Error(\"Failed to load cost config\");\n\t\t\t},\n\t\t},\n\t});\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/query/index.ts",
    "content": "/**\n * TanStack Query Hooks 统一导出\n */\n\n// Activity Hooks\nexport {\n\tuseActivities,\n\tuseActivityEvents,\n\tuseActivityWithEvents,\n\tuseEvent,\n\tuseEvents,\n\tuseEventsList,\n} from \"./activities\";\n// Automation Hooks\nexport {\n\tuseAutomationTasks,\n\tuseCreateAutomationTask,\n\tuseDeleteAutomationTask,\n\tuseRunAutomationTask,\n\tuseToggleAutomationTask,\n\tuseUpdateAutomationTask,\n} from \"./automation\";\n// Chat Hooks\nexport { useChatHistory, useChatSessions } from \"./chat\";\n// Config Hooks\nexport {\n\ttype AppConfig,\n\tuseConfig,\n\tuseConfigMutations,\n\tuseLlmStatus,\n\tuseSaveConfig,\n} from \"./config\";\n// Cost Hooks\nexport { useCostConfig, useCostStats } from \"./cost\";\n// Journal Hooks\nexport {\n\ttype JournalAutoLinkResult,\n\ttype JournalView,\n\tuseJournalMutations,\n\tuseJournals,\n} from \"./journals\";\n// Query Keys\nexport { type QueryKeys, queryKeys } from \"./keys\";\n// Provider\nexport { getQueryClient, QueryProvider } from \"./provider\";\n// Todo Hooks\nexport {\n\ttype ReorderTodoItem,\n\tuseCreateTodo,\n\tuseDeleteTodo,\n\tuseReorderTodos,\n\tuseTodoMutations,\n\tuseTodos,\n\tuseToggleTodoStatus,\n\tuseUpdateTodo,\n} from \"./todos\";\n"
  },
  {
    "path": "free-todo-frontend/lib/query/journals.ts",
    "content": "\"use client\";\n\nimport { useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport { unwrapApiData } from \"@/lib/api/fetcher\";\nimport {\n\tautoLinkJournalApiJournalsAutoLinkPost,\n\tcreateJournalApiJournalsPost,\n\tgenerateAiJournalApiJournalsGenerateAiPost,\n\tgenerateObjectiveJournalApiJournalsGenerateObjectivePost,\n\tupdateJournalApiJournalsJournalIdPut,\n\tuseListJournalsApiJournalsGet,\n} from \"@/lib/generated/journals/journals\";\nimport type {\n\tJournalAutoLinkRequest,\n\tJournalAutoLinkResponse,\n\tJournalCreate,\n\tJournalGenerateRequest,\n\tJournalGenerateResponse,\n\tJournalListResponse,\n\tJournalResponse,\n\tJournalUpdate,\n\tListJournalsApiJournalsGetParams,\n} from \"@/lib/generated/schemas\";\nimport { queryKeys } from \"./keys\";\n\ninterface UseJournalsParams {\n\tlimit?: number;\n\toffset?: number;\n\tstartDate?: string;\n\tendDate?: string;\n}\n\nconst normalizeTags = (\n\traw: Record<string, unknown>[],\n): { id: number; tagName: string }[] =>\n\traw\n\t\t.filter((tag) => typeof tag === \"object\" && tag !== null)\n\t\t.map((tag) => ({\n\t\t\tid: (tag.id as number) ?? 0,\n\t\t\ttagName: (tag.tagName as string) ?? \"\",\n\t\t}));\n\nconst normalizeJournal = (raw: Record<string, unknown>) => ({\n\tid: raw.id as number,\n\tuid: (raw.uid as string) ?? null,\n\tname: (raw.name as string) ?? \"\",\n\tuserNotes: (raw.userNotes as string) ?? \"\",\n\tdate: raw.date as string,\n\tcontentFormat: (raw.contentFormat as string) ?? \"markdown\",\n\tcontentObjective: (raw.contentObjective as string) ?? null,\n\tcontentAi: (raw.contentAi as string) ?? null,\n\tmood: (raw.mood as string) ?? null,\n\tenergy: (raw.energy as number) ?? null,\n\tdayBucketStart: (raw.dayBucketStart as string) ?? null,\n\tcreatedAt: raw.createdAt as string,\n\tupdatedAt: raw.updatedAt as string,\n\tdeletedAt: (raw.deletedAt as string) ?? null,\n\ttags: normalizeTags(\n\t\t((raw.tags as Record<string, unknown>[]) ?? []).filter(Boolean),\n\t),\n\trelatedTodoIds: (raw.relatedTodoIds as number[]) ?? [],\n\trelatedActivityIds: (raw.relatedActivityIds as number[]) ?? [],\n});\n\nconst normalizeAutoLinkResponse = (raw: Record<string, unknown>) => ({\n\trelatedTodoIds: (raw.relatedTodoIds as number[]) ?? [],\n\trelatedActivityIds: (raw.relatedActivityIds as number[]) ?? [],\n\ttodoCandidates: (raw.todoCandidates as Array<Record<string, unknown>>) ?? [],\n\tactivityCandidates:\n\t\t(raw.activityCandidates as Array<Record<string, unknown>>) ?? [],\n});\n\nexport type JournalView = ReturnType<typeof normalizeJournal>;\nexport type JournalAutoLinkResult = ReturnType<typeof normalizeAutoLinkResponse>;\n\nexport function useJournals(params?: UseJournalsParams) {\n\tconst queryParams: ListJournalsApiJournalsGetParams = {\n\t\tlimit: params?.limit ?? 50,\n\t\toffset: params?.offset ?? 0,\n\t\tstart_date: params?.startDate,\n\t\tend_date: params?.endDate,\n\t};\n\n\treturn useListJournalsApiJournalsGet(queryParams, {\n\t\tquery: {\n\t\t\tqueryKey: queryKeys.journals.list(params),\n\t\t\tstaleTime: 30 * 1000,\n\t\t\tselect: (data: unknown) => {\n\t\t\t\tconst response =\n\t\t\t\t\tunwrapApiData<JournalListResponse>(data) ?? {\n\t\t\t\t\t\ttotal: 0,\n\t\t\t\t\t\tjournals: [],\n\t\t\t\t\t};\n\t\t\t\tconst journals = (response.journals ?? []).map((journal) =>\n\t\t\t\t\tnormalizeJournal(journal as unknown as Record<string, unknown>),\n\t\t\t\t);\n\t\t\t\treturn {\n\t\t\t\t\ttotal: response.total ?? 0,\n\t\t\t\t\tjournals,\n\t\t\t\t};\n\t\t\t},\n\t\t},\n\t});\n}\n\nconst createJournal = async (input: JournalCreate) => {\n\tconst response = await createJournalApiJournalsPost(input);\n\tconst data = unwrapApiData<JournalResponse>(response);\n\treturn data ? normalizeJournal(data as unknown as Record<string, unknown>) : null;\n};\n\nconst updateJournal = async (id: number, input: JournalUpdate) => {\n\tconst response = await updateJournalApiJournalsJournalIdPut(id, input);\n\tconst data = unwrapApiData<JournalResponse>(response);\n\treturn data ? normalizeJournal(data as unknown as Record<string, unknown>) : null;\n};\n\nconst autoLinkJournal = async (input: JournalAutoLinkRequest) => {\n\tconst response = await autoLinkJournalApiJournalsAutoLinkPost(input);\n\tconst data = unwrapApiData<JournalAutoLinkResponse>(response);\n\treturn data\n\t\t? normalizeAutoLinkResponse(data as Record<string, unknown>)\n\t\t: normalizeAutoLinkResponse({});\n};\n\nconst generateObjective = async (input: JournalGenerateRequest) => {\n\tconst response = await generateObjectiveJournalApiJournalsGenerateObjectivePost(\n\t\tinput,\n\t);\n\tconst data = unwrapApiData<JournalGenerateResponse>(response);\n\treturn data ?? { content: \"\" };\n};\n\nconst generateAiView = async (input: JournalGenerateRequest) => {\n\tconst response = await generateAiJournalApiJournalsGenerateAiPost(input);\n\tconst data = unwrapApiData<JournalGenerateResponse>(response);\n\treturn data ?? { content: \"\" };\n};\n\nexport function useJournalMutations() {\n\tconst queryClient = useQueryClient();\n\n\tconst createMutation = useMutation({\n\t\tmutationFn: createJournal,\n\t\tonSuccess: () => {\n\t\t\tqueryClient.invalidateQueries({ queryKey: queryKeys.journals.all });\n\t\t},\n\t});\n\n\tconst updateMutation = useMutation({\n\t\tmutationFn: ({ id, input }: { id: number; input: JournalUpdate }) =>\n\t\t\tupdateJournal(id, input),\n\t\tonSuccess: () => {\n\t\t\tqueryClient.invalidateQueries({ queryKey: queryKeys.journals.all });\n\t\t},\n\t});\n\n\tconst autoLinkMutation = useMutation({\n\t\tmutationFn: autoLinkJournal,\n\t\tonSuccess: () => {\n\t\t\tqueryClient.invalidateQueries({ queryKey: queryKeys.journals.all });\n\t\t},\n\t});\n\n\tconst objectiveMutation = useMutation({\n\t\tmutationFn: generateObjective,\n\t\tonSuccess: () => {\n\t\t\tqueryClient.invalidateQueries({ queryKey: queryKeys.journals.all });\n\t\t},\n\t});\n\n\tconst aiMutation = useMutation({\n\t\tmutationFn: generateAiView,\n\t\tonSuccess: () => {\n\t\t\tqueryClient.invalidateQueries({ queryKey: queryKeys.journals.all });\n\t\t},\n\t});\n\n\treturn {\n\t\tcreateJournal: createMutation.mutateAsync,\n\t\tupdateJournal: (id: number, input: JournalUpdate) =>\n\t\t\tupdateMutation.mutateAsync({ id, input }),\n\t\tautoLinkJournal: autoLinkMutation.mutateAsync,\n\t\tgenerateObjective: objectiveMutation.mutateAsync,\n\t\tgenerateAiView: aiMutation.mutateAsync,\n\t\tisCreating: createMutation.isPending,\n\t\tisUpdating: updateMutation.isPending,\n\t\tisAutoLinking: autoLinkMutation.isPending,\n\t\tisGeneratingObjective: objectiveMutation.isPending,\n\t\tisGeneratingAi: aiMutation.isPending,\n\t\tcreateError: createMutation.error,\n\t\tupdateError: updateMutation.error,\n\t};\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/query/keys.ts",
    "content": "/**\n * TanStack Query Keys 常量定义\n * 统一管理所有查询的缓存键，确保类型安全和一致性\n */\n\nexport const queryKeys = {\n\t/**\n\t * Todo 相关查询键\n\t */\n\ttodos: {\n\t\t/** 所有 todo 相关查询的根键 */\n\t\tall: [\"todos\"] as const,\n\t\t/** todo 列表查询 */\n\t\tlist: (params?: { status?: string; limit?: number; offset?: number }) =>\n\t\t\t[\"todos\", \"list\", params] as const,\n\t\t/** 单个 todo 详情 */\n\t\tdetail: (id: string) => [\"todos\", \"detail\", id] as const,\n\t},\n\n\t/**\n\t * Activity 相关查询键\n\t */\n\tactivities: {\n\t\t/** 所有 activity 相关查询的根键 */\n\t\tall: [\"activities\"] as const,\n\t\t/** activity 列表查询 */\n\t\tlist: (params?: {\n\t\t\tlimit?: number;\n\t\t\toffset?: number;\n\t\t\tstart_date?: string;\n\t\t\tend_date?: string;\n\t\t}) => [\"activities\", \"list\", params] as const,\n\t\t/** 单个 activity 的事件列表 */\n\t\tevents: (activityId: number) =>\n\t\t\t[\"activities\", activityId, \"events\"] as const,\n\t},\n\n\t/**\n\t * Event 相关查询键\n\t */\n\tevents: {\n\t\t/** 所有 event 相关查询的根键 */\n\t\tall: [\"events\"] as const,\n\t\t/** 单个 event 详情 */\n\t\tdetail: (id: number) => [\"events\", id] as const,\n\t\t/** event 列表查询 */\n\t\tlist: (params?: {\n\t\t\tlimit?: number;\n\t\t\toffset?: number;\n\t\t\tstart_date?: string;\n\t\t\tend_date?: string;\n\t\t\tapp_name?: string;\n\t\t}) => [\"events\", \"list\", params] as const,\n\t},\n\n\t/**\n\t * Cost 统计查询键\n\t */\n\tcostStats: (days: number) => [\"costStats\", days] as const,\n\n\t/**\n\t * Journal 相关查询键\n\t */\n\tjournals: {\n\t\t/** 所有 journal 相关查询的根键 */\n\t\tall: [\"journals\"] as const,\n\t\t/** journal 列表查询 */\n\t\tlist: (params?: {\n\t\t\tlimit?: number;\n\t\t\toffset?: number;\n\t\t\tstartDate?: string;\n\t\t\tendDate?: string;\n\t\t}) => [\"journals\", \"list\", params] as const,\n\t\t/** 单个 journal 详情 */\n\t\tdetail: (id: number) => [\"journals\", \"detail\", id] as const,\n\t},\n\n\t/**\n\t * 配置相关查询键\n\t */\n\tconfig: [\"config\"] as const,\n\n\t/**\n\t * Chat 历史记录查询键\n\t */\n\tchatHistory: {\n\t\t/** 所有 chat 相关查询的根键 */\n\t\tall: [\"chatHistory\"] as const,\n\t\t/** 会话列表 */\n\t\tsessions: (chatType?: string) =>\n\t\t\t[\"chatHistory\", \"sessions\", chatType] as const,\n\t\t/** 单个会话的消息历史 */\n\t\tsession: (sessionId: string) =>\n\t\t\t[\"chatHistory\", \"session\", sessionId] as const,\n\t},\n\n\t/**\n\t * 自动化任务查询键\n\t */\n\tautomationTasks: {\n\t\tall: [\"automationTasks\"] as const,\n\t\tlist: () => [\"automationTasks\", \"list\"] as const,\n\t},\n} as const;\n\n/**\n * 类型导出：用于类型推断\n */\nexport type QueryKeys = typeof queryKeys;\n"
  },
  {
    "path": "free-todo-frontend/lib/query/provider.tsx",
    "content": "\"use client\";\n\nimport { QueryClient, QueryClientProvider } from \"@tanstack/react-query\";\nimport { type ReactNode, useState } from \"react\";\n\n/**\n * 创建 QueryClient 实例的工厂函数\n * 使用工厂函数确保每个客户端都有独立的 QueryClient 实例\n */\nfunction makeQueryClient() {\n\treturn new QueryClient({\n\t\tdefaultOptions: {\n\t\t\tqueries: {\n\t\t\t\t// 30 秒内数据被认为是新鲜的，不会重新请求\n\t\t\t\tstaleTime: 30 * 1000,\n\t\t\t\t// 数据在缓存中保留 5 分钟\n\t\t\t\tgcTime: 5 * 60 * 1000,\n\t\t\t\t// 窗口聚焦时重新获取数据\n\t\t\t\trefetchOnWindowFocus: true,\n\t\t\t\t// 网络重连时重新获取数据\n\t\t\t\trefetchOnReconnect: true,\n\t\t\t\t// 组件挂载时如果数据过期则重新获取\n\t\t\t\trefetchOnMount: true,\n\t\t\t\t// 失败后重试 1 次\n\t\t\t\tretry: 1,\n\t\t\t\t// 重试延迟\n\t\t\t\tretryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),\n\t\t\t},\n\t\t\tmutations: {\n\t\t\t\t// mutation 失败后不自动重试\n\t\t\t\tretry: false,\n\t\t\t},\n\t\t},\n\t});\n}\n\n// 浏览器端的单例 QueryClient\nlet browserQueryClient: QueryClient | undefined;\n\n/**\n * 获取 QueryClient 实例\n * - 服务端：每次创建新实例\n * - 客户端：使用单例模式\n *\n * 导出此函数以便在非 React 代码中使用（如 Zustand stores）\n */\nexport function getQueryClient() {\n\tif (typeof window === \"undefined\") {\n\t\t// 服务端：总是创建新的 QueryClient\n\t\treturn makeQueryClient();\n\t}\n\t// 客户端：使用单例\n\tif (!browserQueryClient) {\n\t\tbrowserQueryClient = makeQueryClient();\n\t}\n\treturn browserQueryClient;\n}\n\ninterface QueryProviderProps {\n\tchildren: ReactNode;\n}\n\n/**\n * TanStack Query Provider 组件\n * 为整个应用提供 QueryClient 上下文\n */\nexport function QueryProvider({ children }: QueryProviderProps) {\n\t// 使用 useState 确保在 SSR 和 CSR 之间保持一致\n\tconst [queryClient] = useState(() => getQueryClient());\n\n\treturn (\n\t\t<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/query/todos.ts",
    "content": "\"use client\";\n\nimport { useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport {\n\tcreateTodoApiTodosPost,\n\tdeleteTodoApiTodosTodoIdDelete,\n\treorderTodosApiTodosReorderPost,\n\tupdateTodoApiTodosTodoIdPut,\n\tuseListTodosApiTodosGet,\n} from \"@/lib/generated/todos/todos\";\nimport type {\n\tCreateTodoInput,\n\tTodo,\n\tTodoListResponse,\n\tTodoPriority,\n\tTodoStatus,\n\tUpdateTodoInput,\n} from \"@/lib/types\";\nimport { queryKeys } from \"./keys\";\n\n// ============================================================================\n// Helper Functions\n// ============================================================================\n\nconst normalizePriority = (priority: unknown): TodoPriority => {\n\tif (priority === \"high\" || priority === \"medium\" || priority === \"low\") {\n\t\treturn priority;\n\t}\n\treturn \"none\";\n};\n\nconst normalizeStatus = (status: unknown): TodoStatus => {\n\tif (status === \"completed\" || status === \"canceled\" || status === \"draft\")\n\t\treturn status;\n\treturn \"active\";\n};\n\nfunction normalizeDateTimeValue(\n\tvalue?: string | null,\n): string | null | undefined {\n\t// undefined 表示不更新；null 表示显式清空\n\tif (value === undefined) return undefined;\n\tif (value === null) return null;\n\t// 兼容 <input type=\"date\"> 的 YYYY-MM-DD（后端期望 datetime）\n\tif (/^\\d{4}-\\d{2}-\\d{2}$/.test(value)) {\n\t\treturn `${value}T00:00:00`;\n\t}\n\treturn value;\n}\n\n/**\n * Normalize API response to ensure consistent Todo type\n * Now that fetcher auto-converts snake_case -> camelCase, we just need to normalize some optional fields\n */\nfunction normalizeTodo(raw: Record<string, unknown>): Todo {\n\treturn {\n\t\tid: raw.id as number,\n\t\tname: raw.name as string,\n\t\tsummary: (raw.summary as string) ?? undefined,\n\t\tdescription: (raw.description as string) ?? undefined,\n\t\tuserNotes: (raw.userNotes as string) ?? undefined,\n\t\tstatus: normalizeStatus(raw.status),\n\t\tpriority: normalizePriority(raw.priority),\n\t\titemType: (raw.itemType as string) ?? undefined,\n\t\tlocation: (raw.location as string) ?? undefined,\n\t\tcategories: (raw.categories as string) ?? undefined,\n\t\tclassification: (raw.classification as string) ?? undefined,\n\t\tdeadline: (raw.deadline as string) ?? undefined,\n\t\tstartTime: (raw.startTime as string) ?? undefined,\n\t\tendTime: (raw.endTime as string) ?? undefined,\n\t\tdtstart: (raw.dtstart as string) ?? undefined,\n\t\tdtend: (raw.dtend as string) ?? undefined,\n\t\tdue: (raw.due as string) ?? undefined,\n\t\tduration: (raw.duration as string) ?? undefined,\n\t\ttimeZone: (raw.timeZone as string) ?? undefined,\n\t\ttzid: (raw.tzid as string) ?? undefined,\n\t\tisAllDay: (raw.isAllDay as boolean) ?? undefined,\n\t\tdtstamp: (raw.dtstamp as string) ?? undefined,\n\t\tcreated: (raw.created as string) ?? undefined,\n\t\tlastModified: (raw.lastModified as string) ?? undefined,\n\t\tsequence: (raw.sequence as number) ?? undefined,\n\t\trdate: (raw.rdate as string) ?? undefined,\n\t\texdate: (raw.exdate as string) ?? undefined,\n\t\trecurrenceId: (raw.recurrenceId as string) ?? undefined,\n\t\trelatedToUid: (raw.relatedToUid as string) ?? undefined,\n\t\trelatedToReltype: (raw.relatedToReltype as string) ?? undefined,\n\t\ticalStatus: (raw.icalStatus as string) ?? undefined,\n\t\treminderOffsets: (raw.reminderOffsets as number[] | null) ?? undefined,\n\t\trrule: (raw.rrule as string | null) ?? undefined,\n\t\torder: (raw.order as number) ?? 0,\n\t\ttags: (raw.tags as string[]) ?? [],\n\t\tattachments: (raw.attachments as Todo[\"attachments\"]) ?? [],\n\t\tparentTodoId:\n\t\t\traw.parentTodoId === null || raw.parentTodoId === undefined\n\t\t\t\t? null\n\t\t\t\t: (raw.parentTodoId as number),\n\t\trelatedActivities: (raw.relatedActivities as number[]) ?? [],\n\t\tcompletedAt: (raw.completedAt as string) ?? undefined,\n\t\tpercentComplete: (raw.percentComplete as number) ?? undefined,\n\t\tcreatedAt: raw.createdAt as string,\n\t\tupdatedAt: raw.updatedAt as string,\n\t};\n}\n\n// ============================================================================\n// Query Hooks\n// ============================================================================\n\ninterface UseTodosParams {\n\tstatus?: string;\n\tlimit?: number;\n\toffset?: number;\n}\n\n/**\n * 获取 Todo 列表的 Query Hook\n * 使用 Orval 生成的 hook\n */\nexport function useTodos(params?: UseTodosParams) {\n\treturn useListTodosApiTodosGet(\n\t\t{\n\t\t\tlimit: params?.limit ?? 2000,\n\t\t\toffset: params?.offset ?? 0,\n\t\t\tstatus: params?.status,\n\t\t},\n\t\t{\n\t\t\tquery: {\n\t\t\t\tqueryKey: queryKeys.todos.list(params),\n\t\t\t\tstaleTime: 30 * 1000, // 30 秒内数据被认为是新鲜的\n\t\t\t\tselect: (data: unknown) => {\n\t\t\t\t\t// Data is now auto-converted to camelCase by the fetcher\n\t\t\t\t\tconst response = data as TodoListResponse;\n\t\t\t\t\tconst todos = response?.todos ?? [];\n\t\t\t\t\treturn todos.map((raw) =>\n\t\t\t\t\t\tnormalizeTodo(raw as unknown as Record<string, unknown>),\n\t\t\t\t\t);\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t);\n}\n\n// ============================================================================\n// Mutation Hooks\n// ============================================================================\n\n// 防抖更新相关的全局状态\nconst pendingUpdateTimers = new Map<number, ReturnType<typeof setTimeout>>();\nconst pendingUpdatePayloads = new Map<number, UpdateTodoInput>();\n\n/**\n * 创建 Todo 的 Mutation Hook\n */\nexport function useCreateTodo() {\n\tconst queryClient = useQueryClient();\n\n\treturn useMutation({\n\t\tmutationFn: async (input: CreateTodoInput) => {\n\t\t\t// Fetcher will auto-convert camelCase -> snake_case for request\n\t\t\t// and snake_case -> camelCase for response\n\t\t\tconst payload = {\n\t\t\t\tname: input.name,\n\t\t\t\tsummary: input.summary,\n\t\t\t\tdescription: input.description,\n\t\t\t\tuserNotes: input.userNotes,\n\t\t\t\tparentTodoId: input.parentTodoId ?? null,\n\t\t\t\titemType: input.itemType,\n\t\t\t\tlocation: input.location,\n\t\t\t\tcategories: input.categories,\n\t\t\t\tclassification: input.classification,\n\t\t\t\tdeadline: normalizeDateTimeValue(input.deadline),\n\t\t\t\tstartTime: normalizeDateTimeValue(input.startTime),\n\t\t\t\tendTime: normalizeDateTimeValue(input.endTime),\n\t\t\t\tdtstart: normalizeDateTimeValue(input.dtstart),\n\t\t\t\tdtend: normalizeDateTimeValue(input.dtend),\n\t\t\t\tdue: normalizeDateTimeValue(input.due),\n\t\t\t\tduration: input.duration,\n\t\t\t\ttimeZone: input.timeZone,\n\t\t\t\ttzid: input.tzid,\n\t\t\t\tisAllDay: input.isAllDay,\n\t\t\t\tdtstamp: normalizeDateTimeValue(input.dtstamp),\n\t\t\t\tcreated: normalizeDateTimeValue(input.created),\n\t\t\t\tlastModified: normalizeDateTimeValue(input.lastModified),\n\t\t\t\tsequence: input.sequence,\n\t\t\t\trdate: input.rdate,\n\t\t\t\texdate: input.exdate,\n\t\t\t\trecurrenceId: normalizeDateTimeValue(input.recurrenceId),\n\t\t\t\trelatedToUid: input.relatedToUid,\n\t\t\t\trelatedToReltype: input.relatedToReltype,\n\t\t\t\ticalStatus: input.icalStatus,\n\t\t\t\treminderOffsets: input.reminderOffsets,\n\t\t\t\trrule: input.rrule,\n\t\t\t\tstatus: input.status ?? \"active\",\n\t\t\t\tpriority: input.priority ?? \"none\",\n\t\t\t\tcompletedAt: normalizeDateTimeValue(input.completedAt),\n\t\t\t\tpercentComplete: input.percentComplete,\n\t\t\t\torder: input.order ?? 0,\n\t\t\t\ttags: input.tags ?? [],\n\t\t\t\trelatedActivities: input.relatedActivities ?? [],\n\t\t\t};\n\t\t\tconst created = await createTodoApiTodosPost(payload as never);\n\t\t\treturn normalizeTodo(created as unknown as Record<string, unknown>);\n\t\t},\n\t\tonSuccess: () => {\n\t\t\tqueryClient.invalidateQueries({ queryKey: queryKeys.todos.all });\n\t\t},\n\t});\n}\n\ninterface UpdateTodoParams {\n\tid: number;\n\tinput: UpdateTodoInput;\n}\n\n/**\n * 更新 Todo 的 Mutation Hook\n * 支持乐观更新和防抖（针对描述和备注字段）\n */\nexport function useUpdateTodo() {\n\tconst queryClient = useQueryClient();\n\n\treturn useMutation({\n\t\tmutationFn: async ({ id, input }: UpdateTodoParams) => {\n\t\t\tconst keys = Object.keys(input);\n\t\t\tconst shouldDebounce =\n\t\t\t\tkeys.length > 0 &&\n\t\t\t\tkeys.every((k) => k === \"description\" || k === \"userNotes\");\n\n\t\t\t// 合并同一 todo 的待发送 payload\n\t\t\tconst merged: UpdateTodoInput = {\n\t\t\t\t...(pendingUpdatePayloads.get(id) ?? {}),\n\t\t\t\t...input,\n\t\t\t};\n\t\t\tpendingUpdatePayloads.set(id, merged);\n\n\t\t\t// 如果需要防抖，返回一个 Promise 延迟执行\n\t\t\tif (shouldDebounce) {\n\t\t\t\treturn new Promise<Todo>((resolve, reject) => {\n\t\t\t\t\tconst existingTimer = pendingUpdateTimers.get(id);\n\t\t\t\t\tif (existingTimer) clearTimeout(existingTimer);\n\n\t\t\t\t\tconst timer = setTimeout(async () => {\n\t\t\t\t\t\tpendingUpdateTimers.delete(id);\n\t\t\t\t\t\tconst body = pendingUpdatePayloads.get(id);\n\t\t\t\t\t\tpendingUpdatePayloads.delete(id);\n\t\t\t\t\t\tif (!body || Object.keys(body).length === 0) {\n\t\t\t\t\t\t\tconst cachedData = queryClient.getQueryData<TodoListResponse>(\n\t\t\t\t\t\t\t\tqueryKeys.todos.list(),\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\tconst todos = cachedData?.todos ?? [];\n\t\t\t\t\t\t\tconst todo = todos.find((t) => t.id === id);\n\t\t\t\t\t\t\tif (todo) {\n\t\t\t\t\t\t\t\tresolve(todo);\n\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\treject(new Error(\"Todo not found\"));\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t// Build payload with normalized date/time inputs\n\t\t\t\t\t\t\tconst payload = {\n\t\t\t\t\t\t\t\t...body,\n\t\t\t\t\t\t\t\tdeadline: normalizeDateTimeValue(body.deadline),\n\t\t\t\t\t\t\t\tstartTime: normalizeDateTimeValue(body.startTime),\n\t\t\t\t\t\t\t\tendTime: normalizeDateTimeValue(body.endTime),\n\t\t\t\t\t\t\t\tdtstart: normalizeDateTimeValue(body.dtstart),\n\t\t\t\t\t\t\t\tdtend: normalizeDateTimeValue(body.dtend),\n\t\t\t\t\t\t\t\tdue: normalizeDateTimeValue(body.due),\n\t\t\t\t\t\t\t\tdtstamp: normalizeDateTimeValue(body.dtstamp),\n\t\t\t\t\t\t\t\tcreated: normalizeDateTimeValue(body.created),\n\t\t\t\t\t\t\t\tlastModified: normalizeDateTimeValue(body.lastModified),\n\t\t\t\t\t\t\t\trecurrenceId: normalizeDateTimeValue(body.recurrenceId),\n\t\t\t\t\t\t\t\tcompletedAt: normalizeDateTimeValue(body.completedAt),\n\t\t\t\t\t\t\t\trrule: body.rrule,\n\t\t\t\t\t\t\t};\n\t\t\t\t\t\t\tconst updated = await updateTodoApiTodosTodoIdPut(\n\t\t\t\t\t\t\t\tid,\n\t\t\t\t\t\t\t\tpayload as never,\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\tresolve(\n\t\t\t\t\t\t\t\tnormalizeTodo(updated as unknown as Record<string, unknown>),\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\t\treject(err);\n\t\t\t\t\t\t}\n\t\t\t\t\t}, 500);\n\t\t\t\t\tpendingUpdateTimers.set(id, timer);\n\t\t\t\t});\n\t\t\t}\n\n\t\t\t// 非防抖字段立即更新\n\t\t\tconst body = pendingUpdatePayloads.get(id);\n\t\t\tpendingUpdatePayloads.delete(id);\n\t\t\tif (!body || Object.keys(body).length === 0) {\n\t\t\t\tthrow new Error(\"No fields to update\");\n\t\t\t}\n\n\t\t\t// Build payload with normalized date/time inputs\n\t\t\tconst payload = {\n\t\t\t\t...body,\n\t\t\t\tdeadline: normalizeDateTimeValue(body.deadline),\n\t\t\t\tstartTime: normalizeDateTimeValue(body.startTime),\n\t\t\t\tendTime: normalizeDateTimeValue(body.endTime),\n\t\t\t\tdtstart: normalizeDateTimeValue(body.dtstart),\n\t\t\t\tdtend: normalizeDateTimeValue(body.dtend),\n\t\t\t\tdue: normalizeDateTimeValue(body.due),\n\t\t\t\tdtstamp: normalizeDateTimeValue(body.dtstamp),\n\t\t\t\tcreated: normalizeDateTimeValue(body.created),\n\t\t\t\tlastModified: normalizeDateTimeValue(body.lastModified),\n\t\t\t\trecurrenceId: normalizeDateTimeValue(body.recurrenceId),\n\t\t\t\tcompletedAt: normalizeDateTimeValue(body.completedAt),\n\t\t\t\trrule: body.rrule,\n\t\t\t};\n\t\t\tconst updated = await updateTodoApiTodosTodoIdPut(id, payload as never);\n\t\t\treturn normalizeTodo(updated as unknown as Record<string, unknown>);\n\t\t},\n\t\tonMutate: async ({ id, input }) => {\n\t\t\tawait queryClient.cancelQueries({ queryKey: queryKeys.todos.all });\n\n\t\t\tconst previousData = queryClient.getQueryData<TodoListResponse>(\n\t\t\t\tqueryKeys.todos.list(),\n\t\t\t);\n\n\t\t\t// 乐观更新\n\t\t\tqueryClient.setQueryData(\n\t\t\t\tqueryKeys.todos.list(),\n\t\t\t\t(old: TodoListResponse | undefined) => {\n\t\t\t\t\tif (!old || !old.todos) return old;\n\t\t\t\t\tconst updatedTodos = old.todos.map((todo) => {\n\t\t\t\t\t\tif (todo.id === id) {\n\t\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\t\t...todo,\n\t\t\t\t\t\t\t\t...input,\n\t\t\t\t\t\t\t\tpriority: normalizePriority(input.priority ?? todo.priority),\n\t\t\t\t\t\t\t\tstatus: normalizeStatus(input.status ?? todo.status),\n\t\t\t\t\t\t\t\tupdatedAt: new Date().toISOString(),\n\t\t\t\t\t\t\t};\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn todo;\n\t\t\t\t\t});\n\t\t\t\t\treturn { ...old, todos: updatedTodos };\n\t\t\t\t},\n\t\t\t);\n\n\t\t\treturn { previousData };\n\t\t},\n\t\tonError: (_err, _variables, context) => {\n\t\t\tif (context?.previousData) {\n\t\t\t\tqueryClient.setQueryData(queryKeys.todos.list(), context.previousData);\n\t\t\t}\n\t\t},\n\t\tonSettled: () => {\n\t\t\tqueryClient.invalidateQueries({ queryKey: queryKeys.todos.all });\n\t\t},\n\t});\n}\n\n/**\n * 删除 Todo 的 Mutation Hook\n */\nexport function useDeleteTodo() {\n\tconst queryClient = useQueryClient();\n\n\treturn useMutation({\n\t\tmutationFn: async (id: number) => {\n\t\t\tawait deleteTodoApiTodosTodoIdDelete(id);\n\t\t\treturn id;\n\t\t},\n\t\tonMutate: async (id) => {\n\t\t\tawait queryClient.cancelQueries({ queryKey: queryKeys.todos.all });\n\n\t\t\tconst previousData = queryClient.getQueryData<TodoListResponse>(\n\t\t\t\tqueryKeys.todos.list(),\n\t\t\t);\n\n\t\t\tconst previousTodos = previousData?.todos ?? [];\n\n\t\t\t// 递归查找所有子任务 ID\n\t\t\tconst findAllChildIds = (\n\t\t\t\tparentId: number,\n\t\t\t\tallTodos: Todo[],\n\t\t\t): number[] => {\n\t\t\t\tconst childIds: number[] = [];\n\t\t\t\tconst children = allTodos.filter((t) => t.parentTodoId === parentId);\n\t\t\t\tfor (const child of children) {\n\t\t\t\t\tchildIds.push(child.id);\n\t\t\t\t\tchildIds.push(...findAllChildIds(child.id, allTodos));\n\t\t\t\t}\n\t\t\t\treturn childIds;\n\t\t\t};\n\n\t\t\tconst allIdsToDelete = [id, ...findAllChildIds(id, previousTodos)];\n\t\t\tconst idsToDeleteSet = new Set(allIdsToDelete);\n\n\t\t\t// 乐观更新\n\t\t\tqueryClient.setQueryData(\n\t\t\t\tqueryKeys.todos.list(),\n\t\t\t\t(old: TodoListResponse | undefined) => {\n\t\t\t\t\tif (!old || !old.todos) return old;\n\t\t\t\t\tconst updatedTodos = old.todos.filter(\n\t\t\t\t\t\t(todo) => !idsToDeleteSet.has(todo.id),\n\t\t\t\t\t);\n\t\t\t\t\treturn {\n\t\t\t\t\t\t...old,\n\t\t\t\t\t\ttodos: updatedTodos,\n\t\t\t\t\t\ttotal: updatedTodos.length,\n\t\t\t\t\t};\n\t\t\t\t},\n\t\t\t);\n\n\t\t\treturn { previousData, deletedIds: allIdsToDelete };\n\t\t},\n\t\tonError: (_err, _id, context) => {\n\t\t\tif (context?.previousData) {\n\t\t\t\tqueryClient.setQueryData(queryKeys.todos.list(), context.previousData);\n\t\t\t}\n\t\t},\n\t\tonSettled: () => {\n\t\t\tqueryClient.invalidateQueries({ queryKey: queryKeys.todos.all });\n\t\t},\n\t});\n}\n\n/**\n * 切换 Todo 状态的 Mutation Hook\n */\nexport function useToggleTodoStatus() {\n\tconst queryClient = useQueryClient();\n\tconst updateMutation = useUpdateTodo();\n\n\treturn useMutation({\n\t\tmutationFn: async (id: number) => {\n\t\t\tconst cachedData = queryClient.getQueryData<TodoListResponse>(\n\t\t\t\tqueryKeys.todos.list(),\n\t\t\t);\n\t\t\tconst todos = cachedData?.todos ?? [];\n\t\t\tconst todo = todos.find((t) => t.id === id);\n\t\t\tif (!todo) throw new Error(\"Todo not found\");\n\n\t\t\tconst nextStatus: TodoStatus =\n\t\t\t\ttodo.status === \"completed\"\n\t\t\t\t\t? \"active\"\n\t\t\t\t\t: todo.status === \"canceled\"\n\t\t\t\t\t\t? \"canceled\"\n\t\t\t\t\t\t: todo.status === \"draft\"\n\t\t\t\t\t\t\t? \"active\"\n\t\t\t\t\t\t\t: \"completed\";\n\n\t\t\treturn updateMutation.mutateAsync({ id, input: { status: nextStatus } });\n\t\t},\n\t});\n}\n\n/**\n * 重排序参数\n */\nexport interface ReorderTodoItem {\n\tid: number;\n\torder: number;\n\tparentTodoId?: number | null;\n}\n\n/**\n * 批量重排序 Todo 的 Mutation Hook\n */\nexport function useReorderTodos() {\n\tconst queryClient = useQueryClient();\n\n\treturn useMutation({\n\t\tmutationFn: async (items: ReorderTodoItem[]) => {\n\t\t\t// Fetcher will auto-convert camelCase -> snake_case\n\t\t\treturn reorderTodosApiTodosReorderPost({ items } as never);\n\t\t},\n\t\tonMutate: async (items) => {\n\t\t\tawait queryClient.cancelQueries({ queryKey: queryKeys.todos.all });\n\n\t\t\tconst previousData = queryClient.getQueryData<TodoListResponse>(\n\t\t\t\tqueryKeys.todos.list(),\n\t\t\t);\n\n\t\t\t// 乐观更新\n\t\t\tqueryClient.setQueryData(\n\t\t\t\tqueryKeys.todos.list(),\n\t\t\t\t(old: TodoListResponse | undefined) => {\n\t\t\t\t\tif (!old || !old.todos) return old;\n\t\t\t\t\tconst updatedTodos = old.todos.map((todo) => {\n\t\t\t\t\t\tconst item = items.find((i) => i.id === todo.id);\n\t\t\t\t\t\tif (item) {\n\t\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\t\t...todo,\n\t\t\t\t\t\t\t\torder: item.order,\n\t\t\t\t\t\t\t\t...(item.parentTodoId !== undefined\n\t\t\t\t\t\t\t\t\t? { parentTodoId: item.parentTodoId }\n\t\t\t\t\t\t\t\t\t: {}),\n\t\t\t\t\t\t\t\tupdatedAt: new Date().toISOString(),\n\t\t\t\t\t\t\t};\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn todo;\n\t\t\t\t\t});\n\t\t\t\t\treturn { ...old, todos: updatedTodos };\n\t\t\t\t},\n\t\t\t);\n\n\t\t\treturn { previousData };\n\t\t},\n\t\tonError: (_err, _variables, context) => {\n\t\t\tif (context?.previousData) {\n\t\t\t\tqueryClient.setQueryData(queryKeys.todos.list(), context.previousData);\n\t\t\t}\n\t\t},\n\t\tonSettled: () => {\n\t\t\tqueryClient.invalidateQueries({ queryKey: queryKeys.todos.all });\n\t\t},\n\t});\n}\n\n// ============================================================================\n// 组合 Hook：提供完整的 Todo 操作能力\n// ============================================================================\n\n/**\n * 提供所有 Todo Mutation 操作的组合 Hook\n */\nexport function useTodoMutations() {\n\tconst createMutation = useCreateTodo();\n\tconst updateMutation = useUpdateTodo();\n\tconst deleteMutation = useDeleteTodo();\n\tconst toggleStatusMutation = useToggleTodoStatus();\n\tconst reorderMutation = useReorderTodos();\n\n\treturn {\n\t\tcreateTodo: createMutation.mutateAsync,\n\t\tupdateTodo: (id: number, input: UpdateTodoInput) =>\n\t\t\tupdateMutation.mutateAsync({ id, input }),\n\t\tdeleteTodo: deleteMutation.mutateAsync,\n\t\ttoggleTodoStatus: toggleStatusMutation.mutateAsync,\n\t\treorderTodos: reorderMutation.mutateAsync,\n\t\tisCreating: createMutation.isPending,\n\t\tisUpdating: updateMutation.isPending,\n\t\tisDeleting: deleteMutation.isPending,\n\t\tisReordering: reorderMutation.isPending,\n\t\tcreateError: createMutation.error,\n\t\tupdateError: updateMutation.error,\n\t\tdeleteError: deleteMutation.error,\n\t\treorderError: reorderMutation.error,\n\t};\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/reminders.ts",
    "content": "export const REMINDER_PRESET_MINUTES = [0, 5, 10, 30, 60, 1440];\n\nexport type ReminderUnit = \"minutes\" | \"hours\" | \"days\";\n\nexport const sanitizeReminderOffsets = (value: number[]): number[] => {\n\tconst cleaned = value\n\t\t.map((item) => Number(item))\n\t\t.filter((item) => Number.isFinite(item) && item >= 0);\n\treturn Array.from(new Set(cleaned)).sort((a, b) => a - b);\n};\n\nexport const normalizeReminderOffsets = (\n\tvalue: number[] | null | undefined,\n\tfallback: number[] = [],\n): number[] => {\n\tif (value === null || value === undefined) {\n\t\treturn [...fallback];\n\t}\n\treturn sanitizeReminderOffsets(value);\n};\n\nexport const formatReminderOffset = (\n\tt: (key: string, values?: Record<string, string | number | Date>) => string,\n\tminutes: number,\n): string => {\n\tif (minutes === 0) return t(\"atTime\");\n\tif (minutes < 60) return t(\"minutesBefore\", { count: minutes });\n\tif (minutes % 1440 === 0) {\n\t\treturn t(\"daysBefore\", { count: minutes / 1440 });\n\t}\n\tif (minutes % 60 === 0) {\n\t\treturn t(\"hoursBefore\", { count: minutes / 60 });\n\t}\n\treturn t(\"minutesBefore\", { count: minutes });\n};\n\nexport const formatReminderSummary = (\n\tt: (key: string, values?: Record<string, string | number | Date>) => string,\n\toffsets: number[],\n\temptyLabel: string,\n): string => {\n\tif (!offsets.length) return emptyLabel;\n\treturn offsets.map((offset) => formatReminderOffset(t, offset)).join(\", \");\n};\n"
  },
  {
    "path": "free-todo-frontend/lib/services/notification-poller.ts",
    "content": "import { unwrapApiData } from \"@/lib/api/fetcher\";\nimport { getNotificationApiNotificationsGet } from \"@/lib/generated/notifications/notifications\";\nimport { listTodosApiTodosGet } from \"@/lib/generated/todos/todos\";\nimport type {\n\tNotification,\n\tPollingEndpoint,\n} from \"@/lib/store/notification-store\";\nimport { useNotificationStore } from \"@/lib/store/notification-store\";\nimport type { TodoListResponse } from \"@/lib/types\";\n\n// 通知响应类型（后端 OpenAPI 未定义响应 schema，手动定义）\ninterface NotificationResponse {\n\tid?: string;\n\ttitle: string;\n\tcontent: string;\n\ttimestamp?: string;\n\ttodoId?: number;\n}\n\nclass NotificationPoller {\n\tprivate timers: Map<string, NodeJS.Timeout> = new Map();\n\tprivate isPageVisible: boolean = true;\n\n\tconstructor() {\n\t\t// 监听页面可见性变化\n\t\tif (typeof document !== \"undefined\") {\n\t\t\tdocument.addEventListener(\"visibilitychange\", () => {\n\t\t\t\tthis.isPageVisible = !document.hidden;\n\t\t\t\tif (this.isPageVisible) {\n\t\t\t\t\t// 页面可见时恢复所有轮询\n\t\t\t\t\tthis.resumeAll();\n\t\t\t\t} else {\n\t\t\t\t\t// 页面隐藏时暂停所有轮询\n\t\t\t\t\tthis.pauseAll();\n\t\t\t\t}\n\t\t\t});\n\t\t}\n\t}\n\n\t/**\n\t * 注册并启动轮询端点\n\t */\n\tregisterEndpoint(endpoint: PollingEndpoint): void {\n\t\t// 如果已存在，先停止旧的\n\t\tthis.unregisterEndpoint(endpoint.id);\n\n\t\tif (!endpoint.enabled) {\n\t\t\treturn;\n\t\t}\n\n\t\t// 立即执行一次\n\t\tthis.pollEndpoint(endpoint);\n\n\t\t// 设置定时器\n\t\tconst timer = setInterval(() => {\n\t\t\tif (this.isPageVisible) {\n\t\t\t\tthis.pollEndpoint(endpoint);\n\t\t\t}\n\t\t}, endpoint.interval);\n\n\t\tthis.timers.set(endpoint.id, timer);\n\t}\n\n\t/**\n\t * 注销并停止轮询端点\n\t */\n\tunregisterEndpoint(id: string): void {\n\t\tconst timer = this.timers.get(id);\n\t\tif (timer) {\n\t\t\tclearInterval(timer);\n\t\t\tthis.timers.delete(id);\n\t\t}\n\t}\n\n\t/**\n\t * 轮询单个端点\n\t */\n\tprivate async pollEndpoint(endpoint: PollingEndpoint): Promise<void> {\n\t\ttry {\n\t\t\t// 检查是否是 draft todo 端点\n\t\t\tif (\n\t\t\t\tendpoint.url.includes(\"/api/todos\") &&\n\t\t\t\tendpoint.url.includes(\"status=draft\")\n\t\t\t) {\n\t\t\t\tawait this.pollDraftTodos(endpoint);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// 标准通知端点 - 使用 Orval 生成的 API\n\t\t\tconst response = await getNotificationApiNotificationsGet();\n\t\t\tconst notificationData = unwrapApiData<\n\t\t\t\tNotificationResponse[] | NotificationResponse | null\n\t\t\t>(response);\n\n\t\t\tconst rawList = Array.isArray(notificationData)\n\t\t\t\t? notificationData\n\t\t\t\t: notificationData\n\t\t\t\t\t? [notificationData]\n\t\t\t\t\t: [];\n\n\t\t\tconst notifications: Notification[] = rawList\n\t\t\t\t.filter((item) => item && (item.title || item.content))\n\t\t\t\t.map((item, index) => ({\n\t\t\t\t\tid:\n\t\t\t\t\t\titem.id ||\n\t\t\t\t\t\t`${endpoint.id}-${item.timestamp || Date.now()}-${index}`,\n\t\t\t\t\ttitle: item.title,\n\t\t\t\t\tcontent: item.content,\n\t\t\t\t\ttimestamp: item.timestamp || new Date().toISOString(),\n\t\t\t\t\tsource: endpoint.id,\n\t\t\t\t\ttodoId: item.todoId,\n\t\t\t\t}));\n\n\t\t\tconst store = useNotificationStore.getState();\n\t\t\tstore.setNotificationsFromSource(endpoint.id, notifications);\n\t\t} catch (error) {\n\t\t\t// 静默处理错误，避免频繁失败请求\n\t\t\tconsole.warn(`Failed to poll endpoint ${endpoint.id}:`, error);\n\t\t}\n\t}\n\n\t/**\n\t * 轮询 draft todo 端点\n\t */\n\tprivate async pollDraftTodos(endpoint: PollingEndpoint): Promise<void> {\n\t\ttry {\n\t\t\t// 解析 URL 参数\n\t\t\tlet limit = 1;\n\t\t\ttry {\n\t\t\t\tconst urlStr = endpoint.url.startsWith(\"/\")\n\t\t\t\t\t? `http://localhost${endpoint.url}`\n\t\t\t\t\t: endpoint.url;\n\t\t\t\tconst url = new URL(urlStr);\n\t\t\t\tconst limitParam = url.searchParams.get(\"limit\");\n\t\t\t\tif (limitParam) {\n\t\t\t\t\tlimit = parseInt(limitParam, 10) || 1;\n\t\t\t\t}\n\t\t\t} catch {\n\t\t\t\t// URL解析失败，使用默认值\n\t\t\t\tlimit = 1;\n\t\t\t}\n\n\t\t\t// 获取 draft todos - 使用 Orval 生成的 API\n\t\t\tconst result = await listTodosApiTodosGet({\n\t\t\t\tstatus: \"draft\",\n\t\t\t\tlimit,\n\t\t\t\toffset: 0,\n\t\t\t});\n\t\t\tconst data = unwrapApiData<TodoListResponse>(result);\n\t\t\tconst todos = data?.todos ?? [];\n\n\t\t\tconst store = useNotificationStore.getState();\n\t\t\tconst current = store.notifications.find(\n\t\t\t\t(notification) => notification.source === endpoint.id,\n\t\t\t);\n\n\t\t\t// 如果当前有 draft todo 通知，检查对应的 todo 是否还存在\n\t\t\tif (\n\t\t\t\tcurrent &&\n\t\t\t\tcurrent.source === endpoint.id &&\n\t\t\t\tcurrent.todoId !== undefined\n\t\t\t) {\n\t\t\t\tconst todoExists = todos.some((todo) => todo.id === current.todoId);\n\t\t\t\t// 如果 todo 不存在了，清除通知\n\t\t\t\tif (!todoExists) {\n\t\t\t\t\tstore.removeNotificationsBySource(endpoint.id);\n\t\t\t\t\tstore.setExpanded(false);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (todos.length > 0) {\n\t\t\t\t// 取最新的一个 todo\n\t\t\t\tconst latestTodo = todos[0];\n\n\t\t\t\t// 转换为通知格式\n\t\t\t\tconst notification: Notification = {\n\t\t\t\t\tid: `draft-todo-${latestTodo.id}`,\n\t\t\t\t\ttitle: \"新待办事项待确认\",\n\t\t\t\t\tcontent: latestTodo.name || \"待办事项\",\n\t\t\t\t\ttimestamp: latestTodo.createdAt || new Date().toISOString(),\n\t\t\t\t\tsource: endpoint.id,\n\t\t\t\t\ttodoId: latestTodo.id, // 添加 todoId 以便后续操作\n\t\t\t\t};\n\t\t\t\tstore.setNotificationsFromSource(endpoint.id, [notification]);\n\t\t\t} else {\n\t\t\t\t// 如果没有 draft todos 了，且当前通知是来自这个端点的，清除通知\n\t\t\t\tstore.removeNotificationsBySource(endpoint.id);\n\t\t\t\tstore.setExpanded(false);\n\t\t\t}\n\t\t} catch (error) {\n\t\t\t// 静默处理错误，避免频繁失败请求\n\t\t\tconsole.warn(`Failed to poll draft todos from ${endpoint.id}:`, error);\n\t\t}\n\t}\n\n\t/**\n\t * 暂停所有轮询\n\t */\n\tprivate pauseAll(): void {\n\t\t// 定时器继续运行，但 pollEndpoint 会检查 isPageVisible\n\t\t// 这样页面重新可见时可以立即恢复\n\t}\n\n\t/**\n\t * 恢复所有轮询\n\t */\n\tprivate resumeAll(): void {\n\t\t// 立即执行一次所有端点的轮询\n\t\tconst store = useNotificationStore.getState();\n\t\tconst endpoints = store.getAllEndpoints();\n\t\tfor (const endpoint of endpoints) {\n\t\t\tif (endpoint.enabled) {\n\t\t\t\tthis.pollEndpoint(endpoint);\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * 清理所有轮询定时器\n\t */\n\tcleanup(): void {\n\t\tfor (const timer of this.timers.values()) {\n\t\t\tclearInterval(timer);\n\t\t}\n\t\tthis.timers.clear();\n\t}\n\n\t/**\n\t * 更新端点配置\n\t */\n\tupdateEndpoint(endpoint: PollingEndpoint): void {\n\t\tthis.unregisterEndpoint(endpoint.id);\n\t\tif (endpoint.enabled) {\n\t\t\tthis.registerEndpoint(endpoint);\n\t\t}\n\t}\n}\n\n// 单例实例\nlet pollerInstance: NotificationPoller | null = null;\n\nexport function getNotificationPoller(): NotificationPoller {\n\tif (!pollerInstance) {\n\t\tpollerInstance = new NotificationPoller();\n\t}\n\treturn pollerInstance;\n}\n\n// 清理函数（用于组件卸载时）\nexport function cleanupNotificationPoller(): void {\n\tif (pollerInstance) {\n\t\tpollerInstance.cleanup();\n\t\tpollerInstance = null;\n\t}\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/store/activity-store.ts",
    "content": "import { create } from \"zustand\";\nimport { createJSONStorage, persist } from \"zustand/middleware\";\n\ninterface ActivityStoreState {\n\tselectedActivityId: number | null;\n\tsearch: string;\n\tsetSelectedActivityId: (id: number | null) => void;\n\tsetSearch: (search: string) => void;\n}\n\nexport const useActivityStore = create<ActivityStoreState>()(\n\tpersist(\n\t\t(set) => ({\n\t\t\tselectedActivityId: null,\n\t\t\tsearch: \"\",\n\t\t\tsetSelectedActivityId: (id) => set({ selectedActivityId: id }),\n\t\t\tsetSearch: (search) => set({ search }),\n\t\t}),\n\t\t{\n\t\t\tname: \"activity-config\",\n\t\t\tstorage: createJSONStorage(() => {\n\t\t\t\treturn {\n\t\t\t\t\tgetItem: (name: string): string | null => {\n\t\t\t\t\t\tif (typeof window === \"undefined\") return null;\n\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tconst stored = localStorage.getItem(name);\n\t\t\t\t\t\t\tif (!stored) return null;\n\n\t\t\t\t\t\t\tconst parsed = JSON.parse(stored);\n\t\t\t\t\t\t\tconst state = parsed.state || parsed;\n\n\t\t\t\t\t\t\t// 验证 selectedActivityId\n\t\t\t\t\t\t\tlet selectedActivityId: number | null = null;\n\t\t\t\t\t\t\tif (\n\t\t\t\t\t\t\t\tstate.selectedActivityId !== null &&\n\t\t\t\t\t\t\t\tstate.selectedActivityId !== undefined\n\t\t\t\t\t\t\t) {\n\t\t\t\t\t\t\t\tconst id =\n\t\t\t\t\t\t\t\t\ttypeof state.selectedActivityId === \"number\"\n\t\t\t\t\t\t\t\t\t\t? state.selectedActivityId\n\t\t\t\t\t\t\t\t\t\t: Number.parseInt(String(state.selectedActivityId), 10);\n\t\t\t\t\t\t\t\tif (!Number.isNaN(id) && Number.isFinite(id) && id > 0) {\n\t\t\t\t\t\t\t\t\tselectedActivityId = id;\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t// 验证 search\n\t\t\t\t\t\t\tconst search: string =\n\t\t\t\t\t\t\t\ttypeof state.search === \"string\" ? state.search : \"\";\n\n\t\t\t\t\t\t\treturn JSON.stringify({\n\t\t\t\t\t\t\t\tstate: {\n\t\t\t\t\t\t\t\t\tselectedActivityId,\n\t\t\t\t\t\t\t\t\tsearch,\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t} catch (e) {\n\t\t\t\t\t\t\tconsole.error(\"Error loading activity config:\", e);\n\t\t\t\t\t\t\treturn null;\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\tsetItem: (name: string, value: string): void => {\n\t\t\t\t\t\tif (typeof window === \"undefined\") return;\n\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tlocalStorage.setItem(name, value);\n\t\t\t\t\t\t} catch (e) {\n\t\t\t\t\t\t\tconsole.error(\"Error saving activity config:\", e);\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\tremoveItem: (name: string): void => {\n\t\t\t\t\t\tif (typeof window === \"undefined\") return;\n\t\t\t\t\t\tlocalStorage.removeItem(name);\n\t\t\t\t\t},\n\t\t\t\t};\n\t\t\t}),\n\t\t},\n\t),\n);\n"
  },
  {
    "path": "free-todo-frontend/lib/store/audio-recording-store.ts",
    "content": "/**\n * 全局音频录音状态管理\n *\n * 将录音状态和资源提升到全局层面，使录音在面板切换时不会中断。\n * 核心思路：\n * - 使用模块级变量存储不可序列化的资源（WebSocket、AudioContext、MediaStream）\n * - 使用 Zustand store 存储可序列化的状态（isRecording、transcriptionText 等）\n * - 组件卸载时不清理录音资源，只有显式调用 stopRecording 才会停止\n */\n\nimport { create } from \"zustand\";\n\n// ========== 类型定义 ==========\n\ninterface TodoItem {\n\ttitle: string;\n\tdescription?: string;\n\tdeadline?: string;\n\tsource_text?: string;\n}\n\ninterface ScheduleItem {\n\ttitle: string;\n\ttime?: string;\n\tdescription?: string;\n\tsource_text?: string;\n}\n\ntype TranscriptionCallback = (text: string, isFinal: boolean) => void\n\ntype RealtimeNlpCallback = (data: {\n\t\toptimizedText?: string;\n\t\ttodos?: TodoItem[];\n\t\tschedules?: ScheduleItem[];\n\t}) => void\n\ntype ErrorCallback = (error: Error) => void\n\ninterface AudioRecordingState {\n\t/** 是否正在录音 */\n\tisRecording: boolean;\n\t/** 录音开始时间（毫秒时间戳） */\n\trecordingStartedAt: number | null;\n\t/** 录音开始的 Date 对象（用于时间标签） */\n\trecordingStartedDate: Date | null;\n\t/** 上一个 final 文本的时间戳（用于计算段落时间） */\n\tlastFinalEndMs: number | null;\n\n\t// ===== 转录数据（在面板切换时保持） =====\n\t/** 原始转录文本 */\n\ttranscriptionText: string;\n\t/** 正在识别的部分文本（未确认） */\n\tpartialText: string;\n\t/** 优化后的文本 */\n\toptimizedText: string;\n\t/** 段落时间（秒） */\n\tsegmentTimesSec: number[];\n\t/** 段落时间标签 */\n\tsegmentTimeLabels: string[];\n\t/** 段落录音 ID */\n\tsegmentRecordingIds: number[];\n\t/** 段落偏移（秒） */\n\tsegmentOffsetsSec: number[];\n\t/** 实时提取的待办 */\n\tliveTodos: TodoItem[];\n\t/** 实时提取的日程 */\n\tliveSchedules: ScheduleItem[];\n}\n\ninterface AudioRecordingActions {\n\t/** 开始录音 */\n\tstartRecording: (\n\t\tonTranscription: TranscriptionCallback,\n\t\tonRealtimeNlp?: RealtimeNlpCallback,\n\t\tonError?: ErrorCallback,\n\t\tis24x7?: boolean,\n\t) => Promise<void>;\n\t/** 停止录音 */\n\tstopRecording: (segmentTimestamps?: number[]) => void;\n\t/** 重置时间戳引用（用于新段落） */\n\tresetLastFinalEnd: () => void;\n\t/** 更新 lastFinalEndMs */\n\tupdateLastFinalEnd: (ms: number) => void;\n\n\t// ===== 转录数据更新方法 =====\n\t/** 追加转录文本 */\n\tappendTranscriptionText: (text: string) => void;\n\t/** 设置部分文本 */\n\tsetPartialText: (text: string) => void;\n\t/** 设置优化文本 */\n\tsetOptimizedText: (text: string) => void;\n\t/** 追加段落数据 */\n\tappendSegmentData: (data: {\n\t\ttimeSec: number;\n\t\ttimeLabel: string;\n\t\trecordingId: number;\n\t\toffsetSec: number;\n\t}) => void;\n\t/** 设置实时待办 */\n\tsetLiveTodos: (todos: TodoItem[]) => void;\n\t/** 设置实时日程 */\n\tsetLiveSchedules: (schedules: ScheduleItem[]) => void;\n\t/** 清空录音会话数据（开始新录音时调用） */\n\tclearSessionData: () => void;\n}\n\ntype AudioRecordingStore = AudioRecordingState & AudioRecordingActions;\n\n// ========== 模块级资源存储（不可序列化） ==========\n\nlet wsRef: WebSocket | null = null;\nlet audioContextRef: AudioContext | null = null;\nlet processorRef: ScriptProcessorNode | null = null;\nlet mediaStreamRef: MediaStream | null = null;\n\n// 回调函数引用（用于在 WebSocket 消息中调用）\nlet currentOnTranscription: TranscriptionCallback | null = null;\nlet currentOnRealtimeNlp: RealtimeNlpCallback | null = null;\nlet currentOnError: ErrorCallback | null = null;\n\n// ========== 7×24 自动重连相关变量 ==========\nlet reconnectTimeoutRef: ReturnType<typeof setTimeout> | null = null;\nlet reconnectAttemptsRef = 0;\nconst maxReconnectAttempts = 5;\nconst reconnectDelayMs = 3000; // 3秒后重连\nlet shouldReconnectRef = false; // 标记是否应该重连\nlet currentIs24x7 = false; // 当前是否为 7×24 模式\n\n// ========== 内部辅助函数 ==========\n\n/**\n * 获取 API 基础 URL\n */\nfunction getApiBaseUrl(): string {\n\treturn (\n\t\tprocess.env.NEXT_PUBLIC_API_URL ||\n\t\t(typeof window !== \"undefined\" &&\n\t\t\t(window as Window & { __BACKEND_URL__?: string }).__BACKEND_URL__) ||\n\t\t\"http://localhost:8100\"\n\t);\n}\n\n/**\n * 清理录音资源\n * @param segmentTimestamps 段落时间戳数组\n * @param isReconnecting 是否正在重连（重连时不清理回调）\n */\nfunction cleanupRecordingResources(segmentTimestamps?: number[], isReconnecting = false): void {\n\t// 停止 WebAudio\n\tif (processorRef) {\n\t\ttry {\n\t\t\tprocessorRef.disconnect();\n\t\t} catch {\n\t\t\t// ignore\n\t\t}\n\t\tprocessorRef.onaudioprocess = null;\n\t\tprocessorRef = null;\n\t}\n\tif (audioContextRef) {\n\t\ttry {\n\t\t\taudioContextRef.close();\n\t\t} catch {\n\t\t\t// ignore\n\t\t}\n\t\taudioContextRef = null;\n\t}\n\tif (mediaStreamRef) {\n\t\tfor (const track of mediaStreamRef.getTracks()) {\n\t\t\ttrack.stop();\n\t\t}\n\t\tmediaStreamRef = null;\n\t}\n\tif (wsRef) {\n\t\t// 发送停止消息，包含时间戳数组（如果提供）\n\t\tconst stopMessage: { type: string; segment_timestamps?: number[] } = {\n\t\t\ttype: \"stop\",\n\t\t};\n\t\tif (segmentTimestamps && segmentTimestamps.length > 0) {\n\t\t\tstopMessage.segment_timestamps = segmentTimestamps;\n\t\t}\n\t\ttry {\n\t\t\twsRef.send(JSON.stringify(stopMessage));\n\t\t\twsRef.close();\n\t\t} catch {\n\t\t\t// ignore\n\t\t}\n\t\twsRef = null;\n\t}\n\n\t// 如果不是重连，清理回调引用和重连状态\n\tif (!isReconnecting) {\n\t\tcurrentOnTranscription = null;\n\t\tcurrentOnRealtimeNlp = null;\n\t\tcurrentOnError = null;\n\t\t// 停止自动重连\n\t\tshouldReconnectRef = false;\n\t\tif (reconnectTimeoutRef) {\n\t\t\tclearTimeout(reconnectTimeoutRef);\n\t\t\treconnectTimeoutRef = null;\n\t\t}\n\t\treconnectAttemptsRef = 0;\n\t\tcurrentIs24x7 = false;\n\t}\n}\n\n// ========== Zustand Store ==========\n\nexport const useAudioRecordingStore = create<AudioRecordingStore>((set, get) => ({\n\t// ===== 核心状态 =====\n\tisRecording: false,\n\trecordingStartedAt: null,\n\trecordingStartedDate: null,\n\tlastFinalEndMs: null,\n\n\t// ===== 转录数据 =====\n\ttranscriptionText: \"\",\n\tpartialText: \"\",\n\toptimizedText: \"\",\n\tsegmentTimesSec: [],\n\tsegmentTimeLabels: [],\n\tsegmentRecordingIds: [],\n\tsegmentOffsetsSec: [],\n\tliveTodos: [],\n\tliveSchedules: [],\n\n\t// ===== Actions =====\n\n\tstartRecording: async (onTranscription, onRealtimeNlp, onError, is24x7 = false) => {\n\t\t// 如果已经在录音，直接返回\n\t\tif (get().isRecording) {\n\t\t\tconsole.warn(\"[AudioRecordingStore] Already recording, ignoring start request\");\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\t// 设置 7×24 模式标志\n\t\t\tcurrentIs24x7 = is24x7;\n\t\t\tshouldReconnectRef = is24x7; // 7×24 模式启用自动重连\n\n\t\t\t// 如果是重连成功，重置重连计数\n\t\t\tif (reconnectAttemptsRef > 0) {\n\t\t\t\treconnectAttemptsRef = 0;\n\t\t\t\tconsole.log(\"[AudioRecordingStore] WebSocket 重连成功\");\n\t\t\t}\n\n\t\t\t// 获取麦克风权限\n\t\t\tconsole.log(\"[AudioRecordingStore] 请求麦克风权限...\");\n\t\t\tconst stream = await navigator.mediaDevices.getUserMedia({ audio: true });\n\t\t\tconsole.log(\"[AudioRecordingStore] ✅ 麦克风权限已获取\");\n\t\t\tmediaStreamRef = stream;\n\n\t\t\t// 保存回调引用\n\t\t\tcurrentOnTranscription = onTranscription;\n\t\t\tcurrentOnRealtimeNlp = onRealtimeNlp || null;\n\t\t\tcurrentOnError = onError || null;\n\n\t\t\t// 连接到后端 WebSocket\n\t\t\tconst apiBaseUrl = getApiBaseUrl();\n\t\t\tconst wsUrl = apiBaseUrl.replace(\"http://\", \"ws://\").replace(\"https://\", \"wss://\");\n\t\t\tconst wsEndpoint = `${wsUrl}/api/audio/transcribe`;\n\t\t\tconst ws = new WebSocket(wsEndpoint);\n\t\t\tws.binaryType = \"arraybuffer\";\n\n\t\t\tws.onopen = () => {\n\t\t\t\t// 发送初始化消息\n\t\t\t\tws.send(JSON.stringify({ is_24x7: is24x7 }));\n\n\t\t\t\t// 使用 WebAudio 直接发送 PCM16(16k) 到后端\n\t\t\t\ttype AudioContextCtor = typeof AudioContext & {\n\t\t\t\t\twebkitAudioContext?: typeof AudioContext;\n\t\t\t\t};\n\t\t\t\tconst AudioCtx = (window.AudioContext ||\n\t\t\t\t\t(window as unknown as { webkitAudioContext?: typeof AudioContext })\n\t\t\t\t\t\t.webkitAudioContext) as AudioContextCtor;\n\t\t\t\tconst audioContext = new AudioCtx({ sampleRate: 16000 });\n\t\t\t\taudioContextRef = audioContext;\n\n\t\t\t\tconst source = audioContext.createMediaStreamSource(stream);\n\t\t\t\tconst processor = audioContext.createScriptProcessor(4096, 1, 1);\n\t\t\t\tprocessorRef = processor;\n\n\t\t\t\tprocessor.onaudioprocess = (e) => {\n\t\t\t\t\tif (ws.readyState !== WebSocket.OPEN) return;\n\t\t\t\t\tconst input = e.inputBuffer.getChannelData(0); // Float32 [-1, 1]\n\t\t\t\t\t// 转 Int16 little-endian\n\t\t\t\t\tconst buffer = new ArrayBuffer(input.length * 2);\n\t\t\t\t\tconst view = new DataView(buffer);\n\t\t\t\t\tfor (let i = 0; i < input.length; i++) {\n\t\t\t\t\t\tconst s = Math.max(-1, Math.min(1, input[i]));\n\t\t\t\t\t\tview.setInt16(i * 2, s < 0 ? s * 0x8000 : s * 0x7fff, true);\n\t\t\t\t\t}\n\t\t\t\t\tws.send(buffer);\n\t\t\t\t};\n\n\t\t\t\tsource.connect(processor);\n\t\t\t\tprocessor.connect(audioContext.destination);\n\n\t\t\t\t// 记录开始时间并更新状态\n\t\t\t\tconst now = Date.now();\n\t\t\t\tset({\n\t\t\t\t\tisRecording: true,\n\t\t\t\t\trecordingStartedAt: now,\n\t\t\t\t\trecordingStartedDate: new Date(),\n\t\t\t\t\tlastFinalEndMs: null,\n\t\t\t\t});\n\t\t\t};\n\n\t\t\tws.onmessage = (event) => {\n\t\t\t\ttry {\n\t\t\t\t\tif (typeof event.data === \"string\") {\n\t\t\t\t\t\tconst data = JSON.parse(event.data);\n\n\t\t\t\t\t\t// 转录结果\n\t\t\t\t\t\tif (data.header?.name === \"TranscriptionResultChanged\") {\n\t\t\t\t\t\t\tconst text = data.payload?.result;\n\t\t\t\t\t\t\tconst isFinal = data.payload?.is_final || false;\n\t\t\t\t\t\t\tif (text && currentOnTranscription) {\n\t\t\t\t\t\t\t\tcurrentOnTranscription(text, isFinal);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// 实时优化文本\n\t\t\t\t\t\tif (data.header?.name === \"OptimizedTextChanged\") {\n\t\t\t\t\t\t\tconst text = data.payload?.text;\n\t\t\t\t\t\t\tif (currentOnRealtimeNlp && typeof text === \"string\") {\n\t\t\t\t\t\t\t\tcurrentOnRealtimeNlp({ optimizedText: text });\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// 实时提取结果\n\t\t\t\t\t\tif (data.header?.name === \"ExtractionChanged\") {\n\t\t\t\t\t\t\tconst todos = data.payload?.todos;\n\t\t\t\t\t\t\tconst schedules = data.payload?.schedules;\n\t\t\t\t\t\t\tif (currentOnRealtimeNlp) {\n\t\t\t\t\t\t\t\tcurrentOnRealtimeNlp({\n\t\t\t\t\t\t\t\t\ttodos: Array.isArray(todos) ? todos : [],\n\t\t\t\t\t\t\t\t\tschedules: Array.isArray(schedules) ? schedules : [],\n\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// 分段保存通知（7×24 模式）\n\t\t\t\t\t\tif (data.header?.name === \"SegmentSaved\") {\n\t\t\t\t\t\t\t// 通知前端分段已保存，需要重置时间戳和文本\n\t\t\t\t\t\t\t// 通过特殊标记传递给 onTranscription，并传递原因\n\t\t\t\t\t\t\tconst reason = data.payload?.message || \"分段保存\";\n\t\t\t\t\t\t\tif (currentOnTranscription) {\n\t\t\t\t\t\t\t\tcurrentOnTranscription(`__SEGMENT_SAVED__:${reason}`, true);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tconsole.log(\"[AudioRecordingStore] 收到分段保存通知:\", reason);\n\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t} catch (error) {\n\t\t\t\t\tconsole.error(\"Failed to parse transcription data:\", error);\n\t\t\t\t}\n\t\t\t};\n\n\t\t\tws.onerror = (error) => {\n\t\t\t\tconst errorMessage =\n\t\t\t\t\terror instanceof Error\n\t\t\t\t\t\t? error.message\n\t\t\t\t\t\t: \"WebSocket连接错误，请检查后端服务是否运行\";\n\t\t\t\tconsole.error(\"WebSocket error:\", errorMessage, error);\n\t\t\t\tset({ isRecording: false });\n\t\t\t\tif (currentOnError) {\n\t\t\t\t\tcurrentOnError(new Error(errorMessage));\n\t\t\t\t}\n\t\t\t};\n\n\t\t\tws.onclose = (event) => {\n\t\t\t\tset({\n\t\t\t\t\tisRecording: false,\n\t\t\t\t\trecordingStartedAt: null,\n\t\t\t\t\trecordingStartedDate: null,\n\t\t\t\t\tlastFinalEndMs: null,\n\t\t\t\t});\n\n\t\t\t\t// 正常关闭（用户主动停止或服务器正常关闭）不需要触发错误\n\t\t\t\tif (event.wasClean) {\n\t\t\t\t\tshouldReconnectRef = false;\n\t\t\t\t\tcurrentIs24x7 = false;\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\t// 如果已经被标记为不应该重连（用户主动关闭），直接返回\n\t\t\t\tif (!shouldReconnectRef) {\n\t\t\t\t\tconsole.log(\"[AudioRecordingStore] 已禁用自动重连，跳过重连\");\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\t// 异常关闭：如果是 7×24 模式，尝试自动重连\n\t\t\t\tif (currentIs24x7 && shouldReconnectRef && reconnectAttemptsRef < maxReconnectAttempts) {\n\t\t\t\t\treconnectAttemptsRef++;\n\t\t\t\t\tconsole.log(\n\t\t\t\t\t\t`[AudioRecordingStore] WebSocket 连接断开，${reconnectDelayMs / 1000}秒后尝试重连 (${reconnectAttemptsRef}/${maxReconnectAttempts})`\n\t\t\t\t\t);\n\n\t\t\t\t\t// 清理资源但保留回调（用于重连）\n\t\t\t\t\tcleanupRecordingResources(undefined, true);\n\n\t\t\t\t\treconnectTimeoutRef = setTimeout(() => {\n\t\t\t\t\t\tif (currentOnTranscription && shouldReconnectRef) {\n\t\t\t\t\t\t\tconsole.log(\"[AudioRecordingStore] 尝试重新连接 WebSocket...\");\n\t\t\t\t\t\t\t// 使用保存的回调重新启动录音\n\t\t\t\t\t\t\tget().startRecording(\n\t\t\t\t\t\t\t\tcurrentOnTranscription,\n\t\t\t\t\t\t\t\tcurrentOnRealtimeNlp || undefined,\n\t\t\t\t\t\t\t\tcurrentOnError || undefined,\n\t\t\t\t\t\t\t\tcurrentIs24x7\n\t\t\t\t\t\t\t).catch((error) => {\n\t\t\t\t\t\t\t\tconsole.error(\"[AudioRecordingStore] 重连失败:\", error);\n\t\t\t\t\t\t\t\tif (currentOnError) {\n\t\t\t\t\t\t\t\t\tcurrentOnError(error as Error);\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t}\n\t\t\t\t\t}, reconnectDelayMs);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\t// 异常关闭提供详细错误信息\n\t\t\t\tlet errorMessage = \"WebSocket连接异常关闭\";\n\t\t\t\tswitch (event.code) {\n\t\t\t\t\tcase 1006:\n\t\t\t\t\t\terrorMessage =\n\t\t\t\t\t\t\t\"WebSocket连接异常断开，可能是网络问题或服务器未响应。请检查：\\n1. 后端服务是否正常运行\\n2. 网络连接是否正常\\n3. 防火墙或代理设置是否正确\";\n\t\t\t\t\t\tbreak;\n\t\t\t\t\tcase 1000:\n\t\t\t\t\t\treturn;\n\t\t\t\t\tcase 1001:\n\t\t\t\t\t\terrorMessage = \"服务器主动断开连接（端点离开）\";\n\t\t\t\t\t\tbreak;\n\t\t\t\t\tcase 1002:\n\t\t\t\t\t\terrorMessage = \"协议错误导致连接关闭\";\n\t\t\t\t\t\tbreak;\n\t\t\t\t\tcase 1003:\n\t\t\t\t\t\terrorMessage = \"不支持的数据类型导致连接关闭\";\n\t\t\t\t\t\tbreak;\n\t\t\t\t\tcase 1007:\n\t\t\t\t\t\terrorMessage = \"数据格式错误导致连接关闭\";\n\t\t\t\t\t\tbreak;\n\t\t\t\t\tcase 1008:\n\t\t\t\t\t\terrorMessage = \"策略违规导致连接关闭\";\n\t\t\t\t\t\tbreak;\n\t\t\t\t\tcase 1009:\n\t\t\t\t\t\terrorMessage = \"消息过大导致连接关闭\";\n\t\t\t\t\t\tbreak;\n\t\t\t\t\tcase 1010:\n\t\t\t\t\t\terrorMessage = \"扩展协商失败导致连接关闭\";\n\t\t\t\t\t\tbreak;\n\t\t\t\t\tcase 1011:\n\t\t\t\t\t\terrorMessage = \"服务器内部错误导致连接关闭\";\n\t\t\t\t\t\tbreak;\n\t\t\t\t\tcase 1012:\n\t\t\t\t\t\terrorMessage = \"服务重启导致连接关闭\";\n\t\t\t\t\t\tbreak;\n\t\t\t\t\tcase 1013:\n\t\t\t\t\t\terrorMessage = \"服务过载导致连接关闭\";\n\t\t\t\t\t\tbreak;\n\t\t\t\t\tdefault:\n\t\t\t\t\t\terrorMessage = `WebSocket连接异常关闭: ${event.reason || `错误代码 ${event.code}`}`;\n\t\t\t\t}\n\n\t\t\t\tconsole.error(\"[AudioRecordingStore] WebSocket closed abnormally:\", {\n\t\t\t\t\tcode: event.code,\n\t\t\t\t\treason: event.reason,\n\t\t\t\t\twasClean: event.wasClean,\n\t\t\t\t});\n\n\t\t\t\tif (currentOnError) {\n\t\t\t\t\tcurrentOnError(new Error(errorMessage));\n\t\t\t\t}\n\t\t\t};\n\n\t\t\twsRef = ws;\n\t\t} catch (error) {\n\t\t\tconsole.error(\"Failed to start recording:\", error);\n\t\t\tif (onError) {\n\t\t\t\tonError(error as Error);\n\t\t\t}\n\t\t}\n\t},\n\n\tstopRecording: (segmentTimestamps) => {\n\t\t// 停止自动重连\n\t\tshouldReconnectRef = false;\n\t\tif (reconnectTimeoutRef) {\n\t\t\tclearTimeout(reconnectTimeoutRef);\n\t\t\treconnectTimeoutRef = null;\n\t\t}\n\t\treconnectAttemptsRef = 0;\n\t\tcurrentIs24x7 = false;\n\n\t\t// 清理录音资源\n\t\tcleanupRecordingResources(segmentTimestamps);\n\t\tset({\n\t\t\tisRecording: false,\n\t\t\trecordingStartedAt: null,\n\t\t\trecordingStartedDate: null,\n\t\t\tlastFinalEndMs: null,\n\t\t});\n\t},\n\n\tresetLastFinalEnd: () => {\n\t\tset({ lastFinalEndMs: null });\n\t},\n\n\tupdateLastFinalEnd: (ms) => {\n\t\tset({ lastFinalEndMs: ms });\n\t},\n\n\t// ===== 转录数据更新方法 =====\n\n\tappendTranscriptionText: (text) => {\n\t\tset((state) => {\n\t\t\tconst prev = state.transcriptionText;\n\t\t\tconst needsGap = prev && !prev.endsWith(\"\\n\");\n\t\t\treturn {\n\t\t\t\ttranscriptionText: `${prev}${needsGap ? \"\\n\" : \"\"}${text}\\n`,\n\t\t\t};\n\t\t});\n\t},\n\n\tsetPartialText: (text) => {\n\t\tset({ partialText: text });\n\t},\n\n\tsetOptimizedText: (text) => {\n\t\tset({ optimizedText: text });\n\t},\n\n\tappendSegmentData: (data) => {\n\t\tset((state) => ({\n\t\t\tsegmentTimesSec: [...state.segmentTimesSec, data.timeSec],\n\t\t\tsegmentTimeLabels: [...state.segmentTimeLabels, data.timeLabel],\n\t\t\tsegmentRecordingIds: [...state.segmentRecordingIds, data.recordingId],\n\t\t\tsegmentOffsetsSec: [...state.segmentOffsetsSec, data.offsetSec],\n\t\t}));\n\t},\n\n\tsetLiveTodos: (todos) => {\n\t\tset({ liveTodos: todos });\n\t},\n\n\tsetLiveSchedules: (schedules) => {\n\t\tset({ liveSchedules: schedules });\n\t},\n\n\tclearSessionData: () => {\n\t\tset({\n\t\t\ttranscriptionText: \"\",\n\t\t\tpartialText: \"\",\n\t\t\toptimizedText: \"\",\n\t\t\tsegmentTimesSec: [],\n\t\t\tsegmentTimeLabels: [],\n\t\t\tsegmentRecordingIds: [],\n\t\t\tsegmentOffsetsSec: [],\n\t\t\tliveTodos: [],\n\t\t\tliveSchedules: [],\n\t\t});\n\t},\n}));\n\n// ========== 辅助 Hooks ==========\n\n/**\n * 获取录音开始后的经过时间（毫秒）\n */\nexport function getRecordingElapsedMs(): number {\n\tconst { recordingStartedAt } = useAudioRecordingStore.getState();\n\tif (!recordingStartedAt) return 0;\n\treturn Date.now() - recordingStartedAt;\n}\n\n/**\n * 获取段落的开始时间（相对于录音开始）\n * 优先使用 lastFinalEndMs，否则使用录音开始时间\n */\nexport function getSegmentStartMs(): number {\n\tconst { recordingStartedAt, lastFinalEndMs } = useAudioRecordingStore.getState();\n\tif (!recordingStartedAt) return 0;\n\treturn lastFinalEndMs ?? recordingStartedAt;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/store/breakdown-store.ts",
    "content": "import { create } from \"zustand\";\nimport type { ParsedTodoTree } from \"@/apps/chat/types\";\nimport { unwrapApiData } from \"@/lib/api/fetcher\";\nimport {\n\tcreateTodoApiTodosPost,\n\tupdateTodoApiTodosTodoIdPut,\n} from \"@/lib/generated/todos/todos\";\nimport { getQueryClient, queryKeys } from \"@/lib/query\";\n\nexport interface Question {\n\tid: string;\n\tquestion: string;\n\toptions: string[];\n\ttype?: \"single\" | \"multiple\"; // 可选，默认多选\n}\n\ntype BreakdownStage = \"idle\" | \"questionnaire\" | \"summary\" | \"completed\";\n\ninterface BreakdownStoreState {\n\tactiveBreakdownTodoId: number | null;\n\tstage: BreakdownStage;\n\tquestions: Question[];\n\tanswers: Record<string, string[]>;\n\tsummary: string | null;\n\tsubtasks: ParsedTodoTree[] | null;\n\tisLoading: boolean;\n\tisGeneratingSummary: boolean; // 正在生成总结（流式）\n\tsummaryStreamingText: string | null; // 流式生成的文本（用于显示）\n\tisGeneratingQuestions: boolean; // 正在生成问题（流式）\n\tquestionStreamingCount: number; // 当前已生成的问题数量\n\tquestionStreamingTitle: string | null; // 当前正在生成的问题标题\n\terror: string | null;\n\n\tstartBreakdown: (todoId: number) => void;\n\tsetQuestions: (questions: Question[]) => void;\n\tsetAnswer: (questionId: string, options: string[]) => void;\n\tsetSummary: (summary: string, subtasks: ParsedTodoTree[]) => void;\n\tsetSummaryStreaming: (text: string | null) => void; // 设置流式文本\n\tsetIsGeneratingSummary: (isGenerating: boolean) => void; // 设置生成状态\n\tsetQuestionStreaming: (count: number, title: string | null) => void; // 设置问题流式状态\n\tsetIsGeneratingQuestions: (isGenerating: boolean) => void; // 设置问题生成状态\n\tresetBreakdown: () => void;\n\tapplyBreakdown: () => Promise<void>;\n}\n\nexport const useBreakdownStore = create<BreakdownStoreState>()((set, get) => ({\n\tactiveBreakdownTodoId: null,\n\tstage: \"idle\",\n\tquestions: [],\n\tanswers: {},\n\tsummary: null,\n\tsubtasks: null,\n\tisLoading: false,\n\tisGeneratingSummary: false,\n\tsummaryStreamingText: null,\n\tisGeneratingQuestions: false,\n\tquestionStreamingCount: 0,\n\tquestionStreamingTitle: null,\n\terror: null,\n\n\tstartBreakdown: (todoId: number) => {\n\t\tset({\n\t\t\tactiveBreakdownTodoId: todoId,\n\t\t\tstage: \"questionnaire\",\n\t\t\tquestions: [],\n\t\t\tanswers: {},\n\t\t\tsummary: null,\n\t\t\tsubtasks: null,\n\t\t\tisLoading: true,\n\t\t\terror: null,\n\t\t});\n\t},\n\n\tsetQuestions: (questions: Question[]) => {\n\t\tset({\n\t\t\tquestions,\n\t\t\tisLoading: false,\n\t\t\terror: null,\n\t\t});\n\t},\n\n\tsetAnswer: (questionId: string, options: string[]) => {\n\t\tset((state) => ({\n\t\t\tanswers: {\n\t\t\t\t...state.answers,\n\t\t\t\t[questionId]: options,\n\t\t\t},\n\t\t}));\n\t},\n\n\tsetSummary: (summary: string, subtasks: ParsedTodoTree[]) => {\n\t\tset({\n\t\t\tsummary,\n\t\t\tsubtasks,\n\t\t\tstage: \"summary\",\n\t\t\tisLoading: false,\n\t\t\tisGeneratingSummary: false,\n\t\t\tsummaryStreamingText: null,\n\t\t\terror: null,\n\t\t});\n\t},\n\n\tsetSummaryStreaming: (text: string | null) => {\n\t\tset({ summaryStreamingText: text });\n\t},\n\n\tsetIsGeneratingSummary: (isGenerating: boolean) => {\n\t\tset({ isGeneratingSummary: isGenerating });\n\t},\n\n\tsetQuestionStreaming: (count: number, title: string | null) => {\n\t\tset({ questionStreamingCount: count, questionStreamingTitle: title });\n\t},\n\n\tsetIsGeneratingQuestions: (isGenerating: boolean) => {\n\t\tset({\n\t\t\tisGeneratingQuestions: isGenerating,\n\t\t\t...(isGenerating\n\t\t\t\t? {}\n\t\t\t\t: { questionStreamingCount: 0, questionStreamingTitle: null }),\n\t\t});\n\t},\n\n\tresetBreakdown: () => {\n\t\tset({\n\t\t\tactiveBreakdownTodoId: null,\n\t\t\tstage: \"idle\",\n\t\t\tquestions: [],\n\t\t\tanswers: {},\n\t\t\tsummary: null,\n\t\t\tsubtasks: null,\n\t\t\tisLoading: false,\n\t\t\tisGeneratingSummary: false,\n\t\t\tsummaryStreamingText: null,\n\t\t\tisGeneratingQuestions: false,\n\t\t\tquestionStreamingCount: 0,\n\t\t\tquestionStreamingTitle: null,\n\t\t\terror: null,\n\t\t});\n\t},\n\n\tapplyBreakdown: async () => {\n\t\tconst state = get();\n\t\tif (!state.activeBreakdownTodoId || !state.summary || !state.subtasks) {\n\t\t\treturn;\n\t\t}\n\n\t\tset({ isLoading: true, error: null });\n\n\t\ttry {\n\t\t\t// 更新任务描述\n\t\t\tawait updateTodoApiTodosTodoIdPut(state.activeBreakdownTodoId, {\n\t\t\t\tdescription: state.summary,\n\t\t\t});\n\n\t\t\t// 添加子任务 - 递归创建，处理层级关系\n\t\t\tconst createSubtasks = async (\n\t\t\t\ttrees: ParsedTodoTree[],\n\t\t\t\tparentId: number | null,\n\t\t\t): Promise<void> => {\n\t\t\t\tfor (const node of trees) {\n\t\t\t\t\t// 创建当前子任务\n\t\t\t\t\tconst apiTodo = await createTodoApiTodosPost({\n\t\t\t\t\t\tname: node.name,\n\t\t\t\t\t\tdescription: node.description,\n\t\t\t\t\t\torder: node.order,\n\t\t\t\t\t\tparent_todo_id: parentId,\n\t\t\t\t\t});\n\t\t\t\t\tconst created = unwrapApiData<{ id: number }>(apiTodo);\n\t\t\t\t\tconst createdId = created?.id;\n\n\t\t\t\t\t// 如果有嵌套子任务，递归创建\n\t\t\t\t\tif (createdId && node.subtasks && node.subtasks.length > 0) {\n\t\t\t\t\t\tawait createSubtasks(node.subtasks, createdId);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t};\n\n\t\t\tawait createSubtasks(state.subtasks, state.activeBreakdownTodoId);\n\n\t\t\t// 使 todos 缓存失效，触发重新获取\n\t\t\tconst queryClient = getQueryClient();\n\t\t\tawait queryClient.invalidateQueries({ queryKey: queryKeys.todos.all });\n\n\t\t\t// 标记为完成\n\t\t\tset({\n\t\t\t\tstage: \"completed\",\n\t\t\t\tisLoading: false,\n\t\t\t});\n\n\t\t\t// 延迟重置，让用户看到完成状态\n\t\t\tsetTimeout(() => {\n\t\t\t\tget().resetBreakdown();\n\t\t\t}, 2000);\n\t\t} catch (error) {\n\t\t\tconsole.error(\"Failed to apply breakdown:\", error);\n\t\t\tset({\n\t\t\t\terror: error instanceof Error ? error.message : \"应用拆分失败，请重试\",\n\t\t\t\tisLoading: false,\n\t\t\t});\n\t\t}\n\t},\n}));\n"
  },
  {
    "path": "free-todo-frontend/lib/store/chat-store.ts",
    "content": "import { create } from \"zustand\";\nimport { createJSONStorage, persist } from \"zustand/middleware\";\n\ninterface ChatStoreState {\n\tconversationId: string | null;\n\thistoryOpen: boolean;\n\tpendingPrompt: string | null; // 待发送的预设消息（由其他组件触发）\n\tpendingNewChat: boolean; // 是否需要先开启新会话再发送消息\n\tsetConversationId: (id: string | null) => void;\n\tsetHistoryOpen: (open: boolean) => void;\n\tsetPendingPrompt: (prompt: string | null, startNewChat?: boolean) => void;\n}\n\nexport const useChatStore = create<ChatStoreState>()(\n\tpersist(\n\t\t(set) => ({\n\t\t\tconversationId: null,\n\t\t\thistoryOpen: false,\n\t\t\tpendingPrompt: null,\n\t\t\tpendingNewChat: false,\n\t\t\tsetConversationId: (id) => set({ conversationId: id }),\n\t\t\tsetHistoryOpen: (open) => set({ historyOpen: open }),\n\t\t\tsetPendingPrompt: (prompt, startNewChat = false) =>\n\t\t\t\tset({ pendingPrompt: prompt, pendingNewChat: startNewChat }),\n\t\t}),\n\t\t{\n\t\t\tname: \"chat-config\",\n\t\t\tstorage: createJSONStorage(() => {\n\t\t\t\treturn {\n\t\t\t\t\tgetItem: (name: string): string | null => {\n\t\t\t\t\t\tif (typeof window === \"undefined\") return null;\n\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tconst stored = localStorage.getItem(name);\n\t\t\t\t\t\t\tconst parsed = stored ? JSON.parse(stored) : null;\n\t\t\t\t\t\t\tconst state = parsed?.state || parsed || {};\n\n\t\t\t\t\t\t\t// 验证 conversationId - 刷新后清空，不默认选中历史记录\n\t\t\t\t\t\t\tconst conversationId: string | null = null;\n\n\t\t\t\t\t\t\t// 验证 historyOpen\n\t\t\t\t\t\t\tconst historyOpen: boolean =\n\t\t\t\t\t\t\t\ttypeof state.historyOpen === \"boolean\"\n\t\t\t\t\t\t\t\t\t? state.historyOpen\n\t\t\t\t\t\t\t\t\t: false;\n\n\t\t\t\t\t\t\treturn JSON.stringify({\n\t\t\t\t\t\t\t\tstate: {\n\t\t\t\t\t\t\t\t\tconversationId,\n\t\t\t\t\t\t\t\t\thistoryOpen,\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t} catch (e) {\n\t\t\t\t\t\t\tconsole.error(\"Error loading chat config:\", e);\n\t\t\t\t\t\t\treturn null;\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\tsetItem: (name: string, value: string): void => {\n\t\t\t\t\t\tif (typeof window === \"undefined\") return;\n\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tlocalStorage.setItem(name, value);\n\t\t\t\t\t\t} catch (e) {\n\t\t\t\t\t\t\tconsole.error(\"Error saving chat config:\", e);\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\tremoveItem: (name: string): void => {\n\t\t\t\t\t\tif (typeof window === \"undefined\") return;\n\t\t\t\t\t\tlocalStorage.removeItem(name);\n\t\t\t\t\t},\n\t\t\t\t};\n\t\t\t}),\n\t\t},\n\t),\n);\n"
  },
  {
    "path": "free-todo-frontend/lib/store/color-theme.ts",
    "content": "import { create } from \"zustand\";\nimport { createJSONStorage, persist } from \"zustand/middleware\";\n\nexport type ColorTheme =\n\t| \"catppuccin\"\n\t| \"blue\"\n\t| \"neutral\";\n\ninterface ColorThemeState {\n\tcolorTheme: ColorTheme;\n\tsetColorTheme: (colorTheme: ColorTheme) => void;\n}\n\nconst isValidColorTheme = (value: string | null): value is ColorTheme => {\n\treturn (\n\t\tvalue === \"catppuccin\" ||\n\t\tvalue === \"blue\" ||\n\t\tvalue === \"neutral\"\n\t);\n};\n\nconst normalizeColorTheme = (value: string | null): ColorTheme => {\n\tif (value === \"amber-coast\") return \"catppuccin\";\n\tif (isValidColorTheme(value)) return value;\n\treturn \"catppuccin\";\n};\n\nconst colorThemeStorage = {\n\tgetItem: () => {\n\t\tif (typeof window === \"undefined\") return null;\n\n\t\tconst saved = localStorage.getItem(\"color-theme\");\n\t\tconst colorTheme = normalizeColorTheme(saved);\n\n\t\treturn JSON.stringify({ state: { colorTheme } });\n\t},\n\tsetItem: (_name: string, value: string) => {\n\t\tif (typeof window === \"undefined\") return;\n\n\t\ttry {\n\t\t\tconst data = JSON.parse(value);\n\t\t\tconst rawTheme =\n\t\t\t\tdata.state?.colorTheme ?? data.colorTheme ?? \"catppuccin\";\n\t\t\tconst colorTheme = normalizeColorTheme(rawTheme);\n\t\t\tlocalStorage.setItem(\"color-theme\", colorTheme);\n\t\t} catch (e) {\n\t\t\tconsole.error(\"Error saving color theme:\", e);\n\t\t}\n\t},\n\tremoveItem: () => {\n\t\tif (typeof window === \"undefined\") return;\n\t\tlocalStorage.removeItem(\"color-theme\");\n\t},\n};\n\nexport const useColorThemeStore = create<ColorThemeState>()(\n\tpersist(\n\t\t(set) => ({\n\t\t\tcolorTheme: \"catppuccin\",\n\t\t\tsetColorTheme: (colorTheme) => set({ colorTheme }),\n\t\t}),\n\t\t{\n\t\t\tname: \"color-theme\",\n\t\t\tstorage: createJSONStorage(() => colorThemeStorage),\n\t\t},\n\t),\n);\n"
  },
  {
    "path": "free-todo-frontend/lib/store/journal-store.ts",
    "content": "import { create } from \"zustand\";\nimport { createJSONStorage, persist } from \"zustand/middleware\";\n\nexport type JournalRefreshMode = \"fixed\" | \"workHours\" | \"custom\";\n\ninterface JournalSettingsState {\n\trefreshMode: JournalRefreshMode;\n\tfixedTime: string;\n\tworkHoursStart: string;\n\tworkHoursEnd: string;\n\tcustomTime: string;\n\tautoLinkEnabled: boolean;\n\tautoGenerateObjectiveEnabled: boolean;\n\tautoGenerateAiEnabled: boolean;\n\tsetRefreshMode: (mode: JournalRefreshMode) => void;\n\tsetFixedTime: (value: string) => void;\n\tsetWorkHoursStart: (value: string) => void;\n\tsetWorkHoursEnd: (value: string) => void;\n\tsetCustomTime: (value: string) => void;\n\tsetAutoLinkEnabled: (value: boolean) => void;\n\tsetAutoGenerateObjectiveEnabled: (value: boolean) => void;\n\tsetAutoGenerateAiEnabled: (value: boolean) => void;\n}\n\nconst journalStorage = {\n\tgetItem: () => {\n\t\tif (typeof window === \"undefined\") return null;\n\t\treturn localStorage.getItem(\"journal-settings\");\n\t},\n\tsetItem: (_name: string, value: string) => {\n\t\tif (typeof window === \"undefined\") return;\n\t\tlocalStorage.setItem(\"journal-settings\", value);\n\t},\n\tremoveItem: () => {\n\t\tif (typeof window === \"undefined\") return;\n\t\tlocalStorage.removeItem(\"journal-settings\");\n\t},\n};\n\nexport const useJournalStore = create<JournalSettingsState>()(\n\tpersist(\n\t\t(set) => ({\n\t\t\trefreshMode: \"fixed\",\n\t\t\tfixedTime: \"04:00\",\n\t\t\tworkHoursStart: \"10:00\",\n\t\t\tworkHoursEnd: \"02:00\",\n\t\t\tcustomTime: \"04:00\",\n\t\t\tautoLinkEnabled: true,\n\t\t\tautoGenerateObjectiveEnabled: false,\n\t\t\tautoGenerateAiEnabled: false,\n\t\t\tsetRefreshMode: (mode) => set({ refreshMode: mode }),\n\t\t\tsetFixedTime: (value) => set({ fixedTime: value }),\n\t\t\tsetWorkHoursStart: (value) => set({ workHoursStart: value }),\n\t\t\tsetWorkHoursEnd: (value) => set({ workHoursEnd: value }),\n\t\t\tsetCustomTime: (value) => set({ customTime: value }),\n\t\t\tsetAutoLinkEnabled: (value) => set({ autoLinkEnabled: value }),\n\t\t\tsetAutoGenerateObjectiveEnabled: (value) =>\n\t\t\t\tset({ autoGenerateObjectiveEnabled: value }),\n\t\t\tsetAutoGenerateAiEnabled: (value) =>\n\t\t\t\tset({ autoGenerateAiEnabled: value }),\n\t\t}),\n\t\t{\n\t\t\tname: \"journal-settings\",\n\t\t\tstorage: createJSONStorage(() => journalStorage),\n\t\t},\n\t),\n);\n"
  },
  {
    "path": "free-todo-frontend/lib/store/locale.ts",
    "content": "import { create } from \"zustand\";\nimport { createJSONStorage, persist } from \"zustand/middleware\";\n\n// Supported locales - add new languages here\n// Future languages: \"ja\" | \"ko\" | \"ru\" | \"fr\"\nexport type Locale = \"zh\" | \"en\";\n\n// Supported locales list for validation and detection\nconst SUPPORTED_LOCALES: Locale[] = [\"zh\", \"en\"];\n\n// Default locale when no match is found\nconst DEFAULT_LOCALE: Locale = \"en\";\n\ninterface LocaleState {\n\tlocale: Locale;\n\tsetLocale: (locale: Locale) => void;\n\t/** Whether the store has been hydrated from localStorage */\n\t_hasHydrated: boolean;\n\t/** Set hydration state */\n\t_setHasHydrated: (state: boolean) => void;\n}\n\nconst isValidLocale = (value: string | null): value is Locale => {\n\treturn value !== null && SUPPORTED_LOCALES.includes(value as Locale);\n};\n\n// Detect system language and return default locale\n// Returns matching locale if system language is supported, otherwise default\nconst getSystemLocale = (): Locale => {\n\tif (typeof navigator === \"undefined\") return DEFAULT_LOCALE;\n\n\tconst browserLang = (navigator.language || navigator.languages?.[0] || \"\").toLowerCase();\n\n\t// Match against supported locales by prefix\n\tfor (const locale of SUPPORTED_LOCALES) {\n\t\tif (browserLang.startsWith(locale)) {\n\t\t\treturn locale;\n\t\t}\n\t}\n\n\treturn DEFAULT_LOCALE;\n};\n\n// 同步 locale 到 cookie，使服务端可以读取\n// 注意：必须使用同步的 document.cookie，不能使用异步的 Cookie Store API\n// 否则在 router.refresh() 或页面刷新时，cookie 可能还未设置完成\nconst syncLocaleToCookie = (locale: Locale) => {\n\tif (typeof document === \"undefined\") return;\n\t// 设置 cookie，有效期 1 年\n\t// biome-ignore lint/suspicious/noDocumentCookie: 需要同步设置 cookie 以确保刷新前完成\n\tdocument.cookie = `locale=${locale};path=/;max-age=${60 * 60 * 24 * 365};SameSite=Lax`;\n};\n\nconst localeStorage = {\n\tgetItem: () => {\n\t\tif (typeof window === \"undefined\") return null;\n\n\t\tconst language = localStorage.getItem(\"language\");\n\t\t// If user has a saved preference, use it; otherwise detect from system language\n\t\tconst locale: Locale = isValidLocale(language)\n\t\t\t? language\n\t\t\t: getSystemLocale();\n\t\t// Sync to cookie on initialization\n\t\tsyncLocaleToCookie(locale);\n\t\treturn JSON.stringify({ state: { locale } });\n\t},\n\tsetItem: (_name: string, value: string) => {\n\t\tif (typeof window === \"undefined\") return;\n\n\t\ttry {\n\t\t\tconst data = JSON.parse(value);\n\t\t\tconst rawLocale = data.state?.locale || data.locale || getSystemLocale();\n\t\t\tconst locale: Locale = isValidLocale(rawLocale)\n\t\t\t\t? rawLocale\n\t\t\t\t: getSystemLocale();\n\t\t\tlocalStorage.setItem(\"language\", locale);\n\t\t\t// Sync to cookie\n\t\t\tsyncLocaleToCookie(locale);\n\t\t} catch (e) {\n\t\t\tconsole.error(\"Error saving locale:\", e);\n\t\t}\n\t},\n\tremoveItem: () => {\n\t\tif (typeof window === \"undefined\") return;\n\t\tlocalStorage.removeItem(\"language\");\n\t},\n};\n\nexport const useLocaleStore = create<LocaleState>()(\n\tpersist(\n\t\t(set) => ({\n\t\t\tlocale: getSystemLocale(),\n\t\t\t_hasHydrated: false,\n\t\t\t_setHasHydrated: (state: boolean) => set({ _hasHydrated: state }),\n\t\t\tsetLocale: (locale) => {\n\t\t\t\t// Immediately sync to cookie\n\t\t\t\tsyncLocaleToCookie(locale);\n\t\t\t\tset({ locale });\n\t\t\t},\n\t\t}),\n\t\t{\n\t\t\tname: \"locale\",\n\t\t\tstorage: createJSONStorage(() => localeStorage),\n\t\t\tonRehydrateStorage: () => (state) => {\n\t\t\t\t// Called when hydration is complete\n\t\t\t\tstate?._setHasHydrated(true);\n\t\t\t},\n\t\t},\n\t),\n);\n"
  },
  {
    "path": "free-todo-frontend/lib/store/notification-store.ts",
    "content": "import { create } from \"zustand\";\nimport { isTauri, isWeb } from \"@/lib/utils/platform\";\n\nexport interface Notification {\n\tid: string;\n\ttitle: string;\n\tcontent: string;\n\ttimestamp: string;\n\tsource?: string; // 来源端点标识\n\ttodoId?: number; // draft todo 的 ID（如果通知来自 draft todo）\n}\n\nexport interface PollingEndpoint {\n\tid: string;\n\turl: string;\n\tinterval: number; // 毫秒\n\tenabled: boolean;\n}\n\ninterface NotificationStoreState {\n\t// 当前通知列表\n\tnotifications: Notification[];\n\t// 轮询端点配置\n\tendpoints: Map<string, PollingEndpoint>;\n\t// 展开/收起状态\n\tisExpanded: boolean;\n\t// 已触发系统通知的 ID（用于去重）\n\tnotifiedIds: Set<string>;\n\t// 方法\n\tsetNotificationsFromSource: (source: string, notifications: Notification[]) => void;\n\tupsertNotification: (notification: Notification) => void;\n\tremoveNotification: (id: string) => void;\n\tremoveNotificationsBySource: (source: string) => void;\n\tregisterEndpoint: (endpoint: PollingEndpoint) => void;\n\tunregisterEndpoint: (id: string) => void;\n\ttoggleExpanded: () => void;\n\tsetExpanded: (expanded: boolean) => void;\n\tgetEndpoint: (id: string) => PollingEndpoint | undefined;\n\tgetAllEndpoints: () => PollingEndpoint[];\n}\n\nfunction sortNotifications(notifications: Notification[]): Notification[] {\n\treturn [...notifications].sort((a, b) => {\n\t\tconst aTime = new Date(a.timestamp).getTime();\n\t\tconst bTime = new Date(b.timestamp).getTime();\n\t\tconst safeATime = Number.isNaN(aTime) ? 0 : aTime;\n\t\tconst safeBTime = Number.isNaN(bTime) ? 0 : bTime;\n\t\treturn safeBTime - safeATime;\n\t});\n}\n\ntype TauriNotificationApi = {\n\tisPermissionGranted?: () => Promise<boolean> | boolean;\n\trequestPermission?: () => Promise<NotificationPermission> | NotificationPermission;\n\tsendNotification?: (options: { title: string; body?: string }) => Promise<void> | void;\n};\n\nconst getTauriNotificationApi = (): TauriNotificationApi | null => {\n\tif (typeof window === \"undefined\") return null;\n\tconst tauri = (window as Window & { __TAURI__?: { notification?: TauriNotificationApi } })\n\t\t.__TAURI__;\n\treturn tauri?.notification ?? null;\n};\n\nlet webPermissionRequest: Promise<NotificationPermission> | null = null;\n\nconst requestWebPermission = async (): Promise<NotificationPermission> => {\n\tif (webPermissionRequest) return webPermissionRequest;\n\twebPermissionRequest = Notification.requestPermission();\n\treturn webPermissionRequest;\n};\n\nconst showWebNotification = async (notification: Notification): Promise<void> => {\n\tif (typeof window === \"undefined\" || !(\"Notification\" in window)) return;\n\tlet permission = Notification.permission;\n\tif (permission === \"default\") {\n\t\ttry {\n\t\t\tpermission = await requestWebPermission();\n\t\t} catch {\n\t\t\treturn;\n\t\t}\n\t}\n\tif (permission !== \"granted\") return;\n\tconst title = notification.title || \"通知\";\n\ttry {\n\t\tnew Notification(title, {\n\t\t\tbody: notification.content,\n\t\t\ttag: notification.id,\n\t\t});\n\t} catch {\n\t\t// 静默处理错误，不影响应用运行\n\t}\n};\n\nconst showTauriNotification = async (notification: Notification): Promise<void> => {\n\tconst api = getTauriNotificationApi();\n\tif (!api?.sendNotification) return;\n\tlet granted = true;\n\tif (api.isPermissionGranted) {\n\t\ttry {\n\t\t\tgranted = await api.isPermissionGranted();\n\t\t} catch {\n\t\t\tgranted = false;\n\t\t}\n\t}\n\tif (!granted && api.requestPermission) {\n\t\ttry {\n\t\t\tconst permission = await api.requestPermission();\n\t\t\tgranted = permission === \"granted\";\n\t\t} catch {\n\t\t\tgranted = false;\n\t\t}\n\t}\n\tif (!granted) return;\n\ttry {\n\t\tawait api.sendNotification({\n\t\t\ttitle: notification.title || \"通知\",\n\t\t\tbody: notification.content,\n\t\t});\n\t} catch {\n\t\t// 静默处理错误，不影响应用运行\n\t}\n};\n\nfunction notifySystem(notification: Notification): void {\n\tif (typeof window === \"undefined\") return;\n\tif (window.electronAPI?.showNotification) {\n\t\twindow.electronAPI\n\t\t\t.showNotification({\n\t\t\t\tid: notification.id,\n\t\t\t\ttitle: notification.title,\n\t\t\t\tcontent: notification.content,\n\t\t\t\ttimestamp: notification.timestamp,\n\t\t\t})\n\t\t\t.catch((error) => {\n\t\t\t\t// 静默处理错误，不影响应用运行\n\t\t\t\tconsole.warn(\"Failed to show system notification:\", error);\n\t\t\t});\n\t\treturn;\n\t}\n\tif (isTauri()) {\n\t\tvoid showTauriNotification(notification);\n\t\treturn;\n\t}\n\tif (isWeb()) {\n\t\tvoid showWebNotification(notification);\n\t}\n}\n\nexport const useNotificationStore = create<NotificationStoreState>((set, get) => ({\n\tnotifications: [],\n\tendpoints: new Map(),\n\tisExpanded: false,\n\tnotifiedIds: new Set(),\n\n\tsetNotificationsFromSource: (source, notifications) => {\n\t\tconst current = get().notifications;\n\t\tconst filtered = current.filter((item) => item.source !== source);\n\t\tconst tagged = notifications.map((notification) => ({\n\t\t\t...notification,\n\t\t\tsource,\n\t\t}));\n\t\tconst next = sortNotifications([...filtered, ...tagged]);\n\n\t\tconst nextNotifiedIds = new Set(get().notifiedIds);\n\t\tfor (const notification of tagged) {\n\t\t\tif (!nextNotifiedIds.has(notification.id)) {\n\t\t\t\tnotifySystem(notification);\n\t\t\t\tnextNotifiedIds.add(notification.id);\n\t\t\t}\n\t\t}\n\n\t\tset({ notifications: next, notifiedIds: nextNotifiedIds });\n\t},\n\n\tupsertNotification: (notification) => {\n\t\tconst current = get().notifications.filter((item) => item.id !== notification.id);\n\t\tconst next = sortNotifications([...current, notification]);\n\n\t\tconst nextNotifiedIds = new Set(get().notifiedIds);\n\t\tif (!nextNotifiedIds.has(notification.id)) {\n\t\t\tnotifySystem(notification);\n\t\t\tnextNotifiedIds.add(notification.id);\n\t\t}\n\n\t\tset({ notifications: next, notifiedIds: nextNotifiedIds });\n\t},\n\n\tremoveNotification: (id) => {\n\t\tconst current = get().notifications;\n\t\tconst next = current.filter((item) => item.id !== id);\n\t\tset({ notifications: next });\n\t},\n\n\tremoveNotificationsBySource: (source) => {\n\t\tconst current = get().notifications;\n\t\tconst next = current.filter((item) => item.source !== source);\n\t\tset({ notifications: next });\n\t},\n\n\tregisterEndpoint: (endpoint) => {\n\t\tconst { endpoints } = get();\n\t\tconst newEndpoints = new Map(endpoints);\n\t\tnewEndpoints.set(endpoint.id, endpoint);\n\t\tset({ endpoints: newEndpoints });\n\t},\n\n\tunregisterEndpoint: (id) => {\n\t\tconst { endpoints } = get();\n\t\tconst newEndpoints = new Map(endpoints);\n\t\tnewEndpoints.delete(id);\n\t\tset({ endpoints: newEndpoints });\n\t},\n\n\ttoggleExpanded: () => {\n\t\tset((state) => ({ isExpanded: !state.isExpanded }));\n\t},\n\n\tsetExpanded: (expanded) => {\n\t\tset({ isExpanded: expanded });\n\t},\n\n\tgetEndpoint: (id) => {\n\t\treturn get().endpoints.get(id);\n\t},\n\n\tgetAllEndpoints: () => {\n\t\treturn Array.from(get().endpoints.values());\n\t},\n}));\n"
  },
  {
    "path": "free-todo-frontend/lib/store/onboarding-store.ts",
    "content": "import { create } from \"zustand\";\nimport { createJSONStorage, persist } from \"zustand/middleware\";\n\n/**\n * Onboarding state interface\n * Manages the user onboarding tour state\n */\ninterface OnboardingState {\n\t/** Whether the user has completed the onboarding tour */\n\thasCompletedTour: boolean;\n\t/** Current step index (null if not in tour) */\n\tcurrentStep: number | null;\n\t/** Mark the tour as completed */\n\tcompleteTour: () => void;\n\t/** Reset the tour (for testing or re-onboarding) */\n\tresetTour: () => void;\n\t/** Set the current step */\n\tsetCurrentStep: (step: number | null) => void;\n}\n\nconst STORAGE_KEY = \"onboarding\";\n\n/**\n * Custom storage for onboarding state\n * Persists hasCompletedTour to localStorage\n */\nconst onboardingStorage = {\n\tgetItem: () => {\n\t\tif (typeof window === \"undefined\") return null;\n\n\t\ttry {\n\t\t\tconst stored = localStorage.getItem(STORAGE_KEY);\n\t\t\tif (stored) {\n\t\t\t\tconst parsed = JSON.parse(stored);\n\t\t\t\treturn JSON.stringify({\n\t\t\t\t\tstate: {\n\t\t\t\t\t\thasCompletedTour: parsed.state?.hasCompletedTour ?? false,\n\t\t\t\t\t\tcurrentStep: null, // Don't persist currentStep\n\t\t\t\t\t},\n\t\t\t\t});\n\t\t\t}\n\t\t} catch (e) {\n\t\t\tconsole.error(\"Error reading onboarding state:\", e);\n\t\t}\n\t\treturn JSON.stringify({\n\t\t\tstate: { hasCompletedTour: false, currentStep: null },\n\t\t});\n\t},\n\tsetItem: (_name: string, value: string) => {\n\t\tif (typeof window === \"undefined\") return;\n\n\t\ttry {\n\t\t\tconst data = JSON.parse(value);\n\t\t\t// Only persist hasCompletedTour, not currentStep\n\t\t\tlocalStorage.setItem(\n\t\t\t\tSTORAGE_KEY,\n\t\t\t\tJSON.stringify({\n\t\t\t\t\tstate: {\n\t\t\t\t\t\thasCompletedTour: data.state?.hasCompletedTour ?? false,\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t);\n\t\t} catch (e) {\n\t\t\tconsole.error(\"Error saving onboarding state:\", e);\n\t\t}\n\t},\n\tremoveItem: () => {\n\t\tif (typeof window === \"undefined\") return;\n\t\tlocalStorage.removeItem(STORAGE_KEY);\n\t},\n};\n\n/**\n * Onboarding store hook\n * Manages the state of the user onboarding tour\n */\nexport const useOnboardingStore = create<OnboardingState>()(\n\tpersist(\n\t\t(set) => ({\n\t\t\thasCompletedTour: false,\n\t\t\tcurrentStep: null,\n\n\t\t\tcompleteTour: () => {\n\t\t\t\tset({ hasCompletedTour: true, currentStep: null });\n\t\t\t},\n\n\t\t\tresetTour: () => {\n\t\t\t\tset({ hasCompletedTour: false, currentStep: null });\n\t\t\t},\n\n\t\t\tsetCurrentStep: (step: number | null) => {\n\t\t\t\tset({ currentStep: step });\n\t\t\t},\n\t\t}),\n\t\t{\n\t\t\tname: STORAGE_KEY,\n\t\t\tstorage: createJSONStorage(() => onboardingStorage),\n\t\t},\n\t),\n);\n"
  },
  {
    "path": "free-todo-frontend/lib/store/theme.ts",
    "content": "import { create } from \"zustand\";\nimport { createJSONStorage, persist } from \"zustand/middleware\";\n\nexport type Theme = \"light\" | \"dark\" | \"system\";\n\ninterface ThemeState {\n\ttheme: Theme;\n\t_hasHydrated: boolean;\n\tsetTheme: (theme: Theme) => void;\n\tsetHasHydrated: (state: boolean) => void;\n}\n\nconst themeStorage = {\n\tgetItem: () => {\n\t\tif (typeof window === \"undefined\") return null;\n\n\t\tconst theme = localStorage.getItem(\"theme\") || \"system\";\n\n\t\treturn JSON.stringify({\n\t\t\tstate: {\n\t\t\t\ttheme,\n\t\t\t\t_hasHydrated: false,\n\t\t\t},\n\t\t});\n\t},\n\tsetItem: (_name: string, value: string) => {\n\t\tif (typeof window === \"undefined\") return;\n\n\t\ttry {\n\t\t\tconst data = JSON.parse(value);\n\t\t\tconst state = data.state || data;\n\n\t\t\tif (state.theme) {\n\t\t\t\tlocalStorage.setItem(\"theme\", state.theme);\n\t\t\t}\n\t\t} catch (e) {\n\t\t\tconsole.error(\"Error saving theme:\", e);\n\t\t}\n\t},\n\tremoveItem: () => {\n\t\tif (typeof window === \"undefined\") return;\n\t\tlocalStorage.removeItem(\"theme\");\n\t},\n};\n\nexport const useThemeStore = create<ThemeState>()(\n\tpersist(\n\t\t(set) => ({\n\t\t\ttheme: \"system\",\n\t\t\t_hasHydrated: false,\n\t\t\tsetTheme: (theme) => set({ theme }),\n\t\t\tsetHasHydrated: (state) => set({ _hasHydrated: state }),\n\t\t}),\n\t\t{\n\t\t\tname: \"theme-config\",\n\t\t\tstorage: createJSONStorage(() => themeStorage),\n\t\t\tonRehydrateStorage: () => (state) => {\n\t\t\t\tstate?.setHasHydrated(true);\n\t\t\t},\n\t\t},\n\t),\n);\n"
  },
  {
    "path": "free-todo-frontend/lib/store/todo-store.ts",
    "content": "import { create } from \"zustand\";\nimport { createJSONStorage, persist } from \"zustand/middleware\";\n\n/**\n * Todo UI 状态管理\n * 仅管理选中状态和折叠状态等 UI 状态\n * 数据获取和变更操作已迁移到 TanStack Query (lib/query/todos.ts)\n */\n\ninterface TodoUIState {\n\t/** 当前选中的 todo ID（主选中） */\n\tselectedTodoId: number | null;\n\t/** 所有选中的 todo IDs（多选） */\n\tselectedTodoIds: number[];\n\t/** 已折叠的 todo IDs */\n\tcollapsedTodoIds: Set<number>;\n\t/** 范围选择的锚点 todo ID（用于 Shift 键范围选择） */\n\tanchorTodoId: number | null;\n\n\t// UI 操作\n\tsetSelectedTodoId: (id: number | null) => void;\n\tsetSelectedTodoIds: (ids: number[]) => void;\n\ttoggleTodoSelection: (id: number) => void;\n\tclearTodoSelection: () => void;\n\ttoggleTodoExpanded: (id: number) => void;\n\tisTodoExpanded: (id: number) => boolean;\n\tsetAnchorTodoId: (id: number | null) => void;\n\n\t/** 当 todo 被删除时清理相关的 UI 状态 */\n\tonTodoDeleted: (deletedIds: number[]) => void;\n}\n\n// 验证和修复存储的数据\nfunction validateTodoSelectionState(state: {\n\tselectedTodoId: number | null;\n\tselectedTodoIds: number[];\n\tcollapsedTodoIds: number[] | Set<number>;\n}): {\n\tselectedTodoId: number | null;\n\tselectedTodoIds: number[];\n\tcollapsedTodoIds: Set<number>;\n} {\n\t// 验证 selectedTodoId\n\tlet selectedTodoId: number | null = null;\n\tif (state.selectedTodoId && typeof state.selectedTodoId === \"number\") {\n\t\tselectedTodoId = state.selectedTodoId;\n\t}\n\n\t// 验证 selectedTodoIds\n\tlet selectedTodoIds: number[] = [];\n\tif (Array.isArray(state.selectedTodoIds)) {\n\t\tselectedTodoIds = state.selectedTodoIds.filter(\n\t\t\t(id): id is number => typeof id === \"number\",\n\t\t);\n\t}\n\n\t// 验证 collapsedTodoIds\n\tlet collapsedTodoIds: Set<number>;\n\tif (state.collapsedTodoIds instanceof Set) {\n\t\tcollapsedTodoIds = state.collapsedTodoIds;\n\t} else if (Array.isArray(state.collapsedTodoIds)) {\n\t\tcollapsedTodoIds = new Set(\n\t\t\tstate.collapsedTodoIds.filter(\n\t\t\t\t(id): id is number => typeof id === \"number\",\n\t\t\t),\n\t\t);\n\t} else {\n\t\tcollapsedTodoIds = new Set<number>();\n\t}\n\n\t// 确保 selectedTodoId 在 selectedTodoIds 中\n\tif (selectedTodoId && !selectedTodoIds.includes(selectedTodoId)) {\n\t\tselectedTodoIds = [selectedTodoId];\n\t}\n\n\treturn {\n\t\tselectedTodoId,\n\t\tselectedTodoIds,\n\t\tcollapsedTodoIds,\n\t};\n}\n\nexport const useTodoStore = create<TodoUIState>()(\n\tpersist(\n\t\t(set, get) => ({\n\t\t\tselectedTodoId: null,\n\t\t\tselectedTodoIds: [],\n\t\t\tcollapsedTodoIds: new Set<number>(),\n\t\t\tanchorTodoId: null,\n\n\t\t\tsetSelectedTodoId: (id) =>\n\t\t\t\tset({\n\t\t\t\t\tselectedTodoId: id,\n\t\t\t\t\tselectedTodoIds: id ? [id] : [],\n\t\t\t\t\tanchorTodoId: id, // 单独选择时更新锚点\n\t\t\t\t}),\n\n\t\t\tsetSelectedTodoIds: (ids) =>\n\t\t\t\tset({\n\t\t\t\t\tselectedTodoIds: ids,\n\t\t\t\t\tselectedTodoId: ids[0] ?? null,\n\t\t\t\t}),\n\n\t\t\ttoggleTodoSelection: (id) =>\n\t\t\t\tset((state) => {\n\t\t\t\t\tconst exists = state.selectedTodoIds.includes(id);\n\t\t\t\t\tconst nextIds = exists\n\t\t\t\t\t\t? state.selectedTodoIds.filter((item) => item !== id)\n\t\t\t\t\t\t: [...state.selectedTodoIds, id];\n\t\t\t\t\treturn {\n\t\t\t\t\t\tselectedTodoIds: nextIds,\n\t\t\t\t\t\tselectedTodoId: nextIds[0] ?? null,\n\t\t\t\t\t};\n\t\t\t\t}),\n\n\t\t\tclearTodoSelection: () =>\n\t\t\t\tset({ selectedTodoId: null, selectedTodoIds: [], anchorTodoId: null }),\n\n\t\t\tsetAnchorTodoId: (id) => set({ anchorTodoId: id }),\n\n\t\t\ttoggleTodoExpanded: (id) =>\n\t\t\t\tset((state) => {\n\t\t\t\t\tconst newCollapsed = new Set(state.collapsedTodoIds);\n\t\t\t\t\tif (newCollapsed.has(id)) {\n\t\t\t\t\t\t// 如果已折叠，则展开（从 Set 中移除）\n\t\t\t\t\t\tnewCollapsed.delete(id);\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// 如果已展开，则折叠（添加到 Set 中）\n\t\t\t\t\t\tnewCollapsed.add(id);\n\t\t\t\t\t}\n\t\t\t\t\treturn { collapsedTodoIds: newCollapsed };\n\t\t\t\t}),\n\n\t\t\tisTodoExpanded: (id) => {\n\t\t\t\t// 如果 id 不在 collapsedTodoIds 中，说明是展开的\n\t\t\t\treturn !get().collapsedTodoIds.has(id);\n\t\t\t},\n\n\t\t\tonTodoDeleted: (deletedIds) => {\n\t\t\t\tconst deletedSet = new Set(deletedIds);\n\t\t\t\tset((state) => ({\n\t\t\t\t\tselectedTodoId: deletedSet.has(state.selectedTodoId ?? -1)\n\t\t\t\t\t\t? null\n\t\t\t\t\t\t: state.selectedTodoId,\n\t\t\t\t\tselectedTodoIds: state.selectedTodoIds.filter(\n\t\t\t\t\t\t(x) => !deletedSet.has(x),\n\t\t\t\t\t),\n\t\t\t\t\tanchorTodoId: deletedSet.has(state.anchorTodoId ?? -1)\n\t\t\t\t\t\t? null\n\t\t\t\t\t\t: state.anchorTodoId,\n\t\t\t\t}));\n\t\t\t},\n\t\t}),\n\t\t{\n\t\t\tname: \"todo-selection-config\",\n\t\t\tstorage: createJSONStorage(() => {\n\t\t\t\treturn {\n\t\t\t\t\tgetItem: (name: string): string | null => {\n\t\t\t\t\t\tif (typeof window === \"undefined\") return null;\n\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tconst stored = localStorage.getItem(name);\n\t\t\t\t\t\t\tif (!stored) return null;\n\n\t\t\t\t\t\t\tconst parsed = JSON.parse(stored);\n\t\t\t\t\t\t\tconst state = parsed.state || parsed;\n\n\t\t\t\t\t\t\t// 只持久化选中和折叠状态\n\t\t\t\t\t\t\tconst validated = validateTodoSelectionState({\n\t\t\t\t\t\t\t\tselectedTodoId: state.selectedTodoId ?? null,\n\t\t\t\t\t\t\t\tselectedTodoIds: state.selectedTodoIds ?? [],\n\t\t\t\t\t\t\t\tcollapsedTodoIds: state.collapsedTodoIds ?? [],\n\t\t\t\t\t\t\t});\n\n\t\t\t\t\t\t\t// 将 Set 转换为数组以便 JSON 序列化\n\t\t\t\t\t\t\treturn JSON.stringify({\n\t\t\t\t\t\t\t\tstate: {\n\t\t\t\t\t\t\t\t\tselectedTodoId: validated.selectedTodoId,\n\t\t\t\t\t\t\t\t\tselectedTodoIds: validated.selectedTodoIds,\n\t\t\t\t\t\t\t\t\tcollapsedTodoIds: Array.from(validated.collapsedTodoIds),\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t} catch (e) {\n\t\t\t\t\t\t\tconsole.error(\"Error loading todo selection config:\", e);\n\t\t\t\t\t\t\treturn null;\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\tsetItem: (name: string, value: string): void => {\n\t\t\t\t\t\tif (typeof window === \"undefined\") return;\n\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tconst data = JSON.parse(value);\n\t\t\t\t\t\t\tconst state = data.state || data;\n\n\t\t\t\t\t\t\t// 只保存选中和折叠状态\n\t\t\t\t\t\t\tconst toSave = {\n\t\t\t\t\t\t\t\tstate: {\n\t\t\t\t\t\t\t\t\tselectedTodoId: state.selectedTodoId ?? null,\n\t\t\t\t\t\t\t\t\tselectedTodoIds: state.selectedTodoIds ?? [],\n\t\t\t\t\t\t\t\t\tcollapsedTodoIds: Array.isArray(state.collapsedTodoIds)\n\t\t\t\t\t\t\t\t\t\t? state.collapsedTodoIds\n\t\t\t\t\t\t\t\t\t\t: state.collapsedTodoIds instanceof Set\n\t\t\t\t\t\t\t\t\t\t\t? Array.from(state.collapsedTodoIds)\n\t\t\t\t\t\t\t\t\t\t\t: [],\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t};\n\n\t\t\t\t\t\t\tlocalStorage.setItem(name, JSON.stringify(toSave));\n\t\t\t\t\t\t} catch (e) {\n\t\t\t\t\t\t\tconsole.error(\"Error saving todo selection config:\", e);\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\tremoveItem: (name: string): void => {\n\t\t\t\t\t\tif (typeof window === \"undefined\") return;\n\t\t\t\t\t\tlocalStorage.removeItem(name);\n\t\t\t\t\t},\n\t\t\t\t};\n\t\t\t}),\n\t\t\t// 只持久化选中和折叠状态\n\t\t\tpartialize: (state) => ({\n\t\t\t\tselectedTodoId: state.selectedTodoId,\n\t\t\t\tselectedTodoIds: state.selectedTodoIds,\n\t\t\t\tcollapsedTodoIds: Array.from(state.collapsedTodoIds),\n\t\t\t}),\n\t\t\t// 恢复状态时，将数组转换回 Set\n\t\t\tmerge: (persistedState, currentState) => {\n\t\t\t\tconst persisted = persistedState as {\n\t\t\t\t\tselectedTodoId?: number | null;\n\t\t\t\t\tselectedTodoIds?: number[];\n\t\t\t\t\tcollapsedTodoIds?: number[];\n\t\t\t\t};\n\n\t\t\t\tconst validated = validateTodoSelectionState({\n\t\t\t\t\tselectedTodoId: persisted.selectedTodoId ?? null,\n\t\t\t\t\tselectedTodoIds: persisted.selectedTodoIds ?? [],\n\t\t\t\t\tcollapsedTodoIds: persisted.collapsedTodoIds ?? [],\n\t\t\t\t});\n\n\t\t\t\treturn {\n\t\t\t\t\t...currentState,\n\t\t\t\t\tselectedTodoId: validated.selectedTodoId,\n\t\t\t\t\tselectedTodoIds: validated.selectedTodoIds,\n\t\t\t\t\tcollapsedTodoIds: validated.collapsedTodoIds,\n\t\t\t\t};\n\t\t\t},\n\t\t},\n\t),\n);\n"
  },
  {
    "path": "free-todo-frontend/lib/store/ui-store/index.ts",
    "content": "// 类型导出\n\n// 布局预设导出\nexport { LAYOUT_PRESETS } from \"./layout-presets\";\n// Store 导出\nexport { useUiStore } from \"./store\";\nexport type { DockDisplayMode, LayoutPreset, UiStoreState } from \"./types\";\n// 工具函数导出\nexport {\n\tclampWidth,\n\tDEFAULT_PANEL_STATE,\n\tgetPositionByFeature,\n\tMAX_PANEL_WIDTH,\n\tMIN_PANEL_WIDTH,\n\tvalidatePanelFeatureMap,\n} from \"./utils\";\n"
  },
  {
    "path": "free-todo-frontend/lib/store/ui-store/layout-actions.ts",
    "content": "import type { StoreApi } from \"zustand\";\nimport { LAYOUT_PRESETS } from \"./layout-presets\";\nimport type { UiStoreState } from \"./types\";\nimport { clampWidth } from \"./utils\";\n\ntype SetState = StoreApi<UiStoreState>[\"setState\"];\ntype GetState = StoreApi<UiStoreState>[\"getState\"];\n\nexport const createLayoutActions = (set: SetState, get: GetState) => ({\n\tapplyLayout: (layoutId: string) => {\n\t\tconst customLayouts = get().customLayouts;\n\t\tconst layout =\n\t\t\tLAYOUT_PRESETS.find((preset) => preset.id === layoutId) ||\n\t\t\tcustomLayouts.find((preset) => preset.id === layoutId);\n\t\tif (!layout) return;\n\n\t\tset({\n\t\t\tpanelFeatureMap: { ...layout.panelFeatureMap },\n\t\t\tisPanelAOpen: layout.isPanelAOpen,\n\t\t\tisPanelBOpen: layout.isPanelBOpen,\n\t\t\tisPanelCOpen: layout.isPanelCOpen,\n\t\t\t...(layout.panelAWidth !== undefined && {\n\t\t\t\tpanelAWidth: layout.panelAWidth,\n\t\t\t}),\n\t\t\t...(layout.panelCWidth !== undefined && {\n\t\t\t\tpanelCWidth: layout.panelCWidth,\n\t\t\t}),\n\t\t});\n\t},\n\n\tsaveCustomLayout: (name: string, options?: { overwrite?: boolean }) => {\n\t\tconst trimmedName = name.trim();\n\t\tif (!trimmedName) return false;\n\n\t\tconst state = get();\n\t\tconst nameKey = trimmedName.toLocaleLowerCase();\n\t\tconst existingIndex = state.customLayouts.findIndex(\n\t\t\t(layout) => layout.name.toLocaleLowerCase() === nameKey,\n\t\t);\n\t\tconst shouldOverwrite = options?.overwrite ?? false;\n\t\tif (existingIndex >= 0 && !shouldOverwrite) return false;\n\n\t\tconst existing = state.customLayouts[existingIndex];\n\t\tconst layoutId = existing?.id ?? `custom:${encodeURIComponent(trimmedName)}`;\n\n\t\tconst newLayout = {\n\t\t\tid: layoutId,\n\t\t\tname: trimmedName,\n\t\t\tpanelFeatureMap: { ...state.panelFeatureMap },\n\t\t\tisPanelAOpen: state.isPanelAOpen,\n\t\t\tisPanelBOpen: state.isPanelBOpen,\n\t\t\tisPanelCOpen: state.isPanelCOpen,\n\t\t\tpanelAWidth: clampWidth(state.panelAWidth),\n\t\t\tpanelCWidth: clampWidth(state.panelCWidth),\n\t\t};\n\n\t\tconst nextLayouts = [...state.customLayouts];\n\t\tif (existingIndex >= 0) {\n\t\t\tnextLayouts[existingIndex] = newLayout;\n\t\t} else {\n\t\t\tnextLayouts.push(newLayout);\n\t\t}\n\n\t\tset({ customLayouts: nextLayouts });\n\t\treturn true;\n\t},\n\n\trenameCustomLayout: (\n\t\tlayoutId: string,\n\t\tname: string,\n\t\toptions?: { overwrite?: boolean },\n\t) => {\n\t\tconst trimmedName = name.trim();\n\t\tif (!trimmedName) return false;\n\n\t\tconst state = get();\n\t\tconst nameKey = trimmedName.toLocaleLowerCase();\n\t\tconst shouldOverwrite = options?.overwrite ?? false;\n\n\t\tconst target = state.customLayouts.find((layout) => layout.id === layoutId);\n\t\tif (!target) return false;\n\n\t\tconst nextLayouts: typeof state.customLayouts = [];\n\t\tfor (const layout of state.customLayouts) {\n\t\t\tconst layoutNameKey = layout.name.toLocaleLowerCase();\n\t\t\tif (layout.id === layoutId) {\n\t\t\t\tnextLayouts.push({ ...layout, name: trimmedName });\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tif (layoutNameKey === nameKey) {\n\t\t\t\tif (!shouldOverwrite) return false;\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tnextLayouts.push(layout);\n\t\t}\n\n\t\tset({ customLayouts: nextLayouts });\n\t\treturn true;\n\t},\n\n\tdeleteCustomLayout: (layoutId: string) =>\n\t\tset((state) => ({\n\t\t\tcustomLayouts: state.customLayouts.filter(\n\t\t\t\t(layout) => layout.id !== layoutId,\n\t\t\t),\n\t\t})),\n});\n"
  },
  {
    "path": "free-todo-frontend/lib/store/ui-store/layout-presets.ts",
    "content": "import type { LayoutPreset } from \"./types\";\n\n// 导出完整的预设布局列表\nexport const LAYOUT_PRESETS: LayoutPreset[] = [\n\t{\n\t\tid: \"default\",\n\t\tname: \"待办列表模式\",\n\t\tpanelFeatureMap: {\n\t\t\tpanelA: \"todos\",\n\t\t\tpanelB: \"chat\",\n\t\t\tpanelC: \"todoDetail\",\n\t\t},\n\t\tisPanelAOpen: true,\n\t\tisPanelBOpen: true,\n\t\tisPanelCOpen: true,\n\t\tpanelAWidth: 1 / 3, // panelA 占左边 1/4，panelC 占右边 1/4，所以 panelA 占剩余空间的 1/3 (即 0.25/0.75)\n\t\tpanelCWidth: 0.25, // panelC 占右边 1/4\n\t},\n\t{\n\t\tid: \"calendar\",\n\t\tname: \"待办日历模式\",\n\t\tpanelFeatureMap: {\n\t\t\tpanelA: \"calendar\",\n\t\t\tpanelB: \"todoDetail\",\n\t\t\tpanelC: \"chat\",\n\t\t},\n\t\tisPanelAOpen: true,\n\t\tisPanelBOpen: true,\n\t\tisPanelCOpen: true,\n\t\tpanelAWidth: 0.6, // panelA 占左边 1/2\n\t\tpanelCWidth: 0.25, // panelC 占右边 1/4\n\t},\n\t{\n\t\tid: \"lifetrace\",\n\t\tname: \"LifeTrace 模式\",\n\t\tpanelFeatureMap: {\n\t\t\tpanelA: \"activity\",\n\t\t\tpanelB: \"debugShots\",\n\t\t\tpanelC: null,\n\t\t},\n\t\tisPanelAOpen: true,\n\t\tisPanelBOpen: true,\n\t\tisPanelCOpen: false,\n\t\tpanelAWidth: 2 / 3, // 当 panelA 关闭时，这个值不影响布局\n\t\tpanelCWidth: 1 / 4,\n\t},\n];\n"
  },
  {
    "path": "free-todo-frontend/lib/store/ui-store/storage.ts",
    "content": "import { createJSONStorage } from \"zustand/middleware\";\nimport type { PanelFeature, PanelPosition } from \"@/lib/config/panel-config\";\nimport { ALL_PANEL_FEATURES } from \"@/lib/config/panel-config\";\nimport type { DockDisplayMode, LayoutPreset, UiStoreState } from \"./types\";\nimport { clampWidth, DEFAULT_PANEL_STATE, validatePanelFeatureMap } from \"./utils\";\n\ntype PersistedState = Partial<UiStoreState> & {\n\tpanelFeatureMap?: Record<PanelPosition, PanelFeature | null>;\n\tpanelPinMap?: Record<PanelPosition, boolean>;\n\tcustomLayouts?: LayoutPreset[];\n};\n\nconst VALID_POSITIONS: PanelPosition[] = [\"panelA\", \"panelB\", \"panelC\"];\nconst VALID_DOCK_MODES: DockDisplayMode[] = [\"fixed\", \"auto-hide\"];\nconst VALID_EXTERNAL_TOOL_IDS = new Set(DEFAULT_PANEL_STATE.selectedExternalTools);\n\nexport const createUiStoreStorage = () =>\n\tcreateJSONStorage<UiStoreState>(() => {\n\t\tconst customStorage = {\n\t\t\tgetItem: (name: string): string | null => {\n\t\t\t\tif (typeof window === \"undefined\") return null;\n\n\t\t\t\ttry {\n\t\t\t\t\tconst stored = localStorage.getItem(name);\n\t\t\t\t\tif (!stored) return null;\n\n\t\t\t\t\tconst parsed = JSON.parse(stored) as { state?: PersistedState };\n\t\t\t\t\tconst state = (parsed.state ?? parsed) as PersistedState;\n\n\t\t\t\t\t// 验证并修复 panelFeatureMap\n\t\t\t\t\tif (state.panelFeatureMap) {\n\t\t\t\t\t\tstate.panelFeatureMap = validatePanelFeatureMap(state.panelFeatureMap);\n\t\t\t\t\t}\n\n\t\t\t\t\t// 校验 panelPinMap\n\t\t\t\t\tconst normalizedPinMap: Record<PanelPosition, boolean> = {\n\t\t\t\t\t\t...DEFAULT_PANEL_STATE.panelPinMap,\n\t\t\t\t\t};\n\t\t\t\t\tif (state.panelPinMap && typeof state.panelPinMap === \"object\") {\n\t\t\t\t\t\tfor (const position of VALID_POSITIONS) {\n\t\t\t\t\t\t\tconst value = (state.panelPinMap as Record<string, unknown>)[\n\t\t\t\t\t\t\t\tposition\n\t\t\t\t\t\t\t];\n\t\t\t\t\t\t\tif (typeof value === \"boolean\") {\n\t\t\t\t\t\t\t\tnormalizedPinMap[position] = value;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tstate.panelPinMap = normalizedPinMap;\n\n\t\t\t\t\t// 验证宽度值\n\t\t\t\t\tif (\n\t\t\t\t\t\ttypeof state.panelAWidth === \"number\" &&\n\t\t\t\t\t\t!Number.isNaN(state.panelAWidth)\n\t\t\t\t\t) {\n\t\t\t\t\t\tstate.panelAWidth = clampWidth(state.panelAWidth);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tstate.panelAWidth = DEFAULT_PANEL_STATE.panelAWidth;\n\t\t\t\t\t}\n\n\t\t\t\t\tif (\n\t\t\t\t\t\ttypeof state.panelCWidth === \"number\" &&\n\t\t\t\t\t\t!Number.isNaN(state.panelCWidth)\n\t\t\t\t\t) {\n\t\t\t\t\t\tstate.panelCWidth = clampWidth(state.panelCWidth);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tstate.panelCWidth = DEFAULT_PANEL_STATE.panelCWidth;\n\t\t\t\t\t}\n\n\t\t\t\t\t// 验证布尔值\n\t\t\t\t\tif (typeof state.isPanelAOpen !== \"boolean\") {\n\t\t\t\t\t\tstate.isPanelAOpen = DEFAULT_PANEL_STATE.isPanelAOpen;\n\t\t\t\t\t}\n\t\t\t\t\tif (typeof state.isPanelBOpen !== \"boolean\") {\n\t\t\t\t\t\tstate.isPanelBOpen = DEFAULT_PANEL_STATE.isPanelBOpen;\n\t\t\t\t\t}\n\t\t\t\t\tif (typeof state.isPanelCOpen !== \"boolean\") {\n\t\t\t\t\t\tstate.isPanelCOpen = DEFAULT_PANEL_STATE.isPanelCOpen;\n\t\t\t\t\t}\n\n\t\t\t\t\t// 校验禁用功能列表\n\t\t\t\t\tif (Array.isArray(state.disabledFeatures)) {\n\t\t\t\t\t\tstate.disabledFeatures = state.disabledFeatures.filter(\n\t\t\t\t\t\t\t(feature: PanelFeature): feature is PanelFeature =>\n\t\t\t\t\t\t\t\tALL_PANEL_FEATURES.includes(feature),\n\t\t\t\t\t\t);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tstate.disabledFeatures = DEFAULT_PANEL_STATE.disabledFeatures;\n\t\t\t\t\t}\n\n\t\t\t\t\t// 校验后端禁用功能列表\n\t\t\t\t\tif (Array.isArray(state.backendDisabledFeatures)) {\n\t\t\t\t\t\tstate.backendDisabledFeatures = state.backendDisabledFeatures.filter(\n\t\t\t\t\t\t\t(feature: PanelFeature): feature is PanelFeature =>\n\t\t\t\t\t\t\t\tALL_PANEL_FEATURES.includes(feature),\n\t\t\t\t\t\t);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tstate.backendDisabledFeatures =\n\t\t\t\t\t\t\tDEFAULT_PANEL_STATE.backendDisabledFeatures;\n\t\t\t\t\t}\n\t\t\t\t\t// 后端能力禁用列表不持久化，启动后依赖同步结果\n\t\t\t\t\tstate.backendDisabledFeatures =\n\t\t\t\t\t\tDEFAULT_PANEL_STATE.backendDisabledFeatures;\n\n\t\t\t\t\t// 校验自动关闭的panel栈\n\t\t\t\t\tif (Array.isArray(state.autoClosedPanels)) {\n\t\t\t\t\t\tstate.autoClosedPanels = state.autoClosedPanels.filter(\n\t\t\t\t\t\t\t(pos: unknown): pos is PanelPosition =>\n\t\t\t\t\t\t\t\ttypeof pos === \"string\" &&\n\t\t\t\t\t\t\t\tVALID_POSITIONS.includes(pos as PanelPosition),\n\t\t\t\t\t\t);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tstate.autoClosedPanels = DEFAULT_PANEL_STATE.autoClosedPanels;\n\t\t\t\t\t}\n\n\t\t\t\t\t// 校验 dock 显示模式\n\t\t\t\t\tif (\n\t\t\t\t\t\t!state.dockDisplayMode ||\n\t\t\t\t\t\t!VALID_DOCK_MODES.includes(state.dockDisplayMode)\n\t\t\t\t\t) {\n\t\t\t\t\t\tstate.dockDisplayMode = DEFAULT_PANEL_STATE.dockDisplayMode;\n\t\t\t\t\t}\n\n\t\t\t\t\t// 校验 showAgnoToolSelector（默认 false）\n\t\t\t\t\tif (typeof state.showAgnoToolSelector !== \"boolean\") {\n\t\t\t\t\t\tstate.showAgnoToolSelector =\n\t\t\t\t\t\t\tDEFAULT_PANEL_STATE.showAgnoToolSelector;\n\t\t\t\t\t}\n\n\t\t\t\t\t// 校验 selectedAgnoTools（默认空数组）\n\t\t\t\t\tif (!Array.isArray(state.selectedAgnoTools)) {\n\t\t\t\t\t\tstate.selectedAgnoTools = DEFAULT_PANEL_STATE.selectedAgnoTools;\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// 确保数组中的元素都是字符串\n\t\t\t\t\t\tstate.selectedAgnoTools = state.selectedAgnoTools.filter(\n\t\t\t\t\t\t\t(tool: unknown): tool is string => typeof tool === \"string\",\n\t\t\t\t\t\t);\n\t\t\t\t\t}\n\n\t\t\t\t\t// 校验 selectedExternalTools（默认空数组）\n\t\t\t\t\tif (!Array.isArray(state.selectedExternalTools)) {\n\t\t\t\t\t\tstate.selectedExternalTools =\n\t\t\t\t\t\t\tDEFAULT_PANEL_STATE.selectedExternalTools;\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// 确保数组中的元素都是字符串\n\t\t\t\t\t\tstate.selectedExternalTools = state.selectedExternalTools.filter(\n\t\t\t\t\t\t\t(tool: unknown): tool is string =>\n\t\t\t\t\t\t\t\ttypeof tool === \"string\" && VALID_EXTERNAL_TOOL_IDS.has(tool),\n\t\t\t\t\t\t);\n\t\t\t\t\t}\n\n\t\t\t\t\t// 校验 customLayouts（默认空数组）\n\t\t\t\t\tif (Array.isArray(state.customLayouts)) {\n\t\t\t\t\t\tconst seenNames = new Set<string>();\n\t\t\t\t\t\tstate.customLayouts = state.customLayouts\n\t\t\t\t\t\t\t.map((layout: unknown): LayoutPreset | null => {\n\t\t\t\t\t\t\t\tif (!layout || typeof layout !== \"object\") return null;\n\t\t\t\t\t\t\t\tconst raw = layout as {\n\t\t\t\t\t\t\t\t\tid?: unknown;\n\t\t\t\t\t\t\t\t\tname?: unknown;\n\t\t\t\t\t\t\t\t\tpanelFeatureMap?: unknown;\n\t\t\t\t\t\t\t\t\tisPanelAOpen?: unknown;\n\t\t\t\t\t\t\t\t\tisPanelBOpen?: unknown;\n\t\t\t\t\t\t\t\t\tisPanelCOpen?: unknown;\n\t\t\t\t\t\t\t\t\tpanelAWidth?: unknown;\n\t\t\t\t\t\t\t\t\tpanelCWidth?: unknown;\n\t\t\t\t\t\t\t\t};\n\n\t\t\t\t\t\t\t\tif (typeof raw.name !== \"string\") return null;\n\t\t\t\t\t\t\t\tconst name = raw.name.trim();\n\t\t\t\t\t\t\t\tif (!name) return null;\n\t\t\t\t\t\t\t\tconst nameKey = name.toLocaleLowerCase();\n\t\t\t\t\t\t\t\tif (seenNames.has(nameKey)) return null;\n\t\t\t\t\t\t\t\tseenNames.add(nameKey);\n\n\t\t\t\t\t\t\t\tconst panelFeatureMap = raw.panelFeatureMap\n\t\t\t\t\t\t\t\t\t? validatePanelFeatureMap(\n\t\t\t\t\t\t\t\t\t\t\traw.panelFeatureMap as Record<\n\t\t\t\t\t\t\t\t\t\t\t\tPanelPosition,\n\t\t\t\t\t\t\t\t\t\t\t\tPanelFeature | null\n\t\t\t\t\t\t\t\t\t\t\t>,\n\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t: DEFAULT_PANEL_STATE.panelFeatureMap;\n\n\t\t\t\t\t\t\t\tconst panelAWidth =\n\t\t\t\t\t\t\t\t\ttypeof raw.panelAWidth === \"number\"\n\t\t\t\t\t\t\t\t\t\t? clampWidth(raw.panelAWidth)\n\t\t\t\t\t\t\t\t\t\t: DEFAULT_PANEL_STATE.panelAWidth;\n\t\t\t\t\t\t\t\tconst panelCWidth =\n\t\t\t\t\t\t\t\t\ttypeof raw.panelCWidth === \"number\"\n\t\t\t\t\t\t\t\t\t\t? clampWidth(raw.panelCWidth)\n\t\t\t\t\t\t\t\t\t\t: DEFAULT_PANEL_STATE.panelCWidth;\n\n\t\t\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\t\t\tid:\n\t\t\t\t\t\t\t\t\t\ttypeof raw.id === \"string\" && raw.id\n\t\t\t\t\t\t\t\t\t\t\t? raw.id\n\t\t\t\t\t\t\t\t\t\t\t: `custom:${encodeURIComponent(name)}`,\n\t\t\t\t\t\t\t\t\tname,\n\t\t\t\t\t\t\t\t\tpanelFeatureMap,\n\t\t\t\t\t\t\t\t\tisPanelAOpen:\n\t\t\t\t\t\t\t\t\t\ttypeof raw.isPanelAOpen === \"boolean\"\n\t\t\t\t\t\t\t\t\t\t\t? raw.isPanelAOpen\n\t\t\t\t\t\t\t\t\t\t\t: DEFAULT_PANEL_STATE.isPanelAOpen,\n\t\t\t\t\t\t\t\t\tisPanelBOpen:\n\t\t\t\t\t\t\t\t\t\ttypeof raw.isPanelBOpen === \"boolean\"\n\t\t\t\t\t\t\t\t\t\t\t? raw.isPanelBOpen\n\t\t\t\t\t\t\t\t\t\t\t: DEFAULT_PANEL_STATE.isPanelBOpen,\n\t\t\t\t\t\t\t\t\tisPanelCOpen:\n\t\t\t\t\t\t\t\t\t\ttypeof raw.isPanelCOpen === \"boolean\"\n\t\t\t\t\t\t\t\t\t\t\t? raw.isPanelCOpen\n\t\t\t\t\t\t\t\t\t\t\t: DEFAULT_PANEL_STATE.isPanelCOpen,\n\t\t\t\t\t\t\t\t\tpanelAWidth,\n\t\t\t\t\t\t\t\t\tpanelCWidth,\n\t\t\t\t\t\t\t\t};\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t.filter(\n\t\t\t\t\t\t\t\t(layout): layout is LayoutPreset => Boolean(layout),\n\t\t\t\t\t\t\t);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tstate.customLayouts = DEFAULT_PANEL_STATE.customLayouts;\n\t\t\t\t\t}\n\n\t\t\t\t\t// 如果有功能被禁用，确保对应位置不再保留\n\t\t\t\t\tfor (const position of Object.keys(\n\t\t\t\t\t\tstate.panelFeatureMap ?? {},\n\t\t\t\t\t) as PanelPosition[]) {\n\t\t\t\t\t\tconst feature = state.panelFeatureMap?.[position];\n\t\t\t\t\t\tif (\n\t\t\t\t\t\t\tfeature &&\n\t\t\t\t\t\t\t(state.disabledFeatures?.includes(feature) ||\n\t\t\t\t\t\t\t\tstate.backendDisabledFeatures?.includes(feature))\n\t\t\t\t\t\t) {\n\t\t\t\t\t\t\tif (state.panelFeatureMap) {\n\t\t\t\t\t\t\t\tstate.panelFeatureMap[position] = null;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\treturn JSON.stringify({ state });\n\t\t\t\t} catch (e) {\n\t\t\t\t\tconsole.error(\"Error loading panel config:\", e);\n\t\t\t\t\treturn null;\n\t\t\t\t}\n\t\t\t},\n\t\t\tsetItem: (name: string, value: string): void => {\n\t\t\t\tif (typeof window === \"undefined\") return;\n\n\t\t\t\ttry {\n\t\t\t\t\tlocalStorage.setItem(name, value);\n\t\t\t\t} catch (e) {\n\t\t\t\t\tconsole.error(\"Error saving panel config:\", e);\n\t\t\t\t}\n\t\t\t},\n\t\t\tremoveItem: (name: string): void => {\n\t\t\t\tif (typeof window === \"undefined\") return;\n\t\t\t\tlocalStorage.removeItem(name);\n\t\t\t},\n\t\t};\n\t\treturn customStorage;\n\t});\n"
  },
  {
    "path": "free-todo-frontend/lib/store/ui-store/store.ts",
    "content": "import { create } from \"zustand\";\nimport { persist } from \"zustand/middleware\";\nimport type { PanelFeature, PanelPosition } from \"@/lib/config/panel-config\";\nimport { ALL_PANEL_FEATURES } from \"@/lib/config/panel-config\";\nimport { createLayoutActions } from \"./layout-actions\";\nimport { createUiStoreStorage } from \"./storage\";\nimport type { UiStoreState } from \"./types\";\nimport {\n\tclampWidth,\n\tDEFAULT_PANEL_STATE,\n\tgetPositionByFeature,\n} from \"./utils\";\n\nexport const useUiStore = create<UiStoreState>()(\n\tpersist(\n\t\t(set, get) => ({\n\t\t\t// 位置槽位初始状态\n\t\t\tisPanelAOpen: DEFAULT_PANEL_STATE.isPanelAOpen,\n\t\t\tisPanelBOpen: DEFAULT_PANEL_STATE.isPanelBOpen,\n\t\t\tisPanelCOpen: DEFAULT_PANEL_STATE.isPanelCOpen,\n\t\t\tpanelAWidth: DEFAULT_PANEL_STATE.panelAWidth,\n\t\t\tpanelCWidth: DEFAULT_PANEL_STATE.panelCWidth,\n\t\t\t// 动态功能分配初始状态：默认分配\n\t\t\tpanelFeatureMap: DEFAULT_PANEL_STATE.panelFeatureMap,\n\t\t\tpanelPinMap: DEFAULT_PANEL_STATE.panelPinMap,\n\t\t\t// 默认没有禁用的功能\n\t\t\tdisabledFeatures: DEFAULT_PANEL_STATE.disabledFeatures,\n\t\t\tbackendDisabledFeatures: DEFAULT_PANEL_STATE.backendDisabledFeatures,\n\t\t\t// 自动关闭的panel栈\n\t\t\tautoClosedPanels: DEFAULT_PANEL_STATE.autoClosedPanels,\n\t\t\t// Dock 显示模式\n\t\t\tdockDisplayMode: DEFAULT_PANEL_STATE.dockDisplayMode,\n\t\t\t// 是否显示 Agno 工具选择器\n\t\t\tshowAgnoToolSelector: DEFAULT_PANEL_STATE.showAgnoToolSelector,\n\t\t\t// Agno 模式下选中的 FreeTodo 工具\n\t\t\tselectedAgnoTools: DEFAULT_PANEL_STATE.selectedAgnoTools,\n\t\t\t// Agno 模式下选中的外部工具\n\t\t\tselectedExternalTools: DEFAULT_PANEL_STATE.selectedExternalTools,\n\t\t\t// 用户自定义布局\n\t\t\tcustomLayouts: DEFAULT_PANEL_STATE.customLayouts,\n\n\t\t\t// 位置槽位 toggle 方法\n\t\t\ttogglePanelA: () =>\n\t\t\t\tset((state) => {\n\t\t\t\t\tconst newIsOpen = !state.isPanelAOpen;\n\t\t\t\t\t// 如果用户手动关闭panel，从自动关闭栈中移除\n\t\t\t\t\t// 如果用户手动打开panel，清空自动关闭栈（用户意图改变了布局）\n\t\t\t\t\tconst newAutoClosedPanels = newIsOpen\n\t\t\t\t\t\t? []\n\t\t\t\t\t\t: state.autoClosedPanels.filter((pos) => pos !== \"panelA\");\n\t\t\t\t\treturn {\n\t\t\t\t\t\tisPanelAOpen: newIsOpen,\n\t\t\t\t\t\tautoClosedPanels: newAutoClosedPanels,\n\t\t\t\t\t};\n\t\t\t\t}),\n\n\t\t\ttogglePanelB: () =>\n\t\t\t\tset((state) => {\n\t\t\t\t\tconst newIsOpen = !state.isPanelBOpen;\n\t\t\t\t\tconst newAutoClosedPanels = newIsOpen\n\t\t\t\t\t\t? []\n\t\t\t\t\t\t: state.autoClosedPanels.filter((pos) => pos !== \"panelB\");\n\t\t\t\t\treturn {\n\t\t\t\t\t\tisPanelBOpen: newIsOpen,\n\t\t\t\t\t\tautoClosedPanels: newAutoClosedPanels,\n\t\t\t\t\t};\n\t\t\t\t}),\n\n\t\t\ttogglePanelC: () =>\n\t\t\t\tset((state) => {\n\t\t\t\t\tconst newIsOpen = !state.isPanelCOpen;\n\t\t\t\t\tconst newAutoClosedPanels = newIsOpen\n\t\t\t\t\t\t? []\n\t\t\t\t\t\t: state.autoClosedPanels.filter((pos) => pos !== \"panelC\");\n\t\t\t\t\treturn {\n\t\t\t\t\t\tisPanelCOpen: newIsOpen,\n\t\t\t\t\t\tautoClosedPanels: newAutoClosedPanels,\n\t\t\t\t\t};\n\t\t\t\t}),\n\n\t\t\t// 位置槽位宽度设置方法\n\t\t\tsetPanelAWidth: (width: number) =>\n\t\t\t\tset((state) => {\n\t\t\t\t\tif (\n\t\t\t\t\t\t!state.isPanelAOpen ||\n\t\t\t\t\t\t(!state.isPanelBOpen && !state.isPanelCOpen)\n\t\t\t\t\t) {\n\t\t\t\t\t\treturn state;\n\t\t\t\t\t}\n\n\t\t\t\t\treturn {\n\t\t\t\t\t\tpanelAWidth: clampWidth(width),\n\t\t\t\t\t};\n\t\t\t\t}),\n\n\t\t\tsetPanelCWidth: (width: number) =>\n\t\t\t\tset((state) => {\n\t\t\t\t\t// 允许在 panelC 打开且至少有一个左侧面板（A 或 B）打开时调整宽度\n\t\t\t\t\tif (\n\t\t\t\t\t\t!state.isPanelCOpen ||\n\t\t\t\t\t\t(!state.isPanelAOpen && !state.isPanelBOpen)\n\t\t\t\t\t) {\n\t\t\t\t\t\treturn state;\n\t\t\t\t\t}\n\n\t\t\t\t\treturn {\n\t\t\t\t\t\tpanelCWidth: clampWidth(width),\n\t\t\t\t\t};\n\t\t\t\t}),\n\n\t\t\t// 动态功能分配方法\n\t\t\tsetPanelFeature: (position, feature) =>\n\t\t\t\tset((state) => {\n\t\t\t\t\t// 禁用的功能不允许分配\n\t\t\t\t\tif (\n\t\t\t\t\t\tstate.disabledFeatures.includes(feature) ||\n\t\t\t\t\t\tstate.backendDisabledFeatures.includes(feature)\n\t\t\t\t\t) {\n\t\t\t\t\t\treturn state;\n\t\t\t\t\t}\n\t\t\t\t\t// 固定面板不允许替换\n\t\t\t\t\tconst currentMap = { ...state.panelFeatureMap };\n\t\t\t\t\tconst currentFeature = currentMap[position];\n\t\t\t\t\tif (\n\t\t\t\t\t\tstate.panelPinMap[position] &&\n\t\t\t\t\t\tcurrentFeature !== feature\n\t\t\t\t\t) {\n\t\t\t\t\t\treturn state;\n\t\t\t\t\t}\n\n\t\t\t\t\t// 如果该功能已经在其他位置，先清除那个位置的分配\n\t\t\t\t\tfor (const [pos, assignedFeature] of Object.entries(currentMap) as [\n\t\t\t\t\t\tPanelPosition,\n\t\t\t\t\t\tPanelFeature | null,\n\t\t\t\t\t][]) {\n\t\t\t\t\t\tif (assignedFeature === feature && pos !== position) {\n\t\t\t\t\t\t\tif (state.panelPinMap[pos]) {\n\t\t\t\t\t\t\t\treturn state;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tcurrentMap[pos] = null;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\t// 设置新位置的功能\n\t\t\t\t\tcurrentMap[position] = feature;\n\t\t\t\t\treturn { panelFeatureMap: currentMap };\n\t\t\t\t}),\n\n\t\t\tgetFeatureByPosition: (position) => {\n\t\t\t\tconst state = get();\n\t\t\t\tconst feature = state.panelFeatureMap[position];\n\t\t\t\tif (!feature) return null;\n\t\t\t\tif (state.disabledFeatures.includes(feature)) return null;\n\t\t\t\tif (state.backendDisabledFeatures.includes(feature)) return null;\n\t\t\t\treturn feature;\n\t\t\t},\n\n\t\t\tgetAvailableFeatures: () => {\n\t\t\t\tconst state = get();\n\t\t\t\tconst disabledSet = new Set([\n\t\t\t\t\t...state.disabledFeatures,\n\t\t\t\t\t...state.backendDisabledFeatures,\n\t\t\t\t]);\n\t\t\t\tconst assignedFeatures = Object.values(state.panelFeatureMap).filter(\n\t\t\t\t\t(f): f is PanelFeature => f !== null,\n\t\t\t\t);\n\t\t\t\treturn ALL_PANEL_FEATURES.filter(\n\t\t\t\t\t(feature) =>\n\t\t\t\t\t\t!assignedFeatures.includes(feature) && !disabledSet.has(feature),\n\t\t\t\t);\n\t\t\t},\n\n\t\t\tsetFeatureEnabled: (feature, enabled) =>\n\t\t\t\tset((state) => {\n\t\t\t\t\tconst disabledFeatures = new Set(state.disabledFeatures);\n\t\t\t\t\tconst panelFeatureMap = { ...state.panelFeatureMap };\n\n\t\t\t\t\tif (!enabled) {\n\t\t\t\t\t\tdisabledFeatures.add(feature);\n\t\t\t\t\t\t// 移除已分配到任何面板的禁用功能\n\t\t\t\t\t\tfor (const position of Object.keys(\n\t\t\t\t\t\t\tpanelFeatureMap,\n\t\t\t\t\t\t) as PanelPosition[]) {\n\t\t\t\t\t\t\tif (panelFeatureMap[position] === feature) {\n\t\t\t\t\t\t\t\tpanelFeatureMap[position] = null;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t} else {\n\t\t\t\t\t\tif (!state.backendDisabledFeatures.includes(feature)) {\n\t\t\t\t\t\t\tdisabledFeatures.delete(feature);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\treturn {\n\t\t\t\t\t\tdisabledFeatures: Array.from(disabledFeatures),\n\t\t\t\t\t\tpanelFeatureMap,\n\t\t\t\t\t};\n\t\t\t\t}),\n\n\t\t\tisFeatureEnabled: (feature) => {\n\t\t\t\tconst state = get();\n\t\t\t\treturn (\n\t\t\t\t\t!state.disabledFeatures.includes(feature) &&\n\t\t\t\t\t!state.backendDisabledFeatures.includes(feature)\n\t\t\t\t);\n\t\t\t},\n\n\t\t\tsetPanelPinned: (position, pinned) =>\n\t\t\t\tset((state) => ({\n\t\t\t\t\tpanelPinMap: {\n\t\t\t\t\t\t...state.panelPinMap,\n\t\t\t\t\t\t[position]: pinned,\n\t\t\t\t\t},\n\t\t\t\t})),\n\n\t\t\ttogglePanelPinned: (position) =>\n\t\t\t\tset((state) => ({\n\t\t\t\t\tpanelPinMap: {\n\t\t\t\t\t\t...state.panelPinMap,\n\t\t\t\t\t\t[position]: !state.panelPinMap[position],\n\t\t\t\t\t},\n\t\t\t\t})),\n\n\t\t\t// 兼容性方法：基于功能的访问\n\t\t\tgetIsFeatureOpen: (feature) => {\n\t\t\t\tconst position = getPositionByFeature(feature, get().panelFeatureMap);\n\t\t\t\tconst state = get();\n\t\t\t\tif (\n\t\t\t\t\t!position ||\n\t\t\t\t\tstate.disabledFeatures.includes(feature) ||\n\t\t\t\t\tstate.backendDisabledFeatures.includes(feature)\n\t\t\t\t) {\n\t\t\t\t\treturn false;\n\t\t\t\t}\n\t\t\t\tswitch (position) {\n\t\t\t\t\tcase \"panelA\":\n\t\t\t\t\t\treturn state.isPanelAOpen;\n\t\t\t\t\tcase \"panelB\":\n\t\t\t\t\t\treturn state.isPanelBOpen;\n\t\t\t\t\tcase \"panelC\":\n\t\t\t\t\t\treturn state.isPanelCOpen;\n\t\t\t\t}\n\t\t\t},\n\n\t\t\ttoggleFeature: (feature) => {\n\t\t\t\tconst position = getPositionByFeature(feature, get().panelFeatureMap);\n\t\t\t\tif (!position) return;\n\t\t\t\tconst state = get();\n\t\t\t\tswitch (position) {\n\t\t\t\t\tcase \"panelA\":\n\t\t\t\t\t\tstate.togglePanelA();\n\t\t\t\t\t\tbreak;\n\t\t\t\t\tcase \"panelB\":\n\t\t\t\t\t\tstate.togglePanelB();\n\t\t\t\t\t\tbreak;\n\t\t\t\t\tcase \"panelC\":\n\t\t\t\t\t\tstate.togglePanelC();\n\t\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t},\n\n\t\t\tgetFeatureWidth: (feature) => {\n\t\t\t\tconst position = getPositionByFeature(feature, get().panelFeatureMap);\n\t\t\t\tif (!position) return 0;\n\t\t\t\tconst state = get();\n\t\t\t\tswitch (position) {\n\t\t\t\t\tcase \"panelA\":\n\t\t\t\t\t\treturn state.panelAWidth;\n\t\t\t\t\tcase \"panelB\":\n\t\t\t\t\t\t// panelB 的宽度是计算值：1 - panelAWidth\n\t\t\t\t\t\treturn 1 - state.panelAWidth;\n\t\t\t\t\tcase \"panelC\":\n\t\t\t\t\t\treturn state.panelCWidth;\n\t\t\t\t}\n\t\t\t},\n\n\t\t\tsetFeatureWidth: (feature, width) => {\n\t\t\t\tconst position = getPositionByFeature(feature, get().panelFeatureMap);\n\t\t\t\tif (!position) return;\n\t\t\t\tconst state = get();\n\t\t\t\tswitch (position) {\n\t\t\t\t\tcase \"panelA\":\n\t\t\t\t\t\tstate.setPanelAWidth(width);\n\t\t\t\t\t\tbreak;\n\t\t\t\t\tcase \"panelB\":\n\t\t\t\t\t\t// panelB 的宽度通过设置 panelA 的宽度来间接设置\n\t\t\t\t\t\t// 如果设置 panelB 的宽度为 w，则 panelA 的宽度应该是 1 - w\n\t\t\t\t\t\tstate.setPanelAWidth(1 - width);\n\t\t\t\t\t\tbreak;\n\t\t\t\t\tcase \"panelC\":\n\t\t\t\t\t\tstate.setPanelCWidth(width);\n\t\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t},\n\n\t\t\t...createLayoutActions(set, get),\n\n\t\t\tswapPanelPositions: (position1, position2) => {\n\t\t\t\tset((state) => {\n\t\t\t\t\t// 如果两个位置相同，不需要交换\n\t\t\t\t\tif (position1 === position2) return state;\n\t\t\t\t\tif (state.panelPinMap[position1] || state.panelPinMap[position2]) {\n\t\t\t\t\t\treturn state;\n\t\t\t\t\t}\n\n\t\t\t\t\tconst newMap = { ...state.panelFeatureMap };\n\t\t\t\t\t// 交换两个位置的功能\n\t\t\t\t\tconst feature1 = newMap[position1];\n\t\t\t\t\tconst feature2 = newMap[position2];\n\t\t\t\t\tnewMap[position1] = feature2;\n\t\t\t\t\tnewMap[position2] = feature1;\n\n\t\t\t\t\t// 获取两个位置的当前激活状态\n\t\t\t\t\tconst getIsOpen = (pos: PanelPosition): boolean => {\n\t\t\t\t\t\tswitch (pos) {\n\t\t\t\t\t\t\tcase \"panelA\":\n\t\t\t\t\t\t\t\treturn state.isPanelAOpen;\n\t\t\t\t\t\t\tcase \"panelB\":\n\t\t\t\t\t\t\t\treturn state.isPanelBOpen;\n\t\t\t\t\t\t\tcase \"panelC\":\n\t\t\t\t\t\t\t\treturn state.isPanelCOpen;\n\t\t\t\t\t\t}\n\t\t\t\t\t};\n\n\t\t\t\t\tconst isOpen1 = getIsOpen(position1);\n\t\t\t\t\tconst isOpen2 = getIsOpen(position2);\n\n\t\t\t\t\t// 构建更新对象，同时交换功能映射和激活状态\n\t\t\t\t\tconst updates: Partial<UiStoreState> = {\n\t\t\t\t\t\tpanelFeatureMap: newMap,\n\t\t\t\t\t};\n\n\t\t\t\t\t// 交换激活状态：将 position1 的激活状态设置为 position2 的，反之亦然\n\t\t\t\t\tconst setPanelOpen = (\n\t\t\t\t\t\tpos: PanelPosition,\n\t\t\t\t\t\tisOpen: boolean,\n\t\t\t\t\t) => {\n\t\t\t\t\t\tswitch (pos) {\n\t\t\t\t\t\t\tcase \"panelA\":\n\t\t\t\t\t\t\t\tupdates.isPanelAOpen = isOpen;\n\t\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t\tcase \"panelB\":\n\t\t\t\t\t\t\t\tupdates.isPanelBOpen = isOpen;\n\t\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t\tcase \"panelC\":\n\t\t\t\t\t\t\t\tupdates.isPanelCOpen = isOpen;\n\t\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t}\n\t\t\t\t\t};\n\n\t\t\t\t\tsetPanelOpen(position1, isOpen2);\n\t\t\t\t\tsetPanelOpen(position2, isOpen1);\n\n\t\t\t\t\treturn updates;\n\t\t\t\t});\n\t\t\t},\n\n\t\t\t// 自动关闭panel管理方法\n\t\t\tsetAutoClosePanel: (position) =>\n\t\t\t\tset((state) => {\n\t\t\t\t\t// 如果panel已经在栈中，不重复添加\n\t\t\t\t\tif (state.autoClosedPanels.includes(position)) {\n\t\t\t\t\t\treturn state;\n\t\t\t\t\t}\n\t\t\t\t\t// 关闭panel并推入栈\n\t\t\t\t\tconst newAutoClosedPanels = [...state.autoClosedPanels, position];\n\t\t\t\t\tconst updates: Partial<UiStoreState> = {\n\t\t\t\t\t\tautoClosedPanels: newAutoClosedPanels,\n\t\t\t\t\t};\n\t\t\t\t\tswitch (position) {\n\t\t\t\t\t\tcase \"panelA\":\n\t\t\t\t\t\t\tupdates.isPanelAOpen = false;\n\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\tcase \"panelB\":\n\t\t\t\t\t\t\tupdates.isPanelBOpen = false;\n\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\tcase \"panelC\":\n\t\t\t\t\t\t\tupdates.isPanelCOpen = false;\n\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t}\n\t\t\t\t\treturn updates;\n\t\t\t\t}),\n\n\t\t\trestoreAutoClosedPanel: () =>\n\t\t\t\tset((state) => {\n\t\t\t\t\t// 如果栈为空，不执行任何操作\n\t\t\t\t\tif (state.autoClosedPanels.length === 0) {\n\t\t\t\t\t\treturn state;\n\t\t\t\t\t}\n\t\t\t\t\t// 从栈顶弹出最近关闭的panel\n\t\t\t\t\tconst newAutoClosedPanels = [...state.autoClosedPanels];\n\t\t\t\t\tconst positionToRestore = newAutoClosedPanels.pop();\n\t\t\t\t\t// 如果pop返回undefined，不执行任何操作\n\t\t\t\t\tif (!positionToRestore) {\n\t\t\t\t\t\treturn state;\n\t\t\t\t\t}\n\t\t\t\t\tconst updates: Partial<UiStoreState> = {\n\t\t\t\t\t\tautoClosedPanels: newAutoClosedPanels,\n\t\t\t\t\t};\n\t\t\t\t\t// 恢复panel\n\t\t\t\t\tswitch (positionToRestore) {\n\t\t\t\t\t\tcase \"panelA\":\n\t\t\t\t\t\t\tupdates.isPanelAOpen = true;\n\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\tcase \"panelB\":\n\t\t\t\t\t\t\tupdates.isPanelBOpen = true;\n\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\tcase \"panelC\":\n\t\t\t\t\t\t\tupdates.isPanelCOpen = true;\n\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t}\n\t\t\t\t\treturn updates;\n\t\t\t\t}),\n\n\t\t\tclearAutoClosedPanels: () =>\n\t\t\t\tset(() => ({\n\t\t\t\t\tautoClosedPanels: [],\n\t\t\t\t})),\n\n\t\t\t// Dock 显示模式设置方法\n\t\t\tsetDockDisplayMode: (mode) =>\n\t\t\t\tset(() => ({\n\t\t\t\t\tdockDisplayMode: mode,\n\t\t\t\t})),\n\n\t\t\t// 设置是否显示 Agno 工具选择器\n\t\t\tsetShowAgnoToolSelector: (show) =>\n\t\t\t\tset(() => ({\n\t\t\t\t\tshowAgnoToolSelector: show,\n\t\t\t\t})),\n\n\t\t\t// 设置 Agno 模式下选中的 FreeTodo 工具\n\t\t\tsetSelectedAgnoTools: (tools) =>\n\t\t\t\tset(() => ({\n\t\t\t\t\tselectedAgnoTools: tools,\n\t\t\t\t})),\n\n\t\t\t// 设置 Agno 模式下选中的外部工具\n\t\t\tsetSelectedExternalTools: (tools) =>\n\t\t\t\tset(() => ({\n\t\t\t\t\tselectedExternalTools: tools,\n\t\t\t\t})),\n\n\t\t\tsetBackendDisabledFeatures: (features) =>\n\t\t\t\tset((state) => {\n\t\t\t\t\tconst sanitized = features.filter((feature) =>\n\t\t\t\t\t\tALL_PANEL_FEATURES.includes(feature),\n\t\t\t\t\t);\n\t\t\t\t\tconst panelFeatureMap = { ...state.panelFeatureMap };\n\n\t\t\t\t\tfor (const position of Object.keys(\n\t\t\t\t\t\tpanelFeatureMap,\n\t\t\t\t\t) as PanelPosition[]) {\n\t\t\t\t\t\tconst feature = panelFeatureMap[position];\n\t\t\t\t\t\tif (feature && sanitized.includes(feature)) {\n\t\t\t\t\t\t\tpanelFeatureMap[position] = null;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\treturn {\n\t\t\t\t\t\tbackendDisabledFeatures: sanitized,\n\t\t\t\t\t\tpanelFeatureMap,\n\t\t\t\t\t};\n\t\t\t\t}),\n\t\t}),\n\t\t{\n\t\t\tname: \"ui-panel-config\",\n\t\t\tstorage: createUiStoreStorage(),\n\t\t},\n\t),\n);\n"
  },
  {
    "path": "free-todo-frontend/lib/store/ui-store/types.ts",
    "content": "import type { PanelFeature, PanelPosition } from \"@/lib/config/panel-config\";\n\n// Dock 显示模式类型\nexport type DockDisplayMode = \"fixed\" | \"auto-hide\";\n\n// 布局预设类型\nexport interface LayoutPreset {\n\tid: string;\n\tname: string;\n\tpanelFeatureMap: Record<PanelPosition, PanelFeature | null>;\n\tisPanelAOpen: boolean;\n\tisPanelBOpen: boolean;\n\tisPanelCOpen: boolean;\n\tpanelAWidth?: number;\n\tpanelCWidth?: number;\n}\n\n// UI Store 状态接口\nexport interface UiStoreState {\n\t// 位置槽位状态\n\tisPanelAOpen: boolean;\n\tisPanelBOpen: boolean;\n\tisPanelCOpen: boolean;\n\t// 位置槽位宽度\n\tpanelAWidth: number;\n\tpanelCWidth: number;\n\t// panelBWidth 是计算值，不需要单独存储\n\t// 动态功能分配映射：每个位置当前显示的功能\n\tpanelFeatureMap: Record<PanelPosition, PanelFeature | null>;\n\t// 面板是否固定（固定面板不会被替换）\n\tpanelPinMap: Record<PanelPosition, boolean>;\n\t// 被禁用的功能列表\n\tdisabledFeatures: PanelFeature[];\n\t// 后端能力不足导致的禁用功能列表\n\tbackendDisabledFeatures: PanelFeature[];\n\t// 自动关闭的panel栈（记录因窗口缩小而自动关闭的panel，从右到左的顺序）\n\tautoClosedPanels: PanelPosition[];\n\t// Dock 显示模式：固定显示或鼠标离开时自动隐藏\n\tdockDisplayMode: DockDisplayMode;\n\t// 是否显示 Agno 模式的工具选择器（默认关闭）\n\tshowAgnoToolSelector: boolean;\n\t// Agno 模式下选中的 FreeTodo 工具列表（空数组表示不使用任何工具）\n\tselectedAgnoTools: string[];\n\t// Agno 模式下选中的外部工具列表（如 ['duckduckgo']）\n\tselectedExternalTools: string[];\n\t// 位置槽位 toggle 方法\n\ttogglePanelA: () => void;\n\ttogglePanelB: () => void;\n\ttogglePanelC: () => void;\n\t// 位置槽位宽度设置方法\n\tsetPanelAWidth: (width: number) => void;\n\tsetPanelCWidth: (width: number) => void;\n\t// panelBWidth 是计算值，不需要单独设置方法\n\t// 动态功能分配方法\n\tsetPanelFeature: (position: PanelPosition, feature: PanelFeature) => void;\n\tgetFeatureByPosition: (position: PanelPosition) => PanelFeature | null;\n\tgetAvailableFeatures: () => PanelFeature[];\n\tsetFeatureEnabled: (feature: PanelFeature, enabled: boolean) => void;\n\tsetBackendDisabledFeatures: (features: PanelFeature[]) => void;\n\tisFeatureEnabled: (feature: PanelFeature) => boolean;\n\t// 面板固定设置\n\tsetPanelPinned: (position: PanelPosition, pinned: boolean) => void;\n\ttogglePanelPinned: (position: PanelPosition) => void;\n\t// 兼容性方法：为了保持向后兼容，保留基于功能的访问方法\n\t// 这些方法内部会通过动态映射查找位置\n\tgetIsFeatureOpen: (feature: PanelFeature) => boolean;\n\ttoggleFeature: (feature: PanelFeature) => void;\n\tgetFeatureWidth: (feature: PanelFeature) => number;\n\tsetFeatureWidth: (feature: PanelFeature, width: number) => void;\n\t// 应用预设布局\n\tapplyLayout: (layoutId: string) => void;\n\t// 交换两个面板的位置（功能分配）\n\tswapPanelPositions: (\n\t\tposition1: PanelPosition,\n\t\tposition2: PanelPosition,\n\t) => void;\n\t// 用户自定义布局\n\tcustomLayouts: LayoutPreset[];\n\tsaveCustomLayout: (name: string, options?: { overwrite?: boolean }) => boolean;\n\trenameCustomLayout: (\n\t\tlayoutId: string,\n\t\tname: string,\n\t\toptions?: { overwrite?: boolean },\n\t) => boolean;\n\tdeleteCustomLayout: (layoutId: string) => void;\n\t// 自动关闭panel管理方法\n\tsetAutoClosePanel: (position: PanelPosition) => void;\n\trestoreAutoClosedPanel: () => void;\n\tclearAutoClosedPanels: () => void;\n\t// Dock 显示模式设置方法\n\tsetDockDisplayMode: (mode: DockDisplayMode) => void;\n\t// 设置是否显示 Agno 工具选择器\n\tsetShowAgnoToolSelector: (show: boolean) => void;\n\t// 设置 Agno 模式下选中的 FreeTodo 工具\n\tsetSelectedAgnoTools: (tools: string[]) => void;\n\t// 设置 Agno 模式下选中的外部工具\n\tsetSelectedExternalTools: (tools: string[]) => void;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/store/ui-store/utils.ts",
    "content": "import type { PanelFeature, PanelPosition } from \"@/lib/config/panel-config\";\nimport {\n\tALL_PANEL_FEATURES,\n\tDEV_IN_PROGRESS_FEATURES,\n} from \"@/lib/config/panel-config\";\nimport type { DockDisplayMode } from \"./types\";\n\n// 宽度限制常量\nexport const MIN_PANEL_WIDTH = 0.2;\nexport const MAX_PANEL_WIDTH = 0.8;\n\n/**\n * 限制宽度在有效范围内\n */\nexport function clampWidth(width: number): number {\n\tif (Number.isNaN(width)) return 0.5;\n\tif (width < MIN_PANEL_WIDTH) return MIN_PANEL_WIDTH;\n\tif (width > MAX_PANEL_WIDTH) return MAX_PANEL_WIDTH;\n\treturn width;\n}\n\n/**\n * 根据功能查找其所在的位置\n */\nexport function getPositionByFeature(\n\tfeature: PanelFeature,\n\tpanelFeatureMap: Record<PanelPosition, PanelFeature | null>,\n): PanelPosition | null {\n\tfor (const [position, assignedFeature] of Object.entries(panelFeatureMap) as [\n\t\tPanelPosition,\n\t\tPanelFeature | null,\n\t][]) {\n\t\tif (assignedFeature === feature) {\n\t\t\treturn position;\n\t\t}\n\t}\n\treturn null;\n}\n\n// Panel 配置的默认值\nexport const DEFAULT_PANEL_STATE = {\n\tisPanelAOpen: true,\n\tisPanelBOpen: true,\n\tisPanelCOpen: true,\n\tpanelAWidth: 1 / 3, // panelA 占左边 1/4，panelC 占右边 1/4，所以 panelA 占剩余空间的 1/3 (即 0.25/0.75)\n\tpanelCWidth: 0.25, // panelC 占右边 1/4\n\t// 默认关闭的功能：开发中的面板（用户可在设置中手动开启）\n\tdisabledFeatures: DEV_IN_PROGRESS_FEATURES as PanelFeature[],\n\tbackendDisabledFeatures: [] as PanelFeature[],\n\tpanelFeatureMap: {\n\t\tpanelA: \"todos\" as PanelFeature,\n\t\tpanelB: \"chat\" as PanelFeature,\n\t\tpanelC: \"todoDetail\" as PanelFeature,\n\t},\n\tpanelPinMap: {\n\t\tpanelA: false,\n\t\tpanelB: false,\n\t\tpanelC: false,\n\t},\n\tautoClosedPanels: [] as PanelPosition[],\n\tdockDisplayMode: \"fixed\" as DockDisplayMode,\n\t// 是否显示 Agno 模式的工具选择器（默认开启）\n\tshowAgnoToolSelector: true,\n\t// Agno 模式下选中的 FreeTodo 工具列表（默认只选中 todo 管理类工具）\n\tselectedAgnoTools: [\n\t\t\"create_todo\",\n\t\t\"complete_todo\",\n\t\t\"update_todo\",\n\t\t\"list_todos\",\n\t\t\"search_todos\",\n\t\t\"delete_todo\",\n\t] as string[],\n\t// Agno 模式下选中的外部工具列表（默认全部选中）\n\tselectedExternalTools: [\n\t\t\"websearch\",\n\t\t\"hackernews\",\n\t\t\"file\",\n\t\t\"local_fs\",\n\t\t\"shell\",\n\t\t\"sleep\",\n\t] as string[],\n\tcustomLayouts: [],\n};\n\n/**\n * 验证 panelFeatureMap 的有效性\n */\nexport function validatePanelFeatureMap(\n\tmap: Record<PanelPosition, PanelFeature | null>,\n): Record<PanelPosition, PanelFeature | null> {\n\tconst validated: Record<PanelPosition, PanelFeature | null> = {\n\t\tpanelA: null,\n\t\tpanelB: null,\n\t\tpanelC: null,\n\t};\n\n\tfor (const [position, feature] of Object.entries(map) as [\n\t\tPanelPosition,\n\t\tPanelFeature | null,\n\t][]) {\n\t\tif (feature && ALL_PANEL_FEATURES.includes(feature)) {\n\t\t\tvalidated[position] = feature;\n\t\t}\n\t}\n\n\t// 如果验证后所有位置都是 null，使用默认值\n\tif (\n\t\tvalidated.panelA === null &&\n\t\tvalidated.panelB === null &&\n\t\tvalidated.panelC === null\n\t) {\n\t\treturn DEFAULT_PANEL_STATE.panelFeatureMap;\n\t}\n\n\treturn validated;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/store/ui-store.ts",
    "content": "// 为保持向后兼容，从拆分后的模块重新导出所有内容\nexport * from \"./ui-store/index\";\n"
  },
  {
    "path": "free-todo-frontend/lib/toast.ts",
    "content": "/**\n * 简单的Toast通知工具\n * 使用浏览器原生API实现简单的通知功能\n */\n\nexport type ToastType = \"success\" | \"error\" | \"info\" | \"warning\";\n\nexport interface ToastOptions {\n\tduration?: number;\n\ttype?: ToastType;\n}\n\nlet toastContainer: HTMLDivElement | null = null;\n\nfunction getToastContainer(): HTMLDivElement {\n\tif (!toastContainer && typeof document !== \"undefined\") {\n\t\ttoastContainer = document.createElement(\"div\");\n\t\ttoastContainer.id = \"toast-container\";\n\t\ttoastContainer.className =\n\t\t\t\"fixed top-4 right-4 z-[9999] flex flex-col gap-2 pointer-events-none\";\n\t\tdocument.body.appendChild(toastContainer);\n\t}\n\tif (!toastContainer) {\n\t\tthrow new Error(\"Toast container not available\");\n\t}\n\treturn toastContainer;\n}\n\nfunction createToastElement(message: string, type: ToastType): HTMLDivElement {\n\tconst toast = document.createElement(\"div\");\n\ttoast.className = `pointer-events-auto rounded-lg border px-4 py-3 shadow-lg transition-all animate-in slide-in-from-top-2 ${\n\t\ttype === \"success\"\n\t\t\t? \"bg-green-50 border-green-200 text-green-800 dark:bg-green-950 dark:border-green-800 dark:text-green-200\"\n\t\t\t: type === \"error\"\n\t\t\t\t? \"bg-red-50 border-red-200 text-red-800 dark:bg-red-950 dark:border-red-800 dark:text-red-200\"\n\t\t\t\t: type === \"warning\"\n\t\t\t\t\t? \"bg-yellow-50 border-yellow-200 text-yellow-800 dark:bg-yellow-950 dark:border-yellow-800 dark:text-yellow-200\"\n\t\t\t\t\t: \"bg-primary/10 border-primary/30 text-primary dark:bg-primary/20 dark:border-primary/40 dark:text-primary\"\n\t}`;\n\ttoast.textContent = message;\n\treturn toast;\n}\n\nexport function toast(message: string, options: ToastOptions = {}): void {\n\tif (typeof document === \"undefined\") {\n\t\tconsole.log(`[Toast ${options.type || \"info\"}]: ${message}`);\n\t\treturn;\n\t}\n\n\tconst { duration = 3000, type = \"info\" } = options;\n\tconst container = getToastContainer();\n\tconst toastElement = createToastElement(message, type);\n\n\tcontainer.appendChild(toastElement);\n\n\tsetTimeout(() => {\n\t\ttoastElement.style.opacity = \"0\";\n\t\ttoastElement.style.transform = \"translateY(-10px)\";\n\t\tsetTimeout(() => {\n\t\t\tif (toastElement.parentNode) {\n\t\t\t\ttoastElement.parentNode.removeChild(toastElement);\n\t\t\t}\n\t\t}, 200);\n\t}, duration);\n}\n\nexport const toastSuccess = (\n\tmessage: string,\n\toptions?: Omit<ToastOptions, \"type\">,\n) => toast(message, { ...options, type: \"success\" });\nexport const toastError = (\n\tmessage: string,\n\toptions?: Omit<ToastOptions, \"type\">,\n) => toast(message, { ...options, type: \"error\" });\nexport const toastInfo = (\n\tmessage: string,\n\toptions?: Omit<ToastOptions, \"type\">,\n) => toast(message, { ...options, type: \"info\" });\nexport const toastWarning = (\n\tmessage: string,\n\toptions?: Omit<ToastOptions, \"type\">,\n) => toast(message, { ...options, type: \"warning\" });\n"
  },
  {
    "path": "free-todo-frontend/lib/types/index.ts",
    "content": "/**\n * Unified frontend types (camelCase)\n * These types match the auto-transformed API response structure from customFetcher.\n * The fetcher automatically converts snake_case (API) -> camelCase (frontend).\n */\n\n// ============================================================================\n// Todo Types\n// ============================================================================\n\nexport type TodoStatus = \"active\" | \"completed\" | \"canceled\" | \"draft\";\nexport type TodoPriority = \"high\" | \"medium\" | \"low\" | \"none\";\n\nexport interface TodoAttachment {\n\tid: number;\n\tfileName: string;\n\tfilePath: string;\n\tfileSize?: number;\n\tmimeType?: string;\n\tsource?: \"user\" | \"ai\";\n}\n\nexport interface Todo {\n\tid: number;\n\tname: string;\n\tsummary?: string;\n\tdescription?: string;\n\tuserNotes?: string;\n\tparentTodoId?: number | null;\n\titemType?: string;\n\tlocation?: string;\n\tcategories?: string;\n\tclassification?: string;\n\tdeadline?: string;\n\tstartTime?: string;\n\tendTime?: string;\n\tdtstart?: string;\n\tdtend?: string;\n\tdue?: string;\n\tduration?: string;\n\ttimeZone?: string;\n\ttzid?: string;\n\tisAllDay?: boolean;\n\tdtstamp?: string;\n\tcreated?: string;\n\tlastModified?: string;\n\tsequence?: number;\n\trdate?: string;\n\texdate?: string;\n\trecurrenceId?: string;\n\trelatedToUid?: string;\n\trelatedToReltype?: string;\n\ticalStatus?: string;\n\treminderOffsets?: number[] | null;\n\trrule?: string | null;\n\tstatus: TodoStatus;\n\tpriority: TodoPriority;\n\tcompletedAt?: string;\n\tpercentComplete?: number;\n\torder?: number;\n\ttags?: string[];\n\tattachments?: TodoAttachment[];\n\trelatedActivities?: number[];\n\tcreatedAt: string;\n\tupdatedAt: string;\n}\n\nexport interface CreateTodoInput {\n\tname: string;\n\tsummary?: string;\n\tdescription?: string;\n\tuserNotes?: string;\n\tparentTodoId?: number | null;\n\titemType?: string;\n\tlocation?: string;\n\tcategories?: string;\n\tclassification?: string;\n\tdeadline?: string;\n\tstartTime?: string;\n\tendTime?: string;\n\tdtstart?: string;\n\tdtend?: string;\n\tdue?: string;\n\tduration?: string;\n\ttimeZone?: string;\n\ttzid?: string;\n\tisAllDay?: boolean;\n\tdtstamp?: string;\n\tcreated?: string;\n\tlastModified?: string;\n\tsequence?: number;\n\trdate?: string;\n\texdate?: string;\n\trecurrenceId?: string;\n\trelatedToUid?: string;\n\trelatedToReltype?: string;\n\ticalStatus?: string;\n\treminderOffsets?: number[] | null;\n\trrule?: string | null;\n\tstatus?: TodoStatus;\n\tpriority?: TodoPriority;\n\tcompletedAt?: string;\n\tpercentComplete?: number;\n\torder?: number;\n\ttags?: string[];\n\trelatedActivities?: number[];\n}\n\nexport interface UpdateTodoInput {\n\tname?: string;\n\tsummary?: string;\n\tdescription?: string;\n\tuserNotes?: string;\n\tstatus?: TodoStatus;\n\tpriority?: TodoPriority;\n\titemType?: string;\n\tlocation?: string;\n\tcategories?: string;\n\tclassification?: string;\n\tdeadline?: string | null;\n\tstartTime?: string | null;\n\tendTime?: string | null;\n\tdtstart?: string | null;\n\tdtend?: string | null;\n\tdue?: string | null;\n\tduration?: string | null;\n\ttimeZone?: string | null;\n\ttzid?: string | null;\n\tisAllDay?: boolean | null;\n\tdtstamp?: string | null;\n\tcreated?: string | null;\n\tlastModified?: string | null;\n\tsequence?: number | null;\n\trdate?: string | null;\n\texdate?: string | null;\n\trecurrenceId?: string | null;\n\trelatedToUid?: string | null;\n\trelatedToReltype?: string | null;\n\ticalStatus?: string | null;\n\treminderOffsets?: number[] | null;\n\trrule?: string | null;\n\tcompletedAt?: string | null;\n\tpercentComplete?: number | null;\n\torder?: number;\n\ttags?: string[];\n\tparentTodoId?: number | null;\n\trelatedActivities?: number[];\n}\n\n// ============================================================================\n// Screenshot & Event Types\n// ============================================================================\n\nexport interface Screenshot {\n\tid: number;\n\tfilePath: string;\n\tappName: string;\n\twindowTitle: string;\n\tcreatedAt: string;\n\ttextContent?: string;\n\twidth: number;\n\theight: number;\n\tocrResult?: {\n\t\ttextContent: string;\n\t};\n}\n\nexport interface Event {\n\tid: number;\n\tappName: string;\n\twindowTitle: string;\n\tstartTime: string;\n\tendTime?: string;\n\tscreenshotCount: number;\n\tfirstScreenshotId?: number;\n\tscreenshots?: Screenshot[];\n\taiTitle?: string;\n\taiSummary?: string;\n}\n\n// ============================================================================\n// Activity Types\n// ============================================================================\n\nexport interface Activity {\n\tid: number;\n\tstartTime: string;\n\tendTime: string;\n\taiTitle?: string;\n\taiSummary?: string;\n\teventCount: number;\n\tcreatedAt?: string;\n\tupdatedAt?: string;\n}\n\nexport interface ActivityWithEvents extends Activity {\n\teventIds?: number[];\n\tevents?: Event[];\n}\n\n// ============================================================================\n// Utility Types for API List Responses (auto-transformed)\n// ============================================================================\n\nexport interface TodoListResponse {\n\ttotal: number;\n\ttodos: Todo[];\n}\n\nexport interface ActivityListResponse {\n\ttotal: number;\n\tactivities: Activity[];\n}\n\nexport interface EventListResponse {\n\ttotal: number;\n\tevents: Event[];\n}\n\nexport interface ActivityEventsResponse {\n\teventIds: number[];\n}\n\n// ============================================================================\n// Automation Task Types\n// ============================================================================\n\nexport type AutomationScheduleType = \"interval\" | \"cron\" | \"once\";\n\nexport interface AutomationSchedule {\n\ttype: AutomationScheduleType;\n\tintervalSeconds?: number;\n\tcron?: string;\n\trunAt?: string;\n\ttimezone?: string;\n}\n\nexport interface AutomationAction {\n\ttype: string;\n\tpayload: Record<string, unknown>;\n}\n\nexport interface AutomationTask {\n\tid: number;\n\tname: string;\n\tdescription?: string;\n\tenabled: boolean;\n\tschedule: AutomationSchedule;\n\taction: AutomationAction;\n\tlastRunAt?: string;\n\tlastStatus?: string;\n\tlastError?: string;\n\tlastOutput?: string;\n\tcreatedAt: string;\n\tupdatedAt: string;\n}\n\nexport interface AutomationTaskListResponse {\n\ttotal: number;\n\ttasks: AutomationTask[];\n}\n\nexport interface AutomationTaskCreateInput {\n\tname: string;\n\tdescription?: string;\n\tenabled?: boolean;\n\tschedule: AutomationSchedule;\n\taction: AutomationAction;\n}\n\nexport interface AutomationTaskUpdateInput {\n\tname?: string;\n\tdescription?: string;\n\tenabled?: boolean;\n\tschedule?: AutomationSchedule;\n\taction?: AutomationAction;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/utils/electron-api.ts",
    "content": "/**\n * Electron API 类型定义和工具函数\n */\n\nexport type ElectronAPI = typeof window & {\n\telectronAPI?: {\n\t\tcollapseWindow?: () => Promise<void> | void;\n\t\texpandWindow?: () => Promise<void> | void;\n\t\texpandWindowFull?: () => Promise<void> | void;\n\t\tsetIgnoreMouseEvents?: (\n\t\t\tignore: boolean,\n\t\t\toptions?: { forward?: boolean },\n\t\t) => void;\n\t\tresizeWindow?: (dx: number, dy: number, pos: string) => void;\n\t\tquit?: () => void;\n\t\tsetWindowBackgroundColor?: (color: string) => void;\n\t\tcaptureAndExtractTodos?: (\n\t\t\tpanelBounds?: { x: number; y: number; width: number; height: number } | null,\n\t\t) => Promise<{\n\t\t\tsuccess: boolean;\n\t\t\tmessage: string;\n\t\t\textractedTodos: Array<{\n\t\t\t\ttitle: string;\n\t\t\t\tdescription?: string;\n\t\t\t\ttime_info?: Record<string, unknown>;\n\t\t\t\tsource_text?: string;\n\t\t\t\tconfidence: number;\n\t\t\t}>;\n\t\t\tcreatedCount: number;\n\t\t}>;\n\t};\n\trequire?: (module: string) => {\n\t\tipcRenderer?: { send: (...args: unknown[]) => void };\n\t};\n};\n\nexport function getElectronAPI(): ElectronAPI {\n\treturn window as ElectronAPI;\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/utils/electron.ts",
    "content": "/**\n * Electron 环境检测和工具函数\n */\n\n/**\n * Electron 窗口接口扩展\n */\nexport interface ElectronWindow extends Window {\n\telectronAPI?: Window[\"electronAPI\"];\n\trequire?: (module: string) => unknown;\n}\n\n/**\n * 检测是否在 Electron 环境中\n */\nexport function isElectronEnvironment(): boolean {\n\tif (typeof window === \"undefined\") return false;\n\tconst win = window as ElectronWindow;\n\treturn !!(\n\t\twin.electronAPI ||\n\t\twin.require?.(\"electron\") ||\n\t\tnavigator.userAgent.includes(\"Electron\")\n\t);\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/utils/platform.ts",
    "content": "/**\n * Platform detection utilities\n *\n * Provides functions to detect the current runtime environment\n * (Tauri, Electron, or Web browser)\n */\n\n/**\n * Check if running in Tauri environment\n */\nexport const isTauri = (): boolean => {\n  return typeof window !== 'undefined' && '__TAURI__' in window;\n};\n\n/**\n * Check if running in Electron environment\n */\nexport const isElectron = (): boolean => {\n  return typeof window !== 'undefined' &&\n    typeof (window as Window & { process?: { type?: string } }).process !== 'undefined' &&\n    (window as Window & { process?: { type?: string } }).process?.type === 'renderer';\n};\n\n/**\n * Check if running in a desktop environment (Tauri or Electron)\n */\nexport const isDesktop = (): boolean => {\n  return isTauri() || isElectron();\n};\n\n/**\n * Check if running in a web browser (not Tauri or Electron)\n */\nexport const isWeb = (): boolean => {\n  return typeof window !== 'undefined' && !isDesktop();\n};\n\n/**\n * Get current platform type\n */\nexport type PlatformType = 'tauri' | 'electron' | 'web';\n\nexport const getPlatform = (): PlatformType => {\n  if (isTauri()) return 'tauri';\n  if (isElectron()) return 'electron';\n  return 'web';\n};\n\n/**\n * Get operating system\n */\nexport type OSType = 'windows' | 'macos' | 'linux' | 'unknown';\n\nexport const getOS = (): OSType => {\n  if (typeof window === 'undefined') return 'unknown';\n\n  const userAgent = window.navigator.userAgent.toLowerCase();\n\n  if (userAgent.includes('win')) return 'windows';\n  if (userAgent.includes('mac')) return 'macos';\n  if (userAgent.includes('linux')) return 'linux';\n\n  return 'unknown';\n};\n\n/**\n * Check if running on macOS\n */\nexport const isMacOS = (): boolean => getOS() === 'macos';\n\n/**\n * Check if running on Windows\n */\nexport const isWindows = (): boolean => getOS() === 'windows';\n\n/**\n * Check if running on Linux\n */\nexport const isLinux = (): boolean => getOS() === 'linux';\n"
  },
  {
    "path": "free-todo-frontend/lib/utils/time.ts",
    "content": "/**\n * 时间工具函数\n * 处理 UTC 时间和本地时间之间的转换\n */\n\n/**\n * 将 UTC ISO 字符串转换为本地时间字符串（用于 input[type=\"datetime-local\"]）\n * @param utcIso UTC 时间 ISO 字符串（如 \"2025-12-31T15:13:16.855Z\"）\n * @returns 本地时间字符串（如 \"2025-12-31T23:13\"），格式为 YYYY-MM-DDTHH:mm\n */\nexport function utcToLocalInput(utcIso: string): string {\n\tif (!utcIso) return \"\";\n\tconst date = new Date(utcIso);\n\tif (Number.isNaN(date.getTime())) return \"\";\n\n\t// 获取本地时间的各个部分\n\tconst year = date.getFullYear();\n\tconst month = String(date.getMonth() + 1).padStart(2, \"0\");\n\tconst day = String(date.getDate()).padStart(2, \"0\");\n\tconst hours = String(date.getHours()).padStart(2, \"0\");\n\tconst minutes = String(date.getMinutes()).padStart(2, \"0\");\n\n\treturn `${year}-${month}-${day}T${hours}:${minutes}`;\n}\n\n/**\n * 将本地时间字符串（来自 input[type=\"datetime-local\"]）转换为 UTC ISO 字符串\n * @param localInput 本地时间字符串（如 \"2025-12-31T23:13\"）\n * @returns UTC ISO 字符串（如 \"2025-12-31T15:13:00.000Z\"）\n */\nexport function localToUtcIso(localInput: string): string {\n\tif (!localInput) return \"\";\n\tconst date = new Date(localInput);\n\tif (Number.isNaN(date.getTime())) return \"\";\n\treturn date.toISOString();\n}\n\n/**\n * 将 UTC ISO 字符串转换为本地时间显示字符串\n * @param utcIso UTC 时间 ISO 字符串\n * @param format 格式化选项（可选）\n * @returns 本地时间显示字符串\n */\nexport function utcToLocalDisplay(\n\tutcIso: string,\n\tformat?: \"date\" | \"datetime\" | \"time\",\n): string {\n\tif (!utcIso) return \"\";\n\tconst date = new Date(utcIso);\n\tif (Number.isNaN(date.getTime())) return \"\";\n\n\tswitch (format) {\n\t\tcase \"date\":\n\t\t\treturn date.toLocaleDateString();\n\t\tcase \"time\":\n\t\t\treturn date.toLocaleTimeString();\n\t\tdefault:\n\t\t\treturn date.toLocaleString();\n\t}\n}\n"
  },
  {
    "path": "free-todo-frontend/lib/utils.ts",
    "content": "import clsx, { type ClassValue } from \"clsx\";\nimport dayjs from \"dayjs\";\nimport { twMerge } from \"tailwind-merge\";\nimport \"dayjs/locale/zh-cn\";\nimport type { Todo, TodoPriority, TodoStatus } from \"./types\";\n\ndayjs.locale(\"zh-cn\");\n\nexport function cn(...inputs: ClassValue[]): string {\n\treturn twMerge(clsx(inputs));\n}\n\n// 格式化日期时间\nexport function formatDateTime(\n\tdate: string | Date,\n\tformat = \"YYYY-MM-DD HH:mm:ss\",\n): string {\n\treturn dayjs(date).format(format);\n}\n\n// 计算时长（秒）\nexport function calculateDuration(startTime: string, endTime: string): number {\n\tconst start = dayjs(startTime);\n\tconst end = dayjs(endTime);\n\tconst seconds = end.diff(start, \"second\");\n\t// 不足1秒算1秒，使用进一法\n\treturn Math.max(1, seconds);\n}\n\n// 格式化时长\nexport function formatDuration(\n\tseconds: number,\n\ttimeTranslations?: Record<string, string>,\n): string {\n\t// 不足1秒算1秒\n\tif (seconds < 1) {\n\t\tseconds = 1;\n\t}\n\n\t// 计算各个时间单位\n\tconst days = Math.floor(seconds / 86400);\n\tconst hours = Math.floor((seconds % 86400) / 3600);\n\tconst minutes = Math.floor((seconds % 3600) / 60);\n\tconst secs = seconds % 60;\n\n\t// 如果没有提供翻译，使用中文默认值（向后兼容）\n\tif (!timeTranslations) {\n\t\tconst parts = [];\n\t\tif (days > 0) parts.push(`${days} 天`);\n\t\tif (hours > 0) parts.push(`${hours} 小时`);\n\t\tif (minutes > 0) parts.push(`${minutes} 分钟`);\n\t\tif (secs > 0) parts.push(`${secs} 秒`);\n\t\treturn parts.length > 0 ? parts.join(\" \") : \"1 秒\";\n\t}\n\n\t// 使用提供的翻译\n\tconst parts = [];\n\tif (days > 0) parts.push(`${days} ${timeTranslations.days}`);\n\tif (hours > 0) parts.push(`${hours} ${timeTranslations.hours}`);\n\tif (minutes > 0) parts.push(`${minutes} ${timeTranslations.minutes}`);\n\tif (secs > 0) parts.push(`${secs} ${timeTranslations.seconds}`);\n\n\t// 如果所有单位都是0（理论上不会发生，因为最小是1秒），返回\"1 秒\"\n\treturn parts.length > 0 ? parts.join(\" \") : `1 ${timeTranslations.seconds}`;\n}\n\n// ============================================================================\n// Todo 排序工具函数\n// ============================================================================\n\n/**\n * 按 order 字段排序 Todo 列表（用于子任务）\n * 优先按 order 字段排序，如果 order 相同则按创建时间升序排序\n */\nexport function sortTodosByOrder<T extends Todo>(todos: T[]): T[] {\n\treturn [...todos].sort((a, b) => {\n\t\t// 优先按 order 字段排序\n\t\tconst aOrder = a.order ?? 0;\n\t\tconst bOrder = b.order ?? 0;\n\t\tif (aOrder !== bOrder) {\n\t\t\treturn aOrder - bOrder;\n\t\t}\n\t\t// order 相同时，按创建时间排序\n\t\tconst aTime = a.createdAt ? new Date(a.createdAt).getTime() : 0;\n\t\tconst bTime = b.createdAt ? new Date(b.createdAt).getTime() : 0;\n\t\treturn aTime - bTime;\n\t});\n}\n\n/**\n * 按原始顺序排序 Todo 列表（用于根任务）\n * 保持数组中的原始顺序（支持用户拖拽排序）\n */\nexport function sortTodosByOriginalOrder<T extends Todo>(\n\ttodos: T[],\n\torderMap: Map<number, number>,\n): T[] {\n\treturn [...todos].sort(\n\t\t(a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0),\n\t);\n}\n\n// ============================================================================\n// Todo 国际化工具函数\n// ============================================================================\n\n/**\n * 翻译函数类型\n */\nexport type TranslationFunction = (\n\tkey: string,\n\tvalues?: Record<string, string | number | Date>,\n) => string;\n\n/**\n * 获取优先级的本地化标签\n * @param priority 优先级\n * @param t 翻译函数（从 useTranslations(\"common\") 获取）\n */\nexport function getPriorityLabel(\n\tpriority: TodoPriority,\n\tt: TranslationFunction,\n): string {\n\treturn t(`priority.${priority}`);\n}\n\n/**\n * 获取状态的本地化标签\n * @param status 状态\n * @param t 翻译函数（从 useTranslations(\"common\") 获取）\n */\nexport function getStatusLabel(\n\tstatus: TodoStatus,\n\tt: TranslationFunction,\n): string {\n\treturn t(`status.${status}`);\n}\n"
  },
  {
    "path": "free-todo-frontend/next.config.ts",
    "content": "import { execSync } from \"node:child_process\";\nimport type { NextConfig } from \"next\";\nimport createNextIntlPlugin from \"next-intl/plugin\";\n\nconst withNextIntl = createNextIntlPlugin(\"./lib/i18n/request.ts\");\n\n// 获取版本信息\nconst packageJson = require(\"./package.json\");\nconst APP_VERSION = packageJson.version;\n\n// 获取 Git Commit Hash（取前 8 位）\nlet GIT_COMMIT = \"unknown\";\ntry {\n\tGIT_COMMIT = execSync(\"git rev-parse HEAD\").toString().trim().slice(0, 8);\n} catch {\n\tconsole.warn(\"无法获取 Git commit hash\");\n}\n\n// 判断是 build 版还是 dev 版\nconst BUILD_TYPE = process.env.NODE_ENV === \"production\" ? \"build\" : \"dev\";\n\n// 从环境变量读取 API 地址，如果读不到就使用 localhost:8100（Build 模式默认端口）\nconst API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || \"http://localhost:8100\";\nconst apiUrl = new URL(API_BASE_URL);\n\nconst nextConfig: NextConfig = {\n\toutput: \"standalone\",\n\treactStrictMode: true,\n\ttypedRoutes: true,\n\t// 注入版本信息到客户端环境变量\n\tenv: {\n\t\tNEXT_PUBLIC_APP_VERSION: APP_VERSION,\n\t\tNEXT_PUBLIC_GIT_COMMIT: GIT_COMMIT,\n\t\tNEXT_PUBLIC_BUILD_TYPE: BUILD_TYPE,\n\t},\n\t// 增加代理超时时间到 120 秒，避免 LLM 调用超时\n\texperimental: {\n\t\tproxyTimeout: 120000, // 120 秒\n\t},\n\t// 在 Electron 环境中禁用 SSR，避免窗口显示问题\n\t// 注意：这会影响 SEO，但对于 Electron 应用来说不是问题\n\t...(process.env.ELECTRON === \"true\"\n\t\t? {\n\t\t\t\t// 可以在这里添加 Electron 特定的配置\n\t\t\t}\n\t\t: {}),\n\tasync rewrites() {\n\t\treturn [\n\t\t\t{\n\t\t\t\tsource: \"/api/:path*\",\n\t\t\t\tdestination: `${API_BASE_URL}/api/:path*`,\n\t\t\t},\n\t\t\t{\n\t\t\t\tsource: \"/assets/:path*\",\n\t\t\t\tdestination: `${API_BASE_URL}/assets/:path*`,\n\t\t\t},\n\t\t];\n\t},\n\timages: {\n\t\tremotePatterns: [\n\t\t\t{\n\t\t\t\tprotocol: apiUrl.protocol.replace(\":\", \"\") as \"http\" | \"https\",\n\t\t\t\thostname: apiUrl.hostname,\n\t\t\t\tport: apiUrl.port || undefined,\n\t\t\t\tpathname: \"/api/**\",\n\t\t\t},\n\t\t],\n\t},\n};\n\nexport default withNextIntl(nextConfig);\n"
  },
  {
    "path": "free-todo-frontend/orval.config.ts",
    "content": "import { defineConfig } from \"orval\";\n\nexport default defineConfig({\n\tlifetrace: {\n\t\tinput: {\n\t\t\t// 从后端获取 OpenAPI schema\n\t\t\ttarget: \"http://localhost:8001/openapi.json\",\n\t\t},\n\t\toutput: {\n\t\t\t// 生成文件的目标目录\n\t\t\ttarget: \"./lib/generated/generated.ts\",\n\t\t\tschemas: \"./lib/generated/schemas\",\n\t\t\tclient: \"react-query\",\n\t\t\tmode: \"tags-split\", // 按 API tag 分割文件\n\t\t\toverride: {\n\t\t\t\tmutator: {\n\t\t\t\t\tpath: \"./lib/api/fetcher.ts\",\n\t\t\t\t\tname: \"customFetcher\",\n\t\t\t\t},\n\t\t\t\t// 生成 Zod schemas\n\t\t\t\tzod: {\n\t\t\t\t\tstrict: {\n\t\t\t\t\t\tresponse: true, // 响应使用严格验证\n\t\t\t\t\t\tbody: true, // 请求体使用严格验证\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tquery: {\n\t\t\t\t\tuseQuery: true,\n\t\t\t\t\tuseMutation: true,\n\t\t\t\t},\n\t\t\t},\n\t\t\tprettier: false,\n\t\t},\n\t},\n});\n"
  },
  {
    "path": "free-todo-frontend/package.json",
    "content": "{\n\t\"name\": \"FreeTodo\",\n\t\"version\": \"0.1.2\",\n\t\"private\": true,\n\t\"main\": \"dist-electron/main.js\",\n\t\"scripts\": {\n\t\t\"dev\": \"node scripts/dev-with-auto-port.js\",\n\t\t\"dev:frontend:web\": \"next dev --port 3001\",\n\t\t\"dev:frontend:island\": \"next dev --port 3001\",\n\t\t\"dev:frontend:default-port\": \"next dev\",\n\t\t\"electron\": \"pnpm build:electron:web:script:frontend-shell && electron .\",\n\t\t\"start\": \"next start\",\n\t\t\"build:frontend:web\": \"next build\",\n\t\t\"build:frontend:island\": \"next build\",\n\t\t\"build:backend:script\": \"node -e \\\"console.log('Backend script runtime: no build step required.')\\\"\",\n\t\t\"build:backend:pyinstaller\": \"cd ../lifetrace && bash scripts/build-backend.sh\",\n\t\t\"build:backend:pyinstaller:win\": \"cd ../lifetrace && powershell -ExecutionPolicy Bypass -File scripts/build-backend.ps1\",\n\t\t\"build:electron:web:script:frontend-shell\": \"cross-env WINDOW_MODE=web BACKEND_RUNTIME=script node scripts/build-electron.js\",\n\t\t\"build:electron:island:script:frontend-shell\": \"cross-env WINDOW_MODE=island BACKEND_RUNTIME=script node scripts/build-electron.js\",\n\t\t\"build:electron:web:pyinstaller:frontend-shell\": \"cross-env WINDOW_MODE=web BACKEND_RUNTIME=pyinstaller node scripts/build-electron.js\",\n\t\t\"build:electron:island:pyinstaller:frontend-shell\": \"cross-env WINDOW_MODE=island BACKEND_RUNTIME=pyinstaller node scripts/build-electron.js\",\n\t\t\"build:electron:web:script:full\": \"pnpm build:frontend:web && pnpm electron:resolve-symlinks && pnpm electron:copy-missing-deps && pnpm build:electron:web:script:frontend-shell && electron-builder --config electron-builder.web.script.yml\",\n\t\t\"build:electron:web:script:full:win\": \"pnpm build:frontend:web && pnpm electron:resolve-symlinks && pnpm electron:copy-missing-deps && pnpm build:electron:web:script:frontend-shell && electron-builder --config electron-builder.web.script.yml --win\",\n\t\t\"build:electron:web:script:full:mac\": \"pnpm build:frontend:web && pnpm electron:resolve-symlinks && pnpm electron:copy-missing-deps && pnpm build:electron:web:script:frontend-shell && electron-builder --config electron-builder.web.script.yml --mac\",\n\t\t\"build:electron:web:script:full:linux\": \"pnpm build:frontend:web && pnpm electron:resolve-symlinks && pnpm electron:copy-missing-deps && pnpm build:electron:web:script:frontend-shell && electron-builder --config electron-builder.web.script.yml --linux\",\n\t\t\"build:electron:island:script:full\": \"pnpm build:frontend:island && pnpm electron:resolve-symlinks && pnpm electron:copy-missing-deps && pnpm build:electron:island:script:frontend-shell && electron-builder --config electron-builder.island.script.yml\",\n\t\t\"build:electron:island:script:full:win\": \"pnpm build:frontend:island && pnpm electron:resolve-symlinks && pnpm electron:copy-missing-deps && pnpm build:electron:island:script:frontend-shell && electron-builder --config electron-builder.island.script.yml --win\",\n\t\t\"build:electron:island:script:full:mac\": \"pnpm build:frontend:island && pnpm electron:resolve-symlinks && pnpm electron:copy-missing-deps && pnpm build:electron:island:script:frontend-shell && electron-builder --config electron-builder.island.script.yml --mac\",\n\t\t\"build:electron:island:script:full:linux\": \"pnpm build:frontend:island && pnpm electron:resolve-symlinks && pnpm electron:copy-missing-deps && pnpm build:electron:island:script:frontend-shell && electron-builder --config electron-builder.island.script.yml --linux\",\n\t\t\"build:electron:web:pyinstaller:full\": \"pnpm build:frontend:web && pnpm build:backend:pyinstaller && pnpm electron:resolve-symlinks && pnpm electron:copy-missing-deps && pnpm build:electron:web:pyinstaller:frontend-shell && electron-builder --config electron-builder.web.pyinstaller.yml\",\n\t\t\"build:electron:web:pyinstaller:full:win\": \"pnpm build:frontend:web && pnpm build:backend:pyinstaller:win && pnpm electron:resolve-symlinks && pnpm electron:copy-missing-deps && pnpm build:electron:web:pyinstaller:frontend-shell && electron-builder --config electron-builder.web.pyinstaller.yml --win\",\n\t\t\"build:electron:web:pyinstaller:full:mac\": \"pnpm build:frontend:web && pnpm build:backend:pyinstaller && pnpm electron:resolve-symlinks && pnpm electron:copy-missing-deps && pnpm build:electron:web:pyinstaller:frontend-shell && electron-builder --config electron-builder.web.pyinstaller.yml --mac\",\n\t\t\"build:electron:web:pyinstaller:full:linux\": \"pnpm build:frontend:web && pnpm build:backend:pyinstaller && pnpm electron:resolve-symlinks && pnpm electron:copy-missing-deps && pnpm build:electron:web:pyinstaller:frontend-shell && electron-builder --config electron-builder.web.pyinstaller.yml --linux\",\n\t\t\"build:electron:island:pyinstaller:full\": \"pnpm build:frontend:island && pnpm build:backend:pyinstaller && pnpm electron:resolve-symlinks && pnpm electron:copy-missing-deps && pnpm build:electron:island:pyinstaller:frontend-shell && electron-builder --config electron-builder.island.pyinstaller.yml\",\n\t\t\"build:electron:island:pyinstaller:full:win\": \"pnpm build:frontend:island && pnpm build:backend:pyinstaller:win && pnpm electron:resolve-symlinks && pnpm electron:copy-missing-deps && pnpm build:electron:island:pyinstaller:frontend-shell && electron-builder --config electron-builder.island.pyinstaller.yml --win\",\n\t\t\"build:electron:island:pyinstaller:full:mac\": \"pnpm build:frontend:island && pnpm build:backend:pyinstaller && pnpm electron:resolve-symlinks && pnpm electron:copy-missing-deps && pnpm build:electron:island:pyinstaller:frontend-shell && electron-builder --config electron-builder.island.pyinstaller.yml --mac\",\n\t\t\"build:electron:island:pyinstaller:full:linux\": \"pnpm build:frontend:island && pnpm build:backend:pyinstaller && pnpm electron:resolve-symlinks && pnpm electron:copy-missing-deps && pnpm build:electron:island:pyinstaller:frontend-shell && electron-builder --config electron-builder.island.pyinstaller.yml --linux\",\n\t\t\"build:electron:web:script:full:dir\": \"pnpm build:frontend:web && pnpm electron:resolve-symlinks && pnpm electron:copy-missing-deps && pnpm build:electron:web:script:frontend-shell && electron-builder --config electron-builder.web.script.yml --dir\",\n\t\t\"build:electron:island:script:full:dir\": \"pnpm build:frontend:island && pnpm electron:resolve-symlinks && pnpm electron:copy-missing-deps && pnpm build:electron:island:script:frontend-shell && electron-builder --config electron-builder.island.script.yml --dir\",\n\t\t\"build:electron:web:pyinstaller:full:dir\": \"pnpm build:frontend:web && pnpm build:backend:pyinstaller && pnpm electron:resolve-symlinks && pnpm electron:copy-missing-deps && pnpm build:electron:web:pyinstaller:frontend-shell && electron-builder --config electron-builder.web.pyinstaller.yml --dir\",\n\t\t\"build:electron:island:pyinstaller:full:dir\": \"pnpm build:frontend:island && pnpm build:backend:pyinstaller && pnpm electron:resolve-symlinks && pnpm electron:copy-missing-deps && pnpm build:electron:island:pyinstaller:frontend-shell && electron-builder --config electron-builder.island.pyinstaller.yml --dir\",\n\t\t\"lint\": \"biome lint ./app ./apps ./components ./electron ./lib ./scripts\",\n\t\t\"format\": \"biome format --write ./app ./apps ./components ./electron ./lib ./scripts\",\n\t\t\"check\": \"biome check ./app ./apps ./components ./electron ./lib ./scripts\",\n\t\t\"type-check\": \"tsc --noEmit\",\n\t\t\"api:generate\": \"orval\",\n\t\t\"api:generate:watch\": \"orval --watch\",\n\t\t\"electron:resolve-symlinks\": \"node scripts/resolve-symlinks.js\",\n\t\t\"electron:copy-missing-deps\": \"node scripts/copy-missing-deps.js\",\n\t\t\"electron:dev\": \"pnpm electron\",\n\t\t\"electron:dev:web\": \"cross-env WINDOW_MODE=web pnpm electron\",\n\t\t\"electron:dev:island\": \"cross-env WINDOW_MODE=island pnpm electron\",\n\t\t\"electron:dev:utf8\": \"pnpm electron\",\n\t\t\"tauri\": \"tauri\",\n\t\t\"tauri:dev\": \"tauri dev\",\n\t\t\"tauri:build\": \"tauri build\",\n\t\t\"tauri:prebuild\": \"node scripts/tauri-prebuild.js\",\n\t\t\"tauri:copy-resources\": \"node scripts/tauri-copy-resources.js\",\n\t\t\"build:tauri:web:script:full\": \"cross-env WINDOW_MODE=web FREETODO_BACKEND_RUNTIME=script pnpm build:frontend:web && cross-env FREETODO_BACKEND_RUNTIME=script tauri build --config src-tauri/tauri.web.script.json && node scripts/tauri-copy-resources.js\",\n\t\t\"build:tauri:island:script:full\": \"cross-env WINDOW_MODE=island FREETODO_BACKEND_RUNTIME=script pnpm build:frontend:island && cross-env FREETODO_BACKEND_RUNTIME=script tauri build --config src-tauri/tauri.island.script.json && node scripts/tauri-copy-resources.js\",\n\t\t\"build:tauri:web:pyinstaller:full\": \"cross-env WINDOW_MODE=web FREETODO_BACKEND_RUNTIME=pyinstaller pnpm build:frontend:web && pnpm build:backend:pyinstaller && cross-env FREETODO_BACKEND_RUNTIME=pyinstaller tauri build --config src-tauri/tauri.web.pyinstaller.json && node scripts/tauri-copy-resources.js\",\n\t\t\"build:tauri:island:pyinstaller:full\": \"cross-env WINDOW_MODE=island FREETODO_BACKEND_RUNTIME=pyinstaller pnpm build:frontend:island && pnpm build:backend:pyinstaller && cross-env FREETODO_BACKEND_RUNTIME=pyinstaller tauri build --config src-tauri/tauri.island.pyinstaller.json && node scripts/tauri-copy-resources.js\",\n\t\t\"build:tauri:web:script:full:win\": \"cross-env WINDOW_MODE=web FREETODO_BACKEND_RUNTIME=script pnpm build:frontend:web && cross-env FREETODO_BACKEND_RUNTIME=script tauri build --config src-tauri/tauri.web.script.json --target x86_64-pc-windows-msvc && node scripts/tauri-copy-resources.js --target x86_64-pc-windows-msvc && node scripts/collect-tauri-artifacts.js --variant web --runtime script --target x86_64-pc-windows-msvc\",\n\t\t\"build:tauri:web:script:full:mac\": \"cross-env WINDOW_MODE=web FREETODO_BACKEND_RUNTIME=script pnpm build:frontend:web && cross-env FREETODO_BACKEND_RUNTIME=script tauri build --config src-tauri/tauri.web.script.json --target universal-apple-darwin && node scripts/tauri-copy-resources.js --target universal-apple-darwin && node scripts/collect-tauri-artifacts.js --variant web --runtime script --target universal-apple-darwin\",\n\t\t\"build:tauri:web:script:full:linux\": \"cross-env WINDOW_MODE=web FREETODO_BACKEND_RUNTIME=script pnpm build:frontend:web && cross-env FREETODO_BACKEND_RUNTIME=script tauri build --config src-tauri/tauri.web.script.json --target x86_64-unknown-linux-gnu && node scripts/tauri-copy-resources.js --target x86_64-unknown-linux-gnu && node scripts/collect-tauri-artifacts.js --variant web --runtime script --target x86_64-unknown-linux-gnu\",\n\t\t\"build:tauri:island:script:full:win\": \"cross-env WINDOW_MODE=island FREETODO_BACKEND_RUNTIME=script pnpm build:frontend:island && cross-env FREETODO_BACKEND_RUNTIME=script tauri build --config src-tauri/tauri.island.script.json --target x86_64-pc-windows-msvc && node scripts/tauri-copy-resources.js --target x86_64-pc-windows-msvc && node scripts/collect-tauri-artifacts.js --variant island --runtime script --target x86_64-pc-windows-msvc\",\n\t\t\"build:tauri:island:script:full:mac\": \"cross-env WINDOW_MODE=island FREETODO_BACKEND_RUNTIME=script pnpm build:frontend:island && cross-env FREETODO_BACKEND_RUNTIME=script tauri build --config src-tauri/tauri.island.script.json --target universal-apple-darwin && node scripts/tauri-copy-resources.js --target universal-apple-darwin && node scripts/collect-tauri-artifacts.js --variant island --runtime script --target universal-apple-darwin\",\n\t\t\"build:tauri:island:script:full:linux\": \"cross-env WINDOW_MODE=island FREETODO_BACKEND_RUNTIME=script pnpm build:frontend:island && cross-env FREETODO_BACKEND_RUNTIME=script tauri build --config src-tauri/tauri.island.script.json --target x86_64-unknown-linux-gnu && node scripts/tauri-copy-resources.js --target x86_64-unknown-linux-gnu && node scripts/collect-tauri-artifacts.js --variant island --runtime script --target x86_64-unknown-linux-gnu\",\n\t\t\"build:tauri:web:pyinstaller:full:win\": \"cross-env WINDOW_MODE=web FREETODO_BACKEND_RUNTIME=pyinstaller pnpm build:frontend:web && pnpm build:backend:pyinstaller:win && cross-env FREETODO_BACKEND_RUNTIME=pyinstaller tauri build --config src-tauri/tauri.web.pyinstaller.json --target x86_64-pc-windows-msvc && node scripts/tauri-copy-resources.js --target x86_64-pc-windows-msvc && node scripts/collect-tauri-artifacts.js --variant web --runtime pyinstaller --target x86_64-pc-windows-msvc\",\n\t\t\"build:tauri:web:pyinstaller:full:mac\": \"cross-env WINDOW_MODE=web FREETODO_BACKEND_RUNTIME=pyinstaller pnpm build:frontend:web && pnpm build:backend:pyinstaller && cross-env FREETODO_BACKEND_RUNTIME=pyinstaller tauri build --config src-tauri/tauri.web.pyinstaller.json --target universal-apple-darwin && node scripts/tauri-copy-resources.js --target universal-apple-darwin && node scripts/collect-tauri-artifacts.js --variant web --runtime pyinstaller --target universal-apple-darwin\",\n\t\t\"build:tauri:web:pyinstaller:full:linux\": \"cross-env WINDOW_MODE=web FREETODO_BACKEND_RUNTIME=pyinstaller pnpm build:frontend:web && pnpm build:backend:pyinstaller && cross-env FREETODO_BACKEND_RUNTIME=pyinstaller tauri build --config src-tauri/tauri.web.pyinstaller.json --target x86_64-unknown-linux-gnu && node scripts/tauri-copy-resources.js --target x86_64-unknown-linux-gnu && node scripts/collect-tauri-artifacts.js --variant web --runtime pyinstaller --target x86_64-unknown-linux-gnu\",\n\t\t\"build:tauri:island:pyinstaller:full:win\": \"cross-env WINDOW_MODE=island FREETODO_BACKEND_RUNTIME=pyinstaller pnpm build:frontend:island && pnpm build:backend:pyinstaller:win && cross-env FREETODO_BACKEND_RUNTIME=pyinstaller tauri build --config src-tauri/tauri.island.pyinstaller.json --target x86_64-pc-windows-msvc && node scripts/tauri-copy-resources.js --target x86_64-pc-windows-msvc && node scripts/collect-tauri-artifacts.js --variant island --runtime pyinstaller --target x86_64-pc-windows-msvc\",\n\t\t\"build:tauri:island:pyinstaller:full:mac\": \"cross-env WINDOW_MODE=island FREETODO_BACKEND_RUNTIME=pyinstaller pnpm build:frontend:island && pnpm build:backend:pyinstaller && cross-env FREETODO_BACKEND_RUNTIME=pyinstaller tauri build --config src-tauri/tauri.island.pyinstaller.json --target universal-apple-darwin && node scripts/tauri-copy-resources.js --target universal-apple-darwin && node scripts/collect-tauri-artifacts.js --variant island --runtime pyinstaller --target universal-apple-darwin\",\n\t\t\"build:tauri:island:pyinstaller:full:linux\": \"cross-env WINDOW_MODE=island FREETODO_BACKEND_RUNTIME=pyinstaller pnpm build:frontend:island && pnpm build:backend:pyinstaller && cross-env FREETODO_BACKEND_RUNTIME=pyinstaller tauri build --config src-tauri/tauri.island.pyinstaller.json --target x86_64-unknown-linux-gnu && node scripts/tauri-copy-resources.js --target x86_64-unknown-linux-gnu && node scripts/collect-tauri-artifacts.js --variant island --runtime pyinstaller --target x86_64-unknown-linux-gnu\"\n\t},\n\t\"dependencies\": {\n\t\t\"@dnd-kit/core\": \"^6.3.1\",\n\t\t\"@dnd-kit/sortable\": \"^10.0.0\",\n\t\t\"@dnd-kit/utilities\": \"^3.2.2\",\n\t\t\"@radix-ui/react-alert-dialog\": \"^1.1.15\",\n\t\t\"@radix-ui/react-dialog\": \"^1.1.15\",\n\t\t\"@radix-ui/react-dropdown-menu\": \"^2.1.16\",\n\t\t\"@radix-ui/react-slot\": \"^1.2.4\",\n\t\t\"@tanstack/react-query\": \"^5.90.20\",\n\t\t\"@tauri-apps/api\": \"2.9.1\",\n\t\t\"@tiptap/core\": \"^3.18.0\",\n\t\t\"@tiptap/extension-placeholder\": \"^3.18.0\",\n\t\t\"@tiptap/pm\": \"^3.18.0\",\n\t\t\"@tiptap/react\": \"^3.18.0\",\n\t\t\"@tiptap/starter-kit\": \"^3.18.0\",\n\t\t\"clsx\": \"^2.1.1\",\n\t\t\"date-fns\": \"^4.1.0\",\n\t\t\"dayjs\": \"^1.11.19\",\n\t\t\"driver.js\": \"^1.4.0\",\n\t\t\"framer-motion\": \"^12.29.2\",\n\t\t\"jimp\": \"^1.6.0\",\n\t\t\"lucide-react\": \"^0.563.0\",\n\t\t\"markdown-it\": \"^14.1.0\",\n\t\t\"next\": \"16.1.6\",\n\t\t\"next-intl\": \"^4.8.1\",\n\t\t\"next-themes\": \"^0.4.6\",\n\t\t\"openai\": \"^6.17.0\",\n\t\t\"react\": \"19.2.4\",\n\t\t\"react-dom\": \"19.2.4\",\n\t\t\"react-markdown\": \"^10.1.0\",\n\t\t\"remark-gfm\": \"^4.0.1\",\n\t\t\"sharp\": \"^0.34.5\",\n\t\t\"tailwind-merge\": \"^3.4.0\",\n\t\t\"turndown\": \"^7.2.2\",\n\t\t\"zod\": \"^4.3.6\",\n\t\t\"zustand\": \"^5.0.11\"\n\t},\n\t\"devDependencies\": {\n\t\t\"@biomejs/biome\": \"2.3.13\",\n\t\t\"@tailwindcss/postcss\": \"^4.1.18\",\n\t\t\"@tailwindcss/typography\": \"^0.5.19\",\n\t\t\"@tauri-apps/cli\": \"2.9.1\",\n\t\t\"@types/dom-speech-recognition\": \"^0.0.7\",\n\t\t\"@types/markdown-it\": \"^14.1.2\",\n\t\t\"@types/node\": \"^25.1.0\",\n\t\t\"@types/react\": \"^19.2.10\",\n\t\t\"@types/react-dom\": \"^19.2.3\",\n\t\t\"@types/turndown\": \"^5.0.6\",\n\t\t\"autoprefixer\": \"^10.4.24\",\n\t\t\"baseline-browser-mapping\": \"^2.9.19\",\n\t\t\"concurrently\": \"^9.2.1\",\n\t\t\"cross-env\": \"^10.1.0\",\n\t\t\"electron\": \"^40.1.0\",\n\t\t\"electron-builder\": \"^26.7.0\",\n\t\t\"electron-builder-squirrel-windows\": \"26.7.0\",\n\t\t\"esbuild\": \"^0.27.2\",\n\t\t\"orval\": \"^8.2.0\",\n\t\t\"tailwindcss\": \"^4.1.18\",\n\t\t\"typescript\": \"^5.9.3\",\n\t\t\"wait-on\": \"^9.0.3\"\n\t}\n}\n"
  },
  {
    "path": "free-todo-frontend/pnpm-workspace.yaml",
    "content": "packages:\n  - \".\"\nonlyBuiltDependencies:\n  - electron\n  - electron-winstaller\n  - esbuild\n  - sharp\n"
  },
  {
    "path": "free-todo-frontend/postcss.config.mjs",
    "content": "export default {\n\tplugins: {\n\t\t\"@tailwindcss/postcss\": {},\n\t\tautoprefixer: {},\n\t},\n};\n"
  },
  {
    "path": "free-todo-frontend/public/app-icons/README.md",
    "content": "# 应用图标目录\n\n此目录用于存放应用图标文件。系统会自动根据应用名称（去除.exe后缀）来查找对应的图标。\n\n## 使用方法\n\n1. 将应用图标文件（PNG格式）放置在此目录下\n2. 文件命名规则：`应用名称（小写，无.exe后缀）.png`\n   - 例如：`msedge.exe` → `msedge.png`\n   - 例如：`QQ.exe` → `qq.png`\n   - 例如：`explorer.exe` → `explorer.png`\n\n## 图标要求\n\n- **格式**：PNG（推荐）\n- **尺寸**：建议 64x64 或 128x128 像素\n- **背景**：透明背景（PNG支持透明）\n\n## 示例\n\n以下是一些常见应用的图标文件名：\n\n- `msedge.png` - Microsoft Edge\n- `chrome.png` - Google Chrome\n- `firefox.png` - Mozilla Firefox\n- `qq.png` - QQ\n- `wechat.png` - 微信\n- `explorer.png` - Windows 文件资源管理器\n- `code.png` - Visual Studio Code\n- `pycharm.png` - PyCharm\n\n## 注意事项\n\n- 如果应用图标不存在，系统会显示应用名称的首字母作为占位符\n- 图标文件名必须与应用名称（去除.exe后缀后转小写）完全匹配\n- 建议使用知名应用的官方图标，注意版权问题\n"
  },
  {
    "path": "free-todo-frontend/public/free-todo-logos/favicon/site.webmanifest",
    "content": "{\"background_color\":\"#ffffff\",\"display\":\"standalone\",\"icons\":[{\"sizes\":\"192x192\",\"src\":\"/android-chrome-192x192.png\",\"type\":\"image/png\"},{\"sizes\":\"512x512\",\"src\":\"/android-chrome-512x512.png\",\"type\":\"image/png\"}],\"name\":\"\",\"short_name\":\"\",\"theme_color\":\"#ffffff\"}\n"
  },
  {
    "path": "free-todo-frontend/scripts/build-electron.js",
    "content": "/**\n * 构建 Electron 主进程\n * 使用 esbuild 将 TypeScript 编译为 JavaScript\n */\n\nconst esbuild = require(\"esbuild\");\nconst path = require(\"node:path\");\n\nconst isWatch = process.argv.includes(\"--watch\");\n\n// 获取窗口模式（默认为 web）\nconst windowMode = process.env.WINDOW_MODE || \"web\";\n// 获取后端运行时（script 或 pyinstaller）\nconst backendRuntime = process.env.BACKEND_RUNTIME || \"script\";\n\nasync function build() {\n\tconsole.log(\n\t\t`Building Electron with WINDOW_MODE=${windowMode}, BACKEND_RUNTIME=${backendRuntime}`,\n\t);\n\n\tconst mainOptions = {\n\t\tentryPoints: [path.join(__dirname, \"..\", \"electron\", \"main.ts\")],\n\t\tbundle: true,\n\t\tplatform: \"node\",\n\t\ttarget: \"node18\",\n\t\toutfile: path.join(__dirname, \"..\", \"dist-electron\", \"main.js\"),\n\t\texternal: [\"electron\"],\n\t\tsourcemap: true,\n\t\tminify: process.env.NODE_ENV === \"production\",\n\t\t// 在编译时注入窗口模式常量\n\t\tdefine: {\n\t\t\t\"__DEFAULT_WINDOW_MODE__\": JSON.stringify(windowMode),\n\t\t\t\"__DEFAULT_BACKEND_RUNTIME__\": JSON.stringify(backendRuntime),\n\t\t},\n\t};\n\n\tconst preloadOptions = {\n\t\tentryPoints: [path.join(__dirname, \"..\", \"electron\", \"preload.ts\")],\n\t\tbundle: true,\n\t\tplatform: \"node\",\n\t\ttarget: \"node18\",\n\t\toutfile: path.join(__dirname, \"..\", \"dist-electron\", \"preload.js\"),\n\t\texternal: [\"electron\"],\n\t\tsourcemap: true,\n\t\tminify: process.env.NODE_ENV === \"production\",\n\t};\n\n\tif (isWatch) {\n\t\tconst mainCtx = await esbuild.context(mainOptions);\n\t\tconst preloadCtx = await esbuild.context(preloadOptions);\n\t\tawait Promise.all([mainCtx.watch(), preloadCtx.watch()]);\n\t\tconsole.log(\"Watching for changes...\");\n\t} else {\n\t\tawait Promise.all([\n\t\t\tesbuild.build(mainOptions),\n\t\t\tesbuild.build(preloadOptions),\n\t\t]);\n\t\tconsole.log(\"Electron main process and preload script built successfully!\");\n\t}\n}\n\n// 处理信号，确保正常退出\nlet isShuttingDown = false;\n\nconst gracefulShutdown = async (signal) => {\n\tif (isShuttingDown) {\n\t\tconsole.log(`Received ${signal} again, forcing exit...`);\n\t\tprocess.exit(1);\n\t\treturn;\n\t}\n\n\tisShuttingDown = true;\n\tconsole.log(`\\nReceived ${signal} signal, shutting down gracefully...`);\n\n\t// 等待当前构建完成\n\tsetTimeout(() => {\n\t\tprocess.exit(0);\n\t}, 1000);\n};\n\nprocess.on(\"SIGINT\", () => gracefulShutdown(\"SIGINT\"));\nprocess.on(\"SIGTERM\", () => gracefulShutdown(\"SIGTERM\"));\n\nbuild().catch((err) => {\n\tconsole.error(\"Build failed:\", err);\n\tprocess.exit(1);\n});\n"
  },
  {
    "path": "free-todo-frontend/scripts/check_code_lines.js",
    "content": "#!/usr/bin/env node\n/**\n * Check effective TypeScript/TSX code lines (excluding blank lines and comments).\n * Files over the limit are reported and the script exits non-zero.\n *\n * Usage:\n *   # Scan the entire directory (standalone)\n *   node check_code_lines.js [--include dirs] [--exclude dirs] [--max lines]\n *\n *   # Check specified files (pre-commit mode)\n *   node check_code_lines.js [options] file1.ts file2.tsx ...\n *\n * Examples:\n *   # Scan the whole frontend directory\n *   node check_code_lines.js --include apps,components,lib --exclude lib/generated --max 500\n *\n *   # Check specific files (pre-commit passes staged files)\n *   node check_code_lines.js apps/chat/ChatPanel.tsx apps/todo/TodoList.tsx\n */\n\nconst { existsSync, readdirSync, readFileSync } = require(\"node:fs\");\nconst { dirname, isAbsolute, join, relative, resolve } = require(\"node:path\");\n\n// Script directory (CommonJS)\n// In CommonJS, __dirname and __filename are available by default.\n\n// Default configuration\nconst DEFAULT_INCLUDE = [\"apps\", \"components\", \"electron\", \"lib\"];\nconst DEFAULT_EXCLUDE = [\"lib/generated\"];\nconst DEFAULT_MAX_LINES = 500;\n\n/**\n * @typedef {Object} Config\n * @property {string[]} includeDirs\n * @property {string[]} excludeDirs\n * @property {number} maxLines\n * @property {string[]} files - Explicit file list\n */\n\n/**\n * Parse CLI arguments.\n * @returns {Config}\n */\nfunction parseArgs() {\n  const args = process.argv.slice(2);\n  let includeDirs = DEFAULT_INCLUDE;\n  let excludeDirs = DEFAULT_EXCLUDE;\n  let maxLines = DEFAULT_MAX_LINES;\n  /** @type {string[]} */\n  const files = [];\n\n  for (let i = 0; i < args.length; i++) {\n    const arg = args[i];\n    if (arg === \"--include\" && args[i + 1]) {\n      includeDirs = args[i + 1]\n        .split(\",\")\n        .map((d) => d.trim())\n        .filter(Boolean);\n      i++;\n    } else if (arg === \"--exclude\" && args[i + 1]) {\n      excludeDirs = args[i + 1]\n        .split(\",\")\n        .map((d) => d.trim())\n        .filter(Boolean);\n      i++;\n    } else if (arg === \"--max\" && args[i + 1]) {\n      maxLines = parseInt(args[i + 1], 10);\n      i++;\n    } else if (!arg.startsWith(\"--\")) {\n      // Positional args are treated as file paths\n      files.push(arg);\n    }\n  }\n\n  return { includeDirs, excludeDirs, maxLines, files };\n}\n\n/**\n * Check whether a line is a comment-only line.\n *\n * Rule: after trim(), lines starting with //, /*, *, or * / are comments.\n * @param {string} line\n * @returns {boolean}\n */\nfunction isCommentLine(line) {\n  const trimmed = line.trim();\n  return (\n    trimmed.startsWith(\"//\") ||\n    trimmed.startsWith(\"/*\") ||\n    trimmed.startsWith(\"*\") ||\n    trimmed.startsWith(\"*/\")\n  );\n}\n\n/**\n * Count effective code lines (excluding blank lines and comments).\n * @param {string} filePath\n * @returns {number}\n */\nfunction countCodeLines(filePath) {\n  try {\n    const content = readFileSync(filePath, \"utf-8\");\n    const lines = content.split(\"\\n\");\n    let codeLines = 0;\n\n    for (const line of lines) {\n      const trimmed = line.trim();\n      // Skip blank lines\n      if (!trimmed) {\n        continue;\n      }\n      // Skip comment-only lines\n      if (isCommentLine(line)) {\n        continue;\n      }\n      // Counted line\n      codeLines++;\n    }\n\n    return codeLines;\n  } catch (error) {\n    console.error(`Warning: failed to read file ${filePath}: ${error}`);\n    return 0;\n  }\n}\n\n/**\n * Normalize path separators to / (Windows-friendly).\n * @param {string} p\n * @returns {string}\n */\nfunction normalizePath(p) {\n  return p.replace(/\\\\/g, \"/\");\n}\n\n/**\n * Determine whether a file should be checked.\n * @param {string} relPath\n * @param {string[]} includeDirs\n * @param {string[]} excludeDirs\n * @returns {boolean}\n */\nfunction shouldCheckFile(relPath, includeDirs, excludeDirs) {\n  // Normalize to / separators\n  const normalizedPath = normalizePath(relPath);\n\n  // Check include directories\n  const inInclude = includeDirs.some((inc) =>\n    normalizedPath.startsWith(normalizePath(inc))\n  );\n  if (!inInclude) {\n    return false;\n  }\n\n  // Check exclude directories\n  const inExclude = excludeDirs.some((exc) =>\n    normalizedPath.startsWith(normalizePath(exc))\n  );\n  if (inExclude) {\n    return false;\n  }\n\n  return true;\n}\n\n/**\n * Recursively walk a directory for .ts and .tsx files.\n * @param {string} dir\n * @returns {Generator<string>}\n */\nfunction* walkDir(dir) {\n  try {\n    const entries = readdirSync(dir, { withFileTypes: true });\n    for (const entry of entries) {\n      const fullPath = join(dir, entry.name);\n      if (entry.isDirectory()) {\n        // Skip node_modules and hidden directories\n        if (entry.name === \"node_modules\" || entry.name.startsWith(\".\")) {\n          continue;\n        }\n        yield* walkDir(fullPath);\n      } else if (entry.isFile()) {\n        if (entry.name.endsWith(\".ts\") || entry.name.endsWith(\".tsx\")) {\n          yield fullPath;\n        }\n      }\n    }\n  } catch {\n    // Ignore inaccessible directories\n  }\n}\n\n/**\n * Get the list of files to check.\n * @param {Config} config\n * @param {string} rootDir\n * @returns {string[]}\n */\nfunction getFilesToCheck(config, rootDir) {\n  /** @type {string[]} */\n  const filesToCheck = [];\n\n  if (config.files.length > 0) {\n    // Mode 1: Check specified files (pre-commit mode)\n    for (const fileStr of config.files) {\n      // Handle relative and absolute paths\n      const filePath = isAbsolute(fileStr) ? fileStr : resolve(fileStr);\n\n      // Only check .ts/.tsx files\n      if (!filePath.endsWith(\".ts\") && !filePath.endsWith(\".tsx\")) {\n        continue;\n      }\n\n      // Skip missing files\n      if (!existsSync(filePath)) {\n        continue;\n      }\n\n      // Get path relative to frontend root\n      /** @type {string} */\n      let relPath;\n      try {\n        relPath = relative(rootDir, filePath);\n        // If rel path starts with \"..\", it's outside the frontend directory\n        if (relPath.startsWith(\"..\")) {\n          continue;\n        }\n      } catch {\n        continue;\n      }\n\n      // Check include/exclude filters\n      if (shouldCheckFile(relPath, config.includeDirs, config.excludeDirs)) {\n        filesToCheck.push(filePath);\n      }\n    }\n  } else {\n    // Mode 2: Scan entire directory (standalone mode)\n    for (const filePath of walkDir(rootDir)) {\n      const relPath = relative(rootDir, filePath);\n      if (shouldCheckFile(relPath, config.includeDirs, config.excludeDirs)) {\n        filesToCheck.push(filePath);\n      }\n    }\n  }\n\n  return filesToCheck;\n}\n\n/**\n * Main entrypoint.\n * @returns {number}\n */\nfunction main() {\n  const config = parseArgs();\n\n  // Frontend root (script lives in free-todo-frontend/scripts/)\n  const rootDir = dirname(__dirname);\n\n  // Collect files to check\n  const filesToCheck = getFilesToCheck(config, rootDir);\n\n  if (filesToCheck.length === 0) {\n    if (config.files.length > 0) {\n      // No matching files in pre-commit mode\n      return 0;\n    } else {\n      console.log(\"No TS/TSX files to check.\");\n      return 0;\n    }\n  }\n\n  // Collect violations\n  /** @type {Array<{ path: string; lines: number }>} */\n  const violations = [];\n\n  for (const filePath of filesToCheck) {\n    const relPath = relative(rootDir, filePath);\n    const codeLines = countCodeLines(filePath);\n    if (codeLines > config.maxLines) {\n      violations.push({ path: relPath, lines: codeLines });\n    }\n  }\n\n  // Output results\n  if (violations.length > 0) {\n    console.log(\n      `[ERROR] The following files exceed ${config.maxLines} code lines:`\n    );\n    violations.sort((a, b) => a.path.localeCompare(b.path));\n    for (const { path, lines } of violations) {\n      console.log(`  ${path} -> ${lines} lines`);\n    }\n    return 1;\n  } else {\n    const modeDesc =\n      config.files.length > 0 ? `Checked ${filesToCheck.length} files, ` : \"\";\n    console.log(\n      `[OK] ${modeDesc}all TS/TSX files are within ${config.maxLines} code lines`\n    );\n    return 0;\n  }\n}\n\nprocess.exit(main());\n"
  },
  {
    "path": "free-todo-frontend/scripts/check_rust_code_lines.js",
    "content": "#!/usr/bin/env node\n/**\n * Check effective Rust code lines (excluding blank lines and comments).\n * Files over the limit are reported and the script exits non-zero.\n *\n * Usage:\n *   # Scan the entire directory (standalone)\n *   node check_rust_code_lines.js [--include dirs] [--exclude dirs] [--max lines]\n *\n *   # Check specified files (pre-commit mode)\n *   node check_rust_code_lines.js [options] file1.rs file2.rs ...\n */\n\nconst { existsSync, readdirSync, readFileSync } = require(\"node:fs\");\nconst { dirname, isAbsolute, join, relative, resolve } = require(\"node:path\");\n\nconst DEFAULT_INCLUDE = [\"src-tauri/src\"];\nconst DEFAULT_EXCLUDE = [\"src-tauri/target\"];\nconst DEFAULT_MAX_LINES = 500;\n\nfunction parseArgs() {\n  const args = process.argv.slice(2);\n  let includeDirs = DEFAULT_INCLUDE;\n  let excludeDirs = DEFAULT_EXCLUDE;\n  let maxLines = DEFAULT_MAX_LINES;\n  /** @type {string[]} */\n  const files = [];\n\n  for (let i = 0; i < args.length; i++) {\n    const arg = args[i];\n    if (arg === \"--include\" && args[i + 1]) {\n      includeDirs = args[i + 1]\n        .split(\",\")\n        .map((d) => d.trim())\n        .filter(Boolean);\n      i++;\n    } else if (arg === \"--exclude\" && args[i + 1]) {\n      excludeDirs = args[i + 1]\n        .split(\",\")\n        .map((d) => d.trim())\n        .filter(Boolean);\n      i++;\n    } else if (arg === \"--max\" && args[i + 1]) {\n      maxLines = parseInt(args[i + 1], 10);\n      i++;\n    } else if (!arg.startsWith(\"--\")) {\n      files.push(arg);\n    }\n  }\n\n  return { includeDirs, excludeDirs, maxLines, files };\n}\n\nfunction isCommentLine(line) {\n  const trimmed = line.trim();\n  return (\n    trimmed.startsWith(\"//\") ||\n    trimmed.startsWith(\"/*\") ||\n    trimmed.startsWith(\"*\") ||\n    trimmed.startsWith(\"*/\")\n  );\n}\n\nfunction countCodeLines(filePath) {\n  try {\n    const content = readFileSync(filePath, \"utf-8\");\n    const lines = content.split(\"\\n\");\n    let codeLines = 0;\n\n    for (const line of lines) {\n      const trimmed = line.trim();\n      if (!trimmed) {\n        continue;\n      }\n      if (isCommentLine(line)) {\n        continue;\n      }\n      codeLines++;\n    }\n\n    return codeLines;\n  } catch (error) {\n    console.error(`Warning: failed to read file ${filePath}: ${error}`);\n    return 0;\n  }\n}\n\nfunction normalizePath(p) {\n  return p.replace(/\\\\/g, \"/\");\n}\n\nfunction shouldCheckFile(relPath, includeDirs, excludeDirs) {\n  const normalizedPath = normalizePath(relPath);\n\n  const inInclude = includeDirs.some((inc) =>\n    normalizedPath.startsWith(normalizePath(inc))\n  );\n  if (!inInclude) {\n    return false;\n  }\n\n  const inExclude = excludeDirs.some((exc) =>\n    normalizedPath.startsWith(normalizePath(exc))\n  );\n  if (inExclude) {\n    return false;\n  }\n\n  return true;\n}\n\nfunction* walkDir(dir) {\n  try {\n    const entries = readdirSync(dir, { withFileTypes: true });\n    for (const entry of entries) {\n      const fullPath = join(dir, entry.name);\n      if (entry.isDirectory()) {\n        if (entry.name === \"node_modules\" || entry.name.startsWith(\".\")) {\n          continue;\n        }\n        yield* walkDir(fullPath);\n      } else if (entry.isFile()) {\n        if (entry.name.endsWith(\".rs\")) {\n          yield fullPath;\n        }\n      }\n    }\n  } catch {\n    // Ignore inaccessible directories\n  }\n}\n\nfunction getFilesToCheck(config, rootDir) {\n  /** @type {string[]} */\n  const filesToCheck = [];\n\n  if (config.files.length > 0) {\n    for (const fileStr of config.files) {\n      const filePath = isAbsolute(fileStr) ? fileStr : resolve(fileStr);\n      if (!filePath.endsWith(\".rs\")) {\n        continue;\n      }\n      if (!existsSync(filePath)) {\n        continue;\n      }\n\n      /** @type {string} */\n      let relPath;\n      try {\n        relPath = relative(rootDir, filePath);\n        if (relPath.startsWith(\"..\")) {\n          continue;\n        }\n      } catch {\n        continue;\n      }\n\n      if (shouldCheckFile(relPath, config.includeDirs, config.excludeDirs)) {\n        filesToCheck.push(filePath);\n      }\n    }\n  } else {\n    for (const filePath of walkDir(rootDir)) {\n      const relPath = relative(rootDir, filePath);\n      if (shouldCheckFile(relPath, config.includeDirs, config.excludeDirs)) {\n        filesToCheck.push(filePath);\n      }\n    }\n  }\n\n  return filesToCheck;\n}\n\nfunction main() {\n  const config = parseArgs();\n  const rootDir = dirname(__dirname);\n  const filesToCheck = getFilesToCheck(config, rootDir);\n\n  if (filesToCheck.length === 0) {\n    if (config.files.length > 0) {\n      return 0;\n    }\n    console.log(\"No Rust files to check.\");\n    return 0;\n  }\n\n  /** @type {Array<{ path: string; lines: number }>} */\n  const violations = [];\n\n  for (const filePath of filesToCheck) {\n    const relPath = relative(rootDir, filePath);\n    const codeLines = countCodeLines(filePath);\n    if (codeLines > config.maxLines) {\n      violations.push({ path: relPath, lines: codeLines });\n    }\n  }\n\n  if (violations.length > 0) {\n    console.log(\n      `[ERROR] The following files exceed ${config.maxLines} code lines:`\n    );\n    violations.sort((a, b) => a.path.localeCompare(b.path));\n    for (const { path, lines } of violations) {\n      console.log(`  ${path} -> ${lines} lines`);\n    }\n    return 1;\n  }\n\n  const modeDesc =\n    config.files.length > 0 ? `Checked ${filesToCheck.length} files, ` : \"\";\n  console.log(\n    `[OK] ${modeDesc}all Rust files are within ${config.maxLines} code lines`\n  );\n  return 0;\n}\n\nprocess.exit(main());\n"
  },
  {
    "path": "free-todo-frontend/scripts/collect-tauri-artifacts.js",
    "content": "const fs = require(\"node:fs\");\nconst path = require(\"node:path\");\n\nfunction parseArgs() {\n\tconst args = process.argv.slice(2);\n\tconst result = {};\n\tfor (let i = 0; i < args.length; i += 1) {\n\t\tconst key = args[i];\n\t\tconst value = args[i + 1];\n\t\tif (key?.startsWith(\"--\") && value && !value.startsWith(\"--\")) {\n\t\t\tresult[key.slice(2)] = value;\n\t\t\ti += 0;\n\t\t}\n\t}\n\treturn result;\n}\n\nfunction ensureDir(dir) {\n\tif (!fs.existsSync(dir)) {\n\t\tfs.mkdirSync(dir, { recursive: true });\n\t}\n}\n\nfunction copyDir(src, dest) {\n\tif (!fs.existsSync(src)) {\n\t\tthrow new Error(`Source directory not found: ${src}`);\n\t}\n\tensureDir(dest);\n\tfs.cpSync(src, dest, { recursive: true, force: true });\n}\n\nconst args = parseArgs();\nconst variant = args.variant;\nconst runtime = args.runtime;\nconst target = args.target;\n\nif (!variant || !runtime || !target) {\n\tconsole.error(\n\t\t\"Usage: node scripts/collect-tauri-artifacts.js --variant <web|island> --runtime <script|pyinstaller> --target <target-triple>\",\n\t);\n\tprocess.exit(1);\n}\n\nconst rootDir = path.resolve(__dirname, \"..\");\nconst sourceDir = path.join(rootDir, \"src-tauri\", \"target\", target, \"release\", \"bundle\");\nconst destDir = path.join(\n\trootDir,\n\t\"dist-artifacts\",\n\t\"tauri\",\n\tvariant,\n\truntime,\n\ttarget,\n);\n\ntry {\n\tcopyDir(sourceDir, destDir);\n\tconsole.log(`Tauri artifacts copied to: ${destDir}`);\n} catch (error) {\n\tconsole.error(`Failed to collect Tauri artifacts: ${error.message}`);\n\tprocess.exit(1);\n}\n"
  },
  {
    "path": "free-todo-frontend/scripts/copy-missing-deps.js",
    "content": "/**\n * 复制 Next.js standalone 构建中缺失的依赖\n * Next.js standalone 可能不会包含所有运行时需要的依赖\n */\n\nconst fs = require(\"node:fs\");\nconst path = require(\"node:path\");\n\nfunction copyDirectory(src, dest) {\n\tif (!fs.existsSync(src)) {\n\t\tconsole.warn(`Source not found: ${src}`);\n\t\treturn false;\n\t}\n\n\tfs.mkdirSync(dest, { recursive: true });\n\tconst entries = fs.readdirSync(src, { withFileTypes: true });\n\n\tfor (const entry of entries) {\n\t\tconst srcPath = path.join(src, entry.name);\n\t\tconst destPath = path.join(dest, entry.name);\n\n\t\tif (entry.isDirectory()) {\n\t\t\tcopyDirectory(srcPath, destPath);\n\t\t} else {\n\t\t\tfs.copyFileSync(srcPath, destPath);\n\t\t}\n\t}\n\treturn true;\n}\n\n// 需要复制的缺失依赖（Next.js 运行时需要的但 standalone 可能不包含的）\n// 这些是 Next.js 内部使用的依赖，standalone 构建可能不会自动包含\nconst missingDeps = [\n\t\"styled-jsx\",\n\t\"@swc/helpers\",\n\t\"@next/env\",\n\t\"client-only\",\n\t\"buffer-from\",\n\t\"detect-libc\",\n\t// 可以根据需要添加更多依赖\n];\n\nconst standaloneNodeModules = path.join(\n\t__dirname,\n\t\"..\",\n\t\".next\",\n\t\"standalone\",\n\t\"node_modules\",\n);\nconst mainNodeModules = path.join(__dirname, \"..\", \"node_modules\");\n\nif (!fs.existsSync(standaloneNodeModules)) {\n\tconsole.warn(\n\t\t`Standalone node_modules not found at: ${standaloneNodeModules}`,\n\t);\n\tprocess.exit(1);\n}\n\nconsole.log(\"Copying missing dependencies to standalone build...\");\n\nfor (const dep of missingDeps) {\n\tconst srcPath = path.join(mainNodeModules, \".pnpm\");\n\tconst destPath = path.join(standaloneNodeModules, dep);\n\n\t// 对于 scoped packages (@scope/package)，pnpm 使用 + 代替 /\n\tconst pnpmDepName = dep.replace(/\\//g, \"+\");\n\n\t// 查找依赖在 .pnpm 中的位置\n\tconst pnpmDirs = fs\n\t\t.readdirSync(srcPath)\n\t\t.filter((dir) => dir.startsWith(`${pnpmDepName}@`));\n\n\tif (pnpmDirs.length > 0) {\n\t\tconst pnpmPath = path.join(srcPath, pnpmDirs[0], \"node_modules\", dep);\n\n\t\tif (fs.existsSync(pnpmPath)) {\n\t\t\tif (!fs.existsSync(destPath)) {\n\t\t\t\tconsole.log(`Copying ${dep}...`);\n\t\t\t\tcopyDirectory(pnpmPath, destPath);\n\t\t\t\tconsole.log(`✓ Copied ${dep}`);\n\t\t\t} else {\n\t\t\t\tconsole.log(`✓ ${dep} already exists`);\n\t\t\t}\n\t\t} else {\n\t\t\tconsole.warn(`Could not find ${dep} at: ${pnpmPath}`);\n\t\t}\n\t} else {\n\t\tconsole.warn(\n\t\t\t`Could not find ${dep} (looking for ${pnpmDepName}@) in .pnpm directory`,\n\t\t);\n\t}\n}\n\nconsole.log(\"Missing dependencies copy complete!\");\n"
  },
  {
    "path": "free-todo-frontend/scripts/dev-with-auto-port.js",
    "content": "#!/usr/bin/env node\n/**\n * 开发服务器启动脚本（支持动态端口探测）\n *\n * 功能：\n * 1. 自动探测可用的前端端口（默认从 3001 开始，避免与 Build 版冲突）\n * 2. 自动探测 FreeTodo 后端端口（通过 /health 端点验证是否是 FreeTodo 后端）\n * 3. 设置正确的环境变量并启动 Next.js 开发服务器\n *\n * 使用方法：\n *   pnpm dev          - 自动探测端口启动\n *   pnpm dev:backend  - 同时启动后端和前端（需要后端可执行文件）\n */\n\nconst { execSync, spawn } = require(\"node:child_process\");\nconst fs = require(\"node:fs\");\nconst http = require(\"node:http\");\nconst net = require(\"node:net\");\nconst path = require(\"node:path\");\n\n// 默认端口配置（开发版使用不同的默认端口，避免与 Build 版冲突）\nconst DEFAULT_FRONTEND_PORT = 3001;\nconst _DEFAULT_BACKEND_PORT = 8001;\nconst MAX_PORT_ATTEMPTS = 100;\n\nfunction normalizePath(value) {\n\tconst resolved = path.resolve(value);\n\treturn process.platform === \"win32\" ? resolved.toLowerCase() : resolved;\n}\n\nfunction isSymlinkedNodeModules() {\n\tconst nodeModulesPath = path.join(process.cwd(), \"node_modules\");\n\ttry {\n\t\tif (!fs.existsSync(nodeModulesPath)) {\n\t\t\treturn false;\n\t\t}\n\t\tconst stat = fs.lstatSync(nodeModulesPath);\n\t\tif (stat.isSymbolicLink()) {\n\t\t\treturn true;\n\t\t}\n\t\tconst realPath = fs.realpathSync(nodeModulesPath);\n\t\treturn normalizePath(realPath) !== normalizePath(nodeModulesPath);\n\t} catch {\n\t\treturn false;\n\t}\n}\n\n/**\n * 获取当前 Git Commit\n * @returns {string|null} - 完整 Commit Hash，获取失败则返回 null\n */\nfunction getGitCommit() {\n\tconst envCommit = process.env.FREETODO_GIT_COMMIT || process.env.GIT_COMMIT;\n\tif (envCommit) {\n\t\treturn envCommit;\n\t}\n\ttry {\n\t\treturn execSync(\"git rev-parse HEAD\", {\n\t\t\tstdio: [\"ignore\", \"pipe\", \"ignore\"],\n\t\t})\n\t\t\t.toString()\n\t\t\t.trim();\n\t} catch {\n\t\treturn null;\n\t}\n}\n\nconst FRONTEND_GIT_COMMIT = getGitCommit();\n\n/**\n * 检查端口是否可用（同时检查 IPv4 和 IPv6）\n * @param {number} port - 要检查的端口\n * @returns {Promise<boolean>} - 端口是否可用\n */\nfunction isPortAvailable(port) {\n\treturn new Promise((resolve) => {\n\t\tconst server = net.createServer();\n\t\tserver.once(\"error\", () => resolve(false));\n\t\tserver.once(\"listening\", () => {\n\t\t\tserver.close();\n\t\t\tresolve(true);\n\t\t});\n\t\t// 使用 '::' 检查 IPv6（包含 IPv4），与 Next.js 默认行为一致\n\t\t// 如果系统不支持 IPv6，会自动回退到 IPv4\n\t\tserver.listen(port, \"::\");\n\t});\n}\n\n/**\n * 查找可用端口\n * @param {number} startPort - 起始端口\n * @param {number} maxAttempts - 最大尝试次数\n * @returns {Promise<number>} - 可用的端口\n */\nasync function findAvailablePort(startPort, maxAttempts = MAX_PORT_ATTEMPTS) {\n\tfor (let offset = 0; offset < maxAttempts; offset++) {\n\t\tconst port = startPort + offset;\n\t\tif (await isPortAvailable(port)) {\n\t\t\tif (offset > 0) {\n\t\t\t\tconsole.log(`Port ${startPort} is in use, using port ${port}`);\n\t\t\t}\n\t\t\treturn port;\n\t\t}\n\t}\n\tthrow new Error(\n\t\t`Cannot find available port in range ${startPort}-${startPort + maxAttempts}`,\n\t);\n}\n\n/**\n * 检查指定端口是否运行着 FreeTodo 后端\n * 通过调用 /health 端点并验证 app 标识来确认是 FreeTodo 后端\n * @param {number} port - 后端端口\n * @returns {Promise<boolean>} - 是否是 FreeTodo 后端\n */\nasync function isFreeTodoBackend(port) {\n\treturn new Promise((resolve) => {\n\t\tconst req = http.get(\n\t\t\t{\n\t\t\t\thostname: \"127.0.0.1\",\n\t\t\t\tport,\n\t\t\t\tpath: \"/health\",\n\t\t\t\ttimeout: 2000,\n\t\t\t},\n\t\t\t(res) => {\n\t\t\t\tlet data = \"\";\n\t\t\t\tres.on(\"data\", (chunk) => {\n\t\t\t\t\tdata += chunk;\n\t\t\t\t});\n\t\t\t\tres.on(\"end\", () => {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst json = JSON.parse(data);\n\t\t\t\t\t\t// 验证是否是 FreeTodo/LifeTrace 后端\n\t\t\t\t\t\t// 只检查固定的应用标识字段\n\t\t\t\t\t\tif (json.app !== \"lifetrace\") {\n\t\t\t\t\t\t\tresolve(false);\n\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tconst backendCommit =\n\t\t\t\t\t\t\ttypeof json.git_commit === \"string\" ? json.git_commit : null;\n\t\t\t\t\t\tif (FRONTEND_GIT_COMMIT) {\n\t\t\t\t\t\t\tif (!backendCommit || backendCommit === \"unknown\") {\n\t\t\t\t\t\t\t\tresolve(false);\n\t\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tif (backendCommit !== FRONTEND_GIT_COMMIT) {\n\t\t\t\t\t\t\t\tconsole.log(\n\t\t\t\t\t\t\t\t\t`Skip backend at ${port}: git commit mismatch (${backendCommit})`,\n\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t\tresolve(false);\n\t\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tresolve(true);\n\t\t\t\t\t} catch {\n\t\t\t\t\t\tresolve(false);\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t\t},\n\t\t);\n\n\t\treq.on(\"error\", () => resolve(false));\n\t\treq.on(\"timeout\", () => {\n\t\t\treq.destroy();\n\t\t\tresolve(false);\n\t\t});\n\t});\n}\n\n/**\n * 查找运行中的 FreeTodo 后端端口\n * @returns {Promise<number|null>} - 运行中的 FreeTodo 后端端口，或 null\n */\nasync function findRunningBackendPort() {\n\t// 先检查开发版默认端口，然后是 Build 版默认端口\n\tconst priorityPorts = [8001, 8000];\n\tfor (const port of priorityPorts) {\n\t\tif (await isFreeTodoBackend(port)) {\n\t\t\treturn port;\n\t\t}\n\t}\n\t// 再检查其他可能的端口（跳过已检查的）\n\tfor (let port = 8002; port < 8100; port++) {\n\t\tif (await isFreeTodoBackend(port)) {\n\t\t\treturn port;\n\t\t}\n\t}\n\treturn null;\n}\n\nasync function main() {\n\tconsole.log(\"Starting development server...\\n\");\n\n\ttry {\n\t\t// 1. Find available frontend port\n\t\t// If PORT env var is set, use it (Electron main process may have allocated a port)\n\t\tlet frontendPort;\n\t\tif (process.env.PORT) {\n\t\t\tfrontendPort = Number.parseInt(process.env.PORT, 10);\n\t\t\tconsole.log(`Using frontend port from env: ${frontendPort}`);\n\t\t} else {\n\t\t\tfrontendPort = await findAvailablePort(DEFAULT_FRONTEND_PORT);\n\t\t\tconsole.log(`Frontend port: ${frontendPort}`);\n\t\t}\n\n\t\t// 2. Find running FreeTodo backend port (verify via /health endpoint)\n\t\tconsole.log(`Searching for FreeTodo backend...`);\n\t\tif (FRONTEND_GIT_COMMIT) {\n\t\t\tconsole.log(`Frontend git commit: ${FRONTEND_GIT_COMMIT}`);\n\t\t}\n\t\tconst backendPort = await findRunningBackendPort();\n\t\tif (backendPort) {\n\t\t\tconsole.log(`Detected FreeTodo backend running on port: ${backendPort}`);\n\t\t} else {\n\t\t\tconst hint = \"Start backend first - python -m lifetrace.server\";\n\t\t\tconst suffix = FRONTEND_GIT_COMMIT\n\t\t\t\t? ` (git commit: ${FRONTEND_GIT_COMMIT})`\n\t\t\t\t: \"\";\n\t\t\tthrow new Error(\n\t\t\t\t`FreeTodo backend not detected via /health endpoint${suffix}. ${hint}`,\n\t\t\t);\n\t\t}\n\n\t\tconst backendUrl = `http://localhost:${backendPort}`;\n\t\tconsole.log(`\\nBackend API: ${backendUrl}`);\n\t\tconsole.log(`Frontend URL: http://localhost:${frontendPort}\\n`);\n\n\t\tconst disableTurbopack = isSymlinkedNodeModules();\n\t\tif (disableTurbopack && !process.env.NEXT_DISABLE_TURBOPACK) {\n\t\t\tconsole.log(\n\t\t\t\t\"Detected symlinked node_modules, disabling Turbopack for compatibility.\",\n\t\t\t);\n\t\t}\n\n\t\tconst nextEnv = {\n\t\t\t...process.env,\n\t\t\tPORT: String(frontendPort),\n\t\t\tNEXT_PUBLIC_API_URL: backendUrl,\n\t\t};\n\n\t\tif (disableTurbopack && !(\"NEXT_DISABLE_TURBOPACK\" in nextEnv)) {\n\t\t\tnextEnv.NEXT_DISABLE_TURBOPACK = \"1\";\n\t\t}\n\n\t\t// 3. 启动 Next.js 开发服务器\n\t\tconst nextArgs = [\"next\", \"dev\", \"--port\", String(frontendPort)];\n\t\tif (disableTurbopack) {\n\t\t\tnextArgs.push(\"--webpack\");\n\t\t}\n\n\t\tconst nextProcess = spawn(\"pnpm\", nextArgs, {\n\t\t\tstdio: \"inherit\",\n\t\t\tenv: {\n\t\t\t\t...nextEnv,\n\t\t\t},\n\t\t\tshell: true,\n\t\t});\n\n\t\t// 处理进程信号\n\t\tprocess.on(\"SIGINT\", () => {\n\t\t\tnextProcess.kill(\"SIGINT\");\n\t\t\tprocess.exit(0);\n\t\t});\n\n\t\tprocess.on(\"SIGTERM\", () => {\n\t\t\tnextProcess.kill(\"SIGTERM\");\n\t\t\tprocess.exit(0);\n\t\t});\n\n\t\tnextProcess.on(\"exit\", (code) => {\n\t\t\tprocess.exit(code || 0);\n\t\t});\n\t} catch (error) {\n\t\tconsole.error(`Failed to start: ${error.message}`);\n\t\tprocess.exit(1);\n\t}\n}\n\nmain();\n"
  },
  {
    "path": "free-todo-frontend/scripts/electron-dev-electron.ps1",
    "content": "# PowerShell script to run Electron only (without starting frontend dev server)\n# This assumes the frontend dev server is already running separately\n# Use: pnpm electron:dev:electron (after starting frontend with pnpm electron:dev:frontend)\n\n# Set console output encoding to UTF-8\n[Console]::OutputEncoding = [System.Text.Encoding]::UTF8\n$OutputEncoding = [System.Text.Encoding]::UTF8\n\n# Change code page to UTF-8 (65001)\nchcp 65001 | Out-Null\n\n# Build Electron main process and run Electron\ncd $PSScriptRoot/..\npnpm electron:build-main\nelectron .\n"
  },
  {
    "path": "free-todo-frontend/scripts/electron-dev.ps1",
    "content": "# PowerShell script to run electron:dev with UTF-8 encoding\n# This ensures Next.js output displays correctly without garbled characters\n\n# Set console output encoding to UTF-8\n[Console]::OutputEncoding = [System.Text.Encoding]::UTF8\n$OutputEncoding = [System.Text.Encoding]::UTF8\n\n# Change code page to UTF-8 (65001)\nchcp 65001 | Out-Null\n\n# Build Electron main process and run Electron\ncd $PSScriptRoot/..\npnpm electron:build-main\nelectron .\n"
  },
  {
    "path": "free-todo-frontend/scripts/resolve-symlinks.js",
    "content": "/**\n * 解析 standalone 构建中的 pnpm 符号链接\n * 将符号链接替换为实际文件，以便在打包的应用中正常工作\n */\n\nconst fs = require(\"node:fs\");\nconst path = require(\"node:path\");\n\nfunction resolveSymlinks(dir) {\n\tconst entries = fs.readdirSync(dir, { withFileTypes: true });\n\n\tfor (const entry of entries) {\n\t\tconst fullPath = path.join(dir, entry.name);\n\n\t\tif (entry.isSymbolicLink()) {\n\t\t\ttry {\n\t\t\t\tconst target = fs.readlinkSync(fullPath);\n\t\t\t\tconst resolvedPath = path.isAbsolute(target)\n\t\t\t\t\t? target\n\t\t\t\t\t: path.resolve(path.dirname(fullPath), target);\n\n\t\t\t\t// 检查目标是否存在\n\t\t\t\tif (fs.existsSync(resolvedPath)) {\n\t\t\t\t\t// 删除符号链接\n\t\t\t\t\tfs.unlinkSync(fullPath);\n\n\t\t\t\t\t// 如果是目录，复制整个目录\n\t\t\t\t\tif (fs.statSync(resolvedPath).isDirectory()) {\n\t\t\t\t\t\tcopyDirectory(resolvedPath, fullPath);\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// 如果是文件，复制文件\n\t\t\t\t\t\tfs.copyFileSync(resolvedPath, fullPath);\n\t\t\t\t\t}\n\n\t\t\t\t\tconsole.log(`Resolved symlink: ${entry.name} -> ${resolvedPath}`);\n\t\t\t\t} else {\n\t\t\t\t\tconsole.warn(`Symlink target not found: ${fullPath} -> ${target}`);\n\t\t\t\t}\n\t\t\t} catch (error) {\n\t\t\t\tconsole.error(`Error resolving symlink ${fullPath}:`, error.message);\n\t\t\t}\n\t\t} else if (entry.isDirectory()) {\n\t\t\t// 递归处理子目录\n\t\t\tresolveSymlinks(fullPath);\n\t\t}\n\t}\n}\n\nfunction copyDirectory(src, dest) {\n\tfs.mkdirSync(dest, { recursive: true });\n\tconst entries = fs.readdirSync(src, { withFileTypes: true });\n\n\tfor (const entry of entries) {\n\t\tconst srcPath = path.join(src, entry.name);\n\t\tconst destPath = path.join(dest, entry.name);\n\n\t\tif (entry.isDirectory()) {\n\t\t\tcopyDirectory(srcPath, destPath);\n\t\t} else {\n\t\t\tfs.copyFileSync(srcPath, destPath);\n\t\t}\n\t}\n}\n\nconst standaloneDir = path.join(\n\t__dirname,\n\t\"..\",\n\t\".next\",\n\t\"standalone\",\n\t\"node_modules\",\n);\n\nif (fs.existsSync(standaloneDir)) {\n\tconsole.log(\"Resolving symlinks in standalone node_modules...\");\n\tresolveSymlinks(standaloneDir);\n\tconsole.log(\"Symlink resolution complete!\");\n} else {\n\tconsole.warn(`Standalone node_modules not found at: ${standaloneDir}`);\n\tconsole.warn(\"Skipping symlink resolution.\");\n}\n"
  },
  {
    "path": "free-todo-frontend/scripts/tauri-copy-resources.js",
    "content": "const fs = require(\"node:fs\");\nconst path = require(\"node:path\");\n\nfunction parseArgs() {\n\tconst args = process.argv.slice(2);\n\tconst result = {};\n\tfor (let i = 0; i < args.length; i += 1) {\n\t\tconst key = args[i];\n\t\tconst value = args[i + 1];\n\t\tif (key?.startsWith(\"--\") && value && !value.startsWith(\"--\")) {\n\t\t\tresult[key.slice(2)] = value;\n\t\t\ti += 0;\n\t\t}\n\t}\n\treturn result;\n}\n\nfunction copyDir(src, dest) {\n\tif (!fs.existsSync(src)) {\n\t\tconsole.warn(`Source not found, skipping: ${src}`);\n\t\treturn;\n\t}\n\tfs.mkdirSync(dest, { recursive: true });\n\tfs.cpSync(src, dest, { recursive: true, force: true });\n\tconsole.log(`Copied ${src} -> ${dest}`);\n}\n\nfunction findLatestReleaseDir(targetRoot) {\n\tif (!fs.existsSync(targetRoot)) {\n\t\treturn null;\n\t}\n\n\tconst entries = fs.readdirSync(targetRoot, { withFileTypes: true });\n\tconst candidates = [];\n\n\tfor (const entry of entries) {\n\t\tif (!entry.isDirectory()) {\n\t\t\tcontinue;\n\t\t}\n\t\tconst releaseDir = path.join(targetRoot, entry.name, \"release\");\n\t\tif (fs.existsSync(releaseDir)) {\n\t\t\tconst stat = fs.statSync(releaseDir);\n\t\t\tcandidates.push({ dir: releaseDir, mtimeMs: stat.mtimeMs });\n\t\t}\n\t}\n\n\tcandidates.sort((a, b) => b.mtimeMs - a.mtimeMs);\n\treturn candidates[0]?.dir ?? null;\n}\n\nconst args = parseArgs();\nconst rootDir = path.resolve(__dirname, \"..\");\nconst tauriTargetDir = path.join(rootDir, \"src-tauri\", \"target\");\n\nlet releaseDir = null;\nif (args.target) {\n\treleaseDir = path.join(tauriTargetDir, args.target, \"release\");\n} else {\n\tconst defaultRelease = path.join(tauriTargetDir, \"release\");\n\tif (fs.existsSync(defaultRelease)) {\n\t\treleaseDir = defaultRelease;\n\t} else {\n\t\treleaseDir = findLatestReleaseDir(tauriTargetDir);\n\t}\n}\n\nif (!releaseDir || !fs.existsSync(releaseDir)) {\n\tconsole.error(\"Release directory not found. Did tauri build finish?\");\n\tprocess.exit(1);\n}\n\nconst resourcesDir = path.join(releaseDir, \"resources\");\nfs.mkdirSync(resourcesDir, { recursive: true });\n\nconst standaloneSrc = path.join(rootDir, \".next\", \"standalone\");\nconst standaloneDest = path.join(resourcesDir, \"standalone\");\ncopyDir(standaloneSrc, standaloneDest);\n\nconst backendSrc = path.join(rootDir, \"..\", \"dist-backend\");\nconst backendDest = path.join(resourcesDir, \"dist-backend\");\ncopyDir(backendSrc, backendDest);\n"
  },
  {
    "path": "free-todo-frontend/scripts/tauri-prebuild.js",
    "content": "const fs = require(\"node:fs\");\nconst path = require(\"node:path\");\nconst { execSync } = require(\"node:child_process\");\n\nconst distDir = path.join(__dirname, \"..\", \"src-tauri\", \"dist\");\nconst indexPath = path.join(distDir, \"index.html\");\n\nfs.mkdirSync(distDir, { recursive: true });\n\nconst html = `<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"utf-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n    <title>FreeTodo</title>\n    <style>\n      body {\n        margin: 0;\n        font-family: \"Segoe UI\", Arial, sans-serif;\n        background: #0b0e11;\n        color: #e6e9ef;\n        display: flex;\n        align-items: center;\n        justify-content: center;\n        min-height: 100vh;\n      }\n      .card {\n        text-align: center;\n        max-width: 420px;\n        padding: 24px;\n        border-radius: 16px;\n        background: radial-gradient(circle at top, #1c2230 0%, #0f141a 60%);\n        box-shadow: 0 18px 50px rgba(0, 0, 0, 0.4);\n      }\n      h1 {\n        margin: 0 0 8px 0;\n        font-size: 22px;\n        font-weight: 600;\n      }\n      p {\n        margin: 0;\n        font-size: 14px;\n        opacity: 0.7;\n      }\n      .spinner {\n        width: 28px;\n        height: 28px;\n        margin: 16px auto 0;\n        border-radius: 50%;\n        border: 3px solid rgba(230, 233, 239, 0.2);\n        border-top-color: #e6e9ef;\n        animation: spin 0.9s linear infinite;\n      }\n      @keyframes spin {\n        to {\n          transform: rotate(360deg);\n        }\n      }\n    </style>\n  </head>\n  <body>\n    <div class=\"card\">\n      <h1>Starting FreeTodo...</h1>\n      <p>Waiting for the local web server.</p>\n      <div class=\"spinner\"></div>\n    </div>\n    <script>\n      const startPort = 3100;\n      const maxAttempts = 50;\n      const retryDelayMs = 600;\n      const maxWaitMs = 30000;\n\n      const startTime = Date.now();\n\n      async function isServerUp(url) {\n        try {\n          await fetch(url, { mode: \"no-cors\", cache: \"no-store\" });\n          return true;\n        } catch {\n          return false;\n        }\n      }\n\n      async function tryPort(port) {\n        const url = \"http://localhost:\" + port;\n        const ok = await isServerUp(url);\n        if (ok) {\n          window.location.replace(url);\n          return true;\n        }\n        return false;\n      }\n\n      async function poll() {\n        for (let attempt = 0; attempt < maxAttempts; attempt += 1) {\n          const port = startPort + attempt;\n          const ready = await tryPort(port);\n          if (ready) {\n            return;\n          }\n        }\n      }\n\n      async function loop() {\n        while (Date.now() - startTime < maxWaitMs) {\n          await poll();\n          await new Promise((resolve) => setTimeout(resolve, retryDelayMs));\n        }\n      }\n\n      void loop();\n    </script>\n  </body>\n</html>\n`;\n\nfs.writeFileSync(indexPath, html, \"utf8\");\nconsole.log(`Wrote ${indexPath}`);\n\nfunction copyDir(src, dest) {\n\tif (!fs.existsSync(src)) {\n\t\treturn;\n\t}\n\tfs.mkdirSync(dest, { recursive: true });\n\tfs.cpSync(src, dest, { recursive: true, force: true });\n}\n\nconst rootDir = path.join(__dirname, \"..\");\nconst nextDir = path.join(rootDir, \".next\");\nconst standaloneDir = path.join(nextDir, \"standalone\");\n\nif (fs.existsSync(standaloneDir)) {\n\tconst staticSrc = path.join(nextDir, \"static\");\n\tconst staticDest = path.join(standaloneDir, \".next\", \"static\");\n\tconst publicSrc = path.join(rootDir, \"public\");\n\tconst publicDest = path.join(standaloneDir, \"public\");\n\n\tcopyDir(staticSrc, staticDest);\n\tcopyDir(publicSrc, publicDest);\n\n\ttry {\n\t\texecSync(\"node scripts/resolve-symlinks.js\", { cwd: rootDir, stdio: \"inherit\" });\n\t} catch (error) {\n\t\tconsole.warn(`Failed to resolve symlinks: ${error.message}`);\n\t}\n\n\ttry {\n\t\texecSync(\"node scripts/copy-missing-deps.js\", { cwd: rootDir, stdio: \"inherit\" });\n\t} catch (error) {\n\t\tconsole.warn(`Failed to copy missing deps: ${error.message}`);\n\t}\n} else {\n\tconsole.warn(`Standalone output not found at: ${standaloneDir}`);\n}\n"
  },
  {
    "path": "free-todo-frontend/src-tauri/.tauri-lint-dist/.gitkeep",
    "content": ""
  },
  {
    "path": "free-todo-frontend/src-tauri/Cargo.toml",
    "content": "[package]\nname = \"free-todo\"\nversion = \"0.1.2\"\ndescription = \"FreeTodo - Your Intelligent Todo Application\"\nlicense-file = \"../../LICENSE\"\nrepository = \"https://github.com/FreeU-group/FreeTodo\"\nedition = \"2021\"\n\n[build-dependencies]\ntauri-build = { version = \"=2.5.5\", features = [] }\n\n[dependencies]\ntauri = { version = \"=2.9.1\", features = [\"tray-icon\", \"protocol-asset\"] }\ntauri-plugin-shell = \"=2.3.3\"\ntauri-plugin-notification = \"2.3.3\"\ntauri-plugin-global-shortcut = \"2.3.1\"\ntokio = { version = \"1.49.0\", features = [\"full\"] }\nreqwest = { version = \"0.13.1\", features = [\"json\", \"stream\"] }\nserde = { version = \"1.0.228\", features = [\"derive\"] }\nserde_json = \"1.0.149\"\nlog = \"0.4.29\"\nenv_logger = \"0.11.8\"\npng = \"0.18.0\"\naxum = \"0.8.8\"\nrand = \"0.9.2\"\nflate2 = \"1\"\ntar = \"0.4\"\nzip = \"7.2.0\"\nfutures-util = \"0.3\"\n\n[target.'cfg(unix)'.dependencies]\nlibc = \"0.2.180\"\n\n[features]\ndefault = [\"custom-protocol\"]\ncustom-protocol = [\"tauri/custom-protocol\"]\n"
  },
  {
    "path": "free-todo-frontend/src-tauri/PACKAGING_GUIDE.md",
    "content": "# FreeTodo Tauri Packaging Guide (Web Mode)\n\nThis document describes how to build and locate Tauri packaging outputs for the **Web mode** app.\n\nIsland mode is **not packaged** yet (still in development).\n\n## Table of Contents\n\n- [Quick Start](#quick-start)\n- [Build Outputs](#build-outputs)\n- [Build Notes](#build-notes)\n- [Troubleshooting](#troubleshooting)\n\n## Quick Start\n\nRun from the repository root:\n\n```bash\ncd free-todo-frontend\n\n# Web mode (default)\npnpm build:tauri:web:full\n\n# Platform specific\npnpm build:tauri:web:full:win\npnpm build:tauri:web:full:mac\npnpm build:tauri:web:full:linux\n```\n\n## Build Outputs\n\nTauri build artifacts are written under:\n\n```\nfree-todo-frontend/src-tauri/target/<profile>/bundle/\n```\n\nWhere `<profile>` is:\n- `release` for `tauri build` (default)\n- `debug` for `tauri build --debug`\n\n### Windows (NSIS)\n\n```\nfree-todo-frontend/src-tauri/target/release/bundle/nsis/\n  FreeTodo_<version>_x64-setup.exe\n```\n\n### macOS (app / dmg)\n\n```\nfree-todo-frontend/src-tauri/target/release/bundle/macos/\n  FreeTodo.app\n  FreeTodo_<version>_universal.dmg\n```\n\n### Linux (AppImage / deb)\n\n```\nfree-todo-frontend/src-tauri/target/release/bundle/\n  appimage/FreeTodo_<version>_amd64.AppImage\n  deb/free-todo_<version>_amd64.deb\n```\n\n## Build Notes\n\n### Web Mode Only\n\nCurrent Tauri configuration builds **Web mode only**:\n\n- Standard window (1200x800)\n- With window decorations\n- Non-transparent\n\nIsland mode is not packaged by default.\n\n### Frontend Assets\n\nTauri uses a local loading page:\n\n```\nfree-todo-frontend/src-tauri/dist/index.html\n```\n\nThis page redirects to the running Next.js server.\n\n### Next.js Build\n\nThe build command runs:\n\n```\npnpm build:frontend:web\n```\n\nNext.js artifacts:\n\n```\nfree-todo-frontend/.next/\n```\n\n## Troubleshooting\n\n### Where is the app after build?\n\nCheck:\n\n```\nfree-todo-frontend/src-tauri/target/release/bundle/\n```\n\n### Build uses the wrong window mode\n\nTauri currently packages **Web mode only**. Island mode is intentionally excluded.\n\n---\n\n**Last Updated**: 2026-01-29\n"
  },
  {
    "path": "free-todo-frontend/src-tauri/build.rs",
    "content": "fn main() {\n    tauri_build::build()\n}\n"
  },
  {
    "path": "free-todo-frontend/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<adaptive-icon xmlns:android=\"http://schemas.android.com/apk/res/android\">\n  <foreground android:drawable=\"@mipmap/ic_launcher_foreground\"/>\n  <background android:drawable=\"@color/ic_launcher_background\"/>\n</adaptive-icon>\n"
  },
  {
    "path": "free-todo-frontend/src-tauri/icons/android/values/ic_launcher_background.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n  <color name=\"ic_launcher_background\">#fff</color>\n</resources>\n"
  },
  {
    "path": "free-todo-frontend/src-tauri/rust-toolchain.toml",
    "content": "[toolchain]\nchannel = \"stable\"\ncomponents = [\"rustfmt\", \"clippy\"]\n"
  },
  {
    "path": "free-todo-frontend/src-tauri/rustfmt.toml",
    "content": "# Rust formatting configuration\n# See: https://rust-lang.github.io/rustfmt/\n\n# Maximum line width\nmax_width = 100\n\n# Use spaces for indentation\nhard_tabs = false\ntab_spaces = 4\n\n# Edition\nedition = \"2021\"\n\n# Imports organization (stable options only)\nreorder_imports = true\n\n# Formatting\nnewline_style = \"Auto\"\nuse_small_heuristics = \"Default\"\n"
  },
  {
    "path": "free-todo-frontend/src-tauri/src/backend.rs",
    "content": "//! Python Backend Sidecar Management\n//!\n//! This module handles the lifecycle of the Python backend server,\n//! including starting, health checking, proxying, and stopping the process.\n\nuse crate::backend_log::{emit_backend_log, format_download_progress, spawn_log_reader};\nuse crate::backend_paths::{\n    get_backend_path, get_backend_script_entry, get_backend_script_root, get_data_dir,\n    get_requirements_path, get_runtime_root,\n};\nuse crate::backend_proxy::{start_proxy_server, ProxyState};\nuse crate::backend_python::{\n    ensure_uv, ensure_uv_binary_with_progress, ensure_uv_python, ensure_uv_venv, ensure_venv,\n    find_python312, install_requirements, uv_env_pairs,\n};\nuse crate::backend_support::{\n    check_backend_health as check_backend_health_with_timeout, detect_running_backend_port,\n    is_lifetrace_backend, pick_backend_port, verify_backend_mode, wait_for_backend,\n};\nuse crate::config::{self, timeouts, ServerMode};\nuse log::{error, info, warn};\nuse std::path::Path;\nuse std::process::{Child, Command, Stdio};\nuse std::sync::atomic::{AtomicBool, AtomicU16, Ordering};\nuse std::sync::{Arc, Mutex, OnceLock};\nuse std::time::Duration;\nuse tauri::AppHandle;\n\nstruct BackendState {\n    backend_port: Arc<AtomicU16>,\n    ready: Arc<AtomicBool>,\n    proxy_port: AtomicU16,\n    stopping: AtomicBool,\n    proxy_started: AtomicBool,\n    process: Mutex<Option<Child>>,\n    uv_synced: AtomicBool,\n}\n\nstatic STATE: OnceLock<BackendState> = OnceLock::new();\n\nfn state() -> &'static BackendState {\n    STATE.get_or_init(|| BackendState {\n        backend_port: Arc::new(AtomicU16::new(0)),\n        ready: Arc::new(AtomicBool::new(false)),\n        proxy_port: AtomicU16::new(0),\n        stopping: AtomicBool::new(false),\n        proxy_started: AtomicBool::new(false),\n        process: Mutex::new(None),\n        uv_synced: AtomicBool::new(false),\n    })\n}\n\n/// Backend runtime type\n#[derive(Debug, Clone, Copy, PartialEq)]\nenum BackendRuntime {\n    Uv,\n    Script,\n    PyInstaller,\n}\n\n/// Determine backend runtime from env or build-time default\nfn get_backend_runtime() -> BackendRuntime {\n    if let Ok(value) = std::env::var(\"FREETODO_BACKEND_RUNTIME\") {\n        let normalized = value.to_lowercase();\n        if normalized == \"uv\" || normalized == \"uv-run\" || normalized == \"uvrun\" {\n            return BackendRuntime::Uv;\n        }\n        if normalized == \"pyinstaller\" {\n            return BackendRuntime::PyInstaller;\n        }\n        if normalized == \"script\" {\n            return BackendRuntime::Script;\n        }\n    }\n\n    if let Some(value) = option_env!(\"FREETODO_BACKEND_RUNTIME\") {\n        if value.eq_ignore_ascii_case(\"pyinstaller\") {\n            return BackendRuntime::PyInstaller;\n        }\n        if value.eq_ignore_ascii_case(\"script\") {\n            return BackendRuntime::Script;\n        }\n        if value.eq_ignore_ascii_case(\"uv\") || value.eq_ignore_ascii_case(\"uv-run\") {\n            return BackendRuntime::Uv;\n        }\n    }\n\n    BackendRuntime::Uv\n}\n\nfn run_uv_sync_if_needed(backend_root: &Path) -> Result<(), String> {\n    let state = state();\n    if state.uv_synced.load(Ordering::Relaxed) {\n        return Ok(());\n    }\n\n    let mut cmd = Command::new(\"uv\");\n    cmd.arg(\"sync\").current_dir(backend_root);\n    for (key, value) in uv_env_pairs() {\n        cmd.env(key, value);\n    }\n\n    let status = cmd\n        .status()\n        .map_err(|e| format!(\"Failed to run uv sync: {}\", e))?;\n    if status.success() {\n        state.uv_synced.store(true, Ordering::Relaxed);\n        Ok(())\n    } else {\n        Err(format!(\"uv sync failed with status {}\", status))\n    }\n}\n\nfn server_mode() -> ServerMode {\n    ServerMode::current()\n}\n\nfn mode_label(mode: ServerMode) -> &'static str {\n    match mode {\n        ServerMode::Dev => \"dev\",\n        ServerMode::Build => \"build\",\n    }\n}\n\nconst BACKEND_LOG_LABEL: &str = \"backend\";\n\n/// Get the backend URL (proxy port)\npub fn get_backend_url() -> String {\n    let port = state().proxy_port.load(Ordering::Relaxed);\n    let port = if port == 0 {\n        config::ports::backend_port(server_mode())\n    } else {\n        port\n    };\n    format!(\"http://127.0.0.1:{}\", port)\n}\n\n/// Check backend health\npub async fn check_backend_health(\n    port: u16,\n) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {\n    check_backend_health_with_timeout(port, timeouts::HEALTH_CHECK).await\n}\n\n/// Start the Python backend server (with proxy)\npub async fn start_backend(\n    app: &AppHandle,\n) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {\n    let state = state();\n    let mode = server_mode();\n    let proxy_port = config::ports::backend_port(mode);\n\n    state.stopping.store(false, Ordering::Relaxed);\n    state.backend_port.store(0, Ordering::Relaxed);\n    state.ready.store(false, Ordering::Relaxed);\n    state.proxy_port.store(proxy_port, Ordering::Relaxed);\n\n    if !state.proxy_started.swap(true, Ordering::Relaxed) {\n        let proxy_state = ProxyState::new(state.backend_port.clone(), state.ready.clone());\n        if let Err(err) = start_proxy_server(proxy_port, proxy_state).await {\n            state.proxy_started.store(false, Ordering::Relaxed);\n            if is_lifetrace_backend(proxy_port).await {\n                warn!(\n                    \"Proxy port {} already has a backend instance, using it directly\",\n                    proxy_port\n                );\n                state.backend_port.store(proxy_port, Ordering::Relaxed);\n                state.ready.store(true, Ordering::Relaxed);\n            } else {\n                return Err(err.into());\n            }\n        }\n    }\n\n    let app_handle = app.clone();\n    tokio::spawn(async move {\n        if let Err(err) = backend_supervisor(app_handle, mode).await {\n            error!(\"Backend supervisor exited: {}\", err);\n        }\n    });\n\n    Ok(())\n}\n\nasync fn backend_supervisor(app: AppHandle, mode: ServerMode) -> Result<(), String> {\n    let state = state();\n    let mut backoff = Duration::from_millis(500);\n    let max_backoff = Duration::from_secs(10);\n    let interval = Duration::from_millis(config::health_check::BACKEND_INTERVAL);\n\n    loop {\n        if state.stopping.load(Ordering::Relaxed) {\n            break;\n        }\n\n        let mut exited = false;\n        let mut managed = false;\n        {\n            let mut guard = state.process.lock().unwrap();\n            if let Some(child) = guard.as_mut() {\n                managed = true;\n                match child.try_wait() {\n                    Ok(Some(status)) => {\n                        warn!(\"Backend exited: {}\", status);\n                        *guard = None;\n                        exited = true;\n                    }\n                    Ok(None) => {}\n                    Err(err) => {\n                        warn!(\"Failed to check backend status: {}\", err);\n                    }\n                }\n            }\n        }\n\n        if exited {\n            state.ready.store(false, Ordering::Relaxed);\n            state.backend_port.store(0, Ordering::Relaxed);\n        }\n\n        let backend_port = state.backend_port.load(Ordering::Relaxed);\n\n        if managed {\n            if backend_port != 0 {\n                let healthy = check_backend_health(backend_port).await.unwrap_or(false);\n                state.ready.store(healthy, Ordering::Relaxed);\n                if !healthy {\n                    warn!(\"Backend health check failed\");\n                }\n            }\n            tokio::time::sleep(interval).await;\n            continue;\n        }\n\n        if backend_port != 0 {\n            let healthy = check_backend_health(backend_port).await.unwrap_or(false);\n            if healthy {\n                state.ready.store(true, Ordering::Relaxed);\n                tokio::time::sleep(interval).await;\n                continue;\n            }\n            state.ready.store(false, Ordering::Relaxed);\n            state.backend_port.store(0, Ordering::Relaxed);\n        }\n\n        if let Some(port) = detect_running_backend_port(mode).await {\n            state.backend_port.store(port, Ordering::Relaxed);\n            state.ready.store(true, Ordering::Relaxed);\n            backoff = Duration::from_millis(500);\n            tokio::time::sleep(interval).await;\n            continue;\n        }\n\n        match start_backend_process(&app, mode).await {\n            Ok(port) => {\n                state.backend_port.store(port, Ordering::Relaxed);\n                state.ready.store(true, Ordering::Relaxed);\n                backoff = Duration::from_millis(500);\n                emit_backend_log(&app, format!(\"Backend ready on port {}\", port));\n            }\n            Err(err) => {\n                state.ready.store(false, Ordering::Relaxed);\n                warn!(\"Failed to start backend: {}\", err);\n                emit_backend_log(&app, format!(\"Backend start failed: {}\", err));\n                tokio::time::sleep(backoff).await;\n                backoff = (backoff * 2).min(max_backoff);\n            }\n        }\n\n        tokio::time::sleep(interval).await;\n    }\n\n    Ok(())\n}\n\nasync fn start_backend_process(app: &AppHandle, mode: ServerMode) -> Result<u16, String> {\n    let state = state();\n    let backend_runtime = get_backend_runtime();\n    let port = pick_backend_port(mode)?;\n    let mode_label = mode_label(mode);\n\n    state.ready.store(false, Ordering::Relaxed);\n\n    let backend_path = if backend_runtime == BackendRuntime::PyInstaller {\n        get_backend_path(app).map_err(|e| {\n            warn!(\"Backend executable not found: {}\", e);\n            e\n        })?\n    } else {\n        let backend_root = get_backend_script_root(app)?;\n        get_backend_script_entry(&backend_root)\n    };\n\n    let data_dir = get_data_dir(app, mode)?;\n    let mut backend_workdir = backend_path.parent().unwrap_or(&backend_path).to_path_buf();\n\n    let mut command = if backend_runtime == BackendRuntime::Uv {\n        emit_backend_log(app, \"Starting backend with uv runtime...\");\n        let backend_root = get_backend_script_root(app)?;\n        backend_workdir = backend_root;\n        run_uv_sync_if_needed(&backend_workdir)?;\n        let mut cmd = Command::new(\"uv\");\n        cmd.args([\n            \"run\",\n            \"python\",\n            \"-m\",\n            \"lifetrace.server\",\n            \"--port\",\n            &port.to_string(),\n            \"--mode\",\n            mode_label,\n        ]);\n        for (key, value) in uv_env_pairs() {\n            cmd.env(key, value);\n        }\n        cmd\n    } else if backend_runtime == BackendRuntime::Script {\n        emit_backend_log(app, \"Preparing script runtime environment...\");\n        let runtime_root = get_runtime_root(app)?;\n        let venv_dir = runtime_root.join(\"python-venv\");\n        let backend_root = get_backend_script_root(app)?;\n        let requirements_path = get_requirements_path(&backend_root);\n        if !requirements_path.exists() {\n            return Err(format!(\n                \"Requirements file not found at {:?}\",\n                requirements_path\n            ));\n        }\n        let mut venv_python = None;\n\n        emit_backend_log(app, \"Ensuring uv binary is available...\");\n        match ensure_uv_binary_with_progress(&runtime_root, |progress| {\n            emit_backend_log(app, format_download_progress(&progress));\n        })\n        .await\n        {\n            Ok(uv_path) => {\n                emit_backend_log(app, format!(\"uv ready at {}\", uv_path.display()));\n                emit_backend_log(app, \"Ensuring Python 3.12 via uv...\");\n                if let Err(err) = ensure_uv_python(uv_path.as_path()) {\n                    emit_backend_log(app, format!(\"uv python install failed: {}\", err));\n                } else {\n                    emit_backend_log(app, \"uv Python install completed.\");\n                    emit_backend_log(app, \"Creating virtual environment with uv...\");\n                    match ensure_uv_venv(uv_path.as_path(), venv_dir.as_path()) {\n                        Ok(path) => {\n                            emit_backend_log(app, \"uv venv created.\");\n                            emit_backend_log(app, \"Installing backend dependencies with uv...\");\n                            if let Err(err) = install_requirements(\n                                uv_path.as_path(),\n                                path.as_path(),\n                                requirements_path.as_path(),\n                            ) {\n                                emit_backend_log(\n                                    app,\n                                    format!(\"uv dependency install failed: {}\", err),\n                                );\n                            } else {\n                                emit_backend_log(app, \"uv dependency install completed.\");\n                                venv_python = Some(path);\n                            }\n                        }\n                        Err(err) => {\n                            emit_backend_log(app, format!(\"uv venv creation failed: {}\", err));\n                        }\n                    }\n                }\n            }\n            Err(err) => {\n                emit_backend_log(app, format!(\"uv download failed: {}\", err));\n            }\n        }\n\n        if venv_python.is_none() {\n            emit_backend_log(app, \"Falling back to system Python 3.12...\");\n            let system_python = find_python312().ok_or(\"Python 3.12 not found\")?;\n            let fallback_python = ensure_venv(system_python.as_path(), venv_dir.as_path())?;\n            emit_backend_log(app, \"Installing uv in virtual environment...\");\n            let uv_path = ensure_uv(fallback_python.as_path(), venv_dir.as_path())?;\n            emit_backend_log(app, \"Installing backend dependencies with uv...\");\n            install_requirements(\n                uv_path.as_path(),\n                fallback_python.as_path(),\n                requirements_path.as_path(),\n            )?;\n            emit_backend_log(app, \"uv dependency install completed.\");\n            venv_python = Some(fallback_python);\n        }\n\n        let venv_python = venv_python.ok_or(\"Failed to prepare Python runtime\")?;\n        if !venv_python.exists() {\n            return Err(\"Virtual environment python not found\".to_string());\n        }\n\n        backend_workdir = backend_root;\n        let mut cmd = Command::new(venv_python);\n        cmd.arg(&backend_path);\n        cmd\n    } else {\n        Command::new(&backend_path)\n    };\n\n    if backend_runtime != BackendRuntime::Uv {\n        command.args([\n            \"--port\",\n            &port.to_string(),\n            \"--data-dir\",\n            data_dir.to_str().unwrap_or(\"\"),\n            \"--mode\",\n            mode_label,\n        ]);\n    }\n\n    command\n        .current_dir(backend_workdir)\n        .env(\"PYTHONUNBUFFERED\", \"1\")\n        .env(\"PYTHONUTF8\", \"1\")\n        .env(\"LIFETRACE_DATA_DIR\", data_dir.to_str().unwrap_or(\"\"))\n        .env(\"LIFETRACE__OBSERVABILITY__ENABLED\", \"false\")\n        .env(\"LIFETRACE__SERVER__DEBUG\", \"false\");\n\n    info!(\"Starting backend server on port {}\", port);\n    info!(\"Backend runtime: {:?}\", backend_runtime);\n    info!(\"Backend path: {:?}\", backend_path);\n    info!(\"Data directory: {:?}\", data_dir);\n    info!(\"Server mode: {}\", mode_label);\n\n    let mut child = command\n        .stdout(Stdio::piped())\n        .stderr(Stdio::piped())\n        .spawn()\n        .map_err(|e| format!(\"Failed to start backend: {}\", e))?;\n\n    if let Some(stdout) = child.stdout.take() {\n        spawn_log_reader(app.clone(), stdout, BACKEND_LOG_LABEL);\n    }\n    if let Some(stderr) = child.stderr.take() {\n        spawn_log_reader(app.clone(), stderr, BACKEND_LOG_LABEL);\n    }\n\n    {\n        let mut guard = state.process.lock().unwrap();\n        *guard = Some(child);\n    }\n\n    info!(\"Waiting for backend server to be ready...\");\n    if let Err(err) = wait_for_backend(\n        port,\n        timeouts::BACKEND_READY / 1000,\n        timeouts::HEALTH_CHECK,\n        timeouts::HEALTH_CHECK_RETRY,\n    )\n    .await\n    {\n        stop_managed_backend();\n        emit_backend_log(app, format!(\"Backend failed to become ready: {}\", err));\n        return Err(err);\n    }\n    info!(\"Backend server is ready at http://127.0.0.1:{}\", port);\n\n    if let Err(err) = verify_backend_mode(port, mode_label).await {\n        stop_managed_backend();\n        return Err(err);\n    }\n\n    Ok(port)\n}\n\nfn stop_managed_backend() {\n    let state = state();\n    let mut guard = state.process.lock().unwrap();\n    if let Some(child) = guard.take() {\n        #[cfg(unix)]\n        {\n            unsafe {\n                libc::kill(child.id() as i32, libc::SIGTERM);\n            }\n        }\n\n        #[cfg(windows)]\n        {\n            let mut child = child;\n            let _ = child.kill();\n        }\n    }\n}\n\n/// Stop the backend server\npub fn stop_backend() {\n    let state = state();\n    state.stopping.store(true, Ordering::Relaxed);\n    state.ready.store(false, Ordering::Relaxed);\n\n    let mut guard = state.process.lock().unwrap();\n    if let Some(mut child) = guard.take() {\n        info!(\"Stopping backend server...\");\n\n        // Try graceful shutdown first\n        #[cfg(unix)]\n        {\n            unsafe {\n                libc::kill(child.id() as i32, libc::SIGTERM);\n            }\n        }\n\n        #[cfg(windows)]\n        {\n            let _ = child.kill();\n        }\n\n        // Wait a bit for graceful shutdown\n        std::thread::sleep(Duration::from_secs(2));\n\n        // Force kill if still running\n        match child.try_wait() {\n            Ok(Some(_)) => {\n                info!(\"Backend server stopped gracefully\");\n            }\n            Ok(None) => {\n                warn!(\"Backend server did not stop gracefully, forcing kill\");\n                let _ = child.kill();\n            }\n            Err(e) => {\n                error!(\"Error checking backend status: {}\", e);\n                let _ = child.kill();\n            }\n        }\n    }\n}\n\n/// Cleanup on application exit\npub fn cleanup() {\n    stop_backend();\n}\n"
  },
  {
    "path": "free-todo-frontend/src-tauri/src/backend_log.rs",
    "content": "//! Backend logging helpers\n\nuse crate::backend_python::DownloadProgress;\nuse log::info;\nuse std::io::{BufRead, BufReader};\nuse tauri::{AppHandle, Emitter};\n\npub fn emit_backend_log(app: &AppHandle, message: impl Into<String>) {\n    let message = message.into();\n    info!(\"backend-log: {}\", message);\n    let _ = app.emit(\"backend-log\", message);\n}\n\npub fn spawn_log_reader(\n    app: AppHandle,\n    stream: impl std::io::Read + Send + 'static,\n    label: &'static str,\n) {\n    std::thread::spawn(move || {\n        let reader = BufReader::new(stream);\n        for line in reader.lines().map_while(Result::ok) {\n            emit_backend_log(&app, format!(\"[{}] {}\", label, line));\n        }\n    });\n}\n\npub fn format_download_progress(progress: &DownloadProgress) -> String {\n    match progress.total_bytes {\n        Some(total) if total > 0 => {\n            let percent = ((progress.received_bytes * 100) / total).min(100);\n            format!(\n                \"Downloading uv: {}% ({} / {})\",\n                percent,\n                format_bytes(progress.received_bytes),\n                format_bytes(total)\n            )\n        }\n        _ => format!(\"Downloading uv: {}\", format_bytes(progress.received_bytes)),\n    }\n}\n\nfn format_bytes(bytes: u64) -> String {\n    const KB: f64 = 1024.0;\n    const MB: f64 = KB * 1024.0;\n    const GB: f64 = MB * 1024.0;\n    let value = bytes as f64;\n    if value >= GB {\n        format!(\"{:.1} GB\", value / GB)\n    } else if value >= MB {\n        format!(\"{:.1} MB\", value / MB)\n    } else if value >= KB {\n        format!(\"{:.1} KB\", value / KB)\n    } else {\n        format!(\"{} B\", bytes)\n    }\n}\n"
  },
  {
    "path": "free-todo-frontend/src-tauri/src/backend_paths.rs",
    "content": "//! Backend path resolution helpers\n\nuse crate::config::{process, ServerMode};\nuse std::path::{Path, PathBuf};\nuse tauri::{AppHandle, Manager};\n\n/// Get backend executable path for PyInstaller runtime\npub fn get_backend_path(app: &AppHandle) -> Result<PathBuf, String> {\n    let resource_path = app\n        .path()\n        .resource_dir()\n        .map_err(|e| format!(\"Failed to get resource dir: {}\", e))?;\n\n    let packaged_backend = resource_path\n        .join(\"backend\")\n        .join(process::BACKEND_EXEC_NAME);\n    if packaged_backend.exists() {\n        return Ok(packaged_backend);\n    }\n\n    let packaged_dist = resource_path\n        .join(\"dist-backend\")\n        .join(process::BACKEND_EXEC_NAME);\n    if packaged_dist.exists() {\n        return Ok(packaged_dist);\n    }\n\n    // Development mode: try dist-backend\n    let dev_path = std::env::current_dir()\n        .map_err(|e| format!(\"Failed to get current dir: {}\", e))?\n        .parent()\n        .ok_or(\"Failed to get parent dir\")?\n        .join(\"dist-backend\")\n        .join(process::BACKEND_EXEC_NAME);\n\n    if dev_path.exists() {\n        Ok(dev_path)\n    } else {\n        Err(format!(\n            \"Backend executable not found at {:?} or {:?} or {:?}\",\n            packaged_backend, packaged_dist, dev_path\n        ))\n    }\n}\n\n/// Locate backend script root (for script runtime)\npub fn get_backend_script_root(app: &AppHandle) -> Result<PathBuf, String> {\n    let resource_path = app\n        .path()\n        .resource_dir()\n        .map_err(|e| format!(\"Failed to get resource dir: {}\", e))?;\n\n    let candidates = [\n        resource_path.join(\"backend\"),\n        resource_path.join(\"lifetrace\"),\n    ];\n\n    for candidate in candidates {\n        let script_path = candidate\n            .join(\"lifetrace\")\n            .join(\"scripts\")\n            .join(\"start_backend.py\");\n        if script_path.exists() {\n            return Ok(candidate);\n        }\n        let direct_script = candidate.join(\"scripts\").join(\"start_backend.py\");\n        if direct_script.exists() {\n            return Ok(candidate);\n        }\n    }\n\n    // Development fallback\n    let dev_root = std::env::current_dir()\n        .map_err(|e| format!(\"Failed to get current dir: {}\", e))?\n        .parent()\n        .ok_or(\"Failed to get parent dir\")?\n        .to_path_buf();\n    if dev_root\n        .join(\"lifetrace\")\n        .join(\"scripts\")\n        .join(\"start_backend.py\")\n        .exists()\n    {\n        return Ok(dev_root);\n    }\n\n    Err(\"Backend script not found in resources\".to_string())\n}\n\npub fn get_backend_script_entry(root: &Path) -> PathBuf {\n    let nested = root\n        .join(\"lifetrace\")\n        .join(\"scripts\")\n        .join(\"start_backend.py\");\n    if nested.exists() {\n        return nested;\n    }\n    root.join(\"scripts\").join(\"start_backend.py\")\n}\n\npub fn get_requirements_path(root: &Path) -> PathBuf {\n    let nested = root.join(\"requirements-runtime.txt\");\n    if nested.exists() {\n        return nested;\n    }\n    if let Some(parent) = root.parent() {\n        let parent_req = parent.join(\"requirements-runtime.txt\");\n        if parent_req.exists() {\n            return parent_req;\n        }\n    }\n    let fallback = root.join(\"backend\").join(\"requirements-runtime.txt\");\n    if fallback.exists() {\n        return fallback;\n    }\n    nested\n}\n\npub fn get_runtime_root(app: &AppHandle) -> Result<PathBuf, String> {\n    let data_dir = app\n        .path()\n        .app_data_dir()\n        .map_err(|e| format!(\"Failed to get app data dir: {}\", e))?;\n    let runtime_dir = data_dir.join(\"runtime\");\n    if !runtime_dir.exists() {\n        std::fs::create_dir_all(&runtime_dir)\n            .map_err(|e| format!(\"Failed to create runtime dir: {}\", e))?;\n    }\n    Ok(runtime_dir)\n}\n\n/// Get data directory for backend\npub fn get_data_dir(app: &AppHandle, mode: ServerMode) -> Result<PathBuf, String> {\n    let app_data_dir = app\n        .path()\n        .app_data_dir()\n        .map_err(|e| format!(\"Failed to get app data dir: {}\", e))?;\n\n    let legacy_dir = app_data_dir.join(process::BACKEND_DATA_DIR);\n    let mode_suffix = match mode {\n        ServerMode::Dev => \"dev\",\n        ServerMode::Build => \"build\",\n    };\n    let mode_dir = app_data_dir.join(format!(\"{}-{}\", process::BACKEND_DATA_DIR, mode_suffix));\n\n    let data_dir = if mode == ServerMode::Build && legacy_dir.exists() {\n        legacy_dir\n    } else {\n        mode_dir\n    };\n\n    if !data_dir.exists() {\n        std::fs::create_dir_all(&data_dir)\n            .map_err(|e| format!(\"Failed to create data dir: {}\", e))?;\n    }\n\n    Ok(data_dir)\n}\n"
  },
  {
    "path": "free-todo-frontend/src-tauri/src/backend_proxy.rs",
    "content": "//! Backend proxy server for stable frontend ports.\n\nuse axum::{\n    body::{to_bytes, Body},\n    extract::State,\n    http::{header, Request, StatusCode},\n    response::Response,\n    Router,\n};\nuse log::warn;\nuse reqwest::Client;\nuse serde_json::json;\nuse std::sync::{\n    atomic::{AtomicBool, AtomicU16, Ordering},\n    Arc,\n};\nuse std::time::Duration;\n\n#[derive(Clone)]\npub struct ProxyState {\n    backend_port: Arc<AtomicU16>,\n    ready: Arc<AtomicBool>,\n    client: Client,\n}\n\nimpl ProxyState {\n    pub fn new(backend_port: Arc<AtomicU16>, ready: Arc<AtomicBool>) -> Self {\n        let client = Client::builder()\n            .timeout(Duration::from_secs(30))\n            .build()\n            .unwrap_or_default();\n        Self {\n            backend_port,\n            ready,\n            client,\n        }\n    }\n}\n\npub async fn start_proxy_server(port: u16, state: ProxyState) -> Result<(), String> {\n    let listener = tokio::net::TcpListener::bind((\"127.0.0.1\", port))\n        .await\n        .map_err(|e| format!(\"Failed to bind proxy port {}: {}\", port, e))?;\n\n    let app = Router::new().fallback(proxy_handler).with_state(state);\n\n    tokio::spawn(async move {\n        if let Err(err) = axum::serve(listener, app).await {\n            warn!(\"Proxy server exited: {}\", err);\n        }\n    });\n\n    Ok(())\n}\n\nasync fn proxy_handler(State(state): State<ProxyState>, req: Request<Body>) -> Response<Body> {\n    let path = req.uri().path();\n    if path == \"/ready\" {\n        let backend_port = state.backend_port.load(Ordering::Relaxed);\n        let ready = state.ready.load(Ordering::Relaxed);\n        return ready_response(ready, backend_port);\n    }\n\n    let backend_port = state.backend_port.load(Ordering::Relaxed);\n    let ready = state.ready.load(Ordering::Relaxed);\n    if backend_port == 0 || !ready {\n        return ready_response(false, backend_port);\n    }\n\n    let path_and_query = req\n        .uri()\n        .path_and_query()\n        .map(|value| value.as_str())\n        .unwrap_or(\"/\");\n    let url = format!(\"http://127.0.0.1:{}{}\", backend_port, path_and_query);\n\n    let (parts, body) = req.into_parts();\n    let mut builder = state.client.request(parts.method, &url);\n    for (name, value) in parts.headers.iter() {\n        if should_skip_request_header(name) {\n            continue;\n        }\n        builder = builder.header(name, value);\n    }\n\n    let body_bytes = match to_bytes(body, usize::MAX).await {\n        Ok(bytes) => bytes,\n        Err(err) => {\n            warn!(\"Proxy body read failed: {}\", err);\n            return ready_response(false, backend_port);\n        }\n    };\n\n    match builder.body(body_bytes).send().await {\n        Ok(response) => {\n            let status = response.status();\n            let headers = response.headers().clone();\n            let bytes = match response.bytes().await {\n                Ok(body) => body,\n                Err(err) => {\n                    warn!(\"Proxy response read failed: {}\", err);\n                    return ready_response(false, backend_port);\n                }\n            };\n\n            let mut builder = Response::builder().status(status);\n            for (name, value) in headers.iter() {\n                if should_skip_response_header(name) {\n                    continue;\n                }\n                builder = builder.header(name, value);\n            }\n            builder = builder.header(header::CONTENT_LENGTH, bytes.len().to_string());\n            builder\n                .body(Body::from(bytes))\n                .unwrap_or_else(|_| ready_response(false, backend_port))\n        }\n        Err(err) => {\n            warn!(\"Proxy request failed: {}\", err);\n            ready_response(false, backend_port)\n        }\n    }\n}\n\nfn ready_response(ready: bool, backend_port: u16) -> Response<Body> {\n    let payload = if ready {\n        json!({\n            \"status\": \"ready\",\n            \"backend_port\": backend_port,\n        })\n    } else {\n        json!({\n            \"status\": \"starting\",\n        })\n    };\n\n    let mut response = Response::new(Body::from(payload.to_string()));\n    *response.status_mut() = if ready {\n        StatusCode::OK\n    } else {\n        StatusCode::SERVICE_UNAVAILABLE\n    };\n    response.headers_mut().insert(\n        header::CONTENT_TYPE,\n        header::HeaderValue::from_static(\"application/json\"),\n    );\n    response\n}\n\nfn should_skip_request_header(name: &header::HeaderName) -> bool {\n    *name == header::HOST || *name == header::CONTENT_LENGTH || *name == header::CONNECTION\n}\n\nfn should_skip_response_header(name: &header::HeaderName) -> bool {\n    *name == header::CONTENT_LENGTH\n        || *name == header::TRANSFER_ENCODING\n        || *name == header::CONTENT_ENCODING\n        || *name == header::CONNECTION\n}\n"
  },
  {
    "path": "free-todo-frontend/src-tauri/src/backend_python.rs",
    "content": "//! Python runtime helpers for backend bootstrap\n\nuse futures_util::StreamExt;\nuse serde::Deserialize;\nuse std::fs;\nuse std::io;\nuse std::io::Write;\nuse std::path::{Path, PathBuf};\nuse std::process::Command;\n\nconst UV_PYTHON_VERSION: &str = \"3.12\";\n\nenum UvArchiveKind {\n    Zip,\n    TarGz,\n}\n\n#[derive(Deserialize)]\nstruct PythonInfo {\n    version: String,\n    executable: String,\n}\n\npub fn get_venv_python_path(venv_dir: &Path) -> PathBuf {\n    if cfg!(windows) {\n        return venv_dir.join(\"Scripts\").join(\"python.exe\");\n    }\n    venv_dir.join(\"bin\").join(\"python3\")\n}\n\nfn get_venv_uv_path(venv_dir: &Path) -> PathBuf {\n    if cfg!(windows) {\n        return venv_dir.join(\"Scripts\").join(\"uv.exe\");\n    }\n    venv_dir.join(\"bin\").join(\"uv\")\n}\n\nfn is_mainland_china() -> bool {\n    if let Ok(value) = std::env::var(\"FREETODO_REGION\") {\n        let normalized = value.to_lowercase();\n        if normalized == \"cn\" {\n            return true;\n        }\n        if normalized == \"global\" || normalized == \"intl\" {\n            return false;\n        }\n    }\n    if let Ok(lang) = std::env::var(\"LANG\") {\n        if lang.to_lowercase().starts_with(\"zh_cn\") {\n            return true;\n        }\n    }\n    false\n}\n\nfn build_uv_env() -> Vec<(String, String)> {\n    if is_mainland_china() {\n        vec![\n            (\n                \"UV_INDEX_URL\".to_string(),\n                \"https://pypi.tuna.tsinghua.edu.cn/simple\".to_string(),\n            ),\n            (\n                \"UV_EXTRA_INDEX_URL\".to_string(),\n                \"https://pypi.org/simple\".to_string(),\n            ),\n            (\n                \"PIP_INDEX_URL\".to_string(),\n                \"https://pypi.tuna.tsinghua.edu.cn/simple\".to_string(),\n            ),\n            (\n                \"PIP_EXTRA_INDEX_URL\".to_string(),\n                \"https://pypi.org/simple\".to_string(),\n            ),\n        ]\n    } else {\n        vec![\n            (\n                \"UV_INDEX_URL\".to_string(),\n                \"https://pypi.org/simple\".to_string(),\n            ),\n            (\n                \"PIP_INDEX_URL\".to_string(),\n                \"https://pypi.org/simple\".to_string(),\n            ),\n        ]\n    }\n}\n\npub fn uv_env_pairs() -> Vec<(String, String)> {\n    build_uv_env()\n}\n\npub fn get_runtime_uv_path(runtime_root: &Path) -> PathBuf {\n    if cfg!(windows) {\n        return runtime_root.join(\"uv\").join(\"uv.exe\");\n    }\n    runtime_root.join(\"uv\").join(\"uv\")\n}\n\npub struct DownloadProgress {\n    pub received_bytes: u64,\n    pub total_bytes: Option<u64>,\n}\n\nfn uv_archive_kind() -> Result<UvArchiveKind, String> {\n    if cfg!(windows) {\n        return Ok(UvArchiveKind::Zip);\n    }\n    if cfg!(target_os = \"macos\") || cfg!(target_os = \"linux\") {\n        return Ok(UvArchiveKind::TarGz);\n    }\n    Err(\"Unsupported OS for uv download\".to_string())\n}\n\nfn uv_download_url() -> Result<&'static str, String> {\n    if cfg!(windows) {\n        if cfg!(target_arch = \"x86_64\") {\n            return Ok(\n                \"https://github.com/astral-sh/uv/releases/latest/download/uv-x86_64-pc-windows-msvc.zip\",\n            );\n        }\n        if cfg!(target_arch = \"aarch64\") {\n            return Ok(\n                \"https://github.com/astral-sh/uv/releases/latest/download/uv-aarch64-pc-windows-msvc.zip\",\n            );\n        }\n        return Err(\"Unsupported Windows architecture for uv download\".to_string());\n    }\n    if cfg!(target_os = \"macos\") {\n        if cfg!(target_arch = \"x86_64\") {\n            return Ok(\n                \"https://github.com/astral-sh/uv/releases/latest/download/uv-x86_64-apple-darwin.tar.gz\",\n            );\n        }\n        if cfg!(target_arch = \"aarch64\") {\n            return Ok(\n                \"https://github.com/astral-sh/uv/releases/latest/download/uv-aarch64-apple-darwin.tar.gz\",\n            );\n        }\n        return Err(\"Unsupported macOS architecture for uv download\".to_string());\n    }\n    if cfg!(target_os = \"linux\") {\n        if cfg!(target_arch = \"x86_64\") {\n            return Ok(\n                \"https://github.com/astral-sh/uv/releases/latest/download/uv-x86_64-unknown-linux-gnu.tar.gz\",\n            );\n        }\n        if cfg!(target_arch = \"aarch64\") {\n            return Ok(\n                \"https://github.com/astral-sh/uv/releases/latest/download/uv-aarch64-unknown-linux-gnu.tar.gz\",\n            );\n        }\n        return Err(\"Unsupported Linux architecture for uv download\".to_string());\n    }\n    Err(\"Unsupported OS for uv download\".to_string())\n}\n\nfn extract_zip(archive_path: &Path, dest_dir: &Path) -> Result<(), String> {\n    let file = fs::File::open(archive_path).map_err(|e| e.to_string())?;\n    let mut archive = zip::ZipArchive::new(file).map_err(|e| e.to_string())?;\n\n    for i in 0..archive.len() {\n        let mut entry = archive.by_index(i).map_err(|e| e.to_string())?;\n        let outpath = dest_dir.join(entry.mangled_name());\n        if entry.is_dir() {\n            fs::create_dir_all(&outpath).map_err(|e| e.to_string())?;\n        } else {\n            if let Some(parent) = outpath.parent() {\n                fs::create_dir_all(parent).map_err(|e| e.to_string())?;\n            }\n            let mut outfile = fs::File::create(&outpath).map_err(|e| e.to_string())?;\n            io::copy(&mut entry, &mut outfile).map_err(|e| e.to_string())?;\n        }\n    }\n    Ok(())\n}\n\nfn extract_tar_gz(archive_path: &Path, dest_dir: &Path) -> Result<(), String> {\n    let file = fs::File::open(archive_path).map_err(|e| e.to_string())?;\n    let decompressor = flate2::read::GzDecoder::new(file);\n    let mut archive = tar::Archive::new(decompressor);\n    archive.unpack(dest_dir).map_err(|e| e.to_string())?;\n    Ok(())\n}\n\nfn find_uv_binary(root: &Path) -> Option<PathBuf> {\n    let filename = if cfg!(windows) { \"uv.exe\" } else { \"uv\" };\n    let direct = root.join(filename);\n    if direct.exists() {\n        return Some(direct);\n    }\n    let entries = fs::read_dir(root).ok()?;\n    for entry in entries {\n        let entry = entry.ok()?;\n        let path = entry.path();\n        if path.is_dir() {\n            let nested = path.join(filename);\n            if nested.exists() {\n                return Some(nested);\n            }\n        }\n    }\n    None\n}\n\nasync fn download_with_progress<F>(\n    url: &str,\n    archive_path: &Path,\n    mut progress: F,\n) -> Result<(), String>\nwhere\n    F: FnMut(DownloadProgress) + Send,\n{\n    let response = reqwest::get(url)\n        .await\n        .map_err(|e| format!(\"Failed to download uv: {}\", e))?;\n    if !response.status().is_success() {\n        return Err(format!(\n            \"Failed to download uv (status {})\",\n            response.status()\n        ));\n    }\n\n    let total = response.content_length();\n    let mut stream = response.bytes_stream();\n    let mut file =\n        fs::File::create(archive_path).map_err(|e| format!(\"Failed to save uv archive: {}\", e))?;\n    let mut received: u64 = 0;\n    let mut last_percent: Option<u8> = None;\n    let mut last_emit_bytes: u64 = 0;\n\n    while let Some(chunk_result) = stream.next().await {\n        let chunk = chunk_result.map_err(|e| format!(\"Failed to read uv archive: {}\", e))?;\n        file.write_all(&chunk)\n            .map_err(|e| format!(\"Failed to write uv archive: {}\", e))?;\n        received += chunk.len() as u64;\n\n        let percent = total.map(|t| ((received * 100) / t).min(100) as u8);\n        let should_emit = match percent {\n            Some(value) => last_percent != Some(value),\n            None => received.saturating_sub(last_emit_bytes) >= 1_048_576,\n        };\n        if should_emit {\n            progress(DownloadProgress {\n                received_bytes: received,\n                total_bytes: total,\n            });\n            last_percent = percent;\n            last_emit_bytes = received;\n        }\n    }\n\n    progress(DownloadProgress {\n        received_bytes: received,\n        total_bytes: total,\n    });\n\n    Ok(())\n}\n\npub async fn ensure_uv_binary_with_progress<F>(\n    runtime_root: &Path,\n    progress: F,\n) -> Result<PathBuf, String>\nwhere\n    F: FnMut(DownloadProgress) + Send,\n{\n    let uv_path = get_runtime_uv_path(runtime_root);\n    if uv_path.exists() {\n        return Ok(uv_path);\n    }\n\n    let uv_dir = uv_path\n        .parent()\n        .ok_or(\"Invalid uv path for runtime directory\")?;\n    fs::create_dir_all(uv_dir).map_err(|e| format!(\"Failed to create uv dir: {}\", e))?;\n\n    let url = uv_download_url()?;\n    let archive_kind = uv_archive_kind()?;\n    let archive_path = match archive_kind {\n        UvArchiveKind::Zip => uv_dir.join(\"uv.zip\"),\n        UvArchiveKind::TarGz => uv_dir.join(\"uv.tar.gz\"),\n    };\n\n    download_with_progress(url, &archive_path, progress).await?;\n\n    match archive_kind {\n        UvArchiveKind::Zip => extract_zip(&archive_path, uv_dir)?,\n        UvArchiveKind::TarGz => extract_tar_gz(&archive_path, uv_dir)?,\n    }\n    let _ = fs::remove_file(&archive_path);\n\n    let uv_path = if uv_path.exists() {\n        uv_path\n    } else {\n        find_uv_binary(uv_dir).ok_or(\"uv binary not found after extraction\")?\n    };\n\n    #[cfg(unix)]\n    {\n        use std::os::unix::fs::PermissionsExt;\n        let mut perms = fs::metadata(&uv_path)\n            .map_err(|e| format!(\"Failed to read uv permissions: {}\", e))?\n            .permissions();\n        perms.set_mode(0o755);\n        fs::set_permissions(&uv_path, perms)\n            .map_err(|e| format!(\"Failed to set uv permissions: {}\", e))?;\n    }\n\n    Ok(uv_path)\n}\n\nfn run_command(command: &str, args: &[&str], envs: &[(&str, &str)]) -> Result<String, String> {\n    let mut cmd = Command::new(command);\n    cmd.args(args);\n    for (key, value) in envs {\n        cmd.env(key, value);\n    }\n    let output = cmd.output().map_err(|e| e.to_string())?;\n    if output.status.success() {\n        Ok(String::from_utf8_lossy(&output.stdout).to_string())\n    } else {\n        Err(String::from_utf8_lossy(&output.stderr).to_string())\n    }\n}\n\nfn get_python_info(command: &str, args: &[&str]) -> Option<PythonInfo> {\n    let mut full_args = args.to_vec();\n    full_args.extend_from_slice(&[\n        \"-c\",\n        \"import json, sys; print(json.dumps({'version': f'{sys.version_info[0]}.{sys.version_info[1]}', 'executable': sys.executable}))\",\n    ]);\n    let output = run_command(command, &full_args, &[]).ok()?;\n    let line = output.lines().next()?.trim();\n    serde_json::from_str(line).ok()\n}\n\npub fn find_python312() -> Option<PathBuf> {\n    let mut candidates: Vec<(&str, Vec<&str>)> = Vec::new();\n    if cfg!(windows) {\n        candidates.push((\"py\", vec![\"-3.12\"]));\n        candidates.push((\"python3.12\", vec![]));\n        candidates.push((\"python\", vec![]));\n    } else {\n        candidates.push((\"python3.12\", vec![]));\n        candidates.push((\"python3\", vec![]));\n        candidates.push((\"python\", vec![]));\n    }\n\n    for (command, args) in candidates {\n        if let Some(info) = get_python_info(command, &args) {\n            if info.version == \"3.12\" && !info.executable.is_empty() {\n                return Some(PathBuf::from(info.executable));\n            }\n        }\n    }\n    None\n}\n\npub fn ensure_venv(python_path: &Path, venv_dir: &Path) -> Result<PathBuf, String> {\n    let venv_python = get_venv_python_path(venv_dir);\n    if venv_python.exists() {\n        return Ok(venv_python);\n    }\n    std::fs::create_dir_all(venv_dir).map_err(|e| format!(\"Failed to create venv dir: {}\", e))?;\n    run_command(\n        python_path.to_str().ok_or(\"Invalid python path\")?,\n        &[\"-m\", \"venv\", venv_dir.to_str().ok_or(\"Invalid venv path\")?],\n        &[],\n    )?;\n    if venv_python.exists() {\n        Ok(venv_python)\n    } else {\n        Err(\"Failed to create virtual environment\".to_string())\n    }\n}\n\npub fn ensure_uv(venv_python: &Path, venv_dir: &Path) -> Result<PathBuf, String> {\n    let uv_path = get_venv_uv_path(venv_dir);\n    if uv_path.exists() {\n        return Ok(uv_path);\n    }\n    run_command(\n        venv_python.to_str().ok_or(\"Invalid venv python path\")?,\n        &[\"-m\", \"pip\", \"install\", \"--upgrade\", \"uv\"],\n        &[],\n    )?;\n    if uv_path.exists() {\n        Ok(uv_path)\n    } else {\n        Err(\"Failed to install uv in virtual environment\".to_string())\n    }\n}\n\npub fn ensure_uv_python(uv_path: &Path) -> Result<(), String> {\n    let env_pairs = build_uv_env();\n    let env_refs: Vec<(&str, &str)> = env_pairs\n        .iter()\n        .map(|(k, v)| (k.as_str(), v.as_str()))\n        .collect();\n    run_command(\n        uv_path.to_str().ok_or(\"Invalid uv path\")?,\n        &[\"python\", \"install\", UV_PYTHON_VERSION],\n        &env_refs,\n    )?;\n    Ok(())\n}\n\npub fn ensure_uv_venv(uv_path: &Path, venv_dir: &Path) -> Result<PathBuf, String> {\n    let venv_python = get_venv_python_path(venv_dir);\n    if venv_python.exists() {\n        return Ok(venv_python);\n    }\n    fs::create_dir_all(venv_dir).map_err(|e| format!(\"Failed to create venv dir: {}\", e))?;\n    let env_pairs = build_uv_env();\n    let env_refs: Vec<(&str, &str)> = env_pairs\n        .iter()\n        .map(|(k, v)| (k.as_str(), v.as_str()))\n        .collect();\n    run_command(\n        uv_path.to_str().ok_or(\"Invalid uv path\")?,\n        &[\n            \"venv\",\n            venv_dir.to_str().ok_or(\"Invalid venv path\")?,\n            \"--python\",\n            UV_PYTHON_VERSION,\n        ],\n        &env_refs,\n    )?;\n    if venv_python.exists() {\n        Ok(venv_python)\n    } else {\n        Err(\"Failed to create virtual environment with uv\".to_string())\n    }\n}\n\npub fn install_requirements(\n    uv_path: &Path,\n    venv_python: &Path,\n    requirements_path: &Path,\n) -> Result<(), String> {\n    let env_pairs = build_uv_env();\n    let env_refs: Vec<(&str, &str)> = env_pairs\n        .iter()\n        .map(|(k, v)| (k.as_str(), v.as_str()))\n        .collect();\n    run_command(\n        uv_path.to_str().ok_or(\"Invalid uv path\")?,\n        &[\n            \"pip\",\n            \"install\",\n            \"-r\",\n            requirements_path\n                .to_str()\n                .ok_or(\"Invalid requirements path\")?,\n            \"--python\",\n            venv_python.to_str().ok_or(\"Invalid venv python path\")?,\n        ],\n        &env_refs,\n    )?;\n    Ok(())\n}\n"
  },
  {
    "path": "free-todo-frontend/src-tauri/src/backend_support.rs",
    "content": "//! Backend helper utilities (health checks, port selection, detection).\n\nuse crate::config::{self, ServerMode};\nuse log::info;\nuse rand::Rng;\nuse reqwest::Client;\nuse serde::Deserialize;\nuse std::net::TcpListener;\nuse std::time::Duration;\n\n/// Health check response structure\n#[derive(Deserialize, Debug)]\nstruct HealthResponse {\n    app: Option<String>,\n    server_mode: Option<String>,\n}\n\nfn backend_port_range(mode: ServerMode) -> (u16, u16) {\n    match mode {\n        ServerMode::Dev => (\n            config::ports::DEV_BACKEND_RANGE_START,\n            config::ports::DEV_BACKEND_RANGE_END,\n        ),\n        ServerMode::Build => (\n            config::ports::BUILD_BACKEND_RANGE_START,\n            config::ports::BUILD_BACKEND_RANGE_END,\n        ),\n    }\n}\n\npub async fn is_lifetrace_backend(port: u16) -> bool {\n    let url = format!(\"http://127.0.0.1:{}/health\", port);\n    let client = Client::builder()\n        .timeout(Duration::from_secs(2))\n        .build()\n        .unwrap_or_default();\n\n    match client.get(&url).send().await {\n        Ok(response) => {\n            if response.status().is_success() {\n                if let Ok(health) = response.json::<HealthResponse>().await {\n                    return health.app.as_deref() == Some(\"lifetrace\");\n                }\n            }\n            false\n        }\n        Err(_) => false,\n    }\n}\n\npub async fn check_backend_health(\n    port: u16,\n    timeout_ms: u64,\n) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {\n    let url = format!(\"http://127.0.0.1:{}/health\", port);\n    let client = Client::builder()\n        .timeout(Duration::from_millis(timeout_ms))\n        .build()?;\n\n    match client.get(&url).send().await {\n        Ok(response) => Ok(response.status().is_success()),\n        Err(_) => Ok(false),\n    }\n}\n\npub async fn detect_running_backend_port(mode: ServerMode) -> Option<u16> {\n    let (start_port, end_port) = backend_port_range(mode);\n\n    for port in start_port..=end_port {\n        if is_lifetrace_backend(port).await {\n            info!(\"Detected backend running on port: {}\", port);\n            return Some(port);\n        }\n    }\n\n    None\n}\n\nfn port_available(port: u16) -> bool {\n    TcpListener::bind((\"127.0.0.1\", port)).is_ok()\n}\n\npub fn pick_backend_port(mode: ServerMode) -> Result<u16, String> {\n    let (start_port, end_port) = backend_port_range(mode);\n    let mut rng = rand::rng();\n\n    for _ in 0..10 {\n        let port = rng.random_range(start_port..=end_port);\n        if port_available(port) {\n            return Ok(port);\n        }\n    }\n\n    for port in start_port..=end_port {\n        if port_available(port) {\n            return Ok(port);\n        }\n    }\n\n    Err(format!(\n        \"No available backend port in range {}-{}\",\n        start_port, end_port\n    ))\n}\n\npub async fn wait_for_backend(\n    port: u16,\n    timeout_secs: u64,\n    health_timeout_ms: u64,\n    retry_ms: u64,\n) -> Result<(), String> {\n    let start = std::time::Instant::now();\n    let timeout = Duration::from_secs(timeout_secs);\n    let retry_interval = Duration::from_millis(retry_ms);\n\n    while start.elapsed() < timeout {\n        if check_backend_health(port, health_timeout_ms)\n            .await\n            .unwrap_or(false)\n        {\n            return Ok(());\n        }\n        tokio::time::sleep(retry_interval).await;\n    }\n\n    Err(\"Backend did not start in time\".to_string())\n}\n\npub async fn verify_backend_mode(port: u16, expected_mode: &str) -> Result<(), String> {\n    let url = format!(\"http://127.0.0.1:{}/health\", port);\n    let client = Client::builder()\n        .timeout(Duration::from_secs(5))\n        .build()\n        .map_err(|e| e.to_string())?;\n\n    match client.get(&url).send().await {\n        Ok(response) => {\n            if let Ok(health) = response.json::<HealthResponse>().await {\n                if health.app.as_deref() != Some(\"lifetrace\") {\n                    return Err(format!(\n                        \"Backend at port {} is not a LifeTrace server\",\n                        port\n                    ));\n                }\n                if let Some(mode) = health.server_mode {\n                    if mode != expected_mode {\n                        log::warn!(\n                            \"Backend mode mismatch: expected '{}', got '{}'\",\n                            expected_mode,\n                            mode\n                        );\n                    }\n                }\n            }\n            Ok(())\n        }\n        Err(e) => {\n            log::warn!(\"Could not verify backend mode: {}\", e);\n            Ok(())\n        }\n    }\n}\n"
  },
  {
    "path": "free-todo-frontend/src-tauri/src/config.rs",
    "content": "//! Configuration constants for FreeTodo\n//!\n//! Centralized configuration management for ports, timeouts, and paths.\n//!\n//! ## Window Modes\n//!\n//! The application supports two window modes (matching Electron):\n//! - **Web**: Standard window (1200x800, with decorations)\n//! - **Island**: Transparent floating window (separate build config)\n\nuse std::env;\n\n/// Server mode (development or production)\n#[derive(Debug, Clone, Copy, PartialEq)]\npub enum ServerMode {\n    Dev,\n    Build,\n}\n\nimpl ServerMode {\n    /// Get current server mode based on build configuration\n    pub fn current() -> Self {\n        if let Ok(mode) = env::var(\"SERVER_MODE\") {\n            if mode.eq_ignore_ascii_case(\"dev\") {\n                return ServerMode::Dev;\n            }\n            if mode.eq_ignore_ascii_case(\"build\") {\n                return ServerMode::Build;\n            }\n        }\n        if cfg!(debug_assertions) {\n            ServerMode::Dev\n        } else {\n            ServerMode::Build\n        }\n    }\n}\n\n/// Port configuration based on server mode\npub mod ports {\n    use super::ServerMode;\n\n    /// Dev mode ports\n    pub const DEV_FRONTEND_PORT: u16 = 3001;\n    pub const DEV_BACKEND_PORT: u16 = 8001;\n    pub const DEV_BACKEND_RANGE_START: u16 = 8002;\n    pub const DEV_BACKEND_RANGE_END: u16 = 8099;\n\n    /// Build mode ports\n    pub const BUILD_FRONTEND_PORT: u16 = 3100;\n    pub const BUILD_BACKEND_PORT: u16 = 8100;\n    pub const BUILD_BACKEND_RANGE_START: u16 = 8101;\n    pub const BUILD_BACKEND_RANGE_END: u16 = 8199;\n\n    /// Get frontend port for current mode\n    pub fn frontend_port(mode: ServerMode) -> u16 {\n        match mode {\n            ServerMode::Dev => DEV_FRONTEND_PORT,\n            ServerMode::Build => BUILD_FRONTEND_PORT,\n        }\n    }\n\n    /// Get backend port for current mode\n    pub fn backend_port(mode: ServerMode) -> u16 {\n        match mode {\n            ServerMode::Dev => DEV_BACKEND_PORT,\n            ServerMode::Build => BUILD_BACKEND_PORT,\n        }\n    }\n}\n\n/// Timeout configuration (in milliseconds)\npub mod timeouts {\n    /// Backend ready timeout (3 minutes)\n    pub const BACKEND_READY: u64 = 180_000;\n\n    /// Frontend ready timeout (30 seconds)\n    pub const FRONTEND_READY: u64 = 30_000;\n\n    /// Health check timeout (5 seconds)\n    pub const HEALTH_CHECK: u64 = 5_000;\n\n    /// Health check retry interval (500ms)\n    pub const HEALTH_CHECK_RETRY: u64 = 500;\n}\n\n/// Health check intervals (in milliseconds)\npub mod health_check {\n    /// Frontend health check interval (10 seconds)\n    pub const FRONTEND_INTERVAL: u64 = 10_000;\n\n    /// Backend health check interval (30 seconds)\n    pub const BACKEND_INTERVAL: u64 = 30_000;\n}\n\n/// Process configuration\npub mod process {\n    /// Backend executable name (platform-specific)\n    #[cfg(windows)]\n    pub const BACKEND_EXEC_NAME: &str = \"lifetrace.exe\";\n\n    #[cfg(not(windows))]\n    pub const BACKEND_EXEC_NAME: &str = \"lifetrace\";\n\n    /// Backend data directory name\n    pub const BACKEND_DATA_DIR: &str = \"lifetrace-data\";\n}\n\n/// Get the default backend port based on environment or mode\npub fn get_backend_port() -> u16 {\n    if let Ok(port) = env::var(\"BACKEND_PORT\") {\n        if let Ok(p) = port.parse() {\n            return p;\n        }\n    }\n    ports::backend_port(ServerMode::current())\n}\n\n/// Get the default frontend port based on environment or mode\npub fn get_frontend_port() -> u16 {\n    if let Ok(port) = env::var(\"PORT\") {\n        if let Ok(p) = port.parse() {\n            return p;\n        }\n    }\n    ports::frontend_port(ServerMode::current())\n}\n\n/// Get backend URL\npub fn get_backend_url() -> String {\n    format!(\"http://127.0.0.1:{}\", get_backend_port())\n}\n\n/// Get frontend URL\npub fn get_frontend_url() -> String {\n    format!(\"http://localhost:{}\", get_frontend_port())\n}\n"
  },
  {
    "path": "free-todo-frontend/src-tauri/src/lib.rs",
    "content": "//! FreeTodo - Tauri Application Library\n//!\n//! This module contains the core functionality for the FreeTodo desktop application,\n//! including backend management, Next.js server management, system tray, and global shortcuts.\n//!\n//! ## Window Modes\n//!\n//! The application supports two window modes (matching Electron implementation):\n//! - **Web Mode**: Standard window with decorations\n//! - **Island Mode**: Transparent floating window like Dynamic Island (separate build config)\n\npub mod backend;\nmod backend_log;\nmod backend_paths;\nmod backend_proxy;\nmod backend_python;\nmod backend_support;\npub mod config;\npub mod nextjs;\npub mod shortcut;\npub mod tray;\n\nuse log::info;\nuse tauri::Manager;\n\n/// Window mode configuration\n/// Currently only Web mode is supported\n#[derive(Debug, Clone, Copy, PartialEq, Default)]\n#[allow(dead_code)]\npub enum WindowMode {\n    /// Standard window with decorations (default, currently supported)\n    #[default]\n    Web,\n    /// Transparent floating window like Dynamic Island (TODO: not yet implemented)\n    Island,\n}\n\n/// Initialize the Tauri application with all required plugins and setup\n/// Note: Currently only Web mode is supported\npub fn run() {\n    // Initialize logger\n    env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(\"info\")).init();\n\n    info!(\"Starting FreeTodo application...\");\n\n    tauri::Builder::default()\n        .plugin(tauri_plugin_shell::init())\n        .plugin(tauri_plugin_notification::init())\n        .plugin(tauri_plugin_global_shortcut::Builder::new().build())\n        .setup(|app| {\n            let handle = app.handle().clone();\n\n            info!(\"Application setup starting...\");\n\n            // Start Python backend\n            let backend_handle = handle.clone();\n            tauri::async_runtime::spawn(async move {\n                if let Err(e) = backend::start_backend(&backend_handle).await {\n                    log::error!(\"Failed to start backend: {}\", e);\n                }\n            });\n\n            // Start Next.js server (only in release mode)\n            #[cfg(not(debug_assertions))]\n            {\n                let nextjs_handle = handle.clone();\n                tauri::async_runtime::spawn(async move {\n                    if let Err(e) = nextjs::start_nextjs(&nextjs_handle).await {\n                        log::error!(\"Failed to start Next.js: {}\", e);\n                    }\n                });\n            }\n\n            // Setup system tray\n            tray::setup_tray(app)?;\n\n            // Setup global shortcuts\n            shortcut::setup_shortcuts(app)?;\n\n            info!(\"Application setup completed\");\n\n            Ok(())\n        })\n        .invoke_handler(tauri::generate_handler![\n            get_backend_url,\n            get_backend_status,\n            toggle_window,\n            show_window,\n            hide_window,\n        ])\n        .run(tauri::generate_context!())\n        .expect(\"error while running tauri application\");\n}\n\n/// Get the backend server URL\n#[tauri::command]\nfn get_backend_url() -> String {\n    backend::get_backend_url()\n}\n\n/// Get backend server health status\n#[tauri::command]\nasync fn get_backend_status() -> Result<bool, String> {\n    backend::check_backend_health(config::get_backend_port())\n        .await\n        .map_err(|e| e.to_string())\n}\n\n/// Toggle main window visibility\n#[tauri::command]\nfn toggle_window(app: tauri::AppHandle) {\n    if let Some(window) = app.get_webview_window(\"main\") {\n        if window.is_visible().unwrap_or(false) {\n            let _ = window.hide();\n        } else {\n            let _ = window.show();\n            let _ = window.set_focus();\n        }\n    }\n}\n\n/// Show main window\n#[tauri::command]\nfn show_window(app: tauri::AppHandle) {\n    if let Some(window) = app.get_webview_window(\"main\") {\n        let _ = window.show();\n        let _ = window.set_focus();\n    }\n}\n\n/// Hide main window\n#[tauri::command]\nfn hide_window(app: tauri::AppHandle) {\n    if let Some(window) = app.get_webview_window(\"main\") {\n        let _ = window.hide();\n    }\n}\n"
  },
  {
    "path": "free-todo-frontend/src-tauri/src/main.rs",
    "content": "//! FreeTodo - Main Entry Point\n//!\n//! This is the main entry point for the FreeTodo Tauri application.\n//! It initializes the application and starts all required services.\n\n#![cfg_attr(\n    all(not(debug_assertions), target_os = \"windows\"),\n    windows_subsystem = \"windows\"\n)]\n\nfn main() {\n    free_todo::run();\n}\n"
  },
  {
    "path": "free-todo-frontend/src-tauri/src/nextjs.rs",
    "content": "//! Next.js Server Management\n//!\n//! This module handles the lifecycle of the Next.js standalone server,\n//! including starting, health checking, and stopping the process.\n\nuse crate::backend;\nuse crate::config::{self, timeouts};\nuse log::{error, info, warn};\nuse reqwest::Client;\nuse std::path::PathBuf;\nuse std::process::{Child, Command, Stdio};\nuse std::sync::atomic::{AtomicBool, AtomicU16, Ordering};\nuse std::sync::Mutex;\nuse std::time::Duration;\nuse tauri::{AppHandle, Manager};\n\n/// Global Next.js process reference\nstatic NEXTJS_PROCESS: Mutex<Option<Child>> = Mutex::new(None);\n\n/// Current frontend port\nstatic FRONTEND_PORT: AtomicU16 = AtomicU16::new(3001);\n\n/// Flag indicating if server is stopping\nstatic IS_STOPPING: AtomicBool = AtomicBool::new(false);\n\n/// Get the frontend URL\npub fn get_frontend_url() -> String {\n    let port = FRONTEND_PORT.load(Ordering::Relaxed);\n    format!(\"http://localhost:{}\", port)\n}\n\n/// Set the frontend port\npub fn set_frontend_port(port: u16) {\n    FRONTEND_PORT.store(port, Ordering::Relaxed);\n}\n\n/// Get current frontend port\npub fn get_frontend_port() -> u16 {\n    FRONTEND_PORT.load(Ordering::Relaxed)\n}\n\n/// Check if server is healthy\nasync fn check_server_health(port: u16) -> bool {\n    let url = format!(\"http://localhost:{}\", port);\n    let client = Client::builder()\n        .timeout(Duration::from_secs(5))\n        .build()\n        .unwrap_or_default();\n\n    match client.get(&url).send().await {\n        Ok(response) => {\n            let status = response.status().as_u16();\n            status == 200 || status == 304\n        }\n        Err(_) => false,\n    }\n}\n\n/// Wait for server to be ready\nasync fn wait_for_server(url: &str, timeout_ms: u64) -> Result<(), String> {\n    let start = std::time::Instant::now();\n    let timeout = Duration::from_millis(timeout_ms);\n    let retry_interval = Duration::from_millis(500);\n\n    let client = Client::builder()\n        .timeout(Duration::from_secs(5))\n        .build()\n        .map_err(|e| e.to_string())?;\n\n    while start.elapsed() < timeout {\n        if let Ok(response) = client.get(url).send().await {\n            let status = response.status().as_u16();\n            if status == 200 || status == 304 {\n                return Ok(());\n            }\n        }\n        tokio::time::sleep(retry_interval).await;\n    }\n\n    Err(format!(\"Server did not start within {}ms\", timeout_ms))\n}\n\n/// Find available port starting from default\nasync fn find_available_port(start_port: u16, max_attempts: u16) -> Result<u16, String> {\n    for i in 0..max_attempts {\n        let port = start_port + i;\n        if !check_server_health(port).await {\n            // Port is likely available (not responding)\n            return Ok(port);\n        }\n    }\n    Err(format!(\n        \"Could not find available port after {} attempts\",\n        max_attempts\n    ))\n}\n\n/// Get standalone server path\nfn get_server_path(app: &AppHandle) -> Result<PathBuf, String> {\n    let resource_path = app\n        .path()\n        .resource_dir()\n        .map_err(|e| format!(\"Failed to get resource dir: {}\", e))?;\n\n    let server_path = resource_path.join(\"standalone\").join(\"server.js\");\n\n    if server_path.exists() {\n        Ok(server_path)\n    } else {\n        Err(format!(\"Server file not found at {:?}\", server_path))\n    }\n}\n\n/// Start the Next.js server\npub async fn start_nextjs(app: &AppHandle) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {\n    // In development mode, expect external dev server\n    if cfg!(debug_assertions) {\n        let port = config::get_frontend_port();\n        set_frontend_port(port);\n        info!(\n            \"Development mode: expecting Next.js dev server at http://localhost:{}\",\n            port\n        );\n\n        // Check if dev server is already running\n        if check_server_health(port).await {\n            info!(\"Next.js dev server is already running\");\n            return Ok(());\n        }\n\n        // Wait for external dev server\n        info!(\"Waiting for Next.js dev server...\");\n        match wait_for_server(&format!(\"http://localhost:{}\", port), 30000).await {\n            Ok(_) => {\n                info!(\"Next.js dev server is ready\");\n                return Ok(());\n            }\n            Err(e) => {\n                warn!(\"Dev server not available: {}\", e);\n                return Err(e.into());\n            }\n        }\n    }\n\n    // Production mode: start standalone server\n    info!(\"Starting Next.js production server...\");\n\n    // Get server path\n    let server_path = get_server_path(app)?;\n    let server_dir = server_path\n        .parent()\n        .ok_or(\"Failed to get server directory\")?;\n\n    info!(\"Server path: {:?}\", server_path);\n    info!(\"Server directory: {:?}\", server_dir);\n\n    // Find available port\n    let port = find_available_port(config::get_frontend_port(), 50).await?;\n    set_frontend_port(port);\n    info!(\"Frontend will use port: {}\", port);\n\n    // Get backend URL for environment variable\n    let backend_url = backend::get_backend_url();\n\n    // Check for Node.js\n    let node_path = which_node()?;\n    info!(\"Node.js path: {:?}\", node_path);\n\n    // Spawn Next.js server process\n    let child = Command::new(&node_path)\n        .arg(&server_path)\n        .current_dir(server_dir)\n        .env(\"PORT\", port.to_string())\n        .env(\"HOSTNAME\", \"localhost\")\n        .env(\"NODE_ENV\", \"production\")\n        .env(\"NEXT_PUBLIC_API_URL\", &backend_url)\n        .stdout(Stdio::piped())\n        .stderr(Stdio::piped())\n        .spawn()\n        .map_err(|e| format!(\"Failed to start Next.js server: {}\", e))?;\n\n    info!(\"Spawned Next.js process with PID: {:?}\", child.id());\n\n    // Store process reference\n    {\n        let mut guard = NEXTJS_PROCESS.lock().unwrap();\n        *guard = Some(child);\n    }\n\n    // Wait for server to be ready\n    let server_url = format!(\"http://localhost:{}\", port);\n    info!(\n        \"Waiting for Next.js server at {} to be ready...\",\n        server_url\n    );\n\n    wait_for_server(&server_url, timeouts::FRONTEND_READY).await?;\n    info!(\"Next.js server is ready at {}\", server_url);\n\n    // Start health check loop\n    start_health_check_loop(port);\n\n    Ok(())\n}\n\n/// Find Node.js executable\nfn which_node() -> Result<PathBuf, String> {\n    // Try common Node.js locations\n    let candidates = if cfg!(windows) {\n        vec![\n            \"node.exe\",\n            \"C:\\\\Program Files\\\\nodejs\\\\node.exe\",\n            \"C:\\\\Program Files (x86)\\\\nodejs\\\\node.exe\",\n        ]\n    } else {\n        vec![\n            \"node\",\n            \"/usr/local/bin/node\",\n            \"/usr/bin/node\",\n            \"/opt/homebrew/bin/node\",\n        ]\n    };\n\n    for candidate in candidates {\n        let path = PathBuf::from(candidate);\n        if path.exists() {\n            return Ok(path);\n        }\n\n        // Try to find in PATH\n        if let Ok(output) = Command::new(if cfg!(windows) { \"where\" } else { \"which\" })\n            .arg(candidate)\n            .output()\n        {\n            if output.status.success() {\n                let path_str = String::from_utf8_lossy(&output.stdout)\n                    .trim()\n                    .lines()\n                    .next()\n                    .unwrap_or(\"\")\n                    .to_string();\n                if !path_str.is_empty() {\n                    return Ok(PathBuf::from(path_str));\n                }\n            }\n        }\n    }\n\n    Err(\"Node.js not found. Please install Node.js.\".to_string())\n}\n\n/// Start health check loop\nfn start_health_check_loop(port: u16) {\n    tokio::spawn(async move {\n        let interval = Duration::from_millis(config::health_check::FRONTEND_INTERVAL);\n\n        loop {\n            tokio::time::sleep(interval).await;\n\n            if IS_STOPPING.load(Ordering::Relaxed) {\n                break;\n            }\n\n            if !check_server_health(port).await {\n                warn!(\"Next.js health check failed\");\n            }\n        }\n    });\n}\n\n/// Stop the Next.js server\npub fn stop_nextjs() {\n    IS_STOPPING.store(true, Ordering::Relaxed);\n\n    let mut guard = NEXTJS_PROCESS.lock().unwrap();\n    if let Some(mut child) = guard.take() {\n        info!(\"Stopping Next.js server...\");\n\n        // Try graceful shutdown first\n        #[cfg(unix)]\n        {\n            unsafe {\n                libc::kill(child.id() as i32, libc::SIGTERM);\n            }\n        }\n\n        #[cfg(windows)]\n        {\n            let _ = child.kill();\n        }\n\n        // Wait a bit for graceful shutdown\n        std::thread::sleep(Duration::from_secs(2));\n\n        // Force kill if still running\n        match child.try_wait() {\n            Ok(Some(_)) => {\n                info!(\"Next.js server stopped gracefully\");\n            }\n            Ok(None) => {\n                warn!(\"Next.js server did not stop gracefully, forcing kill\");\n                let _ = child.kill();\n            }\n            Err(e) => {\n                error!(\"Error checking Next.js status: {}\", e);\n                let _ = child.kill();\n            }\n        }\n    }\n}\n\n/// Cleanup on application exit\npub fn cleanup() {\n    stop_nextjs();\n}\n"
  },
  {
    "path": "free-todo-frontend/src-tauri/src/shortcut.rs",
    "content": "//! Global Shortcut Management\n//!\n//! This module handles global keyboard shortcuts for the application,\n//! providing quick access to common functions from anywhere in the system.\n//!\n//! Note: Currently designed for Web mode. Island mode may require different shortcuts.\n\nuse log::{error, info, warn};\nuse tauri::{App, AppHandle, Manager};\nuse tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};\n\n/// Default shortcut configurations\npub struct ShortcutConfig {\n    /// Toggle window visibility shortcut\n    pub toggle_window: &'static str,\n}\n\nimpl Default for ShortcutConfig {\n    fn default() -> Self {\n        Self {\n            toggle_window: \"CommandOrControl+Shift+I\",\n        }\n    }\n}\n\n/// Setup global shortcuts\npub fn setup_shortcuts(app: &App) -> Result<(), Box<dyn std::error::Error>> {\n    info!(\"Setting up global shortcuts...\");\n\n    let config = ShortcutConfig::default();\n    let handle = app.handle().clone();\n\n    // Register toggle window shortcut\n    register_toggle_shortcut(&handle, config.toggle_window)?;\n\n    info!(\"Global shortcuts registered successfully\");\n\n    Ok(())\n}\n\n/// Register the toggle window shortcut\nfn register_toggle_shortcut(\n    app: &AppHandle,\n    accelerator: &str,\n) -> Result<(), Box<dyn std::error::Error>> {\n    let shortcut: Shortcut = accelerator.parse()?;\n\n    let app_handle = app.clone();\n    let accel_string = accelerator.to_string();\n\n    app.global_shortcut()\n        .on_shortcut(shortcut, move |_app, _shortcut, event| {\n            if event.state == ShortcutState::Pressed {\n                info!(\"Toggle shortcut triggered: {}\", accel_string);\n                toggle_window(&app_handle);\n            }\n        })?;\n\n    info!(\"Registered shortcut: {} - Toggle Window\", accelerator);\n\n    Ok(())\n}\n\n/// Toggle main window visibility\nfn toggle_window(app: &AppHandle) {\n    if let Some(window) = app.get_webview_window(\"main\") {\n        match window.is_visible() {\n            Ok(true) => {\n                if let Err(e) = window.hide() {\n                    error!(\"Failed to hide window: {}\", e);\n                } else {\n                    info!(\"Window hidden via shortcut\");\n                }\n            }\n            Ok(false) => {\n                if let Err(e) = window.show() {\n                    error!(\"Failed to show window: {}\", e);\n                } else if let Err(e) = window.set_focus() {\n                    warn!(\"Failed to focus window: {}\", e);\n                } else {\n                    info!(\"Window shown via shortcut\");\n                }\n            }\n            Err(e) => {\n                error!(\"Failed to check window visibility: {}\", e);\n            }\n        }\n    } else {\n        error!(\"Main window not found\");\n    }\n}\n\n/// Unregister all shortcuts\n#[allow(dead_code)]\npub fn unregister_all(app: &AppHandle) {\n    if let Err(e) = app.global_shortcut().unregister_all() {\n        error!(\"Failed to unregister shortcuts: {}\", e);\n    } else {\n        info!(\"All shortcuts unregistered\");\n    }\n}\n\n/// Update a shortcut with a new accelerator\n#[allow(dead_code)]\npub fn update_shortcut(\n    app: &AppHandle,\n    old_accelerator: &str,\n    new_accelerator: &str,\n) -> Result<(), Box<dyn std::error::Error>> {\n    // Unregister old shortcut\n    let old_shortcut: Shortcut = old_accelerator.parse()?;\n    app.global_shortcut().unregister(old_shortcut)?;\n\n    // Register new shortcut\n    register_toggle_shortcut(app, new_accelerator)?;\n\n    info!(\n        \"Shortcut updated from {} to {}\",\n        old_accelerator, new_accelerator\n    );\n\n    Ok(())\n}\n\n/// Check if a shortcut is registered\n#[allow(dead_code)]\npub fn is_registered(app: &AppHandle, accelerator: &str) -> bool {\n    match accelerator.parse::<Shortcut>() {\n        Ok(shortcut) => app.global_shortcut().is_registered(shortcut),\n        Err(_) => false,\n    }\n}\n"
  },
  {
    "path": "free-todo-frontend/src-tauri/src/tray.rs",
    "content": "//! System Tray Management\n//!\n//! This module handles the system tray icon and context menu,\n//! providing quick access to common application functions.\n//!\n//! Note: Currently designed for Web mode. Island mode features are placeholders.\n\nuse log::{error, info};\nuse tauri::{\n    image::Image,\n    menu::{Menu, MenuItem, PredefinedMenuItem},\n    tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},\n    App, AppHandle, Manager,\n};\n\n/// Setup the system tray\npub fn setup_tray(app: &App) -> Result<(), Box<dyn std::error::Error>> {\n    info!(\"Setting up system tray...\");\n\n    let handle = app.handle();\n\n    // Create menu items\n    let show_hide = MenuItem::with_id(\n        handle,\n        \"show_hide\",\n        \"Show/Hide Window\",\n        true,\n        Some(\"CmdOrCtrl+Shift+I\"),\n    )?;\n\n    let separator1 = PredefinedMenuItem::separator(handle)?;\n\n    let recording_menu = create_recording_submenu(handle)?;\n    let screenshot_menu = create_screenshot_submenu(handle)?;\n\n    let separator2 = PredefinedMenuItem::separator(handle)?;\n\n    let preferences =\n        MenuItem::with_id(handle, \"preferences\", \"Preferences...\", true, None::<&str>)?;\n\n    let separator3 = PredefinedMenuItem::separator(handle)?;\n\n    let quit = MenuItem::with_id(handle, \"quit\", \"Quit FreeTodo\", true, Some(\"CmdOrCtrl+Q\"))?;\n\n    // Build the menu\n    let menu = Menu::with_items(\n        handle,\n        &[\n            &show_hide,\n            &separator1,\n            &recording_menu,\n            &screenshot_menu,\n            &separator2,\n            &preferences,\n            &separator3,\n            &quit,\n        ],\n    )?;\n\n    // Get tray icon\n    let icon = get_tray_icon(app)?;\n\n    // Create tray icon\n    let _tray = TrayIconBuilder::new()\n        .icon(icon)\n        .menu(&menu)\n        .tooltip(\"FreeTodo - Dynamic Island\")\n        .on_menu_event(move |app, event| {\n            handle_menu_event(app, event.id.as_ref());\n        })\n        .on_tray_icon_event(|tray, event| {\n            handle_tray_event(tray.app_handle(), event);\n        })\n        .build(app)?;\n\n    info!(\"System tray created successfully\");\n\n    Ok(())\n}\n\n/// Create recording submenu\nfn create_recording_submenu(\n    handle: &AppHandle,\n) -> Result<tauri::menu::Submenu<tauri::Wry>, tauri::Error> {\n    let start_recording = MenuItem::with_id(\n        handle,\n        \"start_recording\",\n        \"Start Recording\",\n        false,\n        None::<&str>,\n    )?;\n    let stop_recording = MenuItem::with_id(\n        handle,\n        \"stop_recording\",\n        \"Stop Recording\",\n        false,\n        None::<&str>,\n    )?;\n\n    tauri::menu::Submenu::with_items(\n        handle,\n        \"Recording\",\n        true,\n        &[&start_recording, &stop_recording],\n    )\n}\n\n/// Create screenshot submenu\nfn create_screenshot_submenu(\n    handle: &AppHandle,\n) -> Result<tauri::menu::Submenu<tauri::Wry>, tauri::Error> {\n    let take_screenshot = MenuItem::with_id(\n        handle,\n        \"take_screenshot\",\n        \"Take Screenshot\",\n        false,\n        None::<&str>,\n    )?;\n    let view_screenshots = MenuItem::with_id(\n        handle,\n        \"view_screenshots\",\n        \"View Recent...\",\n        false,\n        None::<&str>,\n    )?;\n\n    tauri::menu::Submenu::with_items(\n        handle,\n        \"Screenshots\",\n        true,\n        &[&take_screenshot, &view_screenshots],\n    )\n}\n\n/// Get tray icon image\nfn get_tray_icon(_app: &App) -> Result<Image<'static>, Box<dyn std::error::Error>> {\n    // Load embedded icon (using PNG decoder)\n    let icon_bytes = include_bytes!(\"../icons/icon.png\");\n\n    // Decode PNG to get RGBA data\n    let decoder = png::Decoder::new(std::io::Cursor::new(icon_bytes));\n    let mut reader = decoder.read_info()?;\n    let buf_size = reader.output_buffer_size().ok_or_else(|| {\n        std::io::Error::new(\n            std::io::ErrorKind::InvalidData,\n            \"PNG output buffer size overflow\",\n        )\n    })?;\n    let mut buf = vec![0; buf_size];\n    let info = reader.next_frame(&mut buf)?;\n\n    // Convert to RGBA if necessary\n    let rgba = match info.color_type {\n        png::ColorType::Rgba => buf[..info.buffer_size()].to_vec(),\n        png::ColorType::Rgb => {\n            // Convert RGB to RGBA\n            let rgb = &buf[..info.buffer_size()];\n            let mut rgba = Vec::with_capacity((rgb.len() / 3) * 4);\n            for chunk in rgb.chunks(3) {\n                rgba.extend_from_slice(chunk);\n                rgba.push(255);\n            }\n            rgba\n        }\n        _ => {\n            error!(\"Unsupported color type: {:?}\", info.color_type);\n            return Err(\"Unsupported color type\".into());\n        }\n    };\n\n    Ok(Image::new_owned(rgba, info.width, info.height))\n}\n\n/// Handle menu item click events\nfn handle_menu_event(app: &AppHandle, menu_id: &str) {\n    info!(\"Menu event: {}\", menu_id);\n\n    match menu_id {\n        \"show_hide\" => {\n            toggle_window(app);\n        }\n        \"preferences\" => {\n            // Show preferences (for now, just show window)\n            show_window(app);\n            info!(\"Preferences clicked - feature not yet implemented\");\n        }\n        \"quit\" => {\n            info!(\"Quit requested from tray menu\");\n            app.exit(0);\n        }\n        \"start_recording\" => {\n            info!(\"Start recording - feature not yet implemented\");\n        }\n        \"stop_recording\" => {\n            info!(\"Stop recording - feature not yet implemented\");\n        }\n        \"take_screenshot\" => {\n            info!(\"Take screenshot - feature not yet implemented\");\n        }\n        \"view_screenshots\" => {\n            info!(\"View screenshots - feature not yet implemented\");\n        }\n        _ => {\n            info!(\"Unknown menu event: {}\", menu_id);\n        }\n    }\n}\n\n/// Handle tray icon events (click, double-click, etc.)\nfn handle_tray_event(app: &AppHandle, event: TrayIconEvent) {\n    match event {\n        TrayIconEvent::Click {\n            button: MouseButton::Left,\n            button_state: MouseButtonState::Up,\n            ..\n        } => {\n            info!(\"Tray icon left-clicked\");\n            toggle_window(app);\n        }\n        TrayIconEvent::DoubleClick {\n            button: MouseButton::Left,\n            ..\n        } => {\n            info!(\"Tray icon double-clicked\");\n            show_window(app);\n        }\n        _ => {}\n    }\n}\n\n/// Toggle main window visibility\nfn toggle_window(app: &AppHandle) {\n    if let Some(window) = app.get_webview_window(\"main\") {\n        match window.is_visible() {\n            Ok(true) => {\n                let _ = window.hide();\n                info!(\"Window hidden\");\n            }\n            Ok(false) => {\n                let _ = window.show();\n                let _ = window.set_focus();\n                info!(\"Window shown\");\n            }\n            Err(e) => {\n                error!(\"Failed to check window visibility: {}\", e);\n            }\n        }\n    } else {\n        error!(\"Main window not found\");\n    }\n}\n\n/// Show main window\nfn show_window(app: &AppHandle) {\n    if let Some(window) = app.get_webview_window(\"main\") {\n        let _ = window.show();\n        let _ = window.set_focus();\n        info!(\"Window shown and focused\");\n    }\n}\n\n/// Hide main window\n#[allow(dead_code)]\nfn hide_window(app: &AppHandle) {\n    if let Some(window) = app.get_webview_window(\"main\") {\n        let _ = window.hide();\n        info!(\"Window hidden\");\n    }\n}\n\n/// Update tray tooltip based on window state\n#[allow(dead_code)]\npub fn update_tray_tooltip(_app: &AppHandle, visible: bool) {\n    // Tray tooltip update would be implemented here\n    // Currently Tauri 2.x doesn't have a direct API for updating tooltip after creation\n    info!(\n        \"Tray state updated: Window is {}\",\n        if visible { \"visible\" } else { \"hidden\" }\n    );\n}\n"
  },
  {
    "path": "free-todo-frontend/src-tauri/tauri.conf.json",
    "content": "{\n  \"$schema\": \"https://schema.tauri.app/config/2.1\",\n  \"productName\": \"FreeTodo\",\n  \"version\": \"0.1.2\",\n  \"identifier\": \"com.freeugroup.freetodo\",\n  \"build\": {\n    \"beforeBuildCommand\": \"pnpm build:frontend:web && pnpm tauri:prebuild\",\n    \"devUrl\": \"http://localhost:3001\",\n    \"frontendDist\": \"dist\"\n  },\n  \"app\": {\n    \"withGlobalTauri\": false,\n    \"windows\": [\n      {\n        \"title\": \"FreeTodo\",\n        \"width\": 1200,\n        \"height\": 800,\n        \"minWidth\": 800,\n        \"minHeight\": 600,\n        \"resizable\": true,\n        \"fullscreen\": false,\n        \"transparent\": false,\n        \"decorations\": true,\n        \"visible\": true,\n        \"center\": true\n      }\n    ],\n    \"security\": {\n      \"csp\": null,\n      \"assetProtocol\": {\n        \"enable\": true,\n        \"scope\": [\"**\"]\n      }\n    },\n    \"trayIcon\": {\n      \"iconPath\": \"icons/icon.png\",\n      \"iconAsTemplate\": true\n    }\n  },\n  \"bundle\": {\n    \"active\": true,\n    \"icon\": [\n      \"icons/32x32.png\",\n      \"icons/128x128.png\",\n      \"icons/128x128@2x.png\",\n      \"icons/icon.ico\"\n    ],\n    \"targets\": \"all\",\n    \"resources\": [],\n    \"windows\": {\n      \"certificateThumbprint\": null,\n      \"digestAlgorithm\": \"sha256\",\n      \"timestampUrl\": \"\"\n    },\n    \"macOS\": {\n      \"frameworks\": [],\n      \"minimumSystemVersion\": \"\",\n      \"exceptionDomain\": \"\",\n      \"signingIdentity\": null,\n      \"providerShortName\": null,\n      \"entitlements\": null\n    },\n    \"linux\": {\n      \"deb\": {\n        \"section\": \"utility\"\n      },\n      \"appimage\": {\n        \"bundleMediaFramework\": false\n      }\n    }\n  },\n  \"plugins\": {\n    \"shell\": {\n      \"open\": true\n    }\n  }\n}\n"
  },
  {
    "path": "free-todo-frontend/src-tauri/tauri.island.pyinstaller.json",
    "content": "{\n  \"$schema\": \"https://schema.tauri.app/config/2.1\",\n  \"productName\": \"FreeTodo Island\",\n  \"version\": \"0.1.2\",\n  \"identifier\": \"com.freeugroup.freetodo.island\",\n  \"build\": {\n    \"beforeBuildCommand\": \"pnpm build:frontend:island && pnpm tauri:prebuild\",\n    \"devUrl\": \"http://localhost:3001\",\n    \"frontendDist\": \"dist\"\n  },\n  \"app\": {\n    \"withGlobalTauri\": false,\n    \"windows\": [\n      {\n        \"title\": \"FreeTodo Island\",\n        \"width\": 380,\n        \"height\": 120,\n        \"minWidth\": 300,\n        \"minHeight\": 80,\n        \"resizable\": false,\n        \"fullscreen\": false,\n        \"transparent\": true,\n        \"decorations\": false,\n        \"alwaysOnTop\": true,\n        \"skipTaskbar\": true,\n        \"visible\": true,\n        \"center\": false\n      }\n    ],\n    \"security\": {\n      \"csp\": null,\n      \"assetProtocol\": {\n        \"enable\": true,\n        \"scope\": [\"**\"]\n      }\n    },\n    \"trayIcon\": {\n      \"iconPath\": \"icons/icon.png\",\n      \"iconAsTemplate\": true\n    }\n  },\n  \"bundle\": {\n    \"active\": true,\n    \"icon\": [\n      \"icons/32x32.png\",\n      \"icons/128x128.png\",\n      \"icons/128x128@2x.png\",\n      \"icons/icon.ico\"\n    ],\n    \"targets\": \"all\",\n    \"resources\": [\n      \"../../dist-backend\",\n      \"../.next/standalone\"\n    ],\n    \"windows\": {\n      \"certificateThumbprint\": null,\n      \"digestAlgorithm\": \"sha256\",\n      \"timestampUrl\": \"\"\n    },\n    \"macOS\": {\n      \"frameworks\": [],\n      \"minimumSystemVersion\": \"\",\n      \"exceptionDomain\": \"\",\n      \"signingIdentity\": null,\n      \"providerShortName\": null,\n      \"entitlements\": null\n    },\n    \"linux\": {\n      \"deb\": {\n        \"section\": \"utility\"\n      },\n      \"appimage\": {\n        \"bundleMediaFramework\": false\n      }\n    }\n  },\n  \"plugins\": {\n    \"shell\": {\n      \"open\": true\n    }\n  }\n}\n"
  },
  {
    "path": "free-todo-frontend/src-tauri/tauri.island.script.json",
    "content": "{\n  \"$schema\": \"https://schema.tauri.app/config/2.1\",\n  \"productName\": \"FreeTodo Island\",\n  \"version\": \"0.1.2\",\n  \"identifier\": \"com.freeugroup.freetodo.island\",\n  \"build\": {\n    \"beforeBuildCommand\": \"pnpm build:frontend:island && pnpm tauri:prebuild\",\n    \"devUrl\": \"http://localhost:3001\",\n    \"frontendDist\": \"dist\"\n  },\n  \"app\": {\n    \"withGlobalTauri\": false,\n    \"windows\": [\n      {\n        \"title\": \"FreeTodo Island\",\n        \"width\": 380,\n        \"height\": 120,\n        \"minWidth\": 300,\n        \"minHeight\": 80,\n        \"resizable\": false,\n        \"fullscreen\": false,\n        \"transparent\": true,\n        \"decorations\": false,\n        \"alwaysOnTop\": true,\n        \"skipTaskbar\": true,\n        \"visible\": true,\n        \"center\": false\n      }\n    ],\n    \"security\": {\n      \"csp\": null,\n      \"assetProtocol\": {\n        \"enable\": true,\n        \"scope\": [\"**\"]\n      }\n    },\n    \"trayIcon\": {\n      \"iconPath\": \"icons/icon.png\",\n      \"iconAsTemplate\": true\n    }\n  },\n  \"bundle\": {\n    \"active\": true,\n    \"icon\": [\n      \"icons/32x32.png\",\n      \"icons/128x128.png\",\n      \"icons/128x128@2x.png\",\n      \"icons/icon.ico\"\n    ],\n    \"targets\": \"all\",\n    \"resources\": [\n      \"../../lifetrace/__init__.py\",\n      \"../../lifetrace/alembic.ini\",\n      \"../../lifetrace/server.py\",\n      \"../../lifetrace/config/default_config.yaml\",\n      \"../../lifetrace/config/prompt.yaml\",\n      \"../../lifetrace/config/rapidocr_config.yaml\",\n      \"../../lifetrace/config/prompts\",\n      \"../../lifetrace/core\",\n      \"../../lifetrace/docs\",\n      \"../../lifetrace/jobs\",\n      \"../../lifetrace/llm\",\n      \"../../lifetrace/migrations\",\n      \"../../lifetrace/models\",\n      \"../../lifetrace/observability\",\n      \"../../lifetrace/repositories\",\n      \"../../lifetrace/routers\",\n      \"../../lifetrace/schemas\",\n      \"../../lifetrace/scripts\",\n      \"../../lifetrace/services\",\n      \"../../lifetrace/storage\",\n      \"../../lifetrace/util\",\n      \"../../requirements-runtime.txt\",\n      \"../.next/standalone\"\n    ],\n    \"windows\": {\n      \"certificateThumbprint\": null,\n      \"digestAlgorithm\": \"sha256\",\n      \"timestampUrl\": \"\"\n    },\n    \"macOS\": {\n      \"frameworks\": [],\n      \"minimumSystemVersion\": \"\",\n      \"exceptionDomain\": \"\",\n      \"signingIdentity\": null,\n      \"providerShortName\": null,\n      \"entitlements\": null\n    },\n    \"linux\": {\n      \"deb\": {\n        \"section\": \"utility\"\n      },\n      \"appimage\": {\n        \"bundleMediaFramework\": false\n      }\n    }\n  },\n  \"plugins\": {\n    \"shell\": {\n      \"open\": true\n    }\n  }\n}\n"
  },
  {
    "path": "free-todo-frontend/src-tauri/tauri.lint.json",
    "content": "{\n  \"$schema\": \"https://schema.tauri.app/config/2.1\",\n  \"productName\": \"FreeTodo\",\n  \"version\": \"0.1.2\",\n  \"identifier\": \"com.freeugroup.freetodo\",\n  \"build\": {\n    \"beforeBuildCommand\": \"\",\n    \"devUrl\": \"http://localhost:3001\",\n    \"frontendDist\": \".tauri-lint-dist\"\n  },\n  \"app\": {\n    \"withGlobalTauri\": false,\n    \"windows\": [\n      {\n        \"title\": \"FreeTodo\",\n        \"width\": 1200,\n        \"height\": 800,\n        \"minWidth\": 800,\n        \"minHeight\": 600,\n        \"resizable\": true,\n        \"fullscreen\": false,\n        \"transparent\": false,\n        \"decorations\": true,\n        \"visible\": true,\n        \"center\": true\n      }\n    ],\n    \"security\": {\n      \"csp\": null,\n      \"assetProtocol\": {\n        \"enable\": true,\n        \"scope\": [\"**\"]\n      }\n    },\n    \"trayIcon\": {\n      \"iconPath\": \"icons/icon.png\",\n      \"iconAsTemplate\": true\n    }\n  },\n  \"bundle\": {\n    \"active\": true,\n    \"icon\": [\n      \"icons/32x32.png\",\n      \"icons/128x128.png\",\n      \"icons/128x128@2x.png\",\n      \"icons/icon.ico\"\n    ],\n    \"targets\": \"all\",\n    \"resources\": [],\n    \"windows\": {\n      \"certificateThumbprint\": null,\n      \"digestAlgorithm\": \"sha256\",\n      \"timestampUrl\": \"\"\n    },\n    \"macOS\": {\n      \"frameworks\": [],\n      \"minimumSystemVersion\": \"\",\n      \"exceptionDomain\": \"\",\n      \"signingIdentity\": null,\n      \"providerShortName\": null,\n      \"entitlements\": null\n    },\n    \"linux\": {\n      \"deb\": {\n        \"section\": \"utility\"\n      },\n      \"appimage\": {\n        \"bundleMediaFramework\": false\n      }\n    }\n  },\n  \"plugins\": {\n    \"shell\": {\n      \"open\": true\n    }\n  }\n}\n"
  },
  {
    "path": "free-todo-frontend/src-tauri/tauri.web.pyinstaller.json",
    "content": "{\n  \"$schema\": \"https://schema.tauri.app/config/2.1\",\n  \"productName\": \"FreeTodo\",\n  \"version\": \"0.1.2\",\n  \"identifier\": \"com.freeugroup.freetodo\",\n  \"build\": {\n    \"beforeBuildCommand\": \"pnpm build:frontend:web && pnpm tauri:prebuild\",\n    \"devUrl\": \"http://localhost:3001\",\n    \"frontendDist\": \"dist\"\n  },\n  \"app\": {\n    \"withGlobalTauri\": false,\n    \"windows\": [\n      {\n        \"title\": \"FreeTodo\",\n        \"width\": 1200,\n        \"height\": 800,\n        \"minWidth\": 800,\n        \"minHeight\": 600,\n        \"resizable\": true,\n        \"fullscreen\": false,\n        \"transparent\": false,\n        \"decorations\": true,\n        \"visible\": true,\n        \"center\": true\n      }\n    ],\n    \"security\": {\n      \"csp\": null,\n      \"assetProtocol\": {\n        \"enable\": true,\n        \"scope\": [\"**\"]\n      }\n    },\n    \"trayIcon\": {\n      \"iconPath\": \"icons/icon.png\",\n      \"iconAsTemplate\": true\n    }\n  },\n  \"bundle\": {\n    \"active\": true,\n    \"icon\": [\n      \"icons/32x32.png\",\n      \"icons/128x128.png\",\n      \"icons/128x128@2x.png\",\n      \"icons/icon.ico\"\n    ],\n    \"targets\": \"all\",\n    \"resources\": [\n      \"../../dist-backend\",\n      \"../.next/standalone\"\n    ],\n    \"windows\": {\n      \"certificateThumbprint\": null,\n      \"digestAlgorithm\": \"sha256\",\n      \"timestampUrl\": \"\"\n    },\n    \"macOS\": {\n      \"frameworks\": [],\n      \"minimumSystemVersion\": \"\",\n      \"exceptionDomain\": \"\",\n      \"signingIdentity\": null,\n      \"providerShortName\": null,\n      \"entitlements\": null\n    },\n    \"linux\": {\n      \"deb\": {\n        \"section\": \"utility\"\n      },\n      \"appimage\": {\n        \"bundleMediaFramework\": false\n      }\n    }\n  },\n  \"plugins\": {\n    \"shell\": {\n      \"open\": true\n    }\n  }\n}\n"
  },
  {
    "path": "free-todo-frontend/src-tauri/tauri.web.script.json",
    "content": "{\n  \"$schema\": \"https://schema.tauri.app/config/2.1\",\n  \"productName\": \"FreeTodo\",\n  \"version\": \"0.1.2\",\n  \"identifier\": \"com.freeugroup.freetodo\",\n  \"build\": {\n    \"beforeBuildCommand\": \"pnpm build:frontend:web && pnpm tauri:prebuild\",\n    \"devUrl\": \"http://localhost:3001\",\n    \"frontendDist\": \"dist\"\n  },\n  \"app\": {\n    \"withGlobalTauri\": false,\n    \"windows\": [\n      {\n        \"title\": \"FreeTodo\",\n        \"width\": 1200,\n        \"height\": 800,\n        \"minWidth\": 800,\n        \"minHeight\": 600,\n        \"resizable\": true,\n        \"fullscreen\": false,\n        \"transparent\": false,\n        \"decorations\": true,\n        \"visible\": true,\n        \"center\": true\n      }\n    ],\n    \"security\": {\n      \"csp\": null,\n      \"assetProtocol\": {\n        \"enable\": true,\n        \"scope\": [\"**\"]\n      }\n    },\n    \"trayIcon\": {\n      \"iconPath\": \"icons/icon.png\",\n      \"iconAsTemplate\": true\n    }\n  },\n  \"bundle\": {\n    \"active\": true,\n    \"icon\": [\n      \"icons/32x32.png\",\n      \"icons/128x128.png\",\n      \"icons/128x128@2x.png\",\n      \"icons/icon.ico\"\n    ],\n    \"targets\": \"all\",\n    \"resources\": [\n      \"../../lifetrace/__init__.py\",\n      \"../../lifetrace/alembic.ini\",\n      \"../../lifetrace/server.py\",\n      \"../../lifetrace/config/default_config.yaml\",\n      \"../../lifetrace/config/prompt.yaml\",\n      \"../../lifetrace/config/rapidocr_config.yaml\",\n      \"../../lifetrace/config/prompts\",\n      \"../../lifetrace/core\",\n      \"../../lifetrace/docs\",\n      \"../../lifetrace/jobs\",\n      \"../../lifetrace/llm\",\n      \"../../lifetrace/migrations\",\n      \"../../lifetrace/models\",\n      \"../../lifetrace/observability\",\n      \"../../lifetrace/repositories\",\n      \"../../lifetrace/routers\",\n      \"../../lifetrace/schemas\",\n      \"../../lifetrace/scripts\",\n      \"../../lifetrace/services\",\n      \"../../lifetrace/storage\",\n      \"../../lifetrace/util\",\n      \"../../requirements-runtime.txt\",\n      \"../.next/standalone\"\n    ],\n    \"windows\": {\n      \"certificateThumbprint\": null,\n      \"digestAlgorithm\": \"sha256\",\n      \"timestampUrl\": \"\"\n    },\n    \"macOS\": {\n      \"frameworks\": [],\n      \"minimumSystemVersion\": \"\",\n      \"exceptionDomain\": \"\",\n      \"signingIdentity\": null,\n      \"providerShortName\": null,\n      \"entitlements\": null\n    },\n    \"linux\": {\n      \"deb\": {\n        \"section\": \"utility\"\n      },\n      \"appimage\": {\n        \"bundleMediaFramework\": false\n      }\n    }\n  },\n  \"plugins\": {\n    \"shell\": {\n      \"open\": true\n    }\n  }\n}\n"
  },
  {
    "path": "free-todo-frontend/tailwind.config.ts",
    "content": "import type { Config } from \"tailwindcss\";\n\nconst config: Config = {\n\tdarkMode: \"class\",\n\tcontent: [\n\t\t\"./app/**/*.{ts,tsx}\",\n\t\t\"./components/**/*.{ts,tsx}\",\n\t\t\"./lib/**/*.{ts,tsx}\",\n\t\t\"./apps/**/*.{ts,tsx}\",\n\t],\n\tplugins: [require(\"@tailwindcss/typography\")],\n};\n\nexport default config;\n"
  },
  {
    "path": "free-todo-frontend/tsconfig.json",
    "content": "{\n\t\"compilerOptions\": {\n\t\t\"target\": \"ESNext\",\n\t\t\"lib\": [\"dom\", \"dom.iterable\", \"esnext\"],\n\t\t\"allowJs\": false,\n\t\t\"skipLibCheck\": true,\n\t\t\"strict\": true,\n\t\t\"noImplicitAny\": true,\n\t\t\"strictNullChecks\": true,\n\t\t\"noUnusedLocals\": true,\n\t\t\"noUnusedParameters\": true,\n\t\t\"noEmit\": true,\n\t\t\"module\": \"ESNext\",\n\t\t\"moduleResolution\": \"bundler\",\n\t\t\"resolveJsonModule\": true,\n\t\t\"isolatedModules\": true,\n\t\t\"jsx\": \"react-jsx\",\n\t\t\"incremental\": true,\n\t\t\"baseUrl\": \".\",\n\t\t\"paths\": {\n\t\t\t\"@/*\": [\"./*\"]\n\t\t},\n\t\t\"allowImportingTsExtensions\": true,\n\t\t\"moduleDetection\": \"force\",\n\t\t\"esModuleInterop\": true,\n\t\t\"plugins\": [\n\t\t\t{\n\t\t\t\t\"name\": \"next\"\n\t\t\t}\n\t\t]\n\t},\n\t\"include\": [\n\t\t\"next-env.d.ts\",\n\t\t\"**/*.ts\",\n\t\t\"**/*.tsx\",\n\t\t\".next/types/**/*.ts\",\n\t\t\".next/dev/types/**/*.ts\"\n\t],\n\t\"exclude\": [\n\t\t\"node_modules\",\n\t\t\"dist-electron\",\n\t\t\"dist-electron-app\",\n\t\t\"dist\",\n\t\t\"dist-backend\",\n\t\t\"../dist-backend\",\n\t\t\"src-tauri\"\n\t]\n}\n"
  },
  {
    "path": "lifetrace/__init__.py",
    "content": "\"\"\"\nLifeTrace - A cross-platform screen recording and activity tracking application\n\"\"\"\n\n__version__ = \"0.1.0\"\n"
  },
  {
    "path": "lifetrace/alembic.ini",
    "content": "# Alembic Configuration File\n\n[alembic]\nscript_location = migrations\nprepend_sys_path = .\n\nsqlalchemy.url = sqlite:///data/lifetrace.db\n\n# 日志配置\n[loggers]\nkeys = root,sqlalchemy,alembic\n\n[handlers]\nkeys = console\n\n[formatters]\nkeys = generic\n\n[logger_root]\nlevel = WARN\nhandlers = console\nqualname =\n\n[logger_sqlalchemy]\nlevel = WARN\nhandlers =\nqualname = sqlalchemy.engine\n\n[logger_alembic]\nlevel = INFO\nhandlers =\nqualname = alembic\n\n[handler_console]\nclass = StreamHandler\nargs = (sys.stderr,)\nlevel = NOTSET\nformatter = generic\n\n[formatter_generic]\nformat = %(levelname)-5.5s [%(name)s] %(message)s\ndatefmt = %H:%M:%S\n"
  },
  {
    "path": "lifetrace/config/default_config.yaml",
    "content": "# LifeTrace 默认配置文件\n\n# ！！！重要提示：\n# 请勿编辑此文件 default_config.yaml，所有配置都应在 config.yaml 中进行设置\n# 如果 config.yaml 不存在，系统会自动从 default_config.yaml 复制并生成\n# 如果 config.yaml 存在，系统会自动验证完整性，如果不完整，系统会自动提示并退出\n# 当此提示出现在 config.yaml 中时，请忽略\n\n# 服务器配置\nserver:\n  host: 127.0.0.1\n  port: 8001 # 默认端口 8001，保留 8000 给其他服务\n  debug: false\n\n# 后端模块启用配置（轻量插件化）\nbackend_modules:\n  enabled:\n    - health\n    - config\n    - system\n    - logs\n    - chat\n    - activity\n    - search\n    - screenshot\n    - event\n    - ocr\n    - vector\n    - rag\n    - scheduler\n    - cost_tracking\n    - time_allocation\n    - todo\n    - todo_extraction\n    - journal\n    - vision\n    - notification\n    - floating_capture\n    - audio\n    - proactive_ocr\n\n# 基础目录配置\nbase_dir: data\ndatabase_path: lifetrace.db\nscreenshots_dir: screenshots/\nattachments_dir: attachments/\n\n# 日志配置\nlogging:\n  level: INFO\n  console_level: INFO\n  file_level: INFO\n  quiet_modules: [] # 示例: [\"activity_service\", \"event_service\"]\n  log_path: logs/\n\n# 调度器配置\nscheduler:\n  enabled: true # 启用调度器\n  database_path: scheduler.db # 调度器数据库路径\n  max_workers: 10 # 最大工作线程数\n  coalesce: true # 合并错过的任务\n  max_instances: 1 # 同一任务同时只能有一个实例\n  misfire_grace_time: 60 # 错过触发时间的容忍度（秒）\n  timezone: Asia/Shanghai # 时区\n\n# 定时任务\njobs:\n  recorder:\n    id: recorder # 任务ID\n    name: '屏幕录制' # 任务显示名称（中文）\n    enabled: false # 是否启用录制器任务（默认关闭，需用户手动开启）\n    interval: 10 # 截图间隔（秒）\n    params:\n      screens: all # 截图屏幕：all 或屏幕编号列表\n      auto_exclude_self: true # 自动排除 LifeTrace 自身窗口\n      deduplicate: true # 启用截图去重（通过文件哈希避免保存重复截图）\n      hash_threshold: 5 # 图像哈希去重阈值（汉明距离），值越小越严格\n      file_io_timeout: 15 # 文件I/O操作超时时间（秒）\n      db_timeout: 20 # 数据库操作超时时间（秒）\n      window_info_timeout: 5 # 获取窗口信息超时时间（秒）\n      blacklist:\n        enabled: false # 是否启用黑名单功能\n        apps: ['微信'] # 应用黑名单，使用友好名称，例如: [\"微信\", \"QQ\", \"钉钉\"]\n        windows: [] # 窗口标题黑名单，例如: [\"记事本\", \"计算器\"]\n  auto_todo_detection:\n    id: auto_todo_detection # 任务ID\n    name: 自动待办检测 # 任务显示名称（中文）\n    enabled: false # 是否启用自动待办检测（默认关闭，需用户手动开启）\n    params:\n      whitelist:\n        apps: ['微信', 'WeChat', '飞书', 'Feishu', 'Lark', '钉钉', 'DingTalk'] # 应用白名单，只有这些应用的截图才会触发自动待办检测\n  todo_recorder:\n    id: todo_recorder # 任务ID\n    name: '屏幕录制（Todo生成）' # 任务显示名称（中文）\n    enabled: false # 是否启用 Todo 专用录制（默认关闭，与 auto_todo_detection 联动）\n    interval: 5 # 截图间隔（秒），默认5秒\n    params:\n      # 白名单应用从 auto_todo_detection.params.whitelist.apps 读取，无需重复配置\n      deduplicate: true # 启用截图去重\n      hash_threshold: 5 # 图像哈希去重阈值（汉明距离）\n      file_io_timeout: 15 # 文件I/O操作超时时间（秒）\n      db_timeout: 20 # 数据库操作超时时间（秒）\n      window_info_timeout: 5 # 获取窗口信息超时时间（秒）\n  ocr:\n    id: ocr # 任务ID\n    name: OCR识别 # 任务显示名称（中文）\n    enabled: false # 是否启用OCR任务（默认关闭，需用户手动开启）\n    interval: 10 # 数据库检查间隔（秒）\n    params:\n      use_gpu: false\n      language: ['ch', 'en']\n      confidence_threshold: 0.5\n  audio_recording:\n    id: audio_recording # 任务ID\n    name: 音频录制 # 任务显示名称（中文）\n    enabled: false # 是否启用音频录制（默认关闭，7x24小时录制）\n    interval: 60 # 状态检查间隔（秒），用于监控录音状态\n    params:\n      segment_duration_minutes: 30 # 分段时长（分钟）\n      silence_threshold_seconds: 600 # 静音检测阈值（秒）\n  activity_aggregator:\n    id: activity_aggregator # 任务ID\n    name: 活动聚合 # 任务显示名称（中文）\n    enabled: false # 是否启用活动聚合任务（默认关闭，需用户手动开启）\n    interval: 900 # 检查间隔（秒），默认15分钟\n  clean_data:\n    id: clean_data # 任务ID\n    name: 截图清理 # 任务显示名称（中文）\n    enabled: false # 是否启用截图清理任务（默认关闭，需要时手动开启）\n    interval: 3600 # 检查间隔（秒），默认每小时检查一次\n    params:\n      max_screenshots: 10000 # 最大截图数量限制\n      max_days: 30 # 数据保留天数（按日期清理旧数据）\n      delete_file_only: true # 只删除文件（true），还是同时删除记录（false）\n  deadline_reminder:\n    id: deadline_reminder # 任务ID\n    name: DDL提醒 # 任务显示名称（中文）\n    enabled: false # 是否启用 DDL 提醒任务（默认关闭，需用户手动开启）\n    params:\n      reminder_window_minutes: 5 # 兼容保留字段，不再作为待办默认提醒\n  proactive_ocr:\n    id: proactive_ocr # 任务ID\n    name: 主动OCR # 任务显示名称（中文）\n    enabled: false # 是否启用主动OCR任务（默认关闭，需用户手动开启，仅Windows）\n    interval: 1.0 # 检测间隔（秒），默认1秒\n    params:\n      apps: [\"wechat\", \"feishu\"] # 目标应用列表\n      use_roi: true # 是否使用ROI裁切加速（只识别聊天区域）\n      resize_max_side: 800 # 图像预缩放最大边长，0表示不缩放\n      det_limit_side_len: 640 # OCR检测输入边长限制\n      min_confidence: 0.8 # 最低置信度阈值\n      auto_extract_todos: true # 是否在识别后自动触发基于 OCR 文本的待办提取\n      min_text_length: 5 # 触发自动待办提取所需的最小文本长度（按字符数）\n\n# 向量数据库配置\nvector_db:\n  enabled: true # 启用向量数据库\n  collection_name: lifetrace_ocr # 集合名称\n  embedding_model: shibing624/text2vec-base-chinese # 嵌入模型\n  rerank_model: BAAI/bge-reranker-base # 重排序模型\n  persist_directory: vector_db # 持久化目录\n\n# 聊天配置\nchat:\n  enable_history: true # 开启后发送消息时附带历史上下文\n  history_limit: 10 # 历史记录轮数限制（1轮=1个用户消息+1个助手回复）\n\n# LLM配置\nllm:\n  api_key: YOUR_LLM_KEY_HERE # LLM 密钥，需要用户配置\n  base_url: https://dashscope.aliyuncs.com/compatible-mode/v1 # LLM API基础URL\n  model: qwen-plus # 使用的模型名称\n  vision_model: qwen3-vl-plus # 视觉多模态模型名称（用于图片分析）\n  temperature: 0.7 # 温度参数\n  max_tokens: 2048 # 最大token数\n  # 模型价格配置（单位：人民币/千token）\n  model_prices:\n    default: # 默认价格，用于未配置的模型（qwen-plus）\n      input_price: 0.0008 # 输入token价格\n      output_price: 0.002 # 输出token价格\n    qwen3-max:\n      input_price: 0.0032\n      output_price: 0.0128\n    qwen-plus:\n      input_price: 0.0008\n      output_price: 0.002\n    qwen-turbo:\n      input_price: 0.0003\n      output_price: 0.0006\n    qwen3-vl-plus: # 视觉模型价格（支持非思考/思考模式，按输入token分层计价）\n      # 兼容旧逻辑的基础单价（默认取第一档）\n      input_price: 0.001 # 输入token价格（元/千token）\n      output_price: 0.01 # 输出token价格（元/千token）\n      tiers:\n        # (0, 32,000]\n        - max_input_tokens: 32000\n          input_price: 0.001\n          output_price: 0.01\n        # (32,000, 128,000]\n        - max_input_tokens: 128000\n          input_price: 0.0015\n          output_price: 0.015\n        # (128,000, 256,000]\n        - max_input_tokens: 256000\n          input_price: 0.003\n          output_price: 0.03\n\n# Tavily 配置（联网搜索）\ntavily:\n  api_key: YOUR_TAVILY_API_KEY_HERE # Tavily API Key，需要用户配置\n  search_depth: basic # 搜索深度：basic 或 advanced\n  max_results: 5 # 最大返回结果数\n  include_domains: [] # 包含的域名列表（可选）\n  exclude_domains: [] # 排除的域名列表（可选）\n\n# 音频识别配置（阿里云Fun-ASR）\naudio:\n  is_24x7: false # 7x24小时录制模式（自动启动录音，默认关闭）\n  asr:\n    api_key: YOUR_LLM_KEY_HERE # 阿里云百炼API Key\n    base_url: wss://dashscope.aliyuncs.com/api-ws/v1/inference/ # WebSocket地址\n    model: fun-asr-realtime # 使用的模型名称\n    sample_rate: 16000 # 采样率（Hz）\n    format: pcm # 音频格式：pcm, wav, mp3, opus, speex, aac, amr\n    semantic_punctuation_enabled: false # 是否开启语义断句\n    max_sentence_silence: 1300 # VAD静音时长阈值（毫秒）\n    heartbeat: false # 是否开启长连接保持\n  storage:\n    audio_dir: audio/ # 音频文件存储目录\n    temp_audio_dir: temp_audio/ # 临时音频文件目录\n\n# 可观测性配置（Phoenix + OpenInference）\nobservability:\n  enabled: true # 是否启用观测功能（默认关闭，需用户手动开启）\n  mode: both # 导出模式：local（本地JSON文件）| phoenix（Phoenix UI）| both（两者都启用）\n  local:\n    traces_dir: traces/ # trace 文件存储目录（相对于 base_dir）\n    max_files: 100 # 最大保留文件数（超出后自动清理旧文件）\n    pretty_print: true # JSON 是否格式化输出（便于人类阅读）\n  phoenix:\n    endpoint: http://localhost:6006 # Phoenix 服务端点（需先启动 phoenix serve）\n    project_name: freetodo-agent # 项目名称（用于 Phoenix UI 中分组）\n    export_timeout_sec: 2.0 # 导出超时（秒），避免 Phoenix 不可用时阻塞\n    disable_after_failures: 1 # 连续失败达到阈值后自动禁用 Phoenix 导出\n    retry_cooldown_sec: 60 # 触发禁用后多久尝试恢复（秒，0 表示不自动重试）\n  terminal:\n    summary_only: true # Terminal 是否只输出一行摘要（推荐 true，保持日志精简）\n"
  },
  {
    "path": "lifetrace/config/prompt.yaml",
    "content": "# ============================================================\n# LifeTrace Prompt 配置文件 - 已迁移说明\n# ============================================================\n#\n# 提示词配置已拆分到 prompts/ 目录下的多个文件中，\n# 便于维护和管理。\n#\n# 新的文件结构：\n#   config/prompts/\n#   ├── rag.yaml         # RAG 服务相关提示词\n#   ├── llm.yaml         # LLM 客户端相关提示词\n#   ├── summary.yaml     # 摘要服务（event_summary, activity_summary, context_builder）\n#   ├── todo.yaml        # 待办相关（todo_extraction, auto_todo_detection）\n#   ├── plan.yaml        # Plan 功能（plan_questionnaire, plan_summary）\n#   ├── chat.yaml        # 前端聊天（chat_frontend）\n#   └── search.yaml      # 搜索相关（web_search, agent）\n#\n# PromptLoader 会自动从 prompts/ 目录加载所有 yaml 文件。\n# 如果 prompts/ 目录不存在，则回退到本文件（兼容旧版本）。\n#\n# 使用方式保持不变：\n#   from lifetrace.util.prompt_loader import get_prompt\n#   prompt = get_prompt(\"rag\", \"system_help\")\n#\n# ============================================================\n\n# 此文件现在为空，所有提示词已迁移到 prompts/ 目录\n# 如需恢复单文件模式，可删除 prompts/ 目录并在此文件中添加提示词配置\n"
  },
  {
    "path": "lifetrace/config/prompts/agno_tools/en/breakdown.yaml",
    "content": "# Task Breakdown Tool Messages - English\n\n# Task breakdown guide (for Agent to break down directly, avoiding nested LLM calls)\nbreakdown_guide: |\n  Please break down the following task into specific, actionable subtasks.\n\n  **Task Description:**\n  {task_description}\n\n  **Breakdown Requirements:**\n  1. Each subtask should be specific and actionable\n  2. Subtasks should have a logical execution order\n  3. Estimate time for each subtask (optional)\n  4. Display in a clear list format with name, description, and estimated time for each subtask\n\n  **Output Format Example:**\n  1. Subtask Name\n     Detailed description\n     [Estimated time]\n  2. Subtask Name\n     Detailed description\n     [Estimated time]\n\n  Please break down the task directly and display the results without calling other tools.\n\n# Keep old message keys for compatibility (deprecated but kept for reference)\nbreakdown_prompt: |\n  Please break down the following task into specific, actionable subtasks.\n\n  **Task Description:**\n  {task_description}\n\n  **Requirements:**\n  1. Each subtask should be specific and actionable\n  2. Subtasks should have a logical execution order\n  3. Estimate time for each subtask (optional)\n  4. Return in JSON format\n\n  **Return Format:**\n  ```json\n  {{\n    \"subtasks\": [\n      {{\"name\": \"subtask name\", \"description\": \"details\", \"estimated_time\": \"estimated time\"}},\n      ...\n    ]\n  }}\n  ```\n\nbreakdown_result: \"Task breakdown result:\\n{result}\"\nbreakdown_failed: \"Failed to break down task: {error}\"\n"
  },
  {
    "path": "lifetrace/config/prompts/agno_tools/en/conflict.yaml",
    "content": "# Conflict Detection Tool Messages - English\n\nconflict_found: \"Found {count} conflict(s) in {time_range}:\\n{conflicts}\"\nconflict_item: \"- #{id} {name} ({start} - {end})\"\nno_conflict: \"No conflicts in {time_range}, available for scheduling\"\nconflict_failed: \"Conflict check failed: {error}\"\n"
  },
  {
    "path": "lifetrace/config/prompts/agno_tools/en/instructions.yaml",
    "content": "# Agent System Instructions - English\n\ninstructions: |\n  You are FreeTodo AI assistant, helping users manage their todos.\n\n  **Tool Usage Guide:**\n  1. Use Todo management tools when users ask to create, query, update, or delete todos\n  2. For time-related inputs, use parse_time tool first to convert to ISO format\n  3. When users ask about schedule, use check_schedule_conflict to detect conflicts\n  4. For statistics or analysis, use get_todo_stats or get_overdue_todos\n  5. **Task Breakdown**: When users ask to break down complex tasks, call breakdown_task tool to get breakdown guidance, then **directly** break down the task into subtasks and display to users without calling LLM again\n  6. **Tag Suggestion**: When users need tag suggestions, call suggest_tags tool to get existing tags and suggestion guidance, then **directly** suggest appropriate tags without calling LLM again\n\n  **Performance Optimization:**\n  - breakdown_task and suggest_tags tools return breakdown/suggestion guidance\n  - You should directly complete task breakdown or tag suggestion based on the guidance, without calling LLM again\n  - This avoids nested LLM calls and significantly improves response speed\n\n  **Notes:**\n  - After successful operations, briefly inform users of the result\n  - If operation fails, explain the reason and provide suggestions\n  - When listing todos, sort by priority and scheduled time\n  - When breaking down tasks, ensure subtasks are specific, actionable, and have a logical execution order\n"
  },
  {
    "path": "lifetrace/config/prompts/agno_tools/en/stats.yaml",
    "content": "# Statistics Tool Messages - English\n\n# Stats\nstats_header: \"Todo Statistics ({date_range}):\\n\"\nstats_total: \"- Total: {total}\"\nstats_completed: \"- Completed: {completed}\"\nstats_active: \"- Active: {active}\"\nstats_overdue: \"- Overdue: {overdue}\"\nstats_by_priority: \"- By priority: High({high}) Medium({medium}) Low({low}) None({none})\"\nstats_failed: \"Failed to get statistics: {error}\"\n\n# Overdue\noverdue_header: \"Overdue todos ({count} items):\\n\"\noverdue_item: \"- #{id} {name} (overdue by {days} days)\"\nno_overdue: \"No overdue todos\"\n"
  },
  {
    "path": "lifetrace/config/prompts/agno_tools/en/tags.yaml",
    "content": "# Tag Management Tool Messages - English\n\n# Tag list\ntags_header: \"All tags ({count} total):\\n\"\ntags_item: \"- {tag} ({count} todos)\"\ntags_empty: \"No tags found\"\n\n# Todos by tag\ntodos_by_tag_header: \"Todos with tag \\\"{tag}\\\" ({count} items):\\n\"\ntodos_by_tag_item: \"- #{id} [{status}] {name}\"\ntodos_by_tag_empty: \"No todos found with tag \\\"{tag}\\\"\"\n\n# Tag suggestion guide (for Agent to suggest directly, avoiding nested LLM calls)\nsuggest_tags_guide: |\n  Please suggest 3-5 appropriate tags based on the following todo name.\n\n  **Todo name:** {todo_name}\n\n  **Existing tags (for reference, you can reuse or create new ones):** {existing_tags}\n\n  **Suggestion Requirements:**\n  1. Suggest 3-5 relevant tags\n  2. Prefer reusing existing tags when appropriate\n  3. Create new tags if existing ones don't fit\n  4. Tags should be concise, meaningful, and useful for categorization\n\n  Please suggest tags directly and display the results without calling other tools. Format: Suggested tags: tag1, tag2, tag3\n\n# Keep old message keys for compatibility (deprecated but kept for reference)\nsuggest_tags_prompt: |\n  Suggest 3-5 appropriate tags based on the following todo name.\n\n  **Todo name:** {todo_name}\n\n  **Existing tags (for reference):** {existing_tags}\n\n  Return in JSON format:\n  ```json\n  {{\"suggested_tags\": [\"tag1\", \"tag2\", \"tag3\"]}}\n  ```\n\nsuggest_tags_result: \"Suggested tags: {tags}\"\nsuggest_tags_failed: \"Failed to suggest tags: {error}\"\n"
  },
  {
    "path": "lifetrace/config/prompts/agno_tools/en/time.yaml",
    "content": "# Time Parsing Tool Messages - English\n\nparse_time_success: \"Parsed result: {result}\"\nparse_time_failed: \"Cannot parse time expression \\\"{expression}\\\": {error}\"\n"
  },
  {
    "path": "lifetrace/config/prompts/agno_tools/en/todo.yaml",
    "content": "# Todo Management Tool Messages - English\n\n# Create\ncreate_success: \"Successfully created todo #{id}: {name}\"\ncreate_failed: \"Failed to create todo: {error}\"\n\n# Complete\ncomplete_success: \"Todo #{id} marked as completed\"\ncomplete_not_found: \"Todo #{id} not found\"\ncomplete_failed: \"Failed to complete todo: {error}\"\n\n# Update\nupdate_success: \"Todo #{id} updated\"\nupdate_not_found: \"Todo #{id} not found\"\nupdate_failed: \"Failed to update todo: {error}\"\n\n# List\nlist_header: \"Todo list ({status}, {count} items):\\n\"\nlist_item: \"- #{id} [{priority}] {name}\"\nlist_item_with_time: \" (time: {time})\"\nlist_empty: \"No {status} todos found\"\n\n# Search\nsearch_header: \"Search results for \\\"{keyword}\\\" ({count} items):\\n\"\nsearch_item: \"- #{id} [{status}] {name}\"\nsearch_empty: \"No todos found containing \\\"{keyword}\\\"\"\n\n# Delete\ndelete_success: \"Todo #{id} deleted\"\ndelete_not_found: \"Todo #{id} not found\"\ndelete_failed: \"Failed to delete todo: {error}\"\n"
  },
  {
    "path": "lifetrace/config/prompts/agno_tools/zh/breakdown.yaml",
    "content": "# 任务拆解工具消息 - 中文版\n\n# 任务拆解指导（用于 Agent 直接拆解，避免嵌套 LLM 调用）\nbreakdown_guide: |\n  请将以下任务拆解为具体的、可执行的子任务列表。\n\n  **任务描述：**\n  {task_description}\n\n  **拆解要求：**\n  1. 每个子任务应该是具体的、可执行的\n  2. 子任务之间应该有合理的执行顺序\n  3. 估算每个子任务的大致时间（可选）\n  4. 以清晰的列表格式展示，每个子任务包含名称、描述和预计时间\n\n  **输出格式示例：**\n  1. 子任务名称\n     详细描述\n     [预计时间]\n  2. 子任务名称\n     详细描述\n     [预计时间]\n\n  请直接拆解任务并展示结果，无需调用其他工具。\n\n# 保留旧的消息键以兼容性（已废弃，但保留以防引用）\nbreakdown_prompt: |\n  请将以下任务拆解为具体的、可执行的子任务列表。\n\n  **任务描述：**\n  {task_description}\n\n  **要求：**\n  1. 每个子任务应该是具体的、可执行的\n  2. 子任务之间应该有合理的执行顺序\n  3. 估算每个子任务的大致时间（可选）\n  4. 返回 JSON 格式\n\n  **返回格式：**\n  ```json\n  {{\n    \"subtasks\": [\n      {{\"name\": \"子任务名称\", \"description\": \"详细描述\", \"estimated_time\": \"预计时间\"}},\n      ...\n    ]\n  }}\n  ```\n\nbreakdown_result: \"任务拆解结果:\\n{result}\"\nbreakdown_failed: \"任务拆解失败: {error}\"\n"
  },
  {
    "path": "lifetrace/config/prompts/agno_tools/zh/conflict.yaml",
    "content": "# 冲突检测工具消息 - 中文版\n\nconflict_found: \"在 {time_range} 时间段内发现 {count} 个冲突:\\n{conflicts}\"\nconflict_item: \"- #{id} {name} ({start} - {end})\"\nno_conflict: \"在 {time_range} 时间段内没有冲突，可以安排\"\nconflict_failed: \"冲突检测失败: {error}\"\n"
  },
  {
    "path": "lifetrace/config/prompts/agno_tools/zh/instructions.yaml",
    "content": "# Agent 系统指令 - 中文版\n\ninstructions: |\n  你是 FreeTodo 智能助手，可以帮助用户管理待办事项。\n\n  **工具使用指南：**\n  1. 当用户要求创建、查询、更新、删除待办时，使用相应的 Todo 管理工具\n  2. 时间相关的输入请先用 parse_time 工具解析为 ISO 格式\n  3. 当用户询问日程安排时，使用 check_schedule_conflict 检测时间冲突\n  4. 当用户想要统计或分析待办时，使用 get_todo_stats 或 get_overdue_todos\n  5. **任务拆解**：当用户要求拆解复杂任务时，调用 breakdown_task 工具获取拆解指导，然后**直接**将任务拆解为子任务列表展示给用户，无需再次调用 LLM\n  6. **标签推荐**：当用户需要推荐标签时，调用 suggest_tags 工具获取现有标签和推荐指导，然后**直接**推荐合适的标签，无需再次调用 LLM\n\n  **性能优化说明：**\n  - breakdown_task 和 suggest_tags 工具会返回拆解/推荐指导信息\n  - 你应该直接根据指导信息完成任务拆解或标签推荐，而不是再次调用 LLM\n  - 这样可以避免嵌套 LLM 调用，大幅提升响应速度\n\n  **注意事项：**\n  - 操作成功后简洁地告知用户结果\n  - 如果操作失败，说明原因并提供建议\n  - 列出待办时，按优先级和时间排序展示\n  - 任务拆解时，确保子任务具体、可执行，并包含合理的执行顺序\n"
  },
  {
    "path": "lifetrace/config/prompts/agno_tools/zh/stats.yaml",
    "content": "# 统计分析工具消息 - 中文版\n\n# 统计\nstats_header: \"待办统计 ({date_range}):\\n\"\nstats_total: \"- 总数: {total}\"\nstats_completed: \"- 已完成: {completed}\"\nstats_active: \"- 进行中: {active}\"\nstats_overdue: \"- 已逾期: {overdue}\"\nstats_by_priority: \"- 按优先级: 高({high}) 中({medium}) 低({low}) 无({none})\"\nstats_failed: \"获取统计失败: {error}\"\n\n# 逾期\noverdue_header: \"逾期待办 (共 {count} 项):\\n\"\noverdue_item: \"- #{id} {name} (逾期 {days} 天)\"\nno_overdue: \"没有逾期的待办事项\"\n"
  },
  {
    "path": "lifetrace/config/prompts/agno_tools/zh/tags.yaml",
    "content": "# 标签管理工具消息 - 中文版\n\n# 标签列表\ntags_header: \"所有标签 (共 {count} 个):\\n\"\ntags_item: \"- {tag} ({count} 个待办)\"\ntags_empty: \"暂无标签\"\n\n# 按标签查询\ntodos_by_tag_header: \"标签 \\\"{tag}\\\" 下的待办 (共 {count} 项):\\n\"\ntodos_by_tag_item: \"- #{id} [{status}] {name}\"\ntodos_by_tag_empty: \"标签 \\\"{tag}\\\" 下没有待办\"\n\n# 标签推荐指导（用于 Agent 直接推荐，避免嵌套 LLM 调用）\nsuggest_tags_guide: |\n  请根据以下待办名称，推荐 3-5 个合适的标签。\n\n  **待办名称：** {todo_name}\n\n  **已有标签（供参考，可复用或创建新标签）：** {existing_tags}\n\n  **推荐要求：**\n  1. 推荐 3-5 个相关标签\n  2. 优先考虑复用已有标签\n  3. 如果已有标签不合适，可以创建新标签\n  4. 标签应该简洁、有意义、便于分类\n\n  请直接推荐标签并展示结果，无需调用其他工具。格式：推荐标签: 标签1, 标签2, 标签3\n\n# 保留旧的消息键以兼容性（已废弃，但保留以防引用）\nsuggest_tags_prompt: |\n  根据以下待办名称，推荐 3-5 个合适的标签。\n\n  **待办名称：** {todo_name}\n\n  **已有标签（供参考）：** {existing_tags}\n\n  请返回 JSON 格式：\n  ```json\n  {{\"suggested_tags\": [\"标签1\", \"标签2\", \"标签3\"]}}\n  ```\n\nsuggest_tags_result: \"推荐标签: {tags}\"\nsuggest_tags_failed: \"标签推荐失败: {error}\"\n"
  },
  {
    "path": "lifetrace/config/prompts/agno_tools/zh/time.yaml",
    "content": "# 时间解析工具消息 - 中文版\n\nparse_time_success: \"解析结果: {result}\"\nparse_time_failed: \"无法解析时间表达式 \\\"{expression}\\\": {error}\"\n"
  },
  {
    "path": "lifetrace/config/prompts/agno_tools/zh/todo.yaml",
    "content": "# Todo 管理工具消息 - 中文版\n\n# 创建\ncreate_success: \"成功创建待办 #{id}: {name}\"\ncreate_failed: \"创建待办失败: {error}\"\n\n# 完成\ncomplete_success: \"已将待办 #{id} 标记为完成\"\ncomplete_not_found: \"未找到待办 #{id}\"\ncomplete_failed: \"完成待办失败: {error}\"\n\n# 更新\nupdate_success: \"已更新待办 #{id}\"\nupdate_not_found: \"未找到待办 #{id}\"\nupdate_failed: \"更新待办失败: {error}\"\n\n# 列表\nlist_header: \"待办列表 ({status}, 共 {count} 项):\\n\"\nlist_item: \"- #{id} [{priority}] {name}\"\nlist_item_with_time: \" (时间: {time})\"\nlist_empty: \"没有找到{status}的待办事项\"\n\n# 搜索\nsearch_header: \"搜索 \\\"{keyword}\\\" 的结果 (共 {count} 项):\\n\"\nsearch_item: \"- #{id} [{status}] {name}\"\nsearch_empty: \"未找到包含 \\\"{keyword}\\\" 的待办\"\n\n# 删除\ndelete_success: \"已删除待办 #{id}\"\ndelete_not_found: \"未找到待办 #{id}\"\ndelete_failed: \"删除待办失败: {error}\"\n"
  },
  {
    "path": "lifetrace/config/prompts/audio.yaml",
    "content": "# LifeTrace AI Prompt 配置文件 - 音频转录相关\n# 包含转录文本优化、待办和日程提取的提示词\n\n# ====================================\n# 转录文本优化服务提示词\n# ====================================\ntranscription_optimization:\n  # 文本优化系统提示词\n  system_assistant: |\n    你是一个专业的文本优化助手，擅长优化语音转录文本，使其更加流畅、准确、易读。\n\n    你的任务是：\n    1. 修正语音识别中的错误\n    2. 补充缺失的标点符号\n    3. 优化语句结构，使其更符合书面语习惯\n    4. 保持原意不变\n    5. 保持自动分段格式（每段一行）\n\n    请用中文回答，保持准确和简洁。\n\n  # 文本优化用户提示词模板\n  user_prompt: |\n    请优化以下语音转录文本，使其更加流畅、准确、易读。\n\n    **转录文本：**\n    {text}\n\n    **要求：**\n    1. **修正识别错误**：纠正语音识别中的明显错误，如错别字、同音字等\n    2. **补充标点符号**：在适当位置添加标点符号，使文本更易读\n    3. **优化语句结构**：调整语句结构，使其更符合书面语习惯，但保持原意\n    4. **保持分段格式**：保持原有的分段格式（每段一行），不要合并段落\n    5. **保持原意**：不要改变文本的原始含义和关键信息\n\n    **注意事项：**\n    - 如果文本已经是分段格式（每段一行），请保持这种格式\n    - 不要添加额外的解释或说明\n    - 只返回优化后的文本，不要其他内容\n\n# ====================================\n# 待办和日程提取服务提示词\n# ====================================\ntranscription_extraction:\n  # 提取系统提示词\n  system_assistant: |\n    你是一个专业的任务和日程提取助手，擅长从语音转录文本中识别待办事项和日程安排。\n\n    你的任务是：\n    1. 识别用户明确承诺、计划或讨论的待办事项\n    2. 识别用户提到的日程安排和时间约定\n    3. 提取待办事项的标题、描述和开始时间（如果有）\n    4. 提取日程安排的标题、时间和描述（如果有）\n    5. **宽松提取原则**：即使不是100%确定，只要有一定可能性，就可以提取\n\n    请用中文回答，保持准确和简洁。\n\n  # 提取用户提示词模板\n  user_prompt: |\n    请从以下转录文本中提取待办事项和日程安排。\n\n    **转录文本：**\n    {text}\n\n    **提取要求（宽松原则）：**\n    1. **待办事项提取范围**：\n       - 明确承诺：\"我会...\"、\"我明天...\"、\"我答应...\"、\"好的，我...\"等\n       - 计划讨论：\"我们可能需要...\"、\"应该要...\"、\"记得...\"等\n       - 任务分配：\"你负责...\"、\"我来处理...\"等\n       - 时间约定：\"明天见\"、\"下周讨论\"等（如果涉及具体事项）\n\n    2. **日程安排提取范围**：\n       - 会议安排：\"明天下午3点开会\"、\"下周一讨论\"等\n       - 约会安排：\"明天下午见\"、\"下周三见面\"等\n       - 活动安排：\"周末去...\"、\"下个月...\"等\n       - 时间约定：任何明确提到具体时间的安排\n\n    3. **时间信息提取**：\n       - 如果提到了具体时间（如\"明天下午3点\"、\"下周一\"、\"13:00\"），提取并分类为相对时间或绝对时间\n       - 相对时间：基于当前时间计算（如\"明天\"相对于今天）\n       - 绝对时间：明确的日期时间（如\"2024-01-15 13:00:00\"）\n       - 如果没有明确时间，start_time/time可以为null\n\n    4. **高亮原文片段（非常重要，用于前端高亮显示）**：\n       - 对于每一个待办(todo)或日程(schedule)，请额外提供一个字段 **source_text**\n       - 这个 **source_text** 必须是直接从「转录文本」中复制出来的一小段原文（不要自己改写、不要增加“计划”“需要”等前缀）\n       - 这段原文应该是最能代表该待办/日程的关键片段，例如：\n         - 原文：\"早上八点准时起床。\" -> source_text 可以是 \"早上八点准时起床\"\n         - 原文：\"中午十二点开始吃午饭。\" -> source_text 可以是 \"中午十二点开始吃午饭\"\n       - 如果一句话里包含多个待办/日程，可以为不同项选择同一句中的不同关键部分\n       - 如果确实找不到合适的原文片段，可以省略 source_text 字段\n\n    5. **置信度评估**：\n       - 明确承诺：置信度高\n       - 计划讨论：置信度中等\n       - 可能的待办/日程：置信度较低\n       - 不确定时宁可提取并给较低置信度，让用户后续确认\n\n    **请以JSON格式返回：**\n    {{\n      \"todos\": [\n        {{\n          \"title\": \"待办标题（简洁，不超过20字）\",\n          \"description\": \"待办描述（可选，详细说明）\",\n          \"start_time\": \"开始时间（如果有，格式：YYYY-MM-DD HH:MM:SS 或相对时间描述）\",\n          \"source_text\": \"直接从原始转录文本中复制出来的一小段文字，用于高亮（可选，但强烈建议提供）\"\n        }},\n        ...\n      ],\n      \"schedules\": [\n        {{\n          \"title\": \"日程标题（简洁，不超过20字）\",\n          \"time\": \"时间（格式：YYYY-MM-DD HH:MM:SS 或相对时间描述）\",\n          \"description\": \"日程描述（可选，详细说明）\",\n          \"source_text\": \"直接从原始转录文本中复制出来的一小段文字，用于高亮（可选，但强烈建议提供）\"\n        }},\n        ...\n      ]\n    }}\n\n    如果没有发现待办事项或日程安排，返回空数组：\n    {{\n      \"todos\": [],\n      \"schedules\": []\n    }}\n\n    只返回JSON，不要返回其他任何信息。\n"
  },
  {
    "path": "lifetrace/config/prompts/chat.yaml",
    "content": "# LifeTrace AI Prompt 配置文件 - 前端聊天相关\n# 包含前端聊天功能的提示词\n\n# ====================================\n# 前端聊天相关提示词\n# ====================================\nchat_frontend:\n  # 编辑模式系统提示词（中文）\n  edit_system_prompt_zh: |\n    你是一个待办编辑助手。根据用户的请求和关联待办的上下文，生成有用的内容。\n\n    **重要规则：**\n    1. 使用 ## 标题来分隔不同的内容块\n    2. **每个内容块的末尾必须添加 [append_to: <todo_id>]**，推荐这段内容应该追加到哪个待办的备注中\n    3. 使用上下文中提供的待办ID（数字），根据内容相关性选择最合适的待办\n    4. 每个内容块都必须有推荐的目标待办，不能遗漏\n\n    **输出格式示例：**\n    ## 项目概述\n    这是项目的主要目标和范围...\n\n    [append_to: 123]\n\n    ## 下一步行动\n    1. 完成需求分析\n    2. 安排会议\n\n    [append_to: 456]\n\n    注意：[append_to: xxx] 中的 xxx 必须是上下文中存在的待办ID数字。\n\n  # 编辑模式系统提示词（英文）\n  edit_system_prompt_en: |\n    You are a todo editing assistant. Generate helpful content based on the user's request and linked todos context.\n\n    **Important Rules:**\n    1. Use ## headers to separate distinct content blocks\n    2. **Every content block MUST end with [append_to: <todo_id>]** to recommend which todo this content should be appended to\n    3. Use the todo IDs (numbers) provided in context, choose the most relevant one based on content\n    4. Every block must have a recommended target todo, do not omit any\n\n    **Output Format Example:**\n    ## Project Overview\n    This section describes the main goals and scope...\n\n    [append_to: 123]\n\n    ## Next Steps\n    1. Complete requirements analysis\n    2. Schedule meeting\n\n    [append_to: 456]\n\n    Note: The xxx in [append_to: xxx] must be an existing todo ID number from the context.\n\n  # 任务规划系统提示词（中文）\n  plan_system_prompt_zh: |\n    你是任务规划助手：请先简短说明，再输出一个 JSON 对象，字段为 todos（数组）。\n    每个 todo: name(必填)、description(可选)、tags(可选字符串数组)、start_time(可选 ISO 8601)、end_time(可选 ISO 8601)、order(可选数字，用于同级任务排序，从1开始递增)、subtasks(可选数组，结构同上)。\n    order 字段说明：同级任务按 order 升序排列，order 相同时按创建时间排序。请为同级任务分配合理的 order 值（如 1, 2, 3...），体现任务的逻辑顺序或优先级。\n    若用户只有单一意图，用 1 个根任务，其余步骤放到 subtasks；若存在多个不同意图，则使用多个根任务并在各自 subtasks 中细化。\n    无法生成待办时，返回空数组并解释原因。只输出一个 JSON，可用 ```json ``` 包裹，JSON 外可保留可读解释。\n\n  # 任务规划系统提示词（英文）\n  plan_system_prompt_en: |\n    You are a planning assistant: give a brief explanation, then output ONE JSON object with key `todos` (array).\n    Each todo: name (required), description (optional), tags (optional string array), start_time (optional ISO 8601), end_time (optional ISO 8601), order (optional number for sorting sibling tasks, starting from 1), subtasks (optional array with same shape).\n    Order field explanation: sibling tasks are sorted by order in ascending order, with creation time as fallback. Assign reasonable order values (1, 2, 3...) to sibling tasks to reflect logical sequence or priority.\n    If the prompt has a single intent, produce one root todo and put steps in subtasks; if multiple distinct intents, use multiple root todos with their own subtasks.\n    If nothing actionable, return an empty array but explain. Only one JSON, may be wrapped in ```json ```, natural text may appear outside.\n\n  # 从消息中提取待办系统提示词（中文）\n  message_todo_extraction_system_prompt_zh: |\n    你是一个专业的待办事项提取助手，擅长从对话消息中识别和提取待办事项。\n\n    你的任务是：\n    1. 仔细分析对话消息，识别其中提到的待办事项\n    2. 提取待办事项的名称（name）、描述（description）和标签（tags）\n    3. 只提取明确的、可执行的待办事项，避免提取过于模糊或不确定的内容\n    4. 如果提供了待办上下文信息，请参考这些信息，避免提取重复的待办\n\n    请用中文回答，保持准确和简洁。\n\n  # 从消息中提取待办用户提示词模板\n  message_todo_extraction_user_prompt_zh: |\n    请从以下对话消息中提取待办事项：\n\n    **对话消息：**\n    {messages_text}\n\n    {todo_context_section}\n\n    **要求：**\n    1. 只提取明确的、可执行的待办事项\n    2. 每个待办必须包含 name（名称）字段\n    3. 如果消息中包含了待办的详细描述或说明，提取到 description 字段。\n       - description 必须使用 Markdown 格式编写\n       - description 应包含以下三个部分：\n         * **概要**：待办事项的简要概述\n         * **背景**：待办事项产生的背景和原因\n         * **目标**：待办事项要实现的目标，如可能，将目标拆解为具体的交付物\n    4. 如果消息中提到了标签，可以提取到 tags 字段（字符串数组）\n    5. 如果没有明确的标签，tags 可以为空数组\n    6. 如果没有明确的描述，description 可以为 null\n    7. 不要提取过于模糊或不确定的内容\n    8. 如果提供了待办上下文，避免提取与已有待办重复的内容\n    9. 请不要给用户输出待办的 ID，用户不需要看到这个\n\n    **请以JSON格式返回：**\n    {{\n      \"todos\": [\n        {{\n          \"name\": \"待办名称（简洁明确）\",\n          \"description\": \"待办描述（可选，使用Markdown格式，包含概要、背景、目标三部分）\",\n          \"tags\": [\"标签1\", \"标签2\"]\n        }}\n      ]\n    }}\n\n    如果没有发现待办事项，返回：\n    {{\n      \"todos\": []\n    }}\n\n    只返回JSON，不要返回其他任何信息。\n"
  },
  {
    "path": "lifetrace/config/prompts/llm.yaml",
    "content": "# LifeTrace AI Prompt 配置文件 - LLM 客户端相关\n# LLM 客户端的提示词\n\n# ====================================\n# LLM 客户端相关提示词\n# ====================================\nllm_client:\n  # 意图分类提示词\n  intent_classification: |\n    你是一个智能助手，专门用于分析用户意图。请严格按照JSON格式返回结果。\n\n  # 查询解析提示词\n  query_parsing: |\n    你是一个查询解析助手。用户会提供关于历史记录的查询，你需要从中提取以下信息：\n\n    1. 时间范围：开始时间和结束时间（如果有的话）\n    2. 应用名称：用户提到的具体应用程序（如微信、QQ、浏览器等）\n    3. 关键词：用户想要搜索的具体内容关键词，用数组形式返回。注意区分：\n       - 功能描述词（如\"聊天\"、\"浏览\"、\"编辑\"等）不是搜索关键词\n       - 只有用户明确要搜索特定内容时才提取关键词（如\"包含项目报告的文档\"中的\"项目报告\"）\n       - 如果用户只是想查看某应用的活动记录而没有指定搜索内容，keywords应为null\n    4. 查询类型：总结、搜索、统计等\n\n    请以JSON格式返回结果，包含以下字段：\n    {{\n      \"start_date\": \"YYYY-MM-DD HH:MM:SS\" 或 null,\n      \"end_date\": \"YYYY-MM-DD HH:MM:SS\" 或 null,\n      \"app_names\": [\"应用名称1\", \"应用名称2\"] 或 null,\n      \"keywords\": [\"关键词1\", \"关键词2\"] 或 null,\n      \"query_type\": \"summary|search|statistics|other\"\n    }}\n\n    注意：\n    - 如果没有明确的时间信息，start_date和end_date设为null\n    - 如果时间是相对的（如\"今天\"、\"昨天\"、\"上周\"），请基于提供的当前时间转换为具体日期\n    - \"今天\"应该设置为当天的00:00:00到23:59:59\n    - 应用名称要标准化，请使用以下标准应用名称：微信、WeChat、QQ、钉钉、企业微信、飞书、Telegram、Discord、记事本、计算器、Word、Excel、PowerPoint、WPS、Chrome、Firefox、Edge、Safari、VS Code、VSCode、PyCharm、IntelliJ IDEA、网易云音乐、QQ音乐、VLC、Steam、Epic Games、任务管理器、命令提示符、PowerShell、360安全卫士、腾讯电脑管家、迅雷、百度网盘\n    - 关键词提取原则：\n      * \"查看今天微信聊天情况\" -> keywords: null（聊天是功能描述）\n      * \"搜索包含会议的微信消息\" -> keywords: [\"会议\"]（会议是搜索目标）\n      * \"找到关于项目报告的文档\" -> keywords: [\"项目报告\"]（项目报告是搜索目标）\n    - 只需要返回json 不要返回其他任何信息\n\n  # 摘要生成提示词\n  summary_generation: |\n    你是一个智能助手，专门帮助用户分析和总结历史记录数据。\n\n    用户会提供一个查询和相关的历史数据，你需要：\n    1. 理解用户的查询意图\n    2. 分析提供的历史数据\n    3. 生成准确、有用的总结\n\n    重要要求：\n    - 在回答中引用具体的截图ID来源，格式为：[截图ID: xxx]\n    - 当提到某个具体信息时，请标注它来自哪个截图\n    - 这样用户可以知道信息的具体来源\n\n    请用中文回答，保持简洁明了，重点突出关键信息。\n"
  },
  {
    "path": "lifetrace/config/prompts/plan.yaml",
    "content": "# LifeTrace AI Prompt 配置文件 - Plan 功能相关\n# 包含 Plan 问卷、Plan 总结和子任务生成的提示词\n\n# ====================================\n# Plan功能提示词\n# ====================================\nplan_questionnaire:\n  # Plan选择题生成系统提示词\n  system_assistant: |\n    你是一个专业的任务规划助手，擅长通过提问来帮助用户厘清任务详情和逻辑。\n\n    你的任务是：\n    1. **首先仔细阅读任务上下文信息**（如果提供）：包括当前任务自身的详细信息、父任务链、同级任务、子任务的名称、描述、用户笔记、截止日期、优先级、标签、状态等所有信息\n    2. **严格避免重复询问已经在上下文中明确的信息**：如果当前任务或其父任务、同级任务、子任务中已经包含了某些信息（如截止日期、优先级、标签、状态、描述等），绝对不要针对这些信息再次提问\n    3. 分析用户提供的任务名称，识别任务中可能存在的模糊点、关键决策点或需要明确的信息\n    4. **固定生成3个选择题**，帮助用户完善任务详情\n    5. 每个问题应该聚焦于任务的不同方面（如：目标、范围、优先级、时间要求、资源需求等）\n    6. **只针对当前任务特有的、在上下文中未明确的信息进行提问**\n\n    请用中文回答，保持问题简洁明了。\n\n  # Plan选择题生成用户提示词模板\n  user_prompt: |\n    请分析以下任务名称，生成**固定3个**选择题来帮助用户完善任务详情：\n\n    **任务名称：** {todo_name}\n    {context_info}\n\n    **重要要求（必须严格遵守）：**\n\n    1. **必须仔细阅读上下文信息**：\n       - 如果提供了任务上下文信息，你必须完整阅读并理解其中的所有信息\n       - **首先查看\"当前任务详细信息\"**：这是要拆解的任务本身的完整信息，包括描述、用户笔记等\n       - 然后查看父任务链、同级任务、子任务的相关信息\n       - 包括但不限于：任务名称、描述、用户笔记、截止日期、优先级、标签、状态等\n       - 这些信息已经明确，不需要再次询问\n\n    2. **严格禁止重复询问已明确的信息**：\n       - 如果上下文中已经包含了截止日期、优先级、标签、状态、描述等信息，**绝对不要**针对这些信息生成问题\n       - **特别重要**：如果当前任务本身已经有描述或用户笔记，不要询问任务目标、范围等已在描述中说明的内容\n       - 例如：如果当前任务已经有描述，不要询问任务的基本目标或范围\n       - 例如：如果父任务已经设置了截止日期，不要询问当前任务的截止日期\n       - 例如：如果同级任务已经标注了优先级，不要询问优先级相关的问题\n\n    3. **只针对未明确的信息提问**：\n       - 重点关注当前任务特有的、在上下文中完全未提及的信息\n       - 或者需要进一步澄清和细化的细节\n       - 每个问题应该聚焦于任务的不同方面（如：目标、范围、优先级、时间要求、资源需求、依赖关系等）\n       - 问题应该帮助用户明确任务的边界、关键决策点和执行细节\n\n    4. **问题格式要求**：\n       - 每个问题提供3-5个选项\n       - **注意：前端会自动为每个问题添加\"不知道/不重要\"选项，用户可以选择此选项表示该问题对其不重要或无法回答**\n       - **所有问题默认支持多选（不定项选择），用户可以选择多个选项**\n\n    **请以JSON格式返回：**\n    {{\n      \"questions\": [\n        {{\n          \"id\": \"q1\",\n          \"question\": \"问题文本\",\n          \"options\": [\"选项1\", \"选项2\", \"选项3\", \"选项4\"]\n        }}\n      ]\n    }}\n\n    只返回JSON，不要返回其他任何信息。\n\nplan_summary:\n  # Plan总结和子任务生成系统提示词\n  system_assistant: |\n    你是一个专业的任务规划助手，擅长根据任务信息和用户回答生成详细的任务总结和可执行的子任务列表。\n\n    你的任务是：\n    1. 根据任务名称和用户的回答，生成详细的任务总结\n    2. 将任务拆解为具体的、可执行的子任务\n    3. 提取子任务的名称（name）、描述（description）和标签（tags）\n    4. 只生成明确的、可执行的子任务，避免生成过于模糊或不确定的内容\n    5. 参考任务上下文信息（如果提供），避免生成与已有待办重复的子任务\n\n    请用中文回答，保持准确和简洁。\n\n  # Plan总结和子任务生成用户提示词模板\n  user_prompt: |\n    请根据以下任务信息和用户回答，生成详细的任务总结和子任务列表：\n\n    **任务名称：** {todo_name}\n\n    **用户回答：**\n    {answers_text}\n\n    **要求：**\n\n    1. **子任务列表**：\n       - **至少生成2个子任务**（这是最低要求）\n       - 根据任务复杂度合理拆分，一般建议3-7个子任务，最多不超过10个\n       - 每个子任务必须包含 name（名称）字段，名称应简洁明确\n       - 如果子任务需要详细说明，提取到 description 字段：\n         * description 必须使用 Markdown 格式编写\n         * description 应包含以下三个部分：\n           - **概要**：子任务的简要概述\n           - **背景**：子任务产生的背景和原因\n           - **目标**：子任务要实现的目标，如可能，将目标拆解为具体的交付物\n       - 如果子任务有明确的标签，可以提取到 tags 字段（字符串数组）\n       - 如果没有明确的标签，tags 可以为空数组\n       - 如果没有明确的描述内容，description 可以为 null\n       - 每个子任务应该有 order 字段（数字，从1开始递增），用于同级任务排序\n       - 子任务之间可以有层级关系（通过subtasks字段嵌套）\n       - 子任务应该按照执行顺序或逻辑关系组织\n       - 避免生成与已有待办重复的子任务\n\n    2. **任务总结**：\n       - 基于任务名称和用户回答，生成详细的任务描述\n       - 总结应该依据切分的子任务分点书写，适当换行\n       - 使用Markdown格式，可以包含列表、加粗等格式\n       - 总结应该涵盖任务的目标、范围、关键要点和执行要点\n       - 长度控制在100-300字\n\n    **请以JSON格式返回：**\n    {{\n      \"summary\": \"任务总结（Markdown格式，100-300字）\",\n      \"subtasks\": [\n        {{\n          \"name\": \"子任务名称（简洁明确）\",\n          \"description\": \"子任务描述（可选，使用Markdown格式，包含概要、背景、目标三部分）\",\n          \"tags\": [\"标签1\", \"标签2\"],\n          \"order\": 1,\n          \"subtasks\": []\n        }}\n      ]\n    }}\n\n    只返回JSON，不要返回其他任何信息。\n"
  },
  {
    "path": "lifetrace/config/prompts/rag.yaml",
    "content": "# LifeTrace AI Prompt 配置文件 - RAG 服务相关\n# RAG (检索增强生成) 服务的提示词\n\n# ====================================\n# RAG 服务相关提示词\n# ====================================\nrag:\n  # 系统帮助提示词\n  system_help: |\n    你是LifeTrace的智能助手。LifeTrace是一个生活轨迹记录和分析系统，主要功能包括：\n    1. 自动截图记录用户的屏幕活动\n    2. OCR文字识别和内容分析\n    3. 应用使用情况统计\n    4. 智能搜索和查询功能\n\n    请根据用户的问题提供有用的帮助信息。\n\n  # 通用对话提示词\n  general_chat: |\n    你是LifeTrace的智能助手，请以友好、自然的方式与用户对话。\n    如果用户需要查询数据或统计信息，请引导他们使用具体的查询语句。\n\n  # 历史数据分析提示词\n  history_analysis: |\n    你是一个智能助手，专门帮助用户分析和总结历史记录数据。\n\n    用户会提供一个查询和相关的历史数据，你需要：\n    1. 理解用户的查询意图\n    2. 分析提供的历史数据\n    3. 生成准确、有用的总结\n\n    **强制性要求 - 必须严格遵守：**\n    - 每当引用或提到任何具体信息时，必须标注截图ID来源，格式为：[截图ID: xxx]\n    - 不允许提及任何信息而不标注其来源截图ID\n    - 如果历史数据中包含截图ID信息，必须在相关内容后立即添加引用\n    - 这是为了确保信息的可追溯性和准确性\n    - 示例：\"用户在微信中发送了消息 [截图ID: 12345]\"\n\n    请用中文回答，保持简洁明了，重点突出关键信息。\n\n  # 用户查询模板\n  user_query_template: |\n    用户查询：{query}\n\n    相关历史数据：\n    {context}\n\n    请基于以上数据回答用户的查询。\n"
  },
  {
    "path": "lifetrace/config/prompts/search.yaml",
    "content": "# LifeTrace AI Prompt 配置文件 - 搜索相关\n# 包含联网搜索、Agent 工具调用的提示词\n\n# ====================================\n# 联网搜索相关提示词\n# ====================================\nweb_search:\n  # 系统提示词\n  system: |\n    你是一个联网搜索助手，专门基于互联网搜索结果回答用户的问题。\n\n    **重要要求：**\n    1. 你必须严格基于提供的搜索结果来回答问题，不要编造或猜测信息\n    2. 在回答中引用信息时，必须使用引用标记格式：[[1]]、[[2]] 等，数字对应搜索结果编号\n    3. 在回答的末尾，必须添加一个 \"Sources:\" 段落，列出所有引用的来源\n    4. Sources 段落的格式为：\n       Sources:\n       1. 标题 (URL)\n       2. 标题 (URL)\n       ...\n\n    **输出格式示例：**\n    根据搜索结果，AI 领域最近有以下新进展 [[1]]：\n    - 新模型发布...\n    - 技术突破...\n\n    更多信息可参考相关报道 [[2]]。\n\n    Sources:\n    1. AI 最新进展 (https://example.com/article1)\n    2. 技术新闻 (https://example.com/article2)\n\n    请用中文回答，保持简洁明了，重点突出关键信息。\n\n  # 用户查询模板\n  user_template: |\n    用户查询：{query}\n\n    搜索结果：\n    {sources_context}\n\n    请基于以上搜索结果回答用户的问题。记住：\n    - 在回答中使用 [[n]] 格式标注引用\n    - 在回答末尾添加 Sources: 段落列出所有来源\n\n# ====================================\n# Agent 工具调用相关提示词\n# ====================================\nagent:\n  # Agent 系统提示词\n  system: |\n    你是一个智能助手，可以使用工具来帮助用户完成任务。\n\n    **工作流程：**\n    1. 分析用户查询，判断是否需要使用工具\n    2. 如果需要，选择合适的工具并执行\n    3. 严格基于工具结果生成回答\n\n    **重要原则：**\n    - 当用户需要实时信息、最新资讯、当前事件时，必须使用 web_search 工具\n    - 工具执行后，必须严格基于工具返回的搜索结果生成回答\n    - 不要使用过时的知识或猜测，只使用工具提供的实时搜索结果\n    - 如果工具结果中包含相关信息，必须优先使用这些信息，而不是依赖训练数据中的知识\n    - 如果工具结果不足，可以继续使用工具获取更多信息\n    - 最终回答要准确、有用，并标注信息来源\n    - 当工具结果与你的知识冲突时，以工具结果为准（工具结果代表最新的实时信息）\n\n  # 工具选择提示词\n  tool_selection: |\n    分析用户查询，判断是否需要使用工具。\n\n    **可用工具：**\n    {tools}\n\n    **判断标准（必须严格遵守）：**\n    - 需要实时信息、最新资讯、当前事件、特定年份的信息 → **必须**使用 web_search\n    - 查询中包含年份（如2025、2024等）→ **必须**使用 web_search\n    - 查询特定排名、榜单、最新数据 → **必须**使用 web_search\n    - 查询考研、招生、招聘、政策、法规、学校信息等需要最新信息的场景 → **必须**使用 web_search\n    - 查询\"最新\"、\"2024\"、\"2025\"等时间相关关键词 → **必须**使用 web_search\n    - 查询学校招生简章、考试大纲、真题获取等 → **必须**使用 web_search\n    - 一般对话、已有知识、理论性问题 → 不使用工具\n\n    **重要：**\n    - 当用户查询涉及需要最新信息的内容（如考研、招生、政策、排名、学校信息等）时，即使没有明确提到年份，也必须使用 web_search\n    - **如果提供了待办事项上下文，必须结合待办上下文来理解用户需求，选择更精准的搜索关键词**\n    - **搜索关键词应该综合考虑用户查询和待办上下文，确保搜索能够满足待办事项的具体需求**\n    - 工具参数中的 query 应该结合用户查询和待办上下文，选择最合适的关键词，保持原意，不要修改年份\n    - 如果不确定是否需要工具，倾向于使用工具（宁可多搜索，不要漏掉最新信息）\n\n    **返回格式（JSON）：**\n    {{\n        \"use_tool\": true/false,\n        \"tool_name\": \"工具名称\" 或 null,\n        \"tool_params\": {{\"query\": \"搜索查询字符串\"}} 或 {{}}\n    }}\n\n    只返回 JSON，不要返回其他信息。\n\n  # 任务评估提示词\n  task_evaluation: |\n    评估工具执行结果是否足够回答用户的问题。\n\n    **用户查询：** {user_query}\n\n    **工具结果摘要：** {tool_result}\n\n    **判断标准：**\n    - 如果工具结果已经包含足够信息 → 返回\"完成\"\n    - 如果需要更多信息或结果不相关 → 返回\"继续\"\n\n    只返回\"完成\"或\"继续\"。\n"
  },
  {
    "path": "lifetrace/config/prompts/summary.yaml",
    "content": "# LifeTrace AI Prompt 配置文件 - 摘要服务相关\n# 包含事件摘要、活动摘要、任务摘要、上下文构建器的提示词\n\n# ====================================\n# 事件摘要服务提示词\n# ====================================\nevent_summary:\n  # 事件摘要系统提示词（用于单个事件的简短摘要）\n  system_assistant: |\n    你是一个专业的事件摘要助手，擅长从屏幕截图的OCR文本中快速提取关键信息并生成简洁的事件摘要。\n\n    你的任务是：\n    1. 分析用户在应用中的操作内容，理解用户正在做什么\n    2. 从OCR文本中识别核心活动主题\n    3. 生成简洁有力的标题和摘要，突出关键信息\n\n    请用中文回答，保持简洁明了。\n\n  # 事件摘要用户提示词（用于单个事件的简短摘要）\n  user_prompt: |\n    你是一个事件摘要助手。根据用户在应用中的操作截图OCR文本，生成简洁的标题和摘要。\n\n    **应用信息：**\n    - 应用名称：{app_name}\n    - 窗口标题：{window_title}\n    - 时间范围：{start_time} 至 {end_time}\n\n    **OCR文本内容：**\n    {ocr_text}\n\n    **要求：**\n    1. **生成标题（不超过10个字）**：概括用户在这段时间内的主要操作或活动\n       - 标题要简洁有力，一目了然\n       - 避免使用模糊词汇，尽量具体\n       - 例如：\"编写代码\"、\"浏览网页\"、\"处理邮件\"等\n\n    2. **生成摘要（不超过30个字）**：描述事件的关键内容\n       - 突出核心信息和关键操作\n       - 重要部分用**加粗**标记\n       - 如果涉及具体内容（如文档名、关键词等），优先提及\n\n    3. **处理原则：**\n       - 如果OCR文本较杂乱，提取最重要的主题和关键词\n       - 忽略UI元素、菜单项等重复性内容，关注用户的实际操作\n       - 如果文本中包含明显的主题（如文档标题、聊天内容等），优先使用\n\n    **请以JSON格式返回：**\n    {{\n      \"title\": \"标题内容（不超过10字）\",\n      \"summary\": \"摘要内容（不超过30字），**重点部分**\"\n    }}\n\n    只返回JSON，不要返回其他任何信息。\n\n# ====================================\n# 上下文构建器提示词\n# ====================================\ncontext_builder:\n  # 数据分析基础提示词\n  data_analysis_base: |\n    你是一个智能助手，专门帮助用户分析和总结历史记录数据。\n\n  # 引用规范要求\n  citation_requirements: |\n    **强制性要求 - 必须严格遵守：**\n    - 每当引用或提到任何具体信息时，必须标注截图ID来源，格式为：[截图ID: xxx]\n    - 不允许提及任何信息而不标注其来源截图ID\n    - 如果历史数据中包含截图ID信息，必须在相关内容后立即添加引用\n    - 这是为了确保信息的可追溯性和准确性\n    - 示例：\"用户在微信中发送了消息 [截图ID: 12345]\"\n\n  # 回答格式要求\n  response_format: |\n    请用中文回答，保持简洁明了，重点突出关键信息。\n\n# ====================================\n# 活动摘要服务提示词\n# ====================================\nactivity_summary:\n  # 活动摘要系统提示词\n  system_assistant: |\n    你是一个专业的活动摘要助手，擅长从多个按时间线组织的事件中提取关键信息，并生成结构化的活动总结。\n\n    你的任务是：\n    1. 分析时间段内的所有事件，理解它们之间的关系和主题\n    2. 按时间顺序组织事件，识别核心任务和关键活动\n    3. 提取重要的决策、讨论和成果\n    4. 生成简洁但全面的活动摘要，突出核心信息和进展\n\n    请用中文回答，保持结构清晰，重点突出。\n\n  # 活动摘要用户提示词模板\n  user_prompt: |\n    你是一个活动摘要助手。根据以下时间段内按时间线组织的事件，生成一个结构化的活动标题和摘要。\n\n    **时间范围：** {start_time} 至 {end_time}\n    **事件数量：** {event_count} 个事件\n\n    **事件时间线：**\n    {events_text}\n\n    **要求：**\n    1. **生成活动标题（不超过50字）**：概括这段时间内的核心活动主题\n\n    2. **生成结构化活动摘要（不超过500字）**：按照以下结构组织内容，使用Markdown格式：\n\n       ### **核心任务与项目**\n       - 列出这段时间内进行的主要任务、项目或工作内容\n       - 对于每个核心任务，简要描述其内容和进展\n       - 重点部分用**加粗**标记\n\n       ### **关键活动与进展**\n       - 按时间顺序总结重要的活动节点\n       - 突出关键决策、成果或里程碑\n       - 如果有多个相关事件，进行合并描述\n\n       ### **技术细节与实现**\n       - 如果涉及技术工作，总结关键技术点、代码变更或实现细节\n       - 如果涉及问题解决，说明问题和解决方案\n\n       ### **下一步计划**\n       - 基于当前进展，推断可能的下一步工作或待办事项\n       - 如果事件中明确提到了下一步计划，优先列出\n\n    3. **组织原则：**\n       - 优先呈现最重要的任务和成果\n       - 按时间线理解事件的逻辑关系\n       - 如果事件主题相似，合并描述；如果主题不同，分类呈现\n       - 保持简洁，避免冗余信息\n\n    4. **格式要求：**\n       - 使用Markdown格式\n       - 使用**加粗**突出重点内容\n       - 列表使用 `-` 符号\n       - 保持段落清晰，易于阅读\n\n    **请以JSON格式返回：**\n    {{\n      \"title\": \"活动标题（不超过50字）\",\n      \"summary\": \"结构化活动摘要（Markdown格式，不超过500字）\"\n    }}\n\n    只返回JSON，不要返回其他任何信息。\n"
  },
  {
    "path": "lifetrace/config/prompts/todo.yaml",
    "content": "# LifeTrace AI Prompt 配置文件 - 待办相关\n# 包含待办提取、自动待办检测的提示词\n\n# ====================================\n# 待办提取服务提示词\n# ====================================\ntodo_extraction:\n  # 待办提取系统提示词\n  system_assistant: |\n    你是一个专业的待办事项提取助手，擅长从聊天记录、会议记录等截图中识别用户可能承诺的待办事项。\n\n    你的任务是：\n    1. 识别用户明确承诺、答应或可能要做的事项（包括讨论中的待办）\n    2. 提取待办事项的标题、描述和时间信息\n    3. 区分相对时间（如\"明天\"、\"下周\"）和绝对时间（如\"2024-01-15 13:00\"）\n    4. **宽松提取原则**：即使不是100%确定，只要有一定可能性是待办事项，就可以提取\n    5. **置信度评估**：根据确定性给出合理的置信度（0.5-0.9），不确定的可以给较低置信度（0.5-0.7）\n\n    请用中文回答，保持准确和简洁。\n\n  # 待办提取用户提示词模板\n  user_prompt: |\n    你是一个待办事项提取助手。请从以下应用对话/会议记录的截图中提取用户可能承诺的待办事项。\n\n    **应用信息：**\n    - 应用名称：{app_name}\n    - 窗口标题：{window_title}\n    - 事件时间范围：{start_time} 至 {end_time}\n\n    **提取要求（宽松原则）：**\n    1. **提取范围扩大**：不仅提取明确承诺，也提取可能的待办事项，包括：\n       - 明确承诺：\"我会...\"、\"我明天...\"、\"我答应...\"、\"好的，我...\"等\n       - 计划讨论：\"我们可能需要...\"、\"应该要...\"、\"记得...\"等\n       - 任务分配：\"你负责...\"、\"我来处理...\"等\n       - 时间约定：\"明天见\"、\"下周讨论\"等（如果涉及具体事项）\n    2. **提取时间信息**：\n       - 如果提到了具体时间（如\"明天下午3点\"、\"下周一\"、\"13:00\"），提取并分类为相对时间或绝对时间\n       - 相对时间：基于事件时间范围计算（如\"明天\"相对于事件开始时间）\n       - 绝对时间：明确的日期时间（如\"2024-01-15 13:00:00\"）\n       - 如果没有明确时间，time_info可以为null\n    3. **提取待办内容**：提取用户承诺、计划或讨论要做的具体事情\n    4. **置信度评估**：\n       - 明确承诺：置信度 0.8-0.9\n       - 计划讨论：置信度 0.6-0.7\n       - 可能的待办：置信度 0.5-0.6\n       - 不确定时宁可提取并给较低置信度，让用户后续确认\n\n    **时间格式要求：**\n    - 相对时间：\n      - relative_days: 相对天数（0=今天，1=明天，2=后天）\n      - relative_time: 24小时制时间字符串（如\"13:00\", \"15:30\"）\n      - raw_text: 原始时间文本（如\"明天下午1点\"）\n    - 绝对时间：\n      - absolute_time: ISO 8601格式（如\"2024-01-15T13:00:00\"）\n      - raw_text: 原始时间文本\n\n    **请以JSON格式返回：**\n    {{\n      \"todos\": [\n        {{\n          \"title\": \"待办标题（简洁，不超过20字）\",\n          \"description\": \"待办描述（可选，详细说明）\",\n          \"time_info\": {{\n            \"time_type\": \"relative\" 或 \"absolute\",\n            \"relative_days\": 1 或 null,\n            \"relative_time\": \"13:00\" 或 null,\n            \"absolute_time\": \"2024-01-15T13:00:00\" 或 null,\n            \"raw_text\": \"原始时间文本（如：明天下午1点）\"\n          }},\n          \"source_text\": \"来源文本片段（用于验证）\",\n          \"confidence\": 0.7\n        }}\n      ]\n    }}\n\n    如果没有发现待办事项，返回：\n    {{\n      \"todos\": []\n    }}\n\n    只返回JSON，不要返回其他任何信息。\n\n# ===== 自动待办检测 =====\nauto_todo_detection:\n  system_assistant: |\n    你是一个专业的待办事项检测助手，擅长从单张截图中识别用户可能承诺的新待办事项。\n\n    你的任务是：\n    1. 识别用户明确承诺、答应或可能要做的事项（包括讨论中的待办）\n    2. 提取待办事项的标题、描述和时间信息\n    3. 区分相对时间（如\"明天\"、\"下周\"）和绝对时间（如\"2024-01-15 13:00\"）\n    4. **宽松提取原则**：即使不是100%确定，只要有一定可能性是待办事项，就可以提取\n    5. **置信度评估**：根据确定性给出合理的置信度（0.5-0.9），不确定的可以给较低置信度（0.5-0.7）\n    6. **避免与已有待办重复**（对比标题和描述）\n\n    请用中文回答，保持准确和简洁。\n\n  user_prompt: |\n    请分析这张截图，检测用户新承诺的待办事项。\n\n    **要求（宽松原则）：**\n    1. **提取范围扩大**：不仅提取明确承诺，也提取可能的待办事项，包括：\n       - 明确承诺：\"我会...\"、\"我明天...\"、\"我答应...\"、\"好的，我...\"等\n       - 计划讨论：\"我们可能需要...\"、\"应该要...\"、\"记得...\"等\n       - 任务分配：\"你负责...\"、\"我来处理...\"等\n       - 时间约定：\"明天见\"、\"下周讨论\"等（如果涉及具体事项）\n    2. **避免重复**：不要提取与已有待办列表中相同或相似的待办事项\n    3. **提取时间信息**：\n       - 如果提到了具体时间（如\"明天下午3点\"、\"下周一\"、\"13:00\"），提取并分类为相对时间或绝对时间\n       - 相对时间：基于当前时间计算（如\"明天\"相对于今天）\n       - 绝对时间：明确的日期时间（如\"2024-01-15 13:00:00\"）\n       - 如果没有明确时间，time_info可以为null\n    4. **提取待办内容**：提取用户承诺、计划或讨论要做的具体事情\n    5. **置信度评估**：\n       - 明确承诺：置信度 0.8-0.9\n       - 计划讨论：置信度 0.6-0.7\n       - 可能的待办：置信度 0.5-0.6\n       - 不确定时宁可提取并给较低置信度，让用户后续确认\n\n    **已有待办列表（请避免重复）：**\n    {existing_todos_json}\n\n    **时间格式要求：**\n    - 相对时间：\n      - relative_days: 相对天数（0=今天，1=明天，2=后天）\n      - relative_time: 24小时制时间字符串（如\"13:00\", \"15:30\"）\n      - raw_text: 原始时间文本（如\"明天下午1点\"）\n    - 绝对时间：\n      - absolute_time: ISO 8601格式（如\"2024-01-15T13:00:00\"）\n      - raw_text: 原始时间文本\n\n    **请以JSON格式返回：**\n    {{\n      \"new_todos\": [\n        {{\n          \"title\": \"待办标题（简洁，不超过20字）\",\n          \"description\": \"待办描述（可选，详细说明）\",\n          \"time_info\": {{\n            \"time_type\": \"relative\" 或 \"absolute\",\n            \"relative_days\": 1 或 null,\n            \"relative_time\": \"13:00\" 或 null,\n            \"absolute_time\": \"2024-01-15T13:00:00\" 或 null,\n            \"raw_text\": \"原始时间文本（如：明天下午1点）\"\n          }},\n          \"source_text\": \"来源文本片段（用于验证）\",\n          \"confidence\": 0.7\n        }}\n      ]\n    }}\n\n    如果没有发现新待办事项，返回：\n    {{\n      \"new_todos\": []\n    }}\n\n    只返回JSON，不要返回其他任何信息。\n"
  },
  {
    "path": "lifetrace/config/rapidocr_config.yaml",
    "content": "# RapidOCR 配置文件 - 性能优化版本\n# 用于解决PyInstaller打包后配置文件缺失的问题\n# 针对打包后性能进行优化\n\nGlobal:\n  use_angle_cls: false # 关闭角度分类器以提升速度\n  print_verbose: false\n  min_height: 20 # 降低最小高度阈值\n\nDet:\n  use_cuda: false\n  limit_side_len: 960 # 增加图像尺寸限制以提高精度\n  limit_type: min\n  thresh: 0.4 # 提高阈值以减少误检\n  box_thresh: 0.6 # 提高框阈值\n  max_candidates: 500 # 减少候选框数量以提升速度\n  unclip_ratio: 1.8 # 优化展开比例\n  use_dilation: false\n  score_mode: fast\n\nCls:\n  use_cuda: false\n  cls_thresh: 0.8 # 降低分类阈值\n\nRec:\n  use_cuda: false\n  rec_batch_num: 8 # 增加批处理数量以提升效率\n\n# 外部模型文件路径配置（用于PyInstaller打包优化）\n# 注意：路径相对于 models 目录，只使用文件名\nModels:\n  det_model_path: 'ch_PP-OCRv4_det_infer.onnx'\n  rec_model_path: 'ch_PP-OCRv4_rec_infer.onnx'\n  cls_model_path: 'ch_ppocr_mobile_v2.0_cls_infer.onnx'\n"
  },
  {
    "path": "lifetrace/core/__init__.py",
    "content": ""
  },
  {
    "path": "lifetrace/core/config_watcher.py",
    "content": "\"\"\"\n配置变更监听与回调机制\n\n提供配置变更时的回调注册和触发功能，用于：\n- LLM API Key 变更时重新初始化 RAG 服务\n- 定时任务开关变更时暂停/恢复任务\n- 其他需要响应配置变更的场景\n\"\"\"\n\nfrom collections.abc import Callable\nfrom typing import Any\n\nfrom lifetrace.core.lazy_services import reinit_rag_service\nfrom lifetrace.jobs.job_manager import get_job_manager\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.settings import settings\n\nlogger = get_logger()\n\n# 配置变更回调注册表\n# 格式: {config_key: [callback_func, ...]}\n_callbacks: dict[str, list[Callable[[Any, Any], None]]] = {}\n\n# 配置值快照（用于检测变更）\n_config_snapshot: dict[str, Any] = {}\n\n\ndef on_config_change(key: str):\n    \"\"\"装饰器：注册配置变更回调\n\n    当指定的配置键发生变更时，回调函数将被调用。\n    回调函数签名：callback(old_value, new_value)\n\n    Args:\n        key: 配置键（支持点号分隔的嵌套键，如 \"llm.api_key\"）\n\n    Example:\n        @on_config_change(\"llm.api_key\")\n        def on_llm_key_change(old_val, new_val):\n            print(f\"LLM API Key changed from {old_val} to {new_val}\")\n    \"\"\"\n\n    def decorator(func: Callable[[Any, Any], None]):\n        register_callback(key, func)\n        return func\n\n    return decorator\n\n\ndef register_callback(key: str, callback: Callable[[Any, Any], None]):\n    \"\"\"注册配置变更回调\n\n    Args:\n        key: 配置键\n        callback: 回调函数，签名为 callback(old_value, new_value)\n    \"\"\"\n    if key not in _callbacks:\n        _callbacks[key] = []\n    if callback not in _callbacks[key]:\n        _callbacks[key].append(callback)\n        logger.debug(f\"已注册配置变更回调: {key} -> {callback.__name__}\")\n\n\ndef unregister_callback(key: str, callback: Callable[[Any, Any], None]):\n    \"\"\"取消注册配置变更回调\n\n    Args:\n        key: 配置键\n        callback: 要取消的回调函数\n    \"\"\"\n    if key in _callbacks and callback in _callbacks[key]:\n        _callbacks[key].remove(callback)\n        logger.debug(f\"已取消配置变更回调: {key} -> {callback.__name__}\")\n\n\ndef notify_config_change(key: str, old_value: Any, new_value: Any):\n    \"\"\"通知配置变更\n\n    触发已注册的所有回调函数。\n\n    Args:\n        key: 配置键\n        old_value: 旧值\n        new_value: 新值\n    \"\"\"\n    if key not in _callbacks:\n        return\n\n    logger.info(f\"配置变更: {key} = {new_value} (原值: {old_value})\")\n\n    for callback in _callbacks[key]:\n        try:\n            callback(old_value, new_value)\n            logger.debug(f\"配置变更回调成功: {key} -> {callback.__name__}\")\n        except Exception as e:\n            logger.error(f\"配置变更回调失败: {key} -> {callback.__name__}: {e}\")\n\n\ndef take_snapshot():\n    \"\"\"获取当前配置快照\n\n    在配置重载前调用，用于后续比对变更。\n    \"\"\"\n    _config_snapshot.clear()\n\n    # 记录所有已注册回调的配置键的当前值\n    for key in _callbacks:\n        try:\n            _config_snapshot[key] = settings.get(key)\n        except KeyError:\n            _config_snapshot[key] = None\n\n\ndef detect_and_notify_changes():\n    \"\"\"检测并通知配置变更\n\n    在配置重载后调用，比对快照与当前值，触发变更回调。\n    \"\"\"\n    for key in _callbacks:\n        old_value = _config_snapshot.get(key)\n        try:\n            new_value = settings.get(key)\n        except KeyError:\n            new_value = None\n\n        if old_value != new_value:\n            notify_config_change(key, old_value, new_value)\n\n\ndef reload_with_callbacks() -> bool:\n    \"\"\"带回调的配置重载\n\n    1. 获取当前配置快照\n    2. 重载配置\n    3. 检测变更并触发回调\n\n    Returns:\n        bool: 重载是否成功\n    \"\"\"\n    # 获取快照\n    take_snapshot()\n\n    # 重载配置\n    try:\n        settings.reload()\n        success = True\n    except Exception:\n        success = False\n\n    if success:\n        # 检测并通知变更\n        detect_and_notify_changes()\n\n    return success\n\n\n# ============================================================\n# 预定义的配置变更回调\n# ============================================================\n\n\n@on_config_change(\"llm.api_key\")\ndef _on_llm_api_key_change(_old_val: Any, _new_val: Any):\n    \"\"\"LLM API Key 变更时重新初始化 RAG 服务\"\"\"\n    try:\n        reinit_rag_service()\n        logger.info(\"LLM API Key 变更，已重新初始化 RAG 服务\")\n    except Exception as e:\n        logger.error(f\"重新初始化 RAG 服务失败: {e}\")\n\n\n@on_config_change(\"llm.base_url\")\ndef _on_llm_base_url_change(_old_val: Any, _new_val: Any):\n    \"\"\"LLM Base URL 变更时重新初始化 RAG 服务\"\"\"\n    try:\n        reinit_rag_service()\n        logger.info(\"LLM Base URL 变更，已重新初始化 RAG 服务\")\n    except Exception as e:\n        logger.error(f\"重新初始化 RAG 服务失败: {e}\")\n\n\n@on_config_change(\"jobs.recorder.enabled\")\ndef _on_recorder_toggle(_old_val: Any, new_val: Any):\n    \"\"\"录制器任务开关变更\"\"\"\n    try:\n        manager = get_job_manager()\n        scheduler = manager.scheduler_manager\n        if not scheduler:\n            logger.warning(\"调度器未初始化，无法更新录制器任务状态\")\n            return\n        if new_val:\n            scheduler.resume_job(\"recorder_job\")\n            logger.info(\"录制器任务已启用\")\n        else:\n            scheduler.pause_job(\"recorder_job\")\n            logger.info(\"录制器任务已暂停\")\n    except Exception as e:\n        logger.error(f\"变更录制器任务状态失败: {e}\")\n\n\n@on_config_change(\"jobs.ocr.enabled\")\ndef _on_ocr_toggle(_old_val: Any, new_val: Any):\n    \"\"\"OCR 任务开关变更\"\"\"\n    try:\n        manager = get_job_manager()\n        scheduler = manager.scheduler_manager\n        if not scheduler:\n            logger.warning(\"调度器未初始化，无法更新 OCR 任务状态\")\n            return\n        if new_val:\n            scheduler.resume_job(\"ocr_job\")\n            logger.info(\"OCR 任务已启用\")\n        else:\n            scheduler.pause_job(\"ocr_job\")\n            logger.info(\"OCR 任务已暂停\")\n    except Exception as e:\n        logger.error(f\"变更 OCR 任务状态失败: {e}\")\n\n\n@on_config_change(\"jobs.auto_todo_detection.enabled\")\ndef _on_auto_todo_detection_toggle(_old_val: Any, new_val: Any):\n    \"\"\"自动待办检测任务开关变更\"\"\"\n    try:\n        manager = get_job_manager()\n        scheduler = manager.scheduler_manager\n        if not scheduler:\n            logger.warning(\"调度器未初始化，无法更新自动待办检测任务状态\")\n            return\n        if new_val:\n            scheduler.resume_job(\"auto_todo_detection_job\")\n            logger.info(\"自动待办检测任务已启用\")\n        else:\n            scheduler.pause_job(\"auto_todo_detection_job\")\n            logger.info(\"自动待办检测任务已暂停\")\n    except Exception as e:\n        logger.error(f\"变更自动待办检测任务状态失败: {e}\")\n\n\n@on_config_change(\"vector_db.enabled\")\ndef _on_vector_db_toggle(_old_val: Any, new_val: Any):\n    \"\"\"向量数据库开关变更\"\"\"\n    try:\n        if new_val:\n            reinit_rag_service()\n            logger.info(\"向量数据库已启用，重新初始化 RAG 服务\")\n        else:\n            logger.info(\"向量数据库已禁用\")\n    except Exception as e:\n        logger.error(f\"变更向量数据库状态失败: {e}\")\n"
  },
  {
    "path": "lifetrace/core/dependencies.py",
    "content": "\"\"\"FastAPI 依赖注入模块\n\n提供数据库会话和服务层的依赖注入工厂函数。\n\"\"\"\n\nfrom collections.abc import Generator\nfrom functools import lru_cache\n\nfrom fastapi import Depends\nfrom sqlalchemy.orm import Session\n\nfrom lifetrace.core.lazy_services import (\n    get_rag_service as lazy_get_rag_service,\n)\nfrom lifetrace.core.lazy_services import (\n    get_vector_service as lazy_get_vector_service,\n)\nfrom lifetrace.repositories.interfaces import (\n    IActivityRepository,\n    IChatRepository,\n    IEventRepository,\n    IJournalRepository,\n    IOcrRepository,\n    ITodoRepository,\n)\nfrom lifetrace.repositories.sql_activity_repository import SqlActivityRepository\nfrom lifetrace.repositories.sql_chat_repository import SqlChatRepository\nfrom lifetrace.repositories.sql_event_repository import SqlEventRepository, SqlOcrRepository\nfrom lifetrace.repositories.sql_journal_repository import SqlJournalRepository\nfrom lifetrace.repositories.sql_todo_repository import SqlTodoRepository\nfrom lifetrace.services.activity_service import ActivityService\nfrom lifetrace.services.chat_service import ChatService\nfrom lifetrace.services.event_service import EventService\nfrom lifetrace.services.journal_service import JournalService\nfrom lifetrace.services.todo_service import TodoService\nfrom lifetrace.storage.database_base import DatabaseBase\nfrom lifetrace.util.settings import settings\n\n\ndef get_db_base() -> DatabaseBase:\n    \"\"\"获取数据库基础实例（复用 storage 模块的单例）\"\"\"\n    from lifetrace.storage.database import db_base  # noqa: PLC0415\n\n    return db_base\n\n\ndef get_db_session(\n    db_base: DatabaseBase = Depends(get_db_base),\n) -> Generator[Session]:\n    \"\"\"获取数据库会话 - 请求级别生命周期\"\"\"\n    if db_base.SessionLocal is None:\n        raise RuntimeError(\"Database session factory is not initialized.\")\n    session = db_base.SessionLocal()\n    try:\n        yield session\n        session.commit()\n    except Exception:\n        session.rollback()\n        raise\n    finally:\n        session.close()\n\n\n# ========== Todo 模块依赖注入 ==========\n\n\ndef get_todo_repository(\n    db_base: DatabaseBase = Depends(get_db_base),\n) -> ITodoRepository:\n    \"\"\"获取 Todo 仓库实例\"\"\"\n    return SqlTodoRepository(db_base)\n\n\ndef get_todo_service(\n    repo: ITodoRepository = Depends(get_todo_repository),\n) -> TodoService:\n    \"\"\"获取 Todo 服务实例\"\"\"\n    return TodoService(repo)\n\n\n# ========== Journal 模块依赖注入 ==========\n\n\ndef get_journal_repository(\n    db_base: DatabaseBase = Depends(get_db_base),\n) -> IJournalRepository:\n    \"\"\"获取 Journal 仓库实例\"\"\"\n    return SqlJournalRepository(db_base)\n\n\ndef get_journal_service(\n    repo: IJournalRepository = Depends(get_journal_repository),\n    db_base: DatabaseBase = Depends(get_db_base),\n) -> JournalService:\n    \"\"\"获取 Journal 服务实例\"\"\"\n    return JournalService(repo, db_base)\n\n\n# ========== Event 模块依赖注入 ==========\n\n\ndef get_event_repository(\n    db_base: DatabaseBase = Depends(get_db_base),\n) -> IEventRepository:\n    \"\"\"获取 Event 仓库实例\"\"\"\n    return SqlEventRepository(db_base)\n\n\ndef get_ocr_repository(\n    db_base: DatabaseBase = Depends(get_db_base),\n) -> IOcrRepository:\n    \"\"\"获取 OCR 仓库实例\"\"\"\n    return SqlOcrRepository(db_base)\n\n\ndef get_event_service(\n    event_repo: IEventRepository = Depends(get_event_repository),\n    ocr_repo: IOcrRepository = Depends(get_ocr_repository),\n) -> EventService:\n    \"\"\"获取 Event 服务实例\"\"\"\n    return EventService(event_repo, ocr_repo)\n\n\n# ========== Activity 模块依赖注入 ==========\n\n\ndef get_activity_repository(\n    db_base: DatabaseBase = Depends(get_db_base),\n) -> IActivityRepository:\n    \"\"\"获取 Activity 仓库实例\"\"\"\n    return SqlActivityRepository(db_base)\n\n\ndef get_activity_service(\n    activity_repo: IActivityRepository = Depends(get_activity_repository),\n    event_repo: IEventRepository = Depends(get_event_repository),\n) -> ActivityService:\n    \"\"\"获取 Activity 服务实例\"\"\"\n    return ActivityService(activity_repo, event_repo)\n\n\n# ========== Chat 模块依赖注入 ==========\n\n\ndef get_chat_repository(\n    db_base: DatabaseBase = Depends(get_db_base),\n) -> IChatRepository:\n    \"\"\"获取 Chat 仓库实例\"\"\"\n    return SqlChatRepository(db_base)\n\n\ndef get_chat_service(\n    repo: IChatRepository = Depends(get_chat_repository),\n) -> ChatService:\n    \"\"\"获取 Chat 服务实例\"\"\"\n    return ChatService(repo)\n\n\n# ========== 延迟加载服务 ==========\n\n\ndef get_vector_service():\n    \"\"\"获取向量服务（延迟加载）\"\"\"\n    return lazy_get_vector_service()\n\n\ndef get_rag_service():\n    \"\"\"获取 RAG 服务（延迟加载）\"\"\"\n    return lazy_get_rag_service()\n\n\n# ========== OCR 处理器依赖注入 ==========\n\n\n@lru_cache(maxsize=1)\ndef get_ocr_processor():\n    \"\"\"获取 OCR 处理器（延迟加载，单例模式）\"\"\"\n    from lifetrace.jobs.ocr_processor import SimpleOCRProcessor  # noqa: PLC0415\n\n    return SimpleOCRProcessor()\n\n\n# ========== 配置依赖注入 ==========\n\n\ndef get_settings():\n    \"\"\"获取配置对象\"\"\"\n    return settings\n"
  },
  {
    "path": "lifetrace/core/lazy_services.py",
    "content": "\"\"\"延迟加载服务模块\n\n解决启动时同步加载向量服务和RAG服务导致的30秒+启动延迟问题。\n服务在首次访问时才进行初始化。\n\"\"\"\n\nfrom functools import lru_cache\nfrom typing import TYPE_CHECKING\n\nif TYPE_CHECKING:\n    from lifetrace.llm.rag_service import RAGService\n    from lifetrace.llm.vector_service import VectorService\n\n\n@lru_cache(maxsize=1)\ndef get_vector_service() -> \"VectorService\":\n    \"\"\"延迟加载向量服务 - 首次访问时初始化\"\"\"\n    from lifetrace.llm.vector_service import create_vector_service  # noqa: PLC0415\n\n    return create_vector_service()\n\n\n@lru_cache(maxsize=1)\ndef get_rag_service() -> \"RAGService\":\n    \"\"\"延迟加载 RAG 服务 - 首次访问时初始化\"\"\"\n    from lifetrace.llm.rag_service import RAGService  # noqa: PLC0415\n\n    return RAGService()\n\n\ndef reinit_vector_service():\n    \"\"\"重新初始化向量服务\n\n    在配置变更（如向量数据库设置变更）时调用。\n    \"\"\"\n    get_vector_service.cache_clear()\n\n\ndef reinit_rag_service():\n    \"\"\"重新初始化 RAG 服务\n\n    在配置变更（如 LLM API Key 或 Base URL 变更）时调用。\n    同时也会重新初始化向量服务。\n    \"\"\"\n    get_rag_service.cache_clear()\n    get_vector_service.cache_clear()\n"
  },
  {
    "path": "lifetrace/core/module_registry.py",
    "content": "\"\"\"Backend module registry for lightweight plugin-style enablement.\"\"\"\n\nfrom __future__ import annotations\n\nfrom collections.abc import Iterable\nfrom dataclasses import dataclass\nfrom importlib import import_module\nfrom importlib import util as importlib_util\nfrom typing import TYPE_CHECKING\n\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.settings import settings\n\nlogger = get_logger()\n\nif TYPE_CHECKING:\n    from fastapi import FastAPI\n\n\n@dataclass(frozen=True)\nclass ModuleDefinition:\n    id: str\n    router_module: str\n    router_attr: str = \"router\"\n    dependencies: tuple[str, ...] = ()\n    requires: tuple[str, ...] = ()\n    core: bool = False\n\n\nMODULES: tuple[ModuleDefinition, ...] = (\n    ModuleDefinition(id=\"health\", router_module=\"lifetrace.routers.health\", core=True),\n    ModuleDefinition(id=\"config\", router_module=\"lifetrace.routers.config\", core=True),\n    ModuleDefinition(id=\"system\", router_module=\"lifetrace.routers.system\", core=True),\n    ModuleDefinition(id=\"logs\", router_module=\"lifetrace.routers.logs\"),\n    ModuleDefinition(id=\"chat\", router_module=\"lifetrace.routers.chat\"),\n    ModuleDefinition(id=\"activity\", router_module=\"lifetrace.routers.activity\"),\n    ModuleDefinition(id=\"search\", router_module=\"lifetrace.routers.search\"),\n    ModuleDefinition(id=\"screenshot\", router_module=\"lifetrace.routers.screenshot\"),\n    ModuleDefinition(id=\"event\", router_module=\"lifetrace.routers.event\"),\n    ModuleDefinition(id=\"ocr\", router_module=\"lifetrace.routers.ocr\"),\n    ModuleDefinition(\n        id=\"vector\",\n        router_module=\"lifetrace.routers.vector\",\n        dependencies=(\"chromadb\", \"sentence_transformers\", \"hdbscan\", \"scipy\"),\n    ),\n    ModuleDefinition(\n        id=\"rag\",\n        router_module=\"lifetrace.routers.rag\",\n        dependencies=(\"chromadb\", \"sentence_transformers\", \"hdbscan\", \"scipy\"),\n        requires=(\"vector\",),\n    ),\n    ModuleDefinition(id=\"scheduler\", router_module=\"lifetrace.routers.scheduler\"),\n    ModuleDefinition(\n        id=\"automation\",\n        router_module=\"lifetrace.routers.automation\",\n        requires=(\"scheduler\",),\n    ),\n    ModuleDefinition(id=\"cost_tracking\", router_module=\"lifetrace.routers.cost_tracking\"),\n    ModuleDefinition(id=\"time_allocation\", router_module=\"lifetrace.routers.time_allocation\"),\n    ModuleDefinition(id=\"todo\", router_module=\"lifetrace.routers.todo\"),\n    ModuleDefinition(id=\"todo_extraction\", router_module=\"lifetrace.routers.todo_extraction\"),\n    ModuleDefinition(id=\"journal\", router_module=\"lifetrace.routers.journal\"),\n    ModuleDefinition(id=\"vision\", router_module=\"lifetrace.routers.vision\"),\n    ModuleDefinition(id=\"notification\", router_module=\"lifetrace.routers.notification\"),\n    ModuleDefinition(id=\"floating_capture\", router_module=\"lifetrace.routers.floating_capture\"),\n    ModuleDefinition(id=\"audio\", router_module=\"lifetrace.routers.audio\"),\n    ModuleDefinition(id=\"proactive_ocr\", router_module=\"lifetrace.routers.proactive_ocr\"),\n)\n\nMODULE_INDEX = {module.id: module for module in MODULES}\nCORE_MODULES = {module.id for module in MODULES if module.core}\n\n\n@dataclass\nclass ModuleState:\n    id: str\n    enabled: bool\n    available: bool\n    missing_deps: list[str]\n\n\ndef _normalize_module_list(value: object | None) -> set[str]:\n    if value is None:\n        return set()\n    if isinstance(value, str):\n        return {value}\n    if isinstance(value, Iterable):\n        return {str(item) for item in value}\n    return set()\n\n\ndef _missing_dependencies(dependencies: tuple[str, ...]) -> list[str]:\n    missing: list[str] = []\n    for dep in dependencies:\n        if importlib_util.find_spec(dep) is None:\n            missing.append(dep)\n    return missing\n\n\ndef _get_enabled_module_ids() -> set[str]:\n    enabled = _normalize_module_list(settings.get(\"backend_modules.enabled\"))\n    disabled = _normalize_module_list(settings.get(\"backend_modules.disabled\"))\n\n    if not enabled:\n        enabled = {module.id for module in MODULES}\n\n    enabled = enabled.difference(disabled)\n    enabled |= CORE_MODULES\n    return enabled\n\n\ndef get_module_states() -> dict[str, ModuleState]:\n    enabled_ids = _get_enabled_module_ids()\n    states: dict[str, ModuleState] = {}\n    forced_unavailable = _normalize_module_list(settings.get(\"backend_modules.unavailable\"))\n\n    for module in MODULES:\n        missing = _missing_dependencies(module.dependencies)\n        states[module.id] = ModuleState(\n            id=module.id,\n            enabled=module.id in enabled_ids,\n            available=not missing,\n            missing_deps=missing,\n        )\n\n    for module_id in forced_unavailable:\n        state = states.get(module_id)\n        if not state:\n            continue\n        if state.available:\n            state.available = False\n            state.missing_deps.append(\"config:unavailable\")\n\n    for module in MODULES:\n        state = states[module.id]\n        if state.available and module.requires:\n            missing_requires = [\n                f\"module:{req}\"\n                for req in module.requires\n                if req not in states or not states[req].available\n            ]\n            if missing_requires:\n                state.available = False\n                state.missing_deps.extend(missing_requires)\n\n    return states\n\n\ndef log_module_summary(states: dict[str, ModuleState]) -> None:\n    enabled_ids = sorted([mid for mid, state in states.items() if state.enabled])\n    disabled_ids = sorted([mid for mid, state in states.items() if not state.enabled])\n    unavailable_ids = sorted(\n        [mid for mid, state in states.items() if state.enabled and not state.available]\n    )\n\n    logger.info(f\"Backend modules enabled: {', '.join(enabled_ids) or 'none'}\")\n    logger.info(f\"Backend modules disabled: {', '.join(disabled_ids) or 'none'}\")\n    if unavailable_ids:\n        logger.warning(f\"Backend modules unavailable: {', '.join(unavailable_ids)}\")\n        for module_id in unavailable_ids:\n            missing = \", \".join(states[module_id].missing_deps) or \"unknown\"\n            logger.warning(f\"Backend module deps missing: {module_id} -> {missing}\")\n    else:\n        logger.info(\"Backend modules unavailable: none\")\n\n\ndef get_enabled_module_ids(states: dict[str, ModuleState] | None = None) -> list[str]:\n    if states is None:\n        states = get_module_states()\n    return [module.id for module in MODULES if states[module.id].enabled]\n\n\ndef register_modules(\n    app: FastAPI,\n    module_ids: Iterable[str],\n    states: dict[str, ModuleState] | None = None,\n) -> list[str]:\n    if states is None:\n        states = get_module_states()\n\n    module_id_set = set(module_ids)\n    enabled_modules: list[str] = []\n\n    for module in MODULES:\n        if module.id not in module_id_set:\n            continue\n        state = states.get(module.id)\n        if not state or not state.enabled:\n            continue\n        if not state.available:\n            logger.warning(\n                \"Module disabled due to missing deps: %s -> %s\",\n                module.id,\n                \", \".join(state.missing_deps),\n            )\n            continue\n        try:\n            router_module = import_module(module.router_module)\n            router = getattr(router_module, module.router_attr)\n            app.include_router(router)\n            enabled_modules.append(module.id)\n        except Exception as exc:\n            logger.error(\"Failed to register module %s: %s\", module.id, exc)\n\n    return enabled_modules\n\n\ndef register_enabled_modules(app: FastAPI) -> list[str]:\n    states = get_module_states()\n    log_module_summary(states)\n    enabled_ids = get_enabled_module_ids(states)\n    return register_modules(app, enabled_ids, states=states)\n\n\ndef get_capabilities_report() -> dict[str, object]:\n    states = get_module_states()\n\n    enabled_modules = [mid for mid, state in states.items() if state.enabled]\n    available_modules = [mid for mid, state in states.items() if state.available]\n    disabled_modules = [mid for mid, state in states.items() if not state.enabled]\n    missing_deps = {mid: state.missing_deps for mid, state in states.items() if state.missing_deps}\n\n    return {\n        \"enabled_modules\": sorted(enabled_modules),\n        \"available_modules\": sorted(available_modules),\n        \"disabled_modules\": sorted(disabled_modules),\n        \"missing_deps\": missing_deps,\n    }\n"
  },
  {
    "path": "lifetrace/docs/MIGRATION_GUIDE.md",
    "content": "# 数据库迁移指南\n\n## 问题：多个 Head 错误\n\n当出现 `Multiple head revisions are present` 错误时，说明迁移链出现了分支。\n\n### 原因\n\n多个迁移文件都基于同一个父版本，导致 Alembic 不知道应该应用哪个迁移。\n\n### 解决方法\n\n1. **检查当前所有 head**：\n   ```bash\n   alembic heads\n   ```\n\n2. **查看迁移历史**：\n   ```bash\n   alembic history\n   ```\n\n3. **修复迁移链**：\n   - 找到最新的迁移文件\n   - 修改新迁移文件的 `down_revision`，让它基于最新的 head\n   - 或者合并多个 head（使用 `alembic merge`）\n\n## 如何预防多个 Head\n\n### ✅ 正确做法\n\n1. **创建新迁移前，先检查当前 head**：\n   ```bash\n   alembic heads\n   # 输出示例：remove_project_task (head)\n   ```\n\n2. **创建新迁移时，使用当前 head 作为父版本**：\n   ```bash\n   alembic revision -m \"描述\" --head=remove_project_task\n   ```\n\n3. **或者手动创建迁移文件时，确保 `down_revision` 指向最新的 head**：\n   ```python\n   revision: str = \"new_revision_id\"\n   down_revision: str = \"remove_project_task\"  # 使用最新的 head\n   ```\n\n### ❌ 错误做法\n\n1. **不要基于旧的迁移版本创建新迁移**：\n   ```python\n   # 错误：如果已经有更新的迁移，不要基于旧版本\n   down_revision: str = \"4ca5036ec7c8\"  # 如果已经有 remove_project_task，不要用这个\n   ```\n\n2. **不要同时创建多个基于同一父版本的迁移**：\n   - 这会导致多个 head\n   - 应该按顺序创建，一个接一个\n\n## 迁移文件命名规范\n\n推荐使用时间戳 + 描述的方式：\n```bash\nalembic revision -m \"add_file_path_to_audio_recordings\"\n# 生成：20260119_123456_add_file_path_to_audio_recordings.py\n```\n\n或者手动创建时使用有意义的 revision ID：\n```python\nrevision: str = \"add_file_path_001\"  # 简短但唯一\n```\n\n## 迁移链示例\n\n正确的迁移链应该是线性的：\n```\ncc25001eb19c (初始基线)\n    ↓\n4ca5036ec7c8 (添加 context)\n    ↓\nremove_project_task (删除项目表)\n    ↓\nadd_file_path_001 (添加 file_path)  ← 当前 head\n```\n\n## 合并多个 Head\n\n如果已经出现了多个 head，可以使用 merge：\n\n```bash\n# 1. 创建一个合并迁移\nalembic merge -m \"merge heads\" heads\n\n# 2. 这会创建一个新的迁移文件，合并所有 head\n\n# 3. 然后运行升级\nalembic upgrade head\n```\n\n## 快速检查清单\n\n创建新迁移前：\n- [ ] 运行 `alembic heads` 查看当前 head\n- [ ] 运行 `alembic current` 查看当前数据库版本\n- [ ] 确保新迁移的 `down_revision` 指向最新的 head\n- [ ] 创建后运行 `alembic heads` 确认只有一个 head\n\n## 常见问题\n\n### Q: 如何查看迁移链？\nA: `alembic history --verbose`\n\n### Q: 如何回滚到特定版本？\nA: `alembic downgrade <revision_id>`\n\n### Q: 如何查看当前数据库版本？\nA: `alembic current`\n\n### Q: 迁移文件冲突怎么办？\nA: 使用 `alembic merge` 合并多个 head\n\n### Q: 迁移运行后表结构仍然不完整怎么办？\nA: 这种情况通常发生在：\n1. 表是通过 `SQLModel.metadata.create_all()` 创建的，而不是迁移\n2. 迁移只添加了部分列，但表缺少其他列\n\n**解决方法**：\n1. **快速修复**：使用修复脚本直接修改数据库\n   ```bash\n   python lifetrace/scripts/fix_audio_recordings_table.py\n   ```\n\n2. **正确方法**：修改迁移文件，添加所有缺失的列，然后：\n   ```bash\n   # 回滚迁移\n   alembic downgrade -1\n   # 重新运行迁移\n   alembic upgrade head\n   ```\n\n3. **预防**：确保所有表都通过迁移创建，而不是 `create_all()`\n\n### Q: 如何确保迁移包含所有列？\nA: 在迁移的 `upgrade()` 函数中：\n- 检查表是否存在，不存在则创建完整表\n- 检查每个列是否存在，不存在则添加\n- 为现有记录设置合理的默认值\n"
  },
  {
    "path": "lifetrace/jobs/activity_aggregator.py",
    "content": "\"\"\"\n活动聚合任务\n定时聚合15分钟内的事件，使用LLM总结，存储到活动表\n\"\"\"\n\nfrom datetime import datetime, timedelta\nfrom functools import lru_cache\n\nfrom lifetrace.llm.activity_summary_service import activity_summary_service\nfrom lifetrace.storage import activity_mgr\nfrom lifetrace.storage.models import Event\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.time_utils import get_utc_now\n\nlogger = get_logger()\n\n# 常量定义\nLONG_EVENT_DURATION_MINUTES = 30  # 长事件判断标准（分钟）\nQUERY_LOOKBACK_HOURS = 1  # 查询回溯时间（小时）\n\n\ndef is_long_event(event: Event) -> bool:\n    \"\"\"判断是否为长事件（>=30分钟）\n\n    Args:\n        event: 事件对象\n\n    Returns:\n        是否为长事件\n    \"\"\"\n    if not event.end_time:\n        return False\n    duration = (event.end_time - event.start_time).total_seconds()\n    return duration >= LONG_EVENT_DURATION_MINUTES * 60\n\n\ndef round_to_15_minutes(dt: datetime) -> datetime:\n    \"\"\"将时间向下取整到最近的15分钟边界\n\n    Args:\n        dt: 原始时间\n\n    Returns:\n        取整后的时间\n    \"\"\"\n    minutes = dt.minute\n    rounded_minutes = (minutes // 15) * 15\n    return dt.replace(minute=rounded_minutes, second=0, microsecond=0)\n\n\ndef group_short_events_by_window(\n    events: list[Event],\n) -> dict[datetime, list[Event]]:\n    \"\"\"将短事件按15分钟窗口分组\n\n    Args:\n        events: 事件列表\n\n    Returns:\n        按窗口分组的字典，key为窗口开始时间，value为事件列表\n    \"\"\"\n    grouped: dict[datetime, list[Event]] = {}\n    for event in events:\n        window_start = round_to_15_minutes(event.start_time)\n        if window_start not in grouped:\n            grouped[window_start] = []\n        grouped[window_start].append(event)\n    return grouped\n\n\ndef create_activity_for_long_event(event: Event) -> bool:\n    \"\"\"为长事件单独创建活动\n\n    Args:\n        event: 长事件对象\n\n    Returns:\n        是否成功\n    \"\"\"\n    try:\n        # 检查是否已存在重叠的活动\n        if activity_mgr.activity_overlaps_with_event(event):\n            logger.debug(f\"事件 {event.id} 已存在重叠的活动，跳过\")\n            return False\n\n        event_id = event.id\n        end_time = event.end_time\n        if event_id is None or end_time is None:\n            if event_id is None:\n                logger.warning(\"事件缺少ID，无法创建活动\")\n            return False\n\n        # 准备事件数据（包含时间信息以支持时间线呈现）\n        event_data = {\n            \"ai_title\": event.ai_title or \"\",\n            \"ai_summary\": event.ai_summary or \"\",\n            \"start_time\": event.start_time,  # 添加时间信息\n        }\n\n        # 生成活动摘要\n        result = activity_summary_service.generate_activity_summary(\n            events=[event_data],\n            start_time=event.start_time,\n            end_time=end_time,\n        )\n\n        if not result:\n            logger.warning(f\"为长事件 {event.id} 生成摘要失败\")\n            return False\n\n        # 创建活动记录\n        activity_id = activity_mgr.create_activity(\n            start_time=event.start_time,\n            end_time=end_time,\n            ai_title=result[\"title\"],\n            ai_summary=result[\"summary\"],\n            event_ids=[event_id],\n        )\n\n        if activity_id:\n            logger.info(f\"为长事件 {event.id} 创建活动 {activity_id}: {result['title']}\")\n            return True\n\n        logger.error(f\"为长事件 {event.id} 创建活动失败\")\n        return False\n\n    except Exception as e:\n        logger.error(f\"为长事件 {event.id} 创建活动时出错: {e}\", exc_info=True)\n        return False\n\n\ndef create_activity_for_window(window_start: datetime, window_events: list[Event]) -> bool:\n    \"\"\"为15分钟窗口内的短事件创建活动\n\n    Args:\n        window_start: 窗口开始时间\n        window_events: 窗口内的事件列表\n\n    Returns:\n        是否成功\n    \"\"\"\n    try:\n        # 检查是否已存在活动记录\n        window_end = window_start + timedelta(minutes=15)\n        if activity_mgr.activity_exists_for_time_window(window_start, window_end):\n            logger.debug(f\"窗口 {window_start} 已存在活动记录，跳过\")\n            return False\n\n        # 准备事件数据（包含时间信息以支持时间线呈现）\n        events_data = []\n        for event in window_events:\n            events_data.append(\n                {\n                    \"ai_title\": event.ai_title or \"\",\n                    \"ai_summary\": event.ai_summary or \"\",\n                    \"start_time\": event.start_time,  # 添加时间信息\n                }\n            )\n\n        # 生成活动摘要\n        result = activity_summary_service.generate_activity_summary(\n            events=events_data,\n            start_time=window_start,\n            end_time=window_end,\n        )\n\n        if not result:\n            logger.warning(f\"为窗口 {window_start} 生成摘要失败\")\n            return False\n\n        # 创建活动记录\n        event_ids = [e.id for e in window_events if e.id is not None]\n        if not event_ids:\n            logger.warning(f\"窗口 {window_start} 没有可用事件ID，跳过活动创建\")\n            return False\n        activity_id = activity_mgr.create_activity(\n            start_time=window_start,\n            end_time=window_end,\n            ai_title=result[\"title\"],\n            ai_summary=result[\"summary\"],\n            event_ids=event_ids,\n        )\n\n        if activity_id:\n            logger.info(\n                f\"为窗口 {window_start} 创建活动 {activity_id}: {result['title']}，包含 {len(event_ids)} 个事件\"\n            )\n            return True\n        else:\n            logger.error(f\"为窗口 {window_start} 创建活动失败\")\n            return False\n\n    except Exception as e:\n        logger.error(f\"为窗口 {window_start} 创建活动时出错: {e}\", exc_info=True)\n        return False\n\n\ndef _calculate_target_window(now: datetime) -> tuple[datetime, datetime] | None:\n    \"\"\"计算目标处理窗口\n\n    Args:\n        now: 当前时间\n\n    Returns:\n        (window_start, window_end) 或 None（如果窗口尚未完成）\n    \"\"\"\n    window_end = round_to_15_minutes(now)\n    window_start = window_end - timedelta(minutes=15)\n    safety_gap = timedelta(minutes=1)  # 留出1分钟缓冲，避免正在结束的事件\n\n    if now < window_end + safety_gap:\n        logger.info(\"当前窗口尚未完全结束，跳过本次聚合\")\n        return None\n\n    return window_start, window_end\n\n\ndef _filter_events_in_window(\n    events: list[Event], window_start: datetime, window_end: datetime\n) -> list[Event]:\n    \"\"\"过滤出落在目标窗口内的事件\n\n    Args:\n        events: 事件列表\n        window_start: 窗口开始时间\n        window_end: 窗口结束时间\n\n    Returns:\n        窗口内的事件列表\n    \"\"\"\n    events_in_window = []\n    for e in events:\n        if not e.end_time:\n            continue\n        # 事件结束时间必须在窗口内；开始时间只需早于窗口结束即可\n        if window_start <= e.end_time <= window_end and e.start_time < window_end:\n            events_in_window.append(e)\n    return events_in_window\n\n\ndef _separate_long_and_short_events(\n    events: list[Event],\n) -> tuple[list[Event], list[Event]]:\n    \"\"\"分离长事件和短事件\n\n    Args:\n        events: 事件列表\n\n    Returns:\n        (长事件列表, 短事件列表)\n    \"\"\"\n    long_events = [e for e in events if is_long_event(e)]\n    short_events = [e for e in events if not is_long_event(e)]\n    return long_events, short_events\n\n\ndef _process_long_events(long_events: list[Event]) -> int:\n    \"\"\"处理长事件，为每个长事件单独创建活动\n\n    Args:\n        long_events: 长事件列表\n\n    Returns:\n        成功处理的长事件数量\n    \"\"\"\n    long_event_count = 0\n    for event in long_events:\n        if activity_mgr.activity_exists_for_event(event):\n            logger.debug(f\"长事件 {event.id} 已关联到活动，跳过\")\n            continue\n\n        if create_activity_for_long_event(event):\n            long_event_count += 1\n\n    return long_event_count\n\n\ndef _process_short_events(short_events: list[Event], window_start: datetime) -> tuple[int, int]:\n    \"\"\"处理短事件，按窗口聚合\n\n    Args:\n        short_events: 短事件列表\n        window_start: 窗口开始时间\n\n    Returns:\n        (成功处理的窗口数, 处理的事件数)\n    \"\"\"\n    unprocessed_short_events = [\n        e for e in short_events if not activity_mgr.activity_exists_for_event(e)\n    ]\n\n    if not unprocessed_short_events:\n        return 0, 0\n\n    grouped_events = {window_start: unprocessed_short_events}\n    window_count = 0\n    for ws, window_events in grouped_events.items():\n        if create_activity_for_window(ws, window_events):\n            window_count += 1\n\n    return window_count, len(unprocessed_short_events)\n\n\ndef execute_activity_aggregation_task():\n    \"\"\"执行活动聚合任务\n\n    只处理“已结束的15分钟窗口”，避免在窗口刚开始时就生成活动导致未来事件无法合并。\n    \"\"\"\n    try:\n        logger.info(\"开始执行活动聚合任务\")\n\n        now = get_utc_now()\n        window_result = _calculate_target_window(now)\n        if not window_result:\n            return\n\n        window_start, window_end = window_result\n\n        # 查询近1小时未处理事件\n        query_start_time = now - timedelta(hours=QUERY_LOOKBACK_HOURS)\n        events = activity_mgr.get_unprocessed_events(query_start_time)\n\n        if not events:\n            logger.debug(\"无待处理事件，跳过\")\n            return\n\n        # 过滤窗口内的事件\n        events_in_window = _filter_events_in_window(events, window_start, window_end)\n        if not events_in_window:\n            logger.debug(f\"窗口 {window_start} ~ {window_end} 内无可处理事件，跳过\")\n            return\n\n        logger.info(f\"窗口 {window_start} ~ {window_end} 待处理事件 {len(events_in_window)} 个\")\n\n        # 分离长事件和短事件\n        long_events, short_events = _separate_long_and_short_events(events_in_window)\n        logger.info(f\"长事件: {len(long_events)} 个，短事件: {len(short_events)} 个\")\n\n        # 处理长事件\n        long_event_count = _process_long_events(long_events)\n        logger.info(f\"成功处理 {long_event_count} 个长事件\")\n\n        # 处理短事件\n        window_count, processed_event_count = _process_short_events(short_events, window_start)\n        if processed_event_count > 0:\n            logger.info(\n                f\"成功处理 {window_count} 个时间窗口，包含 {processed_event_count} 个短事件\"\n            )\n\n        logger.info(\"活动聚合任务执行完成\")\n\n    except Exception as e:\n        logger.error(f\"执行活动聚合任务失败: {e}\", exc_info=True)\n\n\n# 全局单例（用于延迟初始化）\n\n\n@lru_cache(maxsize=1)\ndef get_aggregator_instance():\n    \"\"\"获取聚合器实例（用于初始化）\"\"\"\n    return True  # 不需要实际实例，只是占位\n"
  },
  {
    "path": "lifetrace/jobs/clean_data.py",
    "content": "\"\"\"\n数据清理任务\n负责清理旧的截图数据，防止磁盘空间占用过大\n\"\"\"\n\nimport os\nfrom datetime import timedelta\nfrom functools import lru_cache\n\nfrom lifetrace.storage import get_session, screenshot_mgr\nfrom lifetrace.storage.models import Screenshot\nfrom lifetrace.storage.sql_utils import col\nfrom lifetrace.util.base_paths import get_user_data_dir\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.settings import settings\nfrom lifetrace.util.time_utils import get_utc_now\n\nlogger = get_logger()\n\n\nclass CleanDataService:\n    \"\"\"数据清理服务\"\"\"\n\n    def __init__(self):\n        \"\"\"初始化数据清理服务\"\"\"\n        self.max_screenshots = settings.get(\"jobs.clean_data.params.max_screenshots\")\n        self.max_days = settings.get(\"jobs.clean_data.params.max_days\")\n        self.delete_file_only = settings.get(\"jobs.clean_data.params.delete_file_only\")\n        logger.info(\"数据清理服务已初始化\")\n\n    def execute(self) -> dict:\n        \"\"\"执行数据清理任务\n\n        Returns:\n            执行结果字典，包含清理统计信息\n        \"\"\"\n        try:\n            logger.info(\"开始执行数据清理任务\")\n            result = {\n                \"deleted_files\": 0,\n                \"deleted_records\": 0,\n                \"freed_space\": 0,\n                \"errors\": [],\n            }\n\n            # 1. 按数量清理（保留最新的 N 张截图）\n            if self.max_screenshots:\n                deleted = self._clean_by_count()\n                result[\"deleted_files\"] += deleted[\"files\"]\n                result[\"deleted_records\"] += deleted[\"records\"]\n                result[\"freed_space\"] += deleted[\"space\"]\n\n            # 2. 按时间清理（删除超过 N 天的数据）\n            if self.max_days:\n                deleted = self._clean_by_date()\n                result[\"deleted_files\"] += deleted[\"files\"]\n                result[\"deleted_records\"] += deleted[\"records\"]\n                result[\"freed_space\"] += deleted[\"space\"]\n\n            logger.info(\n                f\"数据清理完成 - 删除文件: {result['deleted_files']}, \"\n                f\"删除记录: {result['deleted_records']}, \"\n                f\"释放空间: {result['freed_space'] / 1024 / 1024:.2f}MB\"\n            )\n            return result\n\n        except Exception as e:\n            logger.error(f\"执行数据清理任务失败: {e}\", exc_info=True)\n            return {\"error\": str(e)}\n\n    def _clean_by_count(self) -> dict:\n        \"\"\"按数量清理截图\n\n        Returns:\n            清理结果统计\n        \"\"\"\n        result = {\"files\": 0, \"records\": 0, \"space\": 0}\n\n        try:\n            # 获取截图总数（排除已删除文件的记录）\n            total = screenshot_mgr.get_screenshot_count(exclude_deleted=True)\n\n            if total <= self.max_screenshots:\n                logger.debug(\n                    f\"截图数量 ({total}) 未超过限制 ({self.max_screenshots})，跳过按数量清理\"\n                )\n                return result\n\n            # 计算需要删除的数量\n            to_delete_count = total - self.max_screenshots\n            logger.info(\n                f\"截图数量超限 ({total} > {self.max_screenshots})，需要删除 {to_delete_count} 张\"\n            )\n\n            # 获取最旧的截图列表（排除已删除文件的记录）\n            with get_session() as session:\n                old_screenshots = (\n                    session.query(Screenshot)\n                    .filter(col(Screenshot.file_deleted).is_not(True))\n                    .order_by(col(Screenshot.created_at).asc())\n                    .limit(to_delete_count)\n                    .all()\n                )\n\n                for screenshot in old_screenshots:\n                    deleted = self._delete_screenshot(screenshot, session)\n                    if deleted[\"success\"]:\n                        result[\"files\"] += 1\n                        result[\"space\"] += deleted[\"size\"]\n                        if not self.delete_file_only:\n                            result[\"records\"] += 1\n\n            return result\n\n        except Exception as e:\n            logger.error(f\"按数量清理截图失败: {e}\", exc_info=True)\n            return result\n\n    def _clean_by_date(self) -> dict:\n        \"\"\"按日期清理截图\n\n        Returns:\n            清理结果统计\n        \"\"\"\n        result = {\"files\": 0, \"records\": 0, \"space\": 0}\n\n        try:\n            # 计算截止日期\n            cutoff_date = get_utc_now() - timedelta(days=self.max_days)\n            logger.info(f\"开始清理 {cutoff_date.strftime('%Y-%m-%d')} 之前的截图数据\")\n\n            # 获取需要清理的截图（排除已删除文件的记录）\n            with get_session() as session:\n                old_screenshots = (\n                    session.query(Screenshot)\n                    .filter(col(Screenshot.created_at) < cutoff_date)\n                    .filter(col(Screenshot.file_deleted).is_not(True))\n                    .all()\n                )\n\n                if not old_screenshots:\n                    logger.debug(\"没有超过保留期限的截图，跳过按日期清理\")\n                    return result\n\n                logger.info(f\"找到 {len(old_screenshots)} 张过期截图\")\n\n                for screenshot in old_screenshots:\n                    deleted = self._delete_screenshot(screenshot, session)\n                    if deleted[\"success\"]:\n                        result[\"files\"] += 1\n                        result[\"space\"] += deleted[\"size\"]\n                        if not self.delete_file_only:\n                            result[\"records\"] += 1\n\n            return result\n\n        except Exception as e:\n            logger.error(f\"按日期清理截图失败: {e}\", exc_info=True)\n            return result\n\n    def _delete_screenshot(self, screenshot, session) -> dict:\n        \"\"\"删除单个截图\n\n        Args:\n            screenshot: 截图对象\n            session: 数据库会话\n\n        Returns:\n            删除结果字典\n        \"\"\"\n        result = {\"success\": False, \"size\": 0}\n\n        try:\n            # 构造完整路径\n            base_dir = str(get_user_data_dir())\n            file_path = os.path.join(base_dir, screenshot.file_path)\n\n            # 删除文件\n            if os.path.exists(file_path):\n                file_size = os.path.getsize(file_path)\n                os.remove(file_path)\n                result[\"size\"] = file_size\n                logger.debug(f\"已删除文件: {file_path}\")\n            # 检查是否已经标记为已删除\n            elif getattr(screenshot, \"file_deleted\", False):\n                logger.debug(f\"文件已在之前被删除: {file_path}\")\n            else:\n                logger.warning(f\"文件不存在: {file_path}\")\n\n            # 如果配置为同时删除记录，则从数据库中删除\n            if not self.delete_file_only:\n                session.delete(screenshot)\n                session.flush()\n                logger.debug(f\"已删除数据库记录: screenshot_id={screenshot.id}\")\n            else:\n                # 如果只删除文件，标记数据库记录为已删除（前端可根据此标识显示占位图）\n                screenshot.file_deleted = True\n                session.flush()\n                logger.debug(f\"已标记文件为已删除: screenshot_id={screenshot.id}\")\n\n            result[\"success\"] = True\n\n        except Exception as e:\n            logger.error(f\"删除截图失败 (id={screenshot.id}): {e}\")\n\n        return result\n\n\n# 全局单例\n\n\n@lru_cache(maxsize=1)\ndef get_clean_data_instance() -> CleanDataService:\n    \"\"\"获取数据清理服务单例\"\"\"\n    return CleanDataService()\n\n\ndef execute_clean_data_task():\n    \"\"\"执行数据清理任务 - 供调度器调用的可序列化函数\"\"\"\n    try:\n        logger.info(\"开始执行数据清理任务\")\n        service = get_clean_data_instance()\n        service.execute()\n        logger.info(\"数据清理任务完成\")\n\n    except Exception as e:\n        logger.error(f\"执行数据清理任务失败: {e}\", exc_info=True)\n"
  },
  {
    "path": "lifetrace/jobs/deadline_reminder.py",
    "content": "\"\"\"\nTodo 提醒调度（基于 APScheduler 的按时触发）\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom datetime import datetime, timedelta\nfrom typing import Any, cast\n\nfrom sqlalchemy import or_\n\nfrom lifetrace.jobs.scheduler import get_scheduler_manager\nfrom lifetrace.storage import todo_mgr\nfrom lifetrace.storage.models import Todo\nfrom lifetrace.storage.notification_storage import add_notification, is_notification_dismissed\nfrom lifetrace.storage.sql_utils import col\nfrom lifetrace.storage.todo_manager_utils import _normalize_reminder_offsets\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.settings import settings\nfrom lifetrace.util.time_utils import get_utc_now, naive_as_utc\n\nlogger = get_logger()\n\nMINUTES_PER_HOUR = 60\nHOURS_PER_DAY = 24\nREMINDER_JOB_PREFIX = \"todo_reminder\"\n\n\ndef _normalize_offsets(value: object | None) -> list[int]:\n    offsets = _normalize_reminder_offsets(value)\n    if not offsets:\n        return []\n    return offsets\n\n\ndef _get_field(todo: object, name: str) -> Any:\n    if isinstance(todo, dict):\n        return todo.get(name)\n    return getattr(todo, name, None)\n\n\ndef _parse_datetime(value: str | None) -> datetime | None:\n    if not value:\n        return None\n    try:\n        parsed = datetime.fromisoformat(value)\n    except (TypeError, ValueError):\n        return None\n    return naive_as_utc(parsed)\n\n\ndef _coerce_datetime(value: Any) -> datetime | None:\n    if isinstance(value, datetime):\n        return value\n    if isinstance(value, str):\n        return _parse_datetime(value)\n    return None\n\n\ndef _resolve_schedule_time(todo: object) -> datetime | None:\n    item_type_raw = _get_field(todo, \"item_type\")\n    item_type = item_type_raw.upper() if isinstance(item_type_raw, str) else \"VTODO\"\n    if item_type == \"VEVENT\":\n        return _coerce_datetime(\n            _get_field(todo, \"dtstart\")\n            or _get_field(todo, \"start_time\")\n            or _get_field(todo, \"due\")\n            or _get_field(todo, \"deadline\")\n        )\n    return _coerce_datetime(\n        _get_field(todo, \"due\")\n        or _get_field(todo, \"deadline\")\n        or _get_field(todo, \"dtstart\")\n        or _get_field(todo, \"start_time\")\n    )\n\n\ndef _format_remaining(deadline: datetime, now: datetime) -> str:\n    remaining_seconds = max(0, int((deadline - now).total_seconds()))\n    minutes = remaining_seconds // MINUTES_PER_HOUR\n    if minutes < MINUTES_PER_HOUR:\n        return f\"{minutes}分钟\"\n    hours = minutes // MINUTES_PER_HOUR\n    if hours < HOURS_PER_DAY and minutes % MINUTES_PER_HOUR == 0:\n        return f\"{hours}小时\"\n    days = hours // HOURS_PER_DAY\n    if days >= 1 and hours % HOURS_PER_DAY == 0:\n        return f\"{days}天\"\n    return f\"{minutes}分钟\"\n\n\ndef _build_reminder_job_id(todo_id: int, reminder_at: datetime) -> str:\n    return f\"{REMINDER_JOB_PREFIX}_{todo_id}_{int(reminder_at.timestamp())}\"\n\n\ndef _build_notification_id(todo_id: int, reminder_at: datetime) -> str:\n    return f\"todo_{todo_id}_reminder_{int(reminder_at.timestamp())}\"\n\n\ndef execute_todo_reminder_job(\n    todo_id: int,\n    reminder_at: str,\n    reminder_offset: int | None = None,\n) -> None:\n    \"\"\"按时触发的提醒任务（由 APScheduler 直接调度）\"\"\"\n    try:\n        todo = todo_mgr.get_todo(todo_id)\n        if not todo:\n            logger.info(\"提醒任务跳过：todo 不存在: %s\", todo_id)\n            return\n\n        if todo.get(\"status\") != \"active\":\n            logger.info(\"提醒任务跳过：todo 非 active: %s\", todo_id)\n            return\n\n        schedule_time = _resolve_schedule_time(todo)\n        if not schedule_time:\n            logger.info(\"提醒任务跳过：todo 无有效时间: %s\", todo_id)\n            return\n\n        schedule_utc = naive_as_utc(schedule_time)\n        reminder_at_dt = _parse_datetime(reminder_at) or schedule_utc\n\n        offset = reminder_offset\n        if offset is None:\n            offset = max(0, int((schedule_utc - reminder_at_dt).total_seconds() // 60))\n\n        expected_reminder_at = schedule_utc - timedelta(minutes=offset)\n        if abs((expected_reminder_at - reminder_at_dt).total_seconds()) >= 1:\n            logger.info(\n                \"提醒任务跳过：时间不匹配 todo_id=%s expected=%s actual=%s\",\n                todo_id,\n                expected_reminder_at,\n                reminder_at_dt,\n            )\n            return\n\n        if is_notification_dismissed(todo_id, reminder_at_dt):\n            logger.debug(\n                \"提醒任务跳过：通知已取消 todo_id=%s reminder_at=%s\",\n                todo_id,\n                reminder_at_dt,\n            )\n            return\n\n        now = get_utc_now()\n        remaining = _format_remaining(schedule_utc, now)\n        notification_id = _build_notification_id(todo_id, reminder_at_dt)\n\n        added = add_notification(\n            notification_id=notification_id,\n            title=todo.get(\"name\") or \"\",\n            content=f\"还有 {remaining}\",\n            timestamp=now,\n            todo_id=todo_id,\n            schedule_time=schedule_utc,\n            reminder_at=reminder_at_dt,\n            reminder_offset=offset,\n        )\n\n        if added:\n            logger.info(\n                \"生成提醒通知: todo_id=%s, name=%s, time=%s, offset=%s\",\n                todo_id,\n                todo.get(\"name\"),\n                schedule_utc,\n                offset,\n            )\n    except Exception as e:\n        logger.error(\"执行提醒任务失败: %s\", e, exc_info=True)\n\n\ndef schedule_todo_reminders(todo: object) -> list[str]:\n    \"\"\"为单个 Todo 创建按时提醒任务\"\"\"\n    todo_id = _get_field(todo, \"id\")\n    schedule_time = _resolve_schedule_time(todo)\n    offsets = _normalize_offsets(_get_field(todo, \"reminder_offsets\"))\n    scheduler = get_scheduler_manager()\n    can_schedule = (\n        settings.get(\"jobs.deadline_reminder.enabled\", False)\n        and isinstance(todo_id, int)\n        and _get_field(todo, \"status\") == \"active\"\n        and schedule_time is not None\n        and offsets\n        and scheduler\n        and scheduler.scheduler\n    )\n    if not can_schedule:\n        if isinstance(todo_id, int) and scheduler and not scheduler.scheduler:\n            logger.warning(\"调度器未初始化，跳过提醒任务创建: todo_id=%s\", todo_id)\n        return []\n\n    todo_id = cast(\"int\", todo_id)\n    schedule_time = cast(\"datetime\", schedule_time)\n\n    schedule_utc = naive_as_utc(schedule_time)\n    now = get_utc_now()\n    grace_seconds = settings.get(\"scheduler.misfire_grace_time\", 60)\n    try:\n        grace_seconds = int(grace_seconds)\n    except (TypeError, ValueError):\n        grace_seconds = 60\n\n    created_jobs: list[str] = []\n    for offset in offsets:\n        reminder_at = schedule_utc - timedelta(minutes=offset)\n        if reminder_at <= now:\n            if (now - reminder_at).total_seconds() <= grace_seconds:\n                reminder_at = now\n            else:\n                continue\n\n        job_id = _build_reminder_job_id(todo_id, reminder_at)\n        scheduler.add_date_job(\n            func=execute_todo_reminder_job,\n            job_id=job_id,\n            name=f\"todo_{todo_id}_reminder\",\n            run_date=reminder_at,\n            replace_existing=True,\n            todo_id=todo_id,\n            reminder_at=reminder_at.isoformat(),\n            reminder_offset=offset,\n        )\n        created_jobs.append(job_id)\n\n    return created_jobs\n\n\ndef remove_todo_reminder_jobs(todo_id: int) -> int:\n    \"\"\"移除指定 Todo 的所有提醒任务\"\"\"\n    scheduler = get_scheduler_manager()\n    if not scheduler or not scheduler.scheduler:\n        return 0\n\n    prefix = f\"{REMINDER_JOB_PREFIX}_{todo_id}_\"\n    removed = 0\n    for job in scheduler.get_all_jobs():\n        if job.id.startswith(prefix) and scheduler.remove_job(job.id):\n            removed += 1\n\n    if removed:\n        logger.debug(\"已移除 %s 个提醒任务: todo_id=%s\", removed, todo_id)\n    return removed\n\n\ndef refresh_todo_reminders(todo: object) -> list[str]:\n    \"\"\"刷新单个 Todo 的提醒任务（先清理再重建）\"\"\"\n    todo_id = _get_field(todo, \"id\")\n    if isinstance(todo_id, int):\n        remove_todo_reminder_jobs(todo_id)\n    return schedule_todo_reminders(todo)\n\n\ndef clear_all_todo_reminder_jobs() -> int:\n    \"\"\"清理所有按时提醒任务\"\"\"\n    scheduler = get_scheduler_manager()\n    if not scheduler or not scheduler.scheduler:\n        return 0\n\n    removed = 0\n    for job in scheduler.get_all_jobs():\n        if job.id.startswith(f\"{REMINDER_JOB_PREFIX}_\") and scheduler.remove_job(job.id):\n            removed += 1\n\n    if removed:\n        logger.info(\"清理提醒任务: %s\", removed)\n    return removed\n\n\ndef sync_all_todo_reminders() -> int:\n    \"\"\"同步所有待办的提醒任务（启动时调用）\"\"\"\n    if not settings.get(\"jobs.deadline_reminder.enabled\", False):\n        logger.info(\"DDL 提醒未启用，跳过同步\")\n        return 0\n\n    scheduler = get_scheduler_manager()\n    if not scheduler or not scheduler.scheduler:\n        logger.warning(\"调度器未初始化，跳过提醒同步\")\n        return 0\n\n    clear_all_todo_reminder_jobs()\n\n    with todo_mgr.db_base.get_session() as session:\n        todos = (\n            session.query(Todo)\n            .filter(\n                col(Todo.status) == \"active\",\n                or_(\n                    col(Todo.due).isnot(None),\n                    col(Todo.dtstart).isnot(None),\n                    col(Todo.deadline).isnot(None),\n                    col(Todo.start_time).isnot(None),\n                ),\n            )\n            .all()\n        )\n\n        created = 0\n        for todo in todos:\n            created += len(schedule_todo_reminders(todo))\n\n    logger.info(\"提醒任务同步完成: %s\", created)\n    return created\n\n\ndef execute_deadline_reminder_task() -> None:\n    \"\"\"兼容旧任务入口：执行一次提醒同步\"\"\"\n    sync_all_todo_reminders()\n"
  },
  {
    "path": "lifetrace/jobs/job_manager.py",
    "content": "# ruff: noqa: PLC0415\n\"\"\"\n后台任务管理器\n负责管理所有后台任务的启动、停止和配置更新\n\"\"\"\n\nfrom functools import lru_cache\n\nfrom lifetrace.core.module_registry import get_module_states\nfrom lifetrace.jobs.scheduler import SchedulerManager, get_scheduler_manager\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.settings import settings\n\nlogger = get_logger()\n\n\ndef _execute_capture_task():\n    from lifetrace.jobs.recorder import execute_capture_task\n\n    return execute_capture_task()\n\n\ndef _execute_todo_capture_task():\n    from lifetrace.jobs.todo_recorder import execute_todo_capture_task\n\n    return execute_todo_capture_task()\n\n\ndef _execute_ocr_task():\n    from lifetrace.jobs.ocr import execute_ocr_task\n\n    return execute_ocr_task()\n\n\ndef _execute_activity_aggregation_task():\n    from lifetrace.jobs.activity_aggregator import execute_activity_aggregation_task\n\n    return execute_activity_aggregation_task()\n\n\ndef _execute_clean_data_task():\n    from lifetrace.jobs.clean_data import execute_clean_data_task\n\n    return execute_clean_data_task()\n\n\ndef _execute_deadline_reminder_task():\n    from lifetrace.jobs.deadline_reminder import execute_deadline_reminder_task\n\n    return execute_deadline_reminder_task()\n\n\ndef _execute_proactive_ocr_task():\n    from lifetrace.jobs.proactive_ocr import execute_proactive_ocr_task\n\n    return execute_proactive_ocr_task()\n\n\ndef execute_audio_recording_status_check():\n    \"\"\"音频录制状态检查任务（用于监控录音状态）\n\n    注意：音频录制实际上由前端WebSocket控制，此任务仅用于状态监控和日志记录\n    \"\"\"\n    try:\n        # 检查配置\n        enabled = settings.get(\"jobs.audio_recording.enabled\", False)\n        audio_is_24x7 = settings.get(\"audio.is_24x7\", False)\n\n        # 如果配置开启，记录状态（实际启动由前端控制）\n        if enabled and audio_is_24x7:\n            logger.debug(\"音频录制服务已启用（由前端WebSocket控制）\")\n        else:\n            logger.debug(\"音频录制服务未启用\")\n    except Exception as e:\n        logger.error(f\"音频录制状态检查失败: {e}\", exc_info=True)\n\n\nclass JobManager:\n    \"\"\"后台任务管理器\"\"\"\n\n    def __init__(self):\n        \"\"\"初始化任务管理器\"\"\"\n        # 后台服务实例\n        self.scheduler_manager: SchedulerManager | None = None\n        self.module_states = {}\n\n        logger.info(\"任务管理器已初始化\")\n\n    def _get_scheduler(self) -> SchedulerManager | None:\n        if not self.scheduler_manager:\n            logger.warning(\"调度器未初始化，跳过任务配置\")\n            return None\n        return self.scheduler_manager\n\n    def _is_module_active(self, *module_ids: str) -> bool:\n        \"\"\"检查模块是否启用且依赖可用\"\"\"\n        if not self.module_states:\n            self.module_states = get_module_states()\n\n        for module_id in module_ids:\n            state = self.module_states.get(module_id)\n            if not state or not state.enabled or not state.available:\n                return False\n        return True\n\n    def start_all(self):\n        \"\"\"启动所有后台任务\"\"\"\n        logger.info(\"开始启动所有后台任务\")\n\n        self.module_states = get_module_states()\n\n        if not self._is_module_active(\"scheduler\"):\n            logger.warning(\"调度器模块未启用或依赖缺失，跳过后台任务启动\")\n            return\n\n        # 启动调度器\n        self._start_scheduler()\n        if not self.scheduler_manager:\n            logger.warning(\"调度器启动失败，停止后台任务初始化\")\n            return\n\n        # 启动录制器任务（事件处理已集成到录制器中，截图后立即处理）\n        self._start_recorder_job()\n\n        # 启动 Todo 专用录制器任务（与自动待办检测联动）\n        self._start_todo_recorder_job()\n\n        # 启动OCR任务\n        self._start_ocr_job()\n\n        # 启动活动聚合任务\n        self._start_activity_aggregator()\n\n        # 启动数据清理任务\n        self._start_clean_data_job()\n\n        # 启动 DDL 提醒任务\n        self._start_deadline_reminder_job()\n\n        # 启动用户自定义自动化任务\n        self._start_automation_tasks()\n\n        # 启动主动OCR任务\n        self._start_proactive_ocr_job()\n\n        # 启动音频录制状态检查任务\n        self._start_audio_recording_job()\n\n        logger.info(\"所有后台任务已启动\")\n\n    def stop_all(self):\n        \"\"\"停止所有后台任务\"\"\"\n        logger.error(\"正在停止所有后台任务\")\n\n        # 停止调度器（会自动停止所有调度任务）\n        self._stop_scheduler()\n\n        logger.error(\"所有后台任务已停止\")\n\n    def _start_scheduler(self):\n        \"\"\"启动调度器\"\"\"\n        try:\n            self.scheduler_manager = get_scheduler_manager()\n            self.scheduler_manager.start()\n            logger.info(\"调度器已启动\")\n        except Exception as e:\n            logger.error(f\"启动调度器失败: {e}\", exc_info=True)\n\n    def _stop_scheduler(self):\n        \"\"\"停止调度器\"\"\"\n        if self.scheduler_manager:\n            try:\n                logger.error(\"正在停止调度器...\")\n                self.scheduler_manager.shutdown(wait=True)\n                logger.error(\"调度器已停止\")\n            except Exception as e:\n                logger.error(f\"停止调度器失败: {e}\")\n\n    def _start_recorder_job(self):\n        \"\"\"启动录制器任务\"\"\"\n        if not self._is_module_active(\"screenshot\"):\n            logger.info(\"截图模块未启用，跳过录制器任务\")\n            return\n        enabled = settings.get(\"jobs.recorder.enabled\")\n\n        try:\n            # 仅在启用时预先初始化，避免阻塞启动\n            if enabled:\n                from lifetrace.jobs.recorder import get_recorder_instance\n\n                get_recorder_instance()\n                logger.info(\"录制器实例已初始化\")\n\n            scheduler = self._get_scheduler()\n            if not scheduler:\n                return\n\n            # 添加录制器定时任务（使用可序列化的函数，无论是否启用都添加）\n            recorder_interval = settings.get(\"jobs.recorder.interval\")\n            recorder_id = settings.get(\"jobs.recorder.id\")\n            scheduler.add_interval_job(\n                func=_execute_capture_task,  # 使用模块级别的函数\n                job_id=\"recorder_job\",\n                name=recorder_id,\n                seconds=recorder_interval,\n                replace_existing=True,\n            )\n            logger.info(f\"录制器定时任务已添加，间隔: {recorder_interval}秒\")\n\n            # 如果未启用，则暂停任务\n            if not enabled:\n                scheduler.pause_job(\"recorder_job\")\n                logger.info(\"录制器服务未启用，已暂停\")\n        except Exception as e:\n            logger.error(f\"启动录制器任务失败: {e}\", exc_info=True)\n\n    def _start_todo_recorder_job(self):\n        \"\"\"启动 Todo 专用录制器任务\n\n        此任务与自动待办检测功能联动：\n        - 仅在白名单应用激活时截图\n        - 截图后直接触发自动待办检测\n        - 与通用录制器完全独立\n        \"\"\"\n        if not self._is_module_active(\"todo_extraction\", \"todo\"):\n            logger.info(\"待办提取模块未启用，跳过 Todo 录制器任务\")\n            return\n        enabled = settings.get(\"jobs.todo_recorder.enabled\", False)\n\n        try:\n            # 仅在启用时预先初始化\n            if enabled:\n                from lifetrace.jobs.todo_recorder import get_todo_recorder_instance\n\n                get_todo_recorder_instance()\n                logger.info(\"Todo 录制器实例已初始化\")\n\n            scheduler = self._get_scheduler()\n            if not scheduler:\n                return\n\n            # 添加 Todo 录制器定时任务（无论是否启用都添加）\n            todo_recorder_interval = settings.get(\"jobs.todo_recorder.interval\", 5)\n            todo_recorder_id = settings.get(\"jobs.todo_recorder.id\", \"todo_recorder\")\n            scheduler.add_interval_job(\n                func=_execute_todo_capture_task,\n                job_id=\"todo_recorder_job\",\n                name=todo_recorder_id,\n                seconds=todo_recorder_interval,\n                replace_existing=True,\n            )\n            logger.info(f\"Todo 录制器定时任务已添加，间隔: {todo_recorder_interval}秒\")\n\n            # 如果未启用，则暂停任务\n            if not enabled:\n                scheduler.pause_job(\"todo_recorder_job\")\n                logger.info(\"Todo 录制器服务未启用，已暂停\")\n        except Exception as e:\n            logger.error(f\"启动 Todo 录制器任务失败: {e}\", exc_info=True)\n\n    def _start_ocr_job(self):\n        \"\"\"启动OCR任务\"\"\"\n        if not self._is_module_active(\"ocr\"):\n            logger.info(\"OCR 模块未启用，跳过 OCR 任务\")\n            return\n        enabled = settings.get(\"jobs.ocr.enabled\")\n\n        try:\n            scheduler = self._get_scheduler()\n            if not scheduler:\n                return\n\n            # 添加OCR定时任务（无论是否启用都添加）\n            ocr_interval = settings.get(\"jobs.ocr.interval\")\n            ocr_id = settings.get(\"jobs.ocr.id\")\n            scheduler.add_interval_job(\n                func=_execute_ocr_task,\n                job_id=\"ocr_job\",\n                name=ocr_id,\n                seconds=ocr_interval,\n                replace_existing=True,\n            )\n            logger.info(f\"OCR定时任务已添加，间隔: {ocr_interval}秒\")\n\n            # 如果未启用，则暂停任务\n            if not enabled:\n                scheduler.pause_job(\"ocr_job\")\n                logger.info(\"OCR服务未启用，已暂停\")\n        except Exception as e:\n            logger.error(f\"启动OCR任务失败: {e}\", exc_info=True)\n\n    def _start_activity_aggregator(self):\n        \"\"\"启动活动聚合任务\"\"\"\n        if not self._is_module_active(\"activity\"):\n            logger.info(\"活动模块未启用，跳过活动聚合任务\")\n            return\n        enabled = settings.get(\"jobs.activity_aggregator.enabled\")\n\n        try:\n            # 仅在启用时预先初始化\n            if enabled:\n                from lifetrace.jobs.activity_aggregator import get_aggregator_instance\n\n                get_aggregator_instance()\n                logger.info(\"活动聚合服务实例已初始化\")\n\n            scheduler = self._get_scheduler()\n            if not scheduler:\n                return\n\n            # 添加到调度器（无论是否启用都添加）\n            interval = settings.get(\"jobs.activity_aggregator.interval\")\n            aggregator_id = settings.get(\"jobs.activity_aggregator.id\")\n            scheduler.add_interval_job(\n                func=_execute_activity_aggregation_task,\n                job_id=\"activity_aggregator_job\",\n                name=aggregator_id,\n                seconds=interval,\n                replace_existing=True,\n            )\n            logger.info(f\"活动聚合定时任务已添加，间隔: {interval}秒\")\n\n            # 如果未启用，则暂停任务\n            if not enabled:\n                scheduler.pause_job(\"activity_aggregator_job\")\n                logger.info(\"活动聚合服务未启用，已暂停\")\n        except Exception as e:\n            logger.error(f\"启动活动聚合服务失败: {e}\", exc_info=True)\n\n    def _start_clean_data_job(self):\n        \"\"\"启动数据清理任务\"\"\"\n        enabled = settings.get(\"jobs.clean_data.enabled\")\n\n        try:\n            # 仅在启用时预先初始化\n            if enabled:\n                from lifetrace.jobs.clean_data import get_clean_data_instance\n\n                get_clean_data_instance()\n                logger.info(\"数据清理服务实例已初始化\")\n\n            scheduler = self._get_scheduler()\n            if not scheduler:\n                return\n\n            # 添加到调度器（无论是否启用都添加）\n            interval = settings.get(\"jobs.clean_data.interval\")\n            clean_data_id = settings.get(\"jobs.clean_data.id\")\n            scheduler.add_interval_job(\n                func=_execute_clean_data_task,\n                job_id=\"clean_data_job\",\n                name=clean_data_id,\n                seconds=interval,\n                replace_existing=True,\n            )\n            logger.info(f\"数据清理定时任务已添加，间隔: {interval}秒\")\n\n            # 如果未启用，则暂停任务\n            if not enabled:\n                scheduler.pause_job(\"clean_data_job\")\n                logger.info(\"数据清理服务未启用，已暂停\")\n        except Exception as e:\n            logger.error(f\"启动数据清理服务失败: {e}\", exc_info=True)\n\n    def _start_deadline_reminder_job(self):\n        \"\"\"启动 DDL 提醒任务\"\"\"\n        if not self._is_module_active(\"todo\", \"notification\"):\n            logger.info(\"待办/通知模块未启用，跳过 DDL 提醒任务\")\n            return\n        enabled = settings.get(\"jobs.deadline_reminder.enabled\")\n\n        try:\n            scheduler = self._get_scheduler()\n            if not scheduler:\n                return\n\n            # 清理旧的定时扫描任务（历史遗留）\n            if scheduler.get_job(\"deadline_reminder_job\"):\n                scheduler.remove_job(\"deadline_reminder_job\")\n                logger.info(\"已移除旧的 DDL 提醒扫描任务\")\n\n            if not enabled:\n                from lifetrace.jobs.deadline_reminder import clear_all_todo_reminder_jobs\n\n                clear_all_todo_reminder_jobs()\n                logger.info(\"DDL 提醒服务未启用，已清理提醒任务\")\n                return\n\n            from lifetrace.jobs.deadline_reminder import sync_all_todo_reminders\n\n            sync_all_todo_reminders()\n            logger.info(\"DDL 提醒任务已同步\")\n        except Exception as e:\n            logger.error(f\"启动 DDL 提醒任务失败: {e}\", exc_info=True)\n\n    def _start_proactive_ocr_job(self):\n        \"\"\"启动主动OCR任务\"\"\"\n        if not self._is_module_active(\"proactive_ocr\"):\n            logger.info(\"主动 OCR 模块未启用，跳过主动 OCR 任务\")\n            return\n        enabled = settings.get(\"jobs.proactive_ocr.enabled\", False)\n\n        try:\n            # 仅在启用时预先初始化\n            if enabled:\n                from lifetrace.jobs.proactive_ocr.service import get_proactive_ocr_service\n\n                get_proactive_ocr_service()\n                logger.info(\"主动OCR服务实例已初始化\")\n\n            scheduler = self._get_scheduler()\n            if not scheduler:\n                return\n\n            # 添加到调度器（无论是否启用都添加）\n            interval = settings.get(\"jobs.proactive_ocr.interval\", 1.0)\n            proactive_ocr_id = settings.get(\"jobs.proactive_ocr.id\", \"proactive_ocr\")\n            scheduler.add_interval_job(\n                func=_execute_proactive_ocr_task,\n                job_id=\"proactive_ocr_job\",\n                name=proactive_ocr_id,\n                seconds=interval,\n                replace_existing=True,\n            )\n            logger.info(f\"主动OCR定时任务已添加，间隔: {interval}秒\")\n\n            # 如果未启用，则暂停任务\n            if not enabled:\n                scheduler.pause_job(\"proactive_ocr_job\")\n                logger.info(\"主动OCR服务未启用，已暂停\")\n            else:\n                # 如果启用，立即执行一次以启动服务\n                _execute_proactive_ocr_task()\n        except Exception as e:\n            logger.error(f\"启动主动OCR任务失败: {e}\", exc_info=True)\n\n    def _start_automation_tasks(self):\n        \"\"\"启动用户自定义自动化任务\"\"\"\n        if not self._is_module_active(\"automation\", \"scheduler\"):\n            logger.info(\"自动化模块未启用，跳过自动化任务\")\n            return\n\n        scheduler = self._get_scheduler()\n        if not scheduler:\n            return\n\n        try:\n            from lifetrace.services.automation_task_service import AutomationTaskService\n\n            AutomationTaskService().sync_all_tasks()\n            logger.info(\"自动化任务同步完成\")\n        except Exception as e:\n            logger.error(f\"自动化任务同步失败: {e}\", exc_info=True)\n\n    def _start_audio_recording_job(self):\n        \"\"\"启动音频录制状态检查任务\n\n        注意：音频录制实际上由前端WebSocket控制，此任务仅用于状态监控\n        \"\"\"\n        if not self._is_module_active(\"audio\"):\n            logger.info(\"音频模块未启用，跳过音频录制状态检查任务\")\n            return\n        enabled = settings.get(\"jobs.audio_recording.enabled\", False)\n\n        try:\n            scheduler = self._get_scheduler()\n            if not scheduler:\n                return\n\n            # 添加到调度器（无论是否启用都添加）\n            interval = settings.get(\"jobs.audio_recording.interval\", 60)\n            audio_recording_id = settings.get(\"jobs.audio_recording.id\", \"audio_recording\")\n            scheduler.add_interval_job(\n                func=execute_audio_recording_status_check,\n                job_id=\"audio_recording_job\",\n                name=audio_recording_id,\n                seconds=interval,\n                replace_existing=True,\n            )\n            logger.info(f\"音频录制状态检查任务已添加，间隔: {interval}秒\")\n\n            # 如果未启用，则暂停任务\n            if not enabled:\n                scheduler.pause_job(\"audio_recording_job\")\n                logger.info(\"音频录制服务未启用，已暂停\")\n            else:\n                logger.info(\"音频录制服务已启用（由前端WebSocket控制）\")\n        except Exception as e:\n            logger.error(f\"启动音频录制任务失败: {e}\", exc_info=True)\n\n\n# 全局单例\n\n\n@lru_cache(maxsize=1)\ndef get_job_manager() -> JobManager:\n    \"\"\"获取任务管理器单例\"\"\"\n    return JobManager()\n"
  },
  {
    "path": "lifetrace/jobs/ocr.py",
    "content": "\"\"\"\nLifeTrace 简化OCR处理器\n参考 pad_ocr.py 设计，提供简单高效的OCR功能\n\"\"\"\n\nimport os\nimport time\nfrom functools import lru_cache\n\nfrom lifetrace.core.lazy_services import get_vector_service as lazy_get_vector_service\nfrom lifetrace.storage import get_session\nfrom lifetrace.storage.models import OCRResult, Screenshot\nfrom lifetrace.storage.sql_utils import col\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.path_utils import get_database_path\nfrom lifetrace.util.settings import settings\n\nfrom .ocr_config import DEFAULT_PROCESSING_DELAY, create_rapidocr_instance, get_ocr_config\nfrom .ocr_processor import (\n    RAPIDOCR_AVAILABLE,\n    SimpleOCRProcessor,\n    extract_text_from_ocr_result,\n    preprocess_image,\n    save_to_database,\n)\n\ntry:\n    from rapidocr_onnxruntime import RapidOCR\nexcept ImportError:\n    RapidOCR = None\n\n# 重新导出以保持向后兼容\n__all__ = [\n    \"RAPIDOCR_AVAILABLE\",\n    \"SimpleOCRProcessor\",\n    \"execute_ocr_task\",\n    \"get_unprocessed_screenshots\",\n    \"ocr_service\",\n    \"process_screenshot_ocr\",\n]\n\nlogger = get_logger()\n\n\ndef get_unprocessed_screenshots(logger_instance=None, limit=50):\n    \"\"\"从数据库获取未处理OCR的截图记录\n\n    Args:\n        logger_instance: 日志记录器，如果为None则使用模块级logger\n        limit: 限制返回的记录数量，避免内存溢出\n    \"\"\"\n    log = logger_instance if logger_instance is not None else logger\n\n    try:\n        with get_session() as session:\n            unprocessed = (\n                session.query(Screenshot)\n                .filter(\n                    ~col(\n                        session.query(OCRResult)\n                        .filter(col(OCRResult.screenshot_id) == col(Screenshot.id))\n                        .exists()\n                    )\n                )\n                .order_by(col(Screenshot.created_at).desc())\n                .limit(limit)\n                .all()\n            )\n\n            log.info(f\"查询到 {len(unprocessed)} 条未处理的截图记录\")\n\n            return [\n                {\n                    \"id\": screenshot.id,\n                    \"file_path\": screenshot.file_path,\n                    \"created_at\": screenshot.created_at,\n                }\n                for screenshot in unprocessed\n            ]\n    except Exception as e:\n        log.error(f\"查询未处理截图失败: {e}\")\n        return []\n\n\ndef process_screenshot_ocr(screenshot_info, ocr_engine, vector_service):\n    \"\"\"处理单个截图的OCR\"\"\"\n    screenshot_id = screenshot_info[\"id\"]\n    file_path = screenshot_info[\"file_path\"]\n\n    try:\n        if not os.path.exists(file_path):\n            return False\n\n        logger.info(f\"开始处理截图 ID {screenshot_id}: {os.path.basename(file_path)}\")\n\n        start_time = time.time()\n        img_array = preprocess_image(file_path)\n        result, _ = ocr_engine(img_array)\n        elapsed_time = time.time() - start_time\n\n        ocr_config = get_ocr_config()\n        ocr_text = extract_text_from_ocr_result(result, ocr_config[\"confidence_threshold\"])\n\n        ocr_result = {\n            \"text_content\": ocr_text,\n            \"confidence\": ocr_config[\"default_confidence\"],\n            \"language\": ocr_config[\"language\"],\n            \"processing_time\": elapsed_time,\n        }\n        save_to_database(file_path, ocr_result, vector_service)\n\n        logger.info(f\"OCR处理完成 ID {screenshot_id}, 用时: {elapsed_time:.2f}秒\")\n        return True\n\n    except Exception as e:\n        logger.error(f\"处理截图 {screenshot_id} 失败: {e}\")\n        return False\n\n\n@lru_cache(maxsize=1)\ndef _get_ocr_engine():\n    \"\"\"获取或初始化 OCR 引擎（带兜底配置）。\"\"\"\n    logger.info(\"正在初始化RapidOCR引擎...\")\n    try:\n        engine = create_rapidocr_instance()\n        logger.info(\"RapidOCR引擎初始化成功\")\n        return engine\n    except Exception as e:\n        logger.error(f\"RapidOCR初始化失败: {e}\")\n        if RapidOCR is None:\n            raise\n        try:\n            logger.info(\"尝试使用最小配置重新初始化 RapidOCR...\")\n            engine = RapidOCR(\n                config_path=None,\n                det_use_cuda=False,\n                cls_use_cuda=False,\n                rec_use_cuda=False,\n                print_verbose=False,\n            )\n            logger.info(\"RapidOCR引擎使用最小配置初始化成功\")\n            return engine\n        except Exception as fallback_error:\n            logger.error(f\"RapidOCR使用最小配置也初始化失败: {fallback_error}\")\n            raise\n\n\ndef _ensure_ocr_initialized():\n    \"\"\"确保OCR引擎已初始化（用于调度器模式）\"\"\"\n    ocr_engine = _get_ocr_engine()\n    vector_service = None\n    try:\n        logger.info(\"正在通过 lazy_services 初始化向量数据库服务...\")\n        vector_service = lazy_get_vector_service()\n        if vector_service and vector_service.is_enabled():\n            logger.info(\"向量数据库服务已启用\")\n        else:\n            logger.info(\"向量数据库服务未启用或不可用\")\n    except Exception as e:\n        logger.error(f\"初始化向量数据库服务失败: {e}\")\n        vector_service = None\n\n    return ocr_engine, vector_service\n\n\ndef execute_ocr_task():\n    \"\"\"执行一次OCR处理任务（用于调度器调用）\n\n    Returns:\n        处理成功的截图数量\n    \"\"\"\n    try:\n        ocr, vector_service = _ensure_ocr_initialized()\n        unprocessed_screenshots = get_unprocessed_screenshots(logger)\n\n        if not unprocessed_screenshots:\n            logger.debug(\"没有待处理的截图\")\n            return 0\n\n        logger.info(f\"发现 {len(unprocessed_screenshots)} 个未处理的截图\")\n\n        processed_count = 0\n        for screenshot_info in unprocessed_screenshots:\n            success = process_screenshot_ocr(screenshot_info, ocr, vector_service)\n            if success:\n                processed_count += 1\n                time.sleep(DEFAULT_PROCESSING_DELAY)\n\n        logger.info(f\"OCR任务完成，成功处理 {processed_count} 张截图\")\n        return processed_count\n\n    except Exception as e:\n        logger.error(f\"执行OCR任务失败: {e}\")\n        return 0\n\n\ndef ocr_service():\n    \"\"\"主函数 - 基于数据库驱动的OCR处理（传统模式，独立运行）\"\"\"\n    logger.info(\"LifeTrace 简化OCR处理器启动...\")\n\n    _ensure_database_initialized()\n\n    check_interval = settings.get(\"jobs.ocr.interval\")\n    ocr, vector_service = _initialize_ocr_and_vector_service()\n\n    logger.info(f\"数据库检查间隔: {check_interval}秒\")\n    logger.info(\"开始基于数据库的OCR处理...\")\n    logger.info(\"按 Ctrl+C 停止服务\")\n    logger.info(f\"OCR服务启动完成，检查间隔: {check_interval}秒\")\n\n    try:\n        _run_ocr_loop(check_interval, ocr, vector_service)\n    except KeyboardInterrupt:\n        logger.error(\"收到停止信号，结束OCR处理\")\n    except Exception as e:\n        logger.error(f\"OCR处理过程中发生错误: {e}\")\n        raise Exception(e) from e\n    finally:\n        logger.error(\"OCR服务已停止\")\n\n\ndef _ensure_database_initialized() -> None:\n    \"\"\"确保数据库已初始化，否则抛出异常。\"\"\"\n    if not get_database_path().exists():\n        raise Exception(\"数据库未初始化，无法启动OCR服务\")\n\n\ndef _initialize_ocr_and_vector_service():\n    \"\"\"初始化 RapidOCR 引擎和向量数据库服务。\"\"\"\n\n    try:\n        ocr = _get_ocr_engine()\n    except Exception as e:\n        raise Exception(e) from e\n\n    try:\n        logger.info(\"正在通过 lazy_services 初始化向量数据库服务...\")\n        vector_service = lazy_get_vector_service()\n        if vector_service and vector_service.is_enabled():\n            logger.info(\"向量数据库服务已启用\")\n        else:\n            logger.info(\"向量数据库服务未启用或不可用\")\n    except Exception as e:\n        logger.error(f\"初始化向量数据库服务失败: {e}\")\n        vector_service = None\n\n    return ocr, vector_service\n\n\ndef _run_ocr_loop(check_interval: float, ocr, vector_service) -> None:\n    \"\"\"主循环：持续从数据库读取未处理截图并执行 OCR。\"\"\"\n    processed_count = 0\n\n    while True:\n        start_time = time.time()  # noqa: F841\n\n        unprocessed_screenshots = get_unprocessed_screenshots(logger)\n\n        if unprocessed_screenshots:\n            logger.info(f\"发现 {len(unprocessed_screenshots)} 个未处理的截图\")\n\n            for screenshot_info in unprocessed_screenshots:\n                success = process_screenshot_ocr(screenshot_info, ocr, vector_service)\n                if success:\n                    processed_count += 1\n                    time.sleep(DEFAULT_PROCESSING_DELAY)\n        else:\n            time.sleep(check_interval)\n\n\nif __name__ == \"__main__\":\n    ocr_service()\n    logger.info(\"OCR服务已启动\")\n"
  },
  {
    "path": "lifetrace/jobs/ocr_config.py",
    "content": "\"\"\"\nOCR 配置模块\n包含 OCR 相关的常量、配置函数和初始化逻辑\n\"\"\"\n\nimport os\nimport sys\n\nimport yaml\n\nfrom lifetrace.util.base_paths import get_app_root, get_config_dir, get_models_dir\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.settings import settings\n\nlogger = get_logger()\n\n# OCR配置常量\nDEFAULT_IMAGE_MAX_SIZE = (1920, 1080)\nDEFAULT_CONFIDENCE = 0.8\nDEFAULT_PROCESSING_DELAY = 0.1\nMIN_CONFIDENCE_THRESHOLD = 0.5\n\n\ndef get_application_path() -> str:\n    \"\"\"获取应用程序路径，兼容PyInstaller打包\"\"\"\n    return str(get_app_root())\n\n\ndef get_rapidocr_config_path() -> str:\n    \"\"\"获取RapidOCR配置文件路径\"\"\"\n    return str(get_config_dir() / \"rapidocr_config.yaml\")\n\n\ndef setup_rapidocr_config():\n    \"\"\"设置RapidOCR配置文件路径\"\"\"\n    config_path = get_rapidocr_config_path()\n    if os.path.exists(config_path):\n        os.environ[\"RAPIDOCR_CONFIG_PATH\"] = config_path\n        logger.info(f\"设置RapidOCR配置路径: {config_path}\")\n    else:\n        logger.warning(f\"配置文件不存在: {config_path}\")\n\n\ndef get_ocr_config() -> dict:\n    \"\"\"从配置中获取OCR相关参数\n\n    Returns:\n        包含OCR配置的字典\n    \"\"\"\n    languages = settings.get(\"jobs.ocr.params.language\")\n    confidence_threshold = settings.get(\"jobs.ocr.params.confidence_threshold\")\n\n    language = languages[0] if isinstance(languages, list) and languages else \"ch\"\n    if isinstance(languages, str):\n        language = languages\n\n    return {\n        \"confidence_threshold\": confidence_threshold,\n        \"language\": language,\n        \"default_confidence\": DEFAULT_CONFIDENCE,\n    }\n\n\ndef create_rapidocr_instance():\n    \"\"\"创建并初始化RapidOCR实例\n\n    Returns:\n        RapidOCR实例\n    \"\"\"\n    rapidocr_cls = _get_rapidocr_cls()\n    if rapidocr_cls is None:\n        raise ImportError(\"RapidOCR 未安装，请运行: pip install rapidocr-onnxruntime\")\n\n    setup_rapidocr_config()\n    config_path = get_rapidocr_config_path()\n\n    # 在 PyInstaller 打包环境中，清除可能干扰的环境变量\n    if getattr(sys, \"frozen\", False) and \"RAPIDOCR_CONFIG_PATH\" in os.environ:\n        del os.environ[\"RAPIDOCR_CONFIG_PATH\"]\n\n    # 配置文件不存在时使用默认配置\n    if not os.path.exists(config_path):\n        logger.warning(f\"配置文件不存在: {config_path}，使用默认配置\")\n        return _create_default_rapidocr(rapidocr_cls)\n\n    logger.info(f\"使用RapidOCR配置文件: {config_path}\")\n\n    try:\n        with open(config_path, encoding=\"utf-8\") as f:\n            config_data = yaml.safe_load(f)\n\n        if \"Models\" not in config_data:\n            logger.info(\"未找到外部模型配置，使用默认方式\")\n            return _create_default_rapidocr_with_cleanup(rapidocr_cls)\n\n        return _create_rapidocr_with_external_models(rapidocr_cls, config_data)\n\n    except Exception as e:\n        logger.error(f\"读取配置文件失败: {e}，使用默认配置\")\n        return _create_default_rapidocr_with_cleanup(rapidocr_cls)\n\n\ndef _get_rapidocr_cls():\n    \"\"\"延迟加载 RapidOCR 类，避免在启动时导入重依赖。\"\"\"\n    try:\n        from rapidocr_onnxruntime import RapidOCR  # noqa: PLC0415\n    except ImportError:\n        return None\n    return RapidOCR\n\n\ndef _create_default_rapidocr(rapidocr_cls):\n    \"\"\"创建默认配置的RapidOCR实例\"\"\"\n    try:\n        return rapidocr_cls(\n            config_path=None,\n            det_use_cuda=False,\n            cls_use_cuda=False,\n            rec_use_cuda=False,\n            print_verbose=False,\n        )\n    except Exception as e:\n        logger.warning(f\"RapidOCR 初始化时遇到问题: {e}，尝试使用环境变量修复\")\n        if \"RAPIDOCR_CONFIG_PATH\" in os.environ:\n            del os.environ[\"RAPIDOCR_CONFIG_PATH\"]\n        return rapidocr_cls(\n            config_path=None,\n            det_use_cuda=False,\n            cls_use_cuda=False,\n            rec_use_cuda=False,\n            print_verbose=False,\n        )\n\n\ndef _create_default_rapidocr_with_cleanup(rapidocr_cls):\n    \"\"\"在PyInstaller环境中清除环境变量后创建默认配置的RapidOCR实例\"\"\"\n    if getattr(sys, \"frozen\", False) and \"RAPIDOCR_CONFIG_PATH\" in os.environ:\n        del os.environ[\"RAPIDOCR_CONFIG_PATH\"]\n    return rapidocr_cls(\n        config_path=None,\n        det_use_cuda=False,\n        cls_use_cuda=False,\n        rec_use_cuda=False,\n        print_verbose=False,\n    )\n\n\ndef _create_rapidocr_with_external_models(rapidocr_cls, config_data: dict):\n    \"\"\"使用外部模型文件创建RapidOCR实例\"\"\"\n    models_config = config_data[\"Models\"]\n    models_dir = get_models_dir()\n\n    det_model_path = str(models_dir / models_config.get(\"det_model_path\", \"\").lstrip(\"/\"))\n    rec_model_path = str(models_dir / models_config.get(\"rec_model_path\", \"\").lstrip(\"/\"))\n    cls_model_path = str(models_dir / models_config.get(\"cls_model_path\", \"\").lstrip(\"/\"))\n\n    if (\n        os.path.exists(det_model_path)\n        and os.path.exists(rec_model_path)\n        and os.path.exists(cls_model_path)\n    ):\n        logger.info(\"使用外部模型文件:\")\n        logger.info(f\"  检测模型: {det_model_path}\")\n        logger.info(f\"  识别模型: {rec_model_path}\")\n        logger.info(f\"  分类模型: {cls_model_path}\")\n\n        return rapidocr_cls(\n            det_model_path=det_model_path,\n            rec_model_path=rec_model_path,\n            cls_model_path=cls_model_path,\n            det_use_cuda=False,\n            cls_use_cuda=False,\n            rec_use_cuda=False,\n            print_verbose=False,\n        )\n    else:\n        logger.warning(\"外部模型文件不存在，使用默认配置\")\n        return _create_default_rapidocr_with_cleanup(rapidocr_cls)\n"
  },
  {
    "path": "lifetrace/jobs/ocr_processor.py",
    "content": "\"\"\"\nOCR 处理器模块\n包含 SimpleOCRProcessor 类和图像处理相关函数\n\"\"\"\n\nimport contextlib\nimport hashlib\nimport os\nimport time\nfrom typing import TYPE_CHECKING, Any\n\nfrom lifetrace.storage import get_session, ocr_mgr, screenshot_mgr\nfrom lifetrace.storage.models import OCRResult, Screenshot\nfrom lifetrace.storage.sql_utils import col\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.settings import settings\n\nfrom .ocr_config import DEFAULT_IMAGE_MAX_SIZE, create_rapidocr_instance, get_ocr_config\n\nlogger = get_logger()\n\nif TYPE_CHECKING:\n    import numpy as np\n\nRAPIDOCR_STATE: dict[str, bool | None] = {\"available\": None}\nRAPIDOCR_AVAILABLE = False\n_OCR_DEPS: dict[str, Any] = {}\n\n\ndef _set_rapidocr_available(value: bool) -> None:\n    RAPIDOCR_STATE[\"available\"] = value\n    globals()[\"RAPIDOCR_AVAILABLE\"] = value\n\n\ndef _load_ocr_deps() -> bool:\n    \"\"\"延迟加载 OCR 依赖，避免启动时阻塞。\"\"\"\n    status = RAPIDOCR_STATE[\"available\"]\n    if status is not None:\n        return bool(status)\n    try:\n        import numpy as np  # noqa: PLC0415\n        from PIL import Image  # noqa: PLC0415\n        from rapidocr_onnxruntime import RapidOCR  # noqa: PLC0415\n    except ImportError:\n        _set_rapidocr_available(False)\n        logger.error(\"RapidOCR 未安装！请运行: pip install rapidocr-onnxruntime\")\n        return False\n\n    _OCR_DEPS[\"np\"] = np\n    _OCR_DEPS[\"Image\"] = Image\n    _OCR_DEPS[\"RapidOCR\"] = RapidOCR\n    _set_rapidocr_available(True)\n    return True\n\n\ndef preprocess_image(image_path: str) -> \"np.ndarray\":\n    \"\"\"预处理图像，转换为RGB并缩放到合适大小\n\n    Args:\n        image_path: 图像文件路径\n\n    Returns:\n        预处理后的图像数组\n    \"\"\"\n    if not _load_ocr_deps():\n        raise RuntimeError(\"RapidOCR 未安装，无法处理图像\")\n    pil_image = _OCR_DEPS[\"Image\"]\n    np = _OCR_DEPS[\"np\"]\n\n    with pil_image.open(image_path) as image:\n        rgb_image = image.convert(\"RGB\")\n        rgb_image.thumbnail(DEFAULT_IMAGE_MAX_SIZE, pil_image.Resampling.LANCZOS)\n        return np.array(rgb_image)\n\n\ndef extract_text_from_ocr_result(result, confidence_threshold: float | None = None) -> str:\n    \"\"\"从OCR结果中提取文本内容\n\n    Args:\n        result: OCR识别结果\n        confidence_threshold: 置信度阈值，如果为None则从配置读取\n\n    Returns:\n        提取的文本内容\n    \"\"\"\n    if confidence_threshold is None:\n        raw_threshold = settings.get(\"jobs.ocr.params.confidence_threshold\")\n        confidence_threshold = float(raw_threshold) if raw_threshold is not None else 0.0\n\n    min_ocr_result_fields = 3\n\n    ocr_text = \"\"\n    if result:\n        for item in result:\n            if len(item) >= min_ocr_result_fields:\n                text = item[1]\n                confidence = float(item[2])\n                if text and text.strip() and confidence > confidence_threshold:\n                    ocr_text += text.strip() + \"\\n\"\n\n    return ocr_text\n\n\nclass SimpleOCRProcessor:\n    \"\"\"简化的OCR处理器类\"\"\"\n\n    def __init__(self):\n        self.ocr = None\n        self.vector_service = None\n        self.is_running = False\n\n    def is_available(self):\n        \"\"\"检查OCR引擎是否可用\"\"\"\n        return _load_ocr_deps()\n\n    def start(self):\n        \"\"\"启动OCR处理服务\"\"\"\n        self.is_running = True\n\n    def stop(self):\n        \"\"\"停止OCR处理服务\"\"\"\n        self.is_running = False\n\n    def get_statistics(self):\n        \"\"\"获取OCR处理统计信息\"\"\"\n        try:\n            with get_session() as session:\n                total_screenshots = session.query(Screenshot).count()\n                ocr_results = session.query(OCRResult).count()\n                unprocessed = total_screenshots - ocr_results\n\n                return {\n                    \"status\": \"running\" if self.is_running else \"stopped\",\n                    \"total_screenshots\": total_screenshots,\n                    \"processed\": ocr_results,\n                    \"unprocessed\": unprocessed,\n                    \"interval\": settings.get(\"jobs.ocr.interval\"),\n                }\n        except Exception as e:\n            logger.error(f\"获取OCR统计信息失败: {e}\")\n            return {\"status\": \"error\", \"error\": str(e)}\n\n    def _ensure_ocr_initialized(self):\n        \"\"\"确保OCR引擎已初始化\"\"\"\n        if self.ocr is None:\n            self.ocr = create_rapidocr_instance()\n\n    def process_image(self, image_path):\n        \"\"\"处理单个图像文件\"\"\"\n        try:\n            self._ensure_ocr_initialized()\n            if self.ocr is None:\n                raise RuntimeError(\"OCR engine is not initialized.\")\n\n            start_time = time.time()\n            img_array = preprocess_image(image_path)\n            result, _ = self.ocr(img_array)\n            processing_time = time.time() - start_time\n\n            ocr_config = get_ocr_config()\n            ocr_text = extract_text_from_ocr_result(result, ocr_config[\"confidence_threshold\"])\n\n            ocr_result = {\n                \"text_content\": ocr_text,\n                \"confidence\": ocr_config[\"default_confidence\"],\n                \"language\": ocr_config[\"language\"],\n                \"processing_time\": processing_time,\n            }\n\n            save_to_database(image_path, ocr_result, self.vector_service)\n\n            return {\n                \"success\": True,\n                \"text_content\": ocr_text,\n                \"processing_time\": processing_time,\n            }\n\n        except Exception as e:\n            logger.error(f\"处理图像失败: {e}\")\n            return {\"success\": False, \"error\": str(e)}\n\n\ndef save_to_database(image_path: str, ocr_result: dict, vector_service=None):\n    \"\"\"保存OCR结果到数据库\"\"\"\n    try:\n        screenshot = screenshot_mgr.get_screenshot_by_path(image_path)\n        if not screenshot:\n            logger.info(f\"为外部截图文件创建数据库记录: {image_path}\")\n            screenshot_id = create_screenshot_record(image_path)\n            if not screenshot_id:\n                logger.warning(f\"无法为外部文件创建截图记录: {image_path}\")\n                return\n        else:\n            screenshot_id = screenshot[\"id\"]\n\n        ocr_result_id = ocr_mgr.add_ocr_result(\n            screenshot_id=screenshot_id,\n            text_content=ocr_result[\"text_content\"],\n            confidence=ocr_result[\"confidence\"],\n            language=ocr_result.get(\"language\", \"ch\"),\n            processing_time=ocr_result[\"processing_time\"],\n        )\n\n        screenshot_mgr.update_screenshot_processed(screenshot_id)\n\n        if vector_service and vector_service.is_enabled() and ocr_result_id:\n            _add_to_vector_database(ocr_result_id, screenshot_id, vector_service)\n\n    except Exception as e:\n        logger.error(f\"保存OCR结果到数据库失败: {e}\")\n\n\ndef _add_to_vector_database(ocr_result_id: int, screenshot_id: int, vector_service):\n    \"\"\"将OCR结果添加到向量数据库\"\"\"\n    try:\n        with get_session() as session:\n            ocr_obj = session.query(OCRResult).filter(col(OCRResult.id) == ocr_result_id).first()\n            screenshot_obj = (\n                session.query(Screenshot).filter(col(Screenshot.id) == screenshot_id).first()\n            )\n\n            if ocr_obj:\n                success = vector_service.add_ocr_result(ocr_obj, screenshot_obj)\n                if success:\n                    logger.debug(f\"OCR结果已添加到向量数据库: {ocr_result_id}\")\n                else:\n                    logger.warning(f\"向量数据库添加失败: {ocr_result_id}\")\n\n            if screenshot_obj and getattr(screenshot_obj, \"event_id\", None):\n                with contextlib.suppress(Exception):\n                    vector_service.upsert_event_document(screenshot_obj.event_id)\n    except Exception as ve:\n        logger.error(f\"向量数据库操作失败: {ve}\")\n\n\ndef create_screenshot_record(image_path: str):\n    \"\"\"为外部截图文件创建数据库记录\"\"\"\n    try:\n        if not os.path.exists(image_path):\n            return None\n\n        if not _load_ocr_deps():\n            raise RuntimeError(\"RapidOCR 未安装，无法处理截图\")\n        pil_image = _OCR_DEPS[\"Image\"]\n\n        with open(image_path, \"rb\") as f:\n            file_hash = hashlib.md5(f.read(), usedforsecurity=False).hexdigest()\n\n        try:\n            with pil_image.open(image_path) as img:\n                width, height = img.size\n        except Exception:\n            width, height = 0, 0\n\n        filename = os.path.basename(image_path)\n        app_name = \"外部工具\"\n        window_title = filename\n\n        if filename.startswith(\"Snipaste_\"):\n            app_name = \"Snipaste\"\n            window_title = f\"Snipaste截图 - {filename}\"\n\n        screenshot_id = screenshot_mgr.add_screenshot(\n            file_path=image_path,\n            file_hash=file_hash,\n            width=width,\n            height=height,\n            metadata={\n                \"screen_id\": 0,\n                \"app_name\": app_name,\n                \"window_title\": window_title,\n            },\n        )\n\n        return screenshot_id\n\n    except Exception as e:\n        logger.error(f\"创建外部截图记录失败: {e}\")\n        return None\n"
  },
  {
    "path": "lifetrace/jobs/proactive_ocr/__init__.py",
    "content": "\"\"\"Proactive OCR module for detecting and processing WeChat/Feishu windows\"\"\"\n\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.settings import settings\n\nfrom .service import get_proactive_ocr_service\n\n__all__ = [\"get_proactive_ocr_service\"]\n\n\ndef execute_proactive_ocr_task():\n    \"\"\"\n    执行主动OCR任务\n    由调度器定期调用，检查配置并启动/停止服务\n    \"\"\"\n    logger = get_logger()\n    service = get_proactive_ocr_service()\n    enabled = settings.get(\"jobs.proactive_ocr.enabled\", False)\n\n    # 如果配置启用但服务未运行，启动服务\n    if enabled and not service.is_running:\n        try:\n            service.start()\n            logger.info(\"ProactiveOCR: Task triggered service start\")\n        except Exception as e:\n            logger.error(f\"ProactiveOCR: Failed to start service: {e}\", exc_info=True)\n    # 如果配置禁用但服务正在运行，停止服务\n    elif not enabled and service.is_running:\n        try:\n            service.stop()\n            logger.info(\"ProactiveOCR: Task triggered service stop\")\n        except Exception as e:\n            logger.error(f\"ProactiveOCR: Failed to stop service: {e}\", exc_info=True)\n"
  },
  {
    "path": "lifetrace/jobs/proactive_ocr/capture.py",
    "content": "\"\"\"\n屏幕捕获模块\n负责从目标窗口捕获画面帧\n支持两种模式：\n1. PrintWindow API - 可以捕获被遮挡的窗口（推荐，仅Windows）\n2. MSS屏幕捕获 - 基于屏幕坐标，窗口被遮挡时会有问题（跨平台）\n\"\"\"\n\nimport contextlib\nimport importlib\nimport platform\nimport shutil\nimport subprocess  # nosec B404\nimport sys\nimport time\nimport uuid\nfrom typing import Any, cast\n\nimport numpy as np\n\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.utils import _get_macos_active_window_bounds, get_active_window_info\n\nfrom .models import BBox, FrameEvent, ImageFrame, WindowMeta\n\nlogger = get_logger()\n\ntry:\n    import mss\n    import mss.tools\nexcept ImportError:\n    mss = None\n    logger.warning(\"mss not available, window capture will be limited\")\n\n# Windows-specific imports\nif sys.platform == \"win32\":\n    try:\n        from ctypes import c_void_p, windll\n\n        import win32con\n        import win32gui\n        import win32process\n\n        win32ui = importlib.import_module(\"win32ui\")\n\n        WIN32_AVAILABLE = True\n    except ImportError:\n        c_void_p = None\n        windll = None\n        win32con = None\n        win32gui = None\n        win32process = None\n        win32ui = None\n        WIN32_AVAILABLE = False\n        logger.warning(\"pywin32 not available, PrintWindow capture disabled\")\nelse:\n    c_void_p = None\n    windll = None\n    win32con = None\n    win32gui = None\n    win32process = None\n    win32ui = None\n    WIN32_AVAILABLE = False\n\ntry:\n    import psutil\nexcept ImportError:\n    psutil = None\n    logger.warning(\"psutil not available, process name detection disabled\")\n\n# Constants\nBGRA_CHANNELS = 4\n\n\n# 设置DPI感知，解决高DPI缩放问题（仅Windows）\ndef set_dpi_awareness():\n    \"\"\"设置进程DPI感知模式\"\"\"\n    if not WIN32_AVAILABLE or windll is None or c_void_p is None:\n        return\n\n    # Windows 10 1607+ 使用 SetProcessDpiAwarenessContext\n    # DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = -4\n    with contextlib.suppress(Exception):\n        windll.user32.SetProcessDpiAwarenessContext(c_void_p(-4))\n        return\n\n    # Windows 8.1+ 使用 SetProcessDpiAwareness\n    # PROCESS_PER_MONITOR_DPI_AWARE = 2\n    with contextlib.suppress(Exception):\n        windll.shcore.SetProcessDpiAwareness(2)\n        return\n\n    # Windows Vista+ 使用 SetProcessDPIAware\n    with contextlib.suppress(Exception):\n        windll.user32.SetProcessDPIAware()\n\n\n# 在模块加载时设置DPI感知（仅Windows）\nif WIN32_AVAILABLE:\n    set_dpi_awareness()\n\n\nclass WindowCapture:\n    \"\"\"跨平台窗口捕获类\"\"\"\n\n    def __init__(self, fps: float = 1.0):\n        \"\"\"\n        初始化捕获器\n\n        Args:\n            fps: 帧率，默认1fps\n        \"\"\"\n        self.fps = fps\n        self.interval = 1.0 / fps\n        self.last_capture_time = 0\n        self._sct = None\n        self.platform = platform.system()\n\n    def _get_mss(self):\n        \"\"\"获取mss实例\"\"\"\n        if mss is None:\n            return None\n        if self._sct is None:\n            self._sct = mss.mss()\n        return self._sct\n\n    def get_all_windows(self) -> list[WindowMeta]:\n        \"\"\"获取所有可见窗口（仅Windows）\"\"\"\n        if not WIN32_AVAILABLE or win32gui is None or win32process is None:\n            logger.warning(\"get_all_windows: Windows-only feature, returning empty list\")\n            return []\n\n        win32gui_local = cast(\"Any\", win32gui)\n        win32process_local = cast(\"Any\", win32process)\n        windows = []\n\n        def enum_callback(hwnd, results):\n            if win32gui_local.IsWindowVisible(hwnd):\n                title = win32gui_local.GetWindowText(hwnd)\n                if title:  # 只获取有标题的窗口\n                    try:\n                        rect = win32gui_local.GetWindowRect(hwnd)\n                        _, pid = win32process_local.GetWindowThreadProcessId(hwnd)\n\n                        # 获取进程名\n                        process_name = \"\"\n                        if psutil:\n                            try:\n                                process = psutil.Process(pid)\n                                process_name = process.name()\n                            except (psutil.NoSuchProcess, psutil.AccessDenied):\n                                pass\n\n                        # 检查是否最小化\n                        is_minimized = bool(win32gui_local.IsIconic(hwnd))\n\n                        window_meta = WindowMeta(\n                            hwnd=hwnd,\n                            title=title,\n                            process_name=process_name,\n                            pid=pid,\n                            rect=BBox(\n                                x=rect[0],\n                                y=rect[1],\n                                width=rect[2] - rect[0],\n                                height=rect[3] - rect[1],\n                            ),\n                            is_visible=True,\n                            is_minimized=is_minimized,\n                        )\n                        results.append(window_meta)\n                    except Exception as e:\n                        logger.debug(f\"Failed to get window info for hwnd {hwnd}: {e}\")\n            return True\n\n        win32gui_local.EnumWindows(enum_callback, windows)\n        return windows\n\n    def get_foreground_window(self) -> WindowMeta | None:\n        \"\"\"获取当前前台窗口（跨平台）\"\"\"\n        if self.platform == \"Windows\" and WIN32_AVAILABLE:\n            return self._get_windows_foreground_window()\n        elif self.platform == \"Darwin\":  # macOS\n            return self._get_macos_foreground_window()\n        elif self.platform == \"Linux\":\n            return self._get_linux_foreground_window()\n        else:\n            logger.warning(f\"Unsupported platform: {self.platform}\")\n            return None\n\n    def _get_windows_foreground_window(self) -> WindowMeta | None:\n        \"\"\"获取Windows前台窗口\"\"\"\n        if not WIN32_AVAILABLE or win32gui is None or win32process is None:\n            return None\n\n        try:\n            win32gui_local = cast(\"Any\", win32gui)\n            win32process_local = cast(\"Any\", win32process)\n            hwnd = win32gui_local.GetForegroundWindow()\n            if not hwnd:\n                return None\n\n            title = win32gui_local.GetWindowText(hwnd)\n            rect = win32gui_local.GetWindowRect(hwnd)\n            _, pid = win32process_local.GetWindowThreadProcessId(hwnd)\n\n            # 获取进程名\n            process_name = \"\"\n            if psutil:\n                try:\n                    process = psutil.Process(pid)\n                    process_name = process.name()\n                except (psutil.NoSuchProcess, psutil.AccessDenied):\n                    pass\n\n            return WindowMeta(\n                hwnd=hwnd,\n                title=title,\n                process_name=process_name,\n                pid=pid,\n                rect=BBox(\n                    x=rect[0],\n                    y=rect[1],\n                    width=rect[2] - rect[0],\n                    height=rect[3] - rect[1],\n                ),\n                is_visible=True,\n                is_minimized=bool(win32gui_local.IsIconic(hwnd)),\n            )\n        except Exception as e:\n            logger.error(f\"Failed to get Windows foreground window: {e}\")\n            return None\n\n    def _get_macos_foreground_window(self) -> WindowMeta | None:\n        \"\"\"获取macOS前台窗口\"\"\"\n        try:\n            # 获取活跃应用和窗口信息\n            app_name, window_title = get_active_window_info()\n            if not app_name:\n                return None\n\n            # 获取窗口边界\n            bounds = _get_macos_active_window_bounds(app_name)\n            if not bounds:\n                # 如果没有找到窗口边界，使用默认值\n                bounds = {\"X\": 0, \"Y\": 0, \"Width\": 800, \"Height\": 600}\n\n            # 获取进程ID\n            pid = 0\n            if psutil:\n                with contextlib.suppress(Exception):\n                    for proc in psutil.process_iter([\"pid\", \"name\"]):\n                        if proc.info[\"name\"] == app_name or proc.info[\"name\"] == f\"{app_name}.app\":\n                            pid = proc.info[\"pid\"]\n                            break\n\n            # macOS没有hwnd，使用pid作为标识\n            return WindowMeta(\n                hwnd=pid,  # 使用pid作为标识符\n                title=window_title or \"\",\n                process_name=app_name,\n                pid=pid,\n                rect=BBox(\n                    x=int(bounds.get(\"X\", 0)),\n                    y=int(bounds.get(\"Y\", 0)),\n                    width=int(bounds.get(\"Width\", 800)),\n                    height=int(bounds.get(\"Height\", 600)),\n                ),\n                is_visible=True,\n                is_minimized=False,  # macOS难以检测最小化状态\n            )\n        except ImportError as e:\n            logger.warning(f\"macOS dependencies not available: {e}\")\n            return None\n        except Exception as e:\n            logger.error(f\"Failed to get macOS foreground window: {e}\")\n            return None\n\n    def _get_linux_foreground_window(self) -> WindowMeta | None:  # noqa: C901\n        \"\"\"获取Linux前台窗口\"\"\"\n        try:\n            # 获取活跃窗口信息\n            app_name, window_title = get_active_window_info()\n            if not app_name:\n                return None\n\n            # 获取窗口位置和大小\n            try:\n                # 使用xdotool获取活跃窗口\n                xdotool_path = shutil.which(\"xdotool\")\n                if not xdotool_path:\n                    raise FileNotFoundError(\"xdotool not found\")\n                result = subprocess.run(  # nosec B603\n                    [xdotool_path, \"getactivewindow\", \"getwindowgeometry\"],\n                    capture_output=True,\n                    text=True,\n                    timeout=2,\n                    check=False,\n                )\n                if result.returncode == 0:\n                    # 解析窗口几何信息\n                    geometry = {}\n                    for line in result.stdout.split(\"\\n\"):\n                        if \"Position:\" in line:\n                            pos = line.split(\"Position:\")[1].strip().split()[0]\n                            x, y = map(int, pos.split(\",\"))\n                            geometry[\"x\"] = x\n                            geometry[\"y\"] = y\n                        elif \"Geometry:\" in line:\n                            size = line.split(\"Geometry:\")[1].strip().split()[0]\n                            width, height = map(int, size.split(\"x\"))\n                            geometry[\"width\"] = width\n                            geometry[\"height\"] = height\n\n                    # 获取窗口ID\n                    wid_result = subprocess.run(  # nosec B603\n                        [xdotool_path, \"getactivewindow\"],\n                        capture_output=True,\n                        text=True,\n                        timeout=2,\n                        check=False,\n                    )\n                    window_id = int(wid_result.stdout.strip()) if wid_result.returncode == 0 else 0\n\n                    # 获取进程ID\n                    pid = 0\n                    if psutil:\n                        with contextlib.suppress(Exception):\n                            for proc in psutil.process_iter([\"pid\", \"name\"]):\n                                if proc.info[\"name\"].lower() == app_name.lower():\n                                    pid = proc.info[\"pid\"]\n                                    break\n\n                    return WindowMeta(\n                        hwnd=window_id,\n                        title=window_title or \"\",\n                        process_name=app_name,\n                        pid=pid,\n                        rect=BBox(\n                            x=geometry.get(\"x\", 0),\n                            y=geometry.get(\"y\", 0),\n                            width=geometry.get(\"width\", 800),\n                            height=geometry.get(\"height\", 600),\n                        ),\n                        is_visible=True,\n                        is_minimized=False,  # Linux难以检测最小化状态\n                    )\n            except (subprocess.TimeoutExpired, subprocess.SubprocessError, FileNotFoundError):\n                # xdotool不可用，使用默认值\n                logger.debug(\"xdotool not available, using default window bounds\")\n                return WindowMeta(\n                    hwnd=0,\n                    title=window_title or \"\",\n                    process_name=app_name,\n                    pid=0,\n                    rect=BBox(x=0, y=0, width=800, height=600),\n                    is_visible=True,\n                    is_minimized=False,\n                )\n        except Exception as e:\n            logger.error(f\"Failed to get Linux foreground window: {e}\")\n            return None\n\n    def capture_window(self, window: WindowMeta, use_printwindow: bool = True) -> ImageFrame | None:\n        \"\"\"\n        捕获指定窗口的画面\n\n        Args:\n            window: 窗口元数据\n            use_printwindow: 是否使用PrintWindow API（可捕获被遮挡窗口，仅Windows）\n\n        Returns:\n            图像帧，如果捕获失败返回None\n        \"\"\"\n        if use_printwindow and WIN32_AVAILABLE:\n            return self._capture_with_printwindow(window)\n        else:\n            return self._capture_with_mss(window)\n\n    def _capture_with_printwindow(self, window: WindowMeta) -> ImageFrame | None:\n        \"\"\"\n        使用PrintWindow API捕获完整窗口（可捕获被遮挡的窗口，仅Windows）\n        \"\"\"\n        if (\n            not WIN32_AVAILABLE\n            or win32gui is None\n            or win32ui is None\n            or win32con is None\n            or windll is None\n        ):\n            return self._capture_with_mss(window)\n\n        try:\n            win32gui_local = cast(\"Any\", win32gui)\n            win32ui_local = cast(\"Any\", win32ui)\n            hwnd = window.hwnd\n\n            # 获取完整窗口大小（包含标题栏和边框）\n            left, top, right, bottom = win32gui_local.GetWindowRect(hwnd)\n            width = right - left\n            height = bottom - top\n\n            if width <= 0 or height <= 0:\n                logger.warning(f\"Invalid window size: {width}x{height}\")\n                return self._capture_with_mss(window)\n\n            # 创建设备上下文 - 使用GetWindowDC获取整个窗口的DC\n            hwnd_dc = win32gui_local.GetWindowDC(hwnd)\n            mfc_dc = win32ui_local.CreateDCFromHandle(hwnd_dc)\n            save_dc = mfc_dc.CreateCompatibleDC()\n\n            # 创建位图\n            bitmap = win32ui_local.CreateBitmap()\n            bitmap.CreateCompatibleBitmap(mfc_dc, width, height)\n            save_dc.SelectObject(bitmap)\n\n            # 使用PrintWindow捕获窗口内容\n            # PW_RENDERFULLCONTENT = 2，可以捕获DWM合成的内容（包含透明效果等）\n            result = windll.user32.PrintWindow(hwnd, save_dc.GetSafeHdc(), 2)\n\n            if result == 0:\n                # PrintWindow失败，尝试用BitBlt从屏幕DC复制\n                save_dc.BitBlt((0, 0), (width, height), mfc_dc, (0, 0), win32con.SRCCOPY)\n\n            # 获取位图数据\n            bmp_info = bitmap.GetInfo()\n            bmp_str = bitmap.GetBitmapBits(True)\n\n            # 转换为numpy数组\n            img_array = np.frombuffer(bmp_str, dtype=np.uint8)\n            img_array = img_array.reshape((bmp_info[\"bmHeight\"], bmp_info[\"bmWidth\"], 4))\n\n            # BGRA to RGB\n            img_array = img_array[:, :, :3]  # 去除alpha\n            img_array = img_array[:, :, ::-1]  # BGR to RGB\n\n            # 清理资源\n            win32gui_local.DeleteObject(bitmap.GetHandle())\n            save_dc.DeleteDC()\n            mfc_dc.DeleteDC()\n            win32gui_local.ReleaseDC(hwnd, hwnd_dc)\n\n            frame = ImageFrame(\n                data=img_array,\n                width=width,\n                height=height,\n                timestamp_ms=int(time.time() * 1000),\n                capture_id=str(uuid.uuid4())[:8],\n            )\n\n            return frame\n\n        except Exception as e:\n            logger.warning(f\"PrintWindow capture failed: {e}, falling back to MSS\")\n            # 回退到mss捕获\n            return self._capture_with_mss(window)\n\n    def _capture_with_mss(self, window: WindowMeta) -> ImageFrame | None:\n        \"\"\"\n        使用MSS捕获屏幕区域（基于屏幕坐标，窗口被遮挡时会有问题）\n        \"\"\"\n        if mss is None:\n            logger.error(\"MSS not available, cannot capture window\")\n            return None\n\n        try:\n            sct = self._get_mss()\n            if sct is None:\n                return None\n\n            # 构建捕获区域\n            monitor = {\n                \"left\": window.rect.x,\n                \"top\": window.rect.y,\n                \"width\": window.rect.width,\n                \"height\": window.rect.height,\n            }\n\n            # 捕获屏幕\n            screenshot = sct.grab(monitor)\n\n            # 转换为numpy数组\n            img_array = np.array(screenshot)\n\n            # mss返回的是BGRA格式，转换为RGB\n            if img_array.shape[2] == BGRA_CHANNELS:\n                img_array = img_array[:, :, :3]  # 去除alpha通道\n\n            # BGR to RGB\n            img_array = img_array[:, :, ::-1]\n\n            frame = ImageFrame(\n                data=img_array,\n                width=window.rect.width,\n                height=window.rect.height,\n                timestamp_ms=int(time.time() * 1000),\n                capture_id=str(uuid.uuid4())[:8],\n            )\n\n            return frame\n\n        except Exception as e:\n            logger.error(f\"MSS capture failed: {e}\")\n            return None\n\n    def capture_frame_event(self, window: WindowMeta) -> FrameEvent | None:\n        \"\"\"\n        捕获帧事件\n\n        Args:\n            window: 窗口元数据\n\n        Returns:\n            帧事件\n        \"\"\"\n        frame = self.capture_window(window)\n        if frame is None:\n            return None\n\n        return FrameEvent(\n            frame=frame,\n            window_meta=window,\n            capture_id=frame.capture_id,\n        )\n\n    def should_capture(self) -> bool:\n        \"\"\"检查是否应该捕获（基于fps限制）\"\"\"\n        current_time = time.time()\n        if current_time - self.last_capture_time >= self.interval:\n            self.last_capture_time = current_time\n            return True\n        return False\n\n    def cleanup(self):\n        \"\"\"清理资源\"\"\"\n        if self._sct:\n            self._sct.close()\n            self._sct = None\n\n\n# 单例实例\n_capture_state: dict[str, WindowCapture | None] = {\"instance\": None}\n\n\ndef get_capture(fps: float = 1.0) -> WindowCapture:\n    \"\"\"获取捕获器单例\"\"\"\n    instance = _capture_state[\"instance\"]\n    if instance is None:\n        instance = WindowCapture(fps=fps)\n        _capture_state[\"instance\"] = instance\n    return instance\n"
  },
  {
    "path": "lifetrace/jobs/proactive_ocr/models.py",
    "content": "\"\"\"\n数据模型定义\n定义系统中使用的核心数据结构\n\"\"\"\n\nimport time\nfrom dataclasses import dataclass, field\nfrom enum import Enum\nfrom typing import Any\n\n\nclass AppType(Enum):\n    \"\"\"应用类型枚举\"\"\"\n\n    WECHAT = \"wechat\"\n    FEISHU = \"feishu\"\n    UNKNOWN = \"unknown\"\n\n\n@dataclass\nclass BBox:\n    \"\"\"边界框\"\"\"\n\n    x: int\n    y: int\n    width: int\n    height: int\n\n    def to_tuple(self) -> tuple:\n        \"\"\"转换为元组格式 (x1, y1, x2, y2)\"\"\"\n        return (self.x, self.y, self.x + self.width, self.y + self.height)\n\n    @classmethod\n    def from_tuple(cls, t: tuple) -> \"BBox\":\n        \"\"\"从元组创建 (x1, y1, x2, y2)\"\"\"\n        return cls(x=t[0], y=t[1], width=t[2] - t[0], height=t[3] - t[1])\n\n\n@dataclass\nclass WindowMeta:\n    \"\"\"窗口元数据\"\"\"\n\n    hwnd: int  # 窗口句柄\n    title: str  # 窗口标题\n    process_name: str  # 进程名\n    pid: int  # 进程ID\n    rect: BBox  # 窗口位置和大小\n    is_visible: bool = True  # 是否可见\n    is_minimized: bool = False  # 是否最小化\n\n\n@dataclass\nclass ImageFrame:\n    \"\"\"图像帧\"\"\"\n\n    data: Any  # 图像数据 (numpy array)\n    width: int\n    height: int\n    timestamp_ms: int = field(default_factory=lambda: int(time.time() * 1000))\n    capture_id: str = \"\"\n\n\n@dataclass\nclass FrameEvent:\n    \"\"\"帧事件\"\"\"\n\n    frame: ImageFrame\n    window_meta: WindowMeta\n    capture_id: str\n\n\n@dataclass\nclass RoutedFrame:\n    \"\"\"路由后的帧\"\"\"\n\n    app_id: AppType\n    frame: ImageFrame\n    window_meta: WindowMeta\n    route_reason: str = \"\"\n\n\n@dataclass\nclass OcrLine:\n    \"\"\"OCR识别的单行文本\"\"\"\n\n    text: str\n    score: float\n    bbox_px: BBox\n\n\n@dataclass\nclass OcrRawResult:\n    \"\"\"OCR原始结果\"\"\"\n\n    lines: list[OcrLine]\n    engine: str = \"rapidocr\"\n    latency_ms: float = 0\n    det_time_ms: float = 0  # 检测耗时\n    rec_time_ms: float = 0  # 识别耗时\n    cls_time_ms: float = 0  # 方向分类耗时\n    model_version: str = \"1.0\"\n    device: str = \"cpu\"\n"
  },
  {
    "path": "lifetrace/jobs/proactive_ocr/ocr_engine.py",
    "content": "\"\"\"\nOCR引擎封装模块\n基于 RapidOCR (ONNX Runtime) 的轻量级OCR实现\n\"\"\"\n\nimport re\nimport time\n\nimport numpy as np\n\nfrom lifetrace.util.logging_config import get_logger\n\nfrom .models import BBox, OcrLine, OcrRawResult\n\nlogger = get_logger()\n\ntry:\n    from rapidocr_onnxruntime import RapidOCR\n\n    RAPIDOCR_AVAILABLE = True\nexcept ImportError:\n    RapidOCR = None\n    RAPIDOCR_AVAILABLE = False\n    logger.error(\"rapidocr-onnxruntime not available\")\n\ntry:\n    import cv2\n\n    CV2_AVAILABLE = True\nexcept ImportError:\n    cv2 = None\n    CV2_AVAILABLE = False\n    logger.warning(\"opencv-python not available, image resizing disabled\")\n\n\nclass OcrEngine:\n    \"\"\"OCR引擎封装类\"\"\"\n\n    def __init__(\n        self,\n        det_limit_side_len: int = 640,\n        det_limit_type: str = \"max\",\n        rec_batch_num: int = 1,\n        use_gpu: bool = False,\n        resize_max_side: int = 0,  # 预缩放最大边长，0表示不缩放\n    ):\n        \"\"\"\n        初始化OCR引擎\n\n        Args:\n            det_limit_side_len: 检测输入图像的边长限制，减小可降低内存占用\n            det_limit_type: 边长限制类型，\"max\"限制最大边，\"min\"限制最小边\n            rec_batch_num: 识别批次大小，减小可降低内存峰值\n            use_gpu: 是否使用GPU（需要安装CUDA版本onnxruntime）\n            resize_max_side: 输入图像预缩放的最大边长，0表示不缩放\n        \"\"\"\n        if not RAPIDOCR_AVAILABLE:\n            raise ImportError(\n                \"rapidocr-onnxruntime not available. Install with: uv add rapidocr-onnxruntime\"\n            )\n        if RapidOCR is None:\n            raise ImportError(\"RapidOCR backend is not available\")\n\n        # 配置参数\n        init_params = {\n            \"det_limit_side_len\": det_limit_side_len,\n            \"det_limit_type\": det_limit_type,\n            \"rec_batch_num\": rec_batch_num,\n        }\n\n        # GPU配置\n        if use_gpu:\n            init_params[\"use_cuda\"] = True\n\n        self.engine = RapidOCR(**init_params)\n\n        self.det_limit_side_len = det_limit_side_len\n        self.det_limit_type = det_limit_type\n        self.rec_batch_num = rec_batch_num\n        self.resize_max_side = resize_max_side\n\n    def _resize_image(self, image: np.ndarray, max_side: int) -> tuple:\n        \"\"\"\n        等比例缩小图像\n\n        Returns:\n            (缩放后图像, 缩放比例)\n        \"\"\"\n        if not CV2_AVAILABLE:\n            logger.warning(\"OpenCV not available, skipping image resize\")\n            return image, 1.0\n        if cv2 is None:\n            logger.warning(\"OpenCV not available, skipping image resize\")\n            return image, 1.0\n\n        cv2_local = cv2\n\n        h, w = image.shape[:2]\n        max_dim = max(h, w)\n\n        if max_dim <= max_side:\n            return image, 1.0\n\n        scale = max_side / max_dim\n        new_w = int(w * scale)\n        new_h = int(h * scale)\n\n        resized = cv2_local.resize(image, (new_w, new_h), interpolation=cv2_local.INTER_AREA)\n        return resized, scale\n\n    def ocr(self, image: np.ndarray) -> OcrRawResult:\n        \"\"\"\n        对图像执行OCR识别\n\n        Args:\n            image: 输入图像，numpy数组，RGB格式\n\n        Returns:\n            OcrRawResult: OCR识别结果\n        \"\"\"\n        start_time = time.time()\n\n        # 预缩放图像\n        scale = 1.0\n        if self.resize_max_side > 0:\n            image, scale = self._resize_image(image, self.resize_max_side)\n\n        # 执行OCR\n        result, elapse = self.engine(image)\n\n        latency_ms = (time.time() - start_time) * 1000\n\n        # 解析 elapse 时间\n        det_time_ms = 0.0\n        rec_time_ms = 0.0\n        cls_time_ms = 0.0\n\n        if elapse:\n            # elapse 可能是字符串或字典\n            if isinstance(elapse, str):\n                # 解析字符串格式\n                det_match = re.search(r\"det[:\\s]+(\\d+\\.?\\d*)s?\", elapse)\n                rec_match = re.search(r\"rec[:\\s]+(\\d+\\.?\\d*)s?\", elapse)\n                cls_match = re.search(r\"cls[:\\s]+(\\d+\\.?\\d*)s?\", elapse)\n\n                if det_match:\n                    det_time_ms = float(det_match.group(1)) * 1000\n                if rec_match:\n                    rec_time_ms = float(rec_match.group(1)) * 1000\n                if cls_match:\n                    cls_time_ms = float(cls_match.group(1)) * 1000\n            elif isinstance(elapse, dict):\n                det_time_ms = elapse.get(\"det\", 0) * 1000\n                rec_time_ms = elapse.get(\"rec\", 0) * 1000\n                cls_time_ms = elapse.get(\"cls\", 0) * 1000\n\n        # 解析结果\n        lines = []\n        if result:\n            for item in result:\n                # item格式: [bbox, text, score]\n                # bbox格式: [[x1,y1], [x2,y1], [x2,y2], [x1,y2]]\n                bbox_points = item[0]\n                text = item[1]\n                score = item[2]\n\n                # 转换bbox为BBox格式（考虑缩放）\n                x_coords = [float(p[0]) for p in bbox_points]\n                y_coords = [float(p[1]) for p in bbox_points]\n                x_min = int(min(x_coords) / scale)\n                y_min = int(min(y_coords) / scale)\n                x_max = int(max(x_coords) / scale)\n                y_max = int(max(y_coords) / scale)\n\n                bbox = BBox(\n                    x=x_min,\n                    y=y_min,\n                    width=x_max - x_min,\n                    height=y_max - y_min,\n                )\n\n                lines.append(OcrLine(text=text, score=float(score), bbox_px=bbox))\n\n        return OcrRawResult(\n            lines=lines,\n            engine=\"rapidocr-onnxruntime\",\n            latency_ms=latency_ms,\n            det_time_ms=det_time_ms,\n            rec_time_ms=rec_time_ms,\n            cls_time_ms=cls_time_ms,\n            model_version=\"1.4.4\",\n            device=\"cpu\",\n        )\n\n    def ocr_simple(self, image: np.ndarray) -> list[tuple[str, float]]:\n        \"\"\"\n        简化版OCR，只返回文本和置信度\n\n        Args:\n            image: 输入图像\n\n        Returns:\n            [(text, score), ...] 文本和置信度列表\n        \"\"\"\n        result = self.ocr(image)\n        return [(line.text, line.score) for line in result.lines]\n\n\n# 单例实例\n_engine_state: dict[str, OcrEngine | None] = {\"instance\": None}\n\n\ndef get_ocr_engine(\n    det_limit_side_len: int = 640,\n    det_limit_type: str = \"max\",\n    rec_batch_num: int = 1,\n    resize_max_side: int = 0,\n) -> OcrEngine:\n    \"\"\"获取OCR引擎单例\"\"\"\n    instance = _engine_state[\"instance\"]\n    if instance is None:\n        instance = OcrEngine(\n            det_limit_side_len=det_limit_side_len,\n            det_limit_type=det_limit_type,\n            rec_batch_num=rec_batch_num,\n            resize_max_side=resize_max_side,\n        )\n        _engine_state[\"instance\"] = instance\n    return instance\n"
  },
  {
    "path": "lifetrace/jobs/proactive_ocr/priors/__init__.py",
    "content": "\"\"\"Application priors for ROI extraction\"\"\"\n\nfrom .registry import get_prior, list_priors, register_prior\n\n__all__ = [\"get_prior\", \"list_priors\", \"register_prior\"]\n"
  },
  {
    "path": "lifetrace/jobs/proactive_ocr/priors/base.py",
    "content": "\"\"\"\n先验配置基类\n定义应用先验的通用接口\n\"\"\"\n\nfrom abc import ABC, abstractmethod\nfrom dataclasses import dataclass\n\nimport numpy as np\n\n\n@dataclass\nclass ThemeConfig:\n    \"\"\"主题配置\"\"\"\n\n    name: str  # 主题名称: \"dark\" / \"light\"\n    chat_bg_color: tuple[int, int, int]  # 聊天区域背景色 RGB\n    color_tolerance: int = 5  # 颜色容差\n\n\n@dataclass\nclass ROIResult:\n    \"\"\"ROI 提取结果\"\"\"\n\n    image: np.ndarray  # 裁切后的图像\n    x: int  # 左边界 x 坐标\n    y: int  # 上边界 y 坐标\n    width: int  # 宽度\n    height: int  # 高度\n    theme: str  # 检测到的主题\n\n\nclass AppPrior(ABC):\n    \"\"\"应用先验基类\"\"\"\n\n    @property\n    @abstractmethod\n    def app_name(self) -> str:\n        \"\"\"应用名称\"\"\"\n        pass\n\n    @property\n    @abstractmethod\n    def themes(self) -> list[ThemeConfig]:\n        \"\"\"支持的主题列表\"\"\"\n        pass\n\n    def detect_theme(self, image: np.ndarray) -> ThemeConfig | None:\n        \"\"\"\n        检测当前图像的主题\n\n        Args:\n            image: 窗口截图 (RGB)\n\n        Returns:\n            检测到的主题配置，未匹配返回 None\n        \"\"\"\n        h, w = image.shape[:2]\n\n        # 在底部区域采样检测主题\n        sample_y = min(h - 100, h - 1)\n        sample_x = w - 50  # 右下角通常是纯背景\n\n        if sample_y < 0 or sample_x < 0:\n            return self.themes[0] if self.themes else None\n\n        # 取采样点颜色\n        pixel = image[sample_y, sample_x].astype(np.float32)\n\n        # 匹配主题\n        for theme in self.themes:\n            target = np.array(theme.chat_bg_color, dtype=np.float32)\n            if np.all(np.abs(pixel - target) <= theme.color_tolerance):\n                return theme\n\n        # 默认返回第一个主题\n        return self.themes[0] if self.themes else None\n\n    @abstractmethod\n    def extract_chat_roi(self, image: np.ndarray) -> ROIResult:\n        \"\"\"\n        提取聊天区域 ROI\n\n        Args:\n            image: 完整窗口截图 (RGB)\n\n        Returns:\n            ROI 提取结果\n        \"\"\"\n        pass\n\n    def _find_bg_left_edge(\n        self,\n        image: np.ndarray,\n        bg_color: tuple[int, int, int],\n        tolerance: int,\n        sample_heights: list[int],\n    ) -> int | None:\n        \"\"\"\n        找到背景色区域的左边界\n\n        Args:\n            image: 图像\n            bg_color: 目标背景色\n            tolerance: 颜色容差\n            sample_heights: 采样高度列表\n\n        Returns:\n            左边界 x 坐标\n        \"\"\"\n        h, _ = image.shape[:2]\n        target = np.array(bg_color, dtype=np.float32)\n\n        # 过滤有效采样高度\n        valid_heights = [y for y in sample_heights if 0 < y < h]\n        if not valid_heights:\n            valid_heights = [h // 2]\n\n        left_edges = []\n\n        for y in valid_heights:\n            row = image[y, :, :]\n            left_x = self._scan_row_left_edge(row, target, tolerance)\n            if left_x is not None:\n                left_edges.append(left_x)\n\n        return min(left_edges) if left_edges else None\n\n    def _scan_row_left_edge(\n        self, row: np.ndarray, target_color: np.ndarray, tolerance: int\n    ) -> int | None:\n        \"\"\"从右向左扫描一行，找到目标颜色区域的左边界\"\"\"\n        w = row.shape[0]\n\n        in_target_region = False\n        last_target_x = None\n\n        for x in range(w - 1, -1, -1):\n            pixel = row[x].astype(np.float32)\n            is_target = np.all(np.abs(pixel - target_color) <= tolerance)\n\n            if is_target:\n                in_target_region = True\n                last_target_x = x\n            elif in_target_region:\n                return last_target_x\n\n        if in_target_region:\n            return 0\n\n        return None\n"
  },
  {
    "path": "lifetrace/jobs/proactive_ocr/priors/feishu.py",
    "content": "\"\"\"\n飞书先验配置\n\"\"\"\n\nimport numpy as np\n\nfrom .base import AppPrior, ROIResult, ThemeConfig\n\n\nclass FeishuPrior(AppPrior):\n    \"\"\"飞书应用先验\"\"\"\n\n    @property\n    def app_name(self) -> str:\n        return \"feishu\"\n\n    @property\n    def themes(self) -> list[ThemeConfig]:\n        return [\n            ThemeConfig(\n                name=\"light\",\n                chat_bg_color=(255, 255, 255),\n                color_tolerance=10,\n            ),\n            ThemeConfig(\n                name=\"dark\",\n                chat_bg_color=(30, 30, 30),  # 估计值，需要根据实际调整\n                color_tolerance=10,\n            ),\n        ]\n\n    def extract_chat_roi(self, image: np.ndarray) -> ROIResult:\n        \"\"\"\n        提取飞书聊天区域\n\n        飞书布局类似微信：左侧列表 + 右侧聊天\n        \"\"\"\n        h, w = image.shape[:2]\n\n        # 1. 先检测当前主题\n        theme = self.detect_theme(image)\n        theme_name = theme.name if theme else \"unknown\"\n\n        # 2. 只用当前主题的背景色检测 ROI\n        split_x = None\n        if theme:\n            sample_heights = [h - 80, h - 120, h - 160]\n            split_x = self._find_bg_left_edge(\n                image,\n                bg_color=theme.chat_bg_color,\n                tolerance=theme.color_tolerance,\n                sample_heights=sample_heights,\n            )\n\n        # 兜底\n        if split_x is None:\n            split_x = int(w * 0.35)\n\n        chat_region = image[:, split_x:, :]\n\n        return ROIResult(\n            image=chat_region,\n            x=split_x,\n            y=0,\n            width=w - split_x,\n            height=h,\n            theme=theme_name,\n        )\n"
  },
  {
    "path": "lifetrace/jobs/proactive_ocr/priors/registry.py",
    "content": "\"\"\"\n先验注册表\n管理所有应用的先验配置\n\"\"\"\n\nfrom ..models import AppType\nfrom .base import AppPrior\nfrom .feishu import FeishuPrior\nfrom .wechat import WeChatPrior\n\n# 全局先验注册表\n_prior_registry: dict[AppType, AppPrior] = {}\n\n\ndef _init_default_priors():\n    \"\"\"初始化默认先验\"\"\"\n    register_prior(AppType.WECHAT, WeChatPrior())\n    register_prior(AppType.FEISHU, FeishuPrior())\n\n\ndef register_prior(app_type: AppType, prior: AppPrior):\n    \"\"\"\n    注册应用先验\n\n    Args:\n        app_type: 应用类型\n        prior: 先验配置实例\n    \"\"\"\n    _prior_registry[app_type] = prior\n\n\ndef get_prior(app_type: AppType) -> AppPrior | None:\n    \"\"\"\n    获取应用先验\n\n    Args:\n        app_type: 应用类型\n\n    Returns:\n        先验配置实例，未注册返回 None\n    \"\"\"\n    # 懒加载初始化\n    if not _prior_registry:\n        _init_default_priors()\n\n    return _prior_registry.get(app_type)\n\n\ndef list_priors() -> dict[AppType, AppPrior]:\n    \"\"\"列出所有已注册的先验\"\"\"\n    if not _prior_registry:\n        _init_default_priors()\n    return _prior_registry.copy()\n"
  },
  {
    "path": "lifetrace/jobs/proactive_ocr/priors/wechat.py",
    "content": "\"\"\"\n微信先验配置\n\"\"\"\n\nimport numpy as np\n\nfrom .base import AppPrior, ROIResult, ThemeConfig\n\n\nclass WeChatPrior(AppPrior):\n    \"\"\"微信应用先验\"\"\"\n\n    @property\n    def app_name(self) -> str:\n        return \"wechat\"\n\n    @property\n    def themes(self) -> list[ThemeConfig]:\n        return [\n            ThemeConfig(\n                name=\"dark\",\n                chat_bg_color=(25, 25, 25),\n                color_tolerance=5,\n            ),\n            ThemeConfig(\n                name=\"light\",\n                chat_bg_color=(237, 237, 237),\n                color_tolerance=5,\n            ),\n        ]\n\n    def extract_chat_roi(self, image: np.ndarray) -> ROIResult:\n        \"\"\"\n        提取微信聊天区域\n\n        微信布局：左侧联系人列表 + 右侧聊天区域\n        聊天区域背景色：深色(25,25,25) 或 亮色(237,237,237)\n        \"\"\"\n        h, w = image.shape[:2]\n\n        # 1. 先检测当前主题（在右下角采样）\n        theme = self.detect_theme(image)\n        theme_name = theme.name if theme else \"unknown\"\n\n        # 2. 只用当前主题的背景色检测 ROI\n        split_x = None\n        if theme:\n            sample_heights = [h - 80, h - 120, h - 160]\n            split_x = self._find_bg_left_edge(\n                image,\n                bg_color=theme.chat_bg_color,\n                tolerance=theme.color_tolerance,\n                sample_heights=sample_heights,\n            )\n\n        # 兜底：使用固定比例\n        if split_x is None:\n            split_x = int(w * 0.35)\n\n        # 裁切\n        chat_region = image[:, split_x:, :]\n\n        return ROIResult(\n            image=chat_region,\n            x=split_x,\n            y=0,\n            width=w - split_x,\n            height=h,\n            theme=theme_name,\n        )\n"
  },
  {
    "path": "lifetrace/jobs/proactive_ocr/roi.py",
    "content": "\"\"\"\nROI (Region of Interest) 提取模块\n使用应用先验配置裁切感兴趣的区域\n\"\"\"\n\nfrom functools import lru_cache\n\nimport numpy as np\n\nfrom .models import AppType, BBox\nfrom .priors import get_prior\nfrom .priors.base import ROIResult\n\n\nclass ROIExtractor:\n    \"\"\"ROI 提取器 - 使用先验配置\"\"\"\n\n    def extract_chat_region(self, image: np.ndarray, app_type: AppType) -> tuple[np.ndarray, BBox]:\n        \"\"\"\n        提取聊天区域\n\n        Args:\n            image: 完整窗口图像 (RGB)\n            app_type: 应用类型\n\n        Returns:\n            (裁切后的图像, 裁切区域的BBox)\n        \"\"\"\n        # 获取应用先验\n        prior = get_prior(app_type)\n\n        if prior is None:\n            # 无先验配置，返回原图\n            h, w = image.shape[:2]\n            return image, BBox(x=0, y=0, width=w, height=h)\n\n        # 使用先验提取 ROI（每次都会动态检测主题）\n        result = prior.extract_chat_roi(image)\n\n        bbox = BBox(\n            x=result.x,\n            y=result.y,\n            width=result.width,\n            height=result.height,\n        )\n\n        return result.image, bbox\n\n    def extract_with_details(self, image: np.ndarray, app_type: AppType) -> ROIResult | None:\n        \"\"\"\n        提取 ROI 并返回详细信息（包括检测到的主题）\n\n        Args:\n            image: 完整窗口图像 (RGB)\n            app_type: 应用类型\n\n        Returns:\n            ROI 提取结果，包含主题信息\n        \"\"\"\n        prior = get_prior(app_type)\n\n        if prior is None:\n            return None\n\n        return prior.extract_chat_roi(image)\n\n\n# 单例实例\n\n\n@lru_cache(maxsize=1)\ndef get_roi_extractor() -> ROIExtractor:\n    \"\"\"获取 ROI 提取器单例\"\"\"\n    return ROIExtractor()\n"
  },
  {
    "path": "lifetrace/jobs/proactive_ocr/router.py",
    "content": "\"\"\"\n应用路由模块\n识别窗口是否为微信/飞书\n\"\"\"\n\nfrom functools import lru_cache\n\nfrom .models import AppType, FrameEvent, RoutedFrame, WindowMeta\n\n# 微信相关进程名和窗口标题关键词（跨平台）\nWECHAT_PROCESS_NAMES = [\n    # Windows\n    \"weixin.exe\",  # 微信主进程 (新版)\n    \"wechat.exe\",  # 微信主进程 (旧版)\n    \"wechatappex.exe\",  # 微信小程序\n    \"wechatbrowser.exe\",  # 微信内置浏览器\n    # macOS\n    \"wechat\",  # macOS 应用名\n    \"微信\",  # macOS 可能返回中文名\n    # Linux\n    \"wechat\",\n    \"electronic-wechat\",\n]\n\nWECHAT_TITLE_KEYWORDS = [\n    \"微信\",\n    \"wechat\",\n]\n\n# 飞书相关进程名和窗口标题关键词（跨平台）\nFEISHU_PROCESS_NAMES = [\n    # Windows\n    \"feishu.exe\",\n    \"lark.exe\",\n    \"bytedance feishu\",\n    # macOS\n    \"feishu\",  # macOS 应用名\n    \"lark\",  # macOS 应用名\n    \"飞书\",  # macOS 可能返回中文名\n    # Linux\n    \"feishu\",\n    \"lark\",\n    # Electron (需要结合标题确认)\n    \"electron\",\n]\n\nFEISHU_TITLE_KEYWORDS = [\n    \"飞书\",\n    \"feishu\",\n    \"lark\",\n]\n\n\nclass AppRouter:\n    \"\"\"应用路由器\"\"\"\n\n    def __init__(self):\n        \"\"\"初始化路由器\"\"\"\n        self.wechat_processes = [p.lower() for p in WECHAT_PROCESS_NAMES]\n        self.wechat_titles = [t.lower() for t in WECHAT_TITLE_KEYWORDS]\n        self.feishu_processes = [p.lower() for p in FEISHU_PROCESS_NAMES]\n        self.feishu_titles = [t.lower() for t in FEISHU_TITLE_KEYWORDS]\n\n    def identify_app(self, window: WindowMeta) -> tuple[AppType, str]:  # noqa: C901\n        \"\"\"\n        识别窗口对应的应用\n\n        Args:\n            window: 窗口元数据\n\n        Returns:\n            (应用类型, 识别原因)\n        \"\"\"\n        process_name = window.process_name.lower()\n        title = window.title.lower()\n\n        # 优先通过进程名识别（更准确）\n        for proc in self.wechat_processes:\n            if proc in process_name:\n                return AppType.WECHAT, f\"process_match:{proc}\"\n\n        for proc in self.feishu_processes:\n            if proc in process_name:\n                # 飞书的Electron进程需要进一步通过标题确认\n                if proc == \"electron\":\n                    for keyword in self.feishu_titles:\n                        if keyword in title:\n                            return AppType.FEISHU, f\"process_electron+title:{keyword}\"\n                else:\n                    return AppType.FEISHU, f\"process_match:{proc}\"\n\n        # 通过标题识别（兜底）\n        for keyword in self.wechat_titles:\n            if keyword in title:\n                return AppType.WECHAT, f\"title_match:{keyword}\"\n\n        for keyword in self.feishu_titles:\n            if keyword in title:\n                return AppType.FEISHU, f\"title_match:{keyword}\"\n\n        return AppType.UNKNOWN, \"no_match\"\n\n    def route(self, frame_event: FrameEvent) -> RoutedFrame | None:\n        \"\"\"\n        路由帧事件\n\n        Args:\n            frame_event: 帧事件\n\n        Returns:\n            路由后的帧，如果是未知应用返回None\n        \"\"\"\n        app_type, reason = self.identify_app(frame_event.window_meta)\n\n        # 未知应用直接丢弃\n        if app_type == AppType.UNKNOWN:\n            return None\n\n        return RoutedFrame(\n            app_id=app_type,\n            frame=frame_event.frame,\n            window_meta=frame_event.window_meta,\n            route_reason=reason,\n        )\n\n    def is_target_window(self, window: WindowMeta) -> bool:\n        \"\"\"\n        检查窗口是否为目标应用\n\n        Args:\n            window: 窗口元数据\n\n        Returns:\n            是否为目标应用\n        \"\"\"\n        app_type, _ = self.identify_app(window)\n        return app_type != AppType.UNKNOWN\n\n    def filter_target_windows(self, windows: list[WindowMeta]) -> list[tuple[WindowMeta, AppType]]:\n        \"\"\"\n        筛选目标应用窗口\n\n        Args:\n            windows: 窗口列表\n\n        Returns:\n            (窗口, 应用类型) 列表\n        \"\"\"\n        results = []\n        for window in windows:\n            app_type, _ = self.identify_app(window)\n            if app_type != AppType.UNKNOWN:\n                results.append((window, app_type))\n        return results\n\n\n# 单例实例\n\n\n@lru_cache(maxsize=1)\ndef get_router() -> AppRouter:\n    \"\"\"获取路由器单例\"\"\"\n    return AppRouter()\n"
  },
  {
    "path": "lifetrace/jobs/proactive_ocr/service.py",
    "content": "\"\"\"\nProactive OCR Service\n主动检测并处理 WeChat/Feishu 窗口的 OCR 服务\n\"\"\"\n\nimport hashlib\nimport sys\nimport threading\nimport time\nfrom functools import lru_cache\nfrom typing import Any\n\nfrom PIL import Image\n\nfrom lifetrace.llm.todo_extraction_service import todo_extraction_service\nfrom lifetrace.storage import ocr_mgr, screenshot_mgr\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.path_utils import get_screenshots_dir\nfrom lifetrace.util.settings import settings\nfrom lifetrace.util.utils import ensure_dir\n\nfrom .capture import get_capture\nfrom .models import AppType\nfrom .ocr_engine import get_ocr_engine\nfrom .roi import get_roi_extractor\nfrom .router import get_router\n\nlogger = get_logger()\n\n\nclass ProactiveOCRService:\n    \"\"\"Proactive OCR 服务\"\"\"\n\n    def __init__(self):\n        self.is_running = False\n        self._monitor_thread: threading.Thread | None = None\n        self._stop_event = threading.Event()\n\n        # 从配置读取参数\n        self.interval = settings.get(\"jobs.proactive_ocr.interval\", 1.0)\n        self.use_roi = settings.get(\"jobs.proactive_ocr.use_roi\", True)\n        self.resize_max_side = settings.get(\"jobs.proactive_ocr.resize_max_side\", 800)\n        self.det_limit_side_len = settings.get(\"jobs.proactive_ocr.det_limit_side_len\", 640)\n        self.min_confidence = settings.get(\"jobs.proactive_ocr.min_confidence\", 0.8)\n\n        # 初始化组件\n        self.router = get_router()\n        self.capture = get_capture(fps=1.0 / self.interval)\n        self.roi_extractor = get_roi_extractor()\n        self.ocr_engine = get_ocr_engine(\n            det_limit_side_len=self.det_limit_side_len,\n            resize_max_side=self.resize_max_side,\n        )\n\n        # 统计信息\n        self.stats = {\n            \"total_captures\": 0,\n            \"successful_ocrs\": 0,\n            \"failed_captures\": 0,\n            \"last_capture_time\": None,\n        }\n\n        logger.info(\n            f\"ProactiveOCR: Service initialized (interval={self.interval}s, \"\n            f\"use_roi={self.use_roi}, resize_max_side={self.resize_max_side})\"\n        )\n\n    def start(self):\n        \"\"\"启动监控服务\"\"\"\n        if self.is_running:\n            logger.warning(\"ProactiveOCR: Service is already running\")\n            return\n\n        self.is_running = True\n        self._stop_event.clear()\n        self._monitor_thread = threading.Thread(\n            target=self._monitor_loop, daemon=True, name=\"ProactiveOCRMonitor\"\n        )\n        self._monitor_thread.start()\n        logger.info(\n            f\"ProactiveOCR: Service started (interval={self.interval}s, \"\n            f\"apps=['wechat', 'feishu'], platform={sys.platform})\"\n        )\n\n    def stop(self):\n        \"\"\"停止监控服务\"\"\"\n        if not self.is_running:\n            return\n\n        self.is_running = False\n        self._stop_event.set()\n\n        if self._monitor_thread and self._monitor_thread.is_alive():\n            self._monitor_thread.join(timeout=5.0)\n\n        logger.info(\"ProactiveOCR: Service stopped\")\n\n    def _monitor_loop(self):\n        \"\"\"监控循环\"\"\"\n        while self.is_running and not self._stop_event.is_set():\n            try:\n                self.run_once()\n            except Exception as e:\n                logger.error(f\"ProactiveOCR: Error in monitor loop: {e}\", exc_info=True)\n\n            # 等待间隔时间\n            self._stop_event.wait(self.interval)\n\n    def run_once(self) -> dict[str, Any] | None:\n        \"\"\"\n        执行一次检测和处理\n\n        Returns:\n            处理结果字典，如果未检测到目标窗口返回 None\n        \"\"\"\n        # 获取前台窗口（跨平台）\n        window = self.capture.get_foreground_window()\n        if not window:\n            return None\n\n        # 检查是否为目标应用\n        app_type, _reason = self.router.identify_app(window)\n        if app_type == AppType.UNKNOWN:\n            return None\n\n        logger.info(\n            f\"ProactiveOCR: Detected foreground window: hwnd={window.hwnd}, \"\n            f'app={app_type.value}, title=\"{window.title[:50]}\"'\n        )\n\n        # 检查窗口是否最小化\n        if window.is_minimized:\n            logger.debug(\"ProactiveOCR: Window is minimized, skipping capture\")\n            return None\n\n        logger.debug(f\"ProactiveOCR: Window size: {window.rect.width}x{window.rect.height}\")\n\n        # 捕获窗口截图\n        timings = {}\n        t0 = time.perf_counter()\n        frame = self.capture.capture_window(window)\n        timings[\"capture\"] = (time.perf_counter() - t0) * 1000\n\n        if frame is None:\n            logger.error(\"ProactiveOCR: Capture window failed\")\n            self.stats[\"failed_captures\"] += 1\n            return None\n\n        logger.info(\n            f\"ProactiveOCR: Capture completed in {timings['capture']:.0f}ms \"\n            f\"({frame.width}x{frame.height})\"\n        )\n\n        # ROI 裁切\n        image_to_ocr = frame.data\n        theme = None\n\n        if self.use_roi:\n            t0 = time.perf_counter()\n            roi_result = self.roi_extractor.extract_with_details(frame.data, app_type)\n            timings[\"roi\"] = (time.perf_counter() - t0) * 1000\n\n            if roi_result:\n                image_to_ocr = roi_result.image\n                theme = roi_result.theme\n                logger.info(\n                    f\"ProactiveOCR: ROI extracted - theme={theme}, \"\n                    f\"region={roi_result.width}x{roi_result.height} \"\n                    f\"(from x={roi_result.x}), time={timings['roi']:.1f}ms\"\n                )\n\n        # 执行 OCR 识别\n        logger.debug(\"ProactiveOCR: Starting OCR recognition...\")\n        t0 = time.perf_counter()\n        ocr_result = self.ocr_engine.ocr(image_to_ocr)\n        timings[\"ocr_total\"] = (time.perf_counter() - t0) * 1000\n\n        logger.info(\n            f\"ProactiveOCR: OCR completed in {timings['ocr_total']:.0f}ms \"\n            f\"(det={ocr_result.det_time_ms:.0f}ms, rec={ocr_result.rec_time_ms:.0f}ms)\"\n        )\n\n        # 过滤低置信度结果\n        valid_lines = [line for line in ocr_result.lines if line.score >= self.min_confidence]\n        logger.info(\n            f\"ProactiveOCR: Found {len(valid_lines)} text blocks \"\n            f\"(confidence >={self.min_confidence})\"\n        )\n\n        if len(valid_lines) > 0:\n            # 提取文本内容\n            text_content = \"\\n\".join([line.text for line in valid_lines])\n            logger.debug(f\"ProactiveOCR: Text preview: {text_content[:100]}...\")\n\n            # 保存截图和 OCR 结果到数据库\n            screenshot_id = self._save_to_database(\n                frame, window, app_type, text_content, ocr_result, valid_lines\n            )\n\n            if screenshot_id:\n                self.stats[\"successful_ocrs\"] += 1\n                logger.info(\n                    f\"ProactiveOCR: Saved screenshot_id={screenshot_id}, \"\n                    f\"ocr_result with {len(valid_lines)} lines\"\n                )\n\n        self.stats[\"total_captures\"] += 1\n        self.stats[\"last_capture_time\"] = time.time()\n\n        total_time = sum(timings.values())\n        logger.debug(f\"ProactiveOCR: Total time: {total_time:.0f}ms\")\n\n        return {\n            \"app_type\": app_type.value,\n            \"window_title\": window.title,\n            \"text_lines\": len(valid_lines),\n            \"timings\": timings,\n        }\n\n    def _save_to_database(\n        self,\n        frame,\n        window,\n        app_type: AppType,\n        text_content: str,\n        ocr_result,\n        valid_lines,\n    ) -> int | None:\n        \"\"\"保存截图和 OCR 结果到数据库\"\"\"\n        try:\n            # 保存图像文件\n            screenshots_dir = get_screenshots_dir()\n            ensure_dir(str(screenshots_dir))\n\n            # 生成文件名\n            timestamp = time.strftime(\"%Y%m%d_%H%M%S\")\n            filename = f\"proactive_{app_type.value}_{timestamp}_{frame.capture_id}.png\"\n            file_path = str(screenshots_dir / filename)\n\n            # 保存图像（PIL Image）\n            img = Image.fromarray(frame.data)\n            img.save(file_path)\n\n            # 计算文件哈希\n            with open(file_path, \"rb\") as f:\n                file_hash = hashlib.md5(f.read(), usedforsecurity=False).hexdigest()\n\n            # 添加截图记录\n            screenshot_id = screenshot_mgr.add_screenshot(\n                file_path=file_path,\n                file_hash=file_hash,\n                width=frame.width,\n                height=frame.height,\n                metadata={\n                    \"screen_id\": 0,\n                    \"app_name\": app_type.value,\n                    \"window_title\": window.title,\n                    \"proactive_ocr\": True,\n                    \"hwnd\": window.hwnd,\n                    \"pid\": window.pid,\n                },\n            )\n\n            if not screenshot_id:\n                logger.error(\"ProactiveOCR: Failed to save screenshot to database\")\n                return None\n\n            # 计算平均置信度\n            avg_confidence = (\n                sum(line.score for line in valid_lines) / len(valid_lines) if valid_lines else 0.0\n            )\n\n            # 添加 OCR 结果\n            ocr_result_id = ocr_mgr.add_ocr_result(\n                screenshot_id=screenshot_id,\n                text_content=text_content,\n                confidence=avg_confidence,\n                language=\"ch\",\n                processing_time=ocr_result.latency_ms / 1000.0,\n            )\n\n            if ocr_result_id:\n                logger.debug(f\"ProactiveOCR: Saved OCR result_id={ocr_result_id}\")\n                # 可选：自动触发基于 OCR 文本的待办提取\n                try:\n                    auto_extract = settings.get(\n                        \"jobs.proactive_ocr.params.auto_extract_todos\", False\n                    )\n                    min_text_length = settings.get(\n                        \"jobs.proactive_ocr.params.min_text_length\",\n                        10,\n                    )\n                    if auto_extract and len((text_content or \"\").strip()) >= min_text_length:\n                        logger.info(\n                            \"ProactiveOCR: auto_extract_todos 开启，开始基于 OCR 文本提取待办\"\n                        )\n                        # 我们仅调用提取逻辑，不在此处直接写 todo，结果由上层或日志查看\n                        extraction_result = todo_extraction_service.extract_todos_from_ocr_text(\n                            ocr_result_id=ocr_result_id,\n                            text_content=text_content,\n                            app_name=app_type.value,\n                            window_title=window.title,\n                        )\n\n                        if extraction_result.get(\"skipped\"):\n                            logger.info(\n                                \"ProactiveOCR: OCR 文本待办提取已跳过 - \"\n                                f\"reason={extraction_result.get('reason')}, \"\n                                f\"ocr_result_id={extraction_result.get('ocr_result_id')}\"\n                            )\n                        else:\n                            todos_count = len(extraction_result.get(\"todos\") or [])\n                            error_message = extraction_result.get(\"error_message\")\n                            created_count = extraction_result.get(\"created_count\")\n                            if error_message:\n                                logger.warning(\n                                    \"ProactiveOCR: OCR 文本待办提取完成但存在错误 - \"\n                                    f\"error={error_message}, \"\n                                    f\"ocr_result_id={extraction_result.get('ocr_result_id')}, \"\n                                    f\"todos_count={todos_count}, \"\n                                    f\"created_count={created_count}\"\n                                )\n                            else:\n                                logger.info(\n                                    \"ProactiveOCR: OCR 文本待办提取完成 - \"\n                                    f\"ocr_result_id={extraction_result.get('ocr_result_id')}, \"\n                                    f\"todos_count={todos_count}, \"\n                                    f\"created_count={created_count}\"\n                                )\n                except Exception as e:\n                    logger.error(f\"ProactiveOCR: 自动待办提取失败（已忽略）: {e}\", exc_info=True)\n\n                return screenshot_id\n\n            return None\n\n        except Exception as e:\n            logger.error(f\"ProactiveOCR: Failed to save to database: {e}\", exc_info=True)\n            return None\n\n    def get_status(self) -> dict[str, Any]:\n        \"\"\"获取服务状态\"\"\"\n        return {\n            \"is_running\": self.is_running,\n            \"interval\": self.interval,\n            \"use_roi\": self.use_roi,\n            \"platform\": sys.platform,\n            \"stats\": self.stats.copy(),\n        }\n\n\n# 单例实例\n\n\n@lru_cache(maxsize=1)\ndef get_proactive_ocr_service() -> ProactiveOCRService:\n    \"\"\"获取 Proactive OCR 服务单例\"\"\"\n    return ProactiveOCRService()\n"
  },
  {
    "path": "lifetrace/jobs/recorder.py",
    "content": "\"\"\"\n屏幕录制器 - 负责截图和相关处理\n\"\"\"\n\nimport argparse\nimport os\nimport time\nfrom datetime import datetime\nfrom functools import lru_cache\n\nimport mss\nfrom PIL import Image\n\nfrom lifetrace.storage import event_mgr, screenshot_mgr\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.path_utils import get_screenshots_dir\nfrom lifetrace.util.settings import settings\nfrom lifetrace.util.utils import ensure_dir, get_active_window_info, get_active_window_screen\n\nfrom .recorder_blacklist import get_blacklist_reason, log_blacklist_config\nfrom .recorder_capture import (\n    ScreenshotCapture,\n    extract_screen_id_from_path,\n    get_unprocessed_files,\n    process_screenshot_event,\n    should_detect_todos,\n    trigger_todo_detection_async,\n)\nfrom .recorder_config import UNKNOWN_APP, UNKNOWN_WINDOW, with_timeout\n\nlogger = get_logger()\n\n\nclass ScreenRecorder:\n    \"\"\"屏幕录制器\"\"\"\n\n    def __init__(self):\n        self.screenshots_dir = str(get_screenshots_dir())\n        self.interval = settings.get(\"jobs.recorder.interval\")\n        self.screens = self._get_screen_list()\n\n        # 超时配置\n        self.file_io_timeout = settings.get(\"jobs.recorder.params.file_io_timeout\")\n        self.db_timeout = settings.get(\"jobs.recorder.params.db_timeout\")\n        self.window_info_timeout = settings.get(\"jobs.recorder.params.window_info_timeout\")\n\n        # 初始化截图捕获器\n        self.capture = ScreenshotCapture(\n            screenshots_dir=self.screenshots_dir,\n            file_io_timeout=self.file_io_timeout,\n            db_timeout=self.db_timeout,\n            deduplicate=settings.get(\"jobs.recorder.params.deduplicate\"),\n            hash_threshold=settings.get(\"jobs.recorder.params.hash_threshold\"),\n        )\n\n        # 初始化截图目录\n        ensure_dir(self.screenshots_dir)\n\n        logger.info(\n            f\"超时配置 - 文件I/O: {self.file_io_timeout}s, \"\n            f\"数据库: {self.db_timeout}s, \"\n            f\"窗口信息: {self.window_info_timeout}s\"\n        )\n\n        logger.info(f\"屏幕录制器初始化完成，监控屏幕: {self.screens}\")\n\n        # 打印黑名单配置信息\n        log_blacklist_config()\n\n        # 启动时扫描未处理的文件\n        self._scan_unprocessed_files()\n\n    def _get_window_info(self) -> tuple[str, str]:\n        \"\"\"获取当前活动窗口信息\"\"\"\n\n        @with_timeout(timeout_seconds=self.window_info_timeout, operation_name=\"获取窗口信息\")\n        def _do_get_window_info():\n            return get_active_window_info()\n\n        try:\n            result = _do_get_window_info()\n            if result is not None:\n                app_name, window_title = result\n                app_name = app_name or UNKNOWN_APP\n                window_title = window_title or UNKNOWN_WINDOW\n                return (app_name, window_title)\n            return (UNKNOWN_APP, UNKNOWN_WINDOW)\n        except Exception as e:\n            logger.error(f\"获取窗口信息失败: {e}\")\n            return (UNKNOWN_APP, UNKNOWN_WINDOW)\n\n    def _get_screen_list(self) -> list[int]:\n        \"\"\"获取要截图的屏幕列表\"\"\"\n        screens_config = settings.get(\"jobs.recorder.params.screens\")\n        logger.debug(f\"屏幕配置: {screens_config}\")\n        with mss.mss() as sct:\n            monitor_count = len(sct.monitors) - 1\n\n            if screens_config == \"all\":\n                return list(range(1, monitor_count + 1))\n            elif isinstance(screens_config, list):\n                return [s for s in screens_config if 1 <= s <= monitor_count]\n            else:\n                return [1] if monitor_count > 0 else []\n\n    def _capture_screen(\n        self,\n        screen_id: int,\n        app_name: str | None = None,\n        window_title: str | None = None,\n    ) -> tuple[str | None, str]:\n        \"\"\"截取指定屏幕\n\n        Returns:\n            (file_path, status) - file_path为截图路径，status为状态: 'success', 'skipped', 'failed'\n        \"\"\"\n        try:\n            screenshot, file_path, timestamp = self.capture.grab_and_prepare_screenshot(screen_id)\n            if not screenshot:\n                return None, \"failed\"\n\n            # 优化：先从内存计算图像哈希，避免不必要的磁盘I/O\n            image_hash = self.capture.calculate_image_hash_from_memory(screenshot)\n            if not image_hash:\n                filename = os.path.basename(file_path)\n                logger.error(f\"[窗口 {screen_id}] 计算图像哈希失败，跳过: {filename}\")\n                return None, \"failed\"\n\n            # 检查是否重复\n            if self.capture.is_duplicate(screen_id, image_hash):\n                filename = os.path.basename(file_path)\n                logger.debug(f\"[窗口 {screen_id}] 检测到重复截图，跳过保存: {filename}\")\n                return None, \"skipped\"\n\n            # 更新哈希记录并保存截图\n            self.capture.last_hashes[screen_id] = image_hash\n            if not self.capture.save_screenshot(screenshot, file_path):\n                filename = os.path.basename(file_path)\n                logger.error(f\"[窗口 {screen_id}] 保存截图失败: {filename}\")\n                return None, \"failed\"\n\n            # 获取窗口信息和保存到数据库\n            app_name, window_title = self._ensure_window_info(app_name, window_title)\n            self._save_screenshot_metadata(file_path, screen_id, app_name, window_title, timestamp)\n\n            return file_path, \"success\"\n\n        except Exception as e:\n            logger.error(f\"[窗口 {screen_id}] 截图失败: {e}\")\n            return None, \"failed\"\n\n    def _ensure_window_info(\n        self,\n        app_name: str | None,\n        window_title: str | None,\n    ) -> tuple[str, str]:\n        \"\"\"确保有窗口信息，如果没有则获取\"\"\"\n        if app_name is None or window_title is None:\n            return self._get_window_info()\n        return app_name, window_title\n\n    def _save_screenshot_metadata(\n        self, file_path: str, screen_id: int, app_name: str, window_title: str, timestamp: datetime\n    ):\n        \"\"\"保存截图的元数据到数据库\"\"\"\n        filename = os.path.basename(file_path)\n\n        width, height = self.capture.get_image_size(file_path)\n        file_hash = self.capture.calculate_file_hash(file_path)\n        if not file_hash:\n            logger.warning(f\"[窗口 {screen_id}] 计算文件哈希失败，使用空值: {filename}\")\n            file_hash = \"\"\n\n        screenshot_id = self.capture.save_to_database(\n            file_path, file_hash, width, height, screen_id, app_name, window_title\n        )\n\n        if screenshot_id:\n            logger.debug(f\"[窗口 {screen_id}] 截图记录已保存到数据库: {screenshot_id}\")\n            process_screenshot_event(screenshot_id, app_name, window_title, timestamp)\n\n            if should_detect_todos(app_name):\n                trigger_todo_detection_async(screenshot_id, app_name)\n        else:\n            logger.warning(f\"[窗口 {screen_id}] 数据库保存失败，但文件已保存: {filename}\")\n\n        file_size = os.path.getsize(file_path)\n        file_size_kb = file_size / 1024\n        logger.info(f\"[窗口 {screen_id}] 截图保存: {filename} ({file_size_kb:.2f} KB) - {app_name}\")\n\n    def _close_active_event_on_blacklist(self):\n        \"\"\"当应用进入黑名单时关闭活跃事件\"\"\"\n        try:\n            event_mgr.close_active_event()\n            logger.info(\"已关闭上一个活跃事件\")\n        except Exception as e:\n            logger.error(f\"关闭活跃事件失败: {e}\")\n\n    def capture_all_screens(self) -> list[str]:\n        \"\"\"只截取活跃窗口所在的屏幕\"\"\"\n        captured_files = []\n\n        app_name, window_title = self._get_window_info()\n        active_screen_id = get_active_window_screen()\n\n        if active_screen_id is None:\n            logger.warning(\"无法获取活跃窗口所在的屏幕，跳过截图\")\n            return captured_files\n\n        if active_screen_id not in self.screens:\n            logger.info(f\"⏭️  活跃窗口在屏幕 {active_screen_id}，但该屏幕未在配置中启用，跳过截图\")\n            return captured_files\n\n        blacklist_reason = get_blacklist_reason(app_name, window_title)\n        is_blacklisted = bool(blacklist_reason)\n\n        if is_blacklisted:\n            logger.info(f\"⏭️  {blacklist_reason}（跳过截图）\")\n            self._close_active_event_on_blacklist()\n            return captured_files\n\n        logger.info(\n            f\"📸 准备截图 - 屏幕: {active_screen_id}, 应用: {app_name}, 窗口: {window_title}\"\n        )\n\n        file_path, status = self._capture_screen(active_screen_id, app_name, window_title)\n        if file_path:\n            captured_files.append(file_path)\n\n        if status == \"success\":\n            logger.info(f\"截图成功 - 屏幕: {active_screen_id}\")\n        elif status == \"skipped\":\n            logger.info(f\"截图跳过 - 屏幕: {active_screen_id}\")\n        elif status == \"failed\":\n            logger.warning(f\"截图失败 - 屏幕: {active_screen_id}\")\n\n        return captured_files\n\n    def execute_capture(self):\n        \"\"\"执行一次截图任务（用于调度器调用）\n\n        Returns:\n            捕获的文件列表\n        \"\"\"\n        try:\n            captured_files = self.capture_all_screens()\n            if captured_files:\n                logger.info(f\"✅ 本次截取了 {len(captured_files)} 张截图\")\n            else:\n                logger.info(\"⏭️  本次未截取截图（窗口被跳过或重复）\")\n            return captured_files\n        except Exception as e:\n            logger.error(f\"执行截图任务失败: {e}\")\n            return []\n\n    def start_recording(self):\n        \"\"\"开始录制（传统模式，独立运行）\"\"\"\n        logger.info(\"开始屏幕录制...\")\n\n        try:\n            while True:\n                start_time = time.time()\n\n                captured_files = self.capture_all_screens()\n\n                if captured_files:\n                    logger.debug(f\"本次截取了 {len(captured_files)} 张截图\")\n\n                elapsed = time.time() - start_time\n                sleep_time = max(0, self.interval - elapsed)\n\n                if sleep_time > 0:\n                    time.sleep(sleep_time)\n                else:\n                    logger.warning(f\"截图处理时间 ({elapsed:.2f}s) 超过间隔时间 ({self.interval}s)\")\n\n        except KeyboardInterrupt:\n            logger.error(\"收到停止信号，结束录制\")\n            self._print_final_stats()\n        except Exception as e:\n            logger.error(f\"录制过程中发生错误: {e}\")\n            self._print_final_stats()\n            raise\n        finally:\n            pass\n\n    def _process_single_file(self, file_path: str) -> bool:\n        \"\"\"处理单个未处理的截图文件，返回是否成功\"\"\"\n        if not os.path.exists(file_path):\n            return False\n\n        file_stats = os.stat(file_path)\n        if file_stats.st_size == 0:\n            logger.warning(f\"文件为空，跳过: {file_path}\")\n            return False\n\n        try:\n            with Image.open(file_path) as img:\n                width, height = img.size\n        except Exception as e:\n            logger.error(f\"无法处理图像文件 {file_path}: {e}\")\n            return False\n\n        screen_id = extract_screen_id_from_path(file_path)\n\n        file_hash = self.capture.calculate_file_hash(file_path)\n        if not file_hash:\n            filename = os.path.basename(file_path)\n            logger.warning(f\"[窗口 {screen_id}] 计算文件哈希失败，使用空值: {filename}\")\n            file_hash = \"\"\n\n        app_name, window_title = self._get_window_info()\n\n        screenshot_id = screenshot_mgr.add_screenshot(\n            file_path=file_path,\n            file_hash=file_hash,\n            width=width,\n            height=height,\n            metadata={\n                \"screen_id\": screen_id,\n                \"app_name\": app_name,\n                \"window_title\": window_title,\n            },\n        )\n\n        if screenshot_id:\n            filename = os.path.basename(file_path)\n            logger.debug(f\"[窗口 {screen_id}] 已处理未处理文件: {filename} (ID: {screenshot_id})\")\n            return True\n\n        logger.warning(f\"[窗口 {screen_id}] 添加截图记录失败: {file_path}\")\n        return False\n\n    def _scan_unprocessed_files(self):\n        \"\"\"扫描并处理未处理的截图文件\"\"\"\n        if not os.path.exists(self.screenshots_dir):\n            logger.info(\"截图目录不存在，跳过扫描\")\n            return\n\n        logger.info(f\"扫描现有截图文件: {self.screenshots_dir}\")\n\n        unprocessed_files = get_unprocessed_files(self.screenshots_dir)\n\n        if not unprocessed_files:\n            logger.info(\"未发现未处理的截图文件\")\n            return\n\n        logger.info(f\"发现 {len(unprocessed_files)} 个未处理文件，开始处理...\")\n\n        processed_count = 0\n        for file_path in unprocessed_files:\n            try:\n                if self._process_single_file(file_path):\n                    processed_count += 1\n            except Exception as e:\n                logger.error(f\"处理文件失败 {file_path}: {e}\")\n\n        logger.info(\n            f\"未处理文件扫描完成，成功处理 {processed_count}/{len(unprocessed_files)} 个文件\"\n        )\n\n    def _print_final_stats(self):\n        \"\"\"输出最终统计信息\"\"\"\n        logger.info(\"录制会话结束\")\n\n\n# 全局录制器实例（用于调度器任务）\n\n\n@lru_cache(maxsize=1)\ndef get_recorder_instance() -> ScreenRecorder:\n    \"\"\"获取全局录制器实例\n\n    Returns:\n        ScreenRecorder 实例\n    \"\"\"\n    return ScreenRecorder()\n\n\ndef execute_capture_task():\n    \"\"\"执行截图任务（供调度器调用的可序列化函数）\n\n    这是一个模块级别的函数，可以被 APScheduler 序列化到数据库中\n    \"\"\"\n    try:\n        logger.info(\"🔄 开始执行录制器任务\")\n        recorder = get_recorder_instance()\n        captured_files = recorder.execute_capture()\n        return len(captured_files)\n    except Exception as e:\n        logger.error(f\"执行录制器任务失败: {e}\", exc_info=True)\n        return 0\n\n\nif __name__ == \"__main__\":\n    parser = argparse.ArgumentParser(description=\"LifeTrace Screen Recorder\")\n    parser.add_argument(\"--config\", help=\"配置文件路径\")\n    parser.add_argument(\"--interval\", type=int, help=\"截图间隔（秒）\")\n    parser.add_argument(\"--screens\", help='要截图的屏幕，用逗号分隔或使用\"all\"')\n    parser.add_argument(\"--debug\", action=\"store_true\", help=\"启用调试日志\")\n\n    args = parser.parse_args()\n\n    if args.interval:\n        settings.set(\"jobs.recorder.interval\", args.interval)\n\n    if args.screens:\n        if args.screens.lower() == \"all\":\n            settings.set(\"jobs.recorder.params.screens\", \"all\")\n        else:\n            screens = [int(s.strip()) for s in args.screens.split(\",\")]\n            settings.set(\"jobs.recorder.params.screens\", screens)\n\n    recorder = ScreenRecorder()\n    recorder.start_recording()\n"
  },
  {
    "path": "lifetrace/jobs/recorder_blacklist.py",
    "content": "\"\"\"\n屏幕录制器黑名单处理模块\n包含黑名单检测和LifeTrace窗口识别逻辑\n\"\"\"\n\nfrom lifetrace.util.app_utils import expand_blacklist_apps\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.settings import settings\n\nfrom .recorder_config import (\n    BROWSER_APPS,\n    LIFETRACE_WINDOW_PATTERNS_REGEX,\n    LIFETRACE_WINDOW_PATTERNS_STR,\n    PYTHON_APPS,\n)\n\nlogger = get_logger()\n\n\ndef check_window_title_patterns(window_title: str) -> bool:\n    \"\"\"检查窗口标题是否匹配LifeTrace模式（支持动态端口）\"\"\"\n    window_title_lower = window_title.lower()\n    # 检查字符串包含模式\n    if any(pattern in window_title_lower for pattern in LIFETRACE_WINDOW_PATTERNS_STR):\n        return True\n    # 检查正则表达式模式（用于端口范围匹配）\n    return any(pattern.search(window_title_lower) for pattern in LIFETRACE_WINDOW_PATTERNS_REGEX)\n\n\ndef is_browser_or_python_app(app_name_lower: str) -> bool:\n    \"\"\"检查是否为浏览器或Python应用\"\"\"\n    return any(browser in app_name_lower for browser in BROWSER_APPS + PYTHON_APPS)\n\n\ndef is_lifetrace_window(app_name: str, window_title: str) -> bool:\n    \"\"\"检查是否为LifeTrace相关窗口\"\"\"\n    if not app_name and not window_title:\n        return False\n\n    # 直接检查窗口标题是否包含LifeTrace模式\n    if window_title and check_window_title_patterns(window_title):\n        return True\n\n    # 检查应用名：如果是浏览器或Python应用，需要进一步检查窗口标题\n    if app_name:\n        app_name_lower = app_name.lower()\n        if is_browser_or_python_app(app_name_lower) and window_title:\n            return check_window_title_patterns(window_title)\n\n    return False\n\n\ndef get_app_blacklist_reason(app_name: str) -> str:\n    \"\"\"获取应用名在黑名单中的原因\n\n    Returns:\n        如果在黑名单中，返回跳过原因；否则返回空字符串\n    \"\"\"\n    if not app_name:\n        return \"\"\n\n    blacklist_apps = settings.get(\"jobs.recorder.params.blacklist.apps\")\n    expanded_blacklist_apps = expand_blacklist_apps(blacklist_apps)\n\n    if not expanded_blacklist_apps:\n        return \"\"\n\n    app_name_lower = app_name.lower()\n    for blacklist_app in expanded_blacklist_apps:\n        if blacklist_app.lower() == app_name_lower or blacklist_app.lower() in app_name_lower:\n            return f\"🚫 [黑名单过滤] 应用 '{app_name}' 匹配黑名单项 '{blacklist_app}'\"\n\n    return \"\"\n\n\ndef get_window_blacklist_reason(window_title: str) -> str:\n    \"\"\"获取窗口标题在黑名单中的原因\n\n    Returns:\n        如果在黑名单中，返回跳过原因；否则返回空字符串\n    \"\"\"\n    if not window_title:\n        return \"\"\n\n    blacklist_windows = settings.get(\"jobs.recorder.params.blacklist.windows\")\n    if not blacklist_windows:\n        return \"\"\n\n    window_title_lower = window_title.lower()\n    for blacklist_window in blacklist_windows:\n        if (\n            blacklist_window.lower() == window_title_lower\n            or blacklist_window.lower() in window_title_lower\n        ):\n            return f\"🚫 [黑名单过滤] 窗口 '{window_title}' 匹配黑名单项 '{blacklist_window}'\"\n\n    return \"\"\n\n\ndef get_blacklist_reason(app_name: str, window_title: str) -> str:\n    \"\"\"获取应用被列入黑名单的原因\n\n    Returns:\n        如果在黑名单中，返回跳过原因；否则返回空字符串\n    \"\"\"\n    # 首先检查是否启用自动排除LifeTrace自身窗口\n    auto_exclude_self = settings.get(\"jobs.recorder.params.auto_exclude_self\")\n    if auto_exclude_self and is_lifetrace_window(app_name, window_title):\n        return (\n            f\"🏠 [自动排除] 检测到 LifeTrace 自身窗口 - 应用: '{app_name}', 窗口: '{window_title}'\"\n        )\n\n    # 检查黑名单功能是否启用\n    blacklist_enabled = settings.get(\"jobs.recorder.params.blacklist.enabled\")\n    if not blacklist_enabled:\n        return \"\"\n\n    # 检查应用名是否在黑名单中\n    app_reason = get_app_blacklist_reason(app_name)\n    if app_reason:\n        return app_reason\n\n    # 检查窗口标题是否在黑名单中\n    window_reason = get_window_blacklist_reason(window_title)\n    if window_reason:\n        return window_reason\n\n    return \"\"\n\n\ndef log_blacklist_config():\n    \"\"\"打印当前黑名单配置\"\"\"\n    blacklist_enabled = settings.get(\"jobs.recorder.params.blacklist.enabled\")\n    blacklist_apps = settings.get(\"jobs.recorder.params.blacklist.apps\")\n    blacklist_windows = settings.get(\"jobs.recorder.params.blacklist.windows\")\n\n    logger.info(\"=\" * 60)\n    logger.info(f\"📋 黑名单配置状态: {'✅ 已启用' if blacklist_enabled else '❌ 已禁用'}\")\n\n    if blacklist_enabled:\n        if blacklist_apps:\n            expanded_apps = expand_blacklist_apps(blacklist_apps)\n            logger.info(f\"🚫 黑名单应用: {blacklist_apps}\")\n            logger.info(f\"   扩展后的进程名: {expanded_apps}\")\n        else:\n            logger.info(\"🚫 黑名单应用: 无\")\n\n        if blacklist_windows:\n            logger.info(f\"🚫 黑名单窗口: {blacklist_windows}\")\n        else:\n            logger.info(\"🚫 黑名单窗口: 无\")\n    else:\n        logger.info(\"   (黑名单功能未启用，所有应用都会被截图)\")\n\n    logger.info(\"=\" * 60)\n"
  },
  {
    "path": "lifetrace/jobs/recorder_capture.py",
    "content": "\"\"\"\n屏幕录制器截图捕获模块\n包含截图捕获、保存和数据库操作\n\"\"\"\n\nimport hashlib\nimport importlib\nimport os\nimport threading\nfrom datetime import datetime\nfrom pathlib import Path\nfrom typing import Any\n\nimport imagehash\nimport mss\nfrom mss import tools as mss_tools\nfrom PIL import Image\n\nfrom lifetrace.storage import event_mgr, screenshot_mgr\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.settings import settings\nfrom lifetrace.util.time_utils import get_utc_now\nfrom lifetrace.util.utils import get_screenshot_filename\n\nfrom .recorder_config import UNKNOWN_APP, UNKNOWN_WINDOW, with_timeout\n\nlogger = get_logger()\n\n\nclass ScreenshotCapture:\n    \"\"\"截图捕获类，处理截图的捕获、保存和数据库操作\"\"\"\n\n    def __init__(\n        self,\n        screenshots_dir: str,\n        file_io_timeout: float,\n        db_timeout: float,\n        deduplicate: bool,\n        hash_threshold: int,\n    ):\n        self.screenshots_dir = screenshots_dir\n        self.file_io_timeout = file_io_timeout\n        self.db_timeout = db_timeout\n        self.deduplicate = deduplicate\n        self.hash_threshold = hash_threshold\n        self.last_hashes = {}\n\n    def save_screenshot(self, screenshot, file_path: str) -> bool:\n        \"\"\"保存截图到文件\"\"\"\n\n        @with_timeout(timeout_seconds=self.file_io_timeout, operation_name=\"保存截图文件\")\n        def _do_save():\n            mss_tools.to_png(screenshot.rgb, screenshot.size, output=file_path)\n            return True\n\n        try:\n            result = _do_save()\n            return result if result is not None else False\n        except Exception as e:\n            logger.error(f\"保存截图失败 {file_path}: {e}\")\n            return False\n\n    def get_image_size(self, file_path: str) -> tuple:\n        \"\"\"获取图像尺寸\"\"\"\n\n        @with_timeout(timeout_seconds=self.file_io_timeout, operation_name=\"读取图像尺寸\")\n        def _do_get_size():\n            with Image.open(file_path) as img:\n                return img.size\n\n        try:\n            result = _do_get_size()\n            return result if result is not None else (0, 0)\n        except Exception as e:\n            logger.error(f\"读取图像尺寸失败 {file_path}: {e}\")\n            return (0, 0)\n\n    def calculate_file_hash(self, file_path: str) -> str:\n        \"\"\"计算文件MD5哈希\"\"\"\n\n        @with_timeout(timeout_seconds=self.file_io_timeout, operation_name=\"计算文件哈希\")\n        def _do_calculate_hash():\n            with open(file_path, \"rb\") as f:\n                return hashlib.md5(f.read(), usedforsecurity=False).hexdigest()\n\n        try:\n            result = _do_calculate_hash()\n            return result if result is not None else \"\"\n        except Exception as e:\n            logger.error(f\"计算文件哈希失败 {file_path}: {e}\")\n            return \"\"\n\n    def calculate_image_hash(self, image_path: str) -> str:\n        \"\"\"计算图像感知哈希值\"\"\"\n\n        @with_timeout(timeout_seconds=self.file_io_timeout, operation_name=\"计算图像哈希\")\n        def _do_calculate_hash():\n            with Image.open(image_path) as img:\n                return str(imagehash.phash(img))\n\n        try:\n            result = _do_calculate_hash()\n            return result if result is not None else \"\"\n        except Exception as e:\n            logger.error(f\"计算图像哈希失败 {image_path}: {e}\")\n            return \"\"\n\n    def calculate_image_hash_from_memory(self, screenshot) -> str:\n        \"\"\"直接从内存中的截图计算图像感知哈希值\"\"\"\n\n        @with_timeout(timeout_seconds=self.file_io_timeout, operation_name=\"从内存计算图像哈希\")\n        def _do_calculate_hash():\n            img = Image.frombytes(\"RGB\", screenshot.size, screenshot.rgb)\n            return str(imagehash.phash(img))\n\n        try:\n            result = _do_calculate_hash()\n            return result if result is not None else \"\"\n        except Exception as e:\n            logger.error(f\"从内存计算图像哈希失败: {e}\")\n            return \"\"\n\n    def is_duplicate(self, screen_id: int, image_hash: str) -> bool:\n        \"\"\"检查是否为重复图像\"\"\"\n        if not self.deduplicate:\n            return False\n\n        if screen_id not in self.last_hashes:\n            return False\n\n        last_hash = self.last_hashes[screen_id]\n        try:\n            current = imagehash.hex_to_hash(image_hash)\n            previous = imagehash.hex_to_hash(last_hash)\n            distance = current - previous\n\n            is_dup = distance <= self.hash_threshold\n\n            if is_dup:\n                logger.info(f\"[窗口 {screen_id}] 跳过重复截图\")\n\n            return is_dup\n        except Exception as e:\n            logger.error(f\"比较图像哈希失败: {e}\")\n            return False\n\n    def save_to_database(\n        self,\n        file_path: str,\n        file_hash: str,\n        width: int,\n        height: int,\n        screen_id: int,\n        app_name: str,\n        window_title: str,\n    ) -> int | None:\n        \"\"\"保存截图信息到数据库\"\"\"\n\n        @with_timeout(timeout_seconds=self.db_timeout, operation_name=\"数据库操作\")\n        def _do_save_to_db():\n            screenshot_id = screenshot_mgr.add_screenshot(\n                file_path=file_path,\n                file_hash=file_hash,\n                width=width,\n                height=height,\n                metadata={\n                    \"screen_id\": screen_id,\n                    \"app_name\": app_name or UNKNOWN_APP,\n                    \"window_title\": window_title or UNKNOWN_WINDOW,\n                    \"event_id\": None,\n                },\n            )\n            return screenshot_id\n\n        try:\n            result = _do_save_to_db()\n            return result\n        except Exception as e:\n            logger.error(f\"保存截图记录到数据库失败: {e}\")\n            return None\n\n    def grab_and_prepare_screenshot(self, screen_id: int) -> tuple[Any | None, str, datetime]:\n        \"\"\"抓取屏幕并准备截图文件路径\"\"\"\n        with mss.mss() as sct:\n            if screen_id >= len(sct.monitors):\n                logger.warning(f\"[窗口 {screen_id}] 屏幕ID不存在\")\n                return None, \"\", get_utc_now()\n\n            monitor = sct.monitors[screen_id]\n            screenshot = sct.grab(monitor)\n            timestamp = get_utc_now()\n            filename = get_screenshot_filename(screen_id, timestamp)\n            file_path = os.path.join(self.screenshots_dir, filename)\n            return screenshot, file_path, timestamp\n\n\ndef process_screenshot_event(\n    screenshot_id: int,\n    app_name: str,\n    window_title: str,\n    timestamp: datetime,\n):\n    \"\"\"处理截图事件：将截图关联到事件\n\n    Args:\n        screenshot_id: 截图ID\n        app_name: 应用名称\n        window_title: 窗口标题\n        timestamp: 截图时间\n    \"\"\"\n    try:\n        event_id = event_mgr.get_or_create_event(\n            app_name=app_name,\n            window_title=window_title,\n            timestamp=timestamp,\n        )\n\n        if event_id:\n            success = event_mgr.add_screenshot_to_event(screenshot_id, event_id)\n            if success:\n                logger.info(\n                    f\"📎 截图 {screenshot_id} 已添加到事件 {event_id} [{app_name} - {window_title}]\"\n                )\n            else:\n                logger.warning(f\"⚠️  截图 {screenshot_id} 添加到事件失败\")\n        else:\n            logger.warning(f\"⚠️  获取或创建事件失败，截图ID: {screenshot_id}\")\n\n    except Exception as e:\n        logger.error(f\"处理截图事件失败: {e}\", exc_info=True)\n\n\ndef get_unprocessed_files(screenshots_dir: str) -> list[str]:\n    \"\"\"获取所有未处理的截图文件列表\"\"\"\n    screenshot_files = []\n    for file_path in Path(screenshots_dir).glob(\"*.png\"):\n        if file_path.is_file():\n            screenshot_files.append(str(file_path))\n\n    unprocessed_files = []\n    for file_path in screenshot_files:\n        screenshot = screenshot_mgr.get_screenshot_by_path(file_path)\n        if not screenshot:\n            unprocessed_files.append(file_path)\n\n    return unprocessed_files\n\n\ndef extract_screen_id_from_path(file_path: str) -> int:\n    \"\"\"从文件名提取屏幕ID\"\"\"\n    min_filename_parts = 2\n\n    try:\n        filename = os.path.basename(file_path)\n        if filename.startswith(\"screen_\"):\n            parts = filename.split(\"_\")\n            if len(parts) >= min_filename_parts:\n                return int(parts[1])\n    except (ValueError, IndexError):\n        pass\n    return 0\n\n\ndef should_detect_todos(app_name: str) -> bool:\n    \"\"\"判断是否需要触发待办检测\n\n    Args:\n        app_name: 应用名称\n\n    Returns:\n        是否需要检测\n    \"\"\"\n    try:\n        enabled = settings.get(\"jobs.auto_todo_detection.enabled\")\n        if not enabled:\n            logger.debug(f\"自动待办检测已禁用，跳过应用: {app_name}\")\n            return False\n    except KeyError:\n        logger.debug(\"自动待办检测配置项不存在，跳过检测\")\n        return False\n\n    if not app_name:\n        return False\n    auto_module = importlib.import_module(\"lifetrace.llm.auto_todo_detection_service\")\n    whitelist_apps = auto_module.get_whitelist_apps()\n    app_name_lower = app_name.lower()\n    is_whitelist = any(whitelist_app.lower() in app_name_lower for whitelist_app in whitelist_apps)\n\n    if is_whitelist:\n        logger.info(f\"🔍 检测到白名单应用: {app_name}，将触发自动待办检测\")\n    else:\n        logger.debug(f\"应用 {app_name} 不在白名单中，跳过自动待办检测\")\n\n    return is_whitelist\n\n\ndef trigger_todo_detection_async(screenshot_id: int, _app_name: str):\n    \"\"\"异步触发待办检测\n\n    Args:\n        screenshot_id: 截图ID\n        app_name: 应用名称\n    \"\"\"\n\n    def _detect_todos():\n        try:\n            auto_module = importlib.import_module(\"lifetrace.llm.auto_todo_detection_service\")\n            auto_todo_detection_service_class = auto_module.AutoTodoDetectionService\n            service = auto_todo_detection_service_class()\n            result = service.detect_and_create_todos_from_screenshot(screenshot_id)\n            logger.info(\n                f\"截图 {screenshot_id} 待办检测完成，创建 {result.get('created_count', 0)} 个draft待办\"\n            )\n        except Exception as e:\n            logger.error(\n                f\"截图 {screenshot_id} 待办检测失败: {e}\",\n                exc_info=True,\n            )\n\n    thread = threading.Thread(target=_detect_todos, daemon=True)\n    thread.start()\n"
  },
  {
    "path": "lifetrace/jobs/recorder_config.py",
    "content": "\"\"\"\n屏幕录制器配置模块\n包含常量、模式匹配和装饰器\n\"\"\"\n\nimport re\nfrom concurrent.futures import Future, ThreadPoolExecutor\nfrom functools import wraps\n\nfrom lifetrace.util.logging_config import get_logger\n\nlogger = get_logger()\n\n# 常量定义\nUNKNOWN_APP = \"未知应用\"\nUNKNOWN_WINDOW = \"未知窗口\"\nDEFAULT_SCREEN_ID = 0  # 用于应用使用记录的默认屏幕ID\n\n# LifeTrace窗口识别模式（支持字符串包含匹配和正则表达式）\nLIFETRACE_WINDOW_PATTERNS_STR = [\n    \"lifetrace\",\n    \"lifetrace - intelligent life recording system\",\n    \"lifetrace desktop\",\n    \"lifetrace 智能生活记录系统\",\n    \"lifetrace 桌面版\",\n    \"lifetrace frontend\",\n    \"lifetrace web interface\",\n    \"freetodo\",  # Electron 应用名\n]\n\n# 端口范围模式（支持 8000-8099 和 3000-3099 动态端口）\nLIFETRACE_WINDOW_PATTERNS_REGEX = [\n    re.compile(r\"localhost:80\\d{2}\"),  # 匹配 localhost:8000-8099\n    re.compile(r\"127\\.0\\.0\\.1:80\\d{2}\"),  # 匹配 127.0.0.1:8000-8099\n    re.compile(r\"localhost:30\\d{2}\"),  # 匹配 localhost:3000-3099\n    re.compile(r\"127\\.0\\.0\\.1:30\\d{2}\"),  # 匹配 127.0.0.1:3000-3099\n]\n\nBROWSER_APPS = [\"chrome\", \"msedge\", \"firefox\", \"electron\"]\nPYTHON_APPS = [\"python\", \"pythonw\"]\n\n\ndef with_timeout(timeout_seconds: float = 5.0, operation_name: str = \"操作\"):\n    \"\"\"超时装饰器 - 使用 Future 实现更清晰的超时控制\"\"\"\n\n    def decorator(func):\n        @wraps(func)\n        def wrapper(*args, **kwargs):\n            executor = ThreadPoolExecutor(max_workers=1)\n            future: Future = executor.submit(func, *args, **kwargs)\n\n            try:\n                result = future.result(timeout=timeout_seconds)\n                return result\n            except TimeoutError:\n                logger.warning(f\"{operation_name}超时 ({timeout_seconds}秒)，操作可能仍在后台执行\")\n                return None\n            except Exception as e:\n                logger.error(f\"{operation_name}执行失败: {e}\")\n                raise\n            finally:\n                executor.shutdown(wait=False)\n\n        return wrapper\n\n    return decorator\n"
  },
  {
    "path": "lifetrace/jobs/scheduler.py",
    "content": "\"\"\"\nAPScheduler 调度器管理模块，用于管理 LifeTrace 的定时任务\n\"\"\"\n\nimport os\nfrom functools import lru_cache\n\nfrom apscheduler.events import (\n    EVENT_JOB_ADDED,\n    EVENT_JOB_ERROR,\n    EVENT_JOB_EXECUTED,\n    EVENT_JOB_REMOVED,\n)\nfrom apscheduler.executors.pool import ThreadPoolExecutor\nfrom apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore\nfrom apscheduler.schedulers.background import BackgroundScheduler\nfrom apscheduler.triggers.interval import IntervalTrigger\n\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.path_utils import get_scheduler_database_path\nfrom lifetrace.util.settings import settings\n\nlogger = get_logger()\n\n\nclass SchedulerManager:\n    \"\"\"APScheduler 调度器管理器\"\"\"\n\n    def __init__(self):\n        \"\"\"初始化调度器管理器\"\"\"\n        self.scheduler: BackgroundScheduler | None = None\n        self._setup_scheduler()\n\n    def _setup_scheduler(self):\n        \"\"\"设置 APScheduler 调度器\"\"\"\n        # 从配置获取调度器数据库路径\n        scheduler_db_path = str(get_scheduler_database_path())\n\n        # 确保数据库目录存在\n        os.makedirs(os.path.dirname(scheduler_db_path), exist_ok=True)\n\n        # 配置作业存储（持久化到 SQLite）\n        jobstores = {\"default\": SQLAlchemyJobStore(url=f\"sqlite:///{scheduler_db_path}\")}\n\n        # 配置执行器（线程池）\n        max_workers = settings.get(\"scheduler.max_workers\")\n        executors = {\"default\": ThreadPoolExecutor(max_workers=max_workers)}\n\n        # 调度器配置\n        job_defaults = {\n            \"coalesce\": settings.get(\"scheduler.coalesce\"),  # 合并错过的任务\n            \"max_instances\": settings.get(\"scheduler.max_instances\"),  # 同一任务同时只能有一个实例\n            \"misfire_grace_time\": settings.get(\n                \"scheduler.misfire_grace_time\"\n            ),  # 错过触发时间的容忍度（秒）\n        }\n\n        # 创建调度器\n        timezone = settings.get(\"scheduler.timezone\")\n        self.scheduler = BackgroundScheduler(\n            jobstores=jobstores,\n            executors=executors,\n            job_defaults=job_defaults,\n            timezone=timezone,  # 从配置读取时区\n        )\n\n        # 添加事件监听器\n        self.scheduler.add_listener(self._job_executed_listener, EVENT_JOB_EXECUTED)\n        self.scheduler.add_listener(self._job_error_listener, EVENT_JOB_ERROR)\n        self.scheduler.add_listener(self._job_added_listener, EVENT_JOB_ADDED)\n        self.scheduler.add_listener(self._job_removed_listener, EVENT_JOB_REMOVED)\n\n        logger.info(f\"调度器已初始化，作业数据库: {scheduler_db_path}\")\n\n    def _job_executed_listener(self, event):\n        \"\"\"任务执行成功的监听器\"\"\"\n        logger.debug(\n            f\"任务执行成功: {event.job_id}, \"\n            f\"返回值: {event.retval if hasattr(event, 'retval') else 'None'}\"\n        )\n\n    def _job_error_listener(self, event):\n        \"\"\"任务执行错误的监听器\"\"\"\n        logger.error(\n            f\"任务执行失败: {event.job_id}, 异常: {event.exception}, traceback: {event.traceback}\"\n        )\n\n    def _job_added_listener(self, event):\n        \"\"\"任务添加的监听器\"\"\"\n        logger.info(f\"任务已添加: {event.job_id}\")\n\n    def _job_removed_listener(self, event):\n        \"\"\"任务移除的监听器\"\"\"\n        logger.info(f\"任务已移除: {event.job_id}\")\n\n    def start(self):\n        \"\"\"启动调度器\"\"\"\n        if self.scheduler and not self.scheduler.running:\n            self.scheduler.start()\n            logger.info(\"调度器已启动\")\n        else:\n            logger.warning(\"调度器已经在运行中\")\n\n    def shutdown(self, wait: bool = True):\n        \"\"\"关闭调度器\n\n        Args:\n            wait: 是否等待所有任务执行完毕\n        \"\"\"\n        if self.scheduler and self.scheduler.running:\n            self.scheduler.shutdown(wait=wait)\n            logger.error(\"调度器已关闭\")\n        else:\n            logger.warning(\"调度器未运行\")\n\n    def add_interval_job(\n        self,\n        func,\n        job_id: str,\n        name: str | None = None,\n        seconds: int | None = None,\n        minutes: int | None = None,\n        hours: int | None = None,\n        replace_existing: bool = True,\n        **kwargs,\n    ):\n        \"\"\"添加间隔型任务\n\n        Args:\n            func: 要执行的函数\n            job_id: 任务ID\n            name: 任务名称（显示用）\n            seconds: 间隔秒数\n            minutes: 间隔分钟数\n            hours: 间隔小时数\n            replace_existing: 如果任务已存在是否替换\n            **kwargs: 传递给函数的参数\n        \"\"\"\n        if not self.scheduler:\n            logger.error(\"调度器未初始化\")\n            return None\n\n        try:\n            # 构建间隔参数\n            interval_kwargs = {}\n            if seconds is not None:\n                interval_kwargs[\"seconds\"] = seconds\n            if minutes is not None:\n                interval_kwargs[\"minutes\"] = minutes\n            if hours is not None:\n                interval_kwargs[\"hours\"] = hours\n\n            if not interval_kwargs:\n                logger.error(\"必须指定至少一个时间间隔参数\")\n                return None\n\n            job = self.scheduler.add_job(\n                func,\n                trigger=\"interval\",\n                id=job_id,\n                name=name,\n                replace_existing=replace_existing,\n                kwargs=kwargs,\n                **interval_kwargs,\n            )\n            logger.info(\n                f\"添加间隔任务: {job_id} ({name}), 间隔: {interval_kwargs}, 下次运行: {job.next_run_time}\"\n            )\n            return job\n        except Exception as e:\n            logger.error(f\"添加任务失败: {e}\")\n            return None\n\n    def add_date_job(\n        self,\n        func,\n        job_id: str,\n        run_date,\n        name: str | None = None,\n        replace_existing: bool = True,\n        **kwargs,\n    ):\n        \"\"\"添加一次性任务（指定时间触发）\"\"\"\n        if not self.scheduler:\n            logger.error(\"调度器未初始化\")\n            return None\n\n        try:\n            job = self.scheduler.add_job(\n                func,\n                trigger=\"date\",\n                id=job_id,\n                name=name,\n                run_date=run_date,\n                replace_existing=replace_existing,\n                kwargs=kwargs,\n            )\n            logger.info(f\"添加一次性任务: {job_id} ({name}), 触发时间: {job.next_run_time}\")\n            return job\n        except Exception as e:\n            logger.error(f\"添加一次性任务失败: {e}\")\n            return None\n\n    def remove_job(self, job_id: str):\n        \"\"\"移除任务\n\n        Args:\n            job_id: 任务ID\n        \"\"\"\n        if not self.scheduler:\n            logger.error(\"调度器未初始化\")\n            return False\n\n        try:\n            self.scheduler.remove_job(job_id)\n            logger.info(f\"任务已移除: {job_id}\")\n            return True\n        except Exception as e:\n            logger.error(f\"移除任务失败: {e}\")\n            return False\n\n    def pause_job(self, job_id: str):\n        \"\"\"暂停任务\n\n        Args:\n            job_id: 任务ID\n        \"\"\"\n        if not self.scheduler:\n            logger.error(\"调度器未初始化\")\n            return False\n\n        try:\n            self.scheduler.pause_job(job_id)\n            logger.warning(f\"任务已暂停: {job_id}\")\n            return True\n        except Exception as e:\n            logger.error(f\"暂停任务失败: {e}\")\n            return False\n\n    def resume_job(self, job_id: str):\n        \"\"\"恢复任务\n\n        Args:\n            job_id: 任务ID\n        \"\"\"\n        if not self.scheduler:\n            logger.error(\"调度器未初始化\")\n            return False\n\n        try:\n            self.scheduler.resume_job(job_id)\n            logger.warning(f\"任务已恢复: {job_id}\")\n            return True\n        except Exception as e:\n            logger.error(f\"恢复任务失败: {e}\")\n            return False\n\n    def get_job(self, job_id: str):\n        \"\"\"获取任务信息\n\n        Args:\n            job_id: 任务ID\n\n        Returns:\n            任务对象或None\n        \"\"\"\n        if not self.scheduler:\n            logger.error(\"调度器未初始化\")\n            return None\n\n        return self.scheduler.get_job(job_id)\n\n    def get_all_jobs(self):\n        \"\"\"获取所有任务\n\n        Returns:\n            任务列表\n        \"\"\"\n        if not self.scheduler:\n            logger.error(\"调度器未初始化\")\n            return []\n\n        return self.scheduler.get_jobs()\n\n    def modify_job_interval(\n        self,\n        job_id: str,\n        seconds: int | None = None,\n        minutes: int | None = None,\n        hours: int | None = None,\n    ):\n        \"\"\"修改任务的执行间隔\n\n        Args:\n            job_id: 任务ID\n            seconds: 新的间隔秒数\n            minutes: 新的间隔分钟数\n            hours: 新的间隔小时数\n        \"\"\"\n        if not self.scheduler:\n            logger.error(\"调度器未初始化\")\n            return False\n\n        try:\n            # 构建间隔参数\n            interval_kwargs = {}\n            if seconds is not None:\n                interval_kwargs[\"seconds\"] = seconds\n            if minutes is not None:\n                interval_kwargs[\"minutes\"] = minutes\n            if hours is not None:\n                interval_kwargs[\"hours\"] = hours\n\n            if not interval_kwargs:\n                logger.error(\"必须指定至少一个时间间隔参数\")\n                return False\n\n            # 创建新的触发器\n            new_trigger = IntervalTrigger(**interval_kwargs)\n            self.scheduler.modify_job(job_id, trigger=new_trigger)\n            logger.info(f\"任务间隔已修改: {job_id}, 新间隔: {interval_kwargs}\")\n            return True\n        except Exception as e:\n            logger.error(f\"修改任务间隔失败: {e}\")\n            return False\n\n    def pause_all_jobs(self):\n        \"\"\"暂停所有任务\n\n        Returns:\n            暂停成功的任务数量\n        \"\"\"\n        if not self.scheduler:\n            logger.error(\"调度器未初始化\")\n            return 0\n\n        try:\n            jobs = self.get_all_jobs()\n            paused_count = 0\n\n            for job in jobs:\n                # 只暂停未暂停的任务\n                if job.next_run_time is not None:\n                    try:\n                        self.scheduler.pause_job(job.id)\n                        paused_count += 1\n                    except Exception as e:\n                        logger.error(f\"暂停任务 {job.id} 失败: {e}\")\n\n            logger.warning(f\"已暂停 {paused_count} 个任务\")\n            return paused_count\n        except Exception as e:\n            logger.error(f\"批量暂停任务失败: {e}\")\n            return 0\n\n    def resume_all_jobs(self):\n        \"\"\"恢复所有任务\n\n        Returns:\n            恢复成功的任务数量\n        \"\"\"\n        if not self.scheduler:\n            logger.error(\"调度器未初始化\")\n            return 0\n\n        try:\n            jobs = self.get_all_jobs()\n            resumed_count = 0\n\n            for job in jobs:\n                # 只恢复已暂停的任务\n                if job.next_run_time is None:\n                    try:\n                        self.scheduler.resume_job(job.id)\n                        resumed_count += 1\n                    except Exception as e:\n                        logger.error(f\"恢复任务 {job.id} 失败: {e}\")\n\n            logger.warning(f\"已恢复 {resumed_count} 个任务\")\n            return resumed_count\n        except Exception as e:\n            logger.error(f\"批量恢复任务失败: {e}\")\n            return 0\n\n\n# 全局调度器实例\n\n\n@lru_cache(maxsize=1)\ndef get_scheduler_manager() -> SchedulerManager:\n    \"\"\"获取全局调度器管理器实例\n\n    Returns:\n        SchedulerManager 实例\n    \"\"\"\n    return SchedulerManager()\n"
  },
  {
    "path": "lifetrace/jobs/todo_recorder.py",
    "content": "\"\"\"\nTodo 专用屏幕录制器 - 仅录制白名单应用，用于自动待办检测\n\n与通用屏幕录制器（recorder.py）完全独立：\n- 用户可以只开启 Todo 专用录制，而不开启通用录制\n- 两者可以同时运行，互不影响\n- 复用截图核心逻辑，但维护独立的运行状态\n\"\"\"\n\nimport hashlib\nimport importlib\nimport os\nimport threading\nfrom concurrent.futures import Future, ThreadPoolExecutor\nfrom functools import lru_cache, wraps\n\nimport imagehash\nimport mss\nfrom mss import tools as mss_tools\nfrom PIL import Image\n\nfrom lifetrace.llm.auto_todo_detection_service import get_whitelist_apps\nfrom lifetrace.storage import screenshot_mgr\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.path_utils import get_screenshots_dir\nfrom lifetrace.util.settings import settings\nfrom lifetrace.util.time_utils import get_utc_now\nfrom lifetrace.util.utils import (\n    ensure_dir,\n    get_active_window_info,\n    get_active_window_screen,\n    get_screenshot_filename,\n)\n\nlogger = get_logger()\n\n# 常量定义\nUNKNOWN_APP = \"未知应用\"\nUNKNOWN_WINDOW = \"未知窗口\"\n\n\ndef with_timeout(timeout_seconds: float = 5.0, operation_name: str = \"操作\"):\n    \"\"\"超时装饰器 - 使用线程池实现超时控制\"\"\"\n\n    def decorator(func):\n        @wraps(func)\n        def wrapper(*args, **kwargs):\n            executor = ThreadPoolExecutor(max_workers=1)\n            future: Future = executor.submit(func, *args, **kwargs)\n\n            try:\n                result = future.result(timeout=timeout_seconds)\n                return result\n            except TimeoutError:\n                logger.warning(f\"{operation_name}超时 ({timeout_seconds}秒)\")\n                return None\n            except Exception as e:\n                logger.error(f\"{operation_name}执行失败: {e}\")\n                raise\n            finally:\n                executor.shutdown(wait=False)\n\n        return wrapper\n\n    return decorator\n\n\nclass TodoScreenRecorder:\n    \"\"\"Todo 专用屏幕录制器\n\n    仅在白名单应用激活时截图，截图后直接触发自动待办检测。\n    与通用录制器完全独立，不依赖其运行状态。\n    \"\"\"\n\n    def __init__(self):\n        \"\"\"初始化 Todo 专用录制器\"\"\"\n        self.screenshots_dir = str(get_screenshots_dir())\n        self.interval = settings.get(\"jobs.todo_recorder.interval\", 5)\n        self.deduplicate = settings.get(\"jobs.todo_recorder.params.deduplicate\", True)\n        self.hash_threshold = settings.get(\"jobs.todo_recorder.params.hash_threshold\", 5)\n\n        # 超时配置\n        self.file_io_timeout = settings.get(\"jobs.todo_recorder.params.file_io_timeout\", 15)\n        self.db_timeout = settings.get(\"jobs.todo_recorder.params.db_timeout\", 20)\n        self.window_info_timeout = settings.get(\"jobs.todo_recorder.params.window_info_timeout\", 5)\n\n        # 初始化截图目录\n        ensure_dir(self.screenshots_dir)\n\n        # 独立的上一张截图哈希值（用于去重，与通用录制器独立）\n        self.last_hash: str | None = None\n\n        logger.info(f\"[Todo录制器] 初始化完成，间隔: {self.interval}秒，去重: {self.deduplicate}\")\n\n    def _get_window_info(self) -> tuple[str, str]:\n        \"\"\"获取当前活动窗口信息\"\"\"\n\n        @with_timeout(timeout_seconds=self.window_info_timeout, operation_name=\"获取窗口信息\")\n        def _do_get_window_info():\n            return get_active_window_info()\n\n        try:\n            result = _do_get_window_info()\n            if result is not None:\n                app_name, window_title = result\n                app_name = app_name or UNKNOWN_APP\n                window_title = window_title or UNKNOWN_WINDOW\n                return (app_name, window_title)\n            return (UNKNOWN_APP, UNKNOWN_WINDOW)\n        except Exception as e:\n            logger.error(f\"[Todo录制器] 获取窗口信息失败: {e}\")\n            return (UNKNOWN_APP, UNKNOWN_WINDOW)\n\n    def _is_whitelist_app(self, app_name: str) -> bool:\n        \"\"\"检查当前应用是否在白名单中\n\n        Args:\n            app_name: 应用名称\n\n        Returns:\n            是否为白名单应用\n        \"\"\"\n        if not app_name or app_name == UNKNOWN_APP:\n            return False\n\n        whitelist_apps = get_whitelist_apps()\n        app_name_lower = app_name.lower()\n        return any(whitelist_app.lower() in app_name_lower for whitelist_app in whitelist_apps)\n\n    def _calculate_image_hash_from_memory(self, screenshot) -> str:\n        \"\"\"从内存中的截图计算图像感知哈希值\"\"\"\n\n        @with_timeout(timeout_seconds=self.file_io_timeout, operation_name=\"计算图像哈希\")\n        def _do_calculate_hash():\n            img = Image.frombytes(\"RGB\", screenshot.size, screenshot.rgb)\n            return str(imagehash.phash(img))\n\n        try:\n            result = _do_calculate_hash()\n            return result if result is not None else \"\"\n        except Exception as e:\n            logger.error(f\"[Todo录制器] 计算图像哈希失败: {e}\")\n            return \"\"\n\n    def _is_duplicate(self, image_hash: str) -> bool:\n        \"\"\"检查是否为重复图像\"\"\"\n        if not self.deduplicate or not self.last_hash:\n            return False\n\n        try:\n            current = imagehash.hex_to_hash(image_hash)\n            previous = imagehash.hex_to_hash(self.last_hash)\n            distance = current - previous\n\n            is_duplicate = distance <= self.hash_threshold\n\n            if is_duplicate:\n                logger.debug(\"[Todo录制器] 检测到重复截图，跳过\")\n\n            return is_duplicate\n        except Exception as e:\n            logger.error(f\"[Todo录制器] 比较图像哈希失败: {e}\")\n            return False\n\n    def _save_screenshot(self, screenshot, file_path: str) -> bool:\n        \"\"\"保存截图到文件\"\"\"\n\n        @with_timeout(timeout_seconds=self.file_io_timeout, operation_name=\"保存截图文件\")\n        def _do_save():\n            mss_tools.to_png(screenshot.rgb, screenshot.size, output=file_path)\n            return True\n\n        try:\n            result = _do_save()\n            return result if result is not None else False\n        except Exception as e:\n            logger.error(f\"[Todo录制器] 保存截图失败 {file_path}: {e}\")\n            return False\n\n    def _get_image_size(self, file_path: str) -> tuple:\n        \"\"\"获取图像尺寸\"\"\"\n\n        @with_timeout(timeout_seconds=self.file_io_timeout, operation_name=\"读取图像尺寸\")\n        def _do_get_size():\n            with Image.open(file_path) as img:\n                return img.size\n\n        try:\n            result = _do_get_size()\n            return result if result is not None else (0, 0)\n        except Exception as e:\n            logger.error(f\"[Todo录制器] 读取图像尺寸失败 {file_path}: {e}\")\n            return (0, 0)\n\n    def _calculate_file_hash(self, file_path: str) -> str:\n        \"\"\"计算文件MD5哈希\"\"\"\n\n        @with_timeout(timeout_seconds=self.file_io_timeout, operation_name=\"计算文件哈希\")\n        def _do_calculate_hash():\n            with open(file_path, \"rb\") as f:\n                return hashlib.md5(f.read(), usedforsecurity=False).hexdigest()\n\n        try:\n            result = _do_calculate_hash()\n            return result if result is not None else \"\"\n        except Exception as e:\n            logger.error(f\"[Todo录制器] 计算文件哈希失败 {file_path}: {e}\")\n            return \"\"\n\n    def _save_to_database(\n        self,\n        file_path: str,\n        file_hash: str,\n        width: int,\n        height: int,\n        screen_id: int,\n        app_name: str,\n        window_title: str,\n    ) -> int | None:\n        \"\"\"保存截图信息到数据库\"\"\"\n\n        @with_timeout(timeout_seconds=self.db_timeout, operation_name=\"数据库操作\")\n        def _do_save_to_db():\n            screenshot_id = screenshot_mgr.add_screenshot(\n                file_path=file_path,\n                file_hash=file_hash,\n                width=width,\n                height=height,\n                metadata={\n                    \"screen_id\": screen_id,\n                    \"app_name\": app_name or UNKNOWN_APP,\n                    \"window_title\": window_title or UNKNOWN_WINDOW,\n                    \"source\": \"todo_recorder\",  # 标记来源为 Todo 专用录制器\n                    \"event_id\": None,\n                },\n            )\n            return screenshot_id\n\n        try:\n            result = _do_save_to_db()\n            return result\n        except Exception as e:\n            logger.error(f\"[Todo录制器] 保存截图记录到数据库失败: {e}\")\n            return None\n\n    def _trigger_todo_detection(self, screenshot_id: int, app_name: str):\n        \"\"\"触发自动待办检测\n\n        Args:\n            screenshot_id: 截图ID\n            app_name: 应用名称\n        \"\"\"\n        _ = app_name\n\n        def _detect_todos():\n            try:\n                auto_module = importlib.import_module(\"lifetrace.llm.auto_todo_detection_service\")\n                auto_todo_detection_service_class = auto_module.AutoTodoDetectionService\n                service = auto_todo_detection_service_class()\n                result = service.detect_and_create_todos_from_screenshot(screenshot_id)\n                logger.info(\n                    f\"[Todo录制器] 截图 {screenshot_id} 待办检测完成，\"\n                    f\"创建 {result.get('created_count', 0)} 个 draft 待办\"\n                )\n            except Exception as e:\n                logger.error(\n                    f\"[Todo录制器] 截图 {screenshot_id} 待办检测失败: {e}\",\n                    exc_info=True,\n                )\n\n        # 使用后台线程异步执行，避免阻塞截图流程\n        thread = threading.Thread(target=_detect_todos, daemon=True)\n        thread.start()\n\n    def _check_whitelist_and_screen(self, app_name: str) -> tuple[int, str, str] | None:\n        \"\"\"检查白名单应用和屏幕\n\n        Returns:\n            (screen_id, app_name, window_title) 或 None（如果检查失败）\n        \"\"\"\n        _, window_title = self._get_window_info()\n\n        if not self._is_whitelist_app(app_name):\n            logger.debug(f\"[Todo录制器] 当前应用 '{app_name}' 不在白名单中，跳过截图\")\n            return None\n\n        active_screen_id = get_active_window_screen()\n        if active_screen_id is None:\n            logger.warning(\"[Todo录制器] 无法获取活跃窗口所在的屏幕，跳过截图\")\n            return None\n\n        logger.info(\n            f\"[Todo录制器] 📸 检测到白名单应用: {app_name}，准备截图 - 屏幕: {active_screen_id}\"\n        )\n        return (active_screen_id, app_name, window_title)\n\n    def _capture_and_save(\n        self,\n        active_screen_id: int,\n        app_name: str,\n        window_title: str,\n    ) -> str | None:\n        \"\"\"执行截图并保存\n\n        Returns:\n            截图文件路径，如果失败则返回 None\n        \"\"\"\n        with mss.mss() as sct:\n            if active_screen_id >= len(sct.monitors):\n                logger.warning(f\"[Todo录制器] 屏幕ID {active_screen_id} 不存在\")\n                return None\n\n            monitor = sct.monitors[active_screen_id]\n            screenshot = sct.grab(monitor)\n            timestamp = get_utc_now()\n            filename = f\"todo_{get_screenshot_filename(active_screen_id, timestamp)}\"\n            file_path = os.path.join(self.screenshots_dir, filename)\n\n            # 计算图像哈希（用于去重）\n            image_hash = self._calculate_image_hash_from_memory(screenshot)\n            if not image_hash:\n                logger.error(\"[Todo录制器] 计算图像哈希失败，跳过\")\n                return None\n\n            # 检查是否重复\n            if self._is_duplicate(image_hash):\n                return None\n\n            # 更新哈希记录并保存\n            self.last_hash = image_hash\n            if not self._save_screenshot(screenshot, file_path):\n                logger.error(f\"[Todo录制器] 保存截图失败: {filename}\")\n                return None\n\n            # 保存元数据并触发检测\n            self._save_metadata_and_trigger(\n                file_path, filename, active_screen_id, app_name, window_title\n            )\n            return file_path\n\n    def _save_metadata_and_trigger(\n        self,\n        file_path: str,\n        filename: str,\n        screen_id: int,\n        app_name: str,\n        window_title: str,\n    ) -> None:\n        \"\"\"保存元数据并触发待办检测\"\"\"\n        width, height = self._get_image_size(file_path)\n        file_hash = self._calculate_file_hash(file_path) or \"\"\n\n        screenshot_id = self._save_to_database(\n            file_path, file_hash, width, height, screen_id, app_name, window_title\n        )\n\n        file_size = os.path.getsize(file_path)\n        file_size_kb = file_size / 1024\n        logger.info(f\"[Todo录制器] ✅ 截图保存: {filename} ({file_size_kb:.2f} KB) - {app_name}\")\n\n        if screenshot_id:\n            self._trigger_todo_detection(screenshot_id, app_name)\n        else:\n            logger.warning(f\"[Todo录制器] 数据库保存失败，但文件已保存: {filename}\")\n\n    def capture_whitelist_app(self) -> str | None:\n        \"\"\"截取白名单应用的屏幕\n\n        仅在当前活动窗口为白名单应用时才截图。\n\n        Returns:\n            截图文件路径，如果未截图则返回 None\n        \"\"\"\n        app_name, window_title = self._get_window_info()\n\n        check_result = self._check_whitelist_and_screen(app_name)\n        if check_result is None:\n            return None\n\n        active_screen_id, app_name, window_title = check_result\n\n        try:\n            return self._capture_and_save(active_screen_id, app_name, window_title)\n        except Exception as e:\n            logger.error(f\"[Todo录制器] 截图失败: {e}\", exc_info=True)\n            return None\n\n    def execute_capture(self) -> str | None:\n        \"\"\"执行一次截图任务（用于调度器调用）\n\n        Returns:\n            截图文件路径，如果未截图则返回 None\n        \"\"\"\n        try:\n            result = self.capture_whitelist_app()\n            if result:\n                logger.info(\"[Todo录制器] ✅ 本次截取了白名单应用截图\")\n            else:\n                logger.debug(\"[Todo录制器] ⏭️ 本次未截取截图（非白名单应用或重复）\")\n            return result\n        except Exception as e:\n            logger.error(f\"[Todo录制器] 执行截图任务失败: {e}\", exc_info=True)\n            return None\n\n\n# 全局录制器实例（用于调度器任务）\n\n\n@lru_cache(maxsize=1)\ndef get_todo_recorder_instance() -> TodoScreenRecorder:\n    \"\"\"获取全局 Todo 录制器实例\n\n    Returns:\n        TodoScreenRecorder 实例\n    \"\"\"\n    return TodoScreenRecorder()\n\n\ndef execute_todo_capture_task() -> int:\n    \"\"\"执行 Todo 截图任务（供调度器调用的可序列化函数）\n\n    这是一个模块级别的函数，可以被 APScheduler 序列化到数据库中\n\n    Returns:\n        1 如果截图成功，0 如果未截图\n    \"\"\"\n    try:\n        logger.debug(\"🔄 [Todo录制器] 开始执行截图任务\")\n        recorder = get_todo_recorder_instance()\n        result = recorder.execute_capture()\n        return 1 if result else 0\n    except Exception as e:\n        logger.error(f\"[Todo录制器] 执行任务失败: {e}\", exc_info=True)\n        return 0\n"
  },
  {
    "path": "lifetrace/llm/activity_summary_service.py",
    "content": "\"\"\"\n活动摘要生成服务\n使用LLM为活动（聚合的事件）生成标题和摘要\n\"\"\"\n\nimport json\nfrom datetime import UTC, datetime\nfrom typing import Any\n\nfrom lifetrace.llm.llm_client import LLMClient\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.prompt_loader import get_prompt\nfrom lifetrace.util.token_usage_logger import log_token_usage\n\nlogger = get_logger()\n\n# 常量定义\nMAX_TITLE_LENGTH = 50  # 活动标题最大长度\nRESPONSE_PREVIEW_LENGTH = 500  # 响应预览文本长度\nMAX_FALLBACK_TITLES = 3  # 后备方案中最多显示的事件标题数量\n\n\nclass ActivitySummaryService:\n    \"\"\"活动摘要生成服务\"\"\"\n\n    def __init__(self):\n        \"\"\"初始化服务\"\"\"\n        self.llm_client = LLMClient()\n\n    def generate_activity_summary(\n        self,\n        events: list[dict[str, Any]],\n        start_time: datetime,\n        end_time: datetime,\n    ) -> dict[str, str] | None:\n        \"\"\"\n        为活动生成摘要\n\n        Args:\n            events: 事件列表，每个事件包含 ai_title 和 ai_summary\n            start_time: 活动开始时间\n            end_time: 活动结束时间\n\n        Returns:\n            {'title': str, 'summary': str} 或 None\n        \"\"\"\n        try:\n            if not events:\n                logger.warning(\"事件列表为空，无法生成活动摘要\")\n                return None\n\n            # 如果LLM不可用，使用后备方案\n            if not self.llm_client.is_available():\n                logger.warning(\"LLM客户端不可用，使用后备方案\")\n                return self._generate_fallback_summary(events, start_time, end_time)\n\n            # 准备输入数据，支持时间信息\n            event_summaries = []\n            for event in events:\n                title = event.get(\"ai_title\", \"\")\n                summary = event.get(\"ai_summary\", \"\")\n                # 支持 start_time 或 time 字段\n                event_time = event.get(\"start_time\") or event.get(\"time\")\n                if title or summary:\n                    event_data = {\"title\": title, \"summary\": summary}\n                    if event_time:\n                        event_data[\"time\"] = event_time\n                    event_summaries.append(event_data)\n\n            if not event_summaries:\n                logger.warning(\"所有事件都没有AI总结，使用后备方案\")\n                return self._generate_fallback_summary(events, start_time, end_time)\n\n            # 如果有时间信息，按时间排序\n            if any(\"time\" in e for e in event_summaries):\n                event_summaries.sort(\n                    key=lambda x: x.get(\"time\") or datetime.min.replace(tzinfo=UTC)\n                )\n\n            # 使用LLM生成总结\n            result = self._generate_summary_with_llm(\n                event_summaries=event_summaries,\n                start_time=start_time,\n                end_time=end_time,\n            )\n\n            # 如果LLM生成成功，返回结果；否则返回fallback\n            return (\n                result if result else self._generate_fallback_summary(events, start_time, end_time)\n            )\n\n        except Exception as e:\n            logger.error(f\"生成活动摘要时出错: {e}\", exc_info=True)\n            return self._generate_fallback_summary(events, start_time, end_time)\n\n    def _generate_summary_with_llm(\n        self,\n        event_summaries: list[dict[str, str]],\n        start_time: datetime,\n        end_time: datetime,\n    ) -> dict[str, str] | None:\n        \"\"\"\n        使用LLM生成活动标题和摘要\n\n        Args:\n            event_summaries: 事件摘要列表，每个包含 title 和 summary\n            start_time: 活动开始时间\n            end_time: 活动结束时间\n\n        Returns:\n            {'title': str, 'summary': str} 或 None\n        \"\"\"\n        try:\n            # 格式化时间\n            start_str = start_time.strftime(\"%Y-%m-%d %H:%M:%S\") if start_time else \"未知\"\n            end_str = end_time.strftime(\"%Y-%m-%d %H:%M:%S\") if end_time else \"进行中\"\n\n            # 构建事件摘要文本（按时间线格式）\n            events_text = \"\"\n            has_time_info = any(\"time\" in e for e in event_summaries)\n\n            for i, event in enumerate(event_summaries, 1):\n                title = event.get(\"title\", \"无标题\")\n                summary = event.get(\"summary\", \"无摘要\")\n\n                if has_time_info and \"time\" in event:\n                    # 如果有时间信息，按时间线格式呈现\n                    event_time = event.get(\"time\")\n                    if isinstance(event_time, datetime):\n                        time_str = event_time.strftime(\"%H:%M:%S\")\n                    else:\n                        time_str = str(event_time)\n                    events_text += f\"{i}. [{time_str}] {title}\\n   {summary}\\n\\n\"\n                else:\n                    # 无时间信息，使用原有格式\n                    events_text += f\"{i}. 标题：{title}\\n   摘要：{summary}\\n\\n\"\n\n            # 从配置文件加载提示词\n            system_prompt = get_prompt(\"activity_summary\", \"system_assistant\")\n            user_prompt = get_prompt(\n                \"activity_summary\",\n                \"user_prompt\",\n                start_time=start_str,\n                end_time=end_str,\n                events_text=events_text,\n                event_count=len(event_summaries),\n            )\n\n            # 调用LLM（增加max_tokens以支持结构化摘要）\n            client = self.llm_client._get_client()\n            response = client.chat.completions.create(\n                model=self.llm_client.model,\n                messages=[\n                    {\"role\": \"system\", \"content\": system_prompt},\n                    {\"role\": \"user\", \"content\": user_prompt},\n                ],\n                temperature=0.3,\n                max_tokens=1000,  # 增加token限制以支持结构化摘要（500字中文约需1000 tokens）\n            )\n\n            # 记录token使用量\n            if hasattr(response, \"usage\") and response.usage:\n                log_token_usage(\n                    model=self.llm_client.model,\n                    input_tokens=response.usage.prompt_tokens,\n                    output_tokens=response.usage.completion_tokens,\n                    endpoint=\"activity_summary\",\n                    response_type=\"summary_generation\",\n                    feature_type=\"activity_summary\",\n                )\n\n            # 解析响应\n            content = (response.choices[0].message.content or \"\").strip()\n            if content:\n                extracted_content, original_content = self._extract_json_from_response(content)\n                if extracted_content:\n                    result = self._parse_llm_response(extracted_content, original_content)\n                    if result:\n                        return result\n                else:\n                    logger.warning(f\"提取JSON后内容为空，原始响应: {original_content[:200]}\")\n            else:\n                logger.warning(\"LLM返回空内容，使用后备方案\")\n\n        except Exception as e:\n            logger.error(f\"LLM生成活动摘要失败: {e}\", exc_info=True)\n\n        return None\n\n    def _extract_json_from_response(self, content: str) -> tuple[str, str]:\n        \"\"\"从LLM响应中提取JSON内容\n\n        Returns:\n            (提取的JSON内容, 原始内容)\n        \"\"\"\n        original_content = content\n        if \"```json\" in content:\n            json_start = content.find(\"```json\") + 7\n            json_end = content.find(\"```\", json_start)\n            content = content[json_start:json_end].strip()\n        elif \"```\" in content:\n            json_start = content.find(\"```\") + 3\n            json_end = content.find(\"```\", json_start)\n            content = content[json_start:json_end].strip()\n        return content, original_content\n\n    def _parse_llm_response(self, content: str, original_content: str) -> dict[str, str] | None:\n        \"\"\"解析LLM响应为字典\n\n        Returns:\n            解析后的结果，如果失败则返回None\n        \"\"\"\n        try:\n            result = json.loads(content)\n            if \"title\" in result and \"summary\" in result:\n                title = result[\"title\"][:MAX_TITLE_LENGTH]\n                summary = result[\"summary\"][:1500]  # 摘要限制在1500字符（约500-750中文字）\n                return {\"title\": title, \"summary\": summary}\n            logger.warning(f\"LLM返回格式不正确: {result}\")\n            return None\n        except json.JSONDecodeError as e:\n            preview = (\n                original_content[:RESPONSE_PREVIEW_LENGTH]\n                if len(original_content) > RESPONSE_PREVIEW_LENGTH\n                else original_content\n            )\n            logger.error(f\"解析LLM响应JSON失败: {e}\\n原始响应: {preview[:200]}\")\n            return None\n\n    def _generate_fallback_summary(\n        self,\n        events: list[dict[str, Any]],\n        start_time: datetime,\n        end_time: datetime,\n    ) -> dict[str, str]:\n        \"\"\"\n        无LLM时的后备方案\n        基于事件标题生成简单描述\n        \"\"\"\n        _ = start_time\n        _ = end_time\n        if not events:\n            return {\"title\": \"无活动\", \"summary\": \"该时间段内无活动记录\"}\n\n        # 收集所有事件的标题\n        titles = []\n        for event in events:\n            title = event.get(\"ai_title\", \"\")\n            if title:\n                titles.append(title)\n\n        if not titles:\n            return {\"title\": \"活动记录\", \"summary\": f\"包含 {len(events)} 个事件\"}\n\n        # 生成简单标题（取第一个标题或合并）\n        title = titles[0] if len(titles) == 1 else f\"{titles[0]}等{len(titles)}项活动\"\n\n        # 生成简单摘要\n        summary = f\"包含 {len(events)} 个事件：\"\n        for i, t in enumerate(titles[:MAX_FALLBACK_TITLES], 1):  # 最多显示MAX_FALLBACK_TITLES个\n            summary += f\"{i}. {t}；\"\n        if len(titles) > MAX_FALLBACK_TITLES:\n            summary += f\"等共{len(titles)}项\"\n\n        return {\"title\": title[:MAX_TITLE_LENGTH], \"summary\": summary}\n\n\n# 全局实例\nactivity_summary_service = ActivitySummaryService()\n"
  },
  {
    "path": "lifetrace/llm/agent_service.py",
    "content": "\"\"\"Agent 服务，管理工具调用工作流\"\"\"\n\nimport json\nfrom collections.abc import Generator\nfrom typing import TYPE_CHECKING, Any, cast\n\nif TYPE_CHECKING:\n    from openai.types.chat import ChatCompletionMessageParam\nelse:\n    ChatCompletionMessageParam = Any\n\n# 导入工具模块以触发工具注册\nfrom lifetrace.llm import tools  # noqa: F401\nfrom lifetrace.llm.llm_client import LLMClient\nfrom lifetrace.llm.tools.base import ToolResult\nfrom lifetrace.llm.tools.registry import ToolRegistry\nfrom lifetrace.util.language import get_language_instruction\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.prompt_loader import get_prompt\n\nlogger = get_logger()\n\n\nclass AgentService:\n    \"\"\"Agent 服务，管理工具调用工作流\"\"\"\n\n    MAX_TOOL_CALLS = 5  # 最大工具调用次数\n    MAX_ITERATIONS = 10  # 最大迭代次数\n\n    def __init__(self):\n        \"\"\"初始化 Agent 服务\"\"\"\n        self.llm_client = LLMClient()\n        # 使用单例模式的工具注册表（工具已在 tools/__init__.py 中注册）\n        self.tool_registry = ToolRegistry()\n\n    def stream_agent_response(\n        self,\n        user_query: str,\n        todo_context: str | None = None,\n        conversation_history: list[dict] | None = None,\n        lang: str = \"zh\",\n    ) -> Generator[str]:\n        \"\"\"\n        流式生成 Agent 回答\n\n        工作流：\n        1. 工具选择：LLM 判断是否需要工具\n        2. 工具执行：执行选中的工具\n        3. 任务评估：LLM 评估任务是否完成\n        4. 循环控制：如果未完成，重新进入工具选择\n        \"\"\"\n        tool_call_count = 0\n        iteration_count = 0\n        accumulated_context = []\n\n        # 构建初始消息\n        messages = self._build_initial_messages(\n            user_query,\n            todo_context,\n            conversation_history,\n            lang,\n        )\n\n        while iteration_count < self.MAX_ITERATIONS:\n            iteration_count += 1\n            logger.info(f\"[Agent] 迭代 {iteration_count}/{self.MAX_ITERATIONS}\")\n\n            # 步骤1: 工具选择\n            tool_decision = self._decide_tool_usage(messages, tool_call_count)\n\n            if tool_decision[\"use_tool\"]:\n                # 步骤2: 执行工具\n                if tool_call_count >= self.MAX_TOOL_CALLS:\n                    yield \"\\n[提示] 已达到最大工具调用次数限制，将基于已有信息生成回答。\\n\\n\"\n                    break\n\n                tool_name = tool_decision[\"tool_name\"]\n                tool_params = tool_decision.get(\"tool_params\", {})\n\n                # 构建工具调用标记，包含参数信息（特别是搜索关键词）\n                if tool_name == \"web_search\" and \"query\" in tool_params:\n                    # 对于 web_search，显示搜索关键词\n                    yield f\"\\n[使用工具: {tool_name} | 关键词: {tool_params['query']}]\\n\\n\"\n                else:\n                    # 其他工具，显示工具名称和参数（如果有）\n                    params_str = \", \".join([f\"{k}: {v}\" for k, v in tool_params.items()])\n                    if params_str:\n                        yield f\"\\n[使用工具: {tool_name} | {params_str}]\\n\\n\"\n                    else:\n                        yield f\"\\n[使用工具: {tool_name}]\\n\\n\"\n\n                tool_result = self._execute_tool(tool_name, tool_params)\n                tool_call_count += 1\n\n                # 将工具结果添加到上下文\n                tool_context = self._format_tool_result(tool_name, tool_result)\n                accumulated_context.append(tool_context)\n\n                # 更新消息历史\n                messages.append(\n                    {\n                        \"role\": \"assistant\",\n                        \"content\": f\"[工具调用: {tool_name}]\",\n                    }\n                )\n                messages.append(\n                    {\n                        \"role\": \"user\",\n                        \"content\": f\"[工具结果]\\n{tool_context}\",\n                    }\n                )\n\n                # 步骤3: 任务评估\n                should_continue = self._evaluate_task_completion(\n                    user_query,\n                    messages,\n                    tool_result,\n                )\n\n                if not should_continue:\n                    logger.info(\"[Agent] 任务评估：可以生成最终回答\")\n                    break\n            else:\n                # 不需要工具，直接生成回答\n                logger.info(\"[Agent] 不需要工具，直接生成回答\")\n                break\n\n        # 步骤4: 生成最终回答\n        yield from self._generate_final_response(\n            user_query,\n            messages,\n            accumulated_context,\n        )\n\n    def _build_initial_messages(\n        self,\n        user_query: str,\n        todo_context: str | None,\n        conversation_history: list[dict] | None,\n        lang: str = \"zh\",\n    ) -> list[dict]:\n        \"\"\"构建初始消息列表\"\"\"\n        messages = []\n\n        # 系统提示词\n        system_prompt = get_prompt(\"agent\", \"system\")\n        if not system_prompt:\n            system_prompt = self._get_default_system_prompt()\n        # 注入语言指令\n        system_prompt += get_language_instruction(lang)\n        messages.append({\"role\": \"system\", \"content\": system_prompt})\n\n        # 添加待办上下文（如果有）\n        if todo_context:\n            messages.append(\n                {\n                    \"role\": \"user\",\n                    \"content\": f\"用户当前的待办事项上下文：\\n{todo_context}\\n\\n\",\n                }\n            )\n\n        # 添加对话历史（如果有）\n        if conversation_history:\n            messages.extend(conversation_history)\n\n        # 添加当前用户查询\n        messages.append({\"role\": \"user\", \"content\": user_query})\n\n        return messages\n\n    def _decide_tool_usage(\n        self,\n        messages: list[dict],\n        tool_call_count: int,\n    ) -> dict[str, Any]:\n        \"\"\"\n        决定是否需要使用工具\n\n        Returns:\n            {\n                \"use_tool\": bool,\n                \"tool_name\": str | None,\n                \"tool_params\": dict | None\n            }\n        \"\"\"\n        if tool_call_count >= self.MAX_TOOL_CALLS:\n            return {\"use_tool\": False, \"tool_name\": None, \"tool_params\": None}\n\n        # 获取可用工具列表\n        available_tools = self.tool_registry.get_available_tools()\n        if not available_tools:\n            return {\"use_tool\": False, \"tool_name\": None, \"tool_params\": None}\n\n        # 构建工具选择提示词\n        tools_schema = self.tool_registry.get_tools_schema()\n        tool_selection_prompt = get_prompt(\n            \"agent\",\n            \"tool_selection\",\n            tools=json.dumps(tools_schema, ensure_ascii=False, indent=2),\n            user_query=messages[-1][\"content\"] if messages else \"\",\n        )\n\n        if not tool_selection_prompt:\n            tool_selection_prompt = self._get_default_tool_selection_prompt(\n                tools_schema,\n            )\n\n        # 调用 LLM 进行工具选择\n        try:\n            decision_messages = self._build_tool_decision_messages(messages, tool_selection_prompt)\n            decision = self._call_llm_for_tool_selection(decision_messages)\n\n            if decision:\n                use_tool = decision.get(\"use_tool\", False)\n                tool_name = decision.get(\"tool_name\")\n                tool_params = decision.get(\"tool_params\", {})\n\n                if use_tool and tool_name:\n                    logger.info(\n                        f\"[Agent] 选择工具: {tool_name}, 参数: {tool_params}\",\n                    )\n                    return {\n                        \"use_tool\": True,\n                        \"tool_name\": tool_name,\n                        \"tool_params\": tool_params,\n                    }\n        except Exception as e:\n            logger.error(f\"[Agent] 工具选择失败: {e}\")\n\n        return {\"use_tool\": False, \"tool_name\": None, \"tool_params\": None}\n\n    def _build_tool_decision_messages(\n        self, messages: list[dict], tool_selection_prompt: str\n    ) -> list[dict]:\n        \"\"\"构建工具选择决策消息，包含完整的上下文但排除工具相关消息\"\"\"\n        decision_messages = [{\"role\": \"system\", \"content\": tool_selection_prompt}]\n\n        # 添加所有非工具相关的消息（保留待办上下文和对话历史）\n        for msg in messages:\n            # 跳过系统提示词（使用新的工具选择提示词）\n            if msg.get(\"role\") == \"system\":\n                continue\n            content = msg.get(\"content\", \"\")\n            # 跳过工具调用和工具结果相关的消息\n            if content.startswith(\"[工具调用:\") or content.startswith(\"[工具结果]\"):\n                continue\n            # 保留待办上下文、对话历史和用户查询\n            decision_messages.append(msg)\n\n        return decision_messages\n\n    def _call_llm_for_tool_selection(self, decision_messages: list[dict]) -> dict[str, Any] | None:\n        \"\"\"调用 LLM 进行工具选择并解析响应\"\"\"\n        client = self.llm_client._get_client()\n        response = client.chat.completions.create(\n            model=self.llm_client.model,\n            messages=cast(\"list[ChatCompletionMessageParam]\", decision_messages),\n            temperature=0.1,  # 低温度确保稳定决策\n            max_tokens=200,\n        )\n\n        decision_text = (response.choices[0].message.content or \"\").strip()\n\n        # 解析 JSON 响应\n        try:\n            # 清理可能的 markdown 代码块\n            clean_text = decision_text.strip()\n            if clean_text.startswith(\"```json\"):\n                clean_text = clean_text[7:]\n            if clean_text.endswith(\"```\"):\n                clean_text = clean_text[:-3]\n            clean_text = clean_text.strip()\n\n            return json.loads(clean_text)\n        except json.JSONDecodeError:\n            logger.warning(\n                f\"[Agent] 工具选择响应解析失败: {decision_text}\",\n            )\n            return None\n\n    def _execute_tool(self, tool_name: str, tool_params: dict) -> ToolResult:\n        \"\"\"执行工具\"\"\"\n        tool = self.tool_registry.get_tool(tool_name)\n        if not tool:\n            return ToolResult(\n                success=False,\n                content=\"\",\n                error=f\"工具 {tool_name} 不存在\",\n            )\n\n        try:\n            return tool.execute(**tool_params)\n        except Exception as e:\n            logger.error(f\"[Agent] 工具执行失败: {e}\")\n            return ToolResult(\n                success=False,\n                content=\"\",\n                error=str(e),\n            )\n\n    def _format_tool_result(self, tool_name: str, result: ToolResult) -> str:\n        \"\"\"格式化工具结果\"\"\"\n        if not result.success:\n            return f\"工具 {tool_name} 执行失败: {result.error}\"\n\n        formatted = f\"工具 {tool_name} 执行结果：\\n{result.content}\"\n\n        # 如果有来源信息，添加到末尾\n        if result.metadata and \"sources\" in result.metadata:\n            sources = result.metadata[\"sources\"]\n            formatted += \"\\n\\nSources:\"\n            for idx, source in enumerate(sources, start=1):\n                formatted += f\"\\n{idx}. {source['title']} ({source['url']})\"\n\n        return formatted\n\n    def _evaluate_task_completion(\n        self,\n        user_query: str,\n        messages: list[dict],\n        tool_result: ToolResult,\n    ) -> bool:\n        \"\"\"\n        评估任务是否完成\n\n        Returns:\n            True: 需要继续使用工具\n            False: 可以生成最终回答\n        \"\"\"\n        _ = messages\n        # 如果工具执行失败，继续尝试\n        if not tool_result.success:\n            return True\n\n        # 使用 LLM 评估\n        evaluation_prompt = get_prompt(\n            \"agent\",\n            \"task_evaluation\",\n            user_query=user_query,\n            tool_result=tool_result.content[:500],  # 限制长度\n        )\n\n        if not evaluation_prompt:\n            evaluation_prompt = self._get_default_evaluation_prompt()\n\n        try:\n            eval_messages = [\n                {\"role\": \"system\", \"content\": evaluation_prompt},\n                {\n                    \"role\": \"user\",\n                    \"content\": (f\"用户查询: {user_query}\\n\\n工具结果: {tool_result.content[:500]}\"),\n                },\n            ]\n\n            client = self.llm_client._get_client()\n            response = client.chat.completions.create(\n                model=self.llm_client.model,\n                messages=cast(\"list[ChatCompletionMessageParam]\", eval_messages),\n                temperature=0.1,\n                max_tokens=100,\n            )\n\n            eval_text = (response.choices[0].message.content or \"\").strip().lower()\n\n            # 简单判断：如果包含\"完成\"、\"足够\"等关键词，认为可以生成回答\n            completion_keywords = [\"完成\", \"足够\", \"可以\", \"complete\", \"sufficient\"]\n            return not any(keyword in eval_text for keyword in completion_keywords)\n        except Exception as e:\n            logger.error(f\"[Agent] 任务评估失败: {e}\")\n            # 默认继续\n            return True\n\n    def _generate_final_response(\n        self,\n        user_query: str,\n        messages: list[dict],\n        accumulated_context: list[str],\n    ) -> Generator[str]:\n        \"\"\"生成最终回答\"\"\"\n        # 构建包含所有工具结果的最终消息\n        final_messages = messages.copy()\n\n        # 检查是否使用了 web_search 工具（通过检查消息历史）\n        used_web_search = any(\n            msg.get(\"content\", \"\").startswith(\"[工具调用: web_search]\") for msg in messages\n        )\n\n        if accumulated_context:\n            # 如果有工具结果，构建强调工具结果的用户消息\n            context_text = \"\\n\\n\".join(accumulated_context)\n            logger.info(\n                f\"[Agent] 生成最终回答，工具结果长度: {len(context_text)} 字符\",\n            )\n\n            # 构建用户消息\n            base_instruction = (\n                f\"用户问题：{user_query}\\n\\n\"\n                f\"工具执行结果：\\n{context_text}\\n\\n\"\n                \"请严格基于上述工具执行结果回答用户的问题。\"\n                \"如果工具结果中包含相关信息，必须使用这些信息。\"\n                \"不要使用过时的知识或猜测，只基于工具提供的搜索结果。\"\n                \"当工具结果与你的训练数据冲突时，以工具结果为准（工具结果代表最新的实时信息）。\"\n            )\n\n            # 如果使用了 web_search，添加 Sources 格式要求\n            if used_web_search:\n                base_instruction += (\n                    \"\\n\\n**重要格式要求（必须严格遵守）：**\"\n                    \"\\n1. 在回答中引用信息时，必须使用引用标记格式：[[1]]、[[2]] 等，数字对应搜索结果编号\"\n                    '\\n2. 在回答的末尾，必须添加一个 \"Sources:\" 段落，列出所有引用的来源'\n                    \"\\n3. Sources 段落的格式必须严格按照以下格式（与工具执行结果中的格式一致）：\"\n                    \"\\n   Sources:\"\n                    \"\\n   1. 标题 (URL)\"\n                    \"\\n   2. 标题 (URL)\"\n                    \"\\n   ...\"\n                    \"\\n4. 工具执行结果中已经包含了 Sources 列表，请直接使用这些来源信息，不要修改格式\"\n                    '\\n5. 确保 Sources 段落与回答正文之间有两个空行（即 \"\\\\n\\\\nSources:\"）'\n                )\n\n            final_messages.append(\n                {\n                    \"role\": \"user\",\n                    \"content\": base_instruction,\n                }\n            )\n        else:\n            # 没有工具结果，直接基于原始查询回答\n            # 重要：明确告诉 LLM 不要假装使用工具\n            final_messages.append(\n                {\n                    \"role\": \"user\",\n                    \"content\": (\n                        f\"{user_query}\\n\\n\"\n                        \"**重要提示：**\\n\"\n                        \"本次回答没有使用任何工具。请直接基于你的知识回答，\"\n                        \"不要提及'正在搜索'、'使用工具'、'工具执行'、'web_search'等词汇，\"\n                        \"不要生成工具调用的描述，不要假装使用了工具。\"\n                        \"如果问题需要最新信息但你无法提供，请诚实说明。\"\n                    ),\n                }\n            )\n\n        # 流式生成回答\n        try:\n            yield from self.llm_client.stream_chat(\n                messages=final_messages,\n                temperature=0.7,\n            )\n        except Exception as e:\n            logger.error(f\"[Agent] 生成最终回答失败: {e}\")\n            yield f\"生成回答时出现错误: {e!s}\"\n\n    def _get_default_system_prompt(self) -> str:\n        \"\"\"默认系统提示词\"\"\"\n        return \"\"\"你是一个智能助手，可以使用工具来帮助用户完成任务。\n你可以使用以下工具：\n- web_search: 联网搜索最新信息\n\n当用户需要实时信息、最新资讯时，你应该使用 web_search 工具。\n使用工具后，基于工具返回的结果生成准确、有用的回答。\"\"\"\n\n    def _get_default_tool_selection_prompt(\n        self,\n        tools_schema: list[dict],\n    ) -> str:\n        \"\"\"默认工具选择提示词\"\"\"\n        tools_desc = \"\\n\".join(\n            [f\"- {tool['name']}: {tool['description']}\" for tool in tools_schema]\n        )\n        return f\"\"\"分析用户查询，判断是否需要使用工具。\n\n可用工具：\n{tools_desc}\n\n请以 JSON 格式返回：\n{{\n    \"use_tool\": true/false,\n    \"tool_name\": \"工具名称\" 或 null,\n    \"tool_params\": {{\"参数名\": \"参数值\"}} 或 {{}}\n}}\n\n只返回 JSON，不要返回其他信息。\"\"\"\n\n    def _get_default_evaluation_prompt(self) -> str:\n        \"\"\"默认任务评估提示词\"\"\"\n        return \"\"\"评估工具执行结果是否足够回答用户的问题。\n\n如果工具结果已经包含足够信息来回答用户问题，返回\"完成\"。\n如果需要更多信息，返回\"继续\"。\n\n只返回\"完成\"或\"继续\"。\"\"\"\n"
  },
  {
    "path": "lifetrace/llm/agno_agent.py",
    "content": "\"\"\"Agno Agent 服务，基于 Agno 框架的通用 Agent 实现\n\n支持 FreeTodoToolkit 工具集和国际化消息。\n支持工具调用事件流，可在前端实时展示 Agent 执行步骤。\n支持 Phoenix + OpenInference 观测（通过配置启用）。\n支持 session_id 传递，实现按会话聚合 trace 文件。\n支持外部工具（如 DuckDuckGo 搜索）。\n\"\"\"\n\nfrom __future__ import annotations\n\nimport importlib\nimport inspect\nimport json\nfrom contextvars import ContextVar\nfrom pathlib import Path\nfrom typing import TYPE_CHECKING\n\nfrom agno.agent import Agent, Message, RunEvent\nfrom agno.models.openai.like import OpenAILike\n\nfrom lifetrace.llm.agno_tools import FreeTodoToolkit\nfrom lifetrace.llm.agno_tools.base import get_message\nfrom lifetrace.observability import setup_observability\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.settings import settings\n\nif TYPE_CHECKING:\n    from collections.abc import Generator\n\n    from agno.tools import Toolkit\n\n# 全局 ContextVar 用于跨 span 传递 session_id\n# file_exporter 可以读取这个值来按 session 聚合文件\ncurrent_session_id: ContextVar[str | None] = ContextVar(\"current_session_id\", default=None)\n\nlogger = get_logger()\n\n# 初始化观测系统（在模块加载时执行一次）\n# 如果配置中 observability.enabled = false，则不会有任何影响\nsetup_observability()\n\n# Default language, can be overridden from settings\nDEFAULT_LANG = \"en\"\n\n# 工具调用事件标记（用于流式输出中区分内容和工具调用事件）\nTOOL_EVENT_PREFIX = \"\\n[TOOL_EVENT:\"\nTOOL_EVENT_SUFFIX = \"]\\n\"\n\n# 工具结果预览最大长度\nRESULT_PREVIEW_MAX_LENGTH = 500\n\n# 可用的外部工具映射\nEXTERNAL_TOOLS_REGISTRY: dict[str, type[Toolkit]] = {}\n\n\ndef _try_register_tool(name: str, module_path: str, class_name: str, warning: str = \"\"):\n    \"\"\"尝试注册单个工具\"\"\"\n    try:\n        module = importlib.import_module(module_path)\n        tool_class = getattr(module, class_name)\n        EXTERNAL_TOOLS_REGISTRY[name] = tool_class\n        logger.debug(f\"已注册外部工具: {name}\")\n    except ImportError:\n        logger.warning(warning or f\"无法导入 {class_name}\")\n\n\ndef _ensure_tool_dependency(tool_name: str, package_name: str) -> bool:\n    \"\"\"检查外部工具依赖是否可用\"\"\"\n    try:\n        importlib.import_module(package_name)\n    except ImportError:\n        logger.warning(f\"{tool_name} 工具依赖 {package_name} 包，未安装，跳过注册\")\n        return False\n    return True\n\n\ndef _register_external_tools():\n    \"\"\"注册可用的外部工具（延迟导入以避免启动时的依赖问题）\"\"\"\n    if EXTERNAL_TOOLS_REGISTRY:\n        return\n\n    # 工具注册配置: (名称, 模块路径, 类名, 警告信息, 依赖包)\n    tools_config = [\n        # 搜索类工具\n        (\"websearch\", \"agno.tools.websearch\", \"WebSearchTools\", \"请确保已安装 ddgs 包\", \"ddgs\"),\n        (\"hackernews\", \"agno.tools.hackernews\", \"HackerNewsTools\", \"\", None),\n        # 本地工具\n        (\"file\", \"agno.tools.file\", \"FileTools\", \"\", None),\n        (\"local_fs\", \"agno.tools.local_file_system\", \"LocalFileSystemTools\", \"\", None),\n        (\"shell\", \"agno.tools.shell\", \"ShellTools\", \"\", None),\n        (\"sleep\", \"agno.tools.sleep\", \"SleepTools\", \"\", None),\n    ]\n\n    for name, module_path, class_name, warning, dependency in tools_config:\n        if dependency and not _ensure_tool_dependency(name, dependency):\n            continue\n        _try_register_tool(name, module_path, class_name, warning)\n\n\ndef get_available_external_tools() -> list[str]:\n    \"\"\"获取可用的外部工具列表\"\"\"\n    _register_external_tools()\n    return list(EXTERNAL_TOOLS_REGISTRY.keys())\n\n\ndef _create_file_tool(tool_class, **kwargs) -> Toolkit | None:\n    \"\"\"创建 FileTools 实例\"\"\"\n    base_dir = kwargs.get(\"base_dir\")\n    if not base_dir:\n        logger.warning(\"FileTools 需要 base_dir 参数，跳过创建\")\n        return None\n    # FileTools 需要 Path 对象，而不是字符串\n    base_dir_path = Path(base_dir) if isinstance(base_dir, str) else base_dir\n    return tool_class(\n        base_dir=base_dir_path,\n        enable_save_file=True,\n        enable_read_file=True,\n        enable_read_file_chunk=True,\n        enable_replace_file_chunk=True,\n        enable_list_files=True,\n        enable_search_files=True,\n        enable_delete_file=kwargs.get(\"enable_delete\", False),\n    )\n\n\ndef _safe_tool_init(tool_class, **kwargs) -> Toolkit:\n    \"\"\"安全初始化工具，兼容不同版本的构造参数\"\"\"\n    try:\n        return tool_class(**kwargs)\n    except TypeError as exc:\n        if \"unexpected keyword argument\" not in str(exc):\n            raise\n        try:\n            sig = inspect.signature(tool_class.__init__)\n        except (TypeError, ValueError):\n            return tool_class()\n        allowed_kwargs = {k: v for k, v in kwargs.items() if k in sig.parameters}\n        if not allowed_kwargs:\n            return tool_class()\n        return tool_class(**allowed_kwargs)\n\n\ndef create_external_tool(tool_name: str, **kwargs) -> Toolkit | None:  # noqa: PLR0911\n    \"\"\"创建外部工具实例\n\n    可用工具:\n        搜索类: websearch, hackernews\n        本地类: file(需要base_dir), local_fs, shell, sleep\n    \"\"\"\n    _register_external_tools()\n    tool_class = EXTERNAL_TOOLS_REGISTRY.get(tool_name)\n    if not tool_class:\n        return None\n\n    base_dir = kwargs.get(\"base_dir\")\n\n    # 搜索类工具\n    if tool_name == \"websearch\":\n        return _safe_tool_init(tool_class, backend=\"auto\", search=True, news=True)\n    if tool_name in (\"hackernews\", \"sleep\"):\n        return _safe_tool_init(tool_class)\n\n    # 本地工具\n    if tool_name == \"file\":\n        return _create_file_tool(tool_class, **kwargs)\n    if tool_name == \"local_fs\":\n        # 确保使用 Path 对象\n        base_dir_path = Path(base_dir) if isinstance(base_dir, str) else base_dir\n        return (\n            _safe_tool_init(tool_class, target_directory=base_dir_path)\n            if base_dir\n            else _safe_tool_init(tool_class)\n        )\n    if tool_name == \"shell\":\n        # 确保使用 Path 对象\n        base_dir_path = Path(base_dir) if isinstance(base_dir, str) else base_dir\n        return (\n            _safe_tool_init(tool_class, base_dir=base_dir_path)\n            if base_dir\n            else _safe_tool_init(tool_class)\n        )\n\n    return _safe_tool_init(tool_class)\n\n\ndef _build_instructions(\n    lang: str,\n    has_tools: bool,\n    use_all_freetodo_tools: bool,\n    has_external_tools: bool,\n) -> list[str] | None:\n    \"\"\"构建 Agent 的 instructions\n\n    Args:\n        lang: 语言代码\n        has_tools: 是否有任何工具启用\n        use_all_freetodo_tools: 是否使用全部 FreeTodo 工具\n        has_external_tools: 是否有外部工具\n\n    Returns:\n        instructions 列表或 None\n    \"\"\"\n    if use_all_freetodo_tools and not has_external_tools:\n        # Load full instructions from agno_tools/{lang}/instructions.yaml\n        instructions = get_message(lang, \"instructions\")\n        return [instructions] if instructions and instructions != \"[instructions]\" else None\n\n    # 简化的 instructions\n    if lang == \"zh\":\n        if has_tools:\n            return [\n                \"你是 FreeTodo 智能助手，可以帮助用户管理待办事项和执行各种任务。\"\n                \"请根据用户的问题选择合适的工具来完成任务。\"\n            ]\n        return [\"你是 FreeTodo 智能助手。当前没有启用任何工具，请直接回答用户的问题。\"]\n\n    # English\n    if has_tools:\n        return [\n            \"You are the FreeTodo assistant that helps users manage their todos \"\n            \"and perform various tasks. Use the appropriate tools to complete tasks.\"\n        ]\n    return [\n        \"You are the FreeTodo assistant. No tools are currently enabled. \"\n        \"Please answer the user's questions directly.\"\n    ]\n\n\nclass AgnoAgentService:\n    \"\"\"Agno Agent 服务，提供基于 Agno 框架的智能对话能力\n\n    Supports:\n    - FreeTodoToolkit for todo management\n    - External tools (DuckDuckGo search, etc.)\n    - Internationalization (i18n) through lang parameter\n    - Streaming responses\n    \"\"\"\n\n    def __init__(\n        self,\n        lang: str | None = None,\n        selected_tools: list[str] | None = None,\n        external_tools: list[str] | None = None,\n        external_tools_config: dict[str, dict] | None = None,\n    ):\n        \"\"\"初始化 Agno Agent 服务\n\n        Args:\n            lang: Language code for messages ('zh' or 'en').\n                  If None, uses DEFAULT_LANG or settings default.\n            selected_tools: List of FreeTodo tool names to enable.\n                           If None or empty, no FreeTodo tools are enabled.\n            external_tools: List of external tool names to enable (e.g., ['duckduckgo', 'file']).\n                           If None or empty, no external tools are enabled.\n            external_tools_config: Configuration dict for external tools.\n                           Example: {\"file\": {\"base_dir\": \"/path/to/workspace\", \"enable_delete\": False}}\n        \"\"\"\n        try:\n            self.lang = lang or DEFAULT_LANG\n            tools_to_use = self._initialize_tools(\n                selected_tools, external_tools, external_tools_config\n            )\n\n            # 判断工具配置\n            total_freetodo_tools_count = 14\n            use_all_freetodo_tools = bool(\n                selected_tools and len(selected_tools) == total_freetodo_tools_count\n            )\n            has_external_tools = bool(external_tools and len(external_tools) > 0)\n\n            instructions_list = _build_instructions(\n                self.lang, bool(tools_to_use), use_all_freetodo_tools, has_external_tools\n            )\n\n            self.agent = Agent(\n                model=OpenAILike(\n                    id=settings.llm.model,\n                    api_key=settings.llm.api_key,\n                    base_url=settings.llm.base_url,\n                ),\n                tools=tools_to_use if tools_to_use else None,\n                instructions=instructions_list,\n                markdown=True,\n            )\n            logger.info(\n                f\"Agno Agent 初始化成功，模型: {settings.llm.model}, \"\n                f\"Base URL: {settings.llm.base_url}, lang: {self.lang}, \"\n                f\"工具数量: {len(tools_to_use)}\",\n            )\n        except Exception as e:\n            logger.error(f\"Agno Agent 初始化失败: {e}\")\n            raise\n\n    def _initialize_tools(\n        self,\n        selected_tools: list[str] | None,\n        external_tools: list[str] | None,\n        external_tools_config: dict[str, dict] | None = None,\n    ) -> list[Toolkit]:\n        \"\"\"初始化工具列表\n\n        Args:\n            selected_tools: FreeTodo 工具名称列表\n            external_tools: 外部工具名称列表\n            external_tools_config: 外部工具配置字典，如 {\"file\": {\"base_dir\": \"/path\"}}\n        \"\"\"\n        tools_to_use: list[Toolkit] = []\n        external_tools_config = external_tools_config or {}\n\n        # Initialize FreeTodoToolkit if any tools are selected\n        if selected_tools and len(selected_tools) > 0:\n            toolkit = FreeTodoToolkit(lang=self.lang, selected_tools=selected_tools)\n            tools_to_use.append(toolkit)\n            logger.info(f\"已启用 FreeTodo 工具: {selected_tools}\")\n\n        # Initialize external tools with config\n        if external_tools and len(external_tools) > 0:\n            for tool_name in external_tools:\n                # 获取该工具的配置\n                config = external_tools_config.get(tool_name, {})\n                external_tool = create_external_tool(tool_name, **config)\n                if external_tool:\n                    tools_to_use.append(external_tool)\n                    logger.info(f\"已启用外部工具: {tool_name}, 配置: {config}\")\n                else:\n                    logger.warning(f\"未找到或无法创建外部工具: {tool_name}\")\n\n        return tools_to_use\n\n    def _build_input_data(\n        self,\n        message: str,\n        conversation_history: list[dict[str, str]] | None,\n    ):\n        \"\"\"构建 Agent 输入数据\"\"\"\n        if not conversation_history:\n            return message\n\n        messages = []\n        for msg in conversation_history:\n            role = msg.get(\"role\", \"user\")\n            content = msg.get(\"content\", \"\")\n            if role in (\"user\", \"assistant\"):\n                messages.append(Message(role=role, content=content))\n        messages.append(Message(role=\"user\", content=message))\n        return messages\n\n    def _format_tool_event(self, event_data: dict) -> str:\n        \"\"\"格式化工具事件为输出字符串\"\"\"\n        return f\"{TOOL_EVENT_PREFIX}{json.dumps(event_data, ensure_ascii=False)}{TOOL_EVENT_SUFFIX}\"\n\n    def _handle_tool_call_started(self, chunk) -> str | None:\n        \"\"\"处理工具调用开始事件\"\"\"\n        tool_info = getattr(chunk, \"tool\", None)\n        if not tool_info:\n            return None\n        event_data = {\n            \"type\": \"tool_call_start\",\n            \"tool_name\": getattr(tool_info, \"tool_name\", \"unknown\"),\n            \"tool_args\": getattr(tool_info, \"tool_args\", {}),\n        }\n        logger.debug(f\"工具调用开始: {event_data['tool_name']}, 参数: {event_data['tool_args']}\")\n        return self._format_tool_event(event_data)\n\n    def _handle_tool_call_completed(self, chunk) -> str | None:\n        \"\"\"处理工具调用完成事件\"\"\"\n        tool_info = getattr(chunk, \"tool\", None)\n        if not tool_info:\n            return None\n        result = getattr(tool_info, \"result\", \"\")\n        result_str = str(result)\n        result_preview = (\n            result_str[:RESULT_PREVIEW_MAX_LENGTH] + \"...\"\n            if len(result_str) > RESULT_PREVIEW_MAX_LENGTH\n            else result_str\n        )\n        event_data = {\n            \"type\": \"tool_call_end\",\n            \"tool_name\": getattr(tool_info, \"tool_name\", \"unknown\"),\n            \"result_preview\": result_preview,\n        }\n        logger.debug(\n            f\"工具调用完成: {event_data['tool_name']}, 结果预览: {result_preview[:100]}...\"\n        )\n        return self._format_tool_event(event_data)\n\n    def _handle_tool_call_error(self, chunk) -> str | None:\n        \"\"\"处理工具调用错误事件\"\"\"\n        tool_info = getattr(chunk, \"tool\", None)\n        if not tool_info:\n            return None\n        error = getattr(tool_info, \"error\", None) or getattr(chunk, \"error\", None)\n        error_str = str(error) if error else \"Unknown error\"\n        error_preview = (\n            error_str[:RESULT_PREVIEW_MAX_LENGTH] + \"...\"\n            if len(error_str) > RESULT_PREVIEW_MAX_LENGTH\n            else error_str\n        )\n        event_data = {\n            \"type\": \"tool_call_end\",\n            \"tool_name\": getattr(tool_info, \"tool_name\", \"unknown\"),\n            \"result_preview\": f\"[Error] {error_preview}\",\n            \"error\": True,\n        }\n        logger.warning(f\"工具调用错误: {event_data['tool_name']}, 错误: {error_preview[:100]}...\")\n        return self._format_tool_event(event_data)\n\n    def _process_stream_chunk(self, chunk, include_tool_events: bool) -> str | None:\n        \"\"\"处理单个流式输出块，返回需要 yield 的内容\"\"\"\n        result = None\n\n        if chunk.event == RunEvent.run_content:\n            result = chunk.content if chunk.content else None\n        elif include_tool_events:\n            if chunk.event == RunEvent.tool_call_started:\n                result = self._handle_tool_call_started(chunk)\n            elif chunk.event == RunEvent.tool_call_completed:\n                result = self._handle_tool_call_completed(chunk)\n            elif chunk.event == RunEvent.tool_call_error:\n                # 处理工具调用错误事件，发送 tool_call_end 以便前端更新状态\n                result = self._handle_tool_call_error(chunk)\n            elif chunk.event == RunEvent.run_started:\n                logger.debug(\"Agent 运行开始\")\n                result = self._format_tool_event({\"type\": \"run_started\"})\n            elif chunk.event == RunEvent.run_completed:\n                logger.debug(\"Agent 运行完成\")\n                result = self._format_tool_event({\"type\": \"run_completed\"})\n\n        return result\n\n    def stream_response(\n        self,\n        message: str,\n        conversation_history: list[dict[str, str]] | None = None,\n        include_tool_events: bool = True,\n        session_id: str | None = None,\n    ) -> Generator[str]:\n        \"\"\"\n        流式生成 Agent 回复\n\n        Args:\n            message: 用户消息\n            conversation_history: 对话历史，格式为 [{\"role\": \"user|assistant\", \"content\": \"...\"}]\n            include_tool_events: 是否包含工具调用事件（默认 True）\n            session_id: 会话 ID，用于 trace 文件按会话聚合和 Phoenix session 追踪\n\n        Yields:\n            回复内容片段（字符串），如果 include_tool_events=True，\n            工具调用事件会以特殊格式输出：[TOOL_EVENT:{\"type\":\"...\",\"data\":{...}}]\n        \"\"\"\n        # 设置本地 ContextVar（用于 file_exporter 按会话聚合）\n        current_session_id.set(session_id)\n\n        try:\n            input_data = self._build_input_data(message, conversation_history)\n            # 直接将 session_id 传递给 agent.run()\n            # Agno Instrumentor 会从参数中读取 session_id 并设置为 span 属性\n            stream = self.agent.run(\n                input_data,\n                stream=True,\n                stream_events=include_tool_events,\n                session_id=session_id,  # 传递给 Agno，用于 Phoenix session 追踪\n            )\n\n            for chunk in stream:\n                output = self._process_stream_chunk(chunk, include_tool_events)\n                if output:\n                    yield output\n\n        except Exception as e:\n            logger.error(f\"Agno Agent 流式生成失败: {e}\")\n            yield f\"Agno Agent 处理失败: {e!s}\"\n        finally:\n            # 清理 ContextVar\n            current_session_id.set(None)\n\n    def is_available(self) -> bool:\n        \"\"\"检查 Agno Agent 是否可用\"\"\"\n        return hasattr(self, \"agent\") and self.agent is not None\n"
  },
  {
    "path": "lifetrace/llm/agno_tools/__init__.py",
    "content": "\"\"\"Agno Tools - FreeTodo Toolkit for Agno Agent\n\nThis module provides tools for managing todos through the Agno Agent framework.\n\nStructure:\n- toolkit.py: Main FreeTodoToolkit class\n- base.py: Message loader and utilities\n- tools/: Individual tool implementations\n  - todo_tools.py: CRUD operations\n  - breakdown_tools.py: Task breakdown\n  - time_tools.py: Time parsing\n  - conflict_tools.py: Schedule conflict detection\n  - stats_tools.py: Statistics and analysis\n  - tag_tools.py: Tag management\n\"\"\"\n\nfrom lifetrace.llm.agno_tools.toolkit import FreeTodoToolkit\n\n__all__ = [\"FreeTodoToolkit\"]\n"
  },
  {
    "path": "lifetrace/llm/agno_tools/base.py",
    "content": "\"\"\"Base module for Agno Tools\n\nProvides message loader and base utilities for all tools.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom pathlib import Path\nfrom typing import Any, ClassVar\n\nimport yaml\n\nfrom lifetrace.util.base_paths import get_config_dir\nfrom lifetrace.util.logging_config import get_logger\n\nlogger = get_logger()\n\n\nclass AgnoToolsMessageLoader:\n    \"\"\"Message loader for Agno Tools\n\n    Loads localized messages from YAML files based on language.\n    Supports caching for performance.\n    \"\"\"\n\n    _instances: ClassVar[dict[str, AgnoToolsMessageLoader]] = {}\n    _messages: ClassVar[dict[str, dict[str, Any]]] = {}\n\n    def __new__(cls, lang: str = \"en\"):\n        \"\"\"Singleton per language\"\"\"\n        if lang not in cls._instances:\n            instance = super().__new__(cls)\n            cls._instances[lang] = instance\n        return cls._instances[lang]\n\n    def __init__(self, lang: str = \"en\"):\n        \"\"\"Initialize message loader\n\n        Args:\n            lang: Language code ('zh' or 'en')\n        \"\"\"\n        self.lang = lang\n        if lang not in self._messages:\n            self._load_messages()\n\n    def _get_prompts_dir(self) -> Path:\n        \"\"\"Get the prompts directory path\"\"\"\n        try:\n            return get_config_dir() / \"prompts\" / \"agno_tools\" / self.lang\n        except ImportError:\n            # Fallback for testing\n            return (\n                Path(__file__).parent.parent.parent\n                / \"config\"\n                / \"prompts\"\n                / \"agno_tools\"\n                / self.lang\n            )\n\n    def _load_messages(self):\n        \"\"\"Load all YAML files from the language directory\"\"\"\n        prompts_dir = self._get_prompts_dir()\n        self._messages[self.lang] = {}\n\n        if not prompts_dir.exists():\n            logger.warning(f\"Prompts directory not found: {prompts_dir}\")\n            return\n\n        yaml_files = list(prompts_dir.glob(\"*.yaml\"))\n        for yaml_file in yaml_files:\n            try:\n                with open(yaml_file, encoding=\"utf-8\") as f:\n                    data = yaml.safe_load(f) or {}\n                    self._messages[self.lang].update(data)\n            except Exception as e:\n                logger.error(f\"Failed to load {yaml_file.name}: {e}\")\n\n        logger.info(\n            f\"AgnoTools messages loaded for '{self.lang}': \"\n            f\"{len(yaml_files)} files, {len(self._messages[self.lang])} keys\"\n        )\n\n    def get(self, key: str, **kwargs) -> str:\n        \"\"\"Get a localized message by key\n\n        Args:\n            key: Message key\n            **kwargs: Format arguments\n\n        Returns:\n            Formatted message string\n        \"\"\"\n        messages = self._messages.get(self.lang, {})\n        template = messages.get(key, \"\")\n\n        if not template:\n            # Fallback to English\n            if self.lang != \"en\":\n                en_messages = self._messages.get(\"en\", {})\n                template = en_messages.get(key, \"\")\n\n            if not template:\n                logger.warning(f\"Message not found: {key}\")\n                return f\"[{key}]\"\n\n        try:\n            if kwargs:\n                return template.format(**kwargs)\n            return template\n        except KeyError as e:\n            logger.error(f\"Missing format key in message '{key}': {e}\")\n            return template\n\n    def reload(self):\n        \"\"\"Reload messages from disk\"\"\"\n        if self.lang in self._messages:\n            del self._messages[self.lang]\n        self._load_messages()\n\n\ndef get_message(lang: str, key: str, **kwargs) -> str:\n    \"\"\"Convenience function to get a localized message\n\n    Args:\n        lang: Language code\n        key: Message key\n        **kwargs: Format arguments\n\n    Returns:\n        Formatted message string\n    \"\"\"\n    loader = AgnoToolsMessageLoader(lang)\n    return loader.get(key, **kwargs)\n"
  },
  {
    "path": "lifetrace/llm/agno_tools/toolkit.py",
    "content": "\"\"\"FreeTodo Toolkit for Agno Agent\n\nMain toolkit class that combines all tool mixins.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport importlib\n\nfrom agno.tools import Toolkit\n\nfrom lifetrace.llm.agno_tools.base import AgnoToolsMessageLoader\nfrom lifetrace.llm.agno_tools.tools import (\n    BreakdownTools,\n    ConflictTools,\n    StatsTools,\n    TagTools,\n    TimeTools,\n    TodoTools,\n)\nfrom lifetrace.util.logging_config import get_logger\n\nlogger = get_logger()\n\n\nclass FreeTodoToolkit(\n    TodoTools,\n    BreakdownTools,\n    TimeTools,\n    ConflictTools,\n    StatsTools,\n    TagTools,\n    Toolkit,\n):\n    \"\"\"FreeTodo Toolkit - Todo management tools for Agno Agent\n\n    Combines all tool mixins into a single Toolkit.\n    Supports internationalization through lang parameter.\n\n    Tools included:\n    - Todo CRUD: create_todo, complete_todo, update_todo, list_todos, search_todos, delete_todo\n    - Task breakdown: breakdown_task\n    - Time parsing: parse_time\n    - Conflict detection: check_schedule_conflict\n    - Statistics: get_todo_stats, get_overdue_todos\n    - Tag management: list_tags, get_todos_by_tag, suggest_tags\n    \"\"\"\n\n    def __init__(self, lang: str = \"en\", selected_tools: list[str] | None = None, **kwargs):\n        \"\"\"Initialize FreeTodoToolkit\n\n        Args:\n            lang: Language code for messages ('zh' or 'en'), defaults to 'en'\n            selected_tools: List of tool names to enable. If None or empty, no tools are enabled.\n            **kwargs: Additional arguments passed to Toolkit base class\n        \"\"\"\n        self.lang = lang\n\n        # Initialize message loader (preload messages)\n        AgnoToolsMessageLoader(lang)\n\n        # Lazy import to avoid circular dependencies\n        repo_module = importlib.import_module(\"lifetrace.repositories.sql_todo_repository\")\n        db_module = importlib.import_module(\"lifetrace.storage.database\")\n        sql_todo_repository_class = repo_module.SqlTodoRepository\n        db_base = db_module.db_base\n\n        self.db_base = db_base\n        self.todo_repo = sql_todo_repository_class(db_base)\n\n        # All available tools\n        all_tools = {\n            # Todo management (from TodoTools)\n            \"create_todo\": self.create_todo,\n            \"complete_todo\": self.complete_todo,\n            \"update_todo\": self.update_todo,\n            \"list_todos\": self.list_todos,\n            \"search_todos\": self.search_todos,\n            \"delete_todo\": self.delete_todo,\n            # Task breakdown (from BreakdownTools)\n            \"breakdown_task\": self.breakdown_task,\n            # Time parsing (from TimeTools)\n            \"parse_time\": self.parse_time,\n            # Conflict detection (from ConflictTools)\n            \"check_schedule_conflict\": self.check_schedule_conflict,\n            # Statistics (from StatsTools)\n            \"get_todo_stats\": self.get_todo_stats,\n            \"get_overdue_todos\": self.get_overdue_todos,\n            # Tag management (from TagTools)\n            \"list_tags\": self.list_tags,\n            \"get_todos_by_tag\": self.get_todos_by_tag,\n            \"suggest_tags\": self.suggest_tags,\n        }\n\n        # Filter tools based on selected_tools\n        # Default: no tools enabled (user must explicitly select tools)\n        if selected_tools and len(selected_tools) > 0:\n            tools = [all_tools[tool_name] for tool_name in selected_tools if tool_name in all_tools]\n            logger.info(\n                f\"FreeTodoToolkit initialized with lang={lang}, \"\n                f\"selected {len(tools)} tools: {selected_tools}\"\n            )\n        else:\n            tools = []\n            logger.info(f\"FreeTodoToolkit initialized with lang={lang}, no tools enabled (default)\")\n\n        super().__init__(name=\"freetodo_toolkit\", tools=tools, **kwargs)\n"
  },
  {
    "path": "lifetrace/llm/agno_tools/tools/__init__.py",
    "content": "\"\"\"Tools subpackage for Agno Tools\n\nContains individual tool implementations organized by functionality.\n\"\"\"\n\nfrom lifetrace.llm.agno_tools.tools.breakdown_tools import BreakdownTools\nfrom lifetrace.llm.agno_tools.tools.conflict_tools import ConflictTools\nfrom lifetrace.llm.agno_tools.tools.stats_tools import StatsTools\nfrom lifetrace.llm.agno_tools.tools.tag_tools import TagTools\nfrom lifetrace.llm.agno_tools.tools.time_tools import TimeTools\nfrom lifetrace.llm.agno_tools.tools.todo_tools import TodoTools\n\n__all__ = [\n    \"BreakdownTools\",\n    \"ConflictTools\",\n    \"StatsTools\",\n    \"TagTools\",\n    \"TimeTools\",\n    \"TodoTools\",\n]\n"
  },
  {
    "path": "lifetrace/llm/agno_tools/tools/breakdown_tools.py",
    "content": "\"\"\"Task Breakdown Tools\n\nTools for breaking down complex tasks into subtasks.\nThe Agent directly breaks down tasks without nested LLM calls for better performance.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom lifetrace.llm.agno_tools.base import get_message\nfrom lifetrace.util.logging_config import get_logger\n\nlogger = get_logger()\n\n\nclass BreakdownTools:\n    \"\"\"Task breakdown tools mixin\"\"\"\n\n    lang: str\n\n    def _msg(self, key: str, **kwargs) -> str:\n        return get_message(self.lang, key, **kwargs)\n\n    def breakdown_task(self, task_description: str) -> str:\n        \"\"\"Break down a complex task into subtasks\n\n        This tool provides context for task breakdown. The Agent should directly\n        break down the task into subtasks without calling LLM again.\n\n        Args:\n            task_description: Description of the task to break down\n\n        Returns:\n            Instructions for the Agent to break down the task directly\n        \"\"\"\n        # 返回拆解指导信息，让 Agent 自己完成拆解\n        # 这样可以避免嵌套 LLM 调用，提升性能\n        breakdown_guide = self._msg(\"breakdown_guide\", task_description=task_description)\n        return breakdown_guide\n"
  },
  {
    "path": "lifetrace/llm/agno_tools/tools/conflict_tools.py",
    "content": "\"\"\"Conflict Detection Tools\n\nSchedule conflict detection for todos.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport contextlib\nfrom datetime import datetime, timedelta\nfrom typing import TYPE_CHECKING\n\nfrom lifetrace.llm.agno_tools.base import get_message\nfrom lifetrace.util.logging_config import get_logger\n\nif TYPE_CHECKING:\n    from lifetrace.repositories.sql_todo_repository import SqlTodoRepository\n\nlogger = get_logger()\n\n# Default duration for todos without explicit end time\nDEFAULT_TODO_DURATION_HOURS = 1\n\n\ndef _parse_datetime(value: str | datetime) -> datetime:\n    \"\"\"Parse datetime from string or return as-is if already datetime.\"\"\"\n    if isinstance(value, str):\n        return datetime.fromisoformat(value.replace(\"Z\", \"+00:00\"))\n    return value\n\n\ndef _parse_duration_value(value: str | None) -> timedelta | None:  # noqa: C901, PLR0912\n    if not value:\n        return None\n    match = value.strip().upper().removeprefix(\"P\")\n    if not match:\n        return None\n    # Basic ISO 8601 duration parsing: PnW, PnD, PTnHnMnS.\n    weeks = days = hours = minutes = seconds = 0\n    if \"T\" in match:\n        date_part, time_part = match.split(\"T\", 1)\n    else:\n        date_part, time_part = match, \"\"\n    if date_part.endswith(\"W\"):\n        with contextlib.suppress(ValueError):\n            weeks = int(date_part[:-1] or 0)\n        date_part = \"\"\n    if date_part.endswith(\"D\"):\n        with contextlib.suppress(ValueError):\n            days = int(date_part[:-1] or 0)\n    if time_part:\n        number = \"\"\n        value_int = 0\n        for ch in time_part:\n            if ch.isdigit():\n                number += ch\n                continue\n            with contextlib.suppress(ValueError):\n                value_int = int(number or 0)\n            if ch == \"H\":\n                hours = value_int\n            elif ch == \"M\":\n                minutes = value_int\n            elif ch == \"S\":\n                seconds = value_int\n            number = \"\"\n    total_days = days + weeks * 7\n    if total_days == hours == minutes == seconds == 0:\n        return None\n    return timedelta(days=total_days, hours=hours, minutes=minutes, seconds=seconds)\n\n\ndef _get_todo_range(todo: dict) -> tuple[datetime, datetime] | None:\n    start_raw = todo.get(\"dtstart\") or todo.get(\"start_time\")\n    end_raw = todo.get(\"dtend\") or todo.get(\"end_time\")\n    due_raw = todo.get(\"due\") or todo.get(\"deadline\")\n\n    if not start_raw:\n        start_raw = due_raw\n    if not start_raw:\n        return None\n\n    todo_start = _parse_datetime(start_raw)\n    if not end_raw and due_raw and start_raw is not due_raw:\n        end_raw = due_raw\n\n    if end_raw:\n        todo_end = _parse_datetime(end_raw)\n    else:\n        duration_raw = todo.get(\"duration\")\n        duration = _parse_duration_value(duration_raw)\n        if duration is not None:\n            try:\n                todo_end = todo_start + duration\n            except Exception:\n                todo_end = todo_start + timedelta(hours=DEFAULT_TODO_DURATION_HOURS)\n        else:\n            todo_end = todo_start + timedelta(hours=DEFAULT_TODO_DURATION_HOURS)\n    return todo_start, todo_end\n\n\ndef _check_schedule_conflict(todo: dict, start: datetime, end: datetime, conflicts: list) -> None:\n    \"\"\"Check if todo schedule overlaps with the specified range.\"\"\"\n    todo_range = _get_todo_range(todo)\n    if not todo_range:\n        return\n    todo_start, todo_end = todo_range\n\n    if start < todo_end and end > todo_start:\n        existing_ids = [c[\"id\"] for c in conflicts]\n        if todo[\"id\"] not in existing_ids:\n            conflicts.append(\n                {\"id\": todo[\"id\"], \"name\": todo[\"name\"], \"start\": todo_start, \"end\": todo_end}\n            )\n\n\ndef _find_conflicts(todos: list, start: datetime, end: datetime) -> list:\n    \"\"\"Find all conflicting todos within the time range.\"\"\"\n    conflicts = []\n    for todo in todos:\n        _check_schedule_conflict(todo, start, end, conflicts)\n    return conflicts\n\n\nclass ConflictTools:\n    \"\"\"Conflict detection tools mixin\"\"\"\n\n    lang: str\n    todo_repo: SqlTodoRepository\n\n    def _msg(self, key: str, **kwargs) -> str:\n        return get_message(self.lang, key, **kwargs)\n\n    def _format_conflict_result(self, conflicts: list, time_range: str) -> str:\n        \"\"\"Format conflict check result as message.\"\"\"\n        if not conflicts:\n            return self._msg(\"no_conflict\", time_range=time_range)\n\n        conflict_lines = [\n            self._msg(\n                \"conflict_item\",\n                id=c[\"id\"],\n                name=c[\"name\"],\n                start=c[\"start\"].strftime(\"%H:%M\") if c.get(\"start\") else \"N/A\",\n                end=c[\"end\"].strftime(\"%H:%M\") if c.get(\"end\") else \"\",\n            )\n            for c in conflicts\n        ]\n        return self._msg(\n            \"conflict_found\",\n            time_range=time_range,\n            count=len(conflicts),\n            conflicts=\"\\n\".join(conflict_lines),\n        )\n\n    def check_schedule_conflict(self, start_time: str, end_time: str | None = None) -> str:\n        \"\"\"Check if the specified time conflicts with existing todos\n\n        Args:\n            start_time: Start time in ISO format\n            end_time: End time in ISO format (optional, defaults to start_time + 1 hour)\n\n        Returns:\n            Conflict information or availability message\n        \"\"\"\n        try:\n            start = datetime.fromisoformat(start_time.replace(\"Z\", \"+00:00\"))\n            end = (\n                datetime.fromisoformat(end_time.replace(\"Z\", \"+00:00\"))\n                if end_time\n                else start + timedelta(hours=DEFAULT_TODO_DURATION_HOURS)\n            )\n\n            time_range = f\"{start.strftime('%Y-%m-%d %H:%M')} - {end.strftime('%H:%M')}\"\n            todos = self.todo_repo.list_todos(limit=200, offset=0, status=\"active\")\n            conflicts = _find_conflicts(todos, start, end)\n\n            return self._format_conflict_result(conflicts, time_range)\n\n        except Exception as e:\n            logger.error(f\"Failed to check schedule conflict: {e}\")\n            return self._msg(\"conflict_failed\", error=str(e))\n"
  },
  {
    "path": "lifetrace/llm/agno_tools/tools/stats_tools.py",
    "content": "\"\"\"Statistics Tools\n\nTodo statistics and analysis.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom datetime import datetime, timedelta\nfrom typing import TYPE_CHECKING\n\nfrom lifetrace.llm.agno_tools.base import get_message\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.time_utils import get_utc_now\n\nif TYPE_CHECKING:\n    from lifetrace.repositories.sql_todo_repository import SqlTodoRepository\n\nlogger = get_logger()\n\n\ndef _parse_datetime(value: str | datetime) -> datetime:\n    \"\"\"Parse datetime from string or return as-is if already datetime.\"\"\"\n    if isinstance(value, str):\n        return datetime.fromisoformat(value.replace(\"Z\", \"+00:00\"))\n    return value\n\n\ndef _get_schedule_time(todo: dict) -> datetime | None:\n    \"\"\"Return schedule time from todo with legacy fallback.\"\"\"\n    schedule = (\n        todo.get(\"due\") or todo.get(\"dtstart\") or todo.get(\"deadline\") or todo.get(\"start_time\")\n    )\n    if not schedule:\n        return None\n    return _parse_datetime(schedule)\n\n\ndef _get_start_date(date_range: str, now: datetime) -> datetime | None:\n    \"\"\"Get start date based on date range string.\"\"\"\n    if date_range == \"today\":\n        return now.replace(hour=0, minute=0, second=0, microsecond=0)\n    if date_range == \"week\":\n        return now - timedelta(days=7)\n    if date_range == \"month\":\n        return now - timedelta(days=30)\n    return None\n\n\ndef _filter_by_date(todos: list, start_date: datetime | None) -> list:\n    \"\"\"Filter todos by start date.\"\"\"\n    if not start_date:\n        return todos\n    return [\n        t for t in todos if t.get(\"created_at\") and _parse_datetime(t[\"created_at\"]) >= start_date\n    ]\n\n\ndef _count_overdue(todos: list, now: datetime) -> int:\n    \"\"\"Count overdue active todos.\"\"\"\n    count = 0\n    for t in todos:\n        if t.get(\"status\") != \"active\":\n            continue\n        schedule = _get_schedule_time(t)\n        if schedule and schedule < now:\n            count += 1\n    return count\n\n\ndef _count_by_priority(todos: list) -> dict:\n    \"\"\"Count todos by priority level.\"\"\"\n    counts = {\"high\": 0, \"medium\": 0, \"low\": 0, \"none\": 0}\n    for t in todos:\n        priority = t.get(\"priority\", \"none\")\n        if priority in counts:\n            counts[priority] += 1\n    return counts\n\n\nclass StatsTools:\n    \"\"\"Statistics tools mixin\"\"\"\n\n    lang: str\n    todo_repo: SqlTodoRepository\n\n    def _msg(self, key: str, **kwargs) -> str:\n        return get_message(self.lang, key, **kwargs)\n\n    def get_todo_stats(self, date_range: str = \"today\") -> str:\n        \"\"\"Get todo statistics\n\n        Args:\n            date_range: Time range - 'today', 'week', 'month', 'all' (default: 'today')\n\n        Returns:\n            Formatted statistics\n        \"\"\"\n        try:\n            all_todos = self.todo_repo.list_todos(limit=1000, offset=0, status=None)\n            now = get_utc_now()\n\n            start_date = _get_start_date(date_range, now)\n            filtered_todos = _filter_by_date(all_todos, start_date)\n\n            total = len(filtered_todos)\n            completed = sum(1 for t in filtered_todos if t.get(\"status\") == \"completed\")\n            active = sum(1 for t in filtered_todos if t.get(\"status\") == \"active\")\n            overdue = _count_overdue(filtered_todos, now)\n            priority_counts = _count_by_priority(filtered_todos)\n\n            result = self._msg(\"stats_header\", date_range=date_range)\n            result += self._msg(\"stats_total\", total=total) + \"\\n\"\n            result += self._msg(\"stats_completed\", completed=completed) + \"\\n\"\n            result += self._msg(\"stats_active\", active=active) + \"\\n\"\n            result += self._msg(\"stats_overdue\", overdue=overdue) + \"\\n\"\n            result += self._msg(\n                \"stats_by_priority\",\n                high=priority_counts[\"high\"],\n                medium=priority_counts[\"medium\"],\n                low=priority_counts[\"low\"],\n                none=priority_counts[\"none\"],\n            )\n\n            return result\n\n        except Exception as e:\n            logger.error(f\"Failed to get todo stats: {e}\")\n            return self._msg(\"stats_failed\", error=str(e))\n\n    def get_overdue_todos(self) -> str:\n        \"\"\"Get all overdue todos\n\n        Returns:\n            Formatted list of overdue todos\n        \"\"\"\n        try:\n            now = get_utc_now()\n            todos = self.todo_repo.list_todos(limit=200, offset=0, status=\"active\")\n\n            overdue = []\n            for todo in todos:\n                schedule = _get_schedule_time(todo)\n                if not schedule:\n                    continue\n                if schedule < now:\n                    days_overdue = (now - schedule).days\n                    overdue.append({\"id\": todo[\"id\"], \"name\": todo[\"name\"], \"days\": days_overdue})\n\n            if not overdue:\n                return self._msg(\"no_overdue\")\n\n            overdue.sort(key=lambda x: x[\"days\"], reverse=True)\n\n            result = self._msg(\"overdue_header\", count=len(overdue))\n            for item in overdue:\n                result += (\n                    self._msg(\"overdue_item\", id=item[\"id\"], name=item[\"name\"], days=item[\"days\"])\n                    + \"\\n\"\n                )\n\n            return result.strip()\n\n        except Exception as e:\n            logger.error(f\"Failed to get overdue todos: {e}\")\n            return self._msg(\"no_overdue\")\n"
  },
  {
    "path": "lifetrace/llm/agno_tools/tools/tag_tools.py",
    "content": "\"\"\"Tag Management Tools\n\nTag listing, filtering, and suggestion.\nThe Agent directly suggests tags without nested LLM calls for better performance.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom typing import TYPE_CHECKING\n\nfrom lifetrace.llm.agno_tools.base import get_message\nfrom lifetrace.util.logging_config import get_logger\n\nif TYPE_CHECKING:\n    from lifetrace.repositories.sql_todo_repository import SqlTodoRepository\n\nlogger = get_logger()\n\n\nclass TagTools:\n    \"\"\"Tag management tools mixin\"\"\"\n\n    lang: str\n    todo_repo: SqlTodoRepository\n\n    def _msg(self, key: str, **kwargs) -> str:\n        return get_message(self.lang, key, **kwargs)\n\n    def list_tags(self) -> str:\n        \"\"\"List all used tags with todo counts\n\n        Returns:\n            Formatted list of tags\n        \"\"\"\n        try:\n            todos = self.todo_repo.list_todos(limit=1000, offset=0, status=None)\n\n            tag_counts: dict[str, int] = {}\n            for todo in todos:\n                tags = todo.get(\"tags\", [])\n                if tags:\n                    for tag in tags:\n                        tag_counts[tag] = tag_counts.get(tag, 0) + 1\n\n            if not tag_counts:\n                return self._msg(\"tags_empty\")\n\n            sorted_tags = sorted(tag_counts.items(), key=lambda x: x[1], reverse=True)\n\n            result = self._msg(\"tags_header\", count=len(sorted_tags))\n            for tag, count in sorted_tags:\n                result += self._msg(\"tags_item\", tag=tag, count=count) + \"\\n\"\n\n            return result.strip()\n\n        except Exception as e:\n            logger.error(f\"Failed to list tags: {e}\")\n            return self._msg(\"tags_empty\")\n\n    def get_todos_by_tag(self, tag: str) -> str:\n        \"\"\"Get all todos with a specific tag\n\n        Args:\n            tag: Tag name to filter by\n\n        Returns:\n            Formatted list of todos with the tag\n        \"\"\"\n        try:\n            todos = self.todo_repo.list_todos(limit=200, offset=0, status=None)\n\n            matches = [todo for todo in todos if tag in (todo.get(\"tags\") or [])]\n\n            if not matches:\n                return self._msg(\"todos_by_tag_empty\", tag=tag)\n\n            result = self._msg(\"todos_by_tag_header\", tag=tag, count=len(matches))\n            for todo in matches:\n                result += (\n                    self._msg(\n                        \"todos_by_tag_item\",\n                        id=todo[\"id\"],\n                        status=todo.get(\"status\", \"active\"),\n                        name=todo[\"name\"],\n                    )\n                    + \"\\n\"\n                )\n\n            return result.strip()\n\n        except Exception as e:\n            logger.error(f\"Failed to get todos by tag: {e}\")\n            return self._msg(\"todos_by_tag_empty\", tag=tag)\n\n    def suggest_tags(self, todo_name: str) -> str:\n        \"\"\"Suggest tags based on todo name\n\n        This tool provides context for tag suggestion. The Agent should directly\n        suggest tags without calling LLM again.\n\n        Args:\n            todo_name: Name of the todo to suggest tags for\n\n        Returns:\n            Instructions for the Agent to suggest tags directly\n        \"\"\"\n        try:\n            # 获取现有标签作为参考\n            todos = self.todo_repo.list_todos(limit=500, offset=0, status=None)\n            existing_tags = set()\n            for todo in todos:\n                for tag in todo.get(\"tags\") or []:\n                    existing_tags.add(tag)\n\n            existing_tags_str = \", \".join(sorted(existing_tags)) if existing_tags else \"None\"\n\n            # 返回推荐指导信息，让 Agent 自己完成推荐\n            # 这样可以避免嵌套 LLM 调用，提升性能\n            suggestion_guide = self._msg(\n                \"suggest_tags_guide\",\n                todo_name=todo_name,\n                existing_tags=existing_tags_str,\n            )\n            return suggestion_guide\n\n        except Exception as e:\n            logger.error(f\"Failed to get tag suggestion context: {e}\")\n            return self._msg(\"suggest_tags_failed\", error=str(e))\n"
  },
  {
    "path": "lifetrace/llm/agno_tools/tools/time_tools.py",
    "content": "\"\"\"Time Parsing Tools\n\nNatural language time expression parsing.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport re\nfrom datetime import datetime, timedelta\n\nfrom lifetrace.llm.agno_tools.base import get_message\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.time_utils import get_utc_now, to_utc\n\nlogger = get_logger()\n\n# Constants for time parsing\nPM_HOUR_OFFSET = 12\nDAYS_IN_WEEK = 7\n\n# Date format patterns\nDATE_FORMATS = [\n    (\"%Y-%m-%d %H:%M:%S\", True),\n    (\"%Y-%m-%d %H:%M\", True),\n    (\"%Y-%m-%d\", False),\n    (\"%Y/%m/%d %H:%M\", True),\n    (\"%Y/%m/%d\", False),\n]\n\n# Chinese weekday mapping\nCHINESE_WEEKDAY_MAP = {\n    \"一\": 0,\n    \"二\": 1,\n    \"三\": 2,\n    \"四\": 3,\n    \"五\": 4,\n    \"六\": 5,\n    \"日\": 6,\n    \"天\": 6,\n}\n\n# English weekday mapping\nENGLISH_WEEKDAY_MAP = {\n    \"monday\": 0,\n    \"tuesday\": 1,\n    \"wednesday\": 2,\n    \"thursday\": 3,\n    \"friday\": 4,\n    \"saturday\": 5,\n    \"sunday\": 6,\n}\n\n# Time patterns: (regex_pattern, hour_offset)\nCHINESE_TIME_PATTERNS = [\n    (r\"下午\\s*(\\d{1,2})\\s*[点:：时]?\\s*(\\d{0,2})\", PM_HOUR_OFFSET),\n    (r\"晚上\\s*(\\d{1,2})\\s*[点:：时]?\\s*(\\d{0,2})\", PM_HOUR_OFFSET),\n    (r\"上午\\s*(\\d{1,2})\\s*[点:：时]?\\s*(\\d{0,2})\", 0),\n    (r\"早上\\s*(\\d{1,2})\\s*[点:：时]?\\s*(\\d{0,2})\", 0),\n    (r\"中午\\s*(\\d{1,2})\\s*[点:：时]?\\s*(\\d{0,2})\", 0),\n    (r\"(\\d{1,2})\\s*[点:：时]\\s*(\\d{0,2})\", 0),\n]\n\n\ndef _parse_iso_format(time_expression: str) -> tuple[datetime | None, bool]:\n    \"\"\"Try to parse as ISO format.\"\"\"\n    try:\n        result = datetime.fromisoformat(time_expression.replace(\"Z\", \"+00:00\"))\n        time_already_set = \"T\" in time_expression or \" \" in time_expression\n        return to_utc(result), time_already_set\n    except ValueError:\n        return None, False\n\n\ndef _parse_date_formats(time_expression: str) -> tuple[datetime | None, bool]:\n    \"\"\"Try common date formats.\"\"\"\n    for fmt, has_time in DATE_FORMATS:\n        try:\n            result = datetime.strptime(time_expression, fmt).astimezone()\n            return to_utc(result), has_time\n        except ValueError:\n            continue\n    return None, False\n\n\ndef _parse_relative_day(expr: str, now: datetime) -> datetime | None:\n    \"\"\"Parse relative day expressions like 今天, 明天, 后天.\"\"\"\n    if \"今天\" in expr or \"today\" in expr:\n        return now.replace(hour=0, minute=0, second=0, microsecond=0)\n    if \"明天\" in expr or \"tomorrow\" in expr:\n        return (now + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)\n    if \"后天\" in expr:\n        return (now + timedelta(days=2)).replace(hour=0, minute=0, second=0, microsecond=0)\n    return None\n\n\ndef _parse_days_offset(expr: str, now: datetime) -> datetime | None:\n    \"\"\"Parse N天后 / in N days patterns.\"\"\"\n    days_match = re.search(r\"(\\d+)\\s*天后\", expr) or re.search(r\"in\\s*(\\d+)\\s*days?\", expr)\n    if days_match:\n        days = int(days_match.group(1))\n        return (now + timedelta(days=days)).replace(hour=0, minute=0, second=0, microsecond=0)\n    return None\n\n\ndef _parse_chinese_weekday(expr: str, now: datetime) -> datetime | None:\n    \"\"\"Parse 下周一/二/.../日 patterns.\"\"\"\n    weekday_match = re.search(r\"下周([一二三四五六日天])\", expr)\n    if weekday_match:\n        target_weekday = CHINESE_WEEKDAY_MAP[weekday_match.group(1)]\n        days_ahead = target_weekday - now.weekday()\n        if days_ahead <= 0:\n            days_ahead += DAYS_IN_WEEK\n        days_ahead += DAYS_IN_WEEK\n        return (now + timedelta(days=days_ahead)).replace(hour=0, minute=0, second=0, microsecond=0)\n    return None\n\n\ndef _parse_english_weekday(expr: str, now: datetime) -> datetime | None:\n    \"\"\"Parse next Monday/Tuesday/etc patterns.\"\"\"\n    for day_name, day_num in ENGLISH_WEEKDAY_MAP.items():\n        if f\"next {day_name}\" in expr:\n            days_ahead = day_num - now.weekday()\n            if days_ahead <= 0:\n                days_ahead += DAYS_IN_WEEK\n            days_ahead += DAYS_IN_WEEK\n            return (now + timedelta(days=days_ahead)).replace(\n                hour=0, minute=0, second=0, microsecond=0\n            )\n    return None\n\n\ndef _parse_relative_time(expr: str, now: datetime) -> datetime | None:\n    \"\"\"Parse all relative time expressions.\"\"\"\n    result = _parse_relative_day(expr, now)\n    if result:\n        return result\n\n    result = _parse_days_offset(expr, now)\n    if result:\n        return result\n\n    result = _parse_chinese_weekday(expr, now)\n    if result:\n        return result\n\n    return _parse_english_weekday(expr, now)\n\n\ndef _apply_chinese_time(time_expression: str, result: datetime) -> datetime:\n    \"\"\"Apply Chinese time patterns like 下午3点.\"\"\"\n    for pattern, offset in CHINESE_TIME_PATTERNS:\n        match = re.search(pattern, time_expression)\n        if match:\n            hour = int(match.group(1))\n            minute = int(match.group(2)) if match.group(2) else 0\n            if offset == PM_HOUR_OFFSET and hour < PM_HOUR_OFFSET:\n                hour += PM_HOUR_OFFSET\n            return result.replace(hour=hour, minute=minute)\n    return result\n\n\ndef _apply_english_time(expr: str, result: datetime) -> datetime:\n    \"\"\"Apply English time patterns like 3pm, 3:30pm.\"\"\"\n    en_time_match = re.search(r\"(\\d{1,2}):?(\\d{2})?\\s*(am|pm)?\", expr, re.IGNORECASE)\n    if en_time_match:\n        hour = int(en_time_match.group(1))\n        minute = int(en_time_match.group(2)) if en_time_match.group(2) else 0\n        ampm = en_time_match.group(3)\n        if ampm and ampm.lower() == \"pm\" and hour < PM_HOUR_OFFSET:\n            hour += PM_HOUR_OFFSET\n        elif ampm and ampm.lower() == \"am\" and hour == PM_HOUR_OFFSET:\n            hour = 0\n        return result.replace(hour=hour, minute=minute)\n    return result\n\n\ndef _extract_time_of_day(time_expression: str, expr: str, result: datetime) -> datetime:\n    \"\"\"Extract and apply time of day from expression.\"\"\"\n    result = _apply_chinese_time(time_expression, result)\n    return _apply_english_time(expr, result)\n\n\nclass TimeTools:\n    \"\"\"Time parsing tools mixin\"\"\"\n\n    lang: str\n\n    def _msg(self, key: str, **kwargs) -> str:\n        return get_message(self.lang, key, **kwargs)\n\n    def parse_time(self, time_expression: str) -> str:\n        \"\"\"Parse natural language time expression to ISO format\n\n        Args:\n            time_expression: Natural language time like '明天下午3点', 'next Monday',\n                           '三天后', '2024-01-20 14:00'\n\n        Returns:\n            Parsed ISO format datetime or error message\n        \"\"\"\n        try:\n            now = get_utc_now()\n            expr = time_expression.lower()\n            time_already_set = False\n\n            # Try ISO format first\n            result, time_already_set = _parse_iso_format(time_expression)\n\n            # Try common date formats\n            if not result:\n                result, time_already_set = _parse_date_formats(time_expression)\n\n            # Try relative time patterns\n            if not result:\n                result = _parse_relative_time(expr, now)\n\n            # Extract time of day (only if not already set)\n            if result and not time_already_set:\n                result = _extract_time_of_day(time_expression, expr, result)\n\n            if result:\n                return self._msg(\"parse_time_success\", result=result.isoformat())\n\n            return self._msg(\n                \"parse_time_failed\",\n                expression=time_expression,\n                error=\"Unrecognized format\",\n            )\n\n        except Exception as e:\n            logger.error(f\"Failed to parse time: {e}\")\n            return self._msg(\"parse_time_failed\", expression=time_expression, error=str(e))\n"
  },
  {
    "path": "lifetrace/llm/agno_tools/tools/todo_tools.py",
    "content": "\"\"\"Todo Management Tools\n\nCRUD operations for todo items.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport contextlib\nfrom datetime import datetime\nfrom typing import TYPE_CHECKING, Any\n\nfrom lifetrace.llm.agno_tools.base import get_message\nfrom lifetrace.util.logging_config import get_logger\n\nif TYPE_CHECKING:\n    from lifetrace.repositories.sql_todo_repository import SqlTodoRepository\n\nlogger = get_logger()\n\n\nclass TodoTools:\n    \"\"\"Todo CRUD tools mixin\"\"\"\n\n    lang: str\n    todo_repo: SqlTodoRepository\n\n    def _msg(self, key: str, **kwargs) -> str:\n        return get_message(self.lang, key, **kwargs)\n\n    def create_todo(  # noqa: PLR0913\n        self,\n        name: str,\n        description: str | None = None,\n        start_time: str | None = None,\n        end_time: str | None = None,\n        time_zone: str | None = None,\n        deadline: str | None = None,\n        priority: str | None = None,\n        tags: str | None = None,\n    ) -> str:\n        \"\"\"Create a new todo item\n\n        Args:\n            name: Todo name/title (required)\n            description: Detailed description (optional)\n            start_time: Start time in ISO format like '2024-01-20T14:00:00' (optional)\n            end_time: End time in ISO format like '2024-01-20T16:00:00' (optional)\n            time_zone: IANA time zone like 'Asia/Shanghai' (optional)\n            deadline: Legacy alias of start_time in ISO format (optional)\n            priority: Priority level - 'high', 'medium', 'low', or 'none' (optional, default: 'none')\n            tags: Comma-separated tags like 'work,urgent' (optional)\n\n        Returns:\n            Success or failure message\n        \"\"\"\n        try:\n            parsed_start_time = None\n            if start_time:\n                with contextlib.suppress(ValueError):\n                    parsed_start_time = datetime.fromisoformat(start_time.replace(\"Z\", \"+00:00\"))\n            elif deadline:\n                with contextlib.suppress(ValueError):\n                    parsed_start_time = datetime.fromisoformat(deadline.replace(\"Z\", \"+00:00\"))\n\n            parsed_end_time = None\n            if end_time:\n                with contextlib.suppress(ValueError):\n                    parsed_end_time = datetime.fromisoformat(end_time.replace(\"Z\", \"+00:00\"))\n\n            # Parse tags\n            tag_list = None\n            if tags:\n                tag_list = [t.strip() for t in tags.split(\",\") if t.strip()]\n\n            # Normalize priority (handle None and invalid values)\n            valid_priorities = (\"high\", \"medium\", \"low\", \"none\")\n            normalized_priority = priority if priority in valid_priorities else \"none\"\n\n            # Create todo\n            todo_id = self.todo_repo.create(\n                name=name,\n                description=description,\n                start_time=parsed_start_time,\n                end_time=parsed_end_time,\n                time_zone=time_zone,\n                priority=normalized_priority,\n                tags=tag_list,\n            )\n\n            if todo_id:\n                return self._msg(\"create_success\", id=todo_id, name=name)\n            else:\n                return self._msg(\"create_failed\", error=\"Unknown error\")\n\n        except Exception as e:\n            logger.error(f\"Failed to create todo: {e}\")\n            return self._msg(\"create_failed\", error=str(e))\n\n    def complete_todo(self, todo_id: int) -> str:\n        \"\"\"Mark a todo as completed\n\n        Args:\n            todo_id: The ID of the todo to complete\n\n        Returns:\n            Success or failure message\n        \"\"\"\n        try:\n            todo = self.todo_repo.get_by_id(todo_id)\n            if not todo:\n                return self._msg(\"complete_not_found\", id=todo_id)\n\n            success = self.todo_repo.update(todo_id, status=\"completed\")\n            if success:\n                return self._msg(\"complete_success\", id=todo_id)\n            else:\n                return self._msg(\"complete_failed\", error=\"Update failed\")\n\n        except Exception as e:\n            logger.error(f\"Failed to complete todo: {e}\")\n            return self._msg(\"complete_failed\", error=str(e))\n\n    def update_todo(  # noqa: PLR0913, C901\n        self,\n        todo_id: int,\n        name: str | None = None,\n        description: str | None = None,\n        start_time: str | None = None,\n        end_time: str | None = None,\n        time_zone: str | None = None,\n        deadline: str | None = None,\n        priority: str | None = None,\n    ) -> str:\n        \"\"\"Update an existing todo\n\n        Args:\n            todo_id: The ID of the todo to update\n            name: New name (optional)\n            description: New description (optional)\n            start_time: New start time in ISO format (optional)\n            end_time: New end time in ISO format (optional)\n            time_zone: IANA time zone like 'Asia/Shanghai' (optional)\n            deadline: Legacy alias of start_time (optional)\n            priority: New priority - 'high', 'medium', 'low', or 'none' (optional)\n\n        Returns:\n            Success or failure message\n        \"\"\"\n        try:\n            todo = self.todo_repo.get_by_id(todo_id)\n            if not todo:\n                return self._msg(\"update_not_found\", id=todo_id)\n\n            update_kwargs: dict[str, Any] = {}\n            if name is not None:\n                update_kwargs[\"name\"] = name\n            if description is not None:\n                update_kwargs[\"description\"] = description\n            if start_time is not None:\n                with contextlib.suppress(ValueError):\n                    update_kwargs[\"start_time\"] = datetime.fromisoformat(\n                        start_time.replace(\"Z\", \"+00:00\")\n                    )\n            elif deadline is not None:\n                with contextlib.suppress(ValueError):\n                    update_kwargs[\"start_time\"] = datetime.fromisoformat(\n                        deadline.replace(\"Z\", \"+00:00\")\n                    )\n            if end_time is not None:\n                with contextlib.suppress(ValueError):\n                    update_kwargs[\"end_time\"] = datetime.fromisoformat(\n                        end_time.replace(\"Z\", \"+00:00\")\n                    )\n            if time_zone is not None:\n                update_kwargs[\"time_zone\"] = time_zone\n            if priority is not None and priority in (\"high\", \"medium\", \"low\", \"none\"):\n                update_kwargs[\"priority\"] = priority\n\n            if not update_kwargs:\n                return self._msg(\"update_success\", id=todo_id)\n\n            success = self.todo_repo.update(todo_id, **update_kwargs)\n            if success:\n                return self._msg(\"update_success\", id=todo_id)\n            else:\n                return self._msg(\"update_failed\", error=\"Update failed\")\n\n        except Exception as e:\n            logger.error(f\"Failed to update todo: {e}\")\n            return self._msg(\"update_failed\", error=str(e))\n\n    def list_todos(self, status: str = \"active\", limit: int = 10) -> str:\n        \"\"\"List todos with optional status filter\n\n        Args:\n            status: Filter by status - 'active', 'completed', 'all' (default: 'active')\n            limit: Maximum number of todos to return (default: 10)\n\n        Returns:\n            Formatted list of todos or empty message\n        \"\"\"\n        try:\n            status_filter = status if status in (\"active\", \"completed\") else None\n            todos = self.todo_repo.list_todos(limit=limit, offset=0, status=status_filter)\n\n            if not todos:\n                return self._msg(\"list_empty\", status=status)\n\n            result = self._msg(\"list_header\", status=status, count=len(todos))\n            for todo in todos:\n                item = self._msg(\n                    \"list_item\",\n                    id=todo[\"id\"],\n                    priority=todo.get(\"priority\", \"none\"),\n                    name=todo[\"name\"],\n                )\n                start_time = (\n                    todo.get(\"dtstart\")\n                    or todo.get(\"due\")\n                    or todo.get(\"start_time\")\n                    or todo.get(\"deadline\")\n                )\n                end_time = todo.get(\"dtend\") or todo.get(\"end_time\")\n                if start_time:\n                    if isinstance(start_time, datetime):\n                        start_label = start_time.strftime(\"%Y-%m-%d %H:%M\")\n                    else:\n                        start_label = str(start_time)\n                    end_label = None\n                    if end_time:\n                        if isinstance(end_time, datetime):\n                            end_label = end_time.strftime(\"%Y-%m-%d %H:%M\")\n                        else:\n                            end_label = str(end_time)\n                    time_label = start_label\n                    if end_label:\n                        time_label = f\"{start_label} ~ {end_label}\"\n                    item += self._msg(\"list_item_with_time\", time=time_label)\n                result += item + \"\\n\"\n\n            return result.strip()\n\n        except Exception as e:\n            logger.error(f\"Failed to list todos: {e}\")\n            return self._msg(\"list_empty\", status=status)\n\n    def search_todos(self, keyword: str) -> str:\n        \"\"\"Search todos by keyword\n\n        Args:\n            keyword: Search keyword to match against todo name and description\n\n        Returns:\n            Formatted search results or empty message\n        \"\"\"\n        try:\n            all_todos = self.todo_repo.list_todos(limit=200, offset=0, status=None)\n            keyword_lower = keyword.lower()\n\n            matches = [\n                todo\n                for todo in all_todos\n                if keyword_lower in todo[\"name\"].lower()\n                or (todo.get(\"description\") and keyword_lower in todo[\"description\"].lower())\n            ]\n\n            if not matches:\n                return self._msg(\"search_empty\", keyword=keyword)\n\n            result = self._msg(\"search_header\", keyword=keyword, count=len(matches))\n            for todo in matches:\n                result += (\n                    self._msg(\n                        \"search_item\",\n                        id=todo[\"id\"],\n                        status=todo.get(\"status\", \"active\"),\n                        name=todo[\"name\"],\n                    )\n                    + \"\\n\"\n                )\n\n            return result.strip()\n\n        except Exception as e:\n            logger.error(f\"Failed to search todos: {e}\")\n            return self._msg(\"search_empty\", keyword=keyword)\n\n    def delete_todo(self, todo_id: int) -> str:\n        \"\"\"Delete a todo item\n\n        Args:\n            todo_id: The ID of the todo to delete\n\n        Returns:\n            Success or failure message\n        \"\"\"\n        try:\n            todo = self.todo_repo.get_by_id(todo_id)\n            if not todo:\n                return self._msg(\"delete_not_found\", id=todo_id)\n\n            success = self.todo_repo.delete(todo_id)\n            if success:\n                return self._msg(\"delete_success\", id=todo_id)\n            else:\n                return self._msg(\"delete_failed\", error=\"Delete failed\")\n\n        except Exception as e:\n            logger.error(f\"Failed to delete todo: {e}\")\n            return self._msg(\"delete_failed\", error=str(e))\n"
  },
  {
    "path": "lifetrace/llm/auto_todo_detection_service.py",
    "content": "\"\"\"自动待办检测服务\n当白名单应用的截图产生时，自动检测其中的待办事项并创建draft状态的todo\n\"\"\"\n\nimport json\nimport re\nfrom datetime import datetime\nfrom typing import Any\n\nfrom lifetrace.llm.llm_client import LLMClient\nfrom lifetrace.storage import screenshot_mgr, todo_mgr\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.prompt_loader import get_prompt\nfrom lifetrace.util.settings import settings\nfrom lifetrace.util.time_parser import calculate_scheduled_time\nfrom lifetrace.util.time_utils import get_utc_now\n\nlogger = get_logger()\n\n# 默认白名单应用列表（当配置不存在时使用）\nDEFAULT_WHITELIST_APPS = [\"微信\", \"WeChat\", \"飞书\", \"Feishu\", \"Lark\", \"钉钉\", \"DingTalk\"]\n\n\ndef get_whitelist_apps() -> list[str]:\n    \"\"\"获取白名单应用列表\n\n    优先从配置文件读取，如果配置不存在则使用默认列表\n\n    Returns:\n        白名单应用列表\n    \"\"\"\n    try:\n        apps = settings.get(\"jobs.auto_todo_detection.params.whitelist.apps\")\n        if apps and isinstance(apps, list):\n            return apps\n    except (KeyError, AttributeError):\n        logger.debug(\"自动待办检测白名单配置不存在，使用默认列表\")\n    return DEFAULT_WHITELIST_APPS\n\n\n# 为了向后兼容，保留原有的常量引用（从配置动态读取）\nTODO_EXTRACTION_WHITELIST_APPS = get_whitelist_apps()\n\n\nclass AutoTodoDetectionService:\n    \"\"\"自动待办检测服务\"\"\"\n\n    def __init__(self):\n        \"\"\"初始化服务\"\"\"\n        self.llm_client = LLMClient()\n\n    def is_whitelist_app(self, app_name: str) -> bool:\n        \"\"\"判断是否为白名单应用\n\n        Args:\n            app_name: 应用名称\n\n        Returns:\n            是否为白名单应用\n        \"\"\"\n        if not app_name:\n            return False\n        # 每次调用时动态获取白名单，支持配置热更新\n        whitelist_apps = get_whitelist_apps()\n        app_name_lower = app_name.lower()\n        return any(whitelist_app.lower() in app_name_lower for whitelist_app in whitelist_apps)\n\n    def detect_and_create_todos_from_screenshot(self, screenshot_id: int) -> dict[str, Any]:\n        \"\"\"\n        检测截图中的待办事项并自动创建draft状态的todo\n\n        Args:\n            screenshot_id: 截图ID\n\n        Returns:\n            包含创建结果的字典：\n            - created_count: 创建的todo数量\n            - todos: 创建的todo列表\n        \"\"\"\n        try:\n            # 获取截图信息\n            screenshot = screenshot_mgr.get_screenshot_by_id(screenshot_id)\n            if not screenshot:\n                logger.warning(f\"截图 {screenshot_id} 不存在\")\n                return {\"created_count\": 0, \"todos\": []}\n\n            app_name = screenshot.get(\"app_name\") or \"\"\n            window_title = screenshot.get(\"window_title\", \"\")\n\n            # 检查是否为白名单应用\n            if not self.is_whitelist_app(app_name):\n                logger.debug(f\"截图 {screenshot_id} 的应用 {app_name} 不在白名单中，跳过检测\")\n                return {\"created_count\": 0, \"todos\": []}\n\n            # 获取所有active和draft状态的待办\n            existing_todos = todo_mgr.list_todos(limit=1000, status=\"active\")\n            existing_todos += todo_mgr.list_todos(limit=1000, status=\"draft\")\n\n            logger.info(\n                f\"开始检测截图 {screenshot_id} 的待办事项，已有待办数量: {len(existing_todos)}\"\n            )\n\n            # 调用视觉模型分析\n            detection_result = self._call_vision_model(\n                screenshot_id=screenshot_id,\n                existing_todos=existing_todos,\n                app_name=app_name or \"\",\n                window_title=window_title,\n            )\n\n            if not detection_result or not detection_result.get(\"new_todos\"):\n                logger.info(f\"截图 {screenshot_id} 未检测到新待办\")\n                return {\"created_count\": 0, \"todos\": []}\n\n            # 创建draft状态的todo\n            result = self._create_draft_todos(\n                todos=detection_result[\"new_todos\"],\n                screenshot_id=screenshot_id,\n                app_name=app_name or \"\",\n                window_title=window_title,\n            )\n\n            logger.info(\n                f\"截图 {screenshot_id} 检测完成，创建 {result['created_count']} 个draft待办\"\n            )\n            return result\n\n        except Exception as e:\n            logger.error(f\"检测截图 {screenshot_id} 待办失败: {e}\", exc_info=True)\n            return {\"created_count\": 0, \"todos\": []}\n\n    def _call_vision_model(\n        self,\n        screenshot_id: int,\n        existing_todos: list[dict[str, Any]],\n        app_name: str,\n        window_title: str,\n    ) -> dict[str, Any]:\n        \"\"\"\n        调用视觉模型分析截图，检测待办事项\n\n        Args:\n            screenshot_id: 截图ID\n            existing_todos: 已有待办列表（用于去重）\n            app_name: 应用名称\n            window_title: 窗口标题\n\n        Returns:\n            检测结果字典，包含new_todos列表\n        \"\"\"\n        _ = app_name\n        _ = window_title\n        if not self.llm_client.is_available():\n            logger.warning(\"LLM客户端不可用，无法检测待办\")\n            return {\"new_todos\": []}\n\n        try:\n            # 格式化已有待办列表为JSON\n            existing_todos_json = json.dumps(\n                [\n                    {\n                        \"id\": todo.get(\"id\"),\n                        \"name\": todo.get(\"name\"),\n                        \"description\": todo.get(\"description\"),\n                    }\n                    for todo in existing_todos[:50]  # 限制数量，避免prompt过长\n                ],\n                ensure_ascii=False,\n                indent=2,\n            )\n\n            # 从配置文件加载提示词\n            system_prompt = get_prompt(\"auto_todo_detection\", \"system_assistant\")\n            user_prompt = get_prompt(\n                \"auto_todo_detection\",\n                \"user_prompt\",\n                existing_todos_json=existing_todos_json,\n            )\n\n            # 构建完整的提示词\n            full_prompt = f\"{system_prompt}\\n\\n{user_prompt}\"\n\n            # 调用视觉模型\n            result = self.llm_client.vision_chat(\n                screenshot_ids=[screenshot_id],\n                prompt=full_prompt,\n                temperature=0.3,  # 使用较低温度以提高准确性\n                max_tokens=2000,\n            )\n\n            response_text = result.get(\"response\", \"\")\n            if not response_text:\n                logger.warning(\"视觉模型返回空响应\")\n                return {\"new_todos\": []}\n\n            # 解析LLM响应\n            detection_result = self._parse_llm_response(response_text)\n            return detection_result\n\n        except Exception as e:\n            logger.error(f\"调用视觉模型检测待办失败: {e}\", exc_info=True)\n            return {\"new_todos\": []}\n\n    def _parse_llm_response(self, response_text: str) -> dict[str, Any]:\n        \"\"\"\n        解析LLM响应为检测结果\n\n        Args:\n            response_text: LLM返回的文本\n\n        Returns:\n            包含new_todos列表的字典\n        \"\"\"\n        try:\n            # 尝试提取JSON\n            json_match = re.search(r\"\\{.*\\}\", response_text, re.DOTALL)\n            if json_match:\n                json_str = json_match.group(0)\n                result = json.loads(json_str)\n                if \"new_todos\" in result:\n                    return result\n\n            # 如果没有找到JSON，尝试直接解析整个响应\n            result = json.loads(response_text)\n            if \"new_todos\" in result:\n                return result\n\n            logger.warning(\"LLM响应格式不正确，未找到new_todos字段\")\n            return {\"new_todos\": []}\n\n        except json.JSONDecodeError as e:\n            logger.error(f\"解析LLM响应JSON失败: {e}, 响应内容: {response_text[:200]}\")\n            return {\"new_todos\": []}\n        except Exception as e:\n            logger.error(f\"解析LLM响应失败: {e}\", exc_info=True)\n            return {\"new_todos\": []}\n\n    def _build_user_notes(\n        self,\n        screenshot_id: int,\n        app_name: str,\n        window_title: str,\n        source_text: str,\n        time_info: dict[str, Any],\n        confidence: float | None,\n    ) -> str:\n        \"\"\"构建user_notes，记录来源信息\"\"\"\n        user_notes_parts = [\n            f\"来源截图ID: {screenshot_id}\",\n            f\"应用: {app_name}\",\n        ]\n        if window_title:\n            user_notes_parts.append(f\"窗口: {window_title}\")\n        if source_text:\n            user_notes_parts.append(f\"来源文本: {source_text}\")\n        if time_info.get(\"raw_text\"):\n            user_notes_parts.append(f\"时间: {time_info.get('raw_text')}\")\n        if confidence is not None:\n            user_notes_parts.append(f\"置信度: {confidence:.2%}\")\n\n        return \"\\n\".join(user_notes_parts)\n\n    def _calculate_todo_scheduled_time(self, time_info: dict[str, Any]) -> datetime | None:\n        \"\"\"计算todo的scheduled_time\"\"\"\n        if not time_info:\n            return None\n\n        try:\n            reference_time = get_utc_now()\n            return calculate_scheduled_time(time_info, reference_time)\n        except Exception as e:\n            logger.warning(f\"计算scheduled_time失败: {e}\")\n            return None\n\n    def _create_single_draft_todo(\n        self,\n        todo_data: dict[str, Any],\n        screenshot_id: int,\n        app_name: str,\n        window_title: str,\n    ) -> dict[str, Any] | None:\n        \"\"\"创建单个draft状态的todo\"\"\"\n        title = todo_data.get(\"title\", \"\").strip()\n        if not title:\n            logger.warning(\"跳过标题为空的待办\")\n            return None\n\n        description = todo_data.get(\"description\")\n        if description:\n            description = description.strip()\n\n        source_text = todo_data.get(\"source_text\", \"\")\n        time_info = todo_data.get(\"time_info\", {})\n        confidence = todo_data.get(\"confidence\")\n\n        scheduled_time = self._calculate_todo_scheduled_time(time_info)\n        user_notes = self._build_user_notes(\n            screenshot_id, app_name, window_title, source_text, time_info, confidence\n        )\n\n        todo_id = todo_mgr.create_todo(\n            name=title,\n            description=description,\n            user_notes=user_notes,\n            start_time=scheduled_time,\n            status=\"draft\",  # 关键：创建为draft状态\n            priority=\"none\",\n            tags=[\"自动提取\"],\n        )\n\n        if todo_id:\n            logger.info(f\"创建draft待办: {todo_id} - {title}\")\n            return {\n                \"id\": todo_id,\n                \"name\": title,\n                \"scheduled_time\": scheduled_time.isoformat() if scheduled_time else None,\n            }\n\n        logger.warning(f\"创建待办失败: {title}\")\n        return None\n\n    def _create_draft_todos(\n        self,\n        todos: list[dict[str, Any]],\n        screenshot_id: int,\n        app_name: str,\n        window_title: str,\n    ) -> dict[str, Any]:\n        \"\"\"\n        创建draft状态的todo\n\n        Args:\n            todos: 检测到的待办列表\n            screenshot_id: 截图ID\n            app_name: 应用名称\n            window_title: 窗口标题\n\n        Returns:\n            创建结果统计\n        \"\"\"\n        created_todos = []\n        created_count = 0\n\n        for todo_data in todos:\n            try:\n                result = self._create_single_draft_todo(\n                    todo_data, screenshot_id, app_name, window_title\n                )\n                if result:\n                    created_count += 1\n                    created_todos.append(result)\n            except Exception as e:\n                logger.error(f\"处理待办数据失败: {e}, 数据: {todo_data}\", exc_info=True)\n                continue\n\n        return {\n            \"created_count\": created_count,\n            \"created_todos\": created_todos,\n        }\n"
  },
  {
    "path": "lifetrace/llm/context_builder.py",
    "content": "import contextlib\nimport json\nfrom datetime import datetime\nfrom typing import Any\n\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.prompt_loader import get_prompt\nfrom lifetrace.util.time_utils import get_utc_now\n\nlogger = get_logger()\n\n# 常量定义\nMAX_RECORDS_PER_APP = 5  # 每个应用最多显示的记录数\nMAX_SEARCH_RESULTS = 10  # 搜索结果最大显示数量\nMAX_APP_STATS = 10  # 应用统计最大显示数量\nOCR_TEXT_SUMMARY_LIMIT = 200  # 总结模式下 OCR 文本截断长度\nOCR_TEXT_SEARCH_LIMIT = 150  # 搜索模式下 OCR 文本截断长度\nOCR_TEXT_TRUNCATE_LIMIT = 100  # 截断模式下 OCR 文本长度\n\n\nclass ContextBuilder:\n    \"\"\"上下文构建器，将检索到的数据整理成适合LLM处理的格式\"\"\"\n\n    def __init__(self, max_context_length: int = 8000):\n        \"\"\"\n        初始化上下文构建器\n\n        Args:\n            max_context_length: 最大上下文长度（字符数）\n        \"\"\"\n        self.max_context_length = max_context_length\n        logger.info(f\"上下文构建器初始化完成，最大长度: {max_context_length}\")\n\n    def build_context(\n        self,\n        query: str,\n        retrieved_data: list[dict[str, Any]],\n        query_type: str = \"search\",\n    ) -> dict[str, Any]:\n        \"\"\"\n        构建完整的上下文\n\n        Args:\n            query: 用户原始查询\n            retrieved_data: 检索到的数据\n            query_type: 查询类型\n\n        Returns:\n            构建好的上下文字典\n        \"\"\"\n        context = {\n            \"query\": query,\n            \"query_type\": query_type,\n            \"data_summary\": self._build_data_summary(retrieved_data),\n            \"detailed_records\": self._build_detailed_records(retrieved_data),\n            \"metadata\": self._build_metadata(retrieved_data),\n        }\n\n        # 检查并截断过长的上下文\n        context = self._truncate_context(context)\n\n        logger.info(f\"上下文构建完成，包含 {len(retrieved_data)} 条记录\")\n        return context\n\n    def _format_timestamp(self, timestamp: str) -> str:\n        \"\"\"格式化时间戳\"\"\"\n        if not timestamp or timestamp == \"未知时间\":\n            return \"未知时间\"\n        try:\n            dt = datetime.fromisoformat(timestamp.replace(\"Z\", \"+00:00\"))\n            return dt.strftime(\"%Y-%m-%d %H:%M\")\n        except Exception:\n            return timestamp\n\n    def _format_record_for_summary(\n        self, index: int, record: dict[str, Any], text_limit: int\n    ) -> str:\n        \"\"\"格式化单条记录用于总结\"\"\"\n        timestamp = self._format_timestamp(record.get(\"timestamp\", \"未知时间\"))\n        ocr_text = record.get(\"ocr_text\", \"无文本内容\")\n        window_title = record.get(\"window_title\", \"\")\n        screenshot_id = record.get(\"screenshot_id\") or record.get(\"id\")\n\n        if len(ocr_text) > text_limit:\n            ocr_text = ocr_text[:text_limit] + \"...\"\n\n        record_text = f\"{index + 1}. 时间: {timestamp}\"\n        if window_title:\n            record_text += f\", 窗口: {window_title}\"\n        if screenshot_id:\n            record_text += f\", 截图ID: {screenshot_id}\"\n        record_text += f\"\\n   内容: {ocr_text}\"\n        return record_text\n\n    def build_summary_context(self, query: str, retrieved_data: list[dict[str, Any]]) -> str:\n        \"\"\"\n        构建用于总结的上下文文本\n\n        Args:\n            query: 用户查询\n            retrieved_data: 检索到的数据\n\n        Returns:\n            格式化的上下文文本\n        \"\"\"\n        if not retrieved_data:\n            return \"没有找到相关的历史记录数据。\"\n\n        context_parts = [\n            get_prompt(\"context_builder\", \"data_analysis_base\"),\n            \"\",\n            get_prompt(\"context_builder\", \"citation_requirements\"),\n            \"\",\n            get_prompt(\"context_builder\", \"response_format\"),\n            \"\",\n            f\"用户查询: {query}\",\n            f\"找到 {len(retrieved_data)} 条相关记录:\",\n            \"\",\n        ]\n\n        # 按应用分组\n        app_groups = self._group_by_app(retrieved_data)\n\n        for app_name, records in app_groups.items():\n            context_parts.append(f\"=== {app_name} ({len(records)} 条记录) ===\")\n\n            for i, record in enumerate(records[:MAX_RECORDS_PER_APP]):\n                record_text = self._format_record_for_summary(i, record, OCR_TEXT_SUMMARY_LIMIT)\n                context_parts.append(record_text)\n\n            if len(records) > MAX_RECORDS_PER_APP:\n                context_parts.append(f\"   ... 还有 {len(records) - MAX_RECORDS_PER_APP} 条记录\")\n\n            context_parts.append(\"\")\n\n        context_text = \"\\n\".join(context_parts)\n\n        # 检查长度并截断\n        if len(context_text) > self.max_context_length:\n            context_text = context_text[: self.max_context_length] + \"\\n\\n[内容过长，已截断]\"\n\n        return context_text\n\n    def build_search_context(self, query: str, retrieved_data: list[dict[str, Any]]) -> str:\n        \"\"\"\n        构建用于搜索的上下文文本\n\n        Args:\n            query: 用户查询\n            retrieved_data: 检索到的数据\n\n        Returns:\n            格式化的上下文文本\n        \"\"\"\n        if not retrieved_data:\n            return f\"查询: {query}\\n\\n未找到相关记录。\"\n\n        context_parts = [\n            get_prompt(\"context_builder\", \"data_analysis_base\"),\n            \"\",\n            get_prompt(\"context_builder\", \"citation_requirements\"),\n            \"\",\n            get_prompt(\"context_builder\", \"response_format\"),\n            \"\",\n            f\"搜索查询: {query}\",\n            f\"找到 {len(retrieved_data)} 条匹配结果:\",\n            \"\",\n        ]\n\n        # 按相关性排序显示\n        sorted_data = sorted(\n            retrieved_data, key=lambda x: x.get(\"relevance_score\", 0), reverse=True\n        )\n\n        for i, record in enumerate(sorted_data[:10]):  # 最多显示10条\n            timestamp = record.get(\"timestamp\", \"未知时间\")\n            if timestamp and timestamp != \"未知时间\":\n                with contextlib.suppress(ValueError, TypeError):\n                    dt = datetime.fromisoformat(timestamp.replace(\"Z\", \"+00:00\"))\n                    timestamp = dt.strftime(\"%Y-%m-%d %H:%M\")\n\n            app_name = record.get(\"app_name\", \"未知应用\")\n            ocr_text = record.get(\"ocr_text\", \"无文本内容\")\n            relevance = record.get(\"relevance_score\", 0)\n            screenshot_id = record.get(\"screenshot_id\") or record.get(\"id\")  # 获取截图ID\n\n            # 截断过长的文本\n            if len(ocr_text) > OCR_TEXT_SEARCH_LIMIT:\n                ocr_text = ocr_text[:OCR_TEXT_SEARCH_LIMIT] + \"...\"\n\n            # 构建包含截图ID的上下文信息\n            id_info = f\" (截图ID: {screenshot_id})\" if screenshot_id else \"\"\n            context_parts.append(\n                f\"{i + 1}. [{app_name}] {timestamp} (相关性: {relevance:.2f}){id_info}\\n   {ocr_text}\"\n            )\n\n        context_text = \"\\n\\n\".join(context_parts)\n\n        # 检查长度并截断\n        if len(context_text) > self.max_context_length:\n            context_text = context_text[: self.max_context_length] + \"\\n\\n[搜索结果过长，已截断]\"\n\n        return context_text\n\n    def _build_app_distribution_context(\n        self, app_distribution: dict[str, int], total_count: int\n    ) -> list[str]:\n        \"\"\"构建应用分布上下文\"\"\"\n        if not app_distribution:\n            return []\n\n        parts = [\"\\n应用分布:\"]\n        sorted_apps = sorted(app_distribution.items(), key=lambda x: x[1], reverse=True)\n        for app, count in sorted_apps[:MAX_APP_STATS]:\n            percentage = (count / total_count * 100) if total_count > 0 else 0\n            parts.append(f\"  {app}: {count} 条 ({percentage:.1f}%)\")\n        return parts\n\n    def _build_time_range_context(self, time_range: dict[str, Any]) -> list[str]:\n        \"\"\"构建时间范围上下文\"\"\"\n        if not time_range.get(\"earliest\") or not time_range.get(\"latest\"):\n            return []\n\n        try:\n            earliest = datetime.fromisoformat(time_range[\"earliest\"].replace(\"Z\", \"+00:00\"))\n            latest = datetime.fromisoformat(time_range[\"latest\"].replace(\"Z\", \"+00:00\"))\n            return [\n                f\"\\n时间范围: {earliest.strftime('%Y-%m-%d %H:%M')} 至 {latest.strftime('%Y-%m-%d %H:%M')}\"\n            ]\n        except Exception:\n            return [f\"\\n时间范围: {time_range['earliest']} 至 {time_range['latest']}\"]\n\n    def _build_query_conditions_context(self, query_conditions: Any) -> list[str]:\n        \"\"\"构建查询条件上下文\"\"\"\n        parts: list[str] = []\n\n        # 从对象或字典中获取字段\n        def get_field(obj: Any, field: str) -> Any:\n            if hasattr(obj, field):\n                return getattr(obj, field)\n            if isinstance(obj, dict):\n                return obj.get(field)\n            return None\n\n        app_names = get_field(query_conditions, \"app_names\")\n        keywords = get_field(query_conditions, \"keywords\")\n        start_date = get_field(query_conditions, \"start_date\")\n        end_date = get_field(query_conditions, \"end_date\")\n\n        if not (app_names or keywords or start_date or end_date):\n            return parts\n\n        parts.append(\"\\n查询条件:\")\n        if app_names:\n            if isinstance(app_names, list):\n                parts.append(f\"  应用: {', '.join(app_names)}\")\n            else:\n                parts.append(f\"  应用: {app_names}\")\n        if keywords:\n            parts.append(f\"  关键词: {', '.join(keywords)}\")\n        if start_date:\n            parts.append(f\"  开始时间: {start_date}\")\n        if end_date:\n            parts.append(f\"  结束时间: {end_date}\")\n\n        return parts\n\n    def build_statistics_context(\n        self, query: str, retrieved_data: list[dict[str, Any]], stats: dict[str, Any]\n    ) -> str:\n        \"\"\"\n        构建用于统计的上下文文本\n\n        Args:\n            query: 用户查询\n            retrieved_data: 检索到的数据\n            stats: 统计信息\n\n        Returns:\n            格式化的上下文文本\n        \"\"\"\n        _ = retrieved_data\n        context_parts = [\n            get_prompt(\"context_builder\", \"data_analysis_base\"),\n            \"\",\n            get_prompt(\"context_builder\", \"citation_requirements\"),\n            \"\",\n            get_prompt(\"context_builder\", \"response_format\"),\n            \"\",\n            f\"统计查询: {query}\",\n            \"\",\n        ]\n\n        # 基础统计\n        total_count = stats.get(\"total_screenshots\", 0)\n        context_parts.append(f\"总记录数: {total_count}\")\n\n        # 应用分布\n        context_parts.extend(\n            self._build_app_distribution_context(stats.get(\"app_distribution\", {}), total_count)\n        )\n\n        # 时间范围\n        context_parts.extend(self._build_time_range_context(stats.get(\"time_range\", {})))\n\n        # 查询条件\n        context_parts.extend(\n            self._build_query_conditions_context(stats.get(\"query_conditions\", {}))\n        )\n\n        return \"\\n\".join(context_parts)\n\n    def _build_data_summary(self, retrieved_data: list[dict[str, Any]]) -> dict[str, Any]:\n        \"\"\"构建数据摘要\"\"\"\n        if not retrieved_data:\n            return {\"total_count\": 0, \"app_distribution\": {}, \"time_span\": None}\n\n        # 应用分布\n        app_counts = {}\n        timestamps = []\n\n        for record in retrieved_data:\n            app_name = record.get(\"app_name\", \"未知应用\")\n            app_counts[app_name] = app_counts.get(app_name, 0) + 1\n\n            timestamp = record.get(\"timestamp\")\n            if timestamp:\n                timestamps.append(timestamp)\n\n        # 时间跨度\n        time_span = None\n        if timestamps:\n            timestamps.sort()\n            time_span = {\"earliest\": timestamps[0], \"latest\": timestamps[-1]}\n\n        return {\n            \"total_count\": len(retrieved_data),\n            \"app_distribution\": app_counts,\n            \"time_span\": time_span,\n        }\n\n    def _build_detailed_records(self, retrieved_data: list[dict[str, Any]]) -> list[dict[str, Any]]:\n        \"\"\"构建详细记录\"\"\"\n        detailed_records = []\n\n        for record in retrieved_data[:20]:  # 最多保留20条详细记录\n            detailed_record = {\n                \"timestamp\": record.get(\"timestamp\"),\n                \"app_name\": record.get(\"app_name\"),\n                \"window_title\": record.get(\"window_title\"),\n                \"ocr_text\": record.get(\"ocr_text\", \"\")[:500],  # 截断OCR文本\n                \"relevance_score\": record.get(\"relevance_score\", 0),\n                \"screenshot_id\": record.get(\"screenshot_id\") or record.get(\"id\"),  # 添加截图ID\n            }\n            detailed_records.append(detailed_record)\n\n        return detailed_records\n\n    def _build_metadata(self, retrieved_data: list[dict[str, Any]]) -> dict[str, Any]:\n        \"\"\"构建元数据\"\"\"\n        return {\n            \"total_retrieved\": len(retrieved_data),\n            \"build_time\": get_utc_now().isoformat(),\n            \"context_version\": \"1.0\",\n        }\n\n    def _group_by_app(\n        self, retrieved_data: list[dict[str, Any]]\n    ) -> dict[str, list[dict[str, Any]]]:\n        \"\"\"按应用分组\"\"\"\n        app_groups = {}\n\n        for record in retrieved_data:\n            app_name = record.get(\"app_name\", \"未知应用\")\n            if app_name not in app_groups:\n                app_groups[app_name] = []\n            app_groups[app_name].append(record)\n\n        # 按记录数量排序\n        return dict(sorted(app_groups.items(), key=lambda x: len(x[1]), reverse=True))\n\n    def _truncate_context(self, context: dict[str, Any]) -> dict[str, Any]:\n        \"\"\"截断过长的上下文\"\"\"\n        context_str = json.dumps(context, ensure_ascii=False)\n\n        if len(context_str) <= self.max_context_length:\n            return context\n\n        # 逐步减少详细记录\n        detailed_records = context.get(\"detailed_records\", [])\n        while (\n            len(json.dumps(context, ensure_ascii=False)) > self.max_context_length\n            and detailed_records\n        ):\n            detailed_records.pop()\n            context[\"detailed_records\"] = detailed_records\n\n        # 如果还是太长，截断OCR文本\n        for record in context.get(\"detailed_records\", []):\n            if \"ocr_text\" in record and len(record[\"ocr_text\"]) > OCR_TEXT_TRUNCATE_LIMIT:\n                record[\"ocr_text\"] = record[\"ocr_text\"][:OCR_TEXT_TRUNCATE_LIMIT] + \"...\"\n\n        logger.warning(f\"上下文过长，已截断至 {len(json.dumps(context, ensure_ascii=False))} 字符\")\n        return context\n"
  },
  {
    "path": "lifetrace/llm/event_summary_clustering.py",
    "content": "\"\"\"\n事件摘要聚类模块\n包含HDBSCAN聚类相关逻辑\n\"\"\"\n\nfrom lifetrace.util.logging_config import get_logger\n\nfrom .event_summary_config import (\n    HDBSCAN_AVAILABLE,\n    MIN_CLUSTER_SIZE,\n    MIN_TEXT_COUNT_FOR_CLUSTERING,\n    SCIPY_AVAILABLE,\n    pdist,\n    squareform,\n)\n\nlogger = get_logger()\n\ntry:\n    import hdbscan\n    import numpy as np\nexcept ImportError:\n    hdbscan = None\n    np = None\n\n\ndef check_clustering_prerequisites(ocr_texts: list[str], vector_service) -> tuple[bool, str]:\n    \"\"\"检查聚类前置条件\n\n    Returns:\n        (是否满足条件, 错误消息)\n    \"\"\"\n    if not HDBSCAN_AVAILABLE:\n        return False, \"HDBSCAN不可用，回退到简单聚合\"\n\n    if not ocr_texts or len(ocr_texts) < MIN_TEXT_COUNT_FOR_CLUSTERING:\n        return False, \"文本数量不足\"\n\n    if not vector_service:\n        return False, \"向量服务未初始化，回退到简单聚合\"\n\n    if not vector_service.is_enabled():\n        return (\n            False,\n            f\"向量服务未启用 (enabled={vector_service.enabled}, \"\n            f\"vector_db={'存在' if vector_service.vector_db else '不存在'})，回退到简单聚合\",\n        )\n\n    if not vector_service.vector_db:\n        return False, \"向量数据库实例不存在，回退到简单聚合\"\n\n    return True, \"\"\n\n\ndef vectorize_texts(ocr_texts: list[str], vector_service) -> tuple[list[list[float]], list[str]]:\n    \"\"\"对OCR文本进行向量化\n\n    Returns:\n        (向量列表, 有效文本列表)\n    \"\"\"\n    embeddings = []\n    valid_texts = []\n    for text in ocr_texts:\n        if not text or not text.strip():\n            continue\n        embedding = vector_service.vector_db.embed_text(text)\n        if embedding:\n            embeddings.append(embedding)\n            valid_texts.append(text)\n    return embeddings, valid_texts\n\n\ndef calculate_cluster_params(text_count: int) -> int:\n    \"\"\"计算HDBSCAN聚类参数\n\n    适应行级别的文本数量（通常远大于截图数量），使用更保守的参数。\n\n    Args:\n        text_count: 文本数量（对于行级别聚类，通常是文本行数量）\n\n    Returns:\n        min_cluster_size\n    \"\"\"\n    min_cluster_size = max(MIN_CLUSTER_SIZE, text_count // 20)\n    max_cluster_size = max(MIN_CLUSTER_SIZE, text_count // 3)\n    min_cluster_size = min(min_cluster_size, max_cluster_size)\n    return max(MIN_CLUSTER_SIZE, min_cluster_size)\n\n\ndef select_representative_texts(cluster_labels: list[int], valid_texts: list[str]) -> list[str]:\n    \"\"\"从聚类结果中选择代表性文本\n\n    Returns:\n        代表性文本列表\n    \"\"\"\n    representative_texts = []\n    unique_labels = set(cluster_labels)\n\n    for label in unique_labels:\n        indices = [\n            idx for idx, cluster_label in enumerate(cluster_labels) if cluster_label == label\n        ]\n        if not indices:\n            continue\n\n        cluster_texts = [valid_texts[i] for i in indices]\n        longest_text = max(cluster_texts, key=len)\n        representative_texts.append(longest_text)\n\n    return representative_texts\n\n\ndef cluster_ocr_texts_with_hdbscan(ocr_texts: list[str], vector_service) -> list[str]:\n    \"\"\"\n    使用HDBSCAN对向量化的OCR文本进行聚类，返回代表性文本\n    \"\"\"\n    can_cluster, error_msg = check_clustering_prerequisites(ocr_texts, vector_service)\n    if not can_cluster:\n        if error_msg and error_msg != \"文本数量不足\":\n            logger.warning(error_msg)\n        return ocr_texts\n\n    try:\n        if hdbscan is None or np is None:\n            logger.warning(\"HDBSCAN 或 numpy 未安装，回退到简单聚合\")\n            return ocr_texts\n        embeddings, valid_texts = vectorize_texts(ocr_texts, vector_service)\n\n        if len(embeddings) < MIN_TEXT_COUNT_FOR_CLUSTERING:\n            logger.debug(\"有效文本数量不足，无法进行聚类\")\n            return valid_texts\n\n        embeddings_array = np.array(embeddings)\n        min_cluster_size = calculate_cluster_params(len(valid_texts))\n        logger.info(\n            f\"使用HDBSCAN聚类: {len(valid_texts)} 个文本, min_cluster_size={min_cluster_size}\"\n        )\n\n        if SCIPY_AVAILABLE and pdist is not None and squareform is not None:\n            cosine_distances = pdist(embeddings_array, metric=\"cosine\")\n            distance_matrix = squareform(cosine_distances)\n            clusterer = hdbscan.HDBSCAN(\n                min_cluster_size=min_cluster_size,\n                min_samples=1,\n                metric=\"precomputed\",\n            )\n            cluster_labels = clusterer.fit_predict(distance_matrix).tolist()\n        else:\n            logger.warning(\"scipy不可用，使用欧氏距离替代余弦距离\")\n            clusterer = hdbscan.HDBSCAN(\n                min_cluster_size=min_cluster_size,\n                min_samples=1,\n                metric=\"euclidean\",\n            )\n            cluster_labels = clusterer.fit_predict(embeddings_array).tolist()\n\n        representative_texts = select_representative_texts(cluster_labels, valid_texts)\n        return representative_texts or valid_texts\n\n    except Exception as e:\n        logger.error(f\"HDBSCAN聚类失败: {e}\", exc_info=True)\n        return ocr_texts\n"
  },
  {
    "path": "lifetrace/llm/event_summary_config.py",
    "content": "\"\"\"\n事件摘要服务配置模块\n包含常量定义和可选依赖检查\n\"\"\"\n\nfrom lifetrace.util.logging_config import get_logger\n\nlogger = get_logger()\n\n# 常量定义\nMIN_SCREENSHOTS_FOR_LLM = 3  # 使用LLM生成摘要的最小截图数量\nMIN_OCR_TEXT_LENGTH = 10  # OCR文本的最小长度阈值\nMAX_COMBINED_TEXT_LENGTH = 3000  # 合并OCR文本的最大长度\nMIN_CLUSTER_SIZE = 2  # HDBSCAN聚类的最小聚类大小\nMIN_TEXT_COUNT_FOR_CLUSTERING = 2  # 进行聚类的最小文本数量\nMIN_OCR_LINE_LENGTH = 3  # OCR文本行的最小长度阈值（用于过滤噪声行）\nMIN_OCR_CONFIDENCE = 0.6  # OCR结果最低置信度，低于此阈值的块跳过\nUI_REPEAT_THRESHOLD = 3  # 将文本标记为UI候选的跨截图重复次数阈值\nUI_CANDIDATE_MAX_LENGTH = 25  # UI候选的最大长度（字符）\nUI_REPRESENTATIVE_LIMIT = 2  # 保留的代表性UI文本数量上限\nMAX_TITLE_LENGTH = 20  # 标题最大长度（字符数）\nMAX_SUMMARY_LENGTH = 50  # 摘要最大长度（字符数，对应提示词要求）\nOCR_PREVIEW_LENGTH = 100  # OCR预览文本长度\nRESPONSE_PREVIEW_LENGTH = 500  # 响应预览文本长度\n\n# 尝试导入HDBSCAN\ntry:\n    import hdbscan  # noqa: F401\n    import numpy as np  # noqa: F401\n    from scipy.spatial.distance import pdist, squareform\n\n    HDBSCAN_AVAILABLE = True\n    SCIPY_AVAILABLE = True\nexcept ImportError:\n    HDBSCAN_AVAILABLE = False\n    SCIPY_AVAILABLE = False\n    pdist = None\n    squareform = None\n    logger.warning(\"HDBSCAN or scipy not available, clustering will fallback to simple aggregation\")\n"
  },
  {
    "path": "lifetrace/llm/event_summary_ocr.py",
    "content": "\"\"\"\n事件摘要OCR文本处理模块\n包含OCR文本提取、过滤和UI候选分离逻辑\n\"\"\"\n\nimport re\nfrom typing import Any\n\nfrom lifetrace.storage import get_session\nfrom lifetrace.storage.models import OCRResult, Screenshot\nfrom lifetrace.storage.sql_utils import col\nfrom lifetrace.util.logging_config import get_logger\n\nfrom .event_summary_config import (\n    MIN_OCR_CONFIDENCE,\n    MIN_OCR_LINE_LENGTH,\n    UI_CANDIDATE_MAX_LENGTH,\n    UI_REPEAT_THRESHOLD,\n    UI_REPRESENTATIVE_LIMIT,\n)\n\nlogger = get_logger()\n\n\ndef should_filter_line(line: str, debug_info: dict[str, Any]) -> bool:\n    \"\"\"判断是否应该过滤掉某行文本\n\n    Returns:\n        True表示应该过滤，False表示保留\n    \"\"\"\n    if not line:\n        return True\n\n    debug_info[\"raw_lines_count\"] += 1\n\n    if len(line) < MIN_OCR_LINE_LENGTH:\n        debug_info[\"filtered_short_count\"] += 1\n        return True\n\n    if line.isdigit() or re.fullmatch(r\"[^\\w\\s]+\", line):\n        debug_info[\"filtered_symbol_or_digit_count\"] += 1\n        return True\n\n    return False\n\n\ndef process_ocr_block(\n    ocr_block: str,\n    screenshot_id: int,\n    ocr_lines: list[str],\n    lines_with_meta: list[dict[str, Any]],\n    debug_info: dict[str, Any],\n) -> None:\n    \"\"\"处理单个OCR块，提取并过滤文本行\"\"\"\n    lines = ocr_block.split(\"\\n\")\n    for raw_line in lines:\n        line = raw_line.strip()\n        if should_filter_line(line, debug_info):\n            continue\n\n        ocr_lines.append(line)\n        lines_with_meta.append({\"text\": line, \"screenshot_id\": screenshot_id})\n\n\ndef get_event_ocr_texts(event_id: int) -> tuple[list[str], dict[str, Any]]:\n    \"\"\"获取事件下所有截图的OCR文本行\n\n    将OCR文本按换行符分割成行（同一水平分组的bounding boxes合并后的文本），\n    然后对每行进行聚类。\n\n    Args:\n        event_id: 事件ID\n\n    Returns:\n        (文本行列表, 调试信息字典)\n    \"\"\"\n    ocr_lines = []\n    original_ocr_blocks = []\n    lines_with_meta: list[dict[str, Any]] = []\n    debug_info = {\n        \"original_ocr_blocks\": [],\n        \"original_ocr_blocks_count\": 0,\n        \"ocr_lines_count\": 0,\n        \"lines_per_block_avg\": 0.0,\n        \"raw_lines_count\": 0,\n        \"filtered_short_count\": 0,\n        \"filtered_symbol_or_digit_count\": 0,\n        \"filtered_low_confidence_blocks\": 0,\n        \"lines_with_meta\": [],\n    }\n\n    try:\n        with get_session() as session:\n            screenshots = (\n                session.query(Screenshot).filter(col(Screenshot.event_id) == event_id).all()\n            )\n\n            for screenshot in screenshots:\n                ocr_results = (\n                    session.query(OCRResult)\n                    .filter(col(OCRResult.screenshot_id) == screenshot.id)\n                    .all()\n                )\n\n                for ocr in ocr_results:\n                    if not ocr.text_content or not ocr.text_content.strip():\n                        continue\n\n                    ocr_block = ocr.text_content.strip()\n                    original_ocr_blocks.append(ocr_block)\n\n                    if ocr.confidence is not None and ocr.confidence < MIN_OCR_CONFIDENCE:\n                        debug_info[\"filtered_low_confidence_blocks\"] += 1\n                        continue\n\n                    process_ocr_block(\n                        ocr_block, screenshot.id, ocr_lines, lines_with_meta, debug_info\n                    )\n\n        debug_info[\"original_ocr_blocks\"] = original_ocr_blocks\n        debug_info[\"original_ocr_blocks_count\"] = len(original_ocr_blocks)\n        debug_info[\"ocr_lines_count\"] = len(ocr_lines)\n        debug_info[\"lines_with_meta\"] = lines_with_meta\n        if len(original_ocr_blocks) > 0:\n            debug_info[\"lines_per_block_avg\"] = len(ocr_lines) / len(original_ocr_blocks)\n\n        return ocr_lines, debug_info\n\n    except Exception as e:\n        logger.error(f\"获取事件OCR文本失败: {e}\")\n        return [], debug_info\n\n\ndef build_text_to_screenshots_map(lines_with_meta: list[dict[str, Any]]) -> dict[str, set[int]]:\n    \"\"\"构建文本到截图ID集合的映射\"\"\"\n    text_to_screenshots: dict[str, set[int]] = {}\n    for item in lines_with_meta:\n        text = item.get(\"text\")\n        screenshot_id = item.get(\"screenshot_id\")\n        if not text:\n            continue\n        if text not in text_to_screenshots:\n            text_to_screenshots[text] = set()\n        screenshot_id = screenshot_id if screenshot_id is not None else -1\n        text_to_screenshots[text].add(screenshot_id)\n    return text_to_screenshots\n\n\ndef identify_ui_candidates(text_to_screenshots: dict[str, set[int]]) -> set[str]:\n    \"\"\"识别UI候选文本\"\"\"\n    return {\n        text\n        for text, screenshots in text_to_screenshots.items()\n        if len(screenshots) >= UI_REPEAT_THRESHOLD and len(text) <= UI_CANDIDATE_MAX_LENGTH\n    }\n\n\ndef separate_ui_and_body_lines(\n    lines_with_meta: list[dict[str, Any]], ui_candidates: set[str]\n) -> tuple[list[str], list[str]]:\n    \"\"\"将行分为UI行和正文行\"\"\"\n    ui_lines: list[str] = []\n    body_lines: list[str] = []\n    for item in lines_with_meta:\n        text = item.get(\"text\")\n        if not text:\n            continue\n        if text in ui_candidates:\n            ui_lines.append(text)\n        else:\n            body_lines.append(text)\n    return ui_lines, body_lines\n\n\ndef select_representative_ui_texts(ui_lines: list[str]) -> list[str]:\n    \"\"\"选择代表性UI文本（去重）\"\"\"\n    seen_ui: set[str] = set()\n    ui_kept: list[str] = []\n    for line in ui_lines:\n        if line in seen_ui:\n            continue\n        ui_kept.append(line)\n        seen_ui.add(line)\n        if len(ui_kept) >= UI_REPRESENTATIVE_LIMIT:\n            break\n    return ui_kept\n\n\ndef separate_ui_candidates(\n    lines_with_meta: list[dict[str, Any]],\n) -> tuple[list[str], dict[str, Any]]:\n    \"\"\"识别跨截图重复的UI候选文本，并返回正文行\n\n    Args:\n        lines_with_meta: 包含文本及其来源截图ID的行级元数据\n\n    Returns:\n        (正文行列表, ui调试信息)\n    \"\"\"\n    ui_info = {\n        \"ui_candidates\": [],\n        \"ui_candidates_count\": 0,\n        \"ui_lines_total\": 0,\n        \"ui_kept\": [],\n        \"body_lines_count\": 0,\n        \"repeat_threshold\": UI_REPEAT_THRESHOLD,\n        \"length_cutoff\": UI_CANDIDATE_MAX_LENGTH,\n    }\n\n    if not lines_with_meta:\n        return [], ui_info\n\n    text_to_screenshots = build_text_to_screenshots_map(lines_with_meta)\n    ui_candidates = identify_ui_candidates(text_to_screenshots)\n    ui_lines, body_lines = separate_ui_and_body_lines(lines_with_meta, ui_candidates)\n    ui_kept = select_representative_ui_texts(ui_lines)\n\n    ui_info.update(\n        {\n            \"ui_candidates\": list(ui_candidates),\n            \"ui_candidates_count\": len(ui_candidates),\n            \"ui_lines_total\": len(ui_lines),\n            \"ui_kept\": ui_kept,\n            \"body_lines_count\": len(body_lines),\n        }\n    )\n\n    return body_lines, ui_info\n"
  },
  {
    "path": "lifetrace/llm/event_summary_service.py",
    "content": "\"\"\"\n事件摘要生成服务\n使用LLM为事件生成标题和摘要\n\"\"\"\n\nimport json\nimport threading\nfrom datetime import datetime\nfrom typing import Any\n\nfrom lifetrace.core.dependencies import get_vector_service\nfrom lifetrace.llm.llm_client import LLMClient\nfrom lifetrace.storage import event_mgr, get_session\nfrom lifetrace.storage.models import Event\nfrom lifetrace.storage.sql_utils import col\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.prompt_loader import get_prompt\nfrom lifetrace.util.token_usage_logger import log_token_usage\n\nfrom .event_summary_clustering import cluster_ocr_texts_with_hdbscan\nfrom .event_summary_config import (\n    MAX_COMBINED_TEXT_LENGTH,\n    MAX_SUMMARY_LENGTH,\n    MAX_TITLE_LENGTH,\n    MIN_OCR_TEXT_LENGTH,\n    MIN_SCREENSHOTS_FOR_LLM,\n    OCR_PREVIEW_LENGTH,\n)\nfrom .event_summary_ocr import get_event_ocr_texts, separate_ui_candidates\n\nlogger = get_logger()\n\n\nclass EventSummaryService:\n    \"\"\"事件摘要生成服务\"\"\"\n\n    def __init__(self, vector_service=None):\n        \"\"\"初始化服务\n\n        Args:\n            vector_service: 向量服务实例（可选），如果未提供则尝试从dependencies导入\n        \"\"\"\n        self.llm_client = LLMClient()\n        self.vector_service = vector_service\n\n    def _get_vector_service(self):\n        \"\"\"动态获取向量服务实例\"\"\"\n        if self.vector_service is not None:\n            logger.debug(\"使用初始化时提供的vector_service\")\n            return self.vector_service\n\n        try:\n            vector_svc = get_vector_service()\n            if vector_svc is not None:\n                logger.info(\n                    f\"从core.dependencies获取到vector_service: \"\n                    f\"enabled={vector_svc.enabled}, \"\n                    f\"vector_db={'存在' if vector_svc.vector_db else '不存在'}\"\n                )\n                return vector_svc\n            else:\n                logger.warning(\"get_vector_service()返回None，可能还未初始化\")\n                return None\n        except ImportError as e:\n            logger.warning(f\"无法导入core.dependencies模块: {e}\")\n            return None\n        except Exception as e:\n            logger.warning(f\"获取vector_service时出错: {e}\")\n            return None\n\n    def _process_event_with_few_screenshots(\n        self, event_id: int, event_info: dict[str, Any], screenshot_count: int\n    ) -> dict[str, Any]:\n        \"\"\"处理截图数量较少的事件\"\"\"\n        logger.info(f\"事件 {event_id} 只有 {screenshot_count} 张截图，使用fallback summary\")\n        ocr_lines, ocr_debug_info = get_event_ocr_texts(event_id)\n        result = self._generate_fallback_summary(\n            app_name=event_info[\"app_name\"],\n            window_title=event_info[\"window_title\"],\n        )\n        return {\n            \"result\": result,\n            \"ocr_lines\": ocr_lines,\n            \"ocr_debug_info\": ocr_debug_info,\n            \"clustering_info\": None,\n            \"llm_info\": None,\n        }\n\n    def _process_event_with_sufficient_screenshots(\n        self, event_id: int, event_info: dict[str, Any]\n    ) -> dict[str, Any]:\n        \"\"\"处理有足够截图的事件\"\"\"\n        ocr_lines, ocr_debug_info = get_event_ocr_texts(event_id)\n        body_lines, ui_info = separate_ui_candidates(ocr_debug_info.get(\"lines_with_meta\", []))\n        ocr_debug_info[\"ui_info\"] = ui_info\n        effective_lines = body_lines if body_lines else ocr_lines\n        combined_ocr_length = len(\"\".join(effective_lines).strip()) if effective_lines else 0\n\n        clustering_info = None\n        llm_info = None\n\n        if effective_lines and combined_ocr_length > MIN_OCR_TEXT_LENGTH:\n            vector_service = self._get_vector_service()\n            clustered_texts = cluster_ocr_texts_with_hdbscan(effective_lines, vector_service)\n            clustering_info = None\n\n            if not clustered_texts:\n                clustered_texts = effective_lines\n\n            ui_kept = ui_info.get(\"ui_kept\", []) if ui_info else []\n            llm_input_texts = clustered_texts + ui_kept if ui_kept else clustered_texts\n\n            result = self._generate_summary_with_llm(\n                ocr_texts=llm_input_texts,\n                app_name=event_info[\"app_name\"],\n                window_title=event_info[\"window_title\"],\n                start_time=event_info[\"start_time\"],\n                end_time=event_info[\"end_time\"],\n            )\n            llm_info = None\n        else:\n            result = self._generate_fallback_summary(\n                app_name=event_info[\"app_name\"],\n                window_title=event_info[\"window_title\"],\n            )\n\n        return {\n            \"result\": result,\n            \"ocr_lines\": ocr_lines,\n            \"ocr_debug_info\": ocr_debug_info,\n            \"clustering_info\": clustering_info,\n            \"llm_info\": llm_info,\n        }\n\n    def _update_event_summary_in_db(self, event_id: int, result: dict[str, str] | None) -> bool:\n        \"\"\"更新数据库中的事件摘要\"\"\"\n        if not result:\n            logger.error(f\"事件 {event_id} 摘要生成失败\")\n            return False\n\n        success = event_mgr.update_event_summary(\n            event_id=event_id,\n            ai_title=result[\"title\"],\n            ai_summary=result[\"summary\"],\n        )\n\n        if success:\n            logger.info(f\"事件 {event_id} 摘要生成成功: {result['title']}\")\n            return True\n        logger.error(f\"事件 {event_id} 摘要更新失败\")\n        return False\n\n    def generate_event_summary(self, event_id: int) -> bool:\n        \"\"\"为单个事件生成摘要\n\n        Args:\n            event_id: 事件ID\n\n        Returns:\n            生成是否成功\n        \"\"\"\n        event_info = None\n\n        try:\n            event_info = self._get_event_info(event_id)\n            if not event_info:\n                logger.warning(f\"事件 {event_id} 不存在\")\n                return False\n\n            screenshots = event_mgr.get_event_screenshots(event_id)\n            screenshot_count = len(screenshots)\n\n            if screenshot_count < MIN_SCREENSHOTS_FOR_LLM:\n                process_result = self._process_event_with_few_screenshots(\n                    event_id, event_info, screenshot_count\n                )\n                result = process_result[\"result\"]\n            else:\n                process_result = self._process_event_with_sufficient_screenshots(\n                    event_id, event_info\n                )\n                result = process_result[\"result\"]\n\n            return self._update_event_summary_in_db(event_id, result)\n\n        except Exception as e:\n            logger.error(f\"生成事件 {event_id} 摘要时出错: {e}\", exc_info=True)\n            return False\n\n    def _get_event_info(self, event_id: int) -> dict[str, Any] | None:\n        \"\"\"获取事件信息\"\"\"\n        try:\n            with get_session() as session:\n                event = session.query(Event).filter(col(Event.id) == event_id).first()\n                if not event:\n                    return None\n\n                return {\n                    \"id\": event.id,\n                    \"app_name\": event.app_name,\n                    \"window_title\": event.window_title,\n                    \"start_time\": event.start_time,\n                    \"end_time\": event.end_time,\n                }\n        except Exception as e:\n            logger.error(f\"获取事件信息失败: {e}\")\n            return None\n\n    def _prepare_ocr_text(self, ocr_texts: list[str]) -> str | None:\n        \"\"\"准备OCR文本，合并并限制长度\"\"\"\n        combined_text = \"\\n\".join(ocr_texts)\n        if len(combined_text) > MAX_COMBINED_TEXT_LENGTH:\n            combined_text = combined_text[:MAX_COMBINED_TEXT_LENGTH] + \"...\"\n\n        if not combined_text or len(combined_text.strip()) < MIN_OCR_TEXT_LENGTH:\n            return None\n        return combined_text\n\n    def _extract_json_from_response(self, content: str) -> tuple[str, str]:\n        \"\"\"从LLM响应中提取JSON内容\"\"\"\n        original_content = content\n        if \"```json\" in content:\n            json_start = content.find(\"```json\") + 7\n            json_end = content.find(\"```\", json_start)\n            content = content[json_start:json_end].strip()\n        elif \"```\" in content:\n            json_start = content.find(\"```\") + 3\n            json_end = content.find(\"```\", json_start)\n            content = content[json_start:json_end].strip()\n        return content, original_content\n\n    def _parse_llm_response(self, content: str, original_content: str) -> dict[str, str] | None:\n        \"\"\"解析LLM响应为字典\"\"\"\n        try:\n            result = json.loads(content)\n            if \"title\" in result and \"summary\" in result:\n                title = result[\"title\"][:MAX_TITLE_LENGTH]\n                summary = result[\"summary\"][:MAX_SUMMARY_LENGTH]\n                return {\"title\": title, \"summary\": summary}\n            logger.warning(f\"LLM返回格式不正确: {result}\")\n            return None\n        except json.JSONDecodeError as e:\n            ocr_preview = (\n                original_content[:OCR_PREVIEW_LENGTH]\n                if len(original_content) > OCR_PREVIEW_LENGTH\n                else original_content\n            )\n            logger.error(f\"解析LLM响应JSON失败: {e}\\n原始响应: {ocr_preview[:200]}\")\n            return None\n\n    def _generate_summary_with_llm(\n        self,\n        ocr_texts: list[str],\n        app_name: str,\n        window_title: str,\n        start_time: datetime,\n        end_time: datetime | None,\n    ) -> dict[str, str] | None:\n        \"\"\"使用LLM生成标题和摘要\"\"\"\n        if not self.llm_client.is_available():\n            logger.warning(\"LLM客户端不可用，使用后备方案\")\n            return self._generate_fallback_summary(app_name, window_title)\n\n        combined_text = self._prepare_ocr_text(ocr_texts)\n        if not combined_text:\n            logger.warning(\"OCR文本内容太少，使用后备方案\")\n            return self._generate_fallback_summary(app_name, window_title)\n\n        try:\n            start_str = start_time.strftime(\"%Y-%m-%d %H:%M:%S\") if start_time else \"未知\"\n            end_str = end_time.strftime(\"%Y-%m-%d %H:%M:%S\") if end_time else \"进行中\"\n\n            system_prompt = get_prompt(\"event_summary\", \"system_assistant\")\n            user_prompt = get_prompt(\n                \"event_summary\",\n                \"user_prompt\",\n                app_name=app_name or \"未知应用\",\n                window_title=window_title or \"未知窗口\",\n                start_time=start_str,\n                end_time=end_str,\n                ocr_text=combined_text,\n            )\n\n            client = self.llm_client._get_client()\n            response = client.chat.completions.create(\n                model=self.llm_client.model,\n                messages=[\n                    {\"role\": \"system\", \"content\": system_prompt},\n                    {\"role\": \"user\", \"content\": user_prompt},\n                ],\n                temperature=0.3,\n                max_tokens=200,\n            )\n\n            if hasattr(response, \"usage\") and response.usage:\n                log_token_usage(\n                    model=self.llm_client.model,\n                    input_tokens=response.usage.prompt_tokens,\n                    output_tokens=response.usage.completion_tokens,\n                    endpoint=\"event_summary\",\n                    response_type=\"summary_generation\",\n                    feature_type=\"event_summary\",\n                )\n\n            content = (response.choices[0].message.content or \"\").strip()\n            if content:\n                extracted_content, original_content = self._extract_json_from_response(content)\n                if extracted_content:\n                    result = self._parse_llm_response(extracted_content, original_content)\n                    if result:\n                        return result\n                logger.warning(\"LLM响应解析失败，使用后备方案\")\n            else:\n                logger.warning(\"LLM返回空内容，使用后备方案\")\n\n        except Exception as e:\n            logger.error(f\"LLM生成摘要失败: {e}\", exc_info=True)\n\n        return self._generate_fallback_summary(app_name, window_title)\n\n    def _generate_fallback_summary(\n        self, app_name: str | None, window_title: str | None\n    ) -> dict[str, str]:\n        \"\"\"无OCR数据时的后备方案\"\"\"\n        app_name = app_name or \"未知应用\"\n        window_title = window_title or \"未知窗口\"\n\n        app_display = app_name.replace(\".exe\", \"\").replace(\".EXE\", \"\")\n\n        title = f\"{app_display}使用\"\n        if len(title) > MAX_TITLE_LENGTH:\n            title = title[:MAX_TITLE_LENGTH]\n\n        summary = f\"在**{app_display}**中活动\"\n        if window_title and window_title != \"未知窗口\":\n            summary = f\"使用**{app_display}**: {window_title[:50]}\"\n\n        return {\"title\": title, \"summary\": summary}\n\n\n# 全局实例\nevent_summary_service = EventSummaryService()\n\n\ndef generate_event_summary_async(event_id: int):\n    \"\"\"异步生成事件摘要（在单独线程中调用）\n\n    Args:\n        event_id: 事件ID\n    \"\"\"\n\n    def _generate():\n        try:\n            event_summary_service.generate_event_summary(event_id)\n        except Exception as e:\n            logger.error(f\"异步生成事件摘要失败: {e}\", exc_info=True)\n\n    thread = threading.Thread(target=_generate, daemon=True)\n    thread.start()\n"
  },
  {
    "path": "lifetrace/llm/journal_generation_service.py",
    "content": "\"\"\"Journal generation service for objective and AI-view content.\"\"\"\n\nfrom __future__ import annotations\n\nfrom datetime import datetime\nfrom typing import Any\n\nfrom lifetrace.llm.llm_client import LLMClient\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.token_usage_logger import log_token_usage\n\nlogger = get_logger()\n\n_MAX_ITEMS = 20\n_RESPONSE_PREVIEW_LENGTH = 500\n\n\nclass JournalGenerationService:\n    \"\"\"Generate objective log and AI view for journals.\"\"\"\n\n    def __init__(self) -> None:\n        self.llm_client = LLMClient()\n\n    def generate_objective(\n        self,\n        *,\n        activities: list[dict[str, Any]],\n        todos: list[dict[str, Any]],\n        language: str,\n    ) -> str:\n        if not self.llm_client.is_available():\n            logger.warning(\"LLM client unavailable, using fallback objective log\")\n            return self._fallback_objective(activities, todos, language)\n\n        try:\n            system_prompt = (\n                \"You are a calm journaling assistant. Summarize facts only, no judgment.\"\n            )\n            user_prompt = self._build_objective_prompt(activities, todos, language)\n            content = self._call_llm(system_prompt, user_prompt, response_type=\"objective\")\n            return content or self._fallback_objective(activities, todos, language)\n        except Exception as exc:\n            logger.error(f\"Objective log generation failed: {exc}\", exc_info=True)\n            return self._fallback_objective(activities, todos, language)\n\n    def generate_ai_view(\n        self,\n        *,\n        title: str,\n        content_original: str,\n        activities: list[dict[str, Any]],\n        todos: list[dict[str, Any]],\n        language: str,\n    ) -> str:\n        if not self.llm_client.is_available():\n            logger.warning(\"LLM client unavailable, using fallback AI view\")\n            return self._fallback_ai_view(content_original, language)\n\n        try:\n            system_prompt = (\n                \"You are a gentle observer. Describe the day in a supportive tone, no judgment.\"\n            )\n            user_prompt = self._build_ai_prompt(\n                title=title,\n                content_original=content_original,\n                activities=activities,\n                todos=todos,\n                language=language,\n            )\n            content = self._call_llm(system_prompt, user_prompt, response_type=\"ai_view\")\n            return content or self._fallback_ai_view(content_original, language)\n        except Exception as exc:\n            logger.error(f\"AI view generation failed: {exc}\", exc_info=True)\n            return self._fallback_ai_view(content_original, language)\n\n    def _call_llm(self, system_prompt: str, user_prompt: str, response_type: str) -> str:\n        client = self.llm_client._get_client()\n        response = client.chat.completions.create(\n            model=self.llm_client.model,\n            messages=[\n                {\"role\": \"system\", \"content\": system_prompt},\n                {\"role\": \"user\", \"content\": user_prompt},\n            ],\n            temperature=0.4,\n            max_tokens=800,\n        )\n\n        if hasattr(response, \"usage\") and response.usage:\n            log_token_usage(\n                model=self.llm_client.model,\n                input_tokens=response.usage.prompt_tokens,\n                output_tokens=response.usage.completion_tokens,\n                endpoint=\"journal_generation\",\n                response_type=response_type,\n                feature_type=\"journal_generation\",\n            )\n\n        content = (response.choices[0].message.content or \"\").strip()\n        if not content:\n            logger.warning(\"LLM returned empty content for journal generation\")\n            return \"\"\n        return content\n\n    def _build_objective_prompt(\n        self,\n        activities: list[dict[str, Any]],\n        todos: list[dict[str, Any]],\n        language: str,\n    ) -> str:\n        activity_text = self._format_activity_text(activities)\n        todo_text = self._format_todo_text(todos)\n        return (\n            \"Generate an objective log in the following language. \"\n            f\"Language: {language}.\\n\\n\"\n            \"Activities:\\n\"\n            f\"{activity_text}\\n\\n\"\n            \"Todos:\\n\"\n            f\"{todo_text}\\n\\n\"\n            \"Return a short timeline and a brief summary.\"\n        )\n\n    def _build_ai_prompt(\n        self,\n        *,\n        title: str,\n        content_original: str,\n        activities: list[dict[str, Any]],\n        todos: list[dict[str, Any]],\n        language: str,\n    ) -> str:\n        activity_text = self._format_activity_text(activities)\n        todo_text = self._format_todo_text(todos)\n        return (\n            \"Write a gentle AI-view journal entry based on the original notes and the day data. \"\n            f\"Language: {language}.\\n\\n\"\n            f\"Title: {title or 'Untitled'}\\n\"\n            f\"Original Notes:\\n{content_original}\\n\\n\"\n            \"Activities:\\n\"\n            f\"{activity_text}\\n\\n\"\n            \"Todos:\\n\"\n            f\"{todo_text}\\n\\n\"\n            \"Keep it supportive, observational, and non-judgmental.\"\n        )\n\n    def _format_activity_text(self, activities: list[dict[str, Any]]) -> str:\n        if not activities:\n            return \"(none)\"\n        lines: list[str] = []\n        for activity in activities[:_MAX_ITEMS]:\n            title = activity.get(\"title\") or \"Activity\"\n            summary = activity.get(\"summary\") or \"\"\n            start = self._format_time(activity.get(\"start_time\"))\n            line = f\"- {start} {title}\"\n            if summary:\n                line = f\"{line}: {summary}\"\n            lines.append(line)\n        if len(activities) > _MAX_ITEMS:\n            lines.append(f\"- ... ({len(activities) - _MAX_ITEMS} more)\")\n        return \"\\n\".join(lines)\n\n    def _format_todo_text(self, todos: list[dict[str, Any]]) -> str:\n        if not todos:\n            return \"(none)\"\n        lines: list[str] = []\n        for todo in todos[:_MAX_ITEMS]:\n            name = todo.get(\"name\") or \"Todo\"\n            status = todo.get(\"status\") or \"unknown\"\n            time_str = self._format_time(todo.get(\"deadline\") or todo.get(\"start_time\"))\n            line = f\"- {name} ({status})\"\n            if time_str:\n                line = f\"{line} @ {time_str}\"\n            lines.append(line)\n        if len(todos) > _MAX_ITEMS:\n            lines.append(f\"- ... ({len(todos) - _MAX_ITEMS} more)\")\n        return \"\\n\".join(lines)\n\n    def _format_time(self, value: Any) -> str:\n        if isinstance(value, datetime):\n            return value.strftime(\"%H:%M\")\n        if value:\n            return str(value)\n        return \"\"\n\n    def _fallback_objective(\n        self,\n        activities: list[dict[str, Any]],\n        todos: list[dict[str, Any]],\n        language: str,\n    ) -> str:\n        activity_count = len(activities)\n        todo_count = len(todos)\n        if language.lower().startswith(\"zh\"):\n            return (\n                \"Objective log (ZH):\\n\"\n                f\"- Activities: {activity_count}\\n\"\n                f\"- Todos: {todo_count}\\n\"\n                \"- Detailed record unavailable\"\n            )\n        return (\n            \"Objective log:\\n\"\n            f\"- Activities: {activity_count}\\n\"\n            f\"- Todos: {todo_count}\\n\"\n            \"- Detailed record unavailable\"\n        )\n\n    def _fallback_ai_view(self, content_original: str, language: str) -> str:\n        preview = content_original.strip()[:_RESPONSE_PREVIEW_LENGTH]\n        if language.lower().startswith(\"zh\"):\n            if preview:\n                return f\"AI view (ZH):\\nYou noted: {preview}\"\n            return \"AI view (ZH):\\nNotes are light today, but your effort still counts.\"\n        if preview:\n            return f\"AI view:\\nYou noted: {preview}\"\n        return \"AI view:\\nToday is light on notes, but your effort still counts.\"\n\n\njournal_generation_service = JournalGenerationService()\n"
  },
  {
    "path": "lifetrace/llm/llm_client.py",
    "content": "\"\"\"\nLLM客户端模块\n提供与OpenAI兼容API的交互\n\"\"\"\n\nimport contextlib\nfrom typing import TYPE_CHECKING, Any, cast\n\nfrom openai import OpenAI\n\nif TYPE_CHECKING:\n    from openai.types.chat import ChatCompletionMessageParam\nelse:\n    ChatCompletionMessageParam = Any\n\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.settings import settings\nfrom lifetrace.util.token_usage_logger import setup_token_logger\n\nfrom .llm_client_intent import classify_intent_with_llm, rule_based_intent_classification\nfrom .llm_client_query import (\n    build_context_text,\n    fallback_summary,\n    generate_summary_with_llm,\n    parse_query_with_llm,\n    rule_based_parse,\n)\nfrom .llm_client_vision import vision_chat\n\nlogger = get_logger()\n\n\nclass LLMClient:\n    \"\"\"LLM客户端，用于与OpenAI兼容的API进行交互（单例模式）\"\"\"\n\n    _instance = None\n    _initialized = False\n\n    def __new__(cls):\n        \"\"\"实现单例模式\"\"\"\n        if cls._instance is None:\n            cls._instance = super().__new__(cls)\n        return cls._instance\n\n    def __init__(self):\n        \"\"\"初始化LLM客户端\"\"\"\n        if not LLMClient._initialized:\n            self._initialize_client()\n            setup_token_logger()\n            LLMClient._initialized = True\n\n    def _initialize_client(self):\n        \"\"\"内部方法：初始化或重新初始化客户端\"\"\"\n        try:\n            self.api_key = settings.llm.api_key\n            self.base_url = settings.llm.base_url\n            self.model = settings.llm.model\n\n            invalid_values = [\n                \"xxx\",\n                \"YOUR_API_KEY_HERE\",\n                \"YOUR_BASE_URL_HERE\",\n                \"YOUR_LLM_KEY_HERE\",\n            ]\n            if not self.api_key or self.api_key in invalid_values:\n                logger.warning(\"LLM Key未配置或为默认占位符，LLM功能可能不可用\")\n            if not self.base_url or self.base_url in invalid_values:\n                logger.warning(\"Base URL未配置或为默认占位符，LLM功能可能不可用\")\n        except Exception as e:\n            logger.error(f\"无法从配置文件读取LLM配置: {e}\")\n            self.api_key = \"YOUR_LLM_KEY_HERE\"\n            self.base_url = \"https://dashscope.aliyuncs.com/compatible-mode/v1\"\n            self.model = \"qwen3-max\"\n            logger.warning(\"使用硬编码默认值初始化LLM客户端\")\n\n        try:\n            if OpenAI is None:\n                raise ImportError(\"openai 依赖未安装\")\n            self.client = OpenAI(base_url=self.base_url, api_key=self.api_key)\n            logger.info(f\"LLM客户端初始化成功，使用模型: {self.model}\")\n            logger.info(f\"API Base URL: {self.base_url}\")\n        except Exception as e:\n            logger.error(f\"LLM客户端初始化失败: {e}\")\n            self.client = None\n\n    def reinitialize(self):\n        \"\"\"重新初始化LLM客户端\"\"\"\n        logger.info(\"正在重新初始化LLM客户端...\")\n        old_api_key = self.api_key if hasattr(self, \"api_key\") else None\n        old_model = self.model if hasattr(self, \"model\") else None\n\n        self._initialize_client()\n\n        if old_api_key != self.api_key:\n            logger.info(\n                f\"API Key已更新: {old_api_key[:10] if old_api_key else 'None'}... -> {self.api_key[:10]}...\"\n            )\n        if old_model != self.model:\n            logger.info(f\"模型已更新: {old_model} -> {self.model}\")\n\n        return self.is_available()\n\n    def is_available(self) -> bool:\n        \"\"\"检查LLM客户端是否可用\"\"\"\n        return self.client is not None\n\n    def _get_client(self) -> OpenAI:\n        if self.client is None:\n            raise RuntimeError(\"LLM客户端不可用，无法进行请求\")\n        return self.client\n\n    def classify_intent(self, user_query: str) -> dict[str, Any]:\n        \"\"\"分类用户意图\"\"\"\n        if not self.is_available():\n            logger.warning(\"LLM客户端不可用，使用规则分类\")\n            return rule_based_intent_classification(user_query)\n\n        return classify_intent_with_llm(self.client, self.model, user_query)\n\n    def parse_query(self, user_query: str) -> dict[str, Any]:\n        \"\"\"解析用户查询\"\"\"\n        if not self.is_available():\n            logger.warning(\"LLM客户端不可用，使用规则解析\")\n            return rule_based_parse(user_query)\n\n        return parse_query_with_llm(self.client, self.model, user_query)\n\n    def generate_summary(self, query: str, context_data: list[dict[str, Any]]) -> str:\n        \"\"\"生成摘要\"\"\"\n        if not self.is_available():\n            logger.warning(\"LLM客户端不可用，使用规则总结\")\n            return fallback_summary(query, context_data)\n\n        return generate_summary_with_llm(self.client, self.model, query, context_data)\n\n    def chat(\n        self,\n        messages: list[dict[str, str]],\n        temperature: float = 0.7,\n        model: str | None = None,\n        max_tokens: int | None = None,\n    ) -> str:\n        \"\"\"通用非流式聊天方法，返回完整文本结果。\"\"\"\n        if not self.is_available():\n            raise RuntimeError(\"LLM客户端不可用，无法进行文本聊天\")\n\n        try:\n            client = self._get_client()\n            response = client.chat.completions.create(\n                model=model or self.model,\n                messages=cast(\"list[ChatCompletionMessageParam]\", messages),\n                temperature=temperature,\n                max_tokens=max_tokens,\n            )\n            content = response.choices[0].message.content or \"\"\n            return content\n        except Exception as e:\n            logger.error(f\"文本聊天失败: {e}\")\n            raise\n\n    def stream_chat(\n        self,\n        messages: list[dict[str, str]],\n        temperature: float = 0.7,\n        model: str | None = None,\n    ):\n        \"\"\"通用流式聊天方法\"\"\"\n        if not self.is_available():\n            raise RuntimeError(\"LLM客户端不可用，无法进行流式生成\")\n        try:\n            # 关闭 enable_thinking 以提升性能（方案 B）\n            # 如果未来需要思考模式，可以通过参数控制\n            client = self._get_client()\n            stream = client.chat.completions.create(\n                model=model or self.model,\n                messages=cast(\"list[ChatCompletionMessageParam]\", messages),\n                temperature=temperature,\n                # extra_body={\"enable_thinking\": True},  # 已移除以提升性能\n                stream=True,\n            )\n            for chunk in stream:\n                with contextlib.suppress(Exception):\n                    delta = chunk.choices[0].delta\n                    text = getattr(delta, \"content\", None)\n                    if text:\n                        yield text\n        except Exception as e:\n            logger.error(f\"流式聊天失败: {e}\")\n            raise\n\n    def vision_chat(\n        self,\n        screenshot_ids: list[int],\n        prompt: str,\n        model: str | None = None,\n        temperature: float | None = None,\n        max_tokens: int | None = None,\n    ) -> dict[str, Any]:\n        \"\"\"视觉多模态聊天\"\"\"\n        if not self.is_available():\n            raise RuntimeError(\"LLM客户端不可用，无法进行视觉多模态分析\")\n\n        return vision_chat(\n            self.client,\n            self.model,\n            screenshot_ids,\n            prompt,\n            model,\n            temperature,\n            max_tokens,\n        )\n\n    # 保持向后兼容的方法\n    def _rule_based_intent_classification(self, user_query: str) -> dict[str, Any]:\n        \"\"\"基于规则的意图分类（向后兼容）\"\"\"\n        return rule_based_intent_classification(user_query)\n\n    def _rule_based_parse(self, user_query: str) -> dict[str, Any]:\n        \"\"\"基于规则的查询解析（向后兼容）\"\"\"\n        return rule_based_parse(user_query)\n\n    def _build_context_text(self, context_data: list[dict[str, Any]]) -> str:\n        \"\"\"构建上下文文本（向后兼容）\"\"\"\n        return build_context_text(context_data)\n\n    def _fallback_summary(self, query: str, context_data: list[dict[str, Any]]) -> str:\n        \"\"\"备用总结（向后兼容）\"\"\"\n        return fallback_summary(query, context_data)\n"
  },
  {
    "path": "lifetrace/llm/llm_client_intent.py",
    "content": "\"\"\"\nLLM 意图分类模块\n包含意图分类和规则匹配逻辑\n\"\"\"\n\nimport json\nfrom typing import Any\n\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.prompt_loader import get_prompt\nfrom lifetrace.util.token_usage_logger import log_token_usage\n\nlogger = get_logger()\n\n\ndef classify_intent_with_llm(client, model: str, user_query: str) -> dict[str, Any]:\n    \"\"\"使用LLM分类用户意图\n\n    Args:\n        client: OpenAI客户端\n        model: 模型名称\n        user_query: 用户查询\n\n    Returns:\n        包含意图分类结果的字典\n    \"\"\"\n    try:\n        prompt = \"\"\"\n请分析以下用户输入，判断用户的意图类型。\n\n用户输入：\"<USER_QUERY>\"\n\n请判断这个输入属于以下哪种类型：\n1. \"database_query\" - 需要查询数据库的请求（如：搜索截图、统计使用情况、查找特定应用等）\n2. \"general_chat\" - 一般对话（如：问候、闲聊、询问功能等）\n3. \"system_help\" - 系统帮助请求（如：如何使用、功能说明等）\n\n请以JSON格式返回结果：\n{\n    \"intent_type\": \"database_query/general_chat/system_help\",\n    \"needs_database\": true/false\n}\n\n只返回JSON，不要返回其他任何信息，不要使用markdown代码块标记。\n\"\"\"\n        user_content = prompt.replace(\"<USER_QUERY>\", user_query)\n\n        response = client.chat.completions.create(\n            model=model,\n            messages=[\n                {\n                    \"role\": \"system\",\n                    \"content\": get_prompt(\"llm_client\", \"intent_classification\"),\n                },\n                {\"role\": \"user\", \"content\": user_content},\n            ],\n            temperature=0.1,\n            max_tokens=200,\n        )\n\n        if hasattr(response, \"usage\") and response.usage:\n            log_token_usage(\n                model=model,\n                input_tokens=response.usage.prompt_tokens,\n                output_tokens=response.usage.completion_tokens,\n                endpoint=\"classify_intent\",\n                user_query=user_query,\n                response_type=\"intent_classification\",\n                feature_type=\"event_assistant\",\n            )\n\n        result_text = response.choices[0].message.content.strip()\n\n        logger.info(\"=== LLM意图分类响应 ===\")\n        logger.info(f\"用户输入: {user_query}\")\n        logger.info(f\"LLM回复: {result_text}\")\n        logger.info(\"=== 响应结束 ===\")\n\n        logger.info(f\"LLM意图分类 - 用户输入: {user_query}\")\n        logger.info(f\"LLM意图分类 - 原始响应: {result_text}\")\n\n        try:\n            clean_text = result_text.strip()\n            if clean_text.startswith(\"```json\"):\n                clean_text = clean_text[7:]\n            if clean_text.endswith(\"```\"):\n                clean_text = clean_text[:-3]\n            clean_text = clean_text.strip()\n\n            result = json.loads(clean_text)\n            logger.info(\n                f\"意图分类结果: {result['intent_type']}, 需要数据库: {result['needs_database']}\"\n            )\n            return result\n        except json.JSONDecodeError:\n            logger.warning(f\"LLM返回的不是有效JSON: {result_text}\")\n            return rule_based_intent_classification(user_query)\n\n    except Exception as e:\n        logger.error(f\"LLM意图分类失败: {e}\")\n        return rule_based_intent_classification(user_query)\n\n\ndef rule_based_intent_classification(user_query: str) -> dict[str, Any]:\n    \"\"\"基于规则的意图分类（备用方案）\"\"\"\n    query_lower = user_query.lower()\n\n    # 数据库查询关键词\n    database_keywords = [\n        \"搜索\",\n        \"查找\",\n        \"统计\",\n        \"显示\",\n        \"截图\",\n        \"应用\",\n        \"使用情况\",\n        \"时间\",\n        \"最近\",\n        \"今天\",\n        \"昨天\",\n        \"本周\",\n        \"上周\",\n        \"本月\",\n        \"上月\",\n        \"search\",\n        \"find\",\n        \"show\",\n        \"statistics\",\n        \"screenshot\",\n        \"app\",\n        \"usage\",\n    ]\n\n    # 一般对话关键词\n    chat_keywords = [\n        \"你好\",\n        \"谢谢\",\n        \"再见\",\n        \"怎么样\",\n        \"如何\",\n        \"为什么\",\n        \"什么是\",\n        \"hello\",\n        \"hi\",\n        \"thanks\",\n        \"bye\",\n        \"how\",\n        \"what\",\n        \"why\",\n    ]\n\n    # 系统帮助关键词\n    help_keywords = [\n        \"帮助\",\n        \"功能\",\n        \"使用方法\",\n        \"教程\",\n        \"说明\",\n        \"介绍\",\n        \"help\",\n        \"function\",\n        \"tutorial\",\n        \"guide\",\n        \"instruction\",\n    ]\n\n    database_score = sum(1 for keyword in database_keywords if keyword in query_lower)\n    chat_score = sum(1 for keyword in chat_keywords if keyword in query_lower)\n    help_score = sum(1 for keyword in help_keywords if keyword in query_lower)\n\n    if database_score > 0:\n        intent_type = \"database_query\"\n        needs_database = True\n    elif help_score > 0:\n        intent_type = \"system_help\"\n        needs_database = False\n    elif chat_score > 0:\n        intent_type = \"general_chat\"\n        needs_database = False\n    else:\n        intent_type = \"database_query\"\n        needs_database = True\n\n    return {\"intent_type\": intent_type, \"needs_database\": needs_database}\n"
  },
  {
    "path": "lifetrace/llm/llm_client_query.py",
    "content": "\"\"\"\nLLM 查询解析和摘要生成模块\n\"\"\"\n\nimport contextlib\nimport json\nfrom datetime import datetime\nfrom typing import Any\n\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.prompt_loader import get_prompt\nfrom lifetrace.util.time_utils import get_utc_now\nfrom lifetrace.util.token_usage_logger import log_token_usage\n\nlogger = get_logger()\n\n\ndef parse_query_with_llm(client, model: str, user_query: str) -> dict[str, Any]:\n    \"\"\"使用LLM解析用户查询\n\n    Args:\n        client: OpenAI客户端\n        model: 模型名称\n        user_query: 用户查询\n\n    Returns:\n        解析后的查询条件字典\n    \"\"\"\n    current_time = get_utc_now().astimezone()\n    current_date_str = current_time.strftime(\"%Y-%m-%d %H:%M:%S\")\n\n    system_prompt = get_prompt(\"llm_client\", \"query_parsing\")\n\n    try:\n        user_message = f\"当前时间是：{current_date_str}\\n请解析这个查询：{user_query}\"\n        response = client.chat.completions.create(\n            messages=[\n                {\"role\": \"system\", \"content\": system_prompt},\n                {\"role\": \"user\", \"content\": user_message},\n            ],\n            model=model,\n            temperature=0.1,\n        )\n\n        if hasattr(response, \"usage\") and response.usage:\n            log_token_usage(\n                model=model,\n                input_tokens=response.usage.prompt_tokens,\n                output_tokens=response.usage.completion_tokens,\n                endpoint=\"parse_query\",\n                user_query=user_query,\n                response_type=\"query_parsing\",\n                feature_type=\"event_assistant\",\n            )\n\n        result_text = response.choices[0].message.content.strip()\n\n        logger.info(\"=== LLM查询解析响应 ===\")\n        logger.info(f\"用户查询: {user_query}\")\n        logger.info(f\"LLM回复: {result_text}\")\n        logger.info(\"=== 响应结束 ===\")\n\n        logger.info(f\"LLM查询解析 - 用户查询: {user_query}\")\n        logger.info(f\"LLM查询解析 - 原始响应: {result_text}\")\n\n        try:\n            clean_text = result_text.strip()\n            if clean_text.startswith(\"```json\"):\n                clean_text = clean_text[7:]\n            if clean_text.endswith(\"```\"):\n                clean_text = clean_text[:-3]\n            clean_text = clean_text.strip()\n            result = json.loads(clean_text)\n            return result\n        except json.JSONDecodeError:\n            logger.warning(f\"LLM返回的不是有效JSON: {result_text}\")\n            return rule_based_parse(user_query)\n\n    except Exception as e:\n        logger.error(f\"LLM解析失败: {e}\")\n        return rule_based_parse(user_query)\n\n\ndef rule_based_parse(user_query: str) -> dict[str, Any]:\n    \"\"\"基于规则的查询解析（备用方案）\"\"\"\n    query_lower = user_query.lower()  # noqa: F841\n\n    keywords = []\n    time_keywords = [\"今天\", \"昨天\", \"本周\", \"上周\", \"本月\", \"上月\", \"最近\"]\n    app_keywords = [\"微信\", \"qq\", \"浏览器\", \"chrome\", \"edge\", \"word\", \"excel\"]\n\n    search_indicators = [\"搜索\", \"查找\", \"包含\", \"关于\", \"找到\"]\n    has_search_intent = any(indicator in user_query for indicator in search_indicators)\n\n    if has_search_intent:\n        function_words = [\"聊天\", \"浏览\", \"编辑\", \"查看\", \"打开\", \"使用\", \"运行\"]\n        blocked_words = {\n            \"搜索\",\n            \"查找\",\n            \"包含\",\n            \"关于\",\n            \"找到\",\n            \"今天\",\n            \"昨天\",\n            \"的\",\n            \"在\",\n            \"上\",\n            \"中\",\n            \"里\",\n        }\n        words = user_query.split()\n        for word in words:\n            if (\n                len(word) > 1\n                and word not in function_words\n                and word not in time_keywords\n                and word not in app_keywords\n                and word not in blocked_words\n            ):\n                keywords.append(word)\n\n    start_date = None\n    end_date = None\n    if \"今天\" in user_query:\n        now = get_utc_now().astimezone()\n        start_date = now.strftime(\"%Y-%m-%d 00:00:00\")\n        end_date = now.strftime(\"%Y-%m-%d 23:59:59\")\n\n    apps = []\n    for app in app_keywords:\n        if app in user_query:\n            apps.append(app)\n\n    if any(kw in user_query for kw in [\"统计\", \"数量\", \"时长\"]):\n        query_type = \"statistics\"\n    elif any(kw in user_query for kw in [\"搜索\", \"查找\", \"包含\"]):\n        query_type = \"search\"\n    else:\n        query_type = \"summary\"\n\n    return {\n        \"start_date\": start_date,\n        \"end_date\": end_date,\n        \"app_names\": apps or None,\n        \"keywords\": keywords or None,\n        \"query_type\": query_type,\n    }\n\n\ndef build_context_text(context_data: list[dict[str, Any]]) -> str:\n    \"\"\"构建上下文文本用于摘要生成\"\"\"\n    max_ocr_text_length = 200\n    max_displayed_records = 10\n\n    if not context_data:\n        return \"没有找到相关的历史记录数据。\"\n\n    context_parts = [f\"找到 {len(context_data)} 条相关记录:\"]\n\n    for i, record in enumerate(context_data[:max_displayed_records]):\n        timestamp = record.get(\"timestamp\", \"未知时间\")\n        if timestamp and timestamp != \"未知时间\":\n            with contextlib.suppress(ValueError, TypeError):\n                dt = datetime.fromisoformat(timestamp.replace(\"Z\", \"+00:00\"))\n                timestamp = dt.strftime(\"%Y-%m-%d %H:%M\")\n\n        app_name = record.get(\"app_name\", \"未知应用\")\n        ocr_text = record.get(\"ocr_text\", \"无文本内容\")\n        window_title = record.get(\"window_title\", \"\")\n        screenshot_id = record.get(\"screenshot_id\") or record.get(\"id\")\n\n        if len(ocr_text) > max_ocr_text_length:\n            ocr_text = ocr_text[:max_ocr_text_length] + \"...\"\n\n        record_text = f\"{i + 1}. [{app_name}] {timestamp}\"\n        if window_title:\n            record_text += f\" - {window_title}\"\n        if screenshot_id:\n            record_text += f\" [截图ID: {screenshot_id}]\"\n        record_text += f\"\\n   内容: {ocr_text}\"\n\n        context_parts.append(record_text)\n\n    if len(context_data) > max_displayed_records:\n        context_parts.append(f\"... 还有 {len(context_data) - max_displayed_records} 条记录\")\n\n    return \"\\n\\n\".join(context_parts)\n\n\ndef generate_summary_with_llm(\n    client, model: str, query: str, context_data: list[dict[str, Any]]\n) -> str:\n    \"\"\"使用LLM生成摘要\n\n    Args:\n        client: OpenAI客户端\n        model: 模型名称\n        query: 用户查询\n        context_data: 上下文数据\n\n    Returns:\n        生成的摘要文本\n    \"\"\"\n    system_prompt = get_prompt(\"llm_client\", \"summary_generation\")\n    context_text = build_context_text(context_data)\n\n    user_prompt = f\"\"\"\n用户查询：{query}\n\n相关历史数据：\n{context_text}\n\n请基于以上数据回答用户的查询。\n\"\"\"\n\n    try:\n        response = client.chat.completions.create(\n            messages=[\n                {\"role\": \"system\", \"content\": system_prompt},\n                {\"role\": \"user\", \"content\": user_prompt},\n            ],\n            model=model,\n            temperature=0.3,\n            extra_body={\"enable_thinking\": True},\n        )\n\n        if hasattr(response, \"usage\") and response.usage:\n            log_token_usage(\n                model=model,\n                input_tokens=response.usage.prompt_tokens,\n                output_tokens=response.usage.completion_tokens,\n                endpoint=\"generate_summary\",\n                user_query=query,\n                response_type=\"summary_generation\",\n                feature_type=\"event_assistant\",\n                additional_info={\"context_records\": len(context_data)},\n            )\n\n        result = response.choices[0].message.content.strip()\n\n        logger.info(\"=== LLM总结生成响应 ===\")\n        logger.info(f\"用户查询: {query}\")\n        logger.info(f\"LLM回复: {result}\")\n        logger.info(\"=== 响应结束 ===\")\n\n        logger.info(f\"LLM总结生成 - 用户查询: {query}\")\n        logger.info(f\"LLM总结生成 - 生成结果: {result}\")\n        logger.info(f\"LLM生成总结成功，长度: {len(result)}\")\n        return result\n\n    except Exception as e:\n        logger.error(f\"LLM总结生成失败: {e}\")\n        return fallback_summary(query, context_data)\n\n\ndef fallback_summary(query: str, context_data: list[dict[str, Any]]) -> str:\n    \"\"\"在LLM不可用或失败时的总结备选方案\"\"\"\n    total_records = len(context_data)\n    summary_parts = [\n        f\"以下是根据历史数据的简要总结（查询: {query}）：\",\n        f\"- 共检索到相关记录 {total_records} 条\",\n        \"- 涉及多个应用和时间点\",\n        \"- 建议进一步细化查询条件以获得更精确的结果\",\n    ]\n    return \"\\n\".join(summary_parts)\n\n\ndef build_context(context_data: list[dict[str, Any]]) -> str:\n    \"\"\"构建用于LLM生成的上下文文本\"\"\"\n    context_parts = []\n    for i, item in enumerate(context_data[:50], start=1):\n        text = item.get(\"text\", \"\")\n        if not text:\n            text = (\n                item.get(\"ocr_result\", {}).get(\"text\", \"\")\n                if isinstance(item.get(\"ocr_result\"), dict)\n                else \"\"\n            )\n        app_name = (\n            item.get(\"metadata\", {}).get(\"app_name\", \"\")\n            if isinstance(item.get(\"metadata\"), dict)\n            else \"\"\n        )\n        timestamp = (\n            item.get(\"metadata\", {}).get(\"created_at\", \"\")\n            if isinstance(item.get(\"metadata\"), dict)\n            else \"\"\n        )\n        context_parts.append(f\"[{i}] 应用: {app_name}, 时间: {timestamp}\\n{text}\\n\")\n    return \"\\n\".join(context_parts)\n"
  },
  {
    "path": "lifetrace/llm/llm_client_vision.py",
    "content": "\"\"\"\nLLM 视觉多模态模块\n包含视觉分析相关功能\n\"\"\"\n\nfrom typing import Any\n\nfrom lifetrace.util.image_utils import get_screenshots_base64\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.settings import settings\nfrom lifetrace.util.token_usage_logger import log_token_usage\n\nlogger = get_logger()\n\n\ndef get_vision_model(model: str | None, default_model: str) -> str:\n    \"\"\"获取视觉模型名称\"\"\"\n    return model or settings.llm.vision_model or default_model\n\n\ndef get_vision_temperature(temperature: float | None) -> float:\n    \"\"\"获取视觉模型温度参数\"\"\"\n    return temperature if temperature is not None else settings.llm.temperature\n\n\ndef get_vision_max_tokens(max_tokens: int | None) -> int:\n    \"\"\"获取视觉模型最大token数\"\"\"\n    return max_tokens if max_tokens is not None else settings.llm.max_tokens\n\n\ndef vision_chat(\n    client,\n    default_model: str,\n    screenshot_ids: list[int],\n    prompt: str,\n    model: str | None = None,\n    temperature: float | None = None,\n    max_tokens: int | None = None,\n) -> dict[str, Any]:\n    \"\"\"视觉多模态聊天：使用通义千问视觉模型分析多张图片\n\n    Args:\n        client: OpenAI客户端\n        default_model: 默认模型名称\n        screenshot_ids: 截图ID列表\n        prompt: 文本提示词\n        model: 视觉模型名称\n        temperature: 温度参数\n        max_tokens: 最大生成token数\n\n    Returns:\n        包含响应和元信息的字典\n    \"\"\"\n    try:\n        screenshot_data = get_screenshots_base64(screenshot_ids)\n        valid_screenshots = [item for item in screenshot_data if \"base64_data\" in item]\n\n        if not valid_screenshots:\n            raise ValueError(\"没有可用的截图，请检查截图ID是否正确\")\n\n        content = []\n\n        for item in valid_screenshots:\n            content.append(\n                {\n                    \"type\": \"image_url\",\n                    \"image_url\": {\"url\": item[\"base64_data\"]},\n                }\n            )\n\n        content.append({\"type\": \"text\", \"text\": prompt})\n\n        messages = [{\"role\": \"user\", \"content\": content}]\n\n        vision_model = get_vision_model(model, default_model)\n        vision_temperature = get_vision_temperature(temperature)\n        vision_max_tokens = get_vision_max_tokens(max_tokens)\n\n        logger.info(f\"调用视觉模型 {vision_model}，处理 {len(valid_screenshots)} 张截图\")\n\n        timeout_seconds = min(300, max(60, len(valid_screenshots) * 30))\n\n        response = client.chat.completions.create(\n            model=vision_model,\n            messages=messages,\n            temperature=vision_temperature,\n            max_tokens=vision_max_tokens,\n            timeout=timeout_seconds,\n        )\n\n        result_text = response.choices[0].message.content.strip()\n\n        usage_info = None\n        if hasattr(response, \"usage\") and response.usage:\n            usage_info = {\n                \"prompt_tokens\": response.usage.prompt_tokens,\n                \"completion_tokens\": response.usage.completion_tokens,\n                \"total_tokens\": response.usage.total_tokens,\n            }\n\n            log_token_usage(\n                model=vision_model,\n                input_tokens=response.usage.prompt_tokens,\n                output_tokens=response.usage.completion_tokens,\n                endpoint=\"vision_chat\",\n                user_query=prompt,\n                response_type=\"vision_analysis\",\n                feature_type=\"vision_assistant\",\n                additional_info={\n                    \"screenshot_count\": len(valid_screenshots),\n                    \"screenshot_ids\": screenshot_ids,\n                },\n            )\n\n        logger.info(f\"视觉模型分析完成，响应长度: {len(result_text)}\")\n\n        return {\n            \"response\": result_text,\n            \"usage_info\": usage_info,\n            \"model\": vision_model,\n            \"screenshot_count\": len(valid_screenshots),\n        }\n\n    except Exception as e:\n        logger.error(f\"视觉多模态分析失败: {e}\", exc_info=True)\n        raise\n"
  },
  {
    "path": "lifetrace/llm/ocr_todo_extractor.py",
    "content": "\"\"\"OCR-based todo extraction helper module.\n\nThis module handles todo extraction from OCR text content, including\ncaching, rate limiting, and deduplication logic.\n\"\"\"\n\nimport hashlib\nimport json\nimport re\nimport time\nfrom datetime import datetime\nfrom typing import Any\n\nfrom lifetrace.llm.llm_client import LLMClient\nfrom lifetrace.storage import ocr_mgr, todo_mgr\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.prompt_loader import get_prompt\nfrom lifetrace.util.time_parser import calculate_scheduled_time\nfrom lifetrace.util.time_utils import get_utc_now\n\nlogger = get_logger()\n\n\ndef _compute_text_hash(text_content: str) -> str | None:\n    \"\"\"对 OCR 文本进行标准化并计算哈希，用于判断是否重复。\n\n    必须与 OCRManager 中的逻辑保持一致。\n    \"\"\"\n    normalized = \" \".join((text_content or \"\").strip().split())\n    if not normalized:\n        return None\n    return hashlib.md5(normalized.encode(\"utf-8\"), usedforsecurity=False).hexdigest()\n\n\nclass OCRTodoExtractor:\n    \"\"\"OCR-based todo extraction helper class.\"\"\"\n\n    def __init__(self, llm_client: LLMClient):\n        \"\"\"Initialize the extractor with an LLM client.\"\"\"\n        self.llm_client = llm_client\n        # 基于 OCR 文本的 LLM 调用缓存与频率控制\n        # key: text_hash, value: {\"timestamp\": float, \"todos_raw\": list[dict[str, Any]]}\n        self._ocr_text_cache: dict[str, dict[str, Any]] = {}\n        # 同一 text_hash 的最小 LLM 调用间隔（秒），用于限流\n        self._ocr_text_min_interval_sec: float = 60.0\n        # 纯内存缓存的有效期（秒），过期后即便有缓存仍会重新调用 LLM\n        self._ocr_text_cache_ttl_sec: float = 3600.0\n        # 记录每个 text_hash 上一次真实调用 LLM 的时间戳\n        self._ocr_text_last_llm_call: dict[str, float] = {}\n\n    def extract_todos(  # noqa: PLR0911, PLR0912, PLR0915, C901\n        self,\n        ocr_result_id: int,\n        text_content: str,\n        app_name: str,\n        window_title: str,\n    ) -> dict[str, Any]:\n        \"\"\"基于主动 OCR 的纯文本进行待办提取。\n\n        - 如果相同文本已经处理过，则跳过 LLM 调用。\n        - 始终在提示词中包含当前活跃 Todo 列表，但不对 LLM 输出做额外去重。\n        \"\"\"\n        try:\n            if not self.llm_client.is_available():\n                logger.warning(\"LLM客户端不可用，跳过基于OCR文本的待办提取\")\n                return {\n                    \"ocr_result_id\": ocr_result_id,\n                    \"todos\": [],\n                    \"skipped\": True,\n                    \"reason\": \"llm_unavailable\",\n                }\n\n            text_hash = _compute_text_hash(text_content)\n            if not text_hash:\n                logger.info(\"OCR 文本为空或无有效内容，跳过待办提取\")\n                return {\n                    \"ocr_result_id\": ocr_result_id,\n                    \"todos\": [],\n                    \"skipped\": True,\n                    \"reason\": \"empty_text\",\n                }\n\n            # 如果相同 text_hash 已存在于其他 OCR 结果中，则认为已处理过，跳过 LLM 调用\n            existing = ocr_mgr.get_by_text_hash(text_hash)\n            if existing and existing.get(\"id\") != ocr_result_id:\n                logger.info(\n                    \"检测到已处理过相同 OCR 文本，跳过本次待办提取：\"\n                    f\"current_id={ocr_result_id}, existing_id={existing.get('id')}\"\n                )\n                return {\n                    \"ocr_result_id\": ocr_result_id,\n                    \"todos\": [],\n                    \"skipped\": True,\n                    \"reason\": \"text_already_processed\",\n                }\n\n            # 获取当前活跃 Todo 列表，用于提示词\n            existing_todos = todo_mgr.get_active_todos_for_prompt(limit=100)\n            existing_todos_json = json.dumps(existing_todos, ensure_ascii=False)\n\n            system_prompt = get_prompt(\"auto_todo_detection\", \"system_assistant\")\n            user_prompt = get_prompt(\n                \"auto_todo_detection\",\n                \"user_prompt\",\n                existing_todos_json=existing_todos_json,\n            )\n\n            # 将 OCR 文本附加在用户提示词后面，并在提示中强调不要重复已有待办\n            user_content = (\n                f\"{user_prompt}\\n\\n\"\n                \"重要规则：\\n\"\n                \"1. 如果候选待办在当前已有待办列表中已经存在（尤其是标题和时间信息相同或非常相似），\"\n                \"请不要重复输出这些待办，仅输出真正新的待办。\\n\"\n                \"2. 可以适当润色标题，但不要把同一条待办拆分成多条含义相同的待办。\\n\\n\"\n                f\"当前应用：{app_name}\\n\"\n                f\"窗口标题：{window_title}\\n\"\n                f\"OCR 文本内容如下，请仅基于这些文本提取新的待办事项：\\n{text_content}\"\n            )\n\n            messages = [\n                {\"role\": \"system\", \"content\": system_prompt},\n                {\"role\": \"user\", \"content\": user_content},\n            ]\n\n            # 频率控制与缓存：尽量减少对同一 text_hash 的重复 LLM 调用\n            now_ts = time.time()\n            cached_entry = self._ocr_text_cache.get(text_hash)\n            todos: list[dict[str, Any]]\n\n            # 如果有有效缓存且未过期，直接复用缓存结果，避免再次调用 LLM\n            if (\n                cached_entry\n                and now_ts - cached_entry.get(\"timestamp\", 0.0) <= self._ocr_text_cache_ttl_sec\n            ):\n                logger.info(\n                    \"基于 OCR 文本待办提取命中缓存，跳过 LLM 调用 \"\n                    f\"(ocr_result_id={ocr_result_id}, text_hash={text_hash})\"\n                )\n                todos = cached_entry.get(\"todos_raw\") or []\n            else:\n                # 如果距离上次真实 LLM 调用的时间间隔过短，则进行限流\n                last_call_ts = self._ocr_text_last_llm_call.get(text_hash)\n                if (\n                    last_call_ts is not None\n                    and now_ts - last_call_ts < self._ocr_text_min_interval_sec\n                ):\n                    logger.info(\n                        \"距离上次基于相同 OCR 文本的 LLM 调用时间过短，跳过本次调用 \"\n                        f\"(ocr_result_id={ocr_result_id}, text_hash={text_hash})\"\n                    )\n                    # 如果存在旧缓存则复用，否则直接跳过（返回空结果）\n                    if cached_entry and cached_entry.get(\"todos_raw\"):\n                        todos = cached_entry.get(\"todos_raw\") or []\n                    else:\n                        return {\n                            \"ocr_result_id\": ocr_result_id,\n                            \"todos\": [],\n                            \"skipped\": True,\n                            \"reason\": \"too_frequent\",\n                            \"created_count\": 0,\n                            \"created_todos\": [],\n                        }\n                else:\n                    logger.info(\"开始基于 OCR 文本调用 LLM 进行待办提取\")\n                    response_text = self.llm_client.chat(\n                        messages=messages,\n                        temperature=0.3,\n                        max_tokens=1500,\n                    )\n                    # 记录本次真实 LLM 调用时间\n                    self._ocr_text_last_llm_call[text_hash] = now_ts\n\n                    # 仅做 JSON 解析，并在本地进行去重（基于标题+时间），避免重复创建相同待办\n                    try:\n                        json_match = re.search(r\"\\{.*\\}\", response_text, re.DOTALL)\n                        if not json_match:\n                            logger.warning(\"基于 OCR 文本的 LLM 响应中未找到 JSON，返回空结果\")\n                            return {\n                                \"ocr_result_id\": ocr_result_id,\n                                \"todos\": [],\n                                \"skipped\": False,\n                                \"error_message\": \"no_json_in_response\",\n                                \"created_count\": 0,\n                                \"created_todos\": [],\n                            }\n\n                        json_str = json_match.group(0)\n                        data = json.loads(json_str)\n                        todos = data.get(\"new_todos\") or data.get(\"todos\") or []\n\n                        if not isinstance(todos, list):\n                            logger.warning(\"LLM 返回的 todos 字段不是列表，返回空结果\")\n                            todos = []\n\n                        # 将本次解析结果写入内存缓存\n                        self._ocr_text_cache[text_hash] = {\n                            \"timestamp\": now_ts,\n                            \"todos_raw\": todos,\n                        }\n                    except Exception as e:\n                        logger.error(\n                            f\"解析基于 OCR 文本的 LLM 响应失败: {e}\\n原始响应: {response_text[:200]}\"\n                        )\n                        return {\n                            \"ocr_result_id\": ocr_result_id,\n                            \"todos\": [],\n                            \"skipped\": False,\n                            \"error_message\": \"parse_error\",\n                            \"created_count\": 0,\n                            \"created_todos\": [],\n                        }\n\n            # 从这里开始，todos 已经就绪（来自缓存或本次 LLM 调用结果）\n            # 后续统一执行本地去重与 draft 待办创建逻辑\n            try:\n                # 构建去重集合：使用数据库中现有的 active/draft 待办，按 (标题, 时间) 去重\n                dedupe_keys: set[tuple[str, str | None]] = set()\n                try:\n                    existing_todos_full = todo_mgr.list_todos(limit=1000, offset=0, status=None)\n                    for t in existing_todos_full:\n                        name = (t.get(\"name\") or \"\").strip()\n                        if not name:\n                            continue\n                        schedule_time = t.get(\"start_time\") or t.get(\"deadline\")\n                        time_key = (\n                            schedule_time.isoformat()\n                            if isinstance(schedule_time, datetime)\n                            else None\n                        )\n                        dedupe_keys.add((name, time_key))\n                except Exception as e:\n                    logger.warning(f\"构建去重集合失败，将跳过本地去重逻辑: {e}\")\n\n                # 基于 LLM 返回的 todos 创建 draft 状态的待办\n                created_todos: list[dict[str, Any]] = []\n                created_count = 0\n\n                for todo_data in todos:\n                    try:\n                        title = (todo_data.get(\"title\") or \"\").strip()\n                        if not title:\n                            logger.warning(\"跳过标题为空的待办（OCR 文本提取）\")\n                            continue\n\n                        description = todo_data.get(\"description\")\n                        if isinstance(description, str):\n                            description = description.strip() or None\n                        else:\n                            description = None\n\n                        time_info = todo_data.get(\"time_info\") or {}\n                        scheduled_time = None\n                        if isinstance(time_info, dict) and time_info:\n                            try:\n                                scheduled_time = calculate_scheduled_time(time_info, get_utc_now())\n                            except Exception as e:\n                                logger.warning(f\"计算 OCR 文本待办 scheduled_time 失败: {e}\")\n\n                        # 使用 (标题 + 时间) 进行本地去重，避免重复创建同一待办\n                        try:\n                            time_key = (\n                                scheduled_time.isoformat()\n                                if isinstance(scheduled_time, datetime)\n                                else None\n                            )\n                            key = (title, time_key)\n                            if key in dedupe_keys:\n                                logger.info(\n                                    \"检测到已存在相同标题与时间的待办，跳过创建：\"\n                                    f\"title={title!r}, scheduled_time={time_key!r}\"\n                                )\n                                continue\n                            # 将当前 key 加入去重集合，避免本批次内重复\n                            dedupe_keys.add(key)\n                        except Exception as e:\n                            logger.warning(f\"本地去重检查失败，仍然尝试创建待办: {e}\")\n\n                        source_text = (todo_data.get(\"source_text\") or \"\").strip()\n                        confidence = todo_data.get(\"confidence\")\n\n                        # 构建 user_notes，记录来源信息\n                        user_notes_parts = [\n                            f\"OCR 结果 ID: {ocr_result_id}\",\n                            f\"应用: {app_name}\",\n                        ]\n                        if window_title:\n                            user_notes_parts.append(f\"窗口: {window_title}\")\n                        if source_text:\n                            user_notes_parts.append(f\"来源文本: {source_text}\")\n                        if isinstance(time_info, dict) and time_info.get(\"raw_text\"):\n                            user_notes_parts.append(f\"时间: {time_info.get('raw_text')}\")\n                        if isinstance(confidence, int | float):\n                            user_notes_parts.append(f\"置信度: {float(confidence):.2%}\")\n\n                        user_notes = \"\\n\".join(user_notes_parts)\n\n                        todo_id = todo_mgr.create_todo(\n                            name=title,\n                            description=description,\n                            user_notes=user_notes,\n                            start_time=scheduled_time,\n                            status=\"draft\",\n                            priority=\"none\",\n                            tags=[\"自动提取\"],\n                        )\n\n                        if todo_id:\n                            created_count += 1\n                            created_todos.append(\n                                {\n                                    \"id\": todo_id,\n                                    \"name\": title,\n                                    \"scheduled_time\": scheduled_time.isoformat()\n                                    if scheduled_time\n                                    else None,\n                                }\n                            )\n                            logger.info(\n                                f\"基于 OCR 文本创建 draft 待办: {todo_id} - {title} (ocr_result_id={ocr_result_id})\"\n                            )\n                        else:\n                            logger.warning(\n                                f\"基于 OCR 文本创建待办失败（create_todo 返回 None）: {title}\"\n                            )\n                    except Exception as e:\n                        logger.error(\n                            f\"处理 OCR 文本待办数据失败: {e}, 数据: {todo_data}\",\n                            exc_info=True,\n                        )\n                        continue\n\n                return {\n                    \"ocr_result_id\": ocr_result_id,\n                    \"todos\": todos,\n                    \"skipped\": False,\n                    \"created_count\": created_count,\n                    \"created_todos\": created_todos,\n                }\n            except Exception as e:\n                logger.error(f\"处理 OCR 文本待办创建逻辑失败: {e}\", exc_info=True)\n                return {\n                    \"ocr_result_id\": ocr_result_id,\n                    \"todos\": [],\n                    \"skipped\": False,\n                    \"error_message\": \"parse_error\",\n                    \"created_count\": 0,\n                    \"created_todos\": [],\n                }\n\n        except Exception as e:\n            logger.error(f\"基于 OCR 文本的待办提取失败: {e}\", exc_info=True)\n            return {\n                \"ocr_result_id\": ocr_result_id,\n                \"todos\": [],\n                \"skipped\": False,\n                \"error_message\": str(e),\n            }\n"
  },
  {
    "path": "lifetrace/llm/rag_fallback.py",
    "content": "\"\"\"\nRAG 回退响应模块\n包含备用响应生成逻辑\n\"\"\"\n\nimport contextlib\nfrom datetime import datetime\nfrom typing import Any\n\nfrom lifetrace.util.logging_config import get_logger\n\nlogger = get_logger()\n\n\ndef summarize_retrieved_data(retrieved_data: list[dict[str, Any]]) -> dict[str, Any]:\n    \"\"\"总结检索到的数据\"\"\"\n    if not retrieved_data:\n        return {\"apps\": {}, \"time_range\": None, \"total\": 0}\n\n    app_counts = {}\n    timestamps = []\n\n    for record in retrieved_data:\n        app_name = record.get(\"app_name\", \"未知应用\")\n        app_counts[app_name] = app_counts.get(app_name, 0) + 1\n\n        timestamp = record.get(\"timestamp\")\n        if timestamp:\n            timestamps.append(timestamp)\n\n    time_range = None\n    if timestamps:\n        timestamps.sort()\n        time_range = {\"earliest\": timestamps[0], \"latest\": timestamps[-1]}\n\n    return {\n        \"apps\": app_counts,\n        \"time_range\": time_range,\n        \"total\": len(retrieved_data),\n    }\n\n\ndef fallback_response(\n    user_query: str,\n    retrieved_data: list[dict[str, Any]],\n    stats: dict[str, Any] | None = None,\n) -> str:\n    \"\"\"备用响应生成（当LLM不可用时）\"\"\"\n    _ = stats\n    if not retrieved_data:\n        return f\"抱歉，没有找到与查询 '{user_query}' 相关的历史记录。\"\n\n    response_parts = [f\"根据您的查询 '{user_query}'，我找到了以下信息：\", \"\"]\n\n    response_parts.append(f\"📊 总共找到 {len(retrieved_data)} 条相关记录\")\n\n    app_summary = summarize_retrieved_data(retrieved_data)\n    if app_summary[\"apps\"]:\n        response_parts.append(\"\\n📱 应用分布：\")\n        for app, count in sorted(app_summary[\"apps\"].items(), key=lambda x: x[1], reverse=True):\n            response_parts.append(f\"  • {app}: {count} 条记录\")\n\n    if app_summary[\"time_range\"]:\n        with contextlib.suppress(ValueError, TypeError):\n            earliest = datetime.fromisoformat(\n                app_summary[\"time_range\"][\"earliest\"].replace(\"Z\", \"+00:00\")\n            )\n            latest = datetime.fromisoformat(\n                app_summary[\"time_range\"][\"latest\"].replace(\"Z\", \"+00:00\")\n            )\n            response_parts.append(\n                f\"\\n⏰ 时间范围: {earliest.strftime('%Y-%m-%d %H:%M')} 至 {latest.strftime('%Y-%m-%d %H:%M')}\"\n            )\n\n    if retrieved_data:\n        response_parts.append(\"\\n📝 最新记录示例：\")\n        latest_record = retrieved_data[0]\n        timestamp = latest_record.get(\"timestamp\", \"未知时间\")\n        app_name = latest_record.get(\"app_name\", \"未知应用\")\n        ocr_text = latest_record.get(\"ocr_text\", \"无内容\")[:100]\n\n        response_parts.append(f\"  时间: {timestamp}\")\n        response_parts.append(f\"  应用: {app_name}\")\n        response_parts.append(f\"  内容: {ocr_text}...\")\n\n    response_parts.append(\"\\n💡 提示：您可以使用更具体的关键词来获得更精确的结果。\")\n\n    return \"\\n\".join(response_parts)\n\n\ndef generate_direct_response(llm_client, user_query: str, intent_result: dict[str, Any]) -> str:\n    \"\"\"为不需要数据库查询的用户输入生成直接回复\"\"\"\n    try:\n        intent_type = intent_result.get(\"intent_type\", \"general_chat\")\n\n        if intent_type == \"system_help\":\n            system_prompt = \"\"\"\n你是LifeTrace的智能助手。LifeTrace是一个生活轨迹记录和分析系统，主要功能包括：\n1. 自动截图记录用户的屏幕活动\n2. OCR文字识别和内容分析\n3. 应用使用情况统计\n4. 智能搜索和查询功能\n\n请根据用户的问题提供有用的帮助信息。\n\"\"\"\n        else:\n            system_prompt = \"\"\"\n你是LifeTrace的智能助手，请以友好、自然的方式与用户对话。\n如果用户需要查询数据或统计信息，请引导他们使用具体的查询语句。\n\"\"\"\n\n        response = llm_client.client.chat.completions.create(\n            model=llm_client.model,\n            messages=[\n                {\"role\": \"system\", \"content\": system_prompt},\n                {\"role\": \"user\", \"content\": user_query},\n            ],\n            temperature=0.7,\n            max_tokens=500,\n        )\n\n        llm_response = response.choices[0].message.content.strip()\n        logger.info(f\"[LLM Direct Response] {llm_response}\")\n        logger.info(f\"LLM直接响应: {llm_response}\")\n\n        return llm_response\n\n    except Exception as e:\n        logger.error(f\"直接响应生成失败: {e}\")\n        return fallback_direct_response(user_query, intent_result)\n\n\ndef fallback_direct_response(user_query: str, intent_result: dict[str, Any]) -> str:\n    \"\"\"当LLM不可用时的直接回复备用方案\"\"\"\n    intent_type = intent_result.get(\"intent_type\", \"general_chat\")\n\n    if intent_type == \"system_help\":\n        return \"\"\"\nLifeTrace是一个生活轨迹记录和分析系统，主要功能包括：\n\n📸 **自动截图记录**\n- 定期捕获屏幕内容\n- 记录应用使用情况\n\n🔍 **智能搜索**\n- 搜索历史截图\n- 基于OCR文字内容查找\n\n📊 **使用统计**\n- 应用使用时长统计\n- 活动模式分析\n\n💬 **智能问答**\n- 自然语言查询\n- 个性化数据分析\n\n如需查询具体数据，请使用如\"搜索包含编程的截图\"或\"统计最近一周的应用使用情况\"等语句。\n\"\"\"\n    elif intent_type == \"general_chat\":\n        greetings = [\n            \"你好！我是LifeTrace的智能助手，很高兴为您服务！\",\n            \"您好！有什么可以帮助您的吗？\",\n            \"欢迎使用LifeTrace！我可以帮您查询和分析您的生活轨迹数据。\",\n        ]\n\n        if any(word in user_query.lower() for word in [\"你好\", \"hello\", \"hi\"]):\n            return greetings[0] + \"\\n\\n您可以询问我关于LifeTrace的功能，或者直接查询您的数据。\"\n        elif any(word in user_query.lower() for word in [\"谢谢\", \"thanks\"]):\n            return \"不客气！如果还有其他问题，随时可以问我。\"\n        else:\n            return greetings[1] + \"\\n\\n您可以尝试搜索截图、查询应用使用情况，或者询问系统功能。\"\n    else:\n        return \"我理解您的问题，但可能需要更多信息才能提供准确的回答。您可以尝试更具体的查询，比如搜索特定内容或统计使用情况。\"\n"
  },
  {
    "path": "lifetrace/llm/rag_service.py",
    "content": "\"\"\"\nRAG (检索增强生成) 服务\n整合查询解析、数据检索、上下文构建和LLM生成\n\"\"\"\n\nimport asyncio\nimport contextlib\nfrom collections.abc import Generator\nfrom datetime import datetime\nfrom typing import Any\n\nfrom lifetrace.llm.context_builder import ContextBuilder\nfrom lifetrace.llm.llm_client import LLMClient\nfrom lifetrace.llm.retrieval_service import RetrievalService\nfrom lifetrace.util.language import get_language_instruction\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.prompt_loader import get_prompt\nfrom lifetrace.util.query_parser import QueryParser\nfrom lifetrace.util.time_utils import get_utc_now\n\nfrom .rag_fallback import (\n    fallback_direct_response,\n    fallback_response,\n    generate_direct_response,\n    summarize_retrieved_data,\n)\nfrom .rag_stream import (\n    RAGStreamContext,\n    get_statistics_if_needed,\n    stream_direct_response,\n    stream_with_retrieval,\n)\n\nlogger = get_logger()\n\n\nclass RAGService:\n    \"\"\"RAG (检索增强生成) 服务\"\"\"\n\n    def __init__(self):\n        \"\"\"初始化RAG服务\"\"\"\n        self.llm_client = LLMClient()\n        self.retrieval_service = RetrievalService()\n        self.context_builder = ContextBuilder()\n        self.query_parser = QueryParser(self.llm_client)\n\n        logger.info(\"RAG服务初始化完成\")\n\n    def _handle_direct_query(\n        self, user_query: str, intent_result: dict, start_time: datetime\n    ) -> dict[str, Any]:\n        \"\"\"处理不需要数据库查询的直接回复\"\"\"\n        logger.info(f\"用户意图不需要数据库查询: {intent_result['intent_type']}\")\n        if self.llm_client.is_available():\n            response_text = generate_direct_response(self.llm_client, user_query, intent_result)\n        else:\n            response_text = fallback_direct_response(user_query, intent_result)\n\n        processing_time = (get_utc_now() - start_time).total_seconds()\n        return {\n            \"success\": True,\n            \"response\": response_text,\n            \"query_info\": {\n                \"original_query\": user_query,\n                \"intent_classification\": intent_result,\n                \"requires_database\": False,\n            },\n            \"performance\": {\n                \"processing_time_seconds\": processing_time,\n                \"timestamp\": start_time.isoformat(),\n            },\n        }\n\n    def _get_statistics_if_needed(\n        self, query_type: str, user_query: str, parsed_query\n    ) -> dict | None:\n        \"\"\"根据查询类型获取统计信息\"\"\"\n        return get_statistics_if_needed(\n            self.retrieval_service, query_type, user_query, parsed_query\n        )\n\n    def _build_context_for_query(\n        self, query_type: str, user_query: str, retrieved_data: list, stats: dict | None\n    ) -> str:\n        \"\"\"根据查询类型构建上下文\"\"\"\n        logger.info(\"开始构建上下文\")\n        if query_type == \"statistics\":\n            return self.context_builder.build_statistics_context(\n                user_query, retrieved_data, stats or {}\n            )\n        if query_type == \"search\":\n            return self.context_builder.build_search_context(user_query, retrieved_data)\n        return self.context_builder.build_summary_context(user_query, retrieved_data)\n\n    async def process_query(self, user_query: str, max_results: int = 50) -> dict[str, Any]:\n        \"\"\"处理用户查询的完整RAG流水线\"\"\"\n        start_time = get_utc_now()\n\n        try:\n            logger.info(f\"开始处理查询: {user_query}\")\n            intent_result = self.llm_client.classify_intent(user_query)\n\n            if not intent_result.get(\"needs_database\", True):\n                return self._handle_direct_query(user_query, intent_result, start_time)\n\n            logger.info(\"需要数据库查询，开始查询解析\")\n            parsed_query = self.query_parser.parse_query(user_query)\n            query_type = \"statistics\" if \"统计\" in user_query else \"search\"\n\n            logger.info(\"开始数据检索\")\n            retrieved_data = self.retrieval_service.search_by_conditions(parsed_query, max_results)\n\n            stats = self._get_statistics_if_needed(query_type, user_query, parsed_query)\n            context_text = self._build_context_for_query(\n                query_type, user_query, retrieved_data, stats\n            )\n\n            logger.info(\"开始LLM生成\")\n            if self.llm_client.is_available():\n                response_text = self.llm_client.generate_summary(user_query, retrieved_data)\n            else:\n                response_text = fallback_response(user_query, retrieved_data, stats)\n\n            processing_time = (get_utc_now() - start_time).total_seconds()\n            logger.info(f\"查询处理完成，耗时 {processing_time:.2f} 秒\")\n\n            return {\n                \"success\": True,\n                \"response\": response_text,\n                \"query_info\": {\n                    \"original_query\": user_query,\n                    \"intent_classification\": intent_result,\n                    \"parsed_query\": parsed_query,\n                    \"query_type\": query_type,\n                    \"requires_database\": True,\n                },\n                \"retrieval_info\": {\n                    \"total_found\": len(retrieved_data),\n                    \"data_summary\": summarize_retrieved_data(retrieved_data),\n                },\n                \"context_info\": {\n                    \"context_length\": len(context_text),\n                    \"llm_available\": self.llm_client.is_available(),\n                },\n                \"performance\": {\n                    \"processing_time_seconds\": processing_time,\n                    \"timestamp\": start_time.isoformat(),\n                },\n                \"statistics\": stats,\n            }\n\n        except Exception as e:\n            logger.error(f\"RAG查询处理失败: {e}\")\n            return {\n                \"success\": False,\n                \"error\": str(e),\n                \"response\": \"抱歉，处理您的查询时出现了错误。请稍后重试。\",\n                \"query_info\": {\"original_query\": user_query},\n                \"performance\": {\n                    \"processing_time_seconds\": (get_utc_now() - start_time).total_seconds(),\n                    \"timestamp\": start_time.isoformat(),\n                },\n            }\n\n    def process_query_sync(self, user_query: str, max_results: int = 50) -> dict[str, Any]:\n        \"\"\"同步版本的查询处理\"\"\"\n        return asyncio.run(self.process_query(user_query, max_results))\n\n    def post_stream_decision(self, user_query: str, output_text: str) -> None:\n        \"\"\"流式输出完成后的判定/记录钩子\"\"\"\n        try:\n            if not output_text:\n                return\n            keywords = [\"免责声明\", \"敏感内容\", \"注意\", \"总结\"]\n            if any(kw in output_text for kw in keywords):\n                logger.info(\n                    f\"[post_stream] 输出包含关键提示，query='{user_query[:50]}...' 触发标记\"\n                )\n            else:\n                logger.debug(\"[post_stream] 无特殊标记\")\n        except Exception as e:\n            logger.debug(f\"[post_stream] 处理异常已忽略: {e}\")\n\n    def stream_query(\n        self,\n        user_query: str,\n        max_results: int = 50,\n        temperature_direct: float = 0.7,\n        temperature_rag: float = 0.3,\n    ) -> Generator[str]:\n        \"\"\"流式处理用户查询\"\"\"\n        try:\n            intent_result = self.llm_client.classify_intent(user_query)\n            needs_db = intent_result.get(\"needs_database\", True)\n\n            if not needs_db:\n                yield from stream_direct_response(\n                    self.llm_client,\n                    user_query,\n                    intent_result,\n                    temperature_direct,\n                    self.post_stream_decision,\n                    fallback_direct_response,\n                )\n                return\n\n            ctx = RAGStreamContext(\n                llm_client=self.llm_client,\n                retrieval_service=self.retrieval_service,\n                context_builder=self.context_builder,\n                query_parser=self.query_parser,\n                post_stream_callback=self.post_stream_decision,\n                fallback_response_func=fallback_response,\n                get_statistics_func=self._get_statistics_if_needed,\n            )\n            yield from stream_with_retrieval(ctx, user_query, max_results, temperature_rag)\n\n        except Exception as e:\n            logger.error(f\"RAG 流式处理失败: {e}\")\n            error_text = \"\\n[提示] 流式处理出现异常，已结束。\"\n            yield error_text\n            with contextlib.suppress(Exception):\n                self.post_stream_decision(user_query, error_text)\n\n    def get_query_suggestions(self, partial_query: str = \"\") -> list[str]:\n        \"\"\"获取查询建议\"\"\"\n        suggestions = [\n            \"总结今天的微信聊天记录\",\n            \"查找包含'会议'的所有记录\",\n            \"统计最近一周各应用的使用情况\",\n            \"搜索昨天浏览器中的内容\",\n            \"总结最近的工作相关截图\",\n            \"查找包含'项目'关键词的记录\",\n            \"统计本月QQ聊天记录数量\",\n            \"搜索最近3天的学习资料\",\n            \"总结上周的网页浏览记录\",\n            \"查找包含'文档'的所有应用记录\",\n        ]\n\n        if partial_query:\n            filtered_suggestions = [\n                s for s in suggestions if any(word in s for word in partial_query.split())\n            ]\n            return filtered_suggestions[:5]\n\n        return suggestions[:5]\n\n    def get_supported_query_types(self) -> dict[str, Any]:\n        \"\"\"获取支持的查询类型信息\"\"\"\n        return {\n            \"query_types\": {\n                \"summary\": {\n                    \"name\": \"总结\",\n                    \"description\": \"对历史记录进行总结和概括\",\n                    \"examples\": [\"总结今天的微信聊天\", \"概括最近的工作记录\"],\n                },\n                \"search\": {\n                    \"name\": \"搜索\",\n                    \"description\": \"搜索包含特定关键词的记录\",\n                    \"examples\": [\"查找包含'会议'的记录\", \"搜索项目相关内容\"],\n                },\n                \"statistics\": {\n                    \"name\": \"统计\",\n                    \"description\": \"统计和分析历史记录数据\",\n                    \"examples\": [\"统计各应用使用情况\", \"分析最近一周的活动\"],\n                },\n            },\n            \"supported_apps\": [\n                \"WeChat\",\n                \"QQ\",\n                \"Browser\",\n                \"Chrome\",\n                \"Firefox\",\n                \"Edge\",\n                \"Word\",\n                \"Excel\",\n                \"PowerPoint\",\n                \"Notepad\",\n                \"VSCode\",\n            ],\n            \"time_expressions\": [\n                \"今天\",\n                \"昨天\",\n                \"最近3天\",\n                \"本周\",\n                \"上周\",\n                \"本月\",\n                \"上月\",\n            ],\n        }\n\n    def health_check(self) -> dict[str, Any]:\n        \"\"\"健康检查\"\"\"\n        return {\n            \"rag_service\": \"healthy\",\n            \"llm_client\": (\"available\" if self.llm_client.is_available() else \"unavailable\"),\n            \"database\": \"connected\",\n            \"components\": {\n                \"retrieval_service\": \"ready\",\n                \"context_builder\": \"ready\",\n                \"query_parser\": \"ready\",\n            },\n            \"timestamp\": get_utc_now().isoformat(),\n        }\n\n    async def process_query_stream(\n        self,\n        user_query: str,\n        session_id: str | None = None,\n        lang: str = \"zh\",\n    ) -> dict[str, Any]:\n        \"\"\"为流式接口处理查询，返回构建好的 messages 和 temperature\"\"\"\n        try:\n            logger.info(f\"[stream] 开始处理查询: {user_query}, session_id: {session_id}\")\n            intent_result = self.llm_client.classify_intent(user_query)\n            needs_db = intent_result.get(\"needs_database\", True)\n\n            # 构建消息\n            if needs_db:\n                parsed_query = self.query_parser.parse_query(user_query)\n                query_type = \"statistics\" if \"统计\" in user_query else \"search\"\n                retrieved_data = self.retrieval_service.search_by_conditions(parsed_query, 500)\n\n                # 构建上下文\n                if query_type == \"statistics\":\n                    stats = self.retrieval_service.get_statistics(parsed_query)\n                    context_text = self.context_builder.build_statistics_context(\n                        user_query, retrieved_data, stats\n                    )\n                else:\n                    context_text = self.context_builder.build_search_context(\n                        user_query, retrieved_data\n                    )\n                logger.debug(f\"构建的上下文内容: {context_text}\")\n\n                # 注入语言指令\n                context_text += get_language_instruction(lang)\n                messages = [{\"role\": \"system\", \"content\": context_text}]\n                temperature = 0.3\n            else:\n                # 不需要数据库查询的直接回复\n                intent_type = intent_result.get(\"intent_type\", \"general_chat\")\n                if intent_type == \"system_help\":\n                    system_prompt = get_prompt(\"rag\", \"system_help\")\n                else:\n                    system_prompt = get_prompt(\"rag\", \"general_chat\")\n                # 注入语言指令\n                system_prompt += get_language_instruction(lang)\n                messages = [{\"role\": \"system\", \"content\": system_prompt}]\n                temperature = 0.7\n\n            # 添加当前用户消息\n            messages.append({\"role\": \"user\", \"content\": user_query})\n\n            return {\n                \"success\": True,\n                \"messages\": messages,\n                \"temperature\": temperature,\n                \"intent_result\": intent_result,\n            }\n\n        except Exception as e:\n            logger.error(f\"[stream] 处理查询失败: {e}\")\n            return {\n                \"success\": False,\n                \"response\": f\"处理查询时出现错误: {e!s}\",\n                \"messages\": [],\n                \"temperature\": 0.7,\n            }\n"
  },
  {
    "path": "lifetrace/llm/rag_stream.py",
    "content": "\"\"\"\nRAG 流式处理模块\n包含流式查询处理逻辑\n\"\"\"\n\nfrom collections.abc import Callable, Generator\nfrom dataclasses import dataclass\nfrom typing import Any\n\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.prompt_loader import get_prompt\nfrom lifetrace.util.query_parser import QueryConditions\n\nlogger = get_logger()\n\n\n@dataclass\nclass RAGStreamContext:\n    \"\"\"RAG 流式处理上下文，封装所有服务依赖\"\"\"\n\n    llm_client: Any\n    retrieval_service: Any\n    context_builder: Any\n    query_parser: Any\n    post_stream_callback: Callable[[str, str], None]\n    fallback_response_func: Callable[..., str]\n    get_statistics_func: Callable[..., dict | None]\n\n\ndef stream_direct_response(\n    llm_client,\n    user_query: str,\n    intent_result: dict,\n    temperature: float,\n    post_stream_callback: Callable[[str, str], None],\n    fallback_response_func: Callable[..., str],\n) -> Generator[str]:\n    \"\"\"流式处理直接对话（不需要数据库）\"\"\"\n    if not llm_client.is_available():\n        fallback_text = fallback_response_func(user_query, intent_result)\n        yield fallback_text\n        post_stream_callback(user_query, fallback_text)\n        return\n\n    intent_type = intent_result.get(\"intent_type\", \"general_chat\")\n    system_prompt = get_prompt(\n        \"rag\", \"system_help\" if intent_type == \"system_help\" else \"general_chat\"\n    )\n    messages = [\n        {\"role\": \"system\", \"content\": system_prompt},\n        {\"role\": \"user\", \"content\": user_query},\n    ]\n\n    output_chunks: list[str] = []\n    for text in llm_client.stream_chat(messages=messages, temperature=temperature):\n        if text:\n            output_chunks.append(text)\n            yield text\n    post_stream_callback(user_query, \"\".join(output_chunks))\n\n\ndef stream_with_retrieval(\n    ctx: RAGStreamContext,\n    user_query: str,\n    max_results: int,\n    temperature: float,\n) -> Generator[str]:\n    \"\"\"流式处理带检索的查询\n\n    Args:\n        ctx: RAG 流式处理上下文\n        user_query: 用户查询\n        max_results: 最大结果数\n        temperature: 温度参数\n    \"\"\"\n    parsed_query = ctx.query_parser.parse_query(user_query)\n    query_type = \"statistics\" if \"统计\" in user_query else \"search\"\n    retrieved_data = ctx.retrieval_service.search_by_conditions(parsed_query, max_results)\n\n    # 获取统计信息\n    stats = None\n    if query_type == \"statistics\" or \"统计\" in user_query:\n        try:\n            stats = ctx.get_statistics_func(query_type, user_query, parsed_query)\n        except Exception:\n            stats = None\n\n    # 构建上下文\n    context_text = _build_context_for_query(\n        ctx.context_builder, query_type, user_query, retrieved_data, stats\n    )\n\n    # LLM 不可用时返回备选\n    if not ctx.llm_client.is_available():\n        fallback_text = ctx.fallback_response_func(user_query, retrieved_data, stats)\n        yield fallback_text\n        ctx.post_stream_callback(user_query, fallback_text)\n        return\n\n    # 流式生成\n    system_prompt = get_prompt(\"rag\", \"history_analysis\")\n    user_prompt = get_prompt(\"rag\", \"user_query_template\", query=user_query, context=context_text)\n    messages = [\n        {\"role\": \"system\", \"content\": system_prompt},\n        {\"role\": \"user\", \"content\": user_prompt},\n    ]\n\n    output_chunks: list[str] = []\n    for text in ctx.llm_client.stream_chat(messages=messages, temperature=temperature):\n        if text:\n            output_chunks.append(text)\n            yield text\n    ctx.post_stream_callback(user_query, \"\".join(output_chunks))\n\n\ndef _build_context_for_query(\n    context_builder, query_type: str, user_query: str, retrieved_data: list, stats: dict | None\n) -> str:\n    \"\"\"根据查询类型构建上下文\"\"\"\n    logger.info(\"开始构建上下文\")\n    if query_type == \"statistics\":\n        return context_builder.build_statistics_context(user_query, retrieved_data, stats)\n    if query_type == \"search\":\n        return context_builder.build_search_context(user_query, retrieved_data)\n    return context_builder.build_summary_context(user_query, retrieved_data)\n\n\ndef get_statistics_if_needed(\n    retrieval_service, query_type: str, user_query: str, parsed_query\n) -> dict | None:\n    \"\"\"根据查询类型获取统计信息\"\"\"\n    if query_type != \"statistics\" and \"统计\" not in user_query:\n        return None\n\n    if isinstance(parsed_query, QueryConditions):\n        conditions = parsed_query\n    else:\n        conditions = QueryConditions(\n            start_date=parsed_query.get(\"start_date\"),\n            end_date=parsed_query.get(\"end_date\"),\n            app_names=parsed_query.get(\"app_names\", []),\n            keywords=parsed_query.get(\"keywords\", []),\n        )\n    return retrieval_service.get_statistics(conditions)\n"
  },
  {
    "path": "lifetrace/llm/retrieval_service.py",
    "content": "from datetime import timedelta\nfrom typing import Any\n\nfrom sqlalchemy import func, or_\n\nfrom lifetrace.storage import get_session\nfrom lifetrace.storage.models import OCRResult, Screenshot\nfrom lifetrace.storage.sql_utils import col\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.query_parser import QueryConditions, QueryParser\nfrom lifetrace.util.time_utils import get_utc_now\n\nlogger = get_logger()\n\n# 常量定义\nMAX_LOG_PREVIEW_RECORDS = 3  # 日志预览最大记录数\nMAX_APP_DISTRIBUTION_DISPLAY = 5  # 应用分布显示最大数量\nTIME_RECENCY_DAY_THRESHOLD = 1  # 时间新近性阈值（天）\nTIME_RECENCY_WEEK_THRESHOLD = 7  # 时间新近性阈值（周）\n\n\nclass RetrievalService:\n    \"\"\"检索服务，用于从数据库中检索相关的截图和OCR数据\"\"\"\n\n    def __init__(self):\n        \"\"\"\n        初始化检索服务\n        \"\"\"\n        self.query_parser = QueryParser()\n        logger.info(\"检索服务初始化完成\")\n\n    def _build_base_query(self, session: Any, conditions: QueryConditions) -> Any:\n        \"\"\"构建基础查询\"\"\"\n        query = session.query(Screenshot).join(\n            OCRResult, col(Screenshot.id) == col(OCRResult.screenshot_id)\n        )\n\n        # 添加时间范围过滤\n        if conditions.start_date:\n            query = query.filter(col(Screenshot.created_at) >= conditions.start_date)\n        if conditions.end_date:\n            query = query.filter(col(Screenshot.created_at) <= conditions.end_date)\n\n        # 添加应用名称过滤\n        if conditions.app_names:\n            app_filters = [\n                col(Screenshot.app_name).ilike(f\"%{app}%\") for app in conditions.app_names\n            ]\n            query = query.filter(or_(*app_filters))\n\n        # 添加关键词过滤\n        if conditions.keywords:\n            keyword_filters = [\n                col(OCRResult.text_content).ilike(f\"%{keyword}%\") for keyword in conditions.keywords\n            ]\n            query = query.filter(or_(*keyword_filters))\n\n        return query.order_by(col(Screenshot.created_at).desc())\n\n    def _convert_screenshot_to_dict(\n        self, session: Any, screenshot: Screenshot, conditions: QueryConditions\n    ) -> dict[str, Any]:\n        \"\"\"将截图转换为字典格式\"\"\"\n        ocr_results = (\n            session.query(OCRResult).filter(col(OCRResult.screenshot_id) == screenshot.id).all()\n        )\n\n        ocr_text = \" \".join([ocr.text_content for ocr in ocr_results if ocr.text_content])\n\n        return {\n            \"screenshot_id\": screenshot.id,\n            \"timestamp\": screenshot.created_at.isoformat() if screenshot.created_at else None,\n            \"app_name\": screenshot.app_name,\n            \"window_title\": screenshot.window_title,\n            \"file_path\": screenshot.file_path,\n            \"ocr_text\": ocr_text,\n            \"ocr_count\": len(ocr_results),\n            \"relevance_score\": self._calculate_relevance(screenshot, ocr_text, conditions),\n        }\n\n    def _log_query_results(self, data_list: list[dict[str, Any]]) -> None:\n        \"\"\"记录查询结果日志\"\"\"\n        logger.info(\"=\" * 60)\n        logger.info(f\"📊 查询结果: 找到 {len(data_list)} 条记录\")\n        logger.info(\"=\" * 60)\n\n        if not data_list:\n            return\n\n        logger.info(\"📝 OCR内容详情 (前3条):\")\n        for i, item in enumerate(data_list[:MAX_LOG_PREVIEW_RECORDS]):\n            ocr_text = item.get(\"ocr_text\", \"\")\n            logger.info(f\"  [{i + 1}] 截图ID: {item['screenshot_id']}\")\n            logger.info(f\"      应用: {item['app_name']}\")\n            logger.info(f\"      时间: {item['timestamp']}\")\n            logger.info(f\"      OCR文本长度: {len(ocr_text)} 字符\")\n            logger.info(f\"      OCR文本预览: {ocr_text[:100] if ocr_text else '❌ 无OCR内容'}\")\n            if not ocr_text:\n                logger.warning(\"      ⚠️  警告: 这条记录没有OCR文本！\")\n\n        # 统计有无OCR内容的记录\n        has_ocr = sum(1 for item in data_list if item.get(\"ocr_text\"))\n        no_ocr = len(data_list) - has_ocr\n        logger.info(\"📈 OCR统计:\")\n        logger.info(f\"   ✅ 有OCR内容: {has_ocr} 条\")\n        logger.info(f\"   ❌ 无OCR内容: {no_ocr} 条\")\n\n        logger.info(\"=\" * 60)\n        logger.info(\"=== 查询完成 ===\")\n        logger.info(\"=\" * 60)\n\n    def search_by_conditions(\n        self, conditions: QueryConditions, limit: int = 50\n    ) -> list[dict[str, Any]]:\n        \"\"\"\n        根据查询条件检索数据\n\n        Args:\n            conditions: 查询条件\n            limit: 返回结果的最大数量\n\n        Returns:\n            检索到的数据列表\n        \"\"\"\n        try:\n            logger.info(f\"执行数据库查询 - 条件: {conditions}, 限制: {limit}\")\n\n            with get_session() as session:\n                query = self._build_base_query(session, conditions)\n\n                # 限制结果数量 - 优先使用QueryConditions中的limit\n                effective_limit = conditions.limit if conditions.limit else limit\n                results = query.limit(effective_limit).all()\n\n                # 转换为字典格式\n                data_list = [\n                    self._convert_screenshot_to_dict(session, screenshot, conditions)\n                    for screenshot in results\n                ]\n\n                # 按时间排序\n                data_list.sort(key=lambda x: x[\"timestamp\"], reverse=True)\n\n                # 记录查询结果\n                self._log_query_results(data_list)\n\n                logger.info(f\"检索完成，找到 {len(data_list)} 条记录\")\n                return data_list\n\n        except Exception as e:\n            logger.error(f\"数据检索失败: {e}\")\n            return []\n\n    def search_by_query(self, user_query: str, limit: int = 50) -> list[dict[str, Any]]:\n        \"\"\"\n        根据用户查询检索数据\n\n        Args:\n            user_query: 用户的自然语言查询\n            limit: 返回结果的最大数量\n\n        Returns:\n            检索到的数据列表\n        \"\"\"\n        # 解析查询\n        conditions = self.query_parser.parse_query(user_query)\n        logger.info(f\"查询解析结果: {conditions}\")\n\n        # 执行检索\n        return self.search_by_conditions(conditions, limit)\n\n    def search_recent(\n        self, hours: int = 24, app_name: str | None = None, limit: int = 20\n    ) -> list[dict[str, Any]]:\n        \"\"\"\n        检索最近的记录\n\n        Args:\n            hours: 最近多少小时的记录\n            app_name: 可选的应用名称过滤\n            limit: 返回结果的最大数量\n\n        Returns:\n            检索到的数据列表\n        \"\"\"\n        end_time = get_utc_now()\n        start_time = end_time - timedelta(hours=hours)\n\n        conditions = QueryConditions(\n            start_date=start_time,\n            end_date=end_time,\n            app_names=[app_name] if app_name else None,\n        )\n\n        return self.search_by_conditions(conditions, limit)\n\n    def search_by_app(self, app_name: str, days: int = 7, limit: int = 50) -> list[dict[str, Any]]:\n        \"\"\"\n        按应用名称检索记录\n\n        Args:\n            app_name: 应用名称\n            days: 检索最近多少天的记录\n            limit: 返回结果的最大数量\n\n        Returns:\n            检索到的数据列表\n        \"\"\"\n        end_time = get_utc_now()\n        start_time = end_time - timedelta(days=days)\n\n        conditions = QueryConditions(\n            start_date=start_time,\n            end_date=end_time,\n            app_names=[app_name] if app_name else None,\n        )\n\n        return self.search_by_conditions(conditions, limit)\n\n    def search_by_keywords(\n        self, keywords: list[str], days: int = 30, limit: int = 50\n    ) -> list[dict[str, Any]]:\n        \"\"\"\n        按关键词检索记录\n\n        Args:\n            keywords: 关键词列表\n            days: 检索最近多少天的记录\n            limit: 返回结果的最大数量\n\n        Returns:\n            检索到的数据列表\n        \"\"\"\n        end_time = get_utc_now()\n        start_time = end_time - timedelta(days=days)\n\n        conditions = QueryConditions(start_date=start_time, end_date=end_time, keywords=keywords)\n\n        return self.search_by_conditions(conditions, limit)\n\n    def _apply_stats_conditions(self, query: Any, conditions: QueryConditions | None) -> Any:\n        \"\"\"应用统计查询条件\"\"\"\n        if not conditions:\n            return query\n\n        if conditions.start_date:\n            query = query.filter(col(Screenshot.created_at) >= conditions.start_date)\n        if conditions.end_date:\n            query = query.filter(col(Screenshot.created_at) <= conditions.end_date)\n        if conditions.app_names:\n            app_filters = [\n                col(Screenshot.app_name).ilike(f\"%{app}%\") for app in conditions.app_names\n            ]\n            query = query.filter(or_(*app_filters))\n\n        return query\n\n    def _build_stats_result(\n        self,\n        total_count: int,\n        app_stats: list[tuple[str, int]],\n        time_range: Any,\n        conditions: QueryConditions | None,\n    ) -> dict[str, Any]:\n        \"\"\"构建统计结果\"\"\"\n        return {\n            \"total_screenshots\": total_count,\n            \"app_distribution\": dict(app_stats),\n            \"time_range\": {\n                \"earliest\": time_range.earliest.isoformat() if time_range.earliest else None,\n                \"latest\": time_range.latest.isoformat() if time_range.latest else None,\n            },\n            \"query_conditions\": {\n                \"start_date\": conditions.start_date.isoformat()\n                if conditions and conditions.start_date\n                else None,\n                \"end_date\": conditions.end_date.isoformat()\n                if conditions and conditions.end_date\n                else None,\n                \"app_names\": conditions.app_names if conditions else None,\n                \"keywords\": conditions.keywords if conditions else [],\n            },\n        }\n\n    def get_statistics(self, conditions: QueryConditions | None = None) -> dict[str, Any]:\n        \"\"\"\n        获取统计信息\n\n        Args:\n            conditions: 可选的查询条件\n\n        Returns:\n            统计信息字典\n        \"\"\"\n        try:\n            logger.info(\"=== 数据库查询 - get_statistics ===\")\n            logger.info(f\"统计查询条件: {conditions}\")\n\n            with get_session() as session:\n                # 基础查询并应用条件\n                query = self._apply_stats_conditions(session.query(Screenshot), conditions)\n                total_count = query.count()\n\n                # 按应用分组统计\n                app_stats_query = session.query(\n                    col(Screenshot.app_name), func.count(col(Screenshot.id)).label(\"count\")\n                ).group_by(col(Screenshot.app_name))\n                app_stats_query = self._apply_stats_conditions(app_stats_query, conditions)\n                app_stats = app_stats_query.all()\n\n                # 时间范围\n                time_range = query.with_entities(\n                    func.min(col(Screenshot.created_at)).label(\"earliest\"),\n                    func.max(col(Screenshot.created_at)).label(\"latest\"),\n                ).first()\n\n                stats = self._build_stats_result(total_count, app_stats, time_range, conditions)\n\n                # 记录统计结果\n                logger.info(f\"统计结果: 总截图数={total_count}\")\n                app_dist = stats[\"app_distribution\"]\n                app_preview = dict(list(app_dist.items())[:MAX_APP_DISTRIBUTION_DISPLAY])\n                logger.info(\n                    f\"  应用分布: {app_preview}{'...' if len(app_dist) > MAX_APP_DISTRIBUTION_DISPLAY else ''}\"\n                )\n                logger.info(\"=== 统计查询完成 ===\")\n\n                return stats\n\n        except Exception as e:\n            logger.error(f\"统计信息获取失败: {e}\")\n            return {\n                \"total_screenshots\": 0,\n                \"app_distribution\": {},\n                \"time_range\": {\"earliest\": None, \"latest\": None},\n                \"query_conditions\": {},\n            }\n\n    def _calculate_relevance(\n        self, screenshot: Screenshot, ocr_text: str, conditions: QueryConditions\n    ) -> float:\n        \"\"\"\n        计算相关性得分\n\n        Args:\n            screenshot: 截图对象\n            ocr_text: OCR文本\n            conditions: 查询条件\n\n        Returns:\n            相关性得分 (0.0 - 1.0)\n        \"\"\"\n        score = 0.0\n\n        # 应用名称匹配加分\n        if (\n            conditions.app_names\n            and screenshot.app_name\n            and any(app.lower() in screenshot.app_name.lower() for app in conditions.app_names)\n        ):\n            score += 0.3\n\n        # 关键词匹配加分\n        if conditions.keywords and ocr_text:\n            text_lower = ocr_text.lower()\n            keyword_matches = 0\n            for keyword in conditions.keywords:\n                if keyword.lower() in text_lower:\n                    keyword_matches += 1\n\n            if keyword_matches > 0:\n                score += 0.5 * (keyword_matches / len(conditions.keywords))\n\n        # 时间新近性加分\n        if screenshot.created_at:\n            now = get_utc_now()\n            time_diff = now - screenshot.created_at\n            if time_diff.days < TIME_RECENCY_DAY_THRESHOLD:\n                score += 0.2\n            elif time_diff.days < TIME_RECENCY_WEEK_THRESHOLD:\n                score += 0.1\n\n        return min(score, 1.0)\n"
  },
  {
    "path": "lifetrace/llm/tavily_client.py",
    "content": "\"\"\"Tavily API 客户端封装模块\"\"\"\n\nfrom typing import Any, cast\n\nfrom tavily import TavilyClient\n\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.settings import settings\n\nlogger = get_logger()\n\n\nclass TavilyClientWrapper:\n    \"\"\"Tavily API 客户端封装类\"\"\"\n\n    _instance = None\n    _initialized = False\n\n    def __new__(cls):\n        \"\"\"实现单例模式\"\"\"\n        if cls._instance is None:\n            cls._instance = super().__new__(cls)\n        return cls._instance\n\n    def __init__(self):\n        \"\"\"初始化 Tavily 客户端\"\"\"\n        if not TavilyClientWrapper._initialized:\n            self._initialize_client()\n            TavilyClientWrapper._initialized = True\n\n    def _initialize_client(self):\n        \"\"\"内部方法：初始化或重新初始化客户端\"\"\"\n        try:\n            self.api_key = settings.tavily.api_key\n            self.search_depth = settings.tavily.search_depth\n            self.max_results = settings.tavily.max_results\n            self.include_domains = settings.tavily.include_domains\n            self.exclude_domains = settings.tavily.exclude_domains\n\n            # 检查 API key 是否配置\n            invalid_values = [\n                \"xxx\",\n                \"YOUR_API_KEY_HERE\",\n                \"YOUR_TAVILY_API_KEY_HERE\",\n            ]\n            if not self.api_key or self.api_key in invalid_values:\n                logger.warning(\"Tavily API Key 未配置或为默认占位符，联网搜索功能不可用\")\n                self.client = None\n                return\n\n            # 初始化 Tavily 客户端\n            self.client = TavilyClient(api_key=self.api_key)\n            logger.info(\"Tavily 客户端初始化成功\")\n        except Exception as e:\n            logger.error(f\"Tavily 客户端初始化失败: {e}\")\n            self.client = None\n\n    def is_available(self) -> bool:\n        \"\"\"检查 Tavily 客户端是否可用\"\"\"\n        return self.client is not None\n\n    def _get_client(self) -> TavilyClient:\n        if self.client is None:\n            raise RuntimeError(\"Tavily 客户端未配置或不可用\")\n        return self.client\n\n    def search(self, query: str, **kwargs) -> dict[str, Any]:\n        \"\"\"\n        执行 Tavily 搜索\n\n        Args:\n            query: 搜索查询字符串\n            **kwargs: 额外的搜索参数\n\n        Returns:\n            包含搜索结果的字典，格式：\n            {\n                \"results\": [\n                    {\n                        \"url\": \"https://...\",\n                        \"title\": \"标题\",\n                        \"content\": \"内容摘要\"\n                    },\n                    ...\n                ],\n                \"raw_response\": {...}  # 原始 Tavily 响应\n            }\n\n        Raises:\n            RuntimeError: 如果客户端未配置或不可用\n            Exception: 如果搜索请求失败\n        \"\"\"\n        if not self.is_available():\n            raise RuntimeError(\"Tavily 客户端未配置或不可用，请在设置中填写 Tavily API Key\")\n\n        try:\n            # 构建搜索参数\n            search_kwargs = {\n                \"query\": query,\n                \"search_depth\": kwargs.get(\"search_depth\", self.search_depth),\n                \"max_results\": kwargs.get(\"max_results\", self.max_results),\n            }\n\n            # 添加域名过滤（如果配置了）\n            if self.include_domains:\n                search_kwargs[\"include_domains\"] = self.include_domains\n            if self.exclude_domains:\n                search_kwargs[\"exclude_domains\"] = self.exclude_domains\n\n            # 合并用户提供的额外参数\n            search_kwargs.update(\n                {k: v for k, v in kwargs.items() if k not in [\"search_depth\", \"max_results\"]}\n            )\n\n            # 调用 Tavily search API\n            client = self._get_client()\n            response = client.search(**search_kwargs)\n            response_data = cast(\"dict[str, Any]\", response)\n\n            # 格式化返回结果\n            results = []\n            if \"results\" in response_data:\n                for item in response_data[\"results\"]:\n                    results.append(\n                        {\n                            \"url\": item.get(\"url\", \"\"),\n                            \"title\": item.get(\"title\", \"\"),\n                            \"content\": item.get(\"content\", \"\"),\n                        }\n                    )\n\n            return {\n                \"results\": results,\n                \"raw_response\": response_data,\n            }\n\n        except Exception as e:\n            logger.error(f\"Tavily 搜索失败: {e}\")\n            raise\n\n    def research(self, query: str, **kwargs) -> dict[str, Any]:\n        \"\"\"\n        执行 Tavily research（深度研究）\n\n        Args:\n            query: 研究查询字符串\n            **kwargs: 额外的研究参数\n\n        Returns:\n            包含研究结果的字典，格式与 search 相同\n\n        Raises:\n            RuntimeError: 如果客户端未配置或不可用\n            Exception: 如果研究请求失败\n        \"\"\"\n        if not self.is_available():\n            raise RuntimeError(\"Tavily 客户端未配置或不可用，请在设置中填写 Tavily API Key\")\n\n        try:\n            # 构建研究参数\n            research_kwargs = {\n                \"query\": query,\n                \"search_depth\": kwargs.get(\"search_depth\", \"advanced\"),\n                \"max_results\": kwargs.get(\"max_results\", self.max_results),\n            }\n\n            # 添加域名过滤（如果配置了）\n            if self.include_domains:\n                research_kwargs[\"include_domains\"] = self.include_domains\n            if self.exclude_domains:\n                research_kwargs[\"exclude_domains\"] = self.exclude_domains\n\n            # 合并用户提供的额外参数\n            research_kwargs.update(\n                {k: v for k, v in kwargs.items() if k not in [\"search_depth\", \"max_results\"]}\n            )\n\n            # 调用 Tavily research API\n            client = self._get_client()\n            response = client.research(**research_kwargs)\n            response_data = cast(\"dict[str, Any]\", response)\n\n            # 格式化返回结果\n            results = []\n            if \"results\" in response_data:\n                for item in response_data[\"results\"]:\n                    results.append(\n                        {\n                            \"url\": item.get(\"url\", \"\"),\n                            \"title\": item.get(\"title\", \"\"),\n                            \"content\": item.get(\"content\", \"\"),\n                        }\n                    )\n\n            return {\n                \"results\": results,\n                \"raw_response\": response_data,\n            }\n\n        except Exception as e:\n            logger.error(f\"Tavily 研究失败: {e}\")\n            raise\n"
  },
  {
    "path": "lifetrace/llm/todo_extraction_service.py",
    "content": "\"\"\"待办提取服务\n从特定应用（微信、飞书等）的事件中提取待办事项\n\"\"\"\n\nimport json\nimport re\nfrom datetime import datetime\nfrom typing import Any\n\nfrom lifetrace.llm.llm_client import LLMClient\nfrom lifetrace.llm.ocr_todo_extractor import OCRTodoExtractor\nfrom lifetrace.storage import event_mgr\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.prompt_loader import get_prompt\nfrom lifetrace.util.time_parser import calculate_scheduled_time\nfrom lifetrace.util.time_utils import get_utc_now\n\nlogger = get_logger()\n\n# 需要特殊处理的应用列表（白名单）\nTODO_EXTRACTION_WHITELIST_APPS = [\"微信\", \"WeChat\", \"飞书\", \"Feishu\", \"Lark\", \"钉钉\", \"DingTalk\"]\n\n# 默认截图采样比例\nDEFAULT_SCREENSHOT_SAMPLE_RATIO = 3\nMIN_SCREENSHOTS = 1\nMAX_SCREENSHOTS = 10\n# 少于这个数量的截图不进行抽样，直接使用全部\nNO_SAMPLE_THRESHOLD = 5\n\n\nclass TodoExtractionService:\n    \"\"\"待办提取服务\"\"\"\n\n    def __init__(self):\n        \"\"\"初始化服务\"\"\"\n        self.llm_client = LLMClient()\n        self._ocr_extractor = OCRTodoExtractor(self.llm_client)\n\n    def is_whitelist_app(self, app_name: str) -> bool:\n        \"\"\"判断是否为白名单应用\n\n        Args:\n            app_name: 应用名称\n\n        Returns:\n            是否为白名单应用\n        \"\"\"\n        if not app_name:\n            return False\n        app_name_lower = app_name.lower()\n        return any(\n            whitelist_app.lower() in app_name_lower\n            for whitelist_app in TODO_EXTRACTION_WHITELIST_APPS\n        )\n\n    def sample_screenshots(\n        self, screenshots: list[dict[str, Any]], sample_ratio: int = DEFAULT_SCREENSHOT_SAMPLE_RATIO\n    ) -> list[dict[str, Any]]:\n        \"\"\"\n        对截图进行采样，选择代表性的截图\n\n        Args:\n            screenshots: 截图列表（已按时间排序）\n            sample_ratio: 采样比例（每N张选1张）\n\n        Returns:\n            采样后的截图列表\n        \"\"\"\n        if not screenshots:\n            return []\n\n        total_count = len(screenshots)\n\n        # 如果截图数量少于阈值，全部使用，不进行抽样\n        if total_count < NO_SAMPLE_THRESHOLD:\n            logger.info(\n                f\"截图数量 {total_count} 少于{NO_SAMPLE_THRESHOLD}张，使用全部截图，不进行抽样\"\n            )\n            return screenshots\n\n        # 计算采样后的数量\n        sampled_count = max(MIN_SCREENSHOTS, min(MAX_SCREENSHOTS, total_count // sample_ratio))\n\n        # 均匀采样\n        if sampled_count >= total_count:\n            return screenshots\n\n        step = total_count / sampled_count\n        sampled = []\n        for i in range(sampled_count):\n            index = int(i * step)\n            if index < total_count:\n                sampled.append(screenshots[index])\n\n        logger.info(f\"从 {total_count} 张截图中采样了 {len(sampled)} 张\")\n        return sampled\n\n    def extract_todos_from_event(\n        self, event_id: int, screenshot_sample_ratio: int | None = None\n    ) -> dict[str, Any]:\n        \"\"\"\n        从事件中提取待办事项\n\n        Args:\n            event_id: 事件ID\n            screenshot_sample_ratio: 截图采样比例，如果不提供则使用默认值\n\n        Returns:\n            包含待办列表和元信息的字典\n        \"\"\"\n        try:\n            # 获取事件信息\n            event_info = event_mgr.get_event_summary(event_id)\n            if not event_info:\n                return {\n                    \"event_id\": event_id,\n                    \"todos\": [],\n                    \"error_message\": \"事件不存在\",\n                }\n\n            app_name = event_info.get(\"app_name\") or \"\"\n            if not self.is_whitelist_app(app_name):\n                return {\n                    \"event_id\": event_id,\n                    \"app_name\": app_name,\n                    \"todos\": [],\n                    \"error_message\": f\"应用 {app_name} 不在待办提取白名单中\",\n                }\n\n            # 获取事件截图\n            screenshots = event_mgr.get_event_screenshots(event_id)\n            if not screenshots:\n                return {\n                    \"event_id\": event_id,\n                    \"app_name\": app_name,\n                    \"todos\": [],\n                    \"error_message\": \"事件中没有可用的截图\",\n                }\n\n            # 采样截图\n            sample_ratio = screenshot_sample_ratio or DEFAULT_SCREENSHOT_SAMPLE_RATIO\n            sampled_screenshots = self.sample_screenshots(screenshots, sample_ratio)\n\n            # 提取截图ID\n            screenshot_ids = [s[\"id\"] for s in sampled_screenshots]\n\n            # 调用多模态模型提取待办\n            todos = self._call_vision_model(\n                screenshot_ids=screenshot_ids,\n                app_name=app_name,\n                window_title=event_info.get(\"window_title\", \"\"),\n                event_start_time=event_info.get(\"start_time\"),\n                event_end_time=event_info.get(\"end_time\"),\n            )\n\n            # 解析时间信息并计算绝对时间\n            reference_time = (\n                event_info.get(\"end_time\") or event_info.get(\"start_time\") or get_utc_now()\n            )\n            parsed_todos = []\n            for todo in todos:\n                parsed_todo = self._parse_todo_time(todo, reference_time)\n                if parsed_todo:\n                    parsed_todo[\"screenshot_ids\"] = screenshot_ids\n                    parsed_todos.append(parsed_todo)\n\n            return {\n                \"event_id\": event_id,\n                \"app_name\": app_name,\n                \"window_title\": event_info.get(\"window_title\"),\n                \"event_start_time\": event_info.get(\"start_time\"),\n                \"event_end_time\": event_info.get(\"end_time\"),\n                \"todos\": parsed_todos,\n                \"screenshot_count\": len(sampled_screenshots),\n            }\n\n        except Exception as e:\n            logger.error(f\"从事件 {event_id} 提取待办失败: {e}\", exc_info=True)\n            return {\n                \"event_id\": event_id,\n                \"todos\": [],\n                \"error_message\": f\"提取待办失败: {e!s}\",\n            }\n\n    def _call_vision_model(\n        self,\n        screenshot_ids: list[int],\n        app_name: str,\n        window_title: str,\n        event_start_time: datetime | None,\n        event_end_time: datetime | None,\n    ) -> list[dict[str, Any]]:\n        \"\"\"\n        调用多模态模型分析截图，提取待办事项\n\n        Args:\n            screenshot_ids: 截图ID列表\n            app_name: 应用名称\n            window_title: 窗口标题\n            event_start_time: 事件开始时间\n            event_end_time: 事件结束时间\n\n        Returns:\n            待办事项列表\n        \"\"\"\n        if not self.llm_client.is_available():\n            logger.warning(\"LLM客户端不可用，无法提取待办\")\n            return []\n\n        try:\n            # 格式化时间\n            start_str = (\n                event_start_time.strftime(\"%Y-%m-%d %H:%M:%S\") if event_start_time else \"未知\"\n            )\n            end_str = event_end_time.strftime(\"%Y-%m-%d %H:%M:%S\") if event_end_time else \"进行中\"\n\n            # 从配置文件加载提示词\n            system_prompt = get_prompt(\"todo_extraction\", \"system_assistant\")\n            user_prompt = get_prompt(\n                \"todo_extraction\",\n                \"user_prompt\",\n                app_name=app_name,\n                window_title=window_title,\n                start_time=start_str,\n                end_time=end_str,\n            )\n\n            # 构建完整的提示词（包含system和user）\n            full_prompt = f\"{system_prompt}\\n\\n{user_prompt}\"\n\n            # 调用视觉模型\n            result = self.llm_client.vision_chat(\n                screenshot_ids=screenshot_ids,\n                prompt=full_prompt,\n                temperature=0.3,  # 使用较低温度以提高准确性\n                max_tokens=2000,\n            )\n\n            response_text = result.get(\"response\", \"\")\n            if not response_text:\n                logger.warning(\"视觉模型返回空响应\")\n                return []\n\n            # 解析LLM响应\n            todos = self._parse_llm_response(response_text)\n            return todos\n\n        except Exception as e:\n            error_msg = str(e)\n            # 检查是否是超时错误\n            is_timeout = \"timeout\" in error_msg.lower() or \"timed out\" in error_msg.lower()\n\n            if is_timeout:\n                logger.error(\n                    f\"调用视觉模型提取待办超时: {error_msg}。\"\n                    f\"处理 {len(screenshot_ids)} 张截图可能需要更长时间，\"\n                    \"建议减少截图数量或检查网络连接\",\n                    exc_info=True,\n                )\n            else:\n                logger.error(f\"调用视觉模型提取待办失败: {error_msg}\", exc_info=True)\n            return []\n\n    def _parse_llm_response(self, response_text: str) -> list[dict[str, Any]]:\n        \"\"\"\n        解析LLM响应为待办事项列表\n\n        Args:\n            response_text: LLM返回的文本\n\n        Returns:\n            待办事项列表\n        \"\"\"\n        try:\n            # 尝试提取JSON\n            json_match = re.search(r\"\\{.*\\}\", response_text, re.DOTALL)\n            if json_match:\n                json_str = json_match.group(0)\n                result = json.loads(json_str)\n\n                if \"todos\" in result and isinstance(result[\"todos\"], list):\n                    todos = []\n                    for todo in result[\"todos\"]:\n                        if \"title\" in todo and \"time_info\" in todo:\n                            todos.append(todo)\n                    return todos\n            else:\n                logger.warning(\"LLM响应中未找到JSON格式\")\n                return []\n\n        except json.JSONDecodeError as e:\n            logger.error(f\"解析LLM响应JSON失败: {e}\\n原始响应: {response_text[:200]}\")\n        except Exception as e:\n            logger.error(f\"解析待办事项失败: {e}\")\n\n        return []\n\n    def _parse_todo_time(\n        self, todo: dict[str, Any], reference_time: datetime\n    ) -> dict[str, Any] | None:\n        \"\"\"\n        解析待办的时间信息，计算绝对时间\n\n        Args:\n            todo: 待办字典，包含time_info\n            reference_time: 参考时间（事件开始或结束时间）\n\n        Returns:\n            解析后的待办字典，包含scheduled_time字段\n        \"\"\"\n        try:\n            time_info = todo.get(\"time_info\")\n            if not time_info:\n                logger.warning(\"待办项缺少time_info字段\")\n                return None\n\n            # 计算绝对时间\n            scheduled_time = calculate_scheduled_time(time_info, reference_time)\n\n            # 构建解析后的待办\n            parsed_todo = todo.copy()\n            parsed_todo[\"scheduled_time\"] = scheduled_time\n\n            return parsed_todo\n\n        except Exception as e:\n            logger.error(f\"解析待办时间失败: {e}\")\n            return None\n\n    # ========= 主动 OCR 文本待办提取 =========\n\n    def extract_todos_from_ocr_text(\n        self,\n        ocr_result_id: int,\n        text_content: str,\n        app_name: str,\n        window_title: str,\n    ) -> dict[str, Any]:\n        \"\"\"基于主动 OCR 的纯文本进行待办提取。\n\n        委托给 OCRTodoExtractor 处理。\n        \"\"\"\n        return self._ocr_extractor.extract_todos(\n            ocr_result_id=ocr_result_id,\n            text_content=text_content,\n            app_name=app_name,\n            window_title=window_title,\n        )\n\n\n# 全局实例\ntodo_extraction_service = TodoExtractionService()\n"
  },
  {
    "path": "lifetrace/llm/tools/__init__.py",
    "content": "\"\"\"工具模块 - Agent 工具调用框架\"\"\"\n\nfrom lifetrace.llm.tools.base import Tool, ToolResult\nfrom lifetrace.llm.tools.registry import ToolRegistry\nfrom lifetrace.llm.tools.web_search_tool import WebSearchTool\n\n# 初始化工具注册表并注册工具\ntool_registry = ToolRegistry()\ntool_registry.register(WebSearchTool())\n\n__all__ = [\"Tool\", \"ToolRegistry\", \"ToolResult\", \"tool_registry\"]\n"
  },
  {
    "path": "lifetrace/llm/tools/base.py",
    "content": "\"\"\"工具基类定义\"\"\"\n\nfrom abc import ABC, abstractmethod\nfrom dataclasses import dataclass\nfrom typing import Any\n\n\n@dataclass\nclass ToolResult:\n    \"\"\"工具执行结果\"\"\"\n\n    success: bool\n    content: str  # 工具返回的内容\n    metadata: dict[str, Any] | None = None  # 额外元数据（如来源链接）\n    error: str | None = None\n\n\nclass Tool(ABC):\n    \"\"\"工具基类\"\"\"\n\n    @property\n    @abstractmethod\n    def name(self) -> str:\n        \"\"\"工具名称\"\"\"\n        pass\n\n    @property\n    @abstractmethod\n    def description(self) -> str:\n        \"\"\"工具描述，用于 LLM 选择工具\"\"\"\n        pass\n\n    @property\n    @abstractmethod\n    def parameters_schema(self) -> dict:\n        \"\"\"工具参数 JSON Schema，用于 LLM 生成参数\"\"\"\n        pass\n\n    @abstractmethod\n    def execute(self, **kwargs) -> ToolResult:\n        \"\"\"执行工具\"\"\"\n        pass\n\n    def is_available(self) -> bool:\n        \"\"\"检查工具是否可用\"\"\"\n        return True\n"
  },
  {
    "path": "lifetrace/llm/tools/registry.py",
    "content": "\"\"\"工具注册表\"\"\"\n\nfrom typing import ClassVar\n\nfrom lifetrace.llm.tools.base import Tool\nfrom lifetrace.util.logging_config import get_logger\n\nlogger = get_logger()\n\n\nclass ToolRegistry:\n    \"\"\"工具注册表（单例）\"\"\"\n\n    _instance: ClassVar[\"ToolRegistry | None\"] = None\n    _tools: ClassVar[dict[str, Tool]] = {}\n\n    def __new__(cls):\n        if cls._instance is None:\n            cls._instance = super().__new__(cls)\n        return cls._instance\n\n    def register(self, tool: Tool):\n        \"\"\"注册工具\"\"\"\n        self._tools[tool.name] = tool\n        logger.info(f\"注册工具: {tool.name}\")\n\n    def get_tool(self, name: str) -> Tool | None:\n        \"\"\"获取工具\"\"\"\n        return self._tools.get(name)\n\n    def get_available_tools(self) -> list[Tool]:\n        \"\"\"获取所有可用工具\"\"\"\n        return [tool for tool in self._tools.values() if tool.is_available()]\n\n    def get_tools_schema(self) -> list[dict]:\n        \"\"\"获取所有工具的 JSON Schema（用于 LLM）\"\"\"\n        return [\n            {\n                \"name\": tool.name,\n                \"description\": tool.description,\n                \"parameters\": tool.parameters_schema,\n            }\n            for tool in self.get_available_tools()\n        ]\n"
  },
  {
    "path": "lifetrace/llm/tools/web_search_tool.py",
    "content": "\"\"\"联网搜索工具实现\"\"\"\n\nfrom lifetrace.llm.tavily_client import TavilyClientWrapper\nfrom lifetrace.llm.tools.base import Tool, ToolResult\nfrom lifetrace.util.logging_config import get_logger\n\nlogger = get_logger()\n\n\nclass WebSearchTool(Tool):\n    \"\"\"联网搜索工具\"\"\"\n\n    def __init__(self):\n        \"\"\"初始化联网搜索工具\"\"\"\n        self.tavily_client = TavilyClientWrapper()\n\n    @property\n    def name(self) -> str:\n        return \"web_search\"\n\n    @property\n    def description(self) -> str:\n        return (\n            \"使用联网搜索工具查找最新的网络信息。\"\n            \"适用于需要实时信息、最新资讯、技术文档、新闻等场景。\"\n            \"当用户询问当前事件、最新技术、实时数据时应该使用此工具。\"\n        )\n\n    @property\n    def parameters_schema(self) -> dict:\n        return {\n            \"type\": \"object\",\n            \"properties\": {\n                \"query\": {\n                    \"type\": \"string\",\n                    \"description\": \"搜索查询字符串\",\n                },\n            },\n            \"required\": [\"query\"],\n        }\n\n    def execute(self, **kwargs) -> ToolResult:\n        \"\"\"执行搜索\"\"\"\n        try:\n            query = kwargs.get(\"query\")\n            if not isinstance(query, str) or not query.strip():\n                return ToolResult(\n                    success=False,\n                    content=\"\",\n                    error=\"缺少有效的搜索查询参数\",\n                )\n\n            if not self.tavily_client.is_available():\n                return ToolResult(\n                    success=False,\n                    content=\"\",\n                    error=\"Tavily API 未配置，无法使用联网搜索\",\n                )\n\n            # 执行 Tavily 搜索\n            logger.info(f\"[WebSearchTool] 执行搜索: {query}\")\n            result = self.tavily_client.search(query)\n            results = result.get(\"results\", [])\n\n            if not results:\n                return ToolResult(\n                    success=True,\n                    content=\"未找到相关搜索结果。\",\n                    metadata={\"results\": []},\n                )\n\n            # 格式化搜索结果\n            formatted_results = []\n            sources = []\n            for idx, item in enumerate(results, start=1):\n                title = item.get(\"title\", \"无标题\")\n                url = item.get(\"url\", \"\")\n                content = item.get(\"content\", \"\")\n                formatted_results.append(\n                    f\"[{idx}] {title}\\nURL: {url}\\n摘要: {content}\",\n                )\n                sources.append({\"title\": title, \"url\": url})\n\n            content = \"\\n\\n\".join(formatted_results)\n\n            logger.info(\n                f\"[WebSearchTool] 搜索完成，找到 {len(results)} 个结果\",\n            )\n\n            return ToolResult(\n                success=True,\n                content=content,\n                metadata={\"results\": results, \"sources\": sources},\n            )\n        except Exception as e:\n            logger.error(f\"[WebSearchTool] 执行失败: {e}\", exc_info=True)\n            return ToolResult(\n                success=False,\n                content=\"\",\n                error=str(e),\n            )\n\n    def is_available(self) -> bool:\n        return self.tavily_client.is_available()\n"
  },
  {
    "path": "lifetrace/llm/vector_db.py",
    "content": "\"\"\"向量数据库模块\n\n提供文本嵌入、向量存储、语义检索和重排序功能。\n支持与现有 SQLite 数据库并行使用。\n\"\"\"\n\nimport hashlib\nfrom typing import Any, cast\n\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.path_utils import get_vector_db_dir\nfrom lifetrace.util.settings import settings\nfrom lifetrace.util.time_utils import get_utc_now\n\nlogger = get_logger()\n\ntry:\n    import chromadb\n    import numpy as np\n    from chromadb.config import Settings\n    from sentence_transformers import CrossEncoder, SentenceTransformer\nexcept ImportError as e:\n    logger.warning(f\"Vector database dependencies not installed: {e}\")\n    logger.warning(\"Please install with: pip install -r requirements_vector.txt\")\n    SentenceTransformer = None\n    CrossEncoder = None\n    chromadb = None\n    np = None\n    Settings = None\n\n\nclass VectorDatabase:\n    \"\"\"向量数据库管理器\n\n    提供文本嵌入、向量存储和语义检索功能。\n    使用 ChromaDB 作为向量数据库后端。\n    \"\"\"\n\n    def __init__(self):\n        \"\"\"初始化向量数据库\"\"\"\n        self.logger = logger\n\n        # 检查依赖\n        if not self._check_dependencies():\n            raise ImportError(\"Vector database dependencies not available\")\n\n        # 初始化模型和数据库\n        self.embedding_model = None\n        self.cross_encoder = None\n        self.chroma_client = None\n        self.collection = None\n\n        # 配置参数\n        self.vector_db_path = get_vector_db_dir()\n        self.embedding_model_name = settings.get(\"vector_db.embedding_model\")\n        self.cross_encoder_model_name = settings.get(\"vector_db.rerank_model\")\n        self.collection_name = settings.vector_db.collection_name\n\n        # 初始化\n        self._initialize()\n\n    def _check_dependencies(self) -> bool:\n        \"\"\"检查依赖是否可用\"\"\"\n        return all(\n            [\n                SentenceTransformer is not None,\n                CrossEncoder is not None,\n                chromadb is not None,\n                np is not None,\n                Settings is not None,\n            ]\n        )\n\n    def _initialize(self):\n        \"\"\"初始化模型和数据库\"\"\"\n        try:\n            # 创建数据目录\n            self.vector_db_path.mkdir(parents=True, exist_ok=True)\n\n            # 初始化嵌入模型\n            if self.embedding_model_name:\n                if SentenceTransformer is None:\n                    raise RuntimeError(\"SentenceTransformer not available\")\n                self.logger.info(f\"Loading embedding model: {self.embedding_model_name}\")\n                self.embedding_model = SentenceTransformer(self.embedding_model_name)\n            else:\n                self.logger.info(\"Skipping embedding model initialization (multimodal mode)\")\n                self.embedding_model = None\n\n            # 初始化 ChromaDB\n            self.logger.info(f\"Initializing ChromaDB at: {self.vector_db_path}\")\n            if chromadb is None:\n                raise RuntimeError(\"ChromaDB dependency not available\")\n            if Settings is None:\n                raise RuntimeError(\"ChromaDB Settings not available\")\n            self.chroma_client = chromadb.PersistentClient(\n                path=str(self.vector_db_path),\n                settings=Settings(anonymized_telemetry=False, allow_reset=True),\n            )\n\n            # 获取或创建集合\n            self.collection = self.chroma_client.get_or_create_collection(\n                name=self.collection_name,\n                metadata={\"description\": \"LifeTrace OCR text embeddings\"},\n            )\n\n            self.logger.info(\"Vector database initialized successfully\")\n\n        except Exception as e:\n            self.logger.error(f\"Failed to initialize vector database: {e}\")\n            raise\n\n    def _get_cross_encoder(self) -> Any:\n        \"\"\"延迟加载交叉编码器\"\"\"\n        if self.cross_encoder is None:\n            self.logger.info(f\"Loading cross-encoder model: {self.cross_encoder_model_name}\")\n            if CrossEncoder is None:\n                raise RuntimeError(\"CrossEncoder not available\")\n            self.cross_encoder = CrossEncoder(self.cross_encoder_model_name)\n        return self.cross_encoder\n\n    def embed_text(self, text: str) -> list[float]:\n        \"\"\"将文本转换为向量嵌入\n\n        Args:\n            text: 输入文本\n\n        Returns:\n            文本的向量嵌入\n        \"\"\"\n        if not text or not text.strip():\n            return []\n\n        if not self.embedding_model:\n            raise RuntimeError(\"Embedding model not available (multimodal mode)\")\n\n        try:\n            embedding_model = self.embedding_model\n            embedding = embedding_model.encode(text.strip(), normalize_embeddings=True)\n            return embedding.tolist()\n        except Exception as e:\n            self.logger.error(f\"Failed to embed text: {e}\")\n            return []\n\n    def add_document(self, doc_id: str, text: str, metadata: dict[str, Any] | None = None) -> bool:\n        \"\"\"添加文档到向量数据库\n\n        Args:\n            doc_id: 文档唯一标识符\n            text: 文档文本内容\n            metadata: 文档元数据\n\n        Returns:\n            是否添加成功\n        \"\"\"\n        if not text or not text.strip():\n            self.logger.warning(f\"Empty text for document {doc_id}\")\n            return False\n\n        try:\n            if self.collection is None:\n                raise RuntimeError(\"Vector collection not initialized\")\n            collection = self.collection\n            # 生成嵌入\n            embedding = self.embed_text(text)\n            if not embedding:\n                return False\n\n            # 准备元数据\n            doc_metadata = {\n                \"timestamp\": get_utc_now().isoformat(),\n                \"text_length\": len(text),\n                \"text_hash\": hashlib.md5(text.encode(), usedforsecurity=False).hexdigest(),\n            }\n            if metadata:\n                doc_metadata.update(metadata)\n\n            # 过滤掉 None 值（ChromaDB 不接受 None）\n            doc_metadata = {k: v for k, v in doc_metadata.items() if v is not None}\n\n            # 添加到集合\n            collection.add(\n                documents=[text],\n                embeddings=[embedding],\n                metadatas=[doc_metadata],\n                ids=[doc_id],\n            )\n\n            self.logger.debug(f\"Added document {doc_id} to vector database\")\n            return True\n\n        except Exception as e:\n            self.logger.error(f\"Failed to add document {doc_id}: {e}\")\n            return False\n\n    def add_document_with_embedding(\n        self,\n        doc_id: str,\n        text: str,\n        embedding: list[float],\n        metadata: dict[str, Any] | None = None,\n    ) -> bool:\n        \"\"\"使用预计算的嵌入向量添加文档到向量数据库\n\n        Args:\n            doc_id: 文档唯一标识符\n            text: 文档文本内容\n            embedding: 预计算的嵌入向量\n            metadata: 文档元数据\n\n        Returns:\n            是否添加成功\n        \"\"\"\n        if not text or not text.strip():\n            self.logger.warning(f\"Empty text for document {doc_id}\")\n            return False\n\n        if not embedding:\n            self.logger.warning(f\"Empty embedding for document {doc_id}\")\n            return False\n\n        try:\n            if self.collection is None:\n                raise RuntimeError(\"Vector collection not initialized\")\n            collection = self.collection\n            # 准备元数据\n            doc_metadata = {\n                \"timestamp\": get_utc_now().isoformat(),\n                \"text_length\": len(text),\n                \"text_hash\": hashlib.md5(text.encode(), usedforsecurity=False).hexdigest(),\n            }\n            if metadata:\n                doc_metadata.update(metadata)\n\n            # 过滤掉 None 值（ChromaDB 不接受 None）\n            doc_metadata = {k: v for k, v in doc_metadata.items() if v is not None}\n\n            # 添加到集合\n            collection.add(\n                documents=[text],\n                embeddings=[embedding],\n                metadatas=[doc_metadata],\n                ids=[doc_id],\n            )\n\n            self.logger.debug(f\"Added document {doc_id} with pre-computed embedding\")\n            return True\n\n        except Exception as e:\n            self.logger.error(f\"Failed to add document {doc_id} with embedding: {e}\")\n            return False\n\n    def update_document(\n        self, doc_id: str, text: str, metadata: dict[str, Any] | None = None\n    ) -> bool:\n        \"\"\"更新文档\n\n        Args:\n            doc_id: 文档唯一标识符\n            text: 新的文档文本内容\n            metadata: 新的文档元数据\n\n        Returns:\n            是否更新成功\n        \"\"\"\n        try:\n            # 先删除旧文档\n            self.delete_document(doc_id)\n            # 添加新文档\n            return self.add_document(doc_id, text, metadata)\n        except Exception as e:\n            self.logger.error(f\"Failed to update document {doc_id}: {e}\")\n            return False\n\n    def delete_document(self, doc_id: str) -> bool:\n        \"\"\"删除文档\n\n        Args:\n            doc_id: 文档唯一标识符\n\n        Returns:\n            是否删除成功\n        \"\"\"\n        try:\n            if self.collection is None:\n                raise RuntimeError(\"Vector collection not initialized\")\n            self.collection.delete(ids=[doc_id])\n            self.logger.debug(f\"Deleted document {doc_id} from vector database\")\n            return True\n        except Exception as e:\n            self.logger.error(f\"Failed to delete document {doc_id}: {e}\")\n            return False\n\n    def search(\n        self, query: str, top_k: int = 10, where: dict[str, Any] | None = None\n    ) -> list[dict[str, Any]]:\n        \"\"\"语义搜索\n\n        Args:\n            query: 查询文本\n            top_k: 返回结果数量\n            where: 元数据过滤条件\n\n        Returns:\n            搜索结果列表，每个结果包含 id, document, metadata, distance\n        \"\"\"\n        if not query or not query.strip():\n            return []\n\n        try:\n            if self.collection is None:\n                raise RuntimeError(\"Vector collection not initialized\")\n            # 生成查询嵌入\n            query_embedding = self.embed_text(query)\n            if not query_embedding:\n                return []\n\n            # 清理和验证 where 条件\n            cleaned_where = self._clean_where_clause(where)\n\n            # 执行搜索\n            results = self.collection.query(\n                query_embeddings=[query_embedding], n_results=top_k, where=cleaned_where\n            )\n            results_dict = cast(\"dict[str, Any]\", results)\n            ids = results_dict.get(\"ids\") or [[]]\n            documents = results_dict.get(\"documents\") or [[]]\n            metadatas = results_dict.get(\"metadatas\") or [[]]\n            distances = results_dict.get(\"distances\") or []\n\n            # 格式化结果\n            formatted_results = []\n            for i in range(len(ids[0])):\n                formatted_results.append(\n                    {\n                        \"id\": ids[0][i],\n                        \"document\": documents[0][i],\n                        \"metadata\": (metadatas[0][i] if metadatas[0] else {}),\n                        \"distance\": (distances[0][i] if distances else None),\n                    }\n                )\n\n            self.logger.debug(f\"Found {len(formatted_results)} results for query: {query[:50]}...\")\n            return formatted_results\n\n        except Exception as e:\n            self.logger.error(f\"Failed to search: {e}\")\n            return []\n\n    def _clean_where_clause(self, where: dict[str, Any] | None) -> dict[str, Any] | None:\n        \"\"\"清理和验证 where 条件，移除空对象和无效操作符\n\n        Args:\n            where: 原始的 where 条件\n\n        Returns:\n            清理后的 where 条件，如果没有有效条件则返回 None\n        \"\"\"\n        if not where:\n            return None\n\n        cleaned = {}\n        for key, value in where.items():\n            # 跳过空对象或无效值\n            if value is None or (isinstance(value, dict) and not value):\n                continue\n\n            # 如果是字典，递归清理\n            if isinstance(value, dict):\n                cleaned_value = self._clean_where_clause(value)\n                if cleaned_value:\n                    cleaned[key] = cleaned_value\n            else:\n                cleaned[key] = value\n\n        return cleaned if cleaned else None\n\n    def rerank(\n        self, query: str, documents: list[str], top_k: int | None = None\n    ) -> list[tuple[str, float]]:\n        \"\"\"使用交叉编码器重排序文档\n\n        Args:\n            query: 查询文本\n            documents: 文档列表\n            top_k: 返回的文档数量，None 表示返回全部\n\n        Returns:\n            重排序后的文档列表，每个元素为 (document, score)\n        \"\"\"\n        if not query or not documents:\n            return []\n\n        try:\n            cross_encoder = self._get_cross_encoder()\n\n            # 构建查询-文档对\n            pairs = [(query, doc) for doc in documents]\n\n            # 计算相关性分数\n            scores = cross_encoder.predict(pairs)\n\n            # 排序\n            scored_docs = list(zip(documents, scores, strict=False))\n            scored_docs.sort(key=lambda x: x[1], reverse=True)\n\n            # 返回指定数量\n            if top_k is not None:\n                scored_docs = scored_docs[:top_k]\n\n            self.logger.debug(f\"Reranked {len(documents)} documents, returning {len(scored_docs)}\")\n            return scored_docs\n\n        except Exception as e:\n            self.logger.error(f\"Failed to rerank documents: {e}\")\n            return [(doc, 0.0) for doc in documents]\n\n    def search_and_rerank(\n        self,\n        query: str,\n        retrieve_k: int = 20,\n        rerank_k: int = 5,\n        where: dict[str, Any] | None = None,\n    ) -> list[dict[str, Any]]:\n        \"\"\"搜索并重排序\n\n        Args:\n            query: 查询文本\n            retrieve_k: 初始检索数量\n            rerank_k: 重排序后返回数量\n            where: 元数据过滤条件\n\n        Returns:\n            重排序后的搜索结果\n        \"\"\"\n        # 初始检索\n        search_results = self.search(query, retrieve_k, where)\n        if not search_results:\n            return []\n\n        # 提取文档文本\n        documents = [result[\"document\"] for result in search_results]\n\n        # 重排序\n        reranked_docs = self.rerank(query, documents, rerank_k)\n\n        # 构建最终结果\n        final_results = []\n        for doc, score in reranked_docs:\n            # 找到对应的原始结果\n            for result in search_results:\n                if result[\"document\"] == doc:\n                    result[\"rerank_score\"] = float(score)\n                    final_results.append(result)\n                    break\n\n        return final_results\n\n    def get_collection_stats(self) -> dict[str, Any]:\n        \"\"\"获取集合统计信息\n\n        Returns:\n            集合统计信息\n        \"\"\"\n        try:\n            if self.collection is None:\n                raise RuntimeError(\"Vector collection not initialized\")\n            count = self.collection.count()\n            return {\n                \"collection_name\": self.collection_name,\n                \"document_count\": count,\n                \"embedding_model\": self.embedding_model_name,\n                \"cross_encoder_model\": self.cross_encoder_model_name,\n                \"vector_db_path\": str(self.vector_db_path),\n            }\n        except Exception as e:\n            self.logger.error(f\"Failed to get collection stats: {e}\")\n            return {}\n\n    def reset_collection(self) -> bool:\n        \"\"\"重置集合（删除所有数据）\n\n        Returns:\n            是否重置成功\n        \"\"\"\n        try:\n            if self.chroma_client is None:\n                raise RuntimeError(\"Chroma client not initialized\")\n            self.chroma_client.delete_collection(self.collection_name)\n            self.collection = self.chroma_client.create_collection(\n                name=self.collection_name,\n                metadata={\"description\": \"LifeTrace OCR text embeddings\"},\n            )\n            self.logger.info(f\"Reset collection {self.collection_name}\")\n            return True\n        except Exception as e:\n            self.logger.error(f\"Failed to reset collection: {e}\")\n            return False\n\n\ndef create_vector_db() -> VectorDatabase | None:\n    \"\"\"创建向量数据库实例\n\n    Returns:\n        向量数据库实例，如果依赖不可用则返回 None\n    \"\"\"\n    # 检查依赖\n    if not all([SentenceTransformer, CrossEncoder, chromadb, np, Settings]):\n        logger.warning(\"Vector database dependencies not available\")\n        return None\n\n    # 检查是否启用向量数据库\n    if not settings.vector_db.enabled:\n        logger.info(\"Vector database is disabled in configuration\")\n        return None\n\n    try:\n        return VectorDatabase()\n    except ImportError:\n        logger.warning(\"Vector database not available, skipping initialization\")\n        return None\n    except Exception as e:\n        logger.error(f\"Failed to create vector database: {e}\")\n        return None\n"
  },
  {
    "path": "lifetrace/llm/vector_service.py",
    "content": "\"\"\"向量数据库服务模块\n\n提供 OCR 结果的向量化存储和语义搜索服务。\n与现有的 SQLite 数据库并行工作。\n\"\"\"\n\nfrom typing import Any\n\nfrom lifetrace.llm.vector_db import create_vector_db\nfrom lifetrace.storage import event_mgr, get_session\nfrom lifetrace.storage.models import Event, OCRResult, Screenshot\nfrom lifetrace.storage.sql_utils import col\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.time_utils import get_utc_now\n\nlogger = get_logger()\n\n\nclass VectorService:\n    \"\"\"向量数据库服务\n\n    负责将 OCR 结果存储到向量数据库，并提供语义搜索功能。\n    \"\"\"\n\n    def __init__(self):\n        \"\"\"初始化向量服务\"\"\"\n        self.logger = logger\n\n        # 初始化向量数据库\n        self.vector_db = create_vector_db()\n        if self.vector_db is None:\n            self.logger.warning(\"Vector database not available\")\n            self.enabled = False\n        else:\n            self.enabled = True\n            self.logger.info(\"Vector service initialized successfully\")\n\n    def is_enabled(self) -> bool:\n        \"\"\"检查向量服务是否可用\"\"\"\n        return self.enabled and self.vector_db is not None\n\n    def _require_vector_db(self):\n        if self.vector_db is None:\n            raise RuntimeError(\"Vector database not initialized\")\n        return self.vector_db\n\n    def add_ocr_result(self, ocr_result: OCRResult, screenshot: Screenshot | None = None) -> bool:\n        \"\"\"添加 OCR 结果到向量数据库\n\n        Args:\n            ocr_result: OCR 结果对象\n            screenshot: 关联的截图对象（可选）\n\n        Returns:\n            是否添加成功\n        \"\"\"\n        if not self.is_enabled():\n            return False\n\n        if not ocr_result.text_content or not ocr_result.text_content.strip():\n            self.logger.debug(f\"Skipping empty OCR result {ocr_result.id}\")\n            return False\n\n        try:\n            vector_db = self._require_vector_db()\n            # 构建文档 ID\n            doc_id = f\"ocr_{ocr_result.id}\"\n\n            # 构建元数据\n            metadata = {\n                \"ocr_result_id\": ocr_result.id,\n                \"screenshot_id\": ocr_result.screenshot_id,\n                \"confidence\": ocr_result.confidence,\n                \"language\": ocr_result.language or \"unknown\",\n                \"processing_time\": ocr_result.processing_time,\n                \"created_at\": (\n                    ocr_result.created_at.isoformat() if ocr_result.created_at else None\n                ),\n                \"text_length\": len(ocr_result.text_content),\n            }\n\n            # 添加截图与事件相关信息\n            if screenshot:\n                metadata.update(\n                    {\n                        \"screenshot_path\": screenshot.file_path,\n                        \"screenshot_timestamp\": (\n                            screenshot.created_at.isoformat() if screenshot.created_at else None\n                        ),\n                        \"application\": screenshot.app_name,\n                        \"window_title\": screenshot.window_title,\n                        \"width\": screenshot.width,\n                        \"height\": screenshot.height,\n                        \"event_id\": getattr(screenshot, \"event_id\", None),\n                    }\n                )\n\n            # 添加到向量数据库\n            success = vector_db.add_document(\n                doc_id=doc_id, text=ocr_result.text_content, metadata=metadata\n            )\n\n            if success:\n                self.logger.debug(f\"Added OCR result {ocr_result.id} to vector database\")\n            else:\n                self.logger.warning(f\"Failed to add OCR result {ocr_result.id} to vector database\")\n\n            return success\n\n        except Exception as e:\n            self.logger.error(f\"Error adding OCR result {ocr_result.id} to vector database: {e}\")\n            return False\n\n    def update_ocr_result(\n        self, ocr_result: OCRResult, screenshot: Screenshot | None = None\n    ) -> bool:\n        \"\"\"更新向量数据库中的 OCR 结果\n\n        Args:\n            ocr_result: OCR 结果对象\n            screenshot: 关联的截图对象（可选）\n\n        Returns:\n            是否更新成功\n        \"\"\"\n        if not self.is_enabled():\n            return False\n\n        try:\n            vector_db = self._require_vector_db()\n            doc_id = f\"ocr_{ocr_result.id}\"\n\n            # 构建元数据\n            metadata = {\n                \"ocr_result_id\": ocr_result.id,\n                \"screenshot_id\": ocr_result.screenshot_id,\n                \"confidence\": ocr_result.confidence,\n                \"language\": ocr_result.language or \"unknown\",\n                \"processing_time\": ocr_result.processing_time,\n                \"created_at\": (\n                    ocr_result.created_at.isoformat() if ocr_result.created_at else None\n                ),\n                \"updated_at\": get_utc_now().isoformat(),\n                \"text_length\": len(ocr_result.text_content or \"\"),\n            }\n\n            if screenshot:\n                metadata.update(\n                    {\n                        \"screenshot_path\": screenshot.file_path,\n                        \"screenshot_timestamp\": (\n                            screenshot.created_at.isoformat() if screenshot.created_at else None\n                        ),\n                        \"application\": screenshot.app_name,\n                        \"window_title\": screenshot.window_title,\n                        \"width\": screenshot.width,\n                        \"height\": screenshot.height,\n                    }\n                )\n\n            success = vector_db.update_document(\n                doc_id=doc_id, text=ocr_result.text_content or \"\", metadata=metadata\n            )\n\n            if success:\n                self.logger.debug(f\"Updated OCR result {ocr_result.id} in vector database\")\n\n            return success\n\n        except Exception as e:\n            self.logger.error(f\"Error updating OCR result {ocr_result.id} in vector database: {e}\")\n            return False\n\n    def delete_ocr_result(self, ocr_result_id: int) -> bool:\n        \"\"\"从向量数据库中删除 OCR 结果\n\n        Args:\n            ocr_result_id: OCR 结果 ID\n\n        Returns:\n            是否删除成功\n        \"\"\"\n        if not self.is_enabled():\n            return False\n\n        try:\n            vector_db = self._require_vector_db()\n            doc_id = f\"ocr_{ocr_result_id}\"\n            success = vector_db.delete_document(doc_id)\n\n            if success:\n                self.logger.debug(f\"Deleted OCR result {ocr_result_id} from vector database\")\n\n            return success\n\n        except Exception as e:\n            self.logger.error(\n                f\"Error deleting OCR result {ocr_result_id} from vector database: {e}\"\n            )\n            return False\n\n    def _compute_score(self, result: dict[str, Any]) -> float:\n        \"\"\"计算统一的相似度分数\"\"\"\n        if \"rerank_score\" in result:\n            return result[\"rerank_score\"]\n        if \"distance\" in result:\n            return max(0, 1 - result[\"distance\"])\n        return 0.0\n\n    def _fetch_db_records(\n        self, ocr_result_id: int | None, screenshot_id: int | None\n    ) -> dict[str, Any]:\n        \"\"\"获取数据库中的 OCR 和截图记录\"\"\"\n        result: dict[str, Any] = {}\n        if not ocr_result_id:\n            return result\n\n        with get_session() as session:\n            ocr_result = session.query(OCRResult).filter(col(OCRResult.id) == ocr_result_id).first()\n            if ocr_result:\n                result[\"ocr_result\"] = {\n                    \"id\": ocr_result.id,\n                    \"text_content\": ocr_result.text_content,\n                    \"confidence\": ocr_result.confidence,\n                    \"language\": ocr_result.language,\n                    \"processing_time\": ocr_result.processing_time,\n                    \"created_at\": (\n                        ocr_result.created_at.isoformat() if ocr_result.created_at else None\n                    ),\n                }\n\n            if screenshot_id:\n                screenshot = (\n                    session.query(Screenshot).filter(col(Screenshot.id) == screenshot_id).first()\n                )\n                if screenshot:\n                    result[\"screenshot\"] = {\n                        \"id\": screenshot.id,\n                        \"file_path\": screenshot.file_path,\n                        \"app_name\": screenshot.app_name,\n                        \"window_title\": screenshot.window_title,\n                        \"width\": screenshot.width,\n                        \"height\": screenshot.height,\n                        \"created_at\": (\n                            screenshot.created_at.isoformat() if screenshot.created_at else None\n                        ),\n                    }\n        return result\n\n    def _enhance_result(self, result: dict[str, Any]) -> dict[str, Any]:\n        \"\"\"增强单个搜索结果\"\"\"\n        enhanced = result.copy()\n        enhanced[\"score\"] = self._compute_score(result)\n\n        metadata = result.get(\"metadata\", {})\n        try:\n            db_records = self._fetch_db_records(\n                metadata.get(\"ocr_result_id\"), metadata.get(\"screenshot_id\")\n            )\n            enhanced.update(db_records)\n        except Exception as db_error:\n            self.logger.warning(f\"无法获取相关数据库记录: {db_error}\")\n\n        return enhanced\n\n    def semantic_search(\n        self,\n        query: str,\n        top_k: int = 10,\n        use_rerank: bool = True,\n        retrieve_k: int | None = None,\n        filters: dict[str, Any] | None = None,\n    ) -> list[dict[str, Any]]:\n        \"\"\"语义搜索 OCR 结果\n\n        Args:\n            query: 搜索查询\n            top_k: 返回结果数量\n            use_rerank: 是否使用重排序\n            retrieve_k: 初始检索数量（用于重排序）\n            filters: 元数据过滤条件\n\n        Returns:\n            搜索结果列表\n        \"\"\"\n        if not self.is_enabled() or not query or not query.strip():\n            return []\n\n        try:\n            vector_db = self._require_vector_db()\n            if use_rerank:\n                if retrieve_k is None:\n                    retrieve_k = min(top_k * 3, 50)\n                results = vector_db.search_and_rerank(\n                    query=query, retrieve_k=retrieve_k, rerank_k=top_k, where=filters\n                )\n            else:\n                results = vector_db.search(query=query, top_k=top_k, where=filters)\n\n            return [self._enhance_result(r) for r in results]\n\n        except Exception as e:\n            self.logger.error(f\"语义搜索失败: {e}\")\n            return []\n\n    # 事件级索引与搜索\n    def upsert_event_document(self, event_id: int) -> bool:\n        \"\"\"将事件聚合文本写入向量库，文档ID: event_{event_id}\"\"\"\n        if not self.is_enabled():\n            return False\n        try:\n            vector_db = self._require_vector_db()\n            # 聚合事件文本\n            event_text = event_mgr.get_event_text(event_id) or \"\"\n            if not event_text or not event_text.strip():\n                self.logger.debug(f\"事件{event_id}无文本，跳过索引\")\n                return False\n\n            # 元数据（基本信息）\n            # 为了简化，这里不再重复查事件信息，向上层调用者可扩展\n            doc_id = f\"event_{event_id}\"\n            return vector_db.update_document(doc_id, event_text, {\"event_id\": event_id})\n        except Exception as e:\n            self.logger.error(f\"事件{event_id}写入向量库失败: {e}\")\n            return False\n\n    def _aggregate_event_scores(self, results: list[dict[str, Any]]) -> dict[int, dict[str, float]]:\n        \"\"\"按 event_id 聚合结果，保留最高分数\"\"\"\n        event_scores: dict[int, dict[str, float]] = {}\n        for result in results:\n            event_id = result.get(\"metadata\", {}).get(\"event_id\")\n            if not event_id:\n                continue\n\n            semantic_score = self._compute_score(result)\n            if event_id not in event_scores or semantic_score > event_scores[event_id][\"score\"]:\n                event_scores[event_id] = {\n                    \"score\": semantic_score,\n                    \"distance\": result.get(\"distance\", 1.0),\n                }\n        return event_scores\n\n    def _fetch_event_details(\n        self, event_id: int, score_info: dict[str, float]\n    ) -> dict[str, Any] | None:\n        \"\"\"获取事件详细信息\"\"\"\n        with get_session() as session:\n            event = session.query(Event).filter(col(Event.id) == event_id).first()\n            if not event:\n                return None\n\n            screenshot_count = (\n                session.query(Screenshot).filter(col(Screenshot.event_id) == event_id).count()\n            )\n            first_screenshot = (\n                session.query(Screenshot)\n                .filter(col(Screenshot.event_id) == event_id)\n                .order_by(col(Screenshot.created_at).asc())\n                .first()\n            )\n\n            return {\n                \"id\": event.id,\n                \"app_name\": event.app_name,\n                \"window_title\": event.window_title,\n                \"start_time\": event.start_time.isoformat() if event.start_time else None,\n                \"end_time\": event.end_time.isoformat() if event.end_time else None,\n                \"screenshot_count\": screenshot_count,\n                \"first_screenshot_id\": first_screenshot.id if first_screenshot else None,\n                \"semantic_score\": score_info[\"score\"],\n                \"distance\": score_info[\"distance\"],\n            }\n\n    def semantic_search_events(self, query: str, top_k: int = 10) -> list[dict[str, Any]]:\n        \"\"\"对事件文档进行语义搜索（基于 event_{id} 文档）\"\"\"\n        if not self.is_enabled():\n            return []\n\n        try:\n            vector_db = self._require_vector_db()\n            search_limit = max(top_k * 3, 50)\n            all_results = vector_db.search(query=query, top_k=search_limit)\n            if not all_results:\n                return []\n\n            event_scores = self._aggregate_event_scores(all_results)\n\n            event_results = []\n            for event_id, score_info in event_scores.items():\n                try:\n                    event_data = self._fetch_event_details(event_id, score_info)\n                    if event_data:\n                        event_results.append(event_data)\n                except Exception as db_error:\n                    self.logger.warning(f\"获取事件{event_id}详细信息失败: {db_error}\")\n\n            event_results.sort(key=lambda x: x.get(\"semantic_score\", 0.0), reverse=True)\n            return event_results[:top_k]\n\n        except Exception as e:\n            self.logger.error(f\"事件语义搜索失败: {e}\")\n            return []\n\n    def _should_reset_vector_db(\n        self, total_ocr_count: int, vector_doc_count: int, force_reset: bool\n    ) -> bool:\n        \"\"\"判断是否需要重置向量数据库\"\"\"\n        if force_reset:\n            return True\n        # SQLite 为空但向量数据库不为空\n        return total_ocr_count == 0 and vector_doc_count > 0\n\n    def _sync_ocr_results(self, session, ocr_results: list) -> int:\n        \"\"\"同步 OCR 结果到向量数据库\"\"\"\n        synced_count = 0\n        for ocr_result in ocr_results:\n            screenshot = (\n                session.query(Screenshot)\n                .filter(col(Screenshot.id) == ocr_result.screenshot_id)\n                .first()\n            )\n            if screenshot is None:\n                self.logger.warning(f\"Screenshot not found for OCR result {ocr_result.id}\")\n                continue\n\n            if self.add_ocr_result(ocr_result, screenshot):\n                synced_count += 1\n                if synced_count % 100 == 0:\n                    self.logger.info(f\"Synced {synced_count} OCR results to vector database\")\n\n        return synced_count\n\n    def sync_from_database(self, limit: int | None = None, force_reset: bool = False) -> int:\n        \"\"\"从 SQLite 数据库同步 OCR 结果到向量数据库\n\n        Args:\n            limit: 同步的最大记录数，None 表示同步全部\n            force_reset: 是否先重置向量数据库\n\n        Returns:\n            同步的记录数\n        \"\"\"\n        if not self.is_enabled():\n            return 0\n\n        try:\n            with get_session() as session:\n                total_ocr_count = session.query(OCRResult).count()\n                vector_db = self._require_vector_db()\n                vector_doc_count = vector_db.get_collection_stats().get(\"document_count\", 0)\n                self.logger.info(\n                    f\"SQLite: {total_ocr_count} OCR results, Vector: {vector_doc_count} documents\"\n                )\n\n                if self._should_reset_vector_db(total_ocr_count, vector_doc_count, force_reset):\n                    self.logger.info(\"Resetting vector database\")\n                    self.reset()\n                    if total_ocr_count == 0:\n                        return 0\n\n                if total_ocr_count == 0:\n                    self.logger.info(\"Both databases are empty, no sync needed\")\n                    return 0\n\n                query = session.query(OCRResult).join(\n                    Screenshot, col(OCRResult.screenshot_id) == col(Screenshot.id)\n                )\n                if limit:\n                    query = query.limit(limit)\n                ocr_results = query.all()\n\n                if not limit and len(ocr_results) != vector_doc_count:\n                    self.logger.info(\"Document count mismatch, resetting vector database\")\n                    self.reset()\n\n                synced_count = self._sync_ocr_results(session, ocr_results)\n                self.logger.info(\n                    f\"Completed sync: {synced_count} OCR results added to vector database\"\n                )\n                return synced_count\n\n        except Exception as e:\n            self.logger.error(f\"Error syncing from database: {e}\")\n            return 0\n\n    def get_stats(self) -> dict[str, Any]:\n        \"\"\"获取向量数据库统计信息\n\n        Returns:\n            统计信息字典\n        \"\"\"\n        if not self.is_enabled():\n            return {\"enabled\": False, \"reason\": \"Vector database not available\"}\n\n        try:\n            vector_db = self._require_vector_db()\n            stats = vector_db.get_collection_stats()\n            stats[\"enabled\"] = True\n            return stats\n        except Exception as e:\n            self.logger.error(f\"Error getting vector database stats: {e}\")\n            return {\"enabled\": True, \"error\": str(e)}\n\n    def reset(self) -> bool:\n        \"\"\"重置向量数据库\n\n        Returns:\n            是否重置成功\n        \"\"\"\n        if not self.is_enabled():\n            return False\n\n        try:\n            vector_db = self._require_vector_db()\n            success = vector_db.reset_collection()\n            if success:\n                self.logger.info(\"Vector database reset successfully\")\n            return success\n        except Exception as e:\n            self.logger.error(f\"Error resetting vector database: {e}\")\n            return False\n\n\ndef create_vector_service() -> VectorService:\n    \"\"\"创建向量服务实例\n\n    Returns:\n        向量服务实例\n    \"\"\"\n    return VectorService()\n"
  },
  {
    "path": "lifetrace/llm/web_search_service.py",
    "content": "\"\"\"联网搜索服务模块 - 整合 Tavily 和 LLM\"\"\"\n\nfrom collections.abc import Generator\n\nfrom lifetrace.llm.llm_client import LLMClient\nfrom lifetrace.llm.tavily_client import TavilyClientWrapper\nfrom lifetrace.util.language import get_language_instruction\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.prompt_loader import get_prompt\n\nlogger = get_logger()\n\n\nclass WebSearchService:\n    \"\"\"联网搜索服务，整合 Tavily 搜索结果和 LLM 生成\"\"\"\n\n    def __init__(self):\n        \"\"\"初始化联网搜索服务\"\"\"\n        self.tavily_client = TavilyClientWrapper()\n        self.llm_client = LLMClient()\n        logger.info(\"联网搜索服务初始化完成\")\n\n    def build_search_prompt(\n        self, query: str, tavily_result: dict, todo_context: str | None = None, lang: str = \"zh\"\n    ) -> list[dict[str, str]]:\n        \"\"\"\n        构建用于 LLM 的搜索提示词\n\n        Args:\n            query: 用户查询\n            tavily_result: Tavily 搜索结果\n            todo_context: 待办事项上下文（可选）\n            lang: 语言代码 (\"zh\" 或 \"en\")\n\n        Returns:\n            LLM messages 列表\n        \"\"\"\n        # 获取 system prompt\n        system_prompt = get_prompt(\"web_search\", \"system\")\n        # 注入语言指令\n        system_prompt += get_language_instruction(lang)\n\n        # 格式化搜索结果\n        results = tavily_result.get(\"results\", [])\n        if not results:\n            sources_context = \"未找到相关搜索结果。\"\n        else:\n            sources_list = []\n            for idx, item in enumerate(results, start=1):\n                url = item.get(\"url\", \"\")\n                title = item.get(\"title\", \"无标题\")\n                content = item.get(\"content\", \"\")\n                sources_list.append(f\"[{idx}] {title}\\nURL: {url}\\n摘要: {content}\")\n\n            sources_context = \"\\n\\n\".join(sources_list)\n\n        # 构建用户提示词，包含待办上下文（如果提供）\n        user_prompt_parts = []\n        if todo_context:\n            user_prompt_parts.append(\"用户当前的待办事项上下文：\")\n            user_prompt_parts.append(todo_context)\n            user_prompt_parts.append(\"\")\n\n        # 获取 user prompt 模板并格式化\n        base_user_prompt = get_prompt(\n            \"web_search\", \"user_template\", query=query, sources_context=sources_context\n        )\n        user_prompt_parts.append(base_user_prompt)\n\n        user_prompt = \"\\n\".join(user_prompt_parts)\n\n        return [\n            {\"role\": \"system\", \"content\": system_prompt},\n            {\"role\": \"user\", \"content\": user_prompt},\n        ]\n\n    def _parse_message_with_context(self, message: str) -> tuple[str, str | None]:\n        \"\"\"\n        解析包含待办上下文的消息，提取用户查询和上下文\n\n        Args:\n            message: 完整的消息（可能包含待办上下文）\n\n        Returns:\n            (用户查询, 待办上下文) 元组\n        \"\"\"\n        # 尝试匹配 \"用户输入:\" 或 \"User input:\" 标记\n        # 支持中英文标签\n        markers = [\"用户输入:\", \"User input:\"]\n        todo_context = None\n        actual_query = message\n        expected_parts = 2\n\n        for marker in markers:\n            if marker in message:\n                parts = message.split(marker, 1)\n                if len(parts) == expected_parts:\n                    # 提取待办上下文（标记前的部分）\n                    context_part = parts[0].strip()\n                    # 提取用户查询（标记后的部分）\n                    actual_query = parts[1].strip()\n\n                    # 如果上下文部分不为空，则作为待办上下文\n                    if context_part:\n                        todo_context = context_part\n                    break\n\n        return actual_query, todo_context\n\n    def stream_answer_with_sources(self, query: str, lang: str = \"zh\") -> Generator[str]:\n        \"\"\"\n        流式生成带来源的回答\n\n        Args:\n            query: 用户查询（可能包含待办上下文）\n            lang: 语言代码 (\"zh\" 或 \"en\")\n\n        Yields:\n            文本块（逐 token）\n        \"\"\"\n        try:\n            # 解析消息，提取实际查询和待办上下文\n            actual_query, todo_context = self._parse_message_with_context(query)\n\n            # 检查 Tavily 是否可用\n            if not self.tavily_client.is_available():\n                error_msg = \"当前未配置联网搜索服务，请在设置中填写 Tavily API Key。\"\n                yield error_msg\n                return\n\n            # 执行 Tavily 搜索（使用实际查询）\n            logger.info(f\"开始执行 Tavily 搜索: {actual_query}\")\n            if todo_context:\n                logger.info(\"检测到待办上下文，将在生成回答时使用\")\n            tavily_result = self.tavily_client.search(actual_query)\n            logger.info(f\"Tavily 搜索完成，找到 {len(tavily_result.get('results', []))} 个结果\")\n\n            # 检查 LLM 是否可用\n            if not self.llm_client.is_available():\n                # LLM 不可用时，返回格式化后的搜索结果\n                fallback_text = self._format_fallback_response(actual_query, tavily_result)\n                yield fallback_text\n                return\n\n            # 构建 prompt（包含待办上下文和语言）\n            messages = self.build_search_prompt(actual_query, tavily_result, todo_context, lang)\n\n            # 流式调用 LLM\n            logger.info(\"开始流式生成回答\")\n            output_chunks: list[str] = []\n            for text in self.llm_client.stream_chat(messages=messages, temperature=0.7):\n                if text:\n                    output_chunks.append(text)\n                    yield text\n\n            logger.info(\"流式生成完成\")\n\n        except RuntimeError as e:\n            # Tavily 配置错误\n            error_msg = str(e)\n            logger.error(f\"联网搜索失败: {error_msg}\")\n            yield error_msg\n        except Exception as e:\n            # 其他错误\n            logger.error(f\"联网搜索处理失败: {e}\", exc_info=True)\n            yield f\"联网搜索处理时出现错误: {e!s}\"\n\n    def _format_fallback_response(self, query: str, tavily_result: dict) -> str:\n        \"\"\"\n        当 LLM 不可用时的备用响应格式\n\n        Args:\n            query: 用户查询\n            tavily_result: Tavily 搜索结果\n\n        Returns:\n            格式化的响应文本\n        \"\"\"\n        results = tavily_result.get(\"results\", [])\n        if not results:\n            return f\"抱歉，未找到与 '{query}' 相关的搜索结果。\"\n\n        response_parts = [\n            f\"根据您的查询 '{query}'，我找到了以下信息：\",\n            \"\",\n        ]\n\n        # 列出搜索结果\n        for idx, item in enumerate(results, start=1):\n            title = item.get(\"title\", \"无标题\")\n            url = item.get(\"url\", \"\")\n            content = item.get(\"content\", \"\")\n            response_parts.append(f\"{idx}. {title}\")\n            response_parts.append(f\"   URL: {url}\")\n            if content:\n                response_parts.append(f\"   摘要: {content[:200]}...\")\n            response_parts.append(\"\")\n\n        response_parts.append(\"\\nSources:\")\n        for idx, item in enumerate(results, start=1):\n            title = item.get(\"title\", \"无标题\")\n            url = item.get(\"url\", \"\")\n            response_parts.append(f\"{idx}. {title} ({url})\")\n\n        return \"\\n\".join(response_parts)\n"
  },
  {
    "path": "lifetrace/migrations/MIGRATIONS.md",
    "content": "Alembic 数据库迁移目录\n\n此目录包含 Alembic 数据库迁移脚本。\n\n## 常用命令\n\n### 生成新的迁移脚本\n```bash\ncd lifetrace\nalembic revision --autogenerate -m \"描述迁移内容\"\n```\n\n### 应用所有迁移\n```bash\nalembic upgrade head\n```\n\n### 回滚迁移\n```bash\nalembic downgrade -1  # 回滚一个版本\nalembic downgrade base  # 回滚到初始状态\n```\n\n### 查看当前版本\n```bash\nalembic current\n```\n\n### 查看迁移历史\n```bash\nalembic history\n```\n\n### 标记当前数据库为已迁移（不实际执行迁移）\n```bash\nalembic stamp head\n```\n"
  },
  {
    "path": "lifetrace/migrations/README",
    "content": "Alembic 数据库迁移目录\n\n此目录包含 Alembic 数据库迁移脚本。\n\n常用命令请见: [MIGRATIONS.md](MIGRATIONS.md)\n"
  },
  {
    "path": "lifetrace/migrations/env.py",
    "content": "\"\"\"Alembic 迁移环境配置\n\n配置 Alembic 使用 SQLModel 进行数据库迁移。\n\"\"\"\n\nimport sys\nfrom logging.config import fileConfig\nfrom pathlib import Path\n\nfrom alembic import context\nfrom sqlalchemy import engine_from_config, pool\nfrom sqlmodel import SQLModel\n\n# 添加项目根目录到 Python 路径\nproject_root = Path(__file__).parent.parent.parent\nsys.path.insert(0, str(project_root))\n\n# 导入所有模型以确保 metadata 包含所有表\nfrom lifetrace.storage.models import (  # noqa: F401, E402\n    Activity,\n    ActivityEventRelation,\n    Attachment,\n    Chat,\n    Event,\n    Journal,\n    JournalTagRelation,\n    Message,\n    OCRResult,\n    Screenshot,\n    Tag,\n    Todo,\n    TodoAttachmentRelation,\n    TodoTagRelation,\n    TokenUsage,\n)\nfrom lifetrace.util.path_utils import get_database_path  # noqa: E402\n\n# Alembic Config 对象\nconfig = context.config\n\n# 设置 Python 日志\nif config.config_file_name is not None:\n    fileConfig(config.config_file_name)\n\n# 使用 SQLModel 的 metadata\ntarget_metadata = SQLModel.metadata\n\n\ndef get_url():\n    \"\"\"获取数据库 URL\"\"\"\n    return f\"sqlite:///{get_database_path()}\"\n\n\ndef run_migrations_offline() -> None:\n    \"\"\"在离线模式下运行迁移。\n\n    这将配置上下文仅使用 URL，而不是 Engine，\n    虽然这里也可以使用 Engine。通过跳过 Engine 创建，\n    我们甚至不需要 DBAPI 可用。\n\n    在这里调用 context.execute() 将会将给定的字符串\n    发送到脚本输出。\n    \"\"\"\n    url = get_url()\n    context.configure(\n        url=url,\n        target_metadata=target_metadata,\n        literal_binds=True,\n        dialect_opts={\"paramstyle\": \"named\"},\n        render_as_batch=True,  # SQLite 需要批处理模式\n    )\n\n    with context.begin_transaction():\n        context.run_migrations()\n\n\ndef run_migrations_online() -> None:\n    \"\"\"在在线模式下运行迁移。\n\n    在这种情况下，我们需要创建一个 Engine 并将连接与上下文关联。\n    \"\"\"\n    configuration = config.get_section(config.config_ini_section) or {}\n    configuration[\"sqlalchemy.url\"] = get_url()\n\n    connectable = engine_from_config(\n        configuration,\n        prefix=\"sqlalchemy.\",\n        poolclass=pool.NullPool,\n    )\n\n    with connectable.connect() as connection:\n        context.configure(\n            connection=connection,\n            target_metadata=target_metadata,\n            render_as_batch=True,  # SQLite 需要批处理模式来支持 ALTER TABLE\n        )\n\n        with context.begin_transaction():\n            context.run_migrations()\n\n\nif context.is_offline_mode():\n    run_migrations_offline()\nelse:\n    run_migrations_online()\n"
  },
  {
    "path": "lifetrace/migrations/re_extract_all_transcriptions.py",
    "content": "#!/usr/bin/env python3\n\"\"\"批量重新提取转录记录的待办和日程\n\n查找转录记录，检查每条记录是否需要重新提取：\n- 有 original_text 但 extracted_todos 或 extracted_schedules 为空或 \"[]\"\n- 有 optimized_text 但 extracted_todos_optimized 或 extracted_schedules_optimized 为空或 \"[]\"\n为需要提取的记录重新提取待办和日程\n\n支持命令行参数：\n  --days N          只处理最近 N 天的记录\n  --start-date DATE 指定开始日期 (YYYY-MM-DD)\n  --end-date DATE   指定结束日期 (YYYY-MM-DD)\n  --ids ID1,ID2,... 指定特定的 transcription_id 列表\n\"\"\"\n\nimport argparse\nimport asyncio\nimport json\nimport sys\nfrom datetime import UTC, datetime, timedelta\nfrom pathlib import Path\nfrom typing import Any\n\n# 添加项目根目录到路径（必须在导入之前）\nproject_root = Path(__file__).parent.parent.parent\nsys.path.insert(0, str(project_root))\n\n# 延迟导入以避免 E402 错误\nif True:\n    from sqlmodel import select\n\n    from lifetrace.llm.llm_client import LLMClient\n    from lifetrace.services.audio_extraction_service import AudioExtractionService\n    from lifetrace.storage import get_session\n    from lifetrace.storage.models import Transcription\n    from lifetrace.storage.sql_utils import col\n    from lifetrace.util.logging_config import get_logger\n\nlogger = get_logger()\n\n\ndef is_empty_extraction(extracted: str | None) -> bool:\n    \"\"\"检查提取结果是否为空\"\"\"\n    if not extracted:\n        return True\n    extracted = extracted.strip()\n    if not extracted or extracted in {\"[]\", \"null\"}:\n        return True\n    try:\n        parsed = json.loads(extracted)\n        if isinstance(parsed, list) and len(parsed) == 0:\n            return True\n    except (json.JSONDecodeError, TypeError):\n        pass\n    return False\n\n\ndef needs_extraction(transcription: Transcription) -> tuple[bool, bool]:\n    \"\"\"检查转录记录是否需要提取\n\n    Returns:\n        (needs_original_extraction, needs_optimized_extraction)\n    \"\"\"\n    needs_original = False\n    needs_optimized = False\n\n    # 检查原文提取\n    if (\n        transcription.original_text\n        and transcription.original_text.strip()\n        and (\n            is_empty_extraction(transcription.extracted_todos)\n            or is_empty_extraction(transcription.extracted_schedules)\n        )\n    ):\n        needs_original = True\n\n    # 检查优化文本提取\n    if (\n        transcription.optimized_text\n        and transcription.optimized_text.strip()\n        and (\n            is_empty_extraction(transcription.extracted_todos_optimized)\n            or is_empty_extraction(transcription.extracted_schedules_optimized)\n        )\n    ):\n        needs_optimized = True\n\n    return needs_original, needs_optimized\n\n\nasync def re_extract_transcription(\n    transcription: Transcription,\n    extraction_service: AudioExtractionService,\n    needs_original: bool,\n    needs_optimized: bool,\n) -> dict[str, Any]:\n    \"\"\"重新提取单个转录记录\n\n    Returns:\n        包含提取结果的字典\n    \"\"\"\n    results = {\n        \"transcription_id\": transcription.id,\n        \"recording_id\": transcription.audio_recording_id,\n        \"original_extracted\": False,\n        \"optimized_extracted\": False,\n        \"errors\": [],\n    }\n\n    # 提取原文\n    if needs_original:\n        try:\n            if transcription.id is None:\n                raise ValueError(\"Transcription must have an id before updating.\")\n            logger.info(\n                f\"提取原文: transcription_id={transcription.id}, \"\n                f\"recording_id={transcription.audio_recording_id}, \"\n                f\"text_length={len(transcription.original_text or '')}\"\n            )\n            result = await extraction_service.extract_todos_and_schedules(\n                transcription.original_text or \"\"\n            )\n            extraction_service.update_extraction(\n                transcription_id=transcription.id,\n                todos=result.get(\"todos\", []),\n                schedules=result.get(\"schedules\", []),\n                optimized=False,\n            )\n            results[\"original_extracted\"] = True\n            logger.info(\n                f\"✓ 原文提取完成: transcription_id={transcription.id}, \"\n                f\"todos={len(result.get('todos', []))}, \"\n                f\"schedules={len(result.get('schedules', []))}\"\n            )\n        except Exception as e:\n            error_msg = f\"原文提取失败: {e}\"\n            logger.error(f\"✗ {error_msg}\")\n            results[\"errors\"].append(error_msg)\n\n    # 提取优化文本\n    if needs_optimized:\n        try:\n            if transcription.id is None:\n                raise ValueError(\"Transcription must have an id before updating.\")\n            logger.info(\n                f\"提取优化文本: transcription_id={transcription.id}, \"\n                f\"recording_id={transcription.audio_recording_id}, \"\n                f\"text_length={len(transcription.optimized_text or '')}\"\n            )\n            result = await extraction_service.extract_todos_and_schedules(\n                transcription.optimized_text or \"\"\n            )\n            extraction_service.update_extraction(\n                transcription_id=transcription.id,\n                todos=result.get(\"todos\", []),\n                schedules=result.get(\"schedules\", []),\n                optimized=True,\n            )\n            results[\"optimized_extracted\"] = True\n            logger.info(\n                f\"✓ 优化文本提取完成: transcription_id={transcription.id}, \"\n                f\"todos={len(result.get('todos', []))}, \"\n                f\"schedules={len(result.get('schedules', []))}\"\n            )\n        except Exception as e:\n            error_msg = f\"优化文本提取失败: {e}\"\n            logger.error(f\"✗ {error_msg}\")\n            results[\"errors\"].append(error_msg)\n\n    return results\n\n\ndef parse_date(date_str: str) -> datetime:\n    \"\"\"解析日期字符串 (YYYY-MM-DD)\"\"\"\n    try:\n        # 转换为 UTC 时间（假设输入是本地时间）\n        return datetime.strptime(date_str, \"%Y-%m-%d\").replace(tzinfo=UTC)\n    except ValueError as e:\n        raise argparse.ArgumentTypeError(f\"无效的日期格式: {date_str} (应为 YYYY-MM-DD)\") from e\n\n\ndef parse_ids(ids_str: str) -> list[int]:\n    \"\"\"解析 ID 列表字符串\"\"\"\n    try:\n        return [int(id_str.strip()) for id_str in ids_str.split(\",\") if id_str.strip()]\n    except ValueError as e:\n        raise argparse.ArgumentTypeError(f\"无效的 ID 列表: {ids_str}\") from e\n\n\ndef setup_argument_parser() -> argparse.ArgumentParser:\n    \"\"\"设置命令行参数解析器\"\"\"\n    parser = argparse.ArgumentParser(\n        description=\"批量重新提取转录记录的待办和日程\",\n        formatter_class=argparse.RawDescriptionHelpFormatter,\n    )\n    parser.add_argument(\n        \"--days\",\n        type=int,\n        help=\"只处理最近 N 天的记录\",\n    )\n    parser.add_argument(\n        \"--start-date\",\n        type=parse_date,\n        help=\"指定开始日期 (YYYY-MM-DD)\",\n    )\n    parser.add_argument(\n        \"--end-date\",\n        type=parse_date,\n        help=\"指定结束日期 (YYYY-MM-DD)\",\n    )\n    parser.add_argument(\n        \"--ids\",\n        type=parse_ids,\n        help=\"指定特定的 transcription_id 列表，用逗号分隔，例如: --ids 1,2,3\",\n    )\n    return parser\n\n\ndef calculate_date_range(args: argparse.Namespace) -> tuple[datetime | None, datetime | None]:\n    \"\"\"计算日期范围\"\"\"\n    end_date = args.end_date\n    start_date = args.start_date\n\n    if args.days:\n        if start_date or end_date:\n            logger.warning(\"--days 参数与 --start-date/--end-date 同时指定，将使用 --days\")\n        end_date = datetime.now(UTC)\n        start_date = end_date - timedelta(days=args.days)\n\n    return start_date, end_date\n\n\ndef log_start_info(\n    start_date: datetime | None, end_date: datetime | None, ids: list[int] | None\n) -> None:\n    \"\"\"记录开始信息\"\"\"\n    logger.info(\"=\" * 60)\n    logger.info(\"开始批量重新提取转录记录的待办和日程\")\n    if start_date or end_date:\n        logger.info(f\"日期范围: {start_date or '无限制'} 至 {end_date or '无限制'}\")\n    if ids:\n        logger.info(f\"指定 ID 列表: {ids}\")\n    logger.info(\"=\" * 60)\n\n\ndef find_transcriptions_needing_extraction(\n    start_date: datetime | None,\n    end_date: datetime | None,\n    ids: list[int] | None,\n) -> list[tuple[int, bool, bool]]:\n    \"\"\"查找需要提取的转录记录\"\"\"\n    needs_extraction_list = []\n    with get_session() as session:\n        statement = select(Transcription)\n\n        # 应用日期过滤\n        if start_date:\n            statement = statement.where(Transcription.created_at >= start_date)\n        if end_date:\n            statement = statement.where(Transcription.created_at <= end_date)\n\n        # 应用 ID 过滤\n        if ids:\n            statement = statement.where(col(Transcription.id).in_(ids))\n\n        transcriptions = list(session.exec(statement).all())\n\n        logger.info(f\"找到 {len(transcriptions)} 条转录记录\")\n\n        # 在会话内检查需要提取的记录\n        for transcription in transcriptions:\n            # 在会话内访问所有属性，避免延迟加载问题\n            needs_original, needs_optimized = needs_extraction(transcription)\n            if needs_original or needs_optimized:\n                # 保存 transcription_id 而不是对象本身，避免会话分离问题\n                needs_extraction_list.append((transcription.id, needs_original, needs_optimized))\n\n    return needs_extraction_list\n\n\nasync def process_extractions(\n    needs_extraction_list: list[tuple[int, bool, bool]],\n    extraction_service: AudioExtractionService,\n) -> dict[str, int]:\n    \"\"\"处理提取任务\"\"\"\n    stats = {\n        \"total\": len(needs_extraction_list),\n        \"original_extracted\": 0,\n        \"optimized_extracted\": 0,\n        \"errors\": 0,\n    }\n\n    # 逐个提取\n    for idx, (transcription_id, needs_original, needs_optimized) in enumerate(\n        needs_extraction_list, 1\n    ):\n        # 重新获取转录记录（在新的会话中）\n        with get_session() as session:\n            transcription = session.get(Transcription, transcription_id)\n            if not transcription:\n                logger.warning(f\"转录记录不存在: transcription_id={transcription_id}\")\n                continue\n\n            logger.info(\n                f\"\\n[{idx}/{len(needs_extraction_list)}] \"\n                f\"处理 transcription_id={transcription.id}, \"\n                f\"recording_id={transcription.audio_recording_id}\"\n            )\n\n            result = await re_extract_transcription(\n                transcription, extraction_service, needs_original, needs_optimized\n            )\n\n            # 在会话内更新统计\n            if result[\"original_extracted\"]:\n                stats[\"original_extracted\"] += 1\n            if result[\"optimized_extracted\"]:\n                stats[\"optimized_extracted\"] += 1\n            if result[\"errors\"]:\n                stats[\"errors\"] += 1\n\n    return stats\n\n\ndef log_final_stats(stats: dict[str, int]) -> None:\n    \"\"\"记录最终统计信息\"\"\"\n    logger.info(\"\\n\" + \"=\" * 60)\n    logger.info(\"提取完成统计:\")\n    logger.info(f\"  总记录数: {stats['total']}\")\n    logger.info(f\"  原文提取成功: {stats['original_extracted']}\")\n    logger.info(f\"  优化文本提取成功: {stats['optimized_extracted']}\")\n    logger.info(f\"  错误数: {stats['errors']}\")\n    logger.info(\"=\" * 60)\n\n\nasync def main():\n    \"\"\"主函数\"\"\"\n    parser = setup_argument_parser()\n    args = parser.parse_args()\n\n    # 计算日期范围\n    start_date, end_date = calculate_date_range(args)\n\n    # 记录开始信息\n    log_start_info(start_date, end_date, args.ids)\n\n    # 初始化服务\n    llm_client = LLMClient()\n    extraction_service = AudioExtractionService(llm_client)\n\n    # 检查 LLM 客户端是否可用\n    if not llm_client.is_available():\n        logger.error(\"LLM 客户端不可用，无法进行提取\")\n        return\n\n    # 查找需要提取的转录记录\n    needs_extraction_list = find_transcriptions_needing_extraction(start_date, end_date, args.ids)\n\n    logger.info(f\"需要重新提取的记录数: {len(needs_extraction_list)}\")\n\n    if len(needs_extraction_list) == 0:\n        logger.info(\"所有记录都已提取完成，无需重新提取\")\n        return\n\n    # 处理提取任务\n    stats = await process_extractions(needs_extraction_list, extraction_service)\n\n    # 输出统计信息\n    log_final_stats(stats)\n\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n"
  },
  {
    "path": "lifetrace/migrations/script.py.mako",
    "content": "\"\"\"${message}\n\nRevision ID: ${up_revision}\nRevises: ${down_revision | comma,n}\nCreate Date: ${create_date}\n\n\"\"\"\nfrom typing import Sequence, Union\n\nfrom alembic import op\nimport sqlalchemy as sa\nimport sqlmodel\n${imports if imports else \"\"}\n\n# revision identifiers, used by Alembic.\nrevision: str = ${repr(up_revision)}\ndown_revision: Union[str, None] = ${repr(down_revision)}\nbranch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}\ndepends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}\n\n\ndef upgrade() -> None:\n    ${upgrades if upgrades else \"pass\"}\n\n\ndef downgrade() -> None:\n    ${downgrades if downgrades else \"pass\"}\n"
  },
  {
    "path": "lifetrace/migrations/versions/034079ad387f_add_segment_timestamps.py",
    "content": "\"\"\"add_segment_timestamps\n\nRevision ID: 034079ad387f\nRevises: 89b2a1f0af8b\nCreate Date: 2026-01-23 10:00:00.000000\n\n添加 segment_timestamps 字段到 transcriptions 表，用于存储每段文本的精确时间戳\n\"\"\"\n\nfrom collections.abc import Sequence\n\nimport sqlalchemy as sa\nfrom alembic import op\n\n# revision identifiers, used by Alembic.\nrevision: str = \"034079ad387f\"\ndown_revision: str = \"add_file_path_001\"\nbranch_labels: str | Sequence[str] | None = None\ndepends_on: str | Sequence[str] | None = None\n\n\ndef upgrade() -> None:\n    \"\"\"添加 segment_timestamps 字段到 transcriptions 表\"\"\"\n    connection = op.get_bind()\n    inspector = sa.inspect(connection)\n    existing_tables = inspector.get_table_names()\n\n    if \"transcriptions\" not in existing_tables:\n        # 如果表不存在，跳过（可能在其他迁移中创建）\n        return\n\n    # 检查列是否已存在\n    existing_columns = [col[\"name\"] for col in inspector.get_columns(\"transcriptions\")]\n\n    # 添加 segment_timestamps 字段（JSON 格式，存储每段文本的时间戳数组）\n    if \"segment_timestamps\" not in existing_columns:\n        op.add_column(\n            \"transcriptions\",\n            sa.Column(\"segment_timestamps\", sa.Text(), nullable=True),\n        )\n\n\ndef downgrade() -> None:\n    \"\"\"移除 segment_timestamps 字段\"\"\"\n    connection = op.get_bind()\n    inspector = sa.inspect(connection)\n    existing_tables = inspector.get_table_names()\n\n    if \"transcriptions\" not in existing_tables:\n        return\n\n    existing_columns = [col[\"name\"] for col in inspector.get_columns(\"transcriptions\")]\n\n    if \"segment_timestamps\" in existing_columns:\n        op.drop_column(\"transcriptions\", \"segment_timestamps\")\n"
  },
  {
    "path": "lifetrace/migrations/versions/4ca5036ec7c8_add_context_to_chats.py",
    "content": "\"\"\"add_context_to_chats\n\nRevision ID: 4ca5036ec7c8\nRevises: cc25001eb19c\nCreate Date: 2025-12-20 14:59:34.383642\n\n为 chats 表添加 context 字段，用于存储会话上下文（JSON 格式）。\n这将会话管理从内存迁移到数据库。\n\"\"\"\n\nfrom collections.abc import Sequence\n\nimport sqlalchemy as sa\nfrom alembic import op\n\n# revision identifiers, used by Alembic.\nrevision: str = \"4ca5036ec7c8\"\ndown_revision: str | None = \"cc25001eb19c\"\nbranch_labels: str | Sequence[str] | None = None\ndepends_on: str | Sequence[str] | None = None\n\n\ndef upgrade() -> None:\n    \"\"\"添加 context 字段到 chats 表\"\"\"\n    # 检查列是否已存在（防止重复添加）\n    conn = op.get_bind()\n    inspector = sa.inspect(conn)\n    columns = [col[\"name\"] for col in inspector.get_columns(\"chats\")]\n\n    if \"context\" not in columns:\n        with op.batch_alter_table(\"chats\", schema=None) as batch_op:\n            batch_op.add_column(sa.Column(\"context\", sa.Text(), nullable=True))\n\n\ndef downgrade() -> None:\n    \"\"\"移除 context 字段\"\"\"\n    with op.batch_alter_table(\"chats\", schema=None) as batch_op:\n        batch_op.drop_column(\"context\")\n"
  },
  {
    "path": "lifetrace/migrations/versions/add_automation_tasks_001.py",
    "content": "\"\"\"add_automation_tasks_001\n\nRevision ID: add_automation_tasks_001\nRevises: add_todo_attachment_source_001, add_todo_reminder_offsets_001\nCreate Date: 2026-02-04\n\nCreate automation_tasks table for user-defined scheduled tasks.\n\"\"\"\n\nfrom collections.abc import Sequence\n\nimport sqlalchemy as sa\nfrom alembic import op\n\n# revision identifiers, used by Alembic.\nrevision: str = \"add_automation_tasks_001\"\ndown_revision: str | Sequence[str] | None = (\n    \"add_todo_attachment_source_001\",\n    \"add_todo_reminder_offsets_001\",\n)\nbranch_labels: str | Sequence[str] | None = None\ndepends_on: str | Sequence[str] | None = None\n\n\ndef upgrade() -> None:\n    connection = op.get_bind()\n    inspector = sa.inspect(connection)\n    existing_tables = inspector.get_table_names()\n\n    if \"automation_tasks\" in existing_tables:\n        return\n\n    op.create_table(\n        \"automation_tasks\",\n        sa.Column(\"id\", sa.Integer(), primary_key=True),\n        sa.Column(\"name\", sa.String(length=200), nullable=False),\n        sa.Column(\"description\", sa.Text(), nullable=True),\n        sa.Column(\"enabled\", sa.Boolean(), nullable=False, server_default=sa.text(\"1\")),\n        sa.Column(\"schedule_type\", sa.String(length=20), nullable=False),\n        sa.Column(\"schedule_config\", sa.Text(), nullable=True),\n        sa.Column(\"action_type\", sa.String(length=50), nullable=False),\n        sa.Column(\"action_payload\", sa.Text(), nullable=True),\n        sa.Column(\"last_run_at\", sa.DateTime(), nullable=True),\n        sa.Column(\"last_status\", sa.String(length=20), nullable=True),\n        sa.Column(\"last_error\", sa.Text(), nullable=True),\n        sa.Column(\"last_output\", sa.Text(), nullable=True),\n        sa.Column(\"created_at\", sa.DateTime(), nullable=True),\n        sa.Column(\"updated_at\", sa.DateTime(), nullable=True),\n        sa.Column(\"deleted_at\", sa.DateTime(), nullable=True),\n    )\n\n\ndef downgrade() -> None:\n    connection = op.get_bind()\n    inspector = sa.inspect(connection)\n    existing_tables = inspector.get_table_names()\n\n    if \"automation_tasks\" in existing_tables:\n        op.drop_table(\"automation_tasks\")\n"
  },
  {
    "path": "lifetrace/migrations/versions/add_file_path_to_audio_recordings.py",
    "content": "\"\"\"add_file_path_to_audio_recordings\n\nRevision ID: add_file_path_001\nRevises: remove_project_task\nCreate Date: 2026-01-19 06:30:00.000000\n\n添加缺失的列到 audio_recordings 表（包括 file_path, file_size, duration 等）\n如果表不存在则创建完整的表结构\n同时创建 transcriptions 表（如果不存在）\n\"\"\"\n\nfrom collections.abc import Sequence\n\nimport sqlalchemy as sa\nfrom alembic import op\n\n# revision identifiers, used by Alembic.\nrevision: str = \"add_file_path_001\"\ndown_revision: str = \"remove_project_task\"\nbranch_labels: str | Sequence[str] | None = None\ndepends_on: str | Sequence[str] | None = None\n\n\ndef upgrade() -> None:\n    \"\"\"添加缺失的列到 audio_recordings 表，并创建 transcriptions 表\"\"\"\n    connection = op.get_bind()\n    inspector = sa.inspect(connection)\n    existing_tables = inspector.get_table_names()\n\n    # 首先创建 transcriptions 表（如果不存在）\n    _create_transcriptions_table_if_not_exists(existing_tables)\n\n    # 检查 audio_recordings 表是否存在\n    if \"audio_recordings\" not in existing_tables:\n        # 如果表不存在，创建完整的表\n        op.create_table(\n            \"audio_recordings\",\n            sa.Column(\"id\", sa.Integer(), nullable=False, primary_key=True),\n            sa.Column(\"created_at\", sa.DateTime(), nullable=False),\n            sa.Column(\"updated_at\", sa.DateTime(), nullable=False),\n            sa.Column(\"deleted_at\", sa.DateTime(), nullable=True),\n            sa.Column(\"file_path\", sa.String(length=500), nullable=False),\n            sa.Column(\"file_size\", sa.Integer(), nullable=False),\n            sa.Column(\"duration\", sa.Float(), nullable=False),\n            sa.Column(\"start_time\", sa.DateTime(), nullable=False),\n            sa.Column(\"end_time\", sa.DateTime(), nullable=True),\n            sa.Column(\"status\", sa.String(length=20), nullable=False, server_default=\"recording\"),\n            sa.Column(\"is_24x7\", sa.Boolean(), nullable=False, server_default=\"0\"),\n            sa.Column(\"is_transcribed\", sa.Boolean(), nullable=False, server_default=\"0\"),\n            sa.Column(\"is_extracted\", sa.Boolean(), nullable=False, server_default=\"0\"),\n            sa.Column(\"is_summarized\", sa.Boolean(), nullable=False, server_default=\"0\"),\n            sa.Column(\"is_full_audio\", sa.Boolean(), nullable=False, server_default=\"0\"),\n            sa.Column(\"is_segment_audio\", sa.Boolean(), nullable=False, server_default=\"0\"),\n            sa.Column(\n                \"transcription_status\",\n                sa.String(length=20),\n                nullable=False,\n                server_default=\"pending\",\n            ),\n        )\n        return\n\n    # 表存在，检查并添加缺失的列\n    columns = {col[\"name\"]: col for col in inspector.get_columns(\"audio_recordings\")}\n\n    # 需要添加的列及其定义\n    columns_to_add = {\n        \"file_path\": sa.Column(\"file_path\", sa.String(length=500), nullable=True),\n        \"file_size\": sa.Column(\"file_size\", sa.Integer(), nullable=True),\n        \"duration\": sa.Column(\"duration\", sa.Float(), nullable=True),\n        \"start_time\": sa.Column(\"start_time\", sa.DateTime(), nullable=True),\n        \"end_time\": sa.Column(\"end_time\", sa.DateTime(), nullable=True),\n        \"status\": sa.Column(\n            \"status\", sa.String(length=20), nullable=True, server_default=\"recording\"\n        ),\n        \"is_24x7\": sa.Column(\"is_24x7\", sa.Boolean(), nullable=True, server_default=\"0\"),\n        \"is_transcribed\": sa.Column(\n            \"is_transcribed\", sa.Boolean(), nullable=True, server_default=\"0\"\n        ),\n        \"is_extracted\": sa.Column(\"is_extracted\", sa.Boolean(), nullable=True, server_default=\"0\"),\n        \"is_summarized\": sa.Column(\n            \"is_summarized\", sa.Boolean(), nullable=True, server_default=\"0\"\n        ),\n        \"is_full_audio\": sa.Column(\n            \"is_full_audio\", sa.Boolean(), nullable=True, server_default=\"0\"\n        ),\n        \"is_segment_audio\": sa.Column(\n            \"is_segment_audio\", sa.Boolean(), nullable=True, server_default=\"0\"\n        ),\n        \"transcription_status\": sa.Column(\n            \"transcription_status\", sa.String(length=20), nullable=True, server_default=\"pending\"\n        ),\n        \"created_at\": sa.Column(\"created_at\", sa.DateTime(), nullable=True),\n        \"updated_at\": sa.Column(\"updated_at\", sa.DateTime(), nullable=True),\n        \"deleted_at\": sa.Column(\"deleted_at\", sa.DateTime(), nullable=True),\n    }\n\n    # 添加缺失的列\n    for col_name, col_def in columns_to_add.items():\n        if col_name not in columns:\n            op.add_column(\"audio_recordings\", col_def)\n\n    # 为现有记录设置默认值\n    op.execute(\"UPDATE audio_recordings SET file_path = '' WHERE file_path IS NULL\")\n    op.execute(\"UPDATE audio_recordings SET file_size = 0 WHERE file_size IS NULL\")\n    op.execute(\"UPDATE audio_recordings SET duration = 0 WHERE duration IS NULL\")\n    op.execute(\"UPDATE audio_recordings SET status = 'recording' WHERE status IS NULL\")\n    op.execute(\"UPDATE audio_recordings SET is_24x7 = 0 WHERE is_24x7 IS NULL\")\n    op.execute(\"UPDATE audio_recordings SET is_transcribed = 0 WHERE is_transcribed IS NULL\")\n    op.execute(\"UPDATE audio_recordings SET is_extracted = 0 WHERE is_extracted IS NULL\")\n    op.execute(\"UPDATE audio_recordings SET is_summarized = 0 WHERE is_summarized IS NULL\")\n    op.execute(\"UPDATE audio_recordings SET is_full_audio = 0 WHERE is_full_audio IS NULL\")\n    op.execute(\"UPDATE audio_recordings SET is_segment_audio = 0 WHERE is_segment_audio IS NULL\")\n    op.execute(\n        \"UPDATE audio_recordings SET transcription_status = 'pending' WHERE transcription_status IS NULL\"\n    )\n\n\ndef _create_transcriptions_table_if_not_exists(existing_tables: list[str]) -> None:\n    \"\"\"创建 transcriptions 表（如果不存在）\"\"\"\n    if \"transcriptions\" not in existing_tables:\n        op.create_table(\n            \"transcriptions\",\n            sa.Column(\"id\", sa.Integer(), nullable=False, primary_key=True),\n            sa.Column(\"created_at\", sa.DateTime(), nullable=False),\n            sa.Column(\"updated_at\", sa.DateTime(), nullable=False),\n            sa.Column(\"deleted_at\", sa.DateTime(), nullable=True),\n            sa.Column(\"audio_recording_id\", sa.Integer(), nullable=False),\n            sa.Column(\"original_text\", sa.Text(), nullable=True),\n            sa.Column(\"optimized_text\", sa.Text(), nullable=True),\n            sa.Column(\n                \"extraction_status\",\n                sa.String(length=20),\n                nullable=False,\n                server_default=\"pending\",\n            ),\n            sa.Column(\"extracted_todos\", sa.Text(), nullable=True),\n            sa.Column(\"extracted_schedules\", sa.Text(), nullable=True),\n        )\n\n\ndef downgrade() -> None:\n    \"\"\"移除 file_path 列\"\"\"\n    op.drop_column(\"audio_recordings\", \"file_path\")\n"
  },
  {
    "path": "lifetrace/migrations/versions/add_icalendar_fields_v2_001.py",
    "content": "\"\"\"add_icalendar_fields_v2_001\n\nRevision ID: add_icalendar_fields_v2_001\nRevises: add_todo_timezone_all_day_001\nCreate Date: 2026-02-06 00:00:00.000000\n\n为 todos 表补齐 iCalendar 相关字段，并回填旧字段映射。\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom typing import TYPE_CHECKING\n\nimport sqlalchemy as sa\nfrom alembic import op\n\nif TYPE_CHECKING:\n    from collections.abc import Sequence\n\n# revision identifiers, used by Alembic.\nrevision: str = \"add_icalendar_fields_v2_001\"\ndown_revision: str | Sequence[str] | None = \"add_todo_timezone_all_day_001\"\nbranch_labels: str | Sequence[str] | None = None\ndepends_on: str | Sequence[str] | None = None\n\n\ndef _add_missing_todo_columns(columns: set[str]) -> None:\n    column_defs = {\n        \"item_type\": sa.Column(\"item_type\", sa.String(length=10), nullable=True),\n        \"summary\": sa.Column(\"summary\", sa.String(length=200), nullable=True),\n        \"location\": sa.Column(\"location\", sa.String(length=200), nullable=True),\n        \"categories\": sa.Column(\"categories\", sa.Text(), nullable=True),\n        \"classification\": sa.Column(\"classification\", sa.String(length=20), nullable=True),\n        \"dtstart\": sa.Column(\"dtstart\", sa.DateTime(), nullable=True),\n        \"dtend\": sa.Column(\"dtend\", sa.DateTime(), nullable=True),\n        \"due\": sa.Column(\"due\", sa.DateTime(), nullable=True),\n        \"duration\": sa.Column(\"duration\", sa.String(length=64), nullable=True),\n        \"tzid\": sa.Column(\"tzid\", sa.String(length=64), nullable=True),\n        \"dtstamp\": sa.Column(\"dtstamp\", sa.DateTime(), nullable=True),\n        \"created\": sa.Column(\"created\", sa.DateTime(), nullable=True),\n        \"last_modified\": sa.Column(\"last_modified\", sa.DateTime(), nullable=True),\n        \"sequence\": sa.Column(\"sequence\", sa.Integer(), nullable=True),\n        \"rdate\": sa.Column(\"rdate\", sa.Text(), nullable=True),\n        \"exdate\": sa.Column(\"exdate\", sa.Text(), nullable=True),\n        \"recurrence_id\": sa.Column(\"recurrence_id\", sa.DateTime(), nullable=True),\n        \"related_to_uid\": sa.Column(\"related_to_uid\", sa.String(length=64), nullable=True),\n        \"related_to_reltype\": sa.Column(\"related_to_reltype\", sa.String(length=20), nullable=True),\n        \"ical_status\": sa.Column(\"ical_status\", sa.String(length=20), nullable=True),\n    }\n\n    with op.batch_alter_table(\"todos\", schema=None) as batch_op:\n        for name, column_def in column_defs.items():\n            if name not in columns:\n                batch_op.add_column(column_def)\n\n\ndef _backfill_todo_ical_fields(connection: sa.Connection) -> None:\n    updates = [\n        \"UPDATE todos SET item_type = 'VTODO' WHERE item_type IS NULL\",\n        \"UPDATE todos SET summary = name WHERE summary IS NULL AND name IS NOT NULL\",\n        \"UPDATE todos SET dtstart = start_time WHERE dtstart IS NULL AND start_time IS NOT NULL\",\n        \"UPDATE todos SET dtend = end_time WHERE dtend IS NULL AND end_time IS NOT NULL\",\n        \"UPDATE todos SET due = deadline WHERE due IS NULL AND deadline IS NOT NULL\",\n        \"UPDATE todos SET tzid = time_zone WHERE tzid IS NULL AND time_zone IS NOT NULL\",\n        \"UPDATE todos SET created = created_at WHERE created IS NULL AND created_at IS NOT NULL\",\n        \"UPDATE todos SET last_modified = updated_at \"\n        \"WHERE last_modified IS NULL AND updated_at IS NOT NULL\",\n        \"UPDATE todos SET dtstamp = updated_at WHERE dtstamp IS NULL AND updated_at IS NOT NULL\",\n        \"UPDATE todos SET sequence = 0 WHERE sequence IS NULL\",\n        \"UPDATE todos SET ical_status = CASE status \"\n        \"WHEN 'completed' THEN 'COMPLETED' \"\n        \"WHEN 'canceled' THEN 'CANCELLED' \"\n        \"WHEN 'draft' THEN 'NEEDS-ACTION' \"\n        \"ELSE 'NEEDS-ACTION' \"\n        \"END \"\n        \"WHERE ical_status IS NULL AND status IS NOT NULL\",\n    ]\n\n    for stmt in updates:\n        connection.execute(sa.text(stmt))\n\n\ndef upgrade() -> None:\n    connection = op.get_bind()\n    inspector = sa.inspect(connection)\n    existing_tables = inspector.get_table_names()\n\n    if \"todos\" not in existing_tables:\n        return\n\n    columns = {col[\"name\"] for col in inspector.get_columns(\"todos\")}\n    _add_missing_todo_columns(columns)\n    _backfill_todo_ical_fields(connection)\n\n\ndef downgrade() -> None:\n    connection = op.get_bind()\n    inspector = sa.inspect(connection)\n    existing_tables = inspector.get_table_names()\n\n    if \"todos\" not in existing_tables:\n        return\n\n    columns = {col[\"name\"] for col in inspector.get_columns(\"todos\")}\n    drop_columns = [\n        \"ical_status\",\n        \"related_to_reltype\",\n        \"related_to_uid\",\n        \"recurrence_id\",\n        \"exdate\",\n        \"rdate\",\n        \"sequence\",\n        \"last_modified\",\n        \"created\",\n        \"dtstamp\",\n        \"tzid\",\n        \"duration\",\n        \"due\",\n        \"dtend\",\n        \"dtstart\",\n        \"classification\",\n        \"categories\",\n        \"location\",\n        \"summary\",\n        \"item_type\",\n    ]\n    with op.batch_alter_table(\"todos\", schema=None) as batch_op:\n        for name in drop_columns:\n            if name in columns:\n                batch_op.drop_column(name)\n"
  },
  {
    "path": "lifetrace/migrations/versions/add_journal_panel_001.py",
    "content": "\"\"\"add_journal_panel_001\n\nRevision ID: add_journal_panel_001\nRevises: merge_heads_todos_20260131\nCreate Date: 2026-02-03 03:05:00.000000\n\nExtend journals table for journal panel and add relation tables.\n\"\"\"\n\nfrom collections.abc import Sequence\n\nimport sqlalchemy as sa\nfrom alembic import op\n\n# revision identifiers, used by Alembic.\nrevision: str = \"add_journal_panel_001\"\ndown_revision: str | Sequence[str] | None = \"merge_heads_todos_20260131\"\nbranch_labels: str | Sequence[str] | None = None\ndepends_on: str | Sequence[str] | None = None\n\n\ndef _add_column_if_missing(table: str, column: sa.Column, columns: set[str]) -> None:\n    if column.name not in columns:\n        op.add_column(table, column)\n\n\ndef upgrade() -> None:\n    connection = op.get_bind()\n    inspector = sa.inspect(connection)\n    existing_tables = inspector.get_table_names()\n\n    if \"journals\" in existing_tables:\n        columns = {col[\"name\"] for col in inspector.get_columns(\"journals\")}\n        _add_column_if_missing(\n            \"journals\",\n            sa.Column(\"content_objective\", sa.Text(), nullable=True),\n            columns,\n        )\n        _add_column_if_missing(\n            \"journals\",\n            sa.Column(\"content_ai\", sa.Text(), nullable=True),\n            columns,\n        )\n        _add_column_if_missing(\n            \"journals\",\n            sa.Column(\"mood\", sa.String(length=50), nullable=True),\n            columns,\n        )\n        _add_column_if_missing(\n            \"journals\",\n            sa.Column(\"energy\", sa.Integer(), nullable=True),\n            columns,\n        )\n        _add_column_if_missing(\n            \"journals\",\n            sa.Column(\"day_bucket_start\", sa.DateTime(), nullable=True),\n            columns,\n        )\n\n    if \"journal_todo_relations\" not in existing_tables:\n        op.create_table(\n            \"journal_todo_relations\",\n            sa.Column(\"id\", sa.Integer(), nullable=False, primary_key=True),\n            sa.Column(\"journal_id\", sa.Integer(), nullable=False),\n            sa.Column(\"todo_id\", sa.Integer(), nullable=False),\n            sa.Column(\"created_at\", sa.DateTime(), nullable=False),\n            sa.Column(\"deleted_at\", sa.DateTime(), nullable=True),\n        )\n\n    if \"journal_activity_relations\" not in existing_tables:\n        op.create_table(\n            \"journal_activity_relations\",\n            sa.Column(\"id\", sa.Integer(), nullable=False, primary_key=True),\n            sa.Column(\"journal_id\", sa.Integer(), nullable=False),\n            sa.Column(\"activity_id\", sa.Integer(), nullable=False),\n            sa.Column(\"created_at\", sa.DateTime(), nullable=False),\n            sa.Column(\"deleted_at\", sa.DateTime(), nullable=True),\n        )\n\n\ndef downgrade() -> None:\n    connection = op.get_bind()\n    inspector = sa.inspect(connection)\n    existing_tables = inspector.get_table_names()\n\n    if \"journal_activity_relations\" in existing_tables:\n        op.drop_table(\"journal_activity_relations\")\n\n    if \"journal_todo_relations\" in existing_tables:\n        op.drop_table(\"journal_todo_relations\")\n\n    if \"journals\" in existing_tables:\n        columns = {col[\"name\"] for col in inspector.get_columns(\"journals\")}\n        with op.batch_alter_table(\"journals\", schema=None) as batch_op:\n            if \"day_bucket_start\" in columns:\n                batch_op.drop_column(\"day_bucket_start\")\n            if \"energy\" in columns:\n                batch_op.drop_column(\"energy\")\n            if \"mood\" in columns:\n                batch_op.drop_column(\"mood\")\n            if \"content_ai\" in columns:\n                batch_op.drop_column(\"content_ai\")\n            if \"content_objective\" in columns:\n                batch_op.drop_column(\"content_objective\")\n"
  },
  {
    "path": "lifetrace/migrations/versions/add_optimized_extraction_fields.py",
    "content": "\"\"\"add_optimized_extraction_fields\n\nRevision ID: add_optimized_extraction_001\nRevises: add_file_path_001\nCreate Date: 2026-01-22 10:00:00.000000\n\n添加优化文本的提取字段到 transcriptions 表\n\"\"\"\n\nfrom collections.abc import Sequence\n\nimport sqlalchemy as sa\nfrom alembic import op\n\n# revision identifiers, used by Alembic.\nrevision: str = \"add_optimized_extraction_001\"\ndown_revision: str = \"add_file_path_001\"\nbranch_labels: str | Sequence[str] | None = None\ndepends_on: str | Sequence[str] | None = None\n\n\ndef upgrade() -> None:\n    \"\"\"添加优化文本的提取字段到 transcriptions 表\"\"\"\n    connection = op.get_bind()\n    inspector = sa.inspect(connection)\n    existing_tables = inspector.get_table_names()\n\n    if \"transcriptions\" not in existing_tables:\n        # 如果表不存在，跳过（可能在其他迁移中创建）\n        return\n\n    # 检查列是否已存在\n    existing_columns = [col[\"name\"] for col in inspector.get_columns(\"transcriptions\")]\n\n    # 添加 extracted_todos_optimized 字段\n    if \"extracted_todos_optimized\" not in existing_columns:\n        op.add_column(\n            \"transcriptions\",\n            sa.Column(\"extracted_todos_optimized\", sa.Text(), nullable=True),\n        )\n\n    # 添加 extracted_schedules_optimized 字段\n    if \"extracted_schedules_optimized\" not in existing_columns:\n        op.add_column(\n            \"transcriptions\",\n            sa.Column(\"extracted_schedules_optimized\", sa.Text(), nullable=True),\n        )\n\n\ndef downgrade() -> None:\n    \"\"\"移除优化文本的提取字段\"\"\"\n    connection = op.get_bind()\n    inspector = sa.inspect(connection)\n    existing_tables = inspector.get_table_names()\n\n    if \"transcriptions\" not in existing_tables:\n        return\n\n    existing_columns = [col[\"name\"] for col in inspector.get_columns(\"transcriptions\")]\n\n    if \"extracted_schedules_optimized\" in existing_columns:\n        op.drop_column(\"transcriptions\", \"extracted_schedules_optimized\")\n\n    if \"extracted_todos_optimized\" in existing_columns:\n        op.drop_column(\"transcriptions\", \"extracted_todos_optimized\")\n"
  },
  {
    "path": "lifetrace/migrations/versions/add_text_hash_to_ocr_results.py",
    "content": "\"\"\"add_text_hash_to_ocr_results\n\nRevision ID: add_text_hash_to_ocr_results\nRevises: cff6e6d7a3cf\nCreate Date: 2026-01-23 00:00:00.000000\n\n为 ocr_results 表添加 text_hash 列和索引，并为已有数据回填哈希值。\n\"\"\"\n\nimport hashlib\nfrom collections.abc import Sequence\n\nimport sqlalchemy as sa\nfrom alembic import op\n\n# revision identifiers, used by Alembic.\nrevision: str = \"add_text_hash_to_ocr_results\"\ndown_revision: str | None = \"cff6e6d7a3cf\"\nbranch_labels: str | Sequence[str] | None = None\ndepends_on: str | Sequence[str] | None = None\n\n\ndef _normalize_text(text: str | None) -> str:\n    if not text:\n        return \"\"\n    # 去掉首尾空白并压缩中间多余空白，保证哈希稳定\n    return \" \".join(text.strip().split())\n\n\ndef upgrade() -> None:\n    connection = op.get_bind()\n    inspector = sa.inspect(connection)\n\n    columns = {col[\"name\"] for col in inspector.get_columns(\"ocr_results\")}\n\n    # 添加 text_hash 列\n    if \"text_hash\" not in columns:\n        with op.batch_alter_table(\"ocr_results\", schema=None) as batch_op:\n            batch_op.add_column(sa.Column(\"text_hash\", sa.String(length=64), nullable=True))\n\n    # 为 text_hash 创建索引（如果不存在）\n    indexes = {idx[\"name\"] for idx in inspector.get_indexes(\"ocr_results\")}\n    index_name = \"idx_ocr_results_text_hash\"\n    if index_name not in indexes:\n        op.create_index(index_name, \"ocr_results\", [\"text_hash\"], unique=False)\n\n    # 回填已有数据的 text_hash\n    result = connection.execute(sa.text(\"SELECT id, text_content FROM ocr_results\"))\n    rows = result.mappings().all()\n\n    for row in rows:\n        normalized = _normalize_text(row[\"text_content\"])\n        text_hash = (\n            None\n            if not normalized\n            else hashlib.md5(normalized.encode(\"utf-8\"), usedforsecurity=False).hexdigest()\n        )\n\n        connection.execute(\n            sa.text(\"UPDATE ocr_results SET text_hash = :text_hash WHERE id = :id\"),\n            {\"text_hash\": text_hash, \"id\": row[\"id\"]},\n        )\n\n\ndef downgrade() -> None:\n    connection = op.get_bind()\n    inspector = sa.inspect(connection)\n\n    # 删除索引（如果存在）\n    indexes = {idx[\"name\"] for idx in inspector.get_indexes(\"ocr_results\")}\n    index_name = \"idx_ocr_results_text_hash\"\n    if index_name in indexes:\n        op.drop_index(index_name, table_name=\"ocr_results\")\n\n    # 删除 text_hash 列（如果存在）\n    columns = {col[\"name\"] for col in inspector.get_columns(\"ocr_results\")}\n    if \"text_hash\" in columns:\n        with op.batch_alter_table(\"ocr_results\", schema=None) as batch_op:\n            batch_op.drop_column(\"text_hash\")\n"
  },
  {
    "path": "lifetrace/migrations/versions/add_todo_attachment_source_001.py",
    "content": "\"\"\"add_todo_attachment_source_001\n\nRevision ID: add_todo_attachment_source_001\nRevises: merge_heads_todos_20260131\nCreate Date: 2026-02-01\n\n为 todo_attachment_relations 表添加 source 字段（user/ai）。\n\"\"\"\n\nfrom collections.abc import Sequence\n\nimport sqlalchemy as sa\nfrom alembic import op\n\n# revision identifiers, used by Alembic.\nrevision: str = \"add_todo_attachment_source_001\"\ndown_revision: str | Sequence[str] | None = \"merge_heads_todos_20260131\"\nbranch_labels: str | Sequence[str] | None = None\ndepends_on: str | Sequence[str] | None = None\n\n\ndef upgrade() -> None:\n    connection = op.get_bind()\n    inspector = sa.inspect(connection)\n    existing_tables = inspector.get_table_names()\n\n    if \"todo_attachment_relations\" not in existing_tables:\n        return\n\n    columns = {col[\"name\"] for col in inspector.get_columns(\"todo_attachment_relations\")}\n    if \"source\" not in columns:\n        op.add_column(\n            \"todo_attachment_relations\",\n            sa.Column(\"source\", sa.String(length=20), nullable=False, server_default=\"user\"),\n        )\n\n\ndef downgrade() -> None:\n    connection = op.get_bind()\n    inspector = sa.inspect(connection)\n    existing_tables = inspector.get_table_names()\n\n    if \"todo_attachment_relations\" not in existing_tables:\n        return\n\n    columns = {col[\"name\"] for col in inspector.get_columns(\"todo_attachment_relations\")}\n    if \"source\" in columns:\n        op.drop_column(\"todo_attachment_relations\", \"source\")\n"
  },
  {
    "path": "lifetrace/migrations/versions/add_todo_end_time_001.py",
    "content": "\"\"\"add_todo_end_time_001\n\nRevision ID: add_todo_end_time_001\nRevises: cff6e6d7a3cf\nCreate Date: 2026-01-30 20:30:00.000000\n\n为 todos 表添加 end_time 字段。\n\"\"\"\n\nfrom collections.abc import Sequence\n\nimport sqlalchemy as sa\nfrom alembic import op\n\n# revision identifiers, used by Alembic.\nrevision: str = \"add_todo_end_time_001\"\ndown_revision: str | Sequence[str] | None = \"cff6e6d7a3cf\"\nbranch_labels: str | Sequence[str] | None = None\ndepends_on: str | Sequence[str] | None = None\n\n\ndef upgrade() -> None:\n    connection = op.get_bind()\n    inspector = sa.inspect(connection)\n    existing_tables = inspector.get_table_names()\n\n    if \"todos\" not in existing_tables:\n        return\n\n    columns = {col[\"name\"] for col in inspector.get_columns(\"todos\")}\n    if \"end_time\" not in columns:\n        op.add_column(\"todos\", sa.Column(\"end_time\", sa.DateTime(), nullable=True))\n\n\ndef downgrade() -> None:\n    connection = op.get_bind()\n    inspector = sa.inspect(connection)\n    existing_tables = inspector.get_table_names()\n\n    if \"todos\" not in existing_tables:\n        return\n\n    columns = {col[\"name\"] for col in inspector.get_columns(\"todos\")}\n    if \"end_time\" in columns:\n        op.drop_column(\"todos\", \"end_time\")\n"
  },
  {
    "path": "lifetrace/migrations/versions/add_todo_reminder_offsets_001.py",
    "content": "\"\"\"add_todo_reminder_offsets_001\n\nRevision ID: add_todo_reminder_offsets_001\nRevises: merge_heads_todos_20260131\nCreate Date: 2026-02-01\n\nAdd reminder_offsets column to todos table.\n\"\"\"\n\nfrom collections.abc import Sequence\n\nimport sqlalchemy as sa\nfrom alembic import op\n\n# revision identifiers, used by Alembic.\nrevision: str = \"add_todo_reminder_offsets_001\"\ndown_revision: str | Sequence[str] | None = \"merge_heads_todos_20260131\"\nbranch_labels: str | Sequence[str] | None = None\ndepends_on: str | Sequence[str] | None = None\n\n\ndef upgrade() -> None:\n    connection = op.get_bind()\n    inspector = sa.inspect(connection)\n    existing_tables = inspector.get_table_names()\n\n    if \"todos\" not in existing_tables:\n        return\n\n    columns = {col[\"name\"] for col in inspector.get_columns(\"todos\")}\n    if \"reminder_offsets\" not in columns:\n        op.add_column(\"todos\", sa.Column(\"reminder_offsets\", sa.Text(), nullable=True))\n\n\ndef downgrade() -> None:\n    connection = op.get_bind()\n    inspector = sa.inspect(connection)\n    existing_tables = inspector.get_table_names()\n\n    if \"todos\" not in existing_tables:\n        return\n\n    columns = {col[\"name\"] for col in inspector.get_columns(\"todos\")}\n    if \"reminder_offsets\" in columns:\n        op.drop_column(\"todos\", \"reminder_offsets\")\n"
  },
  {
    "path": "lifetrace/migrations/versions/add_todo_timezone_all_day_001.py",
    "content": "\"\"\"add_todo_timezone_all_day_001\n\nRevision ID: add_todo_timezone_all_day_001\nRevises: merge_heads_todos_20260131\nCreate Date: 2026-02-03 05:30:00.000000\n\n为 todos 表添加 time_zone 与 is_all_day 字段。\n\"\"\"\n\nfrom collections.abc import Sequence\n\nimport sqlalchemy as sa\nfrom alembic import op\n\n# revision identifiers, used by Alembic.\nrevision: str = \"add_todo_timezone_all_day_001\"\ndown_revision: str | Sequence[str] | None = \"merge_heads_todos_20260131\"\nbranch_labels: str | Sequence[str] | None = None\ndepends_on: str | Sequence[str] | None = None\n\n\ndef upgrade() -> None:\n    connection = op.get_bind()\n    inspector = sa.inspect(connection)\n    existing_tables = inspector.get_table_names()\n\n    if \"todos\" not in existing_tables:\n        return\n\n    columns = {col[\"name\"] for col in inspector.get_columns(\"todos\")}\n    if \"time_zone\" not in columns:\n        op.add_column(\"todos\", sa.Column(\"time_zone\", sa.String(length=64), nullable=True))\n    if \"is_all_day\" not in columns:\n        op.add_column(\n            \"todos\",\n            sa.Column(\"is_all_day\", sa.Boolean(), nullable=True, server_default=sa.text(\"0\")),\n        )\n\n\ndef downgrade() -> None:\n    connection = op.get_bind()\n    inspector = sa.inspect(connection)\n    existing_tables = inspector.get_table_names()\n\n    if \"todos\" not in existing_tables:\n        return\n\n    columns = {col[\"name\"] for col in inspector.get_columns(\"todos\")}\n    if \"is_all_day\" in columns:\n        op.drop_column(\"todos\", \"is_all_day\")\n    if \"time_zone\" in columns:\n        op.drop_column(\"todos\", \"time_zone\")\n"
  },
  {
    "path": "lifetrace/migrations/versions/b53d9b7c8e21_add_uid_to_journals.py",
    "content": "\"\"\"add_uid_to_journals\n\nRevision ID: b53d9b7c8e21\nRevises: remove_project_task\nCreate Date: 2026-02-03 12:00:00.000000\n\n为 journals 表添加 iCalendar UID 字段并回填已有数据。\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom typing import TYPE_CHECKING\nfrom uuid import uuid4\n\nimport sqlalchemy as sa\nfrom alembic import op\n\nif TYPE_CHECKING:\n    from collections.abc import Sequence\n\n# revision identifiers, used by Alembic.\nrevision: str = \"b53d9b7c8e21\"\ndown_revision: str | None = \"merge_heads_journal_todo_20260203\"\nbranch_labels: str | Sequence[str] | None = None\ndepends_on: str | Sequence[str] | None = None\n\n\ndef _add_missing_journal_columns(columns: set[str]) -> None:\n    with op.batch_alter_table(\"journals\", schema=None) as batch_op:\n        if \"uid\" not in columns:\n            batch_op.add_column(sa.Column(\"uid\", sa.String(length=64), nullable=True))\n\n\ndef _ensure_journal_uid_index(inspector: sa.Inspector) -> None:\n    indexes = {idx[\"name\"] for idx in inspector.get_indexes(\"journals\")}\n    if \"idx_journals_uid\" not in indexes:\n        op.create_index(\"idx_journals_uid\", \"journals\", [\"uid\"], unique=False)\n\n\ndef _backfill_journal_uids(connection: sa.Connection) -> None:\n    result = connection.execute(sa.text(\"SELECT id, uid FROM journals\"))\n    rows = result.mappings().all()\n\n    for row in rows:\n        uid = row.get(\"uid\")\n        if uid:\n            continue\n\n        connection.execute(\n            sa.text(\"UPDATE journals SET uid = :uid WHERE id = :id\"),  # nosec B608\n            {\"uid\": str(uuid4()), \"id\": row[\"id\"]},\n        )\n\n\ndef upgrade() -> None:\n    connection = op.get_bind()\n    inspector = sa.inspect(connection)\n    if \"journals\" not in inspector.get_table_names():\n        return\n\n    columns = {col[\"name\"] for col in inspector.get_columns(\"journals\")}\n    _add_missing_journal_columns(columns)\n    _ensure_journal_uid_index(inspector)\n    _backfill_journal_uids(connection)\n\n\ndef downgrade() -> None:\n    connection = op.get_bind()\n    inspector = sa.inspect(connection)\n    if \"journals\" not in inspector.get_table_names():\n        return\n\n    indexes = {idx[\"name\"] for idx in inspector.get_indexes(\"journals\")}\n    if \"idx_journals_uid\" in indexes:\n        op.drop_index(\"idx_journals_uid\", table_name=\"journals\")\n\n    columns = {col[\"name\"] for col in inspector.get_columns(\"journals\")}\n    with op.batch_alter_table(\"journals\", schema=None) as batch_op:\n        if \"uid\" in columns:\n            batch_op.drop_column(\"uid\")\n"
  },
  {
    "path": "lifetrace/migrations/versions/cc25001eb19c_initial_schema.py",
    "content": "\"\"\"initial_schema\n\nRevision ID: cc25001eb19c\nRevises:\nCreate Date: 2025-12-20 14:58:03.694426\n\n这是一个基线迁移，用于标记现有数据库结构。\n对于已存在的数据库，此迁移不执行任何操作。\n\"\"\"\n\nfrom collections.abc import Sequence\n\n# revision identifiers, used by Alembic.\nrevision: str = \"cc25001eb19c\"\ndown_revision: str | None = None\nbranch_labels: str | Sequence[str] | None = None\ndepends_on: str | Sequence[str] | None = None\n\n\ndef upgrade() -> None:\n    \"\"\"基线迁移 - 不执行任何操作\n\n    现有数据库的表结构已经正确，此迁移仅用于建立 Alembic 版本基线。\n    新数据库的表结构由 SQLModel.metadata.create_all() 创建。\n    \"\"\"\n    pass\n\n\ndef downgrade() -> None:\n    \"\"\"基线迁移 - 不执行任何操作\"\"\"\n    pass\n"
  },
  {
    "path": "lifetrace/migrations/versions/cff6e6d7a3cf_merge_heads_segment_timestamps_and_.py",
    "content": "\"\"\"merge heads: segment_timestamps and optimized_extraction\n\nRevision ID: cff6e6d7a3cf\nRevises: 034079ad387f, add_optimized_extraction_001\nCreate Date: 2026-01-23 20:34:00.629399\n\n\"\"\"\n\nfrom collections.abc import Sequence\n\n# revision identifiers, used by Alembic.\nrevision: str = \"cff6e6d7a3cf\"\ndown_revision: str | None = (\"034079ad387f\", \"add_optimized_extraction_001\")\nbranch_labels: str | Sequence[str] | None = None\ndepends_on: str | Sequence[str] | None = None\n\n\ndef upgrade() -> None:\n    pass\n\n\ndef downgrade() -> None:\n    pass\n"
  },
  {
    "path": "lifetrace/migrations/versions/d2f7a9c6b1a4_add_icalendar_fields_to_todos.py",
    "content": "\"\"\"add_icalendar_fields_to_todos\n\nRevision ID: d2f7a9c6b1a4\nRevises: add_text_hash_to_ocr_results\nCreate Date: 2026-01-29 23:30:00.000000\n\n为 todos 表添加 iCalendar 相关字段，并回填已有数据。\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom datetime import datetime\nfrom typing import TYPE_CHECKING\nfrom uuid import uuid4\n\nimport sqlalchemy as sa\nfrom alembic import op\n\nif TYPE_CHECKING:\n    from collections.abc import Sequence\n\n# revision identifiers, used by Alembic.\nrevision: str = \"d2f7a9c6b1a4\"\ndown_revision: str | None = \"add_text_hash_to_ocr_results\"\nbranch_labels: str | Sequence[str] | None = None\ndepends_on: str | Sequence[str] | None = None\n\n\ndef _add_missing_todo_columns(columns: set[str]) -> None:\n    with op.batch_alter_table(\"todos\", schema=None) as batch_op:\n        column_defs = {\n            \"uid\": sa.Column(\"uid\", sa.String(length=64), nullable=True),\n            \"completed_at\": sa.Column(\"completed_at\", sa.DateTime(), nullable=True),\n            \"percent_complete\": sa.Column(\"percent_complete\", sa.Integer(), nullable=True),\n            \"rrule\": sa.Column(\"rrule\", sa.String(length=500), nullable=True),\n        }\n        for name, column_def in column_defs.items():\n            if name not in columns:\n                batch_op.add_column(column_def)\n\n\ndef _ensure_todo_uid_index(inspector: sa.Inspector) -> None:\n    indexes = {idx[\"name\"] for idx in inspector.get_indexes(\"todos\")}\n    if \"idx_todos_uid\" not in indexes:\n        op.create_index(\"idx_todos_uid\", \"todos\", [\"uid\"], unique=False)\n\n\ndef _build_todo_updates(row: dict[str, object]) -> dict[str, object]:\n    updates: dict[str, object] = {}\n\n    uid = row.get(\"uid\")\n    if not uid:\n        updates[\"uid\"] = str(uuid4())\n\n    percent_complete = row.get(\"percent_complete\")\n    if percent_complete is None:\n        updates[\"percent_complete\"] = 100 if row.get(\"status\") == \"completed\" else 0\n\n    if row.get(\"status\") == \"completed\" and row.get(\"completed_at\") is None:\n        fallback = row.get(\"updated_at\") or row.get(\"created_at\")\n        if isinstance(fallback, datetime):\n            updates[\"completed_at\"] = fallback\n\n    return updates\n\n\ndef _backfill_todo_ical_fields(connection: sa.Connection) -> None:\n    result = connection.execute(\n        sa.text(\n            \"SELECT id, uid, status, completed_at, percent_complete, updated_at, created_at FROM todos\"\n        )\n    )\n    rows = result.mappings().all()\n\n    for row in rows:\n        updates = _build_todo_updates(dict(row))\n        if not updates:\n            continue\n\n        updates[\"id\"] = row[\"id\"]\n        sets = \", \".join([f\"{key} = :{key}\" for key in updates if key != \"id\"])\n        connection.execute(\n            sa.text(f\"UPDATE todos SET {sets} WHERE id = :id\"),  # nosec B608\n            updates,\n        )\n\n\ndef upgrade() -> None:\n    connection = op.get_bind()\n    inspector = sa.inspect(connection)\n\n    columns = {col[\"name\"] for col in inspector.get_columns(\"todos\")}\n    _add_missing_todo_columns(columns)\n    _ensure_todo_uid_index(inspector)\n    _backfill_todo_ical_fields(connection)\n\n\ndef downgrade() -> None:\n    connection = op.get_bind()\n    inspector = sa.inspect(connection)\n\n    indexes = {idx[\"name\"] for idx in inspector.get_indexes(\"todos\")}\n    if \"idx_todos_uid\" in indexes:\n        op.drop_index(\"idx_todos_uid\", table_name=\"todos\")\n\n    columns = {col[\"name\"] for col in inspector.get_columns(\"todos\")}\n    with op.batch_alter_table(\"todos\", schema=None) as batch_op:\n        if \"rrule\" in columns:\n            batch_op.drop_column(\"rrule\")\n        if \"percent_complete\" in columns:\n            batch_op.drop_column(\"percent_complete\")\n        if \"completed_at\" in columns:\n            batch_op.drop_column(\"completed_at\")\n        if \"uid\" in columns:\n            batch_op.drop_column(\"uid\")\n"
  },
  {
    "path": "lifetrace/migrations/versions/merge_automation_ical_001.py",
    "content": "\"\"\"merge_automation_ical_001\n\nRevision ID: merge_automation_ical_001\nRevises: merge_journal_uid_automation_20260204, add_icalendar_fields_v2_001\nCreate Date: 2026-02-06\n\nMerge heads for automation tasks and iCalendar fields.\n\"\"\"\n\nfrom collections.abc import Sequence\n\nfrom alembic import op\n\n# revision identifiers, used by Alembic.\nrevision: str = \"merge_automation_ical_001\"\ndown_revision: str | Sequence[str] | None = (\n    \"merge_journal_uid_automation_20260204\",\n    \"add_icalendar_fields_v2_001\",\n)\nbranch_labels: str | Sequence[str] | None = None\ndepends_on: str | Sequence[str] | None = None\n\n\ndef upgrade() -> None:\n    \"\"\"Merge heads - no schema changes.\"\"\"\n    op.execute(\"SELECT 1\")\n\n\ndef downgrade() -> None:\n    \"\"\"Merge heads - no schema changes.\"\"\"\n    op.execute(\"SELECT 1\")\n"
  },
  {
    "path": "lifetrace/migrations/versions/merge_heads_journal_todo_20260203.py",
    "content": "\"\"\"merge_heads_journal_todo_20260203\n\nRevision ID: merge_heads_journal_todo_20260203\nRevises: add_journal_panel_001, add_todo_attachment_source_001\nCreate Date: 2026-02-03\n\"\"\"\n\nfrom collections.abc import Sequence\n\nfrom alembic import op\n\n# revision identifiers, used by Alembic.\nrevision: str = \"merge_heads_journal_todo_20260203\"\ndown_revision: str | Sequence[str] | None = (\n    \"add_journal_panel_001\",\n    \"add_todo_attachment_source_001\",\n)\nbranch_labels: str | Sequence[str] | None = None\ndepends_on: str | Sequence[str] | None = None\n\n\ndef upgrade() -> None:\n    op.execute(\"SELECT 1\")\n\n\ndef downgrade() -> None:\n    op.execute(\"SELECT 1\")\n"
  },
  {
    "path": "lifetrace/migrations/versions/merge_heads_todos_20260131.py",
    "content": "\"\"\"merge heads: todos end_time and icalendar fields\n\nRevision ID: merge_heads_todos_20260131\nRevises: d2f7a9c6b1a4, add_todo_end_time_001\nCreate Date: 2026-01-31\n\n合并两个并行 head：\n- d2f7a9c6b1a4（todos 的 iCalendar 字段）\n- add_todo_end_time_001（todos 的 end_time 字段）\n\n该迁移仅用于合并 revision 图，不包含 schema 变更。\n\"\"\"\n\nfrom collections.abc import Sequence\n\n# revision identifiers, used by Alembic.\nrevision: str = \"merge_heads_todos_20260131\"\ndown_revision: str | Sequence[str] | None = (\"d2f7a9c6b1a4\", \"add_todo_end_time_001\")\nbranch_labels: str | Sequence[str] | None = None\ndepends_on: str | Sequence[str] | None = None\n\n\ndef upgrade() -> None:\n    pass\n\n\ndef downgrade() -> None:\n    pass\n"
  },
  {
    "path": "lifetrace/migrations/versions/merge_journal_uid_automation_20260204.py",
    "content": "\"\"\"merge_journal_uid_automation_20260204\n\nRevision ID: merge_journal_uid_automation_20260204\nRevises: b53d9b7c8e21, add_automation_tasks_001\nCreate Date: 2026-02-04\n\nMerge heads for journal UID and automation tasks.\n\"\"\"\n\nfrom collections.abc import Sequence\n\nfrom alembic import op\n\n# revision identifiers, used by Alembic.\nrevision: str = \"merge_journal_uid_automation_20260204\"\ndown_revision: str | Sequence[str] | None = (\n    \"b53d9b7c8e21\",\n    \"add_automation_tasks_001\",\n)\nbranch_labels: str | Sequence[str] | None = None\ndepends_on: str | Sequence[str] | None = None\n\n\ndef upgrade() -> None:\n    \"\"\"Merge heads - no schema changes.\"\"\"\n    op.execute(\"SELECT 1\")\n\n\ndef downgrade() -> None:\n    \"\"\"Merge heads - no schema changes.\"\"\"\n    op.execute(\"SELECT 1\")\n"
  },
  {
    "path": "lifetrace/migrations/versions/remove_project_task_tables.py",
    "content": "\"\"\"Remove project and task related tables\n\n删除项目管理相关的表和字段：\n- 删除 projects 表\n- 删除 tasks 表\n- 删除 task_progress 表\n- 删除 event_task_relations 表\n- 从 events 表中删除 task_id 和 auto_association_attempted 字段\n\nRevision ID: remove_project_task\nRevises: 4ca5036ec7c8\nCreate Date: 2025-01-07\n\"\"\"\n\nimport sqlalchemy as sa\nfrom alembic import op\n\n# revision identifiers, used by Alembic.\nrevision = \"remove_project_task\"\ndown_revision = \"4ca5036ec7c8\"\nbranch_labels = None\ndepends_on = None\n\n\ndef upgrade() -> None:\n    \"\"\"删除项目和任务相关的表和字段\"\"\"\n    connection = op.get_bind()\n    inspector = sa.inspect(connection)\n    existing_tables = inspector.get_table_names()\n\n    # 1. 删除 event_task_relations 表（如果存在）\n    if \"event_task_relations\" in existing_tables:\n        op.drop_table(\"event_task_relations\")\n\n    # 2. 删除 task_progress 表（如果存在）\n    if \"task_progress\" in existing_tables:\n        op.drop_table(\"task_progress\")\n\n    # 3. 删除 tasks 表（如果存在）\n    if \"tasks\" in existing_tables:\n        op.drop_table(\"tasks\")\n\n    # 4. 删除 projects 表（如果存在）\n    if \"projects\" in existing_tables:\n        op.drop_table(\"projects\")\n\n    # 5. 从 events 表中删除 task_id 和 auto_association_attempted 字段（如果存在）\n    if \"events\" in existing_tables:\n        columns = {col[\"name\"] for col in inspector.get_columns(\"events\")}\n        columns_to_drop = []\n        if \"task_id\" in columns:\n            columns_to_drop.append(\"task_id\")\n        if \"auto_association_attempted\" in columns:\n            columns_to_drop.append(\"auto_association_attempted\")\n        if columns_to_drop:\n            with op.batch_alter_table(\"events\") as batch_op:\n                for col in columns_to_drop:\n                    batch_op.drop_column(col)\n\n\ndef downgrade() -> None:\n    \"\"\"恢复项目和任务相关的表和字段（回滚用）\"\"\"\n    # 1. 恢复 events 表中的字段\n    with op.batch_alter_table(\"events\") as batch_op:\n        batch_op.add_column(sa.Column(\"task_id\", sa.Integer(), nullable=True))\n        batch_op.add_column(\n            sa.Column(\n                \"auto_association_attempted\", sa.Boolean(), nullable=False, server_default=\"0\"\n            )\n        )\n\n    # 2. 恢复 projects 表\n    op.create_table(\n        \"projects\",\n        sa.Column(\"id\", sa.Integer(), nullable=False, primary_key=True),\n        sa.Column(\"name\", sa.String(length=200), nullable=False),\n        sa.Column(\"definition_of_done\", sa.Text(), nullable=True),\n        sa.Column(\"status\", sa.String(length=20), nullable=False, server_default=\"active\"),\n        sa.Column(\"description\", sa.Text(), nullable=True),\n        sa.Column(\"created_at\", sa.DateTime(), nullable=False),\n        sa.Column(\"updated_at\", sa.DateTime(), nullable=False),\n        sa.Column(\"deleted_at\", sa.DateTime(), nullable=True),\n    )\n\n    # 3. 恢复 tasks 表\n    op.create_table(\n        \"tasks\",\n        sa.Column(\"id\", sa.Integer(), nullable=False, primary_key=True),\n        sa.Column(\"project_id\", sa.Integer(), nullable=False),\n        sa.Column(\"name\", sa.String(length=200), nullable=False),\n        sa.Column(\"description\", sa.Text(), nullable=True),\n        sa.Column(\"status\", sa.String(length=20), nullable=False, server_default=\"pending\"),\n        sa.Column(\"created_at\", sa.DateTime(), nullable=False),\n        sa.Column(\"updated_at\", sa.DateTime(), nullable=False),\n        sa.Column(\"deleted_at\", sa.DateTime(), nullable=True),\n    )\n\n    # 4. 恢复 task_progress 表\n    op.create_table(\n        \"task_progress\",\n        sa.Column(\"id\", sa.Integer(), nullable=False, primary_key=True),\n        sa.Column(\"task_id\", sa.Integer(), nullable=False),\n        sa.Column(\"summary\", sa.Text(), nullable=False),\n        sa.Column(\"context_count\", sa.Integer(), nullable=False, server_default=\"0\"),\n        sa.Column(\"generated_at\", sa.DateTime(), nullable=False),\n        sa.Column(\"created_at\", sa.DateTime(), nullable=False),\n        sa.Column(\"updated_at\", sa.DateTime(), nullable=False),\n        sa.Column(\"deleted_at\", sa.DateTime(), nullable=True),\n    )\n\n    # 5. 恢复 event_task_relations 表\n    op.create_table(\n        \"event_task_relations\",\n        sa.Column(\"id\", sa.Integer(), nullable=False, primary_key=True),\n        sa.Column(\"event_id\", sa.Integer(), nullable=False),\n        sa.Column(\"project_id\", sa.Integer(), nullable=True),\n        sa.Column(\"task_id\", sa.Integer(), nullable=True),\n        sa.Column(\"project_confidence\", sa.Float(), nullable=True),\n        sa.Column(\"task_confidence\", sa.Float(), nullable=True),\n        sa.Column(\"reasoning\", sa.Text(), nullable=True),\n        sa.Column(\"association_method\", sa.String(length=50), nullable=True),\n        sa.Column(\"used_in_summary\", sa.Boolean(), nullable=False, server_default=\"0\"),\n        sa.Column(\"created_at\", sa.DateTime(), nullable=False),\n        sa.Column(\"updated_at\", sa.DateTime(), nullable=False),\n        sa.Column(\"deleted_at\", sa.DateTime(), nullable=True),\n    )\n"
  },
  {
    "path": "lifetrace/observability/__init__.py",
    "content": "\"\"\"Observability 模块 - Phoenix + OpenInference 集成\n\n提供 Agent 运行的可观测性功能：\n- 本地 JSON 文件导出（Cursor 友好）\n- Phoenix UI 集成（可选）\n- Terminal 精简摘要输出\n\"\"\"\n\nfrom lifetrace.observability.setup import setup_observability\n\n__all__ = [\"setup_observability\"]\n"
  },
  {
    "path": "lifetrace/observability/config.py",
    "content": "\"\"\"Observability 配置类\n\n定义观测功能的配置结构和默认值。\n\"\"\"\n\nfrom dataclasses import dataclass, field\nfrom pathlib import Path\nfrom typing import Literal\n\nfrom lifetrace.util.base_paths import get_user_data_dir\nfrom lifetrace.util.settings import settings\n\n\n@dataclass\nclass LocalExporterConfig:\n    \"\"\"本地文件导出器配置\"\"\"\n\n    traces_dir: str = \"traces/\"\n    max_files: int = 100\n    pretty_print: bool = True\n\n\n@dataclass\nclass PhoenixConfig:\n    \"\"\"Phoenix 配置\"\"\"\n\n    endpoint: str = \"http://localhost:6006\"\n    project_name: str = \"freetodo-agent\"\n    export_timeout_sec: float = 2.0\n    disable_after_failures: int = 1\n    retry_cooldown_sec: float = 60.0\n\n\n@dataclass\nclass TerminalConfig:\n    \"\"\"Terminal 输出配置\"\"\"\n\n    summary_only: bool = True\n\n\n@dataclass\nclass ObservabilityConfig:\n    \"\"\"观测系统配置\"\"\"\n\n    enabled: bool = False\n    mode: Literal[\"local\", \"phoenix\", \"both\"] = \"both\"\n    local: LocalExporterConfig = field(default_factory=LocalExporterConfig)\n    phoenix: PhoenixConfig = field(default_factory=PhoenixConfig)\n    terminal: TerminalConfig = field(default_factory=TerminalConfig)\n\n\ndef get_observability_config() -> ObservabilityConfig:\n    \"\"\"从 settings 获取观测配置\n\n    Returns:\n        ObservabilityConfig: 观测配置对象\n    \"\"\"\n    obs_settings = settings.get(\"observability\", {})\n\n    # 如果配置不存在或为空，返回默认配置（禁用状态）\n    if not obs_settings:\n        return ObservabilityConfig()\n\n    # 解析 local 配置\n    local_settings = obs_settings.get(\"local\", {})\n    local_config = LocalExporterConfig(\n        traces_dir=local_settings.get(\"traces_dir\", \"traces/\"),\n        max_files=local_settings.get(\"max_files\", 100),\n        pretty_print=local_settings.get(\"pretty_print\", True),\n    )\n\n    # 解析 phoenix 配置\n    phoenix_settings = obs_settings.get(\"phoenix\", {})\n    phoenix_config = PhoenixConfig(\n        endpoint=phoenix_settings.get(\"endpoint\", \"http://localhost:6006\"),\n        project_name=phoenix_settings.get(\"project_name\", \"freetodo-agent\"),\n        export_timeout_sec=phoenix_settings.get(\"export_timeout_sec\", 2.0),\n        disable_after_failures=phoenix_settings.get(\"disable_after_failures\", 1),\n        retry_cooldown_sec=phoenix_settings.get(\"retry_cooldown_sec\", 60.0),\n    )\n\n    # 解析 terminal 配置\n    terminal_settings = obs_settings.get(\"terminal\", {})\n    terminal_config = TerminalConfig(\n        summary_only=terminal_settings.get(\"summary_only\", True),\n    )\n\n    return ObservabilityConfig(\n        enabled=obs_settings.get(\"enabled\", False),\n        mode=obs_settings.get(\"mode\", \"both\"),\n        local=local_config,\n        phoenix=phoenix_config,\n        terminal=terminal_config,\n    )\n\n\ndef get_traces_directory() -> Path:\n    \"\"\"获取 traces 目录的完整路径\n\n    Returns:\n        Path: traces 目录路径\n    \"\"\"\n    config = get_observability_config()\n    data_dir = get_user_data_dir()\n    traces_dir = data_dir / config.local.traces_dir\n    traces_dir.mkdir(parents=True, exist_ok=True)\n    return traces_dir\n"
  },
  {
    "path": "lifetrace/observability/exporters/__init__.py",
    "content": "\"\"\"Observability Exporters 模块\n\n提供各种 trace 导出器实现。\n\"\"\"\n\nfrom lifetrace.observability.exporters.file_exporter import LocalFileExporter\nfrom lifetrace.observability.exporters.phoenix_exporter import PhoenixCircuitBreakerExporter\n\n__all__ = [\"LocalFileExporter\", \"PhoenixCircuitBreakerExporter\"]\n"
  },
  {
    "path": "lifetrace/observability/exporters/file_exporter.py",
    "content": "\"\"\"本地文件导出器\n\n将 OpenTelemetry spans 转换为可读的 JSON 格式并写入本地文件。\n设计目标：\n- Cursor 友好：结构化 JSON，便于 AI 分析\n- 人类可读：格式化输出，清晰的字段命名\n- 日志精简：Terminal 只输出一行摘要\n- 按会话聚合：同一 session 的所有 trace 保存在同一个文件中\n\"\"\"\n\nfrom __future__ import annotations\n\nimport contextlib\nimport importlib\nimport json\nimport os\nimport threading\nfrom collections import defaultdict\nfrom datetime import UTC, datetime\nfrom typing import TYPE_CHECKING, Any\n\nfrom opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult\n\nfrom lifetrace.util.base_paths import get_user_data_dir\nfrom lifetrace.util.logging_config import get_logger\n\nif TYPE_CHECKING:\n    from collections.abc import Sequence\n    from pathlib import Path\n\n    from opentelemetry.sdk.trace import ReadableSpan\n\nlogger = get_logger()\n\n\ndef _get_current_session_id() -> str | None:\n    \"\"\"获取当前 session_id（从 ContextVar 读取）\"\"\"\n    try:\n        agno_module = importlib.import_module(\"lifetrace.llm.agno_agent\")\n        return agno_module.current_session_id.get()\n    except Exception:\n        return None\n\n\n# OpenInference 语义约定中的常用属性\nOPENINFERENCE_SPAN_KIND = \"openinference.span.kind\"\nOPENINFERENCE_INPUT_VALUE = \"input.value\"\nOPENINFERENCE_OUTPUT_VALUE = \"output.value\"\nOPENINFERENCE_LLM_MODEL_NAME = \"llm.model_name\"\nOPENINFERENCE_LLM_INPUT_MESSAGES = \"llm.input_messages\"\nOPENINFERENCE_LLM_OUTPUT_MESSAGES = \"llm.output_messages\"\nOPENINFERENCE_LLM_TOKEN_COUNT_PROMPT = \"llm.token_count.prompt\"  # nosec B105\nOPENINFERENCE_LLM_TOKEN_COUNT_COMPLETION = \"llm.token_count.completion\"  # nosec B105\nOPENINFERENCE_TOOL_NAME = \"tool.name\"\nOPENINFERENCE_TOOL_PARAMETERS = \"tool.parameters\"\n\n\ndef _coerce_int(value: Any) -> int:\n    try:\n        return int(value)\n    except (TypeError, ValueError):\n        return 0\n\n\nclass LocalFileExporter(SpanExporter):\n    \"\"\"本地 JSON 文件导出器\n\n    将 traces 写入本地 JSON 文件，支持：\n    - 按 session_id 聚合：同一会话的所有 trace 保存在同一个文件中\n    - 按 trace_id 聚合 spans（当无 session_id 时）\n    - 格式化输出便于阅读\n    - Terminal 摘要输出\n    - 自动清理旧文件\n    \"\"\"\n\n    def __init__(\n        self,\n        traces_dir: str = \"traces/\",\n        max_files: int = 100,\n        pretty_print: bool = True,\n        summary_only: bool = True,\n    ):\n        \"\"\"初始化导出器\n\n        Args:\n            traces_dir: trace 文件存储目录（相对于 base_dir）\n            max_files: 最大保留文件数\n            pretty_print: 是否格式化 JSON 输出\n            summary_only: Terminal 是否只输出摘要\n        \"\"\"\n        self.traces_dir = traces_dir\n        self.max_files = max_files\n        self.pretty_print = pretty_print\n        self.summary_only = summary_only\n        self._lock = threading.Lock()\n\n        # 用于聚合同一 trace 的 spans\n        self._pending_traces: dict[str, list[ReadableSpan]] = defaultdict(list)\n\n        # session_id -> 文件路径的映射（内存缓存）\n        self._session_files: dict[str, Path] = {}\n\n    def _get_traces_path(self) -> Path:\n        \"\"\"获取 traces 目录路径\"\"\"\n        traces_path = get_user_data_dir() / self.traces_dir\n        traces_path.mkdir(parents=True, exist_ok=True)\n        return traces_path\n\n    def _get_session_file_path(self, session_id: str) -> Path:\n        \"\"\"获取 session 文件路径\n\n        如果已有该 session 的文件，返回现有路径；否则创建新路径。\n        文件名格式：session_{session_id}_{创建时间}.json\n        \"\"\"\n        # 检查内存缓存\n        if session_id in self._session_files:\n            return self._session_files[session_id]\n\n        traces_path = self._get_traces_path()\n\n        # 查找已有的 session 文件\n        existing_files = list(traces_path.glob(f\"session_{session_id}_*.json\"))\n        if existing_files:\n            # 使用最新的文件\n            filepath = max(existing_files, key=lambda f: f.stat().st_mtime)\n            self._session_files[session_id] = filepath\n            return filepath\n\n        # 创建新文件路径\n        timestamp = datetime.now(UTC).strftime(\"%Y%m%d_%H%M%S\")\n        filename = f\"session_{session_id}_{timestamp}.json\"\n        filepath = traces_path / filename\n        self._session_files[session_id] = filepath\n        return filepath\n\n    def _load_session_data(self, filepath: Path) -> dict[str, Any]:\n        \"\"\"加载已有的 session 数据\"\"\"\n        if not filepath.exists():\n            return {}\n        try:\n            with open(filepath, encoding=\"utf-8\") as f:\n                return json.load(f)\n        except (json.JSONDecodeError, OSError) as e:\n            logger.warning(f\"加载 session 文件失败: {e}\")\n            return {}\n\n    def _save_session_data(self, filepath: Path, data: dict[str, Any]) -> bool:\n        \"\"\"保存 session 数据\"\"\"\n        try:\n            with open(filepath, \"w\", encoding=\"utf-8\") as f:\n                if self.pretty_print:\n                    json.dump(data, f, ensure_ascii=False, indent=2)\n                else:\n                    json.dump(data, f, ensure_ascii=False)\n            return True\n        except OSError as e:\n            logger.error(f\"保存 session 文件失败: {e}\")\n            return False\n\n    def _extract_span_kind(self, span: ReadableSpan) -> str:\n        \"\"\"提取 span 类型\"\"\"\n        attrs = dict(span.attributes or {})\n        return str(attrs.get(OPENINFERENCE_SPAN_KIND, span.name))\n\n    def _extract_tool_call(self, span: ReadableSpan) -> dict[str, Any] | None:\n        \"\"\"从 span 提取工具调用信息\"\"\"\n        attrs = dict(span.attributes or {})\n        span_kind = attrs.get(OPENINFERENCE_SPAN_KIND, \"\")\n\n        if span_kind != \"TOOL\" and \"tool\" not in span.name.lower():\n            return None\n\n        tool_name = attrs.get(OPENINFERENCE_TOOL_NAME, span.name)\n        tool_params = attrs.get(OPENINFERENCE_TOOL_PARAMETERS, \"{}\")\n\n        # 尝试解析参数\n        try:\n            args = json.loads(tool_params) if isinstance(tool_params, str) else tool_params\n        except (json.JSONDecodeError, TypeError):\n            args = {\"raw\": str(tool_params)}\n\n        # 获取结果\n        output = attrs.get(OPENINFERENCE_OUTPUT_VALUE, \"\")\n        result_preview = str(output)[:200] if output else \"\"\n\n        # 计算持续时间\n        duration_ms = 0\n        if span.start_time and span.end_time:\n            duration_ms = (span.end_time - span.start_time) / 1_000_000  # ns -> ms\n\n        return {\n            \"name\": str(tool_name),\n            \"args\": args,\n            \"result_preview\": result_preview,\n            \"duration_ms\": round(duration_ms, 2),\n        }\n\n    def _extract_llm_call(self, span: ReadableSpan) -> dict[str, Any] | None:\n        \"\"\"从 span 提取 LLM 调用信息\"\"\"\n        attrs = dict(span.attributes or {})\n        span_kind = attrs.get(OPENINFERENCE_SPAN_KIND, \"\")\n\n        if span_kind != \"LLM\" and \"llm\" not in span.name.lower():\n            return None\n\n        model = attrs.get(OPENINFERENCE_LLM_MODEL_NAME, \"unknown\")\n        input_tokens = _coerce_int(attrs.get(OPENINFERENCE_LLM_TOKEN_COUNT_PROMPT, 0))\n        output_tokens = _coerce_int(attrs.get(OPENINFERENCE_LLM_TOKEN_COUNT_COMPLETION, 0))\n\n        # 计算持续时间\n        duration_ms = 0\n        if span.start_time and span.end_time:\n            duration_ms = (span.end_time - span.start_time) / 1_000_000\n\n        return {\n            \"model\": str(model),\n            \"input_tokens\": input_tokens,\n            \"output_tokens\": output_tokens,\n            \"duration_ms\": round(duration_ms, 2),\n        }\n\n    def _aggregate_spans(self, spans: Sequence[ReadableSpan]) -> dict[str, Any]:\n        \"\"\"将 spans 聚合为结构化的 trace 数据\n\n        Args:\n            spans: OpenTelemetry spans 列表\n\n        Returns:\n            聚合后的 trace 数据字典\n        \"\"\"\n        if not spans:\n            return {}\n\n        # 获取基本信息\n        first_span = spans[0]\n        trace_id = format(first_span.context.trace_id, \"032x\") if first_span.context else \"unknown\"\n\n        # 找到根 span（通常是 agent 运行）\n        root_span = None\n        for span in spans:\n            if span.parent is None:\n                root_span = span\n                break\n        if root_span is None:\n            root_span = first_span\n\n        # 提取输入输出\n        root_attrs = dict(root_span.attributes or {})\n        input_value = root_attrs.get(OPENINFERENCE_INPUT_VALUE, \"\")\n        output_value = root_attrs.get(OPENINFERENCE_OUTPUT_VALUE, \"\")\n\n        # 计算总持续时间\n        start_time = min(s.start_time for s in spans if s.start_time)\n        end_time = max(s.end_time for s in spans if s.end_time)\n        total_duration_ms = (end_time - start_time) / 1_000_000 if start_time and end_time else 0\n\n        # 提取工具调用和 LLM 调用\n        tool_calls = []\n        llm_calls = []\n        for span in spans:\n            tool_call = self._extract_tool_call(span)\n            if tool_call:\n                tool_calls.append(tool_call)\n\n            llm_call = self._extract_llm_call(span)\n            if llm_call:\n                llm_calls.append(llm_call)\n\n        # 确定状态\n        status = \"success\"\n        for span in spans:\n            if span.status and span.status.is_ok is False:\n                status = \"error\"\n                break\n\n        # 生成时间戳\n        timestamp = datetime.now(UTC).isoformat()\n\n        return {\n            \"trace_id\": trace_id[:12],  # 使用短 ID\n            \"timestamp\": timestamp,\n            \"duration_ms\": round(total_duration_ms, 2),\n            \"agent\": root_span.name,\n            \"input\": str(input_value)[:500] if input_value else \"\",\n            \"output_preview\": str(output_value)[:500] if output_value else \"\",\n            \"tool_calls\": tool_calls,\n            \"llm_calls\": llm_calls,\n            \"status\": status,\n            \"span_count\": len(spans),\n        }\n\n    def _write_to_file(\n        self, trace_data: dict[str, Any], session_id: str | None = None\n    ) -> str | None:\n        \"\"\"将 trace 数据写入 JSON 文件\n\n        Args:\n            trace_data: 聚合后的 trace 数据\n            session_id: 会话 ID，如果提供则追加到 session 文件\n\n        Returns:\n            写入的文件路径，失败返回 None\n        \"\"\"\n        if not trace_data:\n            return None\n\n        # 如果有 session_id，追加到 session 文件\n        if session_id:\n            return self._write_to_session_file(trace_data, session_id)\n\n        # 否则，每个 trace 一个文件（原有行为）\n        traces_path = self._get_traces_path()\n        trace_id = trace_data.get(\"trace_id\", \"unknown\")\n        timestamp = datetime.now(UTC).strftime(\"%Y%m%d_%H%M%S\")\n        filename = f\"{timestamp}_{trace_id}.json\"\n        filepath = traces_path / filename\n\n        try:\n            with open(filepath, \"w\", encoding=\"utf-8\") as f:\n                if self.pretty_print:\n                    json.dump(trace_data, f, ensure_ascii=False, indent=2)\n                else:\n                    json.dump(trace_data, f, ensure_ascii=False)\n\n            # 清理旧文件\n            self._cleanup_old_files(traces_path)\n\n            return str(filepath)\n        except Exception as e:\n            logger.error(f\"写入 trace 文件失败: {e}\")\n            return None\n\n    def _write_to_session_file(self, trace_data: dict[str, Any], session_id: str) -> str | None:\n        \"\"\"将 trace 数据追加到 session 文件\n\n        Session 文件格式：\n        {\n            \"session_id\": \"xxx\",\n            \"created_at\": \"2026-01-23T08:27:03Z\",\n            \"updated_at\": \"2026-01-23T08:30:00Z\",\n            \"traces\": [...],\n            \"summary\": {...}\n        }\n        \"\"\"\n        filepath = self._get_session_file_path(session_id)\n\n        try:\n            # 加载已有数据或创建新结构\n            session_data = self._load_session_data(filepath)\n            if not session_data:\n                session_data = {\n                    \"session_id\": session_id,\n                    \"created_at\": datetime.now(UTC).isoformat(),\n                    \"traces\": [],\n                    \"summary\": {\n                        \"total_duration_ms\": 0,\n                        \"tool_count\": 0,\n                        \"llm_count\": 0,\n                        \"trace_count\": 0,\n                        \"status\": \"success\",\n                    },\n                }\n\n            # 追加 trace\n            session_data[\"traces\"].append(trace_data)\n            session_data[\"updated_at\"] = datetime.now(UTC).isoformat()\n\n            # 更新摘要\n            summary = session_data[\"summary\"]\n            summary[\"total_duration_ms\"] += trace_data.get(\"duration_ms\", 0)\n            summary[\"tool_count\"] += len(trace_data.get(\"tool_calls\", []))\n            summary[\"llm_count\"] += len(trace_data.get(\"llm_calls\", []))\n            summary[\"trace_count\"] = len(session_data[\"traces\"])\n            if trace_data.get(\"status\") == \"error\":\n                summary[\"status\"] = \"error\"\n\n            # 保存\n            if self._save_session_data(filepath, session_data):\n                # 清理旧文件\n                self._cleanup_old_files(self._get_traces_path())\n                return str(filepath)\n            return None\n        except Exception as e:\n            logger.error(f\"写入 session 文件失败: {e}\")\n            return None\n\n    def _cleanup_old_files(self, traces_path: Path) -> None:\n        \"\"\"清理超出限制的旧文件\n\n        Args:\n            traces_path: traces 目录路径\n        \"\"\"\n        try:\n            json_files = sorted(\n                traces_path.glob(\"*.json\"),\n                key=lambda f: f.stat().st_mtime,\n                reverse=True,\n            )\n\n            if len(json_files) > self.max_files:\n                for old_file in json_files[self.max_files :]:\n                    with contextlib.suppress(OSError):\n                        old_file.unlink()\n        except Exception as e:\n            logger.debug(f\"清理旧文件失败: {e}\")\n\n    def _print_summary(\n        self,\n        trace_data: dict[str, Any],\n        filepath: str | None,\n        session_id: str | None = None,\n    ) -> None:\n        \"\"\"输出 Terminal 摘要\n\n        Args:\n            trace_data: trace 数据\n            filepath: 文件路径\n            session_id: 会话 ID\n        \"\"\"\n        if not trace_data:\n            return\n\n        trace_id = trace_data.get(\"trace_id\", \"unknown\")\n        tool_count = len(trace_data.get(\"tool_calls\", []))\n        llm_count = len(trace_data.get(\"llm_calls\", []))\n        duration_ms = trace_data.get(\"duration_ms\", 0)\n        duration_s = duration_ms / 1000\n\n        # 获取相对路径用于显示\n        if filepath:\n            try:\n                rel_path = os.path.relpath(filepath, get_user_data_dir())\n            except Exception:\n                rel_path = filepath\n        else:\n            rel_path = \"N/A\"\n\n        # 构建摘要信息\n        parts = [f\"[Trace] {trace_id}\"]\n        if session_id:\n            parts.append(f\"session:{session_id[:8]}\")\n        parts.append(f\"{tool_count} tools\")\n        if llm_count > 0:\n            parts.append(f\"{llm_count} llm\")\n        parts.append(f\"{duration_s:.2f}s\")\n        parts.append(rel_path)\n\n        if self.summary_only:\n            # 精简输出：一行摘要\n            logger.info(\" | \".join(parts))\n        else:\n            # 详细输出\n            logger.info(f\"[Trace] {trace_id}\")\n            if session_id:\n                logger.info(f\"  Session: {session_id}\")\n            logger.info(f\"  Duration: {duration_s:.2f}s\")\n            logger.info(f\"  Tools: {tool_count}\")\n            logger.info(f\"  LLM calls: {llm_count}\")\n            logger.info(f\"  File: {rel_path}\")\n            for tool in trace_data.get(\"tool_calls\", []):\n                logger.info(f\"    - {tool['name']}: {tool.get('duration_ms', 0):.0f}ms\")\n            for llm in trace_data.get(\"llm_calls\", []):\n                logger.info(\n                    f\"    - LLM({llm.get('model', 'unknown')}): {llm.get('duration_ms', 0):.0f}ms\"\n                )\n\n    def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:\n        \"\"\"导出 spans\n\n        Args:\n            spans: 要导出的 spans\n\n        Returns:\n            导出结果\n        \"\"\"\n        if not spans:\n            return SpanExportResult.SUCCESS\n\n        # 获取当前 session_id（在处理 spans 时获取，因为 ContextVar 可能在之后被重置）\n        session_id = _get_current_session_id()\n\n        with self._lock:\n            try:\n                # 按 trace_id 分组\n                traces: dict[str, list[ReadableSpan]] = defaultdict(list)\n                for span in spans:\n                    if span.context:\n                        trace_id = format(span.context.trace_id, \"032x\")\n                        traces[trace_id].append(span)\n\n                # 处理每个 trace\n                for trace_id, trace_spans in traces.items():\n                    # 检查是否有根 span（表示 trace 完成）\n                    has_root = any(s.parent is None for s in trace_spans)\n\n                    if has_root:\n                        # 合并之前缓存的 spans\n                        all_spans = self._pending_traces.pop(trace_id, []) + trace_spans\n\n                        # 聚合并写入\n                        trace_data = self._aggregate_spans(all_spans)\n                        if trace_data:\n                            filepath = self._write_to_file(trace_data, session_id)\n                            self._print_summary(trace_data, filepath, session_id)\n                    else:\n                        # 缓存非根 spans，等待完整 trace\n                        self._pending_traces[trace_id].extend(trace_spans)\n\n                return SpanExportResult.SUCCESS\n            except Exception as e:\n                logger.error(f\"导出 spans 失败: {e}\")\n                return SpanExportResult.FAILURE\n\n    def shutdown(self) -> None:\n        \"\"\"关闭导出器，处理剩余的 spans\"\"\"\n        with self._lock:\n            # 导出所有缓存的 traces（shutdown 时无法获取 session_id，使用独立文件）\n            for _trace_id, spans in self._pending_traces.items():\n                if spans:\n                    trace_data = self._aggregate_spans(spans)\n                    if trace_data:\n                        filepath = self._write_to_file(trace_data, session_id=None)\n                        self._print_summary(trace_data, filepath, session_id=None)\n            self._pending_traces.clear()\n            self._session_files.clear()\n\n    def force_flush(self, timeout_millis: int = 30000) -> bool:\n        \"\"\"强制刷新\n\n        Args:\n            timeout_millis: 超时时间（毫秒）\n\n        Returns:\n            是否成功\n        \"\"\"\n        # 对于文件导出器，export 已经是同步的，不需要特殊处理\n        _ = timeout_millis\n        return True\n"
  },
  {
    "path": "lifetrace/observability/exporters/phoenix_exporter.py",
    "content": "\"\"\"Phoenix 导出器包装器\n\n为 OTLPSpanExporter 增加失败抑制与自动恢复逻辑，避免 Phoenix 未启动时刷屏报错。\n\"\"\"\n\nfrom __future__ import annotations\n\nimport socket\nimport threading\nimport time\nfrom typing import TYPE_CHECKING\nfrom urllib.parse import urlparse\n\nfrom opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult\n\nfrom lifetrace.util.logging_config import get_logger\n\nif TYPE_CHECKING:\n    from collections.abc import Sequence\n\n    from opentelemetry.sdk.trace import ReadableSpan\n\nlogger = get_logger()\n\n\nclass PhoenixCircuitBreakerExporter(SpanExporter):\n    \"\"\"Phoenix 导出器包装器（熔断 + 自动恢复）\"\"\"\n\n    def __init__(\n        self,\n        exporter: SpanExporter,\n        endpoint: str,\n        disable_after_failures: int = 1,\n        retry_cooldown_sec: float = 60.0,\n    ) -> None:\n        self._exporter = exporter\n        self._endpoint = endpoint\n        self._disable_after_failures = int(disable_after_failures)\n        self._retry_cooldown_sec = float(retry_cooldown_sec)\n        self._lock = threading.Lock()\n        self._consecutive_failures = 0\n        self._disabled_until = 0.0\n        self._disabled = False\n        self._has_logged_first_failure = False\n\n    def _should_skip_export(self) -> bool:\n        now = time.monotonic()\n        with self._lock:\n            if not self._disabled:\n                return False\n\n            # 禁用且不重试\n            if self._retry_cooldown_sec <= 0:\n                return True\n\n            # Phoenix 已启动时立即恢复（不等冷却期）\n            if self._endpoint_is_reachable():\n                self._disabled = False\n                self._consecutive_failures = 0\n                self._has_logged_first_failure = False\n                self._disabled_until = 0.0\n                logger.info(f\"Observability: Phoenix 导出已恢复 -> {self._endpoint}\")\n                return False\n\n            # 仍在冷却期\n            if now < self._disabled_until:\n                return True\n\n            # 冷却期结束，尝试恢复\n            self._disabled = False\n            self._consecutive_failures = 0\n            self._has_logged_first_failure = False\n            logger.info(f\"Observability: Phoenix 导出尝试恢复 -> {self._endpoint}\")\n            return False\n\n    def _handle_success(self) -> None:\n        with self._lock:\n            if self._consecutive_failures > 0 or self._disabled:\n                logger.info(f\"Observability: Phoenix 导出已恢复 -> {self._endpoint}\")\n            self._consecutive_failures = 0\n            self._disabled = False\n            self._disabled_until = 0.0\n            self._has_logged_first_failure = False\n\n    def _handle_failure(self, error: Exception | None) -> None:\n        with self._lock:\n            self._consecutive_failures += 1\n\n            if not self._has_logged_first_failure:\n                msg = f\"Observability: Phoenix 导出失败 -> {self._endpoint}\"\n                if error is not None:\n                    msg = f\"{msg} ({error})\"\n                logger.warning(msg)\n                self._has_logged_first_failure = True\n\n            if self._disable_after_failures <= 0:\n                return\n\n            if self._consecutive_failures >= self._disable_after_failures and not self._disabled:\n                self._disabled = True\n                if self._retry_cooldown_sec > 0:\n                    self._disabled_until = time.monotonic() + self._retry_cooldown_sec\n                    logger.warning(\n                        \"Observability: Phoenix 导出已暂停，\"\n                        f\"{self._retry_cooldown_sec:.0f}s 后自动重试 -> {self._endpoint}\"\n                    )\n                else:\n                    self._disabled_until = float(\"inf\")\n                    logger.warning(\n                        \"Observability: Phoenix 导出已暂停，\"\n                        f\"需手动重启或开启 Phoenix -> {self._endpoint}\"\n                    )\n\n    def _endpoint_is_reachable(self) -> bool:\n        try:\n            parsed = urlparse(self._endpoint)\n            host = parsed.hostname\n            port = parsed.port\n            if not host:\n                return False\n            if port is None:\n                port = 443 if parsed.scheme == \"https\" else 80\n            with socket.create_connection((host, port), timeout=0.3):\n                return True\n        except OSError:\n            return False\n\n    def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:\n        if not spans:\n            return SpanExportResult.SUCCESS\n\n        if self._should_skip_export():\n            return SpanExportResult.FAILURE\n\n        try:\n            result = self._exporter.export(spans)\n        except Exception as e:\n            self._handle_failure(e)\n            return SpanExportResult.FAILURE\n\n        if result == SpanExportResult.SUCCESS:\n            self._handle_success()\n        else:\n            self._handle_failure(None)\n        return result\n\n    def shutdown(self) -> None:\n        try:\n            self._exporter.shutdown()\n        except Exception as e:\n            logger.warning(f\"Observability: Phoenix 导出器关闭失败: {e}\")\n\n    def force_flush(self, timeout_millis: int = 30000) -> bool:\n        try:\n            return self._exporter.force_flush(timeout_millis)\n        except Exception:\n            return False\n"
  },
  {
    "path": "lifetrace/observability/setup.py",
    "content": "\"\"\"Observability 初始化模块\n\n负责设置 OpenTelemetry tracing 和 instrumentors。\n\"\"\"\n\nfrom __future__ import annotations\n\nimport importlib\nimport logging\nimport threading\nimport warnings\nfrom typing import Any, cast\n\nfrom lifetrace.observability.config import get_observability_config\nfrom lifetrace.util.logging_config import get_logger\n\nlogger = get_logger()\n\n\ndef _suppress_otel_context_warnings():\n    \"\"\"抑制 OpenTelemetry context detach 警告\n\n    这些警告在异步/生成器环境中是正常的，不影响功能。\n    警告来源：OpenTelemetry 的 context detach 在流式/生成器模式下\n    会因为 context 跨越不同的异步边界而触发。\n    \"\"\"\n\n    # 1. 过滤 logging 模块的警告\n    class ContextDetachFilter(logging.Filter):\n        def filter(self, record: logging.LogRecord) -> bool:\n            msg = record.getMessage()\n            if \"Failed to detach context\" in msg:\n                return False\n            return \"was created in a different Context\" not in msg\n\n    # 应用到 OpenTelemetry 相关的 logger\n    for logger_name in [\"opentelemetry\", \"opentelemetry.context\"]:\n        otel_logger = logging.getLogger(logger_name)\n        otel_logger.addFilter(ContextDetachFilter())\n\n    # 2. 过滤 warnings 模块\n    warnings.filterwarnings(\"ignore\", message=\".*was created in a different Context.*\")\n\n    # 3. 重定向 OpenTelemetry 的 stderr 输出（它直接打印到 stderr）\n    # 通过 monkey-patch OpenTelemetry 的 detach 函数来抑制警告\n    try:\n        otel_context = importlib.import_module(\"opentelemetry.context\")\n        otel_context_any = cast(\"Any\", otel_context)\n        _original_detach = otel_context_any.detach\n\n        def _silent_detach(token):\n            \"\"\"静默版本的 detach，捕获并忽略 context 错误\"\"\"\n            try:\n                return _original_detach(token)\n            except ValueError as e:\n                if \"was created in a different Context\" in str(e):\n                    pass  # 静默忽略这个已知问题\n                else:\n                    raise\n\n        if hasattr(otel_context, \"detach\"):\n            otel_context_any.detach = _silent_detach\n\n    except Exception as e:\n        logger.debug(f\"Observability: patch OTel context 失败: {e}\")\n\n\n# 全局初始化标志，确保只初始化一次\n_initialized = threading.Event()\n_init_lock = threading.Lock()\n\n\ndef _try_create_phoenix_exporter(config):\n    \"\"\"创建 Phoenix 导出器，失败时返回 (None, None)。\"\"\"\n    try:\n        exporter_module = importlib.import_module(\n            \"opentelemetry.exporter.otlp.proto.http.trace_exporter\"\n        )\n        otlp_span_exporter_class = exporter_module.OTLPSpanExporter\n    except ImportError:\n        logger.warning(\"Phoenix 导出器依赖未安装，跳过 Phoenix 集成\")\n        return None, None\n\n    try:\n        phoenix_endpoint = f\"{config.phoenix.endpoint}/v1/traces\"\n        phoenix_module = importlib.import_module(\n            \"lifetrace.observability.exporters.phoenix_exporter\"\n        )\n        phoenix_circuit_breaker_exporter_class = phoenix_module.PhoenixCircuitBreakerExporter\n\n        exporter = otlp_span_exporter_class(\n            endpoint=phoenix_endpoint,\n            timeout=config.phoenix.export_timeout_sec,\n        )\n        safe_exporter = phoenix_circuit_breaker_exporter_class(\n            exporter=exporter,\n            endpoint=phoenix_endpoint,\n            disable_after_failures=config.phoenix.disable_after_failures,\n            retry_cooldown_sec=config.phoenix.retry_cooldown_sec,\n        )\n        return safe_exporter, phoenix_endpoint\n    except Exception as e:\n        logger.warning(f\"Phoenix 导出器初始化失败: {e}\")\n        return None, None\n\n\ndef _setup_phoenix_exporter(tracer_provider, config) -> None:\n    \"\"\"设置 Phoenix 导出器\"\"\"\n    phoenix_exporter, phoenix_endpoint = _try_create_phoenix_exporter(config)\n    if phoenix_exporter is None or phoenix_endpoint is None:\n        return\n\n    exporter_module = importlib.import_module(\"opentelemetry.sdk.trace.export\")\n    simple_span_processor_class = exporter_module.SimpleSpanProcessor\n    tracer_provider.add_span_processor(simple_span_processor_class(phoenix_exporter))\n    logger.info(\n        \"Observability: Phoenix 导出已启用 \"\n        f\"(failures={config.phoenix.disable_after_failures}, \"\n        f\"cooldown={config.phoenix.retry_cooldown_sec:.0f}s) -> {phoenix_endpoint}\"\n    )\n\n\ndef _setup_agno_instrumentor() -> None:\n    \"\"\"设置 Agno Instrumentor\"\"\"\n    try:\n        agno_module = importlib.import_module(\"openinference.instrumentation.agno\")\n        agno_instrumentor_class = agno_module.AgnoInstrumentor\n        agno_instrumentor_class().instrument()\n        logger.info(\"Observability: Agno Instrumentor 已启用\")\n    except ImportError:\n        logger.warning(\"AgnoInstrumentor 未安装，跳过自动 instrument\")\n    except Exception as e:\n        logger.warning(f\"AgnoInstrumentor 初始化失败: {e}\")\n\n\ndef _setup_openai_instrumentor() -> None:\n    \"\"\"设置 OpenAI Instrumentor\n\n    用于追踪 Tool 内部的 LLM 调用，如 breakdown_task 中的 stream_chat。\n    这样可以在 Phoenix 中看到：\n    - Tool 调用的总耗时\n    - 内部 LLM 调用的详细信息（模型、token、延迟等）\n    \"\"\"\n    try:\n        openai_module = importlib.import_module(\"openinference.instrumentation.openai\")\n        openai_instrumentor_class = openai_module.OpenAIInstrumentor\n        openai_instrumentor_class().instrument()\n        logger.info(\"Observability: OpenAI Instrumentor 已启用\")\n    except ImportError:\n        logger.warning(\"OpenAIInstrumentor 未安装，跳过 OpenAI 自动 instrument\")\n    except Exception as e:\n        logger.warning(f\"OpenAIInstrumentor 初始化失败: {e}\")\n\n\ndef setup_observability() -> bool:\n    \"\"\"初始化观测系统\n\n    根据配置设置 OpenTelemetry tracing，支持：\n    - local: 本地 JSON 文件导出\n    - phoenix: Phoenix UI 导出\n    - both: 同时启用两者\n\n    Returns:\n        bool: 是否成功初始化\n    \"\"\"\n    with _init_lock:\n        if _initialized.is_set():\n            return True\n\n        config = get_observability_config()\n        if not config.enabled:\n            logger.debug(\"Observability 已禁用\")\n            return False\n\n        # 抑制 OTel context 警告（在异步环境中是正常的）\n        _suppress_otel_context_warnings()\n\n        try:\n            trace_api = importlib.import_module(\"opentelemetry.trace\")\n            trace_sdk = importlib.import_module(\"opentelemetry.sdk.trace\")\n            exporter_module = importlib.import_module(\"opentelemetry.sdk.trace.export\")\n            batch_span_processor_class = exporter_module.BatchSpanProcessor\n\n            local_exporter_module = importlib.import_module(\n                \"lifetrace.observability.exporters.file_exporter\"\n            )\n            local_file_exporter_class = local_exporter_module.LocalFileExporter\n\n            tracer_provider = trace_sdk.TracerProvider()\n\n            # 本地文件导出\n            if config.mode in (\"local\", \"both\"):\n                file_exporter = local_file_exporter_class(\n                    traces_dir=config.local.traces_dir,\n                    max_files=config.local.max_files,\n                    pretty_print=config.local.pretty_print,\n                    summary_only=config.terminal.summary_only,\n                )\n                tracer_provider.add_span_processor(batch_span_processor_class(file_exporter))\n                logger.info(f\"Observability: 本地文件导出已启用 -> {config.local.traces_dir}\")\n\n            # Phoenix 导出\n            if config.mode in (\"phoenix\", \"both\"):\n                _setup_phoenix_exporter(tracer_provider, config)\n\n            trace_api.set_tracer_provider(tracer_provider)\n            _setup_agno_instrumentor()\n            _setup_openai_instrumentor()  # 追踪 Tool 内部的 LLM 调用\n\n            _initialized.set()\n            logger.info(f\"Observability 初始化成功，模式: {config.mode}\")\n            return True\n\n        except ImportError as e:\n            logger.warning(f\"Observability 依赖未安装: {e}\")\n            return False\n        except Exception as e:\n            logger.error(f\"Observability 初始化失败: {e}\")\n            return False\n\n\ndef is_observability_enabled() -> bool:\n    \"\"\"检查观测系统是否已启用\n\n    Returns:\n        bool: 是否已启用\n    \"\"\"\n    return _initialized.is_set()\n"
  },
  {
    "path": "lifetrace/pyinstaller.spec",
    "content": "# -*- mode: python ; coding: utf-8 -*-\n\"\"\"\nPyInstaller spec file for LifeTrace backend\nCreates a one-folder bundle (recommended for large dependencies like PyTorch)\n\"\"\"\n\nimport os\nimport shutil\nimport sys\nfrom pathlib import Path\n\n# Try to get the directory from SPECPATH (set by PyInstaller)\ntry:\n    # SPECPATH is automatically set by PyInstaller to the spec file's absolute path\n    spec_path = Path(SPECPATH)\n    lifetrace_dir = spec_path.resolve().parent\nexcept (NameError, AttributeError):\n    # Fallback: use current working directory (should be lifetrace dir when script runs)\n    lifetrace_dir = Path(os.getcwd()).resolve()\n\n# Verify the directory contains the expected structure\nconfig_file = lifetrace_dir / \"config\" / \"default_config.yaml\"\nif not config_file.exists():\n    # If config not found, try going up one level to find lifetrace directory\n    # This handles the case where we're in a subdirectory\n    potential_lifetrace = lifetrace_dir.parent / \"lifetrace\"\n    if (potential_lifetrace / \"config\" / \"default_config.yaml\").exists():\n        lifetrace_dir = potential_lifetrace\n    else:\n        # Last resort: try to find it relative to current working directory\n        cwd = Path(os.getcwd())\n        if (cwd / \"config\" / \"default_config.yaml\").exists():\n            lifetrace_dir = cwd\n        elif (cwd / \"lifetrace\" / \"config\" / \"default_config.yaml\").exists():\n            lifetrace_dir = cwd / \"lifetrace\"\n\n# Final verification - raise error if still not found\nif not (lifetrace_dir / \"config\" / \"default_config.yaml\").exists():\n    raise FileNotFoundError(\n        f\"Cannot find config file. Tried: {lifetrace_dir / 'config' / 'default_config.yaml'}\\n\"\n        f\"SPECPATH: {SPECPATH if 'SPECPATH' in globals() else 'not set'}\\n\"\n        f\"CWD: {os.getcwd()}\\n\"\n        f\"Please ensure you run PyInstaller from the lifetrace directory or specify the correct path.\"\n    )\n\ndef _env_flag(name: str, default: bool) -> bool:\n    value = os.getenv(name)\n    if value is None:\n        return default\n    return value.strip().lower() in {\"1\", \"true\", \"yes\", \"on\"}\n\n\ndef _env_int(name: str, default: int) -> int:\n    value = os.getenv(name)\n    if value is None:\n        return default\n    try:\n        return int(value)\n    except ValueError:\n        return default\n\n\n# Build options (override with env vars if needed)\n# LIFETRACE_INCLUDE_VECTOR=1 to include vector deps (chromadb/transformers/torch/etc.)\ninclude_vector = _env_flag(\"LIFETRACE_INCLUDE_VECTOR\", False)\noptimize_level = _env_int(\"PYINSTALLER_OPTIMIZE\", 1)\nenable_strip = _env_flag(\"PYINSTALLER_STRIP\", sys.platform != \"win32\")\nenable_upx = _env_flag(\n    \"PYINSTALLER_UPX\",\n    bool(shutil.which(\"upx\")) and sys.platform != \"darwin\",\n)\n\n# Data files to include\n# 注意：config 和 models 放在 app 根目录（与 _internal 同级别），而不是 _internal 内\n# 这样在打包环境中，路径为 backend/config/ 和 backend/models/\ndatas = [\n    # Configuration files - 放在 app 根目录下的 config/\n    (str(lifetrace_dir / \"config\" / \"default_config.yaml\"), \"config\"),\n    (str(lifetrace_dir / \"config\" / \"rapidocr_config.yaml\"), \"config\"),\n    # Prompts directory - 包含所有拆分后的 prompt yaml 文件\n    (str(lifetrace_dir / \"config\" / \"prompts\"), \"config/prompts\"),\n    # ONNX model files - 放在 app 根目录下的 models/\n    (str(lifetrace_dir / \"models\" / \"ch_PP-OCRv4_det_infer.onnx\"), \"models\"),\n    (str(lifetrace_dir / \"models\" / \"ch_PP-OCRv4_rec_infer.onnx\"), \"models\"),\n    (str(lifetrace_dir / \"models\" / \"ch_ppocr_mobile_v2.0_cls_infer.onnx\"), \"models\"),\n]\n\n# Hidden imports (modules that PyInstaller might miss)\n# 注意：这些模块需要与 pyproject.toml 中的依赖保持一致\nhiddenimports = [\n    # LifeTrace core modules\n    \"lifetrace\",\n    \"lifetrace.server\",\n    \"lifetrace.util\",\n    \"lifetrace.util.config\",\n    \"lifetrace.util.logging_config\",\n    \"lifetrace.routers\",\n    \"lifetrace.storage\",\n    \"lifetrace.llm\",\n    \"lifetrace.jobs\",\n    \"lifetrace.schemas\",\n    \"lifetrace.services\",\n    # FastAPI and web server (fastapi, uvicorn)\n    \"fastapi\",\n    \"uvicorn\",\n    \"uvicorn.loops\",\n    \"uvicorn.loops.auto\",\n    \"uvicorn.protocols\",\n    \"uvicorn.protocols.http\",\n    \"uvicorn.protocols.http.auto\",\n    \"uvicorn.protocols.websockets\",\n    \"uvicorn.protocols.websockets.auto\",\n    \"uvicorn.lifespan\",\n    \"uvicorn.lifespan.on\",\n    \"jinja2\",  # FastAPI 依赖\n    # Data validation and ORM (pydantic, sqlalchemy, sqlmodel, alembic)\n    \"pydantic\",\n    \"pydantic.json\",\n    \"sqlalchemy\",\n    \"sqlalchemy.engine\",\n    \"sqlalchemy.pool\",\n    \"sqlalchemy.dialects.sqlite\",\n    \"sqlmodel\",\n    \"alembic\",\n    \"alembic.config\",\n    \"alembic.script\",\n    \"alembic.runtime\",\n    \"alembic.runtime.environment\",\n    \"alembic.runtime.migration\",\n    # Screenshot and image processing (mss, Pillow, imagehash)\n    \"mss\",\n    \"PIL\",\n    \"PIL.Image\",\n    \"imagehash\",\n    \"cv2\",  # rapidocr 依赖\n    \"numpy\",\n    \"numpy._core\",\n    \"numpy._core._multiarray_umath\",\n    \"numpy._core.multiarray\",\n    \"numpy._core.umath\",\n    \"numpy._globals\",\n    \"numpy._core._globals\",\n    # OCR processing (rapidocr-onnxruntime)\n    \"rapidocr_onnxruntime\",\n    \"rapidocr_onnxruntime.main\",\n    \"rapidocr_onnxruntime.cal_rec_boxes\",\n    \"rapidocr_onnxruntime.ch_ppocr_cls\",\n    \"rapidocr_onnxruntime.ch_ppocr_det\",\n    \"rapidocr_onnxruntime.ch_ppocr_rec\",\n    \"rapidocr_onnxruntime.utils\",\n    # Configuration (pyyaml, dynaconf)\n    \"yaml\",\n    \"dynaconf\",\n    \"dynaconf.loaders\",\n    \"dynaconf.loaders.yaml_loader\",\n    \"dynaconf.utils\",\n    \"dynaconf.utils.boxing\",\n    \"dynaconf.utils.parse_conf\",\n    \"dynaconf.validator\",\n    # Scheduler (apscheduler)\n    \"apscheduler\",\n    \"apscheduler.executors\",\n    \"apscheduler.executors.pool\",\n    \"apscheduler.jobstores\",\n    \"apscheduler.jobstores.memory\",\n    \"apscheduler.triggers\",\n    \"apscheduler.triggers.cron\",\n    \"apscheduler.triggers.interval\",\n    # Utils (psutil, openai, tavily)\n    \"psutil\",\n    \"openai\",\n    \"tavily\",  # Tavily API for web search\n    \"dateutil\",  # 可能被其他库依赖\n    \"rich\",  # 可能被其他库依赖\n    # Logging (loguru)\n    \"loguru\",\n    \"loguru._defaults\",\n    \"loguru._handler\",\n    \"loguru._logger\",\n    \"loguru._recattrs\",\n    \"loguru._file_sink\",\n    \"loguru._colorizer\",\n    \"loguru._contextvars\",\n    \"loguru._get_frame\",\n    \"loguru._simple_sink\",\n    \"loguru._string_parsers\",\n    \"loguru._writer\",\n    # Vector database and semantic search (可选 vector 组) - 按需添加\n]\n\n# 平台特定的 hidden imports\nif sys.platform == \"darwin\":\n    # macOS specific (pyobjc-framework-Cocoa, pyobjc-framework-Quartz)\n    hiddenimports.extend([\n        \"objc\",\n        \"AppKit\",\n        \"Cocoa\",\n        \"Quartz\",\n        \"Quartz.CoreGraphics\",\n        \"CoreFoundation\",\n    ])\nelif sys.platform == \"win32\":\n    # Windows specific (pywin32)\n    hiddenimports.extend([\n        \"win32api\",\n        \"win32con\",\n        \"win32gui\",\n        \"win32process\",\n        \"pywintypes\",\n    ])\n\n# Collect all lifetrace source files to ensure they're included\n# PyInstaller needs the parent directory in pathex to find the lifetrace module\nlifetrace_parent_dir = str(lifetrace_dir.parent)\n\n# Collect data files and binaries from rapidocr_onnxruntime package\n# This ensures config.yaml and other data files are included\nfrom PyInstaller.utils.hooks import collect_data_files, collect_submodules\n\n# Collect all submodules to ensure nothing is missed\nrapidocr_submodules = collect_submodules(\"rapidocr_onnxruntime\")\nhiddenimports.extend(rapidocr_submodules)\n\n# Collect data files (config.yaml, etc.)\nrapidocr_datas = collect_data_files(\"rapidocr_onnxruntime\")\ndatas.extend(rapidocr_datas)\n\n# Collect all chromadb submodules (including telemetry.product.posthog)\n# ChromaDB and sentence-transformers are optional; include only if enabled\nvector_modules = [\n    \"torch\",\n    \"torchvision\",\n    \"torchaudio\",\n    \"transformers\",  # sentence-transformers 依赖\n    \"scipy\",\n    \"hdbscan\",\n    \"sentence_transformers\",\n    \"chromadb\",\n]\nif include_vector:\n    hiddenimports.extend(vector_modules)\n    chromadb_submodules = collect_submodules(\"chromadb\")\n    hiddenimports.extend(chromadb_submodules)\n    chromadb_datas = collect_data_files(\"chromadb\")\n    datas.extend(chromadb_datas)\n\n    sentence_transformers_submodules = collect_submodules(\"sentence_transformers\")\n    hiddenimports.extend(sentence_transformers_submodules)\n    sentence_transformers_datas = collect_data_files(\"sentence_transformers\")\n    datas.extend(sentence_transformers_datas)\n\n# Collect dynaconf submodules and data files (配置管理)\ndynaconf_submodules = collect_submodules(\"dynaconf\")\nhiddenimports.extend(dynaconf_submodules)\ndynaconf_datas = collect_data_files(\"dynaconf\")\ndatas.extend(dynaconf_datas)\n\n# Collect sqlmodel submodules (ORM)\nsqlmodel_submodules = collect_submodules(\"sqlmodel\")\nhiddenimports.extend(sqlmodel_submodules)\n\n# Collect alembic submodules and data files (数据库迁移)\nalembic_submodules = collect_submodules(\"alembic\")\nhiddenimports.extend(alembic_submodules)\nalembic_datas = collect_data_files(\"alembic\")\ndatas.extend(alembic_datas)\n\n# Collect imagehash submodules (图像哈希)\nimagehash_submodules = collect_submodules(\"imagehash\")\nhiddenimports.extend(imagehash_submodules)\n\n# Collect tavily submodules (Tavily API for web search)\ntavily_submodules = collect_submodules(\"tavily\")\nhiddenimports.extend(tavily_submodules)\n\n# Collect tavily data files if any\ntavily_datas = collect_data_files(\"tavily\")\ndatas.extend(tavily_datas)\n\n# Collect numpy submodules (NumPy 2.x 需要显式收集子模块)\n# NumPy 2.4+ 与 PyInstaller 的兼容性问题，需要确保所有核心模块都被包含\nnumpy_submodules = collect_submodules(\"numpy\")\nhiddenimports.extend(numpy_submodules)\n# 特别添加 numpy._core 和 numpy._globals 相关模块\nnumpy_core_submodules = collect_submodules(\"numpy._core\")\nhiddenimports.extend(numpy_core_submodules)\n\nexcludes = [\n    \"matplotlib\",\n    \"tkinter\",\n    \"pytest\",\n    # 注意：不要排除 unittest，因为 imagehash 等库可能依赖它\n    # \"unittest\",\n    \"test\",\n    \"tests\",\n]\nif not include_vector:\n    excludes.extend(vector_modules)\n\na = Analysis(\n    [\"scripts/start_backend.py\"],\n    pathex=[lifetrace_parent_dir, str(lifetrace_dir)],  # Add both parent and lifetrace directory to Python path\n    binaries=[],\n    datas=datas,\n    hiddenimports=hiddenimports,\n    hookspath=[],\n    hooksconfig={},\n    runtime_hooks=[],\n    excludes=excludes,\n    noarchive=False,\n    optimize=optimize_level,\n)\n\npyz = PYZ(a.pure, a.zipped_data, cipher=None)\n\nexe = EXE(\n    pyz,\n    a.scripts,\n    [],\n    exclude_binaries=True,\n    name=\"lifetrace\",\n    debug=False,\n    bootloader_ignore_signals=False,\n    strip=enable_strip,\n    upx=enable_upx,\n    console=True,  # Keep console for debugging\n    disable_windowed_traceback=False,\n    argv_emulation=False,\n    target_arch=None,\n    codesign_identity=None,\n    entitlements_file=None,\n)\n\ncoll = COLLECT(\n    exe,\n    a.binaries,\n    a.zipfiles,\n    a.datas,\n    strip=enable_strip,\n    upx=enable_upx,\n    upx_exclude=[],\n    name=\"lifetrace\",\n)\n"
  },
  {
    "path": "lifetrace/repositories/__init__.py",
    "content": ""
  },
  {
    "path": "lifetrace/repositories/interfaces.py",
    "content": "\"\"\"仓库接口定义模块\n\n定义数据访问层的抽象接口，支持依赖注入和单元测试。\n\"\"\"\n\nfrom abc import ABC, abstractmethod\nfrom datetime import datetime\nfrom typing import Any\n\n\nclass IChatRepository(ABC):\n    \"\"\"Chat 仓库接口\"\"\"\n\n    @abstractmethod\n    def create_chat(\n        self,\n        session_id: str,\n        chat_type: str = \"event\",\n        title: str | None = None,\n        context_id: int | None = None,\n        metadata: str | None = None,\n    ) -> dict[str, Any] | None:\n        \"\"\"创建聊天会话\"\"\"\n        pass\n\n    @abstractmethod\n    def get_chat_by_session_id(self, session_id: str) -> dict[str, Any] | None:\n        \"\"\"根据 session_id 获取聊天会话\"\"\"\n        pass\n\n    @abstractmethod\n    def list_chats(\n        self,\n        chat_type: str | None = None,\n        limit: int = 50,\n        offset: int = 0,\n    ) -> list[dict[str, Any]]:\n        \"\"\"列出聊天会话\"\"\"\n        pass\n\n    @abstractmethod\n    def update_chat_title(self, session_id: str, title: str) -> bool:\n        \"\"\"更新聊天会话标题\"\"\"\n        pass\n\n    @abstractmethod\n    def delete_chat(self, session_id: str) -> bool:\n        \"\"\"删除聊天会话及其所有消息\"\"\"\n        pass\n\n    @abstractmethod\n    def add_message(\n        self,\n        session_id: str,\n        role: str,\n        content: str,\n        token_count: int | None = None,\n        model: str | None = None,\n        metadata: str | None = None,\n    ) -> dict[str, Any] | None:\n        \"\"\"添加消息到聊天会话\"\"\"\n        pass\n\n    @abstractmethod\n    def get_messages(\n        self,\n        session_id: str,\n        limit: int | None = None,\n        offset: int = 0,\n    ) -> list[dict[str, Any]]:\n        \"\"\"获取聊天会话的消息列表\"\"\"\n        pass\n\n    @abstractmethod\n    def get_message_count(self, session_id: str) -> int:\n        \"\"\"获取聊天会话的消息数量\"\"\"\n        pass\n\n    @abstractmethod\n    def get_chat_summaries(\n        self,\n        chat_type: str | None = None,\n        limit: int = 10,\n    ) -> list[dict[str, Any]]:\n        \"\"\"获取聊天会话摘要列表\"\"\"\n        pass\n\n    @abstractmethod\n    def get_chat_context(self, session_id: str) -> str | None:\n        \"\"\"获取会话上下文（JSON 字符串）\"\"\"\n        pass\n\n    @abstractmethod\n    def update_chat_context(self, session_id: str, context: str) -> bool:\n        \"\"\"更新会话上下文\n\n        Args:\n            session_id: 会话ID\n            context: JSON 格式的上下文字符串\n\n        Returns:\n            是否更新成功\n        \"\"\"\n        pass\n\n\nclass ITodoRepository(ABC):\n    \"\"\"Todo 仓库接口\"\"\"\n\n    @abstractmethod\n    def get_by_id(self, todo_id: int) -> dict[str, Any] | None:\n        \"\"\"根据ID获取单个todo\"\"\"\n        pass\n\n    @abstractmethod\n    def get_by_uid(self, uid: str) -> dict[str, Any] | None:\n        \"\"\"根据UID获取单个todo\"\"\"\n        pass\n\n    @abstractmethod\n    def list_todos(self, limit: int, offset: int, status: str | None) -> list[dict[str, Any]]:\n        \"\"\"获取todo列表\"\"\"\n        pass\n\n    @abstractmethod\n    def count(self, status: str | None) -> int:\n        \"\"\"统计todo数量\"\"\"\n        pass\n\n    @abstractmethod\n    def create(self, **kwargs) -> int | None:\n        \"\"\"创建todo，返回ID\"\"\"\n        pass\n\n    @abstractmethod\n    def update(self, todo_id: int, **kwargs) -> bool:\n        \"\"\"更新todo\"\"\"\n        pass\n\n    @abstractmethod\n    def delete(self, todo_id: int) -> bool:\n        \"\"\"删除todo\"\"\"\n        pass\n\n    @abstractmethod\n    def reorder(self, items: list[dict[str, Any]]) -> bool:\n        \"\"\"批量重排序\"\"\"\n        pass\n\n    @abstractmethod\n    def add_attachment(\n        self,\n        *,\n        todo_id: int,\n        file_name: str,\n        file_path: str,\n        file_size: int | None,\n        mime_type: str | None,\n        file_hash: str | None,\n        source: str = \"user\",\n    ) -> dict[str, Any] | None:\n        \"\"\"新增附件并绑定到 todo\"\"\"\n        pass\n\n    @abstractmethod\n    def remove_attachment(self, *, todo_id: int, attachment_id: int) -> bool:\n        \"\"\"解绑附件\"\"\"\n        pass\n\n    @abstractmethod\n    def get_attachment(self, attachment_id: int) -> dict[str, Any] | None:\n        \"\"\"获取附件信息\"\"\"\n        pass\n\n\nclass IJournalRepository(ABC):\n    \"\"\"Journal 仓库接口\"\"\"\n\n    @abstractmethod\n    def get_by_id(self, journal_id: int) -> dict[str, Any] | None:\n        \"\"\"根据ID获取单个日记\"\"\"\n        pass\n\n    @abstractmethod\n    def list_journals(\n        self,\n        limit: int,\n        offset: int,\n        start_date: datetime | None,\n        end_date: datetime | None,\n    ) -> list[dict[str, Any]]:\n        \"\"\"获取日记列表\"\"\"\n        pass\n\n    @abstractmethod\n    def count(self, start_date: datetime | None, end_date: datetime | None) -> int:\n        \"\"\"统计日记数量\"\"\"\n        pass\n\n    @abstractmethod\n    def create(self, payload: Any) -> int | None:\n        \"\"\"创建日记，返回ID\"\"\"\n        pass\n\n    @abstractmethod\n    def update(self, journal_id: int, payload: Any) -> bool:\n        \"\"\"更新日记\"\"\"\n        pass\n\n    @abstractmethod\n    def delete(self, journal_id: int) -> bool:\n        \"\"\"删除日记\"\"\"\n        pass\n\n\nclass IEventRepository(ABC):\n    \"\"\"Event 仓库接口\"\"\"\n\n    @abstractmethod\n    def get_summary(self, event_id: int) -> dict[str, Any] | None:\n        \"\"\"获取单个事件摘要\"\"\"\n        pass\n\n    @abstractmethod\n    def list_events(\n        self,\n        limit: int,\n        offset: int,\n        start_date: datetime | None,\n        end_date: datetime | None,\n        app_name: str | None,\n    ) -> list[dict[str, Any]]:\n        \"\"\"获取事件列表\"\"\"\n        pass\n\n    @abstractmethod\n    def count_events(\n        self,\n        start_date: datetime | None,\n        end_date: datetime | None,\n        app_name: str | None,\n    ) -> int:\n        \"\"\"统计事件数量\"\"\"\n        pass\n\n    @abstractmethod\n    def get_screenshots(self, event_id: int) -> list[dict[str, Any]]:\n        \"\"\"获取事件关联的截图\"\"\"\n        pass\n\n    @abstractmethod\n    def update_summary(self, event_id: int, ai_title: str, ai_summary: str) -> bool:\n        \"\"\"更新事件AI摘要\"\"\"\n        pass\n\n    @abstractmethod\n    def get_events_by_ids(self, event_ids: list[int]) -> list[dict[str, Any]]:\n        \"\"\"批量获取事件\"\"\"\n        pass\n\n\nclass IOcrRepository(ABC):\n    \"\"\"OCR 仓库接口\"\"\"\n\n    @abstractmethod\n    def get_results_by_screenshot(self, screenshot_id: int) -> list[dict[str, Any]]:\n        \"\"\"获取截图的OCR结果\"\"\"\n        pass\n\n\nclass IActivityRepository(ABC):\n    \"\"\"Activity 仓库接口\"\"\"\n\n    @abstractmethod\n    def get_by_id(self, activity_id: int) -> dict[str, Any] | None:\n        \"\"\"根据ID获取活动\"\"\"\n        pass\n\n    @abstractmethod\n    def get_activities(\n        self,\n        limit: int,\n        offset: int,\n        start_date: datetime | None,\n        end_date: datetime | None,\n    ) -> list[dict[str, Any]]:\n        \"\"\"获取活动列表\"\"\"\n        pass\n\n    @abstractmethod\n    def count_activities(\n        self,\n        start_date: datetime | None,\n        end_date: datetime | None,\n    ) -> int:\n        \"\"\"统计活动数量\"\"\"\n        pass\n\n    @abstractmethod\n    def get_activity_events(self, activity_id: int) -> list[int]:\n        \"\"\"获取活动关联的事件ID列表\"\"\"\n        pass\n\n    @abstractmethod\n    def create_activity(\n        self,\n        start_time: datetime,\n        end_time: datetime,\n        ai_title: str,\n        ai_summary: str,\n        event_ids: list[int],\n    ) -> int | None:\n        \"\"\"创建活动\"\"\"\n        pass\n\n    @abstractmethod\n    def activity_exists_for_event_id(self, event_id: int) -> bool:\n        \"\"\"检查事件ID是否已关联到活动\"\"\"\n        pass\n"
  },
  {
    "path": "lifetrace/repositories/sql_activity_repository.py",
    "content": "\"\"\"基于 SQLAlchemy 的 Activity 仓库实现\n\n复用现有的 ActivityManager 逻辑，提供符合仓库接口的数据访问层。\n\"\"\"\n\nfrom datetime import datetime\nfrom typing import Any\n\nfrom lifetrace.repositories.interfaces import IActivityRepository\nfrom lifetrace.storage.activity_manager import ActivityManager\nfrom lifetrace.storage.database_base import DatabaseBase\n\n\nclass SqlActivityRepository(IActivityRepository):\n    \"\"\"基于 SQLAlchemy 的 Activity 仓库实现\"\"\"\n\n    def __init__(self, db_base: DatabaseBase):\n        self._manager = ActivityManager(db_base)\n\n    def get_by_id(self, activity_id: int) -> dict[str, Any] | None:\n        return self._manager.get_activity(activity_id)\n\n    def get_activities(\n        self,\n        limit: int,\n        offset: int,\n        start_date: datetime | None,\n        end_date: datetime | None,\n    ) -> list[dict[str, Any]]:\n        return self._manager.get_activities(\n            limit=limit,\n            offset=offset,\n            start_date=start_date,\n            end_date=end_date,\n        )\n\n    def count_activities(\n        self,\n        start_date: datetime | None,\n        end_date: datetime | None,\n    ) -> int:\n        return self._manager.count_activities(\n            start_date=start_date,\n            end_date=end_date,\n        )\n\n    def get_activity_events(self, activity_id: int) -> list[int]:\n        return self._manager.get_activity_events(activity_id)\n\n    def create_activity(\n        self,\n        start_time: datetime,\n        end_time: datetime,\n        ai_title: str,\n        ai_summary: str,\n        event_ids: list[int],\n    ) -> int | None:\n        return self._manager.create_activity(\n            start_time=start_time,\n            end_time=end_time,\n            ai_title=ai_title,\n            ai_summary=ai_summary,\n            event_ids=event_ids,\n        )\n\n    def activity_exists_for_event_id(self, event_id: int) -> bool:\n        return self._manager.activity_exists_for_event_id(event_id)\n"
  },
  {
    "path": "lifetrace/repositories/sql_chat_repository.py",
    "content": "\"\"\"基于 SQLAlchemy 的 Chat 仓库实现\n\n复用现有的 ChatManager 逻辑，提供符合仓库接口的数据访问层。\n\"\"\"\n\nfrom typing import Any\n\nfrom lifetrace.repositories.interfaces import IChatRepository\nfrom lifetrace.storage.chat_manager import ChatManager\nfrom lifetrace.storage.database_base import DatabaseBase\n\n\nclass SqlChatRepository(IChatRepository):\n    \"\"\"基于 SQLAlchemy 的 Chat 仓库实现\"\"\"\n\n    def __init__(self, db_base: DatabaseBase):\n        # 复用现有的 ChatManager 逻辑\n        self._manager = ChatManager(db_base)\n\n    def create_chat(\n        self,\n        session_id: str,\n        chat_type: str = \"event\",\n        title: str | None = None,\n        context_id: int | None = None,\n        metadata: str | None = None,\n    ) -> dict[str, Any] | None:\n        return self._manager.create_chat(\n            session_id=session_id,\n            chat_type=chat_type,\n            title=title,\n            context_id=context_id,\n            metadata=metadata,\n        )\n\n    def get_chat_by_session_id(self, session_id: str) -> dict[str, Any] | None:\n        return self._manager.get_chat_by_session_id(session_id)\n\n    def list_chats(\n        self,\n        chat_type: str | None = None,\n        limit: int = 50,\n        offset: int = 0,\n    ) -> list[dict[str, Any]]:\n        return self._manager.list_chats(\n            chat_type=chat_type,\n            limit=limit,\n            offset=offset,\n        )\n\n    def update_chat_title(self, session_id: str, title: str) -> bool:\n        return self._manager.update_chat_title(session_id, title)\n\n    def delete_chat(self, session_id: str) -> bool:\n        return self._manager.delete_chat(session_id)\n\n    def add_message(\n        self,\n        session_id: str,\n        role: str,\n        content: str,\n        token_count: int | None = None,\n        model: str | None = None,\n        metadata: str | None = None,\n    ) -> dict[str, Any] | None:\n        return self._manager.add_message(\n            session_id=session_id,\n            role=role,\n            content=content,\n            token_count=token_count,\n            model=model,\n            metadata=metadata,\n        )\n\n    def get_messages(\n        self,\n        session_id: str,\n        limit: int | None = None,\n        offset: int = 0,\n    ) -> list[dict[str, Any]]:\n        return self._manager.get_messages(\n            session_id=session_id,\n            limit=limit,\n            offset=offset,\n        )\n\n    def get_message_count(self, session_id: str) -> int:\n        return self._manager.get_message_count(session_id)\n\n    def get_chat_summaries(\n        self,\n        chat_type: str | None = None,\n        limit: int = 10,\n    ) -> list[dict[str, Any]]:\n        return self._manager.get_chat_summaries(\n            chat_type=chat_type,\n            limit=limit,\n        )\n\n    def get_chat_context(self, session_id: str) -> str | None:\n        return self._manager.get_chat_context(session_id)\n\n    def update_chat_context(self, session_id: str, context: str) -> bool:\n        return self._manager.update_chat_context(session_id, context)\n"
  },
  {
    "path": "lifetrace/repositories/sql_event_repository.py",
    "content": "\"\"\"基于 SQLAlchemy 的 Event 仓库实现\n\n复用现有的 EventManager 和 OcrManager 逻辑，提供符合仓库接口的数据访问层。\n\"\"\"\n\nfrom datetime import datetime\nfrom typing import Any\n\nfrom lifetrace.repositories.interfaces import IEventRepository, IOcrRepository\nfrom lifetrace.storage.database_base import DatabaseBase\nfrom lifetrace.storage.event_manager import EventManager\nfrom lifetrace.storage.ocr_manager import OCRManager\n\n\nclass SqlEventRepository(IEventRepository):\n    \"\"\"基于 SQLAlchemy 的 Event 仓库实现\"\"\"\n\n    def __init__(self, db_base: DatabaseBase):\n        self._manager = EventManager(db_base)\n\n    def get_summary(self, event_id: int) -> dict[str, Any] | None:\n        return self._manager.get_event_summary(event_id)\n\n    def list_events(\n        self,\n        limit: int,\n        offset: int,\n        start_date: datetime | None,\n        end_date: datetime | None,\n        app_name: str | None,\n    ) -> list[dict[str, Any]]:\n        return self._manager.list_events(\n            limit=limit,\n            offset=offset,\n            start_date=start_date,\n            end_date=end_date,\n            app_name=app_name,\n        )\n\n    def count_events(\n        self,\n        start_date: datetime | None,\n        end_date: datetime | None,\n        app_name: str | None,\n    ) -> int:\n        return self._manager.count_events(\n            start_date=start_date,\n            end_date=end_date,\n            app_name=app_name,\n        )\n\n    def get_screenshots(self, event_id: int) -> list[dict[str, Any]]:\n        return self._manager.get_event_screenshots(event_id)\n\n    def update_summary(self, event_id: int, ai_title: str, ai_summary: str) -> bool:\n        return self._manager.update_event_summary(event_id, ai_title, ai_summary)\n\n    def get_events_by_ids(self, event_ids: list[int]) -> list[dict[str, Any]]:\n        return self._manager.get_events_by_ids(event_ids)\n\n\nclass SqlOcrRepository(IOcrRepository):\n    \"\"\"基于 SQLAlchemy 的 OCR 仓库实现\"\"\"\n\n    def __init__(self, db_base: DatabaseBase):\n        self._manager = OCRManager(db_base)\n\n    def get_results_by_screenshot(self, screenshot_id: int) -> list[dict[str, Any]]:\n        return self._manager.get_ocr_results_by_screenshot(screenshot_id)\n"
  },
  {
    "path": "lifetrace/repositories/sql_journal_repository.py",
    "content": "\"\"\"基于 SQLAlchemy 的 Journal 仓库实现\n\n复用现有的 JournalManager 逻辑，提供符合仓库接口的数据访问层。\n\"\"\"\n\nfrom datetime import datetime\nfrom typing import Any\n\nfrom lifetrace.repositories.interfaces import IJournalRepository\nfrom lifetrace.storage.database_base import DatabaseBase\nfrom lifetrace.storage.journal_manager import JournalManager\n\n\nclass SqlJournalRepository(IJournalRepository):\n    \"\"\"基于 SQLAlchemy 的 Journal 仓库实现\"\"\"\n\n    def __init__(self, db_base: DatabaseBase):\n        self._manager = JournalManager(db_base)\n\n    def get_by_id(self, journal_id: int) -> dict[str, Any] | None:\n        return self._manager.get_journal(journal_id)\n\n    def list_journals(\n        self,\n        limit: int,\n        offset: int,\n        start_date: datetime | None,\n        end_date: datetime | None,\n    ) -> list[dict[str, Any]]:\n        return self._manager.list_journals(\n            limit=limit,\n            offset=offset,\n            start_date=start_date,\n            end_date=end_date,\n        )\n\n    def count(self, start_date: datetime | None, end_date: datetime | None) -> int:\n        return self._manager.count_journals(start_date=start_date, end_date=end_date)\n\n    def create(self, payload: Any) -> int | None:\n        return self._manager.create_journal(payload)\n\n    def update(self, journal_id: int, payload: Any) -> bool:\n        return self._manager.update_journal(journal_id, payload)\n\n    def delete(self, journal_id: int) -> bool:\n        return self._manager.delete_journal(journal_id)\n"
  },
  {
    "path": "lifetrace/repositories/sql_todo_repository.py",
    "content": "\"\"\"基于 SQLAlchemy 的 Todo 仓库实现\n\n复用现有的 TodoManager 逻辑，提供符合仓库接口的数据访问层。\n\"\"\"\n\nfrom typing import Any\n\nfrom lifetrace.repositories.interfaces import ITodoRepository\nfrom lifetrace.storage.database_base import DatabaseBase\nfrom lifetrace.storage.todo_manager import TodoManager\n\n\nclass SqlTodoRepository(ITodoRepository):\n    \"\"\"基于 SQLAlchemy 的 Todo 仓库实现\"\"\"\n\n    def __init__(self, db_base: DatabaseBase):\n        # 复用现有的 TodoManager 逻辑\n        self._manager = TodoManager(db_base)\n\n    def get_by_id(self, todo_id: int) -> dict[str, Any] | None:\n        return self._manager.get_todo(todo_id)\n\n    def get_by_uid(self, uid: str) -> dict[str, Any] | None:\n        return self._manager.get_todo_by_uid(uid)\n\n    def list_todos(self, limit: int, offset: int, status: str | None) -> list[dict[str, Any]]:\n        return self._manager.list_todos(limit=limit, offset=offset, status=status)\n\n    def count(self, status: str | None) -> int:\n        return self._manager.count_todos(status=status)\n\n    def create(self, **kwargs) -> int | None:\n        return self._manager.create_todo(**kwargs)\n\n    def update(self, todo_id: int, **kwargs) -> bool:\n        return self._manager.update_todo(todo_id, **kwargs)\n\n    def delete(self, todo_id: int) -> bool:\n        return self._manager.delete_todo(todo_id)\n\n    def reorder(self, items: list[dict[str, Any]]) -> bool:\n        return self._manager.reorder_todos(items)\n\n    def add_attachment(\n        self,\n        *,\n        todo_id: int,\n        file_name: str,\n        file_path: str,\n        file_size: int | None,\n        mime_type: str | None,\n        file_hash: str | None,\n        source: str = \"user\",\n    ) -> dict[str, Any] | None:\n        return self._manager.add_todo_attachment(\n            todo_id=todo_id,\n            file_name=file_name,\n            file_path=file_path,\n            file_size=file_size,\n            mime_type=mime_type,\n            file_hash=file_hash,\n            source=source,\n        )\n\n    def remove_attachment(self, *, todo_id: int, attachment_id: int) -> bool:\n        return self._manager.remove_todo_attachment(\n            todo_id=todo_id,\n            attachment_id=attachment_id,\n        )\n\n    def get_attachment(self, attachment_id: int) -> dict[str, Any] | None:\n        return self._manager.get_attachment(attachment_id)\n"
  },
  {
    "path": "lifetrace/routers/activity.py",
    "content": "\"\"\"活动相关路由\"\"\"\n\nfrom datetime import datetime\n\nfrom fastapi import APIRouter, Depends, HTTPException, Query\n\nfrom lifetrace.core.dependencies import get_activity_service\nfrom lifetrace.schemas.activity import (\n    ActivityEventsResponse,\n    ActivityListResponse,\n    ManualActivityCreateRequest,\n    ManualActivityCreateResponse,\n)\nfrom lifetrace.services.activity_service import ActivityService\nfrom lifetrace.util.logging_config import get_logger\n\nlogger = get_logger()\n\nrouter = APIRouter(prefix=\"/api/activities\", tags=[\"activity\"])\n\n\n@router.get(\"\", response_model=ActivityListResponse)\nasync def list_activities(\n    limit: int = Query(50, ge=1, le=200),\n    offset: int = Query(0, ge=0),\n    start_date: str | None = Query(None),\n    end_date: str | None = Query(None),\n    service: ActivityService = Depends(get_activity_service),\n):\n    \"\"\"获取活动列表（活动=聚合的事件窗口）\"\"\"\n    try:\n        start_dt = datetime.fromisoformat(start_date) if start_date else None\n        end_dt = datetime.fromisoformat(end_date) if end_date else None\n\n        return service.list_activities(\n            limit=limit,\n            offset=offset,\n            start_date=start_dt,\n            end_date=end_dt,\n        )\n    except Exception as e:\n        logger.error(f\"获取活动列表失败: {e}\", exc_info=True)\n        raise HTTPException(status_code=500, detail=str(e)) from e\n\n\n@router.get(\"/{activity_id}/events\", response_model=ActivityEventsResponse)\nasync def get_activity_events(\n    activity_id: int,\n    service: ActivityService = Depends(get_activity_service),\n):\n    \"\"\"获取指定活动关联的事件ID列表\"\"\"\n    try:\n        return service.get_activity_events(activity_id)\n    except Exception as e:\n        logger.error(f\"获取活动 {activity_id} 的事件列表失败: {e}\", exc_info=True)\n        raise HTTPException(status_code=500, detail=str(e)) from e\n\n\n@router.post(\"/manual\", response_model=ManualActivityCreateResponse, status_code=201)\nasync def create_activity_manual(\n    request: ManualActivityCreateRequest,\n    service: ActivityService = Depends(get_activity_service),\n):\n    \"\"\"手动聚合指定事件集合为活动\n\n    Args:\n        request: 包含事件ID列表的请求\n\n    Returns:\n        创建的活动信息\n    \"\"\"\n    try:\n        return service.create_activity_manual(request)\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"手动聚合活动失败: {e}\", exc_info=True)\n        raise HTTPException(status_code=500, detail=f\"手动聚合活动失败: {e!s}\") from e\n"
  },
  {
    "path": "lifetrace/routers/audio.py",
    "content": "\"\"\"音频录制和转录相关路由\"\"\"\n\nimport json\nimport time\nfrom datetime import date as date_type\nfrom datetime import datetime, timedelta, timezone\nfrom pathlib import Path\nfrom typing import Any\n\nfrom fastapi import APIRouter, Query\nfrom fastapi.responses import FileResponse, JSONResponse\nfrom pydantic import BaseModel, Field\nfrom sqlmodel import select\n\nfrom lifetrace.routers.audio_ws import register_audio_ws_routes\nfrom lifetrace.services.asr_client import ASRClient\nfrom lifetrace.services.audio_service import AudioService\nfrom lifetrace.storage import get_session\nfrom lifetrace.storage.models import AudioRecording, Transcription\nfrom lifetrace.storage.sql_utils import col\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.time_utils import get_utc_now\n\nlogger = get_logger()\n\nrouter = APIRouter(prefix=\"/api/audio\", tags=[\"audio\"])\n\n# 全局服务实例\nasr_client = ASRClient()\naudio_service = AudioService()\nregister_audio_ws_routes(\n    router=router, logger=logger, asr_client=asr_client, audio_service=audio_service\n)\n\n\ndef _to_local(dt: datetime | None) -> datetime | None:\n    \"\"\"将时间转换为本地时区（带偏移），并返回 timezone-aware datetime。\"\"\"\n    if dt is None:\n        return None\n    if dt.tzinfo is None:\n        offset = -time.timezone if time.daylight == 0 else -time.altzone\n        local_tz = timezone(timedelta(seconds=offset))\n        return dt.replace(tzinfo=local_tz)\n    return dt.astimezone()\n\n\n@router.get(\"/recordings\")\nasync def get_recordings(date: str | None = Query(None)):\n    \"\"\"获取录音列表\"\"\"\n    try:\n        if date:\n            # 处理日期字符串，支持多种格式\n            try:\n                # 尝试解析ISO格式\n                if \"T\" in date or \"Z\" in date:\n                    target_date = datetime.fromisoformat(date.replace(\"Z\", \"+00:00\"))\n                else:\n                    # 处理 YYYY-MM-DD 格式\n                    date_obj = date_type.fromisoformat(date)\n                    target_date = datetime.combine(date_obj, datetime.min.time())\n            except ValueError as e:\n                logger.error(f\"日期格式错误: {date}, {e}\")\n                return JSONResponse({\"error\": f\"无效的日期格式: {date}\"}, status_code=400)\n        else:\n            target_date = get_utc_now().astimezone()\n\n        recordings = audio_service.get_recordings_by_date(target_date)\n\n        result = []\n        for rec in recordings:\n            if not rec:\n                continue\n            start_time = rec[\"start_time\"]\n            result.append(\n                {\n                    \"id\": rec[\"id\"],\n                    \"date\": start_time.strftime(\"%m月%d日 录音\"),\n                    \"time\": start_time.strftime(\"%H:%M\"),\n                    \"duration\": f\"{int(rec['duration'] // 60)}:{int(rec['duration'] % 60):02d}\",\n                    \"durationSeconds\": float(rec[\"duration\"]),\n                    \"size\": f\"{rec['file_size'] / 1024:.1f} KB\",\n                    \"isCurrent\": rec[\"status\"] == \"recording\",\n                }\n            )\n\n        return JSONResponse({\"recordings\": result})\n    except Exception as e:\n        logger.error(f\"获取录音列表失败: {e}\", exc_info=True)\n        return JSONResponse({\"error\": str(e)}, status_code=500)\n\n\ndef _parse_date_param(date: str | None) -> datetime:\n    \"\"\"解析日期参数\"\"\"\n    if date:\n        try:\n            if \"T\" in date or \"Z\" in date:\n                return datetime.fromisoformat(date.replace(\"Z\", \"+00:00\"))\n            else:\n                date_obj = date_type.fromisoformat(date)\n                return datetime.combine(date_obj, datetime.min.time())\n        except ValueError as e:\n            logger.error(f\"日期格式错误: {date}, {e}\")\n            raise ValueError(f\"无效的日期格式: {date}\") from e\n    else:\n        return get_utc_now().astimezone()\n\n\ndef _build_timeline_item(\n    rec: dict[str, Any], transcription: dict[str, Any] | None, optimized: bool\n) -> dict[str, Any]:\n    \"\"\"构建时间线项\"\"\"\n    text = \"\"\n    segment_timestamps: list[float] | None = None\n    if transcription:\n        if optimized and transcription.get(\"optimized_text\"):\n            text = transcription.get(\"optimized_text\") or \"\"\n        else:\n            text = transcription.get(\"original_text\") or \"\"\n        # 解析时间戳（如果存在）\n        timestamps_str = transcription.get(\"segment_timestamps\")\n        if timestamps_str:\n            try:\n                segment_timestamps = json.loads(timestamps_str)\n                if not isinstance(segment_timestamps, list):\n                    segment_timestamps = None\n            except (json.JSONDecodeError, TypeError):\n                segment_timestamps = None\n    start_local = _to_local(rec[\"start_time\"])\n    timeline_item: dict[str, Any] = {\n        \"id\": rec[\"id\"],\n        \"start_time\": (start_local or rec[\"start_time\"]).isoformat(),\n        \"duration\": float(rec[\"duration\"]),\n        \"text\": text,\n    }\n    # 如果有时间戳，添加到返回数据中\n    if segment_timestamps:\n        timeline_item[\"segment_timestamps\"] = segment_timestamps\n    return timeline_item\n\n\n@router.get(\"/timeline\")\nasync def get_timeline(date: str | None = Query(None), optimized: bool = Query(False)):\n    \"\"\"按日期返回录音时间线（含转录文本）\"\"\"\n    try:\n        target_date = _parse_date_param(date)\n        recordings = audio_service.get_recordings_by_date(target_date)\n        timeline: list[dict[str, Any]] = []\n        for rec in recordings:\n            if not rec:\n                continue\n            transcription = audio_service.get_transcription(int(rec[\"id\"]))\n            timeline_item = _build_timeline_item(rec, transcription, optimized)\n            timeline.append(timeline_item)\n\n        return JSONResponse({\"timeline\": timeline})\n    except ValueError as e:\n        return JSONResponse({\"error\": str(e)}, status_code=400)\n    except Exception as e:\n        logger.error(f\"获取时间线失败: {e}\", exc_info=True)\n        return JSONResponse({\"error\": str(e)}, status_code=500)\n\n\n@router.get(\"/recording/{recording_id}/file\")\nasync def get_recording_file(recording_id: int):\n    \"\"\"获取录音文件（用于前端播放）\"\"\"\n    try:\n        with get_session() as session:\n            rec = session.get(AudioRecording, recording_id)\n            if not rec or not rec.file_path:\n                return JSONResponse({\"error\": \"录音不存在\"}, status_code=404)\n            file_path = Path(rec.file_path)\n            if not file_path.exists():\n                logger.error(f\"录音文件不存在: {file_path}\")\n                return JSONResponse({\"error\": \"录音文件不存在或已被删除\"}, status_code=404)\n            return FileResponse(\n                path=str(file_path),\n                media_type=\"audio/wav\",\n                filename=file_path.name,\n            )\n    except Exception as e:\n        logger.error(f\"获取录音文件失败: {e}\", exc_info=True)\n        return JSONResponse({\"error\": str(e)}, status_code=500)\n\n\ndef _load_extracted_json(transcription: dict[str, Any], field: str) -> list[dict[str, Any]]:\n    \"\"\"从转录数据中加载 JSON 字段。\n\n    Args:\n        transcription: 转录数据字典\n        field: 字段名\n\n    Returns:\n        解析后的列表，如果解析失败则返回空列表\n    \"\"\"\n    value = transcription.get(field)\n    if not value:\n        return []\n    try:\n        return json.loads(value)\n    except Exception:\n        return []\n\n\ndef _refresh_extracted_from_db(\n    transcription_id: int, recording_id: int, optimized: bool\n) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:\n    \"\"\"从数据库刷新提取结果（只读取，不清空）。\n\n    Args:\n        transcription_id: 转录ID\n        recording_id: 录音ID\n        optimized: 是否使用优化文本的提取结果\n\n    Returns:\n        (todos, schedules) 元组\n    \"\"\"\n    _ = transcription_id\n    try:\n        # 直接读取数据库，不要调用 update_extraction（会清空数据）\n        refreshed = audio_service.get_transcription(recording_id)\n        if not refreshed:\n            return [], []\n\n        if optimized:\n            todos = _load_extracted_json(refreshed, \"extracted_todos_optimized\")\n            schedules = _load_extracted_json(refreshed, \"extracted_schedules_optimized\")\n        else:\n            todos = _load_extracted_json(refreshed, \"extracted_todos\")\n            schedules = _load_extracted_json(refreshed, \"extracted_schedules\")\n        return todos, schedules\n    except Exception:\n        return [], []\n\n\ndef _parse_extracted(\n    transcription: dict[str, Any],\n    optimized: bool = False,\n) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:\n    \"\"\"Parse extracted todos/schedules and backfill legacy fields.\n\n    Args:\n        transcription: 转录数据字典\n        optimized: 是否使用优化文本的提取结果\n\n    Returns:\n        (todos, schedules) 元组\n    \"\"\"\n    if optimized:\n        todos = _load_extracted_json(transcription, \"extracted_todos_optimized\")\n        schedules = _load_extracted_json(transcription, \"extracted_schedules_optimized\")\n    else:\n        todos = _load_extracted_json(transcription, \"extracted_todos\")\n        schedules = _load_extracted_json(transcription, \"extracted_schedules\")\n\n    # Backfill legacy items and persist so clients always get id/dedupe_key/linked\n    refreshed_todos, refreshed_schedules = _refresh_extracted_from_db(\n        int(transcription[\"id\"]), transcription[\"audio_recording_id\"], optimized\n    )\n    if refreshed_todos or refreshed_schedules:\n        return refreshed_todos, refreshed_schedules\n\n    return todos, schedules\n\n\n@router.get(\"/transcription/{recording_id}\")\nasync def get_transcription(recording_id: int, optimized: bool = Query(False)):\n    \"\"\"获取转录文本\"\"\"\n    try:\n        transcription = audio_service.get_transcription(recording_id)\n        if not transcription:\n            return JSONResponse({\"error\": \"转录不存在\"}, status_code=404)\n\n        text = transcription[\"optimized_text\"] if optimized else transcription[\"original_text\"]\n        if not text:\n            text = \"\"\n\n        # 根据 optimized 参数选择对应的提取结果\n        todos, schedules = _parse_extracted(transcription, optimized=optimized)\n\n        return JSONResponse(\n            {\n                \"text\": text,\n                \"recording_id\": recording_id,\n                \"todos\": todos,\n                \"schedules\": schedules,\n            }\n        )\n    except Exception as e:\n        logger.error(f\"获取转录文本失败: {e}\")\n        return JSONResponse({\"error\": str(e)}, status_code=500)\n\n\nclass AudioLinkItem(BaseModel):\n    kind: str = Field(..., description=\"todo|schedule\")\n    item_id: str = Field(..., description=\"extracted item id\")\n    todo_id: int = Field(..., description=\"linked todo id\")\n\n\nclass AudioLinkRequest(BaseModel):\n    links: list[AudioLinkItem]\n\n\n@router.post(\"/transcription/{recording_id}/link\")\nasync def link_extracted_items(\n    recording_id: int, request: AudioLinkRequest, optimized: bool = Query(False)\n):\n    \"\"\"Mark extracted items as linked to todos (persisted in transcription JSON).\n\n    Args:\n        recording_id: 录音ID\n        request: 链接请求\n        optimized: 是否更新优化文本的提取结果\n    \"\"\"\n    try:\n        result = audio_service.extraction_service.link_extracted_items(\n            recording_id=recording_id,\n            links=[link.model_dump() for link in request.links],\n            optimized=optimized,\n        )\n        return JSONResponse(result)\n    except Exception as e:\n        logger.error(f\"标记提取项已关联失败: {e}\", exc_info=True)\n        return JSONResponse({\"error\": str(e)}, status_code=500)\n\n\n@router.post(\"/optimize\")\nasync def optimize_transcription(recording_id: int):\n    \"\"\"优化转录文本（使用LLM）\"\"\"\n    try:\n        transcription = audio_service.get_transcription(recording_id)\n        if not transcription:\n            return JSONResponse({\"error\": \"转录不存在\"}, status_code=404)\n\n        text = transcription.get(\"original_text\") or \"\"\n        if not text:\n            return JSONResponse({\"error\": \"转录文本为空\"}, status_code=400)\n\n        # 使用LLM优化\n        optimized_text = await audio_service.optimize_transcription_text(text)\n\n        # 更新转录记录（保留提取结果）\n        with get_session() as session:\n            # 获取 ORM 对象（不是字典）\n            trans = session.exec(\n                select(Transcription)\n                .where(Transcription.audio_recording_id == recording_id)\n                .order_by(col(Transcription.id).desc())\n            ).first()\n            if trans:\n                # 只更新优化文本，保留提取结果等其他字段\n                trans.optimized_text = optimized_text\n                session.add(trans)\n                session.commit()\n\n        return JSONResponse({\"optimized_text\": optimized_text})\n    except Exception as e:\n        logger.error(f\"优化转录文本失败: {e}\")\n        return JSONResponse({\"error\": str(e)}, status_code=500)\n\n\n@router.post(\"/extract\")\nasync def extract_todos_and_schedules(recording_id: int, optimized: bool = Query(False)):\n    \"\"\"提取待办事项和日程安排\n\n    Args:\n        recording_id: 录音ID\n        optimized: 是否从优化文本提取（False=从原文提取）\n    \"\"\"\n    try:\n        transcription = audio_service.get_transcription(recording_id)\n        if not transcription:\n            return JSONResponse({\"error\": \"转录不存在\"}, status_code=404)\n\n        text = (\n            transcription.get(\"optimized_text\") or \"\"\n            if optimized\n            else transcription.get(\"original_text\") or \"\"\n        )\n        if not text:\n            return JSONResponse({\"error\": \"转录文本为空\"}, status_code=400)\n\n        # 使用LLM提取\n        result = await audio_service.extraction_service.extract_todos_and_schedules(text)\n\n        # 更新提取结果（根据 optimized 参数更新对应字段）\n        with get_session() as session:\n            # 查询转录记录（一个 recording_id 只应该有一条）\n            trans = session.exec(\n                select(Transcription)\n                .where(Transcription.audio_recording_id == recording_id)\n                .order_by(col(Transcription.id).desc())\n            ).first()\n            if trans and trans.id is not None:\n                audio_service.update_extraction(\n                    transcription_id=trans.id,\n                    todos=result.get(\"todos\", []),\n                    schedules=result.get(\"schedules\", []),\n                    optimized=optimized,\n                )\n\n        return JSONResponse(result)\n    except Exception as e:\n        logger.error(f\"提取待办和日程失败: {e}\")\n        return JSONResponse({\"error\": str(e)}, status_code=500)\n"
  },
  {
    "path": "lifetrace/routers/audio_ws.py",
    "content": "\"\"\"Audio websocket routes (recording + realtime ASR + realtime NLP).\n\nSplit from `lifetrace.routers.audio` to keep router files small and readable.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport array\nimport asyncio\nimport importlib\nimport json\nimport struct\nimport time\nfrom datetime import datetime, timedelta, timezone\nfrom typing import TYPE_CHECKING, Any\n\nfrom fastapi import APIRouter, WebSocket, WebSocketDisconnect\nfrom starlette.websockets import WebSocketState\n\nfrom lifetrace.util.time_utils import get_utc_now\n\nif TYPE_CHECKING:\n    from collections.abc import Callable\n\n# ---- constants (avoid magic numbers) ----\nSAMPLE_RATE = 16000\nNUM_CHANNELS = 1\nBITS_PER_SAMPLE = 16\n\nPCM_SILENCE_MAX_ABS = 50\nPCM_SILENCE_RMS = 20\n\nMAX_AGC_GAIN = 4.0\nAGC_APPLY_THRESHOLD_GAIN = 1.05\nINT16_MAX = 32767\nINT16_MIN = -32768\nAGC_TARGET_PEAK_RATIO = 0.85\n\n# 分段存储配置\nSEGMENT_DURATION_MINUTES = 30  # 30分钟分段\nSILENCE_DETECTION_THRESHOLD_SECONDS = 600  # 10分钟静音检测阈值\nSILENCE_CHECK_INTERVAL_SECONDS = 60  # 每60秒检查一次静音\n\n\ndef _track_task(task_set: set[asyncio.Task], coro) -> asyncio.Task:\n    task = asyncio.create_task(coro)\n    task_set.add(task)\n    task.add_done_callback(task_set.discard)\n    return task\n\n\ndef _to_local(dt: datetime | None) -> datetime | None:\n    \"\"\"Convert datetime to local timezone (timezone-aware).\"\"\"\n    if dt is None:\n        return None\n    if dt.tzinfo is None:\n        offset = -time.timezone if time.daylight == 0 else -time.altzone\n        local_tz = timezone(timedelta(seconds=offset))\n        return dt.replace(tzinfo=local_tz)\n    return dt.astimezone()\n\n\ndef _pcm16le_to_wav(\n    pcm_data: bytes,\n    sample_rate: int = SAMPLE_RATE,\n    num_channels: int = NUM_CHANNELS,\n    bits_per_sample: int = BITS_PER_SAMPLE,\n) -> bytes:\n    \"\"\"Wrap raw PCM16LE bytes into WAV container bytes.\"\"\"\n    byte_rate = sample_rate * num_channels * bits_per_sample // 8\n    block_align = num_channels * bits_per_sample // 8\n    data_size = len(pcm_data)\n\n    fmt_chunk_size = 16\n    riff_chunk_size = 4 + (8 + fmt_chunk_size) + (8 + data_size)\n\n    header = b\"RIFF\"\n    header += struct.pack(\"<I\", riff_chunk_size)\n    header += b\"WAVE\"\n\n    header += b\"fmt \"\n    header += struct.pack(\n        \"<IHHIIHH\",\n        fmt_chunk_size,\n        1,  # PCM\n        num_channels,\n        sample_rate,\n        byte_rate,\n        block_align,\n        bits_per_sample,\n    )\n\n    header += b\"data\"\n    header += struct.pack(\"<I\", data_size)\n    return header + pcm_data\n\n\ndef _create_result_callback(\n    *,\n    websocket: WebSocket,\n    logger,\n    transcription_text_ref: list[str],\n    is_connected_ref: list[bool],\n    task_set: set[asyncio.Task],\n) -> Callable[[str, bool], None]:\n    \"\"\"Create ASR result callback.\n\n    NOTE: Only commit final sentences to `transcription_text_ref` to avoid duplicates.\n    \"\"\"\n\n    async def _send_result(text: str, is_final: bool) -> None:\n        try:\n            if (\n                is_connected_ref[0]\n                and websocket.application_state == WebSocketState.CONNECTED\n                and websocket.client_state == WebSocketState.CONNECTED\n            ):\n                await websocket.send_json(\n                    {\n                        \"header\": {\"name\": \"TranscriptionResultChanged\"},\n                        \"payload\": {\"result\": text, \"is_final\": is_final},\n                    }\n                )\n        except Exception as e:\n            is_connected_ref[0] = False\n            logger.warning(f\"Failed to send TranscriptionResultChanged to client: {e}\")\n\n    def on_result(text: str, is_final: bool) -> None:\n        if not text or not is_connected_ref[0]:\n            return\n\n        if is_final:\n            committed = transcription_text_ref[0]\n            needs_gap = committed and not committed.endswith(\"\\n\")\n            committed += (\"\\n\" if needs_gap else \"\") + text\n            transcription_text_ref[0] = committed\n\n        try:\n            if (\n                is_connected_ref[0]\n                and websocket.application_state == WebSocketState.CONNECTED\n                and websocket.client_state == WebSocketState.CONNECTED\n            ):\n                _track_task(task_set, _send_result(text, is_final))\n        except Exception as e:\n            logger.warning(f\"Failed to schedule sending TranscriptionResultChanged: {e}\")\n\n    return on_result\n\n\ndef _create_error_callback(\n    *, websocket: WebSocket, logger, is_connected_ref: list[bool], task_set: set[asyncio.Task]\n):\n    async def _send_error(error: Exception) -> None:\n        try:\n            if (\n                is_connected_ref[0]\n                and websocket.application_state == WebSocketState.CONNECTED\n                and websocket.client_state == WebSocketState.CONNECTED\n            ):\n                await websocket.send_json(\n                    {\"header\": {\"name\": \"TaskFailed\"}, \"payload\": {\"error\": str(error)}}\n                )\n        except Exception as e:\n            is_connected_ref[0] = False\n            logger.warning(f\"Failed to send TaskFailed to client: {e}\")\n\n    def on_error(error: Exception) -> None:\n        logger.error(f\"ASR转录错误: {error}\")\n        if is_connected_ref[0]:\n            try:\n                if (\n                    websocket.application_state == WebSocketState.CONNECTED\n                    and websocket.client_state == WebSocketState.CONNECTED\n                ):\n                    _track_task(task_set, _send_error(error))\n            except Exception as e:\n                logger.warning(f\"Failed to schedule sending TaskFailed: {e}\")\n\n    return on_error\n\n\ndef _create_realtime_nlp_handler(  # noqa: C901\n    *,\n    websocket: WebSocket,\n    logger,\n    audio_service,\n    is_connected_ref: list[bool],\n    task_set: set[asyncio.Task],\n    throttle_seconds: float = 8.0,\n):\n    \"\"\"Realtime optimize/extract during recording (only on final sentences).\"\"\"\n\n    class _RealtimeNlpThrottler:\n        def __init__(self):\n            self._buffer = \"\"\n            self._last_emit = 0.0\n            self._pending: asyncio.Task | None = None\n\n        async def _send(self, name: str, payload: dict[str, Any]) -> None:\n            try:\n                if not is_connected_ref[0]:\n                    logger.info(f\"Skip sending {name}: is_connected_ref=False\")\n                    return\n                if not (\n                    websocket.application_state == WebSocketState.CONNECTED\n                    and websocket.client_state == WebSocketState.CONNECTED\n                ):\n                    logger.info(\n                        f\"Skip sending {name}: websocket state not CONNECTED \"\n                        f\"(application_state={websocket.application_state}, client_state={websocket.client_state})\"\n                    )\n                    return\n                await websocket.send_json({\"header\": {\"name\": name}, \"payload\": payload})\n                logger.debug(f\"Sent {name} to client\")\n            except Exception as e:\n                is_connected_ref[0] = False\n                logger.warning(f\"Failed to send {name} to client: {e}\")\n\n        async def _compute(self, text_snapshot: str) -> tuple[str, dict[str, Any]]:\n            optimized = text_snapshot\n            extracted: dict[str, Any] = {\"todos\": [], \"schedules\": []}\n            try:\n                optimized = await audio_service.optimize_transcription_text(text_snapshot)\n            except Exception as e:\n                logger.error(f\"实时优化失败: {e}\")\n            try:\n                extracted = await audio_service.extraction_service.extract_todos_and_schedules(\n                    text_snapshot\n                )\n            except Exception as e:\n                logger.error(f\"实时提取失败: {e}\")\n            return optimized, extracted\n\n        async def _run_once(self) -> None:\n            text_snapshot = self._buffer.strip()\n            if not text_snapshot:\n                return\n            optimized, extracted = await self._compute(text_snapshot)\n\n            preview = optimized.replace(\"\\n\", \" \")[:200]\n            todos_preview = extracted.get(\"todos\", [])\n            schedules_preview = extracted.get(\"schedules\", [])\n            logger.info(\"实时优化/提取完成，准备推送给前端\")\n            logger.info(f\"优化预览: {preview}\")\n            logger.info(f\"提取结果: todos={todos_preview}, schedules={schedules_preview}\")\n\n            await self._send(\"OptimizedTextChanged\", {\"text\": optimized})\n            await self._send(\n                \"ExtractionChanged\",\n                {\"todos\": extracted.get(\"todos\", []), \"schedules\": extracted.get(\"schedules\", [])},\n            )\n\n        async def _debounced_run(self, delay: float) -> None:\n            try:\n                await asyncio.sleep(delay)\n                await self._run_once()\n            finally:\n                self._pending = None\n\n        def on_final_sentence(self, text: str) -> None:\n            if not text:\n                return\n            if self._buffer:\n                self._buffer += \"\\n\"\n            self._buffer += text.strip()\n\n            now = asyncio.get_event_loop().time()\n            elapsed = now - self._last_emit\n            if elapsed >= throttle_seconds:\n                self._last_emit = now\n                _track_task(task_set, self._run_once())\n                return\n\n            if self._pending is None:\n                delay = max(0.0, throttle_seconds - elapsed)\n                self._pending = _track_task(task_set, self._debounced_run(delay))\n\n        def cancel(self) -> None:\n            if self._pending and not self._pending.done():\n                self._pending.cancel()\n            self._pending = None\n\n    throttler = _RealtimeNlpThrottler()\n    return throttler.on_final_sentence, throttler.cancel\n\n\ndef _handle_websocket_text_message(\n    message: dict,\n    logger,\n    segment_timestamps_ref: list[list[float] | None],\n    should_segment_ref: list[bool] | None = None,\n) -> bool:\n    \"\"\"处理 WebSocket 文本消息，返回是否应该停止流。\n\n    Returns:\n        True 如果应该停止流，False 如果继续\n    \"\"\"\n    msg_type = message.get(\"type\")\n    if msg_type == \"stop\":\n        segment_timestamps_from_frontend = message.get(\"segment_timestamps\", [])\n        if isinstance(segment_timestamps_from_frontend, list):\n            segment_timestamps_ref[0] = segment_timestamps_from_frontend\n            logger.info(\n                f\"Received stop signal from client with {len(segment_timestamps_from_frontend)} segment timestamps\"\n            )\n        else:\n            logger.info(\"Received stop signal from client\")\n        return True\n    if msg_type == \"segment\" and should_segment_ref:\n        # 客户端请求分段（用于手动分段或同步）\n        should_segment_ref[0] = True\n        logger.info(\"Received segment request from client\")\n    return False\n\n\nasync def _audio_stream_generator(\n    websocket: WebSocket,\n    logger,\n    audio_chunks: list[bytes],\n    segment_timestamps_ref: list[list[float] | None],\n    should_segment_ref: list[bool] | None = None,\n):\n    \"\"\"Yield audio bytes from websocket until stop signal.\n\n    Args:\n        segment_timestamps_ref: 用于存储从客户端接收的时间戳数组的引用\n        should_segment_ref: 用于标记是否需要分段（外部可以设置此标志来触发分段）\n    \"\"\"\n    while True:\n        try:\n            data = await websocket.receive()\n            if \"bytes\" in data:\n                chunk = data[\"bytes\"]\n                if chunk:\n                    audio_chunks.append(chunk)\n                    yield chunk\n                continue\n            if \"text\" in data:\n                try:\n                    message = json.loads(data[\"text\"])\n                    should_stop = _handle_websocket_text_message(\n                        message, logger, segment_timestamps_ref, should_segment_ref\n                    )\n                    if should_stop:\n                        break\n                except json.JSONDecodeError:\n                    logger.debug(f\"Ignoring non-JSON text message: {data.get('text', '')[:50]}\")\n                continue\n        except WebSocketDisconnect:\n            logger.info(\"WebSocket disconnected in audio stream generator\")\n            break\n        except Exception as e:\n            logger.error(f\"Error in audio stream generator: {e}\")\n            break\n\n\ndef _parse_init_message(logger, init_message: dict[str, Any]) -> bool:\n    logger.info(f\"Received init message: {init_message}\")\n    return bool(init_message.get(\"is_24x7\", False))\n\n\ndef _apply_agc_to_pcm(logger, pcm_bytes: bytes) -> bytes:\n    try:\n        samples = array.array(\"h\")\n        samples.frombytes(pcm_bytes)\n        if not samples:\n            return pcm_bytes\n\n        max_abs = max(abs(s) for s in samples)\n        rms = (sum(s * s for s in samples) / len(samples)) ** 0.5\n        logger.info(f\"录音原始PCM: samples={len(samples)}, max_abs={max_abs}, rms={rms:.2f}\")\n\n        if max_abs < PCM_SILENCE_MAX_ABS and rms < PCM_SILENCE_RMS:\n            logger.warning(\"录音PCM振幅极低，可能无声；请检查麦克风/权限/设备输入。\")\n            return pcm_bytes\n\n        target_peak = AGC_TARGET_PEAK_RATIO * INT16_MAX\n        gain = target_peak / max_abs if max_abs > 0 else 1.0\n        gain = min(gain, MAX_AGC_GAIN)\n        if gain <= AGC_APPLY_THRESHOLD_GAIN:\n            return pcm_bytes\n\n        logger.info(f\"应用自动增益: x{gain:.2f}\")\n        for i in range(len(samples)):\n            v = int(samples[i] * gain)\n            if v > INT16_MAX:\n                v = INT16_MAX\n            elif v < INT16_MIN:\n                v = INT16_MIN\n            samples[i] = v\n        return samples.tobytes()\n    except Exception as e:\n        logger.debug(f\"音量检测失败: {e}\")\n        return pcm_bytes\n\n\ndef _detect_silence(\n    pcm_bytes: bytes,\n    threshold_max_abs: int = PCM_SILENCE_MAX_ABS,\n    threshold_rms: float = PCM_SILENCE_RMS,\n) -> bool:\n    \"\"\"检测音频是否为静音\n\n    Args:\n        pcm_bytes: PCM音频数据\n        threshold_max_abs: 最大振幅阈值\n        threshold_rms: RMS阈值\n\n    Returns:\n        True if silent, False otherwise\n    \"\"\"\n    try:\n        samples = array.array(\"h\")\n        samples.frombytes(pcm_bytes)\n        if not samples:\n            return True\n\n        max_abs = max(abs(s) for s in samples)\n        rms = (sum(s * s for s in samples) / len(samples)) ** 0.5\n        return max_abs < threshold_max_abs and rms < threshold_rms\n    except Exception:\n        return False\n\n\ndef _persist_recording(\n    *,\n    logger,\n    audio_service,\n    audio_chunks: list[bytes],\n    recording_started_at: datetime,\n    is_24x7: bool,\n) -> tuple[int | None, float | None]:\n    if not audio_chunks:\n        return None, None\n\n    pcm_bytes = b\"\".join(audio_chunks)\n    duration = (get_utc_now() - recording_started_at).total_seconds()\n\n    pcm_bytes = _apply_agc_to_pcm(logger, pcm_bytes)\n    wav_bytes = _pcm16le_to_wav(pcm_bytes)\n\n    file_path = audio_service.generate_audio_file_path(recording_started_at)\n    file_path.parent.mkdir(parents=True, exist_ok=True)\n    file_path.write_bytes(wav_bytes)\n\n    recording_id = audio_service.create_recording(\n        file_path=str(file_path),\n        file_size=len(wav_bytes),\n        duration=duration,\n        is_24x7=is_24x7,\n    )\n    audio_service.complete_recording(recording_id)\n    return recording_id, duration\n\n\nasync def _save_transcription_if_any(\n    *,\n    audio_service,\n    recording_id: int | None,\n    text: str,\n    segment_timestamps: list[float] | None = None,\n) -> None:\n    if not recording_id or not text:\n        return\n    await audio_service.save_transcription(\n        recording_id=recording_id,\n        original_text=text,\n        auto_optimize=True,\n        segment_timestamps=segment_timestamps,\n    )\n\n\n# 导入分段相关功能（延迟导入以避免循环依赖）\ndef _get_segment_functions():\n    \"\"\"延迟导入分段函数以避免循环依赖\"\"\"\n    segment_module = importlib.import_module(\"lifetrace.routers.audio_ws_segment\")\n    return segment_module._save_current_segment, segment_module._segment_monitor_task\n\n\n# 导入 WebSocket 处理函数（延迟导入以避免循环依赖）\ndef _get_transcribe_handler():\n    \"\"\"延迟导入 WebSocket 处理函数以避免循环依赖\"\"\"\n    handler_module = importlib.import_module(\"lifetrace.routers.audio_ws_handler\")\n    return handler_module._handle_transcribe_ws\n\n\ndef register_audio_ws_routes(*, router: APIRouter, logger, asr_client, audio_service) -> None:\n    \"\"\"Register websocket endpoints onto the given router.\"\"\"\n\n    @router.websocket(\"/transcribe\")\n    async def websocket_transcribe(websocket: WebSocket) -> None:\n        _handle_transcribe_ws = _get_transcribe_handler()\n        await _handle_transcribe_ws(\n            websocket=websocket, logger=logger, asr_client=asr_client, audio_service=audio_service\n        )\n"
  },
  {
    "path": "lifetrace/routers/audio_ws_handler.py",
    "content": "\"\"\"Audio websocket handler logic.\n\nSplit from `audio_ws.py` to reduce file size and complexity.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport contextlib\nimport importlib\nimport json\n\nfrom fastapi import WebSocket, WebSocketDisconnect\n\nfrom lifetrace.util.time_utils import get_utc_now\n\n\nasync def _handle_json_error(websocket: WebSocket, logger, e: json.JSONDecodeError) -> None:\n    \"\"\"处理 JSON 解析错误\"\"\"\n    logger.error(f\"Failed to parse WebSocket message: {e}\")\n    with contextlib.suppress(Exception):\n        await websocket.close(code=1003, reason=\"Invalid message format\")\n\n\nasync def _handle_websocket_error(websocket: WebSocket, logger, e: Exception) -> None:\n    \"\"\"处理 WebSocket 错误\"\"\"\n    logger.error(f\"WebSocket error: {e}\", exc_info=True)\n    with contextlib.suppress(Exception):\n        await websocket.close(code=1011, reason=str(e))\n\n\nclass _RunTranscriptionStreamContext:\n    \"\"\"运行转录流的上下文，用于减少参数数量\"\"\"\n\n    def __init__(self, **kwargs):\n        self.asr_client = kwargs[\"asr_client\"]\n        self.websocket = kwargs[\"websocket\"]\n        self.logger = kwargs[\"logger\"]\n        self.audio_chunks = kwargs[\"audio_chunks\"]\n        self.segment_timestamps_ref = kwargs[\"segment_timestamps_ref\"]\n        self.should_segment_ref = kwargs[\"should_segment_ref\"]\n        self.on_result = kwargs[\"on_result\"]\n        self.on_error = kwargs[\"on_error\"]\n\n\nasync def _run_transcription_stream(*, ctx: _RunTranscriptionStreamContext) -> None:\n    \"\"\"运行 ASR 转录流\"\"\"\n    audio_ws_module = importlib.import_module(\"lifetrace.routers.audio_ws\")\n    audio_stream = audio_ws_module._audio_stream_generator(\n        websocket=ctx.websocket,\n        logger=ctx.logger,\n        audio_chunks=ctx.audio_chunks,\n        segment_timestamps_ref=ctx.segment_timestamps_ref,\n        should_segment_ref=ctx.should_segment_ref,\n    )\n    await ctx.asr_client.transcribe_stream(\n        audio_stream=audio_stream,\n        on_result=ctx.on_result,\n        on_error=ctx.on_error,\n    )\n\n\ndef _get_audio_ws_functions():\n    \"\"\"延迟导入 audio_ws 模块的函数\"\"\"\n    audio_ws_module = importlib.import_module(\"lifetrace.routers.audio_ws\")\n    return {\n        \"_audio_stream_generator\": audio_ws_module._audio_stream_generator,\n        \"_create_error_callback\": audio_ws_module._create_error_callback,\n        \"_create_realtime_nlp_handler\": audio_ws_module._create_realtime_nlp_handler,\n        \"_create_result_callback\": audio_ws_module._create_result_callback,\n        \"_get_segment_functions\": audio_ws_module._get_segment_functions,\n        \"_handle_json_error\": _handle_json_error,\n        \"_handle_websocket_error\": _handle_websocket_error,\n        \"_parse_init_message\": audio_ws_module._parse_init_message,\n        \"_persist_recording\": audio_ws_module._persist_recording,\n        \"_run_transcription_stream\": _run_transcription_stream,\n        \"_save_transcription_if_any\": audio_ws_module._save_transcription_if_any,\n    }\n\n\nclass _SaveFinalDataContext:\n    \"\"\"保存最终数据的上下文，用于减少参数数量\"\"\"\n\n    def __init__(self, **kwargs):\n        self.data_saved_ref = kwargs[\"data_saved_ref\"]\n        self.stop_segment_task_func = kwargs[\"stop_segment_task_func\"]\n        self.audio_chunks = kwargs[\"audio_chunks\"]\n        self.transcription_text_ref = kwargs[\"transcription_text_ref\"]\n        self.segment_timestamps_ref = kwargs[\"segment_timestamps_ref\"]\n        self.recording_started_at = kwargs[\"recording_started_at\"]\n        self.is_24x7_ref = kwargs[\"is_24x7_ref\"]\n        self.audio_service = kwargs[\"audio_service\"]\n        self.logger = kwargs[\"logger\"]\n        self._persist_recording = kwargs[\"_persist_recording\"]\n        self._save_transcription_if_any = kwargs[\"_save_transcription_if_any\"]\n\n\nasync def _save_final_data_internal(*, ctx: _SaveFinalDataContext) -> None:\n    \"\"\"保存最终数据（确保只执行一次）\"\"\"\n    if ctx.data_saved_ref[0]:\n        return\n    ctx.data_saved_ref[0] = True\n\n    try:\n        await ctx.stop_segment_task_func()\n\n        # 检查是否有数据需要保存\n        if not ctx.audio_chunks and not ctx.transcription_text_ref[0]:\n            ctx.logger.info(\"无数据需要保存\")\n            return\n\n        ctx.logger.info(\n            f\"保存最终数据: audio_chunks={len(ctx.audio_chunks)}, text_len={len(ctx.transcription_text_ref[0])}\"\n        )\n\n        # 保存最后一段\n        recording_id, _duration = ctx._persist_recording(\n            logger=ctx.logger,\n            audio_service=ctx.audio_service,\n            audio_chunks=ctx.audio_chunks,\n            recording_started_at=ctx.recording_started_at,\n            is_24x7=ctx.is_24x7_ref[0],\n        )\n        await ctx._save_transcription_if_any(\n            audio_service=ctx.audio_service,\n            recording_id=recording_id,\n            text=ctx.transcription_text_ref[0],\n            segment_timestamps=ctx.segment_timestamps_ref[0],\n        )\n\n        if recording_id:\n            ctx.logger.info(\n                f\"✅ 数据保存成功: recording_id={recording_id}, duration={_duration:.2f}s\"\n            )\n        else:\n            ctx.logger.warning(\"数据保存完成，但没有生成 recording_id（可能音频为空）\")\n    except Exception as e:\n        ctx.logger.error(f\"❌ 保存最终数据失败: {e}\", exc_info=True)\n\n\nasync def _initialize_handlers_internal(\n    *,\n    websocket: WebSocket,\n    logger,\n    transcription_text_ref: list[str],\n    is_connected_ref: list[bool],\n    is_24x7_ref: list[bool],\n    task_set: set[asyncio.Task],\n    on_final_sentence,\n    _parse_init_message,\n    _create_result_callback,\n    _create_error_callback,\n) -> tuple:\n    \"\"\"初始化处理函数和回调\"\"\"\n    init_message = await websocket.receive_json()\n    is_24x7 = _parse_init_message(logger, init_message)\n    is_24x7_ref[0] = is_24x7\n\n    on_result_base = _create_result_callback(\n        websocket=websocket,\n        logger=logger,\n        transcription_text_ref=transcription_text_ref,\n        is_connected_ref=is_connected_ref,\n        task_set=task_set,\n    )\n\n    def on_result(text: str, is_final: bool) -> None:\n        on_result_base(text, is_final)\n        if is_final:\n            on_final_sentence(text)\n\n    on_error = _create_error_callback(\n        websocket=websocket, logger=logger, is_connected_ref=is_connected_ref, task_set=task_set\n    )\n\n    return on_result, on_error, is_24x7\n\n\nclass _StartSegmentMonitorContext:\n    \"\"\"启动分段监控的上下文，用于减少参数数量\"\"\"\n\n    def __init__(self, **kwargs):\n        self.is_24x7 = kwargs[\"is_24x7\"]\n        self.logger = kwargs[\"logger\"]\n        self.audio_service = kwargs[\"audio_service\"]\n        self.recording_started_at = kwargs[\"recording_started_at\"]\n        self.audio_chunks = kwargs[\"audio_chunks\"]\n        self.transcription_text_ref = kwargs[\"transcription_text_ref\"]\n        self.segment_timestamps_ref = kwargs[\"segment_timestamps_ref\"]\n        self.should_segment_ref = kwargs[\"should_segment_ref\"]\n        self.is_connected_ref = kwargs[\"is_connected_ref\"]\n        self.websocket = kwargs[\"websocket\"]\n        self._get_segment_functions = kwargs[\"_get_segment_functions\"]\n\n\nasync def _start_segment_monitor_internal(\n    *, ctx: _StartSegmentMonitorContext\n) -> asyncio.Task | None:\n    \"\"\"启动分段监控任务\"\"\"\n    if not ctx.is_24x7:\n        return None\n    _save_current_segment, _segment_monitor_task = ctx._get_segment_functions()\n    return asyncio.create_task(\n        _segment_monitor_task(\n            params={\n                \"logger\": ctx.logger,\n                \"audio_service\": ctx.audio_service,\n                \"recording_started_at\": ctx.recording_started_at,\n                \"audio_chunks\": ctx.audio_chunks,\n                \"transcription_text_ref\": ctx.transcription_text_ref,\n                \"segment_timestamps_ref\": ctx.segment_timestamps_ref,\n                \"should_segment_ref\": ctx.should_segment_ref,\n                \"is_connected_ref\": ctx.is_connected_ref,\n                \"websocket\": ctx.websocket,\n            },\n            is_24x7=ctx.is_24x7,\n        )\n    )\n\n\ndef _setup_websocket_state():\n    \"\"\"初始化 WebSocket 状态变量\"\"\"\n    recording_started_at = get_utc_now()\n    transcription_text_ref: list[str] = [\"\"]\n    audio_chunks: list[bytes] = []\n    is_connected_ref: list[bool] = [True]\n    segment_timestamps_ref: list[list[float] | None] = [None]\n    should_segment_ref: list[bool] = [False]\n    is_24x7_ref: list[bool] = [False]\n    data_saved_ref: list[bool] = [False]\n    task_set: set[asyncio.Task] = set()\n    return {\n        \"recording_started_at\": recording_started_at,\n        \"transcription_text_ref\": transcription_text_ref,\n        \"audio_chunks\": audio_chunks,\n        \"is_connected_ref\": is_connected_ref,\n        \"segment_timestamps_ref\": segment_timestamps_ref,\n        \"should_segment_ref\": should_segment_ref,\n        \"is_24x7_ref\": is_24x7_ref,\n        \"data_saved_ref\": data_saved_ref,\n        \"task_set\": task_set,\n    }\n\n\nasync def _run_transcription_with_handlers(\n    *,\n    asr_client,\n    websocket: WebSocket,\n    logger,\n    state: dict,\n    on_result,\n    on_error,\n    _run_transcription_stream,\n):\n    \"\"\"运行转录流处理\"\"\"\n    ctx = _RunTranscriptionStreamContext(\n        asr_client=asr_client,\n        websocket=websocket,\n        logger=logger,\n        audio_chunks=state[\"audio_chunks\"],\n        segment_timestamps_ref=state[\"segment_timestamps_ref\"],\n        should_segment_ref=state[\"should_segment_ref\"],\n        on_result=on_result,\n        on_error=on_error,\n    )\n    await _run_transcription_stream(ctx=ctx)\n\n\nasync def _setup_websocket_connection(*, websocket: WebSocket, logger) -> dict:\n    \"\"\"设置 WebSocket 连接并初始化状态\"\"\"\n    await websocket.accept()\n    logger.info(\n        f\"WebSocket client connected: application_state={websocket.application_state}, client_state={websocket.client_state}\"\n    )\n    return _setup_websocket_state()\n\n\nasync def _create_handlers_and_monitor(\n    *,\n    websocket: WebSocket,\n    logger,\n    audio_service,\n    state: dict,\n    funcs: dict,\n    on_final_sentence,\n) -> tuple:\n    \"\"\"创建处理函数并启动监控任务\"\"\"\n    on_result, on_error, is_24x7 = await _initialize_handlers_internal(\n        websocket=websocket,\n        logger=logger,\n        transcription_text_ref=state[\"transcription_text_ref\"],\n        is_connected_ref=state[\"is_connected_ref\"],\n        is_24x7_ref=state[\"is_24x7_ref\"],\n        task_set=state[\"task_set\"],\n        on_final_sentence=on_final_sentence,\n        _parse_init_message=funcs[\"_parse_init_message\"],\n        _create_result_callback=funcs[\"_create_result_callback\"],\n        _create_error_callback=funcs[\"_create_error_callback\"],\n    )\n    segment_ctx = _StartSegmentMonitorContext(\n        is_24x7=is_24x7,\n        logger=logger,\n        audio_service=audio_service,\n        recording_started_at=state[\"recording_started_at\"],\n        audio_chunks=state[\"audio_chunks\"],\n        transcription_text_ref=state[\"transcription_text_ref\"],\n        segment_timestamps_ref=state[\"segment_timestamps_ref\"],\n        should_segment_ref=state[\"should_segment_ref\"],\n        is_connected_ref=state[\"is_connected_ref\"],\n        websocket=websocket,\n        _get_segment_functions=funcs[\"_get_segment_functions\"],\n    )\n    segment_task = await _start_segment_monitor_internal(ctx=segment_ctx)\n    return on_result, on_error, segment_task\n\n\nasync def _run_main_transcription_flow(\n    *,\n    asr_client,\n    websocket: WebSocket,\n    logger,\n    state: dict,\n    funcs: dict,\n    on_final_sentence,\n    _run_transcription_stream,\n    audio_service,\n) -> tuple:\n    \"\"\"运行主要的转录流程\"\"\"\n    on_result, on_error, segment_task = await _create_handlers_and_monitor(\n        websocket=websocket,\n        logger=logger,\n        audio_service=audio_service,\n        state=state,\n        funcs=funcs,\n        on_final_sentence=on_final_sentence,\n    )\n\n    await _run_transcription_with_handlers(\n        asr_client=asr_client,\n        websocket=websocket,\n        logger=logger,\n        state=state,\n        on_result=on_result,\n        on_error=on_error,\n        _run_transcription_stream=_run_transcription_stream,\n    )\n\n    return on_result, on_error, segment_task\n\n\nasync def _handle_websocket_errors(\n    *,\n    websocket: WebSocket,\n    logger,\n    e: Exception,\n    _handle_json_error,\n    _handle_websocket_error,\n) -> None:\n    \"\"\"处理 WebSocket 错误\"\"\"\n    if isinstance(e, WebSocketDisconnect):\n        logger.info(\"WebSocket client disconnected，正在保存数据...\")\n    elif isinstance(e, json.JSONDecodeError):\n        await _handle_json_error(websocket, logger, e)\n    elif isinstance(e, asyncio.CancelledError):\n        logger.warning(\"WebSocket handler 被取消，正在保存数据...\")\n    elif isinstance(e, KeyboardInterrupt):\n        logger.warning(\"收到 KeyboardInterrupt，正在保存数据...\")\n    else:\n        await _handle_websocket_error(websocket, logger, e)\n\n\nasync def _create_nlp_handler(\n    *, websocket: WebSocket, logger, audio_service, state: dict, funcs: dict\n) -> tuple:\n    \"\"\"创建 NLP 处理函数\"\"\"\n    _create_realtime_nlp_handler = funcs[\"_create_realtime_nlp_handler\"]\n    return _create_realtime_nlp_handler(\n        websocket=websocket,\n        logger=logger,\n        audio_service=audio_service,\n        is_connected_ref=state[\"is_connected_ref\"],\n        task_set=state[\"task_set\"],\n        throttle_seconds=8.0,\n    )\n\n\nasync def _create_save_final_data_func(\n    *,\n    state: dict,\n    stop_segment_task_func,\n    audio_service,\n    logger,\n    _persist_recording,\n    _save_transcription_if_any,\n):\n    \"\"\"创建保存最终数据的函数\"\"\"\n\n    async def save_final_data():\n        \"\"\"保存最终数据（确保只执行一次）\"\"\"\n        ctx = _SaveFinalDataContext(\n            data_saved_ref=state[\"data_saved_ref\"],\n            stop_segment_task_func=stop_segment_task_func,\n            audio_chunks=state[\"audio_chunks\"],\n            transcription_text_ref=state[\"transcription_text_ref\"],\n            segment_timestamps_ref=state[\"segment_timestamps_ref\"],\n            recording_started_at=state[\"recording_started_at\"],\n            is_24x7_ref=state[\"is_24x7_ref\"],\n            audio_service=audio_service,\n            logger=logger,\n            _persist_recording=_persist_recording,\n            _save_transcription_if_any=_save_transcription_if_any,\n        )\n        await _save_final_data_internal(ctx=ctx)\n\n    return save_final_data\n\n\nasync def _cleanup_websocket(\n    *, state: dict, cancel_realtime_nlp, save_final_data, logger, websocket: WebSocket\n) -> None:\n    \"\"\"清理 WebSocket 连接\"\"\"\n    state[\"is_connected_ref\"][0] = False\n    cancel_realtime_nlp()\n\n    try:\n        await save_final_data()\n    except Exception as e:\n        logger.error(f\"finally 中保存数据失败: {e}\", exc_info=True)\n\n    logger.info(\n        f\"WebSocket handler finished: application_state={websocket.application_state}, client_state={websocket.client_state}\"\n    )\n\n\nasync def _handle_transcribe_ws(*, websocket: WebSocket, logger, asr_client, audio_service) -> None:\n    funcs = _get_audio_ws_functions()\n    state = await _setup_websocket_connection(websocket=websocket, logger=logger)\n    segment_task: asyncio.Task | None = None\n\n    on_final_sentence, cancel_realtime_nlp = await _create_nlp_handler(\n        websocket=websocket,\n        logger=logger,\n        audio_service=audio_service,\n        state=state,\n        funcs=funcs,\n    )\n\n    async def stop_segment_task():\n        \"\"\"停止分段监控任务\"\"\"\n        nonlocal segment_task\n        if segment_task and not segment_task.done():\n            segment_task.cancel()\n            try:\n                await segment_task\n            except asyncio.CancelledError:\n                logger.info(\"分段监控任务已取消\")\n\n    save_final_data = await _create_save_final_data_func(\n        state=state,\n        stop_segment_task_func=stop_segment_task,\n        audio_service=audio_service,\n        logger=logger,\n        _persist_recording=funcs[\"_persist_recording\"],\n        _save_transcription_if_any=funcs[\"_save_transcription_if_any\"],\n    )\n\n    try:\n        await _run_main_transcription_flow(\n            asr_client=asr_client,\n            websocket=websocket,\n            logger=logger,\n            state=state,\n            funcs=funcs,\n            on_final_sentence=on_final_sentence,\n            _run_transcription_stream=funcs[\"_run_transcription_stream\"],\n            audio_service=audio_service,\n        )\n        await save_final_data()\n    except Exception as e:\n        await _handle_websocket_errors(\n            websocket=websocket,\n            logger=logger,\n            e=e,\n            _handle_json_error=funcs[\"_handle_json_error\"],\n            _handle_websocket_error=funcs[\"_handle_websocket_error\"],\n        )\n    finally:\n        await _cleanup_websocket(\n            state=state,\n            cancel_realtime_nlp=cancel_realtime_nlp,\n            save_final_data=save_final_data,\n            logger=logger,\n            websocket=websocket,\n        )\n"
  },
  {
    "path": "lifetrace/routers/audio_ws_segment.py",
    "content": "\"\"\"Audio websocket segment monitoring and saving logic.\n\nSplit from `audio_ws.py` to reduce file size and complexity.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport importlib\nfrom typing import TYPE_CHECKING\n\nfrom starlette.websockets import WebSocketState\n\nfrom lifetrace.util.time_utils import get_utc_now\n\nif TYPE_CHECKING:\n    from datetime import datetime\n\n# 常量（从 audio_ws 复制以避免循环导入）\nSILENCE_CHECK_INTERVAL_SECONDS = 60\nSILENCE_DETECTION_THRESHOLD_SECONDS = 600\nSEGMENT_DURATION_MINUTES = 30\n\n_segment_tasks: set[asyncio.Task] = set()\n\n\ndef _track_task(coro) -> asyncio.Task:\n    task = asyncio.create_task(coro)\n    _segment_tasks.add(task)\n    task.add_done_callback(_segment_tasks.discard)\n    return task\n\n\nclass _SegmentMonitorContext:\n    \"\"\"分段监控任务的上下文，用于减少参数数量\"\"\"\n\n    def __init__(self, **kwargs):\n        self.logger = kwargs[\"logger\"]\n        self.audio_service = kwargs[\"audio_service\"]\n        self.recording_started_at = kwargs[\"recording_started_at\"]\n        self.audio_chunks = kwargs[\"audio_chunks\"]\n        self.transcription_text_ref = kwargs[\"transcription_text_ref\"]\n        self.segment_timestamps_ref = kwargs[\"segment_timestamps_ref\"]\n        self.should_segment_ref = kwargs[\"should_segment_ref\"]\n        self.is_connected_ref = kwargs[\"is_connected_ref\"]\n        self.websocket = kwargs.get(\"websocket\")\n\n\nclass _SegmentSaveContext:\n    \"\"\"分段保存的上下文，用于减少参数数量\"\"\"\n\n    def __init__(self, **kwargs):\n        self.logger = kwargs[\"logger\"]\n        self.audio_service = kwargs[\"audio_service\"]\n        self.audio_chunks = kwargs[\"audio_chunks\"]\n        self.transcription_text_ref = kwargs[\"transcription_text_ref\"]\n        self.segment_timestamps_ref = kwargs[\"segment_timestamps_ref\"]\n        self.segment_start_time = kwargs[\"segment_start_time\"]\n        self.websocket = kwargs.get(\"websocket\")\n        self.is_connected_ref = kwargs.get(\"is_connected_ref\")\n        self.segment_reason = kwargs.get(\"segment_reason\")\n\n\nasync def _notify_segment_saved(ctx: _SegmentSaveContext) -> None:\n    \"\"\"通知前端分段已保存\"\"\"\n    if not ctx.websocket or not ctx.is_connected_ref or not ctx.is_connected_ref[0]:\n        return\n\n    try:\n        if (\n            ctx.websocket.application_state == WebSocketState.CONNECTED\n            and ctx.websocket.client_state == WebSocketState.CONNECTED\n        ):\n            reason_message = ctx.segment_reason or \"当前段已保存，开始新段\"\n            await ctx.websocket.send_json(\n                {\n                    \"header\": {\"name\": \"SegmentSaved\"},\n                    \"payload\": {\n                        \"message\": reason_message,\n                        \"segment_start_time\": ctx.segment_start_time.isoformat(),\n                    },\n                }\n            )\n            ctx.logger.info(\"已通知前端分段保存\")\n    except Exception as e:\n        ctx.logger.warning(f\"通知前端分段保存失败: {e}\")\n\n\nasync def _persist_segment_async(\n    *,\n    logger,\n    audio_service,\n    audio_chunks: list[bytes],\n    transcription_text: str,\n    segment_timestamps: list[float] | None,\n    segment_start_time: datetime,\n) -> None:\n    \"\"\"异步保存分段（不阻塞主流程）\"\"\"\n    # 延迟导入以避免循环导入\n    audio_ws_module = importlib.import_module(\"lifetrace.routers.audio_ws\")\n    _persist_recording = audio_ws_module._persist_recording\n    _save_transcription_if_any = audio_ws_module._save_transcription_if_any\n\n    try:\n        recording_id, _duration = _persist_recording(\n            logger=logger,\n            audio_service=audio_service,\n            audio_chunks=audio_chunks,\n            recording_started_at=segment_start_time,\n            is_24x7=True,\n        )\n        await _save_transcription_if_any(\n            audio_service=audio_service,\n            recording_id=recording_id,\n            text=transcription_text,\n            segment_timestamps=segment_timestamps,\n        )\n        logger.info(f\"分段保存完成: recording_id={recording_id}, duration={_duration:.2f}s\")\n    except Exception as e:\n        logger.error(f\"保存分段失败: {e}\", exc_info=True)\n\n\nasync def _save_current_segment(*, params: dict) -> None:\n    \"\"\"保存当前段并清空缓冲区\"\"\"\n    logger = params[\"logger\"]\n    audio_service = params[\"audio_service\"]\n    audio_chunks = params[\"audio_chunks\"]\n    transcription_text_ref = params[\"transcription_text_ref\"]\n    segment_timestamps_ref = params[\"segment_timestamps_ref\"]\n    segment_start_time = params[\"segment_start_time\"]\n    websocket = params.get(\"websocket\")\n    is_connected_ref = params.get(\"is_connected_ref\")\n    segment_reason = params.get(\"segment_reason\")\n\n    if not audio_chunks:\n        logger.debug(\"当前段没有音频数据，跳过保存\")\n        return\n\n    # 保存当前段\n    current_chunks = audio_chunks.copy()\n    current_text = transcription_text_ref[0]\n    current_timestamps = segment_timestamps_ref[0]\n\n    # 清空缓冲区，准备新段\n    audio_chunks.clear()\n    transcription_text_ref[0] = \"\"\n    segment_timestamps_ref[0] = None\n\n    # 创建上下文\n    ctx = _SegmentSaveContext(\n        **{\n            \"logger\": logger,\n            \"audio_service\": audio_service,\n            \"audio_chunks\": current_chunks,\n            \"transcription_text_ref\": [current_text],\n            \"segment_timestamps_ref\": [current_timestamps],\n            \"segment_start_time\": segment_start_time,\n            \"websocket\": websocket,\n            \"is_connected_ref\": is_connected_ref,\n            \"segment_reason\": segment_reason,\n        }\n    )\n\n    # 通知前端分段已保存\n    await _notify_segment_saved(ctx)\n\n    # 异步保存当前段（不阻塞）\n    _track_task(\n        _persist_segment_async(\n            logger=ctx.logger,\n            audio_service=ctx.audio_service,\n            audio_chunks=ctx.audio_chunks,\n            transcription_text=ctx.transcription_text_ref[0],\n            segment_timestamps=ctx.segment_timestamps_ref[0],\n            segment_start_time=ctx.segment_start_time,\n        )\n    )\n\n\nasync def _check_time_segment(\n    ctx: _SegmentMonitorContext, now: datetime, segment_start_time: datetime\n) -> bool:\n    \"\"\"检查30分钟时间分段，返回是否已分段\"\"\"\n    elapsed = (now - segment_start_time).total_seconds()\n    if elapsed >= SEGMENT_DURATION_MINUTES * 60:\n        ctx.logger.info(\"达到30分钟分段时间，保存当前段并开始新段\")\n        await _save_current_segment(\n            params={\n                \"logger\": ctx.logger,\n                \"audio_service\": ctx.audio_service,\n                \"audio_chunks\": ctx.audio_chunks,\n                \"transcription_text_ref\": ctx.transcription_text_ref,\n                \"segment_timestamps_ref\": ctx.segment_timestamps_ref,\n                \"segment_start_time\": segment_start_time,\n                \"websocket\": ctx.websocket,\n                \"is_connected_ref\": ctx.is_connected_ref,\n                \"segment_reason\": \"达到30分钟分段时间，保存当前段并开始新段\",\n            }\n        )\n        return True\n    return False\n\n\nasync def _check_silence_segment(\n    ctx: _SegmentMonitorContext,\n    now: datetime,\n    segment_start_time: datetime,\n    silence_start_time: datetime | None,\n) -> tuple[bool, datetime | None]:\n    \"\"\"检查静音分段，返回(是否已分段, 新的静音开始时间)\"\"\"\n    if len(ctx.audio_chunks) == 0:\n        return False, silence_start_time\n\n    # 检查最近一段音频是否为静音\n    # 延迟导入以避免循环导入\n    audio_ws_module = importlib.import_module(\"lifetrace.routers.audio_ws\")\n    _detect_silence = audio_ws_module._detect_silence\n    recent_chunks = ctx.audio_chunks[-10:]  # 检查最近10个chunk\n    recent_audio = b\"\".join(recent_chunks)\n    is_silent = _detect_silence(recent_audio)\n\n    if is_silent:\n        if silence_start_time is None:\n            return False, now\n        silence_duration = (now - silence_start_time).total_seconds()\n        if silence_duration >= SILENCE_DETECTION_THRESHOLD_SECONDS:\n            ctx.logger.info(f\"检测到长时间静音（{silence_duration:.0f}秒），保存当前段并开始新段\")\n            await _save_current_segment(\n                params={\n                    \"logger\": ctx.logger,\n                    \"audio_service\": ctx.audio_service,\n                    \"audio_chunks\": ctx.audio_chunks,\n                    \"transcription_text_ref\": ctx.transcription_text_ref,\n                    \"segment_timestamps_ref\": ctx.segment_timestamps_ref,\n                    \"segment_start_time\": segment_start_time,\n                    \"websocket\": ctx.websocket,\n                    \"is_connected_ref\": ctx.is_connected_ref,\n                    \"segment_reason\": f\"检测到长时间静音（{silence_duration:.0f}秒），保存当前段并开始新段\",\n                }\n            )\n            return True, None\n        return False, silence_start_time\n    # 有语音，重置静音计时\n    return False, None\n\n\nasync def _check_manual_segment(\n    ctx: _SegmentMonitorContext, now: datetime, segment_start_time: datetime\n) -> bool:\n    \"\"\"检查外部分段请求，返回是否已分段\"\"\"\n    _ = now\n    if ctx.should_segment_ref[0]:\n        ctx.logger.info(\"收到分段请求，保存当前段并开始新段\")\n        await _save_current_segment(\n            params={\n                \"logger\": ctx.logger,\n                \"audio_service\": ctx.audio_service,\n                \"audio_chunks\": ctx.audio_chunks,\n                \"transcription_text_ref\": ctx.transcription_text_ref,\n                \"segment_timestamps_ref\": ctx.segment_timestamps_ref,\n                \"segment_start_time\": segment_start_time,\n                \"websocket\": ctx.websocket,\n                \"is_connected_ref\": ctx.is_connected_ref,\n                \"segment_reason\": \"收到分段请求，保存当前段并开始新段\",\n            }\n        )\n        ctx.should_segment_ref[0] = False\n        return True\n    return False\n\n\nasync def _segment_monitor_task(*, params: dict, is_24x7: bool) -> None:\n    \"\"\"监控分段条件：30分钟时间分段 + 静音检测\"\"\"\n    _ = is_24x7\n    logger = params[\"logger\"]\n    recording_started_at = params[\"recording_started_at\"]\n\n    ctx = _SegmentMonitorContext(**params)\n    segment_start_time = recording_started_at\n    silence_start_time: datetime | None = None\n\n    while ctx.is_connected_ref[0]:\n        try:\n            await asyncio.sleep(SILENCE_CHECK_INTERVAL_SECONDS)\n\n            if not ctx.is_connected_ref[0]:\n                break\n\n            now = get_utc_now()\n\n            # 检查30分钟时间分段\n            if await _check_time_segment(ctx, now, segment_start_time):\n                segment_start_time = now\n                silence_start_time = None\n                ctx.recording_started_at = now\n                continue\n\n            # 检查静音（仅在最近有语音的情况下检查）\n            was_segmented, silence_start_time = await _check_silence_segment(\n                ctx, now, segment_start_time, silence_start_time\n            )\n            if was_segmented:\n                segment_start_time = now\n                ctx.recording_started_at = now\n                continue\n\n            # 检查外部分段请求\n            if await _check_manual_segment(ctx, now, segment_start_time):\n                segment_start_time = now\n                silence_start_time = None\n                ctx.recording_started_at = now\n\n        except asyncio.CancelledError:\n            logger.info(\"分段监控任务已取消\")\n            break\n        except Exception as e:\n            logger.error(f\"分段监控任务错误: {e}\", exc_info=True)\n            await asyncio.sleep(5)  # 出错后等待5秒再继续\n"
  },
  {
    "path": "lifetrace/routers/automation.py",
    "content": "\"\"\"自动化任务路由\"\"\"\n\nfrom fastapi import APIRouter, HTTPException\n\nfrom lifetrace.schemas.automation import (\n    AutomationTaskCreate,\n    AutomationTaskListResponse,\n    AutomationTaskResponse,\n    AutomationTaskUpdate,\n)\nfrom lifetrace.services.automation_task_service import AutomationTaskService\n\nrouter = APIRouter(prefix=\"/api/automation\", tags=[\"automation\"])\n\n\n@router.get(\"/tasks\", response_model=AutomationTaskListResponse)\nasync def list_tasks():\n    service = AutomationTaskService()\n    tasks = service.list_tasks()\n    return AutomationTaskListResponse(\n        total=len(tasks),\n        tasks=[AutomationTaskResponse(**task) for task in tasks],\n    )\n\n\n@router.get(\"/tasks/{task_id}\", response_model=AutomationTaskResponse)\nasync def get_task(task_id: int):\n    service = AutomationTaskService()\n    task = service.get_task(task_id)\n    if not task:\n        raise HTTPException(status_code=404, detail=\"任务不存在\")\n    return task\n\n\n@router.post(\"/tasks\", response_model=AutomationTaskResponse)\nasync def create_task(request: AutomationTaskCreate):\n    service = AutomationTaskService()\n    task = service.create_task(\n        name=request.name,\n        description=request.description,\n        enabled=request.enabled,\n        schedule=request.schedule,\n        action=request.action,\n    )\n    if not task:\n        raise HTTPException(status_code=500, detail=\"创建任务失败\")\n    return task\n\n\n@router.put(\"/tasks/{task_id}\", response_model=AutomationTaskResponse)\nasync def update_task(task_id: int, request: AutomationTaskUpdate):\n    service = AutomationTaskService()\n    task = service.update_task(\n        task_id,\n        name=request.name,\n        description=request.description,\n        enabled=request.enabled,\n        schedule=request.schedule,\n        action=request.action,\n    )\n    if not task:\n        raise HTTPException(status_code=404, detail=\"任务不存在\")\n    return task\n\n\n@router.delete(\"/tasks/{task_id}\")\nasync def delete_task(task_id: int):\n    service = AutomationTaskService()\n    if not service.delete_task(task_id):\n        raise HTTPException(status_code=404, detail=\"任务不存在\")\n    return {\"success\": True}\n\n\n@router.post(\"/tasks/{task_id}/run\")\nasync def run_task(task_id: int):\n    service = AutomationTaskService()\n    task = service.get_task(task_id)\n    if not task:\n        raise HTTPException(status_code=404, detail=\"任务不存在\")\n    success = service.run_task(task_id)\n    if not success:\n        raise HTTPException(status_code=400, detail=\"任务执行失败\")\n    return {\"success\": True}\n\n\n@router.post(\"/tasks/{task_id}/pause\")\nasync def pause_task(task_id: int):\n    service = AutomationTaskService()\n    task = service.update_task(\n        task_id,\n        name=None,\n        description=None,\n        enabled=False,\n        schedule=None,\n        action=None,\n    )\n    if not task:\n        raise HTTPException(status_code=404, detail=\"任务不存在\")\n    return {\"success\": True}\n\n\n@router.post(\"/tasks/{task_id}/resume\")\nasync def resume_task(task_id: int):\n    service = AutomationTaskService()\n    task = service.update_task(\n        task_id,\n        name=None,\n        description=None,\n        enabled=True,\n        schedule=None,\n        action=None,\n    )\n    if not task:\n        raise HTTPException(status_code=404, detail=\"任务不存在\")\n    return {\"success\": True}\n"
  },
  {
    "path": "lifetrace/routers/chat/__init__.py",
    "content": "\"\"\"聊天相关路由聚合包。\n\n此包仅导出统一的 `router`，具体路由实现拆分在多个子模块中：\n- `core`：基础问答与流式聊天\n- `context`：带事件上下文的流式聊天\n- `plan`：Plan 问卷与总结相关路由\n- `misc`：会话管理、历史记录、查询建议等辅助接口\n- `message_todo_extraction`：从消息中提取待办\n\"\"\"\n\nfrom . import context as _context  # noqa: F401\n\n# 导入子模块以注册对应路由（仅用于副作用）\nfrom . import core as _core  # noqa: F401\nfrom . import message_todo_extraction as _message_todo_extraction  # noqa: F401\nfrom . import misc as _misc  # noqa: F401\nfrom . import plan as _plan  # noqa: F401\nfrom .base import router as router\n"
  },
  {
    "path": "lifetrace/routers/chat/base.py",
    "content": "\"\"\"聊天路由基础设施：共享 router 与通用工具函数。\"\"\"\n\nfrom typing import Any, TypedDict\n\nfrom fastapi import APIRouter\n\nfrom lifetrace.services.chat_service import ChatService\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.token_usage_logger import log_token_usage\n\nlogger = get_logger()\n\nrouter = APIRouter(prefix=\"/api/chat\", tags=[\"chat\"])\n\n\nclass StreamMeta(TypedDict, total=False):\n    \"\"\"统一封装流式聊天的上下文字段，减少函数参数数量。\"\"\"\n\n    session_id: str\n    endpoint: str\n    feature_type: str\n    user_query: str\n    additional_info: dict[str, Any]\n\n\ndef _create_llm_stream_generator(\n    *,\n    rag_svc,\n    messages: list[dict[str, str]],\n    temperature: float,\n    chat_service: ChatService,\n    meta: StreamMeta,\n):\n    \"\"\"构造统一的 LLM 流式生成器，并负责保存消息与记录 token 使用量。\"\"\"\n\n    def token_generator():\n        try:\n            if not rag_svc.llm_client.is_available():\n                yield \"抱歉，LLM服务当前不可用，请稍后重试。\"\n                return\n\n            response = rag_svc.llm_client.client.chat.completions.create(\n                model=rag_svc.llm_client.model,\n                messages=messages,\n                temperature=temperature,\n                stream=True,\n                stream_options={\"include_usage\": True},\n            )\n\n            total_content = \"\"\n            usage_info = None\n\n            for chunk in response:\n                if hasattr(chunk, \"usage\") and chunk.usage:\n                    usage_info = chunk.usage\n\n                if chunk.choices and len(chunk.choices) > 0 and chunk.choices[0].delta.content:\n                    content = chunk.choices[0].delta.content\n                    total_content += content\n                    yield content\n\n            if total_content:\n                session_id = meta.get(\"session_id\")\n                if session_id:\n                    chat_service.add_message(\n                        session_id=session_id,\n                        role=\"assistant\",\n                        content=total_content,\n                        token_count=usage_info.total_tokens if usage_info else None,\n                        model=rag_svc.llm_client.model,\n                    )\n                    logger.info(\"[stream] 消息已保存到数据库\")\n\n            if usage_info:\n                session_id = meta.get(\"session_id\")\n                _log_stream_token_usage(\n                    rag_svc=rag_svc,\n                    usage_info=usage_info,\n                    temperature=temperature,\n                    total_content=total_content,\n                    session_id=session_id,\n                    meta=meta,\n                )\n        except Exception as e:\n            logger.error(f\"[stream] 生成失败: {e}\")\n            yield \"\\n[提示] 流式生成出现异常，已结束。\"\n\n    return token_generator()\n\n\ndef _log_stream_token_usage(\n    *,\n    rag_svc,\n    usage_info,\n    temperature: float,\n    total_content: str,\n    session_id: str | None,\n    meta: StreamMeta,\n) -> None:\n    \"\"\"记录流式聊天的 token 使用量，抽离成独立函数以降低主流程复杂度。\"\"\"\n    try:\n        base_additional_info: dict[str, Any] = {\n            \"total_tokens\": usage_info.total_tokens,\n            \"temperature\": temperature,\n            \"response_length\": len(total_content),\n        }\n        if session_id:\n            base_additional_info[\"session_id\"] = session_id\n        additional_info = meta.get(\"additional_info\")\n        if additional_info:\n            base_additional_info.update(additional_info)\n\n        endpoint = meta.get(\"endpoint\", \"\")\n        feature_type = meta.get(\"feature_type\", \"\")\n        user_query = meta.get(\"user_query\", \"\")\n\n        log_token_usage(\n            model=rag_svc.llm_client.model,\n            input_tokens=usage_info.prompt_tokens,\n            output_tokens=usage_info.completion_tokens,\n            endpoint=endpoint,\n            user_query=user_query,\n            response_type=\"stream\",\n            feature_type=feature_type,\n            additional_info=base_additional_info,\n        )\n        logger.info(\n            f\"[stream] Token使用量已记录: input={usage_info.prompt_tokens}, output={usage_info.completion_tokens}\"\n        )\n    except Exception as log_error:\n        logger.error(f\"[stream] 记录token使用量失败: {log_error}\")\n"
  },
  {
    "path": "lifetrace/routers/chat/context.py",
    "content": "\"\"\"带事件上下文的聊天路由。\"\"\"\n\nfrom fastapi import Depends, HTTPException\nfrom fastapi.responses import StreamingResponse\n\nfrom lifetrace.core.dependencies import get_chat_service, get_rag_service\nfrom lifetrace.schemas.chat import ChatMessageWithContext\nfrom lifetrace.services.chat_service import ChatService\n\nfrom .base import _create_llm_stream_generator, logger, router\n\n\n@router.post(\"/stream-with-context\")\nasync def chat_with_context_stream(\n    message: ChatMessageWithContext,\n    chat_service: ChatService = Depends(get_chat_service),\n):\n    \"\"\"带事件上下文的流式聊天接口\"\"\"\n    try:\n        logger.info(\n            f\"[stream-with-context] 收到消息: {message.message}, 上下文事件数: {len(message.event_context or [])}\"\n        )\n\n        # 1. 确保会话存在（事件助手类型）\n        session_id = _ensure_context_stream_session(message, chat_service)\n\n        # 2. 基于上下文构建 messages / temperature，并处理 RAG 失败场景\n        (\n            messages,\n            temperature,\n            error_response,\n        ) = await _build_context_stream_messages_and_temperature(message, session_id)\n        if error_response is not None:\n            return error_response\n\n        # 3. 保存用户消息到数据库\n        chat_service.add_message(\n            session_id=session_id,\n            role=\"user\",\n            content=message.message,\n        )\n\n        # 4. 调用统一的 LLM 流式生成器\n        rag_svc = get_rag_service()\n        token_generator = _create_llm_stream_generator(\n            rag_svc=rag_svc,\n            messages=messages,\n            temperature=temperature,\n            chat_service=chat_service,\n            meta={\n                \"session_id\": session_id,\n                \"endpoint\": \"stream_chat_with_context\",\n                \"feature_type\": \"event_assistant\",\n                \"user_query\": message.message,\n                \"additional_info\": {\n                    \"context_events_count\": len(message.event_context or []),\n                },\n            },\n        )\n\n        # 5. 返回流式响应\n        headers = {\n            \"Cache-Control\": \"no-cache\",\n            \"X-Accel-Buffering\": \"no\",\n            \"X-Session-Id\": session_id,  # 返回 session_id 供前端使用\n        }\n        return StreamingResponse(\n            token_generator, media_type=\"text/plain; charset=utf-8\", headers=headers\n        )\n\n    except Exception as e:\n        logger.error(f\"[stream-with-context] 聊天处理失败: {e}\")\n        raise HTTPException(status_code=500, detail=\"带上下文的流式聊天处理失败\") from e\n\n\ndef _ensure_context_stream_session(\n    message: ChatMessageWithContext,\n    chat_service: ChatService,\n) -> str:\n    \"\"\"确保带上下文的流式聊天有有效 session，并按事件助手类型创建会话。\"\"\"\n    session_id = message.conversation_id or chat_service.generate_session_id()\n    if not message.conversation_id:\n        logger.info(f\"[stream-with-context] 创建新会话: {session_id}\")\n\n    chat = chat_service.get_chat_by_session_id(session_id)\n    if not chat:\n        title = message.message[:50] if len(message.message) > 50 else message.message  # noqa: PLR2004\n        chat_service.create_chat(\n            session_id=session_id,\n            chat_type=\"event\",\n            title=title,\n        )\n        logger.info(f\"[stream-with-context] 在数据库中创建会话: {session_id}\")\n\n    return session_id\n\n\ndef _build_event_context_text(event_context: list[dict[str, str]] | None) -> str:\n    \"\"\"根据事件上下文列表构建可读文本。\"\"\"\n    if not event_context:\n        return \"\"\n\n    context_parts = []\n    for ctx in event_context:\n        event_text = f\"事件ID: {ctx['event_id']}\\n{ctx['text']}\\n\"\n        context_parts.append(event_text)\n    return \"\\n---\\n\".join(context_parts)\n\n\nasync def _build_context_stream_messages_and_temperature(\n    message: ChatMessageWithContext,\n    session_id: str,\n) -> tuple[list[dict[str, str]], float, StreamingResponse | None]:\n    \"\"\"基于事件上下文构建 messages / temperature，并处理 RAG 失败场景。\"\"\"\n    context_text = _build_event_context_text(message.event_context)\n    if context_text:\n        enhanced_message = f\"\"\"用户提供了以下事件上下文（来自屏幕记录的OCR文本）：\n\n===== 事件上下文开始 =====\n{context_text}\n===== 事件上下文结束 =====\n\n用户问题：{message.message}\n\n请基于上述事件上下文回答用户问题。\"\"\"\n    else:\n        enhanced_message = message.message\n\n    rag_service = get_rag_service()\n    rag_result = await rag_service.process_query_stream(enhanced_message, session_id=session_id)\n\n    if not rag_result.get(\"success\", False):\n        error_msg = rag_result.get(\n            \"response\",\n            \"处理您的查询时出现了错误，请稍后重试。\",\n        )\n\n        async def error_generator():\n            yield error_msg\n\n        return (\n            [],\n            0.7,\n            StreamingResponse(\n                error_generator(),\n                media_type=\"text/plain; charset=utf-8\",\n            ),\n        )\n\n    messages = rag_result.get(\"messages\", [])\n    temperature = rag_result.get(\"temperature\", 0.7)\n    return messages, temperature, None\n"
  },
  {
    "path": "lifetrace/routers/chat/core.py",
    "content": "\"\"\"聊天核心路由：基础问答与流式聊天。\"\"\"\n\nfrom fastapi import Depends, HTTPException, Request\nfrom fastapi.responses import StreamingResponse\n\nfrom lifetrace.core.dependencies import get_chat_service, get_rag_service\nfrom lifetrace.schemas.chat import ChatMessage, ChatResponse\nfrom lifetrace.services.chat_service import ChatService\nfrom lifetrace.util.language import get_request_language\nfrom lifetrace.util.time_utils import get_utc_now\n\nfrom .base import _create_llm_stream_generator, logger, router\nfrom .helpers import build_stream_messages_and_temperature, ensure_stream_session\nfrom .modes import (\n    create_agent_streaming_response,\n    create_agno_streaming_response,\n    create_dify_streaming_response,\n    create_web_search_streaming_response,\n)\n\n\n@router.post(\"\", response_model=ChatResponse)\nasync def chat_with_llm(\n    message: ChatMessage,\n    chat_service: ChatService = Depends(get_chat_service),\n):\n    \"\"\"与LLM聊天接口 - 集成RAG功能\"\"\"\n    _ = chat_service\n\n    try:\n        logger.info(f\"收到聊天消息: {message.message}\")\n\n        # 使用RAG服务处理查询\n        rag_service = get_rag_service()\n        rag_result = await rag_service.process_query(message.message)\n\n        if rag_result.get(\"success\", False):\n            success = True  # noqa: F841\n            response = ChatResponse(\n                response=rag_result[\"response\"],\n                timestamp=get_utc_now(),\n                query_info=rag_result.get(\"query_info\"),\n                retrieval_info=rag_result.get(\"retrieval_info\"),\n                performance=rag_result.get(\"performance\"),\n            )\n\n            return response\n        else:\n            # 如果RAG处理失败，返回错误信息\n            error_msg = rag_result.get(\"response\", \"处理您的查询时出现了错误，请稍后重试。\")\n\n            return ChatResponse(\n                response=error_msg,\n                timestamp=get_utc_now(),\n                query_info={\n                    \"original_query\": message.message,\n                    \"error\": rag_result.get(\"error\"),\n                },\n            )\n\n    except Exception as e:\n        logger.error(f\"聊天处理失败: {e}\")\n\n        return ChatResponse(\n            response=\"抱歉，系统暂时无法处理您的请求，请稍后重试。\",\n            timestamp=get_utc_now(),\n            query_info={\"original_query\": message.message, \"error\": str(e)},\n        )\n\n\n@router.post(\"/stream\")\nasync def chat_with_llm_stream(\n    message: ChatMessage,\n    request: Request,\n    chat_service: ChatService = Depends(get_chat_service),\n):\n    \"\"\"与LLM聊天接口（流式输出）\n\n    支持额外的 mode 字段：\n    - 默认为现有行为（走本地 LLM + RAG）\n    - 当 mode == \\\"dify_test\\\" 时，走 Dify 测试通道\n    - 当 mode == \\\"agno\\\" 时，走 Agno Agent 通道（支持 file/shell 等外部工具）\n    \"\"\"\n    try:\n        logger.info(f\"[stream] 收到聊天消息: {message.message}\")\n\n        # 解析请求语言\n        lang = get_request_language(request)\n\n        # 1. 会话初始化与聊天会话创建\n        session_id = ensure_stream_session(message, chat_service)\n\n        # 2. Dify 测试模式（直接返回）\n        if getattr(message, \"mode\", None) == \"dify_test\":\n            return create_dify_streaming_response(message, chat_service, session_id)\n\n        # 2.3. Agent 模式（工具调用框架）\n        if getattr(message, \"mode\", None) == \"agent\":\n            return create_agent_streaming_response(message, chat_service, session_id, lang)\n\n        # 2.5. 联网搜索模式（直接返回，保留向后兼容）\n        if getattr(message, \"mode\", None) == \"web_search\":\n            return create_web_search_streaming_response(message, chat_service, session_id, lang)\n\n        # 2.6. Agno 模式（基于 Agno 框架的 Agent，支持 file/shell 等本地工具）\n        if getattr(message, \"mode\", None) == \"agno\":\n            return create_agno_streaming_response(message, chat_service, session_id, lang)\n\n        # 3. 根据 use_rag 构建 messages / temperature，并处理 RAG 失败场景\n        (\n            messages,\n            temperature,\n            user_message_to_save,\n            error_response,\n        ) = await build_stream_messages_and_temperature(message, session_id, lang)\n\n        if error_response is not None:\n            return error_response\n\n        # 4. 保存用户原始输入（不含 system prompt）\n        chat_service.add_message(\n            session_id=session_id,\n            role=\"user\",\n            content=user_message_to_save,\n        )\n\n        # 5. 调用 LLM，生成统一的流式响应\n        rag_svc = get_rag_service()\n        token_generator = _create_llm_stream_generator(\n            rag_svc=rag_svc,\n            messages=messages,\n            temperature=temperature,\n            chat_service=chat_service,\n            meta={\n                \"session_id\": session_id,\n                \"endpoint\": \"stream_chat\",\n                \"feature_type\": \"event_assistant\",\n                \"user_query\": message.message,\n            },\n        )\n\n        headers = {\n            \"Cache-Control\": \"no-cache\",\n            \"X-Accel-Buffering\": \"no\",\n            \"X-Session-Id\": session_id,  # 返回 session_id 供前端使用\n        }\n        return StreamingResponse(\n            token_generator, media_type=\"text/plain; charset=utf-8\", headers=headers\n        )\n\n    except Exception as e:\n        logger.error(f\"[stream] 聊天处理失败: {e}\")\n        raise HTTPException(status_code=500, detail=\"流式聊天处理失败\") from e\n"
  },
  {
    "path": "lifetrace/routers/chat/helpers.py",
    "content": "\"\"\"聊天路由辅助函数：会话管理、消息构建、工作区验证等。\"\"\"\n\nfrom pathlib import Path\n\nfrom fastapi.responses import StreamingResponse\n\nfrom lifetrace.core.dependencies import get_rag_service\nfrom lifetrace.schemas.chat import ChatMessage\nfrom lifetrace.services.chat_service import ChatService\nfrom lifetrace.util.language import get_language_instruction\nfrom lifetrace.util.logging_config import get_logger\n\nlogger = get_logger()\n\n\n# ============== 会话管理 ==============\n\n\ndef ensure_stream_session(message: ChatMessage, chat_service: ChatService) -> str:\n    \"\"\"确保流式聊天有有效的 session，并在需要时创建数据库会话。\"\"\"\n    session_id = message.conversation_id or chat_service.generate_session_id()\n    if not message.conversation_id:\n        logger.info(f\"[stream] 创建新会话: {session_id}\")\n\n    chat = chat_service.get_chat_by_session_id(session_id)\n    if not chat:\n        chat_type = \"event\"\n        title = message.message[:50] if len(message.message) > 50 else message.message  # noqa: PLR2004\n        chat_service.create_chat(\n            session_id=session_id,\n            chat_type=chat_type,\n            title=title,\n        )\n        logger.info(f\"[stream] 在数据库中创建会话: {session_id}, 类型: {chat_type}\")\n\n    return session_id\n\n\n# ============== 对话历史 ==============\n\n\ndef get_conversation_history(\n    chat_service: ChatService, session_id: str, exclude_content: str | None = None\n) -> list[dict[str, str]] | None:\n    \"\"\"获取对话历史（用于 Agno 模式）\n\n    Args:\n        chat_service: 聊天服务\n        session_id: 会话 ID\n        exclude_content: 要排除的消息内容（通常是刚添加的用户消息）\n\n    Returns:\n        对话历史列表或 None\n    \"\"\"\n    try:\n        chat = chat_service.get_chat_by_session_id(session_id)\n        if not chat:\n            return None\n        messages = chat_service.get_messages(session_id)\n        history = []\n        for msg in messages:\n            role = msg.get(\"role\", \"\")\n            content = msg.get(\"content\", \"\")\n            if role in (\"user\", \"assistant\") and content != exclude_content:\n                history.append({\"role\": role, \"content\": content})\n        return history if history else None\n    except Exception as e:\n        logger.warning(f\"获取对话历史失败: {e}，将使用单次对话模式\")\n        return None\n\n\n# ============== 工作区验证 ==============\n\n# 敏感路径黑名单（禁止作为工作区或其父目录）\nWORKSPACE_FORBIDDEN_PATHS = {\n    \".git\",\n    \".env\",\n    \".ssh\",\n    \"node_modules\",\n    \"__pycache__\",\n    \".venv\",\n    \"venv\",\n}\n\n# 系统目录黑名单\nWORKSPACE_SYSTEM_DIRS = {\"/\", \"/usr\", \"/etc\", \"/var\", \"/bin\", \"/sbin\", \"/home\", \"/root\"}\n\n\ndef validate_workspace_path(workspace_path: str) -> tuple[bool, str]:\n    \"\"\"验证工作区路径安全性\n\n    Args:\n        workspace_path: 工作区路径\n\n    Returns:\n        (is_valid, error_message) 元组\n    \"\"\"\n    try:\n        workspace = Path(workspace_path).resolve()\n    except Exception as e:\n        return False, f\"无效的路径: {e}\"\n\n    # 检查路径是否存在且是目录\n    if not workspace.exists():\n        return False, f\"路径不存在: {workspace_path}\"\n    if not workspace.is_dir():\n        return False, f\"路径不是目录: {workspace_path}\"\n\n    # 检查是否是系统目录\n    if str(workspace) in WORKSPACE_SYSTEM_DIRS:\n        return False, \"不允许将系统目录作为工作区\"\n\n    # 检查路径中是否包含敏感目录名\n    for part in workspace.parts:\n        if part in WORKSPACE_FORBIDDEN_PATHS:\n            return False, f\"工作区路径包含敏感目录: {part}\"\n\n    return True, \"\"\n\n\n# ============== 错误响应 ==============\n\n\ndef make_error_streaming_response(error_msg: str, session_id: str) -> StreamingResponse:\n    \"\"\"创建错误流式响应\"\"\"\n\n    def error_gen():\n        yield error_msg\n\n    return StreamingResponse(\n        error_gen(),\n        media_type=\"text/plain; charset=utf-8\",\n        headers={\"X-Session-Id\": session_id},\n    )\n\n\n# ============== 消息构建 ==============\n\n\ndef build_messages_from_new_schema(\n    message: ChatMessage,\n    user_message_to_save: str,\n    lang: str,\n) -> list[dict[str, str]]:\n    \"\"\"使用新的 schema 字段构建 LLM 消息列表。\"\"\"\n    llm_messages = []\n    if message.system_prompt:\n        system_content = message.system_prompt\n        if message.context:\n            system_content += f\"\\n\\n{message.context}\"\n        system_content += get_language_instruction(lang)\n        llm_messages.append({\"role\": \"system\", \"content\": system_content})\n    elif message.context:\n        system_content = message.context + get_language_instruction(lang)\n        llm_messages.append({\"role\": \"system\", \"content\": system_content})\n\n    llm_messages.append({\"role\": \"user\", \"content\": user_message_to_save})\n    return llm_messages\n\n\ndef build_messages_from_legacy_format(\n    full_message: str,\n    lang: str,\n) -> tuple[list[dict[str, str]], str]:\n    \"\"\"从老格式消息解析构建 LLM 消息列表（向后兼容）。\n\n    返回 (messages, user_message_to_save)。\n    \"\"\"\n    marker = \"用户输入:\" if \"用户输入:\" in full_message else \"User input:\"\n    if marker not in full_message:\n        return [{\"role\": \"user\", \"content\": full_message}], full_message\n\n    parts = full_message.split(marker, 1)\n    if len(parts) != 2:  # noqa: PLR2004\n        return [{\"role\": \"user\", \"content\": full_message}], full_message\n\n    system_prompt = parts[0].strip() + get_language_instruction(lang)\n    user_input = parts[1].strip()\n    messages = [\n        {\"role\": \"system\", \"content\": system_prompt},\n        {\"role\": \"user\", \"content\": user_input},\n    ]\n    return messages, user_input\n\n\nasync def build_stream_messages_and_temperature(\n    message: ChatMessage,\n    session_id: str,\n    lang: str = \"zh\",\n) -> tuple[list[dict[str, str]], float, str, StreamingResponse | None]:\n    \"\"\"根据 use_rag / 前端 prompt 构建 messages 与 temperature。\n\n    返回 (messages, temperature, user_message_to_save, error_response)。\n    当 RAG 失败时，error_response 不为 None，调用方应直接返回该响应。\n    \"\"\"\n    user_message_to_save = message.get_user_input_for_storage()\n\n    if message.use_rag:\n        rag_service = get_rag_service()\n        rag_result = await rag_service.process_query_stream(message.message, session_id, lang=lang)\n\n        if not rag_result.get(\"success\", False):\n            error_msg = rag_result.get(\"response\", \"处理您的查询时出现了错误，请稍后重试。\")\n\n            async def error_generator():\n                yield error_msg\n\n            return (\n                [],\n                0.7,\n                user_message_to_save,\n                StreamingResponse(error_generator(), media_type=\"text/plain; charset=utf-8\"),\n            )\n\n        return (\n            rag_result.get(\"messages\", []),\n            rag_result.get(\"temperature\", 0.7),\n            user_message_to_save,\n            None,\n        )\n\n    # 不使用 RAG：优先新 schema，降级老解析\n    if message.system_prompt is not None or message.user_input is not None:\n        llm_messages = build_messages_from_new_schema(message, user_message_to_save, lang)\n        return llm_messages, 0.7, user_message_to_save, None\n\n    messages, user_message_to_save = build_messages_from_legacy_format(message.message, lang)\n    return messages, 0.7, user_message_to_save, None\n"
  },
  {
    "path": "lifetrace/routers/chat/message_todo_extraction.py",
    "content": "\"\"\"从消息中提取待办的路由\"\"\"\n\nimport json\nimport re\nfrom typing import TYPE_CHECKING, Any, cast\n\nif TYPE_CHECKING:\n    from openai.types.chat import ChatCompletionMessageParam\nelse:\n    ChatCompletionMessageParam = Any\n\nfrom lifetrace.llm.llm_client import LLMClient\nfrom lifetrace.routers.chat.base import router\nfrom lifetrace.schemas.message_todo_extraction import (\n    ExtractedMessageTodo,\n    MessageTodoExtractionRequest,\n    MessageTodoExtractionResponse,\n)\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.prompt_loader import get_prompt\n\nlogger = get_logger()\n\n\ndef _get_llm_client() -> LLMClient:\n    return LLMClient()\n\n\n@router.post(\"/extract-todos-from-messages\", response_model=MessageTodoExtractionResponse)\nasync def extract_todos_from_messages(\n    request: MessageTodoExtractionRequest,\n) -> MessageTodoExtractionResponse:\n    \"\"\"\n    从消息中提取待办事项\n\n    Args:\n        request: 包含消息列表、父待办ID和待办上下文的请求\n\n    Returns:\n        提取的待办列表\n\n    Raises:\n        HTTPException: 当提取失败时\n    \"\"\"\n    try:\n        llm_client = _get_llm_client()\n        if not llm_client.is_available():\n            return MessageTodoExtractionResponse(\n                todos=[],\n                error_message=\"LLM服务当前不可用，请稍后重试\",\n            )\n\n        # 构建消息文本\n        messages_text = \"\\n\".join(\n            [f\"{msg.get('role', 'unknown')}: {msg.get('content', '')}\" for msg in request.messages],\n        )\n\n        # 构建待办上下文部分\n        todo_context_section = \"\"\n        if request.todo_context:\n            todo_context_section = f\"\\n**关联待办上下文：**\\n{request.todo_context}\\n\"\n\n        # 获取提示词\n        system_prompt = get_prompt(\"chat_frontend\", \"message_todo_extraction_system_prompt_zh\")\n        user_prompt_template = get_prompt(\"chat_frontend\", \"message_todo_extraction_user_prompt_zh\")\n\n        # 填充用户提示词\n        user_prompt = user_prompt_template.format(\n            messages_text=messages_text,\n            todo_context_section=todo_context_section,\n        )\n\n        # 调用 LLM\n        messages = [\n            {\"role\": \"system\", \"content\": system_prompt},\n            {\"role\": \"user\", \"content\": user_prompt},\n        ]\n\n        client = llm_client._get_client()\n        response = client.chat.completions.create(\n            model=llm_client.model,\n            messages=cast(\"list[ChatCompletionMessageParam]\", messages),\n            temperature=0.3,\n        )\n\n        response_text = response.choices[0].message.content or \"\"\n\n        # 解析响应\n        todos = _parse_llm_response(response_text)\n\n        return MessageTodoExtractionResponse(todos=todos, error_message=None)\n\n    except Exception as e:\n        logger.error(f\"从消息中提取待办失败: {e}\", exc_info=True)\n        return MessageTodoExtractionResponse(\n            todos=[],\n            error_message=f\"提取待办失败: {e!s}\",\n        )\n\n\ndef _parse_llm_response(response_text: str) -> list[ExtractedMessageTodo]:\n    \"\"\"\n    解析LLM响应为待办事项列表\n\n    Args:\n        response_text: LLM返回的文本\n\n    Returns:\n        待办事项列表\n    \"\"\"\n    try:\n        # 尝试提取JSON\n        json_match = re.search(r\"\\{.*\\}\", response_text, re.DOTALL)\n        if json_match:\n            json_str = json_match.group(0)\n            result = json.loads(json_str)\n\n            if \"todos\" in result and isinstance(result[\"todos\"], list):\n                todos = []\n                for todo_dict in result[\"todos\"]:\n                    if \"name\" in todo_dict:\n                        todos.append(\n                            ExtractedMessageTodo(\n                                name=todo_dict[\"name\"],\n                                description=todo_dict.get(\"description\"),\n                                tags=todo_dict.get(\"tags\", []),\n                            ),\n                        )\n                return todos\n        else:\n            logger.warning(\"LLM响应中未找到JSON格式\")\n            return []\n\n    except json.JSONDecodeError as e:\n        logger.error(f\"解析LLM响应JSON失败: {e}\\n原始响应: {response_text[:200]}\")\n    except Exception as e:\n        logger.error(f\"解析待办事项失败: {e}\")\n\n    return []\n"
  },
  {
    "path": "lifetrace/routers/chat/misc.py",
    "content": "\"\"\"聊天相关的辅助/管理路由。\"\"\"\n\nimport importlib\n\nfrom fastapi import Depends, HTTPException, Query\n\nfrom lifetrace.core.dependencies import get_chat_service, get_rag_service\nfrom lifetrace.schemas.chat import AddMessageRequest, NewChatRequest, NewChatResponse\nfrom lifetrace.services.chat_service import ChatService\nfrom lifetrace.util.time_utils import get_utc_now\n\nfrom .base import logger, router\n\n\n@router.post(\"/new\", response_model=NewChatResponse)\nasync def create_new_chat(\n    request: NewChatRequest | None = None,\n    chat_service: ChatService = Depends(get_chat_service),\n):\n    \"\"\"创建新对话会话\"\"\"\n    try:\n        # 如果提供了session_id，清除其上下文；否则创建新会话\n        if request and request.session_id:\n            if chat_service.clear_session_context(request.session_id):\n                session_id = request.session_id\n                message = \"会话上下文已清除\"\n            else:\n                # 会话不存在，创建新的\n                session_id = chat_service.create_new_session()\n                message = \"创建新对话会话\"\n        else:\n            session_id = chat_service.create_new_session()\n            message = \"创建新对话会话\"\n\n        logger.info(f\"新对话会话: {session_id}\")\n        return NewChatResponse(session_id=session_id, message=message, timestamp=get_utc_now())\n    except Exception as e:\n        logger.error(f\"创建新对话失败: {e}\")\n        raise HTTPException(status_code=500, detail=\"创建新对话失败\") from e\n\n\n@router.post(\"/session/{session_id}/message\")\nasync def add_message_to_session(\n    session_id: str,\n    request: AddMessageRequest,\n    chat_service: ChatService = Depends(get_chat_service),\n):\n    \"\"\"添加消息到会话（消息已在流式聊天中自动保存，此接口保持兼容性）\"\"\"\n    _ = session_id\n    _ = request\n    _ = chat_service\n    try:\n        # 消息在流式聊天接口中已经自动保存，这里只是为了API兼容性\n        # 如果需要手动保存，可以取消注释以下代码\n        # chat_service.add_message(\n        #     session_id=session_id,\n        #     role=request.role,\n        #     content=request.content,\n        # )\n        return {\n            \"success\": True,\n            \"message\": \"消息已保存\",\n            \"timestamp\": get_utc_now(),\n        }\n    except Exception as e:\n        logger.error(f\"保存消息失败: {e}\")\n        raise HTTPException(status_code=500, detail=\"保存消息失败\") from e\n\n\n@router.delete(\"/session/{session_id}\")\nasync def clear_chat_session(\n    session_id: str,\n    chat_service: ChatService = Depends(get_chat_service),\n):\n    \"\"\"清除指定会话的上下文\"\"\"\n    try:\n        success = chat_service.clear_session_context(session_id)\n        if success:\n            return {\n                \"success\": True,\n                \"message\": f\"会话 {session_id} 的上下文已清除\",\n                \"timestamp\": get_utc_now(),\n            }\n        else:\n            raise HTTPException(status_code=404, detail=\"会话不存在\")\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"清除会话上下文失败: {e}\")\n        raise HTTPException(status_code=500, detail=\"清除会话上下文失败\") from e\n\n\n@router.get(\"/history\")\nasync def get_chat_history(\n    session_id: str | None = Query(None),\n    chat_type: str | None = Query(None, description=\"聊天类型过滤：event, project, general\"),\n    chat_service: ChatService = Depends(get_chat_service),\n):\n    \"\"\"获取聊天历史记录（从数据库读取）\"\"\"\n    try:\n        return chat_service.get_chat_history(session_id=session_id, chat_type=chat_type)\n    except Exception as e:\n        logger.error(f\"获取聊天历史失败: {e}\")\n        raise HTTPException(status_code=500, detail=\"获取聊天历史失败\") from e\n\n\n@router.get(\"/suggestions\")\nasync def get_query_suggestions(\n    partial_query: str = Query(\"\", description=\"部分查询文本\"),\n):\n    \"\"\"获取查询建议\"\"\"\n    try:\n        suggestions = get_rag_service().get_query_suggestions(partial_query)\n        return {\"suggestions\": suggestions, \"partial_query\": partial_query}\n    except Exception as e:\n        logger.error(f\"获取查询建议失败: {e}\")\n        raise HTTPException(status_code=500, detail=\"获取查询建议失败\") from e\n\n\n@router.get(\"/query-types\")\nasync def get_supported_query_types():\n    \"\"\"获取支持的查询类型\"\"\"\n    try:\n        return get_rag_service().get_supported_query_types()\n    except Exception as e:\n        logger.error(f\"获取查询类型失败: {e}\")\n        raise HTTPException(status_code=500, detail=\"获取查询类型失败\") from e\n\n\n@router.get(\"/agno/tools\")\nasync def get_available_agno_tools():\n    \"\"\"获取可用的 Agno Agent 工具列表\n\n    返回两种类型的工具：\n    1. FreeTodo 工具：待办管理相关（create_todo, list_todos 等）\n    2. 外部工具：联网搜索等（duckduckgo 等）\n    \"\"\"\n    try:\n        # FreeTodo 工具列表（与 toolkit.py 中的 all_tools 保持同步）\n        agno_module = importlib.import_module(\"lifetrace.llm.agno_agent\")\n        freetodo_tools = [\n            {\n                \"name\": \"create_todo\",\n                \"category\": \"todo\",\n                \"description\": \"创建新的待办事项\",\n                \"description_en\": \"Create a new todo item\",\n            },\n            {\n                \"name\": \"complete_todo\",\n                \"category\": \"todo\",\n                \"description\": \"完成待办事项\",\n                \"description_en\": \"Mark a todo as completed\",\n            },\n            {\n                \"name\": \"update_todo\",\n                \"category\": \"todo\",\n                \"description\": \"更新待办事项\",\n                \"description_en\": \"Update a todo item\",\n            },\n            {\n                \"name\": \"list_todos\",\n                \"category\": \"todo\",\n                \"description\": \"列出待办事项\",\n                \"description_en\": \"List todo items\",\n            },\n            {\n                \"name\": \"search_todos\",\n                \"category\": \"todo\",\n                \"description\": \"搜索待办事项\",\n                \"description_en\": \"Search todo items\",\n            },\n            {\n                \"name\": \"delete_todo\",\n                \"category\": \"todo\",\n                \"description\": \"删除待办事项\",\n                \"description_en\": \"Delete a todo item\",\n            },\n            {\n                \"name\": \"breakdown_task\",\n                \"category\": \"breakdown\",\n                \"description\": \"分解复杂任务为子任务\",\n                \"description_en\": \"Break down complex tasks into subtasks\",\n            },\n            {\n                \"name\": \"parse_time\",\n                \"category\": \"time\",\n                \"description\": \"解析自然语言时间表达\",\n                \"description_en\": \"Parse natural language time expressions\",\n            },\n            {\n                \"name\": \"check_schedule_conflict\",\n                \"category\": \"conflict\",\n                \"description\": \"检查时间冲突\",\n                \"description_en\": \"Check schedule conflicts\",\n            },\n            {\n                \"name\": \"get_todo_stats\",\n                \"category\": \"stats\",\n                \"description\": \"获取待办统计信息\",\n                \"description_en\": \"Get todo statistics\",\n            },\n            {\n                \"name\": \"get_overdue_todos\",\n                \"category\": \"stats\",\n                \"description\": \"获取逾期待办\",\n                \"description_en\": \"Get overdue todos\",\n            },\n            {\n                \"name\": \"list_tags\",\n                \"category\": \"tags\",\n                \"description\": \"列出所有标签\",\n                \"description_en\": \"List all tags\",\n            },\n            {\n                \"name\": \"get_todos_by_tag\",\n                \"category\": \"tags\",\n                \"description\": \"按标签获取待办\",\n                \"description_en\": \"Get todos by tag\",\n            },\n            {\n                \"name\": \"suggest_tags\",\n                \"category\": \"tags\",\n                \"description\": \"建议标签\",\n                \"description_en\": \"Suggest tags for a todo\",\n            },\n        ]\n\n        # 外部工具列表\n        available_external = agno_module.get_available_external_tools()\n        external_tools = []\n\n        if \"duckduckgo\" in available_external:\n            external_tools.append(\n                {\n                    \"name\": \"duckduckgo\",\n                    \"category\": \"search\",\n                    \"description\": \"DuckDuckGo 联网搜索\",\n                    \"description_en\": \"DuckDuckGo web search\",\n                }\n            )\n\n        return {\n            \"freetodo_tools\": freetodo_tools,\n            \"external_tools\": external_tools,\n        }\n    except Exception as e:\n        logger.error(f\"获取 Agno 工具列表失败: {e}\")\n        raise HTTPException(status_code=500, detail=\"获取 Agno 工具列表失败\") from e\n"
  },
  {
    "path": "lifetrace/routers/chat/modes/__init__.py",
    "content": "\"\"\"聊天模式处理器模块。\"\"\"\n\nfrom .agent import create_agent_streaming_response\nfrom .agno import create_agno_streaming_response\nfrom .dify import create_dify_streaming_response\nfrom .web_search import create_web_search_streaming_response\n\n__all__ = [\n    \"create_agent_streaming_response\",\n    \"create_agno_streaming_response\",\n    \"create_dify_streaming_response\",\n    \"create_web_search_streaming_response\",\n]\n"
  },
  {
    "path": "lifetrace/routers/chat/modes/agent.py",
    "content": "\"\"\"Agent 模式处理器（工具调用框架）。\"\"\"\n\nfrom fastapi.responses import StreamingResponse\n\nfrom lifetrace.llm.agent_service import AgentService\nfrom lifetrace.schemas.chat import ChatMessage\nfrom lifetrace.services.chat_service import ChatService\nfrom lifetrace.util.logging_config import get_logger\n\nlogger = get_logger()\n\n\ndef create_agent_streaming_response(\n    message: ChatMessage,\n    chat_service: ChatService,\n    session_id: str,\n    lang: str = \"zh\",\n) -> StreamingResponse:\n    \"\"\"处理 Agent 模式，支持工具调用\"\"\"\n    logger.info(\"[stream] 进入 Agent 模式\")\n\n    # 创建 Agent 服务\n    agent_service = AgentService()\n\n    # 获取待办上下文和用户输入\n    # 优先使用新的 schema 字段，降级到老的解析方式（向后兼容）\n    if message.context is not None or message.user_input is not None:\n        # 新方式：使用 schema 字段\n        todo_context = message.context\n        user_query = message.get_user_input_for_storage()\n    else:\n        # 老方式：从 message 解析（向后兼容）\n        todo_context = None\n        user_query = message.message\n        if \"用户输入:\" in message.message or \"User input:\" in message.message:\n            markers = [\"用户输入:\", \"User input:\"]\n            for marker in markers:\n                if marker in message.message:\n                    parts = message.message.split(marker, 1)\n                    if len(parts) == 2:  # noqa: PLR2004\n                        todo_context = parts[0].strip()\n                        user_query = parts[1].strip()\n                        break\n\n    # 保存用户消息（只保存用户真正的输入，不含系统提示词和上下文）\n    chat_service.add_message(\n        session_id=session_id,\n        role=\"user\",\n        content=user_query,\n    )\n\n    def agent_token_generator():\n        total_content = \"\"\n        try:\n            # 流式生成 Agent 回答\n            for chunk in agent_service.stream_agent_response(\n                user_query=user_query,\n                todo_context=todo_context,\n                lang=lang,\n            ):\n                total_content += chunk\n                yield chunk\n\n            # 保存完整的助手回复\n            if total_content:\n                chat_service.add_message(\n                    session_id=session_id,\n                    role=\"assistant\",\n                    content=total_content,\n                )\n                logger.info(\"[stream][agent] 消息已保存到数据库\")\n        except Exception as e:\n            logger.error(f\"[stream][agent] 生成失败: {e}\")\n            yield \"Agent 处理失败，请检查后端配置。\"\n\n    headers = {\n        \"Cache-Control\": \"no-cache\",\n        \"X-Accel-Buffering\": \"no\",\n        \"X-Session-Id\": session_id,\n    }\n    return StreamingResponse(\n        agent_token_generator(), media_type=\"text/plain; charset=utf-8\", headers=headers\n    )\n"
  },
  {
    "path": "lifetrace/routers/chat/modes/agno.py",
    "content": "\"\"\"Agno 模式处理器（基于 Agno 框架的 Agent）。\"\"\"\n\nimport json\nfrom pathlib import Path\nfrom typing import Any\n\nfrom fastapi.responses import StreamingResponse\n\nfrom lifetrace.llm.agno_agent import (\n    TOOL_EVENT_PREFIX,\n    TOOL_EVENT_SUFFIX,\n    AgnoAgentService,\n)\nfrom lifetrace.schemas.chat import ChatMessage\nfrom lifetrace.services.chat_service import ChatService\nfrom lifetrace.util.logging_config import get_logger\n\nfrom ..helpers import (\n    get_conversation_history,\n    make_error_streaming_response,\n    validate_workspace_path,\n)\n\nlogger = get_logger()\n\n\ndef _strip_tool_events(\n    chunk: str,\n    pending: str,\n) -> tuple[str, str, list[dict[str, Any]]]:\n    \"\"\"从流式 chunk 中剥离工具事件标记，返回纯内容、剩余未完成标记、解析出的事件列表。\"\"\"\n    content = pending + chunk\n    events: list[dict[str, Any]] = []\n\n    while True:\n        start_idx = content.find(TOOL_EVENT_PREFIX)\n        if start_idx == -1:\n            break\n\n        end_idx = content.find(TOOL_EVENT_SUFFIX, start_idx + len(TOOL_EVENT_PREFIX))\n        if end_idx == -1:\n            # 工具事件未完整，保留到下次处理\n            pending_chunk = content[start_idx:]\n            return content[:start_idx], pending_chunk, events\n\n        json_start = start_idx + len(TOOL_EVENT_PREFIX)\n        json_str = content[json_start:end_idx]\n        try:\n            events.append(json.loads(json_str))\n        except json.JSONDecodeError:\n            logger.warning(\"[stream][agno] 工具事件 JSON 解析失败\")\n\n        content = content[:start_idx] + content[end_idx + len(TOOL_EVENT_SUFFIX) :]\n\n    # 处理可能跨 chunk 的前缀残留（例如 '\\n[TOO'）\n    max_prefix_len = min(len(TOOL_EVENT_PREFIX) - 1, len(content))\n    for length in range(max_prefix_len, 0, -1):\n        if TOOL_EVENT_PREFIX.startswith(content[-length:]):\n            return content[:-length], content[-length:], events\n\n    return content, \"\", events\n\n\ndef _build_external_tools_config(\n    external_tools: list[str],\n    workspace_path: str | None,\n    enable_file_delete: bool,\n) -> dict[str, dict]:\n    \"\"\"构建外部工具配置\n\n    Args:\n        external_tools: 外部工具列表\n        workspace_path: 工作区路径\n        enable_file_delete: 是否允许删除文件\n\n    Returns:\n        外部工具配置字典\n    \"\"\"\n    config: dict[str, dict] = {}\n    if not workspace_path:\n        return config\n\n    # 需要 base_dir 的本地工具\n    if \"file\" in external_tools:\n        config[\"file\"] = {\"base_dir\": workspace_path, \"enable_delete\": enable_file_delete}\n    if \"local_fs\" in external_tools:\n        config[\"local_fs\"] = {\"base_dir\": workspace_path}\n    if \"shell\" in external_tools:\n        config[\"shell\"] = {\"base_dir\": workspace_path}\n\n    return config\n\n\ndef create_agno_streaming_response(\n    message: ChatMessage,\n    chat_service: ChatService,\n    session_id: str,\n    lang: str = \"en\",\n) -> StreamingResponse:\n    \"\"\"处理 Agno 模式，使用 Agno 框架的 Agent 进行对话\n\n    支持的外部工具：\n        搜索类（无需配置）：\n        - websearch: 通用网页搜索（自动选择后端）\n        - hackernews: Hacker News 新闻\n\n        本地类（需要 workspace_path）：\n        - file: 文件操作（读写、搜索）\n        - local_fs: 简化文件写入\n        - shell: 命令行执行\n        - sleep: 暂停执行\n    \"\"\"\n    logger.info(f\"[stream] 进入 Agno 模式, lang={lang}\")\n\n    external_tools = message.external_tools or []\n    workspace_path = message.workspace_path\n\n    # 本地类工具需要 workspace_path，如果未提供则使用用户 home 目录\n    local_tools = {\"file\", \"local_fs\", \"shell\"}\n    needs_workspace = bool(local_tools & set(external_tools))\n\n    if needs_workspace and not workspace_path:\n        # 使用用户 home 目录作为默认工作区\n        workspace_path = str(Path.home())\n        logger.info(f\"[stream][agno] 未指定 workspace_path，使用默认值: {workspace_path}\")\n\n    # 如果提供了 workspace_path，验证其有效性\n    if workspace_path:\n        is_valid, validation_error = validate_workspace_path(workspace_path)\n        if not is_valid:\n            err = (\n                f\"工作区验证失败: {validation_error}\"\n                if lang == \"zh\"\n                else f\"Workspace validation failed: {validation_error}\"\n            )\n            return make_error_streaming_response(err, session_id)\n\n    # 构建外部工具配置\n    external_tools_config = _build_external_tools_config(\n        external_tools, workspace_path, message.enable_file_delete\n    )\n\n    logger.info(\n        f\"[stream][agno] selected_tools={message.selected_tools}, external_tools={external_tools}, \"\n        f\"workspace_path={workspace_path}\"\n    )\n\n    # 获取用户真正的输入（用于保存和过滤对话历史）\n    user_input_for_storage = message.get_user_input_for_storage()\n\n    # 保存用户消息\n    chat_service.add_message(\n        session_id=session_id,\n        role=\"user\",\n        content=user_input_for_storage,\n    )\n\n    # 创建 Agno Agent 服务\n    agno_service = AgnoAgentService(\n        lang=lang,\n        selected_tools=message.selected_tools,\n        external_tools=external_tools if external_tools else None,\n        external_tools_config=external_tools_config if external_tools_config else None,\n    )\n\n    # 获取对话历史\n    conversation_history = get_conversation_history(\n        chat_service, session_id, user_input_for_storage\n    )\n\n    def agno_token_generator():\n        storage_chunks: list[str] = []\n        tool_events: list[dict[str, Any]] = []\n        pending_tool_chunk = \"\"\n        try:\n            for chunk in agno_service.stream_response(\n                message=message.message,\n                conversation_history=conversation_history,\n                session_id=session_id,\n            ):\n                yield chunk\n                cleaned, pending_tool_chunk, parsed_events = _strip_tool_events(\n                    chunk, pending_tool_chunk\n                )\n                if cleaned:\n                    storage_chunks.append(cleaned)\n                if parsed_events:\n                    tool_events.extend(parsed_events)\n\n            # 丢弃未完成的工具事件残片，避免写入历史\n            storage_content = \"\".join(storage_chunks).strip()\n            metadata = (\n                json.dumps({\"tool_events\": tool_events}, ensure_ascii=False)\n                if tool_events\n                else None\n            )\n\n            if storage_content or tool_events:\n                chat_service.add_message(\n                    session_id=session_id,\n                    role=\"assistant\",\n                    content=storage_content,\n                    metadata=metadata,\n                )\n                logger.info(\"[stream][agno] 消息已保存到数据库\")\n        except Exception as e:\n            logger.error(f\"[stream][agno] 生成失败: {e}\")\n            yield f\"Agno Agent 处理失败: {e!s}\"\n\n    headers = {\n        \"Cache-Control\": \"no-cache\",\n        \"X-Accel-Buffering\": \"no\",\n        \"X-Session-Id\": session_id,\n    }\n    return StreamingResponse(\n        agno_token_generator(), media_type=\"text/plain; charset=utf-8\", headers=headers\n    )\n"
  },
  {
    "path": "lifetrace/routers/chat/modes/dify.py",
    "content": "\"\"\"Dify 测试模式处理器。\"\"\"\n\nfrom fastapi.responses import StreamingResponse\n\nfrom lifetrace.schemas.chat import ChatMessage\nfrom lifetrace.services.chat_service import ChatService\nfrom lifetrace.services.dify_client import call_dify_chat\nfrom lifetrace.util.logging_config import get_logger\n\nlogger = get_logger()\n\n\ndef create_dify_streaming_response(\n    message: ChatMessage,\n    chat_service: ChatService,\n    session_id: str,\n) -> StreamingResponse:\n    \"\"\"处理 Dify 测试模式，使用真正的流式输出。\n\n    从 message 对象中提取 Dify 相关参数：\n    - dify_response_mode: 响应模式（streaming/blocking），默认 streaming\n    - dify_user: 用户标识，默认 lifetrace-user\n    - dify_inputs: Dify 输入变量字典\n    - 其他以 dify_ 开头的字段会作为额外参数传递给 Dify API\n    \"\"\"\n    logger.info(\"[stream] 进入 Dify 测试模式\")\n\n    # 保存用户消息（只保存用户真正输入的内容，不含系统提示词和上下文）\n    chat_service.add_message(\n        session_id=session_id,\n        role=\"user\",\n        content=message.get_user_input_for_storage(),\n    )\n\n    # 从 message 中提取 Dify 相关参数\n    message_dict = message.model_dump(exclude={\"message\", \"conversation_id\", \"use_rag\", \"mode\"})\n\n    # 提取 Dify 特定参数\n    response_mode = message_dict.pop(\"dify_response_mode\", \"streaming\")\n    user = message_dict.pop(\"dify_user\", None)\n    inputs = message_dict.pop(\"dify_inputs\", None)\n\n    # 构建额外的 payload 参数（移除 dify_ 前缀）\n    extra_payload = {}\n    for key, value in list(message_dict.items()):\n        if key.startswith(\"dify_\"):\n            # 移除 dify_ 前缀，将剩余的键名作为 payload 参数\n            payload_key = key[5:]  # 移除 \"dify_\" 前缀\n            extra_payload[payload_key] = value\n\n    def dify_token_generator():\n        total_content = \"\"\n        try:\n            # 调用 call_dify_chat，传递所有可配置的参数\n            for chunk in call_dify_chat(\n                message=message.message,\n                user=user,\n                response_mode=response_mode,\n                inputs=inputs,\n                **extra_payload,\n            ):\n                total_content += chunk\n                yield chunk\n\n            # 保存完整的助手回复\n            if total_content:\n                chat_service.add_message(\n                    session_id=session_id,\n                    role=\"assistant\",\n                    content=total_content,\n                )\n                logger.info(\"[stream][dify] 消息已保存到数据库\")\n        except Exception as e:\n            logger.error(f\"[stream][dify] 生成失败: {e}\")\n            yield \"Dify 测试模式调用失败，请检查后端 Dify 配置。\"\n\n    headers = {\n        \"Cache-Control\": \"no-cache\",\n        \"X-Accel-Buffering\": \"no\",\n        \"X-Session-Id\": session_id,\n    }\n    return StreamingResponse(\n        dify_token_generator(), media_type=\"text/plain; charset=utf-8\", headers=headers\n    )\n"
  },
  {
    "path": "lifetrace/routers/chat/modes/web_search.py",
    "content": "\"\"\"联网搜索模式处理器。\"\"\"\n\nfrom fastapi.responses import StreamingResponse\n\nfrom lifetrace.llm.web_search_service import WebSearchService\nfrom lifetrace.schemas.chat import ChatMessage\nfrom lifetrace.services.chat_service import ChatService\nfrom lifetrace.util.logging_config import get_logger\n\nlogger = get_logger()\n\n\ndef create_web_search_streaming_response(\n    message: ChatMessage,\n    chat_service: ChatService,\n    session_id: str,\n    lang: str = \"zh\",\n) -> StreamingResponse:\n    \"\"\"处理联网搜索模式，使用 Tavily 搜索和 LLM 生成流式输出\"\"\"\n    logger.info(\"[stream] 进入联网搜索模式\")\n\n    # 保存用户消息\n    chat_service.add_message(\n        session_id=session_id,\n        role=\"user\",\n        content=message.message,\n    )\n\n    # 创建联网搜索服务实例\n    web_search_service = WebSearchService()\n\n    def web_search_token_generator():\n        total_content = \"\"\n        try:\n            # 调用联网搜索服务，流式生成回答\n            for chunk in web_search_service.stream_answer_with_sources(message.message, lang=lang):\n                total_content += chunk\n                yield chunk\n\n            # 保存完整的助手回复\n            if total_content:\n                chat_service.add_message(\n                    session_id=session_id,\n                    role=\"assistant\",\n                    content=total_content,\n                )\n                logger.info(\"[stream][web_search] 消息已保存到数据库\")\n        except Exception as e:\n            logger.error(f\"[stream][web_search] 生成失败: {e}\")\n            yield \"联网搜索处理失败，请检查后端配置。\"\n\n    headers = {\n        \"Cache-Control\": \"no-cache\",\n        \"X-Accel-Buffering\": \"no\",\n        \"X-Session-Id\": session_id,\n    }\n    return StreamingResponse(\n        web_search_token_generator(), media_type=\"text/plain; charset=utf-8\", headers=headers\n    )\n"
  },
  {
    "path": "lifetrace/routers/chat/plan.py",
    "content": "\"\"\"Plan 相关聊天路由：任务问卷与任务总结。\"\"\"\n\nfrom typing import Any\n\nfrom fastapi import Depends, HTTPException\nfrom fastapi.responses import StreamingResponse\n\nfrom lifetrace.core.dependencies import get_chat_service, get_rag_service\nfrom lifetrace.schemas.chat import PlanQuestionnaireRequest, PlanSummaryRequest\nfrom lifetrace.services.chat_service import ChatService\nfrom lifetrace.storage import todo_mgr\nfrom lifetrace.util.prompt_loader import get_prompt\n\nfrom .base import _create_llm_stream_generator, logger, router\n\n\ndef _format_todo_context(context: dict[str, Any]) -> str:  # noqa: C901\n    \"\"\"格式化任务上下文信息为易读的文本\"\"\"\n    lines: list[str] = []\n\n    # 格式化单个任务信息\n    def _format_todo(todo: dict[str, Any], prefix: str = \"\") -> str:\n        parts: list[str] = []\n        parts.append(f\"{prefix}- **{todo.get('name', '未知任务')}**\")\n        # 包含描述信息（如果存在）\n        description = todo.get(\"description\")\n        if description and description.strip():\n            parts.append(f\"{prefix}  描述: {description}\")\n        # 包含用户笔记（如果存在）\n        user_notes = todo.get(\"user_notes\")\n        if user_notes and user_notes.strip():\n            parts.append(f\"{prefix}  用户笔记: {user_notes}\")\n        schedule_start = todo.get(\"start_time\") or todo.get(\"deadline\")\n        schedule_end = todo.get(\"end_time\")\n        if schedule_start:\n            schedule_label = schedule_start\n            if schedule_end:\n                schedule_label = f\"{schedule_start} ~ {schedule_end}\"\n            parts.append(f\"{prefix}  时间: {schedule_label}\")\n        if todo.get(\"priority\") and todo[\"priority\"] != \"none\":\n            parts.append(f\"{prefix}  优先级: {todo['priority']}\")\n        if todo.get(\"tags\"):\n            parts.append(f\"{prefix}  标签: {', '.join(todo['tags'])}\")\n        if todo.get(\"status\"):\n            parts.append(f\"{prefix}  状态: {todo['status']}\")\n        return \"\\n\".join(parts)\n\n    # 当前任务的详细信息（最重要，放在最前面）\n    current = context.get(\"current\")\n    if current:\n        lines.append(\"**当前任务详细信息：**\")\n        lines.append(_format_todo(current))\n\n    # 父任务链\n    parents = context.get(\"parents\", [])\n    if parents:\n        lines.append(\"\\n**父任务链（从直接父任务到根任务）：**\")\n        for i, parent in enumerate(parents):\n            indent = \"  \" * (len(parents) - i - 1)\n            lines.append(_format_todo(parent, indent))\n\n    # 同级任务\n    siblings = context.get(\"siblings\", [])\n    if siblings:\n        lines.append(\"\\n**同级任务：**\")\n        for sibling in siblings:\n            lines.append(_format_todo(sibling, \"  \"))\n\n    # 子任务（递归格式化）\n    def _format_children(children: list[dict[str, Any]], depth: int = 0) -> list[str]:\n        result: list[str] = []\n        for child in children:\n            indent = \"  \" * (depth + 1)\n            result.append(_format_todo(child, indent))\n            # 递归处理子任务的子任务\n            if child.get(\"children\"):\n                result.extend(_format_children(child[\"children\"], depth + 1))\n        return result\n\n    children = context.get(\"children\", [])\n    if children:\n        lines.append(\"\\n**子任务：**\")\n        lines.extend(_format_children(children))\n\n    return \"\\n\".join(lines) if lines else \"\"\n\n\n@router.post(\"/plan/questionnaire/stream\")\nasync def plan_questionnaire_stream(\n    request: PlanQuestionnaireRequest,\n    chat_service: ChatService = Depends(get_chat_service),\n):\n    \"\"\"Plan功能：生成选择题（流式输出）\"\"\"\n    try:\n        logger.info(\n            f\"[plan/questionnaire] 收到请求，任务名称: {request.todo_name}, todo_id: {request.todo_id}, session_id: {request.session_id}\"\n        )\n\n        # 1. 确保 plan 会话存在\n        session_id = _ensure_plan_session(\n            session_id=request.session_id,\n            chat_service=chat_service,\n            todo_name=request.todo_name,\n            context_id=request.todo_id,\n        )\n\n        # 2. 构建任务上下文与 prompt\n        messages, context_info = _build_plan_questionnaire_prompts(request)\n\n        # 3. 保存用户消息到数据库（保存用户请求的任务名称和上下文信息）\n        user_message_content = f\"请求为任务生成选择题：{request.todo_name}\"\n        if context_info:\n            user_message_content += f\"\\n\\n任务上下文：\\n{context_info}\"\n        chat_service.add_message(\n            session_id=session_id,\n            role=\"user\",\n            content=user_message_content,\n        )\n\n        # 4. 使用统一的 LLM 流式生成器\n        rag_svc = get_rag_service()\n        token_generator = _create_llm_stream_generator(\n            rag_svc=rag_svc,\n            messages=messages,\n            temperature=0.7,\n            chat_service=chat_service,\n            meta={\n                \"session_id\": session_id,\n                \"endpoint\": \"plan_questionnaire_stream\",\n                \"feature_type\": \"plan_assistant\",\n                \"user_query\": request.todo_name,\n                \"additional_info\": {\n                    \"todo_id\": request.todo_id,\n                    \"has_context\": bool(context_info),\n                },\n            },\n        )\n\n        headers = {\n            \"Cache-Control\": \"no-cache\",\n            \"X-Accel-Buffering\": \"no\",\n            \"X-Session-Id\": session_id,  # 返回 session_id 供前端使用\n        }\n        return StreamingResponse(\n            token_generator, media_type=\"text/plain; charset=utf-8\", headers=headers\n        )\n\n    except Exception as e:\n        logger.error(f\"[plan/questionnaire] 处理失败: {e}\")\n        raise HTTPException(status_code=500, detail=\"生成选择题失败\") from e\n\n\ndef _ensure_plan_session(\n    *,\n    session_id: str | None,\n    chat_service: ChatService,\n    todo_name: str,\n    context_id: int | None = None,\n) -> str:\n    \"\"\"确保 plan 相关会话存在，并在需要时创建。\"\"\"\n    final_session_id = session_id or chat_service.generate_session_id()\n    if not session_id:\n        logger.info(f\"[plan] 创建新会话: {final_session_id}\")\n\n    chat = chat_service.get_chat_by_session_id(final_session_id)\n    if not chat:\n        chat_service.create_chat(\n            session_id=final_session_id,\n            chat_type=\"plan\",\n            title=f\"规划任务: {todo_name}\",\n            context_id=context_id,\n        )\n        logger.info(f\"[plan] 在数据库中创建会话: {final_session_id}, 类型: plan\")\n\n    return final_session_id\n\n\ndef _build_plan_questionnaire_prompts(\n    request: PlanQuestionnaireRequest,\n) -> tuple[list[dict[str, str]], str]:\n    \"\"\"构建 Plan 问卷的上下文与 prompts。\"\"\"\n    context_info = \"\"\n    if request.todo_id is not None:\n        context = todo_mgr.get_todo_context(request.todo_id)\n        if context:\n            context_info = _format_todo_context(context)\n            current_todo = context.get(\"current\", {})\n            logger.info(\n                f\"[plan/questionnaire] 获取到任务上下文: \"\n                f\"当前任务(id={current_todo.get('id')}, desc={bool(current_todo.get('description'))}, notes={bool(current_todo.get('user_notes'))}), \"\n                f\"{len(context.get('parents', []))} 个父任务, \"\n                f\"{len(context.get('siblings', []))} 个同级任务, {len(context.get('children', []))} 个子任务\"\n            )\n        else:\n            logger.warning(f\"[plan/questionnaire] 无法获取 todo_id={request.todo_id} 的上下文\")\n\n    system_prompt = get_prompt(\"plan_questionnaire\", \"system_assistant\")\n    user_prompt = get_prompt(\n        \"plan_questionnaire\",\n        \"user_prompt\",\n        todo_name=request.todo_name,\n        context_info=context_info,\n    )\n\n    if not system_prompt or not user_prompt:\n        raise HTTPException(status_code=500, detail=\"无法加载 prompt 配置，请检查 prompt.yaml\")\n\n    messages = [\n        {\"role\": \"system\", \"content\": system_prompt},\n        {\"role\": \"user\", \"content\": user_prompt},\n    ]\n    return messages, context_info\n\n\n@router.post(\"/plan/summary/stream\")\nasync def plan_summary_stream(\n    request: PlanSummaryRequest,\n    chat_service: ChatService = Depends(get_chat_service),\n):\n    \"\"\"Plan功能：生成任务总结和子任务（流式输出）\"\"\"\n    try:\n        logger.info(\n            f\"[plan/summary] 收到请求，任务名称: {request.todo_name}, 回答数量: {len(request.answers)}, session_id: {request.session_id}\"\n        )\n\n        # 1. 确保 plan 会话存在\n        session_id = _ensure_plan_session(\n            session_id=request.session_id,\n            chat_service=chat_service,\n            todo_name=request.todo_name,\n        )\n\n        # 2. 构建回答文本与 prompt\n        messages, answers_text = _build_plan_summary_prompts(request)\n\n        # 3. 保存用户消息到数据库（保存用户回答）\n        user_message_content = (\n            f\"为任务生成总结和子任务：{request.todo_name}\\n\\n用户回答：\\n{answers_text}\"\n        )\n        chat_service.add_message(\n            session_id=session_id,\n            role=\"user\",\n            content=user_message_content,\n        )\n\n        # 4. 使用统一的 LLM 流式生成器\n        rag_svc = get_rag_service()\n        token_generator = _create_llm_stream_generator(\n            rag_svc=rag_svc,\n            messages=messages,\n            temperature=0.7,\n            chat_service=chat_service,\n            meta={\n                \"session_id\": session_id,\n                \"endpoint\": \"plan_summary_stream\",\n                \"feature_type\": \"plan_assistant\",\n                \"user_query\": request.todo_name,\n                \"additional_info\": {\n                    \"answers_count\": len(request.answers),\n                },\n            },\n        )\n\n        headers = {\n            \"Cache-Control\": \"no-cache\",\n            \"X-Accel-Buffering\": \"no\",\n            \"X-Session-Id\": session_id,  # 返回 session_id 供前端使用\n        }\n        return StreamingResponse(\n            token_generator, media_type=\"text/plain; charset=utf-8\", headers=headers\n        )\n\n    except Exception as e:\n        logger.error(f\"[plan/summary] 处理失败: {e}\")\n        raise HTTPException(status_code=500, detail=\"生成总结失败\") from e\n\n\ndef _build_plan_summary_prompts(\n    request: PlanSummaryRequest,\n) -> tuple[list[dict[str, str]], str]:\n    \"\"\"构建 Plan 总结的回答文本与 prompts。\"\"\"\n    answers_text = \"\\n\".join(\n        [\n            f\"问题 {question_id}: {', '.join(selected_options)}\"\n            for question_id, selected_options in request.answers.items()\n        ]\n    )\n    answers_text = answers_text.replace(\"custom:\", \"\")\n\n    system_prompt = get_prompt(\"plan_summary\", \"system_assistant\")\n    user_prompt = get_prompt(\n        \"plan_summary\",\n        \"user_prompt\",\n        todo_name=request.todo_name,\n        answers_text=answers_text,\n    )\n\n    if not system_prompt or not user_prompt:\n        raise HTTPException(status_code=500, detail=\"无法加载 prompt 配置，请检查 prompt.yaml\")\n\n    messages = [\n        {\"role\": \"system\", \"content\": system_prompt},\n        {\"role\": \"user\", \"content\": user_prompt},\n    ]\n    return messages, answers_text\n"
  },
  {
    "path": "lifetrace/routers/chat.py",
    "content": "\"\"\"聊天相关路由聚合模块。\n\n此模块仅导出统一的 `router`，具体路由实现拆分在 `chat` 子包中：\n- `chat.core`：基础问答与流式聊天\n- `chat.context`：带事件上下文的流式聊天\n- `chat.plan`：Plan 问卷与总结相关路由\n- `chat.misc`：会话管理、历史记录、查询建议等辅助接口\n\"\"\"\n"
  },
  {
    "path": "lifetrace/routers/config.py",
    "content": "\"\"\"配置相关路由\"\"\"\n\nimport asyncio\nimport json\nimport uuid\nfrom typing import Any\n\nfrom fastapi import APIRouter, HTTPException\n\nfrom lifetrace.services.config_service import ConfigService, is_llm_configured\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.prompt_loader import get_prompt\nfrom lifetrace.util.settings import settings\n\ntry:\n    from tavily import TavilyClient\nexcept ImportError:\n    TavilyClient = None\n\ntry:\n    import websockets\n    from websockets.exceptions import ConnectionClosed, InvalidURI\nexcept ImportError:\n    websockets = None\n    ConnectionClosed = Exception\n    InvalidURI = Exception\n\nlogger = get_logger()\n\nrouter = APIRouter(prefix=\"/api\", tags=[\"config\"])\n\n\n# 初始化配置服务\nconfig_service = ConfigService()\n\n# 追踪 LLM 连接是否已验证成功\n# 只有通过 API 测试成功后才设置为 True\n_llm_connection_state: dict[str, bool] = {\"verified\": False}\n\n\ndef verify_llm_connection_on_startup():\n    \"\"\"在应用启动时验证现有 LLM 配置\n\n    如果配置存在且有效，尝试连接验证\n    \"\"\"\n    if not is_llm_configured():\n        logger.info(\"LLM 未配置，跳过启动时验证\")\n        return\n\n    try:\n        from openai import OpenAI  # noqa: PLC0415\n    except Exception as exc:\n        logger.warning(f\"OpenAI 依赖未安装，跳过启动时验证: {exc}\")\n        return\n\n    try:\n        api_key = settings.llm.api_key\n        base_url = settings.llm.base_url\n        model = settings.llm.model\n\n        # 创建临时客户端进行测试\n        client = OpenAI(api_key=api_key, base_url=base_url)\n\n        # 发送最小化测试请求验证认证\n        client.chat.completions.create(\n            model=model, messages=[{\"role\": \"user\", \"content\": \"test\"}], max_tokens=5\n        )\n\n        _llm_connection_state[\"verified\"] = True\n        logger.info(\"LLM 启动时连接验证成功\")\n    except Exception as e:\n        _llm_connection_state[\"verified\"] = False\n        logger.warning(f\"LLM 启动时连接验证失败: {e}\")\n\n\ndef _validate_aliyun_api_key(llm_key: str) -> dict[str, Any] | None:\n    \"\"\"验证阿里云 API Key 格式\"\"\"\n    min_aliyun_key_length = 20\n\n    if not llm_key.startswith(\"sk-\"):\n        return {\n            \"success\": False,\n            \"error\": \"阿里云 API Key 格式错误，应该以 'sk-' 开头\",\n        }\n    if len(llm_key) < min_aliyun_key_length:\n        return {\n            \"success\": False,\n            \"error\": f\"阿里云 API Key 长度异常（当前: {len(llm_key)} 字符），请检查是否完整\",\n        }\n    return None\n\n\ndef _handle_llm_test_error(error_msg: str, model: str) -> dict[str, Any]:\n    \"\"\"处理LLM测试错误，返回友好的错误信息\"\"\"\n    if \"401\" in error_msg or \"invalid_api_key\" in error_msg:\n        return {\n            \"success\": False,\n            \"error\": f\"API Key 无效，请检查：\\n1. 是否从阿里云控制台正确复制了完整的 API Key\\n2. API Key 是否已启用\\n3. API Key 是否有权限访问所选模型\\n\\n原始错误: {error_msg}\",\n        }\n    if \"404\" in error_msg:\n        return {\n            \"success\": False,\n            \"error\": f\"模型 '{model}' 不存在或无权访问，请检查模型名称是否正确\\n\\n原始错误: {error_msg}\",\n        }\n    return {\"success\": False, \"error\": error_msg}\n\n\ndef _get_config_value(config_data: dict[str, Any], camel_key: str, snake_key: str) -> Any:\n    \"\"\"从配置数据中获取值，同时支持 camelCase 和 snake_case 格式\n\n    Args:\n        config_data: 配置字典\n        camel_key: camelCase 格式的键（如 llmApiKey）\n        snake_key: snake_case 格式的键（如 llm_api_key）\n\n    Returns:\n        配置值，如果都不存在则返回 None\n    \"\"\"\n    return config_data.get(camel_key) or config_data.get(snake_key)\n\n\n@router.post(\"/test-llm-config\")\nasync def test_llm_config(config_data: dict[str, str]):\n    \"\"\"测试LLM配置是否可用（仅验证认证）\"\"\"\n    model = \"\"\n    try:\n        try:\n            from openai import OpenAI  # noqa: PLC0415\n        except Exception as exc:\n            return {\"success\": False, \"error\": f\"OpenAI 依赖未安装: {exc}\"}\n\n        # 同时支持 camelCase 和 snake_case 格式（前端 fetcher 会自动转换为 snake_case）\n        llm_key = _get_config_value(config_data, \"llmApiKey\", \"llm_api_key\")\n        base_url = _get_config_value(config_data, \"llmBaseUrl\", \"llm_base_url\")\n        model = _get_config_value(config_data, \"llmModel\", \"llm_model\")\n\n        if not llm_key or not base_url:\n            return {\"success\": False, \"error\": \"LLM Key 和 Base URL 不能为空\"}\n\n        # 验证 API Key 格式（针对阿里云）\n        if base_url and \"aliyun\" in base_url.lower():\n            validation_error = _validate_aliyun_api_key(llm_key)\n            if validation_error:\n                return validation_error\n\n        logger.info(f\"开始测试 LLM 配置 - 模型: {model}, Key前缀: {llm_key[:10]}...\")\n\n        # 创建临时客户端进行测试\n        client = OpenAI(api_key=llm_key, base_url=base_url)\n\n        # 发送最小化测试请求验证认证\n        try:\n            client.chat.completions.create(\n                model=model, messages=[{\"role\": \"user\", \"content\": \"test\"}], max_tokens=5\n            )\n            logger.info(f\"LLM配置测试成功 - 模型: {model}\")\n            return {\"success\": True, \"message\": \"配置验证成功\"}\n        except Exception as e:\n            logger.error(f\"LLM配置测试失败: {e} - 模型: {model}, Key前缀: {llm_key[:10]}...\")\n            return {\"success\": False, \"error\": str(e)}\n\n    except Exception as e:\n        error_msg = str(e)\n        logger.error(f\"LLM配置测试失败: {error_msg}\")\n        return _handle_llm_test_error(error_msg, model)\n\n\n@router.post(\"/test-tavily-config\")\nasync def test_tavily_config(config_data: dict[str, str]):\n    \"\"\"测试Tavily配置是否可用（仅验证认证）\"\"\"\n    try:\n        if TavilyClient is None:\n            return {\"success\": False, \"error\": \"Tavily 依赖未安装，请先安装 tavily\"}\n\n        # 同时支持 camelCase 和 snake_case 格式（前端 fetcher 会自动转换为 snake_case）\n        tavily_key = _get_config_value(config_data, \"tavilyApiKey\", \"tavily_api_key\")\n\n        if not tavily_key:\n            return {\"success\": False, \"error\": \"Tavily API Key 不能为空\"}\n\n        # 检查是否为占位符\n        invalid_values = [\n            \"xxx\",\n            \"YOUR_API_KEY_HERE\",\n            \"YOUR_TAVILY_API_KEY_HERE\",\n        ]\n        if tavily_key in invalid_values:\n            return {\"success\": False, \"error\": \"请填写有效的 Tavily API Key\"}\n\n        logger.info(f\"开始测试 Tavily 配置 - Key前缀: {tavily_key[:10]}...\")\n\n        # 创建临时客户端进行测试\n        try:\n            client = TavilyClient(api_key=tavily_key)\n            # 执行一个简单的搜索请求来验证 API key\n            client.search(query=\"test\", max_results=1)\n            logger.info(\"Tavily配置测试成功\")\n            return {\"success\": True, \"message\": \"配置验证成功\"}\n        except Exception as e:\n            error_msg = str(e)\n            logger.error(f\"Tavily配置测试失败: {error_msg} - Key前缀: {tavily_key[:10]}...\")\n            # 处理常见的错误\n            if \"401\" in error_msg or \"unauthorized\" in error_msg.lower():\n                error_msg = (\n                    \"API Key 无效，请检查：\\n1. 是否从 Tavily 控制台正确复制了完整的 API Key\\n\"\n                    \"2. API Key 是否已启用\\n\\n原始错误: \" + error_msg\n                )\n            return {\"success\": False, \"error\": error_msg}\n\n    except Exception as e:\n        error_msg = str(e)\n        logger.error(f\"Tavily配置测试失败: {error_msg}\")\n        return {\"success\": False, \"error\": error_msg}\n\n\ndef _parse_asr_config(config_data: dict[str, Any]) -> dict[str, Any]:\n    \"\"\"解析 ASR 配置参数\"\"\"\n    return {\n        \"asr_key\": _get_config_value(config_data, \"audioAsrApiKey\", \"audio_asr_api_key\"),\n        \"base_url\": _get_config_value(config_data, \"audioAsrBaseUrl\", \"audio_asr_base_url\"),\n        \"model\": _get_config_value(config_data, \"audioAsrModel\", \"audio_asr_model\")\n        or \"fun-asr-realtime\",\n        \"sample_rate\": int(\n            _get_config_value(config_data, \"audioAsrSampleRate\", \"audio_asr_sample_rate\") or 16000\n        ),\n        \"format_type\": _get_config_value(config_data, \"audioAsrFormat\", \"audio_asr_format\")\n        or \"pcm\",\n        \"semantic_punc\": _get_config_value(\n            config_data,\n            \"audioAsrSemanticPunctuationEnabled\",\n            \"audio_asr_semantic_punctuation_enabled\",\n        )\n        or False,\n        \"max_silence\": int(\n            _get_config_value(\n                config_data, \"audioAsrMaxSentenceSilence\", \"audio_asr_max_sentence_silence\"\n            )\n            or 1300\n        ),\n        \"heartbeat\": _get_config_value(config_data, \"audioAsrHeartbeat\", \"audio_asr_heartbeat\")\n        or False,\n    }\n\n\ndef _build_asr_run_task_message(\n    task_id: str,\n    model: str,\n    format_type: str,\n    sample_rate: int,\n    semantic_punc: bool,\n    max_silence: int,\n    heartbeat: bool,\n) -> dict[str, Any]:\n    \"\"\"构建 ASR run-task 消息\"\"\"\n    return {\n        \"header\": {\n            \"action\": \"run-task\",\n            \"task_id\": task_id,\n            \"streaming\": \"duplex\",\n        },\n        \"payload\": {\n            \"task_group\": \"audio\",\n            \"task\": \"asr\",\n            \"function\": \"recognition\",\n            \"model\": model,\n            \"parameters\": {\n                \"format\": format_type,\n                \"sample_rate\": sample_rate,\n                \"semantic_punctuation_enabled\": semantic_punc,\n                \"max_sentence_silence\": max_silence,\n                \"heartbeat\": heartbeat,\n            },\n            \"input\": {},\n        },\n    }\n\n\ndef _build_asr_finish_task_message(task_id: str) -> dict[str, Any]:\n    \"\"\"构建 ASR finish-task 消息\"\"\"\n    return {\n        \"header\": {\n            \"action\": \"finish-task\",\n            \"task_id\": task_id,\n            \"streaming\": \"duplex\",\n        },\n        \"payload\": {\"input\": {}},\n    }\n\n\nasync def _handle_asr_websocket_response(ws, task_id: str) -> dict[str, Any]:\n    \"\"\"处理 ASR WebSocket 响应\"\"\"\n    try:\n        response = await asyncio.wait_for(ws.recv(), timeout=3.0)\n        data = json.loads(response)\n        event = data.get(\"header\", {}).get(\"event\")\n        logger.info(f\"ASR 测试收到响应: {event}\")\n\n        if event in (\"task-started\", \"result-generated\"):\n            finish_message = _build_asr_finish_task_message(task_id)\n            await ws.send(json.dumps(finish_message))\n            logger.info(\"ASR配置测试成功\")\n            return {\"success\": True, \"message\": \"配置验证成功\"}\n        if event == \"task-failed\":\n            error_code = data.get(\"header\", {}).get(\"error_code\", \"\")\n            error_message = data.get(\"header\", {}).get(\"error_message\", \"\")\n            error_msg = f\"ASR任务失败: {error_code} - {error_message}\"\n            logger.error(f\"ASR配置测试失败: {error_msg}\")\n            return {\"success\": False, \"error\": error_msg}\n        # 其他事件也视为成功（至少连接和认证通过了）\n        logger.info(\"ASR配置测试成功（收到其他事件）\")\n        return {\"success\": True, \"message\": \"配置验证成功\"}\n    except TimeoutError:\n        # 超时也视为成功（至少连接和认证通过了）\n        logger.info(\"ASR配置测试成功（连接超时但已建立连接）\")\n        return {\"success\": True, \"message\": \"配置验证成功\"}\n\n\nasync def _test_asr_websocket_connection(\n    base_url: str, asr_key: str, run_task_message: dict[str, Any], task_id: str\n) -> dict[str, Any]:\n    \"\"\"测试 ASR WebSocket 连接\"\"\"\n    if websockets is None:\n        return {\"success\": False, \"error\": \"websockets 依赖未安装，请先安装 websockets\"}\n\n    headers = [(\"Authorization\", f\"Bearer {asr_key}\")]\n    try:\n        async with websockets.connect(base_url, additional_headers=headers, close_timeout=5) as ws:\n            await ws.send(json.dumps(run_task_message))\n            logger.info(\"ASR WebSocket 连接成功，已发送 run-task 消息\")\n            return await _handle_asr_websocket_response(ws, task_id)\n    except ConnectionClosed as e:\n        error_msg = f\"WebSocket 连接被关闭: {e}\"\n        logger.error(f\"ASR配置测试失败: {error_msg}\")\n        return {\"success\": False, \"error\": error_msg}\n    except InvalidURI as e:\n        error_msg = f\"WebSocket 地址无效: {e}\"\n        logger.error(f\"ASR配置测试失败: {error_msg}\")\n        return {\"success\": False, \"error\": error_msg}\n    except Exception as e:\n        error_msg = str(e)\n        logger.error(f\"ASR配置测试失败: {error_msg}\")\n        return {\"success\": False, \"error\": error_msg}\n\n\ndef _handle_asr_test_error(error_msg: str, model: str) -> dict[str, Any]:\n    \"\"\"处理ASR测试错误，返回友好的错误信息\"\"\"\n    if \"401\" in error_msg or \"unauthorized\" in error_msg.lower() or \"invalid\" in error_msg.lower():\n        return {\n            \"success\": False,\n            \"error\": f\"API Key 无效，请检查：\\n1. 是否从阿里云控制台正确复制了完整的 API Key\\n2. API Key 是否已启用\\n3. API Key 是否有权限访问 ASR 服务\\n\\n原始错误: {error_msg}\",\n        }\n    if \"404\" in error_msg or \"not found\" in error_msg.lower():\n        return {\n            \"success\": False,\n            \"error\": f\"WebSocket 地址或模型 '{model}' 不存在，请检查配置是否正确\\n\\n原始错误: {error_msg}\",\n        }\n    if \"connection\" in error_msg.lower() or \"timeout\" in error_msg.lower():\n        return {\n            \"success\": False,\n            \"error\": f\"连接失败，请检查：\\n1. WebSocket 地址是否正确\\n2. 网络连接是否正常\\n\\n原始错误: {error_msg}\",\n        }\n    return {\"success\": False, \"error\": error_msg}\n\n\n@router.post(\"/test-asr-config\")\nasync def test_asr_config(config_data: dict[str, Any]):\n    \"\"\"测试ASR配置是否可用（验证WebSocket连接和认证）\"\"\"\n    try:\n        # 解析配置参数\n        config = _parse_asr_config(config_data)\n        asr_key = config[\"asr_key\"]\n        base_url = config[\"base_url\"]\n        model = config[\"model\"]\n\n        if not asr_key or not base_url:\n            return {\"success\": False, \"error\": \"ASR API Key 和 Base URL 不能为空\"}\n\n        # 验证 API Key 格式（针对阿里云）\n        if \"aliyun\" in base_url.lower():\n            validation_error = _validate_aliyun_api_key(asr_key)\n            if validation_error:\n                return validation_error\n\n        logger.info(f\"开始测试 ASR 配置 - 模型: {model}, Key前缀: {asr_key[:10]}...\")\n\n        # 构建测试消息\n        task_id = uuid.uuid4().hex[:32]\n        run_task_message = _build_asr_run_task_message(\n            task_id,\n            model,\n            config[\"format_type\"],\n            config[\"sample_rate\"],\n            config[\"semantic_punc\"],\n            config[\"max_silence\"],\n            config[\"heartbeat\"],\n        )\n\n        # 测试 WebSocket 连接\n        return await _test_asr_websocket_connection(base_url, asr_key, run_task_message, task_id)\n\n    except Exception as e:\n        error_msg = str(e)\n        logger.error(f\"ASR配置测试失败: {error_msg}\")\n        model = (\n            _get_config_value(config_data, \"audioAsrModel\", \"audio_asr_model\") or \"fun-asr-realtime\"\n        )\n        return _handle_asr_test_error(error_msg, model)\n\n\n@router.get(\"/llm-status\")\nasync def get_llm_status():\n    \"\"\"检查 LLM 是否已正确配置并通过连接测试\n\n    Returns:\n        dict: 包含 configured 字段，表示 LLM 是否已配置且连接验证成功\n    \"\"\"\n    try:\n        # 只有配置存在且连接验证成功才返回 True\n        has_config = is_llm_configured()\n        return {\"configured\": has_config and _llm_connection_state[\"verified\"]}\n    except Exception as e:\n        logger.error(f\"检查 LLM 配置状态失败: {e}\")\n        return {\"configured\": False}\n\n\n@router.get(\"/get-config\")\nasync def get_config_detailed():\n    \"\"\"获取当前配置（返回驼峰格式的配置键）\"\"\"\n    try:\n        # 使用配置服务获取前端格式的配置\n        config_dict = config_service.get_config_for_frontend()\n\n        return {\n            \"success\": True,\n            \"config\": config_dict,\n        }\n    except Exception as e:\n        logger.error(f\"获取配置失败: {e}\")\n        raise HTTPException(status_code=500, detail=f\"获取配置失败: {e!s}\") from e\n\n\ndef _validate_config_fields(config_data: dict[str, str]) -> dict[str, Any] | None:\n    \"\"\"验证配置字段，返回错误信息或 None\"\"\"\n    # 同时支持 camelCase 和 snake_case 格式\n    llm_key = _get_config_value(config_data, \"llmApiKey\", \"llm_api_key\")\n    base_url = _get_config_value(config_data, \"llmBaseUrl\", \"llm_base_url\")\n    model = _get_config_value(config_data, \"llmModel\", \"llm_model\")\n\n    # 检查必需字段\n    missing_fields = []\n    if not llm_key:\n        missing_fields.append(\"llmApiKey\")\n    if not base_url:\n        missing_fields.append(\"llmBaseUrl\")\n    if not model:\n        missing_fields.append(\"llmModel\")\n\n    if missing_fields:\n        return {\n            \"success\": False,\n            \"error\": f\"缺少必需字段: {', '.join(missing_fields)}\",\n        }\n\n    # 验证字段类型和内容\n    if not isinstance(llm_key, str) or not llm_key.strip():\n        return {\"success\": False, \"error\": \"LLM Key必须是非空字符串\"}\n\n    if not isinstance(base_url, str) or not base_url.strip():\n        return {\"success\": False, \"error\": \"Base URL必须是非空字符串\"}\n\n    if not isinstance(model, str) or not model.strip():\n        return {\"success\": False, \"error\": \"模型名称必须是非空字符串\"}\n\n    return None\n\n\n@router.post(\"/save-and-init-llm\")\nasync def save_and_init_llm(config_data: dict[str, str]):\n    \"\"\"保存配置并重新初始化LLM服务\"\"\"\n    try:\n        # 验证必需字段\n        validation_error = _validate_config_fields(config_data)\n        if validation_error:\n            return validation_error\n\n        # 1. 先测试配置\n        test_result = await test_llm_config(config_data)\n        if not test_result[\"success\"]:\n            # 测试失败，标记连接未验证\n            _llm_connection_state[\"verified\"] = False\n            return test_result\n\n        # 2. 保存配置到文件（save_config 内部已经会重载配置并智能判断是否需要重新初始化 LLM）\n        save_result = await save_config(config_data)\n\n        if not save_result.get(\"success\"):\n            return {\"success\": False, \"error\": \"保存配置失败\"}\n\n        # 3. 测试成功，标记连接已验证\n        _llm_connection_state[\"verified\"] = True\n        logger.info(\"LLM 连接验证成功，配置已保存\")\n\n        return {\"success\": True, \"message\": \"配置保存成功，正在跳转...\"}\n\n    except Exception as e:\n        error_msg = str(e)\n        logger.error(f\"保存并初始化LLM失败: {error_msg}\")\n        return {\"success\": False, \"error\": error_msg}\n\n\n@router.post(\"/save-config\")\nasync def save_config(settings: dict[str, Any]):\n    \"\"\"保存配置到config.yaml文件\"\"\"\n    try:\n        # 定义更新 LLM 配置状态的回调函数（配置状态已通过 config.is_configured() 实时获取）\n        def update_llm_configured_status():\n            # 配置状态现在通过 config.is_configured() 实时获取\n            pass\n\n        # 调用配置服务保存配置\n        result = config_service.save_config(settings, update_llm_configured_status)\n        return result\n\n    except Exception as e:\n        logger.error(f\"保存配置失败: {e}\")\n        raise HTTPException(status_code=500, detail=f\"保存配置失败: {e!s}\") from e\n\n\n@router.get(\"/get-chat-prompts\")\nasync def get_chat_prompts(locale: str = \"zh\"):\n    \"\"\"获取前端聊天功能所需的 prompt\n\n    Args:\n        locale: 语言代码，'zh' 或 'en'，默认为 'zh'\n\n    Returns:\n        包含 editSystemPrompt 和 planSystemPrompt 的字典\n    \"\"\"\n    try:\n        # 根据语言选择对应的 prompt key\n        edit_key = \"edit_system_prompt_zh\" if locale == \"zh\" else \"edit_system_prompt_en\"\n        plan_key = \"plan_system_prompt_zh\" if locale == \"zh\" else \"plan_system_prompt_en\"\n\n        edit_prompt = get_prompt(\"chat_frontend\", edit_key)\n        plan_prompt = get_prompt(\"chat_frontend\", plan_key)\n\n        if not edit_prompt or not plan_prompt:\n            logger.warning(f\"无法加载 prompt，locale={locale}\")\n            raise HTTPException(\n                status_code=500,\n                detail=\"无法加载 prompt 配置，请检查 prompt.yaml\",\n            )\n\n        return {\n            \"success\": True,\n            \"editSystemPrompt\": edit_prompt,\n            \"planSystemPrompt\": plan_prompt,\n        }\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"获取聊天 prompt 失败: {e}\")\n        raise HTTPException(\n            status_code=500,\n            detail=f\"获取聊天 prompt 失败: {e!s}\",\n        ) from e\n"
  },
  {
    "path": "lifetrace/routers/cost_tracking.py",
    "content": "\"\"\"费用统计相关路由\"\"\"\n\nfrom datetime import timedelta\n\nfrom fastapi import APIRouter, HTTPException, Query\n\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.settings import settings\nfrom lifetrace.util.time_utils import get_utc_now\nfrom lifetrace.util.token_usage_logger import get_token_logger\n\nrouter = APIRouter(prefix=\"/api/cost-tracking\", tags=[\"cost-tracking\"])\nlogger = get_logger()\n\n\n@router.get(\"/stats\")\nasync def get_cost_stats(days: int = Query(30, description=\"统计天数\")):\n    \"\"\"\n    获取费用统计数据\n\n    Args:\n        days: 统计最近多少天的数据\n    \"\"\"\n    try:\n        token_logger = get_token_logger()\n        if not token_logger:\n            raise HTTPException(status_code=500, detail=\"Token日志记录器未初始化\")\n\n        # 获取token使用统计（已包含成本计算）\n        stats = token_logger.get_usage_stats(days=days)\n\n        # 获取当前模型配置\n        current_model = settings.llm.model\n\n        # 获取价格配置\n        token_logger = get_token_logger()\n        try:\n            input_price, output_price = token_logger._get_model_price(current_model)\n        except Exception:\n            input_price, output_price = 0.0, 0.0\n\n        # 整理功能类型费用数据\n        feature_costs = {}\n        for feature_type, feature_stats in stats.get(\"feature_stats\", {}).items():\n            feature_costs[feature_type] = {\n                \"input_tokens\": feature_stats.get(\"input_tokens\", 0),\n                \"output_tokens\": feature_stats.get(\"output_tokens\", 0),\n                \"total_tokens\": feature_stats.get(\"total_tokens\", 0),\n                \"requests\": feature_stats.get(\"requests\", 0),\n                \"cost\": round(feature_stats.get(\"total_cost\", 0), 4),\n            }\n\n        # 整理模型费用数据\n        model_costs = {}\n        for model, model_stats in stats.get(\"model_stats\", {}).items():\n            model_costs[model] = {\n                \"input_tokens\": model_stats.get(\"input_tokens\", 0),\n                \"output_tokens\": model_stats.get(\"output_tokens\", 0),\n                \"total_tokens\": model_stats.get(\"total_tokens\", 0),\n                \"requests\": model_stats.get(\"requests\", 0),\n                \"input_cost\": round(model_stats.get(\"input_cost\", 0), 4),\n                \"output_cost\": round(model_stats.get(\"output_cost\", 0), 4),\n                \"total_cost\": round(model_stats.get(\"total_cost\", 0), 4),\n            }\n\n        # 整理每日费用数据\n        daily_costs = {}\n        for date_str, daily_stats in stats.get(\"daily_stats\", {}).items():\n            daily_costs[date_str] = {\n                \"input_tokens\": daily_stats.get(\"input_tokens\", 0),\n                \"output_tokens\": daily_stats.get(\"output_tokens\", 0),\n                \"total_tokens\": daily_stats.get(\"total_tokens\", 0),\n                \"requests\": daily_stats.get(\"requests\", 0),\n                \"cost\": round(daily_stats.get(\"total_cost\", 0), 4),\n            }\n\n        now = get_utc_now().astimezone()\n        return {\n            \"success\": True,\n            \"data\": {\n                \"total_cost\": round(stats.get(\"total_cost\", 0), 4),\n                \"total_tokens\": stats.get(\"total_tokens\", 0),\n                \"total_requests\": stats.get(\"total_requests\", 0),\n                \"current_model\": current_model,\n                \"input_token_price\": input_price,\n                \"output_token_price\": output_price,\n                \"feature_costs\": feature_costs,\n                \"model_costs\": model_costs,\n                \"daily_costs\": daily_costs,\n                \"start_date\": (now - timedelta(days=days)).strftime(\"%Y-%m-%d\"),\n                \"end_date\": now.strftime(\"%Y-%m-%d\"),\n            },\n        }\n\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"获取费用统计失败: {e}\")\n        raise HTTPException(status_code=500, detail=f\"获取费用统计失败: {e!s}\") from e\n\n\n@router.get(\"/config\")\nasync def get_cost_config():\n    \"\"\"获取费用统计配置\"\"\"\n    try:\n        current_model = settings.llm.model\n        token_logger = get_token_logger()\n        try:\n            input_price, output_price = token_logger._get_model_price(current_model)\n        except Exception:\n            input_price, output_price = 0.0, 0.0\n\n        return {\n            \"success\": True,\n            \"data\": {\n                \"model\": current_model,\n                \"input_token_price\": input_price,\n                \"output_token_price\": output_price,\n            },\n        }\n    except Exception as e:\n        logger.error(f\"获取费用配置失败: {e}\")\n        raise HTTPException(status_code=500, detail=f\"获取费用配置失败: {e!s}\") from e\n"
  },
  {
    "path": "lifetrace/routers/event.py",
    "content": "\"\"\"事件相关路由\"\"\"\n\nfrom datetime import datetime\n\nfrom fastapi import APIRouter, Depends, HTTPException, Query\n\nfrom lifetrace.core.dependencies import get_event_service\nfrom lifetrace.schemas.event import EventDetailResponse, EventListResponse\nfrom lifetrace.services.event_service import EventService\nfrom lifetrace.util.logging_config import get_logger\n\nlogger = get_logger()\n\nrouter = APIRouter(prefix=\"/api/events\", tags=[\"event\"])\n\n\n@router.get(\"\", response_model=EventListResponse)\nasync def list_events(\n    limit: int = Query(50, ge=1, le=200),\n    offset: int = Query(0, ge=0),\n    start_date: str | None = Query(None),\n    end_date: str | None = Query(None),\n    app_name: str | None = Query(None),\n    service: EventService = Depends(get_event_service),\n):\n    \"\"\"获取事件列表（事件=前台应用使用阶段），用于事件级别展示与检索，同时返回总数\"\"\"\n    try:\n        start_dt = datetime.fromisoformat(start_date) if start_date else None\n        end_dt = datetime.fromisoformat(end_date) if end_date else None\n\n        return service.list_events(\n            limit=limit,\n            offset=offset,\n            start_date=start_dt,\n            end_date=end_dt,\n            app_name=app_name,\n        )\n    except Exception as e:\n        logger.error(f\"获取事件列表失败: {e}\")\n        raise HTTPException(status_code=500, detail=str(e)) from e\n\n\n@router.get(\"/count\")\nasync def count_events(\n    start_date: str | None = Query(None),\n    end_date: str | None = Query(None),\n    app_name: str | None = Query(None),\n    service: EventService = Depends(get_event_service),\n):\n    \"\"\"获取事件总数\"\"\"\n    try:\n        start_dt = datetime.fromisoformat(start_date) if start_date else None\n        end_dt = datetime.fromisoformat(end_date) if end_date else None\n        return service.count_events(\n            start_date=start_dt,\n            end_date=end_dt,\n            app_name=app_name,\n        )\n    except Exception as e:\n        logger.error(f\"获取事件总数失败: {e}\")\n        raise HTTPException(status_code=500, detail=str(e)) from e\n\n\n@router.get(\"/{event_id}\", response_model=EventDetailResponse)\nasync def get_event_detail(\n    event_id: int,\n    service: EventService = Depends(get_event_service),\n):\n    \"\"\"获取事件详情（包含该事件下的截图列表）\"\"\"\n    try:\n        return service.get_event_detail(event_id)\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"获取事件详情失败: {e}\")\n        raise HTTPException(status_code=500, detail=str(e)) from e\n\n\n@router.get(\"/{event_id}/context\")\nasync def get_event_context(\n    event_id: int,\n    service: EventService = Depends(get_event_service),\n):\n    \"\"\"获取事件的OCR文本上下文\"\"\"\n    try:\n        return service.get_event_context(event_id)\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"获取事件上下文失败: {e}\")\n        raise HTTPException(status_code=500, detail=str(e)) from e\n\n\n@router.post(\"/{event_id}/generate-summary\")\nasync def generate_event_summary(\n    event_id: int,\n    service: EventService = Depends(get_event_service),\n):\n    \"\"\"手动触发单个事件的摘要生成\"\"\"\n    try:\n        return service.generate_event_summary(event_id)\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"生成事件摘要失败: {e}\")\n        raise HTTPException(status_code=500, detail=str(e)) from e\n"
  },
  {
    "path": "lifetrace/routers/floating_capture.py",
    "content": "\"\"\"悬浮窗截图待办提取路由\"\"\"\n\nimport json\nimport re\nimport time\nfrom functools import lru_cache\nfrom typing import TYPE_CHECKING, Any, cast\n\nfrom fastapi import APIRouter, HTTPException\n\nif TYPE_CHECKING:\n    from openai.types.chat import ChatCompletionMessageParam\nelse:\n    ChatCompletionMessageParam = Any\n\nfrom lifetrace.llm.llm_client import LLMClient\nfrom lifetrace.schemas.floating_capture import (\n    CreatedTodo,\n    ExtractedTodo,\n    FloatingCaptureRequest,\n    FloatingCaptureResponse,\n)\nfrom lifetrace.storage import todo_mgr\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.prompt_loader import get_prompt\nfrom lifetrace.util.settings import settings\nfrom lifetrace.util.time_parser import calculate_scheduled_time\nfrom lifetrace.util.time_utils import get_utc_now\n\nlogger = get_logger()\n\nrouter = APIRouter(prefix=\"/api/floating-capture\", tags=[\"floating-capture\"])\n\n# 常量定义\nMIN_RESPONSE_LENGTH_THRESHOLD = 50  # LLM 响应的最小长度阈值\n\n# LLM 客户端单例\n\n\n@lru_cache(maxsize=1)\ndef get_llm_client() -> LLMClient:\n    \"\"\"获取 LLM 客户端单例\"\"\"\n    return LLMClient()\n\n\n@router.post(\"/extract-todos\", response_model=FloatingCaptureResponse)\nasync def extract_todos_from_capture(request: FloatingCaptureRequest) -> FloatingCaptureResponse:\n    \"\"\"\n    从悬浮窗截图中提取待办事项\n\n    Args:\n        request: 包含 base64 编码截图的请求\n\n    Returns:\n        提取和创建的待办事项列表\n    \"\"\"\n    try:\n        total_start = time.time()\n        logger.info(\"🚀 开始处理悬浮窗截图请求...\")\n\n        llm_client = get_llm_client()\n\n        if not llm_client.is_available():\n            return FloatingCaptureResponse(\n                success=False,\n                message=\"LLM 服务当前不可用，请检查配置\",\n                extracted_todos=[],\n                created_todos=[],\n                created_count=0,\n            )\n\n        # 获取已有待办列表用于去重\n        step_start = time.time()\n        existing_todos = todo_mgr.list_todos(limit=1000, status=\"active\")\n        existing_todos += todo_mgr.list_todos(limit=1000, status=\"draft\")\n        logger.info(\n            f\"⏱️ 获取已有待办列表: {time.time() - step_start:.3f}s (共 {len(existing_todos)} 条)\"\n        )\n\n        # 调用视觉模型提取待办\n        step_start = time.time()\n        extracted_todos = _call_vision_model_with_base64(\n            llm_client=llm_client,\n            image_base64=request.image_base64,\n            existing_todos=existing_todos,\n        )\n        vision_time = time.time() - step_start\n        logger.info(f\"⏱️ 视觉模型调用总耗时: {vision_time:.3f}s\")\n\n        if not extracted_todos:\n            total_time = time.time() - total_start\n            logger.info(f\"✅ 悬浮窗截图处理完成，总耗时: {total_time:.3f}s (未检测到待办事项)\")\n            return FloatingCaptureResponse(\n                success=True,\n                message=\"截图中未检测到待办事项\",\n                extracted_todos=[],\n                created_todos=[],\n                created_count=0,\n            )\n\n        # 转换为 ExtractedTodo 列表（不计入核心处理时间）\n        conversion_start = time.time()\n        extracted_todo_models = [\n            ExtractedTodo(\n                title=todo.get(\"title\", \"\"),\n                description=todo.get(\"description\"),\n                time_info=todo.get(\"time_info\"),\n                source_text=todo.get(\"source_text\"),\n                confidence=todo.get(\"confidence\", 0.5),\n            )\n            for todo in extracted_todos\n        ]\n        conversion_time = time.time() - conversion_start\n        logger.info(f\"⏱️ 数据转换耗时: {conversion_time:.3f}s\")\n\n        # 如果需要创建待办\n        created_todos: list[CreatedTodo] = []\n        created_count = 0\n\n        if request.create_todos:\n            step_start = time.time()\n            for todo_data in extracted_todos:\n                try:\n                    result = _create_draft_todo(todo_data)\n                    if result:\n                        created_count += 1\n                        created_todos.append(\n                            CreatedTodo(\n                                id=result[\"id\"],\n                                name=result[\"name\"],\n                                scheduled_time=result.get(\"scheduled_time\"),\n                            )\n                        )\n                except Exception as e:\n                    logger.error(f\"创建待办失败: {e}\", exc_info=True)\n                    continue\n            create_time = time.time() - step_start\n            logger.info(f\"⏱️ 创建待办到数据库: {create_time:.3f}s\")\n\n        total_time = time.time() - total_start\n        logger.info(\n            f\"✅ 悬浮窗截图处理完成，总耗时: {total_time:.3f}s (提取 {len(extracted_todos)} 个待办，创建 {created_count} 个)\"\n        )\n\n        return FloatingCaptureResponse(\n            success=True,\n            message=f\"成功提取 {len(extracted_todos)} 个待办，创建 {created_count} 个\",\n            extracted_todos=extracted_todo_models,\n            created_todos=created_todos,\n            created_count=created_count,\n        )\n\n    except Exception as e:\n        logger.error(f\"处理悬浮窗截图失败: {e}\", exc_info=True)\n        raise HTTPException(status_code=500, detail=f\"处理截图失败: {e!s}\") from e\n\n\ndef _process_llm_response(response: Any, api_time: float) -> str | None:\n    \"\"\"\n    处理 LLM API 响应，提取响应文本\n\n    Args:\n        response: LLM API 响应对象\n        api_time: API 调用耗时\n\n    Returns:\n        响应文本，如果响应无效则返回 None\n    \"\"\"\n    # 检查响应结构\n    if not response or not hasattr(response, \"choices\") or len(response.choices) == 0:\n        logger.error(f\"LLM API 返回异常响应结构: {response}\")\n        return None\n\n    # 检查 Token 使用情况（诊断性能问题）\n    usage = getattr(response, \"usage\", None)\n    if usage:\n        prompt_tokens = getattr(usage, \"prompt_tokens\", 0)\n        completion_tokens = getattr(usage, \"completion_tokens\", 0)\n        total_tokens = getattr(usage, \"total_tokens\", 0)\n        logger.info(\n            f\"  🔢 Token 使用: prompt={prompt_tokens}, completion={completion_tokens}, total={total_tokens}\"\n        )\n        if completion_tokens > 0:\n            tokens_per_second = completion_tokens / api_time if api_time > 0 else 0\n            logger.info(f\"  ⚡ 生成速度: {tokens_per_second:.1f} tokens/秒\")\n\n    # 检查是否使用了 thinking 模式\n    choice = response.choices[0]\n    message = choice.message\n\n    # 检查是否有 reasoning_content（thinking 模式的输出）\n    reasoning_content = getattr(message, \"reasoning_content\", None)\n    if reasoning_content:\n        reasoning_len = len(reasoning_content) if reasoning_content else 0\n        logger.warning(f\"  🧠 检测到 Thinking 模式，推理内容长度: {reasoning_len} 字符\")\n\n    # 检查 finish_reason\n    finish_reason = getattr(choice, \"finish_reason\", None)\n    if finish_reason:\n        logger.info(f\"  📋 响应完成原因: {finish_reason}\")\n        if finish_reason == \"length\":\n            logger.warning(\"  ⚠️ 响应因达到 max_tokens 限制而截断！\")\n\n    response_text = message.content or \"\"\n    if not response_text:\n        logger.warning(\"视觉模型返回空响应\")\n        return None\n\n    logger.info(f\"  📝 LLM 响应长度: {len(response_text)} 字符\")\n\n    # 诊断：记录响应前100个字符（用于调试）\n    preview = response_text[:100].replace(\"\\n\", \"\\\\n\")\n    logger.debug(f\"  👀 响应预览: {preview}...\")\n\n    return response_text\n\n\ndef _call_vision_model_with_base64(\n    llm_client: LLMClient,\n    image_base64: str,\n    existing_todos: list[dict[str, Any]],\n) -> list[dict[str, Any]]:\n    \"\"\"\n    使用 base64 图片直接调用视觉模型\n\n    Args:\n        llm_client: LLM 客户端\n        image_base64: Base64 编码的图片\n        existing_todos: 已有待办列表\n\n    Returns:\n        提取的待办列表\n    \"\"\"\n    try:\n        step_start = time.time()\n\n        # 格式化已有待办列表为 JSON\n        existing_todos_json = json.dumps(\n            [\n                {\n                    \"id\": todo.get(\"id\"),\n                    \"name\": todo.get(\"name\"),\n                    \"description\": todo.get(\"description\"),\n                }\n                for todo in existing_todos[:50]  # 限制数量\n            ],\n            ensure_ascii=False,\n            indent=2,\n        )\n\n        # 从配置文件加载提示词\n        system_prompt = get_prompt(\"auto_todo_detection\", \"system_assistant\")\n        user_prompt = get_prompt(\n            \"auto_todo_detection\",\n            \"user_prompt\",\n            existing_todos_json=existing_todos_json,\n        )\n\n        # 构建完整的提示词\n        full_prompt = f\"{system_prompt}\\n\\n{user_prompt}\"\n\n        # 确保 base64 有正确的前缀\n        if not image_base64.startswith(\"data:\"):\n            image_base64 = f\"data:image/png;base64,{image_base64}\"\n\n        # 构建消息内容\n        content = [\n            {\n                \"type\": \"image_url\",\n                \"image_url\": {\"url\": image_base64},\n            },\n            {\"type\": \"text\", \"text\": full_prompt},\n        ]\n\n        messages = cast(\"list[ChatCompletionMessageParam]\", [{\"role\": \"user\", \"content\": content}])\n\n        prep_time = time.time() - step_start\n        logger.info(f\"  ⏱️ 构建请求准备: {prep_time:.3f}s\")\n\n        # 获取视觉模型配置\n        vision_model = settings.llm.vision_model or settings.llm.model\n\n        # 计算图片大小\n        image_size_kb = len(image_base64) * 3 / 4 / 1024  # Base64 解码后大小估算\n        logger.info(f\"📷 调用视觉模型 {vision_model} (图片大小: {image_size_kb:.1f}KB)\")\n\n        # 调用模型\n        api_start = time.time()\n        try:\n            client = llm_client._get_client()\n            response = client.chat.completions.create(\n                model=vision_model,\n                messages=messages,\n                temperature=0.3,\n                max_tokens=2000,\n                timeout=60,\n                extra_body={\"enable_thinking\": False},  # 显式禁用 thinking 模式\n            )\n        except Exception as api_error:\n            logger.error(f\"LLM API 调用失败: {api_error}\", exc_info=True)\n            raise\n\n        api_time = time.time() - api_start\n        logger.info(f\"  ⏱️ LLM API 调用耗时: {api_time:.3f}s\")\n\n        # 处理响应\n        response_text = _process_llm_response(response, api_time)\n        if not response_text:\n            return []\n\n        # 解析响应\n        parse_start = time.time()\n        result = _parse_llm_response(response_text)\n        logger.info(f\"  ⏱️ 解析响应: {time.time() - parse_start:.3f}s (提取到 {len(result)} 个待办)\")\n\n        if not result and len(response_text) < MIN_RESPONSE_LENGTH_THRESHOLD:\n            logger.warning(f\"LLM 响应异常短（{len(response_text)} 字符），可能是错误消息或格式问题\")\n\n        return result\n\n    except Exception as e:\n        logger.error(f\"调用视觉模型失败: {e}\", exc_info=True)\n        return []\n\n\ndef _parse_llm_response(response_text: str) -> list[dict[str, Any]]:\n    \"\"\"\n    解析 LLM 响应\n\n    Args:\n        response_text: LLM 返回的文本\n\n    Returns:\n        待办列表\n    \"\"\"\n\n    def _extract_todos_from_result(result: dict[str, Any]) -> list[dict[str, Any]]:\n        \"\"\"从结果中提取待办列表\"\"\"\n        if \"new_todos\" in result:\n            return result[\"new_todos\"]\n        if \"todos\" in result:\n            return result[\"todos\"]\n        return []\n\n    try:\n        # 尝试提取 JSON\n        json_match = re.search(r\"\\{.*\\}\", response_text, re.DOTALL)\n        if json_match:\n            json_str = json_match.group(0)\n            result = json.loads(json_str)\n            todos = _extract_todos_from_result(result)\n            if todos:\n                return todos\n\n        # 如果没有找到 JSON，尝试直接解析\n        result = json.loads(response_text)\n        todos = _extract_todos_from_result(result)\n        if todos:\n            return todos\n\n        logger.warning(\"LLM 响应格式不正确，未找到 new_todos 或 todos 字段\")\n        return []\n\n    except json.JSONDecodeError as e:\n        logger.error(f\"解析 LLM 响应 JSON 失败: {e}\")\n        return []\n    except Exception as e:\n        logger.error(f\"解析 LLM 响应失败: {e}\", exc_info=True)\n        return []\n\n\ndef _create_draft_todo(todo_data: dict[str, Any]) -> dict[str, Any] | None:\n    \"\"\"\n    创建 draft 状态的待办\n\n    Args:\n        todo_data: 待办数据\n\n    Returns:\n        创建结果或 None\n    \"\"\"\n    title = todo_data.get(\"title\", \"\").strip()\n    if not title:\n        return None\n\n    description = todo_data.get(\"description\")\n    if description:\n        description = description.strip()\n\n    source_text = todo_data.get(\"source_text\", \"\")\n    time_info = todo_data.get(\"time_info\", {})\n    confidence = todo_data.get(\"confidence\")\n\n    # 计算 scheduled_time\n    scheduled_time = None\n    if time_info:\n        try:\n            reference_time = get_utc_now()\n            scheduled_time = calculate_scheduled_time(time_info, reference_time)\n        except Exception as e:\n            logger.warning(f\"计算 scheduled_time 失败: {e}\")\n\n    # 构建 user_notes\n    user_notes_parts = [\"来源: 悬浮窗截图\"]\n    if source_text:\n        user_notes_parts.append(f\"来源文本: {source_text}\")\n    if time_info and time_info.get(\"raw_text\"):\n        user_notes_parts.append(f\"时间: {time_info.get('raw_text')}\")\n    if confidence is not None:\n        user_notes_parts.append(f\"置信度: {confidence:.0%}\")\n    user_notes = \"\\n\".join(user_notes_parts)\n\n    # 创建待办\n    todo_id = todo_mgr.create_todo(\n        name=title,\n        description=description,\n        user_notes=user_notes,\n        start_time=scheduled_time,\n        status=\"draft\",\n        priority=\"none\",\n        tags=[\"悬浮窗提取\"],\n    )\n\n    if todo_id:\n        logger.info(f\"创建 draft 待办: {todo_id} - {title}\")\n        return {\n            \"id\": todo_id,\n            \"name\": title,\n            \"scheduled_time\": scheduled_time.isoformat() if scheduled_time else None,\n        }\n\n    return None\n\n\n@router.get(\"/health\")\nasync def health_check():\n    \"\"\"健康检查\"\"\"\n    llm_client = get_llm_client()\n    return {\n        \"status\": \"ok\",\n        \"llm_available\": llm_client.is_available(),\n    }\n"
  },
  {
    "path": "lifetrace/routers/health.py",
    "content": "\"\"\"健康检查路由\"\"\"\n\nimport os\nimport shutil\nimport subprocess  # nosec B404\nfrom functools import lru_cache\n\nfrom fastapi import APIRouter\n\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.settings import settings\nfrom lifetrace.util.time_utils import get_utc_now\n\nlogger = get_logger()\n\nrouter = APIRouter()\n\n# 服务器模式：由命令行参数设置，默认为 \"dev\"\n# \"dev\" = 开发模式（从源码运行或 pnpm dev）\n# \"build\" = 打包模式（Electron 打包后运行）\n_server_state: dict[str, str] = {\"mode\": \"dev\"}\n\n\n@lru_cache(maxsize=1)\ndef get_git_commit() -> str:\n    \"\"\"获取当前 Git Commit（优先读取环境变量，失败时返回 unknown）\"\"\"\n    env_commit = os.getenv(\"LIFETRACE_GIT_COMMIT\") or os.getenv(\"GIT_COMMIT\")\n    if env_commit:\n        return env_commit\n\n    git_path = shutil.which(\"git\")\n    if not git_path:\n        return \"unknown\"\n\n    try:\n        return subprocess.check_output(  # nosec B603\n            [git_path, \"rev-parse\", \"HEAD\"],\n            stderr=subprocess.DEVNULL,\n            text=True,\n        ).strip()\n    except Exception:\n        return \"unknown\"\n\n\ndef set_server_mode(mode: str) -> None:\n    \"\"\"设置服务器模式（由 server.py 在启动时调用）\"\"\"\n    _server_state[\"mode\"] = mode\n    logger.info(f\"服务器模式已设置为: {mode}\")\n\n\ndef get_server_mode() -> str:\n    \"\"\"获取当前服务器模式\"\"\"\n    return _server_state[\"mode\"]\n\n\n@router.get(\"/health\")\nasync def health_check():\n    \"\"\"健康检查\"\"\"\n    from lifetrace.core.dependencies import get_ocr_processor  # noqa: PLC0415\n    from lifetrace.storage import db_base  # noqa: PLC0415\n\n    ocr_processor = get_ocr_processor()\n    return {\n        \"app\": \"lifetrace\",  # 固定的应用标识，用于前端识别后端服务\n        \"status\": \"healthy\",\n        \"server_mode\": _server_state[\"mode\"],  # 服务器模式：dev 或 build\n        \"git_commit\": get_git_commit(),\n        \"timestamp\": get_utc_now(),\n        \"database\": \"connected\" if db_base.engine else \"disconnected\",\n        \"ocr\": \"available\" if ocr_processor.is_available() else \"unavailable\",\n    }\n\n\n@router.get(\"/health/llm\")\nasync def llm_health_check():\n    \"\"\"LLM服务健康检查\"\"\"\n    try:\n        # 获取RAG服务（延迟加载）- 验证服务能正常初始化\n        try:\n            from lifetrace.core.dependencies import get_rag_service  # noqa: PLC0415\n\n            get_rag_service()\n        except Exception as init_error:\n            return {\n                \"status\": \"unavailable\",\n                \"message\": f\"RAG服务初始化失败: {init_error!s}\",\n                \"timestamp\": get_utc_now().isoformat(),\n            }\n\n        # 检查配置是否完整\n        llm_key = settings.llm.api_key\n        base_url = settings.llm.base_url\n\n        if not llm_key or not base_url:\n            return {\n                \"status\": \"unconfigured\",\n                \"message\": \"LLM配置不完整，请设置API Key和Base URL\",\n                \"timestamp\": get_utc_now().isoformat(),\n            }\n\n        try:\n            from openai import OpenAI  # noqa: PLC0415\n        except Exception as exc:\n            logger.error(f\"OpenAI 依赖未安装: {exc}\")\n            return {\n                \"status\": \"error\",\n                \"message\": f\"OpenAI 依赖未安装: {exc}\",\n                \"timestamp\": get_utc_now().isoformat(),\n            }\n\n        client = OpenAI(api_key=llm_key, base_url=base_url)\n        model = settings.llm.model\n\n        # 发送最小化测试请求\n        response = client.chat.completions.create(  # noqa: F841\n            model=model,\n            messages=[{\"role\": \"user\", \"content\": \"test\"}],\n            max_tokens=5,\n            timeout=10,\n        )\n\n        return {\n            \"status\": \"healthy\",\n            \"message\": \"LLM服务正常\",\n            \"model\": model,\n            \"timestamp\": get_utc_now().isoformat(),\n        }\n\n    except Exception as e:\n        logger.error(f\"LLM健康检查失败: {e}\")\n        return {\n            \"status\": \"error\",\n            \"message\": f\"LLM服务异常: {e!s}\",\n            \"timestamp\": get_utc_now().isoformat(),\n        }\n"
  },
  {
    "path": "lifetrace/routers/journal.py",
    "content": "\"\"\"日记相关路由\"\"\"\n\nfrom datetime import datetime\n\nfrom fastapi import APIRouter, Depends, HTTPException, Path, Query\n\nfrom lifetrace.core.dependencies import get_journal_service\nfrom lifetrace.schemas.journal import (\n    JournalAutoLinkRequest,\n    JournalAutoLinkResponse,\n    JournalCreate,\n    JournalGenerateRequest,\n    JournalGenerateResponse,\n    JournalListResponse,\n    JournalResponse,\n    JournalUpdate,\n)\nfrom lifetrace.services.journal_service import JournalService\nfrom lifetrace.util.logging_config import get_logger\n\nlogger = get_logger()\n\nrouter = APIRouter(tags=[\"journals\"])\n\n\n@router.post(\"/api/journals\", response_model=JournalResponse, status_code=201)\nasync def create_journal(\n    journal: JournalCreate,\n    service: JournalService = Depends(get_journal_service),\n):\n    \"\"\"创建日记\"\"\"\n    try:\n        return service.create_journal(journal)\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"创建日记失败: {e}\", exc_info=True)\n        raise HTTPException(status_code=500, detail=f\"创建日记失败: {e!s}\") from e\n\n\n@router.get(\"/api/journals\", response_model=JournalListResponse)\nasync def list_journals(\n    limit: int = Query(100, ge=1, le=1000, description=\"返回数量限制\"),\n    offset: int = Query(0, ge=0, description=\"偏移量\"),\n    start_date: datetime | None = Query(None, description=\"开始日期筛选\"),\n    end_date: datetime | None = Query(None, description=\"结束日期筛选\"),\n    service: JournalService = Depends(get_journal_service),\n):\n    \"\"\"获取日记列表\"\"\"\n    try:\n        return service.list_journals(limit, offset, start_date, end_date)\n    except Exception as e:\n        logger.error(f\"获取日记列表失败: {e}\", exc_info=True)\n        raise HTTPException(status_code=500, detail=f\"获取日记列表失败: {e!s}\") from e\n\n\n@router.get(\"/api/journals/{journal_id}\", response_model=JournalResponse)\nasync def get_journal(\n    journal_id: int = Path(..., description=\"日记ID\"),\n    service: JournalService = Depends(get_journal_service),\n):\n    \"\"\"获取日记详情\"\"\"\n    try:\n        return service.get_journal(journal_id)\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"获取日记详情失败: {e}\", exc_info=True)\n        raise HTTPException(status_code=500, detail=f\"获取日记详情失败: {e!s}\") from e\n\n\n@router.put(\"/api/journals/{journal_id}\", response_model=JournalResponse)\nasync def update_journal(\n    journal_id: int = Path(..., description=\"日记ID\"),\n    journal: JournalUpdate | None = None,\n    service: JournalService = Depends(get_journal_service),\n):\n    \"\"\"更新日记\"\"\"\n    try:\n        if journal is None:\n            raise HTTPException(status_code=400, detail=\"缺少日记更新内容\")\n        return service.update_journal(journal_id, journal)\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"更新日记失败: {e}\", exc_info=True)\n        raise HTTPException(status_code=500, detail=f\"更新日记失败: {e!s}\") from e\n\n\n@router.delete(\"/api/journals/{journal_id}\", status_code=204)\nasync def delete_journal(\n    journal_id: int = Path(..., description=\"日记ID\"),\n    service: JournalService = Depends(get_journal_service),\n):\n    \"\"\"删除日记\"\"\"\n    try:\n        service.delete_journal(journal_id)\n        return None\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"删除日记失败: {e}\", exc_info=True)\n        raise HTTPException(status_code=500, detail=f\"删除日记失败: {e!s}\") from e\n\n\n@router.post(\"/api/journals/auto-link\", response_model=JournalAutoLinkResponse)\nasync def auto_link_journal(\n    payload: JournalAutoLinkRequest,\n    service: JournalService = Depends(get_journal_service),\n):\n    \"\"\"自动关联 Todo/活动\"\"\"\n    try:\n        return service.auto_link(payload)\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"自动关联失败: {e}\", exc_info=True)\n        raise HTTPException(status_code=500, detail=f\"自动关联失败: {e!s}\") from e\n\n\n@router.post(\"/api/journals/generate-objective\", response_model=JournalGenerateResponse)\nasync def generate_objective_journal(\n    payload: JournalGenerateRequest,\n    service: JournalService = Depends(get_journal_service),\n):\n    \"\"\"生成客观记录\"\"\"\n    try:\n        return service.generate_objective(payload)\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"生成客观记录失败: {e}\", exc_info=True)\n        raise HTTPException(status_code=500, detail=f\"生成客观记录失败: {e!s}\") from e\n\n\n@router.post(\"/api/journals/generate-ai\", response_model=JournalGenerateResponse)\nasync def generate_ai_journal(\n    payload: JournalGenerateRequest,\n    service: JournalService = Depends(get_journal_service),\n):\n    \"\"\"生成 AI 视角记录\"\"\"\n    try:\n        return service.generate_ai_view(payload)\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"生成 AI 视角失败: {e}\", exc_info=True)\n        raise HTTPException(status_code=500, detail=f\"生成 AI 视角失败: {e!s}\") from e\n"
  },
  {
    "path": "lifetrace/routers/logs.py",
    "content": "\"\"\"日志相关路由\"\"\"\n\nfrom fastapi import APIRouter, HTTPException, Query\nfrom fastapi.responses import PlainTextResponse\n\nfrom lifetrace.util.base_paths import get_user_logs_dir\nfrom lifetrace.util.logging_config import get_logger\n\nlogger = get_logger()\n\nrouter = APIRouter(prefix=\"/api/logs\", tags=[\"logs\"])\n\n# 常量定义\nBYTES_PER_KB = 1024  # 字节到KB的转换因子\nMAX_LOG_LINES = 1000  # 返回日志的最大行数\n\n\n@router.get(\"/files\")\nasync def get_log_files():\n    \"\"\"获取日志文件列表\"\"\"\n    try:\n        # 使用配置中的日志目录\n        logs_dir = get_user_logs_dir()\n        if not logs_dir.exists():\n            return []\n\n        log_files = []\n        # 递归扫描所有子目录中的.log文件\n        for file_path in logs_dir.rglob(\"*.log\"):\n            # 获取相对于logs目录的路径\n            relative_path = file_path.relative_to(logs_dir)\n            # 获取文件大小\n            file_size = file_path.stat().st_size\n            size_str = (\n                f\"{file_size // BYTES_PER_KB}KB\" if file_size > BYTES_PER_KB else f\"{file_size}B\"\n            )\n\n            log_files.append(\n                {\n                    \"name\": str(relative_path),\n                    \"path\": str(file_path),\n                    \"size\": size_str,\n                    \"category\": relative_path.parent.name\n                    if relative_path.parent.name != \".\"\n                    else \"root\",\n                }\n            )\n\n        return sorted(log_files, key=lambda x: x[\"name\"])\n    except Exception as e:\n        logger.error(f\"获取日志文件列表失败: {e}\")\n        raise HTTPException(status_code=500, detail=str(e)) from e\n\n\n@router.get(\"/content\", response_class=PlainTextResponse)\nasync def get_log_content(file: str = Query(..., description=\"日志文件相对路径\")):\n    \"\"\"获取日志文件内容\"\"\"\n    try:\n        # 使用配置中的日志目录\n        logs_dir = get_user_logs_dir()\n\n        log_file = logs_dir / file\n\n        # 安全检查：确保文件在logs目录内\n        if not str(log_file.resolve()).startswith(str(logs_dir.resolve())):\n            raise HTTPException(status_code=400, detail=\"无效的文件路径\")\n\n        if not log_file.exists():\n            raise HTTPException(status_code=404, detail=\"日志文件不存在\")\n\n        # 读取文件内容\n        with open(log_file, encoding=\"utf-8\") as f:\n            lines = f.readlines()\n            # 只返回最后 MAX_LOG_LINES 行，避免内存问题\n            if len(lines) > MAX_LOG_LINES:\n                lines = lines[-MAX_LOG_LINES:]\n            return \"\".join(lines)\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"读取日志文件失败: {e}\")\n        raise HTTPException(status_code=500, detail=str(e)) from e\n"
  },
  {
    "path": "lifetrace/routers/notification.py",
    "content": "\"\"\"通知相关路由\"\"\"\n\nfrom fastapi import APIRouter, HTTPException\n\nfrom lifetrace.storage.notification_storage import clear_notification, get_notifications\nfrom lifetrace.util.logging_config import get_logger\n\nlogger = get_logger()\n\nrouter = APIRouter(prefix=\"/api/notifications\", tags=[\"notifications\"])\n\n\n@router.get(\"\")\nasync def get_notification():\n    \"\"\"\n    获取通知列表（按时间倒序）\n\n    返回格式：\n    [\n        {\n            \"id\": \"通知ID\",\n            \"title\": \"通知标题\",\n            \"content\": \"通知内容\",\n            \"timestamp\": \"时间戳（ISO格式）\",\n            \"todo_id\": 待办ID（可选）\n        }\n    ]\n    \"\"\"\n    try:\n        notifications = get_notifications()\n        if notifications:\n            logger.debug(\"返回通知列表: %s\", len(notifications))\n        return notifications\n    except Exception as e:\n        logger.error(f\"获取通知失败: {e}\", exc_info=True)\n        raise HTTPException(status_code=500, detail=f\"获取通知失败: {e!s}\") from e\n\n\n@router.delete(\"/{notification_id}\")\nasync def delete_notification(notification_id: str):\n    \"\"\"\n    删除指定通知\n\n    Args:\n        notification_id: 通知ID\n\n    Returns:\n        {\"success\": True, \"message\": \"通知已删除\"}\n    \"\"\"\n    try:\n        deleted = clear_notification(notification_id)\n        if deleted:\n            logger.info(f\"删除通知: {notification_id}\")\n            return {\"success\": True, \"message\": \"通知已删除\"}\n        logger.warning(f\"通知不存在，无法删除: {notification_id}\")\n        return {\"success\": False, \"message\": \"通知不存在\"}\n    except Exception as e:\n        logger.error(f\"删除通知失败: {e}\", exc_info=True)\n        raise HTTPException(status_code=500, detail=f\"删除通知失败: {e!s}\") from e\n"
  },
  {
    "path": "lifetrace/routers/ocr.py",
    "content": "\"\"\"OCR相关路由\"\"\"\n\nfrom fastapi import APIRouter, HTTPException\n\nfrom lifetrace.core.dependencies import get_ocr_processor\nfrom lifetrace.storage import ocr_mgr, screenshot_mgr\nfrom lifetrace.util.logging_config import get_logger\n\nlogger = get_logger()\n\nrouter = APIRouter(prefix=\"/api/ocr\", tags=[\"ocr\"])\n\n\n@router.post(\"/process\")\nasync def process_ocr(screenshot_id: int):\n    \"\"\"手动触发OCR处理\"\"\"\n    ocr_processor = get_ocr_processor()\n    if not ocr_processor.is_available():\n        raise HTTPException(status_code=503, detail=\"OCR服务不可用\")\n\n    screenshot = screenshot_mgr.get_screenshot_by_id(screenshot_id)\n    if not screenshot:\n        raise HTTPException(status_code=404, detail=\"截图不存在\")\n\n    if screenshot[\"is_processed\"]:\n        raise HTTPException(status_code=400, detail=\"截图已经处理过\")\n\n    try:\n        # 执行OCR处理\n        ocr_result = ocr_processor.process_image(screenshot[\"file_path\"])\n\n        if ocr_result[\"success\"]:\n            # 保存OCR结果\n            ocr_mgr.add_ocr_result(\n                screenshot_id=screenshot[\"id\"],\n                text_content=ocr_result[\"text_content\"],\n                confidence=ocr_result[\"confidence\"],\n                language=ocr_result.get(\"language\", \"ch\"),\n                processing_time=ocr_result[\"processing_time\"],\n            )\n\n            return {\n                \"success\": True,\n                \"text_content\": ocr_result[\"text_content\"],\n                \"confidence\": ocr_result[\"confidence\"],\n                \"processing_time\": ocr_result[\"processing_time\"],\n            }\n        else:\n            raise HTTPException(status_code=500, detail=ocr_result[\"error\"])\n\n    except Exception as e:\n        logger.error(f\"OCR处理失败: {e}\")\n        raise HTTPException(status_code=500, detail=str(e)) from e\n\n\n@router.get(\"/statistics\")\nasync def get_ocr_statistics():\n    \"\"\"获取OCR处理统计\"\"\"\n    ocr_processor = get_ocr_processor()\n    return ocr_processor.get_statistics()\n"
  },
  {
    "path": "lifetrace/routers/proactive_ocr.py",
    "content": "\"\"\"Proactive OCR 路由\"\"\"\n\nimport sys\n\nfrom fastapi import APIRouter, HTTPException\n\nfrom lifetrace.jobs.proactive_ocr.service import get_proactive_ocr_service\nfrom lifetrace.util.logging_config import get_logger\n\nlogger = get_logger()\n\nrouter = APIRouter(prefix=\"/api/proactive-ocr\", tags=[\"proactive-ocr\"])\n\n\n@router.post(\"/start\")\nasync def start_proactive_ocr():\n    \"\"\"启动主动OCR监控服务\"\"\"\n    try:\n        service = get_proactive_ocr_service()\n        service.start()\n        return {\n            \"success\": True,\n            \"message\": \"Proactive OCR service started\",\n            \"status\": service.get_status(),\n        }\n    except Exception as e:\n        logger.error(f\"Failed to start proactive OCR: {e}\", exc_info=True)\n        raise HTTPException(status_code=500, detail=f\"Failed to start service: {e!s}\") from e\n\n\n@router.post(\"/stop\")\nasync def stop_proactive_ocr():\n    \"\"\"停止主动OCR监控服务\"\"\"\n    try:\n        service = get_proactive_ocr_service()\n        service.stop()\n        return {\n            \"success\": True,\n            \"message\": \"Proactive OCR service stopped\",\n            \"status\": service.get_status(),\n        }\n    except Exception as e:\n        logger.error(f\"Failed to stop proactive OCR: {e}\", exc_info=True)\n        raise HTTPException(status_code=500, detail=f\"Failed to stop service: {e!s}\") from e\n\n\n@router.post(\"/capture\")\nasync def capture_once():\n    \"\"\"手动触发一次捕获和OCR处理\"\"\"\n    try:\n        service = get_proactive_ocr_service()\n        result = service.run_once()\n\n        if result is None:\n            return {\n                \"success\": False,\n                \"message\": \"No target window detected or capture failed\",\n            }\n\n        return {\n            \"success\": True,\n            \"message\": \"Capture and OCR completed\",\n            \"result\": result,\n        }\n    except Exception as e:\n        logger.error(f\"Failed to capture: {e}\", exc_info=True)\n        raise HTTPException(status_code=500, detail=f\"Capture failed: {e!s}\") from e\n\n\n@router.get(\"/status\")\nasync def get_proactive_ocr_status():\n    \"\"\"获取主动OCR服务状态\"\"\"\n    try:\n        service = get_proactive_ocr_service()\n        status = service.get_status()\n        return {\n            \"success\": True,\n            \"status\": status,\n        }\n    except Exception as e:\n        logger.error(f\"Failed to get status: {e}\", exc_info=True)\n        raise HTTPException(status_code=500, detail=f\"Failed to get status: {e!s}\") from e\n\n\n@router.get(\"/health\")\nasync def health_check():\n    \"\"\"健康检查\"\"\"\n    service = get_proactive_ocr_service()\n    status = service.get_status()\n\n    return {\n        \"status\": \"ok\",\n        \"platform\": sys.platform,\n        \"windows_available\": sys.platform == \"win32\",\n        \"service_running\": status[\"is_running\"],\n    }\n"
  },
  {
    "path": "lifetrace/routers/rag.py",
    "content": "\"\"\"RAG服务和应用图标相关路由\"\"\"\n\nfrom fastapi import APIRouter, HTTPException\nfrom fastapi.responses import FileResponse\n\nfrom lifetrace.core.dependencies import get_rag_service\nfrom lifetrace.util.app_utils import get_icon_filename\nfrom lifetrace.util.base_paths import get_app_root\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.time_utils import get_utc_now\n\nlogger = get_logger()\n\nrouter = APIRouter(prefix=\"/api\", tags=[\"rag\"])\n\n\n@router.get(\"/rag/health\")\nasync def rag_health_check():\n    \"\"\"RAG服务健康检查\"\"\"\n    try:\n        return get_rag_service().health_check()\n    except Exception as e:\n        logger.error(f\"RAG健康检查失败: {e}\")\n        return {\n            \"rag_service\": \"error\",\n            \"error\": str(e),\n            \"timestamp\": get_utc_now().isoformat(),\n        }\n\n\n@router.get(\"/app-icon/{app_name}\")\nasync def get_app_icon(app_name: str):\n    \"\"\"\n    获取应用图标\n    根据映射表返回对应的图标文件\n\n    Args:\n        app_name: 应用名称\n\n    Returns:\n        图标文件\n    \"\"\"\n    try:\n        # 根据映射表获取图标文件名\n        icon_filename = get_icon_filename(app_name)\n\n        if not icon_filename:\n            raise HTTPException(status_code=404, detail=\"图标未找到\")\n\n        # 构建图标文件路径\n        # 获取项目根目录（lifetrace 的父目录）\n        lifetrace_dir = get_app_root()\n        project_root = lifetrace_dir.parent\n        icon_path = project_root / \".github\" / \"assets\" / \"icons\" / \"apps\" / icon_filename\n\n        if not icon_path.exists():\n            logger.warning(f\"图标文件不存在: {icon_path}\")\n            raise HTTPException(status_code=404, detail=\"图标文件不存在\")\n\n        # 返回图标文件\n        return FileResponse(\n            str(icon_path),\n            media_type=\"image/png\",\n            headers={\"Cache-Control\": \"public, max-age=86400\"},  # 缓存1天\n        )\n\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"获取应用图标失败 {app_name}: {e}\")\n        raise HTTPException(status_code=500, detail=f\"获取图标失败: {e!s}\") from e\n"
  },
  {
    "path": "lifetrace/routers/scheduler.py",
    "content": "\"\"\"\n定时任务管理路由\n提供定时任务的查询、管理和控制接口\n\"\"\"\n\nfrom fastapi import APIRouter, HTTPException\nfrom pydantic import BaseModel\n\nfrom lifetrace.jobs.scheduler import get_scheduler_manager\nfrom lifetrace.services.config_service import ConfigService\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.settings import reload_settings\n\nlogger = get_logger()\n\nrouter = APIRouter(prefix=\"/api/scheduler\", tags=[\"scheduler\"])\n\n\n# 数据模型\nclass JobInfo(BaseModel):\n    \"\"\"任务信息\"\"\"\n\n    id: str\n    name: str | None = None\n    func: str\n    trigger: str\n    next_run_time: str | None = None\n    pending: bool = False\n\n\nclass JobListResponse(BaseModel):\n    \"\"\"任务列表响应\"\"\"\n\n    total: int\n    jobs: list[JobInfo]\n\n\nclass JobOperationRequest(BaseModel):\n    \"\"\"任务操作请求\"\"\"\n\n    job_id: str\n\n\nclass JobIntervalUpdateRequest(BaseModel):\n    \"\"\"任务间隔更新请求\"\"\"\n\n    job_id: str\n    seconds: int | None = None\n    minutes: int | None = None\n    hours: int | None = None\n\n\nclass JobOperationResponse(BaseModel):\n    \"\"\"任务操作响应\"\"\"\n\n    success: bool\n    message: str\n\n\n@router.get(\"/jobs\", response_model=JobListResponse)\nasync def get_all_jobs():\n    \"\"\"获取所有定时任务\"\"\"\n    try:\n        scheduler_manager = get_scheduler_manager()\n        jobs = scheduler_manager.get_all_jobs()\n\n        job_list = []\n        for job in jobs:\n            job_info = JobInfo(\n                id=job.id,\n                name=job.name,\n                func=str(job.func_ref),\n                trigger=str(job.trigger),\n                next_run_time=(job.next_run_time.isoformat() if job.next_run_time else None),\n                pending=job.next_run_time is not None,\n            )\n            job_list.append(job_info)\n\n        return JobListResponse(total=len(job_list), jobs=job_list)\n    except Exception as e:\n        logger.error(f\"获取任务列表失败: {e}\")\n        raise HTTPException(status_code=500, detail=str(e)) from e\n\n\n@router.get(\"/jobs/{job_id}\", response_model=JobInfo)\nasync def get_job_detail(job_id: str):\n    \"\"\"获取指定任务的详细信息\"\"\"\n    try:\n        scheduler_manager = get_scheduler_manager()\n        job = scheduler_manager.get_job(job_id)\n\n        if not job:\n            raise HTTPException(status_code=404, detail=\"任务不存在\")\n\n        return JobInfo(\n            id=job.id,\n            name=job.name,\n            func=str(job.func_ref),\n            trigger=str(job.trigger),\n            next_run_time=(job.next_run_time.isoformat() if job.next_run_time else None),\n            pending=job.next_run_time is not None,\n        )\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"获取任务详情失败: {e}\")\n        raise HTTPException(status_code=500, detail=str(e)) from e\n\n\n@router.post(\"/jobs/{job_id}/pause\", response_model=JobOperationResponse)\nasync def pause_job(job_id: str):\n    \"\"\"暂停指定任务\"\"\"\n    try:\n        scheduler_manager = get_scheduler_manager()\n        success = scheduler_manager.pause_job(job_id)\n\n        if success:\n            # 同步更新配置文件中的 enabled 状态\n            _sync_job_enabled_to_config(job_id, False)\n            return JobOperationResponse(success=True, message=f\"任务 {job_id} 已暂停\")\n        else:\n            raise HTTPException(status_code=400, detail=\"暂停任务失败\")\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"暂停任务失败: {e}\")\n        raise HTTPException(status_code=500, detail=str(e)) from e\n\n\n@router.post(\"/jobs/{job_id}/resume\", response_model=JobOperationResponse)\nasync def resume_job(job_id: str):\n    \"\"\"恢复指定任务\"\"\"\n    try:\n        scheduler_manager = get_scheduler_manager()\n        success = scheduler_manager.resume_job(job_id)\n\n        if success:\n            # 同步更新配置文件中的 enabled 状态\n            _sync_job_enabled_to_config(job_id, True)\n            return JobOperationResponse(success=True, message=f\"任务 {job_id} 已恢复\")\n        else:\n            raise HTTPException(status_code=400, detail=\"恢复任务失败\")\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"恢复任务失败: {e}\")\n        raise HTTPException(status_code=500, detail=str(e)) from e\n\n\n@router.put(\"/jobs/{job_id}/interval\", response_model=JobOperationResponse)\nasync def update_job_interval(job_id: str, request: JobIntervalUpdateRequest):\n    \"\"\"更新任务执行间隔\"\"\"\n    try:\n        scheduler_manager = get_scheduler_manager()\n\n        # 验证至少提供一个时间参数\n        if request.seconds is None and request.minutes is None and request.hours is None:\n            raise HTTPException(status_code=400, detail=\"必须提供至少一个时间间隔参数\")\n\n        success = scheduler_manager.modify_job_interval(\n            job_id,\n            seconds=request.seconds,\n            minutes=request.minutes,\n            hours=request.hours,\n        )\n\n        if success:\n            # 同步更新配置文件中的间隔\n            _sync_job_interval_to_config(job_id, request.seconds, request.minutes, request.hours)\n\n            interval_parts = []\n            if request.hours:\n                interval_parts.append(f\"{request.hours}小时\")\n            if request.minutes:\n                interval_parts.append(f\"{request.minutes}分钟\")\n            if request.seconds:\n                interval_parts.append(f\"{request.seconds}秒\")\n            interval_str = \"\".join(interval_parts)\n\n            return JobOperationResponse(\n                success=True,\n                message=f\"任务 {job_id} 的执行间隔已更新为 {interval_str}\",\n            )\n        else:\n            raise HTTPException(status_code=400, detail=\"更新任务间隔失败\")\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"更新任务间隔失败: {e}\")\n        raise HTTPException(status_code=500, detail=str(e)) from e\n\n\n@router.delete(\"/jobs/{job_id}\", response_model=JobOperationResponse)\nasync def remove_job(job_id: str):\n    \"\"\"删除指定任务\"\"\"\n    try:\n        scheduler_manager = get_scheduler_manager()\n        success = scheduler_manager.remove_job(job_id)\n\n        if success:\n            return JobOperationResponse(success=True, message=f\"任务 {job_id} 已删除\")\n        else:\n            raise HTTPException(status_code=400, detail=\"删除任务失败\")\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"删除任务失败: {e}\")\n        raise HTTPException(status_code=500, detail=str(e)) from e\n\n\n@router.get(\"/status\")\nasync def get_scheduler_status():\n    \"\"\"获取调度器状态\"\"\"\n    try:\n        scheduler_manager = get_scheduler_manager()\n        jobs = scheduler_manager.get_all_jobs()\n\n        running_jobs = [job for job in jobs if job.next_run_time is not None]\n        paused_jobs = [job for job in jobs if job.next_run_time is None]\n\n        return {\n            \"running\": scheduler_manager.scheduler.running\n            if scheduler_manager.scheduler\n            else False,\n            \"total_jobs\": len(jobs),\n            \"running_jobs\": len(running_jobs),\n            \"paused_jobs\": len(paused_jobs),\n        }\n    except Exception as e:\n        logger.error(f\"获取调度器状态失败: {e}\")\n        raise HTTPException(status_code=500, detail=str(e)) from e\n\n\n@router.post(\"/jobs/pause-all\", response_model=JobOperationResponse)\nasync def pause_all_jobs():\n    \"\"\"暂停所有任务\"\"\"\n    try:\n        scheduler_manager = get_scheduler_manager()\n\n        # 获取所有任务列表\n        jobs = scheduler_manager.get_all_jobs()\n        paused_jobs = []\n\n        # 逐个暂停任务并同步配置\n        for job in jobs:\n            if job.next_run_time is not None:  # 只暂停未暂停的任务\n                try:\n                    scheduler_manager.pause_job(job.id)\n                    # 同步更新配置文件\n                    _sync_job_enabled_to_config(job.id, False)\n                    paused_jobs.append(job.id)\n                except Exception as e:\n                    logger.error(f\"暂停任务 {job.id} 失败: {e}\")\n\n        return JobOperationResponse(\n            success=True,\n            message=f\"已暂停 {len(paused_jobs)} 个任务\",\n        )\n    except Exception as e:\n        logger.error(f\"批量暂停任务失败: {e}\")\n        raise HTTPException(status_code=500, detail=str(e)) from e\n\n\n@router.post(\"/jobs/resume-all\", response_model=JobOperationResponse)\nasync def resume_all_jobs():\n    \"\"\"恢复所有任务\"\"\"\n    try:\n        scheduler_manager = get_scheduler_manager()\n\n        # 获取所有任务列表\n        jobs = scheduler_manager.get_all_jobs()\n        resumed_jobs = []\n\n        # 逐个恢复任务并同步配置\n        for job in jobs:\n            if job.next_run_time is None:  # 只恢复已暂停的任务\n                try:\n                    scheduler_manager.resume_job(job.id)\n                    # 同步更新配置文件\n                    _sync_job_enabled_to_config(job.id, True)\n                    resumed_jobs.append(job.id)\n                except Exception as e:\n                    logger.error(f\"恢复任务 {job.id} 失败: {e}\")\n\n        return JobOperationResponse(\n            success=True,\n            message=f\"已恢复 {len(resumed_jobs)} 个任务\",\n        )\n    except Exception as e:\n        logger.error(f\"批量恢复任务失败: {e}\")\n        raise HTTPException(status_code=500, detail=str(e)) from e\n\n\ndef _sync_job_enabled_to_config(job_id: str, enabled: bool):\n    \"\"\"同步任务的启用状态到配置文件（持久化到 YAML 文件）\n\n    Args:\n        job_id: 任务ID\n        enabled: 是否启用\n    \"\"\"\n    # 定义任务ID到配置路径的映射\n    job_config_map = {\n        \"recorder_job\": \"jobs.recorder.enabled\",\n        \"ocr_job\": \"jobs.ocr.enabled\",\n        \"clean_data_job\": \"jobs.clean_data.enabled\",\n        \"activity_aggregator_job\": \"jobs.activity_aggregator.enabled\",\n        \"todo_recorder_job\": \"jobs.todo_recorder.enabled\",\n        \"proactive_ocr_job\": \"jobs.proactive_ocr.enabled\",\n    }\n\n    # 联动配置：todo_recorder_job 与 auto_todo_detection 联动\n    linked_config_map = {\n        \"todo_recorder_job\": \"jobs.auto_todo_detection.enabled\",\n    }\n\n    if job_id in job_config_map:\n        config_key = job_config_map[job_id]\n        try:\n            # 使用 ConfigService 持久化配置到文件\n            config_service = ConfigService()\n            config_updates = {config_key: enabled}\n\n            # 如果存在联动配置，同步更新\n            if job_id in linked_config_map:\n                linked_key = linked_config_map[job_id]\n                config_updates[linked_key] = enabled\n                logger.info(f\"联动更新配置: {linked_key} = {enabled}\")\n\n            config_service.update_config_file(config_updates, config_service._config_path)\n            # 重新加载配置到内存\n            reload_settings()\n            logger.info(f\"已同步任务 {job_id} 的启用状态到配置文件: {enabled}\")\n        except Exception as e:\n            logger.error(f\"同步任务启用状态到配置失败: {e}\")\n\n\ndef _sync_job_interval_to_config(\n    job_id: str, seconds: int | None = None, minutes: int | None = None, hours: int | None = None\n):\n    \"\"\"同步任务的执行间隔到配置文件（持久化到 YAML 文件）\n\n    Args:\n        job_id: 任务ID\n        seconds: 秒数\n        minutes: 分钟数\n        hours: 小时数\n    \"\"\"\n    # 定义任务ID到配置路径的映射\n    job_config_map = {\n        \"recorder_job\": \"jobs.recorder.interval\",\n        \"ocr_job\": \"jobs.ocr.interval\",\n        \"clean_data_job\": \"jobs.clean_data.interval\",\n        \"activity_aggregator_job\": \"jobs.activity_aggregator.interval\",\n        \"todo_recorder_job\": \"jobs.todo_recorder.interval\",\n        \"proactive_ocr_job\": \"jobs.proactive_ocr.interval\",\n    }\n\n    if job_id in job_config_map:\n        config_key = job_config_map[job_id]\n        try:\n            # 计算总间隔秒数\n            total_seconds = 0\n            if seconds:\n                total_seconds += seconds\n            if minutes:\n                total_seconds += minutes * 60\n            if hours:\n                total_seconds += hours * 3600\n\n            # 使用 ConfigService 持久化配置到文件\n            config_service = ConfigService()\n            config_service.update_config_file(\n                {config_key: total_seconds}, config_service._config_path\n            )\n            # 重新加载配置到内存\n            reload_settings()\n            logger.info(f\"已同步任务 {job_id} 的执行间隔到配置文件: {total_seconds}秒\")\n        except Exception as e:\n            logger.error(f\"同步任务执行间隔到配置失败: {e}\")\n"
  },
  {
    "path": "lifetrace/routers/screenshot.py",
    "content": "\"\"\"截图相关路由\"\"\"\n\nimport os\nfrom datetime import datetime\n\nfrom fastapi import APIRouter, HTTPException, Query\nfrom fastapi.responses import FileResponse\n\nfrom lifetrace.schemas.screenshot import ScreenshotResponse\nfrom lifetrace.storage import get_session, screenshot_mgr\nfrom lifetrace.storage.models import OCRResult\nfrom lifetrace.util.logging_config import get_logger\n\nlogger = get_logger()\n\nrouter = APIRouter(prefix=\"/api/screenshots\", tags=[\"screenshot\"])\n\n\n@router.get(\"\", response_model=list[ScreenshotResponse])\nasync def get_screenshots(\n    limit: int = Query(50, ge=1, le=200),\n    offset: int = Query(0, ge=0),\n    start_date: str | None = Query(None),\n    end_date: str | None = Query(None),\n    app_name: str | None = Query(None),\n):\n    \"\"\"获取截图列表\"\"\"\n    try:\n        # 解析日期\n        start_dt = None\n        end_dt = None\n\n        if start_date:\n            start_dt = datetime.fromisoformat(start_date)\n        if end_date:\n            end_dt = datetime.fromisoformat(end_date)\n\n        # 搜索截图 - 直接传递offset和limit给数据库查询\n        results = screenshot_mgr.search_screenshots(\n            start_date=start_dt,\n            end_date=end_dt,\n            app_name=app_name,\n            limit=limit,\n            offset=offset,  # 新增offset参数\n        )\n\n        return [ScreenshotResponse(**result) for result in results]\n\n    except Exception as e:\n        logger.error(f\"获取截图列表失败: {e}\")\n        raise HTTPException(status_code=500, detail=str(e)) from e\n\n\n@router.get(\"/{screenshot_id}\")\nasync def get_screenshot(screenshot_id: int):\n    \"\"\"获取单个截图详情\"\"\"\n    screenshot = screenshot_mgr.get_screenshot_by_id(screenshot_id)\n\n    if not screenshot:\n        raise HTTPException(status_code=404, detail=\"截图不存在\")\n\n    # 获取OCR结果\n    ocr_data = None\n    try:\n        with get_session() as session:\n            ocr_result = session.query(OCRResult).filter_by(screenshot_id=screenshot_id).first()\n\n            # 在session内提取数据\n            if ocr_result:\n                ocr_data = {\n                    \"text_content\": ocr_result.text_content,\n                    \"confidence\": ocr_result.confidence,\n                    \"language\": ocr_result.language,\n                    \"processing_time\": ocr_result.processing_time,\n                }\n    except Exception as e:\n        logger.warning(f\"获取OCR结果失败: {e}\")\n\n    # screenshot已经是字典格式，直接使用\n    result = screenshot.copy()\n    result[\"ocr_result\"] = ocr_data\n\n    return result\n\n\n@router.get(\"/{screenshot_id}/image\")\nasync def get_screenshot_image(screenshot_id: int):\n    \"\"\"获取截图图片文件\"\"\"\n    try:\n        screenshot = screenshot_mgr.get_screenshot_by_id(screenshot_id)\n\n        if not screenshot:\n            raise HTTPException(status_code=404, detail=\"截图不存在\")\n\n        # 检查文件是否已被清理\n        if screenshot.get(\"file_deleted\", False):\n            logger.debug(f\"截图文件已被清理: screenshot_id={screenshot_id}\")\n            raise HTTPException(status_code=410, detail=\"文件已被清理\")\n\n        file_path = screenshot[\"file_path\"]\n\n        # 检查文件是否存在\n        if not os.path.exists(file_path):\n            logger.warning(f\"截图文件不存在: screenshot_id={screenshot_id}, path={file_path}\")\n            raise HTTPException(status_code=404, detail=\"图片文件不存在\")\n\n        return FileResponse(\n            file_path,\n            media_type=\"image/png\",\n            filename=f\"screenshot_{screenshot_id}.png\",\n        )\n\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"获取截图图像时发生错误: {e}\")\n        raise HTTPException(status_code=500, detail=\"服务器内部错误\") from e\n\n\n@router.get(\"/{screenshot_id}/path\")\nasync def get_screenshot_path(screenshot_id: int):\n    \"\"\"获取截图文件路径\"\"\"\n    screenshot = screenshot_mgr.get_screenshot_by_id(screenshot_id)\n\n    if not screenshot:\n        raise HTTPException(status_code=404, detail=\"截图不存在\")\n\n    file_path = screenshot[\"file_path\"]\n    if not os.path.exists(file_path):\n        raise HTTPException(status_code=404, detail=\"图片文件不存在\")\n\n    return {\"screenshot_id\": screenshot_id, \"file_path\": file_path, \"exists\": True}\n"
  },
  {
    "path": "lifetrace/routers/search.py",
    "content": "\"\"\"搜索相关路由\"\"\"\n\nfrom fastapi import APIRouter, HTTPException\n\nfrom lifetrace.schemas.event import EventResponse\nfrom lifetrace.schemas.screenshot import ScreenshotResponse\nfrom lifetrace.schemas.search import SearchRequest\nfrom lifetrace.storage import event_mgr, screenshot_mgr\nfrom lifetrace.util.logging_config import get_logger\n\nlogger = get_logger()\n\nrouter = APIRouter(prefix=\"/api\", tags=[\"search\"])\n\n\n@router.post(\"/search\", response_model=list[ScreenshotResponse])\nasync def search_screenshots(search_request: SearchRequest):\n    \"\"\"搜索截图\"\"\"\n    try:\n        results = screenshot_mgr.search_screenshots(\n            query=search_request.query,\n            start_date=search_request.start_date,\n            end_date=search_request.end_date,\n            app_name=search_request.app_name,\n            limit=search_request.limit,\n        )\n\n        return [ScreenshotResponse(**result) for result in results]\n\n    except Exception as e:\n        logger.error(f\"搜索截图失败: {e}\")\n        raise HTTPException(status_code=500, detail=str(e)) from e\n\n\n@router.post(\"/event-search\", response_model=list[EventResponse])\nasync def search_events(search_request: SearchRequest):\n    \"\"\"事件级简单文本搜索：按OCR分组后返回事件摘要\"\"\"\n    try:\n        results = event_mgr.search_events_simple(\n            query=search_request.query,\n            start_date=search_request.start_date,\n            end_date=search_request.end_date,\n            app_name=search_request.app_name,\n            limit=search_request.limit,\n        )\n        return [EventResponse(**r) for r in results]\n    except Exception as e:\n        logger.error(f\"搜索事件失败: {e}\")\n        raise HTTPException(status_code=500, detail=str(e)) from e\n"
  },
  {
    "path": "lifetrace/routers/system.py",
    "content": "\"\"\"系统资源相关路由\"\"\"\n\nimport psutil\nfrom fastapi import APIRouter, HTTPException, Query\n\nfrom lifetrace.core.module_registry import get_capabilities_report\nfrom lifetrace.schemas.stats import StatisticsResponse\nfrom lifetrace.schemas.system import (\n    CapabilitiesResponse,\n    ProcessInfo,\n    SystemResourcesResponse,\n)\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.path_utils import get_database_path, get_screenshots_dir\nfrom lifetrace.util.time_utils import get_utc_now\n\nlogger = get_logger()\n\nrouter = APIRouter(prefix=\"/api\", tags=[\"system\"])\n\n# LifeTrace 相关进程关键字\nLIFETRACE_KEYWORDS = [\n    \"lifetrace\",\n    \"lifetrace.recorder\",\n    \"lifetrace.processor\",\n    \"lifetrace.ocr\",\n    \"lifetrace.jobs.recorder\",\n    \"lifetrace.jobs.processor\",\n    \"lifetrace.jobs.ocr\",\n    \"recorder.py\",\n    \"processor.py\",\n    \"ocr.py\",\n    \"server.py\",\n    \"start_all_services.py\",\n]\n\n# 单位转换常量\nBYTES_PER_MB = 1024 * 1024\nBYTES_PER_GB = 1024**3\n\n\ndef _get_lifetrace_processes() -> tuple[list[ProcessInfo], float, float]:\n    \"\"\"获取 LifeTrace 相关进程信息\n\n    Returns:\n        tuple: (进程列表, 总内存MB, 总CPU百分比)\n    \"\"\"\n    processes = []\n    total_memory = 0.0\n    total_cpu = 0.0\n\n    for proc in psutil.process_iter([\"pid\", \"name\", \"cmdline\", \"memory_info\"]):\n        try:\n            cmdline = \" \".join(proc.info[\"cmdline\"]) if proc.info[\"cmdline\"] else \"\"\n\n            if any(keyword in cmdline.lower() for keyword in LIFETRACE_KEYWORDS):\n                try:\n                    cpu_percent = proc.cpu_percent(interval=None)\n                except Exception:\n                    cpu_percent = 0.0\n\n                memory_mb = proc.info[\"memory_info\"].rss / BYTES_PER_MB\n                memory_vms_mb = proc.info[\"memory_info\"].vms / BYTES_PER_MB\n\n                process_info = ProcessInfo(\n                    pid=proc.info[\"pid\"],\n                    name=proc.info[\"name\"],\n                    cmdline=cmdline,\n                    memory_mb=memory_mb,\n                    memory_vms_mb=memory_vms_mb,\n                    cpu_percent=cpu_percent,\n                )\n                processes.append(process_info)\n                total_memory += memory_mb\n                total_cpu += cpu_percent\n\n        except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):\n            continue\n\n    return processes, total_memory, total_cpu\n\n\ndef _get_disk_usage() -> dict:\n    \"\"\"获取磁盘使用信息\"\"\"\n    disk_usage = {}\n    for partition in psutil.disk_partitions():\n        try:\n            usage = psutil.disk_usage(partition.mountpoint)\n            disk_usage[partition.device] = {\n                \"total_gb\": usage.total / BYTES_PER_GB,\n                \"used_gb\": usage.used / BYTES_PER_GB,\n                \"free_gb\": usage.free / BYTES_PER_GB,\n                \"percent\": (usage.used / usage.total) * 100,\n            }\n        except PermissionError:\n            continue\n    return disk_usage\n\n\ndef _get_storage_info() -> dict:\n    \"\"\"获取数据库和截图存储信息\"\"\"\n    db_path = get_database_path()\n    db_size_mb = db_path.stat().st_size / BYTES_PER_MB if db_path.exists() else 0\n\n    screenshots_path = get_screenshots_dir()\n    screenshots_size_mb = 0.0\n    screenshots_count = 0\n\n    if screenshots_path.exists():\n        for file_path in screenshots_path.glob(\"*.png\"):\n            if file_path.is_file():\n                screenshots_size_mb += file_path.stat().st_size / BYTES_PER_MB\n                screenshots_count += 1\n\n    return {\n        \"database_mb\": db_size_mb,\n        \"screenshots_mb\": screenshots_size_mb,\n        \"screenshots_count\": screenshots_count,\n        \"total_mb\": db_size_mb + screenshots_size_mb,\n    }\n\n\n@router.get(\"/statistics\", response_model=StatisticsResponse)\nasync def get_statistics():\n    \"\"\"获取系统统计信息\"\"\"\n    from lifetrace.storage import stats_mgr  # noqa: PLC0415\n\n    stats = stats_mgr.get_statistics()\n    return StatisticsResponse(**stats)\n\n\n@router.post(\"/cleanup\")\nasync def cleanup_old_data(days: int = Query(30, ge=1)):\n    \"\"\"清理旧数据\"\"\"\n    try:\n        from lifetrace.storage import stats_mgr  # noqa: PLC0415\n\n        stats_mgr.cleanup_old_data(days)\n        return {\"success\": True, \"message\": f\"清理了 {days} 天前的数据\"}\n    except Exception as e:\n        logger.error(f\"清理数据失败: {e}\")\n        raise HTTPException(status_code=500, detail=str(e)) from e\n\n\n@router.get(\"/system-resources\", response_model=SystemResourcesResponse)\nasync def get_system_resources():\n    \"\"\"获取系统资源使用情况\"\"\"\n    try:\n        # 获取 LifeTrace 相关进程\n        lifetrace_processes, total_memory, total_cpu = _get_lifetrace_processes()\n\n        # 获取系统资源信息\n        memory = psutil.virtual_memory()\n        cpu_percent = psutil.cpu_percent(interval=None)\n        cpu_count = psutil.cpu_count()\n\n        # 获取磁盘和存储信息\n        disk_usage = _get_disk_usage()\n        storage_info = _get_storage_info()\n\n        return SystemResourcesResponse(\n            memory={\n                \"total_gb\": memory.total / BYTES_PER_GB,\n                \"available_gb\": memory.available / BYTES_PER_GB,\n                \"used_gb\": (memory.total - memory.available) / BYTES_PER_GB,\n                \"percent\": memory.percent,\n            },\n            cpu={\"percent\": cpu_percent, \"count\": cpu_count},\n            disk=disk_usage,\n            lifetrace_processes=lifetrace_processes,\n            storage=storage_info,\n            summary={\n                \"total_memory_mb\": total_memory,\n                \"total_cpu_percent\": total_cpu,\n                \"process_count\": len(lifetrace_processes),\n                \"total_storage_mb\": storage_info[\"total_mb\"],\n            },\n            timestamp=get_utc_now(),\n        )\n\n    except Exception as e:\n        logger.error(f\"获取系统资源信息失败: {e}\")\n        raise HTTPException(status_code=500, detail=str(e)) from e\n\n\n@router.get(\"/capabilities\", response_model=CapabilitiesResponse)\nasync def get_capabilities():\n    \"\"\"获取后端模块能力状态\"\"\"\n    return get_capabilities_report()\n"
  },
  {
    "path": "lifetrace/routers/time_allocation.py",
    "content": "\"\"\"时间分配相关路由\"\"\"\n\nfrom datetime import UTC, datetime\n\nfrom fastapi import APIRouter, HTTPException, Query\n\nfrom lifetrace.schemas.stats import TimeAllocationResponse\nfrom lifetrace.storage import event_mgr\nfrom lifetrace.util.logging_config import get_logger\n\nlogger = get_logger()\n\nrouter = APIRouter(prefix=\"/api\", tags=[\"time-allocation\"])\n\n# 应用分类关键词映射\n_APP_CATEGORY_KEYWORDS: dict[str, list[str]] = {\n    \"social\": [\n        \"qq\",\n        \"wechat\",\n        \"weixin\",\n        \"微信\",\n        \"telegram\",\n        \"discord\",\n        \"slack\",\n        \"dingtalk\",\n        \"钉钉\",\n        \"wxwork\",\n        \"企业微信\",\n        \"feishu\",\n        \"飞书\",\n        \"lark\",\n        \"whatsapp\",\n        \"line\",\n        \"skype\",\n        \"zoom\",\n        \"teams\",\n        \"腾讯会议\",\n    ],\n    \"browser\": [\n        \"chrome\",\n        \"msedge\",\n        \"edge\",\n        \"firefox\",\n        \"browser\",\n        \"浏览器\",\n        \"safari\",\n        \"opera\",\n        \"brave\",\n    ],\n    \"development\": [\n        \"code\",\n        \"vscode\",\n        \"visual studio code\",\n        \"pycharm\",\n        \"idea\",\n        \"intellij\",\n        \"webstorm\",\n        \"editor\",\n        \"开发工具\",\n        \"sublime\",\n        \"atom\",\n        \"vim\",\n        \"neovim\",\n        \"github desktop\",\n        \"git\",\n        \"github\",\n        \"gitkraken\",\n        \"sourcetree\",\n    ],\n    \"file_management\": [\n        \"explorer\",\n        \"文件\",\n        \"file\",\n        \"finder\",\n        \"nautilus\",\n        \"dolphin\",\n        \"thunar\",\n    ],\n    \"office\": [\n        \"word\",\n        \"excel\",\n        \"powerpoint\",\n        \"wps\",\n        \"libreoffice\",\n        \"office\",\n        \"onenote\",\n        \"outlook\",\n    ],\n}\n\n\ndef _categorize_app(app_name: str) -> str:\n    \"\"\"应用分类逻辑（优先匹配社交类应用）\"\"\"\n    if not app_name:\n        return \"other\"\n\n    app_lower = app_name.lower().strip()\n\n    # 按优先级顺序检查各分类\n    for category, keywords in _APP_CATEGORY_KEYWORDS.items():\n        if any(keyword in app_lower for keyword in keywords):\n            return category\n\n    return \"other\"\n\n\ndef _build_daily_distribution(hourly_usage: dict[int, dict[str, float]]) -> list[dict]:\n    \"\"\"构建24小时分布数据\"\"\"\n    daily_distribution = []\n    for hour in range(24):\n        hour_data: dict = {\"hour\": hour, \"apps\": {}}\n        if hour in hourly_usage:\n            for app_name, duration in hourly_usage[hour].items():\n                hour_data[\"apps\"][app_name] = int(duration)\n        daily_distribution.append(hour_data)\n    return daily_distribution\n\n\ndef _build_app_details(app_usage_summary: dict[str, dict]) -> list[dict]:\n    \"\"\"构建应用详情列表\"\"\"\n    app_details = [\n        {\n            \"app_name\": app_name,\n            \"total_time\": int(app_data.get(\"total_time\", 0)),\n            \"category\": _categorize_app(app_name),\n        }\n        for app_name, app_data in app_usage_summary.items()\n    ]\n    app_details.sort(key=lambda x: x[\"total_time\"], reverse=True)\n    return app_details\n\n\n@router.get(\"/time-allocation\", response_model=TimeAllocationResponse)\nasync def get_time_allocation(\n    start_date: str | None = Query(None, description=\"开始日期, YYYY-MM-DD 格式\"),\n    end_date: str | None = Query(None, description=\"结束日期, YYYY-MM-DD 格式\"),\n    days: int = Query(None, description=\"统计天数 (弃用, 仅用于兼容)\", ge=1, le=365),\n):\n    \"\"\"获取时间分配数据（支持日期区间或天数）\"\"\"\n    try:\n        if start_date and end_date:\n            start_dt = datetime.strptime(start_date, \"%Y-%m-%d\").replace(tzinfo=UTC)\n            end_dt = datetime.strptime(end_date, \"%Y-%m-%d\").replace(tzinfo=UTC)\n            stats_data = event_mgr.get_app_usage_stats(start_date=start_dt, end_date=end_dt)\n        else:\n            use_days = days if days else 7\n            stats_data = event_mgr.get_app_usage_stats(days=use_days)\n\n        total_time = int(stats_data.get(\"total_time\", 0))\n        daily_distribution = _build_daily_distribution(stats_data.get(\"hourly_usage\", {}))\n        app_details = _build_app_details(stats_data.get(\"app_usage_summary\", {}))\n\n        return TimeAllocationResponse(\n            total_time=total_time, daily_distribution=daily_distribution, app_details=app_details\n        )\n\n    except Exception as e:\n        logger.error(f\"获取时间分配数据失败: {e}\")\n        raise HTTPException(status_code=500, detail=f\"获取时间分配数据失败: {e!s}\") from e\n"
  },
  {
    "path": "lifetrace/routers/todo.py",
    "content": "\"\"\"Todo 管理路由 - 使用依赖注入\"\"\"\n\nfrom __future__ import annotations\n\nimport hashlib\nimport os\nfrom pathlib import Path as FsPath\nfrom typing import TYPE_CHECKING\nfrom uuid import uuid4\n\nfrom fastapi import APIRouter, Depends, File, HTTPException, Path, Query, Response, UploadFile\nfrom fastapi.responses import FileResponse\n\nfrom lifetrace.core.dependencies import get_todo_service\nfrom lifetrace.schemas.todo import (\n    TodoAttachmentResponse,\n    TodoCreate,\n    TodoListResponse,\n    TodoReorderRequest,\n    TodoResponse,\n    TodoUpdate,\n)\nfrom lifetrace.services.icalendar_service import ICalendarService\nfrom lifetrace.util.path_utils import get_attachments_dir\n\nif TYPE_CHECKING:\n    from lifetrace.services.todo_service import TodoService\n\nrouter = APIRouter(prefix=\"/api/todos\", tags=[\"todos\"])\nMAX_ATTACHMENT_SIZE = 50 * 1024 * 1024  # 50MB\n\n\ndef _sanitize_filename(name: str) -> str:\n    return FsPath(name).name if name else \"attachment\"\n\n\n@router.get(\"\", response_model=TodoListResponse)\nasync def list_todos(\n    limit: int = Query(200, ge=1, le=2000, description=\"返回数量限制\"),\n    offset: int = Query(0, ge=0, description=\"偏移量\"),\n    status: str | None = Query(None, description=\"状态筛选：active/completed/canceled\"),\n    service: TodoService = Depends(get_todo_service),\n):\n    \"\"\"获取待办列表\"\"\"\n    return service.list_todos(limit, offset, status)\n\n\n@router.get(\"/{todo_id}\", response_model=TodoResponse)\nasync def get_todo(\n    todo_id: int = Path(..., description=\"Todo ID\"),\n    service: TodoService = Depends(get_todo_service),\n):\n    \"\"\"获取单个待办\"\"\"\n    return service.get_todo(todo_id)\n\n\n@router.post(\n    \"/{todo_id}/attachments\",\n    response_model=list[TodoAttachmentResponse],\n    status_code=201,\n)\nasync def upload_attachments(\n    todo_id: int = Path(..., description=\"Todo ID\"),\n    files: list[UploadFile] = File(..., description=\"附件列表\"),\n    service: TodoService = Depends(get_todo_service),\n):\n    \"\"\"上传附件并绑定到 Todo\"\"\"\n    if not files:\n        raise HTTPException(status_code=400, detail=\"未提供附件\")\n\n    attachments_dir = get_attachments_dir()\n    attachments_dir.mkdir(parents=True, exist_ok=True)\n\n    created = []\n    for file in files:\n        if not file.filename:\n            continue\n\n        content = await file.read()\n        if not content:\n            raise HTTPException(status_code=400, detail=\"附件内容为空\")\n\n        size = len(content)\n        if size > MAX_ATTACHMENT_SIZE:\n            raise HTTPException(status_code=413, detail=\"附件超过 50MB 限制\")\n\n        file_name = _sanitize_filename(file.filename)\n        ext = FsPath(file_name).suffix\n        storage_name = f\"{uuid4().hex}{ext}\"\n        target_path = attachments_dir / storage_name\n        target_path.write_bytes(content)\n\n        file_hash = hashlib.sha256(content).hexdigest()\n        created.append(\n            service.add_attachment(\n                todo_id=todo_id,\n                file_name=file_name,\n                file_path=str(target_path),\n                file_size=size,\n                mime_type=file.content_type,\n                file_hash=file_hash,\n            )\n        )\n\n    return created\n\n\n@router.delete(\"/{todo_id}/attachments/{attachment_id}\", status_code=204)\nasync def delete_attachment(\n    todo_id: int = Path(..., description=\"Todo ID\"),\n    attachment_id: int = Path(..., description=\"附件 ID\"),\n    service: TodoService = Depends(get_todo_service),\n):\n    \"\"\"解绑附件（不删除实际文件）\"\"\"\n    service.remove_attachment(todo_id=todo_id, attachment_id=attachment_id)\n\n\n@router.get(\"/attachments/{attachment_id}/file\")\nasync def get_attachment_file(\n    attachment_id: int = Path(..., description=\"附件 ID\"),\n    service: TodoService = Depends(get_todo_service),\n):\n    \"\"\"下载附件文件\"\"\"\n    attachment = service.get_attachment(attachment_id)\n    file_path = attachment[\"file_path\"]\n    if not os.path.exists(file_path):\n        raise HTTPException(status_code=404, detail=\"附件文件不存在\")\n\n    return FileResponse(\n        file_path,\n        media_type=attachment.get(\"mime_type\") or \"application/octet-stream\",\n        filename=attachment.get(\"file_name\") or f\"attachment-{attachment_id}\",\n    )\n\n\n@router.post(\"\", response_model=TodoResponse, status_code=201)\nasync def create_todo(\n    todo: TodoCreate,\n    service: TodoService = Depends(get_todo_service),\n):\n    \"\"\"创建待办\"\"\"\n    return service.create_todo(todo)\n\n\n@router.put(\"/{todo_id}\", response_model=TodoResponse)\nasync def update_todo(\n    todo_id: int = Path(..., description=\"Todo ID\"),\n    todo: TodoUpdate | None = None,\n    service: TodoService = Depends(get_todo_service),\n):\n    \"\"\"更新待办\"\"\"\n    if todo is None:\n        raise HTTPException(status_code=400, detail=\"缺少待办更新内容\")\n    return service.update_todo(todo_id, todo)\n\n\n@router.delete(\"/{todo_id}\", status_code=204)\nasync def delete_todo(\n    todo_id: int = Path(..., description=\"Todo ID\"),\n    service: TodoService = Depends(get_todo_service),\n):\n    \"\"\"删除待办\"\"\"\n    service.delete_todo(todo_id)\n\n\n@router.post(\"/reorder\", status_code=200)\nasync def reorder_todos(\n    request: TodoReorderRequest,\n    service: TodoService = Depends(get_todo_service),\n):\n    \"\"\"批量更新待办的排序和父子关系\"\"\"\n    items = [\n        {\n            \"id\": item.id,\n            \"order\": item.order,\n            **({\"parent_todo_id\": item.parent_todo_id} if item.parent_todo_id is not None else {}),\n        }\n        for item in request.items\n    ]\n    return service.reorder_todos(items)\n\n\n@router.get(\"/export/ics\")\nasync def export_ics(\n    limit: int = Query(2000, ge=1, le=2000, description=\"导出数量限制\"),\n    offset: int = Query(0, ge=0, description=\"导出偏移量\"),\n    status: str | None = Query(None, description=\"状态筛选：active/completed/canceled\"),\n    service: TodoService = Depends(get_todo_service),\n):\n    \"\"\"导出 Todo 为 ICS 文件\"\"\"\n    payload = service.list_todos(limit, offset, status)\n    todos = [t.model_dump() if hasattr(t, \"model_dump\") else t for t in payload.get(\"todos\", [])]\n    ics_content = ICalendarService().export_todos(todos)\n    filename = \"lifetrace-todos.ics\" if not status else f\"lifetrace-todos-{status}.ics\"\n    return Response(\n        content=ics_content,\n        media_type=\"text/calendar; charset=utf-8\",\n        headers={\"Content-Disposition\": f'attachment; filename=\"{filename}\"'},\n    )\n\n\n@router.post(\"/import/ics\", response_model=list[TodoResponse])\nasync def import_ics(\n    file: UploadFile = File(...),\n    service: TodoService = Depends(get_todo_service),\n):\n    \"\"\"从 ICS 文件导入 Todo\"\"\"\n    if not file.filename:\n        raise HTTPException(status_code=400, detail=\"未提供 ICS 文件\")\n\n    content = await file.read()\n    if not content:\n        raise HTTPException(status_code=400, detail=\"ICS 文件为空\")\n\n    try:\n        ics_text = content.decode(\"utf-8\")\n    except UnicodeDecodeError:\n        ics_text = content.decode(\"utf-8\", errors=\"ignore\")\n\n    todos = ICalendarService().import_todos(ics_text)\n    created: list[TodoResponse] = []\n    seen_uids: set[str] = set()\n    for todo in todos:\n        uid = (todo.uid or \"\").strip()\n        if uid:\n            if uid in seen_uids:\n                continue\n            seen_uids.add(uid)\n            if service.get_todo_by_uid(uid):\n                continue\n        created.append(service.create_todo(todo))\n    return created\n"
  },
  {
    "path": "lifetrace/routers/todo_extraction.py",
    "content": "\"\"\"待办提取相关路由\"\"\"\n\nfrom fastapi import APIRouter, HTTPException\n\nfrom lifetrace.llm.todo_extraction_service import todo_extraction_service\nfrom lifetrace.schemas.todo_extraction import (\n    ExtractedTodo,\n    TodoExtractionRequest,\n    TodoExtractionResponse,\n    TodoTimeInfo,\n)\nfrom lifetrace.storage import event_mgr\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.time_utils import get_utc_now\n\nlogger = get_logger()\n\nrouter = APIRouter(prefix=\"/api/todo-extraction\", tags=[\"todo-extraction\"])\n\n\n@router.post(\"/extract\", response_model=TodoExtractionResponse)\nasync def extract_todos_from_event(request: TodoExtractionRequest):\n    \"\"\"\n    从事件中提取待办事项\n\n    针对白名单应用（微信、飞书等）的事件，使用多模态大模型分析截图，\n    提取用户承诺的待办事项，特别是带时间信息的待办。\n\n    Args:\n        request: 待办提取请求，包含事件ID和可选的截图采样比例\n\n    Returns:\n        待办提取响应，包含提取的待办列表和元信息\n\n    Raises:\n        HTTPException: 当请求参数无效或提取失败时\n    \"\"\"\n    try:\n        event_id = request.event_id\n\n        # 验证事件是否存在\n        event_info = event_mgr.get_event_summary(event_id)\n        if not event_info:\n            raise HTTPException(status_code=404, detail=f\"事件 {event_id} 不存在\")\n\n        app_name = event_info.get(\"app_name\")\n        logger.info(f\"开始提取事件 {event_id} 的待办事项，应用: {app_name}\")\n\n        # 调用待办提取服务\n        result = todo_extraction_service.extract_todos_from_event(\n            event_id=event_id,\n            screenshot_sample_ratio=request.screenshot_sample_ratio,\n        )\n\n        # 检查是否有错误\n        if result.get(\"error_message\"):\n            error_msg = result[\"error_message\"]\n            # 如果是白名单检查失败，返回400；其他错误返回500\n            if \"不在待办提取白名单中\" in error_msg:\n                raise HTTPException(status_code=400, detail=error_msg)\n            else:\n                logger.warning(f\"待办提取返回错误: {error_msg}\")\n                # 仍然返回结果，但包含错误信息\n\n        # 构建响应\n        todos = []\n        for todo_dict in result.get(\"todos\", []):\n            try:\n                # 构建时间信息\n                time_info_dict = todo_dict.get(\"time_info\", {})\n                time_info = TodoTimeInfo(**time_info_dict)\n\n                # 构建待办项\n                todo = ExtractedTodo(\n                    title=todo_dict.get(\"title\", \"\"),\n                    description=todo_dict.get(\"description\"),\n                    time_info=time_info,\n                    scheduled_time=todo_dict.get(\"scheduled_time\"),\n                    source_text=todo_dict.get(\"source_text\", \"\"),\n                    confidence=todo_dict.get(\"confidence\"),\n                    screenshot_ids=todo_dict.get(\"screenshot_ids\", []),\n                )\n                todos.append(todo)\n            except Exception as e:\n                logger.warning(f\"构建待办项失败，跳过: {e}\")\n\n        response = TodoExtractionResponse(\n            event_id=event_id,\n            app_name=result.get(\"app_name\"),\n            window_title=result.get(\"window_title\"),\n            event_start_time=result.get(\"event_start_time\"),\n            event_end_time=result.get(\"event_end_time\"),\n            todos=todos,\n            extraction_timestamp=get_utc_now(),\n            screenshot_count=result.get(\"screenshot_count\", 0),\n            error_message=result.get(\"error_message\"),\n        )\n\n        logger.info(f\"待办提取完成: 事件 {event_id}, 提取到 {len(todos)} 个待办事项\")\n\n        return response\n\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"提取待办事项失败: {e}\", exc_info=True)\n        raise HTTPException(\n            status_code=500,\n            detail=f\"提取待办事项时发生错误: {e!s}\",\n        ) from e\n"
  },
  {
    "path": "lifetrace/routers/vector.py",
    "content": "\"\"\"向量数据库相关路由\"\"\"\n\nfrom fastapi import APIRouter, HTTPException, Query\n\nfrom lifetrace.core.dependencies import get_vector_service\nfrom lifetrace.schemas.event import EventResponse\nfrom lifetrace.schemas.vector import (\n    SemanticSearchRequest,\n    SemanticSearchResult,\n    VectorStatsResponse,\n)\nfrom lifetrace.storage import event_mgr\nfrom lifetrace.util.logging_config import get_logger\n\nlogger = get_logger()\n\nrouter = APIRouter(prefix=\"/api\", tags=[\"vector\"])\n\n\n@router.post(\"/semantic-search\", response_model=list[SemanticSearchResult])\nasync def semantic_search(request: SemanticSearchRequest):\n    \"\"\"语义搜索 OCR 结果\"\"\"\n    try:\n        vector_service = get_vector_service()\n        if not vector_service.is_enabled():\n            raise HTTPException(status_code=503, detail=\"向量数据库服务不可用\")\n\n        results = vector_service.semantic_search(\n            query=request.query,\n            top_k=request.top_k,\n            use_rerank=request.use_rerank,\n            retrieve_k=request.retrieve_k,\n            filters=request.filters,\n        )\n\n        # 转换为响应格式\n        search_results = []\n        for result in results:\n            search_result = SemanticSearchResult(\n                text=result.get(\"text\", \"\"),\n                score=result.get(\"score\", 0.0),\n                metadata=result.get(\"metadata\", {}),\n                ocr_result=result.get(\"ocr_result\"),\n                screenshot=result.get(\"screenshot\"),\n            )\n            search_results.append(search_result)\n\n        return search_results\n\n    except Exception as e:\n        logger.error(f\"语义搜索失败: {e}\")\n        raise HTTPException(status_code=500, detail=str(e)) from e\n\n\n@router.post(\"/event-semantic-search\", response_model=list[EventResponse])\nasync def event_semantic_search(request: SemanticSearchRequest):\n    \"\"\"事件级语义搜索（基于事件聚合文本）\"\"\"\n    try:\n        vector_service = get_vector_service()\n        if not vector_service.is_enabled():\n            raise HTTPException(status_code=503, detail=\"向量数据库服务不可用\")\n        raw_results = vector_service.semantic_search_events(\n            query=request.query, top_k=request.top_k\n        )\n\n        # semantic_search_events 现在直接返回格式化的事件数据\n        events_resp: list[EventResponse] = []\n        for event_data in raw_results:\n            # 检查是否已经是完整的事件数据格式\n            if \"id\" in event_data and \"app_name\" in event_data:\n                # 直接使用返回的事件数据\n                events_resp.append(EventResponse(**event_data))\n            else:\n                # 向后兼容：如果是旧格式，使用原来的逻辑\n                metadata = event_data.get(\"metadata\", {})\n                event_id = metadata.get(\"event_id\")\n                if not event_id:\n                    continue\n                matched = event_mgr.get_event_summary(int(event_id))\n                if matched:\n                    events_resp.append(EventResponse(**matched))\n\n        return events_resp\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"事件语义搜索失败: {e}\")\n        raise HTTPException(status_code=500, detail=str(e)) from e\n\n\n@router.get(\"/vector-stats\", response_model=VectorStatsResponse)\nasync def get_vector_stats():\n    \"\"\"获取向量数据库统计信息\"\"\"\n    try:\n        stats = get_vector_service().get_stats()\n        return VectorStatsResponse(**stats)\n\n    except Exception as e:\n        logger.error(f\"获取向量数据库统计信息失败: {e}\")\n        raise HTTPException(status_code=500, detail=str(e)) from e\n\n\n@router.post(\"/vector-sync\")\nasync def sync_vector_database(\n    limit: int | None = Query(None, description=\"同步的最大记录数\"),\n    force_reset: bool = Query(False, description=\"是否强制重置向量数据库\"),\n):\n    \"\"\"同步 SQLite 数据库到向量数据库\"\"\"\n    try:\n        vector_service = get_vector_service()\n        if not vector_service.is_enabled():\n            raise HTTPException(status_code=503, detail=\"向量数据库服务不可用\")\n\n        synced_count = vector_service.sync_from_database(limit=limit, force_reset=force_reset)\n\n        return {\"message\": \"同步完成\", \"synced_count\": synced_count}\n\n    except Exception as e:\n        logger.error(f\"向量数据库同步失败: {e}\")\n        raise HTTPException(status_code=500, detail=str(e)) from e\n\n\n@router.post(\"/vector-reset\")\nasync def reset_vector_database():\n    \"\"\"重置向量数据库\"\"\"\n    try:\n        vector_service = get_vector_service()\n        if not vector_service.is_enabled():\n            raise HTTPException(status_code=503, detail=\"向量数据库服务不可用\")\n\n        success = vector_service.reset()\n\n        if success:\n            return {\"message\": \"向量数据库重置成功\"}\n        else:\n            raise HTTPException(status_code=500, detail=\"向量数据库重置失败\")\n\n    except Exception as e:\n        logger.error(f\"向量数据库重置失败: {e}\")\n        raise HTTPException(status_code=500, detail=str(e)) from e\n"
  },
  {
    "path": "lifetrace/routers/vision.py",
    "content": "\"\"\"视觉多模态相关路由\"\"\"\n\nfrom fastapi import APIRouter, HTTPException\n\nfrom lifetrace.core.dependencies import get_rag_service\nfrom lifetrace.schemas.vision import VisionChatRequest, VisionChatResponse\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.time_utils import get_utc_now\n\nlogger = get_logger()\n\n# 常量定义\nMAX_SCREENSHOTS_PER_REQUEST = 20  # 一次请求最多处理的截图数量\n\nrouter = APIRouter(prefix=\"/api/vision\", tags=[\"vision\"])\n\n\n@router.post(\"/chat\", response_model=VisionChatResponse)\nasync def vision_chat(request: VisionChatRequest):\n    \"\"\"\n    视觉多模态聊天接口\n\n    使用通义千问视觉模型分析多张截图，支持文本提示词。\n\n    Args:\n        request: 视觉聊天请求，包含截图ID列表和提示词\n\n    Returns:\n        视觉聊天响应，包含模型生成的文本和元信息\n\n    Raises:\n        HTTPException: 当请求参数无效或API调用失败时\n    \"\"\"\n    try:\n        # 验证截图ID列表\n        if not request.screenshot_ids:\n            raise HTTPException(\n                status_code=400, detail=\"截图ID列表不能为空，至少需要提供一个截图ID\"\n            )\n\n        if len(request.screenshot_ids) > MAX_SCREENSHOTS_PER_REQUEST:\n            raise HTTPException(\n                status_code=400,\n                detail=f\"一次最多只能处理{MAX_SCREENSHOTS_PER_REQUEST}张截图\",\n            )\n\n        logger.info(\n            f\"收到视觉多模态请求: {len(request.screenshot_ids)} 张截图, prompt长度: {len(request.prompt)}\"\n        )\n\n        # 检查LLM客户端是否可用\n        rag_service = get_rag_service()\n        if not rag_service.llm_client.is_available():\n            raise HTTPException(\n                status_code=503,\n                detail=\"LLM服务当前不可用，请检查配置或稍后重试\",\n            )\n\n        # 调用视觉模型\n        result = rag_service.llm_client.vision_chat(\n            screenshot_ids=request.screenshot_ids,\n            prompt=request.prompt,\n            model=request.model,\n            temperature=request.temperature,\n            max_tokens=request.max_tokens,\n        )\n\n        # 构建响应\n        response = VisionChatResponse(\n            response=result[\"response\"],\n            timestamp=get_utc_now(),\n            usage_info=result.get(\"usage_info\"),\n            model=result.get(\"model\"),\n            screenshot_count=result[\"screenshot_count\"],\n        )\n\n        logger.info(f\"视觉多模态分析完成: 处理了 {result['screenshot_count']} 张截图\")\n\n        return response\n\n    except HTTPException:\n        raise\n    except ValueError as e:\n        logger.error(f\"视觉多模态请求参数错误: {e}\")\n        raise HTTPException(status_code=400, detail=str(e)) from e\n    except RuntimeError as e:\n        logger.error(f\"视觉多模态服务不可用: {e}\")\n        raise HTTPException(status_code=503, detail=str(e)) from e\n    except Exception as e:\n        logger.error(f\"视觉多模态分析失败: {e}\", exc_info=True)\n        raise HTTPException(\n            status_code=500,\n            detail=f\"处理视觉多模态请求时发生错误: {e!s}\",\n        ) from e\n"
  },
  {
    "path": "lifetrace/schemas/__init__.py",
    "content": "\"\"\"Pydantic 模型定义\"\"\"\n\nfrom lifetrace.schemas.chat import (\n    ChatMessage,\n    ChatMessageWithContext,\n    ChatResponse,\n    NewChatRequest,\n    NewChatResponse,\n)\nfrom lifetrace.schemas.event import EventDetailResponse, EventResponse\nfrom lifetrace.schemas.screenshot import ScreenshotResponse\nfrom lifetrace.schemas.search import SearchRequest\nfrom lifetrace.schemas.stats import (\n    StatisticsResponse,\n    TimeAllocationResponse,\n)\nfrom lifetrace.schemas.system import ProcessInfo, SystemResourcesResponse\nfrom lifetrace.schemas.todo_extraction import (\n    ExtractedTodo,\n    TodoExtractionRequest,\n    TodoExtractionResponse,\n    TodoTimeInfo,\n)\nfrom lifetrace.schemas.vector import (\n    SemanticSearchRequest,\n    SemanticSearchResult,\n    VectorStatsResponse,\n)\nfrom lifetrace.schemas.vision import VisionChatRequest, VisionChatResponse\n\n__all__ = [\n    \"ChatMessage\",\n    \"ChatMessageWithContext\",\n    \"ChatResponse\",\n    \"EventDetailResponse\",\n    \"EventResponse\",\n    \"ExtractedTodo\",\n    \"NewChatRequest\",\n    \"NewChatResponse\",\n    \"ProcessInfo\",\n    \"ScreenshotResponse\",\n    \"SearchRequest\",\n    \"SemanticSearchRequest\",\n    \"SemanticSearchResult\",\n    \"StatisticsResponse\",\n    \"SystemResourcesResponse\",\n    \"TimeAllocationResponse\",\n    \"TodoExtractionRequest\",\n    \"TodoExtractionResponse\",\n    \"TodoTimeInfo\",\n    \"VectorStatsResponse\",\n    \"VisionChatRequest\",\n    \"VisionChatResponse\",\n]\n"
  },
  {
    "path": "lifetrace/schemas/activity.py",
    "content": "from datetime import datetime\n\nfrom pydantic import BaseModel\n\n\nclass ActivityResponse(BaseModel):\n    id: int\n    start_time: datetime\n    end_time: datetime\n    ai_title: str | None = None\n    ai_summary: str | None = None\n    event_count: int\n    created_at: datetime | None = None\n    updated_at: datetime | None = None\n\n\nclass ActivityListResponse(BaseModel):\n    activities: list[ActivityResponse]\n    total_count: int\n\n\nclass ActivityEventsResponse(BaseModel):\n    event_ids: list[int]\n\n\nclass ManualActivityCreateRequest(BaseModel):\n    event_ids: list[int]\n\n\nclass ManualActivityCreateResponse(BaseModel):\n    id: int\n    start_time: datetime\n    end_time: datetime\n    ai_title: str | None = None\n    ai_summary: str | None = None\n    event_count: int\n    created_at: datetime | None = None\n"
  },
  {
    "path": "lifetrace/schemas/automation.py",
    "content": "\"\"\"自动化任务相关模型\"\"\"\n\nfrom __future__ import annotations\n\nfrom typing import TYPE_CHECKING, Any\n\nfrom pydantic import BaseModel, Field\n\n\nclass AutomationSchedule(BaseModel):\n    \"\"\"任务调度配置\"\"\"\n\n    type: str = Field(..., description=\"调度类型: interval/cron/once\")\n    interval_seconds: int | None = Field(None, description=\"间隔秒数\")\n    cron: str | None = Field(None, description=\"Cron 表达式 (分钟 小时 日 月 周)\")\n    run_at: datetime | None = Field(None, description=\"一次性执行时间\")\n    timezone: str | None = Field(None, description=\"时区\")\n\n\nclass AutomationAction(BaseModel):\n    \"\"\"任务动作配置\"\"\"\n\n    type: str = Field(..., description=\"动作类型，例如 web_fetch\")\n    payload: dict[str, Any] = Field(default_factory=dict, description=\"动作参数\")\n\n\nclass AutomationTaskCreate(BaseModel):\n    \"\"\"创建自动化任务请求\"\"\"\n\n    name: str = Field(..., min_length=1, max_length=200, description=\"任务名称\")\n    description: str | None = Field(None, description=\"任务描述\")\n    enabled: bool = Field(True, description=\"是否启用\")\n    schedule: AutomationSchedule = Field(..., description=\"调度配置\")\n    action: AutomationAction = Field(..., description=\"动作配置\")\n\n\nclass AutomationTaskUpdate(BaseModel):\n    \"\"\"更新自动化任务请求\"\"\"\n\n    name: str | None = Field(None, min_length=1, max_length=200, description=\"任务名称\")\n    description: str | None = Field(None, description=\"任务描述\")\n    enabled: bool | None = Field(None, description=\"是否启用\")\n    schedule: AutomationSchedule | None = Field(None, description=\"调度配置\")\n    action: AutomationAction | None = Field(None, description=\"动作配置\")\n\n\nclass AutomationTaskResponse(BaseModel):\n    \"\"\"自动化任务响应\"\"\"\n\n    id: int = Field(..., description=\"任务ID\")\n    name: str = Field(..., description=\"任务名称\")\n    description: str | None = Field(None, description=\"任务描述\")\n    enabled: bool = Field(..., description=\"是否启用\")\n    schedule: AutomationSchedule = Field(..., description=\"调度配置\")\n    action: AutomationAction = Field(..., description=\"动作配置\")\n    last_run_at: datetime | None = Field(None, description=\"最后运行时间\")\n    last_status: str | None = Field(None, description=\"最后运行状态\")\n    last_error: str | None = Field(None, description=\"最后错误信息\")\n    last_output: str | None = Field(None, description=\"最后输出摘要\")\n    created_at: datetime = Field(..., description=\"创建时间\")\n    updated_at: datetime = Field(..., description=\"更新时间\")\n\n\nclass AutomationTaskListResponse(BaseModel):\n    \"\"\"自动化任务列表响应\"\"\"\n\n    total: int\n    tasks: list[AutomationTaskResponse]\n\n\nif TYPE_CHECKING:\n    from datetime import datetime\n"
  },
  {
    "path": "lifetrace/schemas/chat.py",
    "content": "\"\"\"聊天相关的 Pydantic 模型\"\"\"\n\nfrom datetime import datetime\nfrom typing import Any\n\nfrom pydantic import BaseModel, ConfigDict\n\n\nclass ChatMessage(BaseModel):\n    model_config = ConfigDict(extra=\"allow\")  # 允许额外字段，用于传递 Dify 等服务的参数\n\n    message: str  # 发送给 LLM 的完整消息（包含 system prompt + context + user input）\n    user_input: str | None = None  # 用户真正输入的内容（用于保存到历史记录）\n    context: str | None = None  # 待办上下文（可选，用于 Agent 处理）\n    system_prompt: str | None = None  # 系统提示词（可选）\n    conversation_id: str | None = None  # 会话ID\n    use_rag: bool = True  # 是否使用RAG\n    mode: str | None = None  # 前端聊天模式（ask/plan/edit/dify_test/agno 等）\n\n    # Agno Agent 工具配置\n    selected_tools: list[str] | None = None  # FreeTodo 工具列表（如 ['create_todo', 'list_todos']）\n    external_tools: list[str] | None = None  # 外部工具列表（如 ['duckduckgo']）\n\n    # Cowork 配置（本地文件操作）\n    workspace_path: str | None = None  # 工作区目录路径（用于 Cowork 模式）\n    enable_file_delete: bool = False  # 是否允许删除文件（默认不允许）\n\n    def get_user_input_for_storage(self) -> str:\n        \"\"\"获取用于保存到历史记录的用户输入内容。\n\n        优先返回 user_input 字段，如果未提供则降级返回完整 message。\n        \"\"\"\n        return self.user_input if self.user_input is not None else self.message\n\n\nclass ChatMessageWithContext(BaseModel):\n    message: str\n    conversation_id: str | None = None\n    event_context: list[dict[str, Any]] | None = None  # 新增事件上下文\n\n\nclass ChatResponse(BaseModel):\n    response: str\n    timestamp: datetime\n    query_info: dict[str, Any] | None = None\n    retrieval_info: dict[str, Any] | None = None\n    performance: dict[str, Any] | None = None\n    session_id: str | None = None\n\n\nclass NewChatRequest(BaseModel):\n    session_id: str | None = None\n\n\nclass NewChatResponse(BaseModel):\n    session_id: str\n    message: str\n    timestamp: datetime\n\n\nclass AddMessageRequest(BaseModel):\n    role: str\n    content: str\n\n\nclass PlanQuestionnaireRequest(BaseModel):\n    todo_name: str\n    todo_id: int | None = None  # 新增：用于查询上下文\n    session_id: str | None = None  # 会话ID，用于保存聊天记录\n\n\nclass PlanSummaryRequest(BaseModel):\n    todo_name: str\n    answers: dict[str, list[str]]  # question_id -> selected_options\n    session_id: str | None = None  # 会话ID，用于保存聊天记录\n"
  },
  {
    "path": "lifetrace/schemas/event.py",
    "content": "\"\"\"事件相关的 Pydantic 模型\"\"\"\n\nfrom datetime import datetime\n\nfrom pydantic import BaseModel\n\nfrom lifetrace.schemas.screenshot import ScreenshotResponse\n\n\nclass EventResponse(BaseModel):\n    id: int\n    app_name: str | None\n    window_title: str | None\n    start_time: datetime\n    end_time: datetime | None\n    screenshot_count: int\n    first_screenshot_id: int | None\n    ai_title: str | None = None\n    ai_summary: str | None = None\n\n\nclass EventDetailResponse(BaseModel):\n    id: int\n    app_name: str | None\n    window_title: str | None\n    start_time: datetime\n    end_time: datetime | None\n    screenshots: list[ScreenshotResponse]\n    ai_title: str | None = None\n    ai_summary: str | None = None\n\n\nclass EventListResponse(BaseModel):\n    \"\"\"事件列表响应，包含事件列表和总数\"\"\"\n\n    events: list[EventResponse]\n    total_count: int\n\n    class Config:\n        from_attributes = True\n"
  },
  {
    "path": "lifetrace/schemas/floating_capture.py",
    "content": "\"\"\"悬浮窗截图提取待办相关的 Pydantic 模型\"\"\"\n\nfrom datetime import datetime\nfrom typing import Any\n\nfrom pydantic import BaseModel, Field\n\n\nclass FloatingCaptureRequest(BaseModel):\n    \"\"\"悬浮窗截图请求模型\"\"\"\n\n    image_base64: str = Field(\n        ..., description=\"Base64 编码的截图数据（不含 data:image/png;base64, 前缀）\"\n    )\n    create_todos: bool = Field(False, description=\"是否自动创建待办（draft 状态）\")\n\n\nclass ExtractedTodo(BaseModel):\n    \"\"\"提取的待办项\"\"\"\n\n    title: str = Field(..., description=\"待办标题\")\n    description: str | None = Field(None, description=\"待办描述\")\n    time_info: dict[str, Any] | None = Field(None, description=\"时间信息\")\n    source_text: str | None = Field(None, description=\"来源文本\")\n    confidence: float = Field(0.5, description=\"置信度\", ge=0.0, le=1.0)\n\n\nclass CreatedTodo(BaseModel):\n    \"\"\"创建的待办项\"\"\"\n\n    id: int = Field(..., description=\"待办 ID\")\n    name: str = Field(..., description=\"待办名称\")\n    scheduled_time: str | None = Field(None, description=\"计划时间\")\n\n\nclass FloatingCaptureResponse(BaseModel):\n    \"\"\"悬浮窗截图响应模型\"\"\"\n\n    success: bool = Field(..., description=\"是否成功\")\n    message: str = Field(..., description=\"处理消息\")\n    extracted_todos: list[ExtractedTodo] = Field(default_factory=list, description=\"提取的待办列表\")\n    created_todos: list[CreatedTodo] = Field(default_factory=list, description=\"创建的待办列表\")\n    created_count: int = Field(0, description=\"创建的待办数量\")\n    timestamp: datetime = Field(default_factory=datetime.now, description=\"响应时间戳\")\n"
  },
  {
    "path": "lifetrace/schemas/journal.py",
    "content": "\"\"\"日记相关的 Pydantic 模型\"\"\"\n\nfrom datetime import datetime\n\nfrom pydantic import BaseModel, Field\n\n\nclass JournalTag(BaseModel):\n    \"\"\"日记关联的标签\"\"\"\n\n    id: int = Field(..., description=\"标签ID\")\n    tag_name: str = Field(..., description=\"标签名称\")\n\n\nclass JournalCreate(BaseModel):\n    \"\"\"创建日记请求模型\"\"\"\n\n    uid: str | None = Field(None, max_length=64, description=\"iCalendar UID\")\n    name: str | None = Field(None, max_length=200, description=\"日记标题\")\n    user_notes: str = Field(..., description=\"日记内容（富文本）\")\n    date: datetime = Field(..., description=\"日记日期\")\n    content_format: str = Field(\n        \"markdown\", max_length=20, description=\"内容格式：markdown/html/json\"\n    )\n    content_objective: str | None = Field(None, description=\"客观记录\")\n    content_ai: str | None = Field(None, description=\"AI 视角\")\n    mood: str | None = Field(None, max_length=50, description=\"情绪\")\n    energy: int | None = Field(None, ge=0, le=10, description=\"精力\")\n    day_bucket_start: datetime | None = Field(None, description=\"日记归属刷新点\")\n    tags: list[str] = Field(default_factory=list, description=\"关联的标签列表\")\n    related_todo_ids: list[int] = Field(default_factory=list, description=\"关联待办ID列表\")\n    related_activity_ids: list[int] = Field(default_factory=list, description=\"关联活动ID列表\")\n\n\nclass JournalUpdate(BaseModel):\n    \"\"\"更新日记请求模型\"\"\"\n\n    name: str | None = Field(None, max_length=200, description=\"日记标题\")\n    user_notes: str | None = Field(None, description=\"日记内容（富文本）\")\n    date: datetime | None = Field(None, description=\"日记日期\")\n    content_format: str | None = Field(\n        None, max_length=20, description=\"内容格式：markdown/html/json\"\n    )\n    content_objective: str | None = Field(None, description=\"客观记录\")\n    content_ai: str | None = Field(None, description=\"AI 视角\")\n    mood: str | None = Field(None, max_length=50, description=\"情绪\")\n    energy: int | None = Field(None, ge=0, le=10, description=\"精力\")\n    day_bucket_start: datetime | None = Field(None, description=\"日记归属刷新点\")\n    tags: list[str] | None = Field(None, description=\"关联的标签列表（覆盖替换）\")\n    related_todo_ids: list[int] | None = Field(None, description=\"关联待办ID列表\")\n    related_activity_ids: list[int] | None = Field(None, description=\"关联活动ID列表\")\n\n\nclass JournalResponse(BaseModel):\n    \"\"\"日记响应模型\"\"\"\n\n    id: int = Field(..., description=\"日记ID\")\n    uid: str = Field(..., description=\"iCalendar UID\")\n    name: str = Field(..., description=\"日记标题\")\n    user_notes: str = Field(..., description=\"日记内容（富文本）\")\n    date: datetime = Field(..., description=\"日记日期\")\n    content_format: str = Field(..., description=\"内容格式\")\n    content_objective: str | None = Field(None, description=\"客观记录\")\n    content_ai: str | None = Field(None, description=\"AI 视角\")\n    mood: str | None = Field(None, description=\"情绪\")\n    energy: int | None = Field(None, description=\"精力\")\n    day_bucket_start: datetime | None = Field(None, description=\"日记归属刷新点\")\n    created_at: datetime = Field(..., description=\"创建时间\")\n    updated_at: datetime = Field(..., description=\"更新时间\")\n    deleted_at: datetime | None = Field(None, description=\"删除时间\")\n    tags: list[JournalTag] = Field(default_factory=list, description=\"关联标签列表\")\n    related_todo_ids: list[int] = Field(default_factory=list, description=\"关联待办ID列表\")\n    related_activity_ids: list[int] = Field(default_factory=list, description=\"关联活动ID列表\")\n\n    class Config:\n        from_attributes = True\n\n\nclass JournalListResponse(BaseModel):\n    \"\"\"日记列表响应模型\"\"\"\n\n    total: int = Field(..., description=\"总数\")\n    journals: list[JournalResponse] = Field(..., description=\"日记列表\")\n\n\nclass JournalAutoLinkRequest(BaseModel):\n    \"\"\"自动关联请求\"\"\"\n\n    journal_id: int | None = Field(None, description=\"日记ID\")\n    title: str | None = Field(None, description=\"日记标题\")\n    content_original: str | None = Field(None, description=\"日记原文\")\n    date: datetime = Field(..., description=\"日记日期\")\n    day_bucket_start: datetime | None = Field(None, description=\"日记归属刷新点\")\n    max_items: int = Field(3, ge=1, le=10, description=\"默认关联数量\")\n\n\nclass JournalAutoLinkCandidate(BaseModel):\n    \"\"\"自动关联候选\"\"\"\n\n    id: int = Field(..., description=\"候选ID\")\n    name: str = Field(..., description=\"候选标题\")\n    score: float = Field(..., description=\"匹配分\")\n\n\nclass JournalAutoLinkResponse(BaseModel):\n    \"\"\"自动关联响应\"\"\"\n\n    related_todo_ids: list[int] = Field(default_factory=list, description=\"关联待办ID列表\")\n    related_activity_ids: list[int] = Field(default_factory=list, description=\"关联活动ID列表\")\n    todo_candidates: list[JournalAutoLinkCandidate] = Field(\n        default_factory=list, description=\"待办候选\"\n    )\n    activity_candidates: list[JournalAutoLinkCandidate] = Field(\n        default_factory=list, description=\"活动候选\"\n    )\n\n\nclass JournalGenerateRequest(BaseModel):\n    \"\"\"生成客观记录/AI 视角请求\"\"\"\n\n    journal_id: int | None = Field(None, description=\"日记ID\")\n    title: str | None = Field(None, description=\"日记标题\")\n    content_original: str | None = Field(None, description=\"日记原文\")\n    date: datetime | None = Field(None, description=\"日记日期\")\n    day_bucket_start: datetime | None = Field(None, description=\"日记归属刷新点\")\n    language: str = Field(\"en\", max_length=10, description=\"语言\")\n\n\nclass JournalGenerateResponse(BaseModel):\n    \"\"\"生成结果响应\"\"\"\n\n    content: str = Field(..., description=\"生成内容\")\n"
  },
  {
    "path": "lifetrace/schemas/message_todo_extraction.py",
    "content": "\"\"\"从消息中提取待办相关的 Pydantic 模型\"\"\"\n\nfrom pydantic import BaseModel, Field\n\n\nclass MessageTodoExtractionRequest(BaseModel):\n    \"\"\"从消息中提取待办的请求模型\"\"\"\n\n    messages: list[dict[str, str]] = Field(\n        ...,\n        description=\"消息列表，包含 role 和 content 字段\",\n    )\n    parent_todo_id: int | None = Field(\n        None,\n        description=\"父待办ID，提取的待办将作为该待办的子待办\",\n    )\n    todo_context: str | None = Field(\n        None,\n        description=\"待办上下文信息，用于帮助AI理解关联的待办\",\n    )\n\n\nclass ExtractedMessageTodo(BaseModel):\n    \"\"\"从消息中提取的待办项结构\"\"\"\n\n    name: str = Field(..., description=\"待办名称\", min_length=1, max_length=100)\n    description: str | None = Field(None, description=\"待办描述（可选）\", max_length=500)\n    tags: list[str] = Field(default_factory=list, description=\"标签列表\")\n\n\nclass MessageTodoExtractionResponse(BaseModel):\n    \"\"\"从消息中提取待办的响应模型\"\"\"\n\n    todos: list[ExtractedMessageTodo] = Field(\n        default_factory=list,\n        description=\"提取的待办列表\",\n    )\n    error_message: str | None = Field(None, description=\"错误信息（如果有）\")\n"
  },
  {
    "path": "lifetrace/schemas/screenshot.py",
    "content": "\"\"\"截图相关的 Pydantic 模型\"\"\"\n\nfrom datetime import datetime\n\nfrom pydantic import BaseModel\n\n\nclass ScreenshotResponse(BaseModel):\n    id: int\n    file_path: str\n    app_name: str | None\n    window_title: str | None\n    created_at: datetime\n    text_content: str | None\n    width: int\n    height: int\n    file_deleted: bool = False  # 文件是否已被清理\n"
  },
  {
    "path": "lifetrace/schemas/search.py",
    "content": "\"\"\"搜索相关的 Pydantic 模型\"\"\"\n\nfrom datetime import datetime\n\nfrom pydantic import BaseModel\n\n\nclass SearchRequest(BaseModel):\n    query: str | None = None\n    start_date: datetime | None = None\n    end_date: datetime | None = None\n    app_name: str | None = None\n    limit: int = 50\n"
  },
  {
    "path": "lifetrace/schemas/stats.py",
    "content": "\"\"\"统计相关的 Pydantic 模型\"\"\"\n\nfrom typing import Any\n\nfrom pydantic import BaseModel\n\n\nclass StatisticsResponse(BaseModel):\n    total_screenshots: int\n    processed_screenshots: int\n    today_screenshots: int\n    processing_rate: float\n\n\nclass TimeAllocationResponse(BaseModel):\n    \"\"\"时间分配响应模型\"\"\"\n\n    total_time: int  # 总使用时间（秒）\n    daily_distribution: list[\n        dict[str, Any]\n    ]  # 24小时分布，格式: [{\"hour\": 0, \"apps\": {\"app_name\": seconds}}, ...]\n    app_details: list[\n        dict[str, Any]\n    ]  # 应用详情，格式: [{\"app_name\": \"xxx.exe\", \"total_time\": seconds, \"category\": \"社交\"}, ...]\n\n    class Config:\n        arbitrary_types_allowed = True\n"
  },
  {
    "path": "lifetrace/schemas/system.py",
    "content": "\"\"\"系统资源相关的 Pydantic 模型\"\"\"\n\nfrom datetime import datetime\nfrom typing import Any\n\nfrom pydantic import BaseModel\n\n\nclass ProcessInfo(BaseModel):\n    pid: int\n    name: str\n    cmdline: str\n    memory_mb: float\n    memory_vms_mb: float\n    cpu_percent: float\n\n\nclass SystemResourcesResponse(BaseModel):\n    memory: dict[str, float]\n    cpu: dict[str, Any]\n    disk: dict[str, dict[str, float]]\n    lifetrace_processes: list[ProcessInfo]\n    storage: dict[str, Any]\n    summary: dict[str, Any]\n    timestamp: datetime\n\n\nclass CapabilitiesResponse(BaseModel):\n    enabled_modules: list[str]\n    available_modules: list[str]\n    disabled_modules: list[str]\n    missing_deps: dict[str, list[str]]\n"
  },
  {
    "path": "lifetrace/schemas/todo.py",
    "content": "\"\"\"待办事项（Todo）相关的 Pydantic 模型\n\n说明：\n- 该模块面向 free-todo-frontend 的 Todo 结构（支持 deadline/priority/tags/attachments 等）\n- 数据库存储使用 lifetrace.storage.models 中的 Todo/Tag/Attachment 相关表\n\"\"\"\n\nfrom datetime import datetime\nfrom enum import Enum\n\nfrom pydantic import BaseModel, Field\n\n\nclass TodoStatus(str, Enum):\n    \"\"\"Todo 状态枚举（与前端保持一致）\"\"\"\n\n    ACTIVE = \"active\"\n    COMPLETED = \"completed\"\n    CANCELED = \"canceled\"\n    DRAFT = \"draft\"\n\n\nclass TodoPriority(str, Enum):\n    \"\"\"Todo 优先级（与前端保持一致）\"\"\"\n\n    HIGH = \"high\"\n    MEDIUM = \"medium\"\n    LOW = \"low\"\n    NONE = \"none\"\n\n\nclass TodoItemType(str, Enum):\n    \"\"\"iCalendar 条目类型\"\"\"\n\n    VTODO = \"VTODO\"\n    VEVENT = \"VEVENT\"\n\n\nclass TodoAttachmentResponse(BaseModel):\n    \"\"\"Todo 附件响应模型\"\"\"\n\n    id: int = Field(..., description=\"附件ID\")\n    file_name: str = Field(..., description=\"文件名\")\n    file_path: str = Field(..., description=\"文件路径\")\n    file_size: int | None = Field(None, description=\"文件大小（字节）\")\n    mime_type: str | None = Field(None, description=\"MIME 类型\")\n    source: str | None = Field(None, description=\"来源(user/ai)\")\n\n    class Config:\n        from_attributes = True\n\n\nclass TodoCreate(BaseModel):\n    \"\"\"创建 Todo 请求模型\"\"\"\n\n    uid: str | None = Field(None, max_length=64, description=\"iCalendar UID\")\n    name: str = Field(..., min_length=1, max_length=200, description=\"待办名称\")\n    summary: str | None = Field(None, description=\"iCalendar SUMMARY\")\n    description: str | None = Field(None, description=\"描述\")\n    user_notes: str | None = Field(None, description=\"用户笔记\")\n    parent_todo_id: int | None = Field(None, description=\"父级待办ID\")\n    item_type: TodoItemType | None = Field(None, description=\"iCalendar 条目类型\")\n    location: str | None = Field(None, description=\"iCalendar LOCATION\")\n    categories: str | None = Field(None, description=\"iCalendar CATEGORIES\")\n    classification: str | None = Field(None, description=\"iCalendar CLASS\")\n    deadline: datetime | None = Field(None, description=\"截止时间（旧字段，逐步废弃）\")\n    start_time: datetime | None = Field(None, description=\"开始时间\")\n    end_time: datetime | None = Field(None, description=\"结束时间\")\n    dtstart: datetime | None = Field(None, description=\"iCalendar DTSTART\")\n    dtend: datetime | None = Field(None, description=\"iCalendar DTEND\")\n    due: datetime | None = Field(None, description=\"iCalendar DUE\")\n    duration: str | None = Field(None, description=\"iCalendar DURATION (ISO 8601)\")\n    time_zone: str | None = Field(None, description=\"时区（IANA）\")\n    tzid: str | None = Field(None, description=\"iCalendar TZID\")\n    is_all_day: bool | None = Field(None, description=\"是否全天\")\n    dtstamp: datetime | None = Field(None, description=\"iCalendar DTSTAMP\")\n    created: datetime | None = Field(None, description=\"iCalendar CREATED\")\n    last_modified: datetime | None = Field(None, description=\"iCalendar LAST-MODIFIED\")\n    sequence: int | None = Field(None, description=\"iCalendar SEQUENCE\")\n    rdate: str | None = Field(None, description=\"iCalendar RDATE\")\n    exdate: str | None = Field(None, description=\"iCalendar EXDATE\")\n    recurrence_id: datetime | None = Field(None, description=\"iCalendar RECURRENCE-ID\")\n    related_to_uid: str | None = Field(None, description=\"iCalendar RELATED-TO UID\")\n    related_to_reltype: str | None = Field(None, description=\"iCalendar RELATED-TO RELTYPE\")\n    ical_status: str | None = Field(None, description=\"iCalendar STATUS\")\n    reminder_offsets: list[int] | None = Field(\n        None, description=\"提醒偏移列表（分钟，基于 dtstart/due）\"\n    )\n    status: TodoStatus = Field(TodoStatus.ACTIVE, description=\"状态\")\n    priority: TodoPriority = Field(TodoPriority.NONE, description=\"优先级\")\n    completed_at: datetime | None = Field(None, description=\"完成时间\")\n    percent_complete: int | None = Field(None, ge=0, le=100, description=\"完成百分比（0-100）\")\n    rrule: str | None = Field(None, description=\"iCalendar RRULE\")\n    order: int = Field(0, description=\"同级待办之间的展示排序\")\n    tags: list[str] = Field(default_factory=list, description=\"标签名称列表\")\n    related_activities: list[int] = Field(default_factory=list, description=\"关联活动ID列表\")\n\n\nclass TodoUpdate(BaseModel):\n    \"\"\"更新 Todo 请求模型（字段均可选）\"\"\"\n\n    name: str | None = Field(None, min_length=1, max_length=200, description=\"待办名称\")\n    summary: str | None = Field(None, description=\"iCalendar SUMMARY\")\n    description: str | None = Field(None, description=\"描述\")\n    user_notes: str | None = Field(None, description=\"用户笔记\")\n    parent_todo_id: int | None = Field(None, description=\"父级待办ID（显式传 null 可清空）\")\n    item_type: TodoItemType | None = Field(None, description=\"iCalendar 条目类型\")\n    location: str | None = Field(None, description=\"iCalendar LOCATION\")\n    categories: str | None = Field(None, description=\"iCalendar CATEGORIES\")\n    classification: str | None = Field(None, description=\"iCalendar CLASS\")\n    deadline: datetime | None = Field(None, description=\"截止时间（旧字段，显式传 null 可清空）\")\n    start_time: datetime | None = Field(None, description=\"开始时间（显式传 null 可清空）\")\n    end_time: datetime | None = Field(None, description=\"结束时间（显式传 null 可清空）\")\n    dtstart: datetime | None = Field(None, description=\"iCalendar DTSTART（显式传 null 可清空）\")\n    dtend: datetime | None = Field(None, description=\"iCalendar DTEND（显式传 null 可清空）\")\n    due: datetime | None = Field(None, description=\"iCalendar DUE（显式传 null 可清空）\")\n    duration: str | None = Field(None, description=\"iCalendar DURATION（显式传 null 可清空）\")\n    time_zone: str | None = Field(None, description=\"时区（显式传 null 可清空）\")\n    tzid: str | None = Field(None, description=\"iCalendar TZID（显式传 null 可清空）\")\n    is_all_day: bool | None = Field(None, description=\"是否全天（显式传 null 可清空）\")\n    dtstamp: datetime | None = Field(None, description=\"iCalendar DTSTAMP（显式传 null 可清空）\")\n    created: datetime | None = Field(None, description=\"iCalendar CREATED（显式传 null 可清空）\")\n    last_modified: datetime | None = Field(\n        None, description=\"iCalendar LAST-MODIFIED（显式传 null 可清空）\"\n    )\n    sequence: int | None = Field(None, description=\"iCalendar SEQUENCE（显式传 null 可清空）\")\n    rdate: str | None = Field(None, description=\"iCalendar RDATE（显式传 null 可清空）\")\n    exdate: str | None = Field(None, description=\"iCalendar EXDATE（显式传 null 可清空）\")\n    recurrence_id: datetime | None = Field(\n        None, description=\"iCalendar RECURRENCE-ID（显式传 null 可清空）\"\n    )\n    related_to_uid: str | None = Field(\n        None, description=\"iCalendar RELATED-TO UID（显式传 null 可清空）\"\n    )\n    related_to_reltype: str | None = Field(\n        None, description=\"iCalendar RELATED-TO RELTYPE（显式传 null 可清空）\"\n    )\n    ical_status: str | None = Field(None, description=\"iCalendar STATUS（显式传 null 可清空）\")\n    reminder_offsets: list[int] | None = Field(\n        None, description=\"提醒偏移列表（分钟，显式传 null 可回退默认）\"\n    )\n    status: TodoStatus | None = Field(None, description=\"状态\")\n    priority: TodoPriority | None = Field(None, description=\"优先级\")\n    completed_at: datetime | None = Field(None, description=\"完成时间（显式传 null 可清空）\")\n    percent_complete: int | None = Field(None, ge=0, le=100, description=\"完成百分比（0-100）\")\n    rrule: str | None = Field(None, description=\"iCalendar RRULE（显式传 null 可清空）\")\n    order: int | None = Field(None, description=\"同级待办之间的展示排序\")\n    tags: list[str] | None = Field(None, description=\"标签名称列表（显式传空数组将清空）\")\n    related_activities: list[int] | None = Field(\n        None, description=\"关联活动ID列表（显式传空数组将清空）\"\n    )\n\n\nclass TodoResponse(BaseModel):\n    \"\"\"Todo 响应模型\"\"\"\n\n    id: int = Field(..., description=\"待办ID\")\n    uid: str = Field(..., description=\"iCalendar UID\")\n    name: str = Field(..., description=\"待办名称\")\n    summary: str | None = Field(None, description=\"iCalendar SUMMARY\")\n    description: str | None = Field(None, description=\"描述\")\n    user_notes: str | None = Field(None, description=\"用户笔记\")\n    parent_todo_id: int | None = Field(None, description=\"父级待办ID\")\n    item_type: str | None = Field(None, description=\"iCalendar 条目类型\")\n    location: str | None = Field(None, description=\"iCalendar LOCATION\")\n    categories: str | None = Field(None, description=\"iCalendar CATEGORIES\")\n    classification: str | None = Field(None, description=\"iCalendar CLASS\")\n    deadline: datetime | None = Field(None, description=\"截止时间（旧字段）\")\n    start_time: datetime | None = Field(None, description=\"开始时间\")\n    end_time: datetime | None = Field(None, description=\"结束时间\")\n    dtstart: datetime | None = Field(None, description=\"iCalendar DTSTART\")\n    dtend: datetime | None = Field(None, description=\"iCalendar DTEND\")\n    due: datetime | None = Field(None, description=\"iCalendar DUE\")\n    duration: str | None = Field(None, description=\"iCalendar DURATION\")\n    time_zone: str | None = Field(None, description=\"时区（IANA）\")\n    tzid: str | None = Field(None, description=\"iCalendar TZID\")\n    is_all_day: bool = Field(False, description=\"是否全天\")\n    dtstamp: datetime | None = Field(None, description=\"iCalendar DTSTAMP\")\n    created: datetime | None = Field(None, description=\"iCalendar CREATED\")\n    last_modified: datetime | None = Field(None, description=\"iCalendar LAST-MODIFIED\")\n    sequence: int | None = Field(None, description=\"iCalendar SEQUENCE\")\n    rdate: str | None = Field(None, description=\"iCalendar RDATE\")\n    exdate: str | None = Field(None, description=\"iCalendar EXDATE\")\n    recurrence_id: datetime | None = Field(None, description=\"iCalendar RECURRENCE-ID\")\n    related_to_uid: str | None = Field(None, description=\"iCalendar RELATED-TO UID\")\n    related_to_reltype: str | None = Field(None, description=\"iCalendar RELATED-TO RELTYPE\")\n    ical_status: str | None = Field(None, description=\"iCalendar STATUS\")\n    reminder_offsets: list[int] | None = Field(\n        None, description=\"提醒偏移列表（分钟，基于 dtstart/due）\"\n    )\n    status: str = Field(..., description=\"状态\")\n    priority: str = Field(..., description=\"优先级\")\n    completed_at: datetime | None = Field(None, description=\"完成时间\")\n    percent_complete: int = Field(0, description=\"完成百分比（0-100）\")\n    rrule: str | None = Field(None, description=\"iCalendar RRULE\")\n    order: int = Field(0, description=\"同级待办之间的展示排序\")\n    tags: list[str] = Field(default_factory=list, description=\"标签名称列表\")\n    attachments: list[TodoAttachmentResponse] = Field(default_factory=list, description=\"附件列表\")\n    related_activities: list[int] = Field(default_factory=list, description=\"关联活动ID列表\")\n    created_at: datetime = Field(..., description=\"创建时间\")\n    updated_at: datetime = Field(..., description=\"更新时间\")\n\n    class Config:\n        from_attributes = True\n\n\nclass TodoListResponse(BaseModel):\n    \"\"\"Todo 列表响应模型\"\"\"\n\n    total: int = Field(..., description=\"总数\")\n    todos: list[TodoResponse] = Field(..., description=\"待办列表\")\n\n\nclass TodoReorderItem(BaseModel):\n    \"\"\"单个待办排序项\"\"\"\n\n    id: int = Field(..., description=\"待办ID\")\n    order: int = Field(..., description=\"新的排序值\")\n    parent_todo_id: int | None = Field(None, description=\"父级待办ID（可选，用于设置父子关系）\")\n\n\nclass TodoReorderRequest(BaseModel):\n    \"\"\"批量重排序请求模型\"\"\"\n\n    items: list[TodoReorderItem] = Field(..., description=\"待排序的待办列表\")\n"
  },
  {
    "path": "lifetrace/schemas/todo_extraction.py",
    "content": "\"\"\"待办提取相关的 Pydantic 模型\"\"\"\n\nfrom datetime import datetime\nfrom typing import Literal\n\nfrom pydantic import BaseModel, Field\n\n\nclass TodoTimeInfo(BaseModel):\n    \"\"\"待办时间信息结构\"\"\"\n\n    time_type: Literal[\"relative\", \"absolute\"] = Field(\n        ..., description=\"时间类型：relative（相对时间）或 absolute（绝对时间）\"\n    )\n    # 相对时间字段\n    relative_days: int | None = Field(\n        None, description=\"相对天数（0=今天，1=明天，2=后天，-1=昨天）\"\n    )\n    relative_time: str | None = Field(\n        None, description=\"相对时间点，24小时制格式（如：'13:00', '15:30'）\"\n    )\n    # 绝对时间字段\n    absolute_time: datetime | None = Field(\n        None, description=\"绝对时间（ISO 8601格式），仅在time_type为absolute时使用\"\n    )\n    # 原始文本\n    raw_text: str = Field(..., description=\"原始时间文本，用于验证和调试\")\n\n\nclass ExtractedTodo(BaseModel):\n    \"\"\"提取的待办项结构\"\"\"\n\n    title: str = Field(..., description=\"待办标题\", min_length=1, max_length=100)\n    description: str | None = Field(None, description=\"待办描述（可选）\", max_length=500)\n    time_info: TodoTimeInfo = Field(..., description=\"时间信息\")\n    scheduled_time: datetime | None = Field(None, description=\"解析后的绝对时间（程序计算得出）\")\n    source_text: str = Field(..., description=\"来源文本片段，用于验证\")\n    confidence: float | None = Field(None, description=\"置信度（0.0-1.0），可选\", ge=0.0, le=1.0)\n    screenshot_ids: list[int] = Field(default_factory=list, description=\"相关的截图ID列表\")\n\n\nclass TodoExtractionRequest(BaseModel):\n    \"\"\"待办提取请求模型\"\"\"\n\n    event_id: int = Field(..., description=\"事件ID\", gt=0)\n    screenshot_sample_ratio: int | None = Field(\n        None, description=\"截图采样比例（每N张选1张），默认3\", ge=1, le=10\n    )\n\n\nclass TodoExtractionResponse(BaseModel):\n    \"\"\"待办提取响应模型\"\"\"\n\n    event_id: int = Field(..., description=\"事件ID\")\n    app_name: str | None = Field(None, description=\"应用名称\")\n    window_title: str | None = Field(None, description=\"窗口标题\")\n    event_start_time: datetime | None = Field(None, description=\"事件开始时间\")\n    event_end_time: datetime | None = Field(None, description=\"事件结束时间\")\n    todos: list[ExtractedTodo] = Field(default_factory=list, description=\"提取的待办列表\")\n    extraction_timestamp: datetime = Field(default_factory=datetime.now, description=\"提取时间戳\")\n    screenshot_count: int = Field(0, description=\"实际分析的截图数量\")\n    error_message: str | None = Field(None, description=\"错误信息（如果有）\")\n"
  },
  {
    "path": "lifetrace/schemas/vector.py",
    "content": "\"\"\"向量数据库相关的 Pydantic 模型\"\"\"\n\nfrom typing import Any\n\nfrom pydantic import BaseModel\n\n\nclass SemanticSearchRequest(BaseModel):\n    query: str\n    top_k: int = 10\n    use_rerank: bool = True\n    retrieve_k: int | None = None\n    filters: dict[str, Any] | None = None\n\n\nclass SemanticSearchResult(BaseModel):\n    text: str\n    score: float\n    metadata: dict[str, Any]\n    ocr_result: dict[str, Any] | None = None\n    screenshot: dict[str, Any] | None = None\n\n\nclass VectorStatsResponse(BaseModel):\n    enabled: bool\n    collection_name: str | None = None\n    document_count: int | None = None\n    error: str | None = None\n"
  },
  {
    "path": "lifetrace/schemas/vision.py",
    "content": "\"\"\"视觉多模态相关的 Pydantic 模型\"\"\"\n\nfrom datetime import datetime\nfrom typing import Any\n\nfrom pydantic import BaseModel, Field\n\n\nclass VisionChatRequest(BaseModel):\n    \"\"\"视觉多模态聊天请求模型\"\"\"\n\n    screenshot_ids: list[int] = Field(..., description=\"截图ID列表，至少包含一个截图ID\")\n    prompt: str = Field(..., description=\"文本提示词\", min_length=1)\n    model: str | None = Field(None, description=\"视觉模型名称，如果不提供则使用配置中的默认模型\")\n    temperature: float | None = Field(\n        None, description=\"温度参数，控制输出的随机性\", ge=0.0, le=2.0\n    )\n    max_tokens: int | None = Field(None, description=\"最大生成token数\")\n\n\nclass VisionChatResponse(BaseModel):\n    \"\"\"视觉多模态聊天响应模型\"\"\"\n\n    response: str = Field(..., description=\"模型生成的响应文本\")\n    timestamp: datetime = Field(default_factory=datetime.now, description=\"响应时间戳\")\n    usage_info: dict[str, Any] | None = Field(None, description=\"Token使用信息\")\n    model: str | None = Field(None, description=\"实际使用的模型名称\")\n    screenshot_count: int = Field(..., description=\"实际处理的截图数量\")\n"
  },
  {
    "path": "lifetrace/scripts/add_file_path_column.py",
    "content": "#!/usr/bin/env python3\n\"\"\"直接添加 file_path 列到 audio_recordings 表（无需 Alembic 迁移）\"\"\"\n\nimport sqlite3\nfrom pathlib import Path\n\n\ndef add_file_path_column():\n    \"\"\"添加 file_path 列到 audio_recordings 表\"\"\"\n    # 直接使用相对路径（相对于脚本位置）\n    script_dir = Path(__file__).parent.parent\n    db_path = script_dir / \"data\" / \"lifetrace.db\"\n\n    if not Path(db_path).exists():\n        print(f\"数据库文件不存在: {db_path}\")\n        return\n\n    conn = sqlite3.connect(db_path)\n    cursor = conn.cursor()\n\n    try:\n        # 检查列是否已存在\n        cursor.execute(\"PRAGMA table_info(audio_recordings)\")\n        columns = [row[1] for row in cursor.fetchall()]\n\n        if \"file_path\" in columns:\n            print(\"[OK] file_path column already exists, no need to add\")\n            return\n\n        # 添加 file_path 列\n        print(\"Adding file_path column...\")\n        cursor.execute(\"ALTER TABLE audio_recordings ADD COLUMN file_path VARCHAR(500)\")\n\n        # 为现有记录设置默认值\n        cursor.execute(\"UPDATE audio_recordings SET file_path = '' WHERE file_path IS NULL\")\n\n        conn.commit()\n        print(\"[OK] file_path column added successfully!\")\n\n    except Exception as e:\n        conn.rollback()\n        print(f\"[ERROR] Failed to add column: {e}\")\n        raise\n    finally:\n        conn.close()\n\n\nif __name__ == \"__main__\":\n    add_file_path_column()\n"
  },
  {
    "path": "lifetrace/scripts/build-backend.ps1",
    "content": "# Build script for LifeTrace backend using PyInstaller (Windows PowerShell)\n# Usage: .\\build-backend.ps1\n\n$ErrorActionPreference = \"Stop\"\n\n# Get the script directory and project root\n$SCRIPT_DIR = Split-Path -Parent $MyInvocation.MyCommand.Path\n# Script is in lifetrace/scripts/, so go up two levels to get project root\n$PROJECT_ROOT = Split-Path -Parent (Split-Path -Parent $SCRIPT_DIR)\n$LIFETRACE_DIR = Split-Path -Parent $SCRIPT_DIR\n$DIST_DIR = \"$PROJECT_ROOT\\dist-backend\"\n$VENV_DIR = \"$PROJECT_ROOT\\.venv\"\n\nWrite-Host \"Building LifeTrace backend...\"\nWrite-Host \"Project root: $PROJECT_ROOT\"\nWrite-Host \"Lifetrace dir: $LIFETRACE_DIR\"\nWrite-Host \"Output dir: $DIST_DIR\"\nWrite-Host \"Using virtual environment: $VENV_DIR\"\n\n# Check if .venv exists\nif (-not (Test-Path $VENV_DIR)) {\n    Write-Host \"Error: Virtual environment not found at $VENV_DIR\"\n    Write-Host \"Please run 'uv sync --group dev' first to create the virtual environment.\"\n    exit 1\n}\n\n# Check if PyInstaller is installed in .venv\n$VENV_PYINSTALLER = \"$VENV_DIR\\Scripts\\pyinstaller.exe\"\nif (-not (Test-Path $VENV_PYINSTALLER)) {\n    Write-Host \"PyInstaller not found in .venv. Installing via uv...\"\n    Set-Location $PROJECT_ROOT\n    uv sync --group dev\n    if (-not (Test-Path $VENV_PYINSTALLER)) {\n        Write-Host \"Error: Failed to install PyInstaller in .venv\"\n        exit 1\n    }\n}\n\n# Use .venv Python and PyInstaller\n$VENV_PYTHON = \"$VENV_DIR\\Scripts\\python.exe\"\n\nWrite-Host \"Using Python: $VENV_PYTHON\"\nWrite-Host \"Using PyInstaller: $VENV_PYINSTALLER\"\n\n# Verify critical dependencies are available in .venv\nWrite-Host \"Verifying dependencies in .venv...\"\ntry {\n    & $VENV_PYTHON -c \"import fastapi, uvicorn, pydantic; print('✓ All critical dependencies found')\"\n    if ($LASTEXITCODE -ne 0) {\n        throw \"Dependency check failed\"\n    }\n} catch {\n    Write-Host \"Error: Missing dependencies in .venv. Please run 'uv sync --group dev' first.\"\n    exit 1\n}\n\n# Clean previous build\nif (Test-Path $DIST_DIR) {\n    Write-Host \"Cleaning previous build...\"\n    Remove-Item -Recurse -Force $DIST_DIR\n}\n\n# Create dist directory\nNew-Item -ItemType Directory -Force -Path $DIST_DIR | Out-Null\n\n# Change to project root directory\nSet-Location $PROJECT_ROOT\n\n# Run PyInstaller using .venv Python\nWrite-Host \"Running PyInstaller...\"\n# Change to lifetrace directory to run PyInstaller (so paths in spec file work correctly)\nSet-Location $LIFETRACE_DIR\n# Use .venv Python explicitly to ensure all dependencies are from .venv\n& $VENV_PYTHON -m PyInstaller --clean --noconfirm pyinstaller.spec\n\n# Copy the built executable to dist-backend\n# PyInstaller creates a directory with the same name as the spec file target\n# PyInstaller runs from LIFETRACE_DIR, so dist is created there\n$BUILD_DIR = \"$LIFETRACE_DIR\\dist\\lifetrace\"\nif (Test-Path $BUILD_DIR) {\n    Write-Host \"Copying build output to $DIST_DIR...\"\n    Copy-Item -Recurse -Force \"$BUILD_DIR\\*\" $DIST_DIR\n\n    # 将 config 和 models 从 _internal 复制到 app 根目录（与 _internal 同级别）\n    # 这样在打包环境中，路径为 backend\\config\\ 和 backend\\models\\\n    $internalConfig = \"$DIST_DIR\\_internal\\config\"\n    if (Test-Path $internalConfig) {\n        Write-Host \"Copying config files to app root...\"\n        $appConfig = \"$DIST_DIR\\config\"\n        New-Item -ItemType Directory -Path $appConfig -Force | Out-Null\n        Copy-Item -Path \"$internalConfig\\*\" -Destination $appConfig -Recurse -Force -ErrorAction SilentlyContinue\n    }\n\n    $internalModels = \"$DIST_DIR\\_internal\\models\"\n    if (Test-Path $internalModels) {\n        Write-Host \"Copying model files to app root...\"\n        $appModels = \"$DIST_DIR\\models\"\n        New-Item -ItemType Directory -Path $appModels -Force | Out-Null\n        Copy-Item -Path \"$internalModels\\*\" -Destination $appModels -Recurse -Force -ErrorAction SilentlyContinue\n    }\n\n    Write-Host \"Backend build complete! Output: $DIST_DIR\"\n    Write-Host \"Backend executable location: $DIST_DIR\\lifetrace.exe\"\n    Write-Host \"Config directory: $DIST_DIR\\config\"\n    Write-Host \"Models directory: $DIST_DIR\\models\"\n} else {\n    Write-Host \"Error: Build directory not found: $BUILD_DIR\"\n    $DIST_PARENT = \"$PROJECT_ROOT\\dist\"\n    if (Test-Path $DIST_PARENT) {\n        Write-Host \"Available directories in dist:\"\n        Get-ChildItem $DIST_PARENT | ForEach-Object { Write-Host \"  $($_.Name)\" }\n    } else {\n        Write-Host \"dist directory does not exist\"\n    }\n    exit 1\n}\n"
  },
  {
    "path": "lifetrace/scripts/build-backend.sh",
    "content": "#!/bin/bash\n# Build script for LifeTrace backend using PyInstaller\n# Usage: ./build-backend.sh\n# Supports: macOS, Linux, Windows (via WSL/Git Bash/MSYS2)\n\nset -e  # Exit on error\n\n# Get the script directory and project root\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n# Script is in lifetrace/scripts/, so go up two levels to get project root\nPROJECT_ROOT=\"$(cd \"$SCRIPT_DIR/../..\" && pwd)\"\nLIFETRACE_DIR=\"$SCRIPT_DIR/..\"\nDIST_DIR=\"$PROJECT_ROOT/dist-backend\"\nVENV_DIR=\"$PROJECT_ROOT/.venv\"\n\n# Detect platform and set paths accordingly\ndetect_platform() {\n    case \"$(uname -s)\" in\n        Linux*)\n            # Check if running in WSL\n            if grep -qi microsoft /proc/version 2>/dev/null; then\n                echo \"windows\"\n            else\n                echo \"linux\"\n            fi\n            ;;\n        Darwin*)\n            echo \"macos\"\n            ;;\n        MINGW*|MSYS*|CYGWIN*)\n            echo \"windows\"\n            ;;\n        *)\n            echo \"unknown\"\n            ;;\n    esac\n}\n\nPLATFORM=$(detect_platform)\necho \"Detected platform: $PLATFORM\"\n\n# Set platform-specific paths\nif [ \"$PLATFORM\" = \"windows\" ]; then\n    # Windows uses Scripts/ directory and .exe extension\n    VENV_BIN_DIR=\"$VENV_DIR/Scripts\"\n    VENV_PYTHON=\"$VENV_BIN_DIR/python.exe\"\n    VENV_PYINSTALLER=\"$VENV_BIN_DIR/pyinstaller.exe\"\nelse\n    # macOS and Linux use bin/ directory\n    VENV_BIN_DIR=\"$VENV_DIR/bin\"\n    VENV_PYTHON=\"$VENV_BIN_DIR/python\"\n    VENV_PYINSTALLER=\"$VENV_BIN_DIR/pyinstaller\"\nfi\n\necho \"Building LifeTrace backend...\"\necho \"Project root: $PROJECT_ROOT\"\necho \"Lifetrace dir: $LIFETRACE_DIR\"\necho \"Output dir: $DIST_DIR\"\necho \"Using virtual environment: $VENV_DIR\"\n\n# Check if .venv exists\nif [ ! -d \"$VENV_DIR\" ]; then\n    echo \"Error: Virtual environment not found at $VENV_DIR\"\n    echo \"Please run 'uv sync --group dev' first to create the virtual environment.\"\n    exit 1\nfi\n\n# Check if PyInstaller is installed in .venv\nif [ ! -f \"$VENV_PYINSTALLER\" ]; then\n    echo \"PyInstaller not found in .venv at: $VENV_PYINSTALLER\"\n    echo \"Attempting to install via uv...\"\n    cd \"$PROJECT_ROOT\"\n\n    # Try to find uv command\n    if command -v uv &> /dev/null; then\n        uv sync --group dev\n    elif [ \"$PLATFORM\" = \"windows\" ]; then\n        # On Windows/WSL, try common paths for uv\n        if [ -f \"$HOME/.local/bin/uv\" ]; then\n            \"$HOME/.local/bin/uv\" sync --group dev\n        elif [ -f \"$HOME/.cargo/bin/uv\" ]; then\n            \"$HOME/.cargo/bin/uv\" sync --group dev\n        else\n            echo \"Error: 'uv' command not found.\"\n            echo \"Please install dependencies manually:\"\n            echo \"  1. In PowerShell: uv sync --group dev\"\n            echo \"  2. Or install uv in WSL: curl -LsSf https://astral.sh/uv/install.sh | sh\"\n            exit 1\n        fi\n    else\n        echo \"Error: 'uv' command not found. Please install it first:\"\n        echo \"  curl -LsSf https://astral.sh/uv/install.sh | sh\"\n        exit 1\n    fi\n\n    if [ ! -f \"$VENV_PYINSTALLER\" ]; then\n        echo \"Error: Failed to install PyInstaller in .venv\"\n        echo \"Expected location: $VENV_PYINSTALLER\"\n        exit 1\n    fi\nfi\n\necho \"Using Python: $VENV_PYTHON\"\necho \"Using PyInstaller: $VENV_PYINSTALLER\"\n\n# Verify critical dependencies are available in .venv\necho \"Verifying dependencies in .venv...\"\n\"$VENV_PYTHON\" -c \"import fastapi, uvicorn, pydantic; print('All critical dependencies found')\" || {\n    echo \"Error: Missing dependencies in .venv. Please run 'uv sync --group dev' first.\"\n    exit 1\n}\n\n# Clean previous build\nif [ -d \"$DIST_DIR\" ]; then\n    echo \"Cleaning previous build...\"\n    rm -rf \"$DIST_DIR\"\nfi\n\n# Create dist directory\nmkdir -p \"$DIST_DIR\"\n\n# Change to project root directory\ncd \"$PROJECT_ROOT\"\n\n# Run PyInstaller using .venv Python\necho \"Running PyInstaller...\"\n# Change to lifetrace directory to run PyInstaller (so paths in spec file work correctly)\ncd \"$LIFETRACE_DIR\"\n# Use .venv Python explicitly to ensure all dependencies are from .venv\n\"$VENV_PYTHON\" -m PyInstaller --clean --noconfirm pyinstaller.spec\n\n# Copy the built executable to dist-backend\n# PyInstaller creates a directory with the same name as the spec file target\n# PyInstaller runs from LIFETRACE_DIR, so dist is created there\nBUILD_DIR=\"$LIFETRACE_DIR/dist/lifetrace\"\nif [ -d \"$BUILD_DIR\" ]; then\n    echo \"Copying build output to $DIST_DIR...\"\n    cp -r \"$BUILD_DIR\"/* \"$DIST_DIR/\"\n\n    # Copy config and models from _internal to app root (same level as _internal)\n    # So in packaged environment, paths are backend/config/ and backend/models/\n    if [ -d \"$DIST_DIR/_internal/config\" ]; then\n        echo \"Copying config files to app root...\"\n        mkdir -p \"$DIST_DIR/config\"\n        cp -r \"$DIST_DIR/_internal/config\"/* \"$DIST_DIR/config/\" 2>/dev/null || true\n    fi\n\n    if [ -d \"$DIST_DIR/_internal/models\" ]; then\n        echo \"Copying model files to app root...\"\n        mkdir -p \"$DIST_DIR/models\"\n        cp -r \"$DIST_DIR/_internal/models\"/* \"$DIST_DIR/models/\" 2>/dev/null || true\n    fi\n\n    echo \"Backend build complete! Output: $DIST_DIR\"\n    if [ \"$PLATFORM\" = \"windows\" ]; then\n        echo \"Backend executable location: $DIST_DIR/lifetrace.exe\"\n    else\n        echo \"Backend executable location: $DIST_DIR/lifetrace\"\n    fi\n    echo \"Config directory: $DIST_DIR/config\"\n    echo \"Models directory: $DIST_DIR/models\"\nelse\n    echo \"Error: Build directory not found: $BUILD_DIR\"\n    echo \"Available directories in dist:\"\n    ls -la \"$PROJECT_ROOT/dist\" 2>/dev/null || echo \"dist directory does not exist\"\n    exit 1\nfi\n"
  },
  {
    "path": "lifetrace/scripts/check_code_lines.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nCheck effective Python code lines (excluding blank lines and comments).\nFiles over the limit are reported and the script exits non-zero.\n\nUsage:\n    # Scan the whole directory (standalone)\n    python check_code_lines.py [--include dirs] [--exclude dirs] [--max lines]\n\n    # Check specific files (pre-commit mode)\n    python check_code_lines.py [options] file1.py file2.py ...\n\nExamples:\n    # Scan the entire lifetrace directory\n    python check_code_lines.py --include lifetrace --exclude lifetrace/__pycache__,lifetrace/dist --max 500\n\n    # Check specific files (pre-commit passes staged files)\n    python check_code_lines.py lifetrace/routers/chat.py lifetrace/services/todo.py\n\"\"\"\n\nimport argparse\nimport sys\nfrom pathlib import Path\n\n# Default configuration\nDEFAULT_INCLUDE = [\"lifetrace\"]\nDEFAULT_EXCLUDE = [\n    \"lifetrace/__pycache__\",\n    \"lifetrace/dist\",\n    \"lifetrace/migrations/versions\",\n]\nDEFAULT_MAX_LINES = 500\n\n\ndef parse_args() -> argparse.Namespace:\n    \"\"\"Parse command-line arguments.\"\"\"\n    parser = argparse.ArgumentParser(\n        description=\"Check effective Python code lines (excluding blank lines and comments).\"\n    )\n    parser.add_argument(\n        \"files\",\n        nargs=\"*\",\n        help=\"Files to check (if omitted, scan the entire directory).\",\n    )\n    parser.add_argument(\n        \"--include\",\n        type=str,\n        default=\",\".join(DEFAULT_INCLUDE),\n        help=(\n            f\"Comma-separated directory prefixes to include (default: {','.join(DEFAULT_INCLUDE)})\"\n        ),\n    )\n    parser.add_argument(\n        \"--exclude\",\n        type=str,\n        default=\",\".join(DEFAULT_EXCLUDE),\n        help=(\n            f\"Comma-separated directory prefixes to exclude (default: {','.join(DEFAULT_EXCLUDE)})\"\n        ),\n    )\n    parser.add_argument(\n        \"--max\",\n        type=int,\n        default=DEFAULT_MAX_LINES,\n        help=f\"Maximum allowed code lines (default: {DEFAULT_MAX_LINES}).\",\n    )\n    return parser.parse_args()\n\n\ndef count_code_lines(file_path: Path) -> int:\n    \"\"\"\n    Count effective code lines (excluding blank lines and comment-only lines).\n\n    Rules:\n    - Blank lines (strip() == \"\"): not counted\n    - Lines starting with \"#\": not counted\n    - All other lines: counted\n    \"\"\"\n    code_lines = 0\n    try:\n        with open(file_path, encoding=\"utf-8\") as f:\n            for line in f:\n                stripped = line.strip()\n                # Skip blank lines\n                if not stripped:\n                    continue\n                # Skip comment-only lines\n                if stripped.startswith(\"#\"):\n                    continue\n                # Counted line\n                code_lines += 1\n    except (OSError, UnicodeDecodeError) as e:\n        print(f\"Warning: failed to read file {file_path}: {e}\", file=sys.stderr)\n        return 0\n    return code_lines\n\n\ndef should_check_file(\n    file_path: Path, root_dir: Path, include_dirs: list[str], exclude_dirs: list[str]\n) -> bool:\n    \"\"\"\n    Determine whether a file should be checked.\n\n    Args:\n        file_path: File path\n        root_dir: Project root directory\n        include_dirs: Directory prefixes to include\n        exclude_dirs: Directory prefixes to exclude\n\n    Returns:\n        True if the file should be checked; otherwise False\n    \"\"\"\n    # Get path relative to the project root\n    try:\n        rel_path = file_path.relative_to(root_dir)\n    except ValueError:\n        return False\n\n    # Normalize to forward slashes to avoid Windows separator issues\n    rel_path_str = str(rel_path).replace(\"\\\\\", \"/\")\n\n    # Check include directories\n    in_include = any(rel_path_str.startswith(inc.replace(\"\\\\\", \"/\")) for inc in include_dirs)\n    if not in_include:\n        return False\n\n    # Check exclude directories\n    in_exclude = any(rel_path_str.startswith(exc.replace(\"\\\\\", \"/\")) for exc in exclude_dirs)\n    return not in_exclude\n\n\ndef get_files_to_check(\n    args: argparse.Namespace, root_dir: Path, include_dirs: list[str], exclude_dirs: list[str]\n) -> list[Path]:\n    \"\"\"\n    Get the list of files to check.\n\n    Args:\n        args: Parsed command-line arguments\n        root_dir: Project root directory\n        include_dirs: Directory prefixes to include\n        exclude_dirs: Directory prefixes to exclude\n\n    Returns:\n        List of file paths to check\n    \"\"\"\n    files_to_check: list[Path] = []\n\n    if args.files:\n        # Mode 1: Check specified files (pre-commit mode)\n        for file_str in args.files:\n            file_path = Path(file_str).resolve()\n            # Only check .py files\n            if file_path.suffix != \".py\":\n                continue\n            # Skip missing files\n            if not file_path.exists():\n                continue\n            # Check include/exclude filters\n            if should_check_file(file_path, root_dir, include_dirs, exclude_dirs):\n                files_to_check.append(file_path)\n    else:\n        # Mode 2: Scan entire directory (standalone mode)\n        for py_file in root_dir.rglob(\"*.py\"):\n            if should_check_file(py_file, root_dir, include_dirs, exclude_dirs):\n                files_to_check.append(py_file)\n\n    return files_to_check\n\n\ndef main() -> int:\n    \"\"\"Main entrypoint.\"\"\"\n    args = parse_args()\n\n    # Parse arguments\n    include_dirs = [d.strip() for d in args.include.split(\",\") if d.strip()]\n    exclude_dirs = [d.strip() for d in args.exclude.split(\",\") if d.strip()]\n    max_lines = args.max\n\n    # Project root (script lives in lifetrace/scripts/)\n    script_dir = Path(__file__).resolve().parent\n    root_dir = script_dir.parent.parent\n\n    # Collect files to check\n    files_to_check = get_files_to_check(args, root_dir, include_dirs, exclude_dirs)\n\n    if not files_to_check:\n        if args.files:\n            # No matching files in pre-commit mode\n            return 0\n        else:\n            print(\"No Python files to check.\")\n            return 0\n\n    # Collect violations\n    violations: list[tuple[str, int]] = []\n\n    for py_file in files_to_check:\n        code_lines = count_code_lines(py_file)\n        if code_lines > max_lines:\n            rel_path = py_file.relative_to(root_dir)\n            violations.append((str(rel_path), code_lines))\n\n    # Output results\n    if violations:\n        print(f\"[ERROR] The following files exceed {max_lines} code lines:\")\n        for path, lines in sorted(violations):\n            print(f\"  {path} -> {lines} lines\")\n        return 1\n    else:\n        mode_desc = f\"Checked {len(files_to_check)} files, \" if args.files else \"\"\n        print(f\"[OK] {mode_desc}all Python files are within {max_lines} code lines\")\n        return 0\n\n\nif __name__ == \"__main__\":\n    sys.exit(main())\n"
  },
  {
    "path": "lifetrace/scripts/fix_audio_recordings_table.py",
    "content": "#!/usr/bin/env python3\n\"\"\"修复 audio_recordings 表，添加所有缺失的列\"\"\"\n\nimport sqlite3\nfrom pathlib import Path\n\n\ndef fix_audio_recordings_table():\n    \"\"\"修复 audio_recordings 表结构\"\"\"\n    # 直接使用相对路径（相对于脚本位置）\n    script_dir = Path(__file__).parent.parent\n    db_path = script_dir / \"data\" / \"lifetrace.db\"\n\n    if not Path(db_path).exists():\n        print(f\"Database file not found: {db_path}\")\n        return\n\n    conn = sqlite3.connect(db_path)\n    cursor = conn.cursor()\n\n    try:\n        # 检查表是否存在\n        cursor.execute(\n            \"SELECT name FROM sqlite_master WHERE type='table' AND name='audio_recordings'\"\n        )\n        if not cursor.fetchone():\n            print(\"Table audio_recordings does not exist, creating...\")\n            # 创建完整的表\n            cursor.execute(\"\"\"\n                CREATE TABLE audio_recordings (\n                    id INTEGER PRIMARY KEY,\n                    created_at DATETIME NOT NULL,\n                    updated_at DATETIME NOT NULL,\n                    deleted_at DATETIME,\n                    file_path VARCHAR(500) NOT NULL,\n                    file_size INTEGER NOT NULL,\n                    duration REAL NOT NULL,\n                    start_time DATETIME NOT NULL,\n                    end_time DATETIME,\n                    status VARCHAR(20) NOT NULL DEFAULT 'recording',\n                    is_24x7 BOOLEAN NOT NULL DEFAULT 0,\n                    transcription_status VARCHAR(20) NOT NULL DEFAULT 'pending'\n                )\n            \"\"\")\n            print(\"[OK] Table created successfully!\")\n            conn.commit()\n            return\n\n        # 表存在，检查并添加缺失的列\n        cursor.execute(\"PRAGMA table_info(audio_recordings)\")\n        existing_columns = {row[1]: row for row in cursor.fetchall()}\n\n        # 需要添加的列\n        columns_to_add = {\n            \"file_path\": \"VARCHAR(500)\",\n            \"file_size\": \"INTEGER\",\n            \"duration\": \"REAL\",\n            \"start_time\": \"DATETIME\",\n            \"end_time\": \"DATETIME\",\n            \"status\": \"VARCHAR(20)\",\n            \"is_24x7\": \"BOOLEAN\",\n            \"is_transcribed\": \"BOOLEAN\",\n            \"is_extracted\": \"BOOLEAN\",\n            \"is_summarized\": \"BOOLEAN\",\n            \"is_full_audio\": \"BOOLEAN\",\n            \"is_segment_audio\": \"BOOLEAN\",\n            \"transcription_status\": \"VARCHAR(20)\",\n            \"created_at\": \"DATETIME\",\n            \"updated_at\": \"DATETIME\",\n            \"deleted_at\": \"DATETIME\",\n        }\n\n        added_columns = []\n        for col_name, col_type in columns_to_add.items():\n            if col_name not in existing_columns:\n                try:\n                    cursor.execute(f\"ALTER TABLE audio_recordings ADD COLUMN {col_name} {col_type}\")\n                    added_columns.append(col_name)\n                    print(f\"Added column: {col_name}\")\n                except sqlite3.OperationalError as e:\n                    print(f\"Failed to add column {col_name}: {e}\")\n\n        # 设置默认值\n        if added_columns:\n            cursor.execute(\"UPDATE audio_recordings SET file_path = '' WHERE file_path IS NULL\")\n            cursor.execute(\"UPDATE audio_recordings SET file_size = 0 WHERE file_size IS NULL\")\n            cursor.execute(\"UPDATE audio_recordings SET duration = 0 WHERE duration IS NULL\")\n            cursor.execute(\"UPDATE audio_recordings SET status = 'recording' WHERE status IS NULL\")\n            cursor.execute(\"UPDATE audio_recordings SET is_24x7 = 0 WHERE is_24x7 IS NULL\")\n            cursor.execute(\n                \"UPDATE audio_recordings SET is_transcribed = 0 WHERE is_transcribed IS NULL\"\n            )\n            cursor.execute(\n                \"UPDATE audio_recordings SET is_extracted = 0 WHERE is_extracted IS NULL\"\n            )\n            cursor.execute(\n                \"UPDATE audio_recordings SET is_summarized = 0 WHERE is_summarized IS NULL\"\n            )\n            cursor.execute(\n                \"UPDATE audio_recordings SET is_full_audio = 0 WHERE is_full_audio IS NULL\"\n            )\n            cursor.execute(\n                \"UPDATE audio_recordings SET is_segment_audio = 0 WHERE is_segment_audio IS NULL\"\n            )\n            cursor.execute(\n                \"UPDATE audio_recordings SET transcription_status = 'pending' WHERE transcription_status IS NULL\"\n            )\n\n        conn.commit()\n\n        if added_columns:\n            print(f\"[OK] Added {len(added_columns)} columns: {', '.join(added_columns)}\")\n        else:\n            print(\"[OK] All columns already exist, no changes needed\")\n\n    except Exception as e:\n        conn.rollback()\n        print(f\"[ERROR] Failed to fix table: {e}\")\n        raise\n    finally:\n        conn.close()\n\n\nif __name__ == \"__main__\":\n    fix_audio_recordings_table()\n"
  },
  {
    "path": "lifetrace/scripts/fix_transcriptions_table.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\n修复 transcriptions 表缺失问题。\n\n用法：\n    python lifetrace/scripts/fix_transcriptions_table.py\n\"\"\"\n\nimport sqlite3\nfrom pathlib import Path\n\n\ndef ensure_transcriptions_table(db_path: Path) -> None:\n    if not db_path.exists():\n        print(f\"数据库不存在：{db_path}\")\n        return\n\n    conn = sqlite3.connect(db_path)\n    cur = conn.cursor()\n    try:\n        cur.execute(\"SELECT name FROM sqlite_master WHERE type='table' AND name='transcriptions'\")\n        exists = cur.fetchone()\n        if exists:\n            print(\"[OK] transcriptions 表已存在，跳过创建\")\n            return\n\n        print(\"[INFO] transcriptions 表不存在，开始创建...\")\n        cur.execute(\n            \"\"\"\n            CREATE TABLE transcriptions (\n                id INTEGER PRIMARY KEY,\n                created_at DATETIME NOT NULL,\n                updated_at DATETIME NOT NULL,\n                deleted_at DATETIME,\n                audio_recording_id INTEGER NOT NULL,\n                original_text TEXT,\n                optimized_text TEXT,\n                extraction_status VARCHAR(20) NOT NULL DEFAULT 'pending',\n                extracted_todos TEXT,\n                extracted_schedules TEXT\n            )\n            \"\"\"\n        )\n        conn.commit()\n        print(\"[OK] transcriptions 表已创建\")\n    finally:\n        conn.close()\n\n\nif __name__ == \"__main__\":\n    db_path = Path(__file__).parent.parent / \"data\" / \"lifetrace.db\"\n    ensure_transcriptions_table(db_path)\n"
  },
  {
    "path": "lifetrace/scripts/start_backend.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nBackend startup wrapper script for LifeTrace\nHandles data directory setup and config initialization before starting the FastAPI server\n\"\"\"\n\nimport argparse\nimport contextlib\nimport importlib\nimport os\nimport shutil\nimport sys\nimport traceback\nfrom pathlib import Path\n\n# Handle PyInstaller bundled application\nif getattr(sys, \"frozen\", False):\n    # PyInstaller bundled - use _MEIPASS for resource path\n    # In one-folder bundle, _MEIPASS points to _internal directory\n    bundle_path = getattr(sys, \"_MEIPASS\", None)\n    if bundle_path:\n        # Add _internal to path where lifetrace modules are located\n        sys.path.insert(0, bundle_path)\n    else:\n        # Fallback: try to find _internal directory relative to executable\n        bundle_dir = Path(sys.executable).parent\n        internal_dir = bundle_dir / \"_internal\"\n        if internal_dir.exists():\n            sys.path.insert(0, str(internal_dir))\n        else:\n            # Last resort: use bundle directory\n            sys.path.insert(0, str(bundle_dir))\nelse:\n    # Development mode - add parent directory to path\n    sys.path.insert(0, str(Path(__file__).parent.parent.parent))\n\n# Import loguru first to ensure PyInstaller detects it\nwith contextlib.suppress(ImportError):\n    import loguru  # noqa: F401\n\nfrom lifetrace.util.base_paths import get_config_dir\nfrom lifetrace.util.logging_config import get_logger\n\nlogger = get_logger()\n\n\ndef setup_data_directory(data_dir: str) -> None:\n    \"\"\"Set up the data directory structure and initialize config files if needed\"\"\"\n    data_path = Path(data_dir)\n\n    # Create directory structure\n    config_dir = data_path / \"config\"\n    data_subdir = data_path / \"data\"\n    logs_dir = data_path / \"logs\"\n\n    for directory in [config_dir, data_subdir, logs_dir]:\n        directory.mkdir(parents=True, exist_ok=True)\n        logger.info(f\"Ensured directory exists: {directory}\")\n\n    # Copy config files if they don't exist\n    # Get the source config directory (from PyInstaller bundle or development)\n    if getattr(sys, \"frozen\", False):\n        # PyInstaller bundled - in one-folder bundle, files are in _internal/\n        # The executable is at bundle_dir/lifetrace, config is at bundle_dir/_internal/config\n        bundle_dir = Path(sys.executable).parent\n        # Try _internal/config first (one-folder bundle), then config (if files are at root)\n        potential_config_dirs = [\n            bundle_dir / \"_internal\" / \"config\",\n            bundle_dir / \"config\",\n        ]\n        source_config_dir = None\n        for potential_config_dir in potential_config_dirs:\n            if (\n                potential_config_dir.exists()\n                and (potential_config_dir / \"default_config.yaml\").exists()\n            ):\n                source_config_dir = potential_config_dir\n                logger.info(f\"Found config directory: {source_config_dir}\")\n                break\n        if source_config_dir is None:\n            logger.warning(\n                f\"Could not find config directory in bundle. Tried: {potential_config_dirs}\"\n            )\n            # Fallback to bundle_dir/config\n            source_config_dir = bundle_dir / \"config\"\n    else:\n        # Development mode\n        source_config_dir = get_config_dir()\n\n    # Copy default config files if they don't exist in data directory\n    config_files = [\"default_config.yaml\", \"prompt.yaml\", \"rapidocr_config.yaml\"]\n    for config_file in config_files:\n        source_file = source_config_dir / config_file\n        dest_file = config_dir / config_file\n\n        if source_file.exists() and not dest_file.exists():\n            shutil.copy2(source_file, dest_file)\n            logger.info(f\"Copied config file: {config_file}\")\n        elif not source_file.exists():\n            logger.warning(f\"Source config file not found: {source_file}\")\n\n    # Initialize config.yaml from default_config.yaml if it doesn't exist\n    default_config = config_dir / \"default_config.yaml\"\n    config_yaml = config_dir / \"config.yaml\"\n\n    if default_config.exists() and not config_yaml.exists():\n        shutil.copy2(default_config, config_yaml)\n        logger.info(\"Initialized config.yaml from default_config.yaml\")\n\n\ndef main():\n    \"\"\"Main entry point\"\"\"\n    parser = argparse.ArgumentParser(description=\"LifeTrace Backend Server\")\n    parser.add_argument(\n        \"--data-dir\",\n        type=str,\n        help=\"Data directory path (default: current directory)\",\n        default=None,\n    )\n    parser.add_argument(\n        \"--port\",\n        type=int,\n        help=\"Server port (default: 8001)\",\n        default=8001,\n    )\n    parser.add_argument(\n        \"--host\",\n        type=str,\n        help=\"Server host (default: 127.0.0.1)\",\n        default=\"127.0.0.1\",\n    )\n    parser.add_argument(\n        \"--mode\",\n        type=str,\n        choices=[\"dev\", \"build\"],\n        default=\"dev\",\n        help=\"Server mode: dev (development) or build (packaged app)\",\n    )\n\n    args = parser.parse_args()\n\n    # Set data directory environment variable if provided\n    if args.data_dir:\n        os.environ[\"LIFETRACE_DATA_DIR\"] = args.data_dir\n        setup_data_directory(args.data_dir)\n        logger.info(f\"Using data directory: {args.data_dir}\")\n    else:\n        # Use current directory as fallback\n        current_dir = os.getcwd()\n        logger.info(f\"No data directory specified, using current directory: {current_dir}\")\n\n    # Import and start the server\n    # The config module will read LIFETRACE_DATA_DIR environment variable\n    # Note: In PyInstaller bundle, lifetrace modules should be in sys._MEIPASS\n    try:\n        uvicorn = importlib.import_module(\"uvicorn\")\n        health_module = importlib.import_module(\"lifetrace.routers.health\")\n        server_module = importlib.import_module(\"lifetrace.server\")\n        settings_module = importlib.import_module(\"lifetrace.util.settings\")\n\n        set_server_mode = health_module.set_server_mode\n        app = server_module.app\n        settings = settings_module.settings\n\n        # Set server mode for health check endpoint\n        set_server_mode(args.mode)\n        logger.info(f\"Server mode: {args.mode}\")\n    except ImportError as e:\n        # If import fails, log the error with path information\n        error_info = f\"\"\"\nImport Error: {e}\nsys.path: {sys.path}\nsys._MEIPASS: {getattr(sys, \"_MEIPASS\", \"Not set\")}\nsys.executable: {sys.executable}\nsys.frozen: {getattr(sys, \"frozen\", False)}\n\"\"\"\n        print(error_info, file=sys.stderr)\n        print(traceback.format_exc(), file=sys.stderr)\n        if logger:\n            logger.error(f\"Failed to import lifetrace modules: {error_info}\")\n        raise\n\n    # Override server config if provided via command line\n    if args.port:\n        settings.set(\"server.port\", args.port)\n    if args.host:\n        settings.set(\"server.host\", args.host)\n\n    server_host = settings.server.host\n    server_port = settings.server.port\n    server_debug = settings.server.debug\n\n    logger.info(\"Starting LifeTrace backend server\")\n    logger.info(f\"Server URL: http://{server_host}:{server_port}\")\n    logger.info(f\"Debug mode: {'enabled' if server_debug else 'disabled'}\")\n    logger.info(f\"Data directory: {os.environ.get('LIFETRACE_DATA_DIR', 'default')}\")\n\n    # Start the server\n    uvicorn.run(\n        app,\n        host=server_host,\n        port=server_port,\n        reload=server_debug,\n        access_log=server_debug,\n        log_level=\"debug\" if server_debug else \"info\",\n    )\n\n\nif __name__ == \"__main__\":\n    try:\n        main()\n    except Exception as e:\n        # Ensure errors are logged and visible\n        error_msg = f\"Fatal error in backend startup: {e}\\n{traceback.format_exc()}\"\n        print(error_msg, file=sys.stderr)\n        if logger:\n            logger.error(error_msg)\n        sys.exit(1)\n"
  },
  {
    "path": "lifetrace/server.py",
    "content": "import argparse\nimport asyncio\nimport socket\nfrom contextlib import asynccontextmanager, suppress\n\nimport uvicorn\nfrom fastapi import FastAPI\nfrom fastapi.middleware.cors import CORSMiddleware\n\nfrom lifetrace.core.module_registry import (\n    MODULES,\n    get_enabled_module_ids,\n    get_module_states,\n    log_module_summary,\n    register_modules,\n)\nfrom lifetrace.jobs.job_manager import get_job_manager\nfrom lifetrace.services.config_service import is_llm_configured\nfrom lifetrace.util.base_paths import get_user_logs_dir\nfrom lifetrace.util.logging_config import get_logger, setup_logging\nfrom lifetrace.util.settings import settings\n\n# 使用处理后的日志路径配置\nlogging_config = settings.get(\"logging\").copy()\nlogging_config[\"log_path\"] = str(get_user_logs_dir()) + \"/\"\nsetup_logging(logging_config)\n\nlogger = get_logger()\n\nPRIORITY_MODULES = (\"health\", \"config\", \"system\", \"todo\")\n\n\n@asynccontextmanager\nasync def lifespan(app: FastAPI):\n    \"\"\"应用生命周期管理\"\"\"\n    # 启动逻辑\n    logger.info(\"Web服务器启动\")\n\n    # 初始化任务管理器\n    manager = get_job_manager()\n    app.state.job_manager = manager\n    background_tasks = []\n    app.state.background_tasks = background_tasks\n\n    # 延迟启动后台任务，避免阻塞启动流程\n    background_tasks.append(asyncio.create_task(_start_job_manager_async(app)))\n\n    # 延迟加载非优先模块\n    background_tasks.append(asyncio.create_task(_register_deferred_modules(app)))\n\n    # 延迟验证 LLM 连接\n    background_tasks.append(asyncio.create_task(_verify_llm_connection_async()))\n\n    yield\n\n    # 关闭逻辑\n    logger.error(\"Web服务器关闭，正在停止后台服务\")\n\n    # 停止后台任务\n    for task in getattr(app.state, \"background_tasks\", []):\n        task.cancel()\n        with suppress(asyncio.CancelledError):\n            await task\n\n    # 停止所有后台任务\n    manager = getattr(app.state, \"job_manager\", None)\n    if manager:\n        manager.stop_all()\n\n\napp = FastAPI(\n    title=\"FreeTodo API\",\n    description=\"FreeTodo API (part of FreeU Project)\",\n    version=\"0.1.2\",\n    lifespan=lifespan,\n)\n\n\ndef get_cors_origins() -> list[str]:\n    \"\"\"\n    生成 CORS 允许的来源列表，支持动态端口。\n\n    为了支持 Build 版和开发版同时运行，需要允许端口范围：\n    - 前端端口范围：3000-3200（包括 3200，Build 版默认端口）\n    - 后端端口范围：8000-8200（包括 8200，Build 版默认端口）\n    \"\"\"\n    origins = []\n    # 前端端口范围 3000-3200（包括 3200）\n    for port in range(3000, 3201):\n        origins.extend([f\"http://localhost:{port}\", f\"http://127.0.0.1:{port}\"])\n    # 后端端口范围 8000-8200（包括 8200）\n    for port in range(8000, 8201):\n        origins.extend([f\"http://localhost:{port}\", f\"http://127.0.0.1:{port}\"])\n    return origins\n\n\napp.add_middleware(\n    CORSMiddleware,\n    allow_origins=get_cors_origins(),\n    allow_credentials=True,\n    allow_methods=[\"*\"],\n    allow_headers=[\"*\"],\n    expose_headers=[\"X-Session-Id\"],  # 允许前端读取会话ID，支持多轮对话\n)\n\n# 向量服务、RAG服务和OCR处理器均改为延迟加载\n# 通过 lifetrace.core.dependencies 模块按需获取\n\n# 全局配置状态标志\nllm_configured = is_llm_configured()\nconfig_status = \"已配置\" if llm_configured else \"未配置，需要引导配置\"\nlogger.info(f\"LLM配置状态: {config_status}\")\n\n\ndef _order_modules(module_ids: list[str]) -> list[str]:\n    module_id_set = set(module_ids)\n    return [module.id for module in MODULES if module.id in module_id_set]\n\n\ndef _register_priority_modules(app: FastAPI) -> None:\n    states = get_module_states()\n    log_module_summary(states)\n    enabled_ids = get_enabled_module_ids(states)\n\n    priority_ids = _order_modules([mid for mid in enabled_ids if mid in PRIORITY_MODULES])\n    deferred_ids = _order_modules([mid for mid in enabled_ids if mid not in PRIORITY_MODULES])\n\n    registered = register_modules(app, priority_ids, states=states)\n    app.state.registered_modules = set(registered)\n    app.state.deferred_modules = [\n        mid for mid in deferred_ids if mid not in app.state.registered_modules\n    ]\n\n    logger.info(f\"快速启动：优先加载模块: {', '.join(priority_ids) or 'none'}\")\n    if app.state.deferred_modules:\n        logger.info(f\"延迟加载模块: {', '.join(app.state.deferred_modules)}\")\n\n\nasync def _register_deferred_modules(app: FastAPI) -> None:\n    deferred_modules = getattr(app.state, \"deferred_modules\", [])\n    if not deferred_modules:\n        return\n\n    logger.info(f\"开始延迟加载 {len(deferred_modules)} 个模块\")\n    for module_id in deferred_modules:\n        registered = register_modules(app, [module_id])\n        if registered:\n            app.state.registered_modules.update(registered)\n        await asyncio.sleep(0)\n    logger.info(\"延迟模块加载完成\")\n\n\nasync def _start_job_manager_async(app: FastAPI) -> None:\n    manager = getattr(app.state, \"job_manager\", None)\n    if not manager:\n        return\n    await asyncio.to_thread(manager.start_all)\n\n\nasync def _verify_llm_connection_async() -> None:\n    try:\n        from lifetrace.routers.config import (  # noqa: PLC0415\n            verify_llm_connection_on_startup,\n        )\n    except Exception as exc:\n        logger.debug(f\"LLM 验证初始化跳过: {exc}\")\n        return\n    await asyncio.to_thread(verify_llm_connection_on_startup)\n\n\n# 注册按配置启用的路由\n_register_priority_modules(app)\n\n\ndef find_available_port(host: str, start_port: int, max_attempts: int = 100) -> int:\n    \"\"\"\n    查找可用端口。\n\n    从 start_port 开始，依次尝试直到找到可用端口。\n    支持 Build 版和开发版同时运行，自动避免端口冲突。\n\n    Args:\n        host: 绑定的主机地址\n        start_port: 起始端口号\n        max_attempts: 最大尝试次数\n\n    Returns:\n        可用的端口号\n\n    Raises:\n        RuntimeError: 如果在指定范围内找不到可用端口\n    \"\"\"\n    for offset in range(max_attempts):\n        port = start_port + offset\n        try:\n            with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:\n                s.bind((host, port))\n                if offset > 0:\n                    logger.info(f\"端口 {start_port} 已被占用，使用端口 {port}\")\n                return port\n        except OSError:\n            continue\n\n    raise RuntimeError(f\"无法在 {start_port}-{start_port + max_attempts} 范围内找到可用端口\")\n\n\ndef parse_args():\n    \"\"\"解析命令行参数\"\"\"\n    parser = argparse.ArgumentParser(description=\"LifeTrace 后端服务器\")\n    parser.add_argument(\n        \"--port\",\n        type=int,\n        default=None,\n        help=\"服务器端口号（默认从配置文件读取）\",\n    )\n    parser.add_argument(\n        \"--data-dir\",\n        type=str,\n        default=None,\n        help=\"数据目录路径\",\n    )\n    parser.add_argument(\n        \"--mode\",\n        type=str,\n        choices=[\"dev\", \"build\"],\n        default=\"dev\",\n        help=\"服务器模式：dev（开发模式）或 build（打包模式）\",\n    )\n    return parser.parse_args()\n\n\nif __name__ == \"__main__\":\n    args = parse_args()\n\n    # 设置服务器模式\n    from lifetrace.routers.health import set_server_mode\n\n    set_server_mode(args.mode)\n\n    server_host = settings.server.host\n    server_port = args.port if args.port else settings.server.port\n    server_debug = settings.server.debug\n\n    # 动态端口分配：如果默认端口被占用，自动尝试下一个可用端口\n    try:\n        actual_port = find_available_port(server_host, server_port)\n    except RuntimeError as e:\n        logger.error(f\"端口分配失败: {e}\")\n        raise\n\n    logger.info(f\"启动服务器: http://{server_host}:{actual_port}\")\n    logger.info(f\"服务器模式: {args.mode}\")\n    logger.info(f\"调试模式: {'开启' if server_debug else '关闭'}\")\n    if actual_port != server_port:\n        logger.info(f\"注意: 原始端口 {server_port} 已被占用，已自动切换到 {actual_port}\")\n\n    uvicorn.run(\n        \"lifetrace.server:app\",\n        host=server_host,\n        port=actual_port,\n        reload=server_debug,\n        access_log=server_debug,\n        log_level=\"debug\" if server_debug else \"info\",\n    )\n"
  },
  {
    "path": "lifetrace/services/__init__.py",
    "content": "\"\"\"服务层 - 业务逻辑服务\"\"\"\n\nfrom lifetrace.services.config_service import ConfigService\n\n__all__ = [\"ConfigService\"]\n"
  },
  {
    "path": "lifetrace/services/activity_service.py",
    "content": "\"\"\"Activity 业务逻辑层\n\n处理 Activity 相关的业务逻辑，与数据访问层解耦。\n\"\"\"\n\nimport importlib\nfrom datetime import datetime\n\nfrom fastapi import HTTPException\n\nfrom lifetrace.repositories.interfaces import IActivityRepository, IEventRepository\nfrom lifetrace.schemas.activity import (\n    ActivityEventsResponse,\n    ActivityListResponse,\n    ActivityResponse,\n    ManualActivityCreateRequest,\n    ManualActivityCreateResponse,\n)\nfrom lifetrace.util.logging_config import get_logger\n\nlogger = get_logger()\n\n\nclass ActivityService:\n    \"\"\"Activity 业务逻辑层\"\"\"\n\n    def __init__(\n        self,\n        activity_repository: IActivityRepository,\n        event_repository: IEventRepository,\n    ):\n        self.activity_repo = activity_repository\n        self.event_repo = event_repository\n\n    def list_activities(\n        self,\n        limit: int,\n        offset: int,\n        start_date: datetime | None,\n        end_date: datetime | None,\n    ) -> ActivityListResponse:\n        \"\"\"获取活动列表\"\"\"\n        logger.info(\n            f\"获取活动列表 - 参数: limit={limit}, offset={offset}, \"\n            f\"start_date={start_date}, end_date={end_date}\"\n        )\n\n        activities = self.activity_repo.get_activities(\n            limit=limit,\n            offset=offset,\n            start_date=start_date,\n            end_date=end_date,\n        )\n        total_count = self.activity_repo.count_activities(\n            start_date=start_date,\n            end_date=end_date,\n        )\n\n        logger.info(\n            f\"获取活动列表 - 结果: activities_count={len(activities)}, total_count={total_count}\"\n        )\n\n        return ActivityListResponse(\n            activities=[ActivityResponse(**a) for a in activities],\n            total_count=total_count,\n        )\n\n    def get_activity_events(self, activity_id: int) -> ActivityEventsResponse:\n        \"\"\"获取指定活动关联的事件ID列表\"\"\"\n        logger.info(f\"获取活动 {activity_id} 的事件列表\")\n        event_ids = self.activity_repo.get_activity_events(activity_id)\n        return ActivityEventsResponse(event_ids=event_ids)\n\n    def create_activity_manual(\n        self, request: ManualActivityCreateRequest\n    ) -> ManualActivityCreateResponse:\n        \"\"\"手动聚合指定事件集合为活动\"\"\"\n        self._validate_event_ids(request.event_ids)\n        logger.info(f\"手动聚合活动 - 事件ID列表: {request.event_ids}\")\n\n        events = self._get_and_validate_events(request.event_ids)\n        self._validate_events_ended(events)\n        self._validate_events_not_linked(events)\n\n        activity_start_time, activity_end_time = self._calculate_activity_time_range(events)\n        events_data = self._prepare_events_data(events)\n\n        created_activity = self._create_activity_with_summary(\n            events_data, activity_start_time, activity_end_time, request.event_ids\n        )\n\n        return ManualActivityCreateResponse(**created_activity)\n\n    def _validate_event_ids(self, event_ids: list[int]) -> None:\n        \"\"\"验证事件ID列表不为空\"\"\"\n        if not event_ids:\n            raise HTTPException(status_code=400, detail=\"事件ID列表不能为空\")\n\n    def _get_and_validate_events(self, event_ids: list[int]) -> list[dict]:\n        \"\"\"批量查询事件详情并验证它们存在\"\"\"\n        events = self.event_repo.get_events_by_ids(event_ids)\n        if not events:\n            raise HTTPException(status_code=404, detail=\"未找到任何事件\")\n\n        found_event_ids = {e[\"id\"] for e in events}\n        missing_event_ids = set(event_ids) - found_event_ids\n        if missing_event_ids:\n            raise HTTPException(\n                status_code=404, detail=f\"以下事件不存在: {sorted(missing_event_ids)}\"\n            )\n\n        return events\n\n    def _validate_events_ended(self, events: list[dict]) -> None:\n        \"\"\"验证所有事件都已结束（有end_time）\"\"\"\n        unended_events = [e for e in events if not e.get(\"end_time\")]\n        if unended_events:\n            unended_ids = [e[\"id\"] for e in unended_events]\n            raise HTTPException(\n                status_code=400,\n                detail=f\"以下事件尚未结束，无法聚合: {sorted(unended_ids)}\",\n            )\n\n    def _validate_events_not_linked(self, events: list[dict]) -> None:\n        \"\"\"检查是否有事件已关联到其他活动\"\"\"\n        already_linked_events = []\n        for event in events:\n            if self.activity_repo.activity_exists_for_event_id(event[\"id\"]):\n                already_linked_events.append(event[\"id\"])\n\n        if already_linked_events:\n            raise HTTPException(\n                status_code=400,\n                detail=f\"以下事件已关联到其他活动: {sorted(already_linked_events)}\",\n            )\n\n    def _calculate_activity_time_range(self, events: list[dict]) -> tuple[datetime, datetime]:\n        \"\"\"计算活动时间范围\"\"\"\n        start_times = [e[\"start_time\"] for e in events if e.get(\"start_time\")]\n        end_times = [e[\"end_time\"] for e in events if e.get(\"end_time\")]\n\n        if not start_times or not end_times:\n            raise HTTPException(status_code=400, detail=\"无法计算活动时间范围\")\n\n        return min(start_times), max(end_times)\n\n    def _prepare_events_data(self, events: list[dict]) -> list[dict]:\n        \"\"\"准备事件数据用于生成摘要\"\"\"\n        return [\n            {\n                \"ai_title\": event.get(\"ai_title\") or \"\",\n                \"ai_summary\": event.get(\"ai_summary\") or \"\",\n                \"start_time\": event.get(\"start_time\"),\n            }\n            for event in events\n        ]\n\n    def _create_activity_with_summary(\n        self,\n        events_data: list[dict],\n        activity_start_time: datetime,\n        activity_end_time: datetime,\n        event_ids: list[int],\n    ) -> dict:\n        \"\"\"生成活动摘要并创建活动\"\"\"\n        # 延迟导入避免循环依赖\n        summary_module = importlib.import_module(\"lifetrace.llm.activity_summary_service\")\n        result = summary_module.activity_summary_service.generate_activity_summary(\n            events=events_data,\n            start_time=activity_start_time,\n            end_time=activity_end_time,\n        )\n\n        if not result:\n            raise HTTPException(status_code=500, detail=\"生成活动摘要失败\")\n\n        activity_id = self.activity_repo.create_activity(\n            start_time=activity_start_time,\n            end_time=activity_end_time,\n            ai_title=result[\"title\"],\n            ai_summary=result[\"summary\"],\n            event_ids=event_ids,\n        )\n\n        if not activity_id:\n            raise HTTPException(status_code=500, detail=\"创建活动失败\")\n\n        created_activity = self.activity_repo.get_by_id(activity_id)\n        if not created_activity:\n            raise HTTPException(status_code=500, detail=\"获取创建的活动信息失败\")\n\n        logger.info(\n            f\"成功手动创建活动 {activity_id}: {result['title']}，包含 {len(event_ids)} 个事件\"\n        )\n\n        return created_activity\n"
  },
  {
    "path": "lifetrace/services/asr_client.py",
    "content": "\"\"\"阿里云Fun-ASR实时语音识别客户端\n\n参考LLM客户端的实现方式，提供单例模式的ASR客户端。\n\"\"\"\n\nimport asyncio\nimport json\nimport uuid\nfrom collections.abc import Callable\nfrom typing import Any\n\nimport websockets\nfrom websockets import protocol\nfrom websockets.exceptions import ConnectionClosed\n\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.settings import settings\n\nlogger = get_logger()\n\n\nclass ASRClient:\n    \"\"\"阿里云Fun-ASR实时语音识别客户端（单例模式）\"\"\"\n\n    _instance = None\n    _initialized = False\n\n    def __new__(cls):\n        \"\"\"实现单例模式\"\"\"\n        if cls._instance is None:\n            cls._instance = super().__new__(cls)\n        return cls._instance\n\n    def __init__(self):\n        \"\"\"初始化ASR客户端\"\"\"\n        if not ASRClient._initialized:\n            self._initialize_client()\n            ASRClient._initialized = True\n\n    def _initialize_client(self):\n        \"\"\"内部方法：初始化或重新初始化客户端\"\"\"\n        try:\n            self.api_key = settings.audio.asr.api_key\n            self.base_url = settings.audio.asr.base_url\n            self.model = settings.audio.asr.model\n            self.sample_rate = settings.audio.asr.sample_rate\n            self.format = settings.audio.asr.format\n            self.semantic_punctuation_enabled = settings.audio.asr.semantic_punctuation_enabled\n            self.max_sentence_silence = settings.audio.asr.max_sentence_silence\n            self.heartbeat = settings.audio.asr.heartbeat\n\n            invalid_values = [\"xxx\", \"YOUR_API_KEY_HERE\", \"YOUR_ASR_KEY_HERE\"]\n            if not self.api_key or self.api_key in invalid_values:\n                logger.warning(\"ASR API Key未配置或为默认占位符，ASR功能可能不可用\")\n        except Exception as e:\n            logger.error(f\"无法从配置文件读取ASR配置: {e}\")\n            self.api_key = \"YOUR_ASR_KEY_HERE\"\n            self.base_url = \"wss://dashscope.aliyuncs.com/api-ws/v1/inference/\"\n            self.model = \"fun-asr-realtime\"\n            self.sample_rate = 16000\n            self.format = \"pcm\"\n            self.semantic_punctuation_enabled = False\n            self.max_sentence_silence = 1300\n            self.heartbeat = False\n            logger.warning(\"使用硬编码默认值初始化ASR客户端\")\n\n    def reinitialize(self):\n        \"\"\"重新初始化ASR客户端（用于配置热重载）\"\"\"\n        self._initialize_client()\n        logger.info(\"ASR客户端已重新初始化\")\n\n    def _build_run_task_message(self, task_id: str) -> dict[str, Any]:\n        \"\"\"构建run-task消息\"\"\"\n        return {\n            \"header\": {\n                \"action\": \"run-task\",\n                \"task_id\": task_id,\n                \"streaming\": \"duplex\",\n            },\n            \"payload\": {\n                \"task_group\": \"audio\",\n                \"task\": \"asr\",\n                \"function\": \"recognition\",\n                \"model\": self.model,\n                \"parameters\": {\n                    \"format\": self.format,\n                    \"sample_rate\": self.sample_rate,\n                    \"semantic_punctuation_enabled\": self.semantic_punctuation_enabled,\n                    \"max_sentence_silence\": self.max_sentence_silence,\n                    \"heartbeat\": self.heartbeat,\n                },\n                \"input\": {},\n            },\n        }\n\n    def _build_finish_task_message(self, task_id: str) -> dict[str, Any]:\n        \"\"\"构建finish-task消息\"\"\"\n        return {\n            \"header\": {\n                \"action\": \"finish-task\",\n                \"task_id\": task_id,\n                \"streaming\": \"duplex\",\n            },\n            \"payload\": {\"input\": {}},\n        }\n\n    def _handle_asr_event(\n        self,\n        event: str,\n        data: dict[str, Any],\n        on_result: Callable[[str, bool], None],\n        on_error: Callable[[Exception], None] | None,\n        task_started_ref: list[bool],\n    ) -> bool:\n        \"\"\"处理ASR事件，返回是否应该继续\"\"\"\n        logger.debug(f\"ASR event received: {event}, data keys: {list(data.keys())}\")\n        if event == \"task-started\":\n            task_started_ref[0] = True\n            logger.info(\"ASR任务已启动\")\n            return True\n        if event == \"result-generated\":\n            payload = data.get(\"payload\", {})\n            output = payload.get(\"output\", {})\n            sentence = output.get(\"sentence\", {})\n            text = sentence.get(\"text\", \"\")\n            is_final = sentence.get(\"sentence_end\", False)\n            if text:\n                logger.info(f\"ASR partial result: {text} (final={is_final})\")\n            if text and on_result:\n                on_result(text, is_final)\n            return True\n        if event == \"task-finished\":\n            logger.info(\"ASR任务已完成\")\n            return False\n        if event == \"task-failed\":\n            error_code = data.get(\"header\", {}).get(\"error_code\", \"\")\n            error_message = data.get(\"header\", {}).get(\"error_message\", \"\")\n            error = Exception(f\"ASR任务失败: {error_code} - {error_message}\")\n            if on_error:\n                on_error(error)\n            logger.error(f\"ASR任务失败: {error_message}\")\n            return False\n        return True\n\n    async def _receive_messages(\n        self,\n        ws: Any,\n        on_result: Callable[[str, bool], None],\n        on_error: Callable[[Exception], None] | None,\n        task_started_ref: list[bool],\n    ) -> None:\n        \"\"\"接收并处理ASR消息\"\"\"\n        async for message in ws:\n            try:\n                data = json.loads(message)\n                event = data.get(\"header\", {}).get(\"event\")\n                should_continue = self._handle_asr_event(\n                    event, data, on_result, on_error, task_started_ref\n                )\n                if not should_continue:\n                    break\n            except json.JSONDecodeError as e:\n                logger.error(f\"解析ASR响应失败: {e}\")\n            except Exception as e:\n                logger.error(f\"处理ASR响应时出错: {e}\")\n                if on_error:\n                    on_error(e)\n\n    async def _send_audio(\n        self, ws: Any, audio_stream: Any, task_id: str, task_started_ref: list[bool]\n    ) -> None:\n        \"\"\"发送音频数据\"\"\"\n        # 等待任务启动\n        max_wait_time = 5.0  # 最多等待5秒\n        wait_interval = 0.1\n        waited_time = 0.0\n        while not task_started_ref[0] and waited_time < max_wait_time:\n            await asyncio.sleep(wait_interval)\n            waited_time += wait_interval\n\n        if not task_started_ref[0]:\n            logger.warning(\"ASR task did not start within timeout, continuing anyway\")\n\n        try:\n            async for chunk in audio_stream:\n                # websockets库使用state属性检查连接状态\n                if ws.state == protocol.State.OPEN and chunk:\n                    await ws.send(chunk)\n                elif ws.state in (protocol.State.CLOSED, protocol.State.CLOSING):\n                    logger.info(\"WebSocket closed, stopping audio stream\")\n                    break\n        except Exception as e:\n            logger.error(f\"Error sending audio stream: {e}\")\n\n        # 发送finish-task指令\n        finish_task_message = self._build_finish_task_message(task_id)\n        if ws.state == protocol.State.OPEN:\n            try:\n                await ws.send(json.dumps(finish_task_message))\n                logger.info(\"Sent finish-task message\")\n            except Exception as e:\n                logger.error(f\"Failed to send finish-task message: {e}\")\n\n    async def transcribe_stream(\n        self,\n        audio_stream: Any,\n        on_result: Callable[[str, bool], None],\n        on_error: Callable[[Exception], None] | None = None,\n    ) -> None:\n        \"\"\"实时语音识别流式转录\n\n        Args:\n            audio_stream: 音频数据流（二进制）\n            on_result: 识别结果回调函数，接收 (text: str, is_final: bool) 参数\n            on_error: 错误回调函数，接收 (error: Exception) 参数\n        \"\"\"\n        task_id = uuid.uuid4().hex[:32]  # 生成32位随机ID\n        # websockets库的additional_headers需要使用列表格式，每个元素是(name, value)元组\n        headers = [\n            (\"Authorization\", f\"Bearer {self.api_key}\"),\n        ]\n\n        try:\n            # 检查API Key是否配置\n            invalid_values = [\"xxx\", \"YOUR_API_KEY_HERE\", \"YOUR_ASR_KEY_HERE\"]\n            if not self.api_key or self.api_key in invalid_values:\n                error_msg = \"ASR API Key未配置，请先配置API Key\"\n                logger.error(error_msg)\n                if on_error:\n                    on_error(Exception(error_msg))\n                return\n\n            # websockets库使用additional_headers参数（接受列表格式）\n            async with websockets.connect(self.base_url, additional_headers=headers) as ws:\n                logger.info(\"Connected to ASR WebSocket\")\n                # 发送run-task指令\n                run_task_message = self._build_run_task_message(task_id)\n                await ws.send(json.dumps(run_task_message))\n                logger.info(\"Sent run-task message\")\n\n                task_started_ref: list[bool] = [False]\n\n                # 并发执行接收和发送\n                await asyncio.gather(\n                    self._receive_messages(ws, on_result, on_error, task_started_ref),\n                    self._send_audio(ws, audio_stream, task_id, task_started_ref),\n                )\n\n        except ConnectionClosed:\n            logger.info(\"ASR WebSocket连接已关闭\")\n        except Exception as e:\n            logger.error(f\"ASR转录失败: {e}\", exc_info=True)\n            if on_error:\n                on_error(e)\n"
  },
  {
    "path": "lifetrace/services/asr_client_dashscope.py",
    "content": "\"\"\"阿里云Fun-ASR实时语音识别客户端（使用DashScope SDK）\n\n使用dashscope SDK进行实时语音识别，支持麦克风输入和本地音频文件。\n\"\"\"\n\nfrom collections.abc import Callable\n\nimport dashscope\nfrom dashscope.audio.asr import Recognition, RecognitionCallback, RecognitionResult\n\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.settings import settings\n\nlogger = get_logger()\n\n\nclass ASRDashScopeClient:\n    \"\"\"阿里云Fun-ASR实时语音识别客户端（使用DashScope SDK）\"\"\"\n\n    _instance = None\n    _initialized = False\n\n    def __new__(cls):\n        \"\"\"实现单例模式\"\"\"\n        if cls._instance is None:\n            cls._instance = super().__new__(cls)\n        return cls._instance\n\n    def __init__(self):\n        \"\"\"初始化ASR客户端\"\"\"\n        if not ASRDashScopeClient._initialized:\n            self._initialize_client()\n            ASRDashScopeClient._initialized = True\n\n    def _initialize_client(self):\n        \"\"\"内部方法：初始化或重新初始化客户端\"\"\"\n        try:\n            self.api_key = settings.audio.asr.api_key\n            self.base_url = settings.audio.asr.base_url\n            self.model = settings.audio.asr.model\n            self.sample_rate = settings.audio.asr.sample_rate\n            self.format = settings.audio.asr.format\n            self.semantic_punctuation_enabled = settings.audio.asr.semantic_punctuation_enabled\n            self.max_sentence_silence = settings.audio.asr.max_sentence_silence\n            self.heartbeat = settings.audio.asr.heartbeat\n\n            # 配置dashscope\n            dashscope.api_key = self.api_key\n            dashscope.base_websocket_api_url = self.base_url\n\n            invalid_values = [\"xxx\", \"YOUR_API_KEY_HERE\", \"YOUR_ASR_KEY_HERE\"]\n            if not self.api_key or self.api_key in invalid_values:\n                logger.warning(\"ASR API Key未配置或为默认占位符，ASR功能可能不可用\")\n        except Exception as e:\n            logger.error(f\"无法从配置文件读取ASR配置: {e}\")\n            self.api_key = \"YOUR_ASR_KEY_HERE\"\n            self.base_url = \"wss://dashscope.aliyuncs.com/api-ws/v1/inference/\"\n            self.model = \"fun-asr-realtime\"\n            self.sample_rate = 16000\n            self.format = \"pcm\"\n            self.semantic_punctuation_enabled = False\n            self.max_sentence_silence = 1300\n            self.heartbeat = False\n            logger.warning(\"使用硬编码默认值初始化ASR客户端\")\n\n    def reinitialize(self):\n        \"\"\"重新初始化ASR客户端（用于配置热重载）\"\"\"\n        self._initialize_client()\n        logger.info(\"ASR客户端已重新初始化\")\n\n    def create_recognition_callback(\n        self,\n        on_result: Callable[[str, bool], None],\n        on_error: Callable[[Exception], None] | None = None,\n    ) -> RecognitionCallback:\n        \"\"\"创建识别回调\n\n        Args:\n            on_result: 识别结果回调，接收 (text: str, is_final: bool) 参数\n            on_error: 错误回调，接收 (error: Exception) 参数\n        \"\"\"\n\n        class Callback(RecognitionCallback):\n            def __init__(self, result_cb, error_cb):\n                self.result_cb = result_cb\n                self.error_cb = error_cb\n\n            def on_open(self) -> None:\n                logger.info(\"ASR识别已启动\")\n\n            def on_close(self) -> None:\n                logger.info(\"ASR识别已关闭\")\n\n            def on_complete(self) -> None:\n                logger.info(\"ASR识别已完成\")\n\n            def on_error(self, result: RecognitionResult) -> None:\n                error_msg = f\"ASR识别错误: {result.message}\"\n                logger.error(error_msg)\n                if self.error_cb:\n                    self.error_cb(Exception(error_msg))\n\n            def on_event(self, result: RecognitionResult) -> None:\n                sentence = result.get_sentence()\n                if isinstance(sentence, dict) and \"text\" in sentence:\n                    text = str(sentence.get(\"text\", \"\"))\n                    is_final = RecognitionResult.is_sentence_end(sentence)\n                    if text and self.result_cb:\n                        self.result_cb(text, is_final)\n\n        return Callback(on_result, on_error)\n\n    def create_recognition(self, callback: RecognitionCallback) -> Recognition:\n        \"\"\"创建识别实例\n\n        Args:\n            callback: 识别回调\n\n        Returns:\n            Recognition实例\n        \"\"\"\n        return Recognition(\n            model=self.model,\n            format=self.format,\n            sample_rate=self.sample_rate,\n            semantic_punctuation_enabled=self.semantic_punctuation_enabled,\n            callback=callback,\n        )\n"
  },
  {
    "path": "lifetrace/services/audio_extraction_service.py",
    "content": "\"\"\"音频提取服务\n\n处理音频转录文本的待办和日程提取逻辑。\n\"\"\"\n\nimport hashlib\nimport json\nfrom typing import Any\n\nfrom sqlmodel import select\n\nfrom lifetrace.llm.llm_client import LLMClient\nfrom lifetrace.storage import get_session\nfrom lifetrace.storage.models import Transcription\nfrom lifetrace.storage.sql_utils import col\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.prompt_loader import get_prompt\n\nlogger = get_logger()\n\n\nclass AudioExtractionService:\n    \"\"\"音频提取服务\"\"\"\n\n    def __init__(self, llm_client: LLMClient):\n        \"\"\"初始化提取服务\n\n        Args:\n            llm_client: LLM客户端\n        \"\"\"\n        self.llm_client = llm_client\n\n    def _stable_extracted_id(self, prefix: str, item: dict) -> str:\n        \"\"\"生成稳定的提取项ID\n\n        Args:\n            prefix: 前缀\n            item: 提取项字典\n\n        Returns:\n            稳定的ID字符串\n        \"\"\"\n        base = \"|\".join(\n            [\n                str(item.get(\"source_text\") or \"\"),\n                str(item.get(\"start_time\") or item.get(\"deadline\") or item.get(\"time\") or \"\"),\n            ]\n        )\n        digest = hashlib.sha1(base.encode(\"utf-8\"), usedforsecurity=False).hexdigest()[:16]\n        return f\"{prefix}_{digest}\"\n\n    def _enrich_extracted_items(self, prefix: str, items: list[dict]) -> list[dict]:\n        \"\"\"丰富提取项，添加缺失字段\n\n        Args:\n            prefix: 前缀\n            items: 提取项列表\n\n        Returns:\n            丰富后的提取项列表\n        \"\"\"\n        out: list[dict] = []\n        for it in items:\n            if not isinstance(it, dict):\n                continue\n            it2 = dict(it)\n            it2.setdefault(\n                \"dedupe_key\",\n                \"|\".join(\n                    [\n                        str(it2.get(\"source_text\") or \"\"),\n                        str(it2.get(\"start_time\") or it2.get(\"deadline\") or it2.get(\"time\") or \"\"),\n                    ]\n                ),\n            )\n            it2.setdefault(\"id\", self._stable_extracted_id(prefix, it2))\n            it2.setdefault(\"linked\", False)\n            it2.setdefault(\"linked_todo_id\", None)\n            out.append(it2)\n        return out\n\n    def update_extraction(\n        self,\n        transcription_id: int,\n        todos: list[dict] | None = None,\n        schedules: list[dict] | None = None,\n        optimized: bool = False,\n    ) -> Transcription | None:\n        \"\"\"更新提取结果\n\n        Args:\n            transcription_id: 转录ID\n            todos: 待办事项列表\n            schedules: 日程安排列表\n            optimized: 是否为优化文本的提取结果\n\n        Returns:\n            更新后的Transcription对象\n        \"\"\"\n        with get_session() as session:\n            transcription = session.get(Transcription, transcription_id)\n            if transcription:\n                if optimized:\n                    if todos is not None:\n                        transcription.extracted_todos_optimized = json.dumps(\n                            self._enrich_extracted_items(\"todo\", todos), ensure_ascii=False\n                        )\n                    if schedules is not None:\n                        transcription.extracted_schedules_optimized = json.dumps(\n                            self._enrich_extracted_items(\"schedule\", schedules), ensure_ascii=False\n                        )\n                else:\n                    if todos is not None:\n                        transcription.extracted_todos = json.dumps(\n                            self._enrich_extracted_items(\"todo\", todos), ensure_ascii=False\n                        )\n                    if schedules is not None:\n                        transcription.extracted_schedules = json.dumps(\n                            self._enrich_extracted_items(\"schedule\", schedules), ensure_ascii=False\n                        )\n                transcription.extraction_status = \"completed\"\n                session.commit()\n                session.refresh(transcription)\n            return transcription\n\n    def _load_extraction_from_transcription(\n        self, transcription: Transcription, optimized: bool\n    ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:\n        \"\"\"从转录对象中加载提取结果\n\n        Args:\n            transcription: 转录对象\n            optimized: 是否加载优化文本的提取结果\n\n        Returns:\n            (todos, schedules) 元组\n        \"\"\"\n        todos: list[dict[str, Any]] = []\n        schedules: list[dict[str, Any]] = []\n\n        if optimized:\n            if transcription.extracted_todos_optimized:\n                try:\n                    todos = json.loads(transcription.extracted_todos_optimized)\n                except Exception:\n                    todos = []\n            if transcription.extracted_schedules_optimized:\n                try:\n                    schedules = json.loads(transcription.extracted_schedules_optimized)\n                except Exception:\n                    schedules = []\n        else:\n            if transcription.extracted_todos:\n                try:\n                    todos = json.loads(transcription.extracted_todos)\n                except Exception:\n                    todos = []\n            if transcription.extracted_schedules:\n                try:\n                    schedules = json.loads(transcription.extracted_schedules)\n                except Exception:\n                    schedules = []\n\n        return todos, schedules\n\n    def _build_item_lookup_maps(\n        self, items: list[dict[str, Any]]\n    ) -> tuple[dict[str, dict[str, Any]], dict[str, dict[str, Any]]]:\n        \"\"\"构建项目的查找映射（按 id 和 dedupe_key）\n\n        Args:\n            items: 项目列表\n\n        Returns:\n            (by_id, by_dedupe) 元组\n        \"\"\"\n        by_id: dict[str, dict[str, Any]] = {}\n        by_dedupe: dict[str, dict[str, Any]] = {}\n\n        for item in items:\n            if not isinstance(item, dict):\n                continue\n            item_id = item.get(\"id\")\n            dedupe_key = item.get(\"dedupe_key\")\n            if item_id:\n                by_id[str(item_id)] = item\n            if dedupe_key:\n                by_dedupe[str(dedupe_key)] = item\n\n        return by_id, by_dedupe\n\n    def _apply_links_to_items(\n        self,\n        links: list[dict[str, Any]],\n        todo_by_id: dict[str, dict[str, Any]],\n        todo_by_dedupe: dict[str, dict[str, Any]],\n        sched_by_id: dict[str, dict[str, Any]],\n        sched_by_dedupe: dict[str, dict[str, Any]],\n    ) -> int:\n        \"\"\"应用链接到项目\n\n        Args:\n            links: 链接列表\n            todo_by_id: 待办按 id 的映射\n            todo_by_dedupe: 待办按 dedupe_key 的映射\n            sched_by_id: 日程按 id 的映射\n            sched_by_dedupe: 日程按 dedupe_key 的映射\n\n        Returns:\n            更新的项目数量\n        \"\"\"\n        updated = 0\n        for link in links:\n            kind = link.get(\"kind\")\n            item_id = link.get(\"item_id\")\n            todo_id = link.get(\"todo_id\")\n            if not kind or not item_id or not todo_id:\n                continue\n\n            if kind == \"todo\":\n                target = todo_by_id.get(item_id) or todo_by_dedupe.get(item_id)\n            else:\n                target = sched_by_id.get(item_id) or sched_by_dedupe.get(item_id)\n\n            if not target:\n                continue\n\n            target[\"linked\"] = True\n            target[\"linked_todo_id\"] = int(todo_id)\n            updated += 1\n\n        return updated\n\n    def link_extracted_items(\n        self,\n        recording_id: int,\n        links: list[dict[str, Any]],\n        optimized: bool = False,\n    ) -> dict[str, Any]:\n        \"\"\"标记提取项为已链接到待办（持久化在转录JSON中）\n\n        Args:\n            recording_id: 录音ID\n            links: 链接列表\n            optimized: 是否更新优化文本的提取结果\n\n        Returns:\n            包含更新数量的字典\n        \"\"\"\n        with get_session() as session:\n            # 查询转录记录（一个 recording_id 只应该有一条）\n            statement = (\n                select(Transcription)\n                .where(Transcription.audio_recording_id == recording_id)\n                .order_by(col(Transcription.id).desc())\n            )\n            transcription = session.exec(statement).first()\n            if not transcription:\n                raise ValueError(\"transcription not found\")\n\n            todos, schedules = self._load_extraction_from_transcription(transcription, optimized)\n\n            # Backfill missing fields for legacy stored items (and persist)\n            todos = self._enrich_extracted_items(\"todo\", todos)\n            schedules = self._enrich_extracted_items(\"schedule\", schedules)\n\n            todo_by_id, todo_by_dedupe = self._build_item_lookup_maps(todos)\n            sched_by_id, sched_by_dedupe = self._build_item_lookup_maps(schedules)\n\n            updated = self._apply_links_to_items(\n                links, todo_by_id, todo_by_dedupe, sched_by_id, sched_by_dedupe\n            )\n\n            if optimized:\n                transcription.extracted_todos_optimized = json.dumps(todos, ensure_ascii=False)\n                transcription.extracted_schedules_optimized = json.dumps(\n                    schedules, ensure_ascii=False\n                )\n            else:\n                transcription.extracted_todos = json.dumps(todos, ensure_ascii=False)\n                transcription.extracted_schedules = json.dumps(schedules, ensure_ascii=False)\n            session.add(transcription)\n            session.commit()\n\n            return {\"updated\": updated}\n\n    def _load_extraction_prompts(self, text: str) -> tuple[str, str]:\n        \"\"\"加载提取提示词\n\n        Args:\n            text: 转录文本\n\n        Returns:\n            (system_prompt, user_prompt) 元组\n        \"\"\"\n        system_prompt = get_prompt(\"transcription_extraction\", \"system_assistant\")\n        user_prompt = get_prompt(\"transcription_extraction\", \"user_prompt\", text=text)\n\n        if not system_prompt or not user_prompt:\n            logger.warning(\"无法加载提取提示词，使用默认提示词\")\n            system_prompt = \"你是一个专业的任务和日程提取助手。\"\n            user_prompt = f\"请从以下转录文本中提取待办事项和日程安排。\\n\\n转录文本：\\n{text}\\n\\n只返回JSON，不要其他内容。\"\n\n        return system_prompt, user_prompt\n\n    def _parse_llm_response(self, result_text: str) -> dict[str, Any]:\n        \"\"\"解析 LLM 响应文本\n\n        Args:\n            result_text: LLM 返回的原始文本\n\n        Returns:\n            解析后的 JSON 字典\n        \"\"\"\n        # 移除可能的markdown代码块标记\n        if result_text.startswith(\"```json\"):\n            result_text = result_text[7:]\n        if result_text.startswith(\"```\"):\n            result_text = result_text[3:]\n        if result_text.endswith(\"```\"):\n            result_text = result_text[:-3]\n        result_text = result_text.strip()\n\n        return json.loads(result_text)\n\n    def _normalize_extraction_result(self, result: dict[str, Any]) -> dict[str, Any]:\n        \"\"\"规范化提取结果格式\n\n        将字符串数组转换为标准格式（对象数组，包含 source_text）\n\n        Args:\n            result: 原始提取结果\n\n        Returns:\n            规范化后的结果\n        \"\"\"\n        # 处理 todos\n        if \"todos\" in result:\n            todos = result[\"todos\"]\n            if todos and isinstance(todos[0], str):\n                result[\"todos\"] = [\n                    {\n                        \"title\": item,\n                        \"description\": None,\n                        \"source_text\": item,\n                    }\n                    for item in todos\n                ]\n\n        # 处理 schedules\n        if \"schedules\" in result:\n            schedules = result[\"schedules\"]\n            if schedules and isinstance(schedules[0], str):\n                result[\"schedules\"] = [\n                    {\n                        \"title\": item,\n                        \"time\": None,\n                        \"description\": None,\n                        \"source_text\": item,\n                    }\n                    for item in schedules\n                ]\n\n        return result\n\n    async def extract_todos_and_schedules(self, text: str) -> dict[str, Any]:\n        \"\"\"从转录文本中提取待办和日程\n\n        Args:\n            text: 转录文本\n\n        Returns:\n            包含todos和schedules的字典\n        \"\"\"\n        try:\n            if not self.llm_client.is_available():\n                logger.warning(\"LLM客户端不可用，跳过提取\")\n                return {\"todos\": [], \"schedules\": []}\n\n            # 加载提示词\n            system_prompt, user_prompt = self._load_extraction_prompts(text)\n\n            # 调用 LLM\n            client = self.llm_client\n            client._initialize_client()\n\n            openai_client = client._get_client()\n            response = openai_client.chat.completions.create(\n                model=client.model,\n                messages=[\n                    {\"role\": \"system\", \"content\": system_prompt},\n                    {\"role\": \"user\", \"content\": user_prompt},\n                ],\n                temperature=0.3,\n            )\n\n            # 解析响应\n            result_text = (response.choices[0].message.content or \"\").strip()\n            result = self._parse_llm_response(result_text)\n\n            # 规范化结果格式\n            result = self._normalize_extraction_result(result)\n\n            return result\n        except Exception as e:\n            logger.error(f\"提取待办和日程失败: {e}\")\n            return {\"todos\": [], \"schedules\": []}\n"
  },
  {
    "path": "lifetrace/services/audio_service.py",
    "content": "\"\"\"音频服务层\n\n处理音频录制、存储、转录等业务逻辑。\n\"\"\"\n\nimport asyncio\nimport json\nfrom datetime import datetime\nfrom pathlib import Path\nfrom typing import Any\n\nfrom sqlmodel import select\n\nfrom lifetrace.llm.llm_client import LLMClient\nfrom lifetrace.services.audio_extraction_service import AudioExtractionService\nfrom lifetrace.storage import get_session\nfrom lifetrace.storage.models import AudioRecording, Transcription\nfrom lifetrace.storage.sql_utils import col\nfrom lifetrace.util.base_paths import get_user_data_dir\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.prompt_loader import get_prompt\nfrom lifetrace.util.settings import settings\nfrom lifetrace.util.time_utils import get_utc_now, to_local\n\nlogger = get_logger()\n\n\nclass AudioService:\n    \"\"\"音频服务\"\"\"\n\n    def __init__(self):\n        \"\"\"初始化音频服务\"\"\"\n        self.llm_client = LLMClient()\n        self.extraction_service = AudioExtractionService(self.llm_client)\n        self._background_tasks: set[asyncio.Task] = set()\n        self.audio_base_dir = Path(get_user_data_dir()) / settings.audio.storage.audio_dir\n        self.temp_audio_dir = Path(get_user_data_dir()) / settings.audio.storage.temp_audio_dir\n        self.audio_base_dir.mkdir(parents=True, exist_ok=True)\n        self.temp_audio_dir.mkdir(parents=True, exist_ok=True)\n\n    def get_audio_dir_for_date(self, date: datetime) -> Path:\n        \"\"\"获取指定日期的音频存储目录（按年月日组织）\n\n        Args:\n            date: 日期\n\n        Returns:\n            音频目录路径（格式：audio/2025/01/17/）\n        \"\"\"\n        year = date.strftime(\"%Y\")\n        month = date.strftime(\"%m\")\n        day = date.strftime(\"%d\")\n        audio_dir = self.audio_base_dir / year / month / day\n        audio_dir.mkdir(parents=True, exist_ok=True)\n        return audio_dir\n\n    def generate_audio_file_path(self, date: datetime, filename: str | None = None) -> Path:\n        \"\"\"生成音频文件路径\n\n        Args:\n            date: 日期\n            filename: 文件名（可选，如果不提供则自动生成）\n\n        Returns:\n            音频文件路径\n        \"\"\"\n        audio_dir = self.get_audio_dir_for_date(date)\n        if filename:\n            return audio_dir / filename\n        # 自动生成文件名：HHMMSS.wav\n        timestamp = date.strftime(\"%H%M%S\")\n        return audio_dir / f\"{timestamp}.wav\"\n\n    def create_recording(\n        self,\n        file_path: str,\n        file_size: int,\n        duration: float,\n        is_24x7: bool = False,\n    ) -> int:\n        \"\"\"创建录音记录\n\n        Args:\n            file_path: 音频文件路径\n            file_size: 文件大小（字节）\n            duration: 录音时长（秒）\n            is_24x7: 是否为7x24小时录制\n\n        Returns:\n            创建的AudioRecording对象\n        \"\"\"\n        # 注意：不要把 ORM 实例（AudioRecording）跨 session 返回到路由层；\n        # SQLAlchemy 默认会在 commit 后过期属性，session 关闭后再访问会触发 refresh，\n        # 从而报 “Instance ... is not bound to a Session”。\n        # 这里只返回 recording_id，路由层需要对象时再用新的 session 查询。\n        with get_session() as session:\n            recording = AudioRecording(\n                file_path=file_path,\n                file_size=file_size,\n                duration=duration,\n                # 使用本地时间记录，避免前端显示存在时区偏移\n                start_time=get_utc_now().astimezone(),\n                status=\"recording\",\n                is_24x7=is_24x7,\n                is_transcribed=False,\n                is_extracted=False,\n                is_summarized=False,\n                is_full_audio=False,\n                is_segment_audio=False,\n                transcription_status=\"pending\",\n            )\n            session.add(recording)\n            session.commit()\n            session.refresh(recording)\n            if recording.id is None:\n                raise ValueError(\"Recording must have an id after creation.\")\n            return int(recording.id)\n\n    def complete_recording(self, recording_id: int) -> AudioRecording | None:\n        \"\"\"完成录音\n\n        Args:\n            recording_id: 录音ID\n\n        Returns:\n            更新后的AudioRecording对象，如果不存在则返回None\n        \"\"\"\n        with get_session() as session:\n            recording = session.get(AudioRecording, recording_id)\n            if recording:\n                recording.status = \"completed\"\n                # 使用本地时间记录结束时间\n                recording.end_time = get_utc_now().astimezone()\n                recording.transcription_status = \"processing\"\n                session.commit()\n                session.refresh(recording)\n            return recording\n\n    def get_recordings_by_date(self, date: datetime) -> list[dict[str, Any]]:\n        \"\"\"根据日期获取录音列表\n\n        Args:\n            date: 日期\n\n        Returns:\n            录音列表（序列化后的字典列表，避免 Session 错误）\n        \"\"\"\n        with get_session() as session:\n            start_of_day = date.replace(hour=0, minute=0, second=0, microsecond=0)\n            end_of_day = date.replace(hour=23, minute=59, second=59, microsecond=999999)\n\n            statement = select(AudioRecording).where(\n                col(AudioRecording.start_time) >= start_of_day,\n                col(AudioRecording.start_time) <= end_of_day,\n                col(AudioRecording.deleted_at).is_(None),\n            )\n            recordings = session.exec(statement).all()\n            # 在 session 内序列化数据，避免 Session 错误\n            result = []\n            for rec in recordings:\n                result.append(\n                    {\n                        \"id\": rec.id,\n                        \"file_path\": rec.file_path,\n                        \"file_size\": rec.file_size,\n                        \"duration\": rec.duration,\n                        \"start_time\": to_local(rec.start_time),\n                        \"end_time\": to_local(rec.end_time) if rec.end_time else None,\n                        \"status\": rec.status,\n                        \"is_24x7\": rec.is_24x7,\n                        \"is_transcribed\": rec.is_transcribed,\n                        \"is_extracted\": rec.is_extracted,\n                        \"is_summarized\": rec.is_summarized,\n                        \"is_full_audio\": rec.is_full_audio,\n                        \"is_segment_audio\": rec.is_segment_audio,\n                        \"transcription_status\": rec.transcription_status,\n                    }\n                )\n            return result\n\n    def _check_has_extraction(self, transcription: Transcription) -> bool:\n        \"\"\"检查转录记录是否有提取结果\n\n        Args:\n            transcription: 转录记录\n\n        Returns:\n            是否有提取结果\n        \"\"\"\n        return bool(\n            (\n                transcription.extracted_todos\n                and transcription.extracted_todos.strip()\n                and transcription.extracted_todos.strip() != \"[]\"\n            )\n            or (\n                transcription.extracted_schedules\n                and transcription.extracted_schedules.strip()\n                and transcription.extracted_schedules.strip() != \"[]\"\n            )\n            or (\n                transcription.extracted_todos_optimized\n                and transcription.extracted_todos_optimized.strip()\n                and transcription.extracted_todos_optimized.strip() != \"[]\"\n            )\n            or (\n                transcription.extracted_schedules_optimized\n                and transcription.extracted_schedules_optimized.strip()\n                and transcription.extracted_schedules_optimized.strip() != \"[]\"\n            )\n        )\n\n    def _check_text_changes(\n        self, existing: Transcription, segmented_text: str, optimized_text: str | None\n    ) -> tuple[bool, bool]:\n        \"\"\"检查文本是否变化\n\n        Args:\n            existing: 现有转录记录\n            segmented_text: 新的分段文本\n            optimized_text: 新的优化文本\n\n        Returns:\n            (original_changed, optimized_changed) 元组\n        \"\"\"\n        original_changed = (existing.original_text or \"\").strip() != (segmented_text or \"\").strip()\n        optimized_changed = (existing.optimized_text or \"\").strip() != (\n            optimized_text or \"\"\n        ).strip()\n        return original_changed, optimized_changed\n\n    def _cleanup_duplicate_transcriptions(\n        self, session, recording_id: int, existing: Transcription\n    ) -> Transcription:\n        \"\"\"清理重复的转录记录\n\n        Args:\n            session: 数据库会话\n            recording_id: 录音ID\n            existing: 现有记录\n\n        Returns:\n            保留的记录\n        \"\"\"\n        all_records = list(\n            session.exec(\n                select(Transcription)\n                .where(col(Transcription.audio_recording_id) == recording_id)\n                .order_by(col(Transcription.id).desc())\n            ).all()\n        )\n        if len(all_records) > 1:\n            logger.warning(\n                f\"[save_transcription] 录音 {recording_id} 发现 {len(all_records)} 条转录记录，\"\n                f\"保留最新的（ID={all_records[0].id}），删除其他 {len(all_records) - 1} 条\"\n            )\n            # 保留第一条（ID最大的），删除其他的\n            for old_record in all_records[1:]:\n                session.delete(old_record)\n            existing = all_records[0]\n            session.flush()\n        return existing\n\n    def _update_existing_transcription(\n        self,\n        session,\n        existing: Transcription,\n        recording_id: int,\n        segmented_text: str,\n        optimized_text: str | None,\n        segment_timestamps_json: str | None = None,\n    ) -> tuple[Transcription, bool]:\n        \"\"\"更新现有转录记录\n\n        Args:\n            session: 数据库会话\n            existing: 现有记录\n            recording_id: 录音ID\n            segmented_text: 分段文本\n            optimized_text: 优化文本\n\n        Returns:\n            (transcription, should_auto_extract) 元组\n        \"\"\"\n        original_changed, optimized_changed = self._check_text_changes(\n            existing, segmented_text, optimized_text\n        )\n        text_changed = original_changed or optimized_changed\n\n        if not text_changed:\n            logger.debug(f\"[save_transcription] 录音 {recording_id} 文本未变化，跳过更新\")\n            return existing, False\n\n        # 文本变化了，更新文本字段（保留提取结果）\n        existing.original_text = segmented_text\n        existing.optimized_text = optimized_text\n        # 如果提供了新的时间戳，也更新\n        if segment_timestamps_json is not None:\n            existing.segment_timestamps = segment_timestamps_json\n\n        has_extraction = self._check_has_extraction(existing)\n        should_auto_extract = False\n\n        if not has_extraction:\n            existing.extraction_status = \"pending\"\n            should_auto_extract = True\n        else:\n            logger.info(\n                f\"[save_transcription] 录音 {recording_id} 文本变化但已有提取结果，\"\n                f\"保留提取结果，不触发自动提取\"\n            )\n\n        session.add(existing)\n        return existing, should_auto_extract\n\n    def _prepare_transcription_data(\n        self,\n        original_text: str,\n        segment_timestamps: list[float] | None,\n        recording_id: int,\n    ) -> tuple[str, str | None]:\n        \"\"\"准备转录数据（处理文本和时间戳）\n\n        Returns:\n            (display_text, segment_timestamps_json)\n        \"\"\"\n        display_lines = [line.strip() for line in (original_text or \"\").split(\"\\n\") if line.strip()]\n        display_text = \"\\n\".join(display_lines)\n\n        segment_timestamps_json = None\n        if segment_timestamps is not None:\n            # 严格一致：不做插值/均分/猜测。长度不一致就丢弃时间戳，前端回退到均匀估算。\n            if len(segment_timestamps) == len(display_lines):\n                segment_timestamps_json = json.dumps(segment_timestamps, ensure_ascii=False)\n            else:\n                logger.warning(\n                    f\"[save_transcription] segment_timestamps 行数不匹配，丢弃时间戳以避免错误跳转。\"\n                    f\" recording_id={recording_id}, timestamps={len(segment_timestamps)}, lines={len(display_lines)}\"\n                )\n\n        return display_text, segment_timestamps_json\n\n    async def _optimize_text_if_needed(self, display_text: str, auto_optimize: bool) -> str | None:\n        \"\"\"如果需要，优化文本\"\"\"\n        if not auto_optimize or not display_text:\n            return None\n        try:\n            return await self.optimize_transcription_text(display_text)\n        except Exception as e:\n            logger.error(f\"自动优化文本失败: {e}\")\n            return None\n\n    def _create_or_update_transcription(\n        self,\n        session: Any,\n        recording_id: int,\n        display_text: str,\n        optimized_text: str | None,\n        segment_timestamps_json: str | None,\n    ) -> tuple[Transcription, bool]:\n        \"\"\"创建或更新转录记录\n\n        Returns:\n            (transcription, should_auto_extract)\n        \"\"\"\n        # 检查是否已存在转录记录\n        existing = session.exec(\n            select(Transcription)\n            .where(col(Transcription.audio_recording_id) == recording_id)\n            .order_by(col(Transcription.id).desc())\n        ).first()\n\n        # 清理重复记录\n        if existing:\n            existing = self._cleanup_duplicate_transcriptions(session, recording_id, existing)\n\n        # 更新或创建记录\n        if existing:\n            transcription, should_auto_extract = self._update_existing_transcription(\n                session,\n                existing,\n                recording_id,\n                display_text,\n                optimized_text,\n                segment_timestamps_json,\n            )\n        else:\n            logger.info(f\"[save_transcription] 录音 {recording_id} 创建新转录记录\")\n            transcription = Transcription(\n                audio_recording_id=recording_id,\n                original_text=display_text,\n                optimized_text=optimized_text,\n                extraction_status=\"pending\",\n                segment_timestamps=segment_timestamps_json,\n            )\n            session.add(transcription)\n            should_auto_extract = True\n\n        return transcription, should_auto_extract\n\n    def _update_recording_status(self, session: Any, recording_id: int) -> None:\n        \"\"\"更新录音记录的转录状态\"\"\"\n        recording = session.get(AudioRecording, recording_id)\n        if recording:\n            recording.transcription_status = \"completed\"\n            session.commit()\n\n    def _trigger_auto_extraction(\n        self, transcription_id: int, display_text: str, optimized_text: str | None\n    ) -> None:\n        \"\"\"触发自动提取待办和日程（异步执行，不阻塞）\"\"\"\n        if display_text:\n            task = asyncio.create_task(\n                self._auto_extract_todos_and_schedules(\n                    transcription_id, display_text, optimized=False\n                )\n            )\n            self._background_tasks.add(task)\n            task.add_done_callback(self._background_tasks.discard)\n        if optimized_text:\n            task = asyncio.create_task(\n                self._auto_extract_todos_and_schedules(\n                    transcription_id, optimized_text, optimized=True\n                )\n            )\n            self._background_tasks.add(task)\n            task.add_done_callback(self._background_tasks.discard)\n\n    async def save_transcription(\n        self,\n        recording_id: int,\n        original_text: str,\n        auto_optimize: bool = True,\n        segment_timestamps: list[float] | None = None,\n    ) -> Transcription:\n        \"\"\"保存转录文本（自动优化和提取）\n\n        Args:\n            recording_id: 录音ID\n            original_text: 原始转录文本（前端展示用文本，final 一句一行）\n            auto_optimize: 是否自动优化文本\n            segment_timestamps: 每段文本的精确时间戳（秒），相对于录音开始时间\n\n        Returns:\n            创建的Transcription对象\n        \"\"\"\n        # 准备数据\n        display_text, segment_timestamps_json = self._prepare_transcription_data(\n            original_text, segment_timestamps, recording_id\n        )\n\n        # 优化文本\n        optimized_text = await self._optimize_text_if_needed(display_text, auto_optimize)\n\n        with get_session() as session:\n            # 创建或更新转录记录\n            transcription, should_auto_extract = self._create_or_update_transcription(\n                session, recording_id, display_text, optimized_text, segment_timestamps_json\n            )\n\n            session.commit()\n            session.refresh(transcription)\n\n            # 更新录音记录的转录状态\n            self._update_recording_status(session, recording_id)\n\n            # 自动提取待办和日程（异步执行，不阻塞）\n            if should_auto_extract:\n                if transcription.id is None:\n                    raise ValueError(\"Transcription must have an id before extraction.\")\n                self._trigger_auto_extraction(transcription.id, display_text, optimized_text)\n\n            return transcription\n\n    async def _auto_extract_todos_and_schedules(\n        self, transcription_id: int, text: str, optimized: bool = False\n    ) -> None:\n        \"\"\"自动提取待办和日程（后台任务）\n\n        Args:\n            transcription_id: 转录ID\n            text: 要提取的文本\n            optimized: 是否为优化文本的提取\n        \"\"\"\n        try:\n            result = await self.extraction_service.extract_todos_and_schedules(text)\n            self.extraction_service.update_extraction(\n                transcription_id=transcription_id,\n                todos=result.get(\"todos\", []),\n                schedules=result.get(\"schedules\", []),\n                optimized=optimized,\n            )\n        except Exception as e:\n            logger.error(f\"自动提取待办和日程失败 (optimized={optimized}): {e}\")\n\n    async def optimize_transcription_text(self, text: str) -> str:\n        \"\"\"使用LLM优化转录文本\n\n        Args:\n            text: 原始转录文本\n\n        Returns:\n            优化后的文本\n        \"\"\"\n        try:\n            if not self.llm_client.is_available():\n                logger.warning(\"LLM客户端不可用，跳过文本优化\")\n                return text\n\n            # 从配置文件加载提示词\n            system_prompt = get_prompt(\"transcription_optimization\", \"system_assistant\")\n            user_prompt = get_prompt(\"transcription_optimization\", \"user_prompt\", text=text)\n\n            if not system_prompt or not user_prompt:\n                logger.warning(\"无法加载优化提示词，使用默认提示词\")\n                system_prompt = \"你是一个专业的文本优化助手，擅长优化语音转录文本。\"\n                user_prompt = f\"请优化以下语音转录文本，使其更加流畅、准确、易读。\\n\\n转录文本：\\n{text}\\n\\n只返回优化后的文本，不要其他内容。\"\n\n            client = self.llm_client\n            client._initialize_client()\n\n            openai_client = client._get_client()\n            response = openai_client.chat.completions.create(\n                model=client.model,\n                messages=[\n                    {\"role\": \"system\", \"content\": system_prompt},\n                    {\"role\": \"user\", \"content\": user_prompt},\n                ],\n                temperature=0.3,\n            )\n\n            optimized_text = (response.choices[0].message.content or \"\").strip()\n            # 移除可能的markdown代码块标记\n            if optimized_text.startswith(\"```\"):\n                lines = optimized_text.split(\"\\n\")\n                if lines[0].startswith(\"```\"):\n                    min_lines_for_code_block = 2\n                    if len(lines) > min_lines_for_code_block:\n                        optimized_text = \"\\n\".join(lines[1:-1])\n                optimized_text = optimized_text.strip()\n\n            return optimized_text\n        except Exception as e:\n            logger.error(f\"优化转录文本失败: {e}\")\n            return text\n\n    @property\n    def extract_todos_and_schedules(self):\n        \"\"\"委托给 extraction_service\"\"\"\n        return self.extraction_service.extract_todos_and_schedules\n\n    @property\n    def update_extraction(self):\n        \"\"\"委托给 extraction_service\"\"\"\n        return self.extraction_service.update_extraction\n\n    @property\n    def link_extracted_items(self):\n        \"\"\"委托给 extraction_service\"\"\"\n        return self.extraction_service.link_extracted_items\n\n    def get_transcription(self, recording_id: int) -> dict[str, Any] | None:\n        \"\"\"获取转录文本（已序列化）\n\n        注意：不要将 ORM 实例返回到路由层，避免 Session 关闭后访问属性时报\n        “Instance <Transcription ...> is not bound to a Session”。\n\n        Args:\n            recording_id: 录音ID\n\n        Returns:\n            包含转录字段的字典，如果不存在则返回None\n        \"\"\"\n        with get_session() as session:\n            # 查询转录记录（一个 recording_id 只应该有一条）\n            statement = (\n                select(Transcription)\n                .where(col(Transcription.audio_recording_id) == recording_id)\n                .order_by(col(Transcription.id).desc())\n            )\n            transcription = session.exec(statement).first()\n            if not transcription:\n                return None\n\n            return {\n                \"id\": transcription.id,\n                \"audio_recording_id\": transcription.audio_recording_id,\n                \"original_text\": transcription.original_text,\n                \"optimized_text\": transcription.optimized_text,\n                \"extracted_todos\": transcription.extracted_todos,\n                \"extracted_schedules\": transcription.extracted_schedules,\n                \"extracted_todos_optimized\": transcription.extracted_todos_optimized,\n                \"extracted_schedules_optimized\": transcription.extracted_schedules_optimized,\n                \"extraction_status\": transcription.extraction_status,\n                \"segment_timestamps\": transcription.segment_timestamps,\n                \"created_at\": transcription.created_at,\n                \"updated_at\": transcription.updated_at,\n            }\n"
  },
  {
    "path": "lifetrace/services/automation_task_service.py",
    "content": "\"\"\"自动化任务服务 - 负责调度同步与执行\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport urllib.request\nfrom datetime import datetime\nfrom typing import TYPE_CHECKING, Any\nfrom urllib.parse import urlparse\nfrom zoneinfo import ZoneInfo\n\nfrom apscheduler.triggers.cron import CronTrigger\nfrom apscheduler.triggers.date import DateTrigger\nfrom apscheduler.triggers.interval import IntervalTrigger\n\nfrom lifetrace.jobs.scheduler import get_scheduler_manager\nfrom lifetrace.storage import automation_task_mgr\nfrom lifetrace.storage.notification_storage import add_notification\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.time_utils import get_utc_now, naive_as_utc\n\nlogger = get_logger()\n\nTASK_JOB_PREFIX = \"automation_task_\"\nDEFAULT_FETCH_TIMEOUT = 10\nDEFAULT_FETCH_MAX_CHARS = 2000\n\nif TYPE_CHECKING:\n    from lifetrace.schemas.automation import AutomationAction, AutomationSchedule\n\n\nclass AutomationTaskService:\n    \"\"\"自动化任务调度与执行服务\"\"\"\n\n    def list_tasks(self) -> list[dict[str, Any]]:\n        tasks = automation_task_mgr.list_tasks()\n        return [self._hydrate_task(task) for task in tasks]\n\n    def get_task(self, task_id: int) -> dict[str, Any] | None:\n        task = automation_task_mgr.get_task(task_id)\n        if not task:\n            return None\n        return self._hydrate_task(task)\n\n    def create_task(\n        self,\n        *,\n        name: str,\n        description: str | None,\n        enabled: bool,\n        schedule: AutomationSchedule,\n        action: AutomationAction,\n    ) -> dict[str, Any] | None:\n        schedule_type, schedule_config = self._serialize_schedule(schedule)\n        action_type, action_payload = self._serialize_action(action)\n        task_id = automation_task_mgr.create_task(\n            name=name,\n            description=description,\n            enabled=enabled,\n            schedule_type=schedule_type,\n            schedule_config=schedule_config,\n            action_type=action_type,\n            action_payload=action_payload,\n        )\n        if task_id is None:\n            return None\n        task = automation_task_mgr.get_task(task_id)\n        if not task:\n            return None\n        self.sync_task(task)\n        return self._hydrate_task(task)\n\n    def update_task(\n        self,\n        task_id: int,\n        *,\n        name: str | None,\n        description: str | None,\n        enabled: bool | None,\n        schedule: AutomationSchedule | None,\n        action: AutomationAction | None,\n    ) -> dict[str, Any] | None:\n        updates: dict[str, Any] = {}\n        if name is not None:\n            updates[\"name\"] = name\n        if description is not None:\n            updates[\"description\"] = description\n        if enabled is not None:\n            updates[\"enabled\"] = enabled\n        if schedule is not None:\n            schedule_type, schedule_config = self._serialize_schedule(schedule)\n            updates[\"schedule_type\"] = schedule_type\n            updates[\"schedule_config\"] = schedule_config\n        if action is not None:\n            action_type, action_payload = self._serialize_action(action)\n            updates[\"action_type\"] = action_type\n            updates[\"action_payload\"] = action_payload\n\n        success = automation_task_mgr.update_task(task_id, **updates)\n        if not success:\n            return None\n\n        task = automation_task_mgr.get_task(task_id)\n        if not task:\n            return None\n        self.sync_task(task)\n        return self._hydrate_task(task)\n\n    def delete_task(self, task_id: int) -> bool:\n        removed = automation_task_mgr.delete_task(task_id)\n        if removed:\n            self._remove_job(task_id)\n        return removed\n\n    def run_task(self, task_id: int) -> bool:\n        task = automation_task_mgr.get_task(task_id)\n        if not task:\n            return False\n        return self._execute_task(task)\n\n    def sync_all_tasks(self) -> None:\n        tasks = automation_task_mgr.list_tasks()\n        for task in tasks:\n            self.sync_task(task)\n\n    def sync_task(self, task: dict[str, Any]) -> None:\n        if not task.get(\"id\"):\n            return\n        if not task.get(\"enabled\", False):\n            self._remove_job(task[\"id\"])\n            return\n\n        scheduler = get_scheduler_manager()\n        if not scheduler or not scheduler.scheduler:\n            logger.warning(\"调度器未就绪，无法同步自动化任务\")\n            return\n\n        try:\n            trigger = self._build_trigger(task)\n        except ValueError as exc:\n            logger.error(\"自动化任务调度配置无效: %s\", exc)\n            return\n\n        scheduler.scheduler.add_job(\n            execute_automation_task,\n            trigger=trigger,\n            id=self._job_id(task[\"id\"]),\n            name=task.get(\"name\") or self._job_id(task[\"id\"]),\n            replace_existing=True,\n            kwargs={\"task_id\": task[\"id\"]},\n        )\n\n    def _execute_task(self, task: dict[str, Any]) -> bool:\n        if not task.get(\"enabled\", False):\n            return False\n\n        now = get_utc_now()\n        last_status = \"success\"\n        last_error: str | None = None\n        last_output: str | None = None\n\n        try:\n            action_type = task.get(\"action_type\") or \"\"\n            payload = self._parse_payload(task.get(\"action_payload\"))\n            last_output = self._run_action(action_type, payload)\n        except Exception as exc:\n            last_status = \"error\"\n            last_error = str(exc)\n            logger.error(\"自动化任务执行失败: %s\", exc, exc_info=True)\n\n        automation_task_mgr.update_task(\n            task[\"id\"],\n            last_run_at=now,\n            last_status=last_status,\n            last_error=last_error,\n            last_output=last_output,\n        )\n\n        self._notify_task_result(task, last_status, last_error, last_output, now)\n\n        if task.get(\"schedule_type\") == \"once\":\n            automation_task_mgr.update_task(task[\"id\"], enabled=False)\n            self._remove_job(task[\"id\"])\n\n        return last_status == \"success\"\n\n    def _notify_task_result(\n        self,\n        task: dict[str, Any],\n        status: str,\n        error: str | None,\n        output: str | None,\n        timestamp: datetime,\n    ) -> None:\n        content = output or \"\"\n        if status != \"success\":\n            content = error or \"执行失败\"\n        if content:\n            content = content[:DEFAULT_FETCH_MAX_CHARS]\n        title = f\"自动化任务: {task.get('name', task.get('id'))}\"\n        notification_id = f\"automation_{task.get('id')}_{int(timestamp.timestamp())}\"\n        add_notification(\n            notification_id=notification_id,\n            title=title,\n            content=content or (\"执行成功\" if status == \"success\" else \"执行失败\"),\n            timestamp=timestamp,\n        )\n\n    def _run_action(self, action_type: str, payload: dict[str, Any]) -> str:\n        if action_type == \"web_fetch\":\n            return self._run_web_fetch(payload)\n        raise ValueError(f\"未知的自动化动作类型: {action_type}\")\n\n    def _run_web_fetch(self, payload: dict[str, Any]) -> str:\n        url = payload.get(\"url\")\n        if not url:\n            raise ValueError(\"web_fetch 需要提供 url\")\n        parsed_url = urlparse(str(url))\n        if parsed_url.scheme not in (\"http\", \"https\"):\n            raise ValueError(\"web_fetch 仅支持 http/https 协议\")\n\n        method = str(payload.get(\"method\") or \"GET\").upper()\n        timeout = int(payload.get(\"timeout_seconds\") or DEFAULT_FETCH_TIMEOUT)\n        max_chars = int(payload.get(\"max_chars\") or DEFAULT_FETCH_MAX_CHARS)\n        headers = payload.get(\"headers\")\n        if not isinstance(headers, dict):\n            headers = {}\n        body = payload.get(\"body\")\n\n        data = body.encode(\"utf-8\") if isinstance(body, str) else None\n        request = urllib.request.Request(url, data=data, method=method)\n        for key, value in headers.items():\n            request.add_header(str(key), str(value))\n\n        with urllib.request.urlopen(request, timeout=timeout) as response:  # nosec B310\n            raw = response.read()\n            text = raw.decode(\"utf-8\", errors=\"replace\")\n            preview = text[:max_chars]\n            status = response.status\n            content_type = response.headers.get(\"content-type\", \"\")\n\n        return f\"[{status}] {content_type}\\n{preview}\"\n\n    def _serialize_schedule(self, schedule: AutomationSchedule) -> tuple[str, str]:\n        schedule_type = schedule.type\n        config = schedule.dict(exclude_none=True)\n        config.pop(\"type\", None)\n        run_at = config.get(\"run_at\")\n        if isinstance(run_at, datetime):\n            config[\"run_at\"] = run_at.isoformat()\n        self._validate_schedule(schedule_type, config)\n        return schedule_type, json.dumps(config)\n\n    def _serialize_action(self, action: AutomationAction) -> tuple[str, str]:\n        action_type = action.type\n        payload = action.payload or {}\n        return action_type, json.dumps(payload)\n\n    def _validate_schedule(self, schedule_type: str, config: dict[str, Any]) -> None:\n        if schedule_type == \"interval\":\n            if not config.get(\"interval_seconds\"):\n                raise ValueError(\"interval 类型必须提供 interval_seconds\")\n            return\n        if schedule_type == \"cron\":\n            if not config.get(\"cron\"):\n                raise ValueError(\"cron 类型必须提供 cron 表达式\")\n            return\n        if schedule_type == \"once\":\n            if not config.get(\"run_at\"):\n                raise ValueError(\"once 类型必须提供 run_at\")\n            return\n        raise ValueError(f\"不支持的 schedule_type: {schedule_type}\")\n\n    def _build_trigger(self, task: dict[str, Any]):\n        schedule_type = task.get(\"schedule_type\") or \"\"\n        config = self._parse_payload(task.get(\"schedule_config\"))\n        timezone = self._get_timezone(config.get(\"timezone\"))\n\n        if schedule_type == \"interval\":\n            seconds = int(config.get(\"interval_seconds\") or 0)\n            if seconds <= 0:\n                raise ValueError(\"interval_seconds 必须大于 0\")\n            return IntervalTrigger(seconds=seconds, timezone=timezone)\n\n        if schedule_type == \"cron\":\n            cron_expr = str(config.get(\"cron\") or \"\").strip()\n            if not cron_expr:\n                raise ValueError(\"cron 表达式不能为空\")\n            return CronTrigger.from_crontab(cron_expr, timezone=timezone)\n\n        if schedule_type == \"once\":\n            run_at_raw = config.get(\"run_at\")\n            run_at = self._parse_datetime(run_at_raw)\n            if not run_at:\n                raise ValueError(\"run_at 无效\")\n            return DateTrigger(run_date=run_at, timezone=timezone)\n\n        raise ValueError(f\"不支持的 schedule_type: {schedule_type}\")\n\n    def _remove_job(self, task_id: int) -> None:\n        scheduler = get_scheduler_manager()\n        if not scheduler:\n            return\n        scheduler.remove_job(self._job_id(task_id))\n\n    @staticmethod\n    def _job_id(task_id: int) -> str:\n        return f\"{TASK_JOB_PREFIX}{task_id}\"\n\n    @staticmethod\n    def _parse_payload(value: Any) -> dict[str, Any]:\n        if value is None:\n            return {}\n        if isinstance(value, dict):\n            return value\n        if isinstance(value, str):\n            try:\n                parsed = json.loads(value)\n                if isinstance(parsed, dict):\n                    return parsed\n            except json.JSONDecodeError:\n                return {}\n        return {}\n\n    @staticmethod\n    def _parse_datetime(value: Any) -> datetime | None:\n        if isinstance(value, datetime):\n            return naive_as_utc(value)\n        if isinstance(value, str):\n            try:\n                normalized = value.replace(\"Z\", \"+00:00\")\n                parsed = datetime.fromisoformat(normalized)\n            except ValueError:\n                return None\n            return naive_as_utc(parsed)\n        return None\n\n    @staticmethod\n    def _get_timezone(value: Any):\n        if not value:\n            return None\n        try:\n            return ZoneInfo(str(value))\n        except Exception:\n            return None\n\n    def _hydrate_task(self, task: dict[str, Any]) -> dict[str, Any]:\n        schedule_config = self._parse_payload(task.get(\"schedule_config\"))\n        schedule = {\n            \"type\": task.get(\"schedule_type\") or \"\",\n            **schedule_config,\n        }\n        action_payload = self._parse_payload(task.get(\"action_payload\"))\n        action = {\n            \"type\": task.get(\"action_type\") or \"\",\n            \"payload\": action_payload,\n        }\n        task[\"schedule\"] = schedule\n        task[\"action\"] = action\n        return task\n\n\ndef execute_automation_task(task_id: int) -> None:\n    \"\"\"调度器入口函数：执行指定自动化任务\"\"\"\n    service = AutomationTaskService()\n    service.run_task(task_id)\n"
  },
  {
    "path": "lifetrace/services/chat_service.py",
    "content": "\"\"\"Chat 业务逻辑层\n\n处理 Chat 相关的业务逻辑，包含会话管理和消息处理。\n会话上下文存储在数据库中，不再使用内存存储。\n\"\"\"\n\nimport json\nimport uuid\nfrom typing import Any\n\nfrom lifetrace.repositories.interfaces import IChatRepository\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.time_utils import get_utc_now\n\nlogger = get_logger()\n\n# 会话上下文的最大消息数量\nMAX_CONTEXT_LENGTH = 50\n\n\nclass ChatService:\n    \"\"\"Chat 业务逻辑层\"\"\"\n\n    def __init__(self, repository: IChatRepository):\n        self.repository = repository\n\n    # ===== 会话 ID 生成 =====\n\n    @staticmethod\n    def generate_session_id() -> str:\n        \"\"\"生成新的会话ID\"\"\"\n        return str(uuid.uuid4())\n\n    # ===== 会话上下文管理（数据库存储） =====\n\n    def create_new_session(self, session_id: str | None = None) -> str:\n        \"\"\"创建新的聊天会话\n\n        Args:\n            session_id: 可选的会话ID，如果不提供则自动生成\n\n        Returns:\n            会话ID\n        \"\"\"\n        if not session_id:\n            session_id = self.generate_session_id()\n\n        # 确保会话在数据库中存在\n        self.ensure_chat_exists(session_id, chat_type=\"general\")\n\n        # 初始化空上下文\n        self.repository.update_chat_context(session_id, json.dumps([]))\n\n        logger.info(f\"创建新会话: {session_id}\")\n        return session_id\n\n    def clear_session_context(self, session_id: str) -> bool:\n        \"\"\"清除会话上下文\n\n        Args:\n            session_id: 会话ID\n\n        Returns:\n            是否清除成功\n        \"\"\"\n        result = self.repository.update_chat_context(session_id, json.dumps([]))\n        if result:\n            logger.info(f\"清除会话上下文: {session_id}\")\n        return result\n\n    def get_session_context(self, session_id: str) -> list[dict[str, Any]]:\n        \"\"\"获取会话上下文\n\n        Args:\n            session_id: 会话ID\n\n        Returns:\n            上下文消息列表\n        \"\"\"\n        context_json = self.repository.get_chat_context(session_id)\n        if context_json:\n            try:\n                return json.loads(context_json)\n            except json.JSONDecodeError:\n                logger.warning(f\"会话上下文 JSON 解析失败: {session_id}\")\n                return []\n        return []\n\n    def add_to_session_context(self, session_id: str, role: str, content: str):\n        \"\"\"添加消息到会话上下文\n\n        Args:\n            session_id: 会话ID\n            role: 消息角色（user, assistant, system）\n            content: 消息内容\n        \"\"\"\n        # 获取当前上下文\n        context = self.get_session_context(session_id)\n\n        # 添加新消息\n        context.append(\n            {\n                \"role\": role,\n                \"content\": content,\n                \"timestamp\": get_utc_now().isoformat(),\n            }\n        )\n\n        # 限制上下文长度，避免数据过大\n        if len(context) > MAX_CONTEXT_LENGTH:\n            context = context[-MAX_CONTEXT_LENGTH:]\n\n        # 保存到数据库\n        self.repository.update_chat_context(session_id, json.dumps(context, ensure_ascii=False))\n\n    # ===== 数据库会话管理 =====\n\n    def create_chat(\n        self,\n        session_id: str,\n        chat_type: str = \"event\",\n        title: str | None = None,\n        context_id: int | None = None,\n        metadata: str | None = None,\n    ) -> dict[str, Any] | None:\n        \"\"\"创建聊天会话（数据库）\"\"\"\n        return self.repository.create_chat(\n            session_id=session_id,\n            chat_type=chat_type,\n            title=title,\n            context_id=context_id,\n            metadata=metadata,\n        )\n\n    def get_chat_by_session_id(self, session_id: str) -> dict[str, Any] | None:\n        \"\"\"根据 session_id 获取聊天会话\"\"\"\n        return self.repository.get_chat_by_session_id(session_id)\n\n    def ensure_chat_exists(\n        self,\n        session_id: str,\n        chat_type: str = \"event\",\n        title: str | None = None,\n        context_id: int | None = None,\n    ) -> dict[str, Any] | None:\n        \"\"\"确保聊天会话存在，如果不存在则创建\"\"\"\n        chat = self.repository.get_chat_by_session_id(session_id)\n        if not chat:\n            chat = self.repository.create_chat(\n                session_id=session_id,\n                chat_type=chat_type,\n                title=title,\n                context_id=context_id,\n            )\n            logger.info(f\"在数据库中创建会话: {session_id}, 类型: {chat_type}\")\n        return chat\n\n    def list_chats(\n        self,\n        chat_type: str | None = None,\n        limit: int = 50,\n        offset: int = 0,\n    ) -> list[dict[str, Any]]:\n        \"\"\"列出聊天会话\"\"\"\n        return self.repository.list_chats(\n            chat_type=chat_type,\n            limit=limit,\n            offset=offset,\n        )\n\n    def update_chat_title(self, session_id: str, title: str) -> bool:\n        \"\"\"更新聊天会话标题\"\"\"\n        return self.repository.update_chat_title(session_id, title)\n\n    def delete_chat(self, session_id: str) -> bool:\n        \"\"\"删除聊天会话及其所有消息\"\"\"\n        return self.repository.delete_chat(session_id)\n\n    # ===== 消息管理 =====\n\n    def add_message(\n        self,\n        session_id: str,\n        role: str,\n        content: str,\n        token_count: int | None = None,\n        model: str | None = None,\n        metadata: str | None = None,\n    ) -> dict[str, Any] | None:\n        \"\"\"添加消息到聊天会话（数据库）\"\"\"\n        return self.repository.add_message(\n            session_id=session_id,\n            role=role,\n            content=content,\n            token_count=token_count,\n            model=model,\n            metadata=metadata,\n        )\n\n    def get_messages(\n        self,\n        session_id: str,\n        limit: int | None = None,\n        offset: int = 0,\n    ) -> list[dict[str, Any]]:\n        \"\"\"获取聊天会话的消息列表\"\"\"\n        return self.repository.get_messages(\n            session_id=session_id,\n            limit=limit,\n            offset=offset,\n        )\n\n    def get_message_count(self, session_id: str) -> int:\n        \"\"\"获取聊天会话的消息数量\"\"\"\n        return self.repository.get_message_count(session_id)\n\n    def get_chat_summaries(\n        self,\n        chat_type: str | None = None,\n        limit: int = 10,\n    ) -> list[dict[str, Any]]:\n        \"\"\"获取聊天会话摘要列表\"\"\"\n        return self.repository.get_chat_summaries(\n            chat_type=chat_type,\n            limit=limit,\n        )\n\n    # ===== 历史记录 =====\n\n    def get_chat_history(\n        self,\n        session_id: str | None = None,\n        chat_type: str | None = None,\n    ) -> dict[str, Any]:\n        \"\"\"获取聊天历史记录\"\"\"\n        if session_id:\n            # 返回指定会话的历史记录\n            messages = self.repository.get_messages(session_id)\n            return {\n                \"session_id\": session_id,\n                \"history\": messages,\n                \"message\": f\"会话 {session_id} 的历史记录\",\n            }\n        else:\n            # 返回所有会话的摘要信息\n            sessions_info = self.repository.get_chat_summaries(chat_type=chat_type, limit=20)\n            return {\"sessions\": sessions_info, \"message\": \"所有会话摘要\"}\n"
  },
  {
    "path": "lifetrace/services/config_service.py",
    "content": "\"\"\"配置服务层 - 处理配置的保存、比对和重载逻辑\"\"\"\n\nimport os\nimport shutil\nfrom collections.abc import Callable\nfrom typing import Any\n\nimport yaml\n\nfrom lifetrace.jobs.scheduler import get_scheduler_manager\nfrom lifetrace.llm.llm_client import LLMClient\nfrom lifetrace.services.asr_client import ASRClient\nfrom lifetrace.util.base_paths import get_config_dir, get_user_config_dir\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.settings import reload_settings, settings\n\nlogger = get_logger()\n\n\n# LLM 相关配置键（支持两种格式，用于判断是否需要重新初始化 LLM）\nLLM_RELATED_BACKEND_KEYS = [\n    # 点分隔格式（后端标准）\n    \"llm.api_key\",\n    \"llm.base_url\",\n    \"llm.model\",\n    # snake_case 格式（前端 fetcher 转换后发送的格式）\n    \"llm_api_key\",\n    \"llm_base_url\",\n    \"llm_model\",\n]\n\n# ASR 相关配置键（支持两种格式，用于判断是否需要重新初始化 ASR）\nASR_RELATED_BACKEND_KEYS = [\n    # 点分隔格式（后端标准）\n    \"audio.asr.api_key\",\n    \"audio.asr.base_url\",\n    \"audio.asr.model\",\n    # snake_case 格式（前端 fetcher 转换后发送的格式）\n    \"audio_asr_api_key\",\n    \"audio_asr_base_url\",\n    \"audio_asr_model\",\n]\n\n# 任务启用状态配置键到调度器任务ID的映射（支持两种格式）\nJOB_ENABLED_CONFIG_TO_JOB_ID = {\n    # 点分隔格式（后端标准）\n    \"jobs.recorder.enabled\": \"recorder_job\",\n    \"jobs.ocr.enabled\": \"ocr_job\",\n    \"jobs.clean_data.enabled\": \"clean_data_job\",\n    \"jobs.activity_aggregator.enabled\": \"activity_aggregator_job\",\n    \"jobs.todo_recorder.enabled\": \"todo_recorder_job\",\n    \"jobs.audio_recording.enabled\": \"audio_recording_job\",\n    # snake_case 格式（前端 fetcher 转换后发送的格式）\n    \"jobs_recorder_enabled\": \"recorder_job\",\n    \"jobs_ocr_enabled\": \"ocr_job\",\n    \"jobs_clean_data_enabled\": \"clean_data_job\",\n    \"jobs_activity_aggregator_enabled\": \"activity_aggregator_job\",\n    \"jobs_todo_recorder_enabled\": \"todo_recorder_job\",\n    \"jobs_audio_recording_enabled\": \"audio_recording_job\",\n}\n\n# 联动配置映射：配置键 -> 需要联动的配置键列表\n# 当一个配置变化时，需要同步更新关联的配置\nJOB_LINKED_CONFIG = {\n    # auto_todo_detection 与 todo_recorder 联动\n    \"jobs.auto_todo_detection.enabled\": [\"jobs.todo_recorder.enabled\"],\n    \"jobs_auto_todo_detection_enabled\": [\"jobs_todo_recorder_enabled\"],\n    \"jobs.todo_recorder.enabled\": [\"jobs.auto_todo_detection.enabled\"],\n    \"jobs_todo_recorder_enabled\": [\"jobs_auto_todo_detection_enabled\"],\n}\n\n\n# 简单前缀映射：prefix -> (prefix_length, dot_prefix)\n_SIMPLE_PREFIX_MAP: dict[str, tuple[int, str]] = {\n    \"llm_\": (4, \"llm\"),\n    \"server_\": (7, \"server\"),\n    \"chat_\": (5, \"chat\"),\n    \"dify_\": (5, \"dify\"),\n    \"tavily_\": (7, \"tavily\"),\n}\n\n# ASR 配置键名映射（保留下划线的键名）\n_ASR_KEY_MAPPING: dict[str, str] = {\n    \"audio_asr_api_key\": \"audio.asr.api_key\",\n    \"audio_asr_base_url\": \"audio.asr.base_url\",\n    \"audio_asr_model\": \"audio.asr.model\",\n    \"audio_asr_sample_rate\": \"audio.asr.sample_rate\",\n    \"audio_asr_format\": \"audio.asr.format\",\n    \"audio_asr_semantic_punctuation_enabled\": \"audio.asr.semantic_punctuation_enabled\",\n    \"audio_asr_max_sentence_silence\": \"audio.asr.max_sentence_silence\",\n    \"audio_asr_heartbeat\": \"audio.asr.heartbeat\",\n    \"audio_is_24x7\": \"audio.is_24x7\",\n}\n\n# 复合任务名映射：首部分 -> 完整任务名\n_COMPOUND_JOB_NAMES: dict[str, str] = {\n    \"clean\": \"clean_data\",\n    \"activity\": \"activity_aggregator\",\n    \"auto\": \"auto_todo_detection\",\n    \"todo\": \"todo_recorder\",\n}\n\n# 最小 jobs 配置部分数量\n_MIN_JOBS_PARTS = 3\n\n\ndef _convert_jobs_key(parts: list[str]) -> str:\n    \"\"\"转换 jobs 相关的配置键\"\"\"\n    job_name = parts[1]  # recorder, ocr, clean_data, activity_aggregator, etc.\n\n    # 处理复合任务名\n    if job_name in _COMPOUND_JOB_NAMES:\n        full_job_name = _COMPOUND_JOB_NAMES[job_name]\n        name_parts = full_job_name.split(\"_\")\n        name_length = len(name_parts)\n\n        if len(parts) > name_length and parts[1 : name_length + 1] == name_parts:\n            remaining = parts[name_length + 1 :]\n            if remaining:\n                return f\"jobs.{full_job_name}.{'.'.join(remaining)}\"\n            return f\"jobs.{full_job_name}\"\n\n    # 简单任务名\n    remaining = parts[2:]\n    if not remaining:\n        return f\"jobs.{job_name}\"\n\n    # 处理 params 子配置\n    if remaining[0] == \"params\" and len(remaining) > 1:\n        return f\"jobs.{job_name}.params.{'.'.join(remaining[1:])}\"\n    return f\"jobs.{job_name}.{'.'.join(remaining)}\"\n\n\ndef snake_to_dot_notation(key: str) -> str:\n    \"\"\"将 snake_case 格式的键转换为点分隔格式\n\n    前端 fetcher 会将 camelCase 转换为 snake_case 发送给后端，\n    例如: jobsRecorderEnabled -> jobs_recorder_enabled\n    后端配置文件使用点分隔格式，例如: jobs.recorder.enabled\n\n    Args:\n        key: snake_case 格式的键，如 \"jobs_recorder_enabled\" 或 \"llm_api_key\"\n\n    Returns:\n        点分隔格式的键，如 \"jobs.recorder.enabled\" 或 \"llm.api_key\"\n    \"\"\"\n    # 如果已经是点分隔格式或不包含下划线，直接返回\n    if \".\" in key or \"_\" not in key:\n        return key\n\n    # 优先检查 ASR 配置键名映射（需要保留下划线的键）\n    if key in _ASR_KEY_MAPPING:\n        return _ASR_KEY_MAPPING[key]\n\n    # 处理 jobs 相关配置\n    if key.startswith(\"jobs_\"):\n        parts = key.split(\"_\")\n        if parts[0] == \"jobs\" and len(parts) >= _MIN_JOBS_PARTS:\n            return _convert_jobs_key(parts)\n\n    # 处理简单前缀（llm, server, chat）\n    for prefix, (prefix_len, dot_prefix) in _SIMPLE_PREFIX_MAP.items():\n        if key.startswith(prefix):\n            return f\"{dot_prefix}.{key[prefix_len:]}\"\n\n    # 默认：简单地将下划线替换为点\n    return key.replace(\"_\", \".\")\n\n\ndef dot_to_snake_notation(key: str) -> str:\n    \"\"\"将点分隔格式的键转换为 snake_case 格式\n\n    后端配置文件使用点分隔格式，例如: jobs.recorder.enabled\n    前端 fetcher 需要 snake_case 格式才能转换为 camelCase，例如: jobs_recorder_enabled\n\n    Args:\n        key: 点分隔格式的键，如 \"jobs.recorder.enabled\" 或 \"llm.api_key\"\n\n    Returns:\n        snake_case 格式的键，如 \"jobs_recorder_enabled\" 或 \"llm_api_key\"\n    \"\"\"\n    # 如果已经是 snake_case 格式或不包含点，直接返回\n    if \".\" not in key:\n        return key\n\n    # 简单地将点替换为下划线\n    return key.replace(\".\", \"_\")\n\n\ndef is_llm_configured() -> bool:\n    \"\"\"检查 LLM 是否已配置\n\n    Returns:\n        bool: 如果 llm_key 和 base_url 都已配置（不是占位符或空），返回 True\n    \"\"\"\n    invalid_values = [\"\", \"xxx\", \"YOUR_API_KEY_HERE\", \"YOUR_BASE_URL_HERE\", \"YOUR_LLM_KEY_HERE\"]\n    return (\n        settings.llm.api_key not in invalid_values and settings.llm.base_url not in invalid_values\n    )\n\n\nclass ConfigService:\n    \"\"\"配置服务类 - 负责配置的保存、比对和热加载\"\"\"\n\n    def __init__(self):\n        \"\"\"初始化配置服务\"\"\"\n        self._config_path = str(get_user_config_dir() / \"config.yaml\")\n\n    def compare_config_changes(self, new_settings: dict[str, Any]) -> tuple[bool, list[str]]:\n        \"\"\"比对配置变更\n\n        Args:\n            new_settings: 前端提交的配置字典（键可以是 snake_case 或点分隔格式）\n\n        Returns:\n            (是否有变更, 变更项列表)\n        \"\"\"\n        config_changed = False\n        changed_items = []\n\n        for raw_key, new_value in new_settings.items():\n            # 将 snake_case 格式转换为点分隔格式\n            backend_key = snake_to_dot_notation(raw_key)\n            try:\n                # 获取当前配置值\n                old_value = settings.get(backend_key)\n\n                # 比对新旧值\n                if old_value != new_value:\n                    config_changed = True\n                    # 记录变更项（敏感信息脱敏）\n                    if \"api_key\" in backend_key.lower():\n                        changed_items.append(\n                            f\"{backend_key}: {str(old_value)[:10] if old_value else 'None'}... -> {str(new_value)[:10]}...\"\n                        )\n                    else:\n                        changed_items.append(f\"{backend_key}: {old_value} -> {new_value}\")\n            except KeyError:\n                # 配置项不存在，视为新增配置\n                config_changed = True\n                if \"api_key\" in backend_key.lower():\n                    changed_items.append(f\"{backend_key}: (新增) {str(new_value)[:10]}...\")\n                else:\n                    changed_items.append(f\"{backend_key}: (新增) {new_value}\")\n\n        return config_changed, changed_items\n\n    def get_llm_config(self) -> dict[str, Any]:\n        \"\"\"获取当前 LLM 配置\n\n        Returns:\n            LLM 配置字典\n        \"\"\"\n        return {\n            \"api_key\": settings.llm.api_key,\n            \"base_url\": settings.llm.base_url,\n            \"model\": settings.llm.model,\n        }\n\n    def get_asr_config(self) -> dict[str, Any]:\n        \"\"\"获取当前 ASR 配置\n\n        Returns:\n            ASR 配置字典\n        \"\"\"\n        try:\n            return {\n                \"api_key\": settings.audio.asr.api_key,\n                \"base_url\": settings.audio.asr.base_url,\n                \"model\": settings.audio.asr.model,\n            }\n        except Exception:\n            return {\n                \"api_key\": None,\n                \"base_url\": None,\n                \"model\": None,\n            }\n\n    def get_config_for_frontend(self) -> dict[str, Any]:\n        \"\"\"获取配置（转换为 snake_case 格式供前端使用）\n\n        前端 fetcher 会将 snake_case 转换为 camelCase。\n        后端配置文件使用点分隔格式，需要转换为 snake_case 格式。\n\n        Returns:\n            snake_case 格式的配置字典，前端 fetcher 会自动转换为 camelCase\n        \"\"\"\n        # 定义需要获取的配置项（后端格式）\n        backend_config_keys = [\n            # 录制配置\n            \"jobs.recorder.params.auto_exclude_self\",\n            \"jobs.recorder.params.blacklist.enabled\",\n            \"jobs.recorder.params.blacklist.apps\",\n            \"jobs.recorder.enabled\",\n            \"jobs.recorder.interval\",\n            \"jobs.recorder.params.screens\",\n            \"jobs.recorder.params.deduplicate\",\n            # LLM配置\n            \"llm.api_key\",\n            \"llm.base_url\",\n            \"llm.model\",\n            \"llm.temperature\",\n            \"llm.max_tokens\",\n            # 服务器配置\n            \"server.host\",\n            \"server.port\",\n            # Clean data 配置\n            \"jobs.clean_data.params.max_days\",\n            \"jobs.clean_data.params.max_screenshots\",\n            # 聊天配置\n            \"chat.enable_history\",\n            \"chat.history_limit\",\n            # 自动待办检测配置\n            \"jobs.auto_todo_detection.enabled\",\n            \"jobs.auto_todo_detection.params.whitelist.apps\",\n            # Todo 专用录制配置\n            \"jobs.todo_recorder.enabled\",\n            \"jobs.todo_recorder.interval\",\n            # Dify 配置\n            \"dify.enabled\",\n            \"dify.api_key\",\n            \"dify.base_url\",\n            # Tavily 配置（联网搜索）\n            \"tavily.api_key\",\n            # 音频录制配置\n            \"audio.is_24x7\",\n            # 音频录制任务配置\n            \"jobs.audio_recording.enabled\",\n            \"jobs.audio_recording.interval\",\n            # 音频识别（ASR）配置\n            \"audio.asr.api_key\",\n            \"audio.asr.base_url\",\n            \"audio.asr.model\",\n            \"audio.asr.sample_rate\",\n            \"audio.asr.format\",\n            \"audio.asr.semantic_punctuation_enabled\",\n            \"audio.asr.max_sentence_silence\",\n            \"audio.asr.heartbeat\",\n        ]\n\n        config_dict = {}\n        for backend_key in backend_config_keys:\n            try:\n                value = settings.get(backend_key)\n                # 将点分隔格式转换为 snake_case 格式，以便前端 fetcher 能正确转换为 camelCase\n                frontend_key = dot_to_snake_notation(backend_key)\n                config_dict[frontend_key] = value\n            except KeyError:\n                # 配置项不存在，跳过或使用默认值\n                logger.debug(f\"配置项 {backend_key} 不存在，跳过\")\n                continue\n\n        return config_dict\n\n    def update_config_file(self, new_settings: dict[str, Any], config_path: str) -> None:\n        \"\"\"更新配置文件\n\n        Args:\n            new_settings: 配置字典（键可以是 snake_case 或点分隔格式）\n            config_path: 配置文件路径\n        \"\"\"\n        # 读取现有配置\n        with open(config_path, encoding=\"utf-8\") as f:\n            current_config = yaml.safe_load(f) or {}\n\n        # 更新配置\n        for raw_key, value in new_settings.items():\n            # 将 snake_case 格式转换为点分隔格式\n            backend_key = snake_to_dot_notation(raw_key)\n            logger.info(f\"更新配置: {raw_key} -> {backend_key} = {value}\")\n\n            # 处理嵌套配置键\n            keys = backend_key.split(\".\")\n            current = current_config\n            for key in keys[:-1]:\n                if key not in current:\n                    current[key] = {}\n                current = current[key]\n            current[keys[-1]] = value\n\n        # 保存配置文件\n        with open(config_path, \"w\", encoding=\"utf-8\") as f:\n            yaml.dump(current_config, f, allow_unicode=True, sort_keys=False)\n\n        logger.info(f\"配置已保存到: {config_path}\")\n\n    def _collect_jobs_to_sync(\n        self, job_config_keys: list[str], new_settings: dict[str, Any]\n    ) -> dict[str, bool]:\n        \"\"\"收集需要同步的任务（包括联动任务）\"\"\"\n        jobs_to_sync: dict[str, bool] = {}\n\n        for config_key in job_config_keys:\n            job_id = JOB_ENABLED_CONFIG_TO_JOB_ID[config_key]\n            enabled = new_settings[config_key]\n            jobs_to_sync[job_id] = enabled\n\n            # 检查是否有联动配置\n            if config_key in JOB_LINKED_CONFIG:\n                self._add_linked_jobs(config_key, job_id, enabled, jobs_to_sync)\n\n        return jobs_to_sync\n\n    def _add_linked_jobs(\n        self, config_key: str, job_id: str, enabled: bool, jobs_to_sync: dict[str, bool]\n    ) -> None:\n        \"\"\"添加联动任务到同步列表\"\"\"\n        linked_keys = JOB_LINKED_CONFIG[config_key]\n        for linked_key in linked_keys:\n            if linked_key in JOB_ENABLED_CONFIG_TO_JOB_ID:\n                linked_job_id = JOB_ENABLED_CONFIG_TO_JOB_ID[linked_key]\n                if linked_job_id not in jobs_to_sync:\n                    jobs_to_sync[linked_job_id] = enabled\n                    logger.info(f\"📢 联动同步：{job_id} -> {linked_job_id} = {enabled}\")\n\n    def sync_job_states_if_needed(self, new_settings: dict[str, Any]) -> None:\n        \"\"\"如果任务启用状态发生变化，同步到调度器\n\n        Args:\n            new_settings: 配置字典（键可以是 snake_case 或点分隔格式）\n        \"\"\"\n        job_config_keys = [key for key in new_settings if key in JOB_ENABLED_CONFIG_TO_JOB_ID]\n\n        if not job_config_keys:\n            return\n\n        try:\n            scheduler_manager = get_scheduler_manager()\n            jobs_to_sync = self._collect_jobs_to_sync(job_config_keys, new_settings)\n\n            for job_id, enabled in jobs_to_sync.items():\n                job = scheduler_manager.get_job(job_id)\n                if not job:\n                    logger.warning(f\"任务 {job_id} 不存在，跳过状态同步\")\n                    continue\n\n                is_running = job.next_run_time is not None\n                if enabled and not is_running:\n                    scheduler_manager.resume_job(job_id)\n                    logger.info(f\"📢 配置变更：任务 {job_id} 已恢复运行\")\n                elif not enabled and is_running:\n                    scheduler_manager.pause_job(job_id)\n                    logger.info(f\"📢 配置变更：任务 {job_id} 已暂停\")\n\n        except Exception as e:\n            logger.error(f\"同步任务状态失败: {e}\", exc_info=True)\n\n    def reinitialize_llm_if_needed(\n        self,\n        new_settings: dict[str, Any],\n        old_llm_config: dict[str, Any],\n        is_llm_configured_callback: Callable[[], None] | None = None,\n    ) -> None:\n        \"\"\"如果 LLM 配置发生变化，重新初始化 LLM 客户端\n\n        Args:\n            new_settings: 配置字典（键为后端格式）\n            old_llm_config: 旧的 LLM 配置\n            is_llm_configured_callback: 更新 LLM 配置状态的回调函数\n        \"\"\"\n        # 检测是否有 LLM 相关配置项在请求中\n        has_llm_keys = any(key in LLM_RELATED_BACKEND_KEYS for key in new_settings)\n\n        if not has_llm_keys:\n            return\n\n        # 获取新的 LLM 配置值\n        new_llm_config = self.get_llm_config()\n\n        # 比对新旧配置值\n        llm_config_changed = old_llm_config != new_llm_config\n\n        if llm_config_changed:\n            logger.info(\"检测到 LLM 配置实际发生变更，正在热加载 LLM 客户端...\")\n            logger.info(\n                f\"旧配置: API Key={old_llm_config['api_key'][:10] if old_llm_config['api_key'] else 'None'}..., \"\n                f\"Base URL={old_llm_config['base_url']}, Model={old_llm_config['model']}\"\n            )\n            logger.info(\n                f\"新配置: API Key={new_llm_config['api_key'][:10] if new_llm_config['api_key'] else 'None'}..., \"\n                f\"Base URL={new_llm_config['base_url']}, Model={new_llm_config['model']}\"\n            )\n\n            try:\n                # 更新配置状态\n                if is_llm_configured_callback:\n                    is_llm_configured_callback()\n\n                configured = is_llm_configured()\n                status = \"已配置\" if configured else \"未配置\"\n                logger.info(f\"LLM 配置状态已更新: {status}\")\n\n                # 重新初始化 LLM 客户端单例（所有服务共享此实例）\n                llm_client = LLMClient()\n                client_available = llm_client.reinitialize()\n                logger.info(f\"LLM 客户端已重新初始化 - 可用: {client_available}\")\n\n                if client_available:\n                    logger.info(\n                        f\"LLM 客户端热加载成功 - \"\n                        f\"API Key: {llm_client.api_key[:10]}..., \"\n                        f\"Model: {llm_client.model}\"\n                    )\n                    logger.info(\"所有服务将自动使用更新后的 LLM 客户端\")\n                else:\n                    logger.warning(\"LLM 客户端重新初始化后不可用，请检查配置\")\n\n                logger.info(\"LLM 配置热加载完成\")\n            except Exception as e:\n                logger.error(f\"热加载 LLM 客户端失败: {e}\", exc_info=True)\n        else:\n            logger.info(\"LLM 配置未发生实际变更，跳过重新加载\")\n\n    def reinitialize_asr_if_needed(\n        self,\n        new_settings: dict[str, Any],\n        old_asr_config: dict[str, Any],\n    ) -> None:\n        \"\"\"如果 ASR 配置发生变化，重新初始化 ASR 客户端\n\n        Args:\n            new_settings: 配置字典（键为后端格式）\n            old_asr_config: 旧的 ASR 配置\n        \"\"\"\n        # 检测是否有 ASR 相关配置项在请求中\n        has_asr_keys = any(key in ASR_RELATED_BACKEND_KEYS for key in new_settings)\n\n        if not has_asr_keys:\n            return\n\n        # 获取新的 ASR 配置值\n        new_asr_config = self.get_asr_config()\n\n        # 比对新旧配置值\n        asr_config_changed = old_asr_config != new_asr_config\n\n        if asr_config_changed:\n            logger.info(\"检测到 ASR 配置实际发生变更，正在热加载 ASR 客户端...\")\n            logger.info(\n                f\"旧配置: API Key={old_asr_config['api_key'][:10] if old_asr_config['api_key'] else 'None'}..., \"\n                f\"Base URL={old_asr_config['base_url']}, Model={old_asr_config['model']}\"\n            )\n            logger.info(\n                f\"新配置: API Key={new_asr_config['api_key'][:10] if new_asr_config['api_key'] else 'None'}..., \"\n                f\"Base URL={new_asr_config['base_url']}, Model={new_asr_config['model']}\"\n            )\n\n            try:\n                # 重新初始化 ASR 客户端单例\n                asr_client = ASRClient()\n                asr_client.reinitialize()\n                logger.info(\n                    f\"ASR 客户端热加载成功 - \"\n                    f\"API Key: {asr_client.api_key[:10] if asr_client.api_key else 'None'}..., \"\n                    f\"Model: {asr_client.model}\"\n                )\n                logger.info(\"ASR 配置热加载完成\")\n            except Exception as e:\n                logger.error(f\"热加载 ASR 客户端失败: {e}\", exc_info=True)\n        else:\n            logger.info(\"ASR 配置未发生实际变更，跳过重新加载\")\n\n    def save_config(\n        self,\n        new_settings: dict[str, Any],\n        is_llm_configured_callback: Callable[[], None] | None = None,\n    ) -> dict[str, Any]:\n        \"\"\"保存配置（主入口方法）\n\n        Args:\n            new_settings: 配置字典（键为后端格式）\n            is_llm_configured_callback: 更新 LLM 配置状态的回调函数\n\n        Returns:\n            操作结果字典\n        \"\"\"\n        config_path = self._config_path\n\n        # 如果配置文件不存在，从默认配置复制\n        if not os.path.exists(config_path):\n            self._init_config_file()\n\n        # 1. 先比对配置是否真的发生了变化\n        config_changed, changed_items = self.compare_config_changes(new_settings)\n\n        # 如果配置没有发生变化，直接返回\n        if not config_changed:\n            logger.info(\"配置未发生变化，跳过保存和重载\")\n            return {\"success\": True, \"message\": \"配置未发生变化\"}\n\n        # 记录变更信息\n        logger.info(f\"检测到配置变更，共 {len(changed_items)} 项:\")\n        for item in changed_items:\n            logger.info(f\"  - {item}\")\n\n        # 2. 保存旧的 LLM 和 ASR 配置值（用于后续比对是否需要重新初始化）\n        old_llm_config = self.get_llm_config()\n        old_asr_config = self.get_asr_config()\n\n        # 3. 更新配置文件\n        self.update_config_file(new_settings, config_path)\n\n        # 4. 重新加载配置（使用封装函数，正确处理返回值）\n        reload_success = reload_settings()\n        if reload_success:\n            logger.info(\"配置已重新加载到内存\")\n        else:\n            logger.warning(\"配置重新加载失败，但文件已保存\")\n\n        # 5. 同步任务状态到调度器（在配置重载后执行，确保使用最新的配置值）\n        self.sync_job_states_if_needed(new_settings)\n\n        # 6. 如果需要，重新初始化 LLM 客户端\n        self.reinitialize_llm_if_needed(new_settings, old_llm_config, is_llm_configured_callback)\n\n        # 7. 如果需要，重新初始化 ASR 客户端\n        self.reinitialize_asr_if_needed(new_settings, old_asr_config)\n\n        return {\"success\": True, \"message\": \"配置保存成功\"}\n\n    def _init_config_file(self) -> None:\n        \"\"\"从默认配置初始化配置文件\"\"\"\n        default_config_path = get_config_dir() / \"default_config.yaml\"\n\n        if not default_config_path.exists():\n            raise FileNotFoundError(\n                f\"默认配置文件不存在: {default_config_path}\\n\"\n                \"请确保 default_config.yaml 文件存在于 config 目录中\"\n            )\n\n        os.makedirs(os.path.dirname(self._config_path), exist_ok=True)\n        shutil.copy2(default_config_path, self._config_path)\n        reload_settings()\n"
  },
  {
    "path": "lifetrace/services/dify_client.py",
    "content": "\"\"\"Dify 集成客户端（测试模式用）\n\n目前仅用于在 Chat 流式接口中提供一个简单的测试通道：\n- 接收一段用户消息\n- 调用 Dify 的 chat-messages 接口（支持流式和非流式）\n- 返回流式增量文本或完整回复\n\n如果后续需要更复杂的能力（带历史、多轮、变量等），可以在此文件中继续扩展。\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nfrom typing import TYPE_CHECKING, Any\n\nimport httpx\n\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.settings import settings\n\nlogger = get_logger()\n\nif TYPE_CHECKING:\n    from collections.abc import Iterator\n\n\ndef _get_dify_config() -> dict[str, str]:\n    \"\"\"从 dynaconf 配置中读取 Dify 相关设置。\n\n    支持以下 dynaconf 路径（config.yaml 中）：\n    - dify.enabled: 是否启用 Dify（可选，默认为 True）\n    - dify.api_key: Dify API Key（必填）\n    - dify.base_url: Dify API Base URL，默认 https://api.dify.ai/v1\n    \"\"\"\n    enabled = getattr(getattr(settings, \"dify\", {}), \"enabled\", True)\n    if enabled is False:\n        raise RuntimeError(\"Dify 功能已在配置中关闭（dify.enabled = false）\")\n\n    api_key = getattr(getattr(settings, \"dify\", {}), \"api_key\", \"\").strip()\n    if not api_key:\n        raise RuntimeError(\"未配置 Dify API Key（dify.api_key），请在设置面板中填写\")\n\n    base_url = getattr(getattr(settings, \"dify\", {}), \"base_url\", \"https://api.dify.ai/v1\")\n    base_url = str(base_url).rstrip(\"/\")\n\n    return {\n        \"api_key\": api_key,\n        \"base_url\": base_url,\n    }\n\n\ndef _parse_sse_event_block(event_block: str) -> dict[str, Any] | None:\n    \"\"\"解析单个 SSE 事件块，提取 JSON 数据。\n\n    Args:\n        event_block: SSE 事件块文本（不包含 \\n\\n 分隔符）\n\n    Returns:\n        解析后的 JSON 数据字典，如果解析失败则返回 None\n    \"\"\"\n    for raw_line in event_block.split(\"\\n\"):\n        line = raw_line.strip()\n        if line.startswith(\"data: \"):\n            data_str = line[6:]  # 跳过 \"data: \" 前缀\n            try:\n                return json.loads(data_str)\n            except json.JSONDecodeError:\n                logger.warning(f\"[dify] 解析 SSE 数据 JSON 失败，原始数据: {data_str}\")\n                continue\n    return None\n\n\ndef _extract_answer_from_sse_data(data_content: dict[str, Any]) -> str | None:\n    \"\"\"从 SSE 数据内容中提取 answer 字段。\n\n    Args:\n        data_content: 解析后的 SSE 事件数据字典\n\n    Returns:\n        answer 文本内容，如果不存在则返回 None\n    \"\"\"\n    answer_delta = data_content.get(\"answer\", \"\")\n    return answer_delta if answer_delta else None\n\n\ndef _process_sse_event_block(event_block: str) -> Iterator[str]:\n    \"\"\"处理单个 SSE 事件块，提取并 yield answer 内容。\n\n    Args:\n        event_block: SSE 事件块文本（已去除 \\n\\n 分隔符并 strip）\n\n    Yields:\n        增量文本内容（如果有）\n    \"\"\"\n    if not event_block:\n        return\n\n    data_content = _parse_sse_event_block(event_block)\n    if not data_content:\n        return\n\n    answer_delta = _extract_answer_from_sse_data(data_content)\n    if answer_delta:\n        yield answer_delta\n\n    event_type = data_content.get(\"event\", \"\")\n    if event_type == \"message_end\":\n        logger.info(\"[dify] 流式响应接收完成\")\n\n\ndef _parse_sse_stream(response: httpx.Response) -> Iterator[str]:\n    \"\"\"解析 SSE 格式的流式响应，提取 answer 字段。\n\n    Args:\n        response: httpx 流式响应对象\n\n    Yields:\n        增量文本内容\n    \"\"\"\n    buffer = \"\"\n    for chunk in response.iter_text():\n        if not chunk:\n            continue\n\n        buffer += chunk\n\n        # SSE 格式：每个事件以 \\n\\n 分隔\n        while \"\\n\\n\" in buffer:\n            event_block, buffer = buffer.split(\"\\n\\n\", 1)\n            yield from _process_sse_event_block(event_block.strip())\n\n    # 处理剩余的 buffer（最后一个可能不完整的 SSE 事件）\n    if buffer.strip():\n        yield from _process_sse_event_block(buffer.strip())\n\n\ndef _handle_blocking_response(response: httpx.Response) -> Iterator[str]:\n    \"\"\"处理 blocking 模式的响应，返回完整回复。\n\n    Args:\n        response: httpx 响应对象\n\n    Yields:\n        完整的回复文本（作为单个元素）\n    \"\"\"\n    try:\n        data = response.json()\n    except json.JSONDecodeError as e:\n        logger.error(f\"[dify] 解析响应 JSON 失败: {e}\")\n        raise\n\n    # Dify 一般会返回 answer 字段，这里做一些兜底\n    answer = data.get(\"answer\") or data.get(\"output\") or data.get(\"result\") or \"\"\n\n    if not answer:\n        logger.warning(\"[dify] 响应中未找到 answer 字段，将返回原始 JSON 文本\")\n        answer = str(data)\n\n    yield answer\n\n\ndef call_dify_chat(\n    message: str,\n    user: str | None = None,\n    response_mode: str = \"streaming\",\n    inputs: dict[str, Any] | None = None,\n    **extra_payload: Any,\n) -> Iterator[str]:\n    \"\"\"调用 Dify chat-messages 接口，返回文本生成器。\n\n    Args:\n        message: 用户消息内容\n        user: 用户标识，默认为 \"lifetrace-user\"\n        response_mode: 响应模式，可选 \"streaming\"（默认）或 \"blocking\"\n        inputs: Dify 输入变量字典，默认为空字典\n        **extra_payload: 额外的 payload 参数，会被合并到请求 payload 中\n\n    Yields:\n        文本内容（流式模式下为增量文本，阻塞模式下为完整文本）\n    \"\"\"\n    cfg = _get_dify_config()\n\n    headers = {\n        \"Authorization\": f\"Bearer {cfg['api_key']}\",\n        \"Content-Type\": \"application/json\",\n    }\n\n    # 构建 payload，允许前端通过参数自定义所有字段\n    payload: dict[str, Any] = {\n        \"inputs\": inputs if inputs is not None else {},\n        \"query\": message,\n        \"response_mode\": response_mode,\n        \"user\": user or \"lifetrace-user\",\n        **extra_payload,  # 允许前端传入额外的参数\n    }\n\n    url = f\"{cfg['base_url']}/chat-messages\"\n\n    logger.info(f\"[dify] 调用 Dify chat-messages 接口（{response_mode} 模式）\")\n\n    try:\n        with httpx.Client(timeout=60) as client:\n            if response_mode == \"streaming\":\n                # 流式模式：使用 stream 方法\n                with client.stream(\"POST\", url, headers=headers, json=payload) as response:\n                    response.raise_for_status()\n                    yield from _parse_sse_stream(response)\n            else:\n                # 阻塞模式：使用普通 post 方法\n                response = client.post(url, headers=headers, json=payload)\n                response.raise_for_status()\n                yield from _handle_blocking_response(response)\n\n    except Exception as e:\n        logger.error(f\"[dify] 调用失败 ({response_mode} 模式): {e}\")\n        raise\n"
  },
  {
    "path": "lifetrace/services/event_service.py",
    "content": "\"\"\"Event 业务逻辑层\n\n处理 Event 相关的业务逻辑，与数据访问层解耦。\n\"\"\"\n\nimport importlib\nfrom datetime import datetime\nfrom typing import Any\n\nfrom fastapi import HTTPException\n\nfrom lifetrace.repositories.interfaces import IEventRepository, IOcrRepository\nfrom lifetrace.schemas.event import EventDetailResponse, EventListResponse, EventResponse\nfrom lifetrace.schemas.screenshot import ScreenshotResponse\nfrom lifetrace.util.logging_config import get_logger\n\nlogger = get_logger()\n\n\nclass EventService:\n    \"\"\"Event 业务逻辑层\"\"\"\n\n    def __init__(self, event_repository: IEventRepository, ocr_repository: IOcrRepository):\n        self.event_repo = event_repository\n        self.ocr_repo = ocr_repository\n\n    def list_events(\n        self,\n        limit: int,\n        offset: int,\n        start_date: datetime | None,\n        end_date: datetime | None,\n        app_name: str | None,\n    ) -> EventListResponse:\n        \"\"\"获取事件列表\"\"\"\n        logger.info(\n            f\"获取事件列表 - 参数: limit={limit}, offset={offset}, \"\n            f\"start_date={start_date}, end_date={end_date}, app_name={app_name}\"\n        )\n\n        events = self.event_repo.list_events(\n            limit=limit,\n            offset=offset,\n            start_date=start_date,\n            end_date=end_date,\n            app_name=app_name,\n        )\n        total_count = self.event_repo.count_events(\n            start_date=start_date,\n            end_date=end_date,\n            app_name=app_name,\n        )\n\n        logger.info(f\"获取事件列表 - 结果: events_count={len(events)}, total_count={total_count}\")\n\n        return EventListResponse(\n            events=[EventResponse(**e) for e in events],\n            total_count=total_count,\n        )\n\n    def count_events(\n        self,\n        start_date: datetime | None,\n        end_date: datetime | None,\n        app_name: str | None,\n    ) -> dict[str, int]:\n        \"\"\"获取事件总数\"\"\"\n        count = self.event_repo.count_events(\n            start_date=start_date,\n            end_date=end_date,\n            app_name=app_name,\n        )\n        return {\"count\": count}\n\n    def get_event_detail(self, event_id: int) -> EventDetailResponse:\n        \"\"\"获取事件详情\"\"\"\n        event_summary = self.event_repo.get_summary(event_id)\n        if not event_summary:\n            raise HTTPException(status_code=404, detail=\"事件不存在\")\n\n        screenshots = self.event_repo.get_screenshots(event_id)\n        screenshots_resp = [\n            ScreenshotResponse(\n                id=s[\"id\"],\n                file_path=s[\"file_path\"],\n                app_name=s[\"app_name\"],\n                window_title=s[\"window_title\"],\n                created_at=s[\"created_at\"],\n                text_content=None,\n                width=s[\"width\"],\n                height=s[\"height\"],\n            )\n            for s in screenshots\n        ]\n\n        return EventDetailResponse(\n            id=event_summary[\"id\"],\n            app_name=event_summary[\"app_name\"],\n            window_title=event_summary[\"window_title\"],\n            start_time=event_summary[\"start_time\"],\n            end_time=event_summary[\"end_time\"],\n            screenshots=screenshots_resp,\n            ai_title=event_summary.get(\"ai_title\"),\n            ai_summary=event_summary.get(\"ai_summary\"),\n        )\n\n    def get_event_context(self, event_id: int) -> dict[str, Any]:\n        \"\"\"获取事件的OCR文本上下文\"\"\"\n        event_summary = self.event_repo.get_summary(event_id)\n        if not event_summary:\n            raise HTTPException(status_code=404, detail=\"事件不存在\")\n\n        screenshots = self.event_repo.get_screenshots(event_id)\n\n        # 聚合OCR文本\n        ocr_texts = []\n        for screenshot in screenshots:\n            ocr_results = self.ocr_repo.get_results_by_screenshot(screenshot[\"id\"])\n            if ocr_results:\n                for ocr in ocr_results:\n                    if ocr.get(\"text_content\"):\n                        ocr_texts.append(ocr[\"text_content\"])\n                        break\n\n        return {\n            \"event_id\": event_id,\n            \"app_name\": event_summary.get(\"app_name\"),\n            \"window_title\": event_summary.get(\"window_title\"),\n            \"start_time\": event_summary.get(\"start_time\"),\n            \"end_time\": event_summary.get(\"end_time\"),\n            \"ocr_texts\": ocr_texts,\n            \"screenshot_count\": len(screenshots),\n        }\n\n    def generate_event_summary(self, event_id: int) -> dict[str, Any]:\n        \"\"\"手动触发单个事件的摘要生成\"\"\"\n        # 检查事件是否存在\n        event_info = self.event_repo.get_summary(event_id)\n        if not event_info:\n            raise HTTPException(status_code=404, detail=\"事件不存在\")\n\n        # 延迟导入避免循环依赖\n        summary_module = importlib.import_module(\"lifetrace.llm.event_summary_service\")\n        success = summary_module.event_summary_service.generate_event_summary(event_id)\n\n        if success:\n            updated_event = self.event_repo.get_summary(event_id)\n            if not updated_event:\n                raise HTTPException(status_code=500, detail=\"事件摘要更新后未找到事件数据\")\n            return {\n                \"success\": True,\n                \"event_id\": event_id,\n                \"ai_title\": updated_event.get(\"ai_title\"),\n                \"ai_summary\": updated_event.get(\"ai_summary\"),\n            }\n        else:\n            raise HTTPException(status_code=500, detail=\"摘要生成失败\")\n\n    def get_events_by_ids(self, event_ids: list[int]) -> list[dict[str, Any]]:\n        \"\"\"批量获取事件\"\"\"\n        return self.event_repo.get_events_by_ids(event_ids)\n"
  },
  {
    "path": "lifetrace/services/icalendar_service.py",
    "content": "\"\"\"iCalendar (ICS) import/export service for Todo items.\"\"\"\n\nfrom __future__ import annotations\n\nfrom datetime import date, datetime, time\nfrom typing import Any\n\nfrom icalendar import Calendar, vRecur\nfrom icalendar import Event as VEvent\nfrom icalendar import Todo as VTodo\n\nfrom lifetrace.schemas.todo import TodoCreate, TodoItemType, TodoPriority, TodoStatus\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.time_utils import ensure_utc, naive_as_utc, to_local\n\nlogger = get_logger()\n\nPERCENT_COMPLETE_MAX = 100\nICAL_PRIORITY_HIGH = 1\nICAL_PRIORITY_MEDIUM = 5\nICAL_PRIORITY_LOW = 9\nICAL_PRIORITY_NONE = 0\n\n\ndef _normalize_percent(value: Any) -> int:\n    if value is None:\n        return 0\n    try:\n        percent = int(value)\n    except Exception:\n        return 0\n    return max(0, min(PERCENT_COMPLETE_MAX, percent))\n\n\ndef _to_local_time(value: datetime | None) -> datetime | None:\n    if value is None:\n        return None\n    if value.tzinfo is None:\n        value = naive_as_utc(value)\n    return to_local(value)\n\n\ndef _from_ical_dt(value: Any) -> datetime | None:\n    if value is None:\n        return None\n    if hasattr(value, \"dt\"):\n        value = value.dt\n    if isinstance(value, datetime):\n        return ensure_utc(value)\n    if isinstance(value, date):\n        return ensure_utc(datetime.combine(value, time.min))\n    return None\n\n\ndef _build_calendar() -> Calendar:\n    cal = Calendar()\n    cal.add(\"prodid\", \"-//LifeTrace//FreeTodo//EN\")\n    cal.add(\"version\", \"2.0\")\n    cal.add(\"calscale\", \"GREGORIAN\")\n    return cal\n\n\ndef _add_optional_text(component: VTodo | VEvent, name: str, value: Any) -> None:\n    text = (value or \"\").strip()\n    if text:\n        component.add(name, text)\n\n\ndef _add_optional_dt(component: VTodo | VEvent, name: str, value: Any) -> None:\n    dt_value = _to_local_time(value)\n    if dt_value:\n        component.add(name, dt_value)\n\n\ndef _add_optional_value(component: VTodo | VEvent, name: str, value: Any) -> None:\n    if value is not None:\n        component.add(name, value)\n\n\ndef _add_optional_categories(component: VTodo | VEvent, tags: list[Any]) -> None:\n    if tags:\n        component.add(\"categories\", [str(t) for t in tags if t])\n\n\ndef _add_optional_rrule(component: VTodo | VEvent, rrule: str) -> None:\n    if not rrule:\n        return\n    try:\n        component.add(\"rrule\", vRecur.from_ical(rrule))\n    except Exception:\n        component.add(\"rrule\", rrule)\n\n\nclass ICalendarService:\n    \"\"\"Convert Todo objects to/from iCalendar VTODO components.\"\"\"\n\n    def export_todos(self, todos: list[dict[str, Any]]) -> str:\n        cal = _build_calendar()\n        for todo in todos:\n            try:\n                item_type = (todo.get(\"item_type\") or \"VTODO\").upper()\n                if item_type == \"VEVENT\":\n                    cal.add_component(self._todo_to_vevent(todo))\n                else:\n                    cal.add_component(self._todo_to_vtodo(todo))\n            except Exception as exc:\n                logger.warning(f\"跳过 todo 导出（ICS）: {exc}\")\n\n        return cal.to_ical().decode(\"utf-8\")\n\n    def import_todos(self, ics_content: str) -> list[TodoCreate]:  # noqa: C901\n        cal = Calendar.from_ical(ics_content)\n        todos: list[TodoCreate] = []\n\n        for component in cal.walk():\n            if component.name not in (\"VTODO\", \"VEVENT\"):\n                continue\n\n            summary = str(component.get(\"summary\") or \"\").strip()\n            if not summary:\n                continue\n\n            item_type = TodoItemType.VEVENT if component.name == \"VEVENT\" else TodoItemType.VTODO\n            uid = str(component.get(\"uid\")) if component.get(\"uid\") else None\n            description = (\n                str(component.get(\"description\")).strip() if component.get(\"description\") else None\n            )\n\n            dtstart = _from_ical_dt(component.get(\"dtstart\"))\n            dtend = _from_ical_dt(component.get(\"dtend\")) if item_type == \"VEVENT\" else None\n            due = _from_ical_dt(component.get(\"due\")) if item_type == \"VTODO\" else None\n            duration_prop = component.get(\"duration\")\n            duration = None\n            if duration_prop is not None:\n                try:\n                    duration = duration_prop.to_ical().decode(\"utf-8\")\n                except Exception:\n                    duration = str(duration_prop)\n            completed_at = _from_ical_dt(component.get(\"completed\"))\n\n            start_time = dtstart or due\n            end_time = dtend\n\n            percent_complete = _normalize_percent(component.get(\"percent-complete\"))\n            status = self._status_from_ical(component.get(\"status\"))\n            if status is None:\n                status = (\n                    TodoStatus.COMPLETED\n                    if percent_complete == PERCENT_COMPLETE_MAX\n                    else TodoStatus.ACTIVE\n                )\n\n            priority = self._priority_from_ical(component.get(\"priority\"))\n\n            categories = component.get(\"categories\")\n            tags: list[str] = []\n            if categories:\n                if hasattr(categories, \"cats\"):\n                    tags = [str(c) for c in categories.cats if c]\n                elif isinstance(categories, list | tuple | set):\n                    tags = [str(c) for c in categories if c]\n                else:\n                    tags = [str(categories)]\n\n            rrule_prop = component.get(\"rrule\")\n            rrule = None\n            if rrule_prop:\n                try:\n                    rrule = rrule_prop.to_ical().decode(\"utf-8\")\n                except Exception:\n                    rrule = str(rrule_prop)\n\n            todos.append(\n                TodoCreate(\n                    uid=uid,\n                    name=summary,\n                    summary=summary,\n                    description=description,\n                    user_notes=None,\n                    parent_todo_id=None,\n                    item_type=item_type,\n                    location=None,\n                    categories=\",\".join(tags) if tags else None,\n                    classification=None,\n                    start_time=start_time,\n                    deadline=None,\n                    end_time=end_time,\n                    dtstart=dtstart,\n                    dtend=dtend,\n                    due=due,\n                    duration=duration,\n                    time_zone=None,\n                    tzid=None,\n                    is_all_day=None,\n                    dtstamp=None,\n                    created=None,\n                    last_modified=None,\n                    sequence=None,\n                    rdate=None,\n                    exdate=None,\n                    recurrence_id=None,\n                    related_to_uid=None,\n                    related_to_reltype=None,\n                    ical_status=None,\n                    reminder_offsets=None,\n                    status=status,\n                    priority=priority,\n                    completed_at=completed_at,\n                    percent_complete=percent_complete,\n                    rrule=rrule,\n                    order=0,\n                    tags=tags,\n                )\n            )\n\n        return todos\n\n    def _status_to_ical(self, status: str | None) -> str | None:\n        if not status:\n            return None\n        mapping = {\n            \"active\": \"NEEDS-ACTION\",\n            \"completed\": \"COMPLETED\",\n            \"canceled\": \"CANCELLED\",\n            \"draft\": \"NEEDS-ACTION\",\n        }\n        return mapping.get(status, \"NEEDS-ACTION\")\n\n    def _status_from_ical(self, status: Any) -> TodoStatus | None:\n        if not status:\n            return None\n        status_str = str(status).upper()\n        if status_str in (\"COMPLETED\",):\n            return TodoStatus.COMPLETED\n        if status_str in (\"CANCELLED\", \"CANCELED\"):\n            return TodoStatus.CANCELED\n        if status_str in (\"IN-PROCESS\", \"NEEDS-ACTION\", \"ACTION\"):\n            return TodoStatus.ACTIVE\n        return None\n\n    def _priority_to_ical(self, priority: str | None) -> int | None:\n        if not priority:\n            return ICAL_PRIORITY_NONE\n        mapping = {\n            \"high\": ICAL_PRIORITY_HIGH,\n            \"medium\": ICAL_PRIORITY_MEDIUM,\n            \"low\": ICAL_PRIORITY_LOW,\n            \"none\": ICAL_PRIORITY_NONE,\n        }\n        return mapping.get(priority, ICAL_PRIORITY_NONE)\n\n    def _priority_from_ical(self, priority: Any) -> TodoPriority:\n        if priority is None:\n            return TodoPriority.NONE\n        try:\n            value = int(priority)\n        except Exception:\n            return TodoPriority.NONE\n        if value <= ICAL_PRIORITY_HIGH:\n            return TodoPriority.HIGH\n        if value <= ICAL_PRIORITY_MEDIUM:\n            return TodoPriority.MEDIUM\n        if value <= ICAL_PRIORITY_LOW:\n            return TodoPriority.LOW\n        return TodoPriority.NONE\n\n    def _todo_to_vtodo(self, todo: dict[str, Any]) -> VTodo:\n        vtodo = VTodo()\n        uid = todo.get(\"uid\") or str(todo.get(\"id\") or \"\")\n        if uid:\n            vtodo.add(\"uid\", uid)\n\n        summary = todo.get(\"summary\") or todo.get(\"name\")\n        _add_optional_text(vtodo, \"summary\", summary)\n        _add_optional_text(vtodo, \"description\", todo.get(\"description\"))\n        dtstart = todo.get(\"dtstart\") or todo.get(\"start_time\") or todo.get(\"deadline\")\n        due = todo.get(\"due\") or todo.get(\"deadline\")\n        duration = todo.get(\"duration\")\n        _add_optional_dt(vtodo, \"dtstart\", dtstart)\n        if duration:\n            _add_optional_value(vtodo, \"duration\", duration)\n        else:\n            _add_optional_dt(vtodo, \"due\", due or dtstart)\n        _add_optional_dt(vtodo, \"created\", todo.get(\"created\") or todo.get(\"created_at\"))\n        _add_optional_dt(\n            vtodo,\n            \"last-modified\",\n            todo.get(\"last_modified\") or todo.get(\"updated_at\"),\n        )\n        _add_optional_value(\n            vtodo, \"status\", todo.get(\"ical_status\") or self._status_to_ical(todo.get(\"status\"))\n        )\n        _add_optional_value(vtodo, \"priority\", self._priority_to_ical(todo.get(\"priority\")))\n        _add_optional_dt(vtodo, \"completed\", todo.get(\"completed_at\"))\n\n        percent_complete = _normalize_percent(todo.get(\"percent_complete\"))\n        if percent_complete:\n            vtodo.add(\"percent-complete\", percent_complete)\n\n        categories_text = (todo.get(\"categories\") or \"\").strip()\n        categories = []\n        if categories_text:\n            categories = [c.strip() for c in categories_text.split(\",\") if c.strip()]\n        categories.extend(todo.get(\"tags\") or [])\n        _add_optional_categories(vtodo, categories)\n        _add_optional_rrule(vtodo, (todo.get(\"rrule\") or \"\").strip())\n        return vtodo\n\n    def _todo_to_vevent(self, todo: dict[str, Any]) -> VEvent:\n        vevent = VEvent()\n        uid = todo.get(\"uid\") or str(todo.get(\"id\") or \"\")\n        if uid:\n            vevent.add(\"uid\", uid)\n\n        summary = todo.get(\"summary\") or todo.get(\"name\")\n        _add_optional_text(vevent, \"summary\", summary)\n        _add_optional_text(vevent, \"description\", todo.get(\"description\"))\n        dtstart = todo.get(\"dtstart\") or todo.get(\"start_time\")\n        dtend = todo.get(\"dtend\") or todo.get(\"end_time\")\n        duration = todo.get(\"duration\")\n        _add_optional_dt(vevent, \"dtstart\", dtstart)\n        if duration:\n            _add_optional_value(vevent, \"duration\", duration)\n        else:\n            _add_optional_dt(vevent, \"dtend\", dtend)\n        _add_optional_dt(vevent, \"created\", todo.get(\"created\") or todo.get(\"created_at\"))\n        _add_optional_dt(\n            vevent,\n            \"last-modified\",\n            todo.get(\"last_modified\") or todo.get(\"updated_at\"),\n        )\n        _add_optional_value(\n            vevent, \"status\", todo.get(\"ical_status\") or self._status_to_ical(todo.get(\"status\"))\n        )\n        _add_optional_value(vevent, \"priority\", self._priority_to_ical(todo.get(\"priority\")))\n        categories_text = (todo.get(\"categories\") or \"\").strip()\n        categories = []\n        if categories_text:\n            categories = [c.strip() for c in categories_text.split(\",\") if c.strip()]\n        categories.extend(todo.get(\"tags\") or [])\n        _add_optional_categories(vevent, categories)\n        _add_optional_rrule(vevent, (todo.get(\"rrule\") or \"\").strip())\n        return vevent\n"
  },
  {
    "path": "lifetrace/services/journal_service.py",
    "content": "\"\"\"Journal 业务逻辑层\n\n处理 Journal 相关的业务逻辑，与数据访问层解耦。\n\"\"\"\n\nfrom __future__ import annotations\n\nimport re\nfrom datetime import datetime, time, timedelta\nfrom typing import TYPE_CHECKING, Any\n\nfrom fastapi import HTTPException\nfrom sqlalchemy import or_\n\nfrom lifetrace.llm.journal_generation_service import journal_generation_service\nfrom lifetrace.schemas.journal import (\n    JournalAutoLinkCandidate,\n    JournalAutoLinkRequest,\n    JournalAutoLinkResponse,\n    JournalCreate,\n    JournalGenerateRequest,\n    JournalGenerateResponse,\n    JournalListResponse,\n    JournalResponse,\n    JournalUpdate,\n)\nfrom lifetrace.storage.journal_manager import JournalCreatePayload, JournalUpdatePayload\nfrom lifetrace.storage.models import Activity, Todo\nfrom lifetrace.storage.sql_utils import col\nfrom lifetrace.util.logging_config import get_logger\n\nlogger = get_logger()\n\nif TYPE_CHECKING:\n    from collections.abc import Callable\n\n    from lifetrace.repositories.interfaces import IJournalRepository\n    from lifetrace.storage.database_base import DatabaseBase\n\n_DEFAULT_BUCKET_START = time(hour=4, minute=0)\n\n\nclass JournalService:\n    \"\"\"Journal 业务逻辑层\"\"\"\n\n    def __init__(self, repository: IJournalRepository, db_base: DatabaseBase):\n        self.repository = repository\n        self.db_base = db_base\n\n    def _normalize_name(self, name: str | None) -> str:\n        cleaned = (name or \"\").strip()\n        return cleaned or \"Untitled\"\n\n    def _resolve_day_bucket_range(\n        self, date: datetime, day_bucket_start: datetime | None\n    ) -> tuple[datetime, datetime]:\n        bucket_time = (day_bucket_start or date).time()\n        if day_bucket_start is None:\n            bucket_time = _DEFAULT_BUCKET_START\n\n        bucket_start = datetime.combine(date.date(), bucket_time, tzinfo=date.tzinfo)\n        if date < bucket_start:\n            bucket_start -= timedelta(days=1)\n        bucket_end = bucket_start + timedelta(days=1)\n        return bucket_start, bucket_end\n\n    def _extract_keywords(self, text: str) -> list[str]:\n        if not text:\n            return []\n        normalized = text.lower()\n        english = re.findall(r\"[a-z0-9][a-z0-9_-]{1,}\", normalized)\n        chinese = re.findall(r\"[\\u4e00-\\u9fff]{2,}\", text)\n        return sorted(set(english + chinese))\n\n    def _score_text(self, text: str, keywords: list[str]) -> float:\n        if not text or not keywords:\n            return 0.0\n        lowered = text.lower()\n        score = sum(1 for keyword in keywords if keyword in lowered)\n        return float(score)\n\n    def _score_candidates(\n        self,\n        items: list[dict[str, Any]],\n        keywords: list[str],\n        text_builder: Callable[[dict[str, Any]], str],\n    ) -> list[dict[str, Any]]:\n        candidates: list[dict[str, Any]] = []\n        for item in items:\n            text = text_builder(item)\n            score = self._score_text(text, keywords)\n            if score <= 0:\n                continue\n            candidates.append(\n                {\n                    \"id\": item[\"id\"],\n                    \"name\": item.get(\"name\") or item.get(\"title\") or \"\",\n                    \"score\": score,\n                }\n            )\n        candidates.sort(key=lambda item: (-item[\"score\"], item[\"id\"]))\n        return candidates\n\n    def _list_todos_for_range(self, start: datetime, end: datetime) -> list[dict[str, Any]]:\n        with self.db_base.get_session() as session:\n            query = session.query(Todo).filter(col(Todo.deleted_at).is_(None))\n            query = query.filter(\n                or_(\n                    col(Todo.start_time).between(start, end),\n                    col(Todo.end_time).between(start, end),\n                    col(Todo.deadline).between(start, end),\n                    col(Todo.created_at).between(start, end),\n                )\n            )\n            todos = query.order_by(col(Todo.created_at).desc()).all()\n            return [\n                {\n                    \"id\": todo.id,\n                    \"name\": todo.name,\n                    \"description\": todo.description,\n                    \"user_notes\": todo.user_notes,\n                    \"status\": todo.status,\n                    \"deadline\": todo.deadline,\n                    \"start_time\": todo.start_time,\n                    \"end_time\": todo.end_time,\n                }\n                for todo in todos\n            ]\n\n    def _list_activities_for_range(self, start: datetime, end: datetime) -> list[dict[str, Any]]:\n        with self.db_base.get_session() as session:\n            query = (\n                session.query(Activity)\n                .filter(col(Activity.deleted_at).is_(None))\n                .filter(col(Activity.start_time) >= start)\n                .filter(col(Activity.start_time) <= end)\n            )\n            activities = query.order_by(col(Activity.start_time).desc()).all()\n            return [\n                {\n                    \"id\": activity.id,\n                    \"title\": activity.ai_title or \"\",\n                    \"summary\": activity.ai_summary or \"\",\n                    \"start_time\": activity.start_time,\n                    \"end_time\": activity.end_time,\n                }\n                for activity in activities\n            ]\n\n    def _resolve_generation_context(\n        self, payload: JournalGenerateRequest\n    ) -> tuple[dict[str, Any] | None, datetime, str, str, datetime | None]:\n        journal = None\n        if payload.journal_id is not None:\n            journal = self.repository.get_by_id(payload.journal_id)\n            if not journal:\n                raise HTTPException(status_code=404, detail=\"日记不存在\")\n\n        date = payload.date or (journal.get(\"date\") if journal else None)\n        if date is None:\n            raise HTTPException(status_code=400, detail=\"缺少日记日期\")\n\n        title = payload.title or (journal.get(\"name\") if journal else \"\") or \"\"\n        content_original = (\n            payload.content_original\n            if payload.content_original is not None\n            else (journal.get(\"user_notes\") if journal else \"\")\n        )\n        content_original = content_original or \"\"\n        day_bucket_start = payload.day_bucket_start or (\n            journal.get(\"day_bucket_start\") if journal else None\n        )\n        return journal, date, title, content_original, day_bucket_start\n\n    def get_journal(self, journal_id: int) -> JournalResponse:\n        \"\"\"获取单个日记\"\"\"\n        journal = self.repository.get_by_id(journal_id)\n        if not journal:\n            raise HTTPException(status_code=404, detail=\"日记不存在\")\n        return JournalResponse(**journal)\n\n    def list_journals(\n        self,\n        limit: int,\n        offset: int,\n        start_date: datetime | None,\n        end_date: datetime | None,\n    ) -> JournalListResponse:\n        \"\"\"获取日记列表\"\"\"\n        journals = self.repository.list_journals(limit, offset, start_date, end_date)\n        total = self.repository.count(start_date, end_date)\n        return JournalListResponse(\n            total=total,\n            journals=[JournalResponse(**j) for j in journals],\n        )\n\n    def create_journal(self, data: JournalCreate) -> JournalResponse:\n        \"\"\"创建日记\"\"\"\n        payload = JournalCreatePayload(\n            uid=data.uid,\n            name=self._normalize_name(data.name),\n            user_notes=data.user_notes,\n            date=data.date,\n            content_format=data.content_format or \"markdown\",\n            content_objective=data.content_objective,\n            content_ai=data.content_ai,\n            mood=data.mood,\n            energy=data.energy,\n            day_bucket_start=data.day_bucket_start,\n            tags=data.tags,\n            related_todo_ids=data.related_todo_ids,\n            related_activity_ids=data.related_activity_ids,\n        )\n        journal_id = self.repository.create(payload)\n        if not journal_id:\n            raise HTTPException(status_code=500, detail=\"创建日记失败\")\n\n        logger.info(f\"成功创建日记: {journal_id} - {payload.name}\")\n        return self.get_journal(journal_id)\n\n    def _build_update_payload(self, data: JournalUpdate) -> JournalUpdatePayload:\n        update_data = data.model_dump(exclude_none=True)\n        if \"name\" in update_data:\n            update_data[\"name\"] = self._normalize_name(update_data[\"name\"])\n        return JournalUpdatePayload(**update_data)\n\n    def update_journal(self, journal_id: int, data: JournalUpdate) -> JournalResponse:\n        \"\"\"更新日记\"\"\"\n        if not self.repository.get_by_id(journal_id):\n            raise HTTPException(status_code=404, detail=\"日记不存在\")\n\n        payload = self._build_update_payload(data)\n\n        if not self.repository.update(journal_id, payload):\n            raise HTTPException(status_code=500, detail=\"更新日记失败\")\n\n        logger.info(f\"成功更新日记: {journal_id}\")\n        return self.get_journal(journal_id)\n\n    def delete_journal(self, journal_id: int) -> None:\n        \"\"\"删除日记\"\"\"\n        if not self.repository.get_by_id(journal_id):\n            raise HTTPException(status_code=404, detail=\"日记不存在\")\n        if not self.repository.delete(journal_id):\n            raise HTTPException(status_code=500, detail=\"删除日记失败\")\n\n        logger.info(f\"成功删除日记: {journal_id}\")\n\n    def auto_link(self, payload: JournalAutoLinkRequest) -> JournalAutoLinkResponse:\n        journal = None\n        if payload.journal_id is not None:\n            journal = self.repository.get_by_id(payload.journal_id)\n            if not journal:\n                raise HTTPException(status_code=404, detail=\"日记不存在\")\n\n        title = payload.title or (journal.get(\"name\") if journal else \"\") or \"\"\n        content_original = (\n            payload.content_original\n            if payload.content_original is not None\n            else (journal.get(\"user_notes\") if journal else \"\")\n        )\n        day_bucket_start = payload.day_bucket_start or (\n            journal.get(\"day_bucket_start\") if journal else None\n        )\n\n        start_time, end_time = self._resolve_day_bucket_range(payload.date, day_bucket_start)\n        todos = self._list_todos_for_range(start_time, end_time)\n        activities = self._list_activities_for_range(start_time, end_time)\n\n        keywords = self._extract_keywords(f\"{title} {content_original}\")\n        todo_candidates = self._score_candidates(\n            todos,\n            keywords,\n            lambda item: \" \".join(\n                filter(None, [item.get(\"name\"), item.get(\"description\"), item.get(\"user_notes\")])\n            ),\n        )\n        activity_candidates = self._score_candidates(\n            activities,\n            keywords,\n            lambda item: \" \".join(filter(None, [item.get(\"title\"), item.get(\"summary\")])),\n        )\n\n        related_todo_ids = [c[\"id\"] for c in todo_candidates[: payload.max_items]]\n        related_activity_ids = [c[\"id\"] for c in activity_candidates[: payload.max_items]]\n\n        if payload.journal_id is not None:\n            update_payload = JournalUpdatePayload(\n                related_todo_ids=related_todo_ids,\n                related_activity_ids=related_activity_ids,\n            )\n            self.repository.update(payload.journal_id, update_payload)\n\n        return JournalAutoLinkResponse(\n            related_todo_ids=related_todo_ids,\n            related_activity_ids=related_activity_ids,\n            todo_candidates=[JournalAutoLinkCandidate(**c) for c in todo_candidates],\n            activity_candidates=[JournalAutoLinkCandidate(**c) for c in activity_candidates],\n        )\n\n    def generate_objective(self, payload: JournalGenerateRequest) -> JournalGenerateResponse:\n        journal, date, _title, content_original, day_bucket_start = (\n            self._resolve_generation_context(payload)\n        )\n        start_time, end_time = self._resolve_day_bucket_range(date, day_bucket_start)\n        todos = self._list_todos_for_range(start_time, end_time)\n        activities = self._list_activities_for_range(start_time, end_time)\n\n        content = journal_generation_service.generate_objective(\n            activities=activities,\n            todos=todos,\n            language=payload.language,\n        )\n\n        if journal:\n            update_payload = JournalUpdatePayload(content_objective=content)\n            self.repository.update(journal[\"id\"], update_payload)\n\n        return JournalGenerateResponse(content=content)\n\n    def generate_ai_view(self, payload: JournalGenerateRequest) -> JournalGenerateResponse:\n        journal, date, title, content_original, day_bucket_start = self._resolve_generation_context(\n            payload\n        )\n        start_time, end_time = self._resolve_day_bucket_range(date, day_bucket_start)\n        todos = self._list_todos_for_range(start_time, end_time)\n        activities = self._list_activities_for_range(start_time, end_time)\n\n        content = journal_generation_service.generate_ai_view(\n            title=title,\n            content_original=content_original,\n            activities=activities,\n            todos=todos,\n            language=payload.language,\n        )\n\n        if journal:\n            update_payload = JournalUpdatePayload(content_ai=content)\n            self.repository.update(journal[\"id\"], update_payload)\n\n        return JournalGenerateResponse(content=content)\n"
  },
  {
    "path": "lifetrace/services/todo_service.py",
    "content": "\"\"\"Todo 业务逻辑层\n\n处理 Todo 相关的业务逻辑，与数据访问层解耦。\n\"\"\"\n\nfrom typing import Any\n\nfrom fastapi import HTTPException\n\nfrom lifetrace.jobs.deadline_reminder import refresh_todo_reminders, remove_todo_reminder_jobs\nfrom lifetrace.repositories.interfaces import ITodoRepository\nfrom lifetrace.schemas.todo import TodoAttachmentResponse, TodoCreate, TodoResponse, TodoUpdate\nfrom lifetrace.storage.notification_storage import (\n    clear_dismissed_mark,\n    clear_notification_by_todo_id,\n)\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.time_utils import get_utc_now\n\nlogger = get_logger()\n\n\ndef _to_ical_status(status: str | None) -> str | None:\n    if not status:\n        return None\n    mapping = {\n        \"active\": \"NEEDS-ACTION\",\n        \"completed\": \"COMPLETED\",\n        \"canceled\": \"CANCELLED\",\n        \"draft\": \"NEEDS-ACTION\",\n    }\n    return mapping.get(status, \"NEEDS-ACTION\")\n\n\ndef _normalize_item_type(item_type: str | None) -> str:\n    return (item_type or \"VTODO\").upper()\n\n\nclass TodoService:\n    \"\"\"Todo 业务逻辑层\"\"\"\n\n    def __init__(self, repository: ITodoRepository):\n        self.repository = repository\n\n    def get_todo(self, todo_id: int) -> TodoResponse:\n        \"\"\"获取单个 Todo\"\"\"\n        todo = self.repository.get_by_id(todo_id)\n        if not todo:\n            raise HTTPException(status_code=404, detail=\"todo 不存在\")\n        return TodoResponse(**todo)\n\n    def get_todo_by_uid(self, uid: str) -> TodoResponse | None:\n        \"\"\"根据 UID 获取单个 Todo\"\"\"\n        todo = self.repository.get_by_uid(uid)\n        return TodoResponse(**todo) if todo else None\n\n    def list_todos(self, limit: int, offset: int, status: str | None) -> dict[str, Any]:\n        \"\"\"获取 Todo 列表\"\"\"\n        todos = self.repository.list_todos(limit, offset, status)\n        total = self.repository.count(status)\n        return {\"total\": total, \"todos\": [TodoResponse(**t) for t in todos]}\n\n    def create_todo(self, data: TodoCreate) -> TodoResponse:\n        \"\"\"创建 Todo\"\"\"\n        dtstart = data.dtstart or data.start_time or data.deadline or data.due\n        dtend = data.dtend or data.end_time\n        due = data.due or data.deadline\n        duration = data.duration\n        if duration and (due or dtend):\n            raise HTTPException(\n                status_code=400,\n                detail=\"duration 与 due/dtend 互斥，请只保留一个\",\n            )\n\n        start_time = data.start_time or dtstart\n        end_time = data.end_time or dtend\n        deadline = data.deadline or due\n\n        item_type = _normalize_item_type(data.item_type)\n        summary = data.summary or data.name\n        tzid = data.tzid or data.time_zone\n        now = get_utc_now()\n        created = data.created or now\n        last_modified = data.last_modified or now\n        dtstamp = data.dtstamp or now\n        ical_status = data.ical_status or _to_ical_status(\n            data.status.value if data.status else None\n        )\n        todo_id = self.repository.create(\n            uid=data.uid,\n            name=data.name,\n            summary=summary,\n            description=data.description,\n            user_notes=data.user_notes,\n            parent_todo_id=data.parent_todo_id,\n            item_type=item_type,\n            location=data.location,\n            categories=data.categories,\n            classification=data.classification,\n            deadline=deadline,\n            start_time=start_time,\n            end_time=end_time,\n            dtstart=dtstart,\n            dtend=dtend,\n            due=due,\n            duration=duration,\n            time_zone=data.time_zone,\n            tzid=tzid,\n            is_all_day=data.is_all_day,\n            dtstamp=dtstamp,\n            created=created,\n            last_modified=last_modified,\n            sequence=data.sequence,\n            rdate=data.rdate,\n            exdate=data.exdate,\n            recurrence_id=data.recurrence_id,\n            related_to_uid=data.related_to_uid,\n            related_to_reltype=data.related_to_reltype,\n            ical_status=ical_status,\n            reminder_offsets=data.reminder_offsets,\n            status=data.status.value if data.status else \"active\",\n            priority=data.priority.value if data.priority else \"none\",\n            completed_at=data.completed_at,\n            percent_complete=data.percent_complete,\n            rrule=data.rrule,\n            order=data.order,\n            tags=data.tags,\n            related_activities=data.related_activities,\n        )\n        if not todo_id:\n            raise HTTPException(status_code=500, detail=\"创建 todo 失败\")\n\n        todo = self.get_todo(todo_id)\n        try:\n            refresh_todo_reminders(todo)\n        except Exception as e:\n            logger.warning(f\"创建待办后同步提醒失败: {e}\")\n        return todo\n\n    def update_todo(self, todo_id: int, data: TodoUpdate) -> TodoResponse:  # noqa: C901, PLR0912, PLR0915\n        \"\"\"更新 Todo\"\"\"\n        # 检查是否存在\n        if not self.repository.get_by_id(todo_id):\n            raise HTTPException(status_code=404, detail=\"todo 不存在\")\n\n        # 提取有效字段（只更新请求中携带的字段）\n        fields_set = (\n            getattr(data, \"model_fields_set\", None)\n            or getattr(data, \"__fields_set__\", None)\n            or set()\n        )\n        kwargs = {field: getattr(data, field) for field in fields_set}\n        existing = self.repository.get_by_id(todo_id)\n        item_type = _normalize_item_type(\n            kwargs.get(\"item_type\") or (existing.get(\"item_type\") if existing else None)\n        )\n\n        # 枚举转字符串\n        if \"status\" in kwargs and kwargs[\"status\"] is not None:\n            kwargs[\"status\"] = kwargs[\"status\"].value\n        if \"priority\" in kwargs and kwargs[\"priority\"] is not None:\n            kwargs[\"priority\"] = kwargs[\"priority\"].value\n        if \"item_type\" in kwargs and kwargs[\"item_type\"] is not None:\n            kwargs[\"item_type\"] = _normalize_item_type(kwargs[\"item_type\"])\n\n        if \"summary\" not in kwargs and \"name\" in kwargs:\n            kwargs[\"summary\"] = kwargs[\"name\"]\n        if \"name\" not in kwargs and \"summary\" in kwargs:\n            kwargs[\"name\"] = kwargs[\"summary\"]\n        if (\n            \"summary\" in kwargs\n            and \"name\" in kwargs\n            and kwargs[\"summary\"]\n            and kwargs[\"name\"]\n            and kwargs[\"summary\"] != kwargs[\"name\"]\n        ):\n            kwargs[\"name\"] = kwargs[\"summary\"]\n\n        if \"tzid\" not in kwargs and \"time_zone\" in kwargs:\n            kwargs[\"tzid\"] = kwargs[\"time_zone\"]\n        if \"time_zone\" not in kwargs and \"tzid\" in kwargs:\n            kwargs[\"time_zone\"] = kwargs[\"tzid\"]\n\n        if \"dtstart\" not in kwargs:\n            if \"start_time\" in kwargs:\n                kwargs[\"dtstart\"] = kwargs[\"start_time\"]\n            elif \"deadline\" in kwargs:\n                kwargs[\"dtstart\"] = kwargs[\"deadline\"]\n            elif \"due\" in kwargs:\n                kwargs[\"dtstart\"] = kwargs[\"due\"]\n        if \"start_time\" not in kwargs and \"dtstart\" in kwargs:\n            kwargs[\"start_time\"] = kwargs[\"dtstart\"]\n\n        if \"dtend\" not in kwargs and \"end_time\" in kwargs:\n            kwargs[\"dtend\"] = kwargs[\"end_time\"]\n        if \"end_time\" not in kwargs and \"dtend\" in kwargs:\n            kwargs[\"end_time\"] = kwargs[\"dtend\"]\n\n        if \"due\" not in kwargs and \"deadline\" in kwargs:\n            kwargs[\"due\"] = kwargs[\"deadline\"]\n        if \"deadline\" not in kwargs and \"due\" in kwargs:\n            kwargs[\"deadline\"] = kwargs[\"due\"]\n\n        if \"deadline\" in kwargs and \"start_time\" not in kwargs:\n            kwargs[\"start_time\"] = kwargs[\"deadline\"]\n\n        if \"duration\" in kwargs and kwargs[\"duration\"] is not None:\n            if (\"due\" in kwargs and kwargs[\"due\"] is not None) or (\n                \"dtend\" in kwargs and kwargs[\"dtend\"] is not None\n            ):\n                raise HTTPException(\n                    status_code=400,\n                    detail=\"duration 与 due/dtend 互斥，请只保留一个\",\n                )\n            if item_type == \"VTODO\":\n                kwargs.setdefault(\"due\", None)\n                kwargs.setdefault(\"deadline\", None)\n            else:\n                kwargs.setdefault(\"dtend\", None)\n                kwargs.setdefault(\"end_time\", None)\n\n        if \"ical_status\" not in kwargs and \"status\" in kwargs:\n            kwargs[\"ical_status\"] = _to_ical_status(kwargs[\"status\"])\n        if \"last_modified\" not in kwargs:\n            kwargs[\"last_modified\"] = get_utc_now()\n        if \"dtstamp\" not in kwargs:\n            kwargs[\"dtstamp\"] = kwargs[\"last_modified\"]\n\n        if not self.repository.update(todo_id, **kwargs):\n            raise HTTPException(status_code=500, detail=\"更新 todo 失败\")\n\n        schedule_fields = {\n            \"start_time\",\n            \"dtstart\",\n            \"due\",\n            \"deadline\",\n            \"reminder_offsets\",\n            \"status\",\n            \"item_type\",\n        }\n        if schedule_fields.intersection(fields_set):\n            clear_notification_by_todo_id(todo_id)\n            clear_dismissed_mark(todo_id)\n\n        todo = self.get_todo(todo_id)\n        if schedule_fields.intersection(fields_set):\n            try:\n                refresh_todo_reminders(todo)\n            except Exception as e:\n                logger.warning(f\"更新待办后同步提醒失败: {e}\")\n        return todo\n\n    def delete_todo(self, todo_id: int) -> None:\n        \"\"\"删除 Todo\"\"\"\n        if not self.repository.get_by_id(todo_id):\n            raise HTTPException(status_code=404, detail=\"todo 不存在\")\n        if not self.repository.delete(todo_id):\n            raise HTTPException(status_code=500, detail=\"删除 todo 失败\")\n        remove_todo_reminder_jobs(todo_id)\n        clear_notification_by_todo_id(todo_id)\n        clear_dismissed_mark(todo_id)\n\n    def reorder_todos(self, items: list[dict[str, Any]]) -> dict[str, Any]:\n        \"\"\"批量重排序 Todo\"\"\"\n        if not self.repository.reorder(items):\n            raise HTTPException(status_code=500, detail=\"批量重排序失败\")\n        return {\"success\": True, \"message\": f\"成功更新 {len(items)} 个待办的排序\"}\n\n    def add_attachment(\n        self,\n        *,\n        todo_id: int,\n        file_name: str,\n        file_path: str,\n        file_size: int | None,\n        mime_type: str | None,\n        file_hash: str | None,\n        source: str = \"user\",\n    ) -> TodoAttachmentResponse:\n        if not self.repository.get_by_id(todo_id):\n            raise HTTPException(status_code=404, detail=\"todo 不存在\")\n\n        attachment = self.repository.add_attachment(\n            todo_id=todo_id,\n            file_name=file_name,\n            file_path=file_path,\n            file_size=file_size,\n            mime_type=mime_type,\n            file_hash=file_hash,\n            source=source,\n        )\n        if not attachment:\n            raise HTTPException(status_code=500, detail=\"创建附件失败\")\n\n        return TodoAttachmentResponse(**attachment)\n\n    def remove_attachment(self, *, todo_id: int, attachment_id: int) -> None:\n        if not self.repository.get_by_id(todo_id):\n            raise HTTPException(status_code=404, detail=\"todo 不存在\")\n        if not self.repository.remove_attachment(todo_id=todo_id, attachment_id=attachment_id):\n            raise HTTPException(status_code=404, detail=\"附件不存在或已解绑\")\n\n    def get_attachment(self, attachment_id: int) -> dict[str, Any]:\n        attachment = self.repository.get_attachment(attachment_id)\n        if not attachment:\n            raise HTTPException(status_code=404, detail=\"附件不存在\")\n        return attachment\n"
  },
  {
    "path": "lifetrace/storage/__init__.py",
    "content": "\"\"\"\nStorage 模块\n\n提供数据库管理和模型定义。\n\n注意：\n- 该包在导入时**不应**执行数据库初始化/迁移等副作用操作。\n- 需要访问 `db_base` / `*_mgr` 等对象时，采用懒加载，避免在 Alembic 迁移环境中\n  （`lifetrace/migrations/env.py`）导入模型时触发递归迁移。\n\"\"\"\n\nfrom __future__ import annotations\n\nimport importlib\nfrom typing import TYPE_CHECKING\n\n__all__ = [\n    \"activity_mgr\",\n    \"automation_task_mgr\",\n    \"chat_mgr\",\n    \"db_base\",\n    \"event_mgr\",\n    \"get_db\",\n    \"get_session\",\n    \"journal_mgr\",\n    \"ocr_mgr\",\n    \"screenshot_mgr\",\n    \"stats_mgr\",\n    \"todo_mgr\",\n]\n\n_LAZY_EXPORTS: set[str] = set(__all__)\n\nif TYPE_CHECKING:\n    from lifetrace.storage.database import (\n        activity_mgr,\n        automation_task_mgr,\n        chat_mgr,\n        db_base,\n        event_mgr,\n        get_db,\n        get_session,\n        journal_mgr,\n        ocr_mgr,\n        screenshot_mgr,\n        stats_mgr,\n        todo_mgr,\n    )\n\n\ndef __getattr__(name: str):\n    if name in _LAZY_EXPORTS:\n        # 仅在真正需要时才触发数据库初始化\n        _database = importlib.import_module(\"lifetrace.storage.database\")\n        return getattr(_database, name)\n    raise AttributeError(f\"module {__name__!r} has no attribute {name!r}\")\n\n\ndef __dir__() -> list[str]:\n    return sorted(set(globals().keys()) | _LAZY_EXPORTS)\n"
  },
  {
    "path": "lifetrace/storage/activity_manager.py",
    "content": "\"\"\"活动管理器 - 负责活动相关的数据库操作\"\"\"\n\nfrom datetime import datetime, timedelta\nfrom typing import Any\n\nfrom sqlalchemy.exc import SQLAlchemyError\n\nfrom lifetrace.storage.database_base import DatabaseBase\nfrom lifetrace.storage.models import Activity, ActivityEventRelation, Event\nfrom lifetrace.storage.sql_utils import col\nfrom lifetrace.util.logging_config import get_logger\n\nlogger = get_logger()\n\n\nclass ActivityManager:\n    \"\"\"活动管理类\"\"\"\n\n    def __init__(self, db_base: DatabaseBase):\n        self.db_base = db_base\n\n    def create_activity(\n        self,\n        start_time: datetime,\n        end_time: datetime,\n        ai_title: str,\n        ai_summary: str,\n        event_ids: list[int],\n    ) -> int | None:\n        \"\"\"创建活动记录并关联事件\n\n        Args:\n            start_time: 活动开始时间\n            end_time: 活动结束时间\n            ai_title: AI生成的活动标题\n            ai_summary: AI生成的活动摘要\n            event_ids: 关联的事件ID列表\n\n        Returns:\n            活动ID，失败返回None\n        \"\"\"\n        try:\n            with self.db_base.get_session() as session:\n                # 创建活动记录\n                activity = Activity(\n                    start_time=start_time,\n                    end_time=end_time,\n                    ai_title=ai_title,\n                    ai_summary=ai_summary,\n                    event_count=len(event_ids),\n                )\n                session.add(activity)\n                session.flush()\n                if activity.id is None:\n                    raise ValueError(\"Activity must have an id before linking events.\")\n\n                # 创建关联关系\n                for event_id in event_ids:\n                    relation = ActivityEventRelation(\n                        activity_id=activity.id,\n                        event_id=event_id,\n                    )\n                    session.add(relation)\n\n                session.commit()\n                logger.info(f\"创建活动 {activity.id}: {ai_title}，包含 {len(event_ids)} 个事件\")\n                return activity.id\n        except SQLAlchemyError as e:\n            logger.error(f\"创建活动失败: {e}\")\n            return None\n\n    def get_activity(self, activity_id: int) -> dict[str, Any] | None:\n        \"\"\"获取单个活动信息\n\n        Args:\n            activity_id: 活动ID\n\n        Returns:\n            活动信息，不存在返回None\n        \"\"\"\n        try:\n            with self.db_base.get_session() as session:\n                activity = (\n                    session.query(Activity)\n                    .filter(col(Activity.id) == activity_id, col(Activity.deleted_at).is_(None))\n                    .first()\n                )\n                if not activity:\n                    return None\n\n                return {\n                    \"id\": activity.id,\n                    \"start_time\": activity.start_time,\n                    \"end_time\": activity.end_time,\n                    \"ai_title\": activity.ai_title,\n                    \"ai_summary\": activity.ai_summary,\n                    \"event_count\": activity.event_count,\n                    \"created_at\": activity.created_at,\n                    \"updated_at\": activity.updated_at,\n                }\n        except SQLAlchemyError as e:\n            logger.error(f\"获取活动信息失败: {e}\")\n            return None\n\n    def get_activities(\n        self,\n        limit: int = 50,\n        offset: int = 0,\n        start_date: datetime | None = None,\n        end_date: datetime | None = None,\n    ) -> list[dict[str, Any]]:\n        \"\"\"查询活动列表\n\n        Args:\n            limit: 返回数量限制\n            offset: 偏移量\n            start_date: 开始日期\n            end_date: 结束日期\n\n        Returns:\n            活动列表\n        \"\"\"\n        try:\n            with self.db_base.get_session() as session:\n                q = session.query(Activity).filter(col(Activity.deleted_at).is_(None))\n                if start_date:\n                    q = q.filter(col(Activity.start_time) >= start_date)\n                if end_date:\n                    q = q.filter(col(Activity.start_time) <= end_date)\n\n                q = q.order_by(col(Activity.start_time).desc()).offset(offset).limit(limit)\n                activities = q.all()\n\n                results: list[dict[str, Any]] = []\n                for activity in activities:\n                    results.append(\n                        {\n                            \"id\": activity.id,\n                            \"start_time\": activity.start_time,\n                            \"end_time\": activity.end_time,\n                            \"ai_title\": activity.ai_title,\n                            \"ai_summary\": activity.ai_summary,\n                            \"event_count\": activity.event_count,\n                            \"created_at\": activity.created_at,\n                            \"updated_at\": activity.updated_at,\n                        }\n                    )\n                return results\n        except SQLAlchemyError as e:\n            logger.error(f\"查询活动列表失败: {e}\")\n            return []\n\n    def count_activities(\n        self,\n        start_date: datetime | None = None,\n        end_date: datetime | None = None,\n    ) -> int:\n        \"\"\"统计活动总数\"\"\"\n        try:\n            with self.db_base.get_session() as session:\n                q = session.query(Activity).filter(col(Activity.deleted_at).is_(None))\n                if start_date:\n                    q = q.filter(col(Activity.start_time) >= start_date)\n                if end_date:\n                    q = q.filter(col(Activity.start_time) <= end_date)\n                return q.count()\n        except SQLAlchemyError as e:\n            logger.error(f\"统计活动数量失败: {e}\")\n            return 0\n\n    def get_activity_events(self, activity_id: int) -> list[int]:\n        \"\"\"获取活动关联的事件ID列表\n\n        Args:\n            activity_id: 活动ID\n\n        Returns:\n            事件ID列表\n        \"\"\"\n        try:\n            with self.db_base.get_session() as session:\n                relations = (\n                    session.query(ActivityEventRelation)\n                    .filter(\n                        col(ActivityEventRelation.activity_id) == activity_id,\n                        col(ActivityEventRelation.deleted_at).is_(None),\n                    )\n                    .all()\n                )\n                return [r.event_id for r in relations]\n        except SQLAlchemyError as e:\n            logger.error(f\"获取活动关联事件失败: {e}\")\n            return []\n\n    def get_unprocessed_events(self, query_start_time: datetime) -> list[Event]:\n        \"\"\"查询未关联到活动的已完成且有AI总结的事件\n\n        Args:\n            query_start_time: 查询起始时间\n\n        Returns:\n            事件列表\n        \"\"\"\n        try:\n            with self.db_base.get_session() as session:\n                # 查询已完成且有AI总结的事件\n                events = (\n                    session.query(Event)\n                    .filter(\n                        col(Event.end_time).isnot(None),\n                        col(Event.ai_title).isnot(None),\n                        col(Event.ai_summary).isnot(None),\n                        col(Event.start_time) >= query_start_time,\n                        col(Event.deleted_at).is_(None),\n                    )\n                    .order_by(col(Event.start_time).asc())\n                    .all()\n                )\n\n                # 过滤掉已关联到活动的事件\n                unprocessed_events = []\n                for event in events:\n                    # 检查是否已关联\n                    relation = (\n                        session.query(ActivityEventRelation)\n                        .filter(\n                            col(ActivityEventRelation.event_id) == event.id,\n                            col(ActivityEventRelation.deleted_at).is_(None),\n                        )\n                        .first()\n                    )\n                    if not relation:\n                        # 在session关闭前访问所有需要的属性，确保它们被加载\n                        # 这样可以避免在session外访问时触发refresh操作\n                        _ = (\n                            event.id,\n                            event.start_time,\n                            event.end_time,\n                            event.ai_title,\n                            event.ai_summary,\n                            event.app_name,\n                            event.window_title,\n                        )\n                        # 将对象从session中分离，使其可以在session外使用\n                        session.expunge(event)\n                        unprocessed_events.append(event)\n\n                return unprocessed_events\n        except SQLAlchemyError as e:\n            logger.error(f\"查询未处理事件失败: {e}\")\n            return []\n\n    def activity_exists_for_time_window(self, window_start: datetime, window_end: datetime) -> bool:\n        \"\"\"检查指定时间窗口是否已存在活动记录\n\n        Args:\n            window_start: 窗口开始时间\n            window_end: 窗口结束时间\n\n        Returns:\n            是否存在\n        \"\"\"\n        try:\n            with self.db_base.get_session() as session:\n                activity = (\n                    session.query(Activity)\n                    .filter(\n                        col(Activity.start_time) == window_start,\n                        col(Activity.end_time) == window_end,\n                        col(Activity.deleted_at).is_(None),\n                    )\n                    .first()\n                )\n                return activity is not None\n        except SQLAlchemyError as e:\n            logger.error(f\"检查活动是否存在失败: {e}\")\n            return False\n\n    def activity_exists_for_event(self, event: Event) -> bool:\n        \"\"\"检查事件是否已关联到某个活动\n\n        Args:\n            event: 事件对象\n\n        Returns:\n            是否已关联\n        \"\"\"\n        try:\n            with self.db_base.get_session() as session:\n                relation = (\n                    session.query(ActivityEventRelation)\n                    .filter(\n                        col(ActivityEventRelation.event_id) == event.id,\n                        col(ActivityEventRelation.deleted_at).is_(None),\n                    )\n                    .first()\n                )\n                return relation is not None\n        except SQLAlchemyError as e:\n            logger.error(f\"检查事件是否已关联失败: {e}\")\n            return False\n\n    def activity_exists_for_event_id(self, event_id: int) -> bool:\n        \"\"\"检查事件ID是否已关联到某个活动\n\n        Args:\n            event_id: 事件ID\n\n        Returns:\n            是否已关联\n        \"\"\"\n        try:\n            with self.db_base.get_session() as session:\n                relation = (\n                    session.query(ActivityEventRelation)\n                    .filter(\n                        col(ActivityEventRelation.event_id) == event_id,\n                        col(ActivityEventRelation.deleted_at).is_(None),\n                    )\n                    .first()\n                )\n                return relation is not None\n        except SQLAlchemyError as e:\n            logger.error(f\"检查事件ID是否已关联失败: {e}\")\n            return False\n\n    def activity_overlaps_with_event(self, event: Event, tolerance_seconds: int = 60) -> bool:\n        \"\"\"检查是否存在与事件时间范围重叠的活动记录\n\n        Args:\n            event: 事件对象\n            tolerance_seconds: 容忍的时间差（秒），用于处理边界情况\n\n        Returns:\n            是否存在重叠\n        \"\"\"\n        try:\n            if not event.end_time:\n                return False\n\n            with self.db_base.get_session() as session:\n                # 查询与事件时间范围有重叠的活动\n                # 重叠条件：活动的开始时间 < 事件的结束时间 + 容忍度\n                # 且活动的结束时间 > 事件的开始时间 - 容忍度\n                activities = (\n                    session.query(Activity)\n                    .filter(\n                        col(Activity.start_time)\n                        < event.end_time + timedelta(seconds=tolerance_seconds),\n                        col(Activity.end_time)\n                        > event.start_time - timedelta(seconds=tolerance_seconds),\n                        col(Activity.deleted_at).is_(None),\n                    )\n                    .all()\n                )\n                return len(activities) > 0\n        except SQLAlchemyError as e:\n            logger.error(f\"检查活动重叠失败: {e}\")\n            return False\n"
  },
  {
    "path": "lifetrace/storage/automation_task_manager.py",
    "content": "\"\"\"自动化任务管理器 - 负责用户自定义任务的数据库操作\"\"\"\n\nfrom __future__ import annotations\n\nfrom typing import Any\n\nfrom sqlalchemy.exc import SQLAlchemyError\n\nfrom lifetrace.storage.models import AutomationTask\nfrom lifetrace.storage.sql_utils import col\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.time_utils import get_utc_now\n\nlogger = get_logger()\n\n_UNSET = object()\n\n\nclass AutomationTaskManager:\n    \"\"\"自动化任务管理类\"\"\"\n\n    def __init__(self, db_base):\n        self.db_base = db_base\n\n    def list_tasks(self, *, enabled: bool | None = None) -> list[dict[str, Any]]:\n        try:\n            with self.db_base.get_session() as session:\n                q = session.query(AutomationTask).filter(col(AutomationTask.deleted_at).is_(None))\n                if enabled is not None:\n                    q = q.filter(col(AutomationTask.enabled) == enabled)\n                q = q.order_by(col(AutomationTask.created_at).desc())\n                tasks = q.all()\n                return [self._to_dict(task) for task in tasks]\n        except SQLAlchemyError as exc:\n            logger.error(\"查询自动化任务失败: %s\", exc)\n            return []\n\n    def get_task(self, task_id: int) -> dict[str, Any] | None:\n        try:\n            with self.db_base.get_session() as session:\n                task = (\n                    session.query(AutomationTask)\n                    .filter(\n                        col(AutomationTask.id) == task_id,\n                        col(AutomationTask.deleted_at).is_(None),\n                    )\n                    .first()\n                )\n                if not task:\n                    return None\n                return self._to_dict(task)\n        except SQLAlchemyError as exc:\n            logger.error(\"获取自动化任务失败: %s\", exc)\n            return None\n\n    def create_task(\n        self,\n        *,\n        name: str,\n        description: str | None,\n        enabled: bool,\n        schedule_type: str,\n        schedule_config: str | None,\n        action_type: str,\n        action_payload: str | None,\n    ) -> int | None:\n        try:\n            with self.db_base.get_session() as session:\n                task = AutomationTask(\n                    name=name,\n                    description=description,\n                    enabled=enabled,\n                    schedule_type=schedule_type,\n                    schedule_config=schedule_config,\n                    action_type=action_type,\n                    action_payload=action_payload,\n                )\n                session.add(task)\n                session.flush()\n                if task.id is None:\n                    raise ValueError(\"AutomationTask must have an id after creation.\")\n                logger.info(\"创建自动化任务: %s - %s\", task.id, task.name)\n                return task.id\n        except SQLAlchemyError as exc:\n            logger.error(\"创建自动化任务失败: %s\", exc)\n            return None\n\n    def update_task(  # noqa: PLR0913\n        self,\n        task_id: int,\n        *,\n        name: str | Any = _UNSET,\n        description: str | None | Any = _UNSET,\n        enabled: bool | Any = _UNSET,\n        schedule_type: str | Any = _UNSET,\n        schedule_config: str | None | Any = _UNSET,\n        action_type: str | Any = _UNSET,\n        action_payload: str | None | Any = _UNSET,\n        last_run_at: Any = _UNSET,\n        last_status: str | None | Any = _UNSET,\n        last_error: str | None | Any = _UNSET,\n        last_output: str | None | Any = _UNSET,\n    ) -> bool:\n        try:\n            with self.db_base.get_session() as session:\n                task = (\n                    session.query(AutomationTask)\n                    .filter(\n                        col(AutomationTask.id) == task_id,\n                        col(AutomationTask.deleted_at).is_(None),\n                    )\n                    .first()\n                )\n                if not task:\n                    return False\n\n                updates = {\n                    \"name\": name,\n                    \"description\": description,\n                    \"enabled\": enabled,\n                    \"schedule_type\": schedule_type,\n                    \"schedule_config\": schedule_config,\n                    \"action_type\": action_type,\n                    \"action_payload\": action_payload,\n                    \"last_run_at\": last_run_at,\n                    \"last_status\": last_status,\n                    \"last_error\": last_error,\n                    \"last_output\": last_output,\n                }\n\n                for attr, value in updates.items():\n                    if value is not _UNSET:\n                        setattr(task, attr, value)\n\n                task.updated_at = get_utc_now()\n                session.flush()\n                logger.info(\"更新自动化任务: %s\", task_id)\n                return True\n        except SQLAlchemyError as exc:\n            logger.error(\"更新自动化任务失败: %s\", exc)\n            return False\n\n    def delete_task(self, task_id: int) -> bool:\n        try:\n            with self.db_base.get_session() as session:\n                task = (\n                    session.query(AutomationTask)\n                    .filter(\n                        col(AutomationTask.id) == task_id,\n                        col(AutomationTask.deleted_at).is_(None),\n                    )\n                    .first()\n                )\n                if not task:\n                    return False\n                task.deleted_at = get_utc_now()\n                task.updated_at = get_utc_now()\n                session.flush()\n                logger.info(\"删除自动化任务: %s\", task_id)\n                return True\n        except SQLAlchemyError as exc:\n            logger.error(\"删除自动化任务失败: %s\", exc)\n            return False\n\n    @staticmethod\n    def _to_dict(task: AutomationTask) -> dict[str, Any]:\n        return {\n            \"id\": task.id,\n            \"name\": task.name,\n            \"description\": task.description,\n            \"enabled\": task.enabled,\n            \"schedule_type\": task.schedule_type,\n            \"schedule_config\": task.schedule_config,\n            \"action_type\": task.action_type,\n            \"action_payload\": task.action_payload,\n            \"last_run_at\": task.last_run_at,\n            \"last_status\": task.last_status,\n            \"last_error\": task.last_error,\n            \"last_output\": task.last_output,\n            \"created_at\": task.created_at,\n            \"updated_at\": task.updated_at,\n        }\n"
  },
  {
    "path": "lifetrace/storage/chat_manager.py",
    "content": "\"\"\"聊天管理器 - 负责聊天会话和消息相关的数据库操作\"\"\"\n\nfrom typing import Any\n\nfrom sqlalchemy.exc import SQLAlchemyError\n\nfrom lifetrace.storage.database_base import DatabaseBase\nfrom lifetrace.storage.models import Chat, Message\nfrom lifetrace.storage.sql_utils import col\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.time_utils import get_utc_now\n\nlogger = get_logger()\n\n# 聊天标题最大长度\nCHAT_TITLE_MAX_LENGTH = 50\n\n\nclass ChatManager:\n    \"\"\"聊天管理类\"\"\"\n\n    def __init__(self, db_base: DatabaseBase):\n        self.db_base = db_base\n\n    def create_chat(\n        self,\n        session_id: str,\n        chat_type: str = \"event\",\n        title: str | None = None,\n        context_id: int | None = None,\n        metadata: str | None = None,\n    ) -> dict[str, Any] | None:\n        \"\"\"创建聊天会话\n\n        Args:\n            session_id: 会话ID（UUID）\n            chat_type: 聊天类型（event, project, general, task等）\n            title: 会话标题\n            context_id: 上下文ID（根据chat_type不同而不同）\n            metadata: JSON格式的元数据\n        \"\"\"\n        try:\n            with self.db_base.get_session() as session:\n                chat = Chat(\n                    session_id=session_id,\n                    chat_type=chat_type,\n                    title=title,\n                    context_id=context_id,\n                    extra_data=metadata,\n                )\n                session.add(chat)\n                session.flush()\n\n                logger.info(f\"创建聊天会话: {session_id}, 类型: {chat_type}\")\n                return {\n                    \"id\": chat.id,\n                    \"session_id\": chat.session_id,\n                    \"chat_type\": chat.chat_type,\n                    \"title\": chat.title,\n                    \"context_id\": chat.context_id,\n                    \"extra_data\": chat.extra_data,\n                    \"created_at\": chat.created_at,\n                    \"updated_at\": chat.updated_at,\n                    \"last_message_at\": chat.last_message_at,\n                }\n        except SQLAlchemyError as e:\n            logger.error(f\"创建聊天会话失败: {e}\")\n            return None\n\n    def get_chat_by_session_id(self, session_id: str) -> dict[str, Any] | None:\n        \"\"\"根据session_id获取聊天会话\"\"\"\n        try:\n            with self.db_base.get_session() as session:\n                chat = session.query(Chat).filter_by(session_id=session_id).first()\n                if chat:\n                    return {\n                        \"id\": chat.id,\n                        \"session_id\": chat.session_id,\n                        \"chat_type\": chat.chat_type,\n                        \"title\": chat.title,\n                        \"context_id\": chat.context_id,\n                        \"extra_data\": chat.extra_data,\n                        \"created_at\": chat.created_at,\n                        \"updated_at\": chat.updated_at,\n                        \"last_message_at\": chat.last_message_at,\n                    }\n                return None\n        except SQLAlchemyError as e:\n            logger.error(f\"获取聊天会话失败: {e}\")\n            return None\n\n    def list_chats(\n        self,\n        chat_type: str | None = None,\n        limit: int = 50,\n        offset: int = 0,\n    ) -> list[dict[str, Any]]:\n        \"\"\"列出聊天会话\n\n        Args:\n            chat_type: 聊天类型过滤（可选）\n            limit: 返回数量限制\n            offset: 偏移量\n        \"\"\"\n        try:\n            with self.db_base.get_session() as session:\n                q = session.query(Chat)\n\n                if chat_type:\n                    q = q.filter(col(Chat.chat_type) == chat_type)\n\n                chats = (\n                    q.order_by(\n                        col(Chat.last_message_at).desc().nullslast(),\n                        col(Chat.created_at).desc(),\n                    )\n                    .offset(offset)\n                    .limit(limit)\n                    .all()\n                )\n\n                return [\n                    {\n                        \"id\": c.id,\n                        \"session_id\": c.session_id,\n                        \"chat_type\": c.chat_type,\n                        \"title\": c.title,\n                        \"context_id\": c.context_id,\n                        \"extra_data\": c.extra_data,\n                        \"created_at\": c.created_at,\n                        \"updated_at\": c.updated_at,\n                        \"last_message_at\": c.last_message_at,\n                    }\n                    for c in chats\n                ]\n        except SQLAlchemyError as e:\n            logger.error(f\"列出聊天会话失败: {e}\")\n            return []\n\n    def update_chat_title(self, session_id: str, title: str) -> bool:\n        \"\"\"更新聊天会话标题\"\"\"\n        try:\n            with self.db_base.get_session() as session:\n                chat = session.query(Chat).filter_by(session_id=session_id).first()\n                if chat:\n                    chat.title = title\n                    session.flush()\n                    logger.info(f\"更新聊天会话标题: {session_id} -> {title}\")\n                    return True\n                return False\n        except SQLAlchemyError as e:\n            logger.error(f\"更新聊天会话标题失败: {e}\")\n            return False\n\n    def delete_chat(self, session_id: str) -> bool:\n        \"\"\"删除聊天会话及其所有消息\"\"\"\n        try:\n            with self.db_base.get_session() as session:\n                chat = session.query(Chat).filter_by(session_id=session_id).first()\n                if chat:\n                    # 删除该会话的所有消息\n                    session.query(Message).filter_by(chat_id=chat.id).delete()\n                    # 删除会话\n                    session.delete(chat)\n                    session.flush()\n                    logger.info(f\"删除聊天会话: {session_id}\")\n                    return True\n                return False\n        except SQLAlchemyError as e:\n            logger.error(f\"删除聊天会话失败: {e}\")\n            return False\n\n    # ===== 消息管理 =====\n\n    def add_message(\n        self,\n        session_id: str,\n        role: str,\n        content: str,\n        token_count: int | None = None,\n        model: str | None = None,\n        metadata: str | None = None,\n    ) -> dict[str, Any] | None:\n        \"\"\"添加消息到聊天会话\n\n        Args:\n            session_id: 会话ID\n            role: 消息角色（user, assistant, system）\n            content: 消息内容\n            token_count: token数量\n            model: 使用的模型\n            metadata: JSON格式的元数据\n        \"\"\"\n        try:\n            with self.db_base.get_session() as session:\n                # 获取或创建聊天会话\n                chat = session.query(Chat).filter_by(session_id=session_id).first()\n                if not chat:\n                    # 如果会话不存在，自动创建\n                    chat = Chat(\n                        session_id=session_id,\n                        chat_type=\"event\",  # 默认类型\n                    )\n                    session.add(chat)\n                    session.flush()\n                    logger.info(f\"自动创建聊天会话: {session_id}\")\n                if chat.id is None:\n                    raise ValueError(\"Chat must have an id before adding messages.\")\n\n                # 添加消息\n                message = Message(\n                    chat_id=chat.id,\n                    role=role,\n                    content=content,\n                    token_count=token_count,\n                    model=model,\n                    extra_data=metadata,\n                )\n                session.add(message)\n\n                # 更新会话的最后消息时间\n                chat.last_message_at = get_utc_now()\n\n                # 如果会话没有标题且这是第一条用户消息，可以设置标题\n                if not chat.title and role == \"user\":\n                    # 使用消息内容的前N个字符作为标题\n                    chat.title = content[:CHAT_TITLE_MAX_LENGTH] + (\n                        \"...\" if len(content) > CHAT_TITLE_MAX_LENGTH else \"\"\n                    )\n\n                session.flush()\n\n                logger.info(f\"添加消息到会话 {session_id}: role={role}\")\n                return {\n                    \"id\": message.id,\n                    \"chat_id\": message.chat_id,\n                    \"role\": message.role,\n                    \"content\": message.content,\n                    \"token_count\": message.token_count,\n                    \"model\": message.model,\n                    \"extra_data\": message.extra_data,\n                    \"created_at\": message.created_at,\n                }\n        except SQLAlchemyError as e:\n            logger.error(f\"添加消息失败: {e}\")\n            return None\n\n    def get_messages(\n        self,\n        session_id: str,\n        limit: int | None = None,\n        offset: int = 0,\n    ) -> list[dict[str, Any]]:\n        \"\"\"获取聊天会话的消息列表\n\n        Args:\n            session_id: 会话ID\n            limit: 返回数量限制（None表示全部）\n            offset: 偏移量\n        \"\"\"\n        try:\n            with self.db_base.get_session() as session:\n                chat = session.query(Chat).filter_by(session_id=session_id).first()\n                if not chat:\n                    return []\n\n                q = (\n                    session.query(Message)\n                    .filter_by(chat_id=chat.id)\n                    .order_by(col(Message.created_at).asc())\n                )\n\n                if offset > 0:\n                    q = q.offset(offset)\n                if limit:\n                    q = q.limit(limit)\n\n                messages = q.all()\n\n                return [\n                    {\n                        \"id\": m.id,\n                        \"chat_id\": m.chat_id,\n                        \"role\": m.role,\n                        \"content\": m.content,\n                        \"token_count\": m.token_count,\n                        \"model\": m.model,\n                        \"extra_data\": m.extra_data,\n                        \"created_at\": m.created_at,\n                    }\n                    for m in messages\n                ]\n        except SQLAlchemyError as e:\n            logger.error(f\"获取消息列表失败: {e}\")\n            return []\n\n    def get_message_count(self, session_id: str) -> int:\n        \"\"\"获取聊天会话的消息数量\"\"\"\n        try:\n            with self.db_base.get_session() as session:\n                chat = session.query(Chat).filter_by(session_id=session_id).first()\n                if not chat:\n                    return 0\n\n                return session.query(Message).filter_by(chat_id=chat.id).count()\n        except SQLAlchemyError as e:\n            logger.error(f\"获取消息数量失败: {e}\")\n            return 0\n\n    def get_chat_summaries(\n        self,\n        chat_type: str | None = None,\n        limit: int = 10,\n    ) -> list[dict[str, Any]]:\n        \"\"\"获取聊天会话摘要列表（包含消息数量）\n\n        Args:\n            chat_type: 聊天类型过滤（可选）\n            limit: 返回数量限制\n        \"\"\"\n        try:\n            with self.db_base.get_session() as session:\n                q = session.query(Chat)\n\n                if chat_type:\n                    q = q.filter(col(Chat.chat_type) == chat_type)\n\n                chats = (\n                    q.order_by(\n                        col(Chat.last_message_at).desc().nullslast(),\n                        col(Chat.created_at).desc(),\n                    )\n                    .limit(limit)\n                    .all()\n                )\n\n                summaries = []\n                for chat in chats:\n                    message_count = session.query(Message).filter_by(chat_id=chat.id).count()\n                    summaries.append(\n                        {\n                            \"session_id\": chat.session_id,\n                            \"chat_type\": chat.chat_type,\n                            \"title\": chat.title,\n                            \"context_id\": chat.context_id,\n                            \"created_at\": chat.created_at,\n                            \"last_active\": chat.last_message_at or chat.created_at,\n                            \"message_count\": message_count,\n                        }\n                    )\n\n                return summaries\n        except SQLAlchemyError as e:\n            logger.error(f\"获取聊天会话摘要失败: {e}\")\n            return []\n\n    # ===== 会话上下文管理 =====\n\n    def get_chat_context(self, session_id: str) -> str | None:\n        \"\"\"获取会话上下文（JSON 字符串）\n\n        Args:\n            session_id: 会话ID\n\n        Returns:\n            上下文 JSON 字符串，如果不存在则返回 None\n        \"\"\"\n        try:\n            with self.db_base.get_session() as session:\n                chat = session.query(Chat).filter_by(session_id=session_id).first()\n                if chat:\n                    return chat.context\n                return None\n        except SQLAlchemyError as e:\n            logger.error(f\"获取会话上下文失败: {e}\")\n            return None\n\n    def update_chat_context(self, session_id: str, context: str) -> bool:\n        \"\"\"更新会话上下文\n\n        Args:\n            session_id: 会话ID\n            context: JSON 格式的上下文字符串\n\n        Returns:\n            是否更新成功\n        \"\"\"\n        try:\n            with self.db_base.get_session() as session:\n                chat = session.query(Chat).filter_by(session_id=session_id).first()\n                if chat:\n                    chat.context = context\n                    chat.updated_at = get_utc_now()\n                    session.flush()\n                    return True\n                else:\n                    # 如果会话不存在，自动创建\n                    chat = Chat(\n                        session_id=session_id,\n                        chat_type=\"general\",\n                        context=context,\n                    )\n                    session.add(chat)\n                    session.flush()\n                    logger.info(f\"自动创建会话并设置上下文: {session_id}\")\n                    return True\n        except SQLAlchemyError as e:\n            logger.error(f\"更新会话上下文失败: {e}\")\n            return False\n"
  },
  {
    "path": "lifetrace/storage/database.py",
    "content": "\"\"\"\n数据库管理器主入口 - 直接暴露各个功能管理器\n\"\"\"\n\nfrom lifetrace.storage.activity_manager import ActivityManager\nfrom lifetrace.storage.automation_task_manager import AutomationTaskManager\nfrom lifetrace.storage.chat_manager import ChatManager\nfrom lifetrace.storage.database_base import DatabaseBase\nfrom lifetrace.storage.event_manager import EventManager\nfrom lifetrace.storage.journal_manager import JournalManager\nfrom lifetrace.storage.ocr_manager import OCRManager\nfrom lifetrace.storage.screenshot_manager import ScreenshotManager\nfrom lifetrace.storage.stats_manager import StatsManager\nfrom lifetrace.storage.todo_manager import TodoManager\nfrom lifetrace.util.logging_config import get_logger\n\nlogger = get_logger()\n\n# ===== 初始化数据库基础 =====\ndb_base = DatabaseBase()\n\n# ===== 初始化各个功能管理器 =====\nscreenshot_mgr = ScreenshotManager(db_base)\nevent_mgr = EventManager(db_base)\nocr_mgr = OCRManager(db_base)\ntodo_mgr = TodoManager(db_base)\nchat_mgr = ChatManager(db_base)\nstats_mgr = StatsManager(db_base)\njournal_mgr = JournalManager(db_base)\nactivity_mgr = ActivityManager(db_base)\nautomation_task_mgr = AutomationTaskManager(db_base)\n\n# ===== 向后兼容：保留原有的接口 =====\nengine = db_base.engine\nSessionLocal = db_base.SessionLocal\n\n\ndef get_session():\n    \"\"\"获取数据库会话上下文管理器\"\"\"\n    return db_base.get_session()\n\n\n# 数据库会话生成器（用于依赖注入）\ndef get_db():\n    \"\"\"获取数据库会话的生成器函数\"\"\"\n    if SessionLocal is None:\n        raise RuntimeError(\"Database session factory is not initialized.\")\n    session = SessionLocal()\n    try:\n        yield session\n    finally:\n        session.close()\n"
  },
  {
    "path": "lifetrace/storage/database_base.py",
    "content": "\"\"\"数据库基础管理器 - 负责数据库初始化和会话管理\n\n使用 SQLModel 进行数据库管理，迁移由 Alembic 处理。\n\"\"\"\n\nimport os\nfrom contextlib import contextmanager\nfrom pathlib import Path\n\nfrom sqlalchemy import create_engine, text\nfrom sqlalchemy.orm import sessionmaker\nfrom sqlmodel import Session, SQLModel\n\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.path_utils import get_database_path\nfrom lifetrace.util.utils import ensure_dir\n\nlogger = get_logger()\n\ntry:\n    from alembic import command\n    from alembic.config import Config\nexcept Exception:\n    command = None\n    Config = None\n\n\nclass DatabaseBase:\n    \"\"\"数据库基础管理类 - 处理数据库初始化和会话管理\"\"\"\n\n    def __init__(self):\n        self.engine = None\n        self.SessionLocal = None\n        self._init_database()\n\n    def _init_database(self):\n        \"\"\"初始化数据库\"\"\"\n        try:\n            db_path = str(get_database_path())\n            # 检查数据库文件是否已存在\n            db_exists = os.path.exists(db_path)\n\n            # 确保数据库目录存在\n            ensure_dir(os.path.dirname(db_path))\n\n            # 创建引擎\n            self.engine = create_engine(\"sqlite:///\" + db_path, echo=False, pool_pre_ping=True)\n\n            # 创建会话工厂（兼容旧代码）\n            self.SessionLocal = sessionmaker(bind=self.engine)\n\n            # 创建表\n            # 对于新数据库：创建所有表\n            # 对于现有数据库：只创建缺失的表（SQLModel.metadata.create_all 会自动跳过已存在的表）\n            if not db_exists:\n                SQLModel.metadata.create_all(bind=self.engine)\n                logger.info(f\"数据库初始化完成: {db_path}\")\n            else:\n                # 对于现有数据库，也调用 create_all 来创建缺失的表\n                # checkfirst=True（默认值）会跳过已存在的表\n                SQLModel.metadata.create_all(bind=self.engine)\n\n            # 运行 Alembic 迁移，补齐已有数据库的新增列/索引\n            self._run_migrations()\n\n            # 性能优化：添加关键索引\n            self._create_performance_indexes()\n\n        except Exception as e:\n            logger.error(f\"数据库初始化失败: {e}\")\n            raise\n\n    def _run_migrations(self) -> None:\n        \"\"\"运行 Alembic 迁移（如可用）\"\"\"\n        if command is None or Config is None:\n            logger.warning(\"Alembic 未就绪，跳过迁移\")\n            return\n\n        alembic_ini = Path(__file__).resolve().parents[1] / \"alembic.ini\"\n        migrations_dir = alembic_ini.parent / \"migrations\"\n\n        if not alembic_ini.exists() or not migrations_dir.exists():\n            logger.warning(\"Alembic 配置缺失，跳过迁移\")\n            return\n\n        config = Config(str(alembic_ini))\n        config.set_main_option(\"script_location\", str(migrations_dir))\n        config.set_main_option(\"sqlalchemy.url\", f\"sqlite:///{get_database_path()}\")\n\n        try:\n            command.upgrade(config, \"head\")\n            logger.info(\"数据库迁移检查完成\")\n        except Exception as exc:\n            logger.error(f\"数据库迁移失败: {exc}\")\n            raise\n\n    def _create_performance_indexes(self):\n        \"\"\"创建性能优化索引\"\"\"\n        try:\n            if self.engine is None:\n                raise RuntimeError(\"Database engine is not initialized.\")\n            with self.engine.connect() as conn:\n                # 获取现有索引列表（只获取索引名称）\n                existing_indexes = [\n                    row[0]\n                    for row in conn.execute(\n                        text(\n                            \"SELECT name FROM sqlite_master WHERE type='index' AND name IS NOT NULL\"\n                        )\n                    ).fetchall()\n                ]\n\n                # 获取所有表的列信息，用于检查列是否存在\n                table_columns: dict[str, set[str]] = {}\n                tables = conn.execute(\n                    text(\"SELECT name FROM sqlite_master WHERE type='table'\")\n                ).fetchall()\n                for (table_name,) in tables:\n                    columns = conn.execute(text(f\"PRAGMA table_info({table_name})\")).fetchall()\n                    table_columns[table_name] = {col[1] for col in columns}\n\n                # 定义需要创建的索引\n                # 格式：(索引名, 表名, 列名列表, 创建SQL)\n                indexes_to_create = [\n                    (\n                        \"idx_ocr_results_screenshot_id\",\n                        \"ocr_results\",\n                        [\"screenshot_id\"],\n                        \"CREATE INDEX IF NOT EXISTS idx_ocr_results_screenshot_id ON ocr_results(screenshot_id)\",\n                    ),\n                    (\n                        \"idx_screenshots_created_at\",\n                        \"screenshots\",\n                        [\"created_at\"],\n                        \"CREATE INDEX IF NOT EXISTS idx_screenshots_created_at ON screenshots(created_at)\",\n                    ),\n                    (\n                        \"idx_screenshots_app_name\",\n                        \"screenshots\",\n                        [\"app_name\"],\n                        \"CREATE INDEX IF NOT EXISTS idx_screenshots_app_name ON screenshots(app_name)\",\n                    ),\n                    (\n                        \"idx_screenshots_event_id\",\n                        \"screenshots\",\n                        [\"event_id\"],\n                        \"CREATE INDEX IF NOT EXISTS idx_screenshots_event_id ON screenshots(event_id)\",\n                    ),\n                    (\n                        \"idx_todos_parent_todo_id\",\n                        \"todos\",\n                        [\"parent_todo_id\"],\n                        \"CREATE INDEX IF NOT EXISTS idx_todos_parent_todo_id ON todos(parent_todo_id)\",\n                    ),\n                    (\n                        \"idx_todos_status\",\n                        \"todos\",\n                        [\"status\"],\n                        \"CREATE INDEX IF NOT EXISTS idx_todos_status ON todos(status)\",\n                    ),\n                    (\n                        \"idx_todos_deleted_at\",\n                        \"todos\",\n                        [\"deleted_at\"],\n                        \"CREATE INDEX IF NOT EXISTS idx_todos_deleted_at ON todos(deleted_at)\",\n                    ),\n                    (\n                        \"idx_todos_priority\",\n                        \"todos\",\n                        [\"priority\"],\n                        \"CREATE INDEX IF NOT EXISTS idx_todos_priority ON todos(priority)\",\n                    ),\n                    (\n                        \"idx_todos_uid\",\n                        \"todos\",\n                        [\"uid\"],\n                        \"CREATE INDEX IF NOT EXISTS idx_todos_uid ON todos(uid)\",\n                    ),\n                    (\n                        \"idx_todos_order\",\n                        \"todos\",\n                        [\"order\"],\n                        'CREATE INDEX IF NOT EXISTS idx_todos_order ON todos(\"order\")',\n                    ),\n                    (\n                        \"idx_attachments_file_hash\",\n                        \"attachments\",\n                        [\"file_hash\"],\n                        \"CREATE INDEX IF NOT EXISTS idx_attachments_file_hash ON attachments(file_hash)\",\n                    ),\n                    (\n                        \"idx_attachments_deleted_at\",\n                        \"attachments\",\n                        [\"deleted_at\"],\n                        \"CREATE INDEX IF NOT EXISTS idx_attachments_deleted_at ON attachments(deleted_at)\",\n                    ),\n                    (\n                        \"idx_todo_attachment_relations_todo_id\",\n                        \"todo_attachment_relations\",\n                        [\"todo_id\"],\n                        \"CREATE INDEX IF NOT EXISTS idx_todo_attachment_relations_todo_id ON todo_attachment_relations(todo_id)\",\n                    ),\n                    (\n                        \"idx_todo_attachment_relations_attachment_id\",\n                        \"todo_attachment_relations\",\n                        [\"attachment_id\"],\n                        \"CREATE INDEX IF NOT EXISTS idx_todo_attachment_relations_attachment_id ON todo_attachment_relations(attachment_id)\",\n                    ),\n                    (\n                        \"idx_tags_tag_name_unique\",\n                        \"tags\",\n                        [\"tag_name\"],\n                        \"CREATE UNIQUE INDEX IF NOT EXISTS idx_tags_tag_name_unique ON tags(tag_name)\",\n                    ),\n                    (\n                        \"idx_tags_deleted_at\",\n                        \"tags\",\n                        [\"deleted_at\"],\n                        \"CREATE INDEX IF NOT EXISTS idx_tags_deleted_at ON tags(deleted_at)\",\n                    ),\n                    (\n                        \"idx_todo_tag_relations_todo_id\",\n                        \"todo_tag_relations\",\n                        [\"todo_id\"],\n                        \"CREATE INDEX IF NOT EXISTS idx_todo_tag_relations_todo_id ON todo_tag_relations(todo_id)\",\n                    ),\n                    (\n                        \"idx_todo_tag_relations_tag_id\",\n                        \"todo_tag_relations\",\n                        [\"tag_id\"],\n                        \"CREATE INDEX IF NOT EXISTS idx_todo_tag_relations_tag_id ON todo_tag_relations(tag_id)\",\n                    ),\n                    (\n                        \"idx_journals_date\",\n                        \"journals\",\n                        [\"date\"],\n                        \"CREATE INDEX IF NOT EXISTS idx_journals_date ON journals(date)\",\n                    ),\n                    (\n                        \"idx_journals_deleted_at\",\n                        \"journals\",\n                        [\"deleted_at\"],\n                        \"CREATE INDEX IF NOT EXISTS idx_journals_deleted_at ON journals(deleted_at)\",\n                    ),\n                    (\n                        \"idx_journals_uid\",\n                        \"journals\",\n                        [\"uid\"],\n                        \"CREATE INDEX IF NOT EXISTS idx_journals_uid ON journals(uid)\",\n                    ),\n                    (\n                        \"idx_journal_tag_relations_journal_id\",\n                        \"journal_tag_relations\",\n                        [\"journal_id\"],\n                        \"CREATE INDEX IF NOT EXISTS idx_journal_tag_relations_journal_id ON journal_tag_relations(journal_id)\",\n                    ),\n                    (\n                        \"idx_journal_tag_relations_tag_id\",\n                        \"journal_tag_relations\",\n                        [\"tag_id\"],\n                        \"CREATE INDEX IF NOT EXISTS idx_journal_tag_relations_tag_id ON journal_tag_relations(tag_id)\",\n                    ),\n                    (\n                        \"idx_journal_todo_relations_journal_id\",\n                        \"journal_todo_relations\",\n                        [\"journal_id\"],\n                        \"CREATE INDEX IF NOT EXISTS idx_journal_todo_relations_journal_id ON journal_todo_relations(journal_id)\",\n                    ),\n                    (\n                        \"idx_journal_todo_relations_todo_id\",\n                        \"journal_todo_relations\",\n                        [\"todo_id\"],\n                        \"CREATE INDEX IF NOT EXISTS idx_journal_todo_relations_todo_id ON journal_todo_relations(todo_id)\",\n                    ),\n                    (\n                        \"idx_journal_activity_relations_journal_id\",\n                        \"journal_activity_relations\",\n                        [\"journal_id\"],\n                        \"CREATE INDEX IF NOT EXISTS idx_journal_activity_relations_journal_id ON journal_activity_relations(journal_id)\",\n                    ),\n                    (\n                        \"idx_journal_activity_relations_activity_id\",\n                        \"journal_activity_relations\",\n                        [\"activity_id\"],\n                        \"CREATE INDEX IF NOT EXISTS idx_journal_activity_relations_activity_id ON journal_activity_relations(activity_id)\",\n                    ),\n                    (\n                        \"idx_activities_start_time\",\n                        \"activities\",\n                        [\"start_time\"],\n                        \"CREATE INDEX IF NOT EXISTS idx_activities_start_time ON activities(start_time)\",\n                    ),\n                    (\n                        \"idx_activities_end_time\",\n                        \"activities\",\n                        [\"end_time\"],\n                        \"CREATE INDEX IF NOT EXISTS idx_activities_end_time ON activities(end_time)\",\n                    ),\n                    (\n                        \"idx_activity_event_relations_activity_id\",\n                        \"activity_event_relations\",\n                        [\"activity_id\"],\n                        \"CREATE INDEX IF NOT EXISTS idx_activity_event_relations_activity_id ON activity_event_relations(activity_id)\",\n                    ),\n                    (\n                        \"idx_activity_event_relations_event_id\",\n                        \"activity_event_relations\",\n                        [\"event_id\"],\n                        \"CREATE INDEX IF NOT EXISTS idx_activity_event_relations_event_id ON activity_event_relations(event_id)\",\n                    ),\n                    (\n                        \"idx_chats_session_id\",\n                        \"chats\",\n                        [\"session_id\"],\n                        \"CREATE INDEX IF NOT EXISTS idx_chats_session_id ON chats(session_id)\",\n                    ),\n                    (\n                        \"idx_messages_chat_id\",\n                        \"messages\",\n                        [\"chat_id\"],\n                        \"CREATE INDEX IF NOT EXISTS idx_messages_chat_id ON messages(chat_id)\",\n                    ),\n                    # 音频相关索引\n                    (\n                        \"idx_audio_recordings_start_time\",\n                        \"audio_recordings\",\n                        [\"start_time\"],\n                        \"CREATE INDEX IF NOT EXISTS idx_audio_recordings_start_time ON audio_recordings(start_time)\",\n                    ),\n                    (\n                        \"idx_audio_recordings_status\",\n                        \"audio_recordings\",\n                        [\"status\"],\n                        \"CREATE INDEX IF NOT EXISTS idx_audio_recordings_status ON audio_recordings(status)\",\n                    ),\n                    (\n                        \"idx_audio_recordings_deleted_at\",\n                        \"audio_recordings\",\n                        [\"deleted_at\"],\n                        \"CREATE INDEX IF NOT EXISTS idx_audio_recordings_deleted_at ON audio_recordings(deleted_at)\",\n                    ),\n                    (\n                        \"idx_transcriptions_audio_recording_id\",\n                        \"transcriptions\",\n                        [\"audio_recording_id\"],\n                        \"CREATE INDEX IF NOT EXISTS idx_transcriptions_audio_recording_id ON transcriptions(audio_recording_id)\",\n                    ),\n                    (\n                        \"idx_transcriptions_extraction_status\",\n                        \"transcriptions\",\n                        [\"extraction_status\"],\n                        \"CREATE INDEX IF NOT EXISTS idx_transcriptions_extraction_status ON transcriptions(extraction_status)\",\n                    ),\n                ]\n\n                # 创建索引\n                created_count = 0\n                skipped_count = 0\n                for index_name, table_name, columns, create_sql in indexes_to_create:\n                    # 检查索引是否已存在\n                    if index_name in existing_indexes:\n                        continue\n\n                    # 检查表是否存在\n                    if table_name not in table_columns:\n                        skipped_count += 1\n                        logger.debug(f\"跳过索引 {index_name}：表 {table_name} 不存在\")\n                        continue\n\n                    # 检查所有需要的列是否存在\n                    missing_columns = [\n                        col for col in columns if col not in table_columns[table_name]\n                    ]\n                    if missing_columns:\n                        skipped_count += 1\n                        logger.debug(\n                            f\"跳过索引 {index_name}：列 {missing_columns} 在表 {table_name} 中不存在\"\n                        )\n                        continue\n\n                    # 创建索引\n                    conn.execute(text(create_sql))\n                    created_count += 1\n                    logger.info(f\"已创建性能索引: {index_name}\")\n\n                conn.commit()\n\n                # 只在有索引被创建或跳过时打印完成信息\n                if created_count > 0 or skipped_count > 0:\n                    logger.info(\n                        f\"性能索引检查完成：创建 {created_count} 个，跳过 {skipped_count} 个（表/列不存在）\"\n                    )\n\n        except Exception as e:\n            logger.warning(f\"创建性能索引失败: {e}\")\n            raise\n\n    @contextmanager\n    def get_session(self):\n        \"\"\"获取数据库会话上下文管理器（使用 SQLModel Session）\"\"\"\n        with Session(self.engine) as session:\n            try:\n                yield session\n                session.commit()\n            except Exception as e:\n                session.rollback()\n                logger.error(f\"数据库操作失败: {e}\")\n                raise\n\n    @contextmanager\n    def get_sqlalchemy_session(self):\n        \"\"\"获取 SQLAlchemy 会话上下文管理器（用于兼容旧代码）\"\"\"\n        if self.SessionLocal is None:\n            raise RuntimeError(\"Database session factory is not initialized.\")\n        session = self.SessionLocal()\n        try:\n            yield session\n            session.commit()\n        except Exception as e:\n            session.rollback()\n            logger.error(f\"数据库操作失败: {e}\")\n            raise\n        finally:\n            session.close()\n\n\n# 数据库会话生成器（用于依赖注入）\ndef get_db(db_base: DatabaseBase):\n    \"\"\"获取数据库会话的生成器函数\"\"\"\n    if db_base.SessionLocal is None:\n        raise RuntimeError(\"Database session factory is not initialized.\")\n    session = db_base.SessionLocal()\n    try:\n        yield session\n    finally:\n        session.close()\n"
  },
  {
    "path": "lifetrace/storage/event_manager.py",
    "content": "\"\"\"事件管理器 - 负责事件相关的数据库操作\"\"\"\n\nimport importlib\nfrom datetime import datetime\nfrom typing import Any\n\nfrom sqlalchemy.exc import SQLAlchemyError\nfrom sqlalchemy.orm import Session\n\nfrom lifetrace.storage.database_base import DatabaseBase\nfrom lifetrace.storage.models import Event, Screenshot\nfrom lifetrace.storage.sql_utils import col\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.time_utils import get_utc_now\n\nfrom .event_queries import (\n    count_events,\n    get_event_id_by_screenshot,\n    get_event_screenshots,\n    get_event_summary,\n    get_event_text,\n    get_events_by_ids,\n    list_events,\n    search_events_simple,\n)\nfrom .event_stats import get_app_usage_stats\n\nlogger = get_logger()\n\n\nclass EventManager:\n    \"\"\"事件管理类\"\"\"\n\n    def __init__(self, db_base: DatabaseBase):\n        self.db_base = db_base\n\n    def _get_last_open_event(self, session: Session) -> Event | None:\n        \"\"\"获取最后一个未结束的事件\"\"\"\n        return (\n            session.query(Event)\n            .filter(col(Event.end_time).is_(None))\n            .order_by(col(Event.start_time).desc())\n            .first()\n        )\n\n    def _should_reuse_event(\n        self,\n        old_app: str | None,\n        old_title: str | None,\n        new_app: str | None,\n        new_title: str | None,\n    ) -> bool:\n        \"\"\"判断是否应该复用事件\"\"\"\n        old_app_norm = (old_app or \"\").strip().lower()\n        new_app_norm = (new_app or \"\").strip().lower()\n        old_title_norm = (old_title or \"\").strip()\n        new_title_norm = (new_title or \"\").strip()\n\n        if old_app_norm != new_app_norm:\n            logger.info(f\"🔄 应用切换: {old_app} → {new_app} (创建新事件)\")\n            return False\n\n        if old_title_norm != new_title_norm:\n            logger.info(f\"📝 窗口标题变化: {old_title} → {new_title} (创建新事件)\")\n            return False\n\n        logger.info(\"♻️  应用名和窗口标题都相同，复用事件\")\n        return True\n\n    def get_active_event(self) -> int | None:\n        \"\"\"获取当前活跃的事件ID\"\"\"\n        try:\n            with self.db_base.get_session() as session:\n                last_event = self._get_last_open_event(session)\n                if last_event:\n                    return last_event.id\n                return None\n        except SQLAlchemyError as e:\n            logger.error(f\"获取活跃事件失败: {e}\")\n            return None\n\n    def get_or_create_event(\n        self,\n        app_name: str | None,\n        window_title: str | None,\n        timestamp: datetime | None = None,\n    ) -> int | None:\n        \"\"\"按当前前台应用和窗口标题维护事件\"\"\"\n        try:\n            closed_event_id = None\n\n            with self.db_base.get_session() as session:\n                now_ts = timestamp or get_utc_now()\n                last_event = self._get_last_open_event(session)\n\n                if last_event:\n                    logger.info(\n                        f\"🔍 检查事件复用 - 旧事件ID: {last_event.id}, \"\n                        f\"旧应用: '{last_event.app_name}', 新应用: '{app_name}', \"\n                        f\"旧标题: '{last_event.window_title}', 新标题: '{window_title}'\"\n                    )\n                    should_reuse = self._should_reuse_event(\n                        old_app=last_event.app_name,\n                        old_title=last_event.window_title,\n                        new_app=app_name,\n                        new_title=window_title,\n                    )\n                    logger.info(f\"📊 事件复用判断结果: {should_reuse}\")\n\n                    if should_reuse:\n                        session.flush()\n                        logger.info(f\"♻️  复用事件 {last_event.id}（不关闭）\")\n                        return last_event.id\n                    else:\n                        last_event.end_time = now_ts\n                        closed_event_id = last_event.id\n                        session.flush()\n                        logger.info(\n                            f\"🔚 关闭旧事件 {closed_event_id}: {last_event.app_name} - {last_event.window_title}\"\n                        )\n                else:\n                    logger.info(\"❌ 没有找到未结束的事件，需要创建新事件\")\n\n                new_event = Event(app_name=app_name, window_title=window_title, start_time=now_ts)\n                session.add(new_event)\n                session.flush()\n                new_event_id = new_event.id\n                logger.info(\n                    f\"✨ 创建新事件 {new_event_id}: {app_name} - {window_title} (end_time=NULL)\"\n                )\n\n            if closed_event_id:\n                try:\n                    logger.info(f\"📝 触发已关闭事件 {closed_event_id} 的摘要生成\")\n                    summary_module = importlib.import_module(\"lifetrace.llm.event_summary_service\")\n                    summary_module.generate_event_summary_async(closed_event_id)\n                except Exception as e:\n                    logger.error(f\"触发事件摘要生成失败: {e}\")\n            else:\n                logger.info(f\"✅ 无需生成摘要（新事件 {new_event_id}，无旧事件关闭）\")\n\n            return new_event_id\n        except SQLAlchemyError as e:\n            logger.error(f\"获取或创建事件失败: {e}\")\n            return None\n\n    def close_active_event(self, end_time: datetime | None = None) -> bool:\n        \"\"\"主动结束当前事件\"\"\"\n        try:\n            closed_event_id = None\n            with self.db_base.get_session() as session:\n                last_event = self._get_last_open_event(session)\n                if last_event and last_event.end_time is None:\n                    last_event.end_time = end_time or get_utc_now()\n                    closed_event_id = last_event.id\n                    session.flush()\n\n            if closed_event_id:\n                try:\n                    summary_module = importlib.import_module(\"lifetrace.llm.event_summary_service\")\n                    summary_module.generate_event_summary_async(closed_event_id)\n                except Exception as e:\n                    logger.error(f\"触发事件摘要生成失败: {e}\")\n\n            return closed_event_id is not None\n        except SQLAlchemyError as e:\n            logger.error(f\"结束事件失败: {e}\")\n            return False\n\n    def update_event_summary(self, event_id: int, ai_title: str, ai_summary: str) -> bool:\n        \"\"\"更新事件的AI生成标题和摘要\"\"\"\n        try:\n            with self.db_base.get_session() as session:\n                event = session.query(Event).filter(col(Event.id) == event_id).first()\n                if event:\n                    event.ai_title = ai_title\n                    event.ai_summary = ai_summary\n                    session.commit()\n                    logger.info(f\"事件 {event_id} AI摘要更新成功\")\n                    return True\n                else:\n                    logger.warning(f\"事件 {event_id} 不存在\")\n                    return False\n        except SQLAlchemyError as e:\n            logger.error(f\"更新事件AI摘要失败: {e}\")\n            return False\n\n    def get_active_event_by_app(self, app_name: str) -> int | None:\n        \"\"\"获取指定应用的活跃事件ID\"\"\"\n        try:\n            with self.db_base.get_session() as session:\n                event = (\n                    session.query(Event)\n                    .filter(\n                        col(Event.app_name) == app_name,\n                        col(Event.status).in_([\"new\", \"processing\"]),\n                    )\n                    .order_by(col(Event.start_time).desc())\n                    .first()\n                )\n                return event.id if event else None\n        except SQLAlchemyError as e:\n            logger.error(f\"获取活跃事件失败: {e}\")\n            return None\n\n    def create_event_for_screenshot(\n        self,\n        screenshot_id: int,\n        app_name: str,\n        window_title: str,\n        timestamp: datetime,\n    ) -> int | None:\n        \"\"\"为截图创建新事件\"\"\"\n        try:\n            with self.db_base.get_session() as session:\n                new_event = Event(\n                    app_name=app_name,\n                    window_title=window_title,\n                    start_time=timestamp,\n                    status=\"new\",\n                )\n                session.add(new_event)\n                session.flush()\n\n                screenshot = (\n                    session.query(Screenshot).filter(col(Screenshot.id) == screenshot_id).first()\n                )\n                if screenshot:\n                    screenshot.event_id = new_event.id\n                    session.flush()\n\n                logger.info(f\"✨ 创建新事件 {new_event.id}: {app_name} (status=new)\")\n                return new_event.id\n        except SQLAlchemyError as e:\n            logger.error(f\"创建事件失败: {e}\")\n            return None\n\n    def add_screenshot_to_event(self, screenshot_id: int, event_id: int) -> bool:\n        \"\"\"将截图添加到指定事件\"\"\"\n        try:\n            with self.db_base.get_session() as session:\n                screenshot = (\n                    session.query(Screenshot).filter(col(Screenshot.id) == screenshot_id).first()\n                )\n                if not screenshot:\n                    logger.warning(f\"截图 {screenshot_id} 不存在\")\n                    return False\n\n                event = session.query(Event).filter(col(Event.id) == event_id).first()\n                if not event:\n                    logger.warning(f\"事件 {event_id} 不存在\")\n                    return False\n\n                screenshot.event_id = event_id\n\n                if event.status == \"new\":\n                    event.status = \"processing\"\n\n                session.flush()\n                logger.debug(\n                    f\"截图 {screenshot_id} 已添加到事件 {event_id}，事件状态: {event.status}\"\n                )\n                return True\n        except SQLAlchemyError as e:\n            logger.error(f\"添加截图到事件失败: {e}\")\n            return False\n\n    def complete_event(self, event_id: int, end_time: datetime) -> bool:\n        \"\"\"完成事件\"\"\"\n        try:\n            with self.db_base.get_session() as session:\n                event = session.query(Event).filter(col(Event.id) == event_id).first()\n                if not event:\n                    logger.warning(f\"事件 {event_id} 不存在\")\n                    return False\n\n                event.status = \"done\"\n                event.end_time = end_time\n                session.flush()\n\n                logger.info(f\"🔚 完成事件 {event_id}: {event.app_name} (status=done)\")\n\n            try:\n                logger.info(f\"📝 触发已完成事件 {event_id} 的摘要生成\")\n                summary_module = importlib.import_module(\"lifetrace.llm.event_summary_service\")\n                summary_module.generate_event_summary_async(event_id)\n            except Exception as e:\n                logger.error(f\"触发事件摘要生成失败: {e}\")\n\n            return True\n        except SQLAlchemyError as e:\n            logger.error(f\"完成事件失败: {e}\")\n            return False\n\n    # 委托给 event_queries 模块的方法\n    def list_events(\n        self,\n        limit: int = 50,\n        offset: int = 0,\n        start_date: datetime | None = None,\n        end_date: datetime | None = None,\n        app_name: str | None = None,\n    ) -> list[dict[str, Any]]:\n        \"\"\"列出事件摘要\"\"\"\n        return list_events(self.db_base, limit, offset, start_date, end_date, app_name)\n\n    def count_events(\n        self,\n        start_date: datetime | None = None,\n        end_date: datetime | None = None,\n        app_name: str | None = None,\n    ) -> int:\n        \"\"\"统计事件总数\"\"\"\n        return count_events(self.db_base, start_date, end_date, app_name)\n\n    def get_event_screenshots(self, event_id: int) -> list[dict[str, Any]]:\n        \"\"\"获取事件内截图列表\"\"\"\n        return get_event_screenshots(self.db_base, event_id)\n\n    def search_events_simple(\n        self,\n        query: str | None,\n        start_date: datetime | None = None,\n        end_date: datetime | None = None,\n        app_name: str | None = None,\n        limit: int = 50,\n    ) -> list[dict[str, Any]]:\n        \"\"\"搜索事件\"\"\"\n        return search_events_simple(self.db_base, query, start_date, end_date, app_name, limit)\n\n    def get_event_summary(self, event_id: int) -> dict[str, Any] | None:\n        \"\"\"获取单个事件的摘要信息\"\"\"\n        return get_event_summary(self.db_base, event_id)\n\n    def get_events_by_ids(self, event_ids: list[int]) -> list[dict[str, Any]]:\n        \"\"\"批量获取事件的摘要信息\"\"\"\n        return get_events_by_ids(self.db_base, event_ids)\n\n    def get_event_id_by_screenshot(self, screenshot_id: int) -> int | None:\n        \"\"\"根据截图ID获取所属事件ID\"\"\"\n        return get_event_id_by_screenshot(self.db_base, screenshot_id)\n\n    def get_event_text(self, event_id: int) -> str:\n        \"\"\"聚合事件下所有截图的OCR文本内容\"\"\"\n        return get_event_text(self.db_base, event_id)\n\n    # 委托给 event_stats 模块的方法\n    def get_app_usage_stats(\n        self,\n        days: int | None = None,\n        start_date: datetime | None = None,\n        end_date: datetime | None = None,\n    ) -> dict[str, Any]:\n        \"\"\"获取应用使用统计\"\"\"\n        return get_app_usage_stats(self.db_base, days, start_date, end_date)\n"
  },
  {
    "path": "lifetrace/storage/event_queries.py",
    "content": "\"\"\"\n事件查询模块\n包含事件查询和搜索相关方法\n\"\"\"\n\nfrom datetime import datetime\nfrom typing import Any\n\nfrom sqlalchemy import text\nfrom sqlalchemy.exc import SQLAlchemyError\n\nfrom lifetrace.storage.database_base import DatabaseBase\nfrom lifetrace.storage.models import Event, OCRResult, Screenshot\nfrom lifetrace.storage.sql_utils import col\nfrom lifetrace.util.logging_config import get_logger\n\nlogger = get_logger()\n\n\ndef list_events(\n    db_base: DatabaseBase,\n    limit: int = 50,\n    offset: int = 0,\n    start_date: datetime | None = None,\n    end_date: datetime | None = None,\n    app_name: str | None = None,\n) -> list[dict[str, Any]]:\n    \"\"\"列出事件摘要（包含首张截图ID与截图数量）\"\"\"\n    try:\n        with db_base.get_session() as session:\n            q = session.query(Event)\n            if start_date:\n                q = q.filter(col(Event.start_time) >= start_date)\n            if end_date:\n                q = q.filter(col(Event.start_time) <= end_date)\n            if app_name:\n                q = q.filter(col(Event.app_name).like(f\"%{app_name}%\"))\n\n            q = q.order_by(col(Event.start_time).desc()).offset(offset).limit(limit)\n            events = q.all()\n\n            results: list[dict[str, Any]] = []\n            for ev in events:\n                first_shot = (\n                    session.query(Screenshot)\n                    .filter(col(Screenshot.event_id) == ev.id)\n                    .order_by(col(Screenshot.created_at).asc())\n                    .first()\n                )\n                shot_count = (\n                    session.query(Screenshot).filter(col(Screenshot.event_id) == ev.id).count()\n                )\n                results.append(\n                    {\n                        \"id\": ev.id,\n                        \"app_name\": ev.app_name,\n                        \"window_title\": ev.window_title,\n                        \"start_time\": ev.start_time,\n                        \"end_time\": ev.end_time,\n                        \"screenshot_count\": shot_count,\n                        \"first_screenshot_id\": (first_shot.id if first_shot else None),\n                        \"ai_title\": ev.ai_title,\n                        \"ai_summary\": ev.ai_summary,\n                    }\n                )\n            return results\n    except SQLAlchemyError as e:\n        logger.error(f\"列出事件失败: {e}\")\n        return []\n\n\ndef count_events(\n    db_base: DatabaseBase,\n    start_date: datetime | None = None,\n    end_date: datetime | None = None,\n    app_name: str | None = None,\n) -> int:\n    \"\"\"统计事件总数\"\"\"\n    try:\n        with db_base.get_session() as session:\n            q = session.query(Event)\n            if start_date:\n                q = q.filter(col(Event.start_time) >= start_date)\n            if end_date:\n                q = q.filter(col(Event.start_time) <= end_date)\n            if app_name:\n                q = q.filter(col(Event.app_name).like(f\"%{app_name}%\"))\n            return q.count()\n    except SQLAlchemyError as e:\n        logger.error(f\"统计事件总数失败: {e}\")\n        return 0\n\n\ndef get_event_screenshots(db_base: DatabaseBase, event_id: int) -> list[dict[str, Any]]:\n    \"\"\"获取事件内截图列表\"\"\"\n    try:\n        with db_base.get_session() as session:\n            shots = (\n                session.query(Screenshot)\n                .filter(col(Screenshot.event_id) == event_id)\n                .order_by(col(Screenshot.created_at).asc())\n                .all()\n            )\n            return [\n                {\n                    \"id\": s.id,\n                    \"file_path\": s.file_path,\n                    \"app_name\": s.app_name,\n                    \"window_title\": s.window_title,\n                    \"created_at\": s.created_at,\n                    \"width\": s.width,\n                    \"height\": s.height,\n                }\n                for s in shots\n            ]\n    except SQLAlchemyError as e:\n        logger.error(f\"获取事件截图失败: {e}\")\n        return []\n\n\ndef search_events_simple(\n    db_base: DatabaseBase,\n    query: str | None,\n    start_date: datetime | None = None,\n    end_date: datetime | None = None,\n    app_name: str | None = None,\n    limit: int = 50,\n) -> list[dict[str, Any]]:\n    \"\"\"基于SQLite的简单事件搜索\"\"\"\n    try:\n        with db_base.get_session() as session:\n            base_sql = \"\"\"\n                SELECT e.id AS event_id,\n                       e.app_name AS app_name,\n                       e.window_title AS window_title,\n                       e.start_time AS start_time,\n                       e.end_time AS end_time,\n                       e.ai_title AS ai_title,\n                       e.ai_summary AS ai_summary,\n                       MIN(s.id) AS first_screenshot_id,\n                       COUNT(s.id) AS screenshot_count\n                FROM events e\n                JOIN screenshots s ON s.event_id = e.id\n                LEFT JOIN ocr_results o ON o.screenshot_id = s.id\n            \"\"\"\n            where_clause = []\n            params: dict[str, Any] = {}\n\n            if query and query.strip():\n                where_clause.append(\n                    \"(e.window_title LIKE :q OR e.ai_title LIKE :q OR e.ai_summary LIKE :q OR o.text_content LIKE :q)\"\n                )\n                params[\"q\"] = f\"%{query}%\"\n\n            if start_date:\n                where_clause.append(\"e.start_time >= :start_date\")\n                params[\"start_date\"] = start_date\n\n            if end_date:\n                where_clause.append(\"e.start_time <= :end_date\")\n                params[\"end_date\"] = end_date\n\n            if app_name:\n                where_clause.append(\"e.app_name LIKE :app_name\")\n                params[\"app_name\"] = f\"%{app_name}%\"\n\n            sql = base_sql\n            if where_clause:\n                sql += \" WHERE \" + \" AND \".join(where_clause)\n            sql += \" GROUP BY e.id ORDER BY e.start_time DESC LIMIT :limit\"\n            params[\"limit\"] = limit\n\n            logger.info(f\"执行搜索SQL: {sql}\")\n            logger.info(f\"参数: {params}\")\n            rows = session.execute(text(sql), params).fetchall()\n            results = []\n            for r in rows:\n                results.append(\n                    {\n                        \"id\": r.event_id,\n                        \"app_name\": r.app_name,\n                        \"window_title\": r.window_title,\n                        \"start_time\": r.start_time,\n                        \"end_time\": r.end_time,\n                        \"ai_title\": r.ai_title,\n                        \"ai_summary\": r.ai_summary,\n                        \"first_screenshot_id\": r.first_screenshot_id,\n                        \"screenshot_count\": r.screenshot_count,\n                    }\n                )\n            return results\n    except SQLAlchemyError as e:\n        logger.error(f\"搜索事件失败: {e}\")\n        return []\n\n\ndef get_event_summary(db_base: DatabaseBase, event_id: int) -> dict[str, Any] | None:\n    \"\"\"获取单个事件的摘要信息\"\"\"\n    try:\n        with db_base.get_session() as session:\n            ev = session.query(Event).filter(col(Event.id) == event_id).first()\n            if not ev:\n                return None\n            first_shot = (\n                session.query(Screenshot)\n                .filter(col(Screenshot.event_id) == ev.id)\n                .order_by(col(Screenshot.created_at).asc())\n                .first()\n            )\n            shot_count = session.query(Screenshot).filter(col(Screenshot.event_id) == ev.id).count()\n            return {\n                \"id\": ev.id,\n                \"app_name\": ev.app_name,\n                \"window_title\": ev.window_title,\n                \"start_time\": ev.start_time,\n                \"end_time\": ev.end_time,\n                \"screenshot_count\": shot_count,\n                \"first_screenshot_id\": first_shot.id if first_shot else None,\n                \"ai_title\": ev.ai_title,\n                \"ai_summary\": ev.ai_summary,\n            }\n    except SQLAlchemyError as e:\n        logger.error(f\"获取事件摘要失败: {e}\")\n        return None\n\n\ndef get_events_by_ids(db_base: DatabaseBase, event_ids: list[int]) -> list[dict[str, Any]]:\n    \"\"\"批量获取事件的摘要信息\"\"\"\n    if not event_ids:\n        return []\n\n    try:\n        with db_base.get_session() as session:\n            events = session.query(Event).filter(col(Event.id).in_(event_ids)).all()\n            if not events:\n                return []\n\n            event_map = {ev.id: ev for ev in events}\n\n            results = []\n            for event_id in event_ids:\n                ev = event_map.get(event_id)\n                if not ev:\n                    continue\n\n                first_shot = (\n                    session.query(Screenshot)\n                    .filter(col(Screenshot.event_id) == ev.id)\n                    .order_by(col(Screenshot.created_at).asc())\n                    .first()\n                )\n                shot_count = (\n                    session.query(Screenshot).filter(col(Screenshot.event_id) == ev.id).count()\n                )\n\n                results.append(\n                    {\n                        \"id\": ev.id,\n                        \"app_name\": ev.app_name,\n                        \"window_title\": ev.window_title,\n                        \"start_time\": ev.start_time,\n                        \"end_time\": ev.end_time,\n                        \"screenshot_count\": shot_count,\n                        \"first_screenshot_id\": first_shot.id if first_shot else None,\n                        \"ai_title\": ev.ai_title,\n                        \"ai_summary\": ev.ai_summary,\n                    }\n                )\n\n            return results\n    except SQLAlchemyError as e:\n        logger.error(f\"批量获取事件摘要失败: {e}\")\n        return []\n\n\ndef get_event_id_by_screenshot(db_base: DatabaseBase, screenshot_id: int) -> int | None:\n    \"\"\"根据截图ID获取所属事件ID\"\"\"\n    try:\n        with db_base.get_session() as session:\n            s = session.query(Screenshot).filter(col(Screenshot.id) == screenshot_id).first()\n            return int(s.event_id) if s and s.event_id is not None else None\n    except SQLAlchemyError as e:\n        logger.error(f\"查询截图所属事件失败: {e}\")\n        return None\n\n\ndef get_event_text(db_base: DatabaseBase, event_id: int) -> str:\n    \"\"\"聚合事件下所有截图的OCR文本内容\"\"\"\n    try:\n        with db_base.get_session() as session:\n            ocr_list = (\n                session.query(OCRResult)\n                .join(Screenshot, col(OCRResult.screenshot_id) == col(Screenshot.id))\n                .filter(col(Screenshot.event_id) == event_id)\n                .order_by(col(OCRResult.created_at).asc())\n                .all()\n            )\n            texts = [o.text_content for o in ocr_list if o and o.text_content]\n            return \"\\n\".join(texts)\n    except SQLAlchemyError as e:\n        logger.error(f\"聚合事件文本失败: {e}\")\n        return \"\"\n"
  },
  {
    "path": "lifetrace/storage/event_stats.py",
    "content": "\"\"\"\n事件统计模块\n包含应用使用统计相关方法\n\"\"\"\n\nfrom datetime import datetime, timedelta\nfrom typing import Any\n\nfrom sqlalchemy.exc import SQLAlchemyError\n\nfrom lifetrace.storage.database_base import DatabaseBase\nfrom lifetrace.storage.models import Event\nfrom lifetrace.storage.sql_utils import col\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.time_utils import get_utc_now\n\nlogger = get_logger()\n\n\ndef get_app_usage_stats(\n    db_base: DatabaseBase,\n    days: int | None = None,\n    start_date: datetime | None = None,\n    end_date: datetime | None = None,\n) -> dict[str, Any]:\n    \"\"\"基于 Event 表获取应用使用统计数据\n\n    相比 AppUsageLog 表，使用 Event 表统计有以下优势：\n    1. 更准确：使用真实的 start_time 和 end_time 计算持续时间\n    2. 数据量更小：不需要每次截图都记录\n    3. 逻辑更简单：减少冗余表和存储逻辑\n\n    Args:\n        db_base: 数据库基类实例\n        days: 统计最近多少天（默认7天）\n        start_date: 开始日期\n        end_date: 结束日期\n\n    Returns:\n        包含应用使用统计的字典\n    \"\"\"\n    try:\n        with db_base.get_session() as session:\n            # 计算时间范围\n            if start_date and end_date:\n                dt_start = start_date\n                dt_end = end_date + timedelta(days=1) - timedelta(seconds=1)\n            else:\n                dt_end = get_utc_now()\n                use_days = days if days else 7\n                dt_start = dt_end - timedelta(days=use_days)\n\n            # 查询已结束的事件\n            events = (\n                session.query(Event)\n                .filter(\n                    col(Event.start_time) >= dt_start,\n                    col(Event.start_time) <= dt_end,\n                    col(Event.end_time).isnot(None),\n                )\n                .all()\n            )\n\n            # 聚合统计数据\n            app_usage_summary = {}\n            daily_usage = {}\n            hourly_usage = {}\n\n            for event in events:\n                app_name = event.app_name\n                if not app_name:\n                    continue\n\n                duration = (event.end_time - event.start_time).total_seconds()\n                date_str = event.start_time.strftime(\"%Y-%m-%d\")\n                hour = event.start_time.hour\n\n                # 应用使用汇总\n                if app_name not in app_usage_summary:\n                    app_usage_summary[app_name] = {\n                        \"app_name\": app_name,\n                        \"total_time\": 0,\n                        \"session_count\": 0,\n                        \"last_used\": event.end_time,\n                    }\n\n                app_usage_summary[app_name][\"total_time\"] += duration\n                app_usage_summary[app_name][\"session_count\"] += 1\n                app_usage_summary[app_name][\"last_used\"] = max(\n                    app_usage_summary[app_name][\"last_used\"], event.end_time\n                )\n\n                # 每日使用统计\n                if date_str not in daily_usage:\n                    daily_usage[date_str] = {}\n                if app_name not in daily_usage[date_str]:\n                    daily_usage[date_str][app_name] = 0\n                daily_usage[date_str][app_name] += duration\n\n                # 小时使用统计\n                if hour not in hourly_usage:\n                    hourly_usage[hour] = {}\n                if app_name not in hourly_usage[hour]:\n                    hourly_usage[hour][app_name] = 0\n                hourly_usage[hour][app_name] += duration\n\n            return {\n                \"app_usage_summary\": app_usage_summary,\n                \"daily_usage\": daily_usage,\n                \"hourly_usage\": hourly_usage,\n                \"total_apps\": len(app_usage_summary),\n                \"total_time\": sum(app[\"total_time\"] for app in app_usage_summary.values()),\n            }\n\n    except SQLAlchemyError as e:\n        logger.error(f\"从Event表获取应用使用统计失败: {e}\")\n        return {\n            \"app_usage_summary\": {},\n            \"daily_usage\": {},\n            \"hourly_usage\": {},\n            \"total_apps\": 0,\n            \"total_time\": 0,\n        }\n"
  },
  {
    "path": "lifetrace/storage/journal_manager.py",
    "content": "\"\"\"日记管理器 - 负责日记及标签关联的数据库操作\"\"\"\n\nfrom collections.abc import Iterable\nfrom dataclasses import dataclass\nfrom datetime import datetime\nfrom typing import Any\n\nfrom sqlalchemy.exc import SQLAlchemyError\n\nfrom lifetrace.storage.database_base import DatabaseBase\nfrom lifetrace.storage.models import (\n    Journal,\n    JournalActivityRelation,\n    JournalTagRelation,\n    JournalTodoRelation,\n    Tag,\n)\nfrom lifetrace.storage.sql_utils import col\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.time_utils import get_utc_now\n\nlogger = get_logger()\n\n_UNSET = object()\n\n\n@dataclass(frozen=True)\nclass JournalCreatePayload:\n    \"\"\"创建日记的聚合参数\"\"\"\n\n    name: str\n    user_notes: str\n    date: datetime\n    uid: str | None = None\n    content_format: str = \"markdown\"\n    content_objective: str | None = None\n    content_ai: str | None = None\n    mood: str | None = None\n    energy: int | None = None\n    day_bucket_start: datetime | None = None\n    tags: list[str] | None = None\n    related_todo_ids: list[int] | None = None\n    related_activity_ids: list[int] | None = None\n\n\n@dataclass(frozen=True)\nclass JournalUpdatePayload:\n    \"\"\"更新日记的聚合参数\"\"\"\n\n    name: str | Any = _UNSET\n    user_notes: str | Any = _UNSET\n    date: datetime | Any = _UNSET\n    content_format: str | Any = _UNSET\n    content_objective: str | None | Any = _UNSET\n    content_ai: str | None | Any = _UNSET\n    mood: str | None | Any = _UNSET\n    energy: int | None | Any = _UNSET\n    day_bucket_start: datetime | None | Any = _UNSET\n    tags: list[str] | None | Any = _UNSET\n    related_todo_ids: list[int] | None | Any = _UNSET\n    related_activity_ids: list[int] | None | Any = _UNSET\n\n\nclass JournalManager:\n    \"\"\"日记管理类\"\"\"\n\n    def __init__(self, db_base: DatabaseBase):\n        self.db_base = db_base\n\n    # ===== 工具方法 =====\n    def _serialize_journal(\n        self,\n        journal: Journal,\n        tags: Iterable[Tag] | None = None,\n        related_todo_ids: list[int] | None = None,\n        related_activity_ids: list[int] | None = None,\n    ) -> dict[str, Any]:\n        tag_list = [{\"id\": t.id, \"tag_name\": t.tag_name} for t in tags] if tags else []\n        return {\n            \"id\": journal.id,\n            \"uid\": journal.uid,\n            \"name\": journal.name,\n            \"user_notes\": journal.user_notes,\n            \"date\": journal.date,\n            \"content_format\": journal.content_format or \"markdown\",\n            \"content_objective\": journal.content_objective,\n            \"content_ai\": journal.content_ai,\n            \"mood\": journal.mood,\n            \"energy\": journal.energy,\n            \"day_bucket_start\": journal.day_bucket_start,\n            \"created_at\": journal.created_at,\n            \"updated_at\": journal.updated_at,\n            \"deleted_at\": journal.deleted_at,\n            \"tags\": tag_list,\n            \"related_todo_ids\": related_todo_ids or [],\n            \"related_activity_ids\": related_activity_ids or [],\n        }\n\n    def _get_tags_for_journal(self, session, journal_id: int) -> list[Tag]:\n        \"\"\"获取日记关联的标签\"\"\"\n        return (\n            session.query(Tag)\n            .join(JournalTagRelation, col(JournalTagRelation.tag_id) == col(Tag.id))\n            .filter(col(JournalTagRelation.journal_id) == journal_id)\n            .filter(col(Tag.deleted_at).is_(None))\n            .all()\n        )\n\n    def _get_related_todo_ids(self, session, journal_id: int) -> list[int]:\n        return [\n            rel.todo_id\n            for rel in session.query(JournalTodoRelation)\n            .filter(col(JournalTodoRelation.journal_id) == journal_id)\n            .filter(col(JournalTodoRelation.deleted_at).is_(None))\n            .all()\n        ]\n\n    def _get_related_activity_ids(self, session, journal_id: int) -> list[int]:\n        return [\n            rel.activity_id\n            for rel in session.query(JournalActivityRelation)\n            .filter(col(JournalActivityRelation.journal_id) == journal_id)\n            .filter(col(JournalActivityRelation.deleted_at).is_(None))\n            .all()\n        ]\n\n    def _replace_tags(self, session, journal_id: int, tags: list[str] | None) -> None:\n        \"\"\"替换日记标签关联\"\"\"\n        session.query(JournalTagRelation).filter_by(journal_id=journal_id).delete(\n            synchronize_session=False\n        )\n\n        if not tags:\n            return\n\n        cleaned: list[str] = []\n        seen: set[str] = set()\n        for tag_name in tags:\n            name = (tag_name or \"\").strip()\n            if not name or name in seen:\n                continue\n            seen.add(name)\n            cleaned.append(name)\n\n        for tag_name in cleaned:\n            tag = session.query(Tag).filter_by(tag_name=tag_name).first()\n            if not tag:\n                tag = Tag(tag_name=tag_name)\n                session.add(tag)\n                session.flush()\n            if tag.id is None:\n                raise ValueError(\"Tag must have an id before creating relation.\")\n            session.add(JournalTagRelation(journal_id=journal_id, tag_id=tag.id))\n\n    def _replace_related_todos(self, session, journal_id: int, todo_ids: list[int] | None) -> None:\n        session.query(JournalTodoRelation).filter_by(journal_id=journal_id).delete(\n            synchronize_session=False\n        )\n\n        if not todo_ids:\n            return\n\n        for todo_id in dict.fromkeys(todo_ids):\n            session.add(JournalTodoRelation(journal_id=journal_id, todo_id=todo_id))\n\n    def _replace_related_activities(\n        self, session, journal_id: int, activity_ids: list[int] | None\n    ) -> None:\n        session.query(JournalActivityRelation).filter_by(journal_id=journal_id).delete(\n            synchronize_session=False\n        )\n\n        if not activity_ids:\n            return\n\n        for activity_id in dict.fromkeys(activity_ids):\n            session.add(JournalActivityRelation(journal_id=journal_id, activity_id=activity_id))\n\n    def _apply_journal_updates(self, journal: Journal, payload: JournalUpdatePayload) -> None:\n        if payload.content_format is not _UNSET:\n            journal.content_format = payload.content_format or \"markdown\"\n\n        updates = {\n            \"name\": payload.name,\n            \"user_notes\": payload.user_notes,\n            \"date\": payload.date,\n            \"content_objective\": payload.content_objective,\n            \"content_ai\": payload.content_ai,\n            \"mood\": payload.mood,\n            \"energy\": payload.energy,\n            \"day_bucket_start\": payload.day_bucket_start,\n        }\n\n        for attr, value in updates.items():\n            if value is not _UNSET:\n                setattr(journal, attr, value)\n\n    # ===== CRUD 接口 =====\n    def create_journal(self, payload: JournalCreatePayload) -> int | None:\n        \"\"\"创建日记\"\"\"\n        try:\n            with self.db_base.get_session() as session:\n                journal_data = {\n                    \"name\": payload.name,\n                    \"user_notes\": payload.user_notes,\n                    \"date\": payload.date,\n                    \"content_format\": payload.content_format or \"markdown\",\n                    \"content_objective\": payload.content_objective,\n                    \"content_ai\": payload.content_ai,\n                    \"mood\": payload.mood,\n                    \"energy\": payload.energy,\n                    \"day_bucket_start\": payload.day_bucket_start,\n                }\n                if payload.uid:\n                    journal_data[\"uid\"] = payload.uid\n                journal = Journal(**journal_data)\n                session.add(journal)\n                session.flush()\n                if journal.id is None:\n                    raise ValueError(\"Journal must have an id before linking relations.\")\n\n                # 处理标签与关联\n                self._replace_tags(session, journal.id, payload.tags)\n                self._replace_related_todos(session, journal.id, payload.related_todo_ids)\n                self._replace_related_activities(session, journal.id, payload.related_activity_ids)\n\n                logger.info(f\"创建日记成功: {journal.id} - {payload.name}\")\n                return journal.id\n        except SQLAlchemyError as e:\n            logger.error(f\"创建日记失败: {e}\")\n            return None\n\n    def get_journal(self, journal_id: int) -> dict[str, Any] | None:\n        \"\"\"获取单个日记\"\"\"\n        try:\n            with self.db_base.get_session() as session:\n                journal = (\n                    session.query(Journal)\n                    .filter(col(Journal.id) == journal_id)\n                    .filter(col(Journal.deleted_at).is_(None))\n                    .first()\n                )\n                if not journal:\n                    return None\n\n                tags = self._get_tags_for_journal(session, journal.id)\n                related_todo_ids = self._get_related_todo_ids(session, journal.id)\n                related_activity_ids = self._get_related_activity_ids(session, journal.id)\n                return self._serialize_journal(\n                    journal,\n                    tags,\n                    related_todo_ids=related_todo_ids,\n                    related_activity_ids=related_activity_ids,\n                )\n        except SQLAlchemyError as e:\n            logger.error(f\"获取日记失败: {e}\")\n            return None\n\n    def list_journals(\n        self,\n        *,\n        limit: int = 100,\n        offset: int = 0,\n        start_date=None,\n        end_date=None,\n    ) -> list[dict[str, Any]]:\n        \"\"\"列出日记\"\"\"\n        try:\n            with self.db_base.get_session() as session:\n                query = session.query(Journal).filter(col(Journal.deleted_at).is_(None))\n\n                if start_date is not None:\n                    query = query.filter(col(Journal.date) >= start_date)\n                if end_date is not None:\n                    query = query.filter(col(Journal.date) <= end_date)\n\n                journals = (\n                    query.order_by(col(Journal.date).desc(), col(Journal.created_at).desc())\n                    .offset(offset)\n                    .limit(limit)\n                    .all()\n                )\n\n                results = []\n                for journal in journals:\n                    tags = self._get_tags_for_journal(session, journal.id)\n                    related_todo_ids = self._get_related_todo_ids(session, journal.id)\n                    related_activity_ids = self._get_related_activity_ids(session, journal.id)\n                    results.append(\n                        self._serialize_journal(\n                            journal,\n                            tags,\n                            related_todo_ids=related_todo_ids,\n                            related_activity_ids=related_activity_ids,\n                        )\n                    )\n                return results\n        except SQLAlchemyError as e:\n            logger.error(f\"列出日记失败: {e}\")\n            return []\n\n    def count_journals(self, start_date=None, end_date=None) -> int:\n        \"\"\"统计日记数量\"\"\"\n        try:\n            with self.db_base.get_session() as session:\n                query = session.query(Journal).filter(col(Journal.deleted_at).is_(None))\n                if start_date is not None:\n                    query = query.filter(col(Journal.date) >= start_date)\n                if end_date is not None:\n                    query = query.filter(col(Journal.date) <= end_date)\n                return query.count()\n        except SQLAlchemyError as e:\n            logger.error(f\"统计日记数量失败: {e}\")\n            return 0\n\n    def update_journal(self, journal_id: int, payload: JournalUpdatePayload) -> bool:\n        \"\"\"更新日记\"\"\"\n        try:\n            with self.db_base.get_session() as session:\n                journal = (\n                    session.query(Journal)\n                    .filter(col(Journal.id) == journal_id)\n                    .filter(col(Journal.deleted_at).is_(None))\n                    .first()\n                )\n                if not journal:\n                    logger.warning(f\"日记不存在: {journal_id}\")\n                    return False\n\n                self._apply_journal_updates(journal, payload)\n\n                if payload.tags is not _UNSET:\n                    self._replace_tags(session, journal_id, payload.tags)\n\n                if payload.related_todo_ids is not _UNSET:\n                    self._replace_related_todos(session, journal_id, payload.related_todo_ids)\n\n                if payload.related_activity_ids is not _UNSET:\n                    self._replace_related_activities(\n                        session, journal_id, payload.related_activity_ids\n                    )\n\n                journal.updated_at = get_utc_now()\n                session.flush()\n                logger.info(f\"更新日记: {journal_id}\")\n                return True\n        except SQLAlchemyError as e:\n            logger.error(f\"更新日记失败: {e}\")\n            return False\n\n    def delete_journal(self, journal_id: int) -> bool:\n        \"\"\"删除日记（物理删除）\"\"\"\n        try:\n            with self.db_base.get_session() as session:\n                journal = session.query(Journal).filter_by(id=journal_id).first()\n                if not journal:\n                    logger.warning(f\"日记不存在: {journal_id}\")\n                    return False\n\n                # 删除标签关联\n                session.query(JournalTagRelation).filter_by(journal_id=journal_id).delete(\n                    synchronize_session=False\n                )\n                session.query(JournalTodoRelation).filter_by(journal_id=journal_id).delete(\n                    synchronize_session=False\n                )\n                session.query(JournalActivityRelation).filter_by(journal_id=journal_id).delete(\n                    synchronize_session=False\n                )\n\n                session.delete(journal)\n                session.flush()\n                logger.info(f\"删除日记: {journal_id}\")\n                return True\n        except SQLAlchemyError as e:\n            logger.error(f\"删除日记失败: {e}\")\n            return False\n"
  },
  {
    "path": "lifetrace/storage/migrations/journal_migration.py",
    "content": "\"\"\"日记表迁移/初始化脚本\n\n运行此脚本可在现有数据库上创建 journals 与 journal_tag_relations 表，并补充相关索引。\n\"\"\"\n\nfrom typing import Any, cast\n\nfrom lifetrace.storage.database_base import DatabaseBase\nfrom lifetrace.storage.models import (\n    Journal,\n    JournalActivityRelation,\n    JournalTagRelation,\n    JournalTodoRelation,\n)\nfrom lifetrace.util.logging_config import get_logger\n\nlogger = get_logger()\n\n\ndef migrate():\n    \"\"\"创建缺失表并刷新性能索引\"\"\"\n    db_base = DatabaseBase()\n    if db_base.engine is None:\n        raise RuntimeError(\"Database engine is not initialized.\")\n\n    with db_base.engine.begin() as conn:\n        cast(\"Any\", Journal).__table__.create(bind=conn, checkfirst=True)\n        cast(\"Any\", JournalTagRelation).__table__.create(bind=conn, checkfirst=True)\n        cast(\"Any\", JournalTodoRelation).__table__.create(bind=conn, checkfirst=True)\n        cast(\"Any\", JournalActivityRelation).__table__.create(bind=conn, checkfirst=True)\n        logger.info(\"journals 相关表检查/创建完成\")\n\n    # 补充索引\n    db_base._create_performance_indexes()\n    logger.info(\"journals 相关索引检查/创建完成\")\n\n\nif __name__ == \"__main__\":\n    migrate()\n"
  },
  {
    "path": "lifetrace/storage/models.py",
    "content": "\"\"\"SQLModel 数据模型定义\n\n使用 SQLModel 重写所有数据模型，保持与现有数据库表结构兼容。\n\"\"\"\n\n# pyright: reportIncompatibleVariableOverride=false\n\nfrom datetime import datetime\nfrom typing import ClassVar\nfrom uuid import uuid4\n\nfrom sqlmodel import Column, Field, SQLModel, Text\n\nfrom lifetrace.util.time_utils import get_utc_now\n\n\ndef get_utc_time():\n    \"\"\"获取 UTC 时间（timezone-aware）\"\"\"\n    return get_utc_now()\n\n\n# ========== 混入类 ==========\n\n\nclass TimestampMixin(SQLModel):\n    \"\"\"时间戳混入类\"\"\"\n\n    created_at: datetime = Field(default_factory=get_utc_time)\n    updated_at: datetime = Field(default_factory=get_utc_time)\n    deleted_at: datetime | None = None\n\n\n# ========== 核心业务模型 ==========\n\n\nclass Screenshot(TimestampMixin, table=True):\n    \"\"\"截图记录模型\"\"\"\n\n    __tablename__: ClassVar[str] = \"screenshots\"\n\n    id: int | None = Field(default=None, primary_key=True)\n    file_path: str = Field(max_length=500, unique=True)  # 文件路径\n    file_hash: str = Field(max_length=64)  # 文件hash值\n    file_size: int  # 文件大小\n    file_deleted: bool = False  # 文件是否已被清理\n    width: int  # 截图宽度\n    height: int  # 截图高度\n    screen_id: int = 0  # 屏幕ID\n    app_name: str | None = Field(default=None, max_length=200)  # 前台应用名称\n    window_title: str | None = Field(default=None, max_length=500)  # 窗口标题\n    event_id: int | None = None  # 关联事件ID\n    is_processed: bool = False  # 是否在进行OCR处理\n    processed_at: datetime | None = None  # OCR处理完成时间\n\n    def __repr__(self):\n        return f\"<Screenshot(id={self.id}, file={self.file_path})>\"\n\n\nclass OCRResult(TimestampMixin, table=True):\n    \"\"\"OCR结果模型\"\"\"\n\n    __tablename__: ClassVar[str] = \"ocr_results\"\n\n    id: int | None = Field(default=None, primary_key=True)\n    screenshot_id: int  # 关联截图ID\n    text_content: str | None = Field(default=None, sa_column=Column(Text))  # 提取的文本内容\n    confidence: float | None = None  # 置信度[0, 1]\n    language: str | None = Field(default=None, max_length=10)  # 识别语言\n    processing_time: float | None = None  # OCR处理耗时（秒）\n    text_hash: str | None = Field(\n        default=None,\n        max_length=64,\n        index=True,\n    )  # 文本内容的哈希值，用于去重和缓存\n\n    def __repr__(self):\n        return f\"<OCRResult(id={self.id}, screenshot_id={self.screenshot_id})>\"\n\n\nclass Event(TimestampMixin, table=True):\n    \"\"\"事件模型（按前台应用连续使用区间聚合截图）\"\"\"\n\n    __tablename__: ClassVar[str] = \"events\"\n\n    id: int | None = Field(default=None, primary_key=True)\n    app_name: str | None = Field(default=None, max_length=200)  # 前台应用名称\n    window_title: str | None = Field(default=None, max_length=500)  # 首个或最近的窗口标题\n    start_time: datetime = Field(default_factory=get_utc_time)  # 事件开始时间\n    end_time: datetime | None = None  # 事件结束时间\n    status: str = Field(default=\"new\", max_length=20)  # 事件状态：new, processing, done\n    ai_title: str | None = Field(default=None, max_length=50)  # LLM生成的事件标题\n    ai_summary: str | None = Field(default=None, sa_column=Column(Text))  # LLM生成的事件摘要\n\n    def __repr__(self):\n        return f\"<Event(id={self.id}, app={self.app_name}, status={self.status})>\"\n\n\nclass Todo(TimestampMixin, table=True):\n    \"\"\"待办事项模型\"\"\"\n\n    __tablename__: ClassVar[str] = \"todos\"\n\n    id: int | None = Field(default=None, primary_key=True)\n    uid: str = Field(\n        default_factory=lambda: str(uuid4()), max_length=64, index=True\n    )  # iCalendar UID\n    name: str = Field(max_length=200)  # 待办名称\n    summary: str | None = Field(default=None, max_length=200)  # iCalendar SUMMARY\n    description: str | None = Field(default=None, sa_column=Column(Text))  # 描述\n    user_notes: str | None = Field(default=None, sa_column=Column(Text))  # 用户笔记\n    parent_todo_id: int | None = None  # 父级待办ID（自关联）\n    item_type: str = Field(default=\"VTODO\", max_length=10)  # iCalendar VTODO/VEVENT\n    location: str | None = Field(default=None, max_length=200)  # iCalendar LOCATION\n    categories: str | None = Field(default=None, sa_column=Column(Text))  # iCalendar CATEGORIES\n    classification: str | None = Field(default=None, max_length=20)  # iCalendar CLASS\n    deadline: datetime | None = None  # 截止时间（旧字段，逐步废弃）\n    start_time: datetime | None = None  # 开始时间\n    end_time: datetime | None = None  # 结束时间\n    dtstart: datetime | None = None  # iCalendar DTSTART\n    dtend: datetime | None = None  # iCalendar DTEND\n    due: datetime | None = None  # iCalendar DUE\n    duration: str | None = Field(default=None, max_length=64)  # iCalendar DURATION (ISO 8601)\n    time_zone: str | None = Field(default=None, max_length=64)  # 时区（IANA）\n    tzid: str | None = Field(default=None, max_length=64)  # iCalendar TZID\n    is_all_day: bool = Field(default=False)  # 是否全天\n    dtstamp: datetime | None = None  # iCalendar DTSTAMP\n    created: datetime | None = None  # iCalendar CREATED\n    last_modified: datetime | None = None  # iCalendar LAST-MODIFIED\n    sequence: int = Field(default=0)  # iCalendar SEQUENCE\n    rdate: str | None = Field(default=None, sa_column=Column(Text))  # iCalendar RDATE\n    exdate: str | None = Field(default=None, sa_column=Column(Text))  # iCalendar EXDATE\n    recurrence_id: datetime | None = None  # iCalendar RECURRENCE-ID\n    related_to_uid: str | None = Field(default=None, max_length=64)  # iCalendar RELATED-TO UID\n    related_to_reltype: str | None = Field(\n        default=None, max_length=20\n    )  # iCalendar RELATED-TO RELTYPE\n    ical_status: str | None = Field(default=None, max_length=20)  # iCalendar STATUS\n    reminder_offsets: str | None = Field(\n        default=None, sa_column=Column(Text)\n    )  # 提醒偏移列表（分钟）\n    status: str = Field(default=\"active\", max_length=20)  # active/completed/canceled\n    priority: str = Field(default=\"none\", max_length=20)  # high/medium/low/none\n    completed_at: datetime | None = None  # 完成时间（iCalendar COMPLETED）\n    percent_complete: int = Field(default=0, ge=0, le=100)  # 完成百分比（PERCENT-COMPLETE）\n    rrule: str | None = Field(default=None, max_length=500)  # iCalendar RRULE\n    order: int = 0  # 同级待办之间的展示排序\n    related_activities: str | None = Field(\n        default=None, sa_column=Column(Text)\n    )  # 关联活动ID的JSON数组\n\n    def __repr__(self):\n        return f\"<Todo(id={self.id}, name={self.name}, status={self.status})>\"\n\n\nclass AutomationTask(TimestampMixin, table=True):\n    \"\"\"用户自定义自动化任务\"\"\"\n\n    __tablename__: ClassVar[str] = \"automation_tasks\"\n\n    id: int | None = Field(default=None, primary_key=True)\n    name: str = Field(max_length=200)\n    description: str | None = Field(default=None, sa_column=Column(Text))\n    enabled: bool = Field(default=True)\n    schedule_type: str = Field(max_length=20)\n    schedule_config: str | None = Field(default=None, sa_column=Column(Text))\n    action_type: str = Field(max_length=50)\n    action_payload: str | None = Field(default=None, sa_column=Column(Text))\n    last_run_at: datetime | None = None\n    last_status: str | None = Field(default=None, max_length=20)\n    last_error: str | None = Field(default=None, sa_column=Column(Text))\n    last_output: str | None = Field(default=None, sa_column=Column(Text))\n\n    def __repr__(self):\n        return f\"<AutomationTask(id={self.id}, name={self.name}, enabled={self.enabled})>\"\n\n\nclass Attachment(TimestampMixin, table=True):\n    \"\"\"附件信息模型\"\"\"\n\n    __tablename__: ClassVar[str] = \"attachments\"\n\n    id: int | None = Field(default=None, primary_key=True)\n    file_path: str = Field(max_length=500)  # 本地持久化路径\n    file_name: str = Field(max_length=200)  # 文件名\n    file_size: int | None = None  # 文件大小（字节）\n    mime_type: str | None = Field(default=None, max_length=100)  # MIME类型\n    file_hash: str | None = Field(default=None, max_length=64)  # 去重hash\n\n    def __repr__(self):\n        return f\"<Attachment(id={self.id}, file_name={self.file_name})>\"\n\n\nclass TodoAttachmentRelation(SQLModel, table=True):\n    \"\"\"待办与附件的多对多关联关系\"\"\"\n\n    __tablename__: ClassVar[str] = \"todo_attachment_relations\"\n\n    id: int | None = Field(default=None, primary_key=True)\n    todo_id: int  # 关联的待办ID\n    attachment_id: int  # 关联的附件ID\n    source: str = Field(default=\"user\", max_length=20)  # user/ai\n    created_at: datetime = Field(default_factory=get_utc_time)\n    deleted_at: datetime | None = None\n\n    def __repr__(self):\n        return f\"<TodoAttachmentRelation(id={self.id}, todo_id={self.todo_id})>\"\n\n\nclass Tag(SQLModel, table=True):\n    \"\"\"标签模型\"\"\"\n\n    __tablename__: ClassVar[str] = \"tags\"\n\n    id: int | None = Field(default=None, primary_key=True)\n    tag_name: str = Field(max_length=50, unique=True)  # 标签名称\n    created_at: datetime = Field(default_factory=get_utc_time)\n    deleted_at: datetime | None = None\n\n    def __repr__(self):\n        return f\"<Tag(id={self.id}, name={self.tag_name})>\"\n\n\nclass TodoTagRelation(SQLModel, table=True):\n    \"\"\"待办与标签的多对多关联关系\"\"\"\n\n    __tablename__: ClassVar[str] = \"todo_tag_relations\"\n\n    id: int | None = Field(default=None, primary_key=True)\n    todo_id: int  # 关联的待办ID\n    tag_id: int  # 关联的标签ID\n    created_at: datetime = Field(default_factory=get_utc_time)\n    deleted_at: datetime | None = None\n\n    def __repr__(self):\n        return f\"<TodoTagRelation(id={self.id}, todo_id={self.todo_id}, tag_id={self.tag_id})>\"\n\n\nclass Journal(TimestampMixin, table=True):\n    \"\"\"日记模型\"\"\"\n\n    __tablename__: ClassVar[str] = \"journals\"\n\n    id: int | None = Field(default=None, primary_key=True)\n    uid: str = Field(\n        default_factory=lambda: str(uuid4()), max_length=64, index=True\n    )  # iCalendar UID\n    name: str = Field(max_length=200)  # 日记标题\n    user_notes: str = Field(sa_column=Column(Text))  # 富文本内容\n    date: datetime  # 日记日期\n    content_format: str = Field(default=\"markdown\", max_length=20)  # 内容格式\n    content_objective: str | None = Field(default=None, sa_column=Column(Text))  # 客观记录\n    content_ai: str | None = Field(default=None, sa_column=Column(Text))  # AI 视角\n    mood: str | None = Field(default=None, max_length=50)  # 情绪\n    energy: int | None = None  # 精力\n    day_bucket_start: datetime | None = None  # 日记归属的刷新点时间\n\n    def __repr__(self):\n        return f\"<Journal(id={self.id}, name={self.name}, date={self.date})>\"\n\n\nclass JournalTagRelation(SQLModel, table=True):\n    \"\"\"日记与标签的多对多关联关系\"\"\"\n\n    __tablename__: ClassVar[str] = \"journal_tag_relations\"\n\n    id: int | None = Field(default=None, primary_key=True)\n    journal_id: int  # 关联的日记ID\n    tag_id: int  # 关联的标签ID\n    created_at: datetime = Field(default_factory=get_utc_time)\n    deleted_at: datetime | None = None\n\n    def __repr__(self):\n        return f\"<JournalTagRelation(id={self.id}, journal_id={self.journal_id}, tag_id={self.tag_id})>\"\n\n\nclass JournalTodoRelation(SQLModel, table=True):\n    \"\"\"日记与待办的关联关系\"\"\"\n\n    __tablename__: ClassVar[str] = \"journal_todo_relations\"\n\n    id: int | None = Field(default=None, primary_key=True)\n    journal_id: int  # 关联的日记ID\n    todo_id: int  # 关联的待办ID\n    created_at: datetime = Field(default_factory=get_utc_time)\n    deleted_at: datetime | None = None\n\n    def __repr__(self):\n        return f\"<JournalTodoRelation(id={self.id}, journal_id={self.journal_id}, todo_id={self.todo_id})>\"\n\n\nclass JournalActivityRelation(SQLModel, table=True):\n    \"\"\"日记与活动的关联关系\"\"\"\n\n    __tablename__: ClassVar[str] = \"journal_activity_relations\"\n\n    id: int | None = Field(default=None, primary_key=True)\n    journal_id: int  # 关联的日记ID\n    activity_id: int  # 关联的活动ID\n    created_at: datetime = Field(default_factory=get_utc_time)\n    deleted_at: datetime | None = None\n\n    def __repr__(self):\n        return (\n            f\"<JournalActivityRelation(id={self.id}, journal_id={self.journal_id}, \"\n            f\"activity_id={self.activity_id})>\"\n        )\n\n\nclass Chat(TimestampMixin, table=True):\n    \"\"\"聊天会话模型\"\"\"\n\n    __tablename__: ClassVar[str] = \"chats\"\n\n    id: int | None = Field(default=None, primary_key=True)\n    session_id: str = Field(max_length=100, unique=True)  # 会话ID\n    chat_type: str | None = Field(default=None, max_length=50)  # 聊天类型\n    title: str | None = Field(default=None, max_length=200)  # 会话标题\n    context_id: int | None = None  # 关联的上下文ID\n    extra_data: str | None = Field(default=None, sa_column=Column(Text))  # 额外数据（JSON格式）\n    context: str | None = Field(default=None, sa_column=Column(Text))  # 会话上下文（JSON格式）\n    last_message_at: datetime | None = None  # 最后一条消息的时间\n\n    def __repr__(self):\n        return f\"<Chat(id={self.id}, session_id={self.session_id}, type={self.chat_type})>\"\n\n\nclass Message(TimestampMixin, table=True):\n    \"\"\"消息模型\"\"\"\n\n    __tablename__: ClassVar[str] = \"messages\"\n\n    id: int | None = Field(default=None, primary_key=True)\n    chat_id: int  # 关联的聊天会话ID\n    role: str = Field(max_length=20)  # 消息角色：user, assistant, system\n    content: str = Field(sa_column=Column(Text))  # 消息内容\n    token_count: int | None = None  # token数量\n    model: str | None = Field(default=None, max_length=100)  # 使用的模型名称\n    extra_data: str | None = Field(default=None, sa_column=Column(Text))  # 额外数据\n\n    def __repr__(self):\n        return f\"<Message(id={self.id}, chat_id={self.chat_id}, role={self.role})>\"\n\n\nclass TokenUsage(TimestampMixin, table=True):\n    \"\"\"Token使用量记录模型\"\"\"\n\n    __tablename__: ClassVar[str] = \"token_usage\"\n\n    id: int | None = Field(default=None, primary_key=True)\n    model: str = Field(max_length=100)  # 使用的模型名称\n    input_tokens: int  # 输入token数量\n    output_tokens: int  # 输出token数量\n    total_tokens: int  # 总token数量\n    endpoint: str | None = Field(default=None, max_length=200)  # API端点\n    response_type: str | None = Field(default=None, max_length=50)  # 响应类型\n    feature_type: str | None = Field(default=None, max_length=50)  # 功能类型\n    user_query_preview: str | None = Field(default=None, sa_column=Column(Text))  # 用户查询预览\n    query_length: int | None = None  # 查询长度\n    input_cost: float | None = None  # 输入成本（元）\n    output_cost: float | None = None  # 输出成本（元）\n    total_cost: float | None = None  # 总成本（元）\n\n    def __repr__(self):\n        return f\"<TokenUsage(id={self.id}, model={self.model}, total_tokens={self.total_tokens})>\"\n\n\nclass Activity(TimestampMixin, table=True):\n    \"\"\"活动模型（聚合15分钟内的事件）\"\"\"\n\n    __tablename__: ClassVar[str] = \"activities\"\n\n    id: int | None = Field(default=None, primary_key=True)\n    start_time: datetime  # 活动开始时间\n    end_time: datetime  # 活动结束时间\n    ai_title: str | None = Field(default=None, max_length=100)  # LLM生成的活动标题\n    ai_summary: str | None = Field(default=None, sa_column=Column(Text))  # LLM生成的活动摘要\n    event_count: int = 0  # 包含的事件数量\n\n    def __repr__(self):\n        return f\"<Activity(id={self.id}, start_time={self.start_time}, event_count={self.event_count})>\"\n\n\nclass ActivityEventRelation(SQLModel, table=True):\n    \"\"\"活动与事件的关联关系表\"\"\"\n\n    __tablename__: ClassVar[str] = \"activity_event_relations\"\n\n    id: int | None = Field(default=None, primary_key=True)\n    activity_id: int  # 关联的活动ID\n    event_id: int  # 关联的事件ID\n    created_at: datetime = Field(default_factory=get_utc_time)\n    deleted_at: datetime | None = None  # 软删除时间戳\n\n    def __repr__(self):\n        return f\"<ActivityEventRelation(id={self.id}, activity_id={self.activity_id})>\"\n\n\nclass AudioRecording(TimestampMixin, table=True):\n    \"\"\"音频录制记录模型\"\"\"\n\n    __tablename__: ClassVar[str] = \"audio_recordings\"\n\n    id: int | None = Field(default=None, primary_key=True)\n    file_path: str = Field(max_length=500)  # 音频文件路径\n    file_size: int  # 文件大小（字节）\n    duration: float  # 录音时长（秒）\n    start_time: datetime = Field(default_factory=get_utc_time)  # 开始时间\n    end_time: datetime | None = None  # 结束时间\n    status: str = Field(default=\"recording\", max_length=20)  # 状态：recording, completed, failed\n    is_24x7: bool = False  # 是否为7x24小时录制\n    is_transcribed: bool = False  # 是否已完成转录\n    is_extracted: bool = False  # 是否已完成待办/日程提取\n    is_summarized: bool = False  # 是否已完成摘要\n    is_full_audio: bool = False  # 是否为完整音频\n    is_segment_audio: bool = False  # 是否为分段音频（用于句子级回放/定位）\n    transcription_status: str = Field(\n        default=\"pending\", max_length=20\n    )  # 转录状态：pending, processing, completed, failed\n\n    def __repr__(self):\n        return f\"<AudioRecording(id={self.id}, duration={self.duration}s)>\"\n\n\nclass Transcription(TimestampMixin, table=True):\n    \"\"\"转录文本模型\"\"\"\n\n    __tablename__: ClassVar[str] = \"transcriptions\"\n\n    id: int | None = Field(default=None, primary_key=True)\n    audio_recording_id: int  # 关联音频录制ID\n    original_text: str | None = Field(default=None, sa_column=Column(Text))  # 原始转录文本\n    optimized_text: str | None = Field(default=None, sa_column=Column(Text))  # 优化后的文本\n    extraction_status: str = Field(\n        default=\"pending\", max_length=20\n    )  # 提取状态：pending, processing, completed, failed\n    extracted_todos: str | None = Field(\n        default=None, sa_column=Column(Text)\n    )  # 从原文提取的待办事项（JSON格式）\n    extracted_schedules: str | None = Field(\n        default=None, sa_column=Column(Text)\n    )  # 从原文提取的日程安排（JSON格式）\n    extracted_todos_optimized: str | None = Field(\n        default=None, sa_column=Column(Text)\n    )  # 从优化文本提取的待办事项（JSON格式）\n    extracted_schedules_optimized: str | None = Field(\n        default=None, sa_column=Column(Text)\n    )  # 从优化文本提取的日程安排（JSON格式）\n    segment_timestamps: str | None = Field(\n        default=None, sa_column=Column(Text)\n    )  # 每段文本的精确时间戳（JSON格式，单位：秒，相对于录音开始时间）\n\n    def __repr__(self):\n        return f\"<Transcription(id={self.id}, audio_recording_id={self.audio_recording_id})>\"\n\n\n# 为兼容旧代码，保留 Base 引用（指向 SQLModel.metadata）\n# 这样现有的 Base.metadata.create_all() 调用仍然有效\nBase = SQLModel\n"
  },
  {
    "path": "lifetrace/storage/notification_storage.py",
    "content": "\"\"\"通知存储模块 - 使用内存存储通知，支持去重\"\"\"\n\nfrom datetime import datetime\nfrom typing import Any\n\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.time_utils import naive_as_utc\n\nlogger = get_logger()\n\n# 内存存储：使用字典存储通知，key 为唯一标识符\n_notifications: dict[str, dict[str, Any]] = {}\n\n# 已取消通知跟踪：记录用户已取消的提醒（todo_id -> reminder_at set）\n_dismissed_notifications: dict[int, set[str]] = {}\n\n\ndef _parse_iso_datetime(value: str | None) -> datetime | None:\n    if not value:\n        return None\n    try:\n        parsed = datetime.fromisoformat(value)\n    except (TypeError, ValueError):\n        return None\n    return naive_as_utc(parsed)\n\n\ndef _build_reminder_key(reminder_at: datetime) -> str:\n    return naive_as_utc(reminder_at).isoformat()\n\n\ndef add_notification(  # noqa: PLR0913\n    notification_id: str,\n    title: str,\n    content: str,\n    timestamp: datetime,\n    todo_id: int | None = None,\n    schedule_time: datetime | None = None,\n    deadline: datetime | None = None,\n    reminder_at: datetime | None = None,\n    reminder_offset: int | None = None,\n) -> bool:\n    \"\"\"\n    添加通知到存储\n\n    Args:\n        notification_id: 通知唯一标识符（用于去重）\n        title: 通知标题\n        content: 通知内容\n        timestamp: 通知时间戳\n        todo_id: 关联的待办 ID（可选）\n        schedule_time: 待办时间点（可选，用于检测更新时间）\n        deadline: 待办截止时间（旧字段，兼容旧调用）\n        reminder_at: 提醒触发时间（可选，用于去重和取消）\n        reminder_offset: 提醒偏移分钟数（可选）\n\n    Returns:\n        bool: 如果通知已存在（去重），返回 False；否则返回 True\n    \"\"\"\n    if notification_id in _notifications:\n        logger.debug(f\"通知已存在，跳过: {notification_id}\")\n        return False\n\n    notification: dict[str, Any] = {\n        \"id\": notification_id,\n        \"title\": title,\n        \"content\": content,\n        \"timestamp\": timestamp.isoformat(),\n    }\n\n    if todo_id is not None:\n        notification[\"todo_id\"] = todo_id\n\n    effective_time = schedule_time or deadline\n    if effective_time is not None:\n        notification[\"schedule_time\"] = effective_time.isoformat()\n    if deadline is not None:\n        notification[\"deadline\"] = deadline.isoformat()\n\n    if reminder_at is not None:\n        notification[\"reminder_at\"] = reminder_at.isoformat()\n\n    if reminder_offset is not None:\n        notification[\"reminder_offset\"] = reminder_offset\n\n    _notifications[notification_id] = notification\n    logger.info(f\"添加通知: {notification_id} - {title}\")\n    return True\n\n\ndef get_latest_notification() -> dict[str, Any] | None:\n    \"\"\"\n    获取最新的通知\n\n    Returns:\n        最新通知的字典，如果没有通知则返回 None\n    \"\"\"\n    notifications = get_notifications()\n    return notifications[0] if notifications else None\n\n\ndef get_notifications() -> list[dict[str, Any]]:\n    \"\"\"获取所有通知（按时间倒序）\"\"\"\n    if not _notifications:\n        return []\n    return sorted(\n        _notifications.values(),\n        key=lambda x: x.get(\"timestamp\", \"\"),\n        reverse=True,\n    )\n\n\ndef get_notification(notification_id: str) -> dict[str, Any] | None:\n    \"\"\"\n    根据 ID 获取通知\n\n    Args:\n        notification_id: 通知 ID\n\n    Returns:\n        通知字典，如果不存在则返回 None\n    \"\"\"\n    return _notifications.get(notification_id)\n\n\ndef clear_notification(notification_id: str) -> bool:\n    \"\"\"\n    清除指定通知（并标记为已取消，防止重复提醒）\n\n    Args:\n        notification_id: 通知 ID\n\n    Returns:\n        如果通知存在并已清除，返回 True；否则返回 False\n    \"\"\"\n    if notification_id in _notifications:\n        notification = _notifications[notification_id]\n        todo_id = notification.get(\"todo_id\")\n        reminder_at = _parse_iso_datetime(\n            notification.get(\"reminder_at\")\n            or notification.get(\"schedule_time\")\n            or notification.get(\"deadline\")\n        )\n        if todo_id is not None and reminder_at is not None:\n            key = _build_reminder_key(reminder_at)\n            existing = _dismissed_notifications.get(todo_id)\n            if existing is None:\n                existing = set()\n                _dismissed_notifications[todo_id] = existing\n            existing.add(key)\n            logger.debug(\n                \"标记通知为已取消: todo_id=%s, reminder_at=%s\",\n                todo_id,\n                reminder_at.isoformat(),\n            )\n\n        del _notifications[notification_id]\n        logger.debug(f\"清除通知: {notification_id}\")\n        return True\n    return False\n\n\ndef clear_all_notifications() -> int:\n    \"\"\"\n    清除所有通知\n\n    Returns:\n        清除的通知数量\n    \"\"\"\n    count = len(_notifications)\n    _notifications.clear()\n    logger.info(f\"清除所有通知，共 {count} 条\")\n    return count\n\n\ndef get_notification_count() -> int:\n    \"\"\"\n    获取当前存储的通知数量\n\n    Returns:\n        通知数量\n    \"\"\"\n    return len(_notifications)\n\n\ndef get_notifications_by_todo_id(todo_id: int) -> list[dict[str, Any]]:\n    \"\"\"根据待办ID查找所有通知\"\"\"\n    return [n for n in _notifications.values() if n.get(\"todo_id\") == todo_id]\n\n\ndef get_notification_by_todo_id(todo_id: int) -> dict[str, Any] | None:\n    \"\"\"根据待办ID查找单条通知（兼容旧逻辑）\"\"\"\n    notifications = get_notifications_by_todo_id(todo_id)\n    return notifications[0] if notifications else None\n\n\ndef clear_notification_by_todo_id(todo_id: int) -> int:\n    \"\"\"根据待办ID清除所有通知\"\"\"\n    notifications = get_notifications_by_todo_id(todo_id)\n    removed = 0\n    for notification in notifications:\n        notification_id = notification.get(\"id\")\n        if notification_id and clear_notification(notification_id):\n            removed += 1\n    return removed\n\n\ndef is_notification_dismissed(todo_id: int, reminder_at: datetime) -> bool:\n    \"\"\"检查指定待办的提醒时间是否已被取消\"\"\"\n    dismissed = _dismissed_notifications.get(todo_id)\n    if not dismissed:\n        return False\n    return _build_reminder_key(reminder_at) in dismissed\n\n\ndef clear_dismissed_mark(todo_id: int) -> None:\n    \"\"\"\n    清除指定待办的已取消标记（用于时间更新时）\n\n    Args:\n        todo_id: 待办ID\n    \"\"\"\n    if todo_id in _dismissed_notifications:\n        del _dismissed_notifications[todo_id]\n        logger.debug(f\"清除已取消标记: todo_id={todo_id}\")\n"
  },
  {
    "path": "lifetrace/storage/ocr_manager.py",
    "content": "\"\"\"OCR管理器 - 负责OCR结果相关的数据库操作\"\"\"\n\nimport hashlib\nfrom typing import Any\n\nfrom sqlalchemy.exc import SQLAlchemyError\n\nfrom lifetrace.storage.database_base import DatabaseBase\nfrom lifetrace.storage.models import OCRResult, Screenshot\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.time_utils import get_utc_now\n\nlogger = get_logger()\n\n\ndef _normalize_text(text: str | None) -> str:\n    \"\"\"标准化 OCR 文本，用于稳定哈希计算。\"\"\"\n    if not text:\n        return \"\"\n    return \" \".join(text.strip().split())\n\n\nclass OCRManager:\n    \"\"\"OCR结果管理类\"\"\"\n\n    def __init__(self, db_base: DatabaseBase):\n        self.db_base = db_base\n\n    def add_ocr_result(\n        self,\n        screenshot_id: int,\n        text_content: str,\n        confidence: float = 0.0,\n        language: str = \"ch\",\n        processing_time: float = 0.0,\n    ) -> int | None:\n        \"\"\"添加OCR结果\"\"\"\n        try:\n            normalized = _normalize_text(text_content)\n            text_hash = (\n                hashlib.md5(normalized.encode(\"utf-8\"), usedforsecurity=False).hexdigest()\n                if normalized\n                else None\n            )\n\n            with self.db_base.get_session() as session:\n                ocr_result = OCRResult(\n                    screenshot_id=screenshot_id,\n                    text_content=text_content,\n                    confidence=confidence,\n                    language=language,\n                    processing_time=processing_time,\n                    text_hash=text_hash,\n                )\n\n                session.add(ocr_result)\n                session.flush()\n\n                # 更新截图处理状态\n                screenshot = session.query(Screenshot).filter_by(id=screenshot_id).first()\n                if screenshot:\n                    screenshot.is_processed = True\n                    screenshot.processed_at = get_utc_now()\n\n                logger.debug(f\"添加OCR结果: {ocr_result.id}, text_hash={text_hash}\")\n                return ocr_result.id\n\n        except SQLAlchemyError as e:\n            logger.error(f\"添加OCR结果失败: {e}\")\n            return None\n\n    def get_ocr_results_by_screenshot(self, screenshot_id: int) -> list[dict[str, Any]]:\n        \"\"\"根据截图ID获取OCR结果\"\"\"\n        try:\n            with self.db_base.get_session() as session:\n                ocr_results = session.query(OCRResult).filter_by(screenshot_id=screenshot_id).all()\n\n                # 转换为字典列表\n                results = []\n                for ocr in ocr_results:\n                    results.append(\n                        {\n                            \"id\": ocr.id,\n                            \"screenshot_id\": ocr.screenshot_id,\n                            \"text_content\": ocr.text_content,\n                            \"confidence\": ocr.confidence,\n                            \"language\": ocr.language,\n                            \"processing_time\": ocr.processing_time,\n                            \"created_at\": ocr.created_at,\n                            \"text_hash\": ocr.text_hash,\n                        }\n                    )\n\n                return results\n\n        except SQLAlchemyError as e:\n            logger.error(f\"获取OCR结果失败: {e}\")\n            return []\n\n    def get_ocr_by_id(self, ocr_result_id: int) -> dict[str, Any] | None:\n        \"\"\"根据 OCR 结果 ID 获取单条记录。\"\"\"\n        try:\n            with self.db_base.get_session() as session:\n                ocr = session.query(OCRResult).filter_by(id=ocr_result_id).first()\n                if not ocr:\n                    return None\n\n                return {\n                    \"id\": ocr.id,\n                    \"screenshot_id\": ocr.screenshot_id,\n                    \"text_content\": ocr.text_content,\n                    \"confidence\": ocr.confidence,\n                    \"language\": ocr.language,\n                    \"processing_time\": ocr.processing_time,\n                    \"created_at\": ocr.created_at,\n                    \"text_hash\": ocr.text_hash,\n                }\n        except SQLAlchemyError as e:\n            logger.error(f\"根据ID获取OCR结果失败: {e}\")\n            return None\n\n    def get_by_text_hash(self, text_hash: str) -> dict[str, Any] | None:\n        \"\"\"根据文本哈希获取一条 OCR 结果，用于判断是否已处理过相同文本。\"\"\"\n        if not text_hash:\n            return None\n\n        try:\n            with self.db_base.get_session() as session:\n                ocr = session.query(OCRResult).filter_by(text_hash=text_hash).first()\n                if not ocr:\n                    return None\n\n                return {\n                    \"id\": ocr.id,\n                    \"screenshot_id\": ocr.screenshot_id,\n                    \"text_content\": ocr.text_content,\n                    \"confidence\": ocr.confidence,\n                    \"language\": ocr.language,\n                    \"processing_time\": ocr.processing_time,\n                    \"created_at\": ocr.created_at,\n                    \"text_hash\": ocr.text_hash,\n                }\n        except SQLAlchemyError as e:\n            logger.error(f\"根据文本哈希获取OCR结果失败: {e}\")\n            return None\n"
  },
  {
    "path": "lifetrace/storage/screenshot_manager.py",
    "content": "\"\"\"截图管理器 - 负责截图相关的数据库操作\"\"\"\n\nimport os\nfrom datetime import datetime\nfrom typing import Any\n\nfrom sqlalchemy.exc import SQLAlchemyError\n\nfrom lifetrace.storage.database_base import DatabaseBase\nfrom lifetrace.storage.models import OCRResult, Screenshot\nfrom lifetrace.storage.sql_utils import col\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.settings import settings\nfrom lifetrace.util.time_utils import get_utc_now\n\nlogger = get_logger()\n\n\nclass ScreenshotManager:\n    \"\"\"截图管理类\"\"\"\n\n    def __init__(self, db_base: DatabaseBase):\n        self.db_base = db_base\n\n    def add_screenshot(\n        self,\n        file_path: str,\n        file_hash: str,\n        width: int,\n        height: int,\n        metadata: dict[str, Any] | None = None,\n    ) -> int | None:\n        \"\"\"添加截图记录\n\n        Args:\n            file_path: 截图文件路径\n            file_hash: 文件哈希值\n            width: 图像宽度\n            height: 图像高度\n            metadata: 元数据字典，可包含以下键：\n                - screen_id: 屏幕ID (默认0)\n                - app_name: 应用名称\n                - window_title: 窗口标题\n                - event_id: 事件ID\n        \"\"\"\n        if metadata is None:\n            metadata = {}\n\n        screen_id = metadata.get(\"screen_id\", 0)\n        app_name = metadata.get(\"app_name\")\n        window_title = metadata.get(\"window_title\")\n        event_id = metadata.get(\"event_id\")\n        try:\n            with self.db_base.get_session() as session:\n                # 首先检查是否已存在相同路径的截图\n                existing_path = session.query(Screenshot).filter_by(file_path=file_path).first()\n                if existing_path:\n                    logger.debug(f\"跳过重复路径截图: {file_path}\")\n                    return existing_path.id\n\n                # 检查是否已存在相同哈希的截图\n                existing_hash = session.query(Screenshot).filter_by(file_hash=file_hash).first()\n                if existing_hash and settings.get(\"jobs.recorder.params.deduplicate\"):\n                    logger.debug(f\"跳过重复哈希截图: {file_path}\")\n                    return existing_hash.id\n\n                file_size = os.path.getsize(file_path) if os.path.exists(file_path) else 0\n\n                screenshot = Screenshot(\n                    file_path=file_path,\n                    file_hash=file_hash,\n                    file_size=file_size,\n                    width=width,\n                    height=height,\n                    screen_id=screen_id,\n                    app_name=app_name,\n                    window_title=window_title,\n                    event_id=event_id,\n                )\n\n                session.add(screenshot)\n                session.flush()  # 获取ID\n\n                logger.debug(f\"添加截图记录: {screenshot.id}\")\n                return screenshot.id\n\n        except SQLAlchemyError as e:\n            logger.error(f\"添加截图记录失败: {e}\")\n            return None\n\n    def get_screenshot_by_id(self, screenshot_id: int) -> dict | None:\n        \"\"\"根据ID获取截图\"\"\"\n        try:\n            with self.db_base.get_session() as session:\n                screenshot = session.query(Screenshot).filter_by(id=screenshot_id).first()\n                if screenshot:\n                    # 转换为字典避免会话分离问题\n                    return {\n                        \"id\": screenshot.id,\n                        \"file_path\": screenshot.file_path,\n                        \"file_hash\": screenshot.file_hash,\n                        \"file_size\": screenshot.file_size,\n                        \"width\": screenshot.width,\n                        \"height\": screenshot.height,\n                        \"screen_id\": screenshot.screen_id,\n                        \"app_name\": screenshot.app_name,\n                        \"window_title\": screenshot.window_title,\n                        \"created_at\": screenshot.created_at,\n                        \"processed_at\": screenshot.processed_at,\n                        \"is_processed\": screenshot.is_processed,\n                        \"file_deleted\": screenshot.file_deleted or False,\n                    }\n                return None\n        except SQLAlchemyError as e:\n            logger.error(f\"获取截图失败: {e}\")\n            return None\n\n    def get_screenshot_by_path(self, file_path: str) -> dict | None:\n        \"\"\"根据文件路径获取截图\"\"\"\n        try:\n            with self.db_base.get_session() as session:\n                screenshot = session.query(Screenshot).filter_by(file_path=file_path).first()\n                if screenshot:\n                    # 转换为字典避免会话分离问题\n                    return {\n                        \"id\": screenshot.id,\n                        \"file_path\": screenshot.file_path,\n                        \"file_hash\": screenshot.file_hash,\n                        \"file_size\": screenshot.file_size,\n                        \"width\": screenshot.width,\n                        \"height\": screenshot.height,\n                        \"screen_id\": screenshot.screen_id,\n                        \"app_name\": screenshot.app_name,\n                        \"window_title\": screenshot.window_title,\n                        \"created_at\": screenshot.created_at,\n                        \"processed_at\": screenshot.processed_at,\n                        \"is_processed\": screenshot.is_processed,\n                    }\n                return None\n        except SQLAlchemyError as e:\n            logger.error(f\"根据路径获取截图失败: {e}\")\n            return None\n\n    def update_screenshot_processed(self, screenshot_id: int):\n        \"\"\"更新截图处理状态\"\"\"\n        try:\n            with self.db_base.get_session() as session:\n                screenshot = session.query(Screenshot).filter_by(id=screenshot_id).first()\n                if screenshot:\n                    screenshot.is_processed = True\n                    screenshot.processed_at = get_utc_now()\n                    logger.debug(f\"更新截图处理状态: {screenshot_id}\")\n                else:\n                    logger.warning(f\"未找到截图记录: {screenshot_id}\")\n        except SQLAlchemyError as e:\n            logger.error(f\"更新截图处理状态失败: {e}\")\n\n    def get_screenshot_count(self, exclude_deleted: bool = False) -> int:\n        \"\"\"获取截图总数\n\n        Args:\n            exclude_deleted: 是否排除已删除文件的记录\n\n        Returns:\n            截图总数\n        \"\"\"\n        try:\n            with self.db_base.get_session() as session:\n                query = session.query(Screenshot)\n                if exclude_deleted:\n                    # 排除 file_deleted=True 的记录（包括 None 和 False）\n                    query = query.filter(col(Screenshot.file_deleted).is_not(True))\n                count = query.count()\n                return count\n        except SQLAlchemyError as e:\n            logger.error(f\"获取截图总数失败: {e}\")\n            return 0\n\n    def search_screenshots(\n        self,\n        query: str | None = None,\n        start_date: datetime | None = None,\n        end_date: datetime | None = None,\n        app_name: str | None = None,\n        limit: int = 50,\n        offset: int = 0,\n    ) -> list[dict[str, Any]]:\n        \"\"\"搜索截图\"\"\"\n        try:\n            with self.db_base.get_session() as session:\n                # 基础查询\n                query_obj = session.query(Screenshot, col(OCRResult.text_content)).outerjoin(\n                    OCRResult, col(Screenshot.id) == col(OCRResult.screenshot_id)\n                )\n\n                # 添加条件\n                if start_date:\n                    query_obj = query_obj.filter(col(Screenshot.created_at) >= start_date)\n\n                if end_date:\n                    query_obj = query_obj.filter(col(Screenshot.created_at) <= end_date)\n\n                if app_name:\n                    query_obj = query_obj.filter(col(Screenshot.app_name).like(f\"%{app_name}%\"))\n\n                if query:\n                    query_obj = query_obj.filter(col(OCRResult.text_content).like(f\"%{query}%\"))\n\n                # 应用分页：先排序，再应用offset和limit\n                results = (\n                    query_obj.order_by(col(Screenshot.created_at).desc())\n                    .offset(offset)\n                    .limit(limit)\n                    .all()\n                )\n\n                # 格式化结果\n                formatted_results = []\n                for screenshot, text_content in results:\n                    formatted_results.append(\n                        {\n                            \"id\": screenshot.id,\n                            \"file_path\": screenshot.file_path,\n                            \"app_name\": screenshot.app_name,\n                            \"window_title\": screenshot.window_title,\n                            \"created_at\": screenshot.created_at,\n                            \"text_content\": text_content,\n                            \"width\": screenshot.width,\n                            \"height\": screenshot.height,\n                            \"file_deleted\": screenshot.file_deleted or False,\n                        }\n                    )\n\n                return formatted_results\n\n        except SQLAlchemyError as e:\n            logger.error(f\"搜索截图失败: {e}\")\n            return []\n\n    def get_unprocessed_screenshots(self, limit: int = 100) -> list[dict[str, Any]]:\n        \"\"\"获取未分配事件的截图列表（按时间升序）\n\n        Args:\n            limit: 最多返回的截图数量\n\n        Returns:\n            截图列表\n        \"\"\"\n        try:\n            with self.db_base.get_session() as session:\n                screenshots = (\n                    session.query(Screenshot)\n                    .filter(col(Screenshot.event_id).is_(None))\n                    .order_by(col(Screenshot.created_at).asc())\n                    .limit(limit)\n                    .all()\n                )\n\n                return [\n                    {\n                        \"id\": s.id,\n                        \"file_path\": s.file_path,\n                        \"app_name\": s.app_name,\n                        \"window_title\": s.window_title,\n                        \"created_at\": s.created_at,\n                    }\n                    for s in screenshots\n                ]\n        except SQLAlchemyError as e:\n            logger.error(f\"获取未处理截图失败: {e}\")\n            return []\n"
  },
  {
    "path": "lifetrace/storage/sql_utils.py",
    "content": "\"\"\"SQLAlchemy typing helpers for SQLModel query expressions.\"\"\"\n\nfrom typing import Any, TypeVar, cast\n\nfrom sqlalchemy.sql.elements import ColumnElement\n\nT = TypeVar(\"T\")\n\n\ndef col(expr: Any) -> ColumnElement[Any]:\n    \"\"\"Cast SQLModel attributes to SQLAlchemy column elements for type checking.\"\"\"\n    return cast(\"ColumnElement[Any]\", expr)\n"
  },
  {
    "path": "lifetrace/storage/stats_manager.py",
    "content": "\"\"\"统计管理器 - 负责统计信息和数据清理相关的数据库操作\"\"\"\n\nimport os\nfrom datetime import timedelta\nfrom typing import Any\n\nfrom sqlalchemy.exc import SQLAlchemyError\n\nfrom lifetrace.storage.database_base import DatabaseBase\nfrom lifetrace.storage.models import OCRResult, Screenshot\nfrom lifetrace.storage.sql_utils import col\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.time_utils import get_utc_now\n\nlogger = get_logger()\n\n\nclass StatsManager:\n    \"\"\"统计和数据清理管理类\"\"\"\n\n    def __init__(self, db_base: DatabaseBase):\n        self.db_base = db_base\n\n    def get_statistics(self) -> dict[str, Any]:\n        \"\"\"获取统计信息\"\"\"\n        try:\n            with self.db_base.get_session() as session:\n                total_screenshots = session.query(Screenshot).count()\n                processed_screenshots = (\n                    session.query(Screenshot).filter_by(is_processed=True).count()\n                )\n\n                # 今日统计\n                now = get_utc_now()\n                today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)\n                today_screenshots = (\n                    session.query(Screenshot)\n                    .filter(col(Screenshot.created_at) >= today_start)\n                    .count()\n                )\n\n                return {\n                    \"total_screenshots\": total_screenshots,\n                    \"processed_screenshots\": processed_screenshots,\n                    \"today_screenshots\": today_screenshots,\n                    \"processing_rate\": processed_screenshots / max(total_screenshots, 1) * 100,\n                }\n\n        except SQLAlchemyError as e:\n            logger.error(f\"获取统计信息失败: {e}\")\n            return {}\n\n    def cleanup_old_data(self, max_days: int):\n        \"\"\"清理旧数据\"\"\"\n        if max_days <= 0:\n            return\n\n        try:\n            cutoff_date = get_utc_now() - timedelta(days=max_days)\n\n            with self.db_base.get_session() as session:\n                # 获取要删除的截图\n                old_screenshots = (\n                    session.query(Screenshot).filter(col(Screenshot.created_at) < cutoff_date).all()\n                )\n\n                deleted_count = 0\n                for screenshot in old_screenshots:\n                    # 删除相关的OCR结果\n                    session.query(OCRResult).filter_by(screenshot_id=screenshot.id).delete()\n\n                    # 删除文件\n                    if os.path.exists(screenshot.file_path):\n                        try:\n                            os.remove(screenshot.file_path)\n                        except Exception as e:\n                            logger.error(f\"删除文件失败 {screenshot.file_path}: {e}\")\n\n                    # 删除截图记录\n                    session.delete(screenshot)\n                    deleted_count += 1\n\n                logger.info(f\"清理了 {deleted_count} 条旧数据\")\n\n        except SQLAlchemyError as e:\n            logger.error(f\"清理旧数据失败: {e}\")\n"
  },
  {
    "path": "lifetrace/storage/todo_manager.py",
    "content": "\"\"\"Todo 管理器 - 负责 Todo/Tag/Attachment 相关数据库操作\"\"\"\n\nfrom __future__ import annotations\n\nimport contextlib\nfrom typing import TYPE_CHECKING, Any\n\nfrom sqlalchemy.exc import SQLAlchemyError\n\nfrom lifetrace.storage.models import Tag, Todo, TodoAttachmentRelation, TodoTagRelation\nfrom lifetrace.storage.sql_utils import col\nfrom lifetrace.storage.todo_manager_attachments import TodoAttachmentMixin\nfrom lifetrace.storage.todo_manager_ical import TodoIcalMixin\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.time_utils import get_utc_now\n\nlogger = get_logger()\n\nif TYPE_CHECKING:\n    from lifetrace.storage.database_base import DatabaseBase\n\n\nclass TodoManager(TodoAttachmentMixin, TodoIcalMixin):\n    \"\"\"Todo 管理类\"\"\"\n\n    def __init__(self, db_base: DatabaseBase):\n        self.db_base = db_base\n\n    # ========== 查询辅助 ==========\n    def _get_todo_tags(self, session, todo_id: int) -> list[str]:\n        rows = (\n            session.query(col(Tag.tag_name))\n            .join(TodoTagRelation, col(TodoTagRelation.tag_id) == col(Tag.id))\n            .filter(col(TodoTagRelation.todo_id) == todo_id)\n            .all()\n        )\n        return [r[0] for r in rows if r and r[0]]\n\n    def get_todo_context(self, todo_id: int) -> dict[str, Any] | None:\n        \"\"\"获取任务的所有相关上下文（父任务链、同级任务、子任务）\"\"\"\n        try:\n            with self.db_base.get_session() as session:\n                # 获取当前任务\n                current_todo = session.query(Todo).filter_by(id=todo_id).first()\n                if not current_todo:\n                    return None\n\n                current_dict = self._todo_to_dict(session, current_todo)\n\n                # 递归向上查找所有父任务\n                parents: list[dict[str, Any]] = []\n                parent_id = current_todo.parent_todo_id\n                visited_parents = set()  # 防止循环引用\n\n                while parent_id is not None and parent_id not in visited_parents:\n                    visited_parents.add(parent_id)\n                    parent_todo = session.query(Todo).filter_by(id=parent_id).first()\n                    if not parent_todo:\n                        break\n                    parents.append(self._todo_to_dict(session, parent_todo))\n                    parent_id = parent_todo.parent_todo_id\n\n                # 查找所有同级任务（相同 parent_todo_id，排除当前任务）\n                siblings: list[dict[str, Any]] = []\n                if current_todo.parent_todo_id is not None:\n                    sibling_todos = (\n                        session.query(Todo)\n                        .filter(\n                            col(Todo.parent_todo_id) == current_todo.parent_todo_id,\n                            col(Todo.id) != todo_id,\n                        )\n                        .all()\n                    )\n                    siblings = [self._todo_to_dict(session, t) for t in sibling_todos]\n\n                # 递归向下查找所有子任务\n                def _get_children_recursive(parent_todo_id: int) -> list[dict[str, Any]]:\n                    children: list[dict[str, Any]] = []\n                    child_todos = (\n                        session.query(Todo).filter(col(Todo.parent_todo_id) == parent_todo_id).all()\n                    )\n                    for child in child_todos:\n                        child_dict = self._todo_to_dict(session, child)\n                        # 递归获取子任务的子任务\n                        child_dict[\"children\"] = _get_children_recursive(child.id)\n                        children.append(child_dict)\n                    return children\n\n                children = _get_children_recursive(todo_id)\n\n                return {\n                    \"current\": current_dict,\n                    \"parents\": parents,\n                    \"siblings\": siblings,\n                    \"children\": children,\n                }\n        except SQLAlchemyError as e:\n            logger.error(f\"获取 todo 上下文失败: {e}\")\n            return None\n\n    # ========== CRUD ==========\n    def get_todo(self, todo_id: int) -> dict[str, Any] | None:\n        try:\n            with self.db_base.get_session() as session:\n                todo = session.query(Todo).filter_by(id=todo_id).first()\n                if not todo:\n                    return None\n                return self._todo_to_dict(session, todo)\n        except SQLAlchemyError as e:\n            logger.error(f\"获取 todo 失败: {e}\")\n            return None\n\n    def get_todo_by_uid(self, uid: str) -> dict[str, Any] | None:\n        if not uid:\n            return None\n        try:\n            with self.db_base.get_session() as session:\n                todo = session.query(Todo).filter_by(uid=uid).first()\n                if not todo:\n                    return None\n                return self._todo_to_dict(session, todo)\n        except SQLAlchemyError as e:\n            logger.error(f\"根据 uid 获取 todo 失败: {e}\")\n            return None\n\n    def list_todos(\n        self,\n        *,\n        limit: int = 200,\n        offset: int = 0,\n        status: str | None = None,\n    ) -> list[dict[str, Any]]:\n        try:\n            with self.db_base.get_session() as session:\n                q = session.query(Todo)\n                # 默认不返回软删除数据（如果未来使用 deleted_at）\n                with contextlib.suppress(Exception):\n                    q = q.filter(col(Todo.deleted_at).is_(None))\n\n                if status:\n                    q = q.filter(col(Todo.status) == status)\n\n                todos = q.order_by(col(Todo.created_at).desc()).offset(offset).limit(limit).all()\n                return [self._todo_to_dict(session, t) for t in todos]\n        except SQLAlchemyError as e:\n            logger.error(f\"列出 todo 失败: {e}\")\n            return []\n\n    def count_todos(self, *, status: str | None = None) -> int:\n        try:\n            with self.db_base.get_session() as session:\n                q = session.query(Todo)\n                with contextlib.suppress(Exception):\n                    q = q.filter(col(Todo.deleted_at).is_(None))\n                if status:\n                    q = q.filter(col(Todo.status) == status)\n                return q.count()\n        except SQLAlchemyError as e:\n            logger.error(f\"统计 todo 数量失败: {e}\")\n            return 0\n\n    def get_active_todos_for_prompt(self, limit: int = 100) -> list[dict[str, Any]]:\n        \"\"\"获取用于提示词的活跃 todo 列表（精简字段）。\n\n        返回的数据适合直接序列化为 JSON 传给 LLM，让模型了解当前已有的待办。\n        \"\"\"\n        try:\n            with self.db_base.get_session() as session:\n                q = session.query(Todo)\n                with contextlib.suppress(Exception):\n                    q = q.filter(col(Todo.deleted_at).is_(None))\n\n                q = (\n                    q.filter(col(Todo.status) == \"active\")\n                    .order_by(col(Todo.created_at).desc())\n                    .limit(limit)\n                )\n                todos = q.all()\n\n                result: list[dict[str, Any]] = []\n                for t in todos:\n                    schedule = t.dtstart or t.start_time or t.due or t.deadline\n                    result.append(\n                        {\n                            \"id\": t.id,\n                            \"name\": t.name,\n                            \"description\": t.description,\n                            \"start_time\": schedule.isoformat() if schedule else None,\n                        }\n                    )\n                return result\n        except SQLAlchemyError as e:\n            logger.error(f\"获取用于提示词的活跃 todo 列表失败: {e}\")\n            return []\n\n    def _delete_todo_recursive(self, session, todo_id: int) -> None:\n        \"\"\"递归删除 todo 及其所有子任务\"\"\"\n        # 查找所有子任务\n        child_todos = session.query(Todo).filter(col(Todo.parent_todo_id) == todo_id).all()\n\n        # 递归删除所有子任务\n        for child in child_todos:\n            self._delete_todo_recursive(session, child.id)\n\n        # 清理关联关系（不删除 Tag/Attachment 实体）\n        session.query(TodoTagRelation).filter(col(TodoTagRelation.todo_id) == todo_id).delete()\n        session.query(TodoAttachmentRelation).filter(\n            col(TodoAttachmentRelation.todo_id) == todo_id\n        ).delete()\n\n        # 删除 todo 本身\n        todo = session.query(Todo).filter_by(id=todo_id).first()\n        if todo:\n            session.delete(todo)\n            logger.info(f\"删除 todo: {todo_id}\")\n\n    def delete_todo(self, todo_id: int) -> bool:\n        try:\n            with self.db_base.get_session() as session:\n                todo = session.query(Todo).filter_by(id=todo_id).first()\n                if not todo:\n                    logger.warning(f\"todo 不存在: {todo_id}\")\n                    return False\n\n                # 递归删除 todo 及其所有子任务\n                self._delete_todo_recursive(session, todo_id)\n                session.flush()\n                logger.info(f\"删除 todo 及其子任务: {todo_id}\")\n                return True\n        except SQLAlchemyError as e:\n            logger.error(f\"删除 todo 失败: {e}\")\n            return False\n\n    # ========== 关系写入 ==========\n    def reorder_todos(self, items: list[dict[str, Any]]) -> bool:\n        \"\"\"批量更新待办的排序和父子关系\n\n        Args:\n            items: 待办列表，每个元素包含 id, order, 可选 parent_todo_id\n\n        Returns:\n            是否全部更新成功\n        \"\"\"\n        try:\n            with self.db_base.get_session() as session:\n                for item in items:\n                    todo_id = item.get(\"id\")\n                    if not todo_id:\n                        continue\n\n                    todo = session.query(Todo).filter_by(id=todo_id).first()\n                    if not todo:\n                        logger.warning(f\"reorder_todos: todo 不存在: {todo_id}\")\n                        continue\n\n                    # 更新 order\n                    if \"order\" in item:\n                        todo.order = item[\"order\"]\n\n                    # 更新 parent_todo_id（如果提供了该字段）\n                    if \"parent_todo_id\" in item:\n                        todo.parent_todo_id = item[\"parent_todo_id\"]\n\n                    todo.updated_at = get_utc_now()\n\n                session.flush()\n                logger.info(f\"批量重排序 {len(items)} 个待办\")\n                return True\n        except SQLAlchemyError as e:\n            logger.error(f\"批量重排序待办失败: {e}\")\n            return False\n\n    def _set_todo_tags(self, session, todo_id: int, tags: list[str]) -> None:\n        # 清空旧关系\n        session.query(TodoTagRelation).filter(col(TodoTagRelation.todo_id) == todo_id).delete()\n\n        # 去重/清洗\n        cleaned = []\n        seen = set()\n        for t in tags:\n            name = (t or \"\").strip()\n            if not name:\n                continue\n            if name in seen:\n                continue\n            seen.add(name)\n            cleaned.append(name)\n\n        for tag_name in cleaned:\n            tag = session.query(Tag).filter_by(tag_name=tag_name).first()\n            if not tag:\n                tag = Tag(tag_name=tag_name)\n                session.add(tag)\n                session.flush()\n\n            if tag.id is None:\n                raise ValueError(\"Tag must have an id before creating relation.\")\n            rel = TodoTagRelation(todo_id=todo_id, tag_id=tag.id)\n            session.add(rel)\n"
  },
  {
    "path": "lifetrace/storage/todo_manager_attachments.py",
    "content": "\"\"\"Attachment helpers for TodoManager.\"\"\"\n\nfrom __future__ import annotations\n\nfrom typing import TYPE_CHECKING, Any\n\nfrom sqlalchemy.exc import SQLAlchemyError\n\nfrom lifetrace.storage.models import Attachment, Todo, TodoAttachmentRelation\nfrom lifetrace.storage.sql_utils import col\nfrom lifetrace.util.logging_config import get_logger\n\nlogger = get_logger()\n\nif TYPE_CHECKING:\n    from lifetrace.storage.database_base import DatabaseBase\n\n\nclass TodoAttachmentMixin:\n    \"\"\"Attachment-related helpers for TodoManager.\"\"\"\n\n    db_base: DatabaseBase\n\n    def _get_todo_attachments(self, session, todo_id: int) -> list[dict[str, Any]]:\n        rows = (\n            session.query(Attachment, TodoAttachmentRelation)\n            .join(\n                TodoAttachmentRelation,\n                col(TodoAttachmentRelation.attachment_id) == col(Attachment.id),\n            )\n            .filter(\n                col(TodoAttachmentRelation.todo_id) == todo_id,\n                col(TodoAttachmentRelation.deleted_at).is_(None),\n            )\n            .all()\n        )\n        return [\n            {\n                \"id\": attachment.id,\n                \"file_name\": attachment.file_name,\n                \"file_path\": attachment.file_path,\n                \"file_size\": attachment.file_size,\n                \"mime_type\": attachment.mime_type,\n                \"source\": relation.source,\n            }\n            for attachment, relation in rows\n        ]\n\n    def add_todo_attachment(\n        self,\n        *,\n        todo_id: int,\n        file_name: str,\n        file_path: str,\n        file_size: int | None,\n        mime_type: str | None,\n        file_hash: str | None,\n        source: str = \"user\",\n    ) -> dict[str, Any] | None:\n        try:\n            with self.db_base.get_session() as session:\n                todo = session.query(Todo).filter_by(id=todo_id).first()\n                if not todo:\n                    return None\n\n                attachment = Attachment(\n                    file_name=file_name,\n                    file_path=file_path,\n                    file_size=file_size,\n                    mime_type=mime_type,\n                    file_hash=file_hash,\n                )\n                session.add(attachment)\n                session.flush()\n                if attachment.id is None:\n                    raise ValueError(\"Attachment must have an id before linking.\")\n\n                relation = TodoAttachmentRelation(\n                    todo_id=todo_id,\n                    attachment_id=attachment.id,\n                    source=source or \"user\",\n                )\n                session.add(relation)\n                session.flush()\n\n                return {\n                    \"id\": attachment.id,\n                    \"file_name\": attachment.file_name,\n                    \"file_path\": attachment.file_path,\n                    \"file_size\": attachment.file_size,\n                    \"mime_type\": attachment.mime_type,\n                    \"source\": relation.source,\n                }\n        except SQLAlchemyError as exc:\n            logger.error(f\"Failed to create attachment: {exc}\")\n            return None\n\n    def remove_todo_attachment(self, *, todo_id: int, attachment_id: int) -> bool:\n        try:\n            with self.db_base.get_session() as session:\n                rows = (\n                    session.query(TodoAttachmentRelation)\n                    .filter(\n                        col(TodoAttachmentRelation.todo_id) == todo_id,\n                        col(TodoAttachmentRelation.attachment_id) == attachment_id,\n                    )\n                    .delete()\n                )\n                return rows > 0\n        except SQLAlchemyError as exc:\n            logger.error(f\"Failed to unlink attachment: {exc}\")\n            return False\n\n    def get_attachment(self, attachment_id: int) -> dict[str, Any] | None:\n        try:\n            with self.db_base.get_session() as session:\n                attachment = session.query(Attachment).filter_by(id=attachment_id).first()\n                if not attachment:\n                    return None\n                return {\n                    \"id\": attachment.id,\n                    \"file_name\": attachment.file_name,\n                    \"file_path\": attachment.file_path,\n                    \"file_size\": attachment.file_size,\n                    \"mime_type\": attachment.mime_type,\n                }\n        except SQLAlchemyError as exc:\n            logger.error(f\"Failed to fetch attachment: {exc}\")\n            return None\n"
  },
  {
    "path": "lifetrace/storage/todo_manager_ical.py",
    "content": "\"\"\"Todo manager iCalendar mappings and CRUD helpers.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nfrom typing import TYPE_CHECKING, Any\n\nfrom sqlalchemy.exc import SQLAlchemyError\n\nfrom lifetrace.storage.models import Todo\nfrom lifetrace.storage.todo_manager_utils import (\n    _normalize_percent,\n    _normalize_reminder_offsets,\n    _safe_int_list,\n    _serialize_reminder_offsets,\n)\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.time_utils import get_utc_now\n\nlogger = get_logger()\n\n_UNSET = object()\n\nif TYPE_CHECKING:\n    from datetime import datetime\n\n    from lifetrace.storage.database_base import DatabaseBase\n\n\ndef _to_ical_status(status: str | None) -> str | None:\n    if not status:\n        return None\n    mapping = {\n        \"active\": \"NEEDS-ACTION\",\n        \"completed\": \"COMPLETED\",\n        \"canceled\": \"CANCELLED\",\n        \"draft\": \"NEEDS-ACTION\",\n    }\n    return mapping.get(status, \"NEEDS-ACTION\")\n\n\nclass TodoIcalMixin:\n    \"\"\"Mixin for iCalendar-aware Todo CRUD and serialization.\"\"\"\n\n    if TYPE_CHECKING:\n        db_base: DatabaseBase\n\n        def _get_todo_tags(self, session, todo_id: int) -> list[str]: ...\n\n        def _get_todo_attachments(self, session, todo_id: int) -> list[dict[str, Any]]: ...\n\n        def _set_todo_tags(self, session, todo_id: int, tags: list[str]) -> None: ...\n\n    def _todo_to_dict(self, session, todo: Todo) -> dict[str, Any]:\n        todo_id = todo.id\n        if todo_id is None:\n            raise ValueError(\"Todo must have an id before serialization.\")\n        summary = getattr(todo, \"summary\", None) or todo.name\n        dtstart = getattr(todo, \"dtstart\", None) or todo.start_time\n        dtend = getattr(todo, \"dtend\", None) or todo.end_time\n        due = getattr(todo, \"due\", None) or todo.deadline\n        tzid = getattr(todo, \"tzid\", None) or getattr(todo, \"time_zone\", None)\n        created = getattr(todo, \"created\", None) or todo.created_at\n        last_modified = getattr(todo, \"last_modified\", None) or todo.updated_at\n        dtstamp = getattr(todo, \"dtstamp\", None) or todo.updated_at\n        ical_status = getattr(todo, \"ical_status\", None) or _to_ical_status(todo.status)\n        is_all_day = getattr(todo, \"is_all_day\", None)\n        if is_all_day is None:\n            is_all_day = False\n        return {\n            \"id\": todo_id,\n            \"uid\": getattr(todo, \"uid\", None),\n            \"name\": todo.name,\n            \"summary\": summary,\n            \"description\": todo.description,\n            \"user_notes\": todo.user_notes,\n            \"parent_todo_id\": todo.parent_todo_id,\n            \"item_type\": getattr(todo, \"item_type\", None),\n            \"location\": getattr(todo, \"location\", None),\n            \"categories\": getattr(todo, \"categories\", None),\n            \"classification\": getattr(todo, \"classification\", None),\n            \"deadline\": todo.deadline,\n            \"start_time\": todo.start_time,\n            \"end_time\": todo.end_time,\n            \"dtstart\": dtstart,\n            \"dtend\": dtend,\n            \"due\": due,\n            \"duration\": getattr(todo, \"duration\", None),\n            \"time_zone\": getattr(todo, \"time_zone\", None),\n            \"tzid\": tzid,\n            \"is_all_day\": bool(is_all_day),\n            \"dtstamp\": dtstamp,\n            \"created\": created,\n            \"last_modified\": last_modified,\n            \"sequence\": getattr(todo, \"sequence\", 0),\n            \"rdate\": getattr(todo, \"rdate\", None),\n            \"exdate\": getattr(todo, \"exdate\", None),\n            \"recurrence_id\": getattr(todo, \"recurrence_id\", None),\n            \"related_to_uid\": getattr(todo, \"related_to_uid\", None),\n            \"related_to_reltype\": getattr(todo, \"related_to_reltype\", None),\n            \"ical_status\": ical_status,\n            \"reminder_offsets\": _normalize_reminder_offsets(\n                getattr(todo, \"reminder_offsets\", None)\n            ),\n            \"status\": todo.status,\n            \"priority\": todo.priority,\n            \"completed_at\": getattr(todo, \"completed_at\", None),\n            \"percent_complete\": (\n                todo.percent_complete if getattr(todo, \"percent_complete\", None) is not None else 0\n            ),\n            \"rrule\": getattr(todo, \"rrule\", None),\n            \"order\": getattr(todo, \"order\", 0),\n            \"tags\": self._get_todo_tags(session, todo_id),\n            \"attachments\": self._get_todo_attachments(session, todo_id),\n            \"related_activities\": _safe_int_list(todo.related_activities),\n            \"source_type\": getattr(todo, \"source_type\", None),\n            \"source_key\": getattr(todo, \"source_key\", None),\n            \"source_date\": getattr(todo, \"source_date\", None),\n            \"created_at\": todo.created_at,\n            \"updated_at\": todo.updated_at,\n        }\n\n    def create_todo(  # noqa: PLR0913, C901, PLR0912\n        self,\n        *,\n        name: str,\n        summary: str | None = None,\n        description: str | None = None,\n        user_notes: str | None = None,\n        parent_todo_id: int | None = None,\n        item_type: str | None = None,\n        location: str | None = None,\n        categories: str | None = None,\n        classification: str | None = None,\n        deadline: datetime | None = None,\n        start_time: datetime | None = None,\n        end_time: datetime | None = None,\n        dtstart: datetime | None = None,\n        dtend: datetime | None = None,\n        due: datetime | None = None,\n        duration: str | None = None,\n        time_zone: str | None = None,\n        tzid: str | None = None,\n        is_all_day: bool | None = None,\n        dtstamp: datetime | None = None,\n        created: datetime | None = None,\n        last_modified: datetime | None = None,\n        sequence: int | None = None,\n        rdate: str | None = None,\n        exdate: str | None = None,\n        recurrence_id: datetime | None = None,\n        related_to_uid: str | None = None,\n        related_to_reltype: str | None = None,\n        ical_status: str | None = None,\n        reminder_offsets: list[int] | None = None,\n        status: str = \"active\",\n        priority: str = \"none\",\n        completed_at: datetime | None = None,\n        percent_complete: int | None = None,\n        rrule: str | None = None,\n        uid: str | None = None,\n        order: int = 0,\n        tags: list[str] | None = None,\n        related_activities: list[int] | None = None,\n    ) -> int | None:\n        try:\n            resolved_percent = (\n                _normalize_percent(percent_complete) if percent_complete is not None else None\n            )\n            if resolved_percent is None:\n                resolved_percent = 100 if status == \"completed\" else 0\n\n            resolved_completed_at = completed_at\n            if resolved_completed_at is None and status == \"completed\":\n                resolved_completed_at = get_utc_now()\n\n            cleaned_rrule = (rrule or \"\").strip() or None\n            cleaned_uid = (uid or \"\").strip() or None\n\n            with self.db_base.get_session() as session:\n                if dtstart is None:\n                    dtstart = start_time or deadline or due\n                if due is None:\n                    due = deadline\n                if dtend is None:\n                    dtend = end_time\n                if start_time is None and dtstart is not None:\n                    start_time = dtstart\n                if end_time is None and dtend is not None:\n                    end_time = dtend\n                if deadline is None and due is not None:\n                    deadline = due\n\n                resolved_summary = summary or name\n                resolved_item_type = (item_type or \"VTODO\").upper()\n                resolved_tzid = tzid or time_zone\n                now = get_utc_now()\n                if created is None:\n                    created = now\n                if last_modified is None:\n                    last_modified = now\n                if dtstamp is None:\n                    dtstamp = now\n\n                todo_kwargs: dict[str, Any] = {\n                    \"name\": name,\n                    \"summary\": resolved_summary,\n                    \"description\": description,\n                    \"user_notes\": user_notes,\n                    \"parent_todo_id\": parent_todo_id,\n                    \"item_type\": resolved_item_type,\n                    \"location\": location,\n                    \"categories\": categories,\n                    \"classification\": classification,\n                    \"deadline\": deadline,\n                    \"start_time\": start_time,\n                    \"end_time\": end_time,\n                    \"dtstart\": dtstart,\n                    \"dtend\": dtend,\n                    \"due\": due,\n                    \"duration\": duration,\n                    \"time_zone\": time_zone,\n                    \"tzid\": resolved_tzid,\n                    \"is_all_day\": bool(is_all_day) if is_all_day is not None else False,\n                    \"dtstamp\": dtstamp,\n                    \"created\": created,\n                    \"last_modified\": last_modified,\n                    \"sequence\": sequence if sequence is not None else 0,\n                    \"rdate\": rdate,\n                    \"exdate\": exdate,\n                    \"recurrence_id\": recurrence_id,\n                    \"related_to_uid\": related_to_uid,\n                    \"related_to_reltype\": related_to_reltype,\n                    \"ical_status\": ical_status,\n                    \"reminder_offsets\": _serialize_reminder_offsets(reminder_offsets),\n                    \"status\": status,\n                    \"priority\": priority,\n                    \"completed_at\": resolved_completed_at,\n                    \"percent_complete\": resolved_percent,\n                    \"rrule\": cleaned_rrule,\n                    \"order\": order,\n                    \"related_activities\": json.dumps(_safe_int_list(related_activities)),\n                }\n                if cleaned_uid:\n                    todo_kwargs[\"uid\"] = cleaned_uid\n\n                todo = Todo(**todo_kwargs)\n                session.add(todo)\n                session.flush()\n\n                if tags is not None:\n                    if todo.id is None:\n                        raise ValueError(\"Todo must have an id before tagging.\")\n                    self._set_todo_tags(session, todo.id, tags)\n\n                logger.info(f\"创建 todo: {todo.id} - {name}\")\n                return todo.id\n        except SQLAlchemyError as e:\n            logger.error(f\"创建 todo 失败: {e}\")\n            return None\n\n    def _apply_todo_updates(  # noqa: PLR0913\n        self,\n        todo: Todo,\n        *,\n        name: str | Any = _UNSET,\n        summary: str | Any = _UNSET,\n        description: str | Any = _UNSET,\n        user_notes: str | Any = _UNSET,\n        parent_todo_id: int | None | Any = _UNSET,\n        item_type: str | None | Any = _UNSET,\n        location: str | None | Any = _UNSET,\n        categories: str | None | Any = _UNSET,\n        classification: str | None | Any = _UNSET,\n        deadline: datetime | None | Any = _UNSET,\n        start_time: datetime | None | Any = _UNSET,\n        end_time: datetime | None | Any = _UNSET,\n        dtstart: datetime | None | Any = _UNSET,\n        dtend: datetime | None | Any = _UNSET,\n        due: datetime | None | Any = _UNSET,\n        duration: str | None | Any = _UNSET,\n        time_zone: str | None | Any = _UNSET,\n        tzid: str | None | Any = _UNSET,\n        is_all_day: bool | None | Any = _UNSET,\n        dtstamp: datetime | None | Any = _UNSET,\n        created: datetime | None | Any = _UNSET,\n        last_modified: datetime | None | Any = _UNSET,\n        sequence: int | Any = _UNSET,\n        rdate: str | None | Any = _UNSET,\n        exdate: str | None | Any = _UNSET,\n        recurrence_id: datetime | None | Any = _UNSET,\n        related_to_uid: str | None | Any = _UNSET,\n        related_to_reltype: str | None | Any = _UNSET,\n        ical_status: str | None | Any = _UNSET,\n        reminder_offsets: list[int] | None | Any = _UNSET,\n        status: str | Any = _UNSET,\n        priority: str | Any = _UNSET,\n        completed_at: datetime | None | Any = _UNSET,\n        percent_complete: int | Any = _UNSET,\n        rrule: str | None | Any = _UNSET,\n        order: int | Any = _UNSET,\n        related_activities: list[int] | Any = _UNSET,\n    ) -> None:\n        \"\"\"应用待办字段更新.\"\"\"\n        if percent_complete is not _UNSET:\n            percent_complete = _normalize_percent(percent_complete)\n        if rrule is not _UNSET:\n            rrule = (rrule or \"\").strip() or None\n\n        updates = {\n            \"name\": name,\n            \"summary\": summary,\n            \"description\": description,\n            \"user_notes\": user_notes,\n            \"parent_todo_id\": parent_todo_id,\n            \"item_type\": item_type,\n            \"location\": location,\n            \"categories\": categories,\n            \"classification\": classification,\n            \"deadline\": deadline,\n            \"start_time\": start_time,\n            \"end_time\": end_time,\n            \"dtstart\": dtstart,\n            \"dtend\": dtend,\n            \"due\": due,\n            \"duration\": duration,\n            \"time_zone\": time_zone,\n            \"tzid\": tzid,\n            \"is_all_day\": is_all_day,\n            \"dtstamp\": dtstamp,\n            \"created\": created,\n            \"last_modified\": last_modified,\n            \"sequence\": sequence,\n            \"rdate\": rdate,\n            \"exdate\": exdate,\n            \"recurrence_id\": recurrence_id,\n            \"related_to_uid\": related_to_uid,\n            \"related_to_reltype\": related_to_reltype,\n            \"ical_status\": ical_status,\n            \"status\": status,\n            \"priority\": priority,\n            \"completed_at\": completed_at,\n            \"percent_complete\": percent_complete,\n            \"rrule\": rrule,\n            \"order\": order,\n        }\n\n        for attr, value in updates.items():\n            if value is not _UNSET:\n                setattr(todo, attr, value)\n\n        if reminder_offsets is not _UNSET:\n            todo.reminder_offsets = _serialize_reminder_offsets(reminder_offsets)\n\n        if related_activities is not _UNSET:\n            todo.related_activities = json.dumps(_safe_int_list(related_activities))\n\n    def update_todo(  # noqa: PLR0913, C901, PLR0912, PLR0915\n        self,\n        todo_id: int,\n        *,\n        name: str | Any = _UNSET,\n        summary: str | Any = _UNSET,\n        description: str | Any = _UNSET,\n        user_notes: str | Any = _UNSET,\n        parent_todo_id: int | None | Any = _UNSET,\n        item_type: str | None | Any = _UNSET,\n        location: str | None | Any = _UNSET,\n        categories: str | None | Any = _UNSET,\n        classification: str | None | Any = _UNSET,\n        deadline: datetime | None | Any = _UNSET,\n        start_time: datetime | None | Any = _UNSET,\n        end_time: datetime | None | Any = _UNSET,\n        dtstart: datetime | None | Any = _UNSET,\n        dtend: datetime | None | Any = _UNSET,\n        due: datetime | None | Any = _UNSET,\n        duration: str | None | Any = _UNSET,\n        time_zone: str | None | Any = _UNSET,\n        tzid: str | None | Any = _UNSET,\n        is_all_day: bool | None | Any = _UNSET,\n        dtstamp: datetime | None | Any = _UNSET,\n        created: datetime | None | Any = _UNSET,\n        last_modified: datetime | None | Any = _UNSET,\n        sequence: int | Any = _UNSET,\n        rdate: str | None | Any = _UNSET,\n        exdate: str | None | Any = _UNSET,\n        recurrence_id: datetime | None | Any = _UNSET,\n        related_to_uid: str | None | Any = _UNSET,\n        related_to_reltype: str | None | Any = _UNSET,\n        ical_status: str | None | Any = _UNSET,\n        reminder_offsets: list[int] | None | Any = _UNSET,\n        status: str | Any = _UNSET,\n        priority: str | Any = _UNSET,\n        completed_at: datetime | None | Any = _UNSET,\n        percent_complete: int | Any = _UNSET,\n        rrule: str | None | Any = _UNSET,\n        order: int | Any = _UNSET,\n        tags: list[str] | Any = _UNSET,\n        related_activities: list[int] | Any = _UNSET,\n    ) -> bool:\n        try:\n            with self.db_base.get_session() as session:\n                todo = session.query(Todo).filter_by(id=todo_id).first()\n                if not todo:\n                    logger.warning(f\"todo 不存在: {todo_id}\")\n                    return False\n\n                resolved_completed_at = completed_at\n                resolved_percent = percent_complete\n\n                if status is not _UNSET:\n                    if status == \"completed\":\n                        if completed_at is _UNSET:\n                            resolved_completed_at = get_utc_now()\n                        if percent_complete is _UNSET:\n                            resolved_percent = 100\n                    else:\n                        if completed_at is _UNSET:\n                            resolved_completed_at = None\n                        if percent_complete is _UNSET:\n                            resolved_percent = 0\n\n                if item_type is not _UNSET and item_type is not None:\n                    item_type = item_type.upper()\n\n                if summary is _UNSET and name is not _UNSET:\n                    summary = name\n                if name is _UNSET and summary is not _UNSET:\n                    name = summary\n\n                if tzid is _UNSET and time_zone is not _UNSET:\n                    tzid = time_zone\n                if time_zone is _UNSET and tzid is not _UNSET:\n                    time_zone = tzid\n\n                if start_time is _UNSET and dtstart is not _UNSET:\n                    start_time = dtstart\n                if end_time is _UNSET and dtend is not _UNSET:\n                    end_time = dtend\n                if deadline is _UNSET and due is not _UNSET:\n                    deadline = due\n\n                if dtstart is _UNSET and start_time is not _UNSET:\n                    dtstart = start_time\n                if dtend is _UNSET and end_time is not _UNSET:\n                    dtend = end_time\n                if due is _UNSET and deadline is not _UNSET:\n                    due = deadline\n                if deadline is not _UNSET and start_time is _UNSET:\n                    start_time = deadline\n                    deadline = None\n\n                if last_modified is _UNSET:\n                    last_modified = get_utc_now()\n                if dtstamp is _UNSET:\n                    dtstamp = last_modified\n\n                self._apply_todo_updates(\n                    todo,\n                    name=name,\n                    summary=summary,\n                    description=description,\n                    user_notes=user_notes,\n                    parent_todo_id=parent_todo_id,\n                    item_type=item_type,\n                    location=location,\n                    categories=categories,\n                    classification=classification,\n                    deadline=deadline,\n                    start_time=start_time,\n                    end_time=end_time,\n                    dtstart=dtstart,\n                    dtend=dtend,\n                    due=due,\n                    duration=duration,\n                    time_zone=time_zone,\n                    tzid=tzid,\n                    is_all_day=is_all_day,\n                    dtstamp=dtstamp,\n                    created=created,\n                    last_modified=last_modified,\n                    sequence=sequence,\n                    rdate=rdate,\n                    exdate=exdate,\n                    recurrence_id=recurrence_id,\n                    related_to_uid=related_to_uid,\n                    related_to_reltype=related_to_reltype,\n                    ical_status=ical_status,\n                    reminder_offsets=reminder_offsets,\n                    status=status,\n                    priority=priority,\n                    completed_at=resolved_completed_at,\n                    percent_complete=resolved_percent,\n                    rrule=rrule,\n                    order=order,\n                    related_activities=related_activities,\n                )\n\n                todo.updated_at = get_utc_now()\n                session.flush()\n\n                if tags is not _UNSET:\n                    self._set_todo_tags(session, todo_id, tags or [])\n\n                logger.info(f\"更新 todo: {todo_id}\")\n                return True\n        except SQLAlchemyError as e:\n            logger.error(f\"更新 todo 失败: {e}\")\n            return False\n"
  },
  {
    "path": "lifetrace/storage/todo_manager_utils.py",
    "content": "\"\"\"Helper utilities for TodoManager.\"\"\"\n\nfrom __future__ import annotations\n\nimport contextlib\nimport json\nfrom typing import Any\n\n\ndef _safe_int_list(value: Any) -> list[int]:\n    if value is None:\n        return []\n    if isinstance(value, list):\n        out: list[int] = []\n        for item in value:\n            with contextlib.suppress(Exception):\n                out.append(int(item))\n        return out\n    # 兼容数据库中存的 JSON 字符串\n    if isinstance(value, str):\n        try:\n            parsed = json.loads(value)\n            return _safe_int_list(parsed)\n        except Exception:\n            return []\n    return []\n\n\ndef _normalize_reminder_offsets(value: Any) -> list[int] | None:\n    if value is None:\n        return None\n    offsets = _safe_int_list(value)\n    cleaned = sorted({offset for offset in offsets if offset >= 0})\n    return cleaned\n\n\ndef _serialize_reminder_offsets(value: Any) -> str | None:\n    normalized = _normalize_reminder_offsets(value)\n    if normalized is None:\n        return None\n    return json.dumps(normalized)\n\n\ndef _normalize_percent(value: Any) -> int:\n    if value is None:\n        return 0\n    try:\n        percent = int(value)\n    except Exception:\n        return 0\n    return max(0, min(100, percent))\n"
  },
  {
    "path": "lifetrace/util/app_utils.py",
    "content": "\"\"\"\n应用工具模块\n合并了应用名称映射和应用图标映射功能\n提供跨平台应用名称到进程名的映射，以及应用名称到图标文件的映射\n\"\"\"\n\nimport platform\n\n# ==================== 应用图标映射 ====================\n# 应用名称(小写) -> 图标文件名\n# 支持 .exe 文件名、应用显示名、中文名等多种形式\nAPP_ICON_MAPPING = {\n    # 浏览器\n    \"chrome.exe\": \"chrome.png\",\n    \"chrome\": \"chrome.png\",\n    \"google chrome\": \"chrome.png\",\n    \"msedge.exe\": \"edge.png\",\n    \"edge\": \"edge.png\",\n    \"edge.exe\": \"edge.png\",\n    \"microsoft edge\": \"edge.png\",\n    \"firefox.exe\": \"firefox.png\",\n    \"firefox\": \"firefox.png\",\n    \"mozilla firefox\": \"firefox.png\",\n    # 开发工具\n    \"code.exe\": \"vscode.png\",\n    \"code\": \"vscode.png\",\n    \"vscode\": \"vscode.png\",\n    \"visual studio code\": \"vscode.png\",\n    \"pycharm64.exe\": \"pycharm.png\",\n    \"pycharm.exe\": \"pycharm.png\",\n    \"pycharm\": \"pycharm.png\",\n    \"idea64.exe\": \"intellij.png\",\n    \"intellij\": \"intellij.png\",\n    \"intellij idea\": \"intellij.png\",\n    \"webstorm64.exe\": \"webstorm.png\",\n    \"webstorm.exe\": \"webstorm.png\",\n    \"webstorm\": \"webstorm.png\",\n    \"githubdesktop.exe\": \"github.png\",\n    \"github desktop\": \"github.png\",\n    \"github\": \"github.png\",\n    # 通讯工具\n    \"wechat.exe\": \"wechat.png\",\n    \"weixin.exe\": \"wechat.png\",\n    \"wechat\": \"wechat.png\",\n    \"weixin\": \"wechat.png\",\n    \"微信\": \"wechat.png\",\n    \"qq.exe\": \"qq.png\",\n    \"qq\": \"qq.png\",\n    \"telegram.exe\": \"telegram.png\",\n    \"telegram\": \"telegram.png\",\n    \"discord.exe\": \"discord.png\",\n    \"discord\": \"discord.png\",\n    # Office 套件\n    \"winword.exe\": \"word.png\",\n    \"word\": \"word.png\",\n    \"microsoft word\": \"word.png\",\n    \"excel.exe\": \"excel.png\",\n    \"excel\": \"excel.png\",\n    \"microsoft excel\": \"excel.png\",\n    \"powerpnt.exe\": \"powerpoint.png\",\n    \"powerpoint.exe\": \"powerpoint.png\",\n    \"powerpoint\": \"powerpoint.png\",\n    \"microsoft powerpoint\": \"powerpoint.png\",\n    \"wps.exe\": \"wps.png\",\n    \"wps\": \"wps.png\",\n    \"wpp.exe\": \"powerpoint.png\",  # WPS演示\n    \"et.exe\": \"excel.png\",  # WPS表格\n    # 设计工具\n    \"photoshop.exe\": \"photoshop.png\",\n    \"photoshop\": \"photoshop.png\",\n    \"xmind.exe\": \"xmind.png\",\n    \"xmind\": \"xmind.png\",\n    \"snipaste.exe\": \"snipaste.png\",\n    \"snipaste\": \"snipaste.png\",\n    # 媒体工具\n    \"spotify.exe\": \"spotify.png\",\n    \"spotify\": \"spotify.png\",\n    \"vlc.exe\": \"vlc.png\",\n    \"vlc\": \"vlc.png\",\n    # macOS 应用\n    \"finder\": \"explorer.png\",\n    \"访达\": \"explorer.png\",\n    \"iterm2\": \"vscode.png\",\n    \"iterm\": \"vscode.png\",\n    \"terminal\": \"vscode.png\",\n    \"cursor\": \"cursor.png\",\n    \"cursor.exe\": \"cursor.png\",\n    \"chatgpt\": \"chrome.png\",\n    \"chatgpt atlas\": \"chrome.png\",\n    \"chatgpt desktop\": \"chrome.png\",\n    \"atlas\": \"chrome.png\",  # ChatGPT Atlas 的简称\n    # 飞书相关\n    \"feishu\": \"feishu.png\",\n    \"feishu.exe\": \"feishu.png\",\n    \"lark\": \"feishu.png\",\n    \"lark.exe\": \"feishu.png\",\n    \"飞书\": \"feishu.png\",\n    \"飞书会议\": \"feishu.png\",\n    # 系统工具\n    \"explorer.exe\": \"explorer.png\",\n    \"explorer\": \"explorer.png\",\n    \"file explorer\": \"explorer.png\",\n    \"文件资源管理器\": \"explorer.png\",\n    \"notepad.exe\": \"notepad.png\",\n    \"notepad\": \"notepad.png\",\n    \"记事本\": \"notepad.png\",\n    \"calc.exe\": \"calculator.png\",\n    \"calculator.exe\": \"calculator.png\",\n    \"calculator\": \"calculator.png\",\n    \"计算器\": \"calculator.png\",\n}\n\n\ndef get_icon_filename(app_name):\n    \"\"\"\n    根据应用名称获取图标文件名\n\n    Args:\n        app_name: 应用名称（支持exe文件名、显示名等）\n\n    Returns:\n        图标文件名，如果未找到返回None\n    \"\"\"\n    if not app_name:\n        return None\n\n    # 转为小写进行匹配\n    app_name_lower = app_name.lower().strip()\n\n    # 精确匹配\n    if app_name_lower in APP_ICON_MAPPING:\n        return APP_ICON_MAPPING[app_name_lower]\n\n    # 模糊匹配（部分包含）\n    for key, icon_file in APP_ICON_MAPPING.items():\n        if key in app_name_lower or app_name_lower in key:\n            return icon_file\n\n    return None\n\n\ndef get_all_supported_apps():\n    \"\"\"获取所有支持的应用列表\"\"\"\n    return sorted(set(APP_ICON_MAPPING.values()))\n\n\n# ==================== 应用名称映射 ====================\n# 跨平台应用名称映射字典\n# key: 友好的应用名称（用户配置时使用）\n# value: 字典，包含各平台的进程名列表\nAPP_MAPPING: dict[str, dict[str, list[str]]] = {\n    # 即时通讯软件\n    \"微信\": {\n        \"Windows\": [\"WeChat.exe\", \"Weixin.exe\", \"微信.exe\"],\n        \"Darwin\": [\"WeChat\", \"微信\"],  # macOS 可能返回中文或英文名\n        \"Linux\": [\"wechat\", \"electronic-wechat\"],\n    },\n    \"WeChat\": {\n        \"Windows\": [\"WeChat.exe\", \"Weixin.exe\", \"微信.exe\"],\n        \"Darwin\": [\"WeChat\", \"微信\"],  # macOS 可能返回中文或英文名\n        \"Linux\": [\"wechat\", \"electronic-wechat\"],\n    },\n    \"QQ\": {\n        \"Windows\": [\"QQ.exe\", \"QQScLauncher.exe\"],\n        \"Darwin\": [\"QQ\"],\n        \"Linux\": [\"qq\", \"linuxqq\"],\n    },\n    \"钉钉\": {\n        \"Windows\": [\"DingTalk.exe\", \"钉钉.exe\"],\n        \"Darwin\": [\"DingTalk\", \"钉钉\"],  # macOS 可能返回中文或英文名\n        \"Linux\": [\"dingtalk\"],\n    },\n    \"企业微信\": {\n        \"Windows\": [\"WXWork.exe\", \"企业微信.exe\"],\n        \"Darwin\": [\"企业微信\", \"WeCom\"],  # macOS 可能返回中文或英文名\n        \"Linux\": [\"wxwork\"],\n    },\n    \"飞书\": {\n        \"Windows\": [\"Feishu.exe\", \"Lark.exe\", \"飞书.exe\"],\n        \"Darwin\": [\"Feishu\", \"Lark\", \"飞书\"],  # macOS 可能返回中文或英文名\n        \"Linux\": [\"feishu\", \"lark\"],\n    },\n    \"Telegram\": {\n        \"Windows\": [\"Telegram.exe\"],\n        \"Darwin\": [\"Telegram\"],\n        \"Linux\": [\"telegram-desktop\", \"telegram\"],\n    },\n    \"Discord\": {\n        \"Windows\": [\"Discord.exe\"],\n        \"Darwin\": [\"Discord\"],\n        \"Linux\": [\"discord\", \"Discord\"],\n    },\n    # 办公软件\n    \"记事本\": {\n        \"Windows\": [\"notepad.exe\"],\n        \"Darwin\": [\"TextEdit\"],\n        \"Linux\": [\"gedit\", \"kate\", \"nano\", \"vim\"],\n    },\n    \"计算器\": {\n        \"Windows\": [\"calc.exe\", \"calculator.exe\"],\n        \"Darwin\": [\"Calculator\"],\n        \"Linux\": [\"gnome-calculator\", \"kcalc\", \"galculator\"],\n    },\n    \"Word\": {\n        \"Windows\": [\"WINWORD.EXE\", \"winword.exe\"],\n        \"Darwin\": [\"Microsoft Word\"],\n        \"Linux\": [\"libreoffice-writer\", \"writer\"],\n    },\n    \"Excel\": {\n        \"Windows\": [\"EXCEL.EXE\", \"excel.exe\"],\n        \"Darwin\": [\"Microsoft Excel\"],\n        \"Linux\": [\"libreoffice-calc\", \"calc\"],\n    },\n    \"PowerPoint\": {\n        \"Windows\": [\"POWERPNT.EXE\", \"powerpnt.exe\"],\n        \"Darwin\": [\"Microsoft PowerPoint\"],\n        \"Linux\": [\"libreoffice-impress\", \"impress\"],\n    },\n    \"WPS\": {\n        \"Windows\": [\"wps.exe\", \"et.exe\", \"wpp.exe\"],\n        \"Darwin\": [\"WPS Office\"],\n        \"Linux\": [\"wps\", \"et\", \"wpp\"],\n    },\n    # 浏览器\n    \"Chrome\": {\n        \"Windows\": [\"chrome.exe\"],\n        \"Darwin\": [\"Google Chrome\"],\n        \"Linux\": [\"google-chrome\", \"chrome\"],\n    },\n    \"Firefox\": {\n        \"Windows\": [\"firefox.exe\"],\n        \"Darwin\": [\"Firefox\"],\n        \"Linux\": [\"firefox\"],\n    },\n    \"Edge\": {\n        \"Windows\": [\"msedge.exe\"],\n        \"Darwin\": [\"Microsoft Edge\"],\n        \"Linux\": [\"microsoft-edge\"],\n    },\n    \"Safari\": {\"Windows\": [\"Safari.exe\"], \"Darwin\": [\"Safari\"], \"Linux\": []},\n    # 开发工具\n    \"VS Code\": {\n        \"Windows\": [\"Code.exe\"],\n        \"Darwin\": [\"Visual Studio Code\"],\n        \"Linux\": [\"code\"],\n    },\n    \"VSCode\": {\n        \"Windows\": [\"Code.exe\"],\n        \"Darwin\": [\"Visual Studio Code\"],\n        \"Linux\": [\"code\"],\n    },\n    \"PyCharm\": {\n        \"Windows\": [\"pycharm64.exe\", \"pycharm.exe\"],\n        \"Darwin\": [\"PyCharm\"],\n        \"Linux\": [\"pycharm\"],\n    },\n    \"IntelliJ IDEA\": {\n        \"Windows\": [\"idea64.exe\", \"idea.exe\"],\n        \"Darwin\": [\"IntelliJ IDEA\"],\n        \"Linux\": [\"idea\"],\n    },\n    # 媒体软件\n    \"网易云音乐\": {\n        \"Windows\": [\"cloudmusic.exe\"],\n        \"Darwin\": [\"NeteaseMusic\"],\n        \"Linux\": [\"netease-cloud-music\"],\n    },\n    \"QQ音乐\": {\"Windows\": [\"QQMusic.exe\"], \"Darwin\": [\"QQMusic\"], \"Linux\": [\"qqmusic\"]},\n    \"VLC\": {\"Windows\": [\"vlc.exe\"], \"Darwin\": [\"VLC\"], \"Linux\": [\"vlc\"]},\n    # 游戏平台\n    \"Steam\": {\"Windows\": [\"steam.exe\"], \"Darwin\": [\"Steam\"], \"Linux\": [\"steam\"]},\n    \"Epic Games\": {\n        \"Windows\": [\"EpicGamesLauncher.exe\"],\n        \"Darwin\": [\"Epic Games Launcher\"],\n        \"Linux\": [\"epic-games-launcher\"],\n    },\n    # 系统工具\n    \"任务管理器\": {\n        \"Windows\": [\"Taskmgr.exe\"],\n        \"Darwin\": [\"Activity Monitor\"],\n        \"Linux\": [\"gnome-system-monitor\", \"htop\", \"top\"],\n    },\n    \"命令提示符\": {\n        \"Windows\": [\"cmd.exe\"],\n        \"Darwin\": [\"Terminal\"],\n        \"Linux\": [\"gnome-terminal\", \"konsole\", \"xterm\"],\n    },\n    \"PowerShell\": {\n        \"Windows\": [\"powershell.exe\", \"pwsh.exe\"],\n        \"Darwin\": [\"pwsh\"],\n        \"Linux\": [\"pwsh\"],\n    },\n    # 安全软件\n    \"360安全卫士\": {\"Windows\": [\"360Safe.exe\", \"360sd.exe\"], \"Darwin\": [], \"Linux\": []},\n    \"腾讯电脑管家\": {\n        \"Windows\": [\"QQPCMgr.exe\", \"QQPCRTP.exe\"],\n        \"Darwin\": [],\n        \"Linux\": [],\n    },\n    # 下载工具\n    \"迅雷\": {\n        \"Windows\": [\"Thunder.exe\", \"ThunderVIP.exe\"],\n        \"Darwin\": [\"Thunder\"],\n        \"Linux\": [],\n    },\n    \"百度网盘\": {\n        \"Windows\": [\"BaiduNetdisk.exe\"],\n        \"Darwin\": [\"BaiduNetdisk\"],\n        \"Linux\": [\"baidunetdisk\"],\n    },\n}\n\n\nclass AppMapper:\n    \"\"\"应用名称映射器\"\"\"\n\n    def __init__(self):\n        self._process_cache: dict[str, set[str]] = {}\n\n    def get_process_names(self, app_name: str) -> list[str]:\n        \"\"\"\n        根据应用名称获取所有平台的进程名列表（合并去重）\n\n        Args:\n            app_name: 友好的应用名称\n\n        Returns:\n            所有平台进程名的合并列表，如果应用不存在则返回空列表\n        \"\"\"\n        if app_name not in APP_MAPPING:\n            return []\n\n        # 使用缓存提高性能\n        if app_name in self._process_cache:\n            return list(self._process_cache[app_name])\n\n        # 合并所有平台的进程名\n        all_processes = set()\n        platform_mapping = APP_MAPPING[app_name]\n        for platform_processes in platform_mapping.values():\n            all_processes.update(platform_processes)\n\n        # 缓存结果\n        self._process_cache[app_name] = all_processes\n        return list(all_processes)\n\n    def expand_app_names(self, app_names: list[str]) -> list[str]:\n        \"\"\"\n        将友好的应用名称列表扩展为实际的进程名列表\n\n        Args:\n            app_names: 友好的应用名称列表\n\n        Returns:\n            扩展后的进程名列表（去重）\n        \"\"\"\n        expanded_names = set()\n\n        for app_name in app_names:\n            # 如果是友好名称，获取对应的进程名\n            process_names = self.get_process_names(app_name)\n            if process_names:\n                expanded_names.update(process_names)\n            else:\n                # 如果不是友好名称，直接添加（可能是用户直接配置的进程名）\n                expanded_names.add(app_name)\n\n        return list(expanded_names)\n\n    def get_supported_apps(self) -> list[str]:\n        \"\"\"\n        获取支持的应用名称列表\n\n        Returns:\n            支持的友好应用名称列表\n        \"\"\"\n        return list(APP_MAPPING.keys())\n\n    def get_app_info(self, app_name: str) -> dict[str, list[str]]:\n        \"\"\"\n        获取应用在所有平台的进程名信息\n\n        Args:\n            app_name: 友好的应用名称\n\n        Returns:\n            包含所有平台进程名的字典\n        \"\"\"\n        return APP_MAPPING.get(app_name, {})\n\n    def is_supported_app(self, app_name: str) -> bool:\n        \"\"\"\n        检查是否为支持的应用名称\n\n        Args:\n            app_name: 应用名称\n\n        Returns:\n            是否为支持的应用名称\n        \"\"\"\n        return app_name in APP_MAPPING\n\n\n# 全局应用映射器实例\napp_mapper = AppMapper()\n\n\ndef get_process_names_for_app(app_name: str) -> list[str]:\n    \"\"\"\n    便捷函数：获取应用对应的进程名列表\n\n    Args:\n        app_name: 友好的应用名称\n\n    Returns:\n        当前平台对应的进程名列表\n    \"\"\"\n    return app_mapper.get_process_names(app_name)\n\n\ndef expand_blacklist_apps(app_names: list[str]) -> list[str]:\n    \"\"\"\n    便捷函数：扩展黑名单应用列表\n\n    Args:\n        app_names: 友好的应用名称列表\n\n    Returns:\n        扩展后的进程名列表\n    \"\"\"\n    return app_mapper.expand_app_names(app_names)\n\n\nif __name__ == \"__main__\":\n    # 测试代码\n    print(f\"当前平台: {platform.system()}\")\n    print(f\"支持的应用数量: {len(APP_MAPPING)}\")\n\n    # 测试几个应用\n    test_apps = [\"微信\", \"QQ\", \"Chrome\", \"VS Code\", \"记事本\"]\n\n    for app in test_apps:\n        processes = get_process_names_for_app(app)\n        print(f\"{app}: {processes}\")\n\n    # 测试扩展功能\n    print(\"\\n扩展测试:\")\n    expanded = expand_blacklist_apps([\"微信\", \"Chrome\", \"unknown_app.exe\"])\n    print(f\"扩展结果: {expanded}\")\n\n    # 测试图标映射\n    print(\"\\n图标映射测试:\")\n    test_icons = [\"微信\", \"Chrome\", \"VS Code\", \"WeChat.exe\"]\n    for app in test_icons:\n        icon = get_icon_filename(app)\n        print(f\"{app}: {icon}\")\n"
  },
  {
    "path": "lifetrace/util/base_paths.py",
    "content": "\"\"\"\n基础路径工具模块\n提供不依赖运行时配置的路径获取函数。\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nimport sys\nfrom pathlib import Path\n\n\ndef get_app_root() -> Path:\n    \"\"\"\n    获取应用程序根目录，兼容开发环境 + PyInstaller 打包环境。\n\n    - 开发环境：返回 lifetrace 包所在的项目根（lifetrace/）\n    - 打包环境：返回可执行文件所在目录（backend/，与 _internal 同级别）\n\n    Returns:\n        Path: 应用程序根目录路径\n    \"\"\"\n    # PyInstaller 冻结环境\n    if getattr(sys, \"frozen\", False):\n        # one-folder 模式：EXE 在 backend/lifetrace，内部依赖在 backend/_internal\n        # 返回 backend/ 目录（可执行文件的父目录）\n        exe_dir = Path(sys.executable).resolve().parent\n        return exe_dir\n\n    # 开发环境：当前文件在 lifetrace/util/path_utils.py\n    # 返回 lifetrace/ 目录\n    return Path(__file__).resolve().parent.parent\n\n\ndef get_internal_root() -> Path:\n    \"\"\"\n    获取 PyInstaller 打包后的内部资源根目录（_internal），\n    开发环境下则退化为 app_root。\n\n    Returns:\n        Path: 内部资源根目录路径\n    \"\"\"\n    app_root = get_app_root()\n    if getattr(sys, \"frozen\", False):\n        # 打包结构：backend/\n        #   - lifetrace        (可执行文件)\n        #   - _internal/       (所有依赖和 data)\n        internal = app_root / \"_internal\"\n        if internal.exists():\n            return internal\n    return app_root\n\n\ndef get_config_dir() -> Path:\n    \"\"\"\n    获取内置配置所在目录（default_config.yaml, prompt.yaml, rapidocr_config.yaml 等）。\n\n    - 开发环境：lifetrace/config/\n    - 打包环境：backend/config/（与 _internal 同级别，不在 _internal 内）\n\n    Returns:\n        Path: 配置目录路径\n    \"\"\"\n    return get_app_root() / \"config\"\n\n\ndef get_models_dir() -> Path:\n    \"\"\"\n    获取内置模型目录（ONNX 等）。\n\n    - 开发环境：lifetrace/models/\n    - 打包环境：backend/models/（与 _internal 同级别，不在 _internal 内）\n\n    Returns:\n        Path: 模型目录路径\n    \"\"\"\n    return get_app_root() / \"models\"\n\n\ndef get_data_directory() -> Path | None:\n    \"\"\"\n    获取用户数据目录路径（从环境变量）。\n\n    如果设置了 LIFETRACE_DATA_DIR，返回该路径；\n    否则返回 None（表示使用应用目录）。\n\n    Returns:\n        Path | None: 用户数据目录路径，如果未设置则返回 None\n    \"\"\"\n    data_dir = os.environ.get(\"LIFETRACE_DATA_DIR\")\n    if data_dir:\n        return Path(data_dir).resolve()\n    return None\n\n\ndef get_user_config_dir() -> Path:\n    \"\"\"\n    获取用户配置目录（数据目录下的 config）。\n\n    如果设置了 LIFETRACE_DATA_DIR，返回 {data_dir}/config/；\n    否则返回应用目录下的 config/。\n\n    Returns:\n        Path: 用户配置目录路径\n    \"\"\"\n    data_dir = get_data_directory()\n    if data_dir:\n        return data_dir / \"config\"\n    return get_config_dir()\n\n\ndef get_user_data_dir() -> Path:\n    \"\"\"\n    获取用户数据目录（数据目录下的 data）。\n\n    如果设置了 LIFETRACE_DATA_DIR，返回 {data_dir}/data/；\n    否则返回应用目录下的 data/。\n\n    Returns:\n        Path: 用户数据目录路径\n    \"\"\"\n    data_dir = get_data_directory()\n    if data_dir:\n        return data_dir / \"data\"\n    return get_app_root() / \"data\"\n\n\ndef get_user_logs_dir() -> Path:\n    \"\"\"\n    获取用户日志目录（数据目录下的 logs）。\n\n    如果设置了 LIFETRACE_DATA_DIR，返回 {data_dir}/logs/；\n    否则返回应用目录下的 logs/。\n\n    Returns:\n        Path: 用户日志目录路径\n    \"\"\"\n    data_dir = get_data_directory()\n    if data_dir:\n        return data_dir / \"logs\"\n    return get_app_root() / \"logs\"\n"
  },
  {
    "path": "lifetrace/util/image_utils.py",
    "content": "\"\"\"图片处理工具函数\"\"\"\n\nimport base64\nimport os\nfrom typing import Any\n\nfrom lifetrace.storage import screenshot_mgr\nfrom lifetrace.util.logging_config import get_logger\n\nlogger = get_logger()\n\n\ndef get_screenshot_base64(screenshot_id: int) -> str | None:\n    \"\"\"\n    从数据库读取截图并转换为base64编码\n\n    Args:\n        screenshot_id: 截图ID\n\n    Returns:\n        base64编码的图片字符串（格式：data:image/png;base64,{base64_str}），\n        如果截图不存在或读取失败则返回None\n    \"\"\"\n    try:\n        # 从数据库获取截图信息\n        screenshot = screenshot_mgr.get_screenshot_by_id(screenshot_id)\n        if not screenshot:\n            logger.warning(f\"截图 {screenshot_id} 不存在\")\n            return None\n\n        file_path = screenshot.get(\"file_path\")\n        if not file_path:\n            logger.warning(f\"截图 {screenshot_id} 没有文件路径\")\n            return None\n\n        # 检查文件是否存在\n        if not os.path.exists(file_path):\n            logger.warning(f\"截图文件不存在: {file_path}\")\n            return None\n\n        # 读取文件并转换为base64\n        with open(file_path, \"rb\") as f:\n            image_data = f.read()\n\n        # 转换为base64\n        base64_str = base64.b64encode(image_data).decode(\"utf-8\")\n\n        # 根据文件扩展名确定MIME类型\n        file_ext = os.path.splitext(file_path)[1].lower()\n        mime_type_map = {\n            \".png\": \"image/png\",\n            \".jpg\": \"image/jpeg\",\n            \".jpeg\": \"image/jpeg\",\n            \".gif\": \"image/gif\",\n            \".webp\": \"image/webp\",\n        }\n        mime_type = mime_type_map.get(file_ext, \"image/png\")\n\n        # 返回data URI格式\n        return f\"data:{mime_type};base64,{base64_str}\"\n\n    except Exception as e:\n        logger.error(f\"读取截图 {screenshot_id} 并转换为base64失败: {e}\")\n        return None\n\n\ndef get_screenshots_base64(screenshot_ids: list[int]) -> list[dict[str, Any]]:\n    \"\"\"\n    批量获取截图的base64编码\n\n    Args:\n        screenshot_ids: 截图ID列表\n\n    Returns:\n        包含截图信息的列表，每个元素包含：\n        - screenshot_id: 截图ID\n        - base64_data: base64编码的图片字符串（如果成功）\n        - error: 错误信息（如果失败）\n    \"\"\"\n    results = []\n    for screenshot_id in screenshot_ids:\n        base64_data = get_screenshot_base64(screenshot_id)\n        if base64_data:\n            results.append({\"screenshot_id\": screenshot_id, \"base64_data\": base64_data})\n        else:\n            results.append(\n                {\n                    \"screenshot_id\": screenshot_id,\n                    \"error\": f\"截图 {screenshot_id} 读取失败\",\n                }\n            )\n    return results\n\n\ndef validate_image_format(file_path: str) -> bool:\n    \"\"\"\n    验证图片格式是否支持\n\n    Args:\n        file_path: 图片文件路径\n\n    Returns:\n        如果格式支持返回True，否则返回False\n    \"\"\"\n    supported_formats = {\".png\", \".jpg\", \".jpeg\", \".gif\", \".webp\"}\n    file_ext = os.path.splitext(file_path)[1].lower()\n    return file_ext in supported_formats\n"
  },
  {
    "path": "lifetrace/util/language.py",
    "content": "\"\"\"Language utility functions: parse request language and generate language instructions\"\"\"\n\nfrom fastapi import Request\n\n# Language instruction mapping - add new languages here\nLANGUAGE_INSTRUCTIONS: dict[str, str] = {\n    \"zh\": \"\\n\\n**语言要求：请始终使用中文回答。**\",\n    \"en\": \"\\n\\n**Language requirement: Always respond in English.**\",\n    # Future languages can be added here:\n    # \"ja\": \"\\n\\n**言語要件：常に日本語で回答してください。**\",\n    # \"ko\": \"\\n\\n**언어 요구 사항: 항상 한국어로 답변하세요.**\",\n    # \"ru\": \"\\n\\n**Требование к языку: Всегда отвечайте на русском языке.**\",\n    # \"fr\": \"\\n\\n**Exigence linguistique: Répondez toujours en français.**\",\n}\n\n# Supported locales list (derived from LANGUAGE_INSTRUCTIONS keys)\nSUPPORTED_LOCALES: list[str] = list(LANGUAGE_INSTRUCTIONS.keys())\n\n# Default locale when no match is found\nDEFAULT_LOCALE: str = \"en\"\n\n\ndef get_request_language(request: Request) -> str:\n    \"\"\"Parse language from request headers\n\n    Args:\n        request: FastAPI request object\n\n    Returns:\n        Language code (e.g., \"zh\", \"en\")\n    \"\"\"\n    accept_lang = request.headers.get(\"Accept-Language\", DEFAULT_LOCALE).lower()\n\n    # Match against supported locales by prefix\n    for locale in SUPPORTED_LOCALES:\n        if accept_lang.startswith(locale):\n            return locale\n\n    return DEFAULT_LOCALE\n\n\ndef get_language_instruction(lang: str) -> str:\n    \"\"\"Generate language instruction to append to system prompt\n\n    Args:\n        lang: Language code (e.g., \"zh\", \"en\")\n\n    Returns:\n        Language instruction string\n    \"\"\"\n    return LANGUAGE_INSTRUCTIONS.get(lang, LANGUAGE_INSTRUCTIONS[DEFAULT_LOCALE])\n"
  },
  {
    "path": "lifetrace/util/logging_config.py",
    "content": "import os\nimport re\nimport sys\nfrom datetime import UTC, datetime\n\nfrom loguru import logger\n\n\ndef _get_local_date_string() -> str:\n    \"\"\"获取当前本地日期字符串（YYYY-MM-DD）\"\"\"\n    return datetime.now(UTC).astimezone().strftime(\"%Y-%m-%d\")\n\n\ndef _generate_log_file_path(log_dir: str, suffix: str = \"\") -> str:\n    \"\"\"\n    生成带日期和序列号的日志文件路径。\n    格式：YYYY-MM-DD-N{suffix}.log（N 是当天第几次启动，从 0 开始）\n\n    Args:\n        log_dir: 日志目录路径\n        suffix: 文件名后缀（如 \".error\"）\n\n    Returns:\n        完整的日志文件路径\n    \"\"\"\n    date_str = _get_local_date_string()\n    # 匹配当天的日志文件，格式：YYYY-MM-DD-N.log 或 YYYY-MM-DD-N.error.log\n    pattern = re.compile(rf\"^{re.escape(date_str)}-(\\d+){re.escape(suffix)}\\.log$\")\n\n    # 扫描现有日志文件，找出当天的最大序列号\n    max_seq = -1\n    try:\n        if os.path.exists(log_dir):\n            for filename in os.listdir(log_dir):\n                match = pattern.match(filename)\n                if match:\n                    seq = int(match.group(1))\n                    max_seq = max(max_seq, seq)\n    except OSError:\n        pass  # 忽略读取错误\n\n    # 新的序列号 = 最大序列号 + 1\n    new_seq = max_seq + 1\n    filename = f\"{date_str}-{new_seq}{suffix}.log\"\n\n    return os.path.join(log_dir, filename)\n\n\nclass LoggerManager:\n    def __init__(self):\n        logger.remove()\n\n    def _build_filter(self, quiet_modules: list[str] | None):\n        if not quiet_modules:\n            return None\n\n        lowered = [item.lower() for item in quiet_modules if isinstance(item, str)]\n\n        if not lowered:\n            return None\n\n        def _filter(record):\n            name = str(record.get(\"name\", \"\")).lower()\n            module = str(record.get(\"module\", \"\")).lower()\n            function = str(record.get(\"function\", \"\")).lower()\n            file_path = \"\"\n            file_info = record.get(\"file\")\n            if file_info is not None:\n                file_path = str(getattr(file_info, \"path\", \"\")).lower()\n            target = f\"{name} {module} {function} {file_path}\"\n            return not any(item in target for item in lowered)\n\n        return _filter\n\n    def configure(self, config: dict):\n        if \"level\" not in config:\n            raise KeyError(\"配置中缺少 'level' 键\")\n        if \"log_path\" not in config:\n            raise KeyError(\"配置中缺少 'log_path' 键\")\n\n        level = config[\"level\"]\n        console_level = config.get(\"console_level\", level)\n        file_level = config.get(\"file_level\", level)\n        log_path = config[\"log_path\"]\n        quiet_modules = config.get(\"quiet_modules\", [])\n        log_filter = self._build_filter(quiet_modules)\n\n        # 控制台格式（使用本地时间）\n        console_format = (\n            \"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | \"\n            \"<level>{level}</level> | \"\n            \"<cyan>{file}:{line}</cyan> | <cyan>{message}</cyan>\"\n        )\n        logger.add(sys.stderr, level=console_level, format=console_format, filter=log_filter)\n\n        if log_path:\n            # 如果 log_path 是目录或以 / 结尾，直接使用目录作为日志目录\n            if log_path.endswith(os.sep) or log_path.endswith(\"/\"):\n                log_dir = log_path.rstrip(os.sep).rstrip(\"/\")\n                os.makedirs(log_dir, exist_ok=True)\n\n                # 生成带序列号的日志文件名（每次启动生成新文件）\n                log_file_path = _generate_log_file_path(log_dir)\n                error_log_path = _generate_log_file_path(log_dir, \".error\")\n            else:\n                raise ValueError(\"log_path must be a directory\")\n\n            # 文件日志格式（使用本地时间）\n            file_format = \"{time:YYYY-MM-DD HH:mm:ss.SSS} | {level} | {file}:{line} | {message}\"\n\n            # 添加主日志文件（静态文件名，不使用 rotation）\n            logger.add(\n                log_file_path,\n                level=file_level,\n                format=file_format,\n                rotation=None,  # 不自动轮转，每次启动一个新文件\n                retention=7,\n                encoding=\"utf-8\",\n                filter=log_filter,\n            )\n\n            # 添加单独的 error 日志文件\n            logger.add(\n                error_log_path,\n                level=\"ERROR\",\n                format=file_format,\n                rotation=None,  # 不自动轮转\n                retention=30,\n                encoding=\"utf-8\",\n                filter=log_filter,\n            )\n\n    def get_logger(self):\n        return logger\n\n\ndef setup_logging(config: dict):\n    logger_manager = LoggerManager()\n    logger_manager.configure(config)\n    logger.info(\"Logging setup completed\")\n\n\ndef get_logger():\n    return logger\n"
  },
  {
    "path": "lifetrace/util/path_utils.py",
    "content": "\"\"\"\n统一的路径工具模块\n提供兼容开发环境和 PyInstaller 打包环境的路径获取函数\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nfrom pathlib import Path\n\nfrom lifetrace.util import base_paths\nfrom lifetrace.util.settings import settings\n\n# ============================================================\n# 基于配置的路径计算函数\n# ============================================================\n\n\ndef get_database_path() -> Path:\n    \"\"\"获取数据库路径（基于配置和数据目录）\n\n    Returns:\n        Path: 数据库文件的绝对路径\n    \"\"\"\n    db_path = settings.database_path\n    if not os.path.isabs(db_path):\n        return base_paths.get_user_data_dir() / db_path\n    return Path(db_path)\n\n\ndef get_screenshots_dir() -> Path:\n    \"\"\"获取截图目录\n\n    Returns:\n        Path: 截图目录的绝对路径\n    \"\"\"\n    screenshots_dir = settings.screenshots_dir\n    if not os.path.isabs(screenshots_dir):\n        return base_paths.get_user_data_dir() / screenshots_dir\n    return Path(screenshots_dir)\n\n\ndef get_attachments_dir() -> Path:\n    \"\"\"获取附件目录\n\n    Returns:\n        Path: 附件目录的绝对路径\n    \"\"\"\n    attachments_dir = settings.attachments_dir\n    if not os.path.isabs(attachments_dir):\n        return base_paths.get_user_data_dir() / attachments_dir\n    return Path(attachments_dir)\n\n\ndef get_scheduler_database_path() -> Path:\n    \"\"\"获取调度器数据库路径\n\n    Returns:\n        Path: 调度器数据库文件的绝对路径\n    \"\"\"\n    db_path = settings.scheduler.database_path\n    if not os.path.isabs(db_path):\n        return base_paths.get_user_data_dir() / db_path\n    return Path(db_path)\n\n\ndef get_vector_db_dir() -> Path:\n    \"\"\"获取向量数据库目录\n\n    Returns:\n        Path: 向量数据库目录的绝对路径\n    \"\"\"\n    persist_dir = settings.vector_db.persist_directory\n    if not os.path.isabs(persist_dir):\n        return base_paths.get_user_data_dir() / persist_dir\n    return Path(persist_dir)\n\n\ndef get_log_dir() -> Path:\n    \"\"\"获取日志目录（替代原有 log_path 属性）\n\n    Returns:\n        Path: 日志目录的绝对路径\n    \"\"\"\n    return base_paths.get_user_logs_dir()\n\n\n# ============================================================\n# 兼容旧 API 的基础路径函数（转发到 base_paths）\n# ============================================================\n\n\ndef get_app_root() -> Path:\n    \"\"\"获取应用程序根目录。\"\"\"\n    return base_paths.get_app_root()\n\n\ndef get_config_dir() -> Path:\n    \"\"\"获取内置配置目录。\"\"\"\n    return base_paths.get_config_dir()\n\n\ndef get_models_dir() -> Path:\n    \"\"\"获取内置模型目录。\"\"\"\n    return base_paths.get_models_dir()\n\n\ndef get_user_config_dir() -> Path:\n    \"\"\"获取用户配置目录。\"\"\"\n    return base_paths.get_user_config_dir()\n"
  },
  {
    "path": "lifetrace/util/prompt_loader.py",
    "content": "\"\"\"提示词加载器模块\n\n从配置文件中加载 LLM 提示词\n\"\"\"\n\nimport yaml\n\nfrom lifetrace.util.base_paths import get_config_dir\nfrom lifetrace.util.logging_config import get_logger\n\nlogger = get_logger()\n\n\nclass PromptLoader:\n    \"\"\"提示词加载器\"\"\"\n\n    _instance = None\n    _prompts = None\n\n    def __new__(cls):\n        \"\"\"单例模式\"\"\"\n        if cls._instance is None:\n            cls._instance = super().__new__(cls)\n        return cls._instance\n\n    def __init__(self):\n        \"\"\"初始化提示词加载器（延迟加载配置）\"\"\"\n        pass\n\n    def _load_prompts(self):\n        \"\"\"从 prompts/ 目录或 prompt.yaml 文件加载提示词\n\n        优先从 prompts/ 目录加载所有 yaml 文件，如果目录不存在则回退到单个 prompt.yaml 文件。\n        \"\"\"\n        try:\n            config_dir = get_config_dir()\n            prompts_dir = config_dir / \"prompts\"\n            self._prompts = {}\n\n            if prompts_dir.exists() and prompts_dir.is_dir():\n                # 新方案：从 prompts/ 目录加载所有 yaml 文件\n                yaml_files = list(prompts_dir.glob(\"*.yaml\"))\n                if yaml_files:\n                    for yaml_file in yaml_files:\n                        try:\n                            with open(yaml_file, encoding=\"utf-8\") as f:\n                                data = yaml.safe_load(f) or {}\n                                self._prompts.update(data)\n                        except Exception as e:\n                            logger.error(f\"加载提示词文件失败 ({yaml_file.name}): {e}\")\n\n                    logger.info(\n                        f\"提示词配置加载成功，从 {len(yaml_files)} 个文件中加载了 {len(self._prompts)} 个分类\"\n                    )\n                    return\n\n            # 回退方案：加载单个 prompt.yaml 文件\n            prompt_file = config_dir / \"prompt.yaml\"\n            if not prompt_file.exists():\n                logger.error(f\"提示词配置文件不存在: {prompt_file}\")\n                return\n\n            with open(prompt_file, encoding=\"utf-8\") as f:\n                self._prompts = yaml.safe_load(f) or {}\n\n            logger.info(f\"提示词配置加载成功，共 {len(self._prompts)} 个分类\")\n\n        except Exception as e:\n            logger.error(f\"加载提示词配置失败: {e}\")\n            self._prompts = {}\n\n    def get_prompt(self, category: str, key: str, **kwargs) -> str:\n        \"\"\"\n        获取提示词\n\n        Args:\n            category: 提示词分类（如 'rag', 'llm_client', 'event_summary'）\n            key: 提示词键名\n            **kwargs: 格式化参数（用于替换提示词模板中的占位符）\n\n        Returns:\n            格式化后的提示词字符串\n        \"\"\"\n        try:\n            if self._prompts is None:\n                self._load_prompts()\n            if self._prompts is None:\n                self._prompts = {}\n\n            # 获取提示词模板\n            prompt_template = self._prompts.get(category, {}).get(key, \"\")\n\n            if not prompt_template:\n                logger.warning(f\"未找到提示词: {category}.{key}\")\n                return \"\"\n\n            # 如果有格式化参数，进行格式化\n            if kwargs:\n                return prompt_template.format(**kwargs)\n\n            return prompt_template\n\n        except Exception as e:\n            logger.error(f\"获取提示词失败 ({category}.{key}): {e}\")\n            return \"\"\n\n    def reload(self):\n        \"\"\"重新加载提示词配置\"\"\"\n        logger.info(\"重新加载提示词配置...\")\n        self._load_prompts()\n\n\n# 创建全局单例实例\nprompt_loader = PromptLoader()\n\n\ndef get_prompt(category: str, key: str, **kwargs) -> str:\n    \"\"\"\n    便捷函数：获取提示词\n\n    Args:\n        category: 提示词分类\n        key: 提示词键名\n        **kwargs: 格式化参数\n\n    Returns:\n        格式化后的提示词字符串\n    \"\"\"\n    return prompt_loader.get_prompt(category, key, **kwargs)\n"
  },
  {
    "path": "lifetrace/util/query_parser.py",
    "content": "\"\"\"查询解析器模块\n\n将自然语言查询转换为结构化的数据库查询条件。\n使用LLM理解用户意图并提取查询参数。\n\"\"\"\n\nimport re\nfrom dataclasses import dataclass\nfrom datetime import datetime, timedelta\nfrom typing import Any\n\nfrom lifetrace.util.app_utils import app_mapper\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.time_utils import get_utc_now, to_utc\n\nlogger = get_logger()\n\n\n@dataclass\nclass QueryConditions:\n    \"\"\"查询条件数据类\"\"\"\n\n    # 时间范围\n    start_date: datetime | None = None\n    end_date: datetime | None = None\n\n    # 应用过滤\n    app_names: list[str] | None = None\n\n    # 文本内容\n    keywords: list[str] | None = None\n\n    # 项目过滤\n    project_id: int | None = None\n\n    # 其他条件\n    limit: int = 1000\n\n    def to_dict(self) -> dict[str, Any]:\n        \"\"\"转换为字典格式\"\"\"\n        result = {}\n\n        if self.start_date:\n            result[\"start_date\"] = self.start_date\n        if self.end_date:\n            result[\"end_date\"] = self.end_date\n        if self.app_names:\n            result[\"app_names\"] = self.app_names\n        if self.keywords:\n            result[\"keywords\"] = self.keywords\n        if self.project_id:\n            result[\"project_id\"] = self.project_id\n        if self.limit:\n            result[\"limit\"] = self.limit\n\n        return result\n\n\nclass QueryParser:\n    \"\"\"查询解析器\"\"\"\n\n    def __init__(self, llm_client=None):\n        self.llm_client = llm_client\n\n        # 应用名称映射（常见的应用别名）\n        self.app_name_mapping = {\n            \"微信\": [\"WeChat\", \"wechat\", \"微信\"],\n            \"QQ\": [\"QQ\", \"qq\", \"TencentQQ\"],\n            \"浏览器\": [\n                \"Chrome\",\n                \"Firefox\",\n                \"Edge\",\n                \"Safari\",\n                \"chrome\",\n                \"firefox\",\n                \"edge\",\n            ],\n            \"VS Code\": [\"Code\", \"vscode\", \"Visual Studio Code\"],\n            \"记事本\": [\"Notepad\", \"notepad\"],\n            \"Word\": [\"WINWORD\", \"Microsoft Word\", \"word\"],\n            \"Excel\": [\"EXCEL\", \"Microsoft Excel\", \"excel\"],\n            \"PowerPoint\": [\"POWERPNT\", \"Microsoft PowerPoint\", \"powerpoint\", \"ppt\"],\n        }\n\n        # 时间关键词映射\n        self.time_keywords = {\n            \"今天\": 0,\n            \"昨天\": 1,\n            \"前天\": 2,\n            \"本周\": 7,\n            \"上周\": 14,\n            \"本月\": 30,\n            \"上月\": 60,\n        }\n\n    def parse_query(self, query: str) -> QueryConditions:\n        \"\"\"解析自然语言查询\n\n        Args:\n            query: 自然语言查询字符串\n\n        Returns:\n            QueryConditions: 解析后的查询条件\n        \"\"\"\n        logger.info(f\"解析查询: {query}\")\n\n        # 如果有LLM客户端，使用LLM解析\n        if self.llm_client:\n            try:\n                parsed_data = self.llm_client.parse_query(query)\n\n                # 检查LLM解析结果是否有效（至少有一个有用的字段）\n                has_keywords = parsed_data.get(\"keywords\") and len(parsed_data[\"keywords\"]) > 0\n                has_app_names = parsed_data.get(\"app_names\") and len(parsed_data[\"app_names\"]) > 0\n                has_time_info = parsed_data.get(\"start_date\") or parsed_data.get(\"end_date\")\n\n                if has_keywords or has_app_names or has_time_info:\n                    logger.info(\"LLM解析结果有效，构建QueryConditions\")\n                    try:\n                        result = self._build_query_conditions(parsed_data)\n                        logger.info(\"=== 最终查询条件 (LLM解析) ===\")\n                        logger.info(f\"查询条件: {result}\")\n                        return result\n                    except Exception as e:\n                        logger.warning(f\"构建查询条件失败: {e}\")\n                        pass\n                else:\n                    logger.warning(\"缺乏有效查询条件\")\n                    # return \"缺乏有效查询条件\"\n            except Exception as e:\n                logger.warning(f\"LLM解析失败: {e}\")\n\n        # 回退到规则解析\n        result = self._parse_with_rules(query)\n        logger.info(\"=== 最终查询条件 (规则解析) ===\")\n        logger.info(f\"查询条件: {result}\")\n        return result\n\n    def _parse_with_rules(self, query: str) -> QueryConditions:\n        \"\"\"使用规则解析查询\"\"\"\n        conditions = QueryConditions()\n\n        # 解析时间\n        conditions.start_date, conditions.end_date = self._extract_time_range(query)\n\n        # 解析应用名称\n        conditions.app_names = self._extract_app_names(query)\n\n        # 解析关键词\n        conditions.keywords = self._extract_keywords(query)\n\n        return conditions\n\n    def _extract_time_range(self, query: str) -> tuple[datetime | None, datetime | None]:\n        \"\"\"提取时间范围\"\"\"\n        now = get_utc_now().astimezone()\n        start_date = None\n        end_date = None\n\n        # 检查时间关键词\n        for keyword, days_ago in self.time_keywords.items():\n            if keyword in query:\n                if keyword in [\"今天\"]:\n                    start_date = now.replace(hour=0, minute=0, second=0, microsecond=0)\n                    end_date = now\n                elif keyword in [\"昨天\"]:\n                    yesterday = now - timedelta(days=1)\n                    start_date = yesterday.replace(hour=0, minute=0, second=0, microsecond=0)\n                    end_date = yesterday.replace(hour=23, minute=59, second=59, microsecond=999999)\n                elif keyword in [\"本周\"]:\n                    # 本周一开始\n                    days_since_monday = now.weekday()\n                    start_date = (now - timedelta(days=days_since_monday)).replace(\n                        hour=0, minute=0, second=0, microsecond=0\n                    )\n                    end_date = now\n                else:\n                    start_date = now - timedelta(days=days_ago)\n                    end_date = now\n                break\n\n        # 检查具体日期格式（如：2024-01-01）\n        date_pattern = r\"(\\d{4}[-/]\\d{1,2}[-/]\\d{1,2})\"\n        dates = re.findall(date_pattern, query)\n        if dates:\n            try:\n                date_str = dates[0].replace(\"/\", \"-\")\n                parsed_date = datetime.strptime(date_str, \"%Y-%m-%d\").astimezone()\n                start_date = parsed_date.replace(hour=0, minute=0, second=0, microsecond=0)\n                end_date = parsed_date.replace(hour=23, minute=59, second=59, microsecond=999999)\n            except ValueError:\n                pass\n\n        return (\n            to_utc(start_date) if start_date else None,\n            to_utc(end_date) if end_date else None,\n        )\n\n    def _find_app_names_from_mappings(self, query: str) -> list[str]:\n        \"\"\"从映射表中查找应用名称\"\"\"\n        friendly_app_names = []\n\n        # 首先检查app_mapping中支持的应用名称\n        for app_name in app_mapper.get_supported_apps():\n            if app_name in query:\n                friendly_app_names.append(app_name)\n\n        # 检查传统的应用别名映射\n        for app_alias in self.app_name_mapping:\n            if app_alias in query and app_alias not in friendly_app_names:\n                friendly_app_names.append(app_alias)\n\n        return friendly_app_names\n\n    def _find_app_names_from_patterns(self, query: str, existing: list[str]) -> list[str]:\n        \"\"\"通过正则模式查找应用名称\"\"\"\n        app_patterns = [\n            r\"在([\\u4e00-\\u9fa5a-zA-Z0-9\\s]+)上\",  # 在XX上\n            r\"([\\u4e00-\\u9fa5a-zA-Z0-9\\s]+)应用\",  # XX应用\n            r\"([\\u4e00-\\u9fa5a-zA-Z0-9\\s]+)软件\",  # XX软件\n        ]\n\n        found_names = list(existing)\n        for pattern in app_patterns:\n            matches = re.findall(pattern, query)\n            for match in matches:\n                app_name = match.strip()\n                if app_name and app_name not in found_names:\n                    found_names.append(app_name)\n\n        return found_names\n\n    def _convert_to_process_names(self, friendly_names: list[str]) -> list[str]:\n        \"\"\"将友好名称转换为实际进程名\"\"\"\n        actual_process_names = []\n        for app_name in friendly_names:\n            process_names = app_mapper.get_process_names(app_name)\n            if process_names:\n                actual_process_names.extend(process_names)\n            else:\n                actual_process_names.append(app_name)\n        return actual_process_names\n\n    def _extract_app_names(self, query: str) -> list[str] | None:\n        \"\"\"提取应用名称\"\"\"\n        friendly_app_names = self._find_app_names_from_mappings(query)\n        friendly_app_names = self._find_app_names_from_patterns(query, friendly_app_names)\n\n        if friendly_app_names:\n            return self._convert_to_process_names(friendly_app_names)\n\n        return None\n\n    def _extract_keywords(self, query: str) -> list[str] | None:\n        \"\"\"提取关键词 - 改进版本，区分功能描述词和搜索关键词\"\"\"\n        keywords = []\n\n        # 检查是否有明确的搜索意图\n        search_indicators = [\"搜索\", \"查找\", \"包含\", \"关于\", \"找到\", \"寻找\"]\n        has_search_intent = any(indicator in query for indicator in search_indicators)\n\n        # 如果没有搜索意图，直接返回None\n        if not has_search_intent:\n            return None\n\n        # 功能描述词列表（这些词不应该作为搜索关键词）\n        function_words = [\n            \"聊天\",\n            \"浏览\",\n            \"编辑\",\n            \"查看\",\n            \"打开\",\n            \"使用\",\n            \"运行\",\n            \"操作\",\n            \"活动\",\n            \"记录\",\n            \"情况\",\n            \"状态\",\n        ]\n\n        # 停用词列表\n        stop_words = [\n            \"帮我\",\n            \"总结\",\n            \"一下\",\n            \"查找\",\n            \"搜索\",\n            \"显示\",\n            \"看看\",\n            \"的\",\n            \"在\",\n            \"上\",\n            \"中\",\n            \"里\",\n            \"包含\",\n            \"关于\",\n            \"找到\",\n            \"寻找\",\n        ]\n\n        # 创建查询副本用于处理\n        processed_query = query\n\n        # 移除时间词汇\n        for time_word in self.time_keywords:\n            processed_query = processed_query.replace(time_word, \"\")\n\n        # 移除应用名称\n        for app_alias in self.app_name_mapping:\n            processed_query = processed_query.replace(app_alias, \"\")\n\n        # 分词并过滤\n        words = re.findall(r\"[\\u4e00-\\u9fa5a-zA-Z0-9]+\", processed_query)\n        for word in words:\n            if word not in stop_words and word not in function_words and len(word) > 1:\n                keywords.append(word)\n\n        return keywords if keywords else None\n\n    def _build_parsing_prompt(self, query: str) -> str:\n        \"\"\"构建LLM解析提示词\"\"\"\n        return f\"\"\"请解析以下自然语言查询，提取出结构化的查询条件。\n\n查询: {query}\n\n请返回JSON格式的结果，包含以下字段：\n- time_range: {{\"start\": \"YYYY-MM-DD HH:MM:SS\", \"end\": \"YYYY-MM-DD HH:MM:SS\"}} 或 null\n- app_names: [\"应用名称1\", \"应用名称2\"] 或 null\n- keywords: [\"关键词1\", \"关键词2\"] 或 null\n\n常见应用名称映射：\n- 微信: WeChat\n- QQ: QQ\n- 浏览器: Chrome, Firefox, Edge\n- VS Code: Code\n- Word: WINWORD\n- Excel: EXCEL\n\n时间解析规则：\n- 今天: 当天0点到现在\n- 昨天: 昨天全天\n- 本周: 本周一到现在\n- 具体日期: 如2024-01-01\n\n只返回JSON，不要其他解释。\"\"\"\n\n    def _parse_datetime_safe(self, date_str: str | None) -> datetime | None:\n        \"\"\"安全地解析日期时间字符串\"\"\"\n        if not date_str:\n            return None\n        try:\n            return datetime.fromisoformat(date_str)\n        except (ValueError, TypeError):\n            return None\n\n    def _extract_time_from_parsed_data(\n        self, parsed_data: dict[str, Any]\n    ) -> tuple[datetime | None, datetime | None]:\n        \"\"\"从解析数据中提取时间范围\"\"\"\n        if parsed_data.get(\"time_range\"):\n            time_range = parsed_data[\"time_range\"]\n            return (\n                self._parse_datetime_safe(time_range.get(\"start\")),\n                self._parse_datetime_safe(time_range.get(\"end\")),\n            )\n        return (\n            self._parse_datetime_safe(parsed_data.get(\"start_date\")),\n            self._parse_datetime_safe(parsed_data.get(\"end_date\")),\n        )\n\n    def _build_query_conditions(self, parsed_data: dict[str, Any]) -> QueryConditions:\n        \"\"\"从解析数据构建查询条件\"\"\"\n        conditions = QueryConditions()\n\n        # 处理时间范围\n        conditions.start_date, conditions.end_date = self._extract_time_from_parsed_data(\n            parsed_data\n        )\n\n        # 处理应用名称\n        if parsed_data.get(\"app_names\"):\n            conditions.app_names = self._convert_to_process_names(parsed_data[\"app_names\"])\n            if not conditions.app_names:\n                conditions.app_names = None\n\n        # 处理关键词\n        if parsed_data.get(\"keywords\"):\n            conditions.keywords = parsed_data[\"keywords\"]\n\n        return conditions\n\n\n# 创建全局实例\nquery_parser = QueryParser()\n"
  },
  {
    "path": "lifetrace/util/settings.py",
    "content": "\"\"\"\nDynaconf 配置模块 - 支持热加载的配置管理\n\n使用 Dynaconf 替代自定义配置类，提供：\n- 配置文件热加载 (reload)\n- 环境变量覆盖 (LIFETRACE__ 前缀)\n- 多配置文件合并\n- 配置验证\n\"\"\"\n\nimport shutil\nfrom pathlib import Path\n\nfrom dynaconf import Dynaconf, Validator\n\nfrom lifetrace.util.base_paths import get_config_dir, get_user_config_dir\n\n\ndef _get_config_dir() -> Path:\n    \"\"\"获取配置目录\"\"\"\n    return get_user_config_dir()\n\n\ndef _get_default_config_dir() -> Path:\n    \"\"\"获取内置默认配置目录\"\"\"\n    return get_config_dir()\n\n\ndef _init_config_files() -> list[str]:\n    \"\"\"初始化并返回配置文件列表\n\n    确保用户配置目录存在，如果 config.yaml 不存在则从默认配置复制。\n    返回按加载顺序排列的配置文件路径列表。\n    \"\"\"\n    user_config_dir = _get_config_dir()\n    default_config_dir = _get_default_config_dir()\n\n    # 确保用户配置目录存在\n    user_config_dir.mkdir(parents=True, exist_ok=True)\n\n    # 默认配置文件路径\n    default_config_path = default_config_dir / \"default_config.yaml\"\n    user_default_config_path = user_config_dir / \"default_config.yaml\"\n    user_config_path = user_config_dir / \"config.yaml\"\n\n    # 如果用户目录没有 default_config.yaml，从内置配置复制\n    if not user_default_config_path.exists() and default_config_path.exists():\n        shutil.copy2(default_config_path, user_default_config_path)\n\n    # 如果用户目录没有 config.yaml，从 default_config.yaml 复制\n    if not user_config_path.exists():\n        source = (\n            user_default_config_path if user_default_config_path.exists() else default_config_path\n        )\n        if source.exists():\n            shutil.copy2(source, user_config_path)\n\n    # 构建配置文件列表（按加载顺序：默认配置 -> 用户配置）\n    settings_files = []\n\n    # 首先加载默认配置\n    if user_default_config_path.exists():\n        settings_files.append(str(user_default_config_path))\n    elif default_config_path.exists():\n        settings_files.append(str(default_config_path))\n\n    # 然后加载用户配置（覆盖默认值）\n    if user_config_path.exists():\n        settings_files.append(str(user_config_path))\n\n    return settings_files\n\n\n# 初始化配置文件并获取路径列表\n_settings_files = _init_config_files()\n\n# Dynaconf 实例\nsettings = Dynaconf(\n    # 配置文件（按顺序加载，后面的覆盖前面的）\n    settings_files=_settings_files,\n    # 环境变量前缀：LIFETRACE__LLM__API_KEY -> llm.api_key\n    envvar_prefix=\"LIFETRACE\",\n    # 嵌套分隔符：双下划线\n    nested_separator=\"__\",\n    # 启用配置合并（字典会合并而非覆盖）\n    merge_enabled=True,\n    # 加载 .env 文件\n    load_dotenv=True,\n    # 允许小写访问\n    lowercase_read=True,\n    # 验证器\n    validators=[\n        # 服务器配置\n        Validator(\"server.host\", default=\"127.0.0.1\"),\n        Validator(\"server.port\", default=8001, is_type_of=int),\n        Validator(\"server.debug\", default=False, is_type_of=bool),\n        # 基础目录配置\n        Validator(\"base_dir\", default=\"data\"),\n        Validator(\"database_path\", default=\"lifetrace.db\"),\n        Validator(\"screenshots_dir\", default=\"screenshots/\"),\n        Validator(\"attachments_dir\", default=\"attachments/\"),\n        # 日志配置\n        Validator(\"logging.level\", default=\"INFO\"),\n        Validator(\"logging.log_path\", default=\"logs/\"),\n        Validator(\"logging.console_level\", default=\"INFO\"),\n        Validator(\"logging.file_level\", default=\"INFO\"),\n        Validator(\"logging.quiet_modules\", default=[], is_type_of=list),\n        # 调度器配置\n        Validator(\"scheduler.enabled\", default=True, is_type_of=bool),\n        Validator(\"scheduler.database_path\", default=\"scheduler.db\"),\n        # 向量数据库配置\n        Validator(\"vector_db.enabled\", default=True, is_type_of=bool),\n        Validator(\"vector_db.collection_name\", default=\"lifetrace_ocr\"),\n        Validator(\"vector_db.persist_directory\", default=\"vector_db\"),\n        # 聊天配置\n        Validator(\"chat.enable_history\", default=True, is_type_of=bool),\n        Validator(\"chat.history_limit\", default=10, is_type_of=int),\n        # LLM 配置（关键配置，启动时不强制要求，运行时检查）\n        Validator(\"llm.api_key\", default=\"YOUR_LLM_KEY_HERE\"),\n        Validator(\"llm.base_url\", default=\"https://dashscope.aliyuncs.com/compatible-mode/v1\"),\n        Validator(\"llm.model\", default=\"qwen-plus\"),\n        Validator(\"llm.vision_model\", default=\"qwen3-vl-plus\"),\n        Validator(\"llm.temperature\", default=0.7),\n        Validator(\"llm.max_tokens\", default=2048, is_type_of=int),\n        # Tavily 配置（联网搜索）\n        Validator(\"tavily.api_key\", default=\"YOUR_TAVILY_API_KEY_HERE\"),\n        Validator(\"tavily.search_depth\", default=\"basic\"),\n        Validator(\"tavily.max_results\", default=5, is_type_of=int),\n        Validator(\"tavily.include_domains\", default=[]),\n        Validator(\"tavily.exclude_domains\", default=[]),\n        # 音频配置\n        Validator(\"audio.is_24x7\", default=False, is_type_of=bool),\n        Validator(\"audio.asr.api_key\", default=\"YOUR_LLM_KEY_HERE\"),\n        Validator(\n            \"audio.asr.base_url\", default=\"wss://dashscope.aliyuncs.com/api-ws/v1/inference/\"\n        ),\n        Validator(\"audio.asr.model\", default=\"fun-asr-realtime\"),\n        Validator(\"audio.asr.sample_rate\", default=16000, is_type_of=int),\n        Validator(\"audio.asr.format\", default=\"pcm\"),\n        Validator(\"audio.asr.semantic_punctuation_enabled\", default=False, is_type_of=bool),\n        Validator(\"audio.asr.max_sentence_silence\", default=1300, is_type_of=int),\n        Validator(\"audio.asr.heartbeat\", default=False, is_type_of=bool),\n        Validator(\"audio.storage.audio_dir\", default=\"audio/\"),\n        Validator(\"audio.storage.temp_audio_dir\", default=\"temp_audio/\"),\n        # 后端模块启用配置\n        Validator(\"backend_modules.enabled\", default=[], is_type_of=list),\n        Validator(\"backend_modules.disabled\", default=[], is_type_of=list),\n        Validator(\"backend_modules.unavailable\", default=[], is_type_of=list),\n    ],\n)\n\n\ndef get_settings() -> Dynaconf:\n    \"\"\"获取 Dynaconf settings 实例\"\"\"\n    return settings\n\n\ndef reload_settings() -> bool:\n    \"\"\"重新加载配置文件\n\n    Returns:\n        bool: 是否成功重载\n    \"\"\"\n    try:\n        settings.reload()\n        return True\n    except Exception:\n        return False\n"
  },
  {
    "path": "lifetrace/util/time_parser.py",
    "content": "\"\"\"时间解析工具函数\"\"\"\n\nimport re\nfrom datetime import datetime, time, timedelta\n\nfrom lifetrace.util.logging_config import get_logger\n\nlogger = get_logger()\n\n# 常量定义\nMAX_HOUR = 23\nMAX_MINUTE = 59\nNOON_HOUR = 12\n\n\ndef _parse_24h_time(time_str: str) -> tuple[int, int] | None:\n    \"\"\"解析24小时制时间格式\"\"\"\n    pattern_24h = r\"(\\d{1,2}):?(\\d{2})\"\n    match = re.match(pattern_24h, time_str.strip())\n    if match:\n        hour = int(match.group(1))\n        minute = int(match.group(2))\n        if 0 <= hour <= MAX_HOUR and 0 <= minute <= MAX_MINUTE:\n            return (hour, minute)\n    return None\n\n\ndef _parse_12h_time(time_str: str) -> tuple[int, int] | None:\n    \"\"\"解析12小时制时间格式（如：下午3点）\"\"\"\n    time_str_lower = time_str.lower()\n    hour_map = {\n        \"凌晨\": 0,\n        \"早上\": 6,\n        \"上午\": 9,\n        \"中午\": 12,\n        \"下午\": 13,\n        \"傍晚\": 18,\n        \"晚上\": 20,\n        \"深夜\": 23,\n    }\n\n    for period in hour_map:\n        if period in time_str_lower:\n            # 提取数字\n            numbers = re.findall(r\"\\d+\", time_str)\n            if numbers:\n                hour = int(numbers[0])\n                if period in [\"下午\", \"傍晚\", \"晚上\"] and hour < NOON_HOUR:\n                    hour += NOON_HOUR\n                elif period == \"中午\" and hour == NOON_HOUR:\n                    hour = NOON_HOUR\n                elif period in [\"凌晨\", \"早上\", \"上午\"] and hour == NOON_HOUR:\n                    hour = 0\n                minute = int(numbers[1]) if len(numbers) > 1 else 0\n                if 0 <= hour <= MAX_HOUR and 0 <= minute <= MAX_MINUTE:\n                    return (hour, minute)\n    return None\n\n\ndef parse_time_string(time_str: str) -> tuple[int, int] | None:\n    \"\"\"\n    解析时间字符串，提取小时和分钟\n\n    Args:\n        time_str: 时间字符串，如 \"13:00\", \"下午3点\", \"15:30\"\n\n    Returns:\n        (小时, 分钟) 元组，如果解析失败返回None\n    \"\"\"\n    if not time_str:\n        return None\n\n    # 先尝试24小时制格式\n    result = _parse_24h_time(time_str)\n    if result:\n        return result\n\n    # 再尝试12小时制格式\n    result = _parse_12h_time(time_str)\n    if result:\n        return result\n\n    logger.warning(f\"无法解析时间字符串: {time_str}\")\n    return None\n\n\ndef normalize_time_string(time_str: str) -> str:\n    \"\"\"\n    标准化时间字符串为24小时制格式\n\n    Args:\n        time_str: 原始时间字符串\n\n    Returns:\n        标准化后的时间字符串（HH:MM格式），如果解析失败返回原字符串\n    \"\"\"\n    result = parse_time_string(time_str)\n    if result:\n        hour, minute = result\n        return f\"{hour:02d}:{minute:02d}\"\n    return time_str\n\n\ndef parse_relative_time(\n    relative_days: int,\n    relative_time_str: str,\n    reference_time: datetime,\n) -> datetime | None:\n    \"\"\"\n    解析相对时间并转换为绝对时间\n\n    Args:\n        relative_days: 相对天数（0=今天，1=明天，2=后天，-1=昨天）\n        relative_time_str: 相对时间点字符串（如 \"13:00\"）\n        reference_time: 参考时间（通常是事件的开始时间或结束时间）\n\n    Returns:\n        解析后的绝对时间，如果解析失败返回None\n    \"\"\"\n    try:\n        # 解析时间字符串\n        time_result = parse_time_string(relative_time_str)\n        if not time_result:\n            logger.warning(f\"无法解析相对时间字符串: {relative_time_str}\")\n            return None\n\n        hour, minute = time_result\n\n        # 计算目标日期\n        target_date = reference_time.date() + timedelta(days=relative_days)\n\n        # 组合日期和时间\n        target_datetime = datetime.combine(target_date, time(hour, minute))\n\n        # 如果目标时间早于参考时间，且relative_days为0，可能需要调整到明天\n        if relative_days == 0 and target_datetime < reference_time:\n            # 可能是\"今天下午1点\"，但现在已经过了，可能指的是明天\n            # 这里保持原逻辑，由调用方决定是否调整\n            pass\n\n        return target_datetime\n\n    except Exception as e:\n        logger.error(f\"解析相对时间失败: {e}\")\n        return None\n\n\ndef parse_absolute_time(absolute_time_str: str | datetime) -> datetime | None:\n    \"\"\"\n    解析绝对时间\n\n    Args:\n        absolute_time_str: 绝对时间字符串（ISO格式）或datetime对象\n\n    Returns:\n        解析后的datetime对象，如果解析失败返回None\n    \"\"\"\n    if isinstance(absolute_time_str, datetime):\n        return absolute_time_str\n\n    if not absolute_time_str:\n        return None\n\n    try:\n        # 尝试解析ISO格式\n        if \"T\" in absolute_time_str or \" \" in absolute_time_str:\n            # ISO格式：2024-01-15T13:00:00 或 2024-01-15 13:00:00\n            dt = datetime.fromisoformat(absolute_time_str.replace(\" \", \"T\"))\n            return dt\n        else:\n            # 日期格式：2024-01-15\n            dt = datetime.fromisoformat(absolute_time_str)\n            return dt\n\n    except Exception as e:\n        logger.warning(f\"解析绝对时间失败: {absolute_time_str}, 错误: {e}\")\n        return None\n\n\ndef calculate_scheduled_time(time_info: dict, reference_time: datetime) -> datetime | None:\n    \"\"\"\n    根据时间信息计算计划时间\n\n    Args:\n        time_info: 时间信息字典，包含time_type、relative_days、relative_time、absolute_time等\n        reference_time: 参考时间（事件开始时间或结束时间）\n\n    Returns:\n        计算后的绝对时间，如果计算失败返回None\n    \"\"\"\n    time_type = time_info.get(\"time_type\")\n\n    if time_type == \"absolute\":\n        absolute_time = time_info.get(\"absolute_time\")\n        if absolute_time:\n            return parse_absolute_time(absolute_time)\n        return None\n\n    elif time_type == \"relative\":\n        relative_days = time_info.get(\"relative_days\")\n        relative_time_str = time_info.get(\"relative_time\")\n\n        if relative_days is not None and relative_time_str:\n            return parse_relative_time(relative_days, relative_time_str, reference_time)\n\n        return None\n\n    else:\n        logger.warning(f\"未知的时间类型: {time_type}\")\n        return None\n"
  },
  {
    "path": "lifetrace/util/time_utils.py",
    "content": "\"\"\"时间工具函数模块\n\n提供 UTC 时间处理相关的工具函数，确保项目中所有时间都使用 UTC 存储和处理。\n\"\"\"\n\nfrom __future__ import annotations\n\nimport time\nfrom datetime import UTC, datetime, timedelta, timezone\n\n\ndef get_utc_now() -> datetime:\n    \"\"\"获取当前 UTC 时间（timezone-aware）\n\n    Returns:\n        datetime: 当前 UTC 时间，带时区信息\n    \"\"\"\n    return datetime.now(UTC)\n\n\ndef to_utc(dt: datetime) -> datetime:\n    \"\"\"将 datetime 转换为 UTC 时间\n\n    Args:\n        dt: 要转换的 datetime 对象（可以是 naive 或 timezone-aware）\n\n    Returns:\n        datetime: UTC 时间（timezone-aware）\n\n    注意：\n        - 如果 dt 是 naive datetime（无时区信息），假设为本地时间并转换为 UTC\n        - 如果 dt 已经是 timezone-aware，则转换为 UTC\n    \"\"\"\n    if dt.tzinfo is None:\n        # naive datetime 假设为本地时间，转换为 UTC\n        # 使用 local timezone 转换\n        local_tz = timezone(\n            timedelta(seconds=-time.timezone if time.daylight == 0 else -time.altzone)\n        )\n        dt_with_tz = dt.replace(tzinfo=local_tz)\n        return dt_with_tz.astimezone(UTC)\n    return dt.astimezone(UTC)\n\n\ndef naive_as_utc(dt: datetime) -> datetime:\n    \"\"\"将 naive datetime 视为 UTC 时间（用于 SQLite 数据库读取）\n\n    注意：SQLite 存储 datetime 为字符串，SQLAlchemy 读取时为 naive datetime。\n    由于我们的代码统一使用 UTC 时间存储，数据库中的 naive datetime 实际上就是 UTC 时间。\n\n    Args:\n        dt: naive datetime 对象\n\n    Returns:\n        datetime: UTC timezone-aware datetime\n\n    Raises:\n        ValueError: 如果 dt 不是 naive datetime（已经有 tzinfo）\n    \"\"\"\n    if dt.tzinfo is not None:\n        # 如果已经有时区信息，直接返回\n        return dt.astimezone(UTC)\n    # 假设 naive datetime 就是 UTC 时间，直接添加 UTC 时区信息\n    return dt.replace(tzinfo=UTC)\n\n\ndef ensure_utc(dt: datetime | None) -> datetime | None:\n    \"\"\"确保 datetime 是 UTC，如果是 None 则返回 None\n\n    Args:\n        dt: 要处理的 datetime 对象或 None\n\n    Returns:\n        datetime | None: UTC 时间（timezone-aware）或 None\n    \"\"\"\n    return to_utc(dt) if dt is not None else None\n\n\ndef to_local(dt: datetime | None) -> datetime | None:\n    \"\"\"将 datetime 转换为本地时间（timezone-aware）。\n\n    如果 dt 为 naive，则视为本地时间并补充本地时区；如果已有 tzinfo，则转换到本地时区。\n    \"\"\"\n    if dt is None:\n        return None\n    if dt.tzinfo is None:\n        offset = -time.timezone if time.daylight == 0 else -time.altzone\n        local_tz = timezone(timedelta(seconds=offset))\n        return dt.replace(tzinfo=local_tz)\n    return dt.astimezone()\n"
  },
  {
    "path": "lifetrace/util/token_usage_logger.py",
    "content": "\"\"\"\nToken使用量记录器\n记录LLM API调用的token使用情况，便于后续统计分析\n\"\"\"\n\nfrom datetime import timedelta\nfrom functools import lru_cache\nfrom typing import Any\n\nfrom lifetrace.storage import get_session\nfrom lifetrace.storage.models import TokenUsage\nfrom lifetrace.storage.sql_utils import col\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.settings import settings\nfrom lifetrace.util.time_utils import get_utc_now\n\nlogger = get_logger()\n\n\ndef _resolve_model_price(\n    model: str,\n    price_config: dict,\n    input_tokens: int | None = None,\n) -> tuple[float, float]:\n    \"\"\"根据价格配置解析模型的单价（元/千token）\n\n    支持分层定价（tiers）和旧版的 input_price/output_price 直配。\n\n    Args:\n        model: 模型名称\n        price_config: 价格配置字典\n        input_tokens: 输入token数量，用于选择分层价格（可选）\n\n    Returns:\n        (input_price, output_price) 元组\n    \"\"\"\n    # 支持分层定价：tiers 为列表，按 max_input_tokens 升序匹配\n    if \"tiers\" in price_config:\n        tiers = price_config.get(\"tiers\") or []\n        if not isinstance(tiers, list) or not tiers:\n            raise ValueError(f\"模型 '{model}' 的 tiers 配置无效\")\n\n        sorted_tiers = sorted(\n            tiers,\n            key=lambda tier: tier.get(\"max_input_tokens\", float(\"inf\")),\n        )\n        tokens = input_tokens if input_tokens is not None else 0\n        selected_tier = None\n        for tier in sorted_tiers:\n            max_tokens = tier.get(\"max_input_tokens\")\n            # 如果未设置上限或在上限内，则匹配到该档\n            if max_tokens is None or tokens <= max_tokens:\n                selected_tier = tier\n                break\n        if selected_tier is None:\n            selected_tier = sorted_tiers[-1]\n\n        if \"input_price\" not in selected_tier or \"output_price\" not in selected_tier:\n            raise KeyError(f\"模型 '{model}' 的 tiers 配置缺少 input_price 或 output_price。\")\n        return float(selected_tier[\"input_price\"]), float(selected_tier[\"output_price\"])\n\n    # 兼容旧配置：直接使用 input_price/output_price\n    if \"input_price\" not in price_config or \"output_price\" not in price_config:\n        raise KeyError(\n            f\"模型 '{model}' 的价格配置不完整。请确保配置了 input_price 和 output_price。\"\n        )\n    return float(price_config[\"input_price\"]), float(price_config[\"output_price\"])\n\n\nclass TokenUsageLogger:\n    \"\"\"Token使用量记录器\"\"\"\n\n    def __init__(self):\n        pass\n\n    def _get_model_price(self, model: str, input_tokens: int | None = None) -> tuple[float, float]:\n        \"\"\"获取模型价格（元/千token）\n\n        Args:\n            model: 模型名称\n            input_tokens: 输入token数量，用于选择分层价格（可选）\n\n        Returns:\n            (input_price, output_price) 元组\n        \"\"\"\n        model_prices = settings.get(\"llm.model_prices\")\n        if model_prices is None:\n            return 0.0, 0.0\n\n        # 将 Dynaconf Box 对象转换为普通字典\n        if hasattr(model_prices, \"to_dict\"):\n            model_prices = model_prices.to_dict()\n\n        # 先尝试获取指定模型的价格\n        if model in model_prices:\n            price_config = model_prices[model]\n            if hasattr(price_config, \"to_dict\"):\n                price_config = price_config.to_dict()\n            return _resolve_model_price(model, price_config, input_tokens=input_tokens)\n\n        # 如果没有找到，使用默认价格\n        if \"default\" not in model_prices:\n            raise KeyError(\n                f\"找不到模型 '{model}' 的价格配置，也没有配置默认价格。\"\n                f\"请在配置文件中添加该模型的价格或配置 default 价格。\"\n            )\n\n        default_config = model_prices[\"default\"]\n        if hasattr(default_config, \"to_dict\"):\n            default_config = default_config.to_dict()\n        return _resolve_model_price(model, default_config, input_tokens=input_tokens)\n\n    def log_token_usage(\n        self,\n        model: str,\n        input_tokens: int,\n        output_tokens: int,\n        metadata: dict[str, Any] | None = None,\n    ):\n        \"\"\"\n        记录token使用量\n\n        Args:\n            model: 使用的模型名称\n            input_tokens: 输入token数量\n            output_tokens: 输出token数量\n            metadata: 元数据字典，可包含以下键：\n                - endpoint: API端点（如 /api/chat, /api/chat/stream）\n                - user_query: 用户查询内容（可选，用于分析）\n                - response_type: 响应类型（如 chat, search, classify）\n                - feature_type: 功能类型（如 event_assistant, project_assistant）\n                - additional_info: 额外信息字典\n        \"\"\"\n        max_query_preview_length = 200\n\n        if metadata is None:\n            metadata = {}\n\n        endpoint = metadata.get(\"endpoint\")\n        user_query = metadata.get(\"user_query\")\n        response_type = metadata.get(\"response_type\")\n        feature_type = metadata.get(\"feature_type\")\n\n        try:\n            # 计算成本\n            input_price, output_price = self._get_model_price(model, input_tokens)\n            input_cost = (input_tokens / 1000) * input_price\n            output_cost = (output_tokens / 1000) * output_price\n            total_cost = input_cost + output_cost\n\n            # 准备用户查询预览\n            user_query_preview = None\n            query_length = None\n            if user_query:\n                # 只记录查询的前N个字符\n                user_query_preview = user_query[:max_query_preview_length] + (\n                    \"...\" if len(user_query) > max_query_preview_length else \"\"\n                )\n                query_length = len(user_query)\n\n            # 写入数据库\n            with get_session() as session:\n                token_usage = TokenUsage(\n                    model=model,\n                    input_tokens=input_tokens,\n                    output_tokens=output_tokens,\n                    total_tokens=input_tokens + output_tokens,\n                    endpoint=endpoint,\n                    response_type=response_type,\n                    feature_type=feature_type,\n                    user_query_preview=user_query_preview,\n                    query_length=query_length,\n                    input_cost=input_cost,\n                    output_cost=output_cost,\n                    total_cost=total_cost,\n                    created_at=get_utc_now(),\n                )\n                session.add(token_usage)\n                session.flush()\n\n            # 记录到标准日志\n            logger.info(\n                f\"Token usage - Model: {model}, Input: {input_tokens}, Output: {output_tokens}, \"\n                f\"Total: {input_tokens + output_tokens}, Cost: ¥{total_cost:.4f}\"\n            )\n\n        except Exception as e:\n            # 记录错误但不影响主流程\n            logger.error(f\"Failed to log token usage: {e}\")\n\n    def get_usage_stats(self, days: int = 30) -> dict[str, Any]:\n        \"\"\"\n        获取token使用统计\n\n        Args:\n            days: 统计最近多少天的数据\n\n        Returns:\n            统计结果字典\n        \"\"\"\n        try:\n            stats = {\n                \"total_input_tokens\": 0,\n                \"total_output_tokens\": 0,\n                \"total_tokens\": 0,\n                \"total_requests\": 0,\n                \"total_cost\": 0.0,\n                \"model_stats\": {},\n                \"endpoint_stats\": {},\n                \"feature_stats\": {},\n                \"daily_stats\": {},\n            }\n\n            end_date = get_utc_now()\n            start_date = end_date - timedelta(days=days)\n\n            # 从数据库查询\n            with get_session() as session:\n                # 查询时间范围内的所有记录\n                records = (\n                    session.query(TokenUsage)\n                    .filter(col(TokenUsage.created_at) >= start_date)\n                    .filter(col(TokenUsage.created_at) <= end_date)\n                    .all()\n                )\n\n                for record in records:\n                    # 更新总计\n                    stats[\"total_input_tokens\"] += record.input_tokens\n                    stats[\"total_output_tokens\"] += record.output_tokens\n                    stats[\"total_tokens\"] += record.total_tokens\n                    stats[\"total_cost\"] += record.total_cost\n                    stats[\"total_requests\"] += 1\n\n                    # 按模型统计\n                    model = record.model\n                    if model not in stats[\"model_stats\"]:\n                        stats[\"model_stats\"][model] = {\n                            \"input_tokens\": 0,\n                            \"output_tokens\": 0,\n                            \"total_tokens\": 0,\n                            \"requests\": 0,\n                            \"input_cost\": 0.0,\n                            \"output_cost\": 0.0,\n                            \"total_cost\": 0.0,\n                        }\n                    stats[\"model_stats\"][model][\"input_tokens\"] += record.input_tokens\n                    stats[\"model_stats\"][model][\"output_tokens\"] += record.output_tokens\n                    stats[\"model_stats\"][model][\"total_tokens\"] += record.total_tokens\n                    stats[\"model_stats\"][model][\"input_cost\"] += record.input_cost\n                    stats[\"model_stats\"][model][\"output_cost\"] += record.output_cost\n                    stats[\"model_stats\"][model][\"total_cost\"] += record.total_cost\n                    stats[\"model_stats\"][model][\"requests\"] += 1\n\n                    # 按端点统计\n                    endpoint = record.endpoint or \"unknown\"\n                    if endpoint not in stats[\"endpoint_stats\"]:\n                        stats[\"endpoint_stats\"][endpoint] = {\n                            \"input_tokens\": 0,\n                            \"output_tokens\": 0,\n                            \"total_tokens\": 0,\n                            \"requests\": 0,\n                            \"total_cost\": 0.0,\n                        }\n                    stats[\"endpoint_stats\"][endpoint][\"input_tokens\"] += record.input_tokens\n                    stats[\"endpoint_stats\"][endpoint][\"output_tokens\"] += record.output_tokens\n                    stats[\"endpoint_stats\"][endpoint][\"total_tokens\"] += record.total_tokens\n                    stats[\"endpoint_stats\"][endpoint][\"total_cost\"] += record.total_cost\n                    stats[\"endpoint_stats\"][endpoint][\"requests\"] += 1\n\n                    # 按功能类型统计\n                    feature_type = record.feature_type or \"unknown\"\n                    if feature_type not in stats[\"feature_stats\"]:\n                        stats[\"feature_stats\"][feature_type] = {\n                            \"input_tokens\": 0,\n                            \"output_tokens\": 0,\n                            \"total_tokens\": 0,\n                            \"requests\": 0,\n                            \"total_cost\": 0.0,\n                        }\n                    stats[\"feature_stats\"][feature_type][\"input_tokens\"] += record.input_tokens\n                    stats[\"feature_stats\"][feature_type][\"output_tokens\"] += record.output_tokens\n                    stats[\"feature_stats\"][feature_type][\"total_tokens\"] += record.total_tokens\n                    stats[\"feature_stats\"][feature_type][\"total_cost\"] += record.total_cost\n                    stats[\"feature_stats\"][feature_type][\"requests\"] += 1\n\n                    # 按日期统计\n                    date_str = record.created_at.strftime(\"%Y-%m-%d\")\n                    if date_str not in stats[\"daily_stats\"]:\n                        stats[\"daily_stats\"][date_str] = {\n                            \"input_tokens\": 0,\n                            \"output_tokens\": 0,\n                            \"total_tokens\": 0,\n                            \"requests\": 0,\n                            \"total_cost\": 0.0,\n                        }\n                    stats[\"daily_stats\"][date_str][\"input_tokens\"] += record.input_tokens\n                    stats[\"daily_stats\"][date_str][\"output_tokens\"] += record.output_tokens\n                    stats[\"daily_stats\"][date_str][\"total_tokens\"] += record.total_tokens\n                    stats[\"daily_stats\"][date_str][\"total_cost\"] += record.total_cost\n                    stats[\"daily_stats\"][date_str][\"requests\"] += 1\n\n            return stats\n\n        except Exception as e:\n            logger.error(f\"Failed to get usage stats: {e}\")\n            return {}\n\n\n# 全局token使用量记录器实例\n\n\n@lru_cache(maxsize=1)\ndef get_token_logger() -> TokenUsageLogger:\n    \"\"\"获取token使用量记录器实例\"\"\"\n    return TokenUsageLogger()\n\n\ndef setup_token_logger() -> TokenUsageLogger:\n    \"\"\"设置token使用量记录器\"\"\"\n    return get_token_logger()\n\n\ndef log_token_usage(model: str, input_tokens: int, output_tokens: int, **kwargs):\n    \"\"\"便捷函数：记录token使用量\n\n    Args:\n        model: 模型名称\n        input_tokens: 输入token数量\n        output_tokens: 输出token数量\n        **kwargs: 传递给 metadata 字典的其他参数\n    \"\"\"\n    token_logger = get_token_logger()\n    return token_logger.log_token_usage(model, input_tokens, output_tokens, metadata=kwargs)\n"
  },
  {
    "path": "lifetrace/util/utils.py",
    "content": "import hashlib\nimport importlib\nimport os\nimport platform\nimport shutil\nimport subprocess  # nosec B404\nfrom datetime import UTC, datetime, timedelta\nfrom pathlib import Path\nfrom typing import Any, cast\n\nfrom lifetrace.util.logging_config import get_logger\nfrom lifetrace.util.time_utils import get_utc_now\n\nlogger = get_logger()\n\n# 常量定义\nMIN_WINDOW_SIZE = 100  # 最小窗口尺寸（用于过滤菜单、工具栏等）\nBYTES_PER_KB = 1024  # 每KB的字节数\nDEFAULT_SCREEN_ID = 1  # 默认屏幕ID\n\ntry:\n    import psutil\n    import win32api\n    import win32gui\n    import win32process\nexcept ImportError:\n    psutil = None\n    win32api = None\n    win32gui = None\n    win32process = None\n\n\ndef _load_appkit() -> Any | None:\n    try:\n        return importlib.import_module(\"AppKit\")\n    except Exception:\n        return None\n\n\ndef _load_quartz() -> Any | None:\n    try:\n        return importlib.import_module(\"Quartz\")\n    except Exception:\n        return None\n\n\ndef get_file_hash(file_path: str) -> str:\n    \"\"\"计算文件MD5哈希值\"\"\"\n    hash_md5 = hashlib.md5(usedforsecurity=False)\n    try:\n        with open(file_path, \"rb\") as f:\n            for chunk in iter(lambda: f.read(4096), b\"\"):\n                hash_md5.update(chunk)\n        return hash_md5.hexdigest()\n    except Exception:\n        return \"\"\n\n\ndef ensure_dir(path: str):\n    \"\"\"确保目录存在\"\"\"\n    os.makedirs(path, exist_ok=True)\n\n\ndef get_active_window_info() -> tuple[str | None, str | None]:\n    \"\"\"获取当前活跃窗口信息\"\"\"\n    try:\n        system = platform.system()\n\n        if system == \"Windows\":\n            return _get_windows_active_window()\n        elif system == \"Darwin\":  # macOS\n            return _get_macos_active_window()\n        elif system == \"Linux\":\n            return _get_linux_active_window()\n        else:\n            return None, None\n    except Exception as e:\n        logger.warning(f\"获取活跃窗口信息失败: {e}\")\n        return None, None\n\n\ndef _get_windows_active_window() -> tuple[str | None, str | None]:\n    \"\"\"获取Windows活跃窗口信息\"\"\"\n    try:\n        if psutil is None or win32gui is None or win32process is None:\n            logger.warning(\"Windows依赖未安装，无法获取窗口信息\")\n            return None, None\n\n        hwnd = win32gui.GetForegroundWindow()\n        if hwnd:\n            window_title = win32gui.GetWindowText(hwnd)\n            _, pid = win32process.GetWindowThreadProcessId(hwnd)\n\n            try:\n                process = psutil.Process(pid)\n                app_name = process.name()\n            except:  # noqa: E722\n                app_name = None\n\n            return app_name, window_title\n    except Exception as e:\n        logger.error(f\"获取Windows窗口信息失败: {e}\")\n\n    return None, None\n\n\ndef _get_macos_active_window() -> tuple[str | None, str | None]:\n    \"\"\"获取macOS活跃窗口信息\"\"\"\n    try:\n        appkit = _load_appkit()\n        quartz = _load_quartz()\n        if appkit is None or quartz is None:\n            logger.warning(\"macOS依赖未安装，无法获取窗口信息\")\n            return None, None\n\n        # 获取活跃应用\n        workspace = appkit.NSWorkspace.sharedWorkspace()\n        active_app = workspace.activeApplication()\n        app_name = active_app.get(\"NSApplicationName\", None) if active_app else None\n\n        # 获取窗口标题\n        try:\n            window_list = quartz.CGWindowListCopyWindowInfo(\n                quartz.kCGWindowListOptionOnScreenOnly, quartz.kCGNullWindowID\n            )\n            if window_list:\n                for window in window_list:\n                    if window.get(\"kCGWindowOwnerName\") == app_name:\n                        window_title = window.get(\"kCGWindowName\", \"\")\n                        if window_title:\n                            return app_name, window_title\n        except Exception as window_error:\n            # 可能是权限问题，返回应用名称但不返回窗口标题\n            logger.warning(f\"无法获取窗口标题（可能缺少屏幕录制权限）: {window_error}\")\n            return app_name, None\n\n        return app_name, None\n    except Exception as e:\n        logger.error(f\"获取macOS窗口信息失败: {e}\")\n\n    return None, None\n\n\ndef get_active_window_screen() -> int | None:\n    \"\"\"获取活跃窗口所在的屏幕ID（从1开始）\"\"\"\n    try:\n        system = platform.system()\n\n        if system == \"Darwin\":  # macOS\n            return _get_macos_active_window_screen()\n        elif system == \"Windows\":\n            return _get_windows_active_window_screen()\n        elif system == \"Linux\":\n            return _get_linux_active_window_screen()\n        else:\n            return None\n    except Exception as e:\n        logger.warning(f\"获取活跃窗口屏幕失败: {e}\")\n        return None\n\n\ndef _get_macos_active_app_name() -> str | None:\n    \"\"\"获取macOS活跃应用名称\"\"\"\n    appkit = _load_appkit()\n    if appkit is None:\n        return None\n    workspace = appkit.NSWorkspace.sharedWorkspace()\n    active_app = workspace.activeApplication()\n    if not active_app:\n        return None\n    return active_app.get(\"NSApplicationName\", None)\n\n\ndef _get_macos_active_window_bounds(app_name: str) -> dict | None:\n    \"\"\"获取macOS活跃窗口的边界\"\"\"\n    quartz = _load_quartz()\n    if quartz is None:\n        return None\n    window_list = quartz.CGWindowListCopyWindowInfo(\n        quartz.kCGWindowListOptionOnScreenOnly, quartz.kCGNullWindowID\n    )\n    if not window_list:\n        return None\n\n    for window in window_list:\n        if window.get(\"kCGWindowOwnerName\") == app_name:\n            bounds = window.get(\"kCGWindowBounds\", {})\n            # 忽略太小的窗口（可能是菜单、工具栏等）\n            if (\n                bounds.get(\"Height\", 0) > MIN_WINDOW_SIZE\n                and bounds.get(\"Width\", 0) > MIN_WINDOW_SIZE\n            ):\n                return bounds\n\n    return None\n\n\ndef _find_screen_for_window_center(window_center: tuple[float, float], screens: list) -> int:\n    \"\"\"查找包含窗口中心点的屏幕\"\"\"\n    window_center_x, window_center_y = window_center\n    main_screen_height = screens[0].frame().size.height\n\n    for i, screen in enumerate(screens):\n        frame = screen.frame()\n        screen_x = frame.origin.x\n        screen_y = frame.origin.y\n        screen_width = frame.size.width\n        screen_height = frame.size.height\n\n        # 转换为窗口坐标系（翻转 y 轴）\n        screen_y_flipped = main_screen_height - screen_y - screen_height\n\n        if (\n            screen_x <= window_center_x <= screen_x + screen_width\n            and screen_y_flipped <= window_center_y <= screen_y_flipped + screen_height\n        ):\n            return i + 1\n\n    return DEFAULT_SCREEN_ID\n\n\ndef _get_macos_active_window_screen() -> int | None:\n    \"\"\"获取macOS活跃窗口所在的屏幕ID\"\"\"\n    try:\n        appkit = _load_appkit()\n        if appkit is None:\n            logger.warning(\"macOS依赖未安装，无法获取屏幕信息\")\n            return None\n        app_name = _get_macos_active_app_name()\n        if not app_name:\n            return None\n\n        active_window_bounds = _get_macos_active_window_bounds(app_name)\n        if not active_window_bounds:\n            return DEFAULT_SCREEN_ID\n\n        # 计算窗口中心点\n        window_x = active_window_bounds.get(\"X\", 0)\n        window_y = active_window_bounds.get(\"Y\", 0)\n        window_width = active_window_bounds.get(\"Width\", 0)\n        window_height = active_window_bounds.get(\"Height\", 0)\n        window_center = (window_x + window_width / 2, window_y + window_height / 2)\n\n        screens = appkit.NSScreen.screens()\n        if not screens:\n            return DEFAULT_SCREEN_ID\n\n        return _find_screen_for_window_center(window_center, screens)\n\n    except Exception as e:\n        logger.error(f\"获取macOS活跃窗口屏幕失败: {e}\")\n\n    return None\n\n\ndef _get_windows_active_window_screen() -> int | None:\n    \"\"\"获取Windows活跃窗口所在的屏幕ID\"\"\"\n    try:\n        if win32api is None or win32gui is None:\n            logger.warning(\"Windows依赖未安装，无法获取屏幕信息\")\n            return None\n\n        hwnd = win32gui.GetForegroundWindow()\n        if not hwnd:\n            return None\n\n        # 获取窗口矩形\n        rect = win32gui.GetWindowRect(hwnd)\n        window_x = rect[0]\n        window_y = rect[1]\n        window_width = rect[2] - rect[0]\n        window_height = rect[3] - rect[1]\n\n        # 计算窗口中心点\n        center_x = window_x + window_width // 2\n        center_y = window_y + window_height // 2\n\n        # 获取所有显示器\n        monitors = win32api.EnumDisplayMonitors()\n\n        # 遍历所有显示器，找到包含窗口中心点的显示器\n        for i, monitor in enumerate(monitors):\n            monitor_handle = cast(\"int\", monitor[0])\n            monitor_info = win32api.GetMonitorInfo(monitor_handle)\n            monitor_rect = monitor_info[\"Monitor\"]\n\n            if (\n                monitor_rect[0] <= center_x <= monitor_rect[2]\n                and monitor_rect[1] <= center_y <= monitor_rect[3]\n            ):\n                return i + 1\n\n        return 1  # 默认返回主屏幕\n\n    except Exception as e:\n        logger.error(f\"获取Windows活跃窗口屏幕失败: {e}\")\n\n    return None\n\n\ndef _parse_linux_window_position(stdout: str) -> tuple[int, int] | None:\n    \"\"\"解析Linux窗口位置\"\"\"\n    for line in stdout.split(\"\\n\"):\n        if \"Position:\" in line:\n            pos = line.split(\"Position:\")[1].split()[0]\n            x, y = map(int, pos.split(\",\"))\n            return x, y\n    return None\n\n\ndef _find_linux_screen_for_position(x: int, y: int, xrandr_stdout: str) -> int:\n    \"\"\"根据位置查找Linux屏幕ID\"\"\"\n    screen_id = 1\n    for xrandr_line in xrandr_stdout.split(\"\\n\"):\n        if \" connected\" not in xrandr_line or \"+\" not in xrandr_line:\n            continue\n\n        for part in xrandr_line.split():\n            if \"+\" not in part or \"x\" not in part:\n                continue\n\n            screen_x = int(part.split(\"+\")[1])\n            screen_y = int(part.split(\"+\")[2])\n            screen_width = int(part.split(\"x\")[0])\n            screen_height = int(part.split(\"x\")[1].split(\"+\")[0])\n\n            if (\n                screen_x <= x <= screen_x + screen_width\n                and screen_y <= y <= screen_y + screen_height\n            ):\n                return screen_id\n\n            screen_id += 1\n\n    return DEFAULT_SCREEN_ID\n\n\ndef _get_linux_active_window_screen() -> int | None:  # noqa: PLR0911\n    \"\"\"获取Linux活跃窗口所在的屏幕ID\"\"\"\n    try:\n        xdotool_path = shutil.which(\"xdotool\")\n        if not xdotool_path:\n            return DEFAULT_SCREEN_ID\n        result = subprocess.run(  # nosec B603\n            [xdotool_path, \"getactivewindow\", \"getwindowgeometry\"],\n            capture_output=True,\n            text=True,\n            check=False,\n        )\n\n        if result.returncode != 0:\n            return DEFAULT_SCREEN_ID\n\n        position = _parse_linux_window_position(result.stdout)\n        if not position:\n            return DEFAULT_SCREEN_ID\n\n        xrandr_path = shutil.which(\"xrandr\")\n        if not xrandr_path:\n            return DEFAULT_SCREEN_ID\n        xrandr_result = subprocess.run(  # nosec B603\n            [xrandr_path, \"--current\"],\n            capture_output=True,\n            text=True,\n            check=False,\n        )\n        if xrandr_result.returncode != 0:\n            return DEFAULT_SCREEN_ID\n\n        return _find_linux_screen_for_position(position[0], position[1], xrandr_result.stdout)\n\n    except Exception as e:\n        logger.error(f\"获取Linux活跃窗口屏幕失败: {e}\")\n\n    return None\n\n\ndef _get_linux_active_window() -> tuple[str | None, str | None]:\n    \"\"\"获取Linux活跃窗口信息\"\"\"\n    try:\n        xprop_path = shutil.which(\"xprop\")\n        if not xprop_path:\n            return None, None\n        # 使用xprop获取活跃窗口ID\n        result = subprocess.run(  # nosec B603\n            [xprop_path, \"-root\", \"_NET_ACTIVE_WINDOW\"],\n            capture_output=True,\n            text=True,\n            check=False,\n        )\n        if result.returncode == 0:\n            window_id = result.stdout.strip().split()[-1]\n\n            # 获取窗口标题\n            title_result = subprocess.run(  # nosec B603\n                [xprop_path, \"-id\", window_id, \"WM_NAME\"],\n                capture_output=True,\n                text=True,\n                check=False,\n            )\n            if title_result.returncode == 0:\n                window_title = (\n                    title_result.stdout.strip().split('\"')[1]\n                    if '\"' in title_result.stdout\n                    else None\n                )\n\n                # 获取应用名称\n                class_result = subprocess.run(  # nosec B603\n                    [xprop_path, \"-id\", window_id, \"WM_CLASS\"],\n                    capture_output=True,\n                    text=True,\n                    check=False,\n                )\n                if class_result.returncode == 0:\n                    app_name = (\n                        class_result.stdout.strip().split('\"')[-2]\n                        if '\"' in class_result.stdout\n                        else None\n                    )\n                    return app_name, window_title\n    except Exception as e:\n        logger.error(f\"获取Linux窗口信息失败: {e}\")\n\n    return None, None\n\n\ndef format_file_size(size_bytes: int) -> str:\n    \"\"\"格式化文件大小\"\"\"\n    if size_bytes == 0:\n        return \"0 B\"\n\n    size_names = [\"B\", \"KB\", \"MB\", \"GB\", \"TB\"]\n    size_value = float(size_bytes)\n    i = 0\n    while size_value >= BYTES_PER_KB and i < len(size_names) - 1:\n        size_value /= float(BYTES_PER_KB)\n        i += 1\n\n    return f\"{size_value:.1f} {size_names[i]}\"\n\n\ndef get_screenshot_filename(screen_id: int = 0, timestamp: datetime | None = None) -> str:\n    \"\"\"生成截图文件名\"\"\"\n    if timestamp is None:\n        timestamp = get_utc_now()\n\n    return f\"screen_{screen_id}_{timestamp.strftime('%Y%m%d_%H%M%S_%f')[:-3]}.png\"\n\n\ndef cleanup_old_files(directory: str, max_days: int):\n    \"\"\"清理旧文件\"\"\"\n    if max_days <= 0:\n        return\n\n    cutoff_time = get_utc_now() - timedelta(days=max_days)\n\n    for file_path in Path(directory).glob(\"*.png\"):\n        try:\n            if datetime.fromtimestamp(file_path.stat().st_mtime, tz=UTC) < cutoff_time:\n                file_path.unlink()\n                logger.info(f\"清理旧文件: {file_path}\")\n        except Exception as e:\n            logger.error(f\"清理文件失败 {file_path}: {e}\")\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[project]\nname = \"lifetrace\"\nversion = \"0.1.2\"\ndescription = \"LifeTrace - A cross-platform screen recording and activity tracking application\"\nreadme = \"README.md\"\nrequires-python = \">=3.12,<3.13\"\ndependencies = [\n    # Core dependencies\n    \"fastapi>=0.100.0\",\n    \"uvicorn[standard]>=0.20.0\",\n    \"pydantic>=2.0.0\",\n    \"sqlalchemy>=2.0.0\",\n    \"sqlmodel>=0.0.22\",\n    \"alembic>=1.14.0\",\n    \"python-multipart>=0.0.6\",   # 文件上传支持（Form data）\n    \"icalendar>=6.0.0\",          # iCalendar (ICS) 导入/导出\n    # Screenshot and image processing\n    \"mss>=9.0.0\",\n    \"Pillow>=10.0.0\",\n    \"imagehash>=4.3.0\",\n    \"opencv-python>=4.8.0\",  # For proactive OCR image processing\n    # OCR processing (rapidocr requires numpy)\n    \"rapidocr-onnxruntime\",\n    \"numpy>=2.4.2\",\n    # Audio processing (for ASR WebSocket stream)\n    \"websockets>=16.0\",      # WebSocket 客户端（用于连接 WhisperLiveKit 服务器）\n    \"python-socks>=2.0.0\",   # SOCKS 代理支持（websockets 库的可选依赖）\n    # FunASR 语音识别（可选，需要 Visual C++ 构建工具）\n    # 如果安装失败，系统音频实时识别功能将不可用\n    # 安装方法：\n    # 1. 安装 Visual C++ 构建工具：https://visualstudio.microsoft.com/visual-cpp-build-tools/\n    # 2. 运行：pip install funasr\n    # \"funasr>=1.0.0\",  # 注释掉，作为可选依赖\n    # Data processing\n    \"pyyaml>=6.0\",\n    \"sentence-transformers>=2.2.0\", # 文本嵌入模型\n    \"chromadb>=1.4.1\",              # 向量数据库\n    \"scipy>=1.9.0\",                 # 科学计算（用于余弦距离）\n    \"hdbscan>=0.8.0\",               # 文本聚类算法\n    # Scheduler\n    \"apscheduler>=3.10.0\",\n    # Utils\n    \"psutil>=7.2.0\",\n    # OpenAI API\n    \"openai>=2.16.0\",\n    # DashScope API (阿里云百炼)\n    \"dashscope>=1.25.0\",\n    # Agno Agent Framework\n    \"agno>=2.4.8\",\n    # DuckDuckGo search for Agno Agent\n    \"ddgs>=9.10.0\",\n    # Tavily API for web search\n    \"tavily-python>=0.7.0\",\n    # Configuration management with hot reload support (includes yaml support)\n    \"dynaconf[yaml]>=3.2.0\",\n    # Logging\n    \"loguru>=0.7.3\",\n    # Observability (Phoenix + OpenInference)\n    \"arize-phoenix>=12.33.0\",  # Phoenix 服务器和 UI（包含 phoenix serve 命令）\n    \"arize-phoenix-otel>=0.14.0\",  # Phoenix OTEL 集成\n    \"openinference-instrumentation>=0.1.0\",  # OpenInference 基础工具（using_session 等）\n    \"openinference-instrumentation-agno>=0.1.0\",  # Agno 自动 instrument\n    \"opentelemetry-sdk>=1.39.0\",\n    \"opentelemetry-exporter-otlp-proto-http>=1.39.0\",\n    # macOS specific dependencies (only install on macOS)\n    \"pyobjc-framework-Cocoa>=12.1; sys_platform == 'darwin'\",\n    \"pyobjc-framework-Quartz>=12.1; sys_platform == 'darwin'\",\n    # Windows specific dependencies (only install on Windows)\n    \"pywin32>=306; sys_platform == 'win32'\",\n    \"openinference-instrumentation-openai>=0.1.41\",\n    \"agno-infra>=1.0.7\",\n]\n\n[dependency-groups]\n# 开发工具\ndev = [\n    \"bandit>=1.9.3\",\n    \"pre-commit>=4.5.1\",\n    \"pytest>=9.0.2\",\n    \"pyright>=1.1.408\",\n    \"pyinstaller>=6.18.0\",\n    \"ruff>=0.14.14\",\n]\n\n[tool.pytest]\ntestpaths = [\"tests\"]\nnorecursedirs = [\".venv\", \"build\", \"dist\"]\n\n# Ruff 配置 - Python linter 和 formatter\n[tool.ruff]\n# 每行最大字符数（默认 88，可以根据需要调整）\nline-length = 100\n\n# 目标 Python 版本\ntarget-version = \"py312\"\n\n# 排除的目录\nexclude = [\".git\", \".venv\", \"__pycache__\", \"build\", \"dist\", \"*.egg-info\"]\n\n[tool.ruff.format]\n# 使用双引号\nquote-style = \"double\"\n\n# 缩进使用 4 个空格\nindent-style = \"space\"\n\n[tool.ruff.lint]\n# 启用的规则集\nselect = [\n    \"E\",    # pycodestyle errors\n    \"W\",    # pycodestyle warnings\n    \"F\",    # pyflakes\n    \"I\",    # isort\n\n    \"B\",    # flake8-bugbear（高价值的潜在 bug 检查）\n    \"C4\",   # flake8-comprehensions（更合理的推导式）\n    \"SIM\",  # flake8-simplify（简化 if / boolean / loop）\n    \"UP\",   # pyupgrade（现代 Python 写法）\n\n    \"PL\",   # Pylint（逻辑 / 可维护性）\n    \"C90\",  # mccabe（复杂度）\n\n    \"A\",    # flake8-builtins（避免覆盖内建名）\n    \"ARG\",  # flake8-unused-arguments\n\n    \"N\",    # pep8-naming（命名规范）\n\n    \"TC\",  # flake8-type-checking\n    \"FA\",   # future annotations\n\n    \"ISC\",  # implicit string concatenation\n    \"FLY\",  # flynt（f-string 转换）\n\n    \"DTZ\",  # flake8-datetimez（时区相关）\n\n    \"RUF\",  # Ruff 原生经验规则\n]\n\n# 忽略的规则\nignore = [\n    \"E501\", # 行太长（已经通过 line-length 控制）\n    \"B008\", # 允许在函数参数默认值中使用函数调用（FastAPI Depends 模式）\n    \"RUF001\", # 中文 / 全角标点\n    \"RUF002\",\n    \"RUF003\",\n]\n\n# McCabe 复杂度配置\n[tool.ruff.lint.mccabe]\n# 最大圈复杂度（软限制：10，硬限制：15）\nmax-complexity = 10\n\n[tool.ruff.lint.per-file-ignores]\n# 测试文件可以使用 assert\n\"tests/*.py\" = [\"S101\"]\n\n# Pylint 复杂度配置\n[tool.ruff.lint.pylint]\n# 函数最大语句数（软限制：50，硬限制：100）\nmax-statements = 50\n# 函数最大参数数\nmax-args = 7\n# 函数最大返回语句数\nmax-returns = 6\n# 函数最大分支数\nmax-branches = 12\n# 类中最大公共方法数（帮助控制单个文件大小）\nmax-public-methods = 20\n\n[tool.uv]\nindex-url = \"https://pypi.tuna.tsinghua.edu.cn/simple\"\nextra-index-url = [\"https://pypi.org/simple\"]\n"
  },
  {
    "path": "pyrightconfig.json",
    "content": "{\n  \"typeCheckingMode\": \"standard\",\n  \"pythonVersion\": \"3.12\",\n  \"venvPath\": \".\",\n  \"venv\": \".venv\",\n  \"include\": [\"lifetrace\", \"scripts\"],\n  \"exclude\": [\n    \"**/__pycache__\",\n    \".venv\",\n    \".venv/**\",\n    \"build\",\n    \"build/**\",\n    \"dist\",\n    \"dist/**\",\n    \"lifetrace/dist\",\n    \"lifetrace/dist/**\",\n    \"lifetrace/data\",\n    \"lifetrace/data/**\",\n    \"lifetrace/migrations/versions\"\n  ],\n  \"reportMissingTypeStubs\": \"none\"\n}\n"
  },
  {
    "path": "requirements-runtime.txt",
    "content": "fastapi>=0.100.0\nuvicorn[standard]>=0.20.0\npydantic>=2.0.0\nsqlalchemy>=2.0.0\nsqlmodel>=0.0.22\nalembic>=1.14.0\npython-multipart>=0.0.6\nmss>=9.0.0\nPillow>=10.0.0\nimagehash>=4.3.0\nopencv-python>=4.8.0\nrapidocr-onnxruntime\nnumpy>=1.21.0,<2.0.0\nwebsockets>=12.0\npython-socks>=2.0.0\npyyaml>=6.0\nsentence-transformers>=2.2.0\nchromadb>=0.4.0\nscipy>=1.9.0\nhdbscan>=0.8.0\napscheduler>=3.10.0\npsutil>=5.9.0\nopenai>=1.0.0\ndashscope>=1.17.0\nagno>=2.4.1\nddgs>=8.0.0\ntavily-python>=0.5.0\ndynaconf[yaml]>=3.2.0\nloguru>=0.7.3\npyobjc-framework-Cocoa>=9.0; sys_platform == \"darwin\"\npyobjc-framework-Quartz>=9.0; sys_platform == \"darwin\"\npywin32>=306; sys_platform == \"win32\"\nagno-infra>=1.0.7\n"
  },
  {
    "path": "scripts/git-hooks/post-checkout",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\nroot=\"$(git rev-parse --show-toplevel 2>/dev/null || exit 0)\"\n\n# Skip main worktree where .git is a directory; worktrees have .git as a file.\nif [[ -d \"$root/.git\" ]]; then\n  exit 0\nfi\n\nif [[ -f \"$root/scripts/link_worktree_deps_here.ps1\" || -f \"$root/scripts/link_worktree_deps_here.sh\" ]]; then\n  case \"$(uname -s)\" in\n    MINGW*|MSYS*|CYGWIN*)\n      powershell -ExecutionPolicy Bypass -File \"$root/scripts/link_worktree_deps_here.ps1\"\n      ;;\n    *)\n      bash \"$root/scripts/link_worktree_deps_here.sh\"\n      ;;\n  esac\nfi\n"
  },
  {
    "path": "scripts/install.ps1",
    "content": "param(\n    [string]$Dir = $env:LIFETRACE_DIR,\n    [string]$Repo = $env:LIFETRACE_REPO,\n    [Alias(\"r\")]\n    [string]$Ref = $env:LIFETRACE_REF,\n    [Alias(\"m\")]\n    [string]$Mode = $env:LIFETRACE_MODE,\n    [string]$Variant = $env:LIFETRACE_VARIANT,\n    [string]$Frontend = $env:LIFETRACE_FRONTEND,\n    [string]$Backend = $env:LIFETRACE_BACKEND,\n    [string]$Run = $env:LIFETRACE_RUN\n)\n\nSet-StrictMode -Version Latest\n$ErrorActionPreference = \"Stop\"\n\n$frontendSet = $PSBoundParameters.ContainsKey(\"Frontend\") -or [bool]$env:LIFETRACE_FRONTEND\n$variantSet = $PSBoundParameters.ContainsKey(\"Variant\") -or [bool]$env:LIFETRACE_VARIANT\n$dirSet = $PSBoundParameters.ContainsKey(\"Dir\") -or [bool]$env:LIFETRACE_DIR\n$modeSet = $PSBoundParameters.ContainsKey(\"Mode\") -or [bool]$env:LIFETRACE_MODE\n$backendSet = $PSBoundParameters.ContainsKey(\"Backend\") -or [bool]$env:LIFETRACE_BACKEND\n\nif (-not $Repo) {\n    $Repo = \"https://github.com/FreeU-group/FreeTodo.git\"\n}\nif (-not $Ref) {\n    $Ref = \"main\"\n}\nif (-not $Mode) {\n    $Mode = \"tauri\"\n}\nif (-not $Variant) {\n    $Variant = \"web\"\n}\nif (-not $Frontend) {\n    $Frontend = \"build\"\n}\nif (-not $Backend) {\n    $Backend = \"script\"\n}\nif (-not $Run) {\n    $Run = \"1\"\n}\n\nfunction Prompt-Choice {\n    param(\n        [string]$Label,\n        [string[]]$Choices,\n        [string]$Default\n    )\n    Write-Host $Label\n    for ($i = 0; $i -lt $Choices.Count; $i++) {\n        Write-Host \"  $($i + 1)) $($Choices[$i])\"\n    }\n    $input = Read-Host \"Select [default: $Default]\"\n    if ([string]::IsNullOrWhiteSpace($input)) {\n        return $Default\n    }\n    if ($input -match '^\\d+$') {\n        $index = [int]$input - 1\n        if ($index -ge 0 -and $index -lt $Choices.Count) {\n            return $Choices[$index]\n        }\n    } else {\n        foreach ($choice in $Choices) {\n            if ($choice -eq $input) {\n                return $choice\n            }\n        }\n    }\n    Write-Host \"Invalid choice. Using default: $Default\"\n    return $Default\n}\n\nif (-not $dirSet) {\n    $repoName = [IO.Path]::GetFileNameWithoutExtension($Repo)\n    $Dir = $repoName\n}\n\nif (-not $variantSet) {\n    $Variant = Prompt-Choice \"Select UI variant:\" @(\"web\", \"island\") \"web\"\n}\nif (-not $backendSet) {\n    $Backend = Prompt-Choice \"Select backend runtime:\" @(\"script\", \"pyinstaller\") \"script\"\n}\nif (-not $modeSet) {\n    $Mode = Prompt-Choice \"Select app mode:\" @(\"tauri\", \"electron\", \"web\") \"tauri\"\n}\n\nif ($Mode -eq \"island\") {\n    $Mode = \"tauri\"\n    $Variant = \"island\"\n    $variantSet = $true\n}\n\nif ($Variant -eq \"island\" -and $Mode -eq \"web\") {\n    Write-Host \"Variant 'island' is not supported in web mode. Switching mode to tauri.\"\n    $Mode = \"tauri\"\n}\n\nif ($Mode -eq \"web\" -and $Variant -ne \"web\") {\n    throw \"Variant '$Variant' is not supported in web mode.\"\n}\n\n$validModes = @(\"web\", \"tauri\", \"electron\")\nif ($validModes -notcontains $Mode) {\n    throw \"Invalid mode: $Mode\"\n}\n\n$validVariants = @(\"web\", \"island\")\nif ($validVariants -notcontains $Variant) {\n    throw \"Invalid variant: $Variant\"\n}\n\n$validFrontend = @(\"build\", \"dev\")\nif ($validFrontend -notcontains $Frontend) {\n    throw \"Invalid frontend action: $Frontend\"\n}\n\n$validBackend = @(\"script\", \"pyinstaller\")\nif ($validBackend -notcontains $Backend) {\n    throw \"Invalid backend runtime: $Backend\"\n}\n\nif ($Backend -eq \"pyinstaller\" -and -not $frontendSet) {\n    $Frontend = \"build\"\n}\n\nif ($Frontend -eq \"dev\" -and $Backend -eq \"pyinstaller\") {\n    throw \"backend=pyinstaller is only supported with frontend=build.\"\n}\n\nif ($Mode -eq \"tauri\" -and $Frontend -eq \"build\" -and $Variant -eq \"island\") {\n    Write-Host \"Island packaging is not supported yet. Switching variant to web for build.\"\n    $Variant = \"web\"\n}\n\nfunction Test-Command {\n    param([string]$Name)\n    return [bool](Get-Command $Name -ErrorAction SilentlyContinue)\n}\n\n$missingDeps = New-Object System.Collections.Generic.List[object]\n\nfunction Add-MissingDep {\n    param(\n        [string]$Name,\n        [string]$Hint,\n        [string]$WingetId = \"\"\n    )\n    $missingDeps.Add([pscustomobject]@{\n            Name     = $Name\n            Hint     = $Hint\n            WingetId = $WingetId\n        })\n}\n\nfunction Refresh-Path {\n    $machinePath = [System.Environment]::GetEnvironmentVariable(\"Path\", \"Machine\")\n    $userPath = [System.Environment]::GetEnvironmentVariable(\"Path\", \"User\")\n    $env:Path = \"$machinePath;$userPath\"\n}\n\nfunction Install-MissingDeps {\n    if ($missingDeps.Count -eq 0) {\n        return\n    }\n\n    $wingetDeps = $missingDeps | Where-Object { $_.WingetId }\n    if ($wingetDeps -and -not (Test-Command \"winget\")) {\n        Install-Winget | Out-Null\n    }\n    if ($wingetDeps -and (Test-Command \"winget\")) {\n        foreach ($dep in $wingetDeps) {\n            Write-Host \"Installing $($dep.Name) with winget...\"\n            winget install --id $($dep.WingetId) -e --accept-package-agreements --accept-source-agreements\n        }\n        Refresh-Path\n    }\n}\n\nfunction Filter-MissingDeps {\n    $remaining = New-Object System.Collections.Generic.List[object]\n    foreach ($dep in $missingDeps) {\n        if (-not (Test-Command $dep.Name)) {\n            $remaining.Add($dep)\n        }\n    }\n    $script:missingDeps = $remaining\n}\n\nfunction Show-MissingDeps {\n    if ($missingDeps.Count -eq 0) {\n        return\n    }\n\n    Write-Host \"Missing required dependencies:\" -ForegroundColor Red\n    foreach ($dep in $missingDeps) {\n        Write-Host \"- $($dep.Name): $($dep.Hint)\"\n    }\n\n    $wingetDeps = $missingDeps | Where-Object { $_.WingetId }\n    if ($wingetDeps -and (Test-Command \"winget\")) {\n        Write-Host \"Install with winget:\"\n        foreach ($dep in $wingetDeps) {\n            Write-Host \"  winget install --id $($dep.WingetId) -e --accept-package-agreements --accept-source-agreements\"\n        }\n    } elseif ($wingetDeps) {\n        Write-Host \"winget not found. Install App Installer from Microsoft Store or from https://aka.ms/getwinget\"\n    }\n\n    throw \"Missing required dependencies. Install them and retry.\"\n}\n\nfunction Install-Winget {\n    if (Test-Command \"winget\") {\n        return $true\n    }\n    if (-not (Test-Command \"Add-AppxPackage\")) {\n        return $false\n    }\n    $wingetInstallerUrl = \"https://aka.ms/getwinget\"\n    $installerPath = Join-Path $env:TEMP \"winget.msixbundle\"\n    try {\n        Write-Host \"winget not found. Attempting to install App Installer...\"\n        Invoke-WebRequest -Uri $wingetInstallerUrl -OutFile $installerPath\n        Add-AppxPackage -Path $installerPath\n        Remove-Item $installerPath -Force -ErrorAction SilentlyContinue\n    } catch {\n        Write-Host \"Automatic winget install failed.\"\n        return $false\n    }\n    return (Test-Command \"winget\")\n}\n\nfunction Get-LatestFile {\n    param(\n        [string]$Path,\n        [string]$Filter\n    )\n    if (-not (Test-Path $Path)) {\n        return $null\n    }\n    $file = Get-ChildItem -Path $Path -Filter $Filter -File -Recurse -ErrorAction SilentlyContinue |\n        Sort-Object LastWriteTime -Descending |\n        Select-Object -First 1\n    if ($file) {\n        return $file.FullName\n    }\n    return $null\n}\n\nfunction Find-TauriArtifact {\n    param(\n        [string]$FrontendDir,\n        [string]$Variant,\n        [string]$Backend\n    )\n    $artifactBase = Join-Path $FrontendDir \"dist-artifacts\\tauri\\$Variant\\$Backend\"\n    $bundleDir = Join-Path $FrontendDir \"src-tauri\\target\\release\\bundle\"\n    $binaryDir = Join-Path $FrontendDir \"src-tauri\\target\\release\"\n    $artifact = Get-LatestFile $binaryDir \"*.exe\"\n    if ($artifact) {\n        return $artifact\n    }\n    $artifact = Get-LatestFile $artifactBase \"*.exe\"\n    if (-not $artifact) {\n        $artifact = Get-LatestFile $bundleDir \"*.exe\"\n    }\n    if (-not $artifact) {\n        $artifact = Get-LatestFile $bundleDir \"*.msi\"\n    }\n    return $artifact\n}\n\nfunction Find-ElectronArtifact {\n    param(\n        [string]$FrontendDir,\n        [string]$Variant,\n        [string]$Backend\n    )\n    $artifactBase = Join-Path $FrontendDir \"dist-artifacts\\electron\\$Variant\\$Backend\"\n    $artifact = Get-LatestFile $artifactBase \"*.exe\"\n    if (-not $artifact) {\n        $artifact = Get-LatestFile $artifactBase \"*.msi\"\n    }\n    return $artifact\n}\n\nfunction Start-BuiltApp {\n    param([string]$ArtifactPath)\n    if (-not $ArtifactPath) {\n        return $false\n    }\n    Write-Host \"Launching built app: $ArtifactPath\"\n    Start-Process -FilePath $ArtifactPath | Out-Null\n    return $true\n}\n\n$pythonCmd = $env:PYTHON_BIN\nif (-not $pythonCmd) {\n    if (Test-Command \"python\") {\n        $pythonCmd = \"python\"\n    } elseif (Test-Command \"python3\") {\n        $pythonCmd = \"python3\"\n    } else {\n        Add-MissingDep \"python\" \"Python 3.12+ not found. Install Python and retry.\" \"Python.Python.3.12\"\n    }\n} elseif (-not (Test-Command $pythonCmd)) {\n    Add-MissingDep \"python\" \"Python 3.12+ not found. Install Python and retry.\" \"Python.Python.3.12\"\n}\n\nif (-not (Test-Command \"git\")) {\n    Add-MissingDep \"git\" \"Install Git and retry.\" \"Git.Git\"\n}\nif (-not (Test-Command \"node\")) {\n    Add-MissingDep \"node\" \"Install Node.js 20+ and retry.\" \"OpenJS.NodeJS.LTS\"\n}\n\nif ($Mode -eq \"tauri\") {\n    if (-not (Test-Command \"cargo\")) {\n        Add-MissingDep \"cargo\" \"Install Rust (rustup) and retry, or set LIFETRACE_MODE=web.\" \"Rustlang.Rustup\"\n    }\n}\n\nInstall-MissingDeps\nFilter-MissingDeps\nShow-MissingDeps\n\nif (-not $pythonCmd -or -not (Test-Command $pythonCmd)) {\n    if (Test-Command \"python\") {\n        $pythonCmd = \"python\"\n    } elseif (Test-Command \"python3\") {\n        $pythonCmd = \"python3\"\n    } else {\n        throw \"Python 3.12+ not found after installation. Reopen your terminal and retry.\"\n    }\n}\n\nif (-not (Test-Command \"uv\")) {\n    Write-Host \"Installing uv...\"\n    irm https://astral.sh/uv/install.ps1 | iex\n    $env:Path = \"$env:USERPROFILE\\.local\\bin;$env:Path\"\n}\n\nif (-not (Test-Command \"pnpm\")) {\n    $pnpmInstalled = $false\n    if (Test-Command \"corepack\") {\n        try {\n            corepack enable\n            corepack prepare pnpm@latest --activate\n            $pnpmInstalled = Test-Command \"pnpm\"\n        } catch {\n            Write-Host \"corepack activation failed. Falling back to pnpm install script.\"\n        }\n    }\n    if (-not $pnpmInstalled -and (Test-Command \"npm\")) {\n        try {\n            npm install -g pnpm\n            Refresh-Path\n            $pnpmInstalled = Test-Command \"pnpm\"\n        } catch {\n            Write-Host \"npm global install failed. Falling back to pnpm install script.\"\n        }\n    }\n    if (-not $pnpmInstalled) {\n        Write-Host \"Installing pnpm via install script...\"\n        $env:PNPM_HOME = Join-Path $env:USERPROFILE \".local\\share\\pnpm\"\n        if (-not (Test-Path $env:PNPM_HOME)) {\n            New-Item -ItemType Directory -Force -Path $env:PNPM_HOME | Out-Null\n        }\n        $env:Path = \"$env:PNPM_HOME;$env:Path\"\n        try {\n            irm https://get.pnpm.io/install.ps1 | iex\n        } catch {\n            throw \"pnpm install script failed. Install pnpm manually and retry.\"\n        }\n        $pnpmInstalled = Test-Command \"pnpm\"\n    }\n    if (-not $pnpmInstalled) {\n        throw \"pnpm not found after installation. Reopen your terminal and retry.\"\n    }\n}\n\n$repoReady = $false\n$depsReady = $false\nif (Test-Path $Dir) {\n    if (-not (Test-Path (Join-Path $Dir \".git\"))) {\n        throw \"Target path '$Dir' exists and is not a git repo. Set LIFETRACE_DIR to a new folder.\"\n    }\n    Set-Location $Dir\n    $gitStatus = git status --porcelain\n    if ($gitStatus) {\n        throw \"Repository has local changes. Commit or stash and retry.\"\n    }\n    git fetch --depth 1 \"$Repo\" \"$Ref\"\n    $headSha = git rev-parse HEAD\n    $remoteSha = git rev-parse FETCH_HEAD\n    if ($headSha -eq $remoteSha) {\n        $repoReady = $true\n    }\n} else {\n    git clone --depth 1 --branch \"$Ref\" \"$Repo\" \"$Dir\"\n    Set-Location $Dir\n}\n\n$venvReady = Test-Path (Join-Path (Get-Location).Path \".venv\")\n$frontendModulesReady = Test-Path (Join-Path (Get-Location).Path \"free-todo-frontend\\node_modules\")\n$depsReady = $venvReady -and $frontendModulesReady\n\nif (-not $repoReady -or -not $depsReady) {\n    $gitStatus = git status --porcelain\n    if ($gitStatus) {\n        throw \"Repository has local changes. Commit or stash and retry.\"\n    }\n    git fetch --depth 1 \"$Repo\" \"$Ref\"\n    git checkout -q -B \"$Ref\" FETCH_HEAD\n    uv sync\n    $venvReady = Test-Path (Join-Path (Get-Location).Path \".venv\")\n    $frontendModulesReady = Test-Path (Join-Path (Get-Location).Path \"free-todo-frontend\\node_modules\")\n    $depsReady = $venvReady -and $frontendModulesReady\n} else {\n    Write-Host \"Repository is up to date. Skipping install steps.\"\n}\n\nif ($Run -ne \"1\") {\n    Write-Host \"Install complete.\"\n    exit 0\n}\n\nif ($Mode -eq \"web\") {\n    $uvPath = (Get-Command uv).Source\n    $backendJob = Start-Job -ScriptBlock {\n        param($RepoDir, $UvPath, $PythonCmd)\n        Set-Location $RepoDir\n        & $UvPath run $PythonCmd -m lifetrace.server\n    } -ArgumentList (Get-Location).Path, $uvPath, $pythonCmd\n\n    try {\n        Set-Location (Join-Path (Get-Location).Path \"free-todo-frontend\")\n        if (-not $frontendModulesReady) {\n            pnpm install\n        }\n        if ($Frontend -eq \"build\") {\n            $nextDir = Join-Path (Get-Location).Path \".next\"\n            if (-not ($repoReady -and $depsReady -and (Test-Path $nextDir))) {\n                pnpm build\n            } else {\n                Write-Host \"Next.js build is up to date. Skipping build step.\"\n            }\n            pnpm start\n        } else {\n            $env:WINDOW_MODE = $Variant\n            pnpm dev\n        }\n    } finally {\n        if ($backendJob -and $backendJob.State -eq \"Running\") {\n            Stop-Job $backendJob | Out-Null\n        }\n        if ($backendJob) {\n            Remove-Job $backendJob -Force | Out-Null\n        }\n    }\n} elseif ($Mode -eq \"tauri\") {\n    Set-Location (Join-Path (Get-Location).Path \"free-todo-frontend\")\n    if (-not $frontendModulesReady) {\n        pnpm install\n    }\n\n    if ($Frontend -eq \"build\") {\n        $artifact = Find-TauriArtifact -FrontendDir (Get-Location).Path -Variant $Variant -Backend $Backend\n        if (-not ($repoReady -and $depsReady -and $artifact)) {\n            pnpm \"build:tauri:${Variant}:${Backend}:full\"\n            $artifact = Find-TauriArtifact -FrontendDir (Get-Location).Path -Variant $Variant -Backend $Backend\n        } else {\n            Write-Host \"Tauri build is up to date. Skipping build step.\"\n        }\n        if (-not (Start-BuiltApp $artifact)) {\n            Write-Host \"Build complete. Open the artifact under src-tauri\\\\target\\\\release\\\\bundle.\"\n        }\n    } else {\n        $uvPath = (Get-Command uv).Source\n        $backendJob = Start-Job -ScriptBlock {\n            param($RepoDir, $UvPath, $PythonCmd)\n            Set-Location $RepoDir\n            & $UvPath run $PythonCmd -m lifetrace.server\n        } -ArgumentList (Resolve-Path \"..\").Path, $uvPath, $pythonCmd\n\n        $frontendJob = Start-Job -ScriptBlock {\n            param($FrontendDir, $Variant)\n            Set-Location $FrontendDir\n            $env:WINDOW_MODE = $Variant\n            pnpm dev\n        } -ArgumentList (Get-Location).Path, $Variant\n\n        try {\n            pnpm tauri:dev\n        } finally {\n            if ($frontendJob -and $frontendJob.State -eq \"Running\") {\n                Stop-Job $frontendJob | Out-Null\n            }\n            if ($frontendJob) {\n                Remove-Job $frontendJob -Force | Out-Null\n            }\n            if ($backendJob -and $backendJob.State -eq \"Running\") {\n                Stop-Job $backendJob | Out-Null\n            }\n            if ($backendJob) {\n                Remove-Job $backendJob -Force | Out-Null\n            }\n        }\n    }\n} else {\n    Set-Location (Join-Path (Get-Location).Path \"free-todo-frontend\")\n    if (-not $frontendModulesReady) {\n        pnpm install\n    }\n\n    if ($Frontend -eq \"build\") {\n        $artifact = Find-ElectronArtifact -FrontendDir (Get-Location).Path -Variant $Variant -Backend $Backend\n        if (-not ($repoReady -and $depsReady -and $artifact)) {\n            pnpm \"build:electron:${Variant}:${Backend}:full:dir\"\n            $artifact = Find-ElectronArtifact -FrontendDir (Get-Location).Path -Variant $Variant -Backend $Backend\n        } else {\n            Write-Host \"Electron build is up to date. Skipping build step.\"\n        }\n        if (-not (Start-BuiltApp $artifact)) {\n            Write-Host \"Build complete. Open the artifact under dist-artifacts\\\\electron.\"\n        }\n    } else {\n        if ($Backend -eq \"pyinstaller\") {\n            throw \"backend=pyinstaller is only supported with frontend=build.\"\n        }\n        if ($Variant -eq \"island\") {\n            pnpm electron:dev:island\n        } else {\n            pnpm electron:dev\n        }\n    }\n}\n"
  },
  {
    "path": "scripts/install.sh",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\nREPO_URL=\"${LIFETRACE_REPO:-https://github.com/FreeU-group/FreeTodo.git}\"\nREF=\"${LIFETRACE_REF:-main}\"\nREPO_NAME=\"${REPO_URL##*/}\"\nREPO_NAME=\"${REPO_NAME%.git}\"\nTARGET_DIR=\"${LIFETRACE_DIR:-$REPO_NAME}\"\nMODE=\"${LIFETRACE_MODE:-tauri}\"\nVARIANT=\"${LIFETRACE_VARIANT:-web}\"\nFRONTEND_ACTION=\"${LIFETRACE_FRONTEND:-build}\"\nBACKEND_RUNTIME=\"${LIFETRACE_BACKEND:-script}\"\nRUN_AFTER_INSTALL=\"${LIFETRACE_RUN:-1}\"\n\nDIR_SET=0\nif [ -n \"${LIFETRACE_DIR:-}\" ]; then\n  DIR_SET=1\nfi\nFRONTEND_SET=0\nif [ -n \"${LIFETRACE_FRONTEND:-}\" ]; then\n  FRONTEND_SET=1\nfi\nVARIANT_SET=0\nif [ -n \"${LIFETRACE_VARIANT:-}\" ]; then\n  VARIANT_SET=1\nfi\nMODE_SET=0\nif [ -n \"${LIFETRACE_MODE:-}\" ]; then\n  MODE_SET=1\nfi\nBACKEND_SET=0\nif [ -n \"${LIFETRACE_BACKEND:-}\" ]; then\n  BACKEND_SET=1\nfi\n\nusage() {\n  cat <<'EOF'\nUsage: install.sh [options]\n\nOptions:\n  --ref, -r       Git branch or tag to clone\n  --mode, -m      web | tauri | electron | island\n  --variant       web | island\n  --frontend      build | dev\n  --backend       script | pyinstaller\n  --repo          Git repo URL\n  --dir           Target directory\n  --run           1 to run after install, 0 to only install\n  --help, -h      Show this help message\n\nEnv vars:\n  LIFETRACE_REPO, LIFETRACE_REF, LIFETRACE_DIR\n  LIFETRACE_MODE, LIFETRACE_VARIANT, LIFETRACE_FRONTEND, LIFETRACE_BACKEND, LIFETRACE_RUN\n\nDefaults:\n  mode=tauri, variant=web, frontend=build, backend=script, ref=main\nEOF\n}\n\nwhile [ $# -gt 0 ]; do\n  case \"$1\" in\n    --ref|-r)\n      if [ $# -lt 2 ]; then\n        echo \"Missing value for --ref.\" >&2\n        exit 1\n      fi\n      REF=\"$2\"\n      shift 2\n      ;;\n    --mode|-m)\n      if [ $# -lt 2 ]; then\n        echo \"Missing value for --mode.\" >&2\n        exit 1\n      fi\n      MODE=\"$2\"\n      MODE_SET=1\n      shift 2\n      ;;\n    --variant)\n      if [ $# -lt 2 ]; then\n        echo \"Missing value for --variant.\" >&2\n        exit 1\n      fi\n      VARIANT=\"$2\"\n      VARIANT_SET=1\n      shift 2\n      ;;\n    --frontend)\n      if [ $# -lt 2 ]; then\n        echo \"Missing value for --frontend.\" >&2\n        exit 1\n      fi\n      FRONTEND_ACTION=\"$2\"\n      FRONTEND_SET=1\n      shift 2\n      ;;\n    --backend)\n      if [ $# -lt 2 ]; then\n        echo \"Missing value for --backend.\" >&2\n        exit 1\n      fi\n      BACKEND_RUNTIME=\"$2\"\n      BACKEND_SET=1\n      shift 2\n      ;;\n    --repo)\n      if [ $# -lt 2 ]; then\n        echo \"Missing value for --repo.\" >&2\n        exit 1\n      fi\n      REPO_URL=\"$2\"\n      REPO_NAME=\"${REPO_URL##*/}\"\n      REPO_NAME=\"${REPO_NAME%.git}\"\n      if [ \"$DIR_SET\" -eq 0 ]; then\n        TARGET_DIR=\"$REPO_NAME\"\n      fi\n      shift 2\n      ;;\n    --dir)\n      if [ $# -lt 2 ]; then\n        echo \"Missing value for --dir.\" >&2\n        exit 1\n      fi\n      TARGET_DIR=\"$2\"\n      DIR_SET=1\n      shift 2\n      ;;\n    --run)\n      if [ $# -lt 2 ]; then\n        echo \"Missing value for --run.\" >&2\n        exit 1\n      fi\n      RUN_AFTER_INSTALL=\"$2\"\n      shift 2\n      ;;\n    --help|-h)\n      usage\n      exit 0\n      ;;\n    *)\n      echo \"Unknown argument: $1\" >&2\n      usage >&2\n      exit 1\n      ;;\n  esac\ndone\n\nprompt_choice() {\n  local label=\"$1\"\n  local default=\"$2\"\n  shift 2\n  local choices=(\"$@\")\n  if [ ! -t 0 ]; then\n    echo \"$default\"\n    return 0\n  fi\n  echo \"$label\" >&2\n  local i=1\n  for choice in \"${choices[@]}\"; do\n    echo \"  $i) $choice\" >&2\n    i=$((i + 1))\n  done\n  read -r -p \"Select [default: $default]: \" input\n  if [ -z \"${input}\" ]; then\n    echo \"$default\"\n    return 0\n  fi\n  if [[ \"$input\" =~ ^[0-9]+$ ]]; then\n    local index=$((input - 1))\n    if [ \"$index\" -ge 0 ] && [ \"$index\" -lt \"${#choices[@]}\" ]; then\n      echo \"${choices[$index]}\"\n      return 0\n    fi\n  else\n    for choice in \"${choices[@]}\"; do\n      if [ \"$choice\" = \"$input\" ]; then\n        echo \"$choice\"\n        return 0\n      fi\n    done\n  fi\n  echo \"Invalid choice. Using default: $default\" >&2\n  echo \"$default\"\n}\n\nif [ \"$VARIANT_SET\" -eq 0 ]; then\n  VARIANT=\"$(prompt_choice \"Select UI variant:\" \"web\" \"web\" \"island\")\"\nfi\nif [ \"$BACKEND_SET\" -eq 0 ]; then\n  BACKEND_RUNTIME=\"$(prompt_choice \"Select backend runtime:\" \"script\" \"script\" \"pyinstaller\")\"\nfi\nif [ \"$MODE_SET\" -eq 0 ]; then\n  MODE=\"$(prompt_choice \"Select app mode:\" \"tauri\" \"tauri\" \"electron\" \"web\")\"\nfi\n\nif [ \"$MODE\" = \"island\" ]; then\n  MODE=\"tauri\"\n  VARIANT=\"island\"\n  VARIANT_SET=1\nfi\n\nif [ \"$VARIANT\" = \"island\" ] && [ \"$MODE\" = \"web\" ]; then\n  echo \"Variant 'island' is not supported in web mode. Switching mode to tauri.\"\n  MODE=\"tauri\"\nfi\n\nif [ \"$MODE\" = \"web\" ] && [ \"$VARIANT\" != \"web\" ]; then\n  echo \"Variant '$VARIANT' is not supported in web mode.\" >&2\n  exit 1\nfi\n\ncase \"$MODE\" in\n  web|tauri|electron) ;;\n  *)\n    echo \"Invalid mode: $MODE\" >&2\n    exit 1\n    ;;\n esac\n\ncase \"$VARIANT\" in\n  web|island) ;;\n  *)\n    echo \"Invalid variant: $VARIANT\" >&2\n    exit 1\n    ;;\nesac\n\ncase \"$FRONTEND_ACTION\" in\n  build|dev) ;;\n  *)\n    echo \"Invalid frontend action: $FRONTEND_ACTION\" >&2\n    exit 1\n    ;;\nesac\n\ncase \"$BACKEND_RUNTIME\" in\n  script|pyinstaller) ;;\n  *)\n    echo \"Invalid backend runtime: $BACKEND_RUNTIME\" >&2\n    exit 1\n    ;;\nesac\n\nif [ \"$BACKEND_RUNTIME\" = \"pyinstaller\" ] && [ \"$FRONTEND_SET\" -eq 0 ]; then\n  FRONTEND_ACTION=\"build\"\nfi\n\nif [ \"$FRONTEND_ACTION\" = \"dev\" ] && [ \"$BACKEND_RUNTIME\" = \"pyinstaller\" ]; then\n  echo \"backend=pyinstaller is only supported with frontend=build.\" >&2\n  exit 1\nfi\n\nif [ \"$MODE\" = \"tauri\" ] && [ \"$FRONTEND_ACTION\" = \"build\" ] && [ \"$VARIANT\" = \"island\" ]; then\n  echo \"Island packaging is not supported yet. Switching variant to web for build.\"\n  VARIANT=\"web\"\nfi\n\nMISSING_DEPS=()\nMISSING_HINTS=()\n\nadd_missing() {\n  MISSING_DEPS+=(\"$1\")\n  MISSING_HINTS+=(\"$2\")\n}\n\nOS_TYPE=\"$(uname -s)\"\n\nas_root() {\n  if [ \"$(id -u)\" -eq 0 ]; then\n    \"$@\"\n  else\n    sudo \"$@\"\n  fi\n}\n\nensure_brew() {\n  if command -v brew >/dev/null 2>&1; then\n    return 0\n  fi\n  if ! command -v curl >/dev/null 2>&1; then\n    echo \"curl is required to install Homebrew.\" >&2\n    return 1\n  fi\n  echo \"Installing Homebrew...\"\n  NONINTERACTIVE=1 /bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"\n  if [ -x /opt/homebrew/bin/brew ]; then\n    eval \"$(/opt/homebrew/bin/brew shellenv)\"\n  elif [ -x /usr/local/bin/brew ]; then\n    eval \"$(/usr/local/bin/brew shellenv)\"\n  fi\n}\n\ninstall_packages() {\n  if [ \"$#\" -eq 0 ]; then\n    return 0\n  fi\n  if [ \"$OS_TYPE\" = \"Darwin\" ]; then\n    ensure_brew || return 1\n    brew install \"$@\"\n    return $?\n  fi\n  if [ \"$OS_TYPE\" = \"Linux\" ]; then\n    if command -v apt-get >/dev/null 2>&1; then\n      as_root apt-get update\n      as_root apt-get install -y \"$@\"\n      return $?\n    fi\n    if command -v dnf >/dev/null 2>&1; then\n      as_root dnf install -y \"$@\"\n      return $?\n    fi\n    if command -v yum >/dev/null 2>&1; then\n      as_root yum install -y \"$@\"\n      return $?\n    fi\n    if command -v pacman >/dev/null 2>&1; then\n      as_root pacman -Sy --noconfirm \"$@\"\n      return $?\n    fi\n  fi\n  echo \"No supported package manager found to install: $*\" >&2\n  return 1\n}\n\ninstall_rustup() {\n  if command -v cargo >/dev/null 2>&1; then\n    return 0\n  fi\n  echo \"Installing Rust (rustup)...\"\n  if command -v curl >/dev/null 2>&1; then\n    curl -sSf https://sh.rustup.rs | sh -s -- -y\n  elif command -v wget >/dev/null 2>&1; then\n    wget -qO- https://sh.rustup.rs | sh -s -- -y\n  else\n    return 1\n  fi\n  export PATH=\"$HOME/.cargo/bin:$PATH\"\n}\n\ninstall_missing_deps() {\n  if [ \"${#MISSING_DEPS[@]}\" -eq 0 ]; then\n    return 0\n  fi\n\n  local packages=()\n  local need_rustup=0\n  local dep\n  for dep in \"${MISSING_DEPS[@]}\"; do\n    case \"$dep\" in\n      python)\n        if [ \"$OS_TYPE\" = \"Darwin\" ]; then\n          packages+=(\"python@3.12\")\n        elif [ \"$OS_TYPE\" = \"Linux\" ]; then\n          if command -v pacman >/dev/null 2>&1; then\n            packages+=(\"python\" \"python-pip\")\n          else\n            packages+=(\"python3\" \"python3-pip\" \"python3-venv\")\n          fi\n        fi\n        ;;\n      git)\n        packages+=(\"git\")\n        ;;\n      node)\n        if [ \"$OS_TYPE\" = \"Darwin\" ]; then\n          packages+=(\"node\")\n        elif [ \"$OS_TYPE\" = \"Linux\" ]; then\n          if command -v pacman >/dev/null 2>&1; then\n            packages+=(\"nodejs\" \"npm\")\n          else\n            packages+=(\"nodejs\" \"npm\")\n          fi\n        fi\n        ;;\n      cargo)\n        need_rustup=1\n        ;;\n      curl/wget)\n        if [ \"$OS_TYPE\" = \"Darwin\" ]; then\n          packages+=(\"curl\" \"wget\")\n        elif [ \"$OS_TYPE\" = \"Linux\" ]; then\n          packages+=(\"curl\" \"wget\")\n        fi\n        ;;\n    esac\n  done\n\n  if [ \"${#packages[@]}\" -gt 0 ]; then\n    install_packages \"${packages[@]}\" || return 1\n  fi\n  if [ \"$need_rustup\" -eq 1 ]; then\n    install_rustup || return 1\n  fi\n}\n\nfilter_missing_deps() {\n  local remaining_deps=()\n  local remaining_hints=()\n  local dep\n  local hint\n  for i in \"${!MISSING_DEPS[@]}\"; do\n    dep=\"${MISSING_DEPS[$i]}\"\n    hint=\"${MISSING_HINTS[$i]}\"\n    if [ \"$dep\" = \"python\" ]; then\n      if command -v python >/dev/null 2>&1 || command -v python3 >/dev/null 2>&1; then\n        continue\n      fi\n    fi\n    if [ \"$dep\" = \"curl/wget\" ]; then\n      if command -v curl >/dev/null 2>&1 || command -v wget >/dev/null 2>&1; then\n        continue\n      fi\n    fi\n    if ! command -v \"$dep\" >/dev/null 2>&1; then\n      remaining_deps+=(\"$dep\")\n      remaining_hints+=(\"$hint\")\n    fi\n  done\n  MISSING_DEPS=(\"${remaining_deps[@]}\")\n  MISSING_HINTS=(\"${remaining_hints[@]}\")\n}\n\nfind_latest_path() {\n  local base=\"$1\"\n  local pattern=\"$2\"\n  local type=\"${3:-f}\"\n  if [ ! -d \"$base\" ]; then\n    return 0\n  fi\n  if [ \"$type\" = \"d\" ]; then\n    find \"$base\" -type d -name \"$pattern\" -print0 2>/dev/null | xargs -0 ls -td 2>/dev/null | head -n1\n  else\n    find \"$base\" -type f -name \"$pattern\" -print0 2>/dev/null | xargs -0 ls -t 2>/dev/null | head -n1\n  fi\n}\n\nfind_tauri_artifact() {\n  local frontend_dir=\"$1\"\n  local variant=\"$2\"\n  local runtime=\"$3\"\n  local artifact_base=\"$frontend_dir/dist-artifacts/tauri/$variant/$runtime\"\n  local bundle_dir=\"$frontend_dir/src-tauri/target/release/bundle\"\n\n  if [ \"$OS_TYPE\" = \"Darwin\" ]; then\n    local app\n    app=\"$(find_latest_path \"$artifact_base\" \"*.app\" \"d\")\"\n    if [ -n \"$app\" ]; then\n      echo \"$app\"\n      return 0\n    fi\n    app=\"$(find_latest_path \"$bundle_dir/macos\" \"*.app\" \"d\")\"\n    if [ -n \"$app\" ]; then\n      echo \"$app\"\n      return 0\n    fi\n    find_latest_path \"$bundle_dir/macos\" \"*.dmg\" \"f\" || true\n    return 0\n  fi\n\n  if [ \"$OS_TYPE\" = \"Linux\" ]; then\n    local appimage\n    appimage=\"$(find_latest_path \"$artifact_base\" \"*.AppImage\" \"f\")\"\n    if [ -n \"$appimage\" ]; then\n      echo \"$appimage\"\n      return 0\n    fi\n    appimage=\"$(find_latest_path \"$bundle_dir\" \"*.AppImage\" \"f\")\"\n    if [ -n \"$appimage\" ]; then\n      echo \"$appimage\"\n      return 0\n    fi\n    local deb\n    deb=\"$(find_latest_path \"$artifact_base\" \"*.deb\" \"f\")\"\n    if [ -n \"$deb\" ]; then\n      echo \"$deb\"\n      return 0\n    fi\n    find_latest_path \"$bundle_dir\" \"*.deb\" \"f\" || true\n    return 0\n  fi\n\n  find_latest_path \"$artifact_base\" \"*.exe\" \"f\" || true\n  find_latest_path \"$bundle_dir\" \"*.exe\" \"f\" || true\n  find_latest_path \"$artifact_base\" \"*.msi\" \"f\" || true\n  find_latest_path \"$bundle_dir\" \"*.msi\" \"f\" || true\n}\n\nfind_electron_artifact() {\n  local frontend_dir=\"$1\"\n  local variant=\"$2\"\n  local runtime=\"$3\"\n  local artifact_base=\"$frontend_dir/dist-artifacts/electron/$variant/$runtime\"\n\n  if [ \"$OS_TYPE\" = \"Darwin\" ]; then\n    local app\n    app=\"$(find_latest_path \"$artifact_base\" \"*.app\" \"d\")\"\n    if [ -n \"$app\" ]; then\n      echo \"$app\"\n      return 0\n    fi\n    find_latest_path \"$artifact_base\" \"*.dmg\" \"f\" || true\n    return 0\n  fi\n\n  if [ \"$OS_TYPE\" = \"Linux\" ]; then\n    local unpacked\n    unpacked=\"$(find_latest_path \"$artifact_base\" \"linux-unpacked\" \"d\")\"\n    if [ -n \"$unpacked\" ]; then\n      find \"$unpacked\" -maxdepth 1 -type f -perm -111 ! -name \"chrome-sandbox\" ! -name \"chrome_crashpad_handler\" 2>/dev/null | head -n1\n      return 0\n    fi\n    local appimage\n    appimage=\"$(find_latest_path \"$artifact_base\" \"*.AppImage\" \"f\")\"\n    if [ -n \"$appimage\" ]; then\n      echo \"$appimage\"\n      return 0\n    fi\n    find_latest_path \"$artifact_base\" \"*.deb\" \"f\" || true\n    return 0\n  fi\n\n  find_latest_path \"$artifact_base\" \"*.exe\" \"f\" || true\n  find_latest_path \"$artifact_base\" \"*.msi\" \"f\" || true\n}\n\nrun_artifact() {\n  local artifact=\"$1\"\n  if [ -z \"$artifact\" ]; then\n    return 1\n  fi\n  echo \"Launching built app: $artifact\"\n  if [ \"$OS_TYPE\" = \"Darwin\" ]; then\n    open \"$artifact\"\n    return 0\n  fi\n  if [ \"$OS_TYPE\" = \"Linux\" ]; then\n    if [[ \"$artifact\" == *.AppImage ]]; then\n      chmod +x \"$artifact\"\n      \"$artifact\" &\n      return 0\n    fi\n    if command -v xdg-open >/dev/null 2>&1; then\n      xdg-open \"$artifact\"\n      return 0\n    fi\n    \"$artifact\" &\n    return 0\n  fi\n  return 1\n}\n\nreport_missing() {\n  if [ \"${#MISSING_DEPS[@]}\" -eq 0 ]; then\n    return 0\n  fi\n\n  echo \"Missing required dependencies:\" >&2\n  for i in \"${!MISSING_DEPS[@]}\"; do\n    echo \"- ${MISSING_DEPS[$i]}: ${MISSING_HINTS[$i]}\" >&2\n  done\n  echo \"Install the missing dependencies and retry.\" >&2\n  exit 1\n}\n\ndownload() {\n  local url=\"$1\"\n  if command -v curl >/dev/null 2>&1; then\n    curl -LsSf \"$url\"\n    return 0\n  fi\n  if command -v wget >/dev/null 2>&1; then\n    wget -qO- \"$url\"\n    return 0\n  fi\n  echo \"Missing required command: curl or wget.\" >&2\n  exit 1\n}\n\nPYTHON_BIN=\"${PYTHON_BIN:-}\"\nif [ -z \"$PYTHON_BIN\" ]; then\n  if command -v python >/dev/null 2>&1; then\n    PYTHON_BIN=\"python\"\n  elif command -v python3 >/dev/null 2>&1; then\n    PYTHON_BIN=\"python3\"\n  else\n    add_missing \"python\" \"Python 3.12+ not found. Install Python and retry.\"\n  fi\nelif ! command -v \"$PYTHON_BIN\" >/dev/null 2>&1; then\n  add_missing \"python\" \"Python 3.12+ not found. Install Python and retry.\"\nfi\n\nif ! command -v git >/dev/null 2>&1; then\n  add_missing \"git\" \"Install Git and retry.\"\nfi\nif ! command -v node >/dev/null 2>&1; then\n  add_missing \"node\" \"Install Node.js 20+ and retry.\"\nfi\n\nif [ \"$MODE\" = \"tauri\" ]; then\n  if ! command -v cargo >/dev/null 2>&1; then\n    add_missing \"cargo\" \"Install Rust (rustup) and retry, or set LIFETRACE_MODE=web.\"\n    if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then\n      add_missing \"curl/wget\" \"curl or wget is required to install Rust.\"\n    fi\n  fi\nfi\n\nif ! command -v uv >/dev/null 2>&1; then\n  if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then\n    add_missing \"curl/wget\" \"curl or wget is required to download uv.\"\n  fi\nfi\n\nif ! command -v pnpm >/dev/null 2>&1; then\n  if ! command -v corepack >/dev/null 2>&1 && ! command -v npm >/dev/null 2>&1; then\n    if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then\n      add_missing \"curl/wget\" \"curl or wget is required to install pnpm.\"\n    fi\n  fi\nfi\n\ninstall_missing_deps\nfilter_missing_deps\nreport_missing\n\nif [ -z \"$PYTHON_BIN\" ] || ! command -v \"$PYTHON_BIN\" >/dev/null 2>&1; then\n  if command -v python >/dev/null 2>&1; then\n    PYTHON_BIN=\"python\"\n  elif command -v python3 >/dev/null 2>&1; then\n    PYTHON_BIN=\"python3\"\n  else\n    echo \"Python 3.12+ not found after installation. Reopen your terminal and retry.\" >&2\n    exit 1\n  fi\nfi\n\nif ! command -v uv >/dev/null 2>&1; then\n  echo \"Installing uv...\"\n  download \"https://astral.sh/uv/install.sh\" | sh\n  export PATH=\"$HOME/.local/bin:$PATH\"\nfi\n\nif ! command -v pnpm >/dev/null 2>&1; then\n  install_pnpm() {\n    if command -v corepack >/dev/null 2>&1; then\n      if corepack enable >/dev/null 2>&1 && corepack prepare pnpm@latest --activate >/dev/null 2>&1; then\n        command -v pnpm >/dev/null 2>&1 && return 0\n      fi\n      echo \"corepack activation failed. Falling back to pnpm install script.\" >&2\n    fi\n    if command -v npm >/dev/null 2>&1; then\n      if npm install -g pnpm >/dev/null 2>&1; then\n        command -v pnpm >/dev/null 2>&1 && return 0\n      fi\n      echo \"npm global install failed. Falling back to pnpm install script.\" >&2\n    fi\n    if command -v curl >/dev/null 2>&1 || command -v wget >/dev/null 2>&1; then\n      echo \"Installing pnpm via install script...\"\n      export PNPM_HOME=\"${PNPM_HOME:-$HOME/.local/share/pnpm}\"\n      mkdir -p \"$PNPM_HOME\"\n      export PATH=\"$PNPM_HOME:$PATH\"\n      if command -v curl >/dev/null 2>&1; then\n        curl -fsSL https://get.pnpm.io/install.sh | sh -s --\n      else\n        wget -qO- https://get.pnpm.io/install.sh | sh -s --\n      fi\n      command -v pnpm >/dev/null 2>&1 && return 0\n    fi\n    return 1\n  }\n\n  if ! install_pnpm; then\n    echo \"pnpm not found after installation. Reopen your terminal and retry.\" >&2\n    exit 1\n  fi\nfi\n\nREPO_READY=0\nDEPS_READY=0\nif [ -e \"$TARGET_DIR\" ] && [ ! -d \"$TARGET_DIR/.git\" ]; then\n  echo \"Target path '$TARGET_DIR' exists and is not a git repo.\" >&2\n  echo \"Set LIFETRACE_DIR to a new folder and retry.\" >&2\n  exit 1\nfi\n\nif [ -d \"$TARGET_DIR/.git\" ]; then\n  cd \"$TARGET_DIR\"\n  if [ -n \"$(git status --porcelain)\" ]; then\n    echo \"Repository has local changes. Commit or stash and retry.\" >&2\n    exit 1\n  fi\n  git fetch --depth 1 \"$REPO_URL\" \"$REF\"\n  HEAD_SHA=\"$(git rev-parse HEAD)\"\n  REMOTE_SHA=\"$(git rev-parse FETCH_HEAD)\"\n  if [ \"$HEAD_SHA\" = \"$REMOTE_SHA\" ]; then\n    REPO_READY=1\n  fi\nelse\n  git clone --depth 1 --branch \"$REF\" \"$REPO_URL\" \"$TARGET_DIR\"\n  cd \"$TARGET_DIR\"\nfi\n\nif [ -d \".venv\" ] && [ -d \"free-todo-frontend/node_modules\" ]; then\n  DEPS_READY=1\nfi\n\nif [ \"$REPO_READY\" -eq 0 ] || [ \"$DEPS_READY\" -eq 0 ]; then\n  if [ -n \"$(git status --porcelain)\" ]; then\n    echo \"Repository has local changes. Commit or stash and retry.\" >&2\n    exit 1\n  fi\n  git fetch --depth 1 \"$REPO_URL\" \"$REF\"\n  git checkout -q -B \"$REF\" FETCH_HEAD\n  uv sync\n  if [ -d \".venv\" ] && [ -d \"free-todo-frontend/node_modules\" ]; then\n    DEPS_READY=1\n  fi\nelse\n  echo \"Repository is up to date. Skipping install steps.\"\nfi\n\nif [ \"$RUN_AFTER_INSTALL\" != \"1\" ]; then\n  echo \"Install complete.\"\n  exit 0\nfi\n\ncase \"$MODE\" in\n  web)\n    echo \"Starting backend...\"\n    uv run \"$PYTHON_BIN\" -m lifetrace.server &\n    BACKEND_PID=$!\n    cleanup() {\n      if kill -0 \"$BACKEND_PID\" >/dev/null 2>&1; then\n        kill \"$BACKEND_PID\" >/dev/null 2>&1 || true\n      fi\n    }\n    trap cleanup EXIT\n\n    cd free-todo-frontend\n    if [ ! -d \"node_modules\" ]; then\n      pnpm install\n    fi\n\n    if [ \"$FRONTEND_ACTION\" = \"build\" ]; then\n      if [ \"$REPO_READY\" -eq 1 ] && [ \"$DEPS_READY\" -eq 1 ] && [ -d \".next\" ]; then\n        echo \"Next.js build is up to date. Skipping build step.\"\n      else\n        echo \"Building frontend...\"\n        pnpm build\n      fi\n      echo \"Starting frontend (production)...\"\n      pnpm start\n    else\n      echo \"Starting frontend (dev)...\"\n      WINDOW_MODE=\"$VARIANT\" pnpm dev\n    fi\n    ;;\n  tauri)\n    cd free-todo-frontend\n    if [ ! -d \"node_modules\" ]; then\n      pnpm install\n    fi\n\n    if [ \"$FRONTEND_ACTION\" = \"build\" ]; then\n      artifact=\"$(find_tauri_artifact \"$(pwd)\" \"$VARIANT\" \"$BACKEND_RUNTIME\")\"\n      if [ -z \"$artifact\" ] || [ \"$REPO_READY\" -eq 0 ] || [ \"$DEPS_READY\" -eq 0 ]; then\n        echo \"Building Tauri app ($VARIANT, $BACKEND_RUNTIME)...\"\n        pnpm \"build:tauri:${VARIANT}:${BACKEND_RUNTIME}:full\"\n        artifact=\"$(find_tauri_artifact \"$(pwd)\" \"$VARIANT\" \"$BACKEND_RUNTIME\")\"\n      else\n        echo \"Tauri build is up to date. Skipping build step.\"\n      fi\n      if ! run_artifact \"$artifact\"; then\n        echo \"Build complete. Open the artifact under src-tauri/target/release/bundle/.\"\n      fi\n    else\n      echo \"Starting backend...\"\n      uv run \"$PYTHON_BIN\" -m lifetrace.server &\n      BACKEND_PID=$!\n      cleanup() {\n        if [ -n \"${FRONTEND_PID:-}\" ] && kill -0 \"$FRONTEND_PID\" >/dev/null 2>&1; then\n          kill \"$FRONTEND_PID\" >/dev/null 2>&1 || true\n        fi\n        if kill -0 \"$BACKEND_PID\" >/dev/null 2>&1; then\n          kill \"$BACKEND_PID\" >/dev/null 2>&1 || true\n        fi\n      }\n      trap cleanup EXIT\n\n      echo \"Starting frontend dev server...\"\n      WINDOW_MODE=\"$VARIANT\" pnpm dev &\n      FRONTEND_PID=$!\n      echo \"Starting Tauri app...\"\n      pnpm tauri:dev\n    fi\n    ;;\n  electron)\n    cd free-todo-frontend\n    if [ ! -d \"node_modules\" ]; then\n      pnpm install\n    fi\n\n    if [ \"$FRONTEND_ACTION\" = \"build\" ]; then\n      artifact=\"$(find_electron_artifact \"$(pwd)\" \"$VARIANT\" \"$BACKEND_RUNTIME\")\"\n      if [ -z \"$artifact\" ] || [ \"$REPO_READY\" -eq 0 ] || [ \"$DEPS_READY\" -eq 0 ]; then\n        echo \"Building Electron app ($VARIANT, $BACKEND_RUNTIME)...\"\n        pnpm \"build:electron:${VARIANT}:${BACKEND_RUNTIME}:full:dir\"\n        artifact=\"$(find_electron_artifact \"$(pwd)\" \"$VARIANT\" \"$BACKEND_RUNTIME\")\"\n      else\n        echo \"Electron build is up to date. Skipping build step.\"\n      fi\n      if ! run_artifact \"$artifact\"; then\n        echo \"Build complete. Open the artifact under dist-artifacts/electron/.\"\n      fi\n    else\n      if [ \"$VARIANT\" = \"island\" ]; then\n        pnpm electron:dev:island\n      else\n        pnpm electron:dev\n      fi\n    fi\n    ;;\nesac\n"
  },
  {
    "path": "scripts/link_worktree_deps.ps1",
    "content": "param(\n    [Parameter(Mandatory = $true)]\n    [string]$Main,\n    [Parameter(Mandatory = $true)]\n    [string]$Worktree,\n    [switch]$Force\n)\n\nSet-StrictMode -Version Latest\n$ErrorActionPreference = \"Stop\"\n\nfunction Resolve-FullPath {\n    param([string]$Path)\n    return (Resolve-Path -LiteralPath $Path).Path\n}\n\nfunction Ensure-Junction {\n    param(\n        [string]$Name,\n        [string]$Source,\n        [string]$Dest\n    )\n\n    if (-not (Test-Path -LiteralPath $Source)) {\n        Write-Warning \"$Name source not found: $Source (skipped)\"\n        return\n    }\n\n    $destParent = Split-Path -Parent $Dest\n    if (-not (Test-Path -LiteralPath $destParent)) {\n        New-Item -ItemType Directory -Path $destParent | Out-Null\n    }\n\n    if (Test-Path -LiteralPath $Dest) {\n        $item = Get-Item -LiteralPath $Dest -Force\n        $isReparse = ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0\n        if ($isReparse) {\n            $target = $item.Target\n            if ($target) {\n                $targetFull = Resolve-FullPath $target\n                $sourceFull = Resolve-FullPath $Source\n                if ($targetFull -eq $sourceFull) {\n                    Write-Host \"$Name already linked: $Dest -> $targetFull\"\n                    return\n                }\n            }\n        }\n\n        if (-not $Force) {\n            Write-Warning \"$Name destination exists: $Dest (use -Force to replace)\"\n            return\n        }\n\n        Remove-Item -LiteralPath $Dest -Recurse -Force\n    }\n\n    New-Item -ItemType Junction -Path $Dest -Target $Source | Out-Null\n    Write-Host \"Linked ${Name}: $Dest -> $Source\"\n}\n\n$mainRoot = Resolve-FullPath $Main\n$worktreeRoot = Resolve-FullPath $Worktree\n\nEnsure-Junction `\n    -Name \"frontend node_modules\" `\n    -Source (Join-Path $mainRoot \"free-todo-frontend\\node_modules\") `\n    -Dest (Join-Path $worktreeRoot \"free-todo-frontend\\node_modules\")\n\nEnsure-Junction `\n    -Name \"python .venv\" `\n    -Source (Join-Path $mainRoot \".venv\") `\n    -Dest (Join-Path $worktreeRoot \".venv\")\n\nWrite-Host \"Done.\"\n"
  },
  {
    "path": "scripts/link_worktree_deps.sh",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\nusage() {\n  cat <<'EOF'\nUsage:\n  scripts/link_worktree_deps.sh --main <main-root> --worktree <worktree-root> [--force]\n\nExample:\n  scripts/link_worktree_deps.sh --main /path/to/LifeTrace \\\n    --worktree /path/to/_worktrees/LifeTrace/chat-tool-ui\nEOF\n}\n\nmain_root=\"\"\nworktree_root=\"\"\nforce=0\n\nwhile [[ $# -gt 0 ]]; do\n  case \"$1\" in\n    --main)\n      main_root=\"$2\"\n      shift 2\n      ;;\n    --worktree)\n      worktree_root=\"$2\"\n      shift 2\n      ;;\n    --force)\n      force=1\n      shift 1\n      ;;\n    -h|--help)\n      usage\n      exit 0\n      ;;\n    *)\n      echo \"Unknown option: $1\" >&2\n      usage\n      exit 1\n      ;;\n  esac\ndone\n\nif [[ -z \"$main_root\" || -z \"$worktree_root\" ]]; then\n  usage\n  exit 1\nfi\n\nmain_root=\"$(cd \"$main_root\" && pwd)\"\nworktree_root=\"$(cd \"$worktree_root\" && pwd)\"\n\nlink_item() {\n  local name=\"$1\"\n  local src=\"$2\"\n  local dest=\"$3\"\n\n  if [[ ! -e \"$src\" ]]; then\n    echo \"Skip: $name source not found: $src\"\n    return 0\n  fi\n\n  mkdir -p \"$(dirname \"$dest\")\"\n\n  if [[ -e \"$dest\" || -L \"$dest\" ]]; then\n    if [[ $force -eq 0 ]]; then\n      echo \"Skip: $name destination exists: $dest (use --force to replace)\"\n      return 0\n    fi\n    rm -rf \"$dest\"\n  fi\n\n  ln -s \"$src\" \"$dest\"\n  echo \"Linked $name: $dest -> $src\"\n}\n\nlink_item \"frontend node_modules\" \\\n  \"$main_root/free-todo-frontend/node_modules\" \\\n  \"$worktree_root/free-todo-frontend/node_modules\"\n\nlink_item \"python .venv\" \\\n  \"$main_root/.venv\" \\\n  \"$worktree_root/.venv\"\n\necho \"Done.\"\n"
  },
  {
    "path": "scripts/link_worktree_deps_here.ps1",
    "content": "param(\n    [switch]$Force\n)\n\nSet-StrictMode -Version Latest\n$ErrorActionPreference = \"Stop\"\n\nfunction Resolve-FullPath {\n    param([string]$Path)\n    return (Resolve-Path -LiteralPath $Path).Path\n}\n\nfunction Get-RepoRoot {\n    $result = & git rev-parse --show-toplevel 2>$null\n    if (-not $result) {\n        throw \"Failed to locate git repo root. Run from inside a git worktree.\"\n    }\n    return $result.Trim()\n}\n\nfunction Get-MainWorktree {\n    $lines = & git worktree list --porcelain\n    if (-not $lines) {\n        throw \"Failed to read git worktree list.\"\n    }\n\n    $paths = @()\n    foreach ($line in $lines) {\n        if ($line -like \"worktree *\") {\n            $paths += $line.Substring(9).Trim()\n        }\n    }\n\n    foreach ($path in $paths) {\n        $gitDir = Join-Path $path \".git\"\n        if (Test-Path -LiteralPath $gitDir -PathType Container) {\n            return $path\n        }\n    }\n\n    throw \"Could not determine main worktree. Please pass -Main to scripts/link_worktree_deps.ps1.\"\n}\n\n$worktreeRoot = Resolve-FullPath (Get-RepoRoot)\n$mainRoot = Resolve-FullPath (Get-MainWorktree)\n\n$scriptPath = Join-Path $worktreeRoot \"scripts\\link_worktree_deps.ps1\"\nif (-not (Test-Path -LiteralPath $scriptPath)) {\n    throw \"Missing script: $scriptPath\"\n}\n\nif ($Force) {\n    & powershell -ExecutionPolicy Bypass -File $scriptPath -Main $mainRoot -Worktree $worktreeRoot -Force\n} else {\n    & powershell -ExecutionPolicy Bypass -File $scriptPath -Main $mainRoot -Worktree $worktreeRoot\n}\n"
  },
  {
    "path": "scripts/link_worktree_deps_here.sh",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\nforce=0\nif [[ \"${1:-}\" == \"--force\" ]]; then\n  force=1\nfi\n\nrepo_root=\"$(git rev-parse --show-toplevel 2>/dev/null || true)\"\nif [[ -z \"${repo_root}\" ]]; then\n  echo \"Failed to locate git repo root. Run from inside a git worktree.\" >&2\n  exit 1\nfi\n\nmain_root=\"\"\nwhile IFS= read -r line; do\n  if [[ \"$line\" == worktree\\ * ]]; then\n    path=\"${line#worktree }\"\n    if [[ -d \"${path}/.git\" ]]; then\n      main_root=\"${path}\"\n      break\n    fi\n  fi\ndone < <(git worktree list --porcelain)\n\nif [[ -z \"${main_root}\" ]]; then\n  echo \"Could not determine main worktree. Please pass --main to scripts/link_worktree_deps.sh.\" >&2\n  exit 1\nfi\n\nscript_path=\"${repo_root}/scripts/link_worktree_deps.sh\"\nif [[ ! -f \"${script_path}\" ]]; then\n  echo \"Missing script: ${script_path}\" >&2\n  exit 1\nfi\n\nif [[ \"${force}\" -eq 1 ]]; then\n  bash \"${script_path}\" --main \"${main_root}\" --worktree \"${repo_root}\" --force\nelse\n  bash \"${script_path}\" --main \"${main_root}\" --worktree \"${repo_root}\"\nfi\n"
  },
  {
    "path": "scripts/new_worktree.py",
    "content": "#!/usr/bin/env python3\nimport argparse\nimport getpass\nimport os\nimport re\nimport shutil\nimport subprocess  # nosec B404\nimport sys\nfrom pathlib import Path\n\n\ndef _get_git_path() -> str:\n    git_path = shutil.which(\"git\")\n    if not git_path:\n        raise FileNotFoundError(\"git executable not found in PATH\")\n    return git_path\n\n\ndef run_git(root: Path, args: list[str], check: bool = True) -> subprocess.CompletedProcess[str]:\n    git_path = _get_git_path()\n    return subprocess.run(  # nosec B603\n        [git_path, \"-C\", str(root), *args],\n        text=True,\n        capture_output=True,\n        check=check,\n    )\n\n\ndef run_link_deps(root: Path, worktree_path: Path, force: bool) -> int:\n    script_dir = root / \"scripts\"\n    if os.name == \"nt\":\n        script = script_dir / \"link_worktree_deps.ps1\"\n        if not script.exists():\n            print(f\"Missing script: {script}\", file=sys.stderr)\n            return 1\n        cmd = [\n            \"powershell\",\n            \"-ExecutionPolicy\",\n            \"Bypass\",\n            \"-File\",\n            str(script),\n            \"-Main\",\n            str(root),\n            \"-Worktree\",\n            str(worktree_path),\n        ]\n        if force:\n            cmd.append(\"-Force\")\n    else:\n        script = script_dir / \"link_worktree_deps.sh\"\n        if not script.exists():\n            print(f\"Missing script: {script}\", file=sys.stderr)\n            return 1\n        cmd = [\n            \"bash\",\n            str(script),\n            \"--main\",\n            str(root),\n            \"--worktree\",\n            str(worktree_path),\n        ]\n        if force:\n            cmd.append(\"--force\")\n\n    result = subprocess.run(cmd, check=False)  # nosec B603\n    return result.returncode\n\n\ndef get_repo_root() -> Path:\n    git_path = _get_git_path()\n    result = subprocess.run(  # nosec B603\n        [git_path, \"rev-parse\", \"--show-toplevel\"],\n        text=True,\n        capture_output=True,\n        check=False,\n    )\n    if result.returncode != 0:\n        print(result.stderr.strip() or \"Failed to locate git repo root.\", file=sys.stderr)\n        sys.exit(result.returncode or 1)\n    return Path(result.stdout.strip())\n\n\ndef slugify(value: str) -> str:\n    slug = re.sub(r\"[^a-zA-Z0-9]+\", \"-\", value.strip().lower()).strip(\"-\")\n    return slug or \"task\"\n\n\ndef branch_exists(root: Path, branch: str) -> bool:\n    result = run_git(root, [\"show-ref\", \"--verify\", f\"refs/heads/{branch}\"], check=False)\n    return result.returncode == 0\n\n\ndef get_git_user(root: Path) -> str:\n    for key in (\"user.name\", \"user.email\"):\n        result = run_git(root, [\"config\", \"--get\", key], check=False)\n        value = result.stdout.strip()\n        if not value:\n            continue\n        if key == \"user.email\":\n            value = value.split(\"@\", 1)[0]\n        return value\n    return getpass.getuser()\n\n\ndef summarize_task(task: str, max_words: int = 3) -> str:\n    slug = slugify(task)\n    words = [w for w in slug.split(\"-\") if w]\n    if not words:\n        return \"task\"\n    return \"-\".join(words[:max_words])\n\n\ndef normalize_type(value: str) -> str:\n    value = value.strip()\n    if not value:\n        return \"chore\"\n    return value.lower()\n\n\ndef unique_branch_and_path(root: Path, base_branch: str, base_path: Path) -> tuple[str, Path]:\n    suffix = 1\n    while True:\n        if suffix == 1:\n            branch = base_branch\n            path = base_path\n        else:\n            branch = f\"{base_branch}-{suffix}\"\n            path = Path(f\"{base_path}-{suffix}\")\n\n        if not branch_exists(root, branch) and not path.exists():\n            return branch, path\n\n        suffix += 1\n\n\ndef main() -> int:\n    parser = argparse.ArgumentParser(description=\"Create a git worktree for a task.\")\n    parser.add_argument(\"task\", help=\"Task name (used to build a worktree path and branch).\")\n    parser.add_argument(\n        \"--type\",\n        default=\"chore\",\n        help=\"Branch type, e.g. Feat/Chore/Fix/Hotfix/Refactor.\",\n    )\n    parser.add_argument(\n        \"--user\",\n        help=\"Git username. Defaults to git config user.name (or user.email).\",\n    )\n    parser.add_argument(\n        \"--link-deps\",\n        action=\"store_true\",\n        help=\"Link worktree deps (.venv, node_modules) from the main worktree.\",\n    )\n    parser.add_argument(\n        \"--force-link\",\n        action=\"store_true\",\n        help=\"Force replace existing linked deps when used with --link-deps.\",\n    )\n    args = parser.parse_args()\n\n    root = get_repo_root()\n    repo_name = root.name\n    task_summary = summarize_task(args.task)\n    branch_type = normalize_type(args.type)\n    git_user = args.user or get_git_user(root)\n    user_slug = slugify(git_user)\n    base_branch = f\"{branch_type}/{user_slug}/{task_summary}\"\n\n    base_dir = root.parent / \"_worktrees\" / repo_name\n    base_path = base_dir / task_summary\n\n    branch, worktree_path = unique_branch_and_path(root, base_branch, base_path)\n\n    base_dir.mkdir(parents=True, exist_ok=True)\n\n    cmd = [\"worktree\", \"add\", \"-b\", branch, str(worktree_path)]\n\n    result = run_git(root, cmd, check=False)\n    if result.stdout.strip():\n        print(result.stdout.strip())\n    if result.stderr.strip():\n        print(result.stderr.strip(), file=sys.stderr)\n\n    if result.returncode != 0:\n        return result.returncode\n\n    if args.link_deps:\n        link_code = run_link_deps(root, worktree_path, force=args.force_link)\n        if link_code != 0:\n            return link_code\n\n    print(f\"Worktree ready: {worktree_path}\")\n    print(f\"Branch: {branch}\")\n    return 0\n\n\nif __name__ == \"__main__\":\n    raise SystemExit(main())\n"
  },
  {
    "path": "scripts/precommit_clippy.py",
    "content": "#!/usr/bin/env python3\nfrom __future__ import annotations\n\nimport os\nimport shutil\nimport subprocess  # nosec B404\nimport sys\nfrom pathlib import Path\n\n\ndef run() -> int:\n    repo_root = Path(__file__).resolve().parents[1]\n    tauri_dir = repo_root / \"free-todo-frontend\" / \"src-tauri\"\n    if not tauri_dir.exists():\n        print(f\"Rust hook skipped: missing {tauri_dir}\", file=sys.stderr)\n        return 0\n\n    cargo_path = shutil.which(\"cargo\")\n    if not cargo_path:\n        print(\"cargo not found in PATH. Install Rust and retry.\", file=sys.stderr)\n        return 127\n\n    env = os.environ.copy()\n    lint_config = tauri_dir / \"tauri.lint.json\"\n    if lint_config.exists():\n        env[\"TAURI_CONFIG\"] = lint_config.read_text(encoding=\"utf-8\")\n    env.setdefault(\"CARGO_TARGET_DIR\", str(tauri_dir / \"target-clippy\"))\n\n    try:\n        subprocess.run(  # nosec B603\n            [cargo_path, \"clippy\", \"--all-targets\", \"--all-features\", \"--\", \"-D\", \"warnings\"],\n            cwd=tauri_dir,\n            env=env,\n            check=True,\n        )\n    except subprocess.CalledProcessError as exc:\n        return exc.returncode\n\n    return 0\n\n\nif __name__ == \"__main__\":\n    raise SystemExit(run())\n"
  },
  {
    "path": "scripts/precommit_rustfmt.py",
    "content": "#!/usr/bin/env python3\nfrom __future__ import annotations\n\nimport shutil\nimport subprocess  # nosec B404\nimport sys\nfrom pathlib import Path\n\n\ndef run() -> int:\n    repo_root = Path(__file__).resolve().parents[1]\n    tauri_dir = repo_root / \"free-todo-frontend\" / \"src-tauri\"\n    if not tauri_dir.exists():\n        print(f\"Rust hook skipped: missing {tauri_dir}\", file=sys.stderr)\n        return 0\n\n    cargo_path = shutil.which(\"cargo\")\n    if not cargo_path:\n        print(\"cargo not found in PATH. Install Rust and retry.\", file=sys.stderr)\n        return 127\n\n    try:\n        subprocess.run(  # nosec B603\n            [cargo_path, \"fmt\", \"--all\", \"--\", \"--check\"],\n            cwd=tauri_dir,\n            check=True,\n        )\n    except subprocess.CalledProcessError as exc:\n        return exc.returncode\n\n    return 0\n\n\nif __name__ == \"__main__\":\n    raise SystemExit(run())\n"
  },
  {
    "path": "scripts/setup_hooks_here.ps1",
    "content": "Set-StrictMode -Version Latest\n$ErrorActionPreference = \"Stop\"\n\nfunction Get-RepoRoot {\n    $result = & git rev-parse --show-toplevel 2>$null\n    if (-not $result) {\n        throw \"Failed to locate git repo root. Run from inside a git worktree.\"\n    }\n    return $result.Trim()\n}\n\n$repoRoot = Get-RepoRoot\n$hooksDir = Join-Path $repoRoot \".githooks\"\n\nif (-not (Test-Path -LiteralPath $hooksDir -PathType Container)) {\n    throw \"Missing hooks directory: $hooksDir\"\n}\n\n& git -C $repoRoot config core.hooksPath .githooks\n\nforeach ($hook in @(\"pre-commit\", \"post-checkout\")) {\n    $hookPath = Join-Path $hooksDir $hook\n    if (-not (Test-Path -LiteralPath $hookPath)) {\n        Write-Warning \"Missing hook file: $hookPath\"\n    }\n}\n\nWrite-Host \"Configured core.hooksPath=.githooks for $repoRoot\"\n"
  },
  {
    "path": "scripts/setup_hooks_here.sh",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\nrepo_root=\"$(git rev-parse --show-toplevel 2>/dev/null || true)\"\nif [[ -z \"${repo_root}\" ]]; then\n  echo \"Failed to locate git repo root. Run from inside a git worktree.\" >&2\n  exit 1\nfi\n\nhooks_dir=\"${repo_root}/.githooks\"\nif [[ ! -d \"${hooks_dir}\" ]]; then\n  echo \"Missing hooks directory: ${hooks_dir}\" >&2\n  exit 1\nfi\n\ngit -C \"${repo_root}\" config core.hooksPath .githooks\n\nfor hook in pre-commit post-checkout; do\n  if [[ ! -f \"${hooks_dir}/${hook}\" ]]; then\n    echo \"Warning: missing hook file: ${hooks_dir}/${hook}\" >&2\n  fi\ndone\n\nif command -v chmod >/dev/null 2>&1; then\n  chmod +x \"${hooks_dir}/pre-commit\" \"${hooks_dir}/post-checkout\" 2>/dev/null || true\nfi\n\necho \"Configured core.hooksPath=.githooks for ${repo_root}\"\n"
  },
  {
    "path": "tests/conftest.py",
    "content": "from __future__ import annotations\n\nimport sys\nfrom pathlib import Path\n\nROOT = Path(__file__).resolve().parents[1]\nif str(ROOT) not in sys.path:\n    sys.path.insert(0, str(ROOT))\n"
  },
  {
    "path": "tests/test_icalendar_service.py",
    "content": "from __future__ import annotations\n\nfrom datetime import datetime, timezone\n\nfrom icalendar import Calendar\n\nfrom lifetrace.schemas.todo import TodoItemType\nfrom lifetrace.services.icalendar_service import ICalendarService\n\n\ndef test_export_vtodo_fallbacks_due_to_dtstart() -> None:\n    service = ICalendarService()\n    dtstart = datetime(2024, 1, 1, 9, 30, tzinfo=timezone.utc)\n\n    ics = service.export_todos(\n        [\n            {\n                \"id\": 1,\n                \"uid\": \"todo-1\",\n                \"name\": \"Test\",\n                \"item_type\": \"VTODO\",\n                \"dtstart\": dtstart,\n            }\n        ]\n    )\n\n    cal = Calendar.from_ical(ics)\n    component = next(comp for comp in cal.walk() if comp.name == \"VTODO\")\n    assert component.get(\"DUE\") is not None\n\n\ndef test_import_vtodo_duration_keeps_due_none() -> None:\n    service = ICalendarService()\n    ics = \"\\n\".join(\n        [\n            \"BEGIN:VCALENDAR\",\n            \"VERSION:2.0\",\n            \"PRODID:-//LifeTrace//FreeTodo//EN\",\n            \"BEGIN:VTODO\",\n            \"UID:todo-2\",\n            \"SUMMARY:Duration Task\",\n            \"DTSTART:20240102T090000Z\",\n            \"DURATION:PT30M\",\n            \"END:VTODO\",\n            \"END:VCALENDAR\",\n            \"\",\n        ]\n    )\n\n    todos = service.import_todos(ics)\n    assert len(todos) == 1\n    todo = todos[0]\n    assert todo.item_type == TodoItemType.VTODO\n    assert todo.duration == \"PT30M\"\n    assert todo.due is None\n"
  },
  {
    "path": "tests/test_todo_serialization.py",
    "content": "from __future__ import annotations\n\nfrom lifetrace.storage.models import Todo\nfrom lifetrace.storage.todo_manager_ical import TodoIcalMixin\n\n\nclass StubTodoManager(TodoIcalMixin):\n    def _get_todo_tags(self, session, todo_id: int):\n        return []\n\n    def _get_todo_attachments(self, session, todo_id: int):\n        return []\n\n    def _set_todo_tags(self, session, todo_id: int, tags):\n        return None\n\n\ndef test_todo_to_dict_coerces_is_all_day_none() -> None:\n    manager = StubTodoManager()\n    todo = Todo(name=\"Test\")\n    todo.id = 1\n    todo.is_all_day = None\n\n    data = manager._todo_to_dict(None, todo)\n\n    assert data[\"is_all_day\"] is False\n\n\ndef test_todo_to_dict_normalizes_reminder_offsets() -> None:\n    manager = StubTodoManager()\n    todo = Todo(name=\"Test\")\n    todo.id = 1\n    todo.reminder_offsets = '[30, \"15\", -5, \"bad\"]'\n\n    data = manager._todo_to_dict(None, todo)\n\n    assert data[\"reminder_offsets\"] == [15, 30]\n"
  },
  {
    "path": "tests/test_todo_service_mapping.py",
    "content": "from __future__ import annotations\n\nfrom datetime import datetime, timezone\n\nimport pytest\nfrom fastapi import HTTPException\n\nfrom lifetrace.schemas.todo import TodoCreate, TodoUpdate\nfrom lifetrace.services.todo_service import TodoService\n\n\nclass FakeTodoRepository:\n    def __init__(self) -> None:\n        now = datetime(2024, 1, 1, 8, 0, tzinfo=timezone.utc)\n        self.todo = {\n            \"id\": 1,\n            \"uid\": \"todo-1\",\n            \"name\": \"Test\",\n            \"item_type\": \"VTODO\",\n            \"status\": \"active\",\n            \"priority\": \"none\",\n            \"created_at\": now,\n            \"updated_at\": now,\n        }\n        self.updated: dict[str, object] | None = None\n        self.created_payload: dict[str, object] | None = None\n\n    def get_by_id(self, todo_id: int):\n        return self.todo\n\n    def get_by_uid(self, uid: str):\n        return None\n\n    def list_todos(self, limit: int, offset: int, status: str | None):\n        return []\n\n    def count(self, status: str | None):\n        return 0\n\n    def create(self, **kwargs):\n        self.created_payload = kwargs\n        return 1\n\n    def update(self, todo_id: int, **kwargs):\n        self.updated = kwargs\n        return True\n\n    def delete(self, todo_id: int):\n        return True\n\n    def reorder(self, items):\n        return True\n\n    def add_attachment(\n        self,\n        *,\n        todo_id: int,\n        file_name: str,\n        file_path: str,\n        file_size: int | None,\n        mime_type: str | None,\n        file_hash: str | None,\n        source: str = \"user\",\n    ):\n        return None\n\n    def remove_attachment(self, *, todo_id: int, attachment_id: int):\n        return True\n\n    def get_attachment(self, attachment_id: int):\n        return None\n\n\ndef test_update_todo_dtstart_does_not_touch_due() -> None:\n    repo = FakeTodoRepository()\n    service = TodoService(repo)\n    dtstart = datetime(2024, 1, 1, 10, 0, tzinfo=timezone.utc)\n\n    service.update_todo(1, TodoUpdate(dtstart=dtstart))\n\n    assert repo.updated is not None\n    assert \"due\" not in repo.updated\n    assert repo.updated[\"dtstart\"] == dtstart\n    assert repo.updated[\"start_time\"] == dtstart\n\n\ndef test_update_todo_duration_conflict_raises() -> None:\n    repo = FakeTodoRepository()\n    service = TodoService(repo)\n    due = datetime(2024, 1, 1, 12, 0, tzinfo=timezone.utc)\n\n    with pytest.raises(HTTPException):\n        service.update_todo(1, TodoUpdate(duration=\"PT30M\", due=due))\n\n\ndef test_create_todo_duration_conflict_raises() -> None:\n    repo = FakeTodoRepository()\n    service = TodoService(repo)\n    due = datetime(2024, 1, 2, 12, 0, tzinfo=timezone.utc)\n\n    with pytest.raises(HTTPException):\n        service.create_todo(TodoCreate(name=\"Test\", duration=\"PT30M\", due=due))\n\n\ndef test_update_todo_time_zone_sets_tzid() -> None:\n    repo = FakeTodoRepository()\n    service = TodoService(repo)\n\n    service.update_todo(1, TodoUpdate(time_zone=\"Asia/Shanghai\"))\n\n    assert repo.updated is not None\n    assert repo.updated[\"time_zone\"] == \"Asia/Shanghai\"\n    assert repo.updated[\"tzid\"] == \"Asia/Shanghai\"\n"
  }
]