Merge branch 'hwg' into 'master'
feat: 智能体精选排序与新对话智能体选择器 See merge request knowledge-base-bj/deerflow-server!41
This commit is contained in:
commit
99992c951d
22 files changed
+1335
-135
No files matched your search
+2
-1
@@ -75,4 +75,5 @@ offline-backend-20260512/backend/skill_evolution.json
|
||||
skills/
|
||||
hermes-agent-main/
|
||||
.gitignore
|
||||
offline-backend-20260512/backend/tests/*
|
||||
offline-backend-20260512/backend/tests/*
|
||||
deploy
|
||||
@@ -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) {
|
||||
已发布
|
||||
</Badge>
|
||||
)}
|
||||
{agent.featured_order != null && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-amber-500/40 text-amber-700 dark:text-amber-300 text-xs"
|
||||
title="已加入首页选择器"
|
||||
>
|
||||
<SparklesIcon className="size-3" />
|
||||
首页推荐
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{agent.description && (
|
||||
@@ -263,6 +300,26 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
</Button>
|
||||
{(canEdit || canDelete || isOwned || isAdmin) && (
|
||||
<div className="flex gap-1">
|
||||
{isAdmin && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-8 w-8 shrink-0",
|
||||
agent.featured_order != null &&
|
||||
"text-amber-600 hover:text-amber-700",
|
||||
)}
|
||||
onClick={() => void handleToggleFeatured()}
|
||||
disabled={setFeatured.isPending}
|
||||
title={
|
||||
agent.featured_order != null
|
||||
? "从首页选择器移除"
|
||||
: "加入首页选择器"
|
||||
}
|
||||
>
|
||||
<SparklesIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Button
|
||||
size="icon"
|
||||
@@ -469,6 +526,7 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
<SkillDetailDialog
|
||||
skillName={detailSkillName}
|
||||
open={detailSkillName !== null}
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
BotIcon,
|
||||
PlusIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Tooltip } from "@/components/workspace/tooltip";
|
||||
import {
|
||||
useAgentSelector,
|
||||
useAgents,
|
||||
useReorderFeaturedAgents,
|
||||
useSetAgentFeatured,
|
||||
} from "@/core/agents";
|
||||
import type { Agent } from "@/core/agents";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AgentManageDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-only homepage agent picker management.
|
||||
*
|
||||
* Single list (no tabs, no per-user buckets): admin curates exactly what every
|
||||
* user sees in the new-chat selector. Up/down to reorder, ✕ to remove, + to
|
||||
* add a published agent. All actions hit the server immediately and react to
|
||||
* TanStack Query invalidation, so the selector dropdown stays in lockstep.
|
||||
*/
|
||||
export function AgentManageDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: AgentManageDialogProps) {
|
||||
const { agents, isLoading } = useAgentSelector();
|
||||
const { agents: allAgents } = useAgents();
|
||||
const setFeaturedMutation = useSetAgentFeatured();
|
||||
const reorderMutation = useReorderFeaturedAgents();
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
|
||||
const candidates = useMemo(() => {
|
||||
const existing = new Set(agents.map((a) => a.id));
|
||||
return allAgents.filter((a) => a.published && !existing.has(a.id));
|
||||
}, [agents, allAgents]);
|
||||
|
||||
const busy = setFeaturedMutation.isPending || reorderMutation.isPending;
|
||||
|
||||
const move = async (index: number, direction: -1 | 1) => {
|
||||
const target = index + direction;
|
||||
if (target < 0 || target >= agents.length) return;
|
||||
const next = agents.map((a) => a.id);
|
||||
[next[index], next[target]] = [next[target]!, next[index]!];
|
||||
try {
|
||||
await reorderMutation.mutateAsync(next);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (agentId: string) => {
|
||||
try {
|
||||
await setFeaturedMutation.mutateAsync({ id: agentId, order: null });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const add = async (agent: Agent) => {
|
||||
if (!agent.published) {
|
||||
toast.error("只有已发布的智能体可以加入选择器");
|
||||
return;
|
||||
}
|
||||
const maxOrder = agents.reduce(
|
||||
(acc, a) => Math.max(acc, a.featured_order ?? 0),
|
||||
-1,
|
||||
);
|
||||
try {
|
||||
await setFeaturedMutation.mutateAsync({
|
||||
id: agent.id,
|
||||
order: maxOrder + 1,
|
||||
});
|
||||
setPickerOpen(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>管理 Agent 列表</DialogTitle>
|
||||
<DialogDescription>
|
||||
配置首页对话框里展示的 agent。所有人会看到同一份列表,按下方顺序展示。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
{isLoading ? (
|
||||
<div className="rounded-md border p-6 text-center text-sm text-muted-foreground">
|
||||
加载中…
|
||||
</div>
|
||||
) : agents.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
还没有配置任何 agent。点击下方“添加 Agent”开始。
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-[50vh] divide-y overflow-y-auto rounded-md border">
|
||||
{agents.map((agent, index) => (
|
||||
<div
|
||||
key={agent.id}
|
||||
className="flex items-center gap-2 px-3 py-2 hover:bg-accent/30"
|
||||
>
|
||||
<span className="text-muted-foreground w-6 shrink-0 text-center text-xs tabular-nums">
|
||||
{index + 1}
|
||||
</span>
|
||||
<BotIcon className="text-muted-foreground size-4 shrink-0" />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-sm">{agent.name}</span>
|
||||
{agent.description && (
|
||||
<span className="truncate text-[11px] text-muted-foreground">
|
||||
{agent.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Tooltip content="上移">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
disabled={busy || index === 0}
|
||||
onClick={() => void move(index, -1)}
|
||||
className="size-7"
|
||||
>
|
||||
<ArrowUpIcon className="size-3.5" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="下移">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
disabled={busy || index === agents.length - 1}
|
||||
onClick={() => void move(index, 1)}
|
||||
className="size-7"
|
||||
>
|
||||
<ArrowDownIcon className="size-3.5" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="从选择器移除">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
disabled={busy}
|
||||
onClick={() => void remove(agent.id)}
|
||||
className="text-destructive hover:text-destructive size-7"
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => setPickerOpen(true)}
|
||||
className="w-full"
|
||||
>
|
||||
<PlusIcon className="mr-1 size-3.5" />
|
||||
添加 Agent
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||
<DialogContent className="overflow-hidden p-0 sm:max-w-md">
|
||||
<DialogTitle className="sr-only">添加 Agent</DialogTitle>
|
||||
<Command className={cn("**:data-[slot=command-input-wrapper]:h-auto")}>
|
||||
<CommandInput placeholder="搜索可添加的 agent…" className="h-auto py-3.5" />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{candidates.length === 0
|
||||
? "没有更多已发布的 agent 可添加。"
|
||||
: "没有找到匹配的 agent。"}
|
||||
</CommandEmpty>
|
||||
{candidates.length > 0 && (
|
||||
<CommandGroup>
|
||||
{candidates.map((agent) => (
|
||||
<CommandItem
|
||||
key={agent.id}
|
||||
value={`${agent.name} ${agent.description ?? ""}`}
|
||||
onSelect={() => void add(agent)}
|
||||
>
|
||||
<BotIcon className="text-muted-foreground mr-2 size-4 shrink-0" />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-sm">{agent.name}</span>
|
||||
{agent.description && (
|
||||
<span className="truncate text-[10px] text-muted-foreground">
|
||||
{agent.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<Agent | null>(() => {
|
||||
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 (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<PromptInputButton
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"gap-1.5! rounded-full border px-3! shadow-none transition-colors",
|
||||
selectedAgent
|
||||
? "border-[#d6a868] bg-[#fff3df] text-[#7a5417] hover:bg-[#ffe8c4]"
|
||||
: "border-[#eee3d5] bg-[#fffaf5] text-[#7e6a57] hover:bg-white dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700",
|
||||
)}
|
||||
>
|
||||
<BotIcon className="size-3" />
|
||||
<span className="max-w-[140px] truncate text-xs font-normal">
|
||||
{triggerLabel}
|
||||
</span>
|
||||
{selectedAgent && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="清除已选 agent"
|
||||
className="ml-0.5 inline-flex items-center justify-center rounded-full p-0.5 hover:bg-[#ffd9a7]"
|
||||
onClick={handleClear}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onAgentChange(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
</span>
|
||||
)}
|
||||
</PromptInputButton>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="overflow-hidden p-0 sm:max-w-md">
|
||||
<DialogTitle className="sr-only">选择 Agent</DialogTitle>
|
||||
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
|
||||
<CommandInput placeholder="搜索 agent…" className="h-auto py-3.5" />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{isLoading ? "加载中…" : "没有找到匹配的 agent"}
|
||||
</CommandEmpty>
|
||||
{agents.length > 0 && (
|
||||
<CommandGroup>
|
||||
{agents.map((agent) => (
|
||||
<CommandItem
|
||||
key={agent.id}
|
||||
value={`${agent.name} ${agent.description ?? ""}`}
|
||||
onSelect={() => handleSelect(agent)}
|
||||
className={cn(
|
||||
"flex items-center gap-2",
|
||||
agent.id === selectedAgentId && "bg-accent",
|
||||
)}
|
||||
>
|
||||
<BotIcon className="text-muted-foreground size-4 shrink-0" />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-sm">{agent.name}</span>
|
||||
{agent.description && (
|
||||
<span className="truncate text-[10px] text-muted-foreground">
|
||||
{agent.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{!isLoading && agents.length === 0 && (
|
||||
<div className="px-4 py-6 text-center text-xs text-muted-foreground">
|
||||
{isAdmin
|
||||
? "还没有配置任何 agent。点击下方 “管理” 添加几个。"
|
||||
: "管理员还没有配置可选的 agent。"}
|
||||
</div>
|
||||
)}
|
||||
</CommandList>
|
||||
{isAdmin && (
|
||||
<div className="border-t px-2 py-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="w-full justify-start text-xs"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
setManageOpen(true);
|
||||
}}
|
||||
>
|
||||
<SettingsIcon className="mr-2 size-3.5" />
|
||||
管理 Agent 列表 …
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{isAdmin && (
|
||||
<AgentManageDialog open={manageOpen} onOpenChange={setManageOpen} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<ComponentProps<typeof PromptInput>, "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({
|
||||
)}
|
||||
</PromptInputTools>
|
||||
<PromptInputTools className="gap-1.5">
|
||||
{agentSelector && (
|
||||
<AgentSelector
|
||||
selectedAgentId={agentSelector.selectedAgentId}
|
||||
onAgentChange={agentSelector.onAgentChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
{promptPrefix && (
|
||||
<Tooltip
|
||||
content={
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { apiFetch } from "@/core/api";
|
||||
|
||||
import type { Agent, CreateAgentRequest, UpdateAgentRequest } from "./types";
|
||||
import type {
|
||||
Agent,
|
||||
AgentSelectorResponse,
|
||||
CreateAgentRequest,
|
||||
UpdateAgentRequest,
|
||||
} from "./types";
|
||||
|
||||
const BACKEND_UNAVAILABLE_STATUSES = new Set([502, 503, 504]);
|
||||
|
||||
@@ -75,6 +80,50 @@ export async function deleteAgent(id: string): Promise<void> {
|
||||
if (!res.ok) throw new Error(`Failed to delete agent: ${res.statusText}`);
|
||||
}
|
||||
|
||||
export async function getAgentSelector(): Promise<AgentSelectorResponse> {
|
||||
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<AgentSelectorResponse>;
|
||||
}
|
||||
|
||||
export async function reorderFeaturedAgents(agentIds: string[]): Promise<void> {
|
||||
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<Agent> {
|
||||
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<Agent>;
|
||||
}
|
||||
|
||||
export async function checkAgentName(
|
||||
name: string,
|
||||
): Promise<{ available: boolean; name: string }> {
|
||||
|
||||
@@ -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<typeof useQueryClient>) {
|
||||
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),
|
||||
});
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
<ThreadContext.Provider value={{ thread }}>
|
||||
@@ -196,32 +228,25 @@ export default function AgentChatPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full -translate-y-4">
|
||||
<InputBox
|
||||
className="w-full"
|
||||
isNewThread={isLandingState}
|
||||
threadId={threadId}
|
||||
autoFocus={isLandingState}
|
||||
status={
|
||||
thread.error
|
||||
? "error"
|
||||
: thread.isLoading
|
||||
? "streaming"
|
||||
: "ready"
|
||||
}
|
||||
context={settings.context}
|
||||
extraHeader={
|
||||
isLandingState && (
|
||||
<AgentWelcome agent={agent} agentName={agent?.name ?? agent_id} />
|
||||
)
|
||||
}
|
||||
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
|
||||
onContextChange={(context) => setSettings("context", context)}
|
||||
onFollowupsVisibilityChange={setShowFollowups}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
</div>
|
||||
<InputBox
|
||||
className={cn("bg-background/5 w-full -translate-y-4")}
|
||||
isNewThread={isNewThread}
|
||||
threadId={threadId}
|
||||
autoFocus={isNewThread}
|
||||
status={
|
||||
thread.error
|
||||
? "error"
|
||||
: thread.isLoading
|
||||
? "streaming"
|
||||
: "ready"
|
||||
}
|
||||
context={settings.context}
|
||||
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
|
||||
onContextChange={(context) => setSettings("context", context)}
|
||||
onFollowupsVisibilityChange={setShowFollowups}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
|
||||
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
|
||||
{t.common.notAvailableInDemoMode}
|
||||
|
||||
@@ -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<string | null>(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}
|
||||
|
||||
@@ -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.**
|
||||
|
||||
@@ -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
|
||||
@@ -144,25 +144,27 @@ checkpointer:
|
||||
# users/{user_id}/memory_config.json > .deer-flow/memory_user_overrides.json > 此处
|
||||
memory:
|
||||
enabled: true
|
||||
provider: hindsight # 'builtin' | 'hindsight'(外部 provider 名)
|
||||
# builtin 始终启用,这里只决定 external 选哪个
|
||||
provider:
|
||||
hindsight # 'builtin' | 'hindsight'(外部 provider 名)
|
||||
# builtin 始终启用,这里只决定 external 选哪个
|
||||
builtin:
|
||||
enabled: true
|
||||
memory_char_limit: 2200
|
||||
user_char_limit: 1375
|
||||
deduplicate_on_load: true
|
||||
|
||||
hindsight: # provider=hindsight 时生效,需另行部署 Hindsight 服务
|
||||
mode: local_external # 'cloud' | 'local_embedded' | 'local_external'
|
||||
api_url: http://localhost:8765
|
||||
api_key: "" # 部署 Hindsight 后填,或改成 $HINDSIGHT_API_KEY 并设同名环境变量
|
||||
# 注意:写成 $VAR 但环境变量未设置会导致配置加载失败
|
||||
hindsight: # provider=hindsight 时生效,需另行部署 Hindsight 服务
|
||||
mode: local_external # 'cloud' | 'local_embedded' | 'local_external'
|
||||
api_url: http://47.88.25.99:18888
|
||||
api_key:
|
||||
"" # 部署 Hindsight 后填,或改成 $HINDSIGHT_API_KEY 并设同名环境变量
|
||||
# 注意:写成 $VAR 但环境变量未设置会导致配置加载失败
|
||||
bank_id_template: "deerflow-user-{user_id}"
|
||||
work_bank_id_template: "deerflow-work-{user_id}-{agent_id}"
|
||||
memory_mode: context # 'context' | 'tools' | 'hybrid'
|
||||
bank_mission: ""
|
||||
bank_retain_mission: ""
|
||||
recall_budget: mid # 'low' | 'mid' | 'high'
|
||||
memory_mode: context # 'context' | 'tools' | 'hybrid'
|
||||
bank_mission: "你是一个中文记忆助手,负责从对话中提炼和存储重要信息。所有记忆内容必须使用中文记录。"
|
||||
bank_retain_mission: "请从以下对话中提炼重要信息,包括用户的偏好、需求、背景知识和关键事实。所有提炼的内容必须用中文表达。"
|
||||
recall_budget: mid # 'low' | 'mid' | 'high'
|
||||
recall_max_tokens: 4096
|
||||
recall_max_input_chars: 800
|
||||
auto_recall: true
|
||||
@@ -188,10 +190,10 @@ memory:
|
||||
max_tokens: 2000
|
||||
include_builtin: true
|
||||
include_hindsight: true
|
||||
context_tag: "memory-context" # 包裹 Hindsight recall 的标签名
|
||||
context_tag: "memory-context" # 包裹 Hindsight recall 的标签名
|
||||
|
||||
security:
|
||||
scan_content: true # 注入/外渗模式扫描
|
||||
scan_content: true # 注入/外渗模式扫描
|
||||
block_invisible_unicode: true
|
||||
# streaming_scrubber:预留字段,代码尚未实现 —— 待版本支持后取消注释。
|
||||
# streaming_scrubber: true # SSE 流前清洗 <memory-context>
|
||||
@@ -200,6 +202,6 @@ memory:
|
||||
# 注意:这三项必须嵌套在 ai_writing: 段下;写到顶层会被 _ai_writing_config()
|
||||
# (app/gateway/routers/ai_writing.py:_ai_writing_config) 直接忽略。
|
||||
ai_writing:
|
||||
keyword_model: deepseek-chat # 检索词生成专用模型,留空回退写作主模型
|
||||
rank_model: deepseek-chat # 素材排序专用模型,留空回退写作主模型
|
||||
max_materials: 20 # 合并去重后的素材总量上限,范围 [1, 100],默认 20
|
||||
keyword_model: deepseek-chat # 检索词生成专用模型,留空回退写作主模型
|
||||
rank_model: deepseek-chat # 素材排序专用模型,留空回退写作主模型
|
||||
max_materials: 20 # 合并去重后的素材总量上限,范围 [1, 100],默认 20
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
+21
@@ -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]
|
||||
+7
-1
@@ -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))
|
||||
@@ -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]
|
||||
@@ -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 "",
|
||||
)
|
||||
|
||||
|
||||
+83
@@ -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")
|
||||
+66
@@ -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,
|
||||
)
|
||||
+61
@@ -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"])
|
||||
Reference in new issue
Block a user