From bcebc8d5fb7dcbb48036b8090df8599fd88bec45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Thu, 4 Jun 2026 16:58:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E6=99=BA=E8=83=BD?= =?UTF-8?q?=E4=BD=93=E7=BD=AE=E9=A1=B6=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workspace/agents/agent-card.tsx | 60 +++-- .../workspace/agents/agent-gallery.tsx | 39 +++- .../workspace/custom-prompt-menu.tsx | 31 ++- .../settings/skill-settings-page.tsx | 78 +++++-- .../workspace/tags/tag-search-bar.tsx | 37 ++- frontend-web/src/core/agents/api.ts | 87 ++++--- frontend-web/src/core/agents/hooks.ts | 162 +++++++------ frontend-web/src/core/agents/types.ts | 17 +- frontend-web/src/core/skills/api.ts | 14 +- frontend-web/src/core/skills/hooks.ts | 18 +- frontend-web/src/core/skills/type.ts | 1 + .../src/core/user-preferences/index.ts | 19 ++ .../backend/app/gateway/routers/agents.py | 219 ++++++++++++------ .../backend/app/gateway/routers/skills.py | 33 ++- .../deerflow/persistence/agents/base.py | 57 +++-- .../deerflow/persistence/agents/memory.py | 76 ++++-- .../deerflow/persistence/agents/model.py | 22 +- .../deerflow/persistence/agents/sql.py | 114 ++++++--- .../versions/20260604_01_agent_pins.py | 56 +++++ offline-backend-20260512/config.yaml | 22 ++ 20 files changed, 852 insertions(+), 310 deletions(-) create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260604_01_agent_pins.py diff --git a/frontend-web/src/components/workspace/agents/agent-card.tsx b/frontend-web/src/components/workspace/agents/agent-card.tsx index 41939a2a..cb4e510c 100644 --- a/frontend-web/src/components/workspace/agents/agent-card.tsx +++ b/frontend-web/src/components/workspace/agents/agent-card.tsx @@ -6,8 +6,9 @@ import { GlobeIcon, InfoIcon, LockIcon, - MessageSquareIcon, - ShieldIcon, + MessageSquareIcon, + PinIcon, + ShieldIcon, SparklesIcon, SquarePenIcon, StarIcon, @@ -53,10 +54,11 @@ import { useAgent, useCreateAgent, useDeleteAgent, - useSetAgentFeatured, - useSetAgentPrivate, - useToggleAgentFavorite, - useUpdateAgent, + useSetAgentFeatured, + useSetAgentPrivate, + useToggleAgentFavorite, + useToggleAgentPin, + useUpdateAgent, } from "@/core/agents"; import { getAgent } from "@/core/agents/api"; import type { Agent } from "@/core/agents"; @@ -186,8 +188,9 @@ export function AgentCard({ agent }: AgentCardProps) { const updateAgent = useUpdateAgent(); const createAgent = useCreateAgent(); const setFeatured = useSetAgentFeatured(); - const setPrivate = useSetAgentPrivate(); - const toggleFavorite = useToggleAgentFavorite(); + const setPrivate = useSetAgentPrivate(); + const toggleFavorite = useToggleAgentFavorite(); + const togglePin = useToggleAgentPin(); const { skills, isLoading: isSkillsLoading } = useSkills(); const [deleteOpen, setDeleteOpen] = 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 // already own, so only offer it on built-in / others' agents. const canFavorite = !isOwned; - const isFavorite = !!agent.is_favorite; + const isFavorite = !!agent.is_favorite; + const isPinned = !!agent.is_pinned; useEffect(() => { if (!editOpen) { @@ -379,7 +383,7 @@ export function AgentCard({ agent }: AgentCardProps) { } } - async function handleToggleFavorite() { + async function handleToggleFavorite() { const next = !isFavorite; try { await toggleFavorite.mutateAsync({ id: agent.id, favorite: next }); @@ -387,7 +391,17 @@ export function AgentCard({ agent }: AgentCardProps) { } catch (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() { // One-click on/off — ordering is handled by the dedicated 管理 Agent 列表 @@ -552,8 +566,8 @@ export function AgentCard({ agent }: AgentCardProps) { {(canEdit || canDelete || isOwned || isAdmin || canFavorite) && (
- {canFavorite && ( - - )} - {isAdmin && ( + + )} + + {isAdmin && (
@@ -314,6 +326,25 @@ export function AgentGallery() { onSearchChange={setSearch} tagIds={tagIds} onTagIdsChange={setTagIds} + actions={ + + } /> diff --git a/frontend-web/src/components/workspace/custom-prompt-menu.tsx b/frontend-web/src/components/workspace/custom-prompt-menu.tsx index 72a450b2..ccce3b77 100644 --- a/frontend-web/src/components/workspace/custom-prompt-menu.tsx +++ b/frontend-web/src/components/workspace/custom-prompt-menu.tsx @@ -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({

关联智能体(可选)

- 关联后,仅在选中该智能体的对话里显示此提示词。 + {selectableAgents.length === 0 + ? "仅可关联你收藏到「我的」的常用智能体;当前还没有收藏任何智能体。" + : "关联后,仅在选中该智能体的对话里显示此提示词。这里只列出你收藏的常用智能体。"}

diff --git a/frontend-web/src/components/workspace/settings/skill-settings-page.tsx b/frontend-web/src/components/workspace/settings/skill-settings-page.tsx index 460d6794..260f0095 100644 --- a/frontend-web/src/components/workspace/settings/skill-settings-page.tsx +++ b/frontend-web/src/components/workspace/settings/skill-settings-page.tsx @@ -1,9 +1,10 @@ "use client"; import { - AlertCircle, - ArchiveIcon, - CheckCircle2, + AlertCircle, + ArchiveIcon, + ArrowDownUpIcon, + CheckCircle2, ChevronLeftIcon, ChevronRightIcon, ClockIcon, @@ -51,8 +52,9 @@ import { ItemContent, ItemDescription, } from "@/components/ui/item"; -import { Input } from "@/components/ui/input"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +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"; 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 默认样式 */ const rescanSuccessToastDarkOptions = { @@ -134,12 +145,13 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) { const [installOpen, setInstallOpen] = useState(false); const [staleSheetOpen, setStaleSheetOpen] = useState(false); const [archiveSheetOpen, setArchiveSheetOpen] = useState(false); - const [search, setSearch] = useState(""); - const [tagIds, setTagIds] = useState([]); - 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 [search, setSearch] = useState(""); + const [tagIds, setTagIds] = useState([]); + const [sort, setSort] = useState("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, sort }); const storedAuth = useMemo(() => getStoredAuth(), []); 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)); // Filter / search / tag changes may shrink the result set — pull the page // back into range without forcing a reset on every keystroke. - useEffect(() => { - if (page > totalPages) setPage(totalPages); - }, [page, totalPages]); + 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], @@ -309,12 +324,33 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
- + setSort(v as SkillSort)}> + + + + + + 最新创建 + 最早创建 + 最近使用 + 最早使用 + 使用最多 + 使用最少 + 名称 A-Z + 名称 Z-A + + + } + /> {isLoading && (
{t.common.loading}
)} diff --git a/frontend-web/src/components/workspace/tags/tag-search-bar.tsx b/frontend-web/src/components/workspace/tags/tag-search-bar.tsx index e4ffe2b4..399f9d84 100644 --- a/frontend-web/src/components/workspace/tags/tag-search-bar.tsx +++ b/frontend-web/src/components/workspace/tags/tag-search-bar.tsx @@ -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 (
-
- - setInput(e.target.value)} - placeholder={t.adminTags.searchPlaceholder} - className="pl-8" - /> +
+
+ + 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" + /> +
+ {actions && ( +
+ {actions} +
+ )}
{allTags.length > 0 && ( diff --git a/frontend-web/src/core/agents/api.ts b/frontend-web/src/core/agents/api.ts index 3d9676f2..0ec29e98 100644 --- a/frontend-web/src/core/agents/api.ts +++ b/frontend-web/src/core/agents/api.ts @@ -2,12 +2,13 @@ import { getBackendBaseURL } from "@/core/config"; import { apiFetch } from "@/core/api"; import type { - Agent, - AgentSelectorResponse, - CreateAgentRequest, - PaginatedAgents, - UpdateAgentRequest, -} from "./types"; + Agent, + AgentSelectorResponse, + AgentSort, + CreateAgentRequest, + PaginatedAgents, + UpdateAgentRequest, +} from "./types"; 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. */ scope?: "mine" | "square"; /** 1-based page index. Providing this (or scope) enables server-side pagination. */ - page?: number; - pageSize?: number; -} + page?: number; + pageSize?: number; + sort?: AgentSort; +} function buildAgentsQuery(params: ListAgentsParams): string { 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.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)); - return qs.toString(); -} + 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(); +} export async function listAgents(params: ListAgentsParams = {}): Promise { const query = buildAgentsQuery(params); @@ -126,13 +129,13 @@ export async function deleteAgent(id: string): Promise { if (!res.ok) throw new Error(`Failed to delete agent: ${res.statusText}`); } -export async function getAgentSelector(): Promise { +export async function getAgentSelector(): Promise { const res = await apiFetch(`${getBackendBaseURL()}/api/agents/selector`); if (!res.ok) throw new Error(`Failed to load agent selector: ${res.statusText}`); return res.json() as Promise; -} - -export async function favoriteAgent(id: string): Promise { +} + +export async function favoriteAgent(id: string): Promise { const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}/favorite`, { method: "POST", }); @@ -142,15 +145,35 @@ export async function favoriteAgent(id: string): Promise { } } -export async function unfavoriteAgent(id: string): Promise { +export async function unfavoriteAgent(id: string): Promise { const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}/favorite`, { method: "DELETE", }); if (!res.ok) { const err = (await res.json().catch(() => ({}))) as { detail?: string }; throw new Error(err.detail ?? `Failed to unfavorite agent: ${res.statusText}`); - } -} + } +} + +export async function pinAgent(id: string): Promise { + 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 { + 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 { const res = await apiFetch( @@ -188,13 +211,14 @@ export async function listPrivateAgents(password: string): Promise { return data.agents; } -export interface ListPrivateAgentsParams { - password: string; - search?: string; - tagIds?: string[]; - page?: number; - pageSize?: number; -} +export interface ListPrivateAgentsParams { + password: string; + search?: string; + tagIds?: string[]; + page?: number; + pageSize?: number; + sort?: AgentSort; +} /** Paginated private-square listing (password-gated). */ export async function listPrivateAgentsPaginated( @@ -208,10 +232,11 @@ export async function listPrivateAgentsPaginated( body: JSON.stringify({ password: params.password, search: params.search || undefined, - tag_ids: params.tagIds && params.tagIds.length ? params.tagIds : undefined, - page: params.page, - page_size: params.pageSize, - }), + tag_ids: params.tagIds && params.tagIds.length ? params.tagIds : undefined, + page: params.page, + page_size: params.pageSize, + sort: params.sort, + }), }, ); if (!res.ok) { diff --git a/frontend-web/src/core/agents/hooks.ts b/frontend-web/src/core/agents/hooks.ts index 36e71869..b1c6fbed 100644 --- a/frontend-web/src/core/agents/hooks.ts +++ b/frontend-web/src/core/agents/hooks.ts @@ -3,31 +3,34 @@ import { useMutation, useQuery, useQueryClient, -} from "@tanstack/react-query"; - -import { +} from "@tanstack/react-query"; + +import { adminSyncAgentSkills, createAgent, deleteAgent, favoriteAgent, getAgent, getAgentSelector, - listAgents, - listAgentsPaginated, - listAllAgentsAdmin, - listPrivateAgents, - listPrivateAgentsPaginated, - reorderFeaturedAgents, - setAgentFeatured, - setAgentPrivate, - unfavoriteAgent, - updateAgent, -} from "./api"; -import type { - Agent, - CreateAgentRequest, - PaginatedAgents, - UpdateAgentRequest, + listAgents, + listAgentsPaginated, + listAllAgentsAdmin, + listPrivateAgents, + listPrivateAgentsPaginated, + pinAgent, + reorderFeaturedAgents, + setAgentFeatured, + setAgentPrivate, + unfavoriteAgent, + unpinAgent, + updateAgent, +} from "./api"; +import type { + Agent, + AgentSort, + CreateAgentRequest, + PaginatedAgents, + UpdateAgentRequest, } from "./types"; /** 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 * the page of agents plus the total count for the "共 N 个" header. */ export function useAgentsPaginated(params: { - scope: "mine" | "square"; - search?: string; - tagIds?: string[]; - page: number; - pageSize: number; - enabled?: boolean; -}) { - const { scope, page, pageSize, enabled = true } = params; - const search = params.search ?? ""; - const tagIds = params.tagIds ?? []; + scope: "mine" | "square"; + search?: string; + tagIds?: string[]; + sort?: AgentSort; + page: number; + pageSize: number; + enabled?: boolean; +}) { + 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", "paginated", - scope, - search, - [...tagIds].sort(), - page, - pageSize, - ], - queryFn: () => - listAgentsPaginated({ scope, search, tagIds, page, pageSize }), + scope, + search, + [...tagIds].sort(), + sort, + page, + pageSize, + ], + queryFn: () => + listAgentsPaginated({ scope, search, tagIds, sort, page, pageSize }), enabled, placeholderData: keepPreviousData, }); @@ -174,7 +180,7 @@ export function useDeleteAgent() { /** Toggle a user's favorite (收藏) on an agent, with an optimistic flip of * ``is_favorite`` across every cached agent listing + detail so the star * reacts instantly before the round-trip completes. */ -export function useToggleAgentFavorite() { +export function useToggleAgentFavorite() { const queryClient = useQueryClient(); return useMutation({ mutationFn: ({ id, favorite }: { id: string; favorite: boolean }) => @@ -190,10 +196,30 @@ export function useToggleAgentFavorite() { onSettled: () => { 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(["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"], queryFn: () => getAgentSelector(), @@ -204,9 +230,9 @@ export function useAgentSelector() { isLoading, error, }; -} - -function invalidateAgentQueries(queryClient: ReturnType) { +} + +function invalidateAgentQueries(queryClient: ReturnType) { void queryClient.invalidateQueries({ queryKey: ["agents"] }); } @@ -251,35 +277,39 @@ export function usePrivateAgents(password: string | null) { * password-gated private square plus the total count. */ export function usePrivateAgentsPaginated( password: string | null, - params: { - search?: string; - tagIds?: string[]; - page: number; - pageSize: number; - enabled?: boolean; - }, -) { - const { page, pageSize, enabled = true } = params; - const search = params.search ?? ""; - const tagIds = params.tagIds ?? []; + params: { + search?: string; + tagIds?: string[]; + sort?: AgentSort; + page: number; + pageSize: number; + enabled?: boolean; + }, +) { + 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", "private-square", password ?? "", - "paginated", - search, - [...tagIds].sort(), - page, - pageSize, + "paginated", + search, + [...tagIds].sort(), + sort, + page, + pageSize, ], queryFn: () => listPrivateAgentsPaginated({ - password: password!, - search, - tagIds, - page, - pageSize, + password: password!, + search, + tagIds, + sort, + page, + pageSize, }), enabled: !!password && enabled, staleTime: 30_000, diff --git a/frontend-web/src/core/agents/types.ts b/frontend-web/src/core/agents/types.ts index b8e99ee3..3919659a 100644 --- a/frontend-web/src/core/agents/types.ts +++ b/frontend-web/src/core/agents/types.ts @@ -1,6 +1,6 @@ import type { Tag } from "@/core/tags"; -export interface Agent { +export interface Agent { id: string; name: string; description: string; @@ -17,12 +17,15 @@ export interface Agent { tags?: Tag[]; featured_order?: number | null; /** Whether the current user has favorited (收藏) this agent. */ - is_favorite?: boolean; -} - -export interface AgentSelectorResponse { - agents: 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[]; +} /** Pagination envelope returned by the server-side paginated agent listings. */ export interface PaginatedAgents { diff --git a/frontend-web/src/core/skills/api.ts b/frontend-web/src/core/skills/api.ts index 5a0bad35..b50d08eb 100644 --- a/frontend-web/src/core/skills/api.ts +++ b/frontend-web/src/core/skills/api.ts @@ -3,15 +3,17 @@ import { apiFetch } from "@/core/api"; import type { Skill, SkillDisplayConfig, SkillState } from "./type"; -export interface ListSkillsParams { - search?: string; - tagIds?: string[]; -} +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.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}` : ""}`, diff --git a/frontend-web/src/core/skills/hooks.ts b/frontend-web/src/core/skills/hooks.ts index 52f9585c..36653921 100644 --- a/frontend-web/src/core/skills/hooks.ts +++ b/frontend-web/src/core/skills/hooks.ts @@ -1,4 +1,4 @@ -import { +import { keepPreviousData, useMutation, useQuery, @@ -22,7 +22,8 @@ import { setSkillPublished, setSkillState, validateSkillUpload, -} from "./api"; +} 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[] } = {}) { - const search = params.search ?? ""; - const tagIds = params.tagIds ?? []; - const { data, isLoading, error } = useQuery({ - queryKey: ["skills", search, [...tagIds].sort()], - queryFn: () => loadSkills({ search, tagIds }), +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(), sort], + queryFn: () => loadSkills({ search, tagIds, sort }), placeholderData: keepPreviousData, }); return { skills: data ?? EMPTY_SKILLS, isLoading, error }; diff --git a/frontend-web/src/core/skills/type.ts b/frontend-web/src/core/skills/type.ts index 814d0a7f..6de43264 100644 --- a/frontend-web/src/core/skills/type.ts +++ b/frontend-web/src/core/skills/type.ts @@ -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; diff --git a/frontend-web/src/core/user-preferences/index.ts b/frontend-web/src/core/user-preferences/index.ts index 4ddc12be..4e6866cd 100644 --- a/frontend-web/src/core/user-preferences/index.ts +++ b/frontend-web/src/core/user-preferences/index.ts @@ -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(QUERY_KEY); + queryClient.setQueryData(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); }, diff --git a/offline-backend-20260512/backend/app/gateway/routers/agents.py b/offline-backend-20260512/backend/app/gateway/routers/agents.py index fe9586dd..a75df4c8 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/agents.py +++ b/offline-backend-20260512/backend/app/gateway/routers/agents.py @@ -15,7 +15,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request from pydantic import BaseModel, Field 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.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 @@ -62,10 +62,15 @@ class AgentResponse(BaseModel): is_favorite: bool = Field( default=False, description="Whether the requesting user has favorited (收藏) this agent.", - ) - - -class AgentsListResponse(BaseModel): + ) + + is_pinned: bool = Field( + default=False, + description="Whether the requesting user has pinned this agent.", + ) + + +class AgentsListResponse(BaseModel): """Response model for listing agents. ``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)") -class AgentSelectorResponse(BaseModel): +class AgentSelectorResponse(BaseModel): """Response for the homepage agent selector dropdown. The list is fully curated by admins (``featured_order`` on each row). @@ -120,10 +125,10 @@ class AgentSelectorResponse(BaseModel): agents: list[AgentResponse] = Field( default_factory=list, 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.""" 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) -def _record_to_response( +def _record_to_response( record: dict[str, Any], *, include_soul: bool = False, @@ -385,10 +390,21 @@ def _record_to_response( tool_groups=extras["tool_groups"], skills=extras["skills"], 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], *, 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 ] ids = [r.id for r in 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) - for resp in 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: resp.skills = cached.get("skills") resp.tool_groups = cached.get("tool_groups") resp.model = cached.get("model") - resp.tags = assignments.get(resp.id, []) - resp.is_favorite = resp.id in favorite_ids - return responses + resp.tags = assignments.get(resp.id, []) + resp.is_favorite = resp.id in favorite_ids + resp.is_pinned = resp.id in pin_ids + return responses @router.get( @@ -491,9 +509,14 @@ async def list_agents( pattern="^(mine|square)$", 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_size: int = Query(default=20, ge=1, le=200, description="Items per page when paginating."), - admin: bool = Query( + 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=( "Admin-only override. When ``true`` and the caller is a system admin, " @@ -520,10 +543,11 @@ async def list_agents( scope=scope, search=search, tag_ids=tag_ids, - page=eff_page, - page_size=page_size, - admin=admin, - ) + 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) @@ -535,10 +559,11 @@ async def list_agents( if search: needle = search.strip().lower() responses = [r for r in responses if needle in r.name.lower()] - 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)] - return AgentsListResponse(agents=responses, total=len(responses), page=1, page_size=len(responses)) + 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 except Exception as e: @@ -551,14 +576,14 @@ async def list_agents( summary="Check Agent Name", description="Validate an agent display name. Duplicate names are allowed.", ) -async def check_agent_name(name: str) -> dict[str, Any]: - _require_agents_api_enabled() - normalized = _validate_display_name(name) - return {"available": True, "name": normalized} - - -@router.get( - "/agents/selector", +async def check_agent_name(name: str) -> dict[str, Any]: + _require_agents_api_enabled() + normalized = _validate_display_name(name) + return {"available": True, "name": normalized} + + +@router.get( + "/agents/selector", response_model=AgentSelectorResponse, summary="Agent Homepage Selector", 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 # worker thread so the asyncio loop stays responsive under concurrent # detail fetches. - response = await _record_to_response_async(record, include_soul=True, include_extras=True) - favorite_ids = await store.list_favorite_ids(user_id) - response.is_favorite = agent_id in favorite_ids - return response + 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 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) except Exception: # noqa: BLE001 — tag cleanup must not block deletion pass - try: - await store.delete_favorites_for_agent(agent_id) - except Exception: # noqa: BLE001 — favorite cleanup must not block deletion - pass - try: - await store.delete_extras_for_agent(agent_id) + try: + 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 pass 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", 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() 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) except HTTPException: raise - except Exception as e: - 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 - - -@router.put( - "/agents/{agent_id}/featured", + except Exception as e: + 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 + + +@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, summary="Set Agent Featured Rank (admin)", 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 -class PrivateSquareListRequest(BaseModel): +class PrivateSquareListRequest(BaseModel): """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.") 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).") - 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: 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): @@ -1003,15 +1086,17 @@ async def list_private_square(body: PrivateSquareListRequest, request: Request) scope="private", search=body.search, tag_ids=body.tag_ids, - page=body.page, - page_size=body.page_size, - ) + 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) - return AgentsListResponse(agents=responses, total=len(responses), page=1, page_size=len(responses)) + 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 except Exception as e: diff --git a/offline-backend-20260512/backend/app/gateway/routers/skills.py b/offline-backend-20260512/backend/app/gateway/routers/skills.py index fee8949e..d5d94d7d 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/skills.py +++ b/offline-backend-20260512/backend/app/gateway/routers/skills.py @@ -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 diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/base.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/base.py index 88a546f7..0ff4ae33 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/base.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/base.py @@ -119,17 +119,18 @@ class AgentStore(ABC): raise NotImplementedError @abstractmethod - async def list_paginated( - self, - *, - user_id: str, - scope: str | None, - search: str | None, - tag_ids: list[str] | None, - page: int, - page_size: int, - admin: bool = False, - ) -> tuple[list[dict[str, Any]], int]: + async def list_paginated( + self, + *, + user_id: str, + scope: str | None, + search: str | None, + tag_ids: list[str] | None, + 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. ``scope``: @@ -145,9 +146,9 @@ class AgentStore(ABC): the COUNT, and LIMIT/OFFSET all execute in SQL. Returns ``(rows, total)`` where ``rows`` is at most ``page_size`` items - for the 1-based ``page``. - """ - raise NotImplementedError + for the 1-based ``page``. + """ + raise NotImplementedError # --- Listing cache (skills / tool_groups / model) --- @@ -198,6 +199,28 @@ class AgentStore(ABC): raise NotImplementedError @abstractmethod - 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 + 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 diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/memory.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/memory.py index 1272ae77..762e3aa2 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/memory.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/memory.py @@ -21,8 +21,9 @@ def _copy(data: dict[str, Any]) -> dict[str, Any]: class MemoryAgentStore(AgentStore): def __init__(self) -> None: - self._agents: dict[str, dict[str, Any]] = {} - self._favorites: set[tuple[str, str]] = set() + 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]] = {} @@ -185,17 +186,18 @@ class MemoryAgentStore(AgentStore): return bool(agent.get("is_private")) return self._is_visible(agent, user_id) - async def list_paginated( - self, - *, - user_id: str, - scope: str | None, + async def list_paginated( + self, + *, + user_id: str, + scope: str | None, search: str | None, - tag_ids: list[str] | None, - page: int, - page_size: int, - admin: bool = False, - ) -> tuple[list[dict[str, Any]], int]: + tag_ids: list[str] | None, + 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. matched = [ @@ -204,9 +206,31 @@ 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 "")) - total = len(matched) - start = max(page - 1, 0) * page_size + 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] 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]: 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: - self._favorites = {(uid, aid) for (uid, aid) in self._favorites if aid != agent_id} + 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} diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/model.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/model.py index d7546348..f5247ecf 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/model.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/model.py @@ -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)) -class AgentFavoriteRow(Base): +class AgentFavoriteRow(Base): """A user's bookmark of a (typically public-square) agent. 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) 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): + 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. ``config.yaml`` under ``.deer-flow/agents/{id}/`` stays the source of truth diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py index b45bfdcc..965c93ae 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py @@ -6,18 +6,19 @@ 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 from deerflow.config.agent_name_desensitize import source_terms_for_needle from deerflow.persistence.agents.base import AgentStore from deerflow.persistence.agents.model import ( - AgentExtrasRow, - AgentFavoriteRow, - AgentRow, - AgentSkillRow, -) + AgentExtrasRow, + AgentFavoriteRow, + AgentPinRow, + AgentRow, + AgentSkillRow, +) from deerflow.persistence.tags.model import TagAssignmentRow @@ -277,17 +278,18 @@ class AgentRepository(AgentStore): ) return AgentRow.id.in_(subq) - async def list_paginated( - self, - *, - user_id: str, - scope: str | None, + async def list_paginated( + self, + *, + user_id: str, + scope: str | None, search: str | None, - tag_ids: list[str] | None, - page: int, - page_size: int, - admin: bool = False, - ) -> tuple[list[dict[str, Any]], int]: + tag_ids: list[str] | None, + 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) if scope_filter is not None: @@ -298,14 +300,28 @@ class AgentRepository(AgentStore): filters.append(self._tag_clause(tag_ids)) count_stmt = select(func.count()).select_from(AgentRow) - 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()) - .limit(page_size) - .offset(max(page - 1, 0) * page_size) - ) + 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) + .order_by(pin_rank.asc(), *order_by) + .limit(page_size) + .offset(max(page - 1, 0) * page_size) + ) if filters: where = and_(*filters) count_stmt = count_stmt.where(where) @@ -425,7 +441,49 @@ class AgentRepository(AgentStore): ) return {row[0] for row in result.all()} - async def delete_favorites_for_agent(self, agent_id: str) -> None: - async with self._sf() as session: - await session.execute(delete(AgentFavoriteRow).where(AgentFavoriteRow.agent_id == agent_id)) - await session.commit() + async def delete_favorites_for_agent(self, agent_id: str) -> None: + 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() diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260604_01_agent_pins.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260604_01_agent_pins.py new file mode 100644 index 00000000..bea90630 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260604_01_agent_pins.py @@ -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") diff --git a/offline-backend-20260512/config.yaml b/offline-backend-20260512/config.yaml index 88ad9743..d616bc35 100644 --- a/offline-backend-20260512/config.yaml +++ b/offline-backend-20260512/config.yaml @@ -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: