ideaco 1.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/.dockerignore +33 -0
  2. package/.nvmrc +1 -0
  3. package/ARCHITECTURE.md +394 -0
  4. package/Dockerfile +50 -0
  5. package/LICENSE +29 -0
  6. package/README.md +206 -0
  7. package/bin/i18n.js +46 -0
  8. package/bin/ideaco.js +494 -0
  9. package/deploy.sh +15 -0
  10. package/docker-compose.yml +30 -0
  11. package/electron/main.cjs +986 -0
  12. package/electron/preload.cjs +14 -0
  13. package/electron/web-backends.cjs +854 -0
  14. package/jsconfig.json +8 -0
  15. package/next.config.mjs +34 -0
  16. package/package.json +134 -0
  17. package/postcss.config.mjs +6 -0
  18. package/public/demo/dashboard.png +0 -0
  19. package/public/demo/employee.png +0 -0
  20. package/public/demo/messages.png +0 -0
  21. package/public/demo/office.png +0 -0
  22. package/public/demo/requirement.png +0 -0
  23. package/public/logo.jpeg +0 -0
  24. package/public/logo.png +0 -0
  25. package/scripts/prepare-electron.js +67 -0
  26. package/scripts/release.js +76 -0
  27. package/src/app/api/agents/[agentId]/chat/route.js +70 -0
  28. package/src/app/api/agents/[agentId]/conversations/route.js +35 -0
  29. package/src/app/api/agents/[agentId]/route.js +106 -0
  30. package/src/app/api/avatar/route.js +104 -0
  31. package/src/app/api/browse-dir/route.js +44 -0
  32. package/src/app/api/chat/route.js +265 -0
  33. package/src/app/api/company/factory-reset/route.js +43 -0
  34. package/src/app/api/company/route.js +82 -0
  35. package/src/app/api/departments/[deptId]/agents/[agentId]/dismiss/route.js +19 -0
  36. package/src/app/api/departments/route.js +92 -0
  37. package/src/app/api/group-chat-loop/events/route.js +70 -0
  38. package/src/app/api/group-chat-loop/route.js +94 -0
  39. package/src/app/api/mailbox/route.js +100 -0
  40. package/src/app/api/messages/route.js +14 -0
  41. package/src/app/api/providers/[id]/configure/route.js +21 -0
  42. package/src/app/api/providers/[id]/refresh-cookie/route.js +38 -0
  43. package/src/app/api/providers/[id]/test-cookie/route.js +28 -0
  44. package/src/app/api/providers/route.js +11 -0
  45. package/src/app/api/requirements/route.js +242 -0
  46. package/src/app/api/secretary/route.js +65 -0
  47. package/src/app/api/system/cli-backends/route.js +91 -0
  48. package/src/app/api/system/cron/route.js +110 -0
  49. package/src/app/api/system/knowledge/route.js +104 -0
  50. package/src/app/api/system/plugins/route.js +40 -0
  51. package/src/app/api/system/skills/route.js +46 -0
  52. package/src/app/api/system/status/route.js +46 -0
  53. package/src/app/api/talent-market/[profileId]/recall/route.js +22 -0
  54. package/src/app/api/talent-market/[profileId]/route.js +17 -0
  55. package/src/app/api/talent-market/route.js +26 -0
  56. package/src/app/api/teams/route.js +773 -0
  57. package/src/app/api/ws-files/[departmentId]/file/route.js +27 -0
  58. package/src/app/api/ws-files/[departmentId]/files/route.js +22 -0
  59. package/src/app/globals.css +130 -0
  60. package/src/app/layout.jsx +40 -0
  61. package/src/app/page.jsx +97 -0
  62. package/src/components/AgentChatModal.jsx +164 -0
  63. package/src/components/AgentDetailModal.jsx +425 -0
  64. package/src/components/AgentSpyModal.jsx +481 -0
  65. package/src/components/AvatarGrid.jsx +29 -0
  66. package/src/components/BossProfileModal.jsx +162 -0
  67. package/src/components/CachedAvatar.jsx +77 -0
  68. package/src/components/ChatPanel.jsx +219 -0
  69. package/src/components/ChatShared.jsx +255 -0
  70. package/src/components/DepartmentDetail.jsx +842 -0
  71. package/src/components/DepartmentView.jsx +367 -0
  72. package/src/components/FileReference.jsx +260 -0
  73. package/src/components/FilesView.jsx +465 -0
  74. package/src/components/GroupChatView.jsx +799 -0
  75. package/src/components/Mailbox.jsx +926 -0
  76. package/src/components/MessagesView.jsx +112 -0
  77. package/src/components/OnboardingGuide.jsx +209 -0
  78. package/src/components/OrgTree.jsx +151 -0
  79. package/src/components/Overview.jsx +391 -0
  80. package/src/components/PixelOffice.jsx +2281 -0
  81. package/src/components/ProviderGrid.jsx +551 -0
  82. package/src/components/ProvidersBoard.jsx +16 -0
  83. package/src/components/RequirementDetail.jsx +1279 -0
  84. package/src/components/RequirementsBoard.jsx +187 -0
  85. package/src/components/SecretarySettings.jsx +295 -0
  86. package/src/components/SetupWizard.jsx +388 -0
  87. package/src/components/Sidebar.jsx +169 -0
  88. package/src/components/SystemMonitor.jsx +808 -0
  89. package/src/components/TalentMarket.jsx +183 -0
  90. package/src/components/TeamDetail.jsx +697 -0
  91. package/src/core/agent/base-agent.js +104 -0
  92. package/src/core/agent/chat-store.js +602 -0
  93. package/src/core/agent/cli-agent/backends/claude-code/README.md +52 -0
  94. package/src/core/agent/cli-agent/backends/claude-code/config.js +27 -0
  95. package/src/core/agent/cli-agent/backends/codebuddy/README.md +236 -0
  96. package/src/core/agent/cli-agent/backends/codebuddy/config.js +27 -0
  97. package/src/core/agent/cli-agent/backends/codex/README.md +51 -0
  98. package/src/core/agent/cli-agent/backends/codex/config.js +27 -0
  99. package/src/core/agent/cli-agent/backends/index.js +27 -0
  100. package/src/core/agent/cli-agent/backends/registry.js +580 -0
  101. package/src/core/agent/cli-agent/index.js +154 -0
  102. package/src/core/agent/index.js +60 -0
  103. package/src/core/agent/llm-agent/client.js +320 -0
  104. package/src/core/agent/llm-agent/index.js +97 -0
  105. package/src/core/agent/message-bus.js +211 -0
  106. package/src/core/agent/session.js +608 -0
  107. package/src/core/agent/tools.js +596 -0
  108. package/src/core/agent/web-agent/backends/base-backend.js +180 -0
  109. package/src/core/agent/web-agent/backends/chatgpt/client.js +146 -0
  110. package/src/core/agent/web-agent/backends/chatgpt/config.js +148 -0
  111. package/src/core/agent/web-agent/backends/chatgpt/dom-scripts.js +303 -0
  112. package/src/core/agent/web-agent/backends/index.js +91 -0
  113. package/src/core/agent/web-agent/index.js +278 -0
  114. package/src/core/agent/web-agent/web-client.js +407 -0
  115. package/src/core/employee/base-employee.js +1088 -0
  116. package/src/core/employee/index.js +35 -0
  117. package/src/core/employee/knowledge.js +327 -0
  118. package/src/core/employee/lifecycle.js +990 -0
  119. package/src/core/employee/memory/index.js +642 -0
  120. package/src/core/employee/memory/store.js +143 -0
  121. package/src/core/employee/performance.js +224 -0
  122. package/src/core/employee/secretary.js +625 -0
  123. package/src/core/employee/skills.js +398 -0
  124. package/src/core/index.js +38 -0
  125. package/src/core/organization/company.js +2600 -0
  126. package/src/core/organization/department.js +737 -0
  127. package/src/core/organization/group-chat-loop.js +264 -0
  128. package/src/core/organization/index.js +8 -0
  129. package/src/core/organization/persistence.js +111 -0
  130. package/src/core/organization/team.js +267 -0
  131. package/src/core/organization/workforce/hr.js +377 -0
  132. package/src/core/organization/workforce/providers.js +468 -0
  133. package/src/core/organization/workforce/role-archetypes.js +805 -0
  134. package/src/core/organization/workforce/talent-market.js +205 -0
  135. package/src/core/prompts.js +532 -0
  136. package/src/core/requirement.js +1789 -0
  137. package/src/core/system/audit.js +483 -0
  138. package/src/core/system/cron.js +449 -0
  139. package/src/core/system/index.js +7 -0
  140. package/src/core/system/plugin.js +2183 -0
  141. package/src/core/utils/json-parse.js +188 -0
  142. package/src/core/workspace.js +239 -0
  143. package/src/lib/api-i18n.js +211 -0
  144. package/src/lib/avatar.js +268 -0
  145. package/src/lib/client-store.js +1025 -0
  146. package/src/lib/config-validator.js +483 -0
  147. package/src/lib/format-time.js +22 -0
  148. package/src/lib/hooks.js +414 -0
  149. package/src/lib/i18n.js +134 -0
  150. package/src/lib/paths.js +23 -0
  151. package/src/lib/store.js +72 -0
  152. package/src/locales/de.js +393 -0
  153. package/src/locales/en.js +1054 -0
  154. package/src/locales/es.js +393 -0
  155. package/src/locales/fr.js +393 -0
  156. package/src/locales/ja.js +501 -0
  157. package/src/locales/ko.js +513 -0
  158. package/src/locales/zh.js +828 -0
  159. package/tailwind.config.mjs +11 -0
