diff --git a/tauri-app/src-tauri/src/lib.rs b/tauri-app/src-tauri/src/lib.rs index 2035a10..a694475 100644 --- a/tauri-app/src-tauri/src/lib.rs +++ b/tauri-app/src-tauri/src/lib.rs @@ -1,9 +1,79 @@ -use tauri::Manager; +use std::process::{Child, Command}; +use std::sync::Mutex; +use tauri::{Manager, State}; + +pub struct ServerState(pub Mutex>); + +#[tauri::command] +fn start_server( + state: State, + model_path: String, + addr: String, + gpu_layers: i32, +) -> Result { + let mut guard = state.0.lock().map_err(|e| e.to_string())?; + + if guard.is_some() { + return Err("Server is already running".into()); + } + + // Resolve binary path relative to the app's resource dir or PATH + let binary = if cfg!(target_os = "windows") { + "localllm.exe" + } else { + "localllm" + }; + + let child = Command::new(binary) + .env("LOCALLLM_MODEL", &model_path) + .env("LOCALLLM_ADDR", &addr) + .env("LOCALLLM_GPU_LAYERS", gpu_layers.to_string()) + .spawn() + .map_err(|e| format!("Failed to start server: {e}"))?; + + let pid = child.id(); + *guard = Some(child); + + Ok(format!("Server started (pid={pid}) on {addr}")) +} + +#[tauri::command] +fn stop_server(state: State) -> Result { + let mut guard = state.0.lock().map_err(|e| e.to_string())?; + + match guard.as_mut() { + None => Err("Server is not running".into()), + Some(child) => { + child + .kill() + .map_err(|e| format!("Failed to kill server: {e}"))?; + let _ = child.wait(); + *guard = None; + Ok("Server stopped".into()) + } + } +} + +#[tauri::command] +fn server_status(state: State) -> String { + let guard = state.0.lock().unwrap(); + if guard.is_some() { + "running".into() + } else { + "stopped".into() + } +} #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_shell::init()) + .manage(ServerState(Mutex::new(None))) + .invoke_handler(tauri::generate_handler![ + start_server, + stop_server, + server_status + ]) .setup(|app| { #[cfg(debug_assertions)] { diff --git a/tauri-app/src-tauri/tauri.conf.json b/tauri-app/src-tauri/tauri.conf.json index 9348a27..469edbc 100644 --- a/tauri-app/src-tauri/tauri.conf.json +++ b/tauri-app/src-tauri/tauri.conf.json @@ -29,7 +29,8 @@ "bundle": { "active": true, "targets": "all", - "icon": [] + "icon": [], + "resources": ["../../../target/release/localllm.exe", "../../../target/release/localllm"] }, "plugins": { "shell": { diff --git a/tauri-app/src/App.tsx b/tauri-app/src/App.tsx index 1fbce65..ff2816e 100644 --- a/tauri-app/src/App.tsx +++ b/tauri-app/src/App.tsx @@ -3,6 +3,7 @@ import Sidebar from "./components/Sidebar"; import ChatView from "./components/ChatView"; import { useDB } from "./hooks/useDB"; import { useModels } from "./hooks/useModels"; +import { useServerProcess } from "./hooks/useServerProcess"; import type { Chat } from "./lib/types"; import "./App.css"; @@ -14,11 +15,19 @@ export default function App() { const [apiKey, setApiKey] = useState(() => localStorage.getItem("forgelm_apikey") || ""); const { models, selectedModel, setSelectedModel, status, refresh: refreshModels } = useModels(server, apiKey); + const serverProcess = useServerProcess(); // Persist server/key useEffect(() => { localStorage.setItem("forgelm_server", server); }, [server]); useEffect(() => { localStorage.setItem("forgelm_apikey", apiKey); }, [apiKey]); + // Auto-refresh models when server starts + useEffect(() => { + if (serverProcess.status === "running") { + setTimeout(refreshModels, 2000); + } + }, [serverProcess.status, refreshModels]); + const loadChats = useCallback(() => { if (!db) return; const rows = db.query("SELECT * FROM chats ORDER BY updated_at DESC"); @@ -70,6 +79,7 @@ export default function App() { onModelChange={setSelectedModel} status={status} onRefreshModels={refreshModels} + serverProcess={serverProcess} /> void; + onStop: () => void; +} + +const STATUS_LABEL: Record = { + stopped: "Stopped", + starting: "Starting…", + running: "Running", + stopping: "Stopping…", + error: "Error", +}; + +const STATUS_COLOR: Record = { + stopped: "#6b7280", + starting: "#f59e0b", + running: "#22c55e", + stopping: "#f59e0b", + error: "#ef4444", +}; + +export default function ServerPanel({ status, message, onStart, onStop }: Props) { + const [modelPath, setModelPath] = useState( + () => localStorage.getItem("forgelm_model_path") || "" + ); + const [addr, setAddr] = useState( + () => localStorage.getItem("forgelm_server_addr") || "127.0.0.1:8080" + ); + const [gpuLayers, setGpuLayers] = useState( + () => parseInt(localStorage.getItem("forgelm_gpu_layers") || "0", 10) + ); + const [expanded, setExpanded] = useState(false); + + const isActive = status === "running" || status === "starting" || status === "stopping"; + + const handleStart = () => { + localStorage.setItem("forgelm_model_path", modelPath); + localStorage.setItem("forgelm_server_addr", addr); + localStorage.setItem("forgelm_gpu_layers", String(gpuLayers)); + onStart({ modelPath, addr, gpuLayers }); + }; + + return ( +
+
setExpanded(!expanded)}> +
+ + Local Server + + {STATUS_LABEL[status]} + +
+ {expanded ? "▲" : "▼"} +
+ + {expanded && ( +
+ + setModelPath(e.target.value)} + placeholder="models/your-model.gguf" + disabled={isActive} + /> + + + setAddr(e.target.value)} + placeholder="127.0.0.1:8080" + disabled={isActive} + /> + + + setGpuLayers(parseInt(e.target.value, 10) || 0)} + disabled={isActive} + /> + +
+ {status === "stopped" || status === "error" ? ( + + ) : ( + + )} +
+ + {message && ( +
+ {message} +
+ )} +
+ )} +
+ ); +} diff --git a/tauri-app/src/components/Sidebar.tsx b/tauri-app/src/components/Sidebar.tsx index 5f86f05..b5f564d 100644 --- a/tauri-app/src/components/Sidebar.tsx +++ b/tauri-app/src/components/Sidebar.tsx @@ -1,5 +1,7 @@ import { useState } from "react"; import type { Chat, ModelInfo, ServerStatus } from "../lib/types"; +import type { ProcessStatus, ServerConfig } from "../hooks/useServerProcess"; +import ServerPanel from "./ServerPanel"; import "./Sidebar.css"; interface Props { @@ -17,12 +19,19 @@ interface Props { onModelChange: (v: string) => void; status: ServerStatus; onRefreshModels: () => void; + serverProcess: { + status: ProcessStatus; + message: string; + start: (cfg: ServerConfig) => void; + stop: () => void; + }; } export default function Sidebar({ chats, currentChatId, onSelectChat, onNewChat, onDeleteChat, server, onServerChange, apiKey, onApiKeyChange, models, selectedModel, onModelChange, status, onRefreshModels, + serverProcess, }: Props) { const [showSettings, setShowSettings] = useState(false); @@ -39,6 +48,14 @@ export default function Sidebar({ + {/* Server process control */} + + {/* Settings panel */} {showSettings && (
diff --git a/tauri-app/src/hooks/useServerProcess.ts b/tauri-app/src/hooks/useServerProcess.ts new file mode 100644 index 0000000..4e32ab9 --- /dev/null +++ b/tauri-app/src/hooks/useServerProcess.ts @@ -0,0 +1,64 @@ +import { useState, useEffect, useCallback } from "react"; +import { invoke } from "@tauri-apps/api/core"; + +export type ProcessStatus = "stopped" | "starting" | "running" | "stopping" | "error"; + +export interface ServerConfig { + modelPath: string; + addr: string; + gpuLayers: number; +} + +export function useServerProcess() { + const [status, setStatus] = useState("stopped"); + const [message, setMessage] = useState(""); + + // Poll actual process status every 3s + useEffect(() => { + const poll = async () => { + try { + const s = await invoke("server_status"); + setStatus((prev) => { + if (prev === "starting" || prev === "stopping") return prev; + return s === "running" ? "running" : "stopped"; + }); + } catch { + // not in Tauri context (browser dev) + } + }; + poll(); + const id = setInterval(poll, 3000); + return () => clearInterval(id); + }, []); + + const start = useCallback(async (cfg: ServerConfig) => { + setStatus("starting"); + setMessage(""); + try { + const msg = await invoke("start_server", { + modelPath: cfg.modelPath, + addr: cfg.addr, + gpuLayers: cfg.gpuLayers, + }); + setStatus("running"); + setMessage(msg); + } catch (e) { + setStatus("error"); + setMessage(String(e)); + } + }, []); + + const stop = useCallback(async () => { + setStatus("stopping"); + try { + const msg = await invoke("stop_server"); + setStatus("stopped"); + setMessage(msg); + } catch (e) { + setStatus("error"); + setMessage(String(e)); + } + }, []); + + return { status, message, start, stop }; +}