diff --git a/src-frontend/config/i18next.config.ts b/src-frontend/config/i18next.config.ts index 1414738..d85b7ba 100644 --- a/src-frontend/config/i18next.config.ts +++ b/src-frontend/config/i18next.config.ts @@ -8,6 +8,10 @@ export default defineConfig({ extract: { input: "src/**/*.{js,jsx,ts,tsx}", output: "src/i18n/locales/{{language}}/{{namespace}}.json", + preservePatterns: [ + "error:*", + "sidebar:toolsets.status.*", + ], defaultNS: false, } }); diff --git a/src-frontend/src/api/orval-mutator/custom-fetch.ts b/src-frontend/src/api/orval-mutator/custom-fetch.ts index f28a2c1..d54fafc 100644 --- a/src-frontend/src/api/orval-mutator/custom-fetch.ts +++ b/src-frontend/src/api/orval-mutator/custom-fetch.ts @@ -1,19 +1,29 @@ import { API_BASE } from ".."; +import { ApiErrorCode, McpConnectErrorCode, ServiceErrorCode } from "../generated/schemas"; + +export type ErrorCode = + | ApiErrorCode + | McpConnectErrorCode + | ServiceErrorCode + | "NETWORK_ERROR" + | "UNEXPECTED_ERROR"; export type ErrorResponse = { - error_code: string; + error_code: ErrorCode; message: string; }; export class FetchError extends Error { statusCode: number; - errorCode: string; + errorCode: ErrorCode; + message: string; constructor(statusCode: number, error: E) { super(error.message); this.name = "FetchError"; this.statusCode = statusCode; this.errorCode = error.error_code; + this.message = error.message; } } @@ -24,7 +34,16 @@ export async function fetchApi( init?: RequestInit ): Promise { const url = new URL(input.toString(), API_BASE); - const res = await fetch(url, init); + let res: Response; + try { + res = await fetch(url, init); + } catch { + throw new FetchError(500, { + error_code: "NETWORK_ERROR", + message: "Network error when fetching", + }); + } + if (res.ok) { if (res.status === 204) { return undefined as T; @@ -32,16 +51,15 @@ export async function fetchApi( return (await res.json()) as T; } + let errorBody: ErrorResponse; try { - const errorBody = (await res.json()) as ErrorResponse; - throw new FetchError(res.status, errorBody); + errorBody = (await res.json()) as ErrorResponse; } catch { - console.warn( - `Failed to parse error response as JSON: ${res.status} ${res.statusText}` - ); + console.warn(`Failed to parse error response as JSON: ${res.status} ${res.statusText}`); throw new FetchError(res.status, { - error_code: "HTTP_ERROR", + error_code: "UNEXPECTED_ERROR", message: res.statusText, }); } + throw new FetchError(res.status, errorBody); } diff --git a/src-frontend/src/api/toolset.ts b/src-frontend/src/api/toolset.ts index 8e8bc6b..b1f7e0b 100644 --- a/src-frontend/src/api/toolset.ts +++ b/src-frontend/src/api/toolset.ts @@ -2,7 +2,7 @@ export { getGetToolsetQueryKey, getGetToolsetsQueryKey, getGetToolsetsBriefQueryKey, - useCreateMcpToolset, + useCreateToolset, useDeleteToolset, useGetToolsetSuspense, useGetToolsetsSuspense, diff --git a/src-frontend/src/components/custom/AsyncBoundary.tsx b/src-frontend/src/components/custom/AsyncBoundary.tsx index d9ea76d..011efd1 100644 --- a/src-frontend/src/components/custom/AsyncBoundary.tsx +++ b/src-frontend/src/components/custom/AsyncBoundary.tsx @@ -6,17 +6,14 @@ import { FailedToLoad } from "./FailedToLoad"; type AsyncBoundaryProps = { children: React.ReactNode; skeleton?: React.ReactNode; + errorRender?: (props: FallbackProps) => React.ReactNode; onError?: (error: unknown, info: React.ErrorInfo) => void; -} & MustOneOf<{ - errorDescription: string | ((error: unknown) => string); - errorRender: (props: FallbackProps) => React.ReactNode; -}>; +}; export function AsyncBoundary({ children, skeleton, onError, - errorDescription, errorRender, }: AsyncBoundaryProps) { return ( @@ -29,12 +26,8 @@ export function AsyncBoundary({ errorRender ?? ((props) => ( )) } diff --git a/src-frontend/src/components/custom/FailedToLoad.tsx b/src-frontend/src/components/custom/FailedToLoad.tsx index a911839..cbc4d69 100644 --- a/src-frontend/src/components/custom/FailedToLoad.tsx +++ b/src-frontend/src/components/custom/FailedToLoad.tsx @@ -1,4 +1,5 @@ import { RefreshCcwIcon } from "lucide-react"; +import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { Empty, @@ -6,24 +7,46 @@ import { EmptyDescription, EmptyTitle, } from "@/components/ui/empty"; +import { FetchError } from "@/api/orval-mutator/custom-fetch"; +import { getErrorMessage } from "@/i18n/error-message"; +import { COMPONENTS_CUSTOM_NAMESPACE } from "@/i18n/resources"; +import { i18n } from "@/i18n"; type FailedToLoadProps = { - refetch: () => void; + className?: string; + retry?: () => void; + title?: string; +} & MustOneOf<{ description: string; -}; + error: unknown; +}>; + +export function FailedToLoad({ + className, + title = i18n.t("load_failed.title", { ns: COMPONENTS_CUSTOM_NAMESPACE }), + description, + error, + retry, +}: FailedToLoadProps) { + const { t } = useTranslation(COMPONENTS_CUSTOM_NAMESPACE); + let errorDescription: string; + if (description) { + errorDescription = description; + } else if (error instanceof FetchError) { + errorDescription = getErrorMessage(error.errorCode); + } else { + errorDescription = getErrorMessage("UNEXPECTED_ERROR"); + } -export function FailedToLoad({ refetch, description }: FailedToLoadProps) { return ( - + - 加载失败 - -
{description}
-
+ {title} + {errorDescription} -
diff --git a/src-frontend/src/components/custom/dialog/resource-dialog/ModelSelectDialog.tsx b/src-frontend/src/components/custom/dialog/resource-dialog/ModelSelectDialog.tsx index 3b2804a..115064d 100644 --- a/src-frontend/src/components/custom/dialog/resource-dialog/ModelSelectDialog.tsx +++ b/src-frontend/src/components/custom/dialog/resource-dialog/ModelSelectDialog.tsx @@ -98,10 +98,7 @@ export function ModelSelectDialog({ value, onChange: onSelect }: ModelSelectDial - } - errorDescription={t("resource.model.load_error")} - > + }> diff --git a/src-frontend/src/components/custom/dialog/resource-dialog/ToolMultiSelectDialog.tsx b/src-frontend/src/components/custom/dialog/resource-dialog/ToolMultiSelectDialog.tsx index 0deb77d..8f1dd55 100644 --- a/src-frontend/src/components/custom/dialog/resource-dialog/ToolMultiSelectDialog.tsx +++ b/src-frontend/src/components/custom/dialog/resource-dialog/ToolMultiSelectDialog.tsx @@ -73,10 +73,7 @@ export function ToolMultiSelectDialog({ value, onChange }: ToolMultiSelectDialog {t("resource.tool.empty")} - } - errorDescription={t("resource.tool.load_error")} - > + }> diff --git a/src-frontend/src/components/custom/form/FormShell.tsx b/src-frontend/src/components/custom/form/FormShell.tsx index 1a47af7..c4b438e 100644 --- a/src-frontend/src/components/custom/form/FormShell.tsx +++ b/src-frontend/src/components/custom/form/FormShell.tsx @@ -35,7 +35,7 @@ export function FormShell({ onSubmit={methods.handleSubmit(onSubmit)} className={cn( "py-4", - "pr-1", // prevent outline of form controls from being cut off + "px-1", // prevent outline of form controls from being cut off className)} > {children} diff --git a/src-frontend/src/components/custom/form/fields/DirectoryField.tsx b/src-frontend/src/components/custom/form/fields/DirectoryField.tsx index 7bed7bd..7a3fbc5 100644 --- a/src-frontend/src/components/custom/form/fields/DirectoryField.tsx +++ b/src-frontend/src/components/custom/form/fields/DirectoryField.tsx @@ -29,6 +29,7 @@ export function DirectoryField({ async function chooseDirectory() { try { + // TODO: use unified api const selected = await open({ directory: true }); if (typeof selected === "string") { setValue(fieldName, selected, { diff --git a/src-frontend/src/features/SideBar/views/AgentsView/AgentList.tsx b/src-frontend/src/features/SideBar/views/AgentsView/AgentList.tsx index e25b0ea..f128047 100644 --- a/src-frontend/src/features/SideBar/views/AgentsView/AgentList.tsx +++ b/src-frontend/src/features/SideBar/views/AgentsView/AgentList.tsx @@ -106,26 +106,6 @@ export function AgentList() { const { t } = useTranslation("sidebar"); const removeTabs = useTabsStore((state) => state.remove); - const asyncConfirm = useAsyncConfirm({ - async onConfirm(agent) { - await deleteAgentMutation.mutateAsync({ agentId: agent.id }); - await invalidateAgentQueries(agent.id); - - removeTabs((tab) => (tab.type === "agent" && - tab.metadata.mode === "edit" && - tab.metadata.id === agent.id)); - - toast.success(t("agents.toast.delete_success_title"), { - description: t("agents.toast.delete_success_description"), - }); - }, - onError(error: Error) { - toast.error(t("agents.toast.delete_error_title"), { - description: error.message || t("agents.toast.delete_error_description"), - }); - }, - }); - const query = useGetAgentsSuspenseInfinite(undefined, { query: PAGINATED_QUERY_DEFAULT_OPTIONS, }); @@ -137,22 +117,38 @@ export function AgentList() { toast.success(t("agents.toast.delete_success_title"), { description: t("agents.toast.delete_success_description"), }); - }, - onError(error: Error) { - toast.error(t("agents.toast.delete_error_title"), { - description: error.message || t("agents.toast.delete_error_description"), - }); - }, + } }, }); + const asyncConfirm = useAsyncConfirm({ + async onConfirm(agent) { + await deleteAgentMutation.mutateAsync({ agentId: agent.id }); + await invalidateAgentQueries(agent.id); + + removeTabs((tab) => (tab.type === "agent" && + tab.metadata.mode === "edit" && + tab.metadata.id === agent.id)); + + toast.success(t("agents.toast.delete_success_title"), { + description: t("agents.toast.delete_success_description"), + }); + } + }); + return ( <> page.items} - itemRender={(agent) => } + itemRender={(agent) => ( + + )} />
- } - errorDescription={t("agents.list.error_load")} - > + }>
diff --git a/src-frontend/src/features/SideBar/views/SettingsView/HelperModelSettings/index.tsx b/src-frontend/src/features/SideBar/views/SettingsView/HelperModelSettings/index.tsx index 58dde4c..95b9b71 100644 --- a/src-frontend/src/features/SideBar/views/SettingsView/HelperModelSettings/index.tsx +++ b/src-frontend/src/features/SideBar/views/SettingsView/HelperModelSettings/index.tsx @@ -30,10 +30,9 @@ export function HelperModelSettings() { )} - errorDescription={t("settings.helper_model.flash_model.error_load")} > - + diff --git a/src-frontend/src/features/SideBar/views/SettingsView/ProviderSettings/ProviderList.tsx b/src-frontend/src/features/SideBar/views/SettingsView/ProviderSettings/ProviderList.tsx index 4c93a63..e68084d 100644 --- a/src-frontend/src/features/SideBar/views/SettingsView/ProviderSettings/ProviderList.tsx +++ b/src-frontend/src/features/SideBar/views/SettingsView/ProviderSettings/ProviderList.tsx @@ -125,12 +125,7 @@ export function ProviderList() { toast.success(t("settings.providers.toast.delete_success_title"), { description: t("settings.providers.toast.delete_success_description"), }); - }, - onError(error: Error) { - toast.error(t("settings.providers.toast.delete_error_title"), { - description: error.message || t("settings.providers.toast.delete_error_description"), - }); - }, + } }, }); diff --git a/src-frontend/src/features/SideBar/views/SettingsView/ProviderSettings/index.tsx b/src-frontend/src/features/SideBar/views/SettingsView/ProviderSettings/index.tsx index 12f36a0..d5e3fdf 100644 --- a/src-frontend/src/features/SideBar/views/SettingsView/ProviderSettings/index.tsx +++ b/src-frontend/src/features/SideBar/views/SettingsView/ProviderSettings/index.tsx @@ -31,10 +31,7 @@ export function ProviderSettings() { return (
- } - errorDescription={t("settings.providers.list.error_load")} - > + }> diff --git a/src-frontend/src/features/SideBar/views/TasksView/TaskList.tsx b/src-frontend/src/features/SideBar/views/TasksView/TaskList.tsx index 7a3f797..e52f4c6 100644 --- a/src-frontend/src/features/SideBar/views/TasksView/TaskList.tsx +++ b/src-frontend/src/features/SideBar/views/TasksView/TaskList.tsx @@ -119,7 +119,7 @@ export function TaskList({ workspaceId }: TaskListProps) { const queryClient = useQueryClient(); const deleteTaskMutation = useDeleteTask(); - useEffect(() => + useEffect(() => SseDispatcher.subscribe("TASK_TITLE_UPDATED", ({ task_id, title }: TaskTitleUpdatedEvent) => { const queryKey = getGetTasksInfiniteQueryKey({ workspace_id: workspaceId }); queryClient.setQueryData>( @@ -151,21 +151,12 @@ export function TaskList({ workspaceId }: TaskListProps) { toast.success(t("tasks.toast.delete_success_title"), { description: t("tasks.toast.delete_success_description"), }); - }, - onError(error: Error) { - toast.error(t("tasks.toast.delete_error_title"), { - description: error.message || t("tasks.toast.delete_error_description"), - }); - }, + } }); const query = useGetTasksSuspenseInfinite( - { - workspace_id: workspaceId, - }, - { - query: PAGINATED_QUERY_DEFAULT_OPTIONS, - } + { workspace_id: workspaceId }, + { query: PAGINATED_QUERY_DEFAULT_OPTIONS } ); if (query.data.pages.length === 0) { diff --git a/src-frontend/src/features/SideBar/views/TasksView/index.tsx b/src-frontend/src/features/SideBar/views/TasksView/index.tsx index 0a0854b..77bc297 100644 --- a/src-frontend/src/features/SideBar/views/TasksView/index.tsx +++ b/src-frontend/src/features/SideBar/views/TasksView/index.tsx @@ -40,7 +40,7 @@ export function TasksView() { } return ( - } errorDescription={t("tasks.list.error_load")}> + }> ); diff --git a/src-frontend/src/features/SideBar/views/ToolsetsView/ToolsetList.tsx b/src-frontend/src/features/SideBar/views/ToolsetsView/ToolsetList.tsx index 05859bf..ded7e97 100644 --- a/src-frontend/src/features/SideBar/views/ToolsetsView/ToolsetList.tsx +++ b/src-frontend/src/features/SideBar/views/ToolsetsView/ToolsetList.tsx @@ -31,8 +31,10 @@ import { tabIdFactory } from "@/lib/tab"; import { cn } from "@/lib/utils"; import { useTabsStore } from "@/stores/tabs-store"; import type { Tab, ToolsetTabMetadata } from "@/types/tab"; +import { getErrorMessage } from "@/i18n/error-message"; import { ToolsetIcon } from "./ToolsetIcon"; + function getStatusColor(status: McpToolsetStatus): string { switch (status) { case "connected": @@ -121,9 +123,9 @@ function ToolsetItem({ toolset, onDelete }: ToolsetItemProps) { {toolset.name} { - + - -

{t(`toolsets.status.${toolset.status}`)}

+

{ + toolset.error_code + ? getErrorMessage(toolset.error_code) + : t(`toolsets.status.${toolset.status}`) + }

@@ -171,19 +177,11 @@ export function ToolsetList() { toast.success(t("toolsets.toast.delete_success_title"), { description: t("toolsets.toast.delete_success_description"), }); - }, - onError(error: Error) { - toast.error(t("toolsets.toast.delete_error_title"), { - description: error.message || t("toolsets.toast.delete_error_description"), - }); - }, + } }); const { data: toolsets } = useGetToolsetsBriefSuspense({ - query: { - // TODO: replace refetch polling with sse - refetchInterval: 3000, - }, + query: { refetchInterval: 3000 }, }); return ( diff --git a/src-frontend/src/features/SideBar/views/ToolsetsView/index.tsx b/src-frontend/src/features/SideBar/views/ToolsetsView/index.tsx index 5dac57e..09c3bda 100644 --- a/src-frontend/src/features/SideBar/views/ToolsetsView/index.tsx +++ b/src-frontend/src/features/SideBar/views/ToolsetsView/index.tsx @@ -32,10 +32,7 @@ export function ToolsetsView() { />
- } - errorDescription={t("toolsets.list.error_load")} - > + }>
diff --git a/src-frontend/src/features/SideBar/views/WorkspacesView/WorkspaceList.tsx b/src-frontend/src/features/SideBar/views/WorkspacesView/WorkspaceList.tsx index 86a5df2..bd06d80 100644 --- a/src-frontend/src/features/SideBar/views/WorkspacesView/WorkspaceList.tsx +++ b/src-frontend/src/features/SideBar/views/WorkspacesView/WorkspaceList.tsx @@ -112,7 +112,7 @@ function WorkspaceItem({ workspace, disabled, isSelected, onSelect, onDelete }: - 设为当前工作区 + {t("workspaces.menu.select")} @@ -151,12 +151,7 @@ export function WorkspaceList() { toast.success(t("workspaces.toast.delete_success_title"), { description: t("workspaces.toast.delete_success_description"), }); - }, - onError(error: Error) { - toast.error(t("workspaces.toast.delete_error_title"), { - description: error.message || t("workspaces.toast.delete_error_description"), - }); - }, + } }); const query = useGetWorkspacesSuspenseInfinite(undefined, { diff --git a/src-frontend/src/features/SideBar/views/WorkspacesView/index.tsx b/src-frontend/src/features/SideBar/views/WorkspacesView/index.tsx index 804048f..23680b8 100644 --- a/src-frontend/src/features/SideBar/views/WorkspacesView/index.tsx +++ b/src-frontend/src/features/SideBar/views/WorkspacesView/index.tsx @@ -33,10 +33,7 @@ export function WorkspacesView() { />
- } - errorDescription={t("workspaces.list.error_load")} - > + }>
diff --git a/src-frontend/src/features/Tabs/AgentPanel/AgentCreateForm.tsx b/src-frontend/src/features/Tabs/AgentPanel/AgentCreateForm.tsx index b347f19..e251e0b 100644 --- a/src-frontend/src/features/Tabs/AgentPanel/AgentCreateForm.tsx +++ b/src-frontend/src/features/Tabs/AgentPanel/AgentCreateForm.tsx @@ -26,11 +26,6 @@ export function AgentCreateForm({ onConfirm }: AgentCreateFormProps) { }); onConfirm?.(); }, - onError(error: Error) { - toast.error(t("toast.create.error_title"), { - description: error.message || t("toast.create.error_description"), - }); - }, }, }); diff --git a/src-frontend/src/features/Tabs/AgentPanel/AgentEditForm.tsx b/src-frontend/src/features/Tabs/AgentPanel/AgentEditForm.tsx index ade21ad..3d6c4cf 100644 --- a/src-frontend/src/features/Tabs/AgentPanel/AgentEditForm.tsx +++ b/src-frontend/src/features/Tabs/AgentPanel/AgentEditForm.tsx @@ -29,11 +29,6 @@ export function AgentEditForm({ agent, onConfirm }: AgentEditFormProps) { }); onConfirm?.(); }, - onError(error: Error) { - toast.error(t("toast.update.error_title"), { - description: error.message || t("toast.update.error_description"), - }); - }, }, }); diff --git a/src-frontend/src/features/Tabs/AgentPanel/fields/ToolMultiSelectField.tsx b/src-frontend/src/features/Tabs/AgentPanel/fields/ToolMultiSelectField.tsx index 491d449..91688f2 100644 --- a/src-frontend/src/features/Tabs/AgentPanel/fields/ToolMultiSelectField.tsx +++ b/src-frontend/src/features/Tabs/AgentPanel/fields/ToolMultiSelectField.tsx @@ -77,7 +77,7 @@ export function ToolMultiSelectField() { - +
diff --git a/src-frontend/src/features/Tabs/AgentPanel/index.tsx b/src-frontend/src/features/Tabs/AgentPanel/index.tsx index 414b023..c8174f4 100644 --- a/src-frontend/src/features/Tabs/AgentPanel/index.tsx +++ b/src-frontend/src/features/Tabs/AgentPanel/index.tsx @@ -1,6 +1,4 @@ -import { useTranslation } from "react-i18next"; import { useGetAgentSuspense } from "@/api/agent"; -import { FailedToLoad } from "@/components/custom/FailedToLoad"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import { AgentCreateForm } from "@/features/Tabs/AgentPanel/AgentCreateForm"; import { AgentEditForm } from "@/features/Tabs/AgentPanel/AgentEditForm"; @@ -41,7 +39,6 @@ export function AgentPanel({ tabId, metadata, }: TabPanelProps) { - const { t } = useTranslation("tabs-agent"); if (metadata.mode === "create") { return ( @@ -52,14 +49,7 @@ export function AgentPanel({ } return ( - ( - - )} - > + ); diff --git a/src-frontend/src/features/Tabs/ProviderPanel/ProviderCreateForm.tsx b/src-frontend/src/features/Tabs/ProviderPanel/ProviderCreateForm.tsx index aceae7e..70d0706 100644 --- a/src-frontend/src/features/Tabs/ProviderPanel/ProviderCreateForm.tsx +++ b/src-frontend/src/features/Tabs/ProviderPanel/ProviderCreateForm.tsx @@ -27,11 +27,6 @@ export function ProviderCreateForm({ onConfirm }: ProviderCreateFormProps) { }); onConfirm?.(); }, - onError(error: Error) { - toast.error(t("toast.create.error_title"), { - description: error.message || t("toast.create.error_description"), - }); - }, }, }); diff --git a/src-frontend/src/features/Tabs/ProviderPanel/ProviderEditForm.tsx b/src-frontend/src/features/Tabs/ProviderPanel/ProviderEditForm.tsx index c462c0e..4d429c1 100644 --- a/src-frontend/src/features/Tabs/ProviderPanel/ProviderEditForm.tsx +++ b/src-frontend/src/features/Tabs/ProviderPanel/ProviderEditForm.tsx @@ -31,11 +31,6 @@ export function ProviderEditForm({ }); onConfirm?.(); }, - onError(error: Error) { - toast.error(t("toast.update.error_title"), { - description: error.message || t("toast.update.error_description"), - }); - }, }, }); diff --git a/src-frontend/src/features/Tabs/ProviderPanel/fields/ModelsField/ModelEditDialog.tsx b/src-frontend/src/features/Tabs/ProviderPanel/fields/ModelsField/ModelEditDialog.tsx index 621837d..8489f10 100644 --- a/src-frontend/src/features/Tabs/ProviderPanel/fields/ModelsField/ModelEditDialog.tsx +++ b/src-frontend/src/features/Tabs/ProviderPanel/fields/ModelsField/ModelEditDialog.tsx @@ -25,6 +25,8 @@ type ModelEditDialogProps = { onCancel?: () => void; }; +// TODO: refactor form + export function ModelEditDialog({ children, model, diff --git a/src-frontend/src/features/Tabs/ProviderPanel/fields/ModelsField/ModelSelectDialog.tsx b/src-frontend/src/features/Tabs/ProviderPanel/fields/ModelsField/ModelSelectDialog.tsx index 03c6321..b12a4bb 100644 --- a/src-frontend/src/features/Tabs/ProviderPanel/fields/ModelsField/ModelSelectDialog.tsx +++ b/src-frontend/src/features/Tabs/ProviderPanel/fields/ModelsField/ModelSelectDialog.tsx @@ -75,10 +75,7 @@ export function ModelSelectDialog({ - } - errorDescription={t("models.select.error_load")} - > + }> diff --git a/src-frontend/src/features/Tabs/ProviderPanel/index.tsx b/src-frontend/src/features/Tabs/ProviderPanel/index.tsx index d938093..8b6d859 100644 --- a/src-frontend/src/features/Tabs/ProviderPanel/index.tsx +++ b/src-frontend/src/features/Tabs/ProviderPanel/index.tsx @@ -1,6 +1,4 @@ -import { useTranslation } from "react-i18next"; import { useGetProviderSuspense } from "@/api/provider"; -import { FailedToLoad } from "@/components/custom/FailedToLoad"; import { ScrollArea } from "@/components/ui/scroll-area"; import { ProviderCreateForm } from "@/features/Tabs/ProviderPanel/ProviderCreateForm"; import { ProviderEditForm } from "@/features/Tabs/ProviderPanel/ProviderEditForm"; @@ -34,8 +32,6 @@ export function ProviderPanel({ tabId, metadata, }: TabPanelProps) { - const { t } = useTranslation("tabs-provider"); - if (metadata.mode === "create") { return ( @@ -45,14 +41,7 @@ export function ProviderPanel({ } return ( - ( - - )} - > + ); diff --git a/src-frontend/src/features/Tabs/TabPanelFrame.tsx b/src-frontend/src/features/Tabs/TabPanelFrame.tsx index d8d321a..070ce52 100644 --- a/src-frontend/src/features/Tabs/TabPanelFrame.tsx +++ b/src-frontend/src/features/Tabs/TabPanelFrame.tsx @@ -1,7 +1,7 @@ -import type { FallbackProps } from "react-error-boundary"; import { AsyncBoundary } from "@/components/custom/AsyncBoundary"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Skeleton } from "@/components/ui/skeleton"; +import { FailedToLoad } from "@/components/custom/FailedToLoad"; function TabPanelFrameSkeleton() { return ( @@ -33,16 +33,15 @@ function TabPanelFrameSkeleton() { type TabPanelFrameProps = { children: React.ReactNode; - errorRender: (props: FallbackProps) => React.ReactNode; }; -export function TabPanelFrame({ children, errorRender }: TabPanelFrameProps) { +export function TabPanelFrame({ children }: TabPanelFrameProps) { return ( } errorRender={(props) => (
- {errorRender(props)} +
)} > diff --git a/src-frontend/src/features/Tabs/TaskPanel/components/PromptInput/ContextSelectPopover.tsx b/src-frontend/src/features/Tabs/TaskPanel/components/PromptInput/ContextSelectPopover.tsx index bd6b6cf..ca92378 100644 --- a/src-frontend/src/features/Tabs/TaskPanel/components/PromptInput/ContextSelectPopover.tsx +++ b/src-frontend/src/features/Tabs/TaskPanel/components/PromptInput/ContextSelectPopover.tsx @@ -91,8 +91,11 @@ function FilesMenu({ onSelect }: { onSelect?: OnSelectHandler }) { ) : ( {t("prompt.context.loading")}} - errorDescription={t("prompt.context.load_error")} + skeleton={( +
+ {t("prompt.context.loading")} +
+ )} >
diff --git a/src-frontend/src/features/Tabs/TaskPanel/hooks/use-agent-task/index.tsx b/src-frontend/src/features/Tabs/TaskPanel/hooks/use-agent-task/index.tsx index 487377c..5dcfb2b 100644 --- a/src-frontend/src/features/Tabs/TaskPanel/hooks/use-agent-task/index.tsx +++ b/src-frontend/src/features/Tabs/TaskPanel/hooks/use-agent-task/index.tsx @@ -2,6 +2,7 @@ import { createContext, useCallback, useContext, useMemo, useRef, useState } fro import { useTranslation } from "react-i18next"; import { produce } from "immer"; import { toast } from "sonner"; +import { useQueryClient } from "@tanstack/react-query"; import { TABS_TASK_NAMESPACE } from "@/i18n/resources"; import { BuiltInTools, @@ -21,6 +22,7 @@ import { } from "@/api/generated/schemas"; import { continueTask, + getGetTaskQueryKey, type TaskSseCallbacks, toolAnswer, toolReview, @@ -29,15 +31,15 @@ import { import { UpdateTodosSchema } from "@/api/tool-schema"; import { tryParseSchema } from "@/lib/utils"; import { UiUserMessage, type SdkMessage } from "@/types/message"; -import { useMessageLifecycle } from "./use-message-lifecycle"; import { UiMessage } from "@/types/message"; import { toUiMessage } from "@/types/message"; import { sendNotification } from "@/lib/notification"; import { useTabsStore } from "@/stores/tabs-store"; +import { isForeground } from "@/lib/is-foreground"; import { useTaskStream } from "./use-task-stream"; import { useTextBuffer } from "./use-text-buffer"; import { useToolCallBuffer } from "./use-tool-call-buffer"; -import { isForeground } from "@/lib/is-foreground"; +import { useMessageLifecycle } from "./use-message-lifecycle"; export type TaskState = "idle" | "waiting" | "running" | "error"; @@ -89,6 +91,7 @@ type AgentTaskProviderProps = { export function AgentTaskProvider({ taskId, children }: AgentTaskProviderProps) { const { t } = useTranslation(TABS_TASK_NAMESPACE); + const queryClient = useQueryClient(); const setActiveTab = useTabsStore((state) => state.setActive); const backToCurrentTab = () => setActiveTab((tab) => ( tab.type === "task" && @@ -211,7 +214,10 @@ export function AgentTaskProvider({ taskId, children }: AgentTaskProviderProps) }); }; - const onClose = () => messageLifecycle.handleClose(); + const onClose = () => { + messageLifecycle.handleClose(); + queryClient.invalidateQueries({ queryKey: getGetTaskQueryKey(taskId) }); + } sseCallbacksRef.current = { onMessageStart, diff --git a/src-frontend/src/features/Tabs/TaskPanel/index.tsx b/src-frontend/src/features/Tabs/TaskPanel/index.tsx index 5918cc4..9af7ddc 100644 --- a/src-frontend/src/features/Tabs/TaskPanel/index.tsx +++ b/src-frontend/src/features/Tabs/TaskPanel/index.tsx @@ -1,5 +1,4 @@ import { useRef } from "react"; -import { useTranslation } from "react-i18next"; import { AsyncBoundary } from "@/components/custom/AsyncBoundary"; import { i18n } from "@/i18n"; import { TABS_TASK_NAMESPACE } from "@/i18n/resources"; @@ -12,7 +11,6 @@ import { SessionView, SessionViewSkeleton } from "./SessionView"; export const DEFAULT_TAB_TITLE = i18n.t("tab.default_title", { ns: TABS_TASK_NAMESPACE }); export function TaskPanel({ tabId, metadata }: TabPanelProps) { - const { t } = useTranslation("tabs-task"); const isInitialDraft = useRef(metadata.isDraft); if (metadata.isDraft) { @@ -20,10 +18,7 @@ export function TaskPanel({ tabId, metadata }: TabPanelProps) { } return ( - } - errorDescription={t("panel.error.load_description")} - > + }> diff --git a/src-frontend/src/features/Tabs/ToolsetPanel/ToolsetCreateForm.tsx b/src-frontend/src/features/Tabs/ToolsetPanel/ToolsetCreateForm.tsx index ae003f9..79c74c8 100644 --- a/src-frontend/src/features/Tabs/ToolsetPanel/ToolsetCreateForm.tsx +++ b/src-frontend/src/features/Tabs/ToolsetPanel/ToolsetCreateForm.tsx @@ -1,6 +1,6 @@ import { useTranslation } from "react-i18next"; import { toast } from "sonner"; -import { invalidateToolsetQueries, useCreateMcpToolset } from "@/api/toolset"; +import { invalidateToolsetQueries, useCreateToolset } from "@/api/toolset"; import { FormShell, FormShellFooter } from "@/components/custom/form/FormShell"; import { NameField } from "@/components/custom/form/fields"; import { Button } from "@/components/ui/button"; @@ -18,7 +18,7 @@ type ToolsetCreateProps = { export function ToolsetCreateForm({ onConfirm }: ToolsetCreateProps) { const { t } = useTranslation("tabs-toolset"); - const createMutation = useCreateMcpToolset({ + const createMutation = useCreateToolset({ mutation: { async onSuccess(newToolset) { await invalidateToolsetQueries(); @@ -26,12 +26,7 @@ export function ToolsetCreateForm({ onConfirm }: ToolsetCreateProps) { description: t("toast.create.success_description_with_name", { name: newToolset.name }), }); onConfirm?.(); - }, - onError(error: Error) { - toast.error(t("toast.create.error_title"), { - description: error.message || t("toast.create.error_description"), - }); - }, + } }, }); diff --git a/src-frontend/src/features/Tabs/ToolsetPanel/ToolsetEditForm.tsx b/src-frontend/src/features/Tabs/ToolsetPanel/ToolsetEditForm.tsx index fdb12a1..dca4298 100644 --- a/src-frontend/src/features/Tabs/ToolsetPanel/ToolsetEditForm.tsx +++ b/src-frontend/src/features/Tabs/ToolsetPanel/ToolsetEditForm.tsx @@ -29,11 +29,6 @@ export function ToolsetEditForm({ toolset, onConfirm }: ToolsetEditFormProps) { }); onConfirm?.(); }, - onError(error: Error) { - toast.error(t("toast.update.error_title"), { - description: error.message || t("toast.update.error_description"), - }); - }, }, }); diff --git a/src-frontend/src/features/Tabs/ToolsetPanel/index.tsx b/src-frontend/src/features/Tabs/ToolsetPanel/index.tsx index 4100ad8..5069a6c 100644 --- a/src-frontend/src/features/Tabs/ToolsetPanel/index.tsx +++ b/src-frontend/src/features/Tabs/ToolsetPanel/index.tsx @@ -1,6 +1,4 @@ -import { useTranslation } from "react-i18next"; import { useGetToolsetSuspense } from "@/api/toolset"; -import { FailedToLoad } from "@/components/custom/FailedToLoad"; import { ScrollArea } from "@/components/ui/scroll-area"; import { ToolsetEditForm } from "@/features/Tabs/ToolsetPanel/ToolsetEditForm"; import { useTabsStore } from "@/stores/tabs-store"; @@ -37,8 +35,6 @@ export function ToolsetPanel({ tabId, metadata, }: TabPanelProps) { - const { t } = useTranslation("tabs-toolset"); - if (metadata.mode === "create") { return ( @@ -48,14 +44,7 @@ export function ToolsetPanel({ } return ( - ( - - )} - > + ); diff --git a/src-frontend/src/features/Tabs/WorkspacePanel/WorkspaceCreateForm.tsx b/src-frontend/src/features/Tabs/WorkspacePanel/WorkspaceCreateForm.tsx index 7cf464f..f3350fc 100644 --- a/src-frontend/src/features/Tabs/WorkspacePanel/WorkspaceCreateForm.tsx +++ b/src-frontend/src/features/Tabs/WorkspacePanel/WorkspaceCreateForm.tsx @@ -25,11 +25,6 @@ export function WorkspaceCreateForm({ onConfirm }: WorkspaceCreateFormProps) { }); onConfirm?.(); }, - onError(error: Error) { - toast.error(t("toast.create.error_title"), { - description: error.message || t("toast.create.error_description"), - }); - }, }, }); diff --git a/src-frontend/src/features/Tabs/WorkspacePanel/WorkspaceEditForm.tsx b/src-frontend/src/features/Tabs/WorkspacePanel/WorkspaceEditForm.tsx index 23bd467..7abaab2 100644 --- a/src-frontend/src/features/Tabs/WorkspacePanel/WorkspaceEditForm.tsx +++ b/src-frontend/src/features/Tabs/WorkspacePanel/WorkspaceEditForm.tsx @@ -49,11 +49,6 @@ export function WorkspaceEditForm({ await syncCurrentWorkspace(); } }, - onError(error: Error) { - toast.error(t("toast.update.error_title"), { - description: error.message || t("toast.update.error_description"), - }); - }, }, }); diff --git a/src-frontend/src/features/Tabs/WorkspacePanel/fields/AgentMultiSelectField.tsx b/src-frontend/src/features/Tabs/WorkspacePanel/fields/AgentMultiSelectField.tsx index 95a5bdb..bf8e3b7 100644 --- a/src-frontend/src/features/Tabs/WorkspacePanel/fields/AgentMultiSelectField.tsx +++ b/src-frontend/src/features/Tabs/WorkspacePanel/fields/AgentMultiSelectField.tsx @@ -121,7 +121,7 @@ export function AgentMultiSelectField() { {t("form.usable_agents.empty")} - } errorDescription={t("form.usable_agents.load_error")}> + }> @@ -135,7 +135,7 @@ export function AgentMultiSelectField() { - + diff --git a/src-frontend/src/features/Tabs/WorkspacePanel/fields/ToolMultiSelectField.tsx b/src-frontend/src/features/Tabs/WorkspacePanel/fields/ToolMultiSelectField.tsx index 794492a..6a7f696 100644 --- a/src-frontend/src/features/Tabs/WorkspacePanel/fields/ToolMultiSelectField.tsx +++ b/src-frontend/src/features/Tabs/WorkspacePanel/fields/ToolMultiSelectField.tsx @@ -77,7 +77,7 @@ export function ToolMultiSelectField() { - + diff --git a/src-frontend/src/features/Tabs/WorkspacePanel/index.tsx b/src-frontend/src/features/Tabs/WorkspacePanel/index.tsx index 442a18f..d8c814e 100644 --- a/src-frontend/src/features/Tabs/WorkspacePanel/index.tsx +++ b/src-frontend/src/features/Tabs/WorkspacePanel/index.tsx @@ -1,6 +1,4 @@ -import { useTranslation } from "react-i18next"; import { useGetWorkspaceSuspense } from "@/api/workspace"; -import { FailedToLoad } from "@/components/custom/FailedToLoad"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import { useTabsStore } from "@/stores/tabs-store"; import type { WorkspaceTabMetadata } from "@/types/tab"; @@ -34,8 +32,6 @@ export function WorkspacePanel({ tabId, metadata, }: TabPanelProps) { - const { t } = useTranslation("tabs-workspace"); - if (metadata.mode === "create") { return ( @@ -46,14 +42,7 @@ export function WorkspacePanel({ } return ( - ( - - )} - > + ); diff --git a/src-frontend/src/i18n/error-message.ts b/src-frontend/src/i18n/error-message.ts new file mode 100644 index 0000000..3b555cc --- /dev/null +++ b/src-frontend/src/i18n/error-message.ts @@ -0,0 +1,12 @@ +import { i18n } from "./index"; +import { ErrorCode } from "@/api/orval-mutator/custom-fetch"; +import { ERROR_NAMESPACE } from "./resources"; + +export function getErrorMessage(errorCode: ErrorCode): string { + if (i18n.exists(errorCode, { ns: ERROR_NAMESPACE })) { + return i18n.t(errorCode, { + ns: ERROR_NAMESPACE + }); + } + return i18n.t("UNEXPECTED_ERROR", { ns: ERROR_NAMESPACE }); +} diff --git a/src-frontend/src/i18n/locales/en/components-custom.json b/src-frontend/src/i18n/locales/en/components-custom.json new file mode 100644 index 0000000..ec23e08 --- /dev/null +++ b/src-frontend/src/i18n/locales/en/components-custom.json @@ -0,0 +1,6 @@ +{ + "load_failed": { + "retry": "Retry", + "title": "Failed to load" + } +} diff --git a/src-frontend/src/i18n/locales/en/dialog.json b/src-frontend/src/i18n/locales/en/dialog.json index 1def5e9..3da7622 100644 --- a/src-frontend/src/i18n/locales/en/dialog.json +++ b/src-frontend/src/i18n/locales/en/dialog.json @@ -11,7 +11,6 @@ "no_model": "No models found", "no_provider": "No providers yet. Add an LLM provider first to use models." }, - "load_error": "Failed to load model list. Please try again later.", "search_placeholder": "Search models...", "trigger": { "deleted": "Model has been deleted", @@ -23,7 +22,6 @@ "cancel": "Cancel", "confirm": "Confirm", "empty": "No matching tools found", - "load_error": "Failed to load tool list. Please try again later.", "search_placeholder": "Search tools...", "select_all": "Select all", "trigger": { diff --git a/src-frontend/src/i18n/locales/en/error.json b/src-frontend/src/i18n/locales/en/error.json new file mode 100644 index 0000000..c6666d2 --- /dev/null +++ b/src-frontend/src/i18n/locales/en/error.json @@ -0,0 +1,25 @@ +{ + "AGENT_NOT_FOUND": "The agent was not found.", + "CANNOT_CREATE_BUILTIN_TOOLSET": "Built-in toolsets cannot be created.", + "MCP_AUTH_FAILED": "Authentication failed. Check your credentials and try again.", + "MCP_COMMAND_NOT_FOUND": "The requested command was not found.", + "MCP_CONNECTION_FAILED": "Connection failed. Please try again.", + "MCP_CONNECTION_TIMEOUT": "Connection timed out. Please try again.", + "MCP_PERMISSION_DENIED": "Permission denied. Please check access rights.", + "MCP_PROCESS_CRASHED": "The MCP process crashed. Please try again.", + "MCP_PROCESS_START_FAILED": "Failed to start the MCP process.", + "MCP_PROTOCOL_ERROR": "Protocol error. Please check the server configuration.", + "MODEL_NOT_FOUND": "The model was not found.", + "NETWORK_ERROR": "Network error. Check your connection and try again.", + "PATH_ACCESS_DENIED": "You do not have permission to access the path.", + "PATH_NOT_DIRECTORY": "The selected path is not a folder.", + "PATH_NOT_FOUND": "The selected path was not found.", + "PROVIDER_NOT_FOUND": "The provider was not found.", + "TASK_NOT_FOUND": "The task was not found.", + "TOOL_CALL_NOT_FOUND": "The requested operation was not found.", + "TOOL_NOT_FOUND": "The tool was not found.", + "TOOLSET_INTERNAL_KEY_ALREADY_EXISTS": "This toolset already exists.", + "TOOLSET_NOT_FOUND": "The toolset was not found.", + "UNEXPECTED_ERROR": "Something went wrong. Please try again.", + "WORKSPACE_NOT_FOUND": "The workspace was not found." +} diff --git a/src-frontend/src/i18n/locales/en/sidebar.json b/src-frontend/src/i18n/locales/en/sidebar.json index c6a3437..cb49e22 100644 --- a/src-frontend/src/i18n/locales/en/sidebar.json +++ b/src-frontend/src/i18n/locales/en/sidebar.json @@ -8,7 +8,6 @@ "title": "Agents" }, "list": { - "error_load": "Failed to load agents. Please try again later.", "no_model": "No model" }, "menu": { @@ -20,8 +19,6 @@ "edit_title_with_name": "Edit: {{name}}" }, "toast": { - "delete_error_description": "Failed to delete the agent. Please try again later.", - "delete_error_title": "Delete failed", "delete_success_description": "Agent deleted successfully.", "delete_success_title": "Delete success" } @@ -59,8 +56,7 @@ "helper_model": { "flash_model": { "title": "Flash model" - }, - "error_load": "Failed to load helper model settings. Please try again later." + } }, "providers": { "actions": { @@ -76,7 +72,6 @@ "list": { "delete_provider_title": "Delete", "edit_provider_title": "Edit provider", - "error_load": "Failed to load providers. Please try again later.", "model_count_with_count_one": "", "model_count_with_count_other": "" }, @@ -85,8 +80,6 @@ "edit_title_with_name": "Edit: {{name}}" }, "toast": { - "delete_error_description": "Failed to delete the provider. Please try again later.", - "delete_error_title": "Delete failed", "delete_success_description": "Provider deleted successfully.", "delete_success_title": "Delete success" } @@ -114,16 +107,11 @@ "create_tooltip": "Create new task", "title": "Tasks" }, - "list": { - "error_load": "Failed to load tasks. Please try again later." - }, "menu": { "delete": "Delete", "rename": "Edit task name" }, "toast": { - "delete_error_description": "Failed to delete the task. Please try again later.", - "delete_error_title": "Delete failed", "delete_success_description": "Task deleted successfully.", "delete_success_title": "Delete success", "rename_feature_description": "Rename feature is not implemented yet", @@ -138,20 +126,21 @@ "create_tooltip": "Connect to MCP server", "title": "Toolsets" }, - "list": { - "error_load": "Failed to load toolsets. Please try again later." - }, "menu": { "delete": "Delete", "edit": "Edit toolset" }, + "status": { + "connected": "Connected", + "connecting": "Connecting", + "disconnected": "Disconnected", + "error": "Error" + }, "tab": { "create_title": "Connect to MCP server", "edit_title_with_name": "Edit: {{name}}" }, "toast": { - "delete_error_description": "Failed to delete the toolset. Please try again later.", - "delete_error_title": "Delete failed", "delete_success_description": "Toolset deleted successfully.", "delete_success_title": "Delete success" } @@ -168,21 +157,16 @@ "create_tooltip": "Create new workspace", "title": "Workspaces" }, - "list": { - "error_load": "Failed to load workspaces. Please try again later." - }, "menu": { "delete": "Delete", "edit": "Edit workspace", - "set_current": "Set as current" + "select": "Set as current" }, "tab": { "create_title": "Create workspace", "edit_title_with_name": "Edit: {{name}}" }, "toast": { - "delete_error_description": "Failed to delete the workspace. Please try again later.", - "delete_error_title": "Delete failed", "delete_success_description": "Workspace deleted successfully.", "delete_success_title": "Delete success" } diff --git a/src-frontend/src/i18n/locales/en/tabs-agent.json b/src-frontend/src/i18n/locales/en/tabs-agent.json index 91f7402..fde4e76 100644 --- a/src-frontend/src/i18n/locales/en/tabs-agent.json +++ b/src-frontend/src/i18n/locales/en/tabs-agent.json @@ -20,8 +20,7 @@ "saving": "Saving..." }, "usable_tools": { - "label": "Available tools", - "load_error": "Failed to load tool list. Please try again later." + "label": "Available tools" } }, "icon_dialog": { @@ -43,21 +42,12 @@ "select": "Select icon" } }, - "panel": { - "error": { - "load_description": "Failed to load agent details. Please try again later." - } - }, "toast": { "create": { - "error_description": "Failed to create agent. Please try again later.", - "error_title": "Create failed", "success_description_with_name": "Agent {{name}} created successfully.", "success_title": "Create success" }, "update": { - "error_description": "Failed to update agent. Please try again later.", - "error_title": "Update failed", "success_description_with_name": "Agent {{name}} updated successfully.", "success_title": "Update success" } diff --git a/src-frontend/src/i18n/locales/en/tabs-provider.json b/src-frontend/src/i18n/locales/en/tabs-provider.json index c91e87e..a4b8e88 100644 --- a/src-frontend/src/i18n/locales/en/tabs-provider.json +++ b/src-frontend/src/i18n/locales/en/tabs-provider.json @@ -59,25 +59,15 @@ "cancel": "Cancel", "confirm": "Confirm", "empty": "No models found", - "error_load": "Failed to load model list. Please try again later.", "search_placeholder": "Search models..." } }, - "panel": { - "error": { - "load_description": "Failed to load provider details. Please try again later." - } - }, "toast": { "create": { - "error_description": "Failed to create provider. Please try again later.", - "error_title": "Create failed", "success_description_with_name": "Provider {{name}} created successfully.", "success_title": "Create success" }, "update": { - "error_description": "Failed to update provider. Please try again later.", - "error_title": "Update failed", "success_description_with_name": "Provider {{name}} updated successfully.", "success_title": "Update success" } diff --git a/src-frontend/src/i18n/locales/en/tabs-task.json b/src-frontend/src/i18n/locales/en/tabs-task.json index 33ab790..9312ba2 100644 --- a/src-frontend/src/i18n/locales/en/tabs-task.json +++ b/src-frontend/src/i18n/locales/en/tabs-task.json @@ -16,10 +16,10 @@ "title": "Task failed. Please retry." } }, - "panel": { - "error": { - "load_description": "Failed to load task. Please try again later." - } + "notification": { + "require_permission": "Dais wants to use {{toolName}}", + "require_response": "Dais is waiting for your response", + "task_done": "Dais has finished the task" }, "prompt": { "agent": { @@ -31,7 +31,6 @@ "workspace_load_failed": "Failed to load workspace" }, "context": { - "load_error": "Failed to load files.", "loading": "Loading...", "no_results": "No results found.", "search_placeholder": "Add files, folders, docs...", @@ -45,11 +44,6 @@ "task_progress": { "completed": "Task completed" }, - "notification": { - "require_response": "Dais is waiting for your response", - "require_permission": "Dais wants to use {{toolName}}", - "task_done": "Dais has finished the task" - }, "toast": { "task_failed": { "title": "Task failed" @@ -82,4 +76,4 @@ "title": "Task todos updated" } } -} \ No newline at end of file +} diff --git a/src-frontend/src/i18n/locales/en/tabs-toolset.json b/src-frontend/src/i18n/locales/en/tabs-toolset.json index 8674852..99c7370 100644 --- a/src-frontend/src/i18n/locales/en/tabs-toolset.json +++ b/src-frontend/src/i18n/locales/en/tabs-toolset.json @@ -47,21 +47,12 @@ "placeholder": "https://api.example.com" } }, - "panel": { - "error": { - "load_description": "Failed to load toolset details. Please try again later." - } - }, "toast": { "create": { - "error_description": "Failed to create toolset. Please try again later.", - "error_title": "Create failed", "success_description_with_name": "Toolset {{name}} created successfully.", "success_title": "Create success" }, "update": { - "error_description": "Failed to update toolset. Please try again later.", - "error_title": "Update failed", "success_description_with_name": "Toolset {{name}} updated successfully.", "success_title": "Update success" } diff --git a/src-frontend/src/i18n/locales/en/tabs-workspace.json b/src-frontend/src/i18n/locales/en/tabs-workspace.json index aab4c85..76d1ef9 100644 --- a/src-frontend/src/i18n/locales/en/tabs-workspace.json +++ b/src-frontend/src/i18n/locales/en/tabs-workspace.json @@ -18,30 +18,19 @@ "confirm": "Confirm", "empty": "No matching agents found", "label": "Available agents", - "load_error": "Failed to load agent list. Please try again later.", "search_placeholder": "Search agents...", "select": "Select" }, "usable_tools": { - "label": "Available tools", - "load_error": "Failed to load tool list. Please try again later." - } - }, - "panel": { - "error": { - "load_description": "Failed to load workspace details. Please try again later." + "label": "Available tools" } }, "toast": { "create": { - "error_description": "Failed to create workspace. Please try again later.", - "error_title": "Create failed", "success_description_with_name": "Workspace \"{{name}}\" created successfully.", "success_title": "Create success" }, "update": { - "error_description": "Failed to update workspace. Please try again later.", - "error_title": "Update failed", "success_description_with_name": "Workspace \"{{name}}\" updated successfully.", "success_title": "Update success" } diff --git a/src-frontend/src/i18n/locales/zh_CN/components-custom.json b/src-frontend/src/i18n/locales/zh_CN/components-custom.json new file mode 100644 index 0000000..b0c58c2 --- /dev/null +++ b/src-frontend/src/i18n/locales/zh_CN/components-custom.json @@ -0,0 +1,6 @@ +{ + "load_failed": { + "retry": "重试", + "title": "加载失败" + } +} diff --git a/src-frontend/src/i18n/locales/zh_CN/dialog.json b/src-frontend/src/i18n/locales/zh_CN/dialog.json index 52a3ff7..d761a71 100644 --- a/src-frontend/src/i18n/locales/zh_CN/dialog.json +++ b/src-frontend/src/i18n/locales/zh_CN/dialog.json @@ -11,7 +11,6 @@ "no_model": "未找到模型", "no_provider": "暂无供应商,请先添加 LLM 供应商以使用模型。" }, - "load_error": "无法加载模型列表,请稍后重试。", "search_placeholder": "搜索模型...", "trigger": { "deleted": "模型已被删除", @@ -23,7 +22,6 @@ "cancel": "取消", "confirm": "确定", "empty": "未找到匹配的工具", - "load_error": "无法加载工具列表,请稍后重试。", "search_placeholder": "搜索工具...", "select_all": "全选", "trigger": { diff --git a/src-frontend/src/i18n/locales/zh_CN/error.json b/src-frontend/src/i18n/locales/zh_CN/error.json new file mode 100644 index 0000000..60571d9 --- /dev/null +++ b/src-frontend/src/i18n/locales/zh_CN/error.json @@ -0,0 +1,25 @@ +{ + "AGENT_NOT_FOUND": "未找到该代理。", + "CANNOT_CREATE_BUILTIN_TOOLSET": "无法创建内置工具集。", + "MCP_AUTH_FAILED": "认证失败,请检查凭据后重试。", + "MCP_COMMAND_NOT_FOUND": "未找到对应的命令。", + "MCP_CONNECTION_FAILED": "连接失败,请稍后重试。", + "MCP_CONNECTION_TIMEOUT": "连接超时,请稍后重试。", + "MCP_PERMISSION_DENIED": "权限不足,请检查访问权限。", + "MCP_PROCESS_CRASHED": "进程崩溃,请稍后重试。", + "MCP_PROCESS_START_FAILED": "进程启动失败,请稍后重试。", + "MCP_PROTOCOL_ERROR": "协议错误,请检查服务端配置。", + "MODEL_NOT_FOUND": "未找到该模型。", + "NETWORK_ERROR": "网络错误,请检查连接后重试。", + "PATH_ACCESS_DENIED": "没有权限访问该路径。", + "PATH_NOT_DIRECTORY": "该路径不是文件夹。", + "PATH_NOT_FOUND": "未找到指定路径。", + "PROVIDER_NOT_FOUND": "未找到该提供商。", + "TASK_NOT_FOUND": "未找到该任务。", + "TOOL_CALL_NOT_FOUND": "未找到对应的工具调用。", + "TOOL_NOT_FOUND": "未找到该工具。", + "TOOLSET_INTERNAL_KEY_ALREADY_EXISTS": "该工具集已存在。", + "TOOLSET_NOT_FOUND": "未找到该工具集。", + "UNEXPECTED_ERROR": "发生错误,请稍后重试。", + "WORKSPACE_NOT_FOUND": "未找到该工作区。" +} diff --git a/src-frontend/src/i18n/locales/zh_CN/sidebar.json b/src-frontend/src/i18n/locales/zh_CN/sidebar.json index 1694d7b..e65640a 100644 --- a/src-frontend/src/i18n/locales/zh_CN/sidebar.json +++ b/src-frontend/src/i18n/locales/zh_CN/sidebar.json @@ -8,7 +8,6 @@ "title": "Agents" }, "list": { - "error_load": "无法加载 Agent 列表,请稍后重试。", "no_model": "无模型" }, "menu": { @@ -20,8 +19,6 @@ "edit_title_with_name": "编辑:{{name}}" }, "toast": { - "delete_error_description": "删除 Agent 时发生错误,请稍后重试。", - "delete_error_title": "删除失败", "delete_success_description": "已成功删除 Agent。", "delete_success_title": "删除成功" } @@ -59,8 +56,7 @@ "helper_model": { "flash_model": { "title": "快速模型" - }, - "error_load": "无法加载助手模型设置,请稍后重试。" + } }, "providers": { "actions": { @@ -76,7 +72,6 @@ "list": { "delete_provider_title": "删除", "edit_provider_title": "编辑服务提供商", - "error_load": "无法加载服务提供商列表,请稍后重试。", "model_count_with_count_one": "", "model_count_with_count_other": "" }, @@ -85,8 +80,6 @@ "edit_title_with_name": "编辑:{{name}}" }, "toast": { - "delete_error_description": "删除服务提供商时发生错误,请稍后重试。", - "delete_error_title": "删除失败", "delete_success_description": "已成功删除服务提供商。", "delete_success_title": "删除成功" } @@ -114,16 +107,11 @@ "create_tooltip": "创建任务", "title": "任务" }, - "list": { - "error_load": "无法加载任务列表,请稍后重试。" - }, "menu": { "delete": "删除", "rename": "编辑任务名称" }, "toast": { - "delete_error_description": "删除任务时发生错误,请稍后重试。", - "delete_error_title": "删除失败", "delete_success_description": "已成功删除任务。", "delete_success_title": "删除成功", "rename_feature_description": "重命名功能待实现", @@ -138,20 +126,21 @@ "create_tooltip": "连接到 MCP 服务", "title": "工具集" }, - "list": { - "error_load": "无法加载工具集列表,请稍后重试。" - }, "menu": { "delete": "删除", "edit": "编辑工具集" }, + "status": { + "connected": "已连接", + "connecting": "连接中", + "disconnected": "已断开", + "error": "错误" + }, "tab": { "create_title": "连接到 MCP 服务", "edit_title_with_name": "编辑:{{name}}" }, "toast": { - "delete_error_description": "删除工具集时发生错误,请稍后重试。", - "delete_error_title": "删除失败", "delete_success_description": "已成功删除工具集。", "delete_success_title": "删除成功" } @@ -168,21 +157,16 @@ "create_tooltip": "创建工作区", "title": "工作区" }, - "list": { - "error_load": "无法加载工作区列表,请稍后重试。" - }, "menu": { "delete": "删除", "edit": "编辑工作区", - "set_current": "设为当前工作区" + "select": "设为当前工作区" }, "tab": { "create_title": "创建工作区", "edit_title_with_name": "编辑:{{name}}" }, "toast": { - "delete_error_description": "删除工作区时发生错误,请稍后重试。", - "delete_error_title": "删除失败", "delete_success_description": "已成功删除工作区。", "delete_success_title": "删除成功" } diff --git a/src-frontend/src/i18n/locales/zh_CN/tabs-agent.json b/src-frontend/src/i18n/locales/zh_CN/tabs-agent.json index a83fadf..75b9549 100644 --- a/src-frontend/src/i18n/locales/zh_CN/tabs-agent.json +++ b/src-frontend/src/i18n/locales/zh_CN/tabs-agent.json @@ -20,8 +20,7 @@ "saving": "保存中..." }, "usable_tools": { - "label": "可用的工具", - "load_error": "无法加载工具列表,请稍后重试。" + "label": "可用的工具" } }, "icon_dialog": { @@ -43,21 +42,12 @@ "select": "选择图标" } }, - "panel": { - "error": { - "load_description": "无法加载 Agent 信息,请稍后重试。" - } - }, "toast": { "create": { - "error_description": "创建 Agent 时发生错误,请稍后重试。", - "error_title": "创建失败", "success_description_with_name": "已成功创建 Agent {{name}}。", "success_title": "创建成功" }, "update": { - "error_description": "更新 Agent 时发生错误,请稍后重试。", - "error_title": "更新失败", "success_description_with_name": "已成功更新 Agent {{name}}。", "success_title": "更新成功" } diff --git a/src-frontend/src/i18n/locales/zh_CN/tabs-provider.json b/src-frontend/src/i18n/locales/zh_CN/tabs-provider.json index 7b06a91..6308b51 100644 --- a/src-frontend/src/i18n/locales/zh_CN/tabs-provider.json +++ b/src-frontend/src/i18n/locales/zh_CN/tabs-provider.json @@ -59,25 +59,15 @@ "cancel": "取消", "confirm": "确认", "empty": "未找到模型", - "error_load": "无法加载模型列表,请稍后重试。", "search_placeholder": "搜索模型..." } }, - "panel": { - "error": { - "load_description": "无法加载服务提供商信息,请稍后重试。" - } - }, "toast": { "create": { - "error_description": "创建服务提供商时发生错误,请稍后重试。", - "error_title": "创建失败", "success_description_with_name": "已成功创建服务提供商 {{name}}。", "success_title": "创建成功" }, "update": { - "error_description": "更新服务提供商时发生错误,请稍后重试。", - "error_title": "更新失败", "success_description_with_name": "已成功更新服务提供商 {{name}}。", "success_title": "更新成功" } diff --git a/src-frontend/src/i18n/locales/zh_CN/tabs-task.json b/src-frontend/src/i18n/locales/zh_CN/tabs-task.json index bb32b60..89fb5e8 100644 --- a/src-frontend/src/i18n/locales/zh_CN/tabs-task.json +++ b/src-frontend/src/i18n/locales/zh_CN/tabs-task.json @@ -16,10 +16,10 @@ "title": "任务出错,请重试" } }, - "panel": { - "error": { - "load_description": "无法加载任务,请稍后重试。" - } + "notification": { + "require_permission": "Dais 想要使用 {{toolName}}", + "require_response": "Dais 正在等待你的回复", + "task_done": "Dais 已完成任务" }, "prompt": { "agent": { @@ -31,7 +31,6 @@ "workspace_load_failed": "工作区加载失败" }, "context": { - "load_error": "加载文件失败。", "loading": "加载中...", "no_results": "未找到结果。", "search_placeholder": "添加文件、文件夹、文档...", @@ -45,11 +44,6 @@ "task_progress": { "completed": "任务已完成" }, - "notification": { - "require_response": "Dais 正在等待你的回复", - "require_permission": "Dais 想要使用 {{toolName}}", - "task_done": "Dais 已完成任务" - }, "toast": { "task_failed": { "title": "任务失败" @@ -82,4 +76,4 @@ "title": "已更新任务待办" } } -} \ No newline at end of file +} diff --git a/src-frontend/src/i18n/locales/zh_CN/tabs-toolset.json b/src-frontend/src/i18n/locales/zh_CN/tabs-toolset.json index d2f5b7e..4ef79d0 100644 --- a/src-frontend/src/i18n/locales/zh_CN/tabs-toolset.json +++ b/src-frontend/src/i18n/locales/zh_CN/tabs-toolset.json @@ -36,9 +36,9 @@ "type": { "label": "类型", "option": { - "built_in": "Built-in", - "mcp_local": "Local MCP", - "mcp_remote": "Remote MCP" + "built_in": "内置", + "mcp_local": "本地 MCP", + "mcp_remote": "远程 MCP" }, "placeholder": "选择 Toolset 类型" }, @@ -47,21 +47,12 @@ "placeholder": "https://api.example.com" } }, - "panel": { - "error": { - "load_description": "无法加载 Toolset 信息,请稍后重试。" - } - }, "toast": { "create": { - "error_description": "创建 Toolset 时发生错误,请稍后重试。", - "error_title": "创建失败", "success_description_with_name": "已成功创建 Toolset {{name}}。", "success_title": "创建成功" }, "update": { - "error_description": "更新 Toolset 时发生错误,请稍后重试。", - "error_title": "更新失败", "success_description_with_name": "已成功更新 Toolset {{name}}。", "success_title": "更新成功" } diff --git a/src-frontend/src/i18n/locales/zh_CN/tabs-workspace.json b/src-frontend/src/i18n/locales/zh_CN/tabs-workspace.json index efdb332..5c3e2ff 100644 --- a/src-frontend/src/i18n/locales/zh_CN/tabs-workspace.json +++ b/src-frontend/src/i18n/locales/zh_CN/tabs-workspace.json @@ -18,30 +18,19 @@ "confirm": "确定", "empty": "未找到匹配的 Agent", "label": "可用的 Agent", - "load_error": "无法加载 Agent 列表,请稍后重试。", "search_placeholder": "搜索 Agent...", "select": "选择" }, "usable_tools": { - "label": "可用的工具", - "load_error": "无法加载工具列表,请稍后重试。" - } - }, - "panel": { - "error": { - "load_description": "无法加载工作区信息,请稍后重试。" + "label": "可用的工具" } }, "toast": { "create": { - "error_description": "创建工作区时发生错误,请稍后重试。", - "error_title": "创建失败", "success_description_with_name": "已成功创建工作区 \"{{name}}\"。", "success_title": "创建成功" }, "update": { - "error_description": "更新工作区时发生错误,请稍后重试。", - "error_title": "更新失败", "success_description_with_name": "已成功更新工作区 \"{{name}}\"。", "success_title": "更新成功" } diff --git a/src-frontend/src/i18n/resources.ts b/src-frontend/src/i18n/resources.ts index 891529e..c44a6ac 100644 --- a/src-frontend/src/i18n/resources.ts +++ b/src-frontend/src/i18n/resources.ts @@ -9,6 +9,8 @@ import enTabsProvider from "./locales/en/tabs-provider.json"; import enTabsTask from "./locales/en/tabs-task.json"; import enTabsToolset from "./locales/en/tabs-toolset.json"; import enTabsWorkspace from "./locales/en/tabs-workspace.json"; +import enError from "./locales/en/error.json"; +import enComponentsCustom from "./locales/en/components-custom.json"; import zhCnDialog from "./locales/zh_CN/dialog.json"; import zhCnForm from "./locales/zh_CN/form.json"; import zhCnSideBar from "./locales/zh_CN/sidebar.json"; @@ -18,6 +20,8 @@ import zhCnTabsProvider from "./locales/zh_CN/tabs-provider.json"; import zhCnTabsTask from "./locales/zh_CN/tabs-task.json"; import zhCnTabsToolset from "./locales/zh_CN/tabs-toolset.json"; import zhCnTabsWorkspace from "./locales/zh_CN/tabs-workspace.json"; +import zhCnError from "./locales/zh_CN/error.json"; +import zhCnComponentsCustom from "./locales/zh_CN/components-custom.json"; export const DEFAULT_NAMESPACE = "common"; export const SIDEBAR_NAMESPACE = "sidebar"; @@ -29,55 +33,11 @@ export const TABS_WORKSPACE_NAMESPACE = "tabs-workspace"; export const TABS_TASK_NAMESPACE = "tabs-task"; export const FORM_NAMESPACE = "form"; export const DIALOG_NAMESPACE = "dialog"; +export const ERROR_NAMESPACE = "error"; +export const COMPONENTS_CUSTOM_NAMESPACE = "components-custom"; export const FALLBACK_LANGUAGE: Language = "en"; export const SUPPORTED_LANGUAGES = ["en", "zh_CN"] as const satisfies readonly Language[]; - -const SIDEBAR_TRANSLATIONS: Record = { - en: enSideBar, - zh_CN: zhCnSideBar, -}; - -const TABS_TRANSLATIONS: Record = { - en: enTabs, - zh_CN: zhCnTabs, -}; - -const TABS_AGENT_TRANSLATIONS: Record = { - en: enTabsAgent, - zh_CN: zhCnTabsAgent, -}; - -const TABS_PROVIDER_TRANSLATIONS: Record = { - en: enTabsProvider, - zh_CN: zhCnTabsProvider, -}; - -const TABS_TOOLSET_TRANSLATIONS: Record = { - en: enTabsToolset, - zh_CN: zhCnTabsToolset, -}; - -const TABS_WORKSPACE_TRANSLATIONS: Record = { - en: enTabsWorkspace, - zh_CN: zhCnTabsWorkspace, -}; - -const TABS_TASK_TRANSLATIONS: Record = { - en: enTabsTask, - zh_CN: zhCnTabsTask, -}; - -const FORM_TRANSLATIONS: Record = { - en: enForm, - zh_CN: zhCnForm, -}; - -const DIALOG_TRANSLATIONS: Record = { - en: enDialog, - zh_CN: zhCnDialog, -}; - export const namespaces = [ DEFAULT_NAMESPACE, SIDEBAR_NAMESPACE, @@ -89,29 +49,34 @@ export const namespaces = [ TABS_TASK_NAMESPACE, FORM_NAMESPACE, DIALOG_NAMESPACE, + ERROR_NAMESPACE, ]; export const resources: Resource = { en: { - [SIDEBAR_NAMESPACE]: SIDEBAR_TRANSLATIONS.en, - [TABS_NAMESPACE]: TABS_TRANSLATIONS.en, - [TABS_AGENT_NAMESPACE]: TABS_AGENT_TRANSLATIONS.en, - [TABS_PROVIDER_NAMESPACE]: TABS_PROVIDER_TRANSLATIONS.en, - [TABS_TOOLSET_NAMESPACE]: TABS_TOOLSET_TRANSLATIONS.en, - [TABS_WORKSPACE_NAMESPACE]: TABS_WORKSPACE_TRANSLATIONS.en, - [TABS_TASK_NAMESPACE]: TABS_TASK_TRANSLATIONS.en, - [FORM_NAMESPACE]: FORM_TRANSLATIONS.en, - [DIALOG_NAMESPACE]: DIALOG_TRANSLATIONS.en, + [SIDEBAR_NAMESPACE]: enSideBar, + [TABS_NAMESPACE]: enTabs, + [TABS_AGENT_NAMESPACE]: enTabsAgent, + [TABS_PROVIDER_NAMESPACE]: enTabsProvider, + [TABS_TOOLSET_NAMESPACE]: enTabsToolset, + [TABS_WORKSPACE_NAMESPACE]: enTabsWorkspace, + [TABS_TASK_NAMESPACE]: enTabsTask, + [FORM_NAMESPACE]: enForm, + [DIALOG_NAMESPACE]: enDialog, + [COMPONENTS_CUSTOM_NAMESPACE]: enComponentsCustom, + [ERROR_NAMESPACE]: enError, }, zh_CN: { - [SIDEBAR_NAMESPACE]: SIDEBAR_TRANSLATIONS.zh_CN, - [TABS_NAMESPACE]: TABS_TRANSLATIONS.zh_CN, - [TABS_AGENT_NAMESPACE]: TABS_AGENT_TRANSLATIONS.zh_CN, - [TABS_PROVIDER_NAMESPACE]: TABS_PROVIDER_TRANSLATIONS.zh_CN, - [TABS_TOOLSET_NAMESPACE]: TABS_TOOLSET_TRANSLATIONS.zh_CN, - [TABS_WORKSPACE_NAMESPACE]: TABS_WORKSPACE_TRANSLATIONS.zh_CN, - [TABS_TASK_NAMESPACE]: TABS_TASK_TRANSLATIONS.zh_CN, - [FORM_NAMESPACE]: FORM_TRANSLATIONS.zh_CN, - [DIALOG_NAMESPACE]: DIALOG_TRANSLATIONS.zh_CN, + [SIDEBAR_NAMESPACE]: zhCnSideBar, + [TABS_NAMESPACE]: zhCnTabs, + [TABS_AGENT_NAMESPACE]: zhCnTabsAgent, + [TABS_PROVIDER_NAMESPACE]: zhCnTabsProvider, + [TABS_TOOLSET_NAMESPACE]: zhCnTabsToolset, + [TABS_WORKSPACE_NAMESPACE]: zhCnTabsWorkspace, + [TABS_TASK_NAMESPACE]: zhCnTabsTask, + [FORM_NAMESPACE]: zhCnForm, + [DIALOG_NAMESPACE]: zhCnDialog, + [COMPONENTS_CUSTOM_NAMESPACE]: zhCnComponentsCustom, + [ERROR_NAMESPACE]: zhCnError, }, }; diff --git a/src-frontend/src/query-client.ts b/src-frontend/src/query-client.ts index 8c1ad3a..623fd68 100644 --- a/src-frontend/src/query-client.ts +++ b/src-frontend/src/query-client.ts @@ -1,4 +1,7 @@ +import { toast } from "sonner"; import { QueryClient } from "@tanstack/react-query"; +import { FetchError } from "@/api/orval-mutator/custom-fetch"; +import { getErrorMessage } from "@/i18n/error-message"; export default new QueryClient({ defaultOptions: { @@ -6,5 +9,15 @@ export default new QueryClient({ staleTime: 5 * 60 * 1000, retry: 1, }, + mutations: { + onError: (error) => { + console.error(error); + if (error instanceof FetchError) { + toast.error(getErrorMessage(error.errorCode), { duration: Number.POSITIVE_INFINITY }); + } else { + toast.error(getErrorMessage("UNEXPECTED_ERROR"), { duration: Number.POSITIVE_INFINITY }); + } + }, + }, }, }); diff --git a/src-server/pyproject.toml b/src-server/pyproject.toml index 516be15..fe8aae5 100644 --- a/src-server/pyproject.toml +++ b/src-server/pyproject.toml @@ -3,7 +3,7 @@ name = "dais-server" version = "0.1.0" requires-python = ">=3.14" dependencies = [ - "dais-sdk==0.8.4", + "dais-sdk==0.8.7", "dais-shell==0.1.2", "alembic==1.18.4", diff --git a/src-server/src/agent/tool/toolset_manager/mcp_toolset_manager.py b/src-server/src/agent/tool/toolset_manager/mcp_toolset_manager.py index 0a90e1e..4fafac6 100644 --- a/src-server/src/agent/tool/toolset_manager/mcp_toolset_manager.py +++ b/src-server/src/agent/tool/toolset_manager/mcp_toolset_manager.py @@ -1,26 +1,35 @@ import asyncio -import threading +from enum import Enum from typing import Sequence, override from loguru import logger -from dais_sdk.tool import Toolset +from dais_sdk.tool import Toolset, McpToolset as SdkMcpToolset from .types import ToolsetManager from ..toolset_wrapper import McpToolset -from ....db import db_context +from ....db import db_context, toolset_models -_logger = logger.bind(name="McpToolsetManager") + +class McpToolsetManagerNotInitializedError(Exception): + def __init__(self): + super().__init__("MCP toolset manager not initialized") + +class McpToolsetManagerState(Enum): + CONNECTING = "connecting" + CONNECTED = "connected" + DISCONNECTING = "disconnecting" + DISCONNECTED = "disconnected" class McpToolsetManager(ToolsetManager): + _logger = logger.bind(name="McpToolsetManager") + def __init__(self): - self._lock = threading.Lock() - self._connecting = False - self._connected = False + self._state = McpToolsetManagerState.DISCONNECTED self._toolset_map: dict[int, McpToolset] | None = None @property @override def toolsets(self) -> Sequence[Toolset]: if self._toolset_map is None: - raise ValueError("Toolset manager not initialized") + raise McpToolsetManagerNotInitializedError() return list(self._toolset_map.values()) async def initialize(self): @@ -28,69 +37,57 @@ async def initialize(self): async with db_context() as db_session: toolset_ents = await ToolsetService(db_session).get_all_mcp_toolsets() - with self._lock: - self._toolset_map = {toolset.id: McpToolset(toolset) for toolset in toolset_ents} + self._toolset_map = {toolset.id: McpToolset(toolset) for toolset in toolset_ents} - async def refresh_toolset_metadata(self): - from ....services import ToolsetService + def append(self, inner_toolset: SdkMcpToolset, toolset_ent: toolset_models.Toolset): + """ + Append a newly connected MCP toolset to the manager. + """ + if self._toolset_map is None: + raise McpToolsetManagerNotInitializedError() + new_toolset = McpToolset(toolset_ent, inner_toolset) + self._toolset_map[toolset_ent.id] = new_toolset + + async def remove(self, toolset_id: int): if self._toolset_map is None: - raise ValueError("Toolset manager not initialized") + raise McpToolsetManagerNotInitializedError() - async with db_context() as db_session: - toolset_ents = await ToolsetService(db_session).get_all_mcp_toolsets() + toolset = self._toolset_map.pop(toolset_id, None) + if toolset is None: + self._logger.warning(f"Toolset {toolset_id} not found, skip disconnecting") + return - toolsets = [] - toolset_connect_tasks = [] - with self._lock: - for toolset in toolset_ents: - existing_toolset = self._toolset_map.get(toolset.id) - if existing_toolset is None: - new_toolset = McpToolset(toolset) - self._toolset_map[toolset.id] = new_toolset - toolsets.append(new_toolset) - toolset_connect_tasks.append(new_toolset.connect()) - continue - toolsets.append(existing_toolset) - existing_toolset.refresh_metadata(toolset.tools) - - results = await asyncio.gather(*toolset_connect_tasks, return_exceptions=True) - for toolset, result in zip(toolsets, results): - if not isinstance(result, BaseException): continue - _logger.exception(f"Failed to connect to MCP server {toolset.name}") - toolset.error = result + try: + await toolset.disconnect() + except Exception as e: + self._logger.opt(exception=e).warning(f"Failed to disconnect from MCP server {toolset.name}") async def connect_mcp_servers(self): if self._toolset_map is None: raise ValueError("Toolset manager not initialized") - with self._lock: - if self._connected or self._connecting: return - self._connecting = True + if self._state != McpToolsetManagerState.DISCONNECTED: return + self._state = McpToolsetManagerState.CONNECTING toolsets = list(self._toolset_map.values()) tasks = [toolset.connect() for toolset in toolsets] results = await asyncio.gather(*tasks, return_exceptions=True) for toolset, result in zip(toolsets, results): if not isinstance(result, BaseException): continue - _logger.exception(f"Failed to connect to MCP server {toolset.name}") - toolset.error = result - - with self._lock: - self._connecting = False - self._connected = True + self._logger.exception(f"Failed to connect to MCP server {toolset.name}") + self._state = McpToolsetManagerState.CONNECTED async def disconnect_mcp_servers(self): if self._toolset_map is None: raise ValueError("Toolset manager not initialized") - with self._lock: - if not self._connected or self._connecting: return - self._connecting = False - self._connected = False + if self._state != McpToolsetManagerState.CONNECTED: return + self._state = McpToolsetManagerState.DISCONNECTING tasks = [toolset.disconnect() for toolset in self._toolset_map.values()] await asyncio.gather(*tasks, return_exceptions=True) + self._state = McpToolsetManagerState.DISCONNECTED __instance: McpToolsetManager | None = None diff --git a/src-server/src/agent/tool/toolset_wrapper/mcp_toolset.py b/src-server/src/agent/tool/toolset_wrapper/mcp_toolset.py index 02acf61..eb56611 100644 --- a/src-server/src/agent/tool/toolset_wrapper/mcp_toolset.py +++ b/src-server/src/agent/tool/toolset_wrapper/mcp_toolset.py @@ -1,38 +1,97 @@ +import asyncio +import subprocess +import anyio +import httpx +import mcp from dataclasses import replace from enum import Enum from typing import cast, override from dais_sdk.mcp_client import LocalServerParams, RemoteServerParams from dais_sdk.tool import Toolset, McpToolset as SdkMcpToolset, LocalMcpToolset, RemoteMcpToolset from dais_sdk.types import ToolDef +from loguru import logger from ..types import ToolMetadata from ....db import db_context from ....db.models import toolset as toolset_models +class McpToolsetNotConnectedError(Exception): + def __init__(self, toolset_name: str): + super().__init__(f"MCP toolset '{toolset_name}' not connected") + class McpToolsetStatus(str, Enum): CONNECTING = "connecting" CONNECTED = "connected" DISCONNECTED = "disconnected" ERROR = "error" -class McpToolset(Toolset): - def __init__(self, toolset_ent: toolset_models.Toolset): - match toolset_ent.type: - case toolset_models.ToolsetType.MCP_LOCAL: - assert isinstance(toolset_ent.params, LocalServerParams) - inner_toolset = LocalMcpToolset(toolset_ent.name, toolset_ent.params) - case toolset_models.ToolsetType.MCP_REMOTE: - assert isinstance(toolset_ent.params, RemoteServerParams) - inner_toolset = RemoteMcpToolset(toolset_ent.name, toolset_ent.params) +class McpConnectErrorCode(str, Enum): + CONNECTION_TIMEOUT = "MCP_CONNECTION_TIMEOUT" + + # remote + CONNECTION_FAILED = "MCP_CONNECTION_FAILED" + AUTH_FAILED = "MCP_AUTH_FAILED" + PROTOCOL_ERROR = "MCP_PROTOCOL_ERROR" + + # local + COMMAND_NOT_FOUND = "MCP_COMMAND_NOT_FOUND" + PERMISSION_DENIED = "MCP_PERMISSION_DENIED" + PROCESS_START_FAILED = "MCP_PROCESS_START_FAILED" + PROCESS_CRASHED = "MCP_PROCESS_CRASHED" + + @classmethod + def from_exception(cls, e: Exception) -> McpConnectErrorCode: + match e: + case asyncio.TimeoutError(): + return cls.CONNECTION_TIMEOUT + case httpx.HTTPStatusError() as e if e.response.status_code == 401: + return cls.AUTH_FAILED + case mcp.McpError(): + return cls.PROTOCOL_ERROR + case FileNotFoundError(): + return cls.COMMAND_NOT_FOUND + case PermissionError(): + return cls.PERMISSION_DENIED + case subprocess.SubprocessError(): + return cls.PROCESS_START_FAILED + case BrokenPipeError() | anyio.EndOfStream(): + return cls.PROCESS_CRASHED case _: - raise ValueError(f"Unsupported toolset type: {toolset_ent.type}") + return cls.CONNECTION_FAILED + +async def mcp_connect_wrapper(toolset: SdkMcpToolset) -> McpConnectErrorCode | None: + error_code = None + try: + await asyncio.wait_for(toolset.connect(), timeout=15) + except* Exception as eg: + e = eg.exceptions[0] + logger.exception(f"MCP server connect error: {type(e).__name__}", exc_info=e) + error_code = McpConnectErrorCode.from_exception(e) + return error_code + +class McpToolset(Toolset): + def __init__(self, toolset_ent: toolset_models.Toolset, inner_toolset: SdkMcpToolset | None = None): + if not inner_toolset: + match toolset_ent.type: + case toolset_models.ToolsetType.MCP_LOCAL: + assert isinstance(toolset_ent.params, LocalServerParams) + inner_toolset = LocalMcpToolset(toolset_ent.name, toolset_ent.params) + case toolset_models.ToolsetType.MCP_REMOTE: + assert isinstance(toolset_ent.params, RemoteServerParams) + inner_toolset = RemoteMcpToolset(toolset_ent.name, toolset_ent.params) + case _: + raise ValueError(f"Unsupported toolset type: {toolset_ent.type}") + self._inner_toolset = inner_toolset self._toolset_id = toolset_ent.id - self._status = McpToolsetStatus.CONNECTING - self._error: BaseException | None = None + self._status = McpToolsetStatus.DISCONNECTED + self._error: McpConnectErrorCode | None = None self._tool_map = {self._inner_toolset.format_tool_name(tool.internal_key): tool for tool in toolset_ent.tools} + if self._inner_toolset.connected: + self._status = McpToolsetStatus.CONNECTED + @property @override def name(self) -> str: @@ -43,14 +102,9 @@ def status(self) -> McpToolsetStatus: return self._status @property - def error(self) -> BaseException | None: + def error(self) -> McpConnectErrorCode | None: return self._error - @error.setter - def error(self, error: BaseException): - self._status = McpToolsetStatus.ERROR - self._error = error - async def _merge_tools(self, latest_tool_list: list[ToolDef]) -> list[toolset_models.Tool]: from ....services import ToolsetService @@ -79,21 +133,28 @@ def get_tools(self) -> list[ToolDef]: needs_user_interaction=False))) return result - def refresh_metadata(self, tools: list[toolset_models.Tool]): - self._tool_map = {self._inner_toolset.format_tool_name(tool.internal_key): tool - for tool in tools} + async def sync(self): + if self._status != McpToolsetStatus.CONNECTED: + raise McpToolsetNotConnectedError(self.name) - async def connect(self): inner_toolset = cast(SdkMcpToolset, self._inner_toolset) - await inner_toolset.connect() - latest_tool_list = inner_toolset.get_tools(namespaced_tool_name=False) merged_tool_list = await self._merge_tools(latest_tool_list) + self._tool_map = {self._inner_toolset.format_tool_name(tool.internal_key): tool + for tool in merged_tool_list} - self.refresh_metadata(merged_tool_list) - self._status = McpToolsetStatus.CONNECTED + async def connect(self): + inner_toolset = cast(SdkMcpToolset, self._inner_toolset) + self._status = McpToolsetStatus.CONNECTING + error_code = await mcp_connect_wrapper(inner_toolset) + if error_code is None: + self._status = McpToolsetStatus.CONNECTED + await self.sync() + else: + self._status = McpToolsetStatus.ERROR + self._error = error_code async def disconnect(self): - inner_toolset = cast(McpToolset, self._inner_toolset) + inner_toolset = cast(SdkMcpToolset, self._inner_toolset) await inner_toolset.disconnect() self._status = McpToolsetStatus.DISCONNECTED diff --git a/src-server/src/api/__init__.py b/src-server/src/api/__init__.py index 1d8244c..27180d9 100644 --- a/src-server/src/api/__init__.py +++ b/src-server/src/api/__init__.py @@ -19,12 +19,14 @@ ) from .exception_handlers import ( ErrorResponseSchema, + handle_api_error, handle_service_error, handle_validation_error, handle_http_exception, handle_unexpected_exception, ) from .lifespan import lifespan +from .exceptions import ApiError from ..services.exceptions import ServiceError @@ -51,6 +53,7 @@ ) app.add_exception_handler(ServiceError, handle_service_error) +app.add_exception_handler(ApiError, handle_api_error) app.add_exception_handler(RequestValidationError, handle_validation_error) app.add_exception_handler(HTTPException, handle_http_exception) app.add_exception_handler(Exception, handle_unexpected_exception) diff --git a/src-server/src/api/exception_handlers.py b/src-server/src/api/exception_handlers.py index ba0c685..b317995 100644 --- a/src-server/src/api/exception_handlers.py +++ b/src-server/src/api/exception_handlers.py @@ -5,13 +5,9 @@ from fastapi.exceptions import HTTPException, RequestValidationError from pydantic import BaseModel from loguru import logger -from ..services.exceptions import ServiceError, ServiceErrorCode +from .exceptions import ApiError +from ..services.exceptions import ServiceError, ServiceStatusCode -SERVICE_ERROR_STATUS_CODES: dict[ServiceErrorCode, int] = { - ServiceErrorCode.NOT_FOUND: status.HTTP_404_NOT_FOUND, - ServiceErrorCode.CONFLICT: status.HTTP_409_CONFLICT, - ServiceErrorCode.BAD_REQUEST: status.HTTP_400_BAD_REQUEST, -} class ErrorResponseContent(TypedDict): error_code: str @@ -46,15 +42,21 @@ async def wrapper(request: Request, exc: Exception) -> JSONResponse: @_specific_exception_handler(ServiceError) async def handle_service_error(_: Request, exc: ServiceError) -> JSONResponse: """Handle service layer errors and convert them to HTTP responses.""" - status_code = SERVICE_ERROR_STATUS_CODES.get(exc.code, status.HTTP_500_INTERNAL_SERVER_ERROR) + status_code = ServiceStatusCode.status_code_map(exc.status_code) return JSONResponse(status_code=status_code, - content=ErrorResponseContent(error_code=exc.code.value, + content=ErrorResponseContent(error_code=exc.error_code.value, message=exc.message)) +@_specific_exception_handler(ApiError) +async def handle_api_error(_: Request, exc: ApiError) -> JSONResponse: + return JSONResponse(status_code=exc.status_code, + content=ErrorResponseContent(error_code=exc.error_code.value, + message=str(exc))) + # pydantic schema validation error @_specific_exception_handler(RequestValidationError) async def handle_validation_error(_: Request, exc: RequestValidationError) -> JSONResponse: - _logger.error(exc) + _logger.warning(exc) return JSONResponse(status_code=status.HTTP_400_BAD_REQUEST, content=ErrorResponseContent(error_code="VALIDATION_ERROR", message="Invalid request parameters")) @@ -66,7 +68,7 @@ async def handle_http_exception(_: Request, exc: HTTPException) -> JSONResponse: message=exc.detail)) async def handle_unexpected_exception(_: Request, exc: Exception) -> JSONResponse: - _logger.error(f"Unexpected server error: ", exc_info=exc) + _logger.error("Unexpected server error: ", exc_info=exc) return JSONResponse(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content=ErrorResponseContent(error_code="UNKNOWN", message="Unexpected server error")) diff --git a/src-server/src/api/exceptions.py b/src-server/src/api/exceptions.py new file mode 100644 index 0000000..eeff893 --- /dev/null +++ b/src-server/src/api/exceptions.py @@ -0,0 +1,18 @@ +from enum import Enum +from ..agent.tool.toolset_wrapper.mcp_toolset import McpConnectErrorCode + + +class ApiErrorCode(str, Enum): + TOOL_CALL_NOT_FOUND = "TOOL_CALL_NOT_FOUND" + CANNOT_CREATE_BUILTIN_TOOLSET = "CANNOT_CREATE_BUILTIN_TOOLSET" + + PATH_NOT_FOUND = "PATH_NOT_FOUND" + PATH_NOT_DIRECTORY = "PATH_NOT_DIRECTORY" + PATH_ACCESS_DENIED = "PATH_ACCESS_DENIED" + +class ApiError(Exception): + """Base class for all API errors.""" + def __init__(self, status_code: int, error_code: ApiErrorCode | McpConnectErrorCode, *args) -> None: + super().__init__(*args) + self.status_code = status_code + self.error_code = error_code diff --git a/src-server/src/api/lifespan.py b/src-server/src/api/lifespan.py index 94738c3..90c21ed 100644 --- a/src-server/src/api/lifespan.py +++ b/src-server/src/api/lifespan.py @@ -1,3 +1,4 @@ +import asyncio from collections.abc import AsyncIterator from contextlib import asynccontextmanager from typing import TypedDict @@ -23,11 +24,16 @@ async def lifespan(_: FastAPI) -> AsyncIterator[AppState]: mcp_toolset_manager = use_mcp_toolset_manager() await mcp_toolset_manager.initialize() - await mcp_toolset_manager.connect_mcp_servers() + mcp_toolset_connect_task = asyncio.create_task(mcp_toolset_manager.connect_mcp_servers()) try: yield AppState(sse_dispatcher=sse_dispatcher, mcp_toolset_manager=mcp_toolset_manager) + + mcp_toolset_connect_task.cancel() + await mcp_toolset_connect_task + except asyncio.CancelledError: + pass finally: await mcp_toolset_manager.disconnect_mcp_servers() await app_setting_manager.persist() diff --git a/src-server/src/api/routes/task/context_file.py b/src-server/src/api/routes/task/context_file.py index 58f32c8..8c2aa4c 100644 --- a/src-server/src/api/routes/task/context_file.py +++ b/src-server/src/api/routes/task/context_file.py @@ -1,10 +1,10 @@ from functools import lru_cache from pathlib import Path from typing import Literal -from fastapi import APIRouter, Query +from fastapi import APIRouter, Query, status from pydantic import BaseModel -from rapidfuzz import process, fuzz -from ....services.exceptions import BadRequestError, NotFoundError +from rapidfuzz import fuzz +from ...exceptions import ApiError, ApiErrorCode from ....db import DbSessionDep from ....services.workspace import WorkspaceService from ....schemas import task as task_schemas @@ -12,40 +12,31 @@ context_file_router = APIRouter(tags=["context_file"]) -class ContextPathError(BadRequestError): - """Raised when the provided context path is invalid.""" - +class ContextFileInternalError(ValueError): def __init__(self, message: str) -> None: super().__init__(message) -class ContextDirectoryNotFoundError(NotFoundError): - """Raised when the requested context directory does not exist.""" - - def __init__(self, path: str) -> None: - super().__init__("Context directory", path) - - def _list_directory(workspace_root: Path, path: str) -> list[task_schemas.ContextFileItem]: normalized_path = path.strip() or "." requested_path = Path(normalized_path) if requested_path.is_absolute(): - raise ContextPathError("Path must be a relative path") + raise ContextFileInternalError("Path must be a relative path") target_directory = (workspace_root / requested_path).resolve() try: target_directory.relative_to(workspace_root) except ValueError as error: - raise ContextPathError("Path is outside workspace directory") from error + raise ContextFileInternalError("Path is outside workspace directory") from error if not target_directory.exists(): - raise ContextDirectoryNotFoundError(normalized_path) + raise ApiError(status.HTTP_404_NOT_FOUND, ApiErrorCode.PATH_NOT_FOUND, f"Path not found: {normalized_path}") if not target_directory.is_dir(): - raise ContextPathError("Path is not a directory") + raise ApiError(status.HTTP_400_BAD_REQUEST, ApiErrorCode.PATH_NOT_DIRECTORY, "Path is not a directory") try: entries = list(target_directory.iterdir()) - except PermissionError as error: - raise ContextPathError("Permission denied for target directory") from error + except PermissionError: + raise ApiError(status.HTTP_403_FORBIDDEN, ApiErrorCode.PATH_ACCESS_DENIED, "Permission denied for target directory") items: list[task_schemas.ContextFileItem] = [] for entry in entries: diff --git a/src-server/src/api/routes/task/stream/__init__.py b/src-server/src/api/routes/task/stream/__init__.py index 48b9338..b548f12 100644 --- a/src-server/src/api/routes/task/stream/__init__.py +++ b/src-server/src/api/routes/task/stream/__init__.py @@ -1,9 +1,10 @@ from typing import Literal -from fastapi import APIRouter, Request, HTTPException, status +from fastapi import APIRouter, Request, status from fastapi.sse import EventSourceResponse from dais_sdk.types import UserMessage from pydantic import BaseModel -from .utils import agent_stream +from .stream_connector import agent_stream +from ....exceptions import ApiError, ApiErrorCode from .....agent.context import AgentContext from .....agent.task import AgentTask, ToolCallNotFoundError from .....agent.types import AgentEvent @@ -72,7 +73,9 @@ async def tool_answer(task_id: int, body: ToolAnswerBody, request: Request): try: replace_event = task.set_tool_call_result(body.call_id, body.answer) except ToolCallNotFoundError as e: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=e.call_id) + raise ApiError(status.HTTP_404_NOT_FOUND, + ApiErrorCode.TOOL_CALL_NOT_FOUND, + e.call_id) yield replace_event async for event in agent_stream(task, request): @@ -92,6 +95,7 @@ async def tool_reviews(task_id: int, body: ToolReviewBody, request: Request): async for event in task.approve_tool_call(body.call_id, body.status == "approved"): yield event except ToolCallNotFoundError as e: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=e.call_id) + raise ApiError(status.HTTP_404_NOT_FOUND, ApiErrorCode.TOOL_CALL_NOT_FOUND, e.call_id) + async for event in agent_stream(task, request): yield event diff --git a/src-server/src/api/routes/task/stream/utils.py b/src-server/src/api/routes/task/stream/stream_connector.py similarity index 96% rename from src-server/src/api/routes/task/stream/utils.py rename to src-server/src/api/routes/task/stream/stream_connector.py index 93740fe..8ee3812 100644 --- a/src-server/src/api/routes/task/stream/utils.py +++ b/src-server/src/api/routes/task/stream/stream_connector.py @@ -9,7 +9,7 @@ ) -_logger = logger.bind(name="TaskStreamRoute") +_logger = logger.bind(name="AgentStream") async def agent_stream(task: AgentTask, request: Request) -> AgentGenerator: pending_terminal_event = None diff --git a/src-server/src/api/routes/toolset.py b/src-server/src/api/routes/toolset.py index dfcc52d..54889ef 100644 --- a/src-server/src/api/routes/toolset.py +++ b/src-server/src/api/routes/toolset.py @@ -1,11 +1,16 @@ from typing import Annotated, cast +from dais_sdk.mcp_client import LocalServerParams, RemoteServerParams +from dais_sdk.tool import LocalMcpToolset, RemoteMcpToolset from fastapi import APIRouter, Depends, Request, status +from ..exceptions import ApiError, ApiErrorCode from ...agent.tool import McpToolset +from ...agent.tool.toolset_wrapper.mcp_toolset import mcp_connect_wrapper from ...agent.tool.toolset_manager.mcp_toolset_manager import McpToolsetManager -from ...db import DbSessionDep +from ...db import DbSessionDep, toolset_models from ...services.toolset import ToolsetService from ...schemas import toolset as toolset_schemas + toolset_router = APIRouter(tags=["toolset"]) def get_mcp_toolset_manager(request: Request) -> McpToolsetManager: @@ -25,23 +30,19 @@ async def get_toolsets_brief( result = [] for toolset in built_in_toolsets: result.append( - toolset_schemas.ToolsetBrief( - id=toolset.id, - name=toolset.name, - type=toolset.type, - status="connected", - ) - ) + toolset_schemas.ToolsetBrief(id=toolset.id, + name=toolset.name, + type=toolset.type, + status="connected", + error_code=None)) for toolset in cast(list[McpToolset], mcp_toolset_manager.toolsets): ent = mcp_toolset_map[toolset.name] result.append( - toolset_schemas.ToolsetBrief( - id=ent.id, - name=ent.name, - type=ent.type, - status=toolset.status, - ) - ) + toolset_schemas.ToolsetBrief(id=ent.id, + name=ent.name, + type=ent.type, + status=toolset.status, + error_code=toolset.error)) return result @toolset_router.get("/", response_model=list[toolset_schemas.ToolsetRead]) @@ -56,15 +57,28 @@ async def get_toolset(toolset_id: int, db_session: DbSessionDep): return await ToolsetService(db_session).get_toolset_by_id(toolset_id) @toolset_router.post("/", status_code=status.HTTP_201_CREATED, response_model=toolset_schemas.ToolsetRead) -async def create_mcp_toolset( +async def create_toolset( body: toolset_schemas.ToolsetCreate, db_session: DbSessionDep, mcp_toolset_manager: McpToolsetManagerDep, ): - new_toolset = await ToolsetService(db_session).create_toolset(body) - if (body.type == toolset_schemas.ToolsetType.MCP_LOCAL or - body.type == toolset_schemas.ToolsetType.MCP_REMOTE): - await mcp_toolset_manager.refresh_toolset_metadata() + match body.type: + case toolset_models.ToolsetType.BUILT_IN: + raise ApiError(status.HTTP_400_BAD_REQUEST, ApiErrorCode.CANNOT_CREATE_BUILTIN_TOOLSET, "Cannot create built-in toolset") + case toolset_models.ToolsetType.MCP_LOCAL: + assert isinstance(body.params, LocalServerParams) + toolset = LocalMcpToolset(body.name, body.params) + case toolset_models.ToolsetType.MCP_REMOTE: + assert isinstance(body.params, RemoteServerParams) + toolset = RemoteMcpToolset(body.name, body.params) + + mcp_connect_error = await mcp_connect_wrapper(toolset) + if mcp_connect_error is not None: + raise ApiError(status.HTTP_503_SERVICE_UNAVAILABLE, mcp_connect_error, "Failed to connect to MCP server") + + tools = toolset.get_tools(namespaced_tool_name=False) + new_toolset = await ToolsetService(db_session).create_toolset(body, tools) + mcp_toolset_manager.append(toolset, new_toolset) return new_toolset @toolset_router.put("/{toolset_id}", response_model=toolset_schemas.ToolsetRead) @@ -74,10 +88,24 @@ async def update_toolset( db_session: DbSessionDep, mcp_toolset_manager: McpToolsetManagerDep, ): + await mcp_toolset_manager.remove(toolset_id) updated_toolset = await ToolsetService(db_session).update_toolset(toolset_id, body) - if (updated_toolset.type == toolset_schemas.ToolsetType.MCP_LOCAL or - updated_toolset.type == toolset_schemas.ToolsetType.MCP_REMOTE): - await mcp_toolset_manager.refresh_toolset_metadata() + + match body.type: + case toolset_models.ToolsetType.MCP_LOCAL: + assert isinstance(body.params, LocalServerParams) + toolset = LocalMcpToolset(updated_toolset.name, body.params) + case toolset_models.ToolsetType.MCP_REMOTE: + assert isinstance(body.params, RemoteServerParams) + toolset = RemoteMcpToolset(updated_toolset.name, body.params) + case _: + raise ApiError(status.HTTP_400_BAD_REQUEST, ApiErrorCode.CANNOT_CREATE_BUILTIN_TOOLSET, "Cannot create built-in toolset") + + mcp_connect_error = await mcp_connect_wrapper(toolset) + if mcp_connect_error is not None: + raise ApiError(status.HTTP_503_SERVICE_UNAVAILABLE, mcp_connect_error, "Failed to connect to MCP server") + + mcp_toolset_manager.append(toolset, updated_toolset) return updated_toolset @toolset_router.delete("/{toolset_id}", status_code=status.HTTP_204_NO_CONTENT) @@ -87,4 +115,4 @@ async def delete_toolset( mcp_toolset_manager: McpToolsetManagerDep, ): await ToolsetService(db_session).delete_toolset(toolset_id) - await mcp_toolset_manager.refresh_toolset_metadata() + await mcp_toolset_manager.remove(toolset_id) diff --git a/src-server/src/db/models/toolset.py b/src-server/src/db/models/toolset.py index 9335f38..7ff6d8f 100644 --- a/src-server/src/db/models/toolset.py +++ b/src-server/src/db/models/toolset.py @@ -44,10 +44,6 @@ def toolset_id(self) -> int: return self._toolset_id _agents: Mapped[list[Agent]] = relationship(secondary=agent_tool_association_table, back_populates="usable_tools") - @staticmethod - def from_mcp_tool(tool: McpTool) -> Tool: - return Tool(name=tool.name, description=tool.description, internal_key=tool.name) - class Toolset(Base): __tablename__ = "toolsets" id: Mapped[int] = mapped_column(primary_key=True) diff --git a/src-server/src/logger.py b/src-server/src/logger.py new file mode 100644 index 0000000..4fd95b4 --- /dev/null +++ b/src-server/src/logger.py @@ -0,0 +1,45 @@ +import sys +import logging +from loguru import logger +from .common import DATA_DIR + +class InterceptHandler(logging.Handler): + def emit(self, record: logging.LogRecord): + try: + level = logger.level(record.levelname).name + except ValueError: + level = record.levelno + + # Find caller from where originated the logged message + frame, depth = sys._getframe(6), 6 + while frame and (depth == 0 or frame.f_code.co_filename == logging.__file__): + frame = frame.f_back + depth += 1 + + logger.opt(depth=depth, exception=record.exc_info).log( + level, record.getMessage() + ) + +def get_log_level(is_dev: bool): + return logging.getLevelNamesMapping().get( + "DEBUG" if is_dev else "INFO", + logging.DEBUG + ) + +def setup_logging(log_level: int): + # intercept everything at the root logger + logging.root.handlers = [InterceptHandler()] + logging.root.setLevel(log_level) + + # remove every other logger's handlers + # and propagate to root logger + for name in logging.root.manager.loggerDict.keys(): + logging.getLogger(name).handlers = [] + logging.getLogger(name).propagate = True + + for noisy_logger in ("aiosqlite", "httpcore", "httpx", "mcp"): + logging.getLogger(noisy_logger).setLevel(logging.WARNING) + + logger.remove() + logger.add(sys.stderr) + logger.add(DATA_DIR / "server.log", rotation="256 MB", mode="w") diff --git a/src-server/src/main.py b/src-server/src/main.py index 8c5850c..ed1cbf5 100644 --- a/src-server/src/main.py +++ b/src-server/src/main.py @@ -1,4 +1,5 @@ import argparse +import logging import uvicorn from typing import Callable from loguru import logger @@ -6,8 +7,8 @@ from . import IS_DEV from .api import app from .db import migrate_db +from .logger import get_log_level, setup_logging from .parent_watchdog import ParentWatchdog -from .common import DATA_DIR def prevent_port_occupancy(port: int): @@ -38,11 +39,15 @@ def try_kill(proc: psutil.Process): continue time.sleep(1) -def create_server(port: int) -> tuple[Server, Callable[[], None]]: +def create_server(port: int, log_level: int) -> tuple[Server, Callable[[], None]]: def stop_server(): server.should_exit = True - server_config = uvicorn.Config(app, host="127.0.0.1", port=port, workers=1) + server_config = uvicorn.Config(app, + host="127.0.0.1", + port=port, + workers=1, + log_level=log_level) server = uvicorn.Server(server_config) return server, stop_server @@ -54,10 +59,11 @@ async def main(): if IS_DEV: prevent_port_occupancy(args.port) - logger.add(DATA_DIR / "server.log", mode="w", enqueue=True) await migrate_db() - server, stop_server = create_server(args.port) + log_level = get_log_level(IS_DEV) + server, stop_server = create_server(args.port, log_level) ParentWatchdog(stop_server).start() + setup_logging(log_level) + await server.serve() - await logger.complete() diff --git a/src-server/src/schemas/extra.py b/src-server/src/schemas/extra.py index e1278d8..5b12ad7 100644 --- a/src-server/src/schemas/extra.py +++ b/src-server/src/schemas/extra.py @@ -1,9 +1,16 @@ from typing import TypeAliasType from dais_sdk.types import ToolSchema from ..agent.tool import get_builtin_tool_enum, get_builtin_tool_arg_schemas +from ..agent.tool.toolset_wrapper.mcp_toolset import McpConnectErrorCode from ..agent.types.metadata import ToolMessageMetadata +from ..api.exceptions import ApiErrorCode +from ..services.exceptions import ServiceErrorCode EXTRA_SCHEMA_TYPES: list[type | TypeAliasType | ToolSchema] = [ + ApiErrorCode, + ServiceErrorCode, + McpConnectErrorCode, + ToolMessageMetadata, get_builtin_tool_enum(), *get_builtin_tool_arg_schemas(), diff --git a/src-server/src/schemas/toolset.py b/src-server/src/schemas/toolset.py index 859f315..150b337 100644 --- a/src-server/src/schemas/toolset.py +++ b/src-server/src/schemas/toolset.py @@ -3,6 +3,7 @@ from . import DTOBase from ..db.models.toolset import ToolsetType from ..agent.tool import McpToolsetStatus +from ..agent.tool.toolset_wrapper.mcp_toolset import McpConnectErrorCode class ToolBase(DTOBase): name: str @@ -33,6 +34,8 @@ class ToolsetBrief(DTOBase): type: ToolsetType # "connected" for built-in toolsets, McpToolsetStatus for MCP toolsets status: Literal["connected"] | McpToolsetStatus + # None for built-in toolsets + error_code: McpConnectErrorCode | None class ToolsetRead(ToolsetBase): id: int diff --git a/src-server/src/services/__init__.py b/src-server/src/services/__init__.py index 9335b64..88c60d6 100644 --- a/src-server/src/services/__init__.py +++ b/src-server/src/services/__init__.py @@ -1,18 +1,7 @@ -from .agent import AgentService, AgentNotFoundError -from .workspace import WorkspaceService, WorkspaceNotFoundError -from .provider import ProviderService, ProviderNotFoundError -from .llm_model import LlmModelService, ModelNotFoundError -from .toolset import ( - ToolsetService, - ToolsetNotFoundError, - ToolsetInternalKeyAlreadyExistsError, - ToolNotFoundError, - CannotCreateBuiltinToolsetError, -) -from .task import TaskService, TaskNotFoundError -from .exceptions import ( - ServiceError, - NotFoundError, - ConflictError, - BadRequestError, -) +from .agent import AgentService +from .workspace import WorkspaceService +from .provider import ProviderService +from .llm_model import LlmModelService +from .toolset import ToolsetService +from .task import TaskService +from .exceptions import ServiceError \ No newline at end of file diff --git a/src-server/src/services/agent.py b/src-server/src/services/agent.py index edfcaf2..5f4db21 100644 --- a/src-server/src/services/agent.py +++ b/src-server/src/services/agent.py @@ -1,18 +1,15 @@ from sqlalchemy import select from sqlalchemy.orm import selectinload from .service_base import ServiceBase -from .exceptions import NotFoundError +from .exceptions import NotFoundError, ServiceErrorCode from ..db.models import agent as agent_models from ..db.models import toolset as toolset_models from ..schemas import agent as agent_schemas class AgentNotFoundError(NotFoundError): - """Raised when an agent is not found.""" - def __init__(self, agent_id: int) -> None: - super().__init__("Agent", agent_id) - + super().__init__(ServiceErrorCode.AGENT_NOT_FOUND, "Agent", agent_id) class AgentService(ServiceBase): def get_agents_query(self): @@ -73,7 +70,5 @@ async def update_agent(self, id: int, data: agent_schemas.AgentUpdate) -> agent_ return updated_agent async def delete_agent(self, id: int) -> None: - agent = await self._db_session.get(agent_models.Agent, id) - if not agent: - raise AgentNotFoundError(id) + agent = await self.get_agent_by_id(id) await self._db_session.delete(agent) diff --git a/src-server/src/services/exceptions.py b/src-server/src/services/exceptions.py index 359d35d..873d2f6 100644 --- a/src-server/src/services/exceptions.py +++ b/src-server/src/services/exceptions.py @@ -1,37 +1,60 @@ -"""Service layer error types. - -This module defines custom error types for the service layer. -These errors should not inherit from framework-specific exception classes. -""" from enum import Enum -class ServiceErrorCode(str, Enum): + +class ServiceStatusCode(str, Enum): NOT_FOUND = "NOT_FOUND" CONFLICT = "CONFLICT" BAD_REQUEST = "BAD_REQUEST" + UNAVAILABLE = "UNAVAILABLE" + + @staticmethod + def status_code_map(code: ServiceStatusCode) -> int: + from fastapi import status + return ({ + ServiceStatusCode.NOT_FOUND: status.HTTP_404_NOT_FOUND, + ServiceStatusCode.CONFLICT: status.HTTP_409_CONFLICT, + ServiceStatusCode.BAD_REQUEST: status.HTTP_400_BAD_REQUEST, + ServiceStatusCode.UNAVAILABLE: status.HTTP_503_SERVICE_UNAVAILABLE, + }).get(code, status.HTTP_500_INTERNAL_SERVER_ERROR) + +class ServiceErrorCode(str, Enum): + AGENT_NOT_FOUND = "AGENT_NOT_FOUND" + + WORKSPACE_NOT_FOUND = "WORKSPACE_NOT_FOUND" + + MODEL_NOT_FOUND = "MODEL_NOT_FOUND" + + PROVIDER_NOT_FOUND = "PROVIDER_NOT_FOUND" + + TASK_NOT_FOUND = "TASK_NOT_FOUND" + + TOOLSET_NOT_FOUND = "TOOLSET_NOT_FOUND" + TOOLSET_INTERNAL_KEY_ALREADY_EXISTS = "TOOLSET_INTERNAL_KEY_ALREADY_EXISTS" + + TOOL_NOT_FOUND = "TOOL_NOT_FOUND" class ServiceError(Exception): - """Base class for all service layer errors.""" - def __init__(self, code: ServiceErrorCode, message: str) -> None: + def __init__(self, status_code: ServiceStatusCode, error_code: ServiceErrorCode, message: str) -> None: super().__init__(message) - self.code = code + self.status_code = status_code + self.error_code = error_code self.message = message class NotFoundError(ServiceError): - """Raised when a requested resource is not found.""" - - def __init__(self, resource_type: str, identifier: int | str) -> None: + def __init__(self, error_code: ServiceErrorCode, resource_type: str, identifier: int | str) -> None: message = f"{resource_type} '{identifier}' not found" - super().__init__(ServiceErrorCode.NOT_FOUND, message) + super().__init__(ServiceStatusCode.NOT_FOUND, error_code, message) self.resource_type = resource_type self.identifier = identifier class ConflictError(ServiceError): - """Raised when there is a conflict with the current state of the resource.""" - def __init__(self, message: str) -> None: - super().__init__(ServiceErrorCode.CONFLICT, message) + def __init__(self, error_code: ServiceErrorCode, message: str) -> None: + super().__init__(ServiceStatusCode.CONFLICT, error_code, message) class BadRequestError(ServiceError): - """Raised when the request is invalid or cannot be processed.""" - def __init__(self, message: str) -> None: - super().__init__(ServiceErrorCode.BAD_REQUEST, message) + def __init__(self, error_code: ServiceErrorCode, message: str) -> None: + super().__init__(ServiceStatusCode.BAD_REQUEST, error_code, message) + +class UnavailableError(ServiceError): + def __init__(self, error_code: ServiceErrorCode, message: str) -> None: + super().__init__(ServiceStatusCode.UNAVAILABLE, error_code, message) diff --git a/src-server/src/services/llm_model.py b/src-server/src/services/llm_model.py index fce0c00..08b43f9 100644 --- a/src-server/src/services/llm_model.py +++ b/src-server/src/services/llm_model.py @@ -1,15 +1,12 @@ from sqlalchemy.orm import selectinload from .service_base import ServiceBase -from .exceptions import NotFoundError +from .exceptions import NotFoundError, ServiceErrorCode from ..db.models import provider as provider_models class ModelNotFoundError(NotFoundError): - """Raised when a model is not found.""" - def __init__(self, model_id: int) -> None: - super().__init__("Model", model_id) - + super().__init__(ServiceErrorCode.MODEL_NOT_FOUND, "Model", model_id) class LlmModelService(ServiceBase): async def get_model_by_id(self, model_id: int) -> provider_models.LlmModel: diff --git a/src-server/src/services/provider.py b/src-server/src/services/provider.py index dac6772..7768784 100644 --- a/src-server/src/services/provider.py +++ b/src-server/src/services/provider.py @@ -2,19 +2,16 @@ from sqlalchemy import select from sqlalchemy.orm import selectinload from .service_base import ServiceBase -from .exceptions import NotFoundError +from .exceptions import NotFoundError, ServiceErrorCode from ..db.models import provider as provider_models from ..schemas import provider as provider_schemas -_logger = logger.bind(name="ProviderService") +_logger = logger.bind(name="ProviderService") class ProviderNotFoundError(NotFoundError): - """Raised when a provider is not found.""" - def __init__(self, provider_id: int) -> None: - super().__init__("Provider", provider_id) - + super().__init__(ServiceErrorCode.PROVIDER_NOT_FOUND, "Provider", provider_id) class ProviderService(ServiceBase): def get_providers_query(self): @@ -128,7 +125,5 @@ def merge_models( return updated_provider async def delete_provider(self, id: int) -> None: - provider = await self._db_session.get(provider_models.Provider, id) - if not provider: - raise ProviderNotFoundError(id) + provider = await self.get_provider_by_id(id) await self._db_session.delete(provider) diff --git a/src-server/src/services/task.py b/src-server/src/services/task.py index dd6f9e2..20dd52c 100644 --- a/src-server/src/services/task.py +++ b/src-server/src/services/task.py @@ -1,17 +1,14 @@ from sqlalchemy import select from sqlalchemy.orm import selectinload from .service_base import ServiceBase -from .exceptions import NotFoundError +from .exceptions import NotFoundError, ServiceErrorCode from ..db.models import task as task_models from ..schemas import task as task_schemas class TaskNotFoundError(NotFoundError): - """Raised when a task is not found.""" - def __init__(self, task_id: int) -> None: - super().__init__("Task", task_id) - + super().__init__(ServiceErrorCode.TASK_NOT_FOUND, "Task", task_id) class TaskService(ServiceBase): def get_tasks_query(self, workspace_id: int): @@ -61,7 +58,5 @@ async def update_task(self, id: int, data: task_schemas.TaskUpdate) -> task_mode return task async def delete_task(self, id: int) -> None: - task = await self._db_session.get(task_models.Task, id) - if not task: - raise TaskNotFoundError(id) + task = await self.get_task_by_id(id) await self._db_session.delete(task) diff --git a/src-server/src/services/toolset.py b/src-server/src/services/toolset.py index a5fc748..e8dbc02 100644 --- a/src-server/src/services/toolset.py +++ b/src-server/src/services/toolset.py @@ -1,40 +1,24 @@ from typing import NamedTuple +from dais_sdk.types import ToolDef from sqlalchemy import select from sqlalchemy.orm import selectinload -from dais_sdk.mcp_client import LocalMcpClient, RemoteMcpClient, LocalServerParams, RemoteServerParams from .service_base import ServiceBase -from .exceptions import NotFoundError, ConflictError, BadRequestError +from .exceptions import NotFoundError, ConflictError, ServiceErrorCode from ..db.models import toolset as toolset_models from ..schemas import toolset as toolset_schemas class ToolsetNotFoundError(NotFoundError): - """Raised when a toolset is not found.""" - def __init__(self, toolset_identifier: int | str) -> None: - super().__init__("Toolset", toolset_identifier) - + super().__init__(ServiceErrorCode.TOOLSET_NOT_FOUND, "Toolset", toolset_identifier) class ToolsetInternalKeyAlreadyExistsError(ConflictError): - """Raised when attempting to create a toolset with a name that already exists.""" - def __init__(self, name: str) -> None: - super().__init__(f"Toolset '{name}' already exists") - + super().__init__(ServiceErrorCode.TOOLSET_INTERNAL_KEY_ALREADY_EXISTS, f"Toolset '{name}' already exists") class ToolNotFoundError(NotFoundError): - """Raised when a tool is not found.""" - def __init__(self, tool_id: int) -> None: - super().__init__("Tool", tool_id) - - -class CannotCreateBuiltinToolsetError(BadRequestError): - """Raised when attempting to create a builtin toolset.""" - - def __init__(self) -> None: - super().__init__("Cannot create builtin toolset") - + super().__init__(ServiceErrorCode.TOOL_NOT_FOUND, "Tool", tool_id) class ToolsetService(ServiceBase): class ToolLike(NamedTuple): @@ -89,17 +73,7 @@ async def get_toolset_by_internal_key(self, internal_key: str) -> toolset_models raise ToolsetNotFoundError(internal_key) return toolset - async def create_toolset(self, data: toolset_schemas.ToolsetCreate) -> toolset_models.Toolset: - match data.type: - case toolset_models.ToolsetType.BUILT_IN: - raise CannotCreateBuiltinToolsetError() - case toolset_models.ToolsetType.MCP_LOCAL: - assert isinstance(data.params, LocalServerParams) - client = LocalMcpClient(data.name, data.params) - case toolset_models.ToolsetType.MCP_REMOTE: - assert isinstance(data.params, RemoteServerParams) - client = RemoteMcpClient(data.name, data.params) - + async def create_toolset(self, data: toolset_schemas.ToolsetCreate, tools: list[ToolDef]) -> toolset_models.Toolset: try: await self.get_toolset_by_internal_key(data.name) except ToolsetNotFoundError: @@ -107,16 +81,15 @@ async def create_toolset(self, data: toolset_schemas.ToolsetCreate) -> toolset_m else: raise ToolsetInternalKeyAlreadyExistsError(data.name) - await client.connect() - try: - tools = await client.list_tools() - finally: - await client.disconnect() - new_toolset = toolset_models.Toolset( - **data.model_dump(), + **data.model_dump(exclude={"params"}), + params=data.params, internal_key=data.name, - tools=[toolset_models.Tool.from_mcp_tool(tool) for tool in tools], + tools=[toolset_models.Tool( + name=tool.name, + internal_key=tool.name, + description=tool.description, + ) for tool in tools], ) self._db_session.add(new_toolset) @@ -133,7 +106,10 @@ async def update_toolset(self, id: int, data: toolset_schemas.ToolsetUpdate) -> for tool_data in data.tools: await self.update_tool(id, tool_data.id, tool_data) - update_data = data.model_dump(exclude={"tools"}, exclude_unset=True) + if data.params is not None: + toolset.params = data.params + + update_data = data.model_dump(exclude={"params", "tools"}, exclude_unset=True) for key, value in update_data.items(): if hasattr(toolset, key) and value is not None: setattr(toolset, key, value) diff --git a/src-server/src/services/workspace.py b/src-server/src/services/workspace.py index 5e00d2f..26ca951 100644 --- a/src-server/src/services/workspace.py +++ b/src-server/src/services/workspace.py @@ -1,7 +1,7 @@ from sqlalchemy import select from sqlalchemy.orm import selectinload from .service_base import ServiceBase -from .exceptions import NotFoundError +from .exceptions import NotFoundError, ServiceErrorCode from ..db.models import workspace as workspace_models from ..db.models import agent as agent_models from ..db.models import toolset as toolset_models @@ -9,11 +9,8 @@ class WorkspaceNotFoundError(NotFoundError): - """Raised when a workspace is not found.""" - def __init__(self, workspace_id: int) -> None: - super().__init__("Workspace", workspace_id) - + super().__init__(ServiceErrorCode.WORKSPACE_NOT_FOUND, "Workspace", workspace_id) class WorkspaceService(ServiceBase): def get_workspaces_query(self): @@ -66,8 +63,6 @@ async def create_workspace(self, data: workspace_schemas.WorkspaceCreate) -> wor async def update_workspace(self, id: int, data: workspace_schemas.WorkspaceUpdate) -> workspace_models.Workspace: workspace = await self.get_workspace_by_id(id) - if not workspace: - raise WorkspaceNotFoundError(id) update_data = data.model_dump(exclude_unset=True, exclude={"usable_agent_ids", "usable_tool_ids"}) @@ -82,7 +77,5 @@ async def update_workspace(self, id: int, data: workspace_schemas.WorkspaceUpdat return updated_workspace async def delete_workspace(self, id: int) -> None: - workspace = await self._db_session.get(workspace_models.Workspace, id) - if not workspace: - raise WorkspaceNotFoundError(id) + workspace = await self.get_workspace_by_id(id) await self._db_session.delete(workspace) diff --git a/src-server/uv.lock b/src-server/uv.lock index bc8b5ea..d16ea2e 100644 --- a/src-server/uv.lock +++ b/src-server/uv.lock @@ -298,7 +298,7 @@ wheels = [ [[package]] name = "dais-sdk" -version = "0.8.4" +version = "0.8.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -308,9 +308,9 @@ dependencies = [ { name = "starlette" }, { name = "uvicorn" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d0/c4/e47d8531441b77a1cb9fcbabda9760ddc57ef5c25c99a522cf4693a49516/dais_sdk-0.8.4.tar.gz", hash = "sha256:d03170e3d3f8ae6b2a4b52f5bee5317d1c7dcddc70ff95bbd11fcedb42ed262b", size = 18193, upload-time = "2026-03-07T14:06:54.114Z" } +sdist = { url = "https://files.pythonhosted.org/packages/24/2f/b2a244ae9b4e2d5da2dec4a785fb500a2713dcc6682ebcca64a839683744/dais_sdk-0.8.7.tar.gz", hash = "sha256:976d312e75465998b3f002770790e0fe91433772cf66aa370a2ee285045ee198", size = 18196, upload-time = "2026-03-12T04:44:59.452Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/60/e9e6bf992f1f4f6905e2e9a7fbb98e8f1d410661838dc4a64609af653436/dais_sdk-0.8.4-py3-none-any.whl", hash = "sha256:cd6059f3b5e85123568067464082fccb168982bb3f3f4868b12dda0788e9c08f", size = 26959, upload-time = "2026-03-07T14:06:52.593Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e9/65f77b5b7806536c779e2b1e1d3191b344be6cd6ce65847e51517896e0e2/dais_sdk-0.8.7-py3-none-any.whl", hash = "sha256:0b961b1a2f3efdd6e1c157bb849459f3d7bb49ca27eb272b25824b19fdac779f", size = 27010, upload-time = "2026-03-12T04:44:58.339Z" }, ] [[package]] @@ -358,7 +358,7 @@ requires-dist = [ { name = "aiosqlite", specifier = "==0.22.1" }, { name = "alembic", specifier = "==1.18.4" }, { name = "binaryornot", specifier = "==0.4.4" }, - { name = "dais-sdk", specifier = "==0.8.4" }, + { name = "dais-sdk", specifier = "==0.8.7" }, { name = "dais-shell", specifier = "==0.1.2" }, { name = "fastapi", specifier = "==0.135.1" }, { name = "fastapi-pagination", specifier = "==0.15.10" },