管理员智能体/技能管理 + 跨标签登录同步 + 上游响应异常重试

- 「设置和更多」新增「智能体管理」「技能管理」入口,与定时任务管理同级;
  独立的 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:
吉鑫升andClaude Opus 4.7 committed 2026-05-27 11:13:29 +08:00
1 parent 1642c3e694
commit eda0ccffdb
23 files changed
+660 -115

No files matched your search

+5 -1
View File
@@ -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>
+1 -1
View File
@@ -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,
@@ -155,7 +155,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")}
+10
View File
@@ -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`);
+16
View File
@@ -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],
+47
View File
@@ -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;
}
+70 -59
View File
@@ -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;
+48 -33
View File
@@ -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>
);
}
+140
View File
@@ -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";
@@ -260,6 +262,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={