diff --git a/backend/main.py b/backend/main.py index 92843bf1e..00321d984 100644 --- a/backend/main.py +++ b/backend/main.py @@ -37,6 +37,8 @@ logging.getLogger("camel.base_model").setLevel(logging.WARNING) logging.getLogger("camel.agents").setLevel(logging.WARNING) logging.getLogger("camel.societies").setLevel(logging.WARNING) +logging.getLogger("httpx").setLevel(logging.WARNING) +logging.getLogger("httpcore").setLevel(logging.WARNING) from app import api from app.component.environment import env diff --git a/electron/main/fileReader.ts b/electron/main/fileReader.ts index adccf1782..40bc2b94c 100644 --- a/electron/main/fileReader.ts +++ b/electron/main/fileReader.ts @@ -31,6 +31,7 @@ interface FileInfo { relativePath: string; task_id?: string; project_id?: string; + source?: 'project_output' | 'camel_log'; } export class FileReader { @@ -658,11 +659,14 @@ export class FileReader { } } - public getFileList( + private resolveTaskPaths( email: string, taskId: string, projectId?: string - ): FileInfo[] { + ): { + dirPath: string; + logPath: string; + } { const safeEmail = email .split('@')[0] .replace(/[\\/*?:"<>|\s]/g, '_') @@ -670,8 +674,8 @@ export class FileReader { const userHome = app.getPath('home'); let dirPath: string; + let logPath: string; - // Check if projectId is provided for new project-based structure if (projectId) { dirPath = path.join( userHome, @@ -680,25 +684,72 @@ export class FileReader { `project_${projectId}`, `task_${taskId}` ); - } else { - // First try project-based structure (scan for existing projects) - const userDir = path.join(userHome, 'eigent', safeEmail); - const projectBasedPath = this.findTaskInProjects(userDir, taskId); + logPath = path.join( + userHome, + '.eigent', + safeEmail, + `project_${projectId}`, + `task_${taskId}` + ); + return { dirPath, logPath }; + } - if (projectBasedPath) { - dirPath = projectBasedPath; + const userDir = path.join(userHome, 'eigent', safeEmail); + const projectBasedPath = this.findTaskInProjects(userDir, taskId); + + if (projectBasedPath) { + dirPath = projectBasedPath; + const projectMatch = projectBasedPath.match(/project_([^\\\/]+)/); + if (projectMatch) { + logPath = path.join( + userHome, + '.eigent', + safeEmail, + projectMatch[0], + `task_${taskId}` + ); } else { - // Fallback to legacy direct task structure - dirPath = path.join(userHome, 'eigent', safeEmail, `task_${taskId}`); + logPath = path.join(userHome, '.eigent', safeEmail, `task_${taskId}`); } + return { dirPath, logPath }; } + dirPath = path.join(userHome, 'eigent', safeEmail, `task_${taskId}`); + logPath = path.join(userHome, '.eigent', safeEmail, `task_${taskId}`); + return { dirPath, logPath }; + } + + public getFileList( + email: string, + taskId: string, + projectId?: string + ): FileInfo[] { + const { dirPath, logPath } = this.resolveTaskPaths( + email, + taskId, + projectId + ); + const camelLogPath = path.join(logPath, 'camel_logs'); + try { - if (!fs.existsSync(dirPath)) { + const projectFiles = fs.existsSync(dirPath) + ? this.getFilesRecursive(dirPath, dirPath).map((file) => ({ + ...file, + source: 'project_output' as const, + })) + : []; + const camelLogFiles = fs.existsSync(camelLogPath) + ? this.getFilesRecursive(camelLogPath, camelLogPath).map((file) => ({ + ...file, + source: 'camel_log' as const, + })) + : []; + + if (projectFiles.length === 0 && camelLogFiles.length === 0) { return []; } - return this.getFilesRecursive(dirPath, dirPath); + return [...projectFiles, ...camelLogFiles]; } catch (err) { console.error('Load file failed:', err); return []; @@ -713,57 +764,11 @@ export class FileReader { success: boolean; path: { dirPath: string; logPath: string }; } { - const safeEmail = email - .split('@')[0] - .replace(/[\\/*?:"<>|\s]/g, '_') - .replace(/^\.+|\.+$/g, ''); - const userHome = app.getPath('home'); - - let dirPath: string; - let logPath: string; - - // Check if projectId is provided for new project-based structure - if (projectId) { - dirPath = path.join( - userHome, - 'eigent', - safeEmail, - `project_${projectId}`, - `task_${taskId}` - ); - logPath = path.join( - userHome, - '.eigent', - safeEmail, - `project_${projectId}`, - `task_${taskId}` - ); - } else { - // First try project-based structure - const userDir = path.join(userHome, 'eigent', safeEmail); - const projectBasedPath = this.findTaskInProjects(userDir, taskId); - - if (projectBasedPath) { - dirPath = projectBasedPath; - // Extract project from path to construct log path - const projectMatch = projectBasedPath.match(/project_([^\\\/]+)/); - if (projectMatch) { - logPath = path.join( - userHome, - '.eigent', - safeEmail, - projectMatch[0], - `task_${taskId}` - ); - } else { - logPath = path.join(userHome, '.eigent', safeEmail, `task_${taskId}`); - } - } else { - // Fallback to legacy direct task structure - dirPath = path.join(userHome, 'eigent', safeEmail, `task_${taskId}`); - logPath = path.join(userHome, '.eigent', safeEmail, `task_${taskId}`); - } - } + const { dirPath, logPath } = this.resolveTaskPaths( + email, + taskId, + projectId + ); try { let success = false; diff --git a/electron/main/index.ts b/electron/main/index.ts index ee5f699a7..67d888e38 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -1830,7 +1830,7 @@ function registerIpcHandlers() { // Read file content const fileContent = await fsp.readFile(filePath); - log.info('File read successfully:', filePath); + // log.info('File read successfully:', filePath); return { success: true, diff --git a/src/store/chatStore.ts b/src/store/chatStore.ts index 3c404ea82..2e7624aee 100644 --- a/src/store/chatStore.ts +++ b/src/store/chatStore.ts @@ -83,6 +83,155 @@ interface Task { nextExecutionId?: string; } +type UploadFileSource = 'project_output' | 'camel_log' | 'user_attachment'; + +interface UploadCandidate { + path: string; + name: string; + uploadName: string; + source: UploadFileSource; +} + +interface GeneratedUploadFile { + path?: string; + name?: string; + isFolder?: boolean; + source?: Exclude; +} + +interface UploadOutcome { + success: boolean; + fileName: string; + source: UploadFileSource; + error?: unknown; +} + +function getFileNameFromPath(filePath: string): string { + const segments = filePath.split(/[\\/]/).filter(Boolean); + return segments.at(-1) || 'file'; +} + +function isReadableLocalPath(filePath?: string): filePath is string { + if (!filePath) return false; + return !/^(https?:|file:|blob:|data:)/i.test(filePath); +} + +function buildUploadName( + fileName: string, + source: UploadFileSource, + taskId: string, + attachmentIndex: number +): string { + if (source === 'camel_log') { + return `camel_log_${taskId}__${fileName}`; + } + + if (source === 'user_attachment') { + return `user_attachment_${attachmentIndex}__${fileName}`; + } + + return fileName; +} + +export function collectTaskUploadFiles( + generatedFiles: GeneratedUploadFile[], + messages: Message[], + pendingAttaches: File[] = [], + taskId = 'unknown_task' +): UploadCandidate[] { + const uploadCandidates: Array> = []; + + for (const file of generatedFiles) { + if (!file?.path || !file?.name || file.isFolder) continue; + uploadCandidates.push({ + path: file.path, + name: file.name, + source: file.source === 'camel_log' ? 'camel_log' : 'project_output', + }); + } + + const attachmentFiles = [ + ...messages.flatMap((message) => message.attaches || []), + ...pendingAttaches, + ]; + + for (const attachment of attachmentFiles) { + if (!isReadableLocalPath(attachment?.filePath)) continue; + uploadCandidates.push({ + path: attachment.filePath, + name: + attachment.fileName?.trim() || getFileNameFromPath(attachment.filePath), + source: 'user_attachment', + }); + } + + const uniqueCandidates = new Map(); + let attachmentIndex = 1; + for (const file of uploadCandidates) { + if (!uniqueCandidates.has(file.path)) { + uniqueCandidates.set(file.path, { + ...file, + uploadName: buildUploadName( + file.name, + file.source, + taskId, + file.source === 'user_attachment' ? attachmentIndex++ : 0 + ), + }); + } + } + + return Array.from(uniqueCandidates.values()); +} + +async function uploadTaskFiles( + files: UploadCandidate[], + uploadTargetId: string +): Promise { + const results: UploadOutcome[] = []; + + for (const file of files) { + try { + const result = await window.ipcRenderer.invoke('read-file', file.path); + if (!result.success || !result.data) { + results.push({ + success: false, + fileName: file.name, + source: file.source, + error: result.error || 'Failed to read file', + }); + continue; + } + + const formData = new FormData(); + const blob = new Blob([result.data], { + type: 'application/octet-stream', + }); + formData.append('file', blob, file.uploadName); + // TODO(file): rename endpoint to use project_id + formData.append('task_id', uploadTargetId); + + await uploadFile('/api/v1/chat/files/upload', formData); + console.log('File uploaded successfully:', file.uploadName, file.source); + results.push({ + success: true, + fileName: file.uploadName, + source: file.source, + }); + } catch (error) { + console.error('File upload failed:', file.uploadName, file.source, error); + results.push({ + success: false, + fileName: file.uploadName, + source: file.source, + error, + }); + } + } + + return results; +} + export interface ChatStore { updateCount: number; activeTaskId: string | null; @@ -2229,83 +2378,59 @@ const chatStore = (initial?: Partial) => ) ); - // Async file upload - let res = await window.ipcRenderer.invoke( - 'get-file-list', - email, - currentTaskId, - (project_id || projectStore.activeProjectId) as string - ); - if ( - !type && - import.meta.env.VITE_USE_LOCAL_PROXY !== 'true' && - res.length > 0 - ) { - // Upload files sequentially to avoid overwhelming the server - const uploadResults = await Promise.allSettled( - res - .filter((file: any) => !file.isFolder) - .map(async (file: any) => { - try { - // Read file content using Electron API - const result = await window.ipcRenderer.invoke( - 'read-file', - file.path - ); - if (result.success && result.data) { - // Create FormData for file upload - const formData = new FormData(); - const blob = new Blob([result.data], { - type: 'application/octet-stream', - }); - formData.append('file', blob, file.name); - //TODO(file): rename endpoint to use project_id - formData.append( - 'task_id', - (project_id || projectStore.activeProjectId) as string - ); - - // Upload file - await uploadFile('/api/v1/chat/files/upload', formData); - console.log('File uploaded successfully:', file.name); - return { success: true, fileName: file.name }; - } else { - console.error('Failed to read file:', result.error); - return { - success: false, - fileName: file.name, - error: result.error, - }; - } - } catch (error) { - console.error('File upload failed:', error); - return { success: false, fileName: file.name, error }; + const uploadTargetId = (project_id || + projectStore.activeProjectId) as string | undefined; + if (!type && import.meta.env.VITE_USE_LOCAL_PROXY !== 'true') { + if (!uploadTargetId) { + console.warn( + 'Skip file upload because no active project ID was found' + ); + } else { + try { + const generatedFiles = + ((await window.ipcRenderer.invoke( + 'get-file-list', + email, + currentTaskId, + uploadTargetId + )) as GeneratedUploadFile[]) || []; + const filesToUpload = collectTaskUploadFiles( + generatedFiles, + tasks[currentTaskId].messages, + tasks[currentTaskId].attaches, + currentTaskId + ); + + if (filesToUpload.length > 0) { + const uploadResults = await uploadTaskFiles( + filesToUpload, + uploadTargetId + ); + const failedUploads = uploadResults.filter( + (result) => !result.success + ); + if (failedUploads.length > 0) { + console.error('Failed to upload files:', failedUploads); } - }) - ); - // Count successful uploads - const successCount = uploadResults.filter( - (result) => - result.status === 'fulfilled' && result.value.success - ).length; - - // Log failures - const failures = uploadResults.filter( - (result) => - result.status === 'rejected' || - (result.status === 'fulfilled' && !result.value.success) - ); - if (failures.length > 0) { - console.error('Failed to upload files:', failures); - } + const generatedSuccessCount = uploadResults.filter( + (result) => + result.success && result.source === 'project_output' + ).length; - // add remote file count for successful uploads only - if (successCount > 0) { - proxyFetchPost(`/api/v1/user/stat`, { - action: 'file_generate_count', - value: successCount, - }); + if (generatedSuccessCount > 0) { + proxyFetchPost(`/api/v1/user/stat`, { + action: 'file_generate_count', + value: generatedSuccessCount, + }); + } + } + } catch (error) { + console.error( + 'Failed to prepare task files for upload:', + error + ); + } } } diff --git a/test/unit/store/chatStore.test.ts b/test/unit/store/chatStore.test.ts index 9f5d07cd1..631e8f992 100644 --- a/test/unit/store/chatStore.test.ts +++ b/test/unit/store/chatStore.test.ts @@ -94,7 +94,10 @@ vi.mock('../../../src/store/projectStore', () => ({ import { proxyFetchGet } from '@/api/http'; import { fetchEventSource } from '@microsoft/fetch-event-source'; import { generateUniqueId } from '../../../src/lib'; -import { useChatStore } from '../../../src/store/chatStore'; +import { + collectTaskUploadFiles, + useChatStore, +} from '../../../src/store/chatStore'; import { useProjectStore } from '../../../src/store/projectStore'; import { ChatTaskStatus } from '../../../src/types/constants'; @@ -114,6 +117,116 @@ describe('ChatStore - Core Functionality', () => { vi.clearAllMocks(); }); + describe('Task Upload Files', () => { + it('collects project outputs, camel logs, and unique user attachments', () => { + const uploadFiles = collectTaskUploadFiles( + [ + { + path: '/tmp/project/report.md', + name: 'report.md', + source: 'project_output', + }, + { + path: '/tmp/logs/agent.log', + name: 'agent.log', + source: 'camel_log', + }, + { + path: '/tmp/project', + name: 'project', + isFolder: true, + source: 'project_output', + }, + ], + [ + { + id: 'msg-1', + role: 'user', + content: 'question', + attaches: [ + { + fileName: 'brief.pdf', + filePath: '/Users/test/Documents/brief.pdf', + }, + { + fileName: 'report.md', + filePath: '/tmp/project/report.md', + }, + ], + }, + ] as any, + [ + { + fileName: 'followup.csv', + filePath: '/Users/test/Documents/followup.csv', + }, + ], + 'task-123' + ); + + expect(uploadFiles).toEqual([ + { + path: '/tmp/project/report.md', + name: 'report.md', + uploadName: 'report.md', + source: 'project_output', + }, + { + path: '/tmp/logs/agent.log', + name: 'agent.log', + uploadName: 'camel_log_task-123__agent.log', + source: 'camel_log', + }, + { + path: '/Users/test/Documents/brief.pdf', + name: 'brief.pdf', + uploadName: 'user_attachment_1__brief.pdf', + source: 'user_attachment', + }, + { + path: '/Users/test/Documents/followup.csv', + name: 'followup.csv', + uploadName: 'user_attachment_2__followup.csv', + source: 'user_attachment', + }, + ]); + }); + + it('skips remote attachment URLs and falls back to filename from path', () => { + const uploadFiles = collectTaskUploadFiles( + [], + [ + { + id: 'msg-2', + role: 'user', + content: 'question', + attaches: [ + { + fileName: '', + filePath: 'C:\\Users\\test\\Desktop\\notes.txt', + }, + { + fileName: 'remote.pdf', + filePath: 'https://example.com/remote.pdf', + }, + ], + }, + ] as any, + [], + 'task-456' + ); + + expect(uploadFiles).toEqual([ + { + path: 'C:\\Users\\test\\Desktop\\notes.txt', + name: 'notes.txt', + uploadName: 'user_attachment_1__notes.txt', + source: 'user_attachment', + }, + ]); + }); + }); + describe('Task Creation', () => { it('should create a task with unique ID', () => { const { result } = renderHook(() => useChatStore());