`',
+ cmdFeedbackError: "Couldn't send your feedback right now. Please try again in a moment.",
+ cmdFeedbackSubmitted: 'Thanks — your feedback has been sent to the LobeHub team.',
+ cmdFeedbackSubmittedWithLink: (issueUrl) =>
+ `Thanks — your feedback has been sent to the LobeHub team. Tracked at: ${issueUrl}`,
+ cmdFeedbackUsage:
+ 'Usage: `/feedback ` — sends feedback directly to the LobeHub team (no AI reply).',
cmdNewReset: 'Conversation reset. Your next message will start a new topic.',
cmdStopNotActive: 'No active execution to stop.',
cmdStopRequested: 'Stop requested.',
@@ -318,6 +328,12 @@ const SYSTEM_STRINGS: Partial> = {
cmdApproveSuccess: (label) => `已审批 ${label}。`,
cmdApproveUnknownCode: '该配对码不存在或已过期。',
cmdApproveUsage: '用法:`/approve <配对码>`',
+ cmdFeedbackError: '发送反馈失败,请稍后再试。',
+ cmdFeedbackSubmitted: '已收到,感谢反馈,已转交 LobeHub 团队。',
+ cmdFeedbackSubmittedWithLink: (issueUrl) =>
+ `已收到,感谢反馈,已转交 LobeHub 团队。跟踪链接:${issueUrl}`,
+ cmdFeedbackUsage:
+ '用法:`/feedback <你的反馈内容>` —— 反馈会直达 LobeHub 团队,不会触发 AI 回复。',
cmdNewReset: '对话已重置,下一条消息会开启新话题。',
cmdStopNotActive: '当前没有正在执行的任务可以停止。',
cmdStopRequested: '已发出停止请求。',
@@ -467,6 +483,9 @@ export type CommandReplyKey =
| 'cmdApproveNotOwner'
| 'cmdApproveUnknownCode'
| 'cmdApproveUsage'
+ | 'cmdFeedbackError'
+ | 'cmdFeedbackSubmitted'
+ | 'cmdFeedbackUsage'
| 'cmdNewReset'
| 'cmdStopNotActive'
| 'cmdStopRequested'
@@ -491,6 +510,17 @@ export function renderApproveSuccess(label: string, lng?: BotReplyLocale): strin
return getSystemStrings(lng).cmdApproveSuccess(label);
}
+/**
+ * Render the `/feedback` success reply. When the feedback backend returns a
+ * tracked issue URL, surface it so the user knows where to follow up — for
+ * Slack / Discord that surface autolinks the URL, on Telegram it remains
+ * tappable in monospace.
+ */
+export function renderFeedbackSubmitted(issueUrl?: string, lng?: BotReplyLocale): string {
+ const strings = getSystemStrings(lng);
+ return issueUrl ? strings.cmdFeedbackSubmittedWithLink(issueUrl) : strings.cmdFeedbackSubmitted;
+}
+
/**
* Render the system message a stranger sees after their first DM when the
* bot is in pairing mode. Variants:
diff --git a/src/server/services/messenger/MessengerRouter.ts b/src/server/services/messenger/MessengerRouter.ts
index 9f6b62f765..cb4b4c241c 100644
--- a/src/server/services/messenger/MessengerRouter.ts
+++ b/src/server/services/messenger/MessengerRouter.ts
@@ -20,8 +20,13 @@ import { getAgentRuntimeRedisClient } from '@/server/modules/AgentRuntime/redis'
import { AiAgentService } from '@/server/services/aiAgent';
import { AgentBridgeService } from '@/server/services/bot/AgentBridgeService';
import { buildBotContext } from '@/server/services/bot/buildBotContext';
+import { submitBotFeedback } from '@/server/services/bot/feedbackSubmit';
import type { PlatformClient } from '@/server/services/bot/platforms';
-import { renderInlineError } from '@/server/services/bot/replyTemplate';
+import {
+ renderCommandReply,
+ renderFeedbackSubmitted,
+ renderInlineError,
+} from '@/server/services/bot/replyTemplate';
import { getInstallationStore } from './installations';
import type { InstallationCredentials } from './installations/types';
@@ -93,6 +98,19 @@ interface MessengerCommand {
description: string;
handler: (ctx: MessengerCommandContext) => Promise;
name: string;
+ /**
+ * Native slash-command argument schema for platforms that require
+ * arguments to be declared up-front (Discord, Slack). Without this,
+ * Discord registers the command as zero-arg — clicking it from the
+ * slash menu fires the handler with no value field shown to the user.
+ * Mirrors `BotCommand.options` in `BotMessageRouter` so command authors
+ * use the same shape across both routers.
+ */
+ options?: Array<{
+ description: string;
+ name: string;
+ required?: boolean;
+ }>;
}
const HELP_TEXT = [
@@ -101,6 +119,7 @@ const HELP_TEXT = [
'• /agents — list your agents and switch the active one',
'• /new — start a new conversation',
'• /stop — stop the current execution',
+ '• /feedback — send feedback to the LobeHub team (no AI reply)',
].join('\n');
/**
@@ -323,7 +342,17 @@ export class MessengerRouter {
if (client.registerBotCommands) {
client
.registerBotCommands(
- this.commands.map((cmd) => ({ command: cmd.name, description: cmd.description })),
+ this.commands.map((cmd) => ({
+ command: cmd.name,
+ description: cmd.description,
+ // Forward the option schema so Discord/Slack surface required
+ // arguments (e.g. `/feedback message:`) in the slash picker.
+ // Without this, the command registers as zero-arg and the
+ // user can fire it without providing the required text — the
+ // handler then sees empty `args` and falls back to the usage
+ // hint. Mirrors `BotMessageRouter.createAndRegisterBot`.
+ options: cmd.options,
+ })),
)
.catch((error) =>
log('registerBotCommands failed for %s: %O', creds.installationKey, error),
@@ -813,6 +842,45 @@ export class MessengerRouter {
},
name: 'stop',
},
+ {
+ description: 'Send feedback directly to the LobeHub team (no AI reply)',
+ // Declaring the argument so Discord/Slack surface a `/feedback `
+ // prompt; without it the slash picker registers the command as zero-arg
+ // and the user can't enter feedback text from the picker UI.
+ options: [
+ {
+ description: 'Your feedback message',
+ name: 'message',
+ required: true,
+ },
+ ],
+ handler: async (ctx) => {
+ // Feedback is tied to a LobeHub account so the team can follow up;
+ // an unbound user has no email/identity to attach. Mirror the
+ // `/new` / `/stop` "you need to /start" guard for consistency.
+ if (!ctx.link) {
+ await ctx.reply('You need to /start to bind your account first.');
+ return;
+ }
+ const body = ctx.args.trim();
+ if (!body) {
+ await ctx.reply(renderCommandReply('cmdFeedbackUsage'));
+ return;
+ }
+ const result = await submitBotFeedback(ctx.serverDB, {
+ body,
+ platform: ctx.platform,
+ threadId: ctx.thread?.id ?? ctx.chatId,
+ userId: ctx.link.userId,
+ });
+ if (!result.success) {
+ await ctx.reply(renderCommandReply('cmdFeedbackError'));
+ return;
+ }
+ await ctx.reply(renderFeedbackSubmitted(result.issueUrl));
+ },
+ name: 'feedback',
+ },
{
description: 'Show usage',
handler: async (ctx) => {
From 7144e9de28b6dbb087510d9917b200bfc0bc7449 Mon Sep 17 00:00:00 2001
From: YuTengjing
Date: Wed, 20 May 2026 01:16:54 +0800
Subject: [PATCH 039/224] =?UTF-8?q?=F0=9F=90=9B=20fix:=20resolve=20desktop?=
=?UTF-8?q?=20visual=20media=20urls=20(#14989)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
packages/builtin-tool-lobe-agent/package.json | 3 +-
.../src/client/executor/index.ts | 5 +-
.../executor/resolveVisualMediaUris.test.ts | 147 ++++++++++++++++++
.../client/executor/resolveVisualMediaUris.ts | 58 +++++++
packages/database/src/models/file.ts | 3 +-
.../routers/lambda/__tests__/file.test.ts | 110 +++++++++++++
src/server/routers/lambda/file.ts | 46 +++++-
.../services/file/__tests__/index.test.ts | 60 ++++++-
src/server/services/file/index.ts | 27 +++-
9 files changed, 452 insertions(+), 7 deletions(-)
create mode 100644 packages/builtin-tool-lobe-agent/src/client/executor/resolveVisualMediaUris.test.ts
create mode 100644 packages/builtin-tool-lobe-agent/src/client/executor/resolveVisualMediaUris.ts
diff --git a/packages/builtin-tool-lobe-agent/package.json b/packages/builtin-tool-lobe-agent/package.json
index 5b8aa146f2..02fa3ce1bb 100644
--- a/packages/builtin-tool-lobe-agent/package.json
+++ b/packages/builtin-tool-lobe-agent/package.json
@@ -14,7 +14,8 @@
},
"dependencies": {
"@lobechat/const": "workspace:*",
- "@lobechat/prompts": "workspace:*"
+ "@lobechat/prompts": "workspace:*",
+ "@lobechat/utils": "workspace:*"
},
"devDependencies": {
"@lobechat/types": "workspace:*"
diff --git a/packages/builtin-tool-lobe-agent/src/client/executor/index.ts b/packages/builtin-tool-lobe-agent/src/client/executor/index.ts
index d1dc87a8c9..166244aa83 100644
--- a/packages/builtin-tool-lobe-agent/src/client/executor/index.ts
+++ b/packages/builtin-tool-lobe-agent/src/client/executor/index.ts
@@ -35,6 +35,7 @@ import {
type PlanRuntimeService,
} from './PlanRuntime';
import { getTodosFromContext } from './planTodoHelper';
+import { resolveClientVisualMediaPayloadItems } from './resolveVisualMediaUris';
const PLAN_DOC_TYPE = 'agent/plan';
@@ -289,6 +290,8 @@ class LobeAgentExecutor extends BaseExecutor {
};
}
+ const payloadItems = await resolveClientVisualMediaPayloadItems({ selectedRefs, selectedUrls });
+
let content = '';
let error: { message?: string } | undefined;
let usage: unknown;
@@ -299,7 +302,7 @@ class LobeAgentExecutor extends BaseExecutor {
max_tokens: 2000,
messages: [
{
- content: buildAnalyzeVisualMediaContent(selectedItems, params.question, {
+ content: buildAnalyzeVisualMediaContent(payloadItems, params.question, {
includeFallbackInstruction: true,
includeFileSummary: true,
}),
diff --git a/packages/builtin-tool-lobe-agent/src/client/executor/resolveVisualMediaUris.test.ts b/packages/builtin-tool-lobe-agent/src/client/executor/resolveVisualMediaUris.test.ts
new file mode 100644
index 0000000000..6279a9170a
--- /dev/null
+++ b/packages/builtin-tool-lobe-agent/src/client/executor/resolveVisualMediaUris.test.ts
@@ -0,0 +1,147 @@
+import { imageUrlToBase64 } from '@lobechat/utils/imageToBase64';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import type { VisualFileItem } from '../../visualMedia';
+import {
+ resolveClientVisualMediaPayloadItems,
+ resolveClientVisualMediaUris,
+} from './resolveVisualMediaUris';
+
+vi.mock('@lobechat/utils/imageToBase64', () => ({
+ imageUrlToBase64: vi.fn(),
+}));
+
+const createVisualItem = (item: Partial): VisualFileItem => ({
+ description: 'test.png',
+ localRef: 'image_1',
+ name: 'test.png',
+ ref: 'msg_1.image_1',
+ type: 'image',
+ uri: 'https://example.com/test.png',
+ ...item,
+});
+
+describe('resolveClientVisualMediaUris', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should convert desktop local visual media URLs to data URLs', async () => {
+ vi.mocked(imageUrlToBase64)
+ .mockResolvedValueOnce({
+ base64: 'image-base64',
+ mimeType: 'image/png',
+ })
+ .mockResolvedValueOnce({
+ base64: 'video-base64',
+ mimeType: 'video/mp4',
+ });
+
+ const localImage = createVisualItem({
+ name: 'local.png',
+ uri: 'http://127.0.0.1:3210/uploads/local.png',
+ });
+ const localVideo = createVisualItem({
+ name: 'local.mp4',
+ type: 'video',
+ uri: 'http://127.0.0.1:3210/uploads/local.mp4',
+ });
+ const remoteImage = createVisualItem({
+ name: 'remote.png',
+ uri: 'https://example.com/remote.png',
+ });
+ const dataImage = createVisualItem({
+ name: 'inline.png',
+ uri: 'data:image/png;base64,inline-base64',
+ });
+
+ const result = await resolveClientVisualMediaUris([
+ localImage,
+ localVideo,
+ remoteImage,
+ dataImage,
+ ]);
+
+ expect(result).toEqual([
+ {
+ ...localImage,
+ uri: 'data:image/png;base64,image-base64',
+ },
+ {
+ ...localVideo,
+ uri: 'data:video/mp4;base64,video-base64',
+ },
+ remoteImage,
+ dataImage,
+ ]);
+ expect(imageUrlToBase64).toHaveBeenCalledTimes(2);
+ expect(imageUrlToBase64).toHaveBeenNthCalledWith(1, 'http://127.0.0.1:3210/uploads/local.png');
+ expect(imageUrlToBase64).toHaveBeenNthCalledWith(2, 'http://127.0.0.1:3210/uploads/local.mp4');
+ });
+
+ it('should reject desktop local URLs when fetched MIME type does not match the item type', async () => {
+ vi.mocked(imageUrlToBase64).mockResolvedValue({
+ base64: 'not-found',
+ mimeType: 'text/plain',
+ });
+
+ const localImage = createVisualItem({
+ name: 'missing.png',
+ uri: 'http://127.0.0.1:3210/uploads/missing.png',
+ });
+
+ await expect(resolveClientVisualMediaUris([localImage])).rejects.toThrow(
+ 'Unable to read image attachment "missing.png": expected image/* MIME type, received text/plain.',
+ );
+ });
+
+ it('should reject desktop local video URLs when fetched MIME type is an image', async () => {
+ vi.mocked(imageUrlToBase64).mockResolvedValue({
+ base64: 'poster',
+ mimeType: 'image/png',
+ });
+
+ const localVideo = createVisualItem({
+ name: 'clip.mp4',
+ type: 'video',
+ uri: 'http://127.0.0.1:3210/uploads/clip.mp4',
+ });
+
+ await expect(resolveClientVisualMediaUris([localVideo])).rejects.toThrow(
+ 'Unable to read video attachment "clip.mp4": expected video/* MIME type, received image/png.',
+ );
+ });
+
+ it('should only convert attachment refs when building visual media payload items', async () => {
+ vi.mocked(imageUrlToBase64).mockResolvedValue({
+ base64: 'attachment-base64',
+ mimeType: 'image/png',
+ });
+
+ const localAttachment = createVisualItem({
+ name: 'attachment.png',
+ uri: 'http://127.0.0.1:3210/uploads/attachment.png',
+ });
+ const directLocalUrl = createVisualItem({
+ localRef: 'url_1',
+ name: 'direct.png',
+ ref: 'url_1',
+ uri: 'http://127.0.0.1:3210/private/direct.png',
+ });
+
+ const result = await resolveClientVisualMediaPayloadItems({
+ selectedRefs: [localAttachment],
+ selectedUrls: [directLocalUrl],
+ });
+
+ expect(result).toEqual([
+ {
+ ...localAttachment,
+ uri: 'data:image/png;base64,attachment-base64',
+ },
+ directLocalUrl,
+ ]);
+ expect(imageUrlToBase64).toHaveBeenCalledTimes(1);
+ expect(imageUrlToBase64).toHaveBeenCalledWith('http://127.0.0.1:3210/uploads/attachment.png');
+ });
+});
diff --git a/packages/builtin-tool-lobe-agent/src/client/executor/resolveVisualMediaUris.ts b/packages/builtin-tool-lobe-agent/src/client/executor/resolveVisualMediaUris.ts
new file mode 100644
index 0000000000..327ff58da0
--- /dev/null
+++ b/packages/builtin-tool-lobe-agent/src/client/executor/resolveVisualMediaUris.ts
@@ -0,0 +1,58 @@
+import { imageUrlToBase64 } from '@lobechat/utils/imageToBase64';
+import { parseDataUri } from '@lobechat/utils/uriParser';
+import { isDesktopLocalStaticServerUrl } from '@lobechat/utils/url';
+
+import type { VisualFileItem } from '../../visualMedia';
+
+interface ResolveClientVisualMediaPayloadItemsParams {
+ selectedRefs: VisualFileItem[];
+ selectedUrls: VisualFileItem[];
+}
+
+const VISUAL_MEDIA_MIME_TYPE_PREFIXES = {
+ image: 'image/',
+ video: 'video/',
+} as const satisfies Record;
+
+const assertExpectedVisualMediaMimeType = (item: VisualFileItem, mimeType: string) => {
+ const expectedPrefix = VISUAL_MEDIA_MIME_TYPE_PREFIXES[item.type];
+ const normalizedMimeType = mimeType.trim().toLowerCase();
+
+ if (normalizedMimeType.startsWith(expectedPrefix)) return;
+
+ throw new TypeError(
+ `Unable to read ${item.type} attachment "${item.name}": expected ${expectedPrefix}* MIME type, received ${normalizedMimeType || 'unknown'}.`,
+ );
+};
+
+/**
+ * Desktop attachments are exposed through a 127.0.0.1 static file server.
+ * Convert those URLs in the client before sending a remote visual request;
+ * otherwise the server sees its own localhost and SSRF protection blocks it.
+ */
+export const resolveClientVisualMediaUris = async (
+ items: VisualFileItem[],
+): Promise =>
+ Promise.all(
+ items.map(async (item) => {
+ const { type } = parseDataUri(item.uri);
+
+ if (type !== 'url' || !isDesktopLocalStaticServerUrl(item.uri)) return item;
+
+ const { base64, mimeType } = await imageUrlToBase64(item.uri);
+ assertExpectedVisualMediaMimeType(item, mimeType);
+
+ return {
+ ...item,
+ uri: `data:${mimeType};base64,${base64}`,
+ };
+ }),
+ );
+
+export const resolveClientVisualMediaPayloadItems = async ({
+ selectedRefs,
+ selectedUrls,
+}: ResolveClientVisualMediaPayloadItemsParams): Promise => [
+ ...(await resolveClientVisualMediaUris(selectedRefs)),
+ ...selectedUrls,
+];
diff --git a/packages/database/src/models/file.ts b/packages/database/src/models/file.ts
index 56c3a6bed7..ae81f774c7 100644
--- a/packages/database/src/models/file.ts
+++ b/packages/database/src/models/file.ts
@@ -95,8 +95,9 @@ export class FileModel {
updateGlobalFile = async (
hashId: string,
data: Partial>,
+ trx?: Transaction,
) => {
- return this.db.update(globalFiles).set(data).where(eq(globalFiles.hashId, hashId));
+ return (trx ?? this.db).update(globalFiles).set(data).where(eq(globalFiles.hashId, hashId));
};
checkHash = async (hash: string) => {
diff --git a/src/server/routers/lambda/__tests__/file.test.ts b/src/server/routers/lambda/__tests__/file.test.ts
index 86328f903e..f30f47b8d6 100644
--- a/src/server/routers/lambda/__tests__/file.test.ts
+++ b/src/server/routers/lambda/__tests__/file.test.ts
@@ -29,6 +29,7 @@ function createCallerWithCtx(partialCtx: any = {}) {
query: vi.fn().mockResolvedValue([]),
delete: vi.fn().mockResolvedValue(undefined),
deleteMany: vi.fn().mockResolvedValue([]),
+ updateGlobalFile: vi.fn().mockResolvedValue(undefined),
clear: vi.fn().mockResolvedValue({} as any),
};
@@ -128,6 +129,7 @@ const mockFileModelDeleteMany = vi.fn();
const mockFileModelFindById = vi.fn();
const mockFileModelFindByIds = vi.fn();
const mockFileModelQuery = vi.fn();
+const mockFileModelUpdateGlobalFile = vi.fn();
const mockFileModelClear = vi.fn();
vi.mock('@/database/models/file', () => ({
@@ -139,6 +141,7 @@ vi.mock('@/database/models/file', () => ({
findById: mockFileModelFindById,
findByIds: mockFileModelFindByIds,
query: mockFileModelQuery,
+ updateGlobalFile: mockFileModelUpdateGlobalFile,
clear: mockFileModelClear,
})),
}));
@@ -215,6 +218,47 @@ describe('fileRouter', () => {
ctx.fileModel.checkHash.mockResolvedValue(undefined);
await expect(caller.checkFileHash({ hash: 'test-hash' })).resolves.toBeUndefined();
});
+
+ it('should return existing hash when the stored object is still available', async () => {
+ const checkResult = {
+ isExist: true,
+ metadata: { path: 'files/existing.png' },
+ url: 'files/existing.png',
+ };
+ mockFileModelCheckHash.mockResolvedValue(checkResult);
+ mockFileServiceGetFileMetadata.mockResolvedValue({
+ contentLength: 100,
+ contentType: 'image/png',
+ });
+
+ await expect(caller.checkFileHash({ hash: 'test-hash' })).resolves.toEqual(checkResult);
+
+ expect(mockFileServiceGetFileMetadata).toHaveBeenCalledWith('files/existing.png');
+ });
+
+ it('should treat stale hash records as missing when the stored object is unavailable', async () => {
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ mockFileModelCheckHash.mockResolvedValue({
+ isExist: true,
+ metadata: { path: 'generations/images/missing_raw.jpg' },
+ url: 'generations/images/missing_raw.jpg',
+ });
+ mockFileServiceGetFileMetadata.mockRejectedValue(new Error('NoSuchKey'));
+
+ await expect(caller.checkFileHash({ hash: 'test-hash' })).resolves.toEqual({
+ isExist: false,
+ });
+
+ expect(mockFileServiceGetFileMetadata).toHaveBeenCalledWith(
+ 'generations/images/missing_raw.jpg',
+ );
+ expect(consoleSpy).toHaveBeenCalledWith(
+ 'Failed to verify existing file hash storage object:',
+ expect.any(Error),
+ );
+
+ consoleSpy.mockRestore();
+ });
});
describe('createFile', () => {
@@ -251,6 +295,72 @@ describe('fileRouter', () => {
});
});
+ it('should refresh global file metadata when an existing hash points to a missing object', async () => {
+ mockFileModelCheckHash.mockResolvedValue({
+ isExist: true,
+ metadata: { path: 'old/path.txt' },
+ url: 'old/path.txt',
+ });
+ mockFileModelCreate.mockResolvedValue({ id: 'new-file-id' });
+ mockFileServiceGetFileMetadata
+ .mockResolvedValueOnce({ contentLength: 100, contentType: 'text/plain' })
+ .mockRejectedValueOnce(new Error('NoSuchKey'));
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ await caller.createFile({
+ hash: 'test-hash',
+ fileType: 'text',
+ metadata: { path: 'new/path.txt' },
+ name: 'test.txt',
+ size: 100,
+ url: 'new/path.txt',
+ });
+
+ expect(mockFileModelUpdateGlobalFile).toHaveBeenCalledWith(
+ 'test-hash',
+ {
+ metadata: { path: 'new/path.txt' },
+ url: 'new/path.txt',
+ },
+ routerMocks.transactionClient,
+ );
+ expect(mockFileModelCreate).toHaveBeenCalledWith(
+ expect.objectContaining({ fileHash: 'test-hash', url: 'new/path.txt' }),
+ false,
+ routerMocks.transactionClient,
+ );
+ consoleSpy.mockRestore();
+ });
+
+ it('should keep the global file pointer when an existing hash object is still available', async () => {
+ mockFileModelCheckHash.mockResolvedValue({
+ isExist: true,
+ metadata: { path: 'old/path.txt' },
+ url: 'old/path.txt',
+ });
+ mockFileModelCreate.mockResolvedValue({ id: 'new-file-id' });
+ mockFileServiceGetFileMetadata.mockResolvedValue({
+ contentLength: 100,
+ contentType: 'text/plain',
+ });
+
+ await caller.createFile({
+ hash: 'test-hash',
+ fileType: 'text',
+ metadata: { path: 'new/path.txt' },
+ name: 'test.txt',
+ size: 100,
+ url: 'new/path.txt',
+ });
+
+ expect(mockFileModelUpdateGlobalFile).not.toHaveBeenCalled();
+ expect(mockFileModelCreate).toHaveBeenCalledWith(
+ expect.objectContaining({ fileHash: 'test-hash', url: 'new/path.txt' }),
+ false,
+ routerMocks.transactionClient,
+ );
+ });
+
it('should run business upload check and file creation in the same transaction', async () => {
mockFileModelCheckHash.mockResolvedValue({ isExist: false });
mockFileModelCreate.mockResolvedValue({ id: 'new-file-id' });
diff --git a/src/server/routers/lambda/file.ts b/src/server/routers/lambda/file.ts
index 919bf38df8..46529c5863 100644
--- a/src/server/routers/lambda/file.ts
+++ b/src/server/routers/lambda/file.ts
@@ -102,6 +102,19 @@ const getKnowledgeItemStatusMap = async (
);
};
+const isStoredObjectAvailable = async (fileService: FileService, url: string): Promise => {
+ try {
+ // Hash records can outlive their backing object, for example when generated
+ // assets are cleaned up but the global hash row remains. Treat stale rows as
+ // missing so the client uploads a fresh copy instead of reusing a dead key.
+ await fileService.getFileMetadata(url);
+ return true;
+ } catch (error) {
+ console.error('Failed to verify existing file hash storage object:', error);
+ return false;
+ }
+};
+
const fileProcedure = authedProcedure.use(serverDatabase).use(async (opts) => {
const { ctx } = opts;
@@ -123,7 +136,13 @@ export const fileRouter = router({
.use(checkFileStorageUsage)
.input(z.object({ hash: z.string() }))
.mutation(async ({ ctx, input }) => {
- return ctx.fileModel.checkHash(input.hash);
+ const existingFile = await ctx.fileModel.checkHash(input.hash);
+ const existingHashUrl = existingFile?.isExist ? existingFile.url : undefined;
+ if (!existingHashUrl) return existingFile;
+
+ const isStorageAvailable = await isStoredObjectAvailable(ctx.fileService, existingHashUrl);
+
+ return isStorageAvailable ? existingFile : { isExist: false };
}),
createFile: fileProcedure
@@ -135,7 +154,8 @@ export const fileRouter = router({
}),
)
.mutation(async ({ ctx, input }) => {
- const { isExist } = await ctx.fileModel.checkHash(input.hash!);
+ const existingFile = await ctx.fileModel.checkHash(input.hash!);
+ const { isExist } = existingFile;
// Resolve parentId if it's a slug
let resolvedParentId = input.parentId;
@@ -177,6 +197,28 @@ export const fileRouter = router({
userId: ctx.userId,
});
+ let shouldRefreshGlobalFile = false;
+ if (isExist && existingFile.url && existingFile.url !== input.url) {
+ shouldRefreshGlobalFile = !(await isStoredObjectAvailable(
+ ctx.fileService,
+ existingFile.url,
+ ));
+ }
+
+ if (shouldRefreshGlobalFile) {
+ // A user may re-upload the same bytes after the old object key was
+ // removed. Keep the global hash pointer on the newly uploaded object so
+ // future dedup checks do not resolve back to the stale key.
+ await ctx.fileModel.updateGlobalFile(
+ input.hash!,
+ {
+ metadata: input.metadata,
+ url: input.url,
+ },
+ trx,
+ );
+ }
+
return ctx.fileModel.create(
{
fileHash: input.hash,
diff --git a/src/server/services/file/__tests__/index.test.ts b/src/server/services/file/__tests__/index.test.ts
index 848022e7fe..7bbcbd3e87 100644
--- a/src/server/services/file/__tests__/index.test.ts
+++ b/src/server/services/file/__tests__/index.test.ts
@@ -24,6 +24,7 @@ vi.mock('../impls', () => ({
deleteFiles: vi.fn(),
getFileContent: vi.fn(),
getFileByteArray: vi.fn(),
+ getFileMetadata: vi.fn(),
createPreSignedUrl: vi.fn(),
createPreSignedUrlForPreview: vi.fn(),
uploadContent: vi.fn(),
@@ -381,7 +382,7 @@ describe('FileService', () => {
});
it('should not insert to global files when hash already exists', async () => {
- mockFileModel.checkHash.mockResolvedValue({ isExist: true });
+ mockFileModel.checkHash.mockResolvedValue({ isExist: true, url: 'files/test.txt' });
mockFileModel.create.mockResolvedValue({ id: 'file-id' });
await service.createFileRecord({
@@ -398,6 +399,63 @@ describe('FileService', () => {
}),
false, // insertToGlobalFiles = false when hash exists
);
+ expect(mockFileModel.updateGlobalFile).not.toHaveBeenCalled();
+ });
+
+ it('should update global file metadata when an existing hash points to a missing object', async () => {
+ mockFileModel.checkHash.mockResolvedValue({ isExist: true, url: 'old/path.txt' });
+ mockFileModel.create.mockResolvedValue({ id: 'file-id' });
+ vi.mocked(service['impl'].getFileMetadata).mockRejectedValue(new Error('NoSuchKey'));
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ await service.createFileRecord({
+ fileHash: 'existing-hash',
+ fileType: 'text/plain',
+ metadata: { dirname: 'new', filename: 'test.txt', path: 'new/path.txt' },
+ name: 'test.txt',
+ size: 100,
+ url: 'new/path.txt',
+ });
+
+ expect(mockFileModel.updateGlobalFile).toHaveBeenCalledWith('existing-hash', {
+ metadata: { dirname: 'new', filename: 'test.txt', path: 'new/path.txt' },
+ url: 'new/path.txt',
+ });
+ expect(mockFileModel.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ fileHash: 'existing-hash',
+ url: 'new/path.txt',
+ }),
+ false,
+ );
+ consoleSpy.mockRestore();
+ });
+
+ it('should keep global file metadata when the existing hash object is still available', async () => {
+ mockFileModel.checkHash.mockResolvedValue({ isExist: true, url: 'old/path.txt' });
+ mockFileModel.create.mockResolvedValue({ id: 'file-id' });
+ vi.mocked(service['impl'].getFileMetadata).mockResolvedValue({
+ contentLength: 100,
+ contentType: 'text/plain',
+ });
+
+ await service.createFileRecord({
+ fileHash: 'existing-hash',
+ fileType: 'text/plain',
+ metadata: { dirname: 'new', filename: 'test.txt', path: 'new/path.txt' },
+ name: 'test.txt',
+ size: 100,
+ url: 'new/path.txt',
+ });
+
+ expect(mockFileModel.updateGlobalFile).not.toHaveBeenCalled();
+ expect(mockFileModel.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ fileHash: 'existing-hash',
+ url: 'new/path.txt',
+ }),
+ false,
+ );
});
});
diff --git a/src/server/services/file/index.ts b/src/server/services/file/index.ts
index fe79682410..8e6633bfa6 100644
--- a/src/server/services/file/index.ts
+++ b/src/server/services/file/index.ts
@@ -119,6 +119,16 @@ export class FileService {
return this.impl.uploadBuffer(key, buffer, contentType);
}
+ private async isStoredFileAvailable(url: string): Promise {
+ try {
+ await this.getFileMetadata(url);
+ return true;
+ } catch (error) {
+ console.error('Failed to verify existing file hash storage object:', error);
+ return false;
+ }
+ }
+
/**
* Create file record (common method)
* Automatically handles globalFiles deduplication logic
@@ -137,7 +147,22 @@ export class FileService {
url: string;
}): Promise<{ fileId: string; url: string }> {
// Check if hash already exists in globalFiles
- const { isExist } = await this.fileModel.checkHash(params.fileHash);
+ const existingFile = await this.fileModel.checkHash(params.fileHash);
+ const { isExist } = existingFile;
+
+ let shouldRefreshGlobalFile = false;
+ if (isExist && existingFile.url && existingFile.url !== params.url) {
+ shouldRefreshGlobalFile = !(await this.isStoredFileAvailable(existingFile.url));
+ }
+
+ if (shouldRefreshGlobalFile) {
+ // Keep global hash dedup usable when the same file is uploaded again to a
+ // fresh object key after the previous storage object has been removed.
+ await this.fileModel.updateGlobalFile(params.fileHash, {
+ metadata: params.metadata,
+ url: params.url,
+ });
+ }
// Create database record
// If hash doesn't exist, also create globalFiles record
From 3bcf6a8d72773091e7f1c9295412c67e4423b1e7 Mon Sep 17 00:00:00 2001
From: Arvin Xu
Date: Wed, 20 May 2026 10:27:35 +0800
Subject: [PATCH 040/224] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(agent-set?=
=?UTF-8?q?tings):=20consolidate=20Chat=20tab=20into=20Params=20popover,?=
=?UTF-8?q?=20drop=20dead=20auto-topic=20feature=20(#14885)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* 🔥 chore(agent-config): drop dead enableAutoCreateTopic feature
Drop enableAutoCreateTopic + autoCreateTopicThreshold end-to-end. No
business code consumed these fields anymore — only types, defaults,
locale copy, UI form items, agent-builder LLM prompts, and test
fixtures kept the dead config alive.
Sweep:
- types & zod schema (LobeAgentChatConfig, AgentChatConfigSchema, openapi)
- DEFAULT_AGENT_CHAT_CONFIG constant
- locale keys in default + 18 translations
- agent-builder system prompts & tool manifests
- AgentChat form items (auto-topic switch + threshold slider)
- test fixtures & integration tests (replaced sample boolean key in
parser tests with enableHistoryCount)
- docs/self-hosting env-var examples
- settings.test snapshot
dataImporter JSON fixtures keep the legacy keys on purpose — they
simulate historical user exports and the zod schema strips unknowns.
Co-Authored-By: Claude Opus 4.7 (1M context)
* ✨ feat(chat-input): move inputTemplate + autoScroll into Params popover
Surface the User Input Preprocessing template (inputTemplate) and
Auto-scroll During AI Response toggle (enableAutoScrollOnStreaming) in
the chat-input Params popover, alongside compression / history /
max_tokens. Drop the matching form items from AgentChat — the popover
is now the single entry point for these two agent-level preferences.
ControlRow's action prop becomes optional so inputTemplate can render
as a label + TextArea without a Switch.
Co-Authored-By: Claude Opus 4.7 (1M context)
* 🔥 refactor(agent-settings): drop AgentChat tab in favor of Params popover
Remove the now-redundant Chat Preferences tab from agent settings:
- delete src/features/AgentSetting/AgentChat/
- drop ChatSettingsTabs.Chat enum and its three registrations
(useCategory, AgentSettingsContent, profile Content)
- drop agentTab.chat locale key in default + 18 translations
- drop MessagesSquare / MessagesSquareIcon imports that became unused
History/compression/auto-scroll/inputTemplate already live in the
chat-input Params popover, so this tab carried no unique
functionality.
Co-Authored-By: Claude Opus 4.7 (1M context)