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

- 「设置和更多」新增「智能体管理」「技能管理」入口,与定时任务管理同级;
  独立的 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 authored and xuhd committed 2026-05-28 18:28:49 +08:00
1 parent a5fb806028
commit 89bd07be92
23 files changed
+660 -115

No files matched your search

@@ -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";
@@ -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={