Skip to content

Commit 6e84828

Browse files
committed
Add app-state, chat-dispatch, and benchmark-persistence modules
First step of splitting main.ts's IPC registration out of one 1000+ line file. Introduces shared-state accessors (mainWindow/isBusy/ forceClose as getter/setter pairs, since a raw exported `let` doesn't get live updates through a CommonJS destructured import) plus the chat-dispatch and benchmark-result-persistence helpers that both the IPC handlers and the scheduler need. Not yet wired into main.ts.
1 parent a90ac2e commit 6e84828

3 files changed

Lines changed: 302 additions & 0 deletions

File tree

‎app/src/app-state.ts‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import * as path from "node:path";
2+
import * as fs from "node:fs";
3+
import { app, BrowserWindow } from "electron";
4+
import * as settingsStore from "./settings-store";
5+
import type * as localServers from "./local-server-manager";
6+
import type { EnergyMonitorSettings } from "./energy-types";
7+
import type { ProviderId } from "./providers/types";
8+
9+
export const PROVIDER_SECRET_KEYS: Record<Exclude<ProviderId, "ollama" | "llamacpp" | "custom" | "mlx" | "rocm" | "vllm">, string> = {
10+
openai: "openai_api_key",
11+
anthropic: "anthropic_api_key",
12+
gemini: "gemini_api_key",
13+
};
14+
15+
export function customProviderSecretKey(customProviderId: string): string {
16+
return `custom_${customProviderId}_api_key`;
17+
}
18+
19+
export const activeChatRequests = new Map<string, AbortController>();
20+
export const activeBenchmarkRequests = new Map<string, AbortController>();
21+
22+
let isBusy = false;
23+
export function getIsBusy(): boolean {
24+
return isBusy;
25+
}
26+
export function setIsBusy(value: boolean): void {
27+
isBusy = value;
28+
}
29+
30+
let forceClose = false;
31+
export function getForceClose(): boolean {
32+
return forceClose;
33+
}
34+
export function setForceClose(value: boolean): void {
35+
forceClose = value;
36+
}
37+
38+
// Every ipcMain.handle callback below is only reachable from this app's own
39+
// preload-bridged renderer (contextIsolation is on, nodeIntegration is off),
40+
// so this isn't a hostile-input boundary in the way a public API would be.
41+
// Still, a malformed/undefined argument reaching a store function as `id`
42+
// would throw a raw TypeError several layers deep — validating up front
43+
// turns that into one clear, loggable error instead.
44+
export function requireString(value: unknown, label: string): string {
45+
if (typeof value !== "string" || !value) {
46+
throw new Error(`Invalid ${label}: expected a non-empty string`);
47+
}
48+
return value;
49+
}
50+
51+
export function getLlamaCppModelsDir(): string {
52+
const configured = settingsStore.getSettings().llamaCppModelsDir;
53+
const dir = configured || path.join(app.getPath("userData"), "llamacpp-models");
54+
fs.mkdirSync(dir, { recursive: true });
55+
return dir;
56+
}
57+
58+
export function getLocalRuntimeConfig(): localServers.LocalBackendConfig {
59+
const settings = settingsStore.getSettings();
60+
return { mlxPythonPath: settings.mlxPythonPath, rocmServerPath: settings.rocmServerPath, vllmCommand: settings.vllmCommand };
61+
}
62+
63+
export function getEnergyMonitorSettings(): EnergyMonitorSettings {
64+
const settings = settingsStore.getSettings();
65+
return {
66+
enabled: settings.energyMonitoringEnabled ?? false,
67+
electricityPricePerKwh: Math.max(0, settings.electricityPricePerKwh ?? 0.2),
68+
currency: settings.energyCurrency?.trim() || "USD",
69+
timeOfUseTariffs: settings.timeOfUseTariffs ?? [],
70+
manualCpuWatts: settings.manualCpuWatts,
71+
manualGpuWatts: settings.manualGpuWatts,
72+
manualSystemIdleWatts: settings.manualSystemIdleWatts,
73+
includeIdleSystemConsumption: settings.includeIdleSystemConsumption ?? true,
74+
retentionDays: Math.max(1, settings.energyUsageRetentionDays ?? 365),
75+
sampleIntervalSeconds: Math.max(1, Math.min(5, settings.energySampleIntervalSeconds ?? 2)),
76+
gridIntensityGCo2PerKwh: settings.gridIntensityGCo2PerKwh,
77+
};
78+
}
79+
80+
let mainWindow: BrowserWindow | null = null;
81+
export function getMainWindow(): BrowserWindow | null {
82+
return mainWindow;
83+
}
84+
export function setMainWindow(win: BrowserWindow | null): void {
85+
mainWindow = win;
86+
}

