deerflow-code/offline-backend-20260512/backend/app/gateway/routers/user_prompts.py
2026-09-07 18:24:55 +08:00

121 lines
4.2 KiB
Python

"""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")