Repository: iOfficeAI/AionUi (TypeScript)

Issue:
[Bug]: 上传文件总是被保存到配置文件夹，"上传文件保存到工作区"不生效
### Platform

Windows

### AionUi Version

1.9.11

### Bug Description

新建会话时，从文件管理器拖拽文件到输入框，然后发送消息后，上传的文件总是被保存到“用户文件夹\AppData\Roaming\AionUi\config\temp\”下，而不是临时工作区或者不保存。
设置里的"上传文件保存到工作区"选项完全不影响这一行为。

### Steps to Reproduce

1.在“新会话”界面，从windows的文件管理器用鼠标拖拽文件到输入框，在输入框输入任意文本，选择任意代理，发送。
2.观察自动创建的临时工作区内的文件，会发现上传的文件不在这里，也不再原始文件位置，而是在~\AppData\Roaming\AionUi\config\temp\


### Expected Behavior

设置里的"上传文件保存到工作区"开启时，应该先创建临时工作区，再把上传文件复制到临时工作区，然后在提示词里添加对于临时工作区内文件的引用。

### Actual Behavior

无论是否启用设置里的"上传文件保存到工作区"，上传的文件总是被保存到“~\AppData\Roaming\AionUi\config\temp\”下，而不是临时工作区或者直接引用原始文件。


### Additional Context

_No response_


Discussion:

Patch:
diff --git a/src/common/adapter/ipcBridge.ts b/src/common/adapter/ipcBridge.ts
index 268304ea67..460bd34884 100644
--- a/src/common/adapter/ipcBridge.ts
+++ b/src/common/adapter/ipcBridge.ts
@@ -219,6 +219,7 @@ export const fs = {
   readFile: bridge.buildProvider<string, { path: string }>('read-file'), // 读取文件内容（UTF-8）
   readFileBuffer: bridge.buildProvider<ArrayBuffer, { path: string }>('read-file-buffer'), // 读取二进制文件为 ArrayBuffer
   createTempFile: bridge.buildProvider<string, { fileName: string }>('create-temp-file'), // 创建临时文件
+  createUploadFile: bridge.buildProvider<string, { fileName: string; conversationId?: string }>('create-upload-file'), // 创建上传文件（根据设置决定保存位置）
   writeFile: bridge.buildProvider<boolean, { path: string; data: Uint8Array | string }>('write-file'), // 写入文件
   createZip: bridge.buildProvider<
     boolean,
diff --git a/src/process/bridge/fileWatchBridge.ts b/src/process/bridge/fileWatchBridge.ts
index 1ed89bcc30..ac300aee6e 100644
--- a/src/process/bridge/fileWatchBridge.ts
+++ b/src/process/bridge/fileWatchBridge.ts
@@ -62,7 +62,7 @@ export async function scanWorkspaceOfficeFiles(workspace: string): Promise<strin
     }
   }
 
-  return [...discovered].sort();
+  return [...discovered].toSorted();
 }
 
 // 初始化文件监听桥接，负责 start/stop 所有 watcher / Initialize file watch bridge to manage start/stop of watchers
diff --git a/src/process/bridge/fsBridge.ts b/src/process/bridge/fsBridge.ts
index a84d0f3739..1e0b1b5cee 100644
--- a/src/process/bridge/fsBridge.ts
+++ b/src/process/bridge/fsBridge.ts
@@ -13,8 +13,15 @@ import https from 'node:https';
 import http from 'node:http';
 import JSZip from 'jszip';
 import { ipcBridge } from '@/common';
-import { getSystemDir, getAssistantsDir, getSkillsDir, getBuiltinSkillsCopyDir } from '@process/utils/initStorage';
+import {
+  getSystemDir,
+  getAssistantsDir,
+  getSkillsDir,
+  getBuiltinSkillsCopyDir,
+  ProcessConfig,
+} from '@process/utils/initStorage';
 import { readDirectoryRecursive } from '@process/utils';
+import { getDatabase } from '@process/services/database';
 import type { IWorkspaceFlatFile } from '@/common/adapter/ipcBridge';
 
 // ============================================================================
@@ -482,6 +489,81 @@ export function initFsBridge(): void {
     }
   });
 
