add desktop codex login and analyst copilot
This commit is contained in:
parent
fb25b5ac97
commit
a8d183f38e
24 changed files with 4127 additions and 97 deletions
8
apps/desktop/src/desktop-ai-ipc.ts
Normal file
8
apps/desktop/src/desktop-ai-ipc.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export const DESKTOP_AI_STATE_CHANNEL = "islandflow:desktop-ai:state";
|
||||
export const DESKTOP_AI_GET_STATE = "islandflow:desktop-ai:get-state";
|
||||
export const DESKTOP_AI_LOGIN_BROWSER = "islandflow:desktop-ai:login-browser";
|
||||
export const DESKTOP_AI_LOGIN_DEVICE = "islandflow:desktop-ai:login-device";
|
||||
export const DESKTOP_AI_CANCEL_LOGIN = "islandflow:desktop-ai:cancel-login";
|
||||
export const DESKTOP_AI_LOGOUT = "islandflow:desktop-ai:logout";
|
||||
export const DESKTOP_AI_UPDATE_PREFERENCES = "islandflow:desktop-ai:update-preferences";
|
||||
export const DESKTOP_AI_RUN_TASK = "islandflow:desktop-ai:run-task";
|
||||
174
apps/desktop/src/desktop-ai.test.ts
Normal file
174
apps/desktop/src/desktop-ai.test.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { createAppServerChildEnv, IslandflowDesktopAiService, summarizeRateLimit } from "./desktop-ai.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
const makeTempDir = async (): Promise<string> => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "islandflow-desktop-ai-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
};
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))
|
||||
);
|
||||
});
|
||||
|
||||
describe("desktop ai auth environment", () => {
|
||||
it("scrubs global OpenAI keys for managed ChatGPT sessions", () => {
|
||||
const env = createAppServerChildEnv("managed-chatgpt", {
|
||||
OPENAI_API_KEY: "openai-test",
|
||||
CODEX_API_KEY: "codex-test",
|
||||
HOME: "/tmp/home"
|
||||
});
|
||||
|
||||
expect(env.OPENAI_API_KEY).toBeUndefined();
|
||||
expect(env.CODEX_API_KEY).toBeUndefined();
|
||||
expect(env.HOME).toBe("/tmp/home");
|
||||
});
|
||||
|
||||
it("preserves keys for api-key mode", () => {
|
||||
const env = createAppServerChildEnv("api-key", {
|
||||
OPENAI_API_KEY: "openai-test",
|
||||
CODEX_API_KEY: "codex-test"
|
||||
});
|
||||
|
||||
expect(env.OPENAI_API_KEY).toBe("openai-test");
|
||||
expect(env.CODEX_API_KEY).toBe("codex-test");
|
||||
});
|
||||
});
|
||||
|
||||
describe("desktop ai usage and state tracking", () => {
|
||||
it("records exact token usage notifications into usage rollups", async () => {
|
||||
const dir = await makeTempDir();
|
||||
const service = new IslandflowDesktopAiService(dir, async () => {}, () => {});
|
||||
const internal = service as any;
|
||||
|
||||
internal.state.account.email = "analyst@example.com";
|
||||
internal.state.account.planType = "plus";
|
||||
internal.state.preferences.model = "gpt-5.4";
|
||||
internal.state.tasks = [
|
||||
{
|
||||
taskId: "task-1",
|
||||
kind: "smart-money-explain",
|
||||
title: "Explain smart money event",
|
||||
subtitle: "AAPL",
|
||||
status: "running",
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
model: "gpt-5.4",
|
||||
reasoningEffort: "high",
|
||||
text: "",
|
||||
error: null,
|
||||
compiledScreen: null
|
||||
}
|
||||
];
|
||||
internal.activeTasksByThreadId.set("thread-1", {
|
||||
taskId: "task-1",
|
||||
taskKind: "smart-money-explain",
|
||||
taskTitle: "Explain smart money event",
|
||||
profileId: "managed-chatgpt"
|
||||
});
|
||||
|
||||
await internal.handleNotification("thread/tokenUsage/updated", {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
tokenUsage: {
|
||||
total: {
|
||||
totalTokens: 1800,
|
||||
inputTokens: 1000,
|
||||
cachedInputTokens: 500,
|
||||
outputTokens: 250,
|
||||
reasoningOutputTokens: 50
|
||||
},
|
||||
last: {
|
||||
totalTokens: 1800,
|
||||
inputTokens: 1000,
|
||||
cachedInputTokens: 500,
|
||||
outputTokens: 250,
|
||||
reasoningOutputTokens: 50
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
expect(service.getState().usage.today.breakdown).toEqual({
|
||||
totalTokens: 1800,
|
||||
inputTokens: 1000,
|
||||
cachedInputTokens: 500,
|
||||
outputTokens: 250,
|
||||
reasoningOutputTokens: 50
|
||||
});
|
||||
expect(service.getState().usage.today.turnCount).toBe(1);
|
||||
expect(service.getState().usage.recentTurns[0]?.normalizedCostUsd).toBeCloseTo(0.007125, 6);
|
||||
});
|
||||
|
||||
it("stores rate-limit snapshots with reset times", async () => {
|
||||
const dir = await makeTempDir();
|
||||
const service = new IslandflowDesktopAiService(dir, async () => {}, () => {});
|
||||
const internal = service as any;
|
||||
|
||||
await internal.handleNotification("account/rateLimits/updated", {
|
||||
rateLimits: {
|
||||
limitId: "chatgpt_plus",
|
||||
limitName: "ChatGPT Plus",
|
||||
primary: {
|
||||
usedPercent: 38.4,
|
||||
windowDurationMins: 180,
|
||||
resetsAt: 1_710_000_000_000
|
||||
},
|
||||
secondary: {
|
||||
usedPercent: 12.1,
|
||||
windowDurationMins: 1440,
|
||||
resetsAt: 1_710_003_600_000
|
||||
},
|
||||
planType: "plus"
|
||||
}
|
||||
});
|
||||
|
||||
expect(service.getState().rateLimitsByLimitId.chatgpt_plus).toEqual(
|
||||
summarizeRateLimit({
|
||||
limitId: "chatgpt_plus",
|
||||
limitName: "ChatGPT Plus",
|
||||
primary: {
|
||||
usedPercent: 38.4,
|
||||
windowDurationMins: 180,
|
||||
resetsAt: 1_710_000_000_000
|
||||
},
|
||||
secondary: {
|
||||
usedPercent: 12.1,
|
||||
windowDurationMins: 1440,
|
||||
resetsAt: 1_710_003_600_000
|
||||
},
|
||||
planType: "plus"
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("clears local account state on logout", async () => {
|
||||
const dir = await makeTempDir();
|
||||
const service = new IslandflowDesktopAiService(dir, async () => {}, () => {});
|
||||
const internal = service as any;
|
||||
|
||||
internal.client = {
|
||||
request: async () => ({})
|
||||
};
|
||||
internal.state.account.loggedIn = true;
|
||||
internal.state.account.email = "analyst@example.com";
|
||||
internal.state.account.planType = "plus";
|
||||
internal.state.account.login = { status: "browser_pending", message: "Waiting", loginId: "login-1", authUrl: "https://example.com" };
|
||||
|
||||
await service.logout();
|
||||
|
||||
expect(service.getState().account.loggedIn).toBe(false);
|
||||
expect(service.getState().account.email).toBeNull();
|
||||
expect(service.getState().account.planType).toBeNull();
|
||||
expect(service.getState().account.login).toEqual({ status: "idle", message: "Logged out." });
|
||||
});
|
||||
});
|
||||
1222
apps/desktop/src/desktop-ai.ts
Normal file
1222
apps/desktop/src/desktop-ai.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,6 @@
|
|||
import { app, BrowserWindow, shell } from "electron";
|
||||
import type { Event as ElectronEvent } from "electron";
|
||||
import { app, BrowserWindow, ipcMain, shell } from "electron";
|
||||
import type { Event as ElectronEvent, IpcMainInvokeEvent } from "electron";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
DESKTOP_PRODUCTION_URL,
|
||||
|
|
@ -7,11 +8,25 @@ import {
|
|||
isTrustedAppUrl,
|
||||
resolveDesktopStartUrl
|
||||
} from "./security.js";
|
||||
import { IslandflowDesktopAiService } from "./desktop-ai.js";
|
||||
import {
|
||||
DESKTOP_AI_CANCEL_LOGIN,
|
||||
DESKTOP_AI_GET_STATE,
|
||||
DESKTOP_AI_LOGIN_BROWSER,
|
||||
DESKTOP_AI_LOGIN_DEVICE,
|
||||
DESKTOP_AI_LOGOUT,
|
||||
DESKTOP_AI_RUN_TASK,
|
||||
DESKTOP_AI_STATE_CHANNEL,
|
||||
DESKTOP_AI_UPDATE_PREFERENCES
|
||||
} from "./desktop-ai-ipc.js";
|
||||
|
||||
const WINDOW_BACKGROUND_COLOR = "#06080b";
|
||||
const WINDOW_TITLE = "Islandflow";
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let desktopAiService: IslandflowDesktopAiService | null = null;
|
||||
|
||||
const PRELOAD_PATH = fileURLToPath(new URL("./preload.js", import.meta.url));
|
||||
|
||||
const canOpenExternalUrl = (sourceUrl: string, targetUrl: string): boolean => {
|
||||
return isTrustedAppUrl(sourceUrl) && isSafeExternalUrl(targetUrl);
|
||||
|
|
@ -61,6 +76,7 @@ const createMainWindow = (): BrowserWindow => {
|
|||
title: WINDOW_TITLE,
|
||||
backgroundColor: WINDOW_BACKGROUND_COLOR,
|
||||
webPreferences: {
|
||||
preload: PRELOAD_PATH,
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
|
|
@ -92,6 +108,68 @@ const createMainWindow = (): BrowserWindow => {
|
|||
return window;
|
||||
};
|
||||
|
||||
const broadcastDesktopAiState = (): void => {
|
||||
if (!desktopAiService) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = desktopAiService.getState();
|
||||
for (const window of BrowserWindow.getAllWindows()) {
|
||||
window.webContents.send(DESKTOP_AI_STATE_CHANNEL, state);
|
||||
}
|
||||
};
|
||||
|
||||
const getTrustedSenderUrl = (event: IpcMainInvokeEvent): string => {
|
||||
const senderUrl = event.senderFrame?.url || event.sender.getURL();
|
||||
if (!isTrustedAppUrl(senderUrl)) {
|
||||
throw new Error(`Rejected desktop AI IPC from untrusted origin: ${senderUrl || "unknown"}`);
|
||||
}
|
||||
|
||||
return senderUrl;
|
||||
};
|
||||
|
||||
const registerDesktopAiIpc = (service: IslandflowDesktopAiService): void => {
|
||||
const guard = (event: IpcMainInvokeEvent): void => {
|
||||
getTrustedSenderUrl(event);
|
||||
};
|
||||
|
||||
ipcMain.handle(DESKTOP_AI_GET_STATE, async (event) => {
|
||||
guard(event);
|
||||
await service.start();
|
||||
return service.getState();
|
||||
});
|
||||
|
||||
ipcMain.handle(DESKTOP_AI_LOGIN_BROWSER, async (event) => {
|
||||
guard(event);
|
||||
await service.loginWithBrowser();
|
||||
});
|
||||
|
||||
ipcMain.handle(DESKTOP_AI_LOGIN_DEVICE, async (event) => {
|
||||
guard(event);
|
||||
await service.loginWithDeviceCode();
|
||||
});
|
||||
|
||||
ipcMain.handle(DESKTOP_AI_CANCEL_LOGIN, async (event) => {
|
||||
guard(event);
|
||||
await service.cancelLogin();
|
||||
});
|
||||
|
||||
ipcMain.handle(DESKTOP_AI_LOGOUT, async (event) => {
|
||||
guard(event);
|
||||
await service.logout();
|
||||
});
|
||||
|
||||
ipcMain.handle(DESKTOP_AI_UPDATE_PREFERENCES, async (event, next) => {
|
||||
guard(event);
|
||||
await service.updatePreferences(next);
|
||||
});
|
||||
|
||||
ipcMain.handle(DESKTOP_AI_RUN_TASK, async (event, request) => {
|
||||
guard(event);
|
||||
return service.runTask(request);
|
||||
});
|
||||
};
|
||||
|
||||
const ensureMainWindow = (): void => {
|
||||
if (mainWindow) {
|
||||
return;
|
||||
|
|
@ -101,6 +179,20 @@ const ensureMainWindow = (): void => {
|
|||
};
|
||||
|
||||
app.whenReady().then(() => {
|
||||
desktopAiService = new IslandflowDesktopAiService(
|
||||
app.getPath("userData"),
|
||||
async (url) => {
|
||||
await shell.openExternal(url);
|
||||
},
|
||||
() => {
|
||||
broadcastDesktopAiState();
|
||||
}
|
||||
);
|
||||
registerDesktopAiIpc(desktopAiService);
|
||||
void desktopAiService.start().catch((error) => {
|
||||
console.error("[desktop-ai] Failed to start Codex bridge:", error);
|
||||
broadcastDesktopAiState();
|
||||
});
|
||||
ensureMainWindow();
|
||||
|
||||
app.on("activate", () => {
|
||||
|
|
|
|||
43
apps/desktop/src/preload.ts
Normal file
43
apps/desktop/src/preload.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import type {
|
||||
IslandflowAiReasoningEffort,
|
||||
IslandflowAiState,
|
||||
IslandflowAiTaskRequest
|
||||
} from "@islandflow/types";
|
||||
import {
|
||||
DESKTOP_AI_CANCEL_LOGIN,
|
||||
DESKTOP_AI_GET_STATE,
|
||||
DESKTOP_AI_LOGIN_BROWSER,
|
||||
DESKTOP_AI_LOGIN_DEVICE,
|
||||
DESKTOP_AI_LOGOUT,
|
||||
DESKTOP_AI_RUN_TASK,
|
||||
DESKTOP_AI_STATE_CHANNEL,
|
||||
DESKTOP_AI_UPDATE_PREFERENCES
|
||||
} from "./desktop-ai-ipc.js";
|
||||
|
||||
const bridge = {
|
||||
ai: {
|
||||
getState: (): Promise<IslandflowAiState> => ipcRenderer.invoke(DESKTOP_AI_GET_STATE),
|
||||
loginWithBrowser: (): Promise<void> => ipcRenderer.invoke(DESKTOP_AI_LOGIN_BROWSER),
|
||||
loginWithDeviceCode: (): Promise<void> => ipcRenderer.invoke(DESKTOP_AI_LOGIN_DEVICE),
|
||||
cancelLogin: (): Promise<void> => ipcRenderer.invoke(DESKTOP_AI_CANCEL_LOGIN),
|
||||
logout: (): Promise<void> => ipcRenderer.invoke(DESKTOP_AI_LOGOUT),
|
||||
updatePreferences: (
|
||||
next: Partial<{ model: string | null; reasoningEffort: IslandflowAiReasoningEffort | null }>
|
||||
): Promise<void> => ipcRenderer.invoke(DESKTOP_AI_UPDATE_PREFERENCES, next),
|
||||
runTask: (request: IslandflowAiTaskRequest): Promise<{ taskId: string }> =>
|
||||
ipcRenderer.invoke(DESKTOP_AI_RUN_TASK, request),
|
||||
subscribe: (listener: (state: IslandflowAiState) => void): (() => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, state: IslandflowAiState) => {
|
||||
listener(state);
|
||||
};
|
||||
|
||||
ipcRenderer.on(DESKTOP_AI_STATE_CHANNEL, handler);
|
||||
return () => {
|
||||
ipcRenderer.off(DESKTOP_AI_STATE_CHANNEL, handler);
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld("islandflowDesktop", bridge);
|
||||
Loading…
Add table
Add a link
Reference in a new issue