@yeaft/webchat-agent 0.0.2

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/workbench.js ADDED
@@ -0,0 +1,907 @@
1
+ import { existsSync, writeFileSync, mkdirSync } from 'fs';
2
+ import { readFile, writeFile, readdir, stat, unlink, rename, mkdir, rm, copyFile, cp } from 'fs/promises';
3
+ import { join, basename, dirname, extname, resolve, isAbsolute, relative } from 'path';
4
+ import { platform } from 'os';
5
+ import { exec } from 'child_process';
6
+ import { promisify } from 'util';
7
+ import ctx from './context.js';
8
+
9
+ const execAsync = promisify(exec);
10
+
11
+ // 路径安全校验 - 确保路径格式正确
12
+ export function resolveAndValidatePath(filePath, workDir) {
13
+ const resolved = isAbsolute(filePath) ? resolve(filePath) : resolve(workDir, filePath);
14
+ return resolved;
15
+ }
16
+
17
+ // Helper: resolve git root for a working directory
18
+ export async function getGitRoot(workDir) {
19
+ try {
20
+ const { stdout } = await execAsync('git rev-parse --show-toplevel', {
21
+ cwd: workDir, timeout: 5000, windowsHide: true
22
+ });
23
+ return stdout.trim();
24
+ } catch {
25
+ return workDir;
26
+ }
27
+ }
28
+
29
+ // Helper: validate file path for shell safety
30
+ export function validateGitPath(filePath) {
31
+ return filePath && !/[`$;|&><!\n\r]/.test(filePath);
32
+ }
33
+
34
+ export async function handleReadFile(msg) {
35
+ const { conversationId, filePath, _requestUserId } = msg;
36
+ console.log('[Agent] handleReadFile received:', { filePath, conversationId, workDir: msg.workDir });
37
+ const conv = ctx.conversations.get(conversationId);
38
+ const workDir = msg.workDir || conv?.workDir || ctx.CONFIG.workDir;
39
+
40
+ try {
41
+ const resolved = resolveAndValidatePath(filePath, workDir);
42
+ const content = await readFile(resolved, 'utf-8');
43
+
44
+ // 检测语言
45
+ const ext = extname(resolved).toLowerCase();
46
+ const langMap = {
47
+ '.js': 'javascript', '.mjs': 'javascript', '.cjs': 'javascript',
48
+ '.ts': 'javascript', '.tsx': 'javascript', '.jsx': 'javascript',
49
+ '.py': 'python', '.pyw': 'python',
50
+ '.html': 'htmlmixed', '.htm': 'htmlmixed',
51
+ '.css': 'css', '.scss': 'css', '.less': 'css',
52
+ '.json': 'javascript',
53
+ '.md': 'markdown',
54
+ '.sh': 'shell', '.bash': 'shell', '.zsh': 'shell',
55
+ '.cs': 'text/x-csharp', '.java': 'text/x-java',
56
+ '.cpp': 'text/x-c++src', '.c': 'text/x-csrc', '.h': 'text/x-csrc',
57
+ '.xml': 'xml', '.svg': 'xml',
58
+ '.yaml': 'yaml', '.yml': 'yaml',
59
+ '.sql': 'sql',
60
+ '.go': 'go', '.rs': 'rust', '.rb': 'ruby',
61
+ '.php': 'php', '.swift': 'swift'
62
+ };
63
+
64
+ console.log('[Agent] Sending file_content:', { filePath: resolved, contentLen: content.length, conversationId });
65
+ ctx.sendToServer({
66
+ type: 'file_content',
67
+ conversationId,
68
+ _requestUserId,
69
+ filePath: resolved,
70
+ content,
71
+ language: langMap[ext] || null
72
+ });
73
+ } catch (e) {
74
+ ctx.sendToServer({
75
+ type: 'file_content',
76
+ conversationId,
77
+ _requestUserId,
78
+ filePath,
79
+ content: '',
80
+ error: e.message
81
+ });
82
+ }
83
+ }
84
+
85
+ export async function handleWriteFile(msg) {
86
+ const { conversationId, filePath, content, _requestUserId } = msg;
87
+ const conv = ctx.conversations.get(conversationId);
88
+ const workDir = msg.workDir || conv?.workDir || ctx.CONFIG.workDir;
89
+
90
+ try {
91
+ const resolved = resolveAndValidatePath(filePath, workDir);
92
+ await writeFile(resolved, content, 'utf-8');
93
+
94
+ ctx.sendToServer({
95
+ type: 'file_saved',
96
+ conversationId,
97
+ _requestUserId,
98
+ filePath: resolved,
99
+ success: true
100
+ });
101
+ } catch (e) {
102
+ ctx.sendToServer({
103
+ type: 'file_saved',
104
+ conversationId,
105
+ _requestUserId,
106
+ filePath,
107
+ success: false,
108
+ error: e.message
109
+ });
110
+ }
111
+ }
112
+
113
+ export async function handleListDirectory(msg) {
114
+ const { conversationId, dirPath, _requestUserId } = msg;
115
+ const conv = ctx.conversations.get(conversationId);
116
+ const workDir = msg.workDir || conv?.workDir || ctx.CONFIG.workDir;
117
+
118
+ // 空路径:列出驱动器(Windows)或根目录(Unix)
119
+ if (!dirPath || dirPath === '') {
120
+ try {
121
+ if (platform() === 'win32') {
122
+ const drives = [];
123
+ for (const letter of 'CDEFGHIJKLMNOPQRSTUVWXYZ'.split('')) {
124
+ const drivePath = letter + ':\\';
125
+ if (existsSync(drivePath)) {
126
+ drives.push({ name: letter + ':', type: 'directory', size: 0 });
127
+ }
128
+ }
129
+ ctx.sendToServer({
130
+ type: 'directory_listing',
131
+ conversationId,
132
+ _requestUserId,
133
+ dirPath: '',
134
+ entries: drives
135
+ });
136
+ } else {
137
+ // Unix: 列出根目录
138
+ const entries = await readdir('/', { withFileTypes: true });
139
+ const result = entries
140
+ .filter(e => !e.name.startsWith('.'))
141
+ .map(e => ({ name: e.name, type: e.isDirectory() ? 'directory' : 'file', size: 0 }))
142
+ .sort((a, b) => a.name.localeCompare(b.name));
143
+ ctx.sendToServer({
144
+ type: 'directory_listing',
145
+ conversationId,
146
+ _requestUserId,
147
+ dirPath: '/',
148
+ entries: result
149
+ });
150
+ }
151
+ } catch (e) {
152
+ ctx.sendToServer({
153
+ type: 'directory_listing',
154
+ conversationId,
155
+ _requestUserId,
156
+ dirPath: '',
157
+ entries: [],
158
+ error: e.message
159
+ });
160
+ }
161
+ return;
162
+ }
163
+
164
+ try {
165
+ const resolved = resolveAndValidatePath(dirPath, workDir);
166
+ const entries = await readdir(resolved, { withFileTypes: true });
167
+ const result = [];
168
+
169
+ for (const entry of entries) {
170
+ // 跳过隐藏文件和 node_modules
171
+ if (entry.name.startsWith('.') && entry.name !== '..') continue;
172
+ if (entry.name === 'node_modules') continue;
173
+
174
+ try {
175
+ const fullPath = join(resolved, entry.name);
176
+ const s = await stat(fullPath);
177
+ result.push({
178
+ name: entry.name,
179
+ type: entry.isDirectory() ? 'directory' : 'file',
180
+ size: s.size
181
+ });
182
+ } catch {
183
+ result.push({
184
+ name: entry.name,
185
+ type: entry.isDirectory() ? 'directory' : 'file',
186
+ size: 0
187
+ });
188
+ }
189
+ }
190
+
191
+ // 排序:目录在前,文件在后,各自按名称排序
192
+ result.sort((a, b) => {
193
+ if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
194
+ return a.name.localeCompare(b.name);
195
+ });
196
+
197
+ ctx.sendToServer({
198
+ type: 'directory_listing',
199
+ conversationId,
200
+ _requestUserId,
201
+ dirPath: resolved,
202
+ entries: result
203
+ });
204
+ } catch (e) {
205
+ ctx.sendToServer({
206
+ type: 'directory_listing',
207
+ conversationId,
208
+ _requestUserId,
209
+ dirPath: dirPath || workDir,
210
+ entries: [],
211
+ error: e.message
212
+ });
213
+ }
214
+ }
215
+
216
+ export async function handleGitStatus(msg) {
217
+ const { conversationId, _requestUserId } = msg;
218
+ const conv = ctx.conversations.get(conversationId);
219
+ const workDir = msg.workDir || conv?.workDir || ctx.CONFIG.workDir;
220
+
221
+ try {
222
+ // Get git repo root to ensure paths are consistent
223
+ let gitRoot = workDir;
224
+ try {
225
+ const { stdout: rootOut } = await execAsync('git rev-parse --show-toplevel', {
226
+ cwd: workDir,
227
+ timeout: 5000,
228
+ windowsHide: true
229
+ });
230
+ gitRoot = rootOut.trim();
231
+ } catch {}
232
+
233
+ const { stdout: statusOut } = await execAsync('git status --porcelain', {
234
+ cwd: gitRoot,
235
+ timeout: 10000,
236
+ windowsHide: true
237
+ });
238
+
239
+ let branch = '';
240
+ try {
241
+ const { stdout: branchOut } = await execAsync('git branch --show-current', {
242
+ cwd: gitRoot,
243
+ timeout: 5000,
244
+ windowsHide: true
245
+ });
246
+ branch = branchOut.trim();
247
+ } catch {}
248
+
249
+ // Get ahead/behind counts relative to upstream
250
+ let ahead = 0, behind = 0;
251
+ try {
252
+ const { stdout: abOut } = await execAsync('git rev-list --left-right --count HEAD...@{upstream}', {
253
+ cwd: gitRoot, timeout: 5000, windowsHide: true
254
+ });
255
+ const parts = abOut.trim().split(/\s+/);
256
+ ahead = parseInt(parts[0]) || 0;
257
+ behind = parseInt(parts[1]) || 0;
258
+ } catch {}
259
+
260
+ const files = [];
261
+ for (const line of statusOut.split('\n')) {
262
+ if (!line || line.length < 4) continue;
263
+ const indexStatus = line[0];
264
+ const workTreeStatus = line[1];
265
+ const path = line.slice(3);
266
+ // Handle renamed files: "R old -> new"
267
+ const displayPath = path.includes(' -> ') ? path.split(' -> ')[1] : path;
268
+ files.push({ path: displayPath, indexStatus, workTreeStatus });
269
+ }
270
+
271
+ ctx.sendToServer({
272
+ type: 'git_status_result',
273
+ conversationId,
274
+ _requestUserId,
275
+ branch,
276
+ files,
277
+ ahead,
278
+ behind,
279
+ workDir,
280
+ gitRoot
281
+ });
282
+ } catch (e) {
283
+ ctx.sendToServer({
284
+ type: 'git_status_result',
285
+ conversationId,
286
+ _requestUserId,
287
+ error: e.message,
288
+ isGitRepo: !e.message.includes('not a git repository') && !e.message.includes('ENOENT')
289
+ });
290
+ }
291
+ }
292
+
293
+ export async function handleGitDiff(msg) {
294
+ const { conversationId, filePath, staged, untracked, fullFile, _requestUserId } = msg;
295
+ const conv = ctx.conversations.get(conversationId);
296
+ const workDir = msg.workDir || conv?.workDir || ctx.CONFIG.workDir;
297
+
298
+ try {
299
+ // 安全检查:验证 filePath 不包含 shell 注入字符
300
+ if (!filePath || /[`$;|&><!\n\r]/.test(filePath)) {
301
+ ctx.sendToServer({
302
+ type: 'git_diff_result',
303
+ conversationId,
304
+ _requestUserId,
305
+ filePath,
306
+ error: 'Invalid file path'
307
+ });
308
+ return;
309
+ }
310
+
311
+ // Get git repo root — git status paths are relative to this, not workDir
312
+ let gitRoot = workDir;
313
+ try {
314
+ const { stdout: rootOut } = await execAsync('git rev-parse --show-toplevel', {
315
+ cwd: workDir,
316
+ timeout: 5000,
317
+ windowsHide: true
318
+ });
319
+ gitRoot = rootOut.trim();
320
+ } catch {}
321
+
322
+ if (untracked) {
323
+ // Untracked files: resolve path relative to git root
324
+ const fullPath = resolve(gitRoot, filePath);
325
+ const resolved = resolveAndValidatePath(fullPath, gitRoot);
326
+ const content = await readFile(resolved, 'utf-8');
327
+ ctx.sendToServer({
328
+ type: 'git_diff_result',
329
+ conversationId,
330
+ _requestUserId,
331
+ filePath,
332
+ diff: null,
333
+ newFileContent: content
334
+ });
335
+ return;
336
+ }
337
+
338
+ // 使用 -- 分隔选项和路径, execAsync 的参数已被验证无注入字符
339
+ const contextFlag = fullFile ? '-U99999' : '';
340
+ const cmd = staged
341
+ ? `git diff --cached ${contextFlag} -- "${filePath}"`
342
+ : `git diff ${contextFlag} -- "${filePath}"`;
343
+
344
+ const { stdout } = await execAsync(cmd, {
345
+ cwd: gitRoot,
346
+ timeout: 15000,
347
+ maxBuffer: 5 * 1024 * 1024,
348
+ windowsHide: true
349
+ });
350
+
351
+ // 如果 git diff 返回空,尝试用 --cached 或反之
352
+ if (!stdout.trim() && !staged) {
353
+ const cachedCmd = `git diff --cached ${contextFlag} -- "${filePath}"`;
354
+ const { stdout: cachedOut } = await execAsync(cachedCmd, {
355
+ cwd: gitRoot,
356
+ timeout: 15000,
357
+ maxBuffer: 5 * 1024 * 1024,
358
+ windowsHide: true
359
+ });
360
+ if (cachedOut.trim()) {
361
+ ctx.sendToServer({
362
+ type: 'git_diff_result',
363
+ conversationId,
364
+ _requestUserId,
365
+ filePath,
366
+ staged: true,
367
+ diff: cachedOut
368
+ });
369
+ return;
370
+ }
371
+ } else if (!stdout.trim() && staged) {
372
+ const wtCmd = `git diff ${contextFlag} -- "${filePath}"`;
373
+ const { stdout: wtOut } = await execAsync(wtCmd, {
374
+ cwd: gitRoot,
375
+ timeout: 15000,
376
+ maxBuffer: 5 * 1024 * 1024,
377
+ windowsHide: true
378
+ });
379
+ if (wtOut.trim()) {
380
+ ctx.sendToServer({
381
+ type: 'git_diff_result',
382
+ conversationId,
383
+ _requestUserId,
384
+ filePath,
385
+ staged: false,
386
+ diff: wtOut
387
+ });
388
+ return;
389
+ }
390
+ }
391
+
392
+ ctx.sendToServer({
393
+ type: 'git_diff_result',
394
+ conversationId,
395
+ _requestUserId,
396
+ filePath,
397
+ staged: !!staged,
398
+ diff: stdout
399
+ });
400
+ } catch (e) {
401
+ ctx.sendToServer({
402
+ type: 'git_diff_result',
403
+ conversationId,
404
+ _requestUserId,
405
+ filePath,
406
+ error: e.message
407
+ });
408
+ }
409
+ }
410
+
411
+ export async function handleGitAdd(msg) {
412
+ const { conversationId, filePath, addAll, _requestUserId } = msg;
413
+ const conv = ctx.conversations.get(conversationId);
414
+ const workDir = msg.workDir || conv?.workDir || ctx.CONFIG.workDir;
415
+
416
+ try {
417
+ const gitRoot = await getGitRoot(workDir);
418
+
419
+ if (addAll) {
420
+ await execAsync('git add -A', { cwd: gitRoot, timeout: 10000, windowsHide: true });
421
+ } else {
422
+ if (!validateGitPath(filePath)) {
423
+ ctx.sendToServer({ type: 'git_op_result', conversationId, _requestUserId, operation: 'add', success: false, error: 'Invalid file path' });
424
+ return;
425
+ }
426
+ await execAsync(`git add -- "${filePath}"`, { cwd: gitRoot, timeout: 10000, windowsHide: true });
427
+ }
428
+
429
+ ctx.sendToServer({ type: 'git_op_result', conversationId, _requestUserId, operation: 'add', success: true, message: addAll ? 'All files staged' : `Staged: ${filePath}` });
430
+ } catch (e) {
431
+ ctx.sendToServer({ type: 'git_op_result', conversationId, _requestUserId, operation: 'add', success: false, error: e.message });
432
+ }
433
+ }
434
+
435
+ export async function handleGitReset(msg) {
436
+ const { conversationId, filePath, resetAll, _requestUserId } = msg;
437
+ const conv = ctx.conversations.get(conversationId);
438
+ const workDir = msg.workDir || conv?.workDir || ctx.CONFIG.workDir;
439
+
440
+ try {
441
+ const gitRoot = await getGitRoot(workDir);
442
+
443
+ if (resetAll) {
444
+ await execAsync('git reset HEAD', { cwd: gitRoot, timeout: 10000, windowsHide: true });
445
+ } else {
446
+ if (!validateGitPath(filePath)) {
447
+ ctx.sendToServer({ type: 'git_op_result', conversationId, _requestUserId, operation: 'reset', success: false, error: 'Invalid file path' });
448
+ return;
449
+ }
450
+ await execAsync(`git reset HEAD -- "${filePath}"`, { cwd: gitRoot, timeout: 10000, windowsHide: true });
451
+ }
452
+
453
+ ctx.sendToServer({ type: 'git_op_result', conversationId, _requestUserId, operation: 'reset', success: true, message: resetAll ? 'All files unstaged' : `Unstaged: ${filePath}` });
454
+ } catch (e) {
455
+ ctx.sendToServer({ type: 'git_op_result', conversationId, _requestUserId, operation: 'reset', success: false, error: e.message });
456
+ }
457
+ }
458
+
459
+ export async function handleGitRestore(msg) {
460
+ const { conversationId, filePath, _requestUserId } = msg;
461
+ const conv = ctx.conversations.get(conversationId);
462
+ const workDir = msg.workDir || conv?.workDir || ctx.CONFIG.workDir;
463
+
464
+ try {
465
+ if (!validateGitPath(filePath)) {
466
+ ctx.sendToServer({ type: 'git_op_result', conversationId, _requestUserId, operation: 'restore', success: false, error: 'Invalid file path' });
467
+ return;
468
+ }
469
+
470
+ const gitRoot = await getGitRoot(workDir);
471
+ await execAsync(`git restore -- "${filePath}"`, { cwd: gitRoot, timeout: 10000, windowsHide: true });
472
+ ctx.sendToServer({ type: 'git_op_result', conversationId, _requestUserId, operation: 'restore', success: true, message: `Restored: ${filePath}` });
473
+ } catch (e) {
474
+ ctx.sendToServer({ type: 'git_op_result', conversationId, _requestUserId, operation: 'restore', success: false, error: e.message });
475
+ }
476
+ }
477
+
478
+ export async function handleGitCommit(msg) {
479
+ const { conversationId, commitMessage, _requestUserId } = msg;
480
+ const conv = ctx.conversations.get(conversationId);
481
+ const workDir = msg.workDir || conv?.workDir || ctx.CONFIG.workDir;
482
+
483
+ try {
484
+ if (!commitMessage || !commitMessage.trim()) {
485
+ ctx.sendToServer({ type: 'git_op_result', conversationId, _requestUserId, operation: 'commit', success: false, error: 'Commit message is required' });
486
+ return;
487
+ }
488
+
489
+ const gitRoot = await getGitRoot(workDir);
490
+
491
+ // Write commit message to temp file to avoid shell injection
492
+ const tmpFile = join(gitRoot, '.git', 'WEBCHAT_COMMIT_MSG');
493
+ await writeFile(tmpFile, commitMessage.trim(), 'utf8');
494
+
495
+ try {
496
+ const { stdout } = await execAsync(`git commit -F "${tmpFile}"`, {
497
+ cwd: gitRoot, timeout: 30000, windowsHide: true
498
+ });
499
+ ctx.sendToServer({ type: 'git_op_result', conversationId, _requestUserId, operation: 'commit', success: true, message: stdout.trim() });
500
+ } finally {
501
+ // Clean up temp file
502
+ try { await writeFile(tmpFile, '', 'utf8'); } catch {}
503
+ }
504
+ } catch (e) {
505
+ ctx.sendToServer({ type: 'git_op_result', conversationId, _requestUserId, operation: 'commit', success: false, error: e.stderr?.trim() || e.message });
506
+ }
507
+ }
508
+
509
+ export async function handleGitPush(msg) {
510
+ const { conversationId, _requestUserId } = msg;
511
+ const conv = ctx.conversations.get(conversationId);
512
+ const workDir = msg.workDir || conv?.workDir || ctx.CONFIG.workDir;
513
+
514
+ try {
515
+ const gitRoot = await getGitRoot(workDir);
516
+ const { stdout, stderr } = await execAsync('git push', {
517
+ cwd: gitRoot, timeout: 60000, windowsHide: true
518
+ });
519
+ ctx.sendToServer({ type: 'git_op_result', conversationId, _requestUserId, operation: 'push', success: true, message: (stdout + '\n' + stderr).trim() || 'Push complete' });
520
+ } catch (e) {
521
+ ctx.sendToServer({ type: 'git_op_result', conversationId, _requestUserId, operation: 'push', success: false, error: e.stderr?.trim() || e.message });
522
+ }
523
+ }
524
+
525
+ export async function handleFileSearch(msg) {
526
+ const { conversationId, query, _requestUserId } = msg;
527
+ const conv = ctx.conversations.get(conversationId);
528
+ const workDir = msg.workDir || conv?.workDir || ctx.CONFIG.workDir;
529
+ const searchRoot = msg.dirPath || workDir;
530
+
531
+ try {
532
+ if (!query || query.trim().length === 0) {
533
+ ctx.sendToServer({ type: 'file_search_result', conversationId, _requestUserId, query, results: [] });
534
+ return;
535
+ }
536
+
537
+ const resolved = resolve(searchRoot);
538
+ const results = [];
539
+ const MAX_RESULTS = 100;
540
+ const lowerQuery = query.toLowerCase();
541
+ const skipDirs = new Set(['.git', 'node_modules', '__pycache__', '.next', '.nuxt', 'dist', 'build', '.cache', 'bin', 'obj']);
542
+
543
+ async function walk(dir, depth) {
544
+ if (depth > 10 || results.length >= MAX_RESULTS) return;
545
+ try {
546
+ const entries = await readdir(dir, { withFileTypes: true });
547
+ for (const entry of entries) {
548
+ if (results.length >= MAX_RESULTS) return;
549
+ if (entry.name.startsWith('.') && depth > 0) continue;
550
+ if (skipDirs.has(entry.name) && entry.isDirectory()) continue;
551
+
552
+ const fullPath = join(dir, entry.name);
553
+
554
+ if (entry.name.toLowerCase().includes(lowerQuery)) {
555
+ let size = 0;
556
+ try { const s = await stat(fullPath); size = s.size; } catch {}
557
+ results.push({
558
+ name: entry.name,
559
+ path: relative(resolved, fullPath).replace(/\\/g, '/'),
560
+ fullPath: fullPath.replace(/\\/g, '/'),
561
+ type: entry.isDirectory() ? 'directory' : 'file',
562
+ size
563
+ });
564
+ }
565
+
566
+ if (entry.isDirectory()) {
567
+ await walk(fullPath, depth + 1);
568
+ }
569
+ }
570
+ } catch {}
571
+ }
572
+
573
+ await walk(resolved, 0);
574
+
575
+ ctx.sendToServer({
576
+ type: 'file_search_result',
577
+ conversationId,
578
+ _requestUserId,
579
+ query,
580
+ results,
581
+ truncated: results.length >= MAX_RESULTS
582
+ });
583
+ } catch (e) {
584
+ ctx.sendToServer({ type: 'file_search_result', conversationId, _requestUserId, query, results: [], error: e.message });
585
+ }
586
+ }
587
+
588
+ export async function handleCreateFile(msg) {
589
+ const { conversationId, filePath, isDirectory, _requestUserId } = msg;
590
+ const conv = ctx.conversations.get(conversationId);
591
+ const workDir = msg.workDir || conv?.workDir || ctx.CONFIG.workDir;
592
+
593
+ try {
594
+ const resolved = resolveAndValidatePath(filePath, workDir);
595
+ if (isDirectory) {
596
+ await mkdir(resolved, { recursive: true });
597
+ } else {
598
+ // Ensure parent directory exists
599
+ const parentDir = dirname(resolved);
600
+ await mkdir(parentDir, { recursive: true });
601
+ // Create file only if it doesn't exist
602
+ if (existsSync(resolved)) {
603
+ throw new Error('File already exists: ' + resolved);
604
+ }
605
+ await writeFile(resolved, '', 'utf-8');
606
+ }
607
+ ctx.sendToServer({
608
+ type: 'file_op_result', conversationId, _requestUserId,
609
+ operation: 'create', success: true,
610
+ message: (isDirectory ? 'Directory' : 'File') + ' created: ' + basename(resolved)
611
+ });
612
+ } catch (e) {
613
+ ctx.sendToServer({
614
+ type: 'file_op_result', conversationId, _requestUserId,
615
+ operation: 'create', success: false, error: e.message
616
+ });
617
+ }
618
+ }
619
+
620
+ export async function handleDeleteFiles(msg) {
621
+ const { conversationId, paths, _requestUserId } = msg;
622
+ const conv = ctx.conversations.get(conversationId);
623
+ const workDir = msg.workDir || conv?.workDir || ctx.CONFIG.workDir;
624
+
625
+ try {
626
+ if (!paths || paths.length === 0) throw new Error('No paths specified');
627
+ const deleted = [];
628
+ const errors = [];
629
+
630
+ for (const p of paths) {
631
+ try {
632
+ const resolved = resolveAndValidatePath(p, workDir);
633
+ const s = await stat(resolved);
634
+ if (s.isDirectory()) {
635
+ await rm(resolved, { recursive: true, force: true });
636
+ } else {
637
+ await unlink(resolved);
638
+ }
639
+ deleted.push(basename(resolved));
640
+ } catch (e) {
641
+ errors.push(basename(p) + ': ' + e.message);
642
+ }
643
+ }
644
+
645
+ const message = deleted.length > 0
646
+ ? 'Deleted: ' + deleted.join(', ') + (errors.length > 0 ? '; Errors: ' + errors.join(', ') : '')
647
+ : 'Failed: ' + errors.join(', ');
648
+
649
+ ctx.sendToServer({
650
+ type: 'file_op_result', conversationId, _requestUserId,
651
+ operation: 'delete', success: deleted.length > 0,
652
+ message, deletedCount: deleted.length, errorCount: errors.length
653
+ });
654
+ } catch (e) {
655
+ ctx.sendToServer({
656
+ type: 'file_op_result', conversationId, _requestUserId,
657
+ operation: 'delete', success: false, error: e.message
658
+ });
659
+ }
660
+ }
661
+
662
+ export async function handleMoveFiles(msg) {
663
+ const { conversationId, paths, destination, newName, _requestUserId } = msg;
664
+ const conv = ctx.conversations.get(conversationId);
665
+ const workDir = msg.workDir || conv?.workDir || ctx.CONFIG.workDir;
666
+
667
+ try {
668
+ if (!paths || paths.length === 0) throw new Error('No paths specified');
669
+ if (!destination) throw new Error('No destination specified');
670
+
671
+ const destResolved = resolveAndValidatePath(destination, workDir);
672
+ // Ensure destination directory exists
673
+ await mkdir(destResolved, { recursive: true });
674
+
675
+ const moved = [];
676
+ const errors = [];
677
+
678
+ for (const p of paths) {
679
+ try {
680
+ const srcResolved = resolveAndValidatePath(p, workDir);
681
+ const name = (newName && paths.length === 1) ? newName : basename(srcResolved);
682
+ const destPath = join(destResolved, name);
683
+ if (existsSync(destPath)) {
684
+ throw new Error('Target already exists: ' + name);
685
+ }
686
+ await rename(srcResolved, destPath);
687
+ moved.push(name);
688
+ } catch (e) {
689
+ errors.push(basename(p) + ': ' + e.message);
690
+ }
691
+ }
692
+
693
+ const message = moved.length > 0
694
+ ? 'Moved: ' + moved.join(', ') + ' → ' + basename(destResolved) + (errors.length > 0 ? '; Errors: ' + errors.join(', ') : '')
695
+ : 'Failed: ' + errors.join(', ');
696
+
697
+ ctx.sendToServer({
698
+ type: 'file_op_result', conversationId, _requestUserId,
699
+ operation: 'move', success: moved.length > 0,
700
+ message, movedCount: moved.length, errorCount: errors.length
701
+ });
702
+ } catch (e) {
703
+ ctx.sendToServer({
704
+ type: 'file_op_result', conversationId, _requestUserId,
705
+ operation: 'move', success: false, error: e.message
706
+ });
707
+ }
708
+ }
709
+
710
+ export async function handleCopyFiles(msg) {
711
+ const { conversationId, paths, destination, _requestUserId } = msg;
712
+ const conv = ctx.conversations.get(conversationId);
713
+ const workDir = msg.workDir || conv?.workDir || ctx.CONFIG.workDir;
714
+
715
+ try {
716
+ if (!paths || paths.length === 0) throw new Error('No paths specified');
717
+ if (!destination) throw new Error('No destination specified');
718
+
719
+ const destResolved = resolveAndValidatePath(destination, workDir);
720
+ await mkdir(destResolved, { recursive: true });
721
+
722
+ const copied = [];
723
+ const errors = [];
724
+
725
+ for (const p of paths) {
726
+ try {
727
+ const srcResolved = resolveAndValidatePath(p, workDir);
728
+ const name = basename(srcResolved);
729
+ let destPath = join(destResolved, name);
730
+
731
+ // If copying to same directory, generate a unique name
732
+ if (destPath === srcResolved) {
733
+ const ext = extname(name);
734
+ const base = basename(name, ext);
735
+ let counter = 1;
736
+ do {
737
+ destPath = join(destResolved, `${base} (copy${counter > 1 ? ' ' + counter : ''})${ext}`);
738
+ counter++;
739
+ } while (existsSync(destPath));
740
+ }
741
+
742
+ const srcStat = await stat(srcResolved);
743
+ if (srcStat.isDirectory()) {
744
+ await cp(srcResolved, destPath, { recursive: true });
745
+ } else {
746
+ await copyFile(srcResolved, destPath);
747
+ }
748
+ copied.push(basename(destPath));
749
+ } catch (e) {
750
+ errors.push(basename(p) + ': ' + e.message);
751
+ }
752
+ }
753
+
754
+ const message = copied.length > 0
755
+ ? 'Copied: ' + copied.join(', ') + (errors.length > 0 ? '; Errors: ' + errors.join(', ') : '')
756
+ : 'Failed: ' + errors.join(', ');
757
+
758
+ ctx.sendToServer({
759
+ type: 'file_op_result', conversationId, _requestUserId,
760
+ operation: 'copy', success: copied.length > 0,
761
+ message, copiedCount: copied.length, errorCount: errors.length
762
+ });
763
+ } catch (e) {
764
+ ctx.sendToServer({
765
+ type: 'file_op_result', conversationId, _requestUserId,
766
+ operation: 'copy', success: false, error: e.message
767
+ });
768
+ }
769
+ }
770
+
771
+ export async function handleUploadToDir(msg) {
772
+ const { conversationId, files, dirPath, _requestUserId } = msg;
773
+ const conv = ctx.conversations.get(conversationId);
774
+ const workDir = msg.workDir || conv?.workDir || ctx.CONFIG.workDir;
775
+
776
+ try {
777
+ if (!files || files.length === 0) throw new Error('No files specified');
778
+
779
+ const targetDir = resolveAndValidatePath(dirPath || workDir, workDir);
780
+ await mkdir(targetDir, { recursive: true });
781
+
782
+ const saved = [];
783
+ const errors = [];
784
+
785
+ for (const file of files) {
786
+ try {
787
+ const dest = join(targetDir, file.name);
788
+ const buffer = Buffer.from(file.data, 'base64');
789
+ await writeFile(dest, buffer);
790
+ saved.push(file.name);
791
+ } catch (e) {
792
+ errors.push(file.name + ': ' + e.message);
793
+ }
794
+ }
795
+
796
+ const message = saved.length > 0
797
+ ? 'Uploaded: ' + saved.join(', ') + (errors.length > 0 ? '; Errors: ' + errors.join(', ') : '')
798
+ : 'Failed: ' + errors.join(', ');
799
+
800
+ ctx.sendToServer({
801
+ type: 'file_op_result', conversationId, _requestUserId,
802
+ operation: 'upload', success: saved.length > 0,
803
+ message, uploadedCount: saved.length, errorCount: errors.length
804
+ });
805
+ } catch (e) {
806
+ ctx.sendToServer({
807
+ type: 'file_op_result', conversationId, _requestUserId,
808
+ operation: 'upload', success: false, error: e.message
809
+ });
810
+ }
811
+ }
812
+
813
+ // 临时文件目录名 (不易冲突)
814
+ const TEMP_UPLOAD_DIR = '.claude-tmp-attachments';
815
+
816
+ export async function handleTransferFiles(msg) {
817
+ const { conversationId, files, prompt, workDir, claudeSessionId } = msg;
818
+ const { startClaudeQuery } = await import('./claude.js');
819
+
820
+ let state = ctx.conversations.get(conversationId);
821
+ const effectiveWorkDir = workDir || state?.workDir || ctx.CONFIG.workDir;
822
+
823
+ // 创建临时目录
824
+ const uploadDir = join(effectiveWorkDir, TEMP_UPLOAD_DIR);
825
+ if (!existsSync(uploadDir)) {
826
+ mkdirSync(uploadDir, { recursive: true });
827
+ }
828
+
829
+ const savedFiles = [];
830
+ const imageFiles = [];
831
+
832
+ for (const file of files) {
833
+ try {
834
+ const timestamp = Date.now();
835
+ const ext = extname(file.name);
836
+ const baseName = basename(file.name, ext);
837
+ const uniqueName = `${baseName}_${timestamp}${ext}`;
838
+ const filePath = join(uploadDir, uniqueName);
839
+ const relativePath = join(TEMP_UPLOAD_DIR, uniqueName);
840
+
841
+ const buffer = Buffer.from(file.data, 'base64');
842
+ writeFileSync(filePath, buffer);
843
+
844
+ const isImage = file.mimeType.startsWith('image/');
845
+ savedFiles.push({
846
+ name: file.name,
847
+ path: relativePath,
848
+ mimeType: file.mimeType,
849
+ isImage
850
+ });
851
+
852
+ if (isImage) {
853
+ imageFiles.push({
854
+ mimeType: file.mimeType,
855
+ data: file.data
856
+ });
857
+ }
858
+
859
+ console.log(`Saved file: ${relativePath}`);
860
+ } catch (e) {
861
+ console.error(`Error saving file ${file.name}:`, e.message);
862
+ }
863
+ }
864
+
865
+ // 如果没有活跃的查询,启动新的
866
+ if (!state || !state.query || !state.inputStream) {
867
+ const resumeSessionId = claudeSessionId || state?.claudeSessionId || null;
868
+ console.log(`[SDK] Starting Claude for ${conversationId} (files), resume: ${resumeSessionId || 'none'}`);
869
+ state = await startClaudeQuery(conversationId, effectiveWorkDir, resumeSessionId);
870
+ }
871
+
872
+ // 构造带附件的消息
873
+ const fileListText = savedFiles.map(f =>
874
+ `- ${f.path} (${f.isImage ? '图片' : f.mimeType})`
875
+ ).join('\n');
876
+
877
+ const fullPrompt = `用户上传了以下文件:\n${fileListText}\n\n用户说:${prompt}`;
878
+
879
+ // 构造 content 数组
880
+ const content = [];
881
+
882
+ for (const img of imageFiles) {
883
+ content.push({
884
+ type: 'image',
885
+ source: {
886
+ type: 'base64',
887
+ media_type: img.mimeType,
888
+ data: img.data
889
+ }
890
+ });
891
+ }
892
+
893
+ content.push({
894
+ type: 'text',
895
+ text: fullPrompt
896
+ });
897
+
898
+ // 发送用户消息到输入流
899
+ const userMessage = {
900
+ type: 'user',
901
+ message: { role: 'user', content }
902
+ };
903
+
904
+ console.log(`[${conversationId}] Sending with ${savedFiles.length} files, ${imageFiles.length} images`);
905
+ state.turnActive = true;
906
+ state.inputStream.enqueue(userMessage);
907
+ }