From 2f2ba44c7c1eb1912e9de180c122e607a82448a6 Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Thu, 14 May 2026 11:07:28 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=8E=A8=E8=8D=90=E9=97=AE?= =?UTF-8?q?=E9=A2=98=E4=BF=AE=E6=94=B9=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/workspace/input-box.tsx | 3 +- .../src/core/recommended-questions/api.ts | 66 ++++++++++ .../src/core/recommended-questions/hooks.ts | 85 +++++++------ .../src/core/recommended-questions/index.ts | 59 ++------- .../src/pages/RecommendedQuestionsPage.tsx | 113 +++++++++++++----- .../backend/app/gateway/app.py | 4 + .../backend/app/gateway/deps.py | 4 + .../backend/app/gateway/routers/__init__.py | 4 +- .../gateway/routers/recommended_questions.py | 101 ++++++++++++++++ .../20260514_01_recommended_questions.py | 44 +++++++ .../recommended_questions/__init__.py | 18 +++ .../recommended_questions/model.py | 21 ++++ .../persistence/recommended_questions/sql.py | 85 +++++++++++++ 13 files changed, 481 insertions(+), 126 deletions(-) create mode 100644 frontend-web/src/core/recommended-questions/api.ts create mode 100644 offline-backend-20260512/backend/app/gateway/routers/recommended_questions.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260514_01_recommended_questions.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/recommended_questions/__init__.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/recommended_questions/model.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/recommended_questions/sql.py diff --git a/frontend-web/src/components/workspace/input-box.tsx b/frontend-web/src/components/workspace/input-box.tsx index 4bc67415..8ae851ac 100644 --- a/frontend-web/src/components/workspace/input-box.tsx +++ b/frontend-web/src/components/workspace/input-box.tsx @@ -82,6 +82,7 @@ import { import { useRecommendedQuestions } from "@/core/recommended-questions/hooks"; + import { useThread } from "./messages/context"; import { ModeHoverGuide } from "./mode-hover-guide"; import { Tooltip } from "./tooltip"; @@ -164,7 +165,7 @@ export function InputBox({ null, ); - const [recommendedQuestions] = useRecommendedQuestions(); + const { questions: recommendedQuestions } = useRecommendedQuestions(); useEffect(() => { if (models.length === 0) { diff --git a/frontend-web/src/core/recommended-questions/api.ts b/frontend-web/src/core/recommended-questions/api.ts new file mode 100644 index 00000000..0d72dd89 --- /dev/null +++ b/frontend-web/src/core/recommended-questions/api.ts @@ -0,0 +1,66 @@ +import { apiFetch } from "@/core/api"; +import { getBackendBaseURL } from "@/core/config"; + +import type { + CreateQuestionRequest, + RecommendedQuestion, + UpdateQuestionRequest, +} from "./index"; + +const BASE = () => `${getBackendBaseURL()}/api/recommended-questions`; + +export async function listRecommendedQuestions(): Promise { + const res = await apiFetch(BASE()); + if (!res.ok) throw new Error(`Failed to load questions: ${res.statusText}`); + const data = (await res.json()) as { questions: RecommendedQuestion[] }; + return data.questions; +} + +export async function createRecommendedQuestion( + body: CreateQuestionRequest, +): Promise { + const res = await apiFetch(BASE(), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const err = (await res.json().catch(() => ({}))) as { detail?: string }; + throw new Error(err.detail ?? `Failed to create question: ${res.statusText}`); + } + return res.json() as Promise; +} + +export async function updateRecommendedQuestion( + id: string, + body: UpdateQuestionRequest, +): Promise { + const res = await apiFetch(`${BASE()}/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const err = (await res.json().catch(() => ({}))) as { detail?: string }; + throw new Error(err.detail ?? `Failed to update question: ${res.statusText}`); + } + return res.json() as Promise; +} + +export async function deleteRecommendedQuestion(id: string): Promise { + const res = await apiFetch(`${BASE()}/${id}`, { method: "DELETE" }); + if (!res.ok) throw new Error(`Failed to delete question: ${res.statusText}`); +} + +export async function reorderRecommendedQuestions( + ids: string[], +): Promise { + const res = await apiFetch(`${getBackendBaseURL()}/api/recommended-questions-reorder`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ids }), + }); + if (!res.ok) throw new Error(`Failed to reorder questions: ${res.statusText}`); + const data = (await res.json()) as { questions: RecommendedQuestion[] }; + return data.questions; +} diff --git a/frontend-web/src/core/recommended-questions/hooks.ts b/frontend-web/src/core/recommended-questions/hooks.ts index f06be605..538f4a33 100644 --- a/frontend-web/src/core/recommended-questions/hooks.ts +++ b/frontend-web/src/core/recommended-questions/hooks.ts @@ -1,58 +1,53 @@ -import { useCallback, useSyncExternalStore } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { - DEFAULT_QUESTIONS, - getRecommendedQuestions, - saveRecommendedQuestions, - type RecommendedQuestion, -} from "./index"; + createRecommendedQuestion, + deleteRecommendedQuestion, + listRecommendedQuestions, + reorderRecommendedQuestions, + updateRecommendedQuestion, +} from "./api"; +import type { CreateQuestionRequest, UpdateQuestionRequest } from "./index"; -type Listener = () => void; +const QK = ["recommended-questions"] as const; -const listeners = new Set(); -let cached: RecommendedQuestion[] | null = null; - -function getSnapshot(): RecommendedQuestion[] { - if (cached === null) { - cached = getRecommendedQuestions(); - } - return cached; +export function useRecommendedQuestions() { + const { data, isLoading, error } = useQuery({ + queryKey: QK, + queryFn: listRecommendedQuestions, + }); + return { questions: data ?? [], isLoading, error }; } -function getServerSnapshot(): RecommendedQuestion[] { - return DEFAULT_QUESTIONS; +export function useCreateRecommendedQuestion() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (body: CreateQuestionRequest) => createRecommendedQuestion(body), + onSuccess: () => void qc.invalidateQueries({ queryKey: QK }), + }); } -function subscribe(listener: Listener): () => void { - listeners.add(listener); - return () => listeners.delete(listener); +export function useUpdateRecommendedQuestion() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, body }: { id: string; body: UpdateQuestionRequest }) => + updateRecommendedQuestion(id, body), + onSuccess: () => void qc.invalidateQueries({ queryKey: QK }), + }); } -function emitChange() { - for (const l of listeners) l(); +export function useDeleteRecommendedQuestion() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => deleteRecommendedQuestion(id), + onSuccess: () => void qc.invalidateQueries({ queryKey: QK }), + }); } -export function updateRecommendedQuestions( - questions: RecommendedQuestion[], -): void { - cached = questions; - saveRecommendedQuestions(questions); - emitChange(); -} - -export function useRecommendedQuestions(): [ - RecommendedQuestion[], - (questions: RecommendedQuestion[]) => void, -] { - const questions = useSyncExternalStore( - subscribe, - getSnapshot, - getServerSnapshot, - ); - - const save = useCallback((qs: RecommendedQuestion[]) => { - updateRecommendedQuestions(qs); - }, []); - - return [questions, save]; +export function useReorderRecommendedQuestions() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (ids: string[]) => reorderRecommendedQuestions(ids), + onSuccess: () => void qc.invalidateQueries({ queryKey: QK }), + }); } diff --git a/frontend-web/src/core/recommended-questions/index.ts b/frontend-web/src/core/recommended-questions/index.ts index 466596fa..a7a5b286 100644 --- a/frontend-web/src/core/recommended-questions/index.ts +++ b/frontend-web/src/core/recommended-questions/index.ts @@ -2,56 +2,19 @@ export interface RecommendedQuestion { id: string; title: string; content: string; + sort_order: number; + created_at?: string; + updated_at?: string; } -const STORAGE_KEY = "deerflow.recommended-questions"; - -export const DEFAULT_QUESTIONS: RecommendedQuestion[] = [ - { - id: "default-1", - title: "市场分析报告", - content: - "请帮我写一份关于当前 AI 市场的详细分析报告,包括市场规模、主要玩家和发展趋势。", - }, - { - id: "default-2", - title: "制定学习计划", - content: - "请帮我制定一个为期 3 个月的 Python 编程学习计划,从基础到进阶,每周安排具体任务。", - }, - { - id: "default-3", - title: "代码审查建议", - content: - "请帮我审查代码,找出潜在的问题、性能瓶颈以及改进建议,并给出修改示例。", - }, - { - id: "default-4", - title: "数据分析方案", - content: - "请帮我设计一个用于分析用户行为数据的方案,包括数据收集、处理和可视化策略。", - }, - { - id: "default-5", - title: "文章润色优化", - content: "请帮我润色以下文章,使其更加流畅、专业,并保持原意不变。", - }, -]; - -export function getRecommendedQuestions(): RecommendedQuestion[] { - if (typeof window === "undefined") return DEFAULT_QUESTIONS; - const json = localStorage.getItem(STORAGE_KEY); - if (!json) return DEFAULT_QUESTIONS; - try { - const parsed = JSON.parse(json) as RecommendedQuestion[]; - if (Array.isArray(parsed) && parsed.length > 0) return parsed; - } catch {} - return DEFAULT_QUESTIONS; +export interface CreateQuestionRequest { + title: string; + content: string; + sort_order?: number; } -export function saveRecommendedQuestions( - questions: RecommendedQuestion[], -): void { - if (typeof window === "undefined") return; - localStorage.setItem(STORAGE_KEY, JSON.stringify(questions)); +export interface UpdateQuestionRequest { + title?: string; + content?: string; + sort_order?: number; } diff --git a/frontend-web/src/pages/RecommendedQuestionsPage.tsx b/frontend-web/src/pages/RecommendedQuestionsPage.tsx index 5484161e..f3d33950 100644 --- a/frontend-web/src/pages/RecommendedQuestionsPage.tsx +++ b/frontend-web/src/pages/RecommendedQuestionsPage.tsx @@ -1,7 +1,13 @@ "use client"; import { useState } from "react"; -import { GripVerticalIcon, PencilIcon, PlusIcon, Trash2Icon } from "lucide-react"; +import { + GripVerticalIcon, + Loader2Icon, + PencilIcon, + PlusIcon, + Trash2Icon, +} from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -14,7 +20,13 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; -import { useRecommendedQuestions } from "@/core/recommended-questions/hooks"; +import { + useCreateRecommendedQuestion, + useDeleteRecommendedQuestion, + useRecommendedQuestions, + useReorderRecommendedQuestions, + useUpdateRecommendedQuestion, +} from "@/core/recommended-questions/hooks"; import type { RecommendedQuestion } from "@/core/recommended-questions"; type DialogState = @@ -22,12 +34,13 @@ type DialogState = | { mode: "edit"; question: RecommendedQuestion } | null; -function generateId() { - return `q-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; -} - export default function RecommendedQuestionsPage() { - const [questions, setQuestions] = useRecommendedQuestions(); + const { questions, isLoading, error } = useRecommendedQuestions(); + const createMutation = useCreateRecommendedQuestion(); + const updateMutation = useUpdateRecommendedQuestion(); + const deleteMutation = useDeleteRecommendedQuestion(); + const reorderMutation = useReorderRecommendedQuestions(); + const [dialog, setDialog] = useState(null); const [formTitle, setFormTitle] = useState(""); const [formContent, setFormContent] = useState(""); @@ -53,7 +66,7 @@ export default function RecommendedQuestionsPage() { setDialog(null); } - function handleSave() { + async function handleSave() { const title = formTitle.trim(); const content = formContent.trim(); if (!title || !content) { @@ -61,24 +74,35 @@ export default function RecommendedQuestionsPage() { return; } - if (dialog?.mode === "add") { - setQuestions([...questions, { id: generateId(), title, content }]); - toast.success("问题已添加"); - } else if (dialog?.mode === "edit") { - setQuestions( - questions.map((q) => - q.id === dialog.question.id ? { ...q, title, content } : q, - ), - ); - toast.success("问题已更新"); + try { + if (dialog?.mode === "add") { + await createMutation.mutateAsync({ + title, + content, + sort_order: questions.length, + }); + toast.success("问题已添加"); + } else if (dialog?.mode === "edit") { + await updateMutation.mutateAsync({ + id: dialog.question.id, + body: { title, content }, + }); + toast.success("问题已更新"); + } + closeDialog(); + } catch (e) { + toast.error(e instanceof Error ? e.message : "操作失败"); } - closeDialog(); } - function handleDelete(q: RecommendedQuestion) { - setQuestions(questions.filter((item) => item.id !== q.id)); - setDeleteTarget(null); - toast.success("问题已删除"); + async function handleDelete(q: RecommendedQuestion) { + try { + await deleteMutation.mutateAsync(q.id); + setDeleteTarget(null); + toast.success("问题已删除"); + } catch (e) { + toast.error(e instanceof Error ? e.message : "删除失败"); + } } function handleDragStart(index: number) { @@ -90,7 +114,7 @@ export default function RecommendedQuestionsPage() { setDragOverIndex(index); } - function handleDrop(toIndex: number) { + async function handleDrop(toIndex: number) { if (dragIndex === null || dragIndex === toIndex) { setDragIndex(null); setDragOverIndex(null); @@ -99,11 +123,21 @@ export default function RecommendedQuestionsPage() { const next = [...questions]; const [moved] = next.splice(dragIndex, 1); next.splice(toIndex, 0, moved!); - setQuestions(next); setDragIndex(null); setDragOverIndex(null); + try { + await reorderMutation.mutateAsync(next.map((q) => q.id)); + } catch { + toast.error("排序保存失败"); + } } + const isSaving = + createMutation.isPending || + updateMutation.isPending || + deleteMutation.isPending || + reorderMutation.isPending; + return (
@@ -113,13 +147,21 @@ export default function RecommendedQuestionsPage() { 管理首页展示的推荐问题,点击后直接发送到对话。拖拽可调整顺序。

-
- {questions.length === 0 ? ( + {isLoading ? ( +
+ +
+ ) : error ? ( +
+

加载失败:{error.message}

+
+ ) : questions.length === 0 ? (

暂无推荐问题

@@ -169,6 +212,7 @@ export default function RecommendedQuestionsPage() { size="icon" className="text-destructive hover:text-destructive size-7" onClick={() => setDeleteTarget(q)} + disabled={isSaving} > @@ -206,10 +250,11 @@ export default function RecommendedQuestionsPage() {
- - @@ -229,13 +274,21 @@ export default function RecommendedQuestionsPage() { 确定要删除「{deleteTarget?.title}」吗?此操作不可撤销。

- diff --git a/offline-backend-20260512/backend/app/gateway/app.py b/offline-backend-20260512/backend/app/gateway/app.py index 0b747e7f..fcd09a3e 100644 --- a/offline-backend-20260512/backend/app/gateway/app.py +++ b/offline-backend-20260512/backend/app/gateway/app.py @@ -30,6 +30,7 @@ from app.gateway.routers import ( mcp, memory, models, + recommended_questions, runs, scheduled_tasks, skills, @@ -485,6 +486,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an # Agents API is mounted at /api/agents app.include_router(agents.router) + # Recommended Questions API is mounted at /api/recommended-questions + app.include_router(recommended_questions.router) + # Suggestions API is mounted at /api/threads/{thread_id}/suggestions app.include_router(suggestions.router) diff --git a/offline-backend-20260512/backend/app/gateway/deps.py b/offline-backend-20260512/backend/app/gateway/deps.py index e163c425..4590fb48 100644 --- a/offline-backend-20260512/backend/app/gateway/deps.py +++ b/offline-backend-20260512/backend/app/gateway/deps.py @@ -94,6 +94,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]: app.state.agent_store = make_agent_store(sf) + from deerflow.persistence.recommended_questions import make_recommended_question_store + + app.state.recommended_question_store = make_recommended_question_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) diff --git a/offline-backend-20260512/backend/app/gateway/routers/__init__.py b/offline-backend-20260512/backend/app/gateway/routers/__init__.py index f4e28800..4223efc2 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/__init__.py +++ b/offline-backend-20260512/backend/app/gateway/routers/__init__.py @@ -1,3 +1,3 @@ -from . import artifacts, assistants_compat, mcp, models, skills, suggestions, thread_runs, threads, uploads +from . import artifacts, assistants_compat, mcp, models, recommended_questions, skills, suggestions, thread_runs, threads, uploads -__all__ = ["artifacts", "assistants_compat", "mcp", "models", "skills", "suggestions", "threads", "thread_runs", "uploads"] +__all__ = ["artifacts", "assistants_compat", "mcp", "models", "recommended_questions", "skills", "suggestions", "threads", "thread_runs", "uploads"] diff --git a/offline-backend-20260512/backend/app/gateway/routers/recommended_questions.py b/offline-backend-20260512/backend/app/gateway/routers/recommended_questions.py new file mode 100644 index 00000000..06ea6c60 --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/routers/recommended_questions.py @@ -0,0 +1,101 @@ +"""CRUD API for recommended questions.""" + +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 + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api", tags=["recommended-questions"]) + + +class RecommendedQuestionResponse(BaseModel): + id: str + title: str + content: str + sort_order: int = 0 + created_at: datetime | str | None = None + updated_at: datetime | str | None = None + + +class RecommendedQuestionsListResponse(BaseModel): + questions: list[RecommendedQuestionResponse] + + +class RecommendedQuestionCreateRequest(BaseModel): + title: str = Field(..., min_length=1, max_length=256) + content: str = Field(..., min_length=1) + sort_order: int = Field(default=0) + + +class RecommendedQuestionUpdateRequest(BaseModel): + title: str | None = Field(default=None, max_length=256) + content: str | None = None + sort_order: int | None = None + + +class ReorderRequest(BaseModel): + ids: list[str] = Field(..., description="Question IDs in the desired order") + + +def _get_store(request: Request): + store = getattr(request.app.state, "recommended_question_store", None) + if store is None: + raise HTTPException(status_code=503, detail="Recommended question store not available") + return store + + +@router.get("/recommended-questions", response_model=RecommendedQuestionsListResponse) +async def list_recommended_questions(request: Request) -> RecommendedQuestionsListResponse: + store = _get_store(request) + rows = await store.list_all() + return RecommendedQuestionsListResponse(questions=[RecommendedQuestionResponse(**r) for r in rows]) + + +@router.post("/recommended-questions", response_model=RecommendedQuestionResponse, status_code=201) +async def create_recommended_question(request: Request, body: RecommendedQuestionCreateRequest) -> RecommendedQuestionResponse: + store = _get_store(request) + data = { + "id": str(uuid4()), + "title": body.title.strip(), + "content": body.content.strip(), + "sort_order": body.sort_order, + } + row = await store.create(data) + return RecommendedQuestionResponse(**row) + + +@router.put("/recommended-questions/{question_id}", response_model=RecommendedQuestionResponse) +async def update_recommended_question(request: Request, question_id: str, body: RecommendedQuestionUpdateRequest) -> RecommendedQuestionResponse: + store = _get_store(request) + data: dict = {} + if body.title is not None: + data["title"] = body.title.strip() + if body.content is not None: + data["content"] = body.content.strip() + if body.sort_order is not None: + data["sort_order"] = body.sort_order + + row = await store.update(question_id, data) + if row is None: + raise HTTPException(status_code=404, detail="Question not found") + return RecommendedQuestionResponse(**row) + + +@router.delete("/recommended-questions/{question_id}", status_code=204) +async def delete_recommended_question(request: Request, question_id: str) -> None: + store = _get_store(request) + deleted = await store.delete(question_id) + if not deleted: + raise HTTPException(status_code=404, detail="Question not found") + + +@router.put("/recommended-questions-reorder", response_model=RecommendedQuestionsListResponse) +async def reorder_recommended_questions(request: Request, body: ReorderRequest) -> RecommendedQuestionsListResponse: + store = _get_store(request) + rows = await store.reorder(body.ids) + return RecommendedQuestionsListResponse(questions=[RecommendedQuestionResponse(**r) for r in rows]) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260514_01_recommended_questions.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260514_01_recommended_questions.py new file mode 100644 index 00000000..5dbde3e7 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260514_01_recommended_questions.py @@ -0,0 +1,44 @@ +"""Create recommended_questions table. + +Revision ID: 20260514_01 +Revises: 20260513_02 +Create Date: 2026-05-14 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import inspect + + +revision = "20260514_01" +down_revision = "20260513_02" +branch_labels = None +depends_on = None + + +def _has_table(table_name: str) -> bool: + inspector = inspect(op.get_bind()) + return table_name in inspector.get_table_names() + + +def upgrade() -> None: + if _has_table("recommended_questions"): + return + + op.create_table( + "recommended_questions", + sa.Column("id", sa.String(64), primary_key=True), + sa.Column("title", sa.String(256), nullable=False), + sa.Column("content", sa.Text(), nullable=False), + sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_index("ix_recommended_questions_sort_order", "recommended_questions", ["sort_order"]) + + +def downgrade() -> None: + if _has_table("recommended_questions"): + op.drop_table("recommended_questions") diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/recommended_questions/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/recommended_questions/__init__.py new file mode 100644 index 00000000..e074d007 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/recommended_questions/__init__.py @@ -0,0 +1,18 @@ +"""Recommended questions persistence.""" + +from __future__ import annotations + +from deerflow.persistence.recommended_questions.model import RecommendedQuestionRow +from deerflow.persistence.recommended_questions.sql import RecommendedQuestionRepository + +__all__ = [ + "RecommendedQuestionRepository", + "RecommendedQuestionRow", + "make_recommended_question_store", +] + + +def make_recommended_question_store(session_factory): + if session_factory is None: + return None + return RecommendedQuestionRepository(session_factory) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/recommended_questions/model.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/recommended_questions/model.py new file mode 100644 index 00000000..74f46345 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/recommended_questions/model.py @@ -0,0 +1,21 @@ +"""ORM model for recommended questions.""" + +from __future__ import annotations + +from datetime import UTC, datetime + +from sqlalchemy import DateTime, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from deerflow.persistence.base import Base + + +class RecommendedQuestionRow(Base): + __tablename__ = "recommended_questions" + + id: Mapped[str] = mapped_column(String(64), primary_key=True) + title: Mapped[str] = mapped_column(String(256), nullable=False) + content: Mapped[str] = mapped_column(Text, nullable=False) + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/recommended_questions/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/recommended_questions/sql.py new file mode 100644 index 00000000..88c20497 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/recommended_questions/sql.py @@ -0,0 +1,85 @@ +"""SQLAlchemy-backed recommended questions repository.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from deerflow.persistence.recommended_questions.model import RecommendedQuestionRow + + +def _row_to_dict(row: RecommendedQuestionRow) -> dict[str, Any]: + data = row.to_dict() + for key in ("created_at", "updated_at"): + value = data.get(key) + if isinstance(value, datetime): + data[key] = value.isoformat() + return data + + +class RecommendedQuestionRepository: + def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None: + self._sf = session_factory + + async def list_all(self) -> list[dict[str, Any]]: + stmt = select(RecommendedQuestionRow).order_by(RecommendedQuestionRow.sort_order.asc(), RecommendedQuestionRow.created_at.asc()) + async with self._sf() as session: + result = await session.execute(stmt) + return [_row_to_dict(row) for row in result.scalars()] + + async def get(self, question_id: str) -> dict[str, Any] | None: + async with self._sf() as session: + row = await session.get(RecommendedQuestionRow, question_id) + return _row_to_dict(row) if row is not None else None + + async def create(self, data: dict[str, Any]) -> dict[str, Any]: + now = datetime.now(UTC) + row = RecommendedQuestionRow( + id=data["id"], + title=data["title"], + content=data["content"], + sort_order=data.get("sort_order", 0), + created_at=now, + updated_at=now, + ) + async with self._sf() as session: + session.add(row) + await session.commit() + await session.refresh(row) + return _row_to_dict(row) + + async def update(self, question_id: str, data: dict[str, Any]) -> dict[str, Any] | None: + async with self._sf() as session: + row = await session.get(RecommendedQuestionRow, question_id) + if row is None: + return None + for key in ("title", "content", "sort_order"): + if key in data: + setattr(row, key, data[key]) + row.updated_at = datetime.now(UTC) + await session.commit() + await session.refresh(row) + return _row_to_dict(row) + + async def delete(self, question_id: str) -> bool: + async with self._sf() as session: + row = await session.get(RecommendedQuestionRow, question_id) + if row is None: + return False + await session.delete(row) + await session.commit() + return True + + async def reorder(self, ordered_ids: list[str]) -> list[dict[str, Any]]: + async with self._sf() as session: + now = datetime.now(UTC) + for index, question_id in enumerate(ordered_ids): + row = await session.get(RecommendedQuestionRow, question_id) + if row is not None: + row.sort_order = index + row.updated_at = now + await session.commit() + return await self.list_all()