codexApi.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441
  1. /**
  2. * Codex 桌面端能力的 IPC 封装。
  3. *
  4. * 与 src/ai/api/http.ts 无关:这里不走 HTTP,也没有 /js/a 前缀与 token。
  5. * 主进程实现在 ai-electron/electron/controller/codexCtl.ts + service/codex/*。
  6. *
  7. * 说明:本文件由 src/ai/api/codexApi.ts 迁移而来(那里改为一行转发),
  8. * 并补齐了原先缺失的审批通道封装(主进程早就在推 codex/approval 与 codex/approval-closed)。
  9. */
  10. /** 主进程 controller 层统一返回结构 */
  11. export type CodexRpc<T> = { success: true; data: T; message?: string } | { success: false; message: string };
  12. const CHANNEL_PREFIX = 'controller/codexCtl/';
  13. /** ee-core 既有 controller,用于选目录 */
  14. const OS_CHANNEL_PREFIX = 'controller/osCtl/';
  15. export const CODEX_EVENT_CHANNEL = 'codex/event';
  16. export const CODEX_STATUS_CHANNEL = 'codex/status';
  17. export const CODEX_DIAGNOSTIC_CHANNEL = 'codex/diagnostic';
  18. export const CODEX_APPROVAL_CHANNEL = 'codex/approval';
  19. export const CODEX_APPROVAL_CLOSED_CHANNEL = 'codex/approval-closed';
  20. type IpcRendererLike = {
  21. invoke: (channel: string, ...args: unknown[]) => Promise<unknown>;
  22. on?: (channel: string, listener: (event: unknown, payload: unknown) => void) => void;
  23. removeListener?: (channel: string, listener: (event: unknown, payload: unknown) => void) => void;
  24. };
  25. /**
  26. * 两种桥都认:ee-core 脚手架默认 nodeIntegration:true + contextIsolation:false(渲染进程里 require 可用),
  27. * 若后续启用 preload/bridge.ts + contextIsolation 则走 window.electron。
  28. */
  29. function resolveIpcRenderer(): IpcRendererLike | null {
  30. const bridge = window.electron?.ipcRenderer as IpcRendererLike | undefined;
  31. if (bridge?.invoke) return bridge;
  32. const nodeRequire = (window as unknown as { require?: (id: string) => { ipcRenderer?: IpcRendererLike } }).require;
  33. try {
  34. const renderer = nodeRequire?.('electron')?.ipcRenderer;
  35. return renderer?.invoke ? renderer : null;
  36. } catch {
  37. return null;
  38. }
  39. }
  40. /** 浏览器(非桌面端)里恒为 false,页面据此整页降级 */
  41. export function isCodexBridgeAvailable(): boolean {
  42. return resolveIpcRenderer() !== null;
  43. }
  44. async function invokeChannel<T>(channel: string, params?: unknown): Promise<T> {
  45. const ipcRenderer = resolveIpcRenderer();
  46. if (!ipcRenderer) throw new Error('当前环境不可用:请在桌面客户端内打开');
  47. const result = (await ipcRenderer.invoke(channel, params)) as CodexRpc<T> | undefined;
  48. if (!result) throw new Error(`桌面端无响应:${channel}`);
  49. if (!result.success) throw new Error(result.message || `桌面端调用失败:${channel}`);
  50. return result.data;
  51. }
  52. /**
  53. * ee-core 的 ipcMain.handle 原样返回 controller 返回值,不包 {success, data}。
  54. * 调用 osCtl 这类既有 controller 必须走这里,否则裸字符串/布尔值会被当成失败。
  55. */
  56. async function invokeRawChannel<T>(channel: string, params?: unknown): Promise<T> {
  57. const ipcRenderer = resolveIpcRenderer();
  58. if (!ipcRenderer) throw new Error('当前环境不可用:请在桌面客户端内打开');
  59. return (await ipcRenderer.invoke(channel, params)) as T;
  60. }
  61. /** 调用失败(桥不可用 / success:false / invoke reject)统一抛 Error,调用方 showMessage(e.message) */
  62. export function codexInvoke<T>(method: string, params?: unknown): Promise<T> {
  63. return invokeChannel<T>(`${CHANNEL_PREFIX}${method}`, params);
  64. }
  65. /** 订阅主进程推送,返回取消订阅函数;非桌面端返回空函数 */
  66. export function onCodexChannel<T>(channel: string, handler: (payload: T) => void): () => void {
  67. const ipcRenderer = resolveIpcRenderer();
  68. if (!ipcRenderer?.on) return () => undefined;
  69. const listener = (_event: unknown, payload: unknown) => handler(payload as T);
  70. ipcRenderer.on(channel, listener);
  71. return () => ipcRenderer.removeListener?.(channel, listener);
  72. }
  73. // ─── 运行时 ────────────────────────────────────────────────────────────────
  74. export interface CodexPingResult {
  75. available: boolean;
  76. version: string | null;
  77. binaryPath: string | null;
  78. codexHome: string;
  79. reason: string | null;
  80. }
  81. export interface AppliedProviderInfo {
  82. model: string;
  83. modelRecordId: string | null;
  84. baseUrl: string | null;
  85. providerId: string | null;
  86. builtinProvider: string | null;
  87. name: string | null;
  88. appliedAt: string;
  89. }
  90. export interface CodexStatus {
  91. state: 'stopped' | 'starting' | 'ready' | 'error';
  92. running: boolean;
  93. version: string | null;
  94. binaryPath: string | null;
  95. codexHome: string;
  96. defaultModel: string | null;
  97. providerId: string | null;
  98. degraded: boolean;
  99. error: string | null;
  100. applied: AppliedProviderInfo | null;
  101. }
  102. export function codexPing(): Promise<CodexPingResult> {
  103. return codexInvoke<CodexPingResult>('ping');
  104. }
  105. export function codexStatus(): Promise<CodexStatus> {
  106. return codexInvoke<CodexStatus>('status');
  107. }
  108. export function codexStart(): Promise<CodexStatus> {
  109. return codexInvoke<CodexStatus>('start');
  110. }
  111. export function codexStop(): Promise<CodexStatus> {
  112. return codexInvoke<CodexStatus>('stop');
  113. }
  114. export function codexRestart(): Promise<CodexStatus> {
  115. return codexInvoke<CodexStatus>('restart');
  116. }
  117. export interface CodexModelOption {
  118. id: string;
  119. model: string;
  120. displayName: string;
  121. description: string;
  122. isDefault: boolean;
  123. defaultEffort: string;
  124. efforts: string[];
  125. }
  126. export function codexListModels(): Promise<CodexModelOption[]> {
  127. return codexInvoke<CodexModelOption[]>('listModels');
  128. }
  129. // ─── 模型 provider ─────────────────────────────────────────────────────────
  130. /** 与主进程 ApplyProviderInput 对应;providerType 取自 agent_model_provider.type */
  131. export interface ApplyProviderInput {
  132. modelId: string;
  133. name?: string | null;
  134. baseUrl?: string | null;
  135. apiKey?: string | null;
  136. headersJson?: string | Record<string, string> | null;
  137. config?: string | Record<string, unknown> | null;
  138. providerType?: string | null;
  139. builtinProvider?: string | null;
  140. modelRecordId?: string | number | null;
  141. }
  142. /** 端点协议判定:responses 直通;chat-only 走内置桥接;unsupported 双协议都没有;unreachable 不可达 */
  143. export type EndpointProtocol = 'responses' | 'chat-only' | 'unsupported' | 'unreachable';
  144. export interface ProviderProbeResult {
  145. protocol: EndpointProtocol;
  146. responsesStatus: number | null;
  147. chatStatus: number | null;
  148. ollamaVersion: string | null;
  149. ollamaNeedsUpgrade: boolean;
  150. detail: string;
  151. hint: string | null;
  152. }
  153. export interface ApplyProviderResult {
  154. applied: AppliedProviderInfo;
  155. hint: string | null;
  156. droppedConfigKeys: string[];
  157. status: CodexStatus;
  158. }
  159. export function codexProbeProvider(input: {
  160. baseUrl: string;
  161. apiKey?: string | null;
  162. modelId?: string | null;
  163. providerType?: string | null;
  164. builtinProvider?: string | null;
  165. }): Promise<ProviderProbeResult> {
  166. return codexInvoke<ProviderProbeResult>('probeProvider', input);
  167. }
  168. export function codexApplyProvider(input: ApplyProviderInput): Promise<ApplyProviderResult> {
  169. return codexInvoke<ApplyProviderResult>('applyProvider', input);
  170. }
  171. export function codexClearProvider(): Promise<CodexStatus> {
  172. return codexInvoke<CodexStatus>('clearProvider');
  173. }
  174. // ─── MCP ───────────────────────────────────────────────────────────────────
  175. export interface CodexMcpServer {
  176. id: string;
  177. transport: 'stdio' | 'http';
  178. command: string | null;
  179. args: string[];
  180. url: string | null;
  181. enabled: boolean;
  182. bearerTokenEnvVar: string | null;
  183. connected: boolean;
  184. toolCount: number;
  185. /** 已连接时 Codex 返回的工具清单;未连接为空数组 */
  186. tools: Array<{ name: string; description: string }>;
  187. authStatus: string;
  188. }
  189. export interface CodexMcpServerInput {
  190. id: string;
  191. transport: 'stdio' | 'http';
  192. command?: string | null;
  193. args?: string[];
  194. url?: string | null;
  195. enabled: boolean;
  196. bearerTokenEnvVar?: string | null;
  197. }
  198. export function codexMcpList(): Promise<CodexMcpServer[]> {
  199. return codexInvoke<CodexMcpServer[]>('mcpList');
  200. }
  201. export function codexMcpSave(input: CodexMcpServerInput): Promise<CodexMcpServer[]> {
  202. return codexInvoke<CodexMcpServer[]>('mcpSave', input);
  203. }
  204. export function codexMcpRemove(id: string): Promise<CodexMcpServer[]> {
  205. return codexInvoke<CodexMcpServer[]>('mcpRemove', { id });
  206. }
  207. // ─── Skill ─────────────────────────────────────────────────────────────────
  208. export interface CodexSkill {
  209. name: string;
  210. description: string;
  211. path: string;
  212. scope: string;
  213. enabled: boolean;
  214. cwd: string;
  215. /** false = Codex 内置(.system 等),不可删除 */
  216. managed: boolean;
  217. }
  218. export interface CodexSkillReadResult {
  219. dir: string;
  220. content: string;
  221. files: Array<{ path: string; size: number }>;
  222. }
  223. export function codexSkillList(forceReload = false): Promise<CodexSkill[]> {
  224. return codexInvoke<CodexSkill[]>('skillList', { forceReload });
  225. }
  226. export function codexSkillRead(target: { path?: string; name?: string }): Promise<CodexSkillReadResult> {
  227. return codexInvoke<CodexSkillReadResult>('skillRead', target);
  228. }
  229. export function codexSkillInstallFolder(input: {
  230. srcPath: string;
  231. name?: string | null;
  232. overwrite?: boolean;
  233. }): Promise<CodexSkill[]> {
  234. return codexInvoke<CodexSkill[]>('skillInstallFolder', input);
  235. }
  236. export function codexSkillInstallZip(input: {
  237. data: Uint8Array;
  238. name?: string | null;
  239. overwrite?: boolean;
  240. }): Promise<CodexSkill[]> {
  241. return codexInvoke<CodexSkill[]>('skillInstallZip', input);
  242. }
  243. export function codexSkillRemove(target: { path?: string; name?: string }): Promise<CodexSkill[]> {
  244. return codexInvoke<CodexSkill[]>('skillRemove', target);
  245. }
  246. export function codexSkillSetEnabled(
  247. target: { path?: string; name?: string },
  248. enabled: boolean,
  249. ): Promise<{ effectiveEnabled: boolean }> {
  250. return codexInvoke<{ effectiveEnabled: boolean }>('skillSetEnabled', { ...target, enabled });
  251. }
  252. export function codexSkillOpenFolder(path?: string): Promise<{ path: string }> {
  253. return codexInvoke<{ path: string }>('skillOpenFolder', { path });
  254. }
  255. /** 复用 ee-core 既有的目录选择对话框,取消时主进程返回空字符串 */
  256. export function selectDirectory(): Promise<string> {
  257. return invokeRawChannel<string>(`${OS_CHANNEL_PREFIX}selectDirectory`);
  258. }
  259. // ─── 会话 ──────────────────────────────────────────────────────────────────
  260. /** 沙箱模式与审批策略的类型定义在 types/codexTypes.ts,这里只做转出,避免两处各写一份 */
  261. export type { CodexApprovalPolicy, CodexSandbox } from '../types/codexTypes';
  262. import type { CodexApprovalPolicy, CodexSandbox } from '../types/codexTypes';
  263. export interface CodexThreadStartInput {
  264. cwd?: string | null;
  265. model?: string | null;
  266. approvalPolicy?: CodexApprovalPolicy;
  267. sandbox?: CodexSandbox;
  268. }
  269. export interface CodexTurnRunInput extends CodexThreadStartInput {
  270. threadId: string;
  271. input: string;
  272. timeoutMs?: number;
  273. }
  274. export interface CodexTurnResult {
  275. turnId: string;
  276. status: string;
  277. text: string;
  278. }
  279. /**
  280. * 返回里带 cwd:cwd 为空时由主进程兜底到默认工作目录,
  281. * 渲染进程必须知道真实值才能把产物栏指向同一个目录。
  282. */
  283. export interface CodexThreadStartResult {
  284. threadId: string;
  285. cwd: string;
  286. }
  287. export function codexThreadStart(input?: CodexThreadStartInput): Promise<CodexThreadStartResult> {
  288. return codexInvoke<CodexThreadStartResult>('threadStart', input);
  289. }
  290. export function codexTurnRun(input: CodexTurnRunInput): Promise<CodexTurnResult> {
  291. return codexInvoke<CodexTurnResult>('turnRun', input);
  292. }
  293. export function codexTurnInterrupt(threadId: string, turnId: string): Promise<Record<string, never>> {
  294. return codexInvoke<Record<string, never>>('turnInterrupt', { threadId, turnId });
  295. }
  296. export function codexThreadUnsubscribe(threadId: string): Promise<Record<string, never>> {
  297. return codexInvoke<Record<string, never>>('threadUnsubscribe', { threadId });
  298. }
  299. /** 应用默认工作目录(用户没选目录时主进程用的是这个) */
  300. export function codexDefaultWorkspace(): Promise<{ cwd: string }> {
  301. return codexInvoke<{ cwd: string }>('defaultWorkspace');
  302. }
  303. export function codexApprovalResolve(input: {
  304. token: string;
  305. decision: 'accept' | 'acceptForSession' | 'decline' | 'cancel';
  306. answers?: Record<string, string[]>;
  307. }): Promise<Record<string, never>> {
  308. return codexInvoke<Record<string, never>>('approvalResolve', input);
  309. }
  310. // ─── 事件与诊断 ────────────────────────────────────────────────────────────
  311. export type CodexAgentEventKind = 'lifecycle' | 'assistant' | 'thought' | 'tool' | 'file' | 'plan' | 'error';
  312. export interface CodexAgentEvent {
  313. id: string;
  314. threadId: string;
  315. turnId: string;
  316. kind: CodexAgentEventKind;
  317. title: string;
  318. message: string;
  319. timestamp: string;
  320. method?: string;
  321. itemId?: string;
  322. /** true 表示增量文本,需按 id 追加到已有事件 */
  323. isDelta?: boolean;
  324. }
  325. export interface CodexDiagnostic {
  326. ts: string;
  327. line: string;
  328. }
  329. /** 与主进程 ApprovalRequest 对齐 */
  330. export interface CodexApprovalPayload {
  331. token: string;
  332. threadId: string | null;
  333. kind: 'command' | 'file' | 'permissions' | 'input';
  334. method: string;
  335. title: string;
  336. summary: string;
  337. details: Record<string, unknown>;
  338. createdAt: string;
  339. }
  340. export function codexEventsRead(threadId: string, limit?: number): Promise<CodexAgentEvent[]> {
  341. return codexInvoke<CodexAgentEvent[]>('eventsRead', { threadId, limit });
  342. }
  343. export function codexLogsTail(limit?: number): Promise<CodexDiagnostic[]> {
  344. return codexInvoke<CodexDiagnostic[]>('logsTail', { limit });
  345. }
  346. export function onCodexEvent(handler: (event: CodexAgentEvent) => void): () => void {
  347. return onCodexChannel<CodexAgentEvent>(CODEX_EVENT_CHANNEL, handler);
  348. }
  349. export function onCodexStatus(handler: (status: CodexStatus) => void): () => void {
  350. return onCodexChannel<CodexStatus>(CODEX_STATUS_CHANNEL, handler);
  351. }
  352. export function onCodexDiagnostic(handler: (item: CodexDiagnostic) => void): () => void {
  353. return onCodexChannel<CodexDiagnostic>(CODEX_DIAGNOSTIC_CHANNEL, handler);
  354. }
  355. /**
  356. * 审批请求。主进程 approvalBroker 在 Codex 需要授权时推来,
  357. * 客户端必须调 codexApprovalResolve 回传决策,否则回合会一直挂到 2 分钟超时(自动拒绝)。
  358. */
  359. export function onCodexApproval(handler: (approval: CodexApprovalPayload) => void): () => void {
  360. return onCodexChannel<CodexApprovalPayload>(CODEX_APPROVAL_CHANNEL, handler);
  361. }
  362. /** 审批结束(本地已决策 / Codex 自行撤销 / 超时),参数是 token */
  363. export function onCodexApprovalClosed(handler: (payload: { token: string }) => void): () => void {
  364. return onCodexChannel<{ token: string }>(CODEX_APPROVAL_CLOSED_CHANNEL, handler);
  365. }
  366. /** 把 File 读成主进程能直接校验的字节 */
  367. export async function readFileBytes(file: File): Promise<Uint8Array> {
  368. return new Uint8Array(await file.arrayBuffer());
  369. }