diff --git a/.vscode/settings.json b/.vscode/settings.json
index 6a914c5401a..b8ac40fa3f1 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -59,9 +59,9 @@
"**/src/**/route.ts": "${dirname(1)}/${dirname} • route",
"**/src/**/index.tsx": "${dirname} • component",
- "**/src/database/repositories/*/index.ts": "${dirname} • db repository",
- "**/src/database/models/*.ts": "${filename} • db model",
- "**/src/database/schemas/*.ts": "${filename} • db schema",
+ "**/packages/database/src/repositories/*/index.ts": "${dirname} • db repository",
+ "**/packages/database/src/models/*.ts": "${filename} • db model",
+ "**/packages/database/src/schemas/*.ts": "${filename} • db schema",
"**/src/services/*.ts": "${filename} • service",
"**/src/services/*/client.ts": "${dirname} • client service",
@@ -81,7 +81,7 @@
"**/src/store/*/slices/*/reducer.ts": "${dirname(2)}/${dirname} • reducer",
"**/src/config/modelProviders/*.ts": "${filename} • provider",
- "**/packages/model-bank/src/aiModels/aiModels/*.ts": "${filename} • model",
+ "**/packages/model-bank/src/aiModels/*.ts": "${filename} • model",
"**/packages/model-runtime/src/*/index.ts": "${dirname} • runtime",
"**/src/server/services/*/index.ts": "${dirname} • server/service",
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d441f6642ed..9148bf8e4bf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,56 @@
# Changelog
+### [Version 1.120.6](https://github.com/lobehub/lobe-chat/compare/v1.120.5...v1.120.6)
+
+Released on **2025-09-01**
+
+#### 💄 Styles
+
+- **misc**: Add upload hint for non-visual model.
+
+
+
+
+Improvements and Fixes
+
+#### Styles
+
+- **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))
+
+
+
+
+
+[](#readme-top)
+
+
+
+### [Version 1.120.5](https://github.com/lobehub/lobe-chat/compare/v1.120.4...v1.120.5)
+
+Released on **2025-09-01**
+
+#### 🐛 Bug Fixes
+
+- **ai-image**: Save config.imageUrl with fullUrl instead of key.
+
+
+
+
+Improvements and Fixes
+
+#### What's fixed
+
+- **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))
+
+
+
+
+
+[](#readme-top)
+
+
+
### [Version 1.120.4](https://github.com/lobehub/lobe-chat/compare/v1.120.3...v1.120.4)
Released on **2025-09-01**
diff --git a/changelog/v1.json b/changelog/v1.json
index 4c01b444162..9b87b0d97ea 100644
--- a/changelog/v1.json
+++ b/changelog/v1.json
@@ -1,4 +1,16 @@
[
+ {
+ "children": {
+ "improvements": ["Add upload hint for non-visual model."]
+ },
+ "date": "2025-09-01",
+ "version": "1.120.6"
+ },
+ {
+ "children": {},
+ "date": "2025-09-01",
+ "version": "1.120.5"
+ },
{
"children": {
"improvements": ["Adjust ControlsForm component to adapt to mobile phone display."]
diff --git a/package.json b/package.json
index d1ecf05da66..ff1d902b636 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@lobehub/chat",
- "version": "1.120.4",
+ "version": "1.120.6",
"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.",
"keywords": [
"framework",
diff --git a/packages/model-bank/src/standard-parameters/index.ts b/packages/model-bank/src/standard-parameters/index.ts
index 961b23bc54c..5e12c187e55 100644
--- a/packages/model-bank/src/standard-parameters/index.ts
+++ b/packages/model-bank/src/standard-parameters/index.ts
@@ -45,8 +45,8 @@ export const DEFAULT_DIMENSION_CONSTRAINTS = {
} as const;
export const CHAT_MODEL_IMAGE_GENERATION_PARAMS: ModelParamsSchema = {
- imageUrl: {
- default: null,
+ imageUrls: {
+ default: [],
},
prompt: { default: '' },
};
diff --git a/src/components/DragUpload/useDragUpload.test.tsx b/src/components/DragUpload/useDragUpload.test.tsx
index b7264b46b81..7f1fb0bfbc8 100644
--- a/src/components/DragUpload/useDragUpload.test.tsx
+++ b/src/components/DragUpload/useDragUpload.test.tsx
@@ -1,15 +1,43 @@
import { act, renderHook } from '@testing-library/react';
+import { App } from 'antd';
import { Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { useModelSupportVision } from '@/hooks/useModelSupportVision';
+import { useAgentStore } from '@/store/agent';
+import { agentSelectors } from '@/store/agent/slices/chat';
+
import { getContainer, useDragUpload } from './useDragUpload';
+// Mock the hooks and components
+vi.mock('@/hooks/useModelSupportVision');
+vi.mock('@/store/agent');
+vi.mock('antd', () => ({
+ App: {
+ useApp: () => ({
+ message: {
+ warning: vi.fn(),
+ },
+ }),
+ },
+}));
+
describe('useDragUpload', () => {
let mockOnUploadFiles: Mock;
+ let mockMessage: { warning: Mock };
beforeEach(() => {
mockOnUploadFiles = vi.fn();
+ mockMessage = { warning: vi.fn() };
vi.useFakeTimers();
document.body.innerHTML = '';
+
+ // Mock the hooks
+ (useModelSupportVision as Mock).mockReturnValue(false);
+ (useAgentStore as unknown as Mock).mockImplementation((selector) => {
+ if (selector === agentSelectors.currentAgentModel) return 'test-model';
+ if (selector === agentSelectors.currentAgentModelProvider) return 'test-provider';
+ return null;
+ });
});
afterEach(() => {
@@ -115,6 +143,89 @@ describe('useDragUpload', () => {
expect(mockOnUploadFiles).toHaveBeenCalledWith([mockFile]);
});
+
+ it('should show warning when dropping image file with vision not supported', async () => {
+ renderHook(() => useDragUpload(mockOnUploadFiles));
+
+ const mockImageFile = new File([''], 'test.png', { type: 'image/png' });
+ const dropEvent = new Event('drop') as DragEvent;
+ Object.defineProperty(dropEvent, 'dataTransfer', {
+ value: {
+ items: [
+ {
+ kind: 'file',
+ getAsFile: () => mockImageFile,
+ webkitGetAsEntry: () => ({
+ isFile: true,
+ file: (cb: (file: File) => void) => cb(mockImageFile),
+ }),
+ },
+ ],
+ types: ['Files'],
+ },
+ });
+
+ await act(async () => {
+ window.dispatchEvent(dropEvent);
+ });
+
+ expect(mockOnUploadFiles).not.toHaveBeenCalled();
+ });
+
+ it('should show warning when pasting image file with vision not supported', async () => {
+ renderHook(() => useDragUpload(mockOnUploadFiles));
+
+ const mockImageFile = new File([''], 'test.png', { type: 'image/png' });
+ const pasteEvent = new Event('paste') as ClipboardEvent;
+ Object.defineProperty(pasteEvent, 'clipboardData', {
+ value: {
+ items: [
+ {
+ kind: 'file',
+ getAsFile: () => mockImageFile,
+ webkitGetAsEntry: () => null,
+ },
+ ],
+ },
+ });
+
+ await act(async () => {
+ window.dispatchEvent(pasteEvent);
+ });
+
+ expect(mockOnUploadFiles).not.toHaveBeenCalled();
+ });
+
+ it('should allow image files when vision is supported', async () => {
+ (useModelSupportVision as Mock).mockReturnValue(true);
+
+ renderHook(() => useDragUpload(mockOnUploadFiles));
+
+ const mockImageFile = new File([''], 'test.png', { type: 'image/png' });
+ const dropEvent = new Event('drop') as DragEvent;
+ Object.defineProperty(dropEvent, 'dataTransfer', {
+ value: {
+ items: [
+ {
+ kind: 'file',
+ getAsFile: () => mockImageFile,
+ webkitGetAsEntry: () => ({
+ isFile: true,
+ file: (cb: (file: File) => void) => cb(mockImageFile),
+ }),
+ },
+ ],
+ types: ['Files'],
+ },
+ });
+
+ await act(async () => {
+ window.dispatchEvent(dropEvent);
+ });
+
+ expect(mockOnUploadFiles).toHaveBeenCalledWith([mockImageFile]);
+ expect(App.useApp().message.warning).not.toHaveBeenCalled();
+ });
});
describe('getContainer', () => {
diff --git a/src/components/DragUpload/useDragUpload.tsx b/src/components/DragUpload/useDragUpload.tsx
index 2c0cc080d20..962015a1abc 100644
--- a/src/components/DragUpload/useDragUpload.tsx
+++ b/src/components/DragUpload/useDragUpload.tsx
@@ -1,5 +1,11 @@
/* eslint-disable no-undef */
+import { App } from 'antd';
import { useEffect, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { useModelSupportVision } from '@/hooks/useModelSupportVision';
+import { useAgentStore } from '@/store/agent';
+import { agentSelectors } from '@/store/agent/selectors';
const DRAGGING_ROOT_ID = 'dragging-root';
export const getContainer = () => document.querySelector(`#${DRAGGING_ROOT_ID}`);
@@ -62,12 +68,18 @@ const getFileListFromDataTransferItems = async (items: DataTransferItem[]) => {
};
export const useDragUpload = (onUploadFiles: (files: File[]) => Promise) => {
+ const { t } = useTranslation('chat');
+ const { message } = App.useApp();
const [isDragging, setIsDragging] = useState(false);
// When a file is dragged to a different area, the 'dragleave' event may be triggered,
// causing isDragging to be mistakenly set to false.
// to fix this issue, use a counter to ensure the status change only when drag event left the browser window .
const dragCounter = useRef(0);
+ const model = useAgentStore(agentSelectors.currentAgentModel);
+ const provider = useAgentStore(agentSelectors.currentAgentModelProvider);
+ const supportVision = useModelSupportVision(model, provider);
+
const handleDragEnter = (e: DragEvent) => {
if (!e.dataTransfer?.items || e.dataTransfer.items.length === 0) return;
@@ -113,6 +125,13 @@ export const useDragUpload = (onUploadFiles: (files: File[]) => Promise) =
if (files.length === 0) return;
+ // 检查是否有图片文件且模型不支持视觉功能
+ const hasImageFiles = files.some((file) => file.type.startsWith('image/'));
+ if (hasImageFiles && !supportVision) {
+ message.warning(t('upload.clientMode.visionNotSupported'));
+ return;
+ }
+
// upload files
onUploadFiles(files);
};
@@ -125,6 +144,13 @@ export const useDragUpload = (onUploadFiles: (files: File[]) => Promise) =
const files = await getFileListFromDataTransferItems(items);
if (files.length === 0) return;
+ // 检查是否有图片文件且模型不支持视觉功能
+ const hasImageFiles = files.some((file) => file.type.startsWith('image/'));
+ if (hasImageFiles && !supportVision) {
+ message.warning(t('upload.clientMode.visionNotSupported'));
+ return;
+ }
+
onUploadFiles(files);
};
diff --git a/src/locales/default/chat.ts b/src/locales/default/chat.ts
index 7a6ef1ba0b1..09499241206 100644
--- a/src/locales/default/chat.ts
+++ b/src/locales/default/chat.ts
@@ -278,6 +278,7 @@ export default {
actionFiletip: '上传文件',
actionTooltip: '上传',
disabled: '当前模型不支持视觉识别和文件分析,请切换模型后使用',
+ visionNotSupported: '当前模型不支持视觉识别,请切换模型后使用',
},
preview: {
prepareTasks: '准备分块...',
diff --git a/src/server/routers/lambda/__tests__/image.test.ts b/src/server/routers/lambda/__tests__/image.test.ts
new file mode 100644
index 00000000000..aed5a5f8fd8
--- /dev/null
+++ b/src/server/routers/lambda/__tests__/image.test.ts
@@ -0,0 +1,138 @@
+import { describe, expect, it } from 'vitest';
+
+// Copy of the validation function from image.ts for testing
+function validateNoUrlsInConfig(obj: any, path: string = ''): void {
+ if (typeof obj === 'string') {
+ if (obj.startsWith('http://') || obj.startsWith('https://')) {
+ throw new Error(
+ `Invalid configuration: Found full URL instead of key at ${path || 'root'}. ` +
+ `URL: "${obj.slice(0, 100)}${obj.length > 100 ? '...' : ''}". ` +
+ `All URLs must be converted to storage keys before database insertion.`,
+ );
+ }
+ } else if (Array.isArray(obj)) {
+ obj.forEach((item, index) => {
+ validateNoUrlsInConfig(item, `${path}[${index}]`);
+ });
+ } else if (obj && typeof obj === 'object') {
+ Object.entries(obj).forEach(([key, value]) => {
+ const currentPath = path ? `${path}.${key}` : key;
+ validateNoUrlsInConfig(value, currentPath);
+ });
+ }
+}
+
+describe('imageRouter', () => {
+ describe('validateNoUrlsInConfig utility', () => {
+ describe('valid configurations', () => {
+ it('should pass with normal keys', () => {
+ const config = {
+ imageUrl: 'images/photo.jpg',
+ imageUrls: ['files/doc.pdf', 'assets/video.mp4'],
+ prompt: 'Generate an image',
+ };
+
+ expect(() => validateNoUrlsInConfig(config)).not.toThrow();
+ });
+
+ it('should pass with empty strings', () => {
+ const config = {
+ imageUrl: '',
+ imageUrls: [],
+ prompt: 'Generate an image',
+ };
+
+ expect(() => validateNoUrlsInConfig(config)).not.toThrow();
+ });
+
+ it('should pass with null/undefined values', () => {
+ const config = {
+ imageUrl: null,
+ imageUrls: undefined,
+ prompt: 'Generate an image',
+ };
+
+ expect(() => validateNoUrlsInConfig(config)).not.toThrow();
+ });
+ });
+
+ describe('invalid configurations', () => {
+ it('should throw for https URL in imageUrl', () => {
+ const config = {
+ imageUrl: 'https://s3.amazonaws.com/bucket/image.jpg',
+ prompt: 'Generate an image',
+ };
+
+ expect(() => validateNoUrlsInConfig(config)).toThrow(
+ 'Invalid configuration: Found full URL instead of key at imageUrl',
+ );
+ });
+
+ it('should throw for http URL in imageUrls array', () => {
+ const config = {
+ imageUrls: ['files/doc.pdf', 'http://example.com/image.jpg'],
+ prompt: 'Generate an image',
+ };
+
+ expect(() => validateNoUrlsInConfig(config)).toThrow(
+ 'Invalid configuration: Found full URL instead of key at imageUrls[1]',
+ );
+ });
+
+ it('should throw for nested URL in complex object', () => {
+ const config = {
+ settings: {
+ imageConfig: {
+ url: 'https://cdn.example.com/very-long-url-that-exceeds-100-characters-to-test-truncation-functionality.jpg',
+ },
+ },
+ };
+
+ expect(() => validateNoUrlsInConfig(config)).toThrow(
+ 'Invalid configuration: Found full URL instead of key at settings.imageConfig.url',
+ );
+ expect(() => validateNoUrlsInConfig(config)).toThrow(
+ 'https://cdn.example.com/very-long-url-that-exceeds-100-characters-to-test-truncation-func',
+ );
+ });
+
+ it('should throw for presigned URL with query parameters', () => {
+ const config = {
+ imageUrl:
+ 'https://s3.amazonaws.com/bucket/file.jpg?X-Amz-Signature=abc&X-Amz-Expires=3600',
+ };
+
+ expect(() => validateNoUrlsInConfig(config)).toThrow(
+ 'All URLs must be converted to storage keys before database insertion',
+ );
+ });
+ });
+
+ describe('edge cases', () => {
+ it('should handle deeply nested structures', () => {
+ const config = {
+ level1: {
+ level2: {
+ level3: {
+ level4: ['normal-key', 'https://bad-url.com'],
+ },
+ },
+ },
+ };
+
+ expect(() => validateNoUrlsInConfig(config)).toThrow(
+ 'Invalid configuration: Found full URL instead of key at level1.level2.level3.level4[1]',
+ );
+ });
+
+ it('should not throw for strings that contain but do not start with http', () => {
+ const config = {
+ imageUrl: 'some-prefix-https://example.com',
+ description: 'This text contains http:// but is not a URL',
+ };
+
+ expect(() => validateNoUrlsInConfig(config)).not.toThrow();
+ });
+ });
+ });
+});
diff --git a/src/server/routers/lambda/image.ts b/src/server/routers/lambda/image.ts
index 9be43dd3606..20ca462c0c9 100644
--- a/src/server/routers/lambda/image.ts
+++ b/src/server/routers/lambda/image.ts
@@ -24,6 +24,31 @@ import { generateUniqueSeeds } from '@/utils/number';
const log = debug('lobe-image:lambda');
+/**
+ * Recursively validate that no full URLs are present in the config
+ * This is a defensive check to ensure only keys are stored in database
+ */
+function validateNoUrlsInConfig(obj: any, path: string = ''): void {
+ if (typeof obj === 'string') {
+ if (obj.startsWith('http://') || obj.startsWith('https://')) {
+ throw new Error(
+ `Invalid configuration: Found full URL instead of key at ${path || 'root'}. ` +
+ `URL: "${obj.slice(0, 100)}${obj.length > 100 ? '...' : ''}". ` +
+ `All URLs must be converted to storage keys before database insertion.`,
+ );
+ }
+ } else if (Array.isArray(obj)) {
+ obj.forEach((item, index) => {
+ validateNoUrlsInConfig(item, `${path}[${index}]`);
+ });
+ } else if (obj && typeof obj === 'object') {
+ Object.entries(obj).forEach(([key, value]) => {
+ const currentPath = path ? `${path}.${key}` : key;
+ validateNoUrlsInConfig(value, currentPath);
+ });
+ }
+}
+
const imageProcedure = authedProcedure
.use(keyVaults)
.use(serverDatabase)
@@ -71,8 +96,9 @@ export const imageRouter = router({
log('Starting image creation process, input: %O', input);
- // 如果 params 中包含 imageUrls,将它们转换为 S3 keys 用于数据库存储
+ // 规范化参考图地址,统一存储 S3 key(避免把会过期的预签名 URL 存进数据库)
let configForDatabase = { ...params };
+ // 1) 处理多图 imageUrls
if (Array.isArray(params.imageUrls) && params.imageUrls.length > 0) {
log('Converting imageUrls to S3 keys for database storage: %O', params.imageUrls);
try {
@@ -82,18 +108,30 @@ export const imageRouter = router({
return key;
});
- // 将转换后的 keys 存储为数据库配置
configForDatabase = {
- ...params,
+ ...configForDatabase,
imageUrls: imageKeys,
};
log('Successfully converted imageUrls to keys for database: %O', imageKeys);
} catch (error) {
log('Error converting imageUrls to keys: %O', error);
- // 如果转换失败,保持原始 URLs(可能是本地文件或其他格式)
log('Keeping original imageUrls due to conversion error');
}
}
+ // 2) 处理单图 imageUrl
+ if (typeof params.imageUrl === 'string' && params.imageUrl) {
+ try {
+ const key = fileService.getKeyFromFullUrl(params.imageUrl);
+ log('Converted single imageUrl to key: %s -> %s', params.imageUrl, key);
+ configForDatabase = { ...configForDatabase, imageUrl: key };
+ } catch (error) {
+ log('Error converting imageUrl to key: %O', error);
+ // 转换失败则保留原始值
+ }
+ }
+
+ // 防御性检测:确保没有完整URL进入数据库
+ validateNoUrlsInConfig(configForDatabase, 'configForDatabase');
// 步骤 1: 在事务中原子性地创建所有数据库记录
const { batch: createdBatch, generationsWithTasks } = await serverDB.transaction(async (tx) => {
diff --git a/src/server/services/file/impls/local.test.ts b/src/server/services/file/impls/local.test.ts
index e4faddd7398..39ae4f36c8e 100644
--- a/src/server/services/file/impls/local.test.ts
+++ b/src/server/services/file/impls/local.test.ts
@@ -1,5 +1,4 @@
import { existsSync, readFileSync } from 'node:fs';
-import path from 'node:path';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { electronIpcClient } from '@/server/modules/ElectronIPCClient';
@@ -234,6 +233,52 @@ describe('DesktopLocalFileImpl', () => {
// 验证
expect(result).toBe('');
});
+
+ // Legacy bug compatibility tests - https://github.com/lobehub/lobe-chat/issues/8994
+ describe('legacy bug compatibility', () => {
+ it('should handle full URL input by extracting key', async () => {
+ const fullUrl = 'http://localhost:3000/desktop-file/documents/test.txt';
+
+ // Mock getKeyFromFullUrl and getLocalFileUrl
+ vi.spyOn(service, 'getKeyFromFullUrl').mockReturnValue('desktop://documents/test.txt');
+ const getLocalFileUrlSpy = vi.spyOn(service as any, 'getLocalFileUrl');
+ getLocalFileUrlSpy.mockResolvedValueOnce('data:image/png;base64,test');
+
+ const result = await service.getFullFileUrl(fullUrl);
+
+ expect(service.getKeyFromFullUrl).toHaveBeenCalledWith(fullUrl);
+ expect(getLocalFileUrlSpy).toHaveBeenCalledWith('desktop://documents/test.txt');
+ expect(result).toBe('data:image/png;base64,test');
+ });
+
+ it('should handle normal desktop:// key input without extraction', async () => {
+ const key = 'desktop://documents/test.txt';
+
+ const extractSpy = vi.spyOn(service, 'getKeyFromFullUrl');
+ const getLocalFileUrlSpy = vi.spyOn(service as any, 'getLocalFileUrl');
+ getLocalFileUrlSpy.mockResolvedValueOnce('data:image/png;base64,test');
+
+ const result = await service.getFullFileUrl(key);
+
+ expect(extractSpy).not.toHaveBeenCalled();
+ expect(getLocalFileUrlSpy).toHaveBeenCalledWith(key);
+ expect(result).toBe('data:image/png;base64,test');
+ });
+
+ it('should handle https:// URLs for legacy compatibility', async () => {
+ const httpsUrl = 'https://localhost:3000/desktop-file/images/photo.jpg';
+
+ vi.spyOn(service, 'getKeyFromFullUrl').mockReturnValue('desktop://images/photo.jpg');
+ const getLocalFileUrlSpy = vi.spyOn(service as any, 'getLocalFileUrl');
+ getLocalFileUrlSpy.mockResolvedValueOnce('data:image/jpeg;base64,test');
+
+ const result = await service.getFullFileUrl(httpsUrl);
+
+ expect(service.getKeyFromFullUrl).toHaveBeenCalledWith(httpsUrl);
+ expect(getLocalFileUrlSpy).toHaveBeenCalledWith('desktop://images/photo.jpg');
+ expect(result).toBe('data:image/jpeg;base64,test');
+ });
+ });
});
describe('uploadContent', () => {
diff --git a/src/server/services/file/impls/local.ts b/src/server/services/file/impls/local.ts
index a0add64251b..95aa3e940ee 100644
--- a/src/server/services/file/impls/local.ts
+++ b/src/server/services/file/impls/local.ts
@@ -6,6 +6,7 @@ import { electronIpcClient } from '@/server/modules/ElectronIPCClient';
import { inferContentTypeFromImageUrl } from '@/utils/url';
import { FileServiceImpl } from './type';
+import { extractKeyFromUrlOrReturnOriginal } from './utils';
/**
* 桌面应用本地文件服务实现
@@ -127,7 +128,11 @@ export class DesktopLocalFileImpl implements FileServiceImpl {
*/
async getFullFileUrl(url?: string | null): Promise {
if (!url) return '';
- return this.getLocalFileUrl(url);
+
+ // Handle legacy data compatibility using shared utility
+ const key = extractKeyFromUrlOrReturnOriginal(url, this.getKeyFromFullUrl.bind(this));
+
+ return this.getLocalFileUrl(key);
}
/**
diff --git a/src/server/services/file/impls/s3.test.ts b/src/server/services/file/impls/s3.test.ts
index de3bf126f85..46291d47055 100644
--- a/src/server/services/file/impls/s3.test.ts
+++ b/src/server/services/file/impls/s3.test.ts
@@ -65,6 +65,56 @@ describe('S3StaticFileImpl', () => {
);
config.S3_ENABLE_PATH_STYLE = false;
});
+
+ // Legacy bug compatibility tests - https://github.com/lobehub/lobe-chat/issues/8994
+ describe('legacy bug compatibility', () => {
+ it('should handle full URL input by extracting key (S3_SET_ACL=false)', async () => {
+ config.S3_SET_ACL = false;
+ const fullUrl = 'https://s3.example.com/bucket/path/to/file.jpg?X-Amz-Signature=expired';
+
+ // Mock getKeyFromFullUrl to return the extracted key
+ vi.spyOn(fileService, 'getKeyFromFullUrl').mockReturnValue('path/to/file.jpg');
+
+ const result = await fileService.getFullFileUrl(fullUrl);
+
+ expect(fileService.getKeyFromFullUrl).toHaveBeenCalledWith(fullUrl);
+ expect(result).toBe('https://presigned.example.com/test.jpg');
+ config.S3_SET_ACL = true;
+ });
+
+ it('should handle full URL input by extracting key (S3_SET_ACL=true)', async () => {
+ const fullUrl = 'https://s3.example.com/bucket/path/to/file.jpg';
+
+ vi.spyOn(fileService, 'getKeyFromFullUrl').mockReturnValue('path/to/file.jpg');
+
+ const result = await fileService.getFullFileUrl(fullUrl);
+
+ expect(fileService.getKeyFromFullUrl).toHaveBeenCalledWith(fullUrl);
+ expect(result).toBe('https://example.com/path/to/file.jpg');
+ });
+
+ it('should handle normal key input without extraction', async () => {
+ const key = 'path/to/file.jpg';
+
+ const spy = vi.spyOn(fileService, 'getKeyFromFullUrl');
+
+ const result = await fileService.getFullFileUrl(key);
+
+ expect(spy).not.toHaveBeenCalled();
+ expect(result).toBe('https://example.com/path/to/file.jpg');
+ });
+
+ it('should handle http:// URLs for legacy compatibility', async () => {
+ const httpUrl = 'http://s3.example.com/bucket/path/to/file.jpg';
+
+ vi.spyOn(fileService, 'getKeyFromFullUrl').mockReturnValue('path/to/file.jpg');
+
+ const result = await fileService.getFullFileUrl(httpUrl);
+
+ expect(fileService.getKeyFromFullUrl).toHaveBeenCalledWith(httpUrl);
+ expect(result).toBe('https://example.com/path/to/file.jpg');
+ });
+ });
});
describe('getFileContent', () => {
diff --git a/src/server/services/file/impls/s3.ts b/src/server/services/file/impls/s3.ts
index 48a86704ebe..2b27eaa8df9 100644
--- a/src/server/services/file/impls/s3.ts
+++ b/src/server/services/file/impls/s3.ts
@@ -4,6 +4,7 @@ import { fileEnv } from '@/config/file';
import { S3 } from '@/server/modules/S3';
import { FileServiceImpl } from './type';
+import { extractKeyFromUrlOrReturnOriginal } from './utils';
/**
* 基于S3的文件服务实现
@@ -46,16 +47,19 @@ export class S3StaticFileImpl implements FileServiceImpl {
async getFullFileUrl(url?: string | null, expiresIn?: number): Promise {
if (!url) return '';
+ // Handle legacy data compatibility using shared utility
+ const key = extractKeyFromUrlOrReturnOriginal(url, this.getKeyFromFullUrl.bind(this));
+
// If bucket is not set public read, the preview address needs to be regenerated each time
if (!fileEnv.S3_SET_ACL) {
- return await this.createPreSignedUrlForPreview(url, expiresIn);
+ return await this.createPreSignedUrlForPreview(key, expiresIn);
}
if (fileEnv.S3_ENABLE_PATH_STYLE) {
- return urlJoin(fileEnv.S3_PUBLIC_DOMAIN!, fileEnv.S3_BUCKET!, url);
+ return urlJoin(fileEnv.S3_PUBLIC_DOMAIN!, fileEnv.S3_BUCKET!, key);
}
- return urlJoin(fileEnv.S3_PUBLIC_DOMAIN!, url);
+ return urlJoin(fileEnv.S3_PUBLIC_DOMAIN!, key);
}
getKeyFromFullUrl(url: string): string {
diff --git a/src/server/services/file/impls/utils.test.ts b/src/server/services/file/impls/utils.test.ts
new file mode 100644
index 00000000000..ff6ff303c29
--- /dev/null
+++ b/src/server/services/file/impls/utils.test.ts
@@ -0,0 +1,154 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import { extractKeyFromUrlOrReturnOriginal } from './utils';
+
+describe('extractKeyFromUrlOrReturnOriginal', () => {
+ const mockGetKeyFromFullUrl = vi.fn();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('URL detection', () => {
+ it('should detect https:// URLs and extract key', () => {
+ const httpsUrl = 'https://s3.example.com/bucket/path/to/file.jpg';
+ const expectedKey = 'path/to/file.jpg';
+
+ mockGetKeyFromFullUrl.mockReturnValue(expectedKey);
+
+ const result = extractKeyFromUrlOrReturnOriginal(httpsUrl, mockGetKeyFromFullUrl);
+
+ expect(mockGetKeyFromFullUrl).toHaveBeenCalledWith(httpsUrl);
+ expect(result).toBe(expectedKey);
+ });
+
+ it('should detect http:// URLs and extract key', () => {
+ const httpUrl = 'http://s3.example.com/bucket/path/to/file.jpg';
+ const expectedKey = 'path/to/file.jpg';
+
+ mockGetKeyFromFullUrl.mockReturnValue(expectedKey);
+
+ const result = extractKeyFromUrlOrReturnOriginal(httpUrl, mockGetKeyFromFullUrl);
+
+ expect(mockGetKeyFromFullUrl).toHaveBeenCalledWith(httpUrl);
+ expect(result).toBe(expectedKey);
+ });
+
+ it('should handle presigned URLs with query parameters', () => {
+ const presignedUrl = 'https://s3.amazonaws.com/bucket/file.jpg?X-Amz-Signature=abc&X-Amz-Expires=3600';
+ const expectedKey = 'file.jpg';
+
+ mockGetKeyFromFullUrl.mockReturnValue(expectedKey);
+
+ const result = extractKeyFromUrlOrReturnOriginal(presignedUrl, mockGetKeyFromFullUrl);
+
+ expect(mockGetKeyFromFullUrl).toHaveBeenCalledWith(presignedUrl);
+ expect(result).toBe(expectedKey);
+ });
+ });
+
+ describe('key passthrough', () => {
+ it('should return original string for plain keys', () => {
+ const key = 'path/to/file.jpg';
+
+ const result = extractKeyFromUrlOrReturnOriginal(key, mockGetKeyFromFullUrl);
+
+ expect(mockGetKeyFromFullUrl).not.toHaveBeenCalled();
+ expect(result).toBe(key);
+ });
+
+ it('should return original string for desktop:// keys', () => {
+ const desktopKey = 'desktop://documents/file.pdf';
+
+ const result = extractKeyFromUrlOrReturnOriginal(desktopKey, mockGetKeyFromFullUrl);
+
+ expect(mockGetKeyFromFullUrl).not.toHaveBeenCalled();
+ expect(result).toBe(desktopKey);
+ });
+
+ it('should return original string for relative paths', () => {
+ const relativePath = './assets/image.png';
+
+ const result = extractKeyFromUrlOrReturnOriginal(relativePath, mockGetKeyFromFullUrl);
+
+ expect(mockGetKeyFromFullUrl).not.toHaveBeenCalled();
+ expect(result).toBe(relativePath);
+ });
+
+ it('should return original string for file:// protocol', () => {
+ const fileUrl = 'file:///Users/test/file.txt';
+
+ const result = extractKeyFromUrlOrReturnOriginal(fileUrl, mockGetKeyFromFullUrl);
+
+ expect(mockGetKeyFromFullUrl).not.toHaveBeenCalled();
+ expect(result).toBe(fileUrl);
+ });
+ });
+
+ describe('edge cases', () => {
+ it('should handle empty string', () => {
+ const emptyString = '';
+
+ const result = extractKeyFromUrlOrReturnOriginal(emptyString, mockGetKeyFromFullUrl);
+
+ expect(mockGetKeyFromFullUrl).not.toHaveBeenCalled();
+ expect(result).toBe(emptyString);
+ });
+
+ it('should handle strings that start with http but are not URLs', () => {
+ const notUrl = 'httpish-string';
+
+ const result = extractKeyFromUrlOrReturnOriginal(notUrl, mockGetKeyFromFullUrl);
+
+ expect(mockGetKeyFromFullUrl).not.toHaveBeenCalled();
+ expect(result).toBe(notUrl);
+ });
+
+ it('should handle case sensitivity correctly', () => {
+ const upperCaseHttps = 'HTTPS://example.com/file.jpg';
+
+ const result = extractKeyFromUrlOrReturnOriginal(upperCaseHttps, mockGetKeyFromFullUrl);
+
+ expect(mockGetKeyFromFullUrl).not.toHaveBeenCalled();
+ expect(result).toBe(upperCaseHttps);
+ });
+ });
+
+ describe('legacy bug scenarios', () => {
+ it('should handle S3 public URLs', () => {
+ const s3PublicUrl = 'https://mybucket.s3.amazonaws.com/images/photo.jpg';
+ const expectedKey = 'images/photo.jpg';
+
+ mockGetKeyFromFullUrl.mockReturnValue(expectedKey);
+
+ const result = extractKeyFromUrlOrReturnOriginal(s3PublicUrl, mockGetKeyFromFullUrl);
+
+ expect(mockGetKeyFromFullUrl).toHaveBeenCalledWith(s3PublicUrl);
+ expect(result).toBe(expectedKey);
+ });
+
+ it('should handle custom domain S3 URLs', () => {
+ const customDomainUrl = 'https://cdn.example.com/files/document.pdf';
+ const expectedKey = 'files/document.pdf';
+
+ mockGetKeyFromFullUrl.mockReturnValue(expectedKey);
+
+ const result = extractKeyFromUrlOrReturnOriginal(customDomainUrl, mockGetKeyFromFullUrl);
+
+ expect(mockGetKeyFromFullUrl).toHaveBeenCalledWith(customDomainUrl);
+ expect(result).toBe(expectedKey);
+ });
+
+ it('should handle local development URLs', () => {
+ const localUrl = 'http://localhost:3000/desktop-file/images/screenshot.png';
+ const expectedKey = 'desktop://images/screenshot.png';
+
+ mockGetKeyFromFullUrl.mockReturnValue(expectedKey);
+
+ const result = extractKeyFromUrlOrReturnOriginal(localUrl, mockGetKeyFromFullUrl);
+
+ expect(mockGetKeyFromFullUrl).toHaveBeenCalledWith(localUrl);
+ expect(result).toBe(expectedKey);
+ });
+ });
+});
\ No newline at end of file
diff --git a/src/server/services/file/impls/utils.ts b/src/server/services/file/impls/utils.ts
new file mode 100644
index 00000000000..127865e1af6
--- /dev/null
+++ b/src/server/services/file/impls/utils.ts
@@ -0,0 +1,17 @@
+/**
+ * Handle legacy bug where full URLs were stored instead of keys
+ * Some historical data stored complete URLs in database instead of just keys
+ * Related issue: https://github.com/lobehub/lobe-chat/issues/8994
+ */
+export function extractKeyFromUrlOrReturnOriginal(
+ url: string,
+ getKeyFromFullUrl: (url: string) => string,
+): string {
+ // Only process URLs that start with http:// or https://
+ if (url.startsWith('http://') || url.startsWith('https://')) {
+ // Extract key from full URL for legacy data compatibility
+ return getKeyFromFullUrl(url);
+ }
+ // Return original input if it's already a key
+ return url;
+}
\ No newline at end of file