islandflow/apps/web/app/desktop-ai.tsx

354 lines
10 KiB
TypeScript

"use client";
import {
createContext,
useContext,
useEffect,
useMemo,
useState,
type ReactNode
} from "react";
import type {
IslandflowAiReasoningEffort,
IslandflowAiState,
IslandflowAiTaskRequest
} from "@islandflow/types";
type DesktopAiBridge = {
ai: {
getState: () => Promise<IslandflowAiState>;
loginWithBrowser: () => Promise<void>;
loginWithDeviceCode: () => Promise<void>;
cancelLogin: () => Promise<void>;
logout: () => Promise<void>;
updatePreferences: (
next: Partial<{ model: string | null; reasoningEffort: IslandflowAiReasoningEffort | null }>
) => Promise<void>;
runTask: (request: IslandflowAiTaskRequest) => Promise<{ taskId: string }>;
cancelTask: (taskId: string) => Promise<void>;
subscribe: (listener: (state: IslandflowAiState) => void) => () => void;
};
};
type DesktopAiRuntime = {
shellAvailable: boolean;
bridgeAvailable: boolean;
bridge: DesktopAiBridge | null;
};
const BROWSER_RUNTIME: DesktopAiRuntime = {
shellAvailable: false,
bridgeAvailable: false,
bridge: null
};
declare global {
interface Window {
islandflowDesktopRuntime?: {
app?: string | null;
shell?: string | null;
};
islandflowDesktop?: DesktopAiBridge;
}
}
type DesktopAiContextValue = {
bridgeAvailable: boolean;
shellAvailable: boolean;
state: IslandflowAiState;
loginWithBrowser: () => Promise<void>;
loginWithDeviceCode: () => Promise<void>;
cancelLogin: () => Promise<void>;
logout: () => Promise<void>;
updatePreferences: (
next: Partial<{ model: string | null; reasoningEffort: IslandflowAiReasoningEffort | null }>
) => Promise<void>;
runTask: (request: IslandflowAiTaskRequest) => Promise<{ taskId: string }>;
cancelTask: (taskId: string) => Promise<void>;
};
const BRIDGE_POLL_INTERVAL_MS = 250;
const BRIDGE_POLL_MAX_ATTEMPTS = 20;
const ELECTRON_USER_AGENT_PATTERN = /\b(?:Electron|IslandflowDesktop)\/\S+/i;
export const detectDesktopShell = (userAgent: string | null | undefined): boolean =>
Boolean(userAgent && ELECTRON_USER_AGENT_PATTERN.test(userAgent));
export const detectDesktopRuntimeMarker = (
runtime:
| {
app?: string | null;
shell?: string | null;
}
| null
| undefined
): boolean => runtime?.app === "islandflow" && runtime.shell === "electron";
export const resolveDesktopAiRuntime = (
value:
| {
islandflowDesktopRuntime?: {
app?: string | null;
shell?: string | null;
};
islandflowDesktop?: DesktopAiBridge;
navigator?: { userAgent?: string | null };
}
| null
| undefined
): DesktopAiRuntime => {
const bridge = value?.islandflowDesktop?.ai ? value.islandflowDesktop : null;
const bridgeAvailable = Boolean(bridge?.ai);
const shellAvailable =
bridgeAvailable ||
detectDesktopRuntimeMarker(value?.islandflowDesktopRuntime) ||
detectDesktopShell(value?.navigator?.userAgent);
return {
shellAvailable,
bridgeAvailable,
bridge
};
};
export const resolveCurrentDesktopAiRuntime = (): DesktopAiRuntime =>
typeof window === "undefined" ? BROWSER_RUNTIME : resolveDesktopAiRuntime(window);
const isSameDesktopAiRuntime = (
left: DesktopAiRuntime,
right: DesktopAiRuntime
): boolean =>
left.shellAvailable === right.shellAvailable &&
left.bridgeAvailable === right.bridgeAvailable &&
left.bridge === right.bridge;
export const createUnavailableState = (runtime?: Partial<DesktopAiRuntime>): IslandflowAiState => {
const shellAvailable = Boolean(runtime?.shellAvailable || runtime?.bridgeAvailable);
const bridgeAvailable = Boolean(runtime?.bridgeAvailable);
const transportError = !shellAvailable
? "Desktop AI is only available inside the Islandflow Electron app."
: bridgeAvailable
? "The desktop AI bridge loaded, but its initial state could not be read."
: "Islandflow Desktop is open, but the native AI bridge is unavailable in this session.";
const loginMessage = !shellAvailable
? "Open Islandflow Desktop to connect a ChatGPT or Codex account."
: bridgeAvailable
? "The desktop bridge connected, but its initial state did not load. Retry the action or restart Islandflow if this persists."
: "This desktop window is missing its native AI bridge. Reload the window or restart Islandflow if this persists.";
return {
desktopAvailable: shellAvailable,
transportStatus: shellAvailable ? "error" : "stopped",
transportError,
profiles: [
{
id: "managed-chatgpt",
label: "Managed ChatGPT login",
description: shellAvailable
? "Managed ChatGPT login belongs to the desktop shell, but this window is not connected to the native bridge yet."
: "Available only in the desktop app.",
mode: "managed-chatgpt",
enabled: shellAvailable,
selected: true,
statusLabel: shellAvailable ? "Bridge unavailable" : "Desktop only"
}
],
selectedProfileId: "managed-chatgpt",
account: {
loggedIn: false,
email: null,
planType: null,
authMode: null,
requiresOpenaiAuth: true,
login: {
status: "idle",
message: loginMessage
}
},
preferences: {
model: null,
reasoningEffort: "high"
},
models: [],
rateLimitsByLimitId: {},
usage: {
today: {
breakdown: {
totalTokens: 0,
inputTokens: 0,
cachedInputTokens: 0,
outputTokens: 0,
reasoningOutputTokens: 0
},
normalizedCostUsd: 0,
turnCount: 0,
activeDays: 0
},
lifetime: {
breakdown: {
totalTokens: 0,
inputTokens: 0,
cachedInputTokens: 0,
outputTokens: 0,
reasoningOutputTokens: 0
},
normalizedCostUsd: 0,
turnCount: 0,
activeDays: 0
},
recentTurns: []
},
tasks: [],
updatedAt: Date.now()
};
};
const DesktopAiContext = createContext<DesktopAiContextValue | null>(null);
const rejectDesktopOnly = async (): Promise<never> => {
throw new Error("Desktop AI is only available inside the Islandflow Electron app.");
};
export function DesktopAiProvider({ children }: { children: ReactNode }) {
const initialRuntime = resolveCurrentDesktopAiRuntime();
const [runtime, setRuntime] = useState<DesktopAiRuntime>(initialRuntime);
const [state, setState] = useState<IslandflowAiState>(() =>
createUnavailableState(initialRuntime)
);
useEffect(() => {
if (typeof window === "undefined") {
return;
}
let disposed = false;
let activeBridge: DesktopAiBridge | null = null;
let unsubscribe = () => {};
let pollTimer: number | null = null;
let attempts = 0;
const clearPendingPoll = () => {
if (pollTimer !== null) {
window.clearTimeout(pollTimer);
pollTimer = null;
}
};
const replaceRuntime = (nextRuntime: DesktopAiRuntime) => {
setRuntime((currentRuntime) =>
isSameDesktopAiRuntime(currentRuntime, nextRuntime) ? currentRuntime : nextRuntime
);
};
const attachBridge = (nextBridge: DesktopAiBridge | null, runtimeSnapshot: DesktopAiRuntime) => {
if (activeBridge === nextBridge) {
return;
}
unsubscribe();
unsubscribe = () => {};
activeBridge = nextBridge;
if (!nextBridge) {
return;
}
const bridgeAtAttach = nextBridge;
void bridgeAtAttach.ai.getState().then(
(nextState) => {
if (!disposed && activeBridge === bridgeAtAttach) {
setState(nextState);
}
},
() => {
if (!disposed && activeBridge === bridgeAtAttach) {
setState(createUnavailableState(runtimeSnapshot));
}
}
);
unsubscribe = bridgeAtAttach.ai.subscribe((nextState) => {
if (!disposed && activeBridge === bridgeAtAttach) {
setState(nextState);
}
});
};
const syncRuntime = (): boolean => {
const nextRuntime = resolveDesktopAiRuntime(window);
replaceRuntime(nextRuntime);
if (nextRuntime.bridge) {
attachBridge(nextRuntime.bridge, nextRuntime);
return true;
}
attachBridge(null, nextRuntime);
setState(createUnavailableState(nextRuntime));
return false;
};
const pollForBridge = () => {
if (disposed) {
return;
}
attempts += 1;
if (syncRuntime() || attempts >= BRIDGE_POLL_MAX_ATTEMPTS) {
clearPendingPoll();
return;
}
pollTimer = window.setTimeout(pollForBridge, BRIDGE_POLL_INTERVAL_MS);
};
const startRetryWindow = () => {
attempts = 0;
clearPendingPoll();
pollTimer = window.setTimeout(pollForBridge, BRIDGE_POLL_INTERVAL_MS);
};
const onWindowLifecycle = () => {
if (!syncRuntime()) {
startRetryWindow();
}
};
window.addEventListener("focus", onWindowLifecycle);
window.addEventListener("pageshow", onWindowLifecycle);
if (!syncRuntime()) {
startRetryWindow();
}
return () => {
disposed = true;
clearPendingPoll();
window.removeEventListener("focus", onWindowLifecycle);
window.removeEventListener("pageshow", onWindowLifecycle);
unsubscribe();
};
}, []);
const value = useMemo<DesktopAiContextValue>(
() => ({
bridgeAvailable: runtime.bridgeAvailable,
shellAvailable: runtime.shellAvailable,
state,
loginWithBrowser: runtime.bridge?.ai.loginWithBrowser ?? rejectDesktopOnly,
loginWithDeviceCode: runtime.bridge?.ai.loginWithDeviceCode ?? rejectDesktopOnly,
cancelLogin: runtime.bridge?.ai.cancelLogin ?? rejectDesktopOnly,
logout: runtime.bridge?.ai.logout ?? rejectDesktopOnly,
updatePreferences: runtime.bridge?.ai.updatePreferences ?? rejectDesktopOnly,
runTask: runtime.bridge?.ai.runTask ?? rejectDesktopOnly,
cancelTask: runtime.bridge?.ai.cancelTask ?? rejectDesktopOnly
}),
[runtime, state]
);
return <DesktopAiContext.Provider value={value}>{children}</DesktopAiContext.Provider>;
}
export const useDesktopAi = (): DesktopAiContextValue => {
const value = useContext(DesktopAiContext);
if (!value) {
throw new Error("Desktop AI context missing");
}
return value;
};