管理员智能体/技能管理 + 跨标签登录同步 + 上游响应异常重试
- 「设置和更多」新增「智能体管理」「技能管理」入口,与定时任务管理同级; 独立的 AdminAgentsPage/AdminSkillsPage 复用 AgentCard/SkillRow, 管理员可查看并编辑/删除所有用户的智能体与技能。 - 后端 /api/agents、/api/skills 增加 ?admin=true 越权列表;agents 端 放宽 _get_editable_or_raise 让管理员可编辑/删除任意智能体;新增 list_all/update_any/delete_any(agents)与 list_all(skills)。 - 新增 AuthSyncWatcher:监听 localStorage["deerflow.auth"] 的 storage 事件,user_id 变化时自动 reload,修复多标签切换登录后老页面点击 「新对话」偶发 404 的问题。 - LLMErrorHandlingMiddleware 将 bad_response_status_code / JSON decode 等上游网关乱码识别为可重试的 upstream 类故障。 - 侧边栏「任务管理」改名为「定时任务」。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
a5fb806028
commit
89bd07be92
23 files changed
+660
-115
No files matched your search
@@ -3,6 +3,7 @@ import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
|
||||
import { QueryClientProvider } from "@/components/query-client-provider";
|
||||
import { AuthSyncWatcher } from "@/core/auth/sync";
|
||||
import { I18nProvider } from "@/core/i18n/context";
|
||||
import { detectLocale } from "@/core/i18n/locale";
|
||||
import { ChatProvider } from "@/contexts/ChatContext";
|
||||
@@ -36,7 +37,10 @@ function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
<QueryClientProvider>
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<ThemeProvider>
|
||||
<ChatProvider>{children}</ChatProvider>
|
||||
<ChatProvider>
|
||||
<AuthSyncWatcher />
|
||||
{children}
|
||||
</ChatProvider>
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -176,7 +176,7 @@ const menuItems: MenuItem[] = [
|
||||
{ id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" },
|
||||
{
|
||||
id: "mgmt-tasks",
|
||||
label: "任务管理",
|
||||
label: "定时任务",
|
||||
icon: ListChecks,
|
||||
path: "/page/strategy/scheduled/tasks",
|
||||
},
|
||||
|
||||
@@ -85,11 +85,12 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
const isOwned =
|
||||
!isBuiltin && agent.user_id !== null && agent.user_id === currentUserId;
|
||||
const isFromOthers = !isBuiltin && !isOwned;
|
||||
// System admins are allowed to edit and delete built-in agents.
|
||||
// System admins are allowed to edit and delete any agent (built-in or owned
|
||||
// by another user) so the admin console can manage the full catalog.
|
||||
// Publish-toggle stays owner-only — built-ins are inherently visible to
|
||||
// everyone so toggling published is moot.
|
||||
const canEdit = isOwned || (isBuiltin && isAdmin);
|
||||
const canDelete = isOwned || (isBuiltin && isAdmin);
|
||||
const canEdit = isOwned || isAdmin;
|
||||
const canDelete = isOwned || isAdmin;
|
||||
|
||||
useEffect(() => {
|
||||
if (!editOpen) {
|
||||
|
||||
@@ -333,7 +333,7 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SkillRow({
|
||||
export function SkillRow({
|
||||
skill,
|
||||
currentUserId,
|
||||
isAdmin,
|
||||
|
||||
@@ -157,7 +157,7 @@ export function WorkspaceNavChatList({
|
||||
>
|
||||
<Link href="/page/workspace/scheduled/tasks">
|
||||
<ListChecks />
|
||||
<span>任务管理</span>
|
||||
<span>定时任务</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {
|
||||
BellIcon,
|
||||
BotIcon,
|
||||
BrainIcon,
|
||||
CalendarClockIcon,
|
||||
ChevronsUpDown,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
Settings2Icon,
|
||||
SettingsIcon,
|
||||
ShieldCheckIcon,
|
||||
SparklesIcon,
|
||||
TagsIcon,
|
||||
UsersIcon,
|
||||
} from "lucide-react";
|
||||
@@ -182,6 +184,22 @@ export function WorkspaceNavMenu() {
|
||||
定时任务管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/agents")}
|
||||
>
|
||||
<BotIcon />
|
||||
智能体管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/skills")}
|
||||
>
|
||||
<SparklesIcon />
|
||||
技能管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/tags")}
|
||||
|
||||
@@ -38,6 +38,16 @@ export async function listAgents(params: ListAgentsParams = {}): Promise<Agent[]
|
||||
return data.agents;
|
||||
}
|
||||
|
||||
export async function listAllAgentsAdmin(): Promise<Agent[]> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/agents?admin=true`);
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(err.detail ?? `Failed to load agents: ${res.statusText}`);
|
||||
}
|
||||
const data = (await res.json()) as { agents: Agent[] };
|
||||
return data.agents;
|
||||
}
|
||||
|
||||
export async function getAgent(id: string): Promise<Agent> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}`);
|
||||
if (!res.ok) throw new Error(`Agent '${id}' not found`);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getAgent,
|
||||
getAgentSelector,
|
||||
listAgents,
|
||||
listAllAgentsAdmin,
|
||||
listPrivateAgents,
|
||||
reorderFeaturedAgents,
|
||||
setAgentFeatured,
|
||||
@@ -48,6 +49,21 @@ export function useAgents(params: { search?: string; tagIds?: string[] } = {}) {
|
||||
return { agents: data ?? [], isLoading, 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],
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
|
||||
const AUTH_STORAGE_KEY = "deerflow.auth";
|
||||
|
||||
function readUserId(): string | null {
|
||||
return getStoredAuth()?.user_id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount once at the app root. Watches `localStorage["deerflow.auth"]` for
|
||||
* cross-tab changes — when another tab logs in as a different user, this tab
|
||||
* still holds the previous user's cached state (chat list, thread ids, etc.)
|
||||
* which then collides with the new token and surfaces as a 404 on "新对话".
|
||||
* Reloading the tab is the simplest way to resync React state with the new
|
||||
* identity.
|
||||
*/
|
||||
export function AuthSyncWatcher() {
|
||||
const lastUserIdRef = useRef<string | null>(readUserId());
|
||||
|
||||
useEffect(() => {
|
||||
function handleStorage(event: StorageEvent) {
|
||||
if (event.key !== null && event.key !== AUTH_STORAGE_KEY) {
|
||||
return;
|
||||
}
|
||||
const nextUserId = readUserId();
|
||||
if (nextUserId === lastUserIdRef.current) {
|
||||
return;
|
||||
}
|
||||
lastUserIdRef.current = nextUserId;
|
||||
if (nextUserId === null) {
|
||||
window.location.replace("#/login");
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
window.addEventListener("storage", handleStorage);
|
||||
return () => window.removeEventListener("storage", handleStorage);
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { apiFetch } from "@/core/api";
|
||||
|
||||
import type { Skill, SkillDisplayConfig, SkillState } from "./type";
|
||||
import type { Skill, SkillDisplayConfig, SkillState } from "./type";
|
||||
|
||||
export interface ListSkillsParams {
|
||||
search?: string;
|
||||
@@ -20,6 +20,17 @@ export async function loadSkills(params: ListSkillsParams = {}) {
|
||||
return json.skills as Skill[];
|
||||
}
|
||||
|
||||
export async function loadAllSkillsAdmin() {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills?admin=true`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to load skills");
|
||||
}
|
||||
const json = await response.json();
|
||||
return json.skills as Skill[];
|
||||
}
|
||||
|
||||
export async function getSkill(skillName: string): Promise<Skill> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}`,
|
||||
@@ -30,7 +41,7 @@ export async function getSkill(skillName: string): Promise<Skill> {
|
||||
return response.json() as Promise<Skill>;
|
||||
}
|
||||
|
||||
export async function enableSkill(skillName: string, enabled: boolean) {
|
||||
export async function enableSkill(skillName: string, enabled: boolean) {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/${skillName}`,
|
||||
{
|
||||
@@ -44,63 +55,63 @@ export async function enableSkill(skillName: string, enabled: boolean) {
|
||||
},
|
||||
);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function setSkillDisplay(
|
||||
skillName: string,
|
||||
display: SkillDisplayConfig | null,
|
||||
sample?: Record<string, unknown> | null,
|
||||
): Promise<Skill> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/display`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ display, sample }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to update skill display adapter");
|
||||
}
|
||||
return response.json() as Promise<Skill>;
|
||||
}
|
||||
|
||||
export interface SkillDisplaySampleResponse {
|
||||
skill_name: string;
|
||||
sample: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PersistedSkillDisplaySampleResponse {
|
||||
skill_name: string;
|
||||
sample: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export async function getPersistedSkillDisplaySample(
|
||||
skillName: string,
|
||||
): Promise<PersistedSkillDisplaySampleResponse> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/display-sample`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to get persisted skill display sample");
|
||||
}
|
||||
return response.json() as Promise<PersistedSkillDisplaySampleResponse>;
|
||||
}
|
||||
|
||||
export async function getSkillDisplaySample(
|
||||
skillName: string,
|
||||
): Promise<SkillDisplaySampleResponse> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/display-sample`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to get skill display sample");
|
||||
}
|
||||
return response.json() as Promise<SkillDisplaySampleResponse>;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setSkillDisplay(
|
||||
skillName: string,
|
||||
display: SkillDisplayConfig | null,
|
||||
sample?: Record<string, unknown> | null,
|
||||
): Promise<Skill> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/display`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ display, sample }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to update skill display adapter");
|
||||
}
|
||||
return response.json() as Promise<Skill>;
|
||||
}
|
||||
|
||||
export interface SkillDisplaySampleResponse {
|
||||
skill_name: string;
|
||||
sample: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PersistedSkillDisplaySampleResponse {
|
||||
skill_name: string;
|
||||
sample: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export async function getPersistedSkillDisplaySample(
|
||||
skillName: string,
|
||||
): Promise<PersistedSkillDisplaySampleResponse> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/display-sample`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to get persisted skill display sample");
|
||||
}
|
||||
return response.json() as Promise<PersistedSkillDisplaySampleResponse>;
|
||||
}
|
||||
|
||||
export async function getSkillDisplaySample(
|
||||
skillName: string,
|
||||
): Promise<SkillDisplaySampleResponse> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/display-sample`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to get skill display sample");
|
||||
}
|
||||
return response.json() as Promise<SkillDisplaySampleResponse>;
|
||||
}
|
||||
|
||||
export interface InstallSkillRequest {
|
||||
thread_id: string;
|
||||
|
||||
@@ -14,17 +14,17 @@ import {
|
||||
getSkillFiles,
|
||||
installSkillUpload,
|
||||
reconcileCustomSkills,
|
||||
saveSkillFile,
|
||||
setSkillDetail,
|
||||
setSkillDisplay,
|
||||
setSkillPinned,
|
||||
saveSkillFile,
|
||||
setSkillDetail,
|
||||
setSkillDisplay,
|
||||
setSkillPinned,
|
||||
setSkillPublished,
|
||||
setSkillState,
|
||||
validateSkillUpload,
|
||||
} from "./api";
|
||||
import type { Skill, SkillState } from "./type";
|
||||
import type { Skill, SkillState } from "./type";
|
||||
|
||||
import { loadSkills } from ".";
|
||||
import { loadAllSkillsAdmin, loadSkills } from ".";
|
||||
|
||||
export function useSkills(params: { search?: string; tagIds?: string[] } = {}) {
|
||||
const search = params.search ?? "";
|
||||
@@ -37,6 +37,21 @@ export function useSkills(params: { search?: string; tagIds?: string[] } = {}) {
|
||||
return { skills: data ?? [], isLoading, error };
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-only listing — every skill in the system (built-in + every user's
|
||||
* custom skills, including unpublished ones owned by other users). Backend
|
||||
* 403s if the caller is not ``system_role === "admin"``.
|
||||
*/
|
||||
export function useAllSkillsAdmin(enabled: boolean) {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["skills", "admin", "all"],
|
||||
queryFn: () => loadAllSkillsAdmin(),
|
||||
enabled,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
return { skills: data ?? [], isLoading, error };
|
||||
}
|
||||
|
||||
export function useSkillContent(skillName: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ["skill-content", skillName],
|
||||
@@ -53,7 +68,7 @@ export function useSkill(skillName: string | null) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetSkillDetail() {
|
||||
export function useSetSkillDetail() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ skillName, detail }: { skillName: string; detail: string }) =>
|
||||
@@ -63,32 +78,32 @@ export function useSetSkillDetail() {
|
||||
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetSkillDisplay() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
skillName,
|
||||
display,
|
||||
sample,
|
||||
}: {
|
||||
skillName: string;
|
||||
display: Parameters<typeof setSkillDisplay>[1];
|
||||
sample?: Parameters<typeof setSkillDisplay>[2];
|
||||
}) => setSkillDisplay(skillName, display, sample),
|
||||
onSuccess: (data, { skillName }) => {
|
||||
queryClient.setQueryData(["skill", skillName], data);
|
||||
queryClient.setQueriesData<Skill[]>({ queryKey: ["skills"] }, (current) =>
|
||||
Array.isArray(current)
|
||||
? current.map((item) => (item.name === skillName ? { ...item, ...data } : item))
|
||||
: current,
|
||||
);
|
||||
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function useSetSkillDisplay() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
skillName,
|
||||
display,
|
||||
sample,
|
||||
}: {
|
||||
skillName: string;
|
||||
display: Parameters<typeof setSkillDisplay>[1];
|
||||
sample?: Parameters<typeof setSkillDisplay>[2];
|
||||
}) => setSkillDisplay(skillName, display, sample),
|
||||
onSuccess: (data, { skillName }) => {
|
||||
queryClient.setQueryData(["skill", skillName], data);
|
||||
queryClient.setQueriesData<Skill[]>({ queryKey: ["skills"] }, (current) =>
|
||||
Array.isArray(current)
|
||||
? current.map((item) => (item.name === skillName ? { ...item, ...data } : item))
|
||||
: current,
|
||||
);
|
||||
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useEnableSkill() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { BotIcon, SearchIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
WorkspaceBody,
|
||||
WorkspaceContainer,
|
||||
WorkspaceHeader,
|
||||
} from "@/components/workspace/workspace-container";
|
||||
import { AgentCard } from "@/components/workspace/agents/agent-card";
|
||||
import { useAllAgentsAdmin } from "@/core/agents";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
|
||||
export default function AdminAgentsPage() {
|
||||
const [search, setSearch] = useState("");
|
||||
const { agents, isLoading } = useAllAgentsAdmin(true);
|
||||
|
||||
const filteredAgents = useMemo(() => {
|
||||
const needle = search.trim().toLowerCase();
|
||||
if (!needle) return agents;
|
||||
return agents.filter((a) =>
|
||||
desensitize(a.name).toLowerCase().includes(needle),
|
||||
);
|
||||
}, [agents, search]);
|
||||
|
||||
return (
|
||||
<WorkspaceContainer>
|
||||
<WorkspaceHeader />
|
||||
<WorkspaceBody>
|
||||
<div className="flex size-full min-h-0 flex-col">
|
||||
<div className="flex items-start justify-between gap-4 border-b px-6 py-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">智能体管理</h1>
|
||||
<p className="text-muted-foreground mt-0.5 text-sm">
|
||||
管理员视图:查看、编辑、删除所有用户的智能体(包括内置、用户自定义与私密广场条目)。
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative w-64 shrink-0">
|
||||
<SearchIcon className="text-muted-foreground absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="按名称搜索"
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm">
|
||||
加载中…
|
||||
</div>
|
||||
) : filteredAgents.length === 0 ? (
|
||||
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center">
|
||||
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full">
|
||||
<BotIcon className="text-muted-foreground h-7 w-7" />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
当前没有可管理的智能体。
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-muted-foreground mb-3 text-xs">
|
||||
共 {filteredAgents.length} 个智能体。
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{filteredAgents.map((agent) => (
|
||||
<AgentCard key={agent.id} agent={agent} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</WorkspaceBody>
|
||||
</WorkspaceContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
SearchIcon,
|
||||
SparklesIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
WorkspaceBody,
|
||||
WorkspaceContainer,
|
||||
WorkspaceHeader,
|
||||
} from "@/components/workspace/workspace-container";
|
||||
import { SkillRow } from "@/components/workspace/settings/skill-settings-page";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useAllSkillsAdmin } from "@/core/skills/hooks";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
export default function AdminSkillsPage() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const { skills, isLoading, error } = useAllSkillsAdmin(true);
|
||||
|
||||
const storedAuth = useMemo(() => getStoredAuth(), []);
|
||||
const currentUserId = storedAuth?.user_id ?? null;
|
||||
|
||||
// Hide archived/stale entries here — they remain accessible from the regular
|
||||
// skills settings dialog. The admin page is for live skills only.
|
||||
const filteredSkills = useMemo(() => {
|
||||
const needle = search.trim().toLowerCase();
|
||||
return skills
|
||||
.filter((s) => (s.state ?? "active") === "active")
|
||||
.filter((s) => !needle || desensitize(s.name).toLowerCase().includes(needle));
|
||||
}, [skills, search]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filteredSkills.length / PAGE_SIZE));
|
||||
useEffect(() => {
|
||||
if (page > totalPages) setPage(totalPages);
|
||||
}, [page, totalPages]);
|
||||
const pagedSkills = useMemo(
|
||||
() => filteredSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
|
||||
[filteredSkills, page],
|
||||
);
|
||||
|
||||
return (
|
||||
<WorkspaceContainer>
|
||||
<WorkspaceHeader />
|
||||
<WorkspaceBody>
|
||||
<div className="flex size-full min-h-0 flex-col">
|
||||
<div className="flex items-start justify-between gap-4 border-b px-6 py-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">技能管理</h1>
|
||||
<p className="text-muted-foreground mt-0.5 text-sm">
|
||||
管理员视图:查看、编辑、删除所有用户的技能(包括内置技能与未发布的自定义技能)。
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative w-64 shrink-0">
|
||||
<SearchIcon className="text-muted-foreground absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="按名称搜索"
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-6">
|
||||
{isLoading && (
|
||||
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm">
|
||||
加载中…
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="text-destructive text-sm">
|
||||
加载失败:{error.message}
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && !error && filteredSkills.length === 0 && (
|
||||
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center">
|
||||
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full">
|
||||
<SparklesIcon className="text-muted-foreground h-7 w-7" />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
当前没有可管理的技能。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && !error && filteredSkills.length > 0 && (
|
||||
<>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
共 {filteredSkills.length} 个技能。
|
||||
</p>
|
||||
{pagedSkills.map((skill) => (
|
||||
<SkillRow
|
||||
key={skill.name}
|
||||
skill={skill}
|
||||
currentUserId={currentUserId}
|
||||
isAdmin
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{!isLoading && !error && totalPages > 1 && (
|
||||
<div className="flex items-center justify-end gap-2 pt-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
上一页
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
第 {page} / {totalPages} 页
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
>
|
||||
下一页
|
||||
<ChevronRightIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</WorkspaceBody>
|
||||
</WorkspaceContainer>
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,9 @@ import ChatPage from "./ChatPage";
|
||||
import AgentsPage from "./AgentsPage";
|
||||
import AgentChatPage from "./AgentChatPage";
|
||||
import NewAgentPage from "./NewAgentPage";
|
||||
import AdminAgentsPage from "./AdminAgentsPage";
|
||||
import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
|
||||
import AdminSkillsPage from "./AdminSkillsPage";
|
||||
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
|
||||
import ScheduledTasksPage from "./ScheduledTasksPage";
|
||||
import LlmMetricsPage from "./LlmMetricsPage";
|
||||
@@ -280,6 +282,30 @@ export default function WorkspaceRoutes({
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/agents"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<AdminAgentsPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/skills"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<AdminSkillsPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/tags"
|
||||
element={
|
||||
|
||||
@@ -297,17 +297,21 @@ async def _get_editable_or_raise(
|
||||
) -> tuple[dict[str, Any], str]:
|
||||
"""Return (record, edit_mode) where edit_mode is 'owner' or 'admin'.
|
||||
|
||||
'admin' is granted only when the agent is built-in (user_id IS NULL) and the
|
||||
caller is a system admin. Custom agents owned by other users still 403.
|
||||
Admins may edit any agent — built-in (``user_id IS NULL``) or owned by
|
||||
another user — so the admin console can manage the full agent catalog.
|
||||
Non-admin callers are still restricted to agents they own.
|
||||
"""
|
||||
owned = await store.get_owned(agent_id, user_id)
|
||||
if owned is not None:
|
||||
return owned, "owner"
|
||||
if is_admin:
|
||||
record = await store.get_any(agent_id)
|
||||
if record is None:
|
||||
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
||||
return record, "admin"
|
||||
visible = await store.get_visible(agent_id, user_id)
|
||||
if visible is None:
|
||||
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
||||
if is_admin and visible.get("user_id") is None:
|
||||
return visible, "admin"
|
||||
raise HTTPException(status_code=403, detail="You can only modify agents that you created")
|
||||
|
||||
|
||||
@@ -321,6 +325,14 @@ async def list_agents(
|
||||
request: Request,
|
||||
search: str | None = Query(default=None, description="Case-insensitive fuzzy match on agent name"),
|
||||
tag_ids: list[str] | None = Query(default=None, description="Keep agents carrying any of these tags (OR)"),
|
||||
admin: bool = Query(
|
||||
default=False,
|
||||
description=(
|
||||
"Admin-only override. When ``true`` and the caller is a system admin, "
|
||||
"every agent is returned (built-in + every user's agents + private-square "
|
||||
"entries) regardless of the usual ownership/visibility filter."
|
||||
),
|
||||
),
|
||||
) -> AgentsListResponse:
|
||||
_require_agents_api_enabled()
|
||||
|
||||
@@ -329,7 +341,12 @@ async def list_agents(
|
||||
tag_store: TagStore = get_tag_store(request)
|
||||
await _sync_legacy_agents(store)
|
||||
user_id = _current_user_id(request)
|
||||
agents = await store.list_visible(user_id)
|
||||
if admin:
|
||||
if not _is_admin_user(request):
|
||||
raise HTTPException(status_code=403, detail="Only system admins can list all agents")
|
||||
agents = await store.list_all()
|
||||
else:
|
||||
agents = await store.list_visible(user_id)
|
||||
responses = [_record_to_response(a, include_soul=True) for a in agents]
|
||||
|
||||
assignments = await tag_store.list_assignments("agent", [r.id for r in responses])
|
||||
@@ -527,7 +544,10 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
|
||||
if not update_data:
|
||||
record = current
|
||||
elif edit_mode == "admin":
|
||||
record = await store.update_builtin(agent_id, update_data)
|
||||
# Admins may edit built-in (``user_id IS NULL``) and any user-owned
|
||||
# agent — ``update_any`` skips the ownership check that ``update``
|
||||
# and ``update_builtin`` enforce.
|
||||
record = await store.update_any(agent_id, update_data)
|
||||
else:
|
||||
record = await store.update(agent_id, user_id, update_data)
|
||||
if record is None:
|
||||
@@ -614,7 +634,7 @@ async def delete_agent(agent_id: str, request: Request) -> None:
|
||||
)
|
||||
|
||||
if edit_mode == "admin":
|
||||
deleted = await store.delete_builtin(agent_id)
|
||||
deleted = await store.delete_any(agent_id)
|
||||
else:
|
||||
deleted = await store.delete(agent_id, user_id)
|
||||
if not deleted:
|
||||
|
||||
@@ -695,11 +695,25 @@ 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)"),
|
||||
admin: bool = Query(
|
||||
default=False,
|
||||
description=(
|
||||
"Admin-only override. When ``true`` and the caller is a system admin, "
|
||||
"every custom skill is returned (including unpublished skills owned by "
|
||||
"other users) regardless of the usual ownership filter."
|
||||
),
|
||||
),
|
||||
) -> SkillsListResponse:
|
||||
try:
|
||||
user_id = _current_user_id(request)
|
||||
if admin and not _is_admin_user(request):
|
||||
raise HTTPException(status_code=403, detail="Only system admins can list all skills")
|
||||
all_skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
|
||||
ownership = await _build_ownership_index(skill_store, user_id)
|
||||
if admin:
|
||||
ownership_records = await skill_store.list_all()
|
||||
ownership = {record["name"]: record for record in ownership_records}
|
||||
else:
|
||||
ownership = await _build_ownership_index(skill_store, user_id)
|
||||
display_adapters = await _list_skill_display_adapters([skill.name for skill in all_skills])
|
||||
responses: list[SkillResponse] = []
|
||||
for skill in all_skills:
|
||||
@@ -720,7 +734,11 @@ async def list_skills(
|
||||
continue
|
||||
record = ownership.get(skill.name)
|
||||
if record is None:
|
||||
continue
|
||||
if not admin:
|
||||
continue
|
||||
# Admin sees every custom skill on disk even without an
|
||||
# ownership row (legacy / orphaned), so they can clean it up.
|
||||
record = None
|
||||
responses.append(
|
||||
_skill_to_response(
|
||||
skill,
|
||||
@@ -731,6 +749,8 @@ async def list_skills(
|
||||
)
|
||||
responses = await _attach_and_filter_tags(tag_store, responses, search, tag_ids)
|
||||
return SkillsListResponse(skills=responses)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load skills: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to load skills: {str(e)}")
|
||||
|
||||
+41
-7
@@ -61,6 +61,21 @@ _AUTH_PATTERNS = (
|
||||
"无权",
|
||||
"未授权",
|
||||
)
|
||||
# Upstream model gateway returned a malformed payload (typically a JSON decode
|
||||
# error wrapped as `bad_response_status_code`, or a truncated/garbled inference
|
||||
# response). These are almost always transient inference-side issues and safe
|
||||
# to retry.
|
||||
_UPSTREAM_GARBAGE_PATTERNS = (
|
||||
"bad_response_status_code",
|
||||
"bad response status",
|
||||
"expecting ',' delimiter",
|
||||
"expecting value",
|
||||
"expecting property name",
|
||||
"unexpected character",
|
||||
"unterminated string",
|
||||
"extra data",
|
||||
"jsondecodeerror",
|
||||
)
|
||||
|
||||
|
||||
class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||
@@ -139,9 +154,10 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||
detail = _extract_error_detail(exc)
|
||||
lowered = detail.lower()
|
||||
error_code = _extract_error_code(exc)
|
||||
error_code_lower = str(error_code).lower() if error_code is not None else ""
|
||||
status_code = _extract_status_code(exc)
|
||||
|
||||
if _matches_any(lowered, _QUOTA_PATTERNS) or _matches_any(str(error_code).lower(), _QUOTA_PATTERNS):
|
||||
if _matches_any(lowered, _QUOTA_PATTERNS) or _matches_any(error_code_lower, _QUOTA_PATTERNS):
|
||||
return False, "quota"
|
||||
if _matches_any(lowered, _AUTH_PATTERNS):
|
||||
return False, "auth"
|
||||
@@ -159,6 +175,11 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||
return True, "transient"
|
||||
if _matches_any(lowered, _BUSY_PATTERNS):
|
||||
return True, "busy"
|
||||
# Treat upstream gateway garbage (malformed JSON, truncated responses)
|
||||
# as a retriable transient — the deterministic char-position errors we
|
||||
# see from vLLM/proxy layers usually clear on a re-roll.
|
||||
if _matches_any(lowered, _UPSTREAM_GARBAGE_PATTERNS) or _matches_any(error_code_lower, _UPSTREAM_GARBAGE_PATTERNS):
|
||||
return True, "upstream"
|
||||
|
||||
return False, "generic"
|
||||
|
||||
@@ -171,21 +192,34 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||
|
||||
def _build_retry_message(self, attempt: int, wait_ms: int, reason: str) -> str:
|
||||
seconds = max(1, round(wait_ms / 1000))
|
||||
reason_text = "服务繁忙" if reason == "busy" else "网络/连接异常"
|
||||
if reason == "busy":
|
||||
reason_text = "服务繁忙"
|
||||
elif reason == "upstream":
|
||||
reason_text = "上游响应异常"
|
||||
else:
|
||||
reason_text = "网络/连接异常"
|
||||
return f"大模型因{reason_text}调用失败,正在第 {attempt}/{self.retry_max_attempts} 次重试,{seconds} 秒后继续…"
|
||||
|
||||
def _build_circuit_breaker_message(self) -> str:
|
||||
return "大模型因连续多次调用失败已被熔断保护,请稍候再试。"
|
||||
|
||||
def _build_user_message(self, exc: BaseException, reason: str) -> str:
|
||||
detail = _extract_error_detail(exc)
|
||||
def _build_user_message(self, reason: str) -> str:
|
||||
if reason == "quota":
|
||||
return "大模型因账户额度不足、账单异常或使用受限而拒绝请求,请检查模型账户后再试。"
|
||||
if reason == "auth":
|
||||
return "大模型因鉴权失败或无访问权限而拒绝请求,请检查模型的 API Key 与权限配置后再试。"
|
||||
if reason in {"busy", "transient"}:
|
||||
return "大模型因服务繁忙或网络异常,多次重试后仍未恢复,请稍候再继续。"
|
||||
return f"大模型因「{detail}」原因调用失败。"
|
||||
if reason == "upstream":
|
||||
return "大模型当前响应异常(上游模型或网关临时故障),多次重试后仍未恢复,请稍后再试或更换模型。"
|
||||
# NOTE: We intentionally do NOT embed the raw exception detail into the
|
||||
# user-facing message. Raw `Error code: 400 - {...}` strings — especially
|
||||
# Python repr-style dict literals — get persisted as AI messages and then
|
||||
# re-fed to the model on the next turn, where their unescaped quotes /
|
||||
# braces can corrupt the upstream JSON payload and cause deterministic
|
||||
# JSONDecodeError loops. Keep the user message clean; full detail is
|
||||
# already in the backend log via `logger.warning(..., exc_info=exc)`.
|
||||
return "大模型本次调用未能成功完成,请稍后再试或更换模型。"
|
||||
|
||||
def _emit_retry_event(self, attempt: int, wait_ms: int, reason: str) -> None:
|
||||
try:
|
||||
@@ -249,7 +283,7 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||
)
|
||||
if retriable:
|
||||
self._record_failure()
|
||||
return AIMessage(content=self._build_user_message(exc, reason))
|
||||
return AIMessage(content=self._build_user_message(reason))
|
||||
|
||||
@override
|
||||
async def awrap_model_call(
|
||||
@@ -295,7 +329,7 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||
)
|
||||
if retriable:
|
||||
self._record_failure()
|
||||
return AIMessage(content=self._build_user_message(exc, reason))
|
||||
return AIMessage(content=self._build_user_message(reason))
|
||||
|
||||
|
||||
def _matches_any(detail: str, patterns: tuple[str, ...]) -> bool:
|
||||
|
||||
@@ -90,3 +90,30 @@ class AgentStore(ABC):
|
||||
already verified the admin-configured private-square password.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def list_all(self) -> list[dict[str, Any]]:
|
||||
"""Return every agent regardless of ownership/visibility.
|
||||
|
||||
Caller is responsible for authorization — typically the caller has
|
||||
already verified ``system_role == "admin"``.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def update_any(self, agent_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Update any agent row by id, regardless of ownership.
|
||||
|
||||
Caller is responsible for authorization (admin only). Returns ``None``
|
||||
if the agent does not exist.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def delete_any(self, agent_id: str) -> bool:
|
||||
"""Delete any agent row by id, regardless of ownership.
|
||||
|
||||
Caller is responsible for authorization (admin only). Returns
|
||||
``False`` if the agent does not exist.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
+26
@@ -138,3 +138,29 @@ class MemoryAgentStore(AgentStore):
|
||||
rows = [self._agents[aid] for aid in agent_ids if aid in self._agents and self._is_visible(self._agents[aid], user_id)]
|
||||
rows.sort(key=lambda a: order_map.get(a["id"], 0))
|
||||
return [_copy(a) for a in rows]
|
||||
|
||||
async def list_private(self) -> list[dict[str, Any]]:
|
||||
rows = [_copy(a) for a in self._agents.values() if bool(a.get("is_private"))]
|
||||
rows.sort(key=lambda a: a.get("created_at") or "")
|
||||
return rows
|
||||
|
||||
async def list_all(self) -> list[dict[str, Any]]:
|
||||
rows = [_copy(a) for a in self._agents.values()]
|
||||
rows.sort(key=lambda a: a.get("created_at") or "")
|
||||
return rows
|
||||
|
||||
async def update_any(self, agent_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
row = self._agents.get(agent_id)
|
||||
if row is None:
|
||||
return None
|
||||
for key in ("name", "description", "published", "is_private"):
|
||||
if key in data:
|
||||
row[key] = data[key]
|
||||
row["updated_at"] = _now()
|
||||
return _copy(row)
|
||||
|
||||
async def delete_any(self, agent_id: str) -> bool:
|
||||
if agent_id not in self._agents:
|
||||
return False
|
||||
self._agents.pop(agent_id, None)
|
||||
return True
|
||||
@@ -202,3 +202,31 @@ class AgentRepository(AgentStore):
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
return [_row_to_dict(row) for row in result.scalars()]
|
||||
|
||||
async def list_all(self) -> list[dict[str, Any]]:
|
||||
stmt = select(AgentRow).order_by(AgentRow.created_at.asc())
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
return [_row_to_dict(row) for row in result.scalars()]
|
||||
|
||||
async def update_any(self, agent_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(AgentRow, agent_id)
|
||||
if row is None:
|
||||
return None
|
||||
for key in ("name", "description", "published", "is_private"):
|
||||
if key in data:
|
||||
setattr(row, key, data[key])
|
||||
row.updated_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return _row_to_dict(row)
|
||||
|
||||
async def delete_any(self, agent_id: str) -> bool:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(AgentRow, agent_id)
|
||||
if row is None:
|
||||
return False
|
||||
await session.delete(row)
|
||||
await session.commit()
|
||||
return True
|
||||
@@ -68,6 +68,14 @@ class SkillStore(ABC):
|
||||
"""Return the number of skills owned by ``user_id`` (owner_user_id IS NOT NULL)."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def list_all(self) -> list[dict[str, Any]]:
|
||||
"""Return every skill record regardless of ownership/visibility.
|
||||
|
||||
Caller is responsible for authorization (admin only).
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def list_published_names(self) -> set[str]:
|
||||
"""Return the set of skill names with ``published = True`` (any owner).
|
||||
|
||||
+6
@@ -134,5 +134,11 @@ class MemorySkillStore(SkillStore):
|
||||
async def count_owned(self, user_id: str) -> int:
|
||||
return sum(1 for s in self._skills.values() if s.get("owner_user_id") == user_id)
|
||||
|
||||
async def list_all(self) -> list[dict[str, Any]]:
|
||||
return [
|
||||
_with_derived(_copy(record)) # type: ignore[arg-type]
|
||||
for record in self._skills.values()
|
||||
]
|
||||
|
||||
async def list_published_names(self) -> set[str]:
|
||||
return {name for name, record in self._skills.items() if record.get("published")}
|
||||
@@ -149,6 +149,12 @@ class SkillRepository(SkillStore):
|
||||
async with self._sf() as session:
|
||||
return await session.scalar(stmt) or 0
|
||||
|
||||
async def list_all(self) -> list[dict[str, Any]]:
|
||||
stmt = select(SkillRow).order_by(SkillRow.created_at.asc())
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
return [_row_to_dict(row) for row in result.scalars()]
|
||||
|
||||
async def list_published_names(self) -> set[str]:
|
||||
stmt = select(SkillRow.name).where(SkillRow.published.is_(True))
|
||||
async with self._sf() as session:
|
||||
|
||||
Reference in new issue
Block a user