@lobehub/chat 1.84.16 → 1.84.18

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 (29) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +2 -2
  3. package/README.zh-CN.md +2 -2
  4. package/apps/desktop/src/main/controllers/LocalFileCtr.ts +121 -20
  5. package/changelog/v1.json +18 -0
  6. package/package.json +2 -1
  7. package/packages/electron-client-ipc/src/events/localFile.ts +2 -0
  8. package/packages/electron-client-ipc/src/types/localFile.ts +16 -0
  9. package/src/features/Conversation/components/MarkdownElements/LocalFile/Render/index.tsx +2 -1
  10. package/src/features/{Conversation/components/MarkdownElements/LocalFile/Render → LocalFile}/LocalFile.tsx +8 -10
  11. package/src/features/LocalFile/LocalFolder.tsx +65 -0
  12. package/src/features/LocalFile/index.tsx +2 -0
  13. package/src/libs/agent-runtime/utils/openaiCompatibleFactory/index.ts +9 -3
  14. package/src/libs/agent-runtime/utils/streams/qwen.test.ts +8 -4
  15. package/src/libs/agent-runtime/utils/streams/qwen.ts +3 -1
  16. package/src/libs/agent-runtime/utils/streams/spark.test.ts +6 -2
  17. package/src/libs/agent-runtime/utils/streams/spark.ts +3 -1
  18. package/src/services/electron/localFileService.ts +5 -0
  19. package/src/store/chat/slices/builtinTool/actions/__tests__/localFile.test.ts +211 -0
  20. package/src/store/chat/slices/builtinTool/actions/localFile.ts +27 -3
  21. package/src/store/electron/selectors/desktopState.ts +7 -0
  22. package/src/store/electron/selectors/index.ts +1 -0
  23. package/src/tools/local-files/Render/ListFiles/index.tsx +2 -45
  24. package/src/tools/local-files/Render/RenameLocalFile/index.tsx +5 -6
  25. package/src/tools/local-files/Render/RunCommand/index.tsx +35 -0
  26. package/src/tools/local-files/Render/WriteFile/index.tsx +32 -0
  27. package/src/tools/local-files/Render/index.tsx +2 -0
  28. package/src/tools/local-files/index.ts +20 -21
  29. package/src/tools/local-files/systemRole.ts +7 -13
@@ -68,7 +68,7 @@ interface OpenAICompatibleFactoryOptions<T extends Record<string, any> = any> {
68
68
  ) => OpenAI.ChatCompletionCreateParamsStreaming;
69
69
  handleStream?: (
70
70
  stream: Stream<OpenAI.ChatCompletionChunk> | ReadableStream,
71
- callbacks?: ChatStreamCallbacks,
71
+ { callbacks, inputStartAt }: { callbacks?: ChatStreamCallbacks; inputStartAt?: number },
72
72
  ) => ReadableStream;
