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)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_shell::init())
|
.plugin(tauri_plugin_shell::init())
|
||||||
|
.manage(ServerState(Mutex::new(None)))
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
start_server,
|
||||||
|
stop_server,
|
||||||
|
server_status
|
||||||
|
])
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -29,7 +29,8 @@
|
|||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": "all",
|
"targets": "all",
|
||||||
"icon": []
|
"icon": [],
|
||||||
|
"resources": ["../../../target/release/localllm.exe", "../../../target/release/localllm"]
|
||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"shell": {
|
"shell": {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import Sidebar from "./components/Sidebar";
|
|||||||
import ChatView from "./components/ChatView";
|
import ChatView from "./components/ChatView";
|
||||||
import { useDB } from "./hooks/useDB";
|
import { useDB } from "./hooks/useDB";
|
||||||
import { useModels } from "./hooks/useModels";
|
import { useModels } from "./hooks/useModels";
|
||||||
|
import { useServerProcess } from "./hooks/useServerProcess";
|
||||||
import type { Chat } from "./lib/types";
|
import type { Chat } from "./lib/types";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
|
|
||||||
@@ -14,11 +15,19 @@ export default function App() {
|
|||||||
const [apiKey, setApiKey] = useState(() => localStorage.getItem("forgelm_apikey") || "");
|
const [apiKey, setApiKey] = useState(() => localStorage.getItem("forgelm_apikey") || "");
|
||||||
|
|
||||||
const { models, selectedModel, setSelectedModel, status, refresh: refreshModels } = useModels(server, apiKey);
|
const { models, selectedModel, setSelectedModel, status, refresh: refreshModels } = useModels(server, apiKey);
|
||||||
|
const serverProcess = useServerProcess();
|
||||||
|
|
||||||
// Persist server/key
|
// Persist server/key
|
||||||
useEffect(() => { localStorage.setItem("forgelm_server", server); }, [server]);
|
useEffect(() => { localStorage.setItem("forgelm_server", server); }, [server]);
|
||||||
useEffect(() => { localStorage.setItem("forgelm_apikey", apiKey); }, [apiKey]);
|
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(() => {
|
const loadChats = useCallback(() => {
|
||||||
if (!db) return;
|
if (!db) return;
|
||||||
const rows = db.query<Chat>("SELECT * FROM chats ORDER BY updated_at DESC");
|
const rows = db.query<Chat>("SELECT * FROM chats ORDER BY updated_at DESC");
|
||||||
@@ -70,6 +79,7 @@ export default function App() {
|
|||||||
onModelChange={setSelectedModel}
|
onModelChange={setSelectedModel}
|
||||||
status={status}
|
status={status}
|
||||||
onRefreshModels={refreshModels}
|
onRefreshModels={refreshModels}
|
||||||
|
serverProcess={serverProcess}
|
||||||
/>
|
/>
|
||||||
<ChatView
|
<ChatView
|
||||||
db={db!}
|
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 { useState } from "react";
|
||||||
import type { Chat, ModelInfo, ServerStatus } from "../lib/types";
|
import type { Chat, ModelInfo, ServerStatus } from "../lib/types";
|
||||||
|
import type { ProcessStatus, ServerConfig } from "../hooks/useServerProcess";
|
||||||
|
import ServerPanel from "./ServerPanel";
|
||||||
import "./Sidebar.css";
|
import "./Sidebar.css";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -17,12 +19,19 @@ interface Props {
|
|||||||
onModelChange: (v: string) => void;
|
onModelChange: (v: string) => void;
|
||||||
status: ServerStatus;
|
status: ServerStatus;
|
||||||
onRefreshModels: () => void;
|
onRefreshModels: () => void;
|
||||||
|
serverProcess: {
|
||||||
|
status: ProcessStatus;
|
||||||
|
message: string;
|
||||||
|
start: (cfg: ServerConfig) => void;
|
||||||
|
stop: () => void;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Sidebar({
|
export default function Sidebar({
|
||||||
chats, currentChatId, onSelectChat, onNewChat, onDeleteChat,
|
chats, currentChatId, onSelectChat, onNewChat, onDeleteChat,
|
||||||
server, onServerChange, apiKey, onApiKeyChange,
|
server, onServerChange, apiKey, onApiKeyChange,
|
||||||
models, selectedModel, onModelChange, status, onRefreshModels,
|
models, selectedModel, onModelChange, status, onRefreshModels,
|
||||||
|
serverProcess,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
|
|
||||||
@@ -39,6 +48,14 @@ export default function Sidebar({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Server process control */}
|
||||||
|
<ServerPanel
|
||||||
|
status={serverProcess.status}
|
||||||
|
message={serverProcess.message}
|
||||||
|
onStart={serverProcess.start}
|
||||||
|
onStop={serverProcess.stop}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Settings panel */}
|
{/* Settings panel */}
|
||||||
{showSettings && (
|
{showSettings && (
|
||||||
<div className="settings-panel">
|
<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