@yeaft/webchat-agent 0.0.233 → 0.0.235

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.
@@ -0,0 +1,313 @@
1
+ import { readFile, writeFile } from 'fs/promises';
2
+ import { join, resolve } from 'path';
3
+ import ctx from '../context.js';
4
+ import { execAsync, resolveAndValidatePath, getGitRoot, validateGitPath } from './utils.js';
5
+
6
+ export async function handleGitStatus(msg) {
7
+ const { conversationId, _requestUserId } = msg;
8
+ const conv = ctx.conversations.get(conversationId);
9
+ const workDir = msg.workDir || conv?.workDir || ctx.CONFIG.workDir;
10
+
11
+ try {
12
+ // Get git repo root to ensure paths are consistent
13
+ let gitRoot = workDir;
14
+ try {
15
+ const { stdout: rootOut } = await execAsync('git rev-parse --show-toplevel', {
16
+ cwd: workDir,
17
+ timeout: 5000,
18
+ windowsHide: true
19
+ });
20
+ gitRoot = rootOut.trim();
21
+ } catch {}
22
+
23
+ const { stdout: statusOut } = await execAsync('git status --porcelain', {
24
+ cwd: gitRoot,
25
+ timeout: 10000,
26
+ windowsHide: true
27
+ });
28
+
29
+ let branch = '';
30
+ try {
31
+ const { stdout: branchOut } = await execAsync('git branch --show-current', {
32
+ cwd: gitRoot,
33
+ timeout: 5000,
34
+ windowsHide: true
35
+ });
36
+ branch = branchOut.trim();
37
+ } catch {}
38
+
39
+ // Get ahead/behind counts relative to upstream
40
+ let ahead = 0, behind = 0;
41
+ try {
42
+ const { stdout: abOut } = await execAsync('git rev-list --left-right --count HEAD...@{upstream}', {
43
+ cwd: gitRoot, timeout: 5000, windowsHide: true
44
+ });
45
+ const parts = abOut.trim().split(/\s+/);
46
+ ahead = parseInt(parts[0]) || 0;
47
+ behind = parseInt(parts[1]) || 0;
48
+ } catch {}
49
+
50
+ const files = [];
51
+ for (const line of statusOut.split('\n')) {
52
+ if (!line || line.length < 4) continue;
53
+ const indexStatus = line[0];
54
+ const workTreeStatus = line[1];
55
+ const path = line.slice(3);
56
+ // Handle renamed files: "R old -> new"
57
+ const displayPath = path.includes(' -> ') ? path.split(' -> ')[1] : path;
58
+ files.push({ path: displayPath, indexStatus, workTreeStatus });
59
+ }
60
+
61
+ ctx.sendToServer({
62
+ type: 'git_status_result',
63
+ conversationId,
64
+ _requestUserId,
65
+ branch,
66
+ files,
67
+ ahead,
68
+ behind,
69
+ workDir,
70
+ gitRoot
71
+ });
72
+ } catch (e) {
73
+ ctx.sendToServer({
74
+ type: 'git_status_result',
75
+ conversationId,
76
+ _requestUserId,
77
+ error: e.message,
78
+ isGitRepo: !e.message.includes('not a git repository') && !e.message.includes('ENOENT')
79
+ });
80
+ }
81
+ }
82
+
83
+ export async function handleGitDiff(msg) {
84
+ const { conversationId, filePath, staged, untracked, fullFile, _requestUserId } = msg;
85
+ const conv = ctx.conversations.get(conversationId);
86
+ const workDir = msg.workDir || conv?.workDir || ctx.CONFIG.workDir;
87
+
88
+ try {
89
+ // 安全检查:验证 filePath 不包含 shell 注入字符
90
+ if (!filePath || /[`$;|&><!\n\r]/.test(filePath)) {
91
+ ctx.sendToServer({
92
+ type: 'git_diff_result',
93
+ conversationId,
94
+ _requestUserId,
95
+ filePath,
96
+ error: 'Invalid file path'
97
+ });
98
+ return;
99
+ }
100
+
101
+ // Get git repo root — git status paths are relative to this, not workDir
102
+ let gitRoot = workDir;
103
+ try {
104
+ const { stdout: rootOut } = await execAsync('git rev-parse --show-toplevel', {
105
+ cwd: workDir,
106
+ timeout: 5000,
107
+ windowsHide: true
108
+ });
109
+ gitRoot = rootOut.trim();
110
+ } catch {}
111
+
112
+ if (untracked) {
113
+ // Untracked files: resolve path relative to git root
114
+ const fullPath = resolve(gitRoot, filePath);
115
+ const resolved = resolveAndValidatePath(fullPath, gitRoot);
116
+ const content = await readFile(resolved, 'utf-8');
117
+ ctx.sendToServer({
118
+ type: 'git_diff_result',
119
+ conversationId,
120
+ _requestUserId,
121
+ filePath,
122
+ diff: null,
123
+ newFileContent: content
124
+ });
125
+ return;
126
+ }
127
+
128
+ // 使用 -- 分隔选项和路径, execAsync 的参数已被验证无注入字符
129
+ const contextFlag = fullFile ? '-U99999' : '';
130
+ const cmd = staged
131
+ ? `git diff --cached ${contextFlag} -- "${filePath}"`
132
+ : `git diff ${contextFlag} -- "${filePath}"`;
133
+
134
+ const { stdout } = await execAsync(cmd, {
135
+ cwd: gitRoot,
136
+ timeout: 15000,
137
+ maxBuffer: 5 * 1024 * 1024,
138
+ windowsHide: true
139
+ });
140
+
141
+ // 如果 git diff 返回空,尝试用 --cached 或反之
142
+ if (!stdout.trim() && !staged) {
143
+ const cachedCmd = `git diff --cached ${contextFlag} -- "${filePath}"`;
144
+ const { stdout: cachedOut } = await execAsync(cachedCmd, {
145
+ cwd: gitRoot,
146
+ timeout: 15000,
147
+ maxBuffer: 5 * 1024 * 1024,
148
+ windowsHide: true
149
+ });
150
+ if (cachedOut.trim()) {
151
+ ctx.sendToServer({
152
+ type: 'git_diff_result',
153
+ conversationId,
154
+ _requestUserId,
155
+ filePath,
156
+ staged: true,
157
+ diff: cachedOut
158
+ });
159
+ return;
160
+ }
161
+ } else if (!stdout.trim() && staged) {
162
+ const wtCmd = `git diff ${contextFlag} -- "${filePath}"`;
163
+ const { stdout: wtOut } = await execAsync(wtCmd, {
164
+ cwd: gitRoot,
165
+ timeout: 15000,
166
+ maxBuffer: 5 * 1024 * 1024,
167
+ windowsHide: true
168
+ });
169
+ if (wtOut.trim()) {
170
+ ctx.sendToServer({
171
+ type: 'git_diff_result',
172
+ conversationId,
173
+ _requestUserId,
174
+ filePath,
175
+ staged: false,
176
+ diff: wtOut
177
+ });
178
+ return;
179
+ }
180
+ }
181
+
182
+ ctx.sendToServer({
183
+ type: 'git_diff_result',
184
+ conversationId,
185
+ _requestUserId,
186
+ filePath,
187
+ staged: !!staged,
188
+ diff: stdout
189
+ });
190
+ } catch (e) {
191
+ ctx.sendToServer({
192
+ type: 'git_diff_result',
193
+ conversationId,
194
+ _requestUserId,
195
+ filePath,
196
+ error: e.message
197
+ });
198
+ }
199
+ }
200
+
201
+ export async function handleGitAdd(msg) {
202
+ const { conversationId, filePath, addAll, _requestUserId } = msg;
203
+ const conv = ctx.conversations.get(conversationId);
204
+ const workDir = msg.workDir || conv?.workDir || ctx.CONFIG.workDir;
205
+
206
+ try {
207
+ const gitRoot = await getGitRoot(workDir);
208
+
209
+ if (addAll) {
210
+ await execAsync('git add -A', { cwd: gitRoot, timeout: 10000, windowsHide: true });
211
+ } else {
212
+ if (!validateGitPath(filePath)) {
213
+ ctx.sendToServer({ type: 'git_op_result', conversationId, _requestUserId, operation: 'add', success: false, error: 'Invalid file path' });
214
+ return;
215
+ }
216
+ await execAsync(`git add -- "${filePath}"`, { cwd: gitRoot, timeout: 10000, windowsHide: true });
217
+ }
218
+
219
+ ctx.sendToServer({ type: 'git_op_result', conversationId, _requestUserId, operation: 'add', success: true, message: addAll ? 'All files staged' : `Staged: ${filePath}` });
220
+ } catch (e) {
221
+ ctx.sendToServer({ type: 'git_op_result', conversationId, _requestUserId, operation: 'add', success: false, error: e.message });
222
+ }
223
+ }
224
+
225
+ export async function handleGitReset(msg) {
226
+ const { conversationId, filePath, resetAll, _requestUserId } = msg;
227
+ const conv = ctx.conversations.get(conversationId);
228
+ const workDir = msg.workDir || conv?.workDir || ctx.CONFIG.workDir;
229
+
230
+ try {
231
+ const gitRoot = await getGitRoot(workDir);
232
+
233
+ if (resetAll) {
234
+ await execAsync('git reset HEAD', { cwd: gitRoot, timeout: 10000, windowsHide: true });
235
+ } else {
236
+ if (!validateGitPath(filePath)) {
237
+ ctx.sendToServer({ type: 'git_op_result', conversationId, _requestUserId, operation: 'reset', success: false, error: 'Invalid file path' });
238
+ return;
239
+ }
240
+ await execAsync(`git reset HEAD -- "${filePath}"`, { cwd: gitRoot, timeout: 10000, windowsHide: true });
241
+ }
242
+
243
+ ctx.sendToServer({ type: 'git_op_result', conversationId, _requestUserId, operation: 'reset', success: true, message: resetAll ? 'All files unstaged' : `Unstaged: ${filePath}` });
244
+ } catch (e) {
245
+ ctx.sendToServer({ type: 'git_op_result', conversationId, _requestUserId, operation: 'reset', success: false, error: e.message });
246
+ }
247
+ }
248
+
249
+ export async function handleGitRestore(msg) {
250
+ const { conversationId, filePath, _requestUserId } = msg;
251
+ const conv = ctx.conversations.get(conversationId);
252
+ const workDir = msg.workDir || conv?.workDir || ctx.CONFIG.workDir;
253
+
254
+ try {
255
+ if (!validateGitPath(filePath)) {
256
+ ctx.sendToServer({ type: 'git_op_result', conversationId, _requestUserId, operation: 'restore', success: false, error: 'Invalid file path' });
257
+ return;
258
+ }
259
+
260
+ const gitRoot = await getGitRoot(workDir);
261
+ await execAsync(`git restore -- "${filePath}"`, { cwd: gitRoot, timeout: 10000, windowsHide: true });
262
+ ctx.sendToServer({ type: 'git_op_result', conversationId, _requestUserId, operation: 'restore', success: true, message: `Restored: ${filePath}` });
263
+ } catch (e) {
264
+ ctx.sendToServer({ type: 'git_op_result', conversationId, _requestUserId, operation: 'restore', success: false, error: e.message });
265
+ }
266
+ }
267
+
268
+ export async function handleGitCommit(msg) {
269
+ const { conversationId, commitMessage, _requestUserId } = msg;
270
+ const conv = ctx.conversations.get(conversationId);
271
+ const workDir = msg.workDir || conv?.workDir || ctx.CONFIG.workDir;
272
+
273
+ try {
274
+ if (!commitMessage || !commitMessage.trim()) {
275
+ ctx.sendToServer({ type: 'git_op_result', conversationId, _requestUserId, operation: 'commit', success: false, error: 'Commit message is required' });
276
+ return;
277
+ }
278
+
279
+ const gitRoot = await getGitRoot(workDir);
280
+
281
+ // Write commit message to temp file to avoid shell injection
282
+ const tmpFile = join(gitRoot, '.git', 'WEBCHAT_COMMIT_MSG');
283
+ await writeFile(tmpFile, commitMessage.trim(), 'utf8');
284
+
285
+ try {
286
+ const { stdout } = await execAsync(`git commit -F "${tmpFile}"`, {
287
+ cwd: gitRoot, timeout: 30000, windowsHide: true
288
+ });
289
+ ctx.sendToServer({ type: 'git_op_result', conversationId, _requestUserId, operation: 'commit', success: true, message: stdout.trim() });
290
+ } finally {
291
+ // Clean up temp file
292
+ try { await writeFile(tmpFile, '', 'utf8'); } catch {}
293
+ }
294
+ } catch (e) {
295
+ ctx.sendToServer({ type: 'git_op_result', conversationId, _requestUserId, operation: 'commit', success: false, error: e.stderr?.trim() || e.message });
296
+ }
297
+ }
298
+
299
+ export async function handleGitPush(msg) {
300
+ const { conversationId, _requestUserId } = msg;
301
+ const conv = ctx.conversations.get(conversationId);
302
+ const workDir = msg.workDir || conv?.workDir || ctx.CONFIG.workDir;
303
+
304
+ try {
305
+ const gitRoot = await getGitRoot(workDir);
306
+ const { stdout, stderr } = await execAsync('git push', {
307
+ cwd: gitRoot, timeout: 60000, windowsHide: true
308
+ });
309
+ ctx.sendToServer({ type: 'git_op_result', conversationId, _requestUserId, operation: 'push', success: true, message: (stdout + '\n' + stderr).trim() || 'Push complete' });
310
+ } catch (e) {
311
+ ctx.sendToServer({ type: 'git_op_result', conversationId, _requestUserId, operation: 'push', success: false, error: e.stderr?.trim() || e.message });
312
+ }
313
+ }
@@ -0,0 +1,99 @@
1
+ import { existsSync, writeFileSync, mkdirSync } from 'fs';
2
+ import { join, basename, extname } from 'path';
3
+ import ctx from '../context.js';
4
+
5
+ // 临时文件目录名 (不易冲突)
6
+ const TEMP_UPLOAD_DIR = '.claude-tmp-attachments';
7
+
8
+ export async function handleTransferFiles(msg) {
9
+ const { conversationId, files, prompt, workDir, claudeSessionId } = msg;
10
+ const { startClaudeQuery } = await import('../claude.js');
11
+
12
+ let state = ctx.conversations.get(conversationId);
13
+ const effectiveWorkDir = workDir || state?.workDir || ctx.CONFIG.workDir;
14
+
15
+ // 创建临时目录
16
+ const uploadDir = join(effectiveWorkDir, TEMP_UPLOAD_DIR);
17
+ if (!existsSync(uploadDir)) {
18
+ mkdirSync(uploadDir, { recursive: true });
19
+ }
20
+
21
+ const savedFiles = [];
22
+ const imageFiles = [];
23
+
24
+ for (const file of files) {
25
+ try {
26
+ const timestamp = Date.now();
27
+ const ext = extname(file.name);
28
+ const baseName = basename(file.name, ext);
29
+ const uniqueName = `${baseName}_${timestamp}${ext}`;
30
+ const filePath = join(uploadDir, uniqueName);
31
+ const relativePath = join(TEMP_UPLOAD_DIR, uniqueName);
32
+
33
+ const buffer = Buffer.from(file.data, 'base64');
34
+ writeFileSync(filePath, buffer);
35
+
36
+ const isImage = file.mimeType.startsWith('image/');
37
+ savedFiles.push({
38
+ name: file.name,
39
+ path: relativePath,
40
+ mimeType: file.mimeType,
41
+ isImage
42
+ });
43
+
44
+ if (isImage) {
45
+ imageFiles.push({
46
+ mimeType: file.mimeType,
47
+ data: file.data
48
+ });
49
+ }
50
+
51
+ console.log(`Saved file: ${relativePath}`);
52
+ } catch (e) {
53
+ console.error(`Error saving file ${file.name}:`, e.message);
54
+ }
55
+ }
56
+
57
+ // 如果没有活跃的查询,启动新的
58
+ if (!state || !state.query || !state.inputStream) {
59
+ const resumeSessionId = claudeSessionId || state?.claudeSessionId || null;
60
+ console.log(`[SDK] Starting Claude for ${conversationId} (files), resume: ${resumeSessionId || 'none'}`);
61
+ state = await startClaudeQuery(conversationId, effectiveWorkDir, resumeSessionId);
62
+ }
63
+
64
+ // 构造带附件的消息
65
+ const fileListText = savedFiles.map(f =>
66
+ `- ${f.path} (${f.isImage ? '图片' : f.mimeType})`
67
+ ).join('\n');
68
+
69
+ const fullPrompt = `用户上传了以下文件:\n${fileListText}\n\n用户说:${prompt}`;
70
+
71
+ // 构造 content 数组
72
+ const content = [];
73
+
74
+ for (const img of imageFiles) {
75
+ content.push({
76
+ type: 'image',
77
+ source: {
78
+ type: 'base64',
79
+ media_type: img.mimeType,
80
+ data: img.data
81
+ }
82
+ });
83
+ }
84
+
85
+ content.push({
86
+ type: 'text',
87
+ text: fullPrompt
88
+ });
89
+
90
+ // 发送用户消息到输入流
91
+ const userMessage = {
92
+ type: 'user',
93
+ message: { role: 'user', content }
94
+ };
95
+
96
+ console.log(`[${conversationId}] Sending with ${savedFiles.length} files, ${imageFiles.length} images`);
97
+ state.turnActive = true;
98
+ state.inputStream.enqueue(userMessage);
99
+ }
@@ -0,0 +1,41 @@
1
+ import { exec } from 'child_process';
2
+ import { promisify } from 'util';
3
+ import { resolve, isAbsolute } from 'path';
4
+
5
+ export const execAsync = promisify(exec);
6
+
7
+ // 路径安全校验 - 确保路径格式正确
8
+ export function resolveAndValidatePath(filePath, workDir) {
9
+ const resolved = isAbsolute(filePath) ? resolve(filePath) : resolve(workDir, filePath);
10
+ return resolved;
11
+ }
12
+
13
+ // Helper: resolve git root for a working directory
14
+ export async function getGitRoot(workDir) {
15
+ try {
16
+ const { stdout } = await execAsync('git rev-parse --show-toplevel', {
17
+ cwd: workDir, timeout: 5000, windowsHide: true
18
+ });
19
+ return stdout.trim();
20
+ } catch {
21
+ return workDir;
22
+ }
23
+ }
24
+
25
+ // Helper: validate file path for shell safety
26
+ export function validateGitPath(filePath) {
27
+ return filePath && !/[`$;|&><!\n\r]/.test(filePath);
28
+ }
29
+
30
+ // Binary file extensions → MIME type mapping
31
+ export const BINARY_EXTENSIONS = {
32
+ '.pdf': 'application/pdf',
33
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
34
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
35
+ '.xls': 'application/vnd.ms-excel',
36
+ '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
37
+ '.ppt': 'application/vnd.ms-powerpoint',
38
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
39
+ '.gif': 'image/gif', '.webp': 'image/webp', '.bmp': 'image/bmp',
40
+ '.ico': 'image/x-icon'
41
+ };