feat: 支持智能体置顶配置
This commit is contained in:
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 && (
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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}` : ""}`,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
+43
-1
@@ -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}
|
||||
+12
@@ -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.
|
||||
|
||||
|
||||
+62
-4
@@ -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()
|
||||
+56
@@ -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")
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user