chore: stream app logs to log window (#5019)

* chore: stream app logs to log window

* chore: remove unused states
This commit is contained in:
Louis 2025-05-19 22:51:37 +07:00 committed by GitHub
parent 2ae7417e10
commit 28c7e0d105
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 261 additions and 180 deletions

View File

@ -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<Vec<Tool>, 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<Vec<Tool>, String> {
let servers = state.mcp_servers.lock().await;
let mut all_tools: Vec<Tool> = 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<String, String> {
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<CallToolResult, String>` - 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<Map<String, Value>>,
) -> Result<CallToolResult, String> {
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<String, String> {
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())
}

View File

@ -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<Vec<Tool>, 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<Vec<Tool>, String> {
let servers = state.mcp_servers.lock().await;
let mut all_tools: Vec<Tool> = 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<CallToolResult, String>` - 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<Map<String, Value>>,
) -> Result<CallToolResult, String> {
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<String, String> {
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::*;

View File

@ -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

View File

@ -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<AppState>()((set) => ({
streamingContent: undefined,
loadingModel: false,
tools: [],
serverStatus: 'stopped',
updateStreamingContent: (content) => {
set({ streamingContent: content })
},
@ -24,4 +27,5 @@ export const useAppState = create<AppState>()((set) => ({
updateTools: (tools) => {
set({ tools })
},
setServerStatus: (value) => set({ serverStatus: value }),
}))

View File

@ -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<LocalApiServerState>()(
@ -41,8 +38,6 @@ export const useLocalApiServer = create<LocalApiServerState>()(
setCorsEnabled: (value) => set({ corsEnabled: value }),
verboseLogs: true,
setVerboseLogs: (value) => set({ verboseLogs: value }),
serverStatus: 'stopped',
setServerStatus: (value) => set({ serverStatus: value }),
}),
{
name: localStoregeKey.settingLocalApiServer,

View File

@ -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) => ({

View File

@ -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'

View File

@ -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<LogEntry[]>([])
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

View File

@ -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 (
<div className="flex flex-col h-full">
<HeaderPage>
@ -95,9 +111,42 @@ function General() {
ns: 'settings',
})}
actions={
<Button variant="destructive" size="sm">
{t('common.reset')}
</Button>
<Dialog>
<DialogTrigger asChild>
<Button variant="destructive" size="sm">
{t('common.reset')}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Factory Reset</DialogTitle>
<DialogDescription>
Are you sure you want to reset the app to factory
settings? This action is irreversible and recommended
only if the application is corrupted.
</DialogDescription>
<DialogFooter className="mt-2 flex items-center">
<DialogClose asChild>
<Button
variant="link"
size="sm"
className="hover:no-underline"
>
Cancel
</Button>
</DialogClose>
<DialogClose asChild>
<Button
variant="destructive"
onClick={() => resetApp()}
>
Reset
</Button>
</DialogClose>
</DialogFooter>
</DialogHeader>
</DialogContent>
</Dialog>
}
/>
</Card>

View File

@ -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() {
</p>
</div>
<Button onClick={toggleAPIServer}>
{`${serverStatus === 'running' ? 'Stop' : 'Start'}`}{' '}
Server
{`${serverStatus === 'running' ? 'Stop' : 'Start'}`} Server
</Button>
</div>
}

View File

@ -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<void>}
*/
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,
}
}