圆桌草稿持久化:localStorage 迁至后端 MySQL
新增 /api/roundtable-drafts 草稿 CRUD 与会话级推荐历史;前端 useDraftPersistence 改走 api/drafts,删除本地 drafts.ts。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
24e92c437d
commit
838cae0d2d
16 files changed
+1410
-480
No files matched your search
@@ -42,6 +42,7 @@ from app.gateway.routers import (
|
||||
public_scheduled_tasks,
|
||||
recommend,
|
||||
recommended_questions,
|
||||
roundtable_drafts,
|
||||
runs,
|
||||
scheduled_tasks,
|
||||
skills,
|
||||
@@ -833,6 +834,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
|
||||
app.include_router(user_prompts.router)
|
||||
|
||||
# Roundtable-planning drafts + recommendation history at /api/roundtable-drafts
|
||||
app.include_router(roundtable_drafts.router)
|
||||
|
||||
# Suggestions API is mounted at /api/threads/{thread_id}/suggestions
|
||||
app.include_router(suggestions.router)
|
||||
|
||||
|
||||
@@ -152,6 +152,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
|
||||
app.state.user_prompt_store = make_user_prompt_store(sf)
|
||||
|
||||
from deerflow.persistence.roundtable_drafts import make_roundtable_draft_store
|
||||
|
||||
app.state.roundtable_draft_store = make_roundtable_draft_store(sf)
|
||||
|
||||
# Run event store (has its own factory with config-driven backend selection)
|
||||
run_events_config = getattr(config, "run_events", None)
|
||||
app.state.run_event_store = make_run_event_store(run_events_config)
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
"""CRUD API for roundtable-planning drafts + recommendation history.
|
||||
|
||||
A draft is one full roundtable session (Step 1 intent + Step 2 multi-agent
|
||||
roundtable). Bound to the requesting user (not the browser), so it follows the
|
||||
account across devices/sessions — replacing the old ``localStorage`` storage.
|
||||
|
||||
Recommendation history is bound to a draft: the recommend dialog shows "last
|
||||
recommendation for this session" on open, while still letting the user
|
||||
re-analyze (which appends a new history row).
|
||||
|
||||
Routes (prefix ``/api/roundtable-drafts``):
|
||||
GET "" list current user's drafts (lightweight)
|
||||
POST "" create a draft
|
||||
GET "/{draft_id}" full draft (incl. step1 / step2 blobs)
|
||||
PUT "/{draft_id}" partial update (title / step / snapshots)
|
||||
DELETE "/{draft_id}" delete
|
||||
GET "/{draft_id}/recommendations" list recommendation history
|
||||
POST "/{draft_id}/recommendations" append one recommendation run
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/roundtable-drafts", tags=["roundtable-drafts"])
|
||||
|
||||
|
||||
# ── schemas ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class DraftMetaResponse(BaseModel):
|
||||
id: str
|
||||
title: str = ""
|
||||
furthest_step: int = 1
|
||||
created_at: datetime | str | None = None
|
||||
updated_at: datetime | str | None = None
|
||||
|
||||
|
||||
class DraftListResponse(BaseModel):
|
||||
drafts: list[DraftMetaResponse]
|
||||
|
||||
|
||||
class DraftResponse(DraftMetaResponse):
|
||||
step1: dict[str, Any] | None = None
|
||||
step2: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class DraftCreateRequest(BaseModel):
|
||||
# Optional client-provided id so the browser can keep one stable id across
|
||||
# the create + subsequent updates without an extra round-trip.
|
||||
id: str | None = Field(default=None, max_length=64)
|
||||
title: str = Field(default="", max_length=512)
|
||||
furthest_step: int = Field(default=1, ge=1, le=3)
|
||||
step1: dict[str, Any] | None = None
|
||||
step2: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class DraftUpdateRequest(BaseModel):
|
||||
# All optional → callers send only what changed. Auto-save omits ``title``
|
||||
# so it never clobbers a manual rename.
|
||||
title: str | None = Field(default=None, max_length=512)
|
||||
furthest_step: int | None = Field(default=None, ge=1, le=3)
|
||||
step1: dict[str, Any] | None = None
|
||||
step2: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class RecommendPickSchema(BaseModel):
|
||||
agent_id: str
|
||||
reason: str = ""
|
||||
|
||||
|
||||
class RecommendCandidateSchema(BaseModel):
|
||||
agent_id: str
|
||||
name: str = ""
|
||||
description: str = ""
|
||||
|
||||
|
||||
class RecommendHistoryResponse(BaseModel):
|
||||
id: str
|
||||
draft_id: str
|
||||
objective: str = ""
|
||||
status: str = "done"
|
||||
model: str | None = None
|
||||
rationale: str | None = None
|
||||
picks: list[RecommendPickSchema] = []
|
||||
candidates: list[RecommendCandidateSchema] = []
|
||||
created_at: datetime | str | None = None
|
||||
|
||||
|
||||
class RecommendHistoryListResponse(BaseModel):
|
||||
history: list[RecommendHistoryResponse]
|
||||
|
||||
|
||||
class RecommendHistoryCreateRequest(BaseModel):
|
||||
objective: str = Field(default="", max_length=512)
|
||||
status: str = Field(default="done", max_length=32)
|
||||
model: str | None = None
|
||||
rationale: str | None = None
|
||||
picks: list[RecommendPickSchema] = []
|
||||
candidates: list[RecommendCandidateSchema] = []
|
||||
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _current_user_id(request: Request) -> str:
|
||||
user = getattr(request.state, "user", None)
|
||||
if user is not None:
|
||||
return str(user.id)
|
||||
return get_effective_user_id()
|
||||
|
||||
|
||||
def _get_store(request: Request):
|
||||
store = getattr(request.app.state, "roundtable_draft_store", None)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="Roundtable draft store not available")
|
||||
return store
|
||||
|
||||
|
||||
# ── draft routes ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("", response_model=DraftListResponse)
|
||||
async def list_drafts(request: Request) -> DraftListResponse:
|
||||
store = _get_store(request)
|
||||
user_id = _current_user_id(request)
|
||||
rows = await store.list_drafts(user_id)
|
||||
return DraftListResponse(drafts=[DraftMetaResponse(**r) for r in rows])
|
||||
|
||||
|
||||
@router.post("", response_model=DraftResponse, status_code=201)
|
||||
async def create_draft(request: Request, body: DraftCreateRequest) -> DraftResponse:
|
||||
store = _get_store(request)
|
||||
user_id = _current_user_id(request)
|
||||
row = await store.create_draft(
|
||||
user_id,
|
||||
{
|
||||
"id": (body.id or uuid4().hex),
|
||||
"title": body.title.strip(),
|
||||
"furthest_step": body.furthest_step,
|
||||
"step1": body.step1,
|
||||
"step2": body.step2,
|
||||
},
|
||||
)
|
||||
return DraftResponse(**row)
|
||||
|
||||
|
||||
@router.get("/{draft_id}", response_model=DraftResponse)
|
||||
async def get_draft(request: Request, draft_id: str) -> DraftResponse:
|
||||
store = _get_store(request)
|
||||
user_id = _current_user_id(request)
|
||||
row = await store.get_draft(draft_id, user_id)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Draft not found")
|
||||
return DraftResponse(**row)
|
||||
|
||||
|
||||
@router.put("/{draft_id}", response_model=DraftResponse)
|
||||
async def update_draft(request: Request, draft_id: str, body: DraftUpdateRequest) -> DraftResponse:
|
||||
store = _get_store(request)
|
||||
user_id = _current_user_id(request)
|
||||
# Build kwargs that distinguish "omitted" from "explicitly None". The store
|
||||
# uses a sentinel; here we pass only the fields the client actually sent.
|
||||
fields = body.model_dump(exclude_unset=True)
|
||||
row = await store.update_draft(draft_id, user_id, **fields)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Draft not found")
|
||||
return DraftResponse(**row)
|
||||
|
||||
|
||||
@router.delete("/{draft_id}", status_code=204)
|
||||
async def delete_draft(request: Request, draft_id: str) -> None:
|
||||
store = _get_store(request)
|
||||
user_id = _current_user_id(request)
|
||||
deleted = await store.delete_draft(draft_id, user_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Draft not found")
|
||||
|
||||
|
||||
# ── recommendation history routes ────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/{draft_id}/recommendations", response_model=RecommendHistoryListResponse)
|
||||
async def list_recommendations(request: Request, draft_id: str) -> RecommendHistoryListResponse:
|
||||
store = _get_store(request)
|
||||
user_id = _current_user_id(request)
|
||||
rows = await store.list_recommendations(draft_id, user_id)
|
||||
return RecommendHistoryListResponse(history=[RecommendHistoryResponse(**r) for r in rows])
|
||||
|
||||
|
||||
@router.post("/{draft_id}/recommendations", response_model=RecommendHistoryResponse, status_code=201)
|
||||
async def add_recommendation(
|
||||
request: Request, draft_id: str, body: RecommendHistoryCreateRequest
|
||||
) -> RecommendHistoryResponse:
|
||||
store = _get_store(request)
|
||||
user_id = _current_user_id(request)
|
||||
# Ownership: the draft must exist and belong to the caller.
|
||||
draft = await store.get_draft(draft_id, user_id)
|
||||
if draft is None:
|
||||
raise HTTPException(status_code=404, detail="Draft not found")
|
||||
row = await store.add_recommendation(
|
||||
user_id,
|
||||
draft_id,
|
||||
{
|
||||
"id": uuid4().hex,
|
||||
"objective": body.objective,
|
||||
"status": body.status,
|
||||
"model": body.model,
|
||||
"rationale": body.rationale,
|
||||
"picks": [p.model_dump() for p in body.picks],
|
||||
"candidates": [c.model_dump() for c in body.candidates],
|
||||
},
|
||||
)
|
||||
return RecommendHistoryResponse(**row)
|
||||
Reference in new issue
Block a user