From f1d58983c2d6e142cb0c88449d07e0f67d39d560 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Thu, 21 May 2026 14:39:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=99=BA=E8=83=BD=E4=BD=93=E7=B2=BE?= =?UTF-8?q?=E9=80=89=E6=8E=92=E5=BA=8F=E4=B8=8E=E6=96=B0=E5=AF=B9=E8=AF=9D?= =?UTF-8?q?=E6=99=BA=E8=83=BD=E4=BD=93=E9=80=89=E6=8B=A9=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端新增 featured_order 字段及精选排序 API(/agents/selector、/agents/{id}/featured、/agents/featured/order),移除 user_agent_pin 表 - 前端新对话页 (ChatPage) 与输入框 (InputBox) 接入 agent-selector,选中后跳转 AgentChatPage 并通过 sessionStorage 透传首条消息 - 新增管理员智能体管理弹窗 (agent-manage-dialog) 支持精选添加/移除/上下移动 --- .gitignore | 3 +- .../workspace/agents/agent-card.tsx | 60 ++++- .../workspace/agents/agent-manage-dialog.tsx | 240 ++++++++++++++++++ .../workspace/agents/agent-selector.tsx | 173 +++++++++++++ .../src/components/workspace/input-box.tsx | 19 ++ frontend-web/src/core/agents/api.ts | 51 +++- frontend-web/src/core/agents/hooks.ts | 40 +++ frontend-web/src/core/agents/types.ts | 5 + frontend-web/src/pages/AgentChatPage.tsx | 47 +++- frontend-web/src/pages/ChatPage.tsx | 40 +++ offline-backend-20260512/backend/CLAUDE.md | 4 +- .../backend/app/gateway/routers/agents.py | 185 +++++++++++++- .../harness/deerflow/config/agents_config.py | 27 +- .../deerflow/persistence/agents/base.py | 20 ++ .../deerflow/persistence/agents/memory.py | 21 ++ .../deerflow/persistence/agents/model.py | 8 +- .../deerflow/persistence/agents/sql.py | 32 +++ .../harness/deerflow/persistence/engine.py | 215 ++++++++++------ .../20260520_01_agent_pins_and_featured.py | 83 ++++++ .../20260520_02_featured_order_bigint.py | 66 +++++ .../20260520_03_drop_user_agent_pin.py | 61 +++++ 21 files changed, 1300 insertions(+), 100 deletions(-) create mode 100644 frontend-web/src/components/workspace/agents/agent-manage-dialog.tsx create mode 100644 frontend-web/src/components/workspace/agents/agent-selector.tsx create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260520_01_agent_pins_and_featured.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260520_02_featured_order_bigint.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260520_03_drop_user_agent_pin.py diff --git a/.gitignore b/.gitignore index 1b033763..6771619f 100644 --- a/.gitignore +++ b/.gitignore @@ -75,4 +75,5 @@ offline-backend-20260512/backend/skill_evolution.json skills/ hermes-agent-main/ .gitignore -offline-backend-20260512/backend/tests/* \ No newline at end of file +offline-backend-20260512/backend/tests/* +deploy \ No newline at end of file diff --git a/frontend-web/src/components/workspace/agents/agent-card.tsx b/frontend-web/src/components/workspace/agents/agent-card.tsx index 59dc4c06..af123c30 100644 --- a/frontend-web/src/components/workspace/agents/agent-card.tsx +++ b/frontend-web/src/components/workspace/agents/agent-card.tsx @@ -6,6 +6,7 @@ import { InfoIcon, LockIcon, MessageSquareIcon, + SparklesIcon, SquarePenIcon, TagsIcon, Trash2Icon, @@ -38,11 +39,16 @@ import { Textarea } from "@/components/ui/textarea"; import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog"; import { TagChipList } from "@/components/workspace/tags/tag-chip"; import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog"; -import { useDeleteAgent, useUpdateAgent } from "@/core/agents"; +import { + useDeleteAgent, + useSetAgentFeatured, + useUpdateAgent, +} from "@/core/agents"; import type { Agent } from "@/core/agents"; import { getStoredAuth } from "@/core/auth"; import { useI18n } from "@/core/i18n/hooks"; import { useSkills } from "@/core/skills/hooks"; +import { cn } from "@/lib/utils"; interface AgentCardProps { agent: Agent; @@ -53,6 +59,7 @@ export function AgentCard({ agent }: AgentCardProps) { const router = useRouter(); const deleteAgent = useDeleteAgent(); const updateAgent = useUpdateAgent(); + const setFeatured = useSetAgentFeatured(); const { skills, isLoading: isSkillsLoading } = useSkills(); const [deleteOpen, setDeleteOpen] = useState(false); const [editOpen, setEditOpen] = useState(false); @@ -147,6 +154,26 @@ export function AgentCard({ agent }: AgentCardProps) { } } + async function handleToggleFeatured() { + // One-click on/off — ordering is handled by the dedicated 管理 Agent 列表 + // dialog. Featured agents get appended to the end on add (Date.now() is a + // safe seed; the manage dialog can rewrite to 0..N-1 later). + const turningOn = agent.featured_order == null; + if (turningOn && !agent.published) { + toast.error("只有已发布的智能体可以加入首页选择器"); + return; + } + try { + await setFeatured.mutateAsync({ + id: agent.id, + order: turningOn ? Date.now() : null, + }); + toast.success(turningOn ? "已加入首页选择器" : "已从首页选择器移除"); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } + } + function toggleSkill(skillName: string) { setSelectedSkills((prev) => { if (prev.includes(skillName)) { @@ -211,6 +238,16 @@ export function AgentCard({ agent }: AgentCardProps) { 已发布 )} + {agent.featured_order != null && ( + + + 首页推荐 + + )} {agent.description && ( @@ -263,6 +300,26 @@ export function AgentCard({ agent }: AgentCardProps) { {(canEdit || canDelete || isOwned || isAdmin) && (
+ {isAdmin && ( + + )} {isAdmin && ( + + + + + + + +
+ + ))} + + )} + + + + + + + + + 添加 Agent + + + + + {candidates.length === 0 + ? "没有更多已发布的 agent 可添加。" + : "没有找到匹配的 agent。"} + + {candidates.length > 0 && ( + + {candidates.map((agent) => ( + void add(agent)} + > + +
+ {agent.name} + {agent.description && ( + + {agent.description} + + )} +
+
+ ))} +
+ )} +
+
+
+
+ + ); +} diff --git a/frontend-web/src/components/workspace/agents/agent-selector.tsx b/frontend-web/src/components/workspace/agents/agent-selector.tsx new file mode 100644 index 00000000..1a196922 --- /dev/null +++ b/frontend-web/src/components/workspace/agents/agent-selector.tsx @@ -0,0 +1,173 @@ +"use client"; + +import { BotIcon, SettingsIcon, XIcon } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Dialog, + DialogContent, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { PromptInputButton } from "@/components/ai-elements/prompt-input"; +import { AgentManageDialog } from "@/components/workspace/agents/agent-manage-dialog"; +import { useAgentSelector } from "@/core/agents"; +import type { Agent } from "@/core/agents"; +import { getStoredAuth } from "@/core/auth"; +import { cn } from "@/lib/utils"; + +interface AgentSelectorProps { + selectedAgentId: string | null; + onAgentChange: (agentId: string | null) => void; + disabled?: boolean; +} + +/** + * Homepage agent picker — shows the admin-curated selector list. + * + * - Non-admins see a read-only dropdown. + * - Admins also see a "管理 Agent 列表" entry that opens the management dialog. + * + * Selecting an agent flips this chip to a coloured pill with a ✕ to clear. + * The actual handoff to ``AgentChatPage`` happens in ``ChatPage`` on submit. + */ +export function AgentSelector({ + selectedAgentId, + onAgentChange, + disabled, +}: AgentSelectorProps) { + const [open, setOpen] = useState(false); + const [manageOpen, setManageOpen] = useState(false); + const { agents, isLoading } = useAgentSelector(); + const isAdmin = useMemo(() => getStoredAuth()?.system_role === "admin", []); + + const selectedAgent = useMemo(() => { + if (!selectedAgentId) return null; + return agents.find((a) => a.id === selectedAgentId) ?? null; + }, [agents, selectedAgentId]); + + const handleSelect = (agent: Agent) => { + onAgentChange(agent.id); + setOpen(false); + }; + + const handleClear = (e: React.MouseEvent) => { + e.stopPropagation(); + onAgentChange(null); + }; + + const triggerLabel = selectedAgent?.name ?? "选择 Agent"; + + return ( + <> + + + + + + {triggerLabel} + + {selectedAgent && ( + { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onAgentChange(null); + } + }} + > + + + )} + + + + 选择 Agent + + + + + {isLoading ? "加载中…" : "没有找到匹配的 agent"} + + {agents.length > 0 && ( + + {agents.map((agent) => ( + handleSelect(agent)} + className={cn( + "flex items-center gap-2", + agent.id === selectedAgentId && "bg-accent", + )} + > + +
+ {agent.name} + {agent.description && ( + + {agent.description} + + )} +
+
+ ))} +
+ )} + {!isLoading && agents.length === 0 && ( +
+ {isAdmin + ? "还没有配置任何 agent。点击下方 “管理” 添加几个。" + : "管理员还没有配置可选的 agent。"} +
+ )} +
+ {isAdmin && ( +
+ +
+ )} +
+
+
+ {isAdmin && ( + + )} + + ); +} diff --git a/frontend-web/src/components/workspace/input-box.tsx b/frontend-web/src/components/workspace/input-box.tsx index ed83b077..3d76650e 100644 --- a/frontend-web/src/components/workspace/input-box.tsx +++ b/frontend-web/src/components/workspace/input-box.tsx @@ -64,6 +64,8 @@ import type { AgentThreadContext } from "@/core/threads"; import { textOfMessage } from "@/core/threads/utils"; import { cn } from "@/lib/utils"; +import { AgentSelector } from "@/components/workspace/agents/agent-selector"; + import { ModelSelector, ModelSelectorContent, @@ -119,6 +121,7 @@ export function InputBox({ onSubmit, onStop, promptPrefix, + agentSelector, ...props }: Omit, "onSubmit"> & { assistantId?: string | null; @@ -159,6 +162,15 @@ export function InputBox({ on: boolean; onToggle: () => void; }; + /** + * Optional agent picker shown alongside the model selector. Only the new-chat + * homepage (``ChatPage``) passes this; ``AgentChatPage`` omits it because the + * thread is already bound to one agent. + */ + agentSelector?: { + selectedAgentId: string | null; + onAgentChange: (agentId: string | null) => void; + }; }) { const { t } = useI18n(); const searchParams = useSearchParams(); @@ -867,6 +879,13 @@ export function InputBox({ )} + {agentSelector && ( + + )} {promptPrefix && ( { if (!res.ok) throw new Error(`Failed to delete agent: ${res.statusText}`); } +export async function getAgentSelector(): Promise { + const res = await apiFetch(`${getBackendBaseURL()}/api/agents/selector`); + if (!res.ok) throw new Error(`Failed to load agent selector: ${res.statusText}`); + return res.json() as Promise; +} + +export async function reorderFeaturedAgents(agentIds: string[]): Promise { + const res = await apiFetch( + `${getBackendBaseURL()}/api/agents/featured/order`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ agent_ids: agentIds }), + }, + ); + if (!res.ok) { + const err = (await res.json().catch(() => ({}))) as { detail?: string }; + throw new Error( + err.detail ?? `Failed to reorder featured agents: ${res.statusText}`, + ); + } +} + +export async function setAgentFeatured( + id: string, + order: number | null, +): Promise { + const res = await apiFetch( + `${getBackendBaseURL()}/api/agents/${id}/featured`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ order }), + }, + ); + if (!res.ok) { + const err = (await res.json().catch(() => ({}))) as { detail?: string }; + throw new Error( + err.detail ?? `Failed to set featured: ${res.statusText}`, + ); + } + return res.json() as Promise; +} + export async function checkAgentName( name: string, ): Promise<{ available: boolean; name: string }> { diff --git a/frontend-web/src/core/agents/hooks.ts b/frontend-web/src/core/agents/hooks.ts index 3f66a86b..e19f56ad 100644 --- a/frontend-web/src/core/agents/hooks.ts +++ b/frontend-web/src/core/agents/hooks.ts @@ -9,7 +9,10 @@ import { createAgent, deleteAgent, getAgent, + getAgentSelector, listAgents, + reorderFeaturedAgents, + setAgentFeatured, updateAgent, } from "./api"; import type { CreateAgentRequest, UpdateAgentRequest } from "./types"; @@ -70,3 +73,40 @@ export function useDeleteAgent() { }, }); } + +export function useAgentSelector() { + const { data, isLoading, error } = useQuery({ + queryKey: ["agents", "selector"], + queryFn: () => getAgentSelector(), + staleTime: 60_000, + }); + return { + agents: data?.agents ?? [], + isLoading, + error, + }; +} + +function invalidateAgentQueries(queryClient: ReturnType) { + void queryClient.invalidateQueries({ queryKey: ["agents"] }); +} + +export function useSetAgentFeatured() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, order }: { id: string; order: number | null }) => + setAgentFeatured(id, order), + onSuccess: (_data, { id }) => { + invalidateAgentQueries(queryClient); + void queryClient.invalidateQueries({ queryKey: ["agents", id] }); + }, + }); +} + +export function useReorderFeaturedAgents() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (agentIds: string[]) => reorderFeaturedAgents(agentIds), + onSuccess: () => invalidateAgentQueries(queryClient), + }); +} diff --git a/frontend-web/src/core/agents/types.ts b/frontend-web/src/core/agents/types.ts index 62cc75cd..14ea12cc 100644 --- a/frontend-web/src/core/agents/types.ts +++ b/frontend-web/src/core/agents/types.ts @@ -14,6 +14,11 @@ export interface Agent { tool_groups?: string[] | null; skills?: string[] | null; tags?: Tag[]; + featured_order?: number | null; +} + +export interface AgentSelectorResponse { + agents: Agent[]; } export interface CreateAgentRequest { diff --git a/frontend-web/src/pages/AgentChatPage.tsx b/frontend-web/src/pages/AgentChatPage.tsx index 9f4679de..cb6321a5 100644 --- a/frontend-web/src/pages/AgentChatPage.tsx +++ b/frontend-web/src/pages/AgentChatPage.tsx @@ -3,11 +3,10 @@ import { BotIcon, PlusSquare } from "lucide-react"; import { useParams, useRouter } from "next/navigation"; import { useRouteBase } from "@/core/route-base"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { PromptInputMessage } from "@/components/ai-elements/prompt-input"; import { Button } from "@/components/ui/button"; -import { AgentWelcome } from "@/components/workspace/agent-welcome"; import { ArtifactTrigger } from "@/components/workspace/artifacts"; import { ChatBox, useThreadChat } from "@/components/workspace/chats"; import { ExportTrigger } from "@/components/workspace/export-trigger"; @@ -56,6 +55,11 @@ export default function AgentChatPage() { } }, [isNewThread, threadId]); + // Auto-submit a message that the user typed on ChatPage right before + // selecting this agent. ChatPage stashed it in sessionStorage so we don't + // depend on router state (the next/navigation shim doesn't carry it). + const autoSubmitDoneRef = useRef(false); + const { showNotification } = useNotification(); const [thread, sendMessage] = useThreadStream({ threadId: isNewThread ? undefined : threadId, @@ -100,6 +104,29 @@ export default function AgentChatPage() { [sendMessage, threadId, agent_id], ); + useEffect(() => { + if (autoSubmitDoneRef.current) return; + if (!isNewThread || hasStartedConversation) return; + let raw: string | null = null; + try { + raw = sessionStorage.getItem("pendingAgentMessage"); + } catch { + return; + } + if (!raw) return; + try { + const parsed = JSON.parse(raw) as { agentId?: string; text?: string }; + if (parsed.agentId !== agent_id || !parsed.text) { + return; + } + autoSubmitDoneRef.current = true; + sessionStorage.removeItem("pendingAgentMessage"); + handleSubmit({ text: parsed.text, files: [] }); + } catch { + sessionStorage.removeItem("pendingAgentMessage"); + } + }, [agent_id, hasStartedConversation, handleSubmit, isNewThread]); + const handleClarificationSubmit = useCallback( (answer: string) => { setHasStartedConversation(true); @@ -116,7 +143,12 @@ export default function AgentChatPage() { ? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM + MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM : undefined; - const isLandingState = isNewThread && !hasStartedConversation; + // AgentChatPage skips the centered welcome/landing state entirely — when the + // user reaches this page they've already committed to an agent (either by + // selecting one on the homepage or by visiting the agent's URL), so we drop + // them straight into the conversation layout: messages area on top, input at + // the bottom. The agent badge in the header still identifies the agent. + const isLandingState = false; return ( @@ -198,9 +230,9 @@ export default function AgentChatPage() { - ) - } disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"} onContextChange={(context) => setSettings("context", context)} onFollowupsVisibilityChange={setShowFollowups} diff --git a/frontend-web/src/pages/ChatPage.tsx b/frontend-web/src/pages/ChatPage.tsx index cdc49a87..9eb6d6db 100644 --- a/frontend-web/src/pages/ChatPage.tsx +++ b/frontend-web/src/pages/ChatPage.tsx @@ -1,5 +1,6 @@ "use client"; +import { useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useState } from "react"; import { type PromptInputMessage } from "@/components/ai-elements/prompt-input"; @@ -34,6 +35,7 @@ import { cn } from "@/lib/utils"; export default function ChatPage() { const routeBase = useRouteBase(); + const router = useRouter(); const { t } = useI18n(); const [showFollowups, setShowFollowups] = useState(false); const [hasStartedConversation, setHasStartedConversation] = useState(false); @@ -44,6 +46,7 @@ export default function ChatPage() { const { tokenUsageEnabled } = useModels(); const { data: promptPrefixSettings } = usePromptPrefixSettings(); const [promptPrefixOn, setPromptPrefixOn] = useState(false); + const [selectedAgentId, setSelectedAgentId] = useState(null); const { selectedArtifact } = useArtifacts(); useSpecificChatMode(); @@ -55,6 +58,7 @@ export default function ChatPage() { if (isNewThread) { setHasStartedConversation(false); setPromptPrefixOn(false); + setSelectedAgentId(null); } }, [isNewThread, threadId]); @@ -105,6 +109,32 @@ export default function ChatPage() { const handleSubmit = useCallback( (message: PromptInputMessage) => { + // If the user picked an agent before sending the opening turn, hand off + // to AgentChatPage so the conversation runs against that agent. We stash + // the pending text in sessionStorage because the next/navigation shim + // doesn't carry router state across hash-route pushes. File attachments + // are bound to the current thread upload directory and would not match + // the new thread, so we skip the handoff when files are present and let + // the message run on Lead Agent instead. + if ( + selectedAgentId && + isNewThread && + !hasStartedConversation && + message.text && + (!message.files || message.files.length === 0) + ) { + try { + sessionStorage.setItem( + "pendingAgentMessage", + JSON.stringify({ agentId: selectedAgentId, text: message.text }), + ); + } catch { + // sessionStorage may be unavailable in private mode; fall through. + } + router.push(`/page/workspace/agents/${selectedAgentId}/chats/new`); + return; + } + // Only the opening turn of a brand-new thread may carry the prefix. const shouldApplyPrefix = isNewThread && @@ -140,6 +170,8 @@ export default function ChatPage() { hasStartedConversation, promptPrefixOn, promptPrefixSettings, + selectedAgentId, + router, settings.context.writing_mode, writingArtifactPath, ], @@ -258,6 +290,14 @@ export default function ChatPage() { setSettings("context", context) } promptPrefix={promptPrefixProp} + agentSelector={ + isLandingState + ? { + selectedAgentId, + onAgentChange: setSelectedAgentId, + } + : undefined + } onFollowupsVisibilityChange={setShowFollowups} onSubmit={handleSubmit} onStop={handleStop} diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index 74539860..c269059c 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -233,7 +233,7 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA | **Skills** (`/api/skills`) | `GET /` - list skills (optional `search` fuzzy-name + `tag_id` filters; each item carries `tags` + owner-authored `detail`); `GET /{name}` - details; `PUT /{name}` - update enabled; `PUT /{name}/detail` - set the long-form `detail` text (custom skill: owner or admin; built-in/public: admin only — materializes a `skills` row on demand); `GET /{name}/content` - raw SKILL.md source, restricted to an admin or the publisher (custom-skill owner); other users get 403 and see only description + detail; `POST /install` - install from .skill archive (accepts standard optional frontmatter like `version`, `author`, `compatibility`) | | **Skills** (`/api/skills`) | `GET /` - list skills (optional `search` fuzzy-name + repeatable `tag_ids` multi-select filter, OR-matched; each item carries `tags` + owner-authored `detail`); `GET /{name}` - details; `PUT /{name}` - update enabled; `PUT /{name}/detail` - set the long-form `detail` text (custom skill: owner or admin; built-in/public: admin only — materializes a `skills` row on demand); `GET /{name}/content` - raw SKILL.md source, restricted to an admin or the publisher (custom-skill owner); other users get 403 and see only description + detail; `POST /install` - install from .skill archive (accepts standard optional frontmatter like `version`, `author`, `compatibility`) | | **Memory** (`/api/memory`) | `GET /` - memory data; `POST /reload` - force reload; `GET /config` - config; `GET /status` - config + data | -| **Agents** (`/api/agents`) | `GET /` - list built-in, owned, and published agents (optional `search` + repeatable `tag_ids` multi-select filter, OR-matched; each item carries `tags`); `POST /` - create user-owned agent; `GET/PUT/DELETE /{id}` - read/update/delete by stable agent id; `GET /check` - validate non-empty display name (duplicates allowed) | +| **Agents** (`/api/agents`) | `GET /` - list built-in, owned, and published agents (optional `search` + repeatable `tag_ids` multi-select filter, OR-matched; each item carries `tags` and `featured_order`); `POST /` - create user-owned agent; `GET/PUT/DELETE /{id}` - read/update/delete by stable agent id; `GET /check` - validate non-empty display name (duplicates allowed); `GET /selector` - homepage agent picker (single admin-curated list, visibility-filtered, sorted by `featured_order` asc); `PUT /{id}/featured` - **admin** sets/clears the homepage `featured_order` (must be `published=true` to feature; passing `order: null` clears unconditionally); `PUT /featured/order` - **admin** bulk-reorders the homepage list (body `{agent_ids: [...]}` → indices become 0..N-1) | | **Tags** (`/api/tags`) | `GET /` - list tags (optional `tag_type` filter; open to all); `POST /` - create tag (admin); `PUT /{id}` / `DELETE /{id}` - update/delete tag (admin); `GET /assignments/{target_type}/{target_id}` - tags on a resource; `PUT /assignments/{target_type}/{target_id}` - replace a resource's tags (admin). Tags are typed `agent`/`skill`/`scheduled_task` and may only tag resources of their own type. | | **Uploads** (`/api/threads/{id}/uploads`) | `POST /` - upload files (auto-converts PDF/PPT/Excel/Word); `GET /list` - list; `DELETE /{filename}` - delete | | **Threads** (`/api/threads/{id}`) | `DELETE /` - remove DeerFlow-managed local thread data after LangGraph thread deletion; unexpected failures are logged server-side and return a generic 500 detail | @@ -260,6 +260,8 @@ Proxied through nginx: `/api/langgraph/*` → LangGraph, all other `/api/*` → Custom agent metadata lives in the `agents` ORM table (`deerflow.persistence.agents`). `agents.id` is the stable runtime id and the filesystem directory name under `.deer-flow/agents/{id}/`; `agents.name` is display-only, can contain Chinese characters, and is not unique. `user_id IS NULL` marks built-in agents, user-owned agents are visible only to their owner unless `published = true`, and update/delete operations require ownership. The runtime accepts `agent_id` in context/configurable, and non-default `assistant_id` values are treated as agent ids after visibility checks. +**Homepage selector (agent picker on the new-chat page)** — single admin-curated list backed by `agents.featured_order` (nullable BIGINT). NULL = not featured. Only `published = true` agents can be featured (router enforces on writes); any published agent is eligible regardless of ownership. Cleared with `order: null`. The list rendered to end users is `WHERE featured_order IS NOT NULL` + visibility-filter, sorted by `featured_order ASC`. Admins reorder by sending the desired ids to `PUT /api/agents/featured/order` (assigns indices 0..N-1). BIGINT is used rather than INT so the management UI can seed initial values from `Date.now()` (overflows INT on MySQL). + ### Portable Column Types (`deerflow/persistence/types.py`) Two `TypeDecorator`s keep ORM columns correct across the `sqlite` / `postgres` / `mysql` backends. **All ORM timestamp/JSON columns must use these, not the raw SQLAlchemy types.** diff --git a/offline-backend-20260512/backend/app/gateway/routers/agents.py b/offline-backend-20260512/backend/app/gateway/routers/agents.py index 78a1aa4c..83bc7bd1 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/agents.py +++ b/offline-backend-20260512/backend/app/gateway/routers/agents.py @@ -45,6 +45,11 @@ class AgentResponse(BaseModel): tags: list[dict] = Field(default_factory=list, description="Tags assigned to this agent") + featured_order: int | None = Field( + default=None, + description="Admin-curated homepage rank. Null = not featured. Lower sorts first.", + ) + class AgentsListResponse(BaseModel): """Response model for listing all visible agents.""" @@ -76,6 +81,28 @@ class AgentUpdateRequest(BaseModel): tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist (config.yaml)") +class AgentSelectorResponse(BaseModel): + """Response for the homepage agent selector dropdown. + + The list is fully curated by admins (``featured_order`` on each row). + Non-admin users get a read-only view of the same list. + """ + + agents: list[AgentResponse] = Field( + default_factory=list, + description="Admin-curated agents, sorted by featured_order ascending.", + ) + + +class AgentFeaturedRequest(BaseModel): + """Request body for setting/clearing an agent's homepage featured rank.""" + + order: int | None = Field( + default=None, + description="Featured rank. Lower sorts first. Pass null to remove the agent from the homepage selector.", + ) + + def _require_agents_api_enabled() -> None: """Reject access unless the custom-agent management API is explicitly enabled.""" if not get_agents_api_config().enabled: @@ -179,7 +206,45 @@ async def _sync_legacy_agents(store: AgentStore) -> None: logger.exception("Failed to sync legacy folder-backed agents") -def _record_to_response(record: dict[str, Any], *, include_soul: bool = False) -> AgentResponse: +def _ensure_agent_dir_from_record(record: dict[str, Any]) -> None: + """Lazily restore an agent's on-disk artifacts from its DB record. + + The DB row is authoritative for the agent's existence. If the matching + ``{base_dir}/agents/{id}/`` directory is missing (data dir moved between + machines, filesystem wiped, etc.) we recreate it with a minimal + ``config.yaml`` + empty ``SOUL.md`` synthesized from the row. This restores + a usable agent without requiring the admin to manually delete + recreate. + """ + agent_id = record.get("id") + if not agent_id: + return + try: + agent_dir = get_paths().agent_dir(agent_id) + if agent_dir.exists(): + return + logger.warning( + "Agent dir missing for '%s' — rebuilding minimal config.yaml + SOUL.md from DB", + agent_id, + ) + _write_agent_config( + agent_id, + name=record.get("name") or agent_id, + description=record.get("description") or "", + ) + _write_agent_soul(agent_id, "") + except Exception: + # Self-heal is best-effort. ``load_agent_config`` already tolerates a + # missing dir, so the worst case is the agent runs with default + # settings until someone re-edits it. + logger.exception("Failed to self-heal agent dir for '%s'", agent_id) + + +def _record_to_response( + record: dict[str, Any], + *, + include_soul: bool = False, +) -> AgentResponse: + _ensure_agent_dir_from_record(record) soul: str | None = None if include_soul: soul = load_agent_soul(record["id"]) or "" @@ -199,6 +264,7 @@ def _record_to_response(record: dict[str, Any], *, include_soul: bool = False) - model=extras["model"], tool_groups=extras["tool_groups"], skills=extras["skills"], + featured_order=record.get("featured_order"), ) @@ -284,6 +350,32 @@ async def check_agent_name(name: str) -> dict[str, Any]: return {"available": True, "name": normalized} +@router.get( + "/agents/selector", + response_model=AgentSelectorResponse, + summary="Agent Homepage Selector", + description=( + "Return the admin-curated agent list shown on the new-chat homepage. " + "Sorted by ``featured_order`` ascending. Visibility-filtered, so an " + "agent the caller can't see (author un-published) is silently omitted." + ), +) +async def get_agent_selector(request: Request) -> AgentSelectorResponse: + _require_agents_api_enabled() + try: + store = get_agent_store(request) + await _sync_legacy_agents(store) + + user_id = _current_user_id(request) + records = await store.list_featured(user_id) + return AgentSelectorResponse(agents=[_record_to_response(r) for r in records]) + except HTTPException: + raise + except Exception as e: + logger.error("Failed to load agent selector: %s", e, exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to load agent selector: {str(e)}") from e + + @router.get( "/agents/{agent_id}", response_model=AgentResponse, @@ -297,7 +389,8 @@ async def get_agent(agent_id: str, request: Request) -> AgentResponse: try: store = get_agent_store(request) await _sync_legacy_agents(store) - record = await store.get_visible(agent_id, _current_user_id(request)) + user_id = _current_user_id(request) + record = await store.get_visible(agent_id, user_id) if record is None: raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found") return _record_to_response(record, include_soul=True) @@ -514,3 +607,91 @@ async def delete_agent(agent_id: str, request: Request) -> None: except Exception as e: logger.error("Failed to delete agent '%s': %s", agent_id, e, exc_info=True) raise HTTPException(status_code=500, detail=f"Failed to delete agent: {str(e)}") from e + + +@router.put( + "/agents/{agent_id}/featured", + response_model=AgentResponse, + summary="Set Agent Featured Rank (admin)", + description=( + "Admin-only. Set or clear the homepage-selector featured rank for any agent. " + "Requires the agent to be ``published=true`` when a non-null rank is supplied; " + "passing ``order: null`` removes the agent from the selector and is always allowed." + ), +) +async def set_agent_featured( + agent_id: str, + body: AgentFeaturedRequest, + request: Request, +) -> AgentResponse: + _require_agents_api_enabled() + agent_id = _validate_storage_id(agent_id) + + if not _is_admin_user(request): + raise HTTPException(status_code=403, detail="Only system admins can set the homepage featured rank") + + try: + store = get_agent_store(request) + await _sync_legacy_agents(store) + record = await store.get_any(agent_id) + if record is None: + raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found") + + if body.order is not None and not record.get("published"): + raise HTTPException(status_code=409, detail="Only published agents can be featured on the homepage") + + updated = await store.set_featured(agent_id, body.order) + if updated is None: + raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found") + logger.info("Set agent '%s' featured_order=%s by admin", agent_id, body.order) + return _record_to_response(updated, include_soul=True) + except HTTPException: + raise + except Exception as e: + logger.error("Failed to set featured for agent '%s': %s", agent_id, e, exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to set featured: {str(e)}") from e + + +class AgentFeaturedReorderRequest(BaseModel): + """Request body for admin to bulk-reorder the homepage featured list.""" + + agent_ids: list[str] = Field( + default_factory=list, + description=( + "Featured agent ids in the desired top→bottom display order. Ids not " + "currently featured are silently skipped, so the client can send the " + "current visible order without first reconciling with the server." + ), + ) + + +@router.put( + "/agents/featured/order", + status_code=204, + summary="Reorder Featured Agents (admin)", + description=( + "Admin-only. Bulk-assign ``featured_order`` to each id in the order received " + "(``0`` is topmost). After this, the homepage selector renders agents in " + "exactly the supplied order. Ids not already featured are ignored." + ), +) +async def reorder_featured_agents( + body: AgentFeaturedReorderRequest, + request: Request, +) -> None: + _require_agents_api_enabled() + if not _is_admin_user(request): + raise HTTPException(status_code=403, detail="Only system admins can reorder the homepage selector") + + try: + store = get_agent_store(request) + for index, agent_id in enumerate(body.agent_ids): + # ``set_featured`` is a no-op for unknown ids — it returns None and + # we move on without raising, matching the docstring. + await store.set_featured(agent_id, index) + logger.info("Admin reordered featured agents (%d ids)", len(body.agent_ids)) + except HTTPException: + raise + except Exception as e: + logger.error("Failed to reorder featured agents: %s", e, exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to reorder featured agents: {str(e)}") from e diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/agents_config.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/agents_config.py index e7eb8a64..63358dd7 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/config/agents_config.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/agents_config.py @@ -57,11 +57,18 @@ def load_agent_config(agent_id: str | None) -> AgentConfig | None: agent_id: The agent storage/runtime id. Returns: - AgentConfig instance. + AgentConfig instance, or ``None`` when the on-disk artifacts are missing. Raises: - FileNotFoundError: If the agent directory or config.yaml does not exist. - ValueError: If config.yaml cannot be parsed. + ValueError: If config.yaml exists but cannot be parsed. + + Tolerance: + A missing agent directory or missing config.yaml is **not** an error — + the runtime simply falls back to default Lead Agent behavior for that + id. This keeps the chat session usable when the DB still has an agent + row but its files have been deleted (e.g. data dir moved between + machines, or filesystem wiped). The router layer is expected to + lazily restore the directory next time the agent is fetched via API. """ if agent_id is None: @@ -72,10 +79,20 @@ def load_agent_config(agent_id: str | None) -> AgentConfig | None: config_file = agent_dir / "config.yaml" if not agent_dir.exists(): - raise FileNotFoundError(f"Agent directory not found: {agent_dir}") + logger.warning( + "Agent directory missing for id '%s' (%s); falling back to default config", + agent_id, + agent_dir, + ) + return None if not config_file.exists() and not (agent_dir / SOUL_FILENAME).exists(): - raise FileNotFoundError(f"Agent config not found: {config_file}") + logger.warning( + "Agent has no config.yaml or %s at %s; falling back to default config", + SOUL_FILENAME, + agent_dir, + ) + return None data: dict[str, Any] = {} if config_file.exists(): diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/base.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/base.py index 13b3a4d2..dc247b7c 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/base.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/base.py @@ -61,3 +61,23 @@ class AgentStore(ABC): async def count_owned(self, user_id: str) -> int: """Return the number of agents owned by ``user_id`` (user_id IS NOT NULL).""" raise NotImplementedError + + @abstractmethod + async def set_featured(self, agent_id: str, order: int | None) -> dict[str, Any] | None: + """Set or clear the homepage-selector featured rank for an agent. + + Returns the updated row, or ``None`` if the agent does not exist. + Caller is responsible for authorization (admin) and for verifying + that ``published`` is true when ``order`` is not ``None``. + """ + raise NotImplementedError + + @abstractmethod + async def list_featured(self, user_id: str) -> list[dict[str, Any]]: + """Return featured + still-visible-to-``user_id`` agents, ordered by ``featured_order`` asc.""" + raise NotImplementedError + + @abstractmethod + async def list_by_ids(self, agent_ids: list[str], user_id: str) -> list[dict[str, Any]]: + """Return rows for ``agent_ids`` visible to ``user_id``, preserving the input order.""" + raise NotImplementedError diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/memory.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/memory.py index 2b837103..269f3660 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/memory.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/memory.py @@ -55,6 +55,7 @@ class MemoryAgentStore(AgentStore): "description": data.get("description") or "", "user_id": data.get("user_id"), "published": bool(data.get("published", False)), + "featured_order": None, "created_at": now, "updated_at": now, } @@ -70,6 +71,7 @@ class MemoryAgentStore(AgentStore): "description": data.get("description") or "", "user_id": None, "published": bool(data.get("published", True)), + "featured_order": None, "created_at": _now(), "updated_at": _now(), } @@ -117,3 +119,22 @@ class MemoryAgentStore(AgentStore): async def count_owned(self, user_id: str) -> int: return sum(1 for a in self._agents.values() if a.get("user_id") == user_id) + + async def set_featured(self, agent_id: str, order: int | None) -> dict[str, Any] | None: + row = self._agents.get(agent_id) + if row is None: + return None + row["featured_order"] = order + row["updated_at"] = _now() + return _copy(row) + + async def list_featured(self, user_id: str) -> list[dict[str, Any]]: + candidates = [a for a in self._agents.values() if a.get("featured_order") is not None and self._is_visible(a, user_id)] + candidates.sort(key=lambda a: (a.get("featured_order"), a.get("created_at") or "")) + return [_copy(a) for a in candidates] + + async def list_by_ids(self, agent_ids: list[str], user_id: str) -> list[dict[str, Any]]: + order_map = {aid: idx for idx, aid in enumerate(agent_ids)} + rows = [self._agents[aid] for aid in agent_ids if aid in self._agents and self._is_visible(self._agents[aid], user_id)] + rows.sort(key=lambda a: order_map.get(a["id"], 0)) + return [_copy(a) for a in rows] diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/model.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/model.py index 2e1a2419..77dde87f 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/model.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/model.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import UTC, datetime -from sqlalchemy import Boolean, String +from sqlalchemy import BigInteger, Boolean, String from sqlalchemy.orm import Mapped, mapped_column from deerflow.persistence.base import Base @@ -21,5 +21,11 @@ class AgentRow(Base): description: Mapped[str] = mapped_column(String(1024), nullable=False, default="") user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) published: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True) + # NULL = not featured on the homepage selector. Non-NULL = admin-curated + # featured rank (lower sorts first). Only ``published=True`` agents are + # eligible; the router enforces that invariant on writes. BIGINT so the + # frontend can seed initial ranks from ``Date.now()`` (ms-resolution + # timestamps overflow a 4-byte INT on MySQL). + featured_order: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True) created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC)) updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py index 60e3f28d..e72e7102 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py @@ -142,3 +142,35 @@ class AgentRepository(AgentStore): stmt = select(func.count()).select_from(AgentRow).where(AgentRow.user_id == user_id) async with self._sf() as session: return await session.scalar(stmt) or 0 + + async def set_featured(self, agent_id: str, order: int | None) -> dict[str, Any] | None: + async with self._sf() as session: + row = await session.get(AgentRow, agent_id) + if row is None: + return None + row.featured_order = order + row.updated_at = datetime.now(UTC) + await session.commit() + await session.refresh(row) + return _row_to_dict(row) + + async def list_featured(self, user_id: str) -> list[dict[str, Any]]: + stmt = ( + select(AgentRow) + .where(AgentRow.featured_order.is_not(None), self._visible_filter(user_id)) + .order_by(AgentRow.featured_order.asc(), AgentRow.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 list_by_ids(self, agent_ids: list[str], user_id: str) -> list[dict[str, Any]]: + if not agent_ids: + return [] + order_map = {aid: idx for idx, aid in enumerate(agent_ids)} + stmt = select(AgentRow).where(AgentRow.id.in_(agent_ids), self._visible_filter(user_id)) + async with self._sf() as session: + result = await session.execute(stmt) + rows = list(result.scalars()) + rows.sort(key=lambda r: order_map.get(r.id, len(order_map))) + return [_row_to_dict(r) for r in rows] diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/engine.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/engine.py index 49d8dc76..331629a5 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/engine.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/engine.py @@ -10,13 +10,14 @@ None and fall back to in-memory implementations. from __future__ import annotations -import json -import logging -import sys -from importlib.util import find_spec -from pathlib import Path - -from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine +import asyncio +import json +import logging +import sys +from importlib.util import find_spec +from pathlib import Path + +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine def _json_serializer(obj: object) -> str: @@ -26,37 +27,89 @@ def _json_serializer(obj: object) -> str: logger = logging.getLogger(__name__) -_engine: AsyncEngine | None = None -_session_factory: async_sessionmaker[AsyncSession] | None = None - - -def _add_runtime_vendor_path() -> None: - """Allow offline deployments to place optional DB drivers in DEER_FLOW_HOME. - - The offline Docker image keeps the Python environment inside the image, while - ``/app/backend/.deer-flow`` is a persistent bind mount. The MySQL switch - script installs ``asyncmy`` into ``{DEER_FLOW_HOME}/python-vendor`` so the - unchanged startup script can still import it after container recreation. - """ - try: - from deerflow.config.runtime_paths import runtime_home - - vendor_dir = runtime_home() / "python-vendor" - except Exception: - vendor_dir = Path(".deer-flow/python-vendor").resolve() - - vendor = str(vendor_dir) - if vendor_dir.exists() and vendor not in sys.path: - sys.path.insert(0, vendor) - - -def _mysql_driver_name(url: str) -> str: - if url.startswith("mysql+aiomysql://"): - return "aiomysql" - return "asyncmy" - - -async def _auto_create_postgres_db(url: str) -> None: +_engine: AsyncEngine | None = None +_session_factory: async_sessionmaker[AsyncSession] | None = None + + +def _add_runtime_vendor_path() -> None: + """Allow offline deployments to place optional DB drivers in DEER_FLOW_HOME. + + The offline Docker image keeps the Python environment inside the image, while + ``/app/backend/.deer-flow`` is a persistent bind mount. The MySQL switch + script installs ``asyncmy`` into ``{DEER_FLOW_HOME}/python-vendor`` so the + unchanged startup script can still import it after container recreation. + """ + try: + from deerflow.config.runtime_paths import runtime_home + + vendor_dir = runtime_home() / "python-vendor" + except Exception: + vendor_dir = Path(".deer-flow/python-vendor").resolve() + + vendor = str(vendor_dir) + if vendor_dir.exists() and vendor not in sys.path: + sys.path.insert(0, vendor) + + +def _mysql_driver_name(url: str) -> str: + if url.startswith("mysql+aiomysql://"): + return "aiomysql" + return "asyncmy" + + +async def _run_alembic_upgrade(url: str) -> None: + """Apply pending Alembic migrations against ``url`` (``alembic upgrade head``). + + Runs after ``Base.metadata.create_all`` so brand-new deployments still get + a complete schema even without Alembic, while existing deployments pick up + in-place schema fixes (column widenings, new indexes, etc.) at the next + restart — no manual ``alembic upgrade`` step required. + + Notes: + + * Each historical migration in ``migrations/versions/`` already guards + itself with ``_has_table`` / ``_has_column``, so re-running them against + a database that was originally built via ``create_all`` is a sequence of + no-ops up to the first migration that actually has work to do. + * The Alembic ``env.py`` calls ``asyncio.run`` internally, which cannot run + inside an already-running loop — so we hop into a worker thread. + * Failures here are logged but do not abort startup. The app remains + operable on the ``create_all`` schema; the operator just won't get + in-place fixes until they investigate. + """ + ini_path = Path(__file__).parent / "migrations" / "alembic.ini" + if not ini_path.exists(): + logger.warning("Alembic config not found at %s; skipping auto-migrate", ini_path) + return + + # Alembic's ``Config`` is backed by ConfigParser, which performs ``%`` + # interpolation on every option value. URL-encoded credentials such as + # ``kland%4088124932`` (the ``@`` in ``kland@88124932``) trip that up with + # ``ValueError: invalid interpolation syntax ... at position N``. Doubling + # the percent signs neutralises the interpolation; ConfigParser un-doubles + # them back to the original value when read. + escaped_url = url.replace("%", "%%") + + def _do_upgrade() -> None: + # Import inside the worker so a missing Alembic install can't break + # the engine import chain. + from alembic import command + from alembic.config import Config + + cfg = Config(str(ini_path)) + cfg.set_main_option("sqlalchemy.url", escaped_url) + # The ``script_location`` in alembic.ini is ``%(here)s`` which points + # at the directory of alembic.ini itself — already correct for us. + command.upgrade(cfg, "head") + + try: + await asyncio.to_thread(_do_upgrade) + logger.info("Alembic migrations applied (or already up-to-date)") + except Exception: + logger.exception("Auto-applying Alembic migrations failed; continuing with create_all schema") + + +async def _auto_create_postgres_db(url: str) -> None: """Connect to the ``postgres`` maintenance DB and CREATE DATABASE. The target database name is extracted from *url*. The connection is @@ -94,8 +147,8 @@ async def init_engine( """Create the async engine and session factory, then auto-create tables. Args: - backend: "memory", "sqlite", "postgres", or "mysql". - url: SQLAlchemy async URL (for sqlite/postgres/mysql). + backend: "memory", "sqlite", "postgres", or "mysql". + url: SQLAlchemy async URL (for sqlite/postgres/mysql). echo: Echo SQL to log. pool_size: Postgres connection pool size. sqlite_dir: Directory to create for SQLite (ensured to exist). @@ -106,20 +159,20 @@ async def init_engine( logger.info("Persistence backend=memory -- ORM engine not initialized") return - if backend == "postgres": - try: - import asyncpg # noqa: F401 - except ImportError: - raise ImportError("database.backend is set to 'postgres' but asyncpg is not installed.\nInstall it with:\n uv sync --extra postgres\nOr switch to backend: sqlite in config.yaml for single-node deployment.") from None - if backend == "mysql": - _add_runtime_vendor_path() - driver = _mysql_driver_name(url) - if find_spec(driver) is None: - raise ImportError( - "database.backend is set to 'mysql' but the MySQL async driver is not installed.\n" - "Install asyncmy, or run switch_database_to_mysql.sh so it can install asyncmy into " - f"{Path('.deer-flow/python-vendor')} from the offline wheelhouse." - ) + if backend == "postgres": + try: + import asyncpg # noqa: F401 + except ImportError: + raise ImportError("database.backend is set to 'postgres' but asyncpg is not installed.\nInstall it with:\n uv sync --extra postgres\nOr switch to backend: sqlite in config.yaml for single-node deployment.") from None + if backend == "mysql": + _add_runtime_vendor_path() + driver = _mysql_driver_name(url) + if find_spec(driver) is None: + raise ImportError( + "database.backend is set to 'mysql' but the MySQL async driver is not installed.\n" + "Install asyncmy, or run switch_database_to_mysql.sh so it can install asyncmy into " + f"{Path('.deer-flow/python-vendor')} from the offline wheelhouse." + ) if backend == "sqlite": import os @@ -150,29 +203,31 @@ async def init_engine( cursor.execute("PRAGMA foreign_keys=ON;") finally: cursor.close() - elif backend == "postgres": - _engine = create_async_engine( - url, - echo=echo, - pool_size=pool_size, - pool_pre_ping=True, - json_serializer=_json_serializer, - ) - elif backend == "mysql": - _engine = create_async_engine( - url, - echo=echo, - pool_size=pool_size, - pool_pre_ping=True, - pool_recycle=1800, - json_serializer=_json_serializer, - ) - else: - raise ValueError(f"Unknown persistence backend: {backend!r}") + elif backend == "postgres": + _engine = create_async_engine( + url, + echo=echo, + pool_size=pool_size, + pool_pre_ping=True, + json_serializer=_json_serializer, + ) + elif backend == "mysql": + _engine = create_async_engine( + url, + echo=echo, + pool_size=pool_size, + pool_pre_ping=True, + pool_recycle=1800, + json_serializer=_json_serializer, + ) + else: + raise ValueError(f"Unknown persistence backend: {backend!r}") _session_factory = async_sessionmaker(_engine, expire_on_commit=False) - # Auto-create tables (dev convenience). Production should use Alembic. + # Auto-create tables for fresh deployments, then let Alembic apply any + # in-place schema fixes on top (see ``_run_alembic_upgrade`` below). Both + # are idempotent so repeating them across restarts is harmless. from deerflow.persistence.base import Base # Import all models so Base.metadata discovers them. @@ -200,6 +255,10 @@ async def init_engine( else: raise + # Apply in-place schema fixes (column widenings, etc.) that ``create_all`` + # cannot do on its own. Failures don't abort startup — see the helper. + await _run_alembic_upgrade(url) + logger.info("Persistence engine initialized: backend=%s", backend) @@ -209,10 +268,10 @@ async def init_engine_from_config(config) -> None: await init_engine("memory") return await init_engine( - backend=config.backend, - url=config.app_sqlalchemy_url, - echo=config.echo_sql, - pool_size=config.pool_size, + backend=config.backend, + url=config.app_sqlalchemy_url, + echo=config.echo_sql, + pool_size=config.pool_size, sqlite_dir=config.sqlite_dir if config.backend == "sqlite" else "", ) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260520_01_agent_pins_and_featured.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260520_01_agent_pins_and_featured.py new file mode 100644 index 00000000..4f866020 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260520_01_agent_pins_and_featured.py @@ -0,0 +1,83 @@ +"""Homepage agent selector: ``user_agent_pin`` table + ``agents.featured_order``. + +Adds two pieces backing the new chat-homepage agent picker: + +- ``agents.featured_order`` (nullable INT) — admin-curated featured rank. + NULL = not shown on the selector. Non-NULL rows sort ascending. +- ``user_agent_pin`` — per-user pinned agents (user's "favorites"). Composite + PK ``(user_id, agent_id)`` so a user pins each agent at most once. Foreign + key to ``agents.id`` with ``ON DELETE CASCADE`` so deleting an agent also + removes every user's pin of it. + +Revision ID: 20260520_01 +Revises: 20260519_02 +Create Date: 2026-05-20 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import inspect + + +revision = "20260520_01" +down_revision = "20260519_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 _has_column(table_name: str, column_name: str) -> bool: + if not _has_table(table_name): + return False + inspector = inspect(op.get_bind()) + return any(col["name"] == column_name for col in inspector.get_columns(table_name)) + + +def _has_index(table_name: str, index_name: str) -> bool: + if not _has_table(table_name): + return False + inspector = inspect(op.get_bind()) + return any(idx["name"] == index_name for idx in inspector.get_indexes(table_name)) + + +def upgrade() -> None: + # 1. agents.featured_order — ALTER existing table. Skipped on brand-new DBs + # where create_all already materialised the column. + if _has_table("agents") and not _has_column("agents", "featured_order"): + op.add_column("agents", sa.Column("featured_order", sa.Integer(), nullable=True)) + if _has_table("agents") and not _has_index("agents", "ix_agents_featured_order"): + op.create_index("ix_agents_featured_order", "agents", ["featured_order"]) + + # 2. user_agent_pin — per-user pinned agents. + if not _has_table("user_agent_pin"): + op.create_table( + "user_agent_pin", + sa.Column("user_id", sa.String(64), nullable=False), + sa.Column( + "agent_id", + sa.String(64), + sa.ForeignKey("agents.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("user_id", "agent_id", name="pk_user_agent_pin"), + ) + op.create_index("ix_user_agent_pin_sort_order", "user_agent_pin", ["sort_order"]) + + +def downgrade() -> None: + if _has_index("user_agent_pin", "ix_user_agent_pin_sort_order"): + op.drop_index("ix_user_agent_pin_sort_order", table_name="user_agent_pin") + if _has_table("user_agent_pin"): + op.drop_table("user_agent_pin") + if _has_index("agents", "ix_agents_featured_order"): + op.drop_index("ix_agents_featured_order", table_name="agents") + if _has_column("agents", "featured_order"): + op.drop_column("agents", "featured_order") diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260520_02_featured_order_bigint.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260520_02_featured_order_bigint.py new file mode 100644 index 00000000..ac64f564 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260520_02_featured_order_bigint.py @@ -0,0 +1,66 @@ +"""Widen ``agents.featured_order`` from INT to BIGINT. + +The previous revision (``20260520_01``) created ``featured_order`` as plain +``INTEGER``. The frontend seeds the initial rank with ``Date.now()`` so admins +get "newest featured sinks to the bottom" with zero UI — but ms-resolution +timestamps (~1.78e12) overflow a 4-byte signed INT on MySQL, surfacing as + + sqlalchemy.exc.DataError: (1264, "Out of range value for column 'featured_order'") + +SQLite and PostgreSQL ``INTEGER`` is already 64-bit / arbitrary-precision and +needs no change, but the migration runs everywhere for schema parity. + +Revision ID: 20260520_02 +Revises: 20260520_01 +Create Date: 2026-05-20 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import inspect + + +revision = "20260520_02" +down_revision = "20260520_01" +branch_labels = None +depends_on = None + + +def _has_column(table_name: str, column_name: str) -> bool: + inspector = inspect(op.get_bind()) + if table_name not in inspector.get_table_names(): + return False + return any(col["name"] == column_name for col in inspector.get_columns(table_name)) + + +def upgrade() -> None: + if not _has_column("agents", "featured_order"): + return + bind = op.get_bind() + # SQLite has only INTEGER (dynamic precision) — alter_column would rebuild + # the table for nothing. PostgreSQL INTEGER is 32-bit too, so alter it. + if bind.dialect.name == "sqlite": + return + op.alter_column( + "agents", + "featured_order", + type_=sa.BigInteger(), + existing_nullable=True, + ) + + +def downgrade() -> None: + if not _has_column("agents", "featured_order"): + return + bind = op.get_bind() + if bind.dialect.name == "sqlite": + return + # Any rows with values > 2^31-1 would be truncated. Downgrade is dev-only. + op.alter_column( + "agents", + "featured_order", + type_=sa.Integer(), + existing_nullable=True, + ) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260520_03_drop_user_agent_pin.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260520_03_drop_user_agent_pin.py new file mode 100644 index 00000000..78c9e028 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260520_03_drop_user_agent_pin.py @@ -0,0 +1,61 @@ +"""Drop ``user_agent_pin`` (homepage selector simplified to admin-only). + +The original design split the homepage agent picker into two buckets: +admin-curated "featured" + per-user "pinned". Product feedback showed the +per-user side added complexity without clear value — admins now own the +single list shown to everyone. ``agents.featured_order`` is the only piece +that survives; this revision removes the now-unused pin table. + +Revision ID: 20260520_03 +Revises: 20260520_02 +Create Date: 2026-05-20 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import inspect + + +revision = "20260520_03" +down_revision = "20260520_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 _has_index(table_name: str, index_name: str) -> bool: + if not _has_table(table_name): + return False + inspector = inspect(op.get_bind()) + return any(idx["name"] == index_name for idx in inspector.get_indexes(table_name)) + + +def upgrade() -> None: + if _has_index("user_agent_pin", "ix_user_agent_pin_sort_order"): + op.drop_index("ix_user_agent_pin_sort_order", table_name="user_agent_pin") + if _has_table("user_agent_pin"): + op.drop_table("user_agent_pin") + + +def downgrade() -> None: + if not _has_table("user_agent_pin"): + op.create_table( + "user_agent_pin", + sa.Column("user_id", sa.String(64), nullable=False), + sa.Column( + "agent_id", + sa.String(64), + sa.ForeignKey("agents.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("user_id", "agent_id", name="pk_user_agent_pin"), + ) + op.create_index("ix_user_agent_pin_sort_order", "user_agent_pin", ["sort_order"])