diff --git a/Makefile b/Makefile index 4bd823437..2515f8bf4 100644 --- a/Makefile +++ b/Makefile @@ -47,6 +47,8 @@ test: lint yarn copy:assets:tauri yarn build:icon cargo test --manifest-path src-tauri/Cargo.toml --no-default-features --features test-tauri -- --test-threads=1 + cargo test --manifest-path src-tauri/plugins/tauri-plugin-hardware/Cargo.toml + cargo test --manifest-path src-tauri/plugins/tauri-plugin-llamacpp/Cargo.toml # Builds and publishes the app build-and-publish: install-and-build diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 58a342a26..efd69e9bf 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -44,7 +44,7 @@ jan-utils = { path = "./utils" } libloading = "0.8.7" log = "0.4" reqwest = { version = "0.11", features = ["json", "blocking", "stream"] } -rmcp = { git = "https://github.com/modelcontextprotocol/rust-sdk", rev = "209dbac50f51737ad953c3a2c8e28f3619b6c277", features = [ +rmcp = { version = "0.6.0", features = [ "client", "transport-sse-client", "transport-streamable-http-client", diff --git a/src-tauri/src/core/mcp/helpers.rs b/src-tauri/src/core/mcp/helpers.rs index 75a1bba3a..80a8b5f86 100644 --- a/src-tauri/src/core/mcp/helpers.rs +++ b/src-tauri/src/core/mcp/helpers.rs @@ -7,10 +7,11 @@ use rmcp::{ ServiceExt, }; use serde_json::Value; -use std::{collections::HashMap, env, sync::Arc, time::Duration}; +use std::{collections::HashMap, env, process::Stdio, sync::Arc, time::Duration}; use tauri::{AppHandle, Emitter, Manager, Runtime, State}; use tauri_plugin_http::reqwest; use tokio::{ + io::AsyncReadExt, process::Command, sync::Mutex, time::{sleep, timeout}, @@ -647,23 +648,8 @@ async fn schedule_mcp_start_task( { cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW: prevents shell window on Windows } - let app_path_str = app_path.to_str().unwrap().to_string(); - let log_file_path = format!("{}/logs/app.log", app_path_str); - match std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(log_file_path) - { - Ok(file) => { - cmd.stderr(std::process::Stdio::from(file)); - } - Err(err) => { - log::error!("Failed to open log file: {}", err); - } - }; cmd.kill_on_drop(true); - log::trace!("Command: {cmd:#?}"); config_params .args @@ -678,26 +664,42 @@ async fn schedule_mcp_start_task( } }); - let process = TokioChildProcess::new(cmd).map_err(|e| { - log::error!("Failed to run command {name}: {e}"); - format!("Failed to run command {name}: {e}") - })?; + let (process, stderr) = TokioChildProcess::builder(cmd) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| { + log::error!("Failed to run command {name}: {e}"); + format!("Failed to run command {name}: {e}") + })?; let service = () .serve(process) .await - .map_err(|e| format!("Failed to start MCP server {name}: {e}"))?; + .map_err(|e| format!("Failed to start MCP server {name}: {e}")); - // Get peer info and clone the needed values before moving the service - let server_info = service.peer_info(); - log::trace!("Connected to server: {server_info:#?}"); - - // Now move the service into the HashMap - servers - .lock() - .await - .insert(name.clone(), RunningServiceEnum::NoInit(service)); - log::info!("Server {name} started successfully."); + match service { + Ok(server) => { + log::trace!("Connected to server: {:#?}", server.peer_info()); + servers + .lock() + .await + .insert(name.clone(), RunningServiceEnum::NoInit(server)); + log::info!("Server {name} started successfully."); + } + Err(_) => { + let mut buffer = String::new(); + let error = match stderr + .expect("stderr must be piped") + .read_to_string(&mut buffer) + .await + { + Ok(_) => format!("Failed to start MCP server {name}: {buffer}"), + Err(_) => format!("Failed to read MCP server {name} stderr"), + }; + log::error!("{error}"); + return Err(error); + } + } // Wait a short time to verify the server is stable before marking as connected // This prevents race conditions where the server quits immediately @@ -754,7 +756,7 @@ pub fn extract_command_args(config: &Value) -> Option { command, args, envs, - headers + headers, }) } diff --git a/web-app/src/containers/dialogs/ErrorDialog.tsx b/web-app/src/containers/dialogs/ErrorDialog.tsx new file mode 100644 index 000000000..cd6ca879a --- /dev/null +++ b/web-app/src/containers/dialogs/ErrorDialog.tsx @@ -0,0 +1,123 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { AlertTriangle, ChevronDown, ChevronRight } from 'lucide-react' +import { IconCopy, IconCopyCheck } from '@tabler/icons-react' +import { useTranslation } from '@/i18n/react-i18next-compat' +import { toast } from 'sonner' +import { useState } from 'react' +import { useAppState } from '@/hooks/useAppState' + +export default function ErrorDialog() { + const { t } = useTranslation() + const { errorMessage, setErrorMessage } = useAppState() + const [isCopying, setIsCopying] = useState(false) + const [isDetailExpanded, setIsDetailExpanded] = useState(true) + + const handleCopy = async () => { + setIsCopying(true) + try { + await navigator.clipboard.writeText(errorMessage?.message ?? '') + toast.success('Copy successful', { + id: 'copy-model', + description: 'Model load error information copied to clipboard', + }) + } catch { + toast.error('Failed to copy', { + id: 'copy-model-error', + description: 'Failed to copy error information to clipboard', + }) + } finally { + setTimeout(() => setIsCopying(false), 2000) + } + } + + const handleDialogOpen = (open: boolean) => { + setErrorMessage(open ? errorMessage : undefined) + } + + return ( + + + +
+
+ +
+
+ {t('common:error')} + + {errorMessage?.title ?? 'Something went wrong'} + +
+
+
+ +
+
+ + + {isDetailExpanded && ( +
{ + if (el) { + el.scrollTop = el.scrollHeight + } + }} + > + {errorMessage?.message} +
+ )} +
+ {errorMessage?.subtitle} +
+ + + + + +
+
+ ) +} diff --git a/web-app/src/hooks/useAppState.ts b/web-app/src/hooks/useAppState.ts index 7b3841f5c..fe885e043 100644 --- a/web-app/src/hooks/useAppState.ts +++ b/web-app/src/hooks/useAppState.ts @@ -4,6 +4,12 @@ import { MCPTool } from '@/types/completion' import { useAssistant } from './useAssistant' import { ChatCompletionMessageToolCall } from 'openai/resources' +type AppErrorMessage = { + message?: string + title?: string + subtitle: string +} + type AppState = { streamingContent?: ThreadMessage loadingModel?: boolean @@ -13,6 +19,7 @@ type AppState = { tokenSpeed?: TokenSpeed currentToolCall?: ChatCompletionMessageToolCall showOutOfContextDialog?: boolean + errorMessage?: AppErrorMessage cancelToolCall?: () => void setServerStatus: (value: 'running' | 'stopped' | 'pending') => void updateStreamingContent: (content: ThreadMessage | undefined) => void @@ -26,6 +33,7 @@ type AppState = { resetTokenSpeed: () => void setOutOfContextDialog: (show: boolean) => void setCancelToolCall: (cancel: (() => void) | undefined) => void + setErrorMessage: (error: AppErrorMessage | undefined) => void } export const useAppState = create()((set) => ({ @@ -120,4 +128,9 @@ export const useAppState = create()((set) => ({ cancelToolCall: cancel, })) }, + setErrorMessage: (error) => { + set(() => ({ + errorMessage: error, + })) + }, })) diff --git a/web-app/src/routes/__root.tsx b/web-app/src/routes/__root.tsx index 77d9f9d2b..a8dc9fb03 100644 --- a/web-app/src/routes/__root.tsx +++ b/web-app/src/routes/__root.tsx @@ -29,6 +29,7 @@ import { import { useCallback, useEffect } from 'react' import GlobalError from '@/containers/GlobalError' import { GlobalEventHandler } from '@/providers/GlobalEventHandler' +import ErrorDialog from '@/containers/dialogs/ErrorDialog' export const Route = createRootRoute({ component: RootLayout, @@ -203,6 +204,7 @@ function RootLayout() { {/* */} + diff --git a/web-app/src/routes/settings/mcp-servers.tsx b/web-app/src/routes/settings/mcp-servers.tsx index 9c65a6f88..c95c47a2d 100644 --- a/web-app/src/routes/settings/mcp-servers.tsx +++ b/web-app/src/routes/settings/mcp-servers.tsx @@ -21,6 +21,7 @@ import { useToolApproval } from '@/hooks/useToolApproval' import { toast } from 'sonner' import { invoke } from '@tauri-apps/api/core' import { useTranslation } from '@/i18n/react-i18next-compat' +import { useAppState } from '@/hooks/useAppState' // Function to mask sensitive values const maskSensitiveValue = (value: string) => { @@ -120,6 +121,7 @@ function MCPServers() { const [loadingServers, setLoadingServers] = useState<{ [key: string]: boolean }>({}) + const { setErrorMessage } = useAppState() const handleOpenDialog = (serverKey?: string) => { if (serverKey) { @@ -247,13 +249,13 @@ function MCPServers() { getConnectedServers().then(setConnectedServers) }) .catch((error) => { - console.log(error, 'error.mcp') editServer(serverKey, { ...(config ?? (mcpServers[serverKey] as MCPServerConfig)), active: false, }) - toast.error(error, { - description: t('mcp-servers:checkParams'), + setErrorMessage({ + message: error, + subtitle: t('mcp-servers:checkParams'), }) }) .finally(() => {