Merge branch 'main' into edge-case-ui-fix

This commit is contained in:
Tong Chen 2026-04-12 17:46:09 +08:00 committed by GitHub
commit 92bd3ac3bf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 498 additions and 201 deletions

View file

@ -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"],

View file

@ -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,

View file

@ -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

View file

@ -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)) {

View file

@ -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<typeof setInterval> | 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 (92249300) 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,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 261 KiB

After

Width:  |  Height:  |  Size: 260 KiB

Before After
Before After

View file

@ -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'
}`}
>
<div className="flex w-[342px] shrink-0 flex-col border-y-0 border-l-0 border-r-[0.5px] border-solid border-border-secondary">
<div className="flex items-center justify-between gap-sm px-3 pb-1 pt-2">
<div className="flex items-center justify-between gap-md">
<div className="border-border-secondary flex w-[342px] shrink-0 flex-col border-y-0 border-r-[0.5px] border-l-0 border-solid">
<div className="gap-sm px-3 pb-1 pt-2 flex items-center justify-between">
<div className="gap-md flex items-center justify-between">
<div
className={`text-base font-bold leading-relaxed ${
agentMap[data.type]?.textColor
@ -432,7 +444,7 @@ export function Node({ id, data }: NodeProps) {
{agentMap[data.type]?.name || data.agent?.name}
</div>
</div>
<div className="flex items-center gap-xs">
<div className="gap-xs flex items-center">
<Button onClick={handleShowLog} variant="ghost" size="icon">
{isExpanded ? <SquareChevronLeft /> : <SquareCode />}
</Button>
@ -448,7 +460,7 @@ export function Node({ id, data }: NodeProps) {
<Ellipsis />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[98px] rounded-[12px] border border-solid border-dropdown-border bg-dropdown-bg p-sm">
<PopoverContent className="border-dropdown-border bg-dropdown-bg p-sm w-[98px] rounded-[12px] border border-solid">
<div className="space-y-1">
<PopoverClose asChild>
<AddWorker
@ -460,7 +472,7 @@ export function Node({ id, data }: NodeProps) {
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-2"
className="gap-2 w-full justify-start"
onClick={(e) => {
e.stopPropagation();
const newWorkerList = workerList.filter(
@ -484,7 +496,7 @@ export function Node({ id, data }: NodeProps) {
</div>
<div
ref={toolsRef}
className="mb-sm flex min-h-4 flex-shrink-0 flex-wrap px-3 text-xs font-normal leading-tight text-text-label"
className="mb-sm min-h-4 px-3 text-xs font-normal leading-tight text-text-label flex flex-shrink-0 flex-wrap"
>
{/* {JSON.stringify(data.agent)} */}
{toolkitLabels.map((toolkit, index) => (
@ -494,7 +506,7 @@ export function Node({ id, data }: NodeProps) {
))}
</div>
<div
className="mb-2 max-h-[180px] px-3"
className="mb-2 px-3 max-h-[180px]"
onClick={() => {
chatStore.setActiveWorkspace(
chatStore.activeTaskId as string,
@ -506,15 +518,15 @@ export function Node({ id, data }: NodeProps) {
>
{browserImages.length > 0 && (
<div
className={`grid h-[180px] w-full gap-1 overflow-hidden ${browserImageGridClass}`}
className={`gap-1 grid h-[180px] w-full overflow-hidden ${browserImageGridClass}`}
>
{browserImages.map((img, index) => (
<div
key={`${img.img}-${index}`}
className="relative h-full w-full overflow-hidden rounded-lg"
className="rounded-lg relative h-full w-full overflow-hidden"
>
<img
className="absolute left-0 top-0 h-[250%] w-[250%] origin-top-left scale-[0.4] object-cover"
className="left-0 top-0 absolute h-[250%] w-[250%] origin-top-left scale-[0.4] object-cover"
src={img.img}
alt={data.type}
/>
@ -524,7 +536,7 @@ export function Node({ id, data }: NodeProps) {
(_, index) => (
<div
key={`browser-placeholder-${index}`}
className="h-full w-full rounded-sm bg-surface-primary"
className="rounded-sm bg-surface-primary h-full w-full"
/>
)
)}
@ -533,8 +545,8 @@ export function Node({ id, data }: NodeProps) {
{data.type === 'document_agent' &&
data?.agent?.tasks &&
data.agent.tasks.length > 0 && (
<div className="relative h-[180px] w-full overflow-hidden rounded-sm">
<div className="absolute left-0 top-0 h-[500px] w-[900px] origin-top-left scale-[0.36]">
<div className="rounded-sm relative h-[180px] w-full overflow-hidden">
<div className="left-0 top-0 absolute h-[500px] w-[900px] origin-top-left scale-[0.36]">
<Folder data={data.agent as Agent} />
</div>
</div>
@ -542,14 +554,14 @@ export function Node({ id, data }: NodeProps) {
{data.type === 'developer_agent' && terminalTasks.length > 0 && (
<div
className={`grid h-[180px] w-full gap-1 overflow-hidden ${terminalGridClass}`}
className={`gap-1 grid h-[180px] w-full overflow-hidden ${terminalGridClass}`}
>
{terminalTasks.map((task) => (
<div
key={task.id}
className="relative h-full w-full overflow-hidden rounded-lg object-cover"
className="rounded-lg relative h-full w-full overflow-hidden object-cover"
>
<div className="absolute left-0 top-0 h-[250%] w-[250%] origin-top-left scale-[0.4]">
<div className="left-0 top-0 absolute h-[250%] w-[250%] origin-top-left scale-[0.4]">
<Terminal content={task.terminal} />
</div>
</div>
@ -558,7 +570,7 @@ export function Node({ id, data }: NodeProps) {
(_, index) => (
<div
key={`terminal-placeholder-${index}`}
className="h-full w-full rounded-lg bg-surface-primary"
className="rounded-lg bg-surface-primary h-full w-full"
/>
)
)}
@ -566,7 +578,7 @@ export function Node({ id, data }: NodeProps) {
)}
</div>
{data.agent?.tasks && data.agent?.tasks.length > 0 && (
<div className="flex flex-col items-start justify-between gap-1 border-[0px] border-t border-solid border-task-border-default px-3 py-sm">
<div className="gap-1 border-task-border-default px-3 py-sm flex flex-col items-start justify-between border-[0px] border-t border-solid">
{/* <div className="font-bold leading-tight text-xs">Subtasks</div> */}
<div className="flex flex-1 justify-end">
<TaskState
@ -618,7 +630,7 @@ export function Node({ id, data }: NodeProps) {
onWheel={(e) => {
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`}
>
<div className="flex items-center gap-sm">
<div className="gap-sm flex items-center">
<div className="text-xs font-bold leading-13 text-text-body">
No. {getTaskId(task.id)}
</div>
{task.reAssignTo ? (
<div className="rounded-lg bg-tag-fill-document px-1 py-0.5 text-xs font-bold leading-none text-text-warning">
<div className="rounded-lg bg-tag-fill-document px-1 py-0.5 text-xs font-bold text-text-warning leading-none">
Reassigned to {task.reAssignTo}
</div>
) : (
@ -772,11 +784,11 @@ export function Node({ id, data }: NodeProps) {
<div>{task.content}</div>
</div>
{task?.status === TaskStatus.RUNNING && (
<div className="duration-400 mt-xs flex items-center gap-2 animate-in fade-in-0 slide-in-from-bottom-2">
<div className="mt-xs gap-2 animate-in fade-in-0 slide-in-from-bottom-2 flex items-center duration-400">
{/* active toolkit */}
{lastActiveToolkit?.toolkitStatus ===
AgentStatusValue.RUNNING && (
<div className="flex min-w-0 flex-1 items-center justify-start gap-sm duration-300 animate-in fade-in-0 slide-in-from-right-2">
<div className="min-w-0 gap-sm animate-in fade-in-0 slide-in-from-right-2 flex flex-1 items-center justify-start duration-300">
{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`}
>
<ShinyText
text={task.toolkits?.[0].toolkitName}
className="pointer-events-auto w-full select-text overflow-hidden text-ellipsis whitespace-nowrap text-xs font-bold leading-17 text-text-primary"
className="text-xs font-bold leading-17 text-text-primary pointer-events-auto w-full overflow-hidden text-ellipsis whitespace-nowrap select-text"
/>
</div>
</div>
@ -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"
>
<div
ref={logRef}
onWheel={(e) => {
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"
>
<AnimatePresence mode="wait">
{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' ? (
<div
key={`notice-${index}`}
className="flex w-full flex-col gap-sm px-2 py-1"
className="gap-sm px-2 py-1 flex w-full flex-col"
>
<MarkDown
content={toolkit?.message}
@ -877,10 +889,10 @@ export function Node({ id, data }: NodeProps) {
toolkit.message;
}
}}
className="flex flex-col items-start justify-center gap-1 rounded-lg bg-log-default p-1 px-2 transition-all duration-300 hover:opacity-50"
className="gap-1 rounded-lg bg-log-default p-1 px-2 flex flex-col items-start justify-center transition-all duration-300 hover:opacity-50"
>
{/* first row: icon + toolkit name */}
<div className="flex w-full items-center justify-start gap-sm">
<div className="gap-sm flex w-full items-center justify-start">
{toolkit.toolkitStatus ===
AgentStatusValue.RUNNING ? (
<LoaderCircle
@ -896,13 +908,13 @@ export function Node({ id, data }: NodeProps) {
) : (
getToolkitIcon(toolkit.toolkitName)
)}
<span className="flex items-center gap-sm text-nowrap text-label-xs font-bold text-text-primary">
<span className="gap-sm text-label-xs font-bold text-text-primary flex items-center text-nowrap">
{toolkit.toolkitName}
</span>
</div>
{/* second row: method + message */}
<div className="pointer-events-auto flex w-full select-text items-start justify-center gap-sm overflow-hidden pl-6">
<div className="text-nowrap text-label-xs font-bold text-text-primary">
<div className="gap-sm pl-6 pointer-events-auto flex w-full items-start justify-center overflow-hidden select-text">
<div className="text-label-xs font-bold text-text-primary text-nowrap">
{toolkit.toolkitMethods
? toolkit.toolkitMethods
.charAt(0)
@ -911,10 +923,10 @@ export function Node({ id, data }: NodeProps) {
: ''}
</div>
<div
className={`max-w-full flex-1 truncate text-label-xs font-normal text-text-primary ${
className={`text-label-xs font-normal text-text-primary max-w-full flex-1 truncate ${
data.isEditMode
? 'overflow-hidden'
: 'overflow-hidden truncate'
: 'truncate overflow-hidden'
}`}
>
{toolkit.message}
@ -925,7 +937,7 @@ export function Node({ id, data }: NodeProps) {
{toolkit.message && (
<TooltipContent
align="start"
className="scrollbar pointer-events-auto !fixed left-6 z-[9999] max-h-[200px] w-max max-w-[296px] select-text overflow-y-auto text-wrap break-words rounded-lg border border-solid border-task-border-default bg-surface-tertiary p-2 text-label-xs"
className="scrollbar left-6 rounded-lg border-task-border-default bg-surface-tertiary p-2 text-label-xs pointer-events-auto !fixed z-[9999] max-h-[200px] w-max max-w-[296px] overflow-y-auto border border-solid text-wrap break-words select-text"
side="bottom"
sideOffset={4}
>
@ -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"
>
<div className="sticky top-0 z-10 flex items-center justify-between rounded-lg bg-surface-primary py-2 pl-2 pr-2">
<div className="top-0 rounded-lg bg-surface-primary py-2 pl-2 pr-2 sticky z-10 flex items-center justify-between">
<div className="text-label-sm font-bold text-text-primary">
Completion Report
</div>

View file

@ -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<UploadFileSource, 'user_attachment'>;
}
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<UploadCandidate, 'uploadName'> & { 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<string, UploadCandidate>();
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<UploadOutcome[]> {
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<ChatStore>) =>
)
);
// 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
);
}
}
}

View file

@ -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());