‎app/src/benchmark-persistence.ts‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import * as path from "node:path";
2+
import * as fs from "node:fs";
3+
import { app, dialog } from "electron";
4+
import * as systemSpecs from "./system-specs";
5+
import * as localServers from "./local-server-manager";
6+
import * as powerMonitor from "./power-monitor";
7+
import { getLogTail } from "./logger";
8+
import type * as benchmarkRunner from "./benchmark-runner";
9+
import { getMainWindow, getEnergyMonitorSettings, getLocalRuntimeConfig } from "./app-state";
10+
11+
function benchmarkResultPath(): string {
12+
const dir = path.join(app.getPath("userData"), "benchmarks");
13+
fs.mkdirSync(dir, { recursive: true });
14+
return path.join(dir, "latest.json");
15+
}
16+
17+
function benchmarkHistoryPath(): string { return path.join(path.dirname(benchmarkResultPath()), "history.json"); }
18+
19+
export function saveBenchmarkResult(result: benchmarkRunner.BenchmarkResult): void {
20+
const destination = benchmarkResultPath();
21+
const temporary = `${destination}.tmp`;
22+
fs.writeFileSync(temporary, JSON.stringify(result, null, 2));
23+
fs.renameSync(temporary, destination);
24+
let history: benchmarkRunner.BenchmarkResult[] = [];
25+
try { history = JSON.parse(fs.readFileSync(benchmarkHistoryPath(), "utf-8")); } catch { /* first benchmark */ }
26+
history.push(result);
27+
fs.writeFileSync(benchmarkHistoryPath(), JSON.stringify(history.slice(-100), null, 2));
28+
}
29+
30+
export function getBenchmarkObservations(): systemSpecs.BenchmarkObservation[] {
31+
try {
32+
const history = JSON.parse(fs.readFileSync(benchmarkHistoryPath(), "utf-8")) as benchmarkRunner.BenchmarkResult[];
33+
return history.flatMap((result) => result.primary ? [{ model: result.model, tokensPerSecond: result.primary.tokensPerSecond, promptTokensPerSecond: result.primary.promptTokensPerSecond, timeToFirstTokenMs: result.primary.timeToFirstTokenMs }] : []);
34+
} catch {
35+
const latest = getLastBenchmarkResult();
36+
return latest?.primary ? [{ model: latest.model, tokensPerSecond: latest.primary.tokensPerSecond, promptTokensPerSecond: latest.primary.promptTokensPerSecond, timeToFirstTokenMs: latest.primary.timeToFirstTokenMs }] : [];
37+
}
38+
}
39+
40+
export function getLastBenchmarkResult(): benchmarkRunner.BenchmarkResult | null {
41+
try {
42+
return JSON.parse(fs.readFileSync(benchmarkResultPath(), "utf-8")) as benchmarkRunner.BenchmarkResult;
43+
} catch {
44+
return null;
45+
}
46+
}
47+
48+
export async function exportDiagnosticReport(result: benchmarkRunner.BenchmarkResult): Promise<{ success: boolean }> {
49+
const specs = await systemSpecs.getSpecs();
50+
const runtimes = await localServers.getRuntimeStatuses(getLocalRuntimeConfig());
51+
const report = {
52+
schemaVersion: 1,
53+
exportedAt: new Date().toISOString(),
54+
application: {
55+
version: app.getVersion(),
56+
electron: process.versions.electron,
57+
chrome: process.versions.chrome,
58+
node: process.versions.node,
59+
platform: process.platform,
60+
arch: process.arch,
61+
},
62+
system: specs,
63+
runtimeHealth: runtimes,
64+
benchmark: result,
65+
energyUsage: powerMonitor.getDashboard(getEnergyMonitorSettings()),
66+
recentLogs: getLogTail(),
67+
};
68+
const date = new Date().toISOString().replace(/[:.]/g, "-");
69+
const options = {
70+
defaultPath: `modelforge-diagnostic-${date}.json`,
71+
filters: [{ name: "JSON", extensions: ["json"] }],
72+
};
73+
const mainWindow = getMainWindow();
74+
const dialogResult = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options);
75+
if (dialogResult.canceled || !dialogResult.filePath) return { success: false };
76+
fs.writeFileSync(dialogResult.filePath, JSON.stringify(report, null, 2));
77+
return { success: true };
78+
}