73
73
  handleStreamBizErrorType?: (error: {
74
74
  message: string;
@@ -256,7 +256,10 @@ export const LobeOpenAICompatibleFactory = <T extends Record<string, any> = any>
256
256
 
257
257
  return StreamingResponse(
258
258
  chatCompletion?.handleStream
259
- ? chatCompletion.handleStream(prod, streamOptions.callbacks)
259
+ ? chatCompletion.handleStream(prod, {
260
+ callbacks: streamOptions.callbacks,
261
+ inputStartAt,
262
+ })
260
263
  : OpenAIStream(prod, { ...streamOptions, inputStartAt }),
261
264
  {
262
265
  headers: options?.headers,
@@ -276,7 +279,10 @@ export const LobeOpenAICompatibleFactory = <T extends Record<string, any> = any>
276
279
 
277
280
  return StreamingResponse(
278
281
  chatCompletion?.handleStream
279
- ? chatCompletion.handleStream(stream, streamOptions.callbacks)
282
+ ? chatCompletion.handleStream(stream, {
283
+ callbacks: streamOptions.callbacks,
284
+ inputStartAt,
285
+ })
280
286
  : OpenAIStream(stream, { ...streamOptions, inputStartAt }),
281
287
  {
282
288
  headers: options?.headers,
@@ -46,9 +46,11 @@ describe('QwenAIStream', () => {
46
46
  const onCompletionMock = vi.fn();
47
47
 
48
48
  const protocolStream = QwenAIStream(mockOpenAIStream, {
49
- onStart: onStartMock,
50
- onText: onTextMock,
51
- onCompletion: onCompletionMock,
49
+ callbacks: {
50
+ onStart: onStartMock,
51
+ onText: onTextMock,
52
+ onCompletion: onCompletionMock,
53
+ },
52
54
  });
53
55
 
54
56
  const decoder = new TextDecoder();
@@ -111,7 +113,9 @@ describe('QwenAIStream', () => {
111
113
  const onToolCallMock = vi.fn();
112
114
 
113
115
  const protocolStream = QwenAIStream(mockOpenAIStream, {
114
- onToolsCalling: onToolCallMock,
116
+ callbacks: {
117
+ onToolsCalling: onToolCallMock,
118
+ },
115
119
  });
116
120
 
117
121
  const decoder = new TextDecoder();
@@ -92,7 +92,9 @@ export const transformQwenStream = (chunk: OpenAI.ChatCompletionChunk): StreamPr
92
92
 
93
93
  export const QwenAIStream = (
94
94
  stream: Stream<OpenAI.ChatCompletionChunk> | ReadableStream,
95
- callbacks?: ChatStreamCallbacks,
95
+ // TODO: preserve for RFC 097
96
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars, unused-imports/no-unused-vars
97
+ { callbacks, inputStartAt }: { callbacks?: ChatStreamCallbacks; inputStartAt?: number } = {},
96
98
  ) => {
97
99
  const readableStream =
98
100
  stream instanceof ReadableStream ? stream : convertIterableToStream(stream);
@@ -96,7 +96,9 @@ describe('SparkAIStream', () => {
96
96
  const onToolCallMock = vi.fn();
97
97
 
98
98
  const protocolStream = SparkAIStream(mockStream, {
99
- onToolsCalling: onToolCallMock,
99
+ callbacks: {
100
+ onToolsCalling: onToolCallMock,
101
+ },
100
102
  });
101
103
 
102
104
  const decoder = new TextDecoder();
@@ -156,7 +158,9 @@ describe('SparkAIStream', () => {
156
158
  const onTextMock = vi.fn();
157
159
 
158
160
  const protocolStream = SparkAIStream(mockStream, {
159
- onText: onTextMock,
161
+ callbacks: {
162
+ onText: onTextMock,
163
+ },
160
164
  });
161
165
 
162
166
  const decoder = new TextDecoder();
@@ -123,7 +123,9 @@ export const transformSparkStream = (chunk: OpenAI.ChatCompletionChunk): StreamP
123
123
 
124
124
  export const SparkAIStream = (
125
125
  stream: Stream<OpenAI.ChatCompletionChunk> | ReadableStream,
126
- callbacks?: ChatStreamCallbacks,
126
+ // TODO: preserve for RFC 097
127
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars, unused-imports/no-unused-vars
128
+ { callbacks, inputStartAt }: { callbacks?: ChatStreamCallbacks; inputStartAt?: number } = {},
127
129
  ) => {
128
130
  const readableStream =
129
131
  stream instanceof ReadableStream ? stream : convertIterableToStream(stream);
@@ -10,6 +10,7 @@ import {
10
10
  OpenLocalFileParams,
11
11
  OpenLocalFolderParams,
12
12
  RenameLocalFileParams,
13
+ WriteLocalFileParams,
13
14
  dispatch,
14
15
  } from '@lobechat/electron-client-ipc';
15
16
 
@@ -46,6 +47,10 @@ class LocalFileService {
46
47
  return dispatch('renameLocalFile', params);
47
48
  }
48
49
 
50
+ async writeFile(params: WriteLocalFileParams) {
51
+ return dispatch('writeLocalFile', params);
52
+ }
53
+
49
54
  async openLocalFileOrFolder(path: string, isDirectory: boolean) {
50
55
  if (isDirectory) {
51
56
  return this.openLocalFolder({ isDirectory, path });
@@ -0,0 +1,211 @@
1
+ import { LocalFileItem, LocalMoveFilesResultItem } from '@lobechat/electron-client-ipc';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+
4
+ import { localFileService } from '@/services/electron/localFileService';
5
+ import { ChatStore } from '@/store/chat/store';
6
+
7
+ import { localFileSlice } from '../localFile';
8
+
9
+ vi.mock('@/services/electron/localFileService', () => ({
10
+ localFileService: {
11
+ listLocalFiles: vi.fn(),
12
+ moveLocalFiles: vi.fn(),
13
+ readLocalFile: vi.fn(),
14
+ readLocalFiles: vi.fn(),
15
+ renameLocalFile: vi.fn(),
16
+ searchLocalFiles: vi.fn(),
17
+ writeFile: vi.fn(),
18
+ },
19
+ }));
20
+
21
+ const mockSet = vi.fn();
22
+
23
+ const mockStore = {
24
+ internal_triggerLocalFileToolCalling: vi.fn(),
25
+ internal_updateMessageContent: vi.fn(),
26
+ internal_updateMessagePluginError: vi.fn(),
27
+ set: mockSet,
28
+ toggleLocalFileLoading: vi.fn(),
29
+ updatePluginArguments: vi.fn(),
30
+ updatePluginState: vi.fn(),
31
+ } as unknown as ChatStore;
32
+
33
+ const createStore = () => {
34
+ return localFileSlice(
35
+ (set) => ({
36
+ ...mockStore,
37
+ set,
38
+ }),
39
+ () => mockStore,
40
+ {} as any,
41
+ );
42
+ };
43
+
44
+ describe('localFileSlice', () => {
45
+ const store = createStore();
46
+
47
+ beforeEach(() => {
48
+ vi.clearAllMocks();
49
+ });
50
+
51
+ describe('internal_triggerLocalFileToolCalling', () => {
52
+ it('should handle successful calling', async () => {
53
+ const mockContent = { foo: 'bar' };
54
+ const mockState = { state: 'test' };
55
+ const mockService = vi.fn().mockResolvedValue({ content: mockContent, state: mockState });
56
+
57
+ await store.internal_triggerLocalFileToolCalling('test-id', mockService);
58
+
59
+ expect(mockStore.toggleLocalFileLoading).toBeCalledWith('test-id', true);
60
+ expect(mockStore.updatePluginState).toBeCalledWith('test-id', mockState);
61
+ expect(mockStore.internal_updateMessageContent).toBeCalledWith(
62
+ 'test-id',
63
+ JSON.stringify(mockContent),
64
+ );
65
+ expect(mockStore.toggleLocalFileLoading).toBeCalledWith('test-id', false);
66
+ });
67
+
68
+ it('should handle error', async () => {
69
+ const mockError = new Error('test error');
70
+ const mockService = vi.fn().mockRejectedValue(mockError);
71
+
72
+ await store.internal_triggerLocalFileToolCalling('test-id', mockService);
73
+
74
+ expect(mockStore.internal_updateMessagePluginError).toBeCalledWith('test-id', {
75
+ body: mockError,
76
+ message: 'test error',
77
+ type: 'PluginServerError',
78
+ });
79
+ });
80
+ });
81
+
82
+ describe('listLocalFiles', () => {
83
+ it('should call listLocalFiles service and update state', async () => {
84
+ const mockResult: LocalFileItem[] = [
85
+ {
86
+ name: 'test.txt',
87
+ path: '/test.txt',
88
+ isDirectory: false,
89
+ createdTime: new Date(),
90
+ lastAccessTime: new Date(),
91
+ modifiedTime: new Date(),
92
+ size: 100,
93
+ type: 'file',
94
+ },
95
+ ];
96
+ vi.mocked(localFileService.listLocalFiles).mockResolvedValue(mockResult);
97
+
98
+ await store.listLocalFiles('test-id', { path: '/test' });
99
+
100
+ expect(mockStore.internal_triggerLocalFileToolCalling).toBeCalled();
101
+ });
102
+ });
103
+
104
+ describe('moveLocalFiles', () => {
105
+ it('should handle successful move', async () => {
106
+ const mockResults = [
107
+ {
108
+ sourcePath: '/test.txt',
109
+ destinationPath: '/target/test.txt',
110
+ success: true,
111
+ },
112
+ ] as unknown as LocalMoveFilesResultItem[];
113
+
114
+ vi.mocked(localFileService.moveLocalFiles).mockResolvedValue(mockResults);
115
+
116
+ await store.moveLocalFiles('test-id', {
117
+ sourcePaths: ['/test.txt'],
118
+ destinationDir: '/target',
119
+ } as any);
120
+
121
+ expect(mockStore.internal_triggerLocalFileToolCalling).toBeCalled();
122
+ });
123
+ });
124
+
125
+ describe('writeLocalFile', () => {
126
+ it('should handle successful write', async () => {
127
+ vi.mocked(localFileService.writeFile).mockResolvedValue({
128
+ success: true,
129
+ newPath: '/test.txt',
130
+ });
131
+
132
+ await store.writeLocalFile('test-id', { path: '/test.txt', content: 'test' });
133
+
134
+ expect(mockStore.internal_triggerLocalFileToolCalling).toBeCalled();
135
+ });
136
+
137
+ it('should handle write error', async () => {
138
+ vi.mocked(localFileService.writeFile).mockResolvedValue({
139
+ success: false,
140
+ error: 'Write failed',
141
+ newPath: '/test.txt',
142
+ });
143
+
144
+ await store.writeLocalFile('test-id', { path: '/test.txt', content: 'test' });
145
+
146
+ expect(mockStore.internal_triggerLocalFileToolCalling).toBeCalled();
147
+ });
148
+ });
149
+
150
+ describe('renameLocalFile', () => {
151
+ it('should handle successful rename', async () => {
152
+ vi.mocked(localFileService.renameLocalFile).mockResolvedValue({
153
+ success: true,
154
+ newPath: '/new.txt',
155
+ });
156
+
157
+ await store.renameLocalFile('test-id', { path: '/test.txt', newName: 'new.txt' });
158
+
159
+ expect(mockStore.internal_triggerLocalFileToolCalling).toBeCalled();
160
+ });
161
+
162
+ it('should handle rename error', async () => {
163
+ vi.mocked(localFileService.renameLocalFile).mockResolvedValue({
164
+ success: false,
165
+ error: 'Rename failed',
166
+ newPath: '/test.txt',
167
+ });
168
+
169
+ await store.renameLocalFile('test-id', { path: '/test.txt', newName: 'new.txt' });
170
+
171
+ expect(mockStore.internal_triggerLocalFileToolCalling).toBeCalled();
172
+ });
173
+
174
+ it('should validate new filename', async () => {
175
+ vi.mocked(localFileService.renameLocalFile).mockRejectedValue(
176
+ new Error('Invalid new name provided'),
177
+ );
178
+
179
+ await store.renameLocalFile('test-id', {
180
+ path: '/test.txt',
181
+ newName: '../invalid.txt',
182
+ });
183
+
184
+ expect(mockStore.internal_triggerLocalFileToolCalling).toBeCalledWith(
185
+ 'test-id',
186
+ expect.any(Function),
187
+ );
188
+ });
189
+ });
190
+
191
+ describe('toggleLocalFileLoading', () => {
192
+ it('should toggle loading state', () => {
193
+ const mockSetFn = vi.fn();
194
+ const testStore = localFileSlice(mockSetFn, () => mockStore, {} as any);
195
+
196
+ testStore.toggleLocalFileLoading('test-id', true);
197
+ expect(mockSetFn).toHaveBeenCalledWith(
198
+ expect.any(Function),
199
+ false,
200
+ 'toggleLocalFileLoading/start',
201
+ );
202
+
203
+ testStore.toggleLocalFileLoading('test-id', false);
204
+ expect(mockSetFn).toHaveBeenCalledWith(
205
+ expect.any(Function),
206
+ false,
207
+ 'toggleLocalFileLoading/end',
208
+ );
209
+ });
210
+ });
211
+ });
@@ -6,6 +6,7 @@ import {
6
6
  LocalSearchFilesParams,
7
7
  MoveLocalFilesParams,
8
8
  RenameLocalFileParams,
9
+ WriteLocalFileParams,
9
10
  } from '@lobechat/electron-client-ipc';
10
11
  import { StateCreator } from 'zustand/vanilla';
11
12
 
@@ -23,7 +24,7 @@ import {
23
24
  export interface LocalFileAction {
24
25
  internal_triggerLocalFileToolCalling: <T = any>(
25
26
  id: string,
26
- callingService: () => Promise<{ content: any; state: T }>,
27
+ callingService: () => Promise<{ content: any; state?: T }>,
27
28
  ) => Promise<boolean>;
28
29
 
29
30
  listLocalFiles: (id: string, params: ListLocalFileParams) => Promise<boolean>;
@@ -34,8 +35,9 @@ export interface LocalFileAction {
34
35
  renameLocalFile: (id: string, params: RenameLocalFileParams) => Promise<boolean>;
35
36
  // Added rename action
36
37
  searchLocalFiles: (id: string, params: LocalSearchFilesParams) => Promise<boolean>;
37
-
38
38
  toggleLocalFileLoading: (id: string, loading: boolean) => void;
39
+
40
+ writeLocalFile: (id: string, params: WriteLocalFileParams) => Promise<boolean>;
39
41
  }
40
42
 
41
43
  export const localFileSlice: StateCreator<
@@ -48,7 +50,9 @@ export const localFileSlice: StateCreator<
48
50
  get().toggleLocalFileLoading(id, true);
49
51
  try {
50
52
  const { state, content } = await callingService();
51
- await get().updatePluginState(id, state as any);
53
+ if (state) {
54
+ await get().updatePluginState(id, state as any);
55
+ }
52
56
  await get().internal_updateMessageContent(id, JSON.stringify(content));
53
57
  } catch (error) {
54
58
  await get().internal_updateMessagePluginError(id, {
@@ -183,4 +187,24 @@ export const localFileSlice: StateCreator<
183
187
  `toggleLocalFileLoading/${loading ? 'start' : 'end'}`,
184
188
  );
185
189
  },
190
+
191
+ writeLocalFile: async (id, params) => {
192
+ return get().internal_triggerLocalFileToolCalling(id, async () => {
193
+ const result = await localFileService.writeFile(params);
194
+
195
+ let content: { message: string; success: boolean };
196
+
197
+ if (result.success) {
198
+ content = {
199
+ message: `成功写入文件 ${params.path}`,
200
+ success: true,
201
+ };
202
+ } else {
203
+ const errorMessage = result.error;
204
+
205
+ content = { message: errorMessage || '写入文件失败', success: false };
206
+ }
207
+ return { content };
208
+ });
209
+ },
186
210
  });
@@ -0,0 +1,7 @@
1
+ import { ElectronState } from '@/store/electron/initialState';
2
+
3
+ const usePath = (s: ElectronState) => s.appState.userPath;
4
+
5
+ export const desktopStateSelectors = {
6
+ usePath,
7
+ };
@@ -1 +1,2 @@
1
+ export * from './desktopState';
1
2
  export * from './sync';
@@ -1,42 +1,12 @@
1
1
  import { ListLocalFileParams } from '@lobechat/electron-client-ipc';
2
- import { Typography } from 'antd';
3
- import { createStyles } from 'antd-style';
4
2
  import React, { memo } from 'react';
5
- import { Flexbox } from 'react-layout-kit';
6
3
 
7
- import FileIcon from '@/components/FileIcon';
8
- import { localFileService } from '@/services/electron/localFileService';
4
+ import { LocalFolder } from '@/features/LocalFile';
9
5
  import { LocalFileListState } from '@/tools/local-files/type';
10
6
  import { ChatMessagePluginError } from '@/types/message';
11
7
 
12
8
  import SearchResult from './Result';
13
9
 
14
- const useStyles = createStyles(({ css, token, cx }) => ({
15
- actions: cx(css`
16
- cursor: pointer;
17
- color: ${token.colorTextTertiary};
18
- opacity: 1;
19
- transition: opacity 0.2s ${token.motionEaseInOut};
20
- `),
21
- container: css`
22
- cursor: pointer;
23
-
24
- padding-block: 2px;
25
- padding-inline: 4px;
26
- border-radius: 4px;
27
-
28
- color: ${token.colorTextSecondary};
29
-
30
- :hover {
31
- color: ${token.colorText};
32
- background: ${token.colorFillTertiary};
33
- }
34
- `,
35
- path: css`
36
- color: ${token.colorTextSecondary};
37
- `,
38
- }));
39
-
40
10
  interface ListFilesProps {
41
11
  args: ListLocalFileParams;
42
12
  messageId: string;
@@ -45,22 +15,9 @@ interface ListFilesProps {
45
15
  }
46
16
 
47
17
  const ListFiles = memo<ListFilesProps>(({ messageId, pluginError, args, pluginState }) => {
48
- const { styles } = useStyles();
49
18
  return (
50
19
  <>
51
- <Flexbox
52
- className={styles.container}
53
- gap={8}
54
- horizontal
55
- onClick={() => {
56
- localFileService.openLocalFolder({ isDirectory: true, path: args.path });
57
- }}
58
- >
59
- <FileIcon fileName={args.path} isDirectory size={22} variant={'raw'} />
60
- <Typography.Text className={styles.path} ellipsis>
61
- {args.path}
62
- </Typography.Text>
63
- </Flexbox>
20
+ <LocalFolder path={args.path} />
64
21
  <SearchResult
65
22
  listResults={pluginState?.listResults}
66
23
  messageId={messageId}
@@ -2,10 +2,11 @@ import { RenameLocalFileParams } from '@lobechat/electron-client-ipc';
2
2
  import { Icon } from '@lobehub/ui';
3
3
  import { createStyles } from 'antd-style';
4
4
  import { ArrowRightIcon } from 'lucide-react';
5
+ import path from 'path-browserify-esm';
5
6
  import React, { memo } from 'react';
6
7
  import { Flexbox } from 'react-layout-kit';
7
8
 
8
- import FileIcon from '@/components/FileIcon';
9
+ import { LocalFile } from '@/features/LocalFile';
9
10
  import { LocalReadFileState } from '@/tools/local-files/type';
10
11
  import { ChatMessagePluginError } from '@/types/message';
11
12
 
@@ -28,17 +29,15 @@ interface RenameLocalFileProps {
28
29
  const RenameLocalFile = memo<RenameLocalFileProps>(({ args }) => {
29
30
  const { styles } = useStyles();
30
31
 
31
- const oldFileName = args.path.split('/').at(-1);
32
+ const { base: oldFileName, dir } = path.parse(args.path);
33
+
32
34
  return (
33
35
  <Flexbox align={'center'} className={styles.container} gap={8} horizontal paddingInline={12}>
34
36
  <Flexbox>{oldFileName}</Flexbox>
35
37
  <Flexbox>
36
38
  <Icon icon={ArrowRightIcon} />
37
39
  </Flexbox>
38
- <Flexbox className={styles.new} gap={4} horizontal>
39
- <FileIcon fileName={args.newName} size={20} variant={'raw'} />
40
- {args.newName}
41
- </Flexbox>
40
+ <LocalFile name={args.newName} path={path.join(dir, args.newName)} />
42
41
  </Flexbox>
43
42
  );
44
43
  });
@@ -0,0 +1,35 @@
1
+ import { RunCommandParams } from '@lobechat/electron-client-ipc';
2
+ import { Terminal } from '@xterm/xterm';
3
+ import '@xterm/xterm/css/xterm.css';
4
+ import { memo, useEffect, useRef } from 'react';
5
+
6
+ import { LocalReadFileState } from '@/tools/local-files/type';
7
+ import { ChatMessagePluginError } from '@/types/message';
8
+
9
+ interface RunCommandProps {
10
+ args: RunCommandParams;
11
+ messageId: string;
12
+ pluginError: ChatMessagePluginError;
13
+ pluginState: LocalReadFileState;
14
+ }
15
+
16
+ const RunCommand = memo<RunCommandProps>(({ args }) => {
17
+ const terminalRef = useRef(null);
18
+
19
+ useEffect(() => {
20
+ if (!terminalRef.current) return;
21
+
22
+ const term = new Terminal({ cols: 80, cursorBlink: true, rows: 30 });
23
+
24
+ term.open(terminalRef.current);
25
+ term.write(args.command);
26
+
27
+ return () => {
28
+ term.dispose();
29
+ };
30
+ }, []);
31
+
32
+ return <div ref={terminalRef} />;
33
+ });
34
+
35
+ export default RunCommand;
@@ -0,0 +1,32 @@
1
+ import { WriteLocalFileParams } from '@lobechat/electron-client-ipc';
2
+ import { Icon } from '@lobehub/ui';
3
+ import { Skeleton } from 'antd';
4
+ import { ChevronRight } from 'lucide-react';
5
+ import path from 'path-browserify-esm';
6
+ import { memo } from 'react';
7
+ import { Flexbox } from 'react-layout-kit';
8
+
9
+ import { LocalFile, LocalFolder } from '@/features/LocalFile';
10
+ import { ChatMessagePluginError } from '@/types/message';
11
+
12
+ interface WriteFileProps {
13
+ args: WriteLocalFileParams;
14
+ messageId: string;
15
+ pluginError: ChatMessagePluginError;
16
+ }
17
+
18
+ const WriteFile = memo<WriteFileProps>(({ args }) => {
19
+ if (!args) return <Skeleton active />;
20
+
21
+ const { base, dir } = path.parse(args.path);
22
+
23
+ return (
24
+ <Flexbox horizontal>
25
+ <LocalFolder path={dir} />
26
+ <Icon icon={ChevronRight} />
27
+ <LocalFile name={base} path={args.path} />
28
+ </Flexbox>
29
+ );
30
+ });
31
+
32
+ export default WriteFile;
@@ -8,12 +8,14 @@ import ListFiles from './ListFiles';
8
8
  import ReadLocalFile from './ReadLocalFile';
9
9
  import RenameLocalFile from './RenameLocalFile';
10
10
  import SearchFiles from './SearchFiles';
11
+ import WriteFile from './WriteFile';
11
12
 
12
13
  const RenderMap = {
13
14
  [LocalFilesApiName.searchLocalFiles]: SearchFiles,
14
15
  [LocalFilesApiName.listLocalFiles]: ListFiles,
15
16
  [LocalFilesApiName.readLocalFile]: ReadLocalFile,
16
17
  [LocalFilesApiName.renameLocalFile]: RenameLocalFile,
18
+ [LocalFilesApiName.writeLocalFile]: WriteFile,
17
19
  };
18
20
 
19
21
  const LocalFilesRender = memo<BuiltinRenderProps<LocalFileItem[]>>(
@@ -8,7 +8,7 @@ export const LocalFilesApiName = {
8
8
  readLocalFile: 'readLocalFile',
9
9
  renameLocalFile: 'renameLocalFile',
10
10
  searchLocalFiles: 'searchLocalFiles',
11
- writeFile: 'writeFile',
11
+ writeLocalFile: 'writeLocalFile',
12
12
  };
13
13
 
14
14
  export const LocalFilesManifest: BuiltinToolManifest = {
@@ -176,26 +176,25 @@ export const LocalFilesManifest: BuiltinToolManifest = {
176
176
  type: 'object',
177
177
  },
178
178
  },
179
- // TODO: Add writeFile API definition later
180
- // {
181
- // description:
182
- // 'Write content to a specific file. Input should be the file path and content. Overwrites existing file or creates a new one.',
183
- // name: LocalFilesApiName.writeFile,
184
- // parameters: {
185
- // properties: {
186
- // path: {
187
- // description: 'The file path to write to',
188
- // type: 'string',
189
- // },
190
- // content: {
191
- // description: 'The content to write',
192
- // type: 'string',
193
- // },
194
- // },
195
- // required: ['path', 'content'],
196
- // type: 'object',
197
- // },
198
- // },
179
+ {
180
+ description:
181
+ 'Write content to a specific file. Input should be the file path and content. Overwrites existing file or creates a new one.',
182
+ name: LocalFilesApiName.writeLocalFile,
183
+ parameters: {
184
+ properties: {
185
+ content: {
186
+ description: 'The content to write',
187
+ type: 'string',
188
+ },
189
+ path: {
190
+ description: 'The file path to write to',
191
+ type: 'string',
192
+ },
193
+ },
194
+ required: ['path', 'content'],
195
+ type: 'object',
196
+ },
197
+ },
199
198
  ],
200
199
  identifier: 'lobe-local-files',
201
200
  meta: {