index.ts 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  1. import { join } from 'node:path';
  2. import { mkdir } from 'node:fs/promises';
  3. import { getMainWindow } from 'ee-core/electron';
  4. import { logger } from 'ee-core/log';
  5. import { ApprovalBroker } from './approvalBroker';
  6. import { chatBridge } from './chatBridgeService';
  7. import { ensureCodexHome, getCodexDataDir, getCodexHome, getEventsDir } from './codexHome';
  8. import { CodexRuntime, type RuntimeStatus } from './codexRuntime';
  9. import { EventLog } from './eventLog';
  10. import { notificationToEvent } from './eventMapper';
  11. import { McpService } from './mcpService';
  12. import { readAppliedProvider } from './providerService';
  13. import { SkillService } from './skillService';
  14. import {
  15. asMessage,
  16. CODEX_APPROVAL_CHANNEL,
  17. CODEX_APPROVAL_CLOSED_CHANNEL,
  18. CODEX_DIAGNOSTIC_CHANNEL,
  19. CODEX_EVENT_CHANNEL,
  20. CODEX_STATUS_CHANNEL,
  21. type AppliedProviderInfo,
  22. type CodexStatusResult,
  23. } from './types';
  24. import type { JsonRpcServerRequest } from './jsonRpcPeer';
  25. /**
  26. * Codex 能力的装配层:懒启动单例、事件转发到渲染进程、诊断环形缓冲。
  27. * 刻意不在启动时自动带上 provider —— apiKey 只能由渲染进程从后端取到后传入,
  28. * 所以重启后需要页面重新「应用模型」,未应用时 MCP/Skill 管理照样可用。
  29. */
  30. const MAX_DIAGNOSTICS = 500;
  31. export interface CodexContainer {
  32. runtime: CodexRuntime;
  33. broker: ApprovalBroker;
  34. eventLog: EventLog;
  35. mcp: McpService;
  36. skills: SkillService;
  37. }
  38. let container: CodexContainer | null = null;
  39. let creating: Promise<CodexContainer> | null = null;
  40. let appliedCache: AppliedProviderInfo | null = null;
  41. let appliedLoaded = false;
  42. const diagnostics: Array<{ ts: string; line: string }> = [];
  43. function send(channel: string, payload: unknown): void {
  44. try {
  45. const win = getMainWindow();
  46. if (win && !win.isDestroyed()) win.webContents.send(channel, payload);
  47. } catch {
  48. // 窗口尚未就绪或已销毁:丢事件,不影响主流程
  49. }
  50. }
  51. function pushDiagnostic(line: string): void {
  52. const entry = { ts: new Date().toISOString(), line };
  53. diagnostics.push(entry);
  54. if (diagnostics.length > MAX_DIAGNOSTICS) {
  55. diagnostics.splice(0, diagnostics.length - MAX_DIAGNOSTICS);
  56. }
  57. // 环形缓冲只在内存里,进程一崩就没了:同步落 ee.log,否则启动失败无从复盘
  58. logger.warn(`[codex] ${line}`);
  59. send(CODEX_DIAGNOSTIC_CHANNEL, entry);
  60. }
  61. export async function getCodex(): Promise<CodexContainer> {
  62. if (container) return container;
  63. if (creating) return creating;
  64. creating = (async () => {
  65. await ensureCodexHome();
  66. // 桥接的诊断出口统一进环形缓冲(providerService 启动桥接时不再单独传)
  67. chatBridge.setDiagnostic(pushDiagnostic);
  68. const runtime = new CodexRuntime({ codexHome: getCodexHome() });
  69. const broker = new ApprovalBroker(runtime);
  70. const eventLog = new EventLog(getEventsDir());
  71. await eventLog.init();
  72. runtime.on('notification', (notification: { method: string; params?: unknown }) => {
  73. if (notification.method === 'serverRequest/resolved') {
  74. const requestId = (notification.params as { requestId?: string | number } | undefined)?.requestId;
  75. if (requestId !== undefined) broker.resolveFromServer(requestId);
  76. }
  77. const event = notificationToEvent(notification);
  78. if (!event) return;
  79. send(CODEX_EVENT_CHANNEL, event);
  80. eventLog.append(event).catch((error: unknown) => {
  81. logger.error('[codex] 事件落盘失败:', asMessage(error));
  82. });
  83. });
  84. runtime.on('status', (status: RuntimeStatus) => send(CODEX_STATUS_CHANNEL, toStatusResult(status)));
  85. runtime.on('diagnostic', (line: string) => pushDiagnostic(line));
  86. runtime.on('serverRequest', (request: JsonRpcServerRequest) => broker.handle(request));
  87. broker.on('approval', (approval: unknown) => send(CODEX_APPROVAL_CHANNEL, approval));
  88. broker.on('closed', (token: string) => send(CODEX_APPROVAL_CLOSED_CHANNEL, { token }));
  89. broker.on('expired', (approval: unknown) => {
  90. pushDiagnostic(`审批超时已自动拒绝:${JSON.stringify(approval)}`);
  91. });
  92. broker.on('diagnostic', (line: string) => pushDiagnostic(line));
  93. container = {
  94. runtime,
  95. broker,
  96. eventLog,
  97. mcp: new McpService(runtime),
  98. skills: new SkillService(runtime, { onDiagnostic: pushDiagnostic }),
  99. };
  100. return container;
  101. })();
  102. try {
  103. return await creating;
  104. } finally {
  105. creating = null;
  106. }
  107. }
  108. export async function getAppliedProvider(): Promise<AppliedProviderInfo | null> {
  109. if (!appliedLoaded) {
  110. appliedCache = await readAppliedProvider();
  111. appliedLoaded = true;
  112. }
  113. return appliedCache;
  114. }
  115. export function setAppliedProvider(value: AppliedProviderInfo | null): void {
  116. appliedCache = value;
  117. appliedLoaded = true;
  118. }
  119. export function toStatusResult(status: RuntimeStatus): CodexStatusResult {
  120. const bridgeInfo = chatBridge.info;
  121. return {
  122. state: status.state,
  123. running: status.state === 'ready',
  124. version: status.version,
  125. binaryPath: status.binaryPath,
  126. codexHome: getCodexHome(),
  127. defaultModel: status.defaultModel,
  128. providerId: status.providerId,
  129. degraded: status.degraded,
  130. error: status.error,
  131. applied: appliedCache,
  132. bridge: bridgeInfo ? { running: true, upstream: bridgeInfo.upstreamBaseUrl } : null,
  133. };
  134. }
  135. export function tailDiagnostics(limit = 200): Array<{ ts: string; line: string }> {
  136. const size = Math.max(1, Math.min(MAX_DIAGNOSTICS, Math.trunc(limit)));
  137. return diagnostics.slice(-size);
  138. }
  139. /** 会话默认工作目录:用户没指定 cwd 时用,避免 Codex 直接在安装目录里动手 */
  140. export async function defaultWorkspaceDir(): Promise<string> {
  141. const dir = join(getCodexDataDir(), 'workspace');
  142. await mkdir(dir, { recursive: true });
  143. return dir;
  144. }
  145. export async function disposeCodex(): Promise<void> {
  146. // 桥接不挂在 container 上,有没有 container 都要兜底停掉
  147. await chatBridge.stop().catch(() => undefined);
  148. const current = container;
  149. container = null;
  150. if (!current) return;
  151. current.broker.closeAll();
  152. await current.eventLog.flush().catch(() => undefined);
  153. await Promise.race([
  154. current.runtime.dispose(),
  155. new Promise<void>((resolve) => setTimeout(resolve, 3_000)),
  156. ]);
  157. }