417 lines
16 KiB
TypeScript
417 lines
16 KiB
TypeScript
import { app, BrowserWindow, dialog, ipcMain, shell } from "electron";
|
|
import { createHash, randomBytes } from "node:crypto";
|
|
import { createReadStream } from "node:fs";
|
|
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
import { createServer, IncomingMessage, Server, ServerResponse } from "node:http";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import {
|
|
AppState,
|
|
BrainInput,
|
|
ModelProvider,
|
|
WebAccessSettings,
|
|
createBrainItem,
|
|
createPairedDevice,
|
|
createPairingChallenge,
|
|
createProvider,
|
|
isPairingChallengeValid,
|
|
nowIso,
|
|
probeOpenAiCompatibleProvider,
|
|
runAgent,
|
|
updateBrainItem
|
|
} from "@coworker/core";
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const rendererDir = path.resolve(__dirname, "../dist");
|
|
const stateFile = () => path.join(app.getPath("userData"), "state.json");
|
|
|
|
let mainWindow: BrowserWindow | undefined;
|
|
let webServer: Server | undefined;
|
|
let activePairing = createPairingChallenge(0);
|
|
|
|
const defaultState: AppState = {
|
|
providers: [
|
|
createProvider("ollama", "Ollama", "http://localhost:11434/v1"),
|
|
createProvider("lmstudio", "LM Studio", "http://localhost:1234/v1")
|
|
],
|
|
brain: [],
|
|
workspaces: [],
|
|
webAccess: {
|
|
enabled: false,
|
|
bindHost: "127.0.0.1",
|
|
port: 4789,
|
|
requirePairing: true
|
|
},
|
|
pairedDevices: []
|
|
};
|
|
|
|
async function readState(): Promise<AppState> {
|
|
try {
|
|
return { ...defaultState, ...JSON.parse(await readFile(stateFile(), "utf8")) };
|
|
} catch {
|
|
return defaultState;
|
|
}
|
|
}
|
|
|
|
async function writeState(state: AppState): Promise<AppState> {
|
|
await mkdir(path.dirname(stateFile()), { recursive: true });
|
|
await writeFile(stateFile(), JSON.stringify(state, null, 2), "utf8");
|
|
return state;
|
|
}
|
|
|
|
async function mutateState(mutator: (state: AppState) => AppState | Promise<AppState>): Promise<AppState> {
|
|
const current = await readState();
|
|
const next = await mutator(current);
|
|
return writeState(next);
|
|
}
|
|
|
|
function createWindow() {
|
|
mainWindow = new BrowserWindow({
|
|
width: 1320,
|
|
height: 860,
|
|
minWidth: 980,
|
|
minHeight: 680,
|
|
title: "Coworker",
|
|
backgroundColor: "#111317",
|
|
webPreferences: {
|
|
preload: path.join(__dirname, "preload.js"),
|
|
contextIsolation: true,
|
|
nodeIntegration: false
|
|
}
|
|
});
|
|
|
|
const devUrl = process.env.VITE_DEV_SERVER_URL;
|
|
if (devUrl) {
|
|
void mainWindow.loadURL(devUrl);
|
|
} else {
|
|
void mainWindow.loadFile(path.join(rendererDir, "index.html"));
|
|
}
|
|
|
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
|
void shell.openExternal(url);
|
|
return { action: "deny" };
|
|
});
|
|
}
|
|
|
|
function registerIpc() {
|
|
ipcMain.handle("state:get", () => readState());
|
|
|
|
ipcMain.handle("providers:save", async (_event, provider: ModelProvider) => mutateState((state) => {
|
|
const next = { ...provider, baseUrl: provider.baseUrl.replace(/\/+$/, ""), updatedAt: nowIso() };
|
|
return {
|
|
...state,
|
|
providers: state.providers.some((item) => item.id === next.id)
|
|
? state.providers.map((item) => item.id === next.id ? next : item)
|
|
: [...state.providers, next]
|
|
};
|
|
}));
|
|
|
|
ipcMain.handle("providers:remove", async (_event, id: string) => mutateState((state) => ({
|
|
...state,
|
|
providers: state.providers.filter((provider) => provider.id !== id)
|
|
})));
|
|
|
|
ipcMain.handle("providers:probe", async (_event, id: string) => {
|
|
const state = await readState();
|
|
const provider = state.providers.find((item) => item.id === id);
|
|
if (!provider) throw new Error("Provider not found.");
|
|
return probeOpenAiCompatibleProvider(provider);
|
|
});
|
|
|
|
ipcMain.handle("brain:add", async (_event, input: BrainInput) => mutateState((state) => ({
|
|
...state,
|
|
brain: [createBrainItem(input), ...state.brain]
|
|
})));
|
|
|
|
ipcMain.handle("brain:update", async (_event, id: string, patch: Partial<BrainInput>) => mutateState((state) => ({
|
|
...state,
|
|
brain: state.brain.map((item) => item.id === id ? updateBrainItem(item, patch) : item)
|
|
})));
|
|
|
|
ipcMain.handle("brain:remove", async (_event, id: string) => mutateState((state) => ({
|
|
...state,
|
|
brain: state.brain.filter((item) => item.id !== id)
|
|
})));
|
|
|
|
ipcMain.handle("workspace:open", async () => {
|
|
const result = await dialog.showOpenDialog({ properties: ["openDirectory"] });
|
|
if (result.canceled || !result.filePaths[0]) return undefined;
|
|
const rootPath = result.filePaths[0];
|
|
const workspace = {
|
|
id: createHash("sha256").update(rootPath).digest("hex").slice(0, 16),
|
|
name: path.basename(rootPath),
|
|
rootPath,
|
|
openedAt: nowIso()
|
|
};
|
|
await mutateState((state) => ({
|
|
...state,
|
|
workspaces: [workspace, ...state.workspaces.filter((item) => item.id !== workspace.id)]
|
|
}));
|
|
return workspace;
|
|
});
|
|
|
|
ipcMain.handle("workspace:list", async (_event, id: string) => {
|
|
const workspace = (await readState()).workspaces.find((item) => item.id === id);
|
|
if (!workspace) throw new Error("Workspace not found.");
|
|
const entries = await readdir(workspace.rootPath, { withFileTypes: true });
|
|
return Promise.all(entries.slice(0, 200).map(async (entry) => {
|
|
const absolute = path.join(workspace.rootPath, entry.name);
|
|
const info = await stat(absolute);
|
|
return {
|
|
path: entry.name,
|
|
type: entry.isDirectory() ? "directory" : "file",
|
|
size: entry.isFile() ? info.size : undefined,
|
|
modifiedAt: info.mtime.toISOString()
|
|
};
|
|
}));
|
|
});
|
|
|
|
ipcMain.handle("workspace:read", async (_event, id: string, relativePath: string) => {
|
|
const workspace = (await readState()).workspaces.find((item) => item.id === id);
|
|
if (!workspace) throw new Error("Workspace not found.");
|
|
const absolute = confinedPath(workspace.rootPath, relativePath);
|
|
const info = await stat(absolute);
|
|
if (!info.isFile() || info.size > 512 * 1024) throw new Error("File is not readable in preview.");
|
|
return readFile(absolute, "utf8");
|
|
});
|
|
|
|
ipcMain.handle("agent:run", async (_event, request) => {
|
|
const state = await readState();
|
|
const provider = request.providerId
|
|
? state.providers.find((item) => item.id === request.providerId)
|
|
: state.providers.find((item) => item.enabled);
|
|
const model = request.model ?? provider?.defaults.coding ?? provider?.defaults.chat;
|
|
return runAgent(
|
|
{
|
|
prompt: String(request.prompt ?? ""),
|
|
workspace: request.workspace,
|
|
provider,
|
|
model,
|
|
messages: request.messages ?? [],
|
|
brain: state.brain
|
|
},
|
|
(messages) => completeWithProvider(provider, model, messages)
|
|
);
|
|
});
|
|
|
|
ipcMain.handle("web:settings", async (_event, settings: WebAccessSettings) => {
|
|
const state = await mutateState((current) => ({ ...current, webAccess: settings }));
|
|
await restartWebServer(state.webAccess);
|
|
return state;
|
|
});
|
|
|
|
ipcMain.handle("web:pairing:create", () => {
|
|
activePairing = createPairingChallenge();
|
|
return activePairing;
|
|
});
|
|
}
|
|
|
|
async function completeWithProvider(provider: ModelProvider | undefined, model: string | undefined, messages: { role: string; content: string }[]) {
|
|
if (!provider || !model) {
|
|
return "No model is configured yet. Add Ollama, LM Studio, or another OpenAI-compatible provider in Settings.";
|
|
}
|
|
const response = await fetch(`${provider.baseUrl.replace(/\/+$/, "")}/chat/completions`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...(provider.apiKey ? { Authorization: `Bearer ${provider.apiKey}` } : {})
|
|
},
|
|
body: JSON.stringify({
|
|
model,
|
|
messages: messages.map((message) => ({ role: message.role, content: message.content })),
|
|
temperature: 0.2
|
|
})
|
|
});
|
|
if (!response.ok) return `Model request failed with HTTP ${response.status}.`;
|
|
const body = await response.json() as { choices?: Array<{ message?: { content?: string } }> };
|
|
return body.choices?.[0]?.message?.content ?? "The model returned an empty response.";
|
|
}
|
|
|
|
function confinedPath(root: string, relativePath: string) {
|
|
const absolute = path.resolve(root, relativePath);
|
|
const normalizedRoot = path.resolve(root);
|
|
if (absolute !== normalizedRoot && !absolute.startsWith(`${normalizedRoot}${path.sep}`)) {
|
|
throw new Error("Path is outside the workspace.");
|
|
}
|
|
return absolute;
|
|
}
|
|
|
|
async function restartWebServer(settings: WebAccessSettings) {
|
|
if (webServer) {
|
|
await new Promise<void>((resolve) => webServer?.close(() => resolve()));
|
|
webServer = undefined;
|
|
}
|
|
if (!settings.enabled) return;
|
|
webServer = createServer(handleWebRequest);
|
|
await new Promise<void>((resolve, reject) => {
|
|
webServer?.listen(settings.port, settings.bindHost, resolve);
|
|
webServer?.on("error", reject);
|
|
});
|
|
}
|
|
|
|
async function handleWebRequest(req: IncomingMessage, res: ServerResponse) {
|
|
const url = new URL(req.url ?? "/", "http://coworker.local");
|
|
if (url.pathname === "/api/pair" && req.method === "POST") return handlePair(req, res);
|
|
if (url.pathname === "/api/state") return sendJson(res, await publicState(req));
|
|
if (url.pathname.startsWith("/api/") && !(await isAuthorized(req))) return sendJson(res, { error: "Pairing required." }, 401);
|
|
if (url.pathname === "/api/providers" && req.method === "POST") return handleWebProviderSave(req, res);
|
|
if (url.pathname.match(/^\/api\/providers\/[^/]+\/probe$/) && req.method === "POST") return handleWebProviderProbe(url, res);
|
|
if (url.pathname === "/api/brain" && req.method === "POST") return handleWebBrainAdd(req, res);
|
|
if (url.pathname.match(/^\/api\/brain\/[^/]+$/) && req.method === "DELETE") return handleWebBrainRemove(url, res);
|
|
if (url.pathname.match(/^\/api\/workspaces\/[^/]+\/files$/)) return handleWebWorkspaceList(url, res);
|
|
if (url.pathname.match(/^\/api\/workspaces\/[^/]+\/files\/.+$/)) return handleWebWorkspaceRead(url, res);
|
|
if (url.pathname === "/api/agent/run" && req.method === "POST") return handleWebAgent(req, res);
|
|
return serveStatic(url.pathname, res);
|
|
}
|
|
|
|
async function handlePair(req: IncomingMessage, res: ServerResponse) {
|
|
const body = await readJsonBody<{ code?: string; name?: string }>(req);
|
|
if (!isPairingChallengeValid(activePairing, body.code ?? "")) return sendJson(res, { error: "Invalid pairing code." }, 403);
|
|
const token = randomBytes(24).toString("hex");
|
|
const device = await createPairedDevice(body.name ?? "Mobile device", token, digest);
|
|
await mutateState((state) => ({ ...state, pairedDevices: [device, ...state.pairedDevices] }));
|
|
res.setHeader("Set-Cookie", `coworker_token=${token}; HttpOnly; SameSite=Lax; Path=/`);
|
|
sendJson(res, { token });
|
|
}
|
|
|
|
async function handleWebProviderSave(req: IncomingMessage, res: ServerResponse) {
|
|
const provider = await readJsonBody<ModelProvider>(req);
|
|
const state = await mutateState((current) => ({
|
|
...current,
|
|
providers: current.providers.some((item) => item.id === provider.id)
|
|
? current.providers.map((item) => item.id === provider.id ? { ...provider, updatedAt: nowIso() } : item)
|
|
: [...current.providers, { ...createProvider(provider.kind, provider.name, provider.baseUrl), ...provider, updatedAt: nowIso() }]
|
|
}));
|
|
sendJson(res, sanitizeState(state));
|
|
}
|
|
|
|
async function handleWebProviderProbe(url: URL, res: ServerResponse) {
|
|
const id = decodeURIComponent(url.pathname.split("/")[3]);
|
|
const provider = (await readState()).providers.find((item) => item.id === id);
|
|
if (!provider) return sendJson(res, { error: "Provider not found." }, 404);
|
|
sendJson(res, await probeOpenAiCompatibleProvider(provider));
|
|
}
|
|
|
|
async function handleWebBrainAdd(req: IncomingMessage, res: ServerResponse) {
|
|
const input = await readJsonBody<BrainInput>(req);
|
|
const state = await mutateState((current) => ({ ...current, brain: [createBrainItem(input), ...current.brain] }));
|
|
sendJson(res, sanitizeState(state));
|
|
}
|
|
|
|
async function handleWebBrainRemove(url: URL, res: ServerResponse) {
|
|
const id = decodeURIComponent(url.pathname.split("/")[3]);
|
|
const state = await mutateState((current) => ({ ...current, brain: current.brain.filter((item) => item.id !== id) }));
|
|
sendJson(res, sanitizeState(state));
|
|
}
|
|
|
|
async function handleWebWorkspaceList(url: URL, res: ServerResponse) {
|
|
const id = decodeURIComponent(url.pathname.split("/")[3]);
|
|
const workspace = (await readState()).workspaces.find((item) => item.id === id);
|
|
if (!workspace) return sendJson(res, { error: "Workspace not found." }, 404);
|
|
const entries = await readdir(workspace.rootPath, { withFileTypes: true });
|
|
const files = await Promise.all(entries.slice(0, 200).map(async (entry) => {
|
|
const absolute = path.join(workspace.rootPath, entry.name);
|
|
const info = await stat(absolute);
|
|
return {
|
|
path: entry.name,
|
|
type: entry.isDirectory() ? "directory" : "file",
|
|
size: entry.isFile() ? info.size : undefined,
|
|
modifiedAt: info.mtime.toISOString()
|
|
};
|
|
}));
|
|
sendJson(res, files);
|
|
}
|
|
|
|
async function handleWebWorkspaceRead(url: URL, res: ServerResponse) {
|
|
const parts = url.pathname.split("/");
|
|
const id = decodeURIComponent(parts[3]);
|
|
const relativePath = decodeURIComponent(parts.slice(5).join("/"));
|
|
const workspace = (await readState()).workspaces.find((item) => item.id === id);
|
|
if (!workspace) return sendJson(res, { error: "Workspace not found." }, 404);
|
|
const absolute = confinedPath(workspace.rootPath, relativePath);
|
|
const info = await stat(absolute);
|
|
if (!info.isFile() || info.size > 512 * 1024) return sendJson(res, { error: "File is not readable in preview." }, 400);
|
|
sendJson(res, { content: await readFile(absolute, "utf8") });
|
|
}
|
|
|
|
async function handleWebAgent(req: IncomingMessage, res: ServerResponse) {
|
|
const body = await readJsonBody<{ prompt?: string; messages?: unknown[]; providerId?: string; model?: string }>(req);
|
|
const state = await readState();
|
|
const provider = body.providerId ? state.providers.find((item) => item.id === body.providerId) : state.providers[0];
|
|
const result = await runAgent(
|
|
{ prompt: body.prompt ?? "", messages: [], brain: state.brain, provider, model: body.model },
|
|
(messages) => completeWithProvider(provider, body.model ?? provider?.defaults.chat, messages)
|
|
);
|
|
sendJson(res, result);
|
|
}
|
|
|
|
async function publicState(req: IncomingMessage) {
|
|
const state = await readState();
|
|
const paired = await isAuthorized(req);
|
|
return {
|
|
paired,
|
|
webAccess: state.webAccess,
|
|
providers: paired ? state.providers.map(({ apiKey, ...provider }) => provider) : [],
|
|
brain: paired ? state.brain : [],
|
|
workspaces: paired ? state.workspaces : []
|
|
};
|
|
}
|
|
|
|
async function isAuthorized(req: IncomingMessage) {
|
|
const token = (req.headers.cookie ?? "").split(";").map((item) => item.trim()).find((item) => item.startsWith("coworker_token="))?.split("=")[1];
|
|
if (!token) return false;
|
|
const hash = await digest(token);
|
|
const state = await readState();
|
|
return state.pairedDevices.some((device) => !device.revokedAt && device.tokenHash === hash);
|
|
}
|
|
|
|
async function digest(value: string) {
|
|
return createHash("sha256").update(value).digest("hex");
|
|
}
|
|
|
|
function sanitizeState(state: AppState): AppState {
|
|
return {
|
|
...state,
|
|
providers: state.providers.map(({ apiKey, ...provider }) => provider),
|
|
pairedDevices: state.pairedDevices.map((device) => ({ ...device, tokenHash: "" }))
|
|
};
|
|
}
|
|
|
|
async function readJsonBody<T>(req: IncomingMessage): Promise<T> {
|
|
const chunks: Buffer[] = [];
|
|
for await (const chunk of req) chunks.push(Buffer.from(chunk));
|
|
return JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}") as T;
|
|
}
|
|
|
|
function sendJson(res: ServerResponse, data: unknown, status = 200) {
|
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify(data));
|
|
}
|
|
|
|
function serveStatic(urlPath: string, res: ServerResponse) {
|
|
try {
|
|
const safePath = urlPath === "/" ? "index.html" : urlPath.replace(/^\/+/, "");
|
|
const absolute = confinedPath(rendererDir, safePath);
|
|
const finalPath = absolute.endsWith(path.sep) ? path.join(absolute, "index.html") : absolute;
|
|
createReadStream(finalPath)
|
|
.on("error", () => createReadStream(path.join(rendererDir, "index.html")).pipe(res))
|
|
.pipe(res);
|
|
} catch {
|
|
res.writeHead(403);
|
|
res.end("Forbidden");
|
|
}
|
|
}
|
|
|
|
app.whenReady().then(async () => {
|
|
registerIpc();
|
|
createWindow();
|
|
await restartWebServer((await readState()).webAccess);
|
|
app.on("activate", () => {
|
|
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
|
});
|
|
});
|
|
|
|
app.on("window-all-closed", () => {
|
|
if (process.platform !== "darwin") app.quit();
|
|
});
|