| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441 |
- /**
- * 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<T> = { 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<unknown>;
- 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<T>(channel: string, params?: unknown): Promise<T> {
- const ipcRenderer = resolveIpcRenderer();
- if (!ipcRenderer) throw new Error('当前环境不可用:请在桌面客户端内打开');
- const result = (await ipcRenderer.invoke(channel, params)) as CodexRpc<T> | 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<T>(channel: string, params?: unknown): Promise<T> {
- 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<T>(method: string, params?: unknown): Promise<T> {
- return invokeChannel<T>(`${CHANNEL_PREFIX}${method}`, params);
- }
- /** 订阅主进程推送,返回取消订阅函数;非桌面端返回空函数 */
- export function onCodexChannel<T>(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<CodexPingResult> {
- return codexInvoke<CodexPingResult>('ping');
- }
- export function codexStatus(): Promise<CodexStatus> {
- return codexInvoke<CodexStatus>('status');
- }
- export function codexStart(): Promise<CodexStatus> {
- return codexInvoke<CodexStatus>('start');
- }
- export function codexStop(): Promise<CodexStatus> {
- return codexInvoke<CodexStatus>('stop');
- }
- export function codexRestart(): Promise<CodexStatus> {
- return codexInvoke<CodexStatus>('restart');
- }
- export interface CodexModelOption {
- id: string;
- model: string;
- displayName: string;
- description: string;
- isDefault: boolean;
- defaultEffort: string;
- efforts: string[];
- }
- export function codexListModels(): Promise<CodexModelOption[]> {
- return codexInvoke<CodexModelOption[]>('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<string, string> | null;
- config?: string | Record<string, unknown> | 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<ProviderProbeResult> {
- return codexInvoke<ProviderProbeResult>('probeProvider', input);
- }
- export function codexApplyProvider(input: ApplyProviderInput): Promise<ApplyProviderResult> {
- return codexInvoke<ApplyProviderResult>('applyProvider', input);
- }
- export function codexClearProvider(): Promise<CodexStatus> {
- return codexInvoke<CodexStatus>('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<CodexMcpServer[]> {
- return codexInvoke<CodexMcpServer[]>('mcpList');
- }
- export function codexMcpSave(input: CodexMcpServerInput): Promise<CodexMcpServer[]> {
- return codexInvoke<CodexMcpServer[]>('mcpSave', input);
- }
- export function codexMcpRemove(id: string): Promise<CodexMcpServer[]> {
- return codexInvoke<CodexMcpServer[]>('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<CodexSkill[]> {
- return codexInvoke<CodexSkill[]>('skillList', { forceReload });
- }
- export function codexSkillRead(target: { path?: string; name?: string }): Promise<CodexSkillReadResult> {
- return codexInvoke<CodexSkillReadResult>('skillRead', target);
- }
- export function codexSkillInstallFolder(input: {
- srcPath: string;
- name?: string | null;
- overwrite?: boolean;
- }): Promise<CodexSkill[]> {
- return codexInvoke<CodexSkill[]>('skillInstallFolder', input);
- }
- export function codexSkillInstallZip(input: {
- data: Uint8Array;
- name?: string | null;
- overwrite?: boolean;
- }): Promise<CodexSkill[]> {
- return codexInvoke<CodexSkill[]>('skillInstallZip', input);
- }
- export function codexSkillRemove(target: { path?: string; name?: string }): Promise<CodexSkill[]> {
- return codexInvoke<CodexSkill[]>('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<string> {
- return invokeRawChannel<string>(`${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<CodexThreadStartResult> {
- return codexInvoke<CodexThreadStartResult>('threadStart', input);
- }
- export function codexTurnRun(input: CodexTurnRunInput): Promise<CodexTurnResult> {
- return codexInvoke<CodexTurnResult>('turnRun', input);
- }
- export function codexTurnInterrupt(threadId: string, turnId: string): Promise<Record<string, never>> {
- return codexInvoke<Record<string, never>>('turnInterrupt', { threadId, turnId });
- }
- export function codexThreadUnsubscribe(threadId: string): Promise<Record<string, never>> {
- return codexInvoke<Record<string, never>>('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<string, string[]>;
- }): Promise<Record<string, never>> {
- return codexInvoke<Record<string, never>>('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<string, unknown>;
- createdAt: string;
- }
- export function codexEventsRead(threadId: string, limit?: number): Promise<CodexAgentEvent[]> {
- return codexInvoke<CodexAgentEvent[]>('eventsRead', { threadId, limit });
- }
- export function codexLogsTail(limit?: number): Promise<CodexDiagnostic[]> {
- return codexInvoke<CodexDiagnostic[]>('logsTail', { limit });
- }
- export function onCodexEvent(handler: (event: CodexAgentEvent) => void): () => void {
- return onCodexChannel<CodexAgentEvent>(CODEX_EVENT_CHANNEL, handler);
- }
- export function onCodexStatus(handler: (status: CodexStatus) => void): () => void {
- return onCodexChannel<CodexStatus>(CODEX_STATUS_CHANNEL, handler);
- }
- export function onCodexDiagnostic(handler: (item: CodexDiagnostic) => void): () => void {
- return onCodexChannel<CodexDiagnostic>(CODEX_DIAGNOSTIC_CHANNEL, handler);
- }
- /**
- * 审批请求。主进程 approvalBroker 在 Codex 需要授权时推来,
- * 客户端必须调 codexApprovalResolve 回传决策,否则回合会一直挂到 2 分钟超时(自动拒绝)。
- */
- export function onCodexApproval(handler: (approval: CodexApprovalPayload) => void): () => void {
- return onCodexChannel<CodexApprovalPayload>(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<Uint8Array> {
- return new Uint8Array(await file.arrayBuffer());
- }
|