+  // 创建上传文件 / Create upload file based on user preference
+  // 根据"上传文件保存到工作区"设置决定文件保存位置
+  ipcBridge.fs.createUploadFile.provider(async ({ fileName, conversationId }) => {
+    try {
+      // 检查用户偏好：保存到工作区还是缓存目录
+      // Check user preference: save to workspace or cache directory
+      const saveToWorkspace = await ProcessConfig.get('upload.saveToWorkspace').catch(() => false);
+
+      let uploadDir: string;
+      if (conversationId && saveToWorkspace) {
+        // 保存到工作区 / Save to workspace
+        try {
+          const db = await getDatabase();
+          const result = db.getConversation(conversationId);
+          const conversationWorkspace = result.data?.extra?.workspace;
+
+          if (!result.success || !conversationWorkspace) {
+            throw new Error('Conversation workspace not found');
+          }
+
+          const resolvedWorkspace = path.resolve(conversationWorkspace);
+          uploadDir = path.join(resolvedWorkspace, 'uploads');
+          await fs.mkdir(uploadDir, { recursive: true });
+        } catch (error) {
+          // 如果无法获取工作区，回退到缓存目录
+          // Fallback to cache directory if workspace cannot be resolved
+          console.warn('[fsBridge] Failed to resolve workspace, using cache directory:', error);
+          const { cacheDir } = getSystemDir();
+          const tempDir = path.join(cacheDir, 'temp');
+          await fs.mkdir(tempDir, { recursive: true });
+          uploadDir = tempDir;
+        }
+      } else {
+        // 保存到缓存目录 / Save to cache directory
+        const { cacheDir } = getSystemDir();
+        const tempDir = path.join(cacheDir, 'temp');
+        await fs.mkdir(tempDir, { recursive: true });
+        uploadDir = tempDir;
+      }
+
+      // 使用原文件名，必要时清理非法字符 / Keep original name but sanitize illegal characters
+      const safeFileName = fileName.replace(/[<>:"/\\|?*]/g, '_');
+      let filePath = path.join(uploadDir, safeFileName);
+
+      // 如果冲突则追加时间戳后缀 / Append timestamp when duplicate exists
+      const fileExists = await fs
+        .access(filePath)
+        .then(() => true)
+        .catch(() => false);
+
+      if (fileExists) {
+        const timestamp = Date.now();
+        const ext = path.extname(safeFileName);
+        const name = path.basename(safeFileName, ext);
+        const newFileName = `${name}${AIONUI_TIMESTAMP_SEPARATOR}${timestamp}${ext}`;
+        filePath = path.join(uploadDir, newFileName);
+      }
+
+      // Defense in depth: ensure path stays within uploadDir
+      const resolvedFilePath = path.resolve(filePath);
+      const resolvedUploadDir = path.resolve(uploadDir);
+      if (!resolvedFilePath.startsWith(resolvedUploadDir + path.sep)) {
+        throw new Error('Invalid file name');
+      }
+
+      // 创建空文件作为占位 / Create empty placeholder file
+      await fs.writeFile(filePath, Buffer.alloc(0));
+
+      return filePath;
+    } catch (error) {
+      console.error('Failed to create upload file:', error);
+      throw error;
+    }
+  });
+
   // 读取文件内容（UTF-8编码）/ Read file content (UTF-8 encoding)
   // V8 string length limit is ~512MB; guard against RangeError on oversized files
   const MAX_READ_FILE_SIZE = 256 * 1024 * 1024; // 256 MB
diff --git a/src/renderer/services/FileService.ts b/src/renderer/services/FileService.ts
index 64e4d9f19e..c6a2b3a7ed 100644
--- a/src/renderer/services/FileService.ts
+++ b/src/renderer/services/FileService.ts
@@ -285,13 +285,13 @@ class FileServiceClass {
               tracker.finish();
             }
           } else {
-            // Electron: use IPC to create temp file
+            // Electron: use IPC to create upload file (respects saveToWorkspace setting)
             const arrayBuffer = await file.arrayBuffer();
             const uint8Array = new Uint8Array(arrayBuffer);
-            const tempPath = await ipcBridge.fs.createTempFile.invoke({ fileName: file.name });
-            if (tempPath) {
-              await ipcBridge.fs.writeFile.invoke({ path: tempPath, data: uint8Array });
-              filePath = tempPath;
+            const uploadPath = await ipcBridge.fs.createUploadFile.invoke({ fileName: file.name, conversationId });
+            if (uploadPath) {
+              await ipcBridge.fs.writeFile.invoke({ path: uploadPath, data: uint8Array });
+              filePath = uploadPath;
             }
           }
         } catch (error) {
diff --git a/src/renderer/services/PasteService.ts b/src/renderer/services/PasteService.ts
index 26868cf653..d7aca9257b 100644
--- a/src/renderer/services/PasteService.ts
+++ b/src/renderer/services/PasteService.ts
@@ -27,7 +27,7 @@ async function createTempFile(
     throw new Error('FILE_TOO_LARGE');
   }
   if (isElectronDesktop()) {
-    const tempPath = await ipcBridge.fs.createTempFile.invoke({ fileName });
+    const tempPath = await ipcBridge.fs.createUploadFile.invoke({ fileName, conversationId });
     if (tempPath) {
       await ipcBridge.fs.writeFile.invoke({ path: tempPath, data });
     }


Test patch:
diff --git a/tests/unit/PasteService.dom.test.ts b/tests/unit/PasteService.dom.test.ts
index 4dbdb55ef2..c7bfaefdaa 100644
--- a/tests/unit/PasteService.dom.test.ts
+++ b/tests/unit/PasteService.dom.test.ts
@@ -13,6 +13,7 @@ vi.mock('@/common', () => ({
   ipcBridge: {
     fs: {
       createTempFile: { invoke: vi.fn() },
+      createUploadFile: { invoke: vi.fn() },
       writeFile: { invoke: vi.fn() },
     },
   },
@@ -77,6 +78,11 @@ describe('PasteService.handlePaste — filename deduplication', () => {
       tempFileCounter++;
       return `/tmp/temp-${tempFileCounter}/${fileName}`;
     });
+    // Each createUploadFile call returns a unique path based on the fileName argument
+    vi.mocked(ipcBridge.fs.createUploadFile.invoke).mockImplementation(async ({ fileName }) => {
+      tempFileCounter++;
+      return `/tmp/upload-${tempFileCounter}/${fileName}`;
+    });
     vi.mocked(ipcBridge.fs.writeFile.invoke).mockResolvedValue(undefined as never);
 
     // Re-import to get a fresh singleton
diff --git a/tests/unit/fsBridge.skills.test.ts b/tests/unit/fsBridge.skills.test.ts
index a809e5ca70..cd0ba76224 100644
--- a/tests/unit/fsBridge.skills.test.ts
+++ b/tests/unit/fsBridge.skills.test.ts
@@ -191,6 +191,7 @@ describe('fsBridge skills functionality', () => {
             readFile: createCommandMock('read-file'),
             readFileBuffer: createCommandMock('read-file-buffer'),
             createTempFile: createCommandMock('create-temp-file'),
+            createUploadFile: createCommandMock('create-upload-file'),
             writeFile: createCommandMock('write-file'),
             createZip: createCommandMock('create-zip-file'),
             cancelZip: createCommandMock('cancel-zip-file'),
diff --git a/tests/unit/process/bridge/fsBridge.downloadRemoteBuffer.test.ts b/tests/unit/process/bridge/fsBridge.downloadRemoteBuffer.test.ts
index d4a08e05d3..2cd56cb03c 100644
--- a/tests/unit/process/bridge/fsBridge.downloadRemoteBuffer.test.ts
+++ b/tests/unit/process/bridge/fsBridge.downloadRemoteBuffer.test.ts
@@ -72,6 +72,7 @@ vi.mock('@/common', () => ({
       readFile: { provider: vi.fn() },
       readFileBuffer: { provider: vi.fn() },
       createTempFile: { provider: vi.fn() },
+      createUploadFile: { provider: vi.fn() },
       writeFile: { provider: vi.fn() },
       createZip: { provider: vi.fn() },
       cancelZip: { provider: vi.fn() },
diff --git a/tests/unit/process/bridge/fsBridge.listWorkspaceFiles.test.ts b/tests/unit/process/bridge/fsBridge.listWorkspaceFiles.test.ts
index 5f6a358af7..93aa39da18 100644
--- a/tests/unit/process/bridge/fsBridge.listWorkspaceFiles.test.ts
+++ b/tests/unit/process/bridge/fsBridge.listWorkspaceFiles.test.ts
@@ -53,6 +53,7 @@ vi.mock('@/common', () => ({
       readFile: makeProvider('readFile'),
       readFileBuffer: makeProvider('readFileBuffer'),
       createTempFile: makeProvider('createTempFile'),
+      createUploadFile: makeProvider('createUploadFile'),
       writeFile: makeProvider('writeFile'),
       createZip: makeProvider('createZip'),
       cancelZip: makeProvider('cancelZip'),
diff --git a/tests/unit/process/bridge/fsBridge.readFile.test.ts b/tests/unit/process/bridge/fsBridge.readFile.test.ts
index 4606d3a66f..11d4408b15 100644
--- a/tests/unit/process/bridge/fsBridge.readFile.test.ts
+++ b/tests/unit/process/bridge/fsBridge.readFile.test.ts
@@ -46,6 +46,7 @@ vi.mock('@/common', () => {
         readFile: makeProvider('readFile'),
         readFileBuffer: makeProvider('readFileBuffer'),
         createTempFile: makeProvider('createTempFile'),
+        createUploadFile: makeProvider('createUploadFile'),
         writeFile: makeProvider('writeFile'),
         createZip: makeProvider('createZip'),
         cancelZip: makeProvider('cancelZip'),
diff --git a/tests/unit/process/bridge/fsBridge.standalone.test.ts b/tests/unit/process/bridge/fsBridge.standalone.test.ts
index 943dfda7d2..39f4572e89 100644
--- a/tests/unit/process/bridge/fsBridge.standalone.test.ts
+++ b/tests/unit/process/bridge/fsBridge.standalone.test.ts
@@ -39,6 +39,7 @@ vi.mock('@/common', () => ({
       readFile: { provider: vi.fn() },
       readFileBuffer: { provider: vi.fn() },
       createTempFile: { provider: vi.fn() },
+      createUploadFile: { provider: vi.fn() },
       writeFile: { provider: vi.fn() },
       createZip: { provider: vi.fn() },
       cancelZip: { provider: vi.fn() },