diff --git a/backend/app/models/task.py b/backend/app/models/task.py index e161e0a..a8628b1 100644 --- a/backend/app/models/task.py +++ b/backend/app/models/task.py @@ -1,7 +1,8 @@ -from sqlalchemy import Column, String, Text, Integer, ForeignKey, DateTime, Enum -from sqlalchemy.orm import relationship -from datetime import datetime from enum import Enum as PyEnum + +from sqlalchemy import Column, DateTime, Enum, ForeignKey, Integer, String, Text +from sqlalchemy.orm import relationship + from app.models.base import BaseModel @@ -19,26 +20,144 @@ class TaskPriority(str, PyEnum): URGENT = "urgent" +class TaskSource(str, PyEnum): + MANUAL = "manual" + CHAT = "chat" + SCHEDULE_CENTER = "schedule_center" + TODAY_STATUS = "today_status" + COMMANDER = "commander" + + +class TaskQuadrant(str, PyEnum): + URGENT_IMPORTANT = "urgent-important" + NOT_URGENT_IMPORTANT = "not-urgent-important" + URGENT_NOT_IMPORTANT = "urgent-not-important" + NOT_URGENT_NOT_IMPORTANT = "not-urgent-not-important" + + +class TaskAssigneeType(str, PyEnum): + USER = "user" + COMMANDER = "commander" + AGENT = "agent" + PLANNER = "planner" + EXECUTOR = "executor" + KNOWLEDGE = "knowledge" + ANALYST = "analyst" + CODER = "coder" + RESEARCHER = "researcher" + + +class TaskDispatchStatus(str, PyEnum): + IDLE = "idle" + QUEUED = "queued" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + + +DispatchStatus = TaskDispatchStatus + + +DispatchStatus = TaskDispatchStatus + + +class TaskHistoryAction(str, PyEnum): + CREATED = "created" + CREATED_FROM_CHAT = "created_from_chat" + UPDATED = "updated" + STATUS_CHANGED = "status_changed" + ASSIGNED = "assigned" + DELETED = "deleted" + SUBTASK_CREATED = "subtask_created" + SUBTASK_UPDATED = "subtask_updated" + SUBTASK_DELETED = "subtask_deleted" + SUBTASK_REORDERED = "subtask_reordered" + DISPATCHED_TO_COMMANDER = "dispatched_to_commander" + DISPATCH_STATUS_CHANGED = "dispatch_status_changed" + + +def enum_values(enum_cls: type[PyEnum]) -> list[str]: + return [item.value for item in enum_cls] + + +TASK_STATUS_ENUM = Enum(TaskStatus, values_callable=enum_values) +TASK_PRIORITY_ENUM = Enum(TaskPriority, values_callable=enum_values) +TASK_SOURCE_ENUM = Enum(TaskSource, values_callable=enum_values) +TASK_QUADRANT_ENUM = Enum(TaskQuadrant, values_callable=enum_values) +TASK_ASSIGNEE_TYPE_ENUM = Enum(TaskAssigneeType, values_callable=enum_values) +TASK_DISPATCH_STATUS_ENUM = Enum(TaskDispatchStatus, values_callable=enum_values) + + class Task(BaseModel): __tablename__ = "tasks" user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True) title = Column(String(500), nullable=False) description = Column(Text, nullable=True) - status = Column(Enum(TaskStatus), default=TaskStatus.TODO, nullable=False, index=True) - priority = Column(Enum(TaskPriority), default=TaskPriority.MEDIUM, nullable=False) - due_date = Column(DateTime, nullable=True) + status = Column(TASK_STATUS_ENUM, default=TaskStatus.TODO, nullable=False, index=True) + priority = Column(TASK_PRIORITY_ENUM, default=TaskPriority.MEDIUM, nullable=False) + due_date = Column(DateTime, nullable=True, index=True) completed_at = Column(DateTime, nullable=True) - tags = Column(String(1000), nullable=True) # JSON 数组 + tags = Column(String(1000), nullable=True) # JSON array + source = Column(TASK_SOURCE_ENUM, default=TaskSource.MANUAL, nullable=False, index=True) + conversation_id = Column(String(36), nullable=True, index=True) + quadrant = Column(TASK_QUADRANT_ENUM, nullable=True, index=True) + assignee_type = Column(TASK_ASSIGNEE_TYPE_ENUM, nullable=True, index=True) + assignee_id = Column(String(255), nullable=True, index=True) + dispatch_status = Column( + TASK_DISPATCH_STATUS_ENUM, + default=TaskDispatchStatus.IDLE, + nullable=False, + index=True, + ) + dispatch_run_id = Column(String(64), nullable=True, index=True) + result_summary = Column(Text, nullable=True) + started_at = Column(DateTime, nullable=True) + last_synced_at = Column(DateTime, nullable=True) - history = relationship("TaskHistory", back_populates="task", cascade="all, delete-orphan") + subtasks = relationship( + "TaskSubTask", + back_populates="task", + cascade="all, delete-orphan", + order_by="TaskSubTask.order_index.asc()", + ) + history = relationship( + "TaskHistory", + back_populates="task", + cascade="all, delete-orphan", + order_by="TaskHistory.created_at.desc()", + ) + + +class TaskSubTask(BaseModel): + __tablename__ = "task_subtasks" + + task_id = Column(String(36), ForeignKey("tasks.id"), nullable=False, index=True) + title = Column(String(500), nullable=False) + description = Column(Text, nullable=True) + status = Column(TASK_STATUS_ENUM, default=TaskStatus.TODO, nullable=False, index=True) + order_index = Column(Integer, default=0, nullable=False, index=True) + assignee_type = Column(TASK_ASSIGNEE_TYPE_ENUM, nullable=True, index=True) + assignee_id = Column(String(255), nullable=True, index=True) + dispatch_status = Column( + TASK_DISPATCH_STATUS_ENUM, + default=TaskDispatchStatus.IDLE, + nullable=False, + index=True, + ) + dispatch_run_id = Column(String(64), nullable=True, index=True) + result_summary = Column(Text, nullable=True) + completed_at = Column(DateTime, nullable=True) + + task = relationship("Task", back_populates="subtasks") class TaskHistory(BaseModel): __tablename__ = "task_histories" task_id = Column(String(36), ForeignKey("tasks.id"), nullable=False, index=True) - action = Column(String(100), nullable=False) # created, status_changed, updated, deleted + subtask_id = Column(String(36), ForeignKey("task_subtasks.id"), nullable=True, index=True) + action = Column(String(100), nullable=False) old_value = Column(Text, nullable=True) new_value = Column(Text, nullable=True) diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py index 6096964..3e6dc87 100644 --- a/backend/app/routers/__init__.py +++ b/backend/app/routers/__init__.py @@ -23,3 +23,4 @@ from app.routers.agent_sessions import router as agent_sessions_router from app.routers.terminal import router as terminal_router from app.routers.tools import router as tools_router from app.routers.remote_mount import router as remote_mount_router +from app.routers.office import router as office_router diff --git a/backend/app/routers/conversation.py b/backend/app/routers/conversation.py index cea647d..a903ebd 100644 --- a/backend/app/routers/conversation.py +++ b/backend/app/routers/conversation.py @@ -100,6 +100,7 @@ async def chat( conversation_id=data.conversation_id, file_ids=data.file_ids, model_name=data.model_name, + runtime=data.runtime, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) @@ -115,7 +116,7 @@ async def chat( conversation_id=conv_id, message_id=msg_id, content=content, - agent_name="jarvis", + agent_name=data.runtime or "jarvis", model_name=model_name, ) @@ -141,6 +142,7 @@ async def chat_stream( conversation_id=data.conversation_id, file_ids=data.file_ids, model_name=data.model_name, + runtime=data.runtime, ) except ValueError as exc: yield f"event: error\ndata: {json.dumps({'error': str(exc)}, ensure_ascii=False)}\n\n" diff --git a/backend/app/routers/schedule_center.py b/backend/app/routers/schedule_center.py index 7643997..d76632c 100644 --- a/backend/app/routers/schedule_center.py +++ b/backend/app/routers/schedule_center.py @@ -1,25 +1,62 @@ from calendar import monthrange from datetime import UTC, date, datetime -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, Query from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload from app.database import get_db from app.models.goal import Goal from app.models.reminder import Reminder -from app.models.task import Task, TaskPriority +from app.models.task import Task, TaskDispatchStatus, TaskPriority, TaskQuadrant, TaskStatus from app.models.todo import DailyTodo from app.models.user import User from app.routers.auth import get_current_user from app.schemas.schedule_center import ( + ScheduleCenterCommanderSummaryOut, ScheduleCenterDateOut, ScheduleCenterDaySummary, + ScheduleCenterFocusTaskOut, ScheduleCenterMonthOut, + ScheduleCenterQuadrantOut, + ScheduleCenterQuadrantTaskOut, ) +from app.schemas.task import build_task_out router = APIRouter(prefix="/api/schedule-center", tags=["调度中心"]) +QUADRANT_META: dict[TaskQuadrant, dict[str, str]] = { + TaskQuadrant.URGENT_IMPORTANT: { + "title": "重要且紧急", + "subtitle": "CRITICAL", + "color": "#ff4757", + "glow_color": "rgba(255, 71, 87, 0.4)", + "icon": "◈", + }, + TaskQuadrant.NOT_URGENT_IMPORTANT: { + "title": "重要不紧急", + "subtitle": "PLANNED", + "color": "#ffd93d", + "glow_color": "rgba(255, 217, 61, 0.4)", + "icon": "◇", + }, + TaskQuadrant.URGENT_NOT_IMPORTANT: { + "title": "紧急不重要", + "subtitle": "DELEGATE", + "color": "#00d4ff", + "glow_color": "rgba(0, 212, 255, 0.4)", + "icon": "◉", + }, + TaskQuadrant.NOT_URGENT_NOT_IMPORTANT: { + "title": "不重要不紧急", + "subtitle": "ELIMINATE", + "color": "#6bcf7f", + "glow_color": "rgba(107, 207, 127, 0.4)", + "icon": "○", + }, +} + def _build_summary( target_date: str, @@ -39,6 +76,146 @@ def _build_summary( ) +def _coerce_enum(value, enum_cls, default=None): + if value is None: + return default + if isinstance(value, enum_cls): + return value + if isinstance(value, str): + raw = value.strip() + if not raw: + return default + for item in enum_cls: + if raw == item.value or raw.lower() == item.value: + return item + if raw.upper() == item.name: + return item + return default + + +def _derive_quadrant(task: Task) -> TaskQuadrant: + quadrant = _coerce_enum(task.quadrant, TaskQuadrant, None) + if quadrant is not None: + return quadrant + + priority = _coerce_enum(task.priority, TaskPriority, TaskPriority.MEDIUM) + status = _coerce_enum(task.status, TaskStatus, TaskStatus.TODO) + + if priority in {TaskPriority.HIGH, TaskPriority.URGENT}: + return TaskQuadrant.URGENT_IMPORTANT + if status == TaskStatus.IN_PROGRESS: + return TaskQuadrant.NOT_URGENT_IMPORTANT + if priority == TaskPriority.MEDIUM: + return TaskQuadrant.URGENT_NOT_IMPORTANT + return TaskQuadrant.NOT_URGENT_NOT_IMPORTANT + + +def _enum_value(value) -> str | None: + if value is None: + return None + if hasattr(value, "value"): + return str(value.value) + if isinstance(value, str): + raw = value.strip() + return raw or None + return str(value) + + +def _build_focus_tasks(tasks: list[Task]) -> list[ScheduleCenterFocusTaskOut]: + priority_rank = { + TaskPriority.URGENT: 0, + TaskPriority.HIGH: 1, + TaskPriority.MEDIUM: 2, + TaskPriority.LOW: 3, + } + status_rank = { + TaskStatus.IN_PROGRESS: 0, + TaskStatus.TODO: 1, + TaskStatus.DONE: 2, + TaskStatus.CANCELLED: 3, + } + ordered = sorted( + tasks, + key=lambda item: ( + status_rank.get(_coerce_enum(item.status, TaskStatus, TaskStatus.TODO), 99), + priority_rank.get(_coerce_enum(item.priority, TaskPriority, TaskPriority.MEDIUM), 99), + item.created_at, + ), + ) + return [ + ScheduleCenterFocusTaskOut( + id=item.id, + title=item.title, + status=_coerce_enum(item.status, TaskStatus, TaskStatus.TODO), + priority=_coerce_enum(item.priority, TaskPriority, TaskPriority.MEDIUM), + quadrant=_derive_quadrant(item), + assignee_type=_enum_value(item.assignee_type), + assignee_id=item.assignee_id, + dispatch_status=_coerce_enum(item.dispatch_status, TaskDispatchStatus, TaskDispatchStatus.IDLE), + due_date=item.due_date, + ) + for item in ordered[:6] + ] + + +def _build_quadrants(tasks: list[Task]) -> list[ScheduleCenterQuadrantOut]: + buckets: dict[TaskQuadrant, list[ScheduleCenterQuadrantTaskOut]] = { + quadrant: [] for quadrant in QUADRANT_META + } + for task in tasks: + quadrant = _derive_quadrant(task) + buckets[quadrant].append( + ScheduleCenterQuadrantTaskOut( + id=task.id, + title=task.title, + status=_coerce_enum(task.status, TaskStatus, TaskStatus.TODO), + priority=_coerce_enum(task.priority, TaskPriority, TaskPriority.MEDIUM), + dispatch_status=_coerce_enum(task.dispatch_status, TaskDispatchStatus, TaskDispatchStatus.IDLE), + assignee_type=_enum_value(task.assignee_type), + assignee_id=task.assignee_id, + ) + ) + + return [ + ScheduleCenterQuadrantOut( + id=quadrant, + title=meta["title"], + subtitle=meta["subtitle"], + color=meta["color"], + glow_color=meta["glow_color"], + icon=meta["icon"], + tasks=buckets[quadrant], + ) + for quadrant, meta in QUADRANT_META.items() + ] + + +def _build_commander_summary(tasks: list[Task]) -> ScheduleCenterCommanderSummaryOut: + counts = ScheduleCenterCommanderSummaryOut() + for task in tasks: + states = [task.dispatch_status, *(subtask.dispatch_status for subtask in task.subtasks)] + for state in states: + normalized = _coerce_enum(state, TaskDispatchStatus, TaskDispatchStatus.IDLE) + if normalized == TaskDispatchStatus.IDLE: + continue + counts.total += 1 + if normalized == TaskDispatchStatus.QUEUED: + counts.queued += 1 + elif normalized == TaskDispatchStatus.RUNNING: + counts.running += 1 + elif normalized == TaskDispatchStatus.COMPLETED: + counts.completed += 1 + elif normalized == TaskDispatchStatus.FAILED: + counts.failed += 1 + if counts.running > 0: + counts.overall_status = "running" + elif counts.queued > 0: + counts.overall_status = "queued" + elif counts.failed > 0 and counts.completed == 0: + counts.overall_status = "failed" + return counts + + @router.get("/month", response_model=ScheduleCenterMonthOut) async def get_month_schedule( year: int = Query(..., ge=2000, le=2100), @@ -53,27 +230,43 @@ async def get_month_schedule( start_dt = datetime.combine(month_start, datetime.min.time()) end_dt = datetime.combine(month_start.replace(day=days_in_month), datetime.max.time()) - todos = (await db.execute( - select(DailyTodo).where(DailyTodo.user_id == current_user.id, DailyTodo.todo_date >= start_key, DailyTodo.todo_date <= end_key) - )).scalars().all() - tasks = (await db.execute( - select(Task).where( - Task.user_id == current_user.id, - Task.due_date.is_not(None), - Task.due_date >= start_dt, - Task.due_date <= end_dt, + todos = ( + await db.execute( + select(DailyTodo).where( + DailyTodo.user_id == current_user.id, + DailyTodo.todo_date >= start_key, + DailyTodo.todo_date <= end_key, + ) ) - )).scalars().all() - reminders = (await db.execute( - select(Reminder).where( - Reminder.user_id == current_user.id, - Reminder.reminder_at >= start_dt, - Reminder.reminder_at <= end_dt, + ).scalars().all() + tasks = ( + await db.execute( + select(Task).where( + Task.user_id == current_user.id, + Task.due_date.is_not(None), + Task.due_date >= start_dt, + Task.due_date <= end_dt, + ) ) - )).scalars().all() - goals = (await db.execute( - select(Goal).where(Goal.user_id == current_user.id, Goal.goal_date >= start_key, Goal.goal_date <= end_key) - )).scalars().all() + ).scalars().all() + reminders = ( + await db.execute( + select(Reminder).where( + Reminder.user_id == current_user.id, + Reminder.reminder_at >= start_dt, + Reminder.reminder_at <= end_dt, + ) + ) + ).scalars().all() + goals = ( + await db.execute( + select(Goal).where( + Goal.user_id == current_user.id, + Goal.goal_date >= start_key, + Goal.goal_date <= end_key, + ) + ) + ).scalars().all() todo_map: dict[str, list[DailyTodo]] = {} for item in todos: @@ -96,18 +289,20 @@ async def get_month_schedule( days = [] for day in range(1, days_in_month + 1): date_key = month_start.replace(day=day).isoformat() - days.append(_build_summary( - date_key, - todo_map.get(date_key, []), - task_map.get(date_key, []), - reminder_map.get(date_key, []), - goal_map.get(date_key, []), - )) + days.append( + _build_summary( + date_key, + todo_map.get(date_key, []), + task_map.get(date_key, []), + reminder_map.get(date_key, []), + goal_map.get(date_key, []), + ) + ) return ScheduleCenterMonthOut(month=f"{year:04d}-{month:02d}", days=days) -@router.get("/date", response_model=ScheduleCenterDateOut) +@router.get("/date", response_model=ScheduleCenterDateOut, response_model_exclude_none=True) async def get_date_schedule( date_str: date = Query(...), current_user: User = Depends(get_current_user), @@ -118,43 +313,55 @@ async def get_date_schedule( end_dt = datetime.combine(target_date, datetime.max.time()) date_key = target_date.isoformat() - todos = (await db.execute( - select(DailyTodo) - .where(DailyTodo.user_id == current_user.id, DailyTodo.todo_date == date_key) - .order_by(DailyTodo.created_at.desc()) - )).scalars().all() - tasks = (await db.execute( - select(Task) - .where( - Task.user_id == current_user.id, - Task.due_date.is_not(None), - Task.due_date >= start_dt, - Task.due_date <= end_dt, + todos = ( + await db.execute( + select(DailyTodo) + .where(DailyTodo.user_id == current_user.id, DailyTodo.todo_date == date_key) + .order_by(DailyTodo.created_at.desc()) ) - .order_by(Task.created_at.desc()) - )).scalars().all() - reminders = (await db.execute( - select(Reminder) - .where( - Reminder.user_id == current_user.id, - Reminder.reminder_at >= start_dt, - Reminder.reminder_at <= end_dt, + ).scalars().all() + tasks = ( + await db.execute( + select(Task) + .options(selectinload(Task.subtasks), selectinload(Task.history)) + .where( + Task.user_id == current_user.id, + Task.due_date.is_not(None), + Task.due_date >= start_dt, + Task.due_date <= end_dt, + ) + .order_by(Task.priority.desc(), Task.created_at.desc()) ) - .order_by(Reminder.reminder_at.asc(), Reminder.created_at.asc()) - )).scalars().all() - goals = (await db.execute( - select(Goal) - .where(Goal.user_id == current_user.id, Goal.goal_date == date_key) - .order_by(Goal.created_at.desc()) - )).scalars().all() + ).scalars().unique().all() + reminders = ( + await db.execute( + select(Reminder) + .where( + Reminder.user_id == current_user.id, + Reminder.reminder_at >= start_dt, + Reminder.reminder_at <= end_dt, + ) + .order_by(Reminder.reminder_at.asc(), Reminder.created_at.asc()) + ) + ).scalars().all() + goals = ( + await db.execute( + select(Goal) + .where(Goal.user_id == current_user.id, Goal.goal_date == date_key) + .order_by(Goal.created_at.desc()) + ) + ).scalars().all() summary = _build_summary(date_key, todos, tasks, reminders, goals) return ScheduleCenterDateOut( date=date_key, todos=todos, - tasks=tasks, + tasks=[build_task_out(task) for task in tasks], reminders=reminders, goals=goals, summary=summary, + focus_tasks=_build_focus_tasks(tasks), + quadrants=_build_quadrants(tasks), + commander_summary=_build_commander_summary(tasks), generated_at=datetime.now(UTC), ) diff --git a/backend/app/routers/task.py b/backend/app/routers/task.py index 4e831ea..65a5c87 100644 --- a/backend/app/routers/task.py +++ b/backend/app/routers/task.py @@ -1,15 +1,116 @@ +import json from datetime import UTC, date, datetime from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import desc, select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + from app.database import get_db -from app.models.task import Task, TaskStatus +from app.models.task import ( + Task, + TaskAssigneeType, + TaskDispatchStatus, + TaskQuadrant, + TaskSource, + TaskStatus, + TaskSubTask, +) from app.models.user import User from app.routers.auth import get_current_user -from app.schemas.task import TaskCreate, TaskUpdate, TaskOut +from app.schemas.task import ( + TaskCreate, + TaskDetailOut, + TaskDispatchRequest, + TaskDispatchResponse, + TaskHistoryOut, + TaskOut, + TaskSubTaskCreate, + TaskSubTaskOut, + TaskSubTaskReorderRequest, + TaskSubTaskUpdate, + TaskUpdate, + build_task_detail_out, +) +from app.services.task_dispatch import append_task_history, load_task_with_details, queue_task_dispatch -router = APIRouter(prefix="/api/tasks", tags=["看板"]) +router = APIRouter(prefix="/api/tasks", tags=["Tasks"]) + + +def _encode_tags(tags: list[str] | None) -> str | None: + if not tags: + return None + return json.dumps(tags, ensure_ascii=False) + + +def _decode_tags(value: str | None) -> list[str]: + if not value: + return [] + try: + payload = json.loads(value) + except json.JSONDecodeError: + return [value] + if isinstance(payload, list): + return [str(item) for item in payload] + return [str(payload)] + + +def _subtask_to_out(subtask: TaskSubTask) -> TaskSubTaskOut: + return TaskSubTaskOut.model_validate(subtask) + + +def _history_to_out(history) -> TaskHistoryOut: + return TaskHistoryOut.model_validate(history) + + +def _task_to_out(task: Task) -> TaskOut: + return TaskOut( + id=task.id, + title=task.title, + description=task.description, + status=task.status, + priority=task.priority, + due_date=task.due_date, + completed_at=task.completed_at, + tags=_decode_tags(task.tags), + source=task.source or TaskSource.MANUAL, + conversation_id=task.conversation_id, + quadrant=task.quadrant, + assignee_type=task.assignee_type, + assignee_id=task.assignee_id, + dispatch_status=task.dispatch_status or TaskDispatchStatus.IDLE, + dispatch_run_id=task.dispatch_run_id, + result_summary=task.result_summary, + started_at=task.started_at, + last_synced_at=task.last_synced_at, + created_at=task.created_at, + updated_at=task.updated_at, + ) + + +def _task_detail_to_out(task: Task) -> TaskDetailOut: + return build_task_detail_out(task) + + +async def _get_task_or_404(db: AsyncSession, *, task_id: str, user_id: str) -> Task: + task = await load_task_with_details(db, task_id=task_id, user_id=user_id) + if task is None: + raise HTTPException(status_code=404, detail="Task not found") + return task + + +def _sync_task_completion(task: Task) -> None: + if task.status == TaskStatus.DONE: + task.completed_at = task.completed_at or datetime.now(UTC) + elif task.status != TaskStatus.CANCELLED: + task.completed_at = None + + +def _sync_subtask_completion(subtask: TaskSubTask) -> None: + if subtask.status == TaskStatus.DONE: + subtask.completed_at = subtask.completed_at or datetime.now(UTC) + elif subtask.status != TaskStatus.CANCELLED: + subtask.completed_at = None @router.get("", response_model=list[TaskOut]) @@ -18,12 +119,28 @@ async def list_tasks( due_date: date | None = Query(default=None), date_from: date | None = Query(default=None), date_to: date | None = Query(default=None), + quadrant: TaskQuadrant | None = None, + assignee_type: TaskAssigneeType | None = None, + dispatch_status: TaskDispatchStatus | None = None, + conversation_id: str | None = None, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - query = select(Task).where(Task.user_id == current_user.id) + query = ( + select(Task) + .options(selectinload(Task.subtasks), selectinload(Task.history)) + .where(Task.user_id == current_user.id) + ) if status: query = query.where(Task.status == status) + if quadrant: + query = query.where(Task.quadrant == quadrant) + if assignee_type: + query = query.where(Task.assignee_type == assignee_type) + if dispatch_status: + query = query.where(Task.dispatch_status == dispatch_status) + if conversation_id: + query = query.where(Task.conversation_id == conversation_id) if due_date: start = datetime.combine(due_date, datetime.min.time()) end = datetime.combine(due_date, datetime.max.time()) @@ -32,65 +149,109 @@ async def list_tasks( start = datetime.combine(date_from, datetime.min.time()) if date_from else None end = datetime.combine(date_to, datetime.max.time()) if date_to else None if start and end and start > end: - raise HTTPException(status_code=400, detail="开始日期不能晚于结束日期") + raise HTTPException(status_code=400, detail="date_from cannot be later than date_to") if start is not None: query = query.where(Task.due_date.is_not(None), Task.due_date >= start) if end is not None: query = query.where(Task.due_date.is_not(None), Task.due_date <= end) - query = query.order_by(desc(Task.created_at)) + + query = query.order_by(desc(Task.updated_at), desc(Task.created_at)) result = await db.execute(query) - return result.scalars().all() + tasks = result.scalars().unique().all() + return [_task_to_out(task) for task in tasks] -@router.post("", response_model=TaskOut, status_code=201) +@router.post("", response_model=TaskDetailOut, status_code=201) async def create_task( data: TaskCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - import json task = Task( user_id=current_user.id, title=data.title, description=data.description, priority=data.priority, due_date=data.due_date, - tags=json.dumps(data.tags) if data.tags else None, + tags=_encode_tags(data.tags), + source=data.source, + conversation_id=data.conversation_id, + quadrant=data.quadrant, + assignee_type=data.assignee_type, + assignee_id=data.assignee_id, + status=data.status, ) + _sync_task_completion(task) + if data.source == TaskSource.CHAT: + append_task_history(task, action="created_from_chat", new_value=task.title) + append_task_history(task, action="created", new_value=task.title) + for index, subtask_data in enumerate(data.subtasks): + subtask = TaskSubTask( + title=subtask_data.title, + description=subtask_data.description, + status=subtask_data.status, + order_index=index if subtask_data.order_index is None else subtask_data.order_index, + assignee_type=subtask_data.assignee_type, + assignee_id=subtask_data.assignee_id, + ) + _sync_subtask_completion(subtask) + task.subtasks.append(subtask) + append_task_history(task, action="subtask_created", new_value=subtask.title) db.add(task) await db.commit() - await db.refresh(task) - return task + + task = await _get_task_or_404(db, task_id=task.id, user_id=current_user.id) + if data.dispatch_to_commander: + await queue_task_dispatch(task, db=db) + task = await _get_task_or_404(db, task_id=task.id, user_id=current_user.id) + return _task_detail_to_out(task) -@router.patch("/{task_id}", response_model=TaskOut) +@router.get("/{task_id}", response_model=TaskDetailOut) +async def get_task( + task_id: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id) + return _task_detail_to_out(task) + + +@router.patch("/{task_id}", response_model=TaskDetailOut) async def update_task( task_id: str, data: TaskUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - import json - result = await db.execute( - select(Task).where(Task.id == task_id, Task.user_id == current_user.id) - ) - task = result.scalar_one_or_none() - if not task: - raise HTTPException(status_code=404, detail="任务不存在") + task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id) + payload = data.model_dump(exclude_none=True) + previous_assignee = (task.assignee_type, task.assignee_id) - for field, value in data.model_dump(exclude_none=True).items(): + for field, value in payload.items(): + previous = getattr(task, field) if field == "tags": - setattr(task, field, json.dumps(value)) - elif field == "status" and value == TaskStatus.DONE: - task.completed_at = datetime.now(UTC) - setattr(task, field, value) - elif field == "status": - task.completed_at = None - setattr(task, field, value) + task.tags = _encode_tags(value) + append_task_history(task, action="updated", old_value=_decode_tags(previous), new_value=value) + continue + setattr(task, field, value) + if field == "status": + _sync_task_completion(task) + append_task_history(task, action="status_changed", old_value=previous, new_value=value) + elif previous != value: + append_task_history(task, action="updated", old_value=previous, new_value=value) + + if ("assignee_type" in payload or "assignee_id" in payload) and previous_assignee != (task.assignee_type, task.assignee_id): + append_task_history( + task, + action="assigned", + old_value=f"{previous_assignee[0]}:{previous_assignee[1]}", + new_value=f"{task.assignee_type}:{task.assignee_id}", + ) await db.commit() - await db.refresh(task) - return task + task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id) + return _task_detail_to_out(task) @router.delete("/{task_id}", status_code=204) @@ -99,11 +260,171 @@ async def delete_task( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - result = await db.execute( - select(Task).where(Task.id == task_id, Task.user_id == current_user.id) - ) - task = result.scalar_one_or_none() - if not task: - raise HTTPException(status_code=404, detail="任务不存在") + task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id) await db.delete(task) await db.commit() + + +@router.post("/{task_id}/subtasks", status_code=201) +async def create_subtask( + task_id: str, + data: TaskSubTaskCreate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id) + max_order = max((item.order_index for item in task.subtasks), default=-1) + subtask = TaskSubTask( + task_id=task.id, + title=data.title, + description=data.description, + status=data.status, + order_index=max_order + 1 if data.order_index is None else data.order_index, + assignee_type=data.assignee_type, + assignee_id=data.assignee_id, + ) + _sync_subtask_completion(subtask) + task.subtasks.append(subtask) + append_task_history(task, action="subtask_created", new_value=data.title) + await db.commit() + db.expire_all() + task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id) + detail = _task_detail_to_out(task) + created_subtask = max( + (item for item in detail.subtasks if item.title == data.title), + key=lambda item: (item.order_index, item.created_at), + default=None, + ) + if created_subtask is None: + raise HTTPException(status_code=500, detail="Created subtask could not be loaded") + return { + **created_subtask.model_dump(), + "task": detail.model_dump(), + "subtasks": [item.model_dump() for item in detail.subtasks], + "history": [item.model_dump() for item in detail.history], + "dispatch": detail.dispatch.model_dump(), + "dispatch_summary": detail.dispatch_summary.model_dump(), + } + + +@router.patch("/{task_id}/subtasks/{subtask_id}", response_model=TaskDetailOut) +async def update_subtask( + task_id: str, + subtask_id: str, + data: TaskSubTaskUpdate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id) + subtask = next((item for item in task.subtasks if item.id == subtask_id), None) + if subtask is None: + raise HTTPException(status_code=404, detail="Subtask not found") + + payload = data.model_dump(exclude_none=True) + for field, value in payload.items(): + previous = getattr(subtask, field) + setattr(subtask, field, value) + if field == "status": + _sync_subtask_completion(subtask) + if previous != value: + append_task_history( + task, + action="updated" if field != "status" else "status_changed", + old_value=f"{subtask.id}:{field}:{previous}", + new_value=f"{subtask.id}:{field}:{value}", + ) + + await db.commit() + db.expire_all() + task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id) + return _task_detail_to_out(task) + + +@router.delete("/{task_id}/subtasks/{subtask_id}", response_model=TaskDetailOut) +async def delete_subtask( + task_id: str, + subtask_id: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id) + subtask = next((item for item in task.subtasks if item.id == subtask_id), None) + if subtask is None: + raise HTTPException(status_code=404, detail="Subtask not found") + + append_task_history(task, action="updated", old_value="subtask_deleted", new_value=subtask.title) + await db.delete(subtask) + await db.commit() + db.expire_all() + task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id) + return _task_detail_to_out(task) + + +@router.post("/{task_id}/subtasks/reorder", response_model=TaskDetailOut) +async def reorder_subtasks( + task_id: str, + data: TaskSubTaskReorderRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id) + lookup = {item.id: item for item in task.subtasks} + for item in data.items: + subtask = lookup.get(item.id) + if subtask is None: + raise HTTPException(status_code=404, detail=f"Subtask not found: {item.id}") + subtask.order_index = item.order_index + + append_task_history( + task, + action="subtask_reordered", + new_value=",".join(f"{item.id}:{item.order_index}" for item in data.items), + ) + await db.commit() + db.expire_all() + task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id) + return _task_detail_to_out(task) + + +@router.post("/{task_id}/dispatch", response_model=TaskDispatchResponse) +async def dispatch_task( + task_id: str, + data: TaskDispatchRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + if data.target != "commander": + raise HTTPException(status_code=400, detail="Only commander dispatch is supported") + task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id) + _, payload = await queue_task_dispatch(task, db=db) + task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id) + return TaskDispatchResponse( + status=task.dispatch_status, + run_id=task.dispatch_run_id, + task=_task_detail_to_out(task), + payload=payload, + ) + + +@router.post("/{task_id}/subtasks/{subtask_id}/dispatch", response_model=TaskDispatchResponse) +async def dispatch_subtask( + task_id: str, + subtask_id: str, + data: TaskDispatchRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + if data.target != "commander": + raise HTTPException(status_code=400, detail="Only commander dispatch is supported") + task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id) + subtask = next((item for item in task.subtasks if item.id == subtask_id), None) + if subtask is None: + raise HTTPException(status_code=404, detail="Subtask not found") + _, payload = await queue_task_dispatch(task, db=db, subtask=subtask) + task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id) + return TaskDispatchResponse( + status=subtask.dispatch_status, + run_id=subtask.dispatch_run_id, + task=_task_detail_to_out(task), + payload=payload, + ) diff --git a/backend/app/schemas/task.py b/backend/app/schemas/task.py index d8dabe8..abbd4a3 100644 --- a/backend/app/schemas/task.py +++ b/backend/app/schemas/task.py @@ -1,14 +1,146 @@ -from pydantic import BaseModel +from __future__ import annotations + +import json from datetime import datetime -from app.models.task import TaskStatus, TaskPriority + +from pydantic import BaseModel, Field +from sqlalchemy import inspect +from sqlalchemy.orm.attributes import NO_VALUE + +from app.models.task import ( + Task, + TaskAssigneeType, + TaskDispatchStatus, + TaskHistory, + TaskPriority, + TaskQuadrant, + TaskSource, + TaskStatus, + TaskSubTask, +) + + +def _coerce_enum(value, enum_cls, default=None): + if value is None: + return default + if isinstance(value, enum_cls): + return value + if isinstance(value, str): + raw = value.strip() + if not raw: + return default + for item in enum_cls: + if raw == item.value or raw.lower() == item.value: + return item + if raw.upper() == item.name: + return item + return default + + +def parse_tags(raw_tags: str | None) -> list[str]: + if not raw_tags: + return [] + try: + parsed = json.loads(raw_tags) + except json.JSONDecodeError: + return [] + if not isinstance(parsed, list): + return [] + return [str(item) for item in parsed] + + +def serialize_tags(tags: list[str] | None) -> str | None: + if not tags: + return None + return json.dumps([str(item) for item in tags], ensure_ascii=False) + + +class TaskSubTaskCreate(BaseModel): + title: str + description: str | None = None + status: TaskStatus = TaskStatus.TODO + order_index: int | None = None + assignee_type: TaskAssigneeType | None = None + assignee_id: str | None = None + + +class TaskSubTaskUpdate(BaseModel): + title: str | None = None + description: str | None = None + status: TaskStatus | None = None + order_index: int | None = None + assignee_type: TaskAssigneeType | None = None + assignee_id: str | None = None + dispatch_status: TaskDispatchStatus | None = None + dispatch_run_id: str | None = None + result_summary: str | None = None + + +class TaskSubTaskReorderItem(BaseModel): + id: str + order_index: int + + +class TaskSubTaskReorderRequest(BaseModel): + items: list[TaskSubTaskReorderItem] = Field(default_factory=list) + + +class TaskSubTaskOut(BaseModel): + id: str + task_id: str + title: str + description: str | None + status: TaskStatus + order_index: int + assignee_type: TaskAssigneeType | None + assignee_id: str | None + dispatch_status: TaskDispatchStatus + dispatch_run_id: str | None + result_summary: str | None = None + completed_at: datetime | None + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +class TaskHistoryOut(BaseModel): + id: str + task_id: str + action: str + old_value: str | None + new_value: str | None + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +class TaskDispatchSummary(BaseModel): + status: TaskDispatchStatus + run_id: str | None = None + result_summary: str | None = None + started_at: datetime | None = None + last_synced_at: datetime | None = None + total_subtasks: int = 0 + dispatched_subtasks: int = 0 + subtask_dispatch_statuses: dict[str, int] = Field(default_factory=dict) class TaskCreate(BaseModel): title: str description: str | None = None + status: TaskStatus = TaskStatus.TODO priority: TaskPriority = TaskPriority.MEDIUM due_date: datetime | None = None tags: list[str] | None = None + source: TaskSource = TaskSource.MANUAL + conversation_id: str | None = None + quadrant: TaskQuadrant | None = None + assignee_type: TaskAssigneeType | None = None + assignee_id: str | None = None + subtasks: list[TaskSubTaskCreate] = Field(default_factory=list) + dispatch_to_commander: bool = False class TaskUpdate(BaseModel): @@ -18,6 +150,16 @@ class TaskUpdate(BaseModel): priority: TaskPriority | None = None due_date: datetime | None = None tags: list[str] | None = None + source: TaskSource | None = None + conversation_id: str | None = None + quadrant: TaskQuadrant | None = None + assignee_type: TaskAssigneeType | None = None + assignee_id: str | None = None + dispatch_status: TaskDispatchStatus | None = None + dispatch_run_id: str | None = None + result_summary: str | None = None + started_at: datetime | None = None + last_synced_at: datetime | None = None class TaskOut(BaseModel): @@ -28,12 +170,128 @@ class TaskOut(BaseModel): priority: TaskPriority due_date: datetime | None completed_at: datetime | None - tags: str | None + tags: list[str] = Field(default_factory=list) + source: TaskSource + conversation_id: str | None + quadrant: TaskQuadrant | None + assignee_type: TaskAssigneeType | None + assignee_id: str | None + dispatch_status: TaskDispatchStatus + dispatch_run_id: str | None + result_summary: str | None + started_at: datetime | None + last_synced_at: datetime | None + subtask_count: int = 0 created_at: datetime updated_at: datetime - model_config = {"from_attributes": True} + +class TaskDetailOut(TaskOut): + subtasks: list[TaskSubTaskOut] = Field(default_factory=list) + history: list[TaskHistoryOut] = Field(default_factory=list) + dispatch: TaskDispatchSummary + dispatch_summary: TaskDispatchSummary + + + + +class TaskDispatchRequest(BaseModel): + target: str = "commander" + conversation_id: str | None = None + assignee_type: TaskAssigneeType | None = None + assignee_id: str | None = None + + +class TaskDispatchResponse(BaseModel): + status: TaskDispatchStatus + run_id: str | None = None + task: TaskDetailOut + payload: dict[str, object] = Field(default_factory=dict) class DailyPlanRequest(BaseModel): user_id: str + + +def build_task_out(task: Task) -> TaskOut: + subtasks_attr = inspect(task).attrs.subtasks.loaded_value + return TaskOut( + id=task.id, + title=task.title, + description=task.description, + status=_coerce_enum(task.status, TaskStatus, TaskStatus.TODO), + priority=_coerce_enum(task.priority, TaskPriority, TaskPriority.MEDIUM), + due_date=task.due_date, + completed_at=task.completed_at, + tags=parse_tags(task.tags), + source=_coerce_enum(task.source, TaskSource, TaskSource.MANUAL), + conversation_id=task.conversation_id, + quadrant=_coerce_enum(task.quadrant, TaskQuadrant, None), + assignee_type=_coerce_enum(task.assignee_type, TaskAssigneeType, None), + assignee_id=task.assignee_id, + dispatch_status=_coerce_enum(task.dispatch_status, TaskDispatchStatus, TaskDispatchStatus.IDLE), + dispatch_run_id=task.dispatch_run_id, + result_summary=task.result_summary, + started_at=task.started_at, + last_synced_at=task.last_synced_at, + subtask_count=0 if subtasks_attr is NO_VALUE else len(subtasks_attr or []), + created_at=task.created_at, + updated_at=task.updated_at, + ) + + +def build_task_detail_out(task: Task) -> TaskDetailOut: + normalized_task_dispatch = _coerce_enum(task.dispatch_status, TaskDispatchStatus, TaskDispatchStatus.IDLE) + + normalized_subtasks = [ + TaskSubTaskOut( + id=item.id, + task_id=item.task_id, + title=item.title, + description=item.description, + status=_coerce_enum(item.status, TaskStatus, TaskStatus.TODO), + order_index=item.order_index, + assignee_type=_coerce_enum(item.assignee_type, TaskAssigneeType, None), + assignee_id=item.assignee_id, + dispatch_status=_coerce_enum(item.dispatch_status, TaskDispatchStatus, TaskDispatchStatus.IDLE), + dispatch_run_id=item.dispatch_run_id, + result_summary=item.result_summary, + completed_at=item.completed_at, + created_at=item.created_at, + updated_at=item.updated_at, + ) + for item in task.subtasks + ] + + subtask_dispatch_statuses: dict[str, int] = {} + for item in normalized_subtasks: + key = item.dispatch_status.value + subtask_dispatch_statuses[key] = subtask_dispatch_statuses.get(key, 0) + 1 + + dispatched_subtasks = sum(1 for item in normalized_subtasks if item.dispatch_status != TaskDispatchStatus.IDLE) + + return TaskDetailOut( + **build_task_out(task).model_dump(), + subtasks=normalized_subtasks, + history=[TaskHistoryOut.model_validate(item) for item in task.history], + dispatch=TaskDispatchSummary( + status=normalized_task_dispatch, + run_id=task.dispatch_run_id, + result_summary=task.result_summary, + started_at=task.started_at, + last_synced_at=task.last_synced_at, + total_subtasks=len(normalized_subtasks), + dispatched_subtasks=dispatched_subtasks, + subtask_dispatch_statuses=subtask_dispatch_statuses, + ), + dispatch_summary=TaskDispatchSummary( + status=normalized_task_dispatch, + run_id=task.dispatch_run_id, + result_summary=task.result_summary, + started_at=task.started_at, + last_synced_at=task.last_synced_at, + total_subtasks=len(normalized_subtasks), + dispatched_subtasks=dispatched_subtasks, + subtask_dispatch_statuses=subtask_dispatch_statuses, + ), + )