"""CRUD API for a user's personal custom prompts (常用提示词). Prompts are bound to the requesting user (not stored in the browser), so they follow the account across devices/sessions. """ from __future__ import annotations import logging from datetime import datetime 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/user-prompts", tags=["user-prompts"]) class UserPromptResponse(BaseModel): id: str title: str = "" content: str sort_order: int = 0 # Optional single-agent association. NULL = global (shown for every agent). agent_id: str | None = None created_at: datetime | str | None = None updated_at: datetime | str | None = None class UserPromptsListResponse(BaseModel): prompts: list[UserPromptResponse] class UserPromptCreateRequest(BaseModel): title: str = Field(default="", max_length=255) content: str = Field(..., min_length=1) sort_order: int = Field(default=0) agent_id: str | None = Field(default=None, max_length=64) class UserPromptUpdateRequest(BaseModel): title: str | None = Field(default=None, max_length=255) content: str | None = Field(default=None, min_length=1) sort_order: int | None = None # Sent on every save; an empty string / null clears the association. Use a # sentinel so "field omitted" (don't touch) differs from "set to null". agent_id: str | None = Field(default=None, max_length=64) 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, "user_prompt_store", None) if store is None: raise HTTPException(status_code=503, detail="User prompt store not available") return store @router.get("", response_model=UserPromptsListResponse) async def list_user_prompts(request: Request) -> UserPromptsListResponse: store = _get_store(request) user_id = _current_user_id(request) rows = await store.list_for_user(user_id) return UserPromptsListResponse(prompts=[UserPromptResponse(**r) for r in rows]) @router.post("", response_model=UserPromptResponse, status_code=201) async def create_user_prompt(request: Request, body: UserPromptCreateRequest) -> UserPromptResponse: store = _get_store(request) user_id = _current_user_id(request) row = await store.create( user_id, { "id": uuid4().hex, "title": (body.title or "").strip(), "content": body.content, "sort_order": body.sort_order, "agent_id": (body.agent_id or "").strip() or None, }, ) return UserPromptResponse(**row) @router.put("/{prompt_id}", response_model=UserPromptResponse) async def update_user_prompt(request: Request, prompt_id: str, body: UserPromptUpdateRequest) -> UserPromptResponse: store = _get_store(request) user_id = _current_user_id(request) data: dict = {} if body.title is not None: data["title"] = body.title.strip() if body.content is not None: data["content"] = body.content if body.sort_order is not None: data["sort_order"] = body.sort_order # ``agent_id`` is nullable: only act when the caller actually sent the field # (``model_fields_set``), so an explicit null clears the association while an # omitted field leaves it untouched. if "agent_id" in body.model_fields_set: data["agent_id"] = (body.agent_id or "").strip() or None row = await store.update(prompt_id, user_id, data) if row is None: raise HTTPException(status_code=404, detail="Prompt not found") return UserPromptResponse(**row) @router.delete("/{prompt_id}", status_code=204) async def delete_user_prompt(request: Request, prompt_id: str) -> None: store = _get_store(request) user_id = _current_user_id(request) deleted = await store.delete(prompt_id, user_id) if not deleted: raise HTTPException(status_code=404, detail="Prompt not found")