diff --git a/src-tauri/src/core/cmd.rs b/src-tauri/src/core/cmd.rs index a9f90ca80..3e816befe 100644 --- a/src-tauri/src/core/cmd.rs +++ b/src-tauri/src/core/cmd.rs @@ -1,15 +1,10 @@ -use rmcp::model::{CallToolRequestParam, CallToolResult, Tool}; use serde::{Deserialize, Serialize}; -use serde_json::{Map, Value}; use std::{fs, path::PathBuf}; use tauri::{AppHandle, Manager, Runtime, State}; use super::{server, setup, state::AppState}; const CONFIGURATION_FILE_NAME: &str = "settings.json"; -const DEFAULT_MCP_CONFIG: &str = r#"{ - "mcpServers": {} -}"#; #[derive(Serialize, Deserialize, Debug, Clone)] pub struct AppConfiguration { @@ -296,100 +291,13 @@ pub async fn stop_server() -> Result<(), String> { Ok(()) } -/// Retrieves all available tools from all MCP servers -/// -/// # Arguments -/// * `state` - Application state containing MCP server connections -/// -/// # Returns -/// * `Result, String>` - A vector of all tools if successful, or an error message if failed -/// -/// This function: -/// 1. Locks the MCP servers mutex to access server connections -/// 2. Iterates through all connected servers -/// 3. Gets the list of tools from each server -/// 4. Combines all tools into a single vector -/// 5. Returns the combined list of all available tools #[tauri::command] -pub async fn get_tools(state: State<'_, AppState>) -> Result, String> { - let servers = state.mcp_servers.lock().await; - let mut all_tools: Vec = Vec::new(); - - for (_, service) in servers.iter() { - // List tools - let tools = service.list_all_tools().await.map_err(|e| e.to_string())?; - - for tool in tools { - all_tools.push(tool); - } +pub async fn read_logs(app: AppHandle) -> Result { + let log_path = get_jan_data_folder_path(app).join("logs").join("app.log"); + if log_path.exists() { + let content = fs::read_to_string(log_path).map_err(|e| e.to_string())?; + Ok(content) + } else { + Err(format!("Log file not found")) } - - Ok(all_tools) -} - -/// Calls a tool on an MCP server by name with optional arguments -/// -/// # Arguments -/// * `state` - Application state containing MCP server connections -/// * `tool_name` - Name of the tool to call -/// * `arguments` - Optional map of argument names to values -/// -/// # Returns -/// * `Result` - Result of the tool call if successful, or error message if failed -/// -/// This function: -/// 1. Locks the MCP servers mutex to access server connections -/// 2. Searches through all servers for one containing the named tool -/// 3. When found, calls the tool on that server with the provided arguments -/// 4. Returns error if no server has the requested tool -#[tauri::command] -pub async fn call_tool( - state: State<'_, AppState>, - tool_name: String, - arguments: Option>, -) -> Result { - let servers = state.mcp_servers.lock().await; - - // Iterate through servers and find the first one that contains the tool - for (_, service) in servers.iter() { - if let Ok(tools) = service.list_all_tools().await { - if tools.iter().any(|t| t.name == tool_name) { - return service - .call_tool(CallToolRequestParam { - name: tool_name.into(), - arguments, - }) - .await - .map_err(|e| e.to_string()); - } - } - } - - Err(format!("Tool {} not found", tool_name)) -} - -#[tauri::command] -pub async fn get_mcp_configs(app: AppHandle) -> Result { - let mut path = get_jan_data_folder_path(app); - path.push("mcp_config.json"); - log::info!("read mcp configs, path: {:?}", path); - - // Create default empty config if file doesn't exist - if !path.exists() { - log::info!("mcp_config.json not found, creating default empty config"); - fs::write(&path, DEFAULT_MCP_CONFIG) - .map_err(|e| format!("Failed to create default MCP config: {}", e))?; - } - - let contents = fs::read_to_string(path).map_err(|e| e.to_string())?; - return Ok(contents); -} - -#[tauri::command] -pub async fn save_mcp_configs(app: AppHandle, configs: String) -> Result<(), String> { - let mut path = get_jan_data_folder_path(app); - path.push("mcp_config.json"); - log::info!("save mcp configs, path: {:?}", path); - - fs::write(path, configs).map_err(|e| e.to_string()) } diff --git a/src-tauri/src/core/mcp.rs b/src-tauri/src/core/mcp.rs index 4dc88b4a1..d544b34dc 100644 --- a/src-tauri/src/core/mcp.rs +++ b/src-tauri/src/core/mcp.rs @@ -1,12 +1,19 @@ use std::{collections::HashMap, env, sync::Arc}; +use rmcp::model::{CallToolRequestParam, CallToolResult, Tool}; use rmcp::{service::RunningService, transport::TokioChildProcess, RoleClient, ServiceExt}; -use serde_json::Value; +use serde_json::{Map, Value}; use tauri::{AppHandle, Emitter, Runtime, State}; use tokio::{process::Command, sync::Mutex}; +use std::{fs}; use super::{cmd::get_jan_data_folder_path, state::AppState}; +const DEFAULT_MCP_CONFIG: &str = r#"{ + "mcpServers": {} +}"#; + + /// Runs MCP commands by reading configuration from a JSON file and initializing servers /// /// # Arguments @@ -175,6 +182,104 @@ pub async fn get_connected_servers( Ok(servers_map.keys().cloned().collect()) } +/// Retrieves all available tools from all MCP servers +/// +/// # Arguments +/// * `state` - Application state containing MCP server connections +/// +/// # Returns +/// * `Result, String>` - A vector of all tools if successful, or an error message if failed +/// +/// This function: +/// 1. Locks the MCP servers mutex to access server connections +/// 2. Iterates through all connected servers +/// 3. Gets the list of tools from each server +/// 4. Combines all tools into a single vector +/// 5. Returns the combined list of all available tools +#[tauri::command] +pub async fn get_tools(state: State<'_, AppState>) -> Result, String> { + let servers = state.mcp_servers.lock().await; + let mut all_tools: Vec = Vec::new(); + + for (_, service) in servers.iter() { + // List tools + let tools = service.list_all_tools().await.map_err(|e| e.to_string())?; + + for tool in tools { + all_tools.push(tool); + } + } + + Ok(all_tools) +} + +/// Calls a tool on an MCP server by name with optional arguments +/// +/// # Arguments +/// * `state` - Application state containing MCP server connections +/// * `tool_name` - Name of the tool to call +/// * `arguments` - Optional map of argument names to values +/// +/// # Returns +/// * `Result` - Result of the tool call if successful, or error message if failed +/// +/// This function: +/// 1. Locks the MCP servers mutex to access server connections +/// 2. Searches through all servers for one containing the named tool +/// 3. When found, calls the tool on that server with the provided arguments +/// 4. Returns error if no server has the requested tool +#[tauri::command] +pub async fn call_tool( + state: State<'_, AppState>, + tool_name: String, + arguments: Option>, +) -> Result { + let servers = state.mcp_servers.lock().await; + + // Iterate through servers and find the first one that contains the tool + for (_, service) in servers.iter() { + if let Ok(tools) = service.list_all_tools().await { + if tools.iter().any(|t| t.name == tool_name) { + return service + .call_tool(CallToolRequestParam { + name: tool_name.into(), + arguments, + }) + .await + .map_err(|e| e.to_string()); + } + } + } + + Err(format!("Tool {} not found", tool_name)) +} + +#[tauri::command] +pub async fn get_mcp_configs(app: AppHandle) -> Result { + let mut path = get_jan_data_folder_path(app); + path.push("mcp_config.json"); + log::info!("read mcp configs, path: {:?}", path); + + // Create default empty config if file doesn't exist + if !path.exists() { + log::info!("mcp_config.json not found, creating default empty config"); + fs::write(&path, DEFAULT_MCP_CONFIG) + .map_err(|e| format!("Failed to create default MCP config: {}", e))?; + } + + let contents = fs::read_to_string(path).map_err(|e| e.to_string())?; + return Ok(contents); +} + +#[tauri::command] +pub async fn save_mcp_configs(app: AppHandle, configs: String) -> Result<(), String> { + let mut path = get_jan_data_folder_path(app); + path.push("mcp_config.json"); + log::info!("save mcp configs, path: {:?}", path); + + fs::write(path, configs).map_err(|e| e.to_string()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3bc99fb9c..21b886172 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -44,13 +44,14 @@ pub fn run() { core::cmd::app_token, core::cmd::start_server, core::cmd::stop_server, - core::cmd::save_mcp_configs, - core::cmd::get_mcp_configs, + core::cmd::read_logs, // MCP commands - core::cmd::get_tools, - core::cmd::call_tool, + core::mcp::get_tools, + core::mcp::call_tool, core::mcp::restart_mcp_servers, core::mcp::get_connected_servers, + core::mcp::save_mcp_configs, + core::mcp::get_mcp_configs, // Threads core::threads::list_threads, core::threads::create_thread, @@ -76,14 +77,14 @@ pub fn run() { .setup(|app| { app.handle().plugin( tauri_plugin_log::Builder::default() - .targets([if cfg!(debug_assertions) { - tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Stdout) - } else { + .targets([ + tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Stdout), + tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Webview), tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Folder { path: get_jan_data_folder_path(app.handle().clone()).join("logs"), file_name: Some("app".to_string()), - }) - }]) + }), + ]) .build(), )?; // Install extensions diff --git a/web-app/src/hooks/useAppState.ts b/web-app/src/hooks/useAppState.ts index f1f3f2864..1c4e37db6 100644 --- a/web-app/src/hooks/useAppState.ts +++ b/web-app/src/hooks/useAppState.ts @@ -6,6 +6,8 @@ type AppState = { streamingContent?: ThreadMessage loadingModel?: boolean tools: MCPTool[] + serverStatus: 'running' | 'stopped' | 'pending' + setServerStatus: (value: 'running' | 'stopped' | 'pending') => void updateStreamingContent: (content: ThreadMessage | undefined) => void updateLoadingModel: (loading: boolean) => void updateTools: (tools: MCPTool[]) => void @@ -15,6 +17,7 @@ export const useAppState = create()((set) => ({ streamingContent: undefined, loadingModel: false, tools: [], + serverStatus: 'stopped', updateStreamingContent: (content) => { set({ streamingContent: content }) }, @@ -24,4 +27,5 @@ export const useAppState = create()((set) => ({ updateTools: (tools) => { set({ tools }) }, + setServerStatus: (value) => set({ serverStatus: value }), })) diff --git a/web-app/src/hooks/useLocalApiServer.ts b/web-app/src/hooks/useLocalApiServer.ts index efb6df71b..65d1c1efa 100644 --- a/web-app/src/hooks/useLocalApiServer.ts +++ b/web-app/src/hooks/useLocalApiServer.ts @@ -21,9 +21,6 @@ type LocalApiServerState = { // Verbose server logs verboseLogs: boolean setVerboseLogs: (value: boolean) => void - // Server status - serverStatus: 'running' | 'stopped' | 'pending' - setServerStatus: (value: 'running' | 'stopped' | 'pending') => void } export const useLocalApiServer = create()( @@ -41,8 +38,6 @@ export const useLocalApiServer = create()( setCorsEnabled: (value) => set({ corsEnabled: value }), verboseLogs: true, setVerboseLogs: (value) => set({ verboseLogs: value }), - serverStatus: 'stopped', - setServerStatus: (value) => set({ serverStatus: value }), }), { name: localStoregeKey.settingLocalApiServer, diff --git a/web-app/src/lib/service.ts b/web-app/src/lib/service.ts index b41cbb68d..48b81a129 100644 --- a/web-app/src/lib/service.ts +++ b/web-app/src/lib/service.ts @@ -26,6 +26,7 @@ export const AppRoutes = [ 'getMcpConfigs', 'restartMcpServers', 'getConnectedServers', + 'readLogs', ] // Define API routes based on different route types export const Routes = [...CoreRoutes, ...APIRoutes, ...AppRoutes].map((r) => ({ diff --git a/web-app/src/providers/ExtensionProvider.tsx b/web-app/src/providers/ExtensionProvider.tsx index 209c4daad..bffce9e66 100644 --- a/web-app/src/providers/ExtensionProvider.tsx +++ b/web-app/src/providers/ExtensionProvider.tsx @@ -1,6 +1,6 @@ import { ExtensionManager } from '@/lib/extension' import { APIs } from '@/lib/service' -import { EventEmitter } from '@/services/eventsService' +import { EventEmitter } from '@/services/events' import { EngineManager, ModelManager } from '@janhq/core' import { PropsWithChildren, useCallback, useEffect, useState } from 'react' diff --git a/web-app/src/routes/local-api-server/logs.tsx b/web-app/src/routes/local-api-server/logs.tsx index d8cef35f0..db4189cb4 100644 --- a/web-app/src/routes/local-api-server/logs.tsx +++ b/web-app/src/routes/local-api-server/logs.tsx @@ -2,6 +2,8 @@ import { createFileRoute } from '@tanstack/react-router' import { route } from '@/constants/routes' import { useEffect, useState } from 'react' +import { parseLogLine, readLogs } from '@/services/app' +import { listen } from '@tauri-apps/api/event' // eslint-disable-next-line @typescript-eslint/no-explicit-any export const Route = createFileRoute(route.localApiServerlogs as any)({ @@ -12,72 +14,36 @@ export const Route = createFileRoute(route.localApiServerlogs as any)({ interface LogEntry { timestamp: string level: 'info' | 'warn' | 'error' | 'debug' + target: string message: string } -// Generate dummy log data -const generateDummyLogs = (): LogEntry[] => { - const logs: LogEntry[] = [] - const levels: ('info' | 'warn' | 'error' | 'debug')[] = [ - 'info', - 'warn', - 'error', - 'debug', - ] - const messages = [ - 'Server started on port 3000', - 'Received request: GET /api/v1/models', - 'Processing request...', - 'Request completed in 120ms', - 'Connection established with client', - 'Authentication successful for user', - 'Failed to connect to database', - 'API rate limit exceeded', - 'Memory usage: 256MB', - 'CPU usage: 45%', - 'Websocket connection closed', - 'Cache miss for key: model_list', - 'Updating configuration...', - 'Configuration updated successfully', - 'Initializing model...', - ] - - // Generate 50 log entries - const now = new Date() - for (let i = 0; i < 50; i++) { - const timestamp = new Date(now.getTime() - (50 - i) * 30000) // 30 seconds apart - logs.push({ - timestamp: timestamp.toISOString(), - level: levels[Math.floor(Math.random() * levels.length)], - message: messages[Math.floor(Math.random() * messages.length)], - }) - } - - return logs -} +const SERVER_LOG_TARGET = 'app_lib::core::server' +const LOG_EVENT_NAME = 'log://log' function LogsViewer() { const [logs, setLogs] = useState([]) useEffect(() => { - // Load dummy logs when component mounts - setLogs(generateDummyLogs()) - - // Simulate new logs coming in every 5 seconds - const interval = setInterval(() => { - setLogs((currentLogs) => { - const newLog: LogEntry = { - timestamp: new Date().toISOString(), - level: ['info', 'warn', 'error', 'debug'][ - Math.floor(Math.random() * 4) - ] as 'info' | 'warn' | 'error' | 'debug', - message: `New activity at ${new Date().toLocaleTimeString()}`, - } - return [...currentLogs, newLog] - }) - }, 5000) - - return () => clearInterval(interval) + readLogs().then((logData) => { + const logs = logData + .filter((log) => log?.target === SERVER_LOG_TARGET) + .filter(Boolean) as LogEntry[] + setLogs(logs) + }) + let unsubscribe = () => {} + listen(LOG_EVENT_NAME, (event) => { + const { message } = event.payload as { message: string } + const log: LogEntry | undefined = parseLogLine(message) + if (log?.target === SERVER_LOG_TARGET) { + setLogs((prevLogs) => [...prevLogs, log]) + } + }).then((unsub) => { + unsubscribe = unsub + }) + return () => { + unsubscribe() + } }, []) // Function to get appropriate color for log level diff --git a/web-app/src/routes/settings/general.tsx b/web-app/src/routes/settings/general.tsx index eeed15145..59ec23db1 100644 --- a/web-app/src/routes/settings/general.tsx +++ b/web-app/src/routes/settings/general.tsx @@ -8,6 +8,17 @@ import { Card, CardItem } from '@/containers/Card' import LanguageSwitcher from '@/containers/LanguageSwitcher' import { useTranslation } from 'react-i18next' import { useGeneralSetting } from '@/hooks/useGeneralSetting' +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog' +import { factoryReset } from '@/services/app' // eslint-disable-next-line @typescript-eslint/no-explicit-any export const Route = createFileRoute(route.settings.general as any)({ @@ -18,6 +29,11 @@ function General() { const { t } = useTranslation() const { spellCheckChatInput, setSpellCheckChatInput } = useGeneralSetting() + const resetApp = async () => { + // TODO: Loading indicator + await factoryReset() + } + return (
@@ -95,9 +111,42 @@ function General() { ns: 'settings', })} actions={ - + + + + + + + Factory Reset + + Are you sure you want to reset the app to factory + settings? This action is irreversible and recommended + only if the application is corrupted. + + + + + + + + + + + + } /> diff --git a/web-app/src/routes/settings/local-api-server.tsx b/web-app/src/routes/settings/local-api-server.tsx index 07e0e7e29..b9e607721 100644 --- a/web-app/src/routes/settings/local-api-server.tsx +++ b/web-app/src/routes/settings/local-api-server.tsx @@ -11,6 +11,7 @@ import { PortInput } from '@/containers/PortInput' import { ApiPrefixInput } from '@/containers/ApiPrefixInput' import { useLocalApiServer } from '@/hooks/useLocalApiServer' import { WebviewWindow } from '@tauri-apps/api/webviewWindow' +import { useAppState } from '@/hooks/useAppState' // eslint-disable-next-line @typescript-eslint/no-explicit-any export const Route = createFileRoute(route.settings.local_api_server as any)({ @@ -27,10 +28,10 @@ function LocalAPIServer() { serverHost, serverPort, apiPrefix, - serverStatus, - setServerStatus, } = useLocalApiServer() + const { serverStatus, setServerStatus } = useAppState() + const toggleAPIServer = async () => { setServerStatus('pending') if (serverStatus === 'stopped') { @@ -115,8 +116,7 @@ function LocalAPIServer() {

} diff --git a/web-app/src/services/app.ts b/web-app/src/services/app.ts new file mode 100644 index 000000000..77a71df8c --- /dev/null +++ b/web-app/src/services/app.ts @@ -0,0 +1,52 @@ +import { AppConfiguration, fs } from '@janhq/core' +import { invoke } from '@tauri-apps/api/core' + +/** + * @description This function is used to reset the app to its factory settings. + * It will remove all the data from the app, including the data folder and local storage. + * @returns {Promise} + */ +export const factoryReset = async () => { + const appConfiguration: AppConfiguration | undefined = + await window.core?.api?.getAppConfigurations() + + const janDataFolderPath = appConfiguration?.data_folder + if (janDataFolderPath) await fs.rm(janDataFolderPath) + window.localStorage.clear() + await window.core?.api?.installExtensions() + await window.core?.api?.relaunch() +} + +/** + * @description This function is used to read the logs from the app. + * It will return the logs as a string. + * @returns + */ +export const readLogs = async () => { + const logData: string = (await invoke('read_logs')) ?? '' + return logData.split('\n').map(parseLogLine) +} + +/** + * @description This function is used to parse a log line. + * It will return the log line as an object. + * @param line + * @returns + */ +export const parseLogLine = (line: string) => { + const regex = /^\[(.*?)\]\[(.*?)\]\[(.*?)\]\[(.*?)\]\s(.*)$/ + const match = line.match(regex) + + if (!match) return undefined // Skip invalid lines + + const [, date, time, target, levelRaw, message] = match + + const level = levelRaw.toLowerCase() as 'info' | 'warn' | 'error' | 'debug' + + return { + timestamp: `${date} ${time}`, + level, + target, + message, + } +} diff --git a/web-app/src/services/eventsService.ts b/web-app/src/services/events.ts similarity index 100% rename from web-app/src/services/eventsService.ts rename to web-app/src/services/events.ts