@lobehub/chat 1.120.4 → 1.120.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.
@@ -59,9 +59,9 @@
59
59
  "**/src/**/route.ts": "${dirname(1)}/${dirname} • route",
60
60
  "**/src/**/index.tsx": "${dirname} • component",
61
61
 
62
- "**/src/database/repositories/*/index.ts": "${dirname} • db repository",
63
- "**/src/database/models/*.ts": "${filename} • db model",
64
- "**/src/database/schemas/*.ts": "${filename} • db schema",
62
+ "**/packages/database/src/repositories/*/index.ts": "${dirname} • db repository",
63
+ "**/packages/database/src/models/*.ts": "${filename} • db model",
64
+ "**/packages/database/src/schemas/*.ts": "${filename} • db schema",
65
65
 
66
66
  "**/src/services/*.ts": "${filename} • service",
67
67
  "**/src/services/*/client.ts": "${dirname} • client service",
@@ -81,7 +81,7 @@
81
81
  "**/src/store/*/slices/*/reducer.ts": "${dirname(2)}/${dirname} • reducer",
82
82
 
83
83
  "**/src/config/modelProviders/*.ts": "${filename} • provider",
84
- "**/packages/model-bank/src/aiModels/aiModels/*.ts": "${filename} • model",
84
+ "**/packages/model-bank/src/aiModels/*.ts": "${filename} • model",
85
85
  "**/packages/model-runtime/src/*/index.ts": "${dirname} • runtime",
86
86
 
87
87
  "**/src/server/services/*/index.ts": "${dirname} • server/service",
package/CHANGELOG.md CHANGED
@@ -2,6 +2,56 @@
2
2
 
3
3
  # Changelog
4
4
 
