codexCtl.ts 12 KB

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