@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/README.en.md +36 -0
- package/README.md +77 -0
- package/package.json +41 -0
- package/public/font.css +315 -0
- package/public/index.html +3550 -0
- package/public/vendor/github-dark.min.css +10 -0
- package/public/vendor/highlight.min.js +1244 -0
- package/public/vendor/marked.min.js +74 -0
- package/server.js +575 -0
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
|
+
});
|