feat: 支持智能体置顶配置

This commit is contained in:
吉鑫升 committed 2026-06-04 16:58:42 +08:00
1 parent 1f2b1e3b8a
commit bcebc8d5fb
20 files changed
+852 -310

No files matched your search

@@ -6,8 +6,9 @@ import {
GlobeIcon, GlobeIcon,
InfoIcon, InfoIcon,
LockIcon, LockIcon,
MessageSquareIcon, MessageSquareIcon,
ShieldIcon, PinIcon,
ShieldIcon,
SparklesIcon, SparklesIcon,
SquarePenIcon, SquarePenIcon,
StarIcon, StarIcon,
@@ -53,10 +54,11 @@ import {
useAgent, useAgent,
useCreateAgent, useCreateAgent,
useDeleteAgent, useDeleteAgent,
useSetAgentFeatured, useSetAgentFeatured,
useSetAgentPrivate, useSetAgentPrivate,
useToggleAgentFavorite, useToggleAgentFavorite,
useUpdateAgent, useToggleAgentPin,
useUpdateAgent,
} from "@/core/agents"; } from "@/core/agents";
import { getAgent } from "@/core/agents/api"; import { getAgent } from "@/core/agents/api";
import type { Agent } from "@/core/agents"; import type { Agent } from "@/core/agents";
@@ -186,8 +188,9 @@ export function AgentCard({ agent }: AgentCardProps) {
const updateAgent = useUpdateAgent(); const updateAgent = useUpdateAgent();
const createAgent = useCreateAgent(); const createAgent = useCreateAgent();
const setFeatured = useSetAgentFeatured(); const setFeatured = useSetAgentFeatured();
const setPrivate = useSetAgentPrivate(); const setPrivate = useSetAgentPrivate();
const toggleFavorite = useToggleAgentFavorite(); const toggleFavorite = useToggleAgentFavorite();
const togglePin = useToggleAgentPin();
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);
@@ -225,7 +228,8 @@ export function AgentCard({ agent }: AgentCardProps) {
// Favoriting collects a square agent into "我的"; pointless for one you // Favoriting collects a square agent into "我的"; pointless for one you
// already own, so only offer it on built-in / others' agents. // already own, so only offer it on built-in / others' agents.
const canFavorite = !isOwned; const canFavorite = !isOwned;
const isFavorite = !!agent.is_favorite; const isFavorite = !!agent.is_favorite;
const isPinned = !!agent.is_pinned;
useEffect(() => { useEffect(() => {
if (!editOpen) { if (!editOpen) {
@@ -379,7 +383,7 @@ export function AgentCard({ agent }: AgentCardProps) {
} }
} }
async function handleToggleFavorite() { async function handleToggleFavorite() {
const next = !isFavorite; const next = !isFavorite;
try { try {
await toggleFavorite.mutateAsync({ id: agent.id, favorite: next }); await toggleFavorite.mutateAsync({ id: agent.id, favorite: next });
@@ -387,7 +391,17 @@ export function AgentCard({ agent }: AgentCardProps) {
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : String(err)); toast.error(err instanceof Error ? err.message : String(err));
} }
} }
async function handleTogglePin() {
const next = !isPinned;
try {
await togglePin.mutateAsync({ id: agent.id, pinned: next });
toast.success(next ? "已置顶" : "已取消置顶");
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleToggleFeatured() { async function handleToggleFeatured() {
// One-click on/off — ordering is handled by the dedicated 管理 Agent 列表 // One-click on/off — ordering is handled by the dedicated 管理 Agent 列表
@@ -552,8 +566,8 @@ export function AgentCard({ agent }: AgentCardProps) {
<CardFooter className="mt-auto flex flex-col items-stretch gap-2 pt-3"> <CardFooter className="mt-auto flex flex-col items-stretch gap-2 pt-3">
{(canEdit || canDelete || isOwned || isAdmin || canFavorite) && ( {(canEdit || canDelete || isOwned || isAdmin || canFavorite) && (
<div className="flex flex-wrap items-center justify-end gap-1"> <div className="flex flex-wrap items-center justify-end gap-1">
{canFavorite && ( {canFavorite && (
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
className={cn( className={cn(
@@ -567,9 +581,23 @@ export function AgentCard({ agent }: AgentCardProps) {
<StarIcon <StarIcon
className={cn("h-3.5 w-3.5", isFavorite && "fill-current")} className={cn("h-3.5 w-3.5", isFavorite && "fill-current")}
/> />
</Button> </Button>
)} )}
{isAdmin && ( <Button
size="icon"
variant="ghost"
className={cn(
"h-8 w-8 shrink-0",
isPinned && "text-emerald-600 hover:text-emerald-700",
!canFavorite && "mr-auto",
)}
onClick={() => void handleTogglePin()}
disabled={togglePin.isPending}
title={isPinned ? "取消置顶" : "置顶"}
>
<PinIcon className={cn("h-3.5 w-3.5", isPinned && "fill-current")} />
</Button>
{isAdmin && (
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
@@ -1,6 +1,6 @@
"use client"; "use client";
import { BotIcon, LockIcon, PlusIcon, ShieldIcon } from "lucide-react"; import { ArrowDownUpIcon, BotIcon, LockIcon, PlusIcon, ShieldIcon } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -8,10 +8,11 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Pagination } from "@/components/ui/pagination"; import { Pagination } from "@/components/ui/pagination";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar"; import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents"; import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents";
import type { Agent } from "@/core/agents"; import type { Agent, AgentSort } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { usePrivateSquareStatus } from "@/core/system-settings/hooks"; import { usePrivateSquareStatus } from "@/core/system-settings/hooks";
@@ -32,6 +33,7 @@ export function AgentGallery() {
); );
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [tagIds, setTagIds] = useState<string[]>([]); const [tagIds, setTagIds] = useState<string[]>([]);
const [sort, setSort] = useState<AgentSort>("time_desc");
// Per-tab 1-based page index, preserved while switching tabs. // Per-tab 1-based page index, preserved while switching tabs.
const [pages, setPages] = useState<{ all: number; mine: number; private: number }>( const [pages, setPages] = useState<{ all: number; mine: number; private: number }>(
{ all: 1, mine: 1, private: 1 }, { all: 1, mine: 1, private: 1 },
@@ -44,7 +46,7 @@ export function AgentGallery() {
const tagKey = tagIds.join(","); const tagKey = tagIds.join(",");
useEffect(() => { useEffect(() => {
setPages({ all: 1, mine: 1, private: 1 }); setPages({ all: 1, mine: 1, private: 1 });
}, [search, tagKey]); }, [search, tagKey, sort]);
const { data: privateStatus } = usePrivateSquareStatus(); const { data: privateStatus } = usePrivateSquareStatus();
const privateFeatureAvailable = !!privateStatus?.enabled; const privateFeatureAvailable = !!privateStatus?.enabled;
@@ -69,6 +71,7 @@ export function AgentGallery() {
scope: mainScope, scope: mainScope,
search, search,
tagIds, tagIds,
sort,
page: mainPage, page: mainPage,
pageSize: PAGE_SIZE, pageSize: PAGE_SIZE,
enabled: activeTab !== "private", enabled: activeTab !== "private",
@@ -82,6 +85,7 @@ export function AgentGallery() {
} = usePrivateAgentsPaginated(privatePassword, { } = usePrivateAgentsPaginated(privatePassword, {
search, search,
tagIds, tagIds,
sort,
page: pages.private, page: pages.private,
pageSize: PAGE_SIZE, pageSize: PAGE_SIZE,
enabled: activeTab === "private", enabled: activeTab === "private",
@@ -98,7 +102,6 @@ export function AgentGallery() {
}, [privateError, privatePassword]); }, [privateError, privatePassword]);
const activeTotal = activeTab === "private" ? privateTotal : mainTotal; const activeTotal = activeTab === "private" ? privateTotal : mainTotal;
const setTabPage = (tab: "all" | "mine" | "private", page: number) => const setTabPage = (tab: "all" | "mine" | "private", page: number) =>
setPages((prev) => ({ ...prev, [tab]: page })); setPages((prev) => ({ ...prev, [tab]: page }));
@@ -240,6 +243,15 @@ export function AgentGallery() {
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") handleSubmitPassword(); if (e.key === "Enter") handleSubmitPassword();
}} }}
// This is a one-off access code, not a login credential. Tell
// browsers / password managers not to save or autofill it (which
// would also stop them from spilling a saved account into the
// name search box on this page).
name="private-square-access-code"
autoComplete="off"
data-1p-ignore
data-lpignore="true"
data-form-type="other"
/> />
<Button onClick={handleSubmitPassword}>进入私密广场</Button> <Button onClick={handleSubmitPassword}>进入私密广场</Button>
</div> </div>
@@ -314,6 +326,25 @@ export function AgentGallery() {
onSearchChange={setSearch} onSearchChange={setSearch}
tagIds={tagIds} tagIds={tagIds}
onTagIdsChange={setTagIds} onTagIdsChange={setTagIds}
actions={
<Select value={sort} onValueChange={(v) => setSort(v as AgentSort)}>
<SelectTrigger
className="h-9 w-full gap-2 px-3 text-xs sm:w-[148px]"
title="排序"
>
<ArrowDownUpIcon className="text-muted-foreground size-3.5" />
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" side="bottom" sideOffset={4}>
<SelectItem value="time_desc">最新创建</SelectItem>
<SelectItem value="time_asc">最早创建</SelectItem>
<SelectItem value="updated_desc">最近更新</SelectItem>
<SelectItem value="updated_asc">最早更新</SelectItem>
<SelectItem value="name_asc">名称 A-Z</SelectItem>
<SelectItem value="name_desc">名称 Z-A</SelectItem>
</SelectContent>
</Select>
}
/> />
</div> </div>
@@ -7,7 +7,7 @@ import {
Settings2Icon, Settings2Icon,
Trash2Icon, Trash2Icon,
} from "lucide-react"; } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -193,6 +193,19 @@ function ManagePromptsDialog({
const agentNameById = (id?: string | null) => const agentNameById = (id?: string | null) =>
id ? agents.find((a) => a.id === id)?.name ?? null : null; id ? agents.find((a) => a.id === id)?.name ?? null : null;
// Only offer the agents the user has favorited (收藏到「我的」) for
// association — the full catalog is huge, mostly irrelevant, and overflowed
// the dropdown. The agent a prompt is *already* bound to is kept in the list
// even if it is no longer a favorite, so editing never silently drops it.
const selectableAgents = useMemo(() => {
const favorites = agents.filter((a) => a.is_favorite);
if (agentId !== NO_AGENT && !favorites.some((a) => a.id === agentId)) {
const bound = agents.find((a) => a.id === agentId);
if (bound) return [bound, ...favorites];
}
return favorites;
}, [agents, agentId]);
const resetForm = () => { const resetForm = () => {
setEditingId(null); setEditingId(null);
setTitle(""); setTitle("");
@@ -299,23 +312,27 @@ function ManagePromptsDialog({
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<p className="text-muted-foreground text-xs">关联智能体(可选)</p> <p className="text-muted-foreground text-xs">关联智能体(可选)</p>
<Select value={agentId} onValueChange={setAgentId}> <Select value={agentId} onValueChange={setAgentId}>
<SelectTrigger className="w-full min-w-0 max-w-full overflow-hidden [&>span]:min-w-0 [&>span]:flex-1 [&>span]:truncate"> <SelectTrigger className="w-full min-w-0 max-w-full overflow-hidden [&>span]:min-w-0 [&>span]:flex-1 [&>span]:truncate [&>span]:text-left">
<SelectValue placeholder="不关联(所有对话可见)" /> <SelectValue placeholder="不关联(所有对话可见)" />
</SelectTrigger> </SelectTrigger>
<SelectContent <SelectContent
position="popper" position="popper"
className="w-[var(--radix-select-trigger-width)] max-w-[var(--radix-select-trigger-width)]" className="max-h-[40vh] w-[var(--radix-select-trigger-width)] max-w-[var(--radix-select-trigger-width)]"
> >
<SelectItem value={NO_AGENT}>不关联(所有对话可见)</SelectItem> <SelectItem value={NO_AGENT}>不关联(所有对话可见)</SelectItem>
{agents.map((agent) => ( {selectableAgents.map((agent) => (
<SelectItem key={agent.id} value={agent.id}> <SelectItem key={agent.id} value={agent.id} className="pr-8">
<span className="block truncate">{desensitize(agent.name)}</span> <span className="block max-w-full truncate">
{desensitize(agent.name)}
</span>
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
关联后,仅在选中该智能体的对话里显示此提示词。 {selectableAgents.length === 0
? "仅可关联你收藏到「我的」的常用智能体;当前还没有收藏任何智能体。"
: "关联后,仅在选中该智能体的对话里显示此提示词。这里只列出你收藏的常用智能体。"}
</p> </p>
</div> </div>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
@@ -1,9 +1,10 @@
"use client"; "use client";
import { import {
AlertCircle, AlertCircle,
ArchiveIcon, ArchiveIcon,
CheckCircle2, ArrowDownUpIcon,
CheckCircle2,
ChevronLeftIcon, ChevronLeftIcon,
ChevronRightIcon, ChevronRightIcon,
ClockIcon, ClockIcon,
@@ -51,8 +52,9 @@ import {
ItemContent, ItemContent,
ItemDescription, ItemDescription,
} from "@/components/ui/item"; } from "@/components/ui/item";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
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";
@@ -91,7 +93,16 @@ export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
); );
} }
const PAGE_SIZE = 10; const PAGE_SIZE = 10;
type SkillSort =
| "time_desc"
| "time_asc"
| "activity_desc"
| "activity_asc"
| "usage_desc"
| "usage_asc"
| "name_asc"
| "name_desc";
/** 重新扫描成功 toast:仅暗色模式使用,浅色走 Sonner 默认样式 */ /** 重新扫描成功 toast:仅暗色模式使用,浅色走 Sonner 默认样式 */
const rescanSuccessToastDarkOptions = { const rescanSuccessToastDarkOptions = {
@@ -134,12 +145,13 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
const [installOpen, setInstallOpen] = useState(false); const [installOpen, setInstallOpen] = useState(false);
const [staleSheetOpen, setStaleSheetOpen] = useState(false); const [staleSheetOpen, setStaleSheetOpen] = useState(false);
const [archiveSheetOpen, setArchiveSheetOpen] = useState(false); const [archiveSheetOpen, setArchiveSheetOpen] = useState(false);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [tagIds, setTagIds] = useState<string[]>([]); const [tagIds, setTagIds] = useState<string[]>([]);
const [page, setPage] = useState(1); const [sort, setSort] = useState<SkillSort>("time_desc");
const reconcileMutation = useReconcileCustomSkills(); const [page, setPage] = useState(1);
// Name + tag filtering is resolved server-side via the /api/skills query. const reconcileMutation = useReconcileCustomSkills();
const { skills, isLoading, error } = useSkills({ search, tagIds }); // Name + tag filtering is resolved server-side via the /api/skills query.
const { skills, isLoading, error } = useSkills({ search, tagIds, sort });
const storedAuth = useMemo(() => getStoredAuth(), []); const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null; const currentUserId = storedAuth?.user_id ?? null;
@@ -191,9 +203,12 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
const totalPages = Math.max(1, Math.ceil(filteredSkills.length / PAGE_SIZE)); const totalPages = Math.max(1, Math.ceil(filteredSkills.length / PAGE_SIZE));
// Filter / search / tag changes may shrink the result set — pull the page // Filter / search / tag changes may shrink the result set — pull the page
// back into range without forcing a reset on every keystroke. // back into range without forcing a reset on every keystroke.
useEffect(() => { useEffect(() => {
if (page > totalPages) setPage(totalPages); if (page > totalPages) setPage(totalPages);
}, [page, totalPages]); }, [page, totalPages]);
useEffect(() => {
setPage(1);
}, [search, tagIds, sort]);
const pagedSkills = useMemo( const pagedSkills = useMemo(
() => filteredSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), () => filteredSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
[filteredSkills, page], [filteredSkills, page],
@@ -309,12 +324,33 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
</Button> </Button>
</div> </div>
</header> </header>
<TagSearchBar <TagSearchBar
targetType="skill" targetType="skill"
onSearchChange={setSearch} onSearchChange={setSearch}
tagIds={tagIds} tagIds={tagIds}
onTagIdsChange={setTagIds} onTagIdsChange={setTagIds}
/> actions={
<Select value={sort} onValueChange={(v) => setSort(v as SkillSort)}>
<SelectTrigger
className="h-9 w-full gap-2 px-3 text-xs sm:w-[148px]"
title="排序"
>
<ArrowDownUpIcon className="text-muted-foreground size-3.5" />
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" side="bottom" sideOffset={4}>
<SelectItem value="time_desc">最新创建</SelectItem>
<SelectItem value="time_asc">最早创建</SelectItem>
<SelectItem value="activity_desc">最近使用</SelectItem>
<SelectItem value="activity_asc">最早使用</SelectItem>
<SelectItem value="usage_desc">使用最多</SelectItem>
<SelectItem value="usage_asc">使用最少</SelectItem>
<SelectItem value="name_asc">名称 A-Z</SelectItem>
<SelectItem value="name_desc">名称 Z-A</SelectItem>
</SelectContent>
</Select>
}
/>
{isLoading && ( {isLoading && (
<div className="text-muted-foreground text-sm">{t.common.loading}</div> <div className="text-muted-foreground text-sm">{t.common.loading}</div>
)} )}
@@ -1,7 +1,7 @@
"use client"; "use client";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon, SearchIcon } from "lucide-react"; import { CheckIcon, ChevronDownIcon, ChevronUpIcon, SearchIcon } from "lucide-react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState, type ReactNode } from "react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
@@ -31,12 +31,14 @@ export function TagSearchBar({
tagIds, tagIds,
onTagIdsChange, onTagIdsChange,
className, className,
actions,
}: { }: {
targetType: TagType; targetType: TagType;
onSearchChange: (value: string) => void; onSearchChange: (value: string) => void;
tagIds: string[]; tagIds: string[];
onTagIdsChange: (value: string[]) => void; onTagIdsChange: (value: string[]) => void;
className?: string; className?: string;
actions?: ReactNode;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const { data: tags } = useTags(targetType); const { data: tags } = useTags(targetType);
@@ -67,14 +69,31 @@ export function TagSearchBar({
return ( return (
<div className={cn("flex flex-col gap-2.5", className)}> <div className={cn("flex flex-col gap-2.5", className)}>
<div className="relative"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<SearchIcon className="text-muted-foreground dark:text-[#60a5fa]/90 absolute left-2.5 top-1/2 size-4 -translate-y-1/2" /> <div className="relative min-w-0 flex-1">
<Input <SearchIcon className="text-muted-foreground dark:text-[#60a5fa]/90 absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
value={input} <Input
onChange={(e) => setInput(e.target.value)} value={input}
placeholder={t.adminTags.searchPlaceholder} onChange={(e) => setInput(e.target.value)}
className="pl-8" placeholder={t.adminTags.searchPlaceholder}
/> className="pl-8"
name="tag-search"
// A `type="password"` field elsewhere on the page (e.g. the private
// square access gate) makes browsers / password managers pick the
// nearest text input as the "username" and autofill the account into
// it. Mark this search box so it is never treated as a credential
// field.
autoComplete="off"
data-1p-ignore
data-lpignore="true"
data-form-type="other"
/>
</div>
{actions && (
<div className="flex w-full shrink-0 justify-end sm:w-auto">
{actions}
</div>
)}
</div> </div>
{allTags.length > 0 && ( {allTags.length > 0 && (
+56 -31
View File
@@ -2,12 +2,13 @@ import { getBackendBaseURL } from "@/core/config";
import { apiFetch } from "@/core/api"; import { apiFetch } from "@/core/api";
import type { import type {
Agent, Agent,
AgentSelectorResponse, AgentSelectorResponse,
CreateAgentRequest, AgentSort,
PaginatedAgents, CreateAgentRequest,
UpdateAgentRequest, PaginatedAgents,
} from "./types"; UpdateAgentRequest,
} from "./types";
const BACKEND_UNAVAILABLE_STATUSES = new Set([502, 503, 504]); const BACKEND_UNAVAILABLE_STATUSES = new Set([502, 503, 504]);
@@ -27,19 +28,21 @@ export interface ListAgentsParams {
/** Pagination scope. Omit for the legacy combined-visibility list. */ /** Pagination scope. Omit for the legacy combined-visibility list. */
scope?: "mine" | "square"; scope?: "mine" | "square";
/** 1-based page index. Providing this (or scope) enables server-side pagination. */ /** 1-based page index. Providing this (or scope) enables server-side pagination. */
page?: number; page?: number;
pageSize?: number; pageSize?: number;
} sort?: AgentSort;
}
function buildAgentsQuery(params: ListAgentsParams): string { function buildAgentsQuery(params: ListAgentsParams): string {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (params.search) qs.set("search", params.search); if (params.search) qs.set("search", params.search);
for (const id of params.tagIds ?? []) qs.append("tag_ids", id); for (const id of params.tagIds ?? []) qs.append("tag_ids", id);
if (params.scope) qs.set("scope", params.scope); if (params.scope) qs.set("scope", params.scope);
if (params.page != null) qs.set("page", String(params.page)); if (params.page != null) qs.set("page", String(params.page));
if (params.pageSize != null) qs.set("page_size", String(params.pageSize)); if (params.pageSize != null) qs.set("page_size", String(params.pageSize));
return qs.toString(); if (params.sort) qs.set("sort", params.sort);
} return qs.toString();
}
export async function listAgents(params: ListAgentsParams = {}): Promise<Agent[]> { export async function listAgents(params: ListAgentsParams = {}): Promise<Agent[]> {
const query = buildAgentsQuery(params); const query = buildAgentsQuery(params);
@@ -126,13 +129,13 @@ 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> { export async function getAgentSelector(): Promise<AgentSelectorResponse> {
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/selector`); const res = await apiFetch(`${getBackendBaseURL()}/api/agents/selector`);
if (!res.ok) throw new Error(`Failed to load agent selector: ${res.statusText}`); if (!res.ok) throw new Error(`Failed to load agent selector: ${res.statusText}`);
return res.json() as Promise<AgentSelectorResponse>; return res.json() as Promise<AgentSelectorResponse>;
} }
export async function favoriteAgent(id: string): Promise<void> { export async function favoriteAgent(id: string): Promise<void> {
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}/favorite`, { const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}/favorite`, {
method: "POST", method: "POST",
}); });
@@ -142,15 +145,35 @@ export async function favoriteAgent(id: string): Promise<void> {
} }
} }
export async function unfavoriteAgent(id: string): Promise<void> { export async function unfavoriteAgent(id: string): Promise<void> {
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}/favorite`, { const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}/favorite`, {
method: "DELETE", method: "DELETE",
}); });
if (!res.ok) { if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string }; const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `Failed to unfavorite agent: ${res.statusText}`); throw new Error(err.detail ?? `Failed to unfavorite agent: ${res.statusText}`);
} }
} }
export async function pinAgent(id: string): Promise<void> {
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}/pin`, {
method: "POST",
});
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `Failed to pin agent: ${res.statusText}`);
}
}
export async function unpinAgent(id: string): Promise<void> {
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}/pin`, {
method: "DELETE",
});
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `Failed to unpin agent: ${res.statusText}`);
}
}
export async function reorderFeaturedAgents(agentIds: string[]): Promise<void> { export async function reorderFeaturedAgents(agentIds: string[]): Promise<void> {
const res = await apiFetch( const res = await apiFetch(
@@ -188,13 +211,14 @@ export async function listPrivateAgents(password: string): Promise<Agent[]> {
return data.agents; return data.agents;
} }
export interface ListPrivateAgentsParams { export interface ListPrivateAgentsParams {
password: string; password: string;
search?: string; search?: string;
tagIds?: string[]; tagIds?: string[];
page?: number; page?: number;
pageSize?: number; pageSize?: number;
} sort?: AgentSort;
}
/** Paginated private-square listing (password-gated). */ /** Paginated private-square listing (password-gated). */
export async function listPrivateAgentsPaginated( export async function listPrivateAgentsPaginated(
@@ -208,10 +232,11 @@ export async function listPrivateAgentsPaginated(
body: JSON.stringify({ body: JSON.stringify({
password: params.password, password: params.password,
search: params.search || undefined, search: params.search || undefined,
tag_ids: params.tagIds && params.tagIds.length ? params.tagIds : undefined, tag_ids: params.tagIds && params.tagIds.length ? params.tagIds : undefined,
page: params.page, page: params.page,
page_size: params.pageSize, page_size: params.pageSize,
}), sort: params.sort,
}),
}, },
); );
if (!res.ok) { if (!res.ok) {
+96 -66
View File
@@ -3,31 +3,34 @@ import {
useMutation, useMutation,
useQuery, useQuery,
useQueryClient, useQueryClient,
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { import {
adminSyncAgentSkills, adminSyncAgentSkills,
createAgent, createAgent,
deleteAgent, deleteAgent,
favoriteAgent, favoriteAgent,
getAgent, getAgent,
getAgentSelector, getAgentSelector,
listAgents, listAgents,
listAgentsPaginated, listAgentsPaginated,
listAllAgentsAdmin, listAllAgentsAdmin,
listPrivateAgents, listPrivateAgents,
listPrivateAgentsPaginated, listPrivateAgentsPaginated,
reorderFeaturedAgents, pinAgent,
setAgentFeatured, reorderFeaturedAgents,
setAgentPrivate, setAgentFeatured,
unfavoriteAgent, setAgentPrivate,
updateAgent, unfavoriteAgent,
} from "./api"; unpinAgent,
import type { updateAgent,
Agent, } from "./api";
CreateAgentRequest, import type {
PaginatedAgents, Agent,
UpdateAgentRequest, AgentSort,
CreateAgentRequest,
PaginatedAgents,
UpdateAgentRequest,
} from "./types"; } from "./types";
/** A cached agent listing is either a plain ``Agent[]`` (legacy ``useAgents``) /** A cached agent listing is either a plain ``Agent[]`` (legacy ``useAgents``)
@@ -76,28 +79,31 @@ export function useAgents(params: { search?: string; tagIds?: string[] } = {}) {
/** Server-side paginated agent listing for one gallery tab (scope). Returns /** Server-side paginated agent listing for one gallery tab (scope). Returns
* the page of agents plus the total count for the "共 N 个" header. */ * the page of agents plus the total count for the "共 N 个" header. */
export function useAgentsPaginated(params: { export function useAgentsPaginated(params: {
scope: "mine" | "square"; scope: "mine" | "square";
search?: string; search?: string;
tagIds?: string[]; tagIds?: string[];
page: number; sort?: AgentSort;
pageSize: number; page: number;
enabled?: boolean; pageSize: number;
}) { enabled?: boolean;
const { scope, page, pageSize, enabled = true } = params; }) {
const search = params.search ?? ""; const { scope, page, pageSize, enabled = true } = params;
const tagIds = params.tagIds ?? []; const search = params.search ?? "";
const tagIds = params.tagIds ?? [];
const sort = params.sort ?? "time_desc";
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: [ queryKey: [
"agents", "agents",
"paginated", "paginated",
scope, scope,
search, search,
[...tagIds].sort(), [...tagIds].sort(),
page, sort,
pageSize, page,
], pageSize,
queryFn: () => ],
listAgentsPaginated({ scope, search, tagIds, page, pageSize }), queryFn: () =>
listAgentsPaginated({ scope, search, tagIds, sort, page, pageSize }),
enabled, enabled,
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
}); });
@@ -174,7 +180,7 @@ export function useDeleteAgent() {
/** Toggle a user's favorite (收藏) on an agent, with an optimistic flip of /** Toggle a user's favorite (收藏) on an agent, with an optimistic flip of
* ``is_favorite`` across every cached agent listing + detail so the star * ``is_favorite`` across every cached agent listing + detail so the star
* reacts instantly before the round-trip completes. */ * reacts instantly before the round-trip completes. */
export function useToggleAgentFavorite() { export function useToggleAgentFavorite() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ id, favorite }: { id: string; favorite: boolean }) => mutationFn: ({ id, favorite }: { id: string; favorite: boolean }) =>
@@ -190,10 +196,30 @@ export function useToggleAgentFavorite() {
onSettled: () => { onSettled: () => {
void queryClient.invalidateQueries({ queryKey: ["agents"] }); void queryClient.invalidateQueries({ queryKey: ["agents"] });
}, },
}); });
} }
export function useAgentSelector() { export function useToggleAgentPin() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, pinned }: { id: string; pinned: boolean }) =>
pinned ? pinAgent(id) : unpinAgent(id),
onMutate: ({ id, pinned }) => {
mapCachedAgents(queryClient, (a) =>
a.id === id ? { ...a, is_pinned: pinned } : a,
);
queryClient.setQueryData<Agent | undefined>(["agents", id], (old) =>
old ? { ...old, is_pinned: pinned } : old,
);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["agents"] });
await queryClient.refetchQueries({ queryKey: ["agents"], type: "active" });
},
});
}
export function useAgentSelector() {
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: ["agents", "selector"], queryKey: ["agents", "selector"],
queryFn: () => getAgentSelector(), queryFn: () => getAgentSelector(),
@@ -204,9 +230,9 @@ export function useAgentSelector() {
isLoading, isLoading,
error, error,
}; };
} }
function invalidateAgentQueries(queryClient: ReturnType<typeof useQueryClient>) { function invalidateAgentQueries(queryClient: ReturnType<typeof useQueryClient>) {
void queryClient.invalidateQueries({ queryKey: ["agents"] }); void queryClient.invalidateQueries({ queryKey: ["agents"] });
} }
@@ -251,35 +277,39 @@ export function usePrivateAgents(password: string | null) {
* password-gated private square plus the total count. */ * password-gated private square plus the total count. */
export function usePrivateAgentsPaginated( export function usePrivateAgentsPaginated(
password: string | null, password: string | null,
params: { params: {
search?: string; search?: string;
tagIds?: string[]; tagIds?: string[];
page: number; sort?: AgentSort;
pageSize: number; page: number;
enabled?: boolean; pageSize: number;
}, enabled?: boolean;
) { },
const { page, pageSize, enabled = true } = params; ) {
const search = params.search ?? ""; const { page, pageSize, enabled = true } = params;
const tagIds = params.tagIds ?? []; const search = params.search ?? "";
const tagIds = params.tagIds ?? [];
const sort = params.sort ?? "time_desc";
const { data, isLoading, error, refetch } = useQuery({ const { data, isLoading, error, refetch } = useQuery({
queryKey: [ queryKey: [
"agents", "agents",
"private-square", "private-square",
password ?? "", password ?? "",
"paginated", "paginated",
search, search,
[...tagIds].sort(), [...tagIds].sort(),
page, sort,
pageSize, page,
pageSize,
], ],
queryFn: () => queryFn: () =>
listPrivateAgentsPaginated({ listPrivateAgentsPaginated({
password: password!, password: password!,
search, search,
tagIds, tagIds,
page, sort,
pageSize, page,
pageSize,
}), }),
enabled: !!password && enabled, enabled: !!password && enabled,
staleTime: 30_000, staleTime: 30_000,
+10 -7
View File
@@ -1,6 +1,6 @@
import type { Tag } from "@/core/tags"; import type { Tag } from "@/core/tags";
export interface Agent { export interface Agent {
id: string; id: string;
name: string; name: string;
description: string; description: string;
@@ -17,12 +17,15 @@ export interface Agent {
tags?: Tag[]; tags?: Tag[];
featured_order?: number | null; featured_order?: number | null;
/** Whether the current user has favorited (收藏) this agent. */ /** Whether the current user has favorited (收藏) this agent. */
is_favorite?: boolean; is_favorite?: boolean;
} is_pinned?: boolean;
}
export interface AgentSelectorResponse {
agents: Agent[]; export type AgentSort = "time_desc" | "time_asc" | "updated_desc" | "updated_asc" | "name_asc" | "name_desc";
}
export interface AgentSelectorResponse {
agents: Agent[];
}
/** Pagination envelope returned by the server-side paginated agent listings. */ /** Pagination envelope returned by the server-side paginated agent listings. */
export interface PaginatedAgents { export interface PaginatedAgents {
+8 -6
View File
@@ -3,15 +3,17 @@ import { apiFetch } from "@/core/api";
import type { Skill, SkillDisplayConfig, SkillState } from "./type"; import type { Skill, SkillDisplayConfig, SkillState } from "./type";
export interface ListSkillsParams { export interface ListSkillsParams {
search?: string; search?: string;
tagIds?: string[]; tagIds?: string[];
} sort?: "time_desc" | "time_asc" | "activity_desc" | "activity_asc" | "usage_desc" | "usage_asc" | "name_asc" | "name_desc";
}
export async function loadSkills(params: ListSkillsParams = {}) { export async function loadSkills(params: ListSkillsParams = {}) {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (params.search) qs.set("search", params.search); if (params.search) qs.set("search", params.search);
for (const id of params.tagIds ?? []) qs.append("tag_ids", id); for (const id of params.tagIds ?? []) qs.append("tag_ids", id);
if (params.sort) qs.set("sort", params.sort);
const query = qs.toString(); const query = qs.toString();
const skills = await apiFetch( const skills = await apiFetch(
`${getBackendBaseURL()}/api/skills${query ? `?${query}` : ""}`, `${getBackendBaseURL()}/api/skills${query ? `?${query}` : ""}`,
+10 -8
View File
@@ -1,4 +1,4 @@
import { import {
keepPreviousData, keepPreviousData,
useMutation, useMutation,
useQuery, useQuery,
@@ -22,7 +22,8 @@ import {
setSkillPublished, setSkillPublished,
setSkillState, setSkillState,
validateSkillUpload, validateSkillUpload,
} from "./api"; } from "./api";
import type { ListSkillsParams } from "./api";
import type { Skill, SkillState } from "./type"; import type { Skill, SkillState } from "./type";
import { loadAllSkillsAdmin, loadSkills } from "."; import { loadAllSkillsAdmin, loadSkills } from ".";
@@ -33,12 +34,13 @@ import { loadAllSkillsAdmin, loadSkills } from ".";
// forever (observed as a "Maximum update depth exceeded" loop on NewAgentPage). // forever (observed as a "Maximum update depth exceeded" loop on NewAgentPage).
const EMPTY_SKILLS: Skill[] = []; const EMPTY_SKILLS: Skill[] = [];
export function useSkills(params: { search?: string; tagIds?: string[] } = {}) { export function useSkills(params: ListSkillsParams = {}) {
const search = params.search ?? ""; const search = params.search ?? "";
const tagIds = params.tagIds ?? []; const tagIds = params.tagIds ?? [];
const { data, isLoading, error } = useQuery({ const sort = params.sort ?? "time_desc";
queryKey: ["skills", search, [...tagIds].sort()], const { data, isLoading, error } = useQuery({
queryFn: () => loadSkills({ search, tagIds }), queryKey: ["skills", search, [...tagIds].sort(), sort],
queryFn: () => loadSkills({ search, tagIds, sort }),
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
}); });
return { skills: data ?? EMPTY_SKILLS, isLoading, error }; return { skills: data ?? EMPTY_SKILLS, isLoading, error };
+1
View File
@@ -63,6 +63,7 @@ export interface Skill {
// Provenance — distinguishes skills created by an agent during a chat // Provenance — distinguishes skills created by an agent during a chat
// (``agent``) from skills the user uploaded / authored manually (``upload``). // (``agent``) from skills the user uploaded / authored manually (``upload``).
source?: SkillSource; source?: SkillSource;
created_at?: string | null;
last_activity_at?: string | null; last_activity_at?: string | null;
use_count?: number; use_count?: number;
view_count?: number; view_count?: number;
@@ -52,6 +52,25 @@ export function useUpdateUserPreferences() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: updateUserPreferences, mutationFn: updateUserPreferences,
// Optimistically flip the cached preference so the switch — and every
// surface that reacts to it (e.g. the chat input's admin prompt-prefix
// toggle) — updates instantly instead of waiting for the round-trip,
// which made the toggle look like it "didn't take effect / didn't refresh".
onMutate: async (input) => {
await queryClient.cancelQueries({ queryKey: QUERY_KEY });
const previous = queryClient.getQueryData<UserPreferences>(QUERY_KEY);
queryClient.setQueryData<UserPreferences>(QUERY_KEY, (old) => ({
...(old ?? DEFAULT_PREFS),
...input,
}));
return { previous };
},
onError: (_err, _input, context) => {
// Roll back to the pre-toggle value if the server rejected the change.
if (context?.previous) {
queryClient.setQueryData(QUERY_KEY, context.previous);
}
},
onSuccess: (data) => { onSuccess: (data) => {
queryClient.setQueryData(QUERY_KEY, data); queryClient.setQueryData(QUERY_KEY, data);
}, },
@@ -15,7 +15,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.gateway.auth.password import verify_password_async from app.gateway.auth.password import verify_password_async
from app.gateway.deps import get_agent_store, get_config, get_tag_store from app.gateway.deps import get_agent_store, get_config, get_tag_store
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
from deerflow.config.agents_api_config import get_agents_api_config from deerflow.config.agents_api_config import get_agents_api_config
from deerflow.config.agents_config import list_custom_agents, load_agent_config, load_agent_soul, validate_agent_id from deerflow.config.agents_config import list_custom_agents, load_agent_config, load_agent_soul, validate_agent_id
@@ -62,10 +62,15 @@ class AgentResponse(BaseModel):
is_favorite: bool = Field( is_favorite: bool = Field(
default=False, default=False,
description="Whether the requesting user has favorited (收藏) this agent.", description="Whether the requesting user has favorited (收藏) this agent.",
) )
is_pinned: bool = Field(
class AgentsListResponse(BaseModel): default=False,
description="Whether the requesting user has pinned this agent.",
)
class AgentsListResponse(BaseModel):
"""Response model for listing agents. """Response model for listing agents.
``total`` / ``page`` / ``page_size`` describe server-side pagination. When ``total`` / ``page`` / ``page_size`` describe server-side pagination. When
@@ -110,7 +115,7 @@ 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): class AgentSelectorResponse(BaseModel):
"""Response for the homepage agent selector dropdown. """Response for the homepage agent selector dropdown.
The list is fully curated by admins (``featured_order`` on each row). The list is fully curated by admins (``featured_order`` on each row).
@@ -120,10 +125,10 @@ class AgentSelectorResponse(BaseModel):
agents: list[AgentResponse] = Field( agents: list[AgentResponse] = Field(
default_factory=list, default_factory=list,
description="Admin-curated agents, sorted by featured_order ascending.", description="Admin-curated agents, sorted by featured_order ascending.",
) )
class AgentFeaturedRequest(BaseModel): class AgentFeaturedRequest(BaseModel):
"""Request body for setting/clearing an agent's homepage featured rank.""" """Request body for setting/clearing an agent's homepage featured rank."""
order: int | None = Field( order: int | None = Field(
@@ -343,7 +348,7 @@ def _ensure_agent_dir_from_record(record: dict[str, Any]) -> None:
logger.exception("Failed to self-heal agent dir for '%s'", agent_id) logger.exception("Failed to self-heal agent dir for '%s'", agent_id)
def _record_to_response( def _record_to_response(
record: dict[str, Any], record: dict[str, Any],
*, *,
include_soul: bool = False, include_soul: bool = False,
@@ -385,10 +390,21 @@ def _record_to_response(
tool_groups=extras["tool_groups"], tool_groups=extras["tool_groups"],
skills=extras["skills"], skills=extras["skills"],
featured_order=record.get("featured_order"), featured_order=record.get("featured_order"),
) )
async def _record_to_response_async( def _sort_agent_responses(responses: list[AgentResponse], sort: str) -> list[AgentResponse]:
reverse = sort not in {"time_asc", "updated_asc", "name_asc"}
if sort in {"name_asc", "name_desc"}:
ordered = sorted(responses, key=lambda r: (r.name or "").lower(), reverse=reverse)
elif sort in {"updated_desc", "updated_asc"}:
ordered = sorted(responses, key=lambda r: str(r.updated_at or ""), reverse=reverse)
else:
ordered = sorted(responses, key=lambda r: str(r.created_at or ""), reverse=reverse)
return sorted(ordered, key=lambda r: 0 if r.is_pinned else 1)
async def _record_to_response_async(
record: dict[str, Any], record: dict[str, Any],
*, *,
include_soul: bool = False, include_soul: bool = False,
@@ -462,18 +478,20 @@ async def _build_list_responses(
_record_to_response(a, include_soul=False, include_extras=False) for a in records _record_to_response(a, include_soul=False, include_extras=False) for a in records
] ]
ids = [r.id for r in responses] ids = [r.id for r in responses]
extras = await store.get_extras_for(ids) extras = await store.get_extras_for(ids)
assignments = await tag_store.list_assignments("agent", ids) assignments = await tag_store.list_assignments("agent", ids)
favorite_ids = await store.list_favorite_ids(user_id) favorite_ids = await store.list_favorite_ids(user_id)
for resp in responses: pin_ids = await store.list_pin_ids(user_id)
for resp in responses:
cached = extras.get(resp.id) cached = extras.get(resp.id)
if cached is not None: if cached is not None:
resp.skills = cached.get("skills") resp.skills = cached.get("skills")
resp.tool_groups = cached.get("tool_groups") resp.tool_groups = cached.get("tool_groups")
resp.model = cached.get("model") resp.model = cached.get("model")
resp.tags = assignments.get(resp.id, []) resp.tags = assignments.get(resp.id, [])
resp.is_favorite = resp.id in favorite_ids resp.is_favorite = resp.id in favorite_ids
return responses resp.is_pinned = resp.id in pin_ids
return responses
@router.get( @router.get(
@@ -491,9 +509,14 @@ async def list_agents(
pattern="^(mine|square)$", pattern="^(mine|square)$",
description="Pagination scope. 'mine' = owned + favorited; 'square' = built-in/published public. Omit for the default combined visibility list.", description="Pagination scope. 'mine' = owned + favorited; 'square' = built-in/published public. Omit for the default combined visibility list.",
), ),
page: int | None = Query(default=None, ge=1, description="1-based page index. Providing this (or 'scope') enables server-side pagination."), page: int | None = Query(default=None, ge=1, description="1-based page index. Providing this (or 'scope') enables server-side pagination."),
page_size: int = Query(default=20, ge=1, le=200, description="Items per page when paginating."), page_size: int = Query(default=20, ge=1, le=200, description="Items per page when paginating."),
admin: bool = Query( sort: str = Query(
default="time_desc",
pattern="^(time_desc|time_asc|updated_desc|updated_asc|name_asc|name_desc)$",
description="Sort order. Default is newest created first.",
),
admin: bool = Query(
default=False, default=False,
description=( description=(
"Admin-only override. When ``true`` and the caller is a system admin, " "Admin-only override. When ``true`` and the caller is a system admin, "
@@ -520,10 +543,11 @@ async def list_agents(
scope=scope, scope=scope,
search=search, search=search,
tag_ids=tag_ids, tag_ids=tag_ids,
page=eff_page, page=eff_page,
page_size=page_size, page_size=page_size,
admin=admin, admin=admin,
) sort=sort,
)
responses = await _build_list_responses(store, tag_store, user_id, rows) responses = await _build_list_responses(store, tag_store, user_id, rows)
return AgentsListResponse(agents=responses, total=total, page=eff_page, page_size=page_size) return AgentsListResponse(agents=responses, total=total, page=eff_page, page_size=page_size)
@@ -535,10 +559,11 @@ async def list_agents(
if search: if search:
needle = search.strip().lower() needle = search.strip().lower()
responses = [r for r in responses if needle in r.name.lower()] responses = [r for r in responses if needle in r.name.lower()]
if tag_ids: if tag_ids:
wanted = set(tag_ids) wanted = set(tag_ids)
responses = [r for r in responses if any(t.get("id") in wanted for t in r.tags)] responses = [r for r in responses if any(t.get("id") in wanted for t in r.tags)]
return AgentsListResponse(agents=responses, total=len(responses), page=1, page_size=len(responses)) responses = _sort_agent_responses(responses, sort)
return AgentsListResponse(agents=responses, total=len(responses), page=1, page_size=len(responses))
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@@ -551,14 +576,14 @@ async def list_agents(
summary="Check Agent Name", summary="Check Agent Name",
description="Validate an agent display name. Duplicate names are allowed.", description="Validate an agent display name. Duplicate names are allowed.",
) )
async def check_agent_name(name: str) -> dict[str, Any]: async def check_agent_name(name: str) -> dict[str, Any]:
_require_agents_api_enabled() _require_agents_api_enabled()
normalized = _validate_display_name(name) normalized = _validate_display_name(name)
return {"available": True, "name": normalized} return {"available": True, "name": normalized}
@router.get( @router.get(
"/agents/selector", "/agents/selector",
response_model=AgentSelectorResponse, response_model=AgentSelectorResponse,
summary="Agent Homepage Selector", summary="Agent Homepage Selector",
description=( description=(
@@ -610,10 +635,12 @@ async def get_agent(agent_id: str, request: Request) -> AgentResponse:
# Detail view legitimately reads SOUL.md + config.yaml — hop to a # Detail view legitimately reads SOUL.md + config.yaml — hop to a
# worker thread so the asyncio loop stays responsive under concurrent # worker thread so the asyncio loop stays responsive under concurrent
# detail fetches. # detail fetches.
response = await _record_to_response_async(record, include_soul=True, include_extras=True) response = await _record_to_response_async(record, include_soul=True, include_extras=True)
favorite_ids = await store.list_favorite_ids(user_id) favorite_ids = await store.list_favorite_ids(user_id)
response.is_favorite = agent_id in favorite_ids pin_ids = await store.list_pin_ids(user_id)
return response response.is_favorite = agent_id in favorite_ids
response.is_pinned = agent_id in pin_ids
return response
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@@ -847,12 +874,16 @@ async def delete_agent(agent_id: str, request: Request) -> None:
await get_tag_store(request).unassign_all("agent", agent_id) await get_tag_store(request).unassign_all("agent", agent_id)
except Exception: # noqa: BLE001 — tag cleanup must not block deletion except Exception: # noqa: BLE001 — tag cleanup must not block deletion
pass pass
try: try:
await store.delete_favorites_for_agent(agent_id) await store.delete_favorites_for_agent(agent_id)
except Exception: # noqa: BLE001 — favorite cleanup must not block deletion except Exception: # noqa: BLE001 — favorite cleanup must not block deletion
pass pass
try: try:
await store.delete_extras_for_agent(agent_id) await store.delete_pins_for_agent(agent_id)
except Exception: # noqa: BLE001 - pin cleanup must not block deletion
pass
try:
await store.delete_extras_for_agent(agent_id)
except Exception: # noqa: BLE001 — cache cleanup must not block deletion except Exception: # noqa: BLE001 — cache cleanup must not block deletion
pass pass
logger.info("Deleted agent '%s' from %s", agent_id, agent_dir) logger.info("Deleted agent '%s' from %s", agent_id, agent_dir)
@@ -894,7 +925,7 @@ async def favorite_agent(agent_id: str, request: Request) -> None:
summary="Unfavorite Agent", summary="Unfavorite Agent",
description="Remove an agent from the current user's favorites. No-op when not favorited.", description="Remove an agent from the current user's favorites. No-op when not favorited.",
) )
async def unfavorite_agent(agent_id: str, request: Request) -> None: async def unfavorite_agent(agent_id: str, request: Request) -> None:
_require_agents_api_enabled() _require_agents_api_enabled()
agent_id = _validate_storage_id(agent_id) agent_id = _validate_storage_id(agent_id)
@@ -905,13 +936,60 @@ async def unfavorite_agent(agent_id: str, request: Request) -> None:
logger.info("User '%s' unfavorited agent '%s'", user_id, agent_id) logger.info("User '%s' unfavorited agent '%s'", user_id, agent_id)
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
logger.error("Failed to unfavorite agent '%s': %s", agent_id, e, exc_info=True) logger.error("Failed to unfavorite agent '%s': %s", agent_id, e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to unfavorite agent: {str(e)}") from e raise HTTPException(status_code=500, detail=f"Failed to unfavorite agent: {str(e)}") from e
@router.put( @router.post(
"/agents/{agent_id}/featured", "/agents/{agent_id}/pin",
status_code=204,
summary="Pin Agent",
description="Pin a visible agent to the top of the current user's lists. Idempotent.",
)
async def pin_agent(agent_id: str, request: Request) -> None:
_require_agents_api_enabled()
agent_id = _validate_storage_id(agent_id)
try:
store = get_agent_store(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")
await store.add_pin(user_id, agent_id)
logger.info("User '%s' pinned agent '%s'", user_id, agent_id)
except HTTPException:
raise
except Exception as e:
logger.error("Failed to pin agent '%s': %s", agent_id, e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to pin agent: {str(e)}") from e
@router.delete(
"/agents/{agent_id}/pin",
status_code=204,
summary="Unpin Agent",
description="Remove the current user's pin from an agent. No-op when not pinned.",
)
async def unpin_agent(agent_id: str, request: Request) -> None:
_require_agents_api_enabled()
agent_id = _validate_storage_id(agent_id)
try:
store = get_agent_store(request)
user_id = _current_user_id(request)
await store.remove_pin(user_id, agent_id)
logger.info("User '%s' unpinned agent '%s'", user_id, agent_id)
except HTTPException:
raise
except Exception as e:
logger.error("Failed to unpin agent '%s': %s", agent_id, e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to unpin agent: {str(e)}") from e
@router.put(
"/agents/{agent_id}/featured",
response_model=AgentResponse, response_model=AgentResponse,
summary="Set Agent Featured Rank (admin)", summary="Set Agent Featured Rank (admin)",
description=( description=(
@@ -953,14 +1031,19 @@ async def set_agent_featured(
raise HTTPException(status_code=500, detail=f"Failed to set featured: {str(e)}") from e raise HTTPException(status_code=500, detail=f"Failed to set featured: {str(e)}") from e
class PrivateSquareListRequest(BaseModel): class PrivateSquareListRequest(BaseModel):
"""Request body for listing all private-square agents.""" """Request body for listing all private-square agents."""
password: str = Field(..., description="Plain-text private-square password. Verified against the admin-configured hash before the listing is returned.") password: str = Field(..., description="Plain-text private-square password. Verified against the admin-configured hash before the listing is returned.")
search: str | None = Field(default=None, description="Case-insensitive fuzzy match on agent name (raw or desensitized).") search: str | None = Field(default=None, description="Case-insensitive fuzzy match on agent name (raw or desensitized).")
tag_ids: list[str] | None = Field(default=None, description="Keep agents carrying any of these tags (OR).") tag_ids: list[str] | None = Field(default=None, description="Keep agents carrying any of these tags (OR).")
page: int | None = Field(default=None, ge=1, description="1-based page index. Providing this enables server-side pagination.") page: int | None = Field(default=None, ge=1, description="1-based page index. Providing this enables server-side pagination.")
page_size: int = Field(default=20, ge=1, le=200, description="Items per page when paginating.") page_size: int = Field(default=20, ge=1, le=200, description="Items per page when paginating.")
sort: str = Field(
default="time_desc",
pattern="^(time_desc|time_asc|updated_desc|updated_asc|name_asc|name_desc)$",
description="Sort order. Default is newest created first.",
)
class AgentPrivateUpdateRequest(BaseModel): class AgentPrivateUpdateRequest(BaseModel):
@@ -1003,15 +1086,17 @@ async def list_private_square(body: PrivateSquareListRequest, request: Request)
scope="private", scope="private",
search=body.search, search=body.search,
tag_ids=body.tag_ids, tag_ids=body.tag_ids,
page=body.page, page=body.page,
page_size=body.page_size, page_size=body.page_size,
) sort=body.sort,
)
responses = await _build_list_responses(store, tag_store, user_id, rows) responses = await _build_list_responses(store, tag_store, user_id, rows)
return AgentsListResponse(agents=responses, total=total, page=body.page, page_size=body.page_size) return AgentsListResponse(agents=responses, total=total, page=body.page, page_size=body.page_size)
records = await store.list_private() records = await store.list_private()
responses = await _build_list_responses(store, tag_store, user_id, records) responses = await _build_list_responses(store, tag_store, user_id, records)
return AgentsListResponse(agents=responses, total=len(responses), page=1, page_size=len(responses)) responses = _sort_agent_responses(responses, body.sort)
return AgentsListResponse(agents=responses, total=len(responses), page=1, page_size=len(responses))
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@@ -7,7 +7,7 @@ import re
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
from datetime import datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -119,6 +119,7 @@ class SkillResponse(BaseModel):
view_count: int = Field(default=0, description="Number of times the skill has been viewed") view_count: int = Field(default=0, description="Number of times the skill has been viewed")
patch_count: int = Field(default=0, description="Number of times the skill has been patched") patch_count: int = Field(default=0, description="Number of times the skill has been patched")
last_activity_at: datetime | None = Field(default=None, description="Most recent activity timestamp") last_activity_at: datetime | None = Field(default=None, description="Most recent activity timestamp")
created_at: datetime | None = Field(default=None, description="Skill creation timestamp when known")
state: str = Field(default="active", description="Lifecycle state: active, stale, or archived") state: str = Field(default="active", description="Lifecycle state: active, stale, or archived")
pinned: bool = Field(default=False, description="Whether this skill is pinned (excluded from curation)") pinned: bool = Field(default=False, description="Whether this skill is pinned (excluded from curation)")
source: str = Field(default="upload", description="Origin: 'agent' for agent-created, 'upload' for user-uploaded") source: str = Field(default="upload", description="Origin: 'agent' for agent-created, 'upload' for user-uploaded")
@@ -241,8 +242,20 @@ def _skill_to_response(
from deerflow.skills.usage import get_entry, latest_activity_at from deerflow.skills.usage import get_entry, latest_activity_at
entry = get_entry(skill.name) entry = get_entry(skill.name)
created_at_str = entry.get("created_at")
last_at_str = latest_activity_at(skill.name) last_at_str = latest_activity_at(skill.name)
created_at: datetime | None = None
last_at: datetime | None = None last_at: datetime | None = None
if created_at_str:
try:
created_at = datetime.fromisoformat(created_at_str)
except Exception:
pass
if created_at is None:
try:
created_at = datetime.fromtimestamp(skill.skill_file.stat().st_mtime, tz=UTC)
except Exception:
pass
if last_at_str: if last_at_str:
try: try:
last_at = datetime.fromisoformat(last_at_str) last_at = datetime.fromisoformat(last_at_str)
@@ -278,6 +291,7 @@ def _skill_to_response(
view_count=entry.get("view_count", 0), view_count=entry.get("view_count", 0),
patch_count=entry.get("patch_count", 0), patch_count=entry.get("patch_count", 0),
last_activity_at=last_at, last_activity_at=last_at,
created_at=created_at,
state=entry.get("state", "active"), state=entry.get("state", "active"),
pinned=entry.get("pinned", False), pinned=entry.get("pinned", False),
source=entry.get("source", "upload"), source=entry.get("source", "upload"),
@@ -688,6 +702,17 @@ async def _attach_and_filter_tags(
return responses return responses
def _sort_skill_responses(responses: list[SkillResponse], sort: str) -> list[SkillResponse]:
reverse = sort not in {"time_asc", "activity_asc", "name_asc", "usage_asc"}
if sort in {"name_asc", "name_desc"}:
return sorted(responses, key=lambda r: (r.name or "").lower(), reverse=reverse)
if sort in {"usage_desc", "usage_asc"}:
return sorted(responses, key=lambda r: int(r.use_count or 0), reverse=reverse)
if sort in {"activity_desc", "activity_asc"}:
return sorted(responses, key=lambda r: str(r.last_activity_at or r.created_at or ""), reverse=reverse)
return sorted(responses, key=lambda r: str(r.created_at or r.last_activity_at or ""), reverse=reverse)
@router.get( @router.get(
"/skills", "/skills",
response_model=SkillsListResponse, response_model=SkillsListResponse,
@@ -701,6 +726,11 @@ async def list_skills(
tag_store: TagStore = Depends(get_tag_store), tag_store: TagStore = Depends(get_tag_store),
search: str | None = Query(default=None, description="Case-insensitive fuzzy match on skill name"), search: str | None = Query(default=None, description="Case-insensitive fuzzy match on skill name"),
tag_ids: list[str] | None = Query(default=None, description="Keep skills carrying any of these tags (OR)"), tag_ids: list[str] | None = Query(default=None, description="Keep skills carrying any of these tags (OR)"),
sort: str = Query(
default="time_desc",
pattern="^(time_desc|time_asc|activity_desc|activity_asc|usage_desc|usage_asc|name_asc|name_desc)$",
description="Sort order. Default is newest created/modified first.",
),
admin: bool = Query( admin: bool = Query(
default=False, default=False,
description=( description=(
@@ -754,6 +784,7 @@ async def list_skills(
) )
) )
responses = await _attach_and_filter_tags(tag_store, responses, search, tag_ids) responses = await _attach_and_filter_tags(tag_store, responses, search, tag_ids)
responses = _sort_skill_responses(responses, sort)
return SkillsListResponse(skills=responses) return SkillsListResponse(skills=responses)
except HTTPException: except HTTPException:
raise raise
@@ -119,17 +119,18 @@ class AgentStore(ABC):
raise NotImplementedError raise NotImplementedError
@abstractmethod @abstractmethod
async def list_paginated( async def list_paginated(
self, self,
*, *,
user_id: str, user_id: str,
scope: str | None, scope: str | None,
search: str | None, search: str | None,
tag_ids: list[str] | None, tag_ids: list[str] | None,
page: int, page: int,
page_size: int, page_size: int,
admin: bool = False, admin: bool = False,
) -> tuple[list[dict[str, Any]], int]: sort: str = "time_desc",
) -> tuple[list[dict[str, Any]], int]:
"""Return one page of agents plus the total matching count. """Return one page of agents plus the total matching count.
``scope``: ``scope``:
@@ -145,9 +146,9 @@ class AgentStore(ABC):
the COUNT, and LIMIT/OFFSET all execute in SQL. the COUNT, and LIMIT/OFFSET all execute in SQL.
Returns ``(rows, total)`` where ``rows`` is at most ``page_size`` items Returns ``(rows, total)`` where ``rows`` is at most ``page_size`` items
for the 1-based ``page``. for the 1-based ``page``.
""" """
raise NotImplementedError raise NotImplementedError
# --- Listing cache (skills / tool_groups / model) --- # --- Listing cache (skills / tool_groups / model) ---
@@ -198,6 +199,28 @@ class AgentStore(ABC):
raise NotImplementedError raise NotImplementedError
@abstractmethod @abstractmethod
async def delete_favorites_for_agent(self, agent_id: str) -> None: async def delete_favorites_for_agent(self, agent_id: str) -> None:
"""Drop every user's favorite of ``agent_id`` (called on agent delete).""" """Drop every user's favorite of ``agent_id`` (called on agent delete)."""
raise NotImplementedError raise NotImplementedError
# --- Per-user pins ("置顶") ---
@abstractmethod
async def add_pin(self, user_id: str, agent_id: str) -> None:
"""Pin ``agent_id`` for ``user_id``. Idempotent."""
raise NotImplementedError
@abstractmethod
async def remove_pin(self, user_id: str, agent_id: str) -> bool:
"""Remove a per-user pin. Returns ``False`` when none existed."""
raise NotImplementedError
@abstractmethod
async def list_pin_ids(self, user_id: str) -> set[str]:
"""Return the set of agent ids pinned by ``user_id``."""
raise NotImplementedError
@abstractmethod
async def delete_pins_for_agent(self, agent_id: str) -> None:
"""Drop every user's pin of ``agent_id`` (called on agent delete)."""
raise NotImplementedError
@@ -21,8 +21,9 @@ def _copy(data: dict[str, Any]) -> dict[str, Any]:
class MemoryAgentStore(AgentStore): class MemoryAgentStore(AgentStore):
def __init__(self) -> None: def __init__(self) -> None:
self._agents: dict[str, dict[str, Any]] = {} self._agents: dict[str, dict[str, Any]] = {}
self._favorites: set[tuple[str, str]] = set() self._favorites: set[tuple[str, str]] = set()
self._pins: set[tuple[str, str]] = set()
# Listing cache mirrors (see AgentExtrasRow / AgentSkillRow): # Listing cache mirrors (see AgentExtrasRow / AgentSkillRow):
self._skills: dict[str, list[str]] = {} self._skills: dict[str, list[str]] = {}
self._extras: dict[str, dict[str, Any]] = {} self._extras: dict[str, dict[str, Any]] = {}
@@ -185,17 +186,18 @@ class MemoryAgentStore(AgentStore):
return bool(agent.get("is_private")) return bool(agent.get("is_private"))
return self._is_visible(agent, user_id) return self._is_visible(agent, user_id)
async def list_paginated( async def list_paginated(
self, self,
*, *,
user_id: str, user_id: str,
scope: str | None, scope: str | None,
search: str | None, search: str | None,
tag_ids: list[str] | None, tag_ids: list[str] | None,
page: int, page: int,
page_size: int, page_size: int,
admin: bool = False, admin: bool = False,
) -> tuple[list[dict[str, Any]], int]: sort: str = "time_desc",
) -> tuple[list[dict[str, Any]], int]:
# NOTE: the in-memory store has no tag-assignment data, so ``tag_ids`` # NOTE: the in-memory store has no tag-assignment data, so ``tag_ids``
# is ignored here. Tag-filter behavior is covered by the SQL repo tests. # is ignored here. Tag-filter behavior is covered by the SQL repo tests.
matched = [ matched = [
@@ -204,9 +206,31 @@ class MemoryAgentStore(AgentStore):
if self._matches_scope(a, user_id=user_id, scope=scope, admin=admin) if self._matches_scope(a, user_id=user_id, scope=scope, admin=admin)
and (not search or not search.strip() or name_matches(a.get("name") or "", search)) and (not search or not search.strip() or name_matches(a.get("name") or "", search))
] ]
matched.sort(key=lambda a: (a.get("created_at") or "", a.get("id") or "")) if sort == "time_asc":
total = len(matched) matched.sort(key=lambda a: (a.get("created_at") or "", a.get("id") or ""))
start = max(page - 1, 0) * page_size elif sort == "updated_desc":
matched.sort(
key=lambda a: (a.get("updated_at") or "", a.get("id") or ""),
reverse=True,
)
elif sort == "updated_asc":
matched.sort(key=lambda a: (a.get("updated_at") or "", a.get("id") or ""))
elif sort == "name_asc":
matched.sort(key=lambda a: ((a.get("name") or "").lower(), a.get("id") or ""))
elif sort == "name_desc":
matched.sort(
key=lambda a: ((a.get("name") or "").lower(), a.get("id") or ""),
reverse=True,
)
else:
matched.sort(
key=lambda a: (a.get("created_at") or "", a.get("id") or ""),
reverse=True,
)
pin_ids = {agent_id for uid, agent_id in self._pins if uid == user_id}
matched.sort(key=lambda a: (0 if a.get("id") in pin_ids else 1,))
total = len(matched)
start = max(page - 1, 0) * page_size
page_rows = matched[start : start + page_size] page_rows = matched[start : start + page_size]
return [_copy(a) for a in page_rows], total return [_copy(a) for a in page_rows], total
@@ -251,5 +275,23 @@ class MemoryAgentStore(AgentStore):
async def list_favorite_ids(self, user_id: str) -> set[str]: async def list_favorite_ids(self, user_id: str) -> set[str]:
return {agent_id for (uid, agent_id) in self._favorites if uid == user_id} return {agent_id for (uid, agent_id) in self._favorites if uid == user_id}
async def delete_favorites_for_agent(self, agent_id: str) -> None: async def delete_favorites_for_agent(self, agent_id: str) -> None:
self._favorites = {(uid, aid) for (uid, aid) in self._favorites if aid != agent_id} self._favorites = {(uid, aid) for (uid, aid) in self._favorites if aid != agent_id}
# --- Pins ("置顶") ---
async def add_pin(self, user_id: str, agent_id: str) -> None:
self._pins.add((user_id, agent_id))
async def remove_pin(self, user_id: str, agent_id: str) -> bool:
key = (user_id, agent_id)
if key not in self._pins:
return False
self._pins.remove(key)
return True
async def list_pin_ids(self, user_id: str) -> set[str]:
return {agent_id for uid, agent_id in self._pins if uid == user_id}
async def delete_pins_for_agent(self, agent_id: str) -> None:
self._pins = {(uid, aid) for (uid, aid) in self._pins if aid != agent_id}
@@ -37,7 +37,7 @@ class AgentRow(Base):
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))
class AgentFavoriteRow(Base): class AgentFavoriteRow(Base):
"""A user's bookmark of a (typically public-square) agent. """A user's bookmark of a (typically public-square) agent.
Lets a user collect agents from the 广场 (square) into their "我的" tab Lets a user collect agents from the 广场 (square) into their "我的" tab
@@ -51,10 +51,22 @@ class AgentFavoriteRow(Base):
id: Mapped[str] = mapped_column(String(64), primary_key=True) id: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
agent_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) agent_id: Mapped[str] = mapped_column(String(64), nullable=False, 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))
class AgentSkillRow(Base): class AgentPinRow(Base):
"""A per-user pin that keeps an agent near the top of that user's lists."""
__tablename__ = "agent_pins"
__table_args__ = (UniqueConstraint("user_id", "agent_id", name="uq_agent_pins_user_agent"),)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
agent_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
class AgentSkillRow(Base):
"""Denormalized cache of a custom agent's selected skills. """Denormalized cache of a custom agent's selected skills.
``config.yaml`` under ``.deer-flow/agents/{id}/`` stays the source of truth ``config.yaml`` under ``.deer-flow/agents/{id}/`` stays the source of truth
@@ -6,18 +6,19 @@ from datetime import UTC, datetime
from typing import Any from typing import Any
from uuid import uuid4 from uuid import uuid4
from sqlalchemy import and_, delete, func, or_, select from sqlalchemy import and_, case, delete, func, or_, select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.config.agent_name_desensitize import source_terms_for_needle from deerflow.config.agent_name_desensitize import source_terms_for_needle
from deerflow.persistence.agents.base import AgentStore from deerflow.persistence.agents.base import AgentStore
from deerflow.persistence.agents.model import ( from deerflow.persistence.agents.model import (
AgentExtrasRow, AgentExtrasRow,
AgentFavoriteRow, AgentFavoriteRow,
AgentRow, AgentPinRow,
AgentSkillRow, AgentRow,
) AgentSkillRow,
)
from deerflow.persistence.tags.model import TagAssignmentRow from deerflow.persistence.tags.model import TagAssignmentRow
@@ -277,17 +278,18 @@ class AgentRepository(AgentStore):
) )
return AgentRow.id.in_(subq) return AgentRow.id.in_(subq)
async def list_paginated( async def list_paginated(
self, self,
*, *,
user_id: str, user_id: str,
scope: str | None, scope: str | None,
search: str | None, search: str | None,
tag_ids: list[str] | None, tag_ids: list[str] | None,
page: int, page: int,
page_size: int, page_size: int,
admin: bool = False, admin: bool = False,
) -> tuple[list[dict[str, Any]], int]: sort: str = "time_desc",
) -> tuple[list[dict[str, Any]], int]:
filters = [] filters = []
scope_filter = self._scope_filter(user_id=user_id, scope=scope, admin=admin) scope_filter = self._scope_filter(user_id=user_id, scope=scope, admin=admin)
if scope_filter is not None: if scope_filter is not None:
@@ -298,14 +300,28 @@ class AgentRepository(AgentStore):
filters.append(self._tag_clause(tag_ids)) filters.append(self._tag_clause(tag_ids))
count_stmt = select(func.count()).select_from(AgentRow) count_stmt = select(func.count()).select_from(AgentRow)
page_stmt = ( pin_subq = select(AgentPinRow.agent_id).where(AgentPinRow.user_id == user_id)
select(AgentRow) pin_rank = case((AgentRow.id.in_(pin_subq), 0), else_=1)
# ``id`` is a stable tiebreaker on top of ``created_at`` so OFFSET
# paging is deterministic when bulk-seeded built-ins share a timestamp. if sort == "time_asc":
.order_by(AgentRow.created_at.asc(), AgentRow.id.asc()) order_by = (AgentRow.created_at.asc(), AgentRow.id.asc())
.limit(page_size) elif sort == "updated_desc":
.offset(max(page - 1, 0) * page_size) order_by = (AgentRow.updated_at.desc(), AgentRow.id.asc())
) elif sort == "updated_asc":
order_by = (AgentRow.updated_at.asc(), AgentRow.id.asc())
elif sort == "name_asc":
order_by = (func.lower(AgentRow.name).asc(), AgentRow.id.asc())
elif sort == "name_desc":
order_by = (func.lower(AgentRow.name).desc(), AgentRow.id.asc())
else:
order_by = (AgentRow.created_at.desc(), AgentRow.id.asc())
page_stmt = (
select(AgentRow)
.order_by(pin_rank.asc(), *order_by)
.limit(page_size)
.offset(max(page - 1, 0) * page_size)
)
if filters: if filters:
where = and_(*filters) where = and_(*filters)
count_stmt = count_stmt.where(where) count_stmt = count_stmt.where(where)
@@ -425,7 +441,49 @@ class AgentRepository(AgentStore):
) )
return {row[0] for row in result.all()} return {row[0] for row in result.all()}
async def delete_favorites_for_agent(self, agent_id: str) -> None: async def delete_favorites_for_agent(self, agent_id: str) -> None:
async with self._sf() as session: async with self._sf() as session:
await session.execute(delete(AgentFavoriteRow).where(AgentFavoriteRow.agent_id == agent_id)) await session.execute(delete(AgentFavoriteRow).where(AgentFavoriteRow.agent_id == agent_id))
await session.commit() await session.commit()
# --- Pins ("置顶") ---
async def add_pin(self, user_id: str, agent_id: str) -> None:
async with self._sf() as session:
existing = (
await session.execute(
select(AgentPinRow.id)
.where(AgentPinRow.user_id == user_id, AgentPinRow.agent_id == agent_id)
.limit(1)
)
).scalar_one_or_none()
if existing is not None:
return
session.add(AgentPinRow(id=uuid4().hex, user_id=user_id, agent_id=agent_id))
try:
await session.commit()
except IntegrityError:
await session.rollback()
async def remove_pin(self, user_id: str, agent_id: str) -> bool:
async with self._sf() as session:
result = await session.execute(
delete(AgentPinRow).where(
AgentPinRow.user_id == user_id,
AgentPinRow.agent_id == agent_id,
)
)
await session.commit()
return (result.rowcount or 0) > 0
async def list_pin_ids(self, user_id: str) -> set[str]:
async with self._sf() as session:
result = await session.execute(
select(AgentPinRow.agent_id).where(AgentPinRow.user_id == user_id)
)
return {row[0] for row in result.all()}
async def delete_pins_for_agent(self, agent_id: str) -> None:
async with self._sf() as session:
await session.execute(delete(AgentPinRow).where(AgentPinRow.agent_id == agent_id))
await session.commit()
@@ -0,0 +1,56 @@
"""Per-user pinned agents.
Revision ID: 20260604_01
Revises: 20260603_03
Create Date: 2026-06-04
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260604_01"
down_revision = "20260603_03"
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 not _has_table("agent_pins"):
op.create_table(
"agent_pins",
sa.Column("id", sa.String(64), nullable=False),
sa.Column("user_id", sa.String(64), nullable=False),
sa.Column("agent_id", sa.String(64), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user_id", "agent_id", name="uq_agent_pins_user_agent"),
)
if not _has_index("agent_pins", "ix_agent_pins_user_id"):
op.create_index("ix_agent_pins_user_id", "agent_pins", ["user_id"])
if not _has_index("agent_pins", "ix_agent_pins_agent_id"):
op.create_index("ix_agent_pins_agent_id", "agent_pins", ["agent_id"])
def downgrade() -> None:
if _has_index("agent_pins", "ix_agent_pins_agent_id"):
op.drop_index("ix_agent_pins_agent_id", table_name="agent_pins")
if _has_index("agent_pins", "ix_agent_pins_user_id"):
op.drop_index("ix_agent_pins_user_id", table_name="agent_pins")
if _has_table("agent_pins"):
op.drop_table("agent_pins")
+22
View File
@@ -137,6 +137,28 @@ title:
User: {user_msg} User: {user_msg}
Assistant: {assistant_msg} Assistant: {assistant_msg}
# 上下文压缩(对话摘要):长对话接近上下文上限时,把较早的消息总结成一段摘要,
# 只保留最近若干条原始消息,避免撑爆模型上下文窗口。
# - trigger:触发条件,可配多个,任意一个先满足就压缩(OR 关系)。
# type=tokens 按 token 总数(最准,直接防溢出)
# type=messages 按消息条数(简单直观,作兜底保险)
# type=fraction 按模型上下文窗口的比例(如 0.8);自定义 base_url 模型可能探不到
# 准确窗口,故此处用写死的 token 阈值更稳妥。
# - keep:压缩后保留多少最近的上下文(同样支持 tokens / messages / fraction)。
# - trim_tokens_to_summarize:喂给摘要模型的消息最多裁到多少 token。
# - model_name:生成摘要用的模型(留空则用主对话模型)。
summarization:
enabled: true
# model_name: deepseek-chat
trigger:
- type: tokens
value: 60000
- type: messages
value: 80
keep:
type: messages
value: 20
trim_tokens_to_summarize: 4000
skills: skills:
path: /app/skills path: /app/skills
security_scan: security_scan: