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();
|
||||
});
|
||||
31
apps/desktop/electron/preload.ts
Normal file
31
apps/desktop/electron/preload.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
|
||||
const api = {
|
||||
state: () => ipcRenderer.invoke("state:get"),
|
||||
providers: {
|
||||
save: (provider: unknown) => ipcRenderer.invoke("providers:save", provider),
|
||||
remove: (id: string) => ipcRenderer.invoke("providers:remove", id),
|
||||
probe: (id: string) => ipcRenderer.invoke("providers:probe", id)
|
||||
},
|
||||
brain: {
|
||||
add: (input: unknown) => ipcRenderer.invoke("brain:add", input),
|
||||
update: (id: string, patch: unknown) => ipcRenderer.invoke("brain:update", id, patch),
|
||||
remove: (id: string) => ipcRenderer.invoke("brain:remove", id)
|
||||
},
|
||||
workspace: {
|
||||
open: () => ipcRenderer.invoke("workspace:open"),
|
||||
list: (id: string) => ipcRenderer.invoke("workspace:list", id),
|
||||
read: (id: string, relativePath: string) => ipcRenderer.invoke("workspace:read", id, relativePath)
|
||||
},
|
||||
agent: {
|
||||
run: (request: unknown) => ipcRenderer.invoke("agent:run", request)
|
||||
},
|
||||
web: {
|
||||
saveSettings: (settings: unknown) => ipcRenderer.invoke("web:settings", settings),
|
||||
createPairing: () => ipcRenderer.invoke("web:pairing:create")
|
||||
}
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld("coworker", api);
|
||||
|
||||
export type CoworkerApi = typeof api;
|
||||
17
apps/desktop/index.html
Normal file
17
apps/desktop/index.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#111317" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-title" content="Coworker" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<title>Coworker</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
6
apps/desktop/package.json
Normal file
6
apps/desktop/package.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "@coworker/desktop",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"private": true
|
||||
}
|
||||
5
apps/desktop/public/icon.svg
Normal file
5
apps/desktop/public/icon.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||
<rect width="128" height="128" rx="28" fill="#111317"/>
|
||||
<path d="M31 70c0-23 14-39 34-39 16 0 27 9 31 23H77c-3-5-7-7-13-7-10 0-16 9-16 23s6 23 16 23c7 0 12-3 15-10h19c-5 17-17 27-35 27-19 0-32-16-32-40Z" fill="#f5f7fb"/>
|
||||
<path d="M22 24h28v13H35v54h15v13H22V24Zm84 80H78V91h15V37H78V24h28v80Z" fill="#7dd3fc"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 387 B |
17
apps/desktop/public/manifest.webmanifest
Normal file
17
apps/desktop/public/manifest.webmanifest
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "Coworker",
|
||||
"short_name": "Coworker",
|
||||
"display": "standalone",
|
||||
"background_color": "#111317",
|
||||
"theme_color": "#111317",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
13
apps/desktop/public/sw.js
Normal file
13
apps/desktop/public/sw.js
Normal file
@@ -0,0 +1,13 @@
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(caches.open("coworker-shell-v1").then((cache) => cache.addAll(["/", "/manifest.webmanifest", "/icon.svg"])));
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
if (event.request.method !== "GET") return;
|
||||
event.respondWith(fetch(event.request).catch(() => caches.match(event.request).then((cached) => cached || caches.match("/"))));
|
||||
});
|
||||
86
apps/desktop/src/api.ts
Normal file
86
apps/desktop/src/api.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type {
|
||||
AgentRunResult,
|
||||
AppState,
|
||||
BrainInput,
|
||||
BrainItem,
|
||||
ModelProvider,
|
||||
PairingChallenge,
|
||||
ProviderProbeResult,
|
||||
WebAccessSettings,
|
||||
WorkspaceFile,
|
||||
WorkspaceState
|
||||
} from "@coworker/core";
|
||||
|
||||
type AgentRequest = {
|
||||
prompt: string;
|
||||
providerId?: string;
|
||||
model?: string;
|
||||
workspace?: WorkspaceState;
|
||||
messages?: Array<{ role: string; content: string }>;
|
||||
};
|
||||
|
||||
async function json<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(init?.headers ?? {})
|
||||
}
|
||||
});
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const coworkerApi = {
|
||||
state(): Promise<AppState> {
|
||||
if (window.coworker) return window.coworker.state() as Promise<AppState>;
|
||||
return json<AppState>("/api/state");
|
||||
},
|
||||
saveProvider(provider: ModelProvider): Promise<AppState> {
|
||||
if (window.coworker) return window.coworker.providers.save(provider) as Promise<AppState>;
|
||||
return json<AppState>("/api/providers", { method: "POST", body: JSON.stringify(provider) });
|
||||
},
|
||||
probeProvider(id: string): Promise<ProviderProbeResult> {
|
||||
if (window.coworker) return window.coworker.providers.probe(id) as Promise<ProviderProbeResult>;
|
||||
return json<ProviderProbeResult>(`/api/providers/${id}/probe`, { method: "POST" });
|
||||
},
|
||||
addBrain(input: BrainInput): Promise<AppState> {
|
||||
if (window.coworker) return window.coworker.brain.add(input) as Promise<AppState>;
|
||||
return json<AppState>("/api/brain", { method: "POST", body: JSON.stringify(input) });
|
||||
},
|
||||
removeBrain(id: string): Promise<AppState> {
|
||||
if (window.coworker) return window.coworker.brain.remove(id) as Promise<AppState>;
|
||||
return json<AppState>(`/api/brain/${id}`, { method: "DELETE" });
|
||||
},
|
||||
openWorkspace(): Promise<WorkspaceState | undefined> {
|
||||
if (window.coworker) return window.coworker.workspace.open() as Promise<WorkspaceState | undefined>;
|
||||
throw new Error("Workspace selection is available in the desktop app.");
|
||||
},
|
||||
listWorkspace(id: string): Promise<WorkspaceFile[]> {
|
||||
if (window.coworker) return window.coworker.workspace.list(id) as Promise<WorkspaceFile[]>;
|
||||
return json<WorkspaceFile[]>(`/api/workspaces/${id}/files`);
|
||||
},
|
||||
readWorkspace(id: string, relativePath: string): Promise<string> {
|
||||
if (window.coworker) return window.coworker.workspace.read(id, relativePath) as Promise<string>;
|
||||
return json<{ content: string }>(`/api/workspaces/${id}/files/${encodeURIComponent(relativePath)}`).then((r) => r.content);
|
||||
},
|
||||
runAgent(request: AgentRequest): Promise<AgentRunResult> {
|
||||
if (window.coworker) return window.coworker.agent.run(request) as Promise<AgentRunResult>;
|
||||
return json<AgentRunResult>("/api/agent/run", { method: "POST", body: JSON.stringify(request) });
|
||||
},
|
||||
saveWebSettings(settings: WebAccessSettings): Promise<AppState> {
|
||||
if (window.coworker) return window.coworker.web.saveSettings(settings) as Promise<AppState>;
|
||||
throw new Error("Web settings are available in the desktop app.");
|
||||
},
|
||||
createPairing(): Promise<PairingChallenge> {
|
||||
if (window.coworker) return window.coworker.web.createPairing() as Promise<PairingChallenge>;
|
||||
throw new Error("Pairing is started from the desktop app.");
|
||||
},
|
||||
pair(code: string, name: string): Promise<{ token: string }> {
|
||||
return json<{ token: string }>("/api/pair", { method: "POST", body: JSON.stringify({ code, name }) });
|
||||
}
|
||||
};
|
||||
|
||||
export function displayBrainItem(item: BrainItem) {
|
||||
return `${item.kind.toUpperCase()} / ${item.scope}${item.pinned ? " / PINNED" : ""}`;
|
||||
}
|
||||
7
apps/desktop/src/global.d.ts
vendored
Normal file
7
apps/desktop/src/global.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { CoworkerApi } from "../electron/preload";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
coworker?: CoworkerApi;
|
||||
}
|
||||
}
|
||||
310
apps/desktop/src/main.tsx
Normal file
310
apps/desktop/src/main.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { AppState, BrainItem, ChatMessage, ModelProvider, WorkspaceFile, WorkspaceState } from "@coworker/core";
|
||||
import { createProvider } from "@coworker/core";
|
||||
import { coworkerApi, displayBrainItem } from "./api";
|
||||
import "./styles.css";
|
||||
|
||||
type View = "agent" | "models" | "brain" | "files" | "settings";
|
||||
|
||||
const emptyState: AppState = {
|
||||
providers: [],
|
||||
brain: [],
|
||||
workspaces: [],
|
||||
webAccess: { enabled: false, bindHost: "127.0.0.1", port: 4789, requirePairing: true },
|
||||
pairedDevices: []
|
||||
};
|
||||
|
||||
function App() {
|
||||
const [state, setState] = useState<AppState>(emptyState);
|
||||
const [view, setView] = useState<View>("agent");
|
||||
const [error, setError] = useState("");
|
||||
const [workspace, setWorkspace] = useState<WorkspaceState | undefined>();
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
setState(await coworkerApi.state());
|
||||
setError("");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load Coworker state.");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
if ("serviceWorker" in navigator) void navigator.serviceWorker.register("/sw.js").catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setWorkspace(state.workspaces[0]);
|
||||
}, [state.workspaces]);
|
||||
|
||||
const activeProvider = state.providers.find((provider) => provider.enabled) ?? state.providers[0];
|
||||
|
||||
return (
|
||||
<main className="shell">
|
||||
<aside className="sidebar">
|
||||
<div className="brand">
|
||||
<span className="brand-mark">C</span>
|
||||
<div>
|
||||
<strong>Coworker</strong>
|
||||
<span>Local coding app</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="nav">
|
||||
{(["agent", "files", "models", "brain", "settings"] as View[]).map((item) => (
|
||||
<button key={item} className={view === item ? "active" : ""} onClick={() => setView(item)}>
|
||||
{label(item)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className="status">
|
||||
<span>Model</span>
|
||||
<strong>{activeProvider?.name ?? "Not configured"}</strong>
|
||||
</div>
|
||||
<button className="primary" onClick={async () => setWorkspace(await coworkerApi.openWorkspace())}>
|
||||
Open project
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<section className="content">
|
||||
<header className="topbar">
|
||||
<div>
|
||||
<h1>{label(view)}</h1>
|
||||
<p>{subtitle(view, workspace)}</p>
|
||||
</div>
|
||||
{error ? <span className="error">{error}</span> : <span className="ok">Ready</span>}
|
||||
</header>
|
||||
|
||||
{view === "agent" && <AgentView state={state} workspace={workspace} onError={setError} />}
|
||||
{view === "files" && <FilesView workspace={workspace} onError={setError} />}
|
||||
{view === "models" && <ModelsView providers={state.providers} onState={setState} onError={setError} />}
|
||||
{view === "brain" && <BrainView items={state.brain} onState={setState} onError={setError} />}
|
||||
{view === "settings" && <SettingsView state={state} onState={setState} onError={setError} />}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentView({ state, workspace, onError }: { state: AppState; workspace?: WorkspaceState; onError: (value: string) => void }) {
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [running, setRunning] = useState(false);
|
||||
const provider = state.providers.find((item) => item.enabled) ?? state.providers[0];
|
||||
const model = provider?.defaults.coding ?? provider?.defaults.chat;
|
||||
|
||||
async function run() {
|
||||
if (!prompt.trim()) return;
|
||||
setRunning(true);
|
||||
const userMessage: ChatMessage = {
|
||||
id: `local_${Date.now()}`,
|
||||
role: "user",
|
||||
content: prompt,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
setMessages((current) => [...current, userMessage]);
|
||||
setPrompt("");
|
||||
try {
|
||||
const result = await coworkerApi.runAgent({ prompt, providerId: provider?.id, model, workspace, messages });
|
||||
setMessages((current) => [...current, result.assistantMessage]);
|
||||
onError("");
|
||||
} catch (err) {
|
||||
onError(err instanceof Error ? err.message : "Agent failed.");
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="agent-grid">
|
||||
<section className="conversation">
|
||||
{messages.length === 0 ? (
|
||||
<div className="empty">
|
||||
<h2>Start a coding session</h2>
|
||||
<p>Open a project, pick a local model, and ask Coworker to inspect, plan, or propose changes.</p>
|
||||
</div>
|
||||
) : messages.map((message) => (
|
||||
<article key={message.id} className={`message ${message.role}`}>
|
||||
<span>{message.role}</span>
|
||||
<pre>{message.content}</pre>
|
||||
</article>
|
||||
))}
|
||||
<div className="composer">
|
||||
<textarea value={prompt} onChange={(event) => setPrompt(event.target.value)} placeholder="Ask Coworker to work on this project..." />
|
||||
<button className="primary" disabled={running} onClick={() => void run()}>{running ? "Running" : "Send"}</button>
|
||||
</div>
|
||||
</section>
|
||||
<aside className="inspector">
|
||||
<h2>Session</h2>
|
||||
<dl>
|
||||
<dt>Project</dt>
|
||||
<dd>{workspace?.name ?? "None"}</dd>
|
||||
<dt>Provider</dt>
|
||||
<dd>{provider?.name ?? "None"}</dd>
|
||||
<dt>Model</dt>
|
||||
<dd>{model ?? "Choose in Models"}</dd>
|
||||
</dl>
|
||||
<h2>Brain recall</h2>
|
||||
<ul className="compact-list">
|
||||
{state.brain.filter((item) => item.pinned).slice(0, 6).map((item) => <li key={item.id}>{item.title}</li>)}
|
||||
</ul>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilesView({ workspace, onError }: { workspace?: WorkspaceState; onError: (value: string) => void }) {
|
||||
const [files, setFiles] = useState<WorkspaceFile[]>([]);
|
||||
const [preview, setPreview] = useState("");
|
||||
useEffect(() => {
|
||||
if (!workspace) return;
|
||||
coworkerApi.listWorkspace(workspace.id).then(setFiles).catch((err) => onError(err instanceof Error ? err.message : "File list failed."));
|
||||
}, [workspace, onError]);
|
||||
return (
|
||||
<div className="split">
|
||||
<section className="list-panel">
|
||||
{files.map((file) => (
|
||||
<button key={file.path} onClick={async () => {
|
||||
if (file.type === "directory" || !workspace) return;
|
||||
setPreview(await coworkerApi.readWorkspace(workspace.id, file.path));
|
||||
}}>
|
||||
<span>{file.type === "directory" ? "DIR" : "FILE"}</span>
|
||||
{file.path}
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
<pre className="preview">{preview || "Open a text file to preview it."}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelsView({ providers, onState, onError }: { providers: ModelProvider[]; onState: (state: AppState) => void; onError: (value: string) => void }) {
|
||||
const [draft, setDraft] = useState<ModelProvider>(() => createProvider("openai-compatible", "Custom endpoint", "http://localhost:8000/v1"));
|
||||
const [probe, setProbe] = useState("");
|
||||
return (
|
||||
<div className="settings-grid">
|
||||
<section>
|
||||
<h2>Providers</h2>
|
||||
<div className="provider-list">
|
||||
{providers.map((provider) => (
|
||||
<article key={provider.id}>
|
||||
<strong>{provider.name}</strong>
|
||||
<span>{provider.baseUrl}</span>
|
||||
<button onClick={async () => {
|
||||
const result = await coworkerApi.probeProvider(provider.id);
|
||||
setProbe(`${result.message} ${result.models.join(", ")}`);
|
||||
}}>Test</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Add endpoint</h2>
|
||||
<input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} />
|
||||
<input value={draft.baseUrl} onChange={(event) => setDraft({ ...draft, baseUrl: event.target.value })} />
|
||||
<button className="primary" onClick={async () => {
|
||||
try {
|
||||
onState(await coworkerApi.saveProvider(draft));
|
||||
setDraft(createProvider("openai-compatible", "Custom endpoint", "http://localhost:8000/v1"));
|
||||
} catch (err) {
|
||||
onError(err instanceof Error ? err.message : "Provider save failed.");
|
||||
}
|
||||
}}>Save provider</button>
|
||||
<p className="muted">{probe || "Ollama and LM Studio defaults are created on first launch."}</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BrainView({ items, onState, onError }: { items: BrainItem[]; onState: (state: AppState) => void; onError: (value: string) => void }) {
|
||||
const [title, setTitle] = useState("");
|
||||
const [body, setBody] = useState("");
|
||||
const [kind, setKind] = useState<"memory" | "skill">("memory");
|
||||
return (
|
||||
<div className="settings-grid">
|
||||
<section>
|
||||
<h2>Brain</h2>
|
||||
<div className="brain-list">
|
||||
{items.map((item) => (
|
||||
<article key={item.id}>
|
||||
<span>{displayBrainItem(item)}</span>
|
||||
<strong>{item.title}</strong>
|
||||
<p>{item.body}</p>
|
||||
<button onClick={async () => onState(await coworkerApi.removeBrain(item.id))}>Remove</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Add Brain item</h2>
|
||||
<div className="segmented">
|
||||
<button className={kind === "memory" ? "active" : ""} onClick={() => setKind("memory")}>Memory</button>
|
||||
<button className={kind === "skill" ? "active" : ""} onClick={() => setKind("skill")}>Skill</button>
|
||||
</div>
|
||||
<input value={title} placeholder="Title" onChange={(event) => setTitle(event.target.value)} />
|
||||
<textarea value={body} placeholder="What should Coworker remember or know how to do?" onChange={(event) => setBody(event.target.value)} />
|
||||
<button className="primary" onClick={async () => {
|
||||
try {
|
||||
onState(await coworkerApi.addBrain({ kind, scope: "global", title, body, pinned: kind === "skill" }));
|
||||
setTitle("");
|
||||
setBody("");
|
||||
} catch (err) {
|
||||
onError(err instanceof Error ? err.message : "Brain save failed.");
|
||||
}
|
||||
}}>Save</button>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsView({ state, onState, onError }: { state: AppState; onState: (state: AppState) => void; onError: (value: string) => void }) {
|
||||
const [settings, setSettings] = useState(state.webAccess);
|
||||
const [pairing, setPairing] = useState("");
|
||||
const url = useMemo(() => `http://${settings.bindHost === "0.0.0.0" ? "localhost" : settings.bindHost}:${settings.port}`, [settings]);
|
||||
return (
|
||||
<div className="settings-grid">
|
||||
<section>
|
||||
<h2>Web access</h2>
|
||||
<label className="switch"><input type="checkbox" checked={settings.enabled} onChange={(event) => setSettings({ ...settings, enabled: event.target.checked })} /> Enable Web/PWA access</label>
|
||||
<label>Bind host</label>
|
||||
<select value={settings.bindHost} onChange={(event) => setSettings({ ...settings, bindHost: event.target.value as "127.0.0.1" | "0.0.0.0" })}>
|
||||
<option value="127.0.0.1">Localhost only</option>
|
||||
<option value="0.0.0.0">LAN / Tailscale</option>
|
||||
</select>
|
||||
<label>Port</label>
|
||||
<input type="number" value={settings.port} min={1024} max={65535} onChange={(event) => setSettings({ ...settings, port: Number(event.target.value) })} />
|
||||
<button className="primary" onClick={async () => {
|
||||
try {
|
||||
onState(await coworkerApi.saveWebSettings(settings));
|
||||
} catch (err) {
|
||||
onError(err instanceof Error ? err.message : "Web access update failed.");
|
||||
}
|
||||
}}>Apply</button>
|
||||
<p className="muted">Open {url} on iPhone or iPad after enabling LAN access.</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Pair device</h2>
|
||||
<button onClick={async () => {
|
||||
const challenge = await coworkerApi.createPairing();
|
||||
setPairing(`${challenge.code} expires ${new Date(challenge.expiresAt).toLocaleTimeString()}`);
|
||||
}}>Create pairing code</button>
|
||||
<div className="pairing-code">{pairing || "No active code"}</div>
|
||||
<p className="muted">The mobile webapp stores a scoped device session after pairing.</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function label(view: View) {
|
||||
return ({ agent: "Agent", models: "Models", brain: "Brain", files: "Files", settings: "Settings" })[view];
|
||||
}
|
||||
|
||||
function subtitle(view: View, workspace?: WorkspaceState) {
|
||||
if (view === "agent") return workspace ? `Working in ${workspace.name}` : "Open a project to start.";
|
||||
if (view === "models") return "Connect Ollama, LM Studio, or OpenAI-compatible endpoints.";
|
||||
if (view === "brain") return "Persistent memory, project knowledge, and reusable skills.";
|
||||
if (view === "files") return workspace ? workspace.rootPath : "Open a project to inspect files.";
|
||||
return "Desktop, web access, pairing, and release-safe defaults.";
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<App />);
|
||||
461
apps/desktop/src/styles.css
Normal file
461
apps/desktop/src/styles.css
Normal file
@@ -0,0 +1,461 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: #111317;
|
||||
color: #f4f6f8;
|
||||
--bg: #111317;
|
||||
--panel: #181b20;
|
||||
--panel-2: #20242b;
|
||||
--line: #303640;
|
||||
--muted: #9ba6b3;
|
||||
--text: #f4f6f8;
|
||||
--accent: #7dd3fc;
|
||||
--accent-2: #84cc16;
|
||||
--danger: #fb7185;
|
||||
--radius: 8px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--panel-2);
|
||||
color: var(--text);
|
||||
min-height: 40px;
|
||||
padding: 0 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: #0f1115;
|
||||
color: var(--text);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 120px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: 260px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
border-right: 1px solid var(--line);
|
||||
background: #0e1014;
|
||||
padding: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: #15202a;
|
||||
color: var(--accent);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.brand strong,
|
||||
.brand span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.brand span,
|
||||
.muted,
|
||||
.status span,
|
||||
.message span,
|
||||
dt {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.nav button {
|
||||
justify-content: flex-start;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.nav button.active,
|
||||
.segmented button.active {
|
||||
border-color: var(--accent);
|
||||
background: #142333;
|
||||
}
|
||||
|
||||
.status {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
background: var(--panel);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.status strong {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.primary {
|
||||
border-color: #24506a;
|
||||
background: #123047;
|
||||
color: #e6f7ff;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
min-height: 86px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 18px 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 4px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 15px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.topbar p {
|
||||
margin-bottom: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.ok,
|
||||
.error {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 7px 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ok {
|
||||
color: var(--accent-2);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.agent-grid,
|
||||
.settings-grid,
|
||||
.split {
|
||||
min-height: 0;
|
||||
padding: 18px;
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.agent-grid {
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
}
|
||||
|
||||
.settings-grid,
|
||||
.split {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(320px, 420px);
|
||||
}
|
||||
|
||||
.conversation,
|
||||
.inspector,
|
||||
.settings-grid section,
|
||||
.list-panel,
|
||||
.preview {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.conversation {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.empty {
|
||||
align-self: center;
|
||||
justify-self: center;
|
||||
max-width: 520px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.message {
|
||||
margin: 14px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: #12161c;
|
||||
}
|
||||
|
||||
.message.assistant {
|
||||
background: #151d20;
|
||||
}
|
||||
|
||||
.message pre,
|
||||
.preview {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
overflow: auto;
|
||||
font-family: "Fira Code", ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.composer {
|
||||
border-top: 1px solid var(--line);
|
||||
padding: 12px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.composer textarea {
|
||||
min-height: 58px;
|
||||
max-height: 180px;
|
||||
}
|
||||
|
||||
.inspector,
|
||||
.settings-grid section {
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
dl {
|
||||
display: grid;
|
||||
grid-template-columns: 86px minmax(0, 1fr);
|
||||
gap: 8px 12px;
|
||||
margin: 0 0 22px;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.compact-list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.provider-list,
|
||||
.brain-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.provider-list article,
|
||||
.brain-list article {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
background: #12161c;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.provider-list span,
|
||||
.brain-list span {
|
||||
color: var(--muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.brain-list p {
|
||||
margin-bottom: 0;
|
||||
color: #d6dbe1;
|
||||
}
|
||||
|
||||
.segmented {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.settings-grid section > * + * {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.pairing-code {
|
||||
min-height: 72px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: #101319;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 20px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.list-panel {
|
||||
padding: 10px;
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.list-panel button {
|
||||
display: grid;
|
||||
grid-template-columns: 48px minmax(0, 1fr);
|
||||
text-align: left;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.list-panel span {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.preview {
|
||||
padding: 14px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 880px) {
|
||||
.shell {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
grid-row: 2;
|
||||
border-right: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
padding: 8px max(8px, env(safe-area-inset-left)) max(8px, env(safe-area-inset-bottom)) max(8px, env(safe-area-inset-right));
|
||||
}
|
||||
|
||||
.brand,
|
||||
.status,
|
||||
.sidebar .primary {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav {
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.nav button {
|
||||
min-height: 48px;
|
||||
padding: 0 6px;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.content {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
min-height: 72px;
|
||||
padding: calc(12px + env(safe-area-inset-top)) 14px 12px;
|
||||
}
|
||||
|
||||
.topbar p,
|
||||
.ok,
|
||||
.error {
|
||||
display: none;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.agent-grid,
|
||||
.settings-grid,
|
||||
.split {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.inspector {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.composer {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.settings-grid,
|
||||
.split {
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
13
apps/desktop/tsconfig.electron.json
Normal file
13
apps/desktop/tsconfig.electron.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"noEmit": false,
|
||||
"outDir": "dist-electron",
|
||||
"rootDir": "electron",
|
||||
"types": ["node"],
|
||||
"lib": ["ES2022", "DOM"]
|
||||
},
|
||||
"include": ["electron/**/*.ts"]
|
||||
}
|
||||
16
apps/desktop/tsconfig.json
Normal file
16
apps/desktop/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@coworker/core": ["../../packages/core/src"],
|
||||
"@coworker/core/*": ["../../packages/core/src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
"electron/**/*.ts",
|
||||
"vite.config.ts"
|
||||
]
|
||||
}
|
||||
21
apps/desktop/vite.config.ts
Normal file
21
apps/desktop/vite.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
|
||||
export default defineConfig({
|
||||
root: fileURLToPath(new URL(".", import.meta.url)),
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@coworker/core": fileURLToPath(new URL("../../packages/core/src", import.meta.url))
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
emptyOutDir: true
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user