- 后端 scope=builtin(user_id IS NULL)加入 sql/memory 的 scope 过滤; /api/agents 扩展 scope 正则并加 admin 闸门(非管理员请求 403) - 新增 _builtin_agent_tag_seed:启动时幂等给功能型内置 agent 按用途打标签 (roundtable-* → 多智能体规划,ai-writing-* → AI写作),只追加不覆盖; 无用途的内置 agent 不打标签 - 前端 agent-gallery 新增「内置智能体」Tab(仅 system_role==admin 可见), scope 类型在 api.ts/hooks.ts 同步 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
381 lines
11 KiB
TypeScript
381 lines
11 KiB
TypeScript
import {
|
|
keepPreviousData,
|
|
useMutation,
|
|
useQuery,
|
|
useQueryClient,
|
|
} from "@tanstack/react-query";
|
|
|
|
import {
|
|
adminSyncAgentSkills,
|
|
createAgent,
|
|
deleteAgent,
|
|
favoriteAgent,
|
|
getAgent,
|
|
getAgentSelector,
|
|
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``)
|
|
* or the paginated ``{ agents, total, ... }`` envelope. Mutators patch both. */
|
|
type CachedAgents = Agent[] | PaginatedAgents | undefined;
|
|
|
|
function isPaginated(value: CachedAgents): value is PaginatedAgents {
|
|
return !!value && !Array.isArray(value) && Array.isArray(value.agents);
|
|
}
|
|
|
|
/** Apply ``map`` to every agent inside whatever listing shape is cached. */
|
|
function mapCachedAgents(
|
|
queryClient: ReturnType<typeof useQueryClient>,
|
|
map: (a: Agent) => Agent,
|
|
) {
|
|
queryClient.setQueriesData<CachedAgents>({ queryKey: ["agents"] }, (old) => {
|
|
if (Array.isArray(old)) return old.map(map);
|
|
if (isPaginated(old)) return { ...old, agents: old.agents.map(map) };
|
|
return old;
|
|
});
|
|
}
|
|
|
|
/** Patch an updated agent into every cached agent listing immediately so
|
|
* cards re-render before the background refetch finishes. Without this the
|
|
* mutation just invalidates the query and the user has to wait for the
|
|
* round-trip + render — which on a big list feels like nothing happened.
|
|
*/
|
|
function applyAgentToCache(
|
|
queryClient: ReturnType<typeof useQueryClient>,
|
|
next: Agent,
|
|
) {
|
|
mapCachedAgents(queryClient, (a) => (a.id === next.id ? { ...a, ...next } : a));
|
|
}
|
|
|
|
export function useAgents(params: { search?: string; tagIds?: string[] } = {}) {
|
|
const search = params.search ?? "";
|
|
const tagIds = params.tagIds ?? [];
|
|
const { data, isLoading, error } = useQuery({
|
|
queryKey: ["agents", search, [...tagIds].sort()],
|
|
queryFn: () => listAgents({ search, tagIds }),
|
|
placeholderData: keepPreviousData,
|
|
});
|
|
return { agents: data ?? [], isLoading, error };
|
|
}
|
|
|
|
/** 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" | "builtin";
|
|
search?: string;
|
|
tagIds?: string[];
|
|
sort?: AgentSort;
|
|
page: number;
|
|
pageSize: number;
|
|
enabled?: boolean;
|
|
/** Keep only agents published by this user id (the card's publisher). */
|
|
owner?: string;
|
|
/** Keep only agents filed under this publish square (发布广场). */
|
|
square?: string;
|
|
}) {
|
|
const { scope, page, pageSize, enabled = true } = params;
|
|
const search = params.search ?? "";
|
|
const tagIds = params.tagIds ?? [];
|
|
const sort = params.sort ?? "time_desc";
|
|
const owner = params.owner ?? "";
|
|
const square = params.square ?? "";
|
|
const { data, isLoading, isFetching, error } = useQuery({
|
|
queryKey: [
|
|
"agents",
|
|
"paginated",
|
|
scope,
|
|
search,
|
|
[...tagIds].sort(),
|
|
sort,
|
|
page,
|
|
pageSize,
|
|
owner,
|
|
square,
|
|
],
|
|
queryFn: () =>
|
|
listAgentsPaginated({
|
|
scope,
|
|
search,
|
|
tagIds,
|
|
sort,
|
|
page,
|
|
pageSize,
|
|
owner: owner || undefined,
|
|
square: square || undefined,
|
|
}),
|
|
enabled,
|
|
placeholderData: keepPreviousData,
|
|
});
|
|
return {
|
|
agents: data?.agents ?? [],
|
|
total: data?.total ?? 0,
|
|
isLoading,
|
|
// keepPreviousData keeps isLoading false on subsequent searches; expose
|
|
// isFetching so callers can show a "searching" indicator on every query.
|
|
isFetching,
|
|
error,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Admin-only listing — returns every agent including unpublished agents owned
|
|
* by other users and private-square entries. Caller must be ``system_role
|
|
* === "admin"`` or the backend will 403.
|
|
*/
|
|
export function useAllAgentsAdmin(enabled: boolean) {
|
|
const { data, isLoading, error } = useQuery({
|
|
queryKey: ["agents", "admin", "all"],
|
|
queryFn: () => listAllAgentsAdmin(),
|
|
enabled,
|
|
staleTime: 30_000,
|
|
});
|
|
return { agents: data ?? [], isLoading, error };
|
|
}
|
|
|
|
export function useAgent(id: string | null | undefined) {
|
|
const { data, isLoading, error } = useQuery({
|
|
queryKey: ["agents", id],
|
|
queryFn: () => getAgent(id!),
|
|
enabled: !!id,
|
|
});
|
|
return { agent: data ?? null, isLoading, error };
|
|
}
|
|
|
|
export function useCreateAgent() {
|
|
const queryClient = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: (request: CreateAgentRequest) => createAgent(request),
|
|
onSuccess: () => {
|
|
void queryClient.invalidateQueries({ queryKey: ["agents"] });
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useUpdateAgent() {
|
|
const queryClient = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: ({
|
|
id,
|
|
request,
|
|
}: {
|
|
id: string;
|
|
request: UpdateAgentRequest;
|
|
}) => updateAgent(id, request),
|
|
onSuccess: (data, { id }) => {
|
|
applyAgentToCache(queryClient, data);
|
|
void queryClient.invalidateQueries({ queryKey: ["agents"] });
|
|
void queryClient.invalidateQueries({ queryKey: ["agents", id] });
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useDeleteAgent() {
|
|
const queryClient = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: (id: string) => deleteAgent(id),
|
|
onSuccess: () => {
|
|
void queryClient.invalidateQueries({ queryKey: ["agents"] });
|
|
},
|
|
});
|
|
}
|
|
|
|
/** 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() {
|
|
const queryClient = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: ({ id, favorite }: { id: string; favorite: boolean }) =>
|
|
favorite ? favoriteAgent(id) : unfavoriteAgent(id),
|
|
onMutate: ({ id, favorite }) => {
|
|
mapCachedAgents(queryClient, (a) =>
|
|
a.id === id ? { ...a, is_favorite: favorite } : a,
|
|
);
|
|
queryClient.setQueryData<Agent | undefined>(["agents", id], (old) =>
|
|
old ? { ...old, is_favorite: favorite } : old,
|
|
);
|
|
},
|
|
onSettled: () => {
|
|
void queryClient.invalidateQueries({ queryKey: ["agents"] });
|
|
},
|
|
});
|
|
}
|
|
|
|
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"],
|
|
queryFn: () => getAgentSelector(),
|
|
staleTime: 60_000,
|
|
});
|
|
return {
|
|
agents: data?.agents ?? [],
|
|
isLoading,
|
|
error,
|
|
};
|
|
}
|
|
|
|
function invalidateAgentQueries(queryClient: ReturnType<typeof useQueryClient>) {
|
|
void queryClient.invalidateQueries({ queryKey: ["agents"] });
|
|
}
|
|
|
|
export function useSetAgentFeatured() {
|
|
const queryClient = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: ({ id, order }: { id: string; order: number | null }) =>
|
|
setAgentFeatured(id, order),
|
|
onSuccess: (data, { id }) => {
|
|
applyAgentToCache(queryClient, data);
|
|
invalidateAgentQueries(queryClient);
|
|
void queryClient.invalidateQueries({ queryKey: ["agents", id] });
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useReorderFeaturedAgents() {
|
|
const queryClient = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: (agentIds: string[]) => reorderFeaturedAgents(agentIds),
|
|
onSuccess: () => invalidateAgentQueries(queryClient),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Query the password-gated private square. ``password`` is sent on every
|
|
* fetch — wire ``enabled`` to false until the user has typed it so we don't
|
|
* hit the endpoint with an empty body and get a 403.
|
|
*/
|
|
export function usePrivateAgents(password: string | null) {
|
|
const { data, isLoading, error, refetch } = useQuery({
|
|
queryKey: ["agents", "private-square", password ?? ""],
|
|
queryFn: () => listPrivateAgents(password!),
|
|
enabled: !!password,
|
|
staleTime: 30_000,
|
|
retry: false,
|
|
});
|
|
return { agents: data ?? [], isLoading, error, refetch };
|
|
}
|
|
|
|
/** Paginated variant of {@link usePrivateAgents} — one page of the
|
|
* password-gated private square plus the total count. */
|
|
export function usePrivateAgentsPaginated(
|
|
password: string | null,
|
|
params: {
|
|
search?: string;
|
|
tagIds?: string[];
|
|
sort?: AgentSort;
|
|
page: number;
|
|
pageSize: number;
|
|
enabled?: boolean;
|
|
/** Keep only agents published by this user id (the card's publisher). */
|
|
owner?: string;
|
|
},
|
|
) {
|
|
const { page, pageSize, enabled = true } = params;
|
|
const search = params.search ?? "";
|
|
const tagIds = params.tagIds ?? [];
|
|
const sort = params.sort ?? "time_desc";
|
|
const owner = params.owner ?? "";
|
|
const { data, isLoading, isFetching, error, refetch } = useQuery({
|
|
queryKey: [
|
|
"agents",
|
|
"private-square",
|
|
password ?? "",
|
|
"paginated",
|
|
search,
|
|
[...tagIds].sort(),
|
|
sort,
|
|
page,
|
|
pageSize,
|
|
owner,
|
|
],
|
|
queryFn: () =>
|
|
listPrivateAgentsPaginated({
|
|
password: password!,
|
|
search,
|
|
tagIds,
|
|
sort,
|
|
page,
|
|
pageSize,
|
|
owner: owner || undefined,
|
|
}),
|
|
enabled: !!password && enabled,
|
|
staleTime: 30_000,
|
|
retry: false,
|
|
placeholderData: keepPreviousData,
|
|
});
|
|
return {
|
|
agents: data?.agents ?? [],
|
|
total: data?.total ?? 0,
|
|
isLoading,
|
|
isFetching,
|
|
error,
|
|
refetch,
|
|
};
|
|
}
|
|
|
|
export function useSetAgentPrivate() {
|
|
const queryClient = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: ({ id, isPrivate }: { id: string; isPrivate: boolean }) =>
|
|
setAgentPrivate(id, isPrivate),
|
|
onSuccess: (data, { id }) => {
|
|
applyAgentToCache(queryClient, data);
|
|
invalidateAgentQueries(queryClient);
|
|
void queryClient.invalidateQueries({ queryKey: ["agents", id] });
|
|
void queryClient.invalidateQueries({
|
|
queryKey: ["agents", "private-square"],
|
|
});
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useAdminSyncAgentSkills() {
|
|
const queryClient = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: adminSyncAgentSkills,
|
|
onSuccess: (result) => {
|
|
if (result.modified > 0) {
|
|
invalidateAgentQueries(queryClient);
|
|
}
|
|
},
|
|
});
|
|
}
|