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
+1257
-57
No files matched your search
@@ -76,3 +76,4 @@ skills/
|
|||||||
hermes-agent-main/
|
hermes-agent-main/
|
||||||
.gitignore
|
.gitignore
|
||||||
offline-backend-20260512/backend/tests/*
|
offline-backend-20260512/backend/tests/*
|
||||||
|
deploy
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
InfoIcon,
|
InfoIcon,
|
||||||
LockIcon,
|
LockIcon,
|
||||||
MessageSquareIcon,
|
MessageSquareIcon,
|
||||||
|
SparklesIcon,
|
||||||
SquarePenIcon,
|
SquarePenIcon,
|
||||||
TagsIcon,
|
TagsIcon,
|
||||||
Trash2Icon,
|
Trash2Icon,
|
||||||
@@ -38,11 +39,16 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
|
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
|
||||||
import { TagChipList } from "@/components/workspace/tags/tag-chip";
|
import { TagChipList } from "@/components/workspace/tags/tag-chip";
|
||||||
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
|
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 type { Agent } from "@/core/agents";
|
||||||
import { getStoredAuth } from "@/core/auth";
|
import { getStoredAuth } from "@/core/auth";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { useSkills } from "@/core/skills/hooks";
|
import { useSkills } from "@/core/skills/hooks";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface AgentCardProps {
|
interface AgentCardProps {
|
||||||
agent: Agent;
|
agent: Agent;
|
||||||
@@ -53,6 +59,7 @@ export function AgentCard({ agent }: AgentCardProps) {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const deleteAgent = useDeleteAgent();
|
const deleteAgent = useDeleteAgent();
|
||||||
const updateAgent = useUpdateAgent();
|
const updateAgent = useUpdateAgent();
|
||||||
|
const setFeatured = useSetAgentFeatured();
|
||||||
const { skills, isLoading: isSkillsLoading } = useSkills();
|
const { skills, isLoading: isSkillsLoading } = useSkills();
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
const [editOpen, setEditOpen] = 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) {
|
function toggleSkill(skillName: string) {
|
||||||
setSelectedSkills((prev) => {
|
setSelectedSkills((prev) => {
|
||||||
if (prev.includes(skillName)) {
|
if (prev.includes(skillName)) {
|
||||||
@@ -211,6 +238,16 @@ export function AgentCard({ agent }: AgentCardProps) {
|
|||||||
已发布
|
已发布
|
||||||
</Badge>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
{agent.description && (
|
{agent.description && (
|
||||||
@@ -263,6 +300,26 @@ export function AgentCard({ agent }: AgentCardProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
{(canEdit || canDelete || isOwned || isAdmin) && (
|
{(canEdit || canDelete || isOwned || isAdmin) && (
|
||||||
<div className="flex gap-1">
|
<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 && (
|
{isAdmin && (
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -469,6 +526,7 @@ export function AgentCard({ agent }: AgentCardProps) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
<SkillDetailDialog
|
<SkillDetailDialog
|
||||||
skillName={detailSkillName}
|
skillName={detailSkillName}
|
||||||
open={detailSkillName !== null}
|
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 { textOfMessage } from "@/core/threads/utils";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
import { AgentSelector } from "@/components/workspace/agents/agent-selector";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ModelSelector,
|
ModelSelector,
|
||||||
ModelSelectorContent,
|
ModelSelectorContent,
|
||||||
@@ -119,6 +121,7 @@ export function InputBox({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
onStop,
|
onStop,
|
||||||
promptPrefix,
|
promptPrefix,
|
||||||
|
agentSelector,
|
||||||
...props
|
...props
|
||||||
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
|
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
|
||||||
assistantId?: string | null;
|
assistantId?: string | null;
|
||||||
@@ -159,6 +162,15 @@ export function InputBox({
|
|||||||
on: boolean;
|
on: boolean;
|
||||||
onToggle: () => void;
|
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 { t } = useI18n();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@@ -867,6 +879,13 @@ export function InputBox({
|
|||||||
)}
|
)}
|
||||||
</PromptInputTools>
|
</PromptInputTools>
|
||||||
<PromptInputTools className="gap-1.5">
|
<PromptInputTools className="gap-1.5">
|
||||||
|
{agentSelector && (
|
||||||
|
<AgentSelector
|
||||||
|
selectedAgentId={agentSelector.selectedAgentId}
|
||||||
|
onAgentChange={agentSelector.onAgentChange}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{promptPrefix && (
|
{promptPrefix && (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={
|
content={
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { getBackendBaseURL } from "@/core/config";
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
import { apiFetch } from "@/core/api";
|
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]);
|
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}`);
|
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(
|
export async function checkAgentName(
|
||||||
name: string,
|
name: string,
|
||||||
): Promise<{ available: boolean; name: string }> {
|
): Promise<{ available: boolean; name: string }> {
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ import {
|
|||||||
createAgent,
|
createAgent,
|
||||||
deleteAgent,
|
deleteAgent,
|
||||||
getAgent,
|
getAgent,
|
||||||
|
getAgentSelector,
|
||||||
listAgents,
|
listAgents,
|
||||||
|
reorderFeaturedAgents,
|
||||||
|
setAgentFeatured,
|
||||||
updateAgent,
|
updateAgent,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
import type { CreateAgentRequest, UpdateAgentRequest } from "./types";
|
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;
|
tool_groups?: string[] | null;
|
||||||
skills?: string[] | null;
|
skills?: string[] | null;
|
||||||
tags?: Tag[];
|
tags?: Tag[];
|
||||||
|
featured_order?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentSelectorResponse {
|
||||||
|
agents: Agent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateAgentRequest {
|
export interface CreateAgentRequest {
|
||||||
|
|||||||
@@ -3,11 +3,10 @@
|
|||||||
import { BotIcon, PlusSquare } from "lucide-react";
|
import { BotIcon, PlusSquare } from "lucide-react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useRouteBase } from "@/core/route-base";
|
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 type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { AgentWelcome } from "@/components/workspace/agent-welcome";
|
|
||||||
import { ArtifactTrigger } from "@/components/workspace/artifacts";
|
import { ArtifactTrigger } from "@/components/workspace/artifacts";
|
||||||
import { ChatBox, useThreadChat } from "@/components/workspace/chats";
|
import { ChatBox, useThreadChat } from "@/components/workspace/chats";
|
||||||
import { ExportTrigger } from "@/components/workspace/export-trigger";
|
import { ExportTrigger } from "@/components/workspace/export-trigger";
|
||||||
@@ -56,6 +55,11 @@ export default function AgentChatPage() {
|
|||||||
}
|
}
|
||||||
}, [isNewThread, threadId]);
|
}, [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 { showNotification } = useNotification();
|
||||||
const [thread, sendMessage] = useThreadStream({
|
const [thread, sendMessage] = useThreadStream({
|
||||||
threadId: isNewThread ? undefined : threadId,
|
threadId: isNewThread ? undefined : threadId,
|
||||||
@@ -100,6 +104,29 @@ export default function AgentChatPage() {
|
|||||||
[sendMessage, threadId, agent_id],
|
[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(
|
const handleClarificationSubmit = useCallback(
|
||||||
(answer: string) => {
|
(answer: string) => {
|
||||||
setHasStartedConversation(true);
|
setHasStartedConversation(true);
|
||||||
@@ -116,7 +143,12 @@ export default function AgentChatPage() {
|
|||||||
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
|
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
|
||||||
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
|
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
|
||||||
: undefined;
|
: 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 (
|
return (
|
||||||
<ThreadContext.Provider value={{ thread }}>
|
<ThreadContext.Provider value={{ thread }}>
|
||||||
@@ -196,32 +228,25 @@ export default function AgentChatPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full -translate-y-4">
|
<InputBox
|
||||||
<InputBox
|
className={cn("bg-background/5 w-full -translate-y-4")}
|
||||||
className="w-full"
|
isNewThread={isNewThread}
|
||||||
isNewThread={isLandingState}
|
threadId={threadId}
|
||||||
threadId={threadId}
|
autoFocus={isNewThread}
|
||||||
autoFocus={isLandingState}
|
status={
|
||||||
status={
|
thread.error
|
||||||
thread.error
|
? "error"
|
||||||
? "error"
|
: thread.isLoading
|
||||||
: thread.isLoading
|
? "streaming"
|
||||||
? "streaming"
|
: "ready"
|
||||||
: "ready"
|
}
|
||||||
}
|
context={settings.context}
|
||||||
context={settings.context}
|
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
|
||||||
extraHeader={
|
onContextChange={(context) => setSettings("context", context)}
|
||||||
isLandingState && (
|
onFollowupsVisibilityChange={setShowFollowups}
|
||||||
<AgentWelcome agent={agent} agentName={agent?.name ?? agent_id} />
|
onSubmit={handleSubmit}
|
||||||
)
|
onStop={handleStop}
|
||||||
}
|
/>
|
||||||
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
|
|
||||||
onContextChange={(context) => setSettings("context", context)}
|
|
||||||
onFollowupsVisibilityChange={setShowFollowups}
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
onStop={handleStop}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
|
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
|
||||||
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
|
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
|
||||||
{t.common.notAvailableInDemoMode}
|
{t.common.notAvailableInDemoMode}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||||
@@ -34,6 +35,7 @@ import { cn } from "@/lib/utils";
|
|||||||
|
|
||||||
export default function ChatPage() {
|
export default function ChatPage() {
|
||||||
const routeBase = useRouteBase();
|
const routeBase = useRouteBase();
|
||||||
|
const router = useRouter();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [showFollowups, setShowFollowups] = useState(false);
|
const [showFollowups, setShowFollowups] = useState(false);
|
||||||
const [hasStartedConversation, setHasStartedConversation] = useState(false);
|
const [hasStartedConversation, setHasStartedConversation] = useState(false);
|
||||||
@@ -44,6 +46,7 @@ export default function ChatPage() {
|
|||||||
const { tokenUsageEnabled } = useModels();
|
const { tokenUsageEnabled } = useModels();
|
||||||
const { data: promptPrefixSettings } = usePromptPrefixSettings();
|
const { data: promptPrefixSettings } = usePromptPrefixSettings();
|
||||||
const [promptPrefixOn, setPromptPrefixOn] = useState(false);
|
const [promptPrefixOn, setPromptPrefixOn] = useState(false);
|
||||||
|
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||||
const { selectedArtifact } = useArtifacts();
|
const { selectedArtifact } = useArtifacts();
|
||||||
useSpecificChatMode();
|
useSpecificChatMode();
|
||||||
|
|
||||||
@@ -55,6 +58,7 @@ export default function ChatPage() {
|
|||||||
if (isNewThread) {
|
if (isNewThread) {
|
||||||
setHasStartedConversation(false);
|
setHasStartedConversation(false);
|
||||||
setPromptPrefixOn(false);
|
setPromptPrefixOn(false);
|
||||||
|
setSelectedAgentId(null);
|
||||||
}
|
}
|
||||||
}, [isNewThread, threadId]);
|
}, [isNewThread, threadId]);
|
||||||
|
|
||||||
@@ -105,6 +109,32 @@ export default function ChatPage() {
|
|||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(message: PromptInputMessage) => {
|
(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.
|
// Only the opening turn of a brand-new thread may carry the prefix.
|
||||||
const shouldApplyPrefix =
|
const shouldApplyPrefix =
|
||||||
isNewThread &&
|
isNewThread &&
|
||||||
@@ -140,6 +170,8 @@ export default function ChatPage() {
|
|||||||
hasStartedConversation,
|
hasStartedConversation,
|
||||||
promptPrefixOn,
|
promptPrefixOn,
|
||||||
promptPrefixSettings,
|
promptPrefixSettings,
|
||||||
|
selectedAgentId,
|
||||||
|
router,
|
||||||
settings.context.writing_mode,
|
settings.context.writing_mode,
|
||||||
writingArtifactPath,
|
writingArtifactPath,
|
||||||
],
|
],
|
||||||
@@ -258,6 +290,14 @@ export default function ChatPage() {
|
|||||||
setSettings("context", context)
|
setSettings("context", context)
|
||||||
}
|
}
|
||||||
promptPrefix={promptPrefixProp}
|
promptPrefix={promptPrefixProp}
|
||||||
|
agentSelector={
|
||||||
|
isLandingState
|
||||||
|
? {
|
||||||
|
selectedAgentId,
|
||||||
|
onAgentChange: setSelectedAgentId,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onFollowupsVisibilityChange={setShowFollowups}
|
onFollowupsVisibilityChange={setShowFollowups}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
onStop={handleStop}
|
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 + `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`) |
|
| **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 |
|
| **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. |
|
| **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 |
|
| **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 |
|
| **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.
|
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`)
|
### 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.**
|
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")
|
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):
|
class AgentsListResponse(BaseModel):
|
||||||
"""Response model for listing all visible agents."""
|
"""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)")
|
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:
|
def _require_agents_api_enabled() -> None:
|
||||||
"""Reject access unless the custom-agent management API is explicitly enabled."""
|
"""Reject access unless the custom-agent management API is explicitly enabled."""
|
||||||
if not get_agents_api_config().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")
|
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
|
soul: str | None = None
|
||||||
if include_soul:
|
if include_soul:
|
||||||
soul = load_agent_soul(record["id"]) or ""
|
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"],
|
model=extras["model"],
|
||||||
tool_groups=extras["tool_groups"],
|
tool_groups=extras["tool_groups"],
|
||||||
skills=extras["skills"],
|
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}
|
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(
|
@router.get(
|
||||||
"/agents/{agent_id}",
|
"/agents/{agent_id}",
|
||||||
response_model=AgentResponse,
|
response_model=AgentResponse,
|
||||||
@@ -297,7 +389,8 @@ async def get_agent(agent_id: str, request: Request) -> AgentResponse:
|
|||||||
try:
|
try:
|
||||||
store = get_agent_store(request)
|
store = get_agent_store(request)
|
||||||
await _sync_legacy_agents(store)
|
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:
|
if record is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
||||||
return _record_to_response(record, include_soul=True)
|
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:
|
except Exception as e:
|
||||||
logger.error("Failed to delete agent '%s': %s", agent_id, e, exc_info=True)
|
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
|
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 > 此处
|
# users/{user_id}/memory_config.json > .deer-flow/memory_user_overrides.json > 此处
|
||||||
memory:
|
memory:
|
||||||
enabled: true
|
enabled: true
|
||||||
provider: hindsight # 'builtin' | 'hindsight'(外部 provider 名)
|
provider:
|
||||||
# builtin 始终启用,这里只决定 external 选哪个
|
hindsight # 'builtin' | 'hindsight'(外部 provider 名)
|
||||||
|
# builtin 始终启用,这里只决定 external 选哪个
|
||||||
builtin:
|
builtin:
|
||||||
enabled: true
|
enabled: true
|
||||||
memory_char_limit: 2200
|
memory_char_limit: 2200
|
||||||
user_char_limit: 1375
|
user_char_limit: 1375
|
||||||
deduplicate_on_load: true
|
deduplicate_on_load: true
|
||||||
|
|
||||||
hindsight: # provider=hindsight 时生效,需另行部署 Hindsight 服务
|
hindsight: # provider=hindsight 时生效,需另行部署 Hindsight 服务
|
||||||
mode: local_external # 'cloud' | 'local_embedded' | 'local_external'
|
mode: local_external # 'cloud' | 'local_embedded' | 'local_external'
|
||||||
api_url: http://localhost:8765
|
api_url: http://47.88.25.99:18888
|
||||||
api_key: "" # 部署 Hindsight 后填,或改成 $HINDSIGHT_API_KEY 并设同名环境变量
|
api_key:
|
||||||
# 注意:写成 $VAR 但环境变量未设置会导致配置加载失败
|
"" # 部署 Hindsight 后填,或改成 $HINDSIGHT_API_KEY 并设同名环境变量
|
||||||
|
# 注意:写成 $VAR 但环境变量未设置会导致配置加载失败
|
||||||
bank_id_template: "deerflow-user-{user_id}"
|
bank_id_template: "deerflow-user-{user_id}"
|
||||||
work_bank_id_template: "deerflow-work-{user_id}-{agent_id}"
|
work_bank_id_template: "deerflow-work-{user_id}-{agent_id}"
|
||||||
memory_mode: context # 'context' | 'tools' | 'hybrid'
|
memory_mode: context # 'context' | 'tools' | 'hybrid'
|
||||||
bank_mission: ""
|
bank_mission: "你是一个中文记忆助手,负责从对话中提炼和存储重要信息。所有记忆内容必须使用中文记录。"
|
||||||
bank_retain_mission: ""
|
bank_retain_mission: "请从以下对话中提炼重要信息,包括用户的偏好、需求、背景知识和关键事实。所有提炼的内容必须用中文表达。"
|
||||||
recall_budget: mid # 'low' | 'mid' | 'high'
|
recall_budget: mid # 'low' | 'mid' | 'high'
|
||||||
recall_max_tokens: 4096
|
recall_max_tokens: 4096
|
||||||
recall_max_input_chars: 800
|
recall_max_input_chars: 800
|
||||||
auto_recall: true
|
auto_recall: true
|
||||||
@@ -188,10 +190,10 @@ memory:
|
|||||||
max_tokens: 2000
|
max_tokens: 2000
|
||||||
include_builtin: true
|
include_builtin: true
|
||||||
include_hindsight: true
|
include_hindsight: true
|
||||||
context_tag: "memory-context" # 包裹 Hindsight recall 的标签名
|
context_tag: "memory-context" # 包裹 Hindsight recall 的标签名
|
||||||
|
|
||||||
security:
|
security:
|
||||||
scan_content: true # 注入/外渗模式扫描
|
scan_content: true # 注入/外渗模式扫描
|
||||||
block_invisible_unicode: true
|
block_invisible_unicode: true
|
||||||
# streaming_scrubber:预留字段,代码尚未实现 —— 待版本支持后取消注释。
|
# streaming_scrubber:预留字段,代码尚未实现 —— 待版本支持后取消注释。
|
||||||
# streaming_scrubber: true # SSE 流前清洗 <memory-context>
|
# streaming_scrubber: true # SSE 流前清洗 <memory-context>
|
||||||
@@ -200,6 +202,6 @@ memory:
|
|||||||
# 注意:这三项必须嵌套在 ai_writing: 段下;写到顶层会被 _ai_writing_config()
|
# 注意:这三项必须嵌套在 ai_writing: 段下;写到顶层会被 _ai_writing_config()
|
||||||
# (app/gateway/routers/ai_writing.py:_ai_writing_config) 直接忽略。
|
# (app/gateway/routers/ai_writing.py:_ai_writing_config) 直接忽略。
|
||||||
ai_writing:
|
ai_writing:
|
||||||
keyword_model: deepseek-chat # 检索词生成专用模型,留空回退写作主模型
|
keyword_model: deepseek-chat # 检索词生成专用模型,留空回退写作主模型
|
||||||
rank_model: deepseek-chat # 素材排序专用模型,留空回退写作主模型
|
rank_model: deepseek-chat # 素材排序专用模型,留空回退写作主模型
|
||||||
max_materials: 20 # 合并去重后的素材总量上限,范围 [1, 100],默认 20
|
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.
|
agent_id: The agent storage/runtime id.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
AgentConfig instance.
|
AgentConfig instance, or ``None`` when the on-disk artifacts are missing.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
FileNotFoundError: If the agent directory or config.yaml does not exist.
|
ValueError: If config.yaml exists but cannot be parsed.
|
||||||
ValueError: If config.yaml 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:
|
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"
|
config_file = agent_dir / "config.yaml"
|
||||||
|
|
||||||
if not agent_dir.exists():
|
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():
|
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] = {}
|
data: dict[str, Any] = {}
|
||||||
if config_file.exists():
|
if config_file.exists():
|
||||||
|
|||||||
@@ -61,3 +61,23 @@ class AgentStore(ABC):
|
|||||||
async def count_owned(self, user_id: str) -> int:
|
async def count_owned(self, user_id: str) -> int:
|
||||||
"""Return the number of agents owned by ``user_id`` (user_id IS NOT NULL)."""
|
"""Return the number of agents owned by ``user_id`` (user_id IS NOT NULL)."""
|
||||||
raise NotImplementedError
|
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 "",
|
"description": data.get("description") or "",
|
||||||
"user_id": data.get("user_id"),
|
"user_id": data.get("user_id"),
|
||||||
"published": bool(data.get("published", False)),
|
"published": bool(data.get("published", False)),
|
||||||
|
"featured_order": None,
|
||||||
"created_at": now,
|
"created_at": now,
|
||||||
"updated_at": now,
|
"updated_at": now,
|
||||||
}
|
}
|
||||||
@@ -70,6 +71,7 @@ class MemoryAgentStore(AgentStore):
|
|||||||
"description": data.get("description") or "",
|
"description": data.get("description") or "",
|
||||||
"user_id": None,
|
"user_id": None,
|
||||||
"published": bool(data.get("published", True)),
|
"published": bool(data.get("published", True)),
|
||||||
|
"featured_order": None,
|
||||||
"created_at": _now(),
|
"created_at": _now(),
|
||||||
"updated_at": _now(),
|
"updated_at": _now(),
|
||||||
}
|
}
|
||||||
@@ -117,3 +119,22 @@ class MemoryAgentStore(AgentStore):
|
|||||||
|
|
||||||
async def count_owned(self, user_id: str) -> int:
|
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)
|
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 datetime import UTC, datetime
|
||||||
|
|
||||||
from sqlalchemy import Boolean, String
|
from sqlalchemy import BigInteger, Boolean, String
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from deerflow.persistence.base import Base
|
from deerflow.persistence.base import Base
|
||||||
@@ -21,5 +21,11 @@ class AgentRow(Base):
|
|||||||
description: Mapped[str] = mapped_column(String(1024), nullable=False, default="")
|
description: Mapped[str] = mapped_column(String(1024), nullable=False, default="")
|
||||||
user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
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)
|
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))
|
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))
|
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)
|
stmt = select(func.count()).select_from(AgentRow).where(AgentRow.user_id == user_id)
|
||||||
async with self._sf() as session:
|
async with self._sf() as session:
|
||||||
return await session.scalar(stmt) or 0
|
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,6 +10,7 @@ None and fall back to in-memory implementations.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
@@ -56,6 +57,58 @@ def _mysql_driver_name(url: str) -> str:
|
|||||||
return "asyncmy"
|
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:
|
async def _auto_create_postgres_db(url: str) -> None:
|
||||||
"""Connect to the ``postgres`` maintenance DB and CREATE DATABASE.
|
"""Connect to the ``postgres`` maintenance DB and CREATE DATABASE.
|
||||||
|
|
||||||
@@ -172,7 +225,9 @@ async def init_engine(
|
|||||||
|
|
||||||
_session_factory = async_sessionmaker(_engine, expire_on_commit=False)
|
_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
|
from deerflow.persistence.base import Base
|
||||||
|
|
||||||
# Import all models so Base.metadata discovers them.
|
# Import all models so Base.metadata discovers them.
|
||||||
@@ -200,6 +255,10 @@ async def init_engine(
|
|||||||
else:
|
else:
|
||||||
raise
|
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)
|
logger.info("Persistence engine initialized: backend=%s", backend)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+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