/** * Codex 桌面端能力的 IPC 封装。 * * 与 src/ai/api/http.ts 无关:这里不走 HTTP,也没有 /js/a 前缀与 token。 * 主进程实现在 ai-electron/electron/controller/codexCtl.ts + service/codex/*。 * * 说明:本文件由 src/ai/api/codexApi.ts 迁移而来(那里改为一行转发), * 并补齐了原先缺失的审批通道封装(主进程早就在推 codex/approval 与 codex/approval-closed)。 */ /** 主进程 controller 层统一返回结构 */ export type CodexRpc = { success: true; data: T; message?: string } | { success: false; message: string }; const CHANNEL_PREFIX = 'controller/codexCtl/'; /** ee-core 既有 controller,用于选目录 */ const OS_CHANNEL_PREFIX = 'controller/osCtl/'; export const CODEX_EVENT_CHANNEL = 'codex/event'; export const CODEX_STATUS_CHANNEL = 'codex/status'; export const CODEX_DIAGNOSTIC_CHANNEL = 'codex/diagnostic'; export const CODEX_APPROVAL_CHANNEL = 'codex/approval'; export const CODEX_APPROVAL_CLOSED_CHANNEL = 'codex/approval-closed'; type IpcRendererLike = { invoke: (channel: string, ...args: unknown[]) => Promise; on?: (channel: string, listener: (event: unknown, payload: unknown) => void) => void; removeListener?: (channel: string, listener: (event: unknown, payload: unknown) => void) => void; }; /** * 两种桥都认:ee-core 脚手架默认 nodeIntegration:true + contextIsolation:false(渲染进程里 require 可用), * 若后续启用 preload/bridge.ts + contextIsolation 则走 window.electron。 */ function resolveIpcRenderer(): IpcRendererLike | null { const bridge = window.electron?.ipcRenderer as IpcRendererLike | undefined; if (bridge?.invoke) return bridge; const nodeRequire = (window as unknown as { require?: (id: string) => { ipcRenderer?: IpcRendererLike } }).require; try { const renderer = nodeRequire?.('electron')?.ipcRenderer; return renderer?.invoke ? renderer : null; } catch { return null; } } /** 浏览器(非桌面端)里恒为 false,页面据此整页降级 */ export function isCodexBridgeAvailable(): boolean { return resolveIpcRenderer() !== null; } async function invokeChannel(channel: string, params?: unknown): Promise { const ipcRenderer = resolveIpcRenderer(); if (!ipcRenderer) throw new Error('当前环境不可用:请在桌面客户端内打开'); const result = (await ipcRenderer.invoke(channel, params)) as CodexRpc | undefined; if (!result) throw new Error(`桌面端无响应:${channel}`); if (!result.success) throw new Error(result.message || `桌面端调用失败:${channel}`); return result.data; } /** * ee-core 的 ipcMain.handle 原样返回 controller 返回值,不包 {success, data}。 * 调用 osCtl 这类既有 controller 必须走这里,否则裸字符串/布尔值会被当成失败。 */ async function invokeRawChannel(channel: string, params?: unknown): Promise { const ipcRenderer = resolveIpcRenderer(); if (!ipcRenderer) throw new Error('当前环境不可用:请在桌面客户端内打开'); return (await ipcRenderer.invoke(channel, params)) as T; } /** 调用失败(桥不可用 / success:false / invoke reject)统一抛 Error,调用方 showMessage(e.message) */ export function codexInvoke(method: string, params?: unknown): Promise { return invokeChannel(`${CHANNEL_PREFIX}${method}`, params); } /** 订阅主进程推送,返回取消订阅函数;非桌面端返回空函数 */ export function onCodexChannel(channel: string, handler: (payload: T) => void): () => void { const ipcRenderer = resolveIpcRenderer(); if (!ipcRenderer?.on) return () => undefined; const listener = (_event: unknown, payload: unknown) => handler(payload as T); ipcRenderer.on(channel, listener); return () => ipcRenderer.removeListener?.(channel, listener); } // ─── 运行时 ──────────────────────────────────────────────────────────────── export interface CodexPingResult { available: boolean; version: string | null; binaryPath: string | null; codexHome: string; reason: string | null; } export interface AppliedProviderInfo { model: string; modelRecordId: string | null; baseUrl: string | null; providerId: string | null; builtinProvider: string | null; name: string | null; appliedAt: string; } export interface CodexStatus { state: 'stopped' | 'starting' | 'ready' | 'error'; running: boolean; version: string | null; binaryPath: string | null; codexHome: string; defaultModel: string | null; providerId: string | null; degraded: boolean; error: string | null; applied: AppliedProviderInfo | null; } export function codexPing(): Promise { return codexInvoke('ping'); } export function codexStatus(): Promise { return codexInvoke('status'); } export function codexStart(): Promise { return codexInvoke('start'); } export function codexStop(): Promise { return codexInvoke('stop'); } export function codexRestart(): Promise { return codexInvoke('restart'); } export interface CodexModelOption { id: string; model: string; displayName: string; description: string; isDefault: boolean; defaultEffort: string; efforts: string[]; } export function codexListModels(): Promise { return codexInvoke('listModels'); } // ─── 模型 provider ───────────────────────────────────────────────────────── /** 与主进程 ApplyProviderInput 对应;providerType 取自 agent_model_provider.type */ export interface ApplyProviderInput { modelId: string; name?: string | null; baseUrl?: string | null; apiKey?: string | null; headersJson?: string | Record | null; config?: string | Record | null; providerType?: string | null; builtinProvider?: string | null; modelRecordId?: string | number | null; } /** 端点协议判定:responses 直通;chat-only 走内置桥接;unsupported 双协议都没有;unreachable 不可达 */ export type EndpointProtocol = 'responses' | 'chat-only' | 'unsupported' | 'unreachable'; export interface ProviderProbeResult { protocol: EndpointProtocol; responsesStatus: number | null; chatStatus: number | null; ollamaVersion: string | null; ollamaNeedsUpgrade: boolean; detail: string; hint: string | null; } export interface ApplyProviderResult { applied: AppliedProviderInfo; hint: string | null; droppedConfigKeys: string[]; status: CodexStatus; } export function codexProbeProvider(input: { baseUrl: string; apiKey?: string | null; modelId?: string | null; providerType?: string | null; builtinProvider?: string | null; }): Promise { return codexInvoke('probeProvider', input); } export function codexApplyProvider(input: ApplyProviderInput): Promise { return codexInvoke('applyProvider', input); } export function codexClearProvider(): Promise { return codexInvoke('clearProvider'); } // ─── MCP ─────────────────────────────────────────────────────────────────── export interface CodexMcpServer { id: string; transport: 'stdio' | 'http'; command: string | null; args: string[]; url: string | null; enabled: boolean; bearerTokenEnvVar: string | null; connected: boolean; toolCount: number; /** 已连接时 Codex 返回的工具清单;未连接为空数组 */ tools: Array<{ name: string; description: string }>; authStatus: string; } export interface CodexMcpServerInput { id: string; transport: 'stdio' | 'http'; command?: string | null; args?: string[]; url?: string | null; enabled: boolean; bearerTokenEnvVar?: string | null; } export function codexMcpList(): Promise { return codexInvoke('mcpList'); } export function codexMcpSave(input: CodexMcpServerInput): Promise { return codexInvoke('mcpSave', input); } export function codexMcpRemove(id: string): Promise { return codexInvoke('mcpRemove', { id }); } // ─── Skill ───────────────────────────────────────────────────────────────── export interface CodexSkill { name: string; description: string; path: string; scope: string; enabled: boolean; cwd: string; /** false = Codex 内置(.system 等),不可删除 */ managed: boolean; } export interface CodexSkillReadResult { dir: string; content: string; files: Array<{ path: string; size: number }>; } export function codexSkillList(forceReload = false): Promise { return codexInvoke('skillList', { forceReload }); } export function codexSkillRead(target: { path?: string; name?: string }): Promise { return codexInvoke('skillRead', target); } export function codexSkillInstallFolder(input: { srcPath: string; name?: string | null; overwrite?: boolean; }): Promise { return codexInvoke('skillInstallFolder', input); } export function codexSkillInstallZip(input: { data: Uint8Array; name?: string | null; overwrite?: boolean; }): Promise { return codexInvoke('skillInstallZip', input); } export function codexSkillRemove(target: { path?: string; name?: string }): Promise { return codexInvoke('skillRemove', target); } export function codexSkillSetEnabled( target: { path?: string; name?: string }, enabled: boolean, ): Promise<{ effectiveEnabled: boolean }> { return codexInvoke<{ effectiveEnabled: boolean }>('skillSetEnabled', { ...target, enabled }); } export function codexSkillOpenFolder(path?: string): Promise<{ path: string }> { return codexInvoke<{ path: string }>('skillOpenFolder', { path }); } /** 复用 ee-core 既有的目录选择对话框,取消时主进程返回空字符串 */ export function selectDirectory(): Promise { return invokeRawChannel(`${OS_CHANNEL_PREFIX}selectDirectory`); } // ─── 会话 ────────────────────────────────────────────────────────────────── /** 沙箱模式与审批策略的类型定义在 types/codexTypes.ts,这里只做转出,避免两处各写一份 */ export type { CodexApprovalPolicy, CodexSandbox } from '../types/codexTypes'; import type { CodexApprovalPolicy, CodexSandbox } from '../types/codexTypes'; export interface CodexThreadStartInput { cwd?: string | null; model?: string | null; approvalPolicy?: CodexApprovalPolicy; sandbox?: CodexSandbox; } export interface CodexTurnRunInput extends CodexThreadStartInput { threadId: string; input: string; timeoutMs?: number; } export interface CodexTurnResult { turnId: string; status: string; text: string; } /** * 返回里带 cwd:cwd 为空时由主进程兜底到默认工作目录, * 渲染进程必须知道真实值才能把产物栏指向同一个目录。 */ export interface CodexThreadStartResult { threadId: string; cwd: string; } export function codexThreadStart(input?: CodexThreadStartInput): Promise { return codexInvoke('threadStart', input); } export function codexTurnRun(input: CodexTurnRunInput): Promise { return codexInvoke('turnRun', input); } export function codexTurnInterrupt(threadId: string, turnId: string): Promise> { return codexInvoke>('turnInterrupt', { threadId, turnId }); } export function codexThreadUnsubscribe(threadId: string): Promise> { return codexInvoke>('threadUnsubscribe', { threadId }); } /** 应用默认工作目录(用户没选目录时主进程用的是这个) */ export function codexDefaultWorkspace(): Promise<{ cwd: string }> { return codexInvoke<{ cwd: string }>('defaultWorkspace'); } export function codexApprovalResolve(input: { token: string; decision: 'accept' | 'acceptForSession' | 'decline' | 'cancel'; answers?: Record; }): Promise> { return codexInvoke>('approvalResolve', input); } // ─── 事件与诊断 ──────────────────────────────────────────────────────────── export type CodexAgentEventKind = 'lifecycle' | 'assistant' | 'thought' | 'tool' | 'file' | 'plan' | 'error'; export interface CodexAgentEvent { id: string; threadId: string; turnId: string; kind: CodexAgentEventKind; title: string; message: string; timestamp: string; method?: string; itemId?: string; /** true 表示增量文本,需按 id 追加到已有事件 */ isDelta?: boolean; } export interface CodexDiagnostic { ts: string; line: string; } /** 与主进程 ApprovalRequest 对齐 */ export interface CodexApprovalPayload { token: string; threadId: string | null; kind: 'command' | 'file' | 'permissions' | 'input'; method: string; title: string; summary: string; details: Record; createdAt: string; } export function codexEventsRead(threadId: string, limit?: number): Promise { return codexInvoke('eventsRead', { threadId, limit }); } export function codexLogsTail(limit?: number): Promise { return codexInvoke('logsTail', { limit }); } export function onCodexEvent(handler: (event: CodexAgentEvent) => void): () => void { return onCodexChannel(CODEX_EVENT_CHANNEL, handler); } export function onCodexStatus(handler: (status: CodexStatus) => void): () => void { return onCodexChannel(CODEX_STATUS_CHANNEL, handler); } export function onCodexDiagnostic(handler: (item: CodexDiagnostic) => void): () => void { return onCodexChannel(CODEX_DIAGNOSTIC_CHANNEL, handler); } /** * 审批请求。主进程 approvalBroker 在 Codex 需要授权时推来, * 客户端必须调 codexApprovalResolve 回传决策,否则回合会一直挂到 2 分钟超时(自动拒绝)。 */ export function onCodexApproval(handler: (approval: CodexApprovalPayload) => void): () => void { return onCodexChannel(CODEX_APPROVAL_CHANNEL, handler); } /** 审批结束(本地已决策 / Codex 自行撤销 / 超时),参数是 token */ export function onCodexApprovalClosed(handler: (payload: { token: string }) => void): () => void { return onCodexChannel<{ token: string }>(CODEX_APPROVAL_CLOSED_CHANNEL, handler); } /** 把 File 读成主进程能直接校验的字节 */ export async function readFileBytes(file: File): Promise { return new Uint8Array(await file.arrayBuffer()); }