@@ -0,0 +1,986 @@
1
+ const { app, BrowserWindow, shell, dialog, ipcMain, session } = require('electron');
2
+ const { spawn } = require('child_process');
3
+ const path = require('path');
4
+ const fs = require('fs');
5
+ const net = require('net');
6
+ const http = require('http');
7
+ const webBackends = require('./web-backends.cjs');
8
+
9
+ const isDev = !app.isPackaged;
10
+ const disableDevtools = process.env.IDEACO_DISABLE_DEVTOOLS === '1';
11
+ const PORT = 9999;
12
+
13
+ let mainWindow = null;
14
+ let serverProcess = null;
15
+
16
+ function getResourcePath() {
17
+ if (isDev) {
18
+ return path.join(__dirname, '..');
19
+ }
20
+ return path.join(process.resourcesPath, 'app');
21
+ }
22
+
23
+ function getUserDataPath() {
24
+ return path.join(app.getPath('userData'), 'server-data');
25
+ }
26
+
27
+ function ensureDataDirs() {
28
+ // In packaged mode, write data to userData (writable) instead of resources (read-only)
29
+ const base = isDev ? getResourcePath() : getUserDataPath();
30
+ const dirs = ['data', 'data/memories', 'data/audit', 'workspace'];
31
+ for (const dir of dirs) {
32
+ const fullPath = path.join(base, dir);
33
+ if (!fs.existsSync(fullPath)) {
34
+ fs.mkdirSync(fullPath, { recursive: true });
35
+ }
36
+ }
37
+ return base;
38
+ }
39
+
40
+ function findAvailablePort(startPort) {
41
+ return new Promise((resolve) => {
42
+ const server = net.createServer();
43
+ server.listen(startPort, () => {
44
+ const port = server.address().port;
45
+ server.close(() => resolve(port));
46
+ });
47
+ server.on('error', () => {
48
+ resolve(findAvailablePort(startPort + 1));
49
+ });
50
+ });
51
+ }
52
+
53
+ function waitForServer(port, retries = 60) {
54
+ return new Promise((resolve, reject) => {
55
+ let attempts = 0;
56
+ const check = () => {
57
+ attempts++;
58
+ const client = net.createConnection({ port, host: '127.0.0.1' }, () => {
59
+ client.end();
60
+ resolve();
61
+ });
62
+ client.on('error', () => {
63
+ if (attempts >= retries) {
64
+ reject(new Error(`Server did not start after ${retries} attempts`));
65
+ } else {
66
+ setTimeout(check, 500);
67
+ }
68
+ });
69
+ };
70
+ check();
71
+ });
72
+ }
73
+
74
+ async function startNextServer(port, dataPath) {
75
+ const resourcePath = getResourcePath();
76
+ const serverJs = path.join(resourcePath, 'server.js');
77
+
78
+ if (!fs.existsSync(serverJs)) {
79
+ throw new Error(`server.js not found at ${serverJs}`);
80
+ }
81
+
82
+ const env = {
83
+ ...process.env,
84
+ NODE_ENV: 'production',
85
+ PORT: String(port),
86
+ HOSTNAME: '127.0.0.1',
87
+ // ELECTRON_RUN_AS_NODE makes Electron binary behave as plain Node.js
88
+ ELECTRON_RUN_AS_NODE: '1',
89
+ CHATGPT_PROXY_PORT: String(chatgptProxyPort || ''),
90
+ };
91
+
92
+ // If packaged, point data/workspace dirs to writable userData location
93
+ if (!isDev) {
94
+ env.IDEACO_DATA_DIR = path.join(dataPath, 'data');
95
+ env.IDEACO_WORKSPACE_DIR = path.join(dataPath, 'workspace');
96
+ }
97
+
98
+ serverProcess = spawn(process.execPath, [serverJs], {
99
+ cwd: resourcePath,
100
+ env,
101
+ stdio: ['ignore', 'pipe', 'pipe'],
102
+ });
103
+
104
+ serverProcess.stdout.on('data', (data) => {
105
+ console.log(`[server] ${data.toString().trim()}`);
106
+ });
107
+
108
+ serverProcess.stderr.on('data', (data) => {
109
+ console.error(`[server] ${data.toString().trim()}`);
110
+ });
111
+
112
+ serverProcess.on('exit', (code) => {
113
+ console.log(`[server] exited with code ${code}`);
114
+ if (code !== 0 && code !== null && mainWindow) {
115
+ dialog.showErrorBox('Server Error', `Next.js server exited unexpectedly (code ${code})`);
116
+ }
117
+ });
118
+
119
+ await waitForServer(port);
120
+ }
121
+
122
+ function createWindow(port) {
123
+ const windowOptions = {
124
+ width: 1440,
125
+ height: 900,
126
+ minWidth: 1024,
127
+ minHeight: 700,
128
+ title: 'IdeaCo',
129
+ icon: path.join(getResourcePath(), 'public', 'logo.png'),
130
+ backgroundColor: '#0a0a0a',
131
+ webPreferences: {
132
+ preload: path.join(__dirname, 'preload.cjs'),
133
+ contextIsolation: true,
134
+ nodeIntegration: false,
135
+ },
136
+ show: false,
137
+ };
138
+
139
+ // macOS: 使用隐藏标题栏 + 内嵌红绿灯按钮,背景色与页面一致
140
+ if (process.platform === 'darwin') {
141
+ windowOptions.titleBarStyle = 'hiddenInset';
142
+ windowOptions.trafficLightPosition = { x: 12, y: 12 };
143
+ }
144
+
145
+ // Windows: 自定义标题栏颜色
146
+ if (process.platform === 'win32') {
147
+ windowOptions.titleBarOverlay = {
148
+ color: '#0a0a0a',
149
+ symbolColor: '#ededed',
150
+ height: 36,
151
+ };
152
+ }
153
+
154
+ mainWindow = new BrowserWindow(windowOptions);
155
+
156
+ mainWindow.loadURL(`http://127.0.0.1:${port}`);
157
+
158
+ mainWindow.once('ready-to-show', () => {
159
+ mainWindow.show();
160
+ if (isDev && !disableDevtools) mainWindow.webContents.openDevTools();
161
+ });
162
+
163
+ mainWindow.webContents.setWindowOpenHandler(({ url }) => {
164
+ shell.openExternal(url);
165
+ return { action: 'deny' };
166
+ });
167
+
168
+ mainWindow.on('closed', () => {
169
+ mainWindow = null;
170
+ });
171
+ }
172
+
173
+ function stopServer() {
174
+ if (serverProcess) {
175
+ serverProcess.kill('SIGTERM');
176
+ setTimeout(() => {
177
+ if (serverProcess && !serverProcess.killed) {
178
+ serverProcess.kill('SIGKILL');
179
+ }
180
+ }, 5000);
181
+ serverProcess = null;
182
+ }
183
+ }
184
+
185
+ // === ChatGPT Cookie Helpers (shared between login and refresh) ===
186
+ // Default partition and UA (kept for backward compatibility with login-chatgpt IPC handler)
187
+ const CHATGPT_PARTITION = 'persist:chatgpt-login';
188
+ const CLEAN_CHROME_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.191 Safari/537.36';
189
+
190
+ /**
191
+ * Get session for a backend (or default to ChatGPT)
192
+ * @param {object} [backend] - Backend config from webBackends.backends
193
+ */
194
+ function getBackendSession(backend) {
195
+ const partition = backend?.partition || CHATGPT_PARTITION;
196
+ const ua = backend?.userAgent || CLEAN_CHROME_UA;
197
+ const ses = session.fromPartition(partition);
198
+ ses.setUserAgent(ua);
199
+ return ses;
200
+ }
201
+
202
+ // Shortcut: ChatGPT session (backward compat)
203
+ function getChatGPTSession() {
204
+ return getBackendSession(webBackends.backends.get('chatgpt'));
205
+ }
206
+
207
+ /**
208
+ * Collect cookies for a backend
209
+ * @param {object} [backend] - Backend config; defaults to ChatGPT
210
+ */
211
+ async function collectCookiesForBackend(backend) {
212
+ const ses = getBackendSession(backend);
213
+ const domains = backend?.cookieDomains || ['.chatgpt.com', 'chatgpt.com', '.openai.com'];
214
+ const results = await Promise.all(
215
+ domains.map(d => ses.cookies.get({ domain: d }).catch(() => []))
216
+ );
217
+ const all = results.flat();
218
+ const seen = new Set();
219
+ return all.filter(c => {
220
+ const key = `${c.name}@${c.domain}`;
221
+ if (seen.has(key)) return false;
222
+ seen.add(key);
223
+ return true;
224
+ });
225
+ }
226
+
227
+ // Backward compat
228
+ async function collectChatGPTCookies() {
229
+ return collectCookiesForBackend(webBackends.backends.get('chatgpt'));
230
+ }
231
+
232
+ /**
233
+ * Check if cookies contain a valid session token for a backend
234
+ * @param {Array} cookies
235
+ * @param {object} [backend]
236
+ */
237
+ function hasSessionToken(cookies, backend) {
238
+ const tokenNames = backend?.sessionTokenNames || [
239
+ '__Secure-next-auth.session-token',
240
+ '__Secure-next-auth.session-token.0',
241
+ ];
242
+ return cookies.some(c => tokenNames.includes(c.name));
243
+ }
244
+
245
+ function cookiesToString(cookies) {
246
+ return cookies.map(c => `${c.name}=${c.value}`).join('; ');
247
+ }
248
+
249
+ /**
250
+ * Open a login window for a web backend and wait for the user to complete login.
251
+ * Supports any backend — uses backend config for URL, partition, session token detection.
252
+ * @param {object} [backend] - Backend config from webBackends.backends; defaults to ChatGPT
253
+ * @returns {Promise<{ok: boolean, cookie?: string, error?: string}>}
254
+ */
255
+ function openLoginWindow(backend) {
256
+ const backendConfig = backend || webBackends.backends.get('chatgpt');
257
+ const displayName = backendConfig?.displayName || 'ChatGPT';
258
+ const siteUrl = backendConfig?.siteUrl || 'https://chatgpt.com/';
259
+ const partition = backendConfig?.partition || CHATGPT_PARTITION;
260
+
261
+ return new Promise((resolve) => {
262
+ const loginWin = new BrowserWindow({
263
+ width: 520,
264
+ height: 720,
265
+ title: `Login to ${displayName}`,
266
+ parent: mainWindow,
267
+ modal: false,
268
+ webPreferences: {
269
+ partition: partition,
270
+ contextIsolation: true,
271
+ nodeIntegration: false,
272
+ },
273
+ });
274
+
275
+ loginWin.loadURL(siteUrl);
276
+ if (isDev) loginWin.webContents.openDevTools({ mode: 'detach' });
277
+
278
+ let resolved = false;
279
+
280
+ const checkLogin = async () => {
281
+ if (resolved) return;
282
+ try {
283
+ const cookies = await collectCookiesForBackend(backendConfig);
284
+ if (isDev) console.log(`[login-${backendConfig?.id || 'chatgpt'}] poll cookies:`, cookies.length, 'hasToken:', hasSessionToken(cookies, backendConfig));
285
+ if (hasSessionToken(cookies, backendConfig)) {
286
+ resolved = true;
287
+ if (isDev) console.log(`[login-${backendConfig?.id || 'chatgpt'}] login detected! cookie names:`, cookies.map(c => c.name));
288
+ loginWin.close();
289
+ resolve({ ok: true, cookie: cookiesToString(cookies) });
290
+ }
291
+ } catch (e) {
292
+ if (isDev) console.log(`[login-${backendConfig?.id || 'chatgpt'}] checkLogin error:`, e.message);
293
+ }
294
+ };
295
+
296
+ const pollInterval = setInterval(() => {
297
+ if (resolved) { clearInterval(pollInterval); return; }
298
+ checkLogin();
299
+ }, 3000);
300
+
301
+ loginWin.on('closed', () => {
302
+ clearInterval(pollInterval);
303
+ if (!resolved) {
304
+ resolved = true;
305
+ resolve({ ok: false, error: `${displayName} login window closed` });
306
+ }
307
+ });
308
+ });
309
+ }
310
+
311
+ // === ChatGPT Browser Login: open a window, let user login, extract cookies ===
312
+ ipcMain.handle('login-chatgpt', async () => {
313
+ // Quick check: if already logged in from a previous session, return cookies directly
314
+ const existingCookies = await collectChatGPTCookies();
315
+ if (isDev) console.log('[login-chatgpt] existing cookies:', existingCookies.length, 'names:', existingCookies.map(c => c.name));
316
+ if (hasSessionToken(existingCookies)) {
317
+ if (isDev) console.log('[login-chatgpt] found existing session token, reusing cookies');
318
+ return { ok: true, cookie: cookiesToString(existingCookies) };
319
+ }
320
+ // No valid session — open login window
321
+ return await openLoginWindow();
322
+ });
323
+
324
+ // === Refresh ChatGPT Cookie: silently re-collect cookies, open login window only if truly expired ===
325
+ ipcMain.handle('refresh-chatgpt-cookie', async () => {
326
+ const cookies = await collectChatGPTCookies();
327
+ if (isDev) console.log('[refresh-cookie] cookies:', cookies.length, 'hasToken:', hasSessionToken(cookies));
328
+
329
+ if (hasSessionToken(cookies)) {
330
+ return { ok: true, cookie: cookiesToString(cookies) };
331
+ }
332
+
333
+ // Cookies expired — open login window for re-login
334
+ if (isDev) console.log('[refresh-cookie] session expired, opening login window...');
335
+ return await openLoginWindow();
336
+ });
337
+
338
+ // === ChatGPT Proxy Server ===
339
+ // Local HTTP proxy that forwards requests to ChatGPT using Chromium's network stack
340
+ // (ses.fetch with persist:chatgpt-login session). This ensures:
341
+ // 1. TLS fingerprint = real Chromium (not Node.js)
342
+ // 2. Cookies auto-managed by Chromium session
343
+ // 3. HTTP/2, header ordering, etc. all match real browser
344
+ let chatgptProxyPort = null;
345
+
346
+ function startChatGPTProxy() {
347
+ return new Promise((resolve) => {
348
+ const ses = getChatGPTSession();
349
+
350
+ const proxyServer = http.createServer(async (req, res) => {
351
+ // Only accept POST from localhost
352
+ if (req.method !== 'POST') {
353
+ res.writeHead(405);
354
+ res.end('Method not allowed');
355
+ return;
356
+ }
357
+
358
+ // Read request body
359
+ const chunks = [];
360
+ for await (const chunk of req) chunks.push(chunk);
361
+ const bodyBuf = Buffer.concat(chunks);
362
+
363
+ try {
364
+ const { url, method, headers, body } = JSON.parse(bodyBuf.toString());
365
+
366
+ // Special route: DOM-based chat via hidden BrowserWindow
367
+ if (url === '__dom_chat__' && method === 'DOM_CHAT') {
368
+ try {
369
+ const params = JSON.parse(body || '{}');
370
+ const result = await webBackends.domChat(params, {
371
+ BrowserWindow, session, isDev,
372
+ openLoginWindow: (backend) => openLoginWindow(backend),
373
+ });
374
+ res.writeHead(200, { 'Content-Type': 'application/json' });
375
+ res.end(JSON.stringify(result));
376
+ } catch (domErr) {
377
+ res.writeHead(200, { 'Content-Type': 'application/json' });
378
+ res.end(JSON.stringify({ text: '', error: domErr.message }));
379
+ }
380
+ return;
381
+ }
382
+
383
+ if (!url || !url.startsWith('https://chatgpt.com/')) {
384
+ res.writeHead(400);
385
+ res.end(JSON.stringify({ error: 'Invalid URL — only chatgpt.com allowed' }));
386
+ return;
387
+ }
388
+
389
+ if (isDev) console.log(`[chatgpt-proxy] ${method || 'GET'} ${url}`);
390
+
391
+ // Build headers for Chromium fetch — use real browser headers
392
+ const fetchHeaders = { ...(headers || {}) };
393
+ // Remove Cookie header — Chromium session manages cookies automatically
394
+ delete fetchHeaders['Cookie'];
395
+ delete fetchHeaders['cookie'];
396
+
397
+ const fetchOpts = {
398
+ method: method || 'GET',
399
+ headers: fetchHeaders,
400
+ };
401
+
402
+ if (body && (method === 'POST' || method === 'PUT' || method === 'PATCH')) {
403
+ fetchOpts.body = body;
404
+ }
405
+
406
+ // Use ses.fetch — Chromium network stack with the persist:chatgpt-login session
407
+ // This automatically sends cookies, uses Chromium TLS, etc.
408
+ const response = await ses.fetch(url, fetchOpts);
409
+
410
+ // Forward response status and headers
411
+ const responseHeaders = {};
412
+ response.headers.forEach((value, key) => {
413
+ // Skip transfer-encoding since we're re-sending the body
414
+ if (key.toLowerCase() === 'transfer-encoding') return;
415
+ responseHeaders[key] = value;
416
+ });
417
+
418
+ const responseBody = await response.text();
419
+
420
+ res.writeHead(response.status, responseHeaders);
421
+ res.end(responseBody);
422
+
423
+ if (isDev) console.log(`[chatgpt-proxy] → ${response.status} (${responseBody.length} bytes)`);
424
+ } catch (err) {
425
+ console.error('[chatgpt-proxy] error:', err.message);
426
+ res.writeHead(502);
427
+ res.end(JSON.stringify({ error: err.message }));
428
+ }
429
+ });
430
+
431
+ // Listen on random available port on localhost only
432
+ proxyServer.listen(0, '127.0.0.1', () => {
433
+ chatgptProxyPort = proxyServer.address().port;
434
+ console.log(`[chatgpt-proxy] listening on http://127.0.0.1:${chatgptProxyPort}`);
435
+ resolve(chatgptProxyPort);
436
+ });
437
+ });
438
+ }
439
+
440
+ // IPC to get the proxy port
441
+ ipcMain.handle('get-chatgpt-proxy-port', () => {
442
+ return chatgptProxyPort;
443
+ });
444
+
445
+ // === Selector Recording & Persistence (delegated to web-backends.cjs) ===
446
+ // Users can calibrate selectors by clicking on actual UI elements.
447
+ // Selector storage and management is handled by the web-backends module.
448
+ // Initialize backends with userData path (called after app.whenReady)
449
+ function initWebBackends() {
450
+ webBackends.init({ userDataPath: app.getPath('userData') });
451
+ }
452
+
453
+ /**
454
+ * Convenience: get selectors for a given backend and role
455
+ */
456
+ function getSelectorsForBackend(backendId, role) {
457
+ const backend = webBackends.backends.get(backendId);
458
+ if (!backend) return [];
459
+ return webBackends.getSelectors(backend, role);
460
+ }
461
+
462
+ /**
463
+ * Generate a CSS selector for a DOM node, given its attributes.
464
+ * Used in the main process to build a selector from CDP node data.
465
+ */
466
+ function buildSelectorFromNodeAttrs(nodeName, attributes) {
467
+ const attrs = {};
468
+ // CDP returns attributes as flat array: [name, value, name, value, ...]
469
+ for (let i = 0; i < attributes.length; i += 2) {
470
+ attrs[attributes[i]] = attributes[i + 1];
471
+ }
472
+ const tag = nodeName.toLowerCase();
473
+ if (attrs.id) return '#' + attrs.id;
474
+ if (attrs['data-testid']) return `${tag}[data-testid="${attrs['data-testid']}"]`;
475
+ if (attrs['aria-label']) return `${tag}[aria-label="${attrs['aria-label']}"]`;
476
+ if (attrs['data-message-author-role']) return `${tag}[data-message-author-role="${attrs['data-message-author-role']}"]`;
477
+ if (attrs.placeholder) return `${tag}[placeholder="${attrs.placeholder}"]`;
478
+ if (attrs.contenteditable === 'true') return `${tag}[contenteditable="true"]`;
479
+ if (attrs.href) return `${tag}[href="${attrs.href}"]`;
480
+ // Fallback: tag + class
481
+ if (attrs.class) {
482
+ const cls = attrs.class.split(/\s+/).filter(c => c && !c.startsWith('__')).slice(0, 2).join('.');
483
+ if (cls) return `${tag}.${cls}`;
484
+ }
485
+ return tag;
486
+ }
487
+
488
+ /**
489
+ * IPC: Open calibration windows for selector recording.
490
+ * Uses CDP (Chrome DevTools Protocol) Overlay.inspectNodeRequested —
491
+ * the same mechanism as Chrome DevTools "select element" button.
492
+ * No JS event listeners injected into the page at all.
493
+ *
494
+ * Dual-window:
495
+ * 1. Guide window (small, always-on-top) — shows step instructions
496
+ * 2. ChatGPT window — CDP inspect mode highlights & selects elements
497
+ */
498
+ ipcMain.handle('calibrate-selectors', async () => {
499
+ /**
500
+ * Calibration flow (redesigned):
501
+ * Step 1: newChat — select the "New Chat" button (starts from homepage)
502
+ * Step 2: (auto-pause) user clicks input box naturally
503
+ * Step 3: input — select the input box
504
+ * Step 4: (auto-pause) user types a message
505
+ * Step 5: send — select the send button
506
+ * Step 6: (auto-pause) user clicks send & waits for AI reply
507
+ * Step 7: response — select the AI reply bubble (LAST one)
508
+ *
509
+ * "auto-pause" steps pause inspect mode so user can interact with the page,
510
+ * then show a "Ready / 继续" button to re-enter selection mode.
511
+ */
512
+ // 从后端配置获取校准步骤
513
+ const chatgptBackend = webBackends.backends.get('chatgpt');
514
+ const STEPS = chatgptBackend ? chatgptBackend.calibrationSteps : [];
515
+
516
+ return new Promise((resolve) => {
517
+ // --- 1. Guide window ---
518
+ const guideWin = new BrowserWindow({
519
+ width: 460,
520
+ height: 580,
521
+ x: 50,
522
+ y: 80,
523
+ alwaysOnTop: true,
524
+ resizable: false,
525
+ minimizable: false,
526
+ title: 'IdeaCo — Selector Calibration',
527
+ webPreferences: { contextIsolation: true, nodeIntegration: false },
528
+ });
529
+
530
+ let inspectPaused = false;
531
+
532
+ const buildGuideHTML = (stepIdx) => {
533
+ const step = STEPS[stepIdx];
534
+ const isPauseStep = step.type === 'pause';
535
+
536
+ const stepsHtml = STEPS.map((s, i) => {
537
+ // Only show 'select' steps in the progress list, but also show current pause step
538
+ const status = i < stepIdx ? '✅' : i === stepIdx ? '👉' : '⬜';
539
+ const opacity = i === stepIdx ? '1' : i > stepIdx ? '0.35' : '0.6';
540
+ const bg = i === stepIdx
541
+ ? (isPauseStep ? 'background:rgba(234,179,8,0.15);' : 'background:rgba(67,97,238,0.15);')
542
+ : '';
543
+ return `<div style="display:flex;align-items:center;gap:10px;padding:7px 14px;opacity:${opacity};border-radius:8px;${bg}">
544
+ <span style="font-size:16px">${status}</span>
545
+ <span style="font-size:18px">${s.icon}</span>
546
+ <div><div style="font-size:12px;font-weight:600">${s.zh}</div><div style="font-size:10px;color:#888;margin-top:2px">${s.en}</div></div>
547
+ </div>`;
548
+ }).join('');
549
+
550
+ // For pause steps: show a prominent "Continue" button
551
+ // For select steps: show status + skip button
552
+ let controlsHtml = '';
553
+ let statusText = '';
554
+
555
+ if (isPauseStep) {
556
+ statusText = '🟡 已暂停 — 请在 ChatGPT 页面中操作,完成后点击继续';
557
+ controlsHtml = `
558
+ <button style="background:rgba(34,197,94,0.25);border-color:rgba(34,197,94,0.5);color:#4ade80;padding:10px 32px;border-radius:8px;border:1px solid;font-size:14px;font-weight:700;cursor:pointer;-webkit-app-region:no-drag"
559
+ onclick="console.log('__CONTINUE__')">▶️ 继续 Continue</button>
560
+ <button style="background:rgba(120,120,120,0.2);border-color:rgba(120,120,120,0.4);color:#aaa;padding:8px 16px;border-radius:8px;border:1px solid;font-size:12px;cursor:pointer;-webkit-app-region:no-drag"
561
+ onclick="console.log('__SKIP_STEP__')">⏭️ 跳过 Skip</button>`;
562
+ } else {
563
+ statusText = '🔵 选择模式 — 鼠标移到元素上高亮,点击选中';
564
+ controlsHtml = `
565
+ <button style="background:rgba(234,179,8,0.25);border-color:rgba(234,179,8,0.5);color:#facc15;padding:8px 18px;border-radius:8px;border:1px solid;font-size:13px;font-weight:600;cursor:pointer;-webkit-app-region:no-drag"
566
+ onclick="console.log('__TOGGLE_PAUSE__')">⏸️ 暂停 Pause</button>
567
+ <button style="background:rgba(120,120,120,0.2);border-color:rgba(120,120,120,0.4);color:#aaa;padding:8px 16px;border-radius:8px;border:1px solid;font-size:12px;cursor:pointer;-webkit-app-region:no-drag"
568
+ onclick="console.log('__SKIP_STEP__')">⏭️ 跳过 Skip</button>`;
569
+ }
570
+
571
+ const selectSteps = STEPS.filter(s => s.type === 'select');
572
+ const currentSelectIdx = step.type === 'select' ? selectSteps.indexOf(step) + 1 : selectSteps.findIndex(s => STEPS.indexOf(s) > stepIdx) ;
573
+ const progressText = `Step ${stepIdx + 1} / ${STEPS.length} (录制 ${currentSelectIdx > 0 ? currentSelectIdx : '—'} / ${selectSteps.length})`;
574
+
575
+ return `<!DOCTYPE html><html><head><meta charset="utf-8">
576
+ <style>
577
+ * { margin:0; padding:0; box-sizing:border-box; }
578
+ body { font-family:system-ui,-apple-system,sans-serif; background:#1a1a2e; color:#e0e0e0;
579
+ padding:16px; user-select:none; overflow-y:auto; }
580
+ .header { text-align:center; margin-bottom:10px; }
581
+ .header h2 { font-size:16px; color:#4361ee; margin-bottom:4px; }
582
+ .header p { font-size:11px; color:#888; }
583
+ .steps { display:flex; flex-direction:column; gap:3px; margin-bottom:12px; }
584
+ .controls { display:flex; gap:8px; justify-content:center; margin-top:12px; flex-wrap:wrap; }
585
+ .status { text-align:center; margin-top:10px; font-size:11px; padding:6px 10px; border-radius:6px;
586
+ background:rgba(255,255,255,0.05); }
587
+ .footer { text-align:center; margin-top:8px; font-size:10px; color:#555; }
588
+ </style></head><body>
589
+ <div class="header">
590
+ <h2>🎯 Selector Calibration</h2>
591
+ <p>${progressText}</p>
592
+ </div>
593
+ <div class="steps">${stepsHtml}</div>
594
+ <div class="controls">${controlsHtml}</div>
595
+ <div class="status">${statusText}</div>
596
+ <div class="footer">关闭任一窗口可取消校准</div>
597
+ </body></html>`;
598
+ };
599
+
600
+ const loadGuideStep = (stepIdx) => {
601
+ if (!guideWin.isDestroyed()) {
602
+ guideWin.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(buildGuideHTML(stepIdx)));
603
+ }
604
+ };
605
+
606
+ // --- 2. ChatGPT window ---
607
+ const chatWin = new BrowserWindow({
608
+ width: 1100,
609
+ height: 820,
610
+ x: 530,
611
+ y: 60,
612
+ title: 'ChatGPT — Select Elements to Calibrate',
613
+ webPreferences: {
614
+ partition: CHATGPT_PARTITION,
615
+ contextIsolation: true,
616
+ nodeIntegration: false,
617
+ },
618
+ });
619
+
620
+ chatWin.loadURL('https://chatgpt.com/');
621
+
622
+ let currentStep = 0;
623
+ const recorded = {};
624
+ let resolved = false;
625
+ const dbg = chatWin.webContents.debugger;
626
+
627
+ const cleanup = () => {
628
+ try { dbg.detach(); } catch {}
629
+ if (!guideWin.isDestroyed()) guideWin.close();
630
+ if (!chatWin.isDestroyed()) chatWin.close();
631
+ };
632
+
633
+ const finishResolve = (result) => {
634
+ if (resolved) return;
635
+ resolved = true;
636
+ cleanup();
637
+ resolve(result);
638
+ };
639
+
640
+ // Attach CDP debugger
641
+ try {
642
+ dbg.attach('1.3');
643
+ } catch (e) {
644
+ console.error('[calibrate] Failed to attach debugger:', e.message);
645
+ guideWin.close();
646
+ chatWin.close();
647
+ resolve({ ok: false, error: 'Failed to attach CDP debugger: ' + e.message });
648
+ return;
649
+ }
650
+
651
+ dbg.sendCommand('DOM.enable').catch(() => {});
652
+ dbg.sendCommand('Overlay.enable').catch(() => {});
653
+
654
+ const startInspectMode = () => {
655
+ if (inspectPaused) return;
656
+ dbg.sendCommand('Overlay.setInspectMode', {
657
+ mode: 'searchForNode',
658
+ highlightConfig: {
659
+ showInfo: true,
660
+ showStyles: false,
661
+ showRulers: false,
662
+ showAccessibilityInfo: false,
663
+ contentColor: { r: 67, g: 97, b: 238, a: 0.3 },
664
+ paddingColor: { r: 67, g: 97, b: 238, a: 0.15 },
665
+ borderColor: { r: 67, g: 97, b: 238, a: 0.8 },
666
+ marginColor: { r: 67, g: 97, b: 238, a: 0.1 },
667
+ },
668
+ }).catch(e => {
669
+ if (isDev) console.log('[calibrate] setInspectMode error:', e.message);
670
+ });
671
+ };
672
+
673
+ const stopInspectMode = () => {
674
+ dbg.sendCommand('Overlay.setInspectMode', {
675
+ mode: 'none',
676
+ highlightConfig: {},
677
+ }).catch(() => {});
678
+ };
679
+
680
+ /**
681
+ * Enter a step: if it's a 'pause' step, stop inspect mode and show continue button.
682
+ * If it's a 'select' step, start inspect mode.
683
+ */
684
+ const enterStep = (stepIdx) => {
685
+ if (stepIdx >= STEPS.length) {
686
+ // All done
687
+ const data = { recorded, timestamp: new Date().toISOString() };
688
+ if (chatgptBackend) webBackends.saveSelectors(chatgptBackend, data);
689
+ finishResolve({ ok: true, selectors: recorded });
690
+ return;
691
+ }
692
+
693
+ currentStep = stepIdx;
694
+ const step = STEPS[stepIdx];
695
+
696
+ if (step.type === 'pause') {
697
+ // Auto-pause: stop inspect so user can interact with ChatGPT
698
+ inspectPaused = true;
699
+ stopInspectMode();
700
+ if (isDev) console.log(`[calibrate] Step ${stepIdx + 1}: AUTO-PAUSE — ${step.zh}`);
701
+ } else {
702
+ // Select step: enable inspect mode
703
+ inspectPaused = false;
704
+ if (isDev) console.log(`[calibrate] Step ${stepIdx + 1}: SELECT — ${step.role}`);
705
+ startInspectMode();
706
+ }
707
+
708
+ loadGuideStep(stepIdx);
709
+ };
710
+
711
+ const advanceStep = () => {
712
+ enterStep(currentStep + 1);
713
+ };
714
+
715
+ // Listen for button clicks from guide window
716
+ guideWin.webContents.on('console-message', (_event, _level, message) => {
717
+ if (resolved) return;
718
+
719
+ if (message === '__CONTINUE__') {
720
+ // User finished the pause-step action, advance to next (select) step
721
+ if (isDev) console.log(`[calibrate] Step ${currentStep + 1}: CONTINUE from pause`);
722
+ advanceStep();
723
+ }
724
+
725
+ if (message === '__TOGGLE_PAUSE__') {
726
+ // Manual pause/resume during a select step
727
+ inspectPaused = !inspectPaused;
728
+ if (isDev) console.log('[calibrate] Manual', inspectPaused ? 'PAUSE' : 'RESUME');
729
+ if (inspectPaused) {
730
+ stopInspectMode();
731
+ } else {
732
+ startInspectMode();
733
+ }
734
+ loadGuideStep(currentStep);
735
+ }
736
+
737
+ if (message === '__SKIP_STEP__') {
738
+ if (isDev) console.log(`[calibrate] Step ${currentStep + 1}: SKIPPED`);
739
+ advanceStep();
740
+ }
741
+ });
742
+
743
+ // CDP inspect node event — only fires during 'select' steps
744
+ dbg.on('message', async (_event, method, params) => {
745
+ if (resolved || inspectPaused) return;
746
+ if (STEPS[currentStep]?.type !== 'select') return;
747
+
748
+ if (method === 'Overlay.inspectNodeRequested') {
749
+ const backendNodeId = params.backendNodeId;
750
+ if (isDev) console.log('[calibrate] inspectNodeRequested, backendNodeId:', backendNodeId);
751
+
752
+ try {
753
+ const { node } = await dbg.sendCommand('DOM.describeNode', { backendNodeId });
754
+ const selector = buildSelectorFromNodeAttrs(node.nodeName, node.attributes || []);
755
+ const step = STEPS[currentStep];
756
+
757
+ if (step.role === 'response') {
758
+ // For response bubbles, we need a selector that matches ALL similar bubbles
759
+ // so we can always pick the last one. The user clicked one bubble — we look
760
+ // for a generalized selector via JS in the page context.
761
+ const generalizedSelector = await chatWin.webContents.executeJavaScript(`
762
+ (function() {
763
+ // Find the element user clicked
764
+ const clicked = document.querySelector(${JSON.stringify(selector)});
765
+ if (!clicked) return ${JSON.stringify(selector)};
766
+
767
+ // Strategy 1: Check if the clicked element's selector already matches multiple
768
+ const directMatches = document.querySelectorAll(${JSON.stringify(selector)});
769
+ if (directMatches.length > 1) return ${JSON.stringify(selector)};
770
+
771
+ // Strategy 2: Walk up and find an ancestor whose tag+attribute selector
772
+ // matches multiple sibling-like elements (i.e. other response bubbles)
773
+ let el = clicked;
774
+ const maxDepth = 6;
775
+ for (let depth = 0; depth < maxDepth && el && el !== document.body; depth++) {
776
+ // Try data-message-author-role attribute (ChatGPT specific)
777
+ const role = el.getAttribute('data-message-author-role');
778
+ if (role === 'assistant') {
779
+ const sel = el.tagName.toLowerCase() + '[data-message-author-role="assistant"]';
780
+ if (document.querySelectorAll(sel).length >= 1) return sel;
781
+ }
782
+ // Try data-testid
783
+ const testId = el.getAttribute('data-testid');
784
+ if (testId && testId.includes('conversation') || testId && testId.includes('message')) {
785
+ const sel = el.tagName.toLowerCase() + '[data-testid="' + testId + '"]';
786
+ if (document.querySelectorAll(sel).length > 1) return sel;
787
+ }
788
+ // Try class-based: find a class that yields multiple matches
789
+ if (el.classList.length > 0) {
790
+ for (const cls of el.classList) {
791
+ if (cls.startsWith('__') || cls.length < 3) continue;
792
+ const sel = el.tagName.toLowerCase() + '.' + cls;
793
+ const matches = document.querySelectorAll(sel);
794
+ // Good if it matches more than 1 (multiple bubbles)
795
+ if (matches.length > 1 && matches.length < 50) return sel;
796
+ }
797
+ }
798
+ el = el.parentElement;
799
+ }
800
+
801
+ // Strategy 3: fallback — use the original selector
802
+ return ${JSON.stringify(selector)};
803
+ })()
804
+ `);
805
+
806
+ recorded[step.role] = generalizedSelector;
807
+ if (isDev) {
808
+ const count = await chatWin.webContents.executeJavaScript(
809
+ `document.querySelectorAll(${JSON.stringify(generalizedSelector)}).length`
810
+ );
811
+ console.log(`[calibrate] Step ${currentStep + 1}: ${step.role} => ${generalizedSelector} (matches ${count} elements, will use last)`);
812
+ }
813
+ } else {
814
+ recorded[step.role] = selector;
815
+ if (isDev) console.log(`[calibrate] Step ${currentStep + 1}: ${step.role} => ${selector}`);
816
+ }
817
+
818
+ advanceStep();
819
+ } catch (e) {
820
+ if (isDev) console.log('[calibrate] describeNode error:', e.message);
821
+ startInspectMode();
822
+ }
823
+ }
824
+ });
825
+
826
+ // Start first step once ChatGPT page loads
827
+ chatWin.webContents.on('dom-ready', () => {
828
+ if (isDev) console.log('[calibrate] dom-ready, entering step 1');
829
+ setTimeout(() => {
830
+ enterStep(0);
831
+ }, 1500);
832
+ });
833
+
834
+ // If either window is closed, save partial and cancel
835
+ chatWin.on('closed', () => {
836
+ if (!resolved) {
837
+ if (Object.keys(recorded).length > 0 && chatgptBackend) {
838
+ const existing = webBackends.loadSelectors(chatgptBackend);
839
+ const merged = { ...existing.recorded, ...recorded };
840
+ webBackends.saveSelectors(chatgptBackend, { recorded: merged, timestamp: new Date().toISOString() });
841
+ }
842
+ finishResolve({ ok: false, partial: recorded, error: 'Calibration window closed' });
843
+ }
844
+ });
845
+ guideWin.on('closed', () => {
846
+ if (!resolved) {
847
+ finishResolve({ ok: false, partial: recorded, error: 'Guide window closed' });
848
+ }
849
+ });
850
+ });
851
+ });
852
+
853
+ /**
854
+ * IPC: Get current selector status (which are recorded vs default)
855
+ */
856
+ ipcMain.handle('get-selector-status', () => {
857
+ const backend = webBackends.backends.get('chatgpt');
858
+ if (!backend) return { recorded: {}, timestamp: null, defaults: {} };
859
+ const saved = webBackends.loadSelectors(backend);
860
+ return {
861
+ recorded: saved.recorded || {},
862
+ timestamp: saved.timestamp,
863
+ defaults: backend.defaultSelectors,
864
+ };
865
+ });
866
+
867
+ /**
868
+ * IPC: Reset selectors to defaults
869
+ */
870
+ ipcMain.handle('reset-selectors', () => {
871
+ const backend = webBackends.backends.get('chatgpt');
872
+ if (!backend || !backend.selectorsFile) return { ok: true };
873
+ try {
874
+ if (fs.existsSync(backend.selectorsFile)) {
875
+ fs.unlinkSync(backend.selectorsFile);
876
+ }
877
+ return { ok: true };
878
+ } catch (e) {
879
+ return { ok: false, error: e.message };
880
+ }
881
+ });
882
+
883
+ // === DOM Interaction via hidden BrowserWindow (delegated to web-backends.cjs) ===
884
+ // Each employee gets an independent BrowserWindow via webBackends.ensureSessionWindow().
885
+ // DOM scripts, selectors, and polling logic are managed by web-backends.cjs.
886
+ // This section only contains IPC handlers that delegate to webBackends.
887
+
888
+ /**
889
+ * IPC handler: send a message via DOM interaction and wait for the response.
890
+ * Delegates to webBackends.domChat() which manages per-session windows.
891
+ */
892
+ ipcMain.handle('chatgpt-dom-chat', async (_event, params) => {
893
+ try {
894
+ return await webBackends.domChat(params, {
895
+ BrowserWindow, session, isDev,
896
+ openLoginWindow: (backend) => openLoginWindow(backend),
897
+ });
898
+ } catch (err) {
899
+ console.error('[dom-chat] Error:', err.message);
900
+ return { error: err.message, text: '' };
901
+ }
902
+ });
903
+
904
+ /**
905
+ * IPC handler: force refresh all chat windows
906
+ */
907
+ ipcMain.handle('refresh-chat-window', async () => {
908
+ webBackends.closeAllWindows();
909
+ return { ok: true };
910
+ });
911
+
912
+ app.whenReady().then(async () => {
913
+ try {
914
+ // Initialize web backends (selectors file paths, window cleanup timer, etc.)
915
+ initWebBackends();
916
+
917
+ // Start the ChatGPT proxy before the Next.js server so it's ready when needed
918
+ const proxyPort = await startChatGPTProxy();
919
+ console.log(`[startup] ChatGPT proxy ready on port ${proxyPort}`);
920
+
921
+ // Write proxy port to temp files so Next.js server can discover it
922
+ // Write to multiple locations to ensure discoverability (os.tmpdir() may differ from app.getPath('temp'))
923
+ const portLocations = [
924
+ path.join(app.getPath('temp'), 'ideaco-chatgpt-proxy-port'),
925
+ path.join(require('os').homedir(), '.ideaco-chatgpt-proxy-port'),
926
+ ];
927
+ for (const loc of portLocations) {
928
+ try { fs.writeFileSync(loc, String(proxyPort)); } catch {}
929
+ }
930
+ if (isDev) console.log(`[startup] Proxy port written to: ${portLocations.join(', ')}`);
931
+
932
+ const dataPath = ensureDataDirs();
933
+
934
+ if (isDev) {
935
+ // Dev 模式:直接连接已运行的 Next.js dev server 或用 next dev 启动
936
+ const port = PORT;
937
+ const isRunning = await new Promise((resolve) => {
938
+ const client = net.createConnection({ port, host: '127.0.0.1' }, () => {
939
+ client.end();
940
+ resolve(true);
941
+ });
942
+ client.on('error', () => resolve(false));
943
+ });
944
+
945
+ if (!isRunning) {
946
+ // 自动启动 next dev
947
+ const npxPath = process.platform === 'win32' ? 'npx.cmd' : 'npx';
948
+ serverProcess = spawn(npxPath, ['next', 'dev', '-p', String(port)], {
949
+ cwd: path.join(__dirname, '..'),
950
+ env: { ...process.env, CHATGPT_PROXY_PORT: String(proxyPort) },
951
+ stdio: ['ignore', 'pipe', 'pipe'],
952
+ });
953
+ serverProcess.stdout.on('data', (d) => console.log(`[next-dev] ${d.toString().trim()}`));
954
+ serverProcess.stderr.on('data', (d) => console.error(`[next-dev] ${d.toString().trim()}`));
955
+ await waitForServer(port);
956
+ }
957
+
958
+ createWindow(port);
959
+ } else {
960
+ // Production 模式:启动 standalone server.js
961
+ const port = await findAvailablePort(PORT);
962
+ await startNextServer(port, dataPath);
963
+ createWindow(port);
964
+ }
965
+ } catch (err) {
966
+ dialog.showErrorBox('Startup Error', err.message);
967
+ app.quit();
968
+ }
969
+ });
970
+
971
+ app.on('window-all-closed', () => {
972
+ stopServer();
973
+ webBackends.cleanup();
974
+ app.quit();
975
+ });
976
+
977
+ app.on('before-quit', () => {
978
+ stopServer();
979
+ webBackends.cleanup();
980
+ });
981
+
982
+ app.on('activate', () => {
983
+ if (BrowserWindow.getAllWindows().length === 0) {
984
+ findAvailablePort(PORT).then((port) => createWindow(port));
985
+ }
986
+ });