‎app/src/chat-dispatch.ts‎

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import * as path from "node:path";
2+
import * as ollama from "./ollama-manager";
3+
import * as settingsStore from "./settings-store";
4+
import * as secretsStore from "./secrets-store";
5+
import * as llamacpp from "./llamacpp-manager";
6+
import * as localServers from "./local-server-manager";
7+
import * as powerMonitor from "./power-monitor";
8+
import * as openaiProvider from "./providers/openai";
9+
import * as anthropicProvider from "./providers/anthropic";
10+
import * as geminiProvider from "./providers/gemini";
11+
import { createOpenAiCompatibleChat } from "./providers/openai-compatible";
12+
import type { ChatMessage, ChatChunk, ChatOptions, ProviderId, ToolDefinition } from "./providers/types";
13+
import { PROVIDER_SECRET_KEYS, customProviderSecretKey, getLlamaCppModelsDir, getEnergyMonitorSettings } from "./app-state";
14+
15+
// Shared by chat:send (renderer-driven, streams tokens back over IPC) and
16+
// the scheduled-task runner (background, wants the full text once done) —
17+
// same provider dispatch and error handling either way.
18+
export async function dispatchChat(
19+
provider: ProviderId,
20+
model: string,
21+
messages: ChatMessage[],
22+
options: ChatOptions | undefined,
23+
onToken: (chunk: ChatChunk) => void,
24+
signal?: AbortSignal,
25+
tools?: ToolDefinition[]
26+
): Promise<void> {
27+
const currentSettings = settingsStore.getSettings();
28+
const customLocal = provider === "custom"
29+
&& currentSettings.customProviders?.find((item) => model.startsWith(`${item.id}::`))?.localGpuBackend;
30+
const localProvider = ["ollama", "llamacpp", "mlx", "rocm", "vllm"].includes(provider) || !!customLocal;
31+
const energySettings = getEnergyMonitorSettings();
32+
energySettings.enabled = energySettings.enabled && localProvider;
33+
const backend = provider === "llamacpp"
34+
? currentSettings.llamaCppGpuBackend ?? "auto"
35+
: provider === "rocm" ? "rocm" : provider;
36+
const initialPromptTokens = Math.max(1, Math.ceil(messages.reduce((sum, message) => sum + message.content.length, 0) / 4));
37+
const activity = powerMonitor.beginRequest(provider, model, backend, energySettings, initialPromptTokens);
38+
const downstreamToken = onToken;
39+
onToken = (chunk) => {
40+
activity.onChunk(chunk);
41+
downstreamToken(chunk);
42+
};
43+
try {
44+
if (provider === "ollama") {
45+
await ollama.chat(model, messages, options, onToken, signal, tools);
46+
} else if (provider === "llamacpp") {
47+
// Same containment rule as the rocm branch below: the model ref is a
48+
// renderer-supplied relative path (may include subfolders), and
49+
// path.join would happily walk ".." segments out of the models dir.
50+
const root = path.resolve(getLlamaCppModelsDir());
51+
const modelPath = path.resolve(root, model);
52+
if (modelPath === root || !modelPath.startsWith(root + path.sep)) {
53+
throw new Error(`Model file "${model}" is outside the models directory.`);
54+
}
55+
await llamacpp.chat(modelPath, messages, options, onToken, signal, tools);
56+
} else if (provider === "mlx" || provider === "rocm" || provider === "vllm") {
57+
const settings = settingsStore.getSettings();
58+
// ROCm serves the same GGUF files as the llama.cpp backend, so the
59+
// model ref is a filename that must stay inside the models dir; MLX
60+
// models are HF repo ids the server resolves itself.
61+
let serverModel = model;
62+
if (provider === "rocm") {
63+
const root = path.resolve(getLlamaCppModelsDir());
64+
const resolved = path.resolve(root, model);
65+
// Was `resolved !== root && !startsWith(...)` (AND) — that only
66+
// threw when BOTH conditions held, so a ref resolving to exactly
67+
// the models dir itself (e.g. "rocm:.") satisfied neither and
68+
// slipped through, handing the whole directory to llama-server
69+
// -m as if it were a single model file.
70+
if (resolved === root || !resolved.startsWith(root + path.sep)) {
71+
throw new Error(`Model file "${model}" is outside the models directory.`);
72+
}
73+
serverModel = resolved;
74+
}
75+
const lease = await localServers.acquireServer(provider, serverModel, {
76+
mlxPythonPath: settings.mlxPythonPath,
77+
rocmServerPath: settings.rocmServerPath,
78+
vllmCommand: settings.vllmCommand,
79+
});
80+
try {
81+
// Managed runtimes are local and unauthenticated; the key is a
82+
// compatibility placeholder for their OpenAI-shaped APIs.
83+
const providerLabel = provider === "mlx" ? "MLX" : provider === "vllm" ? "vLLM" : "ROCm llama-server";
84+
await createOpenAiCompatibleChat(`${lease.baseUrl}/v1`, providerLabel)(
85+
"local",
86+
model,
87+
messages,
88+
options,
89+
onToken,
90+
signal,
91+
tools
92+
);
93+
} finally {
94+
lease.release();
95+
}
96+
} else if (provider === "custom") {
97+
// model is "<customProviderId>::<actual model id>" — see
98+
// frontend/src/lib/providers.ts's formatCustomModelRef.
99+
const sep = model.indexOf("::");
100+
if (sep === -1) throw new Error(`Malformed custom model reference: ${model}`);
101+
const customProviderId = model.slice(0, sep);
102+
const actualModel = model.slice(sep + 2);
103+
const config = settingsStore.getSettings().customProviders?.find((p) => p.id === customProviderId);
104+
if (!config) throw new Error(`Custom provider "${customProviderId}" is no longer configured.`);
105+
const apiKey = secretsStore.getSecret(customProviderSecretKey(customProviderId));
106+
if (!apiKey && !config.localGpuBackend) throw new Error(`No API key set for ${config.name}. Add one in Settings.`);
107+
await createOpenAiCompatibleChat(config.baseUrl, config.name)(
108+
apiKey ?? "local-gpu-backend",
109+
actualModel,
110+
messages,
111+
options,
112+
onToken,
113+
signal,
114+
tools
115+
);
116+
} else {
117+
const secretKey = PROVIDER_SECRET_KEYS[provider];
118+
const apiKey = secretsStore.getSecret(secretKey);
119+
if (!apiKey) throw new Error(`No API key set for ${provider}. Add one in Settings.`);
120+
const providerFn =
121+
provider === "openai" ? openaiProvider.chat : provider === "anthropic" ? anthropicProvider.chat : geminiProvider.chat;
122+
await providerFn(apiKey, model, messages, options, onToken, signal, tools);
123+
}
124+
} finally {
125+
await activity.finish();
126+
}
127+
}
128+
129+
// Runs a single-turn prompt to completion and returns the full text —
130+
// what the scheduled-task runner needs, as opposed to chat:send's
131+
// token-by-token streaming back to the renderer.
132+
export async function completePrompt(provider: ProviderId, model: string, prompt: string): Promise<string> {
133+
let text = "";
134+
await dispatchChat(provider, model, [{ role: "user", content: prompt }], undefined, (chunk) => {
135+
text += chunk.message?.content ?? "";
136+
});
137+
return text;
138+
}

0 commit comments

Comments
 (0)