Initial Coworker app scaffold
This commit is contained in:
416
apps/desktop/electron/main.ts
Normal file
416
apps/desktop/electron/main.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
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();
|
||||
});
|
||||
Reference in New Issue
Block a user