feat: 支持智能体置顶配置

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

No files matched your search

@@ -7,6 +7,7 @@ import {
InfoIcon,
LockIcon,
MessageSquareIcon,
PinIcon,
ShieldIcon,
SparklesIcon,
SquarePenIcon,
@@ -56,6 +57,7 @@ import {
useSetAgentFeatured,
useSetAgentPrivate,
useToggleAgentFavorite,
useToggleAgentPin,
useUpdateAgent,
} from "@/core/agents";
import { getAgent } from "@/core/agents/api";
@@ -188,6 +190,7 @@ export function AgentCard({ agent }: AgentCardProps) {
const setFeatured = useSetAgentFeatured();
const setPrivate = useSetAgentPrivate();
const toggleFavorite = useToggleAgentFavorite();
const togglePin = useToggleAgentPin();
const { skills, isLoading: isSkillsLoading } = useSkills();
const [deleteOpen, setDeleteOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
@@ -226,6 +229,7 @@ export function AgentCard({ agent }: AgentCardProps) {
// already own, so only offer it on built-in / others' agents.
const canFavorite = !isOwned;
const isFavorite = !!agent.is_favorite;
const isPinned = !!agent.is_pinned;
useEffect(() => {
if (!editOpen) {
@@ -389,6 +393,16 @@ export function AgentCard({ agent }: AgentCardProps) {
}
}
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() {
// 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
@@ -569,6 +583,20 @@ export function AgentCard({ agent }: AgentCardProps) {
/>
</Button>
)}
<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
size="icon"
@@ -1,6 +1,6 @@
"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 { useEffect, useState } from "react";
import { toast } from "sonner";
@@ -8,10 +8,11 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
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 { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
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 { usePrivateSquareStatus } from "@/core/system-settings/hooks";
@@ -32,6 +33,7 @@ export function AgentGallery() {
);
const [search, setSearch] = useState("");
const [tagIds, setTagIds] = useState<string[]>([]);
const [sort, setSort] = useState<AgentSort>("time_desc");
// Per-tab 1-based page index, preserved while switching tabs.
const [pages, setPages] = useState<{ all: number; mine: number; private: number }>(
{ all: 1, mine: 1, private: 1 },
@@ -44,7 +46,7 @@ export function AgentGallery() {
const tagKey = tagIds.join(",");
useEffect(() => {
setPages({ all: 1, mine: 1, private: 1 });
}, [search, tagKey]);
}, [search, tagKey, sort]);
const { data: privateStatus } = usePrivateSquareStatus();
const privateFeatureAvailable = !!privateStatus?.enabled;
@@ -69,6 +71,7 @@ export function AgentGallery() {
scope: mainScope,
search,
tagIds,
sort,
page: mainPage,
pageSize: PAGE_SIZE,
enabled: activeTab !== "private",
@@ -82,6 +85,7 @@ export function AgentGallery() {
} = usePrivateAgentsPaginated(privatePassword, {
search,
tagIds,
sort,
page: pages.private,
pageSize: PAGE_SIZE,
enabled: activeTab === "private",
@@ -98,7 +102,6 @@ export function AgentGallery() {
}, [privateError, privatePassword]);
const activeTotal = activeTab === "private" ? privateTotal : mainTotal;
const setTabPage = (tab: "all" | "mine" | "private", page: number) =>
setPages((prev) => ({ ...prev, [tab]: page }));
@@ -240,6 +243,15 @@ export function AgentGallery() {
onKeyDown={(e) => {
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>
</div>
@@ -314,6 +326,25 @@ export function AgentGallery() {
onSearchChange={setSearch}
tagIds={tagIds}
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>
@@ -7,7 +7,7 @@ import {
Settings2Icon,
Trash2Icon,
} from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -193,6 +193,19 @@ function ManagePromptsDialog({
const agentNameById = (id?: string | 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 = () => {
setEditingId(null);
setTitle("");
@@ -299,23 +312,27 @@ function ManagePromptsDialog({
<div className="flex flex-col gap-1">
<p className="text-muted-foreground text-xs">关联智能体(可选)</p>
<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="不关联(所有对话可见)" />
</SelectTrigger>
<SelectContent
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>
{agents.map((agent) => (
<SelectItem key={agent.id} value={agent.id}>
<span className="block truncate">{desensitize(agent.name)}</span>
{selectableAgents.map((agent) => (
<SelectItem key={agent.id} value={agent.id} className="pr-8">
<span className="block max-w-full truncate">
{desensitize(agent.name)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
关联后,仅在选中该智能体的对话里显示此提示词。
{selectableAgents.length === 0
? "仅可关联你收藏到「我的」的常用智能体;当前还没有收藏任何智能体。"
: "关联后,仅在选中该智能体的对话里显示此提示词。这里只列出你收藏的常用智能体。"}
</p>
</div>
<div className="flex justify-end gap-2">
@@ -3,6 +3,7 @@
import {
AlertCircle,
ArchiveIcon,
ArrowDownUpIcon,
CheckCircle2,
ChevronLeftIcon,
ChevronRightIcon,
@@ -52,6 +53,7 @@ import {
ItemDescription,
} from "@/components/ui/item";
import { Input } from "@/components/ui/input";
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 { TagChipList } from "@/components/workspace/tags/tag-chip";
@@ -92,6 +94,15 @@ export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
}
const PAGE_SIZE = 10;
type SkillSort =
| "time_desc"
| "time_asc"
| "activity_desc"
| "activity_asc"
| "usage_desc"
| "usage_asc"
| "name_asc"
| "name_desc";
/** 重新扫描成功 toast:仅暗色模式使用,浅色走 Sonner 默认样式 */
const rescanSuccessToastDarkOptions = {
@@ -136,10 +147,11 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
const [archiveSheetOpen, setArchiveSheetOpen] = useState(false);
const [search, setSearch] = useState("");
const [tagIds, setTagIds] = useState<string[]>([]);
const [sort, setSort] = useState<SkillSort>("time_desc");
const [page, setPage] = useState(1);
const reconcileMutation = useReconcileCustomSkills();
// Name + tag filtering is resolved server-side via the /api/skills query.
const { skills, isLoading, error } = useSkills({ search, tagIds });
const { skills, isLoading, error } = useSkills({ search, tagIds, sort });
const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null;
@@ -194,6 +206,9 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
useEffect(() => {
if (page > totalPages) setPage(totalPages);
}, [page, totalPages]);
useEffect(() => {
setPage(1);
}, [search, tagIds, sort]);
const pagedSkills = useMemo(
() => filteredSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
[filteredSkills, page],
@@ -314,6 +329,27 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
onSearchChange={setSearch}
tagIds={tagIds}
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 && (
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
@@ -1,7 +1,7 @@
"use client";
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 { useI18n } from "@/core/i18n/hooks";
@@ -31,12 +31,14 @@ export function TagSearchBar({
tagIds,
onTagIdsChange,
className,
actions,
}: {
targetType: TagType;
onSearchChange: (value: string) => void;
tagIds: string[];
onTagIdsChange: (value: string[]) => void;
className?: string;
actions?: ReactNode;
}) {
const { t } = useI18n();
const { data: tags } = useTags(targetType);
@@ -67,14 +69,31 @@ export function TagSearchBar({
return (
<div className={cn("flex flex-col gap-2.5", className)}>
<div className="relative">
<SearchIcon className="text-muted-foreground dark:text-[#60a5fa]/90 absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={t.adminTags.searchPlaceholder}
className="pl-8"
/>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="relative min-w-0 flex-1">
<SearchIcon className="text-muted-foreground dark:text-[#60a5fa]/90 absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
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>
{allTags.length > 0 && (
+25
View File
@@ -4,6 +4,7 @@ import { apiFetch } from "@/core/api";
import type {
Agent,
AgentSelectorResponse,
AgentSort,
CreateAgentRequest,
PaginatedAgents,
UpdateAgentRequest,
@@ -29,6 +30,7 @@ export interface ListAgentsParams {
/** 1-based page index. Providing this (or scope) enables server-side pagination. */
page?: number;
pageSize?: number;
sort?: AgentSort;
}
function buildAgentsQuery(params: ListAgentsParams): string {
@@ -38,6 +40,7 @@ function buildAgentsQuery(params: ListAgentsParams): string {
if (params.scope) qs.set("scope", params.scope);
if (params.page != null) qs.set("page", String(params.page));
if (params.pageSize != null) qs.set("page_size", String(params.pageSize));
if (params.sort) qs.set("sort", params.sort);
return qs.toString();
}
@@ -152,6 +155,26 @@ export async function unfavoriteAgent(id: string): Promise<void> {
}
}
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> {
const res = await apiFetch(
`${getBackendBaseURL()}/api/agents/featured/order`,
@@ -194,6 +217,7 @@ export interface ListPrivateAgentsParams {
tagIds?: string[];
page?: number;
pageSize?: number;
sort?: AgentSort;
}
/** Paginated private-square listing (password-gated). */
@@ -211,6 +235,7 @@ export async function listPrivateAgentsPaginated(
tag_ids: params.tagIds && params.tagIds.length ? params.tagIds : undefined,
page: params.page,
page_size: params.pageSize,
sort: params.sort,
}),
},
);
+31 -1
View File
@@ -17,14 +17,17 @@ import {
listAllAgentsAdmin,
listPrivateAgents,
listPrivateAgentsPaginated,
pinAgent,
reorderFeaturedAgents,
setAgentFeatured,
setAgentPrivate,
unfavoriteAgent,
unpinAgent,
updateAgent,
} from "./api";
import type {
Agent,
AgentSort,
CreateAgentRequest,
PaginatedAgents,
UpdateAgentRequest,
@@ -79,6 +82,7 @@ export function useAgentsPaginated(params: {
scope: "mine" | "square";
search?: string;
tagIds?: string[];
sort?: AgentSort;
page: number;
pageSize: number;
enabled?: boolean;
@@ -86,6 +90,7 @@ export function useAgentsPaginated(params: {
const { scope, page, pageSize, enabled = true } = params;
const search = params.search ?? "";
const tagIds = params.tagIds ?? [];
const sort = params.sort ?? "time_desc";
const { data, isLoading, error } = useQuery({
queryKey: [
"agents",
@@ -93,11 +98,12 @@ export function useAgentsPaginated(params: {
scope,
search,
[...tagIds].sort(),
sort,
page,
pageSize,
],
queryFn: () =>
listAgentsPaginated({ scope, search, tagIds, page, pageSize }),
listAgentsPaginated({ scope, search, tagIds, sort, page, pageSize }),
enabled,
placeholderData: keepPreviousData,
});
@@ -193,6 +199,26 @@ export function useToggleAgentFavorite() {
});
}
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({
queryKey: ["agents", "selector"],
@@ -254,6 +280,7 @@ export function usePrivateAgentsPaginated(
params: {
search?: string;
tagIds?: string[];
sort?: AgentSort;
page: number;
pageSize: number;
enabled?: boolean;
@@ -262,6 +289,7 @@ export function usePrivateAgentsPaginated(
const { page, pageSize, enabled = true } = params;
const search = params.search ?? "";
const tagIds = params.tagIds ?? [];
const sort = params.sort ?? "time_desc";
const { data, isLoading, error, refetch } = useQuery({
queryKey: [
"agents",
@@ -270,6 +298,7 @@ export function usePrivateAgentsPaginated(
"paginated",
search,
[...tagIds].sort(),
sort,
page,
pageSize,
],
@@ -278,6 +307,7 @@ export function usePrivateAgentsPaginated(
password: password!,
search,
tagIds,
sort,
page,
pageSize,
}),
+3
View File
@@ -18,8 +18,11 @@ export interface Agent {
featured_order?: number | null;
/** Whether the current user has favorited (收藏) this agent. */
is_favorite?: boolean;
is_pinned?: boolean;
}
export type AgentSort = "time_desc" | "time_asc" | "updated_desc" | "updated_asc" | "name_asc" | "name_desc";
export interface AgentSelectorResponse {
agents: Agent[];
}
+2
View File
@@ -6,12 +6,14 @@ import type { Skill, SkillDisplayConfig, SkillState } from "./type";
export interface ListSkillsParams {
search?: 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 = {}) {
const qs = new URLSearchParams();
if (params.search) qs.set("search", params.search);
for (const id of params.tagIds ?? []) qs.append("tag_ids", id);
if (params.sort) qs.set("sort", params.sort);
const query = qs.toString();
const skills = await apiFetch(
`${getBackendBaseURL()}/api/skills${query ? `?${query}` : ""}`,
+5 -3
View File
@@ -23,6 +23,7 @@ import {
setSkillState,
validateSkillUpload,
} from "./api";
import type { ListSkillsParams } from "./api";
import type { Skill, SkillState } from "./type";
import { loadAllSkillsAdmin, loadSkills } from ".";
@@ -33,12 +34,13 @@ import { loadAllSkillsAdmin, loadSkills } from ".";
// forever (observed as a "Maximum update depth exceeded" loop on NewAgentPage).
const EMPTY_SKILLS: Skill[] = [];
export function useSkills(params: { search?: string; tagIds?: string[] } = {}) {
export function useSkills(params: ListSkillsParams = {}) {
const search = params.search ?? "";
const tagIds = params.tagIds ?? [];
const sort = params.sort ?? "time_desc";
const { data, isLoading, error } = useQuery({
queryKey: ["skills", search, [...tagIds].sort()],
queryFn: () => loadSkills({ search, tagIds }),
queryKey: ["skills", search, [...tagIds].sort(), sort],
queryFn: () => loadSkills({ search, tagIds, sort }),
placeholderData: keepPreviousData,
});
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
// (``agent``) from skills the user uploaded / authored manually (``upload``).
source?: SkillSource;
created_at?: string | null;
last_activity_at?: string | null;
use_count?: number;
view_count?: number;
@@ -52,6 +52,25 @@ export function useUpdateUserPreferences() {
const queryClient = useQueryClient();
return useMutation({
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) => {
queryClient.setQueryData(QUERY_KEY, data);
},
@@ -64,6 +64,11 @@ class AgentResponse(BaseModel):
description="Whether the requesting user has favorited (收藏) this agent.",
)
is_pinned: bool = Field(
default=False,
description="Whether the requesting user has pinned this agent.",
)
class AgentsListResponse(BaseModel):
"""Response model for listing agents.
@@ -388,6 +393,17 @@ def _record_to_response(
)
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],
*,
@@ -465,6 +481,7 @@ async def _build_list_responses(
extras = await store.get_extras_for(ids)
assignments = await tag_store.list_assignments("agent", ids)
favorite_ids = await store.list_favorite_ids(user_id)
pin_ids = await store.list_pin_ids(user_id)
for resp in responses:
cached = extras.get(resp.id)
if cached is not None:
@@ -473,6 +490,7 @@ async def _build_list_responses(
resp.model = cached.get("model")
resp.tags = assignments.get(resp.id, [])
resp.is_favorite = resp.id in favorite_ids
resp.is_pinned = resp.id in pin_ids
return responses
@@ -493,6 +511,11 @@ async def list_agents(
),
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."),
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,
description=(
@@ -523,6 +546,7 @@ async def list_agents(
page=eff_page,
page_size=page_size,
admin=admin,
sort=sort,
)
responses = await _build_list_responses(store, tag_store, user_id, rows)
return AgentsListResponse(agents=responses, total=total, page=eff_page, page_size=page_size)
@@ -538,6 +562,7 @@ async def list_agents(
if 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 = _sort_agent_responses(responses, sort)
return AgentsListResponse(agents=responses, total=len(responses), page=1, page_size=len(responses))
except HTTPException:
raise
@@ -612,7 +637,9 @@ async def get_agent(agent_id: str, request: Request) -> AgentResponse:
# detail fetches.
response = await _record_to_response_async(record, include_soul=True, include_extras=True)
favorite_ids = await store.list_favorite_ids(user_id)
pin_ids = await store.list_pin_ids(user_id)
response.is_favorite = agent_id in favorite_ids
response.is_pinned = agent_id in pin_ids
return response
except HTTPException:
raise
@@ -851,6 +878,10 @@ async def delete_agent(agent_id: str, request: Request) -> None:
await store.delete_favorites_for_agent(agent_id)
except Exception: # noqa: BLE001 — favorite cleanup must not block deletion
pass
try:
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
@@ -910,6 +941,53 @@ async def unfavorite_agent(agent_id: str, request: Request) -> None:
raise HTTPException(status_code=500, detail=f"Failed to unfavorite agent: {str(e)}") from e
@router.post(
"/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,
@@ -961,6 +1039,11 @@ class PrivateSquareListRequest(BaseModel):
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_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):
@@ -1005,12 +1088,14 @@ async def list_private_square(body: PrivateSquareListRequest, request: Request)
tag_ids=body.tag_ids,
page=body.page,
page_size=body.page_size,
sort=body.sort,
)
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)
records = await store.list_private()
responses = await _build_list_responses(store, tag_store, user_id, records)
responses = _sort_agent_responses(responses, body.sort)
return AgentsListResponse(agents=responses, total=len(responses), page=1, page_size=len(responses))
except HTTPException:
raise
@@ -7,7 +7,7 @@ import re
import subprocess
import sys
import tempfile
from datetime import datetime
from datetime import UTC, datetime
from pathlib import Path
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")
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")
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")
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")
@@ -241,8 +242,20 @@ def _skill_to_response(
from deerflow.skills.usage import get_entry, latest_activity_at
entry = get_entry(skill.name)
created_at_str = entry.get("created_at")
last_at_str = latest_activity_at(skill.name)
created_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:
try:
last_at = datetime.fromisoformat(last_at_str)
@@ -278,6 +291,7 @@ def _skill_to_response(
view_count=entry.get("view_count", 0),
patch_count=entry.get("patch_count", 0),
last_activity_at=last_at,
created_at=created_at,
state=entry.get("state", "active"),
pinned=entry.get("pinned", False),
source=entry.get("source", "upload"),
@@ -688,6 +702,17 @@ async def _attach_and_filter_tags(
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(
"/skills",
response_model=SkillsListResponse,
@@ -701,6 +726,11 @@ async def list_skills(
tag_store: TagStore = Depends(get_tag_store),
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)"),
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(
default=False,
description=(
@@ -754,6 +784,7 @@ async def list_skills(
)
)
responses = await _attach_and_filter_tags(tag_store, responses, search, tag_ids)
responses = _sort_skill_responses(responses, sort)
return SkillsListResponse(skills=responses)
except HTTPException:
raise
@@ -129,6 +129,7 @@ class AgentStore(ABC):
page: int,
page_size: int,
admin: bool = False,
sort: str = "time_desc",
) -> tuple[list[dict[str, Any]], int]:
"""Return one page of agents plus the total matching count.
@@ -201,3 +202,25 @@ class AgentStore(ABC):
async def delete_favorites_for_agent(self, agent_id: str) -> None:
"""Drop every user's favorite of ``agent_id`` (called on agent delete)."""
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
@@ -23,6 +23,7 @@ class MemoryAgentStore(AgentStore):
def __init__(self) -> None:
self._agents: dict[str, dict[str, Any]] = {}
self._favorites: set[tuple[str, str]] = set()
self._pins: set[tuple[str, str]] = set()
# Listing cache mirrors (see AgentExtrasRow / AgentSkillRow):
self._skills: dict[str, list[str]] = {}
self._extras: dict[str, dict[str, Any]] = {}
@@ -195,6 +196,7 @@ class MemoryAgentStore(AgentStore):
page: int,
page_size: int,
admin: bool = False,
sort: str = "time_desc",
) -> tuple[list[dict[str, Any]], int]:
# 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.
@@ -204,7 +206,29 @@ class MemoryAgentStore(AgentStore):
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))
]
matched.sort(key=lambda a: (a.get("created_at") or "", a.get("id") or ""))
if sort == "time_asc":
matched.sort(key=lambda a: (a.get("created_at") or "", a.get("id") or ""))
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]
@@ -253,3 +277,21 @@ class MemoryAgentStore(AgentStore):
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}
# --- 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}
@@ -54,6 +54,18 @@ class AgentFavoriteRow(Base):
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
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.
@@ -6,7 +6,7 @@ from datetime import UTC, datetime
from typing import Any
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.ext.asyncio import AsyncSession, async_sessionmaker
@@ -15,6 +15,7 @@ from deerflow.persistence.agents.base import AgentStore
from deerflow.persistence.agents.model import (
AgentExtrasRow,
AgentFavoriteRow,
AgentPinRow,
AgentRow,
AgentSkillRow,
)
@@ -287,6 +288,7 @@ class AgentRepository(AgentStore):
page: int,
page_size: int,
admin: bool = False,
sort: str = "time_desc",
) -> tuple[list[dict[str, Any]], int]:
filters = []
scope_filter = self._scope_filter(user_id=user_id, scope=scope, admin=admin)
@@ -298,11 +300,25 @@ class AgentRepository(AgentStore):
filters.append(self._tag_clause(tag_ids))
count_stmt = select(func.count()).select_from(AgentRow)
pin_subq = select(AgentPinRow.agent_id).where(AgentPinRow.user_id == user_id)
pin_rank = case((AgentRow.id.in_(pin_subq), 0), else_=1)
if sort == "time_asc":
order_by = (AgentRow.created_at.asc(), AgentRow.id.asc())
elif sort == "updated_desc":
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)
# ``id`` is a stable tiebreaker on top of ``created_at`` so OFFSET
# paging is deterministic when bulk-seeded built-ins share a timestamp.
.order_by(AgentRow.created_at.asc(), AgentRow.id.asc())
.order_by(pin_rank.asc(), *order_by)
.limit(page_size)
.offset(max(page - 1, 0) * page_size)
)
@@ -429,3 +445,45 @@ class AgentRepository(AgentStore):
async with self._sf() as session:
await session.execute(delete(AgentFavoriteRow).where(AgentFavoriteRow.agent_id == agent_id))
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}
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:
path: /app/skills
security_scan: