@lobehub/chat 1.141.8 → 1.141.10
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/CHANGELOG.md +50 -0
- package/apps/desktop/package.json +1 -0
- package/apps/desktop/src/main/controllers/LocalFileCtr.ts +279 -52
- package/apps/desktop/src/main/controllers/__tests__/LocalFileCtr.test.ts +392 -0
- package/changelog/v1.json +18 -0
- package/package.json +1 -1
- package/packages/agent-runtime/src/core/InterventionChecker.ts +173 -0
- package/packages/agent-runtime/src/core/UsageCounter.ts +248 -0
- package/packages/agent-runtime/src/core/__tests__/InterventionChecker.test.ts +334 -0
- package/packages/agent-runtime/src/core/__tests__/UsageCounter.test.ts +873 -0
- package/packages/agent-runtime/src/core/__tests__/runtime.test.ts +32 -26
- package/packages/agent-runtime/src/core/index.ts +2 -0
- package/packages/agent-runtime/src/core/runtime.ts +31 -18
- package/packages/agent-runtime/src/types/instruction.ts +1 -1
- package/packages/agent-runtime/src/types/state.ts +3 -3
- package/packages/agent-runtime/src/types/usage.ts +34 -25
- package/packages/context-engine/src/index.ts +1 -0
- package/packages/context-engine/src/tools/ToolNameResolver.ts +2 -2
- package/packages/context-engine/src/tools/ToolsEngine.ts +37 -8
- package/packages/context-engine/src/tools/__tests__/ToolsEngine.test.ts +149 -5
- package/packages/context-engine/src/tools/__tests__/utils.test.ts +2 -2
- package/packages/context-engine/src/tools/index.ts +1 -0
- package/packages/context-engine/src/tools/types.ts +18 -3
- package/packages/context-engine/src/tools/utils.ts +4 -4
- package/packages/types/src/tool/builtin.ts +54 -1
- package/packages/types/src/tool/index.ts +1 -0
- package/packages/types/src/tool/intervention.ts +114 -0
- package/packages/types/src/user/settings/tool.ts +37 -0
- package/src/app/[variants]/(main)/discover/(list)/(home)/Client.tsx +2 -2
- package/src/app/[variants]/(main)/discover/(list)/(home)/HomePage.tsx +2 -2
- package/src/app/[variants]/(main)/discover/DiscoverRouter.tsx +2 -1
- package/src/features/Conversation/Messages/Assistant/Tool/Render/index.tsx +4 -2
- package/src/store/chat/slices/builtinTool/actions/{dalle.test.ts → __tests__/dalle.test.ts} +2 -5
- package/src/store/chat/slices/builtinTool/actions/__tests__/{localFile.test.ts → localSystem.test.ts} +4 -4
- package/src/store/chat/slices/builtinTool/actions/index.ts +2 -2
- package/src/store/chat/slices/builtinTool/actions/{localFile.ts → localSystem.ts} +183 -69
- package/src/store/electron/selectors/__tests__/desktopState.test.ts +3 -3
- package/src/store/electron/selectors/desktopState.ts +11 -2
- package/src/tools/local-system/Placeholder/ListFiles.tsx +10 -8
- package/src/tools/local-system/Placeholder/SearchFiles.tsx +12 -10
- package/src/tools/local-system/Placeholder/index.tsx +1 -1
- package/src/tools/local-system/Render/ReadLocalFile/ReadFileSkeleton.tsx +8 -18
- package/src/tools/local-system/Render/ReadLocalFile/ReadFileView.tsx +21 -6
- package/src/tools/local-system/Render/SearchFiles/Result.tsx +5 -4
- package/src/tools/local-system/Render/SearchFiles/SearchQuery/SearchView.tsx +4 -15
- package/src/tools/local-system/Render/SearchFiles/index.tsx +3 -2
- package/src/tools/local-system/type.ts +39 -0
- package/src/tools/local-system/Placeholder/ReadLocalFile.tsx +0 -9
- package/src/tools/local-system/Render/ReadLocalFile/style.ts +0 -37
- /package/src/store/chat/slices/builtinTool/actions/{search.test.ts → __tests__/search.test.ts} +0 -0
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import type { App } from '@/core/App';
|
|
4
|
+
|
|
5
|
+
import LocalFileCtr from '../LocalFileCtr';
|
|
6
|
+
|
|
7
|
+
// Mock logger
|
|
8
|
+
vi.mock('@/utils/logger', () => ({
|
|
9
|
+
createLogger: () => ({
|
|
10
|
+
debug: vi.fn(),
|
|
11
|
+
info: vi.fn(),
|
|
12
|
+
warn: vi.fn(),
|
|
13
|
+
error: vi.fn(),
|
|
14
|
+
}),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
// Mock file-loaders
|
|
18
|
+
vi.mock('@lobechat/file-loaders', () => ({
|
|
19
|
+
SYSTEM_FILES_TO_IGNORE: ['.DS_Store', 'Thumbs.db'],
|
|
20
|
+
loadFile: vi.fn(),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
// Mock electron
|
|
24
|
+
vi.mock('electron', () => ({
|
|
25
|
+
shell: {
|
|
26
|
+
openPath: vi.fn(),
|
|
27
|
+
},
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
// Mock fast-glob
|
|
31
|
+
vi.mock('fast-glob', () => ({
|
|
32
|
+
default: vi.fn(),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
// Mock node:fs/promises and node:fs
|
|
36
|
+
vi.mock('node:fs/promises', () => ({
|
|
37
|
+
stat: vi.fn(),
|
|
38
|
+
readdir: vi.fn(),
|
|
39
|
+
rename: vi.fn(),
|
|
40
|
+
access: vi.fn(),
|
|
41
|
+
writeFile: vi.fn(),
|
|
42
|
+
readFile: vi.fn(),
|
|
43
|
+
mkdir: vi.fn(),
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
vi.mock('node:fs', () => ({
|
|
47
|
+
Stats: class Stats {},
|
|
48
|
+
constants: {
|
|
49
|
+
F_OK: 0,
|
|
50
|
+
},
|
|
51
|
+
stat: vi.fn(),
|
|
52
|
+
readdir: vi.fn(),
|
|
53
|
+
rename: vi.fn(),
|
|
54
|
+
access: vi.fn(),
|
|
55
|
+
writeFile: vi.fn(),
|
|
56
|
+
readFile: vi.fn(),
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
// Mock FileSearchService
|
|
60
|
+
const mockSearchService = {
|
|
61
|
+
search: vi.fn(),
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// Mock makeSureDirExist
|
|
65
|
+
vi.mock('@/utils/file-system', () => ({
|
|
66
|
+
makeSureDirExist: vi.fn(),
|
|
67
|
+
}));
|
|
68
|
+
|
|
69
|
+
const mockApp = {
|
|
70
|
+
getService: vi.fn(() => mockSearchService),
|
|
71
|
+
} as unknown as App;
|
|
72
|
+
|
|
73
|
+
describe('LocalFileCtr', () => {
|
|
74
|
+
let localFileCtr: LocalFileCtr;
|
|
75
|
+
let mockShell: any;
|
|
76
|
+
let mockFg: any;
|
|
77
|
+
let mockLoadFile: any;
|
|
78
|
+
let mockFsPromises: any;
|
|
79
|
+
|
|
80
|
+
beforeEach(async () => {
|
|
81
|
+
vi.clearAllMocks();
|
|
82
|
+
|
|
83
|
+
// Import mocks
|
|
84
|
+
mockShell = (await import('electron')).shell;
|
|
85
|
+
mockFg = (await import('fast-glob')).default;
|
|
86
|
+
mockLoadFile = (await import('@lobechat/file-loaders')).loadFile;
|
|
87
|
+
mockFsPromises = await import('node:fs/promises');
|
|
88
|
+
|
|
89
|
+
localFileCtr = new LocalFileCtr(mockApp);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('handleOpenLocalFile', () => {
|
|
93
|
+
it('should open file successfully', async () => {
|
|
94
|
+
vi.mocked(mockShell.openPath).mockResolvedValue('');
|
|
95
|
+
|
|
96
|
+
const result = await localFileCtr.handleOpenLocalFile({ path: '/test/file.txt' });
|
|
97
|
+
|
|
98
|
+
expect(result).toEqual({ success: true });
|
|
99
|
+
expect(mockShell.openPath).toHaveBeenCalledWith('/test/file.txt');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should return error when opening file fails', async () => {
|
|
103
|
+
const error = new Error('Failed to open');
|
|
104
|
+
vi.mocked(mockShell.openPath).mockRejectedValue(error);
|
|
105
|
+
|
|
106
|
+
const result = await localFileCtr.handleOpenLocalFile({ path: '/test/file.txt' });
|
|
107
|
+
|
|
108
|
+
expect(result).toEqual({ success: false, error: 'Failed to open' });
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('handleOpenLocalFolder', () => {
|
|
113
|
+
it('should open directory when isDirectory is true', async () => {
|
|
114
|
+
vi.mocked(mockShell.openPath).mockResolvedValue('');
|
|
115
|
+
|
|
116
|
+
const result = await localFileCtr.handleOpenLocalFolder({
|
|
117
|
+
path: '/test/folder',
|
|
118
|
+
isDirectory: true,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
expect(result).toEqual({ success: true });
|
|
122
|
+
expect(mockShell.openPath).toHaveBeenCalledWith('/test/folder');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('should open parent directory when isDirectory is false', async () => {
|
|
126
|
+
vi.mocked(mockShell.openPath).mockResolvedValue('');
|
|
127
|
+
|
|
128
|
+
const result = await localFileCtr.handleOpenLocalFolder({
|
|
129
|
+
path: '/test/folder/file.txt',
|
|
130
|
+
isDirectory: false,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
expect(result).toEqual({ success: true });
|
|
134
|
+
expect(mockShell.openPath).toHaveBeenCalledWith('/test/folder');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('should return error when opening folder fails', async () => {
|
|
138
|
+
const error = new Error('Failed to open folder');
|
|
139
|
+
vi.mocked(mockShell.openPath).mockRejectedValue(error);
|
|
140
|
+
|
|
141
|
+
const result = await localFileCtr.handleOpenLocalFolder({
|
|
142
|
+
path: '/test/folder',
|
|
143
|
+
isDirectory: true,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
expect(result).toEqual({ success: false, error: 'Failed to open folder' });
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe('readFile', () => {
|
|
151
|
+
it('should read file successfully with default location', async () => {
|
|
152
|
+
const mockFileContent = 'line1\nline2\nline3\nline4\nline5';
|
|
153
|
+
vi.mocked(mockLoadFile).mockResolvedValue({
|
|
154
|
+
content: mockFileContent,
|
|
155
|
+
filename: 'test.txt',
|
|
156
|
+
fileType: 'txt',
|
|
157
|
+
createdTime: new Date('2024-01-01'),
|
|
158
|
+
modifiedTime: new Date('2024-01-02'),
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const result = await localFileCtr.readFile({ path: '/test/file.txt' });
|
|
162
|
+
|
|
163
|
+
expect(result.filename).toBe('test.txt');
|
|
164
|
+
expect(result.fileType).toBe('txt');
|
|
165
|
+
expect(result.totalLineCount).toBe(5);
|
|
166
|
+
expect(result.content).toBe(mockFileContent);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('should read file with custom location range', async () => {
|
|
170
|
+
const mockFileContent = 'line1\nline2\nline3\nline4\nline5';
|
|
171
|
+
vi.mocked(mockLoadFile).mockResolvedValue({
|
|
172
|
+
content: mockFileContent,
|
|
173
|
+
filename: 'test.txt',
|
|
174
|
+
fileType: 'txt',
|
|
175
|
+
createdTime: new Date('2024-01-01'),
|
|
176
|
+
modifiedTime: new Date('2024-01-02'),
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const result = await localFileCtr.readFile({ path: '/test/file.txt', loc: [1, 3] });
|
|
180
|
+
|
|
181
|
+
expect(result.content).toBe('line2\nline3');
|
|
182
|
+
expect(result.lineCount).toBe(2);
|
|
183
|
+
expect(result.totalLineCount).toBe(5);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('should handle file read error', async () => {
|
|
187
|
+
vi.mocked(mockLoadFile).mockRejectedValue(new Error('File not found'));
|
|
188
|
+
|
|
189
|
+
const result = await localFileCtr.readFile({ path: '/test/missing.txt' });
|
|
190
|
+
|
|
191
|
+
expect(result.content).toContain('Error accessing or processing file');
|
|
192
|
+
expect(result.lineCount).toBe(0);
|
|
193
|
+
expect(result.charCount).toBe(0);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe('readFiles', () => {
|
|
198
|
+
it('should read multiple files successfully', async () => {
|
|
199
|
+
vi.mocked(mockLoadFile).mockResolvedValue({
|
|
200
|
+
content: 'file content',
|
|
201
|
+
filename: 'test.txt',
|
|
202
|
+
fileType: 'txt',
|
|
203
|
+
createdTime: new Date('2024-01-01'),
|
|
204
|
+
modifiedTime: new Date('2024-01-02'),
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const result = await localFileCtr.readFiles({
|
|
208
|
+
paths: ['/test/file1.txt', '/test/file2.txt'],
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
expect(result).toHaveLength(2);
|
|
212
|
+
expect(mockLoadFile).toHaveBeenCalledTimes(2);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe('handleWriteFile', () => {
|
|
217
|
+
it('should write file successfully', async () => {
|
|
218
|
+
vi.mocked(mockFsPromises.mkdir).mockResolvedValue(undefined);
|
|
219
|
+
vi.mocked(mockFsPromises.writeFile).mockResolvedValue(undefined);
|
|
220
|
+
|
|
221
|
+
const result = await localFileCtr.handleWriteFile({
|
|
222
|
+
path: '/test/file.txt',
|
|
223
|
+
content: 'test content',
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
expect(result).toEqual({ success: true });
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('should return error when path is empty', async () => {
|
|
230
|
+
const result = await localFileCtr.handleWriteFile({
|
|
231
|
+
path: '',
|
|
232
|
+
content: 'test content',
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
expect(result).toEqual({ success: false, error: 'Path cannot be empty' });
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('should return error when content is undefined', async () => {
|
|
239
|
+
const result = await localFileCtr.handleWriteFile({
|
|
240
|
+
path: '/test/file.txt',
|
|
241
|
+
content: undefined as any,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
expect(result).toEqual({ success: false, error: 'Content cannot be empty' });
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('should handle write error', async () => {
|
|
248
|
+
vi.mocked(mockFsPromises.mkdir).mockResolvedValue(undefined);
|
|
249
|
+
vi.mocked(mockFsPromises.writeFile).mockRejectedValue(new Error('Write failed'));
|
|
250
|
+
|
|
251
|
+
const result = await localFileCtr.handleWriteFile({
|
|
252
|
+
path: '/test/file.txt',
|
|
253
|
+
content: 'test content',
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
expect(result).toEqual({ success: false, error: 'Failed to write file: Write failed' });
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe('handleRenameFile', () => {
|
|
261
|
+
it('should rename file successfully', async () => {
|
|
262
|
+
vi.mocked(mockFsPromises.rename).mockResolvedValue(undefined);
|
|
263
|
+
|
|
264
|
+
const result = await localFileCtr.handleRenameFile({
|
|
265
|
+
path: '/test/old.txt',
|
|
266
|
+
newName: 'new.txt',
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
expect(result).toEqual({ success: true, newPath: '/test/new.txt' });
|
|
270
|
+
expect(mockFsPromises.rename).toHaveBeenCalledWith('/test/old.txt', '/test/new.txt');
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('should skip rename when paths are identical', async () => {
|
|
274
|
+
const result = await localFileCtr.handleRenameFile({
|
|
275
|
+
path: '/test/file.txt',
|
|
276
|
+
newName: 'file.txt',
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
expect(result).toEqual({ success: true, newPath: '/test/file.txt' });
|
|
280
|
+
expect(mockFsPromises.rename).not.toHaveBeenCalled();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('should reject invalid new name with path separators', async () => {
|
|
284
|
+
const result = await localFileCtr.handleRenameFile({
|
|
285
|
+
path: '/test/old.txt',
|
|
286
|
+
newName: '../new.txt',
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
expect(result.success).toBe(false);
|
|
290
|
+
expect(result.error).toContain('Invalid new name');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('should reject invalid new name with special characters', async () => {
|
|
294
|
+
const result = await localFileCtr.handleRenameFile({
|
|
295
|
+
path: '/test/old.txt',
|
|
296
|
+
newName: 'new:file.txt',
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
expect(result.success).toBe(false);
|
|
300
|
+
expect(result.error).toContain('Invalid new name');
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('should handle file not found error', async () => {
|
|
304
|
+
const error: any = new Error('File not found');
|
|
305
|
+
error.code = 'ENOENT';
|
|
306
|
+
vi.mocked(mockFsPromises.rename).mockRejectedValue(error);
|
|
307
|
+
|
|
308
|
+
const result = await localFileCtr.handleRenameFile({
|
|
309
|
+
path: '/test/old.txt',
|
|
310
|
+
newName: 'new.txt',
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
expect(result.success).toBe(false);
|
|
314
|
+
expect(result.error).toContain('File or directory not found');
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('should handle file already exists error', async () => {
|
|
318
|
+
const error: any = new Error('File exists');
|
|
319
|
+
error.code = 'EEXIST';
|
|
320
|
+
vi.mocked(mockFsPromises.rename).mockRejectedValue(error);
|
|
321
|
+
|
|
322
|
+
const result = await localFileCtr.handleRenameFile({
|
|
323
|
+
path: '/test/old.txt',
|
|
324
|
+
newName: 'new.txt',
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
expect(result.success).toBe(false);
|
|
328
|
+
expect(result.error).toContain('already exists');
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
describe('handleLocalFilesSearch', () => {
|
|
333
|
+
it('should search files successfully', async () => {
|
|
334
|
+
const mockResults = [
|
|
335
|
+
{
|
|
336
|
+
name: 'test.txt',
|
|
337
|
+
path: '/test/test.txt',
|
|
338
|
+
isDirectory: false,
|
|
339
|
+
size: 100,
|
|
340
|
+
type: 'txt',
|
|
341
|
+
},
|
|
342
|
+
];
|
|
343
|
+
mockSearchService.search.mockResolvedValue(mockResults);
|
|
344
|
+
|
|
345
|
+
const result = await localFileCtr.handleLocalFilesSearch({ keywords: 'test' });
|
|
346
|
+
|
|
347
|
+
expect(result).toEqual(mockResults);
|
|
348
|
+
expect(mockSearchService.search).toHaveBeenCalledWith('test', { limit: 30 });
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('should return empty array on search error', async () => {
|
|
352
|
+
mockSearchService.search.mockRejectedValue(new Error('Search failed'));
|
|
353
|
+
|
|
354
|
+
const result = await localFileCtr.handleLocalFilesSearch({ keywords: 'test' });
|
|
355
|
+
|
|
356
|
+
expect(result).toEqual([]);
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
describe('handleGlobFiles', () => {
|
|
361
|
+
it('should glob files successfully', async () => {
|
|
362
|
+
const mockFiles = [
|
|
363
|
+
{ path: '/test/file1.txt', stats: { mtime: new Date('2024-01-02') } },
|
|
364
|
+
{ path: '/test/file2.txt', stats: { mtime: new Date('2024-01-01') } },
|
|
365
|
+
];
|
|
366
|
+
vi.mocked(mockFg).mockResolvedValue(mockFiles);
|
|
367
|
+
|
|
368
|
+
const result = await localFileCtr.handleGlobFiles({
|
|
369
|
+
pattern: '*.txt',
|
|
370
|
+
path: '/test',
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
expect(result.success).toBe(true);
|
|
374
|
+
expect(result.files).toEqual(['/test/file1.txt', '/test/file2.txt']);
|
|
375
|
+
expect(result.total_files).toBe(2);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it('should handle glob error', async () => {
|
|
379
|
+
vi.mocked(mockFg).mockRejectedValue(new Error('Glob failed'));
|
|
380
|
+
|
|
381
|
+
const result = await localFileCtr.handleGlobFiles({
|
|
382
|
+
pattern: '*.txt',
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
expect(result).toEqual({
|
|
386
|
+
success: false,
|
|
387
|
+
files: [],
|
|
388
|
+
total_files: 0,
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
});
|
package/changelog/v1.json
CHANGED
|
@@ -1,4 +1,22 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"children": {
|
|
4
|
+
"fixes": [
|
|
5
|
+
"Loadmore not work & navbar not show in pwa."
|
|
6
|
+
]
|
|
7
|
+
},
|
|
8
|
+
"date": "2025-10-23",
|
|
9
|
+
"version": "1.141.10"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"children": {
|
|
13
|
+
"improvements": [
|
|
14
|
+
"Improve local system tools render."
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
"date": "2025-10-23",
|
|
18
|
+
"version": "1.141.9"
|
|
19
|
+
},
|
|
2
20
|
{
|
|
3
21
|
"children": {
|
|
4
22
|
"improvements": [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lobehub/chat",
|
|
3
|
-
"version": "1.141.
|
|
3
|
+
"version": "1.141.10",
|
|
4
4
|
"description": "Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"framework",
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ArgumentMatcher,
|
|
3
|
+
HumanInterventionPolicy,
|
|
4
|
+
HumanInterventionRule,
|
|
5
|
+
ShouldInterveneParams,
|
|
6
|
+
} from '@lobechat/types';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Intervention Checker
|
|
10
|
+
* Determines whether a tool call requires human intervention
|
|
11
|
+
*/
|
|
12
|
+
export class InterventionChecker {
|
|
13
|
+
/**
|
|
14
|
+
* Check if a tool call requires intervention
|
|
15
|
+
*
|
|
16
|
+
* @param params - Parameters object containing config, toolArgs, confirmedHistory, and toolKey
|
|
17
|
+
* @returns Policy to apply
|
|
18
|
+
*/
|
|
19
|
+
static shouldIntervene(params: ShouldInterveneParams): HumanInterventionPolicy {
|
|
20
|
+
const { config, toolArgs = {}, confirmedHistory = [], toolKey } = params;
|
|
21
|
+
|
|
22
|
+
// No config means never intervene (auto-execute)
|
|
23
|
+
if (!config) return 'never';
|
|
24
|
+
|
|
25
|
+
// Simple policy string
|
|
26
|
+
if (typeof config === 'string') {
|
|
27
|
+
// For 'first' policy, check if already confirmed
|
|
28
|
+
if (config === 'first' && toolKey && confirmedHistory.includes(toolKey)) {
|
|
29
|
+
return 'never';
|
|
30
|
+
}
|
|
31
|
+
return config;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Array of rules - find first matching rule
|
|
35
|
+
for (const rule of config) {
|
|
36
|
+
if (this.matchesRule(rule, toolArgs)) {
|
|
37
|
+
const policy = rule.policy;
|
|
38
|
+
|
|
39
|
+
// For 'first' policy, check if already confirmed
|
|
40
|
+
if (policy === 'first' && toolKey && confirmedHistory.includes(toolKey)) {
|
|
41
|
+
return 'never';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return policy;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// No rule matched - default to always for safety
|
|
49
|
+
return 'always';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Check if tool arguments match a rule
|
|
54
|
+
*
|
|
55
|
+
* @param rule - Rule to check
|
|
56
|
+
* @param toolArgs - Tool call arguments
|
|
57
|
+
* @returns true if matches
|
|
58
|
+
*/
|
|
59
|
+
private static matchesRule(rule: HumanInterventionRule, toolArgs: Record<string, any>): boolean {
|
|
60
|
+
// No match criteria means it's a default rule
|
|
61
|
+
if (!rule.match) return true;
|
|
62
|
+
|
|
63
|
+
// Check each parameter matcher
|
|
64
|
+
for (const [paramName, matcher] of Object.entries(rule.match)) {
|
|
65
|
+
const paramValue = toolArgs[paramName];
|
|
66
|
+
|
|
67
|
+
// Parameter not present in args
|
|
68
|
+
if (paramValue === undefined) return false;
|
|
69
|
+
|
|
70
|
+
// Check if value matches
|
|
71
|
+
if (!this.matchesArgument(matcher, paramValue)) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Check if a parameter value matches the matcher
|
|
81
|
+
*
|
|
82
|
+
* @param matcher - Argument matcher
|
|
83
|
+
* @param value - Parameter value
|
|
84
|
+
* @returns true if matches
|
|
85
|
+
*/
|
|
86
|
+
private static matchesArgument(matcher: ArgumentMatcher, value: any): boolean {
|
|
87
|
+
const strValue = String(value);
|
|
88
|
+
|
|
89
|
+
// Simple string matcher
|
|
90
|
+
if (typeof matcher === 'string') {
|
|
91
|
+
return this.matchPattern(matcher, strValue);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Complex matcher with type
|
|
95
|
+
const { pattern, type } = matcher;
|
|
96
|
+
|
|
97
|
+
switch (type) {
|
|
98
|
+
case 'exact': {
|
|
99
|
+
return strValue === pattern;
|
|
100
|
+
}
|
|
101
|
+
case 'prefix': {
|
|
102
|
+
return strValue.startsWith(pattern);
|
|
103
|
+
}
|
|
104
|
+
case 'wildcard': {
|
|
105
|
+
return this.matchPattern(pattern, strValue);
|
|
106
|
+
}
|
|
107
|
+
case 'regex': {
|
|
108
|
+
return new RegExp(pattern).test(strValue);
|
|
109
|
+
}
|
|
110
|
+
default: {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Match wildcard pattern (supports * wildcard)
|
|
118
|
+
*
|
|
119
|
+
* @param pattern - Pattern with wildcards
|
|
120
|
+
* @param value - Value to match
|
|
121
|
+
* @returns true if matches
|
|
122
|
+
*/
|
|
123
|
+
private static matchPattern(pattern: string, value: string): boolean {
|
|
124
|
+
// Check for colon-based prefix matching (e.g., "git add:*")
|
|
125
|
+
if (pattern.includes(':')) {
|
|
126
|
+
const [prefix, suffix] = pattern.split(':');
|
|
127
|
+
if (suffix === '*') {
|
|
128
|
+
return value.startsWith(prefix + ':') || value === prefix;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Convert wildcard pattern to regex
|
|
133
|
+
const regexPattern = pattern
|
|
134
|
+
.replaceAll(/[$()+.?[\\\]^{|}]/g, '\\$&') // Escape special chars
|
|
135
|
+
.replaceAll('*', '.*'); // Replace * with .*
|
|
136
|
+
|
|
137
|
+
return new RegExp(`^${regexPattern}$`).test(value);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Generate tool key from identifier and API name
|
|
142
|
+
*
|
|
143
|
+
* @param identifier - Tool identifier
|
|
144
|
+
* @param apiName - API name
|
|
145
|
+
* @param argsHash - Optional hash of arguments
|
|
146
|
+
* @returns Tool key in format "identifier/apiName" or "identifier/apiName#hash"
|
|
147
|
+
*/
|
|
148
|
+
static generateToolKey(identifier: string, apiName: string, argsHash?: string): string {
|
|
149
|
+
const baseKey = `${identifier}/${apiName}`;
|
|
150
|
+
return argsHash ? `${baseKey}#${argsHash}` : baseKey;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Generate simple hash of arguments for 'once' policy
|
|
155
|
+
*
|
|
156
|
+
* @param args - Tool call arguments
|
|
157
|
+
* @returns Hash string
|
|
158
|
+
*/
|
|
159
|
+
static hashArguments(args: Record<string, any>): string {
|
|
160
|
+
const sortedKeys = Object.keys(args).sort();
|
|
161
|
+
const str = sortedKeys.map((key) => `${key}=${JSON.stringify(args[key])}`).join('&');
|
|
162
|
+
|
|
163
|
+
// Simple hash function
|
|
164
|
+
let hash = 0;
|
|
165
|
+
for (let i = 0; i < str.length; i++) {
|
|
166
|
+
const char = str.charCodeAt(i);
|
|
167
|
+
hash = (hash << 5) - hash + char;
|
|
168
|
+
hash = hash & hash; // Convert to 32-bit integer
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return Math.abs(hash).toString(36);
|
|
172
|
+
}
|
|
173
|
+
}
|