@aiscene/aiserver 1.8.4 → 1.8.6

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.
Files changed (37) hide show
  1. package/dist/capture/capture-manager.d.ts +97 -0
  2. package/dist/capture/capture-manager.d.ts.map +1 -0
  3. package/dist/capture/capture-manager.js +426 -0
  4. package/dist/capture/capture-manager.js.map +1 -0
  5. package/dist/capture/knowledge-organizer.d.ts +20 -0
  6. package/dist/capture/knowledge-organizer.d.ts.map +1 -0
  7. package/dist/capture/knowledge-organizer.js +274 -0
  8. package/dist/capture/knowledge-organizer.js.map +1 -0
  9. package/dist/capture/page-extractor.d.ts +12 -0
  10. package/dist/capture/page-extractor.d.ts.map +1 -0
  11. package/dist/capture/page-extractor.js +245 -0
  12. package/dist/capture/page-extractor.js.map +1 -0
  13. package/dist/capture/types.d.ts +152 -0
  14. package/dist/capture/types.d.ts.map +1 -0
  15. package/dist/capture/types.js +7 -0
  16. package/dist/capture/types.js.map +1 -0
  17. package/dist/config/index.d.ts.map +1 -1
  18. package/dist/config/index.js +5 -0
  19. package/dist/config/index.js.map +1 -1
  20. package/dist/config/schema.d.ts +10 -0
  21. package/dist/config/schema.d.ts.map +1 -1
  22. package/dist/debug/types.d.ts +5 -0
  23. package/dist/debug/types.d.ts.map +1 -1
  24. package/dist/debug/websocket-server.d.ts +1 -0
  25. package/dist/debug/websocket-server.d.ts.map +1 -1
  26. package/dist/debug/websocket-server.js +96 -72
  27. package/dist/debug/websocket-server.js.map +1 -1
  28. package/dist/node/service.d.ts.map +1 -1
  29. package/dist/node/service.js +1 -0
  30. package/dist/node/service.js.map +1 -1
  31. package/dist/task/execution-params.d.ts.map +1 -1
  32. package/dist/task/execution-params.js +7 -4
  33. package/dist/task/execution-params.js.map +1 -1
  34. package/dist/web/server.d.ts.map +1 -1
  35. package/dist/web/server.js +889 -0
  36. package/dist/web/server.js.map +1 -1
  37. package/package.json +1 -1
@@ -2,6 +2,8 @@ import express from 'express';
2
2
  import fs from 'fs';
3
3
  import path from 'path';
4
4
  import axios from 'axios';
5
+ import { execFile } from 'child_process';
6
+ import { promisify } from 'util';
5
7
  import { createLogger } from '../core/logger.js';
6
8
  import { deviceRepo } from '../storage/repositories/device-repo.js';
7
9
  import { taskRepo } from '../storage/repositories/task-repo.js';
@@ -11,8 +13,371 @@ import { getDebugPageHtml } from './debug-page.js';
11
13
  import { getConfig } from '../config/index.js';
12
14
  import { AutobotsClient } from '../core/autobots-client.js';
13
15
  import { taskPollerService } from '../task/poller.js';
16
+ import { captureManager } from '../capture/capture-manager.js';
14
17
  import { activateAIServer, isNodeAuthUsable, loadNodeAuth, normalizeNodeAuthServerUrl, } from '../auth/node-auth.js';
15
18
  const logger = createLogger('WebServer');
