@yxai/code 0.0.1

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/server.js ADDED
@@ -0,0 +1,575 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 意心Code (yxcode) - Claude Code 可视化交互界面
4
+ *
5
+ * 极简架构:Express 静态服务 + WebSocket + Claude Agent SDK
6
+ * 仅依赖 Node.js,无需构建工具
7
+ */
8
+
9
+ import express from 'express';
10
+ import http from 'http';
11
+ import { WebSocketServer, WebSocket } from 'ws';
12
+ import { query } from '@anthropic-ai/claude-agent-sdk';
13
+ import crypto from 'crypto';
14
+ import path from 'path';
15
+ import os from 'os';
16
+ import { fileURLToPath } from 'url';
17
+ import { promises as fs } from 'fs';
18
+ import { exec } from 'child_process';
19
+
20
+ const __filename = fileURLToPath(import.meta.url);
21
+ const __dirname = path.dirname(__filename);
22
+
23
+ // --- CLI Arguments ---
24
+ const args = process.argv.slice(2);
25
+ if (args.includes('--help') || args.includes('-h')) {
26
+ console.log(`
27
+ 意心Code (yxcode) - Claude Code 可视化交互界面
28
+
29
+ 用法:
30
+ yxaiCode [选项]
31
+
32
+ 选项:
33
+ -h, --help 显示帮助信息
34
+ -v, --version 显示版本号
35
+ -p, --port 指定端口号 (默认: 3456)
36
+
37
+ 环境变量:
38
+ PORT 自定义端口号
39
+
40
+ 示例:
41
+ yxaiCode
42
+ yxaiCode --port 8080
43
+ PORT=8080 yxaiCode
44
+ `);
45
+ process.exit(0);
46
+ }
47
+
48
+ if (args.includes('--version') || args.includes('-v')) {
49
+ const pkg = JSON.parse(await fs.readFile(path.join(__dirname, 'package.json'), 'utf8'));
50
+ console.log(pkg.version);
51
+ process.exit(0);
52
+ }
53
+
54
+ // --- Config ---
55
+ let PORT = parseInt(process.env.PORT, 10) || 3456;
56
+ const portIndex = args.findIndex(arg => arg === '--port' || arg === '-p');
57
+ if (portIndex !== -1 && args[portIndex + 1]) {
58
+ PORT = parseInt(args[portIndex + 1], 10);
59
+ }
60
+ const API_BASE_URL = 'https://yxai.chat';
61
+
62
+ // --- Session & Permission State ---
63
+ const activeSessions = new Map();
64
+ const pendingApprovals = new Map();
65
+
66
+ // --- Helpers ---
67
+ function uid() {
68
+ return crypto.randomUUID?.() ?? crypto.randomBytes(16).toString('hex');
69
+ }
70
+
71
+ function wsSend(ws, data) {
72
+ if (ws.readyState === WebSocket.OPEN) {
73
+ ws.send(JSON.stringify(data));
74
+ }
75
+ }
76
+
77
+ // --- Tool Approval ---
78
+ function waitForApproval(requestId, signal) {
79
+ return new Promise((resolve) => {
80
+ let settled = false;
81
+ const finalize = (v) => {
82
+ if (settled) return;
83
+ settled = true;
84
+ pendingApprovals.delete(requestId);
85
+ if (signal) signal.removeEventListener('abort', onAbort);
86
+ resolve(v);
87
+ };
88
+ const onAbort = () => finalize({ cancelled: true });
89
+ if (signal) {
90
+ if (signal.aborted) return finalize({ cancelled: true });
91
+ signal.addEventListener('abort', onAbort, { once: true });
92
+ }
93
+ pendingApprovals.set(requestId, finalize);
94
+ });
95
+ }
96
+
97
+ function resolveApproval(requestId, decision) {
98
+ const fn = pendingApprovals.get(requestId);
99
+ if (fn) fn(decision);
100
+ }
101
+
102
+ // --- Claude SDK Query ---
103
+ async function runQuery(prompt, options, ws) {
104
+ let sessionId = options.sessionId || null;
105
+
106
+ // Always use fixed base URL, only inject API Key from options
107
+ const prevBaseUrl = process.env.ANTHROPIC_BASE_URL;
108
+ const prevApiKey = process.env.ANTHROPIC_API_KEY;
109
+
110
+ process.env.ANTHROPIC_BASE_URL = API_BASE_URL;
111
+ console.log(`[config] ANTHROPIC_BASE_URL = ${API_BASE_URL}`);
112
+
113
+ if (options.apiKey) {
114
+ process.env.ANTHROPIC_API_KEY = options.apiKey;
115
+ console.log(`[config] ANTHROPIC_API_KEY = ***${options.apiKey.slice(-6)}`);
116
+ }
117
+
118
+ const sdkOpts = {
119
+ model: options.model || 'sonnet',
120
+ cwd: options.cwd || process.cwd(),
121
+ tools: { type: 'preset', preset: 'claude_code' },
122
+ systemPrompt: { type: 'preset', preset: 'claude_code' },
123
+ settingSources: ['project', 'user', 'local'],
124
+ };
125
+
126
+ if (options.permissionMode && options.permissionMode !== 'default') {
127
+ sdkOpts.permissionMode = options.permissionMode;
128
+ }
129
+ if (sessionId) {
130
+ sdkOpts.resume = sessionId;
131
+ }
132
+
133
+ // Load MCP servers from ~/.claude.json
134
+ const mcpServers = await loadMcpConfig(sdkOpts.cwd);
135
+ if (mcpServers) sdkOpts.mcpServers = mcpServers;
136
+
137
+ // Permission callback
138
+ sdkOpts.canUseTool = async (toolName, input, context) => {
139
+ if (sdkOpts.permissionMode === 'bypassPermissions') {
140
+ return { behavior: 'allow', updatedInput: input };
141
+ }
142
+ const requestId = uid();
143
+ wsSend(ws, {
144
+ type: 'permission-request',
145
+ requestId, toolName, input,
146
+ sessionId,
147
+ });
148
+ const decision = await waitForApproval(requestId, context?.signal);
149
+ if (!decision || decision.cancelled) {
150
+ wsSend(ws, { type: 'permission-cancelled', requestId, sessionId });
151
+ return { behavior: 'deny', message: 'Permission denied or cancelled' };
152
+ }
153
+ if (decision.allow) {
154
+ return { behavior: 'allow', updatedInput: decision.updatedInput ?? input };
155
+ }
156
+ return { behavior: 'deny', message: decision.message ?? 'User denied' };
157
+ };
158
+
159
+ // Start query
160
+ const prev = process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT;
161
+ process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = '300000';
162
+
163
+ const qi = query({ prompt, options: sdkOpts });
164
+
165
+ if (prev !== undefined) process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = prev;
166
+ else delete process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT;
167
+
168
+ if (sessionId) activeSessions.set(sessionId, qi);
169
+
170
+ try {
171
+ for await (const msg of qi) {
172
+ // Debug: log message types for streaming analysis
173
+ console.log('[SDK msg]', msg.type, msg.subtype || '', msg.role || '', Array.isArray(msg.content) ? `content[${msg.content.length}]` : '');
174
+ // Capture session id
175
+ if (msg.session_id && !sessionId) {
176
+ sessionId = msg.session_id;
177
+ activeSessions.set(sessionId, qi);
178
+ wsSend(ws, { type: 'session-created', sessionId });
179
+ }
180
+ wsSend(ws, { type: 'claude-response', data: msg, sessionId });
181
+
182
+ // Token usage
183
+ if (msg.type === 'result' && msg.modelUsage) {
184
+ const mk = Object.keys(msg.modelUsage)[0];
185
+ const md = msg.modelUsage[mk];
186
+ if (md) {
187
+ const used = (md.cumulativeInputTokens || md.inputTokens || 0)
188
+ + (md.cumulativeOutputTokens || md.outputTokens || 0)
189
+ + (md.cumulativeCacheReadInputTokens || md.cacheReadInputTokens || 0)
190
+ + (md.cumulativeCacheCreationInputTokens || md.cacheCreationInputTokens || 0);
191
+ wsSend(ws, { type: 'token-usage', used, sessionId });
192
+ }
193
+ }
194
+ }
195
+ wsSend(ws, { type: 'claude-complete', sessionId });
196
+ } catch (err) {
197
+ console.error('SDK error:', err.message);
198
+ wsSend(ws, { type: 'claude-error', error: err.message, sessionId });
199
+ } finally {
200
+ if (sessionId) activeSessions.delete(sessionId);
201
+ // Restore env vars
202
+ if (prevBaseUrl !== undefined) process.env.ANTHROPIC_BASE_URL = prevBaseUrl;
203
+ else delete process.env.ANTHROPIC_BASE_URL;
204
+ if (prevApiKey !== undefined) process.env.ANTHROPIC_API_KEY = prevApiKey;
205
+ else delete process.env.ANTHROPIC_API_KEY;
206
+ }
207
+ }
208
+
209
+ // --- Load MCP Config ---
210
+ async function loadMcpConfig(cwd) {
211
+ try {
212
+ const cfgPath = path.join(os.homedir(), '.claude.json');
213
+ const raw = await fs.readFile(cfgPath, 'utf8').catch(() => null);
214
+ if (!raw) return null;
215
+ const cfg = JSON.parse(raw);
216
+ let servers = {};
217
+ if (cfg.mcpServers) servers = { ...cfg.mcpServers };
218
+ if (cfg.claudeProjects?.[cwd]?.mcpServers) {
219
+ servers = { ...servers, ...cfg.claudeProjects[cwd].mcpServers };
220
+ }
221
+ return Object.keys(servers).length ? servers : null;
222
+ } catch { return null; }
223
+ }
224
+
225
+ // --- Parse session info (Fix 4: scan full file for meaningful title) ---
226
+ const SYSTEM_TEXT_RE = /^(<system-reminder>|<command-name>|<local-command-|Caveat:)/;
227
+
228
+ function parseSessionInfo(raw) {
229
+ let summary = '', msgCount = 0;
230
+ const lines = raw.split('\n').filter(Boolean);
231
+ for (const line of lines) {
232
+ try {
233
+ const obj = JSON.parse(line);
234
+ if (obj.type === 'human' || obj.type === 'user' || obj.type === 'assistant') msgCount++;
235
+ // Priority 1: summary type entry
236
+ if (!summary && obj.type === 'summary' && obj.summary) {
237
+ summary = obj.summary.slice(0, 50);
238
+ }
239
+ // Priority 2: first user message text (filtered)
240
+ if (!summary && (obj.type === 'human' || obj.type === 'user') && obj.message?.content) {
241
+ let text = '';
242
+ if (typeof obj.message.content === 'string') {
243
+ text = obj.message.content;
244
+ } else if (Array.isArray(obj.message.content)) {
245
+ text = obj.message.content
246
+ .filter(c => c.type === 'text' && c.text && !SYSTEM_TEXT_RE.test(c.text.trim()))
247
+ .map(c => c.text).join(' ');
248
+ }
249
+ text = text.trim();
250
+ if (text) summary = text.slice(0, 50);
251
+ }
252
+ } catch {}
253
+ }
254
+ return { summary, msgCount };
255
+ }
256
+
257
+ // --- Express + WebSocket ---
258
+ const app = express();
259
+ app.use(express.json());
260
+ app.use(express.static(path.join(__dirname, 'public')));
261
+
262
+ // API: list models (proxy from external API)
263
+ app.get('/api/models', async (_req, res) => {
264
+ try {
265
+ const https = await import('https');
266
+ const url = `${API_BASE_URL}/prod-api/model?ModelApiTypes=1&SkipCount=1&MaxResultCount=100`;
267
+ https.get(url, (apiRes) => {
268
+ let data = '';
269
+ apiRes.on('data', chunk => data += chunk);
270
+ apiRes.on('end', () => {
271
+ try {
272
+ const json = JSON.parse(data);
273
+ const models = (json.items || []).map(item => ({
274
+ value: item.modelId,
275
+ label: item.name,
276
+ description: item.description,
277
+ icon: item.iconUrl,
278
+ provider: item.providerName,
279
+ }));
280
+ res.json(models);
281
+ } catch (e) {
282
+ console.error('[models API parse error]', e);
283
+ res.status(500).json({ error: 'Failed to parse models' });
284
+ }
285
+ });
286
+ }).on('error', (e) => {
287
+ console.error('[models API error]', e);
288
+ res.status(500).json({ error: e.message });
289
+ });
290
+ } catch (e) {
291
+ res.status(500).json({ error: e.message });
292
+ }
293
+ });
294
+
295
+ // API: list projects (scan ~/.claude/projects/)
296
+ app.get('/api/projects', async (_req, res) => {
297
+ try {
298
+ const base = path.join(os.homedir(), '.claude', 'projects');
299
+ const entries = await fs.readdir(base, { withFileTypes: true }).catch(() => []);
300
+ const projects = [];
301
+ for (const ent of entries) {
302
+ if (!ent.isDirectory()) continue;
303
+ const projDir = path.join(base, ent.name);
304
+ const files = await fs.readdir(projDir).catch(() => []);
305
+ const sessions = [];
306
+ for (const f of files) {
307
+ if (!f.endsWith('.jsonl')) continue;
308
+ const fp = path.join(projDir, f);
309
+ const stat = await fs.stat(fp).catch(() => null);
310
+ if (!stat) continue;
311
+ const raw = await fs.readFile(fp, 'utf8').catch(() => '');
312
+ const info = parseSessionInfo(raw);
313
+ sessions.push({ id: f.replace('.jsonl', ''), file: f, summary: info.summary, msgCount: info.msgCount, mtime: stat.mtime });
314
+ }
315
+ sessions.sort((a, b) => new Date(b.mtime) - new Date(a.mtime));
316
+ if (sessions.length) projects.push({ name: ent.name, sessions });
317
+ }
318
+ projects.sort((a, b) => {
319
+ const ta = a.sessions[0]?.mtime || 0, tb = b.sessions[0]?.mtime || 0;
320
+ return new Date(tb) - new Date(ta);
321
+ });
322
+ res.json(projects);
323
+ } catch (e) { res.status(500).json({ error: e.message }); }
324
+ });
325
+
326
+ // API: sessions for a single project
327
+ app.get('/api/projects/:name/sessions', async (req, res) => {
328
+ try {
329
+ const projDir = path.join(os.homedir(), '.claude', 'projects', req.params.name);
330
+ const files = await fs.readdir(projDir).catch(() => []);
331
+ const sessions = [];
332
+ for (const f of files) {
333
+ if (!f.endsWith('.jsonl')) continue;
334
+ const fp = path.join(projDir, f);
335
+ const stat = await fs.stat(fp).catch(() => null);
336
+ if (!stat) continue;
337
+ const raw = await fs.readFile(fp, 'utf8').catch(() => '');
338
+ const info = parseSessionInfo(raw);
339
+ sessions.push({ id: f.replace('.jsonl', ''), file: f, summary: info.summary, msgCount: info.msgCount, mtime: stat.mtime });
340
+ }
341
+ sessions.sort((a, b) => new Date(b.mtime) - new Date(a.mtime));
342
+ res.json(sessions);
343
+ } catch (e) { res.status(500).json({ error: e.message }); }
344
+ });
345
+
346
+ // API: messages for a session (Fix 1 + Fix 2 + Fix 7)
347
+ app.get('/api/projects/:name/sessions/:id/messages', async (req, res) => {
348
+ try {
349
+ const fp = path.join(os.homedir(), '.claude', 'projects', req.params.name, req.params.id + '.jsonl');
350
+ const raw = await fs.readFile(fp, 'utf8');
351
+ const messages = [];
352
+ // Collect tool_results keyed by tool_use_id for association
353
+ const toolResults = new Map();
354
+
355
+ // First pass: collect tool_results from user messages
356
+ for (const line of raw.split('\n').filter(Boolean)) {
357
+ try {
358
+ const obj = JSON.parse(line);
359
+ if ((obj.type === 'human' || obj.type === 'user') && Array.isArray(obj.message?.content)) {
360
+ for (const block of obj.message.content) {
361
+ if (block.type === 'tool_result' && block.tool_use_id) {
362
+ const txt = typeof block.content === 'string' ? block.content
363
+ : Array.isArray(block.content) ? block.content.map(c => c.text || '').join('') : '';
364
+ toolResults.set(block.tool_use_id, { tool_use_id: block.tool_use_id, content: txt, is_error: !!block.is_error });
365
+ }
366
+ }
367
+ }
368
+ } catch {}
369
+ }
370
+
371
+ // Second pass: build messages with parts
372
+ for (const line of raw.split('\n').filter(Boolean)) {
373
+ try {
374
+ const obj = JSON.parse(line);
375
+ if (obj.type === 'human' || obj.type === 'user') {
376
+ let text = '';
377
+ if (typeof obj.message?.content === 'string') {
378
+ text = obj.message.content;
379
+ } else if (Array.isArray(obj.message?.content)) {
380
+ text = obj.message.content
381
+ .filter(c => c.type === 'text' && c.text && !SYSTEM_TEXT_RE.test(c.text.trim()))
382
+ .map(c => c.text).join('\n');
383
+ }
384
+ text = text.trim();
385
+ if (text) messages.push({ role: 'user', content: text });
386
+ } else if (obj.type === 'assistant') {
387
+ const content = obj.message?.content;
388
+ const parts = [];
389
+ if (typeof content === 'string') {
390
+ if (content.trim()) parts.push({ type: 'text', text: content });
391
+ } else if (Array.isArray(content)) {
392
+ for (const block of content) {
393
+ if (block.type === 'text' && block.text?.trim()) {
394
+ parts.push({ type: 'text', text: block.text });
395
+ } else if (block.type === 'tool_use') {
396
+ parts.push({ type: 'tool_use', id: block.id, name: block.name, input: block.input });
397
+ // Attach associated tool_result
398
+ const tr = toolResults.get(block.id);
399
+ if (tr) parts.push({ type: 'tool_result', tool_use_id: tr.tool_use_id, content: tr.content, is_error: tr.is_error });
400
+ }
401
+ }
402
+ }
403
+ if (parts.length) messages.push({ role: 'assistant', content: parts.filter(p => p.type === 'text').map(p => p.text).join('\n'), parts });
404
+ }
405
+ } catch {}
406
+ }
407
+ res.json(messages);
408
+ } catch (e) { res.status(500).json({ error: e.message }); }
409
+ });
410
+
411
+ // API: browse directories (Fix 5: folder picker)
412
+ app.get('/api/browse', async (req, res) => {
413
+ try {
414
+ let target = req.query.path || '';
415
+ // Windows: if empty, list drive letters
416
+ if (!target && process.platform === 'win32') {
417
+ const { execSync } = await import('child_process');
418
+ const raw = execSync('wmic logicaldisk get name', { encoding: 'utf8' });
419
+ const drives = raw.split('\n').map(l => l.trim()).filter(l => /^[A-Z]:$/.test(l));
420
+ return res.json({ path: '', parent: '', dirs: drives.map(d => ({ name: d, path: d + '\\' })) });
421
+ }
422
+ if (!target) target = os.homedir();
423
+ const resolved = path.resolve(target);
424
+ const parent = path.dirname(resolved);
425
+ const entries = await fs.readdir(resolved, { withFileTypes: true }).catch(() => []);
426
+ const dirs = [];
427
+ for (const ent of entries) {
428
+ if (!ent.isDirectory()) continue;
429
+ if (ent.name.startsWith('.')) continue;
430
+ dirs.push({ name: ent.name, path: path.join(resolved, ent.name) });
431
+ }
432
+ dirs.sort((a, b) => a.name.localeCompare(b.name));
433
+ res.json({ path: resolved, parent: parent !== resolved ? parent : '', dirs });
434
+ } catch (e) { res.status(500).json({ error: e.message }); }
435
+ });
436
+
437
+ // API: file tree
438
+ const SKIP_DIRS = new Set(['node_modules', '.git', '.svn', '.hg', '__pycache__', '.next', '.nuxt', 'dist', 'build', '.cache', '.claude']);
439
+ app.get('/api/files', async (req, res) => {
440
+ try {
441
+ const root = req.query.cwd || process.cwd();
442
+ async function scan(dir, depth) {
443
+ if (depth > 5) return [];
444
+ const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
445
+ const items = [];
446
+ for (const ent of entries) {
447
+ if (ent.name.startsWith('.') && ent.name !== '.env') continue;
448
+ const full = path.join(dir, ent.name);
449
+ if (ent.isDirectory()) {
450
+ if (SKIP_DIRS.has(ent.name)) continue;
451
+ const children = await scan(full, depth + 1);
452
+ items.push({ name: ent.name, type: 'dir', path: full, children });
453
+ } else {
454
+ const stat = await fs.stat(full).catch(() => null);
455
+ items.push({ name: ent.name, type: 'file', path: full, size: stat?.size || 0 });
456
+ }
457
+ }
458
+ items.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === 'dir' ? -1 : 1));
459
+ return items;
460
+ }
461
+ res.json(await scan(root, 0));
462
+ } catch (e) { res.status(500).json({ error: e.message }); }
463
+ });
464
+
465
+ // API: flat file list for @ mentions
466
+ app.get('/api/files-flat', async (req, res) => {
467
+ try {
468
+ const root = req.query.cwd || process.cwd();
469
+ const results = [];
470
+ const MAX = 1000;
471
+ async function scan(dir, depth) {
472
+ if (depth > 5 || results.length >= MAX) return;
473
+ const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
474
+ for (const ent of entries) {
475
+ if (results.length >= MAX) return;
476
+ if (ent.name.startsWith('.') && ent.name !== '.env') continue;
477
+ const full = path.join(dir, ent.name);
478
+ const rel = path.relative(root, full).replace(/\\/g, '/');
479
+ if (ent.isDirectory()) {
480
+ if (SKIP_DIRS.has(ent.name)) continue;
481
+ results.push({ path: rel + '/', type: 'dir' });
482
+ await scan(full, depth + 1);
483
+ } else {
484
+ results.push({ path: rel, type: 'file' });
485
+ }
486
+ }
487
+ }
488
+ await scan(root, 0);
489
+ res.json(results);
490
+ } catch (e) { res.status(500).json({ error: e.message }); }
491
+ });
492
+
493
+ // API: read single file (max 500KB)
494
+ app.get('/api/file', async (req, res) => {
495
+ try {
496
+ const fp = req.query.path;
497
+ if (!fp) return res.status(400).json({ error: 'path required' });
498
+ const stat = await fs.stat(fp);
499
+ if (stat.size > 500 * 1024) return res.status(413).json({ error: 'File too large (>500KB)' });
500
+ const content = await fs.readFile(fp, 'utf8');
501
+ res.json({ path: fp, size: stat.size, content });
502
+ } catch (e) { res.status(500).json({ error: e.message }); }
503
+ });
504
+
505
+ const server = http.createServer(app);
506
+ const wss = new WebSocketServer({ server, path: '/ws' });
507
+
508
+ wss.on('connection', (ws) => {
509
+ console.log('[WS] client connected');
510
+
511
+ ws.on('message', (raw) => {
512
+ let msg;
513
+ try { msg = JSON.parse(raw); } catch { return; }
514
+
515
+ switch (msg.type) {
516
+ case 'claude-command':
517
+ runQuery(msg.prompt, {
518
+ sessionId: msg.sessionId || null,
519
+ cwd: msg.cwd || null,
520
+ model: msg.model || 'sonnet',
521
+ permissionMode: msg.permissionMode || 'default',
522
+ apiKey: msg.apiKey || null,
523
+ }, ws).catch((e) => console.error('[query error]', e.message));
524
+ break;
525
+
526
+ case 'permission-response':
527
+ resolveApproval(msg.requestId, {
528
+ allow: msg.allow,
529
+ updatedInput: msg.updatedInput,
530
+ message: msg.message,
531
+ });
532
+ break;
533
+
534
+ case 'abort-session':
535
+ if (msg.sessionId && activeSessions.has(msg.sessionId)) {
536
+ const qi = activeSessions.get(msg.sessionId);
537
+ qi.interrupt().catch(() => {});
538
+ activeSessions.delete(msg.sessionId);
539
+ wsSend(ws, { type: 'session-aborted', sessionId: msg.sessionId });
540
+ }
541
+ break;
542
+ }
543
+ });
544
+
545
+ ws.on('close', () => console.log('[WS] client disconnected'));
546
+ });
547
+
548
+ server.listen(PORT, () => {
549
+ const url = `http://localhost:${PORT}`;
550
+ console.log(`\n 意心Code (yxcode) 已启动`);
551
+ console.log(` ${url}\n`);
552
+
553
+ // Auto-open browser
554
+ const open = (url) => {
555
+ const cmd = process.platform === 'win32' ? `start ${url}`
556
+ : process.platform === 'darwin' ? `open ${url}`
557
+ : `xdg-open ${url}`;
558
+ exec(cmd);
559
+ };
560
+
561
+ // Open browser after a short delay
562
+ setTimeout(() => open(url), 1000);
563
+ });
564
+
565
+ // Handle port in use error
566
+ server.on('error', (err) => {
567
+ if (err.code === 'EADDRINUSE') {
568
+ console.error(`\n 错误: 端口 ${PORT} 已被占用`);
569
+ console.error(` 请尝试使用其他端口: yxaiCode --port 8080\n`);
570
+ process.exit(1);
571
+ } else {
572
+ console.error(`\n 服务器错误: ${err.message}\n`);
573
+ process.exit(1);
574
+ }
575
+ });