feat: add server process control panel to Tauri app

This commit is contained in:
Evandro Fonseca Junior committed 2026-03-26 21:16:35 -03:00
1 parent 818d9f99e6
commit 99672b33d0
7 files changed
+437 -2

No files matched your search

+71 -1
View File
@@ -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<Option<Child>>);
#[tauri::command]
fn start_server(
state: State<ServerState>,
model_path: String,
addr: String,
gpu_layers: i32,
) -> Result<String, String> {
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<ServerState>) -> Result<String, String> {
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<ServerState>) -> 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)]
{
+2 -1
View File
@@ -29,7 +29,8 @@
"bundle": {
"active": true,
"targets": "all",
"icon": []
"icon": [],
"resources": ["../../../target/release/localllm.exe", "../../../target/release/localllm"]
},
"plugins": {
"shell": {
+10
View File
@@ -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<Chat>("SELECT * FROM chats ORDER BY updated_at DESC");
@@ -70,6 +79,7 @@ export default function App() {
onModelChange={setSelectedModel}
status={status}
onRefreshModels={refreshModels}
serverProcess={serverProcess}
/>
<ChatView
db={db!}
+146
View File
@@ -0,0 +1,146 @@
.server-panel {
border: 1px solid var(--border);
border-radius: 8px;
margin: 8px 12px;
overflow: hidden;
background: var(--surface);
}
.server-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
cursor: pointer;
user-select: none;
transition: background 0.15s;
}
.server-panel-header:hover {
background: var(--surface-hover, rgba(255,255,255,0.04));
}
.server-panel-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.server-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
transition: background 0.3s;
}
.server-panel-label {
color: var(--text-secondary, #9ca3af);
font-weight: 500;
}
.server-status-text {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
}
.server-panel-chevron {
font-size: 10px;
color: var(--text-secondary, #9ca3af);
}
.server-panel-body {
padding: 4px 12px 12px;
display: flex;
flex-direction: column;
gap: 4px;
border-top: 1px solid var(--border);
}
.sp-label {
font-size: 11px;
color: var(--text-secondary, #9ca3af);
margin-top: 6px;
}
.sp-input {
background: var(--input-bg, #1a1a1e);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text, #e5e7eb);
font-size: 12px;
padding: 6px 8px;
outline: none;
width: 100%;
box-sizing: border-box;
transition: border-color 0.15s;
}
.sp-input:focus {
border-color: var(--accent, #5b6af0);
}
.sp-input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.sp-input-num {
width: 80px;
}
.sp-actions {
margin-top: 8px;
}
.sp-btn {
width: 100%;
padding: 8px;
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s, transform 0.1s;
}
.sp-btn:active {
transform: scale(0.98);
}
.sp-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.sp-btn-start {
background: var(--accent, #5b6af0);
color: #fff;
}
.sp-btn-start:hover:not(:disabled) {
opacity: 0.88;
}
.sp-btn-stop {
background: #ef4444;
color: #fff;
}
.sp-btn-stop:hover:not(:disabled) {
opacity: 0.88;
}
.sp-message {
font-size: 11px;
color: #22c55e;
margin-top: 6px;
word-break: break-all;
line-height: 1.4;
}
.sp-message-error {
color: #ef4444;
}
+127
View File
@@ -0,0 +1,127 @@
import { useState } from "react";
import type { ProcessStatus, ServerConfig } from "../hooks/useServerProcess";
import "./ServerPanel.css";
interface Props {
status: ProcessStatus;
message: string;
onStart: (cfg: ServerConfig) => void;
onStop: () => void;
}
const STATUS_LABEL: Record<ProcessStatus, string> = {
stopped: "Stopped",
starting: "Starting…",
running: "Running",
stopping: "Stopping…",
error: "Error",
};
const STATUS_COLOR: Record<ProcessStatus, string> = {
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 (
<div className="server-panel">
<div className="server-panel-header" onClick={() => setExpanded(!expanded)}>
<div className="server-panel-title">
<span
className="server-status-dot"
style={{ background: STATUS_COLOR[status] }}
/>
<span className="server-panel-label">Local Server</span>
<span className="server-status-text" style={{ color: STATUS_COLOR[status] }}>
{STATUS_LABEL[status]}
</span>
</div>
<span className="server-panel-chevron">{expanded ? "▲" : "▼"}</span>
</div>
{expanded && (
<div className="server-panel-body">
<label className="sp-label">Model path (.gguf)</label>
<input
className="sp-input"
type="text"
value={modelPath}
onChange={(e) => setModelPath(e.target.value)}
placeholder="models/your-model.gguf"
disabled={isActive}
/>
<label className="sp-label">Address</label>
<input
className="sp-input"
type="text"
value={addr}
onChange={(e) => setAddr(e.target.value)}
placeholder="127.0.0.1:8080"
disabled={isActive}
/>
<label className="sp-label">GPU Layers (0 = CPU only)</label>
<input
className="sp-input sp-input-num"
type="number"
min={0}
max={999}
value={gpuLayers}
onChange={(e) => setGpuLayers(parseInt(e.target.value, 10) || 0)}
disabled={isActive}
/>
<div className="sp-actions">
{status === "stopped" || status === "error" ? (
<button
className="sp-btn sp-btn-start"
onClick={handleStart}
disabled={!modelPath.trim()}
>
▶ Start Server
</button>
) : (
<button
className="sp-btn sp-btn-stop"
onClick={onStop}
disabled={status === "starting" || status === "stopping"}
>
■ Stop Server
</button>
)}
</div>
{message && (
<div className={`sp-message ${status === "error" ? "sp-message-error" : ""}`}>
{message}
</div>
)}
</div>
)}
</div>
);
}
+17
View File
@@ -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({
</button>
</div>
{/* Server process control */}
<ServerPanel
status={serverProcess.status}
message={serverProcess.message}
onStart={serverProcess.start}
onStop={serverProcess.stop}
/>
{/* Settings panel */}
{showSettings && (
<div className="settings-panel">
+64
View File
@@ -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<ProcessStatus>("stopped");
const [message, setMessage] = useState<string>("");
// Poll actual process status every 3s
useEffect(() => {
const poll = async () => {
try {
const s = await invoke<string>("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<string>("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<string>("stop_server");
setStatus("stopped");
setMessage(msg);
} catch (e) {
setStatus("error");
setMessage(String(e));
}
}, []);
return { status, message, start, stop };
}