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.
- package/.dockerignore +33 -0
- package/.nvmrc +1 -0
- package/ARCHITECTURE.md +394 -0
- package/Dockerfile +50 -0
- package/LICENSE +29 -0
- package/README.md +206 -0
- package/bin/i18n.js +46 -0
- package/bin/ideaco.js +494 -0
- package/deploy.sh +15 -0
- package/docker-compose.yml +30 -0
- package/electron/main.cjs +986 -0
- package/electron/preload.cjs +14 -0
- package/electron/web-backends.cjs +854 -0
- package/jsconfig.json +8 -0
- package/next.config.mjs +34 -0
- package/package.json +134 -0
- package/postcss.config.mjs +6 -0
- package/public/demo/dashboard.png +0 -0
- package/public/demo/employee.png +0 -0
- package/public/demo/messages.png +0 -0
- package/public/demo/office.png +0 -0
- package/public/demo/requirement.png +0 -0
- package/public/logo.jpeg +0 -0
- package/public/logo.png +0 -0
- package/scripts/prepare-electron.js +67 -0
- package/scripts/release.js +76 -0
- package/src/app/api/agents/[agentId]/chat/route.js +70 -0
- package/src/app/api/agents/[agentId]/conversations/route.js +35 -0
- package/src/app/api/agents/[agentId]/route.js +106 -0
- package/src/app/api/avatar/route.js +104 -0
- package/src/app/api/browse-dir/route.js +44 -0
- package/src/app/api/chat/route.js +265 -0
- package/src/app/api/company/factory-reset/route.js +43 -0
- package/src/app/api/company/route.js +82 -0
- package/src/app/api/departments/[deptId]/agents/[agentId]/dismiss/route.js +19 -0
- package/src/app/api/departments/route.js +92 -0
- package/src/app/api/group-chat-loop/events/route.js +70 -0
- package/src/app/api/group-chat-loop/route.js +94 -0
- package/src/app/api/mailbox/route.js +100 -0
- package/src/app/api/messages/route.js +14 -0
- package/src/app/api/providers/[id]/configure/route.js +21 -0
- package/src/app/api/providers/[id]/refresh-cookie/route.js +38 -0
- package/src/app/api/providers/[id]/test-cookie/route.js +28 -0
- package/src/app/api/providers/route.js +11 -0
- package/src/app/api/requirements/route.js +242 -0
- package/src/app/api/secretary/route.js +65 -0
- package/src/app/api/system/cli-backends/route.js +91 -0
- package/src/app/api/system/cron/route.js +110 -0
- package/src/app/api/system/knowledge/route.js +104 -0
- package/src/app/api/system/plugins/route.js +40 -0
- package/src/app/api/system/skills/route.js +46 -0
- package/src/app/api/system/status/route.js +46 -0
- package/src/app/api/talent-market/[profileId]/recall/route.js +22 -0
- package/src/app/api/talent-market/[profileId]/route.js +17 -0
- package/src/app/api/talent-market/route.js +26 -0
- package/src/app/api/teams/route.js +773 -0
- package/src/app/api/ws-files/[departmentId]/file/route.js +27 -0
- package/src/app/api/ws-files/[departmentId]/files/route.js +22 -0
- package/src/app/globals.css +130 -0
- package/src/app/layout.jsx +40 -0
- package/src/app/page.jsx +97 -0
- package/src/components/AgentChatModal.jsx +164 -0
- package/src/components/AgentDetailModal.jsx +425 -0
- package/src/components/AgentSpyModal.jsx +481 -0
- package/src/components/AvatarGrid.jsx +29 -0
- package/src/components/BossProfileModal.jsx +162 -0
- package/src/components/CachedAvatar.jsx +77 -0
- package/src/components/ChatPanel.jsx +219 -0
- package/src/components/ChatShared.jsx +255 -0
- package/src/components/DepartmentDetail.jsx +842 -0
- package/src/components/DepartmentView.jsx +367 -0
- package/src/components/FileReference.jsx +260 -0
- package/src/components/FilesView.jsx +465 -0
- package/src/components/GroupChatView.jsx +799 -0
- package/src/components/Mailbox.jsx +926 -0
- package/src/components/MessagesView.jsx +112 -0
- package/src/components/OnboardingGuide.jsx +209 -0
- package/src/components/OrgTree.jsx +151 -0
- package/src/components/Overview.jsx +391 -0
- package/src/components/PixelOffice.jsx +2281 -0
- package/src/components/ProviderGrid.jsx +551 -0
- package/src/components/ProvidersBoard.jsx +16 -0
- package/src/components/RequirementDetail.jsx +1279 -0
- package/src/components/RequirementsBoard.jsx +187 -0
- package/src/components/SecretarySettings.jsx +295 -0
- package/src/components/SetupWizard.jsx +388 -0
- package/src/components/Sidebar.jsx +169 -0
- package/src/components/SystemMonitor.jsx +808 -0
- package/src/components/TalentMarket.jsx +183 -0
- package/src/components/TeamDetail.jsx +697 -0
- package/src/core/agent/base-agent.js +104 -0
- package/src/core/agent/chat-store.js +602 -0
- package/src/core/agent/cli-agent/backends/claude-code/README.md +52 -0
- package/src/core/agent/cli-agent/backends/claude-code/config.js +27 -0
- package/src/core/agent/cli-agent/backends/codebuddy/README.md +236 -0
- package/src/core/agent/cli-agent/backends/codebuddy/config.js +27 -0
- package/src/core/agent/cli-agent/backends/codex/README.md +51 -0
- package/src/core/agent/cli-agent/backends/codex/config.js +27 -0
- package/src/core/agent/cli-agent/backends/index.js +27 -0
- package/src/core/agent/cli-agent/backends/registry.js +580 -0
- package/src/core/agent/cli-agent/index.js +154 -0
- package/src/core/agent/index.js +60 -0
- package/src/core/agent/llm-agent/client.js +320 -0
- package/src/core/agent/llm-agent/index.js +97 -0
- package/src/core/agent/message-bus.js +211 -0
- package/src/core/agent/session.js +608 -0
- package/src/core/agent/tools.js +596 -0
- package/src/core/agent/web-agent/backends/base-backend.js +180 -0
- package/src/core/agent/web-agent/backends/chatgpt/client.js +146 -0
- package/src/core/agent/web-agent/backends/chatgpt/config.js +148 -0
- package/src/core/agent/web-agent/backends/chatgpt/dom-scripts.js +303 -0
- package/src/core/agent/web-agent/backends/index.js +91 -0
- package/src/core/agent/web-agent/index.js +278 -0
- package/src/core/agent/web-agent/web-client.js +407 -0
- package/src/core/employee/base-employee.js +1088 -0
- package/src/core/employee/index.js +35 -0
- package/src/core/employee/knowledge.js +327 -0
- package/src/core/employee/lifecycle.js +990 -0
- package/src/core/employee/memory/index.js +642 -0
- package/src/core/employee/memory/store.js +143 -0
- package/src/core/employee/performance.js +224 -0
- package/src/core/employee/secretary.js +625 -0
- package/src/core/employee/skills.js +398 -0
- package/src/core/index.js +38 -0
- package/src/core/organization/company.js +2600 -0
- package/src/core/organization/department.js +737 -0
- package/src/core/organization/group-chat-loop.js +264 -0
- package/src/core/organization/index.js +8 -0
- package/src/core/organization/persistence.js +111 -0
- package/src/core/organization/team.js +267 -0
- package/src/core/organization/workforce/hr.js +377 -0
- package/src/core/organization/workforce/providers.js +468 -0
- package/src/core/organization/workforce/role-archetypes.js +805 -0
- package/src/core/organization/workforce/talent-market.js +205 -0
- package/src/core/prompts.js +532 -0
- package/src/core/requirement.js +1789 -0
- package/src/core/system/audit.js +483 -0
- package/src/core/system/cron.js +449 -0
- package/src/core/system/index.js +7 -0
- package/src/core/system/plugin.js +2183 -0
- package/src/core/utils/json-parse.js +188 -0
- package/src/core/workspace.js +239 -0
- package/src/lib/api-i18n.js +211 -0
- package/src/lib/avatar.js +268 -0
- package/src/lib/client-store.js +1025 -0
- package/src/lib/config-validator.js +483 -0
- package/src/lib/format-time.js +22 -0
- package/src/lib/hooks.js +414 -0
- package/src/lib/i18n.js +134 -0
- package/src/lib/paths.js +23 -0
- package/src/lib/store.js +72 -0
- package/src/locales/de.js +393 -0
- package/src/locales/en.js +1054 -0
- package/src/locales/es.js +393 -0
- package/src/locales/fr.js +393 -0
- package/src/locales/ja.js +501 -0
- package/src/locales/ko.js +513 -0
- package/src/locales/zh.js +828 -0
- 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
|
+
});
|