"""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("/agents/list") async def list_mail_agents(): """列出参与过 Mail 的所有 Agent(用于筛选)""" q = _q() conn = q._conn() try: 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 摘要(未读数、总数)""" 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 mtype = meta.get("type", "inform") by_type[mtype] = by_type.get(mtype, 0) + 1 return {"total": total, "unread": unread, "by_type": by_type} @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") bb.update_must_haves(mail_id, json.dumps(meta)) return {"ok": True}