diff --git a/src/api/mail_routes.py b/src/api/mail_routes.py new file mode 100644 index 0000000..7d42a29 --- /dev/null +++ b/src/api/mail_routes.py @@ -0,0 +1,230 @@ +"""API 路由 — Mail Tab(v2.7) + +Mail 是一个特殊的 Project (_mail),每封 Mail 是一个两点 Task(from → to)。 +显示为独立 Tab,list 形式展示:时间 | From | To | Title | 状态(已读/已执行)。 +""" + +from __future__ import annotations + +import json +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, HTTPException, Query + +from src.blackboard.db import init_db +from src.blackboard.models import Task +from src.blackboard.operations import Blackboard +from src.blackboard.queries import Queries +from src.utils import get_data_root + +router = APIRouter(prefix="/api/mail", tags=["mail"]) + +MAIL_PROJECT_ID = "_mail" + + +def _db_path() -> Path: + root = get_data_root() + db = root / MAIL_PROJECT_ID / "blackboard.db" + db.parent.mkdir(parents=True, exist_ok=True) + init_db(db) + return db + + +def _bb() -> Blackboard: + return Blackboard(_db_path()) + + +def _q() -> Queries: + return Queries(_db_path()) + + +def _mail_meta(task: Task) -> Dict[str, Any]: + """从 Task 的 must_haves 字段解析 Mail 元数据""" + if task.must_haves: + try: + return json.loads(task.must_haves) + except (json.JSONDecodeError, TypeError): + pass + return {} + + +def _task_to_mail(task: Task) -> Dict[str, Any]: + """Task → Mail JSON""" + meta = _mail_meta(task) + return { + "id": task.id, + "title": task.title, + "from": meta.get("from", task.assigned_by), + "to": task.assignee, + "status": task.status, + "type": meta.get("type", "inform"), + "performative": meta.get("performative", "inform"), + "is_read": meta.get("is_read", False), + "created_at": task.created_at, + "updated_at": task.updated_at, + "description": task.description, + "conversation_id": meta.get("conversation_id"), + "in_reply_to": meta.get("in_reply_to"), + } + + +@router.get("") +async def list_mail( + status: Optional[str] = None, + from_agent: Optional[str] = None, + to_agent: Optional[str] = None, + unread: Optional[bool] = None, + limit: int = Query(50, le=200), +): + """Mail 列表(按时间倒序)""" + bb = _bb() + tasks = bb.list_tasks(status=status, assignee=to_agent) + + mails = [] + for t in tasks: + meta = _mail_meta(t) + # from 过滤 + if from_agent and meta.get("from", t.assigned_by) != from_agent: + continue + # unread 过滤 + if unread is True and meta.get("is_read", False): + continue + mails.append(_task_to_mail(t)) + + # 按时间倒序 + mails.sort(key=lambda m: m.get("created_at", ""), reverse=True) + mails = mails[:limit] + + return {"mails": mails, "total": len(mails)} + + +@router.get("/{mail_id}") +async def get_mail(mail_id: str): + """Mail 详情""" + bb = _bb() + task = bb.get_task(mail_id) + if not task: + raise HTTPException(404, f"Mail not found: {mail_id}") + + result = _task_to_mail(task) + # 附加 comments(用于对话线程) + comments = bb.get_comments(mail_id) + result["comments"] = [ + { + "id": c.id, + "author": c.author, + "type": c.comment_type, + "body": c.body, + "created_at": c.created_at, + } + for c in comments + ] + return result + + +@router.post("") +async def send_mail(body: Dict[str, Any]): + """发送 Mail(创建 Task)""" + bb = _bb() + + mail_id = body.get("id", f"mail-{int(datetime.now().timestamp() * 1000)}") + meta = { + "from": body.get("from", "user"), + "type": body.get("type", "text"), + "performative": body.get("performative", "inform"), + "is_read": False, + "conversation_id": body.get("conversation_id"), + "in_reply_to": body.get("in_reply_to"), + } + + task = Task( + id=mail_id, + title=body.get("title", ""), + description=body.get("text", body.get("description", "")), + assignee=body.get("to"), + assigned_by=body.get("from", "user"), + must_haves=json.dumps(meta), + task_type="mail", + status="done" if body.get("type") == "inform" else "pending", + ) + bb.create_task(task) + return {"ok": True, "mail_id": task.id} + + +@router.patch("/{mail_id}") +async def update_mail(mail_id: str, body: Dict[str, Any]): + """更新 Mail(标记已读/已执行)""" + bb = _bb() + task = bb.get_task(mail_id) + if not task: + raise HTTPException(404, f"Mail not found: {mail_id}") + + meta = _mail_meta(task) + + if "is_read" in body: + meta["is_read"] = body["is_read"] + + if body.get("mark_executed"): + meta["is_read"] = True + if task.status not in ("done", "cancelled"): + bb.update_task_status(mail_id, "done", agent="mail-api") + + # 写回 must_haves + conn = bb._conn() + try: + conn.execute("BEGIN IMMEDIATE") + conn.execute( + "UPDATE tasks SET must_haves=?, updated_at=datetime('now') WHERE id=?", + (json.dumps(meta), mail_id), + ) + conn.commit() + finally: + conn.close() + + return {"ok": True} + + +@router.get("/agents/list") +async def list_mail_agents(): + """列出参与过 Mail 的所有 Agent(用于筛选)""" + q = _q() + conn = q._conn() + try: + # 从 assigned_by 和 assignee 去重 + senders = conn.execute( + "SELECT DISTINCT assigned_by FROM tasks WHERE assigned_by IS NOT NULL" + ).fetchall() + receivers = conn.execute( + "SELECT DISTINCT assignee FROM tasks WHERE assignee IS NOT NULL" + ).fetchall() + agents = list(set( + [r["assigned_by"] for r in senders] + + [r["assignee"] for r in receivers] + )) + agents.sort() + return {"agents": agents} + finally: + conn.close() + + +@router.get("/summary") +async def mail_summary(): + """Mail 摘要(未读数、总数)""" + q = _q() + bb = _bb() + all_tasks = bb.list_tasks() + + total = len(all_tasks) + unread = 0 + by_type: Dict[str, int] = {} + + for t in all_tasks: + meta = _mail_meta(t) + if not meta.get("is_read", False): + unread += 1 + t = meta.get("type", "inform") + by_type[t] = by_type.get(t, 0) + 1 + + return {"total": total, "unread": unread, "by_type": by_type}