管理员智能体/技能管理 + 跨标签登录同步 + 上游响应异常重试
- 「设置和更多」新增「智能体管理」「技能管理」入口,与定时任务管理同级; 独立的 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
1642c3e694
commit
eda0ccffdb
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 { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||||
|
|
||||||
import { QueryClientProvider } from "@/components/query-client-provider";
|
import { QueryClientProvider } from "@/components/query-client-provider";
|
||||||
|
import { AuthSyncWatcher } from "@/core/auth/sync";
|
||||||
import { I18nProvider } from "@/core/i18n/context";
|
import { I18nProvider } from "@/core/i18n/context";
|
||||||
import { detectLocale } from "@/core/i18n/locale";
|
import { detectLocale } from "@/core/i18n/locale";
|
||||||
import { ChatProvider } from "@/contexts/ChatContext";
|
import { ChatProvider } from "@/contexts/ChatContext";
|
||||||
@@ -36,7 +37,10 @@ function AppProviders({ children }: { children: React.ReactNode }) {
|
|||||||
<QueryClientProvider>
|
<QueryClientProvider>
|
||||||
<I18nProvider initialLocale={locale}>
|
<I18nProvider initialLocale={locale}>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<ChatProvider>{children}</ChatProvider>
|
<ChatProvider>
|
||||||
|
<AuthSyncWatcher />
|
||||||
|
{children}
|
||||||
|
</ChatProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ const menuItems: MenuItem[] = [
|
|||||||
{ id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" },
|
{ id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" },
|
||||||
{
|
{
|
||||||
id: "mgmt-tasks",
|
id: "mgmt-tasks",
|
||||||
label: "任务管理",
|
label: "定时任务",
|
||||||
icon: ListChecks,
|
icon: ListChecks,
|
||||||
path: "/page/strategy/scheduled/tasks",
|
path: "/page/strategy/scheduled/tasks",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -85,11 +85,12 @@ export function AgentCard({ agent }: AgentCardProps) {
|
|||||||
const isOwned =
|
const isOwned =
|
||||||
!isBuiltin && agent.user_id !== null && agent.user_id === currentUserId;
|
!isBuiltin && agent.user_id !== null && agent.user_id === currentUserId;
|
||||||
const isFromOthers = !isBuiltin && !isOwned;
|
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
|
// Publish-toggle stays owner-only — built-ins are inherently visible to
|
||||||
// everyone so toggling published is moot.
|
// everyone so toggling published is moot.
|
||||||
const canEdit = isOwned || (isBuiltin && isAdmin);
|
const canEdit = isOwned || isAdmin;
|
||||||
const canDelete = isOwned || (isBuiltin && isAdmin);
|
const canDelete = isOwned || isAdmin;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editOpen) {
|
if (!editOpen) {
|
||||||
|
|||||||
@@ -333,7 +333,7 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SkillRow({
|
export function SkillRow({
|
||||||
skill,
|
skill,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ export function WorkspaceNavChatList({
|
|||||||
>
|
>
|
||||||
<Link href="/page/workspace/scheduled/tasks">
|
<Link href="/page/workspace/scheduled/tasks">
|
||||||
<ListChecks />
|
<ListChecks />
|
||||||
<span>任务管理</span>
|
<span>定时任务</span>
|
||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
BellIcon,
|
BellIcon,
|
||||||
|
BotIcon,
|
||||||
BrainIcon,
|
BrainIcon,
|
||||||
CalendarClockIcon,
|
CalendarClockIcon,
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
Settings2Icon,
|
Settings2Icon,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
ShieldCheckIcon,
|
ShieldCheckIcon,
|
||||||
|
SparklesIcon,
|
||||||
TagsIcon,
|
TagsIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -182,6 +184,22 @@ export function WorkspaceNavMenu() {
|
|||||||
定时任务管理
|
定时任务管理
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => navigate("/page/workspace/admin/agents")}
|
||||||
|
>
|
||||||
|
<BotIcon />
|
||||||
|
智能体管理
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => navigate("/page/workspace/admin/skills")}
|
||||||
|
>
|
||||||
|
<SparklesIcon />
|
||||||
|
技能管理
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => navigate("/page/workspace/admin/tags")}
|
onClick={() => navigate("/page/workspace/admin/tags")}
|
||||||
|
|||||||
@@ -38,6 +38,16 @@ export async function listAgents(params: ListAgentsParams = {}): Promise<Agent[]
|
|||||||
return data.agents;
|
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> {
|
export async function getAgent(id: string): Promise<Agent> {
|
||||||
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}`);
|
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}`);
|
||||||
if (!res.ok) throw new Error(`Agent '${id}' not found`);
|
if (!res.ok) throw new Error(`Agent '${id}' not found`);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
getAgent,
|
getAgent,
|
||||||
getAgentSelector,
|
getAgentSelector,
|
||||||
listAgents,
|
listAgents,
|
||||||
|
listAllAgentsAdmin,
|
||||||
listPrivateAgents,
|
listPrivateAgents,
|
||||||
reorderFeaturedAgents,
|
reorderFeaturedAgents,
|
||||||
setAgentFeatured,
|
setAgentFeatured,
|
||||||
@@ -48,6 +49,21 @@ export function useAgents(params: { search?: string; tagIds?: string[] } = {}) {
|
|||||||
return { agents: data ?? [], isLoading, error };
|
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) {
|
export function useAgent(id: string | null | undefined) {
|
||||||
const { data, isLoading, error } = useQuery({
|
const { data, isLoading, error } = useQuery({
|
||||||
queryKey: ["agents", id],
|
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 { getBackendBaseURL } from "@/core/config";
|
||||||
import { apiFetch } from "@/core/api";
|
import { apiFetch } from "@/core/api";
|
||||||
|
|
||||||
import type { Skill, SkillDisplayConfig, SkillState } from "./type";
|
import type { Skill, SkillDisplayConfig, SkillState } from "./type";
|
||||||
|
|
||||||
export interface ListSkillsParams {
|
export interface ListSkillsParams {
|
||||||
search?: string;
|
search?: string;
|
||||||
@@ -20,6 +20,17 @@ export async function loadSkills(params: ListSkillsParams = {}) {
|
|||||||
return json.skills as Skill[];
|
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> {
|
export async function getSkill(skillName: string): Promise<Skill> {
|
||||||
const response = await apiFetch(
|
const response = await apiFetch(
|
||||||
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}`,
|
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}`,
|
||||||
@@ -30,7 +41,7 @@ export async function getSkill(skillName: string): Promise<Skill> {
|
|||||||
return response.json() as 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(
|
const response = await apiFetch(
|
||||||
`${getBackendBaseURL()}/api/skills/${skillName}`,
|
`${getBackendBaseURL()}/api/skills/${skillName}`,
|
||||||
{
|
{
|
||||||
@@ -44,63 +55,63 @@ export async function enableSkill(skillName: string, enabled: boolean) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setSkillDisplay(
|
export async function setSkillDisplay(
|
||||||
skillName: string,
|
skillName: string,
|
||||||
display: SkillDisplayConfig | null,
|
display: SkillDisplayConfig | null,
|
||||||
sample?: Record<string, unknown> | null,
|
sample?: Record<string, unknown> | null,
|
||||||
): Promise<Skill> {
|
): Promise<Skill> {
|
||||||
const response = await apiFetch(
|
const response = await apiFetch(
|
||||||
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/display`,
|
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/display`,
|
||||||
{
|
{
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ display, sample }),
|
body: JSON.stringify({ display, sample }),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw await parseDetailedError(response, "Failed to update skill display adapter");
|
throw await parseDetailedError(response, "Failed to update skill display adapter");
|
||||||
}
|
}
|
||||||
return response.json() as Promise<Skill>;
|
return response.json() as Promise<Skill>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SkillDisplaySampleResponse {
|
export interface SkillDisplaySampleResponse {
|
||||||
skill_name: string;
|
skill_name: string;
|
||||||
sample: Record<string, unknown>;
|
sample: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PersistedSkillDisplaySampleResponse {
|
export interface PersistedSkillDisplaySampleResponse {
|
||||||
skill_name: string;
|
skill_name: string;
|
||||||
sample: Record<string, unknown> | null;
|
sample: Record<string, unknown> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPersistedSkillDisplaySample(
|
export async function getPersistedSkillDisplaySample(
|
||||||
skillName: string,
|
skillName: string,
|
||||||
): Promise<PersistedSkillDisplaySampleResponse> {
|
): Promise<PersistedSkillDisplaySampleResponse> {
|
||||||
const response = await apiFetch(
|
const response = await apiFetch(
|
||||||
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/display-sample`,
|
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/display-sample`,
|
||||||
);
|
);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw await parseDetailedError(response, "Failed to get persisted skill display sample");
|
throw await parseDetailedError(response, "Failed to get persisted skill display sample");
|
||||||
}
|
}
|
||||||
return response.json() as Promise<PersistedSkillDisplaySampleResponse>;
|
return response.json() as Promise<PersistedSkillDisplaySampleResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSkillDisplaySample(
|
export async function getSkillDisplaySample(
|
||||||
skillName: string,
|
skillName: string,
|
||||||
): Promise<SkillDisplaySampleResponse> {
|
): Promise<SkillDisplaySampleResponse> {
|
||||||
const response = await apiFetch(
|
const response = await apiFetch(
|
||||||
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/display-sample`,
|
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/display-sample`,
|
||||||
{ method: "POST" },
|
{ method: "POST" },
|
||||||
);
|
);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw await parseDetailedError(response, "Failed to get skill display sample");
|
throw await parseDetailedError(response, "Failed to get skill display sample");
|
||||||
}
|
}
|
||||||
return response.json() as Promise<SkillDisplaySampleResponse>;
|
return response.json() as Promise<SkillDisplaySampleResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InstallSkillRequest {
|
export interface InstallSkillRequest {
|
||||||
thread_id: string;
|
thread_id: string;
|
||||||
|
|||||||
@@ -14,17 +14,17 @@ import {
|
|||||||
getSkillFiles,
|
getSkillFiles,
|
||||||
installSkillUpload,
|
installSkillUpload,
|
||||||
reconcileCustomSkills,
|
reconcileCustomSkills,
|
||||||
saveSkillFile,
|
saveSkillFile,
|
||||||
setSkillDetail,
|
setSkillDetail,
|
||||||
setSkillDisplay,
|
setSkillDisplay,
|
||||||
setSkillPinned,
|
setSkillPinned,
|
||||||
setSkillPublished,
|
setSkillPublished,
|
||||||
setSkillState,
|
setSkillState,
|
||||||
validateSkillUpload,
|
validateSkillUpload,
|
||||||
} from "./api";
|
} 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[] } = {}) {
|
export function useSkills(params: { search?: string; tagIds?: string[] } = {}) {
|
||||||
const search = params.search ?? "";
|
const search = params.search ?? "";
|
||||||
@@ -37,6 +37,21 @@ export function useSkills(params: { search?: string; tagIds?: string[] } = {}) {
|
|||||||
return { skills: data ?? [], isLoading, error };
|
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) {
|
export function useSkillContent(skillName: string | null) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["skill-content", skillName],
|
queryKey: ["skill-content", skillName],
|
||||||
@@ -53,7 +68,7 @@ export function useSkill(skillName: string | null) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSetSkillDetail() {
|
export function useSetSkillDetail() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ skillName, detail }: { skillName: string; detail: string }) =>
|
mutationFn: ({ skillName, detail }: { skillName: string; detail: string }) =>
|
||||||
@@ -63,32 +78,32 @@ export function useSetSkillDetail() {
|
|||||||
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
|
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSetSkillDisplay() {
|
export function useSetSkillDisplay() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({
|
mutationFn: ({
|
||||||
skillName,
|
skillName,
|
||||||
display,
|
display,
|
||||||
sample,
|
sample,
|
||||||
}: {
|
}: {
|
||||||
skillName: string;
|
skillName: string;
|
||||||
display: Parameters<typeof setSkillDisplay>[1];
|
display: Parameters<typeof setSkillDisplay>[1];
|
||||||
sample?: Parameters<typeof setSkillDisplay>[2];
|
sample?: Parameters<typeof setSkillDisplay>[2];
|
||||||
}) => setSkillDisplay(skillName, display, sample),
|
}) => setSkillDisplay(skillName, display, sample),
|
||||||
onSuccess: (data, { skillName }) => {
|
onSuccess: (data, { skillName }) => {
|
||||||
queryClient.setQueryData(["skill", skillName], data);
|
queryClient.setQueryData(["skill", skillName], data);
|
||||||
queryClient.setQueriesData<Skill[]>({ queryKey: ["skills"] }, (current) =>
|
queryClient.setQueriesData<Skill[]>({ queryKey: ["skills"] }, (current) =>
|
||||||
Array.isArray(current)
|
Array.isArray(current)
|
||||||
? current.map((item) => (item.name === skillName ? { ...item, ...data } : item))
|
? current.map((item) => (item.name === skillName ? { ...item, ...data } : item))
|
||||||
: current,
|
: current,
|
||||||
);
|
);
|
||||||
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||||
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
|
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useEnableSkill() {
|
export function useEnableSkill() {
|
||||||
const queryClient = useQueryClient();
|
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 AgentsPage from "./AgentsPage";
|
||||||
import AgentChatPage from "./AgentChatPage";
|
import AgentChatPage from "./AgentChatPage";
|
||||||
import NewAgentPage from "./NewAgentPage";
|
import NewAgentPage from "./NewAgentPage";
|
||||||
|
import AdminAgentsPage from "./AdminAgentsPage";
|
||||||
import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
|
import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
|
||||||
|
import AdminSkillsPage from "./AdminSkillsPage";
|
||||||
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
|
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
|
||||||
import ScheduledTasksPage from "./ScheduledTasksPage";
|
import ScheduledTasksPage from "./ScheduledTasksPage";
|
||||||
import LlmMetricsPage from "./LlmMetricsPage";
|
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
|
<Route
|
||||||
path="admin/tags"
|
path="admin/tags"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -297,17 +297,21 @@ async def _get_editable_or_raise(
|
|||||||
) -> tuple[dict[str, Any], str]:
|
) -> tuple[dict[str, Any], str]:
|
||||||
"""Return (record, edit_mode) where edit_mode is 'owner' or 'admin'.
|
"""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
|
Admins may edit any agent — built-in (``user_id IS NULL``) or owned by
|
||||||
caller is a system admin. Custom agents owned by other users still 403.
|
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)
|
owned = await store.get_owned(agent_id, user_id)
|
||||||
if owned is not None:
|
if owned is not None:
|
||||||
return owned, "owner"
|
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)
|
visible = await store.get_visible(agent_id, user_id)
|
||||||
if visible is None:
|
if visible is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
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")
|
raise HTTPException(status_code=403, detail="You can only modify agents that you created")
|
||||||
|
|
||||||
|
|
||||||
@@ -321,6 +325,14 @@ async def list_agents(
|
|||||||
request: Request,
|
request: Request,
|
||||||
search: str | None = Query(default=None, description="Case-insensitive fuzzy match on agent name"),
|
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)"),
|
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:
|
) -> AgentsListResponse:
|
||||||
_require_agents_api_enabled()
|
_require_agents_api_enabled()
|
||||||
|
|
||||||
@@ -329,7 +341,12 @@ async def list_agents(
|
|||||||
tag_store: TagStore = get_tag_store(request)
|
tag_store: TagStore = get_tag_store(request)
|
||||||
await _sync_legacy_agents(store)
|
await _sync_legacy_agents(store)
|
||||||
user_id = _current_user_id(request)
|
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]
|
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])
|
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:
|
if not update_data:
|
||||||
record = current
|
record = current
|
||||||
elif edit_mode == "admin":
|
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:
|
else:
|
||||||
record = await store.update(agent_id, user_id, update_data)
|
record = await store.update(agent_id, user_id, update_data)
|
||||||
if record is None:
|
if record is None:
|
||||||
@@ -614,7 +634,7 @@ async def delete_agent(agent_id: str, request: Request) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if edit_mode == "admin":
|
if edit_mode == "admin":
|
||||||
deleted = await store.delete_builtin(agent_id)
|
deleted = await store.delete_any(agent_id)
|
||||||
else:
|
else:
|
||||||
deleted = await store.delete(agent_id, user_id)
|
deleted = await store.delete(agent_id, user_id)
|
||||||
if not deleted:
|
if not deleted:
|
||||||
|
|||||||
@@ -695,11 +695,25 @@ async def list_skills(
|
|||||||
tag_store: TagStore = Depends(get_tag_store),
|
tag_store: TagStore = Depends(get_tag_store),
|
||||||
search: str | None = Query(default=None, description="Case-insensitive fuzzy match on skill name"),
|
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)"),
|
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:
|
) -> SkillsListResponse:
|
||||||
try:
|
try:
|
||||||
user_id = _current_user_id(request)
|
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)
|
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])
|
display_adapters = await _list_skill_display_adapters([skill.name for skill in all_skills])
|
||||||
responses: list[SkillResponse] = []
|
responses: list[SkillResponse] = []
|
||||||
for skill in all_skills:
|
for skill in all_skills:
|
||||||
@@ -720,7 +734,11 @@ async def list_skills(
|
|||||||
continue
|
continue
|
||||||
record = ownership.get(skill.name)
|
record = ownership.get(skill.name)
|
||||||
if record is None:
|
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(
|
responses.append(
|
||||||
_skill_to_response(
|
_skill_to_response(
|
||||||
skill,
|
skill,
|
||||||
@@ -731,6 +749,8 @@ async def list_skills(
|
|||||||
)
|
)
|
||||||
responses = await _attach_and_filter_tags(tag_store, responses, search, tag_ids)
|
responses = await _attach_and_filter_tags(tag_store, responses, search, tag_ids)
|
||||||
return SkillsListResponse(skills=responses)
|
return SkillsListResponse(skills=responses)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to load skills: {e}", exc_info=True)
|
logger.error(f"Failed to load skills: {e}", exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to load skills: {str(e)}")
|
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]):
|
class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||||
@@ -139,9 +154,10 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
|||||||
detail = _extract_error_detail(exc)
|
detail = _extract_error_detail(exc)
|
||||||
lowered = detail.lower()
|
lowered = detail.lower()
|
||||||
error_code = _extract_error_code(exc)
|
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)
|
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"
|
return False, "quota"
|
||||||
if _matches_any(lowered, _AUTH_PATTERNS):
|
if _matches_any(lowered, _AUTH_PATTERNS):
|
||||||
return False, "auth"
|
return False, "auth"
|
||||||
@@ -159,6 +175,11 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
|||||||
return True, "transient"
|
return True, "transient"
|
||||||
if _matches_any(lowered, _BUSY_PATTERNS):
|
if _matches_any(lowered, _BUSY_PATTERNS):
|
||||||
return True, "busy"
|
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"
|
return False, "generic"
|
||||||
|
|
||||||
@@ -171,21 +192,34 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
|||||||
|
|
||||||
def _build_retry_message(self, attempt: int, wait_ms: int, reason: str) -> str:
|
def _build_retry_message(self, attempt: int, wait_ms: int, reason: str) -> str:
|
||||||
seconds = max(1, round(wait_ms / 1000))
|
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} 秒后继续…"
|
return f"大模型因{reason_text}调用失败,正在第 {attempt}/{self.retry_max_attempts} 次重试,{seconds} 秒后继续…"
|
||||||
|
|
||||||
def _build_circuit_breaker_message(self) -> str:
|
def _build_circuit_breaker_message(self) -> str:
|
||||||
return "大模型因连续多次调用失败已被熔断保护,请稍候再试。"
|
return "大模型因连续多次调用失败已被熔断保护,请稍候再试。"
|
||||||
|
|
||||||
def _build_user_message(self, exc: BaseException, reason: str) -> str:
|
def _build_user_message(self, reason: str) -> str:
|
||||||
detail = _extract_error_detail(exc)
|
|
||||||
if reason == "quota":
|
if reason == "quota":
|
||||||
return "大模型因账户额度不足、账单异常或使用受限而拒绝请求,请检查模型账户后再试。"
|
return "大模型因账户额度不足、账单异常或使用受限而拒绝请求,请检查模型账户后再试。"
|
||||||
if reason == "auth":
|
if reason == "auth":
|
||||||
return "大模型因鉴权失败或无访问权限而拒绝请求,请检查模型的 API Key 与权限配置后再试。"
|
return "大模型因鉴权失败或无访问权限而拒绝请求,请检查模型的 API Key 与权限配置后再试。"
|
||||||
if reason in {"busy", "transient"}:
|
if reason in {"busy", "transient"}:
|
||||||
return "大模型因服务繁忙或网络异常,多次重试后仍未恢复,请稍候再继续。"
|
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:
|
def _emit_retry_event(self, attempt: int, wait_ms: int, reason: str) -> None:
|
||||||
try:
|
try:
|
||||||
@@ -249,7 +283,7 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
|||||||
)
|
)
|
||||||
if retriable:
|
if retriable:
|
||||||
self._record_failure()
|
self._record_failure()
|
||||||
return AIMessage(content=self._build_user_message(exc, reason))
|
return AIMessage(content=self._build_user_message(reason))
|
||||||
|
|
||||||
@override
|
@override
|
||||||
async def awrap_model_call(
|
async def awrap_model_call(
|
||||||
@@ -295,7 +329,7 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
|||||||
)
|
)
|
||||||
if retriable:
|
if retriable:
|
||||||
self._record_failure()
|
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:
|
def _matches_any(detail: str, patterns: tuple[str, ...]) -> bool:
|
||||||
|
|||||||
@@ -90,3 +90,30 @@ class AgentStore(ABC):
|
|||||||
already verified the admin-configured private-square password.
|
already verified the admin-configured private-square password.
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError
|
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 = [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))
|
rows.sort(key=lambda a: order_map.get(a["id"], 0))
|
||||||
return [_copy(a) for a in rows]
|
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:
|
async with self._sf() as session:
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
return [_row_to_dict(row) for row in result.scalars()]
|
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)."""
|
"""Return the number of skills owned by ``user_id`` (owner_user_id IS NOT NULL)."""
|
||||||
raise NotImplementedError
|
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
|
@abstractmethod
|
||||||
async def list_published_names(self) -> set[str]:
|
async def list_published_names(self) -> set[str]:
|
||||||
"""Return the set of skill names with ``published = True`` (any owner).
|
"""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:
|
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)
|
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]:
|
async def list_published_names(self) -> set[str]:
|
||||||
return {name for name, record in self._skills.items() if record.get("published")}
|
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:
|
async with self._sf() as session:
|
||||||
return await session.scalar(stmt) or 0
|
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]:
|
async def list_published_names(self) -> set[str]:
|
||||||
stmt = select(SkillRow.name).where(SkillRow.published.is_(True))
|
stmt = select(SkillRow.name).where(SkillRow.published.is_(True))
|
||||||
async with self._sf() as session:
|
async with self._sf() as session:
|
||||||
|
|||||||
Reference in new issue
Block a user