19
+ const execFileAsync = promisify(execFile);
20
+ const CODE_WORKSPACE_IGNORED_NAMES = new Set([
21
+ '.git',
22
+ '.hg',
23
+ '.svn',
24
+ 'node_modules',
25
+ 'dist',
26
+ 'build',
27
+ 'coverage',
28
+ '.next',
29
+ '.nuxt',
30
+ '.turbo',
31
+ '.cache',
32
+ '.idea',
33
+ '.vscode',
34
+ ]);
35
+ const CODE_WORKSPACE_SENSITIVE_NAMES = new Set([
36
+ '.env',
37
+ '.env.local',
38
+ '.env.development',
39
+ '.env.production',
40
+ '.npmrc',
41
+ '.yarnrc',
42
+ 'id_rsa',
43
+ 'id_dsa',
44
+ 'id_ecdsa',
45
+ 'id_ed25519',
46
+ ]);
47
+ const MAX_CODE_WORKSPACE_ENTRIES = 500;
48
+ const MAX_CODE_WORKSPACE_FILE_BYTES = 1024 * 1024;
49
+ const MAX_CODE_WORKSPACE_FILE_CHARS = 240000;
50
+ const MAX_CODE_WORKSPACE_SEARCH_RESULTS = 200;
51
+ const MAX_CODE_WORKSPACE_COMMAND_OUTPUT_CHARS = 120000;
52
+ const CODE_WORKSPACE_BLOCKED_COMMANDS = [
53
+ /\brm\s+-rf\s+\/\b/,
54
+ /\bsudo\b/,
55
+ /\bshutdown\b/,
56
+ /\breboot\b/,
57
+ /\bmkfs\b/,
58
+ /\bdiskutil\b/,
59
+ ];
60
+ function isPathInside(parent, child) {
61
+ const relative = path.relative(parent, child);
62
+ return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative));
63
+ }
64
+ function isAllowedCodeWorkspaceOrigin(req) {
65
+ const origin = req.headers.origin;
66
+ if (!origin || typeof origin !== 'string')
67
+ return true;
68
+ try {
69
+ const hostname = new URL(origin).hostname;
70
+ return (hostname === 'localhost' ||
71
+ hostname === '127.0.0.1' ||
72
+ hostname.endsWith('.localhost') ||
73
+ hostname === 'opentest.jd.com' ||
74
+ hostname === 'clawai.jd.com' ||
75
+ hostname.endsWith('.jd.com'));
76
+ }
77
+ catch {
78
+ return false;
79
+ }
80
+ }
81
+ function isIgnoredWorkspaceName(name) {
82
+ return CODE_WORKSPACE_IGNORED_NAMES.has(name);
83
+ }
84
+ function isSensitiveWorkspaceName(name) {
85
+ const lowerName = name.toLowerCase();
86
+ return CODE_WORKSPACE_SENSITIVE_NAMES.has(lowerName) || lowerName.endsWith('.pem') || lowerName.endsWith('.key');
87
+ }
88
+ async function resolveWorkspaceRoot(rawPath) {
89
+ const rootPath = typeof rawPath === 'string' ? rawPath.trim() : '';
90
+ if (!rootPath) {
91
+ throw new Error('缺少本地工作目录路径');
92
+ }
93
+ if (!path.isAbsolute(rootPath)) {
94
+ throw new Error('本地工作目录必须是绝对路径');
95
+ }
96
+ const resolved = path.resolve(rootPath);
97
+ const realRoot = await fs.promises.realpath(resolved);
98
+ const stat = await fs.promises.stat(realRoot);
99
+ if (!stat.isDirectory()) {
100
+ throw new Error('本地工作目录不是文件夹');
101
+ }
102
+ return realRoot;
103
+ }
104
+ async function resolveWorkspaceTarget(root, rawTarget) {
105
+ const targetText = typeof rawTarget === 'string' && rawTarget.trim() ? rawTarget.trim() : root;
106
+ const resolved = path.resolve(path.isAbsolute(targetText) ? targetText : path.join(root, targetText));
107
+ if (!isPathInside(root, resolved)) {
108
+ throw new Error('目标路径不在本地工作目录内');
109
+ }
110
+ const realTarget = await fs.promises.realpath(resolved);
111
+ if (!isPathInside(root, realTarget)) {
112
+ throw new Error('目标路径指向工作目录外部');
113
+ }
114
+ return realTarget;
115
+ }
116
+ async function resolveWorkspaceWritableTarget(root, rawTarget) {
117
+ const targetText = typeof rawTarget === 'string' && rawTarget.trim() ? rawTarget.trim() : '';
118
+ if (!targetText) {
119
+ throw new Error('缺少目标文件路径');
120
+ }
121
+ const resolved = path.resolve(path.isAbsolute(targetText) ? targetText : path.join(root, targetText));
122
+ if (!isPathInside(root, resolved)) {
123
+ throw new Error('目标路径不在本地工作目录内');
124
+ }
125
+ const parentDir = await fs.promises.realpath(path.dirname(resolved));
126
+ if (!isPathInside(root, parentDir)) {
127
+ throw new Error('目标路径指向工作目录外部');
128
+ }
129
+ return resolved;
130
+ }
131
+ function truncateWorkspaceText(text, maxChars = MAX_CODE_WORKSPACE_COMMAND_OUTPUT_CHARS) {
132
+ if (text.length <= maxChars)
133
+ return { text, truncated: false };
134
+ return { text: text.slice(0, maxChars), truncated: true };
135
+ }
136
+ function assertSafeWorkspaceFile(filePath, action) {
137
+ if (isSensitiveWorkspaceName(path.basename(filePath))) {
138
+ throw new Error(`敏感文件不允许通过工作区${action}`);
139
+ }
140
+ }
141
+ function assertSafeWorkspaceCommand(command) {
142
+ const normalized = command.trim();
143
+ if (!normalized)
144
+ throw new Error('缺少要执行的命令');
145
+ if (CODE_WORKSPACE_BLOCKED_COMMANDS.some((pattern) => pattern.test(normalized))) {
146
+ throw new Error('该命令风险过高,已被本地 AIServer 拦截');
147
+ }
148
+ }
149
+ function deriveGitRepoDirName(repoUrl) {
150
+ const cleanUrl = repoUrl.replace(/[?#].*$/, '').replace(/\/+$/, '');
151
+ const rawName = cleanUrl.split(/[/:]/).filter(Boolean).pop() || 'repository';
152
+ const withoutGit = rawName.replace(/\.git$/i, '');
153
+ return withoutGit.replace(/[^A-Za-z0-9._-]/g, '-').replace(/^-+|-+$/g, '') || `repository-${Date.now()}`;
154
+ }
155
+ function assertSafeGitRepoUrl(repoUrl) {
156
+ if (!repoUrl) {
157
+ throw new Error('缺少 Git 仓库地址');
158
+ }
159
+ if (repoUrl.includes('\n') || repoUrl.includes('\r') || repoUrl.startsWith('-')) {
160
+ throw new Error('Git 仓库地址不合法');
161
+ }
162
+ const isHttps = /^https:\/\/[^\s]+$/i.test(repoUrl);
163
+ const isSshUrl = /^ssh:\/\/[^\s]+$/i.test(repoUrl);
164
+ const isScpLikeSsh = /^[A-Za-z0-9._-]+@[A-Za-z0-9._-]+:[^\s]+$/i.test(repoUrl);
165
+ if (!isHttps && !isSshUrl && !isScpLikeSsh) {
166
+ throw new Error('Git 仓库地址仅支持 https 或 ssh');
167
+ }
168
+ }
169
+ function assertSafeGitRefName(refName, label) {
170
+ if (!refName)
171
+ return;
172
+ if (!/^[A-Za-z0-9._/-]+$/.test(refName) || refName.includes('..') || refName.startsWith('-') || refName.endsWith('/')) {
173
+ throw new Error(`${label}不合法`);
174
+ }
175
+ }
176
+ async function resolveWorkspaceCloneTarget(root, rawTargetDir, repoUrl) {
177
+ const targetText = typeof rawTargetDir === 'string' && rawTargetDir.trim()
178
+ ? rawTargetDir.trim()
179
+ : deriveGitRepoDirName(repoUrl);
180
+ if (targetText.includes('\0')) {
181
+ throw new Error('目标目录不合法');
182
+ }
183
+ const parts = targetText.split(/[\\/]+/).filter(Boolean);
184
+ if (parts.some((part) => part === '..' || isSensitiveWorkspaceName(part) || isIgnoredWorkspaceName(part))) {
185
+ throw new Error('目标目录包含不允许的路径片段');
186
+ }
187
+ const resolved = path.resolve(path.isAbsolute(targetText) ? targetText : path.join(root, targetText));
188
+ if (!isPathInside(root, resolved)) {
189
+ throw new Error('目标目录不在本地工作目录内');
190
+ }
191
+ const parentDir = await fs.promises.realpath(path.dirname(resolved));
192
+ if (!isPathInside(root, parentDir)) {
193
+ throw new Error('目标目录指向工作目录外部');
194
+ }
195
+ const existingEntries = await fs.promises.readdir(resolved).catch((error) => {
196
+ if (error?.code === 'ENOENT')
197
+ return null;
198
+ throw error;
199
+ });
200
+ if (existingEntries && existingEntries.length > 0) {
201
+ throw new Error('目标目录已存在且不为空');
202
+ }
203
+ return resolved;
204
+ }
205
+ async function hasVisibleChildren(dirPath) {
206
+ try {
207
+ const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
208
+ return entries.some((entry) => !isIgnoredWorkspaceName(entry.name) && !isSensitiveWorkspaceName(entry.name));
209
+ }
210
+ catch {
211
+ return false;
212
+ }
213
+ }
214
+ async function buildWorkspaceNode(entryPath, root, depth) {
215
+ const stat = await fs.promises.lstat(entryPath);
216
+ const isDirectory = stat.isDirectory();
217
+ const node = {
218
+ name: path.basename(entryPath),
219
+ path: entryPath,
220
+ relativePath: path.relative(root, entryPath) || '.',
221
+ isDirectory,
222
+ size: stat.size,
223
+ modifiedAt: stat.mtime.toISOString(),
224
+ hasChildren: isDirectory ? await hasVisibleChildren(entryPath) : false,
225
+ };
226
+ if (isDirectory && depth > 0) {
227
+ node.children = await listWorkspaceDirectory(root, entryPath, depth - 1);
228
+ }
229
+ return node;
230
+ }
231
+ async function listWorkspaceDirectory(root, dirPath, depth = 0) {
232
+ const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
233
+ const visibleEntries = entries
234
+ .filter((entry) => !isIgnoredWorkspaceName(entry.name) && !isSensitiveWorkspaceName(entry.name))
235
+ .sort((a, b) => {
236
+ if (a.isDirectory() !== b.isDirectory())
237
+ return a.isDirectory() ? -1 : 1;
238
+ return a.name.localeCompare(b.name, 'zh-Hans-CN');
239
+ })
240
+ .slice(0, MAX_CODE_WORKSPACE_ENTRIES);
241
+ const nodes = await Promise.all(visibleEntries.map((entry) => buildWorkspaceNode(path.join(dirPath, entry.name), root, depth)));
242
+ return nodes;
243
+ }
244
+ async function searchWorkspaceWithNode(root, searchRoot, query, maxResults) {
245
+ const results = [];
246
+ const queue = [searchRoot];
247
+ const lowerQuery = query.toLowerCase();
248
+ while (queue.length > 0 && results.length < maxResults) {
249
+ const current = queue.shift();
250
+ let entries;
251
+ try {
252
+ entries = await fs.promises.readdir(current, { withFileTypes: true });
253
+ }
254
+ catch {
255
+ continue;
256
+ }
257
+ for (const entry of entries) {
258
+ if (results.length >= maxResults)
259
+ break;
260
+ if (isIgnoredWorkspaceName(entry.name) || isSensitiveWorkspaceName(entry.name))
261
+ continue;
262
+ const entryPath = path.join(current, entry.name);
263
+ if (entry.isDirectory()) {
264
+ queue.push(entryPath);
265
+ continue;
266
+ }
267
+ if (!entry.isFile())
268
+ continue;
269
+ let buffer;
270
+ try {
271
+ buffer = await fs.promises.readFile(entryPath);
272
+ }
273
+ catch {
274
+ continue;
275
+ }
276
+ if (buffer.length > MAX_CODE_WORKSPACE_FILE_BYTES)
277
+ continue;
278
+ const text = buffer.toString('utf8');
279
+ if (text.includes('\u0000'))
280
+ continue;
281
+ const lines = text.split(/\r?\n/);
282
+ for (let index = 0; index < lines.length && results.length < maxResults; index++) {
283
+ const line = lines[index];
284
+ if (!line.toLowerCase().includes(lowerQuery))
285
+ continue;
286
+ results.push({
287
+ filePath: entryPath,
288
+ relativePath: path.relative(root, entryPath),
289
+ lineNumber: index + 1,
290
+ line: line.slice(0, 1000),
291
+ });
292
+ }
293
+ }
294
+ }
295
+ return results;
296
+ }
297
+ async function searchWorkspace(root, searchRoot, query, maxResults) {
298
+ if (!query.trim()) {
299
+ throw new Error('缺少搜索关键词');
300
+ }
301
+ try {
302
+ const { stdout } = await execFileAsync('rg', [
303
+ '--line-number',
304
+ '--no-heading',
305
+ '--color',
306
+ 'never',
307
+ '--hidden',
308
+ '--glob',
309
+ '!.git',
310
+ '--glob',
311
+ '!node_modules',
312
+ '--glob',
313
+ '!dist',
314
+ '--glob',
315
+ '!build',
316
+ '--',
317
+ query,
318
+ searchRoot,
319
+ ], { cwd: root, timeout: 15000, maxBuffer: 4 * 1024 * 1024 });
320
+ const results = String(stdout || '')
321
+ .split('\n')
322
+ .filter(Boolean)
323
+ .slice(0, maxResults)
324
+ .map((line) => {
325
+ const match = line.match(/^(.*?):(\d+):(.*)$/);
326
+ if (!match) {
327
+ return { raw: line };
328
+ }
329
+ const filePath = path.resolve(match[1]);
330
+ return {
331
+ filePath,
332
+ relativePath: path.relative(root, filePath),
333
+ lineNumber: Number(match[2]),
334
+ line: match[3],
335
+ };
336
+ });
337
+ return { root, searchRoot, query, results, truncated: results.length >= maxResults };
338
+ }
339
+ catch (error) {
340
+ if (error?.code === 1) {
341
+ return { root, searchRoot, query, results: [], truncated: false };
342
+ }
343
+ const fallbackResults = await searchWorkspaceWithNode(root, searchRoot, query, maxResults);
344
+ return { root, searchRoot, query, results: fallbackResults, truncated: fallbackResults.length >= maxResults };
345
+ }
346
+ }
347
+ async function getGitStatus(root) {
348
+ try {
349
+ const [{ stdout: branchStdout }, { stdout: statusStdout }, { stdout: topLevelStdout }] = await Promise.all([
350
+ execFileAsync('git', ['branch', '--show-current'], { cwd: root, timeout: 5000, maxBuffer: 1024 * 1024 }),
351
+ execFileAsync('git', ['status', '--short'], { cwd: root, timeout: 5000, maxBuffer: 1024 * 1024 }),
352
+ execFileAsync('git', ['rev-parse', '--show-toplevel'], { cwd: root, timeout: 5000, maxBuffer: 1024 * 1024 }),
353
+ ]);
354
+ const changedFiles = String(statusStdout || '')
355
+ .split('\n')
356
+ .map((line) => line.trimEnd())
357
+ .filter(Boolean)
358
+ .slice(0, 200)
359
+ .map((line) => ({
360
+ status: line.slice(0, 2).trim() || line.slice(0, 1),
361
+ path: line.slice(3).trim() || line,
362
+ raw: line,
363
+ }));
364
+ const repositoryRoot = String(topLevelStdout || '').trim() || root;
365
+ return {
366
+ repositories: [{
367
+ root: repositoryRoot,
368
+ relativePath: path.relative(root, repositoryRoot) || '.',
369
+ branch: String(branchStdout || '').trim() || 'HEAD',
370
+ clean: changedFiles.length === 0,
371
+ changedFiles,
372
+ changedFileCount: changedFiles.length,
373
+ truncated: String(statusStdout || '').split('\n').filter(Boolean).length > changedFiles.length,
374
+ }],
375
+ };
376
+ }
377
+ catch {
378
+ return { repositories: [] };
379
+ }
380
+ }
16
381
  function normalizeBackendServerUrl(value, fallbackUrl) {
17
382
  if (typeof value !== 'string' || !value.trim()) {
18
383
  return fallbackUrl;
@@ -343,6 +708,400 @@ export class WebServer {
343
708
  debugSessions,
344
709
  });
345
710
  });
711
+ // ===== Local Code Workspace (read-only) =====
712
+ const guardCodeWorkspace = (req, res) => {
713
+ if (isAllowedCodeWorkspaceOrigin(req))
714
+ return true;
715
+ res.status(403).json({ success: false, message: '当前来源不允许访问本地代码工作区' });
716
+ return false;
717
+ };
718
+ api.post('/code-workspace/status', async (req, res) => {
719
+ if (!guardCodeWorkspace(req, res))
720
+ return;
721
+ try {
722
+ const root = await resolveWorkspaceRoot(req.body?.path);
723
+ res.json({
724
+ success: true,
725
+ data: {
726
+ path: root,
727
+ exists: true,
728
+ isDirectory: true,
729
+ gitStatus: await getGitStatus(root),
730
+ },
731
+ });
732
+ }
733
+ catch (error) {
734
+ res.status(400).json({ success: false, message: error.message });
735
+ }
736
+ });
737
+ api.post('/code-workspace/tree', async (req, res) => {
738
+ if (!guardCodeWorkspace(req, res))
739
+ return;
740
+ try {
741
+ const root = await resolveWorkspaceRoot(req.body?.path);
742
+ const dirPath = req.body?.dirPath
743
+ ? await resolveWorkspaceTarget(root, req.body?.dirPath)
744
+ : root;
745
+ const stat = await fs.promises.stat(dirPath);
746
+ if (!stat.isDirectory()) {
747
+ res.status(400).json({ success: false, message: '目标路径不是文件夹' });
748
+ return;
749
+ }
750
+ const depth = Math.max(0, Math.min(Number(req.body?.depth ?? 1), 3));
751
+ const children = await listWorkspaceDirectory(root, dirPath, depth);
752
+ res.json({ success: true, data: { root, dirPath, tree: children } });
753
+ }
754
+ catch (error) {
755
+ res.status(400).json({ success: false, message: error.message });
756
+ }
757
+ });
758
+ api.post('/code-workspace/children', async (req, res) => {
759
+ if (!guardCodeWorkspace(req, res))
760
+ return;
761
+ try {
762
+ const root = await resolveWorkspaceRoot(req.body?.path);
763
+ const dirPath = await resolveWorkspaceTarget(root, req.body?.dirPath);
764
+ const stat = await fs.promises.stat(dirPath);
765
+ if (!stat.isDirectory()) {
766
+ res.status(400).json({ success: false, message: '目标路径不是文件夹' });
767
+ return;
768
+ }
769
+ const children = await listWorkspaceDirectory(root, dirPath, 0);
770
+ res.json({ success: true, data: { root, dirPath, children } });
771
+ }
772
+ catch (error) {
773
+ res.status(400).json({ success: false, message: error.message });
774
+ }
775
+ });
776
+ api.post('/code-workspace/file', async (req, res) => {
777
+ if (!guardCodeWorkspace(req, res))
778
+ return;
779
+ try {
780
+ const root = await resolveWorkspaceRoot(req.body?.path);
781
+ const filePath = await resolveWorkspaceTarget(root, req.body?.filePath);
782
+ const stat = await fs.promises.stat(filePath);
783
+ if (!stat.isFile()) {
784
+ res.status(400).json({ success: false, message: '目标路径不是文件' });
785
+ return;
786
+ }
787
+ assertSafeWorkspaceFile(filePath, '预览');
788
+ const buffer = await fs.promises.readFile(filePath);
789
+ const sliced = buffer.length > MAX_CODE_WORKSPACE_FILE_BYTES
790
+ ? buffer.subarray(0, MAX_CODE_WORKSPACE_FILE_BYTES)
791
+ : buffer;
792
+ const text = sliced.toString('utf8');
793
+ if (text.includes('\u0000')) {
794
+ res.status(400).json({ success: false, message: '二进制文件不支持预览' });
795
+ return;
796
+ }
797
+ const maxChars = Math.max(1, Math.min(Number(req.body?.maxChars ?? MAX_CODE_WORKSPACE_FILE_CHARS), MAX_CODE_WORKSPACE_FILE_CHARS));
798
+ const content = text.length > maxChars
799
+ ? text.slice(0, maxChars)
800
+ : text;
801
+ res.json({
802
+ success: true,
803
+ data: {
804
+ root,
805
+ filePath,
806
+ content,
807
+ size: buffer.length,
808
+ truncated: buffer.length > sliced.length || text.length > content.length,
809
+ },
810
+ });
811
+ }
812
+ catch (error) {
813
+ res.status(400).json({ success: false, message: error.message });
814
+ }
815
+ });
816
+ api.post('/code-workspace/file/write', async (req, res) => {
817
+ if (!guardCodeWorkspace(req, res))
818
+ return;
819
+ try {
820
+ if (req.body?.approved !== true) {
821
+ res.status(403).json({ success: false, message: '写入本地文件前必须由用户确认' });
822
+ return;
823
+ }
824
+ const root = await resolveWorkspaceRoot(req.body?.path);
825
+ const filePath = await resolveWorkspaceWritableTarget(root, req.body?.filePath);
826
+ assertSafeWorkspaceFile(filePath, '写入');
827
+ const exists = await fs.promises.stat(filePath).then((stat) => stat.isFile()).catch(() => false);
828
+ const parentInvalid = await fs.promises.stat(path.dirname(filePath))
829
+ .then((stat) => !stat.isDirectory())
830
+ .catch(() => true);
831
+ if (!exists && parentInvalid) {
832
+ res.status(400).json({ success: false, message: '目标文件父目录不存在' });
833
+ return;
834
+ }
835
+ const content = typeof req.body?.content === 'string' ? req.body.content : '';
836
+ await fs.promises.writeFile(filePath, content, 'utf8');
837
+ res.json({
838
+ success: true,
839
+ data: {
840
+ root,
841
+ filePath,
842
+ created: !exists,
843
+ size: Buffer.byteLength(content, 'utf8'),
844
+ updatedAt: new Date().toISOString(),
845
+ },
846
+ });
847
+ }
848
+ catch (error) {
849
+ res.status(400).json({ success: false, message: error.message });
850
+ }
851
+ });
852
+ api.post('/code-workspace/git-status', async (req, res) => {
853
+ if (!guardCodeWorkspace(req, res))
854
+ return;
855
+ try {
856
+ const root = await resolveWorkspaceRoot(req.body?.path);
857
+ res.json({ success: true, data: await getGitStatus(root) });
858
+ }
859
+ catch (error) {
860
+ res.status(400).json({ success: false, message: error.message });
861
+ }
862
+ });
863
+ api.post('/code-workspace/git-clone', async (req, res) => {
864
+ if (!guardCodeWorkspace(req, res))
865
+ return;
866
+ try {
867
+ if (req.body?.approved !== true) {
868
+ res.status(403).json({ success: false, message: '克隆代码库前必须由用户确认' });
869
+ return;
870
+ }
871
+ const root = await resolveWorkspaceRoot(req.body?.path);
872
+ const repoUrl = typeof req.body?.repoUrl === 'string'
873
+ ? req.body.repoUrl.trim()
874
+ : typeof req.body?.repo_url === 'string'
875
+ ? req.body.repo_url.trim()
876
+ : '';
877
+ assertSafeGitRepoUrl(repoUrl);
878
+ const branch = typeof req.body?.branch === 'string' ? req.body.branch.trim() : '';
879
+ assertSafeGitRefName(branch, '分支名称');
880
+ const depthValue = Number(req.body?.depth);
881
+ const depth = Number.isInteger(depthValue) && depthValue > 0
882
+ ? Math.min(depthValue, 1000)
883
+ : undefined;
884
+ const targetPath = await resolveWorkspaceCloneTarget(root, req.body?.targetDir ?? req.body?.target_dir, repoUrl);
885
+ const args = ['clone'];
886
+ if (branch)
887
+ args.push('--branch', branch);
888
+ if (depth)
889
+ args.push('--depth', String(depth));
890
+ args.push(repoUrl, targetPath);
891
+ const { stdout, stderr } = await execFileAsync('git', args, {
892
+ cwd: root,
893
+ timeout: 10 * 60 * 1000,
894
+ maxBuffer: 8 * 1024 * 1024,
895
+ });
896
+ const output = truncateWorkspaceText(`${stdout || ''}${stderr ? `\n${stderr}` : ''}`);
897
+ res.json({
898
+ success: true,
899
+ data: {
900
+ root,
901
+ repoUrl,
902
+ targetPath,
903
+ relativePath: path.relative(root, targetPath),
904
+ branch: branch || null,
905
+ depth: depth || null,
906
+ stdout,
907
+ stderr,
908
+ output: output.text,
909
+ truncated: output.truncated,
910
+ gitStatus: await getGitStatus(targetPath),
911
+ },
912
+ });
913
+ }
914
+ catch (error) {
915
+ const stdout = error?.stdout ? String(error.stdout) : '';
916
+ const stderr = error?.stderr ? String(error.stderr) : '';
917
+ const output = truncateWorkspaceText(`${stdout}${stderr ? `\n${stderr}` : ''}`);
918
+ res.status(400).json({
919
+ success: false,
920
+ message: error.message,
921
+ data: {
922
+ exitCode: error?.code,
923
+ output: output.text,
924
+ truncated: output.truncated,
925
+ },
926
+ });
927
+ }
928
+ });
929
+ api.post('/code-workspace/search', async (req, res) => {
930
+ if (!guardCodeWorkspace(req, res))
931
+ return;
932
+ try {
933
+ const root = await resolveWorkspaceRoot(req.body?.path);
934
+ const searchRoot = req.body?.searchPath
935
+ ? await resolveWorkspaceTarget(root, req.body?.searchPath)
936
+ : root;
937
+ const stat = await fs.promises.stat(searchRoot);
938
+ if (!stat.isDirectory()) {
939
+ res.status(400).json({ success: false, message: '搜索路径不是文件夹' });
940
+ return;
941
+ }
942
+ const maxResults = Math.max(1, Math.min(Number(req.body?.maxResults ?? 50), MAX_CODE_WORKSPACE_SEARCH_RESULTS));
943
+ const query = typeof req.body?.query === 'string' ? req.body.query : '';
944
+ res.json({ success: true, data: await searchWorkspace(root, searchRoot, query, maxResults) });
945
+ }
946
+ catch (error) {
947
+ res.status(400).json({ success: false, message: error.message });
948
+ }
949
+ });
950
+ api.post('/code-workspace/file/edit', async (req, res) => {
951
+ if (!guardCodeWorkspace(req, res))
952
+ return;
953
+ try {
954
+ if (req.body?.approved !== true) {
955
+ res.status(403).json({ success: false, message: '编辑本地文件前必须由用户确认' });
956
+ return;
957
+ }
958
+ const root = await resolveWorkspaceRoot(req.body?.path);
959
+ const filePath = await resolveWorkspaceTarget(root, req.body?.filePath);
960
+ const stat = await fs.promises.stat(filePath);
961
+ if (!stat.isFile()) {
962
+ res.status(400).json({ success: false, message: '目标路径不是文件' });
963
+ return;
964
+ }
965
+ assertSafeWorkspaceFile(filePath, '编辑');
966
+ const oldText = typeof req.body?.oldText === 'string' ? req.body.oldText : '';
967
+ const newText = typeof req.body?.newText === 'string' ? req.body.newText : '';
968
+ if (!oldText) {
969
+ res.status(400).json({ success: false, message: '缺少要替换的原文' });
970
+ return;
971
+ }
972
+ const original = await fs.promises.readFile(filePath, 'utf8');
973
+ const firstIndex = original.indexOf(oldText);
974
+ if (firstIndex < 0) {
975
+ res.status(400).json({ success: false, message: '未找到要替换的原文' });
976
+ return;
977
+ }
978
+ const secondIndex = original.indexOf(oldText, firstIndex + oldText.length);
979
+ if (secondIndex >= 0) {
980
+ res.status(400).json({ success: false, message: '原文匹配到多处,请提供更精确的片段' });
981
+ return;
982
+ }
983
+ const nextContent = original.slice(0, firstIndex) + newText + original.slice(firstIndex + oldText.length);
984
+ await fs.promises.writeFile(filePath, nextContent, 'utf8');
985
+ res.json({
986
+ success: true,
987
+ data: {
988
+ root,
989
+ filePath,
990
+ replaced: true,
991
+ updatedAt: new Date().toISOString(),
992
+ },
993
+ });
994
+ }
995
+ catch (error) {
996
+ res.status(400).json({ success: false, message: error.message });
997
+ }
998
+ });
999
+ api.post('/code-workspace/git-diff', async (req, res) => {
1000
+ if (!guardCodeWorkspace(req, res))
1001
+ return;
1002
+ try {
1003
+ const root = await resolveWorkspaceRoot(req.body?.path);
1004
+ const args = ['diff'];
1005
+ if (req.body?.staged === true)
1006
+ args.push('--cached');
1007
+ const filePath = req.body?.filePath
1008
+ ? await resolveWorkspaceTarget(root, req.body?.filePath)
1009
+ : '';
1010
+ if (filePath)
1011
+ args.push('--', filePath);
1012
+ const { stdout } = await execFileAsync('git', args, { cwd: root, timeout: 15000, maxBuffer: 4 * 1024 * 1024 });
1013
+ const output = truncateWorkspaceText(String(stdout || ''));
1014
+ res.json({ success: true, data: { root, filePath: filePath || null, diff: output.text, truncated: output.truncated } });
1015
+ }
1016
+ catch (error) {
1017
+ res.status(400).json({ success: false, message: error.message });
1018
+ }
1019
+ });
1020
+ api.post('/code-workspace/git-branch', async (req, res) => {
1021
+ if (!guardCodeWorkspace(req, res))
1022
+ return;
1023
+ try {
1024
+ const root = await resolveWorkspaceRoot(req.body?.path);
1025
+ const operation = req.body?.operation || 'list';
1026
+ if (operation === 'list') {
1027
+ const { stdout } = await execFileAsync('git', ['branch', '--list'], { cwd: root, timeout: 5000, maxBuffer: 1024 * 1024 });
1028
+ res.json({
1029
+ success: true,
1030
+ data: {
1031
+ root,
1032
+ branches: String(stdout || '').split('\n').map((line) => line.trim()).filter(Boolean),
1033
+ },
1034
+ });
1035
+ return;
1036
+ }
1037
+ if (req.body?.approved !== true) {
1038
+ res.status(403).json({ success: false, message: 'Git 分支变更前必须由用户确认' });
1039
+ return;
1040
+ }
1041
+ const branchName = typeof req.body?.name === 'string' ? req.body.name.trim() : '';
1042
+ if (!/^[A-Za-z0-9._/-]+$/.test(branchName) || branchName.includes('..') || branchName.startsWith('-')) {
1043
+ res.status(400).json({ success: false, message: '分支名称不合法' });
1044
+ return;
1045
+ }
1046
+ const args = operation === 'create'
1047
+ ? ['checkout', '-b', branchName]
1048
+ : operation === 'checkout'
1049
+ ? ['checkout', branchName]
1050
+ : null;
1051
+ if (!args) {
1052
+ res.status(400).json({ success: false, message: '不支持的 Git 分支操作' });
1053
+ return;
1054
+ }
1055
+ const { stdout, stderr } = await execFileAsync('git', args, { cwd: root, timeout: 15000, maxBuffer: 1024 * 1024 });
1056
+ res.json({ success: true, data: { root, operation, branchName, stdout, stderr } });
1057
+ }
1058
+ catch (error) {
1059
+ res.status(400).json({ success: false, message: error.message });
1060
+ }
1061
+ });
1062
+ api.post('/code-workspace/shell', async (req, res) => {
1063
+ if (!guardCodeWorkspace(req, res))
1064
+ return;
1065
+ try {
1066
+ if (req.body?.approved !== true) {
1067
+ res.status(403).json({ success: false, message: '执行本地命令前必须由用户确认' });
1068
+ return;
1069
+ }
1070
+ const root = await resolveWorkspaceRoot(req.body?.path);
1071
+ const command = typeof req.body?.command === 'string' ? req.body.command : '';
1072
+ assertSafeWorkspaceCommand(command);
1073
+ const timeout = Math.max(1000, Math.min(Number(req.body?.timeoutMs ?? 120000), 120000));
1074
+ const { stdout, stderr } = await execFileAsync('/bin/sh', ['-lc', command], {
1075
+ cwd: root,
1076
+ timeout,
1077
+ maxBuffer: 4 * 1024 * 1024,
1078
+ });
1079
+ const output = truncateWorkspaceText(`${stdout || ''}${stderr ? `\n${stderr}` : ''}`);
1080
+ res.json({
1081
+ success: true,
1082
+ data: {
1083
+ root,
1084
+ command,
1085
+ output: output.text,
1086
+ truncated: output.truncated,
1087
+ },
1088
+ });
1089
+ }
1090
+ catch (error) {
1091
+ const stdout = error?.stdout ? String(error.stdout) : '';
1092
+ const stderr = error?.stderr ? String(error.stderr) : '';
1093
+ const output = truncateWorkspaceText(`${stdout}${stderr ? `\n${stderr}` : ''}`);
1094
+ res.status(400).json({
1095
+ success: false,
1096
+ message: error.message,
1097
+ data: {
1098
+ exitCode: error?.code,
1099
+ output: output.text,
1100
+ truncated: output.truncated,
1101
+ },
1102
+ });
1103
+ }
1104
+ });
346
1105
  // ===== Devices =====
347
1106
  api.get('/devices', (_, res) => {
348
1107
  res.json(deviceRepo.getAll());
@@ -597,6 +1356,136 @@ export class WebServer {
597
1356
  res.json({ success: false, message: error.message });
598
1357
  }
599
1358
  });
1359
+ // ===== Knowledge Capture API =====
1360
+ api.post('/capture/start', async (req, res) => {
1361
+ try {
1362
+ const result = await captureManager.startCapture(req.body);
1363
+ res.json({ success: true, data: result });
1364
+ }
1365
+ catch (error) {
1366
+ res.status(500).json({ success: false, message: error.message });
1367
+ }
1368
+ });
1369
+ api.post('/capture/stop', async (req, res) => {
1370
+ try {
1371
+ const { sessionId } = req.body;
1372
+ if (!sessionId) {
1373
+ res.status(400).json({ success: false, message: 'Missing sessionId' });
1374
+ return;
1375
+ }
1376
+ const result = await captureManager.stopCapture(sessionId);
1377
+ res.json({ success: true, data: result });
1378
+ }
1379
+ catch (error) {
1380
+ res.status(500).json({ success: false, message: error.message });
1381
+ }
1382
+ });
1383
+ api.get('/capture/status/:sessionId', (req, res) => {
1384
+ try {
1385
+ const result = captureManager.getStatus(req.params.sessionId);
1386
+ res.json({ success: true, data: result });
1387
+ }
1388
+ catch (error) {
1389
+ res.status(404).json({ success: false, message: error.message });
1390
+ }
1391
+ });
1392
+ api.post('/capture/snapshot', async (req, res) => {
1393
+ try {
1394
+ const { sessionId } = req.body;
1395
+ if (!sessionId) {
1396
+ res.status(400).json({ success: false, message: 'Missing sessionId' });
1397
+ return;
1398
+ }
1399
+ const result = await captureManager.takeSnapshot(sessionId);
1400
+ res.json({ success: true, data: { id: result.id, title: result.title, url: result.url, contentLength: result.content.length, imageCount: result.images.length } });
1401
+ }
1402
+ catch (error) {
1403
+ res.status(500).json({ success: false, message: error.message });
1404
+ }
1405
+ });
1406
+ api.post('/capture/navigate', async (req, res) => {
1407
+ try {
1408
+ const { sessionId, url } = req.body;
1409
+ if (!sessionId || !url) {
1410
+ res.status(400).json({ success: false, message: 'Missing sessionId or url' });
1411
+ return;
1412
+ }
1413
+ const result = await captureManager.navigateAndSnapshot(sessionId, url);
1414
+ res.json({ success: true, data: { id: result.id, title: result.title, url: result.url, contentLength: result.content.length } });
1415
+ }
1416
+ catch (error) {
1417
+ res.status(500).json({ success: false, message: error.message });
1418
+ }
1419
+ });
1420
+ api.get('/capture/list', (_, res) => {
1421
+ const result = captureManager.listSessions();
1422
+ res.json({ success: true, data: result });
1423
+ });
1424
+ api.delete('/capture/:sessionId', (req, res) => {
1425
+ captureManager.deleteSession(req.params.sessionId);
1426
+ res.json({ success: true });
1427
+ });
1428
+ // ===== 截图预览 + 操作 API =====
1429
+ api.get('/capture/screenshot/:sessionId', async (req, res) => {
1430
+ try {
1431
+ const screenshot = await captureManager.getScreenshot(req.params.sessionId);
1432
+ res.json({ success: true, data: { screenshot } });
1433
+ }
1434
+ catch (error) {
1435
+ res.status(404).json({ success: false, message: error.message });
1436
+ }
1437
+ });
1438
+ api.get('/capture/screenshot-cache/:sessionId', (req, res) => {
1439
+ try {
1440
+ const screenshot = captureManager.getCachedScreenshot(req.params.sessionId);
1441
+ res.json({ success: true, data: { screenshot } });
1442
+ }
1443
+ catch (error) {
1444
+ res.status(404).json({ success: false, message: error.message });
1445
+ }
1446
+ });
1447
+ api.post('/capture/click', async (req, res) => {
1448
+ try {
1449
+ const { sessionId, x, y } = req.body;
1450
+ if (!sessionId || x === undefined || y === undefined) {
1451
+ res.status(400).json({ success: false, message: 'Missing sessionId, x or y' });
1452
+ return;
1453
+ }
1454
+ const result = await captureManager.clickAt(sessionId, Number(x), Number(y));
1455
+ res.json({ success: true, data: { snapshot: { id: result.snapshot.id, title: result.snapshot.title, url: result.snapshot.url, contentLength: result.snapshot.content.length }, screenshot: result.screenshot } });
1456
+ }
1457
+ catch (error) {
1458
+ res.status(500).json({ success: false, message: error.message });
1459
+ }
1460
+ });
1461
+ api.post('/capture/scroll', async (req, res) => {
1462
+ try {
1463
+ const { sessionId, direction, pixels } = req.body;
1464
+ if (!sessionId || !direction) {
1465
+ res.status(400).json({ success: false, message: 'Missing sessionId or direction' });
1466
+ return;
1467
+ }
1468
+ const result = await captureManager.scrollPage(sessionId, direction, Number(pixels) || 400);
1469
+ res.json({ success: true, data: { snapshot: { id: result.snapshot.id, title: result.snapshot.title, url: result.snapshot.url, contentLength: result.snapshot.content.length }, screenshot: result.screenshot } });
1470
+ }
1471
+ catch (error) {
1472
+ res.status(500).json({ success: false, message: error.message });
1473
+ }
1474
+ });
1475
+ api.post('/capture/type', async (req, res) => {
1476
+ try {
1477
+ const { sessionId, selector, text } = req.body;
1478
+ if (!sessionId || !selector || text === undefined) {
1479
+ res.status(400).json({ success: false, message: 'Missing sessionId, selector or text' });
1480
+ return;
1481
+ }
1482
+ const result = await captureManager.typeText(sessionId, selector, text);
1483
+ res.json({ success: true, data: { snapshot: { id: result.snapshot.id, title: result.snapshot.title, url: result.snapshot.url, contentLength: result.snapshot.content.length }, screenshot: result.screenshot } });
1484
+ }
1485
+ catch (error) {
1486
+ res.status(500).json({ success: false, message: error.message });
1487
+ }
1488
+ });
600
1489
  this.app.use('/api', api);
601
1490
  }
602
1491
  setupDetailRoutes() {