Add Inference Mesh settings tab (#8094)

Signed-off-by: Michael Neale <michael.neale@gmail.com>
Signed-off-by: jh-block <jhugo@block.xyz>
Co-authored-by: jh-block <jhugo@block.xyz>
This commit is contained in:
Michael Neale
2026-03-31 22:03:18 +11:00
committed by GitHub
parent bc296816b1
commit 4deebf7550
5 changed files with 983 additions and 6 deletions
@@ -9,7 +9,16 @@ import ConfigSettings from './config/ConfigSettings';
import PromptsSettingsSection from './PromptsSettingsSection';
import { ExtensionConfig } from '../../api';
import { MainPanelLayout } from '../Layout/MainPanelLayout';
import { Bot, Share2, Monitor, MessageSquare, FileText, Keyboard, HardDrive } from 'lucide-react';
import {
Bot,
Share2,
Monitor,
MessageSquare,
FileText,
Keyboard,
HardDrive,
Network,
} from 'lucide-react';
import { useState, useEffect, useRef } from 'react';
import TunnelSection from './tunnel/TunnelSection';
import GatewaySettingsSection from './gateways/GatewaySettingsSection';
@@ -17,6 +26,7 @@ import { getTunnelStatus } from '../../api/sdk.gen';
import ChatSettingsSection from './chat/ChatSettingsSection';
import KeyboardShortcutsSection from './keyboard/KeyboardShortcutsSection';
import LocalInferenceSection from './localInference/LocalInferenceSection';
import MeshSection from './mesh/MeshSection';
import { CONFIGURATION_ENABLED } from '../../updates';
import { trackSettingsTabViewed } from '../../utils/analytics';
import { useFeatures } from '../../contexts/FeaturesContext';
@@ -101,21 +111,29 @@ export default function SettingsView({
keyboard: 'keyboard',
gateway: 'sharing',
'local-inference': 'local-inference',
mesh: 'mesh',
};
const targetTab = sectionToTab[viewOptions.section];
if (targetTab && (targetTab !== 'local-inference' || localInference)) {
if (
targetTab &&
(targetTab !== 'local-inference' || localInference) &&
(targetTab !== 'mesh' || !tunnelDisabled)
) {
setActiveTab(targetTab);
}
}
}, [viewOptions.section, localInference]);
}, [viewOptions.section, localInference, tunnelDisabled]);
// Reset active tab if local-inference becomes unavailable
// Reset active tab if local-inference or mesh becomes unavailable
useEffect(() => {
if (!localInference && activeTab === 'local-inference') {
setActiveTab('models');
}
}, [localInference, activeTab]);
if (tunnelDisabled && activeTab === 'mesh') {
setActiveTab('models');
}
}, [localInference, tunnelDisabled, activeTab]);
useEffect(() => {
if (!hasTrackedInitialTab.current) {
@@ -186,6 +204,16 @@ export default function SettingsView({
{intl.formatMessage(i18n.tabLocalInference)}
</TabsTrigger>
)}
{!tunnelDisabled && (
<TabsTrigger
value="mesh"
className="flex gap-2"
data-testid="settings-mesh-tab"
>
<Network className="h-4 w-4" />
Mesh
</TabsTrigger>
)}
<TabsTrigger value="chat" className="flex gap-2" data-testid="settings-chat-tab">
<MessageSquare className="h-4 w-4" />
{intl.formatMessage(i18n.tabChat)}
@@ -238,6 +266,15 @@ export default function SettingsView({
</TabsContent>
)}
{!tunnelDisabled && (
<TabsContent
value="mesh"
className="mt-0 focus-visible:outline-none focus-visible:ring-0"
>
<MeshSection />
</TabsContent>
)}
<TabsContent
value="chat"
className="mt-0 focus-visible:outline-none focus-visible:ring-0"
@@ -0,0 +1,9 @@
import { MeshSettings } from './MeshSettings';
export default function MeshSection() {
return (
<section id="mesh" className="space-y-4 pr-4 pb-8">
<MeshSettings />
</section>
);
}
@@ -0,0 +1,670 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import {
RefreshCw,
ExternalLink,
Zap,
Play,
Square,
Copy,
Check,
ChevronDown,
ChevronRight,
Download,
} from 'lucide-react';
import { Button } from '../../ui/button';
import { Input } from '../../ui/input';
import { setConfigProvider, updateCustomProvider, createCustomProvider, getCustomProvider } from '../../../api';
import { useModelAndProvider } from '../../ModelAndProviderContext';
const MESH_API_PORT = 9337;
const MESH_CONSOLE_PORT = 3131;
const MESH_DEFAULT_MODEL = 'Qwen3-30B-A3B-Q4_K_M';
// Popular models from mesh-llm catalog, grouped by size
const MODEL_CATALOG = [
{ name: 'Qwen3-4B-Q4_K_M', size: '~3 GB', tier: 'small' },
{ name: 'Qwen3-8B-Q4_K_M', size: '~5 GB', tier: 'small' },
{ name: 'Qwen3-14B-Q4_K_M', size: '~9 GB', tier: 'medium' },
{ name: 'Devstral-Small-2505-Q4_K_M', size: '~14 GB', tier: 'medium' },
{ name: 'Qwen3-30B-A3B-Q4_K_M', size: '~17 GB', tier: 'large' },
{ name: 'GLM-4.7-Flash-Q4_K_M', size: '~17 GB', tier: 'large' },
{ name: 'Qwen3-32B-Q4_K_M', size: '~20 GB', tier: 'large' },
{ name: 'Qwen2.5-Coder-32B-Instruct-Q4_K_M', size: '~20 GB', tier: 'large' },
{ name: 'Qwen2.5-72B-Instruct-Q4_K_M', size: '~42 GB', tier: 'xlarge' },
];
type MeshMode = 'new' | 'join' | 'auto';
type MeshStatus = 'unknown' | 'running' | 'stopped' | 'starting' | 'not-installed' | 'downloading';
interface MeshStatusInfo {
running: boolean;
installed: boolean;
models: string[];
token?: string;
peerCount?: number;
nodeStatus?: string;
binaryPath?: string;
}
export const MeshSettings = () => {
const { refreshCurrentModelAndProvider } = useModelAndProvider();
const canAutoDownload = window.electron.platform === 'darwin' && window.electron.arch === 'arm64';
const [status, setStatus] = useState<MeshStatus>('unknown');
const [statusInfo, setStatusInfo] = useState<MeshStatusInfo>({
running: false,
installed: true,
models: [],
});
const [mode, setMode] = useState<MeshMode>('auto');
const [selectedModel, setSelectedModel] = useState(MESH_DEFAULT_MODEL);
const [joinToken, setJoinToken] = useState('');
const [contributeGpu, setContributeGpu] = useState(true);
const [copiedToken, setCopiedToken] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activeModel, setActiveModel] = useState<string | null>(null);
const [meshProviderId, setMeshProviderIdState] = useState<string>(
() => localStorage.getItem('mesh-provider-id') ?? 'mesh'
);
const setMeshProviderId = (id: string) => {
setMeshProviderIdState(id);
localStorage.setItem('mesh-provider-id', id);
};
const [checking, setChecking] = useState(false);
const startTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const checkStatus = useCallback(async () => {
setChecking(true);
try {
const result = await window.electron.checkMesh();
if (result.running) {
setStatus('running');
setStatusInfo(result);
} else if (!result.installed) {
setStatus((prev) => (prev === 'downloading' ? prev : 'not-installed'));
setStatusInfo({ running: false, installed: false, models: [] });
} else {
setStatus((prev) => (prev === 'starting' || prev === 'downloading' ? prev : 'stopped'));
setStatusInfo({ ...result, models: [] });
}
} catch {
setStatus((prev) => (prev === 'starting' || prev === 'downloading' ? prev : 'stopped'));
} finally {
setChecking(false);
}
}, []);
useEffect(() => {
checkStatus();
const interval = setInterval(checkStatus, status === 'starting' ? 3000 : 10000);
return () => clearInterval(interval);
}, [checkStatus, status]);
const meshProviderBody = (models: string[]) => ({
engine: 'openai_compatible' as const,
display_name: 'Inference Mesh',
api_url: `http://localhost:${MESH_API_PORT}`,
api_key: '',
models,
supports_streaming: true,
requires_auth: false,
});
// Create or update the mesh custom provider via the REST API,
// which handles file writes and registry refresh atomically.
// Returns the provider ID to use with setConfigProvider.
const ensureMeshProvider = async (models: string[]): Promise<string> => {
const modelList = models.length > 0 ? models : [MESH_DEFAULT_MODEL];
const body = meshProviderBody(modelList);
// Try the last-known provider ID first, then fall back to 'mesh'
const idsToTry = meshProviderId === 'mesh'
? ['mesh']
: [meshProviderId, 'mesh'];
for (const id of idsToTry) {
const existing = await getCustomProvider({ path: { id } });
if (existing.data) {
await updateCustomProvider({
path: { id },
body,
throwOnError: true,
});
setMeshProviderId(id);
return id;
}
}
// Provider doesn't exist yet — create it
const result = await createCustomProvider({
body,
throwOnError: true,
});
const newId = result.data?.provider_name ?? 'mesh';
setMeshProviderId(newId);
return newId;
};
const activateModel = async (modelId: string) => {
setSaving(true);
setError(null);
try {
const providerId = await ensureMeshProvider(statusInfo.models);
await setConfigProvider({
body: { provider: providerId, model: modelId },
throwOnError: true,
});
await refreshCurrentModelAndProvider();
setActiveModel(modelId);
} catch (err) {
setError(`Failed to activate model: ${err}`);
} finally {
setSaving(false);
}
};
const startMesh = async () => {
setError(null);
setStatus('starting');
try {
const args: string[] = [];
if (mode === 'new') {
args.push('--model', selectedModel);
} else if (mode === 'join') {
if (!joinToken.trim()) {
setError('Paste an invite token to join a mesh');
setStatus('stopped');
return;
}
args.push('--join', joinToken.trim());
if (!contributeGpu) {
args.push('--client');
}
} else {
// auto
args.push('--auto');
if (!contributeGpu) {
args.push('--client');
}
}
const result = await window.electron.startMesh(args);
if (!result.started) {
setError(result.error || 'Failed to start mesh-llm');
setStatus('stopped');
return;
}
// Polling will pick up when it's ready. Timeout after 5 min so
// the UI doesn't get stuck in "starting" if the daemon crashes.
if (startTimeoutRef.current) {
clearTimeout(startTimeoutRef.current);
}
startTimeoutRef.current = setTimeout(() => {
startTimeoutRef.current = null;
setStatus((prev) => {
if (prev === 'starting') {
setError('mesh-llm did not become ready. Check ~/.mesh-llm/mesh-llm.log');
return 'stopped';
}
return prev;
});
}, 300000);
} catch (err) {
setError(`Failed to start: ${err}`);
setStatus('stopped');
}
};
const stopMesh = async () => {
try {
const result = await window.electron.stopMesh();
if (result.stopped) {
setStatus('stopped');
setStatusInfo((prev) => ({ ...prev, running: false, models: [], token: undefined }));
} else {
setError('Failed to stop mesh-llm');
}
} catch {
setError('Failed to stop mesh-llm');
}
};
const downloadMesh = async () => {
setError(null);
setStatus('downloading');
try {
const result = await window.electron.downloadMesh();
if (result.downloaded) {
setStatusInfo((prev) => ({
...prev,
installed: true,
binaryPath: result.binaryPath,
}));
setStatus('stopped');
} else {
setError(result.error || 'Download failed');
setStatus('not-installed');
}
} catch (err) {
setError(`Download failed: ${err}`);
setStatus('not-installed');
}
};
const copyToken = () => {
if (statusInfo.token) {
navigator.clipboard.writeText(statusInfo.token);
setCopiedToken(true);
setTimeout(() => setCopiedToken(false), 2000);
}
};
const StatusIndicator = () => {
switch (status) {
case 'running':
return (
<span className="flex items-center gap-1.5 text-xs text-green-500">
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
Running {statusInfo.models.length} model
{statusInfo.models.length !== 1 ? 's' : ''} available
{statusInfo.peerCount !== undefined && statusInfo.peerCount > 0 && (
<span className="text-text-muted ml-1">
· {statusInfo.peerCount} peer{statusInfo.peerCount !== 1 ? 's' : ''}
</span>
)}
</span>
);
case 'starting':
return (
<span className="flex items-center gap-1.5 text-xs text-yellow-500">
<RefreshCw className="w-3 h-3 animate-spin" />
Starting this may take a minute if downloading a model...
</span>
);
case 'downloading':
return (
<span className="flex items-center gap-1.5 text-xs text-yellow-500">
<RefreshCw className="w-3 h-3 animate-spin" />
Downloading mesh-llm (~19 MB)...
</span>
);
case 'not-installed':
return (
<span className="flex items-center gap-1.5 text-xs text-text-muted">
<span className="w-2 h-2 rounded-full bg-orange-400" />
mesh-llm not installed
</span>
);
case 'stopped':
return (
<span className="flex items-center gap-1.5 text-xs text-text-muted">
{checking ? (
<RefreshCw className="w-3 h-3 animate-spin" />
) : (
<span className="w-2 h-2 rounded-full bg-gray-400" />
)}
Not running
</span>
);
default:
return checking ? (
<span className="flex items-center gap-1.5 text-xs text-text-muted">
<RefreshCw className="w-3 h-3 animate-spin" />
Checking...
</span>
) : null;
}
};
return (
<div className="space-y-6">
{/* Header */}
<div>
<div className="flex items-center justify-between">
<h3 className="text-text-default font-medium">Inference Mesh</h3>
<a
href="https://docs.anarchai.org/"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center text-xs text-text-muted hover:text-text-default transition-colors"
>
<ExternalLink className="w-3 h-3 mr-1" />
Learn more
</a>
</div>
<p className="text-xs text-text-muted max-w-2xl mt-1">
<span className="text-orange-400 font-medium">Experimental.</span> Pool GPUs with others
for decentralized LLM inference no API keys, no cloud. Start a private mesh, join one
with an invite token, or discover public meshes.{' '}
<a
href="https://docs.anarchai.org/"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-text-default"
>
docs.anarchai.org
</a>
</p>
<div className="mt-2">
<StatusIndicator />
</div>
{error && <p className="text-xs text-red-400 mt-1">{error}</p>}
</div>
{/* Not installed — offer download or install link */}
{status === 'not-installed' && (
<div className="border border-border-subtle rounded-xl p-4 bg-background-default">
<p className="text-sm font-medium text-text-default">Get started</p>
<p className="text-xs text-text-muted mt-1">
mesh-llm is a small download (~19 MB) that manages local inference and mesh networking.
Models are downloaded separately when you start a mesh.
</p>
<div className="flex items-center gap-2 mt-3">
{canAutoDownload && (
<Button size="sm" onClick={downloadMesh}>
<Download className="w-3 h-3 mr-1" />
Download mesh-llm
</Button>
)}
<a href="https://docs.anarchai.org/" target="_blank" rel="noopener noreferrer">
<Button variant="outline" size="sm">
<ExternalLink className="w-3 h-3 mr-1" />
Install guide
</Button>
</a>
<Button variant="ghost" size="sm" onClick={checkStatus}>
<RefreshCw className="w-3 h-3 mr-1" />
Check Again
</Button>
</div>
</div>
)}
{/* Downloading */}
{status === 'downloading' && (
<div className="border border-yellow-500/30 rounded-xl p-4 bg-yellow-500/5">
<p className="text-sm font-medium text-text-default">Downloading mesh-llm...</p>
<p className="text-xs text-text-muted mt-1">
Downloading and installing to ~/.mesh-llm/. This should only take a moment.
</p>
</div>
)}
{/* Setup panel — shown when stopped and installed */}
{(status === 'stopped' || status === 'unknown') && (
<div className="border border-border-subtle rounded-xl p-4 bg-background-default space-y-4">
{/* Mode selector */}
<div className="space-y-3">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="mesh-mode"
checked={mode === 'auto'}
onChange={() => setMode('auto')}
/>
<div>
<span className="text-sm font-medium text-text-default">
Auto-discover a public mesh
</span>
<p className="text-xs text-text-muted">
Find and join the best available mesh automatically.
</p>
<p className="text-xs text-orange-400 mt-0.5">
Public meshes are run by volunteers. Your prompts are sent to their hardware no
privacy guarantees.
</p>
</div>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="mesh-mode"
checked={mode === 'join'}
onChange={() => setMode('join')}
/>
<div>
<span className="text-sm font-medium text-text-default">
Join with invite token
</span>
<p className="text-xs text-text-muted">
Join a private mesh someone shared with you.
</p>
</div>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="mesh-mode"
checked={mode === 'new'}
onChange={() => setMode('new')}
/>
<div>
<span className="text-sm font-medium text-text-default">
Start a new private mesh
</span>
<p className="text-xs text-text-muted">
Create your own mesh. Share the invite token with others to pool GPUs.
</p>
</div>
</label>
</div>
{/* Mode-specific options */}
{mode === 'new' && (
<div className="pl-6 space-y-2">
<label className="text-xs text-text-default block">Model to serve</label>
<select
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
className="text-sm bg-background-default border border-border-subtle rounded px-2 py-1.5 text-text-default w-full max-w-sm"
>
{MODEL_CATALOG.map((m) => (
<option key={m.name} value={m.name}>
{m.name} ({m.size})
</option>
))}
</select>
<p className="text-xs text-text-muted">
Downloads automatically if not already cached. Larger models need more VRAM.
</p>
</div>
)}
{mode === 'join' && (
<div className="pl-6 space-y-2">
<label className="text-xs text-text-default block">Invite token</label>
<Input
type="text"
value={joinToken}
onChange={(e) => setJoinToken(e.target.value)}
placeholder="Paste invite token here"
className="max-w-md"
/>
</div>
)}
{(mode === 'auto' || mode === 'join') && (
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={contributeGpu}
onChange={(e) => setContributeGpu(e.target.checked)}
/>
<span className="text-sm text-text-default">
Contribute GPU
<span className="text-text-muted ml-1">(serve models for others too)</span>
</span>
</label>
)}
<Button onClick={startMesh} disabled={checking} size="sm">
<Play className="w-3 h-3 mr-1" />
Start Mesh
</Button>
</div>
)}
{/* Starting indicator */}
{status === 'starting' && (
<div className="border border-yellow-500/30 rounded-xl p-4 bg-yellow-500/5">
<p className="text-sm font-medium text-text-default">Starting mesh-llm...</p>
<p className="text-xs text-text-muted mt-1">
Connecting to the mesh and loading models. This may take a minute on first run.
</p>
</div>
)}
{/* Running state */}
{status === 'running' && (
<>
{/* Invite token */}
{statusInfo.token && (
<div className="border border-border-subtle rounded-xl p-4 bg-background-default">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-text-default">Invite token</p>
<p className="text-xs text-text-muted mt-0.5">
Share this with others so they can join your mesh.
</p>
</div>
<Button variant="outline" size="sm" onClick={copyToken}>
{copiedToken ? (
<>
<Check className="w-3 h-3 mr-1" />
Copied
</>
) : (
<>
<Copy className="w-3 h-3 mr-1" />
Copy
</>
)}
</Button>
</div>
<code className="block text-xs bg-background-default border border-border-subtle rounded p-2 mt-2 text-text-muted break-all select-all max-h-16 overflow-auto">
{statusInfo.token}
</code>
</div>
)}
{/* Model list */}
{statusInfo.models.length > 0 && (
<div>
<h4 className="text-sm font-medium text-text-default mb-2">Available Models</h4>
<p className="text-xs text-text-muted mb-3">
Select a model to use it as your Goose provider.
</p>
<div className="space-y-2">
{statusInfo.models.map((modelId) => {
const isActive = activeModel === modelId;
return (
<div
key={modelId}
className={`border rounded-lg p-3 transition-colors cursor-pointer ${
isActive
? 'border-green-500/50 bg-green-500/5'
: 'border-border-subtle bg-background-default hover:border-border-default'
}`}
onClick={() => !saving && activateModel(modelId)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-text-default">{modelId}</span>
<span className="text-xs text-green-500">live</span>
</div>
{isActive ? (
<span className="text-xs font-medium text-green-500">Active</span>
) : (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
activateModel(modelId);
}}
disabled={saving}
>
<Zap className="w-3 h-3 mr-1" />
Use
</Button>
)}
</div>
</div>
);
})}
</div>
</div>
)}
{statusInfo.models.length === 0 && (
<p className="text-xs text-text-muted">
Mesh is running but no models are available yet. A model may still be loading.
</p>
)}
{/* Actions row */}
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={stopMesh}>
<Square className="w-3 h-3 mr-1" />
Stop Mesh
</Button>
<a
href={`http://localhost:${MESH_CONSOLE_PORT}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center text-xs text-text-muted hover:text-text-default transition-colors px-2 py-1"
>
<ExternalLink className="w-3 h-3 mr-1" />
Open Console
</a>
</div>
</>
)}
{/* Advanced settings */}
<div className="border-t border-border-subtle pt-4">
<button
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex items-center gap-1 text-sm text-text-muted hover:text-text-default transition-colors"
>
{showAdvanced ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)}
Advanced
</button>
{showAdvanced && (
<div className="mt-3 space-y-3">
{statusInfo.binaryPath && (
<div>
<label className="text-xs text-text-muted block">Binary</label>
<code className="text-xs text-text-default">{statusInfo.binaryPath}</code>
</div>
)}
<div>
<label className="text-xs text-text-muted block">API endpoint</label>
<code className="text-xs text-text-default">http://localhost:{MESH_API_PORT}/v1</code>
</div>
<div>
<label className="text-xs text-text-muted block">Console</label>
<code className="text-xs text-text-default">http://localhost:{MESH_CONSOLE_PORT}</code>
</div>
</div>
)}
</div>
{/* Refresh */}
<div className="flex justify-end">
<Button variant="ghost" size="sm" onClick={checkStatus}>
<RefreshCw className="w-3 h-3 mr-1" />
Refresh
</Button>
</div>
</div>
);
};
+244 -1
View File
@@ -23,7 +23,8 @@ import fsSync from 'node:fs';
import started from 'electron-squirrel-startup';
import path from 'node:path';
import os from 'node:os';
import { spawn } from 'child_process';
import { execFile, execFileSync, execSync, spawn } from 'child_process';
import http from 'node:http';
import 'dotenv/config';
import { checkServerStatus } from './goosed';
import { startGoosed } from './goosed';
@@ -1564,6 +1565,248 @@ ipcMain.handle('select-file-or-directory', async (_event, defaultPath?: string)
return null;
});
// ── Mesh-LLM lifecycle ──────────────────────────────────────────────
const MESH_API_PORT = 9337;
const MESH_CONSOLE_PORT = 3131;
const MESH_DOWNLOAD_URL =
'https://github.com/michaelneale/mesh-llm/releases/latest/download/mesh-bundle.tar.gz';
async function findMeshBinary(): Promise<string | null> {
// 1. PATH
try {
const binPath = execSync('which mesh-llm 2>/dev/null || echo ""', { encoding: 'utf8' }).trim();
if (binPath) return binPath;
} catch {
// ignore
}
// 2. ~/.mesh-llm/ (our download location)
const meshDir = path.join(os.homedir(), '.mesh-llm', 'mesh-llm');
if (fsSync.existsSync(meshDir)) return meshDir;
// 3. ~/.local/bin/
const localBin = path.join(os.homedir(), '.local', 'bin', 'mesh-llm');
if (fsSync.existsSync(localBin)) return localBin;
return null;
}
ipcMain.handle('check-mesh', async () => {
const result: {
running: boolean;
installed: boolean;
models: string[];
token?: string;
peerCount?: number;
nodeStatus?: string;
binaryPath?: string;
} = { running: false, installed: true, models: [] };
// Check if mesh-llm binary exists
const binary = await findMeshBinary();
if (binary) {
result.binaryPath = binary;
} else {
result.installed = false;
// Still probe the API — maybe it's running from somewhere unexpected
}
// Probe the API
try {
const modelsData: { running: boolean; models: string[] } = await new Promise((resolve) => {
const req = http.get(`http://localhost:${MESH_API_PORT}/v1/models`, { timeout: 3000 }, (res) => {
let body = '';
res.on('data', (chunk: Buffer) => {
body += chunk.toString();
});
res.on('end', () => {
try {
if (res.statusCode !== 200) {
resolve({ running: false, models: [] });
return;
}
const data = JSON.parse(body);
if (!Array.isArray(data.data)) {
resolve({ running: false, models: [] });
return;
}
const models = data.data
.filter((m: { id?: unknown }) => typeof m.id === 'string')
.map((m: { id: string }) => m.id);
resolve({ running: true, models });
} catch {
resolve({ running: false, models: [] });
}
});
});
req.on('error', () => resolve({ running: false, models: [] }));
req.on('timeout', () => {
req.destroy();
resolve({ running: false, models: [] });
});
});
result.running = modelsData.running;
result.models = modelsData.models;
} catch {
// API not reachable
}
// If running, also grab console status for invite token + peer info
if (result.running) {
try {
const statusData: { token?: string; peerCount?: number; nodeStatus?: string } =
await new Promise((resolve) => {
const req = http.get(
`http://localhost:${MESH_CONSOLE_PORT}/api/status`,
{ timeout: 3000 },
(res) => {
let body = '';
res.on('data', (chunk: Buffer) => {
body += chunk.toString();
});
res.on('end', () => {
try {
const data = JSON.parse(body);
resolve({
token: data.token,
peerCount: Array.isArray(data.peers) ? data.peers.length : undefined,
nodeStatus: data.node_status,
});
} catch {
resolve({});
}
});
}
);
req.on('error', () => resolve({}));
req.on('timeout', () => {
req.destroy();
resolve({});
});
});
result.token = statusData.token;
result.peerCount = statusData.peerCount;
result.nodeStatus = statusData.nodeStatus;
} catch {
// console not available — that's fine
}
}
return result;
});
ipcMain.handle('start-mesh', async (_event, args: string[]) => {
const binary = await findMeshBinary();
if (!binary) {
return {
started: false,
error: 'mesh-llm not found. Download it first from the Mesh settings tab.',
};
}
// Log to ~/.mesh-llm/mesh-llm.log
const logDir = path.join(os.homedir(), '.mesh-llm');
if (!fsSync.existsSync(logDir)) {
fsSync.mkdirSync(logDir, { recursive: true });
}
const logPath = path.join(logDir, 'mesh-llm.log');
const out = fsSync.openSync(logPath, 'a');
// Spawn detached — mesh-llm outlives Goose.
// Wait briefly for early spawn errors (bad permissions, missing binary, etc.)
const child = spawn(binary, args, {
detached: true,
stdio: ['ignore', out, out],
});
const result = await new Promise<{ started: boolean; error?: string; pid?: number }>(
(resolve) => {
const timeout = setTimeout(() => {
child.removeAllListeners('error');
child.unref();
resolve({ started: true, pid: child.pid });
}, 500);
child.once('error', (err) => {
clearTimeout(timeout);
resolve({ started: false, error: `Failed to spawn mesh-llm: ${err.message}` });
});
}
);
fsSync.closeSync(out);
return result;
});
ipcMain.handle('stop-mesh', async () => {
try {
const binary = await findMeshBinary();
if (!binary) {
return { stopped: false };
}
execFileSync(binary, ['stop'], { timeout: 5000, encoding: 'utf8' });
return { stopped: true };
} catch {
return { stopped: false };
}
});
function execFileP(cmd: string, args: string[], opts: { timeout: number }): Promise<void> {
return new Promise((resolve, reject) => {
execFile(cmd, args, opts, (err) => (err ? reject(err) : resolve()));
});
}
ipcMain.handle('download-mesh', async () => {
if (process.platform !== 'darwin' || process.arch !== 'arm64') {
return { downloaded: false, error: 'Auto-download is only available on macOS (Apple Silicon)' };
}
const installDir = path.join(os.homedir(), '.mesh-llm');
if (!fsSync.existsSync(installDir)) {
fsSync.mkdirSync(installDir, { recursive: true });
}
const tarball = path.join(installDir, 'mesh-bundle.tar.gz');
try {
// Download and extract — mesh-bundle.tar.gz contains mesh-bundle/{mesh-llm,rpc-server,llama-server}
await execFileP('curl', ['-fsSL', '-o', tarball, MESH_DOWNLOAD_URL], { timeout: 120000 });
await execFileP('tar', ['xz', '--strip-components=1', '-C', installDir, '-f', tarball], { timeout: 30000 });
const binary = path.join(installDir, 'mesh-llm');
if (!fsSync.existsSync(binary)) {
return { downloaded: false, error: 'Download succeeded but mesh-llm binary not found' };
}
// macOS: ad-hoc codesign + clear quarantine to avoid Gatekeeper prompts
if (process.platform === 'darwin') {
for (const name of ['mesh-llm', 'rpc-server', 'llama-server']) {
const bin = path.join(installDir, name);
if (fsSync.existsSync(bin)) {
try {
await execFileP('codesign', ['-s', '-', bin], { timeout: 10000 });
} catch {
// codesign may fail if already signed
}
try {
await execFileP('xattr', ['-cr', bin], { timeout: 10000 });
} catch {
// xattr may fail
}
}
}
}
return { downloaded: true, binaryPath: binary };
} catch (err) {
return { downloaded: false, error: `Download failed: ${err}` };
} finally {
try { fsSync.unlinkSync(tarball); } catch { /* ignore */ }
}
});
ipcMain.handle('check-ollama', async () => {
try {
return new Promise((resolve) => {
+18
View File
@@ -101,6 +101,7 @@ export interface CreateChatWindowOptions {
// Define the API types in a single place
type ElectronAPI = {
platform: string;
arch: string;
reactReady: () => void;
getConfig: () => Record<string, unknown>;
hideWindow: () => void;
@@ -114,6 +115,18 @@ type ElectronAPI = {
fetchMetadata: (url: string) => Promise<string>;
reloadApp: () => void;
checkForOllama: () => Promise<boolean>;
checkMesh: () => Promise<{
running: boolean;
installed: boolean;
models: string[];
token?: string;
peerCount?: number;
nodeStatus?: string;
binaryPath?: string;
}>;
startMesh: (args: string[]) => Promise<{ started: boolean; error?: string; pid?: number }>;
stopMesh: () => Promise<{ stopped: boolean }>;
downloadMesh: () => Promise<{ downloaded: boolean; error?: string; binaryPath?: string }>;
selectFileOrDirectory: (defaultPath?: string) => Promise<string | null>;
getBinaryPath: (binaryName: string) => Promise<string>;
readFile: (directory: string) => Promise<FileResponse>;
@@ -180,6 +193,7 @@ type AppConfigAPI = {
const electronAPI: ElectronAPI = {
platform: process.platform,
arch: process.arch,
reactReady: () => ipcRenderer.send('react-ready'),
getConfig: () => {
if (!config || Object.keys(config).length === 0) {
@@ -201,6 +215,10 @@ const electronAPI: ElectronAPI = {
fetchMetadata: (url: string) => ipcRenderer.invoke('fetch-metadata', url),
reloadApp: () => ipcRenderer.send('reload-app'),
checkForOllama: () => ipcRenderer.invoke('check-ollama'),
checkMesh: () => ipcRenderer.invoke('check-mesh'),
startMesh: (args: string[]) => ipcRenderer.invoke('start-mesh', args),
stopMesh: () => ipcRenderer.invoke('stop-mesh'),
downloadMesh: () => ipcRenderer.invoke('download-mesh'),
selectFileOrDirectory: (defaultPath?: string) =>
ipcRenderer.invoke('select-file-or-directory', defaultPath),
getBinaryPath: (binaryName: string) => ipcRenderer.invoke('get-binary-path', binaryName),