codexCtl.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369
  1. import { shell } from 'electron';
  2. import { logger } from 'ee-core/log';
  3. import { registerArtifactRoot } from '../service/codex/codexArtifactService';
  4. import { getCodexHome, getSkillsDir, isInsideDir } from '../service/codex/codexHome';
  5. import { probeCodexBinary } from '../service/codex/codexLocator';
  6. import {
  7. applyProvider as applyProviderToRuntime,
  8. baseUrlHint,
  9. clearProvider as clearRuntimeProvider,
  10. describeDroppedKeys,
  11. probeEndpoint,
  12. } from '../service/codex/providerService';
  13. import {
  14. defaultWorkspaceDir,
  15. getAppliedProvider,
  16. getCodex,
  17. setAppliedProvider,
  18. tailDiagnostics,
  19. toStatusResult,
  20. } from '../service/codex/index';
  21. import {
  22. asMessage,
  23. fail,
  24. ok,
  25. type ApprovalAnswers,
  26. type ApprovalDecision,
  27. type ApplyProviderInput,
  28. type CodexPingResult,
  29. type CodexStatusResult,
  30. type Rpc,
  31. } from '../service/codex/types';
  32. import type { ModelOption } from '../service/codex/codexRuntime';
  33. import type { McpServerInput, McpServerSetting } from '../service/codex/mcpService';
  34. import type { SkillListItem, SkillReadResult } from '../service/codex/skillService';
  35. import type { AgentEvent } from '../service/codex/types';
  36. const APPROVAL_DECISIONS: readonly ApprovalDecision[] = ['accept', 'acceptForSession', 'decline', 'cancel'];
  37. /** 统一的异常包装:ee-core 的 ipcMain.handle 不 catch,抛错会被 Electron 包成 "Error occurred in handler for ..." */
  38. async function guard<T>(label: string, run: () => Promise<T>): Promise<Rpc<T>> {
  39. try {
  40. return ok(await run());
  41. } catch (error) {
  42. const message = asMessage(error);
  43. logger.error(`[codexCtl] ${label} failed:`, message);
  44. return fail(message);
  45. }
  46. }
  47. function requireString(value: unknown, label: string): string {
  48. if (typeof value !== 'string' || !value.trim()) throw new Error(`${label} 不能为空`);
  49. return value.trim();
  50. }
  51. /** Codex 的「线程不在本进程里」只以这句错误文本出现,是识别失效线程的唯一依据 */
  52. function isThreadMissingError(error: unknown): boolean {
  53. return /thread not found/iu.test(error instanceof Error ? error.message : String(error));
  54. }
  55. function toBytes(value: unknown): Uint8Array {
  56. if (value instanceof Uint8Array) return value;
  57. if (value instanceof ArrayBuffer) return new Uint8Array(value);
  58. if (Array.isArray(value)) return new Uint8Array(value as number[]);
  59. if (value && typeof value === 'object' && 'data' in value) {
  60. return toBytes((value as { data: unknown }).data);
  61. }
  62. throw new Error('文件内容必须是二进制数据');
  63. }
  64. class CodexCtl {
  65. /** 只探测二进制与 CODEX_HOME,不 spawn 子进程 */
  66. async ping(): Promise<Rpc<CodexPingResult>> {
  67. return guard('ping', async () => {
  68. // getCodex() 会顺带建好 CODEX_HOME 与 skills 目录
  69. await getCodex();
  70. const probe = await probeCodexBinary();
  71. return { ...probe, codexHome: getCodexHome() };
  72. });
  73. }
  74. async status(): Promise<Rpc<CodexStatusResult>> {
  75. return guard('status', async () => {
  76. const { runtime } = await getCodex();
  77. await getAppliedProvider();
  78. return toStatusResult(runtime.status);
  79. });
  80. }
  81. async start(): Promise<Rpc<CodexStatusResult>> {
  82. return guard('start', async () => {
  83. const { runtime } = await getCodex();
  84. await getAppliedProvider();
  85. return toStatusResult(await runtime.start());
  86. });
  87. }
  88. async stop(): Promise<Rpc<CodexStatusResult>> {
  89. return guard('stop', async () => {
  90. const { runtime } = await getCodex();
  91. await runtime.stop();
  92. return toStatusResult(runtime.status);
  93. });
  94. }
  95. async restart(): Promise<Rpc<CodexStatusResult>> {
  96. return guard('restart', async () => {
  97. const { runtime } = await getCodex();
  98. await runtime.stop();
  99. await getAppliedProvider();
  100. return toStatusResult(await runtime.start());
  101. });
  102. }
  103. async listModels(): Promise<Rpc<ModelOption[]>> {
  104. return guard('listModels', async () => {
  105. const { runtime } = await getCodex();
  106. return runtime.listModels();
  107. });
  108. }
  109. async providerCapabilities(): Promise<Rpc<unknown>> {
  110. return guard('providerCapabilities', async () => {
  111. const { runtime } = await getCodex();
  112. return runtime.readModelProviderCapabilities();
  113. });
  114. }
  115. /** 只做端点探测,不改运行时;页面在「应用」前给用户预览兼容性 */
  116. async probeProvider(params: {
  117. baseUrl?: string;
  118. apiKey?: string | null;
  119. modelId?: string | null;
  120. }): Promise<Rpc<unknown>> {
  121. return guard('probeProvider', async () => {
  122. const baseUrl = requireString(params?.baseUrl, 'baseUrl');
  123. const result = await probeEndpoint({
  124. baseUrl,
  125. modelId: params?.modelId ?? null,
  126. apiKey: params?.apiKey ?? null,
  127. });
  128. return { ...result, hint: baseUrlHint(baseUrl) };
  129. });
  130. }
  131. /** 应用后端「模型管理」里的一条记录;会重启子进程(api key 走环境变量) */
  132. async applyProvider(params: ApplyProviderInput & { modelRecordId?: string | number | null }): Promise<Rpc<unknown>> {
  133. return guard('applyProvider', async () => {
  134. if (!params || typeof params !== 'object') throw new Error('缺少模型配置');
  135. const { runtime } = await getCodex();
  136. const applied = await applyProviderToRuntime(runtime, params);
  137. setAppliedProvider(applied);
  138. return {
  139. applied,
  140. hint: baseUrlHint(applied.baseUrl),
  141. droppedConfigKeys: describeDroppedKeys(params),
  142. status: toStatusResult(runtime.status),
  143. };
  144. });
  145. }
  146. async clearProvider(): Promise<Rpc<CodexStatusResult>> {
  147. return guard('clearProvider', async () => {
  148. const { runtime } = await getCodex();
  149. await clearRuntimeProvider(runtime);
  150. setAppliedProvider(null);
  151. return toStatusResult(runtime.status);
  152. });
  153. }
  154. async mcpList(): Promise<Rpc<McpServerSetting[]>> {
  155. return guard('mcpList', async () => (await getCodex()).mcp.list());
  156. }
  157. async mcpSave(params: McpServerInput): Promise<Rpc<McpServerSetting[]>> {
  158. return guard('mcpSave', async () => (await getCodex()).mcp.save(params));
  159. }
  160. async mcpRemove(params: { id?: string }): Promise<Rpc<McpServerSetting[]>> {
  161. return guard('mcpRemove', async () =>
  162. (await getCodex()).mcp.remove(requireString(params?.id, 'id')),
  163. );
  164. }
  165. async skillList(params?: { forceReload?: boolean }): Promise<Rpc<SkillListItem[]>> {
  166. return guard('skillList', async () =>
  167. (await getCodex()).skills.list({ forceReload: params?.forceReload === true }),
  168. );
  169. }
  170. async skillRead(params: { path?: string; name?: string }): Promise<Rpc<SkillReadResult>> {
  171. return guard('skillRead', async () => (await getCodex()).skills.read(params ?? {}));
  172. }
  173. async skillInstallFolder(params: {
  174. srcPath?: string;
  175. name?: string | null;
  176. overwrite?: boolean;
  177. }): Promise<Rpc<SkillListItem[]>> {
  178. return guard('skillInstallFolder', async () =>
  179. (await getCodex()).skills.installFromFolder(requireString(params?.srcPath, 'srcPath'), {
  180. name: params?.name ?? null,
  181. overwrite: params?.overwrite === true,
  182. }),
  183. );
  184. }
  185. async skillInstallZip(params: {
  186. data?: unknown;
  187. name?: string | null;
  188. overwrite?: boolean;
  189. }): Promise<Rpc<SkillListItem[]>> {
  190. return guard('skillInstallZip', async () =>
  191. (await getCodex()).skills.installFromZip(toBytes(params?.data), {
  192. name: params?.name ?? null,
  193. overwrite: params?.overwrite === true,
  194. }),
  195. );
  196. }
  197. async skillRemove(params: { path?: string; name?: string }): Promise<Rpc<SkillListItem[]>> {
  198. return guard('skillRemove', async () => (await getCodex()).skills.remove(params ?? {}));
  199. }
  200. async skillSetEnabled(params: {
  201. path?: string;
  202. name?: string;
  203. enabled?: boolean;
  204. }): Promise<Rpc<{ effectiveEnabled: boolean }>> {
  205. return guard('skillSetEnabled', async () => {
  206. if (typeof params?.enabled !== 'boolean') throw new Error('enabled 必须是布尔值');
  207. const effectiveEnabled = await (await getCodex()).skills.setEnabled(params, params.enabled);
  208. return { effectiveEnabled };
  209. });
  210. }
  211. /** 只允许打开用户 skills 目录或其中的某个 skill */
  212. async skillOpenFolder(params?: { path?: string }): Promise<Rpc<{ path: string }>> {
  213. return guard('skillOpenFolder', async () => {
  214. const skillsDir = getSkillsDir();
  215. const target = params?.path ? params.path : skillsDir;
  216. if (!isInsideDir(skillsDir, target) && target !== skillsDir) {
  217. throw new Error('只能打开 skills 目录内的路径');
  218. }
  219. const result = await shell.openPath(target);
  220. if (result) throw new Error(result);
  221. return { path: target };
  222. });
  223. }
  224. /** 会话默认工作目录(渲染进程也需要知道真实值,产物栏要与 cwd 对齐) */
  225. async defaultWorkspace(): Promise<Rpc<{ cwd: string }>> {
  226. return guard('defaultWorkspace', async () => ({ cwd: await defaultWorkspaceDir() }));
  227. }
  228. async threadStart(params?: {
  229. cwd?: string | null;
  230. model?: string | null;
  231. approvalPolicy?: 'untrusted' | 'on-request' | 'never';
  232. sandbox?: 'read-only' | 'workspace-write' | 'danger-full-access';
  233. }): Promise<Rpc<{ threadId: string; cwd: string }>> {
  234. return guard('threadStart', async () => {
  235. const { runtime } = await getCodex();
  236. const cwd = params?.cwd?.trim() || (await defaultWorkspaceDir());
  237. const threadId = await runtime.startThread({
  238. cwd,
  239. model: params?.model ?? null,
  240. approvalPolicy: params?.approvalPolicy ?? 'never',
  241. sandbox: params?.sandbox ?? 'workspace-write',
  242. });
  243. // 登记产物白名单:产物读写只允许发生在会话工作目录内
  244. registerArtifactRoot(cwd);
  245. return { threadId, cwd };
  246. });
  247. }
  248. /** 跑一整轮并等结果;事件流同时通过 codex/event 推给渲染进程 */
  249. async turnRun(params: {
  250. threadId?: string;
  251. input?: string;
  252. cwd?: string | null;
  253. model?: string | null;
  254. approvalPolicy?: 'untrusted' | 'on-request' | 'never';
  255. timeoutMs?: number;
  256. }): Promise<Rpc<{ turnId: string; status: string; text: string }>> {
  257. return guard('turnRun', async () => {
  258. const { runtime } = await getCodex();
  259. const threadId = requireString(params?.threadId, 'threadId');
  260. const cwd = params?.cwd?.trim() || undefined;
  261. if (cwd) registerArtifactRoot(cwd);
  262. const options = {
  263. threadId,
  264. prompt: requireString(params?.input, 'input'),
  265. cwd,
  266. model: params?.model ?? null,
  267. approvalPolicy: params?.approvalPolicy ?? ('never' as const),
  268. timeoutMs: typeof params?.timeoutMs === 'number' ? params.timeoutMs : undefined,
  269. };
  270. try {
  271. const result = await runtime.runTurn(options);
  272. return { turnId: result.turnId, status: result.status, text: result.text };
  273. } catch (error: unknown) {
  274. // 线程只存在于当前 app-server 进程里:换模型或重启应用后旧 threadId 必然认不出,
  275. // 先按 Codex 落盘的历史把它救回来(thread/resume),再重发这一轮
  276. if (!isThreadMissingError(error)) throw error;
  277. try {
  278. await runtime.resumeThread(threadId, {
  279. cwd: cwd || (await defaultWorkspaceDir()),
  280. model: options.model,
  281. approvalPolicy: options.approvalPolicy,
  282. });
  283. } catch {
  284. // 从没成功跑完过一轮的线程 Codex 根本没落盘,救不回来
  285. throw new Error('该会话的 Codex 线程已失效(中途换过模型或重启过应用),请新建会话继续');
  286. }
  287. const retried = await runtime.runTurn(options);
  288. return { turnId: retried.turnId, status: retried.status, text: retried.text };
  289. }
  290. });
  291. }
  292. async turnInterrupt(params: { threadId?: string; turnId?: string }): Promise<Rpc<Record<string, never>>> {
  293. return guard('turnInterrupt', async () => {
  294. const { runtime } = await getCodex();
  295. await runtime.interruptTurn(requireString(params?.threadId, 'threadId'), requireString(params?.turnId, 'turnId'));
  296. return {};
  297. });
  298. }
  299. async threadUnsubscribe(params: { threadId?: string }): Promise<Rpc<Record<string, never>>> {
  300. return guard('threadUnsubscribe', async () => {
  301. const { runtime } = await getCodex();
  302. await runtime.unsubscribeThread(requireString(params?.threadId, 'threadId'));
  303. return {};
  304. });
  305. }
  306. async approvalResolve(params: {
  307. token?: string;
  308. decision?: ApprovalDecision;
  309. answers?: ApprovalAnswers;
  310. }): Promise<Rpc<Record<string, never>>> {
  311. return guard('approvalResolve', async () => {
  312. const { broker } = await getCodex();
  313. const token = requireString(params?.token, 'token');
  314. const decision = params?.decision;
  315. if (!decision || !APPROVAL_DECISIONS.includes(decision)) throw new Error('decision 不合法');
  316. broker.resolve(token, decision, params?.answers);
  317. return {};
  318. });
  319. }
  320. /** 读回某个 thread 已落盘的事件(页面刷新后恢复现场) */
  321. async eventsRead(params: { threadId?: string; limit?: number }): Promise<Rpc<AgentEvent[]>> {
  322. return guard('eventsRead', async () => {
  323. const { eventLog } = await getCodex();
  324. const threadId = requireString(params?.threadId, 'threadId');
  325. return eventLog.read(threadId, typeof params?.limit === 'number' ? params.limit : undefined);
  326. });
  327. }
  328. async logsTail(params?: { limit?: number }): Promise<Rpc<Array<{ ts: string; line: string }>>> {
  329. return guard('logsTail', async () =>
  330. tailDiagnostics(typeof params?.limit === 'number' ? params.limit : undefined),
  331. );
  332. }
  333. }
  334. CodexCtl.toString = () => 'CodexCtl';
  335. export default CodexCtl;