5
+ ### [Version 1.120.6](https://github.com/lobehub/lobe-chat/compare/v1.120.5...v1.120.6)
6
+
7
+ <sup>Released on **2025-09-01**</sup>
8
+
9
+ #### 💄 Styles
10
+
11
+ - **misc**: Add upload hint for non-visual model.
12
+
13
+ <br/>
14
+
15
+ <details>
16
+ <summary><kbd>Improvements and Fixes</kbd></summary>
17
+
18
+ #### Styles
19
+
20
+ - **misc**: Add upload hint for non-visual model, closes [#7969](https://github.com/lobehub/lobe-chat/issues/7969) ([1224f4e](https://github.com/lobehub/lobe-chat/commit/1224f4e))
21
+
22
+ </details>
23
+
24
+ <div align="right">
25
+
26
+ [![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
27
+
28
+ </div>
29
+
30
+ ### [Version 1.120.5](https://github.com/lobehub/lobe-chat/compare/v1.120.4...v1.120.5)
31
+
32
+ <sup>Released on **2025-09-01**</sup>
33
+
34
+ #### 🐛 Bug Fixes
35
+
36
+ - **ai-image**: Save config.imageUrl with fullUrl instead of key.
37
+
38
+ <br/>
39
+
40
+ <details>
41
+ <summary><kbd>Improvements and Fixes</kbd></summary>
42
+
43
+ #### What's fixed
44
+
45
+ - **ai-image**: Save config.imageUrl with fullUrl instead of key, closes [#9016](https://github.com/lobehub/lobe-chat/issues/9016) ([bad009a](https://github.com/lobehub/lobe-chat/commit/bad009a))
46
+
47
+ </details>
48
+
49
+ <div align="right">
50
+
51
+ [![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
52
+
53
+ </div>
54
+
5
55
  ### [Version 1.120.4](https://github.com/lobehub/lobe-chat/compare/v1.120.3...v1.120.4)
6
56
 
7
57
  <sup>Released on **2025-09-01**</sup>
package/changelog/v1.json CHANGED
@@ -1,4 +1,18 @@
1
1
  [
2
+ {
3
+ "children": {
4
+ "improvements": [
5
+ "Add upload hint for non-visual model."
6
+ ]
7
+ },
8
+ "date": "2025-09-01",
9
+ "version": "1.120.6"
10
+ },
11
+ {
12
+ "children": {},
13
+ "date": "2025-09-01",
14
+ "version": "1.120.5"
15
+ },
2
16
  {
3
17
  "children": {
4
18
  "improvements": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lobehub/chat",
3
- "version": "1.120.4",
3
+ "version": "1.120.6",
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",
@@ -192,7 +192,7 @@
192
192
  "fast-deep-equal": "^3.1.3",
193
193
  "file-type": "^21.0.0",
194
194
  "framer-motion": "^12.23.12",
195
- "gpt-tokenizer": "^2.9.0",
195
+ "gpt-tokenizer": "^3.0.0",
196
196
  "gray-matter": "^4.0.3",
197
197
  "html-to-text": "^9.0.5",
198
198
  "i18next": "^25.3.2",
@@ -45,8 +45,8 @@ export const DEFAULT_DIMENSION_CONSTRAINTS = {
45
45
  } as const;
46
46
 
47
47
  export const CHAT_MODEL_IMAGE_GENERATION_PARAMS: ModelParamsSchema = {
48
- imageUrl: {
49
- default: null,
48
+ imageUrls: {
49
+ default: [],
50
50
  },
51
51
  prompt: { default: '' },
52
52
  };
@@ -1,15 +1,43 @@
1
1
  import { act, renderHook } from '@testing-library/react';
2
+ import { App } from 'antd';
2
3
  import { Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
4
 
5
+ import { useModelSupportVision } from '@/hooks/useModelSupportVision';
6
+ import { useAgentStore } from '@/store/agent';
7
+ import { agentSelectors } from '@/store/agent/slices/chat';
8
+
4
9
  import { getContainer, useDragUpload } from './useDragUpload';
5
10
 
11
+ // Mock the hooks and components
12
+ vi.mock('@/hooks/useModelSupportVision');
13
+ vi.mock('@/store/agent');
14
+ vi.mock('antd', () => ({
15
+ App: {
16
+ useApp: () => ({
17
+ message: {
18
+ warning: vi.fn(),
19
+ },
20
+ }),
21
+ },
22
+ }));
23
+
6
24
  describe('useDragUpload', () => {
7
25
  let mockOnUploadFiles: Mock;
26
+ let mockMessage: { warning: Mock };
8
27
 
9
28
  beforeEach(() => {
10
29
  mockOnUploadFiles = vi.fn();
30
+ mockMessage = { warning: vi.fn() };
11
31
  vi.useFakeTimers();
12
32
  document.body.innerHTML = '';
33
+
34
+ // Mock the hooks
35
+ (useModelSupportVision as Mock).mockReturnValue(false);
36
+ (useAgentStore as unknown as Mock).mockImplementation((selector) => {
37
+ if (selector === agentSelectors.currentAgentModel) return 'test-model';
38
+ if (selector === agentSelectors.currentAgentModelProvider) return 'test-provider';
39
+ return null;
40
+ });
13
41
  });
14
42
 
15
43
  afterEach(() => {
@@ -115,6 +143,89 @@ describe('useDragUpload', () => {
115
143
 
116
144
  expect(mockOnUploadFiles).toHaveBeenCalledWith([mockFile]);
117
145
  });
146
+
147
+ it('should show warning when dropping image file with vision not supported', async () => {
148
+ renderHook(() => useDragUpload(mockOnUploadFiles));
149
+
150
+ const mockImageFile = new File([''], 'test.png', { type: 'image/png' });
151
+ const dropEvent = new Event('drop') as DragEvent;
152
+ Object.defineProperty(dropEvent, 'dataTransfer', {
153
+ value: {
154
+ items: [
155
+ {
156
+ kind: 'file',
157
+ getAsFile: () => mockImageFile,
158
+ webkitGetAsEntry: () => ({
159
+ isFile: true,
160
+ file: (cb: (file: File) => void) => cb(mockImageFile),
161
+ }),
162
+ },
163
+ ],
164
+ types: ['Files'],
165
+ },
166
+ });
167
+
168
+ await act(async () => {
169
+ window.dispatchEvent(dropEvent);
170
+ });
171
+
172
+ expect(mockOnUploadFiles).not.toHaveBeenCalled();
173
+ });
174
+
175
+ it('should show warning when pasting image file with vision not supported', async () => {
176
+ renderHook(() => useDragUpload(mockOnUploadFiles));
177
+
178
+ const mockImageFile = new File([''], 'test.png', { type: 'image/png' });
179
+ const pasteEvent = new Event('paste') as ClipboardEvent;
180
+ Object.defineProperty(pasteEvent, 'clipboardData', {
181
+ value: {
182
+ items: [
183
+ {
184
+ kind: 'file',
185
+ getAsFile: () => mockImageFile,
186
+ webkitGetAsEntry: () => null,
187
+ },
188
+ ],
189
+ },
190
+ });
191
+
192
+ await act(async () => {
193
+ window.dispatchEvent(pasteEvent);
194
+ });
195
+
196
+ expect(mockOnUploadFiles).not.toHaveBeenCalled();
197
+ });
198
+
199
+ it('should allow image files when vision is supported', async () => {
200
+ (useModelSupportVision as Mock).mockReturnValue(true);
201
+
202
+ renderHook(() => useDragUpload(mockOnUploadFiles));
203
+
204
+ const mockImageFile = new File([''], 'test.png', { type: 'image/png' });
205
+ const dropEvent = new Event('drop') as DragEvent;
206
+ Object.defineProperty(dropEvent, 'dataTransfer', {
207
+ value: {
208
+ items: [
209
+ {
210
+ kind: 'file',
211
+ getAsFile: () => mockImageFile,
212
+ webkitGetAsEntry: () => ({
213
+ isFile: true,
214
+ file: (cb: (file: File) => void) => cb(mockImageFile),
215
+ }),
216
+ },
217
+ ],
218
+ types: ['Files'],
219
+ },
220
+ });
221
+
222
+ await act(async () => {
223
+ window.dispatchEvent(dropEvent);
224
+ });
225
+
226
+ expect(mockOnUploadFiles).toHaveBeenCalledWith([mockImageFile]);
227
+ expect(App.useApp().message.warning).not.toHaveBeenCalled();
228
+ });
118
229
  });
119
230
 
120
231
  describe('getContainer', () => {
@@ -1,5 +1,11 @@
1
1
  /* eslint-disable no-undef */
2
+ import { App } from 'antd';
2
3
  import { useEffect, useRef, useState } from 'react';
4
+ import { useTranslation } from 'react-i18next';
5
+
6
+ import { useModelSupportVision } from '@/hooks/useModelSupportVision';
7
+ import { useAgentStore } from '@/store/agent';
8
+ import { agentSelectors } from '@/store/agent/selectors';
3
9
 
4
10
  const DRAGGING_ROOT_ID = 'dragging-root';
5
11
  export const getContainer = () => document.querySelector(`#${DRAGGING_ROOT_ID}`);
@@ -62,12 +68,18 @@ const getFileListFromDataTransferItems = async (items: DataTransferItem[]) => {
62
68
  };
63
69
 
64
70
  export const useDragUpload = (onUploadFiles: (files: File[]) => Promise<void>) => {
71
+ const { t } = useTranslation('chat');
72
+ const { message } = App.useApp();
65
73
  const [isDragging, setIsDragging] = useState(false);
66
74
  // When a file is dragged to a different area, the 'dragleave' event may be triggered,
67
75
  // causing isDragging to be mistakenly set to false.
68
76
  // to fix this issue, use a counter to ensure the status change only when drag event left the browser window .
69
77
  const dragCounter = useRef(0);
70
78
 
79
+ const model = useAgentStore(agentSelectors.currentAgentModel);
80
+ const provider = useAgentStore(agentSelectors.currentAgentModelProvider);
81
+ const supportVision = useModelSupportVision(model, provider);
82
+
71
83
  const handleDragEnter = (e: DragEvent) => {
72
84
  if (!e.dataTransfer?.items || e.dataTransfer.items.length === 0) return;
73
85
 
@@ -113,6 +125,13 @@ export const useDragUpload = (onUploadFiles: (files: File[]) => Promise<void>) =
113
125
 
114
126
  if (files.length === 0) return;
115
127
 
128
+ // 检查是否有图片文件且模型不支持视觉功能
129
+ const hasImageFiles = files.some((file) => file.type.startsWith('image/'));
130
+ if (hasImageFiles && !supportVision) {
131
+ message.warning(t('upload.clientMode.visionNotSupported'));
132
+ return;
133
+ }
134
+
116
135
  // upload files
117
136
  onUploadFiles(files);
118
137
  };
@@ -125,6 +144,13 @@ export const useDragUpload = (onUploadFiles: (files: File[]) => Promise<void>) =
125
144
  const files = await getFileListFromDataTransferItems(items);
126
145
  if (files.length === 0) return;
127
146
 
147
+ // 检查是否有图片文件且模型不支持视觉功能
148
+ const hasImageFiles = files.some((file) => file.type.startsWith('image/'));
149
+ if (hasImageFiles && !supportVision) {
150
+ message.warning(t('upload.clientMode.visionNotSupported'));
151
+ return;
152
+ }
153
+
128
154
  onUploadFiles(files);
129
155
  };
130
156
 
@@ -278,6 +278,7 @@ export default {
278
278
  actionFiletip: '上传文件',
279
279
  actionTooltip: '上传',
280
280
  disabled: '当前模型不支持视觉识别和文件分析,请切换模型后使用',
281
+ visionNotSupported: '当前模型不支持视觉识别,请切换模型后使用',
281
282
  },
282
283
  preview: {
283
284
  prepareTasks: '准备分块...',
@@ -0,0 +1,138 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ // Copy of the validation function from image.ts for testing
4
+ function validateNoUrlsInConfig(obj: any, path: string = ''): void {
5
+ if (typeof obj === 'string') {
6
+ if (obj.startsWith('http://') || obj.startsWith('https://')) {
7
+ throw new Error(
8
+ `Invalid configuration: Found full URL instead of key at ${path || 'root'}. ` +
9
+ `URL: "${obj.slice(0, 100)}${obj.length > 100 ? '...' : ''}". ` +
10
+ `All URLs must be converted to storage keys before database insertion.`,
11
+ );
12
+ }
13
+ } else if (Array.isArray(obj)) {
14
+ obj.forEach((item, index) => {
15
+ validateNoUrlsInConfig(item, `${path}[${index}]`);
16
+ });
17
+ } else if (obj && typeof obj === 'object') {
18
+ Object.entries(obj).forEach(([key, value]) => {
19
+ const currentPath = path ? `${path}.${key}` : key;
20
+ validateNoUrlsInConfig(value, currentPath);
21
+ });
22
+ }
23
+ }
24
+
25
+ describe('imageRouter', () => {
26
+ describe('validateNoUrlsInConfig utility', () => {
27
+ describe('valid configurations', () => {
28
+ it('should pass with normal keys', () => {
29
+ const config = {
30
+ imageUrl: 'images/photo.jpg',
31
+ imageUrls: ['files/doc.pdf', 'assets/video.mp4'],
32
+ prompt: 'Generate an image',
33
+ };
34
+
35
+ expect(() => validateNoUrlsInConfig(config)).not.toThrow();
36
+ });
37
+
38
+ it('should pass with empty strings', () => {
39
+ const config = {
40
+ imageUrl: '',
41
+ imageUrls: [],
42
+ prompt: 'Generate an image',
43
+ };
44
+
45
+ expect(() => validateNoUrlsInConfig(config)).not.toThrow();
46
+ });
47
+
48
+ it('should pass with null/undefined values', () => {
49
+ const config = {
50
+ imageUrl: null,
51
+ imageUrls: undefined,
52
+ prompt: 'Generate an image',
53
+ };
54
+
55
+ expect(() => validateNoUrlsInConfig(config)).not.toThrow();
56
+ });
57
+ });
58
+
59
+ describe('invalid configurations', () => {
60
+ it('should throw for https URL in imageUrl', () => {
61
+ const config = {
62
+ imageUrl: 'https://s3.amazonaws.com/bucket/image.jpg',
63
+ prompt: 'Generate an image',
64
+ };
65
+
66
+ expect(() => validateNoUrlsInConfig(config)).toThrow(
67
+ 'Invalid configuration: Found full URL instead of key at imageUrl',
68
+ );
69
+ });
70
+
71
+ it('should throw for http URL in imageUrls array', () => {
72
+ const config = {
73
+ imageUrls: ['files/doc.pdf', 'http://example.com/image.jpg'],
74
+ prompt: 'Generate an image',
75
+ };
76
+
77
+ expect(() => validateNoUrlsInConfig(config)).toThrow(
78
+ 'Invalid configuration: Found full URL instead of key at imageUrls[1]',
79
+ );
80
+ });
81
+
82
+ it('should throw for nested URL in complex object', () => {
83
+ const config = {
84
+ settings: {
85
+ imageConfig: {
86
+ url: 'https://cdn.example.com/very-long-url-that-exceeds-100-characters-to-test-truncation-functionality.jpg',
87
+ },
88
+ },
89
+ };
90
+
91
+ expect(() => validateNoUrlsInConfig(config)).toThrow(
92
+ 'Invalid configuration: Found full URL instead of key at settings.imageConfig.url',
93
+ );
94
+ expect(() => validateNoUrlsInConfig(config)).toThrow(
95
+ 'https://cdn.example.com/very-long-url-that-exceeds-100-characters-to-test-truncation-func',
96
+ );
97
+ });
98
+
99
+ it('should throw for presigned URL with query parameters', () => {
100
+ const config = {
101
+ imageUrl:
102
+ 'https://s3.amazonaws.com/bucket/file.jpg?X-Amz-Signature=abc&X-Amz-Expires=3600',
103
+ };
104
+
105
+ expect(() => validateNoUrlsInConfig(config)).toThrow(
106
+ 'All URLs must be converted to storage keys before database insertion',
107
+ );
108
+ });
109
+ });
110
+
111
+ describe('edge cases', () => {
112
+ it('should handle deeply nested structures', () => {
113
+ const config = {
114
+ level1: {
115
+ level2: {
116
+ level3: {
117
+ level4: ['normal-key', 'https://bad-url.com'],
118
+ },
119
+ },
120
+ },
121
+ };
122
+
123
+ expect(() => validateNoUrlsInConfig(config)).toThrow(
124
+ 'Invalid configuration: Found full URL instead of key at level1.level2.level3.level4[1]',
125
+ );
126
+ });
127
+
128
+ it('should not throw for strings that contain but do not start with http', () => {
129
+ const config = {
130
+ imageUrl: 'some-prefix-https://example.com',
131
+ description: 'This text contains http:// but is not a URL',
132
+ };
133
+
134
+ expect(() => validateNoUrlsInConfig(config)).not.toThrow();
135
+ });
136
+ });
137
+ });
138
+ });
@@ -24,6 +24,31 @@ import { generateUniqueSeeds } from '@/utils/number';
24
24
 
25
25
  const log = debug('lobe-image:lambda');
26
26
 
27
+ /**
28
+ * Recursively validate that no full URLs are present in the config
29
+ * This is a defensive check to ensure only keys are stored in database
30
+ */
31
+ function validateNoUrlsInConfig(obj: any, path: string = ''): void {
32
+ if (typeof obj === 'string') {
33
+ if (obj.startsWith('http://') || obj.startsWith('https://')) {
34
+ throw new Error(
35
+ `Invalid configuration: Found full URL instead of key at ${path || 'root'}. ` +
36
+ `URL: "${obj.slice(0, 100)}${obj.length > 100 ? '...' : ''}". ` +
37
+ `All URLs must be converted to storage keys before database insertion.`,
38
+ );
39
+ }
40
+ } else if (Array.isArray(obj)) {
41
+ obj.forEach((item, index) => {
42
+ validateNoUrlsInConfig(item, `${path}[${index}]`);
43
+ });
44
+ } else if (obj && typeof obj === 'object') {
45
+ Object.entries(obj).forEach(([key, value]) => {
46
+ const currentPath = path ? `${path}.${key}` : key;
47
+ validateNoUrlsInConfig(value, currentPath);
48
+ });
49
+ }
50
+ }
51
+
27
52
  const imageProcedure = authedProcedure
28
53
  .use(keyVaults)
29
54
  .use(serverDatabase)
@@ -71,8 +96,9 @@ export const imageRouter = router({
71
96
 
72
97
  log('Starting image creation process, input: %O', input);
73
98
 
74
- // 如果 params 中包含 imageUrls,将它们转换为 S3 keys 用于数据库存储
99
+ // 规范化参考图地址,统一存储 S3 key(避免把会过期的预签名 URL 存进数据库)
75
100
  let configForDatabase = { ...params };
101
+ // 1) 处理多图 imageUrls
76
102
  if (Array.isArray(params.imageUrls) && params.imageUrls.length > 0) {
77
103
  log('Converting imageUrls to S3 keys for database storage: %O', params.imageUrls);
78
104
  try {
@@ -82,18 +108,30 @@ export const imageRouter = router({
82
108
  return key;
83
109
  });
84
110
 
85
- // 将转换后的 keys 存储为数据库配置
86
111
  configForDatabase = {
87
- ...params,
112
+ ...configForDatabase,
88
113
  imageUrls: imageKeys,
89
114
  };
90
115
  log('Successfully converted imageUrls to keys for database: %O', imageKeys);
91
116
  } catch (error) {
92
117
  log('Error converting imageUrls to keys: %O', error);
93
- // 如果转换失败,保持原始 URLs(可能是本地文件或其他格式)
94
118
  log('Keeping original imageUrls due to conversion error');
95
119
  }
96
120
  }
121
+ // 2) 处理单图 imageUrl
122
+ if (typeof params.imageUrl === 'string' && params.imageUrl) {
123
+ try {
124
+ const key = fileService.getKeyFromFullUrl(params.imageUrl);
125
+ log('Converted single imageUrl to key: %s -> %s', params.imageUrl, key);
126
+ configForDatabase = { ...configForDatabase, imageUrl: key };
127
+ } catch (error) {
128
+ log('Error converting imageUrl to key: %O', error);
129
+ // 转换失败则保留原始值
130
+ }
131
+ }
132
+
133
+ // 防御性检测:确保没有完整URL进入数据库
134
+ validateNoUrlsInConfig(configForDatabase, 'configForDatabase');
97
135
 
98
136
  // 步骤 1: 在事务中原子性地创建所有数据库记录
99
137
  const { batch: createdBatch, generationsWithTasks } = await serverDB.transaction(async (tx) => {
@@ -1,5 +1,4 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
- import path from 'node:path';
3
2
  import { beforeEach, describe, expect, it, vi } from 'vitest';
4
3
 
5
4
  import { electronIpcClient } from '@/server/modules/ElectronIPCClient';
@@ -234,6 +233,52 @@ describe('DesktopLocalFileImpl', () => {
234
233
  // 验证
235
234
  expect(result).toBe('');
236
235
  });
236
+
237
+ // Legacy bug compatibility tests - https://github.com/lobehub/lobe-chat/issues/8994
238
+ describe('legacy bug compatibility', () => {
239
+ it('should handle full URL input by extracting key', async () => {
240
+ const fullUrl = 'http://localhost:3000/desktop-file/documents/test.txt';
241
+
242
+ // Mock getKeyFromFullUrl and getLocalFileUrl
243
+ vi.spyOn(service, 'getKeyFromFullUrl').mockReturnValue('desktop://documents/test.txt');
244
+ const getLocalFileUrlSpy = vi.spyOn(service as any, 'getLocalFileUrl');
245
+ getLocalFileUrlSpy.mockResolvedValueOnce('data:image/png;base64,test');
246
+
247
+ const result = await service.getFullFileUrl(fullUrl);
248
+
249
+ expect(service.getKeyFromFullUrl).toHaveBeenCalledWith(fullUrl);
250
+ expect(getLocalFileUrlSpy).toHaveBeenCalledWith('desktop://documents/test.txt');
251
+ expect(result).toBe('data:image/png;base64,test');
252
+ });
253
+
254
+ it('should handle normal desktop:// key input without extraction', async () => {
255
+ const key = 'desktop://documents/test.txt';
256
+
257
+ const extractSpy = vi.spyOn(service, 'getKeyFromFullUrl');
258
+ const getLocalFileUrlSpy = vi.spyOn(service as any, 'getLocalFileUrl');
259
+ getLocalFileUrlSpy.mockResolvedValueOnce('data:image/png;base64,test');
260
+
261
+ const result = await service.getFullFileUrl(key);
262
+
263
+ expect(extractSpy).not.toHaveBeenCalled();
264
+ expect(getLocalFileUrlSpy).toHaveBeenCalledWith(key);
265
+ expect(result).toBe('data:image/png;base64,test');
266
+ });
267
+
268
+ it('should handle https:// URLs for legacy compatibility', async () => {
269
+ const httpsUrl = 'https://localhost:3000/desktop-file/images/photo.jpg';
270
+
271
+ vi.spyOn(service, 'getKeyFromFullUrl').mockReturnValue('desktop://images/photo.jpg');
272
+ const getLocalFileUrlSpy = vi.spyOn(service as any, 'getLocalFileUrl');
273
+ getLocalFileUrlSpy.mockResolvedValueOnce('data:image/jpeg;base64,test');
274
+
275
+ const result = await service.getFullFileUrl(httpsUrl);
276
+
277
+ expect(service.getKeyFromFullUrl).toHaveBeenCalledWith(httpsUrl);
278
+ expect(getLocalFileUrlSpy).toHaveBeenCalledWith('desktop://images/photo.jpg');
279
+ expect(result).toBe('data:image/jpeg;base64,test');
280
+ });
281
+ });
237
282
  });
238
283
 
239
284
  describe('uploadContent', () => {
@@ -6,6 +6,7 @@ import { electronIpcClient } from '@/server/modules/ElectronIPCClient';
6
6
  import { inferContentTypeFromImageUrl } from '@/utils/url';
7
7
 
8
8
  import { FileServiceImpl } from './type';
9
+ import { extractKeyFromUrlOrReturnOriginal } from './utils';
9
10
 
10
11
  /**
11
12
  * 桌面应用本地文件服务实现
@@ -127,7 +128,11 @@ export class DesktopLocalFileImpl implements FileServiceImpl {
127
128
  */
128
129
  async getFullFileUrl(url?: string | null): Promise<string> {
129
130
  if (!url) return '';
130
- return this.getLocalFileUrl(url);
131
+
132
+ // Handle legacy data compatibility using shared utility
133
+ const key = extractKeyFromUrlOrReturnOriginal(url, this.getKeyFromFullUrl.bind(this));
134
+
135
+ return this.getLocalFileUrl(key);
131
136
  }
132
137
 
133
138
  /**
@@ -65,6 +65,56 @@ describe('S3StaticFileImpl', () => {
65
65
  );
66
66
  config.S3_ENABLE_PATH_STYLE = false;
67
67
  });
68
+
69
+ // Legacy bug compatibility tests - https://github.com/lobehub/lobe-chat/issues/8994
70
+ describe('legacy bug compatibility', () => {
71
+ it('should handle full URL input by extracting key (S3_SET_ACL=false)', async () => {
72
+ config.S3_SET_ACL = false;
73
+ const fullUrl = 'https://s3.example.com/bucket/path/to/file.jpg?X-Amz-Signature=expired';
74
+
75
+ // Mock getKeyFromFullUrl to return the extracted key
76
+ vi.spyOn(fileService, 'getKeyFromFullUrl').mockReturnValue('path/to/file.jpg');
77
+
78
+ const result = await fileService.getFullFileUrl(fullUrl);
79
+
80
+ expect(fileService.getKeyFromFullUrl).toHaveBeenCalledWith(fullUrl);
81
+ expect(result).toBe('https://presigned.example.com/test.jpg');
82
+ config.S3_SET_ACL = true;
83
+ });
84
+
85
+ it('should handle full URL input by extracting key (S3_SET_ACL=true)', async () => {
86
+ const fullUrl = 'https://s3.example.com/bucket/path/to/file.jpg';
87
+
88
+ vi.spyOn(fileService, 'getKeyFromFullUrl').mockReturnValue('path/to/file.jpg');
89
+
90
+ const result = await fileService.getFullFileUrl(fullUrl);
91
+
92
+ expect(fileService.getKeyFromFullUrl).toHaveBeenCalledWith(fullUrl);
93
+ expect(result).toBe('https://example.com/path/to/file.jpg');
94
+ });
95
+
96
+ it('should handle normal key input without extraction', async () => {
97
+ const key = 'path/to/file.jpg';
98
+
99
+ const spy = vi.spyOn(fileService, 'getKeyFromFullUrl');
100
+
101
+ const result = await fileService.getFullFileUrl(key);
102
+
103
+ expect(spy).not.toHaveBeenCalled();
104
+ expect(result).toBe('https://example.com/path/to/file.jpg');
105
+ });
106
+
107
+ it('should handle http:// URLs for legacy compatibility', async () => {
108
+ const httpUrl = 'http://s3.example.com/bucket/path/to/file.jpg';
109
+
110
+ vi.spyOn(fileService, 'getKeyFromFullUrl').mockReturnValue('path/to/file.jpg');
111
+
112
+ const result = await fileService.getFullFileUrl(httpUrl);
113
+
114
+ expect(fileService.getKeyFromFullUrl).toHaveBeenCalledWith(httpUrl);
115
+ expect(result).toBe('https://example.com/path/to/file.jpg');
116
+ });
117
+ });
68
118
  });
69
119
 
70
120
  describe('getFileContent', () => {
@@ -4,6 +4,7 @@ import { fileEnv } from '@/config/file';
4
4
  import { S3 } from '@/server/modules/S3';
5
5
 
6
6
  import { FileServiceImpl } from './type';
7
+ import { extractKeyFromUrlOrReturnOriginal } from './utils';
7
8
 
8
9
  /**
9
10
  * 基于S3的文件服务实现
@@ -46,16 +47,19 @@ export class S3StaticFileImpl implements FileServiceImpl {
46
47
  async getFullFileUrl(url?: string | null, expiresIn?: number): Promise<string> {
47
48
  if (!url) return '';
48
49
 
50
+ // Handle legacy data compatibility using shared utility
51
+ const key = extractKeyFromUrlOrReturnOriginal(url, this.getKeyFromFullUrl.bind(this));
52
+
49
53
  // If bucket is not set public read, the preview address needs to be regenerated each time
50
54
  if (!fileEnv.S3_SET_ACL) {
51
- return await this.createPreSignedUrlForPreview(url, expiresIn);
55
+ return await this.createPreSignedUrlForPreview(key, expiresIn);
52
56
  }
53
57
 
54
58
  if (fileEnv.S3_ENABLE_PATH_STYLE) {
55
- return urlJoin(fileEnv.S3_PUBLIC_DOMAIN!, fileEnv.S3_BUCKET!, url);
59
+ return urlJoin(fileEnv.S3_PUBLIC_DOMAIN!, fileEnv.S3_BUCKET!, key);
56
60
  }
57
61
 
58
- return urlJoin(fileEnv.S3_PUBLIC_DOMAIN!, url);
62
+ return urlJoin(fileEnv.S3_PUBLIC_DOMAIN!, key);
59
63
  }
60
64
 
61
65
  getKeyFromFullUrl(url: string): string {
@@ -0,0 +1,154 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import { extractKeyFromUrlOrReturnOriginal } from './utils';
4
+
5
+ describe('extractKeyFromUrlOrReturnOriginal', () => {
6
+ const mockGetKeyFromFullUrl = vi.fn();
7
+
8
+ beforeEach(() => {
9
+ vi.clearAllMocks();
10
+ });
11
+
12
+ describe('URL detection', () => {
13
+ it('should detect https:// URLs and extract key', () => {
14
+ const httpsUrl = 'https://s3.example.com/bucket/path/to/file.jpg';
15
+ const expectedKey = 'path/to/file.jpg';
16
+
17
+ mockGetKeyFromFullUrl.mockReturnValue(expectedKey);
18
+
19
+ const result = extractKeyFromUrlOrReturnOriginal(httpsUrl, mockGetKeyFromFullUrl);
20
+
21
+ expect(mockGetKeyFromFullUrl).toHaveBeenCalledWith(httpsUrl);
22
+ expect(result).toBe(expectedKey);
23
+ });
24
+
25
+ it('should detect http:// URLs and extract key', () => {
26
+ const httpUrl = 'http://s3.example.com/bucket/path/to/file.jpg';
27
+ const expectedKey = 'path/to/file.jpg';
28
+
29
+ mockGetKeyFromFullUrl.mockReturnValue(expectedKey);
30
+
31
+ const result = extractKeyFromUrlOrReturnOriginal(httpUrl, mockGetKeyFromFullUrl);
32
+
33
+ expect(mockGetKeyFromFullUrl).toHaveBeenCalledWith(httpUrl);
34
+ expect(result).toBe(expectedKey);
35
+ });
36
+
37
+ it('should handle presigned URLs with query parameters', () => {
38
+ const presignedUrl = 'https://s3.amazonaws.com/bucket/file.jpg?X-Amz-Signature=abc&X-Amz-Expires=3600';
39
+ const expectedKey = 'file.jpg';
40
+
41
+ mockGetKeyFromFullUrl.mockReturnValue(expectedKey);
42
+
43
+ const result = extractKeyFromUrlOrReturnOriginal(presignedUrl, mockGetKeyFromFullUrl);
44
+
45
+ expect(mockGetKeyFromFullUrl).toHaveBeenCalledWith(presignedUrl);
46
+ expect(result).toBe(expectedKey);
47
+ });
48
+ });
49
+
50
+ describe('key passthrough', () => {
51
+ it('should return original string for plain keys', () => {
52
+ const key = 'path/to/file.jpg';
53
+
54
+ const result = extractKeyFromUrlOrReturnOriginal(key, mockGetKeyFromFullUrl);
55
+
56
+ expect(mockGetKeyFromFullUrl).not.toHaveBeenCalled();
57
+ expect(result).toBe(key);
58
+ });
59
+
60
+ it('should return original string for desktop:// keys', () => {
61
+ const desktopKey = 'desktop://documents/file.pdf';
62
+
63
+ const result = extractKeyFromUrlOrReturnOriginal(desktopKey, mockGetKeyFromFullUrl);
64
+
65
+ expect(mockGetKeyFromFullUrl).not.toHaveBeenCalled();
66
+ expect(result).toBe(desktopKey);
67
+ });
68
+
69
+ it('should return original string for relative paths', () => {
70
+ const relativePath = './assets/image.png';
71
+
72
+ const result = extractKeyFromUrlOrReturnOriginal(relativePath, mockGetKeyFromFullUrl);
73
+
74
+ expect(mockGetKeyFromFullUrl).not.toHaveBeenCalled();
75
+ expect(result).toBe(relativePath);
76
+ });
77
+
78
+ it('should return original string for file:// protocol', () => {
79
+ const fileUrl = 'file:///Users/test/file.txt';
80
+
81
+ const result = extractKeyFromUrlOrReturnOriginal(fileUrl, mockGetKeyFromFullUrl);
82
+
83
+ expect(mockGetKeyFromFullUrl).not.toHaveBeenCalled();
84
+ expect(result).toBe(fileUrl);
85
+ });
86
+ });
87
+
88
+ describe('edge cases', () => {
89
+ it('should handle empty string', () => {
90
+ const emptyString = '';
91
+
92
+ const result = extractKeyFromUrlOrReturnOriginal(emptyString, mockGetKeyFromFullUrl);
93
+
94
+ expect(mockGetKeyFromFullUrl).not.toHaveBeenCalled();
95
+ expect(result).toBe(emptyString);
96
+ });
97
+
98
+ it('should handle strings that start with http but are not URLs', () => {
99
+ const notUrl = 'httpish-string';
100
+
101
+ const result = extractKeyFromUrlOrReturnOriginal(notUrl, mockGetKeyFromFullUrl);
102
+
103
+ expect(mockGetKeyFromFullUrl).not.toHaveBeenCalled();
104
+ expect(result).toBe(notUrl);
105
+ });
106
+
107
+ it('should handle case sensitivity correctly', () => {
108
+ const upperCaseHttps = 'HTTPS://example.com/file.jpg';
109
+
110
+ const result = extractKeyFromUrlOrReturnOriginal(upperCaseHttps, mockGetKeyFromFullUrl);
111
+
112
+ expect(mockGetKeyFromFullUrl).not.toHaveBeenCalled();
113
+ expect(result).toBe(upperCaseHttps);
114
+ });
115
+ });
116
+
117
+ describe('legacy bug scenarios', () => {
118
+ it('should handle S3 public URLs', () => {
119
+ const s3PublicUrl = 'https://mybucket.s3.amazonaws.com/images/photo.jpg';
120
+ const expectedKey = 'images/photo.jpg';
121
+
122
+ mockGetKeyFromFullUrl.mockReturnValue(expectedKey);
123
+
124
+ const result = extractKeyFromUrlOrReturnOriginal(s3PublicUrl, mockGetKeyFromFullUrl);
125
+
126
+ expect(mockGetKeyFromFullUrl).toHaveBeenCalledWith(s3PublicUrl);
127
+ expect(result).toBe(expectedKey);
128
+ });
129
+
130
+ it('should handle custom domain S3 URLs', () => {
131
+ const customDomainUrl = 'https://cdn.example.com/files/document.pdf';
132
+ const expectedKey = 'files/document.pdf';
133
+
134
+ mockGetKeyFromFullUrl.mockReturnValue(expectedKey);
135
+
136
+ const result = extractKeyFromUrlOrReturnOriginal(customDomainUrl, mockGetKeyFromFullUrl);
137
+
138
+ expect(mockGetKeyFromFullUrl).toHaveBeenCalledWith(customDomainUrl);
139
+ expect(result).toBe(expectedKey);
140
+ });
141
+
142
+ it('should handle local development URLs', () => {
143
+ const localUrl = 'http://localhost:3000/desktop-file/images/screenshot.png';
144
+ const expectedKey = 'desktop://images/screenshot.png';
145
+
146
+ mockGetKeyFromFullUrl.mockReturnValue(expectedKey);
147
+
148
+ const result = extractKeyFromUrlOrReturnOriginal(localUrl, mockGetKeyFromFullUrl);
149
+
150
+ expect(mockGetKeyFromFullUrl).toHaveBeenCalledWith(localUrl);
151
+ expect(result).toBe(expectedKey);
152
+ });
153
+ });
154
+ });
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Handle legacy bug where full URLs were stored instead of keys
3
+ * Some historical data stored complete URLs in database instead of just keys
4
+ * Related issue: https://github.com/lobehub/lobe-chat/issues/8994
5
+ */
6
+ export function extractKeyFromUrlOrReturnOriginal(
7
+ url: string,
8
+ getKeyFromFullUrl: (url: string) => string,
9
+ ): string {
10
+ // Only process URLs that start with http:// or https://
11
+ if (url.startsWith('http://') || url.startsWith('https://')) {
12
+ // Extract key from full URL for legacy data compatibility
13
+ return getKeyFromFullUrl(url);
14
+ }
15
+ // Return original input if it's already a key
16
+ return url;
17
+ }