diff --git a/backend/app/agent/agent_model.py b/backend/app/agent/agent_model.py index 1b1fe860..eb5c27ba 100644 --- a/backend/app/agent/agent_model.py +++ b/backend/app/agent/agent_model.py @@ -80,6 +80,7 @@ def agent_model( for attr in config_attrs: effective_config[attr] = getattr(options, attr) extra_params = options.extra_params or {} + init_param_keys = { "api_version", "azure_ad_token", @@ -135,6 +136,12 @@ def agent_model( ) model_platform_enum = None + if effective_config["model_platform"].lower() == "anthropic": + if model_config.get("cache_control") is None: + model_config["cache_control"] = "5m" + if model_config.get("max_tokens") is None: + model_config["max_tokens"] = 64000 + model = ModelFactory.create( model_platform=effective_config["model_platform"], model_type=effective_config["model_type"], diff --git a/backend/app/component/model_validation.py b/backend/app/component/model_validation.py index c8da48be..bad0ad48 100644 --- a/backend/app/component/model_validation.py +++ b/backend/app/component/model_validation.py @@ -227,6 +227,10 @@ def create_agent( raise ValueError(f"Invalid model_type: {model_type}") if platform is None: raise ValueError(f"Invalid model_platform: {model_platform}") + if str(platform).lower() == "anthropic": + model_config_dict = dict(model_config_dict or {}) + if model_config_dict.get("max_tokens") is None: + model_config_dict["max_tokens"] = 4096 model = ModelFactory.create( model_platform=platform, model_type=mtype, @@ -326,6 +330,10 @@ def validate_model_with_details( "Creating model", extra={"platform": model_platform, "model_type": model_type}, ) + if str(model_platform).lower() == "anthropic": + model_config_dict = dict(model_config_dict or {}) + if model_config_dict.get("max_tokens") is None: + model_config_dict["max_tokens"] = 4096 model = ModelFactory.create( model_platform=model_platform, model_type=model_type, diff --git a/backend/main.py b/backend/main.py index 92843bf1..00321d98 100644 --- a/backend/main.py +++ b/backend/main.py @@ -37,6 +37,8 @@ logging.getLogger("camel").setLevel(logging.WARNING) 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 adccf178..40bc2b94 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,60 +659,13 @@ export class FileReader { } } - public getFileList( - email: string, - taskId: string, - projectId?: string - ): FileInfo[] { - const safeEmail = email - .split('@')[0] - .replace(/[\\/*?:"<>|\s]/g, '_') - .replace(/^\.+|\.+$/g, ''); - const userHome = app.getPath('home'); - - let dirPath: string; - - // Check if projectId is provided for new project-based structure - if (projectId) { - dirPath = path.join( - userHome, - 'eigent', - safeEmail, - `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); - - if (projectBasedPath) { - dirPath = projectBasedPath; - } else { - // Fallback to legacy direct task structure - dirPath = path.join(userHome, 'eigent', safeEmail, `task_${taskId}`); - } - } - - try { - if (!fs.existsSync(dirPath)) { - return []; - } - - return this.getFilesRecursive(dirPath, dirPath); - } catch (err) { - console.error('Load file failed:', err); - return []; - } - } - - public deleteTaskFiles( + private resolveTaskPaths( email: string, taskId: string, projectId?: string ): { - success: boolean; - path: { dirPath: string; logPath: string }; + dirPath: string; + logPath: string; } { const safeEmail = email .split('@')[0] @@ -722,7 +676,6 @@ export class FileReader { let dirPath: string; let logPath: string; - // Check if projectId is provided for new project-based structure if (projectId) { dirPath = path.join( userHome, @@ -738,33 +691,85 @@ export class FileReader { `project_${projectId}`, `task_${taskId}` ); - } else { - // First try project-based structure - const userDir = path.join(userHome, 'eigent', safeEmail); - const projectBasedPath = this.findTaskInProjects(userDir, taskId); + return { dirPath, logPath }; + } - 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}`); - } + 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 { + 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 [...projectFiles, ...camelLogFiles]; + } catch (err) { + console.error('Load file failed:', err); + return []; + } + } + + public deleteTaskFiles( + email: string, + taskId: string, + projectId?: string + ): { + success: boolean; + path: { dirPath: string; logPath: string }; + } { + const { dirPath, logPath } = this.resolveTaskPaths( + email, + taskId, + projectId + ); + try { let success = false; if (fs.existsSync(dirPath)) { diff --git a/electron/main/index.ts b/electron/main/index.ts index 1b9d5da4..3c699b9c 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -99,6 +99,7 @@ interface CdpBrowser { addedAt: number; } let cdp_browser_pool: CdpBrowser[] = []; +let cdpLastAssignedPort = 9223; // tracks the highest port ever assigned, never decreases let cdpHealthCheckTimer: ReturnType | null = null; const CDP_POOL_FILE = path.join(os.homedir(), '.eigent', 'cdp-browsers.json'); @@ -121,8 +122,12 @@ function loadCdpPool(): void { ...b, isExternal: true, })); + cdpLastAssignedPort = cdp_browser_pool.reduce( + (max, b) => Math.max(max, b.port), + cdpLastAssignedPort + ); log.info( - `[CDP POOL] Loaded ${cdp_browser_pool.length} browser(s) from disk` + `[CDP POOL] Loaded ${cdp_browser_pool.length} browser(s) from disk, lastAssignedPort=${cdpLastAssignedPort}` ); } } catch (e) { @@ -759,18 +764,27 @@ function registerIpcHandlers() { // Launch CDP browser with automatic port assignment ipcMain.handle('launch-cdp-browser', async () => { try { - // 1. Find available port (9224–9300) by checking no CDP browser is listening + // 1. Always increment port from the last assigned port // Port 9223 is reserved for the login browser let port: number | null = null; - for (let p = 9224; p < 9300; p++) { - if ( - !cdp_browser_pool.some((b) => b.port === p) && - !(await isCdpPortAlive(p)) - ) { + for (let p = cdpLastAssignedPort + 1; p < 9300; p++) { + if (!(await isCdpPortAlive(p))) { port = p; break; } } + // Wrap around if we hit the ceiling + if (port === null) { + for (let p = 9224; p <= cdpLastAssignedPort && p < 9300; p++) { + if ( + !cdp_browser_pool.some((b) => b.port === p) && + !(await isCdpPortAlive(p)) + ) { + port = p; + break; + } + } + } if (port === null) { return { success: false, error: 'No available port in 9224-9299' }; } @@ -905,6 +919,7 @@ function registerIpcHandlers() { addedAt: Date.now(), }; cdp_browser_pool.push(newBrowser); + cdpLastAssignedPort = port; saveCdpPool(); notifyCdpPoolChanged(); @@ -1815,7 +1830,6 @@ function registerIpcHandlers() { // Read file content const fileContent = await fsp.readFile(filePath); - log.info('File read successfully:', filePath); return { success: true, diff --git a/src/assets/wechat_qr.jpg b/src/assets/wechat_qr.jpg index 2c1bc979..236b61ab 100644 Binary files a/src/assets/wechat_qr.jpg and b/src/assets/wechat_qr.jpg differ diff --git a/src/components/WorkFlow/node.tsx b/src/components/WorkFlow/node.tsx index c787944f..cd320895 100644 --- a/src/components/WorkFlow/node.tsx +++ b/src/components/WorkFlow/node.tsx @@ -280,18 +280,30 @@ export function Node({ id, data }: NodeProps) { const toolkits = selectedTask?.toolkits; const lastToolkit = toolkits?.[toolkits.length - 1]; - const toolkitChangeKey = `${selectedTask?.id ?? ''}:${toolkits?.length ?? 0}:${lastToolkit?.toolkitId ?? ''}:${lastToolkit?.toolkitStatus ?? ''}`; + const toolkitChangeKey = `${selectedTask?.id ?? ''}:${toolkits?.length ?? 0}:${lastToolkit?.toolkitId ?? ''}:${lastToolkit?.toolkitStatus ?? ''}:${lastToolkit?.message?.slice(-30) ?? ''}`; useEffect(() => { if (!isExpanded || !toolkits?.length) return; scrollLogToBottom(); }, [isExpanded, toolkits?.length, toolkitChangeKey, scrollLogToBottom]); - // Reset scroll-to-bottom flag when switching tasks so new task always starts at bottom + // Scroll to bottom when a report appears + useEffect(() => { + if (!isExpanded || !selectedTask?.report) return; + scrollLogToBottom(); + }, [isExpanded, selectedTask?.report, scrollLogToBottom]); + + // Reset scroll-to-bottom flag when switching tasks or when panel opens useEffect(() => { wasAtBottomRef.current = true; }, [selectedTask?.id]); + useEffect(() => { + if (isExpanded) { + wasAtBottomRef.current = true; + } + }, [isExpanded]); + // Track whether user has scrolled up so we don't override manual reading useEffect(() => { const el = logRef.current; @@ -413,17 +425,17 @@ export function Node({ id, data }: NodeProps) { : 'w-[342px]' } ${ data.isEditMode ? 'h-full' : 'max-h-[calc(100vh-200px)]' - } flex overflow-hidden rounded-xl border border-solid border-worker-border-default bg-worker-surface-primary ${ + } rounded-xl border-worker-border-default bg-worker-surface-primary flex overflow-hidden border border-solid ${ getCurrentTask()?.activeAgent === id ? `${agentMap[data.type]?.borderColor} z-50` - : 'z-10 border-worker-border-default' - } transition-all duration-300 ease-in-out ${ + : 'border-worker-border-default z-10' + } ease-in-out transition-all duration-300 ${ (data.agent?.tasks?.length ?? 0) === 0 && 'opacity-30' }`} > -
-
-
+
+
+
-
+
@@ -448,7 +460,7 @@ export function Node({ id, data }: NodeProps) { - +
{ e.stopPropagation(); const newWorkerList = workerList.filter( @@ -484,7 +496,7 @@ export function Node({ id, data }: NodeProps) {
{/* {JSON.stringify(data.agent)} */} {toolkitLabels.map((toolkit, index) => ( @@ -494,7 +506,7 @@ export function Node({ id, data }: NodeProps) { ))}
{ chatStore.setActiveWorkspace( chatStore.activeTaskId as string, @@ -506,15 +518,15 @@ export function Node({ id, data }: NodeProps) { > {browserImages.length > 0 && (
{browserImages.map((img, index) => (
{data.type} @@ -524,7 +536,7 @@ export function Node({ id, data }: NodeProps) { (_, index) => (
) )} @@ -533,8 +545,8 @@ export function Node({ id, data }: NodeProps) { {data.type === 'document_agent' && data?.agent?.tasks && data.agent.tasks.length > 0 && ( -
-
+
+
@@ -542,14 +554,14 @@ export function Node({ id, data }: NodeProps) { {data.type === 'developer_agent' && terminalTasks.length > 0 && (
{terminalTasks.map((task) => (
-
+
@@ -558,7 +570,7 @@ export function Node({ id, data }: NodeProps) { (_, index) => (
) )} @@ -566,7 +578,7 @@ export function Node({ id, data }: NodeProps) { )}
{data.agent?.tasks && data.agent?.tasks.length > 0 && ( -
+
{/*
Subtasks
*/}
{ e.stopPropagation(); }} - className="scrollbar scrollbar-always-visible flex flex-col gap-2 overflow-y-auto px-3 pb-2 duration-500 ease-out animate-in fade-in-0 slide-in-from-bottom-4" + className="scrollbar scrollbar-always-visible gap-2 px-3 pb-2 ease-out animate-in fade-in-0 slide-in-from-bottom-4 flex flex-col overflow-y-auto duration-500" style={{ maxHeight: data.img && data.img.length > 0 @@ -650,7 +662,7 @@ export function Node({ id, data }: NodeProps) { } }} key={`taskList-${task.id}-${task.failure_count}`} - className={`flex gap-2 rounded-xl px-sm py-sm transition-all duration-300 ease-in-out animate-in fade-in-0 slide-in-from-left-2 ${ + className={`gap-2 rounded-xl px-sm py-sm ease-in-out animate-in fade-in-0 slide-in-from-left-2 flex transition-all duration-300 ${ task.reAssignTo ? 'bg-task-fill-warning' : task.status === TaskStatus.COMPLETED @@ -743,14 +755,14 @@ export function Node({ id, data }: NodeProps) { : task.status === TaskStatus.BLOCKED ? 'text-text-body' : 'text-text-primary' - } pointer-events-auto select-text whitespace-pre-line text-wrap break-all text-xs font-medium leading-13`} + } text-xs font-medium leading-13 pointer-events-auto text-wrap break-all whitespace-pre-line select-text`} > -
+
No. {getTaskId(task.id)}
{task.reAssignTo ? ( -
+
Reassigned to {task.reAssignTo}
) : ( @@ -772,11 +784,11 @@ export function Node({ id, data }: NodeProps) {
{task.content}
{task?.status === TaskStatus.RUNNING && ( -
+
{/* active toolkit */} {lastActiveToolkit?.toolkitStatus === AgentStatusValue.RUNNING && ( -
+
{getToolkitIcon( lastActiveToolkit.toolkitName ?? '' )} @@ -787,11 +799,11 @@ export function Node({ id, data }: NodeProps) { ].activeWorkspace ? '!w-[100px]' : '!w-[500px]' - } min-w-0 flex-shrink-0 flex-grow-0 overflow-hidden text-ellipsis whitespace-nowrap pt-1 text-xs leading-17 text-text-primary`} + } min-w-0 pt-1 text-xs leading-17 text-text-primary flex-shrink-0 flex-grow-0 overflow-hidden text-ellipsis whitespace-nowrap`} >
@@ -812,14 +824,14 @@ export function Node({ id, data }: NodeProps) { animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: 24 }} transition={{ duration: 0.3, ease: 'easeIn' }} - className="flex w-[342px] shrink-0 flex-col gap-sm overflow-hidden rounded-r-xl bg-worker-surface-secondary py-2 pl-sm" + className="gap-sm rounded-r-xl bg-worker-surface-secondary py-2 pl-sm flex w-[342px] shrink-0 flex-col overflow-hidden" >
{ e.stopPropagation(); }} - className="scrollbar scrollbar-always-visible max-h-[calc(100vh-200px)] overflow-y-scroll pr-sm" + className="scrollbar scrollbar-always-visible pr-sm max-h-[calc(100vh-200px)] overflow-y-scroll" > {selectedTask && ( @@ -829,7 +841,7 @@ export function Node({ id, data }: NodeProps) { animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -16 }} transition={{ duration: 0.25, ease: 'easeIn' }} - className="flex w-full flex-col gap-sm" + className="gap-sm flex w-full flex-col" > {selectedTask.toolkits && selectedTask.toolkits.length > 0 && @@ -839,7 +851,7 @@ export function Node({ id, data }: NodeProps) { {toolkit.toolkitName === 'notice' ? (
{/* first row: icon + toolkit name */} -
+
{toolkit.toolkitStatus === AgentStatusValue.RUNNING ? ( + {toolkit.toolkitName}
{/* second row: method + message */} -
-
+
+
{toolkit.toolkitMethods ? toolkit.toolkitMethods .charAt(0) @@ -911,10 +923,10 @@ export function Node({ id, data }: NodeProps) { : ''}
{toolkit.message} @@ -925,7 +937,7 @@ export function Node({ id, data }: NodeProps) { {toolkit.message && ( @@ -948,9 +960,9 @@ export function Node({ id, data }: NodeProps) { onWheel={(e) => { e.stopPropagation(); }} - className="group relative my-2 flex w-full flex-col rounded-lg bg-surface-primary" + className="group my-2 rounded-lg bg-surface-primary relative flex w-full flex-col" > -
+
Completion Report
diff --git a/src/store/chatStore.ts b/src/store/chatStore.ts index 3c404ea8..919cbc9d 100644 --- a/src/store/chatStore.ts +++ b/src/store/chatStore.ts @@ -83,6 +83,165 @@ 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; + relativePath?: string; + 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, + relativePath?: string +): string { + if (source === 'camel_log') { + if (relativePath) { + return `camel_log/${relativePath}/${fileName}`; + } + return `camel_log/${fileName}`; + } + + if (source === 'user_attachment') { + return `user_attachment/${fileName}`; + } + + return `project_output/${fileName}`; +} + +export function collectTaskUploadFiles( + generatedFiles: GeneratedUploadFile[], + messages: Message[], + pendingAttaches: File[] = [], + taskId = 'unknown_task' +): UploadCandidate[] { + const uploadCandidates: Array< + Omit & { relativePath?: string } + > = []; + + for (const file of generatedFiles) { + if (!file?.path || !file?.name || file.isFolder) continue; + uploadCandidates.push({ + path: file.path, + name: file.name, + relativePath: file.relativePath, + 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)) { + const { relativePath, ...rest } = file; + uniqueCandidates.set(file.path, { + ...rest, + uploadName: buildUploadName( + file.name, + file.source, + taskId, + file.source === 'user_attachment' ? attachmentIndex++ : 0, + relativePath + ), + }); + } + } + + 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 +2388,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 - ); + 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 + ); - // 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 }; + 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; + const generatedSuccessCount = uploadResults.filter( + (result) => + result.success && result.source === 'project_output' + ).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); - } - - // 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 9f5d07cd..fcf240df 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,117 @@ 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/ba4462e1/agent.log', + name: 'agent.log', + relativePath: 'ba4462e1', + 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: 'project_output/report.md', + source: 'project_output', + }, + { + path: '/tmp/logs/ba4462e1/agent.log', + name: 'agent.log', + uploadName: 'camel_log/ba4462e1/agent.log', + source: 'camel_log', + }, + { + path: '/Users/test/Documents/brief.pdf', + name: 'brief.pdf', + uploadName: 'user_attachment/brief.pdf', + source: 'user_attachment', + }, + { + path: '/Users/test/Documents/followup.csv', + name: 'followup.csv', + uploadName: 'user_attachment/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/notes.txt', + source: 'user_attachment', + }, + ]); + }); + }); + describe('Task Creation', () => { it('should create a task with unique ID', () => { const { result } = renderHook(() => useChatStore());