feat: add server process control panel to Tauri app
This commit is contained in:
1 parent
818d9f99e6
commit
99672b33d0
7 files changed
+437
-2
No files matched your search
@@ -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)]
|
||||
{
|
||||
|
||||
@@ -29,7 +29,8 @@
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": []
|
||||
"icon": [],
|
||||
"resources": ["../../../target/release/localllm.exe", "../../../target/release/localllm"]
|
||||
},
|
||||
"plugins": {
|
||||
"shell": {
|
||||
|
||||
@@ -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!}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user