diff --git a/frontend-web/src/App.tsx b/frontend-web/src/App.tsx index b7508f12..ea03c986 100644 --- a/frontend-web/src/App.tsx +++ b/frontend-web/src/App.tsx @@ -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 }) { - {children} + + + {children} + diff --git a/frontend-web/src/components/page-sidebar.tsx b/frontend-web/src/components/page-sidebar.tsx index 87645acb..810e9e74 100644 --- a/frontend-web/src/components/page-sidebar.tsx +++ b/frontend-web/src/components/page-sidebar.tsx @@ -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", }, diff --git a/frontend-web/src/components/workspace/agents/agent-card.tsx b/frontend-web/src/components/workspace/agents/agent-card.tsx index 5ee1c8d3..704ebd87 100644 --- a/frontend-web/src/components/workspace/agents/agent-card.tsx +++ b/frontend-web/src/components/workspace/agents/agent-card.tsx @@ -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) { 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 04d42e06..e53ef9af 100644 --- a/frontend-web/src/components/workspace/settings/skill-settings-page.tsx +++ b/frontend-web/src/components/workspace/settings/skill-settings-page.tsx @@ -333,7 +333,7 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) { ); } -function SkillRow({ +export function SkillRow({ skill, currentUserId, isAdmin, diff --git a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx index c4d9c969..7b45abe7 100644 --- a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx +++ b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx @@ -155,7 +155,7 @@ export function WorkspaceNavChatList({ > - 任务管理 + 定时任务 diff --git a/frontend-web/src/components/workspace/workspace-nav-menu.tsx b/frontend-web/src/components/workspace/workspace-nav-menu.tsx index 110cd8b9..d87b34d9 100644 --- a/frontend-web/src/components/workspace/workspace-nav-menu.tsx +++ b/frontend-web/src/components/workspace/workspace-nav-menu.tsx @@ -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() { 定时任务管理 )} + {isAdmin && ( + navigate("/page/workspace/admin/agents")} + > + + 智能体管理 + + )} + {isAdmin && ( + navigate("/page/workspace/admin/skills")} + > + + 技能管理 + + )} {isAdmin && ( navigate("/page/workspace/admin/tags")} diff --git a/frontend-web/src/core/agents/api.ts b/frontend-web/src/core/agents/api.ts index 3fb17baa..83eabbe8 100644 --- a/frontend-web/src/core/agents/api.ts +++ b/frontend-web/src/core/agents/api.ts @@ -38,6 +38,16 @@ export async function listAgents(params: ListAgentsParams = {}): Promise { + 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 { const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}`); if (!res.ok) throw new Error(`Agent '${id}' not found`); diff --git a/frontend-web/src/core/agents/hooks.ts b/frontend-web/src/core/agents/hooks.ts index 802283f6..9099d63c 100644 --- a/frontend-web/src/core/agents/hooks.ts +++ b/frontend-web/src/core/agents/hooks.ts @@ -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], diff --git a/frontend-web/src/core/auth/sync.tsx b/frontend-web/src/core/auth/sync.tsx new file mode 100644 index 00000000..e9f3b460 --- /dev/null +++ b/frontend-web/src/core/auth/sync.tsx @@ -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(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; +} diff --git a/frontend-web/src/core/skills/api.ts b/frontend-web/src/core/skills/api.ts index 01a64404..c098d44f 100644 --- a/frontend-web/src/core/skills/api.ts +++ b/frontend-web/src/core/skills/api.ts @@ -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 { const response = await apiFetch( `${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}`, @@ -30,7 +41,7 @@ export async function getSkill(skillName: string): Promise { return response.json() as Promise; } -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 | null, -): Promise { - 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; -} - -export interface SkillDisplaySampleResponse { - skill_name: string; - sample: Record; -} - -export interface PersistedSkillDisplaySampleResponse { - skill_name: string; - sample: Record | null; -} - -export async function getPersistedSkillDisplaySample( - skillName: string, -): Promise { - 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; -} - -export async function getSkillDisplaySample( - skillName: string, -): Promise { - 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; -} +} + +export async function setSkillDisplay( + skillName: string, + display: SkillDisplayConfig | null, + sample?: Record | null, +): Promise { + 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; +} + +export interface SkillDisplaySampleResponse { + skill_name: string; + sample: Record; +} + +export interface PersistedSkillDisplaySampleResponse { + skill_name: string; + sample: Record | null; +} + +export async function getPersistedSkillDisplaySample( + skillName: string, +): Promise { + 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; +} + +export async function getSkillDisplaySample( + skillName: string, +): Promise { + 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; +} export interface InstallSkillRequest { thread_id: string; diff --git a/frontend-web/src/core/skills/hooks.ts b/frontend-web/src/core/skills/hooks.ts index aa27bb01..c869f40f 100644 --- a/frontend-web/src/core/skills/hooks.ts +++ b/frontend-web/src/core/skills/hooks.ts @@ -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[1]; - sample?: Parameters[2]; - }) => setSkillDisplay(skillName, display, sample), - onSuccess: (data, { skillName }) => { - queryClient.setQueryData(["skill", skillName], data); - queryClient.setQueriesData({ 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[1]; + sample?: Parameters[2]; + }) => setSkillDisplay(skillName, display, sample), + onSuccess: (data, { skillName }) => { + queryClient.setQueryData(["skill", skillName], data); + queryClient.setQueriesData({ 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(); diff --git a/frontend-web/src/pages/AdminAgentsPage.tsx b/frontend-web/src/pages/AdminAgentsPage.tsx new file mode 100644 index 00000000..9a94f848 --- /dev/null +++ b/frontend-web/src/pages/AdminAgentsPage.tsx @@ -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 ( + + + +
+
+
+

智能体管理

+

+ 管理员视图:查看、编辑、删除所有用户的智能体(包括内置、用户自定义与私密广场条目)。 +

+
+
+ + setSearch(e.target.value)} + placeholder="按名称搜索" + className="pl-8" + /> +
+
+ +
+ {isLoading ? ( +
+ 加载中… +
+ ) : filteredAgents.length === 0 ? ( +
+
+ +
+

+ 当前没有可管理的智能体。 +

+
+ ) : ( + <> +

+ 共 {filteredAgents.length} 个智能体。 +

+
+ {filteredAgents.map((agent) => ( + + ))} +
+ + )} +
+
+
+
+ ); +} diff --git a/frontend-web/src/pages/AdminSkillsPage.tsx b/frontend-web/src/pages/AdminSkillsPage.tsx new file mode 100644 index 00000000..521ad859 --- /dev/null +++ b/frontend-web/src/pages/AdminSkillsPage.tsx @@ -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 ( + + + +
+
+
+

技能管理

+

+ 管理员视图:查看、编辑、删除所有用户的技能(包括内置技能与未发布的自定义技能)。 +

+
+
+ + setSearch(e.target.value)} + placeholder="按名称搜索" + className="pl-8" + /> +
+
+ +
+ {isLoading && ( +
+ 加载中… +
+ )} + {error && ( +
+ 加载失败:{error.message} +
+ )} + {!isLoading && !error && filteredSkills.length === 0 && ( +
+
+ +
+

+ 当前没有可管理的技能。 +

+
+ )} + {!isLoading && !error && filteredSkills.length > 0 && ( + <> +

+ 共 {filteredSkills.length} 个技能。 +

+ {pagedSkills.map((skill) => ( + + ))} + + )} + {!isLoading && !error && totalPages > 1 && ( +
+ + + 第 {page} / {totalPages} 页 + + +
+ )} +
+
+
+
+ ); +} diff --git a/frontend-web/src/pages/WorkspaceRoutes.tsx b/frontend-web/src/pages/WorkspaceRoutes.tsx index 3d05b53c..8ad09805 100644 --- a/frontend-web/src/pages/WorkspaceRoutes.tsx +++ b/frontend-web/src/pages/WorkspaceRoutes.tsx @@ -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({ ) } /> + + + + ) : ( + + ) + } + /> + + + + ) : ( + + ) + } + /> 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: diff --git a/offline-backend-20260512/backend/app/gateway/routers/skills.py b/offline-backend-20260512/backend/app/gateway/routers/skills.py index 2a1c6496..9e6dc8d6 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/skills.py +++ b/offline-backend-20260512/backend/app/gateway/routers/skills.py @@ -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)}") diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/llm_error_handling_middleware.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/llm_error_handling_middleware.py index 02ced2c8..7d641553 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/llm_error_handling_middleware.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/llm_error_handling_middleware.py @@ -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: 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 39b5e6f7..84d38732 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 @@ -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 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 269f3660..6ec8d840 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 @@ -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 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 60474ac5..2fa22e25 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 @@ -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 diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/skills/base.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/skills/base.py index 4f82cb4d..f84ea8a5 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/skills/base.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/skills/base.py @@ -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). diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/skills/memory.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/skills/memory.py index 836341f7..d774f560 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/skills/memory.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/skills/memory.py @@ -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")} diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/skills/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/skills/sql.py index 84f9a91d..e17dd015 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/skills/sql.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/skills/sql.py @@ -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: