diff --git a/backend/app/controller/file_controller.py b/backend/app/controller/file_controller.py index 03d9c4ea..8d7e6184 100644 --- a/backend/app/controller/file_controller.py +++ b/backend/app/controller/file_controller.py @@ -22,10 +22,19 @@ from pathlib import Path from typing import Annotated from urllib.parse import quote -from fastapi import APIRouter, File, Header, HTTPException, Query, UploadFile +from fastapi import ( + APIRouter, + Depends, + File, + Header, + HTTPException, + Query, + UploadFile, +) from fastapi.responses import FileResponse from starlette.concurrency import run_in_threadpool +from app.auth import require_local_control_principal from app.component.environment import env from app.utils.file_utils import list_files, resolve_under_base from app.utils.workspace_paths import runtime_owner_key, task_dir_name @@ -206,6 +215,87 @@ def _resolve_file_root( return _resolve_project_root(email, project_id, user_id) +def _task_change_roots(snapshot) -> list[tuple[Path, bool]]: + """Return task roots as ``(path, include_all)`` tuples. + + The output root is Run-owned, so every file there is an artifact even when + a copy operation preserves an old mtime. A distinct working directory may + contain user files; only files modified after Run admission are surfaced. + """ + output_root = Path(snapshot.task_output_root).expanduser().resolve() + working_root = Path(snapshot.working_directory).expanduser().resolve() + roots: list[tuple[Path, bool]] = [] + if output_root.is_dir(): + roots.append((output_root, True)) + if working_root != output_root and working_root.is_dir(): + roots.append((working_root, False)) + return roots + + +def _list_task_changed_files(snapshot, max_entries: int = 500) -> list[dict]: + """List files generated or modified by one Run without uploading them.""" + result: list[dict] = [] + seen_paths: set[str] = set() + remaining = max_entries + # Account for filesystems whose mtimes have one-second resolution. + modified_after = snapshot.task_start_time - 1.0 + + for root, include_all in _task_change_roots(snapshot): + if remaining <= 0: + break + paths = list_files( + str(root), + base=str(root), + max_entries=remaining, + modified_after=None if include_all else modified_after, + ) + for abs_path in paths: + try: + path = Path(abs_path).resolve() + if not path.is_file(): + continue + identity = str(path) + if identity in seen_paths: + continue + relative_path = path.relative_to(root).as_posix() + except (OSError, ValueError): + continue + + seen_paths.add(identity) + remaining -= 1 + result.append( + { + "filename": path.name, + "path": identity, + "relativePath": relative_path, + "changeType": "generated" if include_all else "changed", + } + ) + if remaining <= 0: + break + + return sorted(result, key=lambda item: item["relativePath"]) + + +@router.get( + "/files/changes", + dependencies=[Depends(require_local_control_principal)], +) +async def list_task_changed_files( + task_id: str = Query(..., description="Run/task ID"), + project_id: str = Query(..., description="Project ID"), + email: str = Query(..., description="User email"), + user_id: str | None = Query(None, description="Optional canonical user ID"), +) -> list[dict]: + """Return the Desktop-local preview index for one Run's changed files.""" + snapshot = get_workspace_resolver().store.get_snapshot( + email, task_id, user_id + ) + if snapshot is None or snapshot.project_id != project_id: + raise HTTPException(status_code=404, detail="Task workspace not found") + return await run_in_threadpool(_list_task_changed_files, snapshot) + + @router.get("/files") async def list_project_files( project_id: str = Query(..., description="Project ID"), diff --git a/backend/app/utils/file_utils.py b/backend/app/utils/file_utils.py index 032e3a49..4c5a4435 100644 --- a/backend/app/utils/file_utils.py +++ b/backend/app/utils/file_utils.py @@ -197,6 +197,7 @@ def list_files( skip_dirs: set[str] | None = None, skip_extensions: tuple[str, ...] = DEFAULT_SKIP_EXTENSIONS, skip_prefix: str = ".", + modified_after: float | None = None, stats: dict[str, float | int] | None = None, ) -> list[str]: """List files under dir_path with optional base confinement and filters. @@ -209,6 +210,9 @@ def list_files( skip_dirs (set[str] | None): Directory names to skip (default: DEFAULT_SKIP_DIRS). skip_extensions (tuple[str, ...]): File extensions to skip (default: DEFAULT_SKIP_EXTENSIONS). skip_prefix (str): Skip dirs/files whose name starts with this prefix. + modified_after (float | None): If set, only include files whose mtime + is at or after this Unix timestamp. Filtering happens before the + result limit so recent artifacts are not hidden by older files. Returns: List of real absolute file paths under dir_path (subject to filters and max_entries). @@ -256,6 +260,12 @@ def list_files( continue try: file_path = os.path.join(root, name) + if ( + modified_after is not None + and os.stat(file_path, follow_symlinks=False).st_mtime + < modified_after + ): + continue if os.path.islink(file_path): symlink_count += 1 realpath_started = time.perf_counter() diff --git a/backend/tests/app/controller/test_file_controller.py b/backend/tests/app/controller/test_file_controller.py index 3563e7ba..556cf257 100644 --- a/backend/tests/app/controller/test_file_controller.py +++ b/backend/tests/app/controller/test_file_controller.py @@ -12,6 +12,15 @@ # limitations under the License. # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import os +import time +from types import SimpleNamespace +from unittest.mock import MagicMock + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.auth.local_control import LOCAL_CONTROL_CAPABILITY_HEADER from app.controller import file_controller @@ -87,3 +96,85 @@ def test_resolve_project_root_without_user_id_stays_email_scoped( ) assert resolved == expected + + +def test_task_changes_include_all_outputs_but_only_recent_workspace_edits( + tmp_path, +): + output_root = tmp_path / "outputs" + working_root = tmp_path / "workspace" + output_root.mkdir() + working_root.mkdir() + started_at = time.time() + + copied_output = output_root / "copied-report.csv" + copied_output.write_text("output", encoding="utf-8") + old_workspace_file = working_root / "existing.md" + old_workspace_file.write_text("old", encoding="utf-8") + old_time = started_at - 60 + os.utime(copied_output, (old_time, old_time)) + os.utime(old_workspace_file, (old_time, old_time)) + # Old files must be filtered before the 500-item result bound. Otherwise + # a large selected folder can hide a recent artifact later in the walk. + for index in range(510): + old_file = working_root / f"old-{index:03d}.txt" + old_file.write_text("old", encoding="utf-8") + os.utime(old_file, (old_time, old_time)) + edited_workspace_file = working_root / "reports" / "final.md" + edited_workspace_file.parent.mkdir() + edited_workspace_file.write_text("new", encoding="utf-8") + + files = file_controller._list_task_changed_files( + SimpleNamespace( + task_output_root=str(output_root), + working_directory=str(working_root), + task_start_time=started_at, + ) + ) + + assert {item["path"] for item in files} == { + str(copied_output.resolve()), + str(edited_workspace_file.resolve()), + } + assert {item["relativePath"] for item in files} == { + "copied-report.csv", + "reports/final.md", + } + assert {item["path"]: item["changeType"] for item in files} == { + str(copied_output.resolve()): "generated", + str(edited_workspace_file.resolve()): "changed", + } + + +def test_task_changes_endpoint_requires_local_capability(monkeypatch, tmp_path): + monkeypatch.setenv("EIGENT_RUNTIME", "electron") + monkeypatch.setenv("EIGENT_LOCAL_CONTROL_CAPABILITY", "secret-1") + snapshot = SimpleNamespace( + project_id="project-1", + task_output_root=str(tmp_path), + working_directory=str(tmp_path), + task_start_time=time.time(), + ) + resolver = MagicMock() + resolver.store.get_snapshot.return_value = snapshot + monkeypatch.setattr( + file_controller, "get_workspace_resolver", lambda: resolver + ) + + app = FastAPI() + app.include_router(file_controller.router) + client = TestClient(app, client=("127.0.0.1", 50000)) + params = { + "task_id": "task-1", + "project_id": "project-1", + "email": "user@example.com", + } + + assert client.get("/files/changes", params=params).status_code == 401 + response = client.get( + "/files/changes", + params=params, + headers={LOCAL_CONTROL_CAPABILITY_HEADER: "secret-1"}, + ) + assert response.status_code == 200 + assert response.json() == [] diff --git a/src/components/ChatBox/UserQueryGroup.tsx b/src/components/ChatBox/UserQueryGroup.tsx index 36e2a552..0055c532 100644 --- a/src/components/ChatBox/UserQueryGroup.tsx +++ b/src/components/ChatBox/UserQueryGroup.tsx @@ -79,6 +79,63 @@ const AgentResultCard: React.FC<{ ); }; +/** Run-scoped artifact delta. Every item opens in the existing preview panel. */ +const ArtifactChangeList: React.FC<{ + files?: FileInfo[]; + onOpen: (file: FileInfo) => void; +}> = ({ files, onOpen }) => { + if (!files?.length) return null; + + return ( +
+
+ + Files changed + + {files.length} + +
+
+ {files.map((file, fileIndex) => { + const detail = file.relativePath || file.path || file.name; + const changeLabel = + file.artifactChange === 'generated' + ? 'Generated' + : file.artifactChange === 'changed' + ? 'Changed' + : file.type || 'File'; + return ( + + ); + })} +
+
+ ); +}; + /** Typewriter only for the agent message currently being produced (latest agent row while task is running). */ function shouldUseLiveAgentTypewriter( task: { @@ -429,35 +486,10 @@ export const UserQueryGroup: React.FC = ({ onTyping={() => {}} deferredFooter={ message.fileList?.length ? ( -
- {message.fileList.map( - (file: any, fileIndex: number) => ( - { - openFilePreview(file); - }} - className="flex w-[140px] cursor-pointer items-center gap-2 rounded-lg bg-ds-bg-neutral-default-default px-3 py-2 transition-colors hover:bg-ds-bg-neutral-default-hover" - > - -
-
- {file.name.split('.')[0]} -
-
- {file.type} -
-
-
- ) - )} -
+ ) : undefined } /> @@ -527,35 +559,10 @@ export const UserQueryGroup: React.FC = ({ transition={{ delay: 0.2 }} className="flex flex-col gap-4" > - {message.fileList && ( -
- {message.fileList.map((file: any, fileIndex: number) => ( - { - openFilePreview(file); - }} - className="flex w-[120px] cursor-pointer items-center gap-2 rounded-2xl bg-ds-bg-neutral-default-default px-2 py-1 transition-colors hover:bg-ds-bg-neutral-default-hover" - > - -
-
- {file.name.split('.')[0]} -
-
- {file.type} -
-
-
- ))} -
- )} + ); } diff --git a/src/store/chatStore.ts b/src/store/chatStore.ts index f2112066..27fd5076 100644 --- a/src/store/chatStore.ts +++ b/src/store/chatStore.ts @@ -14,6 +14,7 @@ import { fetchDelete, + fetchGet, fetchPost, fetchPut, getBaseURL, @@ -1033,6 +1034,81 @@ export function extractFinalOutputFileList( return fileInfos; } +type TaskArtifactChange = { + filename?: unknown; + path?: unknown; + relativePath?: unknown; + changeType?: unknown; +}; + +/** Convert Brain's capability-protected local artifact index into preview cards. */ +export function normalizeTaskArtifactFileList(value: unknown): FileInfo[] { + if (!Array.isArray(value)) return []; + + const files: FileInfo[] = []; + const seen = new Set(); + for (const candidate of value as TaskArtifactChange[]) { + const path = + typeof candidate.path === 'string' + ? normalizeOutputPath(candidate.path) + : ''; + const name = + typeof candidate.filename === 'string' + ? candidate.filename.trim() + : getOutputFileNameFromPath(path); + const relativePath = + typeof candidate.relativePath === 'string' + ? normalizeOutputPath(candidate.relativePath) + : undefined; + const type = getFileTypeFromName(name); + const identity = (relativePath || path).toLowerCase(); + if (!path || !name || !identity || seen.has(identity)) continue; + + seen.add(identity); + files.push({ + name, + type, + path, + relativePath, + icon: FileText, + isRemote: false, + artifactChange: + candidate.changeType === 'generated' ? 'generated' : 'changed', + }); + } + return files; +} + +async function loadTaskArtifactFileList({ + taskId, + projectId, + email, + userId, +}: { + taskId: string; + projectId?: string; + email?: string; + userId?: string | number | null; +}): Promise { + // The index contains absolute local paths and is intentionally Desktop-only. + if (!getHostIpcRenderer()?.invoke || !projectId || !email) return []; + + try { + const changes = await fetchGet('/files/changes', { + task_id: taskId, + project_id: projectId, + email, + ...(userId ? { user_id: userId } : {}), + }); + return normalizeTaskArtifactFileList(changes); + } catch (error) { + // Older Brain versions and cloud-only history do not expose the local + // artifact index. Existing WRITE_FILE/final-answer projections remain. + console.info(`[Artifacts] No local changes for task ${taskId}`, error); + return []; + } +} + function getFileInfoIdentities(file: FileInfo): string[] { return [ file.relativePath, @@ -1082,6 +1158,15 @@ export function mergeFileInfoLists( ...file, }; mergedIdentities[existingIndex] = identities; + return; + } + + if (file.artifactChange && !existingFile.artifactChange) { + merged[existingIndex] = { + ...existingFile, + artifactChange: file.artifactChange, + relativePath: existingFile.relativePath || file.relativePath, + }; } }); @@ -4152,6 +4237,12 @@ const chatStore = (initial?: Partial) => const outputProjectId = project_id || projectStore.activeProjectId || undefined; + const taskArtifactFileList = await loadTaskArtifactFileList({ + taskId: currentTaskId, + projectId: outputProjectId, + email: email || undefined, + userId: user_id, + }); const outputBaseURL = await getBaseURL().catch(() => ''); const finalOutputFileList = extractFinalOutputFileList( endMessage, @@ -4160,7 +4251,7 @@ const chatStore = (initial?: Partial) => outputBaseURL || undefined ); const mergedFileList = mergeFileInfoLists( - fileList, + mergeFileInfoLists(fileList, taskArtifactFileList), finalOutputFileList ); diff --git a/src/types/chatbox.d.ts b/src/types/chatbox.d.ts index c73def6d..7c67712c 100644 --- a/src/types/chatbox.d.ts +++ b/src/types/chatbox.d.ts @@ -34,6 +34,7 @@ declare global { isFolder?: boolean; isRemote?: boolean; relativePath?: string; + artifactChange?: 'generated' | 'changed'; } interface ProjectInfo { diff --git a/test/unit/store/chatStore.test.ts b/test/unit/store/chatStore.test.ts index ed940771..04a69064 100644 --- a/test/unit/store/chatStore.test.ts +++ b/test/unit/store/chatStore.test.ts @@ -31,6 +31,7 @@ vi.mock('@/api/http', async () => { const getBaseURL = vi.fn(() => Promise.resolve('http://localhost:8000')); return { + fetchGet: vi.fn(), fetchPost: vi.fn(), fetchPut: vi.fn(), getBaseURL, @@ -135,6 +136,7 @@ import { extractFinalOutputFileList, getCloudModelPlatform, mergeFileInfoLists, + normalizeTaskArtifactFileList, resolveConfirmedUserMessageContent, resolveEndMessageText, useChatStore, @@ -255,6 +257,78 @@ describe('ChatStore - Core Functionality', () => { }); describe('Final output file extraction', () => { + it('normalizes the local artifact change index into previewable files', () => { + const files = normalizeTaskArtifactFileList([ + { + filename: 'final_report.md', + path: '/Users/test/project/reports/final_report.md', + relativePath: 'reports/final_report.md', + changeType: 'changed', + }, + { + filename: 'chart.png', + path: '/Users/test/outputs/chart.png', + relativePath: 'chart.png', + changeType: 'generated', + }, + ]); + + expect(files).toMatchObject([ + { + name: 'final_report.md', + type: 'md', + relativePath: 'reports/final_report.md', + artifactChange: 'changed', + isRemote: false, + }, + { + name: 'chart.png', + type: 'png', + artifactChange: 'generated', + isRemote: false, + }, + ]); + }); + + it('drops malformed and duplicate artifact index rows', () => { + expect( + normalizeTaskArtifactFileList([ + { filename: 'a.csv', path: '/tmp/a.csv', relativePath: 'a.csv' }, + { filename: 'a.csv', path: '/tmp/other.csv', relativePath: 'a.csv' }, + { filename: 'missing.txt' }, + ]) + ).toHaveLength(1); + }); + + it('keeps an existing preview path while adding artifact change metadata', () => { + const [file] = mergeFileInfoLists( + [ + { + name: 'report.md', + path: 'http://localhost/files/stream?path=report.md', + type: 'md', + isRemote: true, + }, + ], + [ + { + name: 'report.md', + path: '/Users/test/project/report.md', + type: 'md', + relativePath: 'report.md', + artifactChange: 'changed', + }, + ] + ); + + expect(file).toMatchObject({ + path: 'http://localhost/files/stream?path=report.md', + relativePath: 'report.md', + artifactChange: 'changed', + isRemote: true, + }); + }); + it('extracts sandbox paths without treating the scheme suffix as a drive', () => { const files = extractFinalOutputFileList( 'Created [CSV](sandbox:/Users/test/eigent/space_123/